Source code for xpectra.plotting

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