Model composition¶
You’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 you
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.
# 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'
[]
[]
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:
import subprocess
print(subprocess.run(
["neml2-inspect", "input.i", "eq"],
capture_output=True, text=True, check=True,
).stdout)
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 you 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, pass the inputs as an {name: SR2} dict to
call_by_name:
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]))
eq.call_by_name({"a": a, "b": 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.
The same thing without ComposedModel¶
To see what ComposedModel is doing for you, 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:
# 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'
[]
[]
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 you 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.