Skip to content

Commit 80cc888

Browse files
authored
Merge pull request #3405 from yger/numba_similarity
Optimizations for template_similarity (numba and dependencies)
2 parents 5e13593 + 07f893f commit 80cc888

3 files changed

Lines changed: 197 additions & 51 deletions

File tree

src/spikeinterface/postprocessing/template_similarity.py

Lines changed: 151 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
from ..core.template_tools import get_dense_templates_array
88
from ..core.sparsity import ChannelSparsity
99

10+
try:
11+
import numba
12+
13+
HAVE_NUMBA = True
14+
except ImportError:
15+
HAVE_NUMBA = False
16+
1017

1118
class ComputeTemplateSimilarity(AnalyzerExtension):
1219
"""Compute similarity between templates with several methods.
@@ -147,54 +154,15 @@ def _get_data(self):
147154
compute_template_similarity = ComputeTemplateSimilarity.function_factory()
148155

149156

150-
def compute_similarity_with_templates_array(
151-
templates_array, other_templates_array, method, support="union", num_shifts=0, sparsity=None, other_sparsity=None
152-
):
153-
import sklearn.metrics.pairwise
157+
def _compute_similarity_matrix_numpy(templates_array, other_templates_array, num_shifts, mask, method):
154158

155-
if method == "cosine_similarity":
156-
method = "cosine"
157-
158-
all_metrics = ["cosine", "l1", "l2"]
159-
160-
if method not in all_metrics:
161-
raise ValueError(f"compute_template_similarity (method {method}) not exists")
162-
163-
assert (
164-
templates_array.shape[1] == other_templates_array.shape[1]
165-
), "The number of samples in the templates should be the same for both arrays"
166-
assert (
167-
templates_array.shape[2] == other_templates_array.shape[2]
168-
), "The number of channels in the templates should be the same for both arrays"
169159
num_templates = templates_array.shape[0]
170160
num_samples = templates_array.shape[1]
171-
num_channels = templates_array.shape[2]
172161
other_num_templates = other_templates_array.shape[0]
173162

174-
same_array = np.array_equal(templates_array, other_templates_array)
175-
176-
mask = None
177-
if sparsity is not None and other_sparsity is not None:
178-
if support == "intersection":
179-
mask = np.logical_and(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :])
180-
elif support == "union":
181-
mask = np.logical_and(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :])
182-
units_overlaps = np.sum(mask, axis=2) > 0
183-
mask = np.logical_or(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :])
184-
mask[~units_overlaps] = False
185-
if mask is not None:
186-
units_overlaps = np.sum(mask, axis=2) > 0
187-
overlapping_templates = {}
188-
for i in range(num_templates):
189-
overlapping_templates[i] = np.flatnonzero(units_overlaps[i])
190-
else:
191-
# here we make a dense mask and overlapping templates
192-
overlapping_templates = {i: np.arange(other_num_templates) for i in range(num_templates)}
193-
mask = np.ones((num_templates, other_num_templates, num_channels), dtype=bool)
194-
195-
assert num_shifts < num_samples, "max_lag is too large"
196163
num_shifts_both_sides = 2 * num_shifts + 1
197164
distances = np.ones((num_shifts_both_sides, num_templates, other_num_templates), dtype=np.float32)
165+
same_array = np.array_equal(templates_array, other_templates_array)
198166

199167
# We can use the fact that dist[i,j] at lag t is equal to dist[j,i] at time -t
200168
# So the matrix can be computed only for negative lags and be transposed
@@ -210,8 +178,9 @@ def compute_similarity_with_templates_array(
210178
tgt_sliced_templates = other_templates_array[:, num_shifts + shift : num_samples - num_shifts + shift]
211179
for i in range(num_templates):
212180
src_template = src_sliced_templates[i]
213-
tgt_templates = tgt_sliced_templates[overlapping_templates[i]]
214-
for gcount, j in enumerate(overlapping_templates[i]):
181+
overlapping_templates = np.flatnonzero(np.sum(mask[i], 1))
182+
tgt_templates = tgt_sliced_templates[overlapping_templates]
183+
for gcount, j in enumerate(overlapping_templates):
215184
# symmetric values are handled later
216185
if same_array and j < i:
217186
# no need exhaustive looping when same template
@@ -222,23 +191,156 @@ def compute_similarity_with_templates_array(
222191
if method == "l1":
223192
norm_i = np.sum(np.abs(src))
224193
norm_j = np.sum(np.abs(tgt))
225-
distances[count, i, j] = sklearn.metrics.pairwise.pairwise_distances(src, tgt, metric="l1").item()
194+
distances[count, i, j] = np.sum(np.abs(src - tgt))
226195
distances[count, i, j] /= norm_i + norm_j
227196
elif method == "l2":
228197
norm_i = np.linalg.norm(src, ord=2)
229198
norm_j = np.linalg.norm(tgt, ord=2)
230-
distances[count, i, j] = sklearn.metrics.pairwise.pairwise_distances(src, tgt, metric="l2").item()
199+
distances[count, i, j] = np.linalg.norm(src - tgt, ord=2)
231200
distances[count, i, j] /= norm_i + norm_j
232-
else:
233-
distances[count, i, j] = sklearn.metrics.pairwise.pairwise_distances(
234-
src, tgt, metric="cosine"
235-
).item()
201+
elif method == "cosine":
202+
norm_i = np.linalg.norm(src, ord=2)
203+
norm_j = np.linalg.norm(tgt, ord=2)
204+
distances[count, i, j] = np.sum(src * tgt)
205+
distances[count, i, j] /= norm_i * norm_j
206+
distances[count, i, j] = 1 - distances[count, i, j]
236207

237208
if same_array:
238209
distances[count, j, i] = distances[count, i, j]
239210

240211
if same_array and num_shifts != 0:
241212
distances[num_shifts_both_sides - count - 1] = distances[count].T
213+
return distances
214+
215+
216+
if HAVE_NUMBA:
217+
218+
from math import sqrt
219+
220+
@numba.jit(nopython=True, parallel=True, fastmath=True, nogil=True)
221+
def _compute_similarity_matrix_numba(templates_array, other_templates_array, num_shifts, mask, method):
222+
num_templates = templates_array.shape[0]
223+
num_samples = templates_array.shape[1]
224+
other_num_templates = other_templates_array.shape[0]
225+
226+
num_shifts_both_sides = 2 * num_shifts + 1
227+
distances = np.ones((num_shifts_both_sides, num_templates, other_num_templates), dtype=np.float32)
228+
same_array = np.array_equal(templates_array, other_templates_array)
229+
230+
# We can use the fact that dist[i,j] at lag t is equal to dist[j,i] at time -t
231+
# So the matrix can be computed only for negative lags and be transposed
232+
233+
if same_array:
234+
# optimisation when array are the same because of symetry in shift
235+
shift_loop = list(range(-num_shifts, 1))
236+
else:
237+
shift_loop = list(range(-num_shifts, num_shifts + 1))
238+
239+
if method == "l1":
240+
metric = 0
241+
elif method == "l2":
242+
metric = 1
243+
elif method == "cosine":
244+
metric = 2
245+
246+
for count in range(len(shift_loop)):
247+
shift = shift_loop[count]
248+
src_sliced_templates = templates_array[:, num_shifts : num_samples - num_shifts]
249+
tgt_sliced_templates = other_templates_array[:, num_shifts + shift : num_samples - num_shifts + shift]
250+
for i in numba.prange(num_templates):
251+
src_template = src_sliced_templates[i]
252+
overlapping_templates = np.flatnonzero(np.sum(mask[i], 1))
253+
tgt_templates = tgt_sliced_templates[overlapping_templates]
254+
for gcount in range(len(overlapping_templates)):
255+
256+
j = overlapping_templates[gcount]
257+
# symmetric values are handled later
258+
if same_array and j < i:
259+
# no need exhaustive looping when same template
260+
continue
261+
src = src_template[:, mask[i, j]].flatten()
262+
tgt = (tgt_templates[gcount][:, mask[i, j]]).flatten()
263+
264+
norm_i = 0
265+
norm_j = 0
266+
distances[count, i, j] = 0
267+
268+
for k in range(len(src)):
269+
if metric == 0:
270+
norm_i += abs(src[k])
271+
norm_j += abs(tgt[k])
272+
distances[count, i, j] += abs(src[k] - tgt[k])
273+
elif metric == 1:
274+
norm_i += src[k] ** 2
275+
norm_j += tgt[k] ** 2
276+
distances[count, i, j] += (src[k] - tgt[k]) ** 2
277+
elif metric == 2:
278+
distances[count, i, j] += src[k] * tgt[k]
279+
norm_i += src[k] ** 2
280+
norm_j += tgt[k] ** 2
281+
282+
if metric == 0:
283+
distances[count, i, j] /= norm_i + norm_j
284+
elif metric == 1:
285+
norm_i = sqrt(norm_i)
286+
norm_j = sqrt(norm_j)
287+
distances[count, i, j] = sqrt(distances[count, i, j])
288+
distances[count, i, j] /= norm_i + norm_j
289+
elif metric == 2:
290+
norm_i = sqrt(norm_i)
291+
norm_j = sqrt(norm_j)
292+
distances[count, i, j] /= norm_i * norm_j
293+
distances[count, i, j] = 1 - distances[count, i, j]
294+
295+
if same_array:
296+
distances[count, j, i] = distances[count, i, j]
297+
298+
if same_array and num_shifts != 0:
299+
distances[num_shifts_both_sides - count - 1] = distances[count].T
300+
301+
return distances
302+
303+
_compute_similarity_matrix = _compute_similarity_matrix_numba
304+
else:
305+
_compute_similarity_matrix = _compute_similarity_matrix_numpy
306+
307+
308+
def compute_similarity_with_templates_array(
309+
templates_array, other_templates_array, method, support="union", num_shifts=0, sparsity=None, other_sparsity=None
310+
):
311+
312+
if method == "cosine_similarity":
313+
method = "cosine"
314+
315+
all_metrics = ["cosine", "l1", "l2"]
316+
317+
if method not in all_metrics:
318+
raise ValueError(f"compute_template_similarity (method {method}) not exists")
319+
320+
assert (
321+
templates_array.shape[1] == other_templates_array.shape[1]
322+
), "The number of samples in the templates should be the same for both arrays"
323+
assert (
324+
templates_array.shape[2] == other_templates_array.shape[2]
325+
), "The number of channels in the templates should be the same for both arrays"
326+
num_templates = templates_array.shape[0]
327+
num_samples = templates_array.shape[1]
328+
num_channels = templates_array.shape[2]
329+
other_num_templates = other_templates_array.shape[0]
330+
331+
mask = np.ones((num_templates, other_num_templates, num_channels), dtype=bool)
332+
333+
if sparsity is not None and other_sparsity is not None:
334+
if support == "intersection":
335+
mask = np.logical_and(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :])
336+
elif support == "union":
337+
mask = np.logical_and(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :])
338+
units_overlaps = np.sum(mask, axis=2) > 0
339+
mask = np.logical_or(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :])
340+
mask[~units_overlaps] = False
341+
342+
assert num_shifts < num_samples, "max_lag is too large"
343+
distances = _compute_similarity_matrix(templates_array, other_templates_array, num_shifts, mask, method)
242344

243345
distances = np.min(distances, axis=0)
244346
similarity = 1 - distances

src/spikeinterface/postprocessing/tests/test_correlograms.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ def test_equal_results_correlograms(window_and_bin_ms):
9393
)
9494

9595
assert np.array_equal(result_numpy, result_numba)
96-
assert np.array_equal(result_numpy, result_numba)
9796

9897

9998
@pytest.mark.parametrize("method", ["numpy", param("numba", marks=SKIP_NUMBA)])

src/spikeinterface/postprocessing/tests/test_template_similarity.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,23 @@
77
)
88

99
from spikeinterface.postprocessing import check_equal_template_with_distribution_overlap, ComputeTemplateSimilarity
10-
from spikeinterface.postprocessing.template_similarity import compute_similarity_with_templates_array
10+
from spikeinterface.postprocessing.template_similarity import (
11+
compute_similarity_with_templates_array,
12+
_compute_similarity_matrix_numpy,
13+
)
14+
15+
try:
16+
import numba
17+
18+
HAVE_NUMBA = True
19+
from spikeinterface.postprocessing.template_similarity import _compute_similarity_matrix_numba
20+
except ModuleNotFoundError as err:
21+
HAVE_NUMBA = False
22+
23+
import pytest
24+
from pytest import param
25+
26+
SKIP_NUMBA = pytest.mark.skipif(not HAVE_NUMBA, reason="Numba not available")
1127

1228

1329
class TestSimilarityExtension(AnalyzerExtensionCommonTestSuite):
@@ -72,6 +88,35 @@ def test_compute_similarity_with_templates_array(params):
7288
print(similarity.shape)
7389

7490

91+
pytest.mark.skipif(not HAVE_NUMBA, reason="Numba not available")
92+
93+
94+
@pytest.mark.parametrize(
95+
"params",
96+
[
97+
dict(method="cosine", num_shifts=8),
98+
dict(method="l1", num_shifts=0),
99+
dict(method="l2", num_shifts=0),
100+
dict(method="cosine", num_shifts=0),
101+
],
102+
)
103+
def test_equal_results_numba(params):
104+
"""
105+
Test that the 2 methods have same results with some varied time bins
106+
that are not tested in other tests.
107+
"""
108+
109+
rng = np.random.default_rng(seed=2205)
110+
templates_array = rng.random(size=(4, 20, 5), dtype=np.float32)
111+
other_templates_array = rng.random(size=(2, 20, 5), dtype=np.float32)
112+
mask = np.ones((4, 2, 5), dtype=bool)
113+
114+
result_numpy = _compute_similarity_matrix_numba(templates_array, other_templates_array, mask=mask, **params)
115+
result_numba = _compute_similarity_matrix_numpy(templates_array, other_templates_array, mask=mask, **params)
116+
117+
assert np.allclose(result_numpy, result_numba, 1e-3)
118+
119+
75120
if __name__ == "__main__":
76121
from spikeinterface.postprocessing.tests.common_extension_tests import get_dataset
77122
from spikeinterface.core import estimate_sparsity

0 commit comments

Comments
 (0)