fmatoolbox 0.1.8__tar.gz → 0.2.1__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.
Files changed (22) hide show
  1. {fmatoolbox-0.1.8/src/fmatoolbox.egg-info → fmatoolbox-0.2.1}/PKG-INFO +6 -6
  2. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/pyproject.toml +6 -6
  3. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox/__init__.py +1 -0
  4. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox/analysis.py +3 -3
  5. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox/data.py +170 -92
  6. fmatoolbox-0.2.1/src/fmatoolbox/general.py +179 -0
  7. fmatoolbox-0.2.1/src/fmatoolbox/intervals.py +299 -0
  8. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox/plotting.py +20 -12
  9. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox/preprocess.py +3 -2
  10. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox/regions.py +35 -37
  11. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1/src/fmatoolbox.egg-info}/PKG-INFO +6 -6
  12. fmatoolbox-0.1.8/src/fmatoolbox/general.py +0 -439
  13. fmatoolbox-0.1.8/src/fmatoolbox/intervals.py +0 -104
  14. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/LICENSE +0 -0
  15. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/README.md +0 -0
  16. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/setup.cfg +0 -0
  17. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox/exceptions.py +0 -0
  18. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox/statistics.py +0 -0
  19. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox.egg-info/SOURCES.txt +0 -0
  20. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox.egg-info/dependency_links.txt +0 -0
  21. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/src/fmatoolbox.egg-info/requires.txt +4 -4
  22. {fmatoolbox-0.1.8 → fmatoolbox-0.2.1}/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.1.8
3
+ Version: 0.2.1
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.11
10
+ Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: numpy
13
+ Requires-Dist: loky
14
14
  Requires-Dist: numba
