Skip Connections With Inertia, in JAX/Flax NNX

· 18 min read

#ml#resnet#momentum-resnet#neural-ode#jax#flax#nnx#implementation#dynamical-systems#symplectic-integrators#deep-learning

Part 1 of 5Networks as Integrators
  1. 1Your Skip Connection Is Half of Newtonthis post's explainer
  2. 2Transformers With a Velocity Ledger
  3. 3A Network That Conserves Energy
  4. 4Backprop Without the Memory
  5. 5Depth on Demand
Explainer companionYour Skip Connection Is Half of NewtonWant the full intuition first? This is the runnable companion to the explainer.Read the explainer

The explainer read a residual block x+F(x)x + F(x) as one forward-Euler step and asked what the missing half of Newton’s law, a velocity state, buys a trained network. This is the implementation: the two integrators in eight lines of the same code, the momentum residual network as a Flax NNX module with one extra line of state, the training runs on the task a first-order flow cannot get exactly right, and the rewind that reconstructs the input from the output until arithmetic runs out. The published numbers are from scripts/momentum_resnet.py; every code block below runs as written in scripts/momentum_nnx_check.py, and every figure is rendered from real runs by scripts/render_momentum_gifs.py.

Prefer a notebook? Run the whole thing on Kaggle: the same code, block by block, with the math written out and every figure reproduced from a real run.

Two integrators, eight lines apart

Before any network, the physics fact the whole post leans on, in code small enough to trust. A second-order system carries two states, position and velocity. Forward Euler updates both from the stale state; leapfrog interleaves them, half a velocity kick, a position drift with the fresh velocity, half a kick to finish. Same force, same step size:

def accel(r):                                  # Kepler, GM = 1
    return -r / np.linalg.norm(r) ** 3

def euler(r, v, dt):
    a = accel(r)
    return r + dt * v, v + dt * a              # both updates from the stale state

def leapfrog(r, v, dt):
    v = v + 0.5 * dt * accel(r)                # half kick
    r = r + dt * v                             # drift with the FRESH velocity
    return r, v + 0.5 * dt * accel(r)          # half kick

Before anyone bills leapfrog double, one accounting note: as written, leapfrog calls accel twice per step where euler calls it once, because this version favors visible symmetry over bookkeeping. In the standard kick-drift-kick formulation the force that closes one step is exactly the force that opens the next, so a production integrator caches it and the steady-state cost is one force evaluation per step, the same as Euler.

Launch the same planet with both, Δt=0.02\Delta t = 0.02, twenty orbits, and print the damage:

Euler:    energy drift +68.3% over 20 orbits, farthest reach 3.37
leapfrog: max energy deviation 0.016%

Euler injects a sliver of energy on the outside of every curve, and twenty orbits later the ellipse with apoapsis 1.01.0 reaches out to 3.373.37 and is still swelling. Leapfrog’s error breathes but never accumulates. Nothing about the force changed; only where the update is written down.

Forward Euler and leapfrog integrating the same Kepler orbit side by side; the Euler planet spirals outward, gaining 68.3 percent energy over 20 orbits, while the leapfrog orbit stays closed with 0.016 percent maximum deviation; energy curves draw below
The code above, run: one planet, one launch, one step size, twenty orbits of a real two-body problem integrated in float64. The dashed curve is the true ellipse. Euler moves the position with a stale velocity and pays +68.3% of its energy for it; leapfrog’s deviation never exceeds 0.016%. This is the cost of collapsing two ledgers into one, and it is the exact defect a plain residual network inherits. Rendered by scripts/render_momentum_gifs.py.

One module, one dial

What does it take to give a residual network the second ledger? One line of state. The momentum residual network of Sander et al. (2021) writes the block’s output into a velocity, damped by a friction μ\mu, and lets the velocity move the state:

vl+1=μvl+(1μ)Fl(xl),xl+1=xl+hvl+1.v_{l+1} = \mu\, v_l + (1-\mu)\, F_l(x_l), \qquad x_{l+1} = x_l + h\, v_{l+1}.

At μ=0\mu = 0 the ledger forgets instantly and this is the plain residual network xl+1=xl+hFl(xl)x_{l+1} = x_l + h\,F_l(x_l), forward Euler on the field FF. One module covers both architectures, at identical parameter count, because μ\mu is a fixed scalar, not a weight:

