Part VI: Discovery in Scientific Domains
Chapter 50: Discovery AI for Physics and Engineering

50.3 Neural Operators for PDEs

"I learned the solution operator for Navier-Stokes in six hours of training. Now I solve new configurations in milliseconds. The CFD team is filing a grievance with HR."

A Fourier Neural Operator With a Suspiciously Low Error Bar
The Big Picture

A physics-informed neural network (PINN) (Section 50.2) learns the solution to one partial differential equation (PDE) instance: one set of initial conditions, one set of boundary conditions, one geometry. Change any of these and you must retrain. A neural operator learns the mapping from input functions (initial conditions, forcing terms, coefficient fields) to output functions (the PDE solution). Once trained, it generalizes to new inputs without retraining. This is the difference between learning a function \(u = f(x)\) and learning a functional \(\mathcal{G}: \mathcal{A} \to \mathcal{U}\) that maps between infinite-dimensional function spaces. The theoretical foundation is the universal approximation theorem for operators (Chen and Chen, 1995), which guarantees that neural networks can approximate any continuous nonlinear operator to arbitrary accuracy.

1. From Functions to Operators

What if you could hand a neural network an entire pressure field, temperature map, or velocity profile and get back the solution to a completely new configuration in milliseconds, without ever re-solving the underlying equations? That is exactly what a neural operator does: instead of learning a mapping between finite-dimensional vectors (\(f: \mathbb{R}^n \to \mathbb{R}^m\)), it learns a mapping between function spaces, \(\mathcal{G}: \mathcal{A} \to \mathcal{U}\), where \(\mathcal{A}\) and \(\mathcal{U}\) are spaces of functions defined on some domain \(D \subset \mathbb{R}^d\).

A neural operator takes an entire function as input and produces an entire function as output. Unlike conventional networks that map fixed-size vectors, it operates on function spaces. Most physics and engineering problems require solving the same equation repeatedly for different configurations. A neural operator amortizes this cost by learning the solution map itself: each new configuration requires only a single forward pass (milliseconds) instead of a full numerical solve (seconds to hours). The core mechanism parameterizes the integral kernel of the solution operator with a neural network. Input-output function pairs in the training data teach the network to reproduce the operator that the PDE defines. Use a neural operator instead of a classical solver when you need thousands of evaluations across varying inputs (optimization loops, uncertainty quantification, real-time control); use a classical solver when you need a single high-fidelity answer or when training data is unavailable.

Consider the steady-state Darcy flow equation, which models groundwater flow through porous media:

$$-\nabla \cdot (a(x) \nabla u(x)) = f(x), \quad x \in D$$

Here \(a(x)\) is the permeability field (how easily fluid flows through the material at each point), \(u(x)\) is the pressure field (the solution), and \(f(x)\) is the source term. The operator we want to learn maps the permeability field to the pressure field: \(\mathcal{G}: a(\cdot) \mapsto u(\cdot)\). This is an operator because both the input \(a\) and the output \(u\) are functions of spatial position, not finite-dimensional vectors.

The key advantage is zero-shot generalization: once \(\mathcal{G}\) is learned, it maps any permeability field to its corresponding pressure solution, including fields never seen during training. A classical solver must re-solve the PDE from scratch for each new \(a(x)\). A neural operator evaluation takes milliseconds. In short: a neural operator learns the solution map once so that every future query costs a forward pass, not a fresh solve.

Key Insight: Resolution Invariance

Because neural operators learn mappings between function spaces rather than between fixed grids, they are inherently resolution-invariant. A Fourier Neural Operator (FNO) trained on 64x64 grids can be evaluated on 256x256 grids (or even irregular point clouds) without retraining. The input and output functions are the mathematical objects; the grid is merely a discretization. This property is unique to operator learning and has no analogue in standard neural networks, which require inputs and outputs of fixed dimension.

Common Misconception

A frequent misunderstanding is that "resolution invariance" means an FNO trained at low resolution will produce equally accurate results when evaluated at much higher resolution. In reality, resolution invariance means the architecture can accept inputs at any resolution, but accuracy at resolutions far beyond the training distribution will degrade because the model has never seen the fine-scale features that higher resolution reveals. Always validate on a few high-resolution samples before trusting a model trained on coarser grids.

2. The Universal Approximation Theorem for Operators

Chen and Chen (1995) proved the universal approximation theorem for operators, the theoretical backbone of neural operator methods. Lu et al. (2021) later extended the result. The theorem guarantees that for any continuous nonlinear operator \(\mathcal{G}: \mathcal{A} \to \mathcal{U}\) and any \(\epsilon > 0\), a neural network architecture exists that approximates \(\mathcal{G}\) to within \(\epsilon\) in the appropriate norm.

