Truncated Backpropagation for Chaotic Brain Networks

Why long-rollout gradients break, and how the grad_horizon knob fixes them

Try this notebook interactively:

Download .ipynb Download .qmd Open in Colab

Introduction

Fitting resting-state functional connectivity (FC) needs long simulations: FC is a long-time covariance, so a stable estimate requires a long forward rollout. Differentiating through that rollout is an RNN backward pass, and its sensitivity grows like \(\exp(T\,\lambda_{\max})\) over a window of length \(T\), where \(\lambda_{\max}\) is the largest Lyapunov exponent of the dynamics. While the network sits at a fixed point or a limit cycle (\(\lambda_{\max} \le 0\)) this is harmless. Once a control parameter pushes the network across a bifurcation into chaos (\(\lambda_{\max} > 0\)), the same backward pass amplifies without bound and the gradient becomes numerically useless.

This notebook makes that failure concrete on a Jansen-Rit whole-brain model, where the global coupling \(G\) drives a bifurcation into chaos. On the stable side the exact autodiff (AD) gradient of an FC loss with respect to \(G\) is correct, checked against a finite-difference ground truth and the measured Lyapunov exponent; past the bifurcation it flips sign and blows up. The fix is truncated backpropagation through time (TBPTT), a single solver knob grad_horizon that keeps the long forward rollout but pulls the gradient back only through a fixed window. With it, optimizing \(G\) to fit empirical FC stays on track where the full gradient does not.

Environment setup and imports
import os

# Mock host devices so the G-sweep can shard across pmap on CPU. Keep this modest:
# each device runs a heavy reverse-mode AD sim and oversubscribing crashes the kernel.
N_DEVICES = 4
os.environ.setdefault(
    "XLA_FLAGS", f"--xla_force_host_platform_device_count={N_DEVICES}"
)

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
import jax
import jax.numpy as jnp
import equinox as eqx
import optax

# 64-bit precision for reliable gradients and Lyapunov exponents
jax.config.update("jax_enable_x64", True)

from tvboptim.utils import set_cache_path, cache
from tvboptim.data import (
    load_structural_connectivity,
    load_functional_connectivity,
)
from tvboptim.execution import ParallelExecution
from tvboptim.types import GridAxis, Space, Parameter
from tvboptim.experimental.network_dynamics import Network, prepare
from tvboptim.experimental.network_dynamics.dynamics.tvb import JansenRit
from tvboptim.experimental.network_dynamics.coupling import SigmoidalJansenRit
from tvboptim.experimental.network_dynamics.graph import DenseGraph
from tvboptim.experimental.network_dynamics.noise import AdditiveNoise
from tvboptim.experimental.network_dynamics.solvers import Heun
from tvboptim.experimental.network_dynamics.analysis.lyapunov import (
    _lyapunov_spectrum_jvp,
)
from tvboptim.experimental.network_dynamics.analysis import adiabatic_scan
from tvboptim.observations.observation import compute_fc, fc_corr, rmse
from tvboptim.observations.tvb_monitors.bold import HRFBold
from tvboptim.optim.optax import OptaxOptimizer
from tvboptim.optim.callbacks import (
    DefaultPrintCallback,
    MultiCallback,
    SavingCallback,
)

set_cache_path("./tbptt")

# Semantic palette (self-contained, no external style file).
INK = "#003754"     # text / reference lines
ACCENT = "#0a5170"  # accurate gradient / differentiated windows
WARN = "#AF1821"    # wrong or unstable gradient / stop-gradient cuts
SHIP = "#3E8E68"    # TBPTT run
ROAD = "#6b7280"    # boundaries, guides
WIN_FILL = "#dce4f0"

The Jansen-Rit Whole-Brain Model

We use a whole-brain Jansen-Rit network on Desikan-Killiany structural connectivity, with instantaneous sigmoidal coupling scaled by the global coupling \(G\) and additive noise on the \(y_4\) population. Sweeping \(G\) takes the network from a quiescent fixed point, through synchronized oscillation, into chaos, which is exactly the regime where long-rollout gradients become dangerous. The empirical FC is the optimization target.

