Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

1. Introduction

Physics-Informed Neural Networks (PINNs) build physical laws, expressed as ordinary or partial differential equations (ODEs/PDEs), directly into the training of a neural network. The physics enters through the loss function: in addition to fitting data, the network is penalized when its predictions violate the governing equation. PINNs can:

  • solve differential equations with or without data,
  • work with few labeled samples, because the physics constrains the solution,
  • fold prior knowledge into a machine learning workflow.

Geoscience is full of processes governed by well-known equations: heat diffusion in the crust, seismic wave propagation, groundwater flow (Darcy’s law), and fluid dynamics (Navier-Stokes). PINNs were introduced by Raissi et al. (2019) and reviewed for the broader physics community by Karniadakis et al. (2021); see the References at the end of this notebook. Example uses in geoscience:

  1. Temperature diffusion in the crust: infer thermal structure and geothermal potential from sparse borehole temperature profiles.
  2. Seismic wave propagation: solve wave equations in heterogeneous media without meshing the domain.
  3. Groundwater flow: solve the Darcy and advection-diffusion equations to model aquifer response and pollutant transport.

In all three cases the appeal is the same: field data are sparse and noisy, but the governing PDE is known. The physics fills in where the data cannot.

🖥️ Lecture slides — Session 26 (Wed Dec 2, enrichment context)

2. Mathematical framework

A PINN has three components:

  1. A neural network uθ(x,t)u_\theta(x, t) with parameters θ that approximates the solution u(x,t)u(x, t).
  2. A governing equation written in residual form L(u,x,t)=0\mathcal{L}(u, x, t) = 0, where L\mathcal{L} is a differential operator. The derivatives of uθu_\theta that appear in L\mathcal{L} are computed exactly with automatic differentiation, the same machinery that backpropagation uses.
  3. A composite loss function
Loss=Data loss+Physics loss+Initial/boundary condition loss,\text{Loss} = \text{Data loss} + \text{Physics loss} + \text{Initial/boundary condition loss},

where

  • the data loss measures misfit to observations,
  • the physics loss is the mean squared PDE residual L(uθ)\mathcal{L}(u_\theta) evaluated at collocation points sampled inside the domain,
  • the initial/boundary condition loss pins the solution where it is known.

Training minimizes the combined loss with a gradient-based optimizer such as Adam. The relative weights of the three terms are hyperparameters, and balancing them is one of the practical difficulties of PINNs (more on this in Section 5).

import functools
import time

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim

device = torch.device("cuda" if torch.cuda.is_available()
                      else "mps" if torch.backends.mps.is_available()
                      else "cpu")
# PINN training differentiates through gradients (double-backward autograd),
# which is most reliable on CPU, and these models are tiny: we train on CPU.
DEVICE = torch.device("cpu")
torch.set_num_threads(1)  # single thread is fastest for such small tensors

torch.manual_seed(42)
np.random.seed(10)

3. Warm-up: Newton’s law of cooling

Before tackling a PDE, we start with an ODE, where automatic differentiation only needs first derivatives. Consider a rock sample cooling toward the ambient temperature TenvT_{\text{env}}. Newton’s law of cooling states

dTdt=R(TenvT),\frac{dT}{dt} = R \, (T_{\text{env}} - T),

where RR is a cooling rate constant. With initial temperature T(0)=T0T(0) = T_0, the analytic solution is

T(t)=Tenv+(T0Tenv)eRt.T(t) = T_{\text{env}} + (T_0 - T_{\text{env}}) \, e^{-Rt}.

We will run a three-way comparison on the same 10 noisy measurements, all taken early in the cooling history:

  1. a plain neural network (baseline),
  2. the same network with L2 weight regularization,
  3. the same network with a physics loss (a PINN).

The question is which model extrapolates correctly beyond the time range covered by the data.

def cooling_law(time, Tenv, T0, R):
    T = Tenv + (T0 - Tenv) * np.exp(-R * time)
    return T

We generate synthetic noisy data: the true curve spans 1000 s, but the 10 training samples only cover the first 300 s.

Tenv = 25
T0 = 100
R = 0.005
times = np.linspace(0, 1000, 1000)
eq = functools.partial(cooling_law, Tenv=Tenv, T0=T0, R=R)
temps = eq(times)

# Make training data
t = np.linspace(0, 300, 10)
T = eq(t) + 2 * np.random.randn(10)

plt.figure(figsize=(6, 4))
plt.plot(times, temps)
plt.plot(t, T, 'o')
plt.legend(['Equation', 'Training data'])
plt.ylabel('Temperature (C)')
plt.xlabel('Time (s)')
plt.title('Newton cooling: truth and noisy samples')
plt.show()
<Figure size 600x400 with 1 Axes>

3.1 A network with a pluggable second loss

The Net class below is a small fully connected network. Its fit method minimizes the data misfit plus an optional second loss loss2, weighted by loss2_weight. We will reuse the same class for all three experiments and only swap loss2: None for the baseline, an L2 penalty for the regularized network, and a physics residual for the PINN.

The grad helper computes the derivative of network outputs with respect to inputs using torch.autograd.grad with create_graph=True, so the result can itself be differentiated during backpropagation. This is the core trick of PINNs.

def np_to_th(x):
    """Convert a numpy array to a float32 torch tensor of shape (n, -1)."""
    n_samples = len(x)
    return torch.from_numpy(x).to(torch.float).to(DEVICE).reshape(n_samples, -1)


