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,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
|
unitrefine/models_hub.py
ADDED
|
@@ -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
|