Model, connectivity, and solver setup
# Model constants (match the chaos regime used on the poster).
A, MU, SIGMA, DT = 0.1, 0.08, 0.0316, 1.0
G_LOW, G_HIGH, N_G = 0.0, 40.0, 80          # G sweep grid (divisible by N_DEVICES)
G_RANGE = np.linspace(G_LOW, G_HIGH, N_G)

WARMUP_T = 20_000.0   # settle out the transient before any measurement (ms)
FC_T1 = 60_000.0      # long BOLD/FC rollout (ms): the gradient horizon at stake
BOLD_PERIOD = 720.0   # BOLD sampling period (ms)
BOLD_SKIP = 25        # BOLD samples dropped before FC
LE_SEGMENT_T = 1_000.0  # Lyapunov segment length (ms)
LE_N = 5                # Lyapunov rescaling steps

assert N_G % N_DEVICES == 0, "N_G must be divisible by N_DEVICES for the pmap sweep"


def build_network(weights, region_labels, G=8.0):
    """Jansen-Rit whole-brain network with instantaneous sigmoidal coupling."""
    return Network(
        dynamics=JansenRit(a=A, mu=MU),
        coupling={"instant": SigmoidalJansenRit(incoming_states=("y1", "y2"), G=G)},
        graph=DenseGraph(weights, region_labels=region_labels),
        noise=AdditiveNoise(sigma=SIGMA, apply_to="y4"),
    )


coupling_key = "instant"
G_lens = lambda c: c.coupling[coupling_key].G  # noqa: E731

weights, lengths, region_labels = load_structural_connectivity("dk_average")
weights = weights / jnp.max(weights)
fc_target = load_functional_connectivity("dk_average")

network = build_network(weights, region_labels)

# Warm up to a settled state; the FC sim's BOLD monitor reuses it as history.
warm_solve, warm_cfg = prepare(network, Heun(), t1=WARMUP_T, dt=DT)
warm_res = jax.block_until_ready(jax.jit(warm_solve)(warm_cfg))
network.update_history(warm_res)

# Two prepared solve functions: the long FC/BOLD rollout, and a short Lyapunov
# segment used to locate the chaos onset.
solve_fc, config_fc = prepare(network, Heun(), t1=FC_T1, dt=DT)
solve_le, config_le = prepare(network, Heun(), t1=LE_SEGMENT_T, dt=DT)
bold = HRFBold(period=BOLD_PERIOD, voi=0, history=warm_res)

The Problem: the Exact Gradient Breaks at the Bifurcation

The loss is the RMSE between the simulated FC and the empirical target. We sweep \(G\) and, at each value, compute the exact reverse-mode AD gradient \(dL/dG\), a finite-difference (FD) ground truth on the same loss, and the top Lyapunov exponent \(\lambda_{\max}\) that marks the chaos onset.

The FD control is the honest reference here. A single secant is butterfly dominated in the chaotic band, so we use a central difference of the seed-averaged loss: at each \(G\) we run several forward sims at \(G \pm \delta\) that share one noise key per seed (common random numbers), average, then form the central difference. Forward sims carry no autodiff tape, so this stays affordable even though the reverse-mode AD leg is expensive.

FC-RMSE loss and per-G probe (AD gradient, FD ground truth, Lyapunov)
FD_DELTA = 0.3        # finite central-difference step in G
N_FD_SEEDS = 8        # seeds averaged for the FD ground truth
FD_SEED_BASE = 0
fd_keys = jax.random.split(jax.random.key(FD_SEED_BASE), N_FD_SEEDS)


def fc_rmse_loss(cfg):
    """FC-RMSE loss for a config: long BOLD rollout -> FC -> RMSE vs target."""
    fc = compute_fc(bold(solve_fc(cfg)), skip_t=BOLD_SKIP)
    return rmse(fc, fc_target)


