Skip to content

Commit 7551141

Browse files
authored
Merge branch 'main' into numba_similarity
2 parents 9caa8c9 + b2ea8c5 commit 7551141

2 files changed

Lines changed: 243 additions & 5 deletions

File tree

src/spikeinterface/core/generate.py

Lines changed: 175 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -742,10 +742,10 @@ def synthesize_poisson_spike_vector(
742742

743743
# Calculate the number of frames in the refractory period
744744
refractory_period_seconds = refractory_period_ms / 1000.0
745-
refactory_period_frames = int(refractory_period_seconds * sampling_frequency)
745+
refractory_period_frames = int(refractory_period_seconds * sampling_frequency)
746746

747-
is_refactory_period_too_long = np.any(refractory_period_seconds >= 1.0 / firing_rates)
748-
if is_refactory_period_too_long:
747+
is_refractory_period_too_long = np.any(refractory_period_seconds >= 1.0 / firing_rates)
748+
if is_refractory_period_too_long:
749749
raise ValueError(
750750
f"The given refractory period {refractory_period_ms} is too long for the firing rates {firing_rates}"
751751
)
@@ -764,9 +764,9 @@ def synthesize_poisson_spike_vector(
764764
binomial_p_modified = modified_firing_rate / sampling_frequency
765765
binomial_p_modified = np.minimum(binomial_p_modified, 1.0)
766766

767-
# Generate inter spike frames, add the refactory samples and accumulate for sorted spike frames
767+
# Generate inter spike frames, add the refractory samples and accumulate for sorted spike frames
768768
inter_spike_frames = rng.geometric(p=binomial_p_modified[:, np.newaxis], size=(num_units, num_spikes_max))
769-
inter_spike_frames[:, 1:] += refactory_period_frames
769+
inter_spike_frames[:, 1:] += refractory_period_frames
770770
spike_frames = np.cumsum(inter_spike_frames, axis=1, out=inter_spike_frames)
771771
spike_frames = spike_frames.ravel()
772772

@@ -1054,6 +1054,176 @@ def synthetize_spike_train_bad_isi(duration, baseline_rate, num_violations, viol
10541054
return spike_train
10551055

10561056

1057+
from spikeinterface.core.basesorting import BaseSortingSegment, BaseSorting
1058+
1059+
1060+
class SortingGenerator(BaseSorting):
1061+
def __init__(
1062+
self,
1063+
num_units: int = 20,
1064+
sampling_frequency: float = 30_000.0, # in Hz
1065+
durations: List[float] = [10.325, 3.5], #  in s for 2 segments
1066+
firing_rates: float | np.ndarray = 3.0,
1067+
refractory_period_ms: float | np.ndarray = 4.0, # in ms
1068+
seed: int = 0,
1069+
):
1070+
"""
1071+
A class for lazily generate synthetic sorting objects with Poisson spike trains.
1072+
1073+
We have two ways of representing spike trains in SpikeInterface:
1074+
1075+
- Spike vector (sample_index, unit_index)
1076+
- Dictionary of unit_id to spike times
1077+
1078+
This class simulates a sorting object that uses a representation based on unit IDs to lists of spike times,
1079+
rather than pre-computed spike vectors. It is intended for testing performance differences and functionalities
1080+
in data handling and analysis frameworks. For the normal use case of sorting objects with spike_vectors use the
1081+
`generate_sorting` function.
1082+
1083+
Parameters
1084+
----------
1085+
num_units : int, optional
1086+
The number of distinct units (neurons) to simulate. Default is 20.
1087+
sampling_frequency : float, optional
1088+
The sampling frequency of the spike data in Hz. Default is 30_000.0.
1089+
durations : list of float, optional
1090+
A list containing the duration in seconds for each segment of the sorting data. Default is [10.325, 3.5],
1091+
corresponding to 2 segments.
1092+
firing_rates : float or np.ndarray, optional
1093+
The firing rate(s) in Hz, which can be specified as a single value applicable to all units or as an array
1094+
with individual firing rates for each unit. Default is 3.0.
1095+
refractory_period_ms : float or np.ndarray, optional
1096+
The refractory period in milliseconds. Can be specified either as a single value for all units or as an
1097+
array with different values for each unit. Default is 4.0.
1098+
seed : int, default: 0
1099+
The seed for the random number generator to ensure reproducibility.
1100+
1101+
Raises
1102+
------
1103+
ValueError
1104+
If the refractory period is too long for the given firing rates, which could result in unrealistic
1105+
physiological conditions.
1106+
1107+
Notes
1108+
-----
1109+
This generator simulates the spike trains using a Poisson process. It takes into account the refractory periods
1110+
by adjusting the firing rates accordingly. See the notes on `synthesize_poisson_spike_vector` for more details.
1111+
1112+
"""
1113+
1114+
unit_ids = np.arange(num_units)
1115+
super().__init__(sampling_frequency, unit_ids)
1116+
1117+
self.num_units = num_units
1118+
self.num_segments = len(durations)
1119+
self.firing_rates = firing_rates
1120+
self.durations = durations
1121+
self.refractory_period_seconds = refractory_period_ms / 1000.0
1122+
1123+
is_refractory_period_too_long = np.any(self.refractory_period_seconds >= 1.0 / firing_rates)
1124+
if is_refractory_period_too_long:
1125+
raise ValueError(
1126+
f"The given refractory period {refractory_period_ms} is too long for the firing rates {firing_rates}"
1127+
)
1128+
1129+
seed = _ensure_seed(seed)
1130+
self.seed = seed
1131+
1132+
for segment_index in range(self.num_segments):
1133+
segment_seed = self.seed + segment_index
1134+
segment = SortingGeneratorSegment(
1135+
num_units=num_units,
1136+
sampling_frequency=sampling_frequency,
1137+
duration=durations[segment_index],
1138+
firing_rates=firing_rates,
1139+
refractory_period_seconds=self.refractory_period_seconds,
1140+
seed=segment_seed,
1141+
t_start=None,
1142+
)
1143+
self.add_sorting_segment(segment)
1144+
1145+
self._kwargs = {
1146+
"num_units": num_units,
1147+
"sampling_frequency": sampling_frequency,
1148+
"durations": durations,
1149+
"firing_rates": firing_rates,
1150+
"refractory_period_ms": refractory_period_ms,
1151+
"seed": seed,
1152+
}
1153+
1154+
1155+
class SortingGeneratorSegment(BaseSortingSegment):
1156+
def __init__(
1157+
self,
1158+
num_units: int,
1159+
sampling_frequency: float,
1160+
duration: float,
1161+
firing_rates: float | np.ndarray,
1162+
refractory_period_seconds: float | np.ndarray,
1163+
seed: int,
1164+
t_start: Optional[float] = None,
1165+
):
1166+
self.num_units = num_units
1167+
self.duration = duration
1168+
self.sampling_frequency = sampling_frequency
1169+
self.refractory_period_seconds = refractory_period_seconds
1170+
1171+
if np.isscalar(firing_rates):
1172+
firing_rates = np.full(num_units, firing_rates, dtype="float64")
1173+
1174+
self.firing_rates = firing_rates
1175+
1176+
if np.isscalar(self.refractory_period_seconds):
1177+
self.refractory_period_seconds = np.full(num_units, self.refractory_period_seconds, dtype="float64")
1178+
1179+
self.segment_seed = seed
1180+
self.units_seed = {unit_id: self.segment_seed + hash(unit_id) for unit_id in range(num_units)}
1181+
self.num_samples = math.ceil(sampling_frequency * duration)
1182+
super().__init__(t_start)
1183+
1184+
def get_unit_spike_train(self, unit_id, start_frame: int | None = None, end_frame: int | None = None) -> np.ndarray:
1185+
unit_seed = self.units_seed[unit_id]
1186+
unit_index = self.parent_extractor.id_to_index(unit_id)
1187+
1188+
rng = np.random.default_rng(seed=unit_seed)
1189+
1190+
firing_rate = self.firing_rates[unit_index]
1191+
refractory_period = self.refractory_period_seconds[unit_index]
1192+
1193+
# p is the probably of an spike per tick of the sampling frequency
1194+
binomial_p = firing_rate / self.sampling_frequency
1195+
# We estimate how many spikes we will have in the duration
1196+
max_frames = int(self.duration * self.sampling_frequency) - 1
1197+
max_binomial_p = float(np.max(binomial_p))
1198+
num_spikes_expected = ceil(max_frames * max_binomial_p)
1199+
num_spikes_std = int(np.sqrt(num_spikes_expected * (1 - max_binomial_p)))
1200+
num_spikes_max = num_spikes_expected + 4 * num_spikes_std
1201+
1202+
# Increase the firing rate to take into account the refractory period
1203+
modified_firing_rate = firing_rate / (1 - firing_rate * refractory_period)
1204+
binomial_p_modified = modified_firing_rate / self.sampling_frequency
1205+
binomial_p_modified = np.minimum(binomial_p_modified, 1.0)
1206+
1207+
inter_spike_frames = rng.geometric(p=binomial_p_modified, size=num_spikes_max)
1208+
spike_frames = np.cumsum(inter_spike_frames)
1209+
1210+
refractory_period_frames = int(refractory_period * self.sampling_frequency)
1211+
spike_frames[1:] += refractory_period_frames
1212+
1213+
if start_frame is not None:
1214+
start_index = np.searchsorted(spike_frames, start_frame, side="left")
1215+
else:
1216+
start_index = 0
1217+
1218+
if end_frame is not None:
1219+
end_index = np.searchsorted(spike_frames[start_index:], end_frame, side="left")
1220+
else:
1221+
end_index = int(self.duration * self.sampling_frequency)
1222+
1223+
spike_frames = spike_frames[start_index:end_index]
1224+
return spike_frames
1225+
1226+
10571227
## Noise generator zone ##
10581228
class NoiseGeneratorRecording(BaseRecording):
10591229
"""

src/spikeinterface/core/tests/test_generate.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
generate_recording,
1111
generate_sorting,
1212
NoiseGeneratorRecording,
13+
SortingGenerator,
1314
TransformSorting,
1415
generate_recording_by_size,
1516
InjectTemplatesRecording,
@@ -94,6 +95,73 @@ def measure_memory_allocation(measure_in_process: bool = True) -> float:
9495
return memory
9596

9697

98+
def test_memory_sorting_generator():
99+
# Test that get_traces does not consume more memory than allocated.
100+
101+
bytes_to_MiB_factor = 1024**2
102+
relative_tolerance = 0.05 # relative tolerance of 5 per cent
103+
104+
sampling_frequency = 30000 # Hz
105+
durations = [60.0]
106+
num_units = 1000
107+
seed = 0
108+
109+
before_instanciation_MiB = measure_memory_allocation() / bytes_to_MiB_factor
110+
sorting = SortingGenerator(
111+
num_units=num_units,
112+
sampling_frequency=sampling_frequency,
113+
durations=durations,
114+
seed=seed,
115+
)
116+
after_instanciation_MiB = measure_memory_allocation() / bytes_to_MiB_factor
117+
memory_usage_MiB = after_instanciation_MiB - before_instanciation_MiB
118+
ratio = memory_usage_MiB / before_instanciation_MiB
119+
expected_allocation_MiB = 0
120+
assert (
121+
ratio <= 1.0 + relative_tolerance
122+
), f"SortingGenerator wrong memory {memory_usage_MiB} instead of {expected_allocation_MiB}"
123+
124+
125+
def test_sorting_generator_consisency_across_calls():
126+
sampling_frequency = 30000 # Hz
127+
durations = [1.0]
128+
num_units = 3
129+
seed = 0
130+
131+
sorting = SortingGenerator(
132+
num_units=num_units,
133+
sampling_frequency=sampling_frequency,
134+
durations=durations,
135+
seed=seed,
136+
)
137+
138+
for unit_id in sorting.get_unit_ids():
139+
spike_train = sorting.get_unit_spike_train(unit_id=unit_id)
140+
spike_train_again = sorting.get_unit_spike_train(unit_id=unit_id)
141+
142+
assert np.allclose(spike_train, spike_train_again)
143+
144+
145+
def test_sorting_generator_consisency_within_trains():
146+
sampling_frequency = 30000 # Hz
147+
durations = [1.0]
148+
num_units = 3
149+
seed = 0
150+
151+
sorting = SortingGenerator(
152+
num_units=num_units,
153+
sampling_frequency=sampling_frequency,
154+
durations=durations,
155+
seed=seed,
156+
)
157+
158+
for unit_id in sorting.get_unit_ids():
159+
spike_train = sorting.get_unit_spike_train(unit_id=unit_id, start_frame=0, end_frame=1000)
160+
spike_train_again = sorting.get_unit_spike_train(unit_id=unit_id, start_frame=0, end_frame=1000)
161+
162+
assert np.allclose(spike_train, spike_train_again)
163+
164+
97165
def test_noise_generator_memory():
98166
# Test that get_traces does not consume more memory than allocated.
99167

0 commit comments

Comments
 (0)