Building the Energy-Conserving Net in JAX/Flax NNX

· 7 min read

#integrators#hamiltonian#resnets#jax#flax#nnx#implementation

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

The explainer trains a network whose motion is derived from a learned scalar, so the scalar is conserved by construction, then turns the same idea into a residual block. This is the implementation: both field models and the leapfrog classifier as Flax NNX modules, depth as a lax.scan, and the structural claims re-checked from scratch. The published numbers are from scripts/hamiltonian_net.py on Kaggle; every code block below runs as written in scripts/hamiltonian_nnx_check.py, and every figure is rendered from the real run by scripts/render_hamiltonian_gifs.py.

One scalar, two outputs for free

The whole trick fits in a class definition. A plain field model maps a state to two numbers and both are free. The HNN maps a state to one number and manufactures the two outputs from its gradient, rotated a quarter turn:

class HNNField(nnx.Module):
    """One scalar H(q, p); the field is (dH/dp, -dH/dq)."""

    def __init__(self, hidden=64, *, rngs):
        self.l1 = nnx.Linear(2, hidden, rngs=rngs)
        self.l2 = nnx.Linear(hidden, hidden, rngs=rngs)
        self.l3 = nnx.Linear(hidden, 1, rngs=rngs)

    def energy(self, qp):                      # (..., 2) -> (...,)
        h = jnp.tanh(self.l1(qp))
        h = jnp.tanh(self.l2(h))
        return self.l3(h)[..., 0]

    def __call__(self, qp):                    # the symplectic gradient
        g = jax.vmap(jax.grad(lambda s: self.energy(s)))(qp)
        return jnp.stack([g[:, 1], -g[:, 0]], axis=1)

jax.grad through the module is the entire implementation of “derive the motion from the energy”. The control is the same MLP with two output units and no scalar anywhere:

class PlainField(nnx.Module):
    def __init__(self, hidden=64, *, rngs):
        self.l1 = nnx.Linear(2, hidden, rngs=rngs)
        self.l2 = nnx.Linear(hidden, hidden, rngs=rngs)
        self.l3 = nnx.Linear(hidden, 2, rngs=rngs)

    def __call__(self, qp):
        h = jnp.tanh(self.l1(qp))
        h = jnp.tanh(self.l2(h))
        return self.l3(h)

Both fit the same pendulum arrows, (q˙,p˙)=(p,sinq)(\dot q, \dot p) = (p, -\sin q) sampled on a band of states, with the same loop:

def fit(model, S, T, steps=1500, lr=1e-3):
    opt = nnx.Optimizer(model, optax.adam(lr), wrt=nnx.Param)

    @nnx.jit
    def step(model, opt):
        loss, grads = nnx.value_and_grad(lambda m: jnp.mean((m(S) - T) ** 2))(model)
        opt.update(model, grads)
        return loss

    for _ in range(steps):
        loss = step(model, opt)
    return float(loss)

In the check run both models fit the arrows well (plain 3.4e-04, HNN 1.1e-04). On the fit, nothing distinguishes them. The distinction is dynamical, so integrate.

Roll both out and watch the invariant

RK4 as a lax.scan, recording the state so we can price each model’s energy behavior:

def rollout_energy(model, q0=1.6, p0=0.0, dt=0.05, n=600):
    def rk4(s, _):
        k1 = model(s); k2 = model(s + 0.5 * dt * k1)
        k3 = model(s + 0.5 * dt * k2); k4 = model(s + dt * k3)
        s2 = s + (dt / 6) * (k1 + 2 * k2 + 2 * k3 + k4)
        return s2, s2[0]

    s0 = jnp.array([[q0, p0]], dtype=jnp.float32)
    _, traj = jax.lax.scan(rk4, s0, None, length=n)
    E = 0.5 * traj[:, 1] ** 2 + (1 - jnp.cos(traj[:, 0]))
    E0 = 0.5 * p0 ** 2 + (1 - np.cos(q0))
    return float(jnp.max(jnp.abs(E - E0)) / E0)

In the check run the plain field drifts 6.4% of the starting energy over the rollout; the HNN holds within 0.4%. The published run’s longer rollout is the figure below, drawn from the exported trajectories.

Two learned pendulum fields integrated from the same start: phase-space trajectories drawing left, true energy along each rollout drawing right; the plain field's energy leaves the dashed starting level while the HNN's stays on it
The race, from the real run’s exported rollouts. Left: phase space. Right: the true pendulum energy along each trajectory, dashes at the starting level. The plain field model reproduces the arrows and still leaks; the HNN cannot leak, because its arrows are built from a level set.

What the HNN learned is worth looking at directly: one scalar over phase space whose level sets are the orbits.

The learned scalar H(q,p) as a contoured landscape with the HNN trajectory tracing along a single level set
The trained HNN’s scalar, contoured, with the rollout riding it. The trajectory follows one stripe because the field is the rotated gradient of this exact surface: conservation is the geometry, not a fit.

The leapfrog classifier

