Skip to content

Neurons

btorch.models.neurons

Attributes

__all__ = ['LIF', 'ALIF', 'ELIF', 'GLIF3', 'Izhikevich', 'MixedNeuronPopulation', 'TwoCompartmentGLIF'] module-attribute

Classes

ALIF

Bases: BaseNode

Adaptive leaky integrate-and-fire neuron with conductance adaptation.

The ALIF model extends standard LIF by adding a voltage-dependent potassium conductance (g_k) that creates spike-frequency adaptation. Each spike increases g_k by dg_k, which then decays exponentially.

Dynamics

dv/dt = (-g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m dg_k/dt = -g_k / tau_adapt

At spike: g_k += dg_k

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold (mV). Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike (mV). Default: 0.0.

0.0
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance (pF). Default: 1.0.

1.0
g_leak float | Float[TensorLike, ' n_neuron']

Leak conductance (nS). Default: 1.0.

1.0
E_leak float | Float[TensorLike, ' n_neuron']

Leak reversal potential (mV). Default: 0.0.

0.0
E_k float | Float[TensorLike, ' n_neuron']

Potassium reversal potential (mV). Default: -70.0.

-70.0
g_k_init float | Float[TensorLike, ' n_neuron']

Initial adaptation conductance (nS). Default: 0.0.

0.0
tau_adapt float | Float[TensorLike, ' n_neuron']

Adaptation time constant (ms). Default: 20.0.

20.0
dg_k float | Float[TensorLike, ' n_neuron']

Adaptation increment per spike (nS). Default: 0.0.

0.0
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period (ms). None disables refractory. Default: None.

None
trainable_param set[str]

Set of parameter names to make trainable.

set()
surrogate_function Callable

Surrogate gradient function. Default: Sigmoid().

Sigmoid()
detach_reset bool

If True, detach reset signal. Default: False.

False
hard_reset bool

If True, use hard reset. Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None

Attributes:

Name Type Description
v Tensor

Membrane potential, shape (*batch, n_neuron).

g_k Tensor

Adaptation conductance, shape (*batch, n_neuron).

refractory Tensor | None

Refractory counter (if tau_ref specified).

c_m, (g_leak, E_leak, E_k)

Neuron parameters.

tau_adapt Tensor | Parameter

Adaptation time constant.

dg_k Tensor | Parameter

Per-spike adaptation increment.

Source code in btorch/models/neurons/alif.py
class ALIF(BaseNode):
    """Adaptive leaky integrate-and-fire neuron with conductance adaptation.

    The ALIF model extends standard LIF by adding a voltage-dependent
    potassium conductance (g_k) that creates spike-frequency adaptation.
    Each spike increases g_k by dg_k, which then decays exponentially.

    Dynamics:
        dv/dt = (-g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m
        dg_k/dt = -g_k / tau_adapt

        At spike: g_k += dg_k

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold (mV). Default: 1.0.
        v_reset: Reset voltage after spike (mV). Default: 0.0.
        c_m: Membrane capacitance (pF). Default: 1.0.
        g_leak: Leak conductance (nS). Default: 1.0.
        E_leak: Leak reversal potential (mV). Default: 0.0.
        E_k: Potassium reversal potential (mV). Default: -70.0.
        g_k_init: Initial adaptation conductance (nS). Default: 0.0.
        tau_adapt: Adaptation time constant (ms). Default: 20.0.
        dg_k: Adaptation increment per spike (nS). Default: 0.0.
        tau_ref: Refractory period (ms). None disables refractory.
            Default: None.
        trainable_param: Set of parameter names to make trainable.
        surrogate_function: Surrogate gradient function. Default: Sigmoid().
        detach_reset: If True, detach reset signal. Default: False.
        hard_reset: If True, use hard reset. Default: False.
        pre_spike_v: If True, store pre-spike voltage. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.

    Attributes:
        v: Membrane potential, shape (*batch, n_neuron).
        g_k: Adaptation conductance, shape (*batch, n_neuron).
        refractory: Refractory counter (if tau_ref specified).
        c_m, g_leak, E_leak, E_k: Neuron parameters.
        tau_adapt: Adaptation time constant.
        dg_k: Per-spike adaptation increment.
    """

    g_k: torch.Tensor
    refractory: torch.Tensor | None

    c_m: torch.Tensor | torch.nn.Parameter
    g_leak: torch.Tensor | torch.nn.Parameter
    E_leak: torch.Tensor | torch.nn.Parameter
    E_k: torch.Tensor | torch.nn.Parameter
    tau_adapt: torch.Tensor | torch.nn.Parameter
    dg_k: torch.Tensor | torch.nn.Parameter
    tau_ref: torch.Tensor | torch.nn.Parameter | None

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        c_m: float | Float[TensorLike, " n_neuron"] = 1.0,
        g_leak: float | Float[TensorLike, " n_neuron"] = 1.0,
        E_leak: float | Float[TensorLike, " n_neuron"] = 0.0,
        E_k: float | Float[TensorLike, " n_neuron"] = -70.0,
        g_k_init: float | Float[TensorLike, " n_neuron"] = 0.0,
        tau_adapt: float | Float[TensorLike, " n_neuron"] = 20.0,
        dg_k: float | Float[TensorLike, " n_neuron"] = 0.0,
        tau_ref: float | Float[TensorLike, " n_neuron"] | None = None,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            hard_reset=hard_reset,
            pre_spike_v=pre_spike_v,
            step_mode=step_mode,
            backend=backend,
            device=device,
            dtype=dtype,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.def_param(
            "c_m",
            c_m,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "g_leak",
            g_leak,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "E_leak",
            E_leak,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "E_k",
            E_k,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "tau_adapt",
            tau_adapt,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "dg_k",
            dg_k,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self._use_refractory = tau_ref is not None
        if self._use_refractory:
            self.def_param(
                "tau_ref",
                tau_ref,
                trainable_param=self.trainable_param,
                **_factory_kwargs,
            )
            self.register_memory("refractory", 0.0, self.n_neuron)
        else:
            self.tau_ref = None

        self.register_memory("g_k", g_k_init, self.n_neuron)

    @property
    def v_rest(self):
        if self._v_rest is None:
            return self.v_reset
        return self._v_rest

    @v_rest.setter
    def v_rest(self, v_rest):
        if self._v_rest is not None:
            self._v_rest = v_rest

    @property
    def v_peak(self):
        return self.v_threshold

    @v_peak.setter
    def v_peak(self, value):
        self.v_threshold = value

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        g_k: Float[Tensor, "*batch n_neuron"],
        x: Float[Tensor, "*batch n_neuron"],
    ):
        leak_term = -self.g_leak * (v - self.E_leak)
        adapt_term = -g_k * (v - self.E_k)
        derivative = (leak_term + adapt_term + x) / self.c_m
        linear = (-self.g_leak - g_k) / self.c_m
        return derivative, linear

    def dgk(
        self, g_k: Float[Tensor, "*batch n_neuron"]
    ) -> Float[Tensor, "*batch n_neuron"]:
        derivative = -g_k / self.tau_adapt
        linear = -1.0 / self.tau_adapt
        return derivative, linear

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        dt = environ.get("dt")
        self.v = exp_euler_step(self.dV, self.v, self.g_k, x, dt=dt)

    def neuronal_adaptation(self):
        dt = environ.get("dt")
        self.g_k = exp_euler_step(self.dgk, self.g_k, dt=dt)

    def neuronal_fire(self):
        spike = self.surrogate_function(
            (self.v - self.v_threshold) / (self.v_threshold - self.v_reset)
        )
        if not self._use_refractory:
            return spike
        not_in_refractory = self.refractory == 0
        spike = spike * not_in_refractory.detach().to(self.v.dtype)
        return spike

    def neuronal_reset(self, spike: Float[Tensor, "*batch n"]):
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

        self.g_k = self.g_k + self.dg_k * spike_d

        if self._use_refractory:
            self.refractory = torch.relu(
                self.refractory + spike_d * self.tau_ref - environ.get("dt")
            )

    def extra_repr(self):
        g_k_init = self._memories_rv["g_k"].value
        parts = [
            f"c_m={self._format_repr_value(self.c_m)}",
            f"g_leak={self._format_repr_value(self.g_leak)}",
            f"E_leak={self._format_repr_value(self.E_leak)}",
            f"E_k={self._format_repr_value(self.E_k)}",
            f"tau_adapt={self._format_repr_value(self.tau_adapt)}",
            f"dg_k={self._format_repr_value(self.dg_k)}",
            f"g_k_init={self._format_repr_value(g_k_init)}",
            f"tau_ref={self._format_repr_value(self.tau_ref)}"
            if self._use_refractory
            else "tau_ref=None",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)

ELIF

Bases: ALIF

Exponential integrate-and-fire neuron with adaptation.

The ELIF model extends ALIF by adding an exponential term to the voltage dynamics, creating a sharp upswing when approaching threshold (the "initiation zone"). This captures the rapid depolarization seen in real neurons.

Dynamics

dv/dt = (g_leak * delta_T * exp((v - v_T) / delta_T) - g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m dg_k/dt = -g_k / tau_adapt

The exponential term creates a soft threshold effect where membrane potential accelerates as it approaches v_T.

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold (mV). Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike (mV). Default: 0.0.

0.0
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance (pF). Default: 1.0.

1.0
g_leak float | Float[TensorLike, ' n_neuron']

Leak conductance (nS). Default: 1.0.

1.0
E_leak float | Float[TensorLike, ' n_neuron']

Leak reversal potential (mV). Default: 0.0.

0.0
E_k float | Float[TensorLike, ' n_neuron']

Potassium reversal potential (mV). Default: -70.0.

-70.0
g_k_init float | Float[TensorLike, ' n_neuron']

Initial adaptation conductance (nS). Default: 0.0.

0.0
tau_adapt float | Float[TensorLike, ' n_neuron']

Adaptation time constant (ms). Default: 20.0.

20.0
dg_k float | Float[TensorLike, ' n_neuron']

Adaptation increment per spike (nS). Default: 0.0.

0.0
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period (ms). Default: 0.0.

0.0
delta_T float | Float[TensorLike, ' n_neuron']

Slope factor for exponential term (mV). Default: 1.0.

1.0
v_T float | Float[TensorLike, ' n_neuron']

Soft threshold potential (mV). Default: 0.0.

0.0
trainable_param set[str]

Set of parameter names to make trainable.

set()
surrogate_function Callable

Surrogate gradient function. Default: Sigmoid().

Sigmoid()
detach_reset bool

If True, detach reset signal. Default: False.

False
hard_reset bool

If True, use hard reset. Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None

Attributes:

Name Type Description
delta_T Tensor | Parameter

Slope factor for exponential term.

v_T Tensor | Parameter

Soft threshold potential.

Source code in btorch/models/neurons/alif.py
class ELIF(ALIF):
    """Exponential integrate-and-fire neuron with adaptation.

    The ELIF model extends ALIF by adding an exponential term to the
    voltage dynamics, creating a sharp upswing when approaching threshold
    (the "initiation zone"). This captures the rapid depolarization seen
    in real neurons.

    Dynamics:
        dv/dt = (g_leak * delta_T * exp((v - v_T) / delta_T)
                 - g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m
        dg_k/dt = -g_k / tau_adapt

    The exponential term creates a soft threshold effect where membrane
    potential accelerates as it approaches v_T.

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold (mV). Default: 1.0.
        v_reset: Reset voltage after spike (mV). Default: 0.0.
        c_m: Membrane capacitance (pF). Default: 1.0.
        g_leak: Leak conductance (nS). Default: 1.0.
        E_leak: Leak reversal potential (mV). Default: 0.0.
        E_k: Potassium reversal potential (mV). Default: -70.0.
        g_k_init: Initial adaptation conductance (nS). Default: 0.0.
        tau_adapt: Adaptation time constant (ms). Default: 20.0.
        dg_k: Adaptation increment per spike (nS). Default: 0.0.
        tau_ref: Refractory period (ms). Default: 0.0.
        delta_T: Slope factor for exponential term (mV). Default: 1.0.
        v_T: Soft threshold potential (mV). Default: 0.0.
        trainable_param: Set of parameter names to make trainable.
        surrogate_function: Surrogate gradient function. Default: Sigmoid().
        detach_reset: If True, detach reset signal. Default: False.
        hard_reset: If True, use hard reset. Default: False.
        pre_spike_v: If True, store pre-spike voltage. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.

    Attributes:
        delta_T: Slope factor for exponential term.
        v_T: Soft threshold potential.
    """

    delta_T: torch.Tensor | torch.nn.Parameter
    v_T: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        c_m: float | Float[TensorLike, " n_neuron"] = 1.0,
        g_leak: float | Float[TensorLike, " n_neuron"] = 1.0,
        E_leak: float | Float[TensorLike, " n_neuron"] = 0.0,
        E_k: float | Float[TensorLike, " n_neuron"] = -70.0,
        g_k_init: float | Float[TensorLike, " n_neuron"] = 0.0,
        tau_adapt: float | Float[TensorLike, " n_neuron"] = 20.0,
        dg_k: float | Float[TensorLike, " n_neuron"] = 0.0,
        tau_ref: float | Float[TensorLike, " n_neuron"] | None = 0.0,
        delta_T: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_T: float | Float[TensorLike, " n_neuron"] = 0.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            c_m=c_m,
            g_leak=g_leak,
            E_leak=E_leak,
            E_k=E_k,
            g_k_init=g_k_init,
            tau_adapt=tau_adapt,
            dg_k=dg_k,
            tau_ref=tau_ref,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            hard_reset=hard_reset,
            pre_spike_v=pre_spike_v,
            step_mode=step_mode,
            backend=backend,
            device=device,
            dtype=dtype,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.def_param(
            "delta_T",
            delta_T,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_T",
            v_T,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        g_k: Float[Tensor, "*batch n_neuron"],
        x: Float[Tensor, "*batch n_neuron"],
    ):
        leak_term = -self.g_leak * (v - self.E_leak)
        adapt_term = -g_k * (v - self.E_k)
        exp_term = self.g_leak * self.delta_T * torch.exp((v - self.v_T) / self.delta_T)
        derivative = (leak_term + adapt_term + exp_term + x) / self.c_m
        linear = (-self.g_leak - g_k + exp_term / self.delta_T) / self.c_m
        return derivative, linear

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        dt = environ.get("dt")
        self.v = exp_euler_step(self.dV, self.v, self.g_k, x, dt=dt)

    def extra_repr(self):
        parts = [
            f"delta_T={self._format_repr_value(self.delta_T)}",
            f"v_T={self._format_repr_value(self.v_T)}",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)

GLIF3

Bases: BaseNode

GLIF3 model with after-spike currents and refractory period.

The GLIF3 model extends standard LIF by adding after-spike currents (ASC) that capture spike-frequency adaptation. Each spike adds asc_amps to the ASC vector, which then decays exponentially with time constants 1/k.

Dynamics

dV/dt = -(V - V_rest) / tau + (I_in + sum(I_asc)) / c_m dI_asc/dt = -k * I_asc

At spike: I_asc += asc_amps

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold (mV). Default: -50.0.

-50.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike (mV). Default: -70.0.

-70.0
v_rest None | float | Float[TensorLike, ' n_neuron']

Resting potential (mV). Defaults to v_reset if None.

None
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance (pF). Default: 0.05.

0.05
tau float | Float[TensorLike, ' n_neuron']

Membrane time constant (ms). Default: 20.0.

20.0
k float | Sequence[float] | Float[TensorLike, 'n_neuron {self.n_Iasc}']

ASC decay rates (ms^-1), can be list for multiple ASC components. Default: [0.2].

[0.2]
asc_amps float | Sequence[float] | Float[TensorLike, 'n_neuron {self.n_Iasc}']

ASC amplitudes (pA) added at each spike. Default: [0.0].

[0.0]
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period (ms). Default: 0.0.

0.0
trainable_param set[str]

Set of parameter names to make trainable.

set()
surrogate_function Callable

Surrogate gradient function. Default: Erf(alpha=4, damping_factor=0.5), matching the Gaussian surrogate used in Chen et al. (2022).

Erf(alpha=4.0, damping_factor=0.5)
detach_reset bool

If True, detach reset signal. Default: False.

False
hard_reset bool

If True, use hard reset. Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None

Attributes:

Name Type Description
v Tensor

Membrane potential, shape (*batch, n_neuron).

Iasc Tensor

After-spike currents, shape (*batch, n_neuron, n_Iasc).

refractory Tensor | None

Refractory counter (if tau_ref > 0).

c_m, (tau, tau_ref)

Neuron parameters.

k Tensor | Parameter

ASC decay rates, shape (n_neuron, n_Iasc) or (n_Iasc,).

asc_amps Tensor | Parameter

ASC amplitudes, shape (n_neuron, n_Iasc) or (n_Iasc,).

n_Iasc int

Number of ASC components.

References

Teeter et al., "Generalized leaky integrate-and-fire models classify multiple neuron types," Nature Communications, 2018.

Chen, G., Scherr, F., & Maass, W. (2022). A data-based large-scale model for primary visual cortex enables brain-like robust and versatile visual processing. Science Advances, 8(44), eabq7592. https://doi.org/10.1126/sciadv.abq7592

Source code in btorch/models/neurons/glif.py
class GLIF3(BaseNode):
    """GLIF3 model with after-spike currents and refractory period.

    The GLIF3 model extends standard LIF by adding after-spike currents
    (ASC) that capture spike-frequency adaptation. Each spike adds
    asc_amps to the ASC vector, which then decays exponentially with
    time constants 1/k.

    Dynamics:
        dV/dt = -(V - V_rest) / tau + (I_in + sum(I_asc)) / c_m
        dI_asc/dt = -k * I_asc

        At spike: I_asc += asc_amps

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold (mV). Default: -50.0.
        v_reset: Reset voltage after spike (mV). Default: -70.0.
        v_rest: Resting potential (mV). Defaults to v_reset if None.
        c_m: Membrane capacitance (pF). Default: 0.05.
        tau: Membrane time constant (ms). Default: 20.0.
        k: ASC decay rates (ms^-1), can be list for multiple ASC components.
            Default: [0.2].
        asc_amps: ASC amplitudes (pA) added at each spike.
            Default: [0.0].
        tau_ref: Refractory period (ms). Default: 0.0.
        trainable_param: Set of parameter names to make trainable.
        surrogate_function: Surrogate gradient function.
            Default: ``Erf(alpha=4, damping_factor=0.5)``, matching the
            Gaussian surrogate used in Chen et al. (2022).
        detach_reset: If True, detach reset signal. Default: False.
        hard_reset: If True, use hard reset. Default: False.
        pre_spike_v: If True, store pre-spike voltage. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.

    Attributes:
        v: Membrane potential, shape (*batch, n_neuron).
        Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).
        refractory: Refractory counter (if tau_ref > 0).
        c_m, tau, tau_ref: Neuron parameters.
        k: ASC decay rates, shape (n_neuron, n_Iasc) or (n_Iasc,).
        asc_amps: ASC amplitudes, shape (n_neuron, n_Iasc) or (n_Iasc,).
        n_Iasc: Number of ASC components.

    References:
        Teeter et al., "Generalized leaky integrate-and-fire models classify
        multiple neuron types," *Nature Communications*, 2018.

        Chen, G., Scherr, F., & Maass, W. (2022). A data-based large-scale
        model for primary visual cortex enables brain-like robust and versatile
        visual processing. *Science Advances*, 8(44), eabq7592.
        https://doi.org/10.1126/sciadv.abq7592
    """

    # make mypy typing and autocompletion easier
    Iasc: torch.Tensor
    refractory: torch.Tensor | None

    c_m: torch.Tensor | torch.nn.Parameter
    tau: torch.Tensor | torch.nn.Parameter
    tau_ref: torch.Tensor | torch.nn.Parameter | None
    k: torch.Tensor | torch.nn.Parameter
    asc_amps: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = -50.0,  # mV
        v_reset: float | Float[TensorLike, " n_neuron"] = -70.0,  # mV
        v_rest: None | float | Float[TensorLike, " n_neuron"] = None,
        c_m: float | Float[TensorLike, " n_neuron"] = 0.05,  # 1/20 pfarad
        tau: float | Float[TensorLike, " n_neuron"] = 20.0,  # ms
        k: float | Sequence[float] | Float[TensorLike, "n_neuron {self.n_Iasc}"] = [
            0.2
        ],  # ms^-1
        asc_amps: float
        | Sequence[float]
        | Float[TensorLike, "n_neuron {self.n_Iasc}"] = [0.0],  # pA
        tau_ref: float | Float[TensorLike, " n_neuron"] | None = 0.0,  # ms
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Erf(alpha=4.0, damping_factor=0.5),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            step_mode=step_mode,
            backend=backend,
            pre_spike_v=pre_spike_v,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.hard_reset = hard_reset
        self.def_param(
            "c_m",
            c_m,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "tau",
            tau,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self._use_refractory = tau_ref is not None
        if self._use_refractory:
            self.def_param(
                "tau_ref",
                tau_ref,
                trainable_param=self.trainable_param,
                **_factory_kwargs,
            )
            self.register_memory("refractory", 0.0, self.n_neuron)
        else:
            self.tau_ref = None

        # for compat
        if v_rest is not None:
            self.def_param(
                "_v_rest",
                v_rest,
                trainable_param=self.trainable_param,
                **_factory_kwargs,
            )
        else:
            self._v_rest = None

        # Handle after-spike currents.
        if isinstance(asc_amps, Number):
            asc_amps = [asc_amps]
        if isinstance(k, Number):
            k = [k]

        resolved_asc_sizes = self.def_param_resolve_sizes(
            k,
            asc_amps,
            sizes=self.n_neuron + (None,),
        )
        self.n_Iasc: int = resolved_asc_sizes[-1]

        self.def_param(
            "k",
            k,
            sizes=resolved_asc_sizes,
            trainable_param=self.trainable_param,
            normalize_to_sizes=True,
            **_factory_kwargs,
        )
        self.def_param(
            "asc_amps",
            asc_amps,
            sizes=resolved_asc_sizes,
            trainable_param=self.trainable_param,
            normalize_to_sizes=True,
            **_factory_kwargs,
        )

        self.register_memory(
            "Iasc",
            [
                0.0,
            ]
            * self.n_Iasc,
            self.n_neuron + (self.n_Iasc,),
        )

    @property
    def v_rest(self) -> torch.Tensor:
        """Resting potential (mV).

        For compatibility with GLIF4/GLIF5, falls back to v_reset if
        not explicitly set during initialization.

        Returns:
            Resting potential tensor.
        """
        if self._v_rest is None:
            return self.v_reset
        return self._v_rest

    @v_rest.setter
    def v_rest(self, v_rest: float | torch.Tensor):
        """Set resting potential.

        Args:
            v_rest: New resting potential value (mV).
        """
        if self._v_rest is not None:
            self._v_rest = v_rest

    def dIasc(self, Iasc: Float[Tensor, "*batch n_neuron {self.n_Iasc}"]) -> tuple:
        """Compute ASC derivative for exponential Euler integration.

        Args:
            Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).

        Returns:
            Tuple of (derivative, linear_coefficient) for exp_euler_step.
        """
        return -self.k * Iasc, -self.k

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        Iasc: Float[Tensor, "*batch n_neuron {self.n_Iasc}"],
        x: Float[Tensor, "*batch n_neuron"],
    ) -> tuple:
        """Compute membrane potential derivative for exp Euler integration.

        Args:
            v: Membrane potential, shape (*batch, n_neuron).
            Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).
            x: Input current, shape (*batch, n_neuron).

        Returns:
            Tuple of (derivative, linear_coefficient) for exp_euler_step.
        """
        Isum = x
        # torch.autocast will cast half to float32 for sum op
        # see https://docs.pytorch.org/docs/stable/amp.html#ops-that-can-autocast-to-float32
        # here Iasc generally only have <4 modes, so no overflow guaranteed
        return (
            -(v - self.v_rest) / self.tau
            + (Isum + Iasc.sum(-1, dtype=Iasc.dtype)) / self.c_m,
            -1.0 / self.tau,
        )

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        v = exp_euler_step(self.dV, self.v, self.Iasc, x, dt=environ.get("dt"))
        self.v = v

    def neuronal_adaptation(self):
        self.Iasc = exp_euler_step(self.dIasc, self.Iasc, dt=environ.get("dt"))

    def neuronal_fire(self):
        # Check if voltage exceeds threshold and not in refractory period
        spike = self.surrogate_function(
            (self.v - self.v_threshold) / (self.v_threshold - self.v_reset)
        )
        if not self._use_refractory:
            return spike
        not_in_refractory = self.refractory == 0
        spike = spike * not_in_refractory.detach().to(self.v.dtype)
        return spike

    def neuronal_reset(self, spike: Float[Tensor, "*batch n"]):
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            # hard reset
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            # soft reset
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

        # Add after-spike currents
        self.Iasc = self.Iasc + self.asc_amps * spike_d[..., None]

        if self._use_refractory:
            # Set refractory period
            self.refractory = torch.relu(
                self.refractory + spike_d * self.tau_ref - environ.get("dt")
            )

    def get_rheobase(self):
        """Calculate rheobase current, the minimum constant input current
        required to make the neuron fire."""
        return get_rheobase(self.v_threshold, self.v_rest, self.c_m, self.tau)

    def extra_repr(self):
        parts = [
            f"c_m={self._format_repr_value(self.c_m)}",
            f"tau={self._format_repr_value(self.tau)}",
            f"tau_ref={self._format_repr_value(self.tau_ref)}"
            if self._use_refractory
            else "tau_ref=None",
            f"n_Iasc={self.n_Iasc}",
            f"k={self._format_repr_value(self.k)}",
            f"asc_amps={self._format_repr_value(self.asc_amps)}",
            "v_rest=auto"
            if self._v_rest is None
            else f"v_rest={self._format_repr_value(self._v_rest)}",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)

    # TODO: headache to define precise input-output shapes
    # TODO: shape handling not torch.compile friendly
    def _normalize_state_shapes(
        self,
        x: TensorLike | float,
        v0: TensorLike | float,
        Iasc0: TensorLike | float,
        dt: TensorLike | float,
        device: torch.device | None = None,
        dtype: torch.dtype | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
        device = device or self.v_reset.device
        dtype = dtype or self.v_reset.dtype
        x, v0, Iasc0 = (
            torch.as_tensor(x, device=device, dtype=dtype),
            torch.as_tensor(v0, device=device, dtype=dtype),
            torch.as_tensor(Iasc0, device=device, dtype=dtype),
        )
        if isinstance(dt, float):
            dt = torch.tensor([dt], device=device, dtype=dtype)
        else:
            dt = torch.as_tensor(dt, device=device, dtype=dtype)

        shapes = (x.shape, v0.shape, Iasc0.shape[:-1])
        longest_shape = max(shapes, key=len)
        if dt.shape[0] != longest_shape[0]:
            dt = expand_trailing_dims(dt, longest_shape, broadcast_only=True)

        return x, v0, Iasc0, dt

    def forward_exact_no_spike(
        self,
        x: Float[Tensor, "*batch #neuron"] | Float[Tensor, "*batch"],
        v0: Float[Tensor, "*batch neuron"] | None = None,
        Iasc0: Float[Tensor, "*batch neuron {self.n_Iasc}"] | None = None,
        dt: float
        | Float[TensorLike, "#time *batch neuron"]
        | Float[TensorLike, "#*batch neuron"]
        | Float[TensorLike, "#time *batch"]
        | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        if dt is None:
            dt = environ.get("dt")

        update = (v0 is None) and (Iasc0 is None)
        if v0 is None:
            v0 = self.v
        if Iasc0 is None:
            Iasc0 = self.Iasc

        x, v0, Iasc0, dt = self._normalize_state_shapes(x, v0, Iasc0, dt)

        v_inf = self.v_reset + x * self.tau / self.c_m

        exp_m = torch.exp(-dt / self.tau)
        # (time, batch, neuron, n_Iasc)
        exp_asc = torch.exp(-dt[..., None] * self.k)

        Iasc = Iasc0 * exp_asc

        # degenerate case if tau=tau_asc=1/k
        Iasc_contrib = torch.where(
            torch.abs(self.k - 1 / self.tau[..., None]) > 1e-12,
            (Iasc0 / self.c_m[..., None])
            * (exp_asc - exp_m[..., None])
            / (1.0 / self.tau[..., None] - self.k),
            (Iasc0 / self.c_m[..., None]) * (dt * exp_m)[..., None],
        )
        v = v_inf + (v0 - v_inf) * exp_m + Iasc_contrib.sum(dim=-1)

        if update:
            self.v = v
            self.Iasc = Iasc
        return v, Iasc
Attributes
v_rest property writable

Resting potential (mV).

For compatibility with GLIF4/GLIF5, falls back to v_reset if not explicitly set during initialization.

Returns:

Type Description
Tensor

Resting potential tensor.

Functions
dIasc(Iasc)

Compute ASC derivative for exponential Euler integration.

Parameters:

Name Type Description Default
Iasc Float[Tensor, '*batch n_neuron {self.n_Iasc}']

After-spike currents, shape (*batch, n_neuron, n_Iasc).

required

Returns:

Type Description
tuple

Tuple of (derivative, linear_coefficient) for exp_euler_step.

Source code in btorch/models/neurons/glif.py
def dIasc(self, Iasc: Float[Tensor, "*batch n_neuron {self.n_Iasc}"]) -> tuple:
    """Compute ASC derivative for exponential Euler integration.

    Args:
        Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).

    Returns:
        Tuple of (derivative, linear_coefficient) for exp_euler_step.
    """
    return -self.k * Iasc, -self.k
dV(v, Iasc, x)

Compute membrane potential derivative for exp Euler integration.

Parameters:

Name Type Description Default
v Float[Tensor, '*batch n_neuron']

Membrane potential, shape (*batch, n_neuron).

required
Iasc Float[Tensor, '*batch n_neuron {self.n_Iasc}']

After-spike currents, shape (*batch, n_neuron, n_Iasc).

required
x Float[Tensor, '*batch n_neuron']

Input current, shape (*batch, n_neuron).

required

Returns:

Type Description
tuple

Tuple of (derivative, linear_coefficient) for exp_euler_step.

Source code in btorch/models/neurons/glif.py
def dV(
    self,
    v: Float[Tensor, "*batch n_neuron"],
    Iasc: Float[Tensor, "*batch n_neuron {self.n_Iasc}"],
    x: Float[Tensor, "*batch n_neuron"],
) -> tuple:
    """Compute membrane potential derivative for exp Euler integration.

    Args:
        v: Membrane potential, shape (*batch, n_neuron).
        Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).
        x: Input current, shape (*batch, n_neuron).

    Returns:
        Tuple of (derivative, linear_coefficient) for exp_euler_step.
    """
    Isum = x
    # torch.autocast will cast half to float32 for sum op
    # see https://docs.pytorch.org/docs/stable/amp.html#ops-that-can-autocast-to-float32
    # here Iasc generally only have <4 modes, so no overflow guaranteed
    return (
        -(v - self.v_rest) / self.tau
        + (Isum + Iasc.sum(-1, dtype=Iasc.dtype)) / self.c_m,
        -1.0 / self.tau,
    )
get_rheobase()

Calculate rheobase current, the minimum constant input current required to make the neuron fire.

Source code in btorch/models/neurons/glif.py
def get_rheobase(self):
    """Calculate rheobase current, the minimum constant input current
    required to make the neuron fire."""
    return get_rheobase(self.v_threshold, self.v_rest, self.c_m, self.tau)

Izhikevich

Bases: BaseNode

Izhikevich neuron with quadratic dynamics and recovery variable.

Efficient model reproducing diverse spiking patterns (tonic, bursting, etc.) via a 2D ODE system with quadratic nonlinearity.

Dynamics

dv/dt = (k(v-v_rest)(v-v_threshold) - u + I) / c_m du/dt = a * (b*(v-v_rest) - u)

At spike: v=v_reset, u=u+d

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons.

required
v_threshold float | Float[TensorLike, ' n_neuron']

Threshold (mV). Default: 30.0.

30.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage (mV). Default: -65.0.

-65.0
v_rest float | Float[TensorLike, ' n_neuron']

Resting potential (mV). Default: -65.0.

-65.0
v_peak float | Float[TensorLike, ' n_neuron']

Spike cutoff (mV). Default: -40.0.

-40.0
c_m float | Float[TensorLike, ' n_neuron']

Capacitance (pF). Default: 100.0.

100.0
k float | Float[TensorLike, ' n_neuron']

Scaling factor (nS/mV). Default: 0.7.

0.7
a float | Float[TensorLike, ' n_neuron']

Recovery timescale (ms^-1). Default: 0.03.

0.03
b float | Float[TensorLike, ' n_neuron']

Recovery coupling (nS). Default: -2.0.

-2.0
d float | Float[TensorLike, ' n_neuron']

Recovery jump (pA). Default: 100.0.

100.0
trainable_param set[str]

Trainable parameters. Default: ().

set()
surrogate_function Callable

Surrogate for backprop. Default: Sigmoid().

Sigmoid()
detach_reset bool

Detach reset signal. Default: False.

False
hard_reset bool

Hard vs soft reset. Default: False.

False
pre_spike bool

Store pre-spike values. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend. Default: "torch".

'torch'
device device | str | None

Device. Default: None.

None
dtype dtype | None

Dtype. Default: None.

None

Attributes:

Name Type Description
v Tensor

Membrane potential (*batch, n_neuron).

u Tensor

Recovery variable (*batch, n_neuron).

Reference

Izhikevich, IEEE Trans. Neural Networks, 2003.

Source code in btorch/models/neurons/izhikevich.py
class Izhikevich(BaseNode):
    """Izhikevich neuron with quadratic dynamics and recovery variable.

    Efficient model reproducing diverse spiking patterns (tonic, bursting,
    etc.) via a 2D ODE system with quadratic nonlinearity.

    Dynamics:
        dv/dt = (k*(v-v_rest)*(v-v_threshold) - u + I) / c_m
        du/dt = a * (b*(v-v_rest) - u)

    At spike: v=v_reset, u=u+d

    Args:
        n_neuron: Number of neurons.
        v_threshold: Threshold (mV). Default: 30.0.
        v_reset: Reset voltage (mV). Default: -65.0.
        v_rest: Resting potential (mV). Default: -65.0.
        v_peak: Spike cutoff (mV). Default: -40.0.
        c_m: Capacitance (pF). Default: 100.0.
        k: Scaling factor (nS/mV). Default: 0.7.
        a: Recovery timescale (ms^-1). Default: 0.03.
        b: Recovery coupling (nS). Default: -2.0.
        d: Recovery jump (pA). Default: 100.0.
        trainable_param: Trainable parameters. Default: ().
        surrogate_function: Surrogate for backprop. Default: Sigmoid().
        detach_reset: Detach reset signal. Default: False.
        hard_reset: Hard vs soft reset. Default: False.
        pre_spike: Store pre-spike values. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend. Default: "torch".
        device: Device. Default: None.
        dtype: Dtype. Default: None.

    Attributes:
        v: Membrane potential (*batch, n_neuron).
        u: Recovery variable (*batch, n_neuron).

    Reference:
        Izhikevich, IEEE Trans. Neural Networks, 2003.
    """

    HIPPOCAMPOME_TO_ARGS = {
        "k": "k",
        "a": "a",
        "b": "b",
        "d": "d",
        "C": "c_m",
        "vr": "v_rest",
        "vt": "v_threshold",
        "vpeak": "v_peak",
        "vmin": "v_reset",
    }

    u: torch.Tensor
    u_pre_spike: torch.Tensor

    v_reset: torch.Tensor | torch.nn.Parameter
    v_rest: torch.Tensor | torch.nn.Parameter
    v_peak: torch.Tensor | torch.nn.Parameter
    c_m: torch.Tensor | torch.nn.Parameter
    k: torch.Tensor | torch.nn.Parameter
    a: torch.Tensor | torch.nn.Parameter
    b: torch.Tensor | torch.nn.Parameter
    d: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 30.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = -65.0,
        v_rest: float | Float[TensorLike, " n_neuron"] = -65.0,
        v_peak: float | Float[TensorLike, " n_neuron"] = -40.0,
        c_m: float | Float[TensorLike, " n_neuron"] = 100.0,
        k: float | Float[TensorLike, " n_neuron"] = 0.7,
        a: float | Float[TensorLike, " n_neuron"] = 0.03,
        b: float | Float[TensorLike, " n_neuron"] = -2.0,
        d: float | Float[TensorLike, " n_neuron"] = 100.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            hard_reset=hard_reset,
            pre_spike_v=pre_spike,
            step_mode=step_mode,
            backend=backend,
            device=device,
            dtype=dtype,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.def_param(
            "c_m",
            c_m,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_rest",
            v_rest,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_peak",
            v_peak,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "k",
            k,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "a",
            a,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "b",
            b,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "d",
            d,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )

        self.register_memory("u", 0, self.n_neuron)
        if pre_spike:
            self.register_memory("u_pre_spike", None, self.n_neuron)

    @classmethod
    def from_hippocampome(
        cls,
        n_neuron: int | Sequence[int],
        k,
        a,
        b,
        d,
        C,
        vr,
        vt,
        vpeak,
        vmin,
        **kwargs,
    ):
        """
        Build an :class:`Izhikevich` neuron using parameter names from
        https://hippocampome.org.

        Parameter mapping (HippoCampome -> Izhikevich args):
        - k -> k (scaling factor)
        - a -> a (recovery time constant)
        - b -> b (recovery sensitivity)
        - d -> d (reset current)
        - C -> c_m (capacitance)
        - vr -> v_rest (resting potential)
        - vt -> v_threshold (instantaneous threshold)
        - vpeak -> v_peak (spike cutoff)
        - vmin -> v_reset (post-spike reset voltage)

        All values are expected in the same units as the canonical
        Izhikevich model (mV, pF, pA).
        """
        kwargs.setdefault("pre_spike", True)
        return cls(
            n_neuron,
            v_threshold=vt,
            v_reset=vmin,
            v_rest=vr,
            v_peak=vpeak,
            c_m=C,
            k=k,
            a=a,
            b=b,
            d=d,
            **kwargs,
        )

    @classmethod
    def from_canonical_quadratic(
        cls,
        n_neuron: int | Sequence[int],
        p1: float = 0.04,
        p2: float = 5.0,
        # TODO: p3: float = 0.0, adjust equation
        v_rest: float = -65.0,
        c_m: float = 1.0,
        v_peak: float = 30.0,
        **kwargs,
    ):
        """
        Instantiate using the canonical quadratic form
        ``dV/dt = p1*v^2 + p2*v + p3 - u + I``.

        The mapping assumes ``c_m`` acts as the membrane capacitance and that
        ``k/c_m`` equals ``p1``. The linear term enforces
        ``v_threshold = -p2/p1 - v_rest``. Remaining
        keyword arguments are passed directly to :class:`Izhikevich`.
        """
        k = p1 * c_m
        v_threshold = -p2 / p1 - v_rest
        # i_bias = p3 - p1 * v_rest * v_threshold

        return cls(
            n_neuron,
            v_threshold=v_threshold,
            v_reset=kwargs.pop("v_reset", v_rest),
            v_rest=v_rest,
            v_peak=v_peak,
            c_m=c_m,
            k=k,
            **kwargs,
        )

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        u: Float[Tensor, "*batch n_neuron"],
        x: Float[Tensor, "*batch n_neuron"],
    ):
        quadratic = self.k * (v - self.v_rest) * (v - self.v_threshold)
        return (x + quadratic - u) / self.c_m

    def dU(
        self,
        u: Float[Tensor, "*batch n_neuron"],
        v: Float[Tensor, "*batch n_neuron"],
    ):
        return self.a * (self.b * (v - self.v_rest) - u)

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        dt = environ.get("dt")
        self.v = euler_step(self.dV, self.v, self.u, x, dt=dt)

    def neuronal_adaptation(self):
        dt = environ.get("dt")
        self.u = euler_step(self.dU, self.u, self.v, dt=dt)

    def neuronal_fire(self):
        # TODO: confirm scaling with (self.v_threshold - self.v_reset)
        # or (self.v_peak - self.v_reset)
        spike = self.surrogate_function(
            (self.v - self.v_peak) / (self.v_threshold - self.v_reset)
        )
        return spike

    def neuronal_reset(self, spike: Float[Tensor, "*batch n"]):
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()
            self.u_pre_spike = self.u.clone()

        if self.hard_reset:
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            self.v = self.v - (self.v_peak - self.v_reset) * spike_d

        self.u = self.u + self.d * spike_d

    def extra_repr(self):
        parts = [
            f"c_m={self._format_repr_value(self.c_m)}",
            f"k={self._format_repr_value(self.k)}",
            f"a={self._format_repr_value(self.a)}",
            f"b={self._format_repr_value(self.b)}",
            f"d={self._format_repr_value(self.d)}",
            f"v_rest={self._format_repr_value(self.v_rest)}",
            f"v_peak={self._format_repr_value(self.v_peak)}",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)
Functions
from_canonical_quadratic(n_neuron, p1=0.04, p2=5.0, v_rest=-65.0, c_m=1.0, v_peak=30.0, **kwargs) classmethod

Instantiate using the canonical quadratic form dV/dt = p1*v^2 + p2*v + p3 - u + I.

The mapping assumes c_m acts as the membrane capacitance and that k/c_m equals p1. The linear term enforces v_threshold = -p2/p1 - v_rest. Remaining keyword arguments are passed directly to :class:Izhikevich.

Source code in btorch/models/neurons/izhikevich.py
@classmethod
def from_canonical_quadratic(
    cls,
    n_neuron: int | Sequence[int],
    p1: float = 0.04,
    p2: float = 5.0,
    # TODO: p3: float = 0.0, adjust equation
    v_rest: float = -65.0,
    c_m: float = 1.0,
    v_peak: float = 30.0,
    **kwargs,
):
    """
    Instantiate using the canonical quadratic form
    ``dV/dt = p1*v^2 + p2*v + p3 - u + I``.

    The mapping assumes ``c_m`` acts as the membrane capacitance and that
    ``k/c_m`` equals ``p1``. The linear term enforces
    ``v_threshold = -p2/p1 - v_rest``. Remaining
    keyword arguments are passed directly to :class:`Izhikevich`.
    """
    k = p1 * c_m
    v_threshold = -p2 / p1 - v_rest
    # i_bias = p3 - p1 * v_rest * v_threshold

    return cls(
        n_neuron,
        v_threshold=v_threshold,
        v_reset=kwargs.pop("v_reset", v_rest),
        v_rest=v_rest,
        v_peak=v_peak,
        c_m=c_m,
        k=k,
        **kwargs,
    )
from_hippocampome(n_neuron, k, a, b, d, C, vr, vt, vpeak, vmin, **kwargs) classmethod

Build an :class:Izhikevich neuron using parameter names from https://hippocampome.org.

Parameter mapping (HippoCampome -> Izhikevich args): - k -> k (scaling factor) - a -> a (recovery time constant) - b -> b (recovery sensitivity) - d -> d (reset current) - C -> c_m (capacitance) - vr -> v_rest (resting potential) - vt -> v_threshold (instantaneous threshold) - vpeak -> v_peak (spike cutoff) - vmin -> v_reset (post-spike reset voltage)

All values are expected in the same units as the canonical Izhikevich model (mV, pF, pA).

Source code in btorch/models/neurons/izhikevich.py
@classmethod
def from_hippocampome(
    cls,
    n_neuron: int | Sequence[int],
    k,
    a,
    b,
    d,
    C,
    vr,
    vt,
    vpeak,
    vmin,
    **kwargs,
):
    """
    Build an :class:`Izhikevich` neuron using parameter names from
    https://hippocampome.org.

    Parameter mapping (HippoCampome -> Izhikevich args):
    - k -> k (scaling factor)
    - a -> a (recovery time constant)
    - b -> b (recovery sensitivity)
    - d -> d (reset current)
    - C -> c_m (capacitance)
    - vr -> v_rest (resting potential)
    - vt -> v_threshold (instantaneous threshold)
    - vpeak -> v_peak (spike cutoff)
    - vmin -> v_reset (post-spike reset voltage)

    All values are expected in the same units as the canonical
    Izhikevich model (mV, pF, pA).
    """
    kwargs.setdefault("pre_spike", True)
    return cls(
        n_neuron,
        v_threshold=vt,
        v_reset=vmin,
        v_rest=vr,
        v_peak=vpeak,
        c_m=C,
        k=k,
        a=a,
        b=b,
        d=d,
        **kwargs,
    )

LIF

Bases: BaseNode

Leaky integrate-and-fire neuron with optional refractory period.

The LIF neuron integrates input current while leaking towards a resting potential. When the membrane potential exceeds a threshold, a spike is emitted and the potential is reset.

Dynamics

dV/dt = -(V - V_reset) / tau + I / c_m

If tau_ref is specified, a refractory period prevents spiking for tau_ref milliseconds after each spike.

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold (mV). Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike (mV). Default: 0.0.

0.0
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance. Default: 1.0.

1.0
tau float | Float[TensorLike, ' n_neuron']

Membrane time constant (ms). Default: 20.0.

20.0
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period duration (ms). None disables refractory behavior. Default: None.

None
trainable_param set[str]

Set of parameter names to make trainable. Default: empty set.

set()
surrogate_function Callable

Surrogate gradient function for backpropagation. Default: Sigmoid().

Sigmoid()
detach_reset bool

If True, detach reset signal from computation graph. Default: False.

False
hard_reset bool

If True, reset to v_reset directly. If False, subtract (v_threshold - v_reset) from membrane potential (soft reset). Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage in v_pre_spike buffer. Default: False.

False
step_mode Literal['s']

Step mode, currently only "s" (single step) supported. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None

Attributes:

Name Type Description
v Tensor

Membrane potential tensor, shape (*batch, n_neuron).

refractory Tensor | None

Refractory counter (if tau_ref specified).

c_m Tensor | Parameter

Membrane capacitance (parameter or buffer).

tau Tensor | Parameter

Time constant (parameter or buffer).

tau_ref Tensor | Parameter | None

Refractory period (parameter or buffer, or None).

Shape
  • Input: (*batch, n_neuron)
  • Output: (*batch, n_neuron) spike tensor (0 or 1)
Source code in btorch/models/neurons/lif.py
class LIF(BaseNode):
    """Leaky integrate-and-fire neuron with optional refractory period.

    The LIF neuron integrates input current while leaking towards a resting
    potential. When the membrane potential exceeds a threshold, a spike is
    emitted and the potential is reset.

    Dynamics:
        dV/dt = -(V - V_reset) / tau + I / c_m

        If tau_ref is specified, a refractory period prevents spiking for
        tau_ref milliseconds after each spike.

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold (mV). Default: 1.0.
        v_reset: Reset voltage after spike (mV). Default: 0.0.
        c_m: Membrane capacitance. Default: 1.0.
        tau: Membrane time constant (ms). Default: 20.0.
        tau_ref: Refractory period duration (ms). None disables refractory
            behavior. Default: None.
        trainable_param: Set of parameter names to make trainable.
            Default: empty set.
        surrogate_function: Surrogate gradient function for backpropagation.
            Default: Sigmoid().
        detach_reset: If True, detach reset signal from computation graph.
            Default: False.
        hard_reset: If True, reset to v_reset directly. If False, subtract
            (v_threshold - v_reset) from membrane potential (soft reset).
            Default: False.
        pre_spike_v: If True, store pre-spike voltage in v_pre_spike buffer.
            Default: False.
        step_mode: Step mode, currently only "s" (single step) supported.
            Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.

    Attributes:
        v: Membrane potential tensor, shape (*batch, n_neuron).
        refractory: Refractory counter (if tau_ref specified).
        c_m: Membrane capacitance (parameter or buffer).
        tau: Time constant (parameter or buffer).
        tau_ref: Refractory period (parameter or buffer, or None).

    Shape:
        - Input: (*batch, n_neuron)
        - Output: (*batch, n_neuron) spike tensor (0 or 1)
    """

    refractory: torch.Tensor | None
    c_m: torch.Tensor | torch.nn.Parameter
    tau: torch.Tensor | torch.nn.Parameter
    tau_ref: torch.Tensor | torch.nn.Parameter | None

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        c_m: float | Float[TensorLike, " n_neuron"] = 1.0,
        tau: float | Float[TensorLike, " n_neuron"] = 20.0,
        tau_ref: float | Float[TensorLike, " n_neuron"] | None = None,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            hard_reset=hard_reset,
            pre_spike_v=pre_spike_v,
            step_mode=step_mode,
            backend=backend,
            device=device,
            dtype=dtype,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.def_param(
            "c_m",
            c_m,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "tau",
            tau,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self._use_refractory = tau_ref is not None
        if self._use_refractory:
            self.def_param(
                "tau_ref",
                tau_ref,
                sizes=self.n_neuron,
                trainable_param=self.trainable_param,
                **_factory_kwargs,
            )
            self.register_memory("refractory", 0.0, self.n_neuron)
        else:
            self.tau_ref = None

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        x: Float[Tensor, "*batch n_neuron"],
    ):
        derivative = -(v - self.v_reset) / self.tau + x / self.c_m
        return derivative

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        v = euler_step(self.dV, self.v, x, dt=environ.get("dt"))
        self.v = v

    def neuronal_adaptation(self):
        # LIF has no intrinsic adaptation other than the refractory counter.
        return None

    def neuronal_fire(self):
        spike = self.surrogate_function(
            (self.v - self.v_threshold) / (self.v_threshold - self.v_reset)
        )
        if not self._use_refractory:
            return spike
        not_in_refractory = self.refractory == 0
        spike = spike * not_in_refractory.detach().to(self.v.dtype)
        return spike

    def neuronal_reset(self, spike: Float[Tensor, "*batch n"]):
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

        if self._use_refractory:
            self.refractory = torch.relu(
                self.refractory + spike_d * self.tau_ref - environ.get("dt")
            )

    def extra_repr(self):
        parts = [
            f"c_m={self._format_repr_value(self.c_m)}",
            f"tau={self._format_repr_value(self.tau)}",
            f"tau_ref={self._format_repr_value(self.tau_ref)}"
            if self._use_refractory
            else "tau_ref=None",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)

MixedNeuronPopulation

Bases: Module

Mix multiple neuron populations into a single logical layer.

The input current tensor is sliced along the last (neuron) dimension and dispatched to each sub-population. Spikes from all groups are concatenated back together so the layer behaves like a single neuron module from the point of view of :class:~btorch.models.rnn.RecurrentNN.

Parameters:

Name Type Description Default
groups Sequence[tuple[int, Module]] | Mapping[str, tuple[int, Module]]

Either a sequence of (count, neuron_module) tuples, or a mapping of name -> (count, neuron_module). When a sequence is given, groups are auto-named group_0, group_1, etc.

required
step_mode str

"s" for single-step or "m" for multi-step dispatch. Default: "s".

's'

Raises:

Type Description
ValueError

If groups is empty or contains a non-positive count.

Examples:

>>> from btorch.models.neurons import GLIF3, TwoCompartmentGLIF
>>> glif = GLIF3(n_neuron=60)
>>> tc = TwoCompartmentGLIF(n_neuron=40)
>>> mixed = MixedNeuronPopulation([(60, glif), (40, tc)])
Source code in btorch/models/neurons/mixed.py
class MixedNeuronPopulation(nn.Module):
    """Mix multiple neuron populations into a single logical layer.

    The input current tensor is sliced along the last (neuron) dimension and
    dispatched to each sub-population.  Spikes from all groups are
    concatenated back together so the layer behaves like a single neuron
    module from the point of view of :class:`~btorch.models.rnn.RecurrentNN`.

    Args:
        groups: Either a sequence of ``(count, neuron_module)`` tuples, or a
            mapping of ``name -> (count, neuron_module)``.  When a sequence
            is given, groups are auto-named ``group_0``, ``group_1``, etc.
        step_mode: ``"s"`` for single-step or ``"m"`` for multi-step
            dispatch.  Default: ``"s"``.

    Raises:
        ValueError: If ``groups`` is empty or contains a non-positive count.

    Examples:
        >>> from btorch.models.neurons import GLIF3, TwoCompartmentGLIF
        >>> glif = GLIF3(n_neuron=60)
        >>> tc = TwoCompartmentGLIF(n_neuron=40)
        >>> mixed = MixedNeuronPopulation([(60, glif), (40, tc)])
    """

    def __init__(
        self,
        groups: Sequence[tuple[int, nn.Module]] | Mapping[str, tuple[int, nn.Module]],
        step_mode: str = "s",
    ):
        super().__init__()
        if not groups:
            raise ValueError("groups must contain at least one population.")

        # Normalise to a list of (name, count, neuron).
        if isinstance(groups, Mapping):
            items = [(name, count, neuron) for name, (count, neuron) in groups.items()]
        else:
            items = [
                (f"group_{i}", count, neuron)
                for i, (count, neuron) in enumerate(groups)
            ]

        counts: list[int] = []
        total = 0
        group_names: list[str] = []
        for name, count, neuron in items:
            if count <= 0:
                raise ValueError(f"Group {name!r} count must be positive, got {count}.")
            self.add_module(name, neuron)
            group_names.append(name)
            counts.append(count)
            if hasattr(neuron, "size"):
                total += int(neuron.size)
            else:
                total += count

        self.counts = counts
        self._group_names = group_names
        self._cumsum = [0] + torch.cumsum(torch.tensor(counts), dim=0).tolist()
        self.n_neuron = (total,)
        self.size = total
        self.step_mode = step_mode

    def _indices(self, idx: int) -> torch.Tensor:
        """Return global neuron indices for group *idx* (contiguous block)."""
        start, end = self._cumsum[idx], self._cumsum[idx + 1]
        return torch.arange(start, end, dtype=torch.long)

    def _concat_attr(self, attr: str) -> Tensor:
        """Concatenate ``attr`` from all sub-populations along neuron dim.

        Scalar attributes are broadcast to the group's neuron count
        before concatenation.
        """
        parts: list[Tensor] = []
        for idx, name in enumerate(self._group_names):
            neuron = getattr(self, name)
            val = getattr(neuron, attr, None)
            if val is None:
                raise AttributeError(
                    f"{neuron.__class__.__name__} has no attribute {attr!r}"
                )
            if isinstance(val, nn.Parameter):
                val = val.data
            # TODO: ParamBufferMixin conflates uniform (scalar) and heterogeneous
            #   (per-neuron tensor) params. When a param is trainable it becomes an
            #   nn.Parameter and may be scalar or per-neuron depending on how it was
            #   initialised, so broadcasting here is fragile. ParamBufferMixin should
            #   be extended to always expose a per-neuron view, making this broadcast
            #   unnecessary and removing the ambiguity for downstream consumers.
            if val.dim() == 0:
                count = self._cumsum[idx + 1] - self._cumsum[idx]
                val = val.expand(count)
            parts.append(val)
        return torch.cat(parts, dim=-1)

    @property
    def v_threshold(self) -> Tensor:
        return self._concat_attr("v_threshold")

    @property
    def v_reset(self) -> Tensor:
        return self._concat_attr("v_reset")

    def _slice(self, x: Tensor, idx: int) -> Tensor:
        """Slice ``x`` along the last dimension for group *idx*."""
        start, end = self._cumsum[idx], self._cumsum[idx + 1]
        return x[..., start:end]

    def single_step_forward(
        self,
        x: Tensor,
        i_apical: Tensor | None = None,
    ) -> Tensor:
        """Advance all sub-populations by one timestep.

        Args:
            x: Input current of shape ``(*batch, n_neuron)``.
            i_apical: Optional apical current of the same shape.  Only slices
                corresponding to :class:`TwoCompartmentGLIF` groups are used.

        Returns:
            Spike tensor of shape ``(*batch, n_neuron)``.
        """
        spikes: list[Tensor] = []
        for idx, name in enumerate(self._group_names):
            neuron = getattr(self, name)
            soma = self._slice(x, idx)
            out: Tensor | tuple[Tensor, ...]

            if isinstance(neuron, TwoCompartmentGLIF):
                apical = self._slice(i_apical, idx) if i_apical is not None else None
                out = neuron(soma, apical)
            else:
                out = neuron(soma)

            # Normalise return value to spikes only.
            spikes.append(out[0] if isinstance(out, tuple) else out)

        return torch.cat(spikes, dim=-1)

    def multi_step_forward(
        self,
        x: Tensor,
        i_apical: Tensor | None = None,
    ) -> Tensor:
        """Advance all sub-populations over a full time sequence.

        Args:
            x: Input sequence of shape ``(T, *batch, n_neuron)``.
            i_apical: Optional apical sequence of the same shape.

        Returns:
            Spike sequence of shape ``(T, *batch, n_neuron)``.
        """
        spike_seq: list[Tensor] = []
        T = x.shape[0]
        for t in range(T):
            step_apical = None if i_apical is None else i_apical[t]
            spike_seq.append(self.single_step_forward(x[t], step_apical))
        return torch.stack(spike_seq, dim=0)

    def forward(
        self,
        x: Tensor,
        i_apical: Tensor | None = None,
    ) -> Tensor:
        """Dispatch to single-step or multi-step forward."""
        if self.step_mode == "m":
            return self.multi_step_forward(x, i_apical)
        return self.single_step_forward(x, i_apical)

    def extra_repr(self) -> str:
        parts = [f"n_neuron={self.n_neuron}"]
        for i, (name, neuron) in enumerate(self.named_children()):
            parts.append(f"{name}={neuron.__class__.__name__}(n={self.counts[i]})")
        return ", ".join(parts)
Functions
forward(x, i_apical=None)

Dispatch to single-step or multi-step forward.

Source code in btorch/models/neurons/mixed.py
def forward(
    self,
    x: Tensor,
    i_apical: Tensor | None = None,
) -> Tensor:
    """Dispatch to single-step or multi-step forward."""
    if self.step_mode == "m":
        return self.multi_step_forward(x, i_apical)
    return self.single_step_forward(x, i_apical)
multi_step_forward(x, i_apical=None)

Advance all sub-populations over a full time sequence.

Parameters:

Name Type Description Default
x Tensor

Input sequence of shape (T, *batch, n_neuron).

required
i_apical Tensor | None

Optional apical sequence of the same shape.

None

Returns:

Type Description
Tensor

Spike sequence of shape (T, *batch, n_neuron).

Source code in btorch/models/neurons/mixed.py
def multi_step_forward(
    self,
    x: Tensor,
    i_apical: Tensor | None = None,
) -> Tensor:
    """Advance all sub-populations over a full time sequence.

    Args:
        x: Input sequence of shape ``(T, *batch, n_neuron)``.
        i_apical: Optional apical sequence of the same shape.

    Returns:
        Spike sequence of shape ``(T, *batch, n_neuron)``.
    """
    spike_seq: list[Tensor] = []
    T = x.shape[0]
    for t in range(T):
        step_apical = None if i_apical is None else i_apical[t]
        spike_seq.append(self.single_step_forward(x[t], step_apical))
    return torch.stack(spike_seq, dim=0)
single_step_forward(x, i_apical=None)

Advance all sub-populations by one timestep.

Parameters:

Name Type Description Default
x Tensor

Input current of shape (*batch, n_neuron).

required
i_apical Tensor | None

Optional apical current of the same shape. Only slices corresponding to :class:TwoCompartmentGLIF groups are used.

None

Returns:

Type Description
Tensor

Spike tensor of shape (*batch, n_neuron).

Source code in btorch/models/neurons/mixed.py
def single_step_forward(
    self,
    x: Tensor,
    i_apical: Tensor | None = None,
) -> Tensor:
    """Advance all sub-populations by one timestep.

    Args:
        x: Input current of shape ``(*batch, n_neuron)``.
        i_apical: Optional apical current of the same shape.  Only slices
            corresponding to :class:`TwoCompartmentGLIF` groups are used.

    Returns:
        Spike tensor of shape ``(*batch, n_neuron)``.
    """
    spikes: list[Tensor] = []
    for idx, name in enumerate(self._group_names):
        neuron = getattr(self, name)
        soma = self._slice(x, idx)
        out: Tensor | tuple[Tensor, ...]

        if isinstance(neuron, TwoCompartmentGLIF):
            apical = self._slice(i_apical, idx) if i_apical is not None else None
            out = neuron(soma, apical)
        else:
            out = neuron(soma)

        # Normalise return value to spikes only.
        spikes.append(out[0] if isinstance(out, tuple) else out)

    return torch.cat(spikes, dim=-1)

TwoCompartmentGLIF

Bases: ParamBufferMixin, MemoryModule

Two-compartment current-based neuron with apical plateaus.

The model combines a fast somatic compartment with a slow apical current:

.. math:: \tau_s \frac{dV_s}{dt} = -(V_s - E_L) + R_s \left(I_{soma} + w_{as} I_a\right) + \Delta_T \exp\left(\frac{V_s - V_{th,eff}}{\Delta_T}\right)

.. math:: \tau_a \frac{dI_a}{dt} = -I_a + I_{apical} + w_{Ca} \sigma(I_a - \theta_{Ca}) + I_{bap}

Somatic spikes are generated by a surrogate threshold on V_s and stored as a pending back-propagating current for the next timestep.

A minimal adaptive threshold state can optionally be enabled:

.. math:: V_{th,eff} = V_{th} + \theta_h

.. math:: \tau_h \frac{d\theta_h}{dt} = -\theta_h

followed by an instantaneous post-spike increment :math:\theta_h \leftarrow \theta_h + \Delta_h.

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons, as an int or tuple of neuron axes.

required
v_threshold float | Float[TensorLike, ' n_neuron']

Somatic spike threshold.

-50.0
v_reset float | Float[TensorLike, ' n_neuron']

Somatic reset voltage after a spike.

-65.0
tau_s float | Float[TensorLike, ' n_neuron']

Somatic membrane time constant.

20.0
R_s float | Float[TensorLike, ' n_neuron']

Somatic input resistance.

1.0
E_L float | Float[TensorLike, ' n_neuron']

Somatic leak reversal / resting voltage.

-70.0
tau_a float | Float[TensorLike, ' n_neuron']

Apical current time constant.

100.0
tau_th float | Float[TensorLike, ' n_neuron']

Adaptive-threshold decay time constant.

50.0
delta_th float | Float[TensorLike, ' n_neuron']

Post-spike threshold increment.

0.0
delta_T float | Float[TensorLike, ' n_neuron']

Exponential spike-initiation scale. Set to zero to disable.

0.0
w_Ca float | Float[TensorLike, ' n_neuron']

Calcium plateau amplitude.

0.0
theta_Ca float | Float[TensorLike, ' n_neuron']

Apical plateau activation threshold.

1.0
w_sa float | Float[TensorLike, ' n_neuron']

Somatic-to-apical coupling weight for the delayed bAP current.

0.5
w_as float | Float[TensorLike, ' n_neuron']

Apical-to-somatic coupling weight for top-down drive.

1.0
trainable_param set[str]

Names of parameters to optimize.

set()
surrogate_function Callable

Surrogate gradient function for soma spikes.

ATan()
detach_reset bool

If True, detach the reset signal from the graph.

False
step_mode Literal['s', 'm']

"s" for single-step or "m" for multi-step dispatch.

's'
backend Literal['torch']

Backend label for consistency with other neuron modules.

'torch'
device device | None

Optional torch device.

None
dtype dtype | None

Optional torch dtype.

None

Attributes:

Name Type Description
v Tensor

Somatic membrane voltage memory.

i_a Tensor

Slow apical current memory.

i_bap Tensor

Pending back-propagating current injected on the next step.

theta_th Tensor

Adaptive threshold memory added to v_threshold.

Examples:

>>> from btorch.models import environ, functional
>>> neuron = TwoCompartmentGLIF(n_neuron=1)
>>> functional.init_net_state(neuron, batch_size=2)
>>> with environ.context(dt=1.0):
...     spike, voltage = neuron.single_step_forward(
...         torch.zeros(2, 1),
...         torch.zeros(2, 1),
...     )
>>> tuple(spike.shape), tuple(voltage.shape)
((2, 1), (2, 1))
Notes

The integration uses a semi-implicit Euler update: the leak / decay term is treated implicitly while external and coupling currents are evaluated from the previous state. This keeps the update stable while preserving a simple differentiable recurrence.

Source code in btorch/models/neurons/two_compartment.py
class TwoCompartmentGLIF(ParamBufferMixin, MemoryModule):
    """Two-compartment current-based neuron with apical plateaus.

    The model combines a fast somatic compartment with a slow apical current:

    .. math::
        \\tau_s \\frac{dV_s}{dt} =
        -(V_s - E_L) + R_s \\left(I_{soma} + w_{as} I_a\\right) +
        \\Delta_T \\exp\\left(\\frac{V_s - V_{th,eff}}{\\Delta_T}\\right)

    .. math::
        \\tau_a \\frac{dI_a}{dt} =
        -I_a + I_{apical} +
        w_{Ca} \\sigma(I_a - \\theta_{Ca}) + I_{bap}

    Somatic spikes are generated by a surrogate threshold on ``V_s`` and stored
    as a pending back-propagating current for the next timestep.

    A minimal adaptive threshold state can optionally be enabled:

    .. math::
        V_{th,eff} = V_{th} + \\theta_h

    .. math::
        \\tau_h \\frac{d\\theta_h}{dt} = -\\theta_h

    followed by an instantaneous post-spike increment
    :math:`\\theta_h \\leftarrow \\theta_h + \\Delta_h`.

    Args:
        n_neuron: Number of neurons, as an ``int`` or tuple of neuron axes.
        v_threshold: Somatic spike threshold.
        v_reset: Somatic reset voltage after a spike.
        tau_s: Somatic membrane time constant.
        R_s: Somatic input resistance.
        E_L: Somatic leak reversal / resting voltage.
        tau_a: Apical current time constant.
        tau_th: Adaptive-threshold decay time constant.
        delta_th: Post-spike threshold increment.
        delta_T: Exponential spike-initiation scale. Set to zero to disable.
        w_Ca: Calcium plateau amplitude.
        theta_Ca: Apical plateau activation threshold.
        w_sa: Somatic-to-apical coupling weight for the delayed bAP current.
        w_as: Apical-to-somatic coupling weight for top-down drive.
        trainable_param: Names of parameters to optimize.
        surrogate_function: Surrogate gradient function for soma spikes.
        detach_reset: If ``True``, detach the reset signal from the graph.
        step_mode: ``"s"`` for single-step or ``"m"`` for multi-step dispatch.
        backend: Backend label for consistency with other neuron modules.
        device: Optional torch device.
        dtype: Optional torch dtype.

    Attributes:
        v: Somatic membrane voltage memory.
        i_a: Slow apical current memory.
        i_bap: Pending back-propagating current injected on the next step.
        theta_th: Adaptive threshold memory added to ``v_threshold``.

    Examples:
        >>> from btorch.models import environ, functional
        >>> neuron = TwoCompartmentGLIF(n_neuron=1)
        >>> functional.init_net_state(neuron, batch_size=2)
        >>> with environ.context(dt=1.0):
        ...     spike, voltage = neuron.single_step_forward(
        ...         torch.zeros(2, 1),
        ...         torch.zeros(2, 1),
        ...     )
        >>> tuple(spike.shape), tuple(voltage.shape)
        ((2, 1), (2, 1))

    Notes:
        The integration uses a semi-implicit Euler update: the leak / decay
        term is treated implicitly while external and coupling currents are
        evaluated from the previous state. This keeps the update stable while
        preserving a simple differentiable recurrence.
    """

    v: torch.Tensor
    i_a: torch.Tensor
    i_bap: torch.Tensor
    theta_th: torch.Tensor

    v_threshold: torch.Tensor | torch.nn.Parameter
    v_reset: torch.Tensor | torch.nn.Parameter
    tau_s: torch.Tensor | torch.nn.Parameter
    R_s: torch.Tensor | torch.nn.Parameter
    E_L: torch.Tensor | torch.nn.Parameter
    tau_a: torch.Tensor | torch.nn.Parameter
    tau_th: torch.Tensor | torch.nn.Parameter
    delta_th: torch.Tensor | torch.nn.Parameter
    delta_T: torch.Tensor | torch.nn.Parameter
    w_Ca: torch.Tensor | torch.nn.Parameter
    theta_Ca: torch.Tensor | torch.nn.Parameter
    w_sa: torch.Tensor | torch.nn.Parameter
    w_as: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        *,
        v_threshold: float | Float[TensorLike, " n_neuron"] = -50.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = -65.0,
        tau_s: float | Float[TensorLike, " n_neuron"] = 20.0,
        R_s: float | Float[TensorLike, " n_neuron"] = 1.0,
        E_L: float | Float[TensorLike, " n_neuron"] = -70.0,
        tau_a: float | Float[TensorLike, " n_neuron"] = 100.0,
        tau_th: float | Float[TensorLike, " n_neuron"] = 50.0,
        delta_th: float | Float[TensorLike, " n_neuron"] = 0.0,
        delta_T: float | Float[TensorLike, " n_neuron"] = 0.0,
        w_Ca: float | Float[TensorLike, " n_neuron"] = 0.0,
        theta_Ca: float | Float[TensorLike, " n_neuron"] = 1.0,
        w_sa: float | Float[TensorLike, " n_neuron"] = 0.5,
        w_as: float | Float[TensorLike, " n_neuron"] = 1.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = ATan(),
        detach_reset: bool = False,
        step_mode: Literal["s", "m"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__()
        self.n_neuron, self.size = normalize_n_neuron(n_neuron)
        self.trainable_param = set(trainable_param)
        self.surrogate_function = surrogate_function
        self.detach_reset = detach_reset
        self.step_mode = step_mode
        self.backend = backend

        self.register_memory("v", v_reset, self.n_neuron)
        self.register_memory("i_a", 0.0, self.n_neuron)
        self.register_memory("i_bap", 0.0, self.n_neuron)
        self.register_memory("theta_th", 0.0, self.n_neuron)

        factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        for name, value in (
            ("v_threshold", v_threshold),
            ("v_reset", v_reset),
            ("tau_s", tau_s),
            ("R_s", R_s),
            ("E_L", E_L),
            ("tau_a", tau_a),
            ("tau_th", tau_th),
            ("delta_th", delta_th),
            ("delta_T", delta_T),
            ("w_Ca", w_Ca),
            ("theta_Ca", theta_Ca),
            ("w_sa", w_sa),
            ("w_as", w_as),
        ):
            self.def_param(
                name,
                value,
                sizes=self.n_neuron,
                trainable_param=self.trainable_param,
                **factory_kwargs,
            )

    def _prepare_step_tensor(
        self,
        x: float | Tensor,
        *,
        name: str,
    ) -> Float[Tensor, "*batch n_neuron"]:
        target = self.v
        x = torch.as_tensor(x, device=target.device, dtype=target.dtype)
        if x.shape == target.shape:
            return x
        if x.numel() == 1:
            return torch.full_like(target, x.reshape(()))
        if is_broadcastable(x.shape, target.shape):
            return torch.broadcast_to(x, target.shape)
        raise RuntimeError(
            f"{name} shape mismatch. Expected a tensor broadcastable to "
            f"{tuple(target.shape)}, got {tuple(x.shape)}."
        )

    @staticmethod
    def _positive(x: Tensor, eps: float = 1e-4) -> Tensor:
        return torch.clamp(x, min=eps)

    def _plateau_current(self) -> Float[Tensor, "*batch n_neuron"]:
        return self.w_Ca * torch.sigmoid(self.i_a - self.theta_Ca)

    def _update_apical_current(
        self,
        i_apical: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_a = self._positive(self.tau_a)
        drive = i_apical + self._plateau_current() + self.i_bap
        decay = dt / tau_a
        return (self.i_a + decay * drive) / (1.0 + decay)

    def _update_somatic_voltage(
        self,
        i_soma: Float[Tensor, "*batch n_neuron"],
        i_a_next: Float[Tensor, "*batch n_neuron"],
        threshold_eff: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_s = self._positive(self.tau_s)
        R_s = self._positive(self.R_s)
        total_current = i_soma + self.w_as * i_a_next
        delta_T = torch.clamp(self.delta_T, min=0.0)
        exp_arg = torch.clamp(
            (self.v - threshold_eff) / torch.clamp(delta_T, min=1e-4),
            max=10.0,
        )
        exp_drive = torch.where(
            delta_T > 0.0,
            delta_T * torch.exp(exp_arg),
            torch.zeros_like(self.v),
        )
        decay = dt / tau_s
        numerator = self.v + decay * (self.E_L + R_s * total_current + exp_drive)
        return numerator / (1.0 + decay)

    def _reset_voltage(
        self,
        v_pre_spike: Float[Tensor, "*batch n_neuron"],
        spike: Float[Tensor, "*batch n_neuron"],
    ) -> Float[Tensor, "*batch n_neuron"]:
        spike_d = spike.detach() if self.detach_reset else spike
        return v_pre_spike - (v_pre_spike - self.v_reset) * spike_d

    def _update_threshold_state(
        self,
        spike: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_th = self._positive(self.tau_th)
        decay = dt / tau_th
        spike_d = spike.detach() if self.detach_reset else spike
        return self.theta_th / (1.0 + decay) + self.delta_th * spike_d

    def single_step_forward(
        self,
        i_soma: Float[Tensor | float, "*batch n_neuron"],
        i_apical: Float[Tensor | float, "*batch n_neuron"] | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Advance the neuron by one timestep.

        Args:
            i_soma: Somatic current input.
            i_apical: Optional apical current input. Defaults to zero.
            return_state: If ``True``, also return a state dictionary.

        Returns:
            A tuple ``(spike, voltage)``. When ``return_state=True``, returns
            ``(spike, voltage, state_dict)`` with ``v_pre_spike``, ``i_a``, and
            ``i_bap`` traces for the current step.
        """
        dt = float(environ.get("dt"))
        soma_drive = self._prepare_step_tensor(i_soma, name="i_soma")
        apical_drive = (
            torch.zeros_like(soma_drive)
            if i_apical is None
            else self._prepare_step_tensor(i_apical, name="i_apical")
        )

        i_a_next = self._update_apical_current(apical_drive, dt)
        threshold_eff = self.v_threshold + self.theta_th
        v_pre_spike = self._update_somatic_voltage(
            soma_drive,
            i_a_next,
            threshold_eff,
            dt,
        )
        spike = self.surrogate_function(v_pre_spike - threshold_eff)

        self.i_a = i_a_next
        self.v = self._reset_voltage(v_pre_spike, spike)
        self.i_bap = self.w_sa * spike
        self.theta_th = self._update_threshold_state(spike, dt)

        if not return_state:
            return spike, self.v

        return (
            spike,
            self.v,
            {
                "v": self.v,
                "v_pre_spike": v_pre_spike,
                "i_a": self.i_a,
                "i_bap": self.i_bap,
                "theta_th": self.theta_th,
                "v_threshold_eff": threshold_eff,
            },
        )

    def multi_step_forward(
        self,
        i_soma_seq: Float[Tensor, "T *batch n_neuron"],
        i_apical_seq: Float[Tensor, "T *batch n_neuron"] | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Advance the neuron over a full input sequence."""
        spike_seq = []
        v_seq = []
        v_pre_seq = []
        i_a_seq = []
        i_bap_seq = []
        theta_th_seq = []
        threshold_seq = []

        for t in range(i_soma_seq.shape[0]):
            step_apical = None if i_apical_seq is None else i_apical_seq[t]
            step_out = self.single_step_forward(
                i_soma_seq[t],
                step_apical,
                return_state=return_state,
            )
            if return_state:
                spike_t, v_t, state_t = step_out
                v_pre_seq.append(state_t["v_pre_spike"])
                i_a_seq.append(state_t["i_a"])
                i_bap_seq.append(state_t["i_bap"])
                theta_th_seq.append(state_t["theta_th"])
                threshold_seq.append(state_t["v_threshold_eff"])
            else:
                spike_t, v_t = step_out
            spike_seq.append(spike_t)
            v_seq.append(v_t)

        spike_seq_t = torch.stack(spike_seq, dim=0)
        v_seq_t = torch.stack(v_seq, dim=0)
        if not return_state:
            return spike_seq_t, v_seq_t

        return (
            spike_seq_t,
            v_seq_t,
            {
                "v": v_seq_t,
                "v_pre_spike": torch.stack(v_pre_seq, dim=0),
                "i_a": torch.stack(i_a_seq, dim=0),
                "i_bap": torch.stack(i_bap_seq, dim=0),
                "theta_th": torch.stack(theta_th_seq, dim=0),
                "v_threshold_eff": torch.stack(threshold_seq, dim=0),
            },
        )

    def forward(
        self,
        i_soma: Tensor,
        i_apical: Tensor | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Dispatch to single-step or multi-step forward."""
        if self.step_mode == "m":
            return self.multi_step_forward(
                i_soma,
                i_apical,
                return_state=return_state,
            )
        return self.single_step_forward(
            i_soma,
            i_apical,
            return_state=return_state,
        )

    def extra_repr(self) -> str:
        parts = [
            f"n_neuron={self.n_neuron}",
            f"step_mode={self.step_mode}",
            f"backend={self.backend}",
            f"surrogate={self.surrogate_function.__class__.__name__}",
        ]
        mem_repr = MemoryModule.extra_repr(self)
        if mem_repr:
            parts.append(mem_repr)
        return ", ".join(parts)
Functions
forward(i_soma, i_apical=None, *, return_state=False)

Dispatch to single-step or multi-step forward.

Source code in btorch/models/neurons/two_compartment.py
def forward(
    self,
    i_soma: Tensor,
    i_apical: Tensor | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Dispatch to single-step or multi-step forward."""
    if self.step_mode == "m":
        return self.multi_step_forward(
            i_soma,
            i_apical,
            return_state=return_state,
        )
    return self.single_step_forward(
        i_soma,
        i_apical,
        return_state=return_state,
    )
multi_step_forward(i_soma_seq, i_apical_seq=None, *, return_state=False)

Advance the neuron over a full input sequence.

Source code in btorch/models/neurons/two_compartment.py
def multi_step_forward(
    self,
    i_soma_seq: Float[Tensor, "T *batch n_neuron"],
    i_apical_seq: Float[Tensor, "T *batch n_neuron"] | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Advance the neuron over a full input sequence."""
    spike_seq = []
    v_seq = []
    v_pre_seq = []
    i_a_seq = []
    i_bap_seq = []
    theta_th_seq = []
    threshold_seq = []

    for t in range(i_soma_seq.shape[0]):
        step_apical = None if i_apical_seq is None else i_apical_seq[t]
        step_out = self.single_step_forward(
            i_soma_seq[t],
            step_apical,
            return_state=return_state,
        )
        if return_state:
            spike_t, v_t, state_t = step_out
            v_pre_seq.append(state_t["v_pre_spike"])
            i_a_seq.append(state_t["i_a"])
            i_bap_seq.append(state_t["i_bap"])
            theta_th_seq.append(state_t["theta_th"])
            threshold_seq.append(state_t["v_threshold_eff"])
        else:
            spike_t, v_t = step_out
        spike_seq.append(spike_t)
        v_seq.append(v_t)

    spike_seq_t = torch.stack(spike_seq, dim=0)
    v_seq_t = torch.stack(v_seq, dim=0)
    if not return_state:
        return spike_seq_t, v_seq_t

    return (
        spike_seq_t,
        v_seq_t,
        {
            "v": v_seq_t,
            "v_pre_spike": torch.stack(v_pre_seq, dim=0),
            "i_a": torch.stack(i_a_seq, dim=0),
            "i_bap": torch.stack(i_bap_seq, dim=0),
            "theta_th": torch.stack(theta_th_seq, dim=0),
            "v_threshold_eff": torch.stack(threshold_seq, dim=0),
        },
    )
single_step_forward(i_soma, i_apical=None, *, return_state=False)

Advance the neuron by one timestep.

Parameters:

Name Type Description Default
i_soma Float[Tensor | float, '*batch n_neuron']

Somatic current input.

required
i_apical Float[Tensor | float, '*batch n_neuron'] | None

Optional apical current input. Defaults to zero.

None
return_state bool

If True, also return a state dictionary.

False

Returns:

Type Description
tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

A tuple (spike, voltage). When return_state=True, returns

tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

(spike, voltage, state_dict) with v_pre_spike, i_a, and

tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

i_bap traces for the current step.

Source code in btorch/models/neurons/two_compartment.py
def single_step_forward(
    self,
    i_soma: Float[Tensor | float, "*batch n_neuron"],
    i_apical: Float[Tensor | float, "*batch n_neuron"] | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Advance the neuron by one timestep.

    Args:
        i_soma: Somatic current input.
        i_apical: Optional apical current input. Defaults to zero.
        return_state: If ``True``, also return a state dictionary.

    Returns:
        A tuple ``(spike, voltage)``. When ``return_state=True``, returns
        ``(spike, voltage, state_dict)`` with ``v_pre_spike``, ``i_a``, and
        ``i_bap`` traces for the current step.
    """
    dt = float(environ.get("dt"))
    soma_drive = self._prepare_step_tensor(i_soma, name="i_soma")
    apical_drive = (
        torch.zeros_like(soma_drive)
        if i_apical is None
        else self._prepare_step_tensor(i_apical, name="i_apical")
    )

    i_a_next = self._update_apical_current(apical_drive, dt)
    threshold_eff = self.v_threshold + self.theta_th
    v_pre_spike = self._update_somatic_voltage(
        soma_drive,
        i_a_next,
        threshold_eff,
        dt,
    )
    spike = self.surrogate_function(v_pre_spike - threshold_eff)

    self.i_a = i_a_next
    self.v = self._reset_voltage(v_pre_spike, spike)
    self.i_bap = self.w_sa * spike
    self.theta_th = self._update_threshold_state(spike, dt)

    if not return_state:
        return spike, self.v

    return (
        spike,
        self.v,
        {
            "v": self.v,
            "v_pre_spike": v_pre_spike,
            "i_a": self.i_a,
            "i_bap": self.i_bap,
            "theta_th": self.theta_th,
            "v_threshold_eff": threshold_eff,
        },
    )

btorch.models.neurons.alif

Adaptive leaky integrate-and-fire (ALIF) neuron models.

This module provides ALIF and ELIF (exponential LIF) neuron implementations with conductance-based adaptation mechanisms.

The ALIF neuron extends LIF by adding a potassium conductance (g_k) that increases with each spike, creating spike-frequency adaptation:

dv/dt = (-g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m
dg_k/dt = -g_k / tau_adapt

where g_k increments by dg_k at each spike and decays exponentially, creating negative feedback that slows firing rate over time.

Attributes

TensorLike = np.ndarray | torch.Tensor module-attribute

Classes

ALIF

Bases: BaseNode

Adaptive leaky integrate-and-fire neuron with conductance adaptation.

The ALIF model extends standard LIF by adding a voltage-dependent potassium conductance (g_k) that creates spike-frequency adaptation. Each spike increases g_k by dg_k, which then decays exponentially.

Dynamics

dv/dt = (-g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m dg_k/dt = -g_k / tau_adapt

At spike: g_k += dg_k

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold (mV). Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike (mV). Default: 0.0.

0.0
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance (pF). Default: 1.0.

1.0
g_leak float | Float[TensorLike, ' n_neuron']

Leak conductance (nS). Default: 1.0.

1.0
E_leak float | Float[TensorLike, ' n_neuron']

Leak reversal potential (mV). Default: 0.0.

0.0
E_k float | Float[TensorLike, ' n_neuron']

Potassium reversal potential (mV). Default: -70.0.

-70.0
g_k_init float | Float[TensorLike, ' n_neuron']

Initial adaptation conductance (nS). Default: 0.0.

0.0
tau_adapt float | Float[TensorLike, ' n_neuron']

Adaptation time constant (ms). Default: 20.0.

20.0
dg_k float | Float[TensorLike, ' n_neuron']

Adaptation increment per spike (nS). Default: 0.0.

0.0
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period (ms). None disables refractory. Default: None.

None
trainable_param set[str]

Set of parameter names to make trainable.

set()
surrogate_function Callable

Surrogate gradient function. Default: Sigmoid().

Sigmoid()
detach_reset bool

If True, detach reset signal. Default: False.

False
hard_reset bool

If True, use hard reset. Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None

Attributes:

Name Type Description
v Tensor

Membrane potential, shape (*batch, n_neuron).

g_k Tensor

Adaptation conductance, shape (*batch, n_neuron).

refractory Tensor | None

Refractory counter (if tau_ref specified).

c_m, (g_leak, E_leak, E_k)

Neuron parameters.

tau_adapt Tensor | Parameter

Adaptation time constant.

dg_k Tensor | Parameter

Per-spike adaptation increment.

Source code in btorch/models/neurons/alif.py
class ALIF(BaseNode):
    """Adaptive leaky integrate-and-fire neuron with conductance adaptation.

    The ALIF model extends standard LIF by adding a voltage-dependent
    potassium conductance (g_k) that creates spike-frequency adaptation.
    Each spike increases g_k by dg_k, which then decays exponentially.

    Dynamics:
        dv/dt = (-g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m
        dg_k/dt = -g_k / tau_adapt

        At spike: g_k += dg_k

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold (mV). Default: 1.0.
        v_reset: Reset voltage after spike (mV). Default: 0.0.
        c_m: Membrane capacitance (pF). Default: 1.0.
        g_leak: Leak conductance (nS). Default: 1.0.
        E_leak: Leak reversal potential (mV). Default: 0.0.
        E_k: Potassium reversal potential (mV). Default: -70.0.
        g_k_init: Initial adaptation conductance (nS). Default: 0.0.
        tau_adapt: Adaptation time constant (ms). Default: 20.0.
        dg_k: Adaptation increment per spike (nS). Default: 0.0.
        tau_ref: Refractory period (ms). None disables refractory.
            Default: None.
        trainable_param: Set of parameter names to make trainable.
        surrogate_function: Surrogate gradient function. Default: Sigmoid().
        detach_reset: If True, detach reset signal. Default: False.
        hard_reset: If True, use hard reset. Default: False.
        pre_spike_v: If True, store pre-spike voltage. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.

    Attributes:
        v: Membrane potential, shape (*batch, n_neuron).
        g_k: Adaptation conductance, shape (*batch, n_neuron).
        refractory: Refractory counter (if tau_ref specified).
        c_m, g_leak, E_leak, E_k: Neuron parameters.
        tau_adapt: Adaptation time constant.
        dg_k: Per-spike adaptation increment.
    """

    g_k: torch.Tensor
    refractory: torch.Tensor | None

    c_m: torch.Tensor | torch.nn.Parameter
    g_leak: torch.Tensor | torch.nn.Parameter
    E_leak: torch.Tensor | torch.nn.Parameter
    E_k: torch.Tensor | torch.nn.Parameter
    tau_adapt: torch.Tensor | torch.nn.Parameter
    dg_k: torch.Tensor | torch.nn.Parameter
    tau_ref: torch.Tensor | torch.nn.Parameter | None

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        c_m: float | Float[TensorLike, " n_neuron"] = 1.0,
        g_leak: float | Float[TensorLike, " n_neuron"] = 1.0,
        E_leak: float | Float[TensorLike, " n_neuron"] = 0.0,
        E_k: float | Float[TensorLike, " n_neuron"] = -70.0,
        g_k_init: float | Float[TensorLike, " n_neuron"] = 0.0,
        tau_adapt: float | Float[TensorLike, " n_neuron"] = 20.0,
        dg_k: float | Float[TensorLike, " n_neuron"] = 0.0,
        tau_ref: float | Float[TensorLike, " n_neuron"] | None = None,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            hard_reset=hard_reset,
            pre_spike_v=pre_spike_v,
            step_mode=step_mode,
            backend=backend,
            device=device,
            dtype=dtype,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.def_param(
            "c_m",
            c_m,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "g_leak",
            g_leak,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "E_leak",
            E_leak,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "E_k",
            E_k,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "tau_adapt",
            tau_adapt,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "dg_k",
            dg_k,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self._use_refractory = tau_ref is not None
        if self._use_refractory:
            self.def_param(
                "tau_ref",
                tau_ref,
                trainable_param=self.trainable_param,
                **_factory_kwargs,
            )
            self.register_memory("refractory", 0.0, self.n_neuron)
        else:
            self.tau_ref = None

        self.register_memory("g_k", g_k_init, self.n_neuron)

    @property
    def v_rest(self):
        if self._v_rest is None:
            return self.v_reset
        return self._v_rest

    @v_rest.setter
    def v_rest(self, v_rest):
        if self._v_rest is not None:
            self._v_rest = v_rest

    @property
    def v_peak(self):
        return self.v_threshold

    @v_peak.setter
    def v_peak(self, value):
        self.v_threshold = value

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        g_k: Float[Tensor, "*batch n_neuron"],
        x: Float[Tensor, "*batch n_neuron"],
    ):
        leak_term = -self.g_leak * (v - self.E_leak)
        adapt_term = -g_k * (v - self.E_k)
        derivative = (leak_term + adapt_term + x) / self.c_m
        linear = (-self.g_leak - g_k) / self.c_m
        return derivative, linear

    def dgk(
        self, g_k: Float[Tensor, "*batch n_neuron"]
    ) -> Float[Tensor, "*batch n_neuron"]:
        derivative = -g_k / self.tau_adapt
        linear = -1.0 / self.tau_adapt
        return derivative, linear

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        dt = environ.get("dt")
        self.v = exp_euler_step(self.dV, self.v, self.g_k, x, dt=dt)

    def neuronal_adaptation(self):
        dt = environ.get("dt")
        self.g_k = exp_euler_step(self.dgk, self.g_k, dt=dt)

    def neuronal_fire(self):
        spike = self.surrogate_function(
            (self.v - self.v_threshold) / (self.v_threshold - self.v_reset)
        )
        if not self._use_refractory:
            return spike
        not_in_refractory = self.refractory == 0
        spike = spike * not_in_refractory.detach().to(self.v.dtype)
        return spike

    def neuronal_reset(self, spike: Float[Tensor, "*batch n"]):
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

        self.g_k = self.g_k + self.dg_k * spike_d

        if self._use_refractory:
            self.refractory = torch.relu(
                self.refractory + spike_d * self.tau_ref - environ.get("dt")
            )

    def extra_repr(self):
        g_k_init = self._memories_rv["g_k"].value
        parts = [
            f"c_m={self._format_repr_value(self.c_m)}",
            f"g_leak={self._format_repr_value(self.g_leak)}",
            f"E_leak={self._format_repr_value(self.E_leak)}",
            f"E_k={self._format_repr_value(self.E_k)}",
            f"tau_adapt={self._format_repr_value(self.tau_adapt)}",
            f"dg_k={self._format_repr_value(self.dg_k)}",
            f"g_k_init={self._format_repr_value(g_k_init)}",
            f"tau_ref={self._format_repr_value(self.tau_ref)}"
            if self._use_refractory
            else "tau_ref=None",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)

BaseNode

Bases: ParamBufferMixin, MemoryModule

Base class for differentiable spiking neurons.

Implements the spiking neuron lifecycle: charge -> adapt -> fire -> reset. Subclasses implement neuronal_charge() and neuronal_adaptation().

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold. Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage. Default: 0.0.

0.0
trainable_param set[str]

Trainable parameter names. Default: ().

set()
surrogate_function Callable

Surrogate for backprop. Default: Sigmoid().

Sigmoid()
detach_reset bool

Detach reset signal. Default: False.

False
hard_reset bool

Hard vs soft reset. Default: False.

False
pre_spike_v bool

Store pre-spike voltage. Default: False.

False
step_mode str

"s" or "m". Default: "s".

's'
backend str

Compute backend. Default: "torch".

'torch'
device device | str | None

Tensor device. Default: None.

None
dtype dtype | None

Tensor dtype. Default: None.

None
Source code in btorch/models/base.py
class BaseNode(ParamBufferMixin, MemoryModule):
    """Base class for differentiable spiking neurons.

    Implements the spiking neuron lifecycle: charge -> adapt -> fire -> reset.
    Subclasses implement neuronal_charge() and neuronal_adaptation().

    Args:
        n_neuron: Number of neurons (int or tuple).
        v_threshold: Firing threshold. Default: 1.0.
        v_reset: Reset voltage. Default: 0.0.
        trainable_param: Trainable parameter names. Default: ().
        surrogate_function: Surrogate for backprop. Default: Sigmoid().
        detach_reset: Detach reset signal. Default: False.
        hard_reset: Hard vs soft reset. Default: False.
        pre_spike_v: Store pre-spike voltage. Default: False.
        step_mode: "s" or "m". Default: "s".
        backend: Compute backend. Default: "torch".
        device: Tensor device. Default: None.
        dtype: Tensor dtype. Default: None.
    """

    n_neuron: tuple[int, ...]
    size: int
    v: torch.Tensor
    v_pre_spike: torch.Tensor
    v_threshold: torch.Tensor | torch.nn.Parameter
    v_reset: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: str = "s",
        backend: str = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        """Modified spikingjelly BaseNode.

        * :ref:`API in English <BaseNode.__init__-en>`

        This class is the base class of differentiable spiking neurons.
        """

        # override neuron.BaseNode's __init__ method to remove unnecessary checks
        # call neuron.BaseNode's parent MemoryModule directly
        super().__init__()

        self.n_neuron, self.size = normalize_n_neuron(n_neuron)
        self.register_memory("v", v_reset, self.n_neuron)
        self.pre_spike_v = pre_spike_v

        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        if pre_spike_v:
            self.register_memory(
                "v_pre_spike", v_reset, self.n_neuron, persistent=False
            )

        self.trainable_param = set(trainable_param)
        self.def_param(
            "v_threshold",
            v_threshold,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_reset",
            v_reset,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )

        self.detach_reset = detach_reset
        self.surrogate_function = surrogate_function
        self.hard_reset = hard_reset

        self.step_mode = step_mode
        self.backend = backend

    def extra_repr(self):
        parts = [
            f"n_neuron={self.n_neuron}",
            f"v_threshold={self._format_repr_value(self.v_threshold)}",
            f"v_reset={self._format_repr_value(self.v_reset)}",
            f"step_mode={self.step_mode}",
            f"backend={self.backend}",
            f"surrogate={self.surrogate_function.__class__.__name__}",
        ]
        if self.detach_reset:
            parts.append("detach_reset=True")
        if self.hard_reset:
            parts.append("hard_reset=True")
        if self.pre_spike_v:
            parts.append("pre_spike_v=True")
        mem_repr = super().extra_repr()
        if mem_repr:
            parts.append(mem_repr)
        return ", ".join(parts)

    @abstractmethod
    def neuronal_charge(self, x: torch.Tensor):
        """Define the charge difference equation.

        Subclasses must implement this.
        """
        raise NotImplementedError

    def neuronal_fire(self):
        """Calculate output spikes from the current membrane potential and
        threshold."""
        return self.surrogate_function(self.v - self.v_threshold)

    def neuronal_reset(self, spike):
        """Reset the membrane potential according to the neurons' output
        spikes."""
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            # hard reset
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            # soft reset
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

    def neuronal_adaptation(self):
        raise NotImplementedError()

    def single_step_forward(self, x: Float[Tensor, "*batch n_neuron"]):
        """
        * :ref:`API in English <BaseNode.single_step_forward-en>`
        """
        self.neuronal_charge(x)
        self.neuronal_adaptation()
        spike = self.neuronal_fire()
        self.neuronal_reset(spike)
        return spike

    def multi_step_forward(self, x_seq: Float[Tensor, "T *batch n_neuron"]):
        s_seq = []
        for t, x in enumerate(x_seq):
            s = self.single_step_forward(x)
            s_seq.append(s)

        return torch.stack(s_seq)
Functions
__init__(n_neuron, v_threshold=1.0, v_reset=0.0, trainable_param=set(), surrogate_function=Sigmoid(), detach_reset=False, hard_reset=False, pre_spike_v=False, step_mode='s', backend='torch', device=None, dtype=None)

Modified spikingjelly BaseNode.

  • :ref:API in English <BaseNode.__init__-en>

This class is the base class of differentiable spiking neurons.

Source code in btorch/models/base.py
def __init__(
    self,
    n_neuron: int | Sequence[int],
    v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
    v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
    trainable_param: set[str] = set(),
    surrogate_function: Callable = Sigmoid(),
    detach_reset: bool = False,
    hard_reset: bool = False,
    pre_spike_v: bool = False,
    step_mode: str = "s",
    backend: str = "torch",
    device: torch.device | str | None = None,
    dtype: torch.dtype | None = None,
):
    """Modified spikingjelly BaseNode.

    * :ref:`API in English <BaseNode.__init__-en>`

    This class is the base class of differentiable spiking neurons.
    """

    # override neuron.BaseNode's __init__ method to remove unnecessary checks
    # call neuron.BaseNode's parent MemoryModule directly
    super().__init__()

    self.n_neuron, self.size = normalize_n_neuron(n_neuron)
    self.register_memory("v", v_reset, self.n_neuron)
    self.pre_spike_v = pre_spike_v

    _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
    if pre_spike_v:
        self.register_memory(
            "v_pre_spike", v_reset, self.n_neuron, persistent=False
        )

    self.trainable_param = set(trainable_param)
    self.def_param(
        "v_threshold",
        v_threshold,
        sizes=self.n_neuron,
        trainable_param=self.trainable_param,
        **_factory_kwargs,
    )
    self.def_param(
        "v_reset",
        v_reset,
        sizes=self.n_neuron,
        trainable_param=self.trainable_param,
        **_factory_kwargs,
    )

    self.detach_reset = detach_reset
    self.surrogate_function = surrogate_function
    self.hard_reset = hard_reset

    self.step_mode = step_mode
    self.backend = backend
neuronal_charge(x) abstractmethod

Define the charge difference equation.

Subclasses must implement this.

Source code in btorch/models/base.py
@abstractmethod
def neuronal_charge(self, x: torch.Tensor):
    """Define the charge difference equation.

    Subclasses must implement this.
    """
    raise NotImplementedError
neuronal_fire()

Calculate output spikes from the current membrane potential and threshold.

Source code in btorch/models/base.py
def neuronal_fire(self):
    """Calculate output spikes from the current membrane potential and
    threshold."""
    return self.surrogate_function(self.v - self.v_threshold)
neuronal_reset(spike)

Reset the membrane potential according to the neurons' output spikes.

Source code in btorch/models/base.py
def neuronal_reset(self, spike):
    """Reset the membrane potential according to the neurons' output
    spikes."""
    if self.detach_reset:
        spike_d = spike.detach()
    else:
        spike_d = spike

    if self.pre_spike_v:
        self.v_pre_spike = self.v.clone()

    if self.hard_reset:
        # hard reset
        self.v = self.v - (self.v - self.v_reset) * spike_d
    else:
        # soft reset
        self.v = self.v - (self.v_threshold - self.v_reset) * spike_d
single_step_forward(x)
  • :ref:API in English <BaseNode.single_step_forward-en>
Source code in btorch/models/base.py
def single_step_forward(self, x: Float[Tensor, "*batch n_neuron"]):
    """
    * :ref:`API in English <BaseNode.single_step_forward-en>`
    """
    self.neuronal_charge(x)
    self.neuronal_adaptation()
    spike = self.neuronal_fire()
    self.neuronal_reset(spike)
    return spike

ELIF

Bases: ALIF

Exponential integrate-and-fire neuron with adaptation.

The ELIF model extends ALIF by adding an exponential term to the voltage dynamics, creating a sharp upswing when approaching threshold (the "initiation zone"). This captures the rapid depolarization seen in real neurons.

Dynamics

dv/dt = (g_leak * delta_T * exp((v - v_T) / delta_T) - g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m dg_k/dt = -g_k / tau_adapt

The exponential term creates a soft threshold effect where membrane potential accelerates as it approaches v_T.

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold (mV). Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike (mV). Default: 0.0.

0.0
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance (pF). Default: 1.0.

1.0
g_leak float | Float[TensorLike, ' n_neuron']

Leak conductance (nS). Default: 1.0.

1.0
E_leak float | Float[TensorLike, ' n_neuron']

Leak reversal potential (mV). Default: 0.0.

0.0
E_k float | Float[TensorLike, ' n_neuron']

Potassium reversal potential (mV). Default: -70.0.

-70.0
g_k_init float | Float[TensorLike, ' n_neuron']

Initial adaptation conductance (nS). Default: 0.0.

0.0
tau_adapt float | Float[TensorLike, ' n_neuron']

Adaptation time constant (ms). Default: 20.0.

20.0
dg_k float | Float[TensorLike, ' n_neuron']

Adaptation increment per spike (nS). Default: 0.0.

0.0
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period (ms). Default: 0.0.

0.0
delta_T float | Float[TensorLike, ' n_neuron']

Slope factor for exponential term (mV). Default: 1.0.

1.0
v_T float | Float[TensorLike, ' n_neuron']

Soft threshold potential (mV). Default: 0.0.

0.0
trainable_param set[str]

Set of parameter names to make trainable.

set()
surrogate_function Callable

Surrogate gradient function. Default: Sigmoid().

Sigmoid()
detach_reset bool

If True, detach reset signal. Default: False.

False
hard_reset bool

If True, use hard reset. Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None

Attributes:

Name Type Description
delta_T Tensor | Parameter

Slope factor for exponential term.

v_T Tensor | Parameter

Soft threshold potential.

Source code in btorch/models/neurons/alif.py
class ELIF(ALIF):
    """Exponential integrate-and-fire neuron with adaptation.

    The ELIF model extends ALIF by adding an exponential term to the
    voltage dynamics, creating a sharp upswing when approaching threshold
    (the "initiation zone"). This captures the rapid depolarization seen
    in real neurons.

    Dynamics:
        dv/dt = (g_leak * delta_T * exp((v - v_T) / delta_T)
                 - g_leak * (v - E_leak) - g_k * (v - E_k) + x) / c_m
        dg_k/dt = -g_k / tau_adapt

    The exponential term creates a soft threshold effect where membrane
    potential accelerates as it approaches v_T.

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold (mV). Default: 1.0.
        v_reset: Reset voltage after spike (mV). Default: 0.0.
        c_m: Membrane capacitance (pF). Default: 1.0.
        g_leak: Leak conductance (nS). Default: 1.0.
        E_leak: Leak reversal potential (mV). Default: 0.0.
        E_k: Potassium reversal potential (mV). Default: -70.0.
        g_k_init: Initial adaptation conductance (nS). Default: 0.0.
        tau_adapt: Adaptation time constant (ms). Default: 20.0.
        dg_k: Adaptation increment per spike (nS). Default: 0.0.
        tau_ref: Refractory period (ms). Default: 0.0.
        delta_T: Slope factor for exponential term (mV). Default: 1.0.
        v_T: Soft threshold potential (mV). Default: 0.0.
        trainable_param: Set of parameter names to make trainable.
        surrogate_function: Surrogate gradient function. Default: Sigmoid().
        detach_reset: If True, detach reset signal. Default: False.
        hard_reset: If True, use hard reset. Default: False.
        pre_spike_v: If True, store pre-spike voltage. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.

    Attributes:
        delta_T: Slope factor for exponential term.
        v_T: Soft threshold potential.
    """

    delta_T: torch.Tensor | torch.nn.Parameter
    v_T: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        c_m: float | Float[TensorLike, " n_neuron"] = 1.0,
        g_leak: float | Float[TensorLike, " n_neuron"] = 1.0,
        E_leak: float | Float[TensorLike, " n_neuron"] = 0.0,
        E_k: float | Float[TensorLike, " n_neuron"] = -70.0,
        g_k_init: float | Float[TensorLike, " n_neuron"] = 0.0,
        tau_adapt: float | Float[TensorLike, " n_neuron"] = 20.0,
        dg_k: float | Float[TensorLike, " n_neuron"] = 0.0,
        tau_ref: float | Float[TensorLike, " n_neuron"] | None = 0.0,
        delta_T: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_T: float | Float[TensorLike, " n_neuron"] = 0.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            c_m=c_m,
            g_leak=g_leak,
            E_leak=E_leak,
            E_k=E_k,
            g_k_init=g_k_init,
            tau_adapt=tau_adapt,
            dg_k=dg_k,
            tau_ref=tau_ref,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            hard_reset=hard_reset,
            pre_spike_v=pre_spike_v,
            step_mode=step_mode,
            backend=backend,
            device=device,
            dtype=dtype,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.def_param(
            "delta_T",
            delta_T,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_T",
            v_T,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        g_k: Float[Tensor, "*batch n_neuron"],
        x: Float[Tensor, "*batch n_neuron"],
    ):
        leak_term = -self.g_leak * (v - self.E_leak)
        adapt_term = -g_k * (v - self.E_k)
        exp_term = self.g_leak * self.delta_T * torch.exp((v - self.v_T) / self.delta_T)
        derivative = (leak_term + adapt_term + exp_term + x) / self.c_m
        linear = (-self.g_leak - g_k + exp_term / self.delta_T) / self.c_m
        return derivative, linear

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        dt = environ.get("dt")
        self.v = exp_euler_step(self.dV, self.v, self.g_k, x, dt=dt)

    def extra_repr(self):
        parts = [
            f"delta_T={self._format_repr_value(self.delta_T)}",
            f"v_T={self._format_repr_value(self.v_T)}",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)

Sigmoid

Bases: SurrogateFunctionBase

Logistic surrogate gradient.

Surrogate gradient: g(v) = 4·σ(k·alpha·v)·(1−σ(k·alpha·v)) where k = 2·ln(√2+1) ≈ 1.763.

alpha is the inverse half-width: HWHM = 1/alpha for any alpha. Peak at threshold is 1.0 when damping=1.

Source code in btorch/models/surrogate/sigmoid.py
class Sigmoid(SurrogateFunctionBase):
    """Logistic surrogate gradient.

    Surrogate gradient: ``g(v) = 4·σ(k·alpha·v)·(1−σ(k·alpha·v))``
    where ``k = 2·ln(√2+1) ≈ 1.763``.

    ``alpha`` is the inverse half-width: HWHM = 1/alpha for any alpha.
    Peak at threshold is 1.0 when damping=1.
    """

    def __init__(
        self, alpha: float = 2.0, damping_factor: float = 1.0, spiking: bool = True
    ):
        super().__init__(alpha=alpha, damping_factor=damping_factor, spiking=spiking)

    def primitive(self, x: torch.Tensor) -> torch.Tensor:
        return _sigmoid_primitive(x, self.alpha)

    def derivative(
        self,
        x: torch.Tensor,
        grad_output: torch.Tensor,
        damping_factor: float = 1.0,
    ) -> torch.Tensor:
        return _sigmoid_derivative(x, grad_output, self.alpha, damping_factor)

Functions

exp_euler_step(f, *args, dt=1.0, linear=None)

One integration step applying the exponential Euler method.

.. math:: rac{dx}{dt} = f(x) = Ax + B

where :math:A is the linear term and :math:f(x) is the derivative.

The update rule is:

.. math:: x_{n+1} = x_n + rac{e^{dt A} - 1}{A} f(x_n) \ &= e^{dt A}x_n + rac{e^{dt A} - 1}{A} B

Source code in btorch/models/ode.py
def exp_euler_step(f: Callable, *args, dt=1.0, linear: Tensor | None = None):
    """One integration step applying the exponential Euler method.

    .. math::
        \frac{dx}{dt} = f(x) = Ax + B

    where :math:`A` is the linear term and :math:`f(x)` is the derivative.

    The update rule is:

    .. math::
        x_{n+1} = x_n + \frac{e^{dt A} - 1}{A} f(x_n) \\
        &= e^{dt A}x_n + \frac{e^{dt A} - 1}{A} B
    """
    out = f(*args)
    derivative, linear_from_f = _split_derivative_linear(out)
    if linear is None:
        linear = linear_from_f
    if linear is None:
        if torch.compiler.is_compiling():
            raise RuntimeError(
                "torch.compile cannot use vjp fallback here; return "
                "(derivative, linear) from f(*args) or pass linear=."
            )
        if len(args) > 1:
            _f = lambda x: _derivative_only(f(x, *args[1:]))
        else:
            _f = lambda x: _derivative_only(f(x))
        derivative, linear_f = vjp(_f, args[0])
        linear = linear_f(torch.ones_like(derivative))[0]
    return args[0] + torch.expm1(dt * linear) / linear * derivative

btorch.models.neurons.glif

Generalized leaky integrate-and-fire (GLIF) neuron models.

This module implements the GLIF3 model from the Allen Institute [1], which extends standard LIF with after-spike currents (ASC) that capture spike-frequency adaptation and other slow currents.

The GLIF3 neuron follows

dV/dt = -(V - V_rest) / tau + (I_in + sum(I_asc)) / c_m dI_asc/dt = -k * I_asc

where I_asc are after-spike currents that increment by asc_amps at each spike.

References

[1] Teeter et al., "Generalized leaky integrate-and-fire models classify multiple neuron types," Nat. Commun., 2018.

Attributes

TensorLike = np.ndarray | torch.Tensor module-attribute

Classes

BaseNode

Bases: ParamBufferMixin, MemoryModule

Base class for differentiable spiking neurons.

Implements the spiking neuron lifecycle: charge -> adapt -> fire -> reset. Subclasses implement neuronal_charge() and neuronal_adaptation().

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold. Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage. Default: 0.0.

0.0
trainable_param set[str]

Trainable parameter names. Default: ().

set()
surrogate_function Callable

Surrogate for backprop. Default: Sigmoid().

Sigmoid()
detach_reset bool

Detach reset signal. Default: False.

False
hard_reset bool

Hard vs soft reset. Default: False.

False
pre_spike_v bool

Store pre-spike voltage. Default: False.

False
step_mode str

"s" or "m". Default: "s".

's'
backend str

Compute backend. Default: "torch".

'torch'
device device | str | None

Tensor device. Default: None.

None
dtype dtype | None

Tensor dtype. Default: None.

None
Source code in btorch/models/base.py
class BaseNode(ParamBufferMixin, MemoryModule):
    """Base class for differentiable spiking neurons.

    Implements the spiking neuron lifecycle: charge -> adapt -> fire -> reset.
    Subclasses implement neuronal_charge() and neuronal_adaptation().

    Args:
        n_neuron: Number of neurons (int or tuple).
        v_threshold: Firing threshold. Default: 1.0.
        v_reset: Reset voltage. Default: 0.0.
        trainable_param: Trainable parameter names. Default: ().
        surrogate_function: Surrogate for backprop. Default: Sigmoid().
        detach_reset: Detach reset signal. Default: False.
        hard_reset: Hard vs soft reset. Default: False.
        pre_spike_v: Store pre-spike voltage. Default: False.
        step_mode: "s" or "m". Default: "s".
        backend: Compute backend. Default: "torch".
        device: Tensor device. Default: None.
        dtype: Tensor dtype. Default: None.
    """

    n_neuron: tuple[int, ...]
    size: int
    v: torch.Tensor
    v_pre_spike: torch.Tensor
    v_threshold: torch.Tensor | torch.nn.Parameter
    v_reset: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: str = "s",
        backend: str = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        """Modified spikingjelly BaseNode.

        * :ref:`API in English <BaseNode.__init__-en>`

        This class is the base class of differentiable spiking neurons.
        """

        # override neuron.BaseNode's __init__ method to remove unnecessary checks
        # call neuron.BaseNode's parent MemoryModule directly
        super().__init__()

        self.n_neuron, self.size = normalize_n_neuron(n_neuron)
        self.register_memory("v", v_reset, self.n_neuron)
        self.pre_spike_v = pre_spike_v

        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        if pre_spike_v:
            self.register_memory(
                "v_pre_spike", v_reset, self.n_neuron, persistent=False
            )

        self.trainable_param = set(trainable_param)
        self.def_param(
            "v_threshold",
            v_threshold,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_reset",
            v_reset,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )

        self.detach_reset = detach_reset
        self.surrogate_function = surrogate_function
        self.hard_reset = hard_reset

        self.step_mode = step_mode
        self.backend = backend

    def extra_repr(self):
        parts = [
            f"n_neuron={self.n_neuron}",
            f"v_threshold={self._format_repr_value(self.v_threshold)}",
            f"v_reset={self._format_repr_value(self.v_reset)}",
            f"step_mode={self.step_mode}",
            f"backend={self.backend}",
            f"surrogate={self.surrogate_function.__class__.__name__}",
        ]
        if self.detach_reset:
            parts.append("detach_reset=True")
        if self.hard_reset:
            parts.append("hard_reset=True")
        if self.pre_spike_v:
            parts.append("pre_spike_v=True")
        mem_repr = super().extra_repr()
        if mem_repr:
            parts.append(mem_repr)
        return ", ".join(parts)

    @abstractmethod
    def neuronal_charge(self, x: torch.Tensor):
        """Define the charge difference equation.

        Subclasses must implement this.
        """
        raise NotImplementedError

    def neuronal_fire(self):
        """Calculate output spikes from the current membrane potential and
        threshold."""
        return self.surrogate_function(self.v - self.v_threshold)

    def neuronal_reset(self, spike):
        """Reset the membrane potential according to the neurons' output
        spikes."""
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            # hard reset
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            # soft reset
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

    def neuronal_adaptation(self):
        raise NotImplementedError()

    def single_step_forward(self, x: Float[Tensor, "*batch n_neuron"]):
        """
        * :ref:`API in English <BaseNode.single_step_forward-en>`
        """
        self.neuronal_charge(x)
        self.neuronal_adaptation()
        spike = self.neuronal_fire()
        self.neuronal_reset(spike)
        return spike

    def multi_step_forward(self, x_seq: Float[Tensor, "T *batch n_neuron"]):
        s_seq = []
        for t, x in enumerate(x_seq):
            s = self.single_step_forward(x)
            s_seq.append(s)

        return torch.stack(s_seq)
Functions
__init__(n_neuron, v_threshold=1.0, v_reset=0.0, trainable_param=set(), surrogate_function=Sigmoid(), detach_reset=False, hard_reset=False, pre_spike_v=False, step_mode='s', backend='torch', device=None, dtype=None)

Modified spikingjelly BaseNode.

  • :ref:API in English <BaseNode.__init__-en>

This class is the base class of differentiable spiking neurons.

Source code in btorch/models/base.py
def __init__(
    self,
    n_neuron: int | Sequence[int],
    v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
    v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
    trainable_param: set[str] = set(),
    surrogate_function: Callable = Sigmoid(),
    detach_reset: bool = False,
    hard_reset: bool = False,
    pre_spike_v: bool = False,
    step_mode: str = "s",
    backend: str = "torch",
    device: torch.device | str | None = None,
    dtype: torch.dtype | None = None,
):
    """Modified spikingjelly BaseNode.

    * :ref:`API in English <BaseNode.__init__-en>`

    This class is the base class of differentiable spiking neurons.
    """

    # override neuron.BaseNode's __init__ method to remove unnecessary checks
    # call neuron.BaseNode's parent MemoryModule directly
    super().__init__()

    self.n_neuron, self.size = normalize_n_neuron(n_neuron)
    self.register_memory("v", v_reset, self.n_neuron)
    self.pre_spike_v = pre_spike_v

    _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
    if pre_spike_v:
        self.register_memory(
            "v_pre_spike", v_reset, self.n_neuron, persistent=False
        )

    self.trainable_param = set(trainable_param)
    self.def_param(
        "v_threshold",
        v_threshold,
        sizes=self.n_neuron,
        trainable_param=self.trainable_param,
        **_factory_kwargs,
    )
    self.def_param(
        "v_reset",
        v_reset,
        sizes=self.n_neuron,
        trainable_param=self.trainable_param,
        **_factory_kwargs,
    )

    self.detach_reset = detach_reset
    self.surrogate_function = surrogate_function
    self.hard_reset = hard_reset

    self.step_mode = step_mode
    self.backend = backend
neuronal_charge(x) abstractmethod

Define the charge difference equation.

Subclasses must implement this.

Source code in btorch/models/base.py
@abstractmethod
def neuronal_charge(self, x: torch.Tensor):
    """Define the charge difference equation.

    Subclasses must implement this.
    """
    raise NotImplementedError
neuronal_fire()

Calculate output spikes from the current membrane potential and threshold.

Source code in btorch/models/base.py
def neuronal_fire(self):
    """Calculate output spikes from the current membrane potential and
    threshold."""
    return self.surrogate_function(self.v - self.v_threshold)
neuronal_reset(spike)

Reset the membrane potential according to the neurons' output spikes.

Source code in btorch/models/base.py
def neuronal_reset(self, spike):
    """Reset the membrane potential according to the neurons' output
    spikes."""
    if self.detach_reset:
        spike_d = spike.detach()
    else:
        spike_d = spike

    if self.pre_spike_v:
        self.v_pre_spike = self.v.clone()

    if self.hard_reset:
        # hard reset
        self.v = self.v - (self.v - self.v_reset) * spike_d
    else:
        # soft reset
        self.v = self.v - (self.v_threshold - self.v_reset) * spike_d
single_step_forward(x)
  • :ref:API in English <BaseNode.single_step_forward-en>
Source code in btorch/models/base.py
def single_step_forward(self, x: Float[Tensor, "*batch n_neuron"]):
    """
    * :ref:`API in English <BaseNode.single_step_forward-en>`
    """
    self.neuronal_charge(x)
    self.neuronal_adaptation()
    spike = self.neuronal_fire()
    self.neuronal_reset(spike)
    return spike

Erf

Bases: SurrogateFunctionBase

Error-function (Gaussian) surrogate gradient.

Surrogate gradient: g(v) = 2^{−(alpha·v)²} = exp(−ln2·(alpha·v)²)

alpha is the inverse half-width: HWHM = 1/alpha for any alpha. Peak at threshold is 1.0 when damping=1.

The default alpha=4 (HWHM=0.25) matches the Gaussian surrogate used in the large-scale V1 model of Chen et al. (2022), which uses gauss_std=0.28 (mapping to alpha = 1/(0.28·√ln2) ≈ 4.3) with damping_factor=0.5.

Parameters

alpha : float Inverse half-width. variance = 1/alpha² sets the Gaussian variance of the gradient envelope; alpha = 1/sqrt(variance). variance : float, optional Convenience alternative to alpha. Sets alpha = 1/sqrt(variance), so the HWHM equals sqrt(variance).

References

Chen, G., Scherr, F., & Maass, W. (2022). A data-based large-scale model for primary visual cortex enables brain-like robust and versatile visual processing. Science Advances, 8(44), eabq7592. https://doi.org/10.1126/sciadv.abq7592

Source code in btorch/models/surrogate/erf.py
class Erf(SurrogateFunctionBase):
    """Error-function (Gaussian) surrogate gradient.

    Surrogate gradient: ``g(v) = 2^{−(alpha·v)²} = exp(−ln2·(alpha·v)²)``

    ``alpha`` is the inverse half-width: HWHM = 1/alpha for any alpha.
    Peak at threshold is 1.0 when damping=1.

    The default ``alpha=4`` (HWHM=0.25) matches the Gaussian surrogate used
    in the large-scale V1 model of Chen et al. (2022), which uses
    ``gauss_std=0.28`` (mapping to ``alpha = 1/(0.28·√ln2) ≈ 4.3``) with
    ``damping_factor=0.5``.

    Parameters
    ----------
    alpha : float
        Inverse half-width.  ``variance = 1/alpha²`` sets the Gaussian
        variance of the gradient envelope; ``alpha = 1/sqrt(variance)``.
    variance : float, optional
        Convenience alternative to ``alpha``.  Sets ``alpha = 1/sqrt(variance)``,
        so the HWHM equals ``sqrt(variance)``.

    References:
        Chen, G., Scherr, F., & Maass, W. (2022). A data-based large-scale
        model for primary visual cortex enables brain-like robust and versatile
        visual processing. *Science Advances*, 8(44), eabq7592.
        https://doi.org/10.1126/sciadv.abq7592
    """

    def __init__(
        self,
        alpha: float = 4.0,
        variance: float | None = None,
        damping_factor: float = 1.0,
        spiking: bool = True,
    ):
        if variance is not None:
            alpha = 1.0 / math.sqrt(variance)
        super().__init__(alpha=alpha, damping_factor=damping_factor, spiking=spiking)

    def primitive(self, x: torch.Tensor) -> torch.Tensor:
        return _erf_primitive(x, self.alpha)

    def derivative(
        self,
        x: torch.Tensor,
        grad_output: torch.Tensor,
        damping_factor: float = 1.0,
    ) -> torch.Tensor:
        return _erf_derivative(x, grad_output, self.alpha, damping_factor)

GLIF3

Bases: BaseNode

GLIF3 model with after-spike currents and refractory period.

The GLIF3 model extends standard LIF by adding after-spike currents (ASC) that capture spike-frequency adaptation. Each spike adds asc_amps to the ASC vector, which then decays exponentially with time constants 1/k.

Dynamics

dV/dt = -(V - V_rest) / tau + (I_in + sum(I_asc)) / c_m dI_asc/dt = -k * I_asc

At spike: I_asc += asc_amps

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold (mV). Default: -50.0.

-50.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike (mV). Default: -70.0.

-70.0
v_rest None | float | Float[TensorLike, ' n_neuron']

Resting potential (mV). Defaults to v_reset if None.

None
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance (pF). Default: 0.05.

0.05
tau float | Float[TensorLike, ' n_neuron']

Membrane time constant (ms). Default: 20.0.

20.0
k float | Sequence[float] | Float[TensorLike, 'n_neuron {self.n_Iasc}']

ASC decay rates (ms^-1), can be list for multiple ASC components. Default: [0.2].

[0.2]
asc_amps float | Sequence[float] | Float[TensorLike, 'n_neuron {self.n_Iasc}']

ASC amplitudes (pA) added at each spike. Default: [0.0].

[0.0]
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period (ms). Default: 0.0.

0.0
trainable_param set[str]

Set of parameter names to make trainable.

set()
surrogate_function Callable

Surrogate gradient function. Default: Erf(alpha=4, damping_factor=0.5), matching the Gaussian surrogate used in Chen et al. (2022).

Erf(alpha=4.0, damping_factor=0.5)
detach_reset bool

If True, detach reset signal. Default: False.

False
hard_reset bool

If True, use hard reset. Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None

Attributes:

Name Type Description
v Tensor

Membrane potential, shape (*batch, n_neuron).

Iasc Tensor

After-spike currents, shape (*batch, n_neuron, n_Iasc).

refractory Tensor | None

Refractory counter (if tau_ref > 0).

c_m, (tau, tau_ref)

Neuron parameters.

k Tensor | Parameter

ASC decay rates, shape (n_neuron, n_Iasc) or (n_Iasc,).

asc_amps Tensor | Parameter

ASC amplitudes, shape (n_neuron, n_Iasc) or (n_Iasc,).

n_Iasc int

Number of ASC components.

References

Teeter et al., "Generalized leaky integrate-and-fire models classify multiple neuron types," Nature Communications, 2018.

Chen, G., Scherr, F., & Maass, W. (2022). A data-based large-scale model for primary visual cortex enables brain-like robust and versatile visual processing. Science Advances, 8(44), eabq7592. https://doi.org/10.1126/sciadv.abq7592

Source code in btorch/models/neurons/glif.py
class GLIF3(BaseNode):
    """GLIF3 model with after-spike currents and refractory period.

    The GLIF3 model extends standard LIF by adding after-spike currents
    (ASC) that capture spike-frequency adaptation. Each spike adds
    asc_amps to the ASC vector, which then decays exponentially with
    time constants 1/k.

    Dynamics:
        dV/dt = -(V - V_rest) / tau + (I_in + sum(I_asc)) / c_m
        dI_asc/dt = -k * I_asc

        At spike: I_asc += asc_amps

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold (mV). Default: -50.0.
        v_reset: Reset voltage after spike (mV). Default: -70.0.
        v_rest: Resting potential (mV). Defaults to v_reset if None.
        c_m: Membrane capacitance (pF). Default: 0.05.
        tau: Membrane time constant (ms). Default: 20.0.
        k: ASC decay rates (ms^-1), can be list for multiple ASC components.
            Default: [0.2].
        asc_amps: ASC amplitudes (pA) added at each spike.
            Default: [0.0].
        tau_ref: Refractory period (ms). Default: 0.0.
        trainable_param: Set of parameter names to make trainable.
        surrogate_function: Surrogate gradient function.
            Default: ``Erf(alpha=4, damping_factor=0.5)``, matching the
            Gaussian surrogate used in Chen et al. (2022).
        detach_reset: If True, detach reset signal. Default: False.
        hard_reset: If True, use hard reset. Default: False.
        pre_spike_v: If True, store pre-spike voltage. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.

    Attributes:
        v: Membrane potential, shape (*batch, n_neuron).
        Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).
        refractory: Refractory counter (if tau_ref > 0).
        c_m, tau, tau_ref: Neuron parameters.
        k: ASC decay rates, shape (n_neuron, n_Iasc) or (n_Iasc,).
        asc_amps: ASC amplitudes, shape (n_neuron, n_Iasc) or (n_Iasc,).
        n_Iasc: Number of ASC components.

    References:
        Teeter et al., "Generalized leaky integrate-and-fire models classify
        multiple neuron types," *Nature Communications*, 2018.

        Chen, G., Scherr, F., & Maass, W. (2022). A data-based large-scale
        model for primary visual cortex enables brain-like robust and versatile
        visual processing. *Science Advances*, 8(44), eabq7592.
        https://doi.org/10.1126/sciadv.abq7592
    """

    # make mypy typing and autocompletion easier
    Iasc: torch.Tensor
    refractory: torch.Tensor | None

    c_m: torch.Tensor | torch.nn.Parameter
    tau: torch.Tensor | torch.nn.Parameter
    tau_ref: torch.Tensor | torch.nn.Parameter | None
    k: torch.Tensor | torch.nn.Parameter
    asc_amps: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = -50.0,  # mV
        v_reset: float | Float[TensorLike, " n_neuron"] = -70.0,  # mV
        v_rest: None | float | Float[TensorLike, " n_neuron"] = None,
        c_m: float | Float[TensorLike, " n_neuron"] = 0.05,  # 1/20 pfarad
        tau: float | Float[TensorLike, " n_neuron"] = 20.0,  # ms
        k: float | Sequence[float] | Float[TensorLike, "n_neuron {self.n_Iasc}"] = [
            0.2
        ],  # ms^-1
        asc_amps: float
        | Sequence[float]
        | Float[TensorLike, "n_neuron {self.n_Iasc}"] = [0.0],  # pA
        tau_ref: float | Float[TensorLike, " n_neuron"] | None = 0.0,  # ms
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Erf(alpha=4.0, damping_factor=0.5),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            step_mode=step_mode,
            backend=backend,
            pre_spike_v=pre_spike_v,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.hard_reset = hard_reset
        self.def_param(
            "c_m",
            c_m,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "tau",
            tau,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self._use_refractory = tau_ref is not None
        if self._use_refractory:
            self.def_param(
                "tau_ref",
                tau_ref,
                trainable_param=self.trainable_param,
                **_factory_kwargs,
            )
            self.register_memory("refractory", 0.0, self.n_neuron)
        else:
            self.tau_ref = None

        # for compat
        if v_rest is not None:
            self.def_param(
                "_v_rest",
                v_rest,
                trainable_param=self.trainable_param,
                **_factory_kwargs,
            )
        else:
            self._v_rest = None

        # Handle after-spike currents.
        if isinstance(asc_amps, Number):
            asc_amps = [asc_amps]
        if isinstance(k, Number):
            k = [k]

        resolved_asc_sizes = self.def_param_resolve_sizes(
            k,
            asc_amps,
            sizes=self.n_neuron + (None,),
        )
        self.n_Iasc: int = resolved_asc_sizes[-1]

        self.def_param(
            "k",
            k,
            sizes=resolved_asc_sizes,
            trainable_param=self.trainable_param,
            normalize_to_sizes=True,
            **_factory_kwargs,
        )
        self.def_param(
            "asc_amps",
            asc_amps,
            sizes=resolved_asc_sizes,
            trainable_param=self.trainable_param,
            normalize_to_sizes=True,
            **_factory_kwargs,
        )

        self.register_memory(
            "Iasc",
            [
                0.0,
            ]
            * self.n_Iasc,
            self.n_neuron + (self.n_Iasc,),
        )

    @property
    def v_rest(self) -> torch.Tensor:
        """Resting potential (mV).

        For compatibility with GLIF4/GLIF5, falls back to v_reset if
        not explicitly set during initialization.

        Returns:
            Resting potential tensor.
        """
        if self._v_rest is None:
            return self.v_reset
        return self._v_rest

    @v_rest.setter
    def v_rest(self, v_rest: float | torch.Tensor):
        """Set resting potential.

        Args:
            v_rest: New resting potential value (mV).
        """
        if self._v_rest is not None:
            self._v_rest = v_rest

    def dIasc(self, Iasc: Float[Tensor, "*batch n_neuron {self.n_Iasc}"]) -> tuple:
        """Compute ASC derivative for exponential Euler integration.

        Args:
            Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).

        Returns:
            Tuple of (derivative, linear_coefficient) for exp_euler_step.
        """
        return -self.k * Iasc, -self.k

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        Iasc: Float[Tensor, "*batch n_neuron {self.n_Iasc}"],
        x: Float[Tensor, "*batch n_neuron"],
    ) -> tuple:
        """Compute membrane potential derivative for exp Euler integration.

        Args:
            v: Membrane potential, shape (*batch, n_neuron).
            Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).
            x: Input current, shape (*batch, n_neuron).

        Returns:
            Tuple of (derivative, linear_coefficient) for exp_euler_step.
        """
        Isum = x
        # torch.autocast will cast half to float32 for sum op
        # see https://docs.pytorch.org/docs/stable/amp.html#ops-that-can-autocast-to-float32
        # here Iasc generally only have <4 modes, so no overflow guaranteed
        return (
            -(v - self.v_rest) / self.tau
            + (Isum + Iasc.sum(-1, dtype=Iasc.dtype)) / self.c_m,
            -1.0 / self.tau,
        )

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        v = exp_euler_step(self.dV, self.v, self.Iasc, x, dt=environ.get("dt"))
        self.v = v

    def neuronal_adaptation(self):
        self.Iasc = exp_euler_step(self.dIasc, self.Iasc, dt=environ.get("dt"))

    def neuronal_fire(self):
        # Check if voltage exceeds threshold and not in refractory period
        spike = self.surrogate_function(
            (self.v - self.v_threshold) / (self.v_threshold - self.v_reset)
        )
        if not self._use_refractory:
            return spike
        not_in_refractory = self.refractory == 0
        spike = spike * not_in_refractory.detach().to(self.v.dtype)
        return spike

    def neuronal_reset(self, spike: Float[Tensor, "*batch n"]):
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            # hard reset
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            # soft reset
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

        # Add after-spike currents
        self.Iasc = self.Iasc + self.asc_amps * spike_d[..., None]

        if self._use_refractory:
            # Set refractory period
            self.refractory = torch.relu(
                self.refractory + spike_d * self.tau_ref - environ.get("dt")
            )

    def get_rheobase(self):
        """Calculate rheobase current, the minimum constant input current
        required to make the neuron fire."""
        return get_rheobase(self.v_threshold, self.v_rest, self.c_m, self.tau)

    def extra_repr(self):
        parts = [
            f"c_m={self._format_repr_value(self.c_m)}",
            f"tau={self._format_repr_value(self.tau)}",
            f"tau_ref={self._format_repr_value(self.tau_ref)}"
            if self._use_refractory
            else "tau_ref=None",
            f"n_Iasc={self.n_Iasc}",
            f"k={self._format_repr_value(self.k)}",
            f"asc_amps={self._format_repr_value(self.asc_amps)}",
            "v_rest=auto"
            if self._v_rest is None
            else f"v_rest={self._format_repr_value(self._v_rest)}",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)

    # TODO: headache to define precise input-output shapes
    # TODO: shape handling not torch.compile friendly
    def _normalize_state_shapes(
        self,
        x: TensorLike | float,
        v0: TensorLike | float,
        Iasc0: TensorLike | float,
        dt: TensorLike | float,
        device: torch.device | None = None,
        dtype: torch.dtype | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
        device = device or self.v_reset.device
        dtype = dtype or self.v_reset.dtype
        x, v0, Iasc0 = (
            torch.as_tensor(x, device=device, dtype=dtype),
            torch.as_tensor(v0, device=device, dtype=dtype),
            torch.as_tensor(Iasc0, device=device, dtype=dtype),
        )
        if isinstance(dt, float):
            dt = torch.tensor([dt], device=device, dtype=dtype)
        else:
            dt = torch.as_tensor(dt, device=device, dtype=dtype)

        shapes = (x.shape, v0.shape, Iasc0.shape[:-1])
        longest_shape = max(shapes, key=len)
        if dt.shape[0] != longest_shape[0]:
            dt = expand_trailing_dims(dt, longest_shape, broadcast_only=True)

        return x, v0, Iasc0, dt

    def forward_exact_no_spike(
        self,
        x: Float[Tensor, "*batch #neuron"] | Float[Tensor, "*batch"],
        v0: Float[Tensor, "*batch neuron"] | None = None,
        Iasc0: Float[Tensor, "*batch neuron {self.n_Iasc}"] | None = None,
        dt: float
        | Float[TensorLike, "#time *batch neuron"]
        | Float[TensorLike, "#*batch neuron"]
        | Float[TensorLike, "#time *batch"]
        | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        if dt is None:
            dt = environ.get("dt")

        update = (v0 is None) and (Iasc0 is None)
        if v0 is None:
            v0 = self.v
        if Iasc0 is None:
            Iasc0 = self.Iasc

        x, v0, Iasc0, dt = self._normalize_state_shapes(x, v0, Iasc0, dt)

        v_inf = self.v_reset + x * self.tau / self.c_m

        exp_m = torch.exp(-dt / self.tau)
        # (time, batch, neuron, n_Iasc)
        exp_asc = torch.exp(-dt[..., None] * self.k)

        Iasc = Iasc0 * exp_asc

        # degenerate case if tau=tau_asc=1/k
        Iasc_contrib = torch.where(
            torch.abs(self.k - 1 / self.tau[..., None]) > 1e-12,
            (Iasc0 / self.c_m[..., None])
            * (exp_asc - exp_m[..., None])
            / (1.0 / self.tau[..., None] - self.k),
            (Iasc0 / self.c_m[..., None]) * (dt * exp_m)[..., None],
        )
        v = v_inf + (v0 - v_inf) * exp_m + Iasc_contrib.sum(dim=-1)

        if update:
            self.v = v
            self.Iasc = Iasc
        return v, Iasc
Attributes
v_rest property writable

Resting potential (mV).

For compatibility with GLIF4/GLIF5, falls back to v_reset if not explicitly set during initialization.

Returns:

Type Description
Tensor

Resting potential tensor.

Functions
dIasc(Iasc)

Compute ASC derivative for exponential Euler integration.

Parameters:

Name Type Description Default
Iasc Float[Tensor, '*batch n_neuron {self.n_Iasc}']

After-spike currents, shape (*batch, n_neuron, n_Iasc).

required

Returns:

Type Description
tuple

Tuple of (derivative, linear_coefficient) for exp_euler_step.

Source code in btorch/models/neurons/glif.py
def dIasc(self, Iasc: Float[Tensor, "*batch n_neuron {self.n_Iasc}"]) -> tuple:
    """Compute ASC derivative for exponential Euler integration.

    Args:
        Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).

    Returns:
        Tuple of (derivative, linear_coefficient) for exp_euler_step.
    """
    return -self.k * Iasc, -self.k
dV(v, Iasc, x)

Compute membrane potential derivative for exp Euler integration.

Parameters:

Name Type Description Default
v Float[Tensor, '*batch n_neuron']

Membrane potential, shape (*batch, n_neuron).

required
Iasc Float[Tensor, '*batch n_neuron {self.n_Iasc}']

After-spike currents, shape (*batch, n_neuron, n_Iasc).

required
x Float[Tensor, '*batch n_neuron']

Input current, shape (*batch, n_neuron).

required

Returns:

Type Description
tuple

Tuple of (derivative, linear_coefficient) for exp_euler_step.

Source code in btorch/models/neurons/glif.py
def dV(
    self,
    v: Float[Tensor, "*batch n_neuron"],
    Iasc: Float[Tensor, "*batch n_neuron {self.n_Iasc}"],
    x: Float[Tensor, "*batch n_neuron"],
) -> tuple:
    """Compute membrane potential derivative for exp Euler integration.

    Args:
        v: Membrane potential, shape (*batch, n_neuron).
        Iasc: After-spike currents, shape (*batch, n_neuron, n_Iasc).
        x: Input current, shape (*batch, n_neuron).

    Returns:
        Tuple of (derivative, linear_coefficient) for exp_euler_step.
    """
    Isum = x
    # torch.autocast will cast half to float32 for sum op
    # see https://docs.pytorch.org/docs/stable/amp.html#ops-that-can-autocast-to-float32
    # here Iasc generally only have <4 modes, so no overflow guaranteed
    return (
        -(v - self.v_rest) / self.tau
        + (Isum + Iasc.sum(-1, dtype=Iasc.dtype)) / self.c_m,
        -1.0 / self.tau,
    )
get_rheobase()

Calculate rheobase current, the minimum constant input current required to make the neuron fire.

Source code in btorch/models/neurons/glif.py
def get_rheobase(self):
    """Calculate rheobase current, the minimum constant input current
    required to make the neuron fire."""
    return get_rheobase(self.v_threshold, self.v_rest, self.c_m, self.tau)

Functions

exp_euler_step(f, *args, dt=1.0, linear=None)

One integration step applying the exponential Euler method.

.. math:: rac{dx}{dt} = f(x) = Ax + B

where :math:A is the linear term and :math:f(x) is the derivative.

The update rule is:

.. math:: x_{n+1} = x_n + rac{e^{dt A} - 1}{A} f(x_n) \ &= e^{dt A}x_n + rac{e^{dt A} - 1}{A} B

Source code in btorch/models/ode.py
def exp_euler_step(f: Callable, *args, dt=1.0, linear: Tensor | None = None):
    """One integration step applying the exponential Euler method.

    .. math::
        \frac{dx}{dt} = f(x) = Ax + B

    where :math:`A` is the linear term and :math:`f(x)` is the derivative.

    The update rule is:

    .. math::
        x_{n+1} = x_n + \frac{e^{dt A} - 1}{A} f(x_n) \\
        &= e^{dt A}x_n + \frac{e^{dt A} - 1}{A} B
    """
    out = f(*args)
    derivative, linear_from_f = _split_derivative_linear(out)
    if linear is None:
        linear = linear_from_f
    if linear is None:
        if torch.compiler.is_compiling():
            raise RuntimeError(
                "torch.compile cannot use vjp fallback here; return "
                "(derivative, linear) from f(*args) or pass linear=."
            )
        if len(args) > 1:
            _f = lambda x: _derivative_only(f(x, *args[1:]))
        else:
            _f = lambda x: _derivative_only(f(x))
        derivative, linear_f = vjp(_f, args[0])
        linear = linear_f(torch.ones_like(derivative))[0]
    return args[0] + torch.expm1(dt * linear) / linear * derivative

expand_trailing_dims(tensor, target_trailing_shape, match_full_shape=False, broadcast_only=False, view=True)

Source code in btorch/models/shape.py
def expand_trailing_dims(
    tensor: torch.Tensor,
    target_trailing_shape: int | tuple[int, ...],
    match_full_shape: bool = False,
    broadcast_only: bool = False,
    view=True,
) -> torch.Tensor:
    return expand_dims(
        tensor,
        target_trailing_shape,
        match_full_shape,
        position="trailing",
        view=view,
        broadcast_only=broadcast_only,
    )

get_rheobase(v_threshold, v_rest, c_m, tau)

Calculate rheobase current.

The rheobase is the minimum constant input current required to make the neuron fire. For GLIF models: I_rheobase = (v_threshold - v_rest) * c_m / tau

Parameters:

Name Type Description Default
v_threshold float | Tensor

Firing threshold (mV).

required
v_rest float | Tensor

Resting potential (mV).

required
c_m float | Tensor

Membrane capacitance (pF).

required
tau float | Tensor

Membrane time constant (ms).

required

Returns:

Type Description
float | Tensor

Rheobase current (pA).

Source code in btorch/models/neurons/glif.py
def get_rheobase(
    v_threshold: float | torch.Tensor,
    v_rest: float | torch.Tensor,
    c_m: float | torch.Tensor,
    tau: float | torch.Tensor,
) -> float | torch.Tensor:
    """Calculate rheobase current.

    The rheobase is the minimum constant input current required to make
    the neuron fire. For GLIF models:
        I_rheobase = (v_threshold - v_rest) * c_m / tau

    Args:
        v_threshold: Firing threshold (mV).
        v_rest: Resting potential (mV).
        c_m: Membrane capacitance (pF).
        tau: Membrane time constant (ms).

    Returns:
        Rheobase current (pA).
    """
    # For GLIF3, rheobase can be calculated as:
    # I_rheobase = (v_threshold - v_rest) * c_m / tau
    I_rheobase = (v_threshold - v_rest) * c_m / tau
    return I_rheobase

btorch.models.neurons.izhikevich

Izhikevich neuron model.

Efficient 2D model reproducing diverse cortical spiking patterns.

Attributes

TensorLike = np.ndarray | torch.Tensor module-attribute

Classes

BaseNode

Bases: ParamBufferMixin, MemoryModule

Base class for differentiable spiking neurons.

Implements the spiking neuron lifecycle: charge -> adapt -> fire -> reset. Subclasses implement neuronal_charge() and neuronal_adaptation().

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold. Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage. Default: 0.0.

0.0
trainable_param set[str]

Trainable parameter names. Default: ().

set()
surrogate_function Callable

Surrogate for backprop. Default: Sigmoid().

Sigmoid()
detach_reset bool

Detach reset signal. Default: False.

False
hard_reset bool

Hard vs soft reset. Default: False.

False
pre_spike_v bool

Store pre-spike voltage. Default: False.

False
step_mode str

"s" or "m". Default: "s".

's'
backend str

Compute backend. Default: "torch".

'torch'
device device | str | None

Tensor device. Default: None.

None
dtype dtype | None

Tensor dtype. Default: None.

None
Source code in btorch/models/base.py
class BaseNode(ParamBufferMixin, MemoryModule):
    """Base class for differentiable spiking neurons.

    Implements the spiking neuron lifecycle: charge -> adapt -> fire -> reset.
    Subclasses implement neuronal_charge() and neuronal_adaptation().

    Args:
        n_neuron: Number of neurons (int or tuple).
        v_threshold: Firing threshold. Default: 1.0.
        v_reset: Reset voltage. Default: 0.0.
        trainable_param: Trainable parameter names. Default: ().
        surrogate_function: Surrogate for backprop. Default: Sigmoid().
        detach_reset: Detach reset signal. Default: False.
        hard_reset: Hard vs soft reset. Default: False.
        pre_spike_v: Store pre-spike voltage. Default: False.
        step_mode: "s" or "m". Default: "s".
        backend: Compute backend. Default: "torch".
        device: Tensor device. Default: None.
        dtype: Tensor dtype. Default: None.
    """

    n_neuron: tuple[int, ...]
    size: int
    v: torch.Tensor
    v_pre_spike: torch.Tensor
    v_threshold: torch.Tensor | torch.nn.Parameter
    v_reset: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: str = "s",
        backend: str = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        """Modified spikingjelly BaseNode.

        * :ref:`API in English <BaseNode.__init__-en>`

        This class is the base class of differentiable spiking neurons.
        """

        # override neuron.BaseNode's __init__ method to remove unnecessary checks
        # call neuron.BaseNode's parent MemoryModule directly
        super().__init__()

        self.n_neuron, self.size = normalize_n_neuron(n_neuron)
        self.register_memory("v", v_reset, self.n_neuron)
        self.pre_spike_v = pre_spike_v

        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        if pre_spike_v:
            self.register_memory(
                "v_pre_spike", v_reset, self.n_neuron, persistent=False
            )

        self.trainable_param = set(trainable_param)
        self.def_param(
            "v_threshold",
            v_threshold,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_reset",
            v_reset,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )

        self.detach_reset = detach_reset
        self.surrogate_function = surrogate_function
        self.hard_reset = hard_reset

        self.step_mode = step_mode
        self.backend = backend

    def extra_repr(self):
        parts = [
            f"n_neuron={self.n_neuron}",
            f"v_threshold={self._format_repr_value(self.v_threshold)}",
            f"v_reset={self._format_repr_value(self.v_reset)}",
            f"step_mode={self.step_mode}",
            f"backend={self.backend}",
            f"surrogate={self.surrogate_function.__class__.__name__}",
        ]
        if self.detach_reset:
            parts.append("detach_reset=True")
        if self.hard_reset:
            parts.append("hard_reset=True")
        if self.pre_spike_v:
            parts.append("pre_spike_v=True")
        mem_repr = super().extra_repr()
        if mem_repr:
            parts.append(mem_repr)
        return ", ".join(parts)

    @abstractmethod
    def neuronal_charge(self, x: torch.Tensor):
        """Define the charge difference equation.

        Subclasses must implement this.
        """
        raise NotImplementedError

    def neuronal_fire(self):
        """Calculate output spikes from the current membrane potential and
        threshold."""
        return self.surrogate_function(self.v - self.v_threshold)

    def neuronal_reset(self, spike):
        """Reset the membrane potential according to the neurons' output
        spikes."""
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            # hard reset
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            # soft reset
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

    def neuronal_adaptation(self):
        raise NotImplementedError()

    def single_step_forward(self, x: Float[Tensor, "*batch n_neuron"]):
        """
        * :ref:`API in English <BaseNode.single_step_forward-en>`
        """
        self.neuronal_charge(x)
        self.neuronal_adaptation()
        spike = self.neuronal_fire()
        self.neuronal_reset(spike)
        return spike

    def multi_step_forward(self, x_seq: Float[Tensor, "T *batch n_neuron"]):
        s_seq = []
        for t, x in enumerate(x_seq):
            s = self.single_step_forward(x)
            s_seq.append(s)

        return torch.stack(s_seq)
Functions
__init__(n_neuron, v_threshold=1.0, v_reset=0.0, trainable_param=set(), surrogate_function=Sigmoid(), detach_reset=False, hard_reset=False, pre_spike_v=False, step_mode='s', backend='torch', device=None, dtype=None)

Modified spikingjelly BaseNode.

  • :ref:API in English <BaseNode.__init__-en>

This class is the base class of differentiable spiking neurons.

Source code in btorch/models/base.py
def __init__(
    self,
    n_neuron: int | Sequence[int],
    v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
    v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
    trainable_param: set[str] = set(),
    surrogate_function: Callable = Sigmoid(),
    detach_reset: bool = False,
    hard_reset: bool = False,
    pre_spike_v: bool = False,
    step_mode: str = "s",
    backend: str = "torch",
    device: torch.device | str | None = None,
    dtype: torch.dtype | None = None,
):
    """Modified spikingjelly BaseNode.

    * :ref:`API in English <BaseNode.__init__-en>`

    This class is the base class of differentiable spiking neurons.
    """

    # override neuron.BaseNode's __init__ method to remove unnecessary checks
    # call neuron.BaseNode's parent MemoryModule directly
    super().__init__()

    self.n_neuron, self.size = normalize_n_neuron(n_neuron)
    self.register_memory("v", v_reset, self.n_neuron)
    self.pre_spike_v = pre_spike_v

    _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
    if pre_spike_v:
        self.register_memory(
            "v_pre_spike", v_reset, self.n_neuron, persistent=False
        )

    self.trainable_param = set(trainable_param)
    self.def_param(
        "v_threshold",
        v_threshold,
        sizes=self.n_neuron,
        trainable_param=self.trainable_param,
        **_factory_kwargs,
    )
    self.def_param(
        "v_reset",
        v_reset,
        sizes=self.n_neuron,
        trainable_param=self.trainable_param,
        **_factory_kwargs,
    )

    self.detach_reset = detach_reset
    self.surrogate_function = surrogate_function
    self.hard_reset = hard_reset

    self.step_mode = step_mode
    self.backend = backend
neuronal_charge(x) abstractmethod

Define the charge difference equation.

Subclasses must implement this.

Source code in btorch/models/base.py
@abstractmethod
def neuronal_charge(self, x: torch.Tensor):
    """Define the charge difference equation.

    Subclasses must implement this.
    """
    raise NotImplementedError
neuronal_fire()

Calculate output spikes from the current membrane potential and threshold.

Source code in btorch/models/base.py
def neuronal_fire(self):
    """Calculate output spikes from the current membrane potential and
    threshold."""
    return self.surrogate_function(self.v - self.v_threshold)
neuronal_reset(spike)

Reset the membrane potential according to the neurons' output spikes.

Source code in btorch/models/base.py
def neuronal_reset(self, spike):
    """Reset the membrane potential according to the neurons' output
    spikes."""
    if self.detach_reset:
        spike_d = spike.detach()
    else:
        spike_d = spike

    if self.pre_spike_v:
        self.v_pre_spike = self.v.clone()

    if self.hard_reset:
        # hard reset
        self.v = self.v - (self.v - self.v_reset) * spike_d
    else:
        # soft reset
        self.v = self.v - (self.v_threshold - self.v_reset) * spike_d
single_step_forward(x)
  • :ref:API in English <BaseNode.single_step_forward-en>
Source code in btorch/models/base.py
def single_step_forward(self, x: Float[Tensor, "*batch n_neuron"]):
    """
    * :ref:`API in English <BaseNode.single_step_forward-en>`
    """
    self.neuronal_charge(x)
    self.neuronal_adaptation()
    spike = self.neuronal_fire()
    self.neuronal_reset(spike)
    return spike

Izhikevich

Bases: BaseNode

Izhikevich neuron with quadratic dynamics and recovery variable.

Efficient model reproducing diverse spiking patterns (tonic, bursting, etc.) via a 2D ODE system with quadratic nonlinearity.

Dynamics

dv/dt = (k(v-v_rest)(v-v_threshold) - u + I) / c_m du/dt = a * (b*(v-v_rest) - u)

At spike: v=v_reset, u=u+d

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons.

required
v_threshold float | Float[TensorLike, ' n_neuron']

Threshold (mV). Default: 30.0.

30.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage (mV). Default: -65.0.

-65.0
v_rest float | Float[TensorLike, ' n_neuron']

Resting potential (mV). Default: -65.0.

-65.0
v_peak float | Float[TensorLike, ' n_neuron']

Spike cutoff (mV). Default: -40.0.

-40.0
c_m float | Float[TensorLike, ' n_neuron']

Capacitance (pF). Default: 100.0.

100.0
k float | Float[TensorLike, ' n_neuron']

Scaling factor (nS/mV). Default: 0.7.

0.7
a float | Float[TensorLike, ' n_neuron']

Recovery timescale (ms^-1). Default: 0.03.

0.03
b float | Float[TensorLike, ' n_neuron']

Recovery coupling (nS). Default: -2.0.

-2.0
d float | Float[TensorLike, ' n_neuron']

Recovery jump (pA). Default: 100.0.

100.0
trainable_param set[str]

Trainable parameters. Default: ().

set()
surrogate_function Callable

Surrogate for backprop. Default: Sigmoid().

Sigmoid()
detach_reset bool

Detach reset signal. Default: False.

False
hard_reset bool

Hard vs soft reset. Default: False.

False
pre_spike bool

Store pre-spike values. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend. Default: "torch".

'torch'
device device | str | None

Device. Default: None.

None
dtype dtype | None

Dtype. Default: None.

None

Attributes:

Name Type Description
v Tensor

Membrane potential (*batch, n_neuron).

u Tensor

Recovery variable (*batch, n_neuron).

Reference

Izhikevich, IEEE Trans. Neural Networks, 2003.

Source code in btorch/models/neurons/izhikevich.py
class Izhikevich(BaseNode):
    """Izhikevich neuron with quadratic dynamics and recovery variable.

    Efficient model reproducing diverse spiking patterns (tonic, bursting,
    etc.) via a 2D ODE system with quadratic nonlinearity.

    Dynamics:
        dv/dt = (k*(v-v_rest)*(v-v_threshold) - u + I) / c_m
        du/dt = a * (b*(v-v_rest) - u)

    At spike: v=v_reset, u=u+d

    Args:
        n_neuron: Number of neurons.
        v_threshold: Threshold (mV). Default: 30.0.
        v_reset: Reset voltage (mV). Default: -65.0.
        v_rest: Resting potential (mV). Default: -65.0.
        v_peak: Spike cutoff (mV). Default: -40.0.
        c_m: Capacitance (pF). Default: 100.0.
        k: Scaling factor (nS/mV). Default: 0.7.
        a: Recovery timescale (ms^-1). Default: 0.03.
        b: Recovery coupling (nS). Default: -2.0.
        d: Recovery jump (pA). Default: 100.0.
        trainable_param: Trainable parameters. Default: ().
        surrogate_function: Surrogate for backprop. Default: Sigmoid().
        detach_reset: Detach reset signal. Default: False.
        hard_reset: Hard vs soft reset. Default: False.
        pre_spike: Store pre-spike values. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend. Default: "torch".
        device: Device. Default: None.
        dtype: Dtype. Default: None.

    Attributes:
        v: Membrane potential (*batch, n_neuron).
        u: Recovery variable (*batch, n_neuron).

    Reference:
        Izhikevich, IEEE Trans. Neural Networks, 2003.
    """

    HIPPOCAMPOME_TO_ARGS = {
        "k": "k",
        "a": "a",
        "b": "b",
        "d": "d",
        "C": "c_m",
        "vr": "v_rest",
        "vt": "v_threshold",
        "vpeak": "v_peak",
        "vmin": "v_reset",
    }

    u: torch.Tensor
    u_pre_spike: torch.Tensor

    v_reset: torch.Tensor | torch.nn.Parameter
    v_rest: torch.Tensor | torch.nn.Parameter
    v_peak: torch.Tensor | torch.nn.Parameter
    c_m: torch.Tensor | torch.nn.Parameter
    k: torch.Tensor | torch.nn.Parameter
    a: torch.Tensor | torch.nn.Parameter
    b: torch.Tensor | torch.nn.Parameter
    d: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 30.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = -65.0,
        v_rest: float | Float[TensorLike, " n_neuron"] = -65.0,
        v_peak: float | Float[TensorLike, " n_neuron"] = -40.0,
        c_m: float | Float[TensorLike, " n_neuron"] = 100.0,
        k: float | Float[TensorLike, " n_neuron"] = 0.7,
        a: float | Float[TensorLike, " n_neuron"] = 0.03,
        b: float | Float[TensorLike, " n_neuron"] = -2.0,
        d: float | Float[TensorLike, " n_neuron"] = 100.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            hard_reset=hard_reset,
            pre_spike_v=pre_spike,
            step_mode=step_mode,
            backend=backend,
            device=device,
            dtype=dtype,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.def_param(
            "c_m",
            c_m,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_rest",
            v_rest,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_peak",
            v_peak,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "k",
            k,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "a",
            a,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "b",
            b,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "d",
            d,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )

        self.register_memory("u", 0, self.n_neuron)
        if pre_spike:
            self.register_memory("u_pre_spike", None, self.n_neuron)

    @classmethod
    def from_hippocampome(
        cls,
        n_neuron: int | Sequence[int],
        k,
        a,
        b,
        d,
        C,
        vr,
        vt,
        vpeak,
        vmin,
        **kwargs,
    ):
        """
        Build an :class:`Izhikevich` neuron using parameter names from
        https://hippocampome.org.

        Parameter mapping (HippoCampome -> Izhikevich args):
        - k -> k (scaling factor)
        - a -> a (recovery time constant)
        - b -> b (recovery sensitivity)
        - d -> d (reset current)
        - C -> c_m (capacitance)
        - vr -> v_rest (resting potential)
        - vt -> v_threshold (instantaneous threshold)
        - vpeak -> v_peak (spike cutoff)
        - vmin -> v_reset (post-spike reset voltage)

        All values are expected in the same units as the canonical
        Izhikevich model (mV, pF, pA).
        """
        kwargs.setdefault("pre_spike", True)
        return cls(
            n_neuron,
            v_threshold=vt,
            v_reset=vmin,
            v_rest=vr,
            v_peak=vpeak,
            c_m=C,
            k=k,
            a=a,
            b=b,
            d=d,
            **kwargs,
        )

    @classmethod
    def from_canonical_quadratic(
        cls,
        n_neuron: int | Sequence[int],
        p1: float = 0.04,
        p2: float = 5.0,
        # TODO: p3: float = 0.0, adjust equation
        v_rest: float = -65.0,
        c_m: float = 1.0,
        v_peak: float = 30.0,
        **kwargs,
    ):
        """
        Instantiate using the canonical quadratic form
        ``dV/dt = p1*v^2 + p2*v + p3 - u + I``.

        The mapping assumes ``c_m`` acts as the membrane capacitance and that
        ``k/c_m`` equals ``p1``. The linear term enforces
        ``v_threshold = -p2/p1 - v_rest``. Remaining
        keyword arguments are passed directly to :class:`Izhikevich`.
        """
        k = p1 * c_m
        v_threshold = -p2 / p1 - v_rest
        # i_bias = p3 - p1 * v_rest * v_threshold

        return cls(
            n_neuron,
            v_threshold=v_threshold,
            v_reset=kwargs.pop("v_reset", v_rest),
            v_rest=v_rest,
            v_peak=v_peak,
            c_m=c_m,
            k=k,
            **kwargs,
        )

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        u: Float[Tensor, "*batch n_neuron"],
        x: Float[Tensor, "*batch n_neuron"],
    ):
        quadratic = self.k * (v - self.v_rest) * (v - self.v_threshold)
        return (x + quadratic - u) / self.c_m

    def dU(
        self,
        u: Float[Tensor, "*batch n_neuron"],
        v: Float[Tensor, "*batch n_neuron"],
    ):
        return self.a * (self.b * (v - self.v_rest) - u)

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        dt = environ.get("dt")
        self.v = euler_step(self.dV, self.v, self.u, x, dt=dt)

    def neuronal_adaptation(self):
        dt = environ.get("dt")
        self.u = euler_step(self.dU, self.u, self.v, dt=dt)

    def neuronal_fire(self):
        # TODO: confirm scaling with (self.v_threshold - self.v_reset)
        # or (self.v_peak - self.v_reset)
        spike = self.surrogate_function(
            (self.v - self.v_peak) / (self.v_threshold - self.v_reset)
        )
        return spike

    def neuronal_reset(self, spike: Float[Tensor, "*batch n"]):
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()
            self.u_pre_spike = self.u.clone()

        if self.hard_reset:
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            self.v = self.v - (self.v_peak - self.v_reset) * spike_d

        self.u = self.u + self.d * spike_d

    def extra_repr(self):
        parts = [
            f"c_m={self._format_repr_value(self.c_m)}",
            f"k={self._format_repr_value(self.k)}",
            f"a={self._format_repr_value(self.a)}",
            f"b={self._format_repr_value(self.b)}",
            f"d={self._format_repr_value(self.d)}",
            f"v_rest={self._format_repr_value(self.v_rest)}",
            f"v_peak={self._format_repr_value(self.v_peak)}",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)
Functions
from_canonical_quadratic(n_neuron, p1=0.04, p2=5.0, v_rest=-65.0, c_m=1.0, v_peak=30.0, **kwargs) classmethod

Instantiate using the canonical quadratic form dV/dt = p1*v^2 + p2*v + p3 - u + I.

The mapping assumes c_m acts as the membrane capacitance and that k/c_m equals p1. The linear term enforces v_threshold = -p2/p1 - v_rest. Remaining keyword arguments are passed directly to :class:Izhikevich.

Source code in btorch/models/neurons/izhikevich.py
@classmethod
def from_canonical_quadratic(
    cls,
    n_neuron: int | Sequence[int],
    p1: float = 0.04,
    p2: float = 5.0,
    # TODO: p3: float = 0.0, adjust equation
    v_rest: float = -65.0,
    c_m: float = 1.0,
    v_peak: float = 30.0,
    **kwargs,
):
    """
    Instantiate using the canonical quadratic form
    ``dV/dt = p1*v^2 + p2*v + p3 - u + I``.

    The mapping assumes ``c_m`` acts as the membrane capacitance and that
    ``k/c_m`` equals ``p1``. The linear term enforces
    ``v_threshold = -p2/p1 - v_rest``. Remaining
    keyword arguments are passed directly to :class:`Izhikevich`.
    """
    k = p1 * c_m
    v_threshold = -p2 / p1 - v_rest
    # i_bias = p3 - p1 * v_rest * v_threshold

    return cls(
        n_neuron,
        v_threshold=v_threshold,
        v_reset=kwargs.pop("v_reset", v_rest),
        v_rest=v_rest,
        v_peak=v_peak,
        c_m=c_m,
        k=k,
        **kwargs,
    )
from_hippocampome(n_neuron, k, a, b, d, C, vr, vt, vpeak, vmin, **kwargs) classmethod

Build an :class:Izhikevich neuron using parameter names from https://hippocampome.org.

Parameter mapping (HippoCampome -> Izhikevich args): - k -> k (scaling factor) - a -> a (recovery time constant) - b -> b (recovery sensitivity) - d -> d (reset current) - C -> c_m (capacitance) - vr -> v_rest (resting potential) - vt -> v_threshold (instantaneous threshold) - vpeak -> v_peak (spike cutoff) - vmin -> v_reset (post-spike reset voltage)

All values are expected in the same units as the canonical Izhikevich model (mV, pF, pA).

Source code in btorch/models/neurons/izhikevich.py
@classmethod
def from_hippocampome(
    cls,
    n_neuron: int | Sequence[int],
    k,
    a,
    b,
    d,
    C,
    vr,
    vt,
    vpeak,
    vmin,
    **kwargs,
):
    """
    Build an :class:`Izhikevich` neuron using parameter names from
    https://hippocampome.org.

    Parameter mapping (HippoCampome -> Izhikevich args):
    - k -> k (scaling factor)
    - a -> a (recovery time constant)
    - b -> b (recovery sensitivity)
    - d -> d (reset current)
    - C -> c_m (capacitance)
    - vr -> v_rest (resting potential)
    - vt -> v_threshold (instantaneous threshold)
    - vpeak -> v_peak (spike cutoff)
    - vmin -> v_reset (post-spike reset voltage)

    All values are expected in the same units as the canonical
    Izhikevich model (mV, pF, pA).
    """
    kwargs.setdefault("pre_spike", True)
    return cls(
        n_neuron,
        v_threshold=vt,
        v_reset=vmin,
        v_rest=vr,
        v_peak=vpeak,
        c_m=C,
        k=k,
        a=a,
        b=b,
        d=d,
        **kwargs,
    )

Sigmoid

Bases: SurrogateFunctionBase

Logistic surrogate gradient.

Surrogate gradient: g(v) = 4·σ(k·alpha·v)·(1−σ(k·alpha·v)) where k = 2·ln(√2+1) ≈ 1.763.

alpha is the inverse half-width: HWHM = 1/alpha for any alpha. Peak at threshold is 1.0 when damping=1.

Source code in btorch/models/surrogate/sigmoid.py
class Sigmoid(SurrogateFunctionBase):
    """Logistic surrogate gradient.

    Surrogate gradient: ``g(v) = 4·σ(k·alpha·v)·(1−σ(k·alpha·v))``
    where ``k = 2·ln(√2+1) ≈ 1.763``.

    ``alpha`` is the inverse half-width: HWHM = 1/alpha for any alpha.
    Peak at threshold is 1.0 when damping=1.
    """

    def __init__(
        self, alpha: float = 2.0, damping_factor: float = 1.0, spiking: bool = True
    ):
        super().__init__(alpha=alpha, damping_factor=damping_factor, spiking=spiking)

    def primitive(self, x: torch.Tensor) -> torch.Tensor:
        return _sigmoid_primitive(x, self.alpha)

    def derivative(
        self,
        x: torch.Tensor,
        grad_output: torch.Tensor,
        damping_factor: float = 1.0,
    ) -> torch.Tensor:
        return _sigmoid_derivative(x, grad_output, self.alpha, damping_factor)

Functions

euler_step(f, *args, dt=1.0)

Source code in btorch/models/ode.py
def euler_step(f: Callable, *args, dt=1.0):
    derivative = _derivative_only(f(*args))
    return args[0] + dt * derivative

btorch.models.neurons.lif

Leaky integrate-and-fire (LIF) neuron models.

This module provides LIF and IF (integrate-and-fire) neuron implementations with optional refractory periods. These are the simplest spiking neuron models, suitable for basic neuromorphic computing tasks and as building blocks for more complex networks.

The LIF neuron follows the dynamics

dV/dt = -(V - V_reset) / tau + I / c_m

where V is membrane potential, tau is the time constant, I is input current, and c_m is membrane capacitance.

Attributes

TensorLike = np.ndarray | torch.Tensor module-attribute

Classes

BaseNode

Bases: ParamBufferMixin, MemoryModule

Base class for differentiable spiking neurons.

Implements the spiking neuron lifecycle: charge -> adapt -> fire -> reset. Subclasses implement neuronal_charge() and neuronal_adaptation().

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold. Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage. Default: 0.0.

0.0
trainable_param set[str]

Trainable parameter names. Default: ().

set()
surrogate_function Callable

Surrogate for backprop. Default: Sigmoid().

Sigmoid()
detach_reset bool

Detach reset signal. Default: False.

False
hard_reset bool

Hard vs soft reset. Default: False.

False
pre_spike_v bool

Store pre-spike voltage. Default: False.

False
step_mode str

"s" or "m". Default: "s".

's'
backend str

Compute backend. Default: "torch".

'torch'
device device | str | None

Tensor device. Default: None.

None
dtype dtype | None

Tensor dtype. Default: None.

None
Source code in btorch/models/base.py
class BaseNode(ParamBufferMixin, MemoryModule):
    """Base class for differentiable spiking neurons.

    Implements the spiking neuron lifecycle: charge -> adapt -> fire -> reset.
    Subclasses implement neuronal_charge() and neuronal_adaptation().

    Args:
        n_neuron: Number of neurons (int or tuple).
        v_threshold: Firing threshold. Default: 1.0.
        v_reset: Reset voltage. Default: 0.0.
        trainable_param: Trainable parameter names. Default: ().
        surrogate_function: Surrogate for backprop. Default: Sigmoid().
        detach_reset: Detach reset signal. Default: False.
        hard_reset: Hard vs soft reset. Default: False.
        pre_spike_v: Store pre-spike voltage. Default: False.
        step_mode: "s" or "m". Default: "s".
        backend: Compute backend. Default: "torch".
        device: Tensor device. Default: None.
        dtype: Tensor dtype. Default: None.
    """

    n_neuron: tuple[int, ...]
    size: int
    v: torch.Tensor
    v_pre_spike: torch.Tensor
    v_threshold: torch.Tensor | torch.nn.Parameter
    v_reset: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: str = "s",
        backend: str = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        """Modified spikingjelly BaseNode.

        * :ref:`API in English <BaseNode.__init__-en>`

        This class is the base class of differentiable spiking neurons.
        """

        # override neuron.BaseNode's __init__ method to remove unnecessary checks
        # call neuron.BaseNode's parent MemoryModule directly
        super().__init__()

        self.n_neuron, self.size = normalize_n_neuron(n_neuron)
        self.register_memory("v", v_reset, self.n_neuron)
        self.pre_spike_v = pre_spike_v

        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        if pre_spike_v:
            self.register_memory(
                "v_pre_spike", v_reset, self.n_neuron, persistent=False
            )

        self.trainable_param = set(trainable_param)
        self.def_param(
            "v_threshold",
            v_threshold,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "v_reset",
            v_reset,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )

        self.detach_reset = detach_reset
        self.surrogate_function = surrogate_function
        self.hard_reset = hard_reset

        self.step_mode = step_mode
        self.backend = backend

    def extra_repr(self):
        parts = [
            f"n_neuron={self.n_neuron}",
            f"v_threshold={self._format_repr_value(self.v_threshold)}",
            f"v_reset={self._format_repr_value(self.v_reset)}",
            f"step_mode={self.step_mode}",
            f"backend={self.backend}",
            f"surrogate={self.surrogate_function.__class__.__name__}",
        ]
        if self.detach_reset:
            parts.append("detach_reset=True")
        if self.hard_reset:
            parts.append("hard_reset=True")
        if self.pre_spike_v:
            parts.append("pre_spike_v=True")
        mem_repr = super().extra_repr()
        if mem_repr:
            parts.append(mem_repr)
        return ", ".join(parts)

    @abstractmethod
    def neuronal_charge(self, x: torch.Tensor):
        """Define the charge difference equation.

        Subclasses must implement this.
        """
        raise NotImplementedError

    def neuronal_fire(self):
        """Calculate output spikes from the current membrane potential and
        threshold."""
        return self.surrogate_function(self.v - self.v_threshold)

    def neuronal_reset(self, spike):
        """Reset the membrane potential according to the neurons' output
        spikes."""
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            # hard reset
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            # soft reset
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

    def neuronal_adaptation(self):
        raise NotImplementedError()

    def single_step_forward(self, x: Float[Tensor, "*batch n_neuron"]):
        """
        * :ref:`API in English <BaseNode.single_step_forward-en>`
        """
        self.neuronal_charge(x)
        self.neuronal_adaptation()
        spike = self.neuronal_fire()
        self.neuronal_reset(spike)
        return spike

    def multi_step_forward(self, x_seq: Float[Tensor, "T *batch n_neuron"]):
        s_seq = []
        for t, x in enumerate(x_seq):
            s = self.single_step_forward(x)
            s_seq.append(s)

        return torch.stack(s_seq)
Functions
__init__(n_neuron, v_threshold=1.0, v_reset=0.0, trainable_param=set(), surrogate_function=Sigmoid(), detach_reset=False, hard_reset=False, pre_spike_v=False, step_mode='s', backend='torch', device=None, dtype=None)

Modified spikingjelly BaseNode.

  • :ref:API in English <BaseNode.__init__-en>

This class is the base class of differentiable spiking neurons.

Source code in btorch/models/base.py
def __init__(
    self,
    n_neuron: int | Sequence[int],
    v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
    v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
    trainable_param: set[str] = set(),
    surrogate_function: Callable = Sigmoid(),
    detach_reset: bool = False,
    hard_reset: bool = False,
    pre_spike_v: bool = False,
    step_mode: str = "s",
    backend: str = "torch",
    device: torch.device | str | None = None,
    dtype: torch.dtype | None = None,
):
    """Modified spikingjelly BaseNode.

    * :ref:`API in English <BaseNode.__init__-en>`

    This class is the base class of differentiable spiking neurons.
    """

    # override neuron.BaseNode's __init__ method to remove unnecessary checks
    # call neuron.BaseNode's parent MemoryModule directly
    super().__init__()

    self.n_neuron, self.size = normalize_n_neuron(n_neuron)
    self.register_memory("v", v_reset, self.n_neuron)
    self.pre_spike_v = pre_spike_v

    _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
    if pre_spike_v:
        self.register_memory(
            "v_pre_spike", v_reset, self.n_neuron, persistent=False
        )

    self.trainable_param = set(trainable_param)
    self.def_param(
        "v_threshold",
        v_threshold,
        sizes=self.n_neuron,
        trainable_param=self.trainable_param,
        **_factory_kwargs,
    )
    self.def_param(
        "v_reset",
        v_reset,
        sizes=self.n_neuron,
        trainable_param=self.trainable_param,
        **_factory_kwargs,
    )

    self.detach_reset = detach_reset
    self.surrogate_function = surrogate_function
    self.hard_reset = hard_reset

    self.step_mode = step_mode
    self.backend = backend
neuronal_charge(x) abstractmethod

Define the charge difference equation.

Subclasses must implement this.

Source code in btorch/models/base.py
@abstractmethod
def neuronal_charge(self, x: torch.Tensor):
    """Define the charge difference equation.

    Subclasses must implement this.
    """
    raise NotImplementedError
neuronal_fire()

Calculate output spikes from the current membrane potential and threshold.

Source code in btorch/models/base.py
def neuronal_fire(self):
    """Calculate output spikes from the current membrane potential and
    threshold."""
    return self.surrogate_function(self.v - self.v_threshold)
neuronal_reset(spike)

Reset the membrane potential according to the neurons' output spikes.

Source code in btorch/models/base.py
def neuronal_reset(self, spike):
    """Reset the membrane potential according to the neurons' output
    spikes."""
    if self.detach_reset:
        spike_d = spike.detach()
    else:
        spike_d = spike

    if self.pre_spike_v:
        self.v_pre_spike = self.v.clone()

    if self.hard_reset:
        # hard reset
        self.v = self.v - (self.v - self.v_reset) * spike_d
    else:
        # soft reset
        self.v = self.v - (self.v_threshold - self.v_reset) * spike_d
single_step_forward(x)
  • :ref:API in English <BaseNode.single_step_forward-en>
Source code in btorch/models/base.py
def single_step_forward(self, x: Float[Tensor, "*batch n_neuron"]):
    """
    * :ref:`API in English <BaseNode.single_step_forward-en>`
    """
    self.neuronal_charge(x)
    self.neuronal_adaptation()
    spike = self.neuronal_fire()
    self.neuronal_reset(spike)
    return spike

IF

Bases: LIF

Integrate-and-fire neuron without leak.

Simplified variant of LIF that lacks the leak term, meaning the membrane potential integrates input current linearly without decay:

dV/dt = I / c_m

This model is useful for theoretical analysis and as a baseline, though it lacks biological realism due to unbounded integration.

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold. Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike. Default: 0.0.

0.0
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance. Default: 1.0.

1.0
tau float | Float[TensorLike, ' n_neuron']

Time constant (inherited from LIF but not used in dynamics).

20.0
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period duration. Default: None.

None
trainable_param set[str]

Set of parameter names to make trainable.

set()
surrogate_function Callable

Surrogate gradient function. Default: Sigmoid().

Sigmoid()
detach_reset bool

If True, detach reset signal. Default: False.

False
hard_reset bool

If True, use hard reset. Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage. Default: False.

False
step_mode Literal['s']

Step mode. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None
Source code in btorch/models/neurons/lif.py
class IF(LIF):
    """Integrate-and-fire neuron without leak.

    Simplified variant of LIF that lacks the leak term, meaning the membrane
    potential integrates input current linearly without decay:

        dV/dt = I / c_m

    This model is useful for theoretical analysis and as a baseline,
    though it lacks biological realism due to unbounded integration.

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold. Default: 1.0.
        v_reset: Reset voltage after spike. Default: 0.0.
        c_m: Membrane capacitance. Default: 1.0.
        tau: Time constant (inherited from LIF but not used in dynamics).
        tau_ref: Refractory period duration. Default: None.
        trainable_param: Set of parameter names to make trainable.
        surrogate_function: Surrogate gradient function. Default: Sigmoid().
        detach_reset: If True, detach reset signal. Default: False.
        hard_reset: If True, use hard reset. Default: False.
        pre_spike_v: If True, store pre-spike voltage. Default: False.
        step_mode: Step mode. Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.
    """

    def dV(
        self,
        x: Float[Tensor, "*batch n_neuron"],
    ) -> Float[Tensor, "*batch n_neuron"]:
        """Compute membrane potential derivative (no leak term).

        Args:
            x: Input current, shape (*batch, n_neuron).

        Returns:
            dV/dt derivative, shape (*batch, n_neuron).
        """
        derivative = x / self.c_m
        return derivative

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        """Update membrane potential using Euler integration (no leak).

        Args:
            x: Input current, shape (*batch, n_neuron).
        """
        v = euler_step(self.dV, x, dt=environ.get("dt"))
        self.v = v
Functions
dV(x)

Compute membrane potential derivative (no leak term).

Parameters:

Name Type Description Default
x Float[Tensor, '*batch n_neuron']

Input current, shape (*batch, n_neuron).

required

Returns:

Type Description
Float[Tensor, '*batch n_neuron']

dV/dt derivative, shape (*batch, n_neuron).

Source code in btorch/models/neurons/lif.py
def dV(
    self,
    x: Float[Tensor, "*batch n_neuron"],
) -> Float[Tensor, "*batch n_neuron"]:
    """Compute membrane potential derivative (no leak term).

    Args:
        x: Input current, shape (*batch, n_neuron).

    Returns:
        dV/dt derivative, shape (*batch, n_neuron).
    """
    derivative = x / self.c_m
    return derivative
neuronal_charge(x)

Update membrane potential using Euler integration (no leak).

Parameters:

Name Type Description Default
x Float[Tensor, '*batch n_neuron']

Input current, shape (*batch, n_neuron).

required
Source code in btorch/models/neurons/lif.py
def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
    """Update membrane potential using Euler integration (no leak).

    Args:
        x: Input current, shape (*batch, n_neuron).
    """
    v = euler_step(self.dV, x, dt=environ.get("dt"))
    self.v = v

LIF

Bases: BaseNode

Leaky integrate-and-fire neuron with optional refractory period.

The LIF neuron integrates input current while leaking towards a resting potential. When the membrane potential exceeds a threshold, a spike is emitted and the potential is reset.

Dynamics

dV/dt = -(V - V_reset) / tau + I / c_m

If tau_ref is specified, a refractory period prevents spiking for tau_ref milliseconds after each spike.

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons (int or tuple of dimensions).

required
v_threshold float | Float[TensorLike, ' n_neuron']

Firing threshold (mV). Default: 1.0.

1.0
v_reset float | Float[TensorLike, ' n_neuron']

Reset voltage after spike (mV). Default: 0.0.

0.0
c_m float | Float[TensorLike, ' n_neuron']

Membrane capacitance. Default: 1.0.

1.0
tau float | Float[TensorLike, ' n_neuron']

Membrane time constant (ms). Default: 20.0.

20.0
tau_ref float | Float[TensorLike, ' n_neuron'] | None

Refractory period duration (ms). None disables refractory behavior. Default: None.

None
trainable_param set[str]

Set of parameter names to make trainable. Default: empty set.

set()
surrogate_function Callable

Surrogate gradient function for backpropagation. Default: Sigmoid().

Sigmoid()
detach_reset bool

If True, detach reset signal from computation graph. Default: False.

False
hard_reset bool

If True, reset to v_reset directly. If False, subtract (v_threshold - v_reset) from membrane potential (soft reset). Default: False.

False
pre_spike_v bool

If True, store pre-spike voltage in v_pre_spike buffer. Default: False.

False
step_mode Literal['s']

Step mode, currently only "s" (single step) supported. Default: "s".

's'
backend Literal['torch']

Backend implementation. Default: "torch".

'torch'
device device | str | None

Device for tensors. Default: None.

None
dtype dtype | None

Data type for tensors. Default: None.

None

Attributes:

Name Type Description
v Tensor

Membrane potential tensor, shape (*batch, n_neuron).

refractory Tensor | None

Refractory counter (if tau_ref specified).

c_m Tensor | Parameter

Membrane capacitance (parameter or buffer).

tau Tensor | Parameter

Time constant (parameter or buffer).

tau_ref Tensor | Parameter | None

Refractory period (parameter or buffer, or None).

Shape
  • Input: (*batch, n_neuron)
  • Output: (*batch, n_neuron) spike tensor (0 or 1)
Source code in btorch/models/neurons/lif.py
class LIF(BaseNode):
    """Leaky integrate-and-fire neuron with optional refractory period.

    The LIF neuron integrates input current while leaking towards a resting
    potential. When the membrane potential exceeds a threshold, a spike is
    emitted and the potential is reset.

    Dynamics:
        dV/dt = -(V - V_reset) / tau + I / c_m

        If tau_ref is specified, a refractory period prevents spiking for
        tau_ref milliseconds after each spike.

    Args:
        n_neuron: Number of neurons (int or tuple of dimensions).
        v_threshold: Firing threshold (mV). Default: 1.0.
        v_reset: Reset voltage after spike (mV). Default: 0.0.
        c_m: Membrane capacitance. Default: 1.0.
        tau: Membrane time constant (ms). Default: 20.0.
        tau_ref: Refractory period duration (ms). None disables refractory
            behavior. Default: None.
        trainable_param: Set of parameter names to make trainable.
            Default: empty set.
        surrogate_function: Surrogate gradient function for backpropagation.
            Default: Sigmoid().
        detach_reset: If True, detach reset signal from computation graph.
            Default: False.
        hard_reset: If True, reset to v_reset directly. If False, subtract
            (v_threshold - v_reset) from membrane potential (soft reset).
            Default: False.
        pre_spike_v: If True, store pre-spike voltage in v_pre_spike buffer.
            Default: False.
        step_mode: Step mode, currently only "s" (single step) supported.
            Default: "s".
        backend: Backend implementation. Default: "torch".
        device: Device for tensors. Default: None.
        dtype: Data type for tensors. Default: None.

    Attributes:
        v: Membrane potential tensor, shape (*batch, n_neuron).
        refractory: Refractory counter (if tau_ref specified).
        c_m: Membrane capacitance (parameter or buffer).
        tau: Time constant (parameter or buffer).
        tau_ref: Refractory period (parameter or buffer, or None).

    Shape:
        - Input: (*batch, n_neuron)
        - Output: (*batch, n_neuron) spike tensor (0 or 1)
    """

    refractory: torch.Tensor | None
    c_m: torch.Tensor | torch.nn.Parameter
    tau: torch.Tensor | torch.nn.Parameter
    tau_ref: torch.Tensor | torch.nn.Parameter | None

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        v_threshold: float | Float[TensorLike, " n_neuron"] = 1.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = 0.0,
        c_m: float | Float[TensorLike, " n_neuron"] = 1.0,
        tau: float | Float[TensorLike, " n_neuron"] = 20.0,
        tau_ref: float | Float[TensorLike, " n_neuron"] | None = None,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = Sigmoid(),
        detach_reset: bool = False,
        hard_reset: bool = False,
        pre_spike_v: bool = False,
        step_mode: Literal["s"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | str | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__(
            n_neuron=n_neuron,
            v_threshold=v_threshold,
            v_reset=v_reset,
            trainable_param=trainable_param,
            surrogate_function=surrogate_function,
            detach_reset=detach_reset,
            hard_reset=hard_reset,
            pre_spike_v=pre_spike_v,
            step_mode=step_mode,
            backend=backend,
            device=device,
            dtype=dtype,
        )
        _factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        self.def_param(
            "c_m",
            c_m,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self.def_param(
            "tau",
            tau,
            sizes=self.n_neuron,
            trainable_param=self.trainable_param,
            **_factory_kwargs,
        )
        self._use_refractory = tau_ref is not None
        if self._use_refractory:
            self.def_param(
                "tau_ref",
                tau_ref,
                sizes=self.n_neuron,
                trainable_param=self.trainable_param,
                **_factory_kwargs,
            )
            self.register_memory("refractory", 0.0, self.n_neuron)
        else:
            self.tau_ref = None

    def dV(
        self,
        v: Float[Tensor, "*batch n_neuron"],
        x: Float[Tensor, "*batch n_neuron"],
    ):
        derivative = -(v - self.v_reset) / self.tau + x / self.c_m
        return derivative

    def neuronal_charge(self, x: Float[Tensor, "*batch n_neuron"]):
        v = euler_step(self.dV, self.v, x, dt=environ.get("dt"))
        self.v = v

    def neuronal_adaptation(self):
        # LIF has no intrinsic adaptation other than the refractory counter.
        return None

    def neuronal_fire(self):
        spike = self.surrogate_function(
            (self.v - self.v_threshold) / (self.v_threshold - self.v_reset)
        )
        if not self._use_refractory:
            return spike
        not_in_refractory = self.refractory == 0
        spike = spike * not_in_refractory.detach().to(self.v.dtype)
        return spike

    def neuronal_reset(self, spike: Float[Tensor, "*batch n"]):
        if self.detach_reset:
            spike_d = spike.detach()
        else:
            spike_d = spike

        if self.pre_spike_v:
            self.v_pre_spike = self.v.clone()

        if self.hard_reset:
            self.v = self.v - (self.v - self.v_reset) * spike_d
        else:
            self.v = self.v - (self.v_threshold - self.v_reset) * spike_d

        if self._use_refractory:
            self.refractory = torch.relu(
                self.refractory + spike_d * self.tau_ref - environ.get("dt")
            )

    def extra_repr(self):
        parts = [
            f"c_m={self._format_repr_value(self.c_m)}",
            f"tau={self._format_repr_value(self.tau)}",
            f"tau_ref={self._format_repr_value(self.tau_ref)}"
            if self._use_refractory
            else "tau_ref=None",
        ]
        base = super().extra_repr()
        if base:
            parts.append(base)
        return ", ".join(parts)

Sigmoid

Bases: SurrogateFunctionBase

Logistic surrogate gradient.

Surrogate gradient: g(v) = 4·σ(k·alpha·v)·(1−σ(k·alpha·v)) where k = 2·ln(√2+1) ≈ 1.763.

alpha is the inverse half-width: HWHM = 1/alpha for any alpha. Peak at threshold is 1.0 when damping=1.

Source code in btorch/models/surrogate/sigmoid.py
class Sigmoid(SurrogateFunctionBase):
    """Logistic surrogate gradient.

    Surrogate gradient: ``g(v) = 4·σ(k·alpha·v)·(1−σ(k·alpha·v))``
    where ``k = 2·ln(√2+1) ≈ 1.763``.

    ``alpha`` is the inverse half-width: HWHM = 1/alpha for any alpha.
    Peak at threshold is 1.0 when damping=1.
    """

    def __init__(
        self, alpha: float = 2.0, damping_factor: float = 1.0, spiking: bool = True
    ):
        super().__init__(alpha=alpha, damping_factor=damping_factor, spiking=spiking)

    def primitive(self, x: torch.Tensor) -> torch.Tensor:
        return _sigmoid_primitive(x, self.alpha)

    def derivative(
        self,
        x: torch.Tensor,
        grad_output: torch.Tensor,
        damping_factor: float = 1.0,
    ) -> torch.Tensor:
        return _sigmoid_derivative(x, grad_output, self.alpha, damping_factor)

Functions

euler_step(f, *args, dt=1.0)

Source code in btorch/models/ode.py
def euler_step(f: Callable, *args, dt=1.0):
    derivative = _derivative_only(f(*args))
    return args[0] + dt * derivative

btorch.models.neurons.mixed

Heterogeneous neuron population that mixes multiple neuron models.

This module allows a single recurrent layer to contain different neuron types (e.g. GLIF3 and TwoCompartmentGLIF) by slicing the input current and dispatching to sub-populations.

Classes

MixedNeuronPopulation

Bases: Module

Mix multiple neuron populations into a single logical layer.

The input current tensor is sliced along the last (neuron) dimension and dispatched to each sub-population. Spikes from all groups are concatenated back together so the layer behaves like a single neuron module from the point of view of :class:~btorch.models.rnn.RecurrentNN.

Parameters:

Name Type Description Default
groups Sequence[tuple[int, Module]] | Mapping[str, tuple[int, Module]]

Either a sequence of (count, neuron_module) tuples, or a mapping of name -> (count, neuron_module). When a sequence is given, groups are auto-named group_0, group_1, etc.

required
step_mode str

"s" for single-step or "m" for multi-step dispatch. Default: "s".

's'

Raises:

Type Description
ValueError

If groups is empty or contains a non-positive count.

Examples:

>>> from btorch.models.neurons import GLIF3, TwoCompartmentGLIF
>>> glif = GLIF3(n_neuron=60)
>>> tc = TwoCompartmentGLIF(n_neuron=40)
>>> mixed = MixedNeuronPopulation([(60, glif), (40, tc)])
Source code in btorch/models/neurons/mixed.py
class MixedNeuronPopulation(nn.Module):
    """Mix multiple neuron populations into a single logical layer.

    The input current tensor is sliced along the last (neuron) dimension and
    dispatched to each sub-population.  Spikes from all groups are
    concatenated back together so the layer behaves like a single neuron
    module from the point of view of :class:`~btorch.models.rnn.RecurrentNN`.

    Args:
        groups: Either a sequence of ``(count, neuron_module)`` tuples, or a
            mapping of ``name -> (count, neuron_module)``.  When a sequence
            is given, groups are auto-named ``group_0``, ``group_1``, etc.
        step_mode: ``"s"`` for single-step or ``"m"`` for multi-step
            dispatch.  Default: ``"s"``.

    Raises:
        ValueError: If ``groups`` is empty or contains a non-positive count.

    Examples:
        >>> from btorch.models.neurons import GLIF3, TwoCompartmentGLIF
        >>> glif = GLIF3(n_neuron=60)
        >>> tc = TwoCompartmentGLIF(n_neuron=40)
        >>> mixed = MixedNeuronPopulation([(60, glif), (40, tc)])
    """

    def __init__(
        self,
        groups: Sequence[tuple[int, nn.Module]] | Mapping[str, tuple[int, nn.Module]],
        step_mode: str = "s",
    ):
        super().__init__()
        if not groups:
            raise ValueError("groups must contain at least one population.")

        # Normalise to a list of (name, count, neuron).
        if isinstance(groups, Mapping):
            items = [(name, count, neuron) for name, (count, neuron) in groups.items()]
        else:
            items = [
                (f"group_{i}", count, neuron)
                for i, (count, neuron) in enumerate(groups)
            ]

        counts: list[int] = []
        total = 0
        group_names: list[str] = []
        for name, count, neuron in items:
            if count <= 0:
                raise ValueError(f"Group {name!r} count must be positive, got {count}.")
            self.add_module(name, neuron)
            group_names.append(name)
            counts.append(count)
            if hasattr(neuron, "size"):
                total += int(neuron.size)
            else:
                total += count

        self.counts = counts
        self._group_names = group_names
        self._cumsum = [0] + torch.cumsum(torch.tensor(counts), dim=0).tolist()
        self.n_neuron = (total,)
        self.size = total
        self.step_mode = step_mode

    def _indices(self, idx: int) -> torch.Tensor:
        """Return global neuron indices for group *idx* (contiguous block)."""
        start, end = self._cumsum[idx], self._cumsum[idx + 1]
        return torch.arange(start, end, dtype=torch.long)

    def _concat_attr(self, attr: str) -> Tensor:
        """Concatenate ``attr`` from all sub-populations along neuron dim.

        Scalar attributes are broadcast to the group's neuron count
        before concatenation.
        """
        parts: list[Tensor] = []
        for idx, name in enumerate(self._group_names):
            neuron = getattr(self, name)
            val = getattr(neuron, attr, None)
            if val is None:
                raise AttributeError(
                    f"{neuron.__class__.__name__} has no attribute {attr!r}"
                )
            if isinstance(val, nn.Parameter):
                val = val.data
            # TODO: ParamBufferMixin conflates uniform (scalar) and heterogeneous
            #   (per-neuron tensor) params. When a param is trainable it becomes an
            #   nn.Parameter and may be scalar or per-neuron depending on how it was
            #   initialised, so broadcasting here is fragile. ParamBufferMixin should
            #   be extended to always expose a per-neuron view, making this broadcast
            #   unnecessary and removing the ambiguity for downstream consumers.
            if val.dim() == 0:
                count = self._cumsum[idx + 1] - self._cumsum[idx]
                val = val.expand(count)
            parts.append(val)
        return torch.cat(parts, dim=-1)

    @property
    def v_threshold(self) -> Tensor:
        return self._concat_attr("v_threshold")

    @property
    def v_reset(self) -> Tensor:
        return self._concat_attr("v_reset")

    def _slice(self, x: Tensor, idx: int) -> Tensor:
        """Slice ``x`` along the last dimension for group *idx*."""
        start, end = self._cumsum[idx], self._cumsum[idx + 1]
        return x[..., start:end]

    def single_step_forward(
        self,
        x: Tensor,
        i_apical: Tensor | None = None,
    ) -> Tensor:
        """Advance all sub-populations by one timestep.

        Args:
            x: Input current of shape ``(*batch, n_neuron)``.
            i_apical: Optional apical current of the same shape.  Only slices
                corresponding to :class:`TwoCompartmentGLIF` groups are used.

        Returns:
            Spike tensor of shape ``(*batch, n_neuron)``.
        """
        spikes: list[Tensor] = []
        for idx, name in enumerate(self._group_names):
            neuron = getattr(self, name)
            soma = self._slice(x, idx)
            out: Tensor | tuple[Tensor, ...]

            if isinstance(neuron, TwoCompartmentGLIF):
                apical = self._slice(i_apical, idx) if i_apical is not None else None
                out = neuron(soma, apical)
            else:
                out = neuron(soma)

            # Normalise return value to spikes only.
            spikes.append(out[0] if isinstance(out, tuple) else out)

        return torch.cat(spikes, dim=-1)

    def multi_step_forward(
        self,
        x: Tensor,
        i_apical: Tensor | None = None,
    ) -> Tensor:
        """Advance all sub-populations over a full time sequence.

        Args:
            x: Input sequence of shape ``(T, *batch, n_neuron)``.
            i_apical: Optional apical sequence of the same shape.

        Returns:
            Spike sequence of shape ``(T, *batch, n_neuron)``.
        """
        spike_seq: list[Tensor] = []
        T = x.shape[0]
        for t in range(T):
            step_apical = None if i_apical is None else i_apical[t]
            spike_seq.append(self.single_step_forward(x[t], step_apical))
        return torch.stack(spike_seq, dim=0)

    def forward(
        self,
        x: Tensor,
        i_apical: Tensor | None = None,
    ) -> Tensor:
        """Dispatch to single-step or multi-step forward."""
        if self.step_mode == "m":
            return self.multi_step_forward(x, i_apical)
        return self.single_step_forward(x, i_apical)

    def extra_repr(self) -> str:
        parts = [f"n_neuron={self.n_neuron}"]
        for i, (name, neuron) in enumerate(self.named_children()):
            parts.append(f"{name}={neuron.__class__.__name__}(n={self.counts[i]})")
        return ", ".join(parts)
Functions
forward(x, i_apical=None)

Dispatch to single-step or multi-step forward.

Source code in btorch/models/neurons/mixed.py
def forward(
    self,
    x: Tensor,
    i_apical: Tensor | None = None,
) -> Tensor:
    """Dispatch to single-step or multi-step forward."""
    if self.step_mode == "m":
        return self.multi_step_forward(x, i_apical)
    return self.single_step_forward(x, i_apical)
multi_step_forward(x, i_apical=None)

Advance all sub-populations over a full time sequence.

Parameters:

Name Type Description Default
x Tensor

Input sequence of shape (T, *batch, n_neuron).

required
i_apical Tensor | None

Optional apical sequence of the same shape.

None

Returns:

Type Description
Tensor

Spike sequence of shape (T, *batch, n_neuron).

Source code in btorch/models/neurons/mixed.py
def multi_step_forward(
    self,
    x: Tensor,
    i_apical: Tensor | None = None,
) -> Tensor:
    """Advance all sub-populations over a full time sequence.

    Args:
        x: Input sequence of shape ``(T, *batch, n_neuron)``.
        i_apical: Optional apical sequence of the same shape.

    Returns:
        Spike sequence of shape ``(T, *batch, n_neuron)``.
    """
    spike_seq: list[Tensor] = []
    T = x.shape[0]
    for t in range(T):
        step_apical = None if i_apical is None else i_apical[t]
        spike_seq.append(self.single_step_forward(x[t], step_apical))
    return torch.stack(spike_seq, dim=0)
single_step_forward(x, i_apical=None)

Advance all sub-populations by one timestep.

Parameters:

Name Type Description Default
x Tensor

Input current of shape (*batch, n_neuron).

required
i_apical Tensor | None

Optional apical current of the same shape. Only slices corresponding to :class:TwoCompartmentGLIF groups are used.

None

Returns:

Type Description
Tensor

Spike tensor of shape (*batch, n_neuron).

Source code in btorch/models/neurons/mixed.py
def single_step_forward(
    self,
    x: Tensor,
    i_apical: Tensor | None = None,
) -> Tensor:
    """Advance all sub-populations by one timestep.

    Args:
        x: Input current of shape ``(*batch, n_neuron)``.
        i_apical: Optional apical current of the same shape.  Only slices
            corresponding to :class:`TwoCompartmentGLIF` groups are used.

    Returns:
        Spike tensor of shape ``(*batch, n_neuron)``.
    """
    spikes: list[Tensor] = []
    for idx, name in enumerate(self._group_names):
        neuron = getattr(self, name)
        soma = self._slice(x, idx)
        out: Tensor | tuple[Tensor, ...]

        if isinstance(neuron, TwoCompartmentGLIF):
            apical = self._slice(i_apical, idx) if i_apical is not None else None
            out = neuron(soma, apical)
        else:
            out = neuron(soma)

        # Normalise return value to spikes only.
        spikes.append(out[0] if isinstance(out, tuple) else out)

    return torch.cat(spikes, dim=-1)

TwoCompartmentGLIF

Bases: ParamBufferMixin, MemoryModule

Two-compartment current-based neuron with apical plateaus.

The model combines a fast somatic compartment with a slow apical current:

.. math:: \tau_s \frac{dV_s}{dt} = -(V_s - E_L) + R_s \left(I_{soma} + w_{as} I_a\right) + \Delta_T \exp\left(\frac{V_s - V_{th,eff}}{\Delta_T}\right)

.. math:: \tau_a \frac{dI_a}{dt} = -I_a + I_{apical} + w_{Ca} \sigma(I_a - \theta_{Ca}) + I_{bap}

Somatic spikes are generated by a surrogate threshold on V_s and stored as a pending back-propagating current for the next timestep.

A minimal adaptive threshold state can optionally be enabled:

.. math:: V_{th,eff} = V_{th} + \theta_h

.. math:: \tau_h \frac{d\theta_h}{dt} = -\theta_h

followed by an instantaneous post-spike increment :math:\theta_h \leftarrow \theta_h + \Delta_h.

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons, as an int or tuple of neuron axes.

required
v_threshold float | Float[TensorLike, ' n_neuron']

Somatic spike threshold.

-50.0
v_reset float | Float[TensorLike, ' n_neuron']

Somatic reset voltage after a spike.

-65.0
tau_s float | Float[TensorLike, ' n_neuron']

Somatic membrane time constant.

20.0
R_s float | Float[TensorLike, ' n_neuron']

Somatic input resistance.

1.0
E_L float | Float[TensorLike, ' n_neuron']

Somatic leak reversal / resting voltage.

-70.0
tau_a float | Float[TensorLike, ' n_neuron']

Apical current time constant.

100.0
tau_th float | Float[TensorLike, ' n_neuron']

Adaptive-threshold decay time constant.

50.0
delta_th float | Float[TensorLike, ' n_neuron']

Post-spike threshold increment.

0.0
delta_T float | Float[TensorLike, ' n_neuron']

Exponential spike-initiation scale. Set to zero to disable.

0.0
w_Ca float | Float[TensorLike, ' n_neuron']

Calcium plateau amplitude.

0.0
theta_Ca float | Float[TensorLike, ' n_neuron']

Apical plateau activation threshold.

1.0
w_sa float | Float[TensorLike, ' n_neuron']

Somatic-to-apical coupling weight for the delayed bAP current.

0.5
w_as float | Float[TensorLike, ' n_neuron']

Apical-to-somatic coupling weight for top-down drive.

1.0
trainable_param set[str]

Names of parameters to optimize.

set()
surrogate_function Callable

Surrogate gradient function for soma spikes.

ATan()
detach_reset bool

If True, detach the reset signal from the graph.

False
step_mode Literal['s', 'm']

"s" for single-step or "m" for multi-step dispatch.

's'
backend Literal['torch']

Backend label for consistency with other neuron modules.

'torch'
device device | None

Optional torch device.

None
dtype dtype | None

Optional torch dtype.

None

Attributes:

Name Type Description
v Tensor

Somatic membrane voltage memory.

i_a Tensor

Slow apical current memory.

i_bap Tensor

Pending back-propagating current injected on the next step.

theta_th Tensor

Adaptive threshold memory added to v_threshold.

Examples:

>>> from btorch.models import environ, functional
>>> neuron = TwoCompartmentGLIF(n_neuron=1)
>>> functional.init_net_state(neuron, batch_size=2)
>>> with environ.context(dt=1.0):
...     spike, voltage = neuron.single_step_forward(
...         torch.zeros(2, 1),
...         torch.zeros(2, 1),
...     )
>>> tuple(spike.shape), tuple(voltage.shape)
((2, 1), (2, 1))
Notes

The integration uses a semi-implicit Euler update: the leak / decay term is treated implicitly while external and coupling currents are evaluated from the previous state. This keeps the update stable while preserving a simple differentiable recurrence.

Source code in btorch/models/neurons/two_compartment.py
class TwoCompartmentGLIF(ParamBufferMixin, MemoryModule):
    """Two-compartment current-based neuron with apical plateaus.

    The model combines a fast somatic compartment with a slow apical current:

    .. math::
        \\tau_s \\frac{dV_s}{dt} =
        -(V_s - E_L) + R_s \\left(I_{soma} + w_{as} I_a\\right) +
        \\Delta_T \\exp\\left(\\frac{V_s - V_{th,eff}}{\\Delta_T}\\right)

    .. math::
        \\tau_a \\frac{dI_a}{dt} =
        -I_a + I_{apical} +
        w_{Ca} \\sigma(I_a - \\theta_{Ca}) + I_{bap}

    Somatic spikes are generated by a surrogate threshold on ``V_s`` and stored
    as a pending back-propagating current for the next timestep.

    A minimal adaptive threshold state can optionally be enabled:

    .. math::
        V_{th,eff} = V_{th} + \\theta_h

    .. math::
        \\tau_h \\frac{d\\theta_h}{dt} = -\\theta_h

    followed by an instantaneous post-spike increment
    :math:`\\theta_h \\leftarrow \\theta_h + \\Delta_h`.

    Args:
        n_neuron: Number of neurons, as an ``int`` or tuple of neuron axes.
        v_threshold: Somatic spike threshold.
        v_reset: Somatic reset voltage after a spike.
        tau_s: Somatic membrane time constant.
        R_s: Somatic input resistance.
        E_L: Somatic leak reversal / resting voltage.
        tau_a: Apical current time constant.
        tau_th: Adaptive-threshold decay time constant.
        delta_th: Post-spike threshold increment.
        delta_T: Exponential spike-initiation scale. Set to zero to disable.
        w_Ca: Calcium plateau amplitude.
        theta_Ca: Apical plateau activation threshold.
        w_sa: Somatic-to-apical coupling weight for the delayed bAP current.
        w_as: Apical-to-somatic coupling weight for top-down drive.
        trainable_param: Names of parameters to optimize.
        surrogate_function: Surrogate gradient function for soma spikes.
        detach_reset: If ``True``, detach the reset signal from the graph.
        step_mode: ``"s"`` for single-step or ``"m"`` for multi-step dispatch.
        backend: Backend label for consistency with other neuron modules.
        device: Optional torch device.
        dtype: Optional torch dtype.

    Attributes:
        v: Somatic membrane voltage memory.
        i_a: Slow apical current memory.
        i_bap: Pending back-propagating current injected on the next step.
        theta_th: Adaptive threshold memory added to ``v_threshold``.

    Examples:
        >>> from btorch.models import environ, functional
        >>> neuron = TwoCompartmentGLIF(n_neuron=1)
        >>> functional.init_net_state(neuron, batch_size=2)
        >>> with environ.context(dt=1.0):
        ...     spike, voltage = neuron.single_step_forward(
        ...         torch.zeros(2, 1),
        ...         torch.zeros(2, 1),
        ...     )
        >>> tuple(spike.shape), tuple(voltage.shape)
        ((2, 1), (2, 1))

    Notes:
        The integration uses a semi-implicit Euler update: the leak / decay
        term is treated implicitly while external and coupling currents are
        evaluated from the previous state. This keeps the update stable while
        preserving a simple differentiable recurrence.
    """

    v: torch.Tensor
    i_a: torch.Tensor
    i_bap: torch.Tensor
    theta_th: torch.Tensor

    v_threshold: torch.Tensor | torch.nn.Parameter
    v_reset: torch.Tensor | torch.nn.Parameter
    tau_s: torch.Tensor | torch.nn.Parameter
    R_s: torch.Tensor | torch.nn.Parameter
    E_L: torch.Tensor | torch.nn.Parameter
    tau_a: torch.Tensor | torch.nn.Parameter
    tau_th: torch.Tensor | torch.nn.Parameter
    delta_th: torch.Tensor | torch.nn.Parameter
    delta_T: torch.Tensor | torch.nn.Parameter
    w_Ca: torch.Tensor | torch.nn.Parameter
    theta_Ca: torch.Tensor | torch.nn.Parameter
    w_sa: torch.Tensor | torch.nn.Parameter
    w_as: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        *,
        v_threshold: float | Float[TensorLike, " n_neuron"] = -50.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = -65.0,
        tau_s: float | Float[TensorLike, " n_neuron"] = 20.0,
        R_s: float | Float[TensorLike, " n_neuron"] = 1.0,
        E_L: float | Float[TensorLike, " n_neuron"] = -70.0,
        tau_a: float | Float[TensorLike, " n_neuron"] = 100.0,
        tau_th: float | Float[TensorLike, " n_neuron"] = 50.0,
        delta_th: float | Float[TensorLike, " n_neuron"] = 0.0,
        delta_T: float | Float[TensorLike, " n_neuron"] = 0.0,
        w_Ca: float | Float[TensorLike, " n_neuron"] = 0.0,
        theta_Ca: float | Float[TensorLike, " n_neuron"] = 1.0,
        w_sa: float | Float[TensorLike, " n_neuron"] = 0.5,
        w_as: float | Float[TensorLike, " n_neuron"] = 1.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = ATan(),
        detach_reset: bool = False,
        step_mode: Literal["s", "m"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__()
        self.n_neuron, self.size = normalize_n_neuron(n_neuron)
        self.trainable_param = set(trainable_param)
        self.surrogate_function = surrogate_function
        self.detach_reset = detach_reset
        self.step_mode = step_mode
        self.backend = backend

        self.register_memory("v", v_reset, self.n_neuron)
        self.register_memory("i_a", 0.0, self.n_neuron)
        self.register_memory("i_bap", 0.0, self.n_neuron)
        self.register_memory("theta_th", 0.0, self.n_neuron)

        factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        for name, value in (
            ("v_threshold", v_threshold),
            ("v_reset", v_reset),
            ("tau_s", tau_s),
            ("R_s", R_s),
            ("E_L", E_L),
            ("tau_a", tau_a),
            ("tau_th", tau_th),
            ("delta_th", delta_th),
            ("delta_T", delta_T),
            ("w_Ca", w_Ca),
            ("theta_Ca", theta_Ca),
            ("w_sa", w_sa),
            ("w_as", w_as),
        ):
            self.def_param(
                name,
                value,
                sizes=self.n_neuron,
                trainable_param=self.trainable_param,
                **factory_kwargs,
            )

    def _prepare_step_tensor(
        self,
        x: float | Tensor,
        *,
        name: str,
    ) -> Float[Tensor, "*batch n_neuron"]:
        target = self.v
        x = torch.as_tensor(x, device=target.device, dtype=target.dtype)
        if x.shape == target.shape:
            return x
        if x.numel() == 1:
            return torch.full_like(target, x.reshape(()))
        if is_broadcastable(x.shape, target.shape):
            return torch.broadcast_to(x, target.shape)
        raise RuntimeError(
            f"{name} shape mismatch. Expected a tensor broadcastable to "
            f"{tuple(target.shape)}, got {tuple(x.shape)}."
        )

    @staticmethod
    def _positive(x: Tensor, eps: float = 1e-4) -> Tensor:
        return torch.clamp(x, min=eps)

    def _plateau_current(self) -> Float[Tensor, "*batch n_neuron"]:
        return self.w_Ca * torch.sigmoid(self.i_a - self.theta_Ca)

    def _update_apical_current(
        self,
        i_apical: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_a = self._positive(self.tau_a)
        drive = i_apical + self._plateau_current() + self.i_bap
        decay = dt / tau_a
        return (self.i_a + decay * drive) / (1.0 + decay)

    def _update_somatic_voltage(
        self,
        i_soma: Float[Tensor, "*batch n_neuron"],
        i_a_next: Float[Tensor, "*batch n_neuron"],
        threshold_eff: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_s = self._positive(self.tau_s)
        R_s = self._positive(self.R_s)
        total_current = i_soma + self.w_as * i_a_next
        delta_T = torch.clamp(self.delta_T, min=0.0)
        exp_arg = torch.clamp(
            (self.v - threshold_eff) / torch.clamp(delta_T, min=1e-4),
            max=10.0,
        )
        exp_drive = torch.where(
            delta_T > 0.0,
            delta_T * torch.exp(exp_arg),
            torch.zeros_like(self.v),
        )
        decay = dt / tau_s
        numerator = self.v + decay * (self.E_L + R_s * total_current + exp_drive)
        return numerator / (1.0 + decay)

    def _reset_voltage(
        self,
        v_pre_spike: Float[Tensor, "*batch n_neuron"],
        spike: Float[Tensor, "*batch n_neuron"],
    ) -> Float[Tensor, "*batch n_neuron"]:
        spike_d = spike.detach() if self.detach_reset else spike
        return v_pre_spike - (v_pre_spike - self.v_reset) * spike_d

    def _update_threshold_state(
        self,
        spike: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_th = self._positive(self.tau_th)
        decay = dt / tau_th
        spike_d = spike.detach() if self.detach_reset else spike
        return self.theta_th / (1.0 + decay) + self.delta_th * spike_d

    def single_step_forward(
        self,
        i_soma: Float[Tensor | float, "*batch n_neuron"],
        i_apical: Float[Tensor | float, "*batch n_neuron"] | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Advance the neuron by one timestep.

        Args:
            i_soma: Somatic current input.
            i_apical: Optional apical current input. Defaults to zero.
            return_state: If ``True``, also return a state dictionary.

        Returns:
            A tuple ``(spike, voltage)``. When ``return_state=True``, returns
            ``(spike, voltage, state_dict)`` with ``v_pre_spike``, ``i_a``, and
            ``i_bap`` traces for the current step.
        """
        dt = float(environ.get("dt"))
        soma_drive = self._prepare_step_tensor(i_soma, name="i_soma")
        apical_drive = (
            torch.zeros_like(soma_drive)
            if i_apical is None
            else self._prepare_step_tensor(i_apical, name="i_apical")
        )

        i_a_next = self._update_apical_current(apical_drive, dt)
        threshold_eff = self.v_threshold + self.theta_th
        v_pre_spike = self._update_somatic_voltage(
            soma_drive,
            i_a_next,
            threshold_eff,
            dt,
        )
        spike = self.surrogate_function(v_pre_spike - threshold_eff)

        self.i_a = i_a_next
        self.v = self._reset_voltage(v_pre_spike, spike)
        self.i_bap = self.w_sa * spike
        self.theta_th = self._update_threshold_state(spike, dt)

        if not return_state:
            return spike, self.v

        return (
            spike,
            self.v,
            {
                "v": self.v,
                "v_pre_spike": v_pre_spike,
                "i_a": self.i_a,
                "i_bap": self.i_bap,
                "theta_th": self.theta_th,
                "v_threshold_eff": threshold_eff,
            },
        )

    def multi_step_forward(
        self,
        i_soma_seq: Float[Tensor, "T *batch n_neuron"],
        i_apical_seq: Float[Tensor, "T *batch n_neuron"] | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Advance the neuron over a full input sequence."""
        spike_seq = []
        v_seq = []
        v_pre_seq = []
        i_a_seq = []
        i_bap_seq = []
        theta_th_seq = []
        threshold_seq = []

        for t in range(i_soma_seq.shape[0]):
            step_apical = None if i_apical_seq is None else i_apical_seq[t]
            step_out = self.single_step_forward(
                i_soma_seq[t],
                step_apical,
                return_state=return_state,
            )
            if return_state:
                spike_t, v_t, state_t = step_out
                v_pre_seq.append(state_t["v_pre_spike"])
                i_a_seq.append(state_t["i_a"])
                i_bap_seq.append(state_t["i_bap"])
                theta_th_seq.append(state_t["theta_th"])
                threshold_seq.append(state_t["v_threshold_eff"])
            else:
                spike_t, v_t = step_out
            spike_seq.append(spike_t)
            v_seq.append(v_t)

        spike_seq_t = torch.stack(spike_seq, dim=0)
        v_seq_t = torch.stack(v_seq, dim=0)
        if not return_state:
            return spike_seq_t, v_seq_t

        return (
            spike_seq_t,
            v_seq_t,
            {
                "v": v_seq_t,
                "v_pre_spike": torch.stack(v_pre_seq, dim=0),
                "i_a": torch.stack(i_a_seq, dim=0),
                "i_bap": torch.stack(i_bap_seq, dim=0),
                "theta_th": torch.stack(theta_th_seq, dim=0),
                "v_threshold_eff": torch.stack(threshold_seq, dim=0),
            },
        )

    def forward(
        self,
        i_soma: Tensor,
        i_apical: Tensor | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Dispatch to single-step or multi-step forward."""
        if self.step_mode == "m":
            return self.multi_step_forward(
                i_soma,
                i_apical,
                return_state=return_state,
            )
        return self.single_step_forward(
            i_soma,
            i_apical,
            return_state=return_state,
        )

    def extra_repr(self) -> str:
        parts = [
            f"n_neuron={self.n_neuron}",
            f"step_mode={self.step_mode}",
            f"backend={self.backend}",
            f"surrogate={self.surrogate_function.__class__.__name__}",
        ]
        mem_repr = MemoryModule.extra_repr(self)
        if mem_repr:
            parts.append(mem_repr)
        return ", ".join(parts)
Functions
forward(i_soma, i_apical=None, *, return_state=False)

Dispatch to single-step or multi-step forward.

Source code in btorch/models/neurons/two_compartment.py
def forward(
    self,
    i_soma: Tensor,
    i_apical: Tensor | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Dispatch to single-step or multi-step forward."""
    if self.step_mode == "m":
        return self.multi_step_forward(
            i_soma,
            i_apical,
            return_state=return_state,
        )
    return self.single_step_forward(
        i_soma,
        i_apical,
        return_state=return_state,
    )
multi_step_forward(i_soma_seq, i_apical_seq=None, *, return_state=False)

Advance the neuron over a full input sequence.

Source code in btorch/models/neurons/two_compartment.py
def multi_step_forward(
    self,
    i_soma_seq: Float[Tensor, "T *batch n_neuron"],
    i_apical_seq: Float[Tensor, "T *batch n_neuron"] | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Advance the neuron over a full input sequence."""
    spike_seq = []
    v_seq = []
    v_pre_seq = []
    i_a_seq = []
    i_bap_seq = []
    theta_th_seq = []
    threshold_seq = []

    for t in range(i_soma_seq.shape[0]):
        step_apical = None if i_apical_seq is None else i_apical_seq[t]
        step_out = self.single_step_forward(
            i_soma_seq[t],
            step_apical,
            return_state=return_state,
        )
        if return_state:
            spike_t, v_t, state_t = step_out
            v_pre_seq.append(state_t["v_pre_spike"])
            i_a_seq.append(state_t["i_a"])
            i_bap_seq.append(state_t["i_bap"])
            theta_th_seq.append(state_t["theta_th"])
            threshold_seq.append(state_t["v_threshold_eff"])
        else:
            spike_t, v_t = step_out
        spike_seq.append(spike_t)
        v_seq.append(v_t)

    spike_seq_t = torch.stack(spike_seq, dim=0)
    v_seq_t = torch.stack(v_seq, dim=0)
    if not return_state:
        return spike_seq_t, v_seq_t

    return (
        spike_seq_t,
        v_seq_t,
        {
            "v": v_seq_t,
            "v_pre_spike": torch.stack(v_pre_seq, dim=0),
            "i_a": torch.stack(i_a_seq, dim=0),
            "i_bap": torch.stack(i_bap_seq, dim=0),
            "theta_th": torch.stack(theta_th_seq, dim=0),
            "v_threshold_eff": torch.stack(threshold_seq, dim=0),
        },
    )
single_step_forward(i_soma, i_apical=None, *, return_state=False)

Advance the neuron by one timestep.

Parameters:

Name Type Description Default
i_soma Float[Tensor | float, '*batch n_neuron']

Somatic current input.

required
i_apical Float[Tensor | float, '*batch n_neuron'] | None

Optional apical current input. Defaults to zero.

None
return_state bool

If True, also return a state dictionary.

False

Returns:

Type Description
tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

A tuple (spike, voltage). When return_state=True, returns

tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

(spike, voltage, state_dict) with v_pre_spike, i_a, and

tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

i_bap traces for the current step.

Source code in btorch/models/neurons/two_compartment.py
def single_step_forward(
    self,
    i_soma: Float[Tensor | float, "*batch n_neuron"],
    i_apical: Float[Tensor | float, "*batch n_neuron"] | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Advance the neuron by one timestep.

    Args:
        i_soma: Somatic current input.
        i_apical: Optional apical current input. Defaults to zero.
        return_state: If ``True``, also return a state dictionary.

    Returns:
        A tuple ``(spike, voltage)``. When ``return_state=True``, returns
        ``(spike, voltage, state_dict)`` with ``v_pre_spike``, ``i_a``, and
        ``i_bap`` traces for the current step.
    """
    dt = float(environ.get("dt"))
    soma_drive = self._prepare_step_tensor(i_soma, name="i_soma")
    apical_drive = (
        torch.zeros_like(soma_drive)
        if i_apical is None
        else self._prepare_step_tensor(i_apical, name="i_apical")
    )

    i_a_next = self._update_apical_current(apical_drive, dt)
    threshold_eff = self.v_threshold + self.theta_th
    v_pre_spike = self._update_somatic_voltage(
        soma_drive,
        i_a_next,
        threshold_eff,
        dt,
    )
    spike = self.surrogate_function(v_pre_spike - threshold_eff)

    self.i_a = i_a_next
    self.v = self._reset_voltage(v_pre_spike, spike)
    self.i_bap = self.w_sa * spike
    self.theta_th = self._update_threshold_state(spike, dt)

    if not return_state:
        return spike, self.v

    return (
        spike,
        self.v,
        {
            "v": self.v,
            "v_pre_spike": v_pre_spike,
            "i_a": self.i_a,
            "i_bap": self.i_bap,
            "theta_th": self.theta_th,
            "v_threshold_eff": threshold_eff,
        },
    )

btorch.models.neurons.two_compartment

Two-compartment current-based spiking neurons.

This module implements a compact soma-apical neuron with:

  • a fast somatic GLIF-like membrane state,
  • a slow apical current state with a nonlinear plateau,
  • bidirectional soma-apical coupling via scalar currents.

The implementation follows btorch's stateful-module conventions so that the membrane voltage and apical state can be initialized, reset, detached, and tracked during truncated BPTT.

Attributes

TensorLike = np.ndarray | torch.Tensor module-attribute

__all__ = ['TwoCompartmentGLIF'] module-attribute

Classes

ATan

Bases: SurrogateFunctionBase

Arctangent (Cauchy) surrogate gradient.

Surrogate gradient: g(v) = 1 / (1 + (alpha·v)²)

alpha is the inverse half-width: HWHM = 1/alpha for any alpha. Peak at threshold is 1.0 when damping=1.

Source code in btorch/models/surrogate/atan.py
class ATan(SurrogateFunctionBase):
    """Arctangent (Cauchy) surrogate gradient.

    Surrogate gradient: ``g(v) = 1 / (1 + (alpha·v)²)``

    ``alpha`` is the inverse half-width: HWHM = 1/alpha for any alpha.
    Peak at threshold is 1.0 when damping=1.
    """

    def __init__(
        self, alpha: float = 2.0, damping_factor: float = 1.0, spiking: bool = True
    ):
        super().__init__(alpha=alpha, damping_factor=damping_factor, spiking=spiking)

    def primitive(self, x: torch.Tensor) -> torch.Tensor:
        return _atan_primitive(x, self.alpha)

    def derivative(
        self,
        x: torch.Tensor,
        grad_output: torch.Tensor,
        damping_factor: float = 1.0,
    ) -> torch.Tensor:
        return _atan_derivative(x, grad_output, self.alpha, damping_factor)

MemoryModule

Bases: StepModule, Module

Base class for all stateful modules with managed memory buffers.

MemoryModule provides infrastructure for managing stateful tensors (memories) in neuromorphic models. Unlike SpikingJelly's MemoryModule, this implementation:

  1. Stores all memories as torch.Tensor buffers (enables ONNX export)
  2. Does not support list/tuple memories (override reset/init for history)
  3. Uses fixed memory sizes, with variable batch size

Memories are registered via register_memory() and automatically initialized/reset via init_state() and reset(). Each memory has a ResetValue configuration controlling its initialization behavior.

Example

class MyNeuron(MemoryModule): ... def init(self, n_neuron): ... super().init() ... self.register_memory("v", 0.0, n_neuron) ... ... def forward(self, x): ... self.v = self.v + x # simple integration ... return self.v

neuron = MyNeuron(10) neuron.init_state(batch_size=2) # init with batch dim out = neuron(torch.randn(2, 10))

Source code in btorch/models/base.py
class MemoryModule(StepModule, torch.nn.Module):
    """Base class for all stateful modules with managed memory buffers.

    MemoryModule provides infrastructure for managing stateful tensors
    (memories) in neuromorphic models. Unlike SpikingJelly's MemoryModule,
    this implementation:

    1. Stores all memories as torch.Tensor buffers (enables ONNX export)
    2. Does not support list/tuple memories (override reset/init for history)
    3. Uses fixed memory sizes, with variable batch size

    Memories are registered via register_memory() and automatically
    initialized/reset via init_state() and reset(). Each memory has a
    ResetValue configuration controlling its initialization behavior.

    Example:
        >>> class MyNeuron(MemoryModule):
        ...     def __init__(self, n_neuron):
        ...         super().__init__()
        ...         self.register_memory("v", 0.0, n_neuron)
        ...
        ...     def forward(self, x):
        ...         self.v = self.v + x  # simple integration
        ...         return self.v
        >>>
        >>> neuron = MyNeuron(10)
        >>> neuron.init_state(batch_size=2)  # init with batch dim
        >>> out = neuron(torch.randn(2, 10))
    """

    @property
    def supported_backends(self) -> tuple[str, ...]:
        return ("torch",)

    @property
    def backend(self) -> str:
        return self._backend

    @backend.setter
    def backend(self, value: str):
        if value not in self.supported_backends:
            raise NotImplementedError(
                f"{value} is not a supported backend of {self._get_name()}!"
            )
        self._backend = value

    @abstractmethod
    def single_step_forward(self, x: torch.Tensor, *args, **kwargs):
        pass

    def multi_step_forward(self, x_seq: torch.Tensor, *args, **kwargs):
        T = x_seq.shape[0]
        y_seq = []
        for t in range(T):
            y = self.single_step_forward(x_seq[t], *args, **kwargs)
            y_seq.append(y.unsqueeze(0))
        return torch.cat(y_seq, 0)

    def __init__(self):
        super().__init__()
        self._memories_rv: dict[str, ResetValue] = {}
        self._backend = "torch"
        self._step_mode = "s"

    @staticmethod
    def _format_repr_value(value: Any) -> str:
        if value is None:
            return "None"
        if isinstance(value, Callable):
            return "callable"
        if isinstance(value, torch.Tensor):
            if value.numel() == 1:
                return f"{value.item():.4g}"
            return f"shape={tuple(value.shape)}"
        if isinstance(value, np.ndarray):
            if value.size == 1:
                return f"{float(value.reshape(-1)[0]):.4g}"
            return f"shape={value.shape}"
        if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
            array = np.asarray(value)
            if array.size == 1:
                return f"{float(array.reshape(-1)[0]):.4g}"
            return f"shape={array.shape}"
        return str(value)

    def _memories_repr(self) -> str:
        if not self._memories_rv:
            return ""
        entries = []
        for name, reset_val in self._memories_rv.items():
            extra = []
            if reset_val.dtype is not None:
                extra.append(str(reset_val.dtype).replace("torch.", ""))
            if reset_val.persistent is not None:
                extra.append("persistent" if reset_val.persistent else "non_persistent")
            if reset_val.has_batch:
                extra.append("has_batch")
            extra_str = f" [{', '.join(extra)}]" if extra else ""
            value_str = ""
            if reset_val.value is not None:
                value_str = f" init={self._format_repr_value(reset_val.value)}"
            entries.append(f"{name}={reset_val.sizes}{value_str}{extra_str}")
        return f"memories=({', '.join(entries)})"

    def extra_repr(self):
        return self._memories_repr()

    def register_memory(
        self,
        name: str,
        value: Any,
        sizes: int | Sequence[int],
        dtype=None,
        persistent=None,
    ):
        assert not hasattr(self, name), f"{name} has been set as a member variable!"
        if isinstance(sizes, int):
            sizes = (sizes,)
        else:
            sizes = tuple(sizes)
        if isinstance(value, Sequence):
            assert (
                len(value) != 0
            ), f"Memory cannot be empty list or sequence: got {value}"
            value = np.asarray(value)

        self.set_reset_value(
            name, value, sizes=sizes, dtype=dtype, persistent=persistent
        )

    def set_reset_value(
        self,
        name: str,
        value: ResetValueType | Number | ResetValue,
        *,
        strict: bool = True,
        **reset_kwargs,
    ):
        if isinstance(value, Number):
            value = np.asarray(value)
        if isinstance(value, ResetValueType):
            reset_kwargs["value"] = value
        else:
            if name not in self._memories_rv and len(reset_kwargs) == 0:
                if not strict:
                    self._memories_rv[name] = value
                    return
            reset_kwargs = {**value.__dict__, **reset_kwargs}

        if "value" not in reset_kwargs:
            raise ValueError(f"'value' is required for register_memory_rv('{name}')")
        if name not in self._memories_rv:
            if "sizes" not in reset_kwargs:
                raise ValueError(
                    f"'sizes' is required when registering new memory_rv '{name}'"
                )
            if "has_batch" not in reset_kwargs:
                reset_kwargs["has_batch"] = False

            _validate_sizes(reset_kwargs)
            _validate_has_batch(reset_kwargs)
            self._memories_rv[name] = ResetValue(**reset_kwargs)
            return

        if "sizes" in reset_kwargs:
            if strict and self._memories_rv[name].sizes != reset_kwargs["sizes"]:
                raise ValueError(
                    f"Memory_rv '{name}' sizes mismatch: "
                    f"existing={self._memories_rv[name].sizes}, "
                    f"new={reset_kwargs['sizes']}"
                )
        else:
            reset_kwargs["sizes"] = self._memories_rv[name].sizes

        _validate_sizes(reset_kwargs)

        if "has_batch" not in reset_kwargs:
            reset_kwargs["has_batch"] = self._memories_rv[name].has_batch

        _validate_has_batch(reset_kwargs)
        for k, v in self._memories_rv[name].__dict__.items():
            # if k not in ("has_batch", "sizes"):
            reset_kwargs.setdefault(k, v)

        self._memories_rv[name] = ResetValue(**reset_kwargs)

    @torch.no_grad()
    def init_state(
        self,
        batch_size=None,
        dtype=None,
        device=None,
        persistent=False,
        skip_mem_name: tuple[str, ...] = (),
    ):
        skip_mem_name_set = set(skip_mem_name)
        for key, reset_val in self._memories_rv.items():
            if key in skip_mem_name_set:
                continue
            dtype = reset_val.dtype or dtype
            v = _memory_var(reset_val, batch_size, dtype=dtype, device=device)
            persistent = reset_val.persistent or persistent
            self.register_buffer(key, v, persistent=persistent)

    def _batch_dim_detect(self, mem_name):
        sizes = self._memories_rv[mem_name].sizes
        buffer = self._buffers[mem_name]
        if (buffer is not None) and (buffer.shape != sizes):
            return buffer.shape[: -len(sizes)]
        return None

    def _batch_dim_exist(self, mem_name: str):
        return self._batch_dim_detect(mem_name) is not None

    @torch.no_grad()
    def reset(
        self,
        batch_size=None,
        dtype=None,
        device=None,
        skip_mem_name: tuple[str, ...] = (),
    ):
        skip_mem_name_set = set(skip_mem_name)
        for key, reset_val in self._memories_rv.items():
            if key in skip_mem_name_set:
                continue
            if reset_val.dtype is not None:
                dtype = reset_val.dtype
            buffer = self._buffers[key]
            format_args = {
                "device": device or buffer.device,
                "dtype": dtype or buffer.dtype,
            }
            if batch_size is None:
                batch_size = self._batch_dim_detect(key)

            v = _memory_var(reset_val, batch_size, **format_args)
            setattr(self, key, v)

    def __getattr__(self, name: str):
        return torch.nn.Module.__getattr__(self, name)

    def __setattr__(self, name: str, value) -> None:
        torch.nn.Module.__setattr__(self, name, value)

    def __delattr__(self, name):
        if name in self._memories_rv:
            del self._memories_rv[name]
        torch.nn.Module.__delattr__(self, name)

    def __dir__(self):
        return torch.nn.Module.__dir__(self)

    def memories(self):
        for name in self._memories_rv.keys():
            yield self._buffers[name]

    def named_memories(self):
        for name in self._memories_rv.keys():
            yield name, self._buffers[name]

    def detach(self):
        for key in self._memories_rv.keys():
            self._buffers[key].detach_()

    def _apply(self, fn):
        return torch.nn.Module._apply(self, fn)

    def _replicate_for_data_parallel(self):
        replica = torch.nn.Module._replicate_for_data_parallel(self)
        return replica

    @property
    def _memories(self):
        return {name: self._buffers[name] for name in self._memories_rv.keys()}

    @_memories.setter
    def _memories(self, value: dict):
        for k, v in value.items():
            assert k in self._memories_rv
            setattr(self, k, v)

    @property
    def memories_rv(self):
        return self._memories_rv

    def set_memories_rv(self, value: dict, strict: bool = False):
        for k, v in value.items():
            assert k in self._memories_rv
            self.set_reset_value(k, v, strict=strict)

    @memories_rv.setter
    def memories_rv(self, value: dict):
        for k, v in value.items():
            assert k in self._memories_rv
            self.set_reset_value(k, v, strict=False)

ParamBufferMixin

Bases: Module

Standard parameter/buffer definition and load-shape behavior.

This mixin allows defining parameters/buffers that can be stored in their minimal broadcastable form (to save memory) or as full arrays. Supports: - easy trainable definition via one argument: trainable_param - optional trainable shape policy (trainable_shape="scalar"|"full"|"auto") - extend load_state_dict for loading non-uniform full tensors on uniform scalar buffer

Source code in btorch/models/base.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
class ParamBufferMixin(torch.nn.Module):
    """Standard parameter/buffer definition and load-shape behavior.

    This mixin allows defining parameters/buffers that can be stored in their
    minimal broadcastable form (to save memory) or as full arrays. Supports:
    - easy trainable definition via one argument: `trainable_param`
    - optional trainable shape policy (`trainable_shape="scalar"|"full"|"auto"`)
    - extend `load_state_dict` for loading non-uniform full tensors
        on uniform scalar buffer
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # name -> "auto" | "scalar" | "full"
        self._param_shape_mode: dict[str, str] = {}
        # name -> whether compact scalar save/load is allowed
        self._param_allow_compact: dict[str, bool] = {}

    def _resolve_is_trainable(
        self,
        name: str,
        trainable_param: bool | set[str] | None = None,
    ) -> bool:
        if isinstance(trainable_param, bool):
            return trainable_param
        if isinstance(trainable_param, set):
            return name in trainable_param
        if hasattr(self, "trainable_param"):
            return name in getattr(self, "trainable_param")
        return False

    @staticmethod
    def _resolve_sizes_spec_for_value(
        val, sizes_spec: tuple[int | None, ...]
    ) -> tuple[int, ...]:
        none_axes = [idx for idx, dim in enumerate(sizes_spec) if dim is None]
        if len(none_axes) > 1:
            raise ValueError(
                f"At most one inferred dimension is supported, got sizes={sizes_spec}."
            )

        if len(none_axes) == 0:
            resolved: list[int] = []
            for dim in sizes_spec:
                if dim is None:
                    raise ValueError(
                        f"Unexpected unresolved dimension in sizes={sizes_spec}."
                    )
                resolved.append(int(dim))
            return tuple(resolved)

        infer_axis = none_axes[0]
        inferred_dim = 1
        val_tensor = torch.as_tensor(val)

        if val_tensor.ndim == len(sizes_spec):
            inferred_dim = int(val_tensor.shape[infer_axis])
        elif infer_axis == len(sizes_spec) - 1 and val_tensor.ndim > infer_axis:
            inferred_dim = int(val_tensor.shape[-1])
        elif infer_axis == len(sizes_spec) - 1:
            known_prefix = tuple(int(dim) for dim in sizes_spec[:-1] if dim is not None)
            prefix_prod = int(np.prod(known_prefix)) if known_prefix else 1
            if (
                prefix_prod > 0
                and val_tensor.ndim == 1
                and val_tensor.numel() % prefix_prod == 0
            ):
                inferred_dim = max(1, int(val_tensor.numel() // prefix_prod))
            elif val_tensor.ndim > 0:
                inferred_dim = int(val_tensor.shape[-1])

        resolved_sizes: list[int] = []
        for dim in sizes_spec:
            if dim is None:
                resolved_sizes.append(inferred_dim)
            else:
                resolved_sizes.append(int(dim))
        return tuple(resolved_sizes)

    def def_param_resolve_sizes(
        self,
        *vals,
        sizes: tuple[int | None, ...] | None = None,
    ) -> tuple[int, ...]:
        """Resolve a concrete size tuple from one or more values.

        If ``sizes`` contains one ``None`` axis, this method infers that axis
        per value and returns the broadcast-compatible maximum across values.
        """
        if sizes is None:
            if not hasattr(self, "n_neuron"):
                raise ValueError("sizes is required when module has no n_neuron.")
            sizes = tuple(getattr(self, "n_neuron"))

        sizes_spec = tuple(sizes)
        if len(vals) == 0:
            return self._resolve_sizes_spec_for_value(0.0, sizes_spec)

        resolved_list = [
            self._resolve_sizes_spec_for_value(v, sizes_spec) for v in vals
        ]
        target = list(resolved_list[0])
        for resolved in resolved_list[1:]:
            if len(resolved) != len(target):
                raise ValueError("Resolved sizes must have the same rank.")
            for axis in range(len(target)):
                d0, d1 = target[axis], resolved[axis]
                dmax = max(d0, d1)
                if d0 not in (1, dmax) or d1 not in (1, dmax):
                    raise ValueError(
                        f"Incompatible resolved sizes at axis {axis}: "
                        f"{tuple(target)} vs {resolved}."
                    )
                target[axis] = dmax
        return tuple(target)

    def def_param_prepare(
        self,
        name: str,
        val: Any,
        *,
        sizes: tuple[int | None, ...] | None = None,
        trainable_param: bool | set[str] | None = None,
        trainable_shape: str = "auto",
        normalize_to_sizes: bool = False,
        **kwargs: Any,
    ) -> PreparedParam:
        """Build a parameter definition without registering it.

        Args:
            name: Attribute name.
            val: Initial value.
            sizes: Intended tensor shape. If None, uses ``self.n_neuron``.
            trainable_param: Trainable selector:
                - ``True``: trainable parameter
                - ``False``: buffer
                - ``set[str]``: trainable when ``name in set``
                - ``None``: fallback to ``self.trainable_param`` if present
            trainable_shape: Shape policy for trainable values:
                - ``"auto"``: keep provided shape
                - ``"scalar"``: require uniform value and store as scalar
                - ``"full"``: store as full tensor with ``sizes``
            normalize_to_sizes: If True, broadcast and materialize value to
                ``sizes`` (ignored for ``trainable_shape="scalar"``).
            **kwargs: Passed to :func:`torch.as_tensor`.

        Raises:
            ValueError: If the value shape is not broadcastable to ``sizes``.

        Returns:
            Prepared parameter metadata and tensor value.
        """
        if sizes is None:
            if not hasattr(self, "n_neuron"):
                raise ValueError("sizes is required when module has no n_neuron.")
            sizes = tuple(getattr(self, "n_neuron"))

        sizes_spec = tuple(sizes)

        is_trainable = self._resolve_is_trainable(name, trainable_param)

        if trainable_shape not in {"auto", "scalar", "full"}:
            raise ValueError(
                f"Invalid trainable_shape={trainable_shape!r}. "
                "Use 'auto', 'scalar', or 'full'."
            )

        sizes = self._resolve_sizes_spec_for_value(val, sizes_spec)
        val = torch.as_tensor(val, **kwargs)

        if val.ndim == 1 and val.numel() == int(np.prod(sizes)):
            val = val.reshape(sizes)

        if (
            len(sizes) > 1
            and val.ndim == len(sizes) - 1
            and tuple(val.shape) == sizes[:-1]
        ):
            val = val[..., None]
        elif (
            val.ndim == 1 and len(sizes) > 1 and val.numel() == int(np.prod(sizes[:-1]))
        ):
            val = val.reshape(sizes[:-1] + (1,))

        if not is_broadcastable(val.shape, sizes):
            raise ValueError(
                f"{name} shape {tuple(val.shape)} is not broadcastable to {sizes}."
            )

        # Keep compacting only for neuron-sized parameters. For explicitly
        # larger shapes (e.g., neuron + extra axes), preserve full shape.
        allow_compact = sizes == tuple(getattr(self, "n_neuron", sizes))

        if is_trainable and trainable_shape == "full" and val.shape != sizes:
            val = expand_leading_dims(val, sizes, match_full_shape=True)
        if is_trainable and trainable_shape == "scalar":
            if not self._is_uniform(val):
                raise ValueError(
                    f"{name} with trainable_shape='scalar' must be uniform, "
                    f"but got shape {tuple(val.shape)} with non-uniform values."
                )
            val = val.reshape(-1)[:1].reshape(())

        if normalize_to_sizes and trainable_shape != "scalar" and val.shape != sizes:
            val = torch.broadcast_to(val, sizes).clone()

        return PreparedParam(
            name=name,
            value=val,
            sizes=sizes,
            is_trainable=is_trainable,
            trainable_shape=trainable_shape,
            allow_compact=allow_compact,
        )

    def def_param_register(self, prepared: PreparedParam) -> None:
        """Register a parameter from :meth:`def_param_prepare`."""
        if hasattr(self, prepared.name):
            delattr(self, prepared.name)

        self._param_shape_mode[prepared.name] = (
            prepared.trainable_shape if prepared.is_trainable else "auto"
        )
        self._param_allow_compact[prepared.name] = prepared.allow_compact

        if prepared.is_trainable:
            self.register_parameter(prepared.name, torch.nn.Parameter(prepared.value))
        else:
            self.register_buffer(prepared.name, prepared.value, persistent=True)

    def def_param(
        self,
        name: str,
        val,
        *,
        sizes: tuple[int | None, ...] | None = None,
        trainable_param: bool | set[str] | None = None,
        trainable_shape: str = "auto",
        normalize_to_sizes: bool = False,
        **kwargs,
    ):
        """Define a trainable parameter or persistent buffer.

        Convenience wrapper equivalent to:

        1. :meth:`def_param_prepare`
        2. :meth:`def_param_register`
        """
        prepared = self.def_param_prepare(
            name,
            val,
            sizes=sizes,
            trainable_param=trainable_param,
            trainable_shape=trainable_shape,
            normalize_to_sizes=normalize_to_sizes,
            **kwargs,
        )
        self.def_param_register(prepared)

    @staticmethod
    def _is_uniform(tensor: torch.Tensor, atol: float = 1e-6) -> bool:
        if tensor.numel() <= 1:
            return True
        if tensor.dtype.is_floating_point:
            return bool(torch.allclose(tensor, tensor.reshape(-1)[0], atol=atol))
        return bool((tensor == tensor.reshape(-1)[0]).all())

    def _replace_registered_tensor(self, name: str, value: torch.Tensor) -> None:
        current = getattr(self, name)
        value = value.to(device=current.device, dtype=current.dtype)
        if name in self._parameters:
            requires_grad = bool(self._parameters[name].requires_grad)
            delattr(self, name)
            self.register_parameter(
                name,
                torch.nn.Parameter(value, requires_grad=requires_grad),
            )
            return
        if name in self._buffers:
            persistent = name not in self._non_persistent_buffers_set
            delattr(self, name)
            self.register_buffer(name, value, persistent=persistent)

    def _save_to_state_dict(self, destination, prefix, keep_vars):
        super()._save_to_state_dict(destination, prefix, keep_vars)
        for name, mode in self._param_shape_mode.items():
            if mode == "full":
                continue
            if not self._param_allow_compact.get(name, True):
                continue
            key = prefix + name
            if key not in destination:
                continue
            value = destination[key]
            if torch.is_tensor(value) and value.numel() > 1 and self._is_uniform(value):
                destination[key] = value.reshape(-1)[:1].reshape(())

    def _load_from_state_dict(
        self,
        state_dict,
        prefix,
        local_metadata,
        strict,
        missing_keys,
        unexpected_keys,
        error_msgs,
    ):
        for name, mode in self._param_shape_mode.items():
            key = prefix + name
            if key not in state_dict or not hasattr(self, name):
                continue

            loaded = state_dict[key]
            if not torch.is_tensor(loaded):
                continue

            current = getattr(self, name)
            current_shape = tuple(current.shape)
            loaded_shape = tuple(loaded.shape)

            # Parameters with trailing dimensions should preserve their full
            # shape. We only broadcast incoming compact tensors to the current
            # shape but never collapse to scalar.
            if not self._param_allow_compact.get(name, True):
                if loaded_shape != current_shape and is_broadcastable(
                    loaded_shape, current_shape
                ):
                    state_dict[key] = torch.broadcast_to(loaded, current_shape).clone()
                elif loaded_shape != current_shape:
                    self._replace_registered_tensor(name, loaded.detach().clone())
                continue

            # Mode full: always keep full tensor shape.
            if mode == "full":
                if loaded_shape != current_shape and is_broadcastable(
                    loaded_shape, current_shape
                ):
                    state_dict[key] = torch.broadcast_to(loaded, current_shape).clone()
                elif loaded_shape != current_shape:
                    self._replace_registered_tensor(name, loaded.detach().clone())
                continue

            if mode == "scalar":
                if loaded.numel() == 1:
                    scalar = loaded.reshape(-1)[:1].reshape(())
                    state_dict[key] = scalar
                    if current_shape != ():
                        self._replace_registered_tensor(name, scalar)
                    continue

                # Non-scalar checkpoint for scalar mode:
                # - trainable parameter: bail out (avoid silent layout changes)
                # - non-trainable buffer: promote to loaded shape
                if name in self._parameters:
                    error_msgs.append(
                        f"{key}: received non-scalar checkpoint tensor for "
                        "trainable_shape='scalar' trainable parameter."
                    )
                    continue

                self._replace_registered_tensor(name, loaded.detach().clone())
                continue

            # Mode auto:
            # - uniform loaded value -> scalar
            # - non-uniform loaded value -> full tensor
            if self._is_uniform(loaded):
                scalar = loaded.reshape(-1)[:1].reshape(())
                state_dict[key] = scalar
                if current_shape != ():
                    self._replace_registered_tensor(name, scalar)
            elif loaded_shape != current_shape:
                self._replace_registered_tensor(name, loaded.detach().clone())

        super()._load_from_state_dict(
            state_dict,
            prefix,
            local_metadata,
            strict,
            missing_keys,
            unexpected_keys,
            error_msgs,
        )
Functions
def_param(name, val, *, sizes=None, trainable_param=None, trainable_shape='auto', normalize_to_sizes=False, **kwargs)

Define a trainable parameter or persistent buffer.

Convenience wrapper equivalent to:

  1. :meth:def_param_prepare
  2. :meth:def_param_register
Source code in btorch/models/base.py
def def_param(
    self,
    name: str,
    val,
    *,
    sizes: tuple[int | None, ...] | None = None,
    trainable_param: bool | set[str] | None = None,
    trainable_shape: str = "auto",
    normalize_to_sizes: bool = False,
    **kwargs,
):
    """Define a trainable parameter or persistent buffer.

    Convenience wrapper equivalent to:

    1. :meth:`def_param_prepare`
    2. :meth:`def_param_register`
    """
    prepared = self.def_param_prepare(
        name,
        val,
        sizes=sizes,
        trainable_param=trainable_param,
        trainable_shape=trainable_shape,
        normalize_to_sizes=normalize_to_sizes,
        **kwargs,
    )
    self.def_param_register(prepared)
def_param_prepare(name, val, *, sizes=None, trainable_param=None, trainable_shape='auto', normalize_to_sizes=False, **kwargs)

Build a parameter definition without registering it.

Parameters:

Name Type Description Default
name str

Attribute name.

required
val Any

Initial value.

required
sizes tuple[int | None, ...] | None

Intended tensor shape. If None, uses self.n_neuron.

None
trainable_param bool | set[str] | None

Trainable selector: - True: trainable parameter - False: buffer - set[str]: trainable when name in set - None: fallback to self.trainable_param if present

None
trainable_shape str

Shape policy for trainable values: - "auto": keep provided shape - "scalar": require uniform value and store as scalar - "full": store as full tensor with sizes

'auto'
normalize_to_sizes bool

If True, broadcast and materialize value to sizes (ignored for trainable_shape="scalar").

False
**kwargs Any

Passed to :func:torch.as_tensor.

{}

Raises:

Type Description
ValueError

If the value shape is not broadcastable to sizes.

Returns:

Type Description
PreparedParam

Prepared parameter metadata and tensor value.

Source code in btorch/models/base.py
def def_param_prepare(
    self,
    name: str,
    val: Any,
    *,
    sizes: tuple[int | None, ...] | None = None,
    trainable_param: bool | set[str] | None = None,
    trainable_shape: str = "auto",
    normalize_to_sizes: bool = False,
    **kwargs: Any,
) -> PreparedParam:
    """Build a parameter definition without registering it.

    Args:
        name: Attribute name.
        val: Initial value.
        sizes: Intended tensor shape. If None, uses ``self.n_neuron``.
        trainable_param: Trainable selector:
            - ``True``: trainable parameter
            - ``False``: buffer
            - ``set[str]``: trainable when ``name in set``
            - ``None``: fallback to ``self.trainable_param`` if present
        trainable_shape: Shape policy for trainable values:
            - ``"auto"``: keep provided shape
            - ``"scalar"``: require uniform value and store as scalar
            - ``"full"``: store as full tensor with ``sizes``
        normalize_to_sizes: If True, broadcast and materialize value to
            ``sizes`` (ignored for ``trainable_shape="scalar"``).
        **kwargs: Passed to :func:`torch.as_tensor`.

    Raises:
        ValueError: If the value shape is not broadcastable to ``sizes``.

    Returns:
        Prepared parameter metadata and tensor value.
    """
    if sizes is None:
        if not hasattr(self, "n_neuron"):
            raise ValueError("sizes is required when module has no n_neuron.")
        sizes = tuple(getattr(self, "n_neuron"))

    sizes_spec = tuple(sizes)

    is_trainable = self._resolve_is_trainable(name, trainable_param)

    if trainable_shape not in {"auto", "scalar", "full"}:
        raise ValueError(
            f"Invalid trainable_shape={trainable_shape!r}. "
            "Use 'auto', 'scalar', or 'full'."
        )

    sizes = self._resolve_sizes_spec_for_value(val, sizes_spec)
    val = torch.as_tensor(val, **kwargs)

    if val.ndim == 1 and val.numel() == int(np.prod(sizes)):
        val = val.reshape(sizes)

    if (
        len(sizes) > 1
        and val.ndim == len(sizes) - 1
        and tuple(val.shape) == sizes[:-1]
    ):
        val = val[..., None]
    elif (
        val.ndim == 1 and len(sizes) > 1 and val.numel() == int(np.prod(sizes[:-1]))
    ):
        val = val.reshape(sizes[:-1] + (1,))

    if not is_broadcastable(val.shape, sizes):
        raise ValueError(
            f"{name} shape {tuple(val.shape)} is not broadcastable to {sizes}."
        )

    # Keep compacting only for neuron-sized parameters. For explicitly
    # larger shapes (e.g., neuron + extra axes), preserve full shape.
    allow_compact = sizes == tuple(getattr(self, "n_neuron", sizes))

    if is_trainable and trainable_shape == "full" and val.shape != sizes:
        val = expand_leading_dims(val, sizes, match_full_shape=True)
    if is_trainable and trainable_shape == "scalar":
        if not self._is_uniform(val):
            raise ValueError(
                f"{name} with trainable_shape='scalar' must be uniform, "
                f"but got shape {tuple(val.shape)} with non-uniform values."
            )
        val = val.reshape(-1)[:1].reshape(())

    if normalize_to_sizes and trainable_shape != "scalar" and val.shape != sizes:
        val = torch.broadcast_to(val, sizes).clone()

    return PreparedParam(
        name=name,
        value=val,
        sizes=sizes,
        is_trainable=is_trainable,
        trainable_shape=trainable_shape,
        allow_compact=allow_compact,
    )
def_param_register(prepared)

Register a parameter from :meth:def_param_prepare.

Source code in btorch/models/base.py
def def_param_register(self, prepared: PreparedParam) -> None:
    """Register a parameter from :meth:`def_param_prepare`."""
    if hasattr(self, prepared.name):
        delattr(self, prepared.name)

    self._param_shape_mode[prepared.name] = (
        prepared.trainable_shape if prepared.is_trainable else "auto"
    )
    self._param_allow_compact[prepared.name] = prepared.allow_compact

    if prepared.is_trainable:
        self.register_parameter(prepared.name, torch.nn.Parameter(prepared.value))
    else:
        self.register_buffer(prepared.name, prepared.value, persistent=True)
def_param_resolve_sizes(*vals, sizes=None)

Resolve a concrete size tuple from one or more values.

If sizes contains one None axis, this method infers that axis per value and returns the broadcast-compatible maximum across values.

Source code in btorch/models/base.py
def def_param_resolve_sizes(
    self,
    *vals,
    sizes: tuple[int | None, ...] | None = None,
) -> tuple[int, ...]:
    """Resolve a concrete size tuple from one or more values.

    If ``sizes`` contains one ``None`` axis, this method infers that axis
    per value and returns the broadcast-compatible maximum across values.
    """
    if sizes is None:
        if not hasattr(self, "n_neuron"):
            raise ValueError("sizes is required when module has no n_neuron.")
        sizes = tuple(getattr(self, "n_neuron"))

    sizes_spec = tuple(sizes)
    if len(vals) == 0:
        return self._resolve_sizes_spec_for_value(0.0, sizes_spec)

    resolved_list = [
        self._resolve_sizes_spec_for_value(v, sizes_spec) for v in vals
    ]
    target = list(resolved_list[0])
    for resolved in resolved_list[1:]:
        if len(resolved) != len(target):
            raise ValueError("Resolved sizes must have the same rank.")
        for axis in range(len(target)):
            d0, d1 = target[axis], resolved[axis]
            dmax = max(d0, d1)
            if d0 not in (1, dmax) or d1 not in (1, dmax):
                raise ValueError(
                    f"Incompatible resolved sizes at axis {axis}: "
                    f"{tuple(target)} vs {resolved}."
                )
            target[axis] = dmax
    return tuple(target)

TwoCompartmentGLIF

Bases: ParamBufferMixin, MemoryModule

Two-compartment current-based neuron with apical plateaus.

The model combines a fast somatic compartment with a slow apical current:

.. math:: \tau_s \frac{dV_s}{dt} = -(V_s - E_L) + R_s \left(I_{soma} + w_{as} I_a\right) + \Delta_T \exp\left(\frac{V_s - V_{th,eff}}{\Delta_T}\right)

.. math:: \tau_a \frac{dI_a}{dt} = -I_a + I_{apical} + w_{Ca} \sigma(I_a - \theta_{Ca}) + I_{bap}

Somatic spikes are generated by a surrogate threshold on V_s and stored as a pending back-propagating current for the next timestep.

A minimal adaptive threshold state can optionally be enabled:

.. math:: V_{th,eff} = V_{th} + \theta_h

.. math:: \tau_h \frac{d\theta_h}{dt} = -\theta_h

followed by an instantaneous post-spike increment :math:\theta_h \leftarrow \theta_h + \Delta_h.

Parameters:

Name Type Description Default
n_neuron int | Sequence[int]

Number of neurons, as an int or tuple of neuron axes.

required
v_threshold float | Float[TensorLike, ' n_neuron']

Somatic spike threshold.

-50.0
v_reset float | Float[TensorLike, ' n_neuron']

Somatic reset voltage after a spike.

-65.0
tau_s float | Float[TensorLike, ' n_neuron']

Somatic membrane time constant.

20.0
R_s float | Float[TensorLike, ' n_neuron']

Somatic input resistance.

1.0
E_L float | Float[TensorLike, ' n_neuron']

Somatic leak reversal / resting voltage.

-70.0
tau_a float | Float[TensorLike, ' n_neuron']

Apical current time constant.

100.0
tau_th float | Float[TensorLike, ' n_neuron']

Adaptive-threshold decay time constant.

50.0
delta_th float | Float[TensorLike, ' n_neuron']

Post-spike threshold increment.

0.0
delta_T float | Float[TensorLike, ' n_neuron']

Exponential spike-initiation scale. Set to zero to disable.

0.0
w_Ca float | Float[TensorLike, ' n_neuron']

Calcium plateau amplitude.

0.0
theta_Ca float | Float[TensorLike, ' n_neuron']

Apical plateau activation threshold.

1.0
w_sa float | Float[TensorLike, ' n_neuron']

Somatic-to-apical coupling weight for the delayed bAP current.

0.5
w_as float | Float[TensorLike, ' n_neuron']

Apical-to-somatic coupling weight for top-down drive.

1.0
trainable_param set[str]

Names of parameters to optimize.

set()
surrogate_function Callable

Surrogate gradient function for soma spikes.

ATan()
detach_reset bool

If True, detach the reset signal from the graph.

False
step_mode Literal['s', 'm']

"s" for single-step or "m" for multi-step dispatch.

's'
backend Literal['torch']

Backend label for consistency with other neuron modules.

'torch'
device device | None

Optional torch device.

None
dtype dtype | None

Optional torch dtype.

None

Attributes:

Name Type Description
v Tensor

Somatic membrane voltage memory.

i_a Tensor

Slow apical current memory.

i_bap Tensor

Pending back-propagating current injected on the next step.

theta_th Tensor

Adaptive threshold memory added to v_threshold.

Examples:

>>> from btorch.models import environ, functional
>>> neuron = TwoCompartmentGLIF(n_neuron=1)
>>> functional.init_net_state(neuron, batch_size=2)
>>> with environ.context(dt=1.0):
...     spike, voltage = neuron.single_step_forward(
...         torch.zeros(2, 1),
...         torch.zeros(2, 1),
...     )
>>> tuple(spike.shape), tuple(voltage.shape)
((2, 1), (2, 1))
Notes

The integration uses a semi-implicit Euler update: the leak / decay term is treated implicitly while external and coupling currents are evaluated from the previous state. This keeps the update stable while preserving a simple differentiable recurrence.

Source code in btorch/models/neurons/two_compartment.py
class TwoCompartmentGLIF(ParamBufferMixin, MemoryModule):
    """Two-compartment current-based neuron with apical plateaus.

    The model combines a fast somatic compartment with a slow apical current:

    .. math::
        \\tau_s \\frac{dV_s}{dt} =
        -(V_s - E_L) + R_s \\left(I_{soma} + w_{as} I_a\\right) +
        \\Delta_T \\exp\\left(\\frac{V_s - V_{th,eff}}{\\Delta_T}\\right)

    .. math::
        \\tau_a \\frac{dI_a}{dt} =
        -I_a + I_{apical} +
        w_{Ca} \\sigma(I_a - \\theta_{Ca}) + I_{bap}

    Somatic spikes are generated by a surrogate threshold on ``V_s`` and stored
    as a pending back-propagating current for the next timestep.

    A minimal adaptive threshold state can optionally be enabled:

    .. math::
        V_{th,eff} = V_{th} + \\theta_h

    .. math::
        \\tau_h \\frac{d\\theta_h}{dt} = -\\theta_h

    followed by an instantaneous post-spike increment
    :math:`\\theta_h \\leftarrow \\theta_h + \\Delta_h`.

    Args:
        n_neuron: Number of neurons, as an ``int`` or tuple of neuron axes.
        v_threshold: Somatic spike threshold.
        v_reset: Somatic reset voltage after a spike.
        tau_s: Somatic membrane time constant.
        R_s: Somatic input resistance.
        E_L: Somatic leak reversal / resting voltage.
        tau_a: Apical current time constant.
        tau_th: Adaptive-threshold decay time constant.
        delta_th: Post-spike threshold increment.
        delta_T: Exponential spike-initiation scale. Set to zero to disable.
        w_Ca: Calcium plateau amplitude.
        theta_Ca: Apical plateau activation threshold.
        w_sa: Somatic-to-apical coupling weight for the delayed bAP current.
        w_as: Apical-to-somatic coupling weight for top-down drive.
        trainable_param: Names of parameters to optimize.
        surrogate_function: Surrogate gradient function for soma spikes.
        detach_reset: If ``True``, detach the reset signal from the graph.
        step_mode: ``"s"`` for single-step or ``"m"`` for multi-step dispatch.
        backend: Backend label for consistency with other neuron modules.
        device: Optional torch device.
        dtype: Optional torch dtype.

    Attributes:
        v: Somatic membrane voltage memory.
        i_a: Slow apical current memory.
        i_bap: Pending back-propagating current injected on the next step.
        theta_th: Adaptive threshold memory added to ``v_threshold``.

    Examples:
        >>> from btorch.models import environ, functional
        >>> neuron = TwoCompartmentGLIF(n_neuron=1)
        >>> functional.init_net_state(neuron, batch_size=2)
        >>> with environ.context(dt=1.0):
        ...     spike, voltage = neuron.single_step_forward(
        ...         torch.zeros(2, 1),
        ...         torch.zeros(2, 1),
        ...     )
        >>> tuple(spike.shape), tuple(voltage.shape)
        ((2, 1), (2, 1))

    Notes:
        The integration uses a semi-implicit Euler update: the leak / decay
        term is treated implicitly while external and coupling currents are
        evaluated from the previous state. This keeps the update stable while
        preserving a simple differentiable recurrence.
    """

    v: torch.Tensor
    i_a: torch.Tensor
    i_bap: torch.Tensor
    theta_th: torch.Tensor

    v_threshold: torch.Tensor | torch.nn.Parameter
    v_reset: torch.Tensor | torch.nn.Parameter
    tau_s: torch.Tensor | torch.nn.Parameter
    R_s: torch.Tensor | torch.nn.Parameter
    E_L: torch.Tensor | torch.nn.Parameter
    tau_a: torch.Tensor | torch.nn.Parameter
    tau_th: torch.Tensor | torch.nn.Parameter
    delta_th: torch.Tensor | torch.nn.Parameter
    delta_T: torch.Tensor | torch.nn.Parameter
    w_Ca: torch.Tensor | torch.nn.Parameter
    theta_Ca: torch.Tensor | torch.nn.Parameter
    w_sa: torch.Tensor | torch.nn.Parameter
    w_as: torch.Tensor | torch.nn.Parameter

    def __init__(
        self,
        n_neuron: int | Sequence[int],
        *,
        v_threshold: float | Float[TensorLike, " n_neuron"] = -50.0,
        v_reset: float | Float[TensorLike, " n_neuron"] = -65.0,
        tau_s: float | Float[TensorLike, " n_neuron"] = 20.0,
        R_s: float | Float[TensorLike, " n_neuron"] = 1.0,
        E_L: float | Float[TensorLike, " n_neuron"] = -70.0,
        tau_a: float | Float[TensorLike, " n_neuron"] = 100.0,
        tau_th: float | Float[TensorLike, " n_neuron"] = 50.0,
        delta_th: float | Float[TensorLike, " n_neuron"] = 0.0,
        delta_T: float | Float[TensorLike, " n_neuron"] = 0.0,
        w_Ca: float | Float[TensorLike, " n_neuron"] = 0.0,
        theta_Ca: float | Float[TensorLike, " n_neuron"] = 1.0,
        w_sa: float | Float[TensorLike, " n_neuron"] = 0.5,
        w_as: float | Float[TensorLike, " n_neuron"] = 1.0,
        trainable_param: set[str] = set(),
        surrogate_function: Callable = ATan(),
        detach_reset: bool = False,
        step_mode: Literal["s", "m"] = "s",
        backend: Literal["torch"] = "torch",
        device: torch.device | None = None,
        dtype: torch.dtype | None = None,
    ):
        super().__init__()
        self.n_neuron, self.size = normalize_n_neuron(n_neuron)
        self.trainable_param = set(trainable_param)
        self.surrogate_function = surrogate_function
        self.detach_reset = detach_reset
        self.step_mode = step_mode
        self.backend = backend

        self.register_memory("v", v_reset, self.n_neuron)
        self.register_memory("i_a", 0.0, self.n_neuron)
        self.register_memory("i_bap", 0.0, self.n_neuron)
        self.register_memory("theta_th", 0.0, self.n_neuron)

        factory_kwargs: dict[str, Any] = {"device": device, "dtype": dtype}
        for name, value in (
            ("v_threshold", v_threshold),
            ("v_reset", v_reset),
            ("tau_s", tau_s),
            ("R_s", R_s),
            ("E_L", E_L),
            ("tau_a", tau_a),
            ("tau_th", tau_th),
            ("delta_th", delta_th),
            ("delta_T", delta_T),
            ("w_Ca", w_Ca),
            ("theta_Ca", theta_Ca),
            ("w_sa", w_sa),
            ("w_as", w_as),
        ):
            self.def_param(
                name,
                value,
                sizes=self.n_neuron,
                trainable_param=self.trainable_param,
                **factory_kwargs,
            )

    def _prepare_step_tensor(
        self,
        x: float | Tensor,
        *,
        name: str,
    ) -> Float[Tensor, "*batch n_neuron"]:
        target = self.v
        x = torch.as_tensor(x, device=target.device, dtype=target.dtype)
        if x.shape == target.shape:
            return x
        if x.numel() == 1:
            return torch.full_like(target, x.reshape(()))
        if is_broadcastable(x.shape, target.shape):
            return torch.broadcast_to(x, target.shape)
        raise RuntimeError(
            f"{name} shape mismatch. Expected a tensor broadcastable to "
            f"{tuple(target.shape)}, got {tuple(x.shape)}."
        )

    @staticmethod
    def _positive(x: Tensor, eps: float = 1e-4) -> Tensor:
        return torch.clamp(x, min=eps)

    def _plateau_current(self) -> Float[Tensor, "*batch n_neuron"]:
        return self.w_Ca * torch.sigmoid(self.i_a - self.theta_Ca)

    def _update_apical_current(
        self,
        i_apical: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_a = self._positive(self.tau_a)
        drive = i_apical + self._plateau_current() + self.i_bap
        decay = dt / tau_a
        return (self.i_a + decay * drive) / (1.0 + decay)

    def _update_somatic_voltage(
        self,
        i_soma: Float[Tensor, "*batch n_neuron"],
        i_a_next: Float[Tensor, "*batch n_neuron"],
        threshold_eff: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_s = self._positive(self.tau_s)
        R_s = self._positive(self.R_s)
        total_current = i_soma + self.w_as * i_a_next
        delta_T = torch.clamp(self.delta_T, min=0.0)
        exp_arg = torch.clamp(
            (self.v - threshold_eff) / torch.clamp(delta_T, min=1e-4),
            max=10.0,
        )
        exp_drive = torch.where(
            delta_T > 0.0,
            delta_T * torch.exp(exp_arg),
            torch.zeros_like(self.v),
        )
        decay = dt / tau_s
        numerator = self.v + decay * (self.E_L + R_s * total_current + exp_drive)
        return numerator / (1.0 + decay)

    def _reset_voltage(
        self,
        v_pre_spike: Float[Tensor, "*batch n_neuron"],
        spike: Float[Tensor, "*batch n_neuron"],
    ) -> Float[Tensor, "*batch n_neuron"]:
        spike_d = spike.detach() if self.detach_reset else spike
        return v_pre_spike - (v_pre_spike - self.v_reset) * spike_d

    def _update_threshold_state(
        self,
        spike: Float[Tensor, "*batch n_neuron"],
        dt: float,
    ) -> Float[Tensor, "*batch n_neuron"]:
        tau_th = self._positive(self.tau_th)
        decay = dt / tau_th
        spike_d = spike.detach() if self.detach_reset else spike
        return self.theta_th / (1.0 + decay) + self.delta_th * spike_d

    def single_step_forward(
        self,
        i_soma: Float[Tensor | float, "*batch n_neuron"],
        i_apical: Float[Tensor | float, "*batch n_neuron"] | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Advance the neuron by one timestep.

        Args:
            i_soma: Somatic current input.
            i_apical: Optional apical current input. Defaults to zero.
            return_state: If ``True``, also return a state dictionary.

        Returns:
            A tuple ``(spike, voltage)``. When ``return_state=True``, returns
            ``(spike, voltage, state_dict)`` with ``v_pre_spike``, ``i_a``, and
            ``i_bap`` traces for the current step.
        """
        dt = float(environ.get("dt"))
        soma_drive = self._prepare_step_tensor(i_soma, name="i_soma")
        apical_drive = (
            torch.zeros_like(soma_drive)
            if i_apical is None
            else self._prepare_step_tensor(i_apical, name="i_apical")
        )

        i_a_next = self._update_apical_current(apical_drive, dt)
        threshold_eff = self.v_threshold + self.theta_th
        v_pre_spike = self._update_somatic_voltage(
            soma_drive,
            i_a_next,
            threshold_eff,
            dt,
        )
        spike = self.surrogate_function(v_pre_spike - threshold_eff)

        self.i_a = i_a_next
        self.v = self._reset_voltage(v_pre_spike, spike)
        self.i_bap = self.w_sa * spike
        self.theta_th = self._update_threshold_state(spike, dt)

        if not return_state:
            return spike, self.v

        return (
            spike,
            self.v,
            {
                "v": self.v,
                "v_pre_spike": v_pre_spike,
                "i_a": self.i_a,
                "i_bap": self.i_bap,
                "theta_th": self.theta_th,
                "v_threshold_eff": threshold_eff,
            },
        )

    def multi_step_forward(
        self,
        i_soma_seq: Float[Tensor, "T *batch n_neuron"],
        i_apical_seq: Float[Tensor, "T *batch n_neuron"] | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Advance the neuron over a full input sequence."""
        spike_seq = []
        v_seq = []
        v_pre_seq = []
        i_a_seq = []
        i_bap_seq = []
        theta_th_seq = []
        threshold_seq = []

        for t in range(i_soma_seq.shape[0]):
            step_apical = None if i_apical_seq is None else i_apical_seq[t]
            step_out = self.single_step_forward(
                i_soma_seq[t],
                step_apical,
                return_state=return_state,
            )
            if return_state:
                spike_t, v_t, state_t = step_out
                v_pre_seq.append(state_t["v_pre_spike"])
                i_a_seq.append(state_t["i_a"])
                i_bap_seq.append(state_t["i_bap"])
                theta_th_seq.append(state_t["theta_th"])
                threshold_seq.append(state_t["v_threshold_eff"])
            else:
                spike_t, v_t = step_out
            spike_seq.append(spike_t)
            v_seq.append(v_t)

        spike_seq_t = torch.stack(spike_seq, dim=0)
        v_seq_t = torch.stack(v_seq, dim=0)
        if not return_state:
            return spike_seq_t, v_seq_t

        return (
            spike_seq_t,
            v_seq_t,
            {
                "v": v_seq_t,
                "v_pre_spike": torch.stack(v_pre_seq, dim=0),
                "i_a": torch.stack(i_a_seq, dim=0),
                "i_bap": torch.stack(i_bap_seq, dim=0),
                "theta_th": torch.stack(theta_th_seq, dim=0),
                "v_threshold_eff": torch.stack(threshold_seq, dim=0),
            },
        )

    def forward(
        self,
        i_soma: Tensor,
        i_apical: Tensor | None = None,
        *,
        return_state: bool = False,
    ) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
        """Dispatch to single-step or multi-step forward."""
        if self.step_mode == "m":
            return self.multi_step_forward(
                i_soma,
                i_apical,
                return_state=return_state,
            )
        return self.single_step_forward(
            i_soma,
            i_apical,
            return_state=return_state,
        )

    def extra_repr(self) -> str:
        parts = [
            f"n_neuron={self.n_neuron}",
            f"step_mode={self.step_mode}",
            f"backend={self.backend}",
            f"surrogate={self.surrogate_function.__class__.__name__}",
        ]
        mem_repr = MemoryModule.extra_repr(self)
        if mem_repr:
            parts.append(mem_repr)
        return ", ".join(parts)
Functions
forward(i_soma, i_apical=None, *, return_state=False)

Dispatch to single-step or multi-step forward.

Source code in btorch/models/neurons/two_compartment.py
def forward(
    self,
    i_soma: Tensor,
    i_apical: Tensor | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Dispatch to single-step or multi-step forward."""
    if self.step_mode == "m":
        return self.multi_step_forward(
            i_soma,
            i_apical,
            return_state=return_state,
        )
    return self.single_step_forward(
        i_soma,
        i_apical,
        return_state=return_state,
    )
multi_step_forward(i_soma_seq, i_apical_seq=None, *, return_state=False)

Advance the neuron over a full input sequence.

Source code in btorch/models/neurons/two_compartment.py
def multi_step_forward(
    self,
    i_soma_seq: Float[Tensor, "T *batch n_neuron"],
    i_apical_seq: Float[Tensor, "T *batch n_neuron"] | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Advance the neuron over a full input sequence."""
    spike_seq = []
    v_seq = []
    v_pre_seq = []
    i_a_seq = []
    i_bap_seq = []
    theta_th_seq = []
    threshold_seq = []

    for t in range(i_soma_seq.shape[0]):
        step_apical = None if i_apical_seq is None else i_apical_seq[t]
        step_out = self.single_step_forward(
            i_soma_seq[t],
            step_apical,
            return_state=return_state,
        )
        if return_state:
            spike_t, v_t, state_t = step_out
            v_pre_seq.append(state_t["v_pre_spike"])
            i_a_seq.append(state_t["i_a"])
            i_bap_seq.append(state_t["i_bap"])
            theta_th_seq.append(state_t["theta_th"])
            threshold_seq.append(state_t["v_threshold_eff"])
        else:
            spike_t, v_t = step_out
        spike_seq.append(spike_t)
        v_seq.append(v_t)

    spike_seq_t = torch.stack(spike_seq, dim=0)
    v_seq_t = torch.stack(v_seq, dim=0)
    if not return_state:
        return spike_seq_t, v_seq_t

    return (
        spike_seq_t,
        v_seq_t,
        {
            "v": v_seq_t,
            "v_pre_spike": torch.stack(v_pre_seq, dim=0),
            "i_a": torch.stack(i_a_seq, dim=0),
            "i_bap": torch.stack(i_bap_seq, dim=0),
            "theta_th": torch.stack(theta_th_seq, dim=0),
            "v_threshold_eff": torch.stack(threshold_seq, dim=0),
        },
    )
single_step_forward(i_soma, i_apical=None, *, return_state=False)

Advance the neuron by one timestep.

Parameters:

Name Type Description Default
i_soma Float[Tensor | float, '*batch n_neuron']

Somatic current input.

required
i_apical Float[Tensor | float, '*batch n_neuron'] | None

Optional apical current input. Defaults to zero.

None
return_state bool

If True, also return a state dictionary.

False

Returns:

Type Description
tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

A tuple (spike, voltage). When return_state=True, returns

tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

(spike, voltage, state_dict) with v_pre_spike, i_a, and

tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]

i_bap traces for the current step.

Source code in btorch/models/neurons/two_compartment.py
def single_step_forward(
    self,
    i_soma: Float[Tensor | float, "*batch n_neuron"],
    i_apical: Float[Tensor | float, "*batch n_neuron"] | None = None,
    *,
    return_state: bool = False,
) -> tuple[Tensor, Tensor] | tuple[Tensor, Tensor, dict[str, Tensor]]:
    """Advance the neuron by one timestep.

    Args:
        i_soma: Somatic current input.
        i_apical: Optional apical current input. Defaults to zero.
        return_state: If ``True``, also return a state dictionary.

    Returns:
        A tuple ``(spike, voltage)``. When ``return_state=True``, returns
        ``(spike, voltage, state_dict)`` with ``v_pre_spike``, ``i_a``, and
        ``i_bap`` traces for the current step.
    """
    dt = float(environ.get("dt"))
    soma_drive = self._prepare_step_tensor(i_soma, name="i_soma")
    apical_drive = (
        torch.zeros_like(soma_drive)
        if i_apical is None
        else self._prepare_step_tensor(i_apical, name="i_apical")
    )

    i_a_next = self._update_apical_current(apical_drive, dt)
    threshold_eff = self.v_threshold + self.theta_th
    v_pre_spike = self._update_somatic_voltage(
        soma_drive,
        i_a_next,
        threshold_eff,
        dt,
    )
    spike = self.surrogate_function(v_pre_spike - threshold_eff)

    self.i_a = i_a_next
    self.v = self._reset_voltage(v_pre_spike, spike)
    self.i_bap = self.w_sa * spike
    self.theta_th = self._update_threshold_state(spike, dt)

    if not return_state:
        return spike, self.v

    return (
        spike,
        self.v,
        {
            "v": self.v,
            "v_pre_spike": v_pre_spike,
            "i_a": self.i_a,
            "i_bap": self.i_bap,
            "theta_th": self.theta_th,
            "v_threshold_eff": threshold_eff,
        },
    )

Functions

is_broadcastable(shape_from, shape_to)

Source code in btorch/models/base.py
def is_broadcastable(shape_from, shape_to):
    try:
        _ = torch.empty(shape_from) + torch.empty(shape_to)
        return True
    except RuntimeError:
        return False

normalize_n_neuron(n_neuron)

Source code in btorch/models/base.py
def normalize_n_neuron(
    n_neuron: int | Sequence[int],
) -> tuple[tuple[int, ...], int]:
    if isinstance(n_neuron, int):
        n_neuron = (n_neuron,)
    else:
        n_neuron = tuple(n_neuron)
    if len(n_neuron) == 0:
        raise ValueError("n_neuron must contain at least one dimension.")
    size = int(np.prod(n_neuron))
    return n_neuron, size