Dynamic PINNs (5): Identifying Unknown Parameters using Inverse PINNs

An inverse PINN learns not just the solution to an ODE, but also an unknown physical parameter from noisy data. This notebook tackles the Duffing oscillator, treating the cubic stiffness alpha as unknown and recovering it from 250 noisy displacement measurements. We walk through the full pipeline, from ground-truth generation with solve_ivp, synthetic noise, input/output normalisation, and a three-part loss (IC, physics residual, data) to observing how the alpha parameter converges from zero to its true value over 30,000 training epochs.

pytorch
SciML
PINN
Author

Mei-Chin Pang

Published

June 20, 2026

Learning Unknown Parameters from Inverse PINNs

The Duffing Oscillator

\boxed{m\frac{d^2x}{dt^2} + c\frac{dx}{dt} + kx + \alpha x^3 = F\cos(\Omega t)}

This is the equation of motion for a Duffing oscillator, which is a nonlinear mass-spring-damper system driven by a harmonic force. It extends the classical forced damped harmonic oscillator by adding a cubic stiffness term \alpha x^3.

Term Meaning
m({d^2x}/{dt^2}) Inertia: mass times acceleration.
c({dx}/{dt}) Viscous damping: a velocity-proportional resistive force that dissipates energy.
kx Linear restoring force: the usual Hooke’s law spring term.
\alpha x^3 Duffing nonlinearity: a cubic stiffness correction. For \alpha > 0 the spring stiffens at large displacements; for \alpha < 0 it softens.
F\cos(\Omega t) External harmonic forcing at frequency \Omega and amplitude F.

We treat \alpha as unknown and use the inverse PINN to identify it from noisy displacement data. All other parameters (m, c, k, F, \Omega) are assumed known.

Initial conditions: x(0) = 0.1 m, {dx}/{dt}(0) = 0 m/s over t \in [0, 30] s.

NoteUnderstanding the Duffing oscillator

(a) What does this equation reduce to when \alpha = 0?

With \alpha = 0, the equation becomes

m\frac{d^2x}{dt^2} + c\frac{dx}{dt} + kx = F\cos(\Omega t),

which is the forced damped harmonic oscillator (or linear mass-spring-damper). This is one of the most studied systems, where its behaviour is well understood: it exhibits a transient response that decays exponentially, followed by a steady-state sinusoidal oscillation at the forcing frequency \Omega, with amplitude determined by the distance between \Omega and the natural frequency \omega_n = \sqrt{k/m}.

(b) How does the \alpha x^3 term affect the oscillation?

The \alpha x^3 term adds a stiffness hardening effect (for \alpha > 0) (i.e. the effective spring constant increases with displacement amplitude). A stiffer spring restores the mass more forcefully, so the oscillation frequency is higher than for a purely linear spring with the same small-displacement stiffness k. This means the resonance peak shifts to higher frequencies as amplitude increases, a hallmark feature of the Duffing oscillator. The phenomenon is called amplitude-frequency dependence or backbone curve shift.

(c) What kind of response do we expect over t \in [0, 30] s?

After the initial transient (driven by the IC) decays due to damping, we expect a periodic steady-state oscillation at the forcing frequency \Omega. Because the system is nonlinear, the steady-state response may also contain harmonics at frequencies 3\Omega, 5\Omega, etc. (odd harmonics, due to the cubic nonlinearity), but the dominant component will be at \Omega. The amplitude of the steady-state will be smaller than the transient peaks.

What makes this an inverse PINN?

In a forward PINN, every parameter in the ODE is known and the network only learns the solution x(t). In an inverse PINN, one or more physical parameters are unknown. The network must learn both the solution and the parameter values simultaneously.

Here, the unknown is \alpha. We declare it as a learnable scalar using torch.nn.Parameter, initialised to zero:

alpha_learnable = torch.nn.Parameter(torch.zeros(1, requires_grad=True, device=device))
optimiser = torch.optim.Adam(list(pinn.parameters()) + [alpha_learnable], lr=learning_rate)

Two things to note:

  • torch.nn.Parameter wraps a tensor so that PyTorch tracks gradients with respect to it, just like a network weight. During backpropagation, \hat\alpha receives a gradient and is updated by Adam alongside the network parameters.
  • The optimiser is given list(pinn.parameters()) + [alpha_learnable], the network weights and \hat\alpha in a single parameter group. This means one optimiser.step() call updates everything: the network learns x(t) while simultaneously nudging \hat\alpha toward the true value.

Physics residual

The ODE residual used in the physics loss is:

r = m\frac{d^2\hat{x}}{dt^2} + c\frac{d\hat{x}}{dt} + k\hat{x} + \hat\alpha\,\hat{x}^3 - F\cos(\Omega t)

This is the original Duffing equation rearranged so that r = 0 when the ODE is satisfied exactly. The key difference from a forward PINN is that \hat\alpha is not a fixed constant, it is the learnable parameter that the optimiser adjusts during training. As the network’s displacement prediction \hat{x}(t) improves (driven by the data loss), the physics loss forces \hat\alpha to take whatever value makes r \approx 0 consistent with that displacement. The data and physics losses work together: the data anchors the trajectory shape, and the physics loss uses that shape to pin down \alpha.