The explainer’s second move turns the integrator into an architecture. Hidden state (q,p)(q, p), and the only learned thing in the block is a scalar potential Vθ(q)V_\theta(q); one layer is one kick-drift-kick step. In NNX, with depth as a lax.scan over the shared block:

class LeapfrogNet(nnx.Module):
    """Hidden state (q, p); one shared block = one leapfrog step of V(q)."""

    def __init__(self, hidden=32, *, rngs):
        self.enc = nnx.Linear(2, 2 * DIM, rngs=rngs)
        self.v1 = nnx.Linear(DIM, hidden, rngs=rngs)
        self.v2 = nnx.Linear(hidden, DIM, rngs=rngs)
        self.dec = nnx.Linear(2 * DIM, 2, rngs=rngs)

    def potential(self, q):                    # scalar V per row
        return (self.v2(jnp.tanh(self.v1(q)))).sum(axis=-1)

    def grad_v(self, q):
        return jax.vmap(jax.grad(lambda qi: self.potential(qi[None])[0]))(q)

    def __call__(self, x, L=16):
        h = T_TOTAL / L
        z = self.enc(x)
        q, p = z[:, :DIM], z[:, DIM:]

        def step(carry, _):
            q, p = carry
            p = p - 0.5 * h * self.grad_v(q)   # kick
            q = q + h * p                      # drift
            p = p - 0.5 * h * self.grad_v(q)   # kick
            return (q, p), None

        (q, p), _ = jax.lax.scan(step, (q, p), None, length=L)
        return self.dec(jnp.concatenate([q, p], axis=1))

Three things to notice. The step size is h=T/Lh = T/L with TT fixed, so depth is a resolution, not a budget: net(x, L=64) runs the same trained weights at four times finer steps. The block is shared across depth, so a deeper call invents no new parameters. And the training loop is completely ordinary:

net = LeapfrogNet(rngs=nnx.Rngs(0))
opt = nnx.Optimizer(net, optax.adam(3e-3), wrt=nnx.Param)

@nnx.jit
def step(net, opt):
    def loss_fn(m):
        return optax.softmax_cross_entropy_with_integer_labels(m(X), y).mean()
    loss, grads = nnx.value_and_grad(loss_fn)(net)
    opt.update(net, grads)
    return loss

In the check run this reaches 100% on held-out moons at its training depth and 100% again at four times that depth, with the learned energy Eθ=12p2+Vθ(q)E_\theta = \tfrac12\|p\|^2 + V_\theta(q) oscillating in a bounded band through the layers rather than trending anywhere. The published three-seed comparison against the parameter-matched plain net is in the explainer’s table, along with the run’s cleanest structural result: the energy band shrinks sixteen-fold when the step halves twice, the h2h^2 signature of a second-order symplectic integrator measured inside a trained classifier. Here is the hidden state itself, from the exported seed-0 weights.

Two scatter clouds of hidden states advancing layer by layer, with a readout tracking the leapfrog net's learned energy, which stays in a narrow band, while the plain net's readout shows the quantity is undefined
The hidden state through depth, layer by layer, real weights on real test points, with the readout tracking the one quantity only the leapfrog network possesses: its learned energy, holding in a band while the clouds move. The plain net’s entry reads undefined, and that is the finding: not a smaller number, a missing row.

Depth as a dial you can turn after training

The payoff figure re-runs both trained networks at depths they never trained at, from 4 to 128 layers, and draws each decision boundary as the sweep passes through.

Two decision boundary maps side by side as depth sweeps from 4 to 128 layers: the leapfrog net's boundary stays put while the plain net's boundary deforms and its accuracy readout falls
Trained at depth 16 (marked in the title as the sweep passes), run everywhere from 4 to 128. The leapfrog net’s boundary sharpens into place and stays; it learned a flow, and more layers only render the same flow finer. The plain net learned a sixteen-fold composition, and at other depths it is a different function, visibly and in the accuracy readout.

The one-line summary of the whole build: put the learnable thing one derivative inside the dynamics, and the dynamics inherit a law no loss term has to police.


Published numbers: scripts/hamiltonian_net.py (Kaggle, three seeds; bundle scripts/results/kgl_blog-hamiltonian-v1/). The code blocks in this post run as written in scripts/hamiltonian_nnx_check.py (Kaggle, bundle scripts/results/kgl_blog-hamiltonian-nnx-check/). Figures: scripts/render_hamiltonian_gifs.py from the run bundle.

Cite as

Bouhsine, T. (). Building the Energy-Conserving Net in JAX/Flax NNX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/a-network-that-conserves-energy-jax-flax-nnx/

BibTeX
@misc{bouhsine2026anetworkthatconservesenergyjaxflaxnnx,
  author       = {Bouhsine, Taha},
  title        = {Building the Energy-Conserving Net in JAX/Flax NNX},
  year         = {2026},
  month        = {jul},
  howpublished = {\url{https://tahabouhsine.com/blog/a-network-that-conserves-energy-jax-flax-nnx/}},
  note         = {Blog post, Records of the !mmortal Data Scientist}
}

References

  1. Greydanus, S., Dzamba, M., Sprague, N. (2019). Hamiltonian Neural Networks. arXiv:1906.01563