Skip to content

Add read_nwb_sorting_analyzer: build a SortingAnalyzer from an NWB Units table - #4645

Open
h-mayorquin wants to merge 46 commits into
SpikeInterface:mainfrom
h-mayorquin:load_analyzer_nwb_heberto
Open

h-mayorquin wants to merge 46 commits into
SpikeInterface:mainfrom
h-mayorquin:load_analyzer_nwb_heberto

Conversation

@h-mayorquin

@h-mayorquin h-mayorquin commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

read_nwb_sorting_analyzer builds a curatable SortingAnalyzer directly from an NWB (Neurodata Without Borders) Units table, populating each extension from what the file already stores rather than recomputing it. When the Units table has waveform_mean, the analyzer is built recordingless from those stored templates, plus the per-unit metrics and the electrodes region for sparsity; when the file has an accessible ElectricalSeries, it is used as the recording. It mirrors read_kilosort_as_analyzer in structure, injecting the templates, quality and template metrics, sparsity, and random_spikes extensions from the file's contents. This supersedes the earlier draft #4270.

The reads are deliberate: Units columns are classified from metadata and only the templates, the electrodes region, and the scalar metric/label columns are materialized, while the large per-spike ragged columns (spike times, amplitudes, depths) are never touched at build. The sorting is kept lazy so its spike times are read only on demand, which builds on #4662 (single bulk-read NWB spike vector) and the copy_sorting option (#4668); the recordingless case builds a lightweight placeholder recording from the electrode rel_x/rel_y geometry (via generate_ground_truth_recording, #4588) purely to carry probe geometry into the standard constructor, then drops it. Together these keep the build small and memory-light regardless of file size.

Because the reads are deliberate and the sorting stays lazy, the function works the same whether the file is local or streamed (stream_mode is passed through to the extractors). That makes the streamed case cheap: as a check, building from a real IBL (International Brain Laboratory) processed file on dandiset 000409, session 6713a4a7-faed-4df2-acab-ee4e63326f8d (898 units, 20.7M spikes), produced a curatable analyzer in about 14 s while transferring only ~22 MB, with the ~130 MB spike read deferred until a spike-based view needs it. This PR is the reader itself; a separate how-to PR will cover the streaming-from-DANDI workflow in depth. It depends on #4662 and the copy_sorting PR (#4668), which should merge first so this branch rebases down to just the reader.

from dandi.dandiapi import DandiAPIClient
from spikeinterface.extractors import read_nwb_sorting_analyzer

# IBL Brain Wide Map (dandiset 000409), one session's processed file (units + templates, no raw traces)
SESSION = "6713a4a7-faed-4df2-acab-ee4e63326f8d"
with DandiAPIClient() as client:
    dandiset = client.get_dandiset("000409", "draft")
    asset = next(a for a in dandiset.get_assets_by_glob(f"*{SESSION}*.nwb") if "desc-processed" in a.path)
    s3_url = asset.get_content_url(follow_redirects=1, strip_query=True)

# build the analyzer directly from the streamed Units table (no full download)
analyzer = read_nwb_sorting_analyzer(s3_url, stream_mode="remfile")

print(analyzer)
print("num_units:", analyzer.get_num_units())
print("templates:", analyzer.get_extension("templates").get_data().shape)
print("quality metrics:", list(analyzer.get_extension("quality_metrics").get_data().columns))

# open in spikeinterface-gui for curation (needs a display and `pip install spikeinterface-gui`)
# import spikeinterface_gui
# spikeinterface_gui.run_mainwindow(analyzer)

Comment thread src/spikeinterface/core/sortinganalyzer.py Outdated
@h-mayorquin h-mayorquin changed the title Experiments - GUI - Dandi Support Add read_nwb_sorting_analyzer: build a SortingAnalyzer from an NWB Units table Jul 9, 2026
@h-mayorquin
h-mayorquin requested a review from chrishalcrow July 9, 2026 12:45
@alejoe91

Copy link
Copy Markdown
Member

This is failing on https://dandiarchive.s3.amazonaws.com/blobs/c41/fae/c41fae67-e6e2-4dc9-adcb-6131d530b6cd since it has two probes and when you select groups it calls a select_units, which doesn't have the spike_times_data

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[4], line 1
----> 1 analyzer = se.read_nwb_sorting_analyzer(dandi_path_sorting, stream_mode="remfile", group_name="Probe00")

