Skip to content

Commit 59323dc

Browse files
[HIGHS conif] Maps dual solution back to dual variables (cvxpy#2838)
* [HIGHS conif] Map dual values back to dual variables * fix tests and dotsort * fix CI * Add cvar test * fix dotsort canonicalization * remove variable bounds handling --------- Co-authored-by: Steven Diamond <steven@gridmatic.com>
1 parent 6b422fb commit 59323dc

File tree

7 files changed

+58
-5
lines changed

7 files changed

+58
-5
lines changed

cvxpy/atoms/dotsort.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ def get_data(self):
133133
"""
134134
return None
135135

136+
def is_pwl(self) -> bool:
137+
"""Is the expression piecewise linear?
138+
"""
139+
return True
140+
136141
@staticmethod
137142
def _get_args_from_values(values: List[np.ndarray]) \
138143
-> Tuple[np.ndarray, np.ndarray]:

cvxpy/reductions/eliminate_pwl/canonicalizers/dotsort_canon.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ def dotsort_canon(expr, args):
3535
# subject to x @ w_unique.T <= t + q.T
3636
# 0 <= t
3737

38-
t = Variable((x.size, 1), nonneg=True)
38+
t = Variable((x.size, 1))
3939
q = Variable((1, w_unique.size))
4040

4141
obj = sum(t) + q @ w_counts
4242
x_w_unique_outer_product = outer(vec(x, order='F'), vec(w_unique, order='F'))
43-
constraints = [x_w_unique_outer_product <= t + q]
43+
constraints = [x_w_unique_outer_product <= t + q, t >= 0]
4444
return obj, constraints

cvxpy/reductions/qp2quad_form/canonicalizers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
CANON_METHODS = {}
3131
CANON_METHODS[abs] = CONE_METHODS[abs]
3232
CANON_METHODS[cumsum] = CONE_METHODS[cumsum]
33+
CANON_METHODS[dotsort] = CONE_METHODS[dotsort]
3334
CANON_METHODS[maximum] = CONE_METHODS[maximum]
3435
CANON_METHODS[minimum] = CONE_METHODS[minimum]
3536
CANON_METHODS[sum_largest] = CONE_METHODS[sum_largest]

cvxpy/reductions/solvers/conic_solvers/highs_conif.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import cvxpy.settings as s
2121
from cvxpy.error import SolverError
2222
from cvxpy.reductions.solution import Solution, failure_solution
23+
from cvxpy.reductions.solvers import utilities
2324
from cvxpy.reductions.solvers.conic_solvers.conic_solver import (
2425
ConicSolver,
2526
dims_to_solver_dict,
@@ -104,14 +105,26 @@ def invert(self, results, inverse_data):
104105
if status in s.SOLUTION_PRESENT:
105106
opt_val = results["info"].objective_function_value + inverse_data[s.OFFSET]
106107
primal_vars = {
108+
# inverse_data[HIGHS.VAR_ID]: ...
109+
# I don't understand how the line below works, the other conif solvers have
110+
# something similar to the commented line above for the "key".
107111
HIGHS.VAR_ID: intf.DEFAULT_INTF.const_to_matrix(
108112
np.array(results["solution"].col_value)
109113
)
110114
}
111115
# add duals if not a MIP.
112116
dual_vars = None
113-
if not inverse_data["is_mip"]:
114-
dual_vars = {HIGHS.DUAL_VAR_ID: -np.array(results["solution"].row_dual)}
117+
if not inverse_data['is_mip']:
118+
# The dual values are retrieved in the order that the
119+
# constraints were added in solve_via_data() below. We
120+
# must be careful to map them to inverse_data[EQ_CONSTR]
121+
# followed by inverse_data[NEQ_CONSTR] accordingly.
122+
y = -np.array(results["solution"].row_dual)
123+
dual_vars = utilities.get_dual_values(
124+
y,
125+
utilities.extract_dual_value,
126+
inverse_data[HIGHS.EQ_CONSTR] + inverse_data[HIGHS.NEQ_CONSTR])
127+
115128
attr[s.NUM_ITERS] = (
116129
results["info"].ipm_iteration_count
117130
+ results["info"].crossover_iteration_count

cvxpy/reductions/solvers/solving_chain.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ def construct_solving_chain(problem, candidates,
260260
# Canonicalize as a QP
261261
solver = candidates['qp_solvers'][0]
262262
solver_instance = slv_def.SOLVER_MAP_QP[solver]
263+
# TODO should CvxAttr2Constr come after qp2symbolic_qp?
263264
reductions += [
264265
CvxAttr2Constr(reduce_bounds=not solver_instance.BOUNDED_VARIABLES),
265266
qp2symbolic_qp.Qp2SymbolicQp(),

cvxpy/tests/test_conic_solvers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2154,7 +2154,12 @@ class TestHIGHS:
21542154
],
21552155
)
21562156
def test_highs_solving(self, problem) -> None:
2157-
problem(solver=cp.HIGHS)
2157+
# HACK needed to use the HiGHS conic interface rather than
2158+
# the QP interface for LPs.
2159+
from cvxpy.reductions.solvers.conic_solvers.highs_conif import HIGHS
2160+
solver = HIGHS()
2161+
solver.name = lambda: "HIGHS CONIC"
2162+
problem(solver=solver)
21582163

21592164
@pytest.mark.parametrize(
21602165
["problem", "confirmation_string"],

cvxpy/tests/test_qp_solvers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,34 @@ def test_highs_warmstart(self) -> None:
507507
result2 = prob.solve(solver=cp.HIGHS, warm_start=False)
508508
self.assertAlmostEqual(result, result2)
509509

510+
def test_highs_cvar(self) -> None:
511+
"""Test problem with CVaR constraint from
512+
https://github.com/cvxpy/cvxpy/issues/2836
513+
"""
514+
if cp.HIGHS in INSTALLED_SOLVERS:
515+
# Generate data
516+
num_stocks = 5
517+
num_samples = 25
518+
np.random.seed(1)
519+
pnl_samples = np.random.uniform(low=0.0, high=1.0, size=(num_samples, num_stocks))
520+
pnl_expected = pnl_samples.mean(axis=0)
521+
522+
# Prepare to solve
523+
quantile = 0.05
524+
w = cp.Variable(num_stocks, nonneg=True)
525+
cvar = cp.cvar(pnl_samples @ w, 1 - quantile)
526+
pnl = w @ pnl_expected
527+
528+
# Solve
529+
objective = cp.Maximize(pnl)
530+
constraints = [cvar <= 0.5]
531+
problem = cp.Problem(objective, constraints)
532+
problem.solve(
533+
solver=cp.HIGHS,
534+
)
535+
assert problem.status == cp.OPTIMAL
536+
537+
510538
def test_piqp_warmstart(self) -> None:
511539
"""Test warm start.
512540
"""

0 commit comments

Comments
 (0)