Skip to content

Commit f1ff67c

Browse files
committed
Handle spike vectors with extra fields, non-integer dtypes, and lazy
backing. reorder_spike_vector_by_unit_and_segment assumed a three-field int64 in-memory array. However, spike vectors can have extra fields (e.g. "channel_index", also int64), including arbitrary user-defined fields (e.g. "amplitude", maybe float64). They can also be lazy (e.g. ZarrSpikeVector). Updated the numba kernel to take extra int64 field(s). Anything spike vector with a non-int64 field now gets routed through the numpy fallback path (which is dtype-agnsotic). Lazy vectors are normalised with np.asarray (which np.lexsort was already doing implicitly).
1 parent 87cba89 commit f1ff67c

2 files changed

Lines changed: 110 additions & 15 deletions

File tree

src/spikeinterface/core/sorting_tools.py

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,28 @@ def vector_to_list_of_spiketrain_numba(sample_indices, unit_indices, num_units):
149149
return vector_to_list_of_spiketrain_numba
150150

151151

152+
def _is_flat_int64_view(dtype: np.dtype) -> bool:
153+
"""
154+
Whether a spike-vector dtype can be safely viewed as a flat (num_spikes, num_fields) int64
155+
matrix, which is what the numba counting sort moves rows through.
156+
157+
Requires every field to be int64, packed with no padding, and the first three fields to be
158+
`minimum_spike_dtype`'s in order, because the kernel addresses unit_index and segment_index
159+
positionally (columns 1 and 2) rather than by name.
160+
161+
The all-int64 rule is deliberately stricter than correctness demands -- the kernel copies rows
162+
bitwise, so any 8-byte field would in fact round-trip through the view. Keeping it narrow means
163+
the kernel only ever sees the layout it is written for, and it costs nothing in practice: every
164+
spike-vector dtype spikeinterface constructs is all-int64.
165+
"""
166+
names = dtype.names
167+
if names is None or names[:3] != tuple(name for name, _ in minimum_spike_dtype):
168+
return False
169+
if dtype.itemsize != 8 * len(names):
170+
return False
171+
return all(dtype.fields[name][0] == np.int64 and dtype.fields[name][1] == 8 * i for i, name in enumerate(names))
172+
173+
152174
def reorder_spike_vector_by_unit_and_segment(
153175
spike_vector: np.ndarray,
154176
num_units: int,
@@ -176,8 +198,11 @@ def reorder_spike_vector_by_unit_and_segment(
176198
177199
Parameters
178200
----------
179-
spike_vector : np.ndarray
180-
Structured array with dtype `minimum_spike_dtype`.
201+
spike_vector : np.ndarray or array-like
202+
Structured array whose first three fields are `minimum_spike_dtype`. Extra trailing fields
203+
(e.g. the "channel_index" that `to_spike_vector(main_channel_indices=...)` adds) are carried
204+
through to the output. Anything array-like is accepted and materialised with `np.asarray`,
205+
so lazily-backed spike vectors work, at the cost of being read into memory.
181206
num_units : int
182207
The number of units. Every `unit_index` must be in [0, num_units).
183208
num_segments : int
@@ -188,14 +213,17 @@ def reorder_spike_vector_by_unit_and_segment(
188213
Returns
189214
-------
190215
ordered_spikes : np.ndarray
191-
Structured array of `minimum_spike_dtype`, the same length as `spike_vector`, with the
192-
spikes grouped by bucket.
216+
Structured array of the same dtype and length as `spike_vector`, with the spikes grouped
217+
by bucket.
193218
order : np.ndarray
194219
1d int64 array such that `spike_vector[order]` equals `ordered_spikes`.
195220
counts : np.ndarray
196221
1d int64 array of length `num_units * num_segments`, the number of spikes in each bucket,
197222
in bucket order.
198223
"""
224+
# Materialise anything array-like (e.g. a lazily zarr-backed spike vector).
225+
spike_vector = np.asarray(spike_vector)
226+
199227
num_units, num_segments = int(num_units), int(num_segments)
200228
if num_units < 0 or num_segments < 0:
201229
raise ValueError(f"`num_units` and `num_segments` must not be negative; got {num_units} and {num_segments}.")
@@ -208,7 +236,7 @@ def reorder_spike_vector_by_unit_and_segment(
208236
num_spikes = spike_vector.size
209237
if num_spikes == 0:
210238
return (
211-
np.empty(0, dtype=minimum_spike_dtype),
239+
np.empty(0, dtype=spike_vector.dtype),
212240
np.empty(0, dtype=np.int64),
213241
np.zeros(num_buckets, dtype=np.int64),
214242
)
@@ -217,20 +245,24 @@ def reorder_spike_vector_by_unit_and_segment(
217245
f"`spike_vector` has a unit_index outside [0, {num_units}) or a segment_index outside [0, {num_segments})."
218246
)
219247

220-
if HAVE_NUMBA:
248+
# The numba kernel expects an all-int64 unpadded dtype (e.g. `minimum_spike_dtype`), but it is
249+
# possible that a spike vector has extra fields with other dtypes (`NumpySorting` allows that).
250+
# So we check taht the numba path is safe, and anything else takes the dtype-agnostic numpy path.
251+
if HAVE_NUMBA and _is_flat_int64_view(spike_vector.dtype):
221252
reorder_spike_vector = get_numba_reorder_spike_vector()
222253

223-
# These flat (num_spikes, 3) int64 views are zero-copy
224-
in_flat = np.ascontiguousarray(spike_vector).view(np.int64).reshape(num_spikes, 3)
225-
out_flat = np.empty((num_spikes, 3), dtype=np.int64)
254+
num_fields = len(spike_vector.dtype.names)
255+
# These flat (num_spikes, num_fields) int64 views are zero-copy
256+
in_flat = np.ascontiguousarray(spike_vector).view(np.int64).reshape(num_spikes, num_fields)
257+
out_flat = np.empty((num_spikes, num_fields), dtype=np.int64)
226258
order = np.empty(num_spikes, dtype=np.int64)
227259
counts = np.empty(num_buckets, dtype=np.int64)
228260

229261
in_range = reorder_spike_vector(in_flat, unit_stride, segment_stride, num_buckets, out_flat, order, counts)
230262
if not in_range:
231263
raise ValueError(out_of_range_error)
232264

233-
ordered_spikes = out_flat.view(minimum_spike_dtype).reshape(num_spikes)
265+
ordered_spikes = out_flat.view(spike_vector.dtype).reshape(num_spikes)
234266
return ordered_spikes, order, counts
235267

236268
# numpy fallback: a stable argsort by bucket is equivalent to the counting sort above.
@@ -260,7 +292,9 @@ def get_numba_reorder_spike_vector():
260292
@jit(nopython=True, nogil=True, cache=False)
261293
def reorder_spike_vector_numba(in_flat, unit_stride, segment_stride, num_buckets, out_flat, order, counts):
262294
"""
263-
Stable counting-sort of a (N, 3) int64 spike-vector flat-buffer view by (unit, segment).
295+
Stable counting-sort of a (N, num_fields) int64 spike-vector flat-buffer view by
296+
(unit, segment). `num_fields` is 3 for `minimum_spike_dtype`, more when the spike vector
297+
carries extra int64 fields; the extra columns are copied along with their spike.
264298
265299
Each spike's bucket is derived on the fly as
266300
`unit_index * unit_stride + segment_index * segment_stride`, so no bucket array is needed.
@@ -280,7 +314,7 @@ def reorder_spike_vector_numba(in_flat, unit_stride, segment_stride, num_buckets
280314
Returns False if any spike falls outside [0, num_buckets),
281315
in which case the outputs are meaningless; True otherwise.
282316
"""
283-
num_spikes = in_flat.shape[0]
317+
num_spikes, num_fields = in_flat.shape
284318

285319
# Pass 1: histogram the buckets and do bounds-check (free! we already have to make the pass)
286320
for b in range(num_buckets):
@@ -303,9 +337,8 @@ def reorder_spike_vector_numba(in_flat, unit_stride, segment_stride, num_buckets
303337
for i in range(num_spikes):
304338
bucket = in_flat[i, 1] * unit_stride + in_flat[i, 2] * segment_stride
305339
pos = write_pos[bucket]
306-
out_flat[pos, 0] = in_flat[i, 0]
307-
out_flat[pos, 1] = in_flat[i, 1]
308-
out_flat[pos, 2] = in_flat[i, 2]
340+
for field in range(num_fields):
341+
out_flat[pos, field] = in_flat[i, field]
309342
order[pos] = i
310343
write_pos[bucket] = pos + 1
311344

src/spikeinterface/core/tests/test_sorting_tools.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,68 @@ def test_reorder_spike_vector_by_unit_and_segment_bucket_dtypes(monkeypatch, num
113113
assert counts.sum() == num_spikes
114114

115115

116+
def _legacy_reorder(spikes, unit_major=True):
117+
"""The pre-counting-sort implementation, for parity testing."""
118+
if unit_major:
119+
keys = (spikes["sample_index"], spikes["segment_index"], spikes["unit_index"])
120+
else:
121+
keys = (spikes["sample_index"], spikes["unit_index"], spikes["segment_index"])
122+
order = np.lexsort(keys)
123+
return spikes[order]
124+
125+
126+
@pytest.mark.parametrize("unit_major", [True, False], ids=["unit_major", "segment_major"])
127+
def test_reorder_spike_vector_by_unit_and_segment_extra_fields(force_numba, unit_major):
128+
"""Fields beyond `minimum_spike_dtype` must survive the reorder, travelling with their spike.
129+
130+
This is needed because `to_spike_vector(main_channel_indices=...)` appends a "channel_index" field.
131+
"""
132+
wide_dtype = minimum_spike_dtype + [("channel_index", "int64")]
133+
num_units, num_segments, num_spikes = 5, 2, 500
134+
135+
rng = np.random.default_rng(0)
136+
spikes = np.empty(num_spikes, dtype=wide_dtype)
137+
segment_indices = np.sort(rng.integers(0, num_segments, size=num_spikes))
138+
spikes["segment_index"] = segment_indices
139+
for segment_index in range(num_segments):
140+
in_segment = segment_indices == segment_index
141+
spikes["sample_index"][in_segment] = np.sort(rng.integers(0, 1_000, size=in_segment.sum()))
142+
spikes["unit_index"] = rng.integers(0, num_units, size=num_spikes)
143+
# Tie channel_index to unit_index so a mis-shuffled column is detectable.
144+
spikes["channel_index"] = spikes["unit_index"] * 7 + 3
145+
146+
ordered_spikes, order, counts = reorder_spike_vector_by_unit_and_segment(
147+
spikes, num_units, num_segments, unit_major=unit_major
148+
)
149+
150+
assert ordered_spikes.dtype == spikes.dtype
151+
assert np.array_equal(ordered_spikes, spikes[order])
152+
assert np.array_equal(ordered_spikes["channel_index"], ordered_spikes["unit_index"] * 7 + 3)
153+
assert np.array_equal(ordered_spikes, _legacy_reorder(spikes, unit_major=unit_major))
154+
assert counts.sum() == num_spikes
155+
156+
157+
@pytest.mark.parametrize("extra_field", [("amplitude", "float32"), ("amplitude", "float64")])
158+
def test_reorder_spike_vector_by_unit_and_segment_non_uniform_dtype(force_numba, extra_field):
159+
"""`NumpySorting` stores whatever dtype its caller hands it. Make sure these weird spike
160+
vectors still get reordered correctly.
161+
"""
162+
dtype = minimum_spike_dtype + [extra_field]
163+
spikes = np.empty(6, dtype=dtype)
164+
spikes["sample_index"] = [10, 10, 11, 12, 12, 13]
165+
spikes["unit_index"] = [2, 0, 1, 2, 0, 0]
166+
spikes["segment_index"] = 0
167+
spikes["amplitude"] = [1.5, -2.25, 3.75, -4.5, 5.125, 6.0]
168+
169+
ordered_spikes, order, counts = reorder_spike_vector_by_unit_and_segment(spikes, 3, 1)
170+
171+
assert ordered_spikes.dtype == spikes.dtype
172+
assert np.array_equal(counts, [3, 1, 2])
173+
assert np.array_equal(ordered_spikes, spikes[order])
174+
assert np.array_equal(ordered_spikes["amplitude"], [-2.25, 5.125, 6.0, 3.75, 1.5, -4.5])
175+
assert np.array_equal(ordered_spikes, _legacy_reorder(spikes))
176+
177+
116178
def test_random_spikes_selection():
117179
recording, sorting = generate_ground_truth_recording(
118180
durations=[20.0, 10.0],

0 commit comments

Comments
 (0)