reimplementing Schwarke et al. in MuJoCo MJX, so the environment, the physics and the training are one JAX program. first ANYmal, then a 29-dof humanoid.
if you want the theory first (why backprop through a simulator, what SHAC is, why contacts kill the gradient), read Differentiable Robotics. this page is just my process.
Schwarke et al. train locomotion policies with SHAC: unroll the simulator for a short window, backprop the reward straight into the policy, and let a critic cover everything after the window. the blocker for legged robots is that the contact solver has a hard pos < 0 switch, so the gradient is zero at every footstep. they replace it with a sigmoid, and that's enough to train a walking ANYmal that runs on the real robot.
i wanted it in MJX so the whole thing (env, physics, policy, optimizer) is a single jit. i started from Andrew Luo's jax_shac, which had SHAC working on toy envs but had given up on ANYmal.
MJX is not meant to be differentiated through contacts, so two things had to change. both are monkey-patches applied at import time, before anything gets jitted.
1. the contact switch. three functions in mjx/_src/constraint.py (frictionless, pyramidal and elliptic cones) have the line active = pos < 0. the patch grabs their source, swaps that line for a sigmoid, and re-execs them inside the module's own namespace so all the helpers are still in scope:
active = pos < 0 # original: gradient dies at contact
active = jax.nn.sigmoid(-pos * kappa) # patched, kappa = 300it raises if the pattern isn't found, so an MJX update fails loudly instead of silently training with hard contacts.
later i added a straight-through mode: hard switch in the forward pass, sigmoid derivative in the backward pass. the simulation is bit-identical to stock MJX and only the gradient is smoothed.
2. the Newton solver. the constraint solver's outer loop is a jax.lax.while_loop, and JAX can't reverse-mode differentiate a while loop. with iterations > 1 you simply can't take a gradient. the patch reroutes it through a bounded scan-based loop (MJX already has one for its linesearch), which is differentiable.
for future reference: the ANYmal example was able to train on a single Newton step, adding the jax.lax.while_loop tweak was more necessary for training the G1.
| param | value |
|---|---|
| envs | 512 |
| unroll length | 32 (~1.3 trot cycles) |
| kappa | 300 |
| iterations | 20 000 (~2 h on an RTX 3090) |
linear velocity tracking reached 0.73 (0 = standing still, 1 = perfect tracking), and the policy loss was still going down when i stopped.
the main difference from the paper: i measured the smoothed MJX gradients against finite differences and got a cosine similarity of 0.374. noisy direction, so i needed 32-step unrolls where the paper uses 12, longer windows average out more of the noise.
things that mattered more than i expected:
optax.clip_by_global_norm(1.0) (what the original PyTorch DiffRL does) fixed a lot.nan_to_num keeps the forward pass finite but the backward pass still runs through the explosion. stop_gradient on the carried state when done cuts it.after ANYmal i moved to a 29-dof Unitree G1 (straight-through contacts, 64 envs, 32-step unrolls, cosine learning-rate decay). a biped is a lot less forgiving than a quadruped: it can't just stand there on four legs while it figures things out. most of the work was reward design:
and one very differentiable-sim bug: the gait frequency used jnp.linalg.norm(vel_cmd). at a zero command the gradient of the norm is stop_gradient on the command fixed it.
i kept a running list of every place where training and deployment disagree. a few that surprised me:
mjx.step (body positions, velocities) are from before the last integration, so in training part of the observation was 2 ms stale. the robot's sensors aren't. it's inside the training noise, so i left it.I'm yet to test it properly without the harness, I don't want to break the G1 that soon :P