More precisely, let \(\mathcal{A}\) be a compact set in a Banach space (a complete normed vector space, generalizing Euclidean space to infinite dimensions) and \(\mathcal{G}: \mathcal{A} \to C(K)\) be a continuous operator mapping into continuous functions on a compact set \(K\). Then \(\mathcal{G}\) can be approximated by a network of the form:

$$\mathcal{G}(a)(y) \approx \sum_{k=1}^{p} \underbrace{c_k \sigma\!\left(\sum_{j=1}^{m} \xi_{kj}\, a(x_j) + \zeta_k \right)}_{\text{branch net (encodes } a)} \cdot \underbrace{\tau_k(y)}_{\text{trunk net (encodes } y)}$$

This is the DeepONet architecture: a branch network that processes the input function \(a\) (evaluated at sensor locations \(x_1, \dots, x_m\)) and a trunk network that processes the evaluation point \(y\). The output is the inner product of the branch and trunk outputs.

DeepONet is theoretically elegant, but for PDE problems on regular grids, the FNO achieves better accuracy with simpler training. The FNO is therefore the primary architecture below; DeepONet returns in Section 6.

3. Fourier Neural Operator Architecture

The key observation behind the FNO is that many PDE operators are naturally expressed in Fourier space. Convolution in physical space becomes pointwise multiplication in frequency space. The FNO exploits this by learning the operator kernel directly in the Fourier domain.

An FNO layer performs four operations, illustrated in Figure 50.3.1 below:

  1. Fourier transform: apply the Fast Fourier Transform (FFT) to the input function, mapping it from physical space to frequency space.
  2. Spectral filtering: multiply the Fourier coefficients by a learnable weight tensor \(R_\phi\), keeping only the lowest \(k_{\max}\) modes. This is the learned kernel in Fourier space.
  3. Inverse Fourier transform: apply the inverse FFT to return to physical space.
  4. Local bypass: add a pointwise linear transformation \(Wv(x)\) (a 1x1 convolution) that captures local, high-frequency interactions missed by the truncated Fourier representation.
Fourier Neural Operator (FNO) layer architecture
Figure 50.3.1: Architecture of a single Fourier Neural Operator layer, showing the parallel global spectral convolution path (FFT, learnable spectral filtering, inverse FFT) and local linear bypass path that merge before nonlinear activation.

Mental Model

Think of spectral convolution like an audio equalizer on a mixing board. An equalizer decomposes a music signal into frequency bands (bass, midrange, treble), lets you boost or cut each band independently with a slider, then recombines the adjusted bands back into a single audio signal. The FNO does the same thing with spatial data: the FFT decomposes the input field into spatial frequency components (large-scale smooth patterns are "bass," small-scale rapid variations are "treble"), the learnable weight tensor \(R_\phi\) acts as the set of sliders that boost or suppress each frequency, and the inverse FFT recombines everything into the output field. The local bypass path is like a separate "direct monitor" channel that preserves crisp transients the equalizer's limited number of bands would smear out. Training teaches the network which spatial frequencies to amplify and which to dampen for a given PDE, just as a sound engineer learns which EQ settings produce the right tonal balance for a particular recording.

Mathematically, one FNO layer maps input \(v_t\) to output \(v_{t+1}\):

$$v_{t+1}(x) = \sigma\!\Big(\underbrace{\mathcal{F}^{-1}\!\big(R_\phi \cdot \mathcal{F}(v_t)\big)(x)}_{\text{global spectral convolution}} + \underbrace{W v_t(x) + b}_{\text{local linear bypass}}\Big)$$

where \(\mathcal{F}\) denotes the FFT, \(R_\phi\) is the learnable spectral weight tensor, \(W\) is a pointwise weight matrix, and \(\sigma\) is a nonlinear activation (typically Gaussian Error Linear Unit, or GELU). Stacking \(L\) such layers (typically \(L = 4\)) with a lifting layer (a pointwise linear map that projects the low-dimensional input, such as coordinates plus the coefficient field, into a higher-dimensional channel space) at the input and a projection layer (a pointwise linear map that collapses the high-dimensional channel representation back to the scalar solution field) at the output gives the complete FNO architecture.

Checkpoint

So far: an FNO layer decomposes its input into frequency components via FFT, applies a learnable spectral filter to the lowest modes, transforms back via inverse FFT, adds a local linear bypass, and passes the sum through a nonlinearity; stacking several such layers between a lifting layer and a projection layer yields the full architecture.

