ml_meta_perf.stats

The handful of statistics the project needs, written out rather than imported.

scipy would supply all of this, but it is a large dependency for four short functions, and the rank machinery here has to be exercised by the test suite regardless.

Study chapter: 5. Evaluation -- the rationale, in prose, with the figures.

  1"""The handful of statistics the project needs, written out rather than imported.
  2
  3scipy would supply all of this, but it is a large dependency for four short functions,
  4and the rank machinery here has to be exercised by the test suite regardless.
  5
  6Study chapter: [5. Evaluation][study-chapter] -- the rationale, in
  7prose, with the figures.
  8
  9[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/05-evaluation.md
 10"""
 11
 12from __future__ import annotations
 13
 14import numpy as np
 15
 16
 17def rankdata(values: np.ndarray) -> np.ndarray:
 18    """Ranks from 1, with ties receiving their average rank.
 19
 20    Average ranks matter here: MCC can pile up at exactly 1.0,
 21    and ordinal ranks would invent an ordering among tied perfect scores.
 22    """
 23    order = np.argsort(values, kind="stable")
 24    ranks = np.empty(values.shape[0], dtype=np.float64)
 25    ranks[order] = np.arange(1, values.shape[0] + 1, dtype=np.float64)
 26    unique, inverse, counts = np.unique(values, return_inverse=True, return_counts=True)
 27    if unique.shape[0] != values.shape[0]:
 28        sums = np.zeros(unique.shape[0], dtype=np.float64)
 29        np.add.at(sums, inverse, ranks)
 30        ranks = (sums / counts)[inverse]
 31    return ranks
 32
 33
 34def rank_columns(matrix: np.ndarray) -> np.ndarray:
 35    """`rankdata` applied down every column at once, with the same tie handling.
 36
 37    `ml_meta_perf.search.guided_screen` needs the ranks of every candidate term in the library, in
 38    every fold. Doing that a column at a time cost 32k calls and a tenth of the study's
 39    runtime; the work is identical but the Python loop is not. ``test_stats`` asserts the
 40    two agree column by column, so `rankdata` stays the definition and this stays a
 41    restatement of it.
 42
 43    Ties are averaged by locating each run of equal values in the sorted order: a run
 44    spanning sorted positions ``i..j`` takes rank ``(i + j) / 2 + 1``, which is the mean of
 45    the consecutive ranks it would otherwise receive.
 46    """
 47    rows = matrix.shape[0]
 48    order = np.argsort(matrix, axis=0, kind="stable")
 49    ordered = np.take_along_axis(matrix, order, axis=0)
 50
 51    starts = np.empty(matrix.shape, dtype=bool)
 52    starts[0] = True
 53    np.not_equal(ordered[1:], ordered[:-1], out=starts[1:])
 54    ends = np.empty(matrix.shape, dtype=bool)
 55    ends[-1] = True
 56    ends[:-1] = starts[1:]
 57
 58    position = np.arange(rows, dtype=np.float64)[:, None]
 59    first = np.maximum.accumulate(np.where(starts, position, -1.0), axis=0)
 60    last = np.minimum.accumulate(np.where(ends, position, float(rows))[::-1], axis=0)[::-1]
 61
 62    ranks = np.empty(matrix.shape, dtype=np.float64)
 63    np.put_along_axis(ranks, order, (first + last) / 2.0 + 1.0, axis=0)
 64    return ranks
 65
 66
 67def pearson(first: np.ndarray, second: np.ndarray) -> float:
 68    """Linear correlation, returning 0.0 when either side is constant."""
 69    a = first - first.mean()
 70    b = second - second.mean()
 71    scale = float(np.sqrt(float(a @ a) * float(b @ b)))
 72    return float(a @ b / scale) if scale > 1e-15 else 0.0
 73
 74
 75def pearson_columns(matrix: np.ndarray, target: np.ndarray) -> np.ndarray:
 76    """`pearson` between every column of ``matrix`` and ``target``, in one pass.
 77
 78    `ml_meta_perf.search.guided_screen` needs this correlation for every candidate term, twice
 79    over -- once raw and once on ranks -- in every fold, which is tens of thousands of calls
 80    to `pearson` for arithmetic that is a single matrix-vector product. Constant columns
 81    return 0.0 here exactly as they do there. ``test_stats`` asserts the two agree column by
 82    column, so `pearson` stays the definition and this stays a restatement of it.
 83    """
 84    centred = matrix - matrix.mean(axis=0)
 85    other = target - target.mean()
 86    norms = np.sqrt(np.einsum("ij,ij->j", centred, centred) * float(other @ other))
 87    with np.errstate(divide="ignore", invalid="ignore"):
 88        return np.where(norms > 1e-15, (centred.T @ other) / np.where(norms > 1e-15, norms, 1.0), 0.0)
 89
 90
 91def spearman(first: np.ndarray, second: np.ndarray) -> float:
 92    """Rank correlation: monotone association, insensitive to the MCC ceiling."""
 93    if first.shape[0] < 2:
 94        return 0.0
 95    return pearson(rankdata(first), rankdata(second))
 96
 97
 98def r2_score(truth: np.ndarray, prediction: np.ndarray) -> float:
 99    """Coefficient of determination against the mean of ``truth``.
100
101    Note the denominator is always the variance of the *evaluated* rows, so an R2 from
102    the 20 aggregated dataset means is not comparable with one from the 484 raw rows.
103    That is exactly why E1 is reported on both scales.
104    """
105    residual = float(((truth - prediction) ** 2).sum())
106    total = float(((truth - truth.mean()) ** 2).sum())
107    return 1.0 - residual / total if total > 1e-15 else 0.0
108
109
110def mae(truth: np.ndarray, prediction: np.ndarray) -> float:
111    """Mean absolute error, in MCC units."""
112    return float(np.abs(truth - prediction).mean())
113
114
115def rmse(truth: np.ndarray, prediction: np.ndarray) -> float:
116    """Root mean squared error, in MCC units."""
117    return float(np.sqrt(float(((truth - prediction) ** 2).mean())))
118
119
120def smape(truth: np.ndarray, prediction: np.ndarray) -> float:
121    """Symmetric mean absolute percentage error, as a percentage in [0, 200].
122
123    **Read this one with care on MCC.** SMAPE divides by ``|truth| + |prediction|``, and
124    rows with MCC exactly 0 each contribute the full
125    200% unless the prediction is also exactly 0, so the metric is dominated by the rows
126    the equation is already known to handle worst rather than by its typical error. It is
127    reported because it was asked for and because it is scale-free, but MAE is the
128    honest headline for a target that legitimately passes through zero.
129
130    Rows where both truth and prediction are zero contribute 0, not a division by zero.
131    """
132    denominator = np.abs(truth) + np.abs(prediction)
133    safe = np.where(denominator > 0.0, denominator, 1.0)
134    ratio = np.where(denominator > 0.0, 2.0 * np.abs(truth - prediction) / safe, 0.0)
135    return float(100.0 * ratio.mean())
def rankdata(values: numpy.ndarray) -> numpy.ndarray:
18def rankdata(values: np.ndarray) -> np.ndarray:
19    """Ranks from 1, with ties receiving their average rank.
20
21    Average ranks matter here: MCC can pile up at exactly 1.0,
22    and ordinal ranks would invent an ordering among tied perfect scores.
23    """
24    order = np.argsort(values, kind="stable")
25    ranks = np.empty(values.shape[0], dtype=np.float64)
26    ranks[order] = np.arange(1, values.shape[0] + 1, dtype=np.float64)
27    unique, inverse, counts = np.unique(values, return_inverse=True, return_counts=True)
28    if unique.shape[0] != values.shape[0]:
29        sums = np.zeros(unique.shape[0], dtype=np.float64)
30        np.add.at(sums, inverse, ranks)
31        ranks = (sums / counts)[inverse]
32    return ranks