def grad(outputs, inputs):
    """Partial derivative of outputs with respect to inputs.

    Args:
        outputs: (N, 1) tensor
        inputs: (N, D) tensor
    """
    return torch.autograd.grad(
        outputs, inputs, grad_outputs=torch.ones_like(outputs), create_graph=True
    )


class Net(nn.Module):
    def __init__(
        self,
        input_dim,
        output_dim,
        n_units=100,
        epochs=1000,
        loss=nn.MSELoss(),
        lr=1e-3,
        loss2=None,
        loss2_weight=0.1,
    ) -> None:
        super().__init__()

        self.epochs = epochs
        self.loss = loss
        self.loss2 = loss2
        self.loss2_weight = loss2_weight
        self.lr = lr
        self.n_units = n_units

        self.layers = nn.Sequential(
            nn.Linear(input_dim, self.n_units),
            nn.ReLU(),
            nn.Linear(self.n_units, self.n_units),
            nn.ReLU(),
            nn.Linear(self.n_units, self.n_units),
            nn.ReLU(),
            nn.Linear(self.n_units, self.n_units),
            nn.ReLU(),
        )
        self.out = nn.Linear(self.n_units, output_dim)

    def forward(self, x):
        h = self.layers(x)
        out = self.out(h)
        return out

    def fit(self, X, y):
        Xt = np_to_th(X)
        yt = np_to_th(y)

        optimiser = optim.Adam(self.parameters(), lr=self.lr)
        self.train()
        losses = []
        for ep in range(self.epochs):
            optimiser.zero_grad()
            outputs = self.forward(Xt)
            loss = self.loss(yt, outputs)
            if self.loss2:
                loss += self.loss2_weight * self.loss2(self)
            loss.backward()
            optimiser.step()
            losses.append(loss.item())
            if ep % int(self.epochs / 10) == 0:
                print(f"Epoch {ep}/{self.epochs}, loss: {losses[-1]:.2f}")
        return losses

    def predict(self, X):
        self.eval()
        out = self.forward(np_to_th(X))
        return out.detach().cpu().numpy()

3.2 Baseline network

Train on the 10 noisy samples with no extra loss term.

net = Net(1, 1, loss2=None, epochs=2000, lr=1e-4).to(DEVICE)

losses = net.fit(t, T)

plt.figure(figsize=(6, 3))
plt.plot(losses)
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.title('Baseline network: training loss')
plt.show()
Epoch 0/2000, loss: 4713.87
Epoch 200/2000, loss: 2527.58
Epoch 400/2000, loss: 2297.78
Epoch 600/2000, loss: 1216.00
Epoch 800/2000, loss: 66.38
Epoch 1000/2000, loss: 2.64
Epoch 1200/2000, loss: 1.56
Epoch 1400/2000, loss: 1.19
Epoch 1600/2000, loss: 0.96
Epoch 1800/2000, loss: 0.74
<Figure size 600x300 with 1 Axes>
prediction_temp_baseline = net.predict(times)

plt.figure(figsize=(6, 4))
plt.plot(times, prediction_temp_baseline, alpha=0.8)
plt.plot(t, T, 'o')
plt.legend(['Prediction', 'Training data'])
plt.ylabel('Temperature (C)')
plt.xlabel('Time (s)')
plt.title('Baseline network prediction')
plt.show()
<Figure size 600x400 with 1 Axes>

The baseline fits the samples it saw and does whatever it wants after 300 s: nothing constrains the extrapolation.

3.3 L2-regularized network

A classic remedy for overfitting is to penalize large weights. We pass the L2 norm of the parameters as loss2.

# Second loss: L2 norm of the network weights
def l2_reg(model: torch.nn.Module):
    return torch.sum(sum([p.pow(2.) for p in model.parameters()]))
netreg = Net(1, 1, loss2=l2_reg, epochs=20000, lr=1e-4, loss2_weight=1).to(DEVICE)

losses = netreg.fit(t, T)

plt.figure(figsize=(6, 3))
plt.plot(losses)
plt.yscale('log')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.title('L2-regularized network: training loss')
plt.show()
Epoch 0/20000, loss: 11219.99
Epoch 2000/20000, loss: 3916.57
Epoch 4000/20000, loss: 2446.58
Epoch 6000/20000, loss: 1616.81
Epoch 8000/20000, loss: 1208.82
Epoch 10000/20000, loss: 1015.52
Epoch 12000/20000, loss: 912.33
Epoch 14000/20000, loss: 840.60
Epoch 16000/20000, loss: 774.12
Epoch 18000/20000, loss: 713.41
<Figure size 600x300 with 1 Axes>
prediction_temp_regularized = netreg.predict(times)

plt.figure(figsize=(6, 4))
plt.plot(times, temps, alpha=0.8)
plt.plot(t, T, 'o')
plt.plot(times, prediction_temp_regularized, alpha=0.8)
plt.plot(times, prediction_temp_baseline, alpha=0.8)
plt.legend(labels=['Equation', 'Training data', 'Regularized', 'Baseline'])
plt.ylabel('Temperature (C)')
plt.xlabel('Time (s)')
plt.title('Baseline vs. L2 regularization')
plt.show()
<Figure size 600x400 with 1 Axes>

Regularization smooths the prediction, but it has no reason to follow the exponential decay toward TenvT_{\text{env}}: it just keeps the weights small.

3.4 PINN

Now replace the L2 penalty with a physics loss. We evaluate the network at 1000 collocation times spanning the full 1000 s window (far beyond the data), differentiate the output with respect to time using grad, and penalize the mean squared residual of the cooling law

