1. PINN initialization and optimization
The example below shows a 2D Poisson problem as a physical
model, posed on a square domain. The
ApproximationSpace wraps the network (an
MLP here) to be later used in a PINN or another
approximation method. Finally, the
Projector ties model, space and sampler into
one object whose .project(...) runs the
optimization loop and returns the optimized space.
from scimba_jax.domains.meshless_domains.domains_2d import Square2D
from scimba_jax.nonlinear_approximation.approximation_spaces.approximation_spaces import (
ApproximationSpace,
)
from scimba_jax.nonlinear_approximation.integration.monte_carlo import (
DomainSampler,
TensorizedSampler,
)
from scimba_jax.nonlinear_approximation.networks.mlp import MLP
from scimba_jax.nonlinear_approximation.numerical_solvers.projectors import Projector
from scimba_jax.physical_models.elliptic_pde.laplacians import LaplacianDirichletND
domain = Square2D([(-1.0, 1.0), (-1.0, 1.0)], is_main_domain=True)
sampler = TensorizedSampler([DomainSampler(domain)], bc=False)
nn = MLP(in_size=2, out_size=1, hidden_sizes=[16, 16], key=key)
# post_processing strongly enforces the BC: u_h = 0 on the boundary,
# for any network weights.
space = ApproximationSpace(
{"x": 2},
[(nn, "scalar", None)],
model_type="x",
post_processing=lambda approx, xy: approx * (1 - xy[0] ** 2) * (1 - xy[1] ** 2),
)
model = LaplacianDirichletND(domain, lambda x: source(x), bc="strong", model_type="x")
# A PINN is optimized by a Projector, like every approximation space in scimba_jax.
pinn = Projector(model, space, sampler) # default optimizer: see below
key, pinn = pinn.project(key, space, n_epochs=200, n_colloc=1000)
# ENG, 200 epochs: loss 95.2 -> 5.5e-09.
2. Brief overview of the math
Optimizing a PINN reduces to the problem of minimizing a sum of squared residuals, one term per piece of the problem. Pieces can be the interior PDEs, the boundary conditions, data, etc. Hence, the loss function reads
\[\mathcal{L}(\theta) = \sum_{\text{label}} \frac{1}{N_{\text{label}}} \sum_{i=1}^{N_{\text{label}}} \left\lVert R_{\text{label}}(u_\theta, x_i) \right\rVert^2.\]
Therefore, we have as many residual terms as sub-domains and
boundary pieces the model declares. For instance, a
Dirichlet boundary and a Neumann boundary each get their own
label and their own term, weighted and summed into one
scalar loss. The network itself can be either
scalar-valued for a single unknown
(temperature, pressure) or
vector-valued when the problem couples
several fields at once. The
ApproximationSpace's
"scalar"/"vec" tag says which. For
instance, a 2D Navier-Stokes velocity-pressure system is a
length-3 vector output [u_x, u_y, p].
3. Optimization algorithms: first-order vs. quasi-second-order
To optimize the Projector, we rely on a single
keyword (optimizer), which is swappable without
touching the model, the space or the sampler. A
first-order optimizer only ever uses the
gradient:
\(\theta^{(k+1)} \gets \theta^{(k)} - \eta\, \nabla \mathcal{L}(\theta^{(k)})\).
Adam, AdamW and
Muon are examples of such optimization
algorithms. Conversely, a
quasi-second-order optimizer also uses some
model of curvature C to rescale that gradient:
\(\theta^{(k+1)} \gets \theta^{(k)} - \eta\, C^{-1} \nabla \mathcal{L}(\theta^{(k)}).\)
For instance, L-BFGS,
SS-BFGS and
SS-Broyden build C from
successive gradients (in a quasi-Newton manner).
ENG, its matrix-free twin
MFENG, ANaGRAM,
RATATAM and
RATATAM-LM build it instead from the PDE's
own natural-gradient/Gauss-Newton metric. It means that the
optimizer uses a curvature that comes from the physics,
rather than from an optimization history. The default is
ENG (Energy Natural Gradient), owing to its
stability and convergence speed.
# Values of the `optimizer=` argument of the `Projector`:
# "ENG" Energy Natural Gradient (default), metric
# induced by the PDE energy
# "MFENG" Matrix-free ENG, without assembling the Gram
# matrix
# "Adam" Usual deep learning optimizer, when the ENG
# Gram matrix is too costly
# "AdamW" Adam with decoupled weight decay (Loshchilov
# & Hutter)
# "Muon" Sets every singular value of the update to 1
# "L-BFGS" Quasi-Newton, second order without a Hessian
# "SS-BFGS" Self-scaled BFGS variant, tuned for PINN
# losses
# "SS-Broyden" Same family, with Broyden's update
# "ANaGRAM" Natural gradient method for large networks
# "RATATAM" Levenberg-Marquardt-type PINN optimizer
# Changing the optimizer does not change the model, the space
# or the sampler.
pinn = Projector(model, space, sampler, optimizer="Adam")
key, pinn = pinn.project(
key, space, n_epochs=1000, n_colloc=1000
)
# a dict, one entry per residual plus "total"
print(pinn.best_loss["total"])
# Adam, 1000 epochs: loss 95.2 -> 0.32, against 5.5e-09 for
# ENG in 200 epochs.
# The best optimizer depends on the problem.
A PINN and a mesh-based solver both approximate a PDE, but they have different trade-offs. Indeed, for PINNs, there is no mesh to build, but the linear systems inherent to mesh-based solvers are replaced by nonconvex optimization problems. In low dimensions (up to 4), if you already have a suitable mesh, consider using a Mesh-based solver instead.