cb-demo / src /eval_reporting.py
DimmingLight's picture
Upload folder using huggingface_hub
9e77d3a verified
Raw
History Blame Contribute Delete
43 kB
"""Eval reporting helpers - load ``results/*.json``, build DataFrames, apply Styler.
Used by ``notebooks/results_report.ipynb`` and ``analysis/benchmark_report.py``.
Higher-is-better metrics use ``Greens`` gradient + bold ``highlight_max``.
Lower-is-better fairness gaps use ``Greens_r`` (inverted) + ``highlight_min``.
Tuning before/after deltas: positive → green cell, negative → red cell.
Per-class precision / F1 / support are persisted by ``compute_metrics`` (see
``src/evaluation.py``) and rendered when present. Older JSONs that predate that
field set fall back to ``NaN`` and render ``-``, so both schemas display cleanly.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from src.paths import (
RESULTS_DIR, METRICS_DIR, METRICS_FILES, CV_FILES, MULTISEED_FILES,
TUNING_SUMMARY_FILE, TUNING_IBT_HYBRID_SUMMARY_FILE,
RANKING_FILE, PLOTS_DIR, REPORT_DIR,
)
# IBT-hybrid variants overridden by the tuned-config eval (ibt_hybrid_eval.ipynb
# + tune_ibt_hybrid.ipynb) when those artifacts are present. Default-config
# rows from hybrid_transformers_metrics.json are otherwise used.
_IBT_HYBRID_VARIANTS = ("IBT-CNN", "IBT-BiLSTM", "IBT-CNN-BiLSTM")
_IBT_HYBRID_FINAL_FILE = METRICS_DIR / "ibt_hybrid_final_metrics.json"
ROOT = Path(__file__).resolve().parent.parent
MODEL_DISPLAY_ORDER = (
# Transformers - paper baseline + multilingual + Twitter-domain backbones.
"IndoBERT", "XLM-R", "mDeBERTa", "IBT",
# Hybrid Transformer - 4 backbones × 3 heads (Kusuma & Chowanda 2023
# CNN / BiLSTM replication + CNN+BiLSTM stack extension). Grouped by
# backbone so each block reads as a head ablation.
"IBT-CNN", "IBT-BiLSTM", "IBT-CNN-BiLSTM",
"IndoBERT-CNN", "IndoBERT-BiLSTM", "IndoBERT-CNN-BiLSTM",
"XLM-R-CNN", "XLM-R-BiLSTM", "XLM-R-CNN-BiLSTM",
"mDeBERTa-CNN", "mDeBERTa-BiLSTM", "mDeBERTa-CNN-BiLSTM",
# Hybrid DL
"CNN-SVM", "CNN-BiGRU", "CNN-LSTM",
# Traditional ML
"NB", "SVM", "RF",
)
# Test-set class support under the canonical 70/15/15 stratified split with
# ``random_state=42`` (n0 = cyberbullying, n1 = non-cyberbullying).
# Used to derive the confusion matrix from per-class recall on metrics JSONs
# that predate the upgraded ``compute_metrics`` (which persists CM directly).
TEST_CLASS_SUPPORT: tuple[int, int] = (152, 147)
CATEGORY_OF = {
"IndoBERT": "Transformer", "XLM-R": "Transformer", "mDeBERTa": "Transformer",
"IBT": "Transformer",
# Hybrid Transformer - 12 variants (4 backbones × 3 heads).
"IBT-CNN": "Hybrid Transformer",
"IBT-BiLSTM": "Hybrid Transformer",
"IBT-CNN-BiLSTM": "Hybrid Transformer",
"IndoBERT-CNN": "Hybrid Transformer",
"IndoBERT-BiLSTM": "Hybrid Transformer",
"IndoBERT-CNN-BiLSTM": "Hybrid Transformer",
"XLM-R-CNN": "Hybrid Transformer",
"XLM-R-BiLSTM": "Hybrid Transformer",
"XLM-R-CNN-BiLSTM": "Hybrid Transformer",
"mDeBERTa-CNN": "Hybrid Transformer",
"mDeBERTa-BiLSTM": "Hybrid Transformer",
"mDeBERTa-CNN-BiLSTM": "Hybrid Transformer",
"CNN-SVM": "Hybrid DL", "CNN-BiGRU": "Hybrid DL", "CNN-LSTM": "Hybrid DL",
"NB": "Trad ML", "SVM": "Trad ML", "RF": "Trad ML",
}
def pick_best_config(
name: str,
default_cfg: dict,
data: dict,
*,
metric: str = "f1_macro",
) -> tuple[dict, str]:
"""Return ``(config, source)`` - tuned config if multi-seed improves over default,
else the default. ``source`` is ``'tuned'`` or ``'default'`` for logging.
Decision rule: if
``data['tuned_multiseed']['models'][name]['mean'][metric]`` exceeds
``data['multiseed']['models'][name]['mean'][metric]``, adopt the tuning
grid's best config (``data['tuning']['best_configs'][name]``). Otherwise
retain the default - consistent with the principle that a wider
hyperparameter search is only justified when it produces measurable gain.
Falls back to the default config if any of the required JSON blobs is
missing (e.g. tuning hasn't been run yet for a new model).
"""
tuned = (data.get("tuned_multiseed") or {}).get("models", {}).get(name)
base = (data.get("multiseed") or {}).get("models", {}).get(name)
if not tuned or not base:
return dict(default_cfg), "default"
try:
tuned_score = float(tuned["mean"][metric])
base_score = float(base["mean"][metric])
except (KeyError, TypeError, ValueError):
return dict(default_cfg), "default"
if tuned_score <= base_score:
return dict(default_cfg), "default"
best = (data.get("tuning") or {}).get("best_configs", {}).get(name)
if not best:
return dict(default_cfg), "default"
return dict(best), "tuned"
_GOOD_NUM_FMT = "{:.4f}"
_INT_FMT = "{:,.0f}"
# ---------------------------------------------------------------------------
# Loaders
# ---------------------------------------------------------------------------
def load_results(results_dir: Path | str = RESULTS_DIR) -> dict:
"""Load every metrics JSON under ``results_dir`` into one bundle.
Paths come from :mod:`src.paths` so layout changes need only one edit there.
The ``results_dir`` argument is kept for legacy callers but the canonical
paths are absolute; this argument is effectively ignored.
"""
# Single-config metrics (one model per file, except trad/hybrid which are dicts of models)
sources: dict[str, Path] = {
"trad": METRICS_FILES["trad"],
"hybrid": METRICS_FILES["hybrid"],
"hybrid_transformers": METRICS_FILES["hybrid_transformers"],
"indobert": METRICS_FILES["indobert"],
"xlm_roberta": METRICS_FILES["xlm_roberta"],
"mdeberta": METRICS_FILES["mdeberta"],
"ibt": METRICS_FILES["ibt"],
# K-fold CV results
"cv_trad": CV_FILES["trad"],
"cv_hybrid": CV_FILES["hybrid"],
"cv_transformers": CV_FILES["transformers"],
"cv_hybrid_transformers": CV_FILES["hybrid_transformers"],
# Multi-seed stability
"multiseed": MULTISEED_FILES["default"],
"tuned_multiseed": MULTISEED_FILES["tuned"],
# IBT-hybrid (tuned config) - overrides default-config rows from
# hybrid_transformers_metrics.json for IBT-{CNN,BiLSTM,CNN-BiLSTM}.
"ibt_hybrid": _IBT_HYBRID_FINAL_FILE,
"ibt_hybrid_multiseed": MULTISEED_FILES["ibt_hybrid"],
"ibt_hybrid_tuning": TUNING_IBT_HYBRID_SUMMARY_FILE,
# Tuning summary
"tuning": TUNING_SUMMARY_FILE,
# Cross-model leaderboard
"ranking": RANKING_FILE,
}
out: dict[str, Any] = {"files_found": [], "files_missing": []}
for key, path in sources.items():
if path.exists():
out[key] = json.loads(path.read_text(encoding="utf-8"))
out["files_found"].append(path.name)
else:
out[key] = None
out["files_missing"].append(path.name)
return out
def _order_by_display(df: pd.DataFrame, col: str = "Model") -> pd.DataFrame:
"""Sort rows by ``MODEL_DISPLAY_ORDER`` (unknown models go last)."""
if df.empty:
return df
rank = {m: i for i, m in enumerate(MODEL_DISPLAY_ORDER)}
df = df.copy()
df["__order"] = df[col].map(rank).fillna(99)
return df.sort_values("__order").drop(columns="__order").reset_index(drop=True)
# ---------------------------------------------------------------------------
# DataFrame builders
# ---------------------------------------------------------------------------
def build_overview_df(data: dict, *, sort_by: str = "F1-macro") -> pd.DataFrame:
"""One row per model: every test metric + per-class recall + compute cost.
Rows sorted best→worst by ``sort_by`` (default ``F1-macro`` desc). Pass
``sort_by=None`` to keep the canonical ``MODEL_DISPLAY_ORDER`` instead.
"""
rows: list[dict] = []
sources: list[tuple[str, dict | None]] = [
("trad", data.get("trad")),
("hybrid", data.get("hybrid")),
("hybrid_transformers", data.get("hybrid_transformers")),
]
# IBT-hybrid override: when tuned-config eval exists, replace the
# default-config rows from hybrid_transformers_metrics.json so the overview
# reflects the proposed model (tuned single-seed) rather than the ablation
# baseline.
ibt_override = data.get("ibt_hybrid") or {}
for kind, blob in sources:
if not blob:
continue
for name, entry in blob.items():
if kind == "hybrid_transformers" and name in ibt_override:
rows.append(_overview_row(name, ibt_override[name], "ibt_hybrid"))
else:
rows.append(_overview_row(name, entry, kind))
# Single-file transformer metrics - one JSON per model, single-model dict.
for display_name, source_key in [
("IndoBERT", "indobert"),
("XLM-R", "xlm_roberta"),
("mDeBERTa", "mdeberta"),
("IBT", "ibt"),
]:
if data.get(source_key):
rows.append(_overview_row(display_name, data[source_key], source_key))
df = pd.DataFrame(rows)
if df.empty:
return df
if sort_by and sort_by in df.columns:
return (df.sort_values(sort_by, ascending=False, na_position="last")
.reset_index(drop=True))
return _order_by_display(df)
def _overview_row(name: str, entry: dict, kind: str) -> dict:
m = entry.get("test_metrics", {})
f = entry.get("test_fairness", {})
pca = f.get("per_class_accuracy", {})
cc = entry.get("compute_cost", {}) or {}
train_time = entry.get("training_time_sec", cc.get("time_sec"))
return {
"Model": name,
"Category": CATEGORY_OF.get(name, "?"),
"Accuracy": m.get("accuracy"),
"Balanced Acc": m.get("balanced_accuracy"),
"F1-macro": m.get("f1_macro"),
"F1-weighted": m.get("f1_weighted"),
"Precision-macro": m.get("precision_macro"),
"Recall-macro": m.get("recall_macro"),
"ROC-AUC": m.get("roc_auc"),
"MCC": m.get("mcc"),
"Cohen's κ": m.get("cohens_kappa"),
"Recall(CB)": pca.get("0"),
"Recall(non-CB)": pca.get("1"),
"Train (s)": train_time,
"Params": cc.get("params"),
"Peak GPU (MB)": cc.get("peak_gpu_mb"),
}
def build_fairness_df(data: dict) -> pd.DataFrame:
"""Per-model fairness gaps. Lower is better for every column."""
rows: list[dict] = []
ibt_override = data.get("ibt_hybrid") or {}
for kind in ("trad", "hybrid", "hybrid_transformers"):
blob = data.get(kind)
if not blob:
continue
for name, entry in blob.items():
if kind == "hybrid_transformers" and name in ibt_override:
rows.append(_fairness_row(name, ibt_override[name]))
else:
rows.append(_fairness_row(name, entry))
for display_name, source_key in [
("IndoBERT", "indobert"),
("XLM-R", "xlm_roberta"),
("mDeBERTa", "mdeberta"),
("IBT", "ibt"),
]:
if data.get(source_key):
rows.append(_fairness_row(display_name, data[source_key]))
return _order_by_display(pd.DataFrame(rows))
def _fairness_row(name: str, entry: dict) -> dict:
f = entry.get("test_fairness", {})
return {
"Model": name,
"Accuracy gap |R(0)-R(1)|": f.get("accuracy_gap"),
"Demographic parity gap": f.get("demographic_parity_gap"),
"Precision gap": f.get("precision_gap"),
}
def build_multiseed_df(data: dict, kind: str = "multiseed") -> pd.DataFrame:
"""Mean ± std across 5 seeds per model. ``kind`` ∈ {'multiseed','tuned_multiseed'}.
For ``kind='tuned_multiseed'``, IBT-hybrid variants are pulled from the
separate ``tuned_multiseed_ibt_hybrid.json`` (tune_ibt_hybrid.ipynb output)
so the table covers all 12 tuned models (9 baselines + 3 IBT variants).
"""
blob = data.get(kind)
rows: list[dict] = []
if blob:
for name, mdl in blob.get("models", {}).items():
mean = mdl.get("mean", {})
std = mdl.get("std", {})
rows.append({
"Model": name,
"Acc μ": mean.get("accuracy"),
"Acc σ": std.get("accuracy"),
"F1-macro μ": mean.get("f1_macro"),
"F1-macro σ": std.get("f1_macro"),
"ROC-AUC μ": mean.get("roc_auc"),
"ROC-AUC σ": std.get("roc_auc"),
"MCC μ": mean.get("mcc"),
"MCC σ": std.get("mcc"),
})
if kind == "tuned_multiseed":
ibt_blob = data.get("ibt_hybrid_multiseed") or {}
seen = {r["Model"] for r in rows}
for name, mdl in ibt_blob.get("models", {}).items():
if name in seen:
continue
mean = mdl.get("mean", {})
std = mdl.get("std", {})
rows.append({
"Model": name,
"Acc μ": mean.get("accuracy"),
"Acc σ": std.get("accuracy"),
"F1-macro μ": mean.get("f1_macro"),
"F1-macro σ": std.get("f1_macro"),
"ROC-AUC μ": mean.get("roc_auc"),
"ROC-AUC σ": std.get("roc_auc"),
"MCC μ": mean.get("mcc"),
"MCC σ": std.get("mcc"),
})
if not rows:
return pd.DataFrame()
return _order_by_display(pd.DataFrame(rows))
def build_cv_df(data: dict) -> pd.DataFrame:
"""5-fold CV mean ± std per model - combines cv_trad + cv_hybrid + cv_transformers.
Schema is parallel to ``build_multiseed_df`` - each model row reports
aggregated metrics across folds. Same Styler (``style_multiseed``) renders
both because the column layout is identical.
"""
rows: list[dict] = []
for kind in ("cv_trad", "cv_hybrid", "cv_transformers", "cv_hybrid_transformers"):
blob = data.get(kind)
if not blob:
continue
for name, mdl in blob.get("models", {}).items():
mean = mdl.get("mean", {})
std = mdl.get("std", {})
rows.append({
"Model": name,
"Acc μ": mean.get("accuracy"),
"Acc σ": std.get("accuracy"),
"F1-macro μ": mean.get("f1_macro"),
"F1-macro σ": std.get("f1_macro"),
"ROC-AUC μ": mean.get("roc_auc"),
"ROC-AUC σ": std.get("roc_auc"),
"MCC μ": mean.get("mcc"),
"MCC σ": std.get("mcc"),
})
return _order_by_display(pd.DataFrame(rows))
def build_per_seed_df(data: dict, model_name: str,
kind: str = "multiseed") -> pd.DataFrame:
"""One row per seed (or fold) for a single model - for per-model deep-dive.
Handles both ``per_seed`` (multi-seed JSONs) and ``per_fold`` (CV JSONs)
schemas - looks for whichever is present.
"""
blob = data.get(kind)
mdl = (blob or {}).get("models", {}).get(model_name) if blob else None
# Tuned-multiseed IBT-hybrid variants live in their own JSON.
if mdl is None and kind == "tuned_multiseed" and model_name in _IBT_HYBRID_VARIANTS:
ibt_blob = data.get("ibt_hybrid_multiseed") or {}
mdl = ibt_blob.get("models", {}).get(model_name)
if not mdl:
return pd.DataFrame()
records = mdl.get("per_seed") or mdl.get("per_fold") or []
df = pd.DataFrame(records)
# Show every scalar field that's present (graceful for old + new schemas).
# List/dict fields like precision_per_class / confusion_matrix stay out of
# the table - they're per-record objects, surfaced via the inline CM card.
preferred_order = [
"seed", "fold",
"accuracy", "balanced_accuracy", "f1_macro", "f1_weighted",
"precision_macro", "recall_macro", "roc_auc", "mcc", "cohens_kappa",
"recall_class_0", "recall_class_1",
"accuracy_gap", "demographic_parity_gap", "precision_gap",
]
scalar = [c for c in df.columns
if pd.api.types.is_numeric_dtype(df[c])]
ordered = [c for c in preferred_order if c in scalar]
extras = [c for c in scalar if c not in preferred_order]
return df[ordered + extras]
def build_classification_card(data: dict, model_name: str) -> dict | None:
"""Classification-report-like card for one model.
Reads per-class precision/recall/F1/support when persisted in the metrics
JSON (``precision_per_class``, ``recall_per_class``, ``f1_per_class``,
``support_per_class`` - added by ``src/evaluation.py::compute_metrics``).
Falls back to per-class recall from fairness ``per_class_accuracy`` when
only old-format JSONs are present, and emits ``NaN`` for missing fields.
"""
entry = _find_entry(data, model_name)
if entry is None:
return None
m = entry.get("test_metrics", {})
f = entry.get("test_fairness", {})
pca = f.get("per_class_accuracy", {})
p_per = m.get("precision_per_class") or [np.nan, np.nan]
r_per = m.get("recall_per_class")
if r_per is None:
r_per = [pca.get("0", np.nan), pca.get("1", np.nan)]
f1_per = m.get("f1_per_class") or [np.nan, np.nan]
support = m.get("support_per_class") or [np.nan, np.nan]
has_full = m.get("precision_per_class") is not None
per_class = pd.DataFrame({
"class": ["cyberbullying (0)", "non-cyberbullying (1)"],
"precision": p_per,
"recall": r_per,
"f1-score": f1_per,
"support": support,
})
summary_metrics = [
("Accuracy", m.get("accuracy")),
("Balanced accuracy", m.get("balanced_accuracy")),
("Precision (macro)", m.get("precision_macro")),
("Recall (macro)", m.get("recall_macro")),
("F1 (macro)", m.get("f1_macro")),
("F1 (weighted)", m.get("f1_weighted")),
("ROC-AUC", m.get("roc_auc")),
("MCC", m.get("mcc")),
("Cohen's κ", m.get("cohens_kappa")),
]
summary = pd.DataFrame({
"metric": [k for k, _ in summary_metrics],
"value": [v for _, v in summary_metrics],
})
note = (
"Sourced from sklearn.metrics - full per-class fields persisted."
if has_full else
"Per-class precision / F1 / support not in current JSON - re-run "
"training with the upgraded `src/evaluation.py::compute_metrics` "
"to populate."
)
return {
"model": model_name,
"category": CATEGORY_OF.get(model_name, "?"),
"per_class_df": per_class,
"summary_df": summary,
"confusion_matrix": m.get("confusion_matrix"),
"note": note,
}
def _find_entry(data: dict, model_name: str) -> dict | None:
# IBT-hybrid tuned eval wins over the default-config 12-variant ablation
# when the tuned-eval artifact is on disk.
if model_name in _IBT_HYBRID_VARIANTS:
ibt = (data.get("ibt_hybrid") or {}).get(model_name)
if ibt:
return ibt
for kind in ("trad", "hybrid", "hybrid_transformers"):
blob = data.get(kind) or {}
if model_name in blob:
return blob[model_name]
# Single-file transformer JSONs (one model per file).
SINGLE_FILE_MAP = {
"IndoBERT": "indobert",
"XLM-R": "xlm_roberta",
"mDeBERTa": "mdeberta",
"IBT": "ibt",
}
src = SINGLE_FILE_MAP.get(model_name)
if src and data.get(src):
return data[src]
return None
def build_confusion_matrix_df(card: dict) -> pd.DataFrame:
"""2x2 confusion matrix (rows=actual, cols=predicted) for one model card."""
cm = card.get("confusion_matrix") if card else None
if not cm:
return pd.DataFrame()
return pd.DataFrame(
[[cm.get("tn", 0), cm.get("fp", 0)],
[cm.get("fn", 0), cm.get("tp", 0)]],
index=pd.Index(["actual: cyberbullying (0)",
"actual: non-cyberbullying (1)"], name=""),
columns=["pred: cyberbullying (0)", "pred: non-cyberbullying (1)"],
)
def style_confusion_matrix(df: pd.DataFrame):
"""Diagonal cells (TN, TP) green; off-diagonal (FP, FN) red. Bold all counts."""
if df.empty:
return df.style if hasattr(df, "style") else df
def _color(_v, row, col):
on_diag = row == col
return ("background-color: #c8e6c9; color: #1b5e20; font-weight: 700;"
if on_diag else
"background-color: #ffcdd2; color: #b71c1c; font-weight: 700;")
n_rows, n_cols = df.shape
styles = pd.DataFrame("", index=df.index, columns=df.columns)
for i in range(n_rows):
for j in range(n_cols):
styles.iat[i, j] = _color(df.iat[i, j], i, j)
return df.style.format(_INT_FMT).apply(lambda _: styles, axis=None)
def build_tuning_diff_df(data: dict) -> pd.DataFrame:
"""Pre/post tuning comparison from ``multiseed`` + ``tuned_multiseed`` means.
IBT-hybrid variants are appended when ``ibt_hybrid_multiseed`` is on disk:
pre = default-config single-seed from ``hybrid_transformers_metrics.json``
(the 12-variant ablation never had a multi-seed default eval), post =
tuned 5-seed mean from ``tuned_multiseed_ibt_hybrid.json``. The mixed-seed
comparison is flagged in the section header in ``results_report.ipynb``.
"""
pre = (data.get("multiseed") or {}).get("models", {})
post = (data.get("tuned_multiseed") or {}).get("models", {})
rows: list[dict] = []
for name in MODEL_DISPLAY_ORDER:
if name not in pre or name not in post:
continue
p = pre[name].get("mean", {})
q = post[name].get("mean", {})
rows.append({
"Model": name,
"Acc (default)": p.get("accuracy"),
"Acc (tuned)": q.get("accuracy"),
"Δ Acc": _safe_delta(q.get("accuracy"), p.get("accuracy")),
"F1-macro (default)": p.get("f1_macro"),
"F1-macro (tuned)": q.get("f1_macro"),
"Δ F1-macro": _safe_delta(q.get("f1_macro"), p.get("f1_macro")),
"MCC (default)": p.get("mcc"),
"MCC (tuned)": q.get("mcc"),
"Δ MCC": _safe_delta(q.get("mcc"), p.get("mcc")),
})
# IBT-hybrid append: default = 12-variant ablation (1 seed); tuned = 5-seed mean.
hybrid_tx = data.get("hybrid_transformers") or {}
ibt_ms = (data.get("ibt_hybrid_multiseed") or {}).get("models", {})
for name in _IBT_HYBRID_VARIANTS:
if name not in hybrid_tx or name not in ibt_ms:
continue
p = (hybrid_tx[name] or {}).get("test_metrics", {})
q = ibt_ms[name].get("mean", {})
rows.append({
"Model": name,
"Acc (default)": p.get("accuracy"),
"Acc (tuned)": q.get("accuracy"),
"Δ Acc": _safe_delta(q.get("accuracy"), p.get("accuracy")),
"F1-macro (default)": p.get("f1_macro"),
"F1-macro (tuned)": q.get("f1_macro"),
"Δ F1-macro": _safe_delta(q.get("f1_macro"), p.get("f1_macro")),
"MCC (default)": p.get("mcc"),
"MCC (tuned)": q.get("mcc"),
"Δ MCC": _safe_delta(q.get("mcc"), p.get("mcc")),
})
if not rows:
return pd.DataFrame()
return _order_by_display(pd.DataFrame(rows))
def _safe_delta(post: float | None, pre: float | None) -> float:
if post is None or pre is None:
return np.nan
return post - pre
def build_best_configs_df(data: dict) -> pd.DataFrame:
"""One row per model with the best hyperparameter config from tuning.
Wide union of all hyperparameter columns across models. Most cells are NaN
because each category uses a disjoint set of hyperparameters - prefer
``build_best_configs_by_category()`` for display.
"""
blob = data.get("tuning")
ibt_blob = data.get("ibt_hybrid_tuning")
if not (blob or ibt_blob):
return pd.DataFrame()
best_configs: dict[str, dict] = {}
if blob:
best_configs.update(blob.get("best_configs", {}) or {})
if ibt_blob:
best_configs.update(ibt_blob.get("best_configs", {}) or {})
rows = []
for name, cfg in best_configs.items():
row = {"Model": name}
for k, v in cfg.items():
row[k] = str(v) if isinstance(v, (list, tuple, dict)) else v
rows.append(row)
return _order_by_display(pd.DataFrame(rows))
def build_best_configs_by_category(data: dict) -> dict[str, pd.DataFrame]:
"""Best hyperparameter configs grouped by category - no NaN cells.
Returns a dict ``{category: DataFrame}``. Categories with a uniform
hyperparameter set (all models share the same keys, e.g. transformers)
get a compact wide table; categories with disjoint sets (e.g. Hybrid DL
where CNN-SVM diverges from CNN-RNN, or Trad ML where every model has
its own params) get a long-format ``Model | Hyperparameter | Value``
table so there are zero NaN cells. Order:
``Transformer`` → ``Hybrid DL`` → ``Trad ML``.
"""
blob = data.get("tuning")
ibt_blob = data.get("ibt_hybrid_tuning")
if not (blob or ibt_blob):
return {}
cat_groups: dict[str, list[dict]] = {
"Transformer": [], "Hybrid Transformer": [], "Hybrid DL": [], "Trad ML": [],
}
rank = {m: i for i, m in enumerate(MODEL_DISPLAY_ORDER)}
best_configs: dict[str, dict] = {}
if blob:
best_configs.update(blob.get("best_configs", {}) or {})
if ibt_blob:
# IBT-hybrid grid lives in its own tuning summary - keys overlap zero
# with the baseline 9-model grid so dict-update is safe.
best_configs.update(ibt_blob.get("best_configs", {}) or {})
for name, cfg in best_configs.items():
cat = CATEGORY_OF.get(name, "?")
if cat not in cat_groups:
cat_groups.setdefault(cat, [])
row = {"Model": name, "__order": rank.get(name, 99)}
for k, v in cfg.items():
row[k] = str(v) if isinstance(v, (list, tuple, dict)) else v
cat_groups[cat].append(row)
out: dict[str, pd.DataFrame] = {}
for cat, rows in cat_groups.items():
if not rows:
continue
df = (pd.DataFrame(rows)
.sort_values("__order")
.drop(columns="__order")
.reset_index(drop=True))
df = df.dropna(axis=1, how="all")
if df.drop(columns="Model").isna().any().any():
# Sparse → long format. Drop NaN rows so every cell is filled.
# Preserve display order via a numeric rank so we don't fall back
# to alphabetical sorting of model names.
df["__r"] = df["Model"].map(rank).fillna(99)
long = (df.melt(id_vars=["Model", "__r"],
var_name="Hyperparameter", value_name="Value")
.dropna(subset=["Value"])
.sort_values(["__r", "Hyperparameter"])
.drop(columns="__r")
.reset_index(drop=True))
df = long
out[cat] = df
return out
# ---------------------------------------------------------------------------
# Stylers - pandas Styler with green / red gradients + max/min highlighting
# ---------------------------------------------------------------------------
# Bold style for the column-best cell. Color is intentionally NOT set here so
# the per-cell adaptive text color (applied first) wins; bolding alone marks
# the winner.
_BOLD_BEST = "font-weight: 700;"
# Threshold (0..1, normalized within column) above which the cell background
# is dark enough to need light text.
_DARK_BG_THRESHOLD = 0.55
_LIGHT_TEXT = "#ffffff"
_DARK_TEXT = "#0a3d0a"
def _adaptive_text_color(df: pd.DataFrame, cols: list[str], *,
invert: bool = False):
"""Return a Styler.apply-compatible function: per-cell text color follows bg.
For each column: cells that map to a *dark* gradient stop (top half by
default) get white text + faint shadow; cells on the *light* half keep
dark green text. ``invert=True`` flips the mapping for ``Greens_r`` cmaps
(lower-is-better metrics) so smallest values still get the light text.
"""
def _per_col(col_series):
if not col_series.notna().any():
return ["" for _ in col_series]
vmin = col_series.min()
vmax = col_series.max()
rng = vmax - vmin
if rng == 0:
return [f"color: {_LIGHT_TEXT};" for _ in col_series]
out = []
for v in col_series:
if pd.isna(v):
out.append("")
continue
t = (v - vmin) / rng
if invert:
t = 1 - t
if t > _DARK_BG_THRESHOLD:
out.append(
f"color: {_LIGHT_TEXT}; "
"text-shadow: 0 0 1px rgba(0,0,0,0.45);"
)
else:
out.append(f"color: {_DARK_TEXT};")
return out
return _per_col
def _non_empty(df: pd.DataFrame, cols: list[str]) -> list[str]:
"""Filter to columns that have at least one non-NaN value.
Avoids the ``RuntimeWarning: All-NaN slice encountered`` from
``Styler.background_gradient`` when a column is fully missing (e.g.,
``Balanced Acc`` / ``Cohen's κ`` from pre-upgrade JSONs).
"""
return [c for c in cols if c in df.columns and df[c].notna().any()]
def style_overview(df: pd.DataFrame):
"""Green gradient + bold ``highlight_max`` per metric column. Cost columns inverted."""
if df.empty:
return df.style if hasattr(df, "style") else df
metric_cols = [c for c in df.columns if c in {
"Accuracy", "Balanced Acc", "F1-macro", "F1-weighted", "Precision-macro",
"Recall-macro", "ROC-AUC", "MCC", "Cohen's κ",
"Recall(CB)", "Recall(non-CB)",
}]
cost_cols = [c for c in df.columns if c in {"Train (s)", "Peak GPU (MB)"}]
fmt: dict[str, str] = {c: _GOOD_NUM_FMT for c in metric_cols}
fmt.update({"Train (s)": "{:.2f}", "Peak GPU (MB)": "{:.1f}", "Params": _INT_FMT})
sty = df.style.format(fmt, na_rep="-")
metric_cols_grad = _non_empty(df, metric_cols)
cost_cols_grad = _non_empty(df, cost_cols)
if metric_cols_grad:
sty = sty.background_gradient(cmap="Greens", subset=metric_cols_grad, axis=0)
sty = sty.apply(_adaptive_text_color(df, metric_cols_grad),
subset=metric_cols_grad, axis=0)
if cost_cols_grad:
sty = sty.background_gradient(cmap="Reds", subset=cost_cols_grad, axis=0)
# Reds: highest = darkest, but also "worst" - invert text-mapping so
# "best" cells (lowest cost) get the dark text on light bg.
sty = sty.apply(_adaptive_text_color(df, cost_cols_grad),
subset=cost_cols_grad, axis=0)
for c in metric_cols_grad:
sty = sty.highlight_max(subset=[c], props=_BOLD_BEST)
for c in cost_cols_grad:
sty = sty.highlight_min(subset=[c], props=_BOLD_BEST)
return sty
def style_fairness(df: pd.DataFrame):
"""All columns are 'lower is better' - inverted Greens + bold ``highlight_min``."""
if df.empty:
return df.style if hasattr(df, "style") else df
gap_cols = [c for c in df.columns if c != "Model"]
fmt = {c: _GOOD_NUM_FMT for c in gap_cols}
sty = df.style.format(fmt, na_rep="-")
grad = _non_empty(df, gap_cols)
if grad:
sty = sty.background_gradient(cmap="Greens_r", subset=grad, axis=0)
# Greens_r: lowest value = darkest cell. Invert the text mapping so
# the dark cells (lowest values) get light text.
sty = sty.apply(_adaptive_text_color(df, grad, invert=True),
subset=grad, axis=0)
for c in grad:
sty = sty.highlight_min(subset=[c], props=_BOLD_BEST)
return sty
def style_multiseed(df: pd.DataFrame):
"""μ columns: Greens (higher better). σ columns: Greens_r (lower = more stable)."""
if df.empty:
return df.style if hasattr(df, "style") else df
mu_cols = [c for c in df.columns if c.endswith("μ")]
sg_cols = [c for c in df.columns if c.endswith("σ")]
fmt = {c: _GOOD_NUM_FMT for c in mu_cols + sg_cols}
sty = df.style.format(fmt, na_rep="-")
mu_grad = _non_empty(df, mu_cols)
sg_grad = _non_empty(df, sg_cols)
if mu_grad:
sty = sty.background_gradient(cmap="Greens", subset=mu_grad, axis=0)
sty = sty.apply(_adaptive_text_color(df, mu_grad),
subset=mu_grad, axis=0)
if sg_grad:
sty = sty.background_gradient(cmap="Greens_r", subset=sg_grad, axis=0)
sty = sty.apply(_adaptive_text_color(df, sg_grad, invert=True),
subset=sg_grad, axis=0)
for c in mu_grad:
sty = sty.highlight_max(subset=[c], props=_BOLD_BEST)
for c in sg_grad:
sty = sty.highlight_min(subset=[c], props=_BOLD_BEST)
return sty
def style_per_seed(df: pd.DataFrame):
if df.empty:
return df.style if hasattr(df, "style") else df
# Treat both "seed" and "fold" as identifier columns (no gradient applied).
num_cols = [c for c in df.columns if c not in ("seed", "fold")]
higher_better = [c for c in num_cols if c in {
"accuracy", "f1_macro", "f1_weighted", "roc_auc", "mcc",
"recall_class_0", "recall_class_1",
}]
lower_better = [c for c in num_cols if c not in higher_better]
fmt = {c: _GOOD_NUM_FMT for c in num_cols}
sty = df.style.format(fmt, na_rep="-")
hb = _non_empty(df, higher_better)
lb = _non_empty(df, lower_better)
if hb:
sty = sty.background_gradient(cmap="Greens", subset=hb, axis=0)
sty = sty.apply(_adaptive_text_color(df, hb), subset=hb, axis=0)
if lb:
sty = sty.background_gradient(cmap="Greens_r", subset=lb, axis=0)
sty = sty.apply(_adaptive_text_color(df, lb, invert=True),
subset=lb, axis=0)
return sty
def style_classification_card(card: dict) -> tuple:
"""Return (per_class Styler, summary Styler) for one model card."""
pc = card["per_class_df"]
sm = card["summary_df"]
# Only gradient columns that have at least one non-NaN value (avoids the
# "All-NaN slice" RuntimeWarning when per-class precision/F1 aren't persisted).
grad_cols = [c for c in ("precision", "recall", "f1-score")
if c in pc.columns and pc[c].notna().any()]
pc_sty = (
pc.style
.format({c: _GOOD_NUM_FMT for c in ("precision", "recall", "f1-score")},
na_rep="-")
.format({"support": _INT_FMT}, na_rep="-")
)
if grad_cols:
pc_sty = pc_sty.background_gradient(cmap="Greens", subset=grad_cols, axis=0)
pc_sty = pc_sty.apply(_adaptive_text_color(pc, grad_cols),
subset=grad_cols, axis=0)
sm_sty = (
sm.style
.format({"value": _GOOD_NUM_FMT}, na_rep="-")
.background_gradient(cmap="Greens", subset=["value"], axis=0)
.apply(_adaptive_text_color(sm, ["value"]), subset=["value"], axis=0)
)
return pc_sty, sm_sty
def style_tuning_diff(df: pd.DataFrame):
"""Δ columns: green when improved, red when regressed; cell-level color."""
if df.empty:
return df.style if hasattr(df, "style") else df
delta_cols = [c for c in df.columns if c.startswith("Δ")]
val_cols = [c for c in df.columns if c not in delta_cols + ["Model"]]
fmt: dict[str, str] = {c: _GOOD_NUM_FMT for c in val_cols}
fmt.update({c: "{:+.4f}" for c in delta_cols})
def _color_delta(v):
if pd.isna(v):
return ""
if v > 0.001:
return "background-color: #c8e6c9; color: #1b5e20; font-weight: 700;"
if v < -0.001:
return "background-color: #ffcdd2; color: #b71c1c;"
return ""
sty = df.style.format(fmt, na_rep="-")
for c in delta_cols:
sty = sty.map(_color_delta, subset=[c])
return sty
def style_best_configs(df: pd.DataFrame):
"""Plain table - no gradient, just consistent formatting."""
if df.empty:
return df.style if hasattr(df, "style") else df
return df.style.format(na_rep="-").set_properties(**{"font-family": "monospace"})
# ---------------------------------------------------------------------------
# Matplotlib renderers
# ---------------------------------------------------------------------------
def resolve_cm_dict(card: dict | None) -> dict | None:
"""Get the 2×2 confusion matrix for a model card.
Prefers the exact ``confusion_matrix`` persisted by the upgraded
``compute_metrics``. Falls back to deriving from per-class recall +
``TEST_CLASS_SUPPORT`` when the JSON predates the upgrade - the math is
lossless (recall was computed *as* ``TN/n0`` and ``TP/n1`` originally).
"""
if not card:
return None
cm = card.get("confusion_matrix")
if cm and all(k in cm for k in ("tn", "fp", "fn", "tp")):
return cm
pc = card.get("per_class_df")
if pc is None or pc.empty or pc["recall"].isna().any():
return None
n0, n1 = TEST_CLASS_SUPPORT
r0 = float(pc["recall"].iloc[0])
r1 = float(pc["recall"].iloc[1])
tn = round(r0 * n0)
tp = round(r1 * n1)
return {
"tn": int(tn), "fp": int(n0 - tn),
"fn": int(n1 - tp), "tp": int(tp),
}
def _draw_cm_on_axes(ax, cm_dict: dict, title: str, *,
normalize: bool = True,
show_xlabel: bool = True,
show_ylabel: bool = True) -> None:
"""Draw a single normalized CM heatmap onto an existing matplotlib Axes."""
cm = np.array([[cm_dict["tn"], cm_dict["fp"]],
[cm_dict["fn"], cm_dict["tp"]]], dtype=float)
if normalize:
row_sums = cm.sum(axis=1, keepdims=True)
data = np.divide(cm, row_sums, out=np.zeros_like(cm),
where=row_sums != 0)
fmt = "{:.2f}"
vmin, vmax = 0.0, 1.0
else:
data = cm
fmt = "{:,.0f}"
vmin, vmax = 0.0, cm.max()
ax.imshow(data, cmap="Blues", vmin=vmin, vmax=vmax)
ax.set_title(title, fontsize=10)
if show_xlabel:
ax.set_xlabel("Predicted", fontsize=9)
if show_ylabel:
ax.set_ylabel("Actual", fontsize=9)
ax.set_xticks([0, 1], labels=["CB", "non-CB"], fontsize=8)
ax.set_yticks([0, 1], labels=["CB", "non-CB"], fontsize=8,
rotation=90, va="center")
threshold = (vmin + vmax) / 2
for i in range(2):
for j in range(2):
color = "white" if data[i, j] > threshold else "#0a3d0a"
ax.text(j, i, fmt.format(data[i, j]),
ha="center", va="center", color=color,
fontsize=10, fontweight="bold")
def make_cm_figure(cm_dict: dict | None, title: str, *, normalize: bool = True,
figsize: tuple[float, float] = (3.6, 3.2)):
"""Single 2×2 confusion-matrix heatmap. Returns ``None`` if ``cm_dict`` is missing."""
if not cm_dict or not all(k in cm_dict for k in ("tn", "fp", "fn", "tp")):
return None
import matplotlib.pyplot as plt # lazy
fig, ax = plt.subplots(figsize=figsize)
_draw_cm_on_axes(ax, cm_dict, title, normalize=normalize)
fig.tight_layout()
return fig
def make_combined_cm_figure(data: dict, *, normalize: bool = True,
ncols: int = 4, figsize_per_cell: tuple[float, float] = (2.7, 2.5)):
"""All-in-one grid: every available model's CM in one figure.
Models with no derivable CM are rendered as a placeholder cell with
"no data" text. Returns ``None`` if zero models have CM data.
"""
import matplotlib.pyplot as plt
items: list[tuple[str, dict | None]] = []
for name in MODEL_DISPLAY_ORDER:
card = build_classification_card(data, name)
cm = resolve_cm_dict(card)
if card is not None:
items.append((name, cm))
if not items or all(cm is None for _, cm in items):
return None
n = len(items)
nrows = (n + ncols - 1) // ncols
fig, axes = plt.subplots(
nrows, ncols,
figsize=(figsize_per_cell[0] * ncols, figsize_per_cell[1] * nrows),
squeeze=False,
)
for idx, (name, cm) in enumerate(items):
r, c = divmod(idx, ncols)
ax = axes[r][c]
if cm is None:
ax.text(0.5, 0.5, f"{name}\n(no CM data)",
ha="center", va="center", fontsize=10, color="#888")
ax.set_xticks([]); ax.set_yticks([])
for spine in ax.spines.values():
spine.set_color("#ccc")
continue
_draw_cm_on_axes(
ax, cm, name,
normalize=normalize,
show_xlabel=(r == nrows - 1),
show_ylabel=(c == 0),
)
# Hide unused trailing slots
for idx in range(n, nrows * ncols):
r, c = divmod(idx, ncols)
axes[r][c].set_visible(False)
fig.suptitle(
f"Confusion matrices - {len(items)} models "
f"({'normalized' if normalize else 'counts'})",
fontsize=12,
)
fig.tight_layout()
return fig
# ---------------------------------------------------------------------------
# PNG export (optional - needs ``dataframe-image``)
# ---------------------------------------------------------------------------
def export_styler_png(styler, path: Path | str) -> bool:
"""Export a Styler to PNG. Returns False if ``dataframe-image`` is not installed."""
try:
import dataframe_image as dfi # type: ignore
except ImportError:
return False
Path(path).parent.mkdir(parents=True, exist_ok=True)
dfi.export(styler, str(path), table_conversion="matplotlib")
return True