15
- Requires-Dist: scipy
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.1.8"
7
+ version = "0.2.1"
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.11"
13
+ requires-python = ">=3.10"
14
14
  dependencies = [
15
- "numpy",
15
+ "loky",
16
16
  "numba",
17
- "scipy",
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,5 +1,6 @@
1
1
  from . import analysis
2
2
  from . import data
3
+ from . import exceptions
3
4
  from . import general
4
5
  from . import intervals
5
6
  from . import plotting
@@ -1,6 +1,6 @@
1
- ''' Specialized analyses for FMAToolbox '''
1
+ """ Specialized analyses for FMAToolbox """
2
2
 
3
- import fmatoolbox.general
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.general.restrict(time,when,s_ind=True)
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
- ''' Functions to load data for a recording session '''
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=False):
246
- # load events from a .evt file
247
- #
248
- # arguments:
249
- # filename file, .evt file, each line must have format 'beginning of basename_event1_1'
250
- # compact bool = false, if true, return events as compact dictionary
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
- with open(filename,'r') as f:
256
- lines = f.read().splitlines()
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
- # extract first non-whitespace token
259
- times = [line.split()[0] for line in lines]
260
- times = np.array([float(t) / 1000.0 for t in times]) # convert to seconds
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
- # remove first token and following whitespace
263
- descriptions = []
264
- for line in lines:
265
- parts = line.split(maxsplit=1)
266
- descriptions.append(parts[1] if len(parts) > 1 else '')
267
-
268
- if compact:
269
- # group by events, description is of type 'beginning of basename_event1' or 'beginning of basename_event1_1'
270
- events = {} #collections.defaultdict(lambda: collections.defaultdict(list))
271
- for t, d in zip(times,descriptions):
272
- if " of " not in d:
273
- raise fmatoolbox.exceptions.FileFormatError(f"Unexpected format: '{d}'")
274
- phase, full_id = d.split(" of ",1)
275
- phase = phase.strip()
276
- full_id = full_id.strip()
277
- # remove trailing 'bis' and '_1'
278
- if full_id.endswith('bis'):
279
- full_id = full_id[:-3]
280
- parts = full_id.split('_')
281
- try:
282
- if parts[-1].isdigit():
283
- part = parts[-2]
284
- else:
285
- part = parts[-1]
286
- except:
287
- raise ValueError(f"Invalid event ID format: '{full_id}'")
288
- event_id = part
289
- # concatenate values
290
- if event_id in events:
291
- if phase in events[event_id]:
292
- events[event_id][phase].append(t)
293
- else:
294
- events[event_id][phase] = [t]
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[event_id] = {phase : [t]}
297
- else:
298
- events = { 'time': times, 'description': descriptions }
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
- # load event files for a session
305
- #
306
- # arguments:
307
- # session path to session's .xml file, event files must be in session folder
308
- # extra (:) string, extensions of other event files named <basename>.extra[i] to load,
309
- # can be 'subdir/extension' to load '.../basename/subdir/basename.extension'
310
- #
311
- # output:
312
- # events dict, keys are event names
313
- # cat_names (:) string, names of events contained in <basename>.cat.evt
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,71 @@ 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
- data = np.load(this_froot.with_suffix(f'.{e}.npz'))
341
- for d in data:
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
- data = np.loadtxt(this_froot.with_suffix(f'.{e}'),comments='%',delimiter=',',ndmin=2)
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, parsed as <subdirs>/<name>.<format>, where <subdirs> and <name> are optional, e.g.,
408
+ - 'sleep.npy' gives name='sleep', format='npy'
409
+ - 'units/firing.wake' gives subdirs='units', name='firing', format='wake'
410
+ accepted formats are:
411
+ - 'npy': DESCRIBE
412
+ - 'npz': DESCRIBE
413
+ - a different str, to save the event as a text file named <basename>.<format>, prepending a metadata header
414
+ in this case, `data` must be an array whose columns represent different quantities; if 1d, interpreted as one column
415
+ data: array to save; for npz format, must be a dict of {'name': array}
416
+ columns: names of columns of `data`, mandatory for text formats, ignored otherwise
417
+ name: event name used to construct the output file name, ignored for text formats, mandatory otherwise
418
+
419
+ Examples:
420
+ # save <basename>.sleep in text format containing two events: 'onset' and 'offset'
421
+ saveEvent(session, 'sleep', [[1,2]], columns=['onset','offset'])
422
+
423
+ # save the same as <basename>.sleep.npz
424
+ saveEvent(session, 'npz', {'onset':[1,2], 'offset':[1.5,3]}, name='sleep')
425
+ # or
426
+ saveEvent(session, 'sleep.npz', {'onset':[1,2], 'offset':[1.5,3]})
427
+
428
+ # save the same file in subdirectory 'new' within the session
429
+ saveEvent(session, 'new/sleep.npz', {'onset':[1,2], 'offset':[1.5,3]})
430
+ """
431
+
432
+ # parse format
433
+ subdirs, _, filename = format.rpartition('/')
434
+ name, dot, format = filename.rpartition('.')
435
+ if not dot:
436
+ name, format = '', filename
437
+ session = pathlib.Path(session)
438
+ froot = session.parent / subdirs / session.stem
439
+
440
+ if format == 'npy' or format == 'npz':
441
+ if not name:
442
+ raise ValueError(f"'name' must be specified for {format} format")
443
+ fname = froot.with_suffix(f'.{name}.{format}')
444
+ np.save(fname, data) if format == 'npy' else np.savez(fname, **data)
445
+
446
+ else:
447
+ if columns is None:
448
+ raise ValueError("'columns' must be specified for text formats")
449
+ fname = froot.with_suffix(f'.{format}')
450
+ np.savetxt(fname, data, delimiter=',', header=f" columns: {', '.join(columns)}", comments="%")
451
+
452
+ return
453
+
454
+
354
455
  def loadSpikeWaveforms(session:str|PathLike[str]):
355
456
 
356
457
  file_root = pathlib.Path(session).with_suffix('')
@@ -474,29 +575,6 @@ def makeAnatFile(session:str|PathLike[str]):
474
575
  return
475
576
 
476
577
 
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
578
  def printXML(session:str|PathLike[str]):
501
579
  """simple utility to print contents of a session's .xml file"""
502
580
 
