Core Concepts#
Welcome to BrainTrace! This notebook introduces the core concepts you need to understand before using the library for online learning in recurrent and spiking neural networks.
BrainTrace is built on JAX and brainstate, providing memory-efficient online learning through eligibility trace propagation.
1. What is Online Learning?#
Training recurrent neural networks (RNNs) typically relies on Backpropagation Through Time (BPTT). BPTT unrolls the full computation graph over all time steps before computing gradients:
BPTT stores the entire computation graph across \(T\) time steps, requiring \(O(T)\) memory.
As sequence length grows, memory usage becomes a bottleneck.
Online learning takes a different approach:
Weights are updated at each time step using eligibility traces that summarize the gradient history.
Memory cost is \(O(1)\) per time step (independent of sequence length).
Eligibility traces accumulate the information needed for gradient computation incrementally.
BrainTrace implements online learning via JAX custom primitives. Instead of relying on string-matching or special parameter wrappers, BrainTrace identifies which operations participate in online learning by their primitive type at the JAX IR level. This gives a clean, composable, and JIT-friendly design.
2. Architecture Overview#
BrainTrace is organized as a 4-layer system. Each layer builds on the one below it:
+--------------------------------------------------------------+
| Algorithms D_RTRL / pp_prop / SnAp / UORO / ThreeFactor |
| DNI / EProp / OSTL... (or an ETraceConfig) |
| trace update + custom_vjp for jax.grad |
+--------------------------------------------------------------+
| Executor ETraceGraphExecutor |
| forward pass + Jacobian computation |
+--------------------------------------------------------------+
| Compiler compile_etrace_graph() |
| jaxpr walk -> find primitives -> connect to |
| hidden states |
+--------------------------------------------------------------+
| Primitives braintrace.matmul / element_wise / conv |
| & Functions JAX custom primitives (thin markers) |
+--------------------------------------------------------------+
How it works:
Primitives & Functions (bottom layer): You call
braintrace.matmul(x, w)in your model. Under the hood, this binds a JAX custom primitive that acts as a marker — the actual computation is standard JAX (x @ w).Compiler: When you call
braintrace.compile(...), BrainTrace walks the JAX intermediate representation (jaxpr), finds all ETP primitives, and connects each one to its associated hidden states and parameters. (compile_graph()is the lower-level entry point this uses.)Executor: During the forward pass, the executor computes the model output and the Jacobians needed for eligibility trace updates.
Algorithms (top layer): the chosen algorithm uses the executor outputs to maintain eligibility traces and provide correct gradients via
custom_vjp. The named algorithms are thin factories over coordinates in theETraceConfigaxis space, so a rule with no name is as constructible as one with a name.
3. Key Concept: Primitive-Based Parameter Selection#
The central design idea of BrainTrace is that the operation you use determines whether a parameter participates in online learning:
What you write |
Effect |
|---|---|
|
|
|
|
There is no need for special parameter classes. All weights are plain brainstate.ParamState. The choice of operation is what matters.
Here is a concrete example:
import os
os.environ.setdefault("JAX_PLATFORMS", "cpu")
import jax
import jax.numpy as jnp
import brainstate
import braintrace
class SimpleRNN(brainstate.nn.Module):
def __init__(self, n_in, n_rec, n_out):
super().__init__()
self.w_in = brainstate.ParamState(brainstate.random.randn(n_in, n_rec) * 0.01)
self.w_rec = brainstate.ParamState(brainstate.random.randn(n_rec, n_rec) * 0.01)
self.w_out = brainstate.ParamState(brainstate.random.randn(n_rec, n_out) * 0.01)
self.h = brainstate.ShortTermState(jnp.zeros(n_rec))
def update(self, x):
# Regular matmul: w_in excluded from online learning
inp = x @ self.w_in.value
# ETP matmul: w_rec included in online learning
rec = braintrace.matmul(self.h.value, self.w_rec.value)
self.h.value = jax.nn.tanh(inp + rec)
# Regular matmul: w_out excluded from online learning
return self.h.value @ self.w_out.value
In the model above:
w_inandw_outuse standardx @ w— they receive only instantaneous gradients (no temporal credit assignment through eligibility traces).w_recusesbraintrace.matmul(h, w_rec)— the compiler will automatically maintain eligibility traces for this weight, enabling gradient computation that accounts for temporal dependencies.
All three weights are the same type (brainstate.ParamState). The operation is the only difference.
4. Using braintrace.nn Modules#
While you can use primitives directly (as shown above), BrainTrace provides pre-built layers in the braintrace.nn module that already use ETP primitives internally. These are drop-in replacements for standard brainstate.nn layers:
Module |
Description |
|---|---|
|
Dense linear layer using |
|
Linear layer with sign-constrained weights (E/I networks) |
|
Weight-standardized linear layer |
|
Linear layer with sparse connectivity (uses |
|
Low-rank adapter layer (uses |
|
Convolutional layers using |
|
Recurrent cells with ETP-aware gates |
|
Rate-coded SNN readout |
|
Normalisation layers |
For the matching low-level API, the user-facing primitive functions are:
braintrace.matmul(x, w, bias=None)– dense matrix multiplicationbraintrace.element_wise(weight, weight_fn=...)– element-wise weight ops (gating, learnable thresholds)braintrace.conv(x, kernel, bias, ...)– convolutionbraintrace.sparse_matmul(x, weight_data, *, sparse_mat, bias=None)– sparse matmulbraintrace.lora_matmul(x, B, A, *, alpha=1.0, bias=None)– LoRA decomposition
Use the braintrace.nn layers when you can; reach for the primitive functions when you need a custom layer that participates in online learning.
class GRUNet(brainstate.nn.Module):
def __init__(self, n_in, n_rec, n_out):
super().__init__()
self.rnn = braintrace.nn.GRUCell(n_in, n_rec)
self.readout = braintrace.nn.Linear(n_rec, n_out)
def update(self, x):
return self.readout(self.rnn(x))
The GRUCell internally uses braintrace.matmul for its weight operations, so all its recurrent parameters automatically participate in online learning. The Linear readout also uses ETP primitives, but the compiler will detect that it is not connected to any hidden state and handle it appropriately.
5. Online Learning in 3 Steps#
Using BrainTrace for online learning follows a simple three-step workflow:
Define the model using
braintrace.nnmodules or manual ETP primitives.Compile with
braintrace.compile— one call initialises states, builds the eligibility-trace graph, and returns a ready learner.Train with standard JAX gradient computation — eligibility traces are updated inside the wrapped model call.
Here is the complete workflow:
# Step 1: Define a reproducible model and one supervised example
brainstate.random.seed(11)
model = GRUNet(3, 8, 1)
input_sample = jnp.array([[0.2, -0.1, 0.3]])
target = jnp.array([[0.5]])
# Step 2: Compile — initialises states, builds the trace graph, returns a ready learner.
# The example input carries the batch axis: shape (batch_size, n_in) = (1, 3).
learner = braintrace.compile(model, braintrace.D_RTRL, input_sample, batch_size=1)
# Step 3: Differentiate, update, and compare clean before/after states
import braintools
weights = model.states(brainstate.ParamState)
optimizer = braintools.optim.SGD(lr=0.1)
optimizer.register_trainable_weights(weights)
def reset_online_state():
brainstate.nn.reset_all_states(model, batch_size=1)
learner.reset_state(batch_size=1)
input_sequence = input_sample[None, ...] # (time=1, batch=1, features=3)
target_sequence = target[None, ...]
def step_loss(x, y):
prediction = learner(x)
return jnp.mean((prediction - y) ** 2), prediction
def evaluate_online():
reset_online_state()
predictions = learner.etrace_evolve(input_sequence, return_outputs=True)
prediction = predictions[-1]
return jnp.mean((prediction - target) ** 2), prediction
initial_loss, initial_prediction = evaluate_online()
parameters_before = jax.tree.map(lambda value: value.copy(), weights.to_dict_values())
reset_online_state()
grads, _, _ = learner.etrace_grad(
input_sequence, target_sequence, step_fn=step_loss,
has_aux=True, return_value=True,
)
optimizer.update(grads)
final_loss, final_prediction = evaluate_online()
parameter_differences = jax.tree.map(
lambda after, before: after - before,
weights.to_dict_values(),
parameters_before,
)
parameter_change = jnp.sqrt(sum(
jnp.sum(delta ** 2) for delta in jax.tree.leaves(parameter_differences)
))
print('initial loss:', float(initial_loss))
print('final loss:', float(final_loss))
print('initial prediction:', initial_prediction)
print('final prediction:', final_prediction)
print('parameter change:', float(parameter_change))
initial loss: 0.24410992860794067
final loss: 0.2132834494113922
initial prediction: [[0.00592517]]
final prediction: [[0.03817381]]
parameter change: 0.05644570291042328
What happens under the hood:
braintrace.compile(model, braintrace.D_RTRL, x0, batch_size=1)initialises all model states, traces the model through JAX, identifies all ETP primitives, and builds the eligibility trace computation graph — all in one call.learner.etrace_evolve(...)drives a sequence without a loss gradient while advancing both hidden state and eligibility traces.learner.etrace_grad(..., step_fn=step_loss)drives the supervised sequence and accumulates online gradients;step_lossowns the single learner call for each step.
Code comparison with BPTT#
A fair comparison holds model structure, initialization, inputs, targets, loss, and reset policy fixed. The online path differentiates one step at a time and accumulates gradients during the forward drive, which learner.etrace_grad performs. The BPTT path first constructs the full sequence loss and differentiates through the complete for_loop.
Online learning |
BPTT |
|---|---|
|
|
Eligibility state carries temporal information forward |
Reverse mode traverses the unrolled sequence |
No stored trajectory for the online update |
Stores or rematerializes the sequence trajectory |
Matching forward losses establish that the comparison starts from the same model and state. They do not imply that every online estimator must equal the BPTT gradient: equality depends on the algorithm, VJP mode, comparison window, and the regime guaranteed by its mathematics.
brainstate.random.seed(23)
online_model = GRUNet(3, 8, 1)
brainstate.random.seed(23)
bptt_model = GRUNet(3, 8, 1)
sequence_inputs = jnp.array([
[[0.2, -0.1, 0.3]],
[[0.1, 0.0, -0.2]],
[[-0.3, 0.2, 0.1]],
])
sequence_targets = jnp.array([[[0.5]], [[0.1]], [[-0.2]]])
online_learner = braintrace.compile(
online_model, braintrace.D_RTRL, sequence_inputs[0], batch_size=1
)
bptt_weights = bptt_model.states(brainstate.ParamState)
brainstate.nn.init_all_states(bptt_model, batch_size=1)
def online_step_loss(x, y):
prediction = online_learner(x)
return jnp.mean((prediction - y) ** 2)
def online_sequence_gradient(inputs, targets):
brainstate.nn.reset_all_states(online_model, batch_size=1)
online_learner.reset_state(batch_size=1)
# The scan this replaces accumulated the per-step gradients without
# dividing, which is exactly reduction='sum'. return_value=True keeps the
# per-step losses the comparison below prints.
return online_learner.etrace_grad(
inputs, targets, step_fn=online_step_loss,
reduction='sum', return_value=True,
)
def bptt_sequence_loss(inputs, targets):
brainstate.nn.reset_all_states(bptt_model, batch_size=1)
predictions = brainstate.transform.for_loop(bptt_model, inputs)
return jnp.mean((predictions - targets) ** 2)
online_grads, online_step_losses = online_sequence_gradient(
sequence_inputs, sequence_targets
)
bptt_grads, bptt_loss = brainstate.transform.grad(
bptt_sequence_loss, bptt_weights, return_value=True
)(sequence_inputs, sequence_targets)
print('online forward loss:', float(online_step_losses.mean()))
print('BPTT forward loss:', float(bptt_loss))
online forward loss: 0.10689683258533478
BPTT forward loss: 0.10689682513475418
6. Available Algorithms#
The table lists the concrete algorithms in the current public API. ES_D_RTRL is a compatibility alias of pp_prop, not a separate method.
Algorithm |
Applicable scope |
|---|---|
Parameter-dimensional online gradients for recurrent models; diagonal recurrence is the default scope. |
|
Input/output-factorized traces for memory-constrained recurrent or spiking models; |
|
SNN learning with symmetric or random-feedback learning signals and an optional kappa trace filter. |
|
Recurrent OSTL coordinate retaining coupled within-position recurrence. |
|
Feedforward/without-H OSTL coordinate using an input/output-factorized trace. |
|
Sparse n-step RTRL approximation when the recurrent position graph has an exploitable sparsity pattern. |
|
Unbiased rank-1 random projection of the influence matrix: \(O(P)\) memory, higher variance. Built on |
|
Modulated learning: an external scalar (or per-group) modulator replaces the backpropagated error. Requires |
|
Decoupled neural interfaces: a learned |
Every one of these is a preset over ETraceConfig, which describes a learning rule as a point in a six-axis space (trace_factorization, temporal_recursion, recurrence_scope, learning_signal, trace_filter, update_schedule). braintrace.compile accepts a config wherever it accepts a name, so you are not restricted to the rows above.
7. Summary#
Here is a quick recap of the core concepts:
Concept |
Description |
|---|---|
Online learning |
Update weights at each time step using eligibility traces, achieving \(O(1)\) memory per step |
ETP primitives |
|
Primitive-based selection |
Use an ETP primitive to include a weight; use regular JAX ops to exclude it |
|
Pre-built layers (Linear, GRUCell, LSTMCell, Conv) that use ETP primitives internally |
|
|
Sequence drivers |
|
Algorithm presets |
|
|
The six-axis space every preset above is a point in; pass one to |
8. Next Steps#
Now that you understand the core concepts, explore the following tutorials:
RNN Online Learning: Train a GRU on the copying task using
D_RTRLand compare the workflow with BPTT.SNN Online Learning: Train a spiking network with
pp_prop/ES_D_RTRL.D-RTRL tutorial: Develop the parameter-dimensional estimator around a MiniGRU.
pp_prop tutorial: Develop the input/output-factorized estimator around the same MiniGRU task.
Operators for Online Learning: Learn the five user-facing operations that mark trainable paths.
Custom ETP Primitives: Advanced guidance for extending ETP primitives.
Batching Strategies: Handle batched inputs with online learning.
RNN Compiler Walkthrough: Compile single-layer and stacked recurrent models, inspect their eligibility-trace relations, and compare the compiler decisions.