Skip to content

Commit e5c7af8

Browse files
committed
working with less computation
1 parent 6dc6f7b commit e5c7af8

2 files changed

Lines changed: 165 additions & 31 deletions

File tree

src/spikeinterface/comparison/comparisontools.py

Lines changed: 163 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,15 @@ def do_count_event(sorting):
108108
return event_counts
109109

110110

111-
def get_optimized_compute_dot_product():
111+
def get_optimized_compute_dot_product_all():
112112
"""
113113
This function wraps around the compute_dot_product function,
114114
which uses numba for JIT compilation. It caches the compiled function
115115
for improved performance on subsequent calls.
116116
"""
117117

118-
if hasattr(get_optimized_compute_dot_product, "_cached_function"):
119-
return get_optimized_compute_dot_product._cached_function
118+
if hasattr(get_optimized_compute_dot_product_all, "_cached_function"):
119+
return get_optimized_compute_dot_product_all._cached_function
120120

121121
import numba
122122

@@ -187,11 +187,147 @@ def compute_dot_product(sample_frames, unit_indices, num_units_sorting1, num_uni
187187
return dot_product_matrix
188188

189189
# Cache the compiled function
190-
get_optimized_compute_dot_product._cached_function = compute_dot_product
190+
get_optimized_compute_dot_product_all._cached_function = compute_dot_product
191191

192192
return compute_dot_product
193193

194194

195+
def get_optimized_dot_product_function():
196+
"""
197+
This function is to avoid the bare try-except pattern when importing the compute_dot_product function
198+
which uses numba. I tested using the numba dispatcher programatically to avoids this
199+
but the performance improvements were lost. Think you can do better? Don't forget to measure performance against
200+
the current implementation!
201+
TODO: unify numba decorator across all modules
202+
"""
203+
204+
if hasattr(get_optimized_dot_product_function, "_cached_function"):
205+
return get_optimized_dot_product_function._cached_function
206+
207+
import numba
208+
209+
@numba.jit(nopython=True, nogil=True)
210+
def compute_dot_product(
211+
spike_frames_train1,
212+
spike_frames_train2,
213+
unit_indices1,
214+
unit_indices2,
215+
num_units_train1,
216+
num_units_train2,
217+
delta_frames,
218+
):
219+
"""
220+
Computes the dot product between two spike trains.
221+
The dot product in this case is the dot product of the spikes viewed as box-care functions in
222+
the Hilbert space L2.
223+
The dot product gives a measure of the similarity between two spike trains. Each match is weighted by the
224+
delta_frames - abs(frame1 - frame2) where frame1 and frame2 are the frames of the matching spikes.
225+
When the spike trains are identical, the dot product returns all the matches within the same spike train.
226+
The sum of this dot product is the squared norm of the spike train in the Hilbert space L2.
227+
Parameters
228+
----------
229+
spike_frames_train1 : ndarray
230+
An array of integer frame numbers corresponding to spike times for the first train. Must be in ascending order.
231+
spike_frames_train2 : ndarray
232+
An array of integer frame numbers corresponding to spike times for the second train. Must be in ascending order.
233+
unit_indices1 : ndarray
234+
An array of integers where `unit_indices1[i]` gives the unit index associated with the spike at `spike_frames_train1[i]`.
235+
unit_indices2 : ndarray
236+
An array of integers where `unit_indices2[i]` gives the unit index associated with the spike at `spike_frames_train2[i]`.
237+
num_units_train1 : int
238+
The total count of unique units in the first spike train.
239+
num_units_train2 : int
240+
The total count of unique units in the second spike train.
241+
delta_frames : int
242+
The inclusive upper limit on the frame difference for which two spikes are considered matching. That is
243+
if `abs(spike_frames_train1[i] - spike_frames_train2[j]) <= delta_frames` then the spikes at `spike_frames_train1[i]`
244+
and `spike_frames_train2[j]` are considered matching.
245+
Returns
246+
-------
247+
dot_product : ndarray
248+
A 2D numpy array of shape `(num_units_train1, num_units_train2)`. Each element `[i, j]` represents
249+
the dot product between unit `i` from `spike_frames_train1` and unit `j` from `spike_frames_train2`.
250+
Notes
251+
-----
252+
This algorithm follows the same logic as the one used in `compute_matching_matrix` but instead of counting
253+
the number of matches, it computes the dot product between the two spike trains by weighting each match
254+
by the delta_frames - abs(frame1 - frame2) where frame1 and frame2 are the frames of the matching spikes.
255+
"""
256+
257+
dot_product = np.zeros((num_units_train1, num_units_train2), dtype=np.uint16)
258+
259+
num_spike_frames_train1 = len(spike_frames_train1)
260+
num_spike_frames_train2 = len(spike_frames_train2)
261+
262+
# Keeps track of which frame in the second spike train should be used as a search start for matches
263+
second_train_search_start = 0
264+
for index1 in range(num_spike_frames_train1):
265+
frame1 = spike_frames_train1[index1]
266+
267+
for index2 in range(second_train_search_start, num_spike_frames_train2):
268+
frame2 = spike_frames_train2[index2]
269+
if frame2 < frame1 - delta_frames:
270+
# Frame2 too early, increase the second_train_search_start
271+
second_train_search_start += 1
272+
continue
273+
elif frame2 > frame1 + delta_frames:
274+
# No matches ahead, stop search in train2 and look for matches for the next spike in train1
275+
break
276+
else:
277+
# match
278+
unit_index1, unit_index2 = unit_indices1[index1], unit_indices2[index2]
279+
280+
match_weight = delta_frames - abs(frame1 - frame2)
281+
dot_product[unit_index1, unit_index2] += match_weight
282+
283+
return dot_product
284+
285+
# Cache the compiled function
286+
get_optimized_dot_product_function._cached_function = compute_dot_product
287+
288+
return compute_dot_product
289+
290+
291+
def get_optimized_compute_norm_function():
292+
if hasattr(get_optimized_compute_norm_function, "_cached_function"):
293+
return get_optimized_compute_norm_function._cached_function
294+
295+
import numba
296+
297+
@numba.jit(nopython=True, nogil=True)
298+
def compute_norm(sample_frames, unit_indices, num_units_sorting, delta_frames):
299+
norm_vector = np.zeros(num_units_sorting, dtype=np.uint32)
300+
301+
minimal_search = 0
302+
num_samples = len(sample_frames)
303+
for index1 in range(num_samples):
304+
frame1 = sample_frames[index1]
305+
unit_index1 = unit_indices[index1]
306+
307+
for index2 in range(minimal_search, num_samples):
308+
frame2 = sample_frames[index2]
309+
unit_index2 = unit_indices[index2]
310+
311+
# Only compare spikes from the same unit
312+
if unit_index1 != unit_index2:
313+
continue
314+
315+
if frame2 < frame1 - delta_frames:
316+
minimal_search += 1
317+
continue
318+
elif frame2 > frame1 + delta_frames:
319+
break
320+
else:
321+
norm_vector[unit_index1] += delta_frames - abs(frame1 - frame2)
322+
323+
return norm_vector
324+
325+
# Cache the compiled function
326+
get_optimized_compute_norm_function._cached_function = compute_norm
327+
328+
return compute_norm
329+
330+
195331
def compute_distance_matrix(sorting1, sorting2, delta_frames):
196332
num_units_sorting1 = sorting1.get_num_units()
197333
num_units_sorting2 = sorting2.get_num_units()
@@ -206,7 +342,8 @@ def compute_distance_matrix(sorting1, sorting2, delta_frames):
206342
num_segments_sorting1 == num_segments_sorting2
207343
), "make_match_count_matrix : sorting1 and sorting2 must have the same segment number"
208344

