The forward operator#
The previous tutorial declared the projectile model’s inputs, outputs,
and parameters. This one fills in the math: a forward() method that
turns a velocity into an acceleration.
The forward method#
forward() is the function NEML2 calls to evaluate our model. The
positional arguments are the inputs in the order we declared them in
the schema — each one is already a typed wrapper (Vec, SR2, …) the
framework built for us. Return the outputs in schema order.
There’s also a v=None keyword in the signature — that’s the
chain-rule hook for first derivatives, covered later on this page.
Until we need derivatives, we can leave it alone; calls without
v get back just the outputs.
Implementation#
Recall the equation for the projectile model:
self.g is the gravity vector (a buffer, read directly), and the
velocity comes in as the first positional argument. The drag
coefficient mu is a parameter, so it is read through
self._get_param("mu", promoted_params, Scalar) rather than self.mu —
more on that below. Here is the complete projectile.py; the
forward method is the new part, dissected line by line below:
%%writefile projectile.py
"""Custom ``ProjectileAcceleration`` model — the running example for the
``extension`` tutorial chain. Mirrors the C++ tutorial's projectile in
the Python-native model surface.
The equation is
a = g - mu * v
where ``v`` is the projectile velocity (input), ``a`` is the
acceleration (output), ``g`` is the gravitational acceleration vector
(buffer; constant), and ``mu`` is the scalar dynamic viscosity
(parameter; calibratable).
"""
from __future__ import annotations
from neml2.factory import register_neml2_object
from neml2.models.chain_rule import ChainRuleDict
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``.
"""
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 forward( # type: ignore[override]
self,
v_in: Vec,
*promoted_params,
v: ChainRuleDict | None = None,
):
# Read the drag coefficient through ``_get_param`` rather than
# ``self.mu``. ``_get_param`` resolves a static slot from ``self`` or
# a promoted runtime input from ``*promoted_params``, so the same forward
# keeps working after ``mu`` is promoted (neml2-compile -p); a bare
# ``self.mu`` would be rejected by the parameter-attribute guard.
mu = self._get_param("mu", promoted_params, Scalar)
# Compute the value: a = g - mu * v. ``self.g`` is a buffer (not a
# parameter), so reading it directly is fine.
a = self.g - mu * v_in
# Pure forward: return the typed output and stop.
if v is None:
return a
# First-order chain rule: ∂a / ∂v_in = -mu * I. The closure
# captures the local ``mu`` and receives an incoming tangent V
# (a ``Vec`` shaped like the input), returns the contribution
# to ∂(acceleration)/∂(seed-leaf).
actions = {self._v_name: lambda V: -mu * V}
# ``apply_chain_rule`` returns the v_out dict; pair it with the
# value so the caller can unpack ``(a, v_out)``.
return a, self.apply_chain_rule(v, "acceleration", actions, output=a)
Writing projectile.py
The first line reads the parameter:
mu = self._get_param("mu", promoted_params, Scalar). Always read a
parameter this way inside forward — never as self.mu. A bare
self.mu is rejected by a runtime guard, because it bypasses
_get_param’s static-or-promoted dispatch: the moment mu is
promoted to a runtime input (neml2-compile -p) the static
nn.Parameter no longer exists and the attribute read breaks.
_get_param works for both static and promoted parameters — it pulls
the value from self when static and from the *promoted_params pack when
promoted — so the leaf stays promotion-compatible. (Buffers like
self.g are not parameters, so reading them directly is fine.) That
is also why the signature is def forward(self, v_in, *promoted_params, v=None): the *promoted_params pack is where promoted parameters arrive.
The next line is the physics: Vec - Scalar * Vec gives back a Vec,
batched or not. If v is None (the usual case) the method returns and
we’re done.
The else branch is the chain-rule hook. actions maps each input
variable to a small function that takes an incoming tangent
(something the same shape as that input) and returns its
contribution to the output’s tangent. For this model the math is
simple: \(\partial \boldsymbol{a}/\partial \boldsymbol{v} = -\mu I\),
so the closure is lambda V: -mu * V, capturing the local mu we
read at the top. apply_chain_rule then sums the contribution against
any tangents the caller seeded on v, without ever building the full
Jacobian matrix in memory.
Evaluation#
That’s the whole model. Load it the same way we’d load any built-in
type — neml2.load_model finds it through the factory as long as the
module that registers it has been imported.
The input file from the previous tutorial wires the model into HIT:
%%writefile input.i
[Models]
[accel]
type = ProjectileAcceleration
velocity = 'v'
acceleration = 'a'
dynamic_viscosity = '0.001'
[]
[]
Writing input.i
Import the module so the class registers, then load and call:
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 result is \(\boldsymbol{g} - \mu \boldsymbol{v} = (0, -9.81, 0) -
0.001 \cdot (10, 2, 0) = (-0.01, -9.812, 0)\) — the typed wrapper
preserves the Vec shape on the way out.
First derivatives#
Because forward() implements the v branch, the same model can hand
back directional derivatives with no extra wiring. Seed a tangent on
the velocity input and the JVP comes back through v_out:
# Seed the identity on Vec to read off the full Jacobian column ∂a/∂v.
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,
grad_fn=<MulBackward0>)
This matches the analytical \(\partial \boldsymbol{a}/\partial \boldsymbol{v} = -\mu I = -0.001\, I_3\) exactly.
Driving the model from a unit-test input#
ModelUnitTest is the usual way to pin a custom model’s behavior in
CI. It loads the model, runs it on inputs we supply, checks the
outputs, and cross-checks the derivatives against PyTorch’s autograd:
%%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
If both counters are positive (and the cell didn’t raise), every value and every JVP matched. A zero on either side means that check was skipped, not that it failed silently.
Where to go next#
The next tutorial, Auto-deriving the chain rule with request_AD, lets NEML2 derive the chain rule for us by automatic differentiation — the same projectile, but with no hand-written
actionsat all.