Ranks from 1, with ties receiving their average rank.

Average ranks matter here: MCC can pile up at exactly 1.0, and ordinal ranks would invent an ordering among tied perfect scores.

def rank_columns(matrix: numpy.ndarray) -> numpy.ndarray:
35def rank_columns(matrix: np.ndarray) -> np.ndarray:
36    """`rankdata` applied down every column at once, with the same tie handling.
37
38    `ml_meta_perf.search.guided_screen` needs the ranks of every candidate term in the library, in
39    every fold. Doing that a column at a time cost 32k calls and a tenth of the study's
40    runtime; the work is identical but the Python loop is not. ``test_stats`` asserts the
41    two agree column by column, so `rankdata` stays the definition and this stays a
42    restatement of it.
43
44    Ties are averaged by locating each run of equal values in the sorted order: a run
45    spanning sorted positions ``i..j`` takes rank ``(i + j) / 2 + 1``, which is the mean of
46    the consecutive ranks it would otherwise receive.
47    """
48    rows = matrix.shape[0]
49    order = np.argsort(matrix, axis=0, kind="stable")
50    ordered = np.take_along_axis(matrix, order, axis=0)
51
52    starts = np.empty(matrix.shape, dtype=bool)
53    starts[0] = True
54    np.not_equal(ordered[1:], ordered[:-1], out=starts[1:])
55    ends = np.empty(matrix.shape, dtype=bool)
56    ends[-1] = True
57    ends[:-1] = starts[1:]
58
59    position = np.arange(rows, dtype=np.float64)[:, None]
60    first = np.maximum.accumulate(np.where(starts, position, -1.0), axis=0)
61    last = np.minimum.accumulate(np.where(ends, position, float(rows))[::-1], axis=0)[::-1]
62
63    ranks = np.empty(matrix.shape, dtype=np.float64)
64    np.put_along_axis(ranks, order, (first + last) / 2.0 + 1.0, axis=0)
65    return ranks

