Implicit models¶
You’ll build a Perzyna viscoplasticity update whose plastic strain is
defined as the root of a nonlinear residual, wrap it in an
ImplicitUpdate so a Newton solver finds that root, and then
differentiate through the solve.
Stiff viscoplastic flow is the canonical case: the plastic-strain increment depends on the stress after the increment, which in turn depends on the plastic strain — so the update has no closed form. The standard move is to write a residual that vanishes at the right answer and let a solver find it; in this tutorial the residual is assembled from leaves already in NEML2’s catalog.
The physics¶
The Perzyna viscoplastic update we will solve is, in continuous form,
Backward-Euler time integration converts the rate equation \(\dot{\boldsymbol{\varepsilon}}^p = \dot{\gamma} \boldsymbol{N}\) into the residual
whose root is the updated plastic strain \(\boldsymbol{\varepsilon}^p\). Everything that feeds \(\dot{\boldsymbol{\varepsilon}}^p\) — elasticity, yield function, normality, the flow rate — depends on the unknown \(\boldsymbol{\varepsilon}^p\), which is what makes the problem implicit. Solving this residual is the return-mapping algorithm familiar from the solid-mechanics literature.
Building the residual¶
Each line in the system above maps onto a model in NEML2’s catalog,
and the residual is the ComposedModel that wires them together. The
full input file:
[Models]
[eq1]
type = SR2LinearCombination
from = 'strain plastic_strain'
to = 'elastic_strain'
weights = '1 -1'
[]
[eq2]
type = LinearIsotropicElasticity
coefficients = '1e5 0.3'
coefficient_types = 'YOUNGS_MODULUS POISSONS_RATIO'
strain = 'elastic_strain'
[]
[eq3]
type = SR2Invariant
invariant_type = 'VONMISES'
tensor = 'stress'
invariant = 'effective_stress'
[]
[eq4]
type = YieldFunction
yield_stress = 5
[]
[surface]
type = ComposedModel
models = 'eq3 eq4'
[]
[eq5]
type = Normality
model = 'surface'
function = 'yield_function'
from = 'stress'
to = 'flow_direction'
[]
[eq6]
type = PerzynaPlasticFlowRate
reference_stress = 100
exponent = 2
yield_function = 'yield_function'
[]
[eq7]
type = AssociativePlasticFlow
[]
[eq8]
type = SR2BackwardEulerTimeIntegration
variable = 'plastic_strain'
[]
[system]
type = ComposedModel
models = 'eq1 eq2 surface eq5 eq6 eq7 eq8'
[]
[model]
type = ImplicitUpdate
equation_system = 'eq_sys'
solver = 'newton'
[]
[]
[EquationSystems]
[eq_sys]
type = NonlinearSystem
model = 'system'
unknowns = 'plastic_strain'
[]
[]
[Solvers]
[newton]
type = Newton
rel_tol = 1e-08
abs_tol = 1e-10
max_its = 50
linear_solver = 'lu'
[]
[lu]
type = DenseLU
[]
[]
The [system] block is the residual model. Loading it gives back a
regular model whose only output is plastic_strain_residual:
import torch
import neml2
from neml2.types import SR2, Scalar
torch.set_default_dtype(torch.float64)
system = neml2.load_model("input.i", "system")
system
ComposedModel(
(eq1): SR2LinearCombination()
(eq2): LinearIsotropicElasticity()
(surface): ComposedModel(
(eq3): SR2Invariant()
(eq4): YieldFunction()
)
(eq5): Normality(
(surface): ComposedModel(
(eq3): SR2Invariant()
(eq4): YieldFunction()
)
)
(eq6): PerzynaPlasticFlowRate()
(eq7): AssociativePlasticFlow()
(eq8): SR2BackwardEulerTimeIntegration()
)
list(system.input_spec), list(system.output_spec)
(['strain', 'plastic_strain', 'plastic_strain~1', 't', 't~1'],
['plastic_strain_residual'])
The inputs are a candidate plastic_strain (the unknown we’ll solve
for), the prescribed strain, the previous-step plastic_strain~1,
and the current and previous times t, t~1. Evaluating system
plugs those into the algebra above and returns the residual at that
guess:
strain = SR2.fill(0.01, 0.005, -0.001) # prescribed total strain
guess = SR2.zeros() # initial guess for ε^p
plastic_strain_n = SR2.zeros()
t = Scalar(1.0)
t_n = Scalar(0.0)
(residual_at_guess,) = system(strain, guess, plastic_strain_n, t, t_n)
residual_at_guess
SR2(data=tensor([-24.2465, -1.5154, 25.7619, 0.0000, 0.0000, 0.0000],
grad_fn=<SubBackward0>), sub_batch_ndim=0, sub_batch_state=(), sub_batch_meta=(), k_ndim=0, k_state=(), k_pairing=())
The residual is far from zero — the guess ε^p = 0 is not a root.
The next section wraps the same residual in an ImplicitUpdate so a
Newton solver finds the root for us.
Wrapping the residual in an ImplicitUpdate¶
The input file adds three pieces around the residual: an
[EquationSystems] block that names the unknowns, a nonlinear solver
(here Newton; see the solver catalog for the other
choices), and a [model] block with type = ImplicitUpdate that
points at the two. Re-reading the same input file but asking for
model returns the wrapped object:
model = neml2.load_model("input.i", "model")
model
ImplicitUpdate(
(_residual_model): ComposedModel(
(eq1): SR2LinearCombination()
(eq2): LinearIsotropicElasticity()
(surface): ComposedModel(
(eq3): SR2Invariant()
(eq4): YieldFunction()
)
(eq5): Normality(
(surface): ComposedModel(
(eq3): SR2Invariant()
(eq4): YieldFunction()
)
)
(eq6): PerzynaPlasticFlowRate()
(eq7): AssociativePlasticFlow()
(eq8): SR2BackwardEulerTimeIntegration()
)
)
The inputs are the same as before — strain, plastic_strain, t,
… — but plastic_strain is now also an output: the solver produces
it. You still pass one in on the call so the solver has an initial
guess to start from.
list(model.input_spec), list(model.output_spec)
(['strain', 'plastic_strain', 'plastic_strain~1', 't', 't~1'],
['plastic_strain'])
Calling model runs the Newton solve and returns the converged
plastic strain:
(plastic_strain,) = model(strain, guess, plastic_strain_n, t, t_n)
plastic_strain
SR2(data=tensor([ 0.0052, 0.0003, -0.0055, 0.0000, 0.0000, 0.0000],
grad_fn=<_ImplicitUpdateFnBackward>), sub_batch_ndim=0, sub_batch_state=(), sub_batch_meta=(), k_ndim=0, k_state=(), k_pairing=())
A useful sanity check: evaluate the residual model at the solver’s answer and confirm it is at solver tolerance:
(residual_at_soln,) = system(strain, plastic_strain, plastic_strain_n, t, t_n)
residual_at_soln.data.norm().item()
8.269534715812813e-09
That is below the solver’s convergence threshold — the Newton block
declares rel_tol = 1e-8 and abs_tol = 1e-10; the solver stops when
either criterion is met.
Vectorized solves¶
ImplicitUpdate batches the same way every other NEML2 model does.
Stack N prescribed strains into a leading batch dimension, pass the
batched inputs in once, and the Newton solver advances every state
together — one step of all N problems, one linear solve of an
(N, 6, 6) Jacobian, until every problem in the batch is below
tolerance.
N = 5
strain_batch = torch.zeros(N, 6, dtype=torch.float64)
strain_batch[:, 0] = torch.linspace(0.005, 0.025, N) # ramp ε_xx
strain_b = SR2(strain_batch)
guess_b = SR2(torch.zeros(N, 6, dtype=torch.float64))
psn_b = SR2(torch.zeros(N, 6, dtype=torch.float64))
t_b = Scalar.ones(N)
tn_b = Scalar.zeros(N)
(plastic_strain_b,) = model(strain_b, guess_b, psn_b, t_b, tn_b)
plastic_strain_b.data
tensor([[ 0.0032, -0.0016, -0.0016, 0.0000, 0.0000, 0.0000],
[ 0.0065, -0.0033, -0.0033, 0.0000, 0.0000, 0.0000],
[ 0.0098, -0.0049, -0.0049, 0.0000, 0.0000, 0.0000],
[ 0.0132, -0.0066, -0.0066, 0.0000, 0.0000, 0.0000],
[ 0.0165, -0.0082, -0.0082, 0.0000, 0.0000, 0.0000]],
grad_fn=<_ImplicitUpdateFnBackward>)
No Python loop — see Vectorization for the general pattern.
Differentiating through the solve¶
The wrapped model is differentiable end-to-end through the Newton solve. Once the residual is zero, the implicit function theorem gives
and ImplicitUpdate reuses the converged Jacobian factorization to
apply it in the backward pass — no unrolling of the Newton iterations.
We can drive the prescribed strain with a scalar load multiplier
alpha, push it through the solver, read off a scalar functional of
the plastic-strain answer, and call .backward():
alpha = torch.tensor(1.0, dtype=torch.float64, requires_grad=True)
strain_base = torch.tensor([0.01, 0.005, -0.001, 0.0, 0.0, 0.0], dtype=torch.float64)
strain_v = SR2(alpha * strain_base)
(ps,) = model(strain_v, guess, plastic_strain_n, t, t_n)
eqv_plastic_strain = torch.sqrt(torch.tensor(2.0 / 3.0)) * ps.data.norm()
eqv_plastic_strain.backward()
float(eqv_plastic_strain), float(alpha.grad)
/tmp/ipykernel_3375/205733107.py:9: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.
Consider using tensor.detach() first. (Triggered internally at /pytorch/torch/csrc/autograd/generated/python_variable_methods.cpp:838.)
float(eqv_plastic_strain), float(alpha.grad)
(0.006223590888647602, 0.0063125968422317325)
The same backward pass works for parameters (e.g. the yield stress,
the Perzyna reference stress) if you mark them with
requires_grad=True — gradient-based training and inverse problems
plug into this hook without changing the forward model.
Note
ImplicitUpdate applies the implicit function theorem in backward:
it re-assembles the system Jacobian at the converged state and runs
a single adjoint linear solve, rather than unrolling the Newton
iterations. Because the IFT formula is exact, the returned gradients
agree with finite differences to roughly machine precision regardless
of how many Newton iterations the forward solve took. Picking a good
predictor shortens the forward solve without changing the backward
result.
Where to go next¶
Model composition —
ImplicitUpdateis just one more model. It can sit inside a biggerComposedModel, feed the next stage of a pipeline, or be composed with other implicit updates.Transient driver — for time-stepping the Perzyna update across a load history,
TransientDriverhandles the outer loop and reuses theImplicitUpdateat each step.Vectorization — the leading batch dimension we used above is the same one every NEML2 model accepts.