aggmetrics 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aggmetrics/__init__.py +74 -0
- aggmetrics/aggregation.py +210 -0
- aggmetrics/core.py +351 -0
- aggmetrics/diagnostics.py +312 -0
- aggmetrics/inference.py +339 -0
- aggmetrics/measures.py +329 -0
- aggmetrics/sklearn_compat.py +87 -0
- aggmetrics/tnorms.py +111 -0
- aggmetrics-0.1.0.dev0.dist-info/METADATA +203 -0
- aggmetrics-0.1.0.dev0.dist-info/RECORD +13 -0
- aggmetrics-0.1.0.dev0.dist-info/WHEEL +5 -0
- aggmetrics-0.1.0.dev0.dist-info/licenses/LICENSE +29 -0
- aggmetrics-0.1.0.dev0.dist-info/top_level.txt +1 -0
aggmetrics/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright (c) 2026, Ronaldo C. Prati
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""
|
|
4
|
+
aggmetrics --- classification evaluation via indicator matrices.
|
|
5
|
+
|
|
6
|
+
Any binary performance measure expressible in terms of the four confusion-matrix
|
|
7
|
+
counts is extended to multiclass, multilabel, ordinal, cost-sensitive and
|
|
8
|
+
soft-label settings by substituting one of three aggregation operators, so that
|
|
9
|
+
micro-, macro- and exemplar-averaging are three instances of one construction
|
|
10
|
+
rather than three separate conventions.
|
|
11
|
+
|
|
12
|
+
Quick start
|
|
13
|
+
-----------
|
|
14
|
+
>>> import numpy as np
|
|
15
|
+
>>> from aggmetrics import IndicatorMatrix
|
|
16
|
+
>>> y_true = np.array([0, 1, 2, 2, 1, 0])
|
|
17
|
+
>>> y_pred = np.array([0, 2, 2, 1, 1, 1])
|
|
18
|
+
>>> im = IndicatorMatrix(y_true, y_pred, task='multiclass')
|
|
19
|
+
>>> round(im.compute('f1', 'macro'), 4)
|
|
20
|
+
0.5222
|
|
21
|
+
>>> round(im.compute('f1', 'micro'), 4)
|
|
22
|
+
0.5
|
|
23
|
+
>>> round(im.accuracy(), 4)
|
|
24
|
+
0.5
|
|
25
|
+
|
|
26
|
+
Micro-F1 equals accuracy on single-label data, so reporting both adds nothing;
|
|
27
|
+
macro-F1 differs because it weights the three classes equally.
|
|
28
|
+
|
|
29
|
+
The package also reports when a measure is being used outside the range where it
|
|
30
|
+
behaves as commonly assumed:
|
|
31
|
+
|
|
32
|
+
>>> from aggmetrics import diagnose
|
|
33
|
+
>>> codes = sorted({f.code for f in diagnose(im)})
|
|
34
|
+
>>> 'micro_collapse' in codes
|
|
35
|
+
True
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0.dev0"
|
|
40
|
+
__author__ = "Ronaldo C. Prati"
|
|
41
|
+
__license__ = "BSD-3-Clause"
|
|
42
|
+
|
|
43
|
+
from .aggregation import (aggregate, cells, fold_aggregate, sigma_1, sigma_k,
|
|
44
|
+
sigma_m, sigma_n)
|
|
45
|
+
from .core import IndicatorMatrix, pareto_dominance
|
|
46
|
+
from .diagnostics import (Finding, MeasureWarning, check_hull, check_ovr_skew_drift,
|
|
47
|
+
check_redundancy, diagnose, micro_macro_gap,
|
|
48
|
+
uniform_error_deviation)
|
|
49
|
+
from .inference import (InferredProperties, infer_properties, register_measure,
|
|
50
|
+
verify_inference)
|
|
51
|
+
from .measures import MEASURES, BinaryMeasure, get_measure, list_measures
|
|
52
|
+
from .sklearn_compat import make_scorer, score, sklearn_average
|
|
53
|
+
from .tnorms import TNORMS, get_tnorm, marginal_defect, soft_cells
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"__version__",
|
|
57
|
+
# core
|
|
58
|
+
"IndicatorMatrix", "pareto_dominance",
|
|
59
|
+
# measures
|
|
60
|
+
"BinaryMeasure", "MEASURES", "get_measure", "list_measures",
|
|
61
|
+
# operators
|
|
62
|
+
"aggregate", "cells", "fold_aggregate", "sigma_1", "sigma_m", "sigma_n", "sigma_k",
|
|
63
|
+
# soft labels
|
|
64
|
+
"TNORMS", "get_tnorm", "marginal_defect", "soft_cells",
|
|
65
|
+
# diagnostics
|
|
66
|
+
"Finding", "MeasureWarning", "check_hull", "check_ovr_skew_drift",
|
|
67
|
+
"check_redundancy", "diagnose", "micro_macro_gap", "uniform_error_deviation",
|
|
68
|
+
# inference
|
|
69
|
+
"register_measure", "infer_properties", "verify_inference",
|
|
70
|
+
"InferredProperties",
|
|
71
|
+
# scikit-learn
|
|
72
|
+
"make_scorer", "score", "sklearn_average",
|
|
73
|
+
]
|
|
74
|
+
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Copyright (c) 2026, Ronaldo C. Prati
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""
|
|
4
|
+
The three aggregation operators, plus the conventions they require.
|
|
5
|
+
|
|
6
|
+
Given indicator matrices :math:`Y, \\hat{Y} \\in \\{0,1\\}^{n \\times m}`, the four
|
|
7
|
+
cell matrices are formed entrywise and then reduced in one of three ways:
|
|
8
|
+
|
|
9
|
+
=============== ========================== ====================================
|
|
10
|
+
Operator Reduction Conventional name
|
|
11
|
+
=============== ========================== ====================================
|
|
12
|
+
``sigma_1`` over all entries micro
|
|
13
|
+
``sigma_m`` column-wise, then averaged macro (or weighted)
|
|
14
|
+
``sigma_n`` row-wise, then averaged exemplar (``average='samples'``)
|
|
15
|
+
=============== ========================== ====================================
|
|
16
|
+
|
|
17
|
+
Column- and row-wise aggregation divide before averaging, so an individual entry
|
|
18
|
+
can be undefined when its denominator vanishes. Because the resolution changes the
|
|
19
|
+
reported value, it is an explicit parameter rather than an implicit default. The
|
|
20
|
+
package defaults to ``'exclude'``, which averages over the defined entries only.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Literal, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from .measures import BinaryMeasure, get_measure
|
|
29
|
+
|
|
30
|
+
__all__ = ["cells", "sigma_1", "sigma_m", "sigma_n", "sigma_k",
|
|
31
|
+
"aggregate", "fold_aggregate",
|
|
32
|
+
"UNDEFINED_CONVENTIONS", "Aggregation"]
|
|
33
|
+
|
|
34
|
+
Aggregation = Literal["micro", "macro", "weighted", "exemplar"]
|
|
35
|
+
UNDEFINED_CONVENTIONS = ("exclude", "zero", "one", "nan")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _as2d(a) -> np.ndarray:
|
|
39
|
+
a = np.asarray(a)
|
|
40
|
+
return a.reshape(-1, 1) if a.ndim == 1 else a
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cells(Y, P, mask=None) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
44
|
+
"""Entrywise TP, FP, FN, TN matrices.
|
|
45
|
+
|
|
46
|
+
Works for crisp indicator matrices and, unchanged, for soft ones in
|
|
47
|
+
``[0, 1]`` when they have already been combined by a t-norm (see
|
|
48
|
+
:mod:`aggmetrics.tnorms`).
|
|
49
|
+
|
|
50
|
+
``mask`` is an optional observation mask: entries where ``mask == 0`` are
|
|
51
|
+
treated as unobserved and contribute nothing, which covers partial labels,
|
|
52
|
+
semi-supervised evaluation and masked-out abstentions.
|
|
53
|
+
"""
|
|
54
|
+
Y, P = _as2d(Y).astype(float), _as2d(P).astype(float)
|
|
55
|
+
if Y.shape != P.shape:
|
|
56
|
+
raise ValueError(f"shape mismatch: Y is {Y.shape}, predictions are {P.shape}")
|
|
57
|
+
TP, FP, FN, TN = Y * P, (1 - Y) * P, Y * (1 - P), (1 - Y) * (1 - P)
|
|
58
|
+
if mask is not None:
|
|
59
|
+
M = _as2d(mask).astype(float)
|
|
60
|
+
if M.shape != Y.shape:
|
|
61
|
+
raise ValueError(f"mask shape {M.shape} does not match {Y.shape}")
|
|
62
|
+
TP, FP, FN, TN = TP * M, FP * M, FN * M, TN * M
|
|
63
|
+
return TP, FP, FN, TN
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sigma_1(X: np.ndarray) -> float:
|
|
67
|
+
"""Global aggregation: sum over all entries."""
|
|
68
|
+
return float(np.sum(X))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sigma_m(X: np.ndarray) -> np.ndarray:
|
|
72
|
+
"""Column-wise aggregation: one value per class or label."""
|
|
73
|
+
return np.asarray(X).sum(axis=0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sigma_n(X: np.ndarray) -> np.ndarray:
|
|
77
|
+
"""Row-wise aggregation: one value per example."""
|
|
78
|
+
return np.asarray(X).sum(axis=1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def sigma_k(cell_list: Sequence[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]
|
|
82
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
83
|
+
"""Fold-wise aggregation across K cross-validation splits.
|
|
84
|
+
|
|
85
|
+
Sums the four cell matrices (TP, FP, FN, TN) across multiple folds.
|
|
86
|
+
"""
|
|
87
|
+
if not cell_list:
|
|
88
|
+
raise ValueError("cell_list must not be empty")
|
|
89
|
+
TP = sum(c[0] for c in cell_list)
|
|
90
|
+
FP = sum(c[1] for c in cell_list)
|
|
91
|
+
FN = sum(c[2] for c in cell_list)
|
|
92
|
+
TN = sum(c[3] for c in cell_list)
|
|
93
|
+
return TP, FP, FN, TN
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _combine(values: np.ndarray, convention: str,
|
|
97
|
+
weights: Optional[np.ndarray] = None) -> float:
|
|
98
|
+
values = np.asarray(values, dtype=float)
|
|
99
|
+
defined = ~np.isnan(values)
|
|
100
|
+
if convention == "nan":
|
|
101
|
+
return float(np.nan) if not defined.all() else float(np.average(
|
|
102
|
+
values, weights=weights))
|
|
103
|
+
if convention == "exclude":
|
|
104
|
+
if not defined.any():
|
|
105
|
+
return float("nan")
|
|
106
|
+
w = None if weights is None else np.asarray(weights, float)[defined]
|
|
107
|
+
return float(np.average(values[defined], weights=w))
|
|
108
|
+
if convention in ("zero", "one"):
|
|
109
|
+
filled = np.where(defined, values, 0.0 if convention == "zero" else 1.0)
|
|
110
|
+
return float(np.average(filled, weights=weights))
|
|
111
|
+
raise ValueError(f"convention must be one of {UNDEFINED_CONVENTIONS}, "
|
|
112
|
+
f"got {convention!r}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def aggregate(Y, P, measure: str | BinaryMeasure, how: Aggregation = "micro",
|
|
116
|
+
*, convention: str = "exclude", mask=None,
|
|
117
|
+
return_parts: bool = False):
|
|
118
|
+
"""Apply ``measure`` to ``(Y, P)`` under the operator ``how``.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
how
|
|
123
|
+
``'micro'`` pools all entries before applying the measure; ``'macro'``
|
|
124
|
+
applies it per class and averages; ``'weighted'`` applies it per class and
|
|
125
|
+
averages with weights proportional to the class prevalence; ``'exemplar'``
|
|
126
|
+
applies it per example and averages.
|
|
127
|
+
convention
|
|
128
|
+
Treatment of undefined per-class or per-example entries. ``'exclude'``
|
|
129
|
+
averages over defined entries only. ``'zero'`` reproduces the behaviour of
|
|
130
|
+
``sklearn``'s ``zero_division=0``.
|
|
131
|
+
return_parts
|
|
132
|
+
If True, also return the vector of per-class or per-example values, which
|
|
133
|
+
is what the diagnostics use to check whether a pooled value lies inside
|
|
134
|
+
their convex hull.
|
|
135
|
+
"""
|
|
136
|
+
meas = get_measure(measure)
|
|
137
|
+
TP, FP, FN, TN = cells(Y, P, mask=mask)
|
|
138
|
+
|
|
139
|
+
if how == "micro":
|
|
140
|
+
value = meas(sigma_1(TP), sigma_1(FP), sigma_1(FN), sigma_1(TN))
|
|
141
|
+
return (value, None) if return_parts else value
|
|
142
|
+
|
|
143
|
+
if how in ("macro", "weighted"):
|
|
144
|
+
red, axis = sigma_m, 0
|
|
145
|
+
elif how == "exemplar":
|
|
146
|
+
red, axis = sigma_n, 1
|
|
147
|
+
else:
|
|
148
|
+
raise ValueError("how must be 'micro', 'macro', 'weighted' or 'exemplar'")
|
|
149
|
+
|
|
150
|
+
tp, fp, fn, tn = red(TP), red(FP), red(FN), red(TN)
|
|
151
|
+
parts = np.array([meas(*c) for c in zip(tp, fp, fn, tn)], dtype=float)
|
|
152
|
+
|
|
153
|
+
weights = None
|
|
154
|
+
if how == "weighted":
|
|
155
|
+
weights = tp + fn # class prevalence
|
|
156
|
+
if float(np.sum(weights)) <= 0:
|
|
157
|
+
weights = None
|
|
158
|
+
|
|
159
|
+
value = _combine(parts, convention, weights)
|
|
160
|
+
return (value, parts) if return_parts else value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def fold_aggregate(im_list: Sequence, measure: str | BinaryMeasure,
|
|
164
|
+
how: Aggregation = "macro",
|
|
165
|
+
pooling: Literal["pooled", "averaged"] = "pooled",
|
|
166
|
+
*, convention: str = "exclude") -> float:
|
|
167
|
+
"""Aggregate a performance measure across K cross-validation folds.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
im_list
|
|
172
|
+
Sequence of :class:`~aggmetrics.core.IndicatorMatrix` instances, one per fold.
|
|
173
|
+
measure
|
|
174
|
+
The performance measure to compute.
|
|
175
|
+
how
|
|
176
|
+
Intra-fold aggregation operator: ``'micro'``, ``'macro'``, or ``'exemplar'``.
|
|
177
|
+
pooling
|
|
178
|
+
``'pooled'`` applies the $\\Upsigma_{\\mathrm{k}}$ reduction over fold cell matrices
|
|
179
|
+
prior to computing the non-linear ratio; ``'averaged'`` computes the measure
|
|
180
|
+
separately in each fold and returns the sample-weighted mean.
|
|
181
|
+
convention
|
|
182
|
+
Convention for undefined entries (``'exclude'``, ``'zero'``, etc.).
|
|
183
|
+
"""
|
|
184
|
+
if not im_list:
|
|
185
|
+
raise ValueError("im_list must not be empty")
|
|
186
|
+
meas = get_measure(measure)
|
|
187
|
+
|
|
188
|
+
if pooling == "averaged":
|
|
189
|
+
vals = [im.compute(meas, how, convention=convention) for im in im_list]
|
|
190
|
+
weights = [im.n for im in im_list]
|
|
191
|
+
return float(np.average(vals, weights=weights))
|
|
192
|
+
|
|
193
|
+
if pooling == "pooled":
|
|
194
|
+
cell_tuples = [im.cells for im in im_list]
|
|
195
|
+
TP, FP, FN, TN = sigma_k(cell_tuples)
|
|
196
|
+
if how == "micro":
|
|
197
|
+
return float(meas(sigma_1(TP), sigma_1(FP), sigma_1(FN), sigma_1(TN)))
|
|
198
|
+
if how in ("macro", "weighted"):
|
|
199
|
+
tp, fp, fn, tn = sigma_m(TP), sigma_m(FP), sigma_m(FN), sigma_m(TN)
|
|
200
|
+
parts = np.array([meas(*c) for c in zip(tp, fp, fn, tn)], dtype=float)
|
|
201
|
+
weights = (tp + fn) if how == "weighted" else None
|
|
202
|
+
return _combine(parts, convention, weights)
|
|
203
|
+
if how == "exemplar":
|
|
204
|
+
tp, fp, fn, tn = sigma_n(TP), sigma_n(FP), sigma_n(FN), sigma_n(TN)
|
|
205
|
+
parts = np.array([meas(*c) for c in zip(tp, fp, fn, tn)], dtype=float)
|
|
206
|
+
return _combine(parts, convention)
|
|
207
|
+
raise ValueError("how must be 'micro', 'macro', 'weighted' or 'exemplar'")
|
|
208
|
+
|
|
209
|
+
raise ValueError("pooling must be 'pooled' or 'averaged'")
|
|
210
|
+
|
aggmetrics/core.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# Copyright (c) 2026, Ronaldo C. Prati
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""
|
|
4
|
+
The :class:`IndicatorMatrix` object: a pair of label matrices plus everything
|
|
5
|
+
derived from them.
|
|
6
|
+
|
|
7
|
+
Construction accepts the shapes practitioners actually have --- class-index
|
|
8
|
+
vectors, binary indicator matrices, or probability matrices --- and converts them
|
|
9
|
+
once. The four cell matrices are computed lazily and cached, so a full report over
|
|
10
|
+
many measures costs one pass over the data.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Iterable, Literal, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .aggregation import Aggregation, aggregate, cells, sigma_1, sigma_m, sigma_n
|
|
19
|
+
from .measures import BinaryMeasure, get_measure, list_measures
|
|
20
|
+
from .tnorms import soft_cells
|
|
21
|
+
|
|
22
|
+
__all__ = ["IndicatorMatrix", "pareto_dominance"]
|
|
23
|
+
|
|
24
|
+
Task = Literal["multiclass", "multilabel", "binary", "ordinal"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _one_hot(idx: Sequence[int], m: Optional[int] = None) -> np.ndarray:
|
|
28
|
+
idx = np.asarray(idx).ravel().astype(int)
|
|
29
|
+
m = int(idx.max()) + 1 if m is None else m
|
|
30
|
+
Z = np.zeros((idx.size, m), dtype=float)
|
|
31
|
+
Z[np.arange(idx.size), idx] = 1.0
|
|
32
|
+
return Z
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cumulative_ordinal(idx: Sequence[int], m: int) -> np.ndarray:
|
|
36
|
+
"""Encode ordinal classes 0..(m-1) into (m-1) cumulative binary thresholds."""
|
|
37
|
+
idx = np.asarray(idx).ravel().astype(int)
|
|
38
|
+
k = max(1, m - 1)
|
|
39
|
+
Z = np.zeros((idx.size, k), dtype=float)
|
|
40
|
+
for j in range(k):
|
|
41
|
+
Z[:, j] = (idx > j).astype(float)
|
|
42
|
+
return Z
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class IndicatorMatrix:
|
|
46
|
+
"""Actual and predicted labels in indicator-matrix form.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
y_true, y_pred
|
|
51
|
+
Either 1-D arrays of class indices (multiclass, binary, or ordinal) or 2-D binary
|
|
52
|
+
matrices of shape ``(n, m)`` (multilabel, or explicit one-hot). ``y_pred``
|
|
53
|
+
may also be a real-valued score or probability matrix, in which case
|
|
54
|
+
``threshold`` or ``argmax`` conversion is applied according to ``task``.
|
|
55
|
+
task
|
|
56
|
+
``'multiclass'`` enforces one prediction per example via argmax;
|
|
57
|
+
``'multilabel'`` thresholds each column independently; ``'binary'`` is
|
|
58
|
+
multilabel with a single column; ``'ordinal'`` encodes $m$ classes into
|
|
59
|
+
$m-1$ cumulative threshold indicators. Inferred when omitted.
|
|
60
|
+
threshold
|
|
61
|
+
Threshold for multilabel conversion of scores. Ignored for multiclass.
|
|
62
|
+
tnorm
|
|
63
|
+
If given, ``y_true`` and ``y_pred`` are treated as soft in ``[0,1]`` and
|
|
64
|
+
combined with this t-norm instead of Boolean conjunction. ``'product'`` is
|
|
65
|
+
the only marginally consistent choice.
|
|
66
|
+
mask
|
|
67
|
+
Optional observation mask; zero entries are treated as unobserved.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, y_true, y_pred, task: Optional[Task] = None,
|
|
71
|
+
*, threshold: float = 0.5, tnorm: Optional[str] = None,
|
|
72
|
+
mask=None, n_classes: Optional[int] = None):
|
|
73
|
+
yt, yp = np.asarray(y_true), np.asarray(y_pred)
|
|
74
|
+
|
|
75
|
+
if task is None:
|
|
76
|
+
if yt.ndim == 1 and len(np.unique(yt)) <= 2 and yp.ndim == 1:
|
|
77
|
+
task = "binary"
|
|
78
|
+
elif yt.ndim == 1:
|
|
79
|
+
task = "multiclass"
|
|
80
|
+
else:
|
|
81
|
+
task = "multilabel"
|
|
82
|
+
self.task: Task = task
|
|
83
|
+
|
|
84
|
+
# -- actual labels -------------------------------------------------
|
|
85
|
+
if task == "ordinal":
|
|
86
|
+
m_classes = n_classes or (int(max(yt.max(), np.asarray(yp).max() if yp.ndim == 1 else 0)) + 1)
|
|
87
|
+
self.m_classes = m_classes
|
|
88
|
+
if yt.ndim == 1:
|
|
89
|
+
self.Y = _cumulative_ordinal(yt, m_classes)
|
|
90
|
+
else:
|
|
91
|
+
self.Y = yt.astype(float)
|
|
92
|
+
n, m = self.Y.shape
|
|
93
|
+
if yp.ndim == 1:
|
|
94
|
+
self.P = _cumulative_ordinal(yp, m_classes)
|
|
95
|
+
elif yp.shape == self.Y.shape:
|
|
96
|
+
self.P = (yp > threshold).astype(float) if not np.isin(yp, (0.0, 1.0)).all() else yp.astype(float)
|
|
97
|
+
elif yp.shape == (n, m_classes):
|
|
98
|
+
self.P = _cumulative_ordinal(yp.argmax(axis=1), m_classes)
|
|
99
|
+
else:
|
|
100
|
+
self.P = yp.astype(float)
|
|
101
|
+
else:
|
|
102
|
+
if yt.ndim == 1:
|
|
103
|
+
m = n_classes or (int(max(yt.max(), np.asarray(yp).max() if yp.ndim == 1
|
|
104
|
+
else 0)) + 1)
|
|
105
|
+
self.Y = _one_hot(yt, m) if task != "binary" else yt.reshape(-1, 1).astype(float)
|
|
106
|
+
else:
|
|
107
|
+
self.Y = yt.astype(float)
|
|
108
|
+
n, m = self.Y.shape
|
|
109
|
+
|
|
110
|
+
# -- predictions ---------------------------------------------------
|
|
111
|
+
if yp.ndim == 1:
|
|
112
|
+
self.P = (_one_hot(yp, m) if task != "binary"
|
|
113
|
+
else yp.reshape(-1, 1).astype(float))
|
|
114
|
+
else:
|
|
115
|
+
P = yp.astype(float)
|
|
116
|
+
is_soft = tnorm is not None or not np.isin(P, (0.0, 1.0)).all()
|
|
117
|
+
if is_soft and tnorm is None:
|
|
118
|
+
if task == "multiclass":
|
|
119
|
+
P = _one_hot(P.argmax(axis=1), m)
|
|
120
|
+
else:
|
|
121
|
+
P = (P > threshold).astype(float)
|
|
122
|
+
self.P = P
|
|
123
|
+
|
|
124
|
+
if self.Y.shape != self.P.shape:
|
|
125
|
+
raise ValueError(f"shape mismatch: Y is {self.Y.shape}, "
|
|
126
|
+
f"predictions are {self.P.shape}")
|
|
127
|
+
|
|
128
|
+
self.tnorm = tnorm
|
|
129
|
+
self.mask = None if mask is None else np.asarray(mask, float)
|
|
130
|
+
self.n, self.m = self.Y.shape
|
|
131
|
+
self._cells = None
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
@property
|
|
135
|
+
def cells(self):
|
|
136
|
+
"""Cached ``(TP, FP, FN, TN)`` cell matrices."""
|
|
137
|
+
if self._cells is None:
|
|
138
|
+
if self.tnorm is not None:
|
|
139
|
+
c = soft_cells(self.Y, self.P, self.tnorm)
|
|
140
|
+
if self.mask is not None:
|
|
141
|
+
c = tuple(x * self.mask for x in c)
|
|
142
|
+
self._cells = c
|
|
143
|
+
else:
|
|
144
|
+
self._cells = cells(self.Y, self.P, mask=self.mask)
|
|
145
|
+
return self._cells
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def counts(self):
|
|
149
|
+
"""Pooled ``(tp, fp, fn, tn)`` scalars."""
|
|
150
|
+
return tuple(sigma_1(X) for X in self.cells)
|
|
151
|
+
|
|
152
|
+
def per_class(self):
|
|
153
|
+
"""Per-class ``(tp, fp, fn, tn)``, one row per class or label."""
|
|
154
|
+
return np.column_stack([sigma_m(X) for X in self.cells])
|
|
155
|
+
|
|
156
|
+
def per_example(self):
|
|
157
|
+
"""Per-example ``(tp, fp, fn, tn)``, one row per example."""
|
|
158
|
+
return np.column_stack([sigma_n(X) for X in self.cells])
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
def compute(self, measure: str | BinaryMeasure,
|
|
162
|
+
aggregation: Aggregation = "micro",
|
|
163
|
+
*, convention: str = "exclude") -> float:
|
|
164
|
+
"""Value of ``measure`` under the chosen aggregation operator."""
|
|
165
|
+
return aggregate(self.Y, self.P if self.tnorm is None else self.P,
|
|
166
|
+
measure, aggregation, convention=convention,
|
|
167
|
+
mask=self.mask) if self.tnorm is None else \
|
|
168
|
+
self._compute_soft(measure, aggregation, convention)
|
|
169
|
+
|
|
170
|
+
def _compute_soft(self, measure, aggregation, convention):
|
|
171
|
+
from .aggregation import _combine
|
|
172
|
+
meas = get_measure(measure)
|
|
173
|
+
TP, FP, FN, TN = self.cells
|
|
174
|
+
if aggregation == "micro":
|
|
175
|
+
return meas(sigma_1(TP), sigma_1(FP), sigma_1(FN), sigma_1(TN))
|
|
176
|
+
red = sigma_m if aggregation in ("macro", "weighted") else sigma_n
|
|
177
|
+
tp, fp, fn, tn = red(TP), red(FP), red(FN), red(TN)
|
|
178
|
+
parts = np.array([meas(*c) for c in zip(tp, fp, fn, tn)], float)
|
|
179
|
+
w = (tp + fn) if aggregation == "weighted" else None
|
|
180
|
+
if w is not None and float(np.sum(w)) <= 0:
|
|
181
|
+
w = None
|
|
182
|
+
return _combine(parts, convention, w)
|
|
183
|
+
|
|
184
|
+
def parts(self, measure: str | BinaryMeasure,
|
|
185
|
+
aggregation: Aggregation = "macro") -> np.ndarray:
|
|
186
|
+
"""Per-class or per-example values, before averaging."""
|
|
187
|
+
_, p = aggregate(self.Y, self.P, measure, aggregation,
|
|
188
|
+
mask=self.mask, return_parts=True)
|
|
189
|
+
if p is None:
|
|
190
|
+
raise ValueError("micro-averaging produces a single value, not parts")
|
|
191
|
+
return p
|
|
192
|
+
|
|
193
|
+
def accuracy(self) -> float:
|
|
194
|
+
"""Standard accuracy.
|
|
195
|
+
|
|
196
|
+
For one-hot multiclass this is ``sigma_1(TP) / n``, which is *not* the
|
|
197
|
+
same as ``compute('label_accuracy')``: the latter is ``(tp+tn)/(nm)`` and
|
|
198
|
+
has a floor of ``(m-2)/m`` under one-hot encoding. Keeping them separate
|
|
199
|
+
avoids a conflation that is easy to make and hard to notice.
|
|
200
|
+
"""
|
|
201
|
+
if self.task == "multiclass":
|
|
202
|
+
return float(sigma_1(self.cells[0]) / self.n)
|
|
203
|
+
return self.compute("label_accuracy", "micro")
|
|
204
|
+
|
|
205
|
+
def subset_accuracy(self) -> float:
|
|
206
|
+
"""Proportion of examples whose predicted label set matches exactly."""
|
|
207
|
+
return float(np.all(np.isclose(self.Y, self.P), axis=1).mean())
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
def report(self, measures: Optional[Iterable[str]] = None,
|
|
211
|
+
aggregations: Sequence[Aggregation] = ("micro", "macro", "exemplar"),
|
|
212
|
+
*, convention: str = "exclude", as_frame: bool = True):
|
|
213
|
+
"""Table of measures against aggregation operators.
|
|
214
|
+
|
|
215
|
+
Returns a ``pandas.DataFrame`` when pandas is available and
|
|
216
|
+
``as_frame`` is True, otherwise a dict of dicts.
|
|
217
|
+
"""
|
|
218
|
+
names = list(measures) if measures is not None else [
|
|
219
|
+
"precision", "recall", "f1", "jaccard", "balanced_accuracy",
|
|
220
|
+
"gmean", "mcc", "label_accuracy"]
|
|
221
|
+
table = {n: {a: self.compute(n, a, convention=convention)
|
|
222
|
+
for a in aggregations} for n in names}
|
|
223
|
+
if as_frame:
|
|
224
|
+
try:
|
|
225
|
+
import pandas as pd
|
|
226
|
+
return pd.DataFrame(table).T[list(aggregations)]
|
|
227
|
+
except ImportError:
|
|
228
|
+
pass
|
|
229
|
+
return table
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def degrees_of_freedom(self) -> int:
|
|
233
|
+
"""Intrinsic independent degrees of freedom of the confusion matrix.
|
|
234
|
+
|
|
235
|
+
For binary classification ($m=2$), this is $2(1) = 2$, spanned by the
|
|
236
|
+
canonical orthogonal basis $(\\text{recall}, \\text{specificity})$.
|
|
237
|
+
For $m$-class multiclass classification, this is $m(m-1)$, spanned by the
|
|
238
|
+
directional off-diagonal conditional misclassification rates $R_{jk}$ ($k \\neq j$).
|
|
239
|
+
For multilabel classification with $m$ labels, each label has 2 degrees of freedom ($2m$).
|
|
240
|
+
"""
|
|
241
|
+
if self.task == "binary" or self.m == 1:
|
|
242
|
+
return 2
|
|
243
|
+
if self.task == "multilabel":
|
|
244
|
+
return 2 * self.m
|
|
245
|
+
return self.m * (self.m - 1)
|
|
246
|
+
|
|
247
|
+
def canonical_coordinates(self) -> np.ndarray:
|
|
248
|
+
"""Extract the canonical orthogonal coordinates (conditional misclassification rates).
|
|
249
|
+
|
|
250
|
+
For multiclass problems, returns the $m(m-1)$ off-diagonal conditional error
|
|
251
|
+
rates $R_{jk} = P(\\hat{Y}=k \\mid Y=j)$ for $k \\neq j$, which form an orthogonal
|
|
252
|
+
basis in Frobenius inner product.
|
|
253
|
+
For binary problems, returns $(\\text{recall}, \\text{specificity})$.
|
|
254
|
+
"""
|
|
255
|
+
if self.task == "binary" or self.m == 1:
|
|
256
|
+
rec = self.compute("recall", "micro")
|
|
257
|
+
spec = self.compute("specificity", "micro")
|
|
258
|
+
return np.array([rec, spec], float)
|
|
259
|
+
C = (self.Y.T @ self.P).astype(float)
|
|
260
|
+
row_sums = C.sum(axis=1, keepdims=True)
|
|
261
|
+
row_sums[row_sums == 0] = 1.0
|
|
262
|
+
R = C / row_sums
|
|
263
|
+
coords = []
|
|
264
|
+
for j in range(self.m):
|
|
265
|
+
for k in range(self.m):
|
|
266
|
+
if j != k:
|
|
267
|
+
coords.append(float(R[j, k]))
|
|
268
|
+
return np.array(coords, float)
|
|
269
|
+
|
|
270
|
+
def cost(self, C, aggregation: Aggregation = "micro") -> float:
|
|
271
|
+
"""Total or averaged cost under an ``m x m`` cost matrix.
|
|
272
|
+
|
|
273
|
+
``Cost = sigma_1((Y C) o Yhat)``. With ``C[j,k] = |j-k|`` this is
|
|
274
|
+
``n`` times the mean absolute error, and with ``C[j,k] = (j-k)^2``
|
|
275
|
+
it is ``n`` times the mean squared error.
|
|
276
|
+
"""
|
|
277
|
+
C = np.asarray(C, float)
|
|
278
|
+
if C.shape != (self.m, self.m):
|
|
279
|
+
raise ValueError(f"cost matrix must be {(self.m, self.m)}, got {C.shape}")
|
|
280
|
+
W = (self.Y @ C) * self.P
|
|
281
|
+
if self.mask is not None:
|
|
282
|
+
W = W * self.mask
|
|
283
|
+
if aggregation == "micro":
|
|
284
|
+
return float(np.sum(W))
|
|
285
|
+
if aggregation == "macro":
|
|
286
|
+
return float(np.mean(W.sum(axis=0)))
|
|
287
|
+
if aggregation == "exemplar":
|
|
288
|
+
return float(np.mean(W.sum(axis=1)))
|
|
289
|
+
raise ValueError("aggregation must be 'micro', 'macro' or 'exemplar'")
|
|
290
|
+
|
|
291
|
+
def dominates(self, other: IndicatorMatrix, strict: bool = False) -> bool:
|
|
292
|
+
"""Check whether this classifier dominates another across all monotonic measures.
|
|
293
|
+
|
|
294
|
+
By Proposition 3 of the companion paper, a classifier A dominates B across all
|
|
295
|
+
measures respecting confusion-matrix monotonicity if and only if A dominates B
|
|
296
|
+
elementwise in the canonical coordinate space:
|
|
297
|
+
- In binary: TP_A >= TP_B, FP_A <= FP_B, FN_A <= FN_B, TN_A >= TN_B
|
|
298
|
+
- In multiclass: R_{jk}^{(A)} <= R_{jk}^{(B)} for all off-diagonal errors (k != j)
|
|
299
|
+
and R_{jj}^{(A)} >= R_{jj}^{(B)} for all on-diagonal recalls.
|
|
300
|
+
"""
|
|
301
|
+
if self.task != other.task or self.m != other.m or self.n != other.n:
|
|
302
|
+
raise ValueError("Both IndicatorMatrix instances must have the same task, m, and n.")
|
|
303
|
+
if self.task == "binary" or self.m == 1:
|
|
304
|
+
tp_a, fp_a, fn_a, tn_a = self.counts
|
|
305
|
+
tp_b, fp_b, fn_b, tn_b = other.counts
|
|
306
|
+
ge = (tp_a >= tp_b) and (fp_a <= fp_b) and (fn_a <= fn_b) and (tn_a >= tn_b)
|
|
307
|
+
strict_ge = (tp_a > tp_b) or (fp_a < fp_b) or (fn_a < fn_b) or (tn_a > tn_b)
|
|
308
|
+
return bool((ge and strict_ge) if strict else ge)
|
|
309
|
+
else:
|
|
310
|
+
C_a = (self.Y.T @ self.P).astype(float)
|
|
311
|
+
C_b = (other.Y.T @ other.P).astype(float)
|
|
312
|
+
row_a = C_a.sum(axis=1, keepdims=True); row_a[row_a == 0] = 1.0
|
|
313
|
+
row_b = C_b.sum(axis=1, keepdims=True); row_b[row_b == 0] = 1.0
|
|
314
|
+
R_a = C_a / row_a
|
|
315
|
+
R_b = C_b / row_b
|
|
316
|
+
diag_ge = np.all(np.diag(R_a) >= np.diag(R_b) - 1e-12)
|
|
317
|
+
off_mask = ~np.eye(self.m, dtype=bool)
|
|
318
|
+
off_le = np.all(R_a[off_mask] <= R_b[off_mask] + 1e-12)
|
|
319
|
+
ge = bool(diag_ge and off_le)
|
|
320
|
+
if not strict:
|
|
321
|
+
return ge
|
|
322
|
+
strict_cond = (np.any(np.diag(R_a) > np.diag(R_b) + 1e-12) or
|
|
323
|
+
np.any(R_a[off_mask] < R_b[off_mask] - 1e-12))
|
|
324
|
+
return bool(ge and strict_cond)
|
|
325
|
+
|
|
326
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
327
|
+
tp, fp, fn, tn = self.counts
|
|
328
|
+
soft = f", tnorm={self.tnorm!r}" if self.tnorm else ""
|
|
329
|
+
return (f"IndicatorMatrix(task={self.task!r}, n={self.n}, m={self.m}{soft}, "
|
|
330
|
+
f"tp={tp:g}, fp={fp:g}, fn={fn:g}, tn={tn:g})")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def pareto_dominance(im_a: IndicatorMatrix, im_b: IndicatorMatrix) -> str:
|
|
334
|
+
"""Determine Pareto dominance between two classifiers.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
- ``'A_dominates_B'`` if A strictly dominates B across all monotonic measures;
|
|
338
|
+
- ``'B_dominates_A'`` if B strictly dominates A;
|
|
339
|
+
- ``'equivalent'`` if both confusion profiles are identical;
|
|
340
|
+
- ``'incomparable'`` if neither dominates the other (metric ranking depends on the choice of measure).
|
|
341
|
+
"""
|
|
342
|
+
a_dom_b = im_a.dominates(im_b, strict=True)
|
|
343
|
+
b_dom_a = im_b.dominates(im_a, strict=True)
|
|
344
|
+
if a_dom_b and not b_dom_a:
|
|
345
|
+
return "A_dominates_B"
|
|
346
|
+
if b_dom_a and not a_dom_b:
|
|
347
|
+
return "B_dominates_A"
|
|
348
|
+
if im_a.dominates(im_b) and im_b.dominates(im_a):
|
|
349
|
+
return "equivalent"
|
|
350
|
+
return "incomparable"
|
|
351
|
+
|