fmatoolbox 0.1.8__tar.gz → 0.2.0__tar.gz
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-0.1.8/src/fmatoolbox.egg-info → fmatoolbox-0.2.0}/PKG-INFO +6 -6
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/pyproject.toml +6 -6
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/__init__.py +1 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/analysis.py +3 -3
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/data.py +162 -92
- fmatoolbox-0.2.0/src/fmatoolbox/general.py +178 -0
- fmatoolbox-0.2.0/src/fmatoolbox/intervals.py +299 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/plotting.py +5 -3
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/preprocess.py +3 -2
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/regions.py +34 -36
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0/src/fmatoolbox.egg-info}/PKG-INFO +6 -6
- fmatoolbox-0.1.8/src/fmatoolbox/general.py +0 -439
- fmatoolbox-0.1.8/src/fmatoolbox/intervals.py +0 -104
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/LICENSE +0 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/README.md +0 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/setup.cfg +0 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/exceptions.py +0 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/statistics.py +0 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox.egg-info/SOURCES.txt +0 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox.egg-info/dependency_links.txt +0 -0
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox.egg-info/requires.txt +4 -4
- {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox.egg-info/top_level.txt +0 -0
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fmatoolbox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python port of FMAToolbox
|
|
5
5
|
Author: Pietro Bozzo
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
7
7
|
Project-URL: Homepage, https://github.com/pietro-bozzo/FMAToolboxPython
|
|
8
8
|
Project-URL: Repository, https://github.com/pietro-bozzo/FMAToolboxPython
|
|
9
9
|
Project-URL: Issues, https://github.com/pietro-bozzo/FMAToolboxPython/issues
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist:
|
|
13
|
+
Requires-Dist: loky
|
|
14
14
|
Requires-Dist: numba
|
|
15
|
-
Requires-Dist:
|
|
15
|
+
Requires-Dist: numpy
|
|
16
16
|
Requires-Dist: matplotlib
|
|
17
|
+
Requires-Dist: scipy
|
|
17
18
|
Requires-Dist: statsmodels
|
|
18
|
-
Requires-Dist: loky
|
|
19
19
|
Provides-Extra: assemblies
|
|
20
|
+
Requires-Dist: joblib; extra == "assemblies"
|
|
20
21
|
Requires-Dist: scikit-learn; extra == "assemblies"
|
|
21
22
|
Requires-Dist: scikit-image; extra == "assemblies"
|
|
22
|
-
Requires-Dist: joblib; extra == "assemblies"
|
|
23
23
|
Provides-Extra: hierarch
|
|
24
24
|
Requires-Dist: hierarch; extra == "hierarch"
|
|
25
25
|
Requires-Dist: pandas; extra == "hierarch"
|
|
@@ -4,27 +4,27 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fmatoolbox"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python port of FMAToolbox"
|
|
9
9
|
authors = [{name = "Pietro Bozzo"}]
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
license = "GPL-3.0-or-later"
|
|
12
12
|
|
|
13
|
-
requires-python = ">=3.
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
14
|
dependencies = [
|
|
15
|
-
"
|
|
15
|
+
"loky",
|
|
16
16
|
"numba",
|
|
17
|
-
"
|
|
17
|
+
"numpy",
|
|
18
18
|
"matplotlib",
|
|
19
|
+
"scipy",
|
|
19
20
|
"statsmodels",
|
|
20
|
-
"loky",
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
24
|
assemblies = [
|
|
25
|
+
"joblib",
|
|
25
26
|
"scikit-learn",
|
|
26
27
|
"scikit-image",
|
|
27
|
-
"joblib",
|
|
28
28
|
]
|
|
29
29
|
hierarch = [
|
|
30
30
|
"hierarch",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
""" Specialized analyses for FMAToolbox """
|
|
2
2
|
|
|
3
|
-
import fmatoolbox.
|
|
3
|
+
import fmatoolbox.intervals
|
|
4
4
|
import numpy as np
|
|
5
5
|
import scipy as sp
|
|
6
6
|
import statsmodels.stats.multitest
|
|
@@ -553,7 +553,7 @@ def cellAssembliesICA(spikes, window:float=None, when=None, drop_mix:bool=False)
|
|
|
553
553
|
time = raster[:,0]
|
|
554
554
|
n = raster[:,1:] # discard time column
|
|
555
555
|
if when is not None:
|
|
556
|
-
time, valid = fmatoolbox.
|
|
556
|
+
time, valid = fmatoolbox.intervals.restrict(time,when,s_ind=True)
|
|
557
557
|
n = n[valid]
|
|
558
558
|
|
|
559
559
|
# remove units which never spiked to avoid cov error
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
""" Functions to load data for a recording session """
|
|
2
2
|
|
|
3
3
|
import pathlib
|
|
4
4
|
import scipy.io
|
|
@@ -242,80 +242,130 @@ def saveRegionSpikes(file:str|PathLike[str], spikes):
|
|
|
242
242
|
return
|
|
243
243
|
|
|
244
244
|
|
|
245
|
-
def loadEventFile(filename:str|PathLike[str], compact:bool=
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# output:
|
|
253
|
-
# events dict, keys are either 'times' and 'descriptions' or event names if 'compact' = True
|
|
245
|
+
def loadEventFile(filename:str|PathLike[str], compact:bool=None):
|
|
246
|
+
"""load an event file, accepted formats:
|
|
247
|
+
- .npy: each column of the array is returned as a value in `events` (single event)
|
|
248
|
+
- .npz: each array of the archive is returned as an event in `events`
|
|
249
|
+
- .mat: DESCRIBE
|
|
250
|
+
- .evt: text file where each line must have format 'beginning of basename_event1_1' or 'end of basename_event1_1'
|
|
251
|
+
- other extensions: interpreted as text files, as produced by ``saveEvent``
|
|
254
252
|
|
|
255
|
-
|
|
256
|
-
|
|
253
|
+
Args:
|
|
254
|
+
filename: file to read
|
|
255
|
+
compact: if True, for .npz files, return a single event instead of a dict of events, for .evt files, return a
|
|
256
|
+
compact dictionary; ignored otherwise, defaults to True
|
|
257
257
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
Returns:
|
|
259
|
+
events: either a single event (a dict) or a dict of events (a dict of dict)
|
|
260
|
+
in general, an event is a dict of type {'name': 1d np array}
|
|
261
|
+
"""
|
|
261
262
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
263
|
+
if compact is None: compact = True
|
|
264
|
+
def event_dict(data, columns=None):
|
|
265
|
+
if columns is None: columns = ['col0'] if data.ndim == 1 else [f'col{i}' for i in range(data.shape[1])]
|
|
266
|
+
return {columns[0]: data} if data.ndim == 1 else {columns[i]: data[:,i] for i in range(data.shape[1])}
|
|
267
|
+
|
|
268
|
+
filename = pathlib.Path(filename)
|
|
269
|
+
format = filename.suffix
|
|
270
|
+
match format:
|
|
271
|
+
|
|
272
|
+
# 1. npy file
|
|
273
|
+
case '.npy':
|
|
274
|
+
events = event_dict(np.load(filename))
|
|
275
|
+
|
|
276
|
+
# 2. npz file
|
|
277
|
+
case '.npz':
|
|
278
|
+
with np.load(filename) as data:
|
|
279
|
+
events = dict(data) if compact else {d: event_dict(data[d]) for d in data}
|
|
280
|
+
|
|
281
|
+
# 3. MATLAB's mat file
|
|
282
|
+
case '.mat':
|
|
283
|
+
name = filename.name.removesuffix(".events.mat").rsplit(".",1)[-1]
|
|
284
|
+
events = {name: scipy.io.loadmat(filename, simplify_cells=True)[name]}
|
|
285
|
+
|
|
286
|
+
# 4. evt file (retrocompatibility)
|
|
287
|
+
case '.evt':
|
|
288
|
+
with open(filename,'r') as f:
|
|
289
|
+
lines = f.read().splitlines()
|
|
290
|
+
|
|
291
|
+
# extract first non-whitespace token
|
|
292
|
+
times = [line.split()[0] for line in lines]
|
|
293
|
+
times = np.array([float(t) / 1000.0 for t in times]) # convert to seconds
|
|
294
|
+
|
|
295
|
+
# remove first token and following whitespace
|
|
296
|
+
descriptions = []
|
|
297
|
+
for line in lines:
|
|
298
|
+
parts = line.split(maxsplit=1)
|
|
299
|
+
descriptions.append(parts[1] if len(parts) > 1 else '')
|
|
300
|
+
|
|
301
|
+
if compact:
|
|
302
|
+
# group by events, description is of type 'beginning of basename_event1' or 'beginning of basename_event1_1'
|
|
303
|
+
events = {}
|
|
304
|
+
for t, d in zip(times,descriptions):
|
|
305
|
+
if " of " not in d:
|
|
306
|
+
raise fmatoolbox.exceptions.FileFormatError(f"Unexpected format: '{d}'")
|
|
307
|
+
phase, full_id = d.split(" of ",1)
|
|
308
|
+
phase = phase.strip()
|
|
309
|
+
full_id = full_id.strip()
|
|
310
|
+
# remove trailing 'bis' and '_1'
|
|
311
|
+
if full_id.endswith('bis'):
|
|
312
|
+
full_id = full_id[:-3]
|
|
313
|
+
parts = full_id.split('_')
|
|
314
|
+
try:
|
|
315
|
+
if parts[-1].isdigit():
|
|
316
|
+
part = parts[-2]
|
|
317
|
+
else:
|
|
318
|
+
part = parts[-1]
|
|
319
|
+
except:
|
|
320
|
+
raise ValueError(f"Invalid event ID format: '{full_id}'")
|
|
321
|
+
event_id = part
|
|
322
|
+
# concatenate values
|
|
323
|
+
if event_id in events:
|
|
324
|
+
if phase in events[event_id]:
|
|
325
|
+
events[event_id][phase].append(t)
|
|
326
|
+
else:
|
|
327
|
+
events[event_id][phase] = [t]
|
|
328
|
+
else:
|
|
329
|
+
events[event_id] = {phase : [t]}
|
|
295
330
|
else:
|
|
296
|
-
events
|
|
297
|
-
|
|
298
|
-
|
|
331
|
+
events = {'time':times, 'description':descriptions}
|
|
332
|
+
|
|
333
|
+
# 5. text file
|
|
334
|
+
case _:
|
|
335
|
+
data = np.loadtxt(filename, comments='%', delimiter=',', ndmin=2)
|
|
336
|
+
# try reading column names from header
|
|
337
|
+
columns = [f'col{i}' for i in range(data.shape[1])]
|
|
338
|
+
with filename.open('r') as f:
|
|
339
|
+
for line in f:
|
|
340
|
+
line = line.strip()
|
|
341
|
+
if not line.startswith('%'):
|
|
342
|
+
break
|
|
343
|
+
line = line.removeprefix('%').strip()
|
|
344
|
+
if line.startswith('columns:'):
|
|
345
|
+
for i, c in enumerate(line.removeprefix('columns:').split(',')):
|
|
346
|
+
columns[i] = c.strip()
|
|
347
|
+
events = {columns[i]: data[:,i] for i in range(data.shape[1])}
|
|
299
348
|
|
|
300
349
|
return events
|
|
301
350
|
|
|
302
351
|
|
|
303
352
|
def loadEvents(session:str|PathLike[str], extra:str|list[str]):
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
353
|
+
"""load event files for a session
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
session: path to session's .xml file, event files must be in session folder
|
|
357
|
+
extra: (:) string, extensions of other event files named <basename>.extra[i] to load,
|
|
358
|
+
can be 'subdir/extension' to load '.../basename/subdir/basename.extension'
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
events: dict, keys are event names
|
|
362
|
+
cat_names: (:) string, names of events contained in <basename>.cat.evt
|
|
363
|
+
"""
|
|
314
364
|
|
|
315
365
|
session = pathlib.Path(session)
|
|
316
366
|
file_root = session.parent
|
|
317
367
|
# accept single string input
|
|
318
|
-
if isinstance(extra,str):
|
|
368
|
+
if isinstance(extra, str):
|
|
319
369
|
extra = [extra]
|
|
320
370
|
|
|
321
371
|
# load all *.evt files
|
|
@@ -323,13 +373,13 @@ def loadEvents(session:str|PathLike[str], extra:str|list[str]):
|
|
|
323
373
|
cat_names = []
|
|
324
374
|
for file in file_root.glob("*.evt"):
|
|
325
375
|
try:
|
|
326
|
-
events.update(loadEventFile(file,compact=True))
|
|
376
|
+
events.update(loadEventFile(file, compact=True))
|
|
327
377
|
except fmatoolbox.exceptions.FileFormatError:
|
|
328
378
|
pass
|
|
329
379
|
if str(file)[-8:] == '.cat.evt':
|
|
330
380
|
cat_names = list(events.keys())
|
|
331
381
|
|
|
332
|
-
# load other file types
|
|
382
|
+
# load other file types TO DO: add npy support, use loadEventFile for mat files as well
|
|
333
383
|
for extension in extra:
|
|
334
384
|
# handle subfolders, e.g., 'subfolder/name'
|
|
335
385
|
e = pathlib.Path(extension).name
|
|
@@ -337,20 +387,63 @@ def loadEvents(session:str|PathLike[str], extra:str|list[str]):
|
|
|
337
387
|
this_froot = file_root / p / session.stem
|
|
338
388
|
if this_froot.with_suffix(f'.{e}.npz').exists():
|
|
339
389
|
# load .npz file
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
events[d] = {f'col{i}': data[d][:,i] for i in range(data[d].shape[1])}
|
|
390
|
+
_e = loadEventFile(this_froot.with_suffix(f'.{e}.npz'), compact=False)
|
|
391
|
+
events.update(_e)
|
|
343
392
|
elif this_froot.with_suffix(f'.{e}.events.mat').exists():
|
|
344
393
|
# load .events.mat file
|
|
345
394
|
events[e] = scipy.io.loadmat(this_froot.with_suffix(f'.{e}.events.mat'),simplify_cells=True)[e]
|
|
346
395
|
else:
|
|
347
396
|
# load text file
|
|
348
|
-
|
|
349
|
-
events[e] = {f'col{i}': data[:,i] for i in range(data.shape[1])}
|
|
397
|
+
events[e] = loadEventFile(this_froot.with_suffix(f'.{e}'))
|
|
350
398
|
|
|
351
399
|
return events, cat_names
|
|
352
400
|
|
|
353
401
|
|
|
402
|
+
def saveEvent(session:str|PathLike[str], format:str, data, columns=None, name=None):
|
|
403
|
+
"""save an event in standard FMAT format
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
session: path to session's .xml file, along which the event file will be created
|
|
407
|
+
format: format of event file, either:
|
|
408
|
+
- 'npy': DESCRIBE
|
|
409
|
+
- 'npz': DESCRIBE
|
|
410
|
+
- 'name.np#': shorthand for format='np#', name='name'
|
|
411
|
+
- a different str, to save the event as a text file named <basename>.<format>, prepending a metadata header
|
|
412
|
+
in this case, `data` must be an array whose columns represent different quantities; if 1d, interpreted as one column
|
|
413
|
+
data: array to save; for npz format, must be a dict of {'name': array}
|
|
414
|
+
columns: names of columns of `data`, mandatory for text formats, ignored otherwise
|
|
415
|
+
name: event name used to construct the output file name, ignored for text formats, mandatory otherwise
|
|
416
|
+
|
|
417
|
+
Examples:
|
|
418
|
+
# save <basename>.sleep in text format containing two events: 'onset' and 'offset'
|
|
419
|
+
saveEvent(session, 'sleep', [[1,2]], columns=['onset','offset'])
|
|
420
|
+
|
|
421
|
+
# save the same as <basename>.sleep.npz
|
|
422
|
+
saveEvent(session, 'npz', {'onset':[1,2], 'offset':[1.5,3]}, name='sleep')
|
|
423
|
+
# or
|
|
424
|
+
saveEvent(session, 'sleep.npz', {'onset':[1,2], 'offset':[1.5,3]})
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
# parse format
|
|
428
|
+
parts = format.rsplit('.', 1)
|
|
429
|
+
format = parts[-1]
|
|
430
|
+
if len(parts) > 1: name = parts[0]
|
|
431
|
+
|
|
432
|
+
if format == 'npy' or format == 'npz':
|
|
433
|
+
if name is None:
|
|
434
|
+
raise ValueError(f"'name' must be specified for {format} format")
|
|
435
|
+
fname = pathlib.Path(session).with_suffix(f'.{name}.{format}')
|
|
436
|
+
np.save(fname, data) if format == 'npy' else np.savez(fname, **data)
|
|
437
|
+
|
|
438
|
+
else:
|
|
439
|
+
if columns is None:
|
|
440
|
+
raise ValueError("'columns' must be specified for text formats")
|
|
441
|
+
fname = pathlib.Path(session).with_suffix(f'.{format}')
|
|
442
|
+
np.savetxt(fname, data, delimiter=',', header=f" columns: {', '.join(columns)}", comments="%")
|
|
443
|
+
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
|
|
354
447
|
def loadSpikeWaveforms(session:str|PathLike[str]):
|
|
355
448
|
|
|
356
449
|
file_root = pathlib.Path(session).with_suffix('')
|
|
@@ -474,29 +567,6 @@ def makeAnatFile(session:str|PathLike[str]):
|
|
|
474
567
|
return
|
|
475
568
|
|
|
476
569
|
|
|
477
|
-
def saveEvent(session:str|PathLike[str], format:str, data, columns):
|
|
478
|
-
"""save an event in standard FMAT format
|
|
479
|
-
|
|
480
|
-
Args:
|
|
481
|
-
session: path to session's .xml file, along which the event file will be created
|
|
482
|
-
format: format of event file, either:
|
|
483
|
-
- 'npy': to implement
|
|
484
|
-
- 'npz': to implement
|
|
485
|
-
- a different str, to save the event as a text file named <basename>.<format>, prepending a metadata header
|
|
486
|
-
in this case, `data` must be an array whose columns represent different quantities; if 1d, interpreted as one column
|
|
487
|
-
data: array to save
|
|
488
|
-
columns: names of columns of `data`
|
|
489
|
-
"""
|
|
490
|
-
|
|
491
|
-
if extension == 'npy' or extension == 'npz':
|
|
492
|
-
print('implement!')
|
|
493
|
-
else:
|
|
494
|
-
fname = pathlib.Path(session).with_suffix(f'.{extension}')
|
|
495
|
-
np.savetxt(fname, data, delimiter=',', header=f" columns: {', '.join(columns)}", comments="%")
|
|
496
|
-
|
|
497
|
-
return
|
|
498
|
-
|
|
499
|
-
|
|
500
570
|
def printXML(session:str|PathLike[str]):
|
|
501
571
|
"""simple utility to print contents of a session's .xml file"""
|
|
502
572
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
""" General-purpose data processing for FMAToolbox """
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def unwhere(indices,shape=None):
|
|
7
|
+
"""perform the inverse operation of ``numpy.where``
|
|
8
|
+
|
|
9
|
+
arguments:
|
|
10
|
+
indices ndarray of int, output of `numpy.where`
|
|
11
|
+
shape tuple = None, shape of the output array
|
|
12
|
+
|
|
13
|
+
output:
|
|
14
|
+
mask ndarray of bool, original input to `numpy.where`
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
if shape is None:
|
|
18
|
+
shape = tuple(i.max() + 1 if len(i) else 0 for i in indices)
|
|
19
|
+
mask = np.zeros(shape,dtype=bool)
|
|
20
|
+
mask[indices] = True
|
|
21
|
+
return mask
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def shuffleEvents(events, offset:float=None, n:int=None, group=None, fast:bool=None, intervals=None):
|
|
25
|
+
'''
|
|
26
|
+
shuffle point-process events (i.e., time stamps) preserving their inter-event interval
|
|
27
|
+
|
|
28
|
+
arguments:
|
|
29
|
+
events (s,) | (s,f) float, samples to shuffle; s: # samples, f: # features, if 2d, first column contains time stamps
|
|
30
|
+
offset float = 0 s, reference time, necessary when events start at a time != 0, e.g., for a portion of a recording
|
|
31
|
+
in [1000 s, 3000 s]; must be smaller than first event time, ignored if 'intervals' are provided
|
|
32
|
+
n int = 1, number of independent repetitions of the shuffling procedure, changes output shape
|
|
33
|
+
group (s,) int, grouping variable to shuffle events separately per group, adds output 'group'
|
|
34
|
+
fast bool = False, if True, 'events' must be time sorted (to save computation time)
|
|
35
|
+
intervals (:,2) float = None, rows are [start, stop] times of intervals to restrict shuffle into; if 1d, reshaped to (1,2)
|
|
36
|
+
|
|
37
|
+
output:
|
|
38
|
+
shuffled (:,:,) float, shuffled events, shape depends on 'n':
|
|
39
|
+
- same as 'events' if 'n' = 1
|
|
40
|
+
- (s,f,n), i.e., with an extra dimension for 'n' shuffling repetitions, otherwise
|
|
41
|
+
group (:,) int, optional, grouping variable for shuffled events, only returned if 'group' is not None, shape depends on 'n':
|
|
42
|
+
- (s,) if 'n' = 1
|
|
43
|
+
- (s,n), otherwise
|
|
44
|
+
'''
|
|
45
|
+
|
|
46
|
+
# default values
|
|
47
|
+
events = np.asarray(events)
|
|
48
|
+
n_dim = events.ndim
|
|
49
|
+
if offset is None: offset = 0
|
|
50
|
+
if n is None: n = 1
|
|
51
|
+
if group is not None: group = np.asarray(group,dtype=int)
|
|
52
|
+
if intervals is not None:
|
|
53
|
+
events, valid = restrict(events,intervals,shift=True,s_ind=True)
|
|
54
|
+
offset = 0
|
|
55
|
+
if group is not None:
|
|
56
|
+
group = group[valid]
|
|
57
|
+
|
|
58
|
+
# zero-repetitions case
|
|
59
|
+
if n == 0:
|
|
60
|
+
if group is not None:
|
|
61
|
+
return np.zeros(events.shape+(0,)), np.zeros((events.shape[0],0))
|
|
62
|
+
return np.zeros(events.shape+(0,))
|
|
63
|
+
|
|
64
|
+
# make 'events' 3d by appending singleton dimensions
|
|
65
|
+
events = events.reshape(*events.shape, *(1,) * (3 - n_dim)) # (events, features, 1)
|
|
66
|
+
|
|
67
|
+
# sort by time
|
|
68
|
+
if not fast:
|
|
69
|
+
order = events[:,0,0].argsort(axis=0)
|
|
70
|
+
events = events[order]
|
|
71
|
+
if group is not None:
|
|
72
|
+
group = group[order]
|
|
73
|
+
|
|
74
|
+
# repeat events 'n' times
|
|
75
|
+
events = np.tile(events,(1,1,n)) # (events, features, shuffle repetitions)
|
|
76
|
+
|
|
77
|
+
# core f to shuffle events of a group
|
|
78
|
+
rng = np.random.default_rng()
|
|
79
|
+
def shuffleGroup(e):
|
|
80
|
+
# shuffle inter-event intervals (IEI)
|
|
81
|
+
iei = np.diff(e[:,0,:],prepend=offset,axis=0)
|
|
82
|
+
iei_shape = iei.shape
|
|
83
|
+
permutation_idx = np.argsort(rng.random(iei_shape),axis=0)
|
|
84
|
+
iei = iei[permutation_idx,np.arange(iei_shape[1])]
|
|
85
|
+
# reconstruct shuffled samples
|
|
86
|
+
shuffled = offset + np.cumsum(iei,axis=0) # shuffled times (events, shuffle repetitions)
|
|
87
|
+
features = e[permutation_idx[:,None,:], np.arange(1,e.shape[1])[None,:,None], np.arange(iei_shape[1])[None,None,:]]
|
|
88
|
+
shuffled = np.concatenate((shuffled[:,None],features),axis=1) # append shuffled features
|
|
89
|
+
return shuffled
|
|
90
|
+
|
|
91
|
+
# perform shuffling
|
|
92
|
+
if group is None:
|
|
93
|
+
shuffled = shuffleGroup(events)
|
|
94
|
+
else:
|
|
95
|
+
shuffled = []
|
|
96
|
+
unique_groups, g_count = np.unique(group,return_counts=True)
|
|
97
|
+
for g in unique_groups:
|
|
98
|
+
shuffled.append(shuffleGroup(events[group==g]))
|
|
99
|
+
# sort by time
|
|
100
|
+
shuffled = np.concatenate(shuffled,axis=0)
|
|
101
|
+
order = shuffled[:,0,:].argsort(axis=0)
|
|
102
|
+
shuffled = shuffled[order[:,None,:], np.arange(shuffled.shape[1])[None,:,None], np.arange(shuffled.shape[2])[None,None,:]]
|
|
103
|
+
# build 'group' for output
|
|
104
|
+
group = np.repeat(unique_groups,g_count) # use unique output to sort
|
|
105
|
+
group = group[order] # sort as 'shuffled'
|
|
106
|
+
|
|
107
|
+
# unshift events back into intervals
|
|
108
|
+
if intervals is not None:
|
|
109
|
+
for i in range(n):
|
|
110
|
+
shuffled[:,:,i] = unshift(shuffled[:,:,i],intervals)
|
|
111
|
+
|
|
112
|
+
# restore shape
|
|
113
|
+
if n_dim == 1 and n == 1:
|
|
114
|
+
shuffled = shuffled.ravel()
|
|
115
|
+
elif n == 1:
|
|
116
|
+
shuffled = shuffled[:,:,0]
|
|
117
|
+
|
|
118
|
+
if group is not None:
|
|
119
|
+
if n_dim == 1 and n == 1:
|
|
120
|
+
group = group.ravel()
|
|
121
|
+
elif n == 1:
|
|
122
|
+
group = group[:,0]
|
|
123
|
+
return shuffled, group
|
|
124
|
+
|
|
125
|
+
return shuffled
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def circularShift(x, shift, axis:int=None):
|
|
129
|
+
"""independent circular shift of each slice of 'x' along a given axis
|
|
130
|
+
|
|
131
|
+
arguments:
|
|
132
|
+
x ndarray to shift
|
|
133
|
+
shift int | (:,) int, shift amount per slice; if int, the same shift is applied to each slice
|
|
134
|
+
axis int = 0, axis to roll along; 0 means columns of 'x' will be rolled
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
if axis is None: axis = 0
|
|
138
|
+
shift = np.array(shift,ndmin=1)
|
|
139
|
+
|
|
140
|
+
n, m = x.shape[:2]
|
|
141
|
+
if axis == 0:
|
|
142
|
+
if shift.size == 1:
|
|
143
|
+
shift = np.repeat(shift,m,axis=0)
|
|
144
|
+
shifted = x[(np.arange(n)[:,None]-shift) % n, np.arange(m)]
|
|
145
|
+
elif axis == 1:
|
|
146
|
+
if shift.size == 1:
|
|
147
|
+
shift = np.repeat(shift,n,axis=0)
|
|
148
|
+
shifted = x[np.arange(n)[:,None], (np.arange(m)-shift[:,None]) % m]
|
|
149
|
+
else:
|
|
150
|
+
raise ValueError('not implemented!')
|
|
151
|
+
|
|
152
|
+
return shifted
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def jitter(samples,dt,low=None):
|
|
156
|
+
"""add a jitter drawn from a uniform distribution to each sample's time stamp
|
|
157
|
+
|
|
158
|
+
arguments:
|
|
159
|
+
samples (s,) | (s,f) numeric, samples to jitter; s: # samples, f: # features, if 2d, first column contains time stamps
|
|
160
|
+
dt float, higher bound of the uniform distribution
|
|
161
|
+
low float = -dt, lower bound of the uniform distribution
|
|
162
|
+
|
|
163
|
+
output:
|
|
164
|
+
samples numeric, jittered samples (only time stamps are changed)
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
if low is None: low = -dt
|
|
168
|
+
|
|
169
|
+
samples = np.asarray(samples,dtype=float)
|
|
170
|
+
n_dim = samples.ndim
|
|
171
|
+
rng = np.random.default_rng()
|
|
172
|
+
|
|
173
|
+
if n_dim < 2:
|
|
174
|
+
samples += rng.uniform(low=low,high=dt,size=samples.shape)
|
|
175
|
+
elif n_dim == 2:
|
|
176
|
+
samples[:,0,...] += rng.uniform(low=low,high=dt,size=samples.shape[:1] + samples.shape[2:])
|
|
177
|
+
|
|
178
|
+
return samples
|