Skip to content

Commit 69bf6e4

Browse files
authored
Merge pull request #3356 from samuelgarcia/node_pipeline_skip_no_peaks
nodepipeline : skip chunks when no peaks inside and skip_after_n_peaks
2 parents c7ac344 + 5d84f6c commit 69bf6e4

2 files changed

Lines changed: 226 additions & 84 deletions

File tree

src/spikeinterface/core/node_pipeline.py

Lines changed: 183 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,6 @@
11
"""
2-
Pipeline on spikes/peaks/detected peaks
3-
4-
Functions that can be chained:
5-
* after peak detection
6-
* already detected peaks
7-
* spikes (labeled peaks)
8-
to compute some additional features on-the-fly:
9-
* peak localization
10-
* peak-to-peak
11-
* pca
12-
* amplitude
13-
* amplitude scaling
14-
* ...
15-
16-
There are two ways for using theses "plugin nodes":
17-
* during `peak_detect()`
18-
* when peaks are already detected and reduced with `select_peaks()`
19-
* on a sorting object
2+
3+
204
"""
215

226
from __future__ import annotations
@@ -103,6 +87,15 @@ def get_trace_margin(self):
10387
def get_dtype(self):
10488
return base_peak_dtype
10589

90+
def get_peak_slice(
91+
self,
92+
segment_index,
93+
start_frame,
94+
end_frame,
95+
):
96+
# not needed for PeakDetector
97+
raise NotImplementedError
98+
10699

107100
# this is used in sorting components
108101
class PeakDetector(PeakSource):
@@ -127,11 +120,18 @@ def get_trace_margin(self):
127120
def get_dtype(self):
128121
return base_peak_dtype
129122

130-
def compute(self, traces, start_frame, end_frame, segment_index, max_margin):
131-
# get local peaks
123+
def get_peak_slice(self, segment_index, start_frame, end_frame, max_margin):
132124
sl = self.segment_slices[segment_index]
133125
peaks_in_segment = self.peaks[sl]
134126
i0, i1 = np.searchsorted(peaks_in_segment["sample_index"], [start_frame, end_frame])
127+
return i0, i1
128+
129+
def compute(self, traces, start_frame, end_frame, segment_index, max_margin, peak_slice):
130+
# get local peaks
131+
sl = self.segment_slices[segment_index]
132+
peaks_in_segment = self.peaks[sl]
133+
# i0, i1 = np.searchsorted(peaks_in_segment["sample_index"], [start_frame, end_frame])
134+
i0, i1 = peak_slice
135135
local_peaks = peaks_in_segment[i0:i1]
136136

137137
# make sample index local to traces
@@ -212,8 +212,7 @@ def get_trace_margin(self):
212212
def get_dtype(self):
213213
return self._dtype
214214

215-
def compute(self, traces, start_frame, end_frame, segment_index, max_margin):
216-
# get local peaks
215+
def get_peak_slice(self, segment_index, start_frame, end_frame, max_margin):
217216
sl = self.segment_slices[segment_index]
218217
peaks_in_segment = self.peaks[sl]
219218
if self.include_spikes_in_margin:
@@ -222,6 +221,20 @@ def compute(self, traces, start_frame, end_frame, segment_index, max_margin):
222221
)
223222
else:
224223
i0, i1 = np.searchsorted(peaks_in_segment["sample_index"], [start_frame, end_frame])
224+
return i0, i1
225+
226+
def compute(self, traces, start_frame, end_frame, segment_index, max_margin, peak_slice):
227+
# get local peaks
228+
sl = self.segment_slices[segment_index]
229+
peaks_in_segment = self.peaks[sl]
230+
# if self.include_spikes_in_margin:
231+
# i0, i1 = np.searchsorted(
232+
# peaks_in_segment["sample_index"], [start_frame - max_margin, end_frame + max_margin]
233+
# )
234+
# else:
235+
# i0, i1 = np.searchsorted(peaks_in_segment["sample_index"], [start_frame, end_frame])
236+
i0, i1 = peak_slice
237+
225238
local_peaks = peaks_in_segment[i0:i1]
226239

