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/evaluation.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data for the figures: compares models with the human labels they were not trained on, and computes
|
|
3
|
+
SHAP values. Kept free of Qt, so it can be used and tested on its own, and run in a background thread.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from unitrefine.predictions import (
|
|
14
|
+
describe_prediction_error,
|
|
15
|
+
get_model_classes,
|
|
16
|
+
get_saved_or_new_predictions,
|
|
17
|
+
is_remote_path,
|
|
18
|
+
metrics_for_model,
|
|
19
|
+
as_model_input,
|
|
20
|
+
predictions_are_up_to_date,
|
|
21
|
+
map_quality_to_model_class,
|
|
22
|
+
predictions_file_name,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
TRAINED_ON_FILE = "trained_on.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------------------------------
|
|
29
|
+
# which human labels a model was trained on
|
|
30
|
+
# --------------------------------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def normalise_path(path):
|
|
33
|
+
"""Analyzer paths are compared in one form, so the same analyzer added in two ways still matches."""
|
|
34
|
+
if is_remote_path(path):
|
|
35
|
+
return str(path)
|
|
36
|
+
return os.path.normcase(os.path.abspath(str(path)))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_trained_on(model_folder, units_by_analyzer, complete=True):
|
|
40
|
+
"""
|
|
41
|
+
Saves which human-labelled units a model was trained on, as {analyzer path: [unit ids]}.
|
|
42
|
+
`complete` is False when the model also learned from units which are not known (see `read_trained_on`).
|
|
43
|
+
"""
|
|
44
|
+
data = {
|
|
45
|
+
"complete": complete,
|
|
46
|
+
"analyzers": {
|
|
47
|
+
normalise_path(path): sorted(set(str(unit_id) for unit_id in unit_ids))
|
|
48
|
+
for path, unit_ids in units_by_analyzer.items()
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
with open(Path(model_folder) / TRAINED_ON_FILE, "w") as f:
|
|
52
|
+
json.dump(data, f, indent=4)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_trained_on(model_folder):
|
|
56
|
+
"""
|
|
57
|
+
Returns ({analyzer path: set of unit ids}, status), where status is
|
|
58
|
+
"recorded": trained in UnitRefine, the units are known
|
|
59
|
+
"downloaded": a model from the Hugging Face Hub, not trained on any of the user's labels
|
|
60
|
+
"unknown": trained before UnitRefine recorded this; assumed trained on all curated labels
|
|
61
|
+
"""
|
|
62
|
+
model_folder = Path(model_folder)
|
|
63
|
+
trained_on_path = model_folder / TRAINED_ON_FILE
|
|
64
|
+
if trained_on_path.is_file():
|
|
65
|
+
with open(trained_on_path) as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
units = {path: set(unit_ids) for path, unit_ids in data["analyzers"].items()}
|
|
68
|
+
return units, ("recorded" if data.get("complete", True) else "unknown")
|
|
69
|
+
if (model_folder / "hfh_path.txt").is_file():
|
|
70
|
+
return {}, "downloaded"
|
|
71
|
+
return {}, "unknown"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def units_used_to_train(model_folder, targets):
|
|
75
|
+
"""
|
|
76
|
+
The (analyzer path, unit id) pairs a model was trained on. For models with an unknown history,
|
|
77
|
+
all curated (original) labels of the project's analyzers are assumed to have been used.
|
|
78
|
+
"""
|
|
79
|
+
units, status = read_trained_on(model_folder)
|
|
80
|
+
used = {(path, unit_id) for path, unit_ids in units.items() for unit_id in unit_ids}
|
|
81
|
+
if status == "unknown":
|
|
82
|
+
for target in targets:
|
|
83
|
+
if target["analyzer_index"] is not None:
|
|
84
|
+
for unit_id in original_labels(target):
|
|
85
|
+
used.add((normalise_path(target["path"]), unit_id))
|
|
86
|
+
return used, status
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------------------------------
|
|
90
|
+
# human labels
|
|
91
|
+
# --------------------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def original_labels(target):
|
|
94
|
+
"""
|
|
95
|
+
The curated or imported labels of an analyzer, as {unit id: label}: for analyzers in the project (section 2),
|
|
96
|
+
and labels imported for new analyzers in section 3.3, which models are never trained on.
|
|
97
|
+
"""
|
|
98
|
+
labels_path = Path(target["save_folder"]) / f"{target['file_prefix']}labels.csv"
|
|
99
|
+
if not labels_path.is_file():
|
|
100
|
+
return {}
|
|
101
|
+
labels = pd.read_csv(labels_path)
|
|
102
|
+
return dict(zip(labels["unit_id"].astype(str), labels["quality"]))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def human_labels(target):
|
|
106
|
+
"""
|
|
107
|
+
All human labels of an analyzer, as {unit id: label}: the curated or imported labels, overwritten by
|
|
108
|
+
the units relabelled later (with any model selection; the most recent relabel wins).
|
|
109
|
+
"""
|
|
110
|
+
labels = original_labels(target)
|
|
111
|
+
relabel_files = Path(target["save_folder"]).glob(f"{target['file_prefix']}relabelled_units_*.csv")
|
|
112
|
+
for relabel_file in sorted(relabel_files, key=lambda path: path.stat().st_mtime):
|
|
113
|
+
relabels = pd.read_csv(relabel_file)
|
|
114
|
+
labels.update(dict(zip(relabels["unit_id"].astype(str), relabels["quality"])))
|
|
115
|
+
return {unit_id: label for unit_id, label in labels.items() if isinstance(label, str)}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------------------------------
|
|
119
|
+
# comparing models with human labels
|
|
120
|
+
# --------------------------------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
# predictions already looked up, keyed by the analyzer, the models and the saved predictions file's
|
|
123
|
+
# modification time, so they are looked up again when the file changes
|
|
124
|
+
_predictions_cache = {}
|
|
125
|
+
|
|
126
|
+
# analyzers which could not be labelled while making a figure, as {path: reason}; cleared for each figure
|
|
127
|
+
skipped_analyzers = {}
|
|
128
|
+
|
|
129
|
+
# called with a short message about what is being computed, so a slow figure can say what it is doing
|
|
130
|
+
_progress_callback = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def set_progress_callback(callback):
|
|
134
|
+
global _progress_callback
|
|
135
|
+
_progress_callback = callback
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def report_progress(message):
|
|
139
|
+
if _progress_callback is not None:
|
|
140
|
+
_progress_callback(message)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def skipped_key(target, model_folders):
|
|
144
|
+
"""How a skipped analyzer is listed: its path and the model(s) which could not label it."""
|
|
145
|
+
return f"{target['path']} (model: {', '.join(Path(model_folder).name for model_folder in model_folders)})"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def target_predictions(target, mode, model_folders, key):
|
|
149
|
+
"""
|
|
150
|
+
Predictions of a model selection for one analyzer, reusing the saved ones when up to date.
|
|
151
|
+
Returns the label and confidence columns (see `get_predictions`) and the analyzer's unit ids as strings,
|
|
152
|
+
which the confidence arrays are aligned with.
|
|
153
|
+
"""
|
|
154
|
+
import spikeinterface.full as si
|
|
155
|
+
|
|
156
|
+
predictions_path = Path(target["save_folder"]) / predictions_file_name(target["file_prefix"], key)
|
|
157
|
+
cache_key = (
|
|
158
|
+
str(target["path"]), mode, tuple(str(model_folder) for model_folder in model_folders), key,
|
|
159
|
+
file_modification_time(predictions_path),
|
|
160
|
+
)
|
|
161
|
+
if cache_key not in _predictions_cache:
|
|
162
|
+
analyzer_name = Path(str(target["path"])).name
|
|
163
|
+
if predictions_are_up_to_date(predictions_path, model_folders, target["path"]):
|
|
164
|
+
report_progress(f"Reading the saved predictions of {analyzer_name}")
|
|
165
|
+
else:
|
|
166
|
+
model_names = ", ".join(Path(model_folder).name for model_folder in model_folders)
|
|
167
|
+
report_progress(f"Applying {model_names} to {analyzer_name} (done once, then saved)")
|
|
168
|
+
sorting_analyzer = si.load_sorting_analyzer(target["path"], load_extensions=False)
|
|
169
|
+
label_columns, confidence_columns, _ = get_saved_or_new_predictions(
|
|
170
|
+
sorting_analyzer, target["path"], mode, model_folders, predictions_path
|
|
171
|
+
)
|
|
172
|
+
# keyed by the file as it is now, which is new if the predictions were just made
|
|
173
|
+
cache_key = cache_key[:-1] + (file_modification_time(predictions_path),)
|
|
174
|
+
_predictions_cache[cache_key] = (label_columns, confidence_columns, [str(unit_id) for unit_id in sorting_analyzer.unit_ids])
|
|
175
|
+
return _predictions_cache[cache_key]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def file_modification_time(path):
|
|
179
|
+
try:
|
|
180
|
+
return os.stat(path).st_mtime_ns
|
|
181
|
+
except OSError:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def evaluate(targets, mode, model_folders, key, prediction_column, classes, excluded_units):
|
|
186
|
+
"""
|
|
187
|
+
Compares the predictions in `prediction_column` with the human labels of all targets, leaving out
|
|
188
|
+
the `excluded_units` (the ones the model was trained on). Human labels are mapped to the model's
|
|
189
|
+
`classes` (e.g. MUA -> neural); labels the model doesn't use are skipped.
|
|
190
|
+
|
|
191
|
+
Returns a DataFrame with one row per compared unit: analyzer, unit_id, human (in the model's
|
|
192
|
+
classes) and predicted. Analyzers which can't be predicted (e.g. missing metrics) are skipped and
|
|
193
|
+
recorded in `skipped_analyzers`.
|
|
194
|
+
"""
|
|
195
|
+
rows = []
|
|
196
|
+
for target in targets:
|
|
197
|
+
labels = human_labels(target)
|
|
198
|
+
analyzer_path = normalise_path(target["path"])
|
|
199
|
+
labels = {unit_id: label for unit_id, label in labels.items() if (analyzer_path, unit_id) not in excluded_units}
|
|
200
|
+
if len(labels) == 0:
|
|
201
|
+
continue
|
|
202
|
+
try:
|
|
203
|
+
label_columns, _, _ = target_predictions(target, mode, model_folders, key)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
skipped_analyzers[skipped_key(target, model_folders)] = describe_prediction_error(e)
|
|
206
|
+
continue
|
|
207
|
+
predictions = label_columns[prediction_column]
|
|
208
|
+
predictions_by_unit = dict(zip(predictions.index.astype(str), predictions.values))
|
|
209
|
+
for unit_id, label in labels.items():
|
|
210
|
+
human_class = map_quality_to_model_class(label, classes)
|
|
211
|
+
if human_class is None or unit_id not in predictions_by_unit:
|
|
212
|
+
continue
|
|
213
|
+
rows.append(dict(analyzer=target["path"], unit_id=unit_id, human=human_class, predicted=predictions_by_unit[unit_id]))
|
|
214
|
+
return pd.DataFrame(rows, columns=["analyzer", "unit_id", "human", "predicted"])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def selection_evaluations(targets, mode, model_folders, key):
|
|
218
|
+
"""
|
|
219
|
+
One evaluation per model of the selection, plus the combined cascade for a cascade. Each is a dict
|
|
220
|
+
with `name`, `classes`, `comparison` (from `evaluate`), the trained-on `status`, the number of
|
|
221
|
+
human-labelled units left out because the model was trained on them, and the number of human
|
|
222
|
+
labels it could be compared with (`num_candidates`; if the comparison is empty but this is not,
|
|
223
|
+
the model could not label the analyzers).
|
|
224
|
+
"""
|
|
225
|
+
model_folders = [Path(model_folder) for model_folder in model_folders]
|
|
226
|
+
evaluations = []
|
|
227
|
+
|
|
228
|
+
def add(name, run_mode, run_model_folders, run_key, prediction_column, classes, excluded, status):
|
|
229
|
+
comparison = evaluate(targets, run_mode, run_model_folders, run_key, prediction_column, classes, excluded)
|
|
230
|
+
num_left_out, num_candidates = 0, 0
|
|
231
|
+
for target in targets:
|
|
232
|
+
analyzer_path = normalise_path(target["path"])
|
|
233
|
+
for unit_id, label in human_labels(target).items():
|
|
234
|
+
if (analyzer_path, unit_id) in excluded:
|
|
235
|
+
num_left_out += 1
|
|
236
|
+
elif map_quality_to_model_class(label, classes) is not None:
|
|
237
|
+
num_candidates += 1
|
|
238
|
+
evaluations.append(dict(
|
|
239
|
+
name=name, classes=list(classes), comparison=comparison, status=status,
|
|
240
|
+
num_left_out=num_left_out, num_candidates=num_candidates,
|
|
241
|
+
))
|
|
242
|
+
|
|
243
|
+
trained = {model_folder: units_used_to_train(model_folder, targets) for model_folder in model_folders}
|
|
244
|
+
|
|
245
|
+
if mode == "cascade":
|
|
246
|
+
layer1, layer2 = model_folders
|
|
247
|
+
excluded = trained[layer1][0] | trained[layer2][0]
|
|
248
|
+
statuses = {trained[layer1][1], trained[layer2][1]}
|
|
249
|
+
status = "unknown" if "unknown" in statuses else ("recorded" if "recorded" in statuses else "downloaded")
|
|
250
|
+
add("cascade", mode, model_folders, key, "cascade_model_label", ["good", "MUA", "noise"], excluded, status)
|
|
251
|
+
add(f"L1 {layer1.name}", mode, model_folders, key, f"L1_{layer1.name}_model_label", get_model_classes(layer1), *trained[layer1])
|
|
252
|
+
add(f"L2 {layer2.name}", mode, model_folders, key, f"L2_{layer2.name}_model_label", get_model_classes(layer2), *trained[layer2])
|
|
253
|
+
else:
|
|
254
|
+
# each binary model is evaluated on its own, so a model which can't label an analyzer
|
|
255
|
+
# (e.g. it needs metrics the analyzer doesn't have) doesn't stop the others
|
|
256
|
+
for model_folder in model_folders:
|
|
257
|
+
add(
|
|
258
|
+
model_folder.name, "binary", [model_folder], model_folder.name, f"{model_folder.name}_model_label",
|
|
259
|
+
get_model_classes(model_folder), *trained[model_folder],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return evaluations
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def lineage_evaluations(targets, lineage_folders):
|
|
266
|
+
"""
|
|
267
|
+
Evaluates each model of a retraining lineage (a model and its retrained versions, in order) on the
|
|
268
|
+
same units: the human labels none of them were trained on, so the rounds can be compared.
|
|
269
|
+
"""
|
|
270
|
+
excluded = set()
|
|
271
|
+
statuses = []
|
|
272
|
+
for model_folder in lineage_folders:
|
|
273
|
+
used, status = units_used_to_train(model_folder, targets)
|
|
274
|
+
excluded |= used
|
|
275
|
+
statuses.append(status)
|
|
276
|
+
|
|
277
|
+
results = []
|
|
278
|
+
for model_folder in lineage_folders:
|
|
279
|
+
model_folder = Path(model_folder)
|
|
280
|
+
comparison = evaluate(
|
|
281
|
+
targets, "binary", [model_folder], model_folder.name, f"{model_folder.name}_model_label",
|
|
282
|
+
get_model_classes(model_folder), excluded,
|
|
283
|
+
)
|
|
284
|
+
results.append(dict(name=model_folder.name, comparison=comparison))
|
|
285
|
+
return results, statuses
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def scores(comparison):
|
|
289
|
+
"""Balanced accuracy, macro precision and macro recall of a comparison, or None if it is empty."""
|
|
290
|
+
from sklearn.metrics import balanced_accuracy_score, precision_score, recall_score
|
|
291
|
+
|
|
292
|
+
if len(comparison) == 0:
|
|
293
|
+
return None
|
|
294
|
+
y_true, y_pred = comparison["human"].values, comparison["predicted"].values
|
|
295
|
+
return dict(
|
|
296
|
+
balanced_accuracy=balanced_accuracy_score(y_true, y_pred),
|
|
297
|
+
precision=precision_score(y_true, y_pred, average="macro", zero_division=0),
|
|
298
|
+
recall=recall_score(y_true, y_pred, average="macro", zero_division=0),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# --------------------------------------------------------------------------------------------------
|
|
303
|
+
# confidence
|
|
304
|
+
# --------------------------------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def confidence_table(targets, mode, model_folders, key):
|
|
307
|
+
"""
|
|
308
|
+
The predicted label and confidence of every unit of every analyzer, for each label column of the
|
|
309
|
+
selection, with whether the unit was picked for relabelling. Returns {column: DataFrame}.
|
|
310
|
+
"""
|
|
311
|
+
from unitrefine.predictions import units_to_relabel_path
|
|
312
|
+
|
|
313
|
+
# a cascade is predicted as a whole; binary models one by one, so one failing model doesn't stop the others
|
|
314
|
+
if mode == "cascade":
|
|
315
|
+
runs = [(mode, model_folders, key)]
|
|
316
|
+
else:
|
|
317
|
+
runs = [("binary", [model_folder], Path(model_folder).name) for model_folder in model_folders]
|
|
318
|
+
|
|
319
|
+
tables = {}
|
|
320
|
+
for target in targets:
|
|
321
|
+
# the units picked for relabelling belong to the whole selection
|
|
322
|
+
picked = set()
|
|
323
|
+
if units_to_relabel_path(target, key).is_file():
|
|
324
|
+
picked = set(pd.read_csv(units_to_relabel_path(target, key))["unit_id"].astype(str))
|
|
325
|
+
for run_mode, run_model_folders, run_key in runs:
|
|
326
|
+
try:
|
|
327
|
+
label_columns, confidence_columns, analyzer_unit_ids = target_predictions(target, run_mode, run_model_folders, run_key)
|
|
328
|
+
except Exception as e:
|
|
329
|
+
skipped_analyzers[skipped_key(target, run_model_folders)] = describe_prediction_error(e)
|
|
330
|
+
continue
|
|
331
|
+
# confidences are aligned with the analyzer's units; labels can cover fewer units (cascade layer 2)
|
|
332
|
+
positions = {unit_id: position for position, unit_id in enumerate(analyzer_unit_ids)}
|
|
333
|
+
for label_column, labels in label_columns.items():
|
|
334
|
+
confidences = confidence_columns[label_column.replace("_model_label", "_confidence")]
|
|
335
|
+
unit_ids = [str(unit_id) for unit_id in labels.index]
|
|
336
|
+
rows = pd.DataFrame(dict(
|
|
337
|
+
analyzer=target["path"],
|
|
338
|
+
unit_id=unit_ids,
|
|
339
|
+
predicted=labels.values,
|
|
340
|
+
confidence=[confidences[positions[unit_id]] for unit_id in unit_ids],
|
|
341
|
+
picked=[unit_id in picked for unit_id in unit_ids],
|
|
342
|
+
))
|
|
343
|
+
tables.setdefault(label_column, []).append(rows)
|
|
344
|
+
return {column: pd.concat(rows, ignore_index=True) for column, rows in tables.items()}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# --------------------------------------------------------------------------------------------------
|
|
348
|
+
# SHAP
|
|
349
|
+
# --------------------------------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def target_metrics(target):
|
|
352
|
+
"""The quality and template metrics of an analyzer, indexed by unit id (as strings)."""
|
|
353
|
+
import spikeinterface.full as si
|
|
354
|
+
from unitrefine.import_labels import compute_all_metrics
|
|
355
|
+
|
|
356
|
+
metrics_path = Path(target["save_folder"]) / f"{target['file_prefix']}all_metrics.csv"
|
|
357
|
+
if metrics_path.is_file():
|
|
358
|
+
metrics = pd.read_csv(metrics_path, index_col="unit_id")
|
|
359
|
+
else:
|
|
360
|
+
metrics = compute_all_metrics(si.load_sorting_analyzer(target["path"], load_extensions=False))
|
|
361
|
+
metrics.index = metrics.index.astype(str)
|
|
362
|
+
return metrics
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def recording_metrics(model_folder, targets):
|
|
366
|
+
"""
|
|
367
|
+
The metrics a model uses, for all units of the analyzers which have them all, indexed by
|
|
368
|
+
(analyzer path, unit id).
|
|
369
|
+
"""
|
|
370
|
+
from spikeinterface.curation.model_based_curation import load_model
|
|
371
|
+
|
|
372
|
+
pipeline, model_info = load_model(model_folder=model_folder, trust_model=True)
|
|
373
|
+
metric_names = list(pipeline.feature_names_in_)
|
|
374
|
+
|
|
375
|
+
metrics, paths = [], []
|
|
376
|
+
for target in targets:
|
|
377
|
+
report_progress(f"Reading the metrics of {Path(str(target['path'])).name}")
|
|
378
|
+
try:
|
|
379
|
+
# with the metric names of the SpikeInterface version the model was trained with
|
|
380
|
+
target_metric_table = metrics_for_model(target_metrics(target), model_info)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
skipped_analyzers[skipped_key(target, [model_folder])] = str(e)
|
|
383
|
+
continue
|
|
384
|
+
missing_metrics = [metric_name for metric_name in metric_names if metric_name not in target_metric_table.columns]
|
|
385
|
+
if len(missing_metrics) > 0:
|
|
386
|
+
skipped_analyzers[skipped_key(target, [model_folder])] = f"missing metrics used by the model: {missing_metrics}"
|
|
387
|
+
continue
|
|
388
|
+
metrics.append(as_model_input(target_metric_table[metric_names]))
|
|
389
|
+
paths.append(str(target["path"]))
|
|
390
|
+
if len(metrics) == 0:
|
|
391
|
+
raise ValueError("None of the analyzers have all the metrics this model uses.")
|
|
392
|
+
return pd.concat(metrics, keys=paths)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def training_metrics(model_folder):
|
|
396
|
+
"""The metrics of the units a model was trained on (its training_data.csv), in the model's metric order."""
|
|
397
|
+
from spikeinterface.curation.model_based_curation import load_model
|
|
398
|
+
|
|
399
|
+
pipeline, _ = load_model(model_folder=model_folder, trust_model=True)
|
|
400
|
+
training_data = pd.read_csv(Path(model_folder) / "training_data.csv")
|
|
401
|
+
return as_model_input(training_data[list(pipeline.feature_names_in_)])
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def preferred_class(classes):
|
|
405
|
+
"""The class SHAP plots explain by default: the one most people care about."""
|
|
406
|
+
for preferred in ["good", "sua", "SUA", "neural"]:
|
|
407
|
+
if preferred in classes:
|
|
408
|
+
return preferred
|
|
409
|
+
return list(classes)[0]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def shap_explanation(model_folder, metrics, class_name, max_units=300, seed=0):
|
|
413
|
+
"""
|
|
414
|
+
SHAP values of a model for one of its classes, on (up to `max_units`) units with the given `metrics`
|
|
415
|
+
(a table with the model's metrics as columns). Returns a shap.Explanation whose data are the metric
|
|
416
|
+
values after imputation and before scaling, so plots show real metric values.
|
|
417
|
+
"""
|
|
418
|
+
import shap
|
|
419
|
+
from spikeinterface.curation.model_based_curation import load_model
|
|
420
|
+
|
|
421
|
+
pipeline, model_info = load_model(model_folder=model_folder, trust_model=True)
|
|
422
|
+
metric_names = list(pipeline.feature_names_in_)
|
|
423
|
+
metrics = metrics[metric_names]
|
|
424
|
+
if len(metrics) > max_units:
|
|
425
|
+
metrics = metrics.sample(max_units, random_state=seed)
|
|
426
|
+
|
|
427
|
+
report_progress(f"Computing SHAP values of {Path(model_folder).name} on {len(metrics)} units")
|
|
428
|
+
imputed = pipeline["imputer"].transform(metrics)
|
|
429
|
+
scaled = pipeline["scaler"].transform(imputed)
|
|
430
|
+
classifier = pipeline["classifier"]
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
values = shap.TreeExplainer(classifier)(scaled, check_additivity=False).values
|
|
434
|
+
except Exception:
|
|
435
|
+
try:
|
|
436
|
+
values = shap.LinearExplainer(classifier, scaled)(scaled).values
|
|
437
|
+
except Exception:
|
|
438
|
+
# other models: explain the predicted probabilities with a small background, on fewer units
|
|
439
|
+
background = shap.kmeans(scaled, min(20, len(scaled)))
|
|
440
|
+
explainer = shap.KernelExplainer(classifier.predict_proba, background)
|
|
441
|
+
raw_values = explainer.shap_values(scaled[:100], nsamples=100, silent=True)
|
|
442
|
+
imputed = imputed[:100]
|
|
443
|
+
values = np.stack(raw_values, axis=-1) if isinstance(raw_values, list) else np.asarray(raw_values)
|
|
444
|
+
|
|
445
|
+
# the classifier's outputs follow its classes_, which are the label codes of label_conversion
|
|
446
|
+
label_conversion = model_info["label_conversion"]
|
|
447
|
+
class_code = [int(code) for code, name in label_conversion.items() if name == class_name][0]
|
|
448
|
+
class_position = [int(code) for code in classifier.classes_].index(class_code)
|
|
449
|
+
if values.ndim == 3:
|
|
450
|
+
class_values = values[:, :, class_position]
|
|
451
|
+
elif class_position == 0:
|
|
452
|
+
# single output models explain the second class; flip the sign for the first
|
|
453
|
+
class_values = -values
|
|
454
|
+
else:
|
|
455
|
+
class_values = values
|
|
456
|
+
|
|
457
|
+
return shap.Explanation(values=class_values, data=np.asarray(imputed), feature_names=metric_names)
|