A redesign of Spikey around two goals:
- (a) fast and provably correct — the RL-STDP rule is a pure function pinned to a reference oracle; speed comes from batching, trust comes from validation.
- (b) light and LLM-friendly — an experiment is a three-line dict, not a class hierarchy.
It keeps Spikey's faithful semantics (LIF + reward-modulated STDP, rate-coded inputs, the random-state task) but changes how you express and run experiments.
pip install -e . # core (numpy only)
pip install -e ".[scale]" # + ray[tune] for multi-core / async searchimport snn2
# one experiment — the whole spec is three meaningful keys
m = snn2.run({"preset": "izhi_randstate", "lr": 0.1, "stdp_window": 100})
print(m["final_reward"], m["mean_out_rate"])
# a sweep is just data
specs = snn2.sweep({"preset": "izhi_randstate"},
{"lr": [0.0, 0.05, 0.1, 0.3], "stdp_window": [50, 100, 200]})
results = snn2.schedule(specs) # 12 runs, one batched bucket, deduped
best = max(results.values(), key=lambda r: r["final_reward"])Validate the learning rule any time:
python -m snn2.validateSpikey expresses an experiment as a network_template(RLNetwork) subclass with
parts + keys dicts, plus scattered subclasses and a hand-written train_func.
Here it's a flat dict. That single change is what makes everything else possible:
a dict is data an LLM can emit (not author), it's hashable (so results dedupe and
resume), and it's diffable (so a sweep is a cartesian product of deltas).
Presets carry the defaults; your spec carries only the deltas. The 18-key config from the original notebook collapses to:
{"preset": "izhi_randstate", "lr": 0.1, "stdp_window": 100}The model genuinely has many degrees of freedom — you don't lose any. You just
stop writing the ones you don't care about. The fully-resolved config (preset
expanded + defaults filled + derived keys) is what actually runs and what gets
hashed, so reproducibility stays honest even though the input was tiny. See
snn2.expand(spec).
A neuron/encoder/readout/reward model is a function registered under a string.
Adding one is a single @snn2.register(...) — no inheritance:
@snn2.register("neuron", "izhikevich")
def izhikevich(state, I, p, rng):
a, b, c, d = p["izhi_a"], p["izhi_b"], p["izhi_c"], p["izhi_d"]
v, u = state["v"], state["u"]
for _ in range(2): # two 0.5 ms Euler sub-steps
v = v + 0.5 * (0.04*v*v + 5*v + 140 - u + I)
u = u + a * (b*v - u)
fired = v >= 30.0
state["v"] = np.where(fired, c, v)
state["u"] = np.where(fired, u + d, u)
return state, firedNote: the original repo's "Izhikevich" is a paper-replication notebook that actually runs the generic LIF neuron.
snn2ships the real a/b/c/d recovery-variable model as a first-class part.
Spikey vectorizes over neurons with NumPy. snn2 adds a leading experiment
axis B: weights [B, S, N], potentials [B, N], traces [B]. One
run_bucket advances every experiment with the same array ops. The only Python
loops are over time — never over experiments or neurons.
Logically-staggered and uneven episodes share one loop via an active mask [B]:
a lane that has reached its own len_episode stops accruing reward and weight
updates while its neighbors keep going. So you can hand each lane a different
episode budget and still run them together.
Specs that differ in shape (different n_neurons/n_inputs) can't share a
tensor, so they're auto-bucketed by shape and each bucket runs batched. Padding
would waste the most compute on the biggest nets; bucketing avoids that.
snn2/stdp.py defines the reward-modulated LTP update three ways:
stdp_delta_ref— an obviously-correct quadruple loop (the oracle),stdp_delta— vectorized single-experiment,stdp_delta_batched— vectorized overB(the hot path).
snn2/validate.py asserts the fast paths equal the oracle byte-for-byte on
random inputs, plus analytic properties: pre-before-post potentiates,
post-only/zero-trace produce no change, recency credit is monotonic. This is the
concrete meaning of "RLSTDP has to work perfectly and be validatable."
Two scheduling problems, two tools:
| level | what it decides | tool |
|---|---|---|
| across workers (coarse) | which specs run where, refill capacity, early-stop losers | Ray Tune (async ASHA) |
| within a worker (fine) | many lanes in lockstep on one core | the batched engine |
results = snn2.schedule_ray(specs) # one process per shape-bucket
analysis = snn2.tune_run(search_space, 500) # staggered async trials + early-stopRay gives real multi-core parallelism because each worker is a separate process with its own interpreter — the CPython GIL is per-interpreter, so N workers = N GILs. And inside a worker the heavy NumPy ops release the GIL anyway. The thing to avoid isn't the GIL, it's a Python loop over experiments/neurons; batching keeps those on array axes so the interpreter time stays negligible. Ray is entirely optional: the core runs on NumPy alone.
snn2/ the package (pip install -e .)
spec.py presets, expand()->resolved, content-addressed hashing; GATES
registry.py string -> pure-function parts (neurons, inputs, ..., engines)
parts.py lif, izhikevich, dendritic(dCaAP), ratemap, threshold/population, ...
stdp.py stdp_delta (+ _batched) and stdp_delta_ref oracle
engines/ engine variants, each registered by name
batched.py run_bucket for STATEFUL games (reset/step) -- engine="batched"
trial.py run_bucket for TRIAL games (cue sequences) -- engine="trial"
api.py run / sweep / schedule / schedule_ray / tune_run (dispatches by engine)
validate.py golden test vs oracle + analytic property tests
florian.py Florian (2007) MSTDPET, validated + figure
docs/
usage.md how an LLM drives this by writing specs only
dcaap.md / dcaap-findings.md dCaAP background + research synthesis
examples/
izh/izhikevich.py Izhikevich (2003) random-state task (batched engine)
florian/florian_fig1.py Florian (2007) figure
izh/conditioning.py instrumental GO/NO-GO, grouping validation (trial engine)
logicgates/base/logic_gates.py R-STDP gate curriculum + dendritic XOR (trial engine)
The engine is selected by the spec's engine key (default "batched"); it is
looked up in the registry exactly like any other part, so adding a third engine
is one @register("engine", "name") in snn2/engines/.
A single output neuron is shown the four cases of two binary operands and rewarded when its spiking matches a target gate. Trained from random weights, an ordinary point neuron learns OR instantly, stalls on AND (~0.75), and is stuck at chance on XOR (~0.5) — XOR is not linearly separable. Swapping only the neuron's activation for a non-monotonic dCaAP unit (fires for threshold-level input, suppressed for stronger input, per Gidon et al. 2020) lets a single neuron learn XOR to ~1.0.
python -m examples.logicgates.base.logic_gates # curriculum table + 9 validation checks
python examples/logicgates/base/logic_gates.py # also renders logic_gates.png- Curves (top-left). The point neuron degrades with difficulty (OR 1.0 → AND 0.75 → XOR 0.5); the dendritic neuron climbs from ~0.83 to ~1.0 on XOR.
- Truth tables (top-right). Red cells are the failures: AND's
11, monotonic-XOR's01/10. The dendritic XOR row is exactly[0,1,1,0]. - Firing map (bottom-left). Why the dendrite works: there is a window of synaptic scale where one input lands in the dCaAP band and two inputs overshoot it. The red two-input curve owning the low-weight region is the "band-entry-order trap" (see docs).
- Contrast (bottom-right). Same task, same rule: point neuron 0.49, dendrite 1.00.
Full write-up — encoding, learning rule, every result explained, the two pitfalls
(band-entry-order collapse and the dead-start trap), config reference, and how to
extend — is in docs/logic_gates.md.
The gate demos use reward-modulated STDP; this one shows why it learns at
all. Farries & Fairhall (2007) proved that a reward signal modulating STDP makes
the trial-averaged weight change climb the gradient of expected reward — the
synaptic eligibility trace acts as the policy-gradient score function — provided
the reward is centered on a baseline. We reproduce that and validate it against an
exact oracle: the finite-difference gradient of E[reward].
python -m snn2.farries # alignment cosines + 5 validation checks
python examples/farries.py # also renders farries_fig.png- Centered R-STDP update lies exactly on the reward-gradient diagonal (cosine 1.00); the uncentered update points the opposite way (cosine −1.00) — the unsupervised Hebbian bias drives an over-firing neuron to fire more.
- Centered learning climbs
E[R]; a mis-set baseline collapses it. Performance peaks sharply at the true baseline.
This is the theoretical backbone under the conditioning and logic-gate demos. Full
derivation and method in docs/farries.md.
Florian and Farries & Fairhall consume a dopamine/reward signal. Chorley & Seth model where it comes from — a dual-pathway circuit (fast SEN→INT→DA excitation racing a slow PFC→STR→DA inhibition, both under DA-modulated plasticity) in which the canonical dopamine shift from reward (US) to reward-predicting cue (CS) emerges.
python -m snn2.chorley_seth # 6 validation checks + summary
python examples/chorley_seth.py # also renders chorley_seth_fig.png- The CS response emerges (DA learns to fire to the cue) via DA-STDP and the distal-reward eligibility mechanism — peri-event DA goes from a single US peak to both a CS and a US peak.
- The inhibitory pathway develops anticipatory STR firing near the US.
This is a faithful reduced-scale reproduction: the cue-response shift is robust;
full cancellation of the US response (the sharply-timed inhibitory volley) needs
the paper's larger polychronous substrate and is partial here — documented honestly
in docs/chorley_seth.md.
The whole speed story rests on grouping: many experiments share one batched
run on a leading axis B. That is only legitimate if the lanes stay independent
— lane i's neurons, weights, and reward must never touch lane j. Instrumental
(operant) conditioning is the ideal stress test, because if grouping leaked, an
agent could not learn a contingency that differs from its batch-mates.
The task — GO / NO-GO. Each trial presents one of two cues. A single output neuron must fire for the GO cue and stay silent for the NO-GO cue; reward is +1 when the action matches the cue's target and −1 otherwise. The two cues reach the neuron through separate input groups, so reward-modulated STDP tunes each on its own — GO weights ratchet up from rewarded exploratory firing, NO-GO weights ratchet down from punished firing — and the agent learns the contingency.
python examples/izh/conditioning.py # validate + render grouping_conditioning.pngThe three panels are exactly the validation:
- Instrumental conditioning (left). 16 agents trained with R-STDP climb from
chance (0.5) to 100% accuracy; the green band is the 25–75th percentile across
agents. The matched
lr=0control (grey, dashed) stays at chance — so the rise is learning, not drift. 16/16 seeds converge. - Grouping independence (middle). Each point is one spec's reward when run
alone (x) versus grouped in a single batched bucket (y). Every point
lies exactly on
y = x— the per-lane weights and reward are bit-identical (max difference0e+00). Lanes are seeded from their spec, not their batch position, so a result never depends on who shares the batch (also verified by a shuffle-invariance check). - Cross-contingency independence (right). Two agents with opposite contingencies (GO=cue0 vs GO=cue1) trained in the same batch each reach 1.00 on their own task. Neighboring lanes with conflicting goals do not interfere — the strongest evidence the grouping is real.
snn2.conditioning.validate() asserts all of this (independence == 0, shuffle
invariance, both cross-task agents > 0.9, ≥14/16 seeds converge).
snn2/izhikevich.py reproduces the famous figure where one simple a,b,c,d
model yields every cortical firing type. The traces are produced by the same
registered izhikevich neuron the batched engine uses (stepped at B=1, N=1),
so the figure validates the model the rest of snn2 relies on.
python examples/izhikevich_fig1.py # validate + render izhikevich_firing_patterns.pngValidated by the behaviors that define each type, not just by plotting: every regime fires under the step current; fast-spiking fires the most; regular-spiking shows spike-frequency adaptation (growing inter-spike intervals); intrinsically- bursting opens with a burst then slows; chattering shows recurring short-ISI bursts while regular-spiking shows none.
The repo's
izhikevich2007.ipynbis a different thing — an RL learning task that runs the LIF neuron. That task is theizhi_randstatepreset / theexamples/izh/izhikevich.pysweep. This figure is the neuron-dynamics reproduction, using the true recovery-variable model the repo lacks.
snn2/florian.py reproduces the MSTDPET demonstration — a single synapse j→i
driven with fixed pre/post spike trains and a reward that flips +1→−1 at 100 ms.
It implements the paper's exact eligibility-trace equations (Eqs 7, 8, 42–44),
not the window approximation, so it matches the reference curve directly.
python examples/florian/florian_fig1.py # validate + render florian_fig1.pngIt is validated, not just plotted: the module's weight curve is asserted equal, byte-for-byte, to an independent inlined transcription of the equations (w: 0.200 → 0.316, peak 0.416), plus the qualitative facts — pre-before-post gives positive eligibility, weight potentiates under positive reward, and the same eligibility reduces the weight once reward turns negative.



