PROJECT 4.2
| Difficulty | Advanced |
| Estimated Time | 5 – 7 hours |
| Prerequisites | Module 1.4 (Higher-Order ODEs), Module 2.2 (PDEs & Boundary Value Problems), Module 3.1 (Neural ODEs), Module 3.2 (PINNs) |
The one-dimensional viscous Burgers equation is a fundamental nonlinear PDE that serves as a prototype for convection-diffusion phenomena and develops sharp gradients (shocks in the inviscid limit). The equation is:
with periodic boundary conditions $u(-1, t) = u(1, t)$ and a parameterized family of initial conditions $u(x, 0) = u_0(x; \alpha)$. For small viscosity $\nu$, the solution develops steep fronts that challenge both classical and neural solvers in different ways: classical finite-difference schemes require fine grids and small time steps near the shock, while physics-informed neural networks (PINNs) struggle with sharp gradients due to spectral bias.
Your task is to implement both a classical finite-difference solver and a PINN solver for this problem, then conduct a rigorous benchmark comparing accuracy (relative $L^2$ error against a high-resolution reference), computational cost (wall-clock time and FLOPs), and generalization (performance across varying viscosity $\nu \in [0.001, 0.1]$ and different initial condition families). The final component is a bandit-based solver selection agent that, given the problem parameters, selects the solver expected to achieve the target accuracy in the least time.
This project provides hands-on experience with the trade-offs between data-free neural methods and well-understood classical discretizations, a central question in scientific machine learning.
The following code generates high-resolution reference solutions for the Burgers equation across all viscosity and initial-condition combinations. These serve as ground truth for evaluating both solvers.
import numpy as np
import time
# --- Grid parameters for reference solution ---
NX_REF = 8192
DX_REF = 2.0 / NX_REF
DT_REF = 1e-6
T_FINAL = 1.0
N_STEPS_REF = int(T_FINAL / DT_REF)
# Spatial grid (cell centers)
x_ref = np.linspace(-1 + DX_REF/2, 1 - DX_REF/2, NX_REF)
# --- Initial condition families ---
def ic_sine(x):
return -np.sin(np.pi * x)
def ic_gaussian(x):
return np.exp(-25.0 * x**2)
def ic_triangular(x):
return np.sign(x) * (1.0 - np.abs(x))
IC_FAMILIES = {
'sine': ic_sine,
'gaussian': ic_gaussian,
'triangular': ic_triangular,
}
# --- Viscosity values ---
NU_VALUES = [0.001, 0.005, 0.01, 0.02, 0.05, 0.1]
def burgers_reference(u0, nu, nx, dx, dt, n_steps):
"""Compute reference solution using operator splitting:
- Upwind (first-order) for advection: u_t + u*u_x = 0
- Crank-Nicolson for diffusion: u_t = nu * u_xx
Periodic boundary conditions via modular indexing.
"""
u = u0.copy()
# Precompute tridiagonal matrix for Crank-Nicolson
r = nu * dt / (2.0 * dx**2)
# For periodic BC, we use scipy sparse solver
from scipy.sparse import diags, eye
from scipy.sparse.linalg import spsolve
# Diffusion matrix: (I - r*D2) u^{n+1} = (I + r*D2) u^n
e = np.ones(nx)
D2 = diags([e, -2*e, e], [-1, 0, 1], shape=(nx, nx)).toarray()
# Periodic BCs
D2[0, -1] = 1.0
D2[-1, 0] = 1.0
A_lhs = np.eye(nx) - r * D2
A_rhs = np.eye(nx) + r * D2
for step in range(n_steps):
# --- Advection substep (upwind) ---
u_new = u.copy()
for i in range(nx):
ip = (i + 1) % nx
im = (i - 1) % nx
if u[i] >= 0:
u_new[i] = u[i] - dt / dx * u[i] * (u[i] - u[im])
else:
u_new[i] = u[i] - dt / dx * u[i] * (u[ip] - u[i])
u = u_new
# --- Diffusion substep (Crank-Nicolson) ---
rhs = A_rhs @ u
u = np.linalg.solve(A_lhs, rhs)
return u
def generate_all_references():
"""Generate reference solutions for all (IC, nu) combinations."""
results = {}
for ic_name, ic_func in IC_FAMILIES.items():
for nu in NU_VALUES:
print(f"Computing reference: IC={ic_name}, nu={nu}...")
u0 = ic_func(x_ref)
# Adjust dt for stability: CFL condition
u_max = np.max(np.abs(u0)) + 1e-8
dt_cfl = min(DX_REF / u_max, DX_REF**2 / (2 * nu))
dt = min(DT_REF, 0.5 * dt_cfl)
n_steps = int(T_FINAL / dt)
t0 = time.time()
u_final = burgers_reference(u0, nu, NX_REF, DX_REF, dt, n_steps)
elapsed = time.time() - t0
results[(ic_name, nu)] = {
'x': x_ref.copy(),
'u0': u0.copy(),
'u_final': u_final.copy(),
'dt': dt,
'n_steps': n_steps,
'wall_time': elapsed,
}
print(f" Done in {elapsed:.1f}s, {n_steps} steps, dt={dt:.2e}")
return results
# --- Generate and save ---
references = generate_all_references()
np.savez('burgers_references.npz', **{
f"{k[0]}_nu{k[1]}": v['u_final'] for k, v in references.items()
}, x=x_ref)
print(f"Saved {len(references)} reference solutions.")
This produces 18 reference solutions (3 ICs $\times$ 6 viscosities). Use 12 for training the solver-selection agent and hold out 6 for evaluation (one per IC family at $\nu \in \{0.005, 0.05\}$).
The classical baseline uses first-order upwind differencing for the convective term and second-order centered differences for the diffusion term on a coarse grid. The time step is determined by the CFL condition.
def burgers_fd_upwind(u0, nu, nx, t_final):
"""Solve Burgers equation with upwind + centered diffusion on coarse grid.
Args:
u0: initial condition on the coarse grid, shape (nx,)
nu: viscosity
nx: number of grid points
t_final: final time
Returns:
u_final: solution at t_final, shape (nx,)
n_steps: number of time steps taken
wall_time: elapsed wall-clock time in seconds
"""
dx = 2.0 / nx
u = u0.copy()
t = 0.0
n_steps = 0
t0 = time.time()
while t < t_final:
u_max = np.max(np.abs(u)) + 1e-10
dt = 0.4 * min(dx / u_max, dx**2 / (2 * nu))
dt = min(dt, t_final - t)
u_new = np.zeros_like(u)
for i in range(nx):
ip = (i + 1) % nx
im = (i - 1) % nx
# Upwind for advection
if u[i] >= 0:
advection = u[i] * (u[i] - u[im]) / dx
else:
advection = u[i] * (u[ip] - u[i]) / dx
# Centered for diffusion
diffusion = nu * (u[ip] - 2*u[i] + u[im]) / dx**2
u_new[i] = u[i] + dt * (-advection + diffusion)
u = u_new
t += dt
n_steps += 1
wall_time = time.time() - t0
return u, n_steps, wall_time
The PINN baseline represents $u(x, t)$ with a 4-layer MLP (64 units per layer, $\tanh$ activation) and minimizes a weighted combination of the PDE residual loss, initial condition loss, and periodic boundary condition loss. No labeled data from the reference solution is used.
import torch
import torch.nn as nn
class BurgersPINN(nn.Module):
def __init__(self, layers=[2, 64, 64, 64, 64, 1]):
super().__init__()
modules = []
for i in range(len(layers) - 2):
modules.append(nn.Linear(layers[i], layers[i+1]))
modules.append(nn.Tanh())
modules.append(nn.Linear(layers[-2], layers[-1]))
self.net = nn.Sequential(*modules)
def forward(self, x, t):
inp = torch.cat([x, t], dim=1)
return self.net(inp)
def pinn_loss(model, nu, x_pde, t_pde, x_ic, u_ic, x_bc_l, x_bc_r, t_bc,
w_pde=1.0, w_ic=10.0, w_bc=10.0):
"""Compute total PINN loss for Burgers equation.
Args:
model: BurgersPINN instance
nu: viscosity (scalar)
x_pde, t_pde: collocation points for PDE residual, shape (N_pde, 1)
x_ic, u_ic: initial condition points and values
x_bc_l, x_bc_r, t_bc: boundary points at x=-1 and x=+1
Returns:
total_loss, (loss_pde, loss_ic, loss_bc)
"""
# PDE residual: u_t + u*u_x - nu*u_xx = 0
x_pde.requires_grad_(True)
t_pde.requires_grad_(True)
u = model(x_pde, t_pde)
u_x = torch.autograd.grad(u, x_pde, torch.ones_like(u),
create_graph=True)[0]
u_t = torch.autograd.grad(u, t_pde, torch.ones_like(u),
create_graph=True)[0]
u_xx = torch.autograd.grad(u_x, x_pde, torch.ones_like(u_x),
create_graph=True)[0]
residual = u_t + u * u_x - nu * u_xx
loss_pde = torch.mean(residual**2)
# Initial condition: u(x, 0) = u_ic(x)
t_zero = torch.zeros_like(x_ic)
u_pred_ic = model(x_ic, t_zero)
loss_ic = torch.mean((u_pred_ic - u_ic)**2)
# Periodic BC: u(-1, t) = u(1, t)
u_left = model(x_bc_l, t_bc)
u_right = model(x_bc_r, t_bc)
loss_bc = torch.mean((u_left - u_right)**2)
total = w_pde * loss_pde + w_ic * loss_ic + w_bc * loss_bc
return total, (loss_pde.item(), loss_ic.item(), loss_bc.item())
def train_pinn(model, nu, ic_func, n_epochs=20000, lr=1e-3, n_pde=10000,
n_ic=200, n_bc=200):
"""Train a PINN for Burgers equation.
Returns:
model: trained model
history: dict of loss histories
wall_time: training time in seconds
"""
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5000,
gamma=0.5)
history = {'total': [], 'pde': [], 'ic': [], 'bc': []}
t0 = time.time()
for epoch in range(n_epochs):
# Sample collocation points
x_pde = torch.rand(n_pde, 1) * 2 - 1 # x in [-1, 1]
t_pde = torch.rand(n_pde, 1) # t in [0, 1]
x_ic = torch.rand(n_ic, 1) * 2 - 1
u_ic_vals = torch.tensor(
ic_func(x_ic.numpy()), dtype=torch.float32
)
t_bc = torch.rand(n_bc, 1)
x_bc_l = -torch.ones(n_bc, 1)
x_bc_r = torch.ones(n_bc, 1)
optimizer.zero_grad()
loss, (l_pde, l_ic, l_bc) = pinn_loss(
model, nu, x_pde, t_pde, x_ic, u_ic_vals, x_bc_l, x_bc_r, t_bc
)
loss.backward()
optimizer.step()
scheduler.step()
history['total'].append(loss.item())
history['pde'].append(l_pde)
history['ic'].append(l_ic)
history['bc'].append(l_bc)
if (epoch + 1) % 2000 == 0:
print(f"Epoch {epoch+1}: loss={loss.item():.6e} "
f"(PDE={l_pde:.2e}, IC={l_ic:.2e}, BC={l_bc:.2e})")
wall_time = time.time() - t0
return model, history, wall_time
The solver-selection problem is framed as a contextual multi-armed bandit. The context (state) for each problem instance is:
where:
The agent selects one of $K = 6$ solver configurations:
where $T_{\text{wall}}$ is the wall-clock time in seconds and $\varepsilon(a, \mathbf{s})$ is the achieved $L^2$ error. The reward encourages the agent to select the fastest solver that meets the accuracy target. Failing to meet the target incurs a large penalty proportional to how far the error exceeds the target.
| # | Item | Description |
|---|---|---|
| D1 | burgers_fd.py |
Finite-difference solver with upwind advection and centered diffusion. Supports configurable $N_x$. Includes reference solution generator. |
| D2 | burgers_pinn.py |
PINN solver with configurable architecture. Includes training loop, loss logging, and prediction on evaluation grid. |
| D3 | generate_references.py |
Script to produce high-resolution reference solutions for all 18 (IC, $\nu$) combinations. |
| D4 | benchmark.py |
Runs all solver configs on all problem instances. Outputs a CSV with columns: IC, nu, solver, Nx/layers, L2_error, wall_time, n_steps/epochs. |
| D5 | solver_agent.py |
LinUCB bandit agent for solver selection. Includes training and evaluation loops. |
| D6 | Report (PDF, 5–7 pages) | Sections: Introduction, Classical Solver, PINN Solver, Benchmark Results, Solver Selection Agent, Discussion, Conclusion. |
| D7 | Plots |
(a) Solution profiles $u(x, 1)$ for FD vs PINN vs reference at three $\nu$ values. (b) $L^2$ error vs $\nu$ for both solvers (log-log plot). (c) Wall-clock time vs $L^2$ error Pareto front. (d) PINN training loss curves for different architectures. (e) Agent's solver selection decisions shown as a confusion matrix vs oracle. |
| D8 | Metrics table | Complete table of $L^2$ error, wall-clock time, and solver selected for all 18 instances and all solver configurations. Mean and std across IC families. |
| Criterion | Weight | Description |
|---|---|---|
| Correctness | 30 | FD solver correctly implements upwind advection and centered diffusion; converges at the expected rate ($O(\Delta x)$ for upwind). PINN correctly computes PDE residual gradients via automatic differentiation. Both solvers produce solutions with $L^2$ error below $10^{-2}$ for $\nu = 0.1$. Reference solution is validated against an analytical solution for the sine IC case. |
| Efficiency | 20 | FD solver uses vectorized NumPy operations (no Python for-loops over grid points in the final version). PINN training uses GPU if available and batched collocation sampling. Benchmark timing is fair (same hardware, warm-up runs excluded). |
| Robustness | 20 | Both solvers produce finite, physically plausible solutions for all 18 problem instances. The FD solver does not blow up for any $\nu$ value. The PINN converges (loss decreases monotonically after initial transient) for all configurations. The bandit agent's selected solver meets the accuracy target on at least 4 of 6 held-out instances. |
| Reproducibility | 15 |
All random seeds are fixed (NumPy, PyTorch, CUDA). A requirements.txt
specifies exact package versions. Running python generate_references.py &&
python benchmark.py && python solver_agent.py reproduces all results.
Benchmark CSV matches reported tables to within 5% on wall-clock time.
|
| Documentation | 15 | Report includes clear derivation of both numerical schemes. Plots are publication-quality with labeled axes, legends, and captions. Code has module-level and function-level docstrings. Discussion section addresses when each solver is preferred and why. |
| Total | 100 |