Skip to content

Value-Function Layer (ValueFuncLayer)

ValueFuncLayer evaluates the value function \(\alpha(\hat y):=f_0(\hat z^\star,\hat y)\) and connects the returned value to PyTorch autograd. Here, \(\hat y\) denotes the parameters supplied to the optimization problem and \(\hat z^\star=\mathcal{O}(\hat y)\) is the corresponding optimal decision. This layer is appropriate when the downstream loss depends on the optimization objective value rather than on the optimizer solution itself.

Accepted problems

The constructor accepts an ordinary cvxpy.Problem when it can be canonicalized to a standard APQP/APLP problem with all of the following properties:

  • a minimization problem with at least one named parameter and one named decision variable
  • DCP and QP compliant, and DPP compliant when parameters are present
  • continuous variables only
  • a constant positive-semidefinite quadratic matrix \(P\)
  • constant equality and inequality coefficient matrices \(A\) and \(G\)
  • parameters entering affinely through the linear objective, objective offset, or constraint right-hand sides

DiffAPQP rejects parameterized \(P\), \(A\), or \(G\), and formulations for which CVXPY canonicalization introduces, removes, or resizes decision variables. Names should therefore be unique and should be assigned explicitly to every parameter and variable used through the public dictionaries. See APQP and Canonicalization for the supported APQP/APLP form.

No manual standardization required

Write the model naturally with CVXPY and pass the original cvxpy.Problem to ValueFuncLayer. DiffAPQP performs the compatible canonicalization and standard-form extraction internally.

Constructor

ValueFuncLayer(
    problem,
    max_n_cores=os.cpu_count(),
)

problem : cvxpy.Problem : Supported continuous APQP/APLP problem described above.

max_n_cores : int, default os.cpu_count() : Maximum number of outer sample workers. 1 selects serial execution and -1 is converted to os.cpu_count(). This setting does not establish CPU affinity. For max_n_cores > 1, forward samples use a thread pool.

Construct a layer once and reuse it across calls. Construction canonicalizes the problem and, when parallel execution is enabled, creates persistent worker pools.

Platform parallelism

Forward samples use a thread pool on every supported platform. On Linux, the current implementation retains the existing fork-based backward pool for benchmark compatibility, although value-function differentiation does not use it. On Windows and macOS, this unused process pool is not created, so parallel ValueFuncLayer calls do not require a multiprocessing entry point guard.

Persistent worker pools can be released explicitly with layer.close(). ValueFuncLayer can also be used as a context manager when its lifetime is limited to a block.

Forward call

value_batch, primal_dict, dual_eq, dual_ineq, raw_solutions, time_batch = layer(
    param_dict,
    idx_list=None,
    warm_start=False,
    update=False,
    solver="OSQP",
    verbose=False,
    solver_args={},
)

Batched parameters

param_dict maps every original CVXPY parameter name to a floating-point torch.Tensor. Every tensor must have the same leading batch dimension. Mathematically, one batch row supplies a value of \(\hat y\). DiffAPQP canonicalizes that instance, solves for the standard-form decision \(\tilde z^\star\), and returns \(\alpha(\hat y)\) in value_batch and the retrieved original decisions \(\hat z^\star=\mathcal{R}(\tilde z^\star)\) in primal_dict.

Original CVXPY shape Accepted batched shape
scalar () (batch,) or (batch, 1)
vector (n,) (batch, n)
matrix (m, n) (batch, m, n)
higher-dimensional shape (batch, *parameter.shape)

Matrix and higher-dimensional values are flattened internally using CVXPY's column-major convention. The current solver path is CPU based: inputs are converted to NumPy for CVXPY, and public outputs are CPU tensors or objects. Use a consistent floating dtype for all parameters.

The first forward call creates one reusable CVXPY problem per batch slot and therefore establishes the largest supported batch size for that layer instance. Later calls may use the same or a smaller batch, but should not exceed the first batch size.

Forward options

idx_list : list[int] | None, default None : Stable dataset ids corresponding one-to-one with the current batch. It is required when warm_start=True for OSQP, SCS, QPALM, or PROXQP and must have the same length and order as the batch.

warm_start : bool, default False : Request a warm start. For the custom solver paths, DiffAPQP stores solver-native iterates by sample id after each successful call.

update : bool, default False : Request in-place solver-data updates. The first call for a batch slot builds the solver workspace; update becomes effective on later calls.

solver : str, default "OSQP" : Case-insensitive CVXPY solver name.

verbose : bool, default False : Forward solver verbosity.

solver_args : dict, default {} : Solver-specific options. Values supplied here override DiffAPQP defaults key by key. See Solvers for the configured profiles.

Warm-start and update lifecycle

DiffAPQP's explicit sample-indexed cache and in-place update hooks cover OSQP, SCS, QPALM, and PROXQP when the custom CVXPY fork is installed. Their mathematical and repeated-solve motivation is described in the efficient forward pass.

  1. On the first call with warm_start=True, cache entries are empty, so the solve initializes both the workspace and the per-sample iterates.
  2. A later call with the same idx_list reuses the iterates associated with each sample id.
  3. update=True reuses a batch slot's solver workspace after that slot has completed its first solve.
  4. Update-only calls do not require idx_list; idx_list is needed for the sample-indexed warm-start cache.

