The oscilloscope CLI

Eoin Murray.

Every figure on this site is generated by one command-line tool. The notebooks under /notebooks are thin wrappers — each runner just hardcodes a recipe and shells out — and the article-level demos sit on the same primitives. This entry is the reference for that tool: every subcommand, every flag, every default. It is not a tutorial; it is the dictionary you reach for when a notebook mentions —ei-strength 0.5 and you want to know exactly what that did.

Invocation

The entry point lives at src/pinglab/cli/__main__.py and is invoked as a Python module:

uv run python -m cli <subcommand> [flags…]

There are five subcommands — sim, image, video, train, infer — plus the bare invocation python -m cli, which prints the top-level help. Every subcommand also responds to —help with its own complete flag listing. There is no global config file: the source of truth for every parameter is the command line. Reproducibility is delegated to two artifacts the CLI writes into the output directory on every run — config.json (a serialisation of the parsed argparse namespace plus provenance metadata) and run.sh (the literal shell command that was invoked). Together they let any downstream subcommand re-derive the exact state via —from-dir.

The five subcommands

sim

Run one forward pass and print firing-rate metrics. No plots, no video, no weights. This is the cheapest mode; the test suite calls it and the dt-stability checks in ar003 use it as a primitive. Writes only config.json, run.sh, and output.log.

image

Run one forward pass and save a still oscilloscope figure (E and I rasters, weight histograms, PSD panel). The input mode is whatever —input / —dataset dictate. Loads trained weights via —load-weights or —from-dir. The default render lands at src/pinglab/artifacts/oscilloscope/ unless —out-dir is set.

image-only flags:

  • —from-dir DIR — Inherit config.json + auto-detect weights.pth from a training run directory. Any CLI flag explicitly passed overrides the inherited value.
  • —load-weights PATH — Direct path to a weights.pth file. Mutually exclusive in spirit with —from-dir (they can both be set; —from-dir sets the auto-detected path which —load-weights then overrides).

video

Sweep one parameter linearly between —scan-min and —scan-max over —frames values and stitch the result into an MP4. The scan variable is named with —scan-var; the legal set is registered in src/pinglab/cli/scan.py (see the Scan variables section below). The video form is how the gamma-gated-sparsity collection presents its parameter sweeps — the eye picks up bifurcations in a sweep far faster than it reads a row of static panels.

video-only flags:

  • —scan-var NAME — Parameter to sweep. Default stim-overdrive. Full list below.
  • —scan-min FLOAT — Sweep start value, in the variable’s natural units. For digit, an integer class.
  • —scan-max FLOAT — Sweep end value. For digit, an integer class.
  • —frames INT — Number of frames to render. Default 10. For digit, overridden by the scan range.
  • —frame-rate INT — Output MP4 fps. Default 10.
  • —from-dir DIR — Same semantics as image.
  • —load-weights PATH — Same semantics as image.

When —input dataset is set, video also emits an init_d0s0.png snapshot alongside the video, so a sweep run has a static reference comparable to train runs.

train

Surrogate-gradient BPTT training loop. Writes weights.pth, metrics.json, a per-step metrics.jsonl, test_predictions.json, plus an optional per-epoch video or image series. —epochs 0 runs the init snapshot only — useful as a probe.