r(t)=R(TenvTθ(t))dTθdt.r(t) = R\,(T_{\text{env}} - T_\theta(t)) - \frac{dT_\theta}{dt}.

Note that the physics loss uses no temperature observations at all, only the equation.

def physics_loss(model: torch.nn.Module):
    ts = torch.linspace(0, 1000, steps=1000).view(-1, 1).requires_grad_(True).to(DEVICE)
    temps = model(ts)
    dT = grad(temps, ts)[0]
    pde = R * (Tenv - temps) - dT
    return torch.mean(pde**2)
net_PINN = Net(1, 1, loss2=physics_loss, epochs=10000, loss2_weight=1, lr=1e-4).to(DEVICE)

losses = net_PINN.fit(t, T)

plt.figure(figsize=(6, 3))
plt.plot(losses)
plt.yscale('log')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.title('PINN: training loss')
plt.show()
Epoch 0/10000, loss: 4775.84
Epoch 1000/10000, loss: 0.63
Epoch 2000/10000, loss: 0.51
Epoch 3000/10000, loss: 2.77
Epoch 4000/10000, loss: 0.37
Epoch 5000/10000, loss: 5.24
Epoch 6000/10000, loss: 0.39
Epoch 7000/10000, loss: 0.51
Epoch 8000/10000, loss: 0.38
Epoch 9000/10000, loss: 1.10
<Figure size 600x300 with 1 Axes>
prediction_temp_pinn = net_PINN.predict(times)

plt.figure(figsize=(6, 4))
plt.plot(times, temps, alpha=0.8)
plt.plot(t, T, 'o')
plt.plot(times, prediction_temp_baseline, alpha=0.8)
plt.plot(times, prediction_temp_regularized, alpha=0.8)
plt.plot(times, prediction_temp_pinn, alpha=0.8)
plt.legend(labels=['Equation', 'Training data', 'Baseline', 'Regularized', 'PINN'])
plt.ylabel('Temperature (C)')
plt.xlabel('Time (s)')
plt.title('Three-way comparison')
plt.show()
<Figure size 600x400 with 1 Axes>

The PINN follows the true exponential decay well past the last data point, because the physics loss constrains the solution everywhere the collocation points reach. The data anchor the amplitude; the equation supplies the shape. That is the whole idea.

Training here is deliberately short. On your own machine you can raise the epoch counts for smoother fits.

4. A real PDE: 1-D heat diffusion in the shallow subsurface

The cooling law was an ODE. Now we solve an actual PDE with a PINN: heat diffusion,

Tt=k2Tx2,\frac{\partial T}{\partial t} = k \, \frac{\partial^2 T}{\partial x^2},

where T(x,t)T(x,t) is temperature and kk is thermal diffusivity.

Physical setting. Think of a temperature anomaly in the top L=10L = 10 m of soil or rock, a permafrost active layer or a shallow geothermal profile. A warm anomaly peaks mid-column and relaxes by diffusion, while the surface (x=0x=0) and the base (x=Lx=L) stay pinned at the mean annual temperature, which we take as the 0 °C reference. A typical rock/soil thermal diffusivity is k106 m2/sk \approx 10^{-6}\ \mathrm{m^2/s}.

Nondimensionalization. Raw SI values (L=10L=10, k=106k=10^{-6}, tt in units of 107 s) make ugly numbers for a neural network, so we rescale: x^=x/L\hat{x} = x/L and t^=kt/L2\hat{t} = k t / L^2. One unit of t^\hat{t} corresponds to L2/k=108 s3.2L^2/k = 10^8\ \mathrm{s} \approx 3.2 years; we solve on t^[0,0.3]\hat{t} \in [0, 0.3], roughly one year. Temperature stays in °C. In these units the PDE becomes T/t^=2T/x^2\partial T/\partial \hat{t} = \partial^2 T/\partial \hat{x}^2 (diffusivity 1).

Analytic reference. A single sine mode diffuses without changing shape:

T(x^,t^)=T0sin(πx^)eπ2t^,T(\hat{x}, \hat{t}) = T_0 \, \sin(\pi \hat{x}) \, e^{-\pi^2 \hat{t}},

with T0=5T_0 = 5 °C. It satisfies the PDE and the boundary conditions T=0T=0 at x^=0\hat{x}=0 and x^=1\hat{x}=1, so we can measure the PINN’s error exactly.

Network. Tθ(x^,t^)T_\theta(\hat{x}, \hat{t}) is a small MLP: 2 inputs, three hidden layers of 64 units, 1 output. We use tanh activations rather than ReLU because the PDE residual needs second derivatives, and the second derivative of ReLU is zero almost everywhere.

T0_heat = 5.0   # amplitude of the initial anomaly, deg C
t_max = 0.3     # nondimensional time horizon (~1 year)


def analytic_T(x, t):
    """Analytic solution: single diffusing sine mode (nondimensional x, t)."""
    return T0_heat * np.sin(np.pi * x) * np.exp(-np.pi**2 * t)


class HeatPINN(nn.Module):
    """T_theta(x, t): 2 inputs -> 3 x 64 tanh -> 1 output."""

    def __init__(self, n_units=64):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(2, n_units), nn.Tanh(),
            nn.Linear(n_units, n_units), nn.Tanh(),
            nn.Linear(n_units, n_units), nn.Tanh(),
            nn.Linear(n_units, 1),
        )

    def forward(self, xt):
        return self.layers(xt)

