Model composition#
In this tutorial, we’ll wire three small models together into a composed model, inspect how NEML2 resolves the connections, and evaluate the result. Then we’ll compare it to doing the same plumbing by hand.
Most constitutive theories are a chain of small maps. A Perzyna-type viscoplastic model, for instance, threads together
Each line on the right-hand side maps to a separate Model in
NEML2’s catalog, and ComposedModel glues a chosen set together.
That keeps each piece testable and swappable, and the Python-side
overhead of stepping through many sub-models drops out once we
export the composed graph through the compilation pipeline (see
Compiled models).
A worked example#
To keep the wiring visible we’ll use three small models from the catalog instead of the full plasticity stack:
The first two are scalar invariants of symmetric tensors, both
handled by SR2Invariant (the second one with
invariant_type = VONMISES). The third is a linear combination of
two SR2 tensors with scalar weights — see
SR2LinearCombination.
%%writefile input.i
# Three small models from the catalog, plus a ComposedModel that wires
# them together via shared variable names.
#
# eq1: a_bar = I1(a) (SR2Invariant)
# eq2: b_bar = vonMises(b) (SR2Invariant)
# eq3: b_rate = b_bar * a + a_bar * b (SR2LinearCombination)
# eq: the three glued together by ComposedModel
[Models]
[eq1]
type = SR2Invariant
tensor = 'a'
invariant = 'a_bar'
invariant_type = I1
[]
[eq2]
type = SR2Invariant
tensor = 'b'
invariant = 'b_bar'
invariant_type = VONMISES
[]
[eq3]
type = SR2LinearCombination
from = 'a b'
to = 'b_rate'
# The two weights are wired to the OUTPUTS of eq1 and eq2 by name.
# The dependency resolver will treat 'a_bar' and 'b_bar' as
# producer/consumer links instead of free parameters.
weights = 'b_bar a_bar'
[]
[eq]
type = ComposedModel
models = 'eq1 eq2 eq3'
[]
[]
Writing input.i
The trick is in eq3’s weights = 'b_bar a_bar'. b_bar and a_bar
aren’t literals or [Tensors] entries — they’re the output names of
eq2 and eq1. ComposedModel notices that eq3 consumes two
scalars that eq1 and eq2 produce, and wires them up.
Inspecting the wiring#
Before evaluating anything, ask neml2-inspect to print the resolved
graph:
# `neml2-inspect` is a thin CLI over neml2 -- import its entry point and call
# it in-process so the resolved graph prints inline.
from neml2.cli.inspect import main as inspect_main
_ = inspect_main(["input.i", "eq"])
Model: ComposedModel
Inputs (2):
a: type=SR2
b: type=SR2
Outputs (1):
b_rate: type=SR2
Parameters (1):
eq3.offset: dtype=torch.float64, device=cpu, shape=[]
Buffers (0):
Three things to notice:
Inputs collapsed to
aandb. The intermediate scalarsa_barandb_bararen’t free inputs — they’re produced internally.Outputs collapsed to
b_rate.a_barandb_barare consumed downstream, so they don’t surface as outputs. (If we want them, add them underadditional_outputson theComposedModel.)Parameters collapsed to
eq3.offset.eq3.weight_0andeq3.weight_1are gone — replaced by the producer links fromeq2andeq1. Only the literaloffset = 0is still free.
Running neml2-inspect right after wiring a composed model is the
fastest way to catch typos: a missed name shows up as a dangling
input or missing output, instead of a shape mismatch later on.
Loading and evaluating the composed model#
The composed model loads and calls just like any other model:
import torch
import neml2
from neml2.types import SR2
torch.set_default_dtype(torch.float64)
eq = neml2.load_model("input.i", "eq")
eq
ComposedModel(
(eq1): SR2Invariant()
(eq2): SR2Invariant()
(eq3): SR2LinearCombination()
)
The input_spec / output_spec properties echo what neml2-inspect
showed:
list(eq.input_spec.keys()), list(eq.output_spec.keys())
(['a', 'b'], ['b_rate'])
To evaluate, call it like any other model — inputs positional, in
input_spec order. A ComposedModel hands its outputs back as a tuple
(in output_spec order), so we unpack the single b_rate:
a = SR2(torch.tensor([0.1, 0.05, -0.03, 0.02, 0.06, 0.03]))
b = SR2(torch.tensor([100.0, 20.0, 10.0, 5.0, -30.0, -20.0]))
(b_rate,) = eq(a, b)
b_rate
SR2(data=tensor([21.6372, 7.2186, -1.6912, 2.5274, 2.1823, 0.4912],
grad_fn=<AddBackward0>), sub_batch_ndim=0, sub_batch_state=(), sub_batch_meta=(), k_ndim=0, k_state=(), k_pairing=())
Under the hood it ran eq1, then eq2, then eq3 (the only order
that respects the dependencies), threaded the intermediate scalars
into eq3’s weight slots, and returned b_rate.
If we’d rather pass and receive values by name — handy when a model has
many inputs — eq.call_by_name({"a": a, "b": b}) runs the same
evaluation with name-keyed dicts on both ends.
The same thing without ComposedModel#
To see what ComposedModel is doing for us, here’s the same
calculation done by hand. The input file is the same three [Models]
entries, but with weights = '1 1' on eq3 so weight_0 and
weight_1 stay as free parameters:
%%writefile input_manual.i
# The same three models without a ComposedModel — the caller is
# responsible for evaluating them in the right order and threading
# intermediate values into eq3's weight parameters by hand.
[Models]
[eq1]
type = SR2Invariant
tensor = 'a'
invariant = 'a_bar'
invariant_type = I1
[]
[eq2]
type = SR2Invariant
tensor = 'b'
invariant = 'b_bar'
invariant_type = VONMISES
[]
[eq3]
type = SR2LinearCombination
from = 'a b'
to = 'b_rate'
weights = '1 1'
[]
[]
Writing input_manual.i
import torch.nn as nn
eq1 = neml2.load_model("input_manual.i", "eq1")
eq2 = neml2.load_model("input_manual.i", "eq2")
eq3 = neml2.load_model("input_manual.i", "eq3")
# 1. Evaluate the two invariants.
a_bar = eq1(a)
b_bar = eq2(b)
# 2. Manually wire the weights of eq3 to those intermediate values.
# weight_0 is the first `from` variable (a), weight_1 is the second (b),
# so b_bar (the von Mises norm of b) goes to weight_0 and vice-versa.
eq3.weight_0 = nn.Parameter(b_bar.data)
eq3.weight_1 = nn.Parameter(a_bar.data)
# 3. Evaluate eq3 to get b_rate.
eq3(a, b)
SR2(data=tensor([21.6372, 7.2186, -1.6912, 2.5274, 2.1823, 0.4912],
grad_fn=<AddBackward0>), sub_batch_ndim=0, sub_batch_state=(), sub_batch_meta=(), k_ndim=0, k_state=(), k_pairing=())
Same answer, but we had to:
pick the right evaluation order,
remember which weight slot maps to which invariant, and
copy the intermediate values into
eq3’s parameters by hand.
A handful is manageable. A realistic constitutive theory with
dozens of small maps isn’t. ComposedModel does this bookkeeping
once at load time, then gets out of the way.
Note
This works because eq3’s weights accepts a list of names that can
resolve to a parameter, a sibling model’s output, or a [Tensors]
entry. See Model parameters revisited for the full
story.
Where to go next#
The flexible name-binding used for
eq3’sweightsis generalized in Model parameters revisited.