NoteUnderstanding the inverse PINN setup

(a) We initialise alpha_learnable = 0. Why is this reasonable, and how does the data loss correct it?

Initialising alpha_learnable = 0 means we start with the assumption of no cubic stiffness, which is the simplest possible model. This is a reasonable prior if we have no information about \alpha. The data loss then corrects this: at \alpha = 0, the network’s physics loss forces it to learn a solution that satisfies the linear ODE, which will not match the observed displacements (since the true data was generated with \alpha = 1). The mismatch between the network’s prediction and the data creates a gradient that flows back through the physics residual to alpha_learnable, gradually pushing it upward toward the value that makes the predicted trajectory match the observations.

(b) What does \partial\mathcal{L}_\text{phys}/\partial\hat\alpha represent?

\partial\mathcal{L}_\text{phys}/\partial\hat\alpha is the rate of change of the physics loss with respect to the estimated cubic stiffness:

\frac{2}{N_f}\sum_i r_i \cdot \hat{x}_i^3

so the gradient is large and positive when the residual r_i is large and the predicted displacement \hat{x}_i is large. The optimiser uses this gradient to decide how to adjust \hat\alpha: if increasing \hat\alpha would reduce the residual (i.e. the current \hat\alpha underestimates the true stiffness), the gradient will be negative and the optimiser will increase \hat\alpha.

(c) Why is \mathcal{L}_\text{data} especially critical here? What happens without it?

Without data, the loss contains only \mathcal{L}_\text{IC} and \mathcal{L}_\text{phys}. But the system m\ddot{x} + c\dot{x} + kx + \hat\alpha x^3 = F\cos(\Omega t) with given ICs has a unique solution for any value of \hat\alpha – each choice of \hat\alpha gives a different but equally valid (self-consistent) trajectory. There is no mechanism to prefer \hat\alpha = 1 over \hat\alpha = 0 or \hat\alpha = 5. The data loss is what anchors the problem: it says “the solution must also match these specific observed values,” which constrains \hat\alpha to the value consistent with those observations.

(d) Give a real engineering scenario where \alpha might be unknown. How would an inverse PINN help?

One example: structural health monitoring of a rubber vibration isolator. Rubber isolators exhibit nonlinear stiffness (the \alpha x^3 term arises from the material’s hyperelastic behaviour), but the precise \alpha depends on rubber compound, temperature, and ageing as it changes over the life of the component. Here, we can attach an accelerometer to the isolated mass (sensor data) and use an inverse PINN to identify the current \alpha from the vibration response in operation, without needing to remove and test the isolator in a lab. This enables condition monitoring and remaining-life prediction.

(e) Could we identify multiple unknown parameters simultaneously? What makes it harder?

Yes, in principle, we can declare multiple nn.Parameter scalars (\hat\alpha, \hat{c}, etc.) and add them all to the optimiser. However, identifying multiple parameters is harder because:

  • identifiability: different parameter combinations may produce similar responses, making it impossible to uniquely recover individual values from limited data;
  • loss landscape complexity, more parameters create a higher-dimensional optimisation problem with more saddle points and local minima;
  • data requirements increase: each additional unknown requires more diverse observations to constrain it independently. Careful experimental design (e.g. varying forcing frequency or amplitude) is often needed to make the multi-parameter problem well-posed.

Model Architecture

NoteTraining an inverse PINN

(a) What does requires_grad=True mean and why is it essential here?

requires_grad=True tells PyTorch to track all operations performed on this tensor in the computational graph so that gradients can be computed with respect to it during backpropagation. It is essential here because alpha_learnable appears inside the physics residual calculation. Without requires_grad=True, PyTorch would not know to compute \partial\mathcal{L}/\partial\hat\alpha, so the optimiser would never update it, and it would remain fixed at its initial value of zero.

(b) Why does identifying an unknown parameter require more training (30,000 epochs) than a forward PINN?

The inverse problem has two coupled learning tasks that must be solved simultaneously:

  • Learning the network weights to approximate x(t).
  • Estimating \hat\alpha.

These tasks are interacting, which mean the network cannot converge to the correct trajectory until \hat\alpha is close to 1.0, and \hat\alpha cannot converge until the network is producing a reasonable trajectory to generate meaningful physics residuals. This chicken-and-egg interaction slows convergence. Additionally, \hat\alpha starts far from the truth (at 0), requiring many epochs for the gradient signal to accumulate enough to move it substantially. The 30-second time window also has more complex dynamics (transient + steady-state) requiring more capacity to represent.

Inverse PINNs Implementation

import time
import warnings

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
from scipy.integrate import odeint, solve_ivp
from tqdm import tqdm

plt.rcParams["text.usetex"] = True

# Suppress Intel Iris Xe "not officially supported" warning
warnings.filterwarnings(
    "ignore",
    message=".*detected GPU.*not officially supported by PyTorch XPU.*")