class MomentumResNet(nnx.Module):
    """v' = mu v + (1 - mu) F_l(x);  x' = x + h v'.  At mu = 0 this IS the
    plain residual network x' = x + h F_l(x): one dial from Euler to Newton."""

    def __init__(self, L=32, width=2, hidden=16, mu=0.9, T=8.0, *, rngs):
        gain1 = nnx.initializers.normal(1.0 / math.sqrt(width))
        gain2 = nnx.initializers.normal(1.0 / math.sqrt(hidden))
        self.blocks = [(nnx.Linear(width, hidden, kernel_init=gain1, rngs=rngs),
                        nnx.Linear(hidden, width, kernel_init=gain2, rngs=rngs))
                       for _ in range(L)]
        self.readout = nnx.Linear(width, 2, kernel_init=gain1, rngs=rngs)
        self.mu, self.h = mu, T / L

    def __call__(self, x, return_state=False):
        v = jnp.zeros_like(x)
        for lin1, lin2 in self.blocks:
            f = lin2(jnp.tanh(lin1(x)))            # the vector field F_l(x)
            v = self.mu * v + (1 - self.mu) * f    # the block writes to the LEDGER
            x = x + self.h * v                     # the ledger moves the state
        return (self.readout(x), x, v) if return_state else self.readout(x)

Two decisions in the constructor matter for everything downstream. The residual stream is width 2, so the hidden state is a literal point in a plane and every trajectory in this post is the state itself, not a projection. And the total integration time is fixed at T=Lh=8T = L\,h = 8, so depth refines the same flow: L=32L = 32 means steps of h=0.25h = 0.25. The blocks are per-layer (untied), a tiny 21622 \to 16 \to 2 tanh MLP each, exactly the explainer’s setup.

Training on the task a flow cannot get exactly right

The interesting dataset is the one the physics singled out: a disk of one class walled in by an annulus of the other. A first-order planar flow is a homeomorphism, its trajectories cannot cross, so it can never pull the inside class out exactly; a network with a velocity state doubles the state to (x,v)(x, v) and crosses freely in the position shadow. Rings are ten lines:

def make_rings(n, rng, noise=0.04):
    """Class 0 is a disk; class 1 is an annulus that walls it in at every angle."""
    n2 = n // 2
    th, r_in = rng.uniform(0, 2 * np.pi, n2), 0.6 * np.sqrt(rng.uniform(0, 1, n2))
    th2, r_out = rng.uniform(0, 2 * np.pi, n2), rng.uniform(1.3, 1.8, n2)
    X = np.concatenate([np.stack([r_in * np.cos(th), r_in * np.sin(th)], 1),
                        np.stack([r_out * np.cos(th2), r_out * np.sin(th2)], 1)])
    X += rng.normal(0, noise, X.shape)
    y = np.repeat(np.arange(2), n2).astype(np.int32)
    return X.astype(np.float32), y

The training loop is standard NNX, full-batch Adam, 4000 steps, a linear readout on the final 2-D state:

def train(mu, seed=0, steps=4000):
    rng = np.random.default_rng(1000 + seed)
    Xtr, ytr = map(jnp.asarray, make_rings(1024, rng))
    Xte, yte = map(jnp.asarray, make_rings(2048, rng))
    model = MomentumResNet(mu=mu, rngs=nnx.Rngs(seed))
    opt = nnx.Optimizer(model, optax.adam(3e-3), wrt=nnx.Param)

    @nnx.jit
    def step(model, opt):
        def loss_fn(m):
            return optax.softmax_cross_entropy_with_integer_labels(m(Xtr), ytr).mean()
        loss, grads = nnx.value_and_grad(loss_fn)(model)
        opt.update(model, grads)
        return loss

    for _ in range(steps):
        loss = step(model, opt)
    acc = (model(Xte).argmax(1) == yte).mean()
    return model, Xte, float(acc)
rings, L=32, mu=0.0: test accuracy 99.80%
rings, L=32, mu=0.9: test accuracy 100.00%

