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 +5 -0
- fmatoolbox/analysis.py +475 -0
- fmatoolbox/data.py +546 -0
- fmatoolbox/exceptions.py +4 -0
- fmatoolbox/general.py +319 -0
- fmatoolbox/plotting.py +500 -0
- fmatoolbox/regions.py +524 -0
- fmatoolbox-0.1.0.dist-info/METADATA +31 -0
- fmatoolbox-0.1.0.dist-info/RECORD +12 -0
- fmatoolbox-0.1.0.dist-info/WHEEL +5 -0
- fmatoolbox-0.1.0.dist-info/licenses/LICENSE +7 -0
- fmatoolbox-0.1.0.dist-info/top_level.txt +1 -0
fmatoolbox/__init__.py
ADDED
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
|