4.1 Training points

Three sets of points, one per loss term:

  • Data points: 40 noisy temperature samples at random (x^,t^)(\hat{x}, \hat{t}) locations, like sparse sensor readings, with 0.2 °C Gaussian noise.
  • Collocation points: 1000 random interior points where we enforce the PDE residual. No temperature values are attached to them.
  • Initial/boundary points: 200 points on the t^=0\hat{t}=0 line (initial profile) and 200 on each boundary x^=0\hat{x}=0 and x^=1\hat{x}=1 (temperature held at 0 °C).
# Data points: sparse noisy samples of the true solution
n_data = 40
x_d = np.random.rand(n_data)
t_d = np.random.rand(n_data) * t_max
T_d = analytic_T(x_d, t_d) + 0.2 * np.random.randn(n_data)
X_data = torch.tensor(np.stack([x_d, t_d], axis=1), dtype=torch.float32)
y_data = torch.tensor(T_d[:, None], dtype=torch.float32)

# Collocation points: interior points where the PDE residual is enforced
n_col = 1000
x_c = torch.rand(n_col, 1)
t_c = torch.rand(n_col, 1) * t_max
X_col = torch.cat([x_c, t_c], dim=1).requires_grad_(True)

# Initial condition points (t = 0) and boundary points (x = 0 and x = 1)
n_b = 200
x_ic = torch.rand(n_b, 1)
X_ic = torch.cat([x_ic, torch.zeros(n_b, 1)], dim=1)
y_ic = T0_heat * torch.sin(np.pi * x_ic)
t_b = torch.rand(n_b, 1) * t_max
X_b0 = torch.cat([torch.zeros(n_b, 1), t_b], dim=1)
X_b1 = torch.cat([torch.ones(n_b, 1), t_b], dim=1)

print(f"data: {X_data.shape}, collocation: {X_col.shape}, "
      f"IC: {X_ic.shape}, BC: {X_b0.shape} + {X_b1.shape}")
data: torch.Size([40, 2]), collocation: torch.Size([1000, 2]), IC: torch.Size([200, 2]), BC: torch.Size([200, 2]) + torch.Size([200, 2])

4.2 Training loop

Each step computes three loss terms and sums them with equal weights:

  • Data loss: MSE between TθT_\theta and the noisy samples.
  • PDE loss: at the collocation points, grad gives Tθ/x^\partial T_\theta / \partial \hat{x} and Tθ/t^\partial T_\theta / \partial \hat{t} in one call (columns of the gradient); a second grad call on the first column gives 2Tθ/x^2\partial^2 T_\theta / \partial \hat{x}^2. The residual is Tθ/t^2Tθ/x^2\partial T_\theta/\partial \hat{t} - \partial^2 T_\theta/\partial \hat{x}^2.
  • IC/BC loss: MSE against the initial sine profile plus the squared temperature at both boundaries.

A few thousand Adam steps are enough for this smooth problem.

model_heat = HeatPINN().to(DEVICE)
optimizer = optim.Adam(model_heat.parameters(), lr=2e-3)
mse = nn.MSELoss()

n_steps = 2000
history = {"data": [], "pde": [], "icbc": []}

tic = time.perf_counter()
for step in range(n_steps):
    optimizer.zero_grad()

    # Data loss
    loss_data = mse(model_heat(X_data), y_data)

    # PDE residual loss at collocation points
    T_c = model_heat(X_col)
    g = grad(T_c, X_col)[0]           # (n_col, 2): columns are dT/dx, dT/dt
    T_x, T_t = g[:, 0:1], g[:, 1:2]
    T_xx = grad(T_x, X_col)[0][:, 0:1]
    loss_pde = torch.mean((T_t - T_xx) ** 2)

    # Initial and boundary condition loss
    loss_icbc = (mse(model_heat(X_ic), y_ic)
                 + torch.mean(model_heat(X_b0) ** 2)
                 + torch.mean(model_heat(X_b1) ** 2))

    loss = loss_data + loss_pde + loss_icbc
    loss.backward()
    optimizer.step()

    history["data"].append(loss_data.item())
    history["pde"].append(loss_pde.item())
    history["icbc"].append(loss_icbc.item())
    if step % 400 == 0 or step == n_steps - 1:
        print(f"step {step:4d}  data {loss_data.item():.4f}  "
              f"pde {loss_pde.item():.5f}  ic/bc {loss_icbc.item():.5f}")
pinn_train_seconds = time.perf_counter() - tic
print(f"wall-clock training time: {pinn_train_seconds:.1f} s")
step    0  data 2.7585  pde 0.00039  ic/bc 12.18578
step  400  data 0.0480  pde 0.02919  ic/bc 0.01632
step  800  data 0.0433  pde 0.00305  ic/bc 0.00282
step 1200  data 0.0418  pde 0.02154  ic/bc 0.00114
step 1600  data 0.0414  pde 0.00386  ic/bc 0.00094
step 1999  data 0.0413  pde 0.00217  ic/bc 0.00081
wall-clock training time: 16.0 s

4.3 Results

First, predicted vs. analytic temperature profiles at four times. The depth axis is back in meters (x=Lx^x = L\hat{x}).

L = 10.0  # m
x_grid = np.linspace(0, 1, 200)
plot_times = [0.0, 0.05, 0.15, 0.3]
years_per_that = 1e8 / (365.25 * 86400)  # one unit of t_hat in years