227240
# make sample index local to traces
@@ -467,31 +480,91 @@ def run_node_pipeline(
467480
nodes,
468481
job_kwargs,
469482
job_name="pipeline",
470-
mp_context=None,
483+
# mp_context=None,
471484
gather_mode="memory",
472485
gather_kwargs={},
473486
squeeze_output=True,
474487
folder=None,
475488
names=None,
476489
verbose=False,
490+
skip_after_n_peaks=None,
477491
):
478492
"""
479-
Common function to run pipeline with peak detector or already detected peak.
493+
Machinery to compute in parallel operations on peaks and traces.
494+
495+
This useful in several use cases:
496+
* in sortingcomponents : detect peaks and make some computation on then (localize, pca, ...)
497+
* in sortingcomponents : replay some peaks and make some computation on then (localize, pca, ...)
498+
* postprocessing : replay some spikes and make some computation on then (localize, pca, ...)
499+
500+
Here a "peak" is a spike without any labels just a "detected".
501+
Here a "spike" is a spike with any a label so already sorted.
502+
503+
The main idea is to have a graph of nodes.
504+
Every node is doing a computaion of some peaks and related traces.
505+
The first node is PeakSource so either a peak detector PeakDetector or peak/spike replay (PeakRetriever/SpikeRetriever)
506+
507+
Every node can have one or several output that can be directed to other nodes (aka nodes have parents).
508+
509+
Every node can optionally have a global output that will be gathered by the main process.
510+
This is controlled by return_output = True.
511+
512+
The gather consists of concatenating features related to peaks (localization, pca, scaling, ...) into a single big vector.
513+
These vectors can be in "memory" or in files ("npy")
514+
515+
516+
Parameters
517+
----------
518+
519+
recording: Recording
520+
521+
nodes: a list of PipelineNode
522+
523+
job_kwargs: dict
524+
The classical job_kwargs
525+
job_name : str
526+
The name of the pipeline used for the progress_bar
527+
gather_mode : "memory" | "npz"
528+
529+
gather_kwargs : dict
530+
OPtions to control the "gather engine". See GatherToMemory or GatherToNpy.
531+
squeeze_output : bool, default True
532+
If only one output node then squeeze the tuple
533+
folder : str | Path | None
534+
Used for gather_mode="npz"
535+
names : list of str
536+
Names of outputs.
537+
verbose : bool, default False
538+
Verbosity.
539+
skip_after_n_peaks : None | int
540+
Skip the computation after n_peaks.
541+
This is not an exact because internally this skip is done per worker in average.
542+
543+
Returns
544+
-------
545+
outputs: tuple of np.array | np.array
546+
a tuple of vector for the output of nodes having return_output=True.
547+
If squeeze_output=True and only one output then directly np.array.
480548
"""
481549

482550
check_graph(nodes)
483551

484552
job_kwargs = fix_job_kwargs(job_kwargs)
485553
assert all(isinstance(node, PipelineNode) for node in nodes)
486554

555+
if skip_after_n_peaks is not None:
556+
skip_after_n_peaks_per_worker = skip_after_n_peaks / job_kwargs["n_jobs"]
557+
else:
558+
skip_after_n_peaks_per_worker = None
559+
487560
if gather_mode == "memory":
488561
gather_func = GatherToMemory()
489562
elif gather_mode == "npy":
490563
gather_func = GatherToNpy(folder, names, **gather_kwargs)
491564
else:
492565
raise ValueError(f"wrong gather_mode : {gather_mode}")
493566

494-
init_args = (recording, nodes)
567+
init_args = (recording, nodes, skip_after_n_peaks_per_worker)
495568

