Skip to content

Multi-Timescale SNN — Heterogeneous Synapses + Multi-Clock

Per-synapse learnable time constants and multi-clock layer scheduling. Biological brains have timescales spanning 5 orders of magnitude (1ms–10s); this module enables the same in simulation.

HetSynLayer — Heterogeneous Synaptic Time Constants

Each synapse has its own tau, initialized log-normally (matching Allen Institute cortical data). Different synapses integrate over different temporal windows, enabling a single layer to capture both fast transients and slow trends.

trace[i,j] = exp(-dt/tau[i,j]) * trace[i,j] + input_spike[j]

Parameter Default Meaning
n_inputs (required) Input dimension
n_neurons (required) Output dimension
tau_mean 5.0 Mean synaptic time constant (ms)
tau_std 1.0 Std of log(tau) for log-normal init
threshold 1.0 LIF spike threshold

The tau_stats property returns {mean, std, min, max, median} of the tau distribution.

MultiClockSNN — Multi-Clock Scheduling

Different layers run at different temporal resolutions. Fast sensory layers tick every step, slow cognitive layers tick every N steps. Between ticks, layers hold their last output (clock-domain crossing buffer).

Parameter Meaning
layers List of HetSynLayer
layer_names Names for each layer
clock_intervals Steps between updates per layer (default all 1)

Methods: step(x, dt), run(inputs, dt), reset().

Usage

from sc_neurocore.temporal_hierarchy.multi_clock import HetSynLayer, MultiClockSNN
import numpy as np

# Fast sensory layer (tick every step)
sensory = HetSynLayer(n_inputs=32, n_neurons=64, tau_mean=2.0)

# Slow cognitive layer (tick every 10 steps)
cognitive = HetSynLayer(n_inputs=64, n_neurons=16, tau_mean=50.0)

# Multi-clock network
net = MultiClockSNN(
    layers=[sensory, cognitive],
    layer_names=["sensory", "cognitive"],
    clock_intervals=[1, 10],
)

# Run 100 timesteps
inputs = np.random.randn(100, 32)
outputs = net.run(inputs, dt=1.0)  # (100, 16)

# Check tau distribution
print(sensory.tau_stats)

Reference: HetSyn (NeurIPS 2025).

sc_neurocore.temporal_hierarchy

Multi-timescale SNN: per-synapse time constants + multi-clock scheduling.

MultiClockSNN

Multi-clock SNN with different temporal resolutions per layer.

Parameters

layers : list of HetSynLayer Network layers. clock_domains : list of ClockDomain Clock domain assignments.

Source code in src/sc_neurocore/temporal_hierarchy/multi_clock.py
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
class MultiClockSNN:
    """Multi-clock SNN with different temporal resolutions per layer.

    Parameters
    ----------
    layers : list of HetSynLayer
        Network layers.
    clock_domains : list of ClockDomain
        Clock domain assignments.
    """

    def __init__(
        self,
        layers: list[HetSynLayer],
        layer_names: list[str],
        clock_intervals: list[int] | None = None,
    ):
        self.layers = layers
        self.layer_names = layer_names
        if clock_intervals is None:
            clock_intervals = [1] * len(layers)
        self.clock_intervals = clock_intervals
        self._step_count = 0
        self._last_outputs: list[np.ndarray] = [np.zeros(l.n_neurons) for l in layers]

    def step(self, x: np.ndarray, dt: float = 1.0) -> np.ndarray:
        """Process one global timestep.

        Layers only update when their clock ticks.
        Between ticks, they hold their last output.

        Parameters
        ----------
        x : ndarray of shape (n_input,)

        Returns
        -------
        ndarray of shape (n_output,), final layer spikes
        """
        self._step_count += 1
        h = x.astype(np.float64)

        for i, (layer, interval) in enumerate(zip(self.layers, self.clock_intervals)):
            if self._step_count % interval == 0:
                spikes = layer.step(h, dt=dt * interval)
                self._last_outputs[i] = spikes
            h = self._last_outputs[i]

        return h

    def run(self, inputs: np.ndarray, dt: float = 1.0) -> np.ndarray:
        """Run full sequence.

        Parameters
        ----------
        inputs : ndarray of shape (T, n_input)

        Returns
        -------
        ndarray of shape (T, n_output)
        """
        self.reset()
        T = inputs.shape[0]
        n_out = self.layers[-1].n_neurons
        outputs = np.zeros((T, n_out))
        for t in range(T):
            outputs[t] = self.step(inputs[t], dt)
        return outputs

    def reset(self):
        self._step_count = 0
        for i, layer in enumerate(self.layers):
            layer.reset()
            self._last_outputs[i] = np.zeros(layer.n_neurons)

step(x, dt=1.0)

Process one global timestep.

Layers only update when their clock ticks. Between ticks, they hold their last output.

Parameters

x : ndarray of shape (n_input,)

Returns

ndarray of shape (n_output,), final layer spikes

Source code in src/sc_neurocore/temporal_hierarchy/multi_clock.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def step(self, x: np.ndarray, dt: float = 1.0) -> np.ndarray:
    """Process one global timestep.

    Layers only update when their clock ticks.
    Between ticks, they hold their last output.

    Parameters
    ----------
    x : ndarray of shape (n_input,)

    Returns
    -------
    ndarray of shape (n_output,), final layer spikes
    """
    self._step_count += 1
    h = x.astype(np.float64)

    for i, (layer, interval) in enumerate(zip(self.layers, self.clock_intervals)):
        if self._step_count % interval == 0:
            spikes = layer.step(h, dt=dt * interval)
            self._last_outputs[i] = spikes
        h = self._last_outputs[i]

    return h

