Skip to content

Commit ec061ad

Browse files
authored
Merge branch 'main' into prepare_release
2 parents 4ddefe3 + 4e5d4b6 commit ec061ad

6 files changed

Lines changed: 169 additions & 58 deletions

File tree

.github/workflows/full-test-with-codecov.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ jobs:
5252
- name: Shows installed packages by pip, git-annex and cached testing files
5353
uses: ./.github/actions/show-test-environment
5454
- name: run tests
55+
env:
56+
HDF5_PLUGIN_PATH: ${{ github.workspace }}/hdf5_plugin_path_maxwell
5557
run: |
5658
source ${{ github.workspace }}/test_env/bin/activate
5759
pytest -m "not sorters_external" --cov=./ --cov-report xml:./coverage.xml -vv -ra --durations=0 | tee report_full.txt; test ${PIPESTATUS[0]} -eq 0 || exit 1

.github/workflows/full-test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ jobs:
132132
- name: Test core
133133
run: ./.github/run_tests.sh core
134134
- name: Test extractors
135+
env:
136+
HDF5_PLUGIN_PATH: ${{ github.workspace }}/hdf5_plugin_path_maxwell
135137
if: ${{ steps.modules-changed.outputs.EXTRACTORS_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }}
136138
run: ./.github/run_tests.sh "extractors and not streaming_extractors"
137139
- name: Test preprocessing

src/spikeinterface/comparison/comparisontools.py

Lines changed: 96 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ def compute_agreement_score(num_matches, num1, num2):
6363
def do_count_event(sorting):
6464
"""
6565
Count event for each units in a sorting.
66+
67+
Kept for backward compatibility sorting.count_num_spikes_per_unit() is doing the same.
68+
6669
Parameters
6770
----------
6871
sorting: SortingExtractor
@@ -75,14 +78,7 @@ def do_count_event(sorting):
7578
"""
7679
import pandas as pd
7780

78-
unit_ids = sorting.get_unit_ids()
79-
ev_counts = np.zeros(len(unit_ids), dtype="int64")
80-
for segment_index in range(sorting.get_num_segments()):
81-
ev_counts += np.array(
82-
[len(sorting.get_unit_spike_train(u, segment_index=segment_index)) for u in unit_ids], dtype="int64"
83-
)
84-
event_counts = pd.Series(ev_counts, index=unit_ids)
85-
return event_counts
81+
return pd.Series(sorting.count_num_spikes_per_unit())
8682

8783

8884
def count_match_spikes(times1, all_times2, delta_frames): # , event_counts1, event_counts2 unit2_ids,
@@ -133,11 +129,9 @@ def compute_matching_matrix(
133129
delta_frames,
134130
):
135131
"""
136-
Compute a matrix representing the matches between two spike trains.
137-
138-
Given two spike trains, this function finds matching spikes based on a temporal proximity criterion
139-
defined by `delta_frames`. The resulting matrix indicates the number of matches between units
140-
in `spike_frames_train1` and `spike_frames_train2`.
132+
Internal function used by `make_match_count_matrix()`.
133+
This function is for one segment only.
134+
The loop over segment is done in `make_match_count_matrix()`
141135
142136
Parameters
143137
----------
@@ -164,31 +158,9 @@ def compute_matching_matrix(
164158
A 2D numpy array of shape `(num_units_train1, num_units_train2)`. Each element `[i, j]` represents
165159
the count of matching spike pairs between unit `i` from `spike_frames_train1` and unit `j` from `spike_frames_train2`.
166160
167-
168-
Notes
169-
-----
170-
This algorithm identifies matching spikes between two ordered spike trains.
171-
By iterating through each spike in the first train, it compares them against spikes in the second train,
172-
determining matches based on the two spikes frames being within `delta_frames` of each other.
173-
174-
To avoid redundant comparisons the algorithm maintains a reference, `second_train_search_start `,
175-
which signifies the minimal index in the second spike train that might match the upcoming spike
176-
in the first train.
177-
178-
The logic can be summarized as follows:
179-
1. Iterate through each spike in the first train
180-
2. For each spike, find the first match in the second train.
181-
3. Save the index of the first match as the new `second_train_search_start `
182-
3. For each match, find as many matches as possible from the first match onwards.
183-
184-
An important condition here is that the same spike is not matched twice. This is managed by keeping track
185-
of the last matched frame for each unit pair in `last_match_frame1` and `last_match_frame2`
186-
187-
For more details on the rationale behind this approach, refer to the documentation of this module and/or
188-
the metrics section in SpikeForest documentation.
189161
"""
190162

