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.
- py_mea_flow/__init__.py +6 -0
- py_mea_flow/burst/__init__.py +1 -0
- py_mea_flow/burst/detection.py +367 -0
- py_mea_flow/burst/metrics.py +359 -0
- py_mea_flow/cli.py +2429 -0
- py_mea_flow/io/__init__.py +1 -0
- py_mea_flow/io/metadata.py +182 -0
- py_mea_flow/io/spk_reader.py +611 -0
- py_mea_flow/network/__init__.py +1 -0
- py_mea_flow/network/detection.py +367 -0
- py_mea_flow/network/metrics.py +184 -0
- py_mea_flow/network/synchrony.py +279 -0
- py_mea_flow/pipeline.py +1039 -0
- py_mea_flow/spike/__init__.py +1 -0
- py_mea_flow/spike/metrics.py +282 -0
- py_mea_flow/stats/__init__.py +19 -0
- py_mea_flow/stats/compare.py +752 -0
- py_mea_flow/viz/__init__.py +1 -0
- py_mea_flow/viz/burst_charts.py +280 -0
- py_mea_flow/viz/comparison.py +371 -0
- py_mea_flow/viz/heatmap.py +174 -0
- py_mea_flow/viz/network_plots.py +198 -0
- py_mea_flow/viz/pca.py +372 -0
- py_mea_flow/viz/plate.py +168 -0
- py_mea_flow/viz/trajectory.py +226 -0
- py_mea_flow-0.5.0.dist-info/LICENSE +21 -0
- py_mea_flow-0.5.0.dist-info/METADATA +340 -0
- py_mea_flow-0.5.0.dist-info/RECORD +31 -0
- py_mea_flow-0.5.0.dist-info/WHEEL +5 -0
- py_mea_flow-0.5.0.dist-info/entry_points.txt +2 -0
- py_mea_flow-0.5.0.dist-info/top_level.txt +1 -0
py_mea_flow/__init__.py
ADDED
|
@@ -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
|