For other solvers, warm_start is passed through the standard CVXPY interface; idx_list is not used and update does not activate a DiffAPQP-specific data update path. See Installation for the custom fork.

Differentiation

Only value_batch is autograd connected. The backward pass uses the value-function derivative directly from the optimal primal and dual solution. See Value-Function Differentiation for the envelope-theorem derivation and comparison with the solution-map adjoint.

For an optimization parameter \(\hat y_i\),

\[ \mathrm{D}_{\hat y_i}\alpha(\hat y) = \tilde z^{\star,T}Q_i +d_i^T -\tilde\nu^{\star,T}B_i -\tilde\lambda^{\star,T}H_i, \]

where \(Q_i\), \(d_i\), \(B_i\), and \(H_i\) describe how \(\hat y_i\) enters the canonical linear objective, objective offset, equality right-hand side, and inequality right-hand side, respectively. The canonical equality and inequality duals are \(\tilde\nu^\star\) and \(\tilde\lambda^\star\). PyTorch's upstream gradient is applied sample by sample, so reductions and weighted combinations of value_batch differentiate normally. No KKT adjoint solve is needed.

The primal and dual outputs from ValueFuncLayer are provided for inspection only and are not autograd connected. Use SolMapLayer when the loss depends on the optimizer solution.

Returns

value_batch : torch.Tensor, shape (batch,) : Autograd-connected value \(\alpha(\hat y)\) for each sample.

primal_dict : dict[str, torch.Tensor] : Non-differentiable retrieved decisions \(\hat z^\star\), keyed by original CVXPY variable name. A variable with original shape shape is returned with shape (batch, *shape); scalar variables have shape (batch,).

dual_eq : torch.Tensor, shape \((\mathrm{batch},n_{\mathrm{eq}})\) : Non-differentiable canonical equality duals \(\tilde\nu^\star\).

dual_ineq : torch.Tensor, shape \((\mathrm{batch},n_{\mathrm{in}})\) : Non-differentiable canonical inequality duals \(\tilde\lambda^\star\).

raw_solutions : list : One solver-native result object per sample. Its type and fields depend on the selected CVXPY solver interface and should be treated as backend specific.

time_batch : dict : Batch wall-time measurements and per-sample pipeline timings:

{
    "canon_time": float,
    "solve_time": float,
    "sample_times": [
        {
            "worker_total_time": float,
            "get_problem_data_time": float,
            "solve_via_data_time": float,
            "unpack_time": float,
        },
        ...
    ],
}

sample_times contains one dictionary per sample in batch order:

Field Meaning
worker_total_time Wall time inside the solve worker, covering solve_via_data, result unpacking, status checks, and small Python overhead. It excludes get_problem_data_time.
get_problem_data_time Wall time for CVXPY's get_problem_data() call, which prepares the solver data and reduction-chain metadata for that sample during the serial preparation phase.
solve_via_data_time Wall time for CVXPY's solve_via_data() call, including solver-interface overhead and the backend's native optimization solve.
unpack_time Wall time for CVXPY's unpack_results() call, which maps the raw solver result back to the standardized CVXPY problem and populates primal, dual, status, and objective values.

canon_time is the serial wall time used to prepare CVXPY data for all samples. solve_time is the wall time of the serial or parallel solve phase, not the sum of sample times. During parallel solve, nested BLAS/OpenMP threads are limited to one to reduce oversubscription.

Errors and warnings

  • ValueError is raised for unsupported problems, missing parameters, invalid parameter types or shapes, inconsistent batch sizes, incompatible canonical variables, or a failed solver status.
  • AssertionError is raised when a custom-path warm start is requested without idx_list.
  • optimal_inaccurate is accepted with a warning; other non-optimal statuses raise ValueError.
  • A UserWarning reports custom warm-start/update requests when the custom CVXPY fork is not detected.

Example

import cvxpy as cp
import torch

from diffapqp import ValueFuncLayer

x = cp.Variable(2, name="x")
q = cp.Parameter(2, name="q")
problem = cp.Problem(
    cp.Minimize(0.5 * cp.sum_squares(x) + q @ x),
    [x >= 0, x <= 10],
)

layer = ValueFuncLayer(problem, max_n_cores=1)
q_batch = torch.tensor(
    [[-1.0, -2.0], [0.5, -1.5]],
    dtype=torch.double,
    requires_grad=True,
) # no standardization required

value_batch, primal_dict, *_ = layer(
    {"q": q_batch},
    solver="OSQP",
)
loss = value_batch.mean()
loss.backward()

assert q_batch.grad.shape == q_batch.shape
assert not primal_dict["x"].requires_grad

In this example, q_batch supplies \(\hat y\), value_batch contains \(\alpha(\hat y)\), and primal_dict["x"] contains the retrieved decisions \(\hat z^\star\). The Python names follow the original CVXPY model and need not match the mathematical symbols.

Repeated warm-start/update calls:

sample_ids = [0, 1]

# First call initializes workspaces and sample caches.
layer(
    {"q": q_batch},
    idx_list=sample_ids,
    warm_start=True,
    update=True,
    solver="OSQP",
)

# Later calls with the same ids can reuse both.
value_batch, *_ = layer(
    {"q": q_batch},
    idx_list=sample_ids,
    warm_start=True,
    update=True,
    solver="OSQP",
)

See also

References