unitrefine 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unitrefine/__init__.py +75 -0
- unitrefine/apply_models.py +111 -0
- unitrefine/cli.py +251 -0
- unitrefine/evaluation.py +457 -0
- unitrefine/figures.py +1458 -0
- unitrefine/import_labels.py +176 -0
- unitrefine/launch_sigui.py +138 -0
- unitrefine/launch_sigui_validate.py +243 -0
- unitrefine/launcher.py +239 -0
- unitrefine/main.py +2395 -0
- unitrefine/manage_models.py +193 -0
- unitrefine/metrics.py +146 -0
- unitrefine/model_selection.py +159 -0
- unitrefine/models_hub.py +118 -0
- unitrefine/phy_curation.py +306 -0
- unitrefine/phy_import.py +428 -0
- unitrefine/predictions.py +507 -0
- unitrefine/resources/curation_gui.jpg +0 -0
- unitrefine/resources/fonts/Inter-Bold.ttf +0 -0
- unitrefine/resources/fonts/Inter-LICENSE.txt +92 -0
- unitrefine/resources/fonts/Inter-Regular.ttf +0 -0
- unitrefine/resources/logo.png +0 -0
- unitrefine/resources/model_training_example.jpg +0 -0
- unitrefine/resources/predict-gui.JPG +0 -0
- unitrefine/resources/re-label.JPG +0 -0
- unitrefine/resources/si-gui.JPG +0 -0
- unitrefine/resources/train_and_load_gui.JPG +0 -0
- unitrefine/resources/unit_types_example.jpg +0 -0
- unitrefine/resources/unitrefine_gui.JPG +0 -0
- unitrefine/retraining_history.py +220 -0
- unitrefine/sigui_progress.py +157 -0
- unitrefine/theme.py +254 -0
- unitrefine/train.py +622 -0
- unitrefine/widgets.py +961 -0
- unitrefine-0.2.0.dist-info/METADATA +153 -0
- unitrefine-0.2.0.dist-info/RECORD +40 -0
- unitrefine-0.2.0.dist-info/WHEEL +5 -0
- unitrefine-0.2.0.dist-info/entry_points.txt +4 -0
- unitrefine-0.2.0.dist-info/licenses/LICENSE +21 -0
- unitrefine-0.2.0.dist-info/top_level.txt +1 -0
unitrefine/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
|
+
]
|