aggmetrics 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aggmetrics/__init__.py +74 -0
- aggmetrics/aggregation.py +210 -0
- aggmetrics/core.py +351 -0
- aggmetrics/diagnostics.py +312 -0
- aggmetrics/inference.py +339 -0
- aggmetrics/measures.py +329 -0
- aggmetrics/sklearn_compat.py +87 -0
- aggmetrics/tnorms.py +111 -0
- aggmetrics-0.1.0.dev0.dist-info/METADATA +203 -0
- aggmetrics-0.1.0.dev0.dist-info/RECORD +13 -0
- aggmetrics-0.1.0.dev0.dist-info/WHEEL +5 -0
- aggmetrics-0.1.0.dev0.dist-info/licenses/LICENSE +29 -0
- aggmetrics-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# Copyright (c) 2026, Ronaldo C. Prati
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""
|
|
4
|
+
Diagnostics: warnings the framework can raise about its own measures.
|
|
5
|
+
|
|
6
|
+
This module is what distinguishes the package from a collection of formulas. Each
|
|
7
|
+
check corresponds to a result about when a measure behaves as a practitioner is
|
|
8
|
+
likely to assume, and fires when it does not:
|
|
9
|
+
|
|
10
|
+
* applying a weighted-averaging interpretation to a measure that is not
|
|
11
|
+
aggregation-decomposable, where the pooled value may lie outside the range of
|
|
12
|
+
the per-class values entirely;
|
|
13
|
+
* reporting a ``tn``-dependent measure on one-hot multiclass data, where the
|
|
14
|
+
attainable floor is bounded away from the nominal minimum by an amount growing
|
|
15
|
+
with the number of classes;
|
|
16
|
+
* selecting an operating point by maximising a measure whose optimal decision
|
|
17
|
+
rule is not the Bayes rule for 0/1 loss --- degenerately so for recall and
|
|
18
|
+
specificity;
|
|
19
|
+
* macro-averaging with undefined per-class entries, where the convention chosen
|
|
20
|
+
can shift the reported value materially;
|
|
21
|
+
* reporting micro-precision, micro-recall or micro-F1 on single-label data, where
|
|
22
|
+
all three collapse onto accuracy.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import warnings
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import List, Optional, Sequence
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from .aggregation import aggregate
|
|
33
|
+
from .measures import BinaryMeasure, get_measure
|
|
34
|
+
|
|
35
|
+
__all__ = ["Finding", "diagnose", "check_hull", "micro_macro_gap",
|
|
36
|
+
"uniform_error_deviation", "check_ovr_skew_drift",
|
|
37
|
+
"check_redundancy", "MeasureWarning"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MeasureWarning(UserWarning):
|
|
41
|
+
"""Raised when a measure is being used outside the range where it behaves
|
|
42
|
+
as commonly assumed."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Finding:
|
|
47
|
+
"""One diagnostic result."""
|
|
48
|
+
|
|
49
|
+
code: str
|
|
50
|
+
severity: str # 'error' | 'warning' | 'note'
|
|
51
|
+
message: str
|
|
52
|
+
detail: dict
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
55
|
+
return f"[{self.severity}] {self.code}: {self.message}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def check_hull(im, measure: str | BinaryMeasure,
|
|
61
|
+
aggregation: str = "macro") -> Optional[Finding]:
|
|
62
|
+
"""Check whether the pooled value lies within the per-class range.
|
|
63
|
+
|
|
64
|
+
Any weighted average of the per-class values must lie between their minimum
|
|
65
|
+
and maximum. A micro value outside that interval therefore proves that *no*
|
|
66
|
+
weighting reproduces it, so no weighted-averaging interpretation of
|
|
67
|
+
micro-averaging is available for that measure.
|
|
68
|
+
"""
|
|
69
|
+
meas = get_measure(measure)
|
|
70
|
+
micro = im.compute(meas, "micro")
|
|
71
|
+
parts = im.parts(meas, aggregation)
|
|
72
|
+
parts = parts[~np.isnan(parts)]
|
|
73
|
+
if parts.size == 0 or np.isnan(micro):
|
|
74
|
+
return None
|
|
75
|
+
lo, hi = float(parts.min()), float(parts.max())
|
|
76
|
+
inside = lo - 1e-12 <= micro <= hi + 1e-12
|
|
77
|
+
if inside:
|
|
78
|
+
return None
|
|
79
|
+
return Finding(
|
|
80
|
+
code="outside_hull",
|
|
81
|
+
severity="warning",
|
|
82
|
+
message=(f"micro-{meas.name} = {micro:.4f} lies outside the range of the "
|
|
83
|
+
f"per-{'class' if aggregation == 'macro' else 'example'} values "
|
|
84
|
+
f"[{lo:.4f}, {hi:.4f}], so it is not a weighted average of them. "
|
|
85
|
+
f"{meas.name!r} is not aggregation-decomposable."),
|
|
86
|
+
detail=dict(measure=meas.name, micro=micro, lo=lo, hi=hi,
|
|
87
|
+
decomposable=meas.decomposable),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def micro_macro_gap(im, measure: str | BinaryMeasure) -> dict:
|
|
92
|
+
"""Decompose the micro--macro gap for a decomposable measure.
|
|
93
|
+
|
|
94
|
+
For an aggregation-decomposable measure the gap equals the covariance between
|
|
95
|
+
the per-class denominators and the per-class values, normalised by the mean
|
|
96
|
+
denominator. The identity does not extend beyond that class, and the returned
|
|
97
|
+
dictionary reports ``exact=False`` when it does not apply.
|
|
98
|
+
"""
|
|
99
|
+
meas = get_measure(measure)
|
|
100
|
+
micro = im.compute(meas, "micro")
|
|
101
|
+
macro = im.compute(meas, "macro")
|
|
102
|
+
out = dict(measure=meas.name, micro=micro, macro=macro,
|
|
103
|
+
gap=micro - macro, decomposable=meas.decomposable, exact=False)
|
|
104
|
+
if not meas.decomposable:
|
|
105
|
+
out["note"] = ("gap is not a covariance for this measure; see the "
|
|
106
|
+
"curvature/mismatched-weight decomposition")
|
|
107
|
+
return out
|
|
108
|
+
pc = im.per_class()
|
|
109
|
+
w = np.array([meas.denominator(tuple(row)) for row in pc], float)
|
|
110
|
+
v = np.array([meas(*row) for row in pc], float)
|
|
111
|
+
ok = ~np.isnan(v) & (w > 0)
|
|
112
|
+
w, v = w[ok], v[ok]
|
|
113
|
+
if w.size == 0:
|
|
114
|
+
return out
|
|
115
|
+
wbar, vbar = w.mean(), v.mean()
|
|
116
|
+
cov = float(np.mean((w - wbar) * (v - vbar)))
|
|
117
|
+
out.update(covariance=cov, mean_denominator=float(wbar),
|
|
118
|
+
predicted_gap=cov / wbar if wbar else float("nan"),
|
|
119
|
+
weights=w.tolist(), values=v.tolist(), exact=True)
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def uniform_error_deviation(im) -> float:
|
|
124
|
+
"""Compute the mean uniform-error deviation across classes in multiclass OvR.
|
|
125
|
+
|
|
126
|
+
Proposition 4 of the companion paper proves that One-vs-Rest specificity is
|
|
127
|
+
strictly prior-free if and only if the off-diagonal error rates into class j
|
|
128
|
+
are uniform ($R_{kj} = \\text{const}$ for all $k \\neq j$). This function computes
|
|
129
|
+
the mean over classes $j$ of the sample standard deviation of $R_{kj}$ over $k \\neq j$.
|
|
130
|
+
"""
|
|
131
|
+
if im.task != "multiclass" or im.m <= 2:
|
|
132
|
+
return 0.0
|
|
133
|
+
C = (im.Y.T @ im.P).astype(float)
|
|
134
|
+
row_sums = C.sum(axis=1, keepdims=True)
|
|
135
|
+
row_sums[row_sums == 0] = 1.0
|
|
136
|
+
R = C / row_sums
|
|
137
|
+
stds = []
|
|
138
|
+
for j in range(im.m):
|
|
139
|
+
off_diag = [R[k, j] for k in range(im.m) if k != j]
|
|
140
|
+
stds.append(float(np.std(off_diag, ddof=1) if len(off_diag) > 1 else 0.0))
|
|
141
|
+
return float(np.mean(stds))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check_ovr_skew_drift(im, threshold: float = 0.01) -> Optional[Finding]:
|
|
145
|
+
"""Check whether multiclass One-vs-Rest specificity and balanced accuracy
|
|
146
|
+
suffer from non-trivial prior drift due to asymmetric off-diagonal error rates.
|
|
147
|
+
"""
|
|
148
|
+
if im.task != "multiclass" or im.m <= 2:
|
|
149
|
+
return None
|
|
150
|
+
dev = uniform_error_deviation(im)
|
|
151
|
+
if dev < threshold:
|
|
152
|
+
return None
|
|
153
|
+
return Finding(
|
|
154
|
+
code="ovr_skew_drift",
|
|
155
|
+
severity="note",
|
|
156
|
+
message=(f"Mean uniform-error deviation is {dev:.4f} >= {threshold:.4f}; "
|
|
157
|
+
f"multiclass macro-specificity and macro-balanced-accuracy will "
|
|
158
|
+
f"drift under class prior shifts (with Delta_BA = 0.5 * Delta_spec) "
|
|
159
|
+
f"due to asymmetric off-diagonal confusion rates into classes."),
|
|
160
|
+
detail=dict(uniform_error_dev=dev, threshold=threshold, m=im.m),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check_redundancy(im, measures: Optional[Sequence[str]] = None) -> Optional[Finding]:
|
|
165
|
+
"""Check whether a suite of performance measures exceeds the intrinsic degrees of freedom.
|
|
166
|
+
|
|
167
|
+
As shown in Proposition 6 and Remark 1 of the companion paper, a confusion matrix has
|
|
168
|
+
strictly d = m(m-1) independent degrees of freedom (or d = 2 in binary classification,
|
|
169
|
+
spanned by the canonical orthogonal basis (recall, specificity)). A benchmark suite with k > d
|
|
170
|
+
measures inevitably contains collinear gradients and functional redundancy (e.g. reporting
|
|
171
|
+
accuracy, balanced accuracy, and F1 alongside recall and specificity under fixed priors).
|
|
172
|
+
"""
|
|
173
|
+
if measures is None:
|
|
174
|
+
return None
|
|
175
|
+
unique_measures = list(dict.fromkeys(measures))
|
|
176
|
+
dof = im.degrees_of_freedom
|
|
177
|
+
if len(unique_measures) > dof:
|
|
178
|
+
return Finding(
|
|
179
|
+
code="redundant_suite",
|
|
180
|
+
severity="note",
|
|
181
|
+
message=(f"The requested suite of {len(unique_measures)} measures exceeds the "
|
|
182
|
+
f"{dof} independent degrees of freedom of the confusion matrix. "
|
|
183
|
+
f"Under fixed priors, scalar measures beyond the canonical orthogonal basis "
|
|
184
|
+
f"have collinear gradients and convey redundant information."),
|
|
185
|
+
detail=dict(measures=unique_measures, count=len(unique_measures), dof=dof),
|
|
186
|
+
)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def diagnose(im, measures: Optional[Sequence[str]] = None,
|
|
193
|
+
*, aggregation: str = "macro", emit_warnings: bool = False
|
|
194
|
+
) -> List[Finding]:
|
|
195
|
+
"""Run all checks against an :class:`~aggmetrics.core.IndicatorMatrix`.
|
|
196
|
+
|
|
197
|
+
Returns a list of :class:`Finding`. With ``emit_warnings=True`` each finding
|
|
198
|
+
of severity ``'warning'`` is also raised as a :class:`MeasureWarning`, which
|
|
199
|
+
makes the checks usable inside a test suite or a CI pipeline.
|
|
200
|
+
"""
|
|
201
|
+
names = list(measures) if measures is not None else [
|
|
202
|
+
"precision", "recall", "f1", "balanced_accuracy", "gmean", "mcc",
|
|
203
|
+
"label_accuracy", "specificity"]
|
|
204
|
+
out: List[Finding] = []
|
|
205
|
+
one_hot = im.task == "multiclass" and np.allclose(im.Y.sum(axis=1), 1.0)
|
|
206
|
+
|
|
207
|
+
# 1. pooled value outside the per-class range
|
|
208
|
+
for n in names:
|
|
209
|
+
f = check_hull(im, n, aggregation)
|
|
210
|
+
if f is not None:
|
|
211
|
+
out.append(f)
|
|
212
|
+
|
|
213
|
+
# 2. tn-dependent measures on one-hot multiclass data
|
|
214
|
+
if one_hot and im.m > 2:
|
|
215
|
+
for n in names:
|
|
216
|
+
meas = get_measure(n)
|
|
217
|
+
if not meas.tn_dependent_at_zero:
|
|
218
|
+
continue
|
|
219
|
+
floor = meas.attainable_floor(im.m)
|
|
220
|
+
out.append(Finding(
|
|
221
|
+
code="inflated_floor",
|
|
222
|
+
severity="warning",
|
|
223
|
+
message=(f"{meas.name!r} cannot fall below {floor:.4f} on this "
|
|
224
|
+
f"{im.m}-class one-hot problem even if every prediction "
|
|
225
|
+
f"is wrong, because a misclassified example still "
|
|
226
|
+
f"contributes {im.m - 2} true negatives."),
|
|
227
|
+
detail=dict(measure=meas.name, m=im.m, floor=floor),
|
|
228
|
+
))
|
|
229
|
+
|
|
230
|
+
# 3. micro-collapse on single-label data
|
|
231
|
+
if one_hot:
|
|
232
|
+
vals = {n: im.compute(n, "micro") for n in ("precision", "recall", "f1")}
|
|
233
|
+
acc = im.accuracy()
|
|
234
|
+
if max(abs(v - acc) for v in vals.values()) < 1e-9:
|
|
235
|
+
out.append(Finding(
|
|
236
|
+
code="micro_collapse",
|
|
237
|
+
severity="note",
|
|
238
|
+
message=("micro-precision, micro-recall and micro-F1 are all "
|
|
239
|
+
f"equal to accuracy ({acc:.6f}) on single-label data; "
|
|
240
|
+
"reporting more than one of them adds no information."),
|
|
241
|
+
detail=dict(accuracy=acc, **vals),
|
|
242
|
+
))
|
|
243
|
+
|
|
244
|
+
# 4. undefined per-class / per-example entries
|
|
245
|
+
for n in names:
|
|
246
|
+
for agg in ("macro", "exemplar"):
|
|
247
|
+
try:
|
|
248
|
+
p = im.parts(n, agg)
|
|
249
|
+
except Exception:
|
|
250
|
+
continue
|
|
251
|
+
k = int(np.isnan(p).sum())
|
|
252
|
+
if k == 0:
|
|
253
|
+
continue
|
|
254
|
+
excl = aggregate(im.Y, im.P, n, agg, convention="exclude")
|
|
255
|
+
zero = aggregate(im.Y, im.P, n, agg, convention="zero")
|
|
256
|
+
out.append(Finding(
|
|
257
|
+
code="undefined_entries",
|
|
258
|
+
severity="warning",
|
|
259
|
+
message=(f"{agg}-{get_measure(n).name} has {k} of {p.size} "
|
|
260
|
+
f"undefined entries; the reported value is "
|
|
261
|
+
f"{excl:.4f} under the exclusion convention and "
|
|
262
|
+
f"{zero:.4f} under substitution of zero "
|
|
263
|
+
f"(difference {abs(excl - zero):.4f})."),
|
|
264
|
+
detail=dict(measure=get_measure(n).name, aggregation=agg,
|
|
265
|
+
undefined=k, total=int(p.size),
|
|
266
|
+
exclude=excl, zero=zero),
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
# 5. measures not optimised by the Bayes rule for 0/1 loss
|
|
270
|
+
for n in names:
|
|
271
|
+
meas = get_measure(n)
|
|
272
|
+
cons = meas.erm_consistent()
|
|
273
|
+
if cons is False:
|
|
274
|
+
t = meas.bayes_threshold()
|
|
275
|
+
extra = ""
|
|
276
|
+
if t is not None and (t <= 1e-9 or t >= 1 - 1e-9):
|
|
277
|
+
extra = (" This is degenerate: the optimum is to predict every "
|
|
278
|
+
"instance " + ("positive." if t <= 1e-9 else "negative."))
|
|
279
|
+
out.append(Finding(
|
|
280
|
+
code="degenerate_optimum",
|
|
281
|
+
severity="warning",
|
|
282
|
+
message=(f"{meas.name!r} is maximised at threshold {t:.3f}."
|
|
283
|
+
+ extra),
|
|
284
|
+
detail=dict(measure=meas.name, bayes_threshold=t),
|
|
285
|
+
))
|
|
286
|
+
else:
|
|
287
|
+
out.append(Finding(
|
|
288
|
+
code="not_erm_consistent",
|
|
289
|
+
severity="note",
|
|
290
|
+
message=(f"{meas.name!r} is maximised at threshold "
|
|
291
|
+
f"{t:.3f}, not 0.5, so standard training does not "
|
|
292
|
+
f"target it."),
|
|
293
|
+
detail=dict(measure=meas.name, bayes_threshold=t),
|
|
294
|
+
))
|
|
295
|
+
|
|
296
|
+
# 6. multiclass OvR prior drift (asymmetric off-diagonal errors)
|
|
297
|
+
if one_hot and im.m > 2 and any(n in names for n in ("specificity", "balanced_accuracy", "gmean", "informedness")):
|
|
298
|
+
f_drift = check_ovr_skew_drift(im)
|
|
299
|
+
if f_drift is not None:
|
|
300
|
+
out.append(f_drift)
|
|
301
|
+
|
|
302
|
+
# 7. suite redundancy beyond intrinsic degrees of freedom
|
|
303
|
+
if measures is not None:
|
|
304
|
+
f_red = check_redundancy(im, measures)
|
|
305
|
+
if f_red is not None:
|
|
306
|
+
out.append(f_red)
|
|
307
|
+
|
|
308
|
+
if emit_warnings:
|
|
309
|
+
for f in out:
|
|
310
|
+
if f.severity == "warning":
|
|
311
|
+
warnings.warn(str(f), MeasureWarning, stacklevel=2)
|
|
312
|
+
return out
|
aggmetrics/inference.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# Copyright (c) 2026, Ronaldo C. Prati
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""
|
|
4
|
+
Deriving a measure's structural properties from its formula.
|
|
5
|
+
|
|
6
|
+
The framework's generative claim is that any function of the four
|
|
7
|
+
confusion-matrix counts extends to the other settings by substituting an
|
|
8
|
+
aggregation operator. Taken alone that is cheap: the extensions are always
|
|
9
|
+
*defined*. What makes an extension usable is knowing which identities hold for
|
|
10
|
+
it, and this module shows that those properties need not be supplied by hand ---
|
|
11
|
+
they are recoverable from the formula itself.
|
|
12
|
+
|
|
13
|
+
Four properties are inferred:
|
|
14
|
+
|
|
15
|
+
*Decomposability.* A measure admits the representation
|
|
16
|
+
:math:`M = \\langle a, v\\rangle / \\langle b, v\\rangle` iff
|
|
17
|
+
:math:`M(v)\\,\\langle b, v\\rangle - \\langle a, v\\rangle = 0` for all count
|
|
18
|
+
vectors :math:`v`. Sampling count vectors turns this into a homogeneous linear
|
|
19
|
+
system in the eight unknowns :math:`(a, b)`, solved by taking the right singular
|
|
20
|
+
vector of smallest singular value. Because a near-null direction can arise by
|
|
21
|
+
chance, the recovered coefficients are then validated on count vectors that were
|
|
22
|
+
not used to fit them; only if they reproduce the measure there is the measure
|
|
23
|
+
reported as decomposable. This both decides the property and returns the
|
|
24
|
+
coefficients, which is what the Bayes threshold and the weighted-averaging
|
|
25
|
+
identity need.
|
|
26
|
+
|
|
27
|
+
*Skew-invariance.* Tested directly against the definition, by rescaling the
|
|
28
|
+
positive- and negative-class counts independently.
|
|
29
|
+
|
|
30
|
+
*Dependence on* ``tn`` *at* ``tp = 0``. Decides whether the measure can attain its
|
|
31
|
+
nominal minimum under one-hot multiclass encoding.
|
|
32
|
+
|
|
33
|
+
*Direction.* Whether correcting a prediction increases the value.
|
|
34
|
+
|
|
35
|
+
Inference is numerical and therefore not a proof; ``verify_inference`` checks it
|
|
36
|
+
against the measures whose properties are known analytically, and the test suite
|
|
37
|
+
requires exact agreement on all of them.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from typing import Callable, Dict, Optional, Sequence, Tuple
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
|
|
46
|
+
__all__ = ["InferredProperties", "infer_properties", "register_measure",
|
|
47
|
+
"verify_inference"]
|
|
48
|
+
|
|
49
|
+
Formula = Callable[[float, float, float, float], float]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class InferredProperties:
|
|
54
|
+
"""Structural properties recovered from a formula."""
|
|
55
|
+
|
|
56
|
+
linear: Optional[Tuple[Tuple[float, float, float, float],
|
|
57
|
+
Tuple[float, float, float, float]]]
|
|
58
|
+
skew_invariant: bool
|
|
59
|
+
tn_dependent_at_zero: bool
|
|
60
|
+
greater_is_better: bool
|
|
61
|
+
residual: float # fit residual on held-out count vectors
|
|
62
|
+
diagnostics: Dict[str, float]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def decomposable(self) -> bool:
|
|
66
|
+
return self.linear is not None
|
|
67
|
+
|
|
68
|
+
def summary(self) -> str:
|
|
69
|
+
lines = [
|
|
70
|
+
f"decomposable : {self.decomposable}",
|
|
71
|
+
f"skew-invariant : {self.skew_invariant}",
|
|
72
|
+
f"tn-dependent at tp=0 : {self.tn_dependent_at_zero}",
|
|
73
|
+
f"greater is better : {self.greater_is_better}",
|
|
74
|
+
]
|
|
75
|
+
if self.linear is not None:
|
|
76
|
+
a, b = self.linear
|
|
77
|
+
fmt = lambda v: "(" + ", ".join(f"{x:+.4g}" for x in v) + ")"
|
|
78
|
+
lines += [f"numerator coeffs : {fmt(a)}",
|
|
79
|
+
f"denominator coeffs : {fmt(b)}",
|
|
80
|
+
f"held-out residual : {self.residual:.2e}"]
|
|
81
|
+
return "\n".join(lines)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _sample_counts(n: int, rng: np.random.Generator,
|
|
87
|
+
low: float = 1.0, high: float = 60.0) -> np.ndarray:
|
|
88
|
+
"""Count vectors that keep every marginal strictly positive.
|
|
89
|
+
|
|
90
|
+
Degenerate vectors are excluded so that measures with vanishing denominators
|
|
91
|
+
do not contaminate the fit; the properties being inferred are about the
|
|
92
|
+
interior of the domain.
|
|
93
|
+
"""
|
|
94
|
+
return rng.uniform(low, high, size=(n, 4))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _eval(fn: Formula, V: np.ndarray) -> np.ndarray:
|
|
98
|
+
return np.array([fn(*v) for v in V], dtype=float)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fit_linear_fractional(fn: Formula, rng: np.random.Generator,
|
|
102
|
+
n_fit: int = 40, n_check: int = 40,
|
|
103
|
+
tol: float = 1e-8):
|
|
104
|
+
"""Recover ``(a, b)`` with ``M = <a,v>/<b,v>``, or None.
|
|
105
|
+
|
|
106
|
+
Returns ``(a, b, residual)``. The system is homogeneous, so the solution is
|
|
107
|
+
fixed only up to scale; it is normalised so that the largest denominator
|
|
108
|
+
coefficient is positive and of unit magnitude, which makes the output
|
|
109
|
+
comparable with hand-written coefficients.
|
|
110
|
+
"""
|
|
111
|
+
V = _sample_counts(n_fit, rng)
|
|
112
|
+
y = _eval(fn, V)
|
|
113
|
+
if not np.all(np.isfinite(y)):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
# rows of [ -v | y * v ] annihilate (a, b)
|
|
117
|
+
A = np.hstack([-V, y[:, None] * V])
|
|
118
|
+
*_, Vt = np.linalg.svd(A, full_matrices=False)
|
|
119
|
+
sol = Vt[-1]
|
|
120
|
+
a, b = sol[:4], sol[4:]
|
|
121
|
+
|
|
122
|
+
if np.max(np.abs(b)) < 1e-10: # denominator collapsed: not of this form
|
|
123
|
+
return None
|
|
124
|
+
k = b[np.argmax(np.abs(b))]
|
|
125
|
+
a, b = a / k, b / k
|
|
126
|
+
|
|
127
|
+
# validate on fresh count vectors: this is what rejects accidental fits
|
|
128
|
+
Vc = _sample_counts(n_check, rng)
|
|
129
|
+
yc = _eval(fn, Vc)
|
|
130
|
+
den = Vc @ b
|
|
131
|
+
ok = np.abs(den) > 1e-9
|
|
132
|
+
if not ok.any() or not np.all(np.isfinite(yc[ok])):
|
|
133
|
+
return None
|
|
134
|
+
pred = (Vc @ a)[ok] / den[ok]
|
|
135
|
+
scale = max(1.0, float(np.max(np.abs(yc[ok]))))
|
|
136
|
+
residual = float(np.max(np.abs(pred - yc[ok])) / scale)
|
|
137
|
+
if residual > tol:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
a = np.where(np.abs(a) < 1e-9, 0.0, a)
|
|
141
|
+
b = np.where(np.abs(b) < 1e-9, 0.0, b)
|
|
142
|
+
return tuple(float(x) for x in a), tuple(float(x) for x in b), residual
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _test_skew_invariance(fn: Formula, rng: np.random.Generator,
|
|
146
|
+
trials: int = 60, tol: float = 1e-9) -> Tuple[bool, float]:
|
|
147
|
+
"""Is ``M(lam*tp, mu*fp, lam*fn, mu*tn) == M(tp, fp, fn, tn)``?"""
|
|
148
|
+
worst = 0.0
|
|
149
|
+
for _ in range(trials):
|
|
150
|
+
v = _sample_counts(1, rng)[0]
|
|
151
|
+
base = fn(*v)
|
|
152
|
+
if not np.isfinite(base):
|
|
153
|
+
continue
|
|
154
|
+
lam, mu = rng.uniform(0.2, 5.0, 2)
|
|
155
|
+
alt = fn(lam * v[0], mu * v[1], lam * v[2], mu * v[3])
|
|
156
|
+
if not np.isfinite(alt):
|
|
157
|
+
continue
|
|
158
|
+
worst = max(worst, abs(alt - base) / max(1.0, abs(base)))
|
|
159
|
+
return bool(worst <= tol), float(worst)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _test_tn_dependence_at_zero(fn: Formula, rng: np.random.Generator,
|
|
163
|
+
trials: int = 40, tol: float = 1e-9
|
|
164
|
+
) -> Tuple[bool, float]:
|
|
165
|
+
"""At ``tp = 0``, does the value still move when only ``tn`` changes?"""
|
|
166
|
+
worst = 0.0
|
|
167
|
+
for _ in range(trials):
|
|
168
|
+
fp, fn_ = rng.uniform(1.0, 50.0, 2)
|
|
169
|
+
vals = []
|
|
170
|
+
for tn in (1.0, 25.0, 500.0, 5000.0):
|
|
171
|
+
v = fn(0.0, fp, fn_, tn)
|
|
172
|
+
if np.isfinite(v):
|
|
173
|
+
vals.append(v)
|
|
174
|
+
if len(vals) >= 2:
|
|
175
|
+
worst = max(worst, max(vals) - min(vals))
|
|
176
|
+
return bool(worst > tol), float(worst)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _test_direction(fn: Formula, rng: np.random.Generator,
|
|
180
|
+
trials: int = 40) -> Tuple[bool, float]:
|
|
181
|
+
"""Does correcting a prediction raise the value?
|
|
182
|
+
|
|
183
|
+
A correction moves the counts by ``(+1, -1, -1, +1)``, which is the admissible
|
|
184
|
+
move under one-hot encoding; it is favourable in all four coordinates at once.
|
|
185
|
+
"""
|
|
186
|
+
ups = 0
|
|
187
|
+
total = 0
|
|
188
|
+
for _ in range(trials):
|
|
189
|
+
v = _sample_counts(1, rng, low=5.0, high=60.0)[0]
|
|
190
|
+
before = fn(*v)
|
|
191
|
+
after = fn(v[0] + 1, v[1] - 1, v[2] - 1, v[3] + 1)
|
|
192
|
+
if np.isfinite(before) and np.isfinite(after) and abs(after - before) > 1e-12:
|
|
193
|
+
total += 1
|
|
194
|
+
ups += after > before
|
|
195
|
+
if total == 0:
|
|
196
|
+
return True, 0.0
|
|
197
|
+
return bool(ups >= total / 2), float(ups / total)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def infer_properties(fn: Formula, *, seed: int = 0,
|
|
203
|
+
tol: float = 1e-8) -> InferredProperties:
|
|
204
|
+
"""Recover the structural properties of a binary measure from its formula.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
fn
|
|
209
|
+
A callable ``fn(tp, fp, fn, tn) -> float``. It may return NaN on
|
|
210
|
+
degenerate inputs; such points are skipped.
|
|
211
|
+
seed
|
|
212
|
+
Seed for the count vectors sampled during inference.
|
|
213
|
+
tol
|
|
214
|
+
Relative tolerance for the held-out validation of the linear-fractional
|
|
215
|
+
fit, and for the invariance tests.
|
|
216
|
+
|
|
217
|
+
Examples
|
|
218
|
+
--------
|
|
219
|
+
>>> from aggmetrics.inference import infer_properties
|
|
220
|
+
>>> p = infer_properties(lambda tp, fp, fn, tn: tp / (tp + fn)) # recall
|
|
221
|
+
>>> p.decomposable, p.skew_invariant
|
|
222
|
+
(True, True)
|
|
223
|
+
>>> p = infer_properties(lambda tp, fp, fn, tn:
|
|
224
|
+
... 0.5 * (tp / (tp + fn) + tn / (tn + fp))) # balanced acc.
|
|
225
|
+
>>> p.decomposable, p.skew_invariant
|
|
226
|
+
(False, True)
|
|
227
|
+
"""
|
|
228
|
+
rng = np.random.default_rng(seed)
|
|
229
|
+
fit = _fit_linear_fractional(fn, rng, tol=tol)
|
|
230
|
+
skew, skew_worst = _test_skew_invariance(fn, rng, tol=tol)
|
|
231
|
+
tn_dep, tn_worst = _test_tn_dependence_at_zero(fn, rng)
|
|
232
|
+
up, up_frac = _test_direction(fn, rng)
|
|
233
|
+
|
|
234
|
+
linear = None if fit is None else (fit[0], fit[1])
|
|
235
|
+
residual = float("nan") if fit is None else fit[2]
|
|
236
|
+
return InferredProperties(
|
|
237
|
+
linear=linear, skew_invariant=skew, tn_dependent_at_zero=tn_dep,
|
|
238
|
+
greater_is_better=up, residual=residual,
|
|
239
|
+
diagnostics=dict(skew_worst=skew_worst, tn_spread=tn_worst,
|
|
240
|
+
fraction_increasing=up_frac),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def register_measure(name: str, fn: Formula, *, aliases: Sequence[str] = (),
|
|
245
|
+
note: str = "", overwrite: bool = False,
|
|
246
|
+
seed: int = 0, **overrides):
|
|
247
|
+
"""Add a user-supplied measure, inferring what is not given explicitly.
|
|
248
|
+
|
|
249
|
+
This is the generative claim as an operation: supply a binary formula and the
|
|
250
|
+
measure becomes available under all four aggregation operators, with the
|
|
251
|
+
properties that determine which identities apply to it derived automatically.
|
|
252
|
+
Any property may be overridden by keyword if it is known analytically.
|
|
253
|
+
|
|
254
|
+
Returns the registered :class:`~aggmetrics.measures.BinaryMeasure`.
|
|
255
|
+
|
|
256
|
+
Examples
|
|
257
|
+
--------
|
|
258
|
+
>>> import numpy as np
|
|
259
|
+
>>> from aggmetrics import IndicatorMatrix, register_measure
|
|
260
|
+
>>> # Prevalence-threshold, a measure not in the registry
|
|
261
|
+
>>> def prevalence_threshold(tp, fp, fn, tn):
|
|
262
|
+
... r = tp / (tp + fn); s = tn / (tn + fp)
|
|
263
|
+
... d = r + s - 1
|
|
264
|
+
... return (np.sqrt(r * (1 - s)) + s - 1) / d if abs(d) > 1e-12 else np.nan
|
|
265
|
+
>>> m = register_measure('prevalence_threshold', prevalence_threshold)
|
|
266
|
+
>>> m.decomposable, m.skew_invariant
|
|
267
|
+
(False, True)
|
|
268
|
+
>>> im = IndicatorMatrix([0, 1, 2, 2, 1, 0], [0, 2, 2, 1, 1, 1],
|
|
269
|
+
... task='multiclass')
|
|
270
|
+
>>> isinstance(im.compute('prevalence_threshold', 'macro'), float)
|
|
271
|
+
True
|
|
272
|
+
"""
|
|
273
|
+
from .measures import _REGISTRY, BinaryMeasure # local import: avoid a cycle
|
|
274
|
+
|
|
275
|
+
key = name.strip().lower().replace("-", "_").replace(" ", "_")
|
|
276
|
+
if key in _REGISTRY and not overwrite:
|
|
277
|
+
raise ValueError(f"measure {name!r} already registered; "
|
|
278
|
+
"pass overwrite=True to replace it")
|
|
279
|
+
|
|
280
|
+
inferred = infer_properties(fn, seed=seed)
|
|
281
|
+
kwargs = dict(
|
|
282
|
+
linear=inferred.linear,
|
|
283
|
+
skew_invariant=inferred.skew_invariant,
|
|
284
|
+
tn_dependent_at_zero=inferred.tn_dependent_at_zero,
|
|
285
|
+
greater_is_better=inferred.greater_is_better,
|
|
286
|
+
)
|
|
287
|
+
unknown = set(overrides) - set(kwargs)
|
|
288
|
+
if unknown:
|
|
289
|
+
raise TypeError(f"unexpected override(s): {sorted(unknown)}")
|
|
290
|
+
kwargs.update(overrides)
|
|
291
|
+
|
|
292
|
+
measure = BinaryMeasure(name=key, fn=fn, aliases=tuple(aliases),
|
|
293
|
+
note=note, **kwargs)
|
|
294
|
+
_REGISTRY[key] = measure
|
|
295
|
+
for a in aliases:
|
|
296
|
+
_REGISTRY[a.strip().lower().replace("-", "_").replace(" ", "_")] = measure
|
|
297
|
+
return measure
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def verify_inference(names: Optional[Sequence[str]] = None,
|
|
301
|
+
*, seed: int = 0) -> Dict[str, dict]:
|
|
302
|
+
"""Compare inferred properties against the registry's analytic annotations.
|
|
303
|
+
|
|
304
|
+
Returns a mapping from measure name to a record of agreement. Used by the test
|
|
305
|
+
suite: inference must reproduce every hand-derived property, which is a check
|
|
306
|
+
on the inference and on the annotations at once.
|
|
307
|
+
"""
|
|
308
|
+
from .measures import get_measure, list_measures
|
|
309
|
+
|
|
310
|
+
out: Dict[str, dict] = {}
|
|
311
|
+
for n in (names or list_measures()):
|
|
312
|
+
meas = get_measure(n)
|
|
313
|
+
inf = infer_properties(meas.fn, seed=seed)
|
|
314
|
+
rec = dict(
|
|
315
|
+
decomposable_declared=meas.decomposable,
|
|
316
|
+
decomposable_inferred=inf.decomposable,
|
|
317
|
+
skew_declared=meas.skew_invariant,
|
|
318
|
+
skew_inferred=inf.skew_invariant,
|
|
319
|
+
tn_declared=meas.tn_dependent_at_zero,
|
|
320
|
+
tn_inferred=inf.tn_dependent_at_zero,
|
|
321
|
+
direction_declared=meas.greater_is_better,
|
|
322
|
+
direction_inferred=inf.greater_is_better,
|
|
323
|
+
)
|
|
324
|
+
rec["agrees"] = (
|
|
325
|
+
rec["decomposable_declared"] == rec["decomposable_inferred"]
|
|
326
|
+
and rec["skew_declared"] == rec["skew_inferred"]
|
|
327
|
+
and rec["tn_declared"] == rec["tn_inferred"]
|
|
328
|
+
and rec["direction_declared"] == rec["direction_inferred"]
|
|
329
|
+
)
|
|
330
|
+
if meas.decomposable and inf.decomposable:
|
|
331
|
+
a0, b0 = meas.linear
|
|
332
|
+
a1, b1 = inf.linear
|
|
333
|
+
a0, b0 = np.asarray(a0, float), np.asarray(b0, float)
|
|
334
|
+
k = b0[np.argmax(np.abs(b0))]
|
|
335
|
+
rec["coeff_max_error"] = float(max(
|
|
336
|
+
np.max(np.abs(a0 / k - np.asarray(a1))),
|
|
337
|
+
np.max(np.abs(b0 / k - np.asarray(b1)))))
|
|
338
|
+
out[meas.name] = rec
|
|
339
|
+
return out
|