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,193 @@
1
+ import json
2
+ import shutil
3
+ from functools import partial
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import PyQt5.QtWidgets as QtWidgets
8
+ from PyQt5.QtCore import pyqtSignal
9
+
10
+ from unitrefine.models_hub import hfh_model_groups
11
+
12
+
13
+ class HFHModelsDialog(QtWidgets.QDialog):
14
+ """Lets the user pick one or more Hugging Face Hub models to download into the project."""
15
+
16
+ def __init__(self, existing_repo_ids, parent=None):
17
+
18
+ super().__init__(parent)
19
+
20
+ self.setWindowTitle("Load models from Hugging Face Hub")
21
+ self.setMinimumWidth(550)
22
+
23
+ layout = QtWidgets.QVBoxLayout(self)
24
+ layout.addWidget(QtWidgets.QLabel("Select the UnitRefine models to download:"))
25
+
26
+ self.checkboxes = {}
27
+ for group_index, group in enumerate(hfh_model_groups):
28
+ if group_index > 0:
29
+ layout.addSpacing(15)
30
+ if group["title"] is not None:
31
+ group_title = QtWidgets.QLabel(group["title"])
32
+ group_title.setStyleSheet("font-weight: bold;")
33
+ layout.addWidget(group_title)
34
+
35
+ for repo_id, note in group["models"]:
36
+ checkbox = QtWidgets.QCheckBox(f"{repo_id} {note}".strip())
37
+ if repo_id in existing_repo_ids:
38
+ checkbox.setText(f"{repo_id} (already in project)")
39
+ checkbox.setChecked(True)
40
+ checkbox.setEnabled(False)
41
+ self.checkboxes[repo_id] = checkbox
42
+ layout.addWidget(checkbox)
43
+
44
+ other_label = QtWidgets.QLabel("Other Hugging Face repos (one per line, e.g. username/model-name):")
45
+ other_label.setStyleSheet("font-weight: bold;")
46
+ layout.addWidget(other_label)
47
+
48
+ self.other_repos_form = QtWidgets.QPlainTextEdit()
49
+ self.other_repos_form.setFixedHeight(70)
50
+ layout.addWidget(self.other_repos_form)
51
+
52
+ button_box = QtWidgets.QDialogButtonBox(
53
+ QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel
54
+ )
55
+ button_box.accepted.connect(self.accept)
56
+ button_box.rejected.connect(self.reject)
57
+ layout.addWidget(button_box)
58
+
59
+ def get_repo_ids(self):
60
+ """Returns the selected repo ids which are not already in the project, without duplicates."""
61
+
62
+ repo_ids = [repo_id for repo_id, checkbox in self.checkboxes.items() if checkbox.isChecked() and checkbox.isEnabled()]
63
+ for line in self.other_repos_form.toPlainText().replace(",", "\n").splitlines():
64
+ repo_id = line.strip()
65
+ if repo_id != "" and repo_id not in repo_ids:
66
+ repo_ids.append(repo_id)
67
+ return repo_ids
68
+
69
+
70
+ def get_model_summary(model_folder):
71
+ """
72
+ Returns the classes a model predicts, the number of training units per class and the
73
+ best balanced accuracy, read from the files saved next to the model.
74
+ """
75
+
76
+ model_folder = Path(model_folder)
77
+ summary = {"classes": [], "class_counts": {}, "balanced_accuracy": None}
78
+
79
+ model_info_path = model_folder / "model_info.json"
80
+ if model_info_path.is_file():
81
+ with open(model_info_path) as f:
82
+ label_conversion = json.load(f).get("label_conversion", {})
83
+ summary["classes"] = [label_conversion[key] for key in sorted(label_conversion, key=int)]
84
+
85
+ labels_path = model_folder / "labels.csv"
86
+ if labels_path.is_file():
87
+ labels = pd.read_csv(labels_path)
88
+ label_column = [column for column in labels.columns if column != "unit_index"][0]
89
+ # labels are stored as class indices; retrained models can store them as floats
90
+ class_indices = pd.to_numeric(labels[label_column], errors="coerce").dropna().astype(int).astype(str)
91
+ counts = class_indices.map(label_conversion).value_counts()
92
+ summary["class_counts"] = {label: int(counts.get(label, 0)) for label in summary["classes"]}
93
+
94
+ accuracies_path = model_folder / "model_accuracies.csv"
95
+ if accuracies_path.is_file():
96
+ accuracies = pd.read_csv(accuracies_path)
97
+ # older models saved the balanced accuracy in an `accuracy` column
98
+ accuracy_column = "balanced_accuracy" if "balanced_accuracy" in accuracies.columns else "accuracy"
99
+ if accuracy_column in accuracies.columns and len(accuracies) > 0:
100
+ summary["balanced_accuracy"] = float(accuracies[accuracy_column].max())
101
+
102
+ return summary
103
+
104
+
105
+ class ManageModelsWindow(QtWidgets.QDialog):
106
+ """Lists the models in a project with the classes they were trained on, and lets the user delete them."""
107
+
108
+ models_changed = pyqtSignal()
109
+
110
+ def __init__(self, project, parent=None):
111
+
112
+ super().__init__(parent)
113
+
114
+ self.project = project
115
+
116
+ self.setWindowTitle("UNITREFINE: Manage models")
117
+ self.resize(900, 400)
118
+
119
+ layout = QtWidgets.QVBoxLayout(self)
120
+ layout.addWidget(QtWidgets.QLabel("Models in this project. Balanced accuracy is on the held-out test set."))
121
+
122
+ self.table = QtWidgets.QTableWidget()
123
+ self.table.setColumnCount(5)
124
+ self.table.setHorizontalHeaderLabels(["Model", "Source", "Classes (training units)", "Balanced accuracy", ""])
125
+ self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
126
+ self.table.verticalHeader().setVisible(False)
127
+ layout.addWidget(self.table)
128
+
129
+ self.make_table()
130
+
131
+ def make_table(self):
132
+
133
+ self.table.setRowCount(len(self.project.model_paths))
134
+
135
+ for row, (model_folder, hfh_or_local) in enumerate(self.project.model_paths):
136
+
137
+ summary = get_model_summary(model_folder)
138
+
139
+ if len(summary["class_counts"]) > 0:
140
+ classes_text = ", ".join(f"{label}: {count}" for label, count in summary["class_counts"].items())
141
+ else:
142
+ classes_text = ", ".join(summary["classes"]) if len(summary["classes"]) > 0 else "unknown"
143
+
144
+ accuracy = summary["balanced_accuracy"]
145
+ accuracy_text = f"{accuracy:.3f}" if accuracy is not None else "---"
146
+
147
+ source_text = "Hugging Face" if hfh_or_local == "hfh" else "local"
148
+
149
+ model_item = QtWidgets.QTableWidgetItem(Path(model_folder).name)
150
+ model_item.setToolTip(str(model_folder))
151
+ self.table.setItem(row, 0, model_item)
152
+ self.table.setItem(row, 1, QtWidgets.QTableWidgetItem(source_text))
153
+ self.table.setItem(row, 2, QtWidgets.QTableWidgetItem(classes_text))
154
+ self.table.setItem(row, 3, QtWidgets.QTableWidgetItem(accuracy_text))
155
+
156
+ delete_button = QtWidgets.QToolButton()
157
+ delete_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_TrashIcon))
158
+ delete_button.setToolTip(f"Delete {Path(model_folder).name}")
159
+ delete_button.clicked.connect(partial(self.delete_model, model_folder))
160
+ self.table.setCellWidget(row, 4, delete_button)
161
+
162
+ self.table.resizeColumnsToContents()
163
+
164
+ def model_is_in_project_folder(self, model_folder):
165
+ return Path(model_folder).resolve().parent == (Path(self.project.folder_name) / "models").resolve()
166
+
167
+ def delete_model(self, model_folder):
168
+
169
+ model_name = Path(model_folder).name
170
+ in_project = self.model_is_in_project_folder(model_folder)
171
+
172
+ if in_project:
173
+ message = f"Delete the model '{model_name}'?\n\nThis permanently deletes the folder:\n{model_folder}"
174
+ else:
175
+ message = (
176
+ f"Remove the model '{model_name}' from this project?\n\n"
177
+ f"It was loaded from outside the project, so its folder is not deleted:\n{model_folder}"
178
+ )
179
+
180
+ answer = QtWidgets.QMessageBox.question(self, "Delete model", message)
181
+ if answer != QtWidgets.QMessageBox.Yes:
182
+ return
183
+
184
+ if in_project:
185
+ shutil.rmtree(model_folder)
186
+ print(f"Deleted model at {model_folder}.")
187
+ else:
188
+ print(f"Removed model at {model_folder} from project.")
189
+
190
+ self.project.model_paths = [model for model in self.project.model_paths if Path(model[0]) != Path(model_folder)]
191
+
192
+ self.make_table()
193
+ self.models_changed.emit()
unitrefine/metrics.py ADDED
@@ -0,0 +1,146 @@
1
+ """
2
+ Computes the metrics a model needs which an analyzer doesn't have yet, e.g. the drift metrics or the multi-channel
3
+ template metrics (spread, velocity_above, ...) used by the models on the Hugging Face Hub. Used by
4
+ unitrefine-apply --compute_missing_metrics (see cli.py), and by Apply in the GUI, which runs this file in its own
5
+ process (python -m unitrefine.metrics, see main below) so the window keeps responding.
6
+
7
+ The metrics are computed with SpikeInterface's default parameters, as the published models were trained with, and
8
+ saved in the analyzer. The metrics it already has are kept.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ import traceback
15
+
16
+ from unitrefine.import_labels import compute_all_metrics
17
+ from unitrefine.predictions import RENAMED_TEMPLATE_METRICS
18
+
19
+ # printed by main, so the GUI can show which analyzer is being done, and read the results
20
+ METRICS_STEP_MARKER = "UNITREFINE_METRICS_STEP:"
21
+ METRICS_RESULT_MARKER = "UNITREFINE_METRICS_RESULT:"
22
+
23
+ # the extensions metrics can depend on, in the order they can be computed in
24
+ PARENT_EXTENSIONS = ["random_spikes", "waveforms", "templates", "noise_levels", "spike_amplitudes", "spike_locations",
25
+ "principal_components"]
26
+
27
+
28
+ def required_metric_columns(model_folders):
29
+ """The metric columns the models use, with the names SpikeInterface uses now (e.g. peak_to_valley is now
30
+ peak_to_trough_duration)."""
31
+ from spikeinterface.curation.model_based_curation import get_required_metrics_from_model
32
+
33
+ columns = set()
34
+ for model_folder in model_folders:
35
+ columns |= set(get_required_metrics_from_model(model_folder=model_folder, trust_model=True))
36
+ return {RENAMED_TEMPLATE_METRICS.get(column, (column,))[0] for column in columns}
37
+
38
+
39
+ def metrics_to_compute(analyzer, model_folders):
40
+ """
41
+ The metrics to compute so the models can be applied: {"quality_metrics" or "template_metrics": [metric names]},
42
+ empty if the analyzer has them all.
43
+ """
44
+ from spikeinterface.metrics import ComputeQualityMetrics, ComputeTemplateMetrics
45
+
46
+ columns = set(compute_all_metrics(analyzer).columns)
47
+ # metrics computed before SpikeInterface 0.103.2 have the old names (e.g. peak_to_valley), which models accept
48
+ columns |= {new_name for old_name, (new_name, _) in RENAMED_TEMPLATE_METRICS.items() if old_name in columns}
49
+ missing = required_metric_columns(model_folders) - columns
50
+ to_compute = {}
51
+ for extension in [ComputeQualityMetrics, ComputeTemplateMetrics]:
52
+ for metric in extension.metric_list:
53
+ if len(missing & set(metric.metric_columns)) > 0:
54
+ to_compute.setdefault(extension.extension_name, []).append(metric.metric_name)
55
+ missing -= set(metric.metric_columns)
56
+ if len(missing) > 0:
57
+ raise ValueError(f"SpikeInterface can't compute the metrics {sorted(missing)}, which the models use.")
58
+ return to_compute
59
+
60
+
61
+ def compute_missing_metrics(analyzer, model_folders, job_kwargs=None):
62
+ """
63
+ Computes and saves the metrics the models need which `analyzer` doesn't have, and the extensions they
64
+ depend on. Returns {extension: [metric names]} of what was computed.
65
+ """
66
+ from spikeinterface.metrics import ComputeQualityMetrics, ComputeTemplateMetrics, get_multi_channel_template_metric_names
67
+
68
+ to_compute = metrics_to_compute(analyzer, model_folders)
69
+ if len(to_compute) == 0:
70
+ return to_compute
71
+
72
+ needed = set()
73
+ if "template_metrics" in to_compute:
74
+ needed |= {"random_spikes", "templates"}
75
+ if "quality_metrics" in to_compute:
76
+ needed |= set(ComputeQualityMetrics.get_optional_dependencies(metric_names=to_compute["quality_metrics"]))
77
+ # e.g. "amplitude_scalings" or "spike_amplitudes": spike_amplitudes is used
78
+ needed -= {"amplitude_scalings"}
79
+ if "principal_components" in needed:
80
+ needed |= {"waveforms"}
81
+ if len(needed) > 0:
82
+ needed |= {"random_spikes"}
83
+ parents = [name for name in PARENT_EXTENSIONS if name in needed and not analyzer.has_extension(name)]
84
+ if len(parents) > 0 and not (analyzer.has_recording() or analyzer.has_temporary_recording()):
85
+ raise ValueError(f"The analyzer has no recording, which is needed to compute {', '.join(parents)} for the "
86
+ f"metrics {to_compute}. Set its recording, or compute these metrics where the recording is.")
87
+
88
+ for name in parents:
89
+ compute_keeping_children(analyzer, name, job_kwargs)
90
+ for extension_name, metric_names in to_compute.items():
91
+ print(f" Computing {', '.join(metric_names)}...", flush=True)
92
+ params = dict(metric_names=list(metric_names), delete_existing_metrics=False)
93
+ if extension_name == "template_metrics":
94
+ params["include_multi_channel_metrics"] = len(set(metric_names) & set(get_multi_channel_template_metric_names())) > 0
95
+ analyzer.compute(extension_name, **params, **(job_kwargs or {}))
96
+ return to_compute
97
+
98
+
99
+ def compute_keeping_children(analyzer, extension_name, job_kwargs=None):
100
+ """
101
+ Computes an extension. SpikeInterface deletes the extensions which depend on it (e.g. computing waveforms, for
102
+ the PCA metrics, deletes the templates and the template metrics), so these are computed again, with the
103
+ parameters they had. Except for unit_locations, whose fit can change a little, they come back the same.
104
+ """
105
+ from spikeinterface.core.sortinganalyzer import _get_children_dependencies
106
+
107
+ children = [child for child in dict.fromkeys(_get_children_dependencies(extension_name)) if analyzer.has_extension(child)]
108
+ children_params = {child: analyzer.get_extension(child).params for child in children}
109
+ print(f" Computing {extension_name}...", flush=True)
110
+ analyzer.compute(extension_name, **(job_kwargs or {}))
111
+ for child in children:
112
+ if not analyzer.has_extension(child):
113
+ print(f" Computing {child} again (computing {extension_name} deleted it), with the same parameters...", flush=True)
114
+ analyzer.compute(child, **children_params[child], **(job_kwargs or {}))
115
+
116
+
117
+ def main(argv=None):
118
+ """
119
+ Computes the metrics the models need for each analyzer (see compute_missing_metrics). Before each analyzer it
120
+ prints METRICS_STEP_MARKER and "analyzer_{index}"; at the end, METRICS_RESULT_MARKER and a json list with, per
121
+ analyzer, its "path" and the "computed" metrics or the "error".
122
+ """
123
+ from spikeinterface.core import load_sorting_analyzer
124
+
125
+ parser = argparse.ArgumentParser(description="Computes the metrics models need which analyzers don't have.")
126
+ parser.add_argument("analyzers", nargs="+")
127
+ parser.add_argument("--model", action="append", required=True, dest="models", help="a model folder")
128
+ parser.add_argument("--n_jobs", type=int, default=-1, help="processes to use (-1: all cores)")
129
+ args = parser.parse_args(argv)
130
+
131
+ results = []
132
+ for index, path in enumerate(args.analyzers):
133
+ print(f"{METRICS_STEP_MARKER} analyzer_{index}", flush=True)
134
+ print(f"\nComputing the missing metrics of {path}...", flush=True)
135
+ try:
136
+ analyzer = load_sorting_analyzer(path, load_extensions=False)
137
+ results.append(dict(path=path, computed=compute_missing_metrics(analyzer, args.models, dict(n_jobs=args.n_jobs))))
138
+ except Exception as error:
139
+ traceback.print_exc()
140
+ results.append(dict(path=path, error=str(error)))
141
+ print(f"{METRICS_RESULT_MARKER}{json.dumps(results)}", flush=True)
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
@@ -0,0 +1,159 @@
1
+ from pathlib import Path
2
+
3
+ import PyQt5.QtWidgets as QtWidgets
4
+ from PyQt5.QtCore import Qt, pyqtSignal
5
+
6
+ from unitrefine.predictions import get_model_classes, is_layer1_model, is_layer2_model, selection_key
7
+
8
+
9
+ class ModelSelectionWidget(QtWidgets.QWidget):
10
+ """
11
+ Choose the models to inspect, relabel and retrain:
12
+ - Binary: one or more models, each applied on its own
13
+ - Cascade: a noise vs neural model (layer 1), then a SUA vs MUA model (layer 2) on the neural units
14
+ """
15
+
16
+ selection_changed = pyqtSignal()
17
+
18
+ def __init__(self, parent=None):
19
+
20
+ super().__init__(parent)
21
+
22
+ self.model_paths = []
23
+
24
+ layout = QtWidgets.QVBoxLayout(self)
25
+ layout.setContentsMargins(0, 0, 0, 0)
26
+
27
+ mode_layout = QtWidgets.QHBoxLayout()
28
+ self.binary_radio = QtWidgets.QRadioButton("Each model separately")
29
+ self.cascade_radio = QtWidgets.QRadioButton("As a cascade (noise vs neural, then SUA vs MUA on the neural units)")
30
+ self.binary_radio.setChecked(True)
31
+ self.binary_radio.toggled.connect(self.on_mode_changed)
32
+ mode_layout.addWidget(self.binary_radio)
33
+ mode_layout.addWidget(self.cascade_radio)
34
+ mode_layout.addStretch()
35
+ layout.addLayout(mode_layout)
36
+
37
+ self.binary_list = QtWidgets.QListWidget()
38
+ # fixed, so that on short screens the page scrolls instead of the list being squeezed
39
+ self.binary_list.setFixedHeight(140)
40
+ self.binary_list.itemChanged.connect(lambda _: self.selection_changed.emit())
41
+ layout.addWidget(self.binary_list)
42
+
43
+ self.cascade_widget = QtWidgets.QWidget()
44
+ cascade_layout = QtWidgets.QFormLayout(self.cascade_widget)
45
+ cascade_layout.setContentsMargins(0, 0, 0, 0)
46
+ self.layer1_combo = QtWidgets.QComboBox()
47
+ self.layer2_combo = QtWidgets.QComboBox()
48
+ self.layer1_combo.currentIndexChanged.connect(lambda _: self.selection_changed.emit())
49
+ self.layer2_combo.currentIndexChanged.connect(lambda _: self.selection_changed.emit())
50
+ cascade_layout.addRow("Layer 1 (noise vs neural):", self.layer1_combo)
51
+ cascade_layout.addRow("Layer 2 (SUA vs MUA):", self.layer2_combo)
52
+ layout.addWidget(self.cascade_widget)
53
+ self.cascade_widget.setVisible(False)
54
+
55
+ def set_models(self, model_paths):
56
+ """
57
+ Rebuilds the lists from the project's models. Which binary models are ticked:
58
+ - first time: the last model
59
+ - after models were trained or loaded: the new models
60
+ - otherwise: the same models as before
61
+ New models which fit a cascade layer are also chosen for that layer.
62
+ """
63
+
64
+ previously_checked = set(self.checked_binary_names())
65
+ previous_names = [Path(model_folder).name for model_folder, _ in self.model_paths]
66
+ previous_layers = [self.layer1_combo.currentText(), self.layer2_combo.currentText()]
67
+
68
+ self.model_paths = list(model_paths)
69
+ names = [Path(model_folder).name for model_folder, _ in self.model_paths]
70
+ new_names = [name for name in names if name not in previous_names]
71
+
72
+ if len(previous_names) == 0:
73
+ names_to_check = set(names[-1:])
74
+ elif len(new_names) > 0:
75
+ names_to_check = set(new_names)
76
+ else:
77
+ names_to_check = previously_checked
78
+
79
+ for widget in [self, self.binary_list, self.layer1_combo, self.layer2_combo]:
80
+ widget.blockSignals(True)
81
+
82
+ self.binary_list.clear()
83
+ self.layer1_combo.clear()
84
+ self.layer2_combo.clear()
85
+
86
+ for model_folder, _ in self.model_paths:
87
+ name = Path(model_folder).name
88
+ classes = get_model_classes(model_folder)
89
+
90
+ item = QtWidgets.QListWidgetItem(f"{name} [{', '.join(classes)}]")
91
+ item.setData(Qt.UserRole, name)
92
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
93
+ item.setCheckState(Qt.Checked if name in names_to_check else Qt.Unchecked)
94
+ self.binary_list.addItem(item)
95
+
96
+ if is_layer1_model(classes):
97
+ self.layer1_combo.addItem(name)
98
+ if is_layer2_model(classes):
99
+ self.layer2_combo.addItem(name)
100
+
101
+ for combo, previous in zip([self.layer1_combo, self.layer2_combo], previous_layers):
102
+ combo_names = [combo.itemText(index) for index in range(combo.count())]
103
+ new_for_layer = [name for name in new_names if name in combo_names]
104
+ choice = new_for_layer[-1] if len(new_for_layer) > 0 else previous
105
+ if choice in combo_names:
106
+ combo.setCurrentIndex(combo_names.index(choice))
107
+
108
+ for widget in [self, self.binary_list, self.layer1_combo, self.layer2_combo]:
109
+ widget.blockSignals(False)
110
+
111
+ self.selection_changed.emit()
112
+
113
+ def on_mode_changed(self):
114
+ is_binary = self.binary_radio.isChecked()
115
+ self.binary_list.setVisible(is_binary)
116
+ self.cascade_widget.setVisible(not is_binary)
117
+ self.selection_changed.emit()
118
+
119
+ def mode(self):
120
+ return "binary" if self.binary_radio.isChecked() else "cascade"
121
+
122
+ def checked_binary_names(self):
123
+ return [
124
+ self.binary_list.item(row).data(Qt.UserRole)
125
+ for row in range(self.binary_list.count())
126
+ if self.binary_list.item(row).checkState() == Qt.Checked
127
+ ]
128
+
129
+ def selected_model_names(self):
130
+ if self.mode() == "binary":
131
+ return self.checked_binary_names()
132
+ layer1, layer2 = self.layer1_combo.currentText(), self.layer2_combo.currentText()
133
+ if layer1 == "" or layer2 == "":
134
+ return []
135
+ return [layer1, layer2]
136
+
137
+ def selected_models(self):
138
+ """Returns [(model_folder, hfh_or_local), ...]; for a cascade, [layer 1, layer 2]."""
139
+ models_by_name = {Path(model_folder).name: (Path(model_folder), hfh_or_local) for model_folder, hfh_or_local in self.model_paths}
140
+ return [models_by_name[name] for name in self.selected_model_names()]
141
+
142
+ def selection_key(self):
143
+ names = self.selected_model_names()
144
+ if len(names) == 0:
145
+ return None
146
+ return selection_key(self.mode(), names)
147
+
148
+ def describe_problem(self):
149
+ """Returns why the selection can't be used, or None if it can."""
150
+ if len(self.model_paths) == 0:
151
+ return "No models trained or loaded into project! Please train or load one first."
152
+ if self.mode() == "binary" and len(self.checked_binary_names()) == 0:
153
+ return "No model selected. Tick at least one model."
154
+ if self.mode() == "cascade":
155
+ if self.layer1_combo.count() == 0:
156
+ return "No noise vs neural model in the project for cascade layer 1."
157
+ if self.layer2_combo.count() == 0:
158
+ return "No SUA vs MUA model in the project for cascade layer 2."
159
+ return None
@@ -0,0 +1,118 @@
1
+ """
2
+ Downloads UnitRefine models from the Hugging Face Hub. Used by the GUI (Load models from Hugging Face Hub) and
3
+ by unitrefine-download-models (see cli.py), so has no Qt.
4
+ """
5
+
6
+ import json
7
+ import re
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ # UnitRefine models published on the Hugging Face Hub, shown as options when loading from HFH.
12
+ # Each group is shown with a gap before it and an optional heading; models are (repo id, note shown after the name).
13
+ CASCADE_NOTE = "(can be combined in a cascade for good / MUA / noise labels)"
14
+ LIGHTWEIGHT_NOTE = "(based on fast-to-compute metrics, no PCA metrics; can be combined in a cascade for good / MUA / noise labels)"
15
+ hfh_model_groups = [
16
+ dict(title=None, models=[
17
+ ("SpikeInterface/UnitRefine_noise_neural_classifier", CASCADE_NOTE),
18
+ ("SpikeInterface/UnitRefine_sua_mua_classifier", CASCADE_NOTE),
19
+ ]),
20
+ dict(title=None, models=[
21
+ ("SpikeInterface/UnitRefine_noise_neural_classifier_lightweight", LIGHTWEIGHT_NOTE),
22
+ ("SpikeInterface/UnitRefine_sua_mua_classifier_lightweight", LIGHTWEIGHT_NOTE),
23
+ ]),
24
+ dict(title="SUA only models", models=[
25
+ ("AnoushkaJain3/UnitRefine-generalized-sua-classifier", ""),
26
+ ("AnoushkaJain3/UnitRefine-mice-sua-classifier", ""),
27
+ ("AnoushkaJain3/UnitRefine-rat-sua-classifier", ""),
28
+ ("AnoushkaJain3/UnitRefine-mole-rat-sua-classifier", ""),
29
+ ("AnoushkaJain3/UnitRefine-monkey-sua-classifier", ""),
30
+ ("AnoushkaJain3/UnitRefine-human-sua-classifier", ""),
31
+ ]),
32
+ ]
33
+
34
+
35
+ # downloaded into every new project (unless unitrefine is started with --no_pretrained_models), so models can be
36
+ # applied straight away: the first two as a cascade (good / MUA / noise), the third on its own (SUA or not)
37
+ DEFAULT_MODELS = [
38
+ "SpikeInterface/UnitRefine_noise_neural_classifier",
39
+ "SpikeInterface/UnitRefine_sua_mua_classifier",
40
+ "AnoushkaJain3/UnitRefine-generalized-sua-classifier",
41
+ ]
42
+
43
+ # the file in a downloaded model's folder which says which Hugging Face repo it came from
44
+ HFH_PATH_FILE = "hfh_path.txt"
45
+
46
+ # class names used by models on the Hugging Face Hub, as the GUI labels them
47
+ LABEL_TRANSLATION = {
48
+ 'MUA': ['mua'],
49
+ 'good': ['sua', 'SUA', 'neural'],
50
+ 'noise': [],
51
+ }
52
+
53
+
54
+ def looks_like_repo_id(text):
55
+ """True for a Hugging Face repo id, e.g. SpikeInterface/UnitRefine_sua_mua_classifier."""
56
+ return re.fullmatch(r"[\w.-]+/[\w.-]+", str(text)) is not None
57
+
58
+
59
+ def downloaded_repo_id(model_folder):
60
+ """The Hugging Face repo a model folder was downloaded from, or None if it wasn't."""
61
+ hfh_path = Path(model_folder) / HFH_PATH_FILE
62
+ return hfh_path.read_text().strip() if hfh_path.is_file() else None
63
+
64
+
65
+ def translate_label_conversion(model_info_path):
66
+ """Renames the classes in a downloaded model's model_info.json to the GUI's labels, e.g. sua to good."""
67
+ with open(model_info_path, 'r') as f:
68
+ model_info = json.load(f)
69
+
70
+ updated_model_info = {}
71
+ for label_index, label in model_info['label_conversion'].items():
72
+ new_label = label
73
+ for sigui_label, user_labels in LABEL_TRANSLATION.items():
74
+ if label in user_labels:
75
+ new_label = sigui_label
76
+ updated_model_info[label_index] = new_label
77
+ model_info['label_conversion'] = updated_model_info
78
+
79
+ with open(model_info_path, 'w') as f:
80
+ json.dump(model_info, f, indent=4)
81
+
82
+
83
+ def download_model(repo_id, model_directory):
84
+ """
85
+ Downloads the model in the Hugging Face repo `repo_id` into `model_directory`, which must not exist yet,
86
+ and renames its classes to the GUI's labels. Nothing is left behind if the download fails.
87
+ """
88
+ from huggingface_hub import snapshot_download
89
+
90
+ model_directory = Path(model_directory)
91
+ print(f"Downloading model from HuggingFaceHub repo {repo_id}.\n")
92
+ try:
93
+ snapshot_download(repo_id, local_dir=model_directory)
94
+ (model_directory / HFH_PATH_FILE).write_text(repo_id)
95
+ translate_label_conversion(model_directory / "model_info.json")
96
+ except BaseException:
97
+ # don't leave a half-downloaded model behind
98
+ shutil.rmtree(model_directory, ignore_errors=True)
99
+ raise
100
+
101
+
102
+ def download_default_models(models_folder, repo_ids=DEFAULT_MODELS):
103
+ """
104
+ Downloads the default models into `models_folder` (a new project's models folder), each into a folder named
105
+ after its repo. Models already there are kept. Returns the repo ids which could not be downloaded, with the
106
+ reason, e.g. no internet: {repo id: message}.
107
+ """
108
+ failed = {}
109
+ for repo_id in repo_ids:
110
+ model_directory = Path(models_folder) / repo_id.split("/")[-1]
111
+ if model_directory.is_dir():
112
+ continue
113
+ try:
114
+ download_model(repo_id, model_directory)
115
+ except Exception as error:
116
+ print(f"Could not download {repo_id}: {error}")
117
+ failed[repo_id] = str(error)
118
+ return failed