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.
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
+