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
@@ -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)