rankdata applied down every column at once, with the same tie handling.

ml_meta_perf.search.guided_screen needs the ranks of every candidate term in the library, in every fold. Doing that a column at a time cost 32k calls and a tenth of the study's runtime; the work is identical but the Python loop is not. test_stats asserts the two agree column by column, so rankdata stays the definition and this stays a restatement of it.

Ties are averaged by locating each run of equal values in the sorted order: a run spanning sorted positions i..j takes rank (i + j) / 2 + 1, which is the mean of the consecutive ranks it would otherwise receive.

def pearson(first: numpy.ndarray, second: numpy.ndarray) -> float:
68def pearson(first: np.ndarray, second: np.ndarray) -> float:
69    """Linear correlation, returning 0.0 when either side is constant."""
70    a = first - first.mean()
71    b = second - second.mean()
72    scale = float(np.sqrt(float(a @ a) * float(b @ b)))
73    return float(a @ b / scale) if scale > 1e-15 else 0.0

Linear correlation, returning 0.0 when either side is constant.

def pearson_columns(matrix: numpy.ndarray, target: numpy.ndarray) -> numpy.ndarray:
76def pearson_columns(matrix: np.ndarray, target: np.ndarray) -> np.ndarray:
77    """`pearson` between every column of ``matrix`` and ``target``, in one pass.
78
79    `ml_meta_perf.search.guided_screen` needs this correlation for every candidate term, twice
80    over -- once raw and once on ranks -- in every fold, which is tens of thousands of calls
81    to `pearson` for arithmetic that is a single matrix-vector product. Constant columns
82    return 0.0 here exactly as they do there. ``test_stats`` asserts the two agree column by
83    column, so `pearson` stays the definition and this stays a restatement of it.
84    """
85    centred = matrix - matrix.mean(axis=0)
86    other = target - target.mean()
87    norms = np.sqrt(np.einsum("ij,ij->j", centred, centred) * float(other @ other))
88    with np.errstate(divide="ignore", invalid="ignore"):
89        return np.where(norms > 1e-15, (centred.T @ other) / np.where(norms > 1e-15, norms, 1.0), 0.0)

