Coverage for src/cvxmarkowitz/problem.py: 100%
61 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:46 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:46 +0000
1# Copyright 2023 Stanford University Convex Optimization Group
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""The built problem container returned by :meth:`Builder.build`."""
16from __future__ import annotations
18from collections.abc import Generator
19from dataclasses import dataclass, field
20from typing import Any
22import cvxpy as cp
23import numpy as np
25from cvxmarkowitz.cvxerror import CvxDataError, CvxSolverError
26from cvxmarkowitz.model import Model
27from cvxmarkowitz.names import DataNames as D
28from cvxmarkowitz.types import Matrix, Parameter, Variables
31@dataclass(frozen=True)
32class Problem:
33 """Frozen container holding a built cvxpy problem and its named models."""
35 problem: cp.Problem
36 model: dict[str, Model] = field(default_factory=dict)
38 def update(self, **kwargs: Matrix) -> None:
39 """Overwrite the parameter values of every model, **in place**.
41 This mutates the problem rather than returning a new one. `frozen=True`
42 on this dataclass only stops attribute rebinding; the `model` mapping and
43 the cvxpy Parameters it holds stay mutable, and that is deliberate --
44 writing new values into the same compiled problem is exactly what lets
45 cvxpy reuse its cached canonicalization across solves:
47 problem = MinVar(assets=4).build() # compile once
48 for data in datasets:
49 problem.update(**data) # overwrite in place
50 problem.solve()
52 Consequently there is only ever one problem. Two `update` calls against
53 the same object do not yield two independently parametrized problems --
54 the second overwrites the first. Call `build()` again for that.
56 The whole payload is validated against every model before the first
57 value is written, so a rejected payload leaves the problem exactly as it
58 was rather than half-overwritten. See `_validate`.
60 Returns `None` (like `Model.update`) so the in-place semantics are
61 visible at the call site.
63 Raises:
64 CvxDataError: If any model is missing data for one of its parameters,
65 or if the models disagree about how large the universe is.
67 Example:
68 An incomplete payload is rejected before anything is written; a
69 complete one describing 2 of the 4 compiled assets is accepted:
71 >>> import numpy as np
72 >>> from cvxmarkowitz import MinVar
73 >>> from cvxmarkowitz.names import DataNames as D
74 >>> problem = MinVar(assets=4).build()
75 >>> chol = np.linalg.cholesky(np.array([[1.0, 0.5], [0.5, 2.0]])).T
76 >>> problem.update(**{D.CHOLESKY: chol})
77 Traceback (most recent call last):
78 ...
79 cvxmarkowitz.cvxerror.CvxDataError: Missing data for vola_uncertainty in model risk
80 >>> problem.update(
81 ... **{
82 ... D.CHOLESKY: chol,
83 ... D.LOWER_BOUND_ASSETS: np.zeros(2),
84 ... D.UPPER_BOUND_ASSETS: np.ones(2),
85 ... D.VOLA_UNCERTAINTY: np.zeros(2),
86 ... }
87 ... )
88 """
89 self._validate(**kwargs)
91 for model in self.model.values():
92 # It's tempting to operate without the models at this stage.
93 # However, we would give up a lot of convenience. For example,
94 # the models can be prepared to deal with data that has not
95 # exactly the correct shape.
96 model.update(**kwargs)
98 def _validate(self, **kwargs: Matrix) -> None:
99 """Check the payload against every model, writing nothing.
101 Two passes, both over all models, both raising `CvxDataError`:
103 1. every keyword each model declares is present, and
104 2. the models agree on the size of each variable they describe.
106 The second is not redundant with the shape checks inside the models.
107 `Model.update` pads a short input up to the compiled size, so a payload
108 that describes two assets to the risk model and four to the bounds
109 solves without complaint -- and solves wrongly, because the padded tail
110 carries no risk while the bounds leave it free, which the solver reads as
111 two riskless assets. Nothing inside a single model can see that; only
112 comparing the models can.
114 Raises:
115 CvxDataError: On a missing keyword, or on models that disagree about
116 the size of a variable.
117 """
118 for name, model in self.model.items():
119 # `Model.keywords`, not `model.data`: a model may consume a keyword
120 # that `data` does not back (see `ExpectedReturns.keywords`), and
121 # checking `data` alone let those through to a bare KeyError.
122 for key in model.keywords:
123 if key not in kwargs:
124 raise CvxDataError(f"Missing data for {key} in model {name}") # noqa: TRY003
126 claimed: dict[str, tuple[str, int]] = {}
128 for name, model in self.model.items():
129 for variable, size in model.dimensions(**kwargs):
130 first_name, first_size = claimed.setdefault(variable, (name, size))
132 if size != first_size:
133 raise CvxDataError( # noqa: TRY003
134 f"Inconsistent size for {variable}: model {first_name} was given "
135 f"{first_size}, model {name} was given {size}"
136 )
138 def solve(self, solver: str = cp.CLARABEL, **kwargs: Any) -> float:
139 """Solve the problem.
141 Example:
142 >>> import numpy as np
143 >>> from cvxmarkowitz import MinVar
144 >>> from cvxmarkowitz.names import DataNames as D
145 >>> problem = MinVar(assets=4).build()
146 >>> problem.update(
147 ... **{
148 ... D.CHOLESKY: np.linalg.cholesky(np.array([[1.0, 0.5], [0.5, 2.0]])).T,
149 ... D.LOWER_BOUND_ASSETS: np.zeros(2),
150 ... D.UPPER_BOUND_ASSETS: np.ones(2),
151 ... D.VOLA_UNCERTAINTY: np.zeros(2),
152 ... }
153 ... )
154 >>> round(problem.solve(), 4)
155 0.9354
156 >>> np.round(problem.weights, 3).tolist()
157 [0.75, 0.25, 0.0, 0.0]
158 """
159 value = self.problem.solve(solver=solver, **kwargs)
161 if self.problem.status is not cp.OPTIMAL:
162 raise CvxSolverError(f"Problem status is {self.problem.status}") # noqa: TRY003
164 return float(value)
166 def get_problem_data(
167 self,
168 solver: str = cp.CLARABEL,
169 gp: bool = False,
170 enforce_dpp: bool = False,
171 ignore_dpp: bool = False,
172 verbose: bool = False,
173 canon_backend: str | None = None,
174 solver_opts: dict[str, Any] | None = None,
175 ) -> Any:
176 """Return the low-level data the solver would be handed for this problem.
178 This forwards to :meth:`cvxpy.Problem.get_problem_data`, exposing the
179 compiled form of the problem without solving it. Useful for inspecting
180 the canonicalization or for driving a solver directly.
182 Args:
183 solver: The target solver to compile for.
184 gp: Whether to parse the problem as a disciplined geometric program.
185 enforce_dpp: Raise if the problem is not DPP-compliant.
186 ignore_dpp: Treat the problem as non-DPP even if it is compliant.
187 verbose: Print compilation progress.
188 canon_backend: Canonicalization backend to use, or None for the default.
189 solver_opts: Extra options forwarded to the solver.
191 Returns:
192 The ``(data, chain, inverse_data)`` triple produced by cvxpy.
193 """
194 return self.problem.get_problem_data(
195 solver,
196 gp=gp,
197 enforce_dpp=enforce_dpp,
198 ignore_dpp=ignore_dpp,
199 verbose=verbose,
200 canon_backend=canon_backend,
201 solver_opts=solver_opts,
202 )
204 @property
205 def value(self) -> float:
206 """Return the current objective value of the solved problem."""
207 return float(self.problem.value)
209 def is_dpp(self) -> bool:
210 """Return True if the problem satisfies disciplined parameterized programming."""
211 return bool(self.problem.is_dpp())
213 @property
214 def data(self) -> Generator[tuple[tuple[str, str], cp.Parameter]]:
215 """Yield ``((model_name, param_key), parameter)`` pairs for all models."""
216 for name, model in self.model.items():
217 for key, value in model.data.items():
218 yield (name, key), value
220 @property
221 def parameter(self) -> Parameter:
222 """Return a mapping of parameter names to cvxpy Parameter objects."""
223 return dict(self.problem.param_dict.items())
225 @property
226 def variables(self) -> Variables:
227 """Return a mapping of variable names to cvxpy Variable objects."""
228 return dict(self.problem.var_dict.items())
230 @property
231 def weights(self) -> Matrix:
232 """Return the optimal asset weights as a numpy array."""
233 return np.array(self.variables[D.WEIGHTS].value)
235 @property
236 def factor_weights(self) -> Matrix:
237 """Return the optimal factor weights as a numpy array.
239 Raises:
240 CvxDataError: If the problem was built without a factor risk model,
241 in which case there is no factor-weight variable.
242 """
243 try:
244 return np.array(self.variables[D.FACTOR_WEIGHTS].value)
245 except KeyError as err:
246 raise CvxDataError( # noqa: TRY003
247 "No factor weights: this problem was built without 'factors'."
248 ) from err