@@ -0,0 +1,179 @@
1
+ """ General-purpose data processing for FMAToolbox """
2
+
3
+ import numpy as np
4
+ import fmatoolbox.intervals
5
+
6
+
7
+ def unwhere(indices,shape=None):
8
+ """perform the inverse operation of ``numpy.where``
9
+
10
+ arguments:
11
+ indices ndarray of int, output of `numpy.where`
12
+ shape tuple = None, shape of the output array
13
+
14
+ output:
15
+ mask ndarray of bool, original input to `numpy.where`
16
+ """
17
+
18
+ if shape is None:
19
+ shape = tuple(i.max() + 1 if len(i) else 0 for i in indices)
20
+ mask = np.zeros(shape,dtype=bool)
21
+ mask[indices] = True
22
+ return mask
23
+
24
+
25
+ def shuffleEvents(events, offset:float=None, n:int=None, group=None, fast:bool=None, intervals=None):
26
+ '''
27
+ shuffle point-process events (i.e., time stamps) preserving their inter-event interval
28
+
29
+ arguments:
30
+ events (s,) | (s,f) float, samples to shuffle; s: # samples, f: # features, if 2d, first column contains time stamps
31
+ offset float = 0 s, reference time, necessary when events start at a time != 0, e.g., for a portion of a recording
32
+ in [1000 s, 3000 s]; must be smaller than first event time, ignored if 'intervals' are provided
33
+ n int = 1, number of independent repetitions of the shuffling procedure, changes output shape
34
+ group (s,) int, grouping variable to shuffle events separately per group, adds output 'group'
35
+ fast bool = False, if True, 'events' must be time sorted (to save computation time)
36
+ intervals (:,2) float = None, rows are [start, stop] times of intervals to restrict shuffle into; if 1d, reshaped to (1,2)
37
+
38
+ output:
39
+ shuffled (:,:,) float, shuffled events, shape depends on 'n':
40
+ - same as 'events' if 'n' = 1
41
+ - (s,f,n), i.e., with an extra dimension for 'n' shuffling repetitions, otherwise
42
+ group (:,) int, optional, grouping variable for shuffled events, only returned if 'group' is not None, shape depends on 'n':
43
+ - (s,) if 'n' = 1
44
+ - (s,n), otherwise
45
+ '''
46
+
47
+ # default values
48
+ events = np.asarray(events)
49
+ n_dim = events.ndim
50
+ if offset is None: offset = 0
51
+ if n is None: n = 1
52
+ if group is not None: group = np.asarray(group,dtype=int)
53
+ if intervals is not None:
54
+ events, valid = fmatoolbox.intervals.restrict(events,intervals,shift=True,s_ind=True)
55
+ offset = 0
56
+ if group is not None:
57
+ group = group[valid]
58
+
59
+ # zero-repetitions case
60
+ if n == 0:
61
+ if group is not None:
62
+ return np.zeros(events.shape+(0,)), np.zeros((events.shape[0],0))
63
+ return np.zeros(events.shape+(0,))
64
+
65
+ # make 'events' 3d by appending singleton dimensions
66
+ events = events.reshape(*events.shape, *(1,) * (3 - n_dim)) # (events, features, 1)
67
+
68
+ # sort by time
69
+ if not fast:
70
+ order = events[:,0,0].argsort(axis=0)
71
+ events = events[order]
72
+ if group is not None:
73
+ group = group[order]
74
+
75
+ # repeat events 'n' times
76
+ events = np.tile(events,(1,1,n)) # (events, features, shuffle repetitions)
77
+
78
+ # core f to shuffle events of a group
79
+ rng = np.random.default_rng()
80
+ def shuffleGroup(e):
81
+ # shuffle inter-event intervals (IEI)
82
+ iei = np.diff(e[:,0,:],prepend=offset,axis=0)
83
+ iei_shape = iei.shape
84
+ permutation_idx = np.argsort(rng.random(iei_shape),axis=0)
85
+ iei = iei[permutation_idx,np.arange(iei_shape[1])]
86
+ # reconstruct shuffled samples
87
+ shuffled = offset + np.cumsum(iei,axis=0) # shuffled times (events, shuffle repetitions)
88
+ features = e[permutation_idx[:,None,:], np.arange(1,e.shape[1])[None,:,None], np.arange(iei_shape[1])[None,None,:]]
89
+ shuffled = np.concatenate((shuffled[:,None],features),axis=1) # append shuffled features
90
+ return shuffled
91
+
92
+ # perform shuffling
93
+ if group is None:
94
+ shuffled = shuffleGroup(events)
95
+ else:
96
+ shuffled = []
97
+ unique_groups, g_count = np.unique(group,return_counts=True)
98
+ for g in unique_groups:
99
+ shuffled.append(shuffleGroup(events[group==g]))
100
+ # sort by time
101
+ shuffled = np.concatenate(shuffled,axis=0)
102
+ order = shuffled[:,0,:].argsort(axis=0)
103
+ shuffled = shuffled[order[:,None,:], np.arange(shuffled.shape[1])[None,:,None], np.arange(shuffled.shape[2])[None,None,:]]
104
+ # build 'group' for output
105
+ group = np.repeat(unique_groups,g_count) # use unique output to sort
106
+ group = group[order] # sort as 'shuffled'
107
+
108
+ # unshift events back into intervals
109
+ if intervals is not None:
110
+ for i in range(n):
111
+ shuffled[:,:,i] = fmatoolbox.intervals.unshift(shuffled[:,:,i],intervals)
112
+
113
+ # restore shape
114
+ if n_dim == 1 and n == 1:
115
+ shuffled = shuffled.ravel()
116
+ elif n == 1:
117
+ shuffled = shuffled[:,:,0]
118
+
119
+ if group is not None:
120
+ if n_dim == 1 and n == 1:
121
+ group = group.ravel()
122
+ elif n == 1:
123
+ group = group[:,0]
124
+ return shuffled, group
125
+
126
+ return shuffled
127
+
128
+
129
+ def circularShift(x, shift, axis:int=None):
130
+ """independent circular shift of each slice of 'x' along a given axis
131
+
132
+ arguments:
133
+ x ndarray to shift
134
+ shift int | (:,) int, shift amount per slice; if int, the same shift is applied to each slice
135
+ axis int = 0, axis to roll along; 0 means columns of 'x' will be rolled
136
+ """
137
+
138
+ if axis is None: axis = 0
139
+ shift = np.array(shift,ndmin=1)
140
+
141
+ n, m = x.shape[:2]
142
+ if axis == 0:
143
+ if shift.size == 1:
144
+ shift = np.repeat(shift,m,axis=0)
145
+ shifted = x[(np.arange(n)[:,None]-shift) % n, np.arange(m)]
146
+ elif axis == 1:
147
+ if shift.size == 1:
148
+ shift = np.repeat(shift,n,axis=0)
149
+ shifted = x[np.arange(n)[:,None], (np.arange(m)-shift[:,None]) % m]
150
+ else:
151
+ raise ValueError('not implemented!')
152
+
153
+ return shifted
154
+
155
+
156
+ def jitter(samples,dt,low=None):
157
+ """add a jitter drawn from a uniform distribution to each sample's time stamp
158
+
159
+ arguments:
160
+ samples (s,) | (s,f) numeric, samples to jitter; s: # samples, f: # features, if 2d, first column contains time stamps
161
+ dt float, higher bound of the uniform distribution
162
+ low float = -dt, lower bound of the uniform distribution
163
+
164
+ output:
165
+ samples numeric, jittered samples (only time stamps are changed)
166
+ """
167
+
168
+ if low is None: low = -dt
169
+
170
+ samples = np.asarray(samples,dtype=float)
171
+ n_dim = samples.ndim
172
+ rng = np.random.default_rng()
173
+
174
+ if n_dim < 2:
175
+ samples += rng.uniform(low=low,high=dt,size=samples.shape)
176
+ elif n_dim == 2:
177
+ samples[:,0,...] += rng.uniform(low=low,high=dt,size=samples.shape[:1] + samples.shape[2:])
178
+
179
+ return samples