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
aggmetrics/measures.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# Copyright (c) 2026, Ronaldo C. Prati
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""
|
|
4
|
+
Binary performance measures, each carrying the metadata the framework needs to
|
|
5
|
+
reason about it.
|
|
6
|
+
|
|
7
|
+
A measure is not merely a function of the four confusion-matrix counts. Whether
|
|
8
|
+
the framework's identities apply to it depends on structural properties that are
|
|
9
|
+
recorded here alongside the formula:
|
|
10
|
+
|
|
11
|
+
``linear``
|
|
12
|
+
Coefficients :math:`(\\mathbf{a}, \\mathbf{b})` such that
|
|
13
|
+
:math:`M = \\langle a, v\\rangle / \\langle b, v\\rangle` with
|
|
14
|
+
:math:`v = (tp, fp, fn, tn)`, or ``None`` if no such representation exists.
|
|
15
|
+
A measure with coefficients is *aggregation-decomposable*: micro-averaging
|
|
16
|
+
equals denominator-weighted macro-averaging. Without them the identity fails,
|
|
17
|
+
and the pooled value need not even lie in the convex hull of the per-class
|
|
18
|
+
values.
|
|
19
|
+
|
|
20
|
+
``skew_invariant``
|
|
21
|
+
Whether the measure is unchanged when the two classes are rescaled
|
|
22
|
+
independently, equivalently whether it is a function of recall and
|
|
23
|
+
specificity alone.
|
|
24
|
+
|
|
25
|
+
``tn_dependent_at_zero``
|
|
26
|
+
Whether the value at ``tp == 0`` still depends on ``tn``. This determines
|
|
27
|
+
whether the measure can attain its nominal minimum under one-hot multiclass
|
|
28
|
+
encoding, where an incorrectly classified example still contributes ``m - 2``
|
|
29
|
+
true negatives.
|
|
30
|
+
|
|
31
|
+
``greater_is_better``
|
|
32
|
+
Direction, needed for ranking and for the scikit-learn scorer interface.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import math
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import Callable, Dict, Optional, Sequence, Tuple
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
__all__ = ["BinaryMeasure", "MEASURES", "get_measure", "list_measures"]
|
|
43
|
+
|
|
44
|
+
Counts = Tuple[float, float, float, float] # (tp, fp, fn, tn)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ratio(num: float, den: float) -> float:
|
|
48
|
+
"""Divide, returning NaN when the denominator vanishes.
|
|
49
|
+
|
|
50
|
+
NaN is used deliberately rather than 0: an undefined per-class value is not
|
|
51
|
+
a value of zero, and the aggregation layer needs to distinguish them in order
|
|
52
|
+
to apply the exclusion convention.
|
|
53
|
+
"""
|
|
54
|
+
return num / den if abs(den) > 1e-12 else float("nan")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class BinaryMeasure:
|
|
59
|
+
"""A binary measure together with its structural properties."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
fn: Callable[[float, float, float, float], float]
|
|
63
|
+
greater_is_better: bool = True
|
|
64
|
+
linear: Optional[Tuple[Sequence[float], Sequence[float]]] = None
|
|
65
|
+
skew_invariant: bool = False
|
|
66
|
+
tn_dependent_at_zero: bool = False
|
|
67
|
+
aliases: Tuple[str, ...] = field(default_factory=tuple)
|
|
68
|
+
note: str = ""
|
|
69
|
+
|
|
70
|
+
# -- structural queries ------------------------------------------------
|
|
71
|
+
@property
|
|
72
|
+
def decomposable(self) -> bool:
|
|
73
|
+
"""True iff micro-averaging equals denominator-weighted macro-averaging."""
|
|
74
|
+
return self.linear is not None
|
|
75
|
+
|
|
76
|
+
def numerator(self, counts: Counts) -> float:
|
|
77
|
+
if self.linear is None:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"{self.name!r} is not aggregation-decomposable, so it has no "
|
|
80
|
+
"numerator that aggregates additively across classes."
|
|
81
|
+
)
|
|
82
|
+
a, _ = self.linear
|
|
83
|
+
return float(np.dot(a, counts))
|
|
84
|
+
|
|
85
|
+
def denominator(self, counts: Counts) -> float:
|
|
86
|
+
if self.linear is None:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"{self.name!r} is not aggregation-decomposable, so it has no "
|
|
89
|
+
"denominator that aggregates additively across classes."
|
|
90
|
+
)
|
|
91
|
+
_, b = self.linear
|
|
92
|
+
return float(np.dot(b, counts))
|
|
93
|
+
|
|
94
|
+
def bayes_threshold(self) -> Optional[float]:
|
|
95
|
+
"""Optimal decision threshold on the conditional probability, or None.
|
|
96
|
+
|
|
97
|
+
For a measure whose numerator is linear with coefficients
|
|
98
|
+
``(alpha, -beta, -gamma, delta)`` and whose denominator does not depend on
|
|
99
|
+
the predictions, the population-optimal rule is to predict positive when
|
|
100
|
+
the conditional probability exceeds
|
|
101
|
+
``(beta + delta) / (alpha + beta + gamma + delta)``.
|
|
102
|
+
|
|
103
|
+
Returns None when the measure is not of that form, in which case no such
|
|
104
|
+
closed form exists and an iterative procedure is required.
|
|
105
|
+
"""
|
|
106
|
+
if self.linear is None:
|
|
107
|
+
return None
|
|
108
|
+
a, b = self.linear
|
|
109
|
+
# denominator must be prediction-independent: it may combine only
|
|
110
|
+
# (tp + fn) and (fp + tn), the two class totals.
|
|
111
|
+
b = np.asarray(b, dtype=float)
|
|
112
|
+
if not (math.isclose(b[0], b[2]) and math.isclose(b[1], b[3])):
|
|
113
|
+
return None
|
|
114
|
+
alpha, beta, gamma, delta = a[0], -a[1], -a[2], a[3]
|
|
115
|
+
total = alpha + beta + gamma + delta
|
|
116
|
+
if abs(total) < 1e-12:
|
|
117
|
+
return None
|
|
118
|
+
return float((beta + delta) / total)
|
|
119
|
+
|
|
120
|
+
def erm_consistent(self) -> Optional[bool]:
|
|
121
|
+
"""Whether the measure is maximised by the Bayes rule for 0/1 loss."""
|
|
122
|
+
t = self.bayes_threshold()
|
|
123
|
+
return None if t is None else math.isclose(t, 0.5, abs_tol=1e-9)
|
|
124
|
+
|
|
125
|
+
def attainable_floor(self, m: int) -> float:
|
|
126
|
+
"""Value attained under one-hot encoding when every prediction is wrong.
|
|
127
|
+
|
|
128
|
+
With ``m`` classes and ``n`` examples all misclassified, the pooled counts
|
|
129
|
+
are ``tp = 0``, ``fp = fn = n`` and ``tn = n (m - 2)``. Measures that do
|
|
130
|
+
not depend on ``tn`` at ``tp = 0`` reach their nominal minimum; the rest
|
|
131
|
+
are bounded away from it by an amount that grows with ``m``.
|
|
132
|
+
"""
|
|
133
|
+
if m < 2:
|
|
134
|
+
raise ValueError("m must be at least 2")
|
|
135
|
+
n = 1000.0
|
|
136
|
+
return float(self.fn(0.0, n, n, n * (m - 2)))
|
|
137
|
+
|
|
138
|
+
def __call__(self, tp: float, fp: float, fn: float, tn: float) -> float:
|
|
139
|
+
return float(self.fn(tp, fp, fn, tn))
|
|
140
|
+
|
|
141
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
142
|
+
tags = ["decomposable" if self.decomposable else "non-decomposable"]
|
|
143
|
+
if self.skew_invariant:
|
|
144
|
+
tags.append("skew-invariant")
|
|
145
|
+
return f"<BinaryMeasure {self.name!r} ({', '.join(tags)})>"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Formulas
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _recall(tp, fp, fn, tn): return _ratio(tp, tp + fn)
|
|
153
|
+
def _specificity(tp, fp, fn, tn): return _ratio(tn, tn + fp)
|
|
154
|
+
def _precision(tp, fp, fn, tn): return _ratio(tp, tp + fp)
|
|
155
|
+
def _npv(tp, fp, fn, tn): return _ratio(tn, tn + fn)
|
|
156
|
+
def _fpr(tp, fp, fn, tn): return _ratio(fp, fp + tn)
|
|
157
|
+
def _fnr(tp, fp, fn, tn): return _ratio(fn, tp + fn)
|
|
158
|
+
def _fdr(tp, fp, fn, tn): return _ratio(fp, tp + fp)
|
|
159
|
+
def _for(tp, fp, fn, tn): return _ratio(fn, fn + tn)
|
|
160
|
+
def _jaccard(tp, fp, fn, tn): return _ratio(tp, tp + fp + fn)
|
|
161
|
+
def _label_accuracy(tp, fp, fn, tn): return _ratio(tp + tn, tp + fp + fn + tn)
|
|
162
|
+
def _error_rate(tp, fp, fn, tn): return _ratio(fp + fn, tp + fp + fn + tn)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _fbeta(beta: float):
|
|
166
|
+
b2 = beta * beta
|
|
167
|
+
|
|
168
|
+
def f(tp, fp, fn, tn):
|
|
169
|
+
return _ratio((1 + b2) * tp, (1 + b2) * tp + b2 * fn + fp)
|
|
170
|
+
|
|
171
|
+
return f
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _balanced_accuracy(tp, fp, fn, tn):
|
|
175
|
+
r, s = _recall(tp, fp, fn, tn), _specificity(tp, fp, fn, tn)
|
|
176
|
+
return float("nan") if (math.isnan(r) or math.isnan(s)) else 0.5 * (r + s)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _gmean(tp, fp, fn, tn):
|
|
180
|
+
r, s = _recall(tp, fp, fn, tn), _specificity(tp, fp, fn, tn)
|
|
181
|
+
if math.isnan(r) or math.isnan(s):
|
|
182
|
+
return float("nan")
|
|
183
|
+
return math.sqrt(max(r * s, 0.0))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _informedness(tp, fp, fn, tn):
|
|
187
|
+
r, s = _recall(tp, fp, fn, tn), _specificity(tp, fp, fn, tn)
|
|
188
|
+
return float("nan") if (math.isnan(r) or math.isnan(s)) else r + s - 1.0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _markedness(tp, fp, fn, tn):
|
|
192
|
+
p, n = _precision(tp, fp, fn, tn), _npv(tp, fp, fn, tn)
|
|
193
|
+
return float("nan") if (math.isnan(p) or math.isnan(n)) else p + n - 1.0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _mcc(tp, fp, fn, tn):
|
|
197
|
+
den = math.sqrt((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn))
|
|
198
|
+
return _ratio(tp * tn - fp * fn, den)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _kappa(tp, fp, fn, tn):
|
|
202
|
+
n = tp + fp + fn + tn
|
|
203
|
+
if n <= 0:
|
|
204
|
+
return float("nan")
|
|
205
|
+
po = (tp + tn) / n
|
|
206
|
+
pe = ((tp + fn) * (tp + fp) + (tn + fp) * (tn + fn)) / (n * n)
|
|
207
|
+
return _ratio(po - pe, 1.0 - pe)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _fowlkes_mallows(tp, fp, fn, tn):
|
|
211
|
+
p, r = _precision(tp, fp, fn, tn), _recall(tp, fp, fn, tn)
|
|
212
|
+
if math.isnan(p) or math.isnan(r):
|
|
213
|
+
return float("nan")
|
|
214
|
+
return math.sqrt(max(p * r, 0.0))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _youden_like_ratio(num_fn, den_fn):
|
|
218
|
+
def f(tp, fp, fn, tn):
|
|
219
|
+
a, b = num_fn(tp, fp, fn, tn), den_fn(tp, fp, fn, tn)
|
|
220
|
+
return _ratio(a, b)
|
|
221
|
+
return f
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Registry
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Coefficient order throughout is (tp, fp, fn, tn).
|
|
228
|
+
|
|
229
|
+
def _m(*args, **kwargs) -> BinaryMeasure:
|
|
230
|
+
return BinaryMeasure(*args, **kwargs)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_REGISTRY: Dict[str, BinaryMeasure] = {}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _register(measure: BinaryMeasure) -> BinaryMeasure:
|
|
237
|
+
_REGISTRY[measure.name] = measure
|
|
238
|
+
for alias in measure.aliases:
|
|
239
|
+
_REGISTRY[alias] = measure
|
|
240
|
+
return measure
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# -- decomposable, skew-invariant --------------------------------------------
|
|
244
|
+
_register(_m("recall", _recall, linear=((1, 0, 0, 0), (1, 0, 1, 0)),
|
|
245
|
+
skew_invariant=True, aliases=("sensitivity", "tpr", "hit_rate")))
|
|
246
|
+
_register(_m("specificity", _specificity, linear=((0, 0, 0, 1), (0, 1, 0, 1)),
|
|
247
|
+
skew_invariant=True, aliases=("tnr", "selectivity"),
|
|
248
|
+
tn_dependent_at_zero=True))
|
|
249
|
+
_register(_m("fnr", _fnr, greater_is_better=False,
|
|
250
|
+
linear=((0, 0, 1, 0), (1, 0, 1, 0)), skew_invariant=True,
|
|
251
|
+
aliases=("miss_rate",)))
|
|
252
|
+
_register(_m("fpr", _fpr, greater_is_better=False,
|
|
253
|
+
linear=((0, 1, 0, 0), (0, 1, 0, 1)), skew_invariant=True,
|
|
254
|
+
tn_dependent_at_zero=True, aliases=("fall_out",),
|
|
255
|
+
note="Lower is better, and its value at tp=0 depends on tn: under "
|
|
256
|
+
"one-hot encoding a wholly wrong classifier attains 1/(m-1), "
|
|
257
|
+
"which looks good rather than bad."))
|
|
258
|
+
|
|
259
|
+
# -- decomposable, not skew-invariant ----------------------------------------
|
|
260
|
+
_register(_m("precision", _precision, linear=((1, 0, 0, 0), (1, 1, 0, 0)),
|
|
261
|
+
aliases=("ppv",)))
|
|
262
|
+
_register(_m("npv", _npv, linear=((0, 0, 0, 1), (0, 0, 1, 1)),
|
|
263
|
+
tn_dependent_at_zero=True))
|
|
264
|
+
_register(_m("fdr", _fdr, greater_is_better=False,
|
|
265
|
+
linear=((0, 1, 0, 0), (1, 1, 0, 0))))
|
|
266
|
+
_register(_m("for", _for, greater_is_better=False,
|
|
267
|
+
linear=((0, 0, 1, 0), (0, 0, 1, 1)), tn_dependent_at_zero=True))
|
|
268
|
+
_register(_m("f1", _fbeta(1.0), linear=((2, 0, 0, 0), (2, 1, 1, 0)),
|
|
269
|
+
aliases=("dice", "f_measure", "fbeta1")))
|
|
270
|
+
_register(_m("f2", _fbeta(2.0), linear=((5, 0, 0, 0), (5, 1, 4, 0))))
|
|
271
|
+
_register(_m("f0.5", _fbeta(0.5), linear=((1.25, 0, 0, 0), (1.25, 1, 0.25, 0))))
|
|
272
|
+
_register(_m("jaccard", _jaccard, linear=((1, 0, 0, 0), (1, 1, 1, 0)),
|
|
273
|
+
aliases=("iou", "csi", "tanimoto")))
|
|
274
|
+
_register(_m("label_accuracy", _label_accuracy,
|
|
275
|
+
linear=((1, 0, 0, 1), (1, 1, 1, 1)), tn_dependent_at_zero=True,
|
|
276
|
+
aliases=("accuracy_labelwise",),
|
|
277
|
+
note="For one-hot multiclass this is not standard accuracy; "
|
|
278
|
+
"see IndicatorMatrix.accuracy()."))
|
|
279
|
+
_register(_m("error_rate", _error_rate, greater_is_better=False,
|
|
280
|
+
linear=((0, 1, 1, 0), (1, 1, 1, 1)), tn_dependent_at_zero=True,
|
|
281
|
+
aliases=("hamming_loss",),
|
|
282
|
+
note="Lower is better, and its value at tp=0 depends on tn: under "
|
|
283
|
+
"one-hot encoding a wholly wrong classifier attains 2/m "
|
|
284
|
+
"rather than 1."))
|
|
285
|
+
|
|
286
|
+
# -- not decomposable --------------------------------------------------------
|
|
287
|
+
_register(_m("balanced_accuracy", _balanced_accuracy, skew_invariant=True,
|
|
288
|
+
tn_dependent_at_zero=True, aliases=("ba",)))
|
|
289
|
+
_register(_m("gmean", _gmean, skew_invariant=True,
|
|
290
|
+
tn_dependent_at_zero=False,
|
|
291
|
+
note="Complete (uses all four cells) yet attains 0 at tp=0, "
|
|
292
|
+
"because recall annihilates the product.",
|
|
293
|
+
aliases=("g_mean", "geometric_mean")))
|
|
294
|
+
_register(_m("informedness", _informedness, skew_invariant=True,
|
|
295
|
+
tn_dependent_at_zero=True, aliases=("youden_j", "bookmaker")))
|
|
296
|
+
_register(_m("markedness", _markedness, tn_dependent_at_zero=True,
|
|
297
|
+
aliases=("deltap",)))
|
|
298
|
+
_register(_m("mcc", _mcc, tn_dependent_at_zero=True,
|
|
299
|
+
aliases=("matthews", "phi")))
|
|
300
|
+
_register(_m("kappa", _kappa, tn_dependent_at_zero=True,
|
|
301
|
+
aliases=("cohen_kappa",)))
|
|
302
|
+
_register(_m("fowlkes_mallows", _fowlkes_mallows, aliases=("fm",)))
|
|
303
|
+
|
|
304
|
+
MEASURES: Dict[str, BinaryMeasure] = _REGISTRY
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def get_measure(name: str | BinaryMeasure) -> BinaryMeasure:
|
|
308
|
+
"""Look up a measure by name or alias."""
|
|
309
|
+
if isinstance(name, BinaryMeasure):
|
|
310
|
+
return name
|
|
311
|
+
key = str(name).strip().lower().replace("-", "_").replace(" ", "_")
|
|
312
|
+
if key not in _REGISTRY:
|
|
313
|
+
raise KeyError(
|
|
314
|
+
f"unknown measure {name!r}. Available: "
|
|
315
|
+
+ ", ".join(sorted({m.name for m in _REGISTRY.values()}))
|
|
316
|
+
)
|
|
317
|
+
return _REGISTRY[key]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def list_measures(decomposable: Optional[bool] = None,
|
|
321
|
+
skew_invariant: Optional[bool] = None) -> list[str]:
|
|
322
|
+
"""Canonical measure names, optionally filtered by structural property."""
|
|
323
|
+
out = sorted({m.name: m for m in _REGISTRY.values()}.values(),
|
|
324
|
+
key=lambda m: m.name)
|
|
325
|
+
if decomposable is not None:
|
|
326
|
+
out = [m for m in out if m.decomposable is decomposable]
|
|
327
|
+
if skew_invariant is not None:
|
|
328
|
+
out = [m for m in out if m.skew_invariant is skew_invariant]
|
|
329
|
+
return [m.name for m in out]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Copyright (c) 2026, Ronaldo C. Prati
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""
|
|
4
|
+
scikit-learn interoperability.
|
|
5
|
+
|
|
6
|
+
``make_scorer`` produces an object usable anywhere scikit-learn accepts a
|
|
7
|
+
``scoring`` argument, giving access to combinations the library does not expose ---
|
|
8
|
+
macro-MCC, exemplar-Jaccard, weighted informedness --- while keeping the sign
|
|
9
|
+
convention that greater is always better.
|
|
10
|
+
|
|
11
|
+
``sklearn_average`` documents the correspondence with the ``average`` parameter,
|
|
12
|
+
which is exact:
|
|
13
|
+
|
|
14
|
+
=============================== ==================
|
|
15
|
+
``sklearn`` ``average`` operator
|
|
16
|
+
=============================== ==================
|
|
17
|
+
``'micro'`` ``sigma_1``
|
|
18
|
+
``'macro'`` ``sigma_m`` (uniform weights)
|
|
19
|
+
``'weighted'`` ``sigma_m`` (prevalence weights)
|
|
20
|
+
``'samples'`` ``sigma_n``
|
|
21
|
+
=============================== ==================
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Callable, Optional
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from .core import IndicatorMatrix
|
|
30
|
+
from .measures import get_measure
|
|
31
|
+
|
|
32
|
+
__all__ = ["make_scorer", "score", "sklearn_average", "SKLEARN_AVERAGE_MAP"]
|
|
33
|
+
|
|
34
|
+
SKLEARN_AVERAGE_MAP = {
|
|
35
|
+
"micro": "micro",
|
|
36
|
+
"macro": "macro",
|
|
37
|
+
"weighted": "weighted",
|
|
38
|
+
"samples": "exemplar",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sklearn_average(average: Optional[str]) -> str:
|
|
43
|
+
"""Translate a scikit-learn ``average`` value into an operator name."""
|
|
44
|
+
if average is None:
|
|
45
|
+
return "macro"
|
|
46
|
+
if average not in SKLEARN_AVERAGE_MAP:
|
|
47
|
+
raise ValueError(f"unknown average {average!r}; expected one of "
|
|
48
|
+
f"{sorted(SKLEARN_AVERAGE_MAP)}")
|
|
49
|
+
return SKLEARN_AVERAGE_MAP[average]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def score(y_true, y_pred, measure: str, aggregation: str = "micro",
|
|
53
|
+
*, task: Optional[str] = None, convention: str = "exclude",
|
|
54
|
+
threshold: float = 0.5) -> float:
|
|
55
|
+
"""One-shot scoring function, in the style of ``sklearn.metrics``."""
|
|
56
|
+
im = IndicatorMatrix(y_true, y_pred, task=task, threshold=threshold)
|
|
57
|
+
return im.compute(measure, aggregation, convention=convention)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def make_scorer(measure: str, aggregation: str = "micro",
|
|
61
|
+
*, task: Optional[str] = None, convention: str = "exclude",
|
|
62
|
+
threshold: float = 0.5) -> Callable:
|
|
63
|
+
"""Build a scikit-learn compatible scorer.
|
|
64
|
+
|
|
65
|
+
The returned object follows the ``scorer(estimator, X, y)`` protocol and
|
|
66
|
+
negates measures for which smaller is better, so that a larger score is always
|
|
67
|
+
preferable --- the convention scikit-learn's model-selection tools assume.
|
|
68
|
+
|
|
69
|
+
Examples
|
|
70
|
+
--------
|
|
71
|
+
>>> from aggmetrics.sklearn_compat import make_scorer # doctest: +SKIP
|
|
72
|
+
>>> macro_mcc = make_scorer('mcc', 'macro') # doctest: +SKIP
|
|
73
|
+
>>> cross_val_score(clf, X, y, scoring=macro_mcc) # doctest: +SKIP
|
|
74
|
+
"""
|
|
75
|
+
meas = get_measure(measure)
|
|
76
|
+
sign = 1.0 if meas.greater_is_better else -1.0
|
|
77
|
+
|
|
78
|
+
def _scorer(estimator, X, y):
|
|
79
|
+
y_pred = estimator.predict(X)
|
|
80
|
+
im = IndicatorMatrix(y, y_pred, task=task, threshold=threshold)
|
|
81
|
+
return sign * im.compute(meas, aggregation, convention=convention)
|
|
82
|
+
|
|
83
|
+
_scorer.__name__ = f"{aggregation}_{meas.name}"
|
|
84
|
+
_scorer.__doc__ = (f"{aggregation}-{meas.name} scorer "
|
|
85
|
+
f"({'greater' if meas.greater_is_better else 'smaller'} "
|
|
86
|
+
f"is better in the underlying measure).")
|
|
87
|
+
return _scorer
|
aggmetrics/tnorms.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Copyright (c) 2026, Ronaldo C. Prati
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""
|
|
4
|
+
Triangular norms for soft and uncertain ground truth.
|
|
5
|
+
|
|
6
|
+
When the label matrix is soft --- annotator proportions, crowd labels, or a
|
|
7
|
+
probabilistic reference standard --- the Boolean conjunction that defines the four
|
|
8
|
+
cells must be replaced by a t-norm :math:`T`, with the standard complement
|
|
9
|
+
:math:`N(x) = 1 - x`:
|
|
10
|
+
|
|
11
|
+
.. math::
|
|
12
|
+
TP = T(y, \\hat{y}),\\quad FP = T(1-y, \\hat{y}),\\quad
|
|
13
|
+
FN = T(y, 1-\\hat{y}),\\quad TN = T(1-y, 1-\\hat{y}).
|
|
14
|
+
|
|
15
|
+
The product t-norm is the only choice for which the resulting cells preserve the
|
|
16
|
+
row and column marginals,
|
|
17
|
+
|
|
18
|
+
.. math:: TP + FN = y, \\qquad TP + FP = \\hat{y},
|
|
19
|
+
|
|
20
|
+
and hence the only one under which the fuzzy confusion matrix behaves like a
|
|
21
|
+
contingency table. :func:`marginal_defect` measures departure from that property,
|
|
22
|
+
and returns zero only for ``product``.
|
|
23
|
+
|
|
24
|
+
Note that the pointwise ordering :math:`T_L \\le T_P \\le T_M` applies to *all four*
|
|
25
|
+
cell matrices in the same direction, since all four are produced by the same
|
|
26
|
+
t-norm. It therefore implies nothing about the direction in which a derived ratio
|
|
27
|
+
moves: numerator and denominator shrink together.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Callable, Dict
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
35
|
+
__all__ = ["TNORMS", "get_tnorm", "marginal_defect", "godel", "product",
|
|
36
|
+
"lukasiewicz"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def godel(a, b):
|
|
40
|
+
"""Minimum (Gödel) t-norm: the largest t-norm."""
|
|
41
|
+
return np.minimum(a, b)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def product(a, b):
|
|
45
|
+
"""Product t-norm: the unique marginally consistent choice."""
|
|
46
|
+
return np.asarray(a) * np.asarray(b)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lukasiewicz(a, b):
|
|
50
|
+
"""Łukasiewicz t-norm: credits only overlap above the unit threshold."""
|
|
51
|
+
return np.maximum(0.0, np.asarray(a) + np.asarray(b) - 1.0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hamacher(gamma: float = 1.0) -> Callable:
|
|
55
|
+
"""Hamacher family; ``gamma == 1`` recovers the product t-norm."""
|
|
56
|
+
def T(a, b):
|
|
57
|
+
a, b = np.asarray(a, float), np.asarray(b, float)
|
|
58
|
+
den = gamma + (1.0 - gamma) * (a + b - a * b)
|
|
59
|
+
out = np.zeros_like(a)
|
|
60
|
+
nz = np.abs(den) > 1e-12
|
|
61
|
+
out[nz] = (a * b)[nz] / den[nz]
|
|
62
|
+
return out
|
|
63
|
+
return T
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
TNORMS: Dict[str, Callable] = {
|
|
67
|
+
"godel": godel,
|
|
68
|
+
"minimum": godel,
|
|
69
|
+
"min": godel,
|
|
70
|
+
"product": product,
|
|
71
|
+
"prod": product,
|
|
72
|
+
"lukasiewicz": lukasiewicz,
|
|
73
|
+
"luk": lukasiewicz,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_tnorm(name: str | Callable) -> Callable:
|
|
78
|
+
if callable(name):
|
|
79
|
+
return name
|
|
80
|
+
key = str(name).strip().lower()
|
|
81
|
+
if key not in TNORMS:
|
|
82
|
+
raise KeyError(f"unknown t-norm {name!r}. Available: "
|
|
83
|
+
f"{', '.join(sorted(set(TNORMS)))}")
|
|
84
|
+
return TNORMS[key]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def marginal_defect(tnorm: str | Callable, grid: int = 21) -> float:
|
|
88
|
+
"""Maximum violation of marginal consistency over a grid of ``[0,1]^2``.
|
|
89
|
+
|
|
90
|
+
Zero iff ``T(a,b) + T(a,1-b) == a`` and ``T(a,b) + T(1-a,b) == b`` throughout,
|
|
91
|
+
which holds only for the product t-norm.
|
|
92
|
+
"""
|
|
93
|
+
T = get_tnorm(tnorm)
|
|
94
|
+
xs = np.linspace(0.0, 1.0, grid)
|
|
95
|
+
A, B = np.meshgrid(xs, xs, indexing="ij")
|
|
96
|
+
d1 = np.abs(T(A, B) + T(A, 1 - B) - A)
|
|
97
|
+
d2 = np.abs(T(A, B) + T(1 - A, B) - B)
|
|
98
|
+
return float(max(d1.max(), d2.max()))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def soft_cells(Y, P, tnorm="product"):
|
|
102
|
+
"""Fuzzy TP, FP, FN, TN matrices under the given t-norm."""
|
|
103
|
+
T = get_tnorm(tnorm)
|
|
104
|
+
Y = np.asarray(Y, float)
|
|
105
|
+
P = np.asarray(P, float)
|
|
106
|
+
if Y.shape != P.shape:
|
|
107
|
+
raise ValueError(f"shape mismatch: {Y.shape} vs {P.shape}")
|
|
108
|
+
for name, arr in (("Y", Y), ("P", P)):
|
|
109
|
+
if arr.min() < -1e-9 or arr.max() > 1 + 1e-9:
|
|
110
|
+
raise ValueError(f"{name} must lie in [0, 1]")
|
|
111
|
+
return T(Y, P), T(1 - Y, P), T(Y, 1 - P), T(1 - Y, 1 - P)
|