Tanh, SiLU, or GELU when the PDE needs higher-order derivatives.
import torch
import torch.nn as nn
class PINN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(1, 32),
nn.Tanh(),
nn.Linear(32, 32),
nn.Tanh(),
nn.Linear(32, 1)
)
def forward(self, t):
return self.net(t)
A classic ODE system is a damped harmonic oscillator governed by the differential equiation $$ 0 =m \frac{d^2x}{dt^2} + \mu \frac{dx}{dt} + kx, $$ with mass $m$, spring constant $k$, and linear friction coefficient $\mu$.
The physics residual for our PINN is: $$ r_x(t)=m \frac{d^2x}{dt^2} + \mu \frac{dx}{dt} + kx . $$
At interior collocation points $\{t_i\}_{i=1}^{N_f}$, the physics loss is $$ \mathcal L_{\text{phys}} = \frac{1}{N_f}\sum_{i=1}^{N_f} r_x(t_i)^2 $$
Enforcing initial conditions with $$ \mathcal L_{\text{IC}} = \big(x(0)-u_0\big)^2 + \big(v(0)-v_0\big)^2, $$
the total loss becomes: $$ \mathcal L = \lambda_{\text{phys}}\,\mathcal L_{\text{phys}} + \lambda_{\text{BC/IC}}\,\mathcal L_{\text{IC}} $$
Below the harmonic oscillator PINN is solved using PyTorch and plotted against the analytical solution.
The key of the implementation is PyTorch's autograd.grad function with the option create_graph=True.
import torch
import torch.nn as nn
import torch.optim as optim
# 2. Physics Parameters (Underdamped System)
m, mu, k = 1.0, 0.5, 4.0
t_collocation = torch.linspace(0, 5, 500, requires_grad=True).view(-1, 1)
t_zero = torch.tensor([[0.0]], requires_grad=True)
model = PINN()
# 3. Core Loss Logic Function (Needed by LBFGS Closure)
def compute_loss():
# Physics Loss
x = model(t_collocation)
dx_dt = torch.autograd.grad(x, t_collocation, grad_outputs=torch.ones_like(x), create_graph=True)[0]
d2x_dt2 = torch.autograd.grad(dx_dt, t_collocation, grad_outputs=torch.ones_like(dx_dt), create_graph=True)[0]
physics_residual = m * d2x_dt2 + mu * dx_dt + k * x
loss_physics = torch.mean(physics_residual ** 2)
# Initial Condition Loss (t=0, x=1, dx/dt=0)
x_zero = model(t_zero)
dx_dt_zero = torch.autograd.grad(x_zero, t_zero, grad_outputs=torch.ones_like(x_zero), create_graph=True)[0]
loss_IC = (x_zero - 1.0)**2 + (dx_dt_zero - 0.0)**2
return loss_physics + 20.0 * loss_IC
# 4. Phase 1: Adam Pre-training (Gets us close quickly)
adam_optimizer = optim.Adam(model.parameters(), lr=0.003)
for epoch in range(1000):
adam_optimizer.zero_grad()
loss = compute_loss()
loss.backward()
adam_optimizer.step()
# 5. Phase 2: LBFGS Fine-tuning (Crushes remaining error)
lbfgs_optimizer = optim.LBFGS(
model.parameters(),
lr=1.0,
max_iter=500, # Maximum iterations per step
history_size=50, # History size for Hessian approximation
line_search_fn="strong_wolfe" # Ensures stable learning rate adjustments
)
# LBFGS requires a closure step function
def closure():
lbfgs_optimizer.zero_grad()
loss = compute_loss()
loss.backward()
return loss
# A single .step() call with LBFGS can run up to max_iter iterations internally
lbfgs_optimizer.step(closure)
final_loss = compute_loss()
1) Track Geometry
We represent the track as a parametric curve in the vertical plane: $$ \mathbf r(u)= \begin{bmatrix} x(u)\\ z(u) \end{bmatrix}, \quad u\in[0,1]. $$ Its tangent with respect to $u$ is $$ \mathbf r'(u)=\frac{d\mathbf r}{du} = \begin{bmatrix} x'(u)\\ z'(u) \end{bmatrix}, $$ with norm $$ \left\|\mathbf r'(u)\right\|=\sqrt{x'(u)^2+z'(u)^2}. $$ The unit tangent is $$ \hat{\mathbf T}(u)=\frac{\mathbf r'(u)}{\|\mathbf r'(u)\|}, $$ $$ T_z(u)=\hat{\mathbf T}(u)\cdot \mathbf e_z =\frac{z'(u)}{\|\mathbf r'(u)\|}. $$
2) Kinematics: Linking $u(t)$ and Physical Speed
Let $s$ be arc length along the track: $$ s(u)=\int_0^u \left\|\mathbf r'(\xi)\right\|\,d\xi. $$ Since speed along the track is $v(t)=\frac{ds}{dt}$, chain rule gives $$ \frac{ds}{dt} = \frac{ds}{du}\frac{du}{dt} = \|\mathbf r'(u)\|\frac{du}{dt}. $$ Therefore, $$ \boxed{ \frac{du}{dt}=\frac{v}{\|\mathbf r'(u)\|} }. $$ This equation converts physical speed (m/s) into parameter-speed (1/s).
3) Dynamics Along the Tangent
Using Newton’s second law projected along the track tangent: $$ m\frac{dv}{dt}=F_{\text{gravity,tan}}+F_{\text{drag,lin}}+F_{\text{drag,quad}}. $$ With gravity $\mathbf g=[0,-g]^T$, the tangential gravity contribution is $$ F_{\text{gravity,tan}}=m\,\mathbf g\cdot \hat{\mathbf T}(u) =-m g\,T_z(u). $$ A common resistive model is: $$ F_{\text{drag,lin}}=-k_1 v,\qquad F_{\text{drag,quad}}=-k_2 v|v|. $$ So $$ m\frac{dv}{dt} = -m g\,T_z(u)-k_1 v-k_2 v|v|, $$ and $$ \boxed{ \frac{dv}{dt} = -g\,T_z(u)-\frac{k_1}{m}v-\frac{k_2}{m}v|v| }. $$
The track is built using SciPy's CubicSpline. A custom Track class encapsulates all relevant track parameters. Theoretically, both $\left\lVert \frac{d\mathbf r}{du}\right\rVert$ and $T_z(u)$ can be evaluated directly from SciPy splines, acting as a lookup table. However, this severs the computational graph, preventing the network from learning correctly. When passing the output $u$ into a SciPy/NumPy array to look up a value, PyTorch sees a "black box". The backward pass completely stops at that lookup step. The gradients cannot flow backward through the parameter and into the weights of the main PINN.
1) From spline geometry to learnable surrogate model
To keep the computational graph intact (preserving differentiability through the chain), we train a surrogate neural network predicting both $u$ dependent parameters: $$ u \mapsto \left\lVert \frac{d\mathbf r}{du}\right\rVert, \qquad u \mapsto T_z(u). $$ $$ t \rightarrow u(t) \rightarrow \{\|dr/du\|, T_z\} \rightarrow \text{ODE residuals}. $$
This also keeps the full PINN workflow in PyTorch and allows GPU execution.
2) Architecture and training
The track model is a basic MLP with one input and two outputs, and nn.Tanh() activation. Training inputs are uniformly sampled $u \in [0,1]$. Ground-truth labels come from the spline derivatives:
import torch
import torch.nn as nn
class TrackNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(1, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh(),
nn.Linear(64, 2)
)
def forward(self, u):
return self.net(u)
3) Losses and optimization
The MLP is trained using the two losses with mean squared error (MSE): $$ \mathcal L_{drdu} = \lambda_{drdu}\,\frac{1}{N}\sum_i \left(\hat d_i - d_i\right)^2, $$ $$ \mathcal L_{tz} = \lambda_{tz}\,\frac{1}{N}\sum_i \left(\hat t_i - t_i\right)^2. $$ After comparing plots for $\|dr/du\|$ and $T_z(u)$, we choose $\lambda_{drdu} = 1$ and $\lambda_{tz} = 100$. We optimize with torch.optim.Adam.
Custom Track class:
import numpy as np
from scipy.interpolate import CubicSpline
class Track():
def __init__(self, x_points=None, z_points=None):
if x_points is None:
self._x_points= np.array([0, 5, 10, 20, 25, 20, 15, 20, 30], dtype=float)
else:
self.members = np.array(list(x_points),dtype=float) # Creates a copy to prevent side effects
if z_points is None:
self._z_points= np.array([30, 20, 10, 10, 17.5, 25, 17.5, 10, 10], dtype=float)
else:
self.members = np.array(list(z_points),dtype=float) # Creates a copy to prevent side effects
# Parameter values corresponding to given control points
u_points = np.linspace(0.0, 1.0, len(self._x_points))
# Cubic splines for x(u), z(u)
self._x_spline = CubicSpline(u_points, self._x_points)
self._z_spline = CubicSpline(u_points, self._z_points)
def x_of_u(self,u):
u = np.asarray(u).reshape(-1)
return self._x_spline(u)[:, None]
def z_of_u(self,u):
u = np.asarray(u).reshape(-1)
return self._z_spline(u)[:, None]
def xz_of_u(self,u):
x = self.x_of_u(u)
z = self.z_of_u(u)
return x, z
def dr_du_vec(self,u):
u = np.asarray(u).reshape(-1)
dx_du = self._x_spline.derivative()(u)
dz_du = self._z_spline.derivative()(u)
return np.stack((dx_du, dz_du), axis=-1)
def dr_du_norm(self,u):
deriv = self.dr_du_vec(u)
return np.linalg.norm(deriv, axis=-1, keepdims=True)
def tangent_unit_vertical(self,u):
deriv = self.dr_du_vec(u)
norm = np.linalg.norm(deriv, axis=-1, keepdims=True)
norm = np.clip(norm, 1e-12, None) # numerical safety
return deriv[:, 1:2] / norm # T_z
Tack parameter learning:
import numpy as np
import torch
from omegaconf import DictConfig
from torch.optim import Adam
from torch.nn import MSELoss
def run_training():
# Build dataset
track = Track()
u_np = np.linspace(0.0, 1.0, 1000).reshape(-1, 1).astype(np.float32)
y_drdu_np = track.dr_du_norm(u_np).astype(np.float32)
y_tz_np = track.tangent_unit_vertical(u_np).astype(np.float32)
u_all = torch.from_numpy(u_np)
y_drdu_all = torch.from_numpy(y_drdu_np)
y_tz_all = torch.from_numpy(y_tz_np)
# Model
track_model = TrackNN()
# Optimizers
opt = Adam(track_model.parameters(), lr=1e-3)
mse = MSELoss()
batch_size = 128
epochs = 20000
n = u_all.shape[0]
for epoch in range(epochs):
idx = torch.randint(0, n, (batch_size,))
u_b = u_all[idx]
y_drdu_b = y_drdu_all[idx]
y_tz_b = y_tz_all[idx]
#
opt.zero_grad()
pred_drdu_tz = track_model(u_b)
loss = mse(pred_drdu_tz[:,0:1], y_drdu_b) + 100*mse(pred_drdu_tz[:,1:2], y_tz_b)
loss.backward()
opt.step()
1) PINN architecture
We train a MLP state model (5 hiddeln layers with each 64 neurons) $$ t \mapsto \big(u(t), v(t)\big) $$ by minimizing a weighted sum of physics residual and initial-condition losses.
import torch
import torch.nn as nn
class PINN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(1, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh(),
nn.Linear(64, 2)
)
def forward(self, t):
return self.net(t)
2) Governing physics residuals
During trajectory training, the state network predicts $(u(t), v(t))$. The frozen track network is then queried at the predicted $u(t)$: $$ \hat d(u)\approx \left\|\frac{d\mathbf r}{du}\right\|, \qquad \hat T_z(u)\approx T_z(u), $$ the ODE residuals are $$ r_u(t)=\frac{du}{dt}-\frac{v}{\hat d(u)}, $$ $$ r_v(t)=\frac{dv}{dt}+g\,\hat T_z(u)+\frac{k_1}{m}v+\frac{k_2}{m}v|v|. $$ At interior collocation points $\{t_i\}_{i=1}^{N_f}$, the physics loss is $$ \mathcal L_{\text{phys}} = w_u\cdot \frac{1}{N_f}\sum_{i=1}^{N_f} r_u(t_i)^2 + w_v\cdot \frac{1}{N_f}\sum_{i=1}^{N_f} r_v(t_i)^2. $$
3) Initial-condition loss
We could enforce intitial conditions through a loss function $$ \mathcal L_{\text{IC}} = w_{u0}\,\big(u(0)-u_0\big)^2 + w_{v0}\,\big(v(0)-v_0\big)^2. $$ As an elegant alternative we hard constrain these initial conditions into the network using a HardConstrainedPINN wrapper class. It trains parameters $u_{raw}$ and $v_{raw}$ of the original PINN MLP such that $$ \begin{align} u &= u_0 + (1 - exp(-t))\,u_{raw}\\ v &= v_0 + (1 - exp(-t))\,v_{raw}. \end{align} $$
# 1. HARD-CONSTRAIN THE NETWORK OUTSIDE THE OPTIMIZATION LOOP
# This forces the network to mathematically satisfy ICs, eliminating ic_loss entirely.
class HardConstrainedPINN(torch.nn.Module):
def __init__(self, raw_pinn, U0, V0):
super().__init__()
self.pinn = raw_pinn
# Register constants to ensure they stay on the correct device
self.register_buffer("u0", torch.tensor([[U0]], dtype=torch.float32))
self.register_buffer("v0", torch.tensor([[V0]], dtype=torch.float32))
def forward(self, t):
uv_raw = self.pinn(t)
u_raw, v_raw = uv_raw[:, 0:1], uv_raw[:, 1:2]
# At t=0, (1 - exp(-t)) is 0, guaranteeing u = u0 and v = v0
# As t grows, the network freely learns the residual dynamics
u_constrained = self.u0 + (1.0 - torch.exp(-t)) * u_raw
v_constrained = self.v0 + (1.0 - torch.exp(-t)) * v_raw
return torch.cat([u_constrained, v_constrained], dim=1)
4) Rewrite physics residual and clamp u
Currently, we have $$ r_u(t)=\frac{du}{dt}-\frac{v}{\hat d(u)}, $$ In theory, as we observed $\hat d(u)$ is always positive and does not go to zero, division by $\hat d(u)$ should not result in numerical issues. However, very likely this causes an imbalance between $r_u(t)$ and $r_v(t)$ due to differing magnitude orders. We solve this by changing $r_u(t)$ to: $$ r_u(t)=\hat d(u)\frac{du}{dt}-v, $$ We have trained the track within the interval $u \in [0,1]$. Especially, during the initial training steps, our PINN could predict $u$ significantly outside this region leading the optimization process into an incorrect solution space. To mitigate this, we clamp $u$ to [0,1] before extracting the track parameters.
5) Training strategy
Unlike traditional ODE solvers, PINNs learn the solution across all time collocation points simultaneously. For better convergence into the correct solution space, we split the time domain $t$ into smaller intervals, training the PINN incrementally with these expanding $t$ intervals. Each interval has a linearly rising distribution generated by a custom function rand_linear_rising and is initially trained with torch.optim.Adam and refined with torch.optim.LBFGS.
def rand_linear_rising(shape, a, b):
# 1. Generate standard uniform random numbers between 0 and 1
u = torch.rand(shape)
# 2. Apply the inverse CDF for a linearly rising distribution
return a + (b - a) * torch.sqrt(u)
In contrast to real-world physics, where a cart stopping precisely at the loop's top is improbable, the PINN might frequently predict this outcome as it constitutes a trivial (constant) solution.
Final implementation:
import os
import torch
from omegaconf import DictConfig
import torch.optim as optim
from torch.optim import Adam, lr_scheduler
# ---------------- constants ----------------
T_MAX = 7
MASS = 500.0
DRAG_LINEAR = 3.0
DRAG_QUADRATIC = 4.0
U0 = 0.0
V0 = 0.25
def load_track_model(ckpt_dir="checkpoints"):
track_model = TrackNN()
params_path = os.path.join(ckpt_dir, "track_model_output.0.pth")
track_model.load_state_dict(torch.load(params_path, map_location="cpu"))
return track_model
def train():
raw_pinn = PINN()
pinn = HardConstrainedPINN(raw_pinn, U0, V0)
# load frozen track heads
track_model = load_track_model()
max_epochs = 2000
t_int_steps = 12
t_max_list = [np.floor(i) for i in np.arange(1,t_int_steps + 1)*T_MAX/t_int_steps]
#t_res = torch.linspace(0, T_MAX, 1000).view(-1, 1).requires_grad_(True)
track = Track()
def compute_loss(current_t_max):
# Collocation points where we enforce ODE
t_int = rand_linear_rising((256,1), a=0, b=current_t_max)
t_res, sort_idx = torch.sort(t_int, dim=0)
t_res.requires_grad_(True)
# Define custom loss function computation
uv = pinn(t_res)
u, v = uv[:, 0:1], uv[:, 1:2]
du_dt = torch.autograd.grad(u, t_res, torch.ones_like(u), create_graph=True)[0]
dv_dt = torch.autograd.grad(v, t_res, torch.ones_like(v), create_graph=True)[0]
# Using clamped u to grab track values safely
u_clamped = torch.clamp(u, min=0, max=1)
drdu_tz = track_model(u_clamped)
drdu, tz = drdu_tz[:, 0:1], drdu_tz[:, 1:2]
# ODE residuals
res1 = du_dt * drdu - v
res2 = dv_dt + 9.81 * tz + (DRAG_LINEAR / MASS) * v + (DRAG_QUADRATIC / MASS) * v * torch.abs(v)
base_res_loss = (1 * (res1**2 + res2**2)).mean()
loss = base_res_loss
return loss, res1, res2
for i, current_t_max in enumerate(t_max_list):
print(f"--- t interval {i+1}/{t_int_steps} ---")
# --- Phase 1: Adam Pre-training ---
print("--- Starting Phase 1: Adam Pre-training ---")
opt = Adam(pinn.parameters(), lr=0.001)
for epoch in range(max_epochs):
opt.zero_grad()
loss, res1, res2 = compute_loss(current_t_max)
loss.backward()
opt.step()
if epoch % 500 == 0:
print(f"Adam Step {epoch:05d} | Loss: {loss.item():.2e} | res_u: {(res1**2).mean().item():.2e} | res_v: {(res2**2).mean().item():.2e}")
# --- Phase 2: LBFGS Fine-Tuning ---
print("--- Starting Phase 2: LBFGS Fine-tuning ---")
# Instantiate LBFGS Optimizer with a stable strong_wolfe line search
lbfgs_opt = optim.LBFGS(
pinn.parameters(),
lr=1.0,
max_iter=1000, # Max iterations within the single LBFGS block
history_size=100, # Curvature history size
line_search_fn="strong_wolfe" # Modulates learning epoch sizes precisely
)
# LBFGS requires a self-contained closure function
def closure():
lbfgs_opt.zero_grad()
loss, _, _ = compute_loss(current_t_max)
loss.backward()
return loss
# Execute LBFGS convergence sequence
lbfgs_opt.step(closure)
# Print the final loss metrics
final_loss = closure()
PINNs avoid many setup challenges of grid-based PDE solvers by learning a solution directly from sampled coordinates. However, this simplicity shifts the challenge to optimization.
Alexander Edelmann ©
Micro and Nanotechnology