# Define the GPU type ("cuda" for NVIDIA GPUs, "xpu" for Intel GPUs)
gpu_type = "xpu"

if gpu_type == "cuda":
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
elif gpu_type == "xpu":
    device = torch.device("xpu" if torch.xpu.is_available() else "cpu")
else:
    raise ValueError("Invalid GPU type. Use 'cuda' or 'xpu'.")

print(f"Using device: {device}")
Using device: cpu

Numerical Ground Truth

Physical parameters

The Duffing oscillator is configured with * m = 1 kg, * k = 1 N/m, * c = 0.75 Ns/m, * F = 0.5 N, and * \Omega = 1 rad/s. * The true cubic stiffness is \alpha = 1 N/m^3, which is the value the inverse PINN will try to recover. * The time window is t \in [0, 30] s with 1,000 evaluation points.

Converting to a first-order system

The Duffing equation is second-order, so we introduce velocity v = \frac{dx}{dt} to write it as two first-order ODEs:

\frac{dx}{dt} = v, \qquad \frac{dv}{dt} = \frac{1}{m}\bigl[F\cos(\Omega t) - cv - kx - \alpha x^3\bigr]

The function duffing_ode(t, y) returns [v,\, a] where a = dv/dt.

solve_ivp vs odeint

Previous notebooks used scipy.integrate.odeint; this notebook uses scipy.integrate.solve_ivp. Both solve the same kind of problem, but with different interfaces:

Aspects odeint solve_ivp
Function signature f(y, t, ...) (state first, then time) f(t, y) (time first, then state)
Call syntax odeint(f, y0, t_array, args=(...)) solve_ivp(f, (t_start, t_end), y0, t_eval=..., method=...)
Duffing example odeint(duffing_ode, y0, t_eval, args=(m, c, k, alpha, F, Omega)) solve_ivp(duffing_ode, (0, 30), y0, t_eval=t_eval, method='RK45')
Time points Solves at every point in t_array Solves adaptively; use t_eval to specify output times
Output shape (n_times, n_vars): rows are time steps sol.y has shape (n_vars, n_times): columns are time steps
Duffing output sol[:, 0] → displacement x(t) sol.y[0, :] → displacement x(t)
Default method lsoda (auto-switches stiff/non-stiff) RK45 (explicit Runge-Kutta)
Status Legacy (still works, not deprecated) Recommended by SciPy for new code

The key practical differences to watch for:

  • Argument order is swapped: duffing_ode(t, y) vs the pendulum_system(y, t, ...) used in previous notebooks. Because solve_ivp passes (t, y) directly, extra parameters (m, c, etc.) are captured from the enclosing scope rather than passed via args.
  • Output is transposed: sol.y[0, :] gives displacement (first variable, all times), whereas odeint returns solution[:, 0] (all times, first variable).
  • t_eval keyword: solve_ivp uses adaptive stepping internally and only returns output at t_eval points. Without t_eval, we get the solver’s own (irregular) time steps.

Output

sol.t contains the 1,000 evaluation times: * sol.y[0, :] holds displacement x(t) * sol.y[1, :] holds velocity {dx}/{dt}(t).

Two plots show the displacement and velocity time histories separately.

# Physical parameters (alpha is the unknown we want to identify)
m     = 1.0    # mass (kg)
k     = 1.0    # linear stiffness (N/m)
c     = 0.75   # damping (Ns/m)
alpha = 1.0    # TRUE cubic stiffness — hidden from the PINN
F     = 0.5    # forcing amplitude (N)
Omega = 1.0    # forcing frequency (rad/s)

# ----------------------------------------------------------------------------
# Generate synthetic data by numerically solving the Duffing ODE 
# with known parameters

# The Duffing equation is a second-order nonlinear ODE:
# m * x'' + c * x' + k * x + alpha * x^3 = F * cos(Omega * t)

# Initial condition: t=0
# x(0) = 0.1 m, x_dot(0) = 0 m/s
y0 = np.array([0.1, 0.0])  

t_start, t_end = 0, 30
t_eval = np.linspace(t_start, t_end, 1000)


def duffing_ode(t, y):
    x, v = y
    a = (F * np.cos(Omega * t) - c * v - k * x - alpha * x**3) / m
    return [v, a]

# Numerically solve the Duffing ODE to generate "observed" data
sol   = solve_ivp(
    duffing_ode,
    (t_start, t_end),
    y0,
    t_eval=t_eval,
    method='RK45')
t     = sol.t
x     = sol.y[0, :]
x_dot = sol.y[1, :]

# ----------------------------------------------------------------------------
# Plot 1: Displacement time history
fig, axes = plt.subplots(
    2,1,
    figsize=(9, 10))

axes[0].plot(
    t, x,
    color='blue',
    label='Displacement (m)')
axes[0].set_xlabel(
    'Time (s)',
    fontsize=14)
axes[0].set_ylabel(
    'Displacement (m)',
    fontsize=14)
