aggmetrics 0.1.0__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"
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,215 @@
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'`` concatenates the folds' predictions before computing the
179
+ non-linear ratio, so folds may differ in size; ``'averaged'`` computes the
180
+ measure 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
+ # Concatenate the folds' cell matrices (one row per test example), so the
195
+ # folds may differ in size. Every example sits in exactly one test fold, hence
196
+ # this is the measure on the pooled predictions, as in the companion paper.
197
+ cell_tuples = [im.cells for im in im_list]
198
+ if len({c[0].shape[1] for c in cell_tuples}) != 1:
199
+ raise ValueError("all folds must have the same number of classes or labels")
200
+ TP, FP, FN, TN = (np.vstack([c[i] for c in cell_tuples]) for i in range(4))
201
+ if how == "micro":
202
+ return float(meas(sigma_1(TP), sigma_1(FP), sigma_1(FN), sigma_1(TN)))
203
+ if how in ("macro", "weighted"):
204
+ tp, fp, fn, tn = sigma_m(TP), sigma_m(FP), sigma_m(FN), sigma_m(TN)
205
+ parts = np.array([meas(*c) for c in zip(tp, fp, fn, tn)], dtype=float)
206
+ weights = (tp + fn) if how == "weighted" else None
207
+ return _combine(parts, convention, weights)
208
+ if how == "exemplar":
209
+ tp, fp, fn, tn = sigma_n(TP), sigma_n(FP), sigma_n(FN), sigma_n(TN)
210
+ parts = np.array([meas(*c) for c in zip(tp, fp, fn, tn)], dtype=float)
211
+ return _combine(parts, convention)
212
+ raise ValueError("how must be 'micro', 'macro', 'weighted' or 'exemplar'")
213
+
214
+ raise ValueError("pooling must be 'pooled' or 'averaged'")
215
+
aggmetrics/core.py ADDED
@@ -0,0 +1,354 @@
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
+ # a score matrix fixes the number of classes even when some of
104
+ # them never occur in y_true
105
+ m = n_classes or (int(max(yt.max(), np.asarray(yp).max() if yp.ndim == 1
106
+ else yp.shape[1] - 1)) + 1)
107
+ self.Y = _one_hot(yt, m) if task != "binary" else yt.reshape(-1, 1).astype(float)
108
+ else:
109
+ self.Y = yt.astype(float)
110
+ n, m = self.Y.shape
111
+
112
+ # -- predictions ---------------------------------------------------
113
+ if yp.ndim == 1:
114
+ self.P = (_one_hot(yp, m) if task != "binary"
115
+ else yp.reshape(-1, 1).astype(float))
116
+ else:
117
+ P = yp.astype(float)
118
+ is_soft = tnorm is not None or not np.isin(P, (0.0, 1.0)).all()
119
+ if is_soft and tnorm is None:
120
+ if task == "multiclass":
121
+ P = _one_hot(P.argmax(axis=1), m)
122
+ else:
123
+ P = (P > threshold).astype(float)
124
+ self.P = P
125
+
126
+ if self.Y.shape != self.P.shape:
127
+ raise ValueError(f"shape mismatch: Y is {self.Y.shape}, "
128
+ f"predictions are {self.P.shape}")
129
+
130
+ self.tnorm = tnorm
131
+ self.mask = None if mask is None else np.asarray(mask, float)
132
+ self.n, self.m = self.Y.shape
133
+ self._cells = None
134
+
135
+ # ------------------------------------------------------------------
136
+ @property
137
+ def cells(self):
138
+ """Cached ``(TP, FP, FN, TN)`` cell matrices."""
139
+ if self._cells is None:
140
+ if self.tnorm is not None:
141
+ c = soft_cells(self.Y, self.P, self.tnorm)
142
+ if self.mask is not None:
143
+ c = tuple(x * self.mask for x in c)
144
+ self._cells = c
145
+ else:
146
+ self._cells = cells(self.Y, self.P, mask=self.mask)
147
+ return self._cells
148
+
149
+ @property
150
+ def counts(self):
151
+ """Pooled ``(tp, fp, fn, tn)`` scalars."""
152
+ return tuple(sigma_1(X) for X in self.cells)
153
+
154
+ def per_class(self):
155
+ """Per-class ``(tp, fp, fn, tn)``, one row per class or label."""
156
+ return np.column_stack([sigma_m(X) for X in self.cells])
157
+
158
+ def per_example(self):
159
+ """Per-example ``(tp, fp, fn, tn)``, one row per example."""
160
+ return np.column_stack([sigma_n(X) for X in self.cells])
161
+
162
+ # ------------------------------------------------------------------
163
+ def compute(self, measure: str | BinaryMeasure,
164
+ aggregation: Aggregation = "micro",
165
+ *, convention: str = "exclude") -> float:
166
+ """Value of ``measure`` under the chosen aggregation operator."""
167
+ return aggregate(self.Y, self.P if self.tnorm is None else self.P,
168
+ measure, aggregation, convention=convention,
169
+ mask=self.mask) if self.tnorm is None else \
170
+ self._compute_soft(measure, aggregation, convention)
171
+
172
+ def _compute_soft(self, measure, aggregation, convention):
173
+ from .aggregation import _combine
174
+ meas = get_measure(measure)
175
+ TP, FP, FN, TN = self.cells
176
+ if aggregation == "micro":
177
+ return meas(sigma_1(TP), sigma_1(FP), sigma_1(FN), sigma_1(TN))
178
+ red = sigma_m if aggregation in ("macro", "weighted") else sigma_n
179
+ tp, fp, fn, tn = red(TP), red(FP), red(FN), red(TN)
180
+ parts = np.array([meas(*c) for c in zip(tp, fp, fn, tn)], float)
181
+ w = (tp + fn) if aggregation == "weighted" else None
182
+ if w is not None and float(np.sum(w)) <= 0:
183
+ w = None
184
+ return _combine(parts, convention, w)
185
+
186
+ def parts(self, measure: str | BinaryMeasure,
187
+ aggregation: Aggregation = "macro") -> np.ndarray:
188
+ """Per-class or per-example values, before averaging."""
189
+ _, p = aggregate(self.Y, self.P, measure, aggregation,
190
+ mask=self.mask, return_parts=True)
191
+ if p is None:
192
+ raise ValueError("micro-averaging produces a single value, not parts")
193
+ return p
194
+
195
+ def accuracy(self) -> float:
196
+ """Standard accuracy.
197
+
198
+ For one-hot multiclass this is ``sigma_1(TP) / n``, which is *not* the
199
+ same as ``compute('label_accuracy')``: the latter is ``(tp+tn)/(nm)`` and
200
+ has a floor of ``(m-2)/m`` under one-hot encoding. Keeping them separate
201
+ avoids a conflation that is easy to make and hard to notice.
202
+ """
203
+ if self.task == "multiclass":
204
+ return float(sigma_1(self.cells[0]) / self.n)
205
+ return self.compute("label_accuracy", "micro")
206
+
207
+ def subset_accuracy(self) -> float:
208
+ """Proportion of examples whose predicted label set matches exactly."""
209
+ return float(np.all(np.isclose(self.Y, self.P), axis=1).mean())
210
+
211
+ # ------------------------------------------------------------------
212
+ def report(self, measures: Optional[Iterable[str]] = None,
213
+ aggregations: Sequence[Aggregation] = ("micro", "macro", "exemplar"),
214
+ *, convention: str = "exclude", as_frame: bool = True):
215
+ """Table of measures against aggregation operators.
216
+
217
+ Returns a ``pandas.DataFrame`` when pandas is available and
218
+ ``as_frame`` is True, otherwise a dict of dicts.
219
+ """
220
+ names = list(measures) if measures is not None else [
221
+ "precision", "recall", "f1", "jaccard", "balanced_accuracy",
222
+ "gmean", "mcc", "label_accuracy"]
223
+ table = {n: {a: self.compute(n, a, convention=convention)
224
+ for a in aggregations} for n in names}
225
+ if as_frame:
226
+ try:
227
+ import pandas as pd
228
+ return pd.DataFrame(table).T[list(aggregations)]
229
+ except ImportError:
230
+ pass
231
+ return table
232
+
233
+ @property
234
+ def degrees_of_freedom(self) -> int:
235
+ """Intrinsic independent degrees of freedom of the confusion matrix.
236
+
237
+ For binary classification ($m=2$), this is $2(1) = 2$, spanned by the
238
+ canonical orthogonal basis $(\\text{recall}, \\text{specificity})$.
239
+ For $m$-class multiclass classification, this is $m(m-1)$, spanned by the
240
+ directional off-diagonal conditional misclassification rates $R_{jk}$ ($k \\neq j$).
241
+ For multilabel classification with $m$ labels, each label has 2 degrees of freedom ($2m$).
242
+ """
243
+ if self.task == "binary" or self.m == 1:
244
+ return 2
245
+ if self.task == "multilabel":
246
+ return 2 * self.m
247
+ return self.m * (self.m - 1)
248
+
249
+ def canonical_coordinates(self) -> np.ndarray:
250
+ """Extract the canonical orthogonal coordinates (conditional misclassification rates).
251
+
252
+ For multiclass problems, returns the $m(m-1)$ off-diagonal conditional error
253
+ rates $R_{jk} = P(\\hat{Y}=k \\mid Y=j)$ for $k \\neq j$, which form an orthogonal
254
+ basis in Frobenius inner product.
255
+ For binary problems, returns $(\\text{recall}, \\text{specificity})$.
256
+ """
257
+ if self.task == "binary" or self.m == 1:
258
+ rec = self.compute("recall", "micro")
259
+ spec = self.compute("specificity", "micro")
260
+ return np.array([rec, spec], float)
261
+ C = (self.Y.T @ self.P).astype(float)
262
+ row_sums = C.sum(axis=1, keepdims=True)
263
+ row_sums[row_sums == 0] = 1.0
264
+ R = C / row_sums
265
+ coords = []
266
+ for j in range(self.m):
267
+ for k in range(self.m):
268
+ if j != k:
269
+ coords.append(float(R[j, k]))
270
+ return np.array(coords, float)
271
+
272
+ def cost(self, C, aggregation: Aggregation = "micro") -> float:
273
+ """Total or averaged cost under an ``m x m`` cost matrix.
274
+
275
+ ``Cost = sigma_1((Y C) o Yhat)``. With ``C[j,k] = |j-k|`` this is
276
+ ``n`` times the mean absolute error, and with ``C[j,k] = (j-k)^2``
277
+ it is ``n`` times the mean squared error.
278
+ """
279
+ C = np.asarray(C, float)
280
+ if C.shape != (self.m, self.m):
281
+ raise ValueError(f"cost matrix must be {(self.m, self.m)}, got {C.shape}")
282
+ W = (self.Y @ C) * self.P
283
+ if self.mask is not None:
284
+ W = W * self.mask
285
+ if aggregation == "micro":
286
+ return float(np.sum(W))
287
+ if aggregation == "macro":
288
+ return float(np.mean(W.sum(axis=0)))
289
+ if aggregation == "exemplar":
290
+ return float(np.mean(W.sum(axis=1)))
291
+ raise ValueError("aggregation must be 'micro', 'macro' or 'exemplar'")
292
+
293
+ def dominates(self, other: IndicatorMatrix, strict: bool = False) -> bool:
294
+ """Check whether this classifier dominates another across all monotonic measures.
295
+
296
+ By Section 7.11 of the companion paper (which rests on Proposition 3), a classifier A dominates B across all
297
+ measures respecting confusion-matrix monotonicity if and only if A dominates B
298
+ elementwise in the canonical coordinate space:
299
+
300
+ - In binary: TP_A >= TP_B, FP_A <= FP_B, FN_A <= FN_B, TN_A >= TN_B
301
+ - In multiclass: R_{jk}^{(A)} <= R_{jk}^{(B)} for all off-diagonal errors (k != j)
302
+ and R_{jj}^{(A)} >= R_{jj}^{(B)} for all on-diagonal recalls.
303
+ """
304
+ if self.task != other.task or self.m != other.m or self.n != other.n:
305
+ raise ValueError("Both IndicatorMatrix instances must have the same task, m, and n.")
306
+ if self.task == "binary" or self.m == 1:
307
+ tp_a, fp_a, fn_a, tn_a = self.counts
308
+ tp_b, fp_b, fn_b, tn_b = other.counts
309
+ ge = (tp_a >= tp_b) and (fp_a <= fp_b) and (fn_a <= fn_b) and (tn_a >= tn_b)
310
+ strict_ge = (tp_a > tp_b) or (fp_a < fp_b) or (fn_a < fn_b) or (tn_a > tn_b)
311
+ return bool((ge and strict_ge) if strict else ge)
312
+ else:
313
+ C_a = (self.Y.T @ self.P).astype(float)
314
+ C_b = (other.Y.T @ other.P).astype(float)
315
+ row_a = C_a.sum(axis=1, keepdims=True); row_a[row_a == 0] = 1.0
316
+ row_b = C_b.sum(axis=1, keepdims=True); row_b[row_b == 0] = 1.0
317
+ R_a = C_a / row_a
318
+ R_b = C_b / row_b
319
+ diag_ge = np.all(np.diag(R_a) >= np.diag(R_b) - 1e-12)
320
+ off_mask = ~np.eye(self.m, dtype=bool)
321
+ off_le = np.all(R_a[off_mask] <= R_b[off_mask] + 1e-12)
322
+ ge = bool(diag_ge and off_le)
323
+ if not strict:
324
+ return ge
325
+ strict_cond = (np.any(np.diag(R_a) > np.diag(R_b) + 1e-12) or
326
+ np.any(R_a[off_mask] < R_b[off_mask] - 1e-12))
327
+ return bool(ge and strict_cond)
328
+
329
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
330
+ tp, fp, fn, tn = self.counts
331
+ soft = f", tnorm={self.tnorm!r}" if self.tnorm else ""
332
+ return (f"IndicatorMatrix(task={self.task!r}, n={self.n}, m={self.m}{soft}, "
333
+ f"tp={tp:g}, fp={fp:g}, fn={fn:g}, tn={tn:g})")
334
+
335
+
336
+ def pareto_dominance(im_a: IndicatorMatrix, im_b: IndicatorMatrix) -> str:
337
+ """Determine Pareto dominance between two classifiers.
338
+
339
+ Returns:
340
+ - ``'A_dominates_B'`` if A strictly dominates B across all monotonic measures;
341
+ - ``'B_dominates_A'`` if B strictly dominates A;
342
+ - ``'equivalent'`` if both confusion profiles are identical;
343
+ - ``'incomparable'`` if neither dominates the other (metric ranking depends on the choice of measure).
344
+ """
345
+ a_dom_b = im_a.dominates(im_b, strict=True)
346
+ b_dom_a = im_b.dominates(im_a, strict=True)
347
+ if a_dom_b and not b_dom_a:
348
+ return "A_dominates_B"
349
+ if b_dom_a and not a_dom_b:
350
+ return "B_dominates_A"
351
+ if im_a.dominates(im_b) and im_b.dominates(im_a):
352
+ return "equivalent"
353
+ return "incomparable"
354
+