def run_motivation(cfg):
    """Per-G probe: [loss, AD grad, seed-averaged FD grad, FD sem, lambda_max]."""
    G0 = G_lens(cfg)

    def f(g):  # single-seed loss at the config's own noise key (AD value + grad)
        return fc_rmse_loss(eqx.tree_at(G_lens, cfg, g))

    val, grad_ad = jax.value_and_grad(f)(G0)

    def central_fd(key):  # common random numbers across G +/- delta
        c = eqx.tree_at(lambda c: c.noise.key, cfg, key)
        fp = fc_rmse_loss(eqx.tree_at(G_lens, c, G0 + FD_DELTA))
        fm = fc_rmse_loss(eqx.tree_at(G_lens, c, G0 - FD_DELTA))
        return (fp - fm) / (2.0 * FD_DELTA)

    fd_seeds = jax.lax.map(central_fd, fd_keys)  # sequential over seeds
    fd_mean = jnp.mean(fd_seeds)
    fd_sem = jnp.std(fd_seeds) / jnp.sqrt(N_FD_SEEDS)

    # Top Lyapunov exponent on a short segment at this G. The public
    # `lyapunov_spectrum(network, Heun(), t, n, k=1)` is the single-network
    # equivalent; here we reuse the prepared segment solver so it vmaps cleanly.
    cfg_le = eqx.tree_at(G_lens, config_le, jnp.asarray(G0))
    lam = _lyapunov_spectrum_jvp(solve_le, cfg_le, t=LE_SEGMENT_T, n=LE_N, k=1)[0]

    return jnp.stack([val, grad_ad, fd_mean, fd_sem, lam])


@cache("motivation")
def compute_motivation():
    _, sweep_cfg = prepare(network, Heun(), t1=FC_T1, dt=DT)
    sweep_cfg.coupling[coupling_key].G = GridAxis(G_LOW, G_HIGH, N_G)
    space = Space(sweep_cfg, mode="product")
    results = ParallelExecution(run_motivation, space, n_pmap=N_DEVICES).run()
    s = np.array([np.asarray(results[i]) for i in range(len(results))])
    return {
        "g": np.asarray(G_RANGE),
        "loss": s[:, 0],      # FC-RMSE(G): also the optimization profile
        "grad_ad": s[:, 1],
        "grad_fd": s[:, 2],
        "fd_sem": s[:, 3],
        "lam": s[:, 4],
    }


mot = compute_motivation()


def g_crit_from(lam, g):
    """First G where lambda_max crosses zero (linear interp), or None."""
    pos = np.where(lam > 0)[0]
    if pos.size == 0 or pos[0] == 0:
        return None
    i = pos[0]
    return float(np.interp(0.0, [lam[i - 1], lam[i]], [g[i - 1], g[i]]))


G_CRIT = g_crit_from(mot["lam"], mot["g"])

To see what the network does across this sweep, we trace the bifurcation with an adiabatic scan: ramp \(G\) up and back down, carrying the settled state between values, and record the oscillation envelope of the pyramidal PSP (\(y_1 - y_2\), the Jansen-Rit EEG observable) at each \(G\). This reads the same transition the Lyapunov exponent flags straight off the dynamics, and scanning both directions exposes any hysteresis at the onset.

Adiabatic G-scan (bifurcation diagram)
# Adiabatic G-scan: ramp G up then back down, carrying the settled state, so each
# step starts from the previous one's attractor (the slow, "adiabatic" ramp). At
# every G we record the oscillation envelope of the pyramidal PSP y1 - y2 -- the
# canonical Jansen-Rit EEG observable, the argument of the output sigmoid -- as
# each node's temporal min/max averaged across the network, plus its mean. The
# envelope is flat at the fixed point, opens at the limit cycle, and broadens in
# the chaotic band.
N_ADIA = 60          # G values per branch (up, then down)
ADIA_T = 4_000.0     # simulation length per G step (ms)
ADIA_SKIP = 2_000.0  # transient dropped before the envelope (ms)