Watch that training happen rather than take the two numbers. The run below is the momentum net’s, dumped every 50 steps: the decision field over the input plane, the hidden trajectories, and the loss, all at the same moment. The striking part is the timing. Accuracy pins at 100% within the first 50 steps, and then the network spends 3,950 more steps reorganizing anyway: the boundary hardens, the probability mass polarizes, the hidden flow straightens. The scoreboard stopped watching long before the dynamics stopped moving.

Training telemetry of the momentum net on rings: the decision-probability field over the input plane organizing from undecided smoke to a hard closed contour, hidden trajectories straightening, and the training loss drawing itself down six orders of magnitude over 4000 real Adam steps
The real training run, 4000 Adam steps with a snapshot every 50: left, the class-probability field over the input plane with the decision contour; middle, the hidden trajectories of 16 test points; right, the training loss on a log axis. Test accuracy reaches 100% before step 50 and the loss still falls another five decades while the field crystallizes. Telemetry dumped by scripts/momentum_gif_data.py (the identical training, rerun with dense logging); rendered by scripts/render_momentum_gifs.py.

The ceiling, measured

Two training runs is not a result. The 99.80% against 100.00% above is one seed at one depth, and before reading anything into a fifth of a point, two questions have to survive the full sweep in scripts/momentum_resnet.py: does the gap hold across three seeds and three depths, and does a plain net that is deep enough to be a true flow ever touch the 100% line at all? The sweep’s first answer is a null. There is no cliff anywhere in it: on two moons both rules sit between 99.85% and 100% everywhere, and even on rings the plain net at depth 128 posts 99.85% on average.

What the theorem forbids is exactness, and exactness is the one line in the figure that matters: which dots ever touch the dashed 100% line.

Test accuracy versus depth on the rings task, three seeds per point: the plain residual net's seed band touches the dashed 100 percent exact-separation line only at depth 8, and never again at depths 32 and 128, while the momentum net's dots sit on the line
Every seed of the rings sweep from scripts/momentum_resnet.py, against the line that matters: 100% is exact separation, the thing a first-order planar flow can never achieve on an enclosed class. The plain net (orange) touches the line only at depth 8, where h = 1 is too coarse to be a flow; once it becomes one, its band hangs just below, at 99.76% and 99.95% best-seed, in all 6 runs. The momentum net (teal) sits on the line in every seed at L = 8 and 32. Rendered by scripts/render_momentum_gifs.py.

The seed accounting behind the dots: at depth 8 the plain net is not yet a flow (steps of h=1h = 1 can jump the wall) and it hits 100.0% in two seeds of three; at depths 32 and 128, where it refines into a true flow, it never touches 100% again in any of its six runs, its best seeds stalling at 99.76% and 99.95%, while the momentum net pins 100.0% in every seed at depths 8 and 32. On spirals the same ledger shows up as reliability rather than a ceiling: the plain net’s three depth-128 seeds spread from 92.9% to 98.3%, the momentum net’s sit within six tenths of a point of each other.

The whole sweep fits in one table, so the plot above can be audited at a glance: three seeds per cell, mean test accuracy with the min-to-max seed range in parentheses, from scripts/momentum_resnet.py.

taskdepth LLplain net, μ=0\mu = 0 (%)momentum net, μ=0.9\mu = 0.9 (%)
moons899.97 (99.95 to 100.0)99.98 (99.95 to 100.0)
moons3299.85 (99.80 to 99.95)99.98 (99.95 to 100.0)
moons12899.89 (99.80 to 100.0)99.97 (99.90 to 100.0)
spirals896.18 (94.87 to 97.61)97.66 (96.88 to 98.44)
spirals3297.82 (97.36 to 98.44)97.54 (97.22 to 97.75)
spirals12896.16 (92.87 to 98.29)98.08 (97.71 to 98.34)
rings899.93 (99.80 to 100.0)100.00 (100.0 in all seeds)
rings3299.64 (99.56 to 99.76)100.00 (100.0 in all seeds)
rings12899.85 (99.76 to 99.95)99.95 (99.85 to 100.0)

The 1890s uniqueness theorem did not predict a collapse; it predicted a ceiling, and the ceiling is exactly where the runs put it. A homeomorphism cornered by topology squeezes the surrounding class into a thin filament, hides its mistakes inside it, and pays a stray handful of test points as rent, forever.

Watching the state instead of the scoreboard