import torch
import torch.nn as nn
import torch.fft

class SpectralConv2d(nn.Module):
    """Spectral convolution layer for 2D FNO."""

    def __init__(self, in_channels, out_channels, modes1, modes2):
        super().__init__()
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.modes1 = modes1  # number of Fourier modes (x)
        self.modes2 = modes2  # number of Fourier modes (y)

        # Learnable weights in Fourier space (complex-valued)
        scale = 1.0 / (in_channels * out_channels)
        self.weights1 = nn.Parameter(
            scale * torch.rand(in_channels, out_channels,
                               modes1, modes2, dtype=torch.cfloat)
        )
        self.weights2 = nn.Parameter(
            scale * torch.rand(in_channels, out_channels,
                               modes1, modes2, dtype=torch.cfloat)
        )

    def compl_mul2d(self, input, weights):
        """Complex multiplication in Fourier space."""
        # input: (batch, in_ch, x, y)
        # weights: (in_ch, out_ch, x, y)
        return torch.einsum("bixy,ioxy->boxy", input, weights)

    def forward(self, x):
        batch_size = x.shape[0]

        # FFT along spatial dimensions
        x_ft = torch.fft.rfft2(x)

        # Multiply relevant Fourier modes with learnable weights
        out_ft = torch.zeros(
            batch_size, self.out_channels,
            x.size(-2), x.size(-1) // 2 + 1,
            dtype=torch.cfloat, device=x.device,
        )
        # Low-frequency modes (positive frequencies)
        out_ft[:, :, :self.modes1, :self.modes2] = \
            self.compl_mul2d(
                x_ft[:, :, :self.modes1, :self.modes2],
                self.weights1,
            )
        # Low-frequency modes (negative frequencies)
        out_ft[:, :, -self.modes1:, :self.modes2] = \
            self.compl_mul2d(
                x_ft[:, :, -self.modes1:, :self.modes2],
                self.weights2,
            )

        # Inverse FFT
        x = torch.fft.irfft2(out_ft, s=(x.size(-2), x.size(-1)))
        return x


class FNO2d(nn.Module):
    """
    Fourier Neural Operator for 2D PDEs.

    Architecture:
        Lift -> [SpectralConv + Linear + Activation] x L -> Project
    """

    def __init__(self, modes1, modes2, width, n_layers=4):
        super().__init__()
        self.modes1 = modes1
        self.modes2 = modes2
        self.width = width
        self.n_layers = n_layers

        # Lifting layer: input channels -> width
        self.lift = nn.Linear(3, width)  # (x, y, a(x,y)) -> width

        # Spectral convolution layers
        self.spectral_convs = nn.ModuleList([
            SpectralConv2d(width, width, modes1, modes2)
            for _ in range(n_layers)
        ])

        # Local linear bypass (1x1 convolution)
        self.linear_layers = nn.ModuleList([
            nn.Conv2d(width, width, 1)
            for _ in range(n_layers)
        ])

        # Projection layer: width -> 1 (solution field)
        self.project = nn.Sequential(
            nn.Linear(width, 128),
            nn.GELU(),
            nn.Linear(128, 1),
        )

    def forward(self, x):
        # x shape: (batch, nx, ny, 3) = (batch, nx, ny, [x, y, a])
        batch, nx, ny, _ = x.shape

        # Lift to higher dimension
        h = self.lift(x)                    # (batch, nx, ny, width)
        h = h.permute(0, 3, 1, 2)          # (batch, width, nx, ny)

        # FNO layers
        for spectral, linear in zip(
            self.spectral_convs, self.linear_layers
        ):
            h1 = spectral(h)                # global Fourier path
            h2 = linear(h)                  # local bypass path
            h = torch.nn.functional.gelu(h1 + h2)

        # Project back to solution space
        h = h.permute(0, 2, 3, 1)          # (batch, nx, ny, width)
        h = self.project(h)                 # (batch, nx, ny, 1)

        return h.squeeze(-1)               # (batch, nx, ny)
Complete 2D Fourier Neural Operator implementation with spectral convolution layers, local linear bypass, and lift/project bookends.
Library Shortcut: NeuralOperator Package

The implementation above illustrates the mechanics, but the neuraloperator library provides production-ready FNO, DeepONet, and related architectures with a clean API. A complete FNO training pipeline reduces from the 100+ lines above to roughly 15 lines: from neuraloperator.models import FNO, configure hyperparameters, and call trainer.train(). The library also handles data loading for standard PDE benchmarks (Darcy flow, Navier-Stokes, Burgers), mixed-precision training, and distributed computation.

4. Training on Darcy Flow