train-only flags:

  • —lr FLOAT — Adam learning rate. Default 0.01. COBA / PING typically need 0.0001; current-based models hold up at 0.01.
  • —epochs INT — Number of epochs. Default 0 (probe only).
  • —batch-size INT — DataLoader batch size. Default 64.
  • —max-samples INT — Cap dataset to N samples for smoke tests.
  • —observe {video, images} — Per-epoch oscilloscope artifact. video emits one MP4 with one frame per epoch; images emits one PNG per epoch.
  • —frame-rate INT — fps for —observe video. Default 10.
  • —v-grad-dampen FLOAT — Dampening factor on the COBA membrane gradient. Default 80.0. Theory in ar006.
  • —fr-reg-upper-theta FLOAT — Upper-bound target spike count per neuron per trial (θ_u). Adds s_u · Σ relu(⟨z_i⟩ − θ_u)² to the loss. Default 0 (off). Cramer et al. SHD RSNN: 100.
  • —fr-reg-upper-strength FLOAT — Coefficient s_u on the upper regulariser. Cramer et al.: 0.06.
  • —fr-reg-lower-theta FLOAT — Lower-bound target spike count (θ_l). Adds s_l · Σ relu(θ_l − ⟨z_i⟩)². Pushes the optimiser to fire more. Default 0.
  • —fr-reg-lower-strength FLOAT — Coefficient s_l on the lower regulariser.
  • —fr-reg-mode {per-neuron, population} — Pooling axis for the regulariser. per-neuron (default) concentrates pressure on the highest-firing cells (the Cramer recipe). population uses a single scalar over the grand mean across batch and neurons; pressure distributes uniformly.
  • —tau-gaba FLOAT — Override the GABA synaptic decay τ_GABA (ms). Default = models.py’s tau_gaba (9.0 ms, Börgers / Buzsaki-Wang range). nb041 sweeps this across {4.5, …, 27} ms while training PING from scratch; the realised gamma frequency f_γ tracks 1/τ_GABA.

infer

Load trained weights and evaluate on the test set. Its distinguishing feature is —dt-sweep: pass a list of dt values and infer runs evaluation at each, optionally with a frozen input encoder so the spike trains are identical across dt rather than re-sampled from Poisson each time. The output is a dt_sweep.png curve, a results.json with per-dt accuracies and hidden-rate stats, and optionally a dt_sweep.mp4 if —observe video is set.

infer-only flags:

  • —from-dir DIR — Inherit config + auto-detect weights.
  • —load-weights PATH — Auto-detected when —from-dir is set; required otherwise.
  • —max-samples INT — Cap evaluation set.
  • —dt-sweep DT [DT …] — Run inference at each dt value. Overrides —dt.
  • —observe {video, image} — With —dt-sweep, video renders one frame per dt. Without, image renders a single snapshot.
  • —frozen-inputs-mode {upsample, downsample, resample} — How input spikes are transported across dt, anchored at the training dt (Parthasarathy et al. §2.1, §2.3). upsample count-preservingly zero-pads to a finer eval-dt and requires eval-dt ≤ train-dt. downsample count-preservingly sum-pools to a coarser eval-dt and requires eval-dt ≥ train-dt. resample draws fresh Poisson at each eval-dt and re-introduces sampling noise. upsample and downsample additionally require integer dt ratios vs train-dt.

Shared flag groups

The following flag groups are attached to every subcommand via a shared argparse parent.

Network

  • —model {ping, cuba-ping, cuba-noping} — Architecture. ping is the COBANet with E↔I coupling (the canonical gamma-generating substrate). cuba-ping is the current-based PING variant — same connectivity, CUBA neurons. cuba-noping is cuba-ping with the inhibitory loop silenced (an explicit no-rhythm control). Default ping.
  • —n-hidden INT [INT …] — Hidden layer sizes. One integer = single layer; multiple stacks layers. Default depends on dataset (scikit 64, mnist 1024, smnist 32, shd 256).
  • —readout {rate, li, spike-count, mem-mean} — Output stage. rate (default) sums last-hidden spikes and projects linearly at the final timestep. li is a non-spiking leaky integrator per class with max-over-time — the SHD-standard readout. spike-count and mem-mean are alternative pooling rules used in dt-stability sweeps.
  • —dales-law / —no-dales-law — Enforce Dale’s law (non-negative weights) or allow signed weights. Default on. —no-dales-law is required for cuba-noping and the snnTorch tutorial recipe.
  • —ei-layers INT [INT …] — 1-indexed list of hidden layers that get E-I structure. Default: all layers. PING only.
  • —ei-strength FLOAT — E-I coupling strength s. Sets W_EI = s and W_IE = s·ratio. Default 0.5.
  • —ei-ratio FLOAT — W_IE / W_EI. Default 2.0.
  • —w-in-sparsity FLOAT — Fraction of input weights zeroed at init. Default 0.95 — the network learns from a sparse subset of input units.
  • —dt FLOAT — Integration timestep (ms). Default 0.25.
  • —t-ms FLOAT — Total trial duration (ms). Default 200. For synthetic-step inputs, must exceed STEP_ON_MS (200 ms) or the stimulus window never opens; the CLI warns in this case.
  • —tau-mem FLOAT — Membrane time constant τ_mem (ms). Default 10 ms (models.py’s tau_snn). Cramer et al. SHD: 20 ms.
  • —tau-syn FLOAT — Synaptic time constant τ_syn (ms) for the exponential synapse. Default 2 ms. Cramer et al. SHD: 10 ms. Only affects models with exponential_synapse=True (COBA / PING).
  • —readout-tau-out FLOAT — Output-LIF τ_out (ms) for the spike-count readout. Default 5 ms. Smaller values prevent saturation under high-rate hidden drive — needed by current-based models at coarse dt. No-op for rate / li.
  • —grad-clip FLOAT — Override the default global-norm gradient clip (1.0). Pass a large value (e.g. 1e6) to effectively disable clipping.
  • —tbptt-window INT — TBPTT detach interval (timesteps) for cuba-ping. Default 10. Scale with 1/dt to hold the physical gradient horizon constant when dt changes.
  • —readout-w-out-scale FLOAT — Multiplicative scalar applied to the readout matrix (and bias if present) after build_net. Use to compensate for the ≈10× lower hidden firing rate of COBA models vs CUBA models under mem-mean / spike-count readouts. Train-mode only. Default 1.0.
  • —surrogate-slope FLOAT — Fast-sigmoid surrogate-gradient slope β. Default 1.0. Cramer et al. use 40 for SHD RSNNs.
  • —device {cpu, mps, cuda} — Compute device. Auto-detected if unset: cuda > mps > cpu.

