Auto-deriving the chain rule with request_AD#
The previous tutorial hand-wrote the
projectile’s first derivative — the actions closure lambda V: -mu * V encoding \(\partial\boldsymbol{a}/\partial\boldsymbol{v} = -\mu I\).
That is the right call when the local Jacobian is simple. But when it
isn’t — a constitutive law with many coupled terms, or a trained
machine-learning surrogate we would never differentiate by hand — NEML2
can derive the chain rule for us by reverse-mode automatic
differentiation.
In this tutorial, we’ll re-implement the same projectile model, but
let request_AD supply the derivative: we write only the value
forward, and the auto-derived chain rule comes out identical to the
hand-written one.
Declaring request_AD#
A leaf opts in by calling self.request_AD() once at construction (in
__post_init__), then writing a value-only forward — no v= branch,
no actions. Everything else is unchanged: the same schema, the same
_get_param read, the same physics. Here is the re-implemented
projectile.py:
%%writefile projectile.py
"""Custom ``ProjectileAcceleration`` model — re-implemented with ``request_AD``.
Same model as the forward-operator tutorial (``a = g - mu * v``), but instead of
hand-writing the chain rule we declare ``request_AD`` and write only the value
``forward``; NEML2 supplies the first-order chain rule by reverse-mode autodiff.
"""
from __future__ import annotations
from neml2.factory import register_neml2_object
from neml2.models.model import Model
from neml2.schema import HitSchema, buffer, input, output, parameter
from neml2.types import Scalar, Vec
@register_neml2_object("ProjectileAcceleration")
class ProjectileAcceleration(Model):
"""Newton's second law for a projectile in a viscous medium, ``a = g - mu * v``,
with the chain rule auto-derived by ``request_AD``.
"""
hit = HitSchema(
input("velocity", Vec, "Velocity of the projectile", attr="_v_name"),
output("acceleration", Vec, "Acceleration of the projectile"),
buffer(
"gravitational_acceleration",
Vec,
"Gravity vector",
attr="g",
default=Vec.fill(0.0, -9.81, 0.0),
),
parameter("dynamic_viscosity", Scalar, "Dynamic viscosity", attr="mu"),
)
_v_name: str
g: Vec
mu: Scalar
def __post_init__(self):
# Opt into auto-derived derivatives. With no arguments this covers every
# (output, input) pair -- here the single pair d(acceleration)/d(velocity).
# Pass outputs=[...] / inputs=[...] to auto-derive only a subset.
self.request_AD()
def forward(self, v_in: Vec, *promoted_params): # type: ignore[override]
# Read the drag coefficient through ``_get_param`` (promotion-compatible),
# exactly as in the hand-written version.
mu = self._get_param("mu", promoted_params, Scalar)
# Value only: a = g - mu * v. No ``v=`` branch, no ``actions`` -- the
# framework differentiates this forward by reverse-mode AD.
return self.g - mu * v_in
Writing projectile.py
Two changes from the hand-written version:
__post_init__callsself.request_AD(). With no arguments it covers every(output, input)pair — here \(\partial\boldsymbol{a}/\partial\boldsymbol{v}\). Passoutputs=[...]/inputs=[...]to auto-derive only a subset and hand-write the rest.forwardreturns only the value. Nov=Nonekeyword, noapply_chain_rule. The framework intercepts the call, runs the value forward under reverse-mode autograd, and assembles the first-order chain rule from the result.
The parameter is still read through _get_param (promotion-compatible,
exactly as before), and self.g is still a buffer read directly.
%%writefile input.i
[Models]
[accel]
type = ProjectileAcceleration
velocity = 'v'
acceleration = 'a'
dynamic_viscosity = '0.001'
[]
[]
Writing input.i
Load and evaluate it exactly as we would any built-in type — the value forward behaves identically to the hand-written model:
import sys, os
sys.path.insert(0, os.getcwd())
import projectile # registers ProjectileAcceleration with the native factory
import neml2
model = neml2.load_model("input.i", "accel")
model
ProjectileAcceleration()
import torch
from neml2.types import Vec
vel = Vec.fill(10.0, 2.0, 0.0)
accel = model(vel)
accel
Vec(data=tensor([-0.0100, -9.8120, 0.0000], dtype=torch.float64,
grad_fn=<SubBackward0>), sub_batch_ndim=0, sub_batch_state=(), sub_batch_meta=(), k_ndim=0, k_state=(), k_pairing=())
The value is \(\boldsymbol{g} - \mu \boldsymbol{v} = (0, -9.81, 0) - 0.001 \cdot (10, 2, 0) = (-0.01, -9.812, 0)\) — identical to the hand-written model, as it must be.
First derivatives, for free#
We never wrote a v= branch, yet the model hands back directional
derivatives just like the analytic one. Seed the identity on the
velocity input and read off the full Jacobian column
\(\partial\boldsymbol{a}/\partial\boldsymbol{v}\):
# The same identity-seed idiom as the hand-written tutorial — only now the
# Jacobian column is auto-derived rather than hand-coded.
seed = Vec(torch.eye(3, dtype=torch.float64))
accel, v_out = model(vel, v={"v": {"velocity_leaf": seed}})
v_out["a"]["velocity_leaf"].data
tensor([[-0.0010, 0.0000, 0.0000],
[ 0.0000, -0.0010, 0.0000],
[ 0.0000, 0.0000, -0.0010]], dtype=torch.float64)
This is exactly the \(-\mu I = -0.001\, I_3\) we hand-wrote last time —
request_AD reproduced it from the value forward alone.
Verifying with ModelUnitTest#
The same ModelUnitTest harness pins the auto-derived model. It
cross-checks every JVP against PyTorch’s autograd, so a positive
JVP checks count is independent confirmation that the auto-derived
chain rule is correct — not merely self-consistent:
%%writefile unit_test.i
[Tensors]
[v_in]
type = Python
expr = 'Vec.fill(10.0, 2.0, 0.0)'
[]
[a_expected]
type = Python
expr = 'Vec.fill(-0.01, -9.812, 0.0)'
[]
[]
[Models]
[accel]
type = ProjectileAcceleration
velocity = 'v'
acceleration = 'a'
dynamic_viscosity = '0.001'
[]
[]
[Drivers]
[unit]
type = ModelUnitTest
model = 'accel'
input_Vec_names = 'v'
input_Vec_values = 'v_in'
output_Vec_names = 'a'
output_Vec_values = 'a_expected'
[]
[]
Writing unit_test.i
from neml2.drivers.ModelUnitTest import ModelUnitTest
report = ModelUnitTest.from_file("unit_test.i").run()
print(f"value checks: {report.value_checks}, JVP checks: {report.jvp_checks}")
value checks: 1, JVP checks: 1
Where this really pays off: an ML surrogate#
The projectile’s \(-\mu I\) was easy to hand-write — request_AD only
saved us a few lines. The real payoff is a forward operator whose
Jacobian we would never want to derive by hand: a trained
torch.nn.Module surrogate. The pattern is identical — declare
request_AD, write only the value forward (now a network call):
class MySurrogate(Model):
hit = HitSchema(...) # same input/output declarations as usual
def __post_init__(self):
self.net = ... # a trained torch.nn.Module
self.request_AD() # auto-derive d(output)/d(input) for all pairs
def forward(self, x, *promoted_params):
return type(x)(self.net(x.data)) # value only -- no `v=`, no `actions`
The auto-derived chain rule is indistinguishable from a hand-written
one and behaves identically on every route — eager (py-eager /
cpp-eager) and AOT-compiled (py-aoti / cpp-aoti / cpp-dispatch).
It slots into the same forward-mode chain-rule graph the framework
already uses: neighbouring analytic leaves keep their hand-written
actions, and only the request_AD leaf’s reverse-mode local Jacobian
is traced inline (and lowered through AOTInductor on the compiled
routes). The tests/regression/_fixtures/SurrogateFlowRate.py fixture
is a worked example wrapping a Python surrogate as a NEML2 flow rate.
A few things to know:
First-order only.
request_ADsupplies thev=channel (\(\partial\,\text{out}/\partial\,\text{in}\)). A leaf that must provide the second-order chain rule (i.e. one used inside aNormalitywrap) still hand-writes it.Reverse-mode under the hood. It is the one autodiff that survives
torch.export→ AOTInductor. If our differentiated path uses a saved-output op (exp/sqrt/tanh/ reciprocal), route it through the AOTI-safe variants inneml2.types.functions(e.g.exp_ad) so the compiled routes lower (see the upstream-bug note in that module); eager is unaffected.AOTI compile. Pass
-dtoneml2-compileto bake the derivative graph, exactly as for an analytic model —request_ADchanges how the Jacobian is computed, not whether it is compiled. This holds even for a request_AD leaf inside anImplicitUpdateresidual: the Newton-step / implicit-function-theorem graphs differentiate the residual (which contains the leaf) and lower the same way.
Where to go next#
The next tutorial, Composing with existing models, shows how to glue several models together so a dependency resolver wires their inputs and outputs and threads the chain rule for us.