The Darcy flow benchmark is the standard test problem for neural operators. The input is a permeability field \(a(x, y)\) (typically a piecewise constant "checkerboard" sampled from a Gaussian random field, where the value at each spatial point is drawn from a multivariate Gaussian distribution with a prescribed spatial correlation structure), and the target is the pressure solution \(u(x, y)\) computed by a high-fidelity finite element solver. The training set contains 1,000 input-output pairs on a 64x64 grid; the test set contains 200 unseen pairs.

import torch
from torch.utils.data import DataLoader, TensorDataset

def load_darcy_flow(data_path, n_train=1000, n_test=200, resolution=64):
    """
    Load the Darcy flow benchmark dataset.

    Each sample contains:
      - a(x,y): permeability coefficient field (64x64)
      - u(x,y): pressure solution field (64x64)
    """
    data = torch.load(data_path)
    a_train = data['a'][:n_train, :resolution, :resolution]
    u_train = data['u'][:n_train, :resolution, :resolution]
    a_test = data['a'][n_train:n_train+n_test, :resolution, :resolution]
    u_test = data['u'][n_train:n_train+n_test, :resolution, :resolution]

    # Create coordinate grid
    grid_x = torch.linspace(0, 1, resolution)
    grid_y = torch.linspace(0, 1, resolution)
    grid_x, grid_y = torch.meshgrid(grid_x, grid_y, indexing='ij')

    # Stack inputs: (x, y, a(x,y))
    def make_input(a_batch):
        batch_size = a_batch.shape[0]
        gx = grid_x.unsqueeze(0).expand(batch_size, -1, -1)
        gy = grid_y.unsqueeze(0).expand(batch_size, -1, -1)
        return torch.stack([gx, gy, a_batch], dim=-1)

    x_train = make_input(a_train)
    x_test = make_input(a_test)

    return (
        DataLoader(TensorDataset(x_train, u_train),
                   batch_size=32, shuffle=True),
        DataLoader(TensorDataset(x_test, u_test),
                   batch_size=32, shuffle=False),
    )


def train_fno(model, train_loader, test_loader, n_epochs=100, lr=1e-3):
    """Train FNO with relative L2 loss (the ratio of the prediction
    error norm to the true solution norm, so 0.01 means 1% error
    regardless of solution scale)."""
    optimizer = torch.optim.Adam(model.parameters(), lr=lr,
                                  weight_decay=1e-4)
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
        optimizer, T_max=n_epochs
    )

    for epoch in range(n_epochs):
        model.train()
        train_loss = 0.0

        for x_batch, u_batch in train_loader:
            optimizer.zero_grad()
            u_pred = model(x_batch)

            # Relative L2 loss: ||u_pred - u_true|| / ||u_true||
            loss = torch.mean(
                torch.norm(u_pred - u_batch, dim=(-2, -1))
                / torch.norm(u_batch, dim=(-2, -1))
            )
            loss.backward()
            optimizer.step()
            train_loss += loss.item()

        scheduler.step()

        # Evaluate
        if (epoch + 1) % 10 == 0:
            model.eval()
            test_loss = 0.0
            with torch.no_grad():
                for x_batch, u_batch in test_loader:
                    u_pred = model(x_batch)
                    test_loss += torch.mean(
                        torch.norm(u_pred - u_batch, dim=(-2, -1))
                        / torch.norm(u_batch, dim=(-2, -1))
                    ).item()

            n_train_batches = len(train_loader)
            n_test_batches = len(test_loader)
            print(
                f"Epoch {epoch+1:3d} | "
                f"Train: {train_loss/n_train_batches:.4f} | "
                f"Test: {test_loss/n_test_batches:.4f}"
            )

    return model

# Instantiate and train
model = FNO2d(modes1=12, modes2=12, width=32, n_layers=4)
print(f"FNO parameters: {sum(p.numel() for p in model.parameters()):,}")
# Typically ~2.4M parameters for this configuration
Darcy flow data loading and FNO training loop with relative L2 loss, cosine annealing scheduler, and periodic test-set evaluation.

On the Darcy flow benchmark, an FNO with 12 Fourier modes and 4 layers achieves a relative L2 test error (the prediction error norm divided by the true solution norm, so that 0.01 means 1% error regardless of solution scale) typically below 1.5% after 100 epochs of training. Each forward pass takes approximately 3 milliseconds on a graphics processing unit (GPU), compared to roughly 10 seconds for the finite element solver, approximately a 3000x speedup. That speed gap is the value proposition of neural operators for engineering applications: once trained, they enable real-time evaluation of PDE solutions across the design space.

