A hawk kernel inside your jitted training step#
Your training step is already jax.jit(jax.value_and_grad(loss)). Put a physics kernel inside the loss and keep
every line around it: grad reads the kernel’s derivative from hawk, and the Param you fit is a plain float.
Time: ~5 min · Runs on: CPU, GPU if visible (picked up automatically) · You need: eagle, hawk and JAX
You will build: a drag coefficient fitted from noisy landing positions, with the gradient checked against finite differences first.
Colab or Kaggle: run this first cell (it installs the packages and downloads the helper files this notebook uses). Elsewhere it only checks the imports.
/dev/shm/jaxprobe/venv/lib/python3.12/site-packages/cupy/_environment.py:677: UserWarning:
--------------------------------------------------------------------------------
CuPy may not function correctly because multiple CuPy packages are installed
in your environment:
cupy, cupy-cuda12x
Follow these steps to resolve this issue:
1. For all packages listed above, run the following command to remove all
existing CuPy installations:
$ pip uninstall <package_name>
If you previously installed CuPy via conda, also run the following:
$ conda uninstall cupy
2. Install the appropriate CuPy package.
Refer to the Installation Guide for detailed instructions.
https://docs.cupy.dev/en/stable/install.html
--------------------------------------------------------------------------------
warnings.warn(f'''
The kernel, as a JAX function#
One time step of a ball under gravity and quadratic drag. cd is a Param: one number for the whole batch, and
the thing we want to learn. wrt names what JAX may differentiate.
import os
os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false") # leave GPU memory for your other libraries
import jax
import jax.numpy as jnp
jax.config.update("jax_enable_x64", True) # the kernel runs in float64
import hawk
from hawk import Mutable, Param, Vector
from hawk.math import norm, vec
from eagle.frameworks import jax as eagle_jax
@hawk.kernel
def flight(r: Vector[3], v: Vector[3], cd: Param, dt: Param, g: Param,
r_next: Mutable[Vector[3]], v_next: Mutable[Vector[3]]):
a = vec(0.0, 0.0, -g) - cd * norm(v) * v
v_new = v + dt * a
v_next = v_new
r_next = r + dt * v_new
step = eagle_jax.function(flight, wrt=("r", "v", "cd"))
step
KernelFunction('flight', inputs=['r', 'v', 'cd', 'dt', 'g'], outputs=['r_next', 'v_next'], wrt=['r', 'v', 'cd'])
Fly 64 balls, observe where they land#
fly(cd) unrolls 50 steps with lax.scan. The truth is cd = 0.02; the observations are the final positions
plus 5 cm of noise.
N, STEPS, DT, G, CD_TRUE = 64, 50, 0.02, 9.81, 0.02
k = jax.random.split(jax.random.key(1), 4)
speed = 10 + 10 * jax.random.uniform(k[0], (N,))
elevation = jnp.radians(30) + jnp.radians(40) * jax.random.uniform(k[1], (N,))
heading = jnp.pi * jax.random.uniform(k[2], (N,))
v0 = jnp.stack([speed * jnp.cos(elevation) * jnp.cos(heading),
speed * jnp.cos(elevation) * jnp.sin(heading),
speed * jnp.sin(elevation)])
r0 = jnp.zeros((3, N)).at[2].set(1.0)
def fly(cd):
def body(state, _):
r, v = step(*state, cd, DT, G)
return (r, v), r
(r, _), path = jax.lax.scan(body, (r0, v0), None, length=STEPS)
return r, path
observed = fly(CD_TRUE)[0] + 0.05 * jax.random.normal(k[3], (3, N))
loss = lambda cd: ((fly(cd)[0] - observed) ** 2).mean()
print("loss at the truth:", round(float(loss(CD_TRUE)), 4), "| at cd = 0.1:", round(float(loss(0.1)), 2))
loss at the truth: 0.0025 | at cd = 0.1: 4.39
Check the gradient first#
jax.test_util.check_grads compares grad against finite differences, forward and reverse mode, to second order.
The derivative of the Param comes from the kernel hawk derives; you wrote no backward pass.
from jax.test_util import check_grads
check_grads(loss, (0.05,), order=2, modes=("fwd", "rev"), rtol=1e-4)
print("d loss / d cd at 0.05:", round(float(jax.grad(loss)(0.05)), 3), "(forward, reverse, first and second order all agree with finite differences)")
d loss / d cd at 0.05: 56.036 (forward, reverse, first and second order all agree with finite differences)
Train#
The usual jitted step: value and gradient, then a gradient-descent update. Start from a drag coefficient five times too big.
@jax.jit
def train_step(cd):
value, grad = jax.value_and_grad(loss)(cd)
return cd - 1e-4 * grad, value
cd, losses = 0.1, []
for _ in range(40):
cd, value = train_step(cd)
losses.append(float(value))
print(f"cd = {float(cd):.4f} (truth {CD_TRUE}); loss {losses[0]:.2f} -> {losses[-1]:.4f} (noise floor {0.05 ** 2:.4f})")
cd = 0.0200 (truth 0.02); loss 4.39 -> 0.0025 (noise floor 0.0025)
Left, six balls before and after the fit (dots are the observed landing points); right, the loss on the way down.
import matplotlib.pyplot as plt
before, after = fly(0.1)[1], fly(float(cd))[1]
fig, (ax, bx) = plt.subplots(1, 2, figsize=(9, 3.4))
for i, colour in zip(range(6), plt.cm.viridis([0.1, 0.25, 0.4, 0.55, 0.7, 0.85])):
ax.plot(before[:, 0, i], before[:, 2, i], "--", color=colour, lw=1)
ax.plot(after[:, 0, i], after[:, 2, i], color=colour, lw=1.6)
ax.plot(observed[0, i], observed[2, i], "o", color=colour, ms=4)
ax.set(xlabel="x (m)", ylabel="height (m)", title="dashed: cd = 0.1, solid: fitted")
bx.semilogy(losses)
bx.axhline(0.05 ** 2, color="#898781", ls=":")
bx.set(xlabel="training step", ylabel="loss", title="the dotted line is the noise floor")
plt.tight_layout()
What just happened#
stepsat insidelax.scan,jitandvalue_and_gradlike any other JAX function.The gradient of the
Paramcdcame from a kernel hawk derived fromflight.check_gradsis the same test you would run on your ownjnpstep.
Try this#
Fit two things at once: add g to the parameters (jax.grad(loss, argnums=(0, 1)) after giving loss a second
argument) and see which of the two the data pins down better.
Next#
Batches and Jacobians:
vmapover trajectories and over Params,jacfwd,hessian.A stepping loop in
lax.scan: balls that land and stop.eagle inside your JAX program: what crosses, what is refused, the limits.