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