Concepts
The residual density
Everything starts from one function. Write the weak residual of the forward PDE as an integrand evaluated at a single quadrature point:
def pde_varf(u, m, p, x):
return jnp.exp(m.val) * hm.inner(u.grad, p.grad) - f(x) * p.val
The forward problem is \(\partial R/\partial p = 0\), where \(R = \int_\Omega \text{density}\,dx\). Reading the residual as the PDE part of a Lagrangian is what makes the rest automatic.
u, m and p are the state, the parameter and the adjoint. Each arrives
as a Field, a small record holding the field’s value
and gradient at that point:
attribute |
scalar space ( |
vector space ( |
|---|---|---|
|
scalar |
shape |
|
shape |
shape |
x is the physical position of the quadrature point, as an array of length
sdim. For H(curl) and H(div) spaces the record carries .curl or .div
instead of .grad, because the gradient is not in the space; see
Finite element spaces.
The density must be traceable by JAX, which in practice means: use jax.numpy
rather than numpy, and do not branch on the value of a field (use
jnp.where). Within those rules it is ordinary Python; see
Densities a form language cannot express.
The blocks, and where they come from
A form-language library builds the operators of the inverse problem by applying
ufl.derivative to a UFL form. Here they come from differentiating the
quadrature sum with JAX, vectorized over elements:
block |
what it is |
|---|---|
\(A = \partial_u \partial_p R\) |
forward Jacobian |
\(C = \partial_m \partial_p R\) |
parameter Jacobian |
\(W_{uu} = \partial_u\partial_u R\) |
second-order state block |
\(W_{um} = \partial_u\partial_m R\) |
mixed block |
\(W_{mm} = \partial_m\partial_m R\) |
second-order parameter block |
third derivatives |
contracted with two directions, for Taylor expansions of a QoI |
Because all of them are derivatives of one object evaluated on one quadrature
rule, they are mutually consistent by construction. That is what makes the adjoint
gradient exact for the discretized problem rather than merely close to the
continuous one, and it is why modelVerify’s finite-difference slopes come out at
1.000 rather than 0.98.
Variables and the state vector
A point in the problem is a list x = [u, m, p] indexed by the constants
STATE,
PARAMETER and
ADJOINT. Entries are
ParVector objects: a numpy array of this
rank’s true dofs, aliased by an mfem.HypreParVector so MFEM and hypre can act
on the same memory without a copy.
x = model.generate_vector() # [u, m, p], all zero
x[PARAMETER] = prior.mean.copy()
model.solveFwd(x[STATE], x)
model.solveAdj(x[ADJOINT], x)
cost, reg, misfit = model.cost(x)
The three objects of an inverse problem
PDEVariationalProblemThe forward problem, built from the residual density and the essential boundary conditions. Supplies the solves and all the blocks above.
- A prior
BiLaplacianPriorand relatives: a Matern-class Gaussian measure whose precision is a differential operator, so sampling and applying \(R^{-1}\) are both linear solves rather than dense factorizations. See Priors.- A misfit
What the data says, as a functional of the state:
DiscreteStateObservationfor pointwise data,ContinuousStateObservationfor a weighted \(L^2\) norm over a region or the boundary. See Observations and misfits.
Model ties the three together and is what the
optimizers, the Hessian and the samplers consume.
Densities a form language cannot express
Because the density is differentiated rather than compiled from a symbolic form, its body may contain anything JAX can handle. Two cases that are awkward or impossible in UFL and ordinary here:
A learned closure. A small neural network inside the residual, with its weights as ordinary captured arrays:
def pde_varf(u, m, p, x):
z = jnp.stack([u.val, m.val])
h = jnp.tanh(W1 @ z + b1)
kappa = jnp.exp(W2 @ h + b2)[0] # a learned conductivity
return kappa * hm.inner(u.grad, p.grad)
An inner solve for a local constitutive law. A few Newton steps on a scalar equation, differentiated through:
def pde_varf(u, m, p, x):
def residual(s): # s solves a local nonlinear law
return s ** 3 + s - jnp.exp(m.val) * u.val
s = 0.0
for _ in range(3): # unrolled Newton
s = s - residual(s) / (3.0 * s ** 2 + 1.0)
return s * hm.inner(u.grad, p.grad)
Both give exact gradients and Hessians: the finite-difference slopes are 1.000 and 1.001 respectively, measured. The reduced Hessian is still assembled from exact second derivatives, so Newton-CG converges at the same rate it does for a textbook density.
What you do not write
Not the adjoint equation, not the parameter Jacobian, not the second-order blocks,
not the boundary-condition bookkeeping for any of them, and not a separate
Gauss-Newton approximation, which is the same object with one term dropped, as
ReducedHessian does with a flag.