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/figures.py ADDED
@@ -0,0 +1,1458 @@
1
+ """
2
+ The figures shown in sections 2, 3 and 4 of the main window.
3
+
4
+ Each figure is described by a `FigureSpec`: how to make it, where to save it, and which files it is made
5
+ from. A saved figure is reused while those files don't change, so the window opens quickly; otherwise it
6
+ is made again in a background thread. Figures are drawn with matplotlib's object oriented API (no pyplot),
7
+ so they can be made outside the main thread. Kept free of Qt.
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import threading
14
+ import time
15
+ import traceback
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ import matplotlib
20
+ import numpy as np
21
+ import pandas as pd
22
+ from matplotlib import font_manager
23
+ from matplotlib.figure import Figure
24
+
25
+ from unitrefine import evaluation, unwritable_file_message
26
+ from unitrefine.predictions import (
27
+ get_model_classes,
28
+ map_quality_to_model_class,
29
+ predictions_file_name,
30
+ relabelled_units_path,
31
+ selection_confidence_from_table,
32
+ selection_key,
33
+ units_to_relabel_path,
34
+ load_or_compute_predictions,
35
+ )
36
+
37
+ # saved figures made with an older version of this file are made again
38
+ FIGURES_VERSION = 8
39
+
40
+ FIGURES_FOLDER = "figures"
41
+ TEST_PREDICTIONS_FILE = "test_predictions.csv"
42
+
43
+ # the same colour for a class in every figure
44
+ CLASS_COLOURS = {
45
+ "good": "#12B76A", "sua": "#12B76A", "SUA": "#12B76A",
46
+ "MUA": "#F79009", "mua": "#F79009",
47
+ "noise": "#98A2B3",
48
+ "neural": "#2E90FA",
49
+ "not-sua": "#9E77ED",
50
+ }
51
+ CLASS_ORDER = ["good", "sua", "SUA", "neural", "MUA", "mua", "not-sua", "noise"]
52
+ HUMAN_LABELS = ["good", "MUA", "noise"]
53
+
54
+ STATUS_TEXT = {
55
+ "downloaded": "a downloaded model, never trained on your labels, so it is compared with all your labels",
56
+ "recorded": "trained in UnitRefine; the labels it was trained on are left out",
57
+ "unknown": "trained before UnitRefine recorded what models learn from, so all your curated labels are "
58
+ "assumed to have been used and are left out",
59
+ }
60
+
61
+
62
+ def use_app_font():
63
+ """Figures use the GUI's font (Inter, in resources/fonts) and a light, modern style."""
64
+ fonts_folder = Path(__file__).absolute().parent / "resources" / "fonts"
65
+ for font_file in fonts_folder.glob("*.ttf"):
66
+ try:
67
+ font_manager.fontManager.addfont(str(font_file))
68
+ except Exception:
69
+ pass
70
+ families = {font.name for font in font_manager.fontManager.ttflist}
71
+ matplotlib.rcParams.update({
72
+ "font.family": ["Inter" if "Inter" in families else "DejaVu Sans", "DejaVu Sans"],
73
+ "font.size": 9,
74
+ "axes.titlesize": 10,
75
+ "axes.titleweight": "bold",
76
+ "axes.titlelocation": "left",
77
+ "axes.labelsize": 9,
78
+ "axes.edgecolor": "#98A2B3",
79
+ "axes.labelcolor": "#344054",
80
+ "axes.spines.top": False,
81
+ "axes.spines.right": False,
82
+ "text.color": "#1D2939",
83
+ "xtick.color": "#475467",
84
+ "ytick.color": "#475467",
85
+ "legend.frameon": False,
86
+ "figure.facecolor": "white",
87
+ "savefig.facecolor": "white",
88
+ })
89
+
90
+
91
+ use_app_font()
92
+
93
+
94
+ class NoData(Exception):
95
+ """The figure can't be made yet; the message says what to do first. Shown as a hint, not an error."""
96
+
97
+
98
+ @dataclass
99
+ class FigureSpec:
100
+ key: str # unique within a section
101
+ title: str
102
+ path: Path # where the png is saved
103
+ make: object # make() -> (Figure, [notes]); raises NoData
104
+ inputs: object # inputs() -> the files the figure is made from
105
+ caption: str = "" # how to read the figure
106
+ params: str = "" # anything else it depends on
107
+ slow: bool = False # takes a few seconds (e.g. SHAP)
108
+ analyzers: tuple = () # the analyzers in the order they are listed in the GUI, which numbers them
109
+
110
+
111
+ # --------------------------------------------------------------------------------------------------
112
+ # saving, and reusing saved figures
113
+ # --------------------------------------------------------------------------------------------------
114
+
115
+ def file_state(path):
116
+ try:
117
+ stat = os.stat(path)
118
+ return [str(path), stat.st_mtime_ns, stat.st_size]
119
+ except OSError:
120
+ return [str(path), None]
121
+
122
+
123
+ def signature(spec):
124
+ """Changes whenever a file the figure is made from changes, or the figure's code changes."""
125
+ state = [FIGURES_VERSION, spec.params, [file_state(path) for path in spec.inputs()]]
126
+ return hashlib.md5(json.dumps(state, default=str).encode()).hexdigest()
127
+
128
+
129
+ def sidecar_path(spec):
130
+ return spec.path.with_suffix(".json")
131
+
132
+
133
+ def cached_result(spec):
134
+ """The saved result of a figure if it is still up to date, else None."""
135
+ try:
136
+ with open(sidecar_path(spec)) as f:
137
+ result = json.load(f)
138
+ except (OSError, ValueError):
139
+ return None
140
+ if result.get("signature") != signature(spec):
141
+ return None
142
+ # errors may be passing (e.g. a file being written), so a figure which failed is always made again
143
+ if result.get("status") == "error":
144
+ return None
145
+ if result.get("status") == "done" and not spec.path.is_file():
146
+ return None
147
+ return result
148
+
149
+
150
+ # --------------------------------------------------------------------------------------------------
151
+ # where a figure's data comes from: printed at the bottom of every figure
152
+ # --------------------------------------------------------------------------------------------------
153
+
154
+ # the analyzers and models the figure being made uses; reset for each figure (figures are made one at a time)
155
+ _provenance = dict(data={}, models=[], numbering=[])
156
+
157
+ # above this many analyzers, dots are numbered (the numbers are listed at the bottom) instead of named
158
+ MAX_NAMED_DOTS = 8
159
+
160
+
161
+ def reset_provenance(analyzers=()):
162
+ """`analyzers` number the analyzers (1, 2, ...), so an analyzer has the same number in every figure."""
163
+ _provenance["data"] = {}
164
+ _provenance["models"] = []
165
+ _provenance["numbering"] = [evaluation.normalise_path(path) for path in analyzers]
166
+
167
+
168
+ def target_group(target):
169
+ """Analyzers in the project (section 2) are used for training; the ones in 3.3 are new."""
170
+ return "training" if target["analyzer_index"] is not None else "new"
171
+
172
+
173
+ def note_data(path, group, num_units):
174
+ """
175
+ Records that the figure uses `num_units` units of the analyzer at `path` ("training" or "new"), and
176
+ returns the analyzer's number, which the bottom of the figure lists and dots can be labelled with.
177
+ """
178
+ data = _provenance["data"]
179
+ key = evaluation.normalise_path(path)
180
+ if key not in data:
181
+ numbering = _provenance["numbering"]
182
+ if key in numbering:
183
+ number = numbering.index(key) + 1
184
+ else:
185
+ # e.g. an analyzer a model was trained on which is no longer in the project
186
+ number = max([len(numbering)] + [info["number"] for info in data.values()]) + 1
187
+ data[key] = dict(path=str(path), group=group, units=0, number=number)
188
+ data[key]["units"] = max(data[key]["units"], int(num_units))
189
+ return data[key]["number"]
190
+
191
+
192
+ def data_number(path):
193
+ return _provenance["data"][evaluation.normalise_path(path)]["number"]
194
+
195
+
196
+ def note_models(*names):
197
+ for name in names:
198
+ if name not in _provenance["models"]:
199
+ _provenance["models"].append(name)
200
+
201
+
202
+ def note_selection(mode, model_folders):
203
+ names = [Path(model_folder).name for model_folder in model_folders]
204
+ if mode == "cascade":
205
+ note_models(f"cascade of {names[0]} (layer 1) and {names[1]} (layer 2)")
206
+ else:
207
+ note_models(*names)
208
+
209
+
210
+ def add_footer(figure):
211
+ """Writes at the bottom of the figure which analyzers (numbered) and models it comes from."""
212
+ import textwrap
213
+
214
+ data, models = _provenance["data"], _provenance["models"]
215
+ parts = []
216
+ for group, title in [("training", "Analyzers used for training"), ("new", "New analyzers")]:
217
+ items = [f"[{info['number']}] {analyzer_name(info['path'])} ({info['units']} units)"
218
+ for info in sorted(data.values(), key=lambda info: info["number"]) if info["group"] == group]
219
+ if len(items) > 0:
220
+ parts.append(f"{title}: " + ", ".join(items))
221
+ if len(models) > 0:
222
+ parts.append(("Model: " if len(models) == 1 else "Models: ") + ", ".join(models))
223
+ if len(parts) == 0:
224
+ return
225
+ width = max(60, int(figure.get_figwidth() * 15))
226
+ text = "\n".join(textwrap.fill(part, width) for part in parts)
227
+ # a figure-level label, so the figure's layout makes room for it
228
+ figure.supxlabel(text, x=0.005, ha="left", fontsize=7.5, color="#475467", fontweight="normal", linespacing=1.5)
229
+
230
+
231
+ def label_dot(ax, x, y, path, num_analyzers, colour="#1D2939", horizontal=False):
232
+ """Labels an analyzer's dot: its name when there are few analyzers, else its number (listed at the bottom)."""
233
+ number = data_number(path)
234
+ if num_analyzers <= MAX_NAMED_DOTS:
235
+ ax.annotate(f"[{number}] {short(analyzer_name(path), 22)}", (x, y), textcoords="offset points", xytext=(7, 0),
236
+ va="center", fontsize=6.5, color=colour, zorder=5,
237
+ bbox=dict(boxstyle="round,pad=0.15", facecolor="white", edgecolor="none", alpha=0.85))
238
+ else:
239
+ ax.annotate(str(number), (x, y), textcoords="offset points", xytext=(5, 3), fontsize=6.5, color=colour, zorder=5,
240
+ bbox=dict(boxstyle="round,pad=0.1", facecolor="white", edgecolor="none", alpha=0.85))
241
+
242
+
243
+ def skipped_notes():
244
+ return [f"Left out {path}: {reason}" for path, reason in evaluation.skipped_analyzers.items()]
245
+
246
+
247
+ # a figure records what it uses in module-level state (_provenance, evaluation.skipped_analyzers), which each
248
+ # figure starts by resetting: figures are made one at a time, also when several threads make them (e.g. the
249
+ # figures thread of a window, and a test)
250
+ RENDER_LOCK = threading.RLock()
251
+
252
+
253
+ def render(spec, progress=None):
254
+ """
255
+ Makes and saves a figure. Returns a dict with the `status` (done, nodata or error), `notes` about
256
+ the data (e.g. analyzers left out), a `message` for nodata and error, and the `seconds` it took.
257
+ """
258
+ with RENDER_LOCK:
259
+ return render_unlocked(spec, progress)
260
+
261
+
262
+ def render_unlocked(spec, progress=None):
263
+ start_time = time.time()
264
+ evaluation.skipped_analyzers.clear()
265
+ reset_provenance(spec.analyzers)
266
+ evaluation.set_progress_callback(progress)
267
+ try:
268
+ spec.path.parent.mkdir(parents=True, exist_ok=True)
269
+ figure, notes = spec.make()
270
+ add_footer(figure)
271
+ temporary_path = spec.path.with_name(spec.path.stem + ".tmp.png")
272
+ # high enough resolution to stay sharp when zoomed in the viewer
273
+ figure.savefig(temporary_path, dpi=200)
274
+ os.replace(temporary_path, spec.path)
275
+ result = dict(status="done", notes=list(notes) + skipped_notes())
276
+ except NoData as e:
277
+ # don't leave an out of date figure behind
278
+ try:
279
+ spec.path.unlink(missing_ok=True)
280
+ except OSError:
281
+ pass
282
+ result = dict(status="nodata", message=str(e), notes=skipped_notes())
283
+ except PermissionError as e:
284
+ # e.g. a read-only file in the figures folder: say how to fix it
285
+ result = dict(status="error", message=unwritable_file_message(e.filename or spec.path, e), notes=skipped_notes())
286
+ except Exception as e:
287
+ traceback.print_exc()
288
+ result = dict(status="error", message=f"{type(e).__name__}: {e}", notes=skipped_notes())
289
+ finally:
290
+ evaluation.set_progress_callback(None)
291
+ result["seconds"] = round(time.time() - start_time, 1)
292
+ result["signature"] = signature(spec)
293
+ result["title"] = spec.title
294
+ try:
295
+ with open(sidecar_path(spec), "w") as f:
296
+ json.dump(result, f, indent=4)
297
+ except OSError as e:
298
+ # the figure is still shown; it is made again next time, as its record could not be saved
299
+ print(unwritable_file_message(sidecar_path(spec), e))
300
+ return result
301
+
302
+
303
+ # --------------------------------------------------------------------------------------------------
304
+ # drawing helpers
305
+ # --------------------------------------------------------------------------------------------------
306
+
307
+ def new_figure(width, height):
308
+ return Figure(figsize=(width, height), layout="constrained")
309
+
310
+
311
+ def ordered_classes(classes):
312
+ classes = list(dict.fromkeys(classes))
313
+ return sorted(classes, key=lambda name: (CLASS_ORDER.index(name) if name in CLASS_ORDER else len(CLASS_ORDER), str(name)))
314
+
315
+
316
+ def class_colour(class_name, index=0):
317
+ return CLASS_COLOURS.get(class_name, matplotlib.colormaps["tab10"](index % 10))
318
+
319
+
320
+ def short(text, length=38):
321
+ text = str(text)
322
+ return text if len(text) <= length else text[: length - 1] + "…"
323
+
324
+
325
+ def short_file_name(name, length=32):
326
+ """File and folder names short enough to keep figure paths under Windows' 260 character limit."""
327
+ name = str(name)
328
+ if len(name) <= length:
329
+ return name
330
+ return f"{name[: length - 9]}_{hashlib.md5(name.encode()).hexdigest()[:8]}"
331
+
332
+
333
+ def analyzer_name(path):
334
+ """Analyzer folders are often all called e.g. sorting_analyzer, so their parent folder is shown too."""
335
+ parts = Path(str(path).rstrip("/\\")).parts
336
+ return short("/".join(parts[-2:]), 34)
337
+
338
+
339
+ def message_axes(ax, text):
340
+ ax.text(0.5, 0.5, text, ha="center", va="center", color="#667085", wrap=True)
341
+ ax.axis("off")
342
+
343
+
344
+ def draw_confusion(ax, true_labels, predicted_labels, row_classes, column_classes, title, xlabel, ylabel):
345
+ """A confusion matrix with the number of units and the share of its row in each cell."""
346
+ counts = np.zeros((len(row_classes), len(column_classes)), dtype=int)
347
+ for true_label, predicted_label in zip(true_labels, predicted_labels):
348
+ if true_label in row_classes and predicted_label in column_classes:
349
+ counts[row_classes.index(true_label), column_classes.index(predicted_label)] += 1
350
+ row_totals = counts.sum(axis=1, keepdims=True)
351
+ fractions = np.divide(counts, row_totals, out=np.zeros(counts.shape), where=row_totals > 0)
352
+ ax.imshow(fractions, cmap="Blues", vmin=0, vmax=1, aspect="auto")
353
+ for row in range(len(row_classes)):
354
+ for column in range(len(column_classes)):
355
+ colour = "white" if fractions[row, column] > 0.6 else "#1D2939"
356
+ text = f"{counts[row, column]}\n{fractions[row, column]:.0%}" if row_totals[row, 0] > 0 else "0"
357
+ ax.text(column, row, text, ha="center", va="center", color=colour, fontsize=9)
358
+ ax.set_xticks(range(len(column_classes)), column_classes)
359
+ ax.set_yticks(range(len(row_classes)), [f"{name} ({row_totals[index, 0]})" for index, name in enumerate(row_classes)])
360
+ ax.set_xlabel(xlabel)
361
+ ax.set_ylabel(ylabel)
362
+ ax.set_title(title, fontsize=9.5)
363
+ ax.spines[:].set_visible(False)
364
+ ax.tick_params(length=0)
365
+ return counts
366
+
367
+
368
+ def balanced_accuracy(true_labels, predicted_labels):
369
+ from sklearn.metrics import balanced_accuracy_score
370
+ return balanced_accuracy_score(list(true_labels), list(predicted_labels))
371
+
372
+
373
+ def bootstrap_balanced_accuracy(true_labels, predicted_labels, num_resamples=500, seed=0):
374
+ """
375
+ Balanced accuracy on `num_resamples` resamples (with replacement) of the units: how much the score could
376
+ vary with another set of units of the same kind. Computed like sklearn's balanced_accuracy_score: the
377
+ mean, over the labels present, of the share of units of that label the model got right.
378
+ """
379
+ true_labels = np.asarray(list(true_labels), dtype=object)
380
+ predicted_labels = np.asarray(list(predicted_labels), dtype=object)
381
+ rng = np.random.default_rng(seed)
382
+ scores = []
383
+ for _ in range(num_resamples):
384
+ indices = rng.integers(0, len(true_labels), len(true_labels))
385
+ resampled_true, resampled_predicted = true_labels[indices], predicted_labels[indices]
386
+ recalls = [np.mean(resampled_predicted[resampled_true == label] == label) for label in np.unique(resampled_true)]
387
+ scores.append(np.mean(recalls))
388
+ return np.array(scores)
389
+
390
+
391
+ def draw_boxes(ax, groups, positions, colours, horizontal=False, width=0.55, dots=True, seed=0):
392
+ """Box plots (median, quartiles, whiskers to 1.5 IQR) with each value as a dot, as in the UnitRefine paper."""
393
+ groups = [np.asarray(group, dtype=float) for group in groups]
394
+ keep = [index for index, group in enumerate(groups) if len(group) > 0]
395
+ if len(keep) == 0:
396
+ return
397
+ boxes = ax.boxplot(
398
+ [groups[index] for index in keep], positions=[positions[index] for index in keep], widths=width,
399
+ orientation="horizontal" if horizontal else "vertical", patch_artist=True, showfliers=False,
400
+ medianprops=dict(color="#1D2939", linewidth=1.4), whiskerprops=dict(color="#667085"),
401
+ capprops=dict(color="#667085"), boxprops=dict(linewidth=1),
402
+ )
403
+ for patch, index in zip(boxes["boxes"], keep):
404
+ patch.set_facecolor(matplotlib.colors.to_rgba(colours[index], 0.35))
405
+ patch.set_edgecolor(colours[index])
406
+ if dots:
407
+ rng = np.random.default_rng(seed)
408
+ for index in keep:
409
+ jitter = rng.uniform(-width * 0.3, width * 0.3, len(groups[index]))
410
+ if horizontal:
411
+ ax.scatter(groups[index], positions[index] + jitter, s=7, color=colours[index], alpha=0.6, linewidths=0, zorder=3)
412
+ else:
413
+ ax.scatter(positions[index] + jitter, groups[index], s=7, color=colours[index], alpha=0.6, linewidths=0, zorder=3)
414
+
415
+
416
+ # metric groups and colours of the UnitRefine paper's AUC-ROC figure
417
+ PAPER_QUALITY_METRICS = [
418
+ 'firing_rate', 'presence_ratio', 'snr', 'isi_violations_ratio', 'isi_violations_count', 'rp_contamination',
419
+ 'rp_violations', 'sliding_rp_violation', 'amplitude_cutoff', 'drift_ptp', 'isolation_distance', 'l_ratio',
420
+ 'd_prime', 'sd_ratio', 'silhouette', 'nn_hit_rate', 'nn_miss_rate', 'amplitude_median', 'num_spikes',
421
+ 'drift_std', 'drift_mad',
422
+ ]
423
+ PAPER_NEW_METRICS = ['amplitude_cv_median', 'amplitude_cv_range', 'sync_spike_2', 'sync_spike_4', 'sync_spike_8', 'firing_range']
424
+ METRIC_GROUP_COLOURS = {"quality": "#E74C3C", "new": "#3498DB", "template": "#2ECC71"}
425
+ METRIC_GROUP_NAMES = {"quality": "Quality metrics", "new": "New metrics", "template": "Template metrics"}
426
+
427
+
428
+ def metric_group(metric_name):
429
+ """Quality, new (introduced with UnitRefine) or template metric, as grouped in the paper."""
430
+ if metric_name in PAPER_QUALITY_METRICS:
431
+ return "quality"
432
+ if metric_name in PAPER_NEW_METRICS:
433
+ return "new"
434
+ return "template"
435
+
436
+
437
+ def draw_shap(figure, explanation, class_name, max_display=12):
438
+ """Mean |SHAP| per metric (left) and a beeswarm of every unit (right), for the top metrics."""
439
+ values = np.asarray(explanation.values)
440
+ data = np.asarray(explanation.data, dtype=float)
441
+ names = list(explanation.feature_names)
442
+ mean_impact = np.abs(values).mean(axis=0)
443
+ order = np.argsort(mean_impact)[::-1][:max_display]
444
+
445
+ bar_ax, swarm_ax = figure.subplots(1, 2, width_ratios=[1, 1.5], sharey=True)
446
+ y_positions = np.arange(len(order))[::-1]
447
+ bar_ax.barh(y_positions, mean_impact[order], color="#6172F3", height=0.65)
448
+ bar_ax.set_yticks(y_positions, [short(names[index], 28) for index in order])
449
+ bar_ax.set_xlabel("Average impact (mean |SHAP value|)")
450
+ bar_ax.set_title("Most important metrics")
451
+ bar_ax.tick_params(axis="y", length=0)
452
+
453
+ rng = np.random.default_rng(0)
454
+ colour_map = matplotlib.colormaps["coolwarm"]
455
+ for y, index in zip(y_positions, order):
456
+ shap_values = values[:, index]
457
+ metric_values = data[:, index]
458
+ # colour by the rank of the metric value, so a few extreme values don't wash out the colours
459
+ ranks = pd.Series(metric_values).rank(pct=True).values
460
+ # spread the dots vertically where they are dense, like a beeswarm
461
+ bins = np.histogram_bin_edges(shap_values, bins=30)
462
+ bin_index = np.clip(np.digitize(shap_values, bins) - 1, 0, len(bins) - 2)
463
+ density = np.bincount(bin_index, minlength=len(bins) - 1)[bin_index].astype(float)
464
+ spread = 0.38 * density / max(density.max(), 1)
465
+ jitter = rng.uniform(-1, 1, len(shap_values)) * spread
466
+ colours = [colour_map(rank) if not np.isnan(rank) else (0.75, 0.75, 0.75, 1) for rank in ranks]
467
+ swarm_ax.scatter(shap_values, y + jitter, c=colours, s=7, linewidths=0, alpha=0.85, rasterized=True)
468
+ swarm_ax.axvline(0, color="#98A2B3", linewidth=0.8)
469
+ swarm_ax.set_xlabel(f"SHAP value (→ more likely '{class_name}')")
470
+ swarm_ax.set_title(f"How metric values push units towards '{class_name}'")
471
+ swarm_ax.tick_params(axis="y", length=0)
472
+ colour_bar = figure.colorbar(
473
+ matplotlib.cm.ScalarMappable(cmap=colour_map, norm=matplotlib.colors.Normalize(0, 1)),
474
+ ax=swarm_ax, ticks=[0, 1], aspect=30, pad=0.01,
475
+ )
476
+ colour_bar.ax.set_yticklabels(["low", "high"])
477
+ colour_bar.set_label("Metric value", labelpad=-8)
478
+ colour_bar.outline.set_visible(False)
479
+
480
+
481
+ # --------------------------------------------------------------------------------------------------
482
+ # section 2: your labels and the models trained on them
483
+ # --------------------------------------------------------------------------------------------------
484
+
485
+ def project_label_files(targets):
486
+ files = []
487
+ for target in targets:
488
+ folder = Path(target["save_folder"])
489
+ files += [folder / "labels.csv", folder / "labelled_metrics.csv", folder / "all_metrics.csv"]
490
+ return files
491
+
492
+
493
+ def read_labels(target):
494
+ labels_path = Path(target["save_folder"]) / "labels.csv"
495
+ if not labels_path.is_file():
496
+ return None
497
+ labels = pd.read_csv(labels_path)
498
+ labels["unit_id"] = labels["unit_id"].astype(str)
499
+ return labels.dropna(subset=["quality"])
500
+
501
+
502
+ def labelled_metrics_table(targets):
503
+ """The metrics and label of every curated or imported unit, with an `analyzer` and a `label` column."""
504
+ tables = []
505
+ for target in targets:
506
+ labels = read_labels(target)
507
+ metrics_path = Path(target["save_folder"]) / "labelled_metrics.csv"
508
+ if labels is None or not metrics_path.is_file():
509
+ continue
510
+ metrics = pd.read_csv(metrics_path, index_col=0)
511
+ metrics.index = metrics.index.astype(str)
512
+ quality = dict(zip(labels["unit_id"], labels["quality"]))
513
+ metrics = metrics[metrics.index.isin(list(quality))]
514
+ metrics = metrics.drop(columns=[column for column in ["quality", "unit_id", "index"] if column in metrics.columns])
515
+ metrics = metrics.select_dtypes(include="number")
516
+ metrics.insert(0, "label", [quality[unit_id] for unit_id in metrics.index])
517
+ metrics.insert(0, "analyzer", str(target["path"]))
518
+ note_data(target["path"], target_group(target), len(metrics))
519
+ tables.append(metrics)
520
+ if len(tables) == 0:
521
+ return None
522
+ return pd.concat(tables)
523
+
524
+
525
+ # dots of analyzers used for training and of new analyzers have different markers
526
+ ANALYZER_GROUP_MARKERS = {"training": ("o", "analyzers used for training"), "new": ("^", "new analyzers")}
527
+
528
+
529
+ def draw_label_shares(ax, rows, classes):
530
+ """
531
+ Box plots of the share of units with each label, over the analyzers: one dot per analyzer, so the plot
532
+ builds up as analyzers are added. `rows` are (analyzer path, {label: count}, group), group being
533
+ "training" or "new" (see ANALYZER_GROUP_MARKERS). Each dot is labelled with its analyzer.
534
+ """
535
+ colours = [class_colour(name, index) for index, name in enumerate(classes)]
536
+ shares = [[100 * counts.get(class_name, 0) / max(sum(counts.values()), 1) for _, counts, _ in rows] for class_name in classes]
537
+ draw_boxes(ax, shares, list(range(len(classes))), colours, width=0.55, dots=False)
538
+ rng = np.random.default_rng(0)
539
+ groups_present = []
540
+ for class_index, class_name in enumerate(classes):
541
+ for (path, _, group), share in zip(rows, shares[class_index]):
542
+ marker = ANALYZER_GROUP_MARKERS[group][0]
543
+ x = class_index + rng.uniform(-0.12, 0.12)
544
+ ax.scatter(x, share, marker=marker, s=26, color=colours[class_index], edgecolors="#1D2939", linewidths=0.5, zorder=3)
545
+ label_dot(ax, x, share, path, len(rows))
546
+ if group not in groups_present:
547
+ groups_present.append(group)
548
+ totals = [sum(counts.get(class_name, 0) for _, counts, _ in rows) for class_name in classes]
549
+ ax.set_xticks(range(len(classes)), [f"{name}\n({total} units)" for name, total in zip(classes, totals)])
550
+ ax.set_xlim(-0.6, len(classes) - 0.1)
551
+ ax.set_ylabel("% of an analyzer's units")
552
+ ax.set_ylim(-3, 103)
553
+ ax.grid(axis="y", alpha=0.25)
554
+ if len(groups_present) > 1:
555
+ handles = [matplotlib.lines.Line2D([0], [0], marker=ANALYZER_GROUP_MARKERS[group][0], color="white",
556
+ markerfacecolor="#98A2B3", markeredgecolor="#1D2939", markersize=7,
557
+ label=ANALYZER_GROUP_MARKERS[group][1]) for group in groups_present]
558
+ ax.legend(handles=handles, loc="upper right", fontsize=8)
559
+
560
+
561
+ def make_label_counts(targets):
562
+ rows = []
563
+ for target in targets:
564
+ labels = read_labels(target)
565
+ if labels is not None and len(labels) > 0:
566
+ note_data(target["path"], "training", len(labels))
567
+ rows.append((target["path"], labels["quality"].value_counts().to_dict(), "training"))
568
+ if len(rows) == 0:
569
+ raise NoData("Curate or import labels to see how many units of each label you have.")
570
+ classes = ordered_classes([class_name for _, counts, _ in rows for class_name in counts])
571
+ num_units = sum(sum(counts.values()) for _, counts, _ in rows)
572
+
573
+ figure = new_figure(6.5, 4.4)
574
+ ax = figure.add_subplot()
575
+ draw_label_shares(ax, rows, classes)
576
+ ax.set_title(f"Your labels: {num_units} units in {len(rows)} analyzer{'s' if len(rows) > 1 else ''}")
577
+ return figure, []
578
+
579
+
580
+ def kruskal_effect_sizes(table, metric_names):
581
+ """How much each metric differs between the labels: the Kruskal–Wallis H test's effect size (ε²)."""
582
+ from scipy.stats import kruskal
583
+
584
+ effect_sizes = {}
585
+ for metric_name in metric_names:
586
+ groups = [group[metric_name].dropna().values for _, group in table.groupby("label")]
587
+ groups = [group for group in groups if len(group) >= 3]
588
+ num_values = sum(len(group) for group in groups)
589
+ if len(groups) < 2 or len(np.unique(np.concatenate(groups))) < 2:
590
+ continue
591
+ statistic, _ = kruskal(*groups)
592
+ effect_sizes[metric_name] = (statistic - len(groups) + 1) / (num_values - len(groups))
593
+ return effect_sizes
594
+
595
+
596
+ def make_metric_distributions(targets, num_metrics=9):
597
+ table = labelled_metrics_table(targets)
598
+ if table is None or len(table) < 6:
599
+ raise NoData("Curate or import labels to see how the metrics differ between your labels.")
600
+ metric_names = [column for column in table.columns if column not in ("analyzer", "label")]
601
+ effect_sizes = kruskal_effect_sizes(table, metric_names)
602
+ if len(effect_sizes) == 0:
603
+ raise NoData("Label units of at least two different labels (at least 3 units each) to compare the metrics.")
604
+ # metrics which don't differ between the labels at all (ε² <= 0) are left out
605
+ ranked = [name for name in sorted(effect_sizes, key=effect_sizes.get, reverse=True) if effect_sizes[name] > 0][:num_metrics]
606
+ if len(ranked) == 0:
607
+ raise NoData("None of the metrics differ between your labels.")
608
+ classes = ordered_classes(table["label"].unique())
609
+ colours = [class_colour(name, index) for index, name in enumerate(classes)]
610
+
611
+ num_columns = 3
612
+ num_rows = int(np.ceil(len(ranked) / num_columns))
613
+ figure = new_figure(9, 0.5 + 2.3 * num_rows)
614
+ axes = figure.subplots(num_rows, num_columns, squeeze=False).ravel()
615
+ for ax, metric_name in zip(axes, ranked):
616
+ values = table[metric_name].replace([np.inf, -np.inf], np.nan)
617
+ groups = [values[table["label"] == class_name].dropna().values for class_name in classes]
618
+ finite = values.dropna()
619
+ low, high = np.percentile(finite, [1, 99])
620
+ # metrics spanning orders of magnitude (e.g. firing rate) are shown on a log scale
621
+ use_log = low > 0 and high / low > 100
622
+ if use_log:
623
+ ax.set_yscale("log")
624
+ groups = [group[group > 0] for group in groups]
625
+ draw_boxes(ax, groups, list(range(len(classes))), colours)
626
+ if high > low:
627
+ # a few extreme units would squash the boxes, so the axis shows the 1st-99th percentile
628
+ padding = (high / low) ** 0.05 if use_log else (high - low) * 0.05
629
+ ax.set_ylim((low / padding, high * padding) if use_log else (low - padding, high + padding))
630
+ ax.set_xticks(range(len(classes)), classes)
631
+ ax.set_title(f"{short(metric_name, 30)} ε² = {effect_sizes[metric_name]:.2f}", fontsize=9)
632
+ ax.grid(axis="y", alpha=0.25)
633
+ for ax in axes[len(ranked):]:
634
+ ax.axis("off")
635
+ counts = table["label"].value_counts()
636
+ handles = [matplotlib.patches.Patch(color=colour, alpha=0.6) for colour in colours]
637
+ figure.legend(handles, [f"{name} ({counts.get(name, 0)})" for name in classes], loc="outside upper right", ncol=len(classes))
638
+ figure.suptitle("Metrics that differ most between your labels", x=0.01, ha="left", fontweight="bold", fontsize=10)
639
+ return figure, []
640
+
641
+
642
+ def roc_auc_from_ranks(is_positive, values):
643
+ """The AUC-ROC from ranks (Mann–Whitney U): the same as sklearn's roc_auc_score, ties included, and much faster."""
644
+ from scipy.stats import rankdata
645
+
646
+ is_positive = np.asarray(is_positive, dtype=bool)
647
+ ranks = rankdata(values)
648
+ num_positive = is_positive.sum()
649
+ num_negative = len(is_positive) - num_positive
650
+ return (ranks[is_positive].sum() - num_positive * (num_positive + 1) / 2) / (num_positive * num_negative)
651
+
652
+
653
+ def metric_auc_values(table, positive_label, metric_names, min_units=3, num_resamples=200, seed=0):
654
+ """
655
+ The AUC-ROC of each metric on its own for telling `positive_label` units from the others, as in the
656
+ UnitRefine paper: one value per analyzer with at least `min_units` units of each kind. With fewer than 3
657
+ such analyzers there are too few values for a box plot, so the units of all analyzers are resampled
658
+ (`num_resamples` times, with replacement) instead. Returns ({metric: values},
659
+ {metric: the analyzer of each value, None for resamples}, method).
660
+ """
661
+ # plain arrays, so the many resamples are quick
662
+ all_values = table[metric_names].replace([np.inf, -np.inf], np.nan).values.astype(float)
663
+ all_positive = (table["label"] == positive_label).values
664
+ analyzers = table["analyzer"].values
665
+
666
+ def aucs(indices):
667
+ is_positive = all_positive[indices]
668
+ values = {}
669
+ for column, metric_name in enumerate(metric_names):
670
+ metric_values = all_values[indices, column]
671
+ finite = ~np.isnan(metric_values)
672
+ positives = is_positive[finite]
673
+ if positives.sum() >= min_units and (~positives).sum() >= min_units and np.ptp(metric_values[finite]) > 0:
674
+ values[metric_name] = roc_auc_from_ranks(positives, metric_values[finite])
675
+ return values
676
+
677
+ per_analyzer = [(analyzer, aucs(np.flatnonzero(analyzers == analyzer))) for analyzer in pd.unique(analyzers)]
678
+ per_analyzer = [(analyzer, values) for analyzer, values in per_analyzer if len(values) > 0]
679
+ if len(per_analyzer) >= 3:
680
+ rows, method = per_analyzer, "analyzers"
681
+ else:
682
+ rng = np.random.default_rng(seed)
683
+ rows = [(None, aucs(rng.integers(0, len(table), len(table)))) for _ in range(num_resamples)]
684
+ method = "resamples"
685
+ collected, sources = {}, {}
686
+ for analyzer, values in rows:
687
+ for metric_name, value in values.items():
688
+ collected.setdefault(metric_name, []).append(value)
689
+ sources.setdefault(metric_name, []).append(analyzer)
690
+ # metrics with values in only a few analyzers or resamples (e.g. mostly missing) would give misleading boxes
691
+ kept = [name for name, values in collected.items() if len(values) >= len(rows) / 2]
692
+ return {name: collected[name] for name in kept}, {name: sources[name] for name in kept}, method
693
+
694
+
695
+ def make_metric_auc(targets):
696
+ table = labelled_metrics_table(targets)
697
+ if table is None or len(table) < 6:
698
+ raise NoData("Curate or import labels to see how well each metric separates your labels.")
699
+ metric_names = [column for column in table.columns if column not in ("analyzer", "label")]
700
+ tasks = [("Noise vs neural", "noise"), ("SUA vs all", "good")]
701
+ panels = []
702
+ for task_name, positive_label in tasks:
703
+ values, sources, method = metric_auc_values(table, positive_label, metric_names)
704
+ if len(values) > 0:
705
+ panels.append((task_name, values, sources, method))
706
+ if len(panels) == 0:
707
+ raise NoData("Label at least 3 noise and 3 other units, or 3 good and 3 other units, to compute the AUC-ROC.")
708
+
709
+ num_metrics = max(len(values) for _, values, _, _ in panels)
710
+ figure = new_figure(4.8 * len(panels) + 0.6, 1.2 + 0.24 * num_metrics)
711
+ axes = figure.subplots(1, len(panels), squeeze=False)[0]
712
+ for ax, (task_name, values, sources, method) in zip(axes, panels):
713
+ # the metrics which separate the labels best (furthest from chance) at the top
714
+ order = sorted(values, key=lambda name: abs(np.median(values[name]) - 0.5))
715
+ colours = [METRIC_GROUP_COLOURS[metric_group(name)] for name in order]
716
+ draw_boxes(ax, [values[name] for name in order], list(range(len(order))), colours, horizontal=True,
717
+ width=0.6, dots=False)
718
+ if method == "analyzers":
719
+ # one dot per analyzer, labelled with the analyzer's number (listed at the bottom)
720
+ for position, name in enumerate(order):
721
+ for value, analyzer in zip(values[name], sources[name]):
722
+ number = data_number(analyzer)
723
+ ax.scatter(value, position, marker=f"${number}$", s=40, color="#1D2939", zorder=4, linewidths=0)
724
+ ax.axvline(0.5, color="#D92D20", linestyle="--", linewidth=1.2)
725
+ ax.set_yticks(range(len(order)), [short(name, 28) for name in order], fontsize=7.5)
726
+ ax.set_xlim(0, 1)
727
+ ax.set_xlabel("AUC-ROC of the metric alone")
728
+ ax.grid(axis="x", alpha=0.25)
729
+ ax.tick_params(axis="y", length=0)
730
+ number = len(next(iter(values.values())))
731
+ ax.set_title(f"{task_name}\n(per analyzer, {number} analyzers)" if method == "analyzers"
732
+ else f"{task_name}\n({number} resamples of your units)")
733
+ handles = [matplotlib.patches.Patch(color=colour, alpha=0.6, label=METRIC_GROUP_NAMES[group])
734
+ for group, colour in METRIC_GROUP_COLOURS.items()]
735
+ handles.append(matplotlib.lines.Line2D([0], [0], color="#D92D20", linestyle="--", label="Chance level"))
736
+ figure.legend(handles=handles, loc="outside lower center", ncol=4, fontsize=8)
737
+ return figure, []
738
+
739
+
740
+ def make_metric_correlations(targets):
741
+ tables = []
742
+ for target in targets:
743
+ folder = Path(target["save_folder"])
744
+ for file_name in ["all_metrics.csv", "labelled_metrics.csv"]:
745
+ if (folder / file_name).is_file():
746
+ tables.append(pd.read_csv(folder / file_name, index_col=0))
747
+ note_data(target["path"], target_group(target), len(tables[-1]))
748
+ break
749
+ if len(tables) == 0:
750
+ raise NoData("Curate or import labels to see how the metrics of your units are related.")
751
+ metrics = pd.concat(tables).select_dtypes(include="number").replace([np.inf, -np.inf], np.nan)
752
+ metrics = metrics.drop(columns=[column for column in ["quality", "unit_id", "index"] if column in metrics.columns])
753
+ metrics = metrics.loc[:, (metrics.notna().sum() >= 10) & (metrics.nunique() > 1)]
754
+ if metrics.shape[1] < 3:
755
+ raise NoData("Not enough metrics to compare (at least 3 with 10 or more values are needed).")
756
+
757
+ correlations = metrics.corr(method="spearman")
758
+ # similar metrics next to each other: order them by hierarchical clustering of 1 - |correlation|
759
+ from scipy.cluster.hierarchy import leaves_list, linkage
760
+ from scipy.spatial.distance import squareform
761
+ # a copy: with newer pandas, .values can be read-only
762
+ distances = (1 - correlations.abs()).fillna(1).to_numpy(copy=True)
763
+ np.fill_diagonal(distances, 0)
764
+ distances = (distances + distances.T) / 2
765
+ order = leaves_list(linkage(squareform(distances, checks=False), method="average"))
766
+ correlations = correlations.iloc[order, order]
767
+
768
+ size = min(11, 3 + 0.2 * len(correlations))
769
+ figure = new_figure(size + 1, size)
770
+ ax = figure.add_subplot()
771
+ image = ax.imshow(correlations.values, cmap="RdBu_r", vmin=-1, vmax=1)
772
+ labels = [short(name, 26) for name in correlations.columns]
773
+ font_size = 8 if len(labels) <= 30 else 6.5
774
+ ax.set_xticks(range(len(labels)), labels, rotation=90, fontsize=font_size)
775
+ ax.set_yticks(range(len(labels)), labels, fontsize=font_size)
776
+ ax.spines[:].set_visible(False)
777
+ ax.tick_params(length=0)
778
+ colour_bar = figure.colorbar(image, ax=ax, shrink=0.6, aspect=30)
779
+ colour_bar.set_label("Spearman correlation")
780
+ colour_bar.outline.set_visible(False)
781
+ ax.set_title(f"How the metrics are related ({len(metrics)} units)")
782
+ return figure, []
783
+
784
+
785
+ def make_metric_map(targets):
786
+ from sklearn.decomposition import PCA
787
+ from sklearn.preprocessing import StandardScaler
788
+
789
+ table = labelled_metrics_table(targets)
790
+ if table is None or len(table) < 6:
791
+ raise NoData("Curate or import labels to see your units in metric space.")
792
+ metrics = table.drop(columns=["analyzer", "label"]).replace([np.inf, -np.inf], np.nan)
793
+ metrics = metrics.loc[:, (metrics.isna().mean() <= 0.5) & (metrics.nunique() > 1)]
794
+ if metrics.shape[1] < 2:
795
+ raise NoData("Not enough metrics with values to make the map.")
796
+ metrics = metrics.fillna(metrics.median())
797
+ pca = PCA(n_components=2)
798
+ components = pca.fit_transform(StandardScaler().fit_transform(metrics.values))
799
+
800
+ figure = new_figure(6.5, 5)
801
+ ax = figure.add_subplot()
802
+ classes = ordered_classes(table["label"].unique())
803
+ for class_index, class_name in enumerate(classes):
804
+ is_class = (table["label"] == class_name).values
805
+ ax.scatter(components[is_class, 0], components[is_class, 1], s=14, alpha=0.75, linewidths=0,
806
+ color=class_colour(class_name, class_index), label=f"{class_name} ({is_class.sum()})")
807
+ explained = pca.explained_variance_ratio_
808
+ ax.set_xlabel(f"PC 1 ({explained[0]:.0%} of the variance)")
809
+ ax.set_ylabel(f"PC 2 ({explained[1]:.0%} of the variance)")
810
+ ax.legend(loc="best", markerscale=1.5)
811
+ ax.set_title(f"Your units in metric space ({metrics.shape[1]} metrics, PCA)")
812
+ return figure, []
813
+
814
+
815
+ def note_training_data(model_folder):
816
+ """The analyzers a model was trained on (from trained_on.json), for the bottom of its figures."""
817
+ units, _ = evaluation.read_trained_on(model_folder)
818
+ for path, unit_ids in units.items():
819
+ note_data(path, "training", len(unit_ids))
820
+ note_models(Path(model_folder).name)
821
+
822
+
823
+ def make_test_confusion(model_folder):
824
+ model_folder = Path(model_folder)
825
+ note_training_data(model_folder)
826
+ test_path = model_folder / TEST_PREDICTIONS_FILE
827
+ if not test_path.is_file():
828
+ raise NoData("The test set of this model was not saved (models trained with older versions of UnitRefine).")
829
+ test = pd.read_csv(test_path).astype(str)
830
+ classes = ordered_classes(get_model_classes(model_folder))
831
+ observed = balanced_accuracy(test["human"], test["predicted"])
832
+ resampled = bootstrap_balanced_accuracy(test["human"], test["predicted"])
833
+
834
+ figure = new_figure(7, 3.9)
835
+ matrix_ax, box_ax = figure.subplots(1, 2, width_ratios=[1.6, 1])
836
+ draw_confusion(
837
+ matrix_ax, test["human"], test["predicted"], classes, classes,
838
+ f"{short(model_folder.name, 40)}\nbalanced accuracy {observed:.2f} · {len(test)} test units",
839
+ "Model label", "Your label",
840
+ )
841
+ draw_boxes(box_ax, [resampled], [0], ["#12B76A"], width=0.5, dots=False)
842
+ box_ax.scatter([0], [observed], color="#1D2939", marker="D", s=28, zorder=4, label="test set")
843
+ low, high = np.percentile(resampled, [2.5, 97.5])
844
+ box_ax.set_title(f"Balanced accuracy\n95% range {low:.2f}–{high:.2f}", fontsize=9.5)
845
+ box_ax.set_xticks([0], ["test units,\nresampled"])
846
+ box_ax.set_ylim(0, 1.02)
847
+ box_ax.axhline(1 / len(classes), color="#D92D20", linestyle="--", linewidth=1, label="chance")
848
+ box_ax.grid(axis="y", alpha=0.25)
849
+ box_ax.legend(loc="lower right", fontsize=7.5)
850
+ return figure, []
851
+
852
+
853
+ def make_shap(model_folder, metrics_source, targets=None):
854
+ """SHAP of a model on its own training data (`metrics_source` "training") or on the targets' units."""
855
+ model_folder = Path(model_folder)
856
+ classes = get_model_classes(model_folder)
857
+ class_name = evaluation.preferred_class(classes)
858
+ if metrics_source == "training":
859
+ if not (model_folder / "training_data.csv").is_file():
860
+ raise NoData("This model has no training data saved, so it can't be explained.")
861
+ note_training_data(model_folder)
862
+ metrics = evaluation.training_metrics(model_folder)
863
+ where = "the units it was trained on"
864
+ else:
865
+ note_models(model_folder.name)
866
+ try:
867
+ metrics = evaluation.recording_metrics(model_folder, targets)
868
+ except ValueError:
869
+ raise NoData(f"{model_folder.name} can't be explained on your analyzers: none of them have all the metrics "
870
+ "it uses (see the notes).")
871
+ groups = {str(target["path"]): target_group(target) for target in targets}
872
+ for path, num_units in metrics.groupby(level=0).size().items():
873
+ note_data(path, groups[str(path)], num_units)
874
+ where = "the units of your analyzers"
875
+ explanation = evaluation.shap_explanation(model_folder, metrics, class_name)
876
+ figure = new_figure(10, 0.9 + 0.36 * min(12, len(explanation.feature_names)))
877
+ draw_shap(figure, explanation, class_name)
878
+ figure.suptitle(f"{model_folder.name}: on {len(explanation.values)} of {where}", x=0.01, ha="left", fontsize=10, fontweight="bold")
879
+ return figure, []
880
+
881
+
882
+ def training_specs(project_folder, targets, trained_model_folders):
883
+ """The figures of section 2: the labels and metrics of the curated units, and the models trained on them."""
884
+ folder = Path(project_folder) / FIGURES_FOLDER / "2_labels_and_training"
885
+ project_targets = [target for target in targets if target["analyzer_index"] is not None]
886
+ params = json.dumps(sorted(str(target["path"]) for target in project_targets))
887
+ analyzers = tuple(str(target["path"]) for target in targets)
888
+
889
+ def label_inputs():
890
+ return project_label_files(project_targets)
891
+
892
+ specs = [
893
+ FigureSpec(
894
+ "label_counts", "Your labels", folder / "label_counts.png",
895
+ lambda: make_label_counts(project_targets), label_inputs, params=params, analyzers=analyzers,
896
+ caption="The share of each analyzer's units with each of your labels, as box plots with one dot per "
897
+ "analyzer, so the plot builds up as you add analyzers. Below each label is its total number of "
898
+ "units. Very unequal numbers (class imbalance) make it harder for a model to learn the rare label, "
899
+ "which is why SUA vs all models are recommended.",
900
+ ),
901
+ FigureSpec(
902
+ "metric_distributions", "Metrics by label", folder / "metric_distributions.png",
903
+ lambda: make_metric_distributions(project_targets), label_inputs, params=params, analyzers=analyzers,
904
+ caption="Box plots of each metric for each of your labels (each dot is a unit), for the 9 metrics which "
905
+ "differ most between the labels, ranked by the effect size ε² of a Kruskal–Wallis test (0: no "
906
+ "difference, 1: the labels are fully separated). Boxes show the median and quartiles, whiskers "
907
+ "1.5 times the interquartile range. The axes show the 1st–99th percentile of the values.",
908
+ ),
909
+ FigureSpec(
910
+ "metric_auc", "Metric AUC-ROC", folder / "metric_auc_roc.png",
911
+ lambda: make_metric_auc(project_targets), label_inputs, params=params, analyzers=analyzers,
912
+ caption="As in the UnitRefine paper: how well each metric <b>on its own</b> separates noise from neural "
913
+ "units, and SUA from all other units. 0.5 (red line) is chance; values far from it, towards 1 "
914
+ "(higher for that label) or 0 (lower for that label), mean the metric separates the labels well. "
915
+ "Metrics are coloured by group and sorted with the most useful at the top. Each box has one AUC "
916
+ "per analyzer, as in the paper; with fewer than 3 analyzers, it shows the AUC on 200 resamples of "
917
+ "your units instead.",
918
+ ),
919
+ FigureSpec(
920
+ "metric_correlations", "Metric correlations", folder / "metric_correlations.png",
921
+ lambda: make_metric_correlations(project_targets), label_inputs, params=params, analyzers=analyzers,
922
+ caption="Spearman correlation between every pair of metrics, over all units of your analyzers. Metrics "
923
+ "are ordered so that related ones are next to each other. Strongly correlated metrics carry "
924
+ "similar information, so a model can rely on either of them.",
925
+ ),
926
+ FigureSpec(
927
+ "metric_map", "Units in metric space", folder / "metric_map.png",
928
+ lambda: make_metric_map(project_targets), label_inputs, params=params, analyzers=analyzers,
929
+ caption="Each dot is a labelled unit, placed by the first two principal components of its (standardised) "
930
+ "metrics. Labels which form separate clouds are easy to tell apart. Overlap here doesn't mean a "
931
+ "model will fail, since it uses all metrics together, not only these two directions.",
932
+ ),
933
+ ]
934
+
935
+ for model_folder in trained_model_folders:
936
+ model_folder = Path(model_folder)
937
+
938
+ def model_inputs(model_folder=model_folder):
939
+ return [model_folder / name for name in ["best_model.skops", "model_info.json", "training_data.csv", TEST_PREDICTIONS_FILE]]
940
+
941
+ if (model_folder / TEST_PREDICTIONS_FILE).is_file():
942
+ specs.append(FigureSpec(
943
+ f"test_{model_folder.name}", f"Test set: {model_folder.name}", folder / f"test_set__{short_file_name(model_folder.name)}.png",
944
+ lambda model_folder=model_folder: make_test_confusion(model_folder), model_inputs, analyzers=analyzers,
945
+ caption="How the model labels the units which were held out from training (the test set). Rows are "
946
+ "your labels, columns the model's; each cell shows the number of units and the share of its "
947
+ "row. A good model has most units on the diagonal. Balanced accuracy is the average of the "
948
+ "diagonal percentages, so every label counts equally however many units it has. "
949
+ "<b>Right</b>: the balanced accuracy on 500 resamples of the test units (box plot), which shows "
950
+ "how much the score could change with another test set of the same size; the diamond is the "
951
+ "test set itself, the red line chance.",
952
+ ))
953
+ specs.append(FigureSpec(
954
+ f"shap_{model_folder.name}", f"SHAP: {model_folder.name}", folder / f"shap__{short_file_name(model_folder.name)}.png",
955
+ lambda model_folder=model_folder: make_shap(model_folder, "training"), model_inputs, slow=True, analyzers=analyzers,
956
+ caption=shap_caption(),
957
+ ))
958
+ return specs
959
+
960
+
961
+ def shap_caption():
962
+ return ("<b>Left</b>: how much each metric affects the model's decision, on average. <b>Right</b>: each dot is a "
963
+ "unit. Dots to the right of the line push the model towards the label in the title, dots to the left "
964
+ "away from it. The colour is the metric's value (red high, blue low): e.g. red dots on the right mean "
965
+ "high values of that metric make the label more likely.")
966
+
967
+
968
+ # --------------------------------------------------------------------------------------------------
969
+ # section 3: the predictions of the chosen models
970
+ # --------------------------------------------------------------------------------------------------
971
+
972
+ def selection_runs(mode, model_folders, key):
973
+ """
974
+ How a selection is predicted: a cascade as a whole, binary models one by one (so a model which can't
975
+ label an analyzer doesn't stop the others). Each run is (mode, model folders, key, label column).
976
+ """
977
+ if mode == "cascade":
978
+ return [(mode, list(model_folders), key, "cascade_model_label")]
979
+ return [("binary", [model_folder], Path(model_folder).name, f"{Path(model_folder).name}_model_label") for model_folder in model_folders]
980
+
981
+
982
+ def selection_inputs(targets, model_folders, keys):
983
+ """The files figures of a model selection are made from: models, labels, metrics and predictions."""
984
+ files = []
985
+ for model_folder in model_folders:
986
+ model_folder = Path(model_folder)
987
+ files += [model_folder / "best_model.skops", model_folder / "model_info.json", model_folder / evaluation.TRAINED_ON_FILE]
988
+ for target in targets:
989
+ save_folder = Path(target["save_folder"])
990
+ prefix = target["file_prefix"]
991
+ files += [save_folder / f"{prefix}labels.csv", save_folder / f"{prefix}all_metrics.csv"]
992
+ files += sorted(save_folder.glob(f"{prefix}relabelled_units_*.csv"))
993
+ for key in keys:
994
+ files += [save_folder / predictions_file_name(prefix, key), Path(save_folder) / f"{prefix}units_to_relabel_{key}.csv"]
995
+ if "://" not in str(target["path"]):
996
+ for extension_name in ["quality_metrics", "template_metrics"]:
997
+ extension_folder = Path(target["path"]) / "extensions" / extension_name
998
+ if extension_folder.is_dir():
999
+ files += sorted(path for path in extension_folder.rglob("*") if path.is_file())
1000
+ return files
1001
+
1002
+
1003
+ def analyzer_columns(paths):
1004
+ """The columns of a grid of panels: all analyzers together (if there are several), then each analyzer."""
1005
+ paths = list(dict.fromkeys(str(path) for path in paths))
1006
+ return ([None] if len(paths) > 1 else []) + paths
1007
+
1008
+
1009
+ def column_title(path, groups):
1010
+ if path is None:
1011
+ return "All analyzers"
1012
+ group = "training" if groups.get(path) == "training" else "new"
1013
+ return f"[{data_number(path)}] {short(analyzer_name(path), 26)} ({group})"
1014
+
1015
+
1016
+ def make_predicted_counts(targets, mode, model_folders, key):
1017
+ note_selection(mode, model_folders)
1018
+ runs = selection_runs(mode, model_folders, key)
1019
+ panels = []
1020
+ for run_mode, run_model_folders, run_key, label_column in runs:
1021
+ rows = []
1022
+ for target in targets:
1023
+ try:
1024
+ label_columns, _, _ = evaluation.target_predictions(target, run_mode, run_model_folders, run_key)
1025
+ except Exception as e:
1026
+ evaluation.skipped_analyzers[evaluation.skipped_key(target, run_model_folders)] = evaluation.describe_prediction_error(e)
1027
+ continue
1028
+ group = target_group(target)
1029
+ note_data(target["path"], group, len(label_columns[label_column]))
1030
+ rows.append((target["path"], label_columns[label_column].value_counts().to_dict(), group))
1031
+ panels.append((label_column.replace("_model_label", ""), rows, run_model_folders))
1032
+ if all(len(rows) == 0 for _, rows, _ in panels):
1033
+ raise NoData("The chosen model(s) could not label any of your analyzers (see the notes).")
1034
+
1035
+ figure = new_figure(min(5.6 * len(panels), 14), 4.6)
1036
+ axes = figure.subplots(1, len(panels), squeeze=False)[0]
1037
+ for ax, (name, rows, run_model_folders) in zip(axes, panels):
1038
+ if len(rows) == 0:
1039
+ message_axes(ax, f"{short(name, 36)}\n\ncould not label your analyzers\n(see the notes)")
1040
+ continue
1041
+ # every class the model can give, also the ones it gave no unit
1042
+ model_classes = HUMAN_LABELS if mode == "cascade" else get_model_classes(run_model_folders[0])
1043
+ classes = ordered_classes(list(model_classes) + [class_name for _, counts, _ in rows for class_name in counts])
1044
+ draw_label_shares(ax, rows, classes)
1045
+ ax.set_title(f"Labels from {short(name, 36)}\n{len(rows)} analyzer{'s' if len(rows) > 1 else ''}")
1046
+ return figure, []
1047
+
1048
+
1049
+ def make_confidence(targets, mode, model_folders, key):
1050
+ note_selection(mode, model_folders)
1051
+ tables = evaluation.confidence_table(targets, mode, model_folders, key)
1052
+ if mode == "binary":
1053
+ # models which could not label any analyzer are only mentioned in the notes
1054
+ for model_folder in model_folders:
1055
+ if f"{Path(model_folder).name}_model_label" not in tables:
1056
+ evaluation.skipped_analyzers.setdefault(
1057
+ f"all analyzers (model: {Path(model_folder).name})", "it could not label any of them")
1058
+ if mode == "cascade":
1059
+ tables = {column: table for column, table in tables.items() if column == "cascade_model_label"}
1060
+ if len(tables) == 0:
1061
+ raise NoData("The chosen model(s) could not label any of your analyzers (see the notes).")
1062
+ groups = {str(target["path"]): target_group(target) for target in targets}
1063
+ for table in tables.values():
1064
+ for path, num_units in table.groupby("analyzer").size().items():
1065
+ note_data(path, groups[str(path)], num_units)
1066
+ # one row per model, one column per analyzer (and all analyzers together)
1067
+ columns = analyzer_columns(path for target in targets for path in [str(target["path"])]
1068
+ if any((table["analyzer"] == path).any() for table in tables.values()))
1069
+ figure = new_figure(min(3.6 * len(columns), 18) + 0.4, 0.6 + 3.0 * len(tables))
1070
+ axes = figure.subplots(len(tables), len(columns), squeeze=False)
1071
+ bins = np.linspace(0, 1, 21)
1072
+ for row, (column_name, table) in enumerate(tables.items()):
1073
+ classes = ordered_classes(table["predicted"].unique())
1074
+ colours = [class_colour(name, index) for index, name in enumerate(classes)]
1075
+ for column, path in enumerate(columns):
1076
+ ax = axes[row, column]
1077
+ part = table if path is None else table[table["analyzer"] == path]
1078
+ if len(part) == 0:
1079
+ message_axes(ax, "could not label\nthis analyzer")
1080
+ continue
1081
+ ax.hist([part.loc[part["predicted"] == class_name, "confidence"] for class_name in classes], bins=bins,
1082
+ stacked=True, label=classes, color=colours)
1083
+ ax.set_title(f"{column_title(path, groups)}\n{len(part)} units", fontsize=8.5)
1084
+ ax.set_xlim(0, 1)
1085
+ ax.set_xlabel("Model confidence")
1086
+ if column == 0:
1087
+ ax.set_ylabel(f"{short(column_name.replace('_model_label', ''), 30)}\nNumber of units")
1088
+ ax.legend(title="Model label", fontsize=7.5, title_fontsize=7.5)
1089
+ return figure, []
1090
+
1091
+
1092
+ def make_comparison(targets, mode, model_folders, key):
1093
+ note_selection(mode, model_folders)
1094
+ evaluations = evaluation.selection_evaluations(targets, mode, model_folders, key)
1095
+ if all(result["num_candidates"] == 0 for result in evaluations):
1096
+ raise NoData("No labels to compare with yet: import labels for a new analyzer (3.3), or relabel units "
1097
+ "(section 4). Models are never compared with labels they learned from.")
1098
+ groups = {str(target["path"]): target_group(target) for target in targets}
1099
+ for result in evaluations:
1100
+ for path, num_units in result["comparison"].groupby("analyzer").size().items():
1101
+ note_data(path, groups[str(path)], num_units)
1102
+ # one row per model, one column per analyzer with labels to compare (and all analyzers together)
1103
+ compared = set(str(path) for result in evaluations for path in result["comparison"]["analyzer"])
1104
+ columns = analyzer_columns(str(target["path"]) for target in targets if str(target["path"]) in compared)
1105
+ if len(columns) == 0:
1106
+ columns = [None]
1107
+ heights = [3.5 if len(result["comparison"]) > 0 else 0.9 for result in evaluations]
1108
+ figure = new_figure(min(3.7 * len(columns), 18) + 0.4, 0.6 + sum(heights))
1109
+ axes = figure.subplots(len(evaluations), len(columns), squeeze=False, height_ratios=heights)
1110
+ notes = []
1111
+ for row, result in enumerate(evaluations):
1112
+ comparison, classes = result["comparison"], ordered_classes(result["classes"])
1113
+ for column, path in enumerate(columns):
1114
+ ax = axes[row, column]
1115
+ part = comparison if path is None else comparison[comparison["analyzer"].astype(str) == path]
1116
+ if len(comparison) == 0:
1117
+ message_axes(ax, (f"{short(result['name'], 40)}: " + (
1118
+ "could not label your analyzers (see the notes)" if result["num_candidates"] > 0
1119
+ else "no labels it was not trained on")) if column == 0 else "")
1120
+ if column == 0:
1121
+ ax.texts[-1].set_ha("left")
1122
+ ax.texts[-1].set_x(0)
1123
+ continue
1124
+ if len(part) == 0:
1125
+ message_axes(ax, f"{column_title(path, groups)}\n\nno labels to compare\nfor this model")
1126
+ continue
1127
+ draw_confusion(
1128
+ ax, part["human"], part["predicted"], classes, classes,
1129
+ f"{column_title(path, groups)}\nbalanced accuracy {balanced_accuracy(part['human'], part['predicted']):.2f}"
1130
+ f" · {len(part)} units",
1131
+ "Model label", f"{short(result['name'], 30)}\nYour label" if column == 0 else "Your label",
1132
+ )
1133
+ left_out = f" {result['num_left_out']} units were left out." if result["num_left_out"] > 0 else ""
1134
+ notes.append(f"{result['name']}: {STATUS_TEXT[result['status']]}.{left_out}")
1135
+ return figure, notes
1136
+
1137
+
1138
+ def apply_specs(project_folder, targets, mode, model_folders, key):
1139
+ """The figures of section 3, for the chosen model selection."""
1140
+ folder = Path(project_folder) / FIGURES_FOLDER / "3_predictions" / short_file_name(key)
1141
+ model_folders = [Path(model_folder) for model_folder in model_folders]
1142
+ keys = [run[2] for run in selection_runs(mode, model_folders, key)] + [key]
1143
+ params = json.dumps([mode, [str(model_folder) for model_folder in model_folders], key, sorted(str(target["path"]) for target in targets)])
1144
+ analyzers = tuple(str(target["path"]) for target in targets)
1145
+
1146
+ def inputs():
1147
+ return selection_inputs(targets, model_folders, keys)
1148
+
1149
+ specs = [
1150
+ FigureSpec(
1151
+ "predicted_counts", "Predicted labels", folder / "predicted_labels.png",
1152
+ lambda: make_predicted_counts(targets, mode, model_folders, key), inputs, params=params, analyzers=analyzers,
1153
+ caption="The share of each analyzer's units which the chosen model(s) give each label, as box plots with "
1154
+ "one marker per analyzer (circles: analyzers used for training, triangles: new analyzers), so the "
1155
+ "plot builds up as you add analyzers. A cascade gives good, MUA or noise; a model used on its own gives its own "
1156
+ "two classes. Below each label is its total number of units.",
1157
+ ),
1158
+ FigureSpec(
1159
+ "confidence", "Confidence", folder / "confidence.png",
1160
+ lambda: make_confidence(targets, mode, model_folders, key), inputs, params=params, analyzers=analyzers,
1161
+ caption="How sure the model is about each unit, coloured by the label it gives: all analyzers together, "
1162
+ "then each analyzer (numbered as at the bottom of the figure). Most units should be on the right. "
1163
+ "The units on the left are the uncertain ones, which are worth checking yourself: section 4 finds "
1164
+ "and relabels them.",
1165
+ ),
1166
+ FigureSpec(
1167
+ "comparison", "Compared with your labels", folder / "compared_with_your_labels.png",
1168
+ lambda: make_comparison(targets, mode, model_folders, key), inputs, params=params, analyzers=analyzers,
1169
+ caption="The model's labels against your labels (curated, imported and relabelled), only for units the "
1170
+ "model was <b>not</b> trained on, so the result is honest. One row per model; the first column has "
1171
+ "all analyzers together, the others one analyzer each (numbered as at the bottom of the figure). "
1172
+ "Your labels are converted to the model's classes (e.g. good and MUA count as neural). Rows are "
1173
+ "your labels; each cell shows the number of units and the share of its row.",
1174
+ ),
1175
+ ]
1176
+ for model_folder in model_folders:
1177
+ specs.append(FigureSpec(
1178
+ f"shap_{model_folder.name}", f"SHAP: {model_folder.name}", folder / f"shap__{short_file_name(model_folder.name)}.png",
1179
+ lambda model_folder=model_folder: make_shap(model_folder, "recordings", targets), inputs, params=params, slow=True,
1180
+ analyzers=analyzers,
1181
+ caption=f"Why {model_folder.name} labels the units of your analyzers the way it does. " + shap_caption(),
1182
+ ))
1183
+ return specs
1184
+
1185
+
1186
+ # --------------------------------------------------------------------------------------------------
1187
+ # section 4: active learning
1188
+ # --------------------------------------------------------------------------------------------------
1189
+
1190
+ def selection_rounds(mode, model_folders, key, lineages):
1191
+ """
1192
+ The model selection of each active learning round: round 1 uses the original versions of the chosen models,
1193
+ round 2 their first retrained versions, and so on (a model which was not retrained again keeps its latest
1194
+ version). Each round's picked and relabelled units are saved under its selection's key.
1195
+ Returns [(round number, model folders, key)], always including the chosen selection (`key`).
1196
+ """
1197
+ model_folders = [Path(model_folder) for model_folder in model_folders]
1198
+ versions = []
1199
+ for model_folder in model_folders:
1200
+ lineage = [model_folder]
1201
+ for folders in lineages.values():
1202
+ folders = [Path(folder) for folder in folders]
1203
+ if model_folder.name in [folder.name for folder in folders]:
1204
+ lineage = folders
1205
+ versions.append(lineage)
1206
+ rounds, keys = [], []
1207
+ for index in range(max(len(lineage) for lineage in versions)):
1208
+ folders = [lineage[min(index, len(lineage) - 1)] for lineage in versions]
1209
+ round_key = selection_key(mode, [folder.name for folder in folders])
1210
+ if round_key not in keys:
1211
+ keys.append(round_key)
1212
+ rounds.append((len(rounds) + 1, folders, round_key))
1213
+ if key not in keys:
1214
+ rounds.append((len(rounds) + 1, model_folders, key))
1215
+ return rounds
1216
+
1217
+
1218
+ def round_title(number, mode, folders):
1219
+ names = [Path(folder).name for folder in folders]
1220
+ if mode == "cascade":
1221
+ return f"Round {number}: cascade of {short(names[0], 30)} and {short(names[1], 30)}"
1222
+ return f"Round {number}: {short(', '.join(names), 64)}"
1223
+
1224
+
1225
+ def round_relabels(target, round_key):
1226
+ """The units relabelled in a round, as {unit id: label}."""
1227
+ path = relabelled_units_path(target, round_key)
1228
+ if not path.is_file():
1229
+ return {}
1230
+ relabels = pd.read_csv(path).dropna(subset=["quality"])
1231
+ return dict(zip(relabels["unit_id"].astype(str), relabels["quality"]))
1232
+
1233
+
1234
+ def make_selection_confidence(targets, mode, model_folders, key, rounds):
1235
+ """
1236
+ For each round: the confidence the units were ranked by in Find least confident units, and which units were
1237
+ picked in that round, which were labelled before it, and which had no label yet.
1238
+ """
1239
+ note_selection(mode, model_folders)
1240
+ labelled_before = {str(target["path"]): set(evaluation.original_labels(target)) for target in targets}
1241
+ panels = []
1242
+ for number, folders, round_key in rounds:
1243
+ picked_anywhere = any(units_to_relabel_path(target, round_key).is_file() for target in targets)
1244
+ if picked_anywhere or round_key == key:
1245
+ rows, num_relabelled = [], 0
1246
+ for target in targets:
1247
+ try:
1248
+ evaluation.report_progress(f"Round {number}: reading the predictions of {Path(str(target['path'])).name}")
1249
+ table = load_or_compute_predictions(target, mode, folders, round_key)
1250
+ except Exception as e:
1251
+ evaluation.skipped_analyzers[evaluation.skipped_key(target, folders)] = evaluation.describe_prediction_error(e)
1252
+ continue
1253
+ note_data(target["path"], target_group(target), len(table))
1254
+ picked = set()
1255
+ if units_to_relabel_path(target, round_key).is_file():
1256
+ picked = set(pd.read_csv(units_to_relabel_path(target, round_key))["unit_id"].astype(str))
1257
+ num_relabelled += len(set(round_relabels(target, round_key)) & picked)
1258
+ before = labelled_before[str(target["path"])]
1259
+ for unit_id, confidence in zip(table.index.astype(str), selection_confidence_from_table(table, mode)):
1260
+ status = "before" if unit_id in before else ("picked" if unit_id in picked else "other")
1261
+ rows.append((confidence, status))
1262
+ if len(rows) > 0:
1263
+ panels.append((number, folders, round_key, pd.DataFrame(rows, columns=["confidence", "status"]).dropna(), num_relabelled))
1264
+ # the units relabelled in this round count as labelled in the next rounds
1265
+ for target in targets:
1266
+ labelled_before[str(target["path"])] |= set(round_relabels(target, round_key))
1267
+ if len(panels) == 0:
1268
+ raise NoData("The chosen model(s) could not label any of your analyzers (see the notes).")
1269
+
1270
+ figure = new_figure(7.5, 0.6 + 3.2 * len(panels))
1271
+ axes = figure.subplots(len(panels), 1, squeeze=False, sharex=True)[:, 0]
1272
+ groups = [
1273
+ ("other", "#D0D5DD", "not labelled yet"),
1274
+ ("picked", "#F04438", "picked in this round"),
1275
+ ("before", "#6172F3", "labelled before this round"),
1276
+ ]
1277
+ notes = []
1278
+ for ax, (number, folders, round_key, table, num_relabelled) in zip(axes, panels):
1279
+ ax.hist([table.loc[table["status"] == status, "confidence"] for status, _, _ in groups], bins=np.linspace(0, 1, 26),
1280
+ stacked=True, color=[colour for _, colour, _ in groups],
1281
+ label=[f"{label} ({(table['status'] == status).sum()})" for status, _, label in groups])
1282
+ picked = table[table["status"] == "picked"]
1283
+ if len(picked) > 0:
1284
+ ax.axvline(picked["confidence"].max(), color="#F04438", linestyle="--", linewidth=1)
1285
+ ax.set_xlim(0, 1)
1286
+ ax.set_ylabel("Number of units")
1287
+ ax.legend(fontsize=8)
1288
+ ax.set_title(f"{round_title(number, mode, folders)}\n{len(picked)} units picked, {num_relabelled} of them relabelled",
1289
+ fontsize=9.5)
1290
+ if len(picked) == 0 and round_key == key:
1291
+ notes.append(f"Round {number}: click Find least confident units to pick the units to relabel.")
1292
+ axes[-1].set_xlabel("Confidence of the round's model(s)" + (" (cascade: layer 1 × layer 2)" if mode == "cascade" else
1293
+ " (lowest over the models)" if len(model_folders) > 1 else ""))
1294
+ return figure, notes
1295
+
1296
+
1297
+ def make_relabel_changes(targets, mode, model_folders, key, rounds):
1298
+ """
1299
+ Your relabels against the labels the model gave the same units: one row per round (and per model of a binary
1300
+ selection), with all analyzers together and each analyzer.
1301
+ """
1302
+ note_selection(mode, model_folders)
1303
+ groups = {str(target["path"]): target_group(target) for target in targets}
1304
+ panels = []
1305
+ for number, folders, round_key in rounds:
1306
+ runs = selection_runs(mode, folders, round_key) if mode == "binary" else [(mode, list(folders), round_key, "cascade_model_label")]
1307
+ for _, run_model_folders, _, label_column in runs:
1308
+ rows = [] # (analyzer, model label, your label)
1309
+ for target in targets:
1310
+ relabels = round_relabels(target, round_key)
1311
+ if len(relabels) == 0:
1312
+ continue
1313
+ try:
1314
+ # the round's predictions, which the units were picked with
1315
+ label_columns, _, _ = evaluation.target_predictions(target, mode, folders, round_key)
1316
+ except Exception as e:
1317
+ evaluation.skipped_analyzers[evaluation.skipped_key(target, folders)] = evaluation.describe_prediction_error(e)
1318
+ continue
1319
+ predictions = dict(zip(label_columns[label_column].index.astype(str), label_columns[label_column].values))
1320
+ for unit_id, quality in relabels.items():
1321
+ if unit_id in predictions:
1322
+ rows.append((str(target["path"]), predictions[unit_id], quality))
1323
+ if len(rows) == 0:
1324
+ continue
1325
+ for path in dict.fromkeys(row[0] for row in rows):
1326
+ note_data(path, groups[path], sum(row[0] == path for row in rows))
1327
+ note_models(*[Path(folder).name for folder in run_model_folders])
1328
+ classes = get_model_classes(run_model_folders[0]) if mode == "binary" else HUMAN_LABELS
1329
+ # the row's label: the round, and the model when several binary models are compared
1330
+ name = f"Round {number}" + (f" · {short(label_column.replace('_model_label', ''), 22)}" if len(runs) > 1 else "")
1331
+ panels.append((name, ordered_classes(classes), rows))
1332
+ if len(panels) == 0:
1333
+ raise NoData("Relabel some units first: Find least confident units, then Relabel.")
1334
+
1335
+ columns = analyzer_columns(str(target["path"]) for target in targets
1336
+ if any(row[0] == str(target["path"]) for _, _, rows in panels for row in rows))
1337
+ figure = new_figure(min(3.7 * len(columns), 18) + 0.4, 0.6 + 3.4 * len(panels))
1338
+ axes = figure.subplots(len(panels), len(columns), squeeze=False)
1339
+ notes = []
1340
+ for row_index, (name, classes, rows) in enumerate(panels):
1341
+ for column, path in enumerate(columns):
1342
+ ax = axes[row_index, column]
1343
+ part = rows if path is None else [row for row in rows if row[0] == path]
1344
+ if len(part) == 0:
1345
+ message_axes(ax, f"{column_title(path, groups)}\n\nno relabelled units")
1346
+ continue
1347
+ model_labels = [row[1] for row in part]
1348
+ your_labels = [row[2] for row in part]
1349
+ # compared in the model's classes (e.g. MUA counts as neural); labels the model doesn't use
1350
+ # (e.g. noise for a SUA vs MUA model) can't agree or disagree with it
1351
+ model_classes_of_yours = [map_quality_to_model_class(label, classes) for label in your_labels]
1352
+ num_comparable = sum(mapped is not None for mapped in model_classes_of_yours)
1353
+ num_disagree = sum(mapped is not None and mapped != model_label
1354
+ for model_label, mapped in zip(model_labels, model_classes_of_yours))
1355
+ draw_confusion(ax, model_labels, your_labels, classes, HUMAN_LABELS,
1356
+ f"{column_title(path, groups)}\nyou disagreed on {num_disagree} of {num_comparable} units",
1357
+ "Your label", f"{short(name, 30)}\nModel label" if column == 0 else "Model label")
1358
+ if path is None or len(columns) == 1:
1359
+ not_comparable = len(model_labels) - num_comparable
1360
+ notes.append(f"{name}: you gave {num_disagree} of {num_comparable} relabelled units a different label "
1361
+ "from the model's (compared in the model's classes)."
1362
+ + (f" {not_comparable} units have a label this model doesn't use." if not_comparable > 0 else ""))
1363
+ return figure, notes
1364
+
1365
+
1366
+ def make_rounds(targets, lineages):
1367
+ lineages = {name: folders for name, folders in lineages.items() if len(folders) > 1}
1368
+ if len(lineages) == 0:
1369
+ raise NoData("The chosen model(s) have not been retrained yet. Find, relabel and retrain, and this figure "
1370
+ "compares the versions.")
1371
+ figure = new_figure(7.5, 4)
1372
+ ax = figure.add_subplot()
1373
+ notes = []
1374
+ max_rounds = max(len(folders) for folders in lineages.values())
1375
+ width = 0.7 / len(lineages)
1376
+ analyzer_groups = {str(target["path"]): target_group(target) for target in targets}
1377
+ for index, (base_name, folders) in enumerate(lineages.items()):
1378
+ results, statuses = evaluation.lineage_evaluations(targets, folders)
1379
+ note_models(*[Path(folder).name for folder in folders])
1380
+ for path, num_units in results[0]["comparison"].groupby("analyzer").size().items():
1381
+ note_data(path, analyzer_groups[str(path)], num_units)
1382
+ colour = f"C{index}"
1383
+ offset = (index - (len(lineages) - 1) / 2) * width
1384
+ positions = [round_number + offset for round_number in range(len(folders))]
1385
+ observed, groups = [], []
1386
+ for result in results:
1387
+ comparison = result["comparison"]
1388
+ if len(comparison) == 0:
1389
+ observed.append(np.nan)
1390
+ groups.append([])
1391
+ else:
1392
+ observed.append(balanced_accuracy(comparison["human"], comparison["predicted"]))
1393
+ groups.append(bootstrap_balanced_accuracy(comparison["human"], comparison["predicted"]))
1394
+ draw_boxes(ax, groups, positions, [colour] * len(groups), width=width * 0.8, dots=False)
1395
+ num_units = len(results[0]["comparison"])
1396
+ ax.plot(positions, observed, marker="D", markersize=5, color=colour, linewidth=1.2,
1397
+ label=f"{short(base_name, 40)} ({num_units} units)", zorder=4)
1398
+ for x, accuracy in zip(positions, observed):
1399
+ if not np.isnan(accuracy):
1400
+ ax.annotate(f"{accuracy:.2f}", (x, accuracy), textcoords="offset points", xytext=(0, 9), ha="center", fontsize=8)
1401
+ if num_units == 0:
1402
+ notes.append(f"{base_name}: no labels are left that none of its versions were trained on, so the versions "
1403
+ "can't be compared fairly. Use + Import labels for a new analyzer (3.3) to compare them.")
1404
+ if "unknown" in statuses:
1405
+ notes.append(f"{base_name}: {STATUS_TEXT['unknown']}.")
1406
+ ax.set_xticks(range(max_rounds), ["original"] + [f"round {round_number}" for round_number in range(1, max_rounds)])
1407
+ ax.set_xlim(-0.6, max_rounds - 0.4)
1408
+ ax.set_ylim(0, 1.08)
1409
+ ax.set_ylabel("Balanced accuracy")
1410
+ ax.grid(axis="y", alpha=0.3)
1411
+ ax.legend(loc="lower right")
1412
+ ax.set_title("Accuracy over retraining rounds")
1413
+ return figure, notes
1414
+
1415
+
1416
+ def active_learning_specs(project_folder, targets, mode, model_folders, key, lineages):
1417
+ """The figures of section 4, for the chosen model selection and its retrained versions (`lineages`)."""
1418
+ folder = Path(project_folder) / FIGURES_FOLDER / "4_active_learning" / short_file_name(key)
1419
+ model_folders = [Path(model_folder) for model_folder in model_folders]
1420
+ lineage_folders = [Path(model_folder) for folders in lineages.values() for model_folder in folders]
1421
+ rounds = selection_rounds(mode, model_folders, key, lineages)
1422
+ keys = [key] + [model_folder.name for model_folder in lineage_folders] + [round_key for _, _, round_key in rounds]
1423
+ params = json.dumps([mode, [str(model_folder) for model_folder in model_folders], key,
1424
+ sorted(str(target["path"]) for target in targets), {name: [str(f) for f in folders] for name, folders in lineages.items()}])
1425
+
1426
+ analyzers = tuple(str(target["path"]) for target in targets)
1427
+
1428
+ def inputs():
1429
+ return selection_inputs(targets, list(dict.fromkeys(model_folders + lineage_folders)), keys)
1430
+
1431
+ return [
1432
+ FigureSpec(
1433
+ "selection_confidence", "Units to relabel", folder / "units_to_relabel.png",
1434
+ lambda: make_selection_confidence(targets, mode, model_folders, key, rounds), inputs, params=params, analyzers=analyzers,
1435
+ caption="One panel per round of active learning (round 1: the original models, round 2: their first "
1436
+ "retrained versions, …): all units of your analyzers, by how confident that round's model(s) were "
1437
+ "about them. Red: the least confident units picked in that round (up to the dashed line). Blue: units "
1438
+ "labelled before that round (curated, imported or relabelled in an earlier round), which are never "
1439
+ "picked again, so each round asks about new units. A new panel is added with each round.",
1440
+ ),
1441
+ FigureSpec(
1442
+ "relabel_changes", "What you relabelled", folder / "what_you_relabelled.png",
1443
+ lambda: make_relabel_changes(targets, mode, model_folders, key, rounds), inputs, params=params, analyzers=analyzers,
1444
+ caption="For the units you relabelled in each round: the label that round's model gave them (rows) against "
1445
+ "yours (columns), one row of panels per round, with all analyzers together and each analyzer. Units "
1446
+ "where you disagree with the model teach it the most when you retrain. Units you gave the same label "
1447
+ "as their original curated label are not saved as relabelled, so they are not counted here.",
1448
+ ),
1449
+ FigureSpec(
1450
+ "rounds", "Accuracy over rounds", folder / "accuracy_over_rounds.png",
1451
+ lambda: make_rounds(targets, lineages), inputs, params=params, analyzers=analyzers,
1452
+ caption="Balanced accuracy of the original model and each retrained version (diamonds), all compared with "
1453
+ "the same labels of yours: the ones <b>none</b> of the versions were trained on. Each box shows the "
1454
+ "balanced accuracy on 500 resamples of those units: if the boxes of two rounds overlap a lot, the "
1455
+ "difference between them may be chance. If the line goes up, active learning is helping; when it "
1456
+ "levels off, the model is ready and more rounds won't improve it much.",
1457
+ ),
1458
+ ]