py-mea-flow 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """py_mea_flow — end-to-end MEA data analysis for Axion Biosystems instruments."""
2
+
3
+ from py_mea_flow.pipeline import MEAExperiment
4
+
5
+ __version__ = "0.5.0"
6
+ __all__ = ["MEAExperiment"]
@@ -0,0 +1 @@
1
+ """Burst detection and burst-level metrics."""
@@ -0,0 +1,367 @@
1
+ """
2
+ burst/detection.py
3
+ ==================
4
+ Burst detection from spike timestamp arrays.
5
+
6
+ Two algorithms are implemented:
7
+
8
+ ``isi_threshold`` (default)
9
+ A burst begins when the inter-spike interval drops below
10
+ *max_isi_s* (default 100 ms, matching the AxIS Navigator default).
11
+ Consecutive bursts separated by less than *min_ibi_s* are merged.
12
+ Burst candidates with fewer than *min_spikes* spikes are discarded.
13
+
14
+ ``poisson_surprise``
15
+ The Poisson Surprise method of Legendy & Salcman (1985). Each
16
+ candidate burst is scored by S = −ln P, where P is the probability
17
+ of observing ≥ n spikes in the burst duration by chance given the
18
+ electrode's mean firing rate. Bursts with S < *surprise_threshold*
19
+ are discarded.
20
+
21
+ Public API
22
+ ----------
23
+ Burst (namedtuple)
24
+ detect_bursts(spike_times, ...)
25
+ """
26
+
27
+ import math
28
+ from collections import namedtuple
29
+ from typing import List, Optional
30
+
31
+ import numpy as np
32
+ from scipy.stats import poisson as _poisson_dist
33
+
34
+ # ── Burst namedtuple ──────────────────────────────────────────────────────────
35
+
36
+ Burst = namedtuple(
37
+ "Burst",
38
+ [
39
+ "start_time", # float — first spike timestamp (s)
40
+ "end_time", # float — last spike timestamp (s)
41
+ "spike_times", # ndarray — timestamps of all spikes in burst (s)
42
+ "n_spikes", # int — spike count
43
+ "duration", # float — end_time − start_time (s)
44
+ "mean_isi_within", # float — mean ISI within the burst (s)
45
+ "median_isi_within", # float — median ISI within the burst (s)
46
+ ],
47
+ )
48
+
49
+
50
+ # ── Public entry point ────────────────────────────────────────────────────────
51
+
52
+ def detect_bursts(
53
+ spike_times: np.ndarray,
54
+ max_isi_s: float = 0.1,
55
+ min_spikes: int = 5,
56
+ min_ibi_s: float = 0.0,
57
+ algorithm: str = "isi_threshold",
58
+ surprise_threshold: float = 2.0,
59
+ ) -> List[Burst]:
60
+ """Detect bursts in a single electrode's spike train.
61
+
62
+ Parameters
63
+ ----------
64
+ spike_times : np.ndarray
65
+ 1-D array of spike timestamps in **seconds**, sorted ascending.
66
+ max_isi_s : float, optional
67
+ ISI-threshold algorithm: maximum within-burst inter-spike interval
68
+ in seconds. Default 0.1 s (100 ms), matching AxIS Navigator.
69
+ Poisson Surprise algorithm: used as the initial candidate ISI
70
+ threshold (defaults to 1 / mean_firing_rate if not overridden).
71
+ min_spikes : int, optional
72
+ Minimum number of spikes required for a valid burst. Default 5.
73
+ min_ibi_s : float, optional
74
+ Minimum inter-burst interval in seconds. Pairs of bursts with a
75
+ gap smaller than this are merged. Default 0.0 s (no merging),
76
+ matching the NeuralMetric Tools ISI-threshold behaviour.
77
+ algorithm : {'isi_threshold', 'poisson_surprise'}, optional
78
+ Burst detection algorithm to use. Default ``'isi_threshold'``.
79
+ surprise_threshold : float, optional
80
+ Poisson Surprise only. Minimum surprise score S = −ln P to
81
+ accept a burst candidate. Default 2.0 (≈ p < 0.135).
82
+
83
+ Returns
84
+ -------
85
+ list of Burst
86
+ Detected bursts sorted by ``start_time``. Empty list when no
87
+ bursts are found or the spike train is too short.
88
+
89
+ Raises
90
+ ------
91
+ ValueError
92
+ If *algorithm* is not one of the supported values.
93
+
94
+ Examples
95
+ --------
96
+ >>> spikes = np.array([0.01, 0.02, 0.03, 0.04, 0.05, # burst 1
97
+ ... 2.00, 2.01, 2.02, 2.03, 2.04]) # burst 2
98
+ >>> bursts = detect_bursts(spikes, max_isi_s=0.1, min_spikes=5)
99
+ >>> len(bursts)
100
+ 2
101
+ >>> round(bursts[0].start_time, 3)
102
+ 0.01
103
+ """
104
+ spike_times = np.asarray(spike_times, dtype=np.float64)
105
+
106
+ if algorithm == "isi_threshold":
107
+ return _detect_isi_threshold(spike_times, max_isi_s, min_spikes, min_ibi_s)
108
+ elif algorithm == "poisson_surprise":
109
+ return _detect_poisson_surprise(
110
+ spike_times, max_isi_s, min_spikes, min_ibi_s, surprise_threshold
111
+ )
112
+ else:
113
+ raise ValueError(
114
+ f"Unknown algorithm '{algorithm}'. "
115
+ "Choose 'isi_threshold' or 'poisson_surprise'."
116
+ )
117
+
118
+
119
+ # ── ISI-threshold algorithm ───────────────────────────────────────────────────
120
+
121
+ def _detect_isi_threshold(
122
+ spike_times: np.ndarray,
123
+ max_isi_s: float,
124
+ min_spikes: int,
125
+ min_ibi_s: float,
126
+ ) -> List[Burst]:
127
+ """Core ISI-threshold burst detection."""
128
+ if len(spike_times) < min_spikes:
129
+ return []
130
+
131
+ # Step 1 — find runs of consecutive spikes with ISI < max_isi_s.
132
+ groups = _find_linked_groups(spike_times, max_isi_s)
133
+
134
+ # Step 2 — discard groups with too few spikes.
135
+ groups = [(s, e) for s, e in groups if (e - s + 1) >= min_spikes]
136
+
137
+ if not groups:
138
+ return []
139
+
140
+ # Step 3 — merge groups whose gap is less than min_ibi_s.
141
+ groups = _merge_groups(groups, spike_times, min_ibi_s)
142
+
143
+ # Step 4 — re-filter after merging (merging can create valid-sized bursts
144
+ # from small fragments, but also discard previously valid ones split
145
+ # across a merge boundary — re-check is cheap).
146
+ groups = [(s, e) for s, e in groups if (e - s + 1) >= min_spikes]
147
+
148
+ # Step 5 — build Burst namedtuples.
149
+ return [_make_burst(spike_times, s, e) for s, e in groups]
150
+
151
+
152
+ def _find_linked_groups(
153
+ spike_times: np.ndarray,
154
+ max_isi_s: float,
155
+ ) -> List[tuple]:
156
+ """Return ``(start_idx, end_idx)`` pairs for runs of short-ISI spikes."""
157
+ isis = np.diff(spike_times)
158
+ links = isis < max_isi_s # links[i] = True ↔ spikes i & i+1 are paired
159
+
160
+ groups = []
161
+ i = 0
162
+ n = len(links)
163
+ while i < n:
164
+ if links[i]:
165
+ j = i + 1
166
+ while j < n and links[j]:
167
+ j += 1
168
+ # Spikes i through j form a burst (j - i + 1 spikes).
169
+ groups.append((i, j))
170
+ i = j
171
+ else:
172
+ i += 1
173
+
174
+ return groups
175
+
176
+
177
+ def _merge_groups(
178
+ groups: List[tuple],
179
+ spike_times: np.ndarray,
180
+ min_ibi_s: float,
181
+ ) -> List[tuple]:
182
+ """Merge adjacent groups whose gap (IBI) is less than *min_ibi_s*."""
183
+ merged = [groups[0]]
184
+ for start, end in groups[1:]:
185
+ prev_start, prev_end = merged[-1]
186
+ gap = spike_times[start] - spike_times[prev_end]
187
+ if gap < min_ibi_s:
188
+ merged[-1] = (prev_start, end)
189
+ else:
190
+ merged.append((start, end))
191
+ return merged
192
+
193
+
194
+ def _make_burst(spike_times: np.ndarray, start_idx: int, end_idx: int) -> Burst:
195
+ """Construct a Burst namedtuple from a spike index range."""
196
+ burst_spikes = spike_times[start_idx : end_idx + 1]
197
+ isis = np.diff(burst_spikes)
198
+ mean_isi = float(np.mean(isis)) if len(isis) > 0 else float("nan")
199
+ median_isi = float(np.median(isis)) if len(isis) > 0 else float("nan")
200
+ return Burst(
201
+ start_time=float(burst_spikes[0]),
202
+ end_time=float(burst_spikes[-1]),
203
+ spike_times=burst_spikes,
204
+ n_spikes=len(burst_spikes),
205
+ duration=float(burst_spikes[-1] - burst_spikes[0]),
206
+ mean_isi_within=mean_isi,
207
+ median_isi_within=median_isi,
208
+ )
209
+
210
+
211
+ # ── Poisson Surprise algorithm ────────────────────────────────────────────────
212
+
213
+ def _detect_poisson_surprise(
214
+ spike_times: np.ndarray,
215
+ max_isi_s: float,
216
+ min_spikes: int,
217
+ min_ibi_s: float,
218
+ surprise_threshold: float,
219
+ ) -> List[Burst]:
220
+ """Poisson Surprise burst detection (Legendy & Salcman, 1985).
221
+
222
+ Strategy
223
+ --------
224
+ 1. Estimate the baseline mean firing rate λ from the full spike train.
225
+ 2. Use *max_isi_s* (or 1/λ if max_isi_s is larger) to seed initial
226
+ burst candidates — the same linked-group logic as the ISI-threshold
227
+ method.
228
+ 3. For each candidate, greedily extend the burst window by one spike
229
+ at a time (both forward and backward) as long as the surprise score
230
+ S = −ln P(X ≥ n | Poisson(λ·T)) increases.
231
+ 4. Keep bursts with S ≥ *surprise_threshold* and n ≥ *min_spikes*.
232
+ 5. Merge overlapping or adjacent bursts and re-filter.
233
+ """
234
+ if len(spike_times) < min_spikes:
235
+ return []
236
+
237
+ total_duration = float(spike_times[-1] - spike_times[0])
238
+ if total_duration <= 0:
239
+ return []
240
+
241
+ mean_rate = (len(spike_times) - 1) / total_duration # spikes per second
242
+
243
+ # Seed threshold: use the smaller of max_isi_s and 1/mean_rate.
244
+ if mean_rate > 0:
245
+ seed_isi = min(max_isi_s, 1.0 / mean_rate)
246
+ else:
247
+ seed_isi = max_isi_s
248
+
249
+ candidates = _find_linked_groups(spike_times, seed_isi)
250
+ candidates = [(s, e) for s, e in candidates if (e - s + 1) >= min_spikes]
251
+
252
+ if not candidates:
253
+ return []
254
+
255
+ n_spikes_total = len(spike_times)
256
+ refined: List[tuple] = []
257
+
258
+ for seed_start, seed_end in candidates:
259
+ best_s, best_start, best_end = _optimise_surprise(
260
+ spike_times, seed_start, seed_end, mean_rate, n_spikes_total
261
+ )
262
+ if best_s >= surprise_threshold and (best_end - best_start + 1) >= min_spikes:
263
+ refined.append((best_start, best_end))
264
+
265
+ if not refined:
266
+ return []
267
+
268
+ # Remove overlapping bursts (keep higher-surprise one — greedy).
269
+ refined = _remove_overlaps(refined, spike_times, mean_rate)
270
+ refined = _merge_groups(refined, spike_times, min_ibi_s)
271
+ refined = [(s, e) for s, e in refined if (e - s + 1) >= min_spikes]
272
+
273
+ return [_make_burst(spike_times, s, e) for s, e in refined]
274
+
275
+
276
+ def _poisson_surprise_score(n: int, duration: float, mean_rate: float) -> float:
277
+ """Compute S = −ln P(X ≥ n | Poisson(λ·T)).
278
+
279
+ Returns ``−inf`` for degenerate inputs.
280
+ """
281
+ if duration <= 0 or mean_rate <= 0 or n < 1:
282
+ return -math.inf
283
+ mu = mean_rate * duration
284
+ # sf(k) = P(X > k) = P(X ≥ k+1); so sf(n-1) = P(X ≥ n).
285
+ p = float(_poisson_dist.sf(n - 1, mu))
286
+ if p <= 0:
287
+ return 700.0 # cap at −ln(~0) ≈ very large
288
+ return -math.log(p)
289
+
290
+
291
+ def _optimise_surprise(
292
+ spike_times: np.ndarray,
293
+ seed_start: int,
294
+ seed_end: int,
295
+ mean_rate: float,
296
+ n_total: int,
297
+ ) -> tuple:
298
+ """Greedily extend a candidate burst to maximise surprise score.
299
+
300
+ Tries extending by one spike forward and one spike backward at each
301
+ step; accepts the extension that most increases S. Stops when no
302
+ extension improves S.
303
+
304
+ Returns
305
+ -------
306
+ (best_s, best_start, best_end) : tuple[float, int, int]
307
+ """
308
+ cur_start, cur_end = seed_start, seed_end
309
+
310
+ def score(s, e):
311
+ T = spike_times[e] - spike_times[s]
312
+ n = e - s + 1
313
+ return _poisson_surprise_score(n, T, mean_rate)
314
+
315
+ best_s = score(cur_start, cur_end)
316
+
317
+ while True:
318
+ improved = False
319
+
320
+ # Try extending forward.
321
+ if cur_end + 1 < n_total:
322
+ s_fwd = score(cur_start, cur_end + 1)
323
+ if s_fwd > best_s:
324
+ best_s = s_fwd
325
+ cur_end += 1
326
+ improved = True
327
+
328
+ # Try extending backward.
329
+ if cur_start - 1 >= 0:
330
+ s_bwd = score(cur_start - 1, cur_end)
331
+ if s_bwd > best_s:
332
+ best_s = s_bwd
333
+ cur_start -= 1
334
+ improved = True
335
+
336
+ if not improved:
337
+ break
338
+
339
+ return best_s, cur_start, cur_end
340
+
341
+
342
+ def _remove_overlaps(
343
+ groups: List[tuple],
344
+ spike_times: np.ndarray,
345
+ mean_rate: float,
346
+ ) -> List[tuple]:
347
+ """Remove overlapping burst groups, keeping the one with higher S."""
348
+ if len(groups) <= 1:
349
+ return groups
350
+
351
+ # Sort by start index.
352
+ groups = sorted(groups, key=lambda g: g[0])
353
+
354
+ def score(s, e):
355
+ T = spike_times[e] - spike_times[s]
356
+ return _poisson_surprise_score(e - s + 1, T, mean_rate)
357
+
358
+ kept = [groups[0]]
359
+ for cur_start, cur_end in groups[1:]:
360
+ prev_start, prev_end = kept[-1]
361
+ if cur_start <= prev_end: # overlap
362
+ if score(cur_start, cur_end) > score(prev_start, prev_end):
363
+ kept[-1] = (cur_start, cur_end)
364
+ else:
365
+ kept.append((cur_start, cur_end))
366
+
367
+ return kept