axes[0].set_title(
    'Duffing Oscillator: Numerical Solution',
    fontsize=16)
axes[0].legend(
    fontsize=16,
    loc='upper right')
axes[0].grid(True, alpha=0.3)

# ----------------------------------------------------------------------------
# Plot 2: Velocity time history
axes[1].plot(
    t, x_dot,
    color='red',
    label='Velocity (m/s)')
axes[1].set_xlabel(
    'Time (s)',
    fontsize=14)
axes[1].set_ylabel(
    'Velocity (m/s)',
    fontsize=14)
axes[1].legend(
    fontsize=16,
    loc='upper right')
axes[1].grid(True, alpha=0.3)
plt.show()

Synthetic Experimental Data

Why more data points for an inverse problem?

In a forward PINN the physics alone can guide the solution, the data loss is a helpful regulariser. In an inverse PINN the data is the only source of information about the unknown parameter \alpha. Without enough observations spread across the time window, the data loss cannot distinguish between different values of \hat\alpha, and the parameter estimate may not converge. 250 points gives the optimiser a denser signal to work with.

Sampling strategy

250 time points are drawn uniformly at random from [0, 30] s and sorted into chronological order. The ODE is solved at exactly these times via solve_ivp to obtain the “true” displacement values. Random (non-uniform) spacing mimics real sensor data.

Adding noise

Each displacement value is perturbed with independent Gaussian noise of \sigma = 0.01 m. This is slightly larger than the \sigma = 0.005 used for the double pendulum, reflecting a noisier sensor environment. The noise makes the inverse problem harder. The PINN must learn to ignore measurement error while still extracting the correct \alpha from the noisy trajectory shape.

Overlay plot

The plot compares the 1,000-point numerical solution (blue curve) with the 250 noisy data points (black scatter). The noise should be visible but small relative to the oscillation amplitude, confirming a realistic signal-to-noise ratio.

# More data points than previous examples: 
# inverse problems need denser observations
# to constrain the unknown parameter uniquely
N_exp_points = 250     # number of experimental observation points
exp_noise    = 0.01    # noise standard deviation (m)

# Generate synthetic "experimental" data by sampling the numerical solution 
# at random time points and adding noise
# Sort the time points to ensure they are in ascending order for plotting
t_exp_np  = np.sort(
    np.random.rand(N_exp_points) * (t_end - t_start) + t_start)

# Solve the ODE at the experimental time points to get the "true" values 
# before adding noise
sol_exp   = solve_ivp(
    duffing_ode, (t_start, t_end), y0, t_eval=t_exp_np, method='RK45')

# Add Gaussian noise to the displacement data to simulate measurement noise
x_exp_np  = sol_exp.y[0, :] + exp_noise * np.random.randn(N_exp_points)

# ---------------------------------------------------------------------------
plt.figure(figsize=(10, 4))
plt.plot(
    t, x,
    color='blue',
    label='Numerical Solution',
    alpha=0.7)
plt.scatter(
    t_exp_np, x_exp_np,
    color='black', s=8,
    label=f'Experimental Data ({N_exp_points} noisy points)')

plt.xlabel(
    'Time (s)',
    fontsize=12)
plt.ylabel(
    'Displacement (m)',
    fontsize=12)
plt.title(
    'Synthetic Experimental Data vs Numerical Solution',
    fontsize=13)
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

Data Normalisation

This cell applies the same normalisation strategy used in previous notebooks, adapted for a single-output problem with a longer time window.

Time normalisation

The raw experimental times t \in [0, 30] are mapped to [-1, +1]:

\tilde{t} = \frac{2(t - t_\text{min})}{t_\text{max} - t_\text{min}} - 1

The longer time window ([0, 30] vs [0, 5] in the double pendulum) makes normalisation especially important. Without it, the raw time values would span a wide range, leading to large input magnitudes that destabilise training.

Displacement normalisation

The displacement is standardised to zero mean and unit variance using the experimental data statistics:

\tilde{x} = \frac{x - \mu_x}{\sigma_x}

Unlike the coupled ODE notebook (which needed per-angle normalisation), here there is only one output variable, so a single pair of constants (\mu_x, \sigma_x) suffices.

Tensor conversion

  • t_exp_norm has shape (250, 1): a column vector of normalised times.
  • x_exp_norm has shape (250, 1): a column vector of normalised displacements.
t_min, t_max = t_start, t_end
dt           = t_max - t_min

# Calculate mean and std of the experimental displacement data 
# for standardisation
x_mean = np.mean(x_exp_np)
x_std  = np.std(x_exp_np)

# ---------------------------------------------------------------------------
# Normalise time to [-1, +1]
t_exp_norm_np = 2 * (t_exp_np - t_min) / dt - 1.0

# ---------------------------------------------------------------------------
# Standardise displacement to zero mean, unit variance
x_exp_norm_np = (x_exp_np - x_mean) / x_std

# Convert to PyTorch tensors and move to device
t_exp_norm = (torch.from_numpy(t_exp_norm_np)
              .float().view(-1, 1).to(device))