File ~/Documents/codes/spike_sorting/spikeinterface/spikeinterface/src/spikeinterface/extractors/nwbextractors.py:2108, in read_nwb_sorting_analyzer(file_path, t_start, sampling_frequency, electrical_series_path, unit_table_path, stream_mode, stream_cache_path, cache, storage_options, use_pynwb, group_name, compute_extra, compute_extra_params, extension_map, verbose)
   2106     analyzer_channel_ids = list(recording.get_channel_ids())
   2107 else:
-> 2108     analyzer_recording, analyzer_channel_ids = _make_placeholder_recording_from_electrodes(
   2109         sorting, electrodes_table, electrodes_indices, verbose=verbose
   2110     )
   2112 # Per-unit sparsity and channel map from the Units `electrodes` region. Each unit's `waveform_mean`
   2113 # is stored only on a subset of channels (near its peak); the region gives which channels those are.
   2114 # We use it both for the analyzer sparsity and to scatter the waveforms onto their true channel
   2115 # positions instead of stacking the sparse block densely.
   2116 sparsity, unit_local_channels = _make_sparsity_from_electrodes(
   2117     sorting, electrodes_table, electrodes_indices, analyzer_channel_ids
   2118 )

File ~/Documents/codes/spike_sorting/spikeinterface/spikeinterface/src/spikeinterface/extractors/nwbextractors.py:2200, in _make_placeholder_recording_from_electrodes(sorting, electrodes_table, electrodes_indices, verbose)
   2192 locations = np.array([electrodes_table_sliced["rel_x"][:], electrodes_table_sliced["rel_y"][:]]).T
   2194 # The recording length only needs to loosely bound the timeline for the recordingless GUI; nothing
   2195 # about curation depends on it being exact. Estimate it cheaply from the last stored spike time (one
   2196 # element, i.e. only the last spike_times chunk) rather than scanning the whole array for the true
   2197 # global maximum. spike_times is in seconds, so this is already a duration in seconds. It is only an
   2198 # approximation: because spike_times is concatenated per unit and not globally sorted, this is the
   2199 # last unit's last spike, not necessarily the latest spike overall.
-> 2200 last_spike_time = float(np.asarray(sorting._sorting_segments[0].spike_times_data[-1]))
   2201 duration = last_spike_time + 1.0
   2203 probe = Probe(si_units="um")

AttributeError: 'UnitsSelectionSortingSegment' object has no attribute 'spike_times_data'

@alejoe91

Copy link
Copy Markdown
Member

This is failing on https://dandiarchive.s3.amazonaws.com/blobs/c41/fae/c41fae67-e6e2-4dc9-adcb-6131d530b6cd since it has two probes and when you select groups it calls a select_units, which doesn't have the spike_times_data

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[4], line 1
----> 1 analyzer = se.read_nwb_sorting_analyzer(dandi_path_sorting, stream_mode="remfile", group_name="Probe00")

File ~/Documents/codes/spike_sorting/spikeinterface/spikeinterface/src/spikeinterface/extractors/nwbextractors.py:2108, in read_nwb_sorting_analyzer(file_path, t_start, sampling_frequency, electrical_series_path, unit_table_path, stream_mode, stream_cache_path, cache, storage_options, use_pynwb, group_name, compute_extra, compute_extra_params, extension_map, verbose)
   2106     analyzer_channel_ids = list(recording.get_channel_ids())
   2107 else:
-> 2108     analyzer_recording, analyzer_channel_ids = _make_placeholder_recording_from_electrodes(
   2109         sorting, electrodes_table, electrodes_indices, verbose=verbose
   2110     )
   2112 # Per-unit sparsity and channel map from the Units `electrodes` region. Each unit's `waveform_mean`
   2113 # is stored only on a subset of channels (near its peak); the region gives which channels those are.
   2114 # We use it both for the analyzer sparsity and to scatter the waveforms onto their true channel
   2115 # positions instead of stacking the sparse block densely.
   2116 sparsity, unit_local_channels = _make_sparsity_from_electrodes(
   2117     sorting, electrodes_table, electrodes_indices, analyzer_channel_ids
   2118 )