plt.figure(figsize=(7, 4.5))
colors = plt.cm.viridis(np.linspace(0, 0.85, len(plot_times)))
for that, c in zip(plot_times, colors):
    X_plot = torch.tensor(np.stack([x_grid, np.full_like(x_grid, that)], axis=1),
                          dtype=torch.float32)
    T_pred = model_heat(X_plot).detach().numpy().ravel()
    label_t = f"t = {that * years_per_that:.2f} yr"
    plt.plot(x_grid * L, analytic_T(x_grid, that), '-', color=c,
             label=f'analytic, {label_t}')
    plt.plot(x_grid * L, T_pred, '--', color=c, label=f'PINN, {label_t}')
plt.xlabel('Depth x (m)')
plt.ylabel('Temperature anomaly (C)')
plt.title('Heat diffusion: PINN vs. analytic solution')
plt.legend(fontsize=8)
plt.show()

# Quantify the error (kept in a dict for the comparison in Section 4.4)
pinn_max_err = {}
for that in plot_times:
    X_plot = torch.tensor(np.stack([x_grid, np.full_like(x_grid, that)], axis=1),
                          dtype=torch.float32)
    T_pred = model_heat(X_plot).detach().numpy().ravel()
    pinn_max_err[that] = np.abs(T_pred - analytic_T(x_grid, that)).max()
    print(f"t_hat = {that:.2f}: max abs error = {pinn_max_err[that]:.3f} C")
<Figure size 700x450 with 1 Axes>
t_hat = 0.00: max abs error = 0.029 C
t_hat = 0.05: max abs error = 0.033 C
t_hat = 0.15: max abs error = 0.017 C
t_hat = 0.30: max abs error = 0.034 C
plt.figure(figsize=(7, 4))
plt.plot(history["data"], label='data loss')
plt.plot(history["pde"], label='PDE residual loss')
plt.plot(history["icbc"], label='IC/BC loss')
plt.yscale('log')
plt.xlabel('Adam iteration')
plt.ylabel('Loss')
plt.title('Loss components during training')
plt.legend()
plt.show()
<Figure size 700x400 with 1 Axes>

The PINN matches the analytic profiles to a few hundredths of a degree from 40 noisy point measurements, because the PDE and the boundary conditions carry most of the information. The data loss plateaus near the noise floor (the network should not fit the 0.2 °C noise), while the PDE and IC/BC losses drop by three to four orders of magnitude. The periodic spikes in those curves are Adam briefly overshooting and recovering, a common sight in PINN training.

Training is kept short on purpose; on your own machine, raise n_steps and n_col for tighter residuals.

Exercise. Set the PDE loss weight to zero (replace loss_pde with 0 * loss_pde in the sum) and retrain. Compare the profiles at t^=0.3\hat{t} = 0.3, where there are few data points and the anomaly is small.

4.4 The baseline: 15 lines of finite differences

This book’s rule is baselines before models, and the physics chapter is not exempt. The forward problem we just solved, with known diffusivity, known initial and boundary conditions, and a smooth solution, is textbook territory for classical numerics, so the PINN has to face the classical method before we praise it. The simplest scheme is FTCS (forward-time, centered-space): put x^\hat{x} on a grid, replace 2T/x^2\partial^2 T/\partial \hat{x}^2 with the centered second difference, and step forward in time with explicit Euler:

Tin+1=Tin+r(Ti+1n2Tin+Ti1n),r=Δt^Δx^2.T_i^{n+1} = T_i^n + r\,(T_{i+1}^n - 2T_i^n + T_{i-1}^n), \qquad r = \frac{\Delta \hat{t}}{\Delta \hat{x}^2}.

The scheme is only stable for r1/2r \le 1/2; we use r=0.4r = 0.4. That constraint is the price of an explicit method (Crank–Nicolson removes it at the cost of a tridiagonal solve), and on this problem it costs almost nothing. The whole solver, timed:

# FTCS finite differences: the entire solver is the 8 lines between tic and toc
nx = 101
dx = 1.0 / (nx - 1)
dt = 0.4 * dx**2                      # explicit stability requires dt <= dx^2 / 2
nt = int(round(t_max / dt))
x_fd = np.linspace(0.0, 1.0, nx)
r = dt / dx**2
save_steps = {int(round(that / dt)): that for that in plot_times if that > 0}

tic = time.perf_counter()
T_fd = T0_heat * np.sin(np.pi * x_fd)             # initial condition
fd_snapshots = {0.0: T_fd.copy()}
for n in range(1, nt + 1):
    T_fd[1:-1] += r * (T_fd[2:] - 2 * T_fd[1:-1] + T_fd[:-2])
    if n in save_steps:                           # endpoints never updated: T = 0 C
        fd_snapshots[save_steps[n]] = T_fd.copy()
fd_seconds = time.perf_counter() - tic

print(f"FTCS: {nx} grid points, {nt} time steps, "
      f"wall-clock {fd_seconds * 1e3:.0f} ms")
print(f"\n{'t_hat':>6} {'FD max err (C)':>16} {'PINN max err (C)':>18}")
for that in plot_times:
    fd_err = np.abs(fd_snapshots[that] - analytic_T(x_fd, that)).max()
    print(f"{that:6.2f} {fd_err:16.2e} {pinn_max_err[that]:18.3f}")
print(f"\nwall-clock: FD {fd_seconds * 1e3:.0f} ms vs. PINN training "
      f"{pinn_train_seconds:.0f} s -> the PINN is "
      f"{pinn_train_seconds / fd_seconds:.0f}x slower")