x_exp_norm = (torch.from_numpy(x_exp_norm_np)
              .float().view(-1, 1).to(device))

print("t_exp_norm range: "
      f"[{t_exp_norm.min():.3f}, {t_exp_norm.max():.3f}]"
      "  (should be near [-1, +1])")
print(f"x_exp_norm mean:  {x_exp_norm.mean():.4f}"
      "  (should be ~0.0)")
print(f"x_exp_norm std:   {x_exp_norm.std():.4f}"
      "   (should be ~1.0)")
t_exp_norm range: [-0.995, 0.992]  (should be near [-1, +1])
x_exp_norm mean:  -0.0000  (should be ~0.0)
x_exp_norm std:   1.0020   (should be ~1.0)

Network Definition and Training Setup

Neural network architecture

The same FullyConnectedNN architecture used in previous notebooks, configured for a single-output inverse problem:

Component Code Description
fcs Linear(1, 64) + Tanh Input layer: maps normalised time to 64 hidden units.
fch 2 x Linear(64, 64) + Tanh Hidden layers: two additional layers (N_LAYERS - 1 = 2).
fce Linear(64, 1) Output layer: maps to one normalised displacement \tilde{x} (no activation).

Note this uses 3 hidden layers (not 4 as in the coupled ODE), since the Duffing oscillator has only one output variable and less complex coupling structure.

Normalisation helpers

  • normalise_t(t_tensor): maps raw physical time to [-1, +1].
  • denormalise_x(x_tensor): reverses the standardisation \hat{x} = \tilde{x} \cdot \sigma_x + \mu_x to recover physical displacement.

The inverse PINN addition

The key difference from a forward PINN is alpha_learnable, a scalar nn.Parameter initialised to 0, representing the unknown cubic stiffness \hat\alpha. It is added to the optimiser alongside the network weights:

optimiser = torch.optim.Adam(
    list(pinn.parameters()) + [alpha_learnable], ...)

This means each optimiser.step() call updates both the network weights (to improve the displacement prediction) and \hat\alpha (to improve the physics residual). The alpha_history list records \hat\alpha at every epoch so we can plot its convergence toward the true value.

Training configuration

  • Architecture: 1 input, 3 hidden layers of 64 units, 1 output.
  • Collocation points: 1,000 uniformly spaced in [0, 30] s with requires_grad=True.
  • Boundary point: t = 0 for the IC loss (x(0) = 0.1, \dot{x}(0) = 0).
  • Loss weights: all set to 1.
  • Optimiser: Adam with LR = 10^{-3}, applied to network weights and alpha_learnable.
  • LR scheduler: StepLR halves the learning rate every 5,000 epochs (at 5k, 10k, 15k, 20k, 25k).
  • Epochs: 30,000, longer than forward PINNs due to the coupled weight-and-parameter learning problem.
torch.manual_seed(42)

class FullyConnectedNN(nn.Module):

    def __init__(self, N_INPUT, N_OUTPUT, N_HIDDEN, N_LAYERS):
        super().__init__()
        
        # Tanh activation
        activation = nn.Tanh
        
        # Define the fully connected layers of the PINN
        # The first layer maps the input to the hidden dimension,
        # followed by Tanh activation
        self.fcs = nn.Sequential(nn.Linear(N_INPUT, N_HIDDEN), activation())
        
        # The hidden layers consist of N_LAYERS-1 blocks of Linear + Tanh
        self.fch = nn.Sequential(*[
            nn.Sequential(nn.Linear(N_HIDDEN, N_HIDDEN), activation())
            for _ in range(N_LAYERS - 1)])
        
        # The final layer maps the hidden dimension to the output dimension
        self.fce = nn.Linear(N_HIDDEN, N_OUTPUT)

    # The forward method defines how the input data flows through the network
    def forward(self, x):
        x = self.fcs(x); x = self.fch(x); x = self.fce(x)
        return x

# ---------------------------------------------------------------------------
# Normalisation helpers
def normalise_t(t_tensor):
    return 2.0 * (t_tensor - t_min) / dt - 1.0

# Denormalisation helper for the PINN output (displacement)
def denormalise_x(x_tensor):
    return x_tensor * x_std + x_mean

# ---------------------------------------------------------------------------
# PINNs hyperparameters
n_input = 1
n_output = 1
n_hidden = 64
n_layers = 3
epochs = 30000
learning_rate = 1e-3

# Loss weights for the different components of the PINN loss function
lambda_boundary = 1
lambda_physics = 1
lambda_data = 1

# ---------------------------------------------------------------------------
# Instantiate the PINN and move it to the appropriate device (GPU or CPU)
pinn = FullyConnectedNN(
    n_input,
    n_output,
    n_hidden,
    n_layers).to(device)

# ---------------------------------------------------------------------------
# Inverse problem setup: we want to learn the unknown parameter alpha
# alpha_learnable is a scalar nn.Parameter initialised to 0
# (we pretend we don't know alpha; it starts as 'no cubic stiffness')
# requires_grad=True lets PyTorch track gradients through it
alpha_learnable = torch.nn.Parameter(
    torch.zeros(1, requires_grad=True, device=device)
)
alpha_history = []