File ~/Documents/codes/spike_sorting/spikeinterface/spikeinterface/src/spikeinterface/extractors/nwbextractors.py:2200, in _make_placeholder_recording_from_electrodes(sorting, electrodes_table, electrodes_indices, verbose)
   2192 locations = np.array([electrodes_table_sliced["rel_x"][:], electrodes_table_sliced["rel_y"][:]]).T
   2194 # The recording length only needs to loosely bound the timeline for the recordingless GUI; nothing
   2195 # about curation depends on it being exact. Estimate it cheaply from the last stored spike time (one
   2196 # element, i.e. only the last spike_times chunk) rather than scanning the whole array for the true
   2197 # global maximum. spike_times is in seconds, so this is already a duration in seconds. It is only an
   2198 # approximation: because spike_times is concatenated per unit and not globally sorted, this is the
   2199 # last unit's last spike, not necessarily the latest spike overall.
-> 2200 last_spike_time = float(np.asarray(sorting._sorting_segments[0].spike_times_data[-1]))
   2201 duration = last_spike_time + 1.0
   2203 probe = Probe(si_units="um")

AttributeError: 'UnitsSelectionSortingSegment' object has no attribute 'spike_times_data'

Fixed in last couple of commits

@alejoe91 alejoe91 mentioned this pull request Jul 17, 2026
1 task
@h-mayorquin

h-mayorquin commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for fixing the multi-group bug and the random-spikes handling. I built on that and fixed a few more things on the branch:

  • Multi-group root cause (bytes vs str): the group_name="Probe00" crash was a byte-string comparison. NWB stores group_name and channel_name as HDF5 byte strings, so the group mask matched zero units. I now decode them to str at the source in _create_df_from_nwb_table, which also fixes the object-dtype channel-id rejection.
  • Build laziness, duration: the get_last_spike_time override sat on NwbSortingSegment instead of NwbSortingExtractor, so it never resolved and the placeholder duration fell through to to_spike_vector, reading the full ~130 MB spike array at build. Replaced it with a cheap spike_times[-1] * 2 bound, since the placeholder only needs to loosely bound the timeline.
  • Build laziness, count: the group path called count_total_num_spikes() on the UnitsSelectionSorting wrapper, which materializes the parent's whole spike vector (~482 MB). Replaced it with a count read straight from spike_times_index. Together these keep the build lazy: single-probe ~150 MB down to 22 MB, two-probe 482 MB down to 47 MB.
  • Init latency: cut the extractor init from ~591 network round-trips to ~35 by not walking the whole file to locate the Units table and the ElectricalSeries. Canonical /units and /acquisition checks, plus defaulting t_start = 0.
  • waveform_unit: dropped the plumbing since the schema fixes that attribute to volts and pynwb never writes the intended value (your issue Update readme to point to the latest release docs #2162). Infer the volts-to-microvolts scale from the template magnitude instead, on by default.

Two behavior changes to flag: with a /units table present we no longer raise on multiple Units tables, and an unnamed ElectricalSeries no longer raises since t_start now defaults to 0.

@h-mayorquin

Copy link
Copy Markdown
Contributor Author

NeurodataWithoutBorders/pynwb#2162

It was on the code (as a comment but not here)

@alejoe91
alejoe91 requested a review from JoeZiminski September 3, 2026 15:20
@alejoe91
alejoe91 marked this pull request as ready for review September 14, 2026 10:21
@samuelgarcia

Copy link
Copy Markdown
Member

This is impressive.
OK for me.

print("Could not load recording, proceeding without it")
recording = None

t_start_tmp = 0 if t_start is None else t_start

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
t_start_tmp = 0 if t_start is None else t_start
if electrical_series_path is None:
t_start_tmp = 0 if t_start is None else t_start
else:
t_start_tmp = None

You can't pass an electical_series_path and a t_start. Something like this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it raise an error up-front if you try?

if extension_map is not None:
resolved_extension_map.update(extension_map)
# try to read recording object to get the analyzer
try:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This try/except tricked me for a bit. I wasn't passing an electrical_series_path so this recording load was failing silently, then there were confusing downstream bugs. Not sure the solution.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same I was having some errors detailed below, removing the try/accept lead to somewhat confusing error like: ValueError: Multiple ElectricalSeries found in the file. Please specify the 'electrical_series_path' argument:Available options are: [].

debug code
from dandi.dandiapi import DandiAPIClient
from spikeinterface.extractors import read_nwb_sorting_analyzer

DANDISET = "000253"
VERSION = "0.240923.1441"

with DandiAPIClient() as client:
    dandiset = client.get_dandiset(DANDISET, VERSION)
    asset = next(a for a in dandiset.get_assets() if a.path.endswith(".nwb"))
    s3_url = asset.get_content_url(follow_redirects=1, strip_query=True)

import remfile
import h5py

analyzer = read_nwb_sorting_analyzer(
    s3_url, 
    stream_mode="remfile",
    sampling_frequency=30_000.0,
  #  group_name="18005110031 1-281"
)

@chrishalcrow

Copy link
Copy Markdown
Member

I was trying this out on non-IBL datasets and I think this code makes quite a lot of assumptions about how the data is structured. E.g. all datasets I tried do not store an electrode index in their units table. I've not managed to load anything non-IBL - I failed to load any Allen datasets, but maybe @alejoe91 knows a good one to try.

So I think the current function name is over promising, and we should change it to be more IBL focused. Or maybe shelve this for 0.105 and work to try and make it more general.

@alejoe91 alejoe91 modified the milestones: 0.105.0, 0.106.0 Sep 15, 2026
@h-mayorquin

Copy link
Copy Markdown
Contributor Author

Thanks for trying this, @alejoe91 I am gonna do another pass on Thursday maybe it can make it for release : )