209-
optimized_compute_dot_product = get_optimized_compute_dot_product()
345+
optimized_compute_dot_product = get_optimized_dot_product_function()
346+
get_optimized_compute_norm = get_optimized_compute_norm_function()
210347

211348
for segment_index in range(num_segments_sorting1):
212349
spike_vector1 = spike_vector1_segments[segment_index]
@@ -215,42 +352,39 @@ def compute_distance_matrix(sorting1, sorting2, delta_frames):
215352
sample_frames1_sorted = spike_vector1["sample_index"]
216353
sample_frames2_sorted = spike_vector2["sample_index"]
217354

218-
# Concatenate
219-
sample_frames = np.concatenate((sample_frames1_sorted, sample_frames2_sorted))
220-
unit_indices2 = spike_vector2["unit_index"] + num_units_sorting1
221-
unit_indices = np.concatenate((spike_vector1["unit_index"], unit_indices2))
355+
unit_indices1 = spike_vector1["unit_index"]
356+
unit_indices2 = spike_vector2["unit_index"]
222357

223-
# Sort by sample frames
224-
indices = sample_frames.argsort()
225-
sample_frames_sorted = sample_frames[indices]
226-
unit_indices_sorted = unit_indices[indices]
358+
norm1 = get_optimized_compute_norm(
359+
sample_frames1_sorted,
360+
unit_indices1,
361+
num_units_sorting1,
362+
delta_frames,
363+
)
364+
365+
norm2 = get_optimized_compute_norm(
366+
sample_frames2_sorted,
367+
unit_indices2,
368+
num_units_sorting2,
369+
delta_frames,
370+
)
227371

