zeechimp commited on
Commit
2af7088
Β·
verified Β·
1 Parent(s): 97277f9

Create modeling_spikecascade.py

Browse files
Files changed (1) hide show
  1. modeling_spikecascade.py +259 -0
modeling_spikecascade.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SpikeCascade: three-stage fidelity cascade for real-time spike sorting.
3
+
4
+ Stage 1: amplitude threshold (cheap, ~1 us).
5
+ Stage 2: CNN template match (medium, ~50 us).
6
+ Stage 3: full KMeans clustering (expensive, ~1 ms, ~1% of spikes).
7
+
8
+ The CascadeReport returned by `infer` implements the calibration
9
+ contract from the paper: it reports activity_fraction, stage3_fraction,
10
+ latency_ms, and correlation (NaN if the baseline is empty).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from typing import Dict, List, Optional
19
+
20
+ import numpy as np
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+ from transformers import PreTrainedModel
25
+
26
+ from configuration_spikecascade import SpikeCascadeConfig
27
+ from preprocessing import bandpass_filter, extract_snippets
28
+
29
+
30
+ # ─────────────────────────────────────────────────────────────────────
31
+ # Stage 2: CNN template matcher
32
+ # ─────────────────────────────────────────────────────────────────────
33
+
34
+ class TemplateCNN(nn.Module):
35
+ """
36
+ A small 1D CNN that maps a multi-channel spike snippet to a
37
+ probability distribution over a bank of learned templates.
38
+
39
+ Input: (batch, n_channels, template_len)
40
+ Output: (batch, template_bank_size) logits, (batch, hidden_dim)
41
+ """
42
+
43
+ def __init__(self, cfg: SpikeCascadeConfig):
44
+ super().__init__()
45
+ c1, c2, c3 = cfg.cnn_channels
46
+ self.conv1 = nn.Conv1d(cfg.n_channels, c1, kernel_size=7, padding=3)
47
+ self.conv2 = nn.Conv1d(c1, c2, kernel_size=5, padding=2)
48
+ self.conv3 = nn.Conv1d(c2, c3, kernel_size=3, padding=1)
49
+ self.pool = nn.AdaptiveAvgPool1d(1)
50
+ self.fc = nn.Linear(c3, cfg.hidden_dim)
51
+ self.head = nn.Linear(cfg.hidden_dim, cfg.template_bank_size)
52
+
53
+ def forward(self, x: torch.Tensor):
54
+ h = F.relu(self.conv1(x))
55
+ h = F.relu(self.conv2(h))
56
+ h = F.relu(self.conv3(h))
57
+ h = self.pool(h).squeeze(-1)
58
+ h = F.relu(self.fc(h))
59
+ return self.head(h), h
60
+
61
+
62
+ # ─────────────────────────────────────────────────────────────────────
63
+ # Cascade report
64
+ # ─────────────────────────────────────────────────────────────────────
65
+
66
+ @dataclass
67
+ class CascadeReport:
68
+ units: Dict[int, List[float]] = field(default_factory=dict)
69
+ activity_fraction: float = 0.0
70
+ stage3_fraction: float = 0.0
71
+ latency_ms: float = 0.0
72
+ correlation: float = float("nan")
73
+ warning: Optional[str] = None
74
+
75
+
76
+ # ─────────────────────────────────────────────────────────────────────
77
+ # The model
78
+ # ─────────────────────────────────────────────────────────────────────
79
+
80
+ class SpikeCascadeForSorting(PreTrainedModel):
81
+ config_class = SpikeCascadeConfig
82
+
83
+ def __init__(self, config: SpikeCascadeConfig):
84
+ super().__init__(config)
85
+ self.config = config
86
+ self.template_cnn = TemplateCNN(config)
87
+
88
+ # Template bank: (template_bank_size, n_channels, template_len).
89
+ # Populated by `set_template_bank` or loaded from the checkpoint.
90
+ self.register_buffer(
91
+ "template_bank",
92
+ torch.zeros(
93
+ config.template_bank_size,
94
+ config.n_channels,
95
+ config.template_len_samples,
96
+ ),
97
+ )
98
+ # Per-template confidence calibration (set during training).
99
+ self.register_buffer(
100
+ "template_prior", torch.ones(config.template_bank_size) / config.template_bank_size
101
+ )
102
+
103
+ # Stage 3 fallback: KMeans centroids. Populated at inference time.
104
+ self._stage3_centroids: Optional[np.ndarray] = None
105
+
106
+ # ── template bank management ───────────────────────────────────
107
+ def set_template_bank(self, templates: torch.Tensor, priors: Optional[torch.Tensor] = None):
108
+ """
109
+ templates: (template_bank_size, n_channels, template_len)
110
+ priors: (template_bank_size,) optional
111
+ """
112
+ assert templates.shape == self.template_bank.shape
113
+ self.template_bank.copy_(templates)
114
+ if priors is not None:
115
+ self.template_prior.copy_(priors / priors.sum())
116
+
117
+ # ── stage 1: threshold ─────────────────────────────────────────
118
+ def _stage1_detect(self, spike_band: np.ndarray) -> np.ndarray:
119
+ """
120
+ Returns indices (sample positions) where any channel crosses
121
+ |x| > threshold_sigma * robust_sigma.
122
+ """
123
+ sigma = np.median(np.abs(spike_band), axis=1, keepdims=True) / 0.6745
124
+ sigma = np.maximum(sigma, 1e-6)
125
+ crossings = (np.abs(spike_band) > self.config.threshold_sigma * sigma).any(axis=0)
126
+ return np.flatnonzero(crossings)
127
+
128
+ # ── stage 2: CNN template match ────────────────────────────────
129
+ @torch.no_grad()
130
+ def _stage2_match(self, snippets: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
131
+ """
132
+ snippets: (n_spikes, n_channels, template_len)
133
+ Returns: (unit_ids, confidences)
134
+ """
135
+ x = torch.from_numpy(snippets).float()
136
+ logits, _ = self.template_cnn(x)
137
+ probs = F.softmax(logits, dim=-1).numpy()
138
+ unit_ids = probs.argmax(axis=1)
139
+ confidences = probs.max(axis=1)
140
+ return unit_ids, confidences
141
+
142
+ # ── stage 3: KMeans fallback ───────────────────────────────────
143
+ def _stage3_cluster(self, snippets: np.ndarray) -> np.ndarray:
144
+ """
145
+ Full clustering on the ambiguous snippets. Fits KMeans on the
146
+ fly if no centroids are cached, then assigns.
147
+ """
148
+ from sklearn.cluster import KMeans
149
+
150
+ flat = snippets.reshape(snippets.shape[0], -1)
151
+ if self._stage3_centroids is None or self._stage3_centroids.shape[1] != flat.shape[1]:
152
+ k = min(self.config.stage3_kmeans_k, max(2, flat.shape[0] // 4))
153
+ km = KMeans(n_clusters=k, max_iter=self.config.stage3_max_iter, n_init=1)
154
+ labels = km.fit_predict(flat)
155
+ self._stage3_centroids = km.cluster_centers_
156
+ return labels + self.config.template_bank_size # offset so IDs do not collide
157
+ # Assign to nearest centroid
158
+ d = ((flat[:, None, :] - self._stage3_centroids[None, :, :]) ** 2).sum(axis=2)
159
+ return d.argmin(axis=1) + self.config.template_bank_size
160
+
161
+ # ── the cascade ────────────────────────────────────────────────
162
+ def infer(
163
+ self,
164
+ voltage: np.ndarray,
165
+ sample_rate: Optional[int] = None,
166
+ baseline_spike_counts: Optional[Dict[int, int]] = None,
167
+ ) -> CascadeReport:
168
+ """
169
+ voltage: (n_channels, n_samples), float32, raw or spike-band.
170
+ sample_rate: defaults to config.sample_rate.
171
+ baseline_spike_counts: optional {unit_id: count} from an offline
172
+ sort, used to compute the correlation. If None, correlation
173
+ is left as NaN and a warning is issued.
174
+ """
175
+ t0 = time.perf_counter()
176
+ sample_rate = sample_rate or self.config.sample_rate
177
+ n_channels, n_samples = voltage.shape
178
+ if n_channels != self.config.n_channels:
179
+ raise ValueError(
180
+ f"expected {self.config.n_channels} channels, got {n_channels}"
181
+ )
182
+
183
+ # Preprocess: bandpass to the spike band.
184
+ spike_band = bandpass_filter(
185
+ voltage, sample_rate, self.config.spike_band_hz[0], self.config.spike_band_hz[1]
186
+ )
187
+
188
+ # Stage 1.
189
+ spike_positions = self._stage1_detect(spike_band)
190
+ n_windows = max(1, n_samples - self.config.template_len_samples)
191
+ activity_fraction = len(spike_positions) / n_windows
192
+
193
+ if len(spike_positions) == 0:
194
+ # Zero-spike branch: report NaN, not 0.0.
195
+ latency_ms = (time.perf_counter() - t0) * 1000
196
+ return CascadeReport(
197
+ units={},
198
+ activity_fraction=0.0,
199
+ stage3_fraction=0.0,
200
+ latency_ms=latency_ms,
201
+ correlation=float("nan"),
202
+ warning=(
203
+ "zero spikes detected; correlation is NaN, not 0. "
204
+ "The speedup over an empty baseline is undefined."
205
+ ),
206
+ )
207
+
208
+ # Extract snippets centred on the crossings.
209
+ snippets = extract_snippets(
210
+ spike_band, spike_positions, self.config.template_len_samples
211
+ ) # (n_spikes, n_channels, template_len)
212
+
213
+ # Stage 2.
214
+ unit_ids_s2, conf_s2 = self._stage2_match(snippets)
215
+
216
+ # Stage 3: only the low-confidence spikes.
217
+ ambiguous = conf_s2 < self.config.template_confidence_threshold
218
+ stage3_fraction = ambiguous.mean()
219
+ if ambiguous.any():
220
+ unit_ids_s3 = self._stage3_cluster(snippets[ambiguous])
221
+ unit_ids = unit_ids_s2.copy()
222
+ unit_ids[ambiguous] = unit_ids_s3
223
+ else:
224
+ unit_ids = unit_ids_s2
225
+
226
+ # Convert positions to milliseconds.
227
+ spike_times_ms = spike_positions / sample_rate * 1000.0
228
+
229
+ # Build the per-unit spike-time dictionary.
230
+ units: Dict[int, List[float]] = {}
231
+ for uid, t in zip(unit_ids.tolist(), spike_times_ms.tolist()):
232
+ units.setdefault(int(uid), []).append(float(t))
233
+
234
+ # Correlation against the offline baseline (if provided).
235
+ correlation = float("nan")
236
+ if baseline_spike_counts is not None:
237
+ correlation = self._correlation(units, baseline_spike_counts)
238
+
239
+ latency_ms = (time.perf_counter() - t0) * 1000
240
+ return CascadeReport(
241
+ units=units,
242
+ activity_fraction=activity_fraction,
243
+ stage3_fraction=stage3_fraction,
244
+ latency_ms=latency_ms,
245
+ correlation=correlation,
246
+ )
247
+
248
+ @staticmethod
249
+ def _correlation(
250
+ units: Dict[int, List[float]], baseline: Dict[int, int]
251
+ ) -> float:
252
+ all_ids = sorted(set(units) | set(baseline))
253
+ if not all_ids:
254
+ return float("nan")
255
+ a = np.array([len(units.get(u, [])) for u in all_ids], dtype=float)
256
+ b = np.array([baseline.get(u, 0) for u in all_ids], dtype=float)
257
+ if a.std() == 0.0 or b.std() == 0.0:
258
+ return float("nan")
259
+ return float(np.corrcoef(a, b)[0, 1])