pearson between every column of matrix and target, in one pass.

ml_meta_perf.search.guided_screen needs this correlation for every candidate term, twice over -- once raw and once on ranks -- in every fold, which is tens of thousands of calls to pearson for arithmetic that is a single matrix-vector product. Constant columns return 0.0 here exactly as they do there. test_stats asserts the two agree column by column, so pearson stays the definition and this stays a restatement of it.

def spearman(first: numpy.ndarray, second: numpy.ndarray) -> float:
92def spearman(first: np.ndarray, second: np.ndarray) -> float:
93    """Rank correlation: monotone association, insensitive to the MCC ceiling."""
94    if first.shape[0] < 2:
95        return 0.0
96    return pearson(rankdata(first), rankdata(second))

Rank correlation: monotone association, insensitive to the MCC ceiling.

def r2_score(truth: numpy.ndarray, prediction: numpy.ndarray) -> float:
 99def r2_score(truth: np.ndarray, prediction: np.ndarray) -> float:
100    """Coefficient of determination against the mean of ``truth``.
101
102    Note the denominator is always the variance of the *evaluated* rows, so an R2 from
103    the 20 aggregated dataset means is not comparable with one from the 484 raw rows.
104    That is exactly why E1 is reported on both scales.
105    """
106    residual = float(((truth - prediction) ** 2).sum())
107    total = float(((truth - truth.mean()) ** 2).sum())
108    return 1.0 - residual / total if total > 1e-15 else 0.0

Coefficient of determination against the mean of truth.

Note the denominator is always the variance of the evaluated rows, so an R2 from the 20 aggregated dataset means is not comparable with one from the 484 raw rows. That is exactly why E1 is reported on both scales.

def mae(truth: numpy.ndarray, prediction: numpy.ndarray) -> float:
111def mae(truth: np.ndarray, prediction: np.ndarray) -> float:
112    """Mean absolute error, in MCC units."""
113    return float(np.abs(truth - prediction).mean())

Mean absolute error, in MCC units.

def rmse(truth: numpy.ndarray, prediction: numpy.ndarray) -> float:
116def rmse(truth: np.ndarray, prediction: np.ndarray) -> float:
117    """Root mean squared error, in MCC units."""
118    return float(np.sqrt(float(((truth - prediction) ** 2).mean())))

Root mean squared error, in MCC units.

def smape(truth: numpy.ndarray, prediction: numpy.ndarray) -> float:
121def smape(truth: np.ndarray, prediction: np.ndarray) -> float:
122    """Symmetric mean absolute percentage error, as a percentage in [0, 200].
123
124    **Read this one with care on MCC.** SMAPE divides by ``|truth| + |prediction|``, and
125    rows with MCC exactly 0 each contribute the full
126    200% unless the prediction is also exactly 0, so the metric is dominated by the rows
127    the equation is already known to handle worst rather than by its typical error. It is
128    reported because it was asked for and because it is scale-free, but MAE is the
129    honest headline for a target that legitimately passes through zero.
130
131    Rows where both truth and prediction are zero contribute 0, not a division by zero.
132    """
133    denominator = np.abs(truth) + np.abs(prediction)
134    safe = np.where(denominator > 0.0, denominator, 1.0)
135    ratio = np.where(denominator > 0.0, 2.0 * np.abs(truth - prediction) / safe, 0.0)
136    return float(100.0 * ratio.mean())

Symmetric mean absolute percentage error, as a percentage in [0, 200].

Read this one with care on MCC. SMAPE divides by |truth| + |prediction|, and rows with MCC exactly 0 each contribute the full 200% unless the prediction is also exactly 0, so the metric is dominated by the rows the equation is already known to handle worst rather than by its typical error. It is reported because it was asked for and because it is scale-free, but MAE is the honest headline for a target that legitimately passes through zero.

Rows where both truth and prediction are zero contribute 0, not a division by zero.