Skip to content
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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
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
@@ -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()
166 changes: 152 additions & 14 deletions pyomo/contrib/solver/solvers/highs.py
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:
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

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]:
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
q_value = []
Copy link
Contributor

Choose a reason for hiding this comment

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

Does q_* stand for quadratic_*?

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 hessian is often referred to as a matrix Q (in many books I see a quadratic objective defined as 1/2 x.T @ Q @ x). I am happy to change to whatever you prefer

q_index = []
q_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:
q_start[last_col] = len(q_value)

# Add the entry
q_index.append(row)
q_value.append(val)

while last_col < dim - 1:
last_col += 1
q_start[last_col] = len(q_value)

nnz = len(q_value)
Copy link
Contributor

@mrmundt mrmundt Apr 10, 2025

Choose a reason for hiding this comment

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

I'm not sure what nnz means here - can you please name the variable more explicitly?

EDIT: Ignore this - jsiirola informs me that it's used in other places, too, so we should make a more concentrated effort to replace universally within Pyomo rather than just here.

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 tried to name the variables in accordance with the highspy function definition:
def passHessian(
self,
dim: int,
nnz: int,
format: int,
q_start: numpy.ndarray[typing.Any, numpy.dtype[numpy.int32]],
q_index: numpy.ndarray[typing.Any, numpy.dtype[numpy.int32]],
q_value: numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]],
) -> HighsStatus: ...

it's used a lot in scipy, their sparse matrices/arrays have a nnz attributes which returns the number of non-zero entries. Maybe num_nz is clearer (closer to the gurobi model attribute 'NumNZs') ? Happy to change it to whatever you think is clearer

status = self.highs.passHessian(
dim,
nnz,
highspy.HessianFormat.kTriangular,
np.array(q_start, dtype=np.int32),
np.array(q_index, dtype=np.int32),
np.array(q_value, dtype=np.double),
)

if status != highspy.HighsStatus.kOk:
logger.warning(
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
)
)

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
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)