GPU-accelerated training of parametric decision rules through multi-stage optimization, using ExaModels.jl and solvers like MadNLP.jl.
This package replicates the core functionality of DecisionRules.jl but replaces JuMP with ExaModels for the optimization backend. JuMP relies on MathOptInterface operations that are not differentiable on GPU and cannot exploit GPU-native sparse linear solvers. ExaModels provides a fully SIMD-compatible algebraic modeling layer whose parameters can be updated at runtime via ExaModels.set_parameter!, enabling efficient repeated solves on GPU with MadNLPGPU (CUDSS-backed interior point).
In the Two-Stage Deep Decision Rule (TS-DDR) framework, a neural-network policy predicts target state trajectories that are projected onto the feasible set by solving a parametric NLP (the deterministic equivalent). Training uses the envelope theorem: dual multipliers on the target constraints give the policy gradient without differentiating through the solver.
The inner NLP solve is the bottleneck. By formulating it in ExaModels and solving with MadNLP + CUDSS on GPU, DecisionRulesExa.jl achieves significant speedups over the CPU JuMP-based workflow in DecisionRules.jl, especially for large-scale problems (AC-OPF, multi-stage hydro scheduling).
using Pkg
Pkg.add(url="https://github.com/LearningToOptimize/DecisionRulesExa.jl.git")using DecisionRulesExa
using ExaModels, MadNLP, Flux, Random
Random.seed!(1)
T = 8 # horizon
nx = 1 # state dimension
# Build a simple linear-tracking NLP on CPU
prob = build_linear_tracking_problem(
horizon = T,
nx = nx,
backend = nothing, # CPU (use CUDABackend() for GPU)
slack_penalty = 10.0,
u_bounds = (-2.0, 2.0),
)
# LSTM policy: input = [w_t ; x_{t-1}], called once per stage
policy = StateConditionedPolicy(nx, nx, nx, [64, 64])
# Uncertainty sampler (returns flat vector of length T*nw)
sampler() = Float32.(0.1 .* randn(T * nx))
# Train with TS-DDR policy gradient
train_tsddr(
policy,
Float32.([1.0]), # initial state
prob,
prob.p_x0,
prob.p_target,
prob.p_w,
sampler;
num_batches = 100,
num_train_per_batch = 4,
optimizer = Flux.Adam(1f-3),
madnlp_kwargs = (print_level = MadNLP.ERROR, tol = 1e-6),
)Note on the uncertainty parameter:
train_tsddrwrites the full sampled trajectory (lengthT * nw) intop_uncertaintywithExaModels.set_parameter!, which enforces an exact size match (ExaModels ≥ 0.11). Thep_wbuilt bybuild_deterministic_equivalent/build_linear_tracking_problemholds only the(T - 1) * nwdynamics entries, so fortrain_tsddryour NLP needs an uncertainty parameter of lengthT * nw(as the Hydro example'sp_inflowis). See the"train_tsddr open-loop smoke test"testset intest/runtests.jlfor a minimal full-length variant of the problem above.
For GPU, replace backend = nothing with backend = CUDABackend() and add linear_solver = CUDSSSolver to madnlp_kwargs.
DecisionRulesExa.jl is model-first: you describe your multi-stage NLP in ExaModels, then the package handles simulation and training.
For a custom problem you need:
- An ExaModels deterministic-equivalent NLP with parametric initial state, uncertainty trajectory, and target trajectory. Target constraints must be added last so their multipliers form a contiguous slice of
result.multipliers. - An uncertainty sampler
() -> w_flatreturning a flatFloat32/Float64vector of lengthT * nw. - A Flux policy (LSTM or MLP) mapping
(w_t, x_{t-1})to targetx_tat each stage.
The package provides build_deterministic_equivalent for generic problems and build_linear_tracking_problem as a ready-made demo. For domain-specific models, build the ExaModels NLP directly; examples/BatteryStorageOPF/ contains an AC-OPF battery example.
The usual TS-DDR deterministic equivalent uses slack-penalized target constraints,
x_t - pi_theta(w_t, x_{t-1}) = delta_t,
objective += rho * penalty(delta_t).
This is the right default for open-loop target trajectories and for cases where the policy can request states that are not reachable from the previous realized state. The target multipliers are then gradients of the penalized projection problem, so their quality depends on the penalty calibration.
For policies whose output is guaranteed to lie in a one-stage reachable state set, a stricter formulation is possible:
x_t = pi_theta(w_t, x_{t-1}), pi_theta(w_t, x_{t-1}) in R(w_t, x_{t-1}).
In that case the deterministic equivalent does not need target slack variables or target penalties. The multiplier on the equality is the local envelope sensitivity of the true stage problem with respect to the policy-imposed next state, not the sensitivity of a penalized approximation. This is useful when:
- users can define a differentiable or piecewise differentiable map into a subset of the one-stage reachable set;
- total recourse is guaranteed by the model for every state produced by that map.
The package supports two ways to make strict mode safe:
- Embedded strict DE (
train_tsddr_embedded,build_embedded_deterministic_equivalent) evaluates the policy inside the NLP against realized states, so the policy always sees the state from which its next target must be reachable. - Regular strict DE with reachable rollout computes targets before solving
the NLP, but starts from the true initial state and feeds the previous target
back to the reachable policy. If
x̂_t ∈ R(x̂_{t-1}, w_t)andx̂_0 = x_0, the full target path is feasible by induction. The strict equality then forces the realized path to equal that reachable target path.
The published hydro case study uses the second path; see
examples/HydroPowerModels.
Do not use strict equality for a generic open-loop target policy. For unreachable targets, the slack-penalty formulation is the robust fallback.
The reachable map depends on the incoming state, and that dependence must be differentiated. Declaring the interval endpoints non-differentiable still produces a gradient and still lowers the loss, while descending a different direction — measured on the hydro case, 6% of the true magnitude and 48 degrees off. Check the complete actor gradient against finite differences before drawing hyperparameter conclusions from it.
The hydro reachable policy keeps recurrence over inflows only. Optional
combiner_layers / DR_HEAD_LAYERS add a nonlinear feed-forward map from
[encoded_inflow; reservoir_state] to targets without adding recurrence over
the state input.
When training samples are independent, multiple NLP instances can be solved concurrently on the same GPU. Pass a problem_pool of independent ExaModels problem copies to train_tsddr:
pool = [(prob, prob.p_x0, prob.p_target, prob.p_w)]
for _ in 2:num_workers
p = build_my_problem(...)
push!(pool, (p, p.p_x0, p.p_target, p.p_w))
end
train_tsddr(policy, x0, prob, ..., sampler;
problem_pool = pool,
num_train_per_batch = num_workers,
)Each pool entry gets its own MadNLP solver on a dedicated thread, with CUDA handles properly bound.
train_tsddr can optionally train a scalar critic C(w, xhat). The critic does
not replace the deterministic-equivalent solve in the default
:control_variate mode: solved target-constraint multipliers remain the
actor's primary local sensitivity signal. Instead, the critic supplies a learned
rollout-value guide and optional control variate.
For critic fitting, the preferred target is the stage-wise rollout objective via
RolloutCriticTarget, with policy_state = :target by default. This matches the
differentiable target recurrence used by the actor while evaluating the true
stage-by-stage objective. Set policy_state = :realized to train the critic on
closed-loop realized-state rollout labels. For ablations, use
DeterministicEquivalentCriticTarget() or critic_training_target = :deterministic_equivalent to fit the deterministic-equivalent objective instead.
The default control_variate = NoCriticControlVariate() recovers the original
dual-only behavior. A scalar critic can be attached with:
input_dim = length(x0) + 2 * T * nx
critic = Chain(
Dense(input_dim => 128, tanh),
Dense(128 => 128, tanh),
Dense(128 => 1),
)
cv = ScalarCriticControlVariate(
critic;
featurizer = default_critic_featurizer,
value_loss_weight = 1.0,
gradient_loss_weight = 0.0,
)
critic_target = RolloutCriticTarget(
stage_problem;
horizon = T,
n_uncertainty = nx,
set_stage_parameters! = set_stage_parameters!,
realized_state = realized_state,
objective_no_target_penalty = objective_no_target_penalty,
policy_state = :target,
objective_value = :objective_no_target_penalty,
)
train_tsddr(
policy, x0, prob, prob.p_x0, prob.p_target, prob.p_w, sampler;
control_variate = cv,
critic_training_target = critic_target,
critic_rollout_samples_per_batch = 1,
actor_gradient_mode = :control_variate,
critic_cv_weight = 1.0,
num_cheap_critic_samples_per_batch = 32,
critic_updates_per_batch = 1,
critic_optimizer = Flux.Adam(1f-3),
)The critic loss combines value matching against the selected target objective and optional gradient matching against target multipliers:
value_loss_weight * mse(C(w, xhat), objective) +
gradient_loss_weight * mse(gradient(xhat -> C(w, xhat), xhat), lambda)Set either weight to zero for objective-only or gradient-only critic training. For rollout targets, objective-only critic training is usually the clean default, because DE target multipliers are not exact gradients of the realized rollout objective. If objectives and multipliers have very different magnitudes, prefer a custom featurizer and tuned loss weights; the Hydro example normalizes volumes and inflows before critic evaluation.
Actor modes:
:control_variate: subtractscritic_cv_weight * gradient_xhat(C)from the solved dual signal and adds the critic actor gradient back on solved or cheap rollout samples. This is the recommended mode when dual multipliers are reliable.critic_cv_weight = 0.0recovers dual-only updates.:surrogate: uses a practical hybrid of solved dual gradients and critic actor gradients, controlled bydual_actor_weightandcritic_actor_weight. This is useful when raw dual/subgradient signals are empirically noisy or unstable, but it is no longer a pure unbiased control-variate estimator.
num_cheap_critic_samples_per_batch draws additional uncertainty samples,
rolls out the current policy, and evaluates the critic actor term without any
extra MadNLP or ExaModels solve.
RolloutEvaluation evaluates the policy in deployment semantics (stage-by-stage sequential solves) on held-out scenarios:
eval = RolloutEvaluation(
stage_problem, x0, eval_scenarios;
horizon = T,
n_uncertainty = nw,
set_stage_parameters! = my_stage_setter!,
realized_state = my_realized_state,
stride = 25,
policy_state = :realized,
)Supports parallel evaluation across a pool of stage problems via stage_problem_pool, and dynamic scenario count via active_scenarios.
DecisionRules.jl implements the same TS-DDR algorithm using JuMP + DiffOpt for CPU-based training. It supports stage-wise decomposition, multiple shooting, and integer strategies that are not yet ported here.
DecisionRulesExa.jl focuses on the deterministic-equivalent training mode with GPU acceleration. Choose this package when:
- Your NLP is large enough that GPU acceleration matters (e.g., AC-OPF with hundreds of buses)
- You need to run many training samples per gradient step
- You want to leverage CUDA-native sparse solvers (CUDSS)
Choose DecisionRules.jl when:
- You need stage-wise or multiple-shooting decomposition
- Your problem is naturally expressed in JuMP
- You need DiffOpt-based sensitivity computation
- You want CPU-only deployment
examples/end_to_end_cpu.jl and examples/end_to_end_gpu.jl train a policy on a
small linear tracking problem on the CPU and on a GPU. The two case studies of the
paper, examples/HydroPowerModels/ and examples/BatteryStorageOPF/, hold the GPU
trainers and the training schedules; the cases, the SDDP baselines and the JuMP
engines are in the same directories of DecisionRules.jl. To reproduce a case
study, start from the
examples README
of DecisionRules.jl. How the code of both packages is organized is described in
Reading the code.
| Path | Purpose |
|---|---|
src/DecisionRulesExa.jl |
Module entrypoint and public exports |
src/policy.jl |
MLP, state-conditioned LSTM policies, bounded target policies, nonlinear target heads |
src/deterministic_equivalent.jl |
Generic open-loop deterministic-equivalent builder and solve helpers |
src/embedded_deterministic_equivalent.jl |
Generic embedded-policy deterministic equivalent with nonlinear oracle |
src/training.jl |
train_tsddr, embedded training, solver retry/warm-start handling |
src/rollout.jl |
Stage-wise rollout evaluation for ExaModels problems |
src/critic_control_variate.jl |
Scalar critic/control-variate helpers |
src/utils.jl |
Indexing and small shared utilities |
examples/end_to_end_cpu.jl |
Minimal CPU training demo |
examples/end_to_end_gpu.jl |
Minimal GPU training demo |
examples/HydroPowerModels/ |
Bolivian hydrothermal case study: GPU trainer, lineage driver, training schedules |
examples/BatteryStorageOPF/ |
Battery-storage AC-OPF case study: GPU trainer |
test/runtests.jl |
Unit and smoke tests |
If you use this package in academic work, please cite:
@article{rosemberg2024efficiently,
title={Efficiently Training Deep-Learning Parametric Policies using Lagrangian Duality},
author={Rosemberg, Andrew and Street, Alexandre and Vallad{\~a}o, Davi M and Van Hentenryck, Pascal},
journal={arXiv preprint arXiv:2405.14973},
year={2024}
}MIT. See LICENSE.