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/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)