Building the Hamiltonian-Step 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 Built from Hamiltonian Stepsthis post's explainer
  4. 4Backprop Without the Memory
  5. 5Depth on Demand
Explainer companionA Network Built from Hamiltonian StepsWant the full intuition first? This is the runnable companion to the explainer.Read the explainer

Fit two models to the same pendulum arrows and nothing separates them: 3.4e-04 for the plain field, 1.1e-04 for the one whose motion comes from a learned scalar. The difference does not exist until you integrate, and then it is the only thing there is. That gap is what the explainer is about. Below are both field models and the leapfrog classifier as Flax NNX modules, depth as a lax.scan, and the structural claims re-derived from scratch against the Kaggle run.

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
Both rollouts drawing at once, from the run’s exported trajectories. Left: phase space. Right: the true pendulum energy along each numerically integrated path, dashes at the starting level. The plain field’s energy peels away; the HNN stays in a narrow band because its continuous vector field is tangent to a learned 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 stays on one stripe because the field is the gradient of this surface rotated a quarter turn, and a gradient has nothing to say along its own level set.

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. 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: Kaggle, three seeds, bundle kgl_blog-hamiltonian-v2. The code blocks in this post run as written on Kaggle, bundle kgl_blog-hamiltonian-nnx-check. Figures are rendered from the run bundle.

Cite as

Bouhsine, T. (). Building the Hamiltonian-Step 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 Hamiltonian-Step 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