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