Download mlate/evaluation.py from Saeed/MLATE: direct link, hf CLI and curl.
- Browser
- Download file 13.9 kB
-
https://huggingface.co/spaces/Saeed/MLATE/resolve/main/mlate/evaluation.py
- Command line
-
hf download hf://spaces/Saeed/MLATE/mlate/evaluation.py
-
curl -L -o evaluation.py https://huggingface.co/spaces/Saeed/MLATE/resolve/main/mlate/evaluation.py
13.9 kB
| """ | |
| Classification metrics for imbalanced, ordinal targets | |
| ====================================================== | |
| Both targets are imbalanced and ordinal, which rules out reporting accuracy on | |
| its own. Printability class 3 holds 49.9% of the corpus, so a constant | |
| prediction scores 0.499 before any learning takes place; Cell Response class 1 | |
| is rarer still. Every result in step 04 therefore carries, alongside accuracy: | |
| balanced accuracy mean per-class recall - a model that ignores the minority | |
| classes cannot hide behind the majority | |
| macro F1 unweighted mean of per-class F1, same motivation | |
| weighted F1 F1 weighted by support, for comparability with the | |
| submitted version, which reported it | |
| Cohen's kappa agreement corrected for chance | |
| quadratic kappa the ordinal variant: predicting class 0 when the truth is | |
| class 3 is penalised more heavily than predicting class 2. | |
| Both targets are ordered, so this is the metric that | |
| matches the problem | |
| MCC Matthews correlation, stable under imbalance | |
| Scores are pooled over folds rather than averaged across them. Out-of-fold | |
| predictions are concatenated and scored once, so every sample contributes | |
| equally regardless of which fold it landed in; the mean-of-folds alternative | |
| gives small folds disproportionate weight and has visibly higher variance. The | |
| per-fold values are retained so that dispersion can still be reported. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.metrics import (accuracy_score, balanced_accuracy_score, | |
| cohen_kappa_score, confusion_matrix, f1_score, | |
| log_loss, matthews_corrcoef, | |
| precision_recall_fscore_support, roc_auc_score) | |
| from mlate import config as cfg | |
| N_BOOTSTRAP = 1000 | |
| def scores(y_true, y_pred, proba=None, labels=None) -> dict: | |
| """ | |
| The metric panel for one set of predictions. | |
| `proba` is an (n_samples, n_classes) array of predicted probabilities | |
| aligned to `labels`. When it is supplied the probability-based metrics are | |
| added; when a model has no predict_proba they come back as NaN rather than | |
| being silently omitted, so every model occupies the same columns. | |
| """ | |
| y_true = np.asarray(y_true) | |
| y_pred = np.asarray(y_pred) | |
| out = { | |
| "accuracy": float(accuracy_score(y_true, y_pred)), | |
| "balanced_accuracy": float(balanced_accuracy_score(y_true, y_pred)), | |
| "macro_precision": float(precision_recall_fscore_support( | |
| y_true, y_pred, average="macro", zero_division=0)[0]), | |
| "macro_recall": float(precision_recall_fscore_support( | |
| y_true, y_pred, average="macro", zero_division=0)[1]), | |
| "weighted_precision": float(precision_recall_fscore_support( | |
| y_true, y_pred, average="weighted", zero_division=0)[0]), | |
| "weighted_recall": float(precision_recall_fscore_support( | |
| y_true, y_pred, average="weighted", zero_division=0)[1]), | |
| "macro_f1": float(f1_score(y_true, y_pred, average="macro", | |
| zero_division=0)), | |
| "weighted_f1": float(f1_score(y_true, y_pred, average="weighted", | |
| zero_division=0)), | |
| "kappa": float(cohen_kappa_score(y_true, y_pred)), | |
| "quadratic_kappa": float(cohen_kappa_score(y_true, y_pred, | |
| weights="quadratic")), | |
| "mcc": float(matthews_corrcoef(y_true, y_pred)), | |
| "specificity_macro": _specificity(y_true, y_pred, labels), | |
| } | |
| out.update(_probability_scores(y_true, proba, labels)) | |
| return out | |
| def _specificity(y_true, y_pred, labels=None) -> float: | |
| """ | |
| Mean per-class true-negative rate. | |
| Macro recall is the mean sensitivity; reporting its complement alongside is | |
| what lets a reader see that a model achieving high specificity on a rare | |
| class may be doing so by never predicting it. | |
| """ | |
| labels = list(labels) if labels is not None else sorted(set(np.asarray(y_true))) | |
| cm = confusion_matrix(y_true, y_pred, labels=labels) | |
| total = cm.sum() | |
| out = [] | |
| for i in range(len(labels)): | |
| tp = cm[i, i] | |
| fn = cm[i, :].sum() - tp | |
| fp = cm[:, i].sum() - tp | |
| tn = total - tp - fn - fp | |
| out.append(tn / (tn + fp) if (tn + fp) else np.nan) | |
| return float(np.nanmean(out)) | |
| def _probability_scores(y_true, proba, labels) -> dict: | |
| """ROC-AUC, log loss and the multiclass Brier score, or NaN throughout.""" | |
| blank = {"roc_auc_macro_ovr": np.nan, "roc_auc_weighted_ovr": np.nan, | |
| "log_loss": np.nan, "brier_multiclass": np.nan} | |
| if proba is None or labels is None: | |
| return blank | |
| proba = np.asarray(proba, dtype=float) | |
| labels = list(labels) | |
| if proba.ndim != 2 or proba.shape[1] != len(labels) or np.isnan(proba).any(): | |
| return blank | |
| # Renormalise defensively; some estimators return rows that do not sum to 1. | |
| rows = proba.sum(axis=1, keepdims=True) | |
| proba = np.divide(proba, np.where(rows == 0, 1.0, rows)) | |
| present = sorted(set(np.asarray(y_true))) | |
| out = dict(blank) | |
| try: | |
| # One-vs-rest AUC is undefined for a class absent from y_true, so the | |
| # score is restricted to the classes actually observed and renormalised. | |
| keep = [labels.index(c) for c in present] | |
| if len(present) >= 2: | |
| sub = proba[:, keep] | |
| sub = sub / np.where(sub.sum(axis=1, keepdims=True) == 0, 1.0, | |
| sub.sum(axis=1, keepdims=True)) | |
| kw = dict(labels=present, multi_class="ovr") | |
| out["roc_auc_macro_ovr"] = float( | |
| roc_auc_score(y_true, sub, average="macro", **kw)) | |
| out["roc_auc_weighted_ovr"] = float( | |
| roc_auc_score(y_true, sub, average="weighted", **kw)) | |
| except Exception: | |
| pass | |
| try: | |
| out["log_loss"] = float(log_loss(y_true, proba, labels=labels)) | |
| except Exception: | |
| pass | |
| try: | |
| onehot = np.zeros_like(proba) | |
| index = {c: i for i, c in enumerate(labels)} | |
| for r, c in enumerate(np.asarray(y_true)): | |
| onehot[r, index[c]] = 1.0 | |
| out["brier_multiclass"] = float(((proba - onehot) ** 2).sum(axis=1).mean()) | |
| except Exception: | |
| pass | |
| return out | |
| def per_class(y_true, y_pred, labels) -> pd.DataFrame: | |
| """Precision, recall, F1 and support for every class, including absent ones.""" | |
| p, r, f, s = precision_recall_fscore_support( | |
| y_true, y_pred, labels=labels, zero_division=0) | |
| predicted = pd.Series(y_pred).value_counts() | |
| return pd.DataFrame({ | |
| "class": labels, "precision": p, "recall": r, "f1": f, | |
| "support": s, | |
| "n_predicted": [int(predicted.get(c, 0)) for c in labels], | |
| }) | |
| def confusion(y_true, y_pred, labels) -> pd.DataFrame: | |
| m = confusion_matrix(y_true, y_pred, labels=labels) | |
| return pd.DataFrame(m, index=[f"true_{c}" for c in labels], | |
| columns=[f"pred_{c}" for c in labels]) | |
| def bootstrap_ci(y_true, y_pred, metric: str = "macro_f1", | |
| n: int = N_BOOTSTRAP, alpha: float = 0.05, | |
| seed: int = cfg.RANDOM_STATE) -> tuple[float, float]: | |
| """ | |
| Percentile confidence interval by resampling predictions. | |
| This is an interval on the metric given these predictions, not on the whole | |
| modelling procedure - refitting inside every replicate would be the stricter | |
| construction and is not affordable across 30 models and three protocols. It | |
| is therefore an optimistic interval and is labelled as such wherever it is | |
| reported. | |
| """ | |
| y_true = np.asarray(y_true) | |
| y_pred = np.asarray(y_pred) | |
| rng = np.random.default_rng(seed) | |
| # Compute only the requested metric. Calling scores() here would evaluate | |
| # the whole panel - including ROC-AUC and a confusion matrix - a thousand | |
| # times per cell, which dominates the entire reporting step. | |
| single = { | |
| "macro_f1": lambda t, p_: f1_score(t, p_, average="macro", | |
| zero_division=0), | |
| "weighted_f1": lambda t, p_: f1_score(t, p_, average="weighted", | |
| zero_division=0), | |
| "accuracy": accuracy_score, | |
| "balanced_accuracy": balanced_accuracy_score, | |
| "mcc": matthews_corrcoef, | |
| "kappa": cohen_kappa_score, | |
| "quadratic_kappa": lambda t, p_: cohen_kappa_score(t, p_, | |
| weights="quadratic"), | |
| }.get(metric) | |
| if single is None: | |
| single = lambda t, p_: scores(t, p_)[metric] | |
| out = [] | |
| for _ in range(n): | |
| idx = rng.integers(0, len(y_true), len(y_true)) | |
| if len(np.unique(y_true[idx])) < 2: | |
| continue | |
| out.append(float(single(y_true[idx], y_pred[idx]))) | |
| if not out: | |
| return (np.nan, np.nan) | |
| return (float(np.percentile(out, 100 * alpha / 2)), | |
| float(np.percentile(out, 100 * (1 - alpha / 2)))) | |
| TASK_LABELS = {"printability": [0, 1, 2, 3], | |
| "cell_response": [1, 2, 3, 4, 5], | |
| "cell_response_cellular": [2, 3, 4, 5]} | |
| def score_predictions(preds: pd.DataFrame, | |
| bootstrap: bool = True) -> pd.DataFrame: | |
| """ | |
| Score a stored prediction table, whatever produced it. | |
| Conventional models, deep architectures and the foundation models all write | |
| the same schema, so they are scored here by the same code rather than by | |
| three near-copies that could drift. Grouping keys are model, task, | |
| protocol, selection and split. | |
| `split` is part of the key and never collapsed: a training score is a | |
| diagnostic and must stay separately labelled, so that no downstream table | |
| can accidentally present one as a result. | |
| Bootstrap intervals are computed on the test partition only. Under the | |
| hold-out design the reported number rests on a single 530- or 574-row | |
| partition, which makes an explicit interval more necessary than it was | |
| under cross-validation - Referee 1's comment 2 asks for exactly this. | |
| """ | |
| rows = [] | |
| keys = ["model", "task", "protocol", "selection", "split"] | |
| keys = [k for k in keys if k in preds.columns] | |
| for values, g in preds.groupby(keys, sort=False): | |
| record = dict(zip(keys, values if isinstance(values, tuple) | |
| else (values,))) | |
| labels = TASK_LABELS[record["task"]] | |
| pcols = [f"p_{c}" for c in labels] | |
| proba = None | |
| if all(c in g.columns for c in pcols): | |
| arr = g[pcols].to_numpy(dtype=float) | |
| proba = None if np.isnan(arr).any() else arr | |
| record["n_scored"] = int(len(g)) | |
| record.update(scores(g["y_true"], g["y_pred"], proba, labels)) | |
| if bootstrap and record.get("split") == "test": | |
| for metric in ("weighted_f1", "macro_f1"): | |
| lo, hi = bootstrap_ci(g["y_true"], g["y_pred"], metric) | |
| record[f"{metric}_lo"] = lo | |
| record[f"{metric}_hi"] = hi | |
| rows.append(record) | |
| return pd.DataFrame(rows) | |
| def train_test_gap(board: pd.DataFrame, | |
| metric: str = "weighted_f1") -> pd.DataFrame: | |
| """ | |
| Training minus test score for every model, as an overfitting diagnostic. | |
| Under the study-grouped protocol this separates two failures that look | |
| identical in a test column alone: a model that could not learn the task at | |
| all, and one that learned study-specific structure which does not transfer. | |
| They call for opposite remedies - better features versus more studies - so | |
| the distinction is worth a column. | |
| """ | |
| keys = [k for k in ("model", "task", "protocol", "selection") | |
| if k in board.columns] | |
| wide = board.pivot_table(index=keys, columns="split", values=metric) | |
| if not {"train", "test"} <= set(wide.columns): | |
| return pd.DataFrame() | |
| wide = wide.reset_index() | |
| wide[f"{metric}_gap"] = wide["train"] - wide["test"] | |
| return wide.rename(columns={"train": f"{metric}_train", | |
| "test": f"{metric}_test"}) | |
| def summarise(frame: pd.DataFrame, labels) -> pd.DataFrame: | |
| """ | |
| Collapse per-sample out-of-fold predictions into one row per | |
| model x task x protocol. | |
| `frame` must carry columns: model, task, protocol, fold, y_true, y_pred. | |
| """ | |
| rows = [] | |
| keys = ["model", "task", "protocol"] | |
| pcols = [f"p_{c}" for c in labels] | |
| def _proba(block): | |
| if not all(c in block.columns for c in pcols): | |
| return None | |
| arr = block[pcols].to_numpy(dtype=float) | |
| return None if np.isnan(arr).any() else arr | |
| for (model, task, protocol), g in frame.groupby(keys, sort=False): | |
| pooled = scores(g["y_true"], g["y_pred"], _proba(g), labels) | |
| by_fold = pd.DataFrame([ | |
| scores(h["y_true"], h["y_pred"], _proba(h), labels) | |
| for _, h in g.groupby("fold")]) | |
| lo, hi = bootstrap_ci(g["y_true"], g["y_pred"], "macro_f1") | |
| rows.append({ | |
| "model": model, "task": task, "protocol": protocol, | |
| "n_scored": int(len(g)), "n_folds": int(g["fold"].nunique()), | |
| **pooled, | |
| "macro_f1_lo": lo, "macro_f1_hi": hi, | |
| "accuracy_sd": float(by_fold["accuracy"].std(ddof=1)), | |
| "macro_f1_sd": float(by_fold["macro_f1"].std(ddof=1)), | |
| }) | |
| return pd.DataFrame(rows) | |