191-
matching_matrix = np.zeros((num_units_train1, num_units_train2), dtype=np.uint16)
163+
matching_matrix = np.zeros((num_units_train1, num_units_train2), dtype=np.uint64)
192164

193165
# Used to avoid the same spike matching twice
194166
last_match_frame1 = -np.ones_like(matching_matrix, dtype=np.int64)
@@ -216,11 +188,11 @@ def compute_matching_matrix(
216188
unit_index1, unit_index2 = unit_indices1[index1], unit_indices2[index2]
217189

218190
if (
219-
frame1 != last_match_frame1[unit_index1, unit_index2]
220-
and frame2 != last_match_frame2[unit_index1, unit_index2]
191+
index1 != last_match_frame1[unit_index1, unit_index2]
192+
and index2 != last_match_frame2[unit_index1, unit_index2]
221193
):
222-
last_match_frame1[unit_index1, unit_index2] = frame1
223-
last_match_frame2[unit_index1, unit_index2] = frame2
194+
last_match_frame1[unit_index1, unit_index2] = index1
195+
last_match_frame2[unit_index1, unit_index2] = index2
224196

225197
matching_matrix[unit_index1, unit_index2] += 1
226198

@@ -232,10 +204,65 @@ def compute_matching_matrix(
232204
return compute_matching_matrix
233205

234206

235-
def make_match_count_matrix(sorting1, sorting2, delta_frames):
207+
def make_match_count_matrix(sorting1, sorting2, delta_frames, ensure_symmetry=False):
208+
"""
209+
Computes a matrix representing the matches between two Sorting objects.
210+
211+
Given two spike trains, this function finds matching spikes based on a temporal proximity criterion
212+
defined by `delta_frames`. The resulting matrix indicates the number of matches between units
213+
in `spike_frames_train1` and `spike_frames_train2` for each pair of units.
214+
215+
Note that this algo is not symmetric and is biased with `sorting1` representing ground truth for the comparison
216+
217+
Parameters
218+
----------
219+
sorting1 : Sorting
220+
An array of integer frame numbers corresponding to spike times for the first train. Must be in ascending order.
221+
sorting2 : Sorting
222+
An array of integer frame numbers corresponding to spike times for the second train. Must be in ascending order.
223+
delta_frames : int
224+
The inclusive upper limit on the frame difference for which two spikes are considered matching. That is
225+
if `abs(spike_frames_train1[i] - spike_frames_train2[j]) <= delta_frames` then the spikes at
226+
`spike_frames_train1[i]` and `spike_frames_train2[j]` are considered matching.
227+
ensure_symmetry: bool, default False
228+
If ensure_symmetry=True, then the algo is run two times by switching sorting1 and sorting2.
229+
And the minimum of the two results is taken.
230+
Returns
231+
-------
232+
matching_matrix : ndarray
233+
A 2D numpy array of shape `(num_units_train1, num_units_train2)`. Each element `[i, j]` represents
234+
the count of matching spike pairs between unit `i` from `spike_frames_train1` and unit `j` from `spike_frames_train2`.
235+
236+
Notes
237+
-----
238+
This algorithm identifies matching spikes between two ordered spike trains.
239+
By iterating through each spike in the first train, it compares them against spikes in the second train,
240+
determining matches based on the two spikes frames being within `delta_frames` of each other.
241+
242+
To avoid redundant comparisons the algorithm maintains a reference, `second_train_search_start `,
243+
which signifies the minimal index in the second spike train that might match the upcoming spike
244+
in the first train.
245+
246+
The logic can be summarized as follows:
247+
1. Iterate through each spike in the first train
248+
2. For each spike, find the first match in the second train.
249+
3. Save the index of the first match as the new `second_train_search_start `
250+
3. For each match, find as many matches as possible from the first match onwards.
251+
252+
An important condition here is that the same spike is not matched twice. This is managed by keeping track
253+
of the last matched frame for each unit pair in `last_match_frame1` and `last_match_frame2`
254+
There are corner cases where a spike can be counted twice in the spiketrain 2 if there are bouts of bursting activity
255+
(below delta_frames) in the spiketrain 1. To ensure that the number of matches does not exceed the number of spikes,
256+
we apply a final clip.
257+
258+
259+
For more details on the rationale behind this approach, refer to the documentation of this module and/or
260+
the metrics section in SpikeForest documentation.
261+
"""
262+
236263
num_units_sorting1 = sorting1.get_num_units()
237264
num_units_sorting2 = sorting2.get_num_units()
238-
matching_matrix = np.zeros((num_units_sorting1, num_units_sorting2), dtype=np.uint16)
265+
matching_matrix = np.zeros((num_units_sorting1, num_units_sorting2), dtype=np.uint64)
239266

240267
spike_vector1_segments = sorting1.to_spike_vector(concatenated=False)
241268
spike_vector2_segments = sorting2.to_spike_vector(concatenated=False)
@@ -257,7 +284,7 @@ def make_match_count_matrix(sorting1, sorting2, delta_frames):
257284
unit_indices1_sorted = spike_vector1["unit_index"]
258285
unit_indices2_sorted = spike_vector2["unit_index"]
259286

260-
matching_matrix += get_optimized_compute_matching_matrix()(
287+
matching_matrix_seg = get_optimized_compute_matching_matrix()(
261288
sample_frames1_sorted,
262289
sample_frames2_sorted,
263290
unit_indices1_sorted,
@@ -267,6 +294,26 @@ def make_match_count_matrix(sorting1, sorting2, delta_frames):
267294
delta_frames,
268295
)
269296

297+
if ensure_symmetry:
298+
matching_matrix_seg_switch = get_optimized_compute_matching_matrix()(
299+
sample_frames2_sorted,
300+
sample_frames1_sorted,
301+
unit_indices2_sorted,
302+
unit_indices1_sorted,
303+
num_units_sorting2,
304+
num_units_sorting1,
305+
delta_frames,
306+
)
307+
matching_matrix_seg = np.maximum(matching_matrix_seg, matching_matrix_seg_switch.T)
308+
309+
matching_matrix += matching_matrix_seg
310+
311+
# ensure the number of match do not exceed the number of spike in train 2
312+
# this is a simple way to handle corner cases for bursting in sorting1
313+
spike_count2 = np.array(list(sorting2.count_num_spikes_per_unit().values()))
314+
spike_count2 = spike_count2[np.newaxis, :]
315+
matching_matrix = np.clip(matching_matrix, None, spike_count2)
316+
270317
# Build a data frame from the matching matrix
271318
import pandas as pd
272319

@@ -277,12 +324,12 @@ def make_match_count_matrix(sorting1, sorting2, delta_frames):
277324
return match_event_counts_df
278325

279326

280-
def make_agreement_scores(sorting1, sorting2, delta_frames):
327+
def make_agreement_scores(sorting1, sorting2, delta_frames, ensure_symmetry=True):
281328
"""
282329
Make the agreement matrix.
283330
No threshold (min_score) is applied at this step.
284331
285-
Note : this computation is symmetric.
332+
Note : this computation is symmetric by default.
286333
Inverting sorting1 and sorting2 give the transposed matrix.
287334
288335
Parameters
@@ -293,7 +340,9 @@ def make_agreement_scores(sorting1, sorting2, delta_frames):
293340
The second sorting extractor
294341
delta_frames: int
295342
Number of frames to consider spikes coincident
296-
343+
ensure_symmetry: bool, default: True
344+
If ensure_symmetry is True, then the algo is run two times by switching sorting1 and sorting2.
345+
And the minimum of the two results is taken.
297346
Returns
298347
-------
299348
agreement_scores: array (float)
@@ -309,7 +358,7 @@ def make_agreement_scores(sorting1, sorting2, delta_frames):
309358
event_counts1 = pd.Series(ev_counts1, index=unit1_ids)
310359
event_counts2 = pd.Series(ev_counts2, index=unit2_ids)
311360

312-
match_event_count = make_match_count_matrix(sorting1, sorting2, delta_frames)
361+
match_event_count = make_match_count_matrix(sorting1, sorting2, delta_frames, ensure_symmetry=ensure_symmetry)
313362

314363
agreement_scores = make_agreement_scores_from_count(match_event_count, event_counts1, event_counts2)
315364

src/spikeinterface/comparison/paircomparisons.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def __init__(
2828
delta_time=0.4,
2929
match_score=0.5,
3030
chance_score=0.1,
31+
ensure_symmetry=False,
3132
n_jobs=1,
3233
verbose=False,
3334
):
@@ -55,6 +56,8 @@ def __init__(
5556
self.unit1_ids = self.sorting1.get_unit_ids()
5657
self.unit2_ids = self.sorting2.get_unit_ids()
5758

59+
self.ensure_symmetry = ensure_symmetry
60+
5861
self._do_agreement()
5962
self._do_matching()
6063

@@ -84,7 +87,9 @@ def _do_agreement(self):
8487
self.event_counts2 = do_count_event(self.sorting2)
8588

8689
# matrix of event match count for each pair
87-
self.match_event_count = make_match_count_matrix(self.sorting1, self.sorting2, self.delta_frames)
90+
self.match_event_count = make_match_count_matrix(
91+
self.sorting1, self.sorting2, self.delta_frames, ensure_symmetry=self.ensure_symmetry
92+
)
8893

8994
# agreement matrix score for each pair
9095
self.agreement_scores = make_agreement_scores_from_count(
@@ -151,6 +156,7 @@ def __init__(
151156
delta_time=delta_time,
152157
match_score=match_score,
153158
chance_score=chance_score,
159+
ensure_symmetry=True,
154160
n_jobs=n_jobs,
155161
verbose=verbose,
156162
)
@@ -283,6 +289,7 @@ def __init__(
283289
delta_time=delta_time,
284290
match_score=match_score,
285291
chance_score=chance_score,
292+
ensure_symmetry=False,
286293
n_jobs=n_jobs,
287294
verbose=verbose,
288295
)

src/spikeinterface/comparison/tests/test_comparisontools.py

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,56 @@ def test_make_match_count_matrix_repeated_matching_but_no_double_counting():
135135
assert_array_equal(result.to_numpy(), expected_result)
136136

137137

138+
def test_make_match_count_matrix_repeated_matching_but_no_double_counting_2():
139+
# More challenging condition, this was failing with the previous approach that used np.where and np.diff
140+
# This actual implementation should fail but the "clip protection" by number of spike make the solution.
141+
# This is cheating but acceptable for really corner cases (burst in the ground truth).
142+
frames_spike_train1 = [100, 105, 110]
143+
frames_spike_train2 = [
144+
100,
145+
105,
146+
]
147+
unit_indices1 = [0, 0, 0]
148+
unit_indices2 = [
149+
0,
150+
0,
151+
]
152+
delta_frames = 20 # long enough, so all frames in both sortings are within each other reach
153+
154+
sorting1, sorting2 = make_sorting(frames_spike_train1, unit_indices1, frames_spike_train2, unit_indices2)
155+
156+
# this is easy because it is sorting2 centric
157+
result = make_match_count_matrix(sorting2, sorting1, delta_frames=delta_frames, ensure_symmetry=False)
158+
expected_result = np.array([[2]])
159+
assert_array_equal(result.to_numpy(), expected_result)
160+
161+
# this work only because we protect by clipping
162+
result = make_match_count_matrix(sorting1, sorting2, delta_frames=delta_frames, ensure_symmetry=False)
163+
expected_result = np.array([[2]])
164+
assert_array_equal(result.to_numpy(), expected_result)
165+
166+
167+
def test_make_match_count_matrix_ensure_symmetry():
168+
frames_spike_train1 = [
169+
100,
170+
102,
171+
105,
172+
120,
173+
1000,
174+
]
175+
unit_indices1 = [0, 2, 1, 0, 0]
176+
frames_spike_train2 = [101, 150, 1000]
177+
unit_indices2 = [0, 1, 0]
178+
delta_frames = 100
179+
180+
sorting1, sorting2 = make_sorting(frames_spike_train1, unit_indices1, frames_spike_train2, unit_indices2)
181+
182+
result = make_match_count_matrix(sorting1, sorting2, delta_frames=delta_frames, ensure_symmetry=True)
183+
result_T = make_match_count_matrix(sorting2, sorting1, delta_frames=delta_frames, ensure_symmetry=True)
184+
185+
assert_array_equal(result.T, result_T)
186+
187+
138188
def test_make_match_count_matrix_test_proper_search_in_the_second_train():
139189
"Search exhaustively in the second train, but only within the delta_frames window, do not terminate search early"
140190
frames_spike_train1 = [500, 600, 800]
@@ -174,7 +224,7 @@ def test_make_agreement_scores():
174224

175225
assert_array_equal(agreement_scores.values, ok)
176226

177-
# test if symetric
227+
# test if symmetric
178228
agreement_scores2 = make_agreement_scores(sorting2, sorting1, delta_frames)
179229
assert_array_equal(agreement_scores, agreement_scores2.T)
180230

@@ -437,15 +487,17 @@ def test_do_count_score_and_perf():
437487
test_make_match_count_matrix_with_mismatched_sortings()
438488
test_make_match_count_matrix_no_double_matching()
439489
test_make_match_count_matrix_repeated_matching_but_no_double_counting()
490+
test_make_match_count_matrix_repeated_matching_but_no_double_counting_2()
440491
test_make_match_count_matrix_test_proper_search_in_the_second_train()
492+
test_make_match_count_matrix_ensure_symmetry()
441493

442-
# test_make_agreement_scores()
494+
test_make_agreement_scores()
443495

444-
# test_make_possible_match()
445-
# test_make_best_match()
446-
# test_make_hungarian_match()
496+
test_make_possible_match()
497+
test_make_best_match()
498+
test_make_hungarian_match()
447499

448-
# test_do_score_labels()
449-
# test_compare_spike_trains()
450-
# test_do_confusion_matrix()
451-
# test_do_count_score_and_perf()
500+
test_do_score_labels()
501+
test_compare_spike_trains()
502+
test_do_confusion_matrix()
503+
test_do_count_score_and_perf()

src/spikeinterface/extractors/tests/test_neoextractors.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,6 @@ class CedRecordingTest(RecordingCommonTestSuite, unittest.TestCase):
278278
]
279279

280280

281-
@pytest.mark.skipif(ON_GITHUB, reason="Maxwell plugin not installed on GitHub")
282281
class MaxwellRecordingTest(RecordingCommonTestSuite, unittest.TestCase):
283282
ExtractorClass = MaxwellRecordingExtractor
284283
downloads = ["maxwell"]

0 commit comments

Comments
 (0)