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.
Files changed (40) hide show
  1. unitrefine/__init__.py +75 -0
  2. unitrefine/apply_models.py +111 -0
  3. unitrefine/cli.py +251 -0
  4. unitrefine/evaluation.py +457 -0
  5. unitrefine/figures.py +1458 -0
  6. unitrefine/import_labels.py +176 -0
  7. unitrefine/launch_sigui.py +138 -0
  8. unitrefine/launch_sigui_validate.py +243 -0
  9. unitrefine/launcher.py +239 -0
  10. unitrefine/main.py +2395 -0
  11. unitrefine/manage_models.py +193 -0
  12. unitrefine/metrics.py +146 -0
  13. unitrefine/model_selection.py +159 -0
  14. unitrefine/models_hub.py +118 -0
  15. unitrefine/phy_curation.py +306 -0
  16. unitrefine/phy_import.py +428 -0
  17. unitrefine/predictions.py +507 -0
  18. unitrefine/resources/curation_gui.jpg +0 -0
  19. unitrefine/resources/fonts/Inter-Bold.ttf +0 -0
  20. unitrefine/resources/fonts/Inter-LICENSE.txt +92 -0
  21. unitrefine/resources/fonts/Inter-Regular.ttf +0 -0
  22. unitrefine/resources/logo.png +0 -0
  23. unitrefine/resources/model_training_example.jpg +0 -0
  24. unitrefine/resources/predict-gui.JPG +0 -0
  25. unitrefine/resources/re-label.JPG +0 -0
  26. unitrefine/resources/si-gui.JPG +0 -0
  27. unitrefine/resources/train_and_load_gui.JPG +0 -0
  28. unitrefine/resources/unit_types_example.jpg +0 -0
  29. unitrefine/resources/unitrefine_gui.JPG +0 -0
  30. unitrefine/retraining_history.py +220 -0
  31. unitrefine/sigui_progress.py +157 -0
  32. unitrefine/theme.py +254 -0
  33. unitrefine/train.py +622 -0
  34. unitrefine/widgets.py +961 -0
  35. unitrefine-0.2.0.dist-info/METADATA +153 -0
  36. unitrefine-0.2.0.dist-info/RECORD +40 -0
  37. unitrefine-0.2.0.dist-info/WHEEL +5 -0
  38. unitrefine-0.2.0.dist-info/entry_points.txt +4 -0
  39. unitrefine-0.2.0.dist-info/licenses/LICENSE +21 -0
  40. 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