5. Navier-Stokes Surrogate

Darcy flow is a steady-state, linear PDE, so the 3000x speedup above is impressive but not yet a stress test; real engineering problems demand that neural operators handle time-dependent, nonlinear dynamics as well.

The more challenging and scientifically significant benchmark is the 2D Navier-Stokes equation for incompressible viscous flow:

$$\frac{\partial w}{\partial t} + u \cdot \nabla w = \nu \Delta w + f, \quad \nabla \cdot u = 0$$

where \(w = \nabla \times u\) is the vorticity (the curl of the velocity field, measuring local rotation in the fluid), \(\nu\) is the viscosity, and \(f\) is an external forcing. The operator learning task maps the vorticity at time steps \([0, T_{\text{in}}]\) to future vorticity at times \([T_{\text{in}}+1, T_{\text{out}}]\).

For this time-dependent problem, the FNO processes a stack of input time steps as channels and predicts a stack of output time steps. At viscosity \(\nu = 10^{-3}\), the flow is mildly turbulent, and the FNO typically achieves relative errors of approximately 5% for 10-step-ahead prediction. At \(\nu = 10^{-4}\) (more turbulent), errors typically rise to approximately 15%, reflecting the increasing complexity of the solution manifold.

Practical Example: Aerodynamic Shape Optimization

An automotive engineer needs to evaluate the drag coefficient for 10,000 different car body shapes during an optimization campaign. Each computational fluid dynamics (CFD) simulation takes 4 hours. Running all 10,000 sequentially would require over 4 years of compute time. Instead, the engineer trains an FNO on 500 high-fidelity CFD solutions (requiring 2,000 hours, feasible on a small cluster over a weekend). The trained FNO evaluates each new shape in 50 milliseconds, completing the entire optimization campaign in under a minute. The FNO predictions have 3% relative error compared to CFD, which is acceptable for the screening phase. Only the top 20 candidate shapes are validated with full CFD, reducing total compute from 40,000 hours to 2,080 hours: a 19x speedup.

6. DeepONet: Branch-Trunk Architecture

While FNO excels on regular grids, DeepONet handles irregular sensor locations and heterogeneous input representations. The architecture consists of two sub-networks:

Branch and Trunk Sub-Networks

The branch network processes the input function \(a\), evaluated at fixed sensor locations \((x_1, \dots, x_m)\). It takes the vector \((a(x_1), \dots, a(x_m))\) and produces a feature vector \(b \in \mathbb{R}^p\).

The trunk network processes the query location \(y\) where we want to evaluate the output function. It takes \(y\) and produces a feature vector \(\tau \in \mathbb{R}^p\).

The operator output is the inner product: \(\mathcal{G}(a)(y) \approx b \cdot \tau\).

import torch
import torch.nn as nn

class DeepONet(nn.Module):
    """
    DeepONet: branch-trunk architecture for operator learning.

    Branch: processes input function at sensor locations.
    Trunk: processes query evaluation point.
    Output: dot product of branch and trunk features.
    """

    def __init__(self, n_sensors, branch_width, trunk_width, p):
        """
        Args:
            n_sensors: number of sensor locations for input function
            branch_width: hidden layer width for branch network
            trunk_width: hidden layer width for trunk network
            p: dimension of the feature vectors (branch/trunk output)
        """
        super().__init__()

        # Branch network: sensor values -> feature vector
        self.branch = nn.Sequential(
            nn.Linear(n_sensors, branch_width),
            nn.Tanh(),
            nn.Linear(branch_width, branch_width),
            nn.Tanh(),
            nn.Linear(branch_width, p),
        )

        # Trunk network: query point -> feature vector
        self.trunk = nn.Sequential(
            nn.Linear(2, trunk_width),    # 2D spatial input
            nn.Tanh(),
            nn.Linear(trunk_width, trunk_width),
            nn.Tanh(),
            nn.Linear(trunk_width, p),
        )

        # Learnable bias
        self.bias = nn.Parameter(torch.zeros(1))

    def forward(self, a_sensors, y_query):
        """
        Args:
            a_sensors: (batch, n_sensors) input function values
            y_query: (batch, n_query, 2) query locations

        Returns:
            u_pred: (batch, n_query) predicted output function values
        """
        # Branch: (batch, p)
        b = self.branch(a_sensors)

        # Trunk: (batch, n_query, p)
        tau = self.trunk(y_query)

        # Dot product: (batch, n_query)
        u_pred = torch.einsum("bp,bqp->bq", b, tau) + self.bias

        return u_pred
DeepONet with separate branch (input function encoder) and trunk (query point encoder) networks, producing the operator output via inner product plus learnable bias.

