Training

#exp006 · SNNSIM ·

This page describes the legacy --executor legacy training path in tools/snnsim/train.py. It connects the training controls to their implementation. For declarative graph training, use exp088Training recipes and graph-native learning graph checkpoints and input bindings have a different contract.

Start a small training run

From the repository root, choose a fresh scratch directory and run a short MNIST plumbing check:

uv run python tools/snnsim/tool.py train --executor legacy \
  --model ping --dataset mnist --n-hidden 32 --epochs 1 \
  --max-samples 128 --batch-size 16 --t-ms 20 --dt 0.25 \
  --readout mem-mean --lr 0.0001 --v-grad-dampen 1000 \
  --seed 42 --out-dir temp/docs-training

This example exercises the training path; its short duration and small dataset are not a scientific baseline. The first run may download MNIST. Inspect finite loss, gradient diagnostics, skipped updates, and output files before scaling up. --epochs 0 is an initialization probe, not a trained checkpoint. Use the committed experiment recipe for a production run.

Checkpoints and evaluation

For MNIST, training uses a held-out validation partition from the official training data. Checkpoint selection minimises validation cross-entropy averaged over encoder draws; validation accuracy breaks exact loss ties. The official test partition is reserved for separate inference.

OutputMeaning
weights.pthValidation-selected parameters, when a selected epoch exists.
weights_final.pthParameters at the end of training. Use when measuring final-epoch dynamics.
config.jsonResolved training configuration, split and encoder settings.
metrics.json / metrics.jsonlSummary and training history; checkpoint metadata records roles and hashes.
test_predictions.jsonLegacy filename for validation predictions; the filename does not make these held-out test results.

A weight file is not a complete optimizer-resume checkpoint. Keep it with its configuration and recorded role. To evaluate the selected parameters separately:

uv run python tools/snnsim/tool.py sim --infer --executor legacy \
  --load-config temp/docs-training/config.json \
  --load-weights temp/docs-training/weights.pth \
  --max-samples 128 --outputs per_cell_rates \
  --out-dir temp/docs-evaluation

This capped evaluation is a plumbing check. Neither command creates a completed Pingstore run; experiment stages retain scientific evidence separately.

Backpropagation through time

Every model here is a recurrent system run forward in time, so gradients come from Backpropagation Through Time (BPTT): unroll the recurrence into a deep feedforward graph — one layer per timestep, all sharing the same weights — and backpropagate through it.

Take a hidden state 𝑘 that evolves as

𝑘=𝑓(𝑘1,𝑥𝑘;𝜃param),𝑧𝑘=𝑔(𝑘;𝜃param)
(1)

with input 𝑥𝑘, score output 𝑧𝑘, and parameters 𝜃param shared across time. Running 𝑁𝑡 steps gives a chain 01𝑁𝑡, which for gradients is treated as a depth-𝑁𝑡 feedforward network with tied weights.

For a scalar loss 𝐿total depending on the final state, define 𝑎𝑘=𝜕𝐿total𝜕𝑘 as the total sensitivity to state 𝑘. Then the contribution through state updates is

𝜕𝐿total𝜕𝜃𝑗=𝑘=1𝑁𝑡(𝑎𝑘)𝜕𝑓(𝑘1,𝑥𝑘;𝜃param)𝜕𝜃𝑗.
(2)

Here 𝜃𝑗 is one shared parameter and the derivative of 𝑓 holds its state and input arguments fixed. Direct parameter use in the readout contributes an additional term. Losses or readouts accumulated over time also inject sensitivities at intermediate steps.

The backward pass contains products of per-step Jacobians 𝜕𝑘+1/𝜕𝑘, the matrices of state derivatives. Repeated contraction can suppress gradients and amplification can enlarge them; individual norms above 1 do not by themselves prove that the product grows.

One simulation step is one step of the recurrence: the state includes membrane voltages, synaptic conductances, and refractory counters. A 200 ms trial at Δ𝑡sim=0.1 ms unrolls to 𝑁𝑡=2000 steps. Gradient behaviour depends on the trajectory, surrogate, weights, and reset gates; recurrent coupling alone does not prove divergence. See exp015Gradient Stabilisation for the implemented intervention.

Surrogate gradients

The spike function 𝑠[𝑘]=𝟏[𝑉candidate𝑉th] has zero gradient almost everywhere, so the backward pass substitutes a smooth surrogate. The legacy spike helper uses a fast-sigmoid surrogate. Forward is the hard step; backward is

𝜕𝑠̃𝜕𝑉candidate=𝑘sg(1+𝑘sg|𝑉candidate𝑉th|)2
(3)

Here 𝑉candidate is the pre-reset membrane value, 𝑉th the spike threshold, and 𝑘sg the surrogate slope in inverse voltage units; 𝑠̃ denotes the backward surrogate, not the forward spike. This is Pinglab’s normalization. snnTorch’s FastSigmoid uses numerator 1 instead of 𝑘sg: equal slopes do not generally give equal gradients.

It takes its slope from SURROGATE_SLOPE = 5.0, overridable per-run with --surrogate-slope.

Gradient stabilisation

--v-grad-dampen scales the backward derivative through the biophysical membrane increment. It does not rescale every gradient or guarantee stable training. Start from the chosen recipe and inspect the diagnostics before changing it; exp015Gradient Stabilisation explains the local derivatives and trade-offs.

The training loop

Logits from the readout go into cross-entropy loss:

𝐿CE=1𝐵𝑏=1𝐵logexp(𝑧𝑏,𝑐𝑏)𝑐exp(𝑧𝑏,𝑐)
(4)

Here 𝐿CE is cross-entropy loss, 𝐵 is minibatch size, 𝑏 indexes presentations, 𝑧𝑏 is the score vector, 𝑐𝑏 the true class, and 𝑐 indexes classes. Uniform predictions on ten classes give loss ln102.30. The implementation uses AdamW with --weight-decay 0 by default. Gradients are clipped to unit norm (GRAD_CLIP = 1.0); an update with non-finite gradient norm is skipped. Checkpoint selection uses validation loss, not this training loss.

Readout

--readout selects the legacy class-score calculation. Set it explicitly when comparing recipes; the CLI default is rate, not mem-mean.

ModeImplemented reduction
rateSum last-hidden spikes, then multiply by the readout matrix. Despite its name, this path does not divide by duration or apply softmax.
mem-meanAverage the output LIF’s pre-reset membrane over time. Its subtractive reset changes later voltages.
spike-countCount spikes of each output LIF neuron, not the hidden population.
spike-rateDivide output-LIF counts by presentation duration in seconds.
cumulative-potentialAccumulate per-step softmax values from a non-spiking leaky decoder.

li is not an accepted legacy CLI mode. Changing a readout changes the score scale and gradient path; it is not merely a display choice. Output membrane parameters are separate from the hidden biophysical constants.

Firing-rate regularisation

Hidden activity can be limited with --fr-reg-upper-target-hz and --fr-reg-upper-strength:

𝑟𝑏=1𝑁𝐸𝑇present𝑛𝐸𝑛spike(𝑏,𝑛),𝐿rate=𝜆rate𝐵𝑏ReLU(𝑟𝑏𝑟𝐸,ceil)2
(5)

Here 𝑛spike(𝑏,𝑛) is the spike count of hidden excitatory neuron 𝑛 in presentation 𝑏, 𝑁𝐸 is the number of those neurons, 𝑇present is presentation duration in seconds, 𝐵 is minibatch size, 𝑟𝑏 and 𝑟𝐸,ceil are rates in Hz, and 𝜆rate is the configured rate-penalty coefficient.

The ceiling is applied separately to each presentation’s population-mean hidden-E rate before averaging across the minibatch. The loss is normalised over neurons, presentation duration, samples, and hidden layers. This is the mechanism behind exp025Accuracy and Firing Rate With and Without Inhibition exp024Accuracy Plateaus While Firing Rate Rises tests the associated rate-plateau interpretation.

Weight init

Dale-constrained magnitudes use a lower-clamped Gaussian, not a half-normal or truncated normal:

𝑋𝑖𝑗𝒩︀(𝜇init,𝜎init2),𝑋𝑖𝑗+=max(0,𝑋𝑖𝑗).
(6)

Here 𝑋𝑖𝑗 is a Gaussian draw for input index 𝑖 and output index 𝑗, and 𝑋𝑖𝑗+ is its non-negative clamp. The configured 𝜇init and 𝜎init are parent-Gaussian parameters on the summed-coupling scale, not moments of one stored edge. With initial-zero fraction 𝑞zero[0,1) and Bernoulli indicator 𝑀𝑖𝑗, the stored initialization is

𝑊𝑖𝑗0=𝑀𝑖𝑗𝑋𝑖𝑗+/((1𝑞zero)𝑁pre).
(7)

Here 𝑁pre is fan-in and 𝑀𝑖𝑗 is 1 with probability 1𝑞zero, otherwise 0. Direct readout initialization (--readout-w-init-mean and --readout-w-init-std) bypasses this fan-in scaling; do not interpret its parameters as summed coupling.

The compensation keeps the expected column sum independent of 𝑠; lower clamping means that expected sum is ℰ︀[max(0,𝑋)], which is recorded alongside the configured parent parameters. Both lower-clamp zeros and explicitly zeroed entries remain trainable and may become positive. This is sparse initialization of a dense trainable matrix, not structural sparsity.

Dale’s law during optimization

When Dale’s law is on, the feedforward matrices 𝑊ff are clamped to 𝑊0 when they are read by the forward pass and every trainable constrained matrix is projected back into the non-negative cone by project_dales() after each optimiser step. The recurrent conductance matrices 𝑊𝑒𝑒, 𝑊𝑒𝑖, 𝑊𝑖𝑒, and 𝑊𝑖𝑖 are not forward-clamped: they are initialised non-negative and, when trainable, kept non-negative by the post-step projection. Their entries are conductance magnitudes; pathway-specific reversal potentials, rather than a negative stored 𝑊𝑖𝑒, determine whether a synapse is excitatory or inhibitory.

Troubleshooting

  1. Nothing trained. Check --epochs, the skipped-update count, and whether weights.pth exists. An initialization snapshot is not a successful training run.
  2. Non-finite gradients. Inspect the first failing batch, forward values, and gradient norms. Damping and clipping act at different points; neither repairs invalid inputs or an unstable forward model.
  3. Unexpected scores or rates. Check readout mode, duration, input encoding, and whether the selected or final checkpoint was loaded.
  4. Unexpected memory use. BPTT retains a time-unrolled graph. Reduce the plumbing example’s batch size, duration, or network width before attempting the production recipe.

Implementation reference: tools/snnsim/train.py, tools/snnsim/models.py, and tools/snnsim/tool.py.

exp100COBANet · exp015Gradient Stabilisation