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/general.py ADDED
@@ -0,0 +1,319 @@
1
+ ''' General-purpose data processing for FMAToolbox '''
2
+
3
+ import numpy as np
4
+
5
+
6
+ def consolidateIntervals(intervals, epsilon:float=0, duration:float=0):
7
+ # remove overlaps in a set of intervals, yielding its most compact description (the union of its elements)
8
+ # e.g., [[1,4],[2,6]] will become [1,6]
9
+ #
10
+ # arguments:
11
+ # intervals (:,2) float, rows are [start, stop] times for an interval (s); if 1d, reshaped to (1,2)
12
+ # epsilon float = 0, intervals with bounds closer than epsilon are also consolidated
13
+ # duration float = 0, trim consolidated set of intervals so that it has given total duration,
14
+ # if negative, duration is counted from the end and intervals are trimmed rightwards
15
+ #
16
+ # output:
17
+ # intervals (:,2) float, consolidated intervals (s)
18
+
19
+ try:
20
+ intervals = np.array(intervals,dtype=float,ndmin=2)
21
+ except Exception as e:
22
+ raise TypeError("'intervals' must be convertible to a NumPy array") from e
23
+ if intervals.shape[1] != 2:
24
+ raise ValueError("'intervals' must be a (n,2) array")
25
+ if (intervals[:,0] > intervals[:,1]).any():
26
+ raise ValueError("rows of 'intervals' must be increasing")
27
+
28
+ # remove [nan nan] rows, handle empty input
29
+ intervals = intervals[~np.all(np.isnan(intervals),axis=1)]
30
+ if intervals.size == 0:
31
+ return intervals
32
+
33
+ # widen intervals
34
+ if epsilon:
35
+ intervals = intervals + np.array([-1,1])*epsilon
36
+
37
+ # sort by start time
38
+ intervals = intervals[intervals[:,0].argsort()]
39
+
40
+ # flatten and argsort to find overlaps
41
+ intervals = intervals.flatten()
42
+ ind = intervals.argsort()
43
+
44
+ # remove all ind which are followed by at least one smaller element
45
+ m = ind[-2:].min()
46
+ is_ok = [True,ind[-2] < ind[-1]]
47
+ for i in range(3,ind.shape[0]+1):
48
+ is_ok.append(ind[-i] < m)
49
+ m = min(ind[-i],m)
50
+ is_ok.reverse()
51
+ ind = ind[is_ok]
52
+
53
+ # remove consecutive odd elements
54
+ is_odd = (ind % 2).astype(bool)
55
+ ind = ind[np.concatenate((~is_odd[:-1] | ~is_odd[1:],[True]))]
56
+
57
+ # rebuild intervals
58
+ intervals = intervals[np.reshape(ind,(ind.shape[0]//2,2))]
59
+
60
+ # re-shorten intervals
61
+ if epsilon:
62
+ intervals = intervals + np.array([1,-1])*epsilon
63
+
64
+ if duration > 0:
65
+ cum_duration = np.cumsum(np.diff(intervals))
66
+ if cum_duration[-1] > duration:
67
+ idx = np.searchsorted(cum_duration,duration) + 1 # first interval where cumulative exceeds duration
68
+ intervals = intervals[:idx]
69
+ intervals[-1,1] -= cum_duration[idx-1] - duration
70
+ elif duration < 0:
71
+ cum_duration = np.cumsum(np.diff(intervals[::-1])) # backwards cumulative duration
72
+ if cum_duration[-1] > -duration:
73
+ idx = np.searchsorted(cum_duration,-duration) + 1
74
+ intervals = intervals[-idx:]
75
+ intervals[0,0] += cum_duration[idx-1] + duration
76
+
77
+ return intervals
78
+
79
+
80
+ def intersectIntervals(intervals):
81
+ # intersect interval sets, obtaining the set of intervals which are contained in all inputs
82
+ #
83
+ # arguments:
84
+ # intervals (n) list of (:,2), every element is a set of [start time, stop time] intervals
85
+ #
86
+ # output:
87
+ # intersection (:,2) float, intersection between elements of input
88
+
89
+ # return single interval unchanged
90
+ if len(intervals) == 1:
91
+ return intervals[0]
92
+
93
+ # 1: more than 2 intervals, recursive call
94
+ if len(intervals) > 2:
95
+ intervals = [intervals[0],intersectIntervals(intervals[1:])]
96
+
97
+ # 2: intersect 2 interval sets
98
+
99
+ # consolidate every interval set
100
+ intervals = [consolidateIntervals(i) for i in intervals]
101
+
102
+ if intervals[0].size == 0:
103
+ return intervals[0]
104
+
105
+ # flatten both sets
106
+ a = intervals[0].flatten()
107
+ b = intervals[1].flatten()
108
+
109
+ # ind[i] is odd iff a[i] falls in an interval of b
110
+ ind = np.digitize(a,b)
111
+
112
+ # handle case when a ∋ [10,20] and b ∋ [20,35] : interval [20,20] must not be in result
113
+ end_nz = ((ind != 0) & (np.arange(ind.shape[0]) % 2)).astype(bool) # end_nz[i] is 1 iff ind[i] is not 0 and i is odd
114
+ change_ind = a[end_nz] == b[ind[end_nz]-1] # change_ind[j] is 1 iff corresponding interval must be shortened
115
+ find_end_nz = np.where(end_nz)[0]
116
+ ind[find_end_nz[change_ind]] -= 1
117
+
118
+ # if ind[2*i-1], ind[2*i] are equal and even, it's an interval of a to exclude entirely
119
+ keep_ind = ((ind[::2] % 2).astype(bool) | (ind[::2] != ind[1::2])).repeat(2)
120
+ ind = ind[keep_ind]
121
+ a = a[keep_ind]
122
+ if a.size == 0:
123
+ return np.zeros((0,2))
124
+
125
+ # odd_ind[i] is index of an odd element of ind, which will be replaced by a[odd_ind[i]]
126
+ odd_ind = np.where(ind % 2)[0]
127
+ # round start to lower even number and stop to lower odd number
128
+ start = ind[::2] - ind[::2] % 2
129
+ stop = ind[1::2] -1 + ind[1::2] % 2
130
+ # expand each start[i], stop[i] pair to include intervals in between
131
+ new_ind = np.concatenate([np.arange(start[i],stop[i]+1) for i in range(start.shape[0])]).astype(int)
132
+ # remap odd_ind to point elements of new_ind, which will have to be drawn from a
133
+ remapping = np.array([np.ones(start.shape),stop-start]).flatten('F').cumsum() - 1
134
+ new_odd_ind = remapping[odd_ind].astype(int)
135
+
136
+ # initialize intervals as [b[new_ind[i]],b[new_ind[i+1]]]
137
+ intersection = b[new_ind]
138
+ # when ind[j] was odd, replace corresponding element with a[odd_ind[j]]
139
+ intersection[new_odd_ind] = a[odd_ind]
140
+ # return as interval set
141
+ intersection = intersection.reshape((intersection.shape[0]//2,2))
142
+
143
+ return intersection
144
+
145
+
146
+ def restrict(samples,intervals,shift=False,s_ind=False,i_ind=False):
147
+ # keep only samples falling in a set of intervals
148
+ #
149
+ # arguments:
150
+ # samples (:,:,) float, rows are [time stamp, value1, value2, ...]; if 1d, reshaped to (:,1)
151
+ # intervals (:,2) float, rows are [start, stop] times for an interval; if 1d, reshaped to (1,2)
152
+ # shift bool = False, if True, shift remaining epochs together in time
153
+ # s_ind bool = False, if True, return also Is
154
+ # i_ind bool = False, if True, return also Ii
155
+ #
156
+ # output:
157
+ # samples (p,m) float, restricted samples, i. e., rows of 'samples' whose first element falls into 'intervals'
158
+ # Is (n) bool, optional, indicese of original samples which were kept
159
+ # Ii (n) bool, optional, indicese of intervals which contain kept samples
160
+
161
+ samples = np.asarray(samples)
162
+ if samples.size == 0:
163
+ samples = np.array([])
164
+
165
+ # promote 1d arrays to 2d
166
+ squeeze = samples.ndim == 1
167
+ if squeeze:
168
+ samples = samples.reshape((-1,1))
169
+
170
+ # consolidate intervals to use vectorized algorithm
171
+ intervals = consolidateIntervals(intervals)
172
+
173
+ # assign and index to each sample time, only odd indeces belong to intervals
174
+ ind = np.digitize(samples[:,0],intervals.flatten())
175
+ is_ok = (ind % 2).astype(bool) # is_ok[i] is 1 iff samples[i] must be kept
176
+ samples = samples[is_ok]
177
+ Ii = (ind[is_ok] - 1) // 2 # Ii[j] indexes interval that contains samples[is_ok][j]
178
+
179
+ if shift:
180
+ # cumulative shifts: distances between intervals
181
+ ii_distance = intervals[1:,0] - intervals[:-1,1]
182
+ cum_shift = np.concatenate(([0],np.cumsum(ii_distance)))
183
+ # assign cumulative shifts to samples
184
+ shifts = cum_shift[Ii]
185
+ samples[:,0] = samples[:,0] - shifts - intervals[0,0]
186
+
187
+ if squeeze:
188
+ samples = samples.reshape(-1)
189
+
190
+ if not s_ind and not i_ind:
191
+ return samples
192
+ out = (samples, is_ok, Ii) # prepare tuple to return requested outputs
193
+ return out[:2+i_ind:2-s_ind]
194
+
195
+
196
+ def unshift(samples,intervals):
197
+ # perform the opposite operation of option 'shift' in restrict
198
+ #
199
+ # arguments:
200
+ # samples (:,:,) float, rows are [time stamp, value1, value2, ...]; if 1d, reshaped to (:,1)
201
+ # intervals (:,2) float, rows are [start, stop] times (s) for an interval, previously used to shift `samples`
202
+
203
+ samples = np.asarray(samples,dtype=float)
204
+ intervals = np.array(intervals,ndmin=2)
205
+ # promote 1d arrays to 2d
206
+ if samples.ndim == 1:
207
+ samples = samples.reshape((-1, 1))
208
+
209
+ shifted_end = np.cumsum(np.diff(intervals,axis=1).flatten())
210
+ shifted_start = np.concatenate(([0],shifted_end[:-1]))
211
+ shifted_intervals = np.column_stack((shifted_start,shifted_end))
212
+
213
+ to_add = intervals[:,0] - shifted_start # offset to add to each sample
214
+ _, is_ok, int_ind = restrict(samples[:,0],shifted_intervals,s_ind=True,i_ind=True)
215
+
216
+ samples[~is_ok,0] = np.nan
217
+ samples[is_ok,0] += to_add[int_ind]
218
+
219
+ return samples
220
+
221
+
222
+ def shuffleEvents(events,offset=0,intervals=None):
223
+ # shuffle events preserving their inter-event interval
224
+ #
225
+ # arguments:
226
+ # events (n,) or (:,n) float, every row is either [event time] or [event time, event id]; if 1d, interpreted as (:,1)
227
+ # offset float = 0 s, reference time, necessary when events start at a time != 0, e.g., for a portion of a recording
228
+ # in [1000 s, 3000 s]; must be smaller than first event time, ignored if 'intervals' are provided
229
+ # intervals (:,2) float = None, rows are [start, stop] times of intervals; if 1d, reshaped to (1,2)
230
+ #
231
+ # output:
232
+ # shuffled (n,) or (:,n) float, shuffled events
233
+
234
+ events = np.asarray(events)
235
+ if intervals is not None:
236
+ intervals = np.array(intervals,ndmin=2)
237
+ n_dim = events.ndim
238
+ events = restrict(events,intervals,shift=True)
239
+ offset = 0
240
+
241
+ # 1. single-column input (only timestamps)
242
+ if events.ndim == 1 or events.shape[1] == 1:
243
+
244
+ # compute and shuffle inter-event intervals
245
+ inter_event_intervals = np.diff(events,prepend=offset,axis=0)
246
+ rng = np.random.default_rng()
247
+ inter_event_intervals = rng.permutation(inter_event_intervals)
248
+ shuffled = offset + np.cumsum(inter_event_intervals,axis=0)
249
+
250
+ # 2: multiple columns (also grouping ids)
251
+ else:
252
+
253
+ times = events[:,0].reshape((-1,1))
254
+ ids = events[:,1:]
255
+ unique_ids = np.unique(ids,axis=0)
256
+
257
+ shuffled = []
258
+ for i in unique_ids:
259
+ # compute and shuffle inter-event intervals per group
260
+ inter_event_intervals = np.diff(times[(ids==i).all(1)],axis=0,prepend=offset)
261
+ inter_event_intervals = np.random.permutation(inter_event_intervals)
262
+ shuffled.append(np.concatenate((offset+np.cumsum(inter_event_intervals,axis=0),np.repeat(i.reshape((1,-1)),len(inter_event_intervals),axis=0)),axis=1))
263
+
264
+ # sort by time
265
+ shuffled = np.concatenate(shuffled)
266
+ shuffled = shuffled[shuffled[:,0].argsort()]
267
+
268
+ if intervals is not None:
269
+ shuffled = unshift(shuffled,intervals)
270
+ if n_dim == 1:
271
+ shuffled = shuffled.ravel()
272
+
273
+ return shuffled
274
+
275
+
276
+ def subtractIntervals(a,b):
277
+ # subtract from an interval set its intersection with a second set
278
+ #
279
+ # arguments:
280
+ # a, b (:,2) float, every row is [start time, stop time]
281
+ #
282
+ # output:
283
+ # c (:,2) float, a \ b
284
+
285
+ # consolidate and flatten
286
+ a = consolidateIntervals(a).flatten()
287
+ b = consolidateIntervals(b).flatten()
288
+ if a.size == 0:
289
+ return a
290
+
291
+ # ind[i] is odd iff a[i] falls in an interval of b
292
+ ind = np.digitize(a,b)
293
+
294
+ # if ind[2*i-1], ind[2*i] are equal and odd, it's an interval of a to exclude entirely
295
+ keep_ind = (~(ind[::2] % 2).astype(bool) | (ind[::2] != ind[1::2])).repeat(2)
296
+ ind = ind[keep_ind]
297
+ a = a[keep_ind]
298
+
299
+ # even_ind[i] is index of an even element of ind, which will be replaced by a[even_ind[i]]
300
+ even_ind = np.where(ind % 2 == 0)[0]
301
+ # round start to upper even number and stop to upper odd number
302
+ start = ind[::2] + ind[::2] % 2
303
+ stop = ind[1::2] + 1 - ind[1::2] % 2
304
+ # expand each start[i], stop[i] pair to include intervals in between
305
+ new_ind = np.concatenate([np.arange(start[i],stop[i]+1) for i in range(start.shape[0])]).astype(int)
306
+ # remap even_ind to point elements of new_ind, which will have to be drawn from a
307
+ remapping = np.array([np.ones(start.shape),stop-start]).flatten('F').cumsum() - 1
308
+ new_even_ind = remapping[even_ind].astype(int)
309
+
310
+ # initialize intervals as [b[new_ind[i]],b[new_ind[i+1]]]
311
+ new_ind -= 1
312
+ new_ind[new_even_ind] = 0 # place holder
313
+ c = b[new_ind]
314
+ # when ind[j] was even, replace corresponding element with a[even_ind[j]]
315
+ c[new_even_ind] = a[even_ind]
316
+ # return as interval set
317
+ c = c.reshape((c.shape[0]//2,2))
318
+
319
+ return c