def _psp(result):  # pyramidal post-synaptic potential y1 - y2 -> [n_time, n_nodes]
    names = result.variable_names
    return result.ys[:, names.index("y1"), :] - result.ys[:, names.index("y2"), :]


def _env_low(a):   # mean across nodes of each node's temporal trough
    return a.min(axis=0).mean()


def _env_high(a):  # mean across nodes of each node's temporal peak
    return a.max(axis=0).mean()


@cache("adiabatic")
def compute_adiabatic():
    res = adiabatic_scan(
        network,
        Heun(),
        accessor=G_lens,
        low=G_LOW,
        high=G_HIGH,
        n=N_ADIA,
        t=ADIA_T,
        skip=ADIA_SKIP,
        dt=DT,
        bothways=True,
        observe=_psp,
        statistics={"mean": lambda a: a.mean(), "lo": _env_low, "hi": _env_high},
    )
    p, n_up, s = np.asarray(res.p), res.n_up, res.stats
    up, dn = slice(0, n_up), slice(n_up, None)
    return {
        "g_up": p[up], "g_dn": p[dn],
        "mean_up": np.asarray(s["mean"])[up], "mean_dn": np.asarray(s["mean"])[dn],
        "lo_up": np.asarray(s["lo"])[up], "lo_dn": np.asarray(s["lo"])[dn],
        "hi_up": np.asarray(s["hi"])[up], "hi_dn": np.asarray(s["hi"])[dn],
    }


adia = compute_adiabatic()
/tmp/ipykernel_5609/2956136089.py:107: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  plt.tight_layout()
Figure 1: The exact FC-loss gradient breaks at the chaos onset. All three panels share the global-coupling axis \(G\), with the chaotic band (\(\lambda_{\max}>0\)) shaded red. Top: the adiabatic bifurcation diagram. The shaded envelope is the oscillation amplitude of the pyramidal PSP \(y_1 - y_2\) (the Jansen-Rit EEG observable; per-node temporal min/max averaged across the network) and the dotted line its mean, swept up (\(G\!\uparrow\), solid) and back down (\(G\!\downarrow\), dashed); the envelope is flat at the fixed point, opens at the limit cycle, and broadens into the chaotic band, with the up/down branches revealing any hysteresis. Middle: the FC-RMSE loss \(L(G)\) (blue, left axis) and the top Lyapunov exponent \(\lambda_{\max}\) (red, right axis), which crosses zero at the bifurcation into chaos. The loss optimum (blue dot) sits just below that boundary. Bottom: the gradient \(dL/dG\). The seed-averaged finite-difference gradient (line + band) is the ground truth; the autodiff gradient (markers) is colored by how it compares: accurate (within 1%), correct direction, wrong sign, or numerically unstable (NaN/Inf). In the fixed-point and limit-cycle regime the AD gradient matches FD; once \(G\) crosses the \(\lambda_{\max}=0\) boundary it flips sign and blows up, while the finite-difference reference stays bounded. This is the failure truncation is built to avoid.

The Fix: Truncating the Gradient Horizon

Truncated backpropagation through time keeps the long forward rollout but pulls the gradient back only through a fixed-length window. In tvboptim it is a single solver knob, grad_horizon, on the native solvers.

The knob is easy to mis-model. The whole n_steps rollout runs as one continuous forward trajectory, then is tiled into windows of \(W =\) grad_horizon steps. The loss depends on every window, so every window is differentiated; what truncation does is sever the carried state gradient at each window boundary with stop_gradient, so credit cannot flow across a boundary and the \(\exp(T\,\lambda_{\max})\) blow-up is cut off at \(W\). The parameter gradient survives the severing because parameters like \(G\) are closed over by the scan body, not threaded through the state carry. The total is the sum of each window’s local contribution:

\[ \frac{dL}{dG} \;\approx\; \sum_{k=0}^{n_\text{windows}-1} \left.\frac{\partial L_k}{\partial G}\right|_{s_k\ \text{detached}} . \]

