"""Publication figures for the trust study.
Colors use the validated categorical palette (light-mode steps); marks are thin,
grids recessive, identity carried by legend + direct labels, never color alone.
"""
from __future__ import annotations
from pathlib import Path
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# Fall back to a headless backend for script/CLI figure generation, but never
# clobber an interactive/inline backend a notebook has already selected — doing so
# would silently drop figures rendered via plt.show() in the notebooks.
if not any(k in mpl.get_backend().lower() for k in ("inline", "nbagg", "ipympl", "widget")):
mpl.use("Agg")
# Validated categorical palette (light surface), fixed slot order.
PALETTE = ["#2a78d6", "#1baf7a", "#eda100", "#008300", "#4a3aa7",
"#e34948", "#e87ba4", "#eb6834"]
INK = "#0b0b0b"
MUTED = "#898781"
GRID = "#e1e0d9"
SURFACE = "#fcfcfb"
CLASS_COLORS = {
"PE": PALETTE[0], "PET": PALETTE[1], "PP": PALETTE[2],
"PS": PALETTE[3], "PVC": PALETTE[4], "Other": PALETTE[5],
}
plt.rcParams.update({
"figure.facecolor": SURFACE, "axes.facecolor": SURFACE,
"savefig.facecolor": SURFACE, "font.family": "sans-serif",
"axes.edgecolor": "#c3c2b7", "axes.labelcolor": INK,
"text.color": INK, "xtick.color": MUTED, "ytick.color": MUTED,
"axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.6,
"axes.spines.top": False, "axes.spines.right": False,
"figure.dpi": 120,
})
def _save(fig, path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(path, bbox_inches="tight")
plt.close(fig)
return path
[docs]
def reliability_diagram(table: pd.DataFrame, title: str, path: str | Path,
ece: float | None = None) -> Path:
fig, ax = plt.subplots(figsize=(4.6, 4.4))
ax.plot([0, 1], [0, 1], "--", color=MUTED, lw=1.5, label="perfect calibration")
if not table.empty:
ax.plot(table["confidence"], table["accuracy"], "-o", color=PALETTE[0],
lw=2, ms=7, label="model")
ax.set_xlabel("mean predicted confidence")
ax.set_ylabel("empirical accuracy")
ax.set_xlim(0, 1); ax.set_ylim(0, 1)
subtitle = f"{title}" + (f" (ECE = {ece:.3f})" if ece is not None else "")
ax.set_title(subtitle, color=INK, fontsize=11)
ax.legend(frameon=False, fontsize=9, loc="upper left")
return _save(fig, path)
[docs]
def gap_barplot(df: pd.DataFrame, path: str | Path) -> Path:
"""Grouped bars: random-split vs LOSO macro-F1 per model."""
models = df["model"].tolist()
x = np.arange(len(models))
w = 0.38
fig, ax = plt.subplots(figsize=(7.5, 4.4))
ax.bar(x - w / 2, df["random_macro_f1"], w, color=PALETTE[0], label="random 80/20")
ax.bar(x + w / 2, df["loso_macro_f1"], w, color=PALETTE[5], label="leave-one-study-out")
ax.set_xticks(x); ax.set_xticklabels(models, rotation=25, ha="right", fontsize=9)
ax.set_ylabel("macro F1"); ax.set_ylim(0, 1)
ax.set_title("Optimism of conventional validation", color=INK, fontsize=11)
ax.legend(frameon=False, fontsize=9)
for xi, (a, b) in enumerate(zip(df["random_macro_f1"], df["loso_macro_f1"])):
ax.annotate(f"-{a - b:.2f}", (xi, max(a, b) + 0.02), ha="center",
fontsize=8, color=MUTED)
return _save(fig, path)
[docs]
def risk_coverage_plot(curves: dict[str, pd.DataFrame], title: str,
path: str | Path) -> Path:
fig, ax = plt.subplots(figsize=(5.2, 4.4))
for i, (name, curve) in enumerate(curves.items()):
ax.plot(curve["coverage"], curve["risk"], "-", lw=2,
color=PALETTE[i % len(PALETTE)], label=name)
ax.set_xlabel("coverage (fraction retained)")
ax.set_ylabel("selective risk (error rate)")
ax.set_xlim(0, 1); ax.set_ylim(bottom=0)
ax.set_title(title, color=INK, fontsize=11)
ax.legend(frameon=False, fontsize=8, loc="upper left")
return _save(fig, path)
[docs]
def umap_overlay(emb_lab: np.ndarray, class_labels: np.ndarray,
emb_unk: np.ndarray, path: str | Path) -> Path:
fig, ax = plt.subplots(figsize=(6.2, 5.4))
ax.scatter(emb_unk[:, 0], emb_unk[:, 1], s=6, c="#c3c2b7", alpha=0.35,
linewidths=0, label="unknown (weathered)")
for name, color in CLASS_COLORS.items():
m = class_labels == name
if m.any():
ax.scatter(emb_lab[m, 0], emb_lab[m, 1], s=10, c=color,
alpha=0.8, linewidths=0, label=name)
ax.set_xlabel("UMAP-1"); ax.set_ylabel("UMAP-2")
ax.set_title("Labeled polymers vs unlabeled environmental spectra",
color=INK, fontsize=11)
ax.legend(frameon=False, fontsize=8, markerscale=1.5, loc="best")
ax.grid(False)
return _save(fig, path)
[docs]
def ci_vs_confidence(ci: np.ndarray, confidence: np.ndarray, path: str | Path,
rho: float | None = None) -> Path:
fig, ax = plt.subplots(figsize=(5.0, 4.4))
valid = np.isfinite(ci)
ax.scatter(ci[valid], confidence[valid], s=8, c=PALETTE[7], alpha=0.35,
linewidths=0)
ax.set_xlabel("carbonyl index (weathering)")
ax.set_ylabel("classifier confidence")
title = "Weathering vs confidence"
if rho is not None:
title += f" (Spearman ρ = {rho:.2f})"
ax.set_title(title, color=INK, fontsize=11)
return _save(fig, path)
[docs]
def ci_boxplot(groups: dict[str, np.ndarray], path: str | Path) -> Path:
fig, ax = plt.subplots(figsize=(5.6, 4.4))
names = list(groups)
data = [groups[n][np.isfinite(groups[n])] for n in names]
bp = ax.boxplot(data, labels=names, patch_artist=True, showfliers=False,
medianprops=dict(color=INK, lw=1.5))
for patch, i in zip(bp["boxes"], range(len(names))):
patch.set_facecolor(PALETTE[i % len(PALETTE)])
patch.set_alpha(0.65)
patch.set_edgecolor(INK)
ax.set_ylabel("carbonyl index")
ax.set_title("Weathering by sample provenance", color=INK, fontsize=11)
plt.setp(ax.get_xticklabels(), rotation=20, ha="right", fontsize=9)
return _save(fig, path)