Spaces:
Running
Running
| """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 | |