Two networks separated by a tenth of a point on the scoreboard are doing violently different things underneath, and with a width-2 stream we can just look. The exported hidden trajectories of the two trained depth-32 rings nets (from public/momentum-resnet/trajectories.json, the same states the explainer’s panels integrate) play out below, block by block.

The literal width-2 hidden states of 32 test points flowing through the plain net and the momentum net side by side, layer by layer: the plain net's paths kink at every block, the momentum net's coast on smooth crossing curves; path-length and turning-angle readouts accumulate
Depth as a time-lapse: the same test points carried through the two trained depth-32 rings nets, drawn in the network’s own 2-D hidden space (no projection exists to hide anything). The readouts accumulate as the layers pass: over the full test set the run measures a mean turn of 52.9 degrees per layer for the plain net against 9.6 for the momentum net, at nearly identical total path lengths, 15.9 versus 14.7 stream units. Same journey, no shortcut; the difference is inertia. Data from scripts/momentum_resnet.py; rendered by scripts/render_momentum_gifs.py.

That is Prediction 1 of the explainer on real weights: Euler trajectories turn as sharply as the local field because every step forgets the last one; momentum trajectories coast through turns, overshoot slightly, and curve back, the way things with mass move.

The ledger, made visible

The velocity state itself is worth seeing, because it is the whole mechanism and it never appears on any scoreboard. Run the trained μ=0.9\mu = 0.9 net forward, keep the ledger’s value at every block, and draw vlv_l as an arrow on every point:

Test points moving through the trained momentum net with their real velocity vectors attached, arrows growing as the ledger fills; below, the mean kinetic energy per layer draws itself, staying near zero for the first half of depth and then climbing to 16 as the classes separate
The second ledger at work in the trained rings net: each arrow is the real velocity state v carried by a test point, and the strip below is the mean kinetic energy of the shown points per layer. The ledger fills slowly from v = 0, carries the enclosed class out across the ring on smooth crossing paths through mid-depth, then funds the final sprint to the readout, where the mean kinetic energy climbs to 16. The block never moves the state; it deposits into v, and v moves the state. Forward pass of the exported weights in public/momentum-resnet/inertia.json; rendered by scripts/render_momentum_gifs.py.

Running the whole network backward

Now the strangest property, and the reason Sander et al. built these networks in the first place. The update rule is algebraically invertible: given the final (x,v)(x, v), each block can solve for its own past, no stored activations anywhere:

def rewind(model, xL, vL):
    """From the final (x, v) alone, solve every block for its own past."""
    x, v = xL, vL
    for lin1, lin2 in reversed(model.blocks):
        x = x - model.h * v                        # undo the drift
        f = lin2(jnp.tanh(lin1(x)))
        v = (v - (1 - model.mu) * f) / model.mu    # undo the deposit: divide by mu
    return x, v

Try to write this for the plain block and you can’t: x+F(x)x + F(x) overwrote its own input, and recovering it means solving a nonlinear equation with no uniqueness guarantee. The velocity is the receipt that makes the computation un-doable.

Run it on the trained net, in double precision, and the receipt is honored to the last digit:

rewind mu=0.9 float64: max |x0_rec - x0| = 5.8e-13

Sixty-four test points pushed through thirty-two blocks and pulled back out, reconstructed to 101310^{-13}. But run the same loop in single precision, and then turn the friction up, and something far more instructive happens:

rewind mu=0.9 float32: max |x0_rec - x0| = 3.3e-04
rewind mu=0.6 float32: max |x0_rec - x0| = 1.7e+02
rewind mu=0.3 float32: max |x0_rec - x0| = 9.7e+09

Nothing is wrong with the formula. Look at the last line of rewind: every backward step divides by μ\mu, so whatever rounding noise the forward pass left behind is amplified by (1/μ)(1/\mu) per layer. At μ=0.9\mu = 0.9 that is a factor of 2929 across 32 layers, gentle enough that single precision still comes home to 10410^{-4}. At μ=0.3\mu = 0.3 it is a factor of 5×10165 \times 10^{16}, and the past is simply gone. The explainer’s canonical run (scripts/momentum_resnet.py) measures the same law on its single-precision forward pass: 3.7×1043.7 \times 10^{-4} at μ=0.9\mu = 0.9, 5454 at μ=0.6\mu = 0.6, 3.7×10103.7 \times 10^{10} at μ=0.3\mu = 0.3. The friction dial is a memory dial: a lightly damped dynamics can be rewound, a heavily damped one has burned its own history, and floating point is just the messenger. None of this is news to the architects: Sander et al. (2021) discuss precisely this finite-precision leak, and their exactly-invertible implementation stores the low-order bits each forward update would discard so the backward pass can hand them back. The three error lines above are the reason that bookkeeping exists.

