---
title: "Universal Differential Equations 1: Learning a Missing Term"
subtitle: "A Shared Equinox Correction Inside a Mechanistic Network Model"
format:
html:
code-fold: false
toc: true
echo: false
embed-resources: true
fig-width: 8
out-width: "100%"
jupyter: python3
execute:
cache: true
---
Try this notebook interactively:
[Download .ipynb](https://github.com/virtual-twin/tvboptim/blob/main/docs/advanced/equinox_ude.ipynb){.btn .btn-primary download="equinox_ude.ipynb"}
[Download .qmd](equinox_ude.qmd){.btn .btn-secondary download="equinox_ude.qmd"}
[Open in Colab](https://colab.research.google.com/github/virtual-twin/tvboptim/blob/main/docs/advanced/equinox_ude.ipynb){.btn .btn-warning target="_blank"}
## Introduction
A universal differential equation (UDE) combines known mechanisms with a
trainable function. Instead of asking a neural network to reproduce the complete
dynamics, we give it a narrower job: correct a specific part of a mechanistic
model that we know is incomplete.
This is the classical residual, or "missing physics," UDE construction: a known
mechanistic vector field is retained while a universal approximator learns only
an unknown or misspecified contribution
([Rackauckas et al., 2020](https://doi.org/10.48550/arXiv.2001.04385)). In
neuroscience, this hybrid formulation provides a bridge between interpretable
neural dynamics and flexible data-driven models
([El-Gazzar and van Gerven, 2025](https://doi.org/10.3389/fncom.2025.1677930)).
Unlike a fully learned Neural ODE, where a neural network parameterizes the
complete vector field ([Chen et al., 2018](https://proceedings.neurips.cc/paper/2018/hash/69386f6bb1dfed68692a24c8686939b9-Abstract.html)),
the MLP here augments equations whose known structure remains explicit.
This tutorial constructs a reproducible stochastic four-region FitzHugh--Nagumo
network ([FitzHugh, 1961](https://doi.org/10.1016/S0006-3495(61)86902-6);
[Nagumo et al., 1962](https://doi.org/10.1109/JRPROC.1962.288235)). The synthetic
teacher uses the complete cubic voltage damping, while the incomplete model
contains only half of it. One Equinox multilayer perceptron (MLP) supplies the
missing local correction at every region:
$$
\begin{aligned}
\mathrm dV_i &= \left[V_i - \alpha\frac{V_i^3}{3} - W_i + I + C_i
+ g_\theta(V_i, W_i)\right]\mathrm dt + \sigma\,\mathrm dB_i, \\
\mathrm dW_i &= \frac{V_i + a - bW_i}{\tau}\,\mathrm dt,
\end{aligned}
$$
where $\alpha=1/2$ in the incomplete model, $C_i$ is the known network input,
and $B_i$ is an independent Brownian motion for each region. The target
correction remains
$$
g^*(V_i) = -(1-\alpha)\frac{V_i^3}{3}.
$$
The same $g_\theta$ is evaluated independently at all four nodes. Their different
initial conditions and network inputs expose it to several trajectories through
state space, but there is still only one set of trainable MLP weights.
::: {.callout-note}
## Where this tutorial sits
This page covers the mechanics end to end: marking a module trainable, getting
gradients through the solver, fitting, and checking what was learned. The example
is deliberately synthetic so that the correction the MLP should recover is known
exactly.
[Universal Differential Equations 2](neural_mass_ude.qmd) picks those mechanics up
and asks the modelling questions instead: where a correction belongs, what to
condition it on, how to make a known limit exact by construction rather than by
fitting, and what counts as evidence that the right term was learned. Read this one
first.
:::
::: {.callout-note}
## Why retain half of the cubic term?
Removing all cubic damping makes the incomplete FitzHugh--Nagumo system
unbounded before an initially untrained MLP can correct it. Retaining a known
stabilizing component is both numerically safer and a more realistic UDE setup:
the mechanistic model is approximate rather than structurally unstable.
:::
```{python}
#| output: false
#| echo: false
try:
import google.colab
print("Running in Google Colab - installing dependencies...")
!pip install -q tvboptim
print("✓ Dependencies installed!")
except ImportError:
pass
```
```{python}
#| output: false
#| code-fold: true
#| code-summary: "Environment Setup and Imports"
#| echo: true
import equinox as eqx
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
import optax
import time
plt.rcParams.update({"figure.dpi": 200, "savefig.dpi": 200})
jax.config.update("jax_enable_x64", True)
from tvboptim.experimental.network_dynamics import (
Bunch,
DenseGraph,
Network,
prepare,
)
from tvboptim.experimental.network_dynamics.coupling import LinearCoupling
from tvboptim.experimental.network_dynamics.dynamics import AbstractDynamics
from tvboptim.experimental.network_dynamics.noise import AdditiveNoise
from tvboptim.experimental.network_dynamics.solvers import Heun
from tvboptim.optim import OptaxOptimizer
from tvboptim.types import (
EquinoxParameter,
combine_state,
partition_state,
)
```
## Complete and Incomplete Dynamics
The teacher contains the complete cubic term. It is used only to generate a
small synthetic data set whose ground truth is known.
```{python}
#| echo: true
class TeacherFitzHughNagumo(AbstractDynamics):
STATE_NAMES = ("V", "W")
INITIAL_STATE = (-1.0, -0.5)
DEFAULT_PARAMS = Bunch(a=0.7, b=0.8, tau=12.5, I=0.5)
COUPLING_INPUTS = {"structural": 1}
def dynamics(self, t, state, params, coupling, external):
del t, external
V, W = state
network_input = coupling.structural[0]
dV = V - V**3 / 3.0 - W + params.I + network_input
dW = (V + params.a - params.b * W) / params.tau
return jnp.stack((dV, dW))
```
The UDE retains half of the cubic damping and calls the same correction module
once per node. TVB-Optim presents the state to `dynamics` in state-major form,
`[states, nodes]`, so both `V` and `W` below have shape `[nodes]`. The Equinox
MLP, however, describes the correction at one node: with `in_size=2`, it expects
one feature vector `[V_i, W_i]`. Stacking along the last axis therefore builds a
`[nodes, 2]` array whose rows are the inputs for the individual nodes. For this
two-state example, `state.T` would have the same values; constructing
`node_features` explicitly documents which states the learned term sees and in
which order, and generalizes naturally to selected or derived features.
`jax.vmap` then applies the *same* MLP to every row of `node_features`. This is
shared-weight evaluation, not four copies of the MLP. Because the MLP was
created with `out_size=1`, one evaluation returns shape `[1]` rather than a JAX
scalar. The mapped result consequently has shape `[nodes, 1]`, and `[:, 0]`
removes that singleton output dimension to obtain the `[nodes]` correction
required by `base_drift`. Without this indexing, adding `[nodes, 1]` to
`[nodes]` would trigger broadcasting and produce an unintended two-dimensional
array.
```{python}
#| echo: true
class UDEFitzHughNagumo(AbstractDynamics):
STATE_NAMES = ("V", "W")
INITIAL_STATE = (-1.0, -0.5)
DEFAULT_PARAMS = Bunch(
a=0.7,
b=0.8,
tau=12.5,
I=0.5,
cubic_fraction=0.5,
correction=None,
)
COUPLING_INPUTS = {"structural": 1}
def dynamics(self, t, state, params, coupling, external):
del t, external
V, W = state
network_input = coupling.structural[0]
node_features = jnp.stack((V, W), axis=-1) # [nodes, 2]
learned_term = jax.vmap(params.correction)(node_features)[:, 0]
base_drift = (
V
- params.cubic_fraction * V**3 / 3.0
- W
+ params.I
+ network_input
)
dW = (V + params.a - params.b * W) / params.tau
return jnp.stack((base_drift + learned_term, dW))
```
## Four Coupled Regions, One Shared Correction
We use a small directed graph. TVB-Optim stores dense connectivity as
`weights[target, source]`, so each row lists the incoming connection strengths
for one target region. The graph is asymmetric to give the four otherwise
identical regions different network contexts.
```{python}
#| echo: true
weights = jnp.array(
[
[0.0, 0.8, 0.0, 0.2],
[0.1, 0.0, 0.7, 0.0],
[0.4, 0.0, 0.0, 0.6],
[0.0, 0.3, 0.2, 0.0],
]
)
initial_state = jnp.array(
[
[-1.2, -0.8, 0.1, 0.7], # V
[-0.6, -0.2, 0.3, 0.5], # W
]
)
DT = 0.05
T1 = 15.0
COUPLING_GAIN = 0.12
NOISE_SIGMA = 0.08
TRAIN_NOISE_KEY = jax.random.key(11)
N_NODES = weights.shape[0]
REGION_LABELS = tuple(f"R{node + 1}" for node in range(N_NODES))
```
```{python}
#| label: fig-shared-correction
#| fig-cap: "**Four coupled regions with one shared local correction.** Directed edges carry the mechanistic network input. Every node evaluates the same MLP $g_\\theta(V,W)$ with shared weights; the MLP is not replicated or fitted independently per region."
#| code-fold: true
#| code-summary: "Show plotting code"
angles = np.linspace(0, 2 * np.pi, N_NODES, endpoint=False) + np.pi / 4
positions = np.stack((np.cos(angles), np.sin(angles)), axis=1)
fig, (ax_graph, ax_shared) = plt.subplots(1, 2, figsize=(8.1, 3.4))
for target in range(N_NODES):
for source in range(N_NODES):
strength = float(weights[target, source])
if strength == 0.0:
continue
start = positions[source] * 0.82
end = positions[target] * 0.82
ax_graph.annotate(
"",
xy=end,
xytext=start,
arrowprops=dict(
arrowstyle="->",
color="0.35",
alpha=0.35 + 0.65 * strength,
linewidth=0.8 + 2.0 * strength,
shrinkA=12,
shrinkB=12,
),
)
colors = plt.cm.cividis(np.linspace(0.15, 0.85, N_NODES))
for node, (position, color) in enumerate(zip(positions, colors)):
ax_graph.scatter(*position, s=650, color=color, edgecolor="black", zorder=3)
ax_graph.text(
*position,
REGION_LABELS[node],
ha="center",
va="center",
fontsize=9,
)
ax_graph.set_title("Directed mechanistic coupling")
ax_graph.set_aspect("equal")
ax_graph.set_xlim(-1.35, 1.35)
ax_graph.set_ylim(-1.25, 1.25)
ax_graph.axis("off")
ax_shared.axis("off")
for node, color in enumerate(colors):
y = 0.83 - node * 0.2
ax_shared.text(
0.06,
y,
f"$(V_{node + 1}, W_{node + 1})$",
transform=ax_shared.transAxes,
ha="center",
va="center",
bbox=dict(boxstyle="round,pad=0.35", facecolor=color, edgecolor="black"),
)
ax_shared.annotate(
"",
xy=(0.61, 0.5),
xytext=(0.22, y),
xycoords="axes fraction",
arrowprops=dict(arrowstyle="->", color="0.35"),
)
ax_shared.text(
0.72,
0.5,
"$g_\\theta(V,W)$\nshared weights",
transform=ax_shared.transAxes,
ha="center",
va="center",
fontsize=12,
bbox=dict(boxstyle="round,pad=0.8", facecolor="white", edgecolor="black", linewidth=1.5),
)
ax_shared.set_title("One module, evaluated at every region")
plt.tight_layout()
```
## Generate Reproducible Noisy Training Data
Both networks use the same graph, coupling, initial conditions, solver, and
mechanistic parameters. A small additive process noise acts only on voltage.
Their only intentional structural difference is the missing half of the cubic
voltage damping.
```{python}
#| echo: true
def prepare_network(dynamics, noise_key=TRAIN_NOISE_KEY, t1=T1):
network = Network(
dynamics=dynamics,
coupling={
"structural": LinearCoupling(source="V", G=COUPLING_GAIN)
},
graph=DenseGraph(weights),
noise=AdditiveNoise(
sigma=NOISE_SIGMA,
apply_to="V",
key=noise_key,
),
)
solve_fn, config = prepare(
network,
Heun(),
t0=0.0,
t1=t1,
dt=DT,
)
config.initial_state.dynamics = initial_state
return solve_fn, config
teacher_solve, teacher_config = prepare_network(TeacherFitzHughNagumo())
target_solution = teacher_solve(teacher_config)
assert target_solution.variable_names == ("V", "W")
assert target_solution.ys.shape == (int(T1 / DT), 2, N_NODES)
assert jnp.all(jnp.isfinite(target_solution.ys))
```
The teacher and UDE use the same fixed random key. This *common random numbers*
design makes the example reproducible and prevents the optimizer from comparing
different random forcing on every evaluation. The noise realization is part of
the synthetic training data; only the MLP weights change during fitting.
This is a software demonstration with synthetic observations, not evidence that
the learned term is a biologically identified mechanism. Its advantage is that
we know exactly what the correction should recover.
## Add an Equinox Parameter
We initialize a small MLP and set its final layer to zero. The first incomplete
rollout therefore contains no learned correction and exposes the mechanistic
model's misspecification directly.
```{python}
#| echo: true
#| output: false
#| code-fold: true
#| code-summary: "Show the zero-initialized MLP helper"
def make_correction_module(key, in_size, out_size, width_size=16, depth=2):
"""Small MLP with a zeroed final layer, so training starts from no correction.
The zeroed final layer makes the correction identically zero at step zero, so
the first rollout is the mechanistic model on its own.
"""
module = eqx.nn.MLP(
in_size=in_size,
out_size=out_size,
width_size=width_size,
depth=depth,
activation=jax.nn.tanh,
key=key,
)
final_layer = module.layers[-1]
return eqx.tree_at(
lambda model: (model.layers[-1].weight, model.layers[-1].bias),
module,
(jnp.zeros_like(final_layer.weight), jnp.zeros_like(final_layer.bias)),
)
```
```{python}
#| echo: true
correction = EquinoxParameter(
make_correction_module(jax.random.key(7), in_size=2, out_size=1)
)
ude_solve, ude_config = prepare_network(
UDEFitzHughNagumo(correction=correction)
)
print(f"Correction wrapper: {type(ude_config.dynamics.correction).__name__}")
print(f"Shared module: {type(ude_config.dynamics.correction.module).__name__}")
```
`EquinoxParameter` marks the MLP's inexact array leaves as trainable. Its
activation functions, architecture, and other non-array leaves remain static.
The wrapper itself is callable, so the dynamics does not need to unwrap it.
Because an Equinox module contains callable leaves, a full configuration should
cross the compilation boundary through `eqx.filter_jit`:
```{python}
#| echo: true
compiled_solve = eqx.filter_jit(ude_solve)
initial_solution = ude_solve(ude_config)
compiled_initial = compiled_solve(ude_config)
assert jnp.allclose(compiled_initial.ys, initial_solution.ys)
assert jnp.all(jnp.isfinite(initial_solution.ys))
```
```{python}
#| label: fig-initial-mismatch
#| fig-cap: "**Effect of the missing nonlinear damping under shared random forcing.** The complete teacher trajectory (black dashed) and the stable but misspecified mechanistic UDE before training (colored) diverge at every region. Different initial states and directed coupling give the shared correction several dynamical contexts to learn from."
#| code-fold: true
#| code-summary: "Show plotting code"
fig, axes = plt.subplots(2, 2, figsize=(8.1, 5.2), sharex=True, sharey=True)
for node, (ax, color) in enumerate(zip(axes.flat, colors)):
ax.plot(
target_solution.ts,
target_solution.ys[:, 0, node],
"k--",
linewidth=1.8,
label="complete teacher",
)
ax.plot(
initial_solution.ts,
initial_solution.ys[:, 0, node],
color=color,
linewidth=1.4,
label="incomplete model",
)
ax.set_title(REGION_LABELS[node])
ax.grid(alpha=0.2)
axes[0, 0].legend(frameon=False, fontsize=8)
for ax in axes[-1]:
ax.set_xlabel("Time [a.u.]")
for ax in axes[:, 0]:
ax.set_ylabel("V")
plt.tight_layout()
```
## Verify Gradient Flow
Partitioning separates the MLP arrays from the fixed graph, solver state,
mechanistic parameters, and callable activation leaves. A direct gradient through
the rollout confirms that every trainable leaf receives finite signal.
```{python}
#| echo: true
target_V = target_solution.ys[:, 0, :]
def loss(config):
predicted_V = ude_solve(config).ys[:, 0, :]
return jnp.mean((predicted_V - target_V) ** 2)
diff_config, static_config = partition_state(ude_config)
def partitioned_loss(diff):
return loss(combine_state(diff, static_config))
gradient = jax.grad(partitioned_loss)(diff_config)
gradient_leaves = jax.tree.leaves(gradient)
gradient_norm = jnp.sqrt(sum(jnp.sum(leaf**2) for leaf in gradient_leaves))
assert gradient_leaves
assert all(jnp.all(jnp.isfinite(leaf)) for leaf in gradient_leaves)
assert gradient_norm > 0.0
print(f"Trainable array leaves: {len(gradient_leaves)}")
print(f"Initial gradient norm: {float(gradient_norm):.3e}")
```
## Fit the Shared Neural Correction
`OptaxOptimizer` uses the same API for ordinary `Parameter` values and
`EquinoxParameter` modules. We record one loss value per ten-step optimizer chunk
to keep the notebook output and Python overhead small.
```{python}
#| echo: true
#| output: false
#| code-fold: true
#| code-summary: "Set up loss-history recording"
compiled_loss = eqx.filter_jit(loss)
initial_loss = float(compiled_loss(ude_config))
loss_steps = [0]
loss_values = [initial_loss]
def record_loss(step, diff, static, fitting_data, aux, loss_value, grads):
del fitting_data, aux, grads
loss_steps.append(int(step) + 1)
loss_values.append(float(loss_value))
return False, diff, static
```
```{python}
#| echo: true
#| output: false
optimizer = OptaxOptimizer(
loss,
optax.adam(learning_rate=3e-3),
callback=record_loss,
)
fitted_config, _ = optimizer.run(
ude_config,
max_steps=500,
chunk_size=10,
)
final_loss = float(compiled_loss(fitted_config))
loss_values[-1] = final_loss
fitted_solution = compiled_solve(fitted_config)
assert jnp.all(jnp.isfinite(fitted_solution.ys))
assert final_loss < initial_loss * 0.05
```
```{python}
#| label: fig-optimization
#| fig-cap: "**Training the shared correction.** Left: trajectory loss decreases as gradients pass through the complete network rollout. Right: after fitting, one shared MLP reproduces the voltage trajectories at all four coupled regions."
#| code-fold: true
#| code-summary: "Show plotting code"
fig, (ax_loss, ax_fit) = plt.subplots(1, 2, figsize=(8.1, 3.4))
ax_loss.semilogy(loss_steps, loss_values, color="black", marker="o", markersize=3)
ax_loss.set_xlabel("Optimizer step")
ax_loss.set_ylabel("Voltage trajectory MSE")
ax_loss.set_title(f"Loss: {initial_loss:.3f} → {final_loss:.4f}")
ax_loss.grid(alpha=0.25, which="both")
for node, color in enumerate(colors):
ax_fit.plot(
target_solution.ts,
target_solution.ys[:, 0, node],
"--",
color=color,
alpha=0.55,
linewidth=2.2,
)
ax_fit.plot(
fitted_solution.ts,
fitted_solution.ys[:, 0, node],
color=color,
linewidth=1.0,
label=REGION_LABELS[node],
)
ax_fit.set_xlabel("Time [a.u.]")
ax_fit.set_ylabel("V")
ax_fit.set_title("Dashed target, solid fitted")
ax_fit.grid(alpha=0.2)
ax_fit.legend(frameon=False, fontsize=7, ncol=2)
plt.tight_layout()
```
## What Did the MLP Learn?
The target residual depends only on voltage, although the MLP receives both
`V` and `W`. We evaluate it at every state visited by the teacher and compare it
with the known missing half-cubic term. We also differentiate the learned
correction with respect to `W` at those same states. This partial derivative
holds `V` fixed, so the true missing term has zero `W` sensitivity even though
`V` and `W` are correlated along a trajectory.
```{python}
#| echo: true
#| output: false
V_observed = target_solution.ys[:, 0, :].reshape(-1)
W_observed = target_solution.ys[:, 1, :].reshape(-1)
observed_features = jnp.stack((V_observed, W_observed), axis=-1)
learned_residual = jax.vmap(fitted_config.dynamics.correction)(
observed_features
)[:, 0]
true_residual = -(1.0 - fitted_config.dynamics.cubic_fraction) * V_observed**3 / 3.0
residual_correlation = np.corrcoef(
np.asarray(learned_residual), np.asarray(true_residual)
)[0, 1]
def correction_scalar(features):
return fitted_config.dynamics.correction(features)[0]
correction_gradient = jax.grad(correction_scalar)
w_sensitivity = jax.vmap(correction_gradient)(observed_features)[:, 1]
w_sensitivity_rms = jnp.sqrt(jnp.mean(w_sensitivity**2))
```
```{python}
#| label: fig-learned-residual
#| fig-cap: "**Learned missing dynamics and dependence on the slow state.** Left: each point evaluates the shared MLP at a state visited during training; color identifies the region that supplied the state, and the black curve is the known missing half-cubic term. Right: the local partial derivative of the correction with respect to $W$ at the same observed states. The true residual is independent of $W$, so its sensitivity is zero (black dashed line). Nonzero sensitivity indicates that the MLP uses correlated $W$ information to reproduce the trajectories. Both diagnostics apply only along the observed state manifold."
#| code-fold: true
#| code-summary: "Show plotting code"
fig, (ax_residual, ax_sensitivity) = plt.subplots(1, 2, figsize=(8.1, 3.5))
n_times = target_solution.ys.shape[0]
for node, color in enumerate(colors):
indices = np.arange(node, n_times * N_NODES, N_NODES)
ax_residual.scatter(
np.asarray(V_observed)[indices],
np.asarray(learned_residual)[indices],
s=9,
alpha=0.3,
color=color,
label=REGION_LABELS[node],
)
ax_sensitivity.scatter(
np.asarray(V_observed)[indices],
np.asarray(w_sensitivity)[indices],
s=9,
alpha=0.3,
color=color,
)
V_grid = jnp.linspace(V_observed.min(), V_observed.max(), 300)
true_curve = -(1.0 - fitted_config.dynamics.cubic_fraction) * V_grid**3 / 3.0
ax_residual.plot(
V_grid, true_curve, color="black", linewidth=2.2, label="true residual"
)
ax_residual.set_xlabel("V")
ax_residual.set_ylabel("Correction to $\\dot V$")
ax_residual.set_title(f"Residual correlation: r = {residual_correlation:.3f}")
ax_residual.grid(alpha=0.2)
ax_residual.legend(frameon=False, fontsize=8, ncol=2)
ax_sensitivity.axhline(
0.0, color="black", linestyle="--", linewidth=1.5, label="true sensitivity"
)
ax_sensitivity.set_xlabel("V")
ax_sensitivity.set_ylabel("$\\partial g_\\theta / \\partial W$")
ax_sensitivity.set_title(f"RMS $W$ sensitivity: {float(w_sensitivity_rms):.3f}")
ax_sensitivity.grid(alpha=0.2)
ax_sensitivity.legend(frameon=False, fontsize=8)
plt.tight_layout()
```
The nonzero sensitivity shows that the fitted MLP does use `W`, despite the true
missing term depending only on `V`. This is compatible with an accurate trajectory
fit because the observed states occupy a narrow, correlated part of the phase
plane. Recovering the intended mechanism would require stronger information, such
as restricting the correction's inputs, sampling more of the phase plane, or
regularizing unwanted state dependence.
That is a general diagnostic, not a quirk of this example. Whenever the true term
depends on fewer variables than the network is handed, the surplus dependence is
measurable, and it is the cheapest sign that a good trajectory fit is not the same
thing as a recovered mechanism. [Tutorial 2](neural_mass_ude.qmd) measures the same
thing a different way, as the width of a loop in the learned residual.
## Validation Initial Conditions
The fitted module has seen four related trajectories, not the entire phase
plane. We therefore rerun both models from new node states without changing the
MLP or graph, and replace the training key with a new shared noise key. This
checks interpolation to nearby trajectories and another random realization; it
is not a claim of global extrapolation.
```{python}
#| echo: true
#| output: false
validation_initial_state = jnp.array(
[
[-1.05, -0.45, 0.35, 0.95],
[-0.45, 0.05, 0.40, 0.70],
]
)
validation_teacher_config = teacher_config.copy()
validation_teacher_config.initial_state.dynamics = validation_initial_state
validation_fitted_config = fitted_config.copy()
validation_fitted_config.initial_state.dynamics = validation_initial_state
validation_noise_key = jax.random.key(29)
validation_teacher_config.noise.key = validation_noise_key
validation_fitted_config.noise.key = validation_noise_key
validation_target = teacher_solve(validation_teacher_config)
validation_prediction = compiled_solve(validation_fitted_config)
validation_mse = jnp.mean(
(validation_prediction.ys[:, 0, :] - validation_target.ys[:, 0, :]) ** 2
)
assert jnp.isfinite(validation_mse)
assert validation_mse < initial_loss * 0.1
```
```{python}
#| label: fig-held-out
#| fig-cap: "**Validation stochastic rollout.** The fitted shared correction is reused without retraining from four unseen initial states and an unseen noise realization. Teacher and UDE receive the same random forcing. Dashed lines show the complete teacher and solid lines show the UDE prediction."
#| code-fold: true
#| code-summary: "Show plotting code"
fig, ax = plt.subplots(figsize=(8.1, 3.4))
for node, color in enumerate(colors):
ax.plot(
validation_target.ts,
validation_target.ys[:, 0, node],
"--",
color=color,
alpha=0.6,
linewidth=2.1,
)
ax.plot(
validation_prediction.ts,
validation_prediction.ys[:, 0, node],
color=color,
linewidth=1.0,
label=REGION_LABELS[node],
)
ax.set_xlabel("Time [a.u.]")
ax.set_ylabel("V")
ax.set_title(f"Validation voltage MSE = {float(validation_mse):.4f}")
ax.grid(alpha=0.2)
ax.legend(frameon=False, fontsize=8, ncol=4)
plt.tight_layout()
```
## Explore Network Capacity and Cost
The width of 16 used above is a deliberately modest default, not a previously
optimized hyperparameter. We can make that choice more systematic by holding
the depth, optimizer, step count, training trajectory, and validation trajectory
fixed while varying only the hidden width.
Here we use a simple tolerance rule: choose the smallest network whose
validation MSE is within 20% of the best value in the sweep. Unlike selecting
the absolute minimum, this favors a smaller model when additional capacity
provides only a marginal improvement.
```{python}
#| echo: true
#| output: false
WIDTHS = (2, 4, 8, 16, 32)
SWEEP_DEPTH = 2
SWEEP_STEPS = 500
KNEE_TOLERANCE = 0.20
```
These are the user-facing sweep controls. The folded implementation below fits
one otherwise identical model per width, records first-fit time, and applies the
tolerance rule.
```{python}
#| echo: true
#| output: false
#| code-fold: true
#| code-summary: "Run the capacity benchmark"
def parameter_count(module):
arrays = jax.tree.leaves(eqx.filter(module, eqx.is_inexact_array))
return sum(array.size for array in arrays)
def block_until_ready(tree):
for leaf in jax.tree.leaves(tree):
if hasattr(leaf, "block_until_ready"):
leaf.block_until_ready()
capacity_results = []
for width in WIDTHS:
module_key = jax.random.fold_in(jax.random.key(101), width)
candidate_correction = EquinoxParameter(
make_correction_module(
module_key,
in_size=2,
out_size=1,
width_size=width,
depth=SWEEP_DEPTH,
)
)
candidate_solve, candidate_config = prepare_network(
UDEFitzHughNagumo(correction=candidate_correction)
)
def candidate_loss(config):
predicted_V = candidate_solve(config).ys[:, 0, :]
return jnp.mean((predicted_V - target_V) ** 2)
candidate_optimizer = OptaxOptimizer(
candidate_loss,
optax.adam(learning_rate=3e-3),
)
start = time.perf_counter()
candidate_fitted, _ = candidate_optimizer.run(
candidate_config,
max_steps=SWEEP_STEPS,
chunk_size=50,
)
block_until_ready(candidate_fitted)
fit_seconds = time.perf_counter() - start
candidate_train_mse = float(candidate_loss(candidate_fitted))
candidate_validation_config = candidate_fitted.copy()
candidate_validation_config.initial_state.dynamics = validation_initial_state
candidate_validation_config.noise.key = validation_noise_key
candidate_validation = candidate_solve(candidate_validation_config)
candidate_validation_mse = float(
jnp.mean(
(
candidate_validation.ys[:, 0, :]
- validation_target.ys[:, 0, :]
)
** 2
)
)
capacity_results.append(
{
"width": width,
"parameters": parameter_count(candidate_correction.module),
"train_mse": candidate_train_mse,
"validation_mse": candidate_validation_mse,
"fit_seconds": fit_seconds,
}
)
assert all(
np.isfinite(result[key])
for result in capacity_results
for key in ("train_mse", "validation_mse", "fit_seconds")
)
best_validation_mse = min(
result["validation_mse"] for result in capacity_results
)
knee_threshold = (1.0 + KNEE_TOLERANCE) * best_validation_mse
selected_capacity = next(
result
for result in capacity_results
if result["validation_mse"] <= knee_threshold
)
```
```{python}
#| label: fig-capacity-sweep
#| fig-cap: "**Capacity and computational cost.** Left: training and validation errors after 500 optimizer steps. The selected point is the smallest model within 20% of the best validation MSE. Right: first-fit wall time, including JAX compilation, on the machine that rendered this page. Width labels appear beside the validation points."
#| code-fold: true
#| code-summary: "Show plotting code"
parameter_counts = np.array(
[result["parameters"] for result in capacity_results]
)
train_errors = np.array(
[result["train_mse"] for result in capacity_results]
)
validation_errors = np.array(
[result["validation_mse"] for result in capacity_results]
)
fit_times = np.array(
[result["fit_seconds"] for result in capacity_results]
)
fig, (ax_error, ax_time) = plt.subplots(1, 2, figsize=(8.1, 3.4))
ax_error.loglog(
parameter_counts,
train_errors,
"o-",
color="0.45",
label="training",
)
ax_error.loglog(
parameter_counts,
validation_errors,
"o-",
color="#1f5a99",
label="validation",
)
ax_error.axhline(
knee_threshold,
color="#1f5a99",
linestyle=":",
alpha=0.7,
label="20% tolerance",
)
ax_error.scatter(
selected_capacity["parameters"],
selected_capacity["validation_mse"],
s=100,
facecolors="none",
edgecolors="#c44e52",
linewidths=2,
zorder=4,
label=f"selected: width {selected_capacity['width']}",
)
for result in capacity_results:
ax_error.annotate(
f"w={result['width']}",
(result["parameters"], result["validation_mse"]),
xytext=(4, 4),
textcoords="offset points",
fontsize=7,
)
ax_error.set_xlabel("Trainable parameters")
ax_error.set_ylabel("Voltage trajectory MSE")
ax_error.set_title("Accuracy–capacity trade-off")
ax_error.grid(alpha=0.2, which="both")
ax_error.legend(frameon=False, fontsize=7)
ax_time.plot(parameter_counts, fit_times, "o-", color="#c44e52")
for result in capacity_results:
ax_time.annotate(
f"w={result['width']}",
(result["parameters"], result["fit_seconds"]),
xytext=(4, 4),
textcoords="offset points",
fontsize=7,
)
ax_time.set_xscale("log")
ax_time.set_xlabel("Trainable parameters")
ax_time.set_ylabel("First-fit wall time [s]")
ax_time.set_title(f"500 steps on {jax.devices()[0].device_kind}")
ax_time.grid(alpha=0.2, which="both")
plt.tight_layout()
```
In this run the rule selects width 16, with 337 trainable parameters. Increasing
the width to 32 raises the count to 1,185 and takes longer, but does not improve
validation error. That is the kind of diminishing-return knee the sweep is
designed to expose.
The timing includes tracing and compilation, so it describes the complete first
fit a notebook user experiences rather than steady-state kernel throughput. It
is hardware- and software-dependent and should not be treated as a portable
benchmark. Compilation caches and system load can also make small timing
differences non-monotonic.
This single sweep gives an objective rule for this example, but not uncertainty
on the architecture choice. For a scientific analysis, repeat the comparison
across initialization and noise seeds, then evaluate the selected architecture
once on a final test set that was not used for fitting or model selection.
## Train One Shared Correction Across Signal Segments
The fit above learns from one 15-unit trajectory. A longer recording exposes the
same correction to more of the phase plane, but differentiating one continuous
rollout also lengthens the gradient path. Here we instead construct consecutive
fixed-length teacher segments and average their losses with `jax.vmap`. Every
segment evaluates the same capacity-selected `EquinoxParameter`, so their
gradients contribute to one shared update. We use shorter five-unit segments to
make the state-coverage difference between one and four segments explicit.
```{python}
#| echo: true
#| output: false
N_TRAIN_SEGMENTS = 4
N_HELD_OUT_SEGMENTS = 2
SEGMENT_T1 = 5.0
SEGMENT_FIT_STEPS = 2_000
SELECTED_WIDTH = selected_capacity["width"]
```
```{python}
#| echo: true
#| output: false
#| code-fold: true
#| code-summary: "Generate consecutive training and held-out segments"
def make_segment_dataset(start_state, n_segments, seed):
segment_keys = jax.random.split(jax.random.key(seed), n_segments)
segment_initial_states = []
segment_targets = []
current_state = start_state
for segment_key in segment_keys:
local_config = segment_teacher_config.copy()
local_config.initial_state.dynamics = current_state
local_config.noise.key = segment_key
segment_solution = segment_teacher_solve(local_config)
segment_initial_states.append(current_state)
segment_targets.append(segment_solution.ys)
current_state = segment_solution.ys[-1]
return {
"initial_state": jnp.stack(segment_initial_states),
"target": jnp.stack(segment_targets),
"noise_key": jnp.stack(segment_keys),
}
segment_teacher_solve, segment_teacher_config = prepare_network(
TeacherFitzHughNagumo(), t1=SEGMENT_T1
)
all_signal_segments = make_segment_dataset(
initial_state,
N_TRAIN_SEGMENTS + N_HELD_OUT_SEGMENTS,
seed=41,
)
training_segments = jax.tree.map(
lambda value: value[:N_TRAIN_SEGMENTS], all_signal_segments
)
held_out_segments = jax.tree.map(
lambda value: value[N_TRAIN_SEGMENTS:], all_signal_segments
)
```
The complete synthetic state is known at every segment boundary. With partially
observed recordings, those initial states would need to be estimated, encoded,
or preceded by a burn-in; segmenting must not silently invent latent states.
```{python}
#| echo: true
#| output: false
#| code-fold: true
#| code-summary: "Define the vectorized multi-segment loss"
def segment_batch_loss(config, dataset):
def one_segment(initial_state, target, noise_key):
local_config = eqx.tree_at(
lambda c: (c.initial_state.dynamics, c.noise.key),
config,
(initial_state, noise_key),
)
prediction = segment_solve(local_config).ys[:, 0, :]
return jnp.mean((prediction - target[:, 0, :]) ** 2)
losses = jax.vmap(one_segment)(
dataset["initial_state"],
dataset["target"],
dataset["noise_key"],
)
return jnp.mean(losses)
single_training_segment = jax.tree.map(
lambda value: value[:1], training_segments
)
def single_segment_loss(config):
return segment_batch_loss(config, single_training_segment)
def multi_segment_loss(config):
return segment_batch_loss(config, training_segments)
def held_out_segment_loss(config):
return segment_batch_loss(config, held_out_segments)
```
Both fits start from identical MLP weights and use the same optimizer settings.
Only the number of training segments differs.
```{python}
#| echo: true
#| output: false
segment_module_key = jax.random.key(151)
single_correction = EquinoxParameter(
make_correction_module(
segment_module_key, in_size=2, out_size=1, width_size=SELECTED_WIDTH
)
)
multi_correction = EquinoxParameter(
make_correction_module(
segment_module_key, in_size=2, out_size=1, width_size=SELECTED_WIDTH
)
)
segment_solve, single_segment_config = prepare_network(
UDEFitzHughNagumo(correction=single_correction), t1=SEGMENT_T1
)
_, multi_segment_config = prepare_network(
UDEFitzHughNagumo(correction=multi_correction), t1=SEGMENT_T1
)
single_segment_fitted, _ = OptaxOptimizer(
single_segment_loss, optax.adam(3e-3)
).run(single_segment_config, max_steps=SEGMENT_FIT_STEPS, chunk_size=50)
multi_segment_fitted, _ = OptaxOptimizer(
multi_segment_loss, optax.adam(3e-3)
).run(multi_segment_config, max_steps=SEGMENT_FIT_STEPS, chunk_size=50)
```
```{python}
#| label: fig-segment-training
#| fig-cap: "**One shared correction trained across signal segments.** Left: learned corrections evaluated along the complete segmented signal and averaged within voltage bins across times and nodes; the black curve is the true residual. This uses the same voltage range as @fig-learned-residual, whereas the temporal holdout alone occupies only its negative-voltage branch. Right: marker shape distinguishes training from held-out trajectory error, while color consistently identifies the one- and four-segment fits; the lower panel shows their absolute train--validation gaps. Both models use the capacity-selected architecture, identical initial weights, and 2,000 updates. The single-segment model sees 5 time units; the multi-segment model averages the loss over four consecutive segments (20 time units) at every update. Both are evaluated on the final two segments of the same longer signal, which were not used for fitting."
#| code-fold: true
#| code-summary: "Evaluate and plot segment generalization"
segment_train_mse = np.array(
[
float(single_segment_loss(single_segment_fitted)),
float(multi_segment_loss(multi_segment_fitted)),
]
)
segment_validation_mse = np.array(
[
float(held_out_segment_loss(single_segment_fitted)),
float(held_out_segment_loss(multi_segment_fitted)),
]
)
segment_gap = np.abs(segment_validation_mse - segment_train_mse)
assert np.all(np.isfinite(segment_train_mse))
assert np.all(np.isfinite(segment_validation_mse))
assert segment_validation_mse[1] < segment_validation_mse[0]
assert segment_gap[1] < segment_gap[0]
comparison_V = all_signal_segments["target"][:, :, 0, :].reshape(-1)
comparison_W = all_signal_segments["target"][:, :, 1, :].reshape(-1)
comparison_features = jnp.stack((comparison_V, comparison_W), axis=-1)
single_comparison_residual = jax.vmap(single_segment_fitted.dynamics.correction)(
comparison_features
)[:, 0]
multi_comparison_residual = jax.vmap(multi_segment_fitted.dynamics.correction)(
comparison_features
)[:, 0]
residual_bin_edges = np.linspace(
float(V_observed.min()), float(V_observed.max()), 31
)
residual_bin_centers = 0.5 * (residual_bin_edges[:-1] + residual_bin_edges[1:])
residual_bin_index = np.clip(
np.digitize(np.asarray(comparison_V), residual_bin_edges) - 1,
0,
len(residual_bin_centers) - 1,
)
def average_in_voltage_bins(values):
values = np.asarray(values)
return np.array(
[
values[residual_bin_index == index].mean()
if np.any(residual_bin_index == index)
else np.nan
for index in range(len(residual_bin_centers))
]
)
single_residual_curve = average_in_voltage_bins(single_comparison_residual)
multi_residual_curve = average_in_voltage_bins(multi_comparison_residual)
true_residual_grid = (
-(1.0 - fitted_config.dynamics.cubic_fraction)
* residual_bin_centers**3
/ 3.0
)
labels = ("one segment", "four segments")
model_colors = ("#c44e52", "#1f5a99")
x = np.arange(len(labels))
fig = plt.figure(figsize=(8.1, 4.4))
grid = fig.add_gridspec(2, 2, width_ratios=(1.2, 1.0))
ax_residual = fig.add_subplot(grid[:, 0])
ax_error = fig.add_subplot(grid[0, 1])
ax_gap = fig.add_subplot(grid[1, 1])
ax_residual.plot(
residual_bin_centers,
true_residual_grid,
color="black",
linewidth=2.2,
label="true residual",
)
ax_residual.plot(
residual_bin_centers,
single_residual_curve,
color=model_colors[0],
linewidth=1.8,
label="one segment",
)
ax_residual.plot(
residual_bin_centers,
multi_residual_curve,
color=model_colors[1],
linewidth=1.8,
label="four segments",
)
ax_residual.set_xlabel("V")
ax_residual.set_ylabel("Correction to $\\dot V$")
ax_residual.set_title("Residual recovery over the full signal")
ax_residual.grid(alpha=0.2)
ax_residual.legend(frameon=False, fontsize=8)
for index, color in enumerate(model_colors):
ax_error.scatter(
x[index], segment_train_mse[index],
s=52, marker="o", color=color,
label="training" if index == 0 else None,
zorder=3,
)
ax_error.scatter(
x[index], segment_validation_mse[index],
s=58, marker="X", color=color,
label="held out" if index == 0 else None,
zorder=3,
)
ax_error.plot(
[x[index], x[index]],
[segment_train_mse[index], segment_validation_mse[index]],
color=color, alpha=0.35, linewidth=1.2,
)
ax_error.set_yscale("log")
ax_error.set_xticks(x, labels)
ax_error.set_xlim(-0.45, 1.45)
ax_error.set_ylabel("Voltage trajectory MSE")
ax_error.set_title("Trajectory error")
ax_error.grid(alpha=0.2, axis="y", which="both")
ax_error.legend(frameon=False, fontsize=8)
ax_gap.plot(x, segment_gap, color="0.7", linewidth=1.2, zorder=1)
ax_gap.scatter(x, segment_gap, color=model_colors, s=58, zorder=2)
ax_gap.set_xticks(x, labels)
ax_gap.set_xlim(-0.45, 1.45)
ax_gap.set_ylabel("Absolute gap")
ax_gap.set_title("Generalization gap")
ax_gap.set_yscale("log")
ax_gap.grid(alpha=0.2, axis="y", which="both")
plt.tight_layout()
```
The one-segment model nearly interpolates its short training window but fails on
the later signal. Training across four segments reduces the held-out error and
the train--validation gap by roughly three orders of magnitude. The improvement
comes from exposing the shared correction to more of the trajectory, not from
stochastic mini-batch regularization: every update uses all four segments.
Segment boundaries also stop state gradients, so this full-batch
multiple-shooting objective is distinct from both one continuous long rollout
and the solver's `grad_horizon` option.
::: {.callout-warning}
## Interpretation and identifiability
Trajectory agreement does not prove that the MLP discovered a unique physical law.
A flexible correction can compensate for errors in fixed parameters, coupling,
observations, or initial conditions, all of which are held at their known synthetic
values here precisely so that the residual has an interpretable target.
[Tutorial 2](neural_mass_ude.qmd) treats this as the central question rather than a
closing caveat: it works in a case where the missing term is known in closed form,
and sets out what to measure, what to hold out, and which comparisons to run before
crediting a network with a discovery.
:::
::: {.callout-note}
## From this controlled example to noisy observations
Common random numbers isolate model error in this synthetic experiment because
the latent forcing is known. With experimental observations, that forcing is
usually unknown. A practical loss may then average over several simulated noise
realizations or compare robust summaries rather than matching one trajectory
point by point.
:::
## Summary
This tutorial demonstrated the complete UDE workflow in TVB-Optim:
1. A mechanistic network model supplied stable, interpretable dynamics and
explicit inter-region coupling.
2. One `EquinoxParameter` marked a shared MLP as trainable inside
`config.dynamics`.
3. `eqx.filter_jit` compiled a full configuration containing callable Equinox
leaves.
4. Gradients propagated through the network solver to every MLP array leaf.
5. `OptaxOptimizer` fitted the correction without a special neural-network
optimization path.
6. Multiple nodes jointly trained the same local correction under fixed random
forcing, which was then evaluated on validation initial conditions and a new
noise realization.
7. A capacity sweep compared validation error, parameter count, and indicative
first-fit runtime instead of assuming that a larger MLP is better.
8. A vectorized multi-segment loss aggregated gradients from a longer signal
into one shared correction and substantially improved temporal validation.
The essential modeling choice is separation of responsibilities: known coupling and
slow recovery dynamics remain mechanistic, while the MLP learns only the local
voltage residual placed explicitly in the equations.
Where those choices themselves come from, and how to tell a useful correction from
a merely well-fitted one, is the subject of
[Universal Differential Equations 2](neural_mass_ude.qmd).
## References
- Rackauckas, C., Ma, Y., Martensen, J., et al. (2020). [Universal Differential
Equations for Scientific Machine Learning](https://doi.org/10.48550/arXiv.2001.04385).
*arXiv:2001.04385*.
- El-Gazzar, A., and van Gerven, M. (2025). [Universal differential equations as
a unifying modeling language for
neuroscience](https://doi.org/10.3389/fncom.2025.1677930). *Frontiers in
Computational Neuroscience*, 19, 1677930.
- Chen, R. T. Q., Rubanova, Y., Bettencourt, J., and Duvenaud, D. K. (2018).
[Neural Ordinary Differential
Equations](https://proceedings.neurips.cc/paper/2018/hash/69386f6bb1dfed68692a24c8686939b9-Abstract.html).
*Advances in Neural Information Processing Systems*, 31.
- FitzHugh, R. (1961). [Impulses and physiological states in theoretical models
of nerve membrane](https://doi.org/10.1016/S0006-3495(61)86902-6).
*Biophysical Journal*, 1(6), 445--466.
- Nagumo, J., Arimoto, S., and Yoshizawa, S. (1962). [An active pulse
transmission line simulating nerve
axon](https://doi.org/10.1109/JRPROC.1962.288235). *Proceedings of the IRE*,
50(10), 2061--2070.