RNN Compiler Walkthrough#
This tutorial follows the same compiler workflow from a single recurrent layer to a stacked RNN. We first establish how to read the compilation report and ETraceGraph, then compile a two-layer model and compare the structures reported for both models.
Single-Layer RNN#
Define and Compile the Model#
The model contains one ValinaRNNCell and one linear readout. The recurrent cell owns the temporal hidden state, while the readout maps that state to the final 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 hidden groups are:
Group 0: [('rnn', 'h')]
The weight parameters which are associated with the hidden states are:
Weight 0: ('rnn', 'W', 'weight') is associated with hidden group 0
The non-etrace weight parameters are:
Weight 0: ('out', 'weight') (excluded: relation_excluded_non_temporal)
Compiler diagnostics (warnings / errors):
[warning] relation_excluded_non_temporal: ETP primitive etp_mv (weight=('out', 'weight')) has no connected hidden states. It will be treated as a non-temporal parameter.
========================================================================================================================
The hidden groups are:
Group 0: [('rnn', 'h')]
The weight parameters which are associated with the hidden states are:
Weight 0: ('rnn', 'W', 'weight') is associated with hidden group 0
The non-etrace weight parameters are:
Weight 0: ('out', 'weight') (excluded: relation_excluded_non_temporal)
Read the Compiler Output#
The compiler identifies one hidden group at ('rnn', 'h'). The recurrent weight at ('rnn', 'W', 'weight') reaches that hidden state through an ETP primitive, so the learner maintains an eligibility trace for it.
The readout weight at ('out', 'weight') is different. Its output does not feed a recurrent hidden state, so it is reported as relation_excluded_non_temporal. It remains trainable through the loss, but it does not require a temporal eligibility trace.
Using learner.report – the CompilationReport#
Every learner returned by braintrace.compile exposes a CompilationReport through learner.report. The report is the concise view of what compilation included, excluded, or diagnosed:
report.countssummarizes hidden groups, ETP weights, excluded weights, warnings, and errors.report.etrace_weightslists parameter paths that participate in eligibility tracing.report.excluded_weightspairs excluded parameter paths with their reasons.report.dynamic_statesrecords non-hidden dynamic states discovered during tracing.report.diagnosticscontains the completeCompilationRecordsequence.
# 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)
========================================================================================================================
The hidden groups are:
Group 0: [('rnn', 'h')]
The weight parameters which are associated with the hidden states are:
Weight 0: ('rnn', 'W', 'weight') is associated with hidden group 0
The non-etrace weight parameters are:
Weight 0: ('out', 'weight') (excluded: relation_excluded_non_temporal)
Counts: {'hidden_groups': 1, 'etrace_weights': 2, 'excluded_weights': 1, 'warnings': 1, 'errors': 0}
ETrace weights: [(('rnn', 'W', 'weight'), [0]), (('rnn', 'W', 'weight'), [0])]
Excluded weights: [(('out', 'weight'), 'relation_excluded_non_temporal')]
Understanding ETraceGraph#
The report summarizes decisions; learner.graph exposes their structural representation. Its central fields are:
Field |
Meaning |
|---|---|
|
Traced Jaxpr and model-state mappings |
|
Hidden states updated as recurrent groups |
|
Hidden-state path to group mapping |
|
Parameter, ETP primitive, and hidden-group relations |
|
Perturbation structure used for hidden Jacobians |
|
Included and excluded compiler decisions |
Inspecting the relation itself confirms which trainable path, primitive, and hidden group produced the summary above.
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}")
=== Hidden Groups ===
Group 0: 1 state(s), shape (32,)
Paths: [('rnn', 'h')]
=== Weight-Primitive-Hidden Relations ===
Relation 0:
Trainable paths: {'weight': ('rnn', 'W', 'weight'), 'bias': ('rnn', 'W', 'weight')}
Primitive: etp_mv
Hidden groups: [0]
=== Perturbation ===
Has perturbation: True
Using compile_etrace_graph Directly#
braintrace.compile(...) is the standard entry point because it initializes states, compiles the graph, and returns a ready learner. For structural debugging or custom algorithm development, braintrace.compile_etrace_graph(...) returns the same ETraceGraph without constructing an algorithm wrapper.
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
Two-Layer RNN#
The stacked model uses the same input and hidden width but introduces a second recurrent state. It uses GRUCell layers to expose multiple gate relations as well as multiple hidden groups; consequently, the final comparison is a comparison of the compiled structures, not a controlled claim that every difference is caused only by depth.
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)
learner2 = braintrace.compile(model2, braintrace.D_RTRL, jnp.zeros(10))
learner2.show_graph()
Inspect the Stacked Graph#
The compiler should keep the two sequential recurrent states in separate hidden groups. For each GRU layer, the update-gate (Wz) and candidate (Wh) weights have direct ETP relations to that layer’s hidden group. Reset-gate (Wr) paths are excluded when they reach the hidden state through another trainable ETP primitive, while the readout remains non-temporal.
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("Hidden paths:", [group.hidden_paths for group in graph2.hidden_groups])
print("Report counts:", learner2.report.counts)
Single-Layer vs Two-Layer#
The comparison below is generated from the two compiled learners rather than maintained as a separate hand-written result table. It distinguishes model architecture from the compiler structures discovered for each model.
single_counts = learner.report.counts
two_counts = learner2.report.counts
print(f"{'Property':<32} {'Single layer':>14} {'Two layers':>14}")
print("-" * 62)
print(f"{'Recurrent cell':<32} {'ValinaRNNCell':>14} {'GRUCell':>14}")
print(f"{'Hidden groups':<32} {len(learner.graph.hidden_groups):>14} {len(learner2.graph.hidden_groups):>14}")
print(f"{'Weight-hidden relations':<32} {len(learner.graph.hidden_param_op_relations):>14} {len(learner2.graph.hidden_param_op_relations):>14}")
print(f"{'ETP weights':<32} {single_counts['etrace_weights']:>14} {two_counts['etrace_weights']:>14}")
print(f"{'Excluded weights':<32} {single_counts['excluded_weights']:>14} {two_counts['excluded_weights']:>14}")
The second recurrent layer creates another independently represented hidden-state transition. The additional GRU gates also create more candidate parameter paths, so the relation-count difference must not be attributed to depth alone. In both models, the report explains classification outcomes and the graph identifies the exact parameter, primitive, and hidden-group connections behind them.
Summary#
The single-layer model establishes the inspection sequence; the two-layer model applies it without repeating the API explanation. Comparing the resulting reports and graphs shows why compiler decisions must be interpreted at the relation level rather than inferred from module names or parameter counts. These structural checks establish what the compiler selected, but they do not by themselves establish gradient correctness.