7. Comparing Neural Operators, PINNs, and Classical Solvers

With both FNO (optimized for regular grids) and DeepONet (flexible on irregular geometries) now in hand, the natural question is when to reach for each, and when a classical solver or a PINN remains the better tool.

Real-World Application: Weather Forecasting with FourCastNet
Real-World Application: Weather Forecasting with FourCastNet

The table below compares classical solvers, PINNs, FNO, and DeepONet on the Darcy flow and Navier-Stokes benchmarks.

Property Classical Solver (Finite Element Method, FEM) PINN FNO DeepONet
Learns from data? No (uses equations) Partially Yes Yes
Requires PDE? Yes Yes No (data-driven) No (data-driven)
Generalizes across inputs? Re-solve each time Re-train Yes (zero-shot) Yes (zero-shot)
Resolution invariant? Mesh-dependent Mesh-free Yes Yes (sensor-dep.)
Training cost N/A Minutes per instance Hours (amortized) Hours (amortized)
Inference cost Seconds to hours Milliseconds Milliseconds Milliseconds
Accuracy (Darcy) Reference ~1% relative ~1.5% relative ~3% relative
Best for Single high-fidelity run Inverse problems Parametric studies Irregular domains
Key Insight: Amortized Cost vs. Per-Instance Cost

The decision between classical solvers and neural operators is an amortization calculation (spreading a large upfront cost over many cheap subsequent uses, so that the per-use cost falls as the number of uses grows). Training an FNO requires generating a dataset of solved PDE instances (say, 1,000 finite element solutions at 10 seconds each = 2.8 hours), plus training time (say, 2 hours on a single GPU). This upfront cost of approximately 5 hours is paid once. After training, each new evaluation costs 3 milliseconds. The break-even point is roughly 1,800 evaluations (5 hours / 10 seconds per classical solve). For engineering optimization campaigns requiring 10,000+ evaluations, the FNO approach is 1,000x cheaper. For a one-off analysis requiring 3 evaluations, the classical solver wins.

8. Physics-Informed Neural Operators

Physics-Informed Neural Operators (PINO, Li et al., 2021) combine the physics-informed losses of PINNs with the operator learning of FNO. PINO adds PDE residual losses to the FNO training objective, enabling training with fewer data pairs and better generalization to out-of-distribution inputs.

def pino_loss(model, x_batch, u_batch, x_colloc, pde_weight=1.0):
    """
    Combined data + physics loss for PINO.

    The data loss fits available solved PDE instances.
    The physics loss enforces the PDE at collocation points
    without requiring solved reference data.
    """
    # Data loss: fit known solutions
    u_pred = model(x_batch)
    loss_data = relative_l2_loss(u_pred, u_batch)

    # Physics loss: PDE residual at unsupervised collocation points,
    # where collocation points are spatial locations sampled across
    # the domain at which the PDE residual is evaluated without
    # requiring reference solution data
    x_colloc.requires_grad_(True)
    u_colloc = model(x_colloc)

    # Compute PDE residual (Darcy flow: -div(a * grad(u)) = f)
    # a is embedded in x_colloc[:, :, :, 2]
    a_field = x_colloc[:, :, :, 2]

    # Spatial gradients via autograd
    du_dx = torch.autograd.grad(
        u_colloc.sum(), x_colloc, create_graph=True
    )[0]
    du_dx_spatial = du_dx[:, :, :, :2]  # gradients w.r.t. (x, y)

    # Divergence of (a * grad u)
    flux = a_field.unsqueeze(-1) * du_dx_spatial
    div_flux = torch.autograd.grad(
        flux.sum(), x_colloc, create_graph=True
    )[0][:, :, :, :2].sum(dim=-1)

    # PDE residual: should equal source term f
    residual = -div_flux  # simplified; f handled separately
    loss_physics = torch.mean(residual**2)

    return loss_data + pde_weight * loss_physics
PINO loss function combining supervised data fitting with unsupervised PDE residual enforcement at collocation points for the Darcy flow equation.

PINO is particularly valuable when generating training data is expensive. Instead of solving 1,000 PDE instances, you can solve 100 and supplement with physics-informed collocation at 10,000 unsupervised points. The physics loss acts as a regularizer that guides the operator toward physically consistent solutions in data-sparse regions of the input space.

Research Frontier

