Heterogeneous Networks on One Connectome

Mixed delayed and instantaneous coupling, parameter sweeps, and fitting

Try this notebook interactively:

Download .ipynb Download .qmd Open in Colab

Introduction

WarningExperimental feature

The heterogeneous-network API is experimental. See Current boundaries for what it does and does not support yet.

HeterogeneousNetwork assigns different neural-mass models to named subsets of one connectome. Each model retains its own states and parameters; explicit routes define the signals exchanged between groups.

This example combines three populations of different state widths, delayed bidirectional long-range activity, and an additional instantaneous relay-sourced signal.

Figure 1 below is the structure we are aiming for. We build it once, then simulate, sweep it with Space, and optimize it through automatic differentiation.

Imports

Show imports
import copy

import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import optax

from tvboptim.execution import ParallelExecution
from tvboptim.experimental.network_dynamics import (
    Bunch,
    GroupObservation,
    HeterogeneousNetwork,
    NodeGroup,
    Readout,
    SignalRoute,
    prepare,
)
from tvboptim.experimental.network_dynamics.coupling import (
    DelayedLinearCoupling,
    LinearCoupling,
)
from tvboptim.experimental.network_dynamics.dynamics.tvb import (
    Generic2dOscillator,
    JansenRit,
    ReducedWongWang,
)
from tvboptim.experimental.network_dynamics.external_input import ConstantInput
from tvboptim.experimental.network_dynamics.graph import DenseDelayGraph
from tvboptim.experimental.network_dynamics.noise import AdditiveNoise
from tvboptim.experimental.network_dynamics.solvers import Heun
from tvboptim.experimental.network_dynamics.utils import print_network
from tvboptim.optim import (
    MultiCallback,
    OptaxOptimizer,
    SavingCallback,
    SavingLossCallback,
)
from tvboptim.observations.observation import welford_cov
from tvboptim.types import GridAxis, Parameter, Space

What we are building

Three neural-mass populations occupy disjoint slices of a single nine-node connectome, and two SignalRoutes carry activity between them. Read the diagram in three layers:

  • Groups (middle) own their own dynamics, states, and parameters. State widths differ per group and are never padded to a common size.
  • Routes (top and bottom) are the only paths between nodes. Each one collects a readout from every source group, packs the results into one graph-ordered [channels, 9] signal, and performs a single graph traversal. The delayed route additionally keeps that packed signal in a history buffer.
  • Local drives (sides) never touch the graph. Noise and external_input are evaluated in group-local node space.

The arrow labels are the things you actually write in code: the readout each group emits into a route, and the dynamics input name each group receives it on.

Show the diagram source
from matplotlib.patches import Circle, FancyArrowPatch, FancyBboxPatch

DELAYED_COLOR = "#0072B2"  # blue, long_range
INSTANT_COLOR = "#D55E00"  # vermilion, fast_relay
GROUP_FACE = "#F1F4F7"
GROUP_EDGE = "#55616D"
LOCAL_COLOR = "#3F3F3F"

# Layout bands. The axes use an equal aspect, so one x unit and one y unit
# are the same size on screen and the node markers stay circular.
INSTANT_Y = (7.5, 8.25)
GROUP_Y = (3.5, 6.2)
DELAYED_Y = (0.25, 1.0)
LOCAL_Y = 4.85

groups = {
    "cortex": {
        "span": (0.35, 4.35),
        "n_nodes": 6,
        "model": "ReducedWongWang",
        "detail": "graph nodes 0-5\nstate [1, 6]",
        "emits": "S",
    },
    "relay": {
        "span": (4.80, 7.70),
        "n_nodes": 2,
        "model": "JansenRit",
        "detail": "graph nodes 6-7\nstate [6, 2]",
        "emits": "pyramidal_activity",
    },
    "driven": {
        "span": (8.15, 10.60),
        "n_nodes": 1,
        "model": "Generic2dOscillator",
        "detail": "graph node 8\nstate [2, 1]",
        "emits": "oscillator_activity",
    },
}

BUS_X = (0.35, 10.60)


def draw_arrow(ax, start, end, color, *, dashed=False):
    ax.add_patch(
        FancyArrowPatch(
            start,
            end,
            arrowstyle="-|>",
            mutation_scale=12,
            linewidth=1.5,
            linestyle=(0, (4, 2)) if dashed else "solid",
            color=color,
            shrinkA=0,
            shrinkB=0,
            zorder=3,
        )
    )


def draw_label(ax, x, y, text, color, *, rotation=0, ha="center", size=8.5):
    """Place a label centred on its anchor, so it never drifts into a box."""
    ax.text(
        x, y, text,
        fontsize=size, color=color, ha=ha, va="center",
        rotation=rotation, zorder=5,
        bbox={"boxstyle": "round,pad=0.18", "facecolor": "white",
              "edgecolor": "none", "alpha": 0.85},
    )


def draw_bus(ax, y_band, color, title, subtitle):
    y0, y1 = y_band
    ax.add_patch(
        FancyBboxPatch(
            (BUS_X[0], y0),
            BUS_X[1] - BUS_X[0],
            y1 - y0,
            boxstyle="round,pad=0.01,rounding_size=0.1",
            facecolor=color,
            edgecolor=color,
            alpha=0.18,
            linewidth=1.4,
            zorder=1,
        )
    )
    ax.text(
        BUS_X[0] + 0.25, (y0 + y1) / 2, title,
        fontsize=9.5, fontweight="bold", color=color,
        ha="left", va="center", zorder=4,
    )
    ax.text(
        BUS_X[1] - 0.25, (y0 + y1) / 2, subtitle,
        fontsize=8.5, color=color, ha="right", va="center", zorder=4,
    )


fig, ax = plt.subplots(figsize=(11, 5.9))

draw_bus(
    ax, INSTANT_Y, INSTANT_COLOR,
    "route  fast_relay",
    "LinearCoupling(G=0.02), instantaneous",
)
draw_bus(
    ax, DELAYED_Y, DELAYED_COLOR,
    "route  long_range",
    "DelayedLinearCoupling(G=0.08), history buffer",
)

