- Create a dedicated Conda environment that includes:
GenerativeNetworkModels(the GNM toolbox)jupyter(so that Quarto can execute Python chunks during render)- Plotting libraries (e.g.,
matplotlib,nilearn,networkx) - Any other dependencies needed across all tutorials
- We will NOT use real data! All brain networks used throughout the tutorials will be generated using the GNM toolbox itself.
- Conceptual intro to brain networks, graph theory, and connectivity matrices.
- Under the hood (without showing the code overtly), we produce two figures:
- A 3D brain connectivity plot using
nilearn, displaying the GNM default network with default coordinates. - A connectivity matrix of the same data.
- A 3D brain connectivity plot using
- These figures are embedded as images — the reader sees the visuals but not the code that generated them.
- Install Python, set up Conda environment,
pip install GenerativeNetworkModels. - Callout box briefly explaining what GNMs are (simple rules to create artificial networks mimicking real brains — full details later).
- Load the default built-in network using
gnm.defaults.get_binary_network(). - Now we show the script! We reproduce the same connectivity matrix from the Introduction tutorial, but this time the reader sees and runs the code themselves.
- Introduce a general scientific question that will drive the rest of the tutorial series.
- Scenario: We have 20 brains, and we want to investigate whether brain network organisation changes depending on early life stress.
- Each brain has an associated early life stress score from a real questionnaire (we need to identify which one — e.g., the Childhood Trauma Questionnaire, CTQ, or the Adverse Childhood Experiences, ACE).
- Frame the question: Do individuals with higher early life stress show different network properties?
- This question will be tackled using different approaches across the following tutorials.
- Before analysing the networks, we must check the data is usable!
- Threshold all networks to the same density (so they are comparable).
- Check: are the networks fully connected? Identify and discard any that are not.
- Any other quality-control steps.
- First approach to the research question: look at topology metrics and see if they correlate with early life stress.
- Compute network metrics for each of the 20 brains (e.g., clustering coefficient, path length, modularity, degree distribution, betweenness centrality, etc.).
- Possibly run a PCA on the metrics to show that they naturally cluster into integration vs segregation dimensions.
- Build regression models in R to test whether topology metrics (or PCA components) correlate with early life stress scores.
- We got very weak correlations from topology alone — but there is more we can do!
- Introduce the idea of Generative Network Models: finding a parsimonious, small set of parameters that captures complex brain topology.
- Explain what a GNM is conceptually: using simple wiring rules to grow artificial networks that look like real brains.
- Introduce the two key parameters:
- eta (η): controls the spatial/distance penalty (how much wiring cost matters).
- gamma (γ): controls the topological preference (e.g., homophily — connecting to nodes with similar existing connectivity).
- Explain what each parameter does and how they interact to shape the generated network.
- Now that we understand GNMs, we apply them!
- Perform a binary sweep over the eta-gamma parameter space.
- Define the energy function (how we measure how close a generated network is to a real one).
- Identify the lowest energy model (the parameter combination that best reproduces the observed brain network).
- Interpret the results at group level: what do the best-fitting eta and gamma values tell us about how brains are wired?
- Move from group-level to individual-level parameter estimation.
- Perform a second sweep that is:
- More focused / fine-grained (narrower parameter range around the group optimum).
- Run over multiple runs to ensure robust individual estimates.
- Extract individual-level eta and gamma estimates for each of the 20 brains.
- Correlate these individual parameter estimates with early life stress scores.
- Interpret the results: what do individual differences in wiring rules tell us?
- Speculate on links to cognition: how might differences in eta/gamma relate to cognitive outcomes?
Big idea: We've seen that brains are networks. We've built generative models to grow brain-like networks. Now we ask: can we build artificial networks that learn — and do they end up looking like real brains?
Architecture note (important for planning): We use a Leaky Recurrent Neural Network (RNN) throughout this section, not a feedforward network. The key reason: the recurrent weight matrix
W_recis alreadyN×N, making it a direct connectivity matrix with no tricks needed. This mirrors the architecture used in state-of-the-art brain-like ANN research (Yang et al. 2019; Khona et al. 2023), but built here from scratch — across several short tutorials (task → model → training) — with no extra dependencies beyond what is already ingnm_box(torch,numpy,matplotlib).
Rendering note (important for authoring): Training an RNN live at render time would make every Quarto build slow and non-deterministic (different weights each build → drifting figures). Follow the same approach as the brain networks in Sections 1–2: train once in a standalone script, save the trained weights (and the loss/accuracy history) to disk, and have the tutorials load those artifacts for plotting. Set a fixed random seed (
torch.manual_seed,np.random.seed) in the training script for reproducibility. The training-loop code is still shown in the Training tutorial so the reader understands it; it just isn't re-executed on every render.
Dependencies: No new packages required. Everything runs in the existing
gnm_boxconda environment.
Goal: Bridge from brain networks to RNNs conceptually — show that the architecture is already familiar before any code is written.
Conceptual walkthrough: - Recap: brain neurons receive inputs from many other neurons, integrate those signals over time, and fire if the integrated input crosses a threshold - Real neurons don't reset instantly — they have a membrane time constant: past activity decays gradually - The leaky integration formula captures this:
h(t+1) = (1 - alpha) * h(t) + alpha * phi( W_rec * h(t) + W_in * x(t) )
h(t): the activity of all N hidden units at time t — a snapshot of the network's internal stateW_rec: the N×N recurrent weight matrix — who influences whom (this is the connectivity matrix!)W_in: input weights (stimulus arriving from outside, like thalamic input)alpha = dt/tau: the leak rate, set by the membrane time constant tau (~100ms in cortex)phi: activation function (ReLU) — neurons fire positively above threshold- The network processes a stimulus one timestep at a time — it has memory built in via
h(t) - Compare to GNMs: GNMs grew a network forward in time using wiring rules; RNNs update a network's activity forward in time using learned weights. Both are defined by the structure of their N×N connectivity matrix.
The key insight to emphasise: - W_rec is an N×N matrix where entry W_ij is the synaptic weight from unit j to unit i - This is structurally identical to the brain connectivity matrices studied since Tutorial 1 - We are not going to look into a black box — the connectivity matrix IS the model, and it's the same object we already know how to analyse
Show a diagram: - Input layer (stimulus) → hidden layer (N recurrent units, fully connected via W_rec) → output layer (decision) - Annotate W_rec as the connectivity matrix; explicitly draw the parallel to brain networks from Section 1
Goal: Introduce cognitive tasks and build a Go/No-Go generator from scratch (no external dependencies). This tutorial is only about the task; the network that solves it comes next.
Stress that many tasks exist: reference the neurogym library and the multi-task work of Yang et al. (2019). We build ours from scratch so every moving part is visible. Tie Go/No-Go to impulse control / executive function, and back to the early-life-stress theme.
Task design (from scratch, but matching the neurogym/Poli period + timing conventions): - Four periods, 500 ms each at dt = 100 → T = 20 timesteps: fixation → stimulus → delay → decision (same structure used in the lab's neurogym tasks). - 3 input channels: a fixation cue (on until the decision period), a Go cue, and a No-Go cue (exactly one of the two is on during the stimulus period). - 1 output: target = 1 during the decision period on Go trials, 0 otherwise (including all of the No-Go trial). - mask marks the decision period — we score the network only there. - n_input = 3 (not 1).
import numpy as np
def generate_batch(batch_size=32, dt=100):
# period durations in milliseconds
fixation, stimulus, delay, decision = 500, 500, 500, 500
# turn milliseconds into a number of timesteps
n_fix, n_stim, n_del, n_dec = (fixation // dt, stimulus // dt,
delay // dt, decision // dt)
T = n_fix + n_stim + n_del + n_dec # total timesteps
stim_on = n_fix
stim_off = n_fix + n_stim
dec_on = n_fix + n_stim + n_del
# 3 input channels: 0 = fixation cue, 1 = Go cue, 2 = No-Go cue
x = np.zeros((T, batch_size, 3))
y = np.zeros((T, batch_size, 1)) # target response
mask = np.zeros((T, batch_size)) # when we score the network
labels = np.random.randint(0, 2, batch_size) # 1 = Go, 0 = No-Go
go, nogo = labels == 1, labels == 0
x[:dec_on, :, 0] = 1 # fixation cue until decision
x[stim_on:stim_off, go, 1] = 1 # Go cue on Go trials
x[stim_on:stim_off, nogo, 2] = 1 # No-Go cue on No-Go trials
y[dec_on:, go, 0] = 1 # respond only on Go decision
mask[dec_on:, :] = 1 # score the decision period
return x, y, mask, labelsPlot to produce: - A single example trial: the input signal x, the target y, and the shaded response window, for both a Go and a No-Go trial side by side. This makes the task structure (stimulus → delay → respond) concrete before any network exists.
Closing hook: We now have a task. But who solves it? In the next tutorial we build the network — and we'll see it's the same N×N connectivity matrix we've worked with since Section 1.
Goal: Implement the Leaky RNN from scratch in PyTorch and run it — on an untrained network — to see that it cannot yet do the task. This sets up the need for training in the following tutorial.
Leaky RNN implementation (~60 lines of PyTorch):
import torch
import torch.nn as nn
class LeakyRNN(nn.Module):
def __init__(self, n_input, n_hidden, n_output, alpha=0.2):
super().__init__()
self.alpha = alpha
self.n_hidden = n_hidden
self.W_rec = nn.Linear(n_hidden, n_hidden, bias=False) # the N×N connectivity matrix
self.W_in = nn.Linear(n_input, n_hidden, bias=True)
self.W_out = nn.Linear(n_hidden, n_output, bias=True)
def forward(self, x):
# x shape: (T, batch, n_input)
h = torch.zeros(x.size(1), self.n_hidden)
outputs = []
for t in range(x.size(0)):
pre = self.W_rec(h) + self.W_in(x[t])
h = (1 - self.alpha) * h + self.alpha * torch.relu(pre)
outputs.append(self.W_out(h))
return torch.stack(outputs), h # (T, batch, n_output)model.W_rec.weightis the N×N connectivity matrix, accessible at any point during or after training- Hidden size: N = 100 — deliberately matching the 100-node brain networks used throughout the series
- Walk through
forwardline by line: it is just the leaky-integration formula from the previous tutorial, applied once per timestep in a loop. Connect each line back to the equationh(t+1) = (1 - alpha) * h(t) + alpha * phi(W_rec h + W_in x).
The untrained network fails (the motivating hook): Before any training, the weights are random, so the network has no idea what to do. Show this explicitly — it makes the next tutorial's payoff land:
model = LeakyRNN(n_input=3, n_hidden=100, n_output=1)
x, y, mask, labels = generate_batch(batch_size=200)
outputs, _ = model(torch.tensor(x, dtype=torch.float32))
# Did it respond correctly on Go vs No-Go? (final-timestep output thresholded at 0.5)
pred = (outputs[-1, :, 0] > 0.5).float().numpy()
print(f"Untrained accuracy: {(pred == labels).mean():.2f}") # ~0.50 — pure chance!- The untrained network sits at ~50% — it is guessing. Emphasise: the architecture is in place, but the weights are meaningless until the network learns them.
Closing hook: The network has the right shape but the wrong numbers. How does it go from random weights to weights that solve the task? That is learning — the subject of the next tutorial.
Goal: Introduce how a network learns (gradient descent) and implement the training loop that turns the random W_rec into one that solves the Go/No-Go task.
How does the network learn? (the key new concept — give it explicit, gentle treatment)
This is the one genuinely new idea in the whole section, and it must not be assumed. In Section 2 the GNM toolbox did all the fitting internally, so the reader has never seen gradient descent. Explain the mechanism in plain language before showing any training code:
- The network starts with random
W_rec,W_in,W_out, so at first it produces nonsense on the task. - We summarise "how wrong it is" with a single number, the loss (here, the squared difference between its output and the correct response, counted only during the response window).
- The key step: PyTorch automatically works out, for every single weight, which direction to nudge it to make the loss a little smaller. This is backpropagation / gradient descent — the "gradient" is just the slope telling each weight which way is "downhill" toward lower error.
- We take one small step in that direction (the step size is the learning rate) and repeat thousands of times. Slowly, the random matrix turns into a matrix that solves the task.
- Crucially: we never tell the network what
W_recshould be — we only tell it whether it got the task right, and the connectivity emerges on its own. That is exactly why inspecting the learnedW_recin the next tutorial is interesting. - Callback to Section 2: a GNM grows a network forward using fixed wiring rules; here the network learns its wiring by repeatedly nudging weights to reduce error. Same object (
W_rec), two opposite routes to it (grow forward vs. train backward).
The training loop (show this code — it is the part the reader actually runs):
import torch.optim as optim
model = LeakyRNN(n_input=3, n_hidden=100, n_output=1)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
losses, accuracies = [], []
for step in range(2000):
# --- get a fresh batch of trials ---
x, y, mask, labels = generate_batch(batch_size=32)
x = torch.tensor(x, dtype=torch.float32) # numpy -> torch
y = torch.tensor(y, dtype=torch.float32)
mask = torch.tensor(mask, dtype=torch.float32)
# --- run the network forward over all timesteps ---
outputs, _ = model(x)
# --- measure error ONLY in the response window (that is what `mask` does) ---
loss = (((outputs - y) ** 2).squeeze(-1) * mask).sum() / mask.sum()
# --- the three lines that make learning happen ---
optimizer.zero_grad() # clear the gradients left over from the previous step
loss.backward() # backprop: compute the downhill direction for every weight
optimizer.step() # nudge every weight one small step downhill
# --- track progress for the plots ---
losses.append(loss.item())
pred = (outputs[-1, :, 0] > 0.5).float()
accuracies.append((pred == torch.tensor(labels, dtype=torch.float32)).float().mean().item())Walk through the three-line core (zero_grad → backward → step) explicitly — this is the heart of all deep learning and the reader is meeting it for the first time. Everything else in the loop is bookkeeping (make a batch, run forward, score it, record progress).
Glossary — define each term as it appears in the code above:
- Timestep: one time point; the network updates
h(t)at each step insidemodel(x)— unlike a feedforward network, it processes a sequence. - Trial / batch: a set of simultaneous trials;
batch_sizesets how many trials are averaged per weight update. - Training step / iteration: one pass through the loop — one batch in, one weight update out (we run 2000 of them).
- Epoch (related term worth a sentence): in a dataset of fixed size, one full pass over all the data; because we generate fresh trials on the fly, there is no fixed dataset, so here we simply count training steps.
- Learning rate (
lr=1e-3): the step size; too large → unstable training, too small → painfully slow convergence. - Loss (masked MSE): how wrong the network is, counted only when it should be responding.
- Accuracy: fraction of trials whose final output lands on the correct side of 0.5.
Plots to produce: - Loss curve and accuracy curve over training steps — the network improves over time - Example trial: plot h(t) for a handful of units, overlaid with the input stimulus and the network's output response - This makes the "network processes time" intuition concrete before we look inside at weights
Goal: The "aha" moment — the recurrent weight matrix is already, structurally, a brain connectivity matrix.
The reveal: - Extract model.W_rec.weight.detach().numpy() — a 100×100 matrix - Plot it as a heatmap — it looks exactly like the connectivity matrices from Tutorial 1 - The analogy is now exact, not approximate: - Brain: entry (i, j) = structural connection from region j to region i - Leaky RNN: entry (i, j) = synaptic weight from unit j to unit i - Both tell us: who influences whom, and how strongly - No binarisation or Gram-matrix tricks needed for the visual — the N×N structure is already there
Continuous vs weighted vs binary: - Recall from Sections 1–2 that the raw brain networks are weighted (streamline counts), and we thresholded then binarised them for the GNM analysis - W_rec is likewise continuous (varying, and crucially signed, weight strengths) - So W_rec is most directly analogous to a weighted brain network (white matter tract strength), before binarisation - For the topological-metric comparison later, we apply the same recipe used in the Preprocessing tutorial: threshold |W_rec| to the same density as the brain networks, then binarise — putting RNN and brain on the same footing
Visualisations: - Heatmap of raw W_rec (signed: blue = negative/inhibitory weights, red = positive/excitatory weights) - Heatmap of |W_rec| (unsigned weight magnitude) - Binarised version (top X% absolute weights) placed side by side with a real brain connectivity matrix
Conceptual bridge to GNMs: - In Section 2 we asked: what wiring rules generated the brain network? - Here we ask: what wiring did the network develop on its own, just by learning the task? - Is task-driven learning alone enough to produce brain-like connectivity? Let's find out.
Goal: Add two biologically motivated constraints to the RNN training — a distance penalty (the direct analogue of GNM's η) and Dale's Law (excitatory/inhibitory sign separation). Train three models and compare their connectivity and performance.
The three models: 1. Basic RNN — standard training, no biological constraints 2. Distance RNN — distance penalty on W_rec (the λ/η analogue) 3. Dale RNN — distance penalty + Dale's Law (excitatory/inhibitory constraint)
-
Use the same MNI brain coordinates from the GNM default network (100 regions)
-
Assign each hidden unit i to brain region i — the same distance matrix
Dused in Sections 1 and 2 -
Add a distance regularisation term to the loss:
L_total = L_task + lambda * sum_ij( |W_rec_ij| * D_ij )Large weights between spatially distant units are penalised — exactly what η does in the GNM
-
λ is the direct analogue of η: higher λ → stronger distance penalty → more local, short-range connectivity
-
Implementation: one extra line in the training loop
dist_penalty = lambda_dist * (torch.abs(model.W_rec.weight) * D_tensor).sum()
loss = task_loss + dist_penalty- In real cortex, ~80% of neurons are excitatory (can only send positive signals to others) and ~20% are inhibitory (can only send negative signals)
- This is Dale's Principle: each neuron has a fixed sign for all its outgoing connections
- Implementation: assign each of the 100 units a sign at initialisation; enforce this sign after every gradient step
# Assign signs: 80 excitatory (+1), 20 inhibitory (-1)
signs = torch.ones(n_hidden)
signs[int(0.8 * n_hidden):] = -1
# After each gradient step, project weights back to sign-constrained space:
with torch.no_grad():
model.W_rec.weight.data = torch.abs(model.W_rec.weight.data) * signs.unsqueeze(0)- This constraint changes the structure of
W_recfundamentally: some columns must be entirely non-negative (excitatory units), others entirely non-positive (inhibitory units) - It is biologically realistic, reduces model degrees of freedom, and produces a visually distinctive sign structure in the weight matrix
What to compare across the three models: - Performance: accuracy curves over training — does adding constraints hurt task performance? - W_rec heatmaps: visual side-by-side comparison of the three matrices - Edge length distributions: does the distance RNN shift toward shorter connections? - Sign structure: does the Dale RNN show the column-wise excitatory/inhibitory organisation visible in real cortex?
Expected qualitative results: - Basic RNN: high accuracy, arbitrary connectivity (long-range weights, mixed signs throughout) - Distance RNN: similar accuracy, connectivity shifted toward shorter-range connections - Dale RNN: slightly lower accuracy (more constrained), but W_rec structure that visually and topologically resembles real brain organisation more closely
Conceptual message: - Biological constraints do not necessarily hurt performance — the brain achieves high cognitive performance despite (or because of?) these constraints - The distance penalty is the exact same wiring principle that GNMs use to grow realistic brain networks; here the network learns to respect it during task training
Goal: Quantitatively compare the connectivity structure of all three trained RNNs against real brain networks using the same metrics from Section 1 — closing the loop of the entire tutorial series.
The four networks to compare: 1. Basic RNN — W_rec binarised to match brain network density 2. Real brain networks — the GNM default network (or the 20 brains from Section 1)
Topological metrics (all introduced in Tutorial: Topology): - Degree distribution: are some units/regions disproportionately connected (hubs)? - Betweenness centrality: which units act as information bottlenecks? - Clustering coefficient: do connected units form dense local clusters (modularity)? - Edge length distribution: how long are the connections on average in MNI space?
KS statistics — callback to the GNM energy function: - Compute the same four KS statistics between each RNN and the real brain networks - This is the exact same energy formula used in Tutorial: Model Fitting (Section 2) - Rank the three models by total KS energy — lower energy = more brain-like connectivity
Visualisations: - Side-by-side heatmaps: Basic | Distance | Dale | Real Brain - Violin plots of each topological metric, one group per network type - Radar/spider plot: all four metrics at once, one polygon per network type — immediate visual comparison of the full profile - Bar chart of KS energy scores across the three RNN models vs real brain baseline
Narrative payoff — closing the series: - Section 1 described brain networks with topology metrics - Section 2 grew brain-like networks using distance and homophily rules (GNMs) - Section 3 trained networks to do a cognitive task and asked whether they develop brain-like connectivity - The answer: task-driven learning alone is not enough — adding the same biological wiring constraints used in GNMs (distance penalty, Dale's Law) pushes the learned connectivity meaningfully closer to real brains - The key message: the brain's wiring constraints are not just metabolic necessities — they shape the kind of network that emerges from learning to perform cognitive tasks, and these constraints are the same whether you grow a network forward (GNM) or train it backward (RNN gradient descent)
- Fit a GNM to the trained RNN weight matrices — what η and γ best describe the Basic vs Dale RNN? Does the Dale RNN produce η/γ values closer to the real brain estimates from Section 2? This is a testable, potentially publishable comparison.
- Add a homophily regulariser (analogous to GNM's γ) — penalise or reward connections between units that share common partners, in addition to the distance penalty
- Train on multiple tasks — does the RNN develop more modular, brain-like connectivity when it must learn several cognitive tasks at once? (The core research question in Yang et al. 2019 and Khona et al. 2023)
- Individual differences — train 20 RNNs with slightly different noise levels (analogous to early life stress) and ask whether the resulting
W_recmatrices show topology differences mirroring the brain differences from Section 1