fmatoolbox 0.1.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.
fmatoolbox/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from . import analysis
2
+ from . import data
3
+ from . import general
4
+ from . import plotting
5
+ from . import regions
fmatoolbox/analysis.py ADDED
@@ -0,0 +1,475 @@
1
+ ''' Specialized analyses for FMAToolbox '''
2
+
3
+ import fmatoolbox.general
4
+ import numpy as np
5
+ import scipy as sp
6
+ import sklearn.decomposition as skdc
7
+ import skimage.filters as skif
8
+ import joblib
9
+ import statsmodels.stats.multitest
10
+ from typing import Callable
11
+
12
+
13
+ def firingRate(spikes, start:float=None, stop:float=None, bin_size:float=None, step:int=None, smooth:float=None, u_range:tuple[int,int]=None):
14
+ # estimate istantaneous firing rate from spike times
15
+ #
16
+ # arguments:
17
+ # spikes (n,:) float, every row is either [spike time] (s) or [spike time, unit id]
18
+ # start float = min(spike_times) s, time to start count at
19
+ # stop float = max(spike_times) s, time to stop count at
20
+ # bin_size float = 0.05 s, time bin to count spikes
21
+ # step int = 1, firing rate is computed in windows of length 'bin_size' and overlap 'bin_size' / 'step', default is no overlap
22
+ # smooth float = None, gaussian kernel std for smoothing over time
23
+ # u_range (2) int, range of units to consider in computation, default is [min(spikes[:,1]), max(spikes[:,1])]
24
+ # (boundaries included, only for 2-columns 'spikes')
25
+ #
26
+ # output:
27
+ # firing_rate (:,m+1) float, every row is [time stamp, firing rates for m units], m is 1 if spikes has just one column
28
+
29
+ # validate input
30
+ spikes = np.asarray(spikes)
31
+ if bin_size is None: bin_size = 0.05
32
+ if step is None: step = 1
33
+ if step % 1 or step == 0:
34
+ raise ValueError("'step' must be a non-zero integer")
35
+
36
+ units = []
37
+ if spikes.ndim == 1:
38
+ times = spikes
39
+ elif spikes.shape[1] == 1:
40
+ times = spikes.reshape(-1)
41
+ else:
42
+ times = spikes[:,0]
43
+ units = spikes[:,1]
44
+ if u_range is None:
45
+ u_range = np.unique(units).astype(int)[[0,-1]]
46
+ else:
47
+ u_range = np.array(u_range,dtype=int)
48
+
49
+ # build time bins, overlapping if requested
50
+ if start is None:
51
+ start = times.min()
52
+ if stop is None:
53
+ stop = times.max()
54
+ time_bins = [np.arange(start,stop+bin_size,bin_size) + i*bin_size/step for i in range(step)]
55
+
56
+ if len(units) == 0:
57
+ # compute firing rate once
58
+ firing_rate = [np.histogram(times,bins=tb)[0] for tb in time_bins]
59
+ # flatten and convert to Hz
60
+ firing_rate = np.array(firing_rate).reshape((-1,1),order='F') / bin_size
61
+ else:
62
+ # compute firing rate once per unit and stack into a matrix
63
+ firing_rate = []
64
+ for u in range(u_range[0],u_range[-1]+1):
65
+ fr = [np.histogram(times[units==u],bins=tb)[0] for tb in time_bins]
66
+ firing_rate.append(np.array(fr).flatten('F'))
67
+ firing_rate = np.array(firing_rate).T / bin_size
68
+
69
+ # center times into time bins
70
+ time_bins = [(tb[:-1] + tb[1:]) / 2 for tb in time_bins]
71
+ time_bins = np.array(time_bins).reshape((-1,1),order='F')
72
+
73
+ # apply smoothing
74
+ if smooth is not None:
75
+ firing_rate = sp.ndimage.gaussian_filter(firing_rate,smooth,axes=0)
76
+
77
+ return np.concatenate((time_bins,firing_rate),1)
78
+
79
+
80
+ def PETH(samples, events, groups=None, g_range:tuple[int,int]=None, limits:tuple[float,float]=None, n_bins:int=None, bin:float=None, step:int=None, fast:bool=False):
81
+ # compute peri-event time histogram of a signal relative to synchronizing events
82
+ #
83
+ # arguments:
84
+ # samples float, either:
85
+ # - (n) array of time stamps (s), describing a point process
86
+ # - (n,:) array, where each row is [time stamp (s), value1, ...], describing one or more continous signals
87
+ # events (m) float, synchronizing events' times, their order is maintained in the output 'mat'
88
+ # groups (n) int, grouping indeces for samples, to compute separate PETHs (only for point process 'samples')
89
+ # g_range (2) int = [0,max(groups)], min and max group id
90
+ # groups (:) int, grouping indeces for samples, to compute separate PETHs (only for point process 'samples')
91
+ # limits (2) float = [-0.5,0.5] (s), defines a window around events, divided into 'n_bins' time bins to compute PETH
92
+ # n_bins float = 101, number of time bins around event times
93
+ # bin float = None (s), bin size, can be given instead of 'n_bins', which will be deduced from 'bin' and 'limits'
94
+ # step int = 1, only for point-process 'samples', for values higher than 1, time bins inside a window will overlap, yielding:
95
+ # - bin_size of (limit[1]-limit[0]) / n_bins, unchanged
96
+ # - time resolution of bin_size / step
97
+ # fast bool = False, if True, 'samples' must be time sorted to save computation time (only for point process 'samples')
98
+ #
99
+ # output:
100
+ # mat (m,n_bins) float, every row corresponds to samples centered on an event
101
+ # t (n_bins) float, times (s)
102
+ # m (n_bins) float, average samples across events
103
+
104
+ # default values
105
+ samples = np.array(samples,ndmin=1)
106
+ point_process = samples.ndim == 1 or samples.shape[1] == 1
107
+ events = np.array(events,ndmin=1)
108
+ if point_process:
109
+ if groups is None:
110
+ groups = np.zeros(samples.shape[0],dtype=int)
111
+ n_groups = 1
112
+ g_range = None
113
+ squeeze = True # squeeze 'mat' to 2d when 'groups' is None
114
+ else:
115
+ groups = np.array(groups,ndmin=1,dtype=int)
116
+ if samples.shape[0] != groups.shape[0]:
117
+ raise ValueError("'samples' and 'groups' must have the same length")
118
+ n_groups = groups.max() + 1
119
+ squeeze = False
120
+ if g_range is not None:
121
+ groups = groups[(groups >= g_range[0]) & (groups <= g_range[1])]
122
+ groups -= int(g_range[0])
123
+ n_groups -= int(g_range[0])
124
+ else:
125
+ squeeze = samples.shape[1] < 3 # squeeze 'mat' to 2d when 'samples' has only one signal column
126
+ if limits is None: limits = [-0.5,0.5]
127
+ if n_bins is None: n_bins = 101 if bin is None else round((limits[1] - limits[0]) / bin)
128
+ if step is None: step = 1
129
+
130
+ # sort by time
131
+ if not fast:
132
+ sort_idx = np.argsort(samples) if samples.ndim == 1 else np.argsort(samples[:,0])
133
+ samples = samples[sort_idx]
134
+ if point_process:
135
+ groups = groups[sort_idx]
136
+
137
+ # 1: point process
138
+ if point_process:
139
+
140
+ # build time bins
141
+ t = np.linspace(limits[0],limits[1],n_bins+1)
142
+ t = (t[:-1] + t[1:]) / 2
143
+ t = np.linspace(t[0],t[-1],(n_bins-1)*step+1)
144
+ bin_width = (limits[1] - limits[0]) / n_bins
145
+ mat = np.zeros((len(events), n_bins*step, n_groups), dtype=int)
146
+
147
+ for i in range(step):
148
+ stride = i * bin_width / step
149
+
150
+ # find where events fall in samples
151
+ left = np.searchsorted(samples, events+limits[0]+stride, side='left')
152
+ right = np.searchsorted(samples, events+limits[1]+stride, side='right')
153
+ counts = right - left
154
+ valid = counts > 0
155
+ # repeat event indices according to how many samples they match
156
+ event_idx = np.repeat(np.arange(len(events))[valid],counts[valid])
157
+ sample_idx = np.concatenate([np.arange(l,r) for l, r in zip(left[valid],right[valid])])
158
+ # build lists of matches
159
+ e_rep = events[event_idx]
160
+ s_sel = samples[sample_idx]
161
+ g_sel = groups[sample_idx]
162
+ # assign matches to 'mat'
163
+ bin_ind = ((s_sel-e_rep-limits[0]-stride) / bin_width).astype(int)
164
+ bin_ind = np.clip(bin_ind, 0, n_bins - 1) # avoid numerical error
165
+ np.add.at(mat, (event_idx, bin_ind*step+i, g_sel), 1)
166
+
167
+ # if step != 1, discard last bins which go outisde limits
168
+ mat = mat[:,:(n_bins-1)*step+1,:]
169
+
170
+ # 2: time series
171
+ else:
172
+ # build time bins
173
+ t = np.linspace(limits[0],limits[1],n_bins)
174
+ # interpolate PETH matrix
175
+ t_mat = events.reshape((-1,1)) + t.reshape((1,-1)) # interpolation times around events
176
+ mat = np.stack( [np.interp(t_mat,samples[:,0],samples[:,i]) for i in range(1,samples.shape[1])], axis=-1)
177
+
178
+ # restore correct 'mat' shape
179
+ if squeeze:
180
+ mat = mat.reshape(mat.shape[:2])
181
+
182
+ m = np.mean(mat,axis=0)
183
+
184
+ return mat, t, m
185
+
186
+
187
+ def avalanchesFromProfile(x, threshold:float, time_step:float, t0:float=0):
188
+ # compute avalanches' sizes and [start,stop] intervals from a time series
189
+ #
190
+ # arguments:
191
+ # x (:) float, time series uniformly sampled in time
192
+ # threshold float in [0,100] (%), percentile of x used as a threshold
193
+ # time_step float, time distance (s) between two consecutive elements of x
194
+ # t0 float = 0 (s), time corresponding to first element of x
195
+ #
196
+ # output:
197
+ # sizes (n) float, avalanche sizes
198
+ # intervals (n,2) float, each row is an avalanche's [start, stop] interval (s)
199
+ # size_t (m) float, size over time, in which every avalanche is separated by a 0
200
+
201
+ x = np.array(x)
202
+
203
+ # threshold the signal
204
+ threshold = np.percentile(x, threshold)
205
+ x = x - threshold
206
+ x[x < 0] = 0
207
+
208
+ is_ok = np.concatenate(([True], (x[1:] != 0) | (x[:-1] != 0))) # is_ok[i] = 0 if i-th element is repeated zero
209
+
210
+ # sizes
211
+ size_t = x[is_ok] * time_step # remove repeated zeros, obtaining size per bin: size over time
212
+ sizes = np.bincount(np.cumsum(size_t == 0) - (x[0] == 0), weights=size_t)
213
+ # remove last zero
214
+ if sizes[-1] == 0:
215
+ sizes = sizes[:-1]
216
+
217
+ # start and stop times
218
+ start = np.where(np.concatenate(([x[0] != 0], (x[1:] != 0) & (x[:-1] == 0))))[0]
219
+ stop = np.where(np.concatenate(((x[1:] == 0) & (x[:-1] != 0), [x[-1] != 0])))[0] + 1
220
+ intervals = np.stack((start, stop), 1) * time_step + t0 - time_step / 2
221
+
222
+ return sizes, intervals, size_t
223
+
224
+
225
+ def cellAssembliesICA(spikes, window:float=None, when=None, drop_mix:bool=False):
226
+ # detect assemblies from spike trains with PCA + ICA
227
+
228
+ if window is None: window = 0.025
229
+
230
+ raster = firingRate(spikes,bin_size=window)
231
+ raster[:,1:] *= window # convert to counts
232
+
233
+ time = raster[:,0]
234
+ n = raster[:,1:] # discard time column
235
+ if when is not None:
236
+ time, valid = fmatoolbox.general.restrict(time,when,s_ind=True)
237
+ n = n[valid]
238
+
239
+ # remove units which never spiked to avoid cov error
240
+ keep = ~(n==0).all(axis=0)
241
+ n = n[:,keep]
242
+
243
+ # correlation matrix
244
+ n = sp.stats.zscore(n,axis=0)
245
+ n_times, n_units = n.shape
246
+ corr = np.cov(n.T)
247
+ eigenvalues, eigenvectors = sp.linalg.eigh(corr) # each column of 'eigenvectors' is an eigenvector
248
+
249
+ # keep only significant eigenvectors according to MP distribution criteria CITE PAPER
250
+ q = n_times / n_units
251
+ lambda_max = (1 + np.sqrt(1 / q)) ** 2
252
+ #lambda_max += n_units**(-2/3) # Tracy-Widom correction
253
+ significant = eigenvalues > lambda_max
254
+ eigenvalues = eigenvalues[significant]
255
+ eigenvectors = eigenvectors[:,significant]
256
+
257
+ # run ICA
258
+ projection = ((eigenvectors @ eigenvectors.T) @ n.T).T
259
+ n_components = sum(significant)
260
+ ica = skdc.FastICA(n_components=n_components,max_iter=1000)
261
+ ica = ica.fit(projection)
262
+ weights = ica.components_.T # (units, components)
263
+
264
+ # normalize weights as in Van de Ven et al (2016)
265
+ weights /= np.linalg.norm(weights,axis=0)
266
+
267
+ # sort by variance of the projected signals, which is NOT explained variance per component (as they are not orthogonal)
268
+ activity = n @ weights # (times, components)
269
+ variance = activity.var(axis=0,ddof=1) / n_units
270
+ order = np.argsort(-variance)
271
+ variance = variance[order]
272
+ weights = weights[:,order]
273
+
274
+ # identify assembly members (will have to choose one of two methods...)
275
+ weights_otsu = weights.copy()
276
+ weights_morici = weights.copy()
277
+ # 1. Otsu threhsolding
278
+ for c in range(n_components):
279
+ w = weights[:,c]
280
+ thresh = skif.threshold_otsu(np.abs(w))
281
+ mask = np.abs(w) > thresh
282
+ weights_otsu[~mask,c] = 0
283
+ # 2. thresholding from Morici et al (2026), identifying features with an above average contrubtion (if weigth vectors have unit norm, all elements are 1 / np.sqrt(n_units) for a "uniform" vector)
284
+ mask = np.abs(weights) > 1 / np.sqrt(n_units)
285
+ weights_morici[~mask] = 0
286
+ weights = weights_otsu
287
+
288
+ # keep only components with no negative "strong" weights
289
+ if drop_mix:
290
+ remove = np.any(weights < 0,axis=0)
291
+ weights = weights[:,~remove]
292
+ eigenvalues = eigenvalues[~remove]
293
+ n_components = sum(~remove)
294
+
295
+ # flip signs (as signs are defined up to a per-component flip)
296
+ #flip = weights.max(axis=0) < -weights.min(axis=0) # let argmax(abs( )) be positive
297
+ flip = np.sum(weights > 1e-7,axis=0) < np.sum(weights < -1e-7,axis=0) # let most elements be positive
298
+ weights[:,flip] *= -1
299
+
300
+ # reintroduce units which never spiked
301
+ if not keep.all():
302
+ weights_old = weights.copy()
303
+ weights = np.zeros((len(keep),n_components))
304
+ weights[keep,:] = weights_old
305
+ n_units = len(keep)
306
+
307
+ # templates, note that they are independent to the sign flip of weight vectors
308
+ templates = np.empty((n_units,n_units,n_components))
309
+ for i in range(n_components):
310
+ template = np.outer(weights[:,i],weights[:,i])
311
+ np.fill_diagonal(template,0) # remove the diagonal
312
+ templates[:,:,i] = template
313
+
314
+ return weights, templates, raster
315
+
316
+
317
+ def reactivationStrength(raster, templates, threshold:float=5):
318
+ # compute reactivation strength of assemblies as quadratic forms between raster and templates
319
+
320
+ def template_strength(template):
321
+ return np.nansum(raster * (raster @ template), axis=1)
322
+
323
+ time = raster[:,0]
324
+ raster = raster[:,1:]
325
+
326
+ # following Morici et al. (2026), smooth and z-score raster
327
+ raster = sp.ndimage.gaussian_filter(raster,0.5,axes=0)
328
+ raster = sp.stats.zscore(raster,axis=0)
329
+
330
+ n_templates = templates.shape[2]
331
+ strength = np.column_stack(joblib.Parallel(n_jobs=-1)(joblib.delayed(template_strength)(templates[:,:,i]) for i in range(n_templates)))
332
+
333
+ # following Morici et al. (2026), peaks are avalanches in reactivation with threshold 5
334
+ peaks = []
335
+ for col in range(strength.shape[1]):
336
+ indices, properties = sp.signal.find_peaks(strength[:,col],height=threshold)
337
+ peaks.append(np.column_stack((time[indices],strength[indices,col])))
338
+
339
+ strength = np.column_stack((time,strength))
340
+
341
+ return strength, peaks
342
+
343
+
344
+ # --- statistics functions ---
345
+
346
+ def MCpValue(surrogate,real,alternative='two-sided'):
347
+ """
348
+ Compute Monte Carlo p-values comparing real statistics to surrogate distributions
349
+
350
+ Parameters
351
+ ----------
352
+ surrogate : array_like, shape (n_surrogates, n_features)
353
+ surrogate statistics
354
+ real : array_like, shape (n_features,)
355
+ observed statistics
356
+ alternative : {"two-sided", "greater", "less"}
357
+ direction of the test
358
+
359
+ Returns
360
+ -------
361
+ pvals : ndarray, shape (n_features,)
362
+ Monte Carlo p-values
363
+ """
364
+
365
+ surrogate = np.asarray(surrogate)
366
+ real = np.asarray(real).ravel()
367
+ if surrogate.ndim == 1:
368
+ surrogate = surrogate.reshape((-1,1))
369
+ if surrogate.shape[1] != real.shape[0]:
370
+ raise ValueError("real must have one element for every column of surrogates")
371
+
372
+ if alternative == "greater":
373
+ count = np.sum(surrogate >= real, axis=0)
374
+
375
+ elif alternative == "less":
376
+ count = np.sum(surrogate <= real, axis=0)
377
+
378
+ elif alternative == "two-sided":
379
+ greater = np.sum(surrogate >= real, axis=0)
380
+ less = np.sum(surrogate <= real, axis=0)
381
+ count = 2 * np.minimum(greater, less)
382
+
383
+ else:
384
+ raise ValueError("alternative must be 'greater', 'less', or 'two-sided'")
385
+
386
+ pvals = (count + 1) / (surrogate.shape[0] + 1) # +1 implement finite-sample Monte Carlo correction
387
+
388
+ return np.minimum(pvals, 1.0)
389
+
390
+
391
+ def holmBonferroni(pvals,alpha=0.05,return_reject=False):
392
+ """
393
+ Holm-Bonferroni correction for multiple tests
394
+
395
+ Parameters
396
+ ----------
397
+ pvals : array-like
398
+ array of p-values, NaNs are ignored in the correction procedure and propagated in output
399
+ alpha : float = 0.05
400
+ significance level
401
+ return_reject : bool = False
402
+ whether to also return rejection decisions
403
+
404
+ Returns
405
+ -------
406
+ corrected : ndarray
407
+ adjusted p-values, preserves input shape
408
+ reject : ndarray of bool, optional
409
+ true for hypothesis that can be rejected for given alpha, preserves input shape
410
+ """
411
+
412
+ pvals = np.asarray(pvals)
413
+ original_shape = pvals.shape
414
+ flat = pvals.ravel()
415
+ valid_mask = np.isfinite(flat) # valid (non-NaN) p-values
416
+ corrected_flat = np.full_like(flat,np.nan,dtype=float)
417
+ reject_flat = np.full_like(flat,False,dtype=bool)
418
+
419
+ if valid_mask.any():
420
+ reject, corrected, _, _ = statsmodels.stats.multitest.multipletests(flat[valid_mask],alpha=alpha,method="holm")
421
+ corrected_flat[valid_mask] = corrected
422
+ reject_flat[valid_mask] = reject
423
+
424
+ # restore original shape
425
+ corrected = corrected_flat.reshape(original_shape)
426
+ reject = reject_flat.reshape(original_shape)
427
+
428
+ if return_reject:
429
+ return corrected, reject
430
+ return corrected
431
+
432
+
433
+ def maxStatisticTest(data, surrogate, statistic=None, alpha:float=0.05, alternative:str='two-sided'):
434
+ # conduct a max statistic test over time
435
+ #
436
+ # arguments:
437
+ # data (n sessions, n times)
438
+ # surrogate (n session, n times, n surrogates)
439
+
440
+ data = np.array(data,ndmin=2)
441
+ surrogate = np.array(surrogate,ndmin=2)
442
+ if data.shape[:2] != surrogate.shape[:2]:
443
+ raise ValueError("'data' and 'surrogate' must have the same first two dimensions")
444
+ if surrogate.ndim != 3:
445
+ raise ValueError("'surrogate' must have dimensions (sessions, times, surrogates)")
446
+ if statistic is None:
447
+ statistic = lambda x : np.nanmean(x,axis=0)
448
+ n_times = data.shape[1]
449
+ n_surrogates = surrogate.shape[2]
450
+
451
+ # statistic for real and surrogate data
452
+ s_real = statistic(data) # (n_times,)
453
+ s_surrogate = np.zeros((n_times,n_surrogates)) # (n_times,n_surrogates)
454
+ for i in range(n_surrogates):
455
+ s_surrogate[:,i] = statistic(surrogate[:,:,i])
456
+
457
+ # p-values per time point
458
+ if alternative == 'greater':
459
+ s_surrogate = np.min(s_surrogate,axis=0) # (n_surrogates,)
460
+ p = MCpValue(np.tile(s_surrogate,(n_times,1)).T,s_real,alternative) # (n_times,)
461
+ elif alternative == 'less':
462
+ s_surrogate = np.max(s_surrogate,axis=0)
463
+ p = MCpValue(np.tile(s_surrogate,(n_times,1)).T,s_real,alternative)
464
+ elif alternative == 'two-sided':
465
+ # standardize statistic to ensure proper two-tailed test
466
+ mu = np.mean(s_surrogate,axis=1) # (n_times,)
467
+ sigma = np.std(s_surrogate,axis=1,ddof=1)
468
+ s_real = np.abs((s_real - mu) / sigma) # abs(z-score( ))
469
+ s_surrogate = (s_surrogate - mu.reshape(-1,1)) / sigma.reshape(-1,1)
470
+ s_surrogate = np.max(np.abs(s_surrogate),axis=0) # max_t(abs(z-score( ))), i.e., (n_surrogates,)
471
+ p = MCpValue(np.tile(s_surrogate,(n_times,1)).T,s_real,"greater")
472
+ else:
473
+ raise ValueError("'alternative' must be 'two-sided', 'greater' or 'less'")
474
+
475
+ return p < alpha