|
| 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) |
0 commit comments