for name, group in groups.items():
    x0, x1 = group["span"]
    centre = (x0 + x1) / 2
    ax.add_patch(
        FancyBboxPatch(
            (x0, GROUP_Y[0]),
            x1 - x0,
            GROUP_Y[1] - GROUP_Y[0],
            boxstyle="round,pad=0.02,rounding_size=0.12",
            facecolor=GROUP_FACE,
            edgecolor=GROUP_EDGE,
            linewidth=1.3,
            zorder=2,
        )
    )
    ax.text(
        centre, GROUP_Y[1] - 0.42, name,
        fontsize=11.5, fontweight="bold", ha="center", va="center", zorder=4,
    )
    ax.text(
        centre, GROUP_Y[1] - 0.85, group["model"],
        fontsize=9, ha="center", va="center", zorder=4,
    )
    ax.text(
        centre, GROUP_Y[1] - 1.35, group["detail"],
        fontsize=7.5, color="#55616D", ha="center", va="center",
        linespacing=1.5, zorder=4,
    )

    # One circle per graph node the group owns.
    offsets = [(index - (group["n_nodes"] - 1) / 2) * 0.46
               for index in range(group["n_nodes"])]
    for offset in offsets:
        ax.add_patch(
            Circle(
                (centre + offset, GROUP_Y[0] + 0.45),
                0.16,
                facecolor="white",
                edgecolor=GROUP_EDGE,
                linewidth=1.2,
                zorder=4,
            )
        )

    # Downward: the readout this group emits into the delayed route.
    delayed_mid = (GROUP_Y[0] + DELAYED_Y[1]) / 2
    draw_arrow(
        ax, (centre - 0.6, GROUP_Y[0]), (centre - 0.6, DELAYED_Y[1]),
        DELAYED_COLOR,
    )
    draw_label(
        ax, centre - 0.6, delayed_mid, group["emits"], DELAYED_COLOR,
        rotation=90,
    )
    # Upward: every group receives the delayed signal on input "delayed".
    draw_arrow(
        ax, (centre + 0.6, DELAYED_Y[1]), (centre + 0.6, GROUP_Y[0]),
        DELAYED_COLOR,
    )
    draw_label(
        ax, centre + 0.6, delayed_mid, '"delayed"', DELAYED_COLOR, rotation=90,
    )

# The instantaneous route is sourced by the relay group alone.
instant_mid = (GROUP_Y[1] + INSTANT_Y[0]) / 2
relay_centre = sum(groups["relay"]["span"]) / 2
draw_arrow(
    ax, (relay_centre, GROUP_Y[1]), (relay_centre, INSTANT_Y[0]), INSTANT_COLOR
)
draw_label(
    ax, relay_centre - 0.18, instant_mid, "pyramidal_activity",
    INSTANT_COLOR, ha="right",
)
for target in ("cortex", "driven"):
    target_centre = sum(groups[target]["span"]) / 2
    draw_arrow(
        ax, (target_centre, INSTANT_Y[0]), (target_centre, GROUP_Y[1]),
        INSTANT_COLOR,
    )
    draw_label(
        ax, target_centre + 0.18, instant_mid, '"instant"', INSTANT_COLOR,
        ha="left",
    )

# Group-local drives: no graph transport, so they enter from the side.
draw_arrow(ax, (-1.90, LOCAL_Y), (BUS_X[0], LOCAL_Y), LOCAL_COLOR, dashed=True)
ax.text(
    (-1.90 + BUS_X[0]) / 2, LOCAL_Y + 0.28, "AdditiveNoise\non S",
    fontsize=8.5, color=LOCAL_COLOR, ha="center", va="bottom",
    linespacing=1.5, zorder=4,
)
draw_arrow(
    ax, (13.05, LOCAL_Y), (BUS_X[1], LOCAL_Y), LOCAL_COLOR, dashed=True
)
ax.text(
    (13.05 + BUS_X[1]) / 2, LOCAL_Y + 0.28, 'ConstantInput\n"stimulus"',
    fontsize=8.5, color=LOCAL_COLOR, ha="center", va="bottom",
    linespacing=1.5, zorder=4,
)

ax.set_xlim(-2.6, 13.7)
ax.set_ylim(0.0, 8.6)
ax.set_aspect("equal")
ax.axis("off")
fig.tight_layout()
plt.show()
Figure 1: The target network. Two routes span one shared connectome: a delayed route joining all three groups, and an instantaneous relay-sourced route. Each route performs one graph traversal no matter how many groups take part.

The shared connectome

We use a small synthetic directed connectome: six cortical nodes, two relay nodes, and one driven oscillator. Groups share the graph and need not occupy contiguous nodes.

Show synthetic connectome setup
n_nodes = 9
cortical_nodes = tuple(range(6))
relay_nodes = (6, 7)
driven_nodes = (8,)

key_weights, key_delays = jax.random.split(jax.random.key(7))
weights = jax.random.uniform(
    key_weights, (n_nodes, n_nodes), maxval=0.015
)
weights = weights.at[jnp.diag_indices(n_nodes)].set(0.0)

# Millisecond transmission delays. Capacity is fixed by max_delay_bound;
# the numerical delay values remain live after prepare().
delays = jax.random.uniform(
    key_delays, (n_nodes, n_nodes), minval=0.5, maxval=2.0
)
delays = delays.at[jnp.diag_indices(n_nodes)].set(0.0)

graph = DenseDelayGraph(
    weights,
    delays,
    max_delay_bound=2.0,
)

# A small offset keeps the two relay trajectories distinguishable in plots.
relay_initial_state = jnp.broadcast_to(
    jnp.asarray(JansenRit.INITIAL_STATE)[:, None],
    (len(JansenRit.INITIAL_STATE), len(relay_nodes)),
)
relay_initial_state = relay_initial_state.at[0, 1].add(0.02)
relay_initial_state = relay_initial_state.at[1, 1].add(0.20)
relay_initial_state = relay_initial_state.at[2, 1].add(-0.10)

Make the exchanged signal explicit

Reduced Wong–Wang transmits its synaptic gating state S. Jansen–Rit instead transmits normalized pyramidal firing activity derived from y1 - y2; a pure JAX callable makes that choice explicit:

def pyramidal_activity(state, params):
    voltage = state[1] - state[2]
    activity = 2.0 / (
        1.0 + jnp.exp(params.r * (params.v0 - voltage))
    )
    return (0.5 * activity)[None, :]  # unitless [0, 1]