@JoeZiminski

Copy link
Copy Markdown
Contributor

Thanks @h-mayorquin will check this out today / tomorrow! I am curious on Chris' message and the NWB provenance. Does this load NWB files when they strictly adhere to the NWB format as described here? And the issue is that in many cases data is stored in NWB files but if not according to the exact schema?

@JoeZiminski JoeZiminski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @h-mayorquin this is very cool, is very nice to be able to load a NWB file directly into an analyzer! + was interesting read, some general comments below.

One thing I was unsure about was what exactly the recording is used for. AFAICT It's loaded so it can be attached to the analyzer, and also for the channel_ids. We also need it for t_start but this is through a parallel mechanism (the NWBSortingExtractor) which I found slightly confusing (to allow a single source of truth, can we use the loaded recording (if successful) also for the t_start?).

Even if the recording can be loaded, is it a good idea to expose it through the analyzer? Is the intention that the user can do things like compute("waveforms") using the dummy randomly selected spikes, and/or recompute randomly selected spikes, waveforms, templates etc? If not I would unset the recording from the analzer to make clearer to the user this isn't a true analyzer but instead a handy wrapper for the precomputed data.

On this point more generally (outside the scope of this PR, but for discussion), now with this PR and the kilosort extractor, there are a few cases where an analyzer can be loaded from external sources to produce something close to an analyzer but which doesn't have the same guarantees. I wonder if it would be nice to have this as a distinct construct, like PrecomputedAnalyzer or ImmutableAnalzyer to make it very clear that this thing is not your usual analyzer. For example get_probe() when no recording will return the probe generated from the unit table, but in general a user would expect the full probe geometry as used in the recording. Similarly the extensions e.g. "templates" are set directly on the extension data object without going through a formal API (as it doesn't exist). This is okay but might be brittle going forward in case the analyzer shape changes. This is all outside of scope but just a note that if the pattern of loading external things into analysers in this way continues, we should probably formalise it.

I tried testing on some other random datasets, below are some issues (using this code below):

from dandi.dandiapi import DandiAPIClient
from spikeinterface.extractors import read_nwb_sorting_analyzer

DANDISET = "000469"
VERSION = "0.240123.1806"

with DandiAPIClient() as client:
    dandiset = client.get_dandiset(DANDISET, VERSION)
    asset = next(a for a in dandiset.get_assets() if a.path.endswith(".nwb"))
    s3_url = asset.get_content_url(follow_redirects=1, strip_query=True)

analyzer = read_nwb_sorting_analyzer(
    s3_url, 
    stream_mode="remfile",
  #  sampling_frequency=30_000.0,
  #  group_name="18005110031 1-281"
)

Often I had to manually pass the sampling_frequency, I'm not sure why it was failing to detect this on the recordings I tried. For

DANDISET = "000363"
VERSION = "0.231012.2129"

I got

#  File "....spikeinterface\src\spikeinterface\extractors\nwbextractors.py", line 2373, in _make_templates
#    dense_templates[unit_index][:, positions] = waveform_mean[unit_index][:, :k]