# ---------------------------------------------------------------------------
# Initial condition time point (t=0) for the initial condition loss
t_ic = torch.tensor([
    [0.0]],
    device=device,
    requires_grad=True)

# We sample the physics loss at N_phys points in the time domain
N_phys  = 1000

# t_phys is a column vector of time points from t_start to t_end,
# with requires_grad=True so we can compute derivatives w.r.t. time
t_phys  = torch.linspace(
    t_start, t_end, N_phys, device=device).view(-1, 1)
t_phys.requires_grad_(True)

# alpha_learnable is passed to Adam alongside the network weights
optimiser = torch.optim.Adam(
    list(pinn.parameters()) + [alpha_learnable],
    lr=learning_rate)

# Learning rate scheduler to reduce the learning rate by half 
# every 5000 epochs
scheduler = torch.optim.lr_scheduler.StepLR(
    optimiser,
    step_size=5000,
    gamma=0.5)


print(f"Network parameters:"  
      + f"{sum(p.numel() for p in pinn.parameters())}")
print(f"alpha_learnable initialised to: "  
      + f"{alpha_learnable.item():.4f}  (target: {alpha})")
print(f"Training for {epochs} epochs...")
Network parameters:8513
alpha_learnable initialised to: 0.0000  (target: 1.0)
Training for 30000 epochs...

PINN Training Loop

Each epoch executes four parts, then updates both the network weights and \hat\alpha.

Part 1: Initial Condition (IC) Loss

  1. Normalise t = 0 and forward-pass to get \tilde{x}(0).
  2. Denormalise to physical displacement.
  3. Compute dx/dt at t = 0 via torch.autograd.grad.
  4. Penalise deviations from both initial values:

\mathcal{L}_\text{IC} = (\hat{x}(0) - 0.1)^2 + \left(\frac{d\hat{x}}{dt}(0)\right)^2

Part 2: Physics (Duffing ODE Residual) Loss

  1. Normalise the 1,000 collocation times and forward-pass to get normalised predictions.
  2. Denormalise to physical displacement \hat{x}.
  3. Compute first and second derivatives via two calls to torch.autograd.grad (both with create_graph=True).
  4. Evaluate the Duffing ODE residual using alpha_learnable (not the true \alpha):

r = m\frac{d^2\hat{x}}{dt^2} + c\frac{d\hat{x}}{dt} + k\hat{x} + \hat\alpha\,\hat{x}^3 - F\cos(\Omega t)

\mathcal{L}_\text{phys} = \text{mean}(r^2)

This is where the inverse learning happens: because alpha_learnable appears in r, backpropagation computes \partial\mathcal{L}_\text{phys}/\partial\hat\alpha and the optimiser adjusts \hat\alpha to reduce the residual.

Part 3: Data Loss (in normalised space)

\mathcal{L}_\text{data} = \text{mean}\!\bigl[(\tilde{\hat{x}} - \tilde{x}^\text{exp})^2\bigr]

This anchors the trajectory shape so that \hat\alpha converges to the value consistent with the observed data.

Part 4: Total Loss and Weight Update

\mathcal{L_\text{total}} = \lambda_\text{IC}\,\mathcal{L}_\text{IC} + \lambda_\text{phys}\,\mathcal{L}_\text{phys} + \lambda_\text{data}\,\mathcal{L}_\text{data}

total_loss.backward() computes gradients for both the network weights and alpha_learnable. optimiser.step() updates all of them via Adam. After each epoch, the current value of \hat\alpha is appended to alpha_history so we can plot its convergence.

total_loss_history = []
ic_loss_history = []
physics_loss_history = []
data_loss_history = []

start_training_time = time.time()

