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
lao/diffapqp/layer.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Layer: differentiates the optimizer solution map x*(theta).
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import torch
|
|
8
|
+
import cvxpy as cp
|
|
9
|
+
from . import value_layer as value_layer_module
|
|
10
|
+
from .value_layer import ValueFuncLayer
|
|
11
|
+
from .solve_result import LayerSolveSummary
|
|
12
|
+
from ..utils import flatten_fortran_torch_batch, unflatten_fortran_torch_batch
|
|
13
|
+
import numpy as np
|
|
14
|
+
import scipy.linalg as la
|
|
15
|
+
import scipy.sparse as sp
|
|
16
|
+
from scipy.sparse import bmat, diags
|
|
17
|
+
from scipy.sparse.linalg import lsqr, minres, spsolve
|
|
18
|
+
from typing import Any
|
|
19
|
+
import warnings
|
|
20
|
+
import multiprocessing as mp
|
|
21
|
+
from threadpoolctl import threadpool_limits
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _LokyPoolAdapter:
|
|
25
|
+
"""Expose the Pool methods used by Layer over a Loky process executor."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, processes: int, initializer, initargs: tuple):
|
|
28
|
+
from loky import ProcessPoolExecutor
|
|
29
|
+
|
|
30
|
+
self._executor = ProcessPoolExecutor(
|
|
31
|
+
max_workers=processes,
|
|
32
|
+
initializer=initializer,
|
|
33
|
+
initargs=initargs,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def map(self, function, iterable, chunksize: int = 1):
|
|
37
|
+
return list(
|
|
38
|
+
self._executor.map(
|
|
39
|
+
function,
|
|
40
|
+
iterable,
|
|
41
|
+
chunksize=chunksize,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def submit(self, function, *args, **kwargs):
|
|
46
|
+
return self._executor.submit(function, *args, **kwargs)
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
self._executor.shutdown(wait=True)
|
|
50
|
+
|
|
51
|
+
def join(self) -> None:
|
|
52
|
+
# shutdown(wait=True) has already joined all workers.
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _as_dense_matrix(matrix):
|
|
57
|
+
return matrix.toarray() if sp.issparse(matrix) else np.asarray(matrix)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _orthonormal_row_basis(matrix, rank_tol: float):
|
|
61
|
+
"""Return an orthonormal basis for the row space of ``matrix``."""
|
|
62
|
+
matrix = _as_dense_matrix(matrix)
|
|
63
|
+
if matrix.size == 0 or matrix.shape[0] == 0:
|
|
64
|
+
return np.zeros((matrix.shape[1], 0))
|
|
65
|
+
|
|
66
|
+
q_mat, r_mat = la.qr(matrix.T, mode="economic", pivoting=False)
|
|
67
|
+
diag = np.abs(np.diag(r_mat))
|
|
68
|
+
if diag.size == 0:
|
|
69
|
+
rank = 0
|
|
70
|
+
else:
|
|
71
|
+
threshold = rank_tol * max(matrix.shape) * max(1.0, diag[0])
|
|
72
|
+
rank = int(np.count_nonzero(diag > threshold))
|
|
73
|
+
return q_mat[:, :rank]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _rank_prune_active_ineq_indices(A_row_basis, G, active_idx, rank_tol: float):
|
|
77
|
+
"""Keep active inequality rows independent relative to equality rows."""
|
|
78
|
+
if len(active_idx) == 0:
|
|
79
|
+
return active_idx
|
|
80
|
+
|
|
81
|
+
G_active = _as_dense_matrix(G[active_idx, :])
|
|
82
|
+
if A_row_basis.shape[1] > 0:
|
|
83
|
+
G_active = G_active - (G_active @ A_row_basis) @ A_row_basis.T
|
|
84
|
+
|
|
85
|
+
_, r_mat, pivots = la.qr(G_active.T, mode="economic", pivoting=True)
|
|
86
|
+
diag = np.abs(np.diag(r_mat))
|
|
87
|
+
if diag.size == 0:
|
|
88
|
+
rank = 0
|
|
89
|
+
else:
|
|
90
|
+
threshold = rank_tol * max(G_active.shape) * max(1.0, diag[0])
|
|
91
|
+
rank = int(np.count_nonzero(diag > threshold))
|
|
92
|
+
return np.sort(active_idx[pivots[:rank]])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Layer(ValueFuncLayer):
|
|
96
|
+
|
|
97
|
+
# Used for resolving active-set tolerances from solver arguments
|
|
98
|
+
_ABS_TOL_KEYS = ("eps_abs", "tol_gap_abs", "tol_feas", "tol_feas_abs")
|
|
99
|
+
_REL_TOL_KEYS = ("eps_rel", "tol_gap_rel", "tol_feas_rel")
|
|
100
|
+
# Internal switch for reduced-KKT active-row rank pruning.
|
|
101
|
+
_PRUNE_ACTIVE_INEQ = False
|
|
102
|
+
_ACTIVE_RANK_TOL = 1e-10
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _build_regularized_problem(problem: cp.Problem, regularization_eps: float) -> cp.Problem:
|
|
106
|
+
"""Return a problem with a small quadratic term added to all variables.
|
|
107
|
+
This problem will be used as the new problem for the layer."""
|
|
108
|
+
if regularization_eps <= 0:
|
|
109
|
+
return problem
|
|
110
|
+
|
|
111
|
+
reg_term = 0
|
|
112
|
+
for var in problem.variables():
|
|
113
|
+
reg_term += cp.sum_squares(var)
|
|
114
|
+
|
|
115
|
+
objective_expr = problem.objective.args[0] + 0.5 * regularization_eps * reg_term
|
|
116
|
+
return cp.Problem(cp.Minimize(objective_expr), problem.constraints)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def _infer_solver_tolerance(cls, solver_args: dict, candidate_keys: tuple[str, ...]) -> float | None:
|
|
120
|
+
for key in candidate_keys:
|
|
121
|
+
value = solver_args.get(key)
|
|
122
|
+
if value is not None:
|
|
123
|
+
return float(value)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def _resolve_active_set_tolerances(
|
|
128
|
+
cls,
|
|
129
|
+
solver_args: dict,
|
|
130
|
+
act_ineq_tol: float | None,
|
|
131
|
+
act_ineq_tol_rel: float | None,
|
|
132
|
+
) -> tuple[float, float]:
|
|
133
|
+
"""Choose active-set tolerances from explicit values or solver tolerances.
|
|
134
|
+
If None, then the tolerance is inferred from the solver arguments."""
|
|
135
|
+
solver_args = {} if solver_args is None else dict(solver_args)
|
|
136
|
+
multiplier = 10.0
|
|
137
|
+
fallback_abs = 1e-4 # default if nothing can be inferred
|
|
138
|
+
fallback_rel = 1e-4
|
|
139
|
+
|
|
140
|
+
inferred_abs = cls._infer_solver_tolerance(solver_args, cls._ABS_TOL_KEYS)
|
|
141
|
+
inferred_rel = cls._infer_solver_tolerance(solver_args, cls._REL_TOL_KEYS)
|
|
142
|
+
|
|
143
|
+
resolved_abs = (
|
|
144
|
+
float(act_ineq_tol) # if given, use the given value
|
|
145
|
+
if act_ineq_tol is not None
|
|
146
|
+
# if not given, use the inferred value or the fallback value
|
|
147
|
+
else max(fallback_abs, multiplier * inferred_abs) if inferred_abs is not None else fallback_abs
|
|
148
|
+
)
|
|
149
|
+
resolved_rel = (
|
|
150
|
+
float(act_ineq_tol_rel)
|
|
151
|
+
if act_ineq_tol_rel is not None
|
|
152
|
+
else max(fallback_rel, multiplier * inferred_rel) if inferred_rel is not None else fallback_rel
|
|
153
|
+
)
|
|
154
|
+
return resolved_abs, resolved_rel
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
problem: cp.Problem,
|
|
159
|
+
max_n_cores: int = os.cpu_count(),
|
|
160
|
+
regularization_eps: float = 0.0,
|
|
161
|
+
act_ineq_tol: float | None = None,
|
|
162
|
+
act_ineq_tol_rel: float | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Initialize the Layer.
|
|
166
|
+
Args:
|
|
167
|
+
problem: CVXPY problem with named parameters and variables.
|
|
168
|
+
max_n_cores: Maximum number of cores to use for parallel solving.
|
|
169
|
+
regularization_eps: Regularization parameter for the problem (useful for LP).
|
|
170
|
+
act_ineq_tol: Absolute tolerance for active inequality constraints, default is None.
|
|
171
|
+
act_ineq_tol_rel: Relative tolerance for active inequality constraints, default is None.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
if regularization_eps < 0:
|
|
175
|
+
raise ValueError("regularization_eps must be nonnegative.")
|
|
176
|
+
if act_ineq_tol is not None and act_ineq_tol < 0:
|
|
177
|
+
raise ValueError("act_ineq_tol must be nonnegative.")
|
|
178
|
+
if act_ineq_tol_rel is not None and act_ineq_tol_rel < 0:
|
|
179
|
+
raise ValueError("act_ineq_tol_rel must be nonnegative.")
|
|
180
|
+
|
|
181
|
+
self.regularization_eps = float(regularization_eps)
|
|
182
|
+
self.act_ineq_tol = None if act_ineq_tol is None else float(act_ineq_tol)
|
|
183
|
+
self.act_ineq_tol_rel = None if act_ineq_tol_rel is None else float(act_ineq_tol_rel)
|
|
184
|
+
self.original_problem = problem
|
|
185
|
+
self.regularized_problem = self._build_regularized_problem(problem, self.regularization_eps)
|
|
186
|
+
|
|
187
|
+
# use the regularized problem to initialize the ValueFuncLayer
|
|
188
|
+
super().__init__(self.regularized_problem, max_n_cores=max_n_cores)
|
|
189
|
+
if self.max_n_cores > 1 and self.backward_pool is None:
|
|
190
|
+
# Loky provides process parallelism without requiring a __main__
|
|
191
|
+
# entry-point guard on Windows or macOS. Linux never enters this
|
|
192
|
+
# branch and retains the existing multiprocessing fork pool.
|
|
193
|
+
try:
|
|
194
|
+
self.backward_pool = _LokyPoolAdapter(
|
|
195
|
+
processes=self.max_n_cores,
|
|
196
|
+
initializer=value_layer_module._init_worker_backward,
|
|
197
|
+
initargs=(self.prob_data,),
|
|
198
|
+
)
|
|
199
|
+
self.backward_pool_start_method = "loky"
|
|
200
|
+
except BaseException:
|
|
201
|
+
self.close()
|
|
202
|
+
raise
|
|
203
|
+
|
|
204
|
+
self._A_row_basis = (
|
|
205
|
+
_orthonormal_row_basis(self.prob_data["A"], self._ACTIVE_RANK_TOL)
|
|
206
|
+
if self._PRUNE_ACTIVE_INEQ
|
|
207
|
+
else None
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _build_solution_summary(self, results: list, standard_param_dict_numpy: dict,
|
|
212
|
+
canon_time: float, solve_time: float,
|
|
213
|
+
act_ineq_tol: float, act_ineq_tol_rel: float) -> LayerSolveSummary:
|
|
214
|
+
|
|
215
|
+
# Reuse the ValueFuncLayer solve path, then identify active inequalities
|
|
216
|
+
# needed by the reduced-KKT adjoint option.
|
|
217
|
+
(x_batch, dual_eq_batch, dual_ineq_batch,
|
|
218
|
+
_, internal_soln_batch, time_batch) = self._summarize_results(
|
|
219
|
+
results, canon_time, solve_time
|
|
220
|
+
) # results: List of SampleSolveResult from _solve_prepared_single_sample;
|
|
221
|
+
# canon_time: time for canonicalization; solve_time: time for solving the problems
|
|
222
|
+
|
|
223
|
+
# Obtain the active inequality set for each sample
|
|
224
|
+
# Standard-form inequality constraints: Gx <= h + sum_i H_i theta_i.
|
|
225
|
+
Gx = self.prob_data["G"] @ x_batch.T # (n_ineq, batch_size)
|
|
226
|
+
h = self.prob_data["h"][:, None].copy()
|
|
227
|
+
for H_key, H_value in self.prob_data["H"].items():
|
|
228
|
+
h = h + H_value @ standard_param_dict_numpy[H_key].T # RHS: h += H_i y_i (n_ineq, batch_size)
|
|
229
|
+
|
|
230
|
+
batch_size = len(results)
|
|
231
|
+
# Per-sample active inequality indices. The relative tolerance keeps the
|
|
232
|
+
# test scale-aware when right-hand sides have different magnitudes.
|
|
233
|
+
active_ineq_idx_list = [
|
|
234
|
+
np.where(
|
|
235
|
+
np.abs(Gx[:, i] - h[:, i])
|
|
236
|
+
<= np.maximum(act_ineq_tol, act_ineq_tol_rel * (1 + np.abs(h[:, i])))
|
|
237
|
+
)[0]
|
|
238
|
+
for i in range(batch_size)
|
|
239
|
+
]
|
|
240
|
+
if self._PRUNE_ACTIVE_INEQ:
|
|
241
|
+
active_ineq_idx_list = [
|
|
242
|
+
_rank_prune_active_ineq_indices(
|
|
243
|
+
self._A_row_basis,
|
|
244
|
+
self.prob_data["G"],
|
|
245
|
+
active_idx,
|
|
246
|
+
self._ACTIVE_RANK_TOL,
|
|
247
|
+
)
|
|
248
|
+
for active_idx in active_ineq_idx_list
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
return LayerSolveSummary(
|
|
252
|
+
primal=x_batch,
|
|
253
|
+
dual_eq=dual_eq_batch,
|
|
254
|
+
dual_ineq=dual_ineq_batch,
|
|
255
|
+
raw_solutions=internal_soln_batch,
|
|
256
|
+
timing=time_batch,
|
|
257
|
+
active_ineq_indices=active_ineq_idx_list,
|
|
258
|
+
inequality_rhs=h,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def forward(self,
|
|
262
|
+
param_dict: dict,
|
|
263
|
+
idx_list: list=None,
|
|
264
|
+
warm_start: bool = False,
|
|
265
|
+
update: bool = False,
|
|
266
|
+
solver: str = "OSQP",
|
|
267
|
+
verbose: bool = False,
|
|
268
|
+
solver_args: dict = {},
|
|
269
|
+
reduced_kkt: bool = False,
|
|
270
|
+
mode: str = None,
|
|
271
|
+
mode_args: dict = {}
|
|
272
|
+
):
|
|
273
|
+
"""
|
|
274
|
+
Forward pass of the Layer.
|
|
275
|
+
Args:
|
|
276
|
+
param_dict: Dictionary of parameters, keys must match self.prob.param_dict, values are torch.Tensors.
|
|
277
|
+
idx_list: List of indices of the current batch of samples to solve. Used for self-defined warm start
|
|
278
|
+
warm_start: Whether to use warm start.
|
|
279
|
+
update: Whether to update the problem.
|
|
280
|
+
solver: The solver to use, default is "OSQP".
|
|
281
|
+
verbose: Whether to print the solver information.
|
|
282
|
+
solver_args: Additional arguments passed to the solver.
|
|
283
|
+
reduced_kkt: Whether to use the reduced KKT system, default is False.
|
|
284
|
+
mode: The method to use to solve the adjoint system, default is None.
|
|
285
|
+
Choose from "lsqr": least squares method, "spsolve": sparse solver, "minres": minimum residual method
|
|
286
|
+
If mode is None, the default is "spsolve".
|
|
287
|
+
mode_args: Dictionary of arguments for the solver for solving adjoint system
|
|
288
|
+
Returns:
|
|
289
|
+
Dictionary of primal variables in their original CVXPY shapes,
|
|
290
|
+
dual variables for the standard-form problem,
|
|
291
|
+
raw solver-native outputs,
|
|
292
|
+
timing information
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
# Stage 1: solve the batched optimization problems in standard form.
|
|
296
|
+
# This returns:
|
|
297
|
+
# - original_param_list_tensor: original-shaped torch inputs kept for autograd
|
|
298
|
+
# - standard_param_dict_numpy: flattened NumPy parameters used by CVXPY
|
|
299
|
+
# - results/canon_time/solve_time: standardized primal-dual solve outputs
|
|
300
|
+
# The actual differentiation of the solution map happens later through
|
|
301
|
+
# the custom autograd wrapper.
|
|
302
|
+
|
|
303
|
+
(
|
|
304
|
+
results,
|
|
305
|
+
solution_cache_list,
|
|
306
|
+
batch_size,
|
|
307
|
+
original_param_list_tensor,
|
|
308
|
+
standard_param_dict_numpy, # Flattened
|
|
309
|
+
canon_time,
|
|
310
|
+
solve_time,
|
|
311
|
+
) = self.forward_solve(
|
|
312
|
+
param_dict,
|
|
313
|
+
idx_list,
|
|
314
|
+
warm_start,
|
|
315
|
+
update,
|
|
316
|
+
solver,
|
|
317
|
+
verbose,
|
|
318
|
+
solver_args,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Stage 2: summarize the standardized solve outputs and identify the
|
|
322
|
+
# active inequality set used by the reduced-KKT adjoint option.
|
|
323
|
+
act_ineq_tol, act_ineq_tol_rel = self._resolve_active_set_tolerances(
|
|
324
|
+
solver_args,
|
|
325
|
+
self.act_ineq_tol,
|
|
326
|
+
self.act_ineq_tol_rel,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
summary = self._build_solution_summary(
|
|
330
|
+
results,
|
|
331
|
+
standard_param_dict_numpy,
|
|
332
|
+
canon_time,
|
|
333
|
+
solve_time,
|
|
334
|
+
act_ineq_tol,
|
|
335
|
+
act_ineq_tol_rel,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Cache solver-native iterates only for solvers that expose the custom
|
|
339
|
+
# warm-start/update interface in the modified CVXPY fork.
|
|
340
|
+
if warm_start and solver.upper() in self._modified_solver_list:
|
|
341
|
+
self._update_solution_cache(solver, batch_size, idx_list, summary.raw_solutions)
|
|
342
|
+
|
|
343
|
+
# Stage 3: choose how to solve the backward adjoint system.
|
|
344
|
+
if mode is None:
|
|
345
|
+
if reduced_kkt:
|
|
346
|
+
mode = "minres"
|
|
347
|
+
else:
|
|
348
|
+
mode = "lsqr"
|
|
349
|
+
# Direct sparse-solve default used in recent experiments:
|
|
350
|
+
# mode = "spsolve"
|
|
351
|
+
elif mode == "minres" and not reduced_kkt:
|
|
352
|
+
raise ValueError("minres mode is only supported for reduced KKT system.")
|
|
353
|
+
elif mode == "lsqr" and reduced_kkt:
|
|
354
|
+
warnings.warn("For the reduced KKT system, minres is usually preferred.")
|
|
355
|
+
|
|
356
|
+
# Stage 4: wrap the already-computed primal solution in a custom
|
|
357
|
+
# autograd Function. The forward pass simply returns the primal
|
|
358
|
+
# variables in their original CVXPY shapes; the backward pass solves
|
|
359
|
+
# the adjoint system to recover parameter gradients.
|
|
360
|
+
autograd_func = _SolutionMapAutogradFn(
|
|
361
|
+
primal_batch=summary.primal,
|
|
362
|
+
dual_ineq_batch=summary.dual_ineq,
|
|
363
|
+
# _compute_adjoint expects one RHS vector per sample.
|
|
364
|
+
h_batch=summary.inequality_rhs.T,
|
|
365
|
+
active_ineq_idx_list_per_sample=summary.active_ineq_indices,
|
|
366
|
+
prob_data=self.prob_data,
|
|
367
|
+
ori_var_size_dict=self.ori_var_size_dict,
|
|
368
|
+
ori_var_shape_dict=self.ori_var_shape_dict,
|
|
369
|
+
ori_param_shape_dict=self.ori_param_shape_dict,
|
|
370
|
+
param_keys=list(self.ori_param_shape_dict.keys()),
|
|
371
|
+
standard_var_size_dict=self.standard_var_size_dict,
|
|
372
|
+
reduced_kkt=reduced_kkt,
|
|
373
|
+
mode=mode,
|
|
374
|
+
mode_args=mode_args,
|
|
375
|
+
pool=self.backward_pool,
|
|
376
|
+
max_n_cores=self.max_n_cores,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# The primal_list has been recovered into the ORIGINAL CVXPY variable shapes
|
|
380
|
+
# [Parameter 1 (batch_size, *), Parameter 2 (batch_size, *), ...]
|
|
381
|
+
# Link each parameter into autograd graph
|
|
382
|
+
primal_list_per_var_tensor = autograd_func(*original_param_list_tensor)
|
|
383
|
+
|
|
384
|
+
# Turn decision variables' list into dictionary
|
|
385
|
+
# PyTorch autograd Functions only support return tuples/tensors, not dictionaries,
|
|
386
|
+
# so rebuild the public variable-name mapping expected by the user-facing API.
|
|
387
|
+
primal_list_per_var_dict = {}
|
|
388
|
+
for idx, key in enumerate(self.ori_var_size_dict.keys()):
|
|
389
|
+
primal_list_per_var_dict[key] = primal_list_per_var_tensor[idx]
|
|
390
|
+
|
|
391
|
+
return primal_list_per_var_dict, summary.dual_eq, summary.dual_ineq, summary.raw_solutions, summary.timing
|
|
392
|
+
|
|
393
|
+
def _SolutionMapAutogradFn(primal_batch,
|
|
394
|
+
dual_ineq_batch,
|
|
395
|
+
h_batch,
|
|
396
|
+
active_ineq_idx_list_per_sample,
|
|
397
|
+
prob_data,
|
|
398
|
+
ori_var_size_dict,
|
|
399
|
+
ori_var_shape_dict,
|
|
400
|
+
ori_param_shape_dict,
|
|
401
|
+
param_keys,
|
|
402
|
+
standard_var_size_dict,
|
|
403
|
+
reduced_kkt: bool,
|
|
404
|
+
mode: str,
|
|
405
|
+
mode_args: dict,
|
|
406
|
+
max_n_cores: int,
|
|
407
|
+
pool: Any):
|
|
408
|
+
|
|
409
|
+
"""
|
|
410
|
+
Define the custom autograd function for the solution-map Layer.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
primal_batch: Batch of primal variables in the standard-form
|
|
414
|
+
dual_ineq_batch: Batch of dual variables for inequality constraints in the standard-form
|
|
415
|
+
h_batch: Batch of right-hand side of inequality constraints in the standard-form
|
|
416
|
+
active_ineq_idx_list_per_sample: List of array of indices of
|
|
417
|
+
the active inequality constraints of each sample
|
|
418
|
+
prob_data: Problem data including P, A, G, H, B, Q, h, g
|
|
419
|
+
ori_var_size_dict: Dictionary of original variable sizes
|
|
420
|
+
ori_var_shape_dict: Dictionary of original variable shapes
|
|
421
|
+
ori_param_shape_dict: Dictionary of original parameter shapes
|
|
422
|
+
param_keys: Parameter names in the same order as the autograd inputs
|
|
423
|
+
standard_var_size_dict: Dictionary of standard-form variable sizes.
|
|
424
|
+
mode: The method to use to solve the adjoint system, default is None.
|
|
425
|
+
Choose from
|
|
426
|
+
- "lsqr": least squares method
|
|
427
|
+
- "spsolve": sparse solver,
|
|
428
|
+
- "minres": minimum residual method
|
|
429
|
+
mode_args: Dictionary of arguments for the solver for solving adjoint system
|
|
430
|
+
max_n_cores: Maximum number of cores to use
|
|
431
|
+
pool: Process pool or executor for the backward pass
|
|
432
|
+
Returns:
|
|
433
|
+
PyTorch autograd function for computing gradients
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
class _SolutionMapAutogradFnFn(torch.autograd.Function):
|
|
437
|
+
|
|
438
|
+
"""
|
|
439
|
+
The forward signature seen by PyTorch is:
|
|
440
|
+
forward pass: list of parameters -> list of original-shaped primal decision variables.
|
|
441
|
+
include conversion from standard-form variable order to original variable order.
|
|
442
|
+
backward pass: list of gradients wrt the primal decision variables -> list of gradients wrt the parameters.
|
|
443
|
+
include conversion from original variable order to standard-form variable order.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
@staticmethod
|
|
447
|
+
def forward(ctx, *param_list):
|
|
448
|
+
"""
|
|
449
|
+
Forward pass of autograd function
|
|
450
|
+
- Receive the standard-form solutions from the forward pass (batch_size, flattened size)
|
|
451
|
+
- Recover the original-shaped primal decision variables [Parameter 1 (batch_size, *), Parameter 2 (batch_size, *), ...]
|
|
452
|
+
which is in the same order as the variable order (NOTE: the pytorch autograd does not support dictionary input and output)
|
|
453
|
+
args:
|
|
454
|
+
- param_list: original problem parameters. The optimization has
|
|
455
|
+
already been solved before this Function is called; these
|
|
456
|
+
inputs define the autograd dependency.
|
|
457
|
+
return:
|
|
458
|
+
- x_list: list of primal variables of the original problem in the same order as param_list
|
|
459
|
+
"""
|
|
460
|
+
# store the context for the backward pass
|
|
461
|
+
ctx.active_ineq_idx_list_per_sample = active_ineq_idx_list_per_sample
|
|
462
|
+
ctx.prob_data = prob_data
|
|
463
|
+
ctx.standard_var_size_dict = standard_var_size_dict
|
|
464
|
+
ctx.ori_var_shape_dict = ori_var_shape_dict
|
|
465
|
+
ctx.ori_param_shape_dict = ori_param_shape_dict
|
|
466
|
+
ctx.param_keys = param_keys
|
|
467
|
+
ctx.reduced_kkt = reduced_kkt
|
|
468
|
+
ctx.batch_size = len(active_ineq_idx_list_per_sample)
|
|
469
|
+
ctx.max_n_cores = max_n_cores
|
|
470
|
+
ctx.primal_batch = primal_batch
|
|
471
|
+
ctx.dual_ineq_batch = dual_ineq_batch
|
|
472
|
+
ctx.h_batch = h_batch
|
|
473
|
+
ctx.mode = mode
|
|
474
|
+
ctx.mode_args = mode_args
|
|
475
|
+
ctx.pool = pool
|
|
476
|
+
|
|
477
|
+
primal_batch_tensor = torch.from_numpy(primal_batch).to(dtype=param_list[0].dtype)
|
|
478
|
+
|
|
479
|
+
# Recover original CVXPY variable shapes. CVXPY vectorizes matrices
|
|
480
|
+
# in column-major order, so the shared helper reverses that layout.
|
|
481
|
+
dim = 0
|
|
482
|
+
primal_list_per_var_tensor = []
|
|
483
|
+
output_var_keys = []
|
|
484
|
+
for key, size in standard_var_size_dict.items():
|
|
485
|
+
# The standard-form variable order matches the canonicalized data.
|
|
486
|
+
if key not in ori_var_size_dict:
|
|
487
|
+
raise ValueError(
|
|
488
|
+
f"Unexpected standard-form variable '{key}'. "
|
|
489
|
+
"DiffAPQP requires canonical variables to match the "
|
|
490
|
+
"original CVXPY variables exactly."
|
|
491
|
+
)
|
|
492
|
+
x_slice = primal_batch_tensor[:, dim:dim+size]
|
|
493
|
+
primal_list_per_var_tensor.append(
|
|
494
|
+
unflatten_fortran_torch_batch(x_slice, ori_var_shape_dict[key])
|
|
495
|
+
)
|
|
496
|
+
output_var_keys.append(key)
|
|
497
|
+
dim += size
|
|
498
|
+
|
|
499
|
+
ctx.dtype = primal_batch_tensor.dtype
|
|
500
|
+
ctx.output_var_keys = output_var_keys
|
|
501
|
+
|
|
502
|
+
# Each returned tensor has shape (batch_size, *original_variable_shape).
|
|
503
|
+
return tuple(primal_list_per_var_tensor)
|
|
504
|
+
|
|
505
|
+
@staticmethod
|
|
506
|
+
def backward(ctx, *grad_output):
|
|
507
|
+
"""
|
|
508
|
+
backward pass
|
|
509
|
+
`grad_output` has the same shapes as the original decision-variable
|
|
510
|
+
tensors returned by forward. We flatten them back to the standard
|
|
511
|
+
CVXPY variable order before solving the adjoint system.
|
|
512
|
+
"""
|
|
513
|
+
# Retrieve cached solve data and shape metadata.
|
|
514
|
+
active_ineq_idx_list_per_sample = ctx.active_ineq_idx_list_per_sample
|
|
515
|
+
prob_data = ctx.prob_data
|
|
516
|
+
reduced_kkt = ctx.reduced_kkt
|
|
517
|
+
batch_size = ctx.batch_size
|
|
518
|
+
max_n_cores = ctx.max_n_cores
|
|
519
|
+
primal_batch = ctx.primal_batch
|
|
520
|
+
dual_ineq_batch = ctx.dual_ineq_batch
|
|
521
|
+
h_batch = ctx.h_batch
|
|
522
|
+
dtype = ctx.dtype
|
|
523
|
+
mode = ctx.mode
|
|
524
|
+
mode_args = ctx.mode_args
|
|
525
|
+
pool = ctx.pool
|
|
526
|
+
ori_var_shape_dict = ctx.ori_var_shape_dict
|
|
527
|
+
ori_param_shape_dict = ctx.ori_param_shape_dict
|
|
528
|
+
param_keys = ctx.param_keys
|
|
529
|
+
output_var_keys = ctx.output_var_keys
|
|
530
|
+
|
|
531
|
+
# Flatten shaped decision-variable gradients back to the standard
|
|
532
|
+
# CVXPY column-major order expected by the adjoint KKT solve.
|
|
533
|
+
# In a custom torch.autograd.Function, the backward(ctx, *grad_output) arguments follow the exact order of the tensors returned by forward().
|
|
534
|
+
grad_output_flat = [
|
|
535
|
+
flatten_fortran_torch_batch(grad, ori_var_shape_dict[key])
|
|
536
|
+
for grad, key in zip(grad_output, output_var_keys)
|
|
537
|
+
] # as defined in the forward pass, the sequence of grad_output is the same as the sequence of output_var_keys
|
|
538
|
+
grad_output_tensor = torch.cat(grad_output_flat, dim=1) # (batch_size, sum(dim_i))
|
|
539
|
+
grad_output_numpy = grad_output_tensor.detach().cpu().numpy()
|
|
540
|
+
|
|
541
|
+
# prepare parallel arguments for the adjoint system
|
|
542
|
+
grad_args = [(grad_output_numpy[i],
|
|
543
|
+
primal_batch[i],
|
|
544
|
+
dual_ineq_batch[i],
|
|
545
|
+
active_ineq_idx_list_per_sample[i],
|
|
546
|
+
h_batch[i],
|
|
547
|
+
reduced_kkt,
|
|
548
|
+
mode,
|
|
549
|
+
mode_args)
|
|
550
|
+
for i in range(batch_size)]
|
|
551
|
+
|
|
552
|
+
# Store problem data in this module for serial execution. Parallel
|
|
553
|
+
# workers get the same data from ValueFuncLayer's pool initializer.
|
|
554
|
+
global _GLOBAL_PROB_DATA
|
|
555
|
+
_GLOBAL_PROB_DATA = prob_data
|
|
556
|
+
|
|
557
|
+
if max_n_cores == 1 or pool is None:
|
|
558
|
+
# list of dicts of gradients wrt the parameters (in standard-form variable order) for each sample
|
|
559
|
+
grad_params_list = [_compute_adjoint(arg) for arg in grad_args]
|
|
560
|
+
else:
|
|
561
|
+
grad_params_list = pool.map(
|
|
562
|
+
_compute_adjoint,
|
|
563
|
+
grad_args,
|
|
564
|
+
# Control how many samples are handed to each worker.
|
|
565
|
+
chunksize=max(1, len(grad_args)//(4*max_n_cores))
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Combine gradients across batch dimension
|
|
569
|
+
combined_grads = {}
|
|
570
|
+
grad_param_keys = grad_params_list[0].keys()
|
|
571
|
+
|
|
572
|
+
for key in grad_param_keys:
|
|
573
|
+
# Stack gradients for each parameter across batch dimension
|
|
574
|
+
combined_grads[key] = torch.from_numpy(
|
|
575
|
+
np.stack([grad_params[key] for grad_params in grad_params_list])).to(dtype = dtype)
|
|
576
|
+
|
|
577
|
+
# Return gradients in the same order as input parameters
|
|
578
|
+
grad_list = [
|
|
579
|
+
unflatten_fortran_torch_batch(combined_grads[key], ori_param_shape_dict[key])
|
|
580
|
+
for key in param_keys
|
|
581
|
+
]
|
|
582
|
+
|
|
583
|
+
return tuple(grad_list) # this matches the sequence of param_keys
|
|
584
|
+
|
|
585
|
+
return _SolutionMapAutogradFnFn.apply
|
|
586
|
+
|
|
587
|
+
def _compute_adjoint(args):
|
|
588
|
+
# for preventing BLAS/OpenMP oversubscription in multiprocessing workers
|
|
589
|
+
with threadpool_limits(limits=1):
|
|
590
|
+
return _compute_adjoint_impl(args)
|
|
591
|
+
|
|
592
|
+
def _compute_adjoint_impl(args: tuple) -> dict:
|
|
593
|
+
"""
|
|
594
|
+
Compute ONE sample's adjoint vector and parameter gradients.
|
|
595
|
+
|
|
596
|
+
To be used in parallel backward pass.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
args: Gradient wrt the primal solution, primal solution, inequality
|
|
600
|
+
duals, active inequality indices, inequality RHS, reduced-KKT flag,
|
|
601
|
+
adjoint linear-solver mode, and solver-specific mode kwargs.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
dict: Gradient of the parameters.
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
# g is dL/dx in the flattened standard-form variable order.
|
|
608
|
+
# Everything is in the standard-form
|
|
609
|
+
g, primal, dual_ineq, active_ineq_idx, h, reduced_kkt, mode, mode_args = args
|
|
610
|
+
mode_args = {} if mode_args is None else dict(mode_args)
|
|
611
|
+
if mp.current_process().name != "MainProcess":
|
|
612
|
+
# Backward process workers are initialized with problem data in
|
|
613
|
+
# value_layer.py. Linux fork workers may also inherit stale module data
|
|
614
|
+
# from a previous benchmark run, so always prefer the initializer data.
|
|
615
|
+
prob_data = value_layer_module._GLOBAL_PROB_DATA
|
|
616
|
+
else:
|
|
617
|
+
prob_data = _GLOBAL_PROB_DATA
|
|
618
|
+
|
|
619
|
+
# P, A, and G are sparse standard-form matrices.
|
|
620
|
+
P = prob_data["P"]
|
|
621
|
+
A = prob_data["A"]
|
|
622
|
+
G = prob_data["G"]
|
|
623
|
+
|
|
624
|
+
if not reduced_kkt:
|
|
625
|
+
# Full complementarity KKT system. This is generally nonsymmetric and
|
|
626
|
+
# can be ill-conditioned when slacks or duals are close to zero.
|
|
627
|
+
K = bmat([
|
|
628
|
+
[P, A.T, G.T],
|
|
629
|
+
[A, None, None],
|
|
630
|
+
[diags(dual_ineq) @ G, None, diags(G @ primal - h)]
|
|
631
|
+
]).T # NOTE: the transpose
|
|
632
|
+
|
|
633
|
+
else:
|
|
634
|
+
# Reduced active-set KKT system. For QPs this is symmetric and avoids
|
|
635
|
+
# inactive complementarity rows.
|
|
636
|
+
# Note that it can also be ill-conditioned
|
|
637
|
+
G_act = G[active_ineq_idx, :]
|
|
638
|
+
H_act_dict = {key: value[active_ineq_idx, :] for key, value in prob_data["H"].items()}
|
|
639
|
+
|
|
640
|
+
K = bmat([
|
|
641
|
+
[P, A.T, G_act.T],
|
|
642
|
+
[A, None, None],
|
|
643
|
+
[G_act, None, None]
|
|
644
|
+
])
|
|
645
|
+
|
|
646
|
+
# Square KKT branches use the common [primal; equality-dual;
|
|
647
|
+
# inequality-dual] unknown layout, so augment the RHS with zeros for
|
|
648
|
+
# dual-adjoint components.
|
|
649
|
+
|
|
650
|
+
g_ = np.zeros(K.shape[1]) # set the dual variables to be zero.
|
|
651
|
+
g_[:len(g)] = g # NOTE: currently differentiation with respect to the dual variables are not supported
|
|
652
|
+
|
|
653
|
+
# Solve the adjoint system
|
|
654
|
+
if mode == "lsqr":
|
|
655
|
+
# LSQR can be slow or inaccurate on poorly scaled full KKT systems;
|
|
656
|
+
# use it as an iterative fallback when direct solves are too costly.
|
|
657
|
+
lsqr_args = {
|
|
658
|
+
"atol": 1e-6,
|
|
659
|
+
"btol": 1e-6,
|
|
660
|
+
"iter_lim": 20000,
|
|
661
|
+
"show": False,
|
|
662
|
+
}
|
|
663
|
+
lsqr_args.update(mode_args)
|
|
664
|
+
adjoint = lsqr(K.tocsc(), g_, **lsqr_args)[0]
|
|
665
|
+
elif mode == "spsolve":
|
|
666
|
+
spsolve_args = {}
|
|
667
|
+
spsolve_args.update(mode_args)
|
|
668
|
+
adjoint = spsolve(K.tocsc(), g_, **spsolve_args)
|
|
669
|
+
elif mode == "minres":
|
|
670
|
+
minres_args = {
|
|
671
|
+
"rtol": 1e-9
|
|
672
|
+
}
|
|
673
|
+
minres_args.update(mode_args)
|
|
674
|
+
adjoint = minres(K.tocsc(), g_, **minres_args)[0]
|
|
675
|
+
|
|
676
|
+
# Convert the adjoint vector into gradients for each canonical parameter.
|
|
677
|
+
grad_params = {}
|
|
678
|
+
|
|
679
|
+
for key, value in prob_data["H"].items():
|
|
680
|
+
if not reduced_kkt:
|
|
681
|
+
# Full KKT data derivative: D_i = [-Q_i; B_i; diag(lambda) H_i].
|
|
682
|
+
D = bmat(
|
|
683
|
+
[
|
|
684
|
+
[-prob_data['Q'][key]],
|
|
685
|
+
[prob_data['B'][key]],
|
|
686
|
+
[diags(dual_ineq) @ value]
|
|
687
|
+
]
|
|
688
|
+
)
|
|
689
|
+
else:
|
|
690
|
+
# Reduced KKT data derivative: D_i = [-Q_i; B_i; H_active_i].
|
|
691
|
+
D = bmat(
|
|
692
|
+
[
|
|
693
|
+
[-prob_data['Q'][key]],
|
|
694
|
+
[prob_data['B'][key]],
|
|
695
|
+
[H_act_dict[key]]
|
|
696
|
+
]
|
|
697
|
+
)
|
|
698
|
+
grad_params[key] = D.T @ adjoint
|
|
699
|
+
|
|
700
|
+
return grad_params
|
|
701
|
+
|
|
702
|
+
|