FTCS: 101 grid points, 7500 time steps, wall-clock 33 ms

 t_hat   FD max err (C)   PINN max err (C)
  0.00         0.00e+00              0.029
  0.05         1.73e-04              0.033
  0.15         1.94e-04              0.017
  0.30         8.83e-05              0.034

wall-clock: FD 33 ms vs. PINN training 16 s -> the PINN is 481x slower

The verdict is not close. The finite-difference solver finishes in about 20 ms and matches the analytic solution to about 2×1042 \times 10^{-4} °C; the PINN needed 14 s of training to get within 0.03 °C. On this forward problem the classical solver wins by a factor of roughly 750 in speed and two orders of magnitude in accuracy, with no learning rate, no architecture, and no seed. That is the honest reading of Section 4.3, and it generalizes: when the coefficients, initial condition, and boundary conditions are known and a standard discretization exists, use the standard discretization. The PINN’s case has to be made elsewhere.

Two things the FD solver did not do, though. It never touched the 40 noisy interior measurements, because a forward solver has no slot for scattered observations. And it required kk as an input. Both gaps point to the same place, and that is where we go next.

4.5 The inverse problem: recovering the diffusivity

Section 5 will argue that inverse problems are the first regime where PINNs remain the right tool. Here is the demonstration, on the data we already have. Pretend the diffusivity is unknown. In scaled units the generator’s value is exactly k^=1\hat{k} = 1, because the nondimensionalization used the true k=106 m2/sk = 10^{-6}\ \mathrm{m^2/s}; the model is not told that. We initialize a trainable k^\hat{k} at 0.2, five times too low, and let the optimizer recover it from the same 40 noisy samples.

The change to the training loop of Section 4.2 is five lines, marked (1)(4) below: k^\hat{k} becomes an nn.Parameter (stored as logk^\log \hat{k} so it stays positive), it joins the optimizer’s parameter list, and the residual becomes Tθ/t^k^2Tθ/x^2\partial T_\theta/\partial \hat{t} - \hat{k}\, \partial^2 T_\theta/\partial \hat{x}^2. Everything else is untouched. The information flows like this: the initial condition pins the shape of the anomaly, the 40 samples pin how fast it decays, and the PDE ties that decay rate to k^\hat{k}.

model_inv = HeatPINN().to(DEVICE)
log_k = nn.Parameter(torch.log(torch.tensor(0.2)))    # (1) trainable k_hat, start 5x too low
optimizer_inv = optim.Adam(list(model_inv.parameters()) + [log_k],  # (2) k joins the optimizer
                           lr=2e-3)

k_history = []
tic = time.perf_counter()
for step in range(n_steps):
    optimizer_inv.zero_grad()
    loss_data = mse(model_inv(X_data), y_data)
    T_c = model_inv(X_col)
    g = grad(T_c, X_col)[0]
    T_x, T_t = g[:, 0:1], g[:, 1:2]
    T_xx = grad(T_x, X_col)[0][:, 0:1]
    loss_pde = torch.mean((T_t - torch.exp(log_k) * T_xx) ** 2)     # (3) k in the residual
    loss_icbc = (mse(model_inv(X_ic), y_ic)
                 + torch.mean(model_inv(X_b0) ** 2)
                 + torch.mean(model_inv(X_b1) ** 2))
    (loss_data + loss_pde + loss_icbc).backward()
    optimizer_inv.step()
    k_history.append(torch.exp(log_k).item())                       # (4) track the estimate
    if step % 400 == 0 or step == n_steps - 1:
        print(f"step {step:4d}  k_hat = {k_history[-1]:.3f}")
inv_seconds = time.perf_counter() - tic

k_rec = k_history[-1]
print(f"\nrecovered k_hat = {k_rec:.3f}  (generator truth 1.0, "
      f"error {abs(k_rec - 1.0) * 100:.1f}%)")
print(f"physical units: k = {k_rec * 1e-6:.2e} m^2/s  (truth 1.00e-06)")
print(f"wall-clock: {inv_seconds:.0f} s")
step    0  k_hat = 0.200
step  400  k_hat = 0.460
step  800  k_hat = 0.736
step 1200  k_hat = 0.884
step 1600  k_hat = 0.951
step 1999  k_hat = 0.984

recovered k_hat = 0.984  (generator truth 1.0, error 1.6%)
physical units: k = 9.84e-07 m^2/s  (truth 1.00e-06)
wall-clock: 16 s
plt.figure(figsize=(6, 3.5))
plt.plot(k_history)
plt.axhline(1.0, color='k', ls=':', label='generator truth')
plt.xlabel('Adam iteration')
plt.ylabel(r'$\hat{k}$ estimate')
plt.title('Diffusivity recovered jointly with the temperature field')
plt.legend()
plt.show()
<Figure size 600x350 with 1 Axes>

The recovered diffusivity lands within 2% of the generator truth, from 40 noisy point measurements and nothing gridded. Be clear about what this run is: a calibration, also called history matching. The finite-difference solver of Section 4.4 cannot do it alone, because it needs kk as an input. The classical route wraps the solver in an outer optimization loop that reruns it for every candidate kk; in groundwater practice that is what PEST does around a MODFLOW model when calibrating hydraulic conductivity against sparse well records. The PINN merges the two loops: one training run fits the temperature field and inverts for the parameter at the same time, and the same trick extends to many unknowns (a spatially variable k(x^)k(\hat{x}) can be a second small network). Sparse, noisy point data plus a trusted equation plus unknown coefficients: this is the regime where the extra machinery pays for itself.

