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.
@@ -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
@@ -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