In this chapter, we will use SpikeInterface to implement a standard spike sorting pipeline from start to finish.
Here we will go through the key steps at a high level, and develop the theory while adding more advanced methods and use cases in the following chapters.
In this walkthrough, we will use a short example dataset available in the data/pipeline-walkthrough of the course repository. Alterantively, the data can be downloaded from here ADD ZENODO.
This recording is only 2.5 seconds long, from 384 channels on a 4-shank Neuropixels 2 probe acquired in SpikeGLX. We use two of the four shanks, with 192 recording channels on each.
We will load and preprocess the data, run spike sorting and compute quality metrics for assessing the sorting output.
Letβs get started!
3.1 Data Loading and Exploration
3.1.1 Data Loading
First, we will load the data with SpikeInterface.
SpikeInterface can load data from many different of acquisition systems, using functions within its extractors module. As our data is from SpikeGLX, we will use the []read_spikeglx](https://github.com/neuroinformatics-unit/course-ephys-osss/tree/main/book/data/pipeline-walkthrough) method:
import spikeinterface.extractors as si_extractors# This should be the path to the folder containing# the data. On Windows, you may need to prepend# r to the string e.g. r"C:\my\path"path_to_data_folder ="data/pipeline-walkthrough"recording = si_extractors.read_spikeglx( path_to_data_folder, stream_id="imec0.ap")print(recording)
We need to tell SpikeInterface where the data is on our machine, and what data streamπ‘ we want to load. If you are unsure of the stream, run the function without the stream_id argument and SpikeInterface will prompt you with the available streams.
Because we want to load the AP data stream for spike sorting, we pass "imec0.ap".
Note
SpikeInterface uses βlazy loadingβ, which means at this stage no data is actually loaded into memory. Data is only loaded when we request it (e.g. for visualisation, or spike sorting). This makes operations very fast.
3.1.2 The Recording object
SpikeInterface extractor functions return the recording as a Python object. This object has many useful methods for exploring the recording as well as accessing the underlying data. In our case, recording is a SpikeGLXRecordingExtractor recording object. See the inheritanceπ‘ for details on inheritence in SpikeInterface.
You can also use Pythonβs dir() function to see all attributes and methods associated with any Python object.
print( [method for method indir(recording) if method.startswith("get")])
This is a good time to explore the recording object using the API docs. Have a go at the following:
Run the read_spikeglx function without stream_id to see what streams are on the recording.
Print the locations of all channels on the probe.
Print the time vector associated with the recording.
Use get_traces() to retrieve a segment of raw data as a NumPy array. What are its dimensions?
Look through the results of the dir() call above. Try out any functions that look interesting!
3.1.3 Visualising the probe
There are many brands of recording probes used in extracellular electrophysiology (e.g. Neuropixels, Cambridge Neurotech or NeuroNexus ) which makes loading probes into a common framework complex.
Under the hood, SpikeInterface uses the ProbeInterface package to load many different types of probe. In our case, it automatically identifies and loads the Neuropixels 2 (NP2) probe.
It is important to check that the probe has loaded as expected, and that the geometry matches how it was set up in your acquisition software. We can plot the probe to confirm it loaded correctly:
from probeinterface.plotting import plot_probeimport matplotlib.pyplot as pltprobe = recording.get_probe()plot_probe(probe)plt.show()
As expected, we have a 4-shank Neurpixels probe in which all the recording channels are located in the middle two shanks.
3.1.4 Visualising the data
Electrophysiological recordings are susceptible to contamination from multiple noise sources: electrical interference, mechanical noise caused by probe movement, and artefacts arising from hardware defects or poor contact.
As such, it is important to visualise the data, especially after preprocessing, to check the data quality. First, letsβs plot one second of data from our raw recording:
return_in_uV to ensure the data is returned in microvolts, as by default most electrophysiological recordings are stored as unitless int16 valuesπ‘.
order_channel_by_depth is used as, depending on the probe, the default ordering of channels is not always by depth, meaning the order may be scrambled on the plot.
As we can see - the raw data looks terrible! It is severely contaminated by electrical noise. We will need to preprocess it to tidy it up.
3.2 Preprocessing
Below, we will apply three key preprocessing steps:
phase_shift : corrects a hardware artefact in the Neuropixels probe which causes small shifts between the recording time across nearby channels.
bandpass_filter : removes high and low frequency oscillations from the data. It is applied to each channel separately.
commmon_reference : removes noise spikes that appear as verticle stripes across the data. It is applied across channels separately at each timepoint.
To preprocess the data, we will pass our recording object into functions that return a new, recording object. e.g. filtered_recording = bandpass_filter(recording). We can do this for every step in the preprocessing chain:
In SpikeInterface, the preprocessing is applied almost instantly due to the βlazyβ loading mentioned earlier. When we apply the preprocessing function, we only really just change the metadata associated with the recording, the data is not actually changed at this stage.
Only when we get the data (e.g. in plot_traces, or with get_traces() or when saving the data) is the data preprocessed in the requested chunk. This makes operating very fast!
As you can see, it is much cleaner and we can see action potentials firing! However, there does seem to be an artefact, small gaps in the action potential waveform. We will investigate these in the next section.
3.2.1 Splitting the data by shank
You might notice the data in the above image looks slightly strange. All spikes have predictable gaps between them, we can see this clearly if we zoom into a single action potential waveform:
Look carefully at the plot above. Why do you think there are predictable gaps in the signal every two channels?
NoteClick to reveal the answer
Our recording channels are located on two shanks, however we have plot all recording channels together. The channels on different shanks have the same y-position, so they are plot next to eachother, but different x-positions.
This means that the AP recorded on one shanks channels is interleaved with blank channels from the other shank.
We can solve this by spliting the recording by shank with the groups property. When SpikeInterface loads the data, it will automatically assign different shanks to different groups:
Now we have our preprocessed data, and have visually confirmed the data quality is good, we are ready to perform spike sorting!
TipThings to try
How does the data look using median vs. mean for common_average?
What happens if you set the bandpass filter minimum cutoff to zero? What frequencies are including now? Or set the maximum cutoff to 15000?
Set return_in_uV=False. Does the data look much different?
Save the data to disk with the Recording objects .save attribute
Use get_traces() with and without return_in_uV as True and False to inspect the data type.
Perform spike detection (e.g. absolute threshold method) on the preprocessed data obtained with get_traces()
3.3 Sorting
Now we are ready to move onto spike sorting. There are many possible spike sorters to choose from, today we will use Kilosort 4.
All we need to do is pass our preprocessed recording to the sorter of choice, along with any parameters. In our case, we will skip the filtering and common average referencing that Kilosort 4 can perform, as we have already run these steps in SpikeInterface (we cannot turn filtering off, so just set it to a very low cutoff).
Kilosort will then detect spikes and assign each spike to a unit (i.e. a putative neuron). It will output the time of each spike, what unit it belongs to.
It is often easiest to sort each shank separately. From now on, we will only sort the first shank of the recording.
In SpikeInterface, running a sorter is quite simple:
KiloSortSortingExtractor: 40 units - 1 segments - 30.0kHz
The sorting output is written to disk. The output contains all of the output files of the sorter, and its organisation is sorter-dependent. As we as spike times and unit assignments, it contains other outputs from the sorter e.g. representative waveforms for each units (βtemplatesβ).
In SpikeInterface, we can access the spike times and unit assignments in a sorter-independent manner, using the sorting object. For example, to get the spike times and unit assignments:
spikes = sorting.to_spike_vector()spike_times = spikes["sample_index"] / sorting.get_sampling_frequency()# show the first 5 spikesprint(f"`spikes` is a structured number array with fields`: {spikes.dtype.names}\n")print(f"First 5 spike times (s): {spike_times[:5]}\n")print(f"First 5 unit assignments: {spikes['unit_index'][:5]}")
`spikes` is a structured number array with fields`: ('sample_index', 'unit_index', 'segment_index')
First 5 spike times (s): [6.66666667e-05 6.66666667e-05 1.00000000e-04 1.00000000e-04
1.33333333e-04]
First 5 unit assignments: [ 3 11 7 19 39]
TipThings to try
Get a list of all non-empty unit ids
Remove a unit - what happens to the spikes assigned to this unit?
Get the spike times for a specific unit
3.4 Postprocessing
TODO! Depending on timing and content of the other sections, we may want to reduce this down to a single command where we compute all of the extensions, and then go through them in more detail in the SpikeInterface GUI
Spike sorting is a complex task undertaken in the precense of significant noise. Therefore, many units will not cleanly represent a single neuron. As well as good units (i.. where each spike is from the same putative neuron) units may be noise (XXX) or multi-unit activity (MUA).
To determine whether units are good or not, we must look at the individual spikes that belong to a unit, and compute metrics. Cut then out, waveforms. In this section we will go over some key concepts at a very high level - we will develop this much further in the Postprocessing chapter.
3.4.1 Creating a SortingAnalzyer
The SortingAnalzyer is the key object for postprocessing in SpikeInterface. We can create the SortingAnalzyer by passing the sorting output and preprocessed recording:
from spikeinterface import create_sorting_analyzeranalyzer = create_sorting_analyzer( sorting=sorting, recording=first_shank_recording)
Using this sorting analyzer interface, we will compute all of the postprocessing results needed to assess the quality of our sorting.
Some sorters will output their own waveforms, templates, amplitudes ETC (for example, Kilosort has a templates.npy file containnig the templates it generated during sorting).
These are not loaded by SpikeInterface. Instead, the sorting analzyer will always recompute these features directly from the passed recording.
3.4.2 Selecting spikes
In our sorting, we can have many many detected spikes. This may be in the order of millios of spikes for an hour long recording.
In general, we will compute summary statistics over metrics computed from individual spikes. Therefore, including millions of spikes is computationally intensive while providing marginal gains. Instead, we can subselect spikes to include in postprocessing computations (typically, 3-500 spikes per unit is used a default).
<spikeinterface.core.analyzer_extension_core.ComputeRandomSpikes at 0x7f5e1fa5abd0>
To access the data assicated with a computed extension, we can use the get_extension function as below. We can see the indices (and compute the spike times) for the selected spikes:
The action potential waveforms are important for assessing unit quality. For example, we expect that the waveforms of spikes which belong to a single unit are all similar.
To compute waveforms in SpikeInterface, we can use the waveforms extension. This will βchop outβ the spike waveform from the data, with a time window specified by the ms_before and ms_after (the spike peak) arguments. For example in the schematic below, we could cut out the blue spike in a window defined by the red blox:
Example raw trace segment with an artifact highlighted in red.
<spikeinterface.core.analyzer_extension_core.ComputeWaveforms at 0x7f5e1f6c5990>
The waveforms array is three dimensional `(num waveforms, num samples, num channels)
waveforms = analyzer.get_extension("waveforms").data["waveforms"]print(waveforms.shape)# This is the waveform of the 19th spike (chosen as it looks nice)plt.imshow(waveforms[18, :, :].T, aspect="auto")plt.show()
(1533, 90, 26)
3.4.4 Templates
The template is a representative wavecform from a putative neuron. Often, it is computed as the average waveform from all of the spikes that were assigned to a unit.
We can compute the extension and access the templates with:
SpikeInterface also includes many useful post-processing plots in the widget smodule. For example we can plot a unit template as below:
# Plot the template waveform of the first unit si_widgets.plot_unit_templates(analyzer, unit_ids=[0])plt.show()
3.4.5 Amplitudes
Finally, spike amplitudes are a useful measure for assessing unit quality. We expect that the amplitude of a spike is relatively stable througghout a recording. Changes in spike amplitudes may indicate problems with hte neuron health, drift, or other issues.
This gives a list of amplitudes, one for every spike. We will explore how visualisng spike amplitude across time is a useful measure for quality asessment later.
3.5 Manual Curation in spikeinterface-gui
From this, we can compute quality metrics used to filter the units e.g. bombcell. We cover this more in the postprocessing chapter.