evalsuite-python 0.1.0a1__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 @@
1
+ """Core infrastructure: exceptions, validation, results, registry and evaluation context."""
@@ -0,0 +1,181 @@
1
+ """EvaluationContext: validate inputs once and cache shared intermediate results.
2
+
3
+ Every classification metric derives from per-class counts (TP, FP, FN, TN). The context computes the
4
+ confusion matrix once; ``evaluate()`` passes one context to all requested metrics, so ten metrics cost one
5
+ pass over the data.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import cached_property
11
+ from typing import Any, Optional
12
+
13
+ import numpy as np
14
+ from numpy.typing import NDArray
15
+
16
+ from .exceptions import InputValidationError, UnsupportedTaskError
17
+ from .types import ArrayLike, FloatArray
18
+ from .validation import (
19
+ TargetType,
20
+ check_consistent_length,
21
+ check_finite,
22
+ resolve_labels,
23
+ target_type,
24
+ to_numpy,
25
+ validate_probabilities,
26
+ validate_sample_weight,
27
+ )
28
+
29
+ __all__ = ["ClassificationContext"]
30
+
31
+
32
+ class ClassificationContext:
33
+ """Validated classification inputs plus cached counts.
34
+
35
+ For binary and multiclass targets, ``labels`` defines the class order used by every per-class output and
36
+ by the columns of 2-D ``y_prob``. For multilabel targets, labels are column indices.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ y_true: ArrayLike,
42
+ y_pred: Optional[ArrayLike] = None,
43
+ *,
44
+ y_prob: Optional[ArrayLike] = None,
45
+ labels: Optional[ArrayLike] = None,
46
+ sample_weight: Optional[ArrayLike] = None,
47
+ ) -> None:
48
+ yt = to_numpy(y_true, "y_true", allow_2d=True)
49
+ yp = None if y_pred is None else to_numpy(y_pred, "y_pred", allow_2d=True)
50
+ check_finite(yt, "y_true")
51
+ if yp is not None:
52
+ check_finite(yp, "y_pred")
53
+ self.n = check_consistent_length(y_true=yt, y_pred=yp)
54
+ kind = target_type(yt)
55
+ if kind == "continuous":
56
+ raise UnsupportedTaskError(
57
+ "y_true contains non-integer numbers, which looks like a regression target. "
58
+ 'Use evalsuite.regression metrics or evaluate(..., task="regression").'
59
+ )
60
+ if yp is not None and yt.ndim != yp.ndim:
61
+ raise InputValidationError(
62
+ f"y_true has {yt.ndim} dimension(s) but y_pred has {yp.ndim}. Multilabel targets need both as "
63
+ "indicator matrices; single-label targets need both as 1-D label arrays."
64
+ )
65
+ if kind == "multilabel" and yp is not None:
66
+ if yp.shape[1] != yt.shape[1]:
67
+ raise InputValidationError(f"y_true has {yt.shape[1]} label columns but y_pred has {yp.shape[1]}.")
68
+ if not np.isin(yp, (0, 1)).all():
69
+ raise InputValidationError("Multilabel y_pred must be a 0/1 indicator matrix.")
70
+ self.target_type: TargetType = kind
71
+ self.y_true = yt
72
+ self.y_pred = yp
73
+ self.sample_weight = validate_sample_weight(sample_weight, self.n)
74
+ if kind == "multilabel":
75
+ self.labels: NDArray[Any] = np.arange(yt.shape[1])
76
+ else:
77
+ self.labels = resolve_labels(yt, yp, labels)
78
+ if labels is not None:
79
+ observed = np.unique(yt if yp is None else np.concatenate([yt, yp]))
80
+ missing = np.setdiff1d(observed, self.labels)
81
+ if missing.size:
82
+ raise InputValidationError(
83
+ f"y_true/y_pred contain label(s) {missing.tolist()} that are not in labels. "
84
+ "Include every label that occurs, or filter the data first."
85
+ )
86
+ if kind == "binary" and self.labels.shape[0] > 2:
87
+ self.target_type = "multiclass"
88
+ self._y_prob_raw = y_prob
89
+
90
+ # ---- encodings -------------------------------------------------------------------------------
91
+ @cached_property
92
+ def _index(self) -> dict[Any, int]:
93
+ return {lab.item() if hasattr(lab, "item") else lab: i for i, lab in enumerate(self.labels)}
94
+
95
+ def encode(self, y: NDArray[Any]) -> NDArray[np.int64]:
96
+ sorted_labels = np.all(self.labels[:-1] <= self.labels[1:]) if self.labels.dtype != object else False
97
+ if sorted_labels:
98
+ return np.searchsorted(self.labels, y).astype(np.int64)
99
+ idx = self._index
100
+ return np.fromiter((idx[v.item() if hasattr(v, "item") else v] for v in y), dtype=np.int64, count=len(y))
101
+
102
+ @cached_property
103
+ def true_idx(self) -> NDArray[np.int64]:
104
+ return self.encode(self.y_true)
105
+
106
+ @cached_property
107
+ def pred_idx(self) -> NDArray[np.int64]:
108
+ if self.y_pred is None:
109
+ raise InputValidationError("This metric needs y_pred (predicted labels).")
110
+ return self.encode(self.y_pred)
111
+
112
+ @cached_property
113
+ def weights(self) -> FloatArray:
114
+ return np.ones(self.n) if self.sample_weight is None else self.sample_weight
115
+
116
+ # ---- cached counts ---------------------------------------------------------------------------
117
+ @cached_property
118
+ def confusion_matrix(self) -> FloatArray:
119
+ """Rows: true labels; columns: predicted labels (weighted counts). Single-label targets only."""
120
+ if self.target_type == "multilabel":
121
+ raise UnsupportedTaskError(
122
+ "A single confusion matrix is not defined for multilabel targets; use multilabel_counts."
123
+ )
124
+ k = self.labels.shape[0]
125
+ flat = self.true_idx * k + self.pred_idx
126
+ return np.bincount(flat, weights=self.weights, minlength=k * k).reshape(k, k).astype(np.float64)
127
+
128
+ @cached_property
129
+ def counts(self) -> dict[str, FloatArray]:
130
+ """Per-class (or per-label) weighted tp, fp, fn, tn and support."""
131
+ if self.target_type == "multilabel":
132
+ if self.y_pred is None:
133
+ raise InputValidationError("This metric needs y_pred (predicted labels).")
134
+ w = self.weights[:, None]
135
+ t = self.y_true.astype(bool)
136
+ p = self.y_pred.astype(bool)
137
+ tp = ((t & p) * w).sum(0)
138
+ fp = ((~t & p) * w).sum(0)
139
+ fn = ((t & ~p) * w).sum(0)
140
+ tn = ((~t & ~p) * w).sum(0)
141
+ else:
142
+ cm = self.confusion_matrix
143
+ tp = np.diag(cm).copy()
144
+ fp = cm.sum(0) - tp
145
+ fn = cm.sum(1) - tp
146
+ tn = cm.sum() - tp - fp - fn
147
+ return {"tp": tp, "fp": fp, "fn": fn, "tn": tn, "support": tp + fn}
148
+
149
+ @cached_property
150
+ def total_weight(self) -> float:
151
+ return float(self.weights.sum())
152
+
153
+ # ---- probabilities ---------------------------------------------------------------------------
154
+ @cached_property
155
+ def y_prob(self) -> FloatArray:
156
+ if self._y_prob_raw is None:
157
+ raise InputValidationError(
158
+ "This metric needs predicted probabilities (y_prob): P(positive class) for binary tasks, "
159
+ "or one column per class in label order for multiclass tasks."
160
+ )
161
+ k = self.labels.shape[0]
162
+ p = validate_probabilities(
163
+ self._y_prob_raw,
164
+ self.n,
165
+ n_classes=None if self.target_type == "binary" else k,
166
+ rows_sum_to_one=self.target_type == "multiclass",
167
+ unit="label" if self.target_type == "multilabel" else "class",
168
+ )
169
+ if self.target_type == "binary" and p.ndim == 2:
170
+ if p.shape[1] != 2:
171
+ raise InputValidationError(
172
+ f"For binary tasks y_prob must be P(positive) or two columns; received {p.shape[1]} columns."
173
+ )
174
+ p = p[:, 1]
175
+ if self.target_type == "multiclass" and p.ndim == 1:
176
+ raise InputValidationError(
177
+ f"Multiclass tasks need y_prob with one column per class ({k} columns, in label order)."
178
+ )
179
+ if self.target_type == "multilabel" and (p.ndim != 2 or p.shape[1] != self.y_true.shape[1]):
180
+ raise InputValidationError("Multilabel y_prob must have one probability column per label.")
181
+ return p
@@ -0,0 +1,52 @@
1
+ """Exception hierarchy.
2
+
3
+ Every message says what failed, why, and how to fix it.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ __all__ = [
9
+ "EvalSuiteError",
10
+ "InputValidationError",
11
+ "MetricInputError",
12
+ "OptionalDependencyError",
13
+ "StatisticalTestError",
14
+ "UndefinedMetricWarning",
15
+ "UnsupportedTaskError",
16
+ ]
17
+
18
+
19
+ class EvalSuiteError(Exception):
20
+ """Base class for all EvalSuite errors."""
21
+
22
+
23
+ class InputValidationError(EvalSuiteError, ValueError):
24
+ """Inputs have the wrong shape, length, type or values."""
25
+
26
+
27
+ class MetricInputError(EvalSuiteError, ValueError):
28
+ """Inputs are valid arrays but outside the domain a metric is defined on."""
29
+
30
+
31
+ class UnsupportedTaskError(EvalSuiteError, ValueError):
32
+ """The requested task or averaging mode is not supported for this metric."""
33
+
34
+
35
+ class OptionalDependencyError(EvalSuiteError, ImportError):
36
+ """A feature needs an optional dependency that is not installed."""
37
+
38
+ def __init__(self, package: str, extra: str, feature: str) -> None:
39
+ super().__init__(
40
+ f"{feature} requires the optional dependency '{package}'. "
41
+ f'Install it with: pip install "evalsuite-python[{extra}]"'
42
+ )
43
+ self.package = package
44
+ self.extra = extra
45
+
46
+
47
+ class StatisticalTestError(EvalSuiteError, ValueError):
48
+ """A statistical procedure cannot be applied to the given data."""
49
+
50
+
51
+ class UndefinedMetricWarning(UserWarning):
52
+ """A metric is undefined for the input (for example a zero denominator)."""
@@ -0,0 +1,90 @@
1
+ """Metric registry: programmatic discovery of every metric and its documentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Any, Optional, TypeVar
8
+
9
+ __all__ = ["MetricInfo", "list_metrics", "metric_info", "register"]
10
+
11
+ F = TypeVar("F", bound=Callable[..., Any])
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class MetricInfo:
16
+ """Documentation for one metric. ``id`` is ``"<category>.<name>"``, e.g. ``"classification.f1"``."""
17
+
18
+ id: str
19
+ name: str
20
+ category: str
21
+ task: str
22
+ definition: str
23
+ formula: str
24
+ range: str
25
+ input_requirements: tuple[str, ...] = ()
26
+ references: tuple[str, ...] = ()
27
+ higher_is_better: Optional[bool] = True
28
+ function: Optional[Callable[..., Any]] = field(default=None, repr=False, compare=False)
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ out = asdict(self)
32
+ out.pop("function", None)
33
+ out["input_requirements"] = list(self.input_requirements)
34
+ out["references"] = list(self.references)
35
+ out["api"] = f"evalsuite.{self.id}"
36
+ return out
37
+
38
+
39
+ _REGISTRY: dict[str, MetricInfo] = {}
40
+
41
+
42
+ def register(
43
+ *,
44
+ category: str,
45
+ task: str,
46
+ name: str,
47
+ definition: str,
48
+ formula: str,
49
+ range: str,
50
+ input_requirements: tuple[str, ...] = (),
51
+ references: tuple[str, ...] = (),
52
+ higher_is_better: Optional[bool] = True,
53
+ ) -> Callable[[F], F]:
54
+ """Decorator registering a public metric function under ``<category>.<function name>``."""
55
+
56
+ def deco(fn: F) -> F:
57
+ metric_id = f"{category}.{fn.__name__}"
58
+ if metric_id in _REGISTRY:
59
+ raise RuntimeError(f"Metric {metric_id} is registered twice.")
60
+ _REGISTRY[metric_id] = MetricInfo(
61
+ id=metric_id,
62
+ name=name,
63
+ category=category,
64
+ task=task,
65
+ definition=definition,
66
+ formula=formula,
67
+ range=range,
68
+ input_requirements=input_requirements,
69
+ references=references,
70
+ higher_is_better=higher_is_better,
71
+ function=fn,
72
+ )
73
+ return fn
74
+
75
+ return deco
76
+
77
+
78
+ def list_metrics(category: Optional[str] = None) -> list[str]:
79
+ """Sorted metric ids, optionally filtered by category (``"classification"``, ``"regression"``)."""
80
+ return sorted(k for k, v in _REGISTRY.items() if category is None or v.category == category)
81
+
82
+
83
+ def metric_info(metric_id: str) -> MetricInfo:
84
+ """Documentation for one metric, e.g. ``metric_info("classification.mcc")``."""
85
+ try:
86
+ return _REGISTRY[metric_id]
87
+ except KeyError:
88
+ close = [k for k in _REGISTRY if metric_id.split(".")[-1] in k]
89
+ hint = f" Did you mean: {', '.join(close)}?" if close else " Use evalsuite.list_metrics() to see all ids."
90
+ raise KeyError(f"Unknown metric '{metric_id}'.{hint}") from None
@@ -0,0 +1,277 @@
1
+ """Immutable result objects with portable exports (dict, JSON, DataFrame, Markdown, LaTeX).
2
+
3
+ JSON uses the standard library only; NaN and infinity become ``null`` so the output is valid JSON.
4
+ Pickle is never used.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ from collections.abc import Iterator, Mapping
12
+ from dataclasses import dataclass, field
13
+ from types import MappingProxyType
14
+ from typing import TYPE_CHECKING, Any, Optional, Union
15
+
16
+ import numpy as np
17
+
18
+ if TYPE_CHECKING:
19
+ import pandas as pd
20
+
21
+ __all__ = ["EvaluationResult", "MetricResult"]
22
+
23
+ Number = Union[float, int]
24
+
25
+
26
+ def _json_safe(value: Any) -> Any:
27
+ if isinstance(value, (np.floating, float)):
28
+ f = float(value)
29
+ return None if math.isnan(f) or math.isinf(f) else f
30
+ if isinstance(value, (np.integer,)):
31
+ return int(value)
32
+ if isinstance(value, np.ndarray):
33
+ return [_json_safe(v) for v in value.tolist()]
34
+ if isinstance(value, (list, tuple)):
35
+ return [_json_safe(v) for v in value]
36
+ if isinstance(value, Mapping):
37
+ return {str(k): _json_safe(v) for k, v in value.items()}
38
+ if isinstance(value, (np.bool_,)):
39
+ return bool(value)
40
+ return value
41
+
42
+
43
+ def _fmt(value: Any, digits: int) -> str:
44
+ if value is None:
45
+ return "–"
46
+ if isinstance(value, (float, np.floating)):
47
+ return "NaN" if math.isnan(value) else f"{float(value):.{digits}f}"
48
+ return str(value)
49
+
50
+
51
+ def _latex_escape(text: str) -> str:
52
+ repl = {
53
+ "\\": r"\textbackslash{}",
54
+ "&": r"\&",
55
+ "%": r"\%",
56
+ "$": r"\$",
57
+ "#": r"\#",
58
+ "_": r"\_",
59
+ "{": r"\{",
60
+ "}": r"\}",
61
+ "~": r"\textasciitilde{}",
62
+ "^": r"\textasciicircum{}",
63
+ }
64
+ return "".join(repl.get(c, c) for c in text)
65
+
66
+
67
+ @dataclass(frozen=True, eq=False)
68
+ class MetricResult:
69
+ """One metric's value.
70
+
71
+ ``value`` is a float, or a read-only array of per-class values when ``average=None``
72
+ (aligned with ``labels``). ``params`` records how it was computed (average, zero_division, ...).
73
+ """
74
+
75
+ metric: str
76
+ name: str
77
+ value: Union[float, np.ndarray]
78
+ params: Mapping[str, Any] = field(default_factory=dict)
79
+ labels: Optional[tuple[Any, ...]] = None
80
+
81
+ def __post_init__(self) -> None:
82
+ if isinstance(self.value, np.ndarray):
83
+ arr = np.array(self.value, dtype=np.float64)
84
+ arr.setflags(write=False)
85
+ object.__setattr__(self, "value", arr)
86
+ else:
87
+ object.__setattr__(self, "value", float(self.value))
88
+ object.__setattr__(self, "params", MappingProxyType(dict(self.params)))
89
+
90
+ def __float__(self) -> float:
91
+ if isinstance(self.value, np.ndarray):
92
+ raise TypeError(f"{self.name} has per-class values; use .value, .per_class() or .to_dict().")
93
+ return float(self.value)
94
+
95
+ # Scalar results behave like numbers: f"{r:.3f}", r > 0.8, round(r, 3), r == 0.75.
96
+ def __format__(self, spec: str) -> str:
97
+ return format(float(self), spec) if spec else repr(self)
98
+
99
+ def __round__(self, ndigits: Optional[int] = None) -> float:
100
+ return round(float(self), ndigits)
101
+
102
+ def __lt__(self, other: Number) -> bool:
103
+ return float(self) < float(other)
104
+
105
+ def __le__(self, other: Number) -> bool:
106
+ return float(self) <= float(other)
107
+
108
+ def __gt__(self, other: Number) -> bool:
109
+ return float(self) > float(other)
110
+
111
+ def __ge__(self, other: Number) -> bool:
112
+ return float(self) >= float(other)
113
+
114
+ def __eq__(self, other: object) -> bool:
115
+ if isinstance(other, (int, float, np.floating, np.integer)) and not isinstance(self.value, np.ndarray):
116
+ return float(self.value) == float(other)
117
+ return self is other
118
+
119
+ def __hash__(self) -> int:
120
+ return id(self)
121
+
122
+ def __repr__(self) -> str:
123
+ if isinstance(self.value, np.ndarray):
124
+ pairs = ", ".join(f"{lab}: {_fmt(v, 4)}" for lab, v in zip(self.labels or (), self.value))
125
+ return f"MetricResult({self.metric}: {{{pairs}}})"
126
+ return f"MetricResult({self.metric}={_fmt(self.value, 6)})"
127
+
128
+ def per_class(self) -> dict[Any, float]:
129
+ if not isinstance(self.value, np.ndarray):
130
+ raise TypeError(f"{self.name} is a single value; compute it with average=None for per-class values.")
131
+ return {lab: float(v) for lab, v in zip(self.labels or (), self.value)}
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ out: dict[str, Any] = {"metric": self.metric, "name": self.name, "value": _json_safe(self.value)}
135
+ if self.labels is not None:
136
+ out["labels"] = _json_safe(list(self.labels))
137
+ if self.params:
138
+ out["params"] = _json_safe(dict(self.params))
139
+ return out
140
+
141
+ def to_json(self, *, indent: Optional[int] = 2) -> str:
142
+ return json.dumps(self.to_dict(), indent=indent, allow_nan=False)
143
+
144
+ def to_dataframe(self) -> pd.DataFrame:
145
+ import pandas as pd
146
+
147
+ frame: pd.DataFrame
148
+ if isinstance(self.value, np.ndarray):
149
+ frame = pd.DataFrame({"label": list(self.labels or ()), self.metric: self.value})
150
+ else:
151
+ frame = pd.DataFrame({"metric": [self.metric], "name": [self.name], "value": [self.value]})
152
+ return frame
153
+
154
+ def to_markdown(self, *, digits: int = 4) -> str:
155
+ if isinstance(self.value, np.ndarray):
156
+ rows = [f"| {lab} | {_fmt(v, digits)} |" for lab, v in zip(self.labels or (), self.value)]
157
+ return "\n".join([f"| Label | {self.name} |", "| --- | ---: |", *rows])
158
+ return "\n".join(["| Metric | Value |", "| --- | ---: |", f"| {self.name} | {_fmt(self.value, digits)} |"])
159
+
160
+ def to_latex(self, *, digits: int = 4) -> str:
161
+ return _latex_table(
162
+ ["Metric", "Value"] if not isinstance(self.value, np.ndarray) else ["Label", _latex_escape(self.name)],
163
+ [[_latex_escape(self.name), _fmt(self.value, digits)]]
164
+ if not isinstance(self.value, np.ndarray)
165
+ else [[_latex_escape(str(lab)), _fmt(v, digits)] for lab, v in zip(self.labels or (), self.value)],
166
+ )
167
+
168
+
169
+ def _latex_table(
170
+ header: list[str], rows: list[list[str]], caption: Optional[str] = None, label: Optional[str] = None
171
+ ) -> str:
172
+ cols = "l" + "r" * (len(header) - 1)
173
+ lines = [r"\begin{table}[ht]", r"\centering"]
174
+ if caption:
175
+ lines.append(rf"\caption{{{_latex_escape(caption)}}}")
176
+ if label:
177
+ lines.append(rf"\label{{{label}}}")
178
+ lines += [rf"\begin{{tabular}}{{{cols}}}", r"\toprule", " & ".join(header) + r" \\", r"\midrule"]
179
+ lines += [" & ".join(r) + r" \\" for r in rows]
180
+ lines += [r"\bottomrule", r"\end{tabular}", r"\end{table}"]
181
+ return "\n".join(lines)
182
+
183
+
184
+ @dataclass(frozen=True, eq=False)
185
+ class EvaluationResult(Mapping[str, MetricResult]):
186
+ """All metrics from one evaluation, keyed by metric id (e.g. ``"accuracy"``)."""
187
+
188
+ task: str
189
+ metrics: Mapping[str, MetricResult]
190
+ n_samples: int
191
+ target_type: str
192
+ labels: Optional[tuple[Any, ...]] = None
193
+ confusion_matrix: Optional[np.ndarray] = None
194
+ metadata: Mapping[str, Any] = field(default_factory=dict)
195
+
196
+ def __post_init__(self) -> None:
197
+ object.__setattr__(self, "metrics", MappingProxyType(dict(self.metrics)))
198
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
199
+ if self.confusion_matrix is not None:
200
+ cm = np.array(self.confusion_matrix)
201
+ cm.setflags(write=False)
202
+ object.__setattr__(self, "confusion_matrix", cm)
203
+
204
+ # Mapping interface: result["f1"]
205
+ def __getitem__(self, key: str) -> MetricResult:
206
+ try:
207
+ return self.metrics[key]
208
+ except KeyError:
209
+ raise KeyError(f"No metric '{key}' in this result. Available: {', '.join(self.metrics)}") from None
210
+
211
+ def __iter__(self) -> Iterator[str]:
212
+ return iter(self.metrics)
213
+
214
+ def __len__(self) -> int:
215
+ return len(self.metrics)
216
+
217
+ @property
218
+ def value(self) -> dict[str, Any]:
219
+ return {k: m.value for k, m in self.metrics.items()}
220
+
221
+ def summary(self, *, digits: int = 4) -> str:
222
+ width = max((len(m.name) for m in self.metrics.values()), default=6)
223
+ lines = [f"EvalSuite {self.task} evaluation ({self.target_type}, n={self.n_samples})"]
224
+ for m in self.metrics.values():
225
+ if isinstance(m.value, np.ndarray):
226
+ lines.append(
227
+ f" {m.name:<{width}} "
228
+ + ", ".join(f"{lab}={_fmt(v, digits)}" for lab, v in zip(m.labels or (), m.value))
229
+ )
230
+ else:
231
+ lines.append(f" {m.name:<{width}} {_fmt(m.value, digits)}")
232
+ return "\n".join(lines)
233
+
234
+ def __repr__(self) -> str:
235
+ return self.summary()
236
+
237
+ def to_dict(self) -> dict[str, Any]:
238
+ out: dict[str, Any] = {
239
+ "task": self.task,
240
+ "target_type": self.target_type,
241
+ "n_samples": self.n_samples,
242
+ "metrics": {k: m.to_dict() for k, m in self.metrics.items()},
243
+ "metadata": _json_safe(dict(self.metadata)),
244
+ }
245
+ if self.labels is not None:
246
+ out["labels"] = _json_safe(list(self.labels))
247
+ if self.confusion_matrix is not None:
248
+ out["confusion_matrix"] = _json_safe(self.confusion_matrix)
249
+ return out
250
+
251
+ def to_json(self, *, indent: Optional[int] = 2) -> str:
252
+ return json.dumps(self.to_dict(), indent=indent, allow_nan=False)
253
+
254
+ def _scalar_rows(self) -> list[MetricResult]:
255
+ return [m for m in self.metrics.values() if not isinstance(m.value, np.ndarray)]
256
+
257
+ def to_dataframe(self) -> pd.DataFrame:
258
+ """One row per scalar metric (per-class metrics: use ``result[id].to_dataframe()``)."""
259
+ import pandas as pd
260
+
261
+ rows = self._scalar_rows()
262
+ frame: pd.DataFrame = pd.DataFrame(
263
+ {
264
+ "metric": [m.metric for m in rows],
265
+ "name": [m.name for m in rows],
266
+ "value": [m.value for m in rows],
267
+ }
268
+ )
269
+ return frame
270
+
271
+ def to_markdown(self, *, digits: int = 4) -> str:
272
+ rows = [f"| {m.name} | {_fmt(m.value, digits)} |" for m in self._scalar_rows()]
273
+ return "\n".join(["| Metric | Value |", "| --- | ---: |", *rows])
274
+
275
+ def to_latex(self, *, digits: int = 4, caption: Optional[str] = None, label: Optional[str] = None) -> str:
276
+ rows = [[_latex_escape(m.name), _fmt(m.value, digits)] for m in self._scalar_rows()]
277
+ return _latex_table(["Metric", "Value"], rows, caption=caption, label=label)
@@ -0,0 +1,23 @@
1
+ """Shared type aliases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, Union
6
+
7
+ import numpy as np
8
+ from numpy.typing import ArrayLike, NDArray
9
+
10
+ __all__ = ["Average", "ArrayLike", "FloatArray", "IntArray", "Task", "ZeroDivision"]
11
+
12
+ FloatArray = NDArray[np.float64]
13
+ IntArray = NDArray[np.int64]
14
+
15
+ #: How per-class results are combined. ``None`` returns per-class values.
16
+ Average = Literal["binary", "micro", "macro", "weighted", "samples", None]
17
+
18
+ #: Value used when a metric's denominator is zero. ``"warn"`` returns NaN and warns.
19
+ ZeroDivision = Union[Literal["warn"], float]
20
+
21
+ Task = Literal["classification", "regression"]
22
+
23
+ JSONValue = Union[None, bool, int, float, str, list[Any], dict[str, Any]]