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
unitrefine/main.py
ADDED
|
@@ -0,0 +1,2395 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import shutil
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from argparse import ArgumentParser
|
|
9
|
+
from functools import partial
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from huggingface_hub import repo_exists
|
|
15
|
+
from sklearn.base import clone
|
|
16
|
+
from sklearn.metrics import balanced_accuracy_score
|
|
17
|
+
from spikeinterface.curation.model_based_curation import load_model
|
|
18
|
+
from skops.io import dump
|
|
19
|
+
|
|
20
|
+
import PyQt5.QtWidgets as QtWidgets
|
|
21
|
+
from PyQt5.QtCore import Qt, QSize, QProcess, QProcessEnvironment, QTimer, QCoreApplication, QEvent, QUrl
|
|
22
|
+
from PyQt5.QtGui import QDesktopServices, QFont, QFontDatabase, QIcon
|
|
23
|
+
from PyQt5.QtWidgets import QStyleFactory
|
|
24
|
+
|
|
25
|
+
from spikeinterface_gui.main import check_folder_is_analyzer
|
|
26
|
+
from spikeinterface.core.core_tools import is_path_remote
|
|
27
|
+
from spikeinterface.core import load_sorting_analyzer
|
|
28
|
+
|
|
29
|
+
from unitrefine import LABELS_BACKUP_MARKER, SIGUI_SHOWN_MARKER, SIGUI_STEP_MARKER, unwritable_file_message
|
|
30
|
+
from unitrefine.sigui_progress import OpeningDialog, CURATE_STEPS, INSPECT_STEPS
|
|
31
|
+
from unitrefine.train import TRAINING_UNITS_FILE, TrainWindow, read_training_units
|
|
32
|
+
from unitrefine.import_labels import import_labels, compute_all_metrics
|
|
33
|
+
from unitrefine.manage_models import ManageModelsWindow, HFHModelsDialog
|
|
34
|
+
from unitrefine.model_selection import ModelSelectionWidget
|
|
35
|
+
from unitrefine.predictions import (
|
|
36
|
+
QUALITY_ALIASES,
|
|
37
|
+
analyzer_output_names,
|
|
38
|
+
label_units_function,
|
|
39
|
+
apply_to_analyzers,
|
|
40
|
+
find_analyzers,
|
|
41
|
+
find_least_confident_units,
|
|
42
|
+
map_quality_to_model_class,
|
|
43
|
+
metrics_for_model,
|
|
44
|
+
as_model_input,
|
|
45
|
+
predictions_file_name,
|
|
46
|
+
relabelled_units_path,
|
|
47
|
+
units_to_relabel_path,
|
|
48
|
+
)
|
|
49
|
+
from unitrefine.models_hub import download_model
|
|
50
|
+
from unitrefine.evaluation import read_trained_on, write_trained_on, normalise_path
|
|
51
|
+
from unitrefine import figures, theme
|
|
52
|
+
from unitrefine.launcher import enable_high_dpi_scaling, set_app_font
|
|
53
|
+
from unitrefine.phy_import import (
|
|
54
|
+
EXPORTED_STEPS, GAIN_HINT, KILOSORT_STEPS, PHY_RESULT_MARKER, PHY_STEP_MARKER, default_output_folder, made_from_phy,
|
|
55
|
+
matches_phy_folder, phy_folder_kind, recording_file, recording_needs_gain,
|
|
56
|
+
)
|
|
57
|
+
from unitrefine.metrics import METRICS_RESULT_MARKER, METRICS_STEP_MARKER, metrics_to_compute
|
|
58
|
+
from unitrefine.retraining_history import RetrainingHistoryTable, write_log as write_retraining_log
|
|
59
|
+
from unitrefine.widgets import (
|
|
60
|
+
BusyNotice,
|
|
61
|
+
CollapsibleSection,
|
|
62
|
+
ElidedLabel,
|
|
63
|
+
FigureGallery,
|
|
64
|
+
FigureWorker,
|
|
65
|
+
PathRow,
|
|
66
|
+
chip,
|
|
67
|
+
clear_layout,
|
|
68
|
+
group_label,
|
|
69
|
+
list_box,
|
|
70
|
+
list_message,
|
|
71
|
+
sigui_help_box,
|
|
72
|
+
muted_label,
|
|
73
|
+
open_folder,
|
|
74
|
+
subheading,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
import re
|
|
78
|
+
_RETRAINED_RE = re.compile(r"^(?P<base>.*?)(?:_retrained(?:_(?P<idx>\d+))?)?$")
|
|
79
|
+
|
|
80
|
+
import warnings
|
|
81
|
+
warnings.filterwarnings("ignore")
|
|
82
|
+
|
|
83
|
+
UPLOAD_TUTORIAL_URL = "https://spikeinterface.readthedocs.io/en/stable/tutorials/curation/plot_3_upload_a_model.html"
|
|
84
|
+
|
|
85
|
+
APP_DESCRIPTION = "a machine-learning toolbox that simplifies spike sorting curation by reducing manual curation"
|
|
86
|
+
|
|
87
|
+
# width of the column holding the sections, in device independent pixels
|
|
88
|
+
CONTENT_MIN_WIDTH = 720
|
|
89
|
+
CONTENT_MAX_WIDTH = 1100
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class UrlInputDialog(QtWidgets.QDialog):
|
|
93
|
+
def __init__(self, parent=None, default=None, background_color=None):
|
|
94
|
+
super().__init__(parent)
|
|
95
|
+
|
|
96
|
+
if default is None:
|
|
97
|
+
default = "https://www.example.com"
|
|
98
|
+
|
|
99
|
+
self.setWindowTitle("Open URL")
|
|
100
|
+
self.setMinimumWidth(400)
|
|
101
|
+
|
|
102
|
+
layout = QtWidgets.QVBoxLayout()
|
|
103
|
+
|
|
104
|
+
self.label = QtWidgets.QLabel("Please enter the s3 path to an analyzer:")
|
|
105
|
+
layout.addWidget(self.label)
|
|
106
|
+
|
|
107
|
+
self.url_input = QtWidgets.QLineEdit()
|
|
108
|
+
self.url_input.setPlaceholderText(default)
|
|
109
|
+
self.url_input.setClearButtonEnabled(True)
|
|
110
|
+
layout.addWidget(self.url_input)
|
|
111
|
+
|
|
112
|
+
self.button_box = QtWidgets.QDialogButtonBox(
|
|
113
|
+
QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel
|
|
114
|
+
)
|
|
115
|
+
layout.addWidget(self.button_box)
|
|
116
|
+
|
|
117
|
+
self.setLayout(layout)
|
|
118
|
+
|
|
119
|
+
self.button_box.accepted.connect(self.accept)
|
|
120
|
+
self.button_box.rejected.connect(self.reject)
|
|
121
|
+
|
|
122
|
+
def get_url(self):
|
|
123
|
+
"""Returns the text entered in the line edit."""
|
|
124
|
+
return self.url_input.text().strip()
|
|
125
|
+
|
|
126
|
+
class UnitRefineProject:
|
|
127
|
+
|
|
128
|
+
def __init__(self, folder_name):
|
|
129
|
+
|
|
130
|
+
self.folder_name = folder_name
|
|
131
|
+
self.analyzers = {}
|
|
132
|
+
self.model_paths = []
|
|
133
|
+
self.config = {}
|
|
134
|
+
self.selected_model = None
|
|
135
|
+
|
|
136
|
+
def save(self, folder_name=None):
|
|
137
|
+
|
|
138
|
+
if folder_name is None:
|
|
139
|
+
folder_name = self.folder_name
|
|
140
|
+
|
|
141
|
+
Path(folder_name).mkdir(exist_ok=True)
|
|
142
|
+
|
|
143
|
+
Path(folder_name / "analyzers").mkdir(exist_ok=True)
|
|
144
|
+
Path(folder_name / "models").mkdir(exist_ok=True)
|
|
145
|
+
|
|
146
|
+
with open(folder_name / "config.json", 'w') as f:
|
|
147
|
+
json.dump(self.config, f, indent=4)
|
|
148
|
+
|
|
149
|
+
for analyzer_name, analyzer_dict in self.analyzers.items():
|
|
150
|
+
|
|
151
|
+
path = analyzer_dict.get('path')
|
|
152
|
+
|
|
153
|
+
save_path = Path(folder_name / "analyzers" / f"analyzer_{analyzer_name}")
|
|
154
|
+
save_path.mkdir(exist_ok=True)
|
|
155
|
+
|
|
156
|
+
if path is not None:
|
|
157
|
+
with open(save_path / 'path.txt', 'w') as output:
|
|
158
|
+
output.write(str(path))
|
|
159
|
+
|
|
160
|
+
# labels.csv is not written here: Curate and Import labels write it themselves. Writing the copy read
|
|
161
|
+
# when the project was opened would replace labels set since then
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def save_config(self):
|
|
165
|
+
"""Saves only config.json, leaving the analyzers' label files untouched."""
|
|
166
|
+
config_path = Path(self.folder_name) / "config.json"
|
|
167
|
+
try:
|
|
168
|
+
with open(config_path, 'w') as f:
|
|
169
|
+
json.dump(self.config, f, indent=4)
|
|
170
|
+
except OSError as e:
|
|
171
|
+
# e.g. a read-only file: the settings are not saved, but nothing else is lost
|
|
172
|
+
report_unwritable_file(config_path, e)
|
|
173
|
+
|
|
174
|
+
# called with (path, message) when a file can't be written; the main window shows the message
|
|
175
|
+
on_unwritable_file = None
|
|
176
|
+
|
|
177
|
+
def add_analyzer(self, directory):
|
|
178
|
+
|
|
179
|
+
# a number no analyzer has used, also not one whose folder is still on disk (e.g. left from a removed
|
|
180
|
+
# analyzer), so a new analyzer never picks up another analyzer's files
|
|
181
|
+
used_keys = set(self.analyzers.keys())
|
|
182
|
+
for folder in (Path(self.folder_name) / "analyzers").glob("analyzer_*"):
|
|
183
|
+
if folder.name.split("_")[-1].isdigit():
|
|
184
|
+
used_keys.add(int(folder.name.split("_")[-1]))
|
|
185
|
+
new_key = max(used_keys) + 1 if len(used_keys) > 0 else 0
|
|
186
|
+
|
|
187
|
+
analyer_in_project = Path(f'analyzers/analyzer_{new_key}')
|
|
188
|
+
self.analyzers[new_key] = {'path': directory, 'analyzer_in_project': analyer_in_project}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def report_unwritable_file(path, error):
|
|
192
|
+
message = unwritable_file_message(path, error)
|
|
193
|
+
print(message)
|
|
194
|
+
if UnitRefineProject.on_unwritable_file is not None:
|
|
195
|
+
UnitRefineProject.on_unwritable_file(path, message)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def load_project(folder_name):
|
|
199
|
+
|
|
200
|
+
folder_name = Path(folder_name)
|
|
201
|
+
|
|
202
|
+
project = UnitRefineProject(folder_name)
|
|
203
|
+
|
|
204
|
+
config_path = folder_name / "config.json"
|
|
205
|
+
if config_path.is_file():
|
|
206
|
+
with open(config_path) as f:
|
|
207
|
+
project.config = json.load(f)
|
|
208
|
+
|
|
209
|
+
analyzers_folder = folder_name / "analyzers"
|
|
210
|
+
# in the order they were added (by number: analyzer_10 after analyzer_9). The folders are listed in no
|
|
211
|
+
# particular order on Linux, and alphabetically on Windows
|
|
212
|
+
analyzer_folders = [folder for folder in analyzers_folder.glob('*/') if re.fullmatch(r"analyzer_\d+", folder.name)]
|
|
213
|
+
analyzer_folders.sort(key=lambda folder: int(folder.name.split('_')[1]))
|
|
214
|
+
|
|
215
|
+
for analyzer_folder in analyzer_folders:
|
|
216
|
+
|
|
217
|
+
analyzer_dict = {}
|
|
218
|
+
|
|
219
|
+
# without path.txt the folder doesn't belong to an analyzer in the project (e.g. it was removed)
|
|
220
|
+
if not (analyzer_folder / 'path.txt').is_file():
|
|
221
|
+
print(f"Skipping {analyzer_folder}: it has no path.txt, so it is not an analyzer in this project.")
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
with open(analyzer_folder / 'path.txt') as f:
|
|
225
|
+
analyzer_path = f.read()
|
|
226
|
+
analyzer_dict['path'] = analyzer_path
|
|
227
|
+
|
|
228
|
+
metrics_path = analyzer_folder / 'all_metrics.csv'
|
|
229
|
+
if metrics_path.is_file():
|
|
230
|
+
all_metrics = pd.read_csv(metrics_path)
|
|
231
|
+
analyzer_dict['all_metrics'] = all_metrics
|
|
232
|
+
|
|
233
|
+
labelled_metrics_path = analyzer_folder / 'labelled_metrics.csv'
|
|
234
|
+
if labelled_metrics_path.is_file():
|
|
235
|
+
labelled_metrics = pd.read_csv(labelled_metrics_path)
|
|
236
|
+
analyzer_dict['labelled_metrics'] = labelled_metrics
|
|
237
|
+
|
|
238
|
+
labels_path = analyzer_folder / 'labels.csv'
|
|
239
|
+
if labels_path.is_file():
|
|
240
|
+
curation = pd.read_csv(labels_path)
|
|
241
|
+
analyzer_dict['labels'] = curation
|
|
242
|
+
|
|
243
|
+
analyzer_dict['analyzer_in_project'] = f"analyzers/{Path(analyzer_folder).name}"
|
|
244
|
+
|
|
245
|
+
project.analyzers[int(str(analyzer_folder.name).split('_')[1])] = analyzer_dict
|
|
246
|
+
|
|
247
|
+
models_folder = folder_name / "models"
|
|
248
|
+
model_folders = sorted(folder for folder in models_folder.glob('*/') if '.DS' not in str(folder))
|
|
249
|
+
|
|
250
|
+
for model_folder in model_folders:
|
|
251
|
+
if (model_folder / "hfh_path.txt").is_file():
|
|
252
|
+
project.model_paths.append((model_folder, "hfh"))
|
|
253
|
+
else:
|
|
254
|
+
project.model_paths.append((model_folder, "local"))
|
|
255
|
+
|
|
256
|
+
return project
|
|
257
|
+
|
|
258
|
+
class MainWindow(QtWidgets.QWidget):
|
|
259
|
+
|
|
260
|
+
# added to the phy_import and metrics commands (e.g. --n_jobs 1 in the tests)
|
|
261
|
+
phy_import_options = []
|
|
262
|
+
metrics_options = []
|
|
263
|
+
|
|
264
|
+
def __init__(self, project):
|
|
265
|
+
|
|
266
|
+
super().__init__()
|
|
267
|
+
|
|
268
|
+
self.setObjectName("page")
|
|
269
|
+
|
|
270
|
+
self.w = None
|
|
271
|
+
self.sorting_analyzer_paths = []
|
|
272
|
+
|
|
273
|
+
self.output_folder = project.folder_name
|
|
274
|
+
|
|
275
|
+
self.project = project
|
|
276
|
+
self.num_units_to_relabel = 50
|
|
277
|
+
|
|
278
|
+
# figures are made one at a time in a background thread; changes are collected for a moment first,
|
|
279
|
+
# so several changes at once (e.g. a new model selected) only update the figures once
|
|
280
|
+
# files which can't be written (e.g. read-only) are reported once each, in a message box
|
|
281
|
+
self.reported_unwritable_files = set()
|
|
282
|
+
UnitRefineProject.on_unwritable_file = self.show_unwritable_file
|
|
283
|
+
|
|
284
|
+
self.figure_worker = FigureWorker(self)
|
|
285
|
+
self.figure_refresh_timer = QTimer(self)
|
|
286
|
+
self.figure_refresh_timer.setSingleShot(True)
|
|
287
|
+
self.figure_refresh_timer.setInterval(150)
|
|
288
|
+
self.figure_refresh_timer.timeout.connect(self.refresh_figures)
|
|
289
|
+
|
|
290
|
+
# The sections sit in a column centred in a scroll area. The column follows the window width between
|
|
291
|
+
# a minimum (smaller windows scroll sideways) and a maximum (larger windows keep it centred instead of
|
|
292
|
+
# stretching it). Sizes are in device independent pixels, so they follow the screen's display scaling.
|
|
293
|
+
content_widget = QtWidgets.QWidget()
|
|
294
|
+
content_widget.setObjectName("page")
|
|
295
|
+
content_widget.setMinimumWidth(CONTENT_MIN_WIDTH)
|
|
296
|
+
content_widget.setMaximumWidth(CONTENT_MAX_WIDTH)
|
|
297
|
+
self.main_layout = QtWidgets.QVBoxLayout(content_widget)
|
|
298
|
+
self.main_layout.setContentsMargins(4, 20, 4, 28)
|
|
299
|
+
self.main_layout.setSpacing(16)
|
|
300
|
+
|
|
301
|
+
centring_widget = QtWidgets.QWidget()
|
|
302
|
+
centring_widget.setObjectName("page")
|
|
303
|
+
centring_layout = QtWidgets.QHBoxLayout(centring_widget)
|
|
304
|
+
centring_layout.setContentsMargins(16, 0, 16, 0)
|
|
305
|
+
# the column takes the width first; the stretches on either side only get what is left once
|
|
306
|
+
# the column reaches its maximum width, which keeps it centred
|
|
307
|
+
centring_layout.addStretch(0)
|
|
308
|
+
centring_layout.addWidget(content_widget, 1, alignment=Qt.AlignmentFlag.AlignTop)
|
|
309
|
+
centring_layout.addStretch(0)
|
|
310
|
+
|
|
311
|
+
scroll_area = QtWidgets.QScrollArea()
|
|
312
|
+
scroll_area.setWidgetResizable(True)
|
|
313
|
+
scroll_area.setFrameShape(QtWidgets.QFrame.NoFrame)
|
|
314
|
+
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
315
|
+
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
316
|
+
scroll_area.setWidget(centring_widget)
|
|
317
|
+
|
|
318
|
+
window_layout = QtWidgets.QVBoxLayout(self)
|
|
319
|
+
window_layout.setContentsMargins(0, 0, 0, 0)
|
|
320
|
+
window_layout.setSpacing(0)
|
|
321
|
+
# banner shown while the SpikeInterface-GUI is open; outside the scroll area so it's always visible
|
|
322
|
+
self.sigui_banner = QtWidgets.QFrame()
|
|
323
|
+
self.sigui_banner.setStyleSheet(f"QFrame {{ background-color: {theme.WARNING_SOFT}; border-bottom: 1px solid #F5D78E; }}")
|
|
324
|
+
banner_layout = QtWidgets.QHBoxLayout(self.sigui_banner)
|
|
325
|
+
banner_layout.setContentsMargins(20, 10, 20, 10)
|
|
326
|
+
self.sigui_banner_text = QtWidgets.QLabel("")
|
|
327
|
+
self.sigui_banner_text.setWordWrap(True)
|
|
328
|
+
self.sigui_banner_text.setStyleSheet("border: none;")
|
|
329
|
+
banner_layout.addWidget(self.sigui_banner_text, 1)
|
|
330
|
+
self.sigui_banner.setVisible(False)
|
|
331
|
+
window_layout.addWidget(self.sigui_banner)
|
|
332
|
+
|
|
333
|
+
window_layout.addWidget(scroll_area)
|
|
334
|
+
self.content_widget = content_widget
|
|
335
|
+
self.sigui_process = None
|
|
336
|
+
# adding a Phy folder (+ Phy output)
|
|
337
|
+
self.phy_process = None
|
|
338
|
+
self.phy_reuse = False
|
|
339
|
+
self.metrics_process = None
|
|
340
|
+
self.retrainedModelNameForm = None
|
|
341
|
+
|
|
342
|
+
header_layout = QtWidgets.QHBoxLayout()
|
|
343
|
+
header_layout.setContentsMargins(4, 0, 4, 4)
|
|
344
|
+
app_title = QtWidgets.QLabel("UnitRefine")
|
|
345
|
+
app_title.setStyleSheet("font-weight: bold; font-size: 20pt;")
|
|
346
|
+
header_layout.addWidget(app_title, 0, Qt.AlignmentFlag.AlignTop)
|
|
347
|
+
app_subtitle = muted_label(APP_DESCRIPTION[0].upper() + APP_DESCRIPTION[1:])
|
|
348
|
+
app_subtitle.setStyleSheet("padding-top: 9px; padding-left: 4px;")
|
|
349
|
+
header_layout.addWidget(app_subtitle, 1)
|
|
350
|
+
self.main_layout.addLayout(header_layout)
|
|
351
|
+
|
|
352
|
+
###############
|
|
353
|
+
# 1. PROJECT
|
|
354
|
+
###############
|
|
355
|
+
|
|
356
|
+
self.project_section = self.make_section(1, "Project")
|
|
357
|
+
self.project_section.set_summary(Path(self.output_folder).name)
|
|
358
|
+
|
|
359
|
+
project_form = QtWidgets.QGridLayout()
|
|
360
|
+
project_form.setHorizontalSpacing(12)
|
|
361
|
+
project_form.setVerticalSpacing(6)
|
|
362
|
+
project_form.setColumnStretch(1, 1)
|
|
363
|
+
folders = [
|
|
364
|
+
("Project folder", Path(self.output_folder)),
|
|
365
|
+
("Models folder", Path(self.output_folder) / "models"),
|
|
366
|
+
("Figures folder", Path(self.output_folder) / figures.FIGURES_FOLDER),
|
|
367
|
+
]
|
|
368
|
+
for row, (name, folder) in enumerate(folders):
|
|
369
|
+
project_form.addWidget(muted_label(name, word_wrap=False), row, 0)
|
|
370
|
+
project_form.addWidget(ElidedLabel(str(folder)), row, 1)
|
|
371
|
+
open_button = theme.make_flat(QtWidgets.QPushButton("Open"))
|
|
372
|
+
open_button.clicked.connect(lambda _checked=False, folder=folder: open_folder(folder))
|
|
373
|
+
project_form.addWidget(open_button, row, 2)
|
|
374
|
+
project_form.addWidget(muted_label("Labels", word_wrap=False), len(folders), 0)
|
|
375
|
+
project_form.addWidget(QtWidgets.QLabel("good, MUA, noise"), len(folders), 1)
|
|
376
|
+
project_form.addWidget(muted_label("Contents", word_wrap=False), len(folders) + 1, 0)
|
|
377
|
+
self.project_counts_label = QtWidgets.QLabel("")
|
|
378
|
+
project_form.addWidget(self.project_counts_label, len(folders) + 1, 1, 1, 2)
|
|
379
|
+
self.project_section.body_layout.addLayout(project_form)
|
|
380
|
+
|
|
381
|
+
###############
|
|
382
|
+
# 2. LABEL AND TRAIN
|
|
383
|
+
###############
|
|
384
|
+
|
|
385
|
+
self.curation_section = self.make_section(2, "Label your units and train a model")
|
|
386
|
+
self.curation_section.body_layout.addWidget(muted_label(
|
|
387
|
+
"Label the units of your recordings yourself with <b>Curate</b> (opens the SpikeInterface-GUI), or "
|
|
388
|
+
"<b>+ Import labels</b> you already have. Then <b>train a model</b> on them.<br><br>"
|
|
389
|
+
"<b>+ Import labels</b> reads Phy (cluster_group.tsv, cluster_info.tsv), SortingView (.json), SpikeInterface "
|
|
390
|
+
"curation (.json), or a .csv/.tsv with <i>unit_id</i> and <i>quality</i> columns. Labels good, MUA and "
|
|
391
|
+
"noise are used; others (e.g. unsorted) are skipped.<br><br>"
|
|
392
|
+
"<b>+ Phy output</b> adds a Kilosort output folder curated in Phy. UnitRefine makes an analyzer from Phy's "
|
|
393
|
+
"units and the recording Kilosort sorted, which takes a few minutes, and saves the analyzer next to the Phy "
|
|
394
|
+
"folder. The labels you set in Phy are imported automatically, as <b>+ Import labels</b> would. If the Phy "
|
|
395
|
+
"folder was made from a SpikeInterface analyzer (with export_to_phy) instead of by Kilosort, no new analyzer "
|
|
396
|
+
"is made: Phy's merges and splits are applied to that analyzer."
|
|
397
|
+
))
|
|
398
|
+
|
|
399
|
+
add_buttons_layout = QtWidgets.QHBoxLayout()
|
|
400
|
+
add_buttons_layout.addWidget(QtWidgets.QLabel("Add your spike sorted data:"))
|
|
401
|
+
self.add_sa_button = QtWidgets.QPushButton("+ Sorting Analyzer folder")
|
|
402
|
+
self.add_sa_button.clicked.connect(self.selectDirectoryDialog)
|
|
403
|
+
add_buttons_layout.addWidget(self.add_sa_button)
|
|
404
|
+
self.add_s3_button = QtWidgets.QPushButton("+ Analyzer from s3")
|
|
405
|
+
self.add_s3_button.clicked.connect(self.add_from_s3)
|
|
406
|
+
add_buttons_layout.addWidget(self.add_s3_button)
|
|
407
|
+
add_phy_button = QtWidgets.QPushButton("+ Phy output")
|
|
408
|
+
add_phy_button.setToolTip("A Kilosort output folder curated in Phy, or a Phy folder exported from an analyzer. "
|
|
409
|
+
"An analyzer is made from it, and Phy's labels are imported.")
|
|
410
|
+
add_phy_button.clicked.connect(lambda: self.add_phy_output(for_training=True))
|
|
411
|
+
add_buttons_layout.addWidget(add_phy_button)
|
|
412
|
+
add_buttons_layout.addStretch()
|
|
413
|
+
self.curation_section.body_layout.addLayout(add_buttons_layout)
|
|
414
|
+
|
|
415
|
+
self.analyzer_rows_layout = self.add_rows_container(self.curation_section)
|
|
416
|
+
self.curation_section.body_layout.addWidget(sigui_help_box())
|
|
417
|
+
|
|
418
|
+
train_layout = QtWidgets.QHBoxLayout()
|
|
419
|
+
train_button = theme.make_primary(QtWidgets.QPushButton("Train a model"))
|
|
420
|
+
train_button.clicked.connect(self.show_train_window)
|
|
421
|
+
train_layout.addWidget(train_button)
|
|
422
|
+
train_layout.addStretch()
|
|
423
|
+
self.curation_section.body_layout.addLayout(train_layout)
|
|
424
|
+
|
|
425
|
+
self.training_gallery = FigureGallery(self.figure_worker, self.figures_folder("2_labels_and_training"))
|
|
426
|
+
self.curation_section.body_layout.addWidget(self.training_gallery)
|
|
427
|
+
|
|
428
|
+
###############
|
|
429
|
+
# 3. APPLY MODELS
|
|
430
|
+
###############
|
|
431
|
+
|
|
432
|
+
self.apply_section = self.make_section(3, "Apply models")
|
|
433
|
+
self.apply_section.body_layout.addWidget(muted_label(
|
|
434
|
+
"Get labels from a model you trained or downloaded. Use any model on its own, or combine a noise vs "
|
|
435
|
+
"neural model and a SUA vs MUA model into a <b>cascade</b>. The figures show what the models predict, "
|
|
436
|
+
"and how they compare with your labels."
|
|
437
|
+
))
|
|
438
|
+
|
|
439
|
+
self.apply_section.body_layout.addWidget(subheading("3.1 Load models"))
|
|
440
|
+
load_layout = QtWidgets.QHBoxLayout()
|
|
441
|
+
load_model_button = QtWidgets.QPushButton("+ Load")
|
|
442
|
+
load_model_button.setToolTip("Add a model folder from your computer")
|
|
443
|
+
load_model_button.clicked.connect(self.selectModelDialog)
|
|
444
|
+
load_layout.addWidget(load_model_button)
|
|
445
|
+
load_model_hf = QtWidgets.QPushButton("+ Load from HFH")
|
|
446
|
+
load_model_hf.setToolTip("Download UnitRefine models from the Hugging Face Hub")
|
|
447
|
+
load_model_hf.clicked.connect(self.add_from_hfh)
|
|
448
|
+
load_layout.addWidget(load_model_hf)
|
|
449
|
+
manage_models_button = QtWidgets.QPushButton("Manage models")
|
|
450
|
+
manage_models_button.setToolTip("See the classes each model was trained on, and delete models")
|
|
451
|
+
manage_models_button.clicked.connect(self.show_manage_models_window)
|
|
452
|
+
load_layout.addWidget(manage_models_button)
|
|
453
|
+
load_layout.addWidget(muted_label("Models trained in section 2 are added here.", word_wrap=False), 1)
|
|
454
|
+
self.apply_section.body_layout.addLayout(load_layout)
|
|
455
|
+
|
|
456
|
+
self.apply_section.body_layout.addWidget(subheading("3.2 Choose your models"))
|
|
457
|
+
self.apply_section.body_layout.addWidget(muted_label(
|
|
458
|
+
"Tick several models to compare them on the same recordings: their labels and confidence appear side by "
|
|
459
|
+
"side in <b>Inspect</b> and in the figures below."))
|
|
460
|
+
self.model_selector = ModelSelectionWidget(self)
|
|
461
|
+
self.apply_section.body_layout.addWidget(self.model_selector)
|
|
462
|
+
|
|
463
|
+
self.apply_section.body_layout.addWidget(subheading("3.3 Apply models to get predictions and inspect"))
|
|
464
|
+
new_data_layout = QtWidgets.QHBoxLayout()
|
|
465
|
+
new_data_layout.addWidget(QtWidgets.QLabel("Add spike sorted data to get predictions on:"))
|
|
466
|
+
add_new_folder_button = QtWidgets.QPushButton("+ Sorting Analyzer folder")
|
|
467
|
+
add_new_folder_button.setToolTip("One analyzer, or a folder with several (searched 3 levels down)")
|
|
468
|
+
add_new_folder_button.clicked.connect(self.add_new_analyzers_from_folder)
|
|
469
|
+
new_data_layout.addWidget(add_new_folder_button)
|
|
470
|
+
add_new_s3_button = QtWidgets.QPushButton("+ Analyzer from s3")
|
|
471
|
+
add_new_s3_button.clicked.connect(self.add_new_analyzer_from_s3)
|
|
472
|
+
new_data_layout.addWidget(add_new_s3_button)
|
|
473
|
+
add_new_phy_button = QtWidgets.QPushButton("+ Phy output")
|
|
474
|
+
add_new_phy_button.setToolTip("A Kilosort output folder curated in Phy, or a Phy folder exported from an analyzer. "
|
|
475
|
+
"An analyzer is made from it, and Phy's labels are imported to compare the models with.")
|
|
476
|
+
add_new_phy_button.clicked.connect(lambda: self.add_phy_output(for_training=False))
|
|
477
|
+
new_data_layout.addWidget(add_new_phy_button)
|
|
478
|
+
new_data_layout.addStretch()
|
|
479
|
+
self.apply_section.body_layout.addLayout(new_data_layout)
|
|
480
|
+
|
|
481
|
+
# the new analyzers, and (when shown) the training analyzers; rebuilt when they change
|
|
482
|
+
validate_container = QtWidgets.QWidget()
|
|
483
|
+
self.validate_rows_layout = QtWidgets.QVBoxLayout(validate_container)
|
|
484
|
+
self.validate_rows_layout.setContentsMargins(0, 0, 0, 0)
|
|
485
|
+
self.validate_rows_layout.setSpacing(8)
|
|
486
|
+
self.apply_section.body_layout.addWidget(validate_container)
|
|
487
|
+
self.show_training_analyzers = False
|
|
488
|
+
|
|
489
|
+
apply_code_button = theme.make_flat(QtWidgets.QPushButton("Generate code to apply the model(s) in a script"))
|
|
490
|
+
apply_code_button.setToolTip("Prints Python code to the terminal which applies the chosen models to an analyzer")
|
|
491
|
+
apply_code_button.clicked.connect(self.make_apply_code)
|
|
492
|
+
code_layout = QtWidgets.QHBoxLayout()
|
|
493
|
+
code_layout.addWidget(apply_code_button)
|
|
494
|
+
code_layout.addStretch()
|
|
495
|
+
self.apply_section.body_layout.addLayout(code_layout)
|
|
496
|
+
|
|
497
|
+
self.apply_gallery = FigureGallery(self.figure_worker, self.figures_folder("3_predictions"))
|
|
498
|
+
self.apply_section.body_layout.addWidget(self.apply_gallery)
|
|
499
|
+
|
|
500
|
+
#################
|
|
501
|
+
# 4. ACTIVE LEARNING
|
|
502
|
+
##################
|
|
503
|
+
|
|
504
|
+
self.relabel_section = self.make_section(4, "Improve the model: relabel and retrain")
|
|
505
|
+
self.relabel_section.body_layout.addWidget(muted_label(
|
|
506
|
+
"<b>Find</b> the units the model is least sure about across all your analyzers, <b>relabel</b> them in "
|
|
507
|
+
"the SpikeInterface-GUI, and <b>retrain</b>. Units you have already labelled are never picked again, so "
|
|
508
|
+
"each round asks about new units.<br>"
|
|
509
|
+
"<i>Suggestion:</i> repeat for a few rounds, and stop when the <i>Accuracy over rounds</i> figure levels off."
|
|
510
|
+
))
|
|
511
|
+
|
|
512
|
+
self.relabel_section.body_layout.addWidget(subheading("4.1 Find the least confident units"))
|
|
513
|
+
find_layout = QtWidgets.QHBoxLayout()
|
|
514
|
+
self.unitLabel = QtWidgets.QLabel("Number of units to relabel, across all analyzers:")
|
|
515
|
+
find_layout.addWidget(self.unitLabel)
|
|
516
|
+
self.numUnitsForm = QtWidgets.QLineEdit(f"{self.num_units_to_relabel}")
|
|
517
|
+
self.numUnitsForm.setFixedWidth(60)
|
|
518
|
+
self.numUnitsForm.textChanged.connect(self.update_num_units_to_relabel)
|
|
519
|
+
find_layout.addWidget(self.numUnitsForm)
|
|
520
|
+
find_units_button = QtWidgets.QPushButton("Find least confident units")
|
|
521
|
+
find_units_button.setToolTip("Ranks the units of all analyzers by the confidence of the selected model(s), "
|
|
522
|
+
"and picks the least confident ones you have not labelled yet")
|
|
523
|
+
find_units_button.clicked.connect(self.pick_units_to_relabel)
|
|
524
|
+
find_layout.addWidget(find_units_button)
|
|
525
|
+
find_layout.addStretch()
|
|
526
|
+
self.relabel_section.body_layout.addLayout(find_layout)
|
|
527
|
+
|
|
528
|
+
self.relabel_section.body_layout.addWidget(subheading("4.2 Relabel them in each analyzer"))
|
|
529
|
+
self.relabel_rows_layout = self.add_rows_container(self.relabel_section)
|
|
530
|
+
|
|
531
|
+
self.relabel_section.body_layout.addWidget(subheading("4.3 Retrain"))
|
|
532
|
+
retrain_choice_layout = QtWidgets.QHBoxLayout()
|
|
533
|
+
retrain_choice_layout.addWidget(QtWidgets.QLabel("Model to retrain:"))
|
|
534
|
+
self.retrain_choice = QtWidgets.QComboBox()
|
|
535
|
+
self.retrain_choice.setToolTip("With several models chosen in 3.2, retrain one of them, or all")
|
|
536
|
+
self.retrain_choice.currentIndexChanged.connect(self.on_retrain_choice_changed)
|
|
537
|
+
retrain_choice_layout.addWidget(self.retrain_choice, 1)
|
|
538
|
+
self.relabel_section.body_layout.addLayout(retrain_choice_layout)
|
|
539
|
+
self.relabel_section.body_layout.addWidget(muted_label(
|
|
540
|
+
"Choose a model from the models you selected in <b>3.2</b>."))
|
|
541
|
+
retrain_layout = QtWidgets.QHBoxLayout()
|
|
542
|
+
retrain_layout.addWidget(QtWidgets.QLabel("Retrained model name:"))
|
|
543
|
+
self.retrainedModelNameForm = QtWidgets.QLineEdit("")
|
|
544
|
+
retrain_layout.addWidget(self.retrainedModelNameForm, 1)
|
|
545
|
+
retrain_button = theme.make_primary(QtWidgets.QPushButton('Retrain'))
|
|
546
|
+
retrain_button.clicked.connect(partial(self.retrain_model))
|
|
547
|
+
retrain_layout.addWidget(retrain_button)
|
|
548
|
+
self.relabel_section.body_layout.addLayout(retrain_layout)
|
|
549
|
+
|
|
550
|
+
self.relabel_section.body_layout.addWidget(subheading("4.4 Retraining history"))
|
|
551
|
+
self.history_message = muted_label("")
|
|
552
|
+
self.relabel_section.body_layout.addWidget(self.history_message)
|
|
553
|
+
self.history_table = RetrainingHistoryTable()
|
|
554
|
+
self.relabel_section.body_layout.addWidget(self.history_table)
|
|
555
|
+
|
|
556
|
+
self.active_learning_gallery = FigureGallery(self.figure_worker, self.figures_folder("4_active_learning"))
|
|
557
|
+
self.relabel_section.body_layout.addWidget(self.active_learning_gallery)
|
|
558
|
+
|
|
559
|
+
###############
|
|
560
|
+
# 5. SHARE
|
|
561
|
+
###############
|
|
562
|
+
|
|
563
|
+
self.share_section = self.make_section(5, "Share your model")
|
|
564
|
+
self.share_section.body_layout.addWidget(muted_label(
|
|
565
|
+
"You can upload your models to the Hugging Face Hub, so others can download and use them, like the "
|
|
566
|
+
"UnitRefine models in <b>+ Load from HFH</b>."))
|
|
567
|
+
tutorial_layout = QtWidgets.QHBoxLayout()
|
|
568
|
+
tutorial_button = theme.make_flat(QtWidgets.QPushButton("Optional: open the tutorial on uploading a model"))
|
|
569
|
+
tutorial_button.setToolTip(UPLOAD_TUTORIAL_URL)
|
|
570
|
+
tutorial_button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(UPLOAD_TUTORIAL_URL)))
|
|
571
|
+
tutorial_layout.addWidget(tutorial_button)
|
|
572
|
+
tutorial_layout.addStretch()
|
|
573
|
+
self.share_section.body_layout.addLayout(tutorial_layout)
|
|
574
|
+
|
|
575
|
+
self.main_layout.addStretch(1)
|
|
576
|
+
|
|
577
|
+
self.make_curation_button_list()
|
|
578
|
+
self.make_relabel_button_list()
|
|
579
|
+
|
|
580
|
+
# fill the model selection once all the sections it updates exist
|
|
581
|
+
self.model_selector.selection_changed.connect(self.on_model_selection_changed)
|
|
582
|
+
self.make_model_list()
|
|
583
|
+
|
|
584
|
+
def finish_loading(self):
|
|
585
|
+
"""
|
|
586
|
+
Fills in everything before the window is first shown, so it doesn't change in front of the user: the
|
|
587
|
+
figures (saved ones are shown at once, the others are made in the background), and the widgets which
|
|
588
|
+
were replaced while the lists were built.
|
|
589
|
+
"""
|
|
590
|
+
self.figure_refresh_timer.stop()
|
|
591
|
+
self.refresh_figures()
|
|
592
|
+
QCoreApplication.sendPostedEvents(None, QEvent.DeferredDelete)
|
|
593
|
+
self.ensurePolished()
|
|
594
|
+
self.layout().activate()
|
|
595
|
+
|
|
596
|
+
def make_section(self, number, title):
|
|
597
|
+
"""A collapsible section card; whether it is folded is remembered in the project's config."""
|
|
598
|
+
collapsed = self.project.config.get("collapsed_sections")
|
|
599
|
+
if collapsed is None:
|
|
600
|
+
# a new project opens with section 3 only: applying a (downloaded) model is where most people start
|
|
601
|
+
expanded = number == 3
|
|
602
|
+
else:
|
|
603
|
+
# the project details are folded unless opened: they are not needed all the time
|
|
604
|
+
expanded = not collapsed.get(str(number), number == 1)
|
|
605
|
+
section = CollapsibleSection(number, title, expanded=expanded)
|
|
606
|
+
section.toggled.connect(lambda expanded, number=number: self.remember_section_state(number, expanded))
|
|
607
|
+
self.main_layout.addWidget(section)
|
|
608
|
+
return section
|
|
609
|
+
|
|
610
|
+
def remember_section_state(self, number, expanded):
|
|
611
|
+
self.project.config.setdefault("collapsed_sections", {})[str(number)] = not expanded
|
|
612
|
+
self.project.save_config()
|
|
613
|
+
|
|
614
|
+
def add_rows_container(self, section):
|
|
615
|
+
"""A list of rows (e.g. one per analyzer) which is rebuilt when the project changes."""
|
|
616
|
+
container, rows_layout = list_box()
|
|
617
|
+
section.body_layout.addWidget(container)
|
|
618
|
+
return rows_layout
|
|
619
|
+
|
|
620
|
+
def figures_folder(self, section_folder):
|
|
621
|
+
return Path(self.output_folder) / figures.FIGURES_FOLDER / section_folder
|
|
622
|
+
|
|
623
|
+
def schedule_figure_refresh(self):
|
|
624
|
+
self.figure_refresh_timer.start()
|
|
625
|
+
|
|
626
|
+
def refresh_figures(self):
|
|
627
|
+
"""
|
|
628
|
+
Updates the figures of sections 2, 3 and 4. Figures whose files did not change are shown at once
|
|
629
|
+
from the saved png; the others are made again in the background.
|
|
630
|
+
"""
|
|
631
|
+
targets = self.relabel_targets()
|
|
632
|
+
|
|
633
|
+
# until some units are labelled, there is nothing to plot: say so instead of showing empty figures
|
|
634
|
+
has_labels = any(
|
|
635
|
+
(Path(target['save_folder']) / "labels.csv").is_file() for target in targets if target['analyzer_index'] is not None
|
|
636
|
+
)
|
|
637
|
+
if has_labels:
|
|
638
|
+
self.training_gallery.set_specs(figures.training_specs(self.output_folder, targets, self.trained_model_folders()))
|
|
639
|
+
elif len(self.project.analyzers) == 0:
|
|
640
|
+
self.training_gallery.set_specs([], empty_message="Figures appear here once you add your spike sorted data and label its units.")
|
|
641
|
+
else:
|
|
642
|
+
self.training_gallery.set_specs([], empty_message="Figures appear here once you curate or import labels.")
|
|
643
|
+
|
|
644
|
+
problem = self.model_selector.describe_problem()
|
|
645
|
+
if problem is not None:
|
|
646
|
+
message = f"Figures appear here once models are chosen. {problem}"
|
|
647
|
+
self.apply_gallery.set_specs([], empty_message=message)
|
|
648
|
+
self.active_learning_gallery.set_specs([], empty_message=message)
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
mode = self.model_selector.mode()
|
|
652
|
+
model_folders = [model_folder for model_folder, _ in self.model_selector.selected_models()]
|
|
653
|
+
key = self.model_selector.selection_key()
|
|
654
|
+
apply_specs = figures.apply_specs(self.output_folder, targets, mode, model_folders, key)
|
|
655
|
+
self.apply_gallery.set_specs(apply_specs, folder=apply_specs[0].path.parent)
|
|
656
|
+
lineages = dict(self.model_lineage(model_folder.name) for model_folder in model_folders)
|
|
657
|
+
active_learning_specs = figures.active_learning_specs(self.output_folder, targets, mode, model_folders, key, lineages)
|
|
658
|
+
self.active_learning_gallery.set_specs(active_learning_specs, folder=active_learning_specs[0].path.parent)
|
|
659
|
+
|
|
660
|
+
def trained_model_folders(self, max_models=6):
|
|
661
|
+
"""The models trained in this project (not retrained versions), newest first, for the figures of section 2."""
|
|
662
|
+
trained = []
|
|
663
|
+
for model_folder, hfh_or_local in self.project.model_paths:
|
|
664
|
+
model_folder = Path(model_folder)
|
|
665
|
+
base_name, _ = _base_and_idx(model_folder.name)
|
|
666
|
+
if hfh_or_local != "local" or base_name != model_folder.name:
|
|
667
|
+
continue
|
|
668
|
+
if not ((model_folder / figures.TEST_PREDICTIONS_FILE).is_file() or (model_folder / "trained_on.json").is_file()):
|
|
669
|
+
continue
|
|
670
|
+
model_file = model_folder / "best_model.skops"
|
|
671
|
+
if model_file.is_file():
|
|
672
|
+
trained.append((model_file.stat().st_mtime, model_folder))
|
|
673
|
+
return [model_folder for _, model_folder in sorted(trained, key=lambda item: item[0], reverse=True)][:max_models]
|
|
674
|
+
|
|
675
|
+
def update_summaries(self):
|
|
676
|
+
"""The short summaries in the section headers, and the counts in the project details."""
|
|
677
|
+
num_labelled = 0
|
|
678
|
+
for analyzer in self.project.analyzers.values():
|
|
679
|
+
labels_path = Path(self.project.folder_name) / analyzer['analyzer_in_project'] / "labels.csv"
|
|
680
|
+
if labels_path.is_file():
|
|
681
|
+
num_labelled += len(pd.read_csv(labels_path))
|
|
682
|
+
num_analyzers = len(self.project.analyzers)
|
|
683
|
+
num_applied = len(self.project.config.get("applied_analyzers", []))
|
|
684
|
+
self.project_counts_label.setText(
|
|
685
|
+
f"{num_analyzers} analyzer{'s' if num_analyzers != 1 else ''}, {num_labelled} labelled units, "
|
|
686
|
+
f"{len(self.project.model_paths)} models, {num_applied} new analyzers with predictions"
|
|
687
|
+
)
|
|
688
|
+
self.curation_section.set_summary(
|
|
689
|
+
f"{num_analyzers} analyzer{'s' if num_analyzers != 1 else ''} · {num_labelled} labelled units"
|
|
690
|
+
)
|
|
691
|
+
if hasattr(self, "model_selector"):
|
|
692
|
+
names = self.model_selector.selected_model_names()
|
|
693
|
+
self.apply_section.set_summary(self.selection_description() if len(names) > 0 else "no model chosen")
|
|
694
|
+
|
|
695
|
+
def add_from_s3(self):
|
|
696
|
+
|
|
697
|
+
dialog = UrlInputDialog()
|
|
698
|
+
|
|
699
|
+
if dialog.exec():
|
|
700
|
+
url = dialog.get_url()
|
|
701
|
+
if is_path_remote(url):
|
|
702
|
+
self.project.add_analyzer(url)
|
|
703
|
+
self.project.save()
|
|
704
|
+
self.make_curation_button_list()
|
|
705
|
+
self.make_validate_button_list()
|
|
706
|
+
self.make_relabel_button_list()
|
|
707
|
+
print(f"Successfully added analyzer at {url} to project.")
|
|
708
|
+
else:
|
|
709
|
+
print(f"url {url} is not a valid analyzer path.")
|
|
710
|
+
|
|
711
|
+
def update_num_units_to_relabel(self):
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
num_units = int(self.numUnitsForm.text())
|
|
715
|
+
except ValueError:
|
|
716
|
+
print("Cannot parse the number of units to relabel. Must be a whole number. Please change the value.")
|
|
717
|
+
return
|
|
718
|
+
if num_units < 1:
|
|
719
|
+
print("Number of units to relabel should be at least 1. Please change the value.")
|
|
720
|
+
return
|
|
721
|
+
self.num_units_to_relabel = num_units
|
|
722
|
+
|
|
723
|
+
def relabel_targets(self):
|
|
724
|
+
"""
|
|
725
|
+
The analyzers whose units can be relabelled: the analyzers in the project (section 2) and the
|
|
726
|
+
new analyzers the models were applied to (section 3.3). Each is a dict with the analyzer `path`,
|
|
727
|
+
the `save_folder` and `file_prefix` of its files, and its `analyzer_index` in the project (None for new analyzers).
|
|
728
|
+
"""
|
|
729
|
+
targets = []
|
|
730
|
+
for analyzer_index, analyzer in enumerate(self.project.analyzers.values()):
|
|
731
|
+
targets.append(dict(
|
|
732
|
+
path=str(analyzer['path']),
|
|
733
|
+
save_folder=Path(self.project.folder_name) / analyzer['analyzer_in_project'],
|
|
734
|
+
file_prefix="",
|
|
735
|
+
analyzer_index=analyzer_index,
|
|
736
|
+
analyzer=analyzer,
|
|
737
|
+
))
|
|
738
|
+
for applied in self.project.config.get("applied_analyzers", []):
|
|
739
|
+
targets.append(dict(
|
|
740
|
+
path=applied['path'],
|
|
741
|
+
save_folder=Path(applied['output_folder']),
|
|
742
|
+
file_prefix=f"{applied['output_name']}__",
|
|
743
|
+
analyzer_index=None,
|
|
744
|
+
analyzer={'path': applied['path']},
|
|
745
|
+
))
|
|
746
|
+
return targets
|
|
747
|
+
|
|
748
|
+
def pick_units_to_relabel(self):
|
|
749
|
+
|
|
750
|
+
problem = self.model_selector.describe_problem()
|
|
751
|
+
if problem is not None:
|
|
752
|
+
print(problem)
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
targets = self.relabel_targets()
|
|
756
|
+
if len(targets) == 0:
|
|
757
|
+
print("No analyzers in the project. Add analyzers in section 2, or apply the models to new analyzers in section 3.3.")
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
mode = self.model_selector.mode()
|
|
761
|
+
model_folders = [model_folder for model_folder, _ in self.model_selector.selected_models()]
|
|
762
|
+
selection_key = self.model_selector.selection_key()
|
|
763
|
+
|
|
764
|
+
print(f"\nFinding the {self.num_units_to_relabel} least confident units of {self.selection_description()}, across {len(targets)} analyzers...")
|
|
765
|
+
with BusyNotice(
|
|
766
|
+
self, f"Finding the {self.num_units_to_relabel} least confident units",
|
|
767
|
+
f"Ranking the units of {len(targets)} analyzers by the confidence of {self.selection_description()}. "
|
|
768
|
+
"Analyzers without saved predictions are labelled by the model(s) first, which takes longer the first time.",
|
|
769
|
+
):
|
|
770
|
+
num_picked, skipped = find_least_confident_units(targets, mode, model_folders, selection_key, self.num_units_to_relabel)
|
|
771
|
+
|
|
772
|
+
for target_index, target in enumerate(targets):
|
|
773
|
+
print(f" {target['path']}: {num_picked[target_index]} units")
|
|
774
|
+
for target, error in skipped:
|
|
775
|
+
print(f" Skipped {target['path']}: {error}")
|
|
776
|
+
print()
|
|
777
|
+
|
|
778
|
+
self.make_relabel_button_list()
|
|
779
|
+
|
|
780
|
+
if len(skipped) == len(targets):
|
|
781
|
+
self.show_problem(
|
|
782
|
+
"Could not rank the units",
|
|
783
|
+
"The chosen model(s) could not label any of the analyzers:\n\n"
|
|
784
|
+
+ "\n".join(f"• {Path(str(target['path'])).name}: {error}" for target, error in skipped),
|
|
785
|
+
)
|
|
786
|
+
elif sum(num_picked.values()) == 0:
|
|
787
|
+
self.show_problem(
|
|
788
|
+
"No units left to relabel",
|
|
789
|
+
"Every unit of your analyzers already has a label (curated, imported or relabelled), so there is "
|
|
790
|
+
"nothing new to ask about. Add new analyzers in section 3.3 to continue.",
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def add_from_hfh(self):
|
|
794
|
+
|
|
795
|
+
existing_repo_ids = []
|
|
796
|
+
for model_folder, hfh_or_local in self.project.model_paths:
|
|
797
|
+
hfh_path = Path(model_folder) / 'hfh_path.txt'
|
|
798
|
+
if hfh_or_local == "hfh" and hfh_path.is_file():
|
|
799
|
+
existing_repo_ids.append(hfh_path.read_text().strip())
|
|
800
|
+
|
|
801
|
+
dialog = HFHModelsDialog(existing_repo_ids, parent=self)
|
|
802
|
+
if not dialog.exec():
|
|
803
|
+
return
|
|
804
|
+
|
|
805
|
+
repo_ids = dialog.get_repo_ids()
|
|
806
|
+
if len(repo_ids) == 0:
|
|
807
|
+
print("No models selected.")
|
|
808
|
+
return
|
|
809
|
+
|
|
810
|
+
added_repo_ids = []
|
|
811
|
+
with BusyNotice(self, f"Downloading {len(repo_ids)} model{'s' if len(repo_ids) > 1 else ''} from the Hugging Face Hub", "") as notice:
|
|
812
|
+
for repo_index, repo_id in enumerate(repo_ids):
|
|
813
|
+
notice.update(f"{repo_index + 1} of {len(repo_ids)}: {repo_id}")
|
|
814
|
+
try:
|
|
815
|
+
if self.download_hfh_model(repo_id):
|
|
816
|
+
added_repo_ids.append(repo_id)
|
|
817
|
+
except Exception as e:
|
|
818
|
+
print(f"Could not download model from {repo_id}: {e}\n")
|
|
819
|
+
|
|
820
|
+
self.make_model_list()
|
|
821
|
+
print(f"Added {len(added_repo_ids)}/{len(repo_ids)} models to project: {added_repo_ids}\n")
|
|
822
|
+
|
|
823
|
+
def download_hfh_model(self, url):
|
|
824
|
+
"""Downloads one model from the Hugging Face Hub into the project. Returns True if it was added."""
|
|
825
|
+
|
|
826
|
+
if not repo_exists(url):
|
|
827
|
+
print(f"Repo {url} does not exist.")
|
|
828
|
+
return False
|
|
829
|
+
|
|
830
|
+
model_directory = self.project.folder_name / "models" / url.split('/')[-1]
|
|
831
|
+
if model_directory.is_dir():
|
|
832
|
+
print(f"Skipping {url}: a model folder called {model_directory.name} already exists in the project.")
|
|
833
|
+
return False
|
|
834
|
+
|
|
835
|
+
download_model(url, model_directory)
|
|
836
|
+
|
|
837
|
+
self.project.model_paths = self.project.model_paths + [(model_directory, "hfh")]
|
|
838
|
+
|
|
839
|
+
print(f"Model at {url} successfully added to project.\n")
|
|
840
|
+
return True
|
|
841
|
+
|
|
842
|
+
def make_apply_code(self):
|
|
843
|
+
|
|
844
|
+
problem = self.model_selector.describe_problem()
|
|
845
|
+
if problem is not None:
|
|
846
|
+
print(problem)
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
model_folders = [model_folder for model_folder, _ in self.model_selector.selected_models()]
|
|
850
|
+
|
|
851
|
+
# the function names of the installed SpikeInterface version (auto_label_units was renamed)
|
|
852
|
+
label_units = label_units_function().__name__
|
|
853
|
+
import spikeinterface.curation as spikeinterface_curation
|
|
854
|
+
has_cascade_function = hasattr(spikeinterface_curation, "unitrefine_label_units")
|
|
855
|
+
|
|
856
|
+
code_text = "\n"
|
|
857
|
+
code_text += "import spikeinterface.full as si\n"
|
|
858
|
+
code_text += "import spikeinterface.curation as sc\n\n"
|
|
859
|
+
code_text += "# point this path to the analyzer you want to apply the model to\n"
|
|
860
|
+
code_text += "path_to_analyzer = 'path/to/analyzer'\n"
|
|
861
|
+
code_text += "analyzer_to_curate = si.load_sorting_analyzer(path_to_analyzer)\n\n"
|
|
862
|
+
|
|
863
|
+
if self.model_selector.mode() == "cascade" and has_cascade_function:
|
|
864
|
+
code_text += "# Cascade, as in the UnitRefine paper: noise vs neural, then SUA vs MUA on the neural units\n"
|
|
865
|
+
code_text += (f"labels = sc.unitrefine_label_units(\n\tsorting_analyzer = analyzer_to_curate,\n"
|
|
866
|
+
f"\tnoise_neural_classifier = r'{model_folders[0]}',\n\tsua_mua_classifier = r'{model_folders[1]}',\n)\n\n")
|
|
867
|
+
elif self.model_selector.mode() == "cascade":
|
|
868
|
+
code_text += "# Cascade: layer 1 separates noise from neural units, layer 2 splits the neural units into SUA and MUA\n"
|
|
869
|
+
code_text += f"layer1 = sc.{label_units}(\n\tsorting_analyzer = analyzer_to_curate,\n\tmodel_folder = r'{model_folders[0]}',\n\ttrust_model = True,\n)\n"
|
|
870
|
+
code_text += f"layer2 = sc.{label_units}(\n\tsorting_analyzer = analyzer_to_curate,\n\tmodel_folder = r'{model_folders[1]}',\n\ttrust_model = True,\n)\n\n"
|
|
871
|
+
code_text += "is_noise = layer1['prediction'] == 'noise'\n"
|
|
872
|
+
code_text += f"layer2_labels = layer2['prediction'].map({QUALITY_ALIASES})\n"
|
|
873
|
+
code_text += "labels = layer2_labels.where(~is_noise, 'noise')\n"
|
|
874
|
+
code_text += "# confidence of the cascade: p(layer 1) x p(layer 2) for neural units, p(layer 1) for noise units\n"
|
|
875
|
+
code_text += "confidence = (layer1['probability'] * layer2['probability']).where(~is_noise, layer1['probability'])\n\n"
|
|
876
|
+
else:
|
|
877
|
+
for model_folder in model_folders:
|
|
878
|
+
code_text += f"# labels will be a DataFrame of curated labels and their probability, determined by {model_folder.name}.\n"
|
|
879
|
+
code_text += f"labels = sc.{label_units}(\n\tsorting_analyzer = analyzer_to_curate,\n\tmodel_folder = r'{model_folder}',\n\ttrust_model = True,\n)\n\n"
|
|
880
|
+
|
|
881
|
+
code_text += "# Read more here: https://spikeinterface.readthedocs.io/en/stable/tutorials/curation/plot_1_automated_curation.html\n\n"
|
|
882
|
+
|
|
883
|
+
print(code_text)
|
|
884
|
+
|
|
885
|
+
def selectDirectoryDialog(self):
|
|
886
|
+
file_dialog = QtWidgets.QFileDialog(self)
|
|
887
|
+
file_dialog.setWindowTitle("Select Directory")
|
|
888
|
+
file_dialog.setFileMode(QtWidgets.QFileDialog.FileMode.Directory)
|
|
889
|
+
file_dialog.setViewMode(QtWidgets.QFileDialog.ViewMode.List)
|
|
890
|
+
|
|
891
|
+
if file_dialog.exec():
|
|
892
|
+
selected_directory = file_dialog.selectedFiles()[0]
|
|
893
|
+
|
|
894
|
+
if check_folder_is_analyzer(selected_directory):
|
|
895
|
+
|
|
896
|
+
self.project.add_analyzer(selected_directory)
|
|
897
|
+
self.project.save()
|
|
898
|
+
|
|
899
|
+
self.make_curation_button_list()
|
|
900
|
+
self.make_model_list()
|
|
901
|
+
self.make_relabel_button_list()
|
|
902
|
+
|
|
903
|
+
print(f"Successfully added analyzer at {selected_directory} to project.")
|
|
904
|
+
else:
|
|
905
|
+
print(f"Selected directory {selected_directory} is not a SortingAnalyzer.")
|
|
906
|
+
|
|
907
|
+
def selectModelDialog(self):
|
|
908
|
+
file_dialog = QtWidgets.QFileDialog(self)
|
|
909
|
+
file_dialog.setWindowTitle("Select Directory")
|
|
910
|
+
file_dialog.setFileMode(QtWidgets.QFileDialog.FileMode.Directory)
|
|
911
|
+
file_dialog.setViewMode(QtWidgets.QFileDialog.ViewMode.List)
|
|
912
|
+
|
|
913
|
+
if file_dialog.exec():
|
|
914
|
+
selected_directory = file_dialog.selectedFiles()[0]
|
|
915
|
+
|
|
916
|
+
if is_a_model(selected_directory):
|
|
917
|
+
self.project.model_paths = self.project.model_paths + [(Path(selected_directory), "local")]
|
|
918
|
+
self.make_model_list()
|
|
919
|
+
print(f"Model at {selected_directory} added to project.")
|
|
920
|
+
else:
|
|
921
|
+
print(f"{selected_directory} is not a UnitRefine model folder.")
|
|
922
|
+
|
|
923
|
+
def make_curation_button_list(self):
|
|
924
|
+
|
|
925
|
+
clear_layout(self.analyzer_rows_layout)
|
|
926
|
+
|
|
927
|
+
if len(self.project.analyzers) == 0:
|
|
928
|
+
self.analyzer_rows_layout.addWidget(list_message("No analyzers yet: add a Sorting Analyzer folder or a Phy output to start."))
|
|
929
|
+
|
|
930
|
+
for row_index, (analyzer_index, analyzer) in enumerate(self.project.analyzers.items()):
|
|
931
|
+
|
|
932
|
+
selected_directory = analyzer['path']
|
|
933
|
+
|
|
934
|
+
curate_button = theme.make_sigui(QtWidgets.QPushButton("Curate"))
|
|
935
|
+
curate_button.setToolTip(f"Label the units of {selected_directory} in the SpikeInterface-GUI")
|
|
936
|
+
curate_button.clicked.connect(
|
|
937
|
+
lambda _checked=False, directory=selected_directory, index=analyzer_index: self.show_curation_window(directory, index))
|
|
938
|
+
|
|
939
|
+
or_text = muted_label("or", word_wrap=False)
|
|
940
|
+
|
|
941
|
+
curation_output_folder = Path(self.project.folder_name) / Path(f"analyzers/analyzer_{analyzer_index}")
|
|
942
|
+
curation_output_folder.mkdir(exist_ok=True)
|
|
943
|
+
|
|
944
|
+
imported_source_path = curation_output_folder / "imported_labels_source.txt"
|
|
945
|
+
if imported_source_path.is_file():
|
|
946
|
+
import_labels_button = QtWidgets.QPushButton("Imported ✓")
|
|
947
|
+
import_labels_button.setToolTip(f"Labels imported from {imported_source_path.read_text()}. Click to import again.")
|
|
948
|
+
else:
|
|
949
|
+
import_labels_button = QtWidgets.QPushButton("+ Import labels")
|
|
950
|
+
import_labels_button.setToolTip(
|
|
951
|
+
"Import labels from Phy (cluster_group.tsv, cluster_info.tsv), SortingView or SpikeInterface "
|
|
952
|
+
"curation (.json), or a .csv/.tsv with unit_id and quality columns"
|
|
953
|
+
)
|
|
954
|
+
import_labels_button.clicked.connect(lambda _checked=False, index=analyzer_index: self.import_labels_dialog(index))
|
|
955
|
+
|
|
956
|
+
if (curation_output_folder / "all_metrics.csv").is_file() and (curation_output_folder / "labels.csv").is_file():
|
|
957
|
+
num_labelled = len(pd.read_csv(curation_output_folder / "labels.csv"))
|
|
958
|
+
num_units = len(pd.read_csv(curation_output_folder / "all_metrics.csv"))
|
|
959
|
+
count_chip = chip(f"{num_labelled}/{num_units} labelled", done=num_labelled == num_units)
|
|
960
|
+
else:
|
|
961
|
+
count_chip = chip("not labelled")
|
|
962
|
+
count_chip.setMinimumWidth(110)
|
|
963
|
+
|
|
964
|
+
remove_button = QtWidgets.QToolButton()
|
|
965
|
+
remove_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_TrashIcon))
|
|
966
|
+
remove_button.setToolTip(f"Remove {selected_directory} from the project")
|
|
967
|
+
remove_button.clicked.connect(lambda _checked=False, index=analyzer_index: self.remove_analyzer(index))
|
|
968
|
+
|
|
969
|
+
self.analyzer_rows_layout.addWidget(PathRow(
|
|
970
|
+
str(selected_directory), [curate_button, or_text, import_labels_button, count_chip, remove_button], first=row_index == 0))
|
|
971
|
+
|
|
972
|
+
self.update_summaries()
|
|
973
|
+
self.schedule_figure_refresh()
|
|
974
|
+
|
|
975
|
+
def ask_label_file(self):
|
|
976
|
+
"""Asks for a label file; returns "" if cancelled."""
|
|
977
|
+
label_file, _ = QtWidgets.QFileDialog.getOpenFileName(
|
|
978
|
+
self,
|
|
979
|
+
"Select label file",
|
|
980
|
+
"",
|
|
981
|
+
"All supported label files (*.tsv *.csv *.json);;"
|
|
982
|
+
"Phy (cluster_group.tsv cluster_info.tsv cluster_KSLabel.tsv);;"
|
|
983
|
+
"SortingView or SpikeInterface curation (*.json);;"
|
|
984
|
+
"Table with unit_id and quality columns (*.csv *.tsv);;"
|
|
985
|
+
"All files (*)",
|
|
986
|
+
)
|
|
987
|
+
return label_file
|
|
988
|
+
|
|
989
|
+
def import_labels_dialog(self, analyzer_index):
|
|
990
|
+
|
|
991
|
+
label_file = self.ask_label_file()
|
|
992
|
+
if label_file == "":
|
|
993
|
+
return
|
|
994
|
+
self.import_label_file(analyzer_index, label_file)
|
|
995
|
+
|
|
996
|
+
def import_label_file(self, analyzer_index, label_file):
|
|
997
|
+
"""Imports the labels in `label_file` for an analyzer in the project. Returns True if it worked."""
|
|
998
|
+
|
|
999
|
+
analyzer_dict = self.project.analyzers[analyzer_index]
|
|
1000
|
+
save_folder = Path(self.project.folder_name) / Path(f"analyzers/analyzer_{analyzer_index}")
|
|
1001
|
+
|
|
1002
|
+
labels_path = save_folder / "labels.csv"
|
|
1003
|
+
existing_labels = pd.read_csv(labels_path) if labels_path.is_file() else None
|
|
1004
|
+
|
|
1005
|
+
print(f"\nImporting labels from {label_file} for analyzer at {analyzer_dict['path']}...")
|
|
1006
|
+
try:
|
|
1007
|
+
with BusyNotice(self, "Importing labels", f"Reading {Path(label_file).name} and the metrics of the analyzer's units."):
|
|
1008
|
+
labels_df = import_labels(label_file, analyzer_dict['path'], save_folder, existing_labels=existing_labels)
|
|
1009
|
+
except Exception as e:
|
|
1010
|
+
print(f"Could not import labels: {e}")
|
|
1011
|
+
QtWidgets.QMessageBox.warning(self, "Could not import labels", str(e))
|
|
1012
|
+
return False
|
|
1013
|
+
|
|
1014
|
+
# keep the in-memory project in sync, so a later project.save() does not overwrite the imported labels
|
|
1015
|
+
analyzer_dict['labels'] = labels_df
|
|
1016
|
+
(save_folder / "imported_labels_source.txt").write_text(str(label_file))
|
|
1017
|
+
|
|
1018
|
+
self.make_curation_button_list()
|
|
1019
|
+
return True
|
|
1020
|
+
|
|
1021
|
+
def show_unwritable_file(self, path, message):
|
|
1022
|
+
if str(path) in self.reported_unwritable_files:
|
|
1023
|
+
return
|
|
1024
|
+
self.reported_unwritable_files.add(str(path))
|
|
1025
|
+
QtWidgets.QMessageBox.warning(self, "Could not save a file", message)
|
|
1026
|
+
|
|
1027
|
+
def confirm(self, title, text):
|
|
1028
|
+
"""Asks Yes or No, with No as the default; returns True for Yes."""
|
|
1029
|
+
answer = QtWidgets.QMessageBox.question(
|
|
1030
|
+
self, title, text, QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No)
|
|
1031
|
+
return answer == QtWidgets.QMessageBox.Yes
|
|
1032
|
+
|
|
1033
|
+
def remove_analyzer(self, analyzer_index):
|
|
1034
|
+
|
|
1035
|
+
analyzer = self.project.analyzers[analyzer_index]
|
|
1036
|
+
analyzer_folder = Path(self.project.folder_name) / Path(analyzer['analyzer_in_project'])
|
|
1037
|
+
|
|
1038
|
+
# the folder holds the labels curated or imported for this analyzer, which can't be brought back
|
|
1039
|
+
labels_path = analyzer_folder / "labels.csv"
|
|
1040
|
+
num_labels = len(pd.read_csv(labels_path)) if labels_path.is_file() else 0
|
|
1041
|
+
losing = (f"its {num_labels} labels, which can't be brought back" if num_labels > 0
|
|
1042
|
+
else "its files in the project")
|
|
1043
|
+
if not self.confirm(
|
|
1044
|
+
"Remove analyzer?",
|
|
1045
|
+
f"Remove {analyzer['path']} from the project and delete {losing}?\n\n"
|
|
1046
|
+
"The recording itself is not deleted.",
|
|
1047
|
+
):
|
|
1048
|
+
return
|
|
1049
|
+
|
|
1050
|
+
shutil.rmtree(str(analyzer_folder))
|
|
1051
|
+
if analyzer_folder.is_dir():
|
|
1052
|
+
os.rmdir(str(analyzer_folder))
|
|
1053
|
+
|
|
1054
|
+
print(f"Removing analyzer at {analyzer_folder} from project.")
|
|
1055
|
+
|
|
1056
|
+
self.project.analyzers = {key: value for key, value in self.project.analyzers.items() if key != analyzer_index}
|
|
1057
|
+
|
|
1058
|
+
self.make_curation_button_list()
|
|
1059
|
+
self.make_validate_button_list()
|
|
1060
|
+
self.make_relabel_button_list()
|
|
1061
|
+
|
|
1062
|
+
def make_relabel_button_list(self):
|
|
1063
|
+
|
|
1064
|
+
clear_layout(self.relabel_rows_layout)
|
|
1065
|
+
|
|
1066
|
+
selection_key = self.model_selector.selection_key()
|
|
1067
|
+
targets = self.relabel_targets()
|
|
1068
|
+
|
|
1069
|
+
if len(targets) == 0:
|
|
1070
|
+
self.relabel_rows_layout.addWidget(list_message(
|
|
1071
|
+
"No analyzers yet: add them in section 2, or apply the models to new analyzers in section 3.3."))
|
|
1072
|
+
|
|
1073
|
+
total_relabelled, total_picked = 0, 0
|
|
1074
|
+
for target_index, target in enumerate(targets):
|
|
1075
|
+
|
|
1076
|
+
# same groups as in section 3.3
|
|
1077
|
+
is_first_new = target['analyzer_index'] is None and (target_index == 0 or targets[target_index - 1]['analyzer_index'] is not None)
|
|
1078
|
+
if target_index == 0 and target['analyzer_index'] is not None:
|
|
1079
|
+
self.relabel_rows_layout.addWidget(group_label("Analyzers used during training the models:"))
|
|
1080
|
+
elif is_first_new:
|
|
1081
|
+
self.relabel_rows_layout.addWidget(group_label("New analyzers the models were applied to:"))
|
|
1082
|
+
|
|
1083
|
+
relabel_button = theme.make_sigui(QtWidgets.QPushButton("Relabel"))
|
|
1084
|
+
# lambda drops the `checked` argument which clicked passes
|
|
1085
|
+
relabel_button.clicked.connect(lambda _checked=False, target=target: self.show_validate_window(
|
|
1086
|
+
target['analyzer'], target['analyzer_index'], True, save_folder=target['save_folder'], file_prefix=target['file_prefix']))
|
|
1087
|
+
|
|
1088
|
+
status_chip = chip("not searched yet")
|
|
1089
|
+
relabel_button.setToolTip("Click Find least confident units first")
|
|
1090
|
+
if selection_key is not None and units_to_relabel_path(target, selection_key).is_file():
|
|
1091
|
+
units_to_relabel = pd.read_csv(units_to_relabel_path(target, selection_key))
|
|
1092
|
+
num_relabelled = 0
|
|
1093
|
+
if relabelled_units_path(target, selection_key).is_file():
|
|
1094
|
+
relabelled = set(pd.read_csv(relabelled_units_path(target, selection_key)).dropna(subset=['quality'])['unit_id'].astype(str))
|
|
1095
|
+
num_relabelled = int(units_to_relabel['unit_id'].astype(str).isin(relabelled).sum())
|
|
1096
|
+
total_relabelled += num_relabelled
|
|
1097
|
+
total_picked += len(units_to_relabel)
|
|
1098
|
+
if len(units_to_relabel) == 0:
|
|
1099
|
+
status_chip = chip("no units picked")
|
|
1100
|
+
relabel_button.setEnabled(False)
|
|
1101
|
+
relabel_button.setToolTip("None of the least confident units are in this analyzer")
|
|
1102
|
+
else:
|
|
1103
|
+
status_chip = chip(f"{num_relabelled}/{len(units_to_relabel)} relabelled", done=num_relabelled == len(units_to_relabel))
|
|
1104
|
+
relabel_button.setToolTip("Open the least confident units of this analyzer in the SpikeInterface-GUI")
|
|
1105
|
+
status_chip.setMinimumWidth(120)
|
|
1106
|
+
|
|
1107
|
+
self.relabel_rows_layout.addWidget(PathRow(str(target['path']), [status_chip, relabel_button], first=False))
|
|
1108
|
+
|
|
1109
|
+
self.relabel_section.set_summary(f"{total_relabelled}/{total_picked} least confident units relabelled" if total_picked > 0 else "")
|
|
1110
|
+
self.make_retraining_history()
|
|
1111
|
+
self.schedule_figure_refresh()
|
|
1112
|
+
|
|
1113
|
+
def make_retraining_history(self):
|
|
1114
|
+
"""The retraining rounds of the chosen models: what each added, from where, and the change in accuracy."""
|
|
1115
|
+
names = self.model_selector.selected_model_names()
|
|
1116
|
+
lineages = dict(self.model_lineage(name) for name in names)
|
|
1117
|
+
num_rounds = self.history_table.set_lineages(lineages)
|
|
1118
|
+
self.history_table.setVisible(num_rounds > 0)
|
|
1119
|
+
if len(names) == 0:
|
|
1120
|
+
self.history_message.setText("Choose a model in 3.2 to see its retraining rounds.")
|
|
1121
|
+
elif num_rounds == 0:
|
|
1122
|
+
self.history_message.setText("The chosen model(s) have not been retrained yet: each round you retrain appears here.")
|
|
1123
|
+
else:
|
|
1124
|
+
self.history_message.setText(
|
|
1125
|
+
"Each round: the units you added and where they come from (<b>Details</b> lists every unit with its old "
|
|
1126
|
+
"and new label), and the balanced accuracy on held-out units of the same classifier trained without, "
|
|
1127
|
+
"then with them.")
|
|
1128
|
+
|
|
1129
|
+
def make_validate_button_list(self):
|
|
1130
|
+
|
|
1131
|
+
clear_layout(self.validate_rows_layout)
|
|
1132
|
+
|
|
1133
|
+
# the new analyzers: predictions on recordings the models were not trained on
|
|
1134
|
+
new_list, new_rows_layout = list_box()
|
|
1135
|
+
applied_analyzers = self.project.config.get("applied_analyzers", [])
|
|
1136
|
+
if len(applied_analyzers) == 0:
|
|
1137
|
+
new_rows_layout.addWidget(list_message("No new analyzers yet: add them with the buttons above."))
|
|
1138
|
+
|
|
1139
|
+
for applied_index, applied in enumerate(applied_analyzers):
|
|
1140
|
+
file_prefix = f"{applied['output_name']}__"
|
|
1141
|
+
labels_path = Path(applied['output_folder']) / f"{file_prefix}labels.csv"
|
|
1142
|
+
|
|
1143
|
+
if labels_path.is_file():
|
|
1144
|
+
import_labels_button = QtWidgets.QPushButton("Imported ✓")
|
|
1145
|
+
source_path = Path(applied['output_folder']) / f"{file_prefix}imported_labels_source.txt"
|
|
1146
|
+
source = source_path.read_text() if source_path.is_file() else str(labels_path)
|
|
1147
|
+
import_labels_button.setToolTip(f"Labels imported from {source}. Click to import again.")
|
|
1148
|
+
labels_chip = chip(f"{len(pd.read_csv(labels_path))} labelled", done=True)
|
|
1149
|
+
labels_chip.setMinimumWidth(90)
|
|
1150
|
+
else:
|
|
1151
|
+
import_labels_button = QtWidgets.QPushButton("+ Import labels")
|
|
1152
|
+
import_labels_button.setToolTip("Optional: your labels for this analyzer, to compare them with the predictions")
|
|
1153
|
+
# the + Import labels button already says there are none
|
|
1154
|
+
labels_chip = None
|
|
1155
|
+
import_labels_button.clicked.connect(lambda _checked=False, applied=applied: self.import_labels_for_new_analyzer(applied))
|
|
1156
|
+
|
|
1157
|
+
inspect_button = theme.make_sigui(QtWidgets.QPushButton("Inspect"))
|
|
1158
|
+
inspect_button.setToolTip(f"Open in the SpikeInterface-GUI with the models' labels and confidence. "
|
|
1159
|
+
f"Predictions are saved in {applied['output_folder']}")
|
|
1160
|
+
inspect_button.clicked.connect(lambda _checked=False, applied=applied: self.show_applied_analyzer_window(applied))
|
|
1161
|
+
|
|
1162
|
+
remove_button = QtWidgets.QToolButton()
|
|
1163
|
+
remove_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_TrashIcon))
|
|
1164
|
+
remove_button.setToolTip("Remove from this list (saved csv files are kept)")
|
|
1165
|
+
remove_button.clicked.connect(lambda _checked=False, index=applied_index: self.remove_applied_analyzer(index))
|
|
1166
|
+
new_rows_layout.addWidget(PathRow(
|
|
1167
|
+
applied['path'], [widget for widget in [import_labels_button, labels_chip, inspect_button, remove_button] if widget is not None], first=applied_index == 0))
|
|
1168
|
+
self.validate_rows_layout.addWidget(new_list)
|
|
1169
|
+
self.validate_rows_layout.addWidget(muted_label(
|
|
1170
|
+
"<b>+ Import labels</b> (optional) can be used to see how a model performs on your recordings. For more "
|
|
1171
|
+
"about <b>+ Import labels</b> and <b>+ Phy output</b>, see section 2."))
|
|
1172
|
+
|
|
1173
|
+
# the training analyzers are hidden at first: predictions on training data look better than they are
|
|
1174
|
+
toggle_button = theme.make_flat(QtWidgets.QPushButton(
|
|
1175
|
+
("▾ Hide" if self.show_training_analyzers else "▸ Show") + " the analyzers used for training"))
|
|
1176
|
+
toggle_button.setToolTip("Inspecting them shows what the model learned, but not how well it does: "
|
|
1177
|
+
"predictions on training data look better than they are")
|
|
1178
|
+
toggle_button.clicked.connect(self.toggle_training_analyzers)
|
|
1179
|
+
toggle_layout = QtWidgets.QHBoxLayout()
|
|
1180
|
+
toggle_layout.addWidget(toggle_button)
|
|
1181
|
+
toggle_layout.addStretch()
|
|
1182
|
+
self.validate_rows_layout.addLayout(toggle_layout)
|
|
1183
|
+
|
|
1184
|
+
if self.show_training_analyzers:
|
|
1185
|
+
training_list, training_rows_layout = list_box()
|
|
1186
|
+
if len(self.project.analyzers) == 0:
|
|
1187
|
+
training_rows_layout.addWidget(list_message("None yet: add analyzers in section 2."))
|
|
1188
|
+
for analyzer_index, analyzer in enumerate(self.project.analyzers.values()):
|
|
1189
|
+
inspect_button = theme.make_sigui(QtWidgets.QPushButton("Inspect"))
|
|
1190
|
+
inspect_button.setToolTip("Open the analyzer in the SpikeInterface-GUI with the models' labels and confidence")
|
|
1191
|
+
inspect_button.clicked.connect(lambda _checked=False, analyzer=analyzer, analyzer_index=analyzer_index: self.show_validate_window(analyzer, analyzer_index, False))
|
|
1192
|
+
training_rows_layout.addWidget(PathRow(str(analyzer['path']), [inspect_button], first=analyzer_index == 0))
|
|
1193
|
+
self.validate_rows_layout.addWidget(training_list)
|
|
1194
|
+
|
|
1195
|
+
self.update_summaries()
|
|
1196
|
+
self.schedule_figure_refresh()
|
|
1197
|
+
|
|
1198
|
+
def selection_description(self):
|
|
1199
|
+
names = self.model_selector.selected_model_names()
|
|
1200
|
+
if self.model_selector.mode() == "cascade":
|
|
1201
|
+
return f"cascade of {names[0]} (layer 1) and {names[1]} (layer 2)"
|
|
1202
|
+
return ", ".join(names)
|
|
1203
|
+
|
|
1204
|
+
def show_problem(self, title, message):
|
|
1205
|
+
print(message)
|
|
1206
|
+
QtWidgets.QMessageBox.information(self, title, message)
|
|
1207
|
+
|
|
1208
|
+
def add_new_analyzers_from_folder(self):
|
|
1209
|
+
"""Adds one new analyzer, or all analyzers inside a folder, and applies the chosen models to them."""
|
|
1210
|
+
|
|
1211
|
+
problem = self.model_selector.describe_problem()
|
|
1212
|
+
if problem is not None:
|
|
1213
|
+
self.show_problem("Choose a model first", problem)
|
|
1214
|
+
return
|
|
1215
|
+
|
|
1216
|
+
folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Select a Sorting Analyzer folder, or a folder containing several")
|
|
1217
|
+
if folder == "":
|
|
1218
|
+
return
|
|
1219
|
+
analyzer_paths = [str(path) for path in find_analyzers(folder)]
|
|
1220
|
+
if len(analyzer_paths) == 0:
|
|
1221
|
+
self.show_problem("No Sorting Analyzer found", f"No Sorting Analyzer was found in {folder} (searched 3 folder levels down).")
|
|
1222
|
+
return
|
|
1223
|
+
self.apply_to_new_analyzers(analyzer_paths)
|
|
1224
|
+
|
|
1225
|
+
def add_new_analyzer_from_s3(self):
|
|
1226
|
+
|
|
1227
|
+
problem = self.model_selector.describe_problem()
|
|
1228
|
+
if problem is not None:
|
|
1229
|
+
self.show_problem("Choose a model first", problem)
|
|
1230
|
+
return
|
|
1231
|
+
|
|
1232
|
+
dialog = UrlInputDialog()
|
|
1233
|
+
if not dialog.exec():
|
|
1234
|
+
return
|
|
1235
|
+
url = dialog.get_url()
|
|
1236
|
+
if not is_path_remote(url):
|
|
1237
|
+
self.show_problem("Not an s3 path", f"{url} is not a valid analyzer path.")
|
|
1238
|
+
return
|
|
1239
|
+
self.apply_to_new_analyzers([url])
|
|
1240
|
+
|
|
1241
|
+
def apply_to_new_analyzers(self, analyzer_paths, after_added=None):
|
|
1242
|
+
"""
|
|
1243
|
+
Applies the chosen models to new analyzers, saving their predictions in the project's predictions folder,
|
|
1244
|
+
and adds them to the list in 3.3. Analyzers already in the list are skipped. If some analyzers don't have
|
|
1245
|
+
all the metrics the models use, offers to compute them (in their own process, see run_metrics_process) and
|
|
1246
|
+
then applies the models to them. `after_added()` is called once that is done.
|
|
1247
|
+
"""
|
|
1248
|
+
mode = self.model_selector.mode()
|
|
1249
|
+
model_folders = [model_folder for model_folder, _ in self.model_selector.selected_models()]
|
|
1250
|
+
selection_key = self.model_selector.selection_key()
|
|
1251
|
+
output_folder = Path(self.project.folder_name) / "predictions"
|
|
1252
|
+
|
|
1253
|
+
applied_analyzers = self.project.config.get("applied_analyzers", [])
|
|
1254
|
+
listed = {normalise_path(applied['path']) for applied in applied_analyzers}
|
|
1255
|
+
already_listed = [path for path in analyzer_paths if normalise_path(path) in listed]
|
|
1256
|
+
analyzer_paths = [path for path in analyzer_paths if normalise_path(path) not in listed]
|
|
1257
|
+
if len(analyzer_paths) == 0:
|
|
1258
|
+
self.show_problem("Already added", "These analyzers are already in the list.")
|
|
1259
|
+
return
|
|
1260
|
+
|
|
1261
|
+
# file names must not clash with the analyzers already in the list, which keep theirs
|
|
1262
|
+
used_names = {applied['output_name'] for applied in applied_analyzers}
|
|
1263
|
+
candidate_names = analyzer_output_names([applied['path'] for applied in applied_analyzers] + analyzer_paths)[len(applied_analyzers):]
|
|
1264
|
+
output_names = []
|
|
1265
|
+
for name in candidate_names:
|
|
1266
|
+
unique_name, number = name, 2
|
|
1267
|
+
while unique_name in used_names:
|
|
1268
|
+
unique_name, number = f"{name}_{number}", number + 1
|
|
1269
|
+
used_names.add(unique_name)
|
|
1270
|
+
output_names.append(unique_name)
|
|
1271
|
+
|
|
1272
|
+
with BusyNotice(
|
|
1273
|
+
self, f"Applying {self.selection_description()} to {len(analyzer_paths)} analyzer{'s' if len(analyzer_paths) > 1 else ''}",
|
|
1274
|
+
"The predictions are saved, so they are only computed once.",
|
|
1275
|
+
):
|
|
1276
|
+
results = apply_to_analyzers(analyzer_paths, mode, model_folders, selection_key, output_folder, output_names=output_names)
|
|
1277
|
+
|
|
1278
|
+
# remember the analyzers, so they can be inspected later
|
|
1279
|
+
for result in results:
|
|
1280
|
+
if "error" not in result:
|
|
1281
|
+
applied_analyzers.append(dict(path=result['path'], output_folder=str(output_folder), output_name=result['output_name']))
|
|
1282
|
+
self.project.config["applied_analyzers"] = applied_analyzers
|
|
1283
|
+
self.project.save_config()
|
|
1284
|
+
|
|
1285
|
+
failed = [result for result in results if "error" in result]
|
|
1286
|
+
print(f"\nApplied {self.selection_description()} to {len(results) - len(failed)}/{len(results)} analyzers. Predictions saved in {output_folder}.\n")
|
|
1287
|
+
# metrics can be computed for analyzers on this computer (remote ones can't be written)
|
|
1288
|
+
without_metrics = [result for result in failed
|
|
1289
|
+
if "required metrics" in result['error'] and not is_path_remote(str(result['path']))]
|
|
1290
|
+
compute_metrics = len(without_metrics) > 0 and self.confirm_computing_metrics(without_metrics, model_folders)
|
|
1291
|
+
not_added = [result for result in failed if not (compute_metrics and result in without_metrics)]
|
|
1292
|
+
if len(not_added) > 0 or len(already_listed) > 0:
|
|
1293
|
+
lines = [f"• {Path(str(result['path'])).name}: {result['error']}" for result in not_added]
|
|
1294
|
+
lines += [f"• {Path(str(path)).name}: already in the list" for path in already_listed]
|
|
1295
|
+
QtWidgets.QMessageBox.warning(self, "Some analyzers were not added", "\n\n".join(lines))
|
|
1296
|
+
|
|
1297
|
+
self.make_validate_button_list()
|
|
1298
|
+
self.make_relabel_button_list()
|
|
1299
|
+
|
|
1300
|
+
if compute_metrics:
|
|
1301
|
+
self.run_metrics_process([result['path'] for result in without_metrics], model_folders, after_added)
|
|
1302
|
+
elif after_added is not None:
|
|
1303
|
+
after_added()
|
|
1304
|
+
|
|
1305
|
+
def confirm_computing_metrics(self, without_metrics, model_folders):
|
|
1306
|
+
"""Asks whether to compute the metrics the models use which these analyzers don't have."""
|
|
1307
|
+
lines = []
|
|
1308
|
+
for result in without_metrics:
|
|
1309
|
+
try:
|
|
1310
|
+
analyzer = load_sorting_analyzer(str(result['path']), load_extensions=False)
|
|
1311
|
+
metric_names = [name for names in metrics_to_compute(analyzer, model_folders).values() for name in names]
|
|
1312
|
+
lines.append(f"• {Path(str(result['path'])).name}: {', '.join(metric_names)}")
|
|
1313
|
+
except Exception:
|
|
1314
|
+
lines.append(f"• {Path(str(result['path'])).name}: {result['error']}")
|
|
1315
|
+
which = "These analyzers don't" if len(lines) > 1 else "This analyzer doesn't"
|
|
1316
|
+
return self.confirm(
|
|
1317
|
+
"Compute the missing metrics?",
|
|
1318
|
+
f"{which} have all the metrics the models use:\n" + "\n".join(lines)
|
|
1319
|
+
+ "\n\nCompute them now? They are computed with SpikeInterface's default parameters, as the models on the "
|
|
1320
|
+
"Hugging Face Hub were trained with, and saved in the analyzer; the metrics it has are kept. This needs "
|
|
1321
|
+
"the analyzer's recording, and can take a few minutes per analyzer."
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
def run_metrics_process(self, analyzer_paths, model_folders, after_added=None):
|
|
1325
|
+
"""
|
|
1326
|
+
Computes the missing metrics (python -m unitrefine.metrics) in its own process, with a progress dialog,
|
|
1327
|
+
then applies the models to the analyzers which have them now.
|
|
1328
|
+
"""
|
|
1329
|
+
process = QProcess(self)
|
|
1330
|
+
process.setProcessChannelMode(QProcess.MergedChannels)
|
|
1331
|
+
environment = QProcessEnvironment.systemEnvironment()
|
|
1332
|
+
environment.insert("PYTHONUNBUFFERED", "1")
|
|
1333
|
+
environment.insert("PYTHONIOENCODING", "utf-8")
|
|
1334
|
+
process.setProcessEnvironment(environment)
|
|
1335
|
+
self.metrics_process = process
|
|
1336
|
+
self.metrics_output_buffer = ""
|
|
1337
|
+
self.metrics_result = None
|
|
1338
|
+
self.metrics_cancelled = False
|
|
1339
|
+
|
|
1340
|
+
steps = [("start", "Starting Python and SpikeInterface")]
|
|
1341
|
+
steps += [(f"analyzer_{index}", f"Computing the metrics of {Path(str(path)).name}") for index, path in enumerate(analyzer_paths)]
|
|
1342
|
+
self.metrics_dialog = OpeningDialog(
|
|
1343
|
+
f"Computing the missing metrics of {len(analyzer_paths)} analyzer{'s' if len(analyzer_paths) > 1 else ''}",
|
|
1344
|
+
steps, parent=self, window_title="Computing metrics",
|
|
1345
|
+
note="The metrics are saved in the analyzers, and the models are applied once they are computed. "
|
|
1346
|
+
"The terminal shows the details.",
|
|
1347
|
+
slow_message=("This takes a few minutes for a long recording, most of it extracting the waveforms. "
|
|
1348
|
+
"Nothing has crashed, please don't close anything."),
|
|
1349
|
+
)
|
|
1350
|
+
self.metrics_dialog.cancelled.connect(self.cancel_metrics_process)
|
|
1351
|
+
self.metrics_dialog.show()
|
|
1352
|
+
self.content_widget.setEnabled(False)
|
|
1353
|
+
|
|
1354
|
+
process.readyReadStandardOutput.connect(self.on_metrics_output)
|
|
1355
|
+
process.finished.connect(lambda exit_code, exit_status: self.on_metrics_finished(after_added))
|
|
1356
|
+
arguments = [str(path) for path in analyzer_paths]
|
|
1357
|
+
for model_folder in model_folders:
|
|
1358
|
+
arguments += ["--model", str(model_folder)]
|
|
1359
|
+
print(f"\nComputing the missing metrics of {len(analyzer_paths)} analyzers...")
|
|
1360
|
+
process.start(sys.executable, ["-m", "unitrefine.metrics", *arguments, *self.metrics_options])
|
|
1361
|
+
|
|
1362
|
+
def on_metrics_output(self):
|
|
1363
|
+
output = bytes(self.metrics_process.readAllStandardOutput()).decode("utf-8", errors="replace")
|
|
1364
|
+
self.metrics_output_buffer += output.replace("\r\n", "\n")
|
|
1365
|
+
*lines, self.metrics_output_buffer = self.metrics_output_buffer.split("\n")
|
|
1366
|
+
for line in lines:
|
|
1367
|
+
if line.startswith(METRICS_STEP_MARKER):
|
|
1368
|
+
self.metrics_dialog.set_step(line[len(METRICS_STEP_MARKER):].strip())
|
|
1369
|
+
elif line.startswith(METRICS_RESULT_MARKER):
|
|
1370
|
+
self.metrics_result = json.loads(line[len(METRICS_RESULT_MARKER):])
|
|
1371
|
+
else:
|
|
1372
|
+
sys.stdout.write(line + "\n")
|
|
1373
|
+
sys.stdout.flush()
|
|
1374
|
+
|
|
1375
|
+
def cancel_metrics_process(self):
|
|
1376
|
+
if self.metrics_process is not None:
|
|
1377
|
+
print("Cancelled computing the metrics.")
|
|
1378
|
+
self.metrics_cancelled = True
|
|
1379
|
+
self.metrics_process.kill()
|
|
1380
|
+
|
|
1381
|
+
def on_metrics_finished(self, after_added):
|
|
1382
|
+
self.metrics_dialog.done_opening()
|
|
1383
|
+
self.metrics_process = None
|
|
1384
|
+
self.content_widget.setEnabled(True)
|
|
1385
|
+
if self.metrics_cancelled:
|
|
1386
|
+
return
|
|
1387
|
+
if self.metrics_result is None:
|
|
1388
|
+
QtWidgets.QMessageBox.warning(self, "Could not compute the metrics",
|
|
1389
|
+
"It stopped without a result. The terminal shows the messages.")
|
|
1390
|
+
return
|
|
1391
|
+
|
|
1392
|
+
failed = [result for result in self.metrics_result if "error" in result]
|
|
1393
|
+
if len(failed) > 0:
|
|
1394
|
+
QtWidgets.QMessageBox.warning(self, "Could not compute the metrics", "\n\n".join(
|
|
1395
|
+
f"• {Path(result['path']).name}: {result['error']}" for result in failed))
|
|
1396
|
+
computed = [result['path'] for result in self.metrics_result if "error" not in result]
|
|
1397
|
+
if len(computed) > 0:
|
|
1398
|
+
self.apply_to_new_analyzers(computed, after_added)
|
|
1399
|
+
|
|
1400
|
+
def import_labels_for_new_analyzer(self, applied):
|
|
1401
|
+
"""Labels of a new analyzer, which the models are compared with (they are never trained on them)."""
|
|
1402
|
+
|
|
1403
|
+
label_file = self.ask_label_file()
|
|
1404
|
+
if label_file == "":
|
|
1405
|
+
return
|
|
1406
|
+
self.import_label_file_for_new_analyzer(applied, label_file)
|
|
1407
|
+
|
|
1408
|
+
def import_label_file_for_new_analyzer(self, applied, label_file):
|
|
1409
|
+
"""Imports the labels in `label_file` for a new analyzer (section 3.3). Returns True if it worked."""
|
|
1410
|
+
save_folder = Path(applied['output_folder'])
|
|
1411
|
+
file_prefix = f"{applied['output_name']}__"
|
|
1412
|
+
labels_path = save_folder / f"{file_prefix}labels.csv"
|
|
1413
|
+
existing_labels = pd.read_csv(labels_path) if labels_path.is_file() else None
|
|
1414
|
+
|
|
1415
|
+
print(f"\nImporting labels from {label_file} for analyzer at {applied['path']}...")
|
|
1416
|
+
try:
|
|
1417
|
+
with BusyNotice(self, "Importing labels", f"Reading {Path(label_file).name} and the metrics of the analyzer's units."):
|
|
1418
|
+
import_labels(label_file, applied['path'], save_folder, existing_labels=existing_labels, file_prefix=file_prefix)
|
|
1419
|
+
except Exception as e:
|
|
1420
|
+
print(f"Could not import labels: {e}")
|
|
1421
|
+
QtWidgets.QMessageBox.warning(self, "Could not import labels", str(e))
|
|
1422
|
+
return False
|
|
1423
|
+
(save_folder / f"{file_prefix}imported_labels_source.txt").write_text(str(label_file))
|
|
1424
|
+
|
|
1425
|
+
self.make_validate_button_list()
|
|
1426
|
+
return True
|
|
1427
|
+
|
|
1428
|
+
# ----------------------------------------------------------------------------------------------
|
|
1429
|
+
# Phy output (+ Phy output, in sections 2 and 3.3)
|
|
1430
|
+
# ----------------------------------------------------------------------------------------------
|
|
1431
|
+
|
|
1432
|
+
def add_phy_output(self, for_training):
|
|
1433
|
+
"""
|
|
1434
|
+
Adds a Phy folder: makes its analyzer (see phy_import.py) in a separate process, then adds it to section 2
|
|
1435
|
+
(`for_training`) or applies the chosen models to it (section 3.3), with Phy's labels imported.
|
|
1436
|
+
"""
|
|
1437
|
+
if not for_training:
|
|
1438
|
+
problem = self.model_selector.describe_problem()
|
|
1439
|
+
if problem is not None:
|
|
1440
|
+
self.show_problem("Choose a model first", problem)
|
|
1441
|
+
return
|
|
1442
|
+
if self.phy_process is not None:
|
|
1443
|
+
self.show_problem("Already making an analyzer", "Wait until the Phy folder being added is finished.")
|
|
1444
|
+
return
|
|
1445
|
+
|
|
1446
|
+
phy_folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Select a Phy folder (the folder with params.py)")
|
|
1447
|
+
if phy_folder == "":
|
|
1448
|
+
return
|
|
1449
|
+
phy_folder = Path(phy_folder)
|
|
1450
|
+
try:
|
|
1451
|
+
kind = phy_folder_kind(phy_folder)
|
|
1452
|
+
except ValueError as e:
|
|
1453
|
+
self.show_problem("Not a Phy folder", str(e))
|
|
1454
|
+
return
|
|
1455
|
+
|
|
1456
|
+
output_folder = self.phy_output_folder(phy_folder)
|
|
1457
|
+
if output_folder is None:
|
|
1458
|
+
return
|
|
1459
|
+
labels_path = Path(self.project.folder_name) / "phy_labels" / f"{output_folder.name}_phy_labels.csv"
|
|
1460
|
+
arguments = [phy_folder, "--output", output_folder, "--labels", labels_path]
|
|
1461
|
+
|
|
1462
|
+
if kind == "exported":
|
|
1463
|
+
analyzer_path = self.analyzer_exported_to(phy_folder)
|
|
1464
|
+
if analyzer_path is None:
|
|
1465
|
+
return
|
|
1466
|
+
arguments += ["--analyzer", analyzer_path]
|
|
1467
|
+
steps, what = EXPORTED_STEPS, f"Applying the curation in {phy_folder.name} to {Path(str(analyzer_path)).name}"
|
|
1468
|
+
else:
|
|
1469
|
+
recording_path = recording_file(phy_folder)
|
|
1470
|
+
if recording_path is None:
|
|
1471
|
+
recording_path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
|
1472
|
+
self, "The recording Kilosort sorted was not found: select it (e.g. the .ap.bin file)",
|
|
1473
|
+
str(phy_folder), "Recordings (*.bin *.dat *.cbin *.raw);;All files (*)")
|
|
1474
|
+
if recording_path == "":
|
|
1475
|
+
return
|
|
1476
|
+
arguments += ["--recording", recording_path]
|
|
1477
|
+
if recording_needs_gain(recording_path):
|
|
1478
|
+
gain, accepted = QtWidgets.QInputDialog.getDouble(
|
|
1479
|
+
self, "Gain of the recording",
|
|
1480
|
+
f"{Path(recording_path).name} is a plain binary file, which doesn't say how to convert its values "
|
|
1481
|
+
f"to µV.\nGive its gain in µV per bit ({GAIN_HINT}):", 1.0, 0.0001, 1000.0, 4)
|
|
1482
|
+
if not accepted:
|
|
1483
|
+
return
|
|
1484
|
+
arguments += ["--gain_to_uv", gain]
|
|
1485
|
+
steps, what = KILOSORT_STEPS, f"Making an analyzer from {phy_folder.name}"
|
|
1486
|
+
|
|
1487
|
+
self.run_phy_import(arguments, steps, what, output_folder, for_training)
|
|
1488
|
+
|
|
1489
|
+
def phy_output_folder(self, phy_folder):
|
|
1490
|
+
"""
|
|
1491
|
+
Where the analyzer made from a Phy folder is saved: next to the Phy folder, or elsewhere if that can't be
|
|
1492
|
+
written. If an analyzer made from this Phy folder exists, asks whether to use it or make it again.
|
|
1493
|
+
Returns None if cancelled.
|
|
1494
|
+
"""
|
|
1495
|
+
output_folder = default_output_folder(phy_folder)
|
|
1496
|
+
made_from = made_from_phy(output_folder)
|
|
1497
|
+
if made_from is not None and normalise_path(made_from["phy_folder"]) == normalise_path(phy_folder):
|
|
1498
|
+
box = QtWidgets.QMessageBox(self)
|
|
1499
|
+
box.setWindowTitle("Analyzer already made")
|
|
1500
|
+
box.setText(f"An analyzer was made from {phy_folder.name} on {made_from['date'].replace('T', ' ')}:\n{output_folder}\n\n"
|
|
1501
|
+
"Make it again if you curated more in Phy since then.")
|
|
1502
|
+
use_button = box.addButton("Use it", QtWidgets.QMessageBox.AcceptRole)
|
|
1503
|
+
box.addButton("Make it again", QtWidgets.QMessageBox.DestructiveRole)
|
|
1504
|
+
cancel_button = box.addButton(QtWidgets.QMessageBox.Cancel)
|
|
1505
|
+
box.exec()
|
|
1506
|
+
if box.clickedButton() is cancel_button:
|
|
1507
|
+
return None
|
|
1508
|
+
if box.clickedButton() is use_button:
|
|
1509
|
+
self.phy_reuse = True
|
|
1510
|
+
return output_folder
|
|
1511
|
+
number = 2
|
|
1512
|
+
while output_folder.exists():
|
|
1513
|
+
# e.g. a folder of the same name, not made from this Phy folder
|
|
1514
|
+
output_folder = default_output_folder(phy_folder).with_name(f"{default_output_folder(phy_folder).name}_{number}")
|
|
1515
|
+
number += 1
|
|
1516
|
+
if not os.access(output_folder.parent, os.W_OK):
|
|
1517
|
+
chosen = QtWidgets.QFileDialog.getExistingDirectory(
|
|
1518
|
+
self, f"{output_folder.parent} can't be written: choose where to save the analyzer")
|
|
1519
|
+
if chosen == "":
|
|
1520
|
+
return None
|
|
1521
|
+
output_folder = Path(chosen) / output_folder.name
|
|
1522
|
+
return output_folder
|
|
1523
|
+
|
|
1524
|
+
def analyzer_exported_to(self, phy_folder):
|
|
1525
|
+
"""The analyzer a Phy folder was exported from: found among the analyzers in the project, or asked for."""
|
|
1526
|
+
candidates = [analyzer['path'] for analyzer in self.project.analyzers.values()]
|
|
1527
|
+
candidates += [applied['path'] for applied in self.project.config.get("applied_analyzers", [])]
|
|
1528
|
+
with BusyNotice(self, "Finding the analyzer", f"Looking for the analyzer {phy_folder.name} was exported from."):
|
|
1529
|
+
for path in candidates:
|
|
1530
|
+
if is_path_remote(str(path)):
|
|
1531
|
+
continue
|
|
1532
|
+
try:
|
|
1533
|
+
sorting = load_sorting_analyzer(str(path), load_extensions=False).sorting
|
|
1534
|
+
except Exception:
|
|
1535
|
+
continue
|
|
1536
|
+
if matches_phy_folder(sorting, phy_folder):
|
|
1537
|
+
print(f"{phy_folder.name} was exported from {path}.")
|
|
1538
|
+
return path
|
|
1539
|
+
folder = QtWidgets.QFileDialog.getExistingDirectory(
|
|
1540
|
+
self, f"{phy_folder.name} was exported from a SpikeInterface analyzer: select that analyzer")
|
|
1541
|
+
if folder == "":
|
|
1542
|
+
return None
|
|
1543
|
+
if not check_folder_is_analyzer(folder):
|
|
1544
|
+
self.show_problem("Not a Sorting Analyzer", f"{folder} is not a Sorting Analyzer.")
|
|
1545
|
+
return None
|
|
1546
|
+
return folder
|
|
1547
|
+
|
|
1548
|
+
def run_phy_import(self, arguments, steps, what, output_folder, for_training):
|
|
1549
|
+
"""Runs phy_import in its own process, with a progress dialog, then adds the analyzer (see on_phy_result)."""
|
|
1550
|
+
if self.phy_reuse:
|
|
1551
|
+
# an analyzer made earlier from this Phy folder: only the labels are read again (fast)
|
|
1552
|
+
self.phy_reuse = False
|
|
1553
|
+
arguments = arguments + ["--reuse"]
|
|
1554
|
+
process = QProcess(self)
|
|
1555
|
+
process.setProcessChannelMode(QProcess.MergedChannels)
|
|
1556
|
+
environment = QProcessEnvironment.systemEnvironment()
|
|
1557
|
+
environment.insert("PYTHONUNBUFFERED", "1")
|
|
1558
|
+
environment.insert("PYTHONIOENCODING", "utf-8")
|
|
1559
|
+
process.setProcessEnvironment(environment)
|
|
1560
|
+
self.phy_process = process
|
|
1561
|
+
self.phy_output_buffer = ""
|
|
1562
|
+
self.phy_result = None
|
|
1563
|
+
self.phy_cancelled = False
|
|
1564
|
+
|
|
1565
|
+
timing_key = normalise_path(arguments[0])
|
|
1566
|
+
previous_seconds = self.project.config.get("phy_import_seconds", {}).get(timing_key)
|
|
1567
|
+
self.phy_dialog = OpeningDialog(
|
|
1568
|
+
what, steps, previous_seconds, parent=self, window_title="Adding Phy output",
|
|
1569
|
+
note=(f"Your Phy folder is not changed. The analyzer is saved in<br><i>{output_folder}</i><br>"
|
|
1570
|
+
"The terminal shows the details."),
|
|
1571
|
+
slow_message=("This takes a few minutes for a long recording, most of it extracting the waveforms. "
|
|
1572
|
+
"Nothing has crashed, please don't close anything."),
|
|
1573
|
+
)
|
|
1574
|
+
self.phy_dialog.cancelled.connect(self.cancel_phy_import)
|
|
1575
|
+
self.phy_dialog.show()
|
|
1576
|
+
self.content_widget.setEnabled(False)
|
|
1577
|
+
|
|
1578
|
+
def finished(exit_code, exit_status):
|
|
1579
|
+
self.on_phy_finished(arguments, steps, what, output_folder, for_training, timing_key)
|
|
1580
|
+
|
|
1581
|
+
process.readyReadStandardOutput.connect(self.on_phy_output)
|
|
1582
|
+
process.finished.connect(finished)
|
|
1583
|
+
print(f"\n{what}...")
|
|
1584
|
+
process.start(sys.executable, ["-m", "unitrefine.phy_import", *[str(argument) for argument in arguments + self.phy_import_options]])
|
|
1585
|
+
|
|
1586
|
+
def on_phy_output(self):
|
|
1587
|
+
output = bytes(self.phy_process.readAllStandardOutput()).decode("utf-8", errors="replace")
|
|
1588
|
+
self.phy_output_buffer += output.replace("\r\n", "\n")
|
|
1589
|
+
*lines, self.phy_output_buffer = self.phy_output_buffer.split("\n")
|
|
1590
|
+
for line in lines:
|
|
1591
|
+
if line.startswith(PHY_STEP_MARKER):
|
|
1592
|
+
self.phy_dialog.set_step(line[len(PHY_STEP_MARKER):].strip())
|
|
1593
|
+
elif line.startswith(PHY_RESULT_MARKER):
|
|
1594
|
+
self.phy_result = json.loads(line[len(PHY_RESULT_MARKER):])
|
|
1595
|
+
else:
|
|
1596
|
+
sys.stdout.write(line + "\n")
|
|
1597
|
+
sys.stdout.flush()
|
|
1598
|
+
|
|
1599
|
+
def cancel_phy_import(self):
|
|
1600
|
+
if self.phy_process is not None:
|
|
1601
|
+
print("Cancelled adding the Phy folder.")
|
|
1602
|
+
self.phy_cancelled = True
|
|
1603
|
+
self.phy_process.kill()
|
|
1604
|
+
|
|
1605
|
+
def on_phy_finished(self, arguments, steps, what, output_folder, for_training, timing_key):
|
|
1606
|
+
self.phy_dialog.done_opening()
|
|
1607
|
+
elapsed = self.phy_dialog.elapsed_seconds()
|
|
1608
|
+
self.phy_process = None
|
|
1609
|
+
self.content_widget.setEnabled(True)
|
|
1610
|
+
result = self.phy_result or dict(status="error", message="It stopped without a result. The terminal shows the messages.")
|
|
1611
|
+
if self.phy_cancelled:
|
|
1612
|
+
return
|
|
1613
|
+
|
|
1614
|
+
if result["status"] == "hard_merge_required":
|
|
1615
|
+
if self.confirm("Merges need the raw recording", f"{result['message']}\n\nDo these merges? It can take a long time."):
|
|
1616
|
+
self.run_phy_import(arguments + ["--allow_hard_merge"], steps, what, output_folder, for_training)
|
|
1617
|
+
return
|
|
1618
|
+
if result["status"] != "done":
|
|
1619
|
+
QtWidgets.QMessageBox.warning(self, "Could not add the Phy folder", result["message"])
|
|
1620
|
+
return
|
|
1621
|
+
|
|
1622
|
+
self.project.config.setdefault("phy_import_seconds", {})[timing_key] = round(elapsed, 1)
|
|
1623
|
+
self.project.save_config()
|
|
1624
|
+
self.add_phy_analyzer(result, for_training)
|
|
1625
|
+
|
|
1626
|
+
def add_phy_analyzer(self, result, for_training):
|
|
1627
|
+
"""Adds the analyzer made from a Phy folder to section 2 or 3.3, and imports Phy's labels for it."""
|
|
1628
|
+
analyzer_path = result["analyzer"]
|
|
1629
|
+
has_labels = result["num_labels"] > 0
|
|
1630
|
+
if for_training:
|
|
1631
|
+
existing = [index for index, analyzer in self.project.analyzers.items()
|
|
1632
|
+
if normalise_path(analyzer['path']) == normalise_path(analyzer_path)]
|
|
1633
|
+
if len(existing) > 0:
|
|
1634
|
+
analyzer_index = existing[0]
|
|
1635
|
+
else:
|
|
1636
|
+
self.project.add_analyzer(analyzer_path)
|
|
1637
|
+
self.project.save()
|
|
1638
|
+
analyzer_index = max(self.project.analyzers)
|
|
1639
|
+
if has_labels:
|
|
1640
|
+
self.import_label_file(analyzer_index, result["labels"])
|
|
1641
|
+
self.make_curation_button_list()
|
|
1642
|
+
self.make_model_list()
|
|
1643
|
+
self.make_relabel_button_list()
|
|
1644
|
+
where = "section 2"
|
|
1645
|
+
else:
|
|
1646
|
+
def listed():
|
|
1647
|
+
return [applied for applied in self.project.config.get("applied_analyzers", [])
|
|
1648
|
+
if normalise_path(applied['path']) == normalise_path(analyzer_path)]
|
|
1649
|
+
|
|
1650
|
+
def after_added():
|
|
1651
|
+
if len(listed()) == 0:
|
|
1652
|
+
return
|
|
1653
|
+
if has_labels:
|
|
1654
|
+
self.import_label_file_for_new_analyzer(listed()[0], result["labels"])
|
|
1655
|
+
self.report_phy_analyzer_added(result, "section 3.3")
|
|
1656
|
+
|
|
1657
|
+
if len(listed()) == 0:
|
|
1658
|
+
# the models may need metrics the analyzer doesn't have: they are computed first (if the user agrees)
|
|
1659
|
+
self.apply_to_new_analyzers([analyzer_path], after_added)
|
|
1660
|
+
else:
|
|
1661
|
+
after_added()
|
|
1662
|
+
return
|
|
1663
|
+
self.report_phy_analyzer_added(result, where)
|
|
1664
|
+
|
|
1665
|
+
def report_phy_analyzer_added(self, result, where):
|
|
1666
|
+
analyzer_path = result["analyzer"]
|
|
1667
|
+
has_labels = result["num_labels"] > 0
|
|
1668
|
+
labels_text = (f"{result['num_labels']} units have a label from Phy, which were imported."
|
|
1669
|
+
if has_labels else "No units were labelled in Phy.")
|
|
1670
|
+
print(f"Added {analyzer_path} to {where}. {labels_text}")
|
|
1671
|
+
QtWidgets.QMessageBox.information(self, "Phy folder added", f"The analyzer is added to {where}:\n{analyzer_path}\n\n{labels_text}")
|
|
1672
|
+
|
|
1673
|
+
def toggle_training_analyzers(self):
|
|
1674
|
+
self.show_training_analyzers = not self.show_training_analyzers
|
|
1675
|
+
self.make_validate_button_list()
|
|
1676
|
+
|
|
1677
|
+
def remove_applied_analyzer(self, applied_index):
|
|
1678
|
+
applied_analyzers = self.project.config.get("applied_analyzers", [])
|
|
1679
|
+
self.project.config["applied_analyzers"] = [applied for index, applied in enumerate(applied_analyzers) if index != applied_index]
|
|
1680
|
+
self.project.save_config()
|
|
1681
|
+
self.make_validate_button_list()
|
|
1682
|
+
|
|
1683
|
+
def show_applied_analyzer_window(self, applied):
|
|
1684
|
+
analyzer = {'path': applied['path']}
|
|
1685
|
+
self.show_validate_window(analyzer, None, False, save_folder=applied['output_folder'], file_prefix=f"{applied['output_name']}__")
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def retrain_model(self):
|
|
1689
|
+
"""Retrains every selected model (both layers for a cascade) with the units relabelled for this selection."""
|
|
1690
|
+
|
|
1691
|
+
problem = self.model_selector.describe_problem()
|
|
1692
|
+
if problem is not None:
|
|
1693
|
+
print(problem)
|
|
1694
|
+
return
|
|
1695
|
+
|
|
1696
|
+
names_to_retrain = self.models_to_retrain()
|
|
1697
|
+
selected_models = [model for model in self.model_selector.selected_models() if model[0].name in names_to_retrain]
|
|
1698
|
+
selection_key = self.model_selector.selection_key()
|
|
1699
|
+
|
|
1700
|
+
if len(selected_models) == 1:
|
|
1701
|
+
retrained_names = [self.retrainedModelNameForm.text().strip()]
|
|
1702
|
+
else:
|
|
1703
|
+
retrained_names = self.retrained_names()
|
|
1704
|
+
|
|
1705
|
+
results = []
|
|
1706
|
+
with BusyNotice(self, "Retraining", "") as notice:
|
|
1707
|
+
for (model_folder, _), retrained_name in zip(selected_models, retrained_names):
|
|
1708
|
+
notice.update(f"Retraining {model_folder.name} as {retrained_name}, with the units you relabelled.")
|
|
1709
|
+
print(f"\nRetraining {model_folder.name} as {retrained_name}...")
|
|
1710
|
+
results.append((model_folder.name, self.retrain_one_model(model_folder, retrained_name, selection_key)))
|
|
1711
|
+
|
|
1712
|
+
self.make_model_list()
|
|
1713
|
+
self.show_retrain_summary(results)
|
|
1714
|
+
|
|
1715
|
+
def show_retrain_summary(self, results):
|
|
1716
|
+
"""Tells the user what retraining did, and what they can do next."""
|
|
1717
|
+
|
|
1718
|
+
lines = []
|
|
1719
|
+
for model_name, result in results:
|
|
1720
|
+
if not result.get("retrained", True):
|
|
1721
|
+
reasons = "".join(f"<br>• {reason}" for reason in result["reasons"])
|
|
1722
|
+
lines.append(f"<li><b>{model_name}</b>: not retrained, none of your relabelled units could be added:"
|
|
1723
|
+
f"<span style='color:{theme.DANGER}'>{reasons}</span></li>")
|
|
1724
|
+
else:
|
|
1725
|
+
recordings = ", ".join(f"{Path(str(path)).parent.name}/{Path(str(path)).name} ({len(unit_ids)})"
|
|
1726
|
+
for path, unit_ids in result['recordings'].items())
|
|
1727
|
+
change = result['held_out_balanced_accuracy'] - result['held_out_before']
|
|
1728
|
+
lines.append(
|
|
1729
|
+
f"<li><b>{model_name}</b> → <b>{result['name']}</b>: learned from {result['num_units_added']} "
|
|
1730
|
+
f"relabelled units ({recordings}). Held-out balanced accuracy {result['held_out_before']:.2f} → "
|
|
1731
|
+
f"{result['held_out_balanced_accuracy']:.2f} ({change:+.2f}) on {result['num_held_out']} units.</li>"
|
|
1732
|
+
)
|
|
1733
|
+
retrained_any = any(result.get("retrained", True) for _, result in results)
|
|
1734
|
+
|
|
1735
|
+
if retrained_any:
|
|
1736
|
+
next_steps = (
|
|
1737
|
+
"<p>The retrained model(s) are now selected in <b>3.2 Choose your models</b>. What you can do next:</p><ul>"
|
|
1738
|
+
"<li><b>See what changed</b>: the <i>Retraining history</i> in section 4 lists every round, and "
|
|
1739
|
+
"<b>Details</b> every unit added with its old and new label.</li>"
|
|
1740
|
+
"<li><b>Check whether it improved</b>: look at the <i>Accuracy over rounds</i> figure in section 4 "
|
|
1741
|
+
"(it is updated in the background, in a few seconds).</li>"
|
|
1742
|
+
"<li><b>Another round of active learning</b>: in section 4, click <b>Find least confident units</b>, "
|
|
1743
|
+
"relabel them, and retrain again.</li>"
|
|
1744
|
+
"<li><b>Done?</b> If the accuracy no longer improves, or the least confident units look right to you, "
|
|
1745
|
+
"the model is ready: use <b>+ Apply to new analyzers</b> (section 3.3) to label your recordings.</li></ul>"
|
|
1746
|
+
)
|
|
1747
|
+
elif all(result.get("no_relabels") for _, result in results):
|
|
1748
|
+
next_steps = (
|
|
1749
|
+
"<p>Relabel some units first: in section 4, click <b>Find least confident units</b>, then "
|
|
1750
|
+
"<b>Relabel</b>, and give units a label different from the model's or a new label.</p>"
|
|
1751
|
+
)
|
|
1752
|
+
else:
|
|
1753
|
+
next_steps = "<p>The terminal shows more details.</p>"
|
|
1754
|
+
|
|
1755
|
+
message_box = QtWidgets.QMessageBox(self)
|
|
1756
|
+
message_box.setWindowTitle("Retraining finished" if retrained_any else "Nothing to retrain")
|
|
1757
|
+
message_box.setTextFormat(Qt.TextFormat.RichText)
|
|
1758
|
+
message_box.setText(f"<ul>{''.join(lines)}</ul>{next_steps}")
|
|
1759
|
+
message_box.exec()
|
|
1760
|
+
|
|
1761
|
+
def retrain_one_model(self, model_folder, retrained_model_name, selection_key):
|
|
1762
|
+
from sklearn.model_selection import train_test_split
|
|
1763
|
+
from sklearn.metrics import balanced_accuracy_score
|
|
1764
|
+
|
|
1765
|
+
model_folder = Path(model_folder)
|
|
1766
|
+
retrained_model_folder = model_folder.parent / retrained_model_name
|
|
1767
|
+
|
|
1768
|
+
model, model_info = load_model(
|
|
1769
|
+
model_folder=model_folder, trust_model=True
|
|
1770
|
+
)
|
|
1771
|
+
label_conversion = model_info['label_conversion']
|
|
1772
|
+
invert_label_conversion = {val: int(key) for key, val in label_conversion.items()}
|
|
1773
|
+
model_metric_names = model.feature_names_in_
|
|
1774
|
+
model_classes = list(invert_label_conversion.keys())
|
|
1775
|
+
|
|
1776
|
+
def _map_quality(q: str):
|
|
1777
|
+
# map GUI labels to model labels to handle cascade models, e.g. trained with "not-sua" or "neural"
|
|
1778
|
+
return map_quality_to_model_class(q, model_classes)
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
# keep the imputer as fitted: changing keep_empty_features after fitting adds back metrics that
|
|
1782
|
+
# were empty during training, so the imputer output no longer matches what the scaler expects
|
|
1783
|
+
model_imputer = model['imputer']
|
|
1784
|
+
|
|
1785
|
+
model_scaler = model['scaler']
|
|
1786
|
+
|
|
1787
|
+
X_training_raw = pd.read_csv(model_folder / 'training_data.csv')
|
|
1788
|
+
X_training = X_training_raw.drop('unit_id', axis=1).values
|
|
1789
|
+
Y_training_raw = pd.read_csv(model_folder / 'labels.csv')
|
|
1790
|
+
Y_training = Y_training_raw.drop('unit_index', axis=1).values
|
|
1791
|
+
label_column = Y_training_raw.drop('unit_index', axis=1).columns[0]
|
|
1792
|
+
|
|
1793
|
+
y_training = Y_training.ravel()
|
|
1794
|
+
|
|
1795
|
+
# which analyzer and unit each training row comes from, so a relabelled unit the model was trained on gets
|
|
1796
|
+
# its label changed in its own row, instead of a second row with the other label. Not known for models
|
|
1797
|
+
# trained before this was saved, or downloaded ones: their relabelled units are added as new rows
|
|
1798
|
+
training_rows = read_training_units(model_folder, len(y_training))
|
|
1799
|
+
if training_rows is None:
|
|
1800
|
+
print(f"{model_folder.name} has no {TRAINING_UNITS_FILE}, so relabelled units it was trained on are added as new rows.")
|
|
1801
|
+
row_of_unit = None
|
|
1802
|
+
else:
|
|
1803
|
+
row_of_unit = {(analyzer, unit_id): row for row, (analyzer, unit_id) in
|
|
1804
|
+
enumerate(zip(training_rows['analyzer'], training_rows['unit_id']))}
|
|
1805
|
+
|
|
1806
|
+
# Split indices so we can keep unit_id aligned too
|
|
1807
|
+
idx = np.arange(len(y_training))
|
|
1808
|
+
train_idx, test_idx = train_test_split(
|
|
1809
|
+
idx,
|
|
1810
|
+
test_size=0.2,
|
|
1811
|
+
random_state=0, # keep stable across retrains
|
|
1812
|
+
stratify=y_training if len(np.unique(y_training)) > 1 else None
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
# --- preprocess consistently using the already-fitted preprocessing,
|
|
1816
|
+
# as 32-bit floats, like SpikeInterface when it trains and applies models
|
|
1817
|
+
X_training_proc = model_scaler.transform(
|
|
1818
|
+
model_imputer.transform(as_model_input(pd.DataFrame(X_training, columns=model_metric_names)))
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
X_new = []
|
|
1822
|
+
X_just_values = []
|
|
1823
|
+
y_new = []
|
|
1824
|
+
# the analyzer and unit of each new row
|
|
1825
|
+
new_rows_units = []
|
|
1826
|
+
units_added = {}
|
|
1827
|
+
# new labels for rows of the training data, as {row: label code}
|
|
1828
|
+
changed_rows = {}
|
|
1829
|
+
# what the retraining log records about each added unit, and the relabelled units which were not added
|
|
1830
|
+
added_unit_details = []
|
|
1831
|
+
num_same_as_original = 0
|
|
1832
|
+
num_not_in_classes = 0
|
|
1833
|
+
|
|
1834
|
+
# units already learned in an earlier round are not added again
|
|
1835
|
+
parent_trained_on, _ = read_trained_on(model_folder)
|
|
1836
|
+
num_already_learned = 0
|
|
1837
|
+
num_relabelled = 0
|
|
1838
|
+
analyzers_missing_metrics = {}
|
|
1839
|
+
|
|
1840
|
+
# relabelled units from the training analyzers and from the new analyzers the models were applied to.
|
|
1841
|
+
# Your label is your label whichever model picked the unit, so the relabels made with any model selection
|
|
1842
|
+
# are used (when a unit was relabelled several times, the most recent label)
|
|
1843
|
+
for target in self.relabel_targets():
|
|
1844
|
+
|
|
1845
|
+
save_path = Path(target['save_folder'])
|
|
1846
|
+
file_prefix = target['file_prefix']
|
|
1847
|
+
|
|
1848
|
+
latest_relabels = {}
|
|
1849
|
+
relabel_files = sorted(save_path.glob(f"{file_prefix}relabelled_units_*.csv"), key=lambda path: path.stat().st_mtime)
|
|
1850
|
+
for relabel_file in relabel_files:
|
|
1851
|
+
picked_with = relabel_file.stem[len(f"{file_prefix}relabelled_units_"):]
|
|
1852
|
+
table = pd.read_csv(relabel_file)
|
|
1853
|
+
for quality, unit_id in zip(table['quality'], table['unit_id'].astype(str)):
|
|
1854
|
+
if isinstance(quality, str):
|
|
1855
|
+
latest_relabels[unit_id] = (quality, picked_with)
|
|
1856
|
+
if len(latest_relabels) == 0:
|
|
1857
|
+
continue
|
|
1858
|
+
num_relabelled += len(latest_relabels)
|
|
1859
|
+
relabelled_units = pd.DataFrame(
|
|
1860
|
+
[(quality, unit_id, picked_with) for unit_id, (quality, picked_with) in latest_relabels.items()],
|
|
1861
|
+
columns=['quality', 'unit_id', 'picked_with'],
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
all_metrics_path = save_path / f"{file_prefix}all_metrics.csv"
|
|
1865
|
+
all_metrics = pd.read_csv(all_metrics_path) if all_metrics_path.is_file() else None
|
|
1866
|
+
relabelled_unit_ids = set(relabelled_units['unit_id'].astype(str))
|
|
1867
|
+
# recompute the metrics if there is no file yet, or if it misses relabelled units (older versions
|
|
1868
|
+
# of the Relabel window saved the metrics of the units shown only)
|
|
1869
|
+
if all_metrics is None or not relabelled_unit_ids.issubset(set(all_metrics['unit_id'].astype(str))):
|
|
1870
|
+
analyzer = load_sorting_analyzer(target['path'], load_extensions=False)
|
|
1871
|
+
all_metrics = compute_all_metrics(analyzer)
|
|
1872
|
+
all_metrics.to_csv(all_metrics_path, index_label="unit_id")
|
|
1873
|
+
all_metrics = pd.read_csv(all_metrics_path)
|
|
1874
|
+
# the original labels: curated or imported (also for new analyzers); without them every relabelled unit is new
|
|
1875
|
+
labels_path = save_path / f"{file_prefix}labels.csv"
|
|
1876
|
+
if labels_path.is_file():
|
|
1877
|
+
original_labels = pd.read_csv(labels_path)
|
|
1878
|
+
else:
|
|
1879
|
+
original_labels = pd.DataFrame(columns=['quality', 'unit_id'])
|
|
1880
|
+
|
|
1881
|
+
# with the metric names of the SpikeInterface version the model was trained with
|
|
1882
|
+
all_metrics = metrics_for_model(all_metrics, model_info)
|
|
1883
|
+
missing_metrics = [metric for metric in model_metric_names if metric not in all_metrics.columns]
|
|
1884
|
+
if len(missing_metrics) > 0:
|
|
1885
|
+
print(f"Skipping relabelled units of {target['path']}: it is missing metrics used by {model_folder.name}: {missing_metrics}")
|
|
1886
|
+
analyzers_missing_metrics[target['path']] = missing_metrics
|
|
1887
|
+
continue
|
|
1888
|
+
all_metrics = all_metrics[np.concat([model_metric_names, ['unit_id']])]
|
|
1889
|
+
# unit ids are matched as strings, since csv files are read back with pandas' inferred dtype
|
|
1890
|
+
all_metrics_unit_ids = all_metrics['unit_id'].astype(str).values
|
|
1891
|
+
original_qualities = dict(zip(original_labels['unit_id'].astype(str), original_labels['quality']))
|
|
1892
|
+
|
|
1893
|
+
def model_labels_from(key):
|
|
1894
|
+
"""This model's labels in the predictions saved for a model selection, as {unit id: label}."""
|
|
1895
|
+
predictions_path = save_path / predictions_file_name(file_prefix, key)
|
|
1896
|
+
if not predictions_path.is_file():
|
|
1897
|
+
return {}
|
|
1898
|
+
predictions = pd.read_csv(predictions_path, index_col="unit_id")
|
|
1899
|
+
label_columns = [column for column in predictions.columns if column.endswith(f"{model_folder.name}_model_label")]
|
|
1900
|
+
if len(label_columns) == 0:
|
|
1901
|
+
return {}
|
|
1902
|
+
return dict(zip(predictions.index.astype(str), predictions[label_columns[0]].fillna("").astype(str)))
|
|
1903
|
+
|
|
1904
|
+
# what this model said about each unit: from the predictions of the selection the unit was picked
|
|
1905
|
+
# with, or else from this model's own saved predictions
|
|
1906
|
+
own_model_labels = model_labels_from(model_folder.name)
|
|
1907
|
+
picked_with_labels = {key: model_labels_from(key) for key in relabelled_units['picked_with'].unique()}
|
|
1908
|
+
already_learned = parent_trained_on.get(normalise_path(target['path']), set())
|
|
1909
|
+
analyzer_key = normalise_path(target['path'])
|
|
1910
|
+
|
|
1911
|
+
new_units = []
|
|
1912
|
+
for _, relabelled_unit in relabelled_units.iterrows():
|
|
1913
|
+
new_class = _map_quality(relabelled_unit['quality'])
|
|
1914
|
+
if new_class is None:
|
|
1915
|
+
num_not_in_classes += 1
|
|
1916
|
+
continue
|
|
1917
|
+
unit_id = str(relabelled_unit['unit_id'])
|
|
1918
|
+
label_code = invert_label_conversion[new_class]
|
|
1919
|
+
row = row_of_unit.get((analyzer_key, unit_id)) if row_of_unit is not None else None
|
|
1920
|
+
if row is not None:
|
|
1921
|
+
# the model was trained on this unit: compare with the label it learned, in the model's
|
|
1922
|
+
# classes (e.g. MUA -> good is no change for a noise vs neural model)
|
|
1923
|
+
if y_training[row] == label_code:
|
|
1924
|
+
if training_rows['label_from'][row] == "":
|
|
1925
|
+
num_same_as_original += 1
|
|
1926
|
+
else:
|
|
1927
|
+
# its label was set in an earlier retraining round
|
|
1928
|
+
num_already_learned += 1
|
|
1929
|
+
continue
|
|
1930
|
+
changed_rows[row] = label_code
|
|
1931
|
+
change = "label corrected"
|
|
1932
|
+
elif row_of_unit is not None:
|
|
1933
|
+
# a unit the model was not trained on (e.g. from a new analyzer): a new row
|
|
1934
|
+
new_units.append((label_code, unit_id))
|
|
1935
|
+
change = "new unit"
|
|
1936
|
+
else:
|
|
1937
|
+
# without training_units.csv, only the units and their original labels are known
|
|
1938
|
+
if unit_id in already_learned and unit_id not in original_qualities:
|
|
1939
|
+
# added in an earlier retraining round
|
|
1940
|
+
num_already_learned += 1
|
|
1941
|
+
continue
|
|
1942
|
+
if unit_id in original_qualities and new_class == _map_quality(original_qualities[unit_id]):
|
|
1943
|
+
num_same_as_original += 1
|
|
1944
|
+
continue
|
|
1945
|
+
new_units.append((label_code, unit_id))
|
|
1946
|
+
change = "new unit"
|
|
1947
|
+
added_unit_details.append(dict(
|
|
1948
|
+
analyzer=str(target['path']),
|
|
1949
|
+
group="training" if target['analyzer_index'] is not None else "new",
|
|
1950
|
+
unit_id=unit_id,
|
|
1951
|
+
original_label=original_qualities.get(unit_id, ""),
|
|
1952
|
+
model_label=picked_with_labels[relabelled_unit['picked_with']].get(unit_id, own_model_labels.get(unit_id, "")),
|
|
1953
|
+
your_label=relabelled_unit['quality'],
|
|
1954
|
+
taught_as=new_class,
|
|
1955
|
+
change=change,
|
|
1956
|
+
))
|
|
1957
|
+
units_added.setdefault(target['path'], []).append(unit_id)
|
|
1958
|
+
|
|
1959
|
+
for label_code, unit_id in new_units:
|
|
1960
|
+
unit_metrics = all_metrics[all_metrics_unit_ids == unit_id]
|
|
1961
|
+
X_new.append(unit_metrics.values[0])
|
|
1962
|
+
X_just_values.append(unit_metrics.drop('unit_id', axis=1).values[0])
|
|
1963
|
+
y_new.append(label_code)
|
|
1964
|
+
new_rows_units.append((analyzer_key, unit_id))
|
|
1965
|
+
|
|
1966
|
+
if len(y_new) == 0 and len(changed_rows) == 0:
|
|
1967
|
+
# say why, so the user knows what to do
|
|
1968
|
+
reasons = []
|
|
1969
|
+
if num_relabelled == 0:
|
|
1970
|
+
reasons.append("you have not relabelled any units yet")
|
|
1971
|
+
for path, missing_metrics in analyzers_missing_metrics.items():
|
|
1972
|
+
reasons.append(f"{Path(str(path)).parent.name}/{Path(str(path)).name} is missing metrics the model uses: "
|
|
1973
|
+
f"{', '.join(missing_metrics)}")
|
|
1974
|
+
if num_not_in_classes > 0:
|
|
1975
|
+
reasons.append(f"{num_not_in_classes} units have a label this model doesn't predict "
|
|
1976
|
+
f"(its classes are {', '.join(model_classes)})")
|
|
1977
|
+
if num_same_as_original > 0:
|
|
1978
|
+
reasons.append(f"{num_same_as_original} units got the same label as before, in this model's classes")
|
|
1979
|
+
if num_already_learned > 0:
|
|
1980
|
+
reasons.append(f"{num_already_learned} units were already added in an earlier retraining round")
|
|
1981
|
+
print(f"No relabelled units change the training data of {model_folder.name}, so it was not retrained: {reasons}")
|
|
1982
|
+
return dict(retrained=False, reasons=reasons, no_relabels=num_relabelled == 0)
|
|
1983
|
+
|
|
1984
|
+
# the training data with your labels: corrected labels in their rows, then the new units
|
|
1985
|
+
y_corrected = y_training.copy()
|
|
1986
|
+
for row, label_code in changed_rows.items():
|
|
1987
|
+
y_corrected[row] = label_code
|
|
1988
|
+
# relabelled units in the held-out split are learned too, so they are not held out; both classifiers are
|
|
1989
|
+
# scored on the same held-out units, whose labels did not change
|
|
1990
|
+
test_idx = np.array([row for row in test_idx if row not in changed_rows], dtype=int)
|
|
1991
|
+
moved_idx = np.array(sorted(row for row in changed_rows if row not in set(train_idx)), dtype=int)
|
|
1992
|
+
X_test_proc, y_test = X_training_proc[test_idx], y_training[test_idx]
|
|
1993
|
+
|
|
1994
|
+
def held_out_score(classifier):
|
|
1995
|
+
# no held-out units are left if you relabelled all of them
|
|
1996
|
+
return balanced_accuracy_score(y_test, classifier.predict(X_test_proc)) if len(y_test) > 0 else float("nan")
|
|
1997
|
+
|
|
1998
|
+
# the classifier trained on the training split only, with the labels before this round: its score on the
|
|
1999
|
+
# held-out units is the baseline (the parent model itself may have been trained on the held-out units,
|
|
2000
|
+
# so it would not be a fair baseline)
|
|
2001
|
+
classifier_before = clone(model['classifier']).fit(X_training_proc[train_idx], y_training[train_idx])
|
|
2002
|
+
held_out_before = held_out_score(classifier_before)
|
|
2003
|
+
|
|
2004
|
+
# then trained again with your labels
|
|
2005
|
+
X_new_proc = model_scaler.transform(
|
|
2006
|
+
model_imputer.transform(as_model_input(pd.DataFrame(X_just_values, columns=model_metric_names)))
|
|
2007
|
+
) if len(y_new) > 0 else np.empty((0, X_training_proc.shape[1]))
|
|
2008
|
+
learned_idx = np.concatenate([train_idx, moved_idx])
|
|
2009
|
+
retrained_classifier = clone(model['classifier']).fit(
|
|
2010
|
+
np.concatenate([X_training_proc[learned_idx], X_new_proc]),
|
|
2011
|
+
np.concatenate([y_corrected[learned_idx], np.asarray(y_new, dtype=y_corrected.dtype)]),
|
|
2012
|
+
)
|
|
2013
|
+
|
|
2014
|
+
all_training_data = pd.concat([
|
|
2015
|
+
pd.DataFrame(X_training_raw, columns=np.concat([['unit_id'],model_metric_names])),
|
|
2016
|
+
pd.DataFrame(X_new, columns=np.concat([model_metric_names,['unit_id']]))
|
|
2017
|
+
])
|
|
2018
|
+
|
|
2019
|
+
# save labels
|
|
2020
|
+
all_labels = pd.concat([
|
|
2021
|
+
pd.DataFrame({label_column: y_corrected}),
|
|
2022
|
+
pd.DataFrame({label_column: np.asarray(y_new, dtype=y_corrected.dtype)})
|
|
2023
|
+
], ignore_index=True)
|
|
2024
|
+
all_labels.insert(0, 'unit_index', all_labels.index)
|
|
2025
|
+
|
|
2026
|
+
retrained_model_folder.mkdir(exist_ok=True, parents=True)
|
|
2027
|
+
from sklearn.pipeline import Pipeline
|
|
2028
|
+
retrained_pipeline = Pipeline(
|
|
2029
|
+
[("imputer", model_imputer), ("scaler", model_scaler), ("classifier", retrained_classifier)]
|
|
2030
|
+
)
|
|
2031
|
+
dump(retrained_pipeline, retrained_model_folder / "best_model.skops")
|
|
2032
|
+
all_labels.to_csv(retrained_model_folder / 'labels.csv', index=False)
|
|
2033
|
+
all_training_data.to_csv(retrained_model_folder / 'training_data.csv', index=False)
|
|
2034
|
+
if training_rows is not None:
|
|
2035
|
+
# which round set the label of each row: none for the rows from training
|
|
2036
|
+
label_from = training_rows['label_from'].tolist()
|
|
2037
|
+
for row in changed_rows:
|
|
2038
|
+
label_from[row] = retrained_model_name
|
|
2039
|
+
pd.DataFrame({
|
|
2040
|
+
'analyzer': training_rows['analyzer'].tolist() + [analyzer for analyzer, _ in new_rows_units],
|
|
2041
|
+
'unit_id': training_rows['unit_id'].tolist() + [unit_id for _, unit_id in new_rows_units],
|
|
2042
|
+
'label_from': label_from + [retrained_model_name] * len(new_rows_units),
|
|
2043
|
+
}).to_csv(retrained_model_folder / TRAINING_UNITS_FILE, index=False)
|
|
2044
|
+
|
|
2045
|
+
shutil.copyfile(model_folder / 'model_info.json', retrained_model_folder / 'model_info.json')
|
|
2046
|
+
|
|
2047
|
+
# the retrained model learned from what its parent learned from, plus the relabelled units
|
|
2048
|
+
parent_units, parent_status = read_trained_on(model_folder)
|
|
2049
|
+
trained_on = {path: set(unit_ids) for path, unit_ids in parent_units.items()}
|
|
2050
|
+
for path, unit_ids in units_added.items():
|
|
2051
|
+
trained_on.setdefault(normalise_path(path), set()).update(unit_ids)
|
|
2052
|
+
write_trained_on(retrained_model_folder, trained_on, complete=(parent_status != "unknown"))
|
|
2053
|
+
|
|
2054
|
+
self.project.model_paths = self.project.model_paths + [(retrained_model_folder, "local")]
|
|
2055
|
+
|
|
2056
|
+
print(f"Successfully retrained model! Saved at {retrained_model_folder}.")
|
|
2057
|
+
held_out_balanced_accuracy = held_out_score(retrained_classifier)
|
|
2058
|
+
print(f"balanced accuracy (held-out test) = {held_out_before:.4f} without the relabelled units, "
|
|
2059
|
+
f"{held_out_balanced_accuracy:.4f} with them")
|
|
2060
|
+
|
|
2061
|
+
# what this round did, for the Retraining history in section 4
|
|
2062
|
+
write_retraining_log(retrained_model_folder, dict(
|
|
2063
|
+
parent=model_folder.name,
|
|
2064
|
+
model=retrained_model_name,
|
|
2065
|
+
selection=selection_key,
|
|
2066
|
+
num_training_units_before=int(len(y_training)),
|
|
2067
|
+
num_new_units=len(y_new),
|
|
2068
|
+
num_corrected_labels=len(changed_rows),
|
|
2069
|
+
num_same_as_original=num_same_as_original,
|
|
2070
|
+
num_not_in_classes=num_not_in_classes,
|
|
2071
|
+
num_already_learned=num_already_learned,
|
|
2072
|
+
held_out=dict(num_units=int(len(y_test)), before=float(held_out_before), after=float(held_out_balanced_accuracy)),
|
|
2073
|
+
), added_unit_details)
|
|
2074
|
+
|
|
2075
|
+
return dict(name=retrained_model_name, num_units_added=len(added_unit_details), num_new_units=len(y_new),
|
|
2076
|
+
num_corrected_labels=len(changed_rows), held_out_balanced_accuracy=held_out_balanced_accuracy,
|
|
2077
|
+
held_out_before=held_out_before, num_held_out=int(len(y_test)), recordings=units_added)
|
|
2078
|
+
|
|
2079
|
+
def show_curation_window(self, selected_directory, analyzer_index):
|
|
2080
|
+
|
|
2081
|
+
analyzer_path = selected_directory
|
|
2082
|
+
|
|
2083
|
+
print(f"\nLaunching SpikeInterface-GUI to curate analyzer at {analyzer_path}...")
|
|
2084
|
+
print("Label units as noise, good and MUA by pressing 'n', 'g' and 'm' on your keyboard.")
|
|
2085
|
+
print("Your labels are shown in the 'quality' column.")
|
|
2086
|
+
print("To change a label, select the unit and press another key: the new label replaces the old one.")
|
|
2087
|
+
print("Your labels are saved automatically when you close the SpikeInterface-GUI.")
|
|
2088
|
+
|
|
2089
|
+
curate_filepath = Path(__file__).absolute().parent / "launch_sigui.py"
|
|
2090
|
+
self.run_sigui(
|
|
2091
|
+
[sys.executable, curate_filepath, analyzer_path, f'{self.output_folder}', f'{analyzer_index}'],
|
|
2092
|
+
title=f"Opening {Path(str(analyzer_path)).name} to do curation",
|
|
2093
|
+
steps=CURATE_STEPS,
|
|
2094
|
+
timing_key=f"curate:{analyzer_path}",
|
|
2095
|
+
on_closed=self.make_curation_button_list,
|
|
2096
|
+
)
|
|
2097
|
+
|
|
2098
|
+
def run_sigui(self, command, title, steps, timing_key, on_closed):
|
|
2099
|
+
"""
|
|
2100
|
+
Opens the SpikeInterface-GUI in its own process, without blocking this window. While it opens,
|
|
2101
|
+
a dialog shows the steps it goes through and the time it takes (and how long it took last time
|
|
2102
|
+
for this recording); once it is open, a banner says so. `on_closed` is called when it is closed.
|
|
2103
|
+
"""
|
|
2104
|
+
if self.sigui_process is not None:
|
|
2105
|
+
print("The SpikeInterface-GUI is already open. Close it before opening another one.")
|
|
2106
|
+
return
|
|
2107
|
+
|
|
2108
|
+
process = QProcess(self)
|
|
2109
|
+
process.setProcessChannelMode(QProcess.MergedChannels)
|
|
2110
|
+
environment = QProcessEnvironment.systemEnvironment()
|
|
2111
|
+
# print the SpikeInterface-GUI's messages as they come, not when it closes
|
|
2112
|
+
environment.insert("PYTHONUNBUFFERED", "1")
|
|
2113
|
+
environment.insert("PYTHONIOENCODING", "utf-8")
|
|
2114
|
+
process.setProcessEnvironment(environment)
|
|
2115
|
+
process.readyReadStandardOutput.connect(self.on_sigui_output)
|
|
2116
|
+
process.finished.connect(lambda exit_code, exit_status: self.on_sigui_finished(exit_code, on_closed))
|
|
2117
|
+
self.sigui_process = process
|
|
2118
|
+
self.sigui_window_shown = False
|
|
2119
|
+
self.sigui_cancelled = False
|
|
2120
|
+
self.sigui_output_buffer = ""
|
|
2121
|
+
self.sigui_labels_backups = []
|
|
2122
|
+
self.sigui_last_message = "No message was printed."
|
|
2123
|
+
self.sigui_timing_key = timing_key
|
|
2124
|
+
|
|
2125
|
+
previous_seconds = self.project.config.get("sigui_opening_seconds", {}).get(timing_key)
|
|
2126
|
+
self.sigui_opening_dialog = OpeningDialog(title, steps, previous_seconds, parent=self)
|
|
2127
|
+
self.sigui_opening_dialog.cancelled.connect(self.cancel_sigui)
|
|
2128
|
+
self.sigui_opening_dialog.show()
|
|
2129
|
+
|
|
2130
|
+
self.content_widget.setEnabled(False)
|
|
2131
|
+
|
|
2132
|
+
process.start(str(command[0]), [str(argument) for argument in command[1:]])
|
|
2133
|
+
|
|
2134
|
+
def on_sigui_output(self):
|
|
2135
|
+
output = bytes(self.sigui_process.readAllStandardOutput()).decode("utf-8", errors="replace")
|
|
2136
|
+
# output can arrive in pieces, so only complete lines are handled
|
|
2137
|
+
self.sigui_output_buffer += output.replace("\r\n", "\n")
|
|
2138
|
+
*lines, self.sigui_output_buffer = self.sigui_output_buffer.split("\n")
|
|
2139
|
+
for line in lines:
|
|
2140
|
+
if line.startswith(SIGUI_STEP_MARKER):
|
|
2141
|
+
self.sigui_opening_dialog.set_step(line[len(SIGUI_STEP_MARKER):].strip())
|
|
2142
|
+
elif line.strip() == SIGUI_SHOWN_MARKER:
|
|
2143
|
+
self.on_sigui_shown()
|
|
2144
|
+
elif line.startswith(LABELS_BACKUP_MARKER):
|
|
2145
|
+
backup, intended = line[len(LABELS_BACKUP_MARKER):].strip().split("|", 1)
|
|
2146
|
+
self.sigui_labels_backups.append((backup, intended))
|
|
2147
|
+
else:
|
|
2148
|
+
sys.stdout.write(line + "\n")
|
|
2149
|
+
if line.strip() != "":
|
|
2150
|
+
self.sigui_last_message = line.strip()
|
|
2151
|
+
sys.stdout.flush()
|
|
2152
|
+
|
|
2153
|
+
def on_sigui_shown(self):
|
|
2154
|
+
self.sigui_window_shown = True
|
|
2155
|
+
|
|
2156
|
+
# remember how long it took, to show next time
|
|
2157
|
+
opening_seconds = self.project.config.setdefault("sigui_opening_seconds", {})
|
|
2158
|
+
opening_seconds[self.sigui_timing_key] = round(self.sigui_opening_dialog.elapsed_seconds(), 1)
|
|
2159
|
+
self.project.save_config()
|
|
2160
|
+
|
|
2161
|
+
self.sigui_opening_dialog.done_opening()
|
|
2162
|
+
self.sigui_banner_text.setText(
|
|
2163
|
+
"<b>The SpikeInterface-GUI is open.</b> Label units with the <b>n</b>, <b>g</b> and <b>m</b> keys "
|
|
2164
|
+
"(noise, good, MUA) in the 'quality' column. To change a label, press another key: the new label "
|
|
2165
|
+
"replaces the old one.<br><b>Your labels are saved automatically when you close it</b>, and UnitRefine "
|
|
2166
|
+
"is available again."
|
|
2167
|
+
)
|
|
2168
|
+
self.sigui_banner.setVisible(True)
|
|
2169
|
+
|
|
2170
|
+
def on_sigui_finished(self, exit_code, on_closed):
|
|
2171
|
+
if not self.sigui_window_shown:
|
|
2172
|
+
# still loading: cancelled, or failed before the window opened
|
|
2173
|
+
self.sigui_opening_dialog.done_opening()
|
|
2174
|
+
if exit_code != 0 and not self.sigui_cancelled:
|
|
2175
|
+
print("The SpikeInterface-GUI did not open. See the messages above for the reason.")
|
|
2176
|
+
QtWidgets.QMessageBox.warning(
|
|
2177
|
+
self, "SpikeInterface-GUI did not open",
|
|
2178
|
+
f"The SpikeInterface-GUI could not be opened:\n\n{self.sigui_last_message}\n\nThe terminal shows all the messages.",
|
|
2179
|
+
)
|
|
2180
|
+
self.sigui_process = None
|
|
2181
|
+
self.sigui_banner.setVisible(False)
|
|
2182
|
+
self.content_widget.setEnabled(True)
|
|
2183
|
+
if self.sigui_window_shown:
|
|
2184
|
+
print("SpikeInterface-GUI closed, resuming main app.\n")
|
|
2185
|
+
for backup, intended in self.sigui_labels_backups:
|
|
2186
|
+
QtWidgets.QMessageBox.warning(
|
|
2187
|
+
self, "Your labels were saved to a backup file",
|
|
2188
|
+
f"Your labels could not be saved to {intended} (it is read-only or open in another program), so "
|
|
2189
|
+
f"they were saved to:\n\n{backup}\n\nNothing is lost. Make {Path(intended).name} writable "
|
|
2190
|
+
"(in File Explorer: right-click, Properties, untick 'Read-only'), then copy the backup file over it.",
|
|
2191
|
+
)
|
|
2192
|
+
on_closed()
|
|
2193
|
+
|
|
2194
|
+
def cancel_sigui(self):
|
|
2195
|
+
if self.sigui_process is not None:
|
|
2196
|
+
print("Cancelled opening the SpikeInterface-GUI.")
|
|
2197
|
+
self.sigui_cancelled = True
|
|
2198
|
+
self.sigui_process.kill()
|
|
2199
|
+
|
|
2200
|
+
def show_train_window(self):
|
|
2201
|
+
|
|
2202
|
+
if not self.curated_units_exist():
|
|
2203
|
+
print("No units have been curated - cannot train a model.")
|
|
2204
|
+
return
|
|
2205
|
+
|
|
2206
|
+
self.w = TrainWindow(self.project)
|
|
2207
|
+
self.w.resize(800, 600)
|
|
2208
|
+
self.w.show()
|
|
2209
|
+
self.w.update_signal.connect(self.make_model_list)
|
|
2210
|
+
|
|
2211
|
+
def curated_units_exist(self):
|
|
2212
|
+
|
|
2213
|
+
for analyzer in self.project.analyzers.values():
|
|
2214
|
+
analyzer_folder = self.project.folder_name / analyzer['analyzer_in_project']
|
|
2215
|
+
labels_path = analyzer_folder / "labels.csv"
|
|
2216
|
+
if labels_path.is_file():
|
|
2217
|
+
return True
|
|
2218
|
+
|
|
2219
|
+
return False
|
|
2220
|
+
|
|
2221
|
+
|
|
2222
|
+
def model_lineage(self, model_name):
|
|
2223
|
+
"""A model and its retrained versions in the project, in order: the original, _retrained_01, _02, ..."""
|
|
2224
|
+
base_name, _ = _base_and_idx(model_name)
|
|
2225
|
+
versions = []
|
|
2226
|
+
for model_folder, _ in self.project.model_paths:
|
|
2227
|
+
name = Path(model_folder).name
|
|
2228
|
+
name_base, index = _base_and_idx(name)
|
|
2229
|
+
if name_base != base_name:
|
|
2230
|
+
continue
|
|
2231
|
+
# "base_retrained" without a number counts as the first retraining round
|
|
2232
|
+
round_number = 0 if name == base_name else (index if index is not None else 1)
|
|
2233
|
+
versions.append((round_number, Path(model_folder)))
|
|
2234
|
+
return base_name, [model_folder for _, model_folder in sorted(versions, key=lambda version: version[0])]
|
|
2235
|
+
|
|
2236
|
+
def show_manage_models_window(self):
|
|
2237
|
+
|
|
2238
|
+
if len(self.project.model_paths) == 0:
|
|
2239
|
+
print("No models trained or loaded into project.")
|
|
2240
|
+
return
|
|
2241
|
+
|
|
2242
|
+
manage_window = ManageModelsWindow(self.project, parent=self)
|
|
2243
|
+
manage_window.models_changed.connect(self.make_model_list)
|
|
2244
|
+
manage_window.exec()
|
|
2245
|
+
|
|
2246
|
+
def make_model_list(self):
|
|
2247
|
+
self.model_selector.set_models(self.project.model_paths)
|
|
2248
|
+
|
|
2249
|
+
def models_to_retrain(self):
|
|
2250
|
+
"""The names of the models chosen in 'Model to retrain' (one of the models chosen in 3.2, or all)."""
|
|
2251
|
+
names = self.retrain_choice.currentData() if self.retrain_choice.count() > 0 else None
|
|
2252
|
+
return list(names) if names is not None else self.model_selector.selected_model_names()
|
|
2253
|
+
|
|
2254
|
+
def update_retrain_choice(self):
|
|
2255
|
+
"""Fills 'Model to retrain' with the models chosen in 3.2; one model is retrained at a time."""
|
|
2256
|
+
names = self.model_selector.selected_model_names()
|
|
2257
|
+
previous = self.retrain_choice.currentData()
|
|
2258
|
+
self.retrain_choice.blockSignals(True)
|
|
2259
|
+
self.retrain_choice.clear()
|
|
2260
|
+
if self.model_selector.mode() == "cascade" and len(names) == 2:
|
|
2261
|
+
self.retrain_choice.addItem(f"Layer 1: {names[0]}", [names[0]])
|
|
2262
|
+
self.retrain_choice.addItem(f"Layer 2: {names[1]}", [names[1]])
|
|
2263
|
+
else:
|
|
2264
|
+
for name in names:
|
|
2265
|
+
self.retrain_choice.addItem(name, [name])
|
|
2266
|
+
for index in range(self.retrain_choice.count()):
|
|
2267
|
+
if self.retrain_choice.itemData(index) == previous:
|
|
2268
|
+
self.retrain_choice.setCurrentIndex(index)
|
|
2269
|
+
self.retrain_choice.setEnabled(self.retrain_choice.count() > 1)
|
|
2270
|
+
self.retrain_choice.blockSignals(False)
|
|
2271
|
+
self.on_retrain_choice_changed()
|
|
2272
|
+
|
|
2273
|
+
def on_retrain_choice_changed(self):
|
|
2274
|
+
retrained_names = self.retrained_names()
|
|
2275
|
+
# a single model can be given any name; several models are named automatically
|
|
2276
|
+
if len(retrained_names) == 1:
|
|
2277
|
+
self.retrainedModelNameForm.setText(retrained_names[0])
|
|
2278
|
+
self.retrainedModelNameForm.setReadOnly(False)
|
|
2279
|
+
else:
|
|
2280
|
+
self.retrainedModelNameForm.setText(", ".join(retrained_names))
|
|
2281
|
+
self.retrainedModelNameForm.setReadOnly(True)
|
|
2282
|
+
|
|
2283
|
+
def retrained_names(self):
|
|
2284
|
+
"""The next free retrained name (…_retrained_01, _02, …) for each model to retrain."""
|
|
2285
|
+
|
|
2286
|
+
existing_names = [str(model_path.name) for (model_path, _) in self.project.model_paths]
|
|
2287
|
+
retrained_names = []
|
|
2288
|
+
for model_name in self.models_to_retrain():
|
|
2289
|
+
base_name, _ = _base_and_idx(model_name)
|
|
2290
|
+
retrained_name = _next_retrained_name(base_name, existing_names + retrained_names, width=2)
|
|
2291
|
+
retrained_names.append(retrained_name)
|
|
2292
|
+
return retrained_names
|
|
2293
|
+
|
|
2294
|
+
def on_model_selection_changed(self):
|
|
2295
|
+
|
|
2296
|
+
self.update_retrain_choice()
|
|
2297
|
+
|
|
2298
|
+
self.make_validate_button_list()
|
|
2299
|
+
self.make_relabel_button_list()
|
|
2300
|
+
|
|
2301
|
+
def show_validate_window(self, analyzer, analyzer_index, relabel=False, *, save_folder=None, file_prefix=""):
|
|
2302
|
+
|
|
2303
|
+
analyzer_path = analyzer['path']
|
|
2304
|
+
if save_folder is None:
|
|
2305
|
+
save_folder = Path(self.output_folder) / analyzer['analyzer_in_project']
|
|
2306
|
+
|
|
2307
|
+
problem = self.model_selector.describe_problem()
|
|
2308
|
+
if problem is not None:
|
|
2309
|
+
print(problem)
|
|
2310
|
+
return
|
|
2311
|
+
|
|
2312
|
+
mode = self.model_selector.mode()
|
|
2313
|
+
model_folders = [str(model_folder) for model_folder, _ in self.model_selector.selected_models()]
|
|
2314
|
+
selection_key = self.model_selector.selection_key()
|
|
2315
|
+
|
|
2316
|
+
validate_filepath = Path(__file__).absolute().parent / "launch_sigui_validate.py"
|
|
2317
|
+
command = [
|
|
2318
|
+
sys.executable, validate_filepath, str(analyzer_path),
|
|
2319
|
+
"--save_folder", str(save_folder), "--file_prefix", file_prefix,
|
|
2320
|
+
"--mode", mode, "--model_folders", *model_folders, "--selection_key", selection_key,
|
|
2321
|
+
]
|
|
2322
|
+
# curated or imported labels fill the quality column: of a project analyzer, or imported for a new one
|
|
2323
|
+
human_labels_path = Path(save_folder) / f"{file_prefix}labels.csv"
|
|
2324
|
+
if human_labels_path.is_file():
|
|
2325
|
+
command += ["--human_labels", str(human_labels_path)]
|
|
2326
|
+
if relabel:
|
|
2327
|
+
units_file = units_to_relabel_path(dict(save_folder=save_folder, file_prefix=file_prefix), selection_key)
|
|
2328
|
+
if not units_file.is_file():
|
|
2329
|
+
print("Click 'Find least confident units' first, to choose the units to relabel.")
|
|
2330
|
+
return
|
|
2331
|
+
command += ["--relabel", "--units_file", str(units_file)]
|
|
2332
|
+
purpose = "to relabel the least confident units" if relabel else "to inspect the model predictions"
|
|
2333
|
+
self.run_sigui(
|
|
2334
|
+
command,
|
|
2335
|
+
title=f"Opening {Path(str(analyzer_path)).name} {purpose}",
|
|
2336
|
+
steps=INSPECT_STEPS,
|
|
2337
|
+
timing_key=f"inspect:{analyzer_path}",
|
|
2338
|
+
on_closed=self.make_relabel_button_list,
|
|
2339
|
+
)
|
|
2340
|
+
|
|
2341
|
+
def main():
|
|
2342
|
+
"""Starts UnitRefine; see launcher.py, which shows an 'Opening UnitRefine' box while it loads."""
|
|
2343
|
+
from unitrefine.launcher import main as launch
|
|
2344
|
+
launch()
|
|
2345
|
+
|
|
2346
|
+
def initial_window_size(available_geometry):
|
|
2347
|
+
"""
|
|
2348
|
+
The window opens wide enough for the sections at their maximum width (or the whole screen width on a
|
|
2349
|
+
small laptop), and as tall as the screen allows; the rest scrolls.
|
|
2350
|
+
"""
|
|
2351
|
+
width = min(CONTENT_MAX_WIDTH + 60, available_geometry.width())
|
|
2352
|
+
height = min(1100, int(available_geometry.height() * 0.9))
|
|
2353
|
+
return QSize(width, height)
|
|
2354
|
+
|
|
2355
|
+
def is_a_model(directory):
|
|
2356
|
+
return (Path(directory) / "best_model.skops").is_file()
|
|
2357
|
+
|
|
2358
|
+
def _base_and_idx(name: str):
|
|
2359
|
+
"""
|
|
2360
|
+
Returns (base_name, idx or None) where idx is the numeric suffix after _retrained_.
|
|
2361
|
+
Handles:
|
|
2362
|
+
- foo
|
|
2363
|
+
- foo_retrained
|
|
2364
|
+
- foo_retrained_01
|
|
2365
|
+
- foo_retrained_9
|
|
2366
|
+
"""
|
|
2367
|
+
m = _RETRAINED_RE.match(name)
|
|
2368
|
+
if not m:
|
|
2369
|
+
return name, None
|
|
2370
|
+
base = m.group("base")
|
|
2371
|
+
idx = m.group("idx")
|
|
2372
|
+
return base, (int(idx) if idx is not None else None)
|
|
2373
|
+
|
|
2374
|
+
def _next_retrained_name(base: str, existing_names: list[str], width: int = 2) -> str:
|
|
2375
|
+
"""
|
|
2376
|
+
Finds next available retrained index based on existing names.
|
|
2377
|
+
Produces: base_retrained_01, base_retrained_02, ...
|
|
2378
|
+
If base_retrained exists without number, treat it as idx=1.
|
|
2379
|
+
"""
|
|
2380
|
+
max_idx = 0
|
|
2381
|
+
for n in existing_names:
|
|
2382
|
+
b, idx = _base_and_idx(n)
|
|
2383
|
+
if b != base:
|
|
2384
|
+
continue
|
|
2385
|
+
|
|
2386
|
+
if n == f"{base}_retrained" and idx is None:
|
|
2387
|
+
max_idx = max(max_idx, 1)
|
|
2388
|
+
elif idx is not None:
|
|
2389
|
+
max_idx = max(max_idx, idx)
|
|
2390
|
+
|
|
2391
|
+
next_idx = max_idx + 1
|
|
2392
|
+
return f"{base}_retrained_{next_idx:0{width}d}"
|
|
2393
|
+
|
|
2394
|
+
if __name__ == "__main__":
|
|
2395
|
+
main()
|