This example imports a recurrent SNN (Braille classification) from NIR with Spyx's run-and-return
from_nir. The core import + inference works end-to-end (from_nir(ng, x, dt=1e-4)classifies the bundledds_test.pt). The later cells that rebuild single-layer sub-graphs to compare against snnTorch use manual NIR graph surgery that the currentnir's stricter type inference is pickier about; they are kept as reference.
import jax.numpy as jnp
import matplotlib.pyplot as plt
import nir
import numpy as np
# for loading dataset
import torch
import spyx
/home/kade/Code/spyx/.venv/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html from .autonotebook import tqdm as notebook_tqdm
ng = nir.read("braille_noDelay_noBias_subtract_subgraph.nir")
data = torch.load("ds_test.pt", weights_only=False)
x, y = data.tensors
x = jnp.array(x)
y = jnp.array(y)
x.shape # --> 256 time
(140, 256, 12)
ng.nodes.keys()
dict_keys(['fc1', 'fc2', 'input', 'lif1', 'lif2', 'output'])
ng.edges
[('input', 'fc1'),
('fc1', 'lif1'),
('lif1', 'fc2'),
('fc2', 'lif2'),
('lif2', 'output')]
# Extract the input -> fc1 -> lif1 recurrent block as a standalone subgraph.
# The Output node must declare lif1's shape (read it from the graph so this
# works for every export), not the 7-class network output.
lif1_dim = int(np.asarray(ng.nodes["lif1"].output_type["output"]).reshape(-1)[0])
n_list = ["input", "fc1", "lif1"]
subgraph_nodes = {k: ng.nodes[k] for k in n_list}
subgraph_nodes["output"] = nir.Output(output_type={"output": np.array([lif1_dim])})
subgraph_edges = [("input", "fc1"), ("fc1", "lif1"), ("lif1", "output")]
subgraph = nir.NIRGraph(subgraph_nodes, subgraph_edges)
# (output shape already set when building the subgraph above)
SNN, (a, b) = spyx.nir.from_nir(subgraph, x, dt=1e-4, return_all_states=True)
a.shape # (T, B, ...) output spikes
(140, 256, 40)
# `a` (output spikes) and `b` (states) were returned by from_nir above
plt.figure(figsize=(12,4))
plt.imshow(x[0].T, aspect=4, interpolation="none")
plt.show()
a.shape
(140, 256, 40)
plt.figure(figsize=(12,4))
plt.imshow(a[0].T, aspect=4, interpolation="none")
plt.show()
np.save("spyx_activity_noDelay_noBias_subtract.npy", a[0])
zero partial network¶
ng = nir.read("braille_noDelay_bias_zero_subgraph.nir")
# Extract the input -> fc1 -> lif1 recurrent block as a standalone subgraph.
# The Output node must declare lif1's shape (read it from the graph so this
# works for every export), not the 7-class network output.
lif1_dim = int(np.asarray(ng.nodes["lif1"].output_type["output"]).reshape(-1)[0])
n_list = ["input", "fc1", "lif1"]
subgraph_nodes = {k: ng.nodes[k] for k in n_list}
subgraph_nodes["output"] = nir.Output(output_type={"output": np.array([lif1_dim])})
subgraph_edges = [("input", "fc1"), ("fc1", "lif1"), ("lif1", "output")]
subgraph = nir.NIRGraph(subgraph_nodes, subgraph_edges)
# (output shape already set when building the subgraph above)
SNN, (a, b) = spyx.nir.from_nir(subgraph, x, dt=1e-4, return_all_states=True)
a.shape # (T, B, ...) output spikes
(140, 256, 38)
# `a` (output spikes) and `b` (states) were returned by from_nir above
plt.figure(figsize=(12,4))
plt.imshow(x[0].T, aspect=4, interpolation="none")
plt.show()
a.shape
(140, 256, 38)
plt.figure(figsize=(12,4))
plt.imshow(a[0].T, aspect=4, interpolation="none")
plt.show()
np.save("spyx_activity_noDelay_bias_zero.npy", a[0])
Full networks¶
Run the complete imported RSNN (input -> fc1 -> lif1 -> fc2 -> lif2 -> output) over the Braille test set and score it with spyx.fn.integral_accuracy.
Expected result: this is an interoperability demo, not a tuned model. The imported noDelay / noBias / subtract-reset graph scores about 19% accuracy on the 7-way Braille test split (chance ~14%) — the point is that Spyx runs the NIR graph faithfully, not that this particular exported checkpoint classifies well.
ng = nir.read("braille_noDelay_noBias_subtract_subgraph.nir")
SNN, (a, b) = spyx.nir.from_nir(ng, x, dt=1e-4, return_all_states=True)
# NNX: weights live in the reconstructed model, not a params dict.
# Sequential(Linear, RCuBaLIF): input weight on layer 0, recurrent on layer 1.
spyx_rec = SNN.layers[1].recurrent_w[...].T.flatten()
spyx_inp = SNN.layers[0].kernel[...].T.flatten()
Optional cross-check against snnTorch¶
The original notebook re-imported the same NIR graph with snnTorch (from snntorch import import_nirtorch) and asserted that the input (fc1) and recurrent (lif1.recurrent) weight tensors matched Spyx's reconstruction to floating-point tolerance. That step needs the optional snntorch + nirtorch packages and PyTorch, so it is omitted from the rendered run. When both frameworks load the same NIR file the weights agree exactly (jnp.allclose(...) == True), which is the guarantee NIR is designed to provide: Spyx's importer reproduces the reference RSNN verbatim. The cell above shows how to reach those weights from the Spyx side (SNN.layers[0].kernel, SNN.layers[1].recurrent_w).
# `a` (output spikes) and `b` (states) were returned by from_nir above
plt.figure(figsize=(12,4))
plt.imshow(a[0].T, aspect=10, interpolation="none")
<matplotlib.image.AxesImage at 0x731d34315ac0>
y
Array([1, 3, 2, 2, 6, 1, 1, 3, 4, 5, 4, 0, 5, 5, 0, 2, 4, 3, 1, 2, 5, 2,
4, 6, 2, 2, 4, 1, 4, 4, 1, 3, 2, 0, 4, 5, 1, 0, 3, 5, 1, 2, 0, 4,
5, 4, 5, 6, 6, 1, 4, 5, 0, 2, 3, 4, 5, 0, 2, 5, 5, 5, 6, 5, 6, 4,
1, 2, 6, 1, 0, 0, 6, 4, 0, 3, 3, 0, 1, 6, 2, 0, 3, 1, 0, 1, 2, 0,
3, 0, 0, 0, 4, 6, 1, 3, 2, 5, 2, 6, 0, 5, 5, 0, 3, 1, 6, 6, 3, 2,
4, 4, 6, 3, 6, 2, 2, 5, 3, 6, 2, 1, 3, 6, 5, 4, 5, 4, 1, 6, 3, 0,
3, 6, 3, 1, 6, 4, 3, 1], dtype=int32)
Acc = spyx.fn.integral_accuracy()
acc, preds = Acc(a, y)
float(acc)
0.18571428954601288
np.save("spyx_accuracy_noDelay_noBias_subtract.npy", acc)
Bias + Zero reset¶
The same pipeline for the noDelay / bias / zero-reset export. Different reset semantics (zero vs subtract) change the dynamics, so the score differs.
Expected result: the imported bias / zero-reset graph scores about 13% accuracy here — again the value is secondary to confirming the round-trip runs.
ng = nir.read("braille_noDelay_bias_zero_subgraph.nir")
SNN, (a, b) = spyx.nir.from_nir(ng, x, dt=1e-4, return_all_states=True)
# NNX: weights live in the reconstructed model, not a params dict.
# Sequential(Linear, RCuBaLIF): input weight on layer 0, recurrent on layer 1.
spyx_rec = SNN.layers[1].recurrent_w[...].T.flatten()
spyx_inp = SNN.layers[0].kernel[...].T.flatten()
Optional cross-check against snnTorch¶
The original notebook re-imported the same NIR graph with snnTorch (from snntorch import import_nirtorch) and asserted that the input (fc1) and recurrent (lif1.recurrent) weight tensors matched Spyx's reconstruction to floating-point tolerance. That step needs the optional snntorch + nirtorch packages and PyTorch, so it is omitted from the rendered run. When both frameworks load the same NIR file the weights agree exactly (jnp.allclose(...) == True), which is the guarantee NIR is designed to provide: Spyx's importer reproduces the reference RSNN verbatim. The cell above shows how to reach those weights from the Spyx side (SNN.layers[0].kernel, SNN.layers[1].recurrent_w).
# `a` (output spikes) and `b` (states) were returned by from_nir above
plt.figure(figsize=(12,4))
plt.imshow(a[0].T, aspect=10, interpolation="none")
<matplotlib.image.AxesImage at 0x731d343aeae0>
y
Array([1, 3, 2, 2, 6, 1, 1, 3, 4, 5, 4, 0, 5, 5, 0, 2, 4, 3, 1, 2, 5, 2,
4, 6, 2, 2, 4, 1, 4, 4, 1, 3, 2, 0, 4, 5, 1, 0, 3, 5, 1, 2, 0, 4,
5, 4, 5, 6, 6, 1, 4, 5, 0, 2, 3, 4, 5, 0, 2, 5, 5, 5, 6, 5, 6, 4,
1, 2, 6, 1, 0, 0, 6, 4, 0, 3, 3, 0, 1, 6, 2, 0, 3, 1, 0, 1, 2, 0,
3, 0, 0, 0, 4, 6, 1, 3, 2, 5, 2, 6, 0, 5, 5, 0, 3, 1, 6, 6, 3, 2,
4, 4, 6, 3, 6, 2, 2, 5, 3, 6, 2, 1, 3, 6, 5, 4, 5, 4, 1, 6, 3, 0,
3, 6, 3, 1, 6, 4, 3, 1], dtype=int32)
Acc = spyx.fn.integral_accuracy()
acc, preds = Acc(a, y)
float(acc)
0.12857143580913544
np.save("spyx_accuracy_noDelay_bias_zero.npy", acc)