evalsuite-python 0.1.0__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 +159 -0
- evalsuite/__main__.py +5 -0
- evalsuite/api.py +264 -0
- evalsuite/benchmarks.py +272 -0
- evalsuite/classification/__init__.py +51 -0
- evalsuite/classification/_common.py +111 -0
- evalsuite/classification/metrics.py +943 -0
- evalsuite/cli/__init__.py +5 -0
- evalsuite/cli/main.py +389 -0
- evalsuite/core/__init__.py +1 -0
- evalsuite/core/context.py +181 -0
- evalsuite/core/exceptions.py +52 -0
- evalsuite/core/export.py +100 -0
- evalsuite/core/registry.py +90 -0
- evalsuite/core/result.py +379 -0
- evalsuite/core/types.py +23 -0
- evalsuite/core/validation.py +202 -0
- evalsuite/plot.py +296 -0
- evalsuite/py.typed +0 -0
- evalsuite/regression/__init__.py +41 -0
- evalsuite/regression/metrics.py +604 -0
- evalsuite/reporting.py +241 -0
- evalsuite/stats/__init__.py +25 -0
- evalsuite/stats/_resolve.py +90 -0
- evalsuite/stats/compare.py +414 -0
- evalsuite/stats/effect.py +113 -0
- evalsuite/stats/intervals.py +320 -0
- evalsuite/stats/paired.py +207 -0
- evalsuite/stats/results.py +129 -0
- evalsuite/version.py +3 -0
- evalsuite_python-0.1.0.dist-info/METADATA +247 -0
- evalsuite_python-0.1.0.dist-info/RECORD +35 -0
- evalsuite_python-0.1.0.dist-info/WHEEL +4 -0
- evalsuite_python-0.1.0.dist-info/entry_points.txt +2 -0
- evalsuite_python-0.1.0.dist-info/licenses/LICENSE +21 -0
evalsuite/reporting.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Classification report: per-class precision, recall, F1, specificity and support, with averages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Optional, cast
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .core.context import ClassificationContext
|
|
13
|
+
from .core.result import _fmt, _json_safe, _latex_escape, _latex_table
|
|
14
|
+
from .core.types import ArrayLike, ZeroDivision
|
|
15
|
+
from .core.validation import safe_divide, validate_zero_division
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
__all__ = ["ClassificationReport", "classification_report"]
|
|
21
|
+
|
|
22
|
+
_COLUMNS = ("precision", "recall", "f1", "specificity", "support")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, eq=False)
|
|
26
|
+
class ClassificationReport:
|
|
27
|
+
"""Per-class rows plus summary rows (``accuracy``, ``micro avg``, ``macro avg``, ``weighted avg``)."""
|
|
28
|
+
|
|
29
|
+
target_type: str
|
|
30
|
+
rows: tuple[dict[str, Any], ...]
|
|
31
|
+
summary_rows: tuple[dict[str, Any], ...]
|
|
32
|
+
n_samples: int
|
|
33
|
+
params: Any = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
object.__setattr__(self, "rows", tuple(MappingProxyType(dict(r)) for r in self.rows))
|
|
37
|
+
object.__setattr__(self, "summary_rows", tuple(MappingProxyType(dict(r)) for r in self.summary_rows))
|
|
38
|
+
object.__setattr__(self, "params", MappingProxyType(dict(self.params)))
|
|
39
|
+
|
|
40
|
+
def __getitem__(self, label: Any) -> Any:
|
|
41
|
+
"""Row for a class label or a summary name (``"macro avg"``)."""
|
|
42
|
+
for row in (*self.rows, *self.summary_rows):
|
|
43
|
+
if row["label"] == label:
|
|
44
|
+
return row
|
|
45
|
+
raise KeyError(f"No row {label!r}. Labels: {[r['label'] for r in self.rows]}.")
|
|
46
|
+
|
|
47
|
+
def _table(self, digits: int) -> tuple[list[str], list[list[str]]]:
|
|
48
|
+
header = ["", "precision", "recall", "f1", "specificity", "support"]
|
|
49
|
+
out = []
|
|
50
|
+
for row in (*self.rows, *self.summary_rows):
|
|
51
|
+
cells = [str(row["label"])]
|
|
52
|
+
for col in _COLUMNS:
|
|
53
|
+
v = row.get(col)
|
|
54
|
+
if v is None:
|
|
55
|
+
cells.append("")
|
|
56
|
+
elif col == "support":
|
|
57
|
+
cells.append(str(int(v)) if float(v).is_integer() else _fmt(v, 2))
|
|
58
|
+
else:
|
|
59
|
+
cells.append(_fmt(v, digits))
|
|
60
|
+
out.append(cells)
|
|
61
|
+
return header, out
|
|
62
|
+
|
|
63
|
+
def summary(self, *, digits: int = 4) -> str:
|
|
64
|
+
"""Fixed-width text table, like scikit-learn's report plus a specificity column."""
|
|
65
|
+
header, rows = self._table(digits)
|
|
66
|
+
widths = [max(len(header[i]), *(len(r[i]) for r in rows)) for i in range(len(header))]
|
|
67
|
+
|
|
68
|
+
def fmt(cells: list[str]) -> str:
|
|
69
|
+
return " ".join(c.ljust(widths[i]) if i == 0 else c.rjust(widths[i]) for i, c in enumerate(cells))
|
|
70
|
+
|
|
71
|
+
lines = [fmt(header), ""]
|
|
72
|
+
for i, r in enumerate(rows):
|
|
73
|
+
if i == len(self.rows):
|
|
74
|
+
lines.append("")
|
|
75
|
+
lines.append(fmt(r))
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
return self.summary()
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, Any]:
|
|
82
|
+
return cast(
|
|
83
|
+
"dict[str, Any]",
|
|
84
|
+
_json_safe(
|
|
85
|
+
{
|
|
86
|
+
"target_type": self.target_type,
|
|
87
|
+
"n_samples": self.n_samples,
|
|
88
|
+
"params": dict(self.params),
|
|
89
|
+
"classes": [dict(r) for r in self.rows],
|
|
90
|
+
"summary": [dict(r) for r in self.summary_rows],
|
|
91
|
+
}
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def to_json(self, *, indent: Optional[int] = 2) -> str:
|
|
96
|
+
return json.dumps(self.to_dict(), indent=indent, allow_nan=False)
|
|
97
|
+
|
|
98
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
99
|
+
import pandas as pd
|
|
100
|
+
|
|
101
|
+
frame: pd.DataFrame = pd.DataFrame([dict(r) for r in (*self.rows, *self.summary_rows)]).set_index("label")
|
|
102
|
+
return frame
|
|
103
|
+
|
|
104
|
+
def to_csv(self, path: Optional[str] = None) -> str:
|
|
105
|
+
from .core.export import csv_text
|
|
106
|
+
|
|
107
|
+
rows = [
|
|
108
|
+
[r["label"], *(r.get(c) if r.get(c) is not None else float("nan") for c in _COLUMNS)]
|
|
109
|
+
for r in (*self.rows, *self.summary_rows)
|
|
110
|
+
]
|
|
111
|
+
text = csv_text(["label", *_COLUMNS], rows)
|
|
112
|
+
if path is not None:
|
|
113
|
+
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
114
|
+
fh.write(text)
|
|
115
|
+
return text
|
|
116
|
+
|
|
117
|
+
def to_markdown(self, *, digits: int = 4) -> str:
|
|
118
|
+
header, rows = self._table(digits)
|
|
119
|
+
header[0] = "Class"
|
|
120
|
+
lines = ["| " + " | ".join(header) + " |", "| --- |" + " ---: |" * (len(header) - 1)]
|
|
121
|
+
lines += ["| " + " | ".join(r) + " |" for r in rows]
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
def to_latex(self, *, digits: int = 4, caption: Optional[str] = None, label: Optional[str] = None) -> str:
|
|
125
|
+
header, rows = self._table(digits)
|
|
126
|
+
header = ["Class", "Precision", "Recall", "F1", "Specificity", "Support"]
|
|
127
|
+
return _latex_table(
|
|
128
|
+
header,
|
|
129
|
+
[[_latex_escape(c) for c in r] for r in rows],
|
|
130
|
+
caption=caption or "Classification report.",
|
|
131
|
+
label=label,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def to_html(self, *, digits: int = 4, caption: Optional[str] = None, full: bool = False) -> str:
|
|
135
|
+
from .core.export import html_document, html_table
|
|
136
|
+
|
|
137
|
+
header, rows = self._table(digits)
|
|
138
|
+
header[0] = "Class"
|
|
139
|
+
classes = [""] * len(self.rows) + ["avg"] * len(self.summary_rows)
|
|
140
|
+
table = html_table(header, rows, caption=caption or "Classification report", row_classes=classes)
|
|
141
|
+
meta = f"{self.target_type}, n = {self.n_samples}"
|
|
142
|
+
return html_document("EvalSuite classification report", table, meta) if full else table
|
|
143
|
+
|
|
144
|
+
def save(self, path: str, *, digits: int = 4) -> str:
|
|
145
|
+
"""Save as .json .csv .md .tex .html or .txt (chosen by the extension)."""
|
|
146
|
+
from .core.export import save_as
|
|
147
|
+
|
|
148
|
+
return save_as(
|
|
149
|
+
path,
|
|
150
|
+
{
|
|
151
|
+
"json": self.to_json,
|
|
152
|
+
"csv": self.to_csv,
|
|
153
|
+
"markdown": lambda: self.to_markdown(digits=digits) + "\n",
|
|
154
|
+
"latex": lambda: self.to_latex(digits=digits) + "\n",
|
|
155
|
+
"html": lambda: self.to_html(digits=digits, full=True),
|
|
156
|
+
"text": lambda: self.summary(digits=digits) + "\n",
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def classification_report(
|
|
162
|
+
y_true: ArrayLike,
|
|
163
|
+
y_pred: ArrayLike,
|
|
164
|
+
*,
|
|
165
|
+
labels: Optional[ArrayLike] = None,
|
|
166
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
167
|
+
zero_division: ZeroDivision = "warn",
|
|
168
|
+
) -> ClassificationReport:
|
|
169
|
+
"""Per-class precision, recall, F1, specificity and support, plus accuracy (single-label targets),
|
|
170
|
+
micro (multilabel), macro and support-weighted averages. Values match scikit-learn's
|
|
171
|
+
``classification_report`` where both define them; specificity is EvalSuite's addition.
|
|
172
|
+
|
|
173
|
+
>>> import evalsuite as es
|
|
174
|
+
>>> print(es.classification_report([0, 1, 1, 2], [0, 1, 2, 2])) # doctest: +SKIP
|
|
175
|
+
"""
|
|
176
|
+
zd = validate_zero_division(zero_division)
|
|
177
|
+
ctx = ClassificationContext(y_true, y_pred, labels=labels, sample_weight=sample_weight)
|
|
178
|
+
c = ctx.counts
|
|
179
|
+
tp, fp, fn, tn, support = c["tp"], c["fp"], c["fn"], c["tn"], c["support"]
|
|
180
|
+
prec = safe_divide(tp, tp + fp, zero_division=zd, metric="Precision")
|
|
181
|
+
rec = safe_divide(tp, tp + fn, zero_division=zd, metric="Recall")
|
|
182
|
+
f1 = safe_divide(2 * tp, 2 * tp + fp + fn, zero_division=zd, metric="F1")
|
|
183
|
+
spec = safe_divide(tn, tn + fp, zero_division=zd, metric="Specificity")
|
|
184
|
+
rows = [
|
|
185
|
+
{
|
|
186
|
+
"label": lab,
|
|
187
|
+
"precision": float(p),
|
|
188
|
+
"recall": float(r),
|
|
189
|
+
"f1": float(f),
|
|
190
|
+
"specificity": float(s),
|
|
191
|
+
"support": float(n),
|
|
192
|
+
}
|
|
193
|
+
for lab, p, r, f, s, n in zip(ctx.labels.tolist(), prec, rec, f1, spec, support)
|
|
194
|
+
]
|
|
195
|
+
total = float(support.sum())
|
|
196
|
+
summary: list[dict[str, Any]] = []
|
|
197
|
+
if ctx.target_type == "multilabel":
|
|
198
|
+
tps, fps, fns, tns = tp.sum(), fp.sum(), fn.sum(), tn.sum()
|
|
199
|
+
summary.append(
|
|
200
|
+
{
|
|
201
|
+
"label": "micro avg",
|
|
202
|
+
"precision": float(safe_divide(tps, tps + fps, zero_division=zd, metric="Precision")),
|
|
203
|
+
"recall": float(safe_divide(tps, tps + fns, zero_division=zd, metric="Recall")),
|
|
204
|
+
"f1": float(safe_divide(2 * tps, 2 * tps + fps + fns, zero_division=zd, metric="F1")),
|
|
205
|
+
"specificity": float(safe_divide(tns, tns + fps, zero_division=zd, metric="Specificity")),
|
|
206
|
+
"support": total,
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
correct = float(np.trace(ctx.confusion_matrix))
|
|
211
|
+
summary.append(
|
|
212
|
+
{
|
|
213
|
+
"label": "accuracy",
|
|
214
|
+
"precision": None,
|
|
215
|
+
"recall": None,
|
|
216
|
+
"f1": correct / ctx.total_weight,
|
|
217
|
+
"specificity": None,
|
|
218
|
+
"support": ctx.total_weight,
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
stacked = np.vstack([prec, rec, f1, spec])
|
|
222
|
+
macro = stacked.mean(axis=1)
|
|
223
|
+
weighted = (stacked * support).sum(axis=1) / total if total > 0 else np.full(4, np.nan)
|
|
224
|
+
for name, vals in (("macro avg", macro), ("weighted avg", weighted)):
|
|
225
|
+
summary.append(
|
|
226
|
+
{
|
|
227
|
+
"label": name,
|
|
228
|
+
"precision": float(vals[0]),
|
|
229
|
+
"recall": float(vals[1]),
|
|
230
|
+
"f1": float(vals[2]),
|
|
231
|
+
"specificity": float(vals[3]),
|
|
232
|
+
"support": total,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
return ClassificationReport(
|
|
236
|
+
ctx.target_type,
|
|
237
|
+
tuple(rows),
|
|
238
|
+
tuple(summary),
|
|
239
|
+
ctx.n,
|
|
240
|
+
{"zero_division": zero_division, "weighted": sample_weight is not None},
|
|
241
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Statistical evaluation: confidence intervals, paired tests, effect sizes, multiple comparisons, compare()."""
|
|
2
|
+
|
|
3
|
+
from .compare import ComparisonResult, compare
|
|
4
|
+
from .effect import adjust_pvalues, cliffs_delta, cohens_d, hedges_g
|
|
5
|
+
from .intervals import accuracy_ci, bootstrap_ci, proportion_ci, roc_auc_ci
|
|
6
|
+
from .paired import delong_test, mcnemar_test, paired_bootstrap_test
|
|
7
|
+
from .results import ConfidenceInterval, TestResult
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ComparisonResult",
|
|
11
|
+
"ConfidenceInterval",
|
|
12
|
+
"TestResult",
|
|
13
|
+
"accuracy_ci",
|
|
14
|
+
"adjust_pvalues",
|
|
15
|
+
"bootstrap_ci",
|
|
16
|
+
"cliffs_delta",
|
|
17
|
+
"cohens_d",
|
|
18
|
+
"compare",
|
|
19
|
+
"delong_test",
|
|
20
|
+
"hedges_g",
|
|
21
|
+
"mcnemar_test",
|
|
22
|
+
"paired_bootstrap_test",
|
|
23
|
+
"proportion_ci",
|
|
24
|
+
"roc_auc_ci",
|
|
25
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Resolve metric names to functions and call them on index subsets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
|
|
13
|
+
from ..core.exceptions import InputValidationError
|
|
14
|
+
from ..core.validation import target_type, to_numpy
|
|
15
|
+
|
|
16
|
+
MetricFn = Callable[..., Any]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_metric(metric: Any) -> tuple[MetricFn, str]:
|
|
20
|
+
"""A metric function (``es.f1``) or its name (``"f1"``, ``"classification.f1"``)."""
|
|
21
|
+
if callable(metric):
|
|
22
|
+
return metric, getattr(metric, "__name__", "metric")
|
|
23
|
+
if isinstance(metric, str):
|
|
24
|
+
import evalsuite
|
|
25
|
+
|
|
26
|
+
name = metric.split(".")[-1]
|
|
27
|
+
fn = getattr(evalsuite, name, None)
|
|
28
|
+
if callable(fn) and name in {m.split(".")[-1] for m in evalsuite.list_metrics()}:
|
|
29
|
+
return fn, name
|
|
30
|
+
raise InputValidationError(
|
|
31
|
+
f"Unknown metric '{metric}'. Use a metric function or a name from list_metrics()."
|
|
32
|
+
)
|
|
33
|
+
raise InputValidationError("metric must be a metric function (e.g. es.f1) or its name (e.g. 'f1').")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_categorical(y: NDArray[Any]) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
return target_type(y) in ("binary", "multiclass", "multilabel")
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MetricCall:
|
|
44
|
+
"""Evaluate ``fn(y_true[idx], second[idx], **kwargs)`` repeatedly on index subsets."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
fn: MetricFn,
|
|
49
|
+
y_true: Any,
|
|
50
|
+
y_pred: Any = None,
|
|
51
|
+
y_prob: Any = None,
|
|
52
|
+
sample_weight: Any = None,
|
|
53
|
+
kwargs: Optional[dict[str, Any]] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
if (y_pred is None) == (y_prob is None):
|
|
56
|
+
raise InputValidationError(
|
|
57
|
+
"Pass exactly one of y_pred (label/value metrics) or y_prob (probability metrics)."
|
|
58
|
+
)
|
|
59
|
+
self.fn = fn
|
|
60
|
+
self.y_true = to_numpy(y_true, "y_true", allow_2d=True)
|
|
61
|
+
second = y_pred if y_pred is not None else y_prob
|
|
62
|
+
self.second = to_numpy(second, "y_pred" if y_pred is not None else "y_prob", allow_2d=True)
|
|
63
|
+
if self.second.shape[0] != self.y_true.shape[0]:
|
|
64
|
+
raise InputValidationError(
|
|
65
|
+
f"y_true and {'y_pred' if y_pred is not None else 'y_prob'} must contain the same number of "
|
|
66
|
+
f"observations. Received {self.y_true.shape[0]} and {self.second.shape[0]}."
|
|
67
|
+
)
|
|
68
|
+
self.n = self.y_true.shape[0]
|
|
69
|
+
self.weight = None if sample_weight is None else to_numpy(sample_weight, "sample_weight")
|
|
70
|
+
self.kwargs = dict(kwargs or {})
|
|
71
|
+
params = inspect.signature(fn).parameters
|
|
72
|
+
self.categorical = is_categorical(self.y_true)
|
|
73
|
+
# Keep every class in every resample so per-class and averaged metrics stay comparable.
|
|
74
|
+
if self.categorical and "labels" in params and "labels" not in self.kwargs and self.y_true.ndim == 1:
|
|
75
|
+
present = self.y_true if y_pred is None else np.concatenate([self.y_true, self.second])
|
|
76
|
+
with contextlib.suppress(TypeError): # incomparable labels: the metric reports it
|
|
77
|
+
self.kwargs["labels"] = np.unique(present)
|
|
78
|
+
if self.weight is not None and "sample_weight" not in params:
|
|
79
|
+
raise InputValidationError(f"{getattr(fn, '__name__', 'This metric')} does not accept sample_weight.")
|
|
80
|
+
|
|
81
|
+
def __call__(self, idx: Optional[NDArray[np.int64]] = None) -> float:
|
|
82
|
+
if idx is None:
|
|
83
|
+
yt, s, w = self.y_true, self.second, self.weight
|
|
84
|
+
else:
|
|
85
|
+
yt, s = self.y_true[idx], self.second[idx]
|
|
86
|
+
w = None if self.weight is None else self.weight[idx]
|
|
87
|
+
kw = dict(self.kwargs)
|
|
88
|
+
if w is not None:
|
|
89
|
+
kw["sample_weight"] = w
|
|
90
|
+
return float(self.fn(yt, s, **kw))
|