Transformer-based neural operators are rapidly closing the gap with FNO on complex, multi-scale PDEs. The Transolver (Wu et al., "Transolver: A Fast Transformer Solver for PDEs on General Geometries," ICML 2024) replaces spectral convolution with a physics-aware attention mechanism that adaptively partitions the spatial domain into learned slices, each attending to regions of similar physical behavior. This allows Transolver to handle irregular meshes, complex 3D geometries (car bodies, airfoils, fluid-structure interactions), and multi-scale phenomena that challenge the FNO's reliance on uniform grids and global Fourier modes. On standard benchmarks, Transolver matches or exceeds FNO accuracy while generalizing to unstructured meshes without architectural changes, pointing toward a unified neural operator that works across grid types, geometries, and PDE families.

9. Connecting to Engineering Design

These architectural advances are compelling on their own, but the real payoff comes when neural operators slot into a larger engineering workflow where speed per evaluation directly determines how many design candidates can be explored.

Neural operators transform engineering design by enabling real-time PDE evaluation within optimization loops. The workflow is:

  1. Generate training data: run the classical solver for a representative sample of design configurations.
  2. Train the operator: fit an FNO or DeepONet to map design parameters to performance metrics.
  3. Optimize: run gradient-based or Bayesian optimization over the trained surrogate, evaluating thousands of candidates in seconds.
  4. Validate: run the classical solver on the top candidates to verify the surrogate's predictions.

This four-step loop is the foundation of the physics discovery pipeline in Section 50.4, where we combine PySR for law discovery, PINNs for parameter estimation, FNO for surrogate modeling, and BoTorch for optimization into a single integrated pipeline.

Try It: Train an FNO on the 1D Burgers Equation

Build a minimal neural operator from scratch in under an hour using only PyTorch and NumPy.

  1. Generate training data. Write a simple finite-difference solver for the 1D viscous Burgers equation \(u_t + u\,u_x = \nu\,u_{xx}\) on \([0, 1]\) with periodic boundary conditions, viscosity \(\nu = 0.01\), and 256 grid points. Generate 1,200 samples by drawing random initial conditions from a truncated Fourier series with 5 random modes. Save the initial condition and the solution at \(t = 1.0\) for each sample.
  2. Implement a 1D spectral convolution layer. Adapt the SpectralConv2d class from this section to 1D: use torch.fft.rfft and torch.fft.irfft instead of rfft2/irfft2, and keep only one set of learnable weights for 16 Fourier modes.
  3. Build and train. Stack 4 spectral convolution layers with GELU activations, a lifting layer (nn.Linear(2, 64) mapping spatial coordinate plus initial condition to width 64), and a projection layer back to 1 channel. Train for 200 epochs with the Adam optimizer (lr=1e-3) and relative L2 loss on 1,000 training pairs.
  4. Evaluate. Test on the 200 held-out samples. Compute the mean relative L2 error (target: below 3%). Plot three examples showing the true solution, the FNO prediction, and the pointwise error.
  5. Test resolution transfer. Evaluate your trained model (trained on 256-point grids) on 512-point and 1024-point grids by interpolating the initial condition onto the finer grid, running the forward pass, and comparing against the finite-difference solution at the finer resolution. Observe how accuracy changes with resolution.

Exercise 50.3.1

An FNO is trained on 1,000 Darcy flow samples at 64x64 resolution with 12 Fourier modes per spatial dimension. The spectral weight tensor \(R_\phi\) in each layer has shape (in_channels, out_channels, modes1, modes2) with complex-valued entries. If the network width is 32 (meaning in_channels = out_channels = 32), how many learnable real-valued parameters does a single SpectralConv2d layer contain? (Count both weights1 and weights2.)

Hint

Each complex number has two real components (real and imaginary parts). Each weight tensor has shape (32, 32, 12, 12). There are two such tensors per layer (weights1 for positive frequencies and weights2 for negative frequencies). Multiply the total number of complex parameters by 2 to get real-valued parameter count.

Step-Through: One FNO Layer on a 4-Point 1D Signal

Trace through a single 1D FNO layer with a tiny input to see each operation concretely. Suppose our input signal is \(v = [1.0,\; 0.0,\; -1.0,\; 0.0]\) on a 4-point grid, with 1 input channel, 1 output channel, and \(k_{\max} = 2\) retained Fourier modes.

Step 1 (FFT): Compute the real FFT of \(v\). The result is \(\hat{v} = [0.0,\; (1.0 - 1.0i),\; 0.0]\) (three complex coefficients for a length-4 real signal: DC, mode 1, and Nyquist).

Step 2 (Spectral filtering): With \(k_{\max} = 2\), we keep modes 0 and 1. Suppose the learnable weight \(R_\phi = [0.5,\; (0.0 + 1.0i)]\) for these two modes. Multiply pointwise: filtered coefficients become \([0.0 \times 0.5,\; (1.0 - 1.0i)(0.0 + 1.0i)] = [0.0,\; (1.0 + 1.0i)]\). The Nyquist mode is zeroed: \([0.0,\; (1.0 + 1.0i),\; 0.0]\).

