unitrefine 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unitrefine/__init__.py +75 -0
- unitrefine/apply_models.py +111 -0
- unitrefine/cli.py +251 -0
- unitrefine/evaluation.py +457 -0
- unitrefine/figures.py +1458 -0
- unitrefine/import_labels.py +176 -0
- unitrefine/launch_sigui.py +138 -0
- unitrefine/launch_sigui_validate.py +243 -0
- unitrefine/launcher.py +239 -0
- unitrefine/main.py +2395 -0
- unitrefine/manage_models.py +193 -0
- unitrefine/metrics.py +146 -0
- unitrefine/model_selection.py +159 -0
- unitrefine/models_hub.py +118 -0
- unitrefine/phy_curation.py +306 -0
- unitrefine/phy_import.py +428 -0
- unitrefine/predictions.py +507 -0
- unitrefine/resources/curation_gui.jpg +0 -0
- unitrefine/resources/fonts/Inter-Bold.ttf +0 -0
- unitrefine/resources/fonts/Inter-LICENSE.txt +92 -0
- unitrefine/resources/fonts/Inter-Regular.ttf +0 -0
- unitrefine/resources/logo.png +0 -0
- unitrefine/resources/model_training_example.jpg +0 -0
- unitrefine/resources/predict-gui.JPG +0 -0
- unitrefine/resources/re-label.JPG +0 -0
- unitrefine/resources/si-gui.JPG +0 -0
- unitrefine/resources/train_and_load_gui.JPG +0 -0
- unitrefine/resources/unit_types_example.jpg +0 -0
- unitrefine/resources/unitrefine_gui.JPG +0 -0
- unitrefine/retraining_history.py +220 -0
- unitrefine/sigui_progress.py +157 -0
- unitrefine/theme.py +254 -0
- unitrefine/train.py +622 -0
- unitrefine/widgets.py +961 -0
- unitrefine-0.2.0.dist-info/METADATA +153 -0
- unitrefine-0.2.0.dist-info/RECORD +40 -0
- unitrefine-0.2.0.dist-info/WHEEL +5 -0
- unitrefine-0.2.0.dist-info/entry_points.txt +4 -0
- unitrefine-0.2.0.dist-info/licenses/LICENSE +21 -0
- unitrefine-0.2.0.dist-info/top_level.txt +1 -0
unitrefine/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# the version in pyproject.toml, e.g. to say which version a problem was found with
|
|
5
|
+
__version__ = version("unitrefine")
|
|
6
|
+
except PackageNotFoundError:
|
|
7
|
+
# run from the source folder without installing it
|
|
8
|
+
__version__ = "unknown"
|
|
9
|
+
|
|
10
|
+
# The SpikeInterface-GUI scripts run in their own process. They print these markers so the main
|
|
11
|
+
# window can show which step they are on while they open, and when their window is shown.
|
|
12
|
+
SIGUI_SHOWN_MARKER = "UNITREFINE_SIGUI_WINDOW_SHOWN"
|
|
13
|
+
SIGUI_STEP_MARKER = "UNITREFINE_SIGUI_STEP:"
|
|
14
|
+
# printed when labels could not be saved where they belong and were saved to a backup file instead
|
|
15
|
+
LABELS_BACKUP_MARKER = "UNITREFINE_LABELS_BACKUP:"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# The labels are passed to the SpikeInterface-GUI in curation format 2, which spikeinterface-gui handles
|
|
19
|
+
# correctly (also for units shown without a label) from this version on
|
|
20
|
+
MINIMUM_SIGUI_VERSION = "0.14.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_sigui_version():
|
|
24
|
+
"""
|
|
25
|
+
Stops the SpikeInterface-GUI script with a clear message if spikeinterface-gui is too old: older versions
|
|
26
|
+
would not save the labels set with the n/g/m keys correctly.
|
|
27
|
+
"""
|
|
28
|
+
import sys
|
|
29
|
+
from packaging.version import parse
|
|
30
|
+
import spikeinterface_gui
|
|
31
|
+
|
|
32
|
+
installed = getattr(spikeinterface_gui, "__version__", "0")
|
|
33
|
+
if parse(installed) < parse(MINIMUM_SIGUI_VERSION):
|
|
34
|
+
print(f"UnitRefine needs spikeinterface-gui {MINIMUM_SIGUI_VERSION} or newer, but {installed} is installed, "
|
|
35
|
+
f"which would not save your labels correctly. Please upgrade it, e.g. with "
|
|
36
|
+
f"'pip install --upgrade spikeinterface-gui' or 'uv sync'.", flush=True)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def unwritable_file_message(path, error):
|
|
41
|
+
"""What to tell the user when a file can't be written, e.g. because it is read-only."""
|
|
42
|
+
advice = ("The file is read-only or open in another program. In File Explorer, right-click it, choose "
|
|
43
|
+
"Properties and untick 'Read-only' (or close the other program), then try again.")
|
|
44
|
+
return f"UnitRefine could not save {path}:\n{error}\n\n{advice}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_labels_or_backup(labels, path, write=None, **to_csv_kwargs):
|
|
48
|
+
"""
|
|
49
|
+
Saves the labels set in the SpikeInterface-GUI to `path` (with `write(labels, path)` if given). If that
|
|
50
|
+
fails, e.g. because the file is read-only, the labels are saved to a backup file in the home folder, so
|
|
51
|
+
they are not lost, and the main window is told where (LABELS_BACKUP_MARKER). Returns where they were saved.
|
|
52
|
+
"""
|
|
53
|
+
import time
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
path = Path(path)
|
|
57
|
+
try:
|
|
58
|
+
if write is not None:
|
|
59
|
+
write(labels, path)
|
|
60
|
+
else:
|
|
61
|
+
labels.to_csv(path, **to_csv_kwargs)
|
|
62
|
+
return path
|
|
63
|
+
except OSError as error:
|
|
64
|
+
backup = Path.home() / f"UnitRefine_unsaved_{time.strftime('%Y%m%d_%H%M%S')}_{path.name}"
|
|
65
|
+
labels.to_csv(backup, **to_csv_kwargs)
|
|
66
|
+
print(unwritable_file_message(path, error), flush=True)
|
|
67
|
+
print(f"Your labels were saved to {backup} instead. Once {path.name} can be written, copy that file over it.",
|
|
68
|
+
flush=True)
|
|
69
|
+
print(f"{LABELS_BACKUP_MARKER}{backup}|{path}", flush=True)
|
|
70
|
+
return backup
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def report_sigui_step(step_name):
|
|
74
|
+
"""Called by the SpikeInterface-GUI scripts when they start a step, e.g. 'loading'."""
|
|
75
|
+
print(f"{SIGUI_STEP_MARKER}{step_name}", flush=True)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import PyQt5.QtWidgets as QtWidgets
|
|
4
|
+
from spikeinterface_gui.main import check_folder_is_analyzer
|
|
5
|
+
|
|
6
|
+
from unitrefine.predictions import find_analyzers
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApplyModelsDialog(QtWidgets.QDialog):
|
|
10
|
+
"""Pick the analyzers to apply the selected models to, and the folder to save the predictions in."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, selection_text, default_output_folder, parent=None):
|
|
13
|
+
|
|
14
|
+
super().__init__(parent)
|
|
15
|
+
|
|
16
|
+
self.setWindowTitle("UNITREFINE: Apply models to analyzers")
|
|
17
|
+
self.setMinimumWidth(650)
|
|
18
|
+
|
|
19
|
+
layout = QtWidgets.QVBoxLayout(self)
|
|
20
|
+
|
|
21
|
+
selection_label = QtWidgets.QLabel(f"Models: {selection_text}")
|
|
22
|
+
selection_label.setWordWrap(True)
|
|
23
|
+
layout.addWidget(selection_label)
|
|
24
|
+
|
|
25
|
+
analyzers_label = QtWidgets.QLabel("Analyzers the models will be applied to:")
|
|
26
|
+
analyzers_label.setStyleSheet("font-weight: bold;")
|
|
27
|
+
layout.addWidget(analyzers_label)
|
|
28
|
+
|
|
29
|
+
self.analyzer_list = QtWidgets.QListWidget()
|
|
30
|
+
self.analyzer_list.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
|
|
31
|
+
layout.addWidget(self.analyzer_list)
|
|
32
|
+
|
|
33
|
+
buttons_layout = QtWidgets.QHBoxLayout()
|
|
34
|
+
add_button = QtWidgets.QPushButton("+ Add analyzer folder")
|
|
35
|
+
add_button.clicked.connect(self.add_analyzer_folder)
|
|
36
|
+
add_all_button = QtWidgets.QPushButton("+ Add all analyzers inside a folder")
|
|
37
|
+
add_all_button.setToolTip("Searches up to 3 folder levels down for Sorting Analyzer folders")
|
|
38
|
+
add_all_button.clicked.connect(self.add_analyzers_inside_folder)
|
|
39
|
+
remove_button = QtWidgets.QPushButton("Remove selected")
|
|
40
|
+
remove_button.clicked.connect(self.remove_selected)
|
|
41
|
+
buttons_layout.addWidget(add_button)
|
|
42
|
+
buttons_layout.addWidget(add_all_button)
|
|
43
|
+
buttons_layout.addWidget(remove_button)
|
|
44
|
+
layout.addLayout(buttons_layout)
|
|
45
|
+
|
|
46
|
+
output_label = QtWidgets.QLabel("Save the predictions (one csv per analyzer) in:")
|
|
47
|
+
output_label.setStyleSheet("font-weight: bold;")
|
|
48
|
+
layout.addWidget(output_label)
|
|
49
|
+
|
|
50
|
+
output_layout = QtWidgets.QHBoxLayout()
|
|
51
|
+
self.output_folder_form = QtWidgets.QLineEdit(str(default_output_folder))
|
|
52
|
+
browse_button = QtWidgets.QPushButton("Browse")
|
|
53
|
+
browse_button.clicked.connect(self.choose_output_folder)
|
|
54
|
+
output_layout.addWidget(self.output_folder_form)
|
|
55
|
+
output_layout.addWidget(browse_button)
|
|
56
|
+
layout.addLayout(output_layout)
|
|
57
|
+
|
|
58
|
+
button_box = QtWidgets.QDialogButtonBox()
|
|
59
|
+
self.apply_button = button_box.addButton("Apply", QtWidgets.QDialogButtonBox.AcceptRole)
|
|
60
|
+
button_box.addButton(QtWidgets.QDialogButtonBox.StandardButton.Cancel)
|
|
61
|
+
button_box.accepted.connect(self.accept_if_ready)
|
|
62
|
+
button_box.rejected.connect(self.reject)
|
|
63
|
+
layout.addWidget(button_box)
|
|
64
|
+
|
|
65
|
+
def add_paths(self, paths):
|
|
66
|
+
existing = set(self.analyzer_paths())
|
|
67
|
+
for path in paths:
|
|
68
|
+
if str(path) not in existing:
|
|
69
|
+
self.analyzer_list.addItem(str(path))
|
|
70
|
+
existing.add(str(path))
|
|
71
|
+
|
|
72
|
+
def add_analyzer_folder(self):
|
|
73
|
+
folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Select a Sorting Analyzer folder")
|
|
74
|
+
if folder == "":
|
|
75
|
+
return
|
|
76
|
+
if check_folder_is_analyzer(folder):
|
|
77
|
+
self.add_paths([Path(folder)])
|
|
78
|
+
else:
|
|
79
|
+
print(f"Selected directory {folder} is not a SortingAnalyzer.")
|
|
80
|
+
|
|
81
|
+
def add_analyzers_inside_folder(self):
|
|
82
|
+
folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Select a folder containing Sorting Analyzers")
|
|
83
|
+
if folder == "":
|
|
84
|
+
return
|
|
85
|
+
analyzers = find_analyzers(folder)
|
|
86
|
+
print(f"Found {len(analyzers)} Sorting Analyzers in {folder}.")
|
|
87
|
+
self.add_paths(analyzers)
|
|
88
|
+
|
|
89
|
+
def remove_selected(self):
|
|
90
|
+
for item in self.analyzer_list.selectedItems():
|
|
91
|
+
self.analyzer_list.takeItem(self.analyzer_list.row(item))
|
|
92
|
+
|
|
93
|
+
def choose_output_folder(self):
|
|
94
|
+
folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Select the folder to save the predictions in")
|
|
95
|
+
if folder != "":
|
|
96
|
+
self.output_folder_form.setText(folder)
|
|
97
|
+
|
|
98
|
+
def analyzer_paths(self):
|
|
99
|
+
return [self.analyzer_list.item(row).text() for row in range(self.analyzer_list.count())]
|
|
100
|
+
|
|
101
|
+
def output_folder(self):
|
|
102
|
+
return self.output_folder_form.text().strip()
|
|
103
|
+
|
|
104
|
+
def accept_if_ready(self):
|
|
105
|
+
if len(self.analyzer_paths()) == 0:
|
|
106
|
+
QtWidgets.QMessageBox.warning(self, "No analyzers", "Add at least one analyzer to apply the models to.")
|
|
107
|
+
return
|
|
108
|
+
if self.output_folder() == "":
|
|
109
|
+
QtWidgets.QMessageBox.warning(self, "No output folder", "Choose a folder to save the predictions in.")
|
|
110
|
+
return
|
|
111
|
+
self.accept()
|
unitrefine/cli.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Applies models to analyzers without the GUI, e.g. on a computing cluster (HPC). Nothing here imports Qt, so it
|
|
3
|
+
runs without a screen.
|
|
4
|
+
|
|
5
|
+
unitrefine-apply ANALYZERS --model MODEL [--model MODEL ...] [--cascade] [--output FOLDER] [--compute_missing_metrics]
|
|
6
|
+
unitrefine-download-models [REPO_ID ...] [--models_folder FOLDER]
|
|
7
|
+
|
|
8
|
+
ANALYZERS can be analyzer folders, folders with analyzers inside, or text files with one analyzer path per line.
|
|
9
|
+
A MODEL is a model folder (e.g. my_project/models/my_model) or a Hugging Face repo id, which is downloaded once
|
|
10
|
+
into the models folder. The predictions are the ones the GUI makes, with the same labels and columns: one csv per
|
|
11
|
+
analyzer, {output}/{analyzer name}__labels_from_{models}.csv. Running it again reuses the predictions which are up
|
|
12
|
+
to date, so only new or changed analyzers are labelled. With --compute_missing_metrics, the metrics the models need
|
|
13
|
+
which an analyzer doesn't have (e.g. the drift metrics) are computed first, and saved in the analyzer.
|
|
14
|
+
|
|
15
|
+
With --task_index, only one of the analyzers is labelled, e.g. one per job of a SLURM job array
|
|
16
|
+
(--task_index $SLURM_ARRAY_TASK_ID); --list shows the analyzers and their numbers. The csv files are named
|
|
17
|
+
from the whole list, so the jobs' files don't clash.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from unitrefine import __version__
|
|
27
|
+
from unitrefine.models_hub import downloaded_repo_id, download_model, hfh_model_groups, looks_like_repo_id
|
|
28
|
+
from unitrefine.predictions import (
|
|
29
|
+
analyzer_output_names,
|
|
30
|
+
apply_to_analyzers,
|
|
31
|
+
find_analyzers,
|
|
32
|
+
get_model_classes,
|
|
33
|
+
is_layer1_model,
|
|
34
|
+
is_layer2_model,
|
|
35
|
+
is_remote_path,
|
|
36
|
+
selection_key,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# where models downloaded from the Hugging Face Hub are kept, unless --models_folder is given
|
|
40
|
+
MODELS_FOLDER_VARIABLE = "UNITREFINE_MODELS_FOLDER"
|
|
41
|
+
DEFAULT_MODELS_FOLDER = Path.home() / ".cache" / "unitrefine" / "models"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UsageError(ValueError):
|
|
45
|
+
"""A problem with what was asked for, e.g. a model which doesn't exist: shown as a message, not a traceback."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_models_folder():
|
|
49
|
+
return Path(os.environ.get(MODELS_FOLDER_VARIABLE, DEFAULT_MODELS_FOLDER))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def collect_analyzers(sources, max_depth=3):
|
|
53
|
+
"""
|
|
54
|
+
The analyzers in `sources`: analyzer folders, folders with analyzers inside (up to `max_depth` levels down),
|
|
55
|
+
text files with one path per line (blank lines and lines starting with # are skipped), or remote (s3) paths.
|
|
56
|
+
"""
|
|
57
|
+
analyzers = []
|
|
58
|
+
for source in sources:
|
|
59
|
+
source = str(source)
|
|
60
|
+
if is_remote_path(source):
|
|
61
|
+
found = [source]
|
|
62
|
+
elif Path(source).is_file():
|
|
63
|
+
lines = [line.strip() for line in Path(source).read_text().splitlines()]
|
|
64
|
+
found = collect_analyzers([line for line in lines if line != "" and not line.startswith("#")], max_depth)
|
|
65
|
+
elif Path(source).is_dir():
|
|
66
|
+
found = find_analyzers(source, max_depth)
|
|
67
|
+
if len(found) == 0:
|
|
68
|
+
raise UsageError(f"No analyzers found in {source} (searched {max_depth} folder levels down).")
|
|
69
|
+
else:
|
|
70
|
+
raise UsageError(f"{source} does not exist.")
|
|
71
|
+
for path in found:
|
|
72
|
+
if str(path) not in [str(analyzer) for analyzer in analyzers]:
|
|
73
|
+
analyzers.append(path)
|
|
74
|
+
return analyzers
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_model(model, models_folder=None, download=True):
|
|
78
|
+
"""
|
|
79
|
+
The folder of `model`: a model folder, or a Hugging Face repo id, which is downloaded into `models_folder`
|
|
80
|
+
the first time. Several jobs can download the same model at once: each downloads it to its own folder, and
|
|
81
|
+
the first one to finish is kept.
|
|
82
|
+
"""
|
|
83
|
+
if Path(model).is_dir():
|
|
84
|
+
model_folder = Path(model)
|
|
85
|
+
if not (model_folder / "best_model.skops").is_file():
|
|
86
|
+
raise UsageError(f"{model_folder} is not a UnitRefine model: it has no best_model.skops.")
|
|
87
|
+
return model_folder
|
|
88
|
+
if not looks_like_repo_id(model):
|
|
89
|
+
raise UsageError(f"The model {model} was not found: give a model folder, or a Hugging Face repo id "
|
|
90
|
+
f"such as SpikeInterface/UnitRefine_sua_mua_classifier.")
|
|
91
|
+
|
|
92
|
+
models_folder = Path(models_folder) if models_folder is not None else default_models_folder()
|
|
93
|
+
model_folder = models_folder / model.split("/")[-1]
|
|
94
|
+
if model_folder.is_dir():
|
|
95
|
+
if downloaded_repo_id(model_folder) != model:
|
|
96
|
+
raise UsageError(f"{model_folder} exists, but was not downloaded from {model}. Give another --models_folder.")
|
|
97
|
+
return model_folder
|
|
98
|
+
if not download:
|
|
99
|
+
raise UsageError(f"{model} has not been downloaded into {models_folder}.")
|
|
100
|
+
|
|
101
|
+
models_folder.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
temporary_folder = models_folder / f".{model_folder.name}.download-{os.getpid()}"
|
|
103
|
+
try:
|
|
104
|
+
download_model(model, temporary_folder)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise UsageError(
|
|
107
|
+
f"Could not download {model} from the Hugging Face Hub: {e}\nIf this computer has no internet (e.g. a "
|
|
108
|
+
f"compute node), download the model first where there is, with: unitrefine-download-models {model} "
|
|
109
|
+
f"--models_folder {models_folder}"
|
|
110
|
+
) from e
|
|
111
|
+
try:
|
|
112
|
+
temporary_folder.rename(model_folder)
|
|
113
|
+
except OSError:
|
|
114
|
+
if not model_folder.is_dir():
|
|
115
|
+
raise
|
|
116
|
+
# another job downloaded it at the same time
|
|
117
|
+
shutil.rmtree(temporary_folder, ignore_errors=True)
|
|
118
|
+
print(f"Saved {model} in {model_folder}.")
|
|
119
|
+
return model_folder
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def model_selection(model_folders, cascade):
|
|
123
|
+
"""The mode ("binary" or "cascade") and the model folders in the order they are applied."""
|
|
124
|
+
if not cascade:
|
|
125
|
+
return "binary", list(model_folders)
|
|
126
|
+
if len(model_folders) != 2:
|
|
127
|
+
raise UsageError("A cascade needs two models: a noise vs neural model and a SUA vs MUA model.")
|
|
128
|
+
first, second = model_folders
|
|
129
|
+
if is_layer1_model(get_model_classes(first)) and is_layer2_model(get_model_classes(second)):
|
|
130
|
+
return "cascade", [first, second]
|
|
131
|
+
if is_layer1_model(get_model_classes(second)) and is_layer2_model(get_model_classes(first)):
|
|
132
|
+
return "cascade", [second, first]
|
|
133
|
+
raise UsageError(
|
|
134
|
+
f"A cascade needs a noise vs neural model and a SUA vs MUA model, but {Path(first).name} predicts "
|
|
135
|
+
f"{get_model_classes(first)} and {Path(second).name} predicts {get_model_classes(second)}."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply_models(analyzers, models, cascade=False, output_folder="unitrefine_predictions", models_folder=None,
|
|
140
|
+
task_index=None, max_depth=3, compute_missing_metrics=False, n_jobs=1):
|
|
141
|
+
"""
|
|
142
|
+
Applies `models` (model folders or Hugging Face repo ids) to `analyzers` (see collect_analyzers), each on its
|
|
143
|
+
own or as a cascade, and saves one predictions csv per analyzer in `output_folder`. With `task_index`, only
|
|
144
|
+
that analyzer (counting from 0) is labelled. With `compute_missing_metrics`, the metrics the models need which
|
|
145
|
+
an analyzer doesn't have are computed first (with `n_jobs` processes) and saved in the analyzer. Returns a list of dicts with the analyzer's `path` and the saved
|
|
146
|
+
`file`, or the `error` for analyzers which could not be labelled, e.g. because a metric is missing.
|
|
147
|
+
"""
|
|
148
|
+
analyzer_paths = collect_analyzers(analyzers, max_depth)
|
|
149
|
+
if len(analyzer_paths) == 0:
|
|
150
|
+
raise UsageError("No analyzers were given.")
|
|
151
|
+
# the names come from the whole list, so each job of an array gets a different file name
|
|
152
|
+
output_names = analyzer_output_names(analyzer_paths)
|
|
153
|
+
if task_index is not None:
|
|
154
|
+
if not 0 <= task_index < len(analyzer_paths):
|
|
155
|
+
raise UsageError(f"--task_index {task_index} is out of range: there are {len(analyzer_paths)} analyzers, "
|
|
156
|
+
f"numbered 0 to {len(analyzer_paths) - 1}.")
|
|
157
|
+
analyzer_paths, output_names = [analyzer_paths[task_index]], [output_names[task_index]]
|
|
158
|
+
|
|
159
|
+
model_folders = [resolve_model(model, models_folder) for model in models]
|
|
160
|
+
mode, model_folders = model_selection(model_folders, cascade)
|
|
161
|
+
key = selection_key(mode, [model_folder.name for model_folder in model_folders])
|
|
162
|
+
return apply_to_analyzers(analyzer_paths, mode, model_folders, key, output_folder, output_names=output_names,
|
|
163
|
+
compute_missing_metrics=compute_missing_metrics, job_kwargs=dict(n_jobs=n_jobs))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def apply_main(argv=None):
|
|
167
|
+
parser = argparse.ArgumentParser(
|
|
168
|
+
prog="unitrefine-apply",
|
|
169
|
+
description="Applies UnitRefine models to Sorting Analyzers and saves their predictions, one csv per analyzer.",
|
|
170
|
+
epilog="Example: unitrefine-apply /data/sortings --model SpikeInterface/UnitRefine_noise_neural_classifier "
|
|
171
|
+
"--model SpikeInterface/UnitRefine_sua_mua_classifier --cascade --output predictions",
|
|
172
|
+
)
|
|
173
|
+
parser.add_argument("analyzers", nargs="+",
|
|
174
|
+
help="analyzer folders, folders with analyzers inside, or text files with one analyzer per line")
|
|
175
|
+
parser.add_argument("--version", action="version", version=f"unitrefine {__version__}")
|
|
176
|
+
parser.add_argument("--model", action="append", default=[], dest="models",
|
|
177
|
+
help="a model folder or a Hugging Face repo id; give --model again for more models")
|
|
178
|
+
parser.add_argument("--cascade", action="store_true",
|
|
179
|
+
help="apply the two models as a cascade: noise vs neural, then SUA vs MUA on the neural units")
|
|
180
|
+
parser.add_argument("--output", default="unitrefine_predictions", help="folder to save the predictions in")
|
|
181
|
+
parser.add_argument("--models_folder", default=None,
|
|
182
|
+
help=f"where models from the Hugging Face Hub are kept (default: ${MODELS_FOLDER_VARIABLE} "
|
|
183
|
+
f"or {DEFAULT_MODELS_FOLDER})")
|
|
184
|
+
parser.add_argument("--task_index", type=int, default=None,
|
|
185
|
+
help="only label this analyzer, counting from 0, e.g. $SLURM_ARRAY_TASK_ID")
|
|
186
|
+
parser.add_argument("--compute_missing_metrics", action="store_true",
|
|
187
|
+
help="compute the metrics the models need which an analyzer doesn't have, and save them in "
|
|
188
|
+
"the analyzer (needs its recording)")
|
|
189
|
+
parser.add_argument("--n_jobs", type=int, default=1,
|
|
190
|
+
help="processes used to compute metrics, e.g. $SLURM_CPUS_PER_TASK")
|
|
191
|
+
parser.add_argument("--max_depth", type=int, default=3, help="how many folder levels down to look for analyzers")
|
|
192
|
+
parser.add_argument("--list", action="store_true", help="only list the analyzers and their numbers")
|
|
193
|
+
args = parser.parse_args(argv)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
if args.list:
|
|
197
|
+
analyzer_paths = collect_analyzers(args.analyzers, args.max_depth)
|
|
198
|
+
for index, path in enumerate(analyzer_paths):
|
|
199
|
+
print(f"{index}\t{path}")
|
|
200
|
+
print(f"{len(analyzer_paths)} analyzers. For a SLURM job array: --array=0-{len(analyzer_paths) - 1}")
|
|
201
|
+
return 0
|
|
202
|
+
if len(args.models) == 0:
|
|
203
|
+
parser.error("give at least one --model")
|
|
204
|
+
results = apply_models(args.analyzers, args.models, cascade=args.cascade, output_folder=args.output,
|
|
205
|
+
models_folder=args.models_folder, task_index=args.task_index, max_depth=args.max_depth,
|
|
206
|
+
compute_missing_metrics=args.compute_missing_metrics, n_jobs=args.n_jobs)
|
|
207
|
+
except UsageError as e:
|
|
208
|
+
print(f"unitrefine-apply: {e}", file=sys.stderr)
|
|
209
|
+
return 2
|
|
210
|
+
|
|
211
|
+
failed = [result for result in results if "error" in result]
|
|
212
|
+
print(f"\nLabelled {len(results) - len(failed)}/{len(results)} analyzers. Predictions saved in {Path(args.output).resolve()}.")
|
|
213
|
+
for result in failed:
|
|
214
|
+
print(f"Not labelled: {result['path']}: {result['error']}", file=sys.stderr)
|
|
215
|
+
if any("required metrics" in result["error"] for result in failed) and not args.compute_missing_metrics:
|
|
216
|
+
print("To compute the missing metrics and save them in the analyzers, add --compute_missing_metrics.", file=sys.stderr)
|
|
217
|
+
return 1 if len(failed) > 0 else 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def download_main(argv=None):
|
|
221
|
+
parser = argparse.ArgumentParser(
|
|
222
|
+
prog="unitrefine-download-models",
|
|
223
|
+
description="Downloads UnitRefine models from the Hugging Face Hub, e.g. before running unitrefine-apply on "
|
|
224
|
+
"compute nodes without internet. Without repo ids, lists the published UnitRefine models.",
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument("repo_ids", nargs="*", help="Hugging Face repo ids, e.g. SpikeInterface/UnitRefine_sua_mua_classifier")
|
|
227
|
+
parser.add_argument("--models_folder", default=None,
|
|
228
|
+
help=f"where to save them (default: ${MODELS_FOLDER_VARIABLE} or {DEFAULT_MODELS_FOLDER})")
|
|
229
|
+
args = parser.parse_args(argv)
|
|
230
|
+
|
|
231
|
+
if len(args.repo_ids) == 0:
|
|
232
|
+
print("UnitRefine models on the Hugging Face Hub:")
|
|
233
|
+
for group in hfh_model_groups:
|
|
234
|
+
for repo_id, note in group["models"]:
|
|
235
|
+
print(f" {repo_id} {note}".rstrip())
|
|
236
|
+
print("\nDownload them with: unitrefine-download-models REPO_ID [REPO_ID ...]")
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
status = 0
|
|
240
|
+
for repo_id in args.repo_ids:
|
|
241
|
+
try:
|
|
242
|
+
model_folder = resolve_model(repo_id, args.models_folder)
|
|
243
|
+
print(f"{repo_id}: {model_folder}")
|
|
244
|
+
except UsageError as e:
|
|
245
|
+
print(f"unitrefine-download-models: {e}", file=sys.stderr)
|
|
246
|
+
status = 2
|
|
247
|
+
return status
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
sys.exit(apply_main())
|