def oscillator_activity(state, params):
    del params
    return jnp.clip((state[0:1] + 2.0) / 4.0, 0.0, 1.0)


rate_params = Bunch(v0=5.52, r=0.56)

The callable and output shape are static. Numerical parameters at config.routes.<route>.source_params remain changeable, sweepable, and differentiable after preparation.

Construct a mixed-coupling network

Each route performs one graph traversal regardless of group count. The delayed route packs cortical S, relay firing activity, and driven voltage into one graph-ordered signal and stores only that signal in history. On the relay-sourced instantaneous route, all other nodes emit zero.

Converted values from routes targeting the same coupling input are added. Use distinct dynamics input names when signals must remain separate.

network = HeterogeneousNetwork(
    graph=graph,
    groups={
        "cortex": NodeGroup(
            dynamics=ReducedWongWang(w=0.6),
            nodes=cortical_nodes,
            noise=AdditiveNoise(
                sigma=0.002,
                apply_to="S",
                key=jax.random.key(11),
            ),
        ),
        "relay": NodeGroup(
            dynamics=JansenRit(mu=0.22),
            nodes=relay_nodes,
            initial_state=relay_initial_state,
        ),
        "driven": NodeGroup(
            dynamics=Generic2dOscillator(),
            nodes=driven_nodes,
            external_input={
                "stimulus": ConstantInput(amplitude=0.1),
            },
        ),
    },
    routes={
        "long_range": SignalRoute(
            source={
                "cortex": "S",
                "relay": pyramidal_activity,
                "driven": oscillator_activity,
            },
            source_params={"relay": rate_params},
            coupling=DelayedLinearCoupling(
                G=0.08,
                history_interpolation="linear",
                buffer_strategy="circular",
            ),
            target={
                "cortex": "delayed",
                "relay": "delayed",
                "driven": "delayed",
            },
        ),
        "fast_relay": SignalRoute(
            source={"relay": pyramidal_activity},
            source_params={"relay": rate_params},
            coupling=LinearCoupling(G=0.02),
            target={
                "cortex": "instant",
                "driven": "instant",
            },
        ),
    },
)

The network printer summarizes the partition and both signal routes before we run anything. Expand the summary when you need to inspect the structure:

print_network(network)
 Heterogeneous Network Dynamics System
==================================================

Graph: DenseDelayGraph
  Nodes: 9
  Max delay: 1.9926822185516357 ms

Groups
--------------------------------------------------
cortex (ReducedWongWang)
  Nodes (6): [0, 1, 2, 3, 4, 5]
  States: S
  Noise: AdditiveNoise
  External inputs: none
  Parameters: I_o=0.33, J_N=0.2609, a=0.27, b=0.108, d=154.0, gamma=0.641, tau_s=100.0, w=0.6

driven (Generic2dOscillator)
  Nodes (1): [8]
  States: V, W
  Noise: none
  External inputs: stimulus
  Parameters: I=0.0, a=-2.0, alpha=1.0, b=-10.0, beta=1.0, c=0.0, d=0.02, e=3.0, f=1.0, g=0.0, gamma=1.0, tau=1.0

relay (JansenRit)
  Nodes (2): [6, 7]
  States: y0, y1, y2, y3, y4, y5
  Noise: none
  External inputs: none
  Parameters: A=3.25, B=22.0, J=135.0, a=0.1, a_1=1.0, a_2=0.8, a_3=0.25, a_4=0.25, b=0.05, mu=0.22, nu_max=0.0025, r=0.56, v0=5.52

Routes
--------------------------------------------------
fast_relay (LinearCoupling, instantaneous)
  Source: relay=pyramidal_activity
  Target: cortex=instant, driven=instant
  Parameters: G=0.02, b=0.0

long_range (DelayedLinearCoupling, delayed)
  Source: cortex=S, relay=pyramidal_activity, driven=oscillator_activity
  Target: cortex=delayed, relay=delayed, driven=delayed
  Parameters: G=0.08, b=0.0

Only the routed signal is temporarily packed as [1, 9].

Target conversions adapt a transported signal to one receiver’s units. For example, a cortical input expecting current rather than unitless activity can declare:

def to_cortical_current(signal, params):
    return params.scale * signal


SignalRoute(
    source={...},
    coupling=DelayedLinearCoupling(G=0.08),
    target={"cortex": ("delayed", to_cortical_current), ...},
    target_params={"cortex": Bunch(scale=0.25)},
)

Target conversion occurs after graph transport. Sources on one route must therefore emit comparable channels: conversion cannot undo the mixing of incompatible units under one gain.

Prepare once, simulate many times

simulate, config = prepare(
    network,
    Heun(block_size=25),
    t0=0.0,
    t1=10.0,
    dt=0.1,
)

solution = jax.jit(simulate)(config)

With no group=, plot() puts each group in a column while retaining its own variable rows and node axis. The unequal panel counts in Figure 2 make the different state dimensions visible immediately:

with plt.style.context("default"):
    solution.plot(
        groups=("cortex", "relay", "driven"),
        variables={
            "cortex": ("S",),
            "relay": ("y0", "y1", "y2"),
            "driven": ("V", "W"),
        },
        nodes=2,
        figsize=(10, 6),
    )
Figure 2: One shared time grid, three groups, and each neural mass’s native variables and graph nodes.

For a closer view, plot(group="relay", ...) delegates to the ordinary single-group trajectory plot.

Selection and graph projection remain explicit:

Inspect group-local results and graph projection
print("cortex:", solution.groups.cortex.ys.shape)
print("relay: ", solution.groups.relay.ys.shape)
print("driven:", solution.groups.driven.ys.shape)
print("graph projection:", solution.to_graph("S", groups=["cortex"]).shape)
print("delayed history:", config.routes.long_range.history.shape)
cortex: (100, 1, 6)
relay:  (100, 6, 2)
driven: (100, 2, 1)
graph projection: (100, 9)
delayed history: (22, 1, 9)

Index sequences preserve construction order; boolean masks normalize to ascending graph-node order. A group’s initial_state columns must follow that order, or values can silently reach the wrong nodes despite matching shapes.

The prepared config is live

Every numerical value in the prepared config stays live. For example:

changed = copy.deepcopy(config)
changed.routes.long_range.coupling.G = 0.10
changed.groups.cortex.dynamics.w = 0.65
changed.groups.cortex.noise.sigma = 0.003
changed.groups.driven.external.stimulus.amplitude = 0.15
changed.graph.delays = jnp.minimum(changed.graph.delays * 1.1, 2.0)

changed_solution = jax.jit(simulate)(changed)

Changing shapes, group membership, sparse indices, or maximum delay capacity requires another prepare(); changing in-bounds values does not.

Sweeping, fitting, and continuing a simulation below all rest on this one property.

Observe a common graph-order signal

Because group trajectories have different variable axes, whole-network reducers require a common observable. GroupObservation applies a readout to each group and packs the outputs as [channels, graph nodes]:

def relay_observable(voi, params):
    voltage = voi[1] - voi[2]
    activity = 2.0 / (
        1.0 + jnp.exp(params.r * (params.v0 - voltage))
    )
    return (0.5 * activity)[None, :]  # normalize [0, 2] to [0, 1]


def driven_observable(voi, params):
    del params
    voltage = voi[0:1]  # V is the first oscillator variable of interest
    return jnp.clip((voltage + 2.0) / 4.0, 0.0, 1.0)


common_activity = GroupObservation(
    {
        "cortex": "S",
        "relay": Readout(
            relay_observable,
            name="relay_activity",
            reads="voi",
        ),
        "driven": Readout(
            driven_observable,
            name="driven_activity",
            reads="voi",
        ),
    },
    params={"relay": rate_params},
    channels=("activity",),
)

observe_simulation, observe_config = prepare(
    network,
    Heun(block_size=25),
    t0=0.0,
    t1=10.0,
    dt=0.1,
    observe=common_activity,
)
observed = observe_simulation(observe_config)
print(observed.ys.shape)  # [time, common channels, graph nodes]
print(observed.variable_names)
(100, 1, 9)
('activity',)

Each group now emits a unitless [0, 1] activity proxy. channels= names the shared axis and becomes NativeSolution.variable_names. Equal width verifies only shape, not scientific comparability. Before FC or BOLD, transform every group to a common quantity and unit.

Route and observation readouts consume different inputs

A route reads the complete state that a model integrates; an observation reads the selected variables of interest (VOI), in VOI order. Named variables resolve against the correct namespace. Wrap positional callables with Readout(..., reads="state") on a route or Readout(..., reads="voi") in a GroupObservation. Preparation rejects a wrapped readout placed in the other context. Bare callables retain the historical position-dependent behavior and can only be checked by output shape.

Readout parameters use their position-specific live mappings: source_params/local_params on routes and GroupObservation.params for observations. Shared config.readouts parameters are not yet supported.

Stream FC and BOLD without retaining the neural trajectory

Existing reducers consume the graph-order observation directly:

fc_simulation, fc_config = prepare(
    network,
    Heun(block_size=25),
    t0=0.0,
    t1=10.0,
    dt=0.1,
    observe=common_activity,
    reduce=welford_cov(),
)
fc = fc_simulation(fc_config)
print(fc.shape)
(9, 9)

observe= plus reduce= bounds forward memory only when block_size is set. Otherwise the full [time, channels, nodes] trajectory is materialized before reduction, and preparation warns. Differentiated FC has a separate backward memory cost of O(n_steps / block_size + block_size), described in Streaming Reductions.

Without a reducer, observations may omit groups and fill their nodes with fill_value. Reduction requires full coverage because constant fill causes zero-variance or NaN covariance rows and appears as real drive to BOLD. Use allow_partial_coverage=True only with a verified fill-aware reducer.

Sweep prepared leaves with Space

Axes can target heterogeneous config paths directly. Here Space forms the Cartesian product of seven delayed gains and three cortical recurrence values; ParallelExecution vectorizes the prepared solve.

def observe(cfg):
    result = simulate(cfg)
    relay_v = result.groups.relay.sel("y1") - result.groups.relay.sel("y2")
    return {
        "mean_cortical_S": result.groups.cortex.sel("S")[-20:].mean(),
        "mean_relay_voltage": relay_v[-20:].mean(),
    }


sweep_config = copy.deepcopy(config)
sweep_config.routes.long_range.coupling.G = GridAxis(0.04, 0.12, 7)
sweep_config.groups.cortex.dynamics.w = GridAxis(0.5, 0.7, 3)

space = Space(sweep_config, mode="product")
sweep_result = ParallelExecution(
    observe,
    space,
    n_vmap=7,
    n_pmap=1,
).run()

sweep_frame = sweep_result.to_dataframe()

to_dataframe() labels each row with the config path that varied, so the result plots directly in Figure 3:

Show the sweep plot
G_COLUMN = "routes.long_range.coupling.G"
W_COLUMN = "groups.cortex.dynamics.w"
panels = {
    "mean_cortical_S": "mean cortical $S$",
    "mean_relay_voltage": "mean relay $y_1 - y_2$",
}

w_values = sorted(sweep_frame[W_COLUMN].unique())
shades = [
    plt.cm.Blues(0.45 + 0.45 * index / max(len(w_values) - 1, 1))
    for index in range(len(w_values))
]

fig, axes = plt.subplots(1, 2, figsize=(10, 3.8))
for axis, (column, label) in zip(axes, panels.items()):
    for shade, w_value in zip(shades, w_values):
        rows = sweep_frame[sweep_frame[W_COLUMN] == w_value]
        rows = rows.sort_values(G_COLUMN)
        axis.plot(
            rows[G_COLUMN],
            rows[column].astype(float),
            marker="o",
            markersize=4,
            linewidth=2,
            color=shade,
            label=f"{w_value:g}",
        )
    axis.set_xlabel("long_range gain $G$")
    axis.set_ylabel(label)
    axis.grid(alpha=0.25)
# The w curves coincide on the relay panel, so resolve them without the G trend.
relay_pivot = sweep_frame.pivot_table(
    index=G_COLUMN, columns=W_COLUMN, values="mean_relay_voltage"
)
relay_spread = relay_pivot.sub(relay_pivot.mean(axis=1), axis=0) * 1e6
inset = axes[1].inset_axes([0.10, 0.46, 0.42, 0.34])
for shade, w_value in zip(shades, w_values):
    inset.plot(
        relay_spread.index,
        relay_spread[w_value].astype(float),
        color=shade,
        linewidth=1.5,
    )
