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.
- aggmetrics-0.1.0.dev0/LICENSE +29 -0
- aggmetrics-0.1.0.dev0/PKG-INFO +203 -0
- aggmetrics-0.1.0.dev0/README.md +167 -0
- aggmetrics-0.1.0.dev0/aggmetrics/__init__.py +74 -0
- aggmetrics-0.1.0.dev0/aggmetrics/aggregation.py +210 -0
- aggmetrics-0.1.0.dev0/aggmetrics/core.py +351 -0
- aggmetrics-0.1.0.dev0/aggmetrics/diagnostics.py +312 -0
- aggmetrics-0.1.0.dev0/aggmetrics/inference.py +339 -0
- aggmetrics-0.1.0.dev0/aggmetrics/measures.py +329 -0
- aggmetrics-0.1.0.dev0/aggmetrics/sklearn_compat.py +87 -0
- aggmetrics-0.1.0.dev0/aggmetrics/tnorms.py +111 -0
- aggmetrics-0.1.0.dev0/aggmetrics.egg-info/PKG-INFO +203 -0
- aggmetrics-0.1.0.dev0/aggmetrics.egg-info/SOURCES.txt +19 -0
- aggmetrics-0.1.0.dev0/aggmetrics.egg-info/dependency_links.txt +1 -0
- aggmetrics-0.1.0.dev0/aggmetrics.egg-info/requires.txt +20 -0
- aggmetrics-0.1.0.dev0/aggmetrics.egg-info/top_level.txt +1 -0
- aggmetrics-0.1.0.dev0/pyproject.toml +45 -0
- aggmetrics-0.1.0.dev0/setup.cfg +4 -0
- aggmetrics-0.1.0.dev0/tests/test_api.py +233 -0
- aggmetrics-0.1.0.dev0/tests/test_inference.py +193 -0
- aggmetrics-0.1.0.dev0/tests/test_theory.py +548 -0
|
@@ -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
|
+
|