Skip to content

Commit 2a430f7

Browse files
grahamfindlayzm711alejoe91
authored
Add per-group ref_channel_ids to common_reference (#4601)
Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> Co-authored-by: Alessio Buccino <alejoe9187@gmail.com>
1 parent 7487236 commit 2a430f7

2 files changed

Lines changed: 81 additions & 3 deletions

File tree

src/spikeinterface/preprocessing/common_reference.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,17 @@ class CommonReferenceRecording(BasePreprocessor):
5555
ref_channel_ids : list | str | int | None, default: None
5656
If "global" reference, a list of channels to be used as reference.
5757
If "single" reference, a list of one channel or a single channel id is expected.
58-
If "groups" is provided, then a list of channels to be applied to each group is expected.
58+
If "groups" is provided with "single" reference, a list with one reference channel id
59+
per group is expected.
60+
If "groups" is provided with "global" reference, a list with one *list* of reference
61+
channel ids per group is expected: the reference subtracted from each group is the
62+
operator (median/average) over that group's reference set. The reference set may contain
63+
channels outside the group, enabling cross-group referencing (e.g. referencing each
64+
tetrode to the median of all channels on the other tetrodes). If None, each group is
65+
referenced to its own channels.
66+
As a shortcut for that cross-group case, pass the string "out_of_group" (with "global"
67+
reference and "groups"): each group is then referenced to all channels NOT in it,
68+
i.e. ref_channel_ids is auto-built as each group's complement (its out-of-group channels).
5969
local_radius : tuple(int, int), default: (30, 55)
6070
Use in the local CAR implementation as the selecting annulus with the following format:
6171
@@ -98,9 +108,27 @@ def __init__(
98108
raise ValueError("'operator' must be either 'median', 'average'")
99109

100110
if reference == "global":
111+
if ref_channel_ids == "out_of_group":
112+
# Convenience: reference each group to all channels NOT in it (its out-of-group channels).
113+
if groups is None:
114+
raise ValueError("ref_channel_ids='out_of_group' requires 'groups' to be set")
115+
all_ids = list(recording.channel_ids)
116+
ref_channel_ids = [[c for c in all_ids if c not in set(group)] for group in groups]
101117
if ref_channel_ids is not None:
102118
if not isinstance(ref_channel_ids, list):
103119
raise ValueError("With 'global' reference, provide 'ref_channel_ids' as a list")
120+
if groups is not None:
121+
# Per-group reference sets: one list of channel ids per group. The reference
122+
# subtracted from each group is the operator over that group's reference set
123+
# (which may be channels outside the group, e.g. for cross-group referencing).
124+
assert len(ref_channel_ids) == len(groups), (
125+
"With 'global' reference and 'groups', 'ref_channel_ids' must be a list "
126+
"with one channel-id list per group"
127+
)
128+
assert all(isinstance(r, (list, np.ndarray)) for r in ref_channel_ids), (
129+
"With 'global' reference and 'groups', each element of 'ref_channel_ids' "
130+
"must itself be a list of channel ids (the reference set for that group)"
131+
)
104132
elif reference == "single":
105133
assert ref_channel_ids is not None, "With 'single' reference, provide 'ref_channel_ids'"
106134
if groups is not None:
@@ -150,7 +178,11 @@ def __init__(
150178
else:
151179
group_indices = None
152180
if ref_channel_ids is not None:
153-
ref_channel_indices = self.ids_to_indices(ref_channel_ids)
181+
if reference == "global" and groups is not None:
182+
# one reference-channel index array per group
183+
ref_channel_indices = [self.ids_to_indices(r) for r in ref_channel_ids]
184+
else:
185+
ref_channel_indices = self.ids_to_indices(ref_channel_ids)
154186
else:
155187
ref_channel_indices = None
156188

@@ -247,7 +279,11 @@ def get_traces(self, start_frame, end_frame, channel_indices):
247279
in_group_traces = traces[:, selected_indices_in_group]
248280

249281
if self.reference == "global":
250-
shift = self.operator_func(traces[:, all_group_indices], axis=1, keepdims=True)
282+
if self.ref_channel_indices is None:
283+
ref_indices = all_group_indices # reference each group to its own channels
284+
else:
285+
ref_indices = self.ref_channel_indices[group_index] # per-group reference set
286+
shift = self.operator_func(traces[:, ref_indices], axis=1, keepdims=True)
251287
re_referenced_traces[:, out_indices] = in_group_traces - shift
252288
else:
253289
# single (as local is not allowed for groups)

src/spikeinterface/preprocessing/tests/test_common_reference.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,48 @@ def test_common_reference_groups(recording):
170170
assert np.allclose(traces[:, 1], 0)
171171

172172

173+
def test_common_reference_groups_cross(recording):
174+
# "global" reference with groups AND a per-group ref_channel_ids: each group is
175+
# referenced to a (possibly external) set of channels -> enables cross-group referencing.
176+
original_traces = recording.get_traces()
177+
groups = [["a", "c"], ["b", "d"]]
178+
ref_channel_ids = [["b", "d"], ["a", "c"]] # reference each group to the OTHER group's channels
179+
180+
rec_cross = common_reference(
181+
recording, reference="global", operator="median", groups=groups, ref_channel_ids=ref_channel_ids
182+
)
183+
traces = rec_cross.get_traces(channel_ids=["a", "b", "c", "d"])
184+
# a, c (group 0) referenced to median of b, d
185+
ref0 = np.median(original_traces[:, [1, 3]], axis=1)
186+
assert np.allclose(traces[:, 0], original_traces[:, 0] - ref0, atol=0.01)
187+
assert np.allclose(traces[:, 2], original_traces[:, 2] - ref0, atol=0.01)
188+
# b, d (group 1) referenced to median of a, c
189+
ref1 = np.median(original_traces[:, [0, 2]], axis=1)
190+
assert np.allclose(traces[:, 1], original_traces[:, 1] - ref1, atol=0.01)
191+
assert np.allclose(traces[:, 3], original_traces[:, 3] - ref1, atol=0.01)
192+
193+
# mismatched lengths raise
194+
with pytest.raises(AssertionError):
195+
common_reference(recording, reference="global", groups=groups, ref_channel_ids=[["b", "d"]])
196+
197+
198+
def test_out_of_group_common_reference(recording):
199+
# ref_channel_ids="out_of_group" shortcut: reference each group to all channels NOT in it.
200+
groups = [["a", "c"], ["b", "d"]]
201+
# out-of-group channels for these groups within {a,b,c,d} are exactly [["b","d"], ["a","c"]]
202+
explicit = common_reference(
203+
recording, reference="global", operator="median", groups=groups, ref_channel_ids=[["b", "d"], ["a", "c"]]
204+
)
205+
sugar = common_reference(
206+
recording, reference="global", operator="median", groups=groups, ref_channel_ids="out_of_group"
207+
)
208+
assert np.allclose(sugar.get_traces(), explicit.get_traces(), atol=1e-6)
209+
210+
# "out_of_group" requires groups
211+
with pytest.raises(ValueError):
212+
common_reference(recording, reference="global", ref_channel_ids="out_of_group")
213+
214+
173215
def test_common_reference_int_dtype_rounds():
174216
# Casting the re-referenced float traces down to an integer dtype must round
175217
# to the nearest integer, not truncate toward zero (b3).

0 commit comments

Comments
 (0)