Article 1 derived the continuous-time leaky integrate-and-fire (LIF) neuron from Maxwell’s equations and the membrane circuit. This article picks up where that derivation leaves off and asks: how do you turn the continuous LIF into a discrete update rule that runs on a GPU? Two integrator choices (snnTorch-style and zero-order hold) give two rungs — standard-snn and cuba — both backed by a single Python class, CUBANet, in src/pinglab/models.py. Every empirical result in the notebooks on the CUBA / standard-snn family traces back to the math here.
The implementation lives in src/pinglab/models.py. This article keeps the derivations.
Architecture
CUBANet (current-based LIF) is a single class with one switch:
discretisation ∈ {snntorch, zoh} — how the continuous LIF is discretised. Spikes drive the membrane directly (no synaptic filter); the conductance-filtered story lives one rung up in COBANet.
The two canonical rungs:
rung
discretisation
standard-snn
snnTorch
cuba
ZOH
Tutorial mode (—kaiming-init) is a further switch that overrides Dale’s law and weight scaling to match snn.Leaky bit-for-bit at initialisation, used only by the parity probe.
The LIF primitive
The forward pass and backward pass share a primitive snn_lif_step that performs one timestep of decay + drive + spike + reset:
mem = β * mem + Is = σ_surr(mem − θ)if reset == "subtract": mem = mem − θ · selse: # "zero" mem = mem · (1 − s)
In words: decay the old membrane, add the (already-scaled) drive I, threshold the new membrane to emit a spike, and apply the reset. The caller is responsible for scaling I — that’s where the two discretisations diverge.
snnTorch discretisation
Used by standard-snn. The update is the snnTorch convention — decay times the previous membrane plus the unscaled drive — with the reset applied after the spike is emitted:
Two things to notice. First, the spike is computed on the post-update membrane Ut+1, not the previous one — that’s the order of operations in snn_lif_step. Second, W and b enter the update without a Δt scaling, so the same trained weights produce different per-step contributions at different Δt. This is the dt-fragility shared with snn.Leaky.
Bias-balloon derivation
Take the expectation of (5) under a stationary mean input rate r (Hz). Each step, the expected input is E[st]=rΔt, so E[Wst]=WrΔt. The fixed-point of the membrane is the value Uss that satisfies Uss=βUss+WrΔt+b, giving
Uss=1−βWrΔt+b(7)
For Δt≪τ, use the first-order expansion 1−β≈Δt/τ:
Uss≈Wrτ+Δtbτ(8)
The spike contribution Wrτ is Δt-invariant: halve Δt and the per-step kick halves, but you take twice as many steps. The bias contribution scales as 1/Δt: halve Δt and the steady-state bias contribution doubles. This is why standard-snn with a non-zero bias drifts toward firing-rate saturation as Δt shrinks at eval time.
ZOH discretisation
Used by cuba. Start from the continuous-time LIF in canonical form (EL=0, R=1):
τdtdV=−V+I(t),I(t)=k∑Wδ(t−tk)+b(9)
where the spike train ∑kδ(t−tk) represents incoming presynaptic spikes at times {tk}.
Step 1 — set up an integrating factor
Rewrite (9) as
dtdV+τ1V=τI(t)
This is a first-order linear ODE.
Multiplying both sides of the original ODE by μ(t):
et/τdtdV+τ1et/τV=τI(t)et/τ
The left-hand side is, by the product rule, the derivative of V(t)et/τ:
The decay coefficient on V(t) simplifies using the exponent rule eaeb=ea+b:
et/τe−(t+Δt)/τ=e(t−t−Δt)/τ=e−Δt/τ.
That gives the homogeneous part of the solution: V(t+Δt)=e−Δt/τV(t)+(integral term).
For the integral term, the factor e−(t+Δt)/τ sits outside the integral but is constant with respect to the integration variable s — neither t nor Δt depend on s, they are fixed bounds. A standard property of integrals is that any factor independent of the integration variable can be moved freely across the integral sign:
α∫tt+Δtf(s)ds=∫tt+Δtαf(s)ds,α independent of s.
Applying that here with α=e−(t+Δt)/τ and f(s)=I(s)es/τ:
Inside the integrand the two exponentials now multiply each other. Combining via the same exponent rule:
es/τe−(t+Δt)/τ=e(s−t−Δt)/τ.
So the integral term becomes τ1∫tt+ΔtI(s)e(s−t−Δt)/τds, and we can write the full update as
V(t+Δt)=e−Δt/τV(t)+τ1∫tt+ΔtI(s)e(s−t−Δt)/τds
Step 4 — change of variable ξ=s−t
Then ds=dξ, s=t+ξ, and s−t−Δt=ξ−Δt. The limits transform as s=t⇒ξ=0 and s=t+Δt⇒ξ=Δt.
Substituting:
V(t+Δt)=e−Δt/τV(t)+τ1∫0ΔtI(t+ξ)e−(Δt−ξ)/τdξ(10)
This is exact for any forcing I(t) — no approximation has been made yet.
Step 5 — replace the Dirac by a rectangle
So far we have an exact equation (10) for Vt+1 given Vt and the forcing I over the step. Now we hit the problem the rectangle approximation exists to solve. Suppose exactly one spike lands inside the step at some time tk∈[t,t+Δt]. The Dirac forcing for that spike alone is
I(t+ξ)=Wδ(t+ξ−tk),
i.e. the forcing is zero everywhere across the step except at ξ=tk−t, where it is an infinitely tall pulse of area W. Plugging that into the integral in (10),
τ1∫0Δte−(Δt−ξ)/τWδ(t+ξ−tk)dξ,
and using the sifting property of the Dirac — ∫f(ξ)δ(ξ−a)dξ=f(a) whenever a lies inside the integration interval — the integrand is collapsed to its value at the spike’s location, ξ=tk−t. The contribution to Vt+1 from that single spike comes out as
τWe−(Δt−(tk−t))/τ.
Read the exponent: Δt−(tk−t) is the amount of time between the spike and the end of the step. So the spike contributes more if it lands late in the step (small remaining time means little decay before reaching t+Δt) and less if it lands early (long remaining time means heavy exponential decay). In other words: the exact answer depends on where inside the step the spike arrived — on the sub-step arrival time tk−t, a continuous number between 0 and Δt.
That sub-step time is information the discrete simulator does not carry. Once we have agreed to integrate in steps of size Δt, the only data we keep per neuron per step is “did at least one spike arrive in this step?” — a single Boolean st∈{0,1}. We have collapsed the continuous spike time onto a binary step indicator, and the moment we do that we forfeit the dependence on tk−t that the exact integral wants.
We replace each Dirac that lands in [t,t+Δt] by a rectangle of equal area W and width Δt, i.e. constant height W/Δt. Writing st∈{0,1} for the indicator that a spike occurred in step t, the effective forcing over the step is
I(t+ξ)→ΔtWst+b,ξ∈[0,Δt]
The bias b is already constant, so it is unmodified. Define β≡e−Δt/τ and Vt≡V(t). Substituting into (10):
Vt+1=βVt+τ1∫0Δte−(Δt−ξ)/τ[ΔtWst+b]dξ(11)
Step 6 — pull the constants out of the integral
The bracket is independent of ξ, so
Vt+1=βVt+[ΔtWst+b]⋅call this Jτ1∫0Δte−(Δt−ξ)/τdξ
Step 7 — evaluate J by u-substitution
Let u=Δt−ξ. Then du=−dξ, i.e. dξ=−du. The limits transform as ξ=0⇒u=Δt and ξ=Δt⇒u=0. Substituting:
J=τ1∫u=Δtu=0e−u/τ(−du)
The negative sign flips the limits:
J=τ1∫0Δte−u/τdu
The antiderivative of e−u/τ with respect to u is −τe−u/τ (check: dud[−τe−u/τ]=−τ⋅(−1/τ)e−u/τ=e−u/τ). Evaluate:
Renaming V→U to match the code’s symbol for membrane potential:
Ut+1=βUt+Δt1−βWst+(1−β)b(13)
In the code, the two prefactors are computed once per step as spike_scale=(1−β)/Δt and bias_scale=1−β; see models.py:705-710.
Step 9 — Δt-invariance, part 1: bias steady state
In the absence of spikes (st=0), (13) reduces to Ut+1=βUt+(1−β)b. Setting Ut+1=Ut=Uˉ for the fixed point:
Uˉ=βUˉ+(1−β)b⟹Uˉ−βUˉ=(1−β)b⟹(1−β)Uˉ=(1−β)b⟹Uˉ=b
The steady-state membrane equals b regardless of Δt. ✓
Step 10 — Δt-invariance, part 2: single-spike time integral
Consider a network at rest, U0=0, that receives one spike at step 0 (s0=1, st=0 for t≥1), with b=0. Then from (13):
U1=β⋅0+Δt1−βW⋅1+0=K,K≡Δt1−βW
For t≥1 there is no further forcing, so Ut+1=βUt, which means Ut=Kβt−1 for t≥1. To get a Δt-invariant quantity, integrate U against time using a left-Riemann sum with step Δt:
The sum is a geometric series with ratio β∈(0,1), so ∑m=0∞βm=1/(1−β). Substituting:
∫0∞U(t)dt≈1−βKΔt=1−β1⋅Δt1−βW⋅Δt=W
The result is exactly W, independent of Δt. The corresponding continuous-time impulse response of (9) is V(t)=(W/τ)e−t/τ1t≥0, whose time integral is
∫0∞τWe−t/τdt=τW⋅τ[1−0]=W
The discrete and continuous time integrals agree. ✓
Step 11 — small-Δt limit (sanity check)
Taylor-expand β=e−Δt/τ around Δt=0:
β=1−τΔt+21(τΔt)2−⋯⟹1−β=τΔt−21(τΔt)2+⋯
To leading order in Δt/τ:
(1−β)/Δt=1/τ−Δt/(2τ2)+O(Δt2)→1/τ.
(1−β)=Δt/τ+O(Δt2), so (1−β)/Δt=1/τ+O(Δt).
In words: the per-spike kick (1−β)/Δt⋅W approaches W/τ — i.e. the impulse response of the continuous LIF — and the per-step bias contribution (1−β)b shrinks linearly with Δt. The number of steps per ms scales as 1/Δt, so the per-millisecond bias contribution is (1−β)b/Δt→b/τ, also Δt-invariant.
Contrast with snnTorch
In the snnTorch discretisation, W and b have prefactor 1 regardless of Δt, so the per-spike kick is W (not W/τ) and the per-step bias is b (not bΔt/τ). Changing Δt at eval time therefore shifts the effective input scale in standard-snn, while ZOH leaves it fixed — verified empirically in 013 — dt sensitivity.
Reset modes
Two choices on spike, controlled by —reset-mode:
zero — hard reset Ut+1←0. Discards overshoot. Standard for biological neurons.
subtract — Ut+1←Ut+1−θ. Preserves overshoot above threshold for the next step. Standard for snnTorch tutorials.
Defaults: standard-snn uses subtract under tutorial-mode; cuba uses zero.
Refractory period
Optional, controlled by —ref-ms. Implemented as a per-neuron can_fire mask in snn_lif_step: during refractory the membrane is clamped to Vreset and the spike output is forced to zero. Default 0 ms (no refractory) for the CUBA-family rungs.
Where this leads
The two CUBA rungs span the design space of a single LIF cell with current-based inputs. The next step up the ladder adds genuine conductance-based synapses and a recurrent E-I loop — the COBA and PING models, documented in COBANet and exercised throughout the notebooks.