Skip to content

add qp support for highs #3531

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
39f4392
add qp support for highs
quantresearch1 Mar 20, 2025
823d882
wrong result in test qp
quantresearch1 Mar 25, 2025
2621b3e
pass qp tests
quantresearch1 Mar 25, 2025
0815a03
rename to quadratic_coefs for consistency with gurobi
quantresearch1 Mar 25, 2025
9bba7d0
rename to quadratic_coefs for consistency with gurobi 2
quantresearch1 Mar 25, 2025
1b42058
introduce _MutableObjective
quantresearch1 Mar 25, 2025
c659ccc
fix persistency delta bug
quantresearch1 Mar 25, 2025
38a0a85
or polynomial degree > 2
quantresearch1 Mar 25, 2025
b289dac
Merge branch 'main' into feature/3381_quadratic_obj_highs
quantresearch1 Mar 25, 2025
6c4b7b1
remove changes to legacy appsi_highs
quantresearch1 Mar 25, 2025
891483e
add back mutable vs non mutable handling
quantresearch1 Mar 26, 2025
6d6c2d2
fix more unit tests
quantresearch1 Mar 27, 2025
4a92411
Merge branch 'main' into feature/3381_quadratic_obj_highs
quantresearch1 Mar 28, 2025
8902562
Merge branch 'main' into feature/3381_quadratic_obj_highs
mrmundt Apr 8, 2025
720a5ed
Merge branch 'main' into feature/3381_quadratic_obj_highs
mrmundt Apr 9, 2025
3d71b39
standardize pyomo.environ
quantresearch1 Apr 11, 2025
04eb847
rename q_value to hessian_value
quantresearch1 Apr 14, 2025
9234f32
Merge branch 'main' into feature/3381_quadratic_obj_highs
quantresearch1 Apr 14, 2025
f9fa55a
Merge branch 'main' into feature/3381_quadratic_obj_highs
quantresearch1 May 7, 2025
249fee0
Merge branch 'main' into feature/3381_quadratic_obj_highs
quantresearch1 May 14, 2025
203c6a9
Merge branch 'main' into feature/3381_quadratic_obj_highs
mrmundt May 20, 2025
3f7c74f
Merge branch 'main' into feature/3381_quadratic_obj_highs
quantresearch1 Jun 24, 2025
c1422b7
move qp tests to test_solvers
quantresearch1 Jun 24, 2025
be02bba
delete variable x in test
quantresearch1 Jun 24, 2025
5823583
addressing michael's comments MutableQuadraticCoefficient
quantresearch1 Jun 24, 2025
3fb1476
remove commented bit in test
quantresearch1 Jun 24, 2025
4489372
Merge branch 'main' into feature/3381_quadratic_obj_highs
mrmundt Jun 30, 2025
83784fc
Merge branch 'main' into feature/3381_quadratic_obj_highs
mrmundt Jul 1, 2025
a5317fa
add back TerminationCondition
quantresearch1 Jul 3, 2025
a1410b8
Merge branch 'main' into feature/3381_quadratic_obj_highs
quantresearch1 Jul 3, 2025
c4b08aa
update mutable qp test
michaelbynum Jul 29, 2025
de53637
run black
michaelbynum Jul 29, 2025
0d45025
typo
michaelbynum Jul 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from pyomo.common.log import LoggingIntercept
from pyomo.common.tee import capture_output
from pyomo.contrib.appsi.solvers.highs import Highs
from pyomo.contrib.appsi.base import TerminationCondition


opt = Highs()
Expand Down
166 changes: 152 additions & 14 deletions pyomo/contrib/solver/solvers/highs.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,121 @@ def update(self):
self.highs.changeColCost(col_ndx, value(self.expr))


class _MutableQuadraticCoefficient:
def __init__(self, expr, row_idx, col_idx):
self.expr = expr
self.row_idx = row_idx
self.col_idx = col_idx


class _MutableObjective:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emma58 I have tried to emulate the new gurobi_persistent and use a similar objective data structure. However, highs quadratic objective handling is too different so I could not make it fit as nicely as I would have hoped. To avoid always calling passHessian at each update I first check against the last set of coefficients, let me know if you think there's a better way

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a better way. I think this is okay.

def __init__(self, highs, constant, linear_coefs, quadratic_coefs):
self.highs = highs
self.constant = constant
self.linear_coefs = linear_coefs
self.quadratic_coefs = quadratic_coefs
self.last_quadratic_coef_values = [value(i.expr) for i in self.quadratic_coefs]
# Store the quadratic coefficients in dictionary format
self.quad_coef_dict = {}
self._initialize_quad_coef_dict()
# Flag to force first update of quadratic coefficients
self._first_update = True

