Skip to content

Commit 967c37a

Browse files
committed
first draft
1 parent 5e8ee52 commit 967c37a

4 files changed

Lines changed: 288 additions & 4 deletions

File tree

cvxpy/reductions/solvers/nlp_solvers/nlp_solver.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,11 @@ def __init__(
171171
verbose: bool = True,
172172
use_hessian: bool = True,
173173
) -> None:
174-
from cvxpy.reductions.solvers.nlp_solvers.diff_engine import C_problem
174+
from cvxpy.reductions.solvers.nlp_solvers.sparsediff_adapter import (
175+
build_sparsediff_problem,
176+
)
175177

176-
self.c_problem = C_problem(problem, verbose=verbose)
178+
self.c_problem = build_sparsediff_problem(problem, verbose=verbose)
177179
self.use_hessian = use_hessian
178180

179181
# Always initialize Jacobian
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
Copyright, the CVXPY authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
17+
from cvxpy.reductions.solvers.nlp_solvers.sparsediff_adapter.adapter import (
18+
build_sparsediff_problem,
19+
)
20+
21+
__all__ = ["build_sparsediff_problem"]
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
"""
2+
Copyright, the CVXPY authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
Adapter that converts a CVXPY Problem into a sparsediffpy.Problem by
17+
translating the CVXPY expression tree into SparseDiffPy expressions.
18+
"""
19+
import numpy as np
20+
import sparsediffpy as sp
21+
from scipy import sparse
22+
from sparsediffpy._core._constants import Constant as _SpConstant
23+
from sparsediffpy._core._constants import SparseConstant as _SpSparseConstant
24+
25+
import cvxpy as cp
26+
from cvxpy.reductions.inverse_data import InverseData
27+
28+
29+
def _normalize_shape(shape):
30+
"""Normalise a CVXPY shape to 2-D, prepending 1s (row convention).
31+
32+
Matches CVXPY's broadcasting semantics: a 1-D shape `(N,)` behaves as a
33+
row `(1, N)` when broadcast against 2-D, and as a column in `A @ x`
34+
contexts — the latter is handled via a local reshape in MulExpression.
35+
"""
36+
shape = tuple(shape)
37+
return (1,) * (2 - len(shape)) + shape
38+
39+
40+
def _as_column(x):
41+
"""Reshape a (1, n) row or (n, 1) column to a column (n, 1)."""
42+
if x.shape[1] == 1:
43+
return x
44+
return sp.reshape(x, x.shape[0] * x.shape[1], 1)
45+
46+
47+
def _wrap_constant_value(value, shape):
48+
"""Wrap a CVXPY constant value into a SparseDiffPy Constant/SparseConstant.
49+
50+
Uses the CVXPY-declared `shape` (normalised to 2-D) so downstream operator
51+
dispatch sees a consistent shape for every constant node.
52+
"""
53+
if sparse.issparse(value):
54+
return _SpSparseConstant(value)
55+
d1, d2 = _normalize_shape(shape)
56+
return _SpConstant(np.asarray(value, dtype=np.float64), (d1, d2))
57+
58+
59+
def _convert_matmul(expr, children):
60+
# SparseDiffPy's `@` enforces `left.shape[1] == right.shape[0]` strictly;
61+
# the old C-level matmul was lenient and accepted any (1, n) / (n,) / (n, 1)
62+
# combination for vector-matmul, so no reshaping was needed there. The two
63+
# reshapes below are the minimal fixups to satisfy the strict check:
64+
# 1. RHS: CVXPY 1-D `(n,)` is stored as row (1, n); matmul needs a
65+
# column (n, 1) on the right — covers both `A @ x` and `x @ y` dot.
66+
# 2. Result: `A @ x` with 1-D `x` yields (m, 1), but CVXPY declared the
67+
# result 1-D which we normalise to a row (1, m).
68+
left, right = children
69+
if len(expr.args[1].shape) == 1 and right.shape[1] != 1:
70+
right = _as_column(right)
71+
result = left @ right
72+
if len(expr.shape) == 1 and result.shape[1] == 1 and result.shape[0] != 1:
73+
result = sp.reshape(result, 1, result.shape[0])
74+
return result
75+
76+
77+
def _convert_transpose(expr, children):
78+
# For vectors ((1, n) or (n, 1)), transpose is a no-op in Fortran-order
79+
# flat storage, so use the cheap reshape; for true matrices, use the real
80+
# Transpose node which permutes elements.
81+
child_shape = _normalize_shape(expr.args[0].shape)
82+
if 1 in child_shape:
83+
return sp.reshape(children[0], child_shape[1], child_shape[0])
84+
return children[0].T
85+
86+
87+
def _convert_reshape(expr, children):
88+
if expr.order != "F":
89+
raise NotImplementedError(
90+
f"reshape with order='{expr.order}' not supported. "
91+
"Only order='F' (Fortran) is currently supported."
92+
)
93+
d1, d2 = _normalize_shape(expr.shape)
94+
return sp.reshape(children[0], d1, d2)
95+
96+
97+
def _convert_diag_vec(expr, children):
98+
if expr.k != 0:
99+
raise NotImplementedError(
100+
"diag_vec with k != 0 not supported in diff engine"
101+
)
102+
return sp.diag_vec(_as_column(children[0]))
103+
104+
105+
def _convert_quad_form(expr, children):
106+
P = expr.args[1]
107+
if not isinstance(P, cp.Constant):
108+
raise NotImplementedError("quad_form requires P to be a constant matrix")
109+
P_val = P.value
110+
if not isinstance(P_val, sparse.csr_matrix):
111+
P_val = sparse.csr_matrix(P_val)
112+
return sp.quad_form(_as_column(children[0]), P_val)
113+
114+
115+
def _convert_index(expr, children):
116+
parent_shape = expr.args[0].shape
117+
slices = [np.arange(s.start, s.stop, s.step) for s in expr.key]
118+
if len(slices) == 1:
119+
idxs = slices[0].astype(np.int32)
120+
elif len(slices) == 2:
121+
idxs = (
122+
np.add.outer(slices[0], slices[1] * parent_shape[0])
123+
.flatten(order="F")
124+
.astype(np.int32)
125+
)
126+
else:
127+
raise NotImplementedError("index with >2 dimensions not supported")
128+
return sp.index_flat(children[0], idxs, _normalize_shape(expr.shape))
129+
130+
131+
def _convert_special_index(expr, children):
132+
idxs = np.reshape(
133+
expr._select_mat, expr._select_mat.size, order="F"
134+
).astype(np.int32)
135+
return sp.index_flat(children[0], idxs, _normalize_shape(expr.shape))
136+
137+
138+
def _sum_args(children):
139+
result = children[0]
140+
for c in children[1:]:
141+
result = result + c
142+
return result
143+
144+
145+
def _elementwise(fn):
146+
return lambda expr, children: fn(children[0])
147+
148+
149+
def _convert_promote(expr, children):
150+
return sp.broadcast(children[0], _normalize_shape(expr.shape))
151+
152+
153+
def _convert_broadcast(expr, children):
154+
return sp.broadcast(children[0], tuple(expr.broadcast_shape))
155+
156+
157+
_CONVERTERS = {
158+
# N-ary / unary
159+
"AddExpression": lambda expr, children: _sum_args(children),
160+
"NegExpression": lambda expr, children: -children[0],
161+
"multiply": lambda expr, children: children[0] * children[1],
162+
"MulExpression": _convert_matmul,
163+
164+
# Structural / affine
165+
"Promote": _convert_promote,
166+
"broadcast_to": _convert_broadcast,
167+
"Sum": lambda expr, children: sp.sum(children[0], axis=expr.axis),
168+
"Prod": lambda expr, children: sp.prod(children[0], axis=expr.axis),
169+
"Power": lambda expr, children: sp.power(children[0], float(expr.p.value)),
170+
"PowerApprox": lambda expr, children: sp.power(children[0], float(expr.p.value)),
171+
"Trace": lambda expr, children: sp.trace(children[0]),
172+
"Hstack": lambda expr, children: sp.hstack(children),
173+
"transpose": _convert_transpose,
174+
"reshape": _convert_reshape,
175+
"diag_vec": _convert_diag_vec,
176+
"index": _convert_index,
177+
"special_index": _convert_special_index,
178+
179+
# Bivariate
180+
"QuadForm": _convert_quad_form,
181+
"quad_over_lin": lambda expr, children: sp.quad_over_lin(children[0], children[1]),
182+
"rel_entr": lambda expr, children: sp.rel_entr(children[0], children[1]),
183+
184+
# Elementwise unary
185+
"log": _elementwise(sp.log),
186+
"exp": _elementwise(sp.exp),
187+
"sin": _elementwise(sp.sin),
188+
"cos": _elementwise(sp.cos),
189+
"tan": _elementwise(sp.tan),
190+
"sinh": _elementwise(sp.sinh),
191+
"tanh": _elementwise(sp.tanh),
192+
"asinh": _elementwise(sp.asinh),
193+
"atanh": _elementwise(sp.atanh),
194+
"entr": _elementwise(sp.entr),
195+
"logistic": _elementwise(sp.logistic),
196+
"xexp": _elementwise(sp.xexp),
197+
"normcdf": _elementwise(sp.normal_cdf),
198+
}
199+
200+
201+
def _convert(expr, var_map, param_map):
202+
if isinstance(expr, cp.Variable):
203+
return var_map[expr.id]
204+
if isinstance(expr, cp.Parameter):
205+
return param_map[expr.id]
206+
if isinstance(expr, cp.Constant):
207+
return _wrap_constant_value(expr.value, expr.shape)
208+
209+
atom_name = type(expr).__name__
210+
converter = _CONVERTERS.get(atom_name)
211+
if converter is None:
212+
raise NotImplementedError(f"Atom '{atom_name}' not supported")
213+
214+
children = [_convert(arg, var_map, param_map) for arg in expr.args]
215+
result = converter(expr, children)
216+
217+
target = _normalize_shape(expr.shape)
218+
if result.shape != target:
219+
raise ValueError(
220+
f"Dimension mismatch for atom '{atom_name}': "
221+
f"SparseDiff shape {result.shape} vs CVXPY shape {target}"
222+
)
223+
return result
224+
225+
226+
def build_sparsediff_problem(
227+
cvxpy_problem: cp.Problem, verbose: bool = False
228+
) -> sp.Problem:
229+
"""Build a sparsediffpy.Problem from a CVXPY Problem.
230+
231+
Variables are created in the order given by InverseData's id_map (sorted
232+
by offset) so the resulting flat-vector layout matches what Oracles sends
233+
to objective_forward / constraint_forward. Parameters are created in the
234+
order of cvxpy_problem.parameters() so Oracles.update_params' Fortran-flat
235+
concatenation aligns with the sparsediffpy.Problem's parameter layout.
236+
"""
237+
inverse_data = InverseData(cvxpy_problem)
238+
scope = sp.Scope()
239+
240+
var_map = {}
241+
for var_id, (_offset, _length) in sorted(
242+
inverse_data.id_map.items(), key=lambda kv: kv[1][0]
243+
):
244+
d1, d2 = _normalize_shape(inverse_data.var_shapes[var_id])
245+
var_map[var_id] = scope.Variable(d1, d2)
246+
247+
param_map = {}
248+
for cvxpy_param in cvxpy_problem.parameters():
249+
d1, d2 = _normalize_shape(inverse_data.param_shapes[cvxpy_param.id])
250+
sp_param = scope.Parameter(d1, d2)
251+
sp_param.value = np.asarray(cvxpy_param.value, dtype=np.float64)
252+
param_map[cvxpy_param.id] = sp_param
253+
254+
obj_expr = _convert(cvxpy_problem.objective.expr, var_map, param_map)
255+
constraint_exprs = [
256+
_convert(c.expr, var_map, param_map) for c in cvxpy_problem.constraints
257+
]
258+
259+
return sp.Problem(obj_expr, constraint_exprs, verbose=verbose)

cvxpy/tests/nlp_tests/derivative_checker.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ def __init__(self, problem):
3636
The CVXPY problem to check derivatives for.
3737
"""
3838
from cvxpy.reductions.dnlp2smooth.dnlp2smooth import Dnlp2Smooth
39-
from cvxpy.reductions.solvers.nlp_solvers.diff_engine import C_problem
39+
from cvxpy.reductions.solvers.nlp_solvers.sparsediff_adapter import (
40+
build_sparsediff_problem,
41+
)
4042

4143
self.original_problem = problem
4244
self._coo_initialized = False
@@ -47,7 +49,7 @@ def __init__(self, problem):
4749

4850
# Construct the C version
4951
print("Constructing C diff engine problem for derivative checking...")
50-
self.c_problem = C_problem(self.canonicalized_problem)
52+
self.c_problem = build_sparsediff_problem(self.canonicalized_problem)
5153
print("Done constructing C diff engine problem.")
5254

5355
# Construct initial point using Bounds functionality

0 commit comments

Comments
 (0)