The window must be shorter than the gradient’s memory horizon \(\tau_\lambda \sim 1/\lambda_{\max}\) to tame the chaotic amplification, but long enough to keep the slow structure the loss actually depends on.

Figure 2: How grad_horizon tiles the rollout. One continuous forward pass (top strip, color = time) is split into windows of \(W\) steps. Every window is differentiated and contributes its local \(\partial L_k/\partial G\) (blue), but the carried state gradient is cut at every boundary (red dashed). The shared parameter \(G\) is closed over by all windows, so its gradient is the sum of the per-window contributions. The window length is kept below the chaotic memory horizon, \(W < \tau_\lambda\).

The Payoff: Optimizing \(G\) With and Without Truncation

Now we fit \(G\) to the empirical FC by minimizing the FC-RMSE, starting from a sub-critical \(G\) and taking the same optimizer and learning rate in two runs that differ only in how the gradient is obtained:

  • Full AD runs forward-mode autodiff through the untruncated solver. For a single scalar parameter, forward-mode AD equals full reverse-mode BPTT (same \(dL/dG\)), so this trajectory is the exact, untruncated gradient.
  • TBPTT replaces the backward pass with a windowed solver Heun(grad_horizon=W), assigning credit only over the last \(W\) steps.
Optimization setup: loss, callbacks, and the two runs
G_START = 5.0       # sub-critical start
LR = 1.0
MAX_STEPS = 100
PRINT_EVERY = 25
W_TBPTT = 100       # gradient horizon for the truncated run (steps)


def make_rmse_loss(solve):
    """FC-RMSE loss through a given solve function (full or windowed)."""
    def loss(state):
        fc = compute_fc(bold(solve(state)), skip_t=BOLD_SKIP)
        err = rmse(fc, fc_target)
        return err, {"rmse": err, "G": jnp.asarray(G_lens(state))}
    return loss


def save_traj(i, diff_state, static_state, fitting_data, aux, loss_value, grads):
    return {"step": int(i), "G": float(aux["G"]), "rmse": float(aux["rmse"])}


def run_optim(loss_fn, cfg, mode, label):
    """One optimization from G_START; returns (steps, G, rmse) arrays."""
    cfg.coupling[coupling_key].G = Parameter(jnp.asarray(G_START))
    cb = MultiCallback([
        DefaultPrintCallback(every=PRINT_EVERY),
        SavingCallback(key="traj", save_fun=save_traj),
    ])
    opt = OptaxOptimizer(loss_fn, optax.adam(LR), callback=cb, has_aux=True)
    print(f"\n=== {label} ===")
    _, data = opt.run(cfg, max_steps=MAX_STEPS, mode=mode)
    rows = list(data["traj"]["save"])
    return (np.array([r["step"] for r in rows]),
            np.array([r["G"] for r in rows]),
            np.array([r["rmse"] for r in rows]))


@cache("optim_runs")
def compute_optim():
    # Full AD (forward-mode == full BPTT for scalar G), untruncated solver.
    _, cfg_ad = prepare(network, Heun(), t1=FC_T1, dt=DT)
    ad = run_optim(make_rmse_loss(solve_fc), cfg_ad, "fwd", "Full AD (no truncation)")

    # TBPTT: identical forward dynamics, backward pass windowed to W*dt.
    solve_w, cfg_w = prepare(network, Heun(grad_horizon=W_TBPTT), t1=FC_T1, dt=DT)
    tb = run_optim(make_rmse_loss(solve_w), cfg_w, "rev", f"TBPTT W={W_TBPTT}")
    return {"ad": ad, "tb": tb}


runs = compute_optim()


def _last_finite(arr):
    a = np.asarray(arr, float)
    fin = np.flatnonzero(np.isfinite(a))
    return float(a[fin[-1]]) if fin.size else float("nan")