def _initialize_quad_coef_dict(self):
for coef in self.quadratic_coefs:
v1_ndx = coef.row_idx
v2_ndx = coef.col_idx
# Ensure we're storing the lower triangular part
row = max(v1_ndx, v2_ndx)
col = min(v1_ndx, v2_ndx)

coef_val = value(coef.expr)
# Adjust for diagonal elements
if v1_ndx == v2_ndx:
coef_val *= 2.0

self.quad_coef_dict[(row, col)] = coef_val

def update(self):
"""
Update the quadratic objective expression.
"""
needs_quadratic_update = self._first_update

self.constant.update()
for coef in self.linear_coefs:
coef.update()

for ndx, coef in enumerate(self.quadratic_coefs):
current_val = value(coef.expr)
if current_val != self.last_quadratic_coef_values[ndx]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the set of quadratic variables changes (e.g., the objective changes from x**2 + y**2 to x**2 + z**2) will ndx be in self.last_quadratic_coef_values?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, this should be fine because _set_objective should have been called when the objective changed. Can you just make sure there is a test that validates this? There might already be one. I don't remember.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed that logic. The test that deletes x1 should cover this now.

needs_quadratic_update = True

v1_ndx = coef.row_idx
v2_ndx = coef.col_idx
row = max(v1_ndx, v2_ndx)
col = min(v1_ndx, v2_ndx)

# Adjust the diagonal to match Highs' expected format
if v1_ndx == v2_ndx:
current_val *= 2.0

self.quad_coef_dict[(row, col)] = current_val

self.last_quadratic_coef_values[ndx] = current_val

# If anything changed, rebuild and pass the Hessian
if needs_quadratic_update:
self._build_and_pass_hessian()
self._first_update = False

def _build_and_pass_hessian(self):
"""Build and pass the Hessian to HiGHS in CSC format"""
if not self.quad_coef_dict:
return

dim = self.highs.getNumCol()

# Build CSC format for the lower triangular part
hessian_value = []
hessian_index = []
hessian_start = [0] * dim

sorted_entries = sorted(
self.quad_coef_dict.items(), key=lambda x: (x[0][1], x[0][0])
)

last_col = -1
for (row, col), val in sorted_entries:
while col > last_col:
last_col += 1
if last_col < dim:
hessian_start[last_col] = len(hessian_value)

# Add the entry
hessian_index.append(row)
hessian_value.append(val)

while last_col < dim - 1:
last_col += 1
hessian_start[last_col] = len(hessian_value)

nnz = len(hessian_value)
status = self.highs.passHessian(
dim,
nnz,
highspy.HessianFormat.kTriangular,
np.array(hessian_start, dtype=np.int32),
np.array(hessian_index, dtype=np.int32),
np.array(hessian_value, dtype=np.double),
)

if status != highspy.HighsStatus.kOk:
logger.warning(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be an exception. Thoughts?

Copy link
Contributor Author

@quantresearch1 quantresearch1 Jun 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking a bit more about it, pretty much every single function in highspy returns a HighsStatus (https://github.com/ERGO-Code/HiGHS/blob/364c83a51e44ba6c27def9c8fc1a49b1daf5ad5c/highs/Highs.h#L962),
However, if I look at the current usage in this file, we don't capture the status when calling changeRowBounds, changeCoeff, changeColCost, etc... Just for the sake of consistency, there's an argument to remove this check

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I guess I don't feel strongly one way or the other.

f"HiGHS returned non-OK status when passing Hessian: {status}"
)


class _MutableObjectiveOffset:
def __init__(self, expr, highs):
self.expr = expr
Expand Down Expand Up @@ -141,7 +256,6 @@ def __init__(self, **kwds):
self._solver_con_to_pyomo_con_map = {}
self._mutable_helpers = {}
self._mutable_bounds = {}
self._objective_helpers = []
self._last_results_object: Optional[Results] = None
self._sol = None

Expand Down Expand Up @@ -472,13 +586,14 @@ def update_parameters(self):
self._sol = None
if self._last_results_object is not None:
self._last_results_object.solution_loader.invalidate()

for con, helpers in self._mutable_helpers.items():
for helper in helpers:
helper.update()
for k, (v, helper) in self._mutable_bounds.items():
helper.update()
for helper in self._objective_helpers:
helper.update()

self._mutable_objective.update()

def _set_objective(self, obj):
self._sol = None
Expand All @@ -487,10 +602,14 @@ def _set_objective(self, obj):
n = len(self._pyomo_var_to_solver_var_map)
indices = np.arange(n)
costs = np.zeros(n, dtype=np.double)
self._objective_helpers = []

# Initialize empty lists for all coefficient types
mutable_linear_coefficients = []
mutable_quadratic_coefficients = []

if obj is None:
sense = highspy.ObjSense.kMinimize
self._solver_model.changeObjectiveOffset(0)
mutable_constant = _MutableObjectiveOffset(expr=0, highs=self._solver_model)
else:
if obj.sense == minimize:
sense = highspy.ObjSense.kMinimize
Expand All @@ -500,9 +619,9 @@ def _set_objective(self, obj):
raise ValueError(f'Objective sense is not recognized: {obj.sense}')

repn = generate_standard_repn(
obj.expr, quadratic=False, compute_values=False
obj.expr, quadratic=True, compute_values=False
)
if repn.nonlinear_expr is not None:
if repn.nonlinear_expr is not None or repn.polynomial_degree() > 2:
raise IncompatibleModelError(
f'Highs interface does not support expressions of degree {repn.polynomial_degree()}'
)
Expand All @@ -518,17 +637,36 @@ def _set_objective(self, obj):
expr=coef,
highs=self._solver_model,
)
self._objective_helpers.append(mutable_objective_coef)
mutable_linear_coefficients.append(mutable_objective_coef)

self._solver_model.changeObjectiveOffset(value(repn.constant))
if not is_constant(repn.constant):
mutable_objective_offset = _MutableObjectiveOffset(
expr=repn.constant, highs=self._solver_model
)
self._objective_helpers.append(mutable_objective_offset)
mutable_constant = _MutableObjectiveOffset(
expr=repn.constant, highs=self._solver_model
)

if repn.quadratic_vars and len(repn.quadratic_vars) > 0:
for ndx, (v1, v2) in enumerate(repn.quadratic_vars):
v1_id = id(v1)
v2_id = id(v2)
v1_ndx = self._pyomo_var_to_solver_var_map[v1_id]
v2_ndx = self._pyomo_var_to_solver_var_map[v2_id]

coef = repn.quadratic_coefs[ndx]

mutable_quadratic_coefficients.append(
_MutableQuadraticCoefficient(
expr=coef, row_idx=v1_ndx, col_idx=v2_ndx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you should store the variable indices here. If a variable gets removed from the model, these indices will become incorrect. Note how the _MutableObjectiveCoefficient stores the variable id and the _pyomo_var_to_solver_var_map. That way, it can get the correct index at the time update is called.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize this is very convoluted, but I'm not sure how to avoid it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to add a test for this scenario.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes you're right, let me add one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed the code such that the dictionary index is the id of variable 1 and 2, I think that should address your concern. These indices only get turned to row/col indices when we build the hessian. I am not sold on the Coefficient class having the map itself, it's definitely a model property in my mind.

)
)

self._solver_model.changeObjectiveSense(sense)
self._solver_model.changeColsCost(n, indices, costs)
self._mutable_objective = _MutableObjective(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea here is we collect all the mutable objective terms and update them once through _mutable_objective.update()

self._solver_model,
mutable_constant,
mutable_linear_coefficients,
mutable_quadratic_coefficients,
)
self._mutable_objective.update()

def _postsolve(self):
config = self._active_config
Expand Down
158 changes: 158 additions & 0 deletions pyomo/contrib/solver/tests/solvers/test_highs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# ___________________________________________________________________________
#
# Pyomo: Python Optimization Modeling Objects
# Copyright (c) 2008-2025
# National Technology and Engineering Solutions of Sandia, LLC
# Under the terms of Contract DE-NA0003525 with National Technology and
# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
# rights in this software.
# This software is distributed under the 3-clause BSD License.
# ___________________________________________________________________________

import pyomo.common.unittest as unittest
import pyomo.environ as pyo

from pyomo.contrib.solver.solvers.highs import Highs

opt = Highs()
if not opt.available():
raise unittest.SkipTest


class TestBugs(unittest.TestCase):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mrmundt I have copied the test cases that are passing from the legacy appsi_highs. I am not sure if you wanted to leave the test cases for a later stage, let me know if I should remove them (I do need the new qp test cases though)

def test_mutable_params_with_remove_cons(self):
m = pyo.ConcreteModel()
m.x = pyo.Var(bounds=(-10, 10))
m.y = pyo.Var()

m.p1 = pyo.Param(mutable=True)
m.p2 = pyo.Param(mutable=True)

m.obj = pyo.Objective(expr=m.y)
m.c1 = pyo.Constraint(expr=m.y >= m.x + m.p1)
m.c2 = pyo.Constraint(expr=m.y >= -m.x + m.p2)

m.p1.value = 1
m.p2.value = 1

opt = Highs()
res = opt.solve(m)
self.assertAlmostEqual(res.objective_bound, 1)

del m.c1
m.p2.value = 2
res = opt.solve(m)
self.assertAlmostEqual(res.objective_bound, -8)

def test_mutable_params_with_remove_vars(self):
m = pyo.ConcreteModel()
m.x = pyo.Var()
m.y = pyo.Var()

m.p1 = pyo.Param(mutable=True)
m.p2 = pyo.Param(mutable=True)

m.y.setlb(m.p1)
m.y.setub(m.p2)

m.obj = pyo.Objective(expr=m.y)
m.c1 = pyo.Constraint(expr=m.y >= m.x + 1)
m.c2 = pyo.Constraint(expr=m.y >= -m.x + 1)

m.p1.value = -10
m.p2.value = 10

opt = Highs()
res = opt.solve(m)
self.assertAlmostEqual(res.objective_bound, 1)

del m.c1
del m.c2
m.p1.value = -9
m.p2.value = 9
res = opt.solve(m)
self.assertAlmostEqual(res.objective_bound, -9)

def test_fix_and_unfix(self):
# Tests issue https://github.com/Pyomo/pyomo/issues/3127

m = pyo.ConcreteModel()
m.x = pyo.Var(domain=pyo.Binary)
m.y = pyo.Var(domain=pyo.Binary)
m.fx = pyo.Var(domain=pyo.NonNegativeReals)
m.fy = pyo.Var(domain=pyo.NonNegativeReals)
m.c1 = pyo.Constraint(expr=m.fx <= m.x)
m.c2 = pyo.Constraint(expr=m.fy <= m.y)
m.c3 = pyo.Constraint(expr=m.x + m.y <= 1)

m.obj = pyo.Objective(expr=m.fx * 0.5 + m.fy * 0.4, sense=pyo.maximize)

opt = Highs()

# solution 1 has m.x == 1 and m.y == 0
r = opt.solve(m)
self.assertAlmostEqual(m.fx.value, 1, places=5)
self.assertAlmostEqual(m.fy.value, 0, places=5)
self.assertAlmostEqual(r.objective_bound, 0.5, places=5)

# solution 2 has m.x == 0 and m.y == 1
m.y.fix(1)
r = opt.solve(m)
self.assertAlmostEqual(m.fx.value, 0, places=5)
self.assertAlmostEqual(m.fy.value, 1, places=5)
self.assertAlmostEqual(r.objective_bound, 0.4, places=5)

# solution 3 should be equal solution 1
m.y.unfix()
m.x.fix(1)
r = opt.solve(m)
self.assertAlmostEqual(m.fx.value, 1, places=5)
self.assertAlmostEqual(m.fy.value, 0, places=5)
self.assertAlmostEqual(r.objective_bound, 0.5, places=5)

def test_qp1(self):
# test issue #3381
m = pyo.ConcreteModel()

m.x1 = pyo.Var(name='x1', domain=pyo.Reals)
m.x2 = pyo.Var(name='x2', domain=pyo.Reals)

# Quadratic Objective function
m.obj = pyo.Objective(expr=m.x1 * m.x1 + m.x2 * m.x2, sense=pyo.minimize)

m.con1 = pyo.Constraint(expr=m.x1 >= 1)
m.con2 = pyo.Constraint(expr=m.x2 >= 1)

results = opt.solve(m)
self.assertAlmostEqual(m.x1.value, 1, places=5)
self.assertAlmostEqual(m.x2.value, 1, places=5)
self.assertEqual(results.objective_bound, 2)

def test_qp2(self):
# test issue #3381
m = pyo.ConcreteModel()

m.x1 = pyo.Var(name='x1', domain=pyo.Reals)
m.x2 = pyo.Var(name='x2', domain=pyo.Reals)

m.p = pyo.Param(initialize=1, mutable=True)

m.obj = pyo.Objective(
expr=m.p * m.x1 * m.x1 + m.x2 * m.x2 - m.x1 * m.x2, sense=pyo.minimize
)

m.con1 = pyo.Constraint(expr=m.x1 >= 1)
m.con2 = pyo.Constraint(expr=m.x2 >= 1)

opt.set_instance(m)
results = opt.solve(m)
self.assertAlmostEqual(m.x1.value, 1, places=5)
self.assertAlmostEqual(m.x2.value, 1, places=5)
self.assertEqual(results.objective_bound, 1)

m.p.value = 2.0
opt.update_parameters()
results = opt.solve(m)
self.assertAlmostEqual(m.x1.value, 1, places=5)
self.assertAlmostEqual(m.x2.value, 1, places=5)
self.assertEqual(results.objective_bound, 2)
Loading