ml_meta_perf.plots
Figures for the study.
matplotlib is imported through the Agg backend so these run headless, in CI and over ssh, without a display. Every function takes a destination path, writes one figure, and returns that path, so the caller decides where output lands and nothing is written as a side effect of importing.
No titles and no annotations. These figures are written for a LaTeX document where the caption carries the description, and a title rendered into the PNG would duplicate it in a font the document cannot restyle. What stays is the part a caption cannot replace: axis labels, tick labels, and legends identifying the series. Anything a reader would otherwise have to be told is instead drawn -- a reference level becomes a line with a legend entry, not a sentence.
Each figure carries one message and gets its own caption. The ranking-quality figure uses two aligned axes because its unitless ranking scores and MCC regret must not share a scale.
The palette is colour-blind safe and every series is distinguished by marker or line style as well as colour, so the figures survive being printed in greyscale.
1"""Figures for the study. 2 3matplotlib is imported through the Agg backend so these run headless, in CI and over 4ssh, without a display. Every function takes a destination path, writes one figure, and 5returns that path, so the caller decides where output lands and nothing is written as a 6side effect of importing. 7 8**No titles and no annotations.** These figures are written for a LaTeX document where 9the caption carries the description, and a title rendered into the PNG would duplicate it 10in a font the document cannot restyle. What stays is the part a caption cannot replace: 11axis labels, tick labels, and legends identifying the series. Anything a reader would 12otherwise have to be *told* is instead drawn -- a reference level becomes a line with a 13legend entry, not a sentence. 14 15Each figure carries one message and gets its own caption. The ranking-quality figure uses two 16aligned axes because its unitless ranking scores and MCC regret must not share a scale. 17 18The palette is colour-blind safe and every series is distinguished by marker or line 19style as well as colour, so the figures survive being printed in greyscale. 20""" 21 22from __future__ import annotations 23 24import tempfile 25import time 26from pathlib import Path 27 28import matplotlib 29 30matplotlib.use("Agg") 31 32import matplotlib.pyplot as plt 33import numpy as np 34import polars as pl 35from matplotlib.figure import Figure 36from matplotlib.patches import Rectangle 37from matplotlib.text import Text 38 39IN_SAMPLE = "#1b6ca8" 40LOO_DATASET = "#d1495b" 41LOO_MODEL = "#00798c" 42CEILING = "#6b7280" 43POSITIVE = "#00798c" 44NEGATIVE = "#d1495b" 45 46FIGURE_DPI = 150 47 48# The current paper uses elsarticle's preprint, 12pt layout. In that layout the 49# standard article class gives \textwidth = 390pt. DejaVu Sans looks larger than 50# the paper's Latin Modern Roman at the same nominal size; 8.5pt labels align 51# visually with its body text and captions after \includegraphics scales the PDF. 52PAPER_FONT_SIZE_PT = 8.5 53PAPER_TEXT_WIDTH_PT = 390.0 54 55 56#: Written alongside the raster for every figure. A vector copy is what a paper's 57#: typesetting actually wants, and generating it here costs one extra ``savefig``. 58VECTOR_SUFFIX = ".pdf" 59 60 61def _save_atomic(figure: Figure, path: Path, *, vector: bool) -> None: 62 """Render beside ``path`` and replace it, tolerating brief Windows reader locks.""" 63 with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False) as handle: 64 temporary = Path(handle.name) 65 try: 66 if vector: 67 figure.savefig( 68 temporary, 69 format="pdf", 70 bbox_inches="tight", 71 transparent=True, 72 metadata={"CreationDate": None}, 73 ) 74 else: 75 figure.savefig( 76 temporary, 77 format="png", 78 dpi=FIGURE_DPI, 79 bbox_inches="tight", 80 transparent=True, 81 ) 82 for attempt in range(10): 83 try: 84 temporary.replace(path) 85 return 86 except OSError: 87 if attempt == 9: 88 raise 89 time.sleep(0.1) 90 finally: 91 temporary.unlink(missing_ok=True) 92 93 94def _match_paper_font(figure: Figure, width_fraction: float, *, tight_layout: bool) -> None: 95 """Match label size to the body text after scaling to the paper width.""" 96 target_width_pt = PAPER_TEXT_WIDTH_PT * width_fraction 97 font_size = PAPER_FONT_SIZE_PT 98 for _ in range(8): 99 for axes in figure.axes: 100 axes.tick_params(axis="both", labelsize=font_size) 101 for label in figure.findobj(match=Text): 102 label.set_fontsize(font_size) 103 if tight_layout: 104 figure.tight_layout() 105 figure.canvas.draw() 106 bounds = figure.get_tightbbox(figure.canvas.get_renderer()) 107 saved_width_pt = 72 * (bounds.width + 2 * plt.rcParams["savefig.pad_inches"]) 108 revised = PAPER_FONT_SIZE_PT * saved_width_pt / target_width_pt 109 if abs(revised - font_size) < 0.01: 110 break 111 font_size = revised 112 113 114def _finish( 115 figure: Figure, 116 destination: str | Path, 117 *, 118 paper_width_fraction: float = 0.99, 119 tight_layout: bool = True, 120) -> Path: 121 """Write the figure as PNG and PDF, both on a transparent background. 122 123 Transparent rather than white so a figure sits on whatever the page behind it is, 124 without a rectangle of the wrong shade around it. Text and ticks keep matplotlib's 125 near-black default, which suits the white or near-white page these are written for; 126 on a dark background they would need restyling, and transparency alone would not be 127 enough. 128 129 The returned path is the PNG: it is what the markdown chapters embed. The PDF sits 130 beside it under the same stem, since a paper's typesetting wants the vector copy. 131 """ 132 path = Path(destination) 133 path.parent.mkdir(parents=True, exist_ok=True) 134 _match_paper_font(figure, paper_width_fraction, tight_layout=tight_layout) 135 _save_atomic(figure, path, vector=False) 136 # `CreationDate: None` because matplotlib otherwise stamps the PDF with the wall clock, 137 # so every run rewrote seven tracked figures with byte-different, visually identical 138 # files. That makes `git status` dirty after any run and makes "did this change the 139 # output?" -- the check this project verifies optimisations with -- unanswerable for the 140 # vector figures. The PNGs were already deterministic. 141 _save_atomic(figure, path.with_suffix(VECTOR_SUFFIX), vector=True) 142 plt.close(figure) 143 return path 144 145 146#: Model features abbreviated in rendered term labels. The full names are five syllables long 147#: and a full equation figure cannot carry them; the glossary in chapter 1 expands them. 148TERM_ABBREVIATIONS: dict[str, str] = { 149 "Processing Units Number": "PUN", 150 "Model Capability": "MC", 151 "Input Distribution Modelling": "IDM", 152 "Fitting Regime": "FR", 153 "Solution Stochasticity": "SS", 154 "Loss Margin Behaviour": "LMB", 155} 156 157 158#: Division rendered inline, as ``\nicefrac`` would set it, rather than as a built-up 159#: ``\frac``. Mathtext shrinks the two levels of a built-up fraction to fit the line, so a 160#: figure mixing ``a * b`` with ``a / b`` labels drew half its terms at full size and half at 161#: roughly two thirds of it -- see the term-effect figure before 2026-09-07, where 162#: ``log(gravity) / log(PUN)`` was visibly smaller than the product above it. An inline slash 163#: keeps every label on one baseline at one size, which is the whole readability argument. 164DIVIDE = r"\,/\," 165 166 167def _math_atom(text: str) -> tuple[str, bool]: 168 """One operand of a term as mathtext, and whether it nests without parentheses. 169 170 The flag is what keeps an inline slash unambiguous. ``1/f`` renders as ``1 / f``, so 171 dropping it into ``[1/f] / [g]`` unparenthesised would read ``1 / f / g`` -- a different 172 expression. Atoms that bracket themselves (a parenthesised ``\\log``, a radical, a power, 173 a bare name) need no help; the reciprocal is the only form that does. 174 """ 175 text = text.strip() 176 # ``\log(x)``, not ``\log x``: without the parentheses a product reads as 177 # ``log gravity * log MC``, where nothing on the page says how far the first log extends. 178 for pattern, wrap in ((r"log(", r"\log({})"), (r"sqrt(", r"\sqrt{{{}}}")): 179 if text.startswith(pattern) and text.endswith(")"): 180 return wrap.format(_math_atom(text[len(pattern) : -1])[0]), True 181 if text.endswith("^2"): 182 return _math_atom(text[:-2])[0] + "^{2}", True 183 if text.startswith("1/"): 184 return "1" + DIVIDE + _nested(text[2:]), False 185 if all(character.isdigit() or character == "." for character in text): 186 return text, True 187 name = TERM_ABBREVIATIONS.get(text, text) 188 return r"\mathrm{" + name.replace("_", r"\_").replace(" ", r"\ ") + "}", True 189 190 191def _unwrap(text: str) -> str: 192 """Drop the ``[...]`` a term name puts around each operand.""" 193 text = text.strip() 194 return text[1:-1] if text.startswith("[") and text.endswith("]") else text 195 196 197def _math_operand(text: str) -> str: 198 """An operand standing alone, where no parentheses are needed.""" 199 return _math_atom(_unwrap(text))[0] 200 201 202def _nested(text: str) -> str: 203 """An operand standing inside a larger expression, parenthesised if it has to be.""" 204 expression, atomic = _math_atom(_unwrap(text)) 205 return expression if atomic else r"\left(" + expression + r"\right)" 206 207 208def _split_top(text: str, operator: str) -> tuple[str, str] | None: 209 """Split on ``operator`` at bracket depth zero, so operators inside `[...]` are ignored.""" 210 depth = 0 211 for index, character in enumerate(text): 212 if character in "[(": 213 depth += 1 214 elif character in "])": 215 depth -= 1 216 elif depth == 0 and text.startswith(operator, index): 217 return text[:index], text[index + len(operator) :] 218 return None 219 220 221def term_to_math(name: str) -> str: 222 """A term name as a mathtext expression, set on one line at one size. 223 224 Term names are written for a CSV -- ``[log(gravity)] / [log(Processing Units Number)]`` -- 225 and a figure full of those is a wall of brackets. Rendered as mathematics the same 226 term is one expression, which is how it would appear in the paper the equation is for. 227 228 Three conventions, all chosen for a figure whose labels have to be read at a glance 229 beside each other rather than for typographic elegance in isolation: 230 231 * **Division inline, not built up.** ``a / b`` rather than a two-storey ``\\frac``; see 232 ``DIVIDE``. Every label then has the same x-height, which a mixed set does not. 233 * **Logarithms parenthesised.** ``\\log(a) \\times \\log(b)``, never ``\\log a \\log b``, 234 which does not say where the first logarithm stops. 235 * **Multiplication as ``\\times``.** A centre dot is easy to lose next to a decimal point 236 and next to the slash, at the size these labels are set. 237 238 Precedence is explicit rather than assumed: a term name has exactly one operator at the 239 top level, and any operand that is itself a division is parenthesised on the way in, so 240 ``[1/f] / [g]`` sets as ``(1 / f) / g`` and never as ``1 / f / g``. 241 """ 242 text = name.strip() 243 ratio = _split_top(text, " / ") 244 if ratio is not None: 245 numerator, denominator = ratio 246 inner = numerator.strip() 247 if inner.startswith("(") and inner.endswith(")"): 248 summed = _split_top(inner[1:-1], " + ") 249 if summed is not None: 250 left, right = summed 251 summed_math = _math_operand(left) + " + " + _math_operand(right) 252 return "$" + r"\left(" + summed_math + r"\right)" + DIVIDE + _nested(denominator) + "$" 253 return "$" + _nested(numerator) + DIVIDE + _nested(denominator) + "$" 254 product = _split_top(text, " * ") 255 if product is not None: 256 left, right = product 257 return "$" + _nested(left) + r" \times " + _nested(right) + "$" 258 return "$" + _math_operand(text) + "$" 259 260 261def _length_ticks(sizes: np.ndarray, limit: int = 16) -> np.ndarray: 262 """Tick positions for a length axis, thinned so the labels stay readable. 263 264 The curve is reported at every length now, which is right for the detector and wrong for 265 the axis: 32 labels collide into a grey band. Every nth length is labelled instead, with 266 the last one always kept so the axis states its own range. 267 """ 268 if sizes.shape[0] <= limit: 269 return sizes 270 step = int(np.ceil(sizes.shape[0] / limit)) 271 kept = list(sizes[::step]) 272 # Append the final length so the axis states its own range -- unless it would sit on top 273 # of the tick before it, which is what produced a "3132" smudge at the right-hand end. 274 if sizes[-1] not in kept: 275 if kept and sizes[-1] - kept[-1] < step: 276 kept[-1] = sizes[-1] 277 else: 278 kept.append(sizes[-1]) 279 return np.asarray(kept) 280 281 282def term_count_curve( 283 curve: pl.DataFrame, 284 destination: str | Path, 285 *, 286 reference: float | None = None, 287 marker: int | None = None, 288 marker_label: str | None = None, 289) -> Path: 290 """Accuracy against equation length: the explainability trade. 291 292 The reference line is the point of the figure. Without it a reader sees a curve still 293 climbing and assumes more terms would keep paying, when the whole approach is bounded 294 well below 1. It is drawn as a labelled line rather than described in text. 295 296 **It is labelled "additive mean-based reference", not "additive ceiling".** 297 `validate.additive_mean_reference` implements the descriptive reference that is additive 298 in *dataset effect plus model effect* -- observed group means and nothing else. E3 carries 299 mixed terms, each multiplying a dataset feature by a model one, so it represents 300 interactions the additive reference cannot and can cross it at longer lengths. Calling 301 that line a ceiling would therefore misstate what it bounds. 302 """ 303 figure, axes = plt.subplots(figsize=(7.0, 4.4)) 304 sizes = curve["n_terms"].to_numpy() 305 306 axes.plot(sizes, curve["r2_in_sample"].to_numpy(), "o-", color=IN_SAMPLE, label="IS", linewidth=2) 307 if "r2_loo_dataset" in curve.columns: 308 axes.plot(sizes, curve["r2_loo_dataset"].to_numpy(), "s--", color=LOO_DATASET, label="LODO") 309 if "r2_loo_model" in curve.columns: 310 axes.plot(sizes, curve["r2_loo_model"].to_numpy(), "^:", color=LOO_MODEL, label="LOMO") 311 if "r2_loo_cell" in curve.columns: 312 axes.plot(sizes, curve["r2_loo_cell"].to_numpy(), "d-.", color="#D1495B", label="DHO") 313 if reference is not None: 314 axes.axhline( 315 reference, 316 color=CEILING, 317 linestyle="-.", 318 linewidth=1.2, 319 label=f"additive mean-based reference ({reference:.3f})", 320 ) 321 # Headroom above whichever is higher. Pinning the top to the reference cropped the 322 # IS curve the moment it crossed -- which is exactly when it matters most. 323 highest = max(float(curve["r2_in_sample"].to_numpy().max()), reference) 324 axes.set_ylim(top=highest + 0.05) 325 326 if marker is not None: 327 # This figure is *about* choosing a length, so the chosen one belongs on it. Named by 328 # the caller for the same reason `error_curve`'s is: a line whose label is fixed in 329 # the plotting code cannot be kept in step with what is actually being passed. 330 name = marker_label or "marked length" 331 axes.axvline(marker, color=CEILING, linestyle=":", linewidth=1.4, label=f"{name} ({marker} terms)") 332 333 axes.set_xlabel("number of terms") 334 axes.set_ylabel("$R^2$") 335 axes.set_xticks(_length_ticks(sizes)) 336 axes.grid(alpha=0.25, linestyle=":") 337 axes.legend(loc="lower right", facecolor="white", edgecolor="none", framealpha=0.9) 338 return _finish(figure, destination, paper_width_fraction=0.95) 339 340 341def scatter_limits( 342 truth: np.ndarray, 343 predicted: np.ndarray, 344 margin: float = 0.05, 345 floor: float = 0.0, 346) -> tuple[float, float]: 347 """Square axis bounds covering both series, padded by ``margin`` and stopping at ``floor``. 348 349 Deliberately not MCC's theoretical [-1, 1]. Nothing here approaches -1 -- the observed 350 minimum is -0.29, one row -- and the interval below zero is worse than empty, it is 351 uninformative: around MCC = 0 sit 15 degenerate results and one anti-correlated one, 352 carrying no linear structure for the diagonal to be read against. Including them only 353 compresses the range where the relationship lives. 354 """ 355 lowest = max(float(min(truth.min(), predicted.min())) - margin, floor) 356 highest = float(max(truth.max(), predicted.max())) + margin 357 return lowest, highest 358 359 360def count_below_floor(truth: np.ndarray, predicted: np.ndarray, floor: float = 0.0) -> int: 361 """How many points ``predicted_versus_actual`` leaves outside its axes. 362 363 Kept separate from the figure so the disclosure goes in the caption rather than being 364 rendered into the image, where it could not be restyled or edited. 365 """ 366 return int(np.sum((truth < floor) | (predicted < floor))) 367 368 369def predicted_versus_actual( 370 truth: np.ndarray, 371 predicted: np.ndarray, 372 destination: str | Path, 373 *, 374 margin: float = 0.05, 375 floor: float = 0.0, 376 groups: np.ndarray | None = None, 377) -> Path: 378 """A scatter against the diagonal. 379 380 17% of the meta-dataset sits at exactly MCC = 1.0 and 8% at exactly 0.0. Both pile-ups 381 are the most important thing to see about this target, and a scatter shows them in a 382 way no summary statistic does. 383 384 ``groups`` is accepted and unused. A rug of the target's marginal distribution was drawn 385 along the bottom axis and removed: on an axis that starts at zero it reads as a row of 386 predictions at MCC = 0, which is exactly the region this target genuinely occupies, and 387 no caption reliably undoes that. 388 """ 389 limits = scatter_limits(truth, predicted, margin, floor) 390 391 figure, axes = plt.subplots(figsize=(5.6, 5.4)) 392 axes.plot(limits, limits, color=CEILING, linewidth=1.0, linestyle="--", label="perfect", zorder=1) 393 axes.scatter(truth, predicted, s=18, alpha=0.55, color=IN_SAMPLE, edgecolor="none", zorder=2) 394 axes.set_xlim(limits) 395 axes.set_ylim(limits) 396 axes.set_aspect("equal") 397 axes.set_xlabel("actual MCC") 398 axes.set_ylabel("predicted MCC") 399 axes.grid(alpha=0.25, linestyle=":") 400 axes.legend(frameon=False, loc="upper left", fontsize=9) 401 return _finish(figure, destination, paper_width_fraction=0.64) 402 403 404def equation_comparison(comparison: pl.DataFrame, destination: str | Path) -> Path: 405 """Every equation and every ceiling on one scale. 406 407 The headline result of the study. Equations are drawn solid and the reference levels 408 they are read against are drawn hatched immediately beside them, so the gap each 409 equation leaves against its own limit is read directly off the figure instead of 410 computed by the reader from a table. 411 412 **The capability equation is deliberately absent.** It appears in one table in chapter 4 413 and nowhere else, because a bar chart of headline equations is exactly the place a reader 414 would take it for a second recommendation. Its purpose is to answer one question about how 415 far the form reaches, and a figure cannot carry that qualification. 416 417 **"Reference level", not "ceiling", because one of the three is not a ceiling for the 418 bar next to it.** The E1 and E2 references are genuine ceilings -- true per-group means 419 are the most a predictor constant within that group can achieve. The additive mean-based reference is 420 a ceiling only for an equation additive in dataset *and* model effects, and E3 is not 421 one: its mixed terms carry interactions that the additive reference cannot represent. The current 422 E3 passes that reference, which is permitted because a mixed equation is not structurally 423 bounded by it. 424 """ 425 table = comparison.filter(~pl.col("equation").str.contains("capability")) 426 labels = table["equation"].to_list() 427 values = table["r2"].to_numpy() 428 is_ceiling = [("reference" in label) or ("oracle" in label) for label in labels] 429 430 figure, axes = plt.subplots(figsize=(8.6, 4.2)) 431 positions = np.arange(len(labels)) 432 axes.bar( 433 positions, 434 values, 435 color=[CEILING if ceiling else IN_SAMPLE for ceiling in is_ceiling], 436 hatch=["//" if ceiling else "" for ceiling in is_ceiling], 437 alpha=0.85, 438 edgecolor="white", 439 ) 440 axes.set_xticks(positions) 441 short_labels = [] 442 for label in labels: 443 if label.startswith("E1 reference"): 444 short_labels.append("dataset\nmean") 445 elif label.startswith("E2 reference"): 446 short_labels.append("model\nmean") 447 elif label.startswith("reference: additive"): 448 short_labels.append("additive\nmean") 449 elif label.startswith("E3-Valid"): 450 short_labels.append("E3-\nValid") 451 elif label.startswith("E3-MAX"): 452 short_labels.append("E3-\nMAX") 453 else: 454 short_labels.append(label.split(",", 1)[0]) 455 axes.set_xticklabels(short_labels) 456 axes.set_ylabel("$R^2$ on observed pairs") 457 axes.grid(axis="y", alpha=0.25, linestyle=":") 458 459 solid = Rectangle((0, 0), 1, 1, facecolor=IN_SAMPLE, alpha=0.85) 460 hatched = Rectangle((0, 0), 1, 1, facecolor=CEILING, alpha=0.85, hatch="//") 461 axes.legend([solid, hatched], ["fitted equation", "reference level"], frameon=False, fontsize=9) 462 return _finish(figure, destination) 463 464 465def term_effects(effects: pl.DataFrame, destination: str | Path, *, top: int | None = None) -> Path: 466 """Per-term effect sizes in MCC units, signed, strongest at the top. 467 468 ``top`` defaults to every term in the equation so the small-effect terms remain visible; 469 those are the terms a reader most needs to see when judging the brevity argument. 470 471 Terms are rendered as mathematics rather than as their CSV names -- a ratio becomes a 472 fraction, a product a centre dot -- because a figure full of bracketed strings is a 473 wall of punctuation, and the equation is written for a paper. `term_to_math` does it and 474 `TERM_ABBREVIATIONS` shortens the five-syllable model features; chapter 1 expands them. 475 """ 476 table = (effects if top is None else effects.head(top)).reverse() 477 labels = [term_to_math(name) for name in table["term"].to_list()] 478 values = table["effect"].to_numpy() * np.sign(table["beta"].to_numpy()) 479 480 # At 12pt on the page, each of the 18 term labels needs its own readable row. 481 figure_height = max(2.5, 0.29 * len(labels) + 1.6) 482 figure, axes = plt.subplots(figsize=(8.0, figure_height)) 483 axes.barh( 484 range(len(labels)), 485 values, 486 height=0.4, 487 color=[POSITIVE if value > 0 else NEGATIVE for value in values], 488 alpha=0.85, 489 ) 490 axes.set_yticks(range(len(labels))) 491 axes.set_yticklabels(labels, fontsize=7.5) 492 axes.axvline(0.0, color="black", linewidth=0.8) 493 axes.set_xlabel("effect on predicted MCC") 494 axes.tick_params(axis="x", labelsize=8) 495 axes.grid(axis="x", alpha=0.25, linestyle=":") 496 return _finish(figure, destination, paper_width_fraction=0.99) 497 498 499def practice_effects(practices: pl.DataFrame, destination: str | Path) -> Path: 500 """What the fitted equation says each raw feature does to MCC, and how stable that is. 501 502 One bar per feature the equation uses. Its length is the change in **predicted MCC** 503 between that feature's lowest and highest decile, holding the rest of the equation -- 504 so a bar reaching -0.6 means the equation predicts 0.6 less MCC at the top of that 505 feature's range than at the bottom. Sign is measured on the data rather than read off a 506 weight, because a feature can appear in several terms and inside denominators, and then 507 it has no single weight to read. 508 509 Opacity is the confidence its written practice was rated at, which is driven by how often 510 the feature's terms survived reselection across folds. A long bar at low opacity is a 511 large effect the folds disagreed about; both facts are needed and neither is the other. 512 513 This is the evidence layer of chapter 6, and it is not itself advice: a statement about a 514 meta-feature column becomes a practice only when it supports or contradicts something a 515 practitioner could already have been told. 516 """ 517 table = practices.reverse() 518 labels = [TERM_ABBREVIATIONS.get(str(name), str(name)) for name in table["feature"].to_list()] 519 values = table["effect"].to_numpy() 520 alphas = _confidence_alpha(table) 521 522 figure, axes = plt.subplots(figsize=(7.8, 0.44 * len(labels) + 1.4)) 523 for index, (value, alpha) in enumerate(zip(values, alphas, strict=True)): 524 axes.barh(index, value, color=POSITIVE if value > 0 else NEGATIVE, alpha=alpha) 525 axes.set_yticks(range(len(labels))) 526 axes.set_yticklabels(labels, fontsize=9) 527 axes.axvline(0.0, color="black", linewidth=0.8) 528 axes.set_xlabel("change in predicted MCC") 529 axes.grid(axis="x", alpha=0.25, linestyle=":") 530 531 # Only the levels actually present, and drawn in the palette the bars use. A legend in a 532 # third colour reads as a third series, and one advertising levels the table does not 533 # contain describes a distinction that is not on the plot. 534 present = _confidence_levels(table) 535 if present: 536 handles = [Rectangle((0, 0), 1, 1, facecolor=POSITIVE, alpha=CONFIDENCE_ALPHA[name]) for name in present] 537 figure.legend(handles, present, frameon=False, loc="upper right", title="confidence") 538 figure.subplots_adjust(left=0.18, right=0.98, bottom=0.24, top=0.72) 539 return _finish(figure, destination, tight_layout=False) 540 541 542#: Bar opacity per rated confidence. ``unrated`` is deliberately the faintest rather than 543#: sharing ``moderate``'s value: an unrated practice is one whose term never stabilised, which 544#: is weaker evidence than a moderate rating, not equal to it. 545CONFIDENCE_ALPHA: dict[str, float] = {"strong": 0.95, "moderate": 0.65, "weak": 0.4, "unrated": 0.25} 546 547 548def _confidence_levels(table: pl.DataFrame) -> list[str]: 549 """The rated levels present in ``table``, strongest first.""" 550 if "confidence" not in table.columns: 551 return [] 552 seen = {str(value) for value in table["confidence"].to_list()} 553 return [name for name in CONFIDENCE_ALPHA if name in seen] 554 555 556def _confidence_alpha(table: pl.DataFrame) -> list[float]: 557 if "confidence" not in table.columns: 558 return [0.85] * table.height 559 return [CONFIDENCE_ALPHA.get(str(value), 0.25) for value in table["confidence"].to_list()] 560 561 562def _shorten(name: str, limit: int = 46) -> str: 563 return name if len(name) <= limit else name[: limit - 1] + "…" 564 565 566def _wrap(label: str, width: int = 18) -> str: 567 words = label.split() 568 lines: list[str] = [] 569 current = "" 570 for word in words: 571 candidate = f"{current} {word}".strip() 572 if len(candidate) > width and current: 573 lines.append(current) 574 current = word 575 else: 576 current = candidate 577 if current: 578 lines.append(current) 579 return "\n".join(lines) 580 581 582def decision_quality(decision: pl.DataFrame, destination: str | Path) -> Path: 583 """The go/no-go decision against threshold, as **F1**, one line per protocol. 584 585 One metric, four lines, and what differs between them is only how much the equation was 586 allowed to see. Previously this drew the equation's accuracy, the equation's F1 and a 587 majority-class baseline on one axis -- two metrics and a predictor, so no reader could 588 tell which metric the baseline was being scored on. 589 590 F1 rather than accuracy: the classes are unbalanced at the outer thresholds, where always 591 answering with the larger one reaches 0.51 accuracy at an F1 of exactly zero. Accuracy 592 would draw four flattering lines and hide that. The trivial predictors stay in the table 593 rather than on the plot, because they cannot be computed under the strictest protocol at 594 all -- a model held out of every fold has no rows to average. 595 596 The four lines are the point: the gap between the top and the bottom one is the whole cost 597 of generalisation on this task, and on this corpus it is small. The legend uses IS for 598 in-sample (IS), leave-one-dataset-out (LODO), leave-one-model-out (LOMO), and doubly 599 held-out (DHO), in which both the dataset and model are absent from training. 600 """ 601 figure, axes = plt.subplots(figsize=(6.8, 4.2)) 602 table = decision.sort("threshold") 603 604 order = ["in-sample", "loo-dataset", "loo-model", "loo-cell"] 605 styles = { 606 "in-sample": (IN_SAMPLE, "o-", 1.8), 607 "loo-dataset": (LOO_DATASET, "s--", 1.8), 608 "loo-model": (LOO_MODEL, "^:", 1.8), 609 "loo-cell": ("#2f2f2f", "D-", 2.2), 610 } 611 protocol_labels = { 612 "in-sample": "IS", 613 "loo-dataset": "LODO", 614 "loo-model": "LOMO", 615 "loo-cell": "DHO", 616 } 617 618 if "predictor" in table.columns: 619 names = [str(name) for name in table["predictor"].unique(maintain_order=True)] 620 equations = [name for name in names if "equation" in name.lower()] 621 equations.sort(key=lambda name: next((i for i, k in enumerate(order) if k in name), len(order))) 622 for name in equations: 623 rows = table.filter(pl.col("predictor") == name).sort("threshold") 624 key = next((k for k in order if k in name), "in-sample") 625 colour, marker, width = styles[key] 626 axes.plot( 627 rows["threshold"], 628 rows["f1"], 629 marker, 630 color=colour, 631 label=protocol_labels[key], 632 linewidth=width, 633 ) 634 else: 635 axes.plot(table["threshold"], table["f1"], "o-", color=IN_SAMPLE, label="equation", linewidth=2) 636 637 axes.set_xlabel("MCC threshold for the go/no-go decision") 638 axes.set_ylabel("F1 of the decision") 639 axes.set_xticks(sorted({float(value) for value in table["threshold"]})) 640 axes.grid(alpha=0.25, linewidth=0.6) 641 axes.legend(fontsize=9, loc="lower left", framealpha=0.0, title="Evaluation protocol") 642 return _finish(figure, destination, paper_width_fraction=0.73) 643 644 645def ranking_quality(selection: pl.DataFrame, destination: str | Path) -> Path: 646 """Per-dataset ranking quality beside what a bad ranking actually *costs*, in MCC. 647 648 Ranking metrics alone raise a question they cannot answer. Four datasets here score 649 average precision below 0.85 and two below 0.5, which reads as serious failure -- until 650 one asks what following the bad ranking costs, and the answer is at most 0.13 MCC and on 651 most of them nothing at all. The two belong together: a dataset whose models all score 652 within a hair of each other is *unrankable*, and ranking it badly is not a cost. 653 654 Two panels sharing the dataset axis rather than two scales on one axis. Regret is in MCC 655 and the ranking metrics are unitless, so overlaying them would repeat the mistake this 656 figure exists to avoid -- a reader cannot tell which axis a marker belongs to. Sorted by 657 average precision, so the hard datasets group at the bottom and the cost of each sits 658 directly beside it. 659 """ 660 table = selection.sort("ap", descending=True) 661 positions = np.arange(table.height) 662 663 figure, (axes, cost) = plt.subplots( 664 1, 665 2, 666 figsize=(9.0, 0.37 * table.height + 2.0), 667 sharey=True, 668 gridspec_kw={"width_ratios": [2.1, 1.0], "wspace": 0.10}, 669 ) 670 671 axes.barh(positions - 0.16, table["ap"], height=0.28, color=IN_SAMPLE, label="average precision") 672 axes.barh(positions + 0.16, table["mrr"], height=0.28, color=LOO_MODEL, label="reciprocal rank") 673 hits = [index for index, value in enumerate(table["hit_at_1"]) if value >= 1.0] 674 if hits: 675 axes.scatter( 676 [0.03] * len(hits), 677 hits, 678 marker="*", 679 s=70, 680 color="white", 681 edgecolor="#333333", 682 linewidth=0.7, 683 zorder=5, 684 label="top choice within 0.01 of best", 685 ) 686 axes.set_yticks(positions) 687 axes.set_yticklabels([_shorten(name, 24) for name in table["group"]], fontsize=8) 688 axes.set_xlim(0.0, 1.0) 689 axes.set_xticks([0.0, 0.2, 0.4, 0.6, 0.8]) 690 axes.set_xlabel("average precision /\nreciprocal rank") 691 axes.invert_yaxis() 692 axes.grid(alpha=0.25, linewidth=0.6, axis="x") 693 handles, labels = axes.get_legend_handles_labels() 694 figure.legend(handles, labels, loc="upper center", bbox_to_anchor=(0.5, 0.995), ncol=2, frameon=False) 695 696 regret = table["regret"].to_numpy() 697 cost.barh(positions, regret, height=0.42, color=[CEILING if v <= 1e-9 else NEGATIVE for v in regret]) 698 cost.set_xlabel("top-choice regret\n(MCC)") 699 cost.grid(alpha=0.25, linewidth=0.6, axis="x") 700 cost.tick_params(labelleft=False) 701 # ``tight_layout`` warns on this shared-y pair in Matplotlib even though the axes are 702 # placed correctly. Set the margins explicitly and retain ``bbox_inches='tight'`` in 703 # `_finish`, which still expands the saved canvas around labels and the legend. 704 figure.subplots_adjust(left=0.30, right=0.98, bottom=0.10, top=0.89, wspace=0.10) 705 return _finish(figure, destination, paper_width_fraction=0.99, tight_layout=False)
222def term_to_math(name: str) -> str: 223 """A term name as a mathtext expression, set on one line at one size. 224 225 Term names are written for a CSV -- ``[log(gravity)] / [log(Processing Units Number)]`` -- 226 and a figure full of those is a wall of brackets. Rendered as mathematics the same 227 term is one expression, which is how it would appear in the paper the equation is for. 228 229 Three conventions, all chosen for a figure whose labels have to be read at a glance 230 beside each other rather than for typographic elegance in isolation: 231 232 * **Division inline, not built up.** ``a / b`` rather than a two-storey ``\\frac``; see 233 ``DIVIDE``. Every label then has the same x-height, which a mixed set does not. 234 * **Logarithms parenthesised.** ``\\log(a) \\times \\log(b)``, never ``\\log a \\log b``, 235 which does not say where the first logarithm stops. 236 * **Multiplication as ``\\times``.** A centre dot is easy to lose next to a decimal point 237 and next to the slash, at the size these labels are set. 238 239 Precedence is explicit rather than assumed: a term name has exactly one operator at the 240 top level, and any operand that is itself a division is parenthesised on the way in, so 241 ``[1/f] / [g]`` sets as ``(1 / f) / g`` and never as ``1 / f / g``. 242 """ 243 text = name.strip() 244 ratio = _split_top(text, " / ") 245 if ratio is not None: 246 numerator, denominator = ratio 247 inner = numerator.strip() 248 if inner.startswith("(") and inner.endswith(")"): 249 summed = _split_top(inner[1:-1], " + ") 250 if summed is not None: 251 left, right = summed 252 summed_math = _math_operand(left) + " + " + _math_operand(right) 253 return "$" + r"\left(" + summed_math + r"\right)" + DIVIDE + _nested(denominator) + "$" 254 return "$" + _nested(numerator) + DIVIDE + _nested(denominator) + "$" 255 product = _split_top(text, " * ") 256 if product is not None: 257 left, right = product 258 return "$" + _nested(left) + r" \times " + _nested(right) + "$" 259 return "$" + _math_operand(text) + "$"
A term name as a mathtext expression, set on one line at one size.
Term names are written for a CSV -- [log(gravity)] / [log(Processing Units Number)] --
and a figure full of those is a wall of brackets. Rendered as mathematics the same
term is one expression, which is how it would appear in the paper the equation is for.
Three conventions, all chosen for a figure whose labels have to be read at a glance beside each other rather than for typographic elegance in isolation:
- Division inline, not built up.
a / brather than a two-storey\frac; seeDIVIDE. Every label then has the same x-height, which a mixed set does not. - Logarithms parenthesised.
\log(a) \times \log(b), never\log a \log b, which does not say where the first logarithm stops. - Multiplication as
\times. A centre dot is easy to lose next to a decimal point and next to the slash, at the size these labels are set.
Precedence is explicit rather than assumed: a term name has exactly one operator at the
top level, and any operand that is itself a division is parenthesised on the way in, so
[1/f] / [g] sets as (1 / f) / g and never as 1 / f / g.
283def term_count_curve( 284 curve: pl.DataFrame, 285 destination: str | Path, 286 *, 287 reference: float | None = None, 288 marker: int | None = None, 289 marker_label: str | None = None, 290) -> Path: 291 """Accuracy against equation length: the explainability trade. 292 293 The reference line is the point of the figure. Without it a reader sees a curve still 294 climbing and assumes more terms would keep paying, when the whole approach is bounded 295 well below 1. It is drawn as a labelled line rather than described in text. 296 297 **It is labelled "additive mean-based reference", not "additive ceiling".** 298 `validate.additive_mean_reference` implements the descriptive reference that is additive 299 in *dataset effect plus model effect* -- observed group means and nothing else. E3 carries 300 mixed terms, each multiplying a dataset feature by a model one, so it represents 301 interactions the additive reference cannot and can cross it at longer lengths. Calling 302 that line a ceiling would therefore misstate what it bounds. 303 """ 304 figure, axes = plt.subplots(figsize=(7.0, 4.4)) 305 sizes = curve["n_terms"].to_numpy() 306 307 axes.plot(sizes, curve["r2_in_sample"].to_numpy(), "o-", color=IN_SAMPLE, label="IS", linewidth=2) 308 if "r2_loo_dataset" in curve.columns: 309 axes.plot(sizes, curve["r2_loo_dataset"].to_numpy(), "s--", color=LOO_DATASET, label="LODO") 310 if "r2_loo_model" in curve.columns: 311 axes.plot(sizes, curve["r2_loo_model"].to_numpy(), "^:", color=LOO_MODEL, label="LOMO") 312 if "r2_loo_cell" in curve.columns: 313 axes.plot(sizes, curve["r2_loo_cell"].to_numpy(), "d-.", color="#D1495B", label="DHO") 314 if reference is not None: 315 axes.axhline( 316 reference, 317 color=CEILING, 318 linestyle="-.", 319 linewidth=1.2, 320 label=f"additive mean-based reference ({reference:.3f})", 321 ) 322 # Headroom above whichever is higher. Pinning the top to the reference cropped the 323 # IS curve the moment it crossed -- which is exactly when it matters most. 324 highest = max(float(curve["r2_in_sample"].to_numpy().max()), reference) 325 axes.set_ylim(top=highest + 0.05) 326 327 if marker is not None: 328 # This figure is *about* choosing a length, so the chosen one belongs on it. Named by 329 # the caller for the same reason `error_curve`'s is: a line whose label is fixed in 330 # the plotting code cannot be kept in step with what is actually being passed. 331 name = marker_label or "marked length" 332 axes.axvline(marker, color=CEILING, linestyle=":", linewidth=1.4, label=f"{name} ({marker} terms)") 333 334 axes.set_xlabel("number of terms") 335 axes.set_ylabel("$R^2$") 336 axes.set_xticks(_length_ticks(sizes)) 337 axes.grid(alpha=0.25, linestyle=":") 338 axes.legend(loc="lower right", facecolor="white", edgecolor="none", framealpha=0.9) 339 return _finish(figure, destination, paper_width_fraction=0.95)
Accuracy against equation length: the explainability trade.
The reference line is the point of the figure. Without it a reader sees a curve still climbing and assumes more terms would keep paying, when the whole approach is bounded well below 1. It is drawn as a labelled line rather than described in text.
It is labelled "additive mean-based reference", not "additive ceiling".
validate.additive_mean_reference implements the descriptive reference that is additive
in dataset effect plus model effect -- observed group means and nothing else. E3 carries
mixed terms, each multiplying a dataset feature by a model one, so it represents
interactions the additive reference cannot and can cross it at longer lengths. Calling
that line a ceiling would therefore misstate what it bounds.
342def scatter_limits( 343 truth: np.ndarray, 344 predicted: np.ndarray, 345 margin: float = 0.05, 346 floor: float = 0.0, 347) -> tuple[float, float]: 348 """Square axis bounds covering both series, padded by ``margin`` and stopping at ``floor``. 349 350 Deliberately not MCC's theoretical [-1, 1]. Nothing here approaches -1 -- the observed 351 minimum is -0.29, one row -- and the interval below zero is worse than empty, it is 352 uninformative: around MCC = 0 sit 15 degenerate results and one anti-correlated one, 353 carrying no linear structure for the diagonal to be read against. Including them only 354 compresses the range where the relationship lives. 355 """ 356 lowest = max(float(min(truth.min(), predicted.min())) - margin, floor) 357 highest = float(max(truth.max(), predicted.max())) + margin 358 return lowest, highest
Square axis bounds covering both series, padded by margin and stopping at floor.
Deliberately not MCC's theoretical [-1, 1]. Nothing here approaches -1 -- the observed minimum is -0.29, one row -- and the interval below zero is worse than empty, it is uninformative: around MCC = 0 sit 15 degenerate results and one anti-correlated one, carrying no linear structure for the diagonal to be read against. Including them only compresses the range where the relationship lives.
361def count_below_floor(truth: np.ndarray, predicted: np.ndarray, floor: float = 0.0) -> int: 362 """How many points ``predicted_versus_actual`` leaves outside its axes. 363 364 Kept separate from the figure so the disclosure goes in the caption rather than being 365 rendered into the image, where it could not be restyled or edited. 366 """ 367 return int(np.sum((truth < floor) | (predicted < floor)))
How many points predicted_versus_actual leaves outside its axes.
Kept separate from the figure so the disclosure goes in the caption rather than being rendered into the image, where it could not be restyled or edited.
370def predicted_versus_actual( 371 truth: np.ndarray, 372 predicted: np.ndarray, 373 destination: str | Path, 374 *, 375 margin: float = 0.05, 376 floor: float = 0.0, 377 groups: np.ndarray | None = None, 378) -> Path: 379 """A scatter against the diagonal. 380 381 17% of the meta-dataset sits at exactly MCC = 1.0 and 8% at exactly 0.0. Both pile-ups 382 are the most important thing to see about this target, and a scatter shows them in a 383 way no summary statistic does. 384 385 ``groups`` is accepted and unused. A rug of the target's marginal distribution was drawn 386 along the bottom axis and removed: on an axis that starts at zero it reads as a row of 387 predictions at MCC = 0, which is exactly the region this target genuinely occupies, and 388 no caption reliably undoes that. 389 """ 390 limits = scatter_limits(truth, predicted, margin, floor) 391 392 figure, axes = plt.subplots(figsize=(5.6, 5.4)) 393 axes.plot(limits, limits, color=CEILING, linewidth=1.0, linestyle="--", label="perfect", zorder=1) 394 axes.scatter(truth, predicted, s=18, alpha=0.55, color=IN_SAMPLE, edgecolor="none", zorder=2) 395 axes.set_xlim(limits) 396 axes.set_ylim(limits) 397 axes.set_aspect("equal") 398 axes.set_xlabel("actual MCC") 399 axes.set_ylabel("predicted MCC") 400 axes.grid(alpha=0.25, linestyle=":") 401 axes.legend(frameon=False, loc="upper left", fontsize=9) 402 return _finish(figure, destination, paper_width_fraction=0.64)
A scatter against the diagonal.
17% of the meta-dataset sits at exactly MCC = 1.0 and 8% at exactly 0.0. Both pile-ups are the most important thing to see about this target, and a scatter shows them in a way no summary statistic does.
groups is accepted and unused. A rug of the target's marginal distribution was drawn
along the bottom axis and removed: on an axis that starts at zero it reads as a row of
predictions at MCC = 0, which is exactly the region this target genuinely occupies, and
no caption reliably undoes that.
405def equation_comparison(comparison: pl.DataFrame, destination: str | Path) -> Path: 406 """Every equation and every ceiling on one scale. 407 408 The headline result of the study. Equations are drawn solid and the reference levels 409 they are read against are drawn hatched immediately beside them, so the gap each 410 equation leaves against its own limit is read directly off the figure instead of 411 computed by the reader from a table. 412 413 **The capability equation is deliberately absent.** It appears in one table in chapter 4 414 and nowhere else, because a bar chart of headline equations is exactly the place a reader 415 would take it for a second recommendation. Its purpose is to answer one question about how 416 far the form reaches, and a figure cannot carry that qualification. 417 418 **"Reference level", not "ceiling", because one of the three is not a ceiling for the 419 bar next to it.** The E1 and E2 references are genuine ceilings -- true per-group means 420 are the most a predictor constant within that group can achieve. The additive mean-based reference is 421 a ceiling only for an equation additive in dataset *and* model effects, and E3 is not 422 one: its mixed terms carry interactions that the additive reference cannot represent. The current 423 E3 passes that reference, which is permitted because a mixed equation is not structurally 424 bounded by it. 425 """ 426 table = comparison.filter(~pl.col("equation").str.contains("capability")) 427 labels = table["equation"].to_list() 428 values = table["r2"].to_numpy() 429 is_ceiling = [("reference" in label) or ("oracle" in label) for label in labels] 430 431 figure, axes = plt.subplots(figsize=(8.6, 4.2)) 432 positions = np.arange(len(labels)) 433 axes.bar( 434 positions, 435 values, 436 color=[CEILING if ceiling else IN_SAMPLE for ceiling in is_ceiling], 437 hatch=["//" if ceiling else "" for ceiling in is_ceiling], 438 alpha=0.85, 439 edgecolor="white", 440 ) 441 axes.set_xticks(positions) 442 short_labels = [] 443 for label in labels: 444 if label.startswith("E1 reference"): 445 short_labels.append("dataset\nmean") 446 elif label.startswith("E2 reference"): 447 short_labels.append("model\nmean") 448 elif label.startswith("reference: additive"): 449 short_labels.append("additive\nmean") 450 elif label.startswith("E3-Valid"): 451 short_labels.append("E3-\nValid") 452 elif label.startswith("E3-MAX"): 453 short_labels.append("E3-\nMAX") 454 else: 455 short_labels.append(label.split(",", 1)[0]) 456 axes.set_xticklabels(short_labels) 457 axes.set_ylabel("$R^2$ on observed pairs") 458 axes.grid(axis="y", alpha=0.25, linestyle=":") 459 460 solid = Rectangle((0, 0), 1, 1, facecolor=IN_SAMPLE, alpha=0.85) 461 hatched = Rectangle((0, 0), 1, 1, facecolor=CEILING, alpha=0.85, hatch="//") 462 axes.legend([solid, hatched], ["fitted equation", "reference level"], frameon=False, fontsize=9) 463 return _finish(figure, destination)
Every equation and every ceiling on one scale.
The headline result of the study. Equations are drawn solid and the reference levels they are read against are drawn hatched immediately beside them, so the gap each equation leaves against its own limit is read directly off the figure instead of computed by the reader from a table.
The capability equation is deliberately absent. It appears in one table in chapter 4 and nowhere else, because a bar chart of headline equations is exactly the place a reader would take it for a second recommendation. Its purpose is to answer one question about how far the form reaches, and a figure cannot carry that qualification.
"Reference level", not "ceiling", because one of the three is not a ceiling for the bar next to it. The E1 and E2 references are genuine ceilings -- true per-group means are the most a predictor constant within that group can achieve. The additive mean-based reference is a ceiling only for an equation additive in dataset and model effects, and E3 is not one: its mixed terms carry interactions that the additive reference cannot represent. The current E3 passes that reference, which is permitted because a mixed equation is not structurally bounded by it.
466def term_effects(effects: pl.DataFrame, destination: str | Path, *, top: int | None = None) -> Path: 467 """Per-term effect sizes in MCC units, signed, strongest at the top. 468 469 ``top`` defaults to every term in the equation so the small-effect terms remain visible; 470 those are the terms a reader most needs to see when judging the brevity argument. 471 472 Terms are rendered as mathematics rather than as their CSV names -- a ratio becomes a 473 fraction, a product a centre dot -- because a figure full of bracketed strings is a 474 wall of punctuation, and the equation is written for a paper. `term_to_math` does it and 475 `TERM_ABBREVIATIONS` shortens the five-syllable model features; chapter 1 expands them. 476 """ 477 table = (effects if top is None else effects.head(top)).reverse() 478 labels = [term_to_math(name) for name in table["term"].to_list()] 479 values = table["effect"].to_numpy() * np.sign(table["beta"].to_numpy()) 480 481 # At 12pt on the page, each of the 18 term labels needs its own readable row. 482 figure_height = max(2.5, 0.29 * len(labels) + 1.6) 483 figure, axes = plt.subplots(figsize=(8.0, figure_height)) 484 axes.barh( 485 range(len(labels)), 486 values, 487 height=0.4, 488 color=[POSITIVE if value > 0 else NEGATIVE for value in values], 489 alpha=0.85, 490 ) 491 axes.set_yticks(range(len(labels))) 492 axes.set_yticklabels(labels, fontsize=7.5) 493 axes.axvline(0.0, color="black", linewidth=0.8) 494 axes.set_xlabel("effect on predicted MCC") 495 axes.tick_params(axis="x", labelsize=8) 496 axes.grid(axis="x", alpha=0.25, linestyle=":") 497 return _finish(figure, destination, paper_width_fraction=0.99)
Per-term effect sizes in MCC units, signed, strongest at the top.
top defaults to every term in the equation so the small-effect terms remain visible;
those are the terms a reader most needs to see when judging the brevity argument.
Terms are rendered as mathematics rather than as their CSV names -- a ratio becomes a
fraction, a product a centre dot -- because a figure full of bracketed strings is a
wall of punctuation, and the equation is written for a paper. term_to_math does it and
TERM_ABBREVIATIONS shortens the five-syllable model features; chapter 1 expands them.
500def practice_effects(practices: pl.DataFrame, destination: str | Path) -> Path: 501 """What the fitted equation says each raw feature does to MCC, and how stable that is. 502 503 One bar per feature the equation uses. Its length is the change in **predicted MCC** 504 between that feature's lowest and highest decile, holding the rest of the equation -- 505 so a bar reaching -0.6 means the equation predicts 0.6 less MCC at the top of that 506 feature's range than at the bottom. Sign is measured on the data rather than read off a 507 weight, because a feature can appear in several terms and inside denominators, and then 508 it has no single weight to read. 509 510 Opacity is the confidence its written practice was rated at, which is driven by how often 511 the feature's terms survived reselection across folds. A long bar at low opacity is a 512 large effect the folds disagreed about; both facts are needed and neither is the other. 513 514 This is the evidence layer of chapter 6, and it is not itself advice: a statement about a 515 meta-feature column becomes a practice only when it supports or contradicts something a 516 practitioner could already have been told. 517 """ 518 table = practices.reverse() 519 labels = [TERM_ABBREVIATIONS.get(str(name), str(name)) for name in table["feature"].to_list()] 520 values = table["effect"].to_numpy() 521 alphas = _confidence_alpha(table) 522 523 figure, axes = plt.subplots(figsize=(7.8, 0.44 * len(labels) + 1.4)) 524 for index, (value, alpha) in enumerate(zip(values, alphas, strict=True)): 525 axes.barh(index, value, color=POSITIVE if value > 0 else NEGATIVE, alpha=alpha) 526 axes.set_yticks(range(len(labels))) 527 axes.set_yticklabels(labels, fontsize=9) 528 axes.axvline(0.0, color="black", linewidth=0.8) 529 axes.set_xlabel("change in predicted MCC") 530 axes.grid(axis="x", alpha=0.25, linestyle=":") 531 532 # Only the levels actually present, and drawn in the palette the bars use. A legend in a 533 # third colour reads as a third series, and one advertising levels the table does not 534 # contain describes a distinction that is not on the plot. 535 present = _confidence_levels(table) 536 if present: 537 handles = [Rectangle((0, 0), 1, 1, facecolor=POSITIVE, alpha=CONFIDENCE_ALPHA[name]) for name in present] 538 figure.legend(handles, present, frameon=False, loc="upper right", title="confidence") 539 figure.subplots_adjust(left=0.18, right=0.98, bottom=0.24, top=0.72) 540 return _finish(figure, destination, tight_layout=False)
What the fitted equation says each raw feature does to MCC, and how stable that is.
One bar per feature the equation uses. Its length is the change in predicted MCC between that feature's lowest and highest decile, holding the rest of the equation -- so a bar reaching -0.6 means the equation predicts 0.6 less MCC at the top of that feature's range than at the bottom. Sign is measured on the data rather than read off a weight, because a feature can appear in several terms and inside denominators, and then it has no single weight to read.
Opacity is the confidence its written practice was rated at, which is driven by how often the feature's terms survived reselection across folds. A long bar at low opacity is a large effect the folds disagreed about; both facts are needed and neither is the other.
This is the evidence layer of chapter 6, and it is not itself advice: a statement about a meta-feature column becomes a practice only when it supports or contradicts something a practitioner could already have been told.
583def decision_quality(decision: pl.DataFrame, destination: str | Path) -> Path: 584 """The go/no-go decision against threshold, as **F1**, one line per protocol. 585 586 One metric, four lines, and what differs between them is only how much the equation was 587 allowed to see. Previously this drew the equation's accuracy, the equation's F1 and a 588 majority-class baseline on one axis -- two metrics and a predictor, so no reader could 589 tell which metric the baseline was being scored on. 590 591 F1 rather than accuracy: the classes are unbalanced at the outer thresholds, where always 592 answering with the larger one reaches 0.51 accuracy at an F1 of exactly zero. Accuracy 593 would draw four flattering lines and hide that. The trivial predictors stay in the table 594 rather than on the plot, because they cannot be computed under the strictest protocol at 595 all -- a model held out of every fold has no rows to average. 596 597 The four lines are the point: the gap between the top and the bottom one is the whole cost 598 of generalisation on this task, and on this corpus it is small. The legend uses IS for 599 in-sample (IS), leave-one-dataset-out (LODO), leave-one-model-out (LOMO), and doubly 600 held-out (DHO), in which both the dataset and model are absent from training. 601 """ 602 figure, axes = plt.subplots(figsize=(6.8, 4.2)) 603 table = decision.sort("threshold") 604 605 order = ["in-sample", "loo-dataset", "loo-model", "loo-cell"] 606 styles = { 607 "in-sample": (IN_SAMPLE, "o-", 1.8), 608 "loo-dataset": (LOO_DATASET, "s--", 1.8), 609 "loo-model": (LOO_MODEL, "^:", 1.8), 610 "loo-cell": ("#2f2f2f", "D-", 2.2), 611 } 612 protocol_labels = { 613 "in-sample": "IS", 614 "loo-dataset": "LODO", 615 "loo-model": "LOMO", 616 "loo-cell": "DHO", 617 } 618 619 if "predictor" in table.columns: 620 names = [str(name) for name in table["predictor"].unique(maintain_order=True)] 621 equations = [name for name in names if "equation" in name.lower()] 622 equations.sort(key=lambda name: next((i for i, k in enumerate(order) if k in name), len(order))) 623 for name in equations: 624 rows = table.filter(pl.col("predictor") == name).sort("threshold") 625 key = next((k for k in order if k in name), "in-sample") 626 colour, marker, width = styles[key] 627 axes.plot( 628 rows["threshold"], 629 rows["f1"], 630 marker, 631 color=colour, 632 label=protocol_labels[key], 633 linewidth=width, 634 ) 635 else: 636 axes.plot(table["threshold"], table["f1"], "o-", color=IN_SAMPLE, label="equation", linewidth=2) 637 638 axes.set_xlabel("MCC threshold for the go/no-go decision") 639 axes.set_ylabel("F1 of the decision") 640 axes.set_xticks(sorted({float(value) for value in table["threshold"]})) 641 axes.grid(alpha=0.25, linewidth=0.6) 642 axes.legend(fontsize=9, loc="lower left", framealpha=0.0, title="Evaluation protocol") 643 return _finish(figure, destination, paper_width_fraction=0.73)
The go/no-go decision against threshold, as F1, one line per protocol.
One metric, four lines, and what differs between them is only how much the equation was allowed to see. Previously this drew the equation's accuracy, the equation's F1 and a majority-class baseline on one axis -- two metrics and a predictor, so no reader could tell which metric the baseline was being scored on.
F1 rather than accuracy: the classes are unbalanced at the outer thresholds, where always answering with the larger one reaches 0.51 accuracy at an F1 of exactly zero. Accuracy would draw four flattering lines and hide that. The trivial predictors stay in the table rather than on the plot, because they cannot be computed under the strictest protocol at all -- a model held out of every fold has no rows to average.
The four lines are the point: the gap between the top and the bottom one is the whole cost of generalisation on this task, and on this corpus it is small. The legend uses IS for in-sample (IS), leave-one-dataset-out (LODO), leave-one-model-out (LOMO), and doubly held-out (DHO), in which both the dataset and model are absent from training.
646def ranking_quality(selection: pl.DataFrame, destination: str | Path) -> Path: 647 """Per-dataset ranking quality beside what a bad ranking actually *costs*, in MCC. 648 649 Ranking metrics alone raise a question they cannot answer. Four datasets here score 650 average precision below 0.85 and two below 0.5, which reads as serious failure -- until 651 one asks what following the bad ranking costs, and the answer is at most 0.13 MCC and on 652 most of them nothing at all. The two belong together: a dataset whose models all score 653 within a hair of each other is *unrankable*, and ranking it badly is not a cost. 654 655 Two panels sharing the dataset axis rather than two scales on one axis. Regret is in MCC 656 and the ranking metrics are unitless, so overlaying them would repeat the mistake this 657 figure exists to avoid -- a reader cannot tell which axis a marker belongs to. Sorted by 658 average precision, so the hard datasets group at the bottom and the cost of each sits 659 directly beside it. 660 """ 661 table = selection.sort("ap", descending=True) 662 positions = np.arange(table.height) 663 664 figure, (axes, cost) = plt.subplots( 665 1, 666 2, 667 figsize=(9.0, 0.37 * table.height + 2.0), 668 sharey=True, 669 gridspec_kw={"width_ratios": [2.1, 1.0], "wspace": 0.10}, 670 ) 671 672 axes.barh(positions - 0.16, table["ap"], height=0.28, color=IN_SAMPLE, label="average precision") 673 axes.barh(positions + 0.16, table["mrr"], height=0.28, color=LOO_MODEL, label="reciprocal rank") 674 hits = [index for index, value in enumerate(table["hit_at_1"]) if value >= 1.0] 675 if hits: 676 axes.scatter( 677 [0.03] * len(hits), 678 hits, 679 marker="*", 680 s=70, 681 color="white", 682 edgecolor="#333333", 683 linewidth=0.7, 684 zorder=5, 685 label="top choice within 0.01 of best", 686 ) 687 axes.set_yticks(positions) 688 axes.set_yticklabels([_shorten(name, 24) for name in table["group"]], fontsize=8) 689 axes.set_xlim(0.0, 1.0) 690 axes.set_xticks([0.0, 0.2, 0.4, 0.6, 0.8]) 691 axes.set_xlabel("average precision /\nreciprocal rank") 692 axes.invert_yaxis() 693 axes.grid(alpha=0.25, linewidth=0.6, axis="x") 694 handles, labels = axes.get_legend_handles_labels() 695 figure.legend(handles, labels, loc="upper center", bbox_to_anchor=(0.5, 0.995), ncol=2, frameon=False) 696 697 regret = table["regret"].to_numpy() 698 cost.barh(positions, regret, height=0.42, color=[CEILING if v <= 1e-9 else NEGATIVE for v in regret]) 699 cost.set_xlabel("top-choice regret\n(MCC)") 700 cost.grid(alpha=0.25, linewidth=0.6, axis="x") 701 cost.tick_params(labelleft=False) 702 # ``tight_layout`` warns on this shared-y pair in Matplotlib even though the axes are 703 # placed correctly. Set the margins explicitly and retain ``bbox_inches='tight'`` in 704 # `_finish`, which still expands the saved canvas around labels and the legend. 705 figure.subplots_adjust(left=0.30, right=0.98, bottom=0.10, top=0.89, wspace=0.10) 706 return _finish(figure, destination, paper_width_fraction=0.99, tight_layout=False)
Per-dataset ranking quality beside what a bad ranking actually costs, in MCC.
Ranking metrics alone raise a question they cannot answer. Four datasets here score average precision below 0.85 and two below 0.5, which reads as serious failure -- until one asks what following the bad ranking costs, and the answer is at most 0.13 MCC and on most of them nothing at all. The two belong together: a dataset whose models all score within a hair of each other is unrankable, and ranking it badly is not a cost.
Two panels sharing the dataset axis rather than two scales on one axis. Regret is in MCC and the ranking metrics are unitless, so overlaying them would repeat the mistake this figure exists to avoid -- a reader cannot tell which axis a marker belongs to. Sorted by average precision, so the hard datasets group at the bottom and the cost of each sits directly beside it.