unitrefine 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. unitrefine/__init__.py +75 -0
  2. unitrefine/apply_models.py +111 -0
  3. unitrefine/cli.py +251 -0
  4. unitrefine/evaluation.py +457 -0
  5. unitrefine/figures.py +1458 -0
  6. unitrefine/import_labels.py +176 -0
  7. unitrefine/launch_sigui.py +138 -0
  8. unitrefine/launch_sigui_validate.py +243 -0
  9. unitrefine/launcher.py +239 -0
  10. unitrefine/main.py +2395 -0
  11. unitrefine/manage_models.py +193 -0
  12. unitrefine/metrics.py +146 -0
  13. unitrefine/model_selection.py +159 -0
  14. unitrefine/models_hub.py +118 -0
  15. unitrefine/phy_curation.py +306 -0
  16. unitrefine/phy_import.py +428 -0
  17. unitrefine/predictions.py +507 -0
  18. unitrefine/resources/curation_gui.jpg +0 -0
  19. unitrefine/resources/fonts/Inter-Bold.ttf +0 -0
  20. unitrefine/resources/fonts/Inter-LICENSE.txt +92 -0
  21. unitrefine/resources/fonts/Inter-Regular.ttf +0 -0
  22. unitrefine/resources/logo.png +0 -0
  23. unitrefine/resources/model_training_example.jpg +0 -0
  24. unitrefine/resources/predict-gui.JPG +0 -0
  25. unitrefine/resources/re-label.JPG +0 -0
  26. unitrefine/resources/si-gui.JPG +0 -0
  27. unitrefine/resources/train_and_load_gui.JPG +0 -0
  28. unitrefine/resources/unit_types_example.jpg +0 -0
  29. unitrefine/resources/unitrefine_gui.JPG +0 -0
  30. unitrefine/retraining_history.py +220 -0
  31. unitrefine/sigui_progress.py +157 -0
  32. unitrefine/theme.py +254 -0
  33. unitrefine/train.py +622 -0
  34. unitrefine/widgets.py +961 -0
  35. unitrefine-0.2.0.dist-info/METADATA +153 -0
  36. unitrefine-0.2.0.dist-info/RECORD +40 -0
  37. unitrefine-0.2.0.dist-info/WHEEL +5 -0
  38. unitrefine-0.2.0.dist-info/entry_points.txt +4 -0
  39. unitrefine-0.2.0.dist-info/licenses/LICENSE +21 -0
  40. unitrefine-0.2.0.dist-info/top_level.txt +1 -0
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()