Input

  • —input {synthetic-conductance, synthetic-spikes, dataset} — Stimulus regime. synthetic-spikes (default) is Poisson at —input-rate Hz. synthetic-conductance is a step current with —stim-overdrive multiplier. dataset draws from —dataset. Auto-flip: if —dataset, —digit, or —sample are passed explicitly and —input is left at the default, the parser silently flips —input to dataset. This closes a class of silent footguns where image —dataset mnist —digit 3 used to go through the synthetic-spikes branch and ignore the digit.
  • —input-rate FLOAT — Baseline input rate (Hz). Default 25.
  • —stim-overdrive FLOAT — Stimulus multiplier. Default 1.0.
  • —digit INT — Dataset class (0–9). Default 0.
  • —sample INT — Sample index within the class. Default 0.
  • —dataset {scikit, mnist, smnist, shd} — Dataset. Default scikit. scikit is the 8×8 sklearn digits; mnist is full 28×28; smnist serialises MNIST row-by-row over time; shd is the Spiking Heidelberg Digits audio dataset (700 input channels). SHD is cached under $PINGLAB_SHD_DIR or a default site location.

Weights (advanced)

  • —w-in MEAN [STD] — Input fan-in init. Default 0.3 0.06. Single value sets STD = MEAN × 0.1.
  • —w-ei MEAN STD — Override the W_EI init computed from —ei-strength.
  • —w-ie MEAN STD — Override the W_IE init computed from —ei-strength / —ei-ratio.
  • —trainable-w-ee — Promote COBANet’s E→E recurrent matrix from frozen to gradient-carrying (default frozen). Use for working-memory tasks where the E attractor needs to learn.
  • —trainable-w-ei — Promote E→I from frozen to gradient-carrying. Asks whether the optimiser will discover the PING-loop weights from scratch.
  • —trainable-w-ie — Promote I→E. Same question, opposite direction. The nb049 result gradient descent dismantles PING comes from flipping —trainable-w-ei and —trainable-w-ie on simultaneously.

Output

  • —out-dir DIR — Output directory. Default src/pinglab/artifacts/oscilloscope/.
  • —wipe-dir — Clear the output directory before the run. Off by default.
  • —raster {scatter, imshow} — Raster-plot style. Default scatter.

Execution

  • —seed INT — RNG seed. Seeds Python, NumPy, and torch (CPU + CUDA + MPS) before dataset load and model init. Persisted to config.json.
  • —modal — Re-dispatch this command to Modal.com. Artifacts sync back to —out-dir after completion. Costs money — the project default is local; only flip this on when explicitly instructed.
  • —modal-gpu {none, T4, L4, A10G, A100, H100} — GPU type for Modal runs. Default T4. none runs CPU-only on Modal.

