aggmetrics 0.1.0.dev0__tar.gz

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.
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Ronaldo C. Prati
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its contributors
17
+ may be used to endorse or promote products derived from this software without
18
+ specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: aggmetrics
3
+ Version: 0.1.0.dev0
4
+ Summary: Aggregating classification performance measures for machine learning: indicator matrices and aggregation operators
5
+ Author-email: "Ronaldo C. Prati" <ronaldo.prati@ufabc.edu.br>
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/rcprati/aggmetrics
8
+ Project-URL: Repository, https://github.com/rcprati/aggmetrics
9
+ Project-URL: Documentation, https://aggmetrics.readthedocs.io
10
+ Project-URL: Issues, https://github.com/rcprati/aggmetrics/issues
11
+ Keywords: machine learning,classification,evaluation,performance measures,metrics,multilabel,multiclass,class imbalance,confusion matrix
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: BSD License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy>=1.20
21
+ Provides-Extra: sklearn
22
+ Requires-Dist: scikit-learn>=1.0; extra == "sklearn"
23
+ Provides-Extra: frame
24
+ Requires-Dist: pandas>=1.3; extra == "frame"
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=7.0; extra == "test"
27
+ Requires-Dist: scikit-learn>=1.0; extra == "test"
28
+ Requires-Dist: pandas>=1.3; extra == "test"
29
+ Provides-Extra: docs
30
+ Requires-Dist: sphinx>=5.0; extra == "docs"
31
+ Requires-Dist: furo; extra == "docs"
32
+ Provides-Extra: all
33
+ Requires-Dist: scikit-learn>=1.0; extra == "all"
34
+ Requires-Dist: pandas>=1.3; extra == "all"
35
+ Dynamic: license-file
36
+
37
+ # aggmetrics
38
+
39
+ **Aggregating classification performance measures for machine learning: indicator matrices and aggregation operators.**
40
+
41
+ Any binary performance measure expressible in terms of the four confusion-matrix
42
+ counts is extended to multiclass, multilabel, ordinal, cost-sensitive and
43
+ soft-label settings by substituting one of a small set of aggregation operators.
44
+ Micro-, macro- and exemplar-averaging (and pooling across cross-validation folds)
45
+ become instances of a single construction rather than separate conventions.
46
+
47
+ The package also reports when a measure is being used outside the range where it
48
+ behaves as commonly assumed — which is the part that is hard to get from a
49
+ formula library.
50
+
51
+ ```python
52
+ import numpy as np
53
+ from aggmetrics import IndicatorMatrix, diagnose
54
+
55
+ y_true = np.array([0, 1, 2, 2, 1, 0])
56
+ y_pred = np.array([0, 2, 2, 1, 1, 1])
57
+ im = IndicatorMatrix(y_true, y_pred, task='multiclass')
58
+
59
+ im.compute('f1', 'macro') # 0.5222
60
+ im.compute('f1', 'micro') # 0.5 -- equals accuracy on single-label data
61
+ im.compute('mcc', 'macro') # macro-MCC: no standard implementation exists
62
+ im.compute('informedness', 'micro')
63
+ im.accuracy() # 0.5
64
+
65
+ im.report() # DataFrame: measures x operators
66
+ ```
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ pip install aggmetrics # core, requires only numpy
72
+ pip install "aggmetrics[all]" # adds scikit-learn and pandas support
73
+ ```
74
+
75
+ ## Why not just use `sklearn.metrics`?
76
+
77
+ `sklearn` exposes four `average` values for a fixed list of measures. The
78
+ correspondence is exact — `'micro'`, `'macro'`, `'weighted'` and `'samples'` are
79
+ Σ₁, Σₘ with uniform weights, Σₘ with prevalence weights and Σₙ respectively — but
80
+ the combinations available are a subset of those the construction generates, and
81
+ nothing in the library tells you when a combination is misleading.
82
+
83
+ Three things this package adds:
84
+
85
+ **Any measure under any operator.** Macro-MCC, exemplar-Jaccard, micro-informedness,
86
+ weighted G-mean. 21 binary measures × 4 operators, without special-casing.
87
+
88
+ **Structural properties as first-class data.** Each measure records whether it is
89
+ *aggregation-decomposable*, whether it is skew-invariant, and whether its value at
90
+ `tp = 0` depends on `tn`. These determine which identities apply.
91
+
92
+ ```python
93
+ from aggmetrics import get_measure, list_measures
94
+
95
+ list_measures(decomposable=True) # micro == denominator-weighted macro
96
+ get_measure('mcc').decomposable # False
97
+ get_measure('recall').bayes_threshold() # 0.0 -- maximised by predicting all positive
98
+ get_measure('mcc').attainable_floor(10) # -0.111, not -1
99
+ ```
100
+
101
+ **The generative claim, as an operation.** Supply a binary formula; the properties
102
+ that decide which identities apply are *derived*, not declared:
103
+
104
+ ```python
105
+ from aggmetrics import register_measure, infer_properties
106
+
107
+ def prevalence_threshold(tp, fp, fn, tn):
108
+ r, s = tp / (tp + fn), tn / (tn + fp)
109
+ return (np.sqrt(r * (1 - s)) + s - 1) / (r + s - 1)
110
+
111
+ m = register_measure('prevalence_threshold', prevalence_threshold)
112
+ m.decomposable # False -- inferred by fitting <a,v>/<b,v> and validating
113
+ m.skew_invariant # True -- a function of recall and specificity
114
+ m.bayes_threshold() # None -- no closed form outside the decomposable class
115
+
116
+ im.compute('prevalence_threshold', 'exemplar') # usable immediately
117
+ ```
118
+
119
+ Inference is checked against the 21 measures whose properties are known
120
+ analytically — `verify_inference()` — and all 21 agree, with recovered coefficients
121
+ accurate to 4.2e-15. The check paid for itself: it found two errors in our own hand
122
+ annotations (error rate and FPR both retain a `tn` dependence at `tp = 0`, so both
123
+ *flatter* a wholly wrong classifier, reaching 2/m and 1/(m-1) respectively).
124
+
125
+ **Diagnostics.** `diagnose()` flags the traps:
126
+
127
+ ```python
128
+ y = np.arange(500) % 10
129
+ yhat = (y + 1) % 10 # every single prediction is wrong
130
+ im = IndicatorMatrix(y, yhat, task='multiclass')
131
+
132
+ im.compute('label_accuracy', 'micro') # 0.800 (!)
133
+ im.compute('mcc', 'micro') # -0.111 (!)
134
+ im.accuracy() # 0.000
135
+
136
+ for f in diagnose(im):
137
+ print(f)
138
+ # [warning] inflated_floor: 'label_accuracy' cannot fall below 0.8000 on this
139
+ # 10-class one-hot problem even if every prediction is wrong ...
140
+ # [note] micro_collapse: micro-precision, micro-recall and micro-F1 are all
141
+ # equal to accuracy ...
142
+ # [warning] degenerate_optimum: 'recall' is maximised at threshold 0.000 ...
143
+ ```
144
+
145
+ Checks currently implemented: pooled value outside the per-class range;
146
+ `tn`-dependent measures on one-hot multiclass data; micro-collapse on single-label
147
+ data; undefined per-class entries and the size of the convention effect; measures
148
+ whose optimal decision rule is not the Bayes rule for 0/1 loss.
149
+
150
+ ## Features
151
+
152
+ - **21 binary measures**, resolvable by name or alias (`iou` → `jaccard`, `dice` → `f1`),
153
+ plus `register_measure` for your own, with properties inferred.
154
+ - **Four operators**: `micro`, `macro`, `weighted`, `exemplar`.
155
+ - **Explicit undefined-value conventions**: `exclude` (default), `zero`
156
+ (`sklearn`'s behaviour), `one`, `nan`. The choice is a parameter, not a default
157
+ buried in the implementation.
158
+ - **Soft ground truth** via t-norms, with `marginal_defect()` showing that only the
159
+ product t-norm preserves the marginals.
160
+ - **Cost matrices**, recovering MAE and MSE as special cases.
161
+ - **Observation masks** for partial labels, semi-supervised evaluation and abstention.
162
+ - **scikit-learn scorers** via `make_scorer`, usable in `cross_val_score` and
163
+ `GridSearchCV`.
164
+ - **AI Agent Skills included** (`.agents/skills/aggmetrics-guide/`), providing machine-readable
165
+ runbooks, axiomatic invariants, and diagnostic protocols for AI coding assistants.
166
+
167
+ ## The test suite verifies the theory
168
+
169
+ Each result of the companion paper is an executable test, so a change that breaks
170
+ a theorem breaks a test:
171
+
172
+ ```bash
173
+ python -m pytest tests/test_theory.py -v
174
+ ```
175
+
176
+ | Test | Result verified |
177
+ |---|---|
178
+ | `test_theorem1_holds_for_decomposable` | micro = denominator-weighted macro, 15 measures × 3 samples |
179
+ | `test_theorem1_fails_for_non_decomposable` | the identity has no extension beyond that class |
180
+ | `test_theorem2_product_is_uniquely_marginally_consistent` | only the product t-norm preserves marginals |
181
+ | `test_theorem5_micro_collapse` | micro-P = micro-R = micro-F₁ = accuracy, single-label |
182
+ | `test_proposition5_thresholds` | Bayes threshold `(β+δ)/(α+β+γ+δ)` |
183
+ | `test_proposition7_floors` | attainable floors under one-hot encoding |
184
+ | `test_proposition2_recall_prior_free_specificity_not` | one-vs-rest skew behaviour |
185
+ | `test_corollary5_gap_is_normalised_covariance` | the micro–macro gap identity |
186
+ | `test_theorem3_mae_and_mse_as_cost_matrices` | ordinal losses as cost matrices |
187
+ | `test_inference_reproduces_every_annotation` | inference recovers all 21 analytic annotations |
188
+
189
+ 138 tests, no skips.
190
+
191
+ ## Examples
192
+
193
+ ```bash
194
+ python examples/quickstart.py
195
+ ```
196
+
197
+ ## Citing
198
+
199
+ Please cite both the software and the companion paper; see `CITATION.cff`.
200
+
201
+ ## License
202
+
203
+ BSD 3-Clause.
@@ -0,0 +1,167 @@
1
+ # aggmetrics
2
+
3
+ **Aggregating classification performance measures for machine learning: indicator matrices and aggregation operators.**
4
+
5
+ Any binary performance measure expressible in terms of the four confusion-matrix
6
+ counts is extended to multiclass, multilabel, ordinal, cost-sensitive and
7
+ soft-label settings by substituting one of a small set of aggregation operators.
8
+ Micro-, macro- and exemplar-averaging (and pooling across cross-validation folds)
9
+ become instances of a single construction rather than separate conventions.
10
+
11
+ The package also reports when a measure is being used outside the range where it
12
+ behaves as commonly assumed — which is the part that is hard to get from a
13
+ formula library.
14
+
15
+ ```python
16
+ import numpy as np
17
+ from aggmetrics import IndicatorMatrix, diagnose
18
+
19
+ y_true = np.array([0, 1, 2, 2, 1, 0])
20
+ y_pred = np.array([0, 2, 2, 1, 1, 1])
21
+ im = IndicatorMatrix(y_true, y_pred, task='multiclass')
22
+
23
+ im.compute('f1', 'macro') # 0.5222
24
+ im.compute('f1', 'micro') # 0.5 -- equals accuracy on single-label data
25
+ im.compute('mcc', 'macro') # macro-MCC: no standard implementation exists
26
+ im.compute('informedness', 'micro')
27
+ im.accuracy() # 0.5
28
+
29
+ im.report() # DataFrame: measures x operators
30
+ ```
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install aggmetrics # core, requires only numpy
36
+ pip install "aggmetrics[all]" # adds scikit-learn and pandas support
37
+ ```
38
+
39
+ ## Why not just use `sklearn.metrics`?
40
+
41
+ `sklearn` exposes four `average` values for a fixed list of measures. The
42
+ correspondence is exact — `'micro'`, `'macro'`, `'weighted'` and `'samples'` are
43
+ Σ₁, Σₘ with uniform weights, Σₘ with prevalence weights and Σₙ respectively — but
44
+ the combinations available are a subset of those the construction generates, and
45
+ nothing in the library tells you when a combination is misleading.
46
+
47
+ Three things this package adds:
48
+
49
+ **Any measure under any operator.** Macro-MCC, exemplar-Jaccard, micro-informedness,
50
+ weighted G-mean. 21 binary measures × 4 operators, without special-casing.
51
+
52
+ **Structural properties as first-class data.** Each measure records whether it is
53
+ *aggregation-decomposable*, whether it is skew-invariant, and whether its value at
54
+ `tp = 0` depends on `tn`. These determine which identities apply.
55
+
56
+ ```python
57
+ from aggmetrics import get_measure, list_measures
58
+
59
+ list_measures(decomposable=True) # micro == denominator-weighted macro
60
+ get_measure('mcc').decomposable # False
61
+ get_measure('recall').bayes_threshold() # 0.0 -- maximised by predicting all positive
62
+ get_measure('mcc').attainable_floor(10) # -0.111, not -1
63
+ ```
64
+
65
+ **The generative claim, as an operation.** Supply a binary formula; the properties
66
+ that decide which identities apply are *derived*, not declared:
67
+
68
+ ```python
69
+ from aggmetrics import register_measure, infer_properties
70
+
71
+ def prevalence_threshold(tp, fp, fn, tn):
72
+ r, s = tp / (tp + fn), tn / (tn + fp)
73
+ return (np.sqrt(r * (1 - s)) + s - 1) / (r + s - 1)
74
+
75
+ m = register_measure('prevalence_threshold', prevalence_threshold)
76
+ m.decomposable # False -- inferred by fitting <a,v>/<b,v> and validating
77
+ m.skew_invariant # True -- a function of recall and specificity
78
+ m.bayes_threshold() # None -- no closed form outside the decomposable class
79
+
80
+ im.compute('prevalence_threshold', 'exemplar') # usable immediately
81
+ ```
82
+
83
+ Inference is checked against the 21 measures whose properties are known
84
+ analytically — `verify_inference()` — and all 21 agree, with recovered coefficients
85
+ accurate to 4.2e-15. The check paid for itself: it found two errors in our own hand
86
+ annotations (error rate and FPR both retain a `tn` dependence at `tp = 0`, so both
87
+ *flatter* a wholly wrong classifier, reaching 2/m and 1/(m-1) respectively).
88
+
89
+ **Diagnostics.** `diagnose()` flags the traps:
90
+
91
+ ```python
92
+ y = np.arange(500) % 10
93
+ yhat = (y + 1) % 10 # every single prediction is wrong
94
+ im = IndicatorMatrix(y, yhat, task='multiclass')
95
+
96
+ im.compute('label_accuracy', 'micro') # 0.800 (!)
97
+ im.compute('mcc', 'micro') # -0.111 (!)
98
+ im.accuracy() # 0.000
99
+
100
+ for f in diagnose(im):
101
+ print(f)
102
+ # [warning] inflated_floor: 'label_accuracy' cannot fall below 0.8000 on this
103
+ # 10-class one-hot problem even if every prediction is wrong ...
104
+ # [note] micro_collapse: micro-precision, micro-recall and micro-F1 are all
105
+ # equal to accuracy ...
106
+ # [warning] degenerate_optimum: 'recall' is maximised at threshold 0.000 ...
107
+ ```
108
+
109
+ Checks currently implemented: pooled value outside the per-class range;
110
+ `tn`-dependent measures on one-hot multiclass data; micro-collapse on single-label
111
+ data; undefined per-class entries and the size of the convention effect; measures
112
+ whose optimal decision rule is not the Bayes rule for 0/1 loss.
113
+
114
+ ## Features
115
+
116
+ - **21 binary measures**, resolvable by name or alias (`iou` → `jaccard`, `dice` → `f1`),
117
+ plus `register_measure` for your own, with properties inferred.
118
+ - **Four operators**: `micro`, `macro`, `weighted`, `exemplar`.
119
+ - **Explicit undefined-value conventions**: `exclude` (default), `zero`
120
+ (`sklearn`'s behaviour), `one`, `nan`. The choice is a parameter, not a default
121
+ buried in the implementation.
122
+ - **Soft ground truth** via t-norms, with `marginal_defect()` showing that only the
123
+ product t-norm preserves the marginals.
124
+ - **Cost matrices**, recovering MAE and MSE as special cases.
125
+ - **Observation masks** for partial labels, semi-supervised evaluation and abstention.
126
+ - **scikit-learn scorers** via `make_scorer`, usable in `cross_val_score` and
127
+ `GridSearchCV`.
128
+ - **AI Agent Skills included** (`.agents/skills/aggmetrics-guide/`), providing machine-readable
129
+ runbooks, axiomatic invariants, and diagnostic protocols for AI coding assistants.
130
+
131
+ ## The test suite verifies the theory
132
+
133
+ Each result of the companion paper is an executable test, so a change that breaks
134
+ a theorem breaks a test:
135
+
136
+ ```bash
137
+ python -m pytest tests/test_theory.py -v
138
+ ```
139
+
140
+ | Test | Result verified |
141
+ |---|---|
142
+ | `test_theorem1_holds_for_decomposable` | micro = denominator-weighted macro, 15 measures × 3 samples |
143
+ | `test_theorem1_fails_for_non_decomposable` | the identity has no extension beyond that class |
144
+ | `test_theorem2_product_is_uniquely_marginally_consistent` | only the product t-norm preserves marginals |
145
+ | `test_theorem5_micro_collapse` | micro-P = micro-R = micro-F₁ = accuracy, single-label |
146
+ | `test_proposition5_thresholds` | Bayes threshold `(β+δ)/(α+β+γ+δ)` |
147
+ | `test_proposition7_floors` | attainable floors under one-hot encoding |
148
+ | `test_proposition2_recall_prior_free_specificity_not` | one-vs-rest skew behaviour |
149
+ | `test_corollary5_gap_is_normalised_covariance` | the micro–macro gap identity |
150
+ | `test_theorem3_mae_and_mse_as_cost_matrices` | ordinal losses as cost matrices |
151
+ | `test_inference_reproduces_every_annotation` | inference recovers all 21 analytic annotations |
152
+
153
+ 138 tests, no skips.
154
+
155
+ ## Examples
156
+
157
+ ```bash
158
+ python examples/quickstart.py
159
+ ```
160
+
161
+ ## Citing
162
+
163
+ Please cite both the software and the companion paper; see `CITATION.cff`.
164
+
165
+ ## License
166
+
167
+ BSD 3-Clause.
@@ -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
+