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.
- evalsuite/__init__.py +118 -0
- evalsuite/api.py +249 -0
- evalsuite/classification/__init__.py +47 -0
- evalsuite/classification/_common.py +111 -0
- evalsuite/classification/metrics.py +872 -0
- evalsuite/core/__init__.py +1 -0
- evalsuite/core/context.py +181 -0
- evalsuite/core/exceptions.py +52 -0
- evalsuite/core/registry.py +90 -0
- evalsuite/core/result.py +277 -0
- evalsuite/core/types.py +23 -0
- evalsuite/core/validation.py +202 -0
- evalsuite/py.typed +0 -0
- evalsuite/regression/__init__.py +41 -0
- evalsuite/regression/metrics.py +587 -0
- evalsuite/version.py +3 -0
- evalsuite_python-0.1.0a1.dist-info/METADATA +150 -0
- evalsuite_python-0.1.0a1.dist-info/RECORD +21 -0
- evalsuite_python-0.1.0a1.dist-info/WHEEL +4 -0
- evalsuite_python-0.1.0a1.dist-info/entry_points.txt +2 -0
- evalsuite_python-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
evalsuite/core/result.py
ADDED
|
@@ -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)
|
evalsuite/core/types.py
ADDED
|
@@ -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]]
|