Three acts: test points flow forward through the trained momentum net; hollow markers then retrace the exact path home from only the final position and velocity, with the float64 error curve flat near 1e-13; then the float32 rewind errors at mu 0.9, 0.6 and 0.3 rise along dotted one-over-mu-to-the-k prediction lines on a log axis, reaching 1e10
The rewind, run on the exported trained weights. First the forward pass (float64); then the backward pass is handed only the final (x, v) of each point and retraces every layer, the hollow rings landing back on the forward path with a worst error near 1e-13. Then the same rewind in float32 at three frictions: each curve is the measured reconstruction error against layers rewound, and each dotted line is the pure (1/mu)^k prediction. This render measures 2.7e-4, 3.2e+1 and 1.3e+10 at mu = 0.9, 0.6, 0.3; the canonical script’s single-precision pass lands at 3.7e-4, 54 and 3.7e+10, the same amplification law on a different noise floor. Friction is the thief of memory. Rendered by scripts/render_momentum_gifs.py.

This is not a party trick. A network you can run backward is a network you can train without caching activations: recompute each block’s input from its output during the backward pass, and the memory cost of depth almost vanishes. That is the engineering payoff Sander et al. (2021) built Momentum ResNets around, and it falls out of the same physics that keeps the leapfrog planet on its ellipse: time-reversible integrators conserve the information the rewind needs, and μ\mu is the dial that decides how much of it survives arithmetic.

What carried over

Every claim the explainer made on physics grounds survived contact with real trained weights, in about a hundred lines of NNX. The plain residual block is forward Euler, and it kinks (52.9 degrees per layer) where the momentum block coasts (9.6). The missing second state is one line of module code, costs zero parameters, and buys crossing trajectories where topology forbids a flow to go: the plain net never touches 100% on rings once it is deep enough to be a true flow, and the momentum net pins it. At every scale the scoreboard was blind to the ledger: across architectures, across μ\mu, and inside a single run, where accuracy pinned at step 50 and the dynamics kept crystallizing for 3,950 more. And the ledger’s damping is exactly friction, right down to which networks can remember their own past. Architecture choices are integrator choices; the numerical-analysis shelf is full of integrators nobody has read as networks yet.


The momentum residual network is from Sander et al. (2021); depth-as-time from Chen et al. (2018); the non-crossing obstruction as a network ceiling from Dupont et al. (2019); the geometry of why leapfrog holds its orbit from Hairer, Lubich and Wanner (2006). The conceptual companion is Your Skip Connection Is Half of Newton.

Cite as

Bouhsine, T. (). Skip Connections With Inertia, in JAX/Flax NNX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/momentum-resnet-jax-flax-nnx/

BibTeX
@misc{bouhsine2026momentumresnetjaxflaxnnx,
  author       = {Bouhsine, Taha},
  title        = {Skip Connections With Inertia, in JAX/Flax NNX},
  year         = {2026},
  month        = {jul},
  howpublished = {\url{https://tahabouhsine.com/blog/momentum-resnet-jax-flax-nnx/}},
  note         = {Blog post, Records of the !mmortal Data Scientist}
}

References

  1. Sander, M. E., Ablin, P., Blondel, M., Peyré, G. (2021). Momentum Residual Neural Networks. ICML 2021.arXiv:2102.07870
  2. Chen, R. T. Q., Rubanova, Y., Bettencourt, J., Duvenaud, D. (2018). Neural Ordinary Differential Equations. NeurIPS 2018.arXiv:1806.07366
  3. Dupont, E., Doucet, A., Teh, Y. W. (2019). Augmented Neural ODEs. NeurIPS 2019.arXiv:1904.01681
  4. Hairer, E., Lubich, C., Wanner, G. (2006). Geometric Numerical Integration: Structure-Preserving Algorithms for Ordinary Differential Equations. Springer, 2nd ed..