@cache("final_fc")
def compute_final_fc():
    """Simulated FC at each run's optimized G (last finite step), vs the target."""
    def fc_at(G):
        cfg = eqx.tree_at(G_lens, config_fc, jnp.asarray(float(G)))
        fc = compute_fc(bold(solve_fc(cfg)), skip_t=BOLD_SKIP)
        return np.asarray(fc), float(rmse(fc, fc_target)), float(fc_corr(fc, fc_target))

    out = {"fc_target": np.asarray(fc_target)}
    for key in ("ad", "tb"):
        G = _last_finite(runs[key][1])
        fc, err, r = fc_at(G)
        out[key] = {"G": G, "fc": fc, "rmse": err, "r": r}
    return out


final_fc = compute_final_fc()
Figure 3: Optimizing \(G\) to fit FC, with and without truncation. Top: each trajectory is one run; the y-axis is \(G\) at each optimization step, points colored by the FC-RMSE they reached (lower is better). The dashed line is the \(\lambda_{max}=0\) boundary and the red band above it is the chaotic regime. The grey curve on the left is the \(RMSE(G)\) profile from the sweep above, bulging right at the optimum. The full-AD run is driven by the unstable gradient into the chaotic band; the TBPTT (\(W=100\)) run descends on a stable, short-horizon gradient and settles near the RMSE optimum on the stable side. Bottom: the simulated FC at each run’s optimized \(G\) beside the empirical target. The TBPTT fit reproduces the target structure; the full-AD run, stranded in the chaotic band, does not.

Interpretation and Practical Recipe

  • The exact gradient is correct until the dynamics turn chaotic. Below the \(\lambda_{\max}=0\) boundary the AD gradient agrees with the finite-difference ground truth. Above it the AD gradient flips sign and diverges, so an optimizer driven by it is pushed into the chaotic regime instead of toward the fit.
  • Truncation restores a usable gradient. Severing the carry gradient at the window boundary caps the backward horizon at \(W\,dt\), so the runaway amplification never accumulates. The windowed run descends on a stable gradient and settles near the FC optimum on the stable side.
  • The window is a real hyperparameter. A too-short window is blind to slow FC structure and biases toward fast timescales; a too-long window lets the chaotic amplification back in. Choose \(W\) from the slowest timescale you need credit for, not from the simulation length.
NoteForward is unchanged

grad_horizon only changes the backward pass. The forward trajectory, and hence the FC and the loss value, are bit-identical to the untruncated run for every window. The same long simulation can serve a long-horizon statistic while the gradient is taken over a short, stable window.

grad_horizon sets the gradient horizon, not the cost. Bounding runtime and memory is the job of block_size (gradient checkpointing), an orthogonal knob that rematerializes activations within each window and nests with grad_horizon. The same block_size seam can also stream the FC so memory scales with the block rather than n_steps. See the RWW tutorial for the full FC/BOLD pipeline.

References

Truncated backpropagation through time:

  • Werbos, P. J. (1990). Backpropagation through time: what it does and how to do it. Proceedings of the IEEE, 78(10), 1550-1560.
  • Williams, R. J., & Peng, J. (1990). An efficient gradient-based algorithm for on-line training of recurrent network trajectories. Neural Computation, 2(4), 490-501. The windowed truncated-BPTT scheme used here.
  • Tallec, C., & Ollivier, Y. (2017). Unbiasing truncated backpropagation through time. arXiv:1705.08209. On the bias of a too-short window.

Why the gradient horizon is finite (the \(\exp(T\,\lambda_{\max})\) growth):

  • Bengio, Y., Simard, P., & Frasconi, P. (1994). Learning long-term dependencies with gradient descent is difficult. IEEE Transactions on Neural Networks, 5(2), 157-166.
  • Pascanu, R., Mikolov, T., & Bengio, Y. (2013). On the difficulty of training recurrent neural networks. ICML, PMLR 28(3), 1310-1318. Ties the gradient growth to the recurrent Jacobian’s spectral radius (the \(\lambda_{\max}\) above).

Why an FC (covariance) loss’s horizon is the system’s correlation time:

  • Kubo, R. (1966). The fluctuation-dissipation theorem. Reports on Progress in Physics, 29(1), 255-284.