Graph Compilation & Visualization#
In braintrace, models are compiled into an ETraceGraph – an intermediate representation that captures the
structural relationships between weight parameters, ETP primitives (the operations that connect inputs to
hidden states), and hidden state groups. This compilation step is what enables efficient online learning:
by analyzing the computation graph, braintrace can automatically determine which weights influence which
hidden states, and how eligibility traces should propagate.
The show_graph() method visualizes these relationships, providing a human-readable summary of:
Hidden groups: clusters of hidden states that evolve together (e.g., the membrane potential and adaptation current of a neuron population)
Weight-primitive-hidden connections: which weight parameters are associated with which hidden groups through which ETP primitives
Non-ETP weights: parameters that exist in the model but do not participate in online learning
Understanding the compiled graph is essential for debugging model structure, verifying that the correct parameters are included in online learning, and optimizing model design.
Single-Layer RNN#
We start with the simplest case: a single recurrent layer followed by a linear readout.
The ValinaRNNCell contains one hidden state and one recurrent weight, and the Linear
readout has its own weight that feeds into the output.
import jax
import jax.numpy as jnp
import brainstate
import braintrace
class SingleLayerRNN(brainstate.nn.Module):
def __init__(self, n_in, n_rec, n_out):
super().__init__()
self.rnn = braintrace.nn.ValinaRNNCell(n_in, n_rec)
self.out = braintrace.nn.Linear(n_rec, n_out)
def update(self, x):
return self.out(self.rnn(x))
model = SingleLayerRNN(10, 32, 5)
# braintrace.compile initialises states, compiles the ETP graph, and returns a ready learner.
# We compile for a single unbatched sample (no batch_size), so the hidden state is (32,) and
# the recurrent op is the matrix-vector primitive etp_mv. verbose=2 prints full diagnostics.
learner = braintrace.compile(model, braintrace.D_RTRL, jnp.zeros(10), verbose=2)
learner.show_graph()
The output shows:
Hidden Group 0: the hidden state of the
ValinaRNNCell(path('rnn', 'h'))Associated weight: the recurrent weight inside the RNN cell (
('rnn', 'W', 'weight')), eligibility-traced because it feeds the hidden state through an ETP primitiveNon-etrace weight: the readout weight (
('out', 'weight')).braintrace.nn.Lineardoes use an ETP primitive, but the readout’s output is the network’s final output and never flows back into a hidden state – so the compiler reports it as a non-temporal parameter (still trained, just not through an eligibility trace). A matchinghas no connected hidden stateswarning is emitted at compile time.
This tells us that D_RTRL will maintain an eligibility trace for the recurrent weight,
tracking how it influences the hidden state over time.
Using learner.report — the CompilationReport#
Every learner returned by braintrace.compile carries a CompilationReport at learner.report. It aggregates the compiler’s findings into a single inspectable object so you can programmatically query what was included, what was excluded, and why — without parsing log output.
Key members:
report.counts— summary dict with keyshidden_groups,etrace_weights,excluded_weights,warnings,errorsreport.etrace_weights— list of weight paths that have eligibility tracesreport.excluded_weights— list of(weight path, reason)pairs excluded from online learningreport.dynamic_states— list of non-hidden dynamic-state paths discovered by the compilerreport.diagnostics— full list ofCompilationRecordobjectsreport.show(level)— print a human-readable summary (1= hidden groups + weight lists;2= also raw WARNING/ERROR diagnostics)
# report.show(level) prints a structured summary at the requested verbosity.
# level=1 shows hidden groups, etrace weights, and excluded weights.
learner.report.show(1)
# Programmatic access to the summary counts
print("Counts:", learner.report.counts)
# Which weights participate in online learning?
print("ETrace weights:", learner.report.etrace_weights)
# Which weights were excluded (e.g., non-temporal readouts)?
print("Excluded weights:", learner.report.excluded_weights)
Understanding ETraceGraph#
The compiled graph is an ETraceGraph named tuple with several key fields:
Field |
Type |
Description |
|---|---|---|
|
|
Jaxpr and state mappings extracted from the model |
|
|
Discovered hidden state groups |
|
|
Mapping from hidden state path to its group |
|
|
Weight-primitive-hidden connections |
|
|
Perturbation structure for Jacobian computation |
Each HiddenGroup records a cluster of hidden states that are updated together in one
recurrent step. Each HiddenParamOpRelation records the connection between a weight
parameter and the hidden groups it feeds into through an ETP primitive.
Let’s inspect these programmatically:
graph = learner.graph
print("=== Hidden Groups ===")
for g in graph.hidden_groups:
print(f" Group {g.index}: {g.num_state} state(s), shape {g.varshape}")
print(f" Paths: {g.hidden_paths}")
print("\n=== Weight-Primitive-Hidden Relations ===")
for i, r in enumerate(graph.hidden_param_op_relations):
print(f" Relation {i}:")
# ``trainable_paths`` is a dict {trainable key -> owning ParamState path};
# a single primitive may own several (e.g. {weight, bias}).
print(f" Trainable paths: {r.trainable_paths}")
print(f" Primitive: {r.primitive}")
print(f" Hidden groups: {[g.index for g in r.hidden_groups]}")
print(f"\n=== Perturbation ===")
print(f" Has perturbation: {graph.hidden_perturb is not None}")
The HiddenGroup.num_state property returns the total number of state variables in the group,
and HiddenGroup.varshape returns the shape of each state variable. The
HiddenParamOpRelation.primitive field identifies which ETP primitive connects the weight to
the hidden state – here etp_mv (matrix-vector product); you will also see etp_mm
(matrix-matrix, e.g. under batching) and etp_conv (convolution) in other models.
Two-Layer RNN#
Stacking recurrent layers makes the compiled graph richer: there are more hidden states and
more weights, and the compiler must decide how to group the hidden states. Below we stack two
GRUCell layers and a linear readout, then read the structure straight off show_graph() –
rather than assuming one group per layer, we let the compiler tell us how it grouped things.
class TwoLayerRNN(brainstate.nn.Module):
def __init__(self, n_in, n_rec, n_out):
super().__init__()
self.rnn1 = braintrace.nn.GRUCell(n_in, n_rec)
self.rnn2 = braintrace.nn.GRUCell(n_rec, n_rec)
self.out = braintrace.nn.Linear(n_rec, n_out)
def update(self, x):
h1 = self.rnn1(x)
h2 = self.rnn2(h1)
return self.out(h2)
model2 = TwoLayerRNN(10, 32, 5)
# Compile for a single unbatched sample (no batch_size).
learner2 = braintrace.compile(model2, braintrace.D_RTRL, jnp.zeros(10))
learner2.show_graph()
Notice that:
Both GRU hidden states –
('rnn1', 'h')and('rnn2', 'h')– are collected into a single hidden group. The compiler co-locates coupled hidden states that share the same shape, stacking their per-state Jacobians along one axis; because both layers here use the same recurrent width (n_rec = 32), they land in one group. Give the two layers different widths and the compiler reports two separate groups instead – the Hidden State Management tutorial shows exactly that case (a 16-unit layer feeding an 8-unit layer yields one group each). The practical habit is to read the grouping offshow_graph()rather than assume it.Four weight matrices are eligibility-traced into that group: the update-gate (
Wz) and candidate (Wh) weights of each layer.The reset-gate weights (
Wr) and the readout (out) are listed as non-etrace parameters.Wris excluded by the weight -> weight -> hidden rule – its only path to a hidden state runs through the candidate weightWh, so it must be learned by BPTT (see the Limitations tutorial) – whileoutnever feeds a hidden state at all.
Reading this off show_graph() tells you exactly which weights D_RTRL learns online
(the four traced GRU weights) and which it leaves to ordinary backprop (Wr, out).
# Inspect the two-layer graph programmatically via learner.graph and learner.report
graph2 = learner2.graph
print(f"Number of hidden groups: {len(graph2.hidden_groups)}")
print(f"Number of weight-hidden relations: {len(graph2.hidden_param_op_relations)}")
print("\nHidden groups:")
for g in graph2.hidden_groups:
print(f" Group {g.index}: {g.hidden_paths}")
print("\nRelations:")
for i, r in enumerate(graph2.hidden_param_op_relations):
groups = [g.index for g in r.hidden_groups]
# ``trainable_paths`` maps each trainable key to its owning ParamState path.
print(f" Weight {i}: {r.trainable_paths} -> hidden group(s) {groups}")
# Quick summary via the report
print("\nReport counts:", learner2.report.counts)
Convolutional Network#
ETP primitives also support convolutional operations via braintrace.nn.Conv2d. When a
convolution operates recurrently on a hidden feature map – reading the current map and
writing the result back – the compiler discovers the connection between the convolution
kernel and the hidden state it updates. This demonstrates the generality of the graph
compilation: it works with any ETP primitive, not just matrix multiplication.
Note.
braintrace.nn.Conv2dtakes a channel-lastin_sizeof the form(H, W, C)(the spatial dimensions plus the number of input channels) and a separateout_channels. The kernel is only traced as an ETP parameter if its output reaches a hidden state directly – an intervening shape-changing op (such as areshapeflattening the feature map before aLinear) breaks the connection, just like the slice in the Limitations tutorial.
class ConvRNN(brainstate.nn.Module):
"""A convolutional *recurrent* layer.
The conv operates on its own hidden feature map ``self.h`` and writes the
result back, so the conv kernel feeds a hidden state **directly** -- which is
what lets the compiler trace it as an ETP parameter.
"""
def __init__(self):
super().__init__()
# in_size is the channel-last spatial+channel shape (H, W, C).
self.conv = braintrace.nn.Conv2d(in_size=(28, 28, 8), out_channels=8,
kernel_size=3, padding='SAME')
self.h = brainstate.HiddenState(jnp.zeros((28, 28, 8)))
self.out = braintrace.nn.Linear(8 * 28 * 28, 10)
def update(self, x):
# x: (28, 28, 8) external drive in the same feature space as the hidden map.
# The conv reads the recurrent hidden map and writes back into it, with no
# shape-changing op in between, so the kernel -> hidden connection is traced.
self.h.value = jax.nn.tanh(self.conv(self.h.value) + x)
return self.out(self.h.value.reshape(-1))
model3 = ConvRNN()
learner3 = braintrace.compile(model3, braintrace.D_RTRL, jnp.zeros((28, 28, 8)), batch_size=1)
learner3.show_graph()
In this model:
The
Conv2dkernel is discovered as an ETP parameter (theetp_convprimitive), because the convolution operates on the recurrent hidden feature mapself.hand writes the result back into it – so the kernel feeds a hidden state directly, exactly like a recurrent matmul weight would.The single hidden group is the
(28, 28, 8)convolutional feature map.The readout
Linearis non-temporal – its output does not flow back into a hidden state – so the compiler excludes it from eligibility-trace tracking (it is still trained, just as a plain parameter learned through the loss, not an online trace).
The key requirement is that the conv output reach the hidden state without an intervening
shape-changing op (a reshape/slice) or another trainable ETP weight. Here it does, so the
compiler traces the conv kernel’s influence on the recurrent hidden state – showing that ETP
generalises beyond matrix multiplication to convolutional recurrence.
Using compile_etrace_graph Directly#
Most users should call braintrace.compile(...) — it initialises states, compiles the graph, and returns a learner in one call (access the underlying graph via learner.graph). For advanced users who want to inspect the graph without wrapping the model in an algorithm, braintrace.compile_etrace_graph() is also available. This is useful for:
Debugging model structure before training
Verifying that ETP primitives are correctly placed
Building custom online learning algorithms on top of the graph
model_direct = SingleLayerRNN(10, 32, 5)
brainstate.nn.init_all_states(model_direct)
graph_direct = braintrace.compile_etrace_graph(model_direct, jnp.zeros(10))
print(f"Number of hidden groups: {len(graph_direct.hidden_groups)}")
print(f"Number of relations: {len(graph_direct.hidden_param_op_relations)}")
print(f"Has perturbation: {graph_direct.hidden_perturb is not None}")
print("\nGraph fields:")
for key in graph_direct.dict().keys():
print(f" {key}")
Number of hidden groups: 1
Number of relations: 1
Has perturbation: True
Graph fields:
module_info
hidden_groups
hid_path_to_group
hidden_param_op_relations
hidden_perturb
diagnostics
The compile_etrace_graph() function returns the same ETraceGraph named tuple that is
stored internally by D_RTRL and other algorithms. You can use it to build custom
training loops or to programmatically analyze model structure.
Summary#
In this tutorial, we covered the graph compilation and visualization tools in braintrace:
compile_graph()(on algorithm objects) andcompile_etrace_graph()(standalone function) analyze the model’s computation graph to discover the structural relationships between weights, ETP primitives, and hidden statesshow_graph()provides a human-readable summary of the compiled graph, showing hidden groups, weight-hidden associations, and non-ETP parametersThe compiled graph reveals which weights participate in online learning – only weights whose ETP-primitive output feeds a hidden state are eligibility-traced; readouts and weights caught by the weight -> weight -> hidden rule (e.g. the GRU reset gate) are left to ordinary backprop
Multi-layer and convolutional models create richer graph structures – more hidden states and more weight relations – and the compiler decides how to group the hidden states, co-locating coupled same-shape states into a single group while keeping differently-shaped states apart
The
ETraceGraphnamed tuple can be inspected programmatically for custom analysis or to build custom online learning algorithms
Understanding the compiled graph is a key step in verifying that your model is correctly structured
for online learning with braintrace.