Spaces:
Running
Running
File size: 6,769 Bytes
8e4895a 7de5ac6 0aa6ca0 7de5ac6 8e4895a 7de5ac6 8e4895a 1c68f9a ad5ee0d cb0c5af 7de5ac6 8e4895a 7de5ac6 1c68f9a 7de5ac6 75e6a8b 7de5ac6 75e6a8b 7de5ac6 75e6a8b 7de5ac6 1c68f9a ad5ee0d 75e6a8b 8e4895a 7de5ac6 6286da1 8e4895a 7de5ac6 6286da1 7de5ac6 8e4895a 7de5ac6 cd19179 1c68f9a 7de5ac6 8e4895a 6286da1 8e4895a 1c68f9a 8e4895a 6286da1 8e4895a 6286da1 8e4895a cb0c5af 8e4895a cb0c5af 1c68f9a cb0c5af be5ca40 8e4895a be5ca40 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | """Scoring policy for the PRIMO benchmark: predictions -> per-category numbers.
Everything that turns a model's fold-wise cross-validation scores into leaderboard
numbers lives here, kept apart from the probe and from any I/O so it stays easy
to change as the benchmark grows.
The scoring unit is a task = (dataset, target). Tasks are grouped by their
``category`` (including treatment outcome, clinical scores, endotype, and
perturbation response). A category uses a SINGLE metric (enforced in the registry),
so its leaderboard number is a plain mean of that metric -- AUROC, Pearson and
centered Spearman are never averaged together inside a category column.
``sort_key`` is the one place they are averaged, to give the board a single
order. It is shown as the ``Mean`` column, labelled as a cross-metric average so
nobody reads it as a metric in its own right; the per-category columns remain
the numbers to compare on.
Pure numpy / sklearn-metrics -- no huggingface, no file I/O, so it unit-tests
without a network and is safe to rework mid-project.
"""
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass, field
import numpy as np
from sklearn.metrics import roc_auc_score
def compute_auroc(
y_true: np.ndarray, y_pred: np.ndarray, classes: np.ndarray | None = None
) -> float:
"""AUROC from class probabilities (``y_pred`` is an ``(n, n_classes)`` matrix).
``classes`` names the columns of ``y_pred``. It matters whenever ``y_true``
holds fewer classes than the task does -- a transfer split whose test cohort
misses one. The columns of the absent classes are dropped and the rest
renormalized, so the score never reads a column belonging to another class,
and never trips sklearn's sum-to-one check.
"""
classes = np.unique(y_true) if classes is None else np.asarray(classes)
present = np.isin(classes, np.unique(y_true))
scores = np.asarray(y_pred)[:, present]
totals = scores.sum(axis=1, keepdims=True)
if not (totals > 0).all():
raise ValueError(
"some samples carry no probability on any class present in y_true"
)
scores = scores / totals
kept = classes[present]
if len(kept) == 2:
return float(roc_auc_score(y_true, scores[:, 1]))
return float(
roc_auc_score(
y_true, scores, multi_class="ovr", average="weighted", labels=kept
)
)
def compute_pearson(
y_true: np.ndarray, y_pred: np.ndarray, classes: np.ndarray | None = None
) -> float:
"""Pearson r between predictions and targets; NaN if either is constant."""
if np.std(y_pred) == 0 or np.std(y_true) == 0:
return float("nan")
return float(np.corrcoef(y_pred, y_true)[0, 1])
def _average_ranks(values: np.ndarray) -> np.ndarray:
"""Return stable, one-based average ranks with deterministic tie handling."""
values = np.asarray(values)
order = np.argsort(values, kind="stable")
sorted_values = values[order]
sorted_ranks = np.empty(len(values), dtype=float)
start = 0
while start < len(values):
stop = start + 1
while stop < len(values) and sorted_values[stop] == sorted_values[start]:
stop += 1
sorted_ranks[start:stop] = 0.5 * (start + stop - 1) + 1.0
start = stop
ranks = np.empty(len(values), dtype=float)
ranks[order] = sorted_ranks
return ranks
def _mean_sample_spearman(y_true: np.ndarray, y_pred: np.ndarray) -> float:
"""Return mean row-wise rank correlation, scoring degenerate rows at zero."""
correlations = []
for truth, prediction in zip(y_true, y_pred):
correlation = compute_pearson(_average_ranks(truth), _average_ranks(prediction))
correlations.append(correlation if np.isfinite(correlation) else 0.0)
return float(np.mean(correlations)) if correlations else float("nan")
def compute_residual_sample_spearman(
y_true: np.ndarray,
y_pred: np.ndarray,
training_mean: np.ndarray,
) -> float:
"""Mean sample-wise Spearman beyond the training-fold mean response."""
return _mean_sample_spearman(y_true - training_mean, y_pred - training_mean)
def compute_target_centered_sample_spearman(
y_true: np.ndarray,
y_pred: np.ndarray,
) -> float:
"""Mean sample-wise Spearman after removing each target-cohort gene mean."""
truth_centered = y_true - np.mean(y_true, axis=0)
prediction_centered = y_pred - np.mean(y_pred, axis=0)
return _mean_sample_spearman(truth_centered, prediction_centered)
METRICS: dict[str, Callable[[np.ndarray, np.ndarray, np.ndarray | None], float]] = {
"auroc": compute_auroc,
"pearson": compute_pearson,
}
@dataclass(frozen=True)
class TaskScore:
"""One task's result: a raw metric plus the category it is grouped under."""
task_id: str
dataset_id: str
category: str
metric: str
score: float
n_samples: int
repeat_scores: tuple[float, ...] = ()
diagnostics: dict[str, float] = field(default_factory=dict)
def category_means(scores: list[TaskScore]) -> dict[str, dict]:
"""Mean of the native metric per task category.
A category uses one metric, so this is a plain mean of that metric -- never a
mix of native metrics. Degenerate (non-finite) task scores are dropped from
the mean. Returns ``{category: {metric, mean, n_tasks}}`` for the categories
present in ``scores``.
"""
by_category: dict[str, list[TaskScore]] = defaultdict(list)
for score in scores:
by_category[score.category].append(score)
out = {}
for category, items in by_category.items():
finite = [s.score for s in items if np.isfinite(s.score)]
out[category] = {
"metric": items[0].metric,
"mean": float(np.mean(finite)) if finite else float("nan"),
"n_tasks": len(items),
}
return out
def sort_key(categories: dict[str, dict]) -> float:
"""Leaderboard ranking key: mean of the per-category means.
Orders the rows, and is shown as the ``Mean`` column on boards holding more
than one category. It does average across native metrics, a
deliberate compromise for a single order; swap for a per-category-normalized
mean if the ranking needs to be metric-fair.
An entry with nothing finite to average sorts LAST, not at zero: a constant
embedding scores NaN on every Pearson task, and zero would float it above a
model that merely correlates negatively -- ranking "no score" over "a bad
score". Nothing finite means nothing to show, so the table renders it blank.
"""
means = [c["mean"] for c in categories.values() if np.isfinite(c["mean"])]
return float(np.mean(means)) if means else float("-inf")
|