diffapqp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffapqp/__init__.py +11 -0
- diffapqp-0.1.0.dist-info/METADATA +457 -0
- diffapqp-0.1.0.dist-info/RECORD +14 -0
- diffapqp-0.1.0.dist-info/WHEEL +5 -0
- diffapqp-0.1.0.dist-info/licenses/LICENSE +201 -0
- diffapqp-0.1.0.dist-info/top_level.txt +2 -0
- lao/__init__.py +14 -0
- lao/__version__.py +1 -0
- lao/cvxpy_compat.py +69 -0
- lao/diffapqp/__init__.py +2 -0
- lao/diffapqp/layer.py +702 -0
- lao/diffapqp/solve_result.py +55 -0
- lao/diffapqp/value_layer.py +799 -0
- lao/utils.py +459 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diffapqp.ValueFuncLayer: takes the differentiation of the value function.
|
|
3
|
+
For given APQP,
|
|
4
|
+
- In the forward pass, it solves the continuous problem,
|
|
5
|
+
and outputs the optimal value (value function) and primal/dual variables
|
|
6
|
+
- In the backward pass, it computes the gradients of the VALUE FUNCTION
|
|
7
|
+
with respect to the parameters for FREE (no implicit differentiation)
|
|
8
|
+
- Everything can be in parallel
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
import numpy as np
|
|
13
|
+
from ..cvxpy_compat import warn_missing_cvxpy_fork
|
|
14
|
+
from ..utils import (
|
|
15
|
+
_flattened_size,
|
|
16
|
+
flatten_fortran_numpy_sample,
|
|
17
|
+
get_default_solver_args,
|
|
18
|
+
return_standard_form_in_cvxpy,
|
|
19
|
+
unflatten_fortran_torch_batch,
|
|
20
|
+
)
|
|
21
|
+
import cvxpy as cp
|
|
22
|
+
from copy import deepcopy
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import warnings
|
|
26
|
+
import time
|
|
27
|
+
from threadpoolctl import threadpool_limits
|
|
28
|
+
from multiprocessing.pool import ThreadPool
|
|
29
|
+
import multiprocessing as mp
|
|
30
|
+
from .solve_result import PreparedSolveData, PreparedSolveTask, SampleSolveResult
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _init_worker_backward(prob_data):
|
|
34
|
+
"""Broadcast read-only canonical problem data once per backward worker.
|
|
35
|
+
Each worker process receives the data instead of sending/pickling the data with every backward call
|
|
36
|
+
Only used for the Layer. ValueFuncLayer's backward pass is already efficient for serial solving.
|
|
37
|
+
"""
|
|
38
|
+
global _GLOBAL_PROB_DATA
|
|
39
|
+
_GLOBAL_PROB_DATA = prob_data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _should_use_fork_backward_pool() -> bool:
|
|
43
|
+
"""Keep the existing fork path where supported, except on macOS."""
|
|
44
|
+
return sys.platform != "darwin" and "fork" in mp.get_all_start_methods()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ValueFuncLayer(torch.nn.Module):
|
|
48
|
+
"""
|
|
49
|
+
Differentiable value function layer for continuous APQP problems.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
_modified_solver_list = {"SCS", "OSQP", "QPALM", "PROXQP"}
|
|
53
|
+
|
|
54
|
+
def __init__(self, problem: cp.Problem, max_n_cores: int = os.cpu_count()) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Initialize the ValueFuncLayer.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
problem (cp.Problem): CVXPY problem with named parameters and variables.
|
|
60
|
+
Must contain only continuous variables.
|
|
61
|
+
"""
|
|
62
|
+
super(ValueFuncLayer, self).__init__()
|
|
63
|
+
|
|
64
|
+
# Validate problem has parameters and variables
|
|
65
|
+
if len(problem.param_dict) < 1:
|
|
66
|
+
raise ValueError("Problem must have at least one parameter.")
|
|
67
|
+
if len(problem.var_dict) < 1:
|
|
68
|
+
raise ValueError("Problem must have at least one variable.")
|
|
69
|
+
|
|
70
|
+
self._canonicalize(problem) # will also check the problem
|
|
71
|
+
|
|
72
|
+
# Forward solves are threaded because the solver calls release most of
|
|
73
|
+
# the GIL. On Linux, retain the existing fork-based backward pool for
|
|
74
|
+
# benchmark compatibility. Windows and macOS leave it to SolMapLayer to
|
|
75
|
+
# create a Loky process pool because ValueFuncLayer does not use it.
|
|
76
|
+
if max_n_cores == -1:
|
|
77
|
+
max_n_cores = os.cpu_count()
|
|
78
|
+
if max_n_cores > 1:
|
|
79
|
+
self.forward_pool = ThreadPool(processes=max_n_cores)
|
|
80
|
+
if _should_use_fork_backward_pool():
|
|
81
|
+
ctx_mp = mp.get_context("fork")
|
|
82
|
+
self.backward_pool = ctx_mp.Pool(
|
|
83
|
+
processes=max_n_cores,
|
|
84
|
+
initializer=_init_worker_backward,
|
|
85
|
+
initargs=(self.prob_data,)
|
|
86
|
+
)
|
|
87
|
+
self.backward_pool_start_method = "fork"
|
|
88
|
+
else:
|
|
89
|
+
self.backward_pool = None
|
|
90
|
+
self.backward_pool_start_method = None
|
|
91
|
+
else:
|
|
92
|
+
self.forward_pool = None
|
|
93
|
+
self.backward_pool = None
|
|
94
|
+
self.backward_pool_start_method = None
|
|
95
|
+
|
|
96
|
+
self.max_n_cores = max_n_cores
|
|
97
|
+
|
|
98
|
+
# Expensive CVXPY/solver objects are cached by the first batch slot in
|
|
99
|
+
# self.cvxpy_prob_list;
|
|
100
|
+
# cheap warm-start vectors are cached by sample index for all samples.
|
|
101
|
+
self.solution_cache_dict = {}
|
|
102
|
+
self.cvxpy_prob_list = None
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
"""Close persistent worker pools owned by this layer."""
|
|
106
|
+
if self.backward_pool is not None:
|
|
107
|
+
self.backward_pool.close()
|
|
108
|
+
self.backward_pool.join()
|
|
109
|
+
self.backward_pool = None
|
|
110
|
+
self.backward_pool_start_method = None
|
|
111
|
+
|
|
112
|
+
if self.forward_pool is not None:
|
|
113
|
+
self.forward_pool.close()
|
|
114
|
+
self.forward_pool.join()
|
|
115
|
+
self.forward_pool = None
|
|
116
|
+
|
|
117
|
+
def __enter__(self):
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
121
|
+
self.close()
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def _canonicalize(self, problem: cp.Problem) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Convert the problem to standard APQP form.
|
|
127
|
+
|
|
128
|
+
Standard form:
|
|
129
|
+
min 1/2 x^T P x + (sum Q_i y_i)^T x
|
|
130
|
+
s.t. Ax == b + sum B_i y_i
|
|
131
|
+
Gx <= h + sum H_i z_i
|
|
132
|
+
"""
|
|
133
|
+
# Store original shapes so public outputs/gradients can be reshaped back
|
|
134
|
+
# after CVXPY canonicalizes everything into flat vectors.
|
|
135
|
+
self.ori_var_shape_dict = {var.name(): tuple(var.shape) for var in problem.variables()}
|
|
136
|
+
self.ori_var_size_dict = {var.name(): _flattened_size(var.shape) for var in problem.variables()}
|
|
137
|
+
self.ori_param_shape_dict = {param.name(): tuple(param.shape) for param in problem.parameters()}
|
|
138
|
+
self.ori_param_size_dict = {param.name(): _flattened_size(param.shape) for param in problem.parameters()}
|
|
139
|
+
|
|
140
|
+
# Convert once to affine-parametric QP standard form:
|
|
141
|
+
# Q: linear cost, d: objective offset, B: equality RHS, H: inequality RHS.
|
|
142
|
+
# prob_data includes P, Q, q, c, d, A, G, b, h, B, H
|
|
143
|
+
self.prob, self.standard_var_size_dict, self.prob_data = return_standard_form_in_cvxpy(problem, sparse=True)
|
|
144
|
+
self.n_eq_standard = self.prob_data["A"].shape[0]
|
|
145
|
+
self.n_ineq_standard = self.prob_data["G"].shape[0]
|
|
146
|
+
|
|
147
|
+
if set(self.standard_var_size_dict) != set(self.ori_var_size_dict):
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"CVXPY canonicalization introduced or removed variables. "
|
|
150
|
+
"DiffAPQP requires standard-form variables to match the "
|
|
151
|
+
"original CVXPY variables exactly. Please reformulate the problem."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for key, size in self.ori_var_size_dict.items():
|
|
155
|
+
if self.standard_var_size_dict[key] != size:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"Variable '{key}' changed size during canonicalization. "
|
|
158
|
+
"DiffAPQP requires standard-form variable sizes to match "
|
|
159
|
+
"the original CVXPY variables. Please reformulate the problem."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _prepare_forward_data(self, param_dict: dict):
|
|
163
|
+
"""
|
|
164
|
+
Validate and convert batched parameters for the forward pass.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
param_dict:
|
|
168
|
+
Mapping from original CVXPY parameter names to batched torch
|
|
169
|
+
tensors. After the leading batch dimension, each tensor must
|
|
170
|
+
match the ORIGINAL CVXPY parameter shape:
|
|
171
|
+
- scalar parameter: ``(batch,)`` or ``(batch, 1)``
|
|
172
|
+
- vector parameter of shape ``(n,)``: ``(batch, n)``
|
|
173
|
+
- matrix parameter of shape ``(m, n)``: ``(batch, m, n)``
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
standard_param_dict_numpy:
|
|
177
|
+
Parameters converted to NumPy and flattened into the
|
|
178
|
+
standard-form representation expected by the canonicalized
|
|
179
|
+
CVXPY problem. Matrix parameters are vectorized in Fortran
|
|
180
|
+
(column-major) order.
|
|
181
|
+
original_param_list_tensor:
|
|
182
|
+
Original torch tensors kept in CVXPY parameter order for
|
|
183
|
+
autograd bookkeeping.
|
|
184
|
+
batch_size:
|
|
185
|
+
Shared batch dimension across all parameters.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
standard_param_dict_numpy = {} # {parameter_name: numpy_array (batch_size, flattened_size)}
|
|
189
|
+
original_param_list_tensor = []
|
|
190
|
+
batch_size = None
|
|
191
|
+
|
|
192
|
+
for key in self.ori_param_size_dict.keys():
|
|
193
|
+
# Preserve CVXPY's original parameter order; autograd gradients must
|
|
194
|
+
# be returned in this same order.
|
|
195
|
+
if key not in param_dict:
|
|
196
|
+
raise ValueError(f"Missing parameter '{key}' in input param_dict.")
|
|
197
|
+
if not isinstance(param_dict[key], torch.Tensor):
|
|
198
|
+
raise ValueError(f"Parameter {key} must be a torch.Tensor")
|
|
199
|
+
|
|
200
|
+
tensor = param_dict[key]
|
|
201
|
+
original_param_list_tensor.append(tensor) # record in CVXPY parameter order
|
|
202
|
+
tensor_np = tensor.detach().cpu().numpy()
|
|
203
|
+
expected_shape = self.ori_param_shape_dict[key]
|
|
204
|
+
|
|
205
|
+
if tensor_np.ndim == 0:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"Parameter '{key}' must include a leading batch dimension. "
|
|
208
|
+
f"Expected shape (batch, ...) for original parameter shape {expected_shape}."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if batch_size is None:
|
|
212
|
+
batch_size = tensor_np.shape[0]
|
|
213
|
+
elif tensor_np.shape[0] != batch_size:
|
|
214
|
+
raise ValueError(f"Inconsistent batch size for parameter '{key}'.")
|
|
215
|
+
|
|
216
|
+
actual_shape = tuple(tensor_np.shape[1:])
|
|
217
|
+
if len(expected_shape) == 0:
|
|
218
|
+
# scalar parameter
|
|
219
|
+
if actual_shape not in [(), (1,)]:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"Parameter '{key}' has invalid shape {tuple(tensor_np.shape)}. "
|
|
222
|
+
f"Expected (batch,) or (batch, 1) for scalar parameter."
|
|
223
|
+
)
|
|
224
|
+
elif actual_shape != expected_shape:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Parameter '{key}' has invalid shape {tuple(tensor_np.shape)}. "
|
|
227
|
+
f"Expected (batch, {', '.join(str(dim) for dim in expected_shape)}) "
|
|
228
|
+
f"to match original parameter shape {expected_shape}."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Standard-form CVXPY parameters are flat vectors. Matrix-shaped
|
|
232
|
+
# parameters use CVXPY's column-major vectorization convention.
|
|
233
|
+
if len(expected_shape) == 0:
|
|
234
|
+
# Supports scalar parameters passed as shape (batch,).
|
|
235
|
+
tensor_np = tensor_np.reshape(batch_size, 1)
|
|
236
|
+
elif len(expected_shape) > 1:
|
|
237
|
+
tensor_np = np.stack(
|
|
238
|
+
[flatten_fortran_numpy_sample(tensor_np[i]) for i in range(batch_size)],
|
|
239
|
+
axis=0,
|
|
240
|
+
)
|
|
241
|
+
standard_param_dict_numpy[key] = tensor_np
|
|
242
|
+
|
|
243
|
+
return standard_param_dict_numpy, original_param_list_tensor, batch_size
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _ensure_solver_options_in_inverse_data(inverse_data, solver_args: dict) -> None:
|
|
247
|
+
"""Fill missing `solver_options` fields expected by some cvxpy solver inversions.
|
|
248
|
+
Some CVXPY versions/solver paths may leave inverse_data.solver_options as None,
|
|
249
|
+
while the unpack/invert logic expects it to be a dict.
|
|
250
|
+
"""
|
|
251
|
+
if inverse_data is None:
|
|
252
|
+
return
|
|
253
|
+
if isinstance(inverse_data, (list, tuple)):
|
|
254
|
+
for item in inverse_data:
|
|
255
|
+
ValueFuncLayer._ensure_solver_options_in_inverse_data(item, solver_args)
|
|
256
|
+
return
|
|
257
|
+
if hasattr(inverse_data, "solver_options") and getattr(inverse_data, "solver_options") is None:
|
|
258
|
+
inverse_data.solver_options = dict(solver_args) if solver_args is not None else {}
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _prepare_single_sample(
|
|
262
|
+
cvxpy_prob: cp.Problem,
|
|
263
|
+
param_dict: dict,
|
|
264
|
+
warm_start: bool,
|
|
265
|
+
update: bool,
|
|
266
|
+
solver: str,
|
|
267
|
+
warm_start_solution_dict: dict,
|
|
268
|
+
) -> PreparedSolveData:
|
|
269
|
+
"""Assign one sample's parameters and create CVXPY solver data.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
cvxpy_prob: CVXPY problem in standard form returned by return_standard_form_in_cvxpy
|
|
273
|
+
param_dict: Dictionary of parameters returned by _prepare_forward_data in standard form
|
|
274
|
+
warm_start: Whether to use warm start
|
|
275
|
+
update: Whether to update the problem
|
|
276
|
+
solver: The solver to use
|
|
277
|
+
warm_start_solution_dict: Dictionary of warm start solution
|
|
278
|
+
"""
|
|
279
|
+
for key, value in param_dict.items():
|
|
280
|
+
cvxpy_prob.param_dict[key].value = value
|
|
281
|
+
warm_start_solution_dict = {} if warm_start_solution_dict is None else warm_start_solution_dict
|
|
282
|
+
prepare_start_time = time.time()
|
|
283
|
+
data, chain, inverse_data = cvxpy_prob.get_problem_data(solver)
|
|
284
|
+
prepare_time = time.time() - prepare_start_time
|
|
285
|
+
|
|
286
|
+
if solver.upper() in ValueFuncLayer._modified_solver_list:
|
|
287
|
+
# for the modified solvers, we need to set the update and warm_start flags
|
|
288
|
+
cache_ready = getattr(cvxpy_prob, "_lao_update_cache_ready", False)
|
|
289
|
+
# The first solve must build the solver workspace. Later solves may
|
|
290
|
+
# use the custom update path exposed by the modified CVXPY fork.
|
|
291
|
+
data["update"] = bool(update and cache_ready)
|
|
292
|
+
effective_warm_start = warm_start and len(warm_start_solution_dict) > 0
|
|
293
|
+
data["warm_start"] = effective_warm_start
|
|
294
|
+
if effective_warm_start:
|
|
295
|
+
data["warm_start_solution_dict"] = warm_start_solution_dict
|
|
296
|
+
|
|
297
|
+
return PreparedSolveData(
|
|
298
|
+
cvxpy_prob=cvxpy_prob,
|
|
299
|
+
data=data,
|
|
300
|
+
chain=chain,
|
|
301
|
+
inverse_data=inverse_data,
|
|
302
|
+
prepare_time=prepare_time,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _solve_prepared_single_sample(task: PreparedSolveTask) -> SampleSolveResult:
|
|
307
|
+
"""Solve one sample from already-prepared CVXPY data."""
|
|
308
|
+
cvxpy_prob = task.prepared.cvxpy_prob
|
|
309
|
+
data = task.prepared.data
|
|
310
|
+
chain = task.prepared.chain
|
|
311
|
+
inverse_data = task.prepared.inverse_data
|
|
312
|
+
prepare_time = task.prepared.prepare_time
|
|
313
|
+
|
|
314
|
+
n_eq_standard = task.n_eq_standard
|
|
315
|
+
n_ineq_standard = task.n_ineq_standard
|
|
316
|
+
solver = task.solver
|
|
317
|
+
warm_start = task.warm_start
|
|
318
|
+
verbose = task.verbose
|
|
319
|
+
solver_args = task.solver_args
|
|
320
|
+
|
|
321
|
+
worker_start_time = time.time()
|
|
322
|
+
# This wraps CVXPY's solver interface, including Python/binding overhead
|
|
323
|
+
# around the solver's own internal timer.
|
|
324
|
+
solve_via_data_start_time = time.time()
|
|
325
|
+
soln = chain.solve_via_data(
|
|
326
|
+
problem=cvxpy_prob,
|
|
327
|
+
data=data,
|
|
328
|
+
# For non-modified solvers, warm_start here is the only handle
|
|
329
|
+
# For modified solvers, warm_start here is ignored
|
|
330
|
+
warm_start=warm_start, # The warm-start is passed for the solver that are not modified
|
|
331
|
+
verbose=verbose,
|
|
332
|
+
solver_opts=solver_args,
|
|
333
|
+
)
|
|
334
|
+
solve_via_data_time = time.time() - solve_via_data_start_time
|
|
335
|
+
|
|
336
|
+
ValueFuncLayer._ensure_solver_options_in_inverse_data(inverse_data, solver_args)
|
|
337
|
+
|
|
338
|
+
# Unpack primal/dual values back into the standardized CVXPY problem.
|
|
339
|
+
unpack_start_time = time.time()
|
|
340
|
+
cvxpy_prob.unpack_results(soln, chain, inverse_data)
|
|
341
|
+
unpack_time = time.time() - unpack_start_time
|
|
342
|
+
|
|
343
|
+
if solver.upper() in ValueFuncLayer._modified_solver_list:
|
|
344
|
+
# Mark that this batch slot now has a solver workspace eligible for
|
|
345
|
+
# a future custom update call.
|
|
346
|
+
cvxpy_prob._lao_update_cache_ready = True
|
|
347
|
+
|
|
348
|
+
if cvxpy_prob.status == "optimal_inaccurate":
|
|
349
|
+
warnings.warn(
|
|
350
|
+
"The problem is solved in an inaccurate way. Please consider using a more accurate solver."
|
|
351
|
+
)
|
|
352
|
+
print(f"The value is: {cvxpy_prob.value}")
|
|
353
|
+
elif cvxpy_prob.status != "optimal":
|
|
354
|
+
raise ValueError(f"The problem is not solved. Status: {cvxpy_prob.status}")
|
|
355
|
+
|
|
356
|
+
worker_total_time = time.time() - worker_start_time
|
|
357
|
+
|
|
358
|
+
# The standard problem has constraints in the order:
|
|
359
|
+
# Ax == b and Gx <= h; in case no equality and/or inequality constraints are handled.
|
|
360
|
+
constraint_idx = 0
|
|
361
|
+
if n_eq_standard > 0:
|
|
362
|
+
dual_eq = np.asarray(cvxpy_prob.constraints[constraint_idx].dual_value)
|
|
363
|
+
constraint_idx += 1
|
|
364
|
+
else:
|
|
365
|
+
dual_eq = np.zeros((0,), dtype=float)
|
|
366
|
+
|
|
367
|
+
if n_ineq_standard > 0:
|
|
368
|
+
dual_ineq = np.asarray(cvxpy_prob.constraints[constraint_idx].dual_value)
|
|
369
|
+
else:
|
|
370
|
+
dual_ineq = np.zeros((0,), dtype=float)
|
|
371
|
+
|
|
372
|
+
return SampleSolveResult(
|
|
373
|
+
primal=cvxpy_prob.variables()[0].value, # in standard form
|
|
374
|
+
dual_eq=dual_eq, # in standard form
|
|
375
|
+
dual_ineq=dual_ineq, # in standard form
|
|
376
|
+
value=cvxpy_prob.value,
|
|
377
|
+
raw_solution=soln, # for whatever returned by the internal solver
|
|
378
|
+
timing={
|
|
379
|
+
"worker_total_time": worker_total_time,
|
|
380
|
+
"get_problem_data_time": prepare_time,
|
|
381
|
+
"solve_via_data_time": solve_via_data_time,
|
|
382
|
+
"unpack_time": unpack_time,
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _solve_parallel(cvxpy_prob_list: list, # standardized CVXPY problems, one per batch slot
|
|
388
|
+
param_dict_list: list, # one parameter dictionary per sample
|
|
389
|
+
warm_start: bool = True,
|
|
390
|
+
update: bool = False,
|
|
391
|
+
solver: str = "OSQP",
|
|
392
|
+
verbose: bool = False,
|
|
393
|
+
solver_args: dict = None,
|
|
394
|
+
n_eq_standard: int = 0,
|
|
395
|
+
n_ineq_standard: int = 0,
|
|
396
|
+
warm_start_solution_dict_list: list = None, # each element is a dictionary with the same keys
|
|
397
|
+
pool = None, # thread pool for parallel solving
|
|
398
|
+
) -> tuple:
|
|
399
|
+
|
|
400
|
+
batch_size = len(param_dict_list)
|
|
401
|
+
solver_args = {} if solver_args is None else solver_args
|
|
402
|
+
|
|
403
|
+
warm_list = warm_start_solution_dict_list if warm_start_solution_dict_list is not None else [{} for _ in range(batch_size)]
|
|
404
|
+
warn_missing_cvxpy_fork(
|
|
405
|
+
solver,
|
|
406
|
+
warm_start=warm_start,
|
|
407
|
+
update=update,
|
|
408
|
+
warm_start_solution_dict_list=warm_list,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
start_time = time.time()
|
|
412
|
+
|
|
413
|
+
# Phase 1 (serial): stuff problem data for each sample. This remains
|
|
414
|
+
# serial because CVXPY problem-data generation mutates problem objects.
|
|
415
|
+
prepared_list = []
|
|
416
|
+
for i in range(batch_size):
|
|
417
|
+
prepared = ValueFuncLayer._prepare_single_sample(
|
|
418
|
+
cvxpy_prob=cvxpy_prob_list[i],
|
|
419
|
+
param_dict=param_dict_list[i],
|
|
420
|
+
warm_start=warm_start,
|
|
421
|
+
update=update,
|
|
422
|
+
solver=solver,
|
|
423
|
+
warm_start_solution_dict=warm_list[i],
|
|
424
|
+
)
|
|
425
|
+
prepared_list.append(
|
|
426
|
+
PreparedSolveTask(
|
|
427
|
+
prepared=prepared,
|
|
428
|
+
solver=solver,
|
|
429
|
+
warm_start=warm_start,
|
|
430
|
+
verbose=verbose,
|
|
431
|
+
solver_args=solver_args,
|
|
432
|
+
n_eq_standard=n_eq_standard,
|
|
433
|
+
n_ineq_standard=n_ineq_standard,
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
canon_time = time.time() - start_time
|
|
438
|
+
|
|
439
|
+
start_time = time.time()
|
|
440
|
+
# Phase 2 (parallel): solve using the already-prepared data. Limit
|
|
441
|
+
# nested BLAS/OpenMP threads so outer sample parallelism does not
|
|
442
|
+
# oversubscribe the machine.
|
|
443
|
+
if pool is None:
|
|
444
|
+
# serial solving
|
|
445
|
+
results = [ValueFuncLayer._solve_prepared_single_sample(args) for args in prepared_list]
|
|
446
|
+
else:
|
|
447
|
+
with threadpool_limits(limits=1):
|
|
448
|
+
# parallel solving
|
|
449
|
+
# threadpool_limits is used to limit the nested BLAS/OpenMP threads
|
|
450
|
+
# so outer sample parallelism does not oversubscribe the machine.
|
|
451
|
+
results = pool.map(ValueFuncLayer._solve_prepared_single_sample, prepared_list)
|
|
452
|
+
|
|
453
|
+
solve_time = time.time() - start_time
|
|
454
|
+
|
|
455
|
+
return results, canon_time, solve_time
|
|
456
|
+
|
|
457
|
+
def _summarize_results(self, results: list, canon_time: float, solve_time: float):
|
|
458
|
+
|
|
459
|
+
# Each results is a list of SampleSolveResult from _solve_prepared_single_sample.
|
|
460
|
+
# For each sample's result, timing includes worker total time,
|
|
461
|
+
# get_problem_data_time, solve_via_data_time, unpack_time
|
|
462
|
+
# of each sample
|
|
463
|
+
batch_size = len(results)
|
|
464
|
+
total_x_dim = sum(self.ori_var_size_dict.values())
|
|
465
|
+
|
|
466
|
+
x_batch = np.zeros((batch_size, total_x_dim))
|
|
467
|
+
dual_eq_batch = np.zeros((batch_size, self.n_eq_standard))
|
|
468
|
+
dual_ineq_batch = np.zeros((batch_size, self.n_ineq_standard))
|
|
469
|
+
optimal_value_batch = []
|
|
470
|
+
internal_soln_batch = []
|
|
471
|
+
sample_time_batch = []
|
|
472
|
+
time_batch = {
|
|
473
|
+
# this is outside the internal solver time
|
|
474
|
+
"canon_time": canon_time,
|
|
475
|
+
"solve_time": solve_time,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for i, result in enumerate(results):
|
|
479
|
+
x_batch[i] = result.primal
|
|
480
|
+
dual_eq_batch[i] = result.dual_eq
|
|
481
|
+
dual_ineq_batch[i] = result.dual_ineq
|
|
482
|
+
optimal_value_batch.append(result.value)
|
|
483
|
+
internal_soln_batch.append(result.raw_solution)
|
|
484
|
+
sample_time_batch.append(result.timing)
|
|
485
|
+
|
|
486
|
+
time_batch["sample_times"] = sample_time_batch
|
|
487
|
+
|
|
488
|
+
return x_batch, dual_eq_batch, dual_ineq_batch, optimal_value_batch, internal_soln_batch, time_batch
|
|
489
|
+
|
|
490
|
+
def forward_solve(self,
|
|
491
|
+
param_dict: dict,
|
|
492
|
+
idx_list: list=None,
|
|
493
|
+
warm_start: bool = True,
|
|
494
|
+
update: bool = False,
|
|
495
|
+
solver: str = "OSQP",
|
|
496
|
+
verbose: bool = False,
|
|
497
|
+
solver_args: dict = {}
|
|
498
|
+
):
|
|
499
|
+
|
|
500
|
+
"""
|
|
501
|
+
Solve the problem using the targeted solver in its standard form.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
param_dict (dict): Dict of parameters, keys must match self.prob.param_dict, values are torch.Tensors.
|
|
505
|
+
idx_list (list): List of indices of the current batch of samples to solve.
|
|
506
|
+
warm_start (bool): Whether to use warm start.
|
|
507
|
+
update (bool): Whether to update the problem.
|
|
508
|
+
solver (str): The solver to use, default is "OSQP".
|
|
509
|
+
verbose (bool): Whether to print the solver information.
|
|
510
|
+
solver_args (dict): Additional arguments passed to the solver.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
tuple:
|
|
514
|
+
results: List of SampleSolveResult objects.
|
|
515
|
+
solution_cache_list: Warm-start cache entries for the current batch, or None.
|
|
516
|
+
batch_size: Number of samples in the batch.
|
|
517
|
+
original_param_list_tensor: Original torch parameters in CVXPY parameter order.
|
|
518
|
+
standard_param_dict_numpy: Flattened NumPy parameters for the standard-form problem.
|
|
519
|
+
canon_time: Time spent preparing CVXPY problem data.
|
|
520
|
+
solve_time: Wall time spent solving prepared samples.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
# standard_param_dict_numpy feeds CVXPY; original_param_list_tensor
|
|
524
|
+
# preserves the original torch inputs and order needed by autograd.
|
|
525
|
+
standard_param_dict_numpy, original_param_list_tensor, batch_size = self._prepare_forward_data(param_dict)
|
|
526
|
+
|
|
527
|
+
solver = solver.upper()
|
|
528
|
+
solver_args = {**get_default_solver_args(solver), **solver_args} # call the default and overwrite
|
|
529
|
+
|
|
530
|
+
solution_cache_list = None
|
|
531
|
+
|
|
532
|
+
# Maintain a bounded expensive cache: one standardized CVXPY problem per
|
|
533
|
+
# batch slot, not one per dataset sample (which is very expensive in memory).
|
|
534
|
+
# Warm-start vectors below are cached per sample id and are much cheaper.
|
|
535
|
+
if self.cvxpy_prob_list is None:
|
|
536
|
+
self.cvxpy_prob_list = [deepcopy(self.prob) for _ in range(batch_size)]
|
|
537
|
+
|
|
538
|
+
cvxpy_prob_list = self.cvxpy_prob_list[:batch_size]
|
|
539
|
+
|
|
540
|
+
if warm_start and solver.upper() in self._modified_solver_list:
|
|
541
|
+
assert idx_list is not None, "idx_list must be provided for warm start."
|
|
542
|
+
for idx in idx_list:
|
|
543
|
+
if idx not in self.solution_cache_dict.keys():
|
|
544
|
+
# initialize the solution cache for the sample
|
|
545
|
+
self.solution_cache_dict[idx] = {}
|
|
546
|
+
|
|
547
|
+
# idx_list maps the current batch positions to per-sample warm-start
|
|
548
|
+
# vectors from previous calls. the sequence must be maintained
|
|
549
|
+
solution_cache_list = [self.solution_cache_dict[idx] for idx in idx_list]
|
|
550
|
+
|
|
551
|
+
# Split batched NumPy parameters into per-sample dictionaries for CVXPY.
|
|
552
|
+
param_dict_list = [
|
|
553
|
+
{k: v[i] for k, v in standard_param_dict_numpy.items()}
|
|
554
|
+
for i in range(batch_size)
|
|
555
|
+
]
|
|
556
|
+
results, canon_time, solve_time = ValueFuncLayer._solve_parallel(
|
|
557
|
+
cvxpy_prob_list,
|
|
558
|
+
param_dict_list,
|
|
559
|
+
warm_start=warm_start,
|
|
560
|
+
update=update,
|
|
561
|
+
solver=solver,
|
|
562
|
+
verbose=verbose,
|
|
563
|
+
solver_args=solver_args,
|
|
564
|
+
n_eq_standard=self.n_eq_standard,
|
|
565
|
+
n_ineq_standard=self.n_ineq_standard,
|
|
566
|
+
warm_start_solution_dict_list=(
|
|
567
|
+
solution_cache_list if solution_cache_list is not None else [{} for _ in range(batch_size)]
|
|
568
|
+
),
|
|
569
|
+
pool=self.forward_pool,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
return (
|
|
573
|
+
results,
|
|
574
|
+
solution_cache_list,
|
|
575
|
+
batch_size,
|
|
576
|
+
original_param_list_tensor,
|
|
577
|
+
standard_param_dict_numpy,
|
|
578
|
+
canon_time,
|
|
579
|
+
solve_time,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
def _update_solution_cache(self, solver, batch_size, idx_list, internal_soln_batch):
|
|
583
|
+
for i in range(batch_size):
|
|
584
|
+
idx = idx_list[i]
|
|
585
|
+
# Store solver-native iterates for explicit warm starts in the
|
|
586
|
+
# modified CVXPY solver interfaces.
|
|
587
|
+
if solver.upper() == "SCS":
|
|
588
|
+
self.solution_cache_dict[idx] = {
|
|
589
|
+
"x": internal_soln_batch[i]["x"],
|
|
590
|
+
"y": internal_soln_batch[i]["y"],
|
|
591
|
+
"s": internal_soln_batch[i]["s"]
|
|
592
|
+
}
|
|
593
|
+
elif solver.upper() == "OSQP":
|
|
594
|
+
self.solution_cache_dict[idx] = {
|
|
595
|
+
"x": internal_soln_batch[i].x,
|
|
596
|
+
"y": internal_soln_batch[i].y
|
|
597
|
+
}
|
|
598
|
+
elif solver.upper() == "QPALM":
|
|
599
|
+
self.solution_cache_dict[idx] = {
|
|
600
|
+
"x": internal_soln_batch[i].solution.x,
|
|
601
|
+
"y": internal_soln_batch[i].solution.y
|
|
602
|
+
}
|
|
603
|
+
elif solver.upper() == "PROXQP":
|
|
604
|
+
self.solution_cache_dict[idx] = {
|
|
605
|
+
"x": internal_soln_batch[i].x,
|
|
606
|
+
"y": internal_soln_batch[i].y,
|
|
607
|
+
"z": internal_soln_batch[i].z
|
|
608
|
+
}
|
|
609
|
+
else:
|
|
610
|
+
raise ValueError(f"Solver {solver} is not supported for warm start.")
|
|
611
|
+
|
|
612
|
+
def forward(self,
|
|
613
|
+
param_dict: dict,
|
|
614
|
+
idx_list: list=None,
|
|
615
|
+
warm_start: bool = False,
|
|
616
|
+
update: bool = False,
|
|
617
|
+
solver: str = "OSQP",
|
|
618
|
+
verbose: bool = False,
|
|
619
|
+
solver_args: dict = {}
|
|
620
|
+
):
|
|
621
|
+
|
|
622
|
+
(
|
|
623
|
+
results,
|
|
624
|
+
solution_cache_list,
|
|
625
|
+
batch_size,
|
|
626
|
+
original_param_list_tensor,
|
|
627
|
+
standard_param_dict_numpy,
|
|
628
|
+
canon_time,
|
|
629
|
+
solve_time,
|
|
630
|
+
) = self.forward_solve(
|
|
631
|
+
param_dict,
|
|
632
|
+
idx_list,
|
|
633
|
+
warm_start,
|
|
634
|
+
update,
|
|
635
|
+
solver,
|
|
636
|
+
verbose,
|
|
637
|
+
solver_args,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
(x_batch, dual_eq_batch, dual_ineq_batch, optimal_value_batch,
|
|
641
|
+
internal_soln_batch, time_batch) = self._summarize_results(results, canon_time, solve_time)
|
|
642
|
+
if warm_start and solver.upper() in self._modified_solver_list:
|
|
643
|
+
self._update_solution_cache(solver, batch_size, idx_list, internal_soln_batch)
|
|
644
|
+
|
|
645
|
+
autograd_fn = _ValueFunctionAutogradFn(
|
|
646
|
+
dual_eq_batch=dual_eq_batch,
|
|
647
|
+
dual_ineq_batch=dual_ineq_batch,
|
|
648
|
+
optimal_value_batch=optimal_value_batch,
|
|
649
|
+
optimal_primal_batch=x_batch,
|
|
650
|
+
H_dict=self.prob_data["H"],
|
|
651
|
+
B_dict=self.prob_data["B"],
|
|
652
|
+
Q_dict=self.prob_data["Q"],
|
|
653
|
+
d_dict=self.prob_data["d"],
|
|
654
|
+
ori_param_size_dict=self.ori_param_size_dict,
|
|
655
|
+
ori_param_shape_dict=self.ori_param_shape_dict,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# Creates the PyTorch autograd connection from the output value to the input parameters:
|
|
659
|
+
# Custom autograd only supports tuple input and output
|
|
660
|
+
# No unflattening is needed here
|
|
661
|
+
optimal_value_batch = autograd_fn(*original_param_list_tensor)
|
|
662
|
+
|
|
663
|
+
# Recover primal variables in their original CVXPY shapes
|
|
664
|
+
primal_dict = {}
|
|
665
|
+
dim = 0
|
|
666
|
+
for key, size in self.ori_var_size_dict.items():
|
|
667
|
+
shape = self.ori_var_shape_dict[key]
|
|
668
|
+
x_slice = x_batch[:, dim:dim+size]
|
|
669
|
+
primal_dict[key] = unflatten_fortran_torch_batch(torch.tensor(x_slice), shape)
|
|
670
|
+
dim += size
|
|
671
|
+
if dim != x_batch.shape[1]:
|
|
672
|
+
raise ValueError(
|
|
673
|
+
"Standard-form primal dimension does not match the original "
|
|
674
|
+
"CVXPY variable dimensions. Please reformulate the problem."
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
dual_eq_batch = torch.tensor(dual_eq_batch)
|
|
678
|
+
dual_ineq_batch = torch.tensor(dual_ineq_batch)
|
|
679
|
+
|
|
680
|
+
# note that the primal and dual is not differentiable in ValueFuncLayer.forward
|
|
681
|
+
return optimal_value_batch, primal_dict, dual_eq_batch, dual_ineq_batch, internal_soln_batch, time_batch
|
|
682
|
+
|
|
683
|
+
def _ValueFunctionAutogradFn(dual_eq_batch, dual_ineq_batch, optimal_value_batch, optimal_primal_batch,
|
|
684
|
+
H_dict, B_dict, Q_dict, d_dict, ori_param_size_dict, ori_param_shape_dict):
|
|
685
|
+
"""Define the value-function autograd wrapper.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
dual_eq_batch: Batch of dual variables for equality constraints of the canonical form problem
|
|
689
|
+
dual_ineq_batch: Batch of dual variables for inequality constraints of the canonical form problem
|
|
690
|
+
optimal_value_batch: Batch of optimal objective values
|
|
691
|
+
optimal_primal_batch: Batch of optimal primal variables in standard form
|
|
692
|
+
H_dict: Dictionary mapping parameter names to inequality constraint matrices
|
|
693
|
+
B_dict: Dictionary mapping parameter names to equality constraint matrices
|
|
694
|
+
Q_dict: Dictionary mapping parameter names to first order objective vector
|
|
695
|
+
d_dict: Dictionary mapping parameter names to scalar objective-offset vectors
|
|
696
|
+
ori_param_size_dict: Dictionary of original parameter sizes (flattened)
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
PyTorch autograd function for computing gradients
|
|
700
|
+
"""
|
|
701
|
+
class _ValueFunctionAutogradFnFn(torch.autograd.Function):
|
|
702
|
+
@staticmethod
|
|
703
|
+
def forward(ctx, *param_list):
|
|
704
|
+
"""Return precomputed optimal values while storing data for backward.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
param_list: List of parameter tensors (dummy inputs since forward pass
|
|
708
|
+
already computed the optimal value and primal/dual)
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
Tensor of optimal objective values (batch_size,)
|
|
712
|
+
"""
|
|
713
|
+
# Save the optimal primal/dual solution and parameter-affine data.
|
|
714
|
+
ctx.H_dict = H_dict
|
|
715
|
+
ctx.B_dict = B_dict
|
|
716
|
+
ctx.Q_dict = Q_dict
|
|
717
|
+
ctx.d_dict = d_dict
|
|
718
|
+
ctx.dual_ineq_batch = dual_ineq_batch
|
|
719
|
+
ctx.dual_eq_batch = dual_eq_batch
|
|
720
|
+
ctx.ori_param_size_dict = ori_param_size_dict
|
|
721
|
+
ctx.ori_param_shape_dict = ori_param_shape_dict
|
|
722
|
+
|
|
723
|
+
return torch.tensor(optimal_value_batch)
|
|
724
|
+
|
|
725
|
+
@staticmethod
|
|
726
|
+
def backward(ctx, *grad_output):
|
|
727
|
+
"""Compute value-function gradients by the envelope theorem.
|
|
728
|
+
|
|
729
|
+
Since this custom autograd function has one output, ``grad_output`` is a
|
|
730
|
+
single-element tuple. ``grad_output[0]`` has the same shape as
|
|
731
|
+
``optimal_value_batch``: ``(batch_size,)``.
|
|
732
|
+
|
|
733
|
+
Each returned parameter gradient has the same shape as that parameter's input
|
|
734
|
+
to the forward pass. And the number of the gradients is the same as the number of the parameters.
|
|
735
|
+
The sequence of the gradients is the same as the sequence of the parameters.
|
|
736
|
+
|
|
737
|
+
However, dictionary-type input and output are not allowed in this custom autograd function.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
grad_output: Gradient of loss (after this layer) with respect to outputs
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
Tuple of gradients for each parameter
|
|
744
|
+
"""
|
|
745
|
+
|
|
746
|
+
dual_ineq_batch = ctx.dual_ineq_batch # (batch_size, n_ineq)
|
|
747
|
+
dual_eq_batch = ctx.dual_eq_batch # (batch_size, n_eq)
|
|
748
|
+
H_dict = ctx.H_dict
|
|
749
|
+
B_dict = ctx.B_dict
|
|
750
|
+
Q_dict = ctx.Q_dict
|
|
751
|
+
d_dict = ctx.d_dict
|
|
752
|
+
ori_param_size_dict = ctx.ori_param_size_dict
|
|
753
|
+
ori_param_shape_dict = ctx.ori_param_shape_dict
|
|
754
|
+
|
|
755
|
+
# For standard form, dV/dtheta_i =
|
|
756
|
+
# -lambda^T H_i - nu^T B_i + x*^T Q_i + d_i.
|
|
757
|
+
# Scale by upstream grad_output for general scalar losses of V.
|
|
758
|
+
grad_list = []
|
|
759
|
+
for param_name, param_size in ori_param_size_dict.items():
|
|
760
|
+
grad_value = (
|
|
761
|
+
-dual_ineq_batch @ H_dict[param_name]
|
|
762
|
+
- dual_eq_batch @ B_dict[param_name]
|
|
763
|
+
+ optimal_primal_batch @ Q_dict[param_name]
|
|
764
|
+
+ d_dict[param_name]
|
|
765
|
+
)
|
|
766
|
+
grad_flat = torch.einsum(
|
|
767
|
+
"b, bj->bj",
|
|
768
|
+
grad_output[0],
|
|
769
|
+
torch.as_tensor(
|
|
770
|
+
grad_value,
|
|
771
|
+
dtype=grad_output[0].dtype,
|
|
772
|
+
device=grad_output[0].device,
|
|
773
|
+
),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# recover the gradient to the original parameter shapes
|
|
777
|
+
param_shape = ori_param_shape_dict[param_name]
|
|
778
|
+
if len(param_shape) == 0:
|
|
779
|
+
grad_list.append(grad_flat[:, 0])
|
|
780
|
+
elif len(param_shape) == 1:
|
|
781
|
+
grad_list.append(grad_flat)
|
|
782
|
+
else:
|
|
783
|
+
# matrix parameter
|
|
784
|
+
grad_np = grad_flat.detach().cpu().numpy()
|
|
785
|
+
grad_reshaped = np.stack(
|
|
786
|
+
[grad_np[i].reshape(param_shape, order="F") for i in range(grad_np.shape[0])],
|
|
787
|
+
axis=0,
|
|
788
|
+
)
|
|
789
|
+
grad_list.append(
|
|
790
|
+
torch.as_tensor(
|
|
791
|
+
grad_reshaped,
|
|
792
|
+
dtype=grad_output[0].dtype,
|
|
793
|
+
device=grad_output[0].device,
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
return tuple(grad_list)
|
|
798
|
+
|
|
799
|
+
return _ValueFunctionAutogradFnFn.apply
|