for i in range(epochs + 1):
    optimiser.zero_grad()

    # -----------------------------------------------------------------------
    # Part 1: Initial Condition Loss
    t_boundary_norm = normalise_t(t_ic)
    x_boundary_norm = pinn(t_boundary_norm)
    x_boundary      = denormalise_x(x_boundary_norm)
    dx_dt_boundary  = torch.autograd.grad(
        x_boundary, t_ic,
        torch.ones_like(x_boundary), create_graph=True)[0]

    # Squared error between predicted displacement at t=0 and 0.1 m
    loss_x_boundary  = torch.mean((x_boundary - 0.1) ** 2)

    # Squared error between predicted velocity at t=0 and 0.0 m/s
    loss_dx_boundary = torch.mean((dx_dt_boundary - 0.0) ** 2)

    loss_boundary = loss_x_boundary + loss_dx_boundary

    # -----------------------------------------------------------------------
    # Part 2: Physics (Residual) Loss
    t_physics_norm = normalise_t(t_phys)
    x_physics_norm = pinn(t_physics_norm)
    x_physics      = denormalise_x(x_physics_norm)

    dx_dt   = torch.autograd.grad(
        x_physics, t_phys,
        torch.ones_like(x_physics), create_graph=True)[0]
    d2x_dt2 = torch.autograd.grad(
        dx_dt, t_phys,
        torch.ones_like(dx_dt), create_graph=True)[0]

    # Duffing ODE residual with alpha_learnable
    # r = m*(d2x/dt2) + c*(dx/dt) + k*x + alpha_hat*x^3 - F*cos(Omega*t)
    # Backprop through this line updates alpha_learnable toward 
    # the true alpha=1.0
    r_physics    = (
        m*d2x_dt2
        + c*dx_dt
        + k*x_physics
        + alpha_learnable*(x_physics**3)
        - F*torch.cos(Omega*t_phys))
    loss_physics = torch.mean(r_physics ** 2)

    # -----------------------------------------------------------------------
    # Part 3: Data Loss (normalised space)
    x_data_norm = pinn(t_exp_norm)
    loss_data   = torch.mean((x_data_norm - x_exp_norm) ** 2)

    # -----------------------------------------------------------------------
    # Part 4: Total Loss
    total_loss = (lambda_boundary * loss_boundary 
        + lambda_physics * loss_physics 
        + lambda_data * loss_data)


    # Track alpha parameter history for plotting later
    alpha_history.append(alpha_learnable.item())

    # Track different loss components for plotting later
    total_loss_history.append(total_loss.item())
    ic_loss_history.append(loss_boundary.item())
    physics_loss_history.append(loss_physics.item())
    data_loss_history.append(loss_data.item())

    total_loss.backward()
    optimiser.step()
    scheduler.step()

    if i % 1000 == 0 or i == epochs:
        print(f"Epoch {i:>5d}/{epochs}  "
              f"Loss: {total_loss.item():.6f}  "
              f"alpha: {alpha_learnable.item():.4f}")

end_training_time = time.time()
training_duration = end_training_time - start_training_time

print(f"\nTraining complete.")
print(f"Training duration:     {training_duration:.4f} seconds")
print(f"Final loss:            {total_loss_history[-1]:.6f}")
print(f"Identified alpha:      {alpha_learnable.item():.4f}")
print(f"True alpha:            {alpha:.4f}")
print(f"Error:                 {abs(alpha_learnable.item() - alpha):.4f}")
Epoch     0/30000  Loss: 1.158624  alpha: -0.0010
Epoch  1000/30000  Loss: 0.018673  alpha: 0.6067
Epoch  2000/30000  Loss: 0.010201  alpha: 0.9503
Epoch  3000/30000  Loss: 0.008680  alpha: 0.9707
Epoch  4000/30000  Loss: 0.004569  alpha: 0.9637
Epoch  5000/30000  Loss: 0.001877  alpha: 0.9419
Epoch  6000/30000  Loss: 0.001186  alpha: 0.9636
Epoch  7000/30000  Loss: 0.000978  alpha: 0.9675
Epoch  8000/30000  Loss: 0.000895  alpha: 0.9710
Epoch  9000/30000  Loss: 0.000889  alpha: 0.9737
Epoch 10000/30000  Loss: 0.000806  alpha: 0.9759
Epoch 11000/30000  Loss: 0.000781  alpha: 0.9774
Epoch 12000/30000  Loss: 0.000751  alpha: 0.9792
Epoch 13000/30000  Loss: 0.000725  alpha: 0.9810
Epoch 14000/30000  Loss: 0.000705  alpha: 0.9820
Epoch 15000/30000  Loss: 0.000700  alpha: 0.9826
Epoch 16000/30000  Loss: 0.000676  alpha: 0.9836
Epoch 17000/30000  Loss: 0.000665  alpha: 0.9842
Epoch 18000/30000  Loss: 0.000656  alpha: 0.9847
Epoch 19000/30000  Loss: 0.000649  alpha: 0.9851
Epoch 20000/30000  Loss: 0.000698  alpha: 0.9854
Epoch 21000/30000  Loss: 0.000640  alpha: 0.9856
Epoch 22000/30000  Loss: 0.000637  alpha: 0.9857
Epoch 23000/30000  Loss: 0.000633  alpha: 0.9859
Epoch 24000/30000  Loss: 0.000630  alpha: 0.9860
Epoch 25000/30000  Loss: 0.000636  alpha: 0.9861
Epoch 26000/30000  Loss: 0.000625  alpha: 0.9862
Epoch 27000/30000  Loss: 0.000623  alpha: 0.9863
Epoch 28000/30000  Loss: 0.000621  alpha: 0.9864
Epoch 29000/30000  Loss: 0.000618  alpha: 0.9865
Epoch 30000/30000  Loss: 0.000616  alpha: 0.9866

Training complete.
Training duration:     265.5618 seconds
Final loss:            0.000616
Identified alpha:      0.9866
True alpha:            1.0000
Error:                 0.0134

Evaluation and Plots

PINN displacement prediction

300 evenly spaced test points are generated over [0, 30] s, normalised, and passed through the trained network inside torch.no_grad() (no gradient tracking needed at inference). The normalised output is denormalised back to physical displacement for plotting.

