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 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
+