Step 3 (Inverse FFT): Apply the inverse real FFT to get \(v_{\text{spectral}} = [0.5,\; 0.5,\; -0.5,\; -0.5]\).

Step 4 (Local bypass): Apply a pointwise linear weight \(W = 0.3\): \(v_{\text{local}} = [0.3,\; 0.0,\; -0.3,\; 0.0]\).

Step 5 (Combine + activate): Sum the two paths and apply GELU: \(v_{\text{out}} = \text{GELU}([0.8,\; 0.5,\; -0.8,\; -0.5]) \approx [0.72,\; 0.35,\; -0.18,\; -0.15]\). The spectral path captured the global shape; the bypass preserved local detail.

Real-World Application: Weather Forecasting with FourCastNet

NVIDIA's FourCastNet system uses an Adaptive Fourier Neural Operator (AFNO), a variant of the FNO that replaces fixed spectral filters with channel-mixing attention in the frequency domain, to produce global weather forecasts at 0.25-degree resolution (roughly 25 km). Trained on 40 years of ERA5 reanalysis data (a global atmospheric dataset produced by the European Centre for Medium-Range Weather Forecasts that combines historical observations with numerical model output), FourCastNet generates a 7-day forecast in under 2 seconds on a single GPU, compared to hours of supercomputer time for traditional numerical weather prediction models. The system has demonstrated competitive accuracy with the Integrated Forecasting System (IFS) for variables such as surface wind speed and precipitation, making neural operators a viable path toward real-time ensemble weather prediction at scale. (As of 2024, Google DeepMind's GraphCast and GenCast have surpassed FourCastNet on many forecast metrics, achieving accuracy competitive with the best operational numerical weather models while retaining the millisecond-scale inference advantage of neural operators.)

The Operator That Outsmarted Its Training Data

When Li et al. first tested FNO on turbulent Navier-Stokes flows (\(\nu = 10^{-5}\)), they noticed something unexpected: the FNO's predictions were occasionally smoother than the reference solutions, yet produced lower error on downstream quantities like energy spectra. The likely explanation was that the reference solver's grid was too coarse for that viscosity, introducing numerical artifacts that the FNO, having learned the underlying operator from many samples, effectively filtered out. In this interpretation, the surrogate had learned a better approximation of the true operator than the solver used to generate its own training data. This "student surpasses the teacher" phenomenon has since been observed in multiple neural operator studies and raises a provocative question: when should we trust the learned surrogate over the numerical solver?

Lab: Resolution Transfer with a 1D Fourier Neural Operator

Goal: Empirically verify that an FNO trained at one grid resolution can generalize to finer resolutions, and measure how accuracy degrades as the evaluation resolution departs from the training resolution.

Tools needed: Python 3.8+, PyTorch, NumPy, Matplotlib. No GPU required (1D problems are fast on CPU).

Procedure (25 minutes):

  1. Generate 1,200 samples of the 1D Burgers equation (\(\nu = 0.01\), periodic boundaries) using a simple finite-difference solver at 256 grid points. Use random 5-mode Fourier initial conditions. Split into 1,000 train / 200 test.
  2. Implement and train a 1D FNO (4 layers, width 64, 16 modes, 200 epochs) on the 256-point data.
  3. Evaluate the trained model at resolutions 128, 256, 512, and 1024 by interpolating the test initial conditions onto each grid before the forward pass. Compute the reference solution at each resolution with the finite-difference solver.

What to vary: The number of retained Fourier modes (\(k_{\max}\)). Try 8, 16, 32, and 64 modes and retrain for each. Also try evaluating a model trained at 128 points on a 1024-point grid.

What to observe: Plot relative L2 error versus evaluation resolution for each \(k_{\max}\). You should see that accuracy at the training resolution is always best, that higher \(k_{\max}\) improves accuracy at finer resolutions (up to a point), and that there is a resolution ceiling beyond which errors plateau or increase. Record the crossover point where the FNO becomes less accurate than a coarse classical solve.

What's Next

We now have all three building blocks: symbolic regression discovers compact laws (Section 50.1), PINNs recover unknown parameters from sparse data (Section 50.2), and neural operators provide fast surrogates for parametric PDE evaluation (this section). Section 50.4 assembles these into a complete physics discovery pipeline, applying the full stack to a heat transfer engineering design problem with Bayesian optimization and Discovery Workbench integration.