Plot 1: Displacement comparison

This plot overlays five elements:

  • PINN prediction (black solid line): the trained network’s displacement \hat{x}(t).
  • Numerical solution (red dashed line): the ground-truth solve_ivp result. If training succeeded, the two curves should be nearly indistinguishable.
  • Experimental data (black scatter): the 250 noisy observations used for training.
  • Physics collocation points (grey scatter at x = 0): a visual indication of where the ODE residual was enforced. These are shown at x = 0 for display purposes only – the actual residual was evaluated at the network’s predicted displacement.
  • Boundary point (red dot at (0, 0.1)): the initial condition.

Plot 2: Training loss

The total loss on a log scale over 30,000 epochs. Orange dashed lines mark the LR reduction points (every 5,000 epochs). Each reduction should produce a visible drop in loss as the optimiser enters a finer convergence regime.

Plot 3: \alpha parameter convergence

The centrepiece of this notebook. This plot shows alpha_history, which is the value of \hat\alpha at every epoch converging from its initial value of 0 toward the true value \alpha = 1.0 (red dashed horizontal line). The convergence curve reveals how quickly and smoothly the inverse PINN identifies the unknown parameter. Typically \hat\alpha rises rapidly in the first few thousand epochs (as the network begins producing a reasonable trajectory), then refines gradually as the displacement prediction improves.

# PINN predictions for plotting
t_test      = torch.linspace(t_start, t_end, 300, device=device).view(-1, 1)
t_test_norm = normalise_t(t_test)

# We use torch.no_grad() to avoid tracking gradients during inference,
# which saves memory and computation since we don't need to update the model 
# during evaluation.
with torch.no_grad():
    x_pred_norm = pinn(t_test_norm)
    x_pred      = denormalise_x(x_pred_norm)

t_plot = t_test[:, 0].detach().cpu().numpy()
x_plot = x_pred[:, 0].detach().cpu().numpy()


# Convert t_phys to CPU numpy for plotting the collocation points
t_phys_np = t_phys.detach().cpu().numpy()

# ---------------------------------------------------------------------------
# Plot 1: Displacement time history with PINN prediction, numerical solution,
# physics collocation points, and experimental data
plt.figure(figsize=(9, 5))

# Plot PINN prediction
plt.plot(
    t_plot, x_plot,
    color='black',
    label='PINN Prediction',
    alpha=0.85)

# Plot the original numerical solution for reference
plt.plot(
    t, x,
    color='red',
    linestyle='dashed',
    label='Numerical Solution',
    alpha=0.7)

# Plot the physics collocation points 
plt.scatter(
    t_phys_np,
    np.zeros_like(t_phys_np),
    color='grey',
    s=10,
    label='Physics Collocation Points',
    zorder=3)

# Plot the experimental data points
plt.scatter(
    t_exp_np, x_exp_np,
    color='black',
    s=8,
    label='Experimental Data',
    zorder=4)
plt.scatter(
    [0], [0.1],
    color='red',
    s=60,
    zorder=5,
    label='Boundary Point (IC)')
plt.xlabel('Time (s)', fontsize=14)
plt.ylabel('Displacement (m)', fontsize=14)
plt.title('Inverse PINN: Duffing Oscillator Displacement', fontsize=16)
plt.legend(fontsize=16)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# ---------------------------------------------------------------------------
# Plot 2: Training loss
plt.figure(figsize=(9, 5))
plt.plot(
    total_loss_history,
    color='teal',
    linewidth=1.2,
    label='Total Training Loss')

for ep in [5000, 10000, 15000, 20000, 25000]:
    plt.axvline(x=ep, color='orange', linestyle='--', alpha=0.4)

plt.axvline(
    x=5000,
    color='orange',
    linestyle='--',
    alpha=0.4,
    label='LR reductions')

plt.yscale('log')
plt.xlabel('Epoch', fontsize=14)
plt.ylabel('Total Loss (log scale)', fontsize=14)
plt.title('Inverse PINN Training Loss', fontsize=16)
plt.legend(fontsize=16)
plt.grid(True, which='both', linestyle='-', alpha=0.2)
plt.tight_layout()
plt.show()

# ---------------------------------------------------------------------------
# Plot 3: Alpha convergence - the centrepiece of this notebook
plt.figure(figsize=(9, 5))
plt.plot(
    alpha_history,
    color='black',
    linewidth=1.2,
    label=fr'Learned $\hat\alpha$: {alpha_learnable.item():.4f}')

plt.axhline(
    y=alpha,
    color='red',
    linestyle='--',
    linewidth=1.5,
    label=f'True alpha = {alpha}')
plt.xlabel('Epoch', fontsize=14)
plt.ylabel(r'$\hat\alpha$ (N/m³)', fontsize=14)
plt.title(r'Inverse PINN: $\alpha$ Parameter Convergence', fontsize=16)
plt.legend(fontsize=16)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

References

  1. Francis Fernandes (2026). Mastering Dynamic PINNs.