Scan variables

The legal values of —scan-var on the video subcommand are registered in SCAN_DEFAULTS in src/pinglab/cli/scan.py. Each entry pairs a default centre value with a unit label used for axis annotation.

  • stim-overdrive — input multiplier; default 1.0 ×
  • tau_gaba — GABA synaptic decay (ms); default 9.0 ms
  • tau_ampa — AMPA synaptic decay (ms); default 2.0 ms
  • w_ei_mean — W_EI mean (μS); default = W_EI[0]
  • w_ie_mean — W_IE mean (μS); default = W_IE[0]
  • ei_strength — joint scaling of W_EI and W_IE (dimensionless)
  • spike_rate — input Poisson rate (Hz)
  • bias — DC bias conductance (μS)
  • dt — integration timestep (ms)
  • digit — dataset class (integer)
  • noise — additive Poisson noise on input (Hz)

The w_ei_mean and w_ie_mean sweeps scale the existing matrix rather than re-sampling it, so within-sweep variance stays comparable. All other sweeps re-derive the parameter and call into patch_dt if the value affects the integration step.

Inheritance via —from-dir

The trick that makes the notebook chain work is —from-dir. Every train run writes a config.json alongside its weights.pth; image, video, and infer can read that directory and inherit the model, hidden sizes, dataset, E-I parameters, regulariser settings, and seed from it. Any flag the user passes explicitly on the command line wins over the inherited value — the parser builds the set of CLI-explicit flags from sys.argv before applying inheritance.

The inheritance map is defined in _apply_from_dir and covers:

model, dt, t_ms, dataset, hidden_sizes, ei_strength, ei_ratio, sparsity, w_in_sparsity, w_in, input_rate, max_samples, dales_law, ei_layers, seed.

—out-dir is also auto-set to a sibling infer/ subdirectory of the source run when infer inherits, unless overridden.

Legacy model names in older config.json files are remapped via LEGACY_MODEL_ALIASES with a one-line stderr note. Old configs missing the dales_law field trigger a warning prompting the user to pass it explicitly or retrain.

Artifacts and provenance

Every subcommand calls save_run_artifacts on entry, which produces three files in —out-dir:

  • config.json — The parsed argparse namespace, augmented with a provenance block from run_log.provenance() containing git_sha, device, run_id, started_at, and the resolved Python interpreter. Used by —from-dir and by the notebook landing-page metadata extractors.
  • run.sh — The literal sys.argv joined with spaces and prefixed with #!/bin/bash. Re-running this script reproduces the run bit-for-bit (modulo seeded RNG state, which is also in config.json).
  • output.log — File handler attached to the oscilloscope logger. ANSI escape sequences are stripped from the file but preserved on stdout, so terminals see colour while the log stays grep-friendly.

A .running marker file is created during the run (containing PID, start time, run_id, and the invoking command) and removed by an atexit hook on normal exit. Stale .running files signal a crashed run.

Standard outputs by mode

  • simconfig.json, run.sh, output.log only.
  • image — Above, plus the still oscilloscope figure (e.g. snapshot.png) and any input-encoding diagnostic plots.
  • video — Above, plus the sweep MP4 (named after the scan variable) and init_d0s0.png if the input is a dataset.
  • train — Above, plus weights.pth, metrics.json (per-epoch test accuracy and rate stats), metrics.jsonl (per-step training trace), test_predictions.json, and optionally observe_<i>.png per epoch or observe.mp4.
  • inferresults.json (with the dt sweep table and per-dt rate stats), dt_sweep.png, optionally dt_sweep.mp4 and infer_d0s0.png.

The recipe lives in the notebook, not the flag

The one constraint the project imposes on itself: every scientific knob is a flag on the CLI, and every notebook runner src/pinglab/notebooks/nbNNN.py hardcodes its choice of those flags as a literal string. The notebook runner accepts only —tier (size) and —modal-gpu (dispatch target). No lr argument, no epoch argument, no surrogate-slope argument on the notebook itself. New scientific knobs go here, on this CLI, as a flag — the notebook just passes the recipe value inline. If a notebook is reproducing a result, you should be able to read its source and see every choice it made, with no environment variables or .yaml configs to chase. The CLI is the substrate; the notebook is the recipe.