496569
processor = ChunkRecordingExecutor(
497570
recording,
@@ -510,79 +583,103 @@ def run_node_pipeline(
510583
return outs
511584

512585

513-
def _init_peak_pipeline(recording, nodes):
586+
def _init_peak_pipeline(recording, nodes, skip_after_n_peaks_per_worker):
514587
# create a local dict per worker
515588
worker_ctx = {}
516589
worker_ctx["recording"] = recording
517590
worker_ctx["nodes"] = nodes
518591
worker_ctx["max_margin"] = max(node.get_trace_margin() for node in nodes)
592+
worker_ctx["skip_after_n_peaks_per_worker"] = skip_after_n_peaks_per_worker
593+
worker_ctx["num_peaks"] = 0
519594
return worker_ctx
520595

521596

522597
def _compute_peak_pipeline_chunk(segment_index, start_frame, end_frame, worker_ctx):
523598
recording = worker_ctx["recording"]
524599
max_margin = worker_ctx["max_margin"]
525600
nodes = worker_ctx["nodes"]
601+
skip_after_n_peaks_per_worker = worker_ctx["skip_after_n_peaks_per_worker"]
526602

527603
recording_segment = recording._recording_segments[segment_index]
528-
traces_chunk, left_margin, right_margin = get_chunk_with_margin(
529-
recording_segment, start_frame, end_frame, None, max_margin, add_zeros=True
530-
)
604+
node0 = nodes[0]
531605

532-
# compute the graph
533-
pipeline_outputs = {}
534-
for node in nodes:
535-
node_parents = node.parents if node.parents else list()
536-
node_input_args = tuple()
537-
for parent in node_parents:
538-
parent_output = pipeline_outputs[parent]
539-
parent_outputs_tuple = parent_output if isinstance(parent_output, tuple) else (parent_output,)
540-
node_input_args += parent_outputs_tuple
541-
if isinstance(node, PeakDetector):
542-
# to handle compatibility peak detector is a special case
543-
# with specific margin
544-
# TODO later when in master: change this later
545-
extra_margin = max_margin - node.get_trace_margin()
546-
if extra_margin:
547-
trace_detection = traces_chunk[extra_margin:-extra_margin]
606+
if isinstance(node0, (SpikeRetriever, PeakRetriever)):
607+
# in this case PeakSource could have no peaks and so no need to load traces just skip
608+
peak_slice = i0, i1 = node0.get_peak_slice(segment_index, start_frame, end_frame, max_margin)
609+
load_trace_and_compute = i0 < i1
610+
else:
611+
# PeakDetector always need traces
612+
load_trace_and_compute = True
613+
614+
if skip_after_n_peaks_per_worker is not None:
615+
if worker_ctx["num_peaks"] > skip_after_n_peaks_per_worker:
616+
load_trace_and_compute = False
617+
618+
if load_trace_and_compute:
619+
traces_chunk, left_margin, right_margin = get_chunk_with_margin(
620+
recording_segment, start_frame, end_frame, None, max_margin, add_zeros=True
621+
)
622+
# compute the graph
623+
pipeline_outputs = {}
624+
for node in nodes:
625+
node_parents = node.parents if node.parents else list()
626+
node_input_args = tuple()
627+
for parent in node_parents:
628+
parent_output = pipeline_outputs[parent]
629+
parent_outputs_tuple = parent_output if isinstance(parent_output, tuple) else (parent_output,)
630+
node_input_args += parent_outputs_tuple
631+
if isinstance(node, PeakDetector):
632+
# to handle compatibility peak detector is a special case
633+
# with specific margin
634+
# TODO later when in master: change this later
635+
extra_margin = max_margin - node.get_trace_margin()
636+
if extra_margin:
637+
trace_detection = traces_chunk[extra_margin:-extra_margin]
638+
else:
639+
trace_detection = traces_chunk
640+
node_output = node.compute(trace_detection, start_frame, end_frame, segment_index, max_margin)
641+
# set sample index to local
642+
node_output[0]["sample_index"] += extra_margin
643+
elif isinstance(node, PeakSource):
644+
node_output = node.compute(traces_chunk, start_frame, end_frame, segment_index, max_margin, peak_slice)
548645
else:
549-
trace_detection = traces_chunk
550-
node_output = node.compute(trace_detection, start_frame, end_frame, segment_index, max_margin)
551-
# set sample index to local
552-
node_output[0]["sample_index"] += extra_margin
553-
elif isinstance(node, PeakSource):
554-
node_output = node.compute(traces_chunk, start_frame, end_frame, segment_index, max_margin)
555-
else:
556-
# TODO later when in master: change the signature of all nodes (or maybe not!)
557-
node_output = node.compute(traces_chunk, *node_input_args)
558-
pipeline_outputs[node] = node_output
559-
560-
# propagate the output
561-
pipeline_outputs_tuple = tuple()
562-
for node in nodes:
563-
# handle which buffer are given to the output
564-
# this is controlled by node.return_output being a bool or tuple of bool
565-
out = pipeline_outputs[node]
566-
if isinstance(out, tuple):
567-
if isinstance(node.return_output, bool) and node.return_output:
568-
pipeline_outputs_tuple += out
569-
elif isinstance(node.return_output, tuple):
570-
for flag, e in zip(node.return_output, out):
571-
if flag:
572-
pipeline_outputs_tuple += (e,)
573-
else:
574-
if isinstance(node.return_output, bool) and node.return_output:
575-
pipeline_outputs_tuple += (out,)
576-
elif isinstance(node.return_output, tuple):
577-
# this should not apppend : maybe a checker somewhere before ?
578-
pass
646+
# TODO later when in master: change the signature of all nodes (or maybe not!)
647+
node_output = node.compute(traces_chunk, *node_input_args)
648+
pipeline_outputs[node] = node_output
649+
650+
if skip_after_n_peaks_per_worker is not None and isinstance(node, PeakSource):
651+
worker_ctx["num_peaks"] += node_output[0].size
652+
653+
# propagate the output
654+
pipeline_outputs_tuple = tuple()
655+
for node in nodes:
656+
# handle which buffer are given to the output
657+
# this is controlled by node.return_output being a bool or tuple of bool
658+
out = pipeline_outputs[node]
659+
if isinstance(out, tuple):
660+
if isinstance(node.return_output, bool) and node.return_output:
661+
pipeline_outputs_tuple += out
662+
elif isinstance(node.return_output, tuple):
663+
for flag, e in zip(node.return_output, out):
664+
if flag:
665+
pipeline_outputs_tuple += (e,)
666+
else:
667+
if isinstance(node.return_output, bool) and node.return_output:
668+
pipeline_outputs_tuple += (out,)
669+
elif isinstance(node.return_output, tuple):
670+
# this should not apppend : maybe a checker somewhere before ?
671+
pass
672+
673+
if isinstance(nodes[0], PeakDetector):
674+
# the first out element is the peak vector
675+
# we need to go back to absolut sample index
676+
pipeline_outputs_tuple[0]["sample_index"] += start_frame - left_margin
579677

580-
if isinstance(nodes[0], PeakDetector):
581-
# the first out element is the peak vector
582-
# we need to go back to absolut sample index
583-
pipeline_outputs_tuple[0]["sample_index"] += start_frame - left_margin
678+
return pipeline_outputs_tuple
584679

585-
return pipeline_outputs_tuple
680+
else:
681+
# the gather will skip this output and not concatenate it
682+
return
586683

587684

588685
class GatherToMemory:
@@ -595,6 +692,9 @@ def __init__(self):
595692
self.tuple_mode = None
596693

597694
def __call__(self, res):
695+
if res is None:
696+
return
697+
598698
if self.tuple_mode is None:
599699
# first loop only
600700
self.tuple_mode = isinstance(res, tuple)
@@ -655,6 +755,9 @@ def __init__(self, folder, names, npy_header_size=1024, exist_ok=False):
655755
self.final_shapes.append(None)
656756

657757
def __call__(self, res):
758+
if res is None:
759+
return
760+
658761
if self.tuple_mode is None:
659762
# first loop only
660763
self.tuple_mode = isinstance(res, tuple)

0 commit comments

Comments
 (0)