unitrefine 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unitrefine/__init__.py +75 -0
- unitrefine/apply_models.py +111 -0
- unitrefine/cli.py +251 -0
- unitrefine/evaluation.py +457 -0
- unitrefine/figures.py +1458 -0
- unitrefine/import_labels.py +176 -0
- unitrefine/launch_sigui.py +138 -0
- unitrefine/launch_sigui_validate.py +243 -0
- unitrefine/launcher.py +239 -0
- unitrefine/main.py +2395 -0
- unitrefine/manage_models.py +193 -0
- unitrefine/metrics.py +146 -0
- unitrefine/model_selection.py +159 -0
- unitrefine/models_hub.py +118 -0
- unitrefine/phy_curation.py +306 -0
- unitrefine/phy_import.py +428 -0
- unitrefine/predictions.py +507 -0
- unitrefine/resources/curation_gui.jpg +0 -0
- unitrefine/resources/fonts/Inter-Bold.ttf +0 -0
- unitrefine/resources/fonts/Inter-LICENSE.txt +92 -0
- unitrefine/resources/fonts/Inter-Regular.ttf +0 -0
- unitrefine/resources/logo.png +0 -0
- unitrefine/resources/model_training_example.jpg +0 -0
- unitrefine/resources/predict-gui.JPG +0 -0
- unitrefine/resources/re-label.JPG +0 -0
- unitrefine/resources/si-gui.JPG +0 -0
- unitrefine/resources/train_and_load_gui.JPG +0 -0
- unitrefine/resources/unit_types_example.jpg +0 -0
- unitrefine/resources/unitrefine_gui.JPG +0 -0
- unitrefine/retraining_history.py +220 -0
- unitrefine/sigui_progress.py +157 -0
- unitrefine/theme.py +254 -0
- unitrefine/train.py +622 -0
- unitrefine/widgets.py +961 -0
- unitrefine-0.2.0.dist-info/METADATA +153 -0
- unitrefine-0.2.0.dist-info/RECORD +40 -0
- unitrefine-0.2.0.dist-info/WHEEL +5 -0
- unitrefine-0.2.0.dist-info/entry_points.txt +4 -0
- unitrefine-0.2.0.dist-info/licenses/LICENSE +21 -0
- unitrefine-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Translate a manual curation done in phy back into the SpikeInterface curation format.
|
|
3
|
+
|
|
4
|
+
The phy folder must come from `spikeinterface.exporters.export_to_phy`. phy only stores the end
|
|
5
|
+
result of a curation (the final cluster of every spike), so the curation is reconstructed as an
|
|
6
|
+
equivalent sequence of SpikeInterface steps: splits, then merges, then labels on the final units.
|
|
7
|
+
The result is the same as doing the curation in SpikeInterface-GUI.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from spikeinterface.core import SortingAnalyzer
|
|
16
|
+
from spikeinterface.curation import apply_curation
|
|
17
|
+
from spikeinterface.curation.curation_model import Curation, SequentialCuration
|
|
18
|
+
|
|
19
|
+
# phy group / UnitRefine labels -> SpikeInterface-GUI quality labels
|
|
20
|
+
QUALITY_LABELS = {"good": "good", "sua": "good", "mua": "MUA", "noise": "noise"}
|
|
21
|
+
QUALITY_OPTIONS = ["good", "MUA", "noise"]
|
|
22
|
+
UNLABELED = {"", "unsorted", "nan"}
|
|
23
|
+
|
|
24
|
+
# cluster_*.tsv files that phy or export_to_phy write but that are not curation labels
|
|
25
|
+
NON_LABEL_FILES = {"cluster_group.tsv", "cluster_info.tsv", "cluster_si_unit_ids.tsv", "cluster_channel_group.tsv"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HardMergeRequired(RuntimeError):
|
|
29
|
+
"""Raised when phy merges can only be applied as hard merges, which need the raw recording."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def phy_to_curation(phy_folder, sorting_or_analyzer):
|
|
33
|
+
"""
|
|
34
|
+
Build a SpikeInterface curation from the changes made in phy.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
phy_folder : str | Path
|
|
39
|
+
Folder created by `export_to_phy` and curated in phy.
|
|
40
|
+
sorting_or_analyzer : BaseSorting | SortingAnalyzer
|
|
41
|
+
The sorting or analyzer that was exported to phy.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
curation : SequentialCuration
|
|
46
|
+
Curation that can be applied with `spikeinterface.curation.apply_curation`.
|
|
47
|
+
cluster_to_unit : dict
|
|
48
|
+
Maps each final phy cluster id to the unit id it gets after the curation.
|
|
49
|
+
"""
|
|
50
|
+
phy_folder = Path(phy_folder)
|
|
51
|
+
sorting = getattr(sorting_or_analyzer, "sorting", sorting_or_analyzer)
|
|
52
|
+
if sorting.get_num_segments() != 1:
|
|
53
|
+
raise ValueError("export_to_phy only exports the first segment, so only single-segment sortings are supported")
|
|
54
|
+
|
|
55
|
+
unit_ids = sorting.unit_ids.tolist()
|
|
56
|
+
original = np.load(phy_folder / "spike_templates.npy").ravel() # unit index per spike, never changed by phy
|
|
57
|
+
final = np.load(phy_folder / "spike_clusters.npy").ravel() # cluster per spike after curation
|
|
58
|
+
|
|
59
|
+
counts = np.bincount(original, minlength=len(unit_ids))
|
|
60
|
+
if len(final) != len(original) or not np.array_equal(counts, sorting.count_num_spikes_per_unit(outputs="array")):
|
|
61
|
+
raise ValueError(f"{phy_folder} does not match the spike counts of this sorting - was it exported from it?")
|
|
62
|
+
|
|
63
|
+
# final clusters of each unit's spikes, in spike train order
|
|
64
|
+
order = np.argsort(original, kind="stable")
|
|
65
|
+
final_per_unit = np.split(final[order], np.cumsum(counts)[:-1])
|
|
66
|
+
|
|
67
|
+
whole_units = {} # final cluster -> units that went into it completely
|
|
68
|
+
split_parts = {} # unit -> [(final cluster, spike indices within the unit)]
|
|
69
|
+
for unit_id, unit_clusters in zip(unit_ids, final_per_unit):
|
|
70
|
+
clusters = np.unique(unit_clusters)
|
|
71
|
+
if len(clusters) == 1:
|
|
72
|
+
whole_units.setdefault(int(clusters[0]), []).append(unit_id)
|
|
73
|
+
elif len(clusters) > 1:
|
|
74
|
+
split_parts[unit_id] = [(int(c), np.flatnonzero(unit_clusters == c).tolist()) for c in clusters]
|
|
75
|
+
|
|
76
|
+
split_pieces = {} # final cluster -> number of split pieces that went into it
|
|
77
|
+
for parts in split_parts.values():
|
|
78
|
+
for cluster, _ in parts:
|
|
79
|
+
split_pieces[cluster] = split_pieces.get(cluster, 0) + 1
|
|
80
|
+
|
|
81
|
+
used_ids = set(unit_ids)
|
|
82
|
+
use_str = len(unit_ids) > 0 and isinstance(unit_ids[0], str)
|
|
83
|
+
|
|
84
|
+
def take_id(preferred=None):
|
|
85
|
+
new_id = None if preferred is None else (str(preferred) if use_str else preferred)
|
|
86
|
+
if new_id is None or new_id in used_ids:
|
|
87
|
+
new_id = max((int(u) for u in used_ids if str(u).isdigit()), default=-1) + 1
|
|
88
|
+
new_id = str(new_id) if use_str else new_id
|
|
89
|
+
used_ids.add(new_id)
|
|
90
|
+
return new_id
|
|
91
|
+
|
|
92
|
+
# unchanged units keep their id, new units get their phy cluster id if it is free
|
|
93
|
+
cluster_to_unit = {}
|
|
94
|
+
for cluster in sorted(set(whole_units) | set(split_pieces)):
|
|
95
|
+
units = whole_units.get(cluster, [])
|
|
96
|
+
if len(units) == 1 and cluster not in split_pieces:
|
|
97
|
+
cluster_to_unit[cluster] = units[0]
|
|
98
|
+
else:
|
|
99
|
+
cluster_to_unit[cluster] = take_id(cluster)
|
|
100
|
+
|
|
101
|
+
# step 1: splits. A piece that is not merged afterwards directly gets its final id
|
|
102
|
+
splits = []
|
|
103
|
+
for unit_id, parts in split_parts.items():
|
|
104
|
+
new_ids = []
|
|
105
|
+
for cluster, _ in parts:
|
|
106
|
+
alone = cluster not in whole_units and split_pieces[cluster] == 1
|
|
107
|
+
new_ids.append(cluster_to_unit[cluster] if alone else take_id())
|
|
108
|
+
splits.append(dict(unit_id=unit_id, mode="indices", indices=[i for _, i in parts], new_unit_ids=new_ids))
|
|
109
|
+
|
|
110
|
+
# step 2: merges of all units and pieces that ended up in the same cluster
|
|
111
|
+
pieces = {cluster: list(units) for cluster, units in whole_units.items()}
|
|
112
|
+
for split in splits:
|
|
113
|
+
for (cluster, _), new_id in zip(split_parts[split["unit_id"]], split["new_unit_ids"]):
|
|
114
|
+
pieces.setdefault(cluster, []).append(new_id)
|
|
115
|
+
merges = [
|
|
116
|
+
dict(unit_ids=ids, new_unit_id=cluster_to_unit[cluster]) for cluster, ids in pieces.items() if len(ids) > 1
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
split_ids = [u for u in unit_ids if u not in split_parts] + [i for s in splits for i in s["new_unit_ids"]]
|
|
120
|
+
merged = {i for m in merges for i in m["unit_ids"]}
|
|
121
|
+
final_ids = [u for u in split_ids if u not in merged] + [m["new_unit_id"] for m in merges]
|
|
122
|
+
|
|
123
|
+
# step 3: labels on the final units. phy copies custom columns to split/merged clusters, so for
|
|
124
|
+
# new units only the curator's quality label is kept
|
|
125
|
+
label_definitions, labels = _read_phy_labels(phy_folder)
|
|
126
|
+
manual_labels = {}
|
|
127
|
+
unknown = set()
|
|
128
|
+
for name, values in labels.items():
|
|
129
|
+
for cluster, value in values.items():
|
|
130
|
+
if cluster not in cluster_to_unit:
|
|
131
|
+
unknown.add(cluster)
|
|
132
|
+
continue
|
|
133
|
+
unit_id = cluster_to_unit[cluster]
|
|
134
|
+
if name != "quality" and unit_id not in unit_ids:
|
|
135
|
+
continue
|
|
136
|
+
manual_labels.setdefault(unit_id, {"unit_id": unit_id, "labels": {}})["labels"][name] = [value]
|
|
137
|
+
if unknown:
|
|
138
|
+
warnings.warn(f"Ignored labels of {len(unknown)} cluster ids that no longer exist in {phy_folder}")
|
|
139
|
+
|
|
140
|
+
steps = []
|
|
141
|
+
if splits:
|
|
142
|
+
steps.append(dict(format_version="2", unit_ids=unit_ids, splits=splits))
|
|
143
|
+
if merges:
|
|
144
|
+
steps.append(dict(format_version="2", unit_ids=split_ids, merges=merges))
|
|
145
|
+
steps.append(
|
|
146
|
+
dict(
|
|
147
|
+
format_version="2",
|
|
148
|
+
unit_ids=final_ids,
|
|
149
|
+
label_definitions=label_definitions,
|
|
150
|
+
manual_labels=list(manual_labels.values()),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return SequentialCuration(curation_steps=steps), cluster_to_unit
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def apply_phy_curation(sorting_or_analyzer, phy_folder, allow_hard_merge=False, **apply_curation_kwargs):
|
|
157
|
+
"""
|
|
158
|
+
Apply a phy curation to the sorting or analyzer that was exported to phy.
|
|
159
|
+
|
|
160
|
+
Unit ids that phy did not change are kept, units created in phy get their phy cluster id
|
|
161
|
+
when it is free. The result is checked spike by spike against the phy folder.
|
|
162
|
+
Metrics are never taken from phy: the analyzer extensions are updated by SpikeInterface.
|
|
163
|
+
|
|
164
|
+
Merges are done as fast soft merges, which do not need the raw recording. Units that share too
|
|
165
|
+
few channels can only be merged with a hard merge, which recomputes the merged units from the
|
|
166
|
+
raw recording. This is only done with `allow_hard_merge=True`.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
sorting_or_analyzer : BaseSorting | SortingAnalyzer
|
|
171
|
+
The sorting or analyzer that was exported to phy.
|
|
172
|
+
phy_folder : str | Path
|
|
173
|
+
Folder created by `export_to_phy` and curated in phy.
|
|
174
|
+
allow_hard_merge : bool, default: False
|
|
175
|
+
If False, raise HardMergeRequired when some merges need a hard merge. If True, do these
|
|
176
|
+
merges as hard merges. The raw recording must then be attached to the analyzer.
|
|
177
|
+
**apply_curation_kwargs
|
|
178
|
+
Passed to `spikeinterface.curation.apply_curation`, e.g. `sparsity_overlap` or job kwargs.
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
curated : BaseSorting | SortingAnalyzer
|
|
183
|
+
The curated sorting or analyzer (in memory).
|
|
184
|
+
"""
|
|
185
|
+
curation, cluster_to_unit = phy_to_curation(phy_folder, sorting_or_analyzer)
|
|
186
|
+
steps = list(curation.curation_steps)
|
|
187
|
+
|
|
188
|
+
hard_merges = _merges_needing_hard_mode(
|
|
189
|
+
sorting_or_analyzer, steps, apply_curation_kwargs.get("sparsity_overlap", 0.75)
|
|
190
|
+
)
|
|
191
|
+
if hard_merges:
|
|
192
|
+
merge_list = ", ".join(f"{m.unit_ids} -> {m.new_unit_id}" for m in hard_merges)
|
|
193
|
+
if not allow_hard_merge:
|
|
194
|
+
raise HardMergeRequired(
|
|
195
|
+
f"{len(hard_merges)} merges cannot be done as a fast soft merge because the units share too few "
|
|
196
|
+
f"channels: {merge_list}. These merges need a hard merge, which requires the raw recording and will "
|
|
197
|
+
"likely take a long time to run. Check that the merges are intended, attach the recording with "
|
|
198
|
+
"`analyzer.set_temporary_recording(recording)` and rerun with `allow_hard_merge=True`."
|
|
199
|
+
)
|
|
200
|
+
if not (sorting_or_analyzer.has_recording() or sorting_or_analyzer.has_temporary_recording()):
|
|
201
|
+
raise HardMergeRequired(
|
|
202
|
+
f"Hard merges of {merge_list} need the raw recording, but the analyzer has none. "
|
|
203
|
+
"Attach it with `analyzer.set_temporary_recording(recording)`."
|
|
204
|
+
)
|
|
205
|
+
steps = _separate_hard_merges(steps, hard_merges)
|
|
206
|
+
|
|
207
|
+
# steps are applied one by one: apply_curation (spikeinterface 0.105) wraps job kwargs of a
|
|
208
|
+
# SequentialCuration in a `job_kwargs` argument, which breaks hard merges
|
|
209
|
+
curated = sorting_or_analyzer
|
|
210
|
+
for step in steps:
|
|
211
|
+
kwargs = dict(apply_curation_kwargs)
|
|
212
|
+
if hard_merges and step.merges and step.merges[0] in hard_merges:
|
|
213
|
+
kwargs["merging_mode"] = "hard"
|
|
214
|
+
curated = apply_curation(curated, step, **kwargs)
|
|
215
|
+
|
|
216
|
+
sorting = getattr(curated, "sorting", curated)
|
|
217
|
+
spike_times = np.load(Path(phy_folder) / "spike_times.npy").ravel()
|
|
218
|
+
final = np.load(Path(phy_folder) / "spike_clusters.npy").ravel()
|
|
219
|
+
order = np.argsort(final, kind="stable")
|
|
220
|
+
clusters, starts = np.unique(final[order], return_index=True)
|
|
221
|
+
mismatched = [
|
|
222
|
+
cluster
|
|
223
|
+
for cluster, cluster_times in zip(clusters, np.split(spike_times[order], starts[1:]))
|
|
224
|
+
if not np.array_equal(sorting.get_unit_spike_train(cluster_to_unit[int(cluster)]), cluster_times)
|
|
225
|
+
]
|
|
226
|
+
if mismatched:
|
|
227
|
+
raise RuntimeError(f"{len(mismatched)} curated units do not match their phy clusters, e.g. {mismatched[:5]}")
|
|
228
|
+
return curated
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _merges_needing_hard_mode(sorting_or_analyzer, steps, sparsity_overlap):
|
|
232
|
+
"""Return the merges whose units overlap too little for a soft merge."""
|
|
233
|
+
if not isinstance(sorting_or_analyzer, SortingAnalyzer) or sorting_or_analyzer.sparsity is None:
|
|
234
|
+
return []
|
|
235
|
+
# split pieces keep the channels of the unit they come from
|
|
236
|
+
parents = {}
|
|
237
|
+
for step in steps:
|
|
238
|
+
for split in step.splits:
|
|
239
|
+
parents.update({new_id: split.unit_id for new_id in split.new_unit_ids})
|
|
240
|
+
merges = [merge for step in steps for merge in step.merges]
|
|
241
|
+
groups = [[parents.get(u, u) for u in merge.unit_ids] for merge in merges]
|
|
242
|
+
mergeable = sorting_or_analyzer.are_units_mergeable(groups, sparsity_overlap=sparsity_overlap)
|
|
243
|
+
return [merge for merge, group in zip(merges, groups) if not mergeable[tuple(group)]]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _separate_hard_merges(steps, hard_merges):
|
|
247
|
+
"""Split the merge step into a step with the soft merges and a step with the hard merges."""
|
|
248
|
+
new_steps = []
|
|
249
|
+
for step in steps:
|
|
250
|
+
if not step.merges:
|
|
251
|
+
new_steps.append(step)
|
|
252
|
+
continue
|
|
253
|
+
soft = [m for m in step.merges if m not in hard_merges]
|
|
254
|
+
unit_ids = step.unit_ids
|
|
255
|
+
if soft:
|
|
256
|
+
soft_step = Curation(format_version="2", unit_ids=unit_ids, merges=soft)
|
|
257
|
+
new_steps.append(soft_step)
|
|
258
|
+
unit_ids = soft_step.get_final_ids_from_new_unit_ids()
|
|
259
|
+
new_steps.append(Curation(format_version="2", unit_ids=unit_ids, merges=hard_merges))
|
|
260
|
+
return new_steps
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _read_phy_labels(phy_folder):
|
|
264
|
+
"""Return SpikeInterface label definitions and {label name: Series(cluster id -> label)} from a phy folder."""
|
|
265
|
+
label_definitions = {"quality": dict(name="quality", label_options=QUALITY_OPTIONS, exclusive=True)}
|
|
266
|
+
labels = {}
|
|
267
|
+
|
|
268
|
+
group_file = phy_folder / "cluster_group.tsv"
|
|
269
|
+
if group_file.is_file():
|
|
270
|
+
group = pd.read_csv(group_file, sep="\t", index_col="cluster_id").iloc[:, 0]
|
|
271
|
+
quality = _to_quality_labels(group)
|
|
272
|
+
if quality is None:
|
|
273
|
+
raise ValueError(f"Unknown phy group labels in {group_file}: {sorted(set(group.dropna()))}")
|
|
274
|
+
labels["quality"] = quality
|
|
275
|
+
|
|
276
|
+
for tsv in sorted(phy_folder.glob("cluster_*.tsv")):
|
|
277
|
+
if tsv.name in NON_LABEL_FILES:
|
|
278
|
+
continue
|
|
279
|
+
table = pd.read_csv(tsv, sep="\t", index_col="cluster_id")
|
|
280
|
+
for name in table.columns:
|
|
281
|
+
if pd.api.types.is_numeric_dtype(table[name]) or name in label_definitions:
|
|
282
|
+
continue # metrics exported by export_to_phy, or a name that is already taken
|
|
283
|
+
values = _to_quality_labels(table[name])
|
|
284
|
+
if values is not None:
|
|
285
|
+
options = QUALITY_OPTIONS
|
|
286
|
+
else:
|
|
287
|
+
values = _drop_unlabeled(table[name])
|
|
288
|
+
options = sorted(set(values))
|
|
289
|
+
if len(options) < 2:
|
|
290
|
+
warnings.warn(f"Skipped column {name} in {tsv.name}: SpikeInterface needs at least two label options")
|
|
291
|
+
continue
|
|
292
|
+
label_definitions[name] = dict(name=name, label_options=options, exclusive=True)
|
|
293
|
+
labels[name] = values
|
|
294
|
+
return label_definitions, labels
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _drop_unlabeled(values):
|
|
298
|
+
values = values.dropna().astype(str).str.strip()
|
|
299
|
+
return values[~values.str.lower().isin(UNLABELED)]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _to_quality_labels(values):
|
|
303
|
+
"""Map labels to good/MUA/noise, or return None if some labels are not quality labels."""
|
|
304
|
+
values = _drop_unlabeled(values)
|
|
305
|
+
mapped = values.str.lower().map(QUALITY_LABELS)
|
|
306
|
+
return mapped if mapped.notna().all() else None
|