inset.set_title(r"spread across $w$ ($10^{-6}$)", fontsize=7, pad=3)
inset.tick_params(labelsize=6)
inset.grid(alpha=0.25)

handles, labels = axes[0].get_legend_handles_labels()
fig.legend(
    handles,
    labels,
    title="cortical $w$",
    frameon=False,
    ncol=len(labels),
    loc="lower center",
)
fig.tight_layout(rect=(0, 0.12, 1, 1))
plt.show()
Figure 3: Sweep over the delayed gain at three cortical recurrence values. Each observable follows a different knob.

All 21 simulations share static group indices, route callables, and buffer capacity; only the selected numerical leaves are batched. The two observables separate the knobs: cortical S tracks w and barely responds to G, while the relay voltage tracks G so closely that the three w curves coincide at plot scale. The inset removes the G trend to resolve the much smaller but systematic w effect that the route still transmits.

Fit a route parameter

The config is also an optimization state. We generate a synthetic target at a known delayed gain, then fit that gain from another starting value.

def cortical_summary(cfg):
    result = simulate(cfg)
    return result.groups.cortex.sel("S")[-20:].mean()


target_config = copy.deepcopy(config)
target_config.routes.long_range.coupling.G = 0.10
target = jax.lax.stop_gradient(cortical_summary(target_config))

fit_config = copy.deepcopy(config)
fit_config.routes.long_range.coupling.G = Parameter(jnp.array(0.04))

def loss(cfg):
    # Scaling only improves conditioning of this small synthetic example.
    error = cortical_summary(cfg) - target
    return 1e8 * error**2


def record_gain(step, diff_state, static_state, fitting_data, aux, value, grads):
    del step, static_state, fitting_data, aux, value, grads
    return float(diff_state.routes.long_range.coupling.G.constrained_value)


optimizer = OptaxOptimizer(
    loss,
    optax.adam(2e-3),
    callback=MultiCallback(
        [
            SavingLossCallback(every=1),
            SavingCallback(every=1, key="gain", save_fun=record_gain),
        ]
    ),
)
fitted_config, history = optimizer.run(fit_config, max_steps=120)

print("target gain: 0.10")
print(
    "fitted gain:",
    float(fitted_config.routes.long_range.coupling.G.constrained_value),
)
target gain: 0.10
fitted gain: 0.10004748404026031

Callbacks record one row per step, so the run above omits chunk_size. That argument fuses several steps into one scan and reports only the last step of each chunk, which is faster but leaves nothing to plot in between. Figure 4 shows the recorded history.

Show the fit plot
loss_history = history["loss"]
gain_history = history["gain"]

fig, axes = plt.subplots(1, 2, figsize=(10, 3.8))
axes[0].plot(
    loss_history["step"].astype(float),
    loss_history["save"].astype(float),
    color="#0072B2",
    linewidth=2,
)
axes[0].set_yscale("log")
axes[0].set_ylabel("loss")

axes[1].plot(
    gain_history["step"].astype(float),
    gain_history["save"].astype(float),
    color="#0072B2",
    linewidth=2,
)
axes[1].axhline(0.10, color="black", linestyle="--", linewidth=1)
axes[1].text(
    0.5, 0.10, " target 0.10", fontsize=8, va="bottom", ha="left",
)
axes[1].set_ylabel("long_range gain $G$")

for axis in axes:
    axis.set_xlabel("optimization step")
    axis.grid(alpha=0.25)
fig.tight_layout()
plt.show()
Figure 4: Fitting the delayed gain. The loss drops sharply each time the gain crosses the target, and the gain settles onto the value that generated it.

Only Parameter leaves are optimized. The graph, groups, other route, initial state, and delay history stay fixed; gradients pass through both routes and the delayed circular history.

Continue a delayed simulation

update_history() stores final group states and reconstructs each delayed route’s signal history from the saved trajectory:

network.update_history(solution)
continue_simulation, continue_config = prepare(
    network,
    Heun(block_size=25),
    t0=10.0,
    t1=12.0,
    dt=0.1,
)
continued = continue_simulation(continue_config)

The continuation uses the previous solution instead of a constant initial history. This requires retaining all integrated state variables.

Performance model and current boundaries

Heterogeneity does not create a padded state tensor. Group k operates on [S_k, N_k], so local vector-field cost follows actual group sizes rather than max(S_k) * N.

A route instead packs one graph-ordered signal with Q channels and performs one transport regardless of participating group count:

Graph representation Approximate transport work per route evaluation
Dense O(Q * N²)
Sparse with E stored edges O(Q * E)

Dense multiplication can outperform sparse message passing on small or moderately dense graphs, especially on GPUs. Choose by benchmark and memory needs, not density alone. Sparse indices and representation are static after prepare(); weights and delays remain live.

Merge signals that share transport semantics

Combine signals that share a coupling family, delay policy, and scientific interpretation. Compatible source or target groups add no graph traversal. Sources must emit the same Q-channel schema but may use different readouts.

Separate routes when coupling equations, gains, delays, timing, or conversions differ. Each adds transport work, and reused readouts are evaluated and packed again, so route separation should reflect a scientific distinction.

A subset-source route still packs [Q, N]; unselected nodes emit zero, but a dense graph still performs N x N transport. Source selection is a semantic mask, not an induced-subgraph optimization. Use a sparse shared graph for large sparse problems.

Delays scale with transmitted signals

A delayed route stores approximately:

[history capacity, Q_source, N]

Prepared maximum delay, dt, interpolation, and buffer strategy determine capacity. It grows with neither simulation duration nor full source-state width. Each delayed route owns a history, so compatible signals should share a route. In-capacity delay changes remain live; increasing capacity requires preparation.

Keep the number of groups scientifically meaningful

JAX unrolls static group loops while tracing local vector fields. More groups produce more separately lowered calls and fragment forward and reverse programs. Use groups for distinct neural populations, not merely for parameter management—especially when differentiating on GPUs.

Prefer, in order:

  1. a global scalar when every node shares a parameter;
  2. a node-local parameter vector when values vary by node; and
  3. separate groups when nodes truly need different dynamics, state layouts, noise/external-input definitions, or signal semantics.

Solver stages multiply route work