run(inputs, dt=1.0)

Run full sequence.

Parameters

inputs : ndarray of shape (T, n_input)

Returns

ndarray of shape (T, n_output)

Source code in src/sc_neurocore/temporal_hierarchy/multi_clock.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def run(self, inputs: np.ndarray, dt: float = 1.0) -> np.ndarray:
    """Run full sequence.

    Parameters
    ----------
    inputs : ndarray of shape (T, n_input)

    Returns
    -------
    ndarray of shape (T, n_output)
    """
    self.reset()
    T = inputs.shape[0]
    n_out = self.layers[-1].n_neurons
    outputs = np.zeros((T, n_out))
    for t in range(T):
        outputs[t] = self.step(inputs[t], dt)
    return outputs

ClockDomain dataclass

One clock domain in a multi-clock SNN.

Parameters

name : str tick_interval : int Steps between updates (1 = every step, 10 = every 10th step). layers : list of str Layer names assigned to this clock domain.

Source code in src/sc_neurocore/temporal_hierarchy/multi_clock.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@dataclass
class ClockDomain:
    """One clock domain in a multi-clock SNN.

    Parameters
    ----------
    name : str
    tick_interval : int
        Steps between updates (1 = every step, 10 = every 10th step).
    layers : list of str
        Layer names assigned to this clock domain.
    """

    name: str
    tick_interval: int = 1
    layers: list[str] = field(default_factory=list)

HetSynLayer

Layer with heterogeneous per-synapse time constants.

Each synapse has its own tau, initialized log-normally (mean=5ms, std=1ms). The synaptic trace at each synapse decays at its own rate: trace[i,j] = exp(-dt/tau[i,j]) * trace[i,j] + input_spike[j]

Parameters

n_inputs : int n_neurons : int tau_mean : float Mean synaptic time constant (ms). tau_std : float Std of log(tau) for log-normal initialization. threshold : float seed : int

Source code in src/sc_neurocore/temporal_hierarchy/multi_clock.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 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
class HetSynLayer:
    """Layer with heterogeneous per-synapse time constants.

    Each synapse has its own tau, initialized log-normally (mean=5ms, std=1ms).
    The synaptic trace at each synapse decays at its own rate:
      trace[i,j] = exp(-dt/tau[i,j]) * trace[i,j] + input_spike[j]

    Parameters
    ----------
    n_inputs : int
    n_neurons : int
    tau_mean : float
        Mean synaptic time constant (ms).
    tau_std : float
        Std of log(tau) for log-normal initialization.
    threshold : float
    seed : int
    """

    def __init__(
        self,
        n_inputs: int,
        n_neurons: int,
        tau_mean: float = 5.0,
        tau_std: float = 1.0,
        threshold: float = 1.0,
        seed: int = 42,
    ):
        self.n_inputs = n_inputs
        self.n_neurons = n_neurons
        self.threshold = threshold

        rng = np.random.RandomState(seed)
        # Per-synapse time constants (log-normal)
        log_tau = np.log(tau_mean) + tau_std * rng.randn(n_neurons, n_inputs)
        self.tau = np.exp(log_tau)
        self.tau = np.clip(self.tau, 0.5, 100.0)

        self.W = rng.randn(n_neurons, n_inputs) * np.sqrt(2.0 / n_inputs)
        self._traces = np.zeros((n_neurons, n_inputs))
        self._v = np.zeros(n_neurons)

    def step(self, x: np.ndarray, dt: float = 1.0) -> np.ndarray:
        """Process one timestep.

        Parameters
        ----------
        x : ndarray of shape (n_inputs,)
        dt : float

        Returns
        -------
        ndarray of shape (n_neurons,), binary spikes
        """
        decay = np.exp(-dt / self.tau)
        self._traces = decay * self._traces + x[np.newaxis, :]
        current = (self.W * self._traces).sum(axis=1)
        self._v += current
        spikes = (self._v >= self.threshold).astype(np.float64)
        self._v -= spikes * self.threshold
        return spikes

    def reset(self):
        self._traces = np.zeros((self.n_neurons, self.n_inputs))
        self._v = np.zeros(self.n_neurons)

    @property
    def tau_stats(self) -> dict:
        return {
            "mean": float(self.tau.mean()),
            "std": float(self.tau.std()),
            "min": float(self.tau.min()),
            "max": float(self.tau.max()),
            "median": float(np.median(self.tau)),
        }

step(x, dt=1.0)

Process one timestep.

Parameters

x : ndarray of shape (n_inputs,) dt : float

Returns

ndarray of shape (n_neurons,), binary spikes

Source code in src/sc_neurocore/temporal_hierarchy/multi_clock.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def step(self, x: np.ndarray, dt: float = 1.0) -> np.ndarray:
    """Process one timestep.

    Parameters
    ----------
    x : ndarray of shape (n_inputs,)
    dt : float

    Returns
    -------
    ndarray of shape (n_neurons,), binary spikes
    """
    decay = np.exp(-dt / self.tau)
    self._traces = decay * self._traces + x[np.newaxis, :]
    current = (self.W * self._traces).sum(axis=1)
    self._v += current
    spikes = (self._v >= self.threshold).astype(np.float64)
    self._v -= spikes * self.threshold
    return spikes