228372
dot_product_matrix = optimized_compute_dot_product(
229-
sample_frames_sorted,
230-
unit_indices_sorted,
373+
sample_frames1_sorted,
374+
sample_frames2_sorted,
375+
unit_indices1,
376+
unit_indices2,
231377
num_units_sorting1,
232378
num_units_sorting2,
233379
delta_frames,
234380
)
235381

236-
# Diagonal is dot product of a spike with itself, hence norm
237-
within_train1_dot_product = dot_product_matrix[:num_units_sorting1, :num_units_sorting1]
238-
within_train2_dot_product = dot_product_matrix[num_units_sorting1:, num_units_sorting1:]
239-
norm1 = np.diag(within_train1_dot_product)
240-
norm2 = np.diag(within_train2_dot_product)
241-
242382
# Now perform the addition
243383
norm_matrix = norm1[:, np.newaxis] + norm2[np.newaxis, :]
244384

245-
# Dot product are the matches between units in train1 and train2
246-
dot_product12 = dot_product_matrix[:num_units_sorting1, num_units_sorting1:]
247-
dot_product21 = dot_product_matrix[num_units_sorting1:, :num_units_sorting1]
248-
249-
dot_product = (dot_product12 + dot_product21.T) // 2
250-
251-
distance_matrix += norm_matrix - 2 * dot_product
385+
distance_matrix += norm_matrix - 2 * dot_product_matrix
252386

253-
return np.sqrt(distance_matrix), dot_product
387+
return np.sqrt(distance_matrix), dot_product_matrix
254388

255389

256390
def get_optimized_compute_matching_matrix():

src/spikeinterface/comparison/tests/test_comparisontools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ def test_make_match_count_matrix_no_double_matching():
121121

122122
def test_make_match_count_matrix_repeated_matching_but_no_double_counting():
123123
# Challenging condition, this was failing with the previous approach that used np.where and np.diff
124-
frames_spike_train1 = [100, 105, 110, 120] # Will fail with [100, 105, 110, 120]
124+
frames_spike_train1 = [100, 105, 110] # Will fail with [100, 105, 110, 120]
125125
frames_spike_train2 = [100, 105, 110]
126-
unit_indices1 = [0, 0, 0, 0] # Will fail with [0, 0, 0, 0]
126+
unit_indices1 = [0, 0, 0] # Will fail with [0, 0, 0, 0]
127127
unit_indices2 = [0, 0, 0]
128128
delta_frames = 20 # long enough, so all frames in both sortings are within each other reach
129129

0 commit comments

Comments
 (0)