it didn't seem to like that these are 1D waveform means, but i think this is supported by NWB spec and so would make sense to handle.

Similarly for

DANDISET = "000469"
VERSION = "0.240123.1806"

it didn't like that the electrode values were scalar not list, but I think this is supported in NWB spec (I think related issue to above)

Finally with

DANDISET = "000253"
VERSION = "0.240923.1441"

The electrodes couldn't be found and it failed. In this case (any maybe other unsupported cases) it can fail with a message indicating the information it was expecting, but could not find.

return 0
return int(np.max(spikes_in_segment["sample_index"]))

def get_last_spike_time(self, segment_index: int | None = None) -> float:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this / the version in NwbSortingSegment used anywhere?

print("Could not load recording, proceeding without it")
recording = None

t_start_tmp = 0 if t_start is None else t_start

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it raise an error up-front if you try?

print("Could not load recording, proceeding without it")
recording = None

t_start_tmp = 0 if t_start is None else t_start

@JoeZiminski JoeZiminski Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this stage if the recording was successfully loaded do we want to read t_start directly from it and set electrical_series_path to None below? (rather than perform it twice)

load_unit_properties=False, # columns are read deliberately below, into their extensions
)

sorting = sorting_tmp

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to do this but maybe an explanatory comment, could sorting_tmp be called instead sorting_orig or something?

)

sorting = sorting_tmp
# Recordingless case: leave t_start at 0 (set when the sorting was constructed). NWB spike times are

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recordingless case (+ when t_start is not passed)?

positions = [
position_of_channel_id[electrode_row_to_channel_id[int(electrode_row)]]
for electrode_row in region
if electrode_row_to_channel_id[int(electrode_row)] in position_of_channel_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it not the case that if this is not true, something has gone wrong? e.g. a channel id exists in the units table that does not exist on the full probe?

# `read_nwb_sorting_analyzer`); `None` disables an extension. This is only for real analyzer extensions;
# sparsity, sorting properties, and recording metadata are handled separately by the reader.
#
# The "typed_container" source is a hook: the typed reader currently lives in ndx-spikesorting, so for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially found this a bit confusing, with the hooks ignored and and unit_locations is computed by default through a different mechanism compute_extra: List[str] | None = ["unit_locations"] but reading this you might anticipate it be read from UnitsMetrics. For clarity could these hooks be removed and implemented together when ndx-spikesorting is supported

rescale_templates_to_uV: bool = True,
verbose: bool = False,
) -> SortingAnalyzer:
# extension_map overrides (per extension) merge over DEFAULT_EXTENSION_MAP; see its docstring.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could expand the docstring here, in particular to explan what the recording is used for and when (I think channel_ids, and t_start) and what you can / cannot do with the produced analyzer (e.g. can I compute("waveforms") directly from the recording, given the dummy random_spikes="all", remake templates etc?

if extension_map is not None:
resolved_extension_map.update(extension_map)
# try to read recording object to get the analyzer
try:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same I was having some errors detailed below, removing the try/accept lead to somewhat confusing error like: ValueError: Multiple ElectricalSeries found in the file. Please specify the 'electrical_series_path' argument:Available options are: [].

debug code
from dandi.dandiapi import DandiAPIClient
from spikeinterface.extractors import read_nwb_sorting_analyzer

DANDISET = "000253"
VERSION = "0.240923.1441"

with DandiAPIClient() as client:
    dandiset = client.get_dandiset(DANDISET, VERSION)
    asset = next(a for a in dandiset.get_assets() if a.path.endswith(".nwb"))
    s3_url = asset.get_content_url(follow_redirects=1, strip_query=True)

import remfile
import h5py

analyzer = read_nwb_sorting_analyzer(
    s3_url, 
    stream_mode="remfile",
    sampling_frequency=30_000.0,
  #  group_name="18005110031 1-281"
)

check_recordings_equal(recording_backend, recording_pynwb)


def _make_units_nwb(path, n_units=6, n_ch=8, n_samp=30, with_std=False):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how difficult it would be in the SI testing infrastructure but it would be nice to also test this on some very small real world recordings, across pynwb and the other path, and check against manually verified ground truth (e.g. label and quality metric values, template values).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Edinburgh hackathon 2026 PRs from Edinburgh hackathon 2026 extractors Related to extractors module

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants