-
Notifications
You must be signed in to change notification settings - Fork 548
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
add qp support for highs #3531
Changes from 21 commits
39f4392
823d882
2621b3e
0815a03
9bba7d0
1b42058
c659ccc
38a0a85
b289dac
6c4b7b1
891483e
6d6c2d2
4a92411
8902562
720a5ed
3d71b39
04eb847
9234f32
f9fa55a
249fee0
203c6a9
3f7c74f
c1422b7
be02bba
5823583
3fb1476
4489372
83784fc
a5317fa
a1410b8
c4b08aa
de53637
0d45025
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, this should be fine because There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this should be an exception. Thoughts? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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 | ||
|
||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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()}' | ||
) | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be great to add a test for this scenario. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yes you're right, let me add one. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
quantresearch1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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) | ||
quantresearch1 marked this conversation as resolved.
Show resolved
Hide resolved
|
Uh oh!
There was an error while loading. Please reload this page.