4.6 Breaking the PINN: loss-weight imbalance

So far the composite loss has behaved because its three terms happened to sit at comparable scales; equal weights just worked. That is luck, not a law, and Section 5’s warning about loss balancing deserves the same treatment the broken training runs got in notebook 4.5: show the failure, read the symptoms, name the fix.

The exercise in Section 4.3 removed the physics term and watched the model decay into a noisy regression. Here we break the balance in the opposite direction: the physics term swamps the data and initial condition. The run below repeats Section 4.2 with a single change, w_pde = 1e4 on the PDE residual. Watch the printed total: it keeps falling, as if training were going well.

model_bad = HeatPINN().to(DEVICE)
optimizer_bad = optim.Adam(model_bad.parameters(), lr=2e-3)
w_pde = 1e4                       # the only change from Section 4.2

history_bad = {"data": [], "pde": [], "icbc": []}
for step in range(n_steps):
    optimizer_bad.zero_grad()
    loss_data = mse(model_bad(X_data), y_data)
    T_c = model_bad(X_col)
    g = grad(T_c, X_col)[0]
    T_x, T_t = g[:, 0:1], g[:, 1:2]
    T_xx = grad(T_x, X_col)[0][:, 0:1]
    loss_pde = torch.mean((T_t - T_xx) ** 2)
    loss_icbc = (mse(model_bad(X_ic), y_ic)
                 + torch.mean(model_bad(X_b0) ** 2)
                 + torch.mean(model_bad(X_b1) ** 2))
    total = loss_data + w_pde * loss_pde + loss_icbc
    total.backward()
    optimizer_bad.step()
    for key, val in zip(("data", "pde", "icbc"), (loss_data, loss_pde, loss_icbc)):
        history_bad[key].append(val.item())
    if step % 400 == 0 or step == n_steps - 1:
        print(f"step {step:4d}  total {total.item():8.3f}  data {loss_data.item():.3f}  "
              f"pde {loss_pde.item():.5f}  ic/bc {loss_icbc.item():.3f}")
step    0  total   96.174  data 2.569  pde 0.00819  ic/bc 11.750
step  400  total    9.546  data 1.123  pde 0.00000  ic/bc 8.419
step  800  total    9.475  data 1.110  pde 0.00000  ic/bc 8.362
step 1200  total    9.470  data 1.111  pde 0.00000  ic/bc 8.356
step 1600  total    9.506  data 1.119  pde 0.00000  ic/bc 8.383
step 1999  total    9.244  data 1.071  pde 0.00000  ic/bc 8.161
fig, axes = plt.subplots(1, 2, figsize=(11, 4))

axes[0].plot(history_bad["data"], label='data loss')
axes[0].plot(history_bad["pde"], label='PDE residual loss')
axes[0].plot(history_bad["icbc"], label='IC/BC loss')
axes[0].set_yscale('log')
axes[0].set_xlabel('Adam iteration')
axes[0].set_ylabel('Loss (unweighted)')
axes[0].set_title(f'Loss components, w_pde = {w_pde:.0e}')
axes[0].legend()

X_plot0 = torch.tensor(np.stack([x_grid, np.zeros_like(x_grid)], axis=1),
                       dtype=torch.float32)
axes[1].plot(x_grid * L, analytic_T(x_grid, 0.0), 'k-', label='analytic, t = 0')
axes[1].plot(x_grid * L, model_heat(X_plot0).detach().numpy().ravel(), '--',
             label='balanced PINN (Sec. 4.2)')
axes[1].plot(x_grid * L, model_bad(X_plot0).detach().numpy().ravel(), '--',
             label=f'w_pde = {w_pde:.0e}')
axes[1].set_xlabel('Depth x (m)')
axes[1].set_ylabel('Temperature anomaly (C)')
axes[1].set_title('Initial profile: balanced vs. imbalanced')
axes[1].legend()
plt.tight_layout()
plt.show()

err_bad = np.abs(model_bad(X_plot0).detach().numpy().ravel()
                 - analytic_T(x_grid, 0.0)).max()
print(f"max error at t_hat = 0: broken run {err_bad:.2f} C, "
      f"balanced run {pinn_max_err[0.0]:.3f} C  (signal amplitude {T0_heat} C)")
<Figure size 1100x400 with 2 Axes>
max error at t_hat = 0: broken run 3.86 C, balanced run 0.029 C  (signal amplitude 5.0 C)

The total loss falls and the answer is garbage. That combination is the signature of loss imbalance, and it is invisible unless you log the components separately. Read them, not the total: after a few hundred steps the PDE residual is pinned below 10-5, while the data loss sits near 1 (far above the 0.04 noise floor of Section 4.2) and the IC/BC loss stalls near 8, the same order as the 12.5 a network outputting zero everywhere would score (the mean of (5sinπx^)2(5\sin\pi\hat{x})^2). The network has found a near-trivial solution: T0T \approx 0 satisfies the heat equation and the boundary conditions exactly, so the weighted objective rewards staying close to it, and any move toward the true initial profile is punished through the dominant term before it can pay off. The profile at t^=0\hat{t}=0 misses the 5 °C anomaly by about 4 °C.

