Function approximation¶
This page provides an overview of the pre-built function approximators in diff-soup. The recipe, data, and inference problem are those of fitting parameters.
An overview of function approximation in diff-soup¶
During inference, unknown functions can be approximated using both
classical and deep learning approaches, through the built-in
approximators on this page or a custom one. In general, any unknown can
be parameterized as an equinox module whose array fields are the
coefficients and declared with spec.function
(named functions). For
example, to parameterize a reaction rate \(r(u) = e^{a u}\) with the
learnable coefficient \(a\):
class Exponential(eqx.Module):
rate: jax.Array
def __call__(self, u):
return jnp.exp(self.rate * u)
def learnable_paths(self):
return ("rate",)
decay = spec.function("decay", Exponential(jnp.array(0.0)), learnable=True)
@spec.equation(writes="u", reads=("u",))
def reaction_diffusion(q):
return q.p.D * q.ops.laplacian(q.u) - q.fn.decay(q.u) * q.u
- The array fields of the module hold the coefficients, and
__call__evaluates the function. learnable_paths()names the fields the fit moves. A module without it has every array field fitted, the weights and biases of a network included.spec.function(name, module, learnable=True)declares the function, andq.fn.<name>evaluates it inside an equation.pointwise=Trueapplies a module that maps one sample at every node (named functions).- The fit selects every declared learnable function by default, alongside the learnable parameters (pose the inference problem).
The built-in approximators live in diff_soup.inference.approx and
fall into three classes by their argument:
- functions of the state variables, for physical operators and constitutive relationships,
- functions of position, for fields on spatial domains, and
- functions of time, for time-varying signals.
Physical operators and constitutive relationships¶
For a function of the state variables, the classical approximation is a truncated expansion in orthogonal polynomials,
where \(P_k\) is the Legendre polynomial of degree \(k\) on \([-1, 1]\), \(x\) is the state mapped onto that interval, and the coefficients \(a_k\) are the learnable parameters. Orthogonality keeps the fit well conditioned as the degree grows, and a transform applied to the raw expansion builds in known structure, such as positivity through an exponential.
Orthogonal polynomials in one dimension¶
Legendre(coeffs, interval=(0.0, 1.0), transform=None, clip=None) is
the learnable Legendre expansion of one state variable.
coeffsholds the coefficients \(a_0, \dots, a_K\), one per degree, and is the fitted field.interval=is the range of the physical input, mapped onto \([-1, 1]\). The default is(0.0, 1.0).transform=is a functiontransform(u, p)of the inputuand the raw expansionp, applied to the result. The default returns the raw expansion.lambda u, p: jnp.exp(p)gives a positive function.clip=(lo, hi)clips the input to that range before evaluation. The default evaluates the input as given.
For example, to fit a diffusivity \(D(u) = \exp\big(\sum_k a_k P_k\big)\) with three coefficients, starting from the constant \(D = 0.1\):
from diff_soup.inference.approx import Legendre
spec = ds.problem("heat")
spec.domain("rod", rod)
spec.field("u", mass="differential", scale=1.0, init=lambda x: x * (1.0 - x))
coeffs = jnp.array([np.log(0.1), 0.0, 0.0])
D_of_u = spec.function("D_of_u", Legendre(coeffs, interval=(0.0, 0.25), transform=lambda u, p: jnp.exp(p)),
learnable=True)
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.ops.div_flux(q.fn.D_of_u(q.u), q.u)
spec.bc.dirichlet("u", node_indices=np.array([0, n - 1]), value=0.0)
problem = spec.build()
y0 = spec.assemble_state({})
infproblem = ds.inference.InferenceProblem(
ds.inference.Experiment(problem, solver, (0.0, 0.1), y0, (measurement,)), parameters=D_of_u)
result = ds.inference.optsolve(infproblem)
The expansion evaluates \(\sum_k a_k P_k(x)\), with \(x\) the state
mapped onto \([-1, 1]\) and the coefficients \(a_k\) the fitted values,
and it broadcasts over the nodal array on its own, so it is declared
without pointwise=True. ds.inference.Tikhonov weights the
coefficient of degree \(k\) by \(1 + k(k + 1)\) by default, which damps
the high-degree coefficients first
(regularization).
The raw expansions, for composing a parameterization by hand:
LegendreExpansion(params)evaluates \(\sum_k a_k P_k(x)\) for an input already on \([-1, 1]\).ChebyshevExpansion(params)evaluates \(\sum_k a_k T_k(x)\), with \(T_k\) the Chebyshev polynomials, for an input already on \([-1, 1]\).MonomialExpansion(params)evaluates \(\sum_k a_k u^k\).mono_to_legendre(mono_coeffs, n_leg_terms, offset=0.0)converts monomial coefficients on \([0, 1]\) to Legendre coefficients by a least-squares fit.
Sparse orthogonal polynomials in several dimensions¶
For a function of several state variables, \(f(u_1, \dots, u_d)\), the
sparse orthogonal polynomial expansion runs over products \(P_{k_1}(x_1) \cdots P_{k_d}(x_d)\) with
the total degree \(k_1 + \dots + k_d \le K\), so the number of terms
grows far more slowly than the full tensor product.
SparseLegendre(*, intervals, degree, truncation="total", coeffs=None,
transform=None, key=None, init_std=0.0) is the learnable expansion of
several state variables.
intervals=gives the range of each input, one(lo, hi)pair per variable, each mapped onto \([-1, 1]\).degree=is the total degree \(K\).truncation="total"keeps the products with \(k_1 + \dots + k_d \le K\)."hyperbolic"keeps those with \((k_1 + 1) \cdots (k_d + 1) \le K + 1\), fewer mixed terms at the same degree.coeffs=holds one coefficient per retained product, in the order ofbasis.indices, the terms sorted by total degree and then lexicographically. The default starts every coefficient at zero, andkey=withinit_std=draws random starting coefficients.transform=is a functiontransform(u_1, ..., u_d, p)of the inputs and the raw expansion, applied to the result.sparse_legendre_weights(basis)gives the weight \(1 + k(k + 1)\) of each term by its total degree \(k\), fords.inference.Tikhonov(weights=).
For example, to fit a reaction rate that depends on the concentration and the temperature, \(r(c, T)\), with total degree two:
from diff_soup.inference.approx import SparseLegendre
rate = spec.function("rate", SparseLegendre(intervals=((0.0, 1.0), (250.0, 350.0)), degree=2,
coeffs=jnp.array([0.25, 0.0, 0.0, 0.0, 0.0, 0.0])), learnable=True)
@spec.equation(writes="c", reads=("c", "T"))
def reaction(q):
return -q.fn.rate(q.c, q.T) * q.c
Neural networks¶
Modern deep learning methods are supported through equinox modules declared the same way. For example, to learn a reaction term with a multilayer perceptron (MLP):
network = eqx.nn.MLP("scalar", "scalar", width_size=4, depth=1, activation=jax.nn.tanh,
key=jax.random.PRNGKey(0))
reaction = spec.function("reaction", network, learnable=True, pointwise=True)
@spec.equation(writes="u", reads=("u",))
def reaction_diffusion(q):
return q.p.D * q.ops.laplacian(q.u) + q.fn.reaction(q.u)
- A network maps one sample, so it is declared with
pointwise=True. - Every weight and bias is fitted.
- The optimizer choice for a network is covered under optimization solvers.
Three modules build known structure into a network:
ICNN(in_size, *, width, depth, key, activation="softplus")is a scalar network convex in every input.activation=is"softplus"or"relu".MonotoneMLP(in_size, *, width, depth, key, monotone_in=None, activation="tanh")is a scalar network with a declared sign of the derivative in each input.monotone_in=has one entry per input,+1for increasing,-1for decreasing, and0for unconstrained. The default is increasing in every input.Transform(module, fn)appliesfnto the output ofmodule.fnis a function, or one of"exp","softplus","abs","square","even", and"odd", the last two averaging the module at \(x\) and \(-x\).
The weights are fitted in every case, and a network of one input takes the nodal array as it is. For example, a reaction rate that increases with the state:
from diff_soup.inference.approx import MonotoneMLP
rate = spec.function("rate", MonotoneMLP(1, width=8, depth=1, key=jax.random.PRNGKey(0)), learnable=True)
@spec.equation(writes="u", reads=("u",))
def reaction_diffusion(q):
return q.p.D * q.ops.laplacian(q.u) - q.fn.rate(q.u) * q.u
Neural operators¶
A neural operator maps a whole field to a field, so a closure that depends on the profile as a whole, and not on the value at one point, can be learned.
DeepONet(branch_size, trunk_size, *, width=64, depth=3, key, n_basis=None)reads the field atbranch_sizefixed sensors and evaluates the output at coordinates of dimensiontrunk_size,q.fn.G(q.u, q.x)inside an equation.FNO1d(*, modes, width, depth, key, in_channels=1, out_channels=1)is a Fourier neural operator on a uniform one-dimensional grid,q.fn.K(q.u)inside an equation, withmodesthe number of Fourier modes kept,widththe hidden channels, anddepththe number of spectral layers.FNO2dis the same on a uniform two-dimensional grid, withmodesone integer or a pair.- A Fourier operator is refused on a domain whose grid is not uniform.
For example, a diffusivity that depends on the whole profile, kept positive through a transform:
from diff_soup.inference.approx import FNO1d, Transform
K = spec.function("K", Transform(FNO1d(modes=3, width=4, depth=1, key=jax.random.PRNGKey(0)), "softplus"),
learnable=True)
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.ops.div_flux(q.fn.K(q.u), q.u)
Function approximation on spatial domains¶
For a function of position, the classical approximation is a Karhunen-Loeve (KL) expansion of a Gaussian random field (GRF), a sum of the leading eigenmodes of the field's covariance kernel weighted by learnable coefficients, with the number of modes kept setting the resolution of the field.
Karhunen-Loeve expansions¶
KLBasis.from_domain(domain, *, length_scale, sigma=1.0, sigma_psi0=1.0,
n_modes=20, key=None, init_std=0.0) builds the expansion on the nodes of
a registered grid or mesh by eigendecomposing the covariance matrix over
them, weighted by the mass of each node.
domainis the grid or mesh the field lives on. The basis records the domain, and evaluating it on the nodes of another domain is refused.length_scaleis the correlation length of the field,sigma=its standard deviation,sigma_psi0=the standard deviation of the constant mode, andn_modes=the number of modes kept. The covariance kernel is the squared exponential.key=andinit_std=draw random starting coefficients. The default starts every coefficient at zero.- Calling the basis,
q.fn.<name>(), returns the field at every node, andkl.k()returns its exponential, the positivity transform. learnable_paths()names the coefficientsZ, so the precomputed modes stay fixed in a fit.KLBasis.from_mesh(points, length_scale, ...)builds the expansion from a coordinate array instead, without the mass weighting.KLBasis.from_covariance_operator(A, M, n_modes=)builds it from a discretized covariance operatorAand a mass matrixM, andKLBasis.from_lumped_covariance(covariance_operator, lumped_mass, n_modes=)from a diagonal mass, with the modes orthonormal in that mass.GRFBasis.from_domain(domain, *, M, init_std=0.1, key=None)is the special case of a rectangle, with cosine modes of total degree up toMin place of the computed eigenmodes.
For example, to fit a diffusivity field on the rod with three modes:
from diff_soup.inference.approx import KLBasis
kl = KLBasis.from_domain(rod, length_scale=0.25, sigma=0.5, n_modes=3)
psi = spec.function("psi", kl, learnable=True)
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.ops.div_flux(jnp.exp(q.fn.psi()), q.u)
Coordinate networks¶
A coordinate network is a function of position, \(x \mapsto f(x)\), with a network in place of the expansion. Three are provided, and each takes one coordinate or every node at once.
FourierFeatureNet(in_size, *, n_frequencies=8, width=32, depth=2, key, out_size=1, activation="tanh", transform=None, bounds=None)feeds the sines and cosines \(\sin(2^k \pi x)\) and \(\cos(2^k \pi x)\), \(k = 0, \dots, n - 1\), of each coordinate into a multilayer perceptron. The network is fitted and the frequencies stay fixed.SIREN(in_size, *, width=32, depth=3, key, out_size=1, omega_0=30.0, transform=None, bounds=None)is a network with the sine activation \(\sin(\omega_0 z)\) in every hidden layer and a linear output.WIRE(in_size, *, width=32, depth=3, key, out_size=1, omega_0=20.0, s_0=10.0, transform=None, bounds=None)is SIREN with the Gabor wavelet \(\cos(\omega_0 z)\, e^{-(s_0 z)^2}\) in place of the sine (Saragadam et al., 2023). The defaults are the paper's.bounds=gives the range of each coordinate, one(lo, hi)pair per dimension, mapped onto \([-1, 1]\) before the network.from_domain(domain, ...)takes the dimension and the bounds from a registered grid or mesh.transform=is a functiontransform(x, raw)of the position and the raw output, applied to the result,lambda x, raw: jnp.exp(raw)for a positive field.
\(\omega_0\) sets how fast a unit oscillates. In WIRE, \(s_0\) sets how local it is, so a compact feature such as a front or a streak leaves the field smooth elsewhere. SIREN suits a field that varies everywhere at one scale.
For example, to fit a diffusivity field on the rod:
from diff_soup.inference.approx import FourierFeatureNet
D_x = spec.function("D_x", FourierFeatureNet.from_domain(rod, n_frequencies=2, width=5, depth=1, key=jax.random.PRNGKey(0),
transform=lambda x, raw: 0.2 * jnp.exp(raw)), learnable=True)
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.ops.div_flux(q.fn.D_x(q.x), q.u)
Time-varying signals¶
For a function of time, such as a boundary value, a forcing term, or a
control input, a signal \(s(t)\) is built on knots
\(t_0 < t_1 < \dots < t_K\) or on a period \(T\), with the values at
the knots or the harmonic amplitudes as the learnable parameters. Each
basis is an equinox module evaluated inside an equation as
q.fn.<name>(q.t).
PiecewiseConstant(knots, values)is the step function \(s(t) = v_k\) between \(t_k\) and \(t_{k+1}\), with one value per segment.PiecewiseLinear(knots, values)interpolates linearly between the values at the knots.CubicHermite(knots, values, slopes)is a cubic on each segment matched to the value and the slope at both of its knots, so the signal is smooth.Fourier(period, cos_coeffs, sin_coeffs, mean)is the truncated series \(s(t) = \bar s + \sum_{k=1}^{n} c_k \cos(2\pi k t / T) + s_k \sin(2\pi k t / T)\), periodic with period \(T\).
The knots and the period define the basis and stay fixed in a fit. A time outside the knots takes the value at the nearest end, and a time exactly on an interior knot takes the segment to its left, the segment a solver step ending there was integrated against. A multi-component signal carries a trailing axis of size \(m\) on its values.
For example, to fit a forcing term that steps once at \(t = 0.05\):
from diff_soup.inference.approx import PiecewiseConstant
knots = jnp.array([0.0, 0.05, 0.1])
drive = spec.function("drive", PiecewiseConstant(knots, jnp.zeros(2)), learnable=True)
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.p.D * q.ops.laplacian(q.u) + q.fn.drive(q.t)
A signal also drives a boundary value through the callback form of the
condition (boundary conditions),
and is fitted there like any other declared function. A fixed trace of
boundary values, as in
data assimilation,
is passed as value_fn= instead. For example, to fit the value at the
left end of the rod:
from diff_soup.inference.approx import PiecewiseLinear
inlet = spec.function("inlet", PiecewiseLinear(knots, jnp.zeros(3)), learnable=True)
spec.bc.dirichlet("u", node_indices=np.array([0]), fn=lambda q, t: q.fn.inlet(t))