Differentiable simulation¶
Reinforcement learning usually treats the environment as a black box: the
agent sees rewards, never how they depend on its actions, and estimates that
dependence from many trials. When the simulator is written in JAX, the
dependence can be computed exactly instead. Run the controller in closed
loop, sum the rewards, and jax.grad gives the gradient of that sum with
respect to the controller's parameters, back through every step of the
physics.
This is the analytic policy gradient. It works well when the dynamics are smooth and the horizon is short to moderate: there is no critic to learn and no noise to average out, so each update is precise. It works poorly through contacts and other discontinuities, and over long horizons, where gradients can explode or vanish.
Ajax supports it at three levels: environments whose transitions keep their gradients, families of systems and tracking tasks to train on, and the APG agent, which includes the in-context controller of Busetto et al., 2024.
Differentiable environments¶
Ajax's collectors cut gradients at every environment step, as reinforcement
learning expects. gymnax (from version 1.0) also detaches its transitions by
default; a few environments let you switch that back on.
with_transition_gradients(env) does so, through any Ajax wrappers around
the environment, and raises for an environment that cannot, instead of
silently returning zero gradients. Today these are Pendulum,
MountainCarContinuous, PointRobot, Reacher and Swimmer.
closed_loop_rollout runs a policy against such an environment for a fixed
number of steps with no gradient cut anywhere. The policy is any function
(carry, obs, resets) -> (action, carry), where carry is its memory
(None for a memoryless one) and resets marks episode starts. Here, plain
gradient ascent tunes the two gains of a PD controller over a family of
pendulums:
import gymnax
import jax
import jax.numpy as jnp
from ajax.environments.differentiable import closed_loop_rollout, with_transition_gradients
from ajax.environments.system_class import UniformPerturbation
env, params = gymnax.make("Pendulum-v1")
env = with_transition_gradients(env)
systems = UniformPerturbation(params, fields=("m", "l"), scale=0.2) # mass, length +-20 %
def mean_return(gains, key):
def pd(carry, obs, resets):
theta = jnp.arctan2(obs[:, 1], obs[:, 0])
torque = -(gains[0] * theta + gains[1] * obs[:, 2])
return jnp.clip(torque, -2.0, 2.0)[:, None], carry
system_key, rollout_key = jax.random.split(key)
batch = systems.sample(system_key, 16) # 16 pendulums, one per parallel env
rollout, _ = closed_loop_rollout(pd, None, rollout_key, env, batch, horizon=50)
return rollout.reward.sum(axis=0).mean() # rollout leaves are (horizon, envs, ...)
gains = jnp.array([1.0, 0.1])
step = jax.jit(jax.value_and_grad(mean_return))
for i in range(20):
value, grad = step(gains, jax.random.PRNGKey(i))
gains = gains + 1e-3 * grad # ascend the return
print(value, gains)
closed_loop_rollout hands the policy's actions to the environment as they
are, so the policy above returns torques in the environment's units; APG
maps its controller's [-1, 1] actions onto the bounds itself (below).
This path is gymnax only: Brax and MuJoCo Playground environments take no
per-step parameters and have their own gradient machinery. For those,
differentiable_reset=True in build_env_from_id lets gradients flow
through the automatic reset as well (see Environments).
Families of systems¶
A controller tuned for one plant may fail on its neighbours. A system class is a distribution over an environment's physical parameters, so training can cover a whole family:
UniformPerturbation(nominal, fields, scale)draws each listed parameter asp · (1 + U(−scale, scale)), independently, leaving the others nominal; a vector or matrix field is scaled by one factor per system. Busetto et al. build their meta-dataset this way, withscale=0.05. A misspelt field raises when the class is built.FixedSystem(nominal)always returns the nominal system; it is the first stage of a curriculum.- Subclass
SystemClassand implementsample(rng, n)for anything else.
sample(rng, n) returns parameters with a leading axis of n, one system
per parallel environment. Ajax's gymnax reset and step notice that axis
and simulate each environment with its own system. APG trains on such
parameters (its system_class, or a sample passed as env_params), and
DreamerV3 and TDMPC2 take a sample as env_params. The other agents
compile env_params into their collection step as a constant, so they take
one system for every copy and refuse a sample when built.
Tracking tasks: model-reference control¶
In model-reference control, the controller makes a plant's measured output
y follow the output of a reference model driven by a reference signal
r: say, a first-order lag towards each new setpoint. Three pieces turn any
gymnax environment into such a task:
StepReference(horizon, min_value, max_value, min_duration, max_duration)draws piecewise-constant references: steps of random height held for a random number of steps.LinearReferenceModelis the reference model,x' = A x + B r,y_d = C x + D r.LinearReferenceModel.first_order()defaults to the paper's model.ModelReferenceWrapper(env, reference, model, output_fn=...)is the task as the controller sees it: the observation is the tracking error and the previous action,[e, u_prev](optionally followed by the plant's own observation), and the reward is−‖y_d − y‖². The undiscounted return is minus the matching cost, andsqrt(−return / horizon)is the paper's M-RMSE. The error is computed from the observation the plant returns, so sensor noise on the plant (anObservationNoiseWrapperunder the task) reaches the controller and the reward alike. On the step a plant episode ends, the reward scores the plant's last output before its reset, and the final observation a time limit bootstraps on is in the controller's view too, so any agent can train on the task.
All three are plain jnp, so the task stays differentiable when the plant
is. ajax.wrappers.InitialStateWrapper(env, fn) sets each episode's initial
state, for tasks that fix it, as the paper does.
Training a controller with APG¶
APG puts these together. Each update samples n_envs systems from the
system class, runs the controller on them for horizon steps, and takes one
optimiser step on minus the mean return. One update consumes
n_envs · horizon of n_timesteps. The controller is deterministic and its
actions are bounded with tanh (squash=True), then mapped onto the
environment's bounds, as every agent's are
(Actions); an unsquashed controller is clipped to
[-1, 1] first. The map is differentiable, so gradients flow through it.
A controller made of a small MLP and a learnable PID output layer, trained to track step references across pendulums whose mass and length vary by 30 %:
import gymnax
import jax.numpy as jnp
from ajax import APG
from ajax.agents.APG import PIDHeadConfig
from ajax.environments.model_reference import (
LinearReferenceModel,
ModelReferenceWrapper,
StepReference,
)
from ajax.environments.system_class import UniformPerturbation
from ajax.wrappers import InitialStateWrapper
plant, params = gymnax.make("Pendulum-v1")
params = params.replace(g=0.0) # no gravity: a pure inertia to steer
def at_rest(key, state, params): # every episode starts at angle 0, at rest
return state.replace(theta=jnp.asarray(0.0), theta_dot=jnp.asarray(0.0))
task = ModelReferenceWrapper(
InitialStateWrapper(plant, at_rest),
StepReference(horizon=100, min_value=-0.5, max_value=0.5, min_duration=20, max_duration=50),
LinearReferenceModel.first_order(a=0.9, b=0.1, c=1.0, d=0.0),
output_fn=lambda obs: jnp.arctan2(obs[1], obs[0]), # the measured output: the angle
)
systems = UniformPerturbation(params, fields=("m", "l"), scale=0.3)
agent = APG(
task,
system_class=systems,
n_envs=8,
horizon=100,
actor_architecture=("64", "relu"),
pid=PIDHeadConfig(), # close the controller with a learnable PID layer
)
state, aux = agent.train(seed=0, n_timesteps=8 * 100 * 200) # 200 updates
print(aux.m_rmse[0, 0], aux.m_rmse[0, -1]) # (seeds, updates): falls from about 1.7 to 0.45
train always returns every update's loss, matching_loss, m_rmse,
grad_norm and timestep, with a seed axis first. With a logging_config, it also
evaluates on freshly drawn systems every log_frequency steps. The
APG page lists every hyperparameter.
Two defaults depart from a plain implementation, both for stability on this
test bed: gradients are clipped to a global norm of 1 (max_grad_norm;
without it, training across a system class diverged), and actions are
tanh-bounded.
The in-context controller¶
Busetto, Breschi, Forgione, Piga and Formentin, One controller to rule them
all, 2024 (arXiv:2411.06482), train one
controller for a whole class of systems. It never learns which system it
faces explicitly; it infers it from the history of tracking errors and
inputs it has seen in the current episode, as a transformer reads its
context. APG.contextual_controller(task, system_class) builds that
configuration:
- a decoder-only transformer over the episode's history (8 layers, 4 heads,
width 128, context 100), with a linear input embedding
(
actor_architecture=()), closed by a PID output layer; - one system per update (
n_envs=1) and a horizon of 100; - Adam with betas (0.9, 0.95) (AdamW once
weight_decayis above its default of 0), a 5,000-update linear warm-up, then cosine decay to a tenth of the learning rate.
Ajax departs from the reference code in three places, each backed by runs on
the pendulum tracking test bed: actions are tanh-bounded, gradients are
clipped, and the PID layer starts as the identity (gains 1, 0, 0) rather than
all ones. Those runs predate the map of actions onto the environment's
bounds, when the pendulum's torque was capped at 1 of its 2. A fourth
difference is not backed by runs: the transformer's LayerNorms keep a bias
and an epsilon of 1e-6, where the reference's have no bias and 1e-5
(buswayne/in-context-controller at 7eb094ce17: transformer_onestep.py:33
and :156, train_onestep_lin_CL.py:103).
The paper trains in stages of growing diversity (its Algorithm 2): one system
first, then the class. train_curriculum chains CurriculumStage(agent,
n_timesteps) objects through Ajax's resume path, carrying the parameters
over and, by default, restarting the optimiser and its schedule at each
stage (reset_optimizer=True). The stages must share n_envs, the network
and the output head; each may have its own task and system class. The paper
trains each stage to a loss tolerance; Ajax gives each a fixed budget.
# doc: skip (compiles an 8-layer transformer and trains 700,000 steps)
import gymnax
from ajax import APG
from ajax.agents.APG import CurriculumStage, train_curriculum
from ajax.environments.system_class import FixedSystem, UniformPerturbation
# `task` as in the APG example above
_, params = gymnax.make("Pendulum-v1")
systems = UniformPerturbation(params, fields=("m", "l"), scale=0.05)
stages = [
CurriculumStage(APG.contextual_controller(task, FixedSystem(params)), n_timesteps=200_000),
CurriculumStage(APG.contextual_controller(task, systems), n_timesteps=500_000),
]
results = train_curriculum(stages, seed=0) # one (state, aux) per stage
The PID output layer¶
PIDOutputHead closes a network with a learnable PID. The network emits a
signal z_k, and the layer applies
u_k = K_P z_k + K_I Σ_{j≤k} z_j + K_D (z_k − z_{k−1})
with learnable gains, one triple per action dimension. The integral term helps remove steady-state error on step references, and Busetto et al. report faster training with it. Like a memory block, the layer carries a state (the running sum and the previous signal), reset at episode starts.
PIDHeadConfig sets it up for APG: use_p, use_i, use_d switch each
term on (a disabled term has no parameter); kp_init=1, ki_init=0,
kd_init=0 start it as the identity; anti_windup stops the integral from
growing while the output is beyond that magnitude in the same direction
(about 3 suits a tanh bound, where tanh is within 0.5 % of its limit).
Ajax has two PID components, which do different jobs:
PID output layer (pid=PIDHeadConfig()) |
PID policy (Networks) | |
|---|---|---|
| Acts on | the network's own output signal | the tracking-error terms in the observation |
| Role | the last layer of a larger controller | the whole actor |
| Agents | APG | SAC, TD3, REDQ, ASAC, AVG, PPO, APO |
| Deploys as | the network plus the layer | a plain PID with the learned gains |
Your own controller¶
APG(controller_factory=...) trains any Flax module you build, given the
agent's context (env_args, network_args, action_dim, pid, squash).
The module must follow the Controller contract in
ajax/agents/APG/networks.py: a
static stateful attribute, initialize_carry(rng, batch_size) when
stateful, and a call that returns a deterministic distrax distribution
(plus the new carry when stateful). This lets downstream projects train
their own architectures with the same trainer.
APG also takes extensions in the actor_loss phase, whose dictionary holds
the rollout, the controller's actor_params and the per-system
returns, plus the phases every agent runs (pretrain, post_update,
eval_metrics). See Extensions.
See also¶
- APG: hyperparameters, notes and results.
- Environments: building environments and wrappers.
ajax/environments/differentiable.py,system_class.pyandmodel_reference.py: the source, with the paper's equations in the docstrings.