The fix is the balance we already had: with equal weights, the same architecture and the same 2000 steps reached 0.03 °C in Section 4.2. When no fixed weighting works, because the terms have genuinely different scales, the standard moves are to normalize each term by its initial value, tune the weights against a held-out validation metric, or use an adaptive weighting scheme (Karniadakis et al., 2021); longer training and wider layers only help when the imbalance is mild. The habit to take away: always plot the loss components separately. A single total-loss curve hides exactly this failure.

Exercise (spectral bias). The other failure mode Section 5 names is spectral bias. Add a higher-frequency mode to the initial condition:

T(x^,0)=5sin(πx^)+2sin(7πx^),T(\hat{x}, 0) = 5 \sin(\pi \hat{x}) + 2 \sin(7\pi \hat{x}),

whose analytic solution adds 2sin(7πx^)e49π2t^2\sin(7\pi\hat{x})\,e^{-49\pi^2 \hat{t}} to the single-mode reference. Regenerate y_ic and the 40 data samples from the two-mode solution, retrain the Section 4.2 PINN (equal weights), and rerun the FTCS solver of Section 4.4 with the new initial profile. Compare both to the analytic solution at t^=0\hat{t} = 0.

5. Where PINNs stand in 2026

PINNs are no longer a novelty, and their failure modes are well documented; two of them are now sitting in this notebook. Loss balancing is a persistent tuning problem: a bad balance makes training converge to a solution that satisfies one term and ignores the others, and Section 4.6 showed it on our own problem, where a factor of 104 on the physics term produced a network stuck near the trivial zero solution, missing the 5 °C anomaly by about 4 °C while its total loss kept falling. Standard MLPs also carry a spectral bias toward smooth, low-frequency functions: they learn smooth solutions quickly and oscillatory or fine-scale structure slowly, if at all (Karniadakis et al., 2021); the exercise in 4.6 makes a tanh MLP flatten a seventh-mode ripple that the finite-difference solver resolves without any change. Stiff PDEs, sharp fronts, and widely separated time scales compound both problems, because the residual then varies over orders of magnitude across the domain and no fixed weighting is right everywhere.

The first rule of use is negative: do not solve a clean forward problem with a PINN. Section 4.4 put numbers on this. Fifteen lines of FTCS beat the trained PINN by a factor of roughly 750 in wall-clock time and two orders of magnitude in accuracy, with no hyperparameters to tune. When the coefficients and boundary conditions are known and a standard discretization exists, the classical solver wins. And for the adjacent problem of building fast surrogates evaluated many times, neural operators have largely taken over: the Fourier Neural Operator (Li et al., 2021) and DeepONet (Lu et al., 2021) learn the solution operator, the map from an initial condition, boundary condition, or coefficient field to the solution, so that after training on many simulation pairs a new case costs milliseconds, where a PINN would retrain from scratch. For parametric studies, uncertainty quantification, and operational forecasting, that is the tool that gets used.

PINNs still earn their keep in two regimes, and both are now demonstrated rather than claimed. The first is inverse problems: Section 4.5 made the diffusivity a trainable parameter and recovered it to within 2% from the 40 noisy samples, the PDE acting as the forward model inside a regression (Raissi et al., 2019). This is joint inversion, the PEST-around-MODFLOW pattern of groundwater calibration collapsed into a single training loop. The second is the sparse-data regime those same 40 points represent: scattered, noisy observations plus a trusted governing equation and no simulation library to train an operator on. A forward solver has no slot for such data; the PINN treats them as one more loss term. Both situations are common in geoscience, which is why the method remains part of the toolkit (Karniadakis et al., 2021).

References

  • Raissi, M., Perdikaris, P., and Karniadakis, G. E. (2019). Physics-informed neural networks: A deep learning framework for solving forward and inverse problems involving nonlinear partial differential equations. Journal of Computational Physics, 378, 686-707.
  • Karniadakis, G. E., Kevrekidis, I. G., Lu, L., Perdikaris, P., Wang, S., and Yang, L. (2021). Physics-informed machine learning. Nature Reviews Physics, 3, 422-440.
  • Li, Z., Kovachki, N., Azizzadenesheli, K., Liu, B., Bhattacharya, K., Stuart, A., and Anandkumar, A. (2021). Fourier neural operator for parametric partial differential equations. International Conference on Learning Representations (ICLR).
  • Lu, L., Jin, P., Pang, G., Zhang, Z., and Karniadakis, G. E. (2021). Learning nonlinear operators via DeepONet based on the universal approximation theorem of operators. Nature Machine Intelligence, 3, 218-229.

Summary

  • A PINN adds the residual of a governing equation, evaluated by automatic differentiation at collocation points, to the training loss.
  • On the cooling-law ablation, only the physics term produced correct extrapolation beyond the data; L2 regularization did not.
  • On the 1-D heat equation, a 3-layer tanh MLP recovered the analytic diffusing mode from 40 noisy samples plus the PDE and boundary conditions.
  • The classical baseline won the forward problem outright: 15 lines of FTCS were a factor of ~750 faster and two orders of magnitude more accurate than the PINN (Section 4.4).
  • Making the diffusivity a trainable parameter turned the same loop into a joint inversion that recovered k^\hat{k} to within 2% from the 40 noisy samples, a calibration the forward solver cannot do alone (Section 4.5).
  • A 104 weight on the physics loss gave a falling total loss and a wrong answer; the failure is only visible in the per-term loss curves, so always plot the components (Section 4.6).
  • In 2026: classical solvers for clean forward problems, neural operators (FNO, DeepONet) for parametric surrogates, PINNs for inverse and sparse-data problems.