With recompute_coupling_per_stage=False, routes are evaluated once per step and held through native solver stages. With True, they are evaluated at every stage, multiplying transport work by stage count. This may improve instantaneous-coupling accuracy; measure both forward and reverse cost. See Coupling Freezing for accuracy trade-offs.

Keep local drives local

Noise and external_input belong to NodeGroup: they are evaluated in group-local node space without graph transport. Routes are for signals transmitted between nodes. To propagate a stimulus, drive one group locally and emit its resulting state through a route.

Readout and conversion callables should be pure, fixed-shape JAX functions. Avoid data-dependent Python control flow and large dense-array reconstruction. Source readouts run before transport; target conversions run on the transported target slice.

Forward and reverse mode

Reverse mode differentiates group kernels, readouts, transports, conversions, and solver stages. Delayed history and retained trajectories can dominate long simulations. Use block checkpointing, return only required variables, and benchmark the differentiated loss rather than only the forward solve.

Streaming reduction requires a GroupObservation defining one graph-order array. With block_size, it folds each block instead of retaining the neural trajectory; without it, reduction is post-hoc and saves no forward memory. Omit observe= when short, group-specific trajectories are needed.

Current boundaries

The API supports a fixed partition of one square graph, one time step, and the native Euler, Heun, and Runge–Kutta solvers. Diffrax, multiple clocks, changing membership, partial graph partitions, and networks on different node spaces are future work.

Measure group-count scaling

The benchmark fixes model, node count, graph, steps, and route transports while splitting the same Jansen–Rit work across more static groups. Before timing, it asserts that the homogeneous and one-group heterogeneous trajectories, losses, and gain gradients agree. All cases are then compiled and warmed before runtime measurements are taken in rotating order. Points show medians, shaded bands show the interquartile range, and values are normalized to the matching homogeneous Network baseline. Compilation is measured once per case and has no band.

Edit benchmark_options to match your node count, group sweep, and rollout.

Show the benchmark implementation and configuration
import statistics
import time
from collections.abc import Mapping, Sequence

import matplotlib.pyplot as plt
import pandas as pd
from jax.extend import core as jax_core

from tvboptim.experimental.network_dynamics import DenseGraph, Network
from tvboptim.utils import cache, set_cache_path


ROUTE_KINDS = ("instantaneous", "delayed")
ROUTE_COLORS = {
    "instantaneous": "#0072B2",  # blue
    "delayed": "#D55E00",        # vermilion
}

set_cache_path("./heterogeneous_networks_benchmark")


def _sync(tree):
    for leaf in jax.tree.leaves(tree):
        if hasattr(leaf, "block_until_ready"):
            leaf.block_until_ready()
    return tree


def _nested_jaxprs(value, active=None):
    if active is None:
        active = set()
    value_id = id(value)
    if value_id in active:
        return
    if not isinstance(
        value, (jax_core.Jaxpr, jax_core.ClosedJaxpr, Mapping, tuple, list)
    ):
        return
    active.add(value_id)
    try:
        if isinstance(value, jax_core.Jaxpr):
            yield value
            for equation in value.eqns:
                yield from _nested_jaxprs(equation.params, active)
        elif isinstance(value, jax_core.ClosedJaxpr):
            yield from _nested_jaxprs(value.jaxpr, active)
        elif isinstance(value, Mapping):
            for child in value.values():
                yield from _nested_jaxprs(child, active)
        else:
            for child in value:
                yield from _nested_jaxprs(child, active)
    finally:
        active.remove(value_id)


def _jaxpr_statistics(closed):
    equations = [
        equation
        for jaxpr in _nested_jaxprs(closed)
        for equation in jaxpr.eqns
    ]
    return {
        "jaxpr_equations": len(equations),
        "dot_general_count": sum(
            equation.primitive.name == "dot_general" for equation in equations
        ),
        "scatter_count": sum(
            equation.primitive.name == "scatter" for equation in equations
        ),
    }


def _benchmark_graphs(n_nodes, dt, key):
    weight_key, delay_key = jax.random.split(key)
    weights = jax.random.uniform(
        weight_key, (n_nodes, n_nodes), minval=0.0, maxval=0.01
    )
    weights = weights.at[jnp.diag_indices(n_nodes)].set(0.0)
    delays = jax.random.uniform(
        delay_key, (n_nodes, n_nodes), minval=2.0 * dt, maxval=10.0 * dt
    )
    delays = delays.at[jnp.diag_indices(n_nodes)].set(0.0)
    return (
        DenseGraph(weights),
        DenseDelayGraph(weights, delays, max_delay_bound=10.0 * dt),
    )


def _benchmark_case(
    *, n_nodes, n_groups, route_kind, graph, steps, dt, block_size
):
    delayed = route_kind == "delayed"
    input_name = "delayed" if delayed else "instant"
    solver = Heun(block_size=block_size)
    gain = jnp.array(0.1)

    if n_groups == 0:
        coupling = (
            DelayedLinearCoupling(
                source="y1",
                G=gain,
                history_interpolation="linear",
                buffer_strategy="circular",
            )
            if delayed
            else LinearCoupling(source="y1", G=gain)
        )
        network = Network(JansenRit(), {input_name: coupling}, graph)
        solve_function, config = prepare(
            network, solver, t1=steps * dt, dt=dt
        )

        def primal(value):
            local = config.copy()
            local.coupling[input_name].G = value
            return solve_function(local).ys

        implementation = "homogeneous"
    else:
        groups = {}
        source = {}
        target = {}
        for index in range(n_groups):
            name = f"g{index}"
            nodes = tuple(range(index, n_nodes, n_groups))
            groups[name] = NodeGroup(JansenRit(), nodes)
            source[name] = "y1"
            target[name] = input_name
        coupling = (
            DelayedLinearCoupling(
                G=gain,
                history_interpolation="linear",
                buffer_strategy="circular",
            )
            if delayed
            else LinearCoupling(G=gain)
        )
        network = HeterogeneousNetwork(
            graph=graph,
            groups=groups,
            routes={
                "activity": SignalRoute(
                    source=source,
                    coupling=coupling,
                    target=target,
                )
            },
        )
        solve_function, config = prepare(
            network, solver, t1=steps * dt, dt=dt
        )

        def primal(value):
            local = config.copy()
            local.routes.activity.coupling.G = value
            return solve_function(local).ys

        implementation = "heterogeneous"

    return implementation, primal, gain


def _compile_benchmark_case(primal, argument):
    def loss(value):
        return sum(
            jnp.square(leaf).sum() for leaf in jax.tree.leaves(primal(value))
        )

    reverse = jax.value_and_grad(loss)
    primal_stats = _jaxpr_statistics(jax.make_jaxpr(primal)(argument))
    reverse_stats = _jaxpr_statistics(jax.make_jaxpr(reverse)(argument))

    jax.clear_caches()
    start = time.perf_counter()
    primal_executable = jax.jit(primal).lower(argument).compile()
    forward_compile = time.perf_counter() - start

    jax.clear_caches()
    start = time.perf_counter()
    reverse_executable = jax.jit(reverse).lower(argument).compile()
    reverse_compile = time.perf_counter() - start
    return {
        "argument": argument,
        "forward_executable": primal_executable,
        "reverse_executable": reverse_executable,
        "forward_compile_s": forward_compile,
        "reverse_compile_s": reverse_compile,
        **{f"forward_{name}": value for name, value in primal_stats.items()},
        **{f"reverse_{name}": value for name, value in reverse_stats.items()},
    }


def _rotated_order(keys, round_index):
    """Rotate and reverse case order so no case owns one timing position."""
    offset = round_index % len(keys)
    order = keys[offset:] + keys[:offset]
    return list(reversed(order)) if round_index % 2 else order


def _percentile(samples, quantile):
    ordered = sorted(samples)
    position = (len(ordered) - 1) * quantile
    lower = int(position)
    upper = min(lower + 1, len(ordered) - 1)
    fraction = position - lower
    return ordered[lower] + fraction * (ordered[upper] - ordered[lower])


def _summarize_samples(samples):
    return {
        "value": statistics.median(samples),
        "q25": _percentile(samples, 0.25),
        "q75": _percentile(samples, 0.75),
    }


def _measure_interleaved(compiled, repeats, warmup):
    """Warm all cases, then time every case once per rotated round."""
    keys = list(compiled)
    samples = {
        phase: {key: [] for key in keys} for phase in ("forward", "reverse")
    }
    for phase in samples:
        executable_key = f"{phase}_executable"
        for round_index in range(warmup):
            for key in _rotated_order(keys, round_index):
                case = compiled[key]
                _sync(case[executable_key](case["argument"]))
        for repeat in range(repeats):
            for key in _rotated_order(keys, warmup + repeat):
                case = compiled[key]
                start = time.perf_counter()
                _sync(case[executable_key](case["argument"]))
                samples[phase][key].append(time.perf_counter() - start)
    return samples


def _parity_check(compiled, route_kind, *, rtol=2e-5, atol=2e-7):
    """Require the ordinary and one-group implementations to be equivalent."""
    ordinary = compiled[("homogeneous", 0)]
    grouped = compiled[("heterogeneous", 1)]

    ordinary_ys = jax.tree.leaves(
        _sync(ordinary["forward_executable"](ordinary["argument"]))
    )
    grouped_ys = jax.tree.leaves(
        _sync(grouped["forward_executable"](grouped["argument"]))
    )
    if len(ordinary_ys) != 1 or len(grouped_ys) != 1:
        raise AssertionError("one-group parity expects one returned trajectory")
    trajectory_error = float(jnp.max(jnp.abs(ordinary_ys[0] - grouped_ys[0])))
    if not bool(jnp.allclose(ordinary_ys[0], grouped_ys[0], rtol=rtol, atol=atol)):
        raise AssertionError(
            f"{route_kind} one-group trajectory mismatch: {trajectory_error}"
        )

    ordinary_value, ordinary_grad = _sync(
        ordinary["reverse_executable"](ordinary["argument"])
    )
    grouped_value, grouped_grad = _sync(
        grouped["reverse_executable"](grouped["argument"])
    )
    if not bool(jnp.allclose(ordinary_value, grouped_value, rtol=rtol, atol=atol)):
        raise AssertionError(f"{route_kind} one-group loss mismatch")
    if not bool(jnp.allclose(ordinary_grad, grouped_grad, rtol=rtol, atol=atol)):
        raise AssertionError(f"{route_kind} one-group gradient mismatch")

    def relative_error(left, right):
        scale = jnp.maximum(jnp.maximum(jnp.abs(left), jnp.abs(right)), 1e-30)
        return float(jnp.abs(left - right) / scale)

    return {
        "route": route_kind,
        "max trajectory error": trajectory_error,
        "loss relative error": relative_error(ordinary_value, grouped_value),
        "gradient relative error": relative_error(ordinary_grad, grouped_grad),
    }


def run_group_scaling_benchmark(
    *,
    n_nodes=256,
    groups: Sequence[int] = (1, 2, 4, 8),
    route_kinds: Sequence[str] = ROUTE_KINDS,
    steps=1000,
    dt=0.05,
    block_size=100,
    repeats=5,
    warmup=2,
    seed=0,
    device_platform="cpu",
) -> tuple[pd.DataFrame, pd.DataFrame]:
    """Run parity checks and the fixed-work, interleaved scaling sweep."""
    groups = tuple(int(value) for value in groups)
    route_kinds = tuple(route_kinds)
    if n_nodes < 1 or steps < 1 or repeats < 1 or warmup < 0:
        raise ValueError("nodes, steps, and repeats must be positive; warmup >= 0")
    if any(value < 1 or value > n_nodes for value in groups):
        raise ValueError("every heterogeneous group count must be in [1, n_nodes]")
    if 1 not in groups:
        raise ValueError("groups must include 1 for homogeneous parity checks")
    unknown_routes = set(route_kinds) - set(ROUTE_KINDS)
    if unknown_routes:
        raise ValueError(f"unknown route kinds {sorted(unknown_routes)}")
    if block_size is not None and block_size < 1:
        raise ValueError("block_size must be positive or None")

    devices = jax.devices(device_platform)
    if not devices:
        raise ValueError(
            f"no JAX device is available for platform {device_platform!r}"
        )
    device = devices[0]
    rows = []
    parity_rows = []
    with jax.default_device(device):
        instant_graph, delayed_graph = _benchmark_graphs(
            n_nodes, dt, jax.random.key(seed)
        )
        for route_kind in route_kinds:
            graph = delayed_graph if route_kind == "delayed" else instant_graph
            cases = {}
            for group_count in (0, *groups):
                implementation, primal, gain = _benchmark_case(
                    n_nodes=n_nodes,
                    n_groups=group_count,
                    route_kind=route_kind,
                    graph=graph,
                    steps=steps,
                    dt=dt,
                    block_size=block_size,
                )
                key = (implementation, group_count)
                cases[key] = _compile_benchmark_case(primal, gain)

            parity_rows.append(_parity_check(cases, route_kind))
            samples = _measure_interleaved(cases, repeats, warmup)
            for (implementation, group_count), case in cases.items():
                measurements = {
                    "forward_wall_s": _summarize_samples(
                        samples["forward"][(implementation, group_count)]
                    ),
                    "reverse_wall_s": _summarize_samples(
                        samples["reverse"][(implementation, group_count)]
                    ),
                }
                for metric, value in case.items():
                    if metric in {
                        "argument",
                        "forward_executable",
                        "reverse_executable",
                    }:
                        continue
                    measurements[metric] = {
                        "value": float(value),
                        "q25": float(value),
                        "q75": float(value),
                    }
                for metric, summary in measurements.items():
                    rows.append(
                        {
                            "implementation": implementation,
                            "group_count": group_count,
                            "route_kind": route_kind,
                            "metric": metric,
                            **summary,
                            "unit": "s" if metric.endswith("_s") else "count",
                            "n_nodes": n_nodes,
                            "steps": steps,
                            "dt": dt,
                            "block_size": block_size,
                            "model": "JansenRit",
                            "device": str(device),
                            "jax_version": jax.__version__,
                        }
                    )
            del cases
            jax.clear_caches()
    return pd.DataFrame(rows), pd.DataFrame(parity_rows)


@cache("group_scaling_interleaved_v050", redo=False)
def calculate_group_scaling_benchmark():
    # Keep workload options inside the cached function so changing one
    # invalidates the source-stamped cache. Plot-only edits reuse it.
    benchmark_options = {
        "n_nodes": 256,
        "groups": (1, 2, 4, 8),
        "route_kinds": ROUTE_KINDS,
        "steps": 1000,
        "dt": 0.05,
        "block_size": 100,
        "repeats": 5,
        "warmup": 2,
    }
    return run_group_scaling_benchmark(**benchmark_options)


scaling, parity = calculate_group_scaling_benchmark()
print("Homogeneous / one-group parity checks:")
print(parity.to_string(index=False))
shown_metrics = {
    "forward_wall_s": "Forward wall time",
    "reverse_wall_s": "Value + gradient wall time",
    "reverse_compile_s": "Value + gradient compile time",
}
baseline = (
    scaling[
        (scaling.implementation == "homogeneous")
        & scaling.metric.isin(shown_metrics)
    ]
    .set_index(["route_kind", "metric"])
    .value
)
relative = scaling[
    (scaling.implementation == "heterogeneous")
    & scaling.metric.isin(shown_metrics)
].copy()
relative["relative_to_homogeneous"] = [
    value / baseline.loc[(route_kind, metric)]
    for value, route_kind, metric in zip(
        relative.value, relative.route_kind, relative.metric
    )
]
for quantile in ("q25", "q75"):
    relative[f"{quantile}_relative_to_homogeneous"] = [
        value / baseline.loc[(route_kind, metric)]
        for value, route_kind, metric in zip(
            relative[quantile], relative.route_kind, relative.metric
        )
    ]

fig, axes = plt.subplots(1, 3, figsize=(12, 3.4), sharex=True)
for axis, (metric, title) in zip(axes, shown_metrics.items()):
    selected = relative[relative.metric == metric]
    for route_kind in ROUTE_KINDS:
        values = selected[selected.route_kind == route_kind]
        values = values.sort_values("group_count")
        axis.plot(
            values.group_count,
            values.relative_to_homogeneous,
            color=ROUTE_COLORS[route_kind],
            marker="o",
            label=route_kind,
        )
        axis.fill_between(
            values.group_count,
            values.q25_relative_to_homogeneous,
            values.q75_relative_to_homogeneous,
            color=ROUTE_COLORS[route_kind],
            alpha=0.15,
            linewidth=0,
        )
    axis.axhline(1.0, color="black", linestyle="--", linewidth=1)
    axis.set_title(title)
    axis.set_xlabel("heterogeneous groups")
    axis.grid(alpha=0.25)
axes[0].set_ylabel("relative to homogeneous")
axes[-1].legend(frameon=False)
fig.tight_layout()
plt.show()
Cache stored here: /home/marius/Documents/Projekte/tvboptim/docs/network_dynamics/cache/./heterogeneous_networks_benchmark
Loading group_scaling_interleaved_v050 from cache, last modified 2026-08-10 14:21:12.406114
Homogeneous / one-group parity checks:
        route  max trajectory error  loss relative error  gradient relative error
instantaneous              0.000244         0.000000e+00             1.169035e-07
      delayed              0.000244         8.665525e-08             7.617908e-08
Figure 5: Group-count scaling at fixed total Jansen–Rit node work, normalized to the matching homogeneous network. Runtime bands show the interquartile range across interleaved measurements.

The curve in Figure 5 should rise as separately lowered vector fields and signal packing fragment the traced program, although each route still performs one transport. Its slope depends on device, JAX/XLA version, node count, and group widths. If only the plot changes, @cache reuses the measured table. Workload-option edits invalidate the source-stamped cache; after changing a benchmark helper, bump the cache key or delete cache/heterogeneous_networks_benchmark before rendering.

NoteWhy delayed reverse time can fall below 1×

On the reference CPU, the one-group heterogeneous delayed reverse pass can be faster than the homogeneous baseline. Dedicated interleaved timings ruled out compile time and asynchronous dispatch, and the difference disappeared with nearest-neighbor delays. Profiling linear interpolation showed that CPU XLA fused the upstream dynamics adjoint into two dense interpolation-cotangent kernels in the homogeneous path. The heterogeneous route instead materialized the node-wise cotangent before those kernels; reproducing that materialization internally closed the timing gap without changing trajectories or gradients.

This is a backend- and compiler-dependent optimization opportunity, not an expected semantic advantage of heterogeneous networks. GPU fusion may behave differently, and the candidate change still needs broader gradient, graph, and backend testing before it belongs in the implementation.