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/__init__.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""EvalSuite: unified, reproducible evaluation for machine learning and research.
|
|
2
|
+
|
|
3
|
+
>>> import evalsuite as es
|
|
4
|
+
>>> result = es.evaluate([0, 1, 1, 0], [0, 1, 0, 0])
|
|
5
|
+
>>> print(result.summary()) # doctest: +SKIP
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import classification, plot, regression, stats
|
|
9
|
+
from .api import evaluate
|
|
10
|
+
from .classification import (
|
|
11
|
+
accuracy,
|
|
12
|
+
average_precision,
|
|
13
|
+
balanced_accuracy,
|
|
14
|
+
brier_score,
|
|
15
|
+
calibration_curve,
|
|
16
|
+
cohen_kappa,
|
|
17
|
+
confusion_matrix,
|
|
18
|
+
expected_calibration_error,
|
|
19
|
+
f1,
|
|
20
|
+
fbeta,
|
|
21
|
+
hamming_loss,
|
|
22
|
+
jaccard,
|
|
23
|
+
log_loss,
|
|
24
|
+
mcc,
|
|
25
|
+
npv,
|
|
26
|
+
pr_curve,
|
|
27
|
+
precision,
|
|
28
|
+
recall,
|
|
29
|
+
roc_auc,
|
|
30
|
+
roc_curve,
|
|
31
|
+
specificity,
|
|
32
|
+
top_k_accuracy,
|
|
33
|
+
)
|
|
34
|
+
from .core.exceptions import (
|
|
35
|
+
EvalSuiteError,
|
|
36
|
+
InputValidationError,
|
|
37
|
+
MetricInputError,
|
|
38
|
+
OptionalDependencyError,
|
|
39
|
+
StatisticalTestError,
|
|
40
|
+
UndefinedMetricWarning,
|
|
41
|
+
UnsupportedTaskError,
|
|
42
|
+
)
|
|
43
|
+
from .core.registry import MetricInfo, list_metrics, metric_info
|
|
44
|
+
from .core.result import EvaluationResult, MetricResult
|
|
45
|
+
from .regression import (
|
|
46
|
+
adjusted_r2,
|
|
47
|
+
explained_variance,
|
|
48
|
+
huber_loss,
|
|
49
|
+
mae,
|
|
50
|
+
mape,
|
|
51
|
+
max_error,
|
|
52
|
+
mean_bias_error,
|
|
53
|
+
median_absolute_error,
|
|
54
|
+
mse,
|
|
55
|
+
msle,
|
|
56
|
+
quantile_loss,
|
|
57
|
+
r2,
|
|
58
|
+
rae,
|
|
59
|
+
rmse,
|
|
60
|
+
rmsle,
|
|
61
|
+
rse,
|
|
62
|
+
smape,
|
|
63
|
+
)
|
|
64
|
+
from .reporting import ClassificationReport, classification_report
|
|
65
|
+
from .stats import (
|
|
66
|
+
ComparisonResult,
|
|
67
|
+
ConfidenceInterval,
|
|
68
|
+
TestResult,
|
|
69
|
+
accuracy_ci,
|
|
70
|
+
adjust_pvalues,
|
|
71
|
+
bootstrap_ci,
|
|
72
|
+
cliffs_delta,
|
|
73
|
+
cohens_d,
|
|
74
|
+
compare,
|
|
75
|
+
delong_test,
|
|
76
|
+
hedges_g,
|
|
77
|
+
mcnemar_test,
|
|
78
|
+
paired_bootstrap_test,
|
|
79
|
+
proportion_ci,
|
|
80
|
+
roc_auc_ci,
|
|
81
|
+
)
|
|
82
|
+
from .version import __version__
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"plot",
|
|
86
|
+
"expected_calibration_error",
|
|
87
|
+
"calibration_curve",
|
|
88
|
+
"classification_report",
|
|
89
|
+
"ClassificationReport",
|
|
90
|
+
"stats",
|
|
91
|
+
"roc_auc_ci",
|
|
92
|
+
"proportion_ci",
|
|
93
|
+
"paired_bootstrap_test",
|
|
94
|
+
"mcnemar_test",
|
|
95
|
+
"hedges_g",
|
|
96
|
+
"delong_test",
|
|
97
|
+
"compare",
|
|
98
|
+
"cohens_d",
|
|
99
|
+
"cliffs_delta",
|
|
100
|
+
"bootstrap_ci",
|
|
101
|
+
"adjust_pvalues",
|
|
102
|
+
"accuracy_ci",
|
|
103
|
+
"TestResult",
|
|
104
|
+
"ConfidenceInterval",
|
|
105
|
+
"ComparisonResult",
|
|
106
|
+
"EvalSuiteError",
|
|
107
|
+
"EvaluationResult",
|
|
108
|
+
"InputValidationError",
|
|
109
|
+
"MetricInfo",
|
|
110
|
+
"MetricInputError",
|
|
111
|
+
"MetricResult",
|
|
112
|
+
"OptionalDependencyError",
|
|
113
|
+
"StatisticalTestError",
|
|
114
|
+
"UndefinedMetricWarning",
|
|
115
|
+
"UnsupportedTaskError",
|
|
116
|
+
"__version__",
|
|
117
|
+
"accuracy",
|
|
118
|
+
"adjusted_r2",
|
|
119
|
+
"average_precision",
|
|
120
|
+
"balanced_accuracy",
|
|
121
|
+
"brier_score",
|
|
122
|
+
"classification",
|
|
123
|
+
"cohen_kappa",
|
|
124
|
+
"confusion_matrix",
|
|
125
|
+
"evaluate",
|
|
126
|
+
"explained_variance",
|
|
127
|
+
"f1",
|
|
128
|
+
"fbeta",
|
|
129
|
+
"hamming_loss",
|
|
130
|
+
"huber_loss",
|
|
131
|
+
"jaccard",
|
|
132
|
+
"list_metrics",
|
|
133
|
+
"log_loss",
|
|
134
|
+
"mae",
|
|
135
|
+
"mape",
|
|
136
|
+
"max_error",
|
|
137
|
+
"mcc",
|
|
138
|
+
"mean_bias_error",
|
|
139
|
+
"median_absolute_error",
|
|
140
|
+
"metric_info",
|
|
141
|
+
"mse",
|
|
142
|
+
"msle",
|
|
143
|
+
"npv",
|
|
144
|
+
"pr_curve",
|
|
145
|
+
"precision",
|
|
146
|
+
"quantile_loss",
|
|
147
|
+
"r2",
|
|
148
|
+
"rae",
|
|
149
|
+
"recall",
|
|
150
|
+
"regression",
|
|
151
|
+
"rmse",
|
|
152
|
+
"rmsle",
|
|
153
|
+
"roc_auc",
|
|
154
|
+
"roc_curve",
|
|
155
|
+
"rse",
|
|
156
|
+
"smape",
|
|
157
|
+
"specificity",
|
|
158
|
+
"top_k_accuracy",
|
|
159
|
+
]
|
evalsuite/__main__.py
ADDED
evalsuite/api.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""High-level API: ``evaluate()``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .classification import metrics as clf
|
|
12
|
+
from .classification._common import averaged
|
|
13
|
+
from .core.context import ClassificationContext
|
|
14
|
+
from .core.exceptions import InputValidationError, UnsupportedTaskError
|
|
15
|
+
from .core.result import EvaluationResult, MetricResult
|
|
16
|
+
from .core.types import ArrayLike, ZeroDivision
|
|
17
|
+
from .core.validation import target_type, to_numpy
|
|
18
|
+
from .regression import metrics as reg
|
|
19
|
+
from .version import __version__
|
|
20
|
+
|
|
21
|
+
__all__ = ["evaluate"]
|
|
22
|
+
|
|
23
|
+
CtxMetric = Callable[[ClassificationContext], MetricResult]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _classification_registry(
|
|
27
|
+
average: Optional[str], pos_label: Any, zero_division: ZeroDivision
|
|
28
|
+
) -> dict[str, tuple[CtxMetric, bool]]:
|
|
29
|
+
"""metric id -> (function of a shared context, needs probabilities)."""
|
|
30
|
+
|
|
31
|
+
def avg(fn: Any, metric: str, name: str) -> CtxMetric:
|
|
32
|
+
return lambda ctx: averaged(
|
|
33
|
+
ctx,
|
|
34
|
+
fn,
|
|
35
|
+
metric=metric,
|
|
36
|
+
name=name,
|
|
37
|
+
average=average,
|
|
38
|
+
pos_label=pos_label,
|
|
39
|
+
zero_division=zero_division,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"accuracy": (clf._accuracy, False),
|
|
44
|
+
"balanced_accuracy": (clf._balanced_accuracy, False),
|
|
45
|
+
"precision": (avg(clf._precision, "precision", "Precision"), False),
|
|
46
|
+
"recall": (avg(clf._recall, "recall", "Recall"), False),
|
|
47
|
+
"f1": (avg(clf._fbeta_fn(1.0), "f1", "F1"), False),
|
|
48
|
+
"specificity": (avg(clf._specificity, "specificity", "Specificity"), False),
|
|
49
|
+
"npv": (avg(clf._npv, "npv", "NPV"), False),
|
|
50
|
+
"jaccard": (avg(clf._jaccard, "jaccard", "Jaccard"), False),
|
|
51
|
+
"mcc": (lambda ctx: clf._mcc(ctx, zero_division), False),
|
|
52
|
+
"cohen_kappa": (lambda ctx: clf._cohen_kappa(ctx, None, zero_division), False),
|
|
53
|
+
"hamming_loss": (_hamming, False),
|
|
54
|
+
"roc_auc": (lambda ctx: clf._roc_auc(ctx, average=average, pos_label=pos_label), True),
|
|
55
|
+
"average_precision": (
|
|
56
|
+
lambda ctx: clf._average_precision(ctx, average=average, pos_label=pos_label),
|
|
57
|
+
True,
|
|
58
|
+
),
|
|
59
|
+
"log_loss": (lambda ctx: clf._log_loss(ctx, pos_label), True),
|
|
60
|
+
"brier_score": (lambda ctx: clf._brier(ctx, pos_label), True),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _hamming(ctx: ClassificationContext) -> MetricResult:
|
|
65
|
+
if ctx.target_type == "multilabel":
|
|
66
|
+
assert ctx.y_pred is not None # noqa: S101
|
|
67
|
+
wrong = (ctx.y_true != ctx.y_pred).mean(axis=1)
|
|
68
|
+
else:
|
|
69
|
+
wrong = (ctx.true_idx != ctx.pred_idx).astype(float)
|
|
70
|
+
return MetricResult("hamming_loss", "Hamming loss", float(np.average(wrong, weights=ctx.weights)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_DEFAULT_SINGLE = [
|
|
74
|
+
"accuracy",
|
|
75
|
+
"balanced_accuracy",
|
|
76
|
+
"precision",
|
|
77
|
+
"recall",
|
|
78
|
+
"f1",
|
|
79
|
+
"specificity",
|
|
80
|
+
"mcc",
|
|
81
|
+
"cohen_kappa",
|
|
82
|
+
]
|
|
83
|
+
_DEFAULT_MULTILABEL = ["accuracy", "hamming_loss", "precision", "recall", "f1", "jaccard"]
|
|
84
|
+
_DEFAULT_PROB = ["roc_auc", "average_precision", "log_loss", "brier_score"]
|
|
85
|
+
_DEFAULT_PROB_MULTILABEL = ["roc_auc", "average_precision"]
|
|
86
|
+
|
|
87
|
+
_REGRESSION: dict[str, Callable[..., MetricResult]] = {
|
|
88
|
+
"mae": reg.mae,
|
|
89
|
+
"mse": reg.mse,
|
|
90
|
+
"rmse": reg.rmse,
|
|
91
|
+
"r2": reg.r2,
|
|
92
|
+
"explained_variance": reg.explained_variance,
|
|
93
|
+
"median_absolute_error": reg.median_absolute_error,
|
|
94
|
+
"max_error": reg.max_error,
|
|
95
|
+
"mean_bias_error": reg.mean_bias_error,
|
|
96
|
+
"mape": reg.mape,
|
|
97
|
+
"smape": reg.smape,
|
|
98
|
+
"msle": reg.msle,
|
|
99
|
+
"rmsle": reg.rmsle,
|
|
100
|
+
"rae": reg.rae,
|
|
101
|
+
"rse": reg.rse,
|
|
102
|
+
"huber_loss": reg.huber_loss,
|
|
103
|
+
"quantile_loss": reg.quantile_loss,
|
|
104
|
+
}
|
|
105
|
+
_DEFAULT_REGRESSION = [
|
|
106
|
+
"mae",
|
|
107
|
+
"mse",
|
|
108
|
+
"rmse",
|
|
109
|
+
"r2",
|
|
110
|
+
"explained_variance",
|
|
111
|
+
"median_absolute_error",
|
|
112
|
+
"max_error",
|
|
113
|
+
"mean_bias_error",
|
|
114
|
+
]
|
|
115
|
+
_NO_WEIGHTS = {"max_error"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _infer_task(y_true: ArrayLike, y_prob: Optional[ArrayLike]) -> str:
|
|
119
|
+
if y_prob is not None:
|
|
120
|
+
return "classification"
|
|
121
|
+
return (
|
|
122
|
+
"regression"
|
|
123
|
+
if target_type(to_numpy(y_true, "y_true", allow_2d=True)) == "continuous"
|
|
124
|
+
else "classification"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def evaluate(
|
|
129
|
+
y_true: ArrayLike,
|
|
130
|
+
y_pred: Optional[ArrayLike] = None,
|
|
131
|
+
*,
|
|
132
|
+
y_prob: Optional[ArrayLike] = None,
|
|
133
|
+
task: Optional[str] = None,
|
|
134
|
+
metrics: Optional[Sequence[str]] = None,
|
|
135
|
+
average: Optional[str] = "auto",
|
|
136
|
+
labels: Optional[ArrayLike] = None,
|
|
137
|
+
pos_label: Any = None,
|
|
138
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
139
|
+
zero_division: ZeroDivision = "warn",
|
|
140
|
+
) -> EvaluationResult:
|
|
141
|
+
"""Evaluate predictions with a standard set of metrics (or the ``metrics`` you name).
|
|
142
|
+
|
|
143
|
+
The task is inferred when omitted: probabilities or integer/string labels mean classification, non-integer
|
|
144
|
+
numbers mean regression. Inputs are validated once and shared intermediate results (the confusion matrix)
|
|
145
|
+
are computed once for all metrics.
|
|
146
|
+
|
|
147
|
+
>>> import evalsuite as es
|
|
148
|
+
>>> r = es.evaluate([0, 1, 1, 0], [0, 1, 0, 0])
|
|
149
|
+
>>> round(r["accuracy"], 2)
|
|
150
|
+
0.75
|
|
151
|
+
"""
|
|
152
|
+
task = task or _infer_task(y_true, y_prob)
|
|
153
|
+
if task == "classification":
|
|
154
|
+
return _evaluate_classification(
|
|
155
|
+
y_true, y_pred, y_prob, metrics, average, labels, pos_label, sample_weight, zero_division
|
|
156
|
+
)
|
|
157
|
+
if task == "regression":
|
|
158
|
+
if y_pred is None:
|
|
159
|
+
raise InputValidationError("Regression evaluation needs y_pred.")
|
|
160
|
+
return _evaluate_regression(y_true, y_pred, metrics, sample_weight)
|
|
161
|
+
raise UnsupportedTaskError("task must be 'classification' or 'regression' in EvalSuite 0.1.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _metadata(**extra: Any) -> dict[str, Any]:
|
|
165
|
+
return {
|
|
166
|
+
"evalsuite_version": __version__,
|
|
167
|
+
"numpy_version": np.__version__,
|
|
168
|
+
"python_version": platform.python_version(),
|
|
169
|
+
**extra,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _evaluate_classification(
|
|
174
|
+
y_true: ArrayLike,
|
|
175
|
+
y_pred: Optional[ArrayLike],
|
|
176
|
+
y_prob: Optional[ArrayLike],
|
|
177
|
+
metrics: Optional[Sequence[str]],
|
|
178
|
+
average: Optional[str],
|
|
179
|
+
labels: Optional[ArrayLike],
|
|
180
|
+
pos_label: Any,
|
|
181
|
+
sample_weight: Optional[ArrayLike],
|
|
182
|
+
zero_division: ZeroDivision,
|
|
183
|
+
) -> EvaluationResult:
|
|
184
|
+
if y_pred is None and y_prob is None:
|
|
185
|
+
raise InputValidationError("Classification evaluation needs y_pred, y_prob, or both.")
|
|
186
|
+
ctx = ClassificationContext(y_true, y_pred, y_prob=y_prob, labels=labels, sample_weight=sample_weight)
|
|
187
|
+
registry = _classification_registry(average, pos_label, zero_division)
|
|
188
|
+
if metrics is None:
|
|
189
|
+
multilabel = ctx.target_type == "multilabel"
|
|
190
|
+
names = [] if y_pred is None else list(_DEFAULT_MULTILABEL if multilabel else _DEFAULT_SINGLE)
|
|
191
|
+
if y_prob is not None:
|
|
192
|
+
names += _DEFAULT_PROB_MULTILABEL if multilabel else _DEFAULT_PROB
|
|
193
|
+
else:
|
|
194
|
+
names = list(metrics)
|
|
195
|
+
unknown = [m for m in names if m not in registry]
|
|
196
|
+
if unknown:
|
|
197
|
+
raise InputValidationError(
|
|
198
|
+
f"Unknown classification metric(s): {', '.join(unknown)}. Available: {', '.join(registry)}."
|
|
199
|
+
)
|
|
200
|
+
out: dict[str, MetricResult] = {}
|
|
201
|
+
for name in names:
|
|
202
|
+
fn, needs_prob = registry[name]
|
|
203
|
+
if needs_prob and y_prob is None:
|
|
204
|
+
raise InputValidationError(f"Metric '{name}' needs y_prob (predicted probabilities).")
|
|
205
|
+
if not needs_prob and y_pred is None:
|
|
206
|
+
raise InputValidationError(f"Metric '{name}' needs y_pred (predicted labels).")
|
|
207
|
+
out[name] = fn(ctx)
|
|
208
|
+
cm = None if ctx.target_type == "multilabel" or y_pred is None else ctx.confusion_matrix
|
|
209
|
+
return EvaluationResult(
|
|
210
|
+
task="classification",
|
|
211
|
+
metrics=out,
|
|
212
|
+
n_samples=ctx.n,
|
|
213
|
+
target_type=ctx.target_type,
|
|
214
|
+
labels=tuple(ctx.labels.tolist()),
|
|
215
|
+
confusion_matrix=cm,
|
|
216
|
+
metadata=_metadata(average=average, zero_division=zero_division, weighted=sample_weight is not None),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _evaluate_regression(
|
|
221
|
+
y_true: ArrayLike, y_pred: ArrayLike, metrics: Optional[Sequence[str]], sample_weight: Optional[ArrayLike]
|
|
222
|
+
) -> EvaluationResult:
|
|
223
|
+
yt = to_numpy(y_true, "y_true", allow_2d=True)
|
|
224
|
+
multi = yt.ndim == 2
|
|
225
|
+
if metrics is None:
|
|
226
|
+
names = [m for m in _DEFAULT_REGRESSION if not (multi and m == "max_error")]
|
|
227
|
+
else:
|
|
228
|
+
names = list(metrics)
|
|
229
|
+
unknown = [m for m in names if m not in _REGRESSION]
|
|
230
|
+
if unknown:
|
|
231
|
+
raise InputValidationError(
|
|
232
|
+
f"Unknown regression metric(s): {', '.join(unknown)}. Available: {', '.join(_REGRESSION)}."
|
|
233
|
+
)
|
|
234
|
+
out: dict[str, MetricResult] = {}
|
|
235
|
+
shared = reg._Inputs(y_true, y_pred, sample_weight) # validate once for every metric
|
|
236
|
+
token = reg._SHARED.set((id(y_true), id(y_pred), id(sample_weight), shared))
|
|
237
|
+
try:
|
|
238
|
+
_regression_loop(names, out, y_true, y_pred, sample_weight)
|
|
239
|
+
finally:
|
|
240
|
+
reg._SHARED.reset(token)
|
|
241
|
+
return EvaluationResult(
|
|
242
|
+
task="regression",
|
|
243
|
+
metrics=out,
|
|
244
|
+
n_samples=yt.shape[0],
|
|
245
|
+
target_type="continuous",
|
|
246
|
+
metadata=_metadata(weighted=sample_weight is not None, outputs=yt.shape[1] if multi else 1),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _regression_loop(
|
|
251
|
+
names: list[str],
|
|
252
|
+
out: dict[str, MetricResult],
|
|
253
|
+
y_true: ArrayLike,
|
|
254
|
+
y_pred: ArrayLike,
|
|
255
|
+
sample_weight: Optional[ArrayLike],
|
|
256
|
+
) -> None:
|
|
257
|
+
for name in names:
|
|
258
|
+
fn = _REGRESSION[name]
|
|
259
|
+
if name in _NO_WEIGHTS:
|
|
260
|
+
if sample_weight is not None:
|
|
261
|
+
raise InputValidationError(f"Metric '{name}' does not support sample_weight.")
|
|
262
|
+
out[name] = fn(y_true, y_pred)
|
|
263
|
+
else:
|
|
264
|
+
out[name] = fn(y_true, y_pred, sample_weight=sample_weight)
|
evalsuite/benchmarks.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Speed and memory benchmarks, against scikit-learn when it is installed.
|
|
2
|
+
|
|
3
|
+
Each case times the fastest of ``repeat`` runs (after one warm-up) and measures peak traced memory with
|
|
4
|
+
``tracemalloc`` (NumPy reports its allocations to it). Both libraries compute the same metrics on the same
|
|
5
|
+
data, and the largest absolute difference between their results is reported, so speed is never shown for
|
|
6
|
+
numbers that disagree.
|
|
7
|
+
|
|
8
|
+
>>> from evalsuite.benchmarks import run_benchmarks
|
|
9
|
+
>>> print(run_benchmarks(sizes=(10_000,), repeat=3)) # doctest: +SKIP
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import platform
|
|
16
|
+
import time
|
|
17
|
+
import tracemalloc
|
|
18
|
+
import warnings
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from types import MappingProxyType
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Optional, cast
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from .core.result import _json_safe, _latex_escape, _latex_table
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
import pandas as pd
|
|
30
|
+
|
|
31
|
+
__all__ = ["BenchmarkResult", "run_benchmarks"]
|
|
32
|
+
|
|
33
|
+
_HEADER = (
|
|
34
|
+
"case",
|
|
35
|
+
"n",
|
|
36
|
+
"evalsuite_ms",
|
|
37
|
+
"sklearn_ms",
|
|
38
|
+
"speedup",
|
|
39
|
+
"evalsuite_peak_mb",
|
|
40
|
+
"sklearn_peak_mb",
|
|
41
|
+
"max_abs_diff",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _measure(fn: Callable[[], Any], repeat: int) -> tuple[float, float, Any]:
|
|
46
|
+
"""(fastest seconds, peak MiB, result)."""
|
|
47
|
+
with warnings.catch_warnings():
|
|
48
|
+
warnings.simplefilter("ignore")
|
|
49
|
+
result = fn() # warm-up
|
|
50
|
+
best = float("inf")
|
|
51
|
+
for _ in range(repeat):
|
|
52
|
+
t0 = time.perf_counter()
|
|
53
|
+
fn()
|
|
54
|
+
best = min(best, time.perf_counter() - t0)
|
|
55
|
+
tracemalloc.start()
|
|
56
|
+
try:
|
|
57
|
+
fn()
|
|
58
|
+
_, peak = tracemalloc.get_traced_memory()
|
|
59
|
+
finally:
|
|
60
|
+
tracemalloc.stop()
|
|
61
|
+
return best, peak / 2**20, result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cases(n: int, rng: np.random.Generator) -> list[tuple[str, Callable[[], Any], Optional[Callable[[], Any]]]]:
|
|
65
|
+
import evalsuite as es
|
|
66
|
+
|
|
67
|
+
y = rng.integers(0, 2, n)
|
|
68
|
+
p = np.where(rng.random(n) < 0.8, y, 1 - y)
|
|
69
|
+
prob = 1 / (1 + np.exp(-(2.0 * (y - 0.5) + rng.normal(0, 1, n))))
|
|
70
|
+
yk = rng.integers(0, 10, n)
|
|
71
|
+
pk = np.where(rng.random(n) < 0.7, yk, rng.integers(0, 10, n))
|
|
72
|
+
yr = rng.normal(10, 3, n)
|
|
73
|
+
pr = yr + rng.normal(0, 1, n)
|
|
74
|
+
names = ["accuracy", "balanced_accuracy", "precision", "recall", "f1", "specificity", "mcc", "cohen_kappa"]
|
|
75
|
+
|
|
76
|
+
def es_binary() -> list[float]:
|
|
77
|
+
r = es.evaluate(y, p, metrics=names)
|
|
78
|
+
return [float(r[m]) for m in names]
|
|
79
|
+
|
|
80
|
+
def es_reg() -> list[float]:
|
|
81
|
+
r = es.evaluate(yr, pr, metrics=["mae", "mse", "rmse", "r2"])
|
|
82
|
+
return [float(r[m]) for m in ("mae", "mse", "rmse", "r2")]
|
|
83
|
+
|
|
84
|
+
cases: list[tuple[str, Callable[[], Any], Optional[Callable[[], Any]]]] = [
|
|
85
|
+
("binary: 8 label metrics via evaluate()", es_binary, None),
|
|
86
|
+
("10 classes: macro F1", lambda: [float(es.f1(yk, pk, average="macro"))], None),
|
|
87
|
+
("binary: ROC AUC", lambda: [float(es.roc_auc(y, prob))], None),
|
|
88
|
+
("regression: MAE, MSE, RMSE, R² via evaluate()", es_reg, None),
|
|
89
|
+
]
|
|
90
|
+
try:
|
|
91
|
+
import sklearn.metrics as skm # type: ignore[import-untyped]
|
|
92
|
+
except ImportError:
|
|
93
|
+
return cases
|
|
94
|
+
|
|
95
|
+
def sk_binary() -> list[float]:
|
|
96
|
+
return [
|
|
97
|
+
skm.accuracy_score(y, p),
|
|
98
|
+
skm.balanced_accuracy_score(y, p),
|
|
99
|
+
skm.precision_score(y, p),
|
|
100
|
+
skm.recall_score(y, p),
|
|
101
|
+
skm.f1_score(y, p),
|
|
102
|
+
skm.recall_score(y, p, pos_label=0),
|
|
103
|
+
skm.matthews_corrcoef(y, p),
|
|
104
|
+
skm.cohen_kappa_score(y, p),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def sk_reg() -> list[float]:
|
|
108
|
+
mse = skm.mean_squared_error(yr, pr)
|
|
109
|
+
return [skm.mean_absolute_error(yr, pr), mse, float(np.sqrt(mse)), skm.r2_score(yr, pr)]
|
|
110
|
+
|
|
111
|
+
sk = [sk_binary, lambda: [skm.f1_score(yk, pk, average="macro")], lambda: [skm.roc_auc_score(y, prob)], sk_reg]
|
|
112
|
+
return [(name, es_fn, sk_fn) for (name, es_fn, _), sk_fn in zip(cases, sk)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, eq=False)
|
|
116
|
+
class BenchmarkResult:
|
|
117
|
+
"""Rows of timings (milliseconds), peak memory (MiB) and agreement, plus the environment they ran in."""
|
|
118
|
+
|
|
119
|
+
rows: tuple[Any, ...]
|
|
120
|
+
environment: Any = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
object.__setattr__(self, "rows", tuple(MappingProxyType(dict(r)) for r in self.rows))
|
|
124
|
+
object.__setattr__(self, "environment", MappingProxyType(dict(self.environment)))
|
|
125
|
+
|
|
126
|
+
def _cells(self, digits: int) -> list[list[str]]:
|
|
127
|
+
def f(v: Any, d: int = digits) -> str:
|
|
128
|
+
return "–" if v is None else f"{v:.{d}f}"
|
|
129
|
+
|
|
130
|
+
return [
|
|
131
|
+
[
|
|
132
|
+
r["case"],
|
|
133
|
+
f"{r['n']:,}",
|
|
134
|
+
f(r["evalsuite_ms"]),
|
|
135
|
+
f(r["sklearn_ms"]),
|
|
136
|
+
"–" if r["speedup"] is None else f"{r['speedup']:.2f}×",
|
|
137
|
+
f(r["evalsuite_peak_mb"], 2),
|
|
138
|
+
f(r["sklearn_peak_mb"], 2),
|
|
139
|
+
"–" if r["max_abs_diff"] is None else f"{r['max_abs_diff']:.1e}",
|
|
140
|
+
]
|
|
141
|
+
for r in self.rows
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
_TITLES = (
|
|
145
|
+
"Case",
|
|
146
|
+
"n",
|
|
147
|
+
"EvalSuite (ms)",
|
|
148
|
+
"scikit-learn (ms)",
|
|
149
|
+
"Speed-up",
|
|
150
|
+
"EvalSuite peak (MiB)",
|
|
151
|
+
"scikit-learn peak (MiB)",
|
|
152
|
+
"Max |difference|",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def summary(self, *, digits: int = 3) -> str:
|
|
156
|
+
cells = self._cells(digits)
|
|
157
|
+
widths = [max(len(t), *(len(c[i]) for c in cells)) for i, t in enumerate(self._TITLES)]
|
|
158
|
+
|
|
159
|
+
def line(c: Sequence[str]) -> str:
|
|
160
|
+
return " ".join(x.ljust(widths[i]) if i == 0 else x.rjust(widths[i]) for i, x in enumerate(c))
|
|
161
|
+
|
|
162
|
+
env = self.environment
|
|
163
|
+
head = (
|
|
164
|
+
f"EvalSuite {env['evalsuite']} benchmarks | Python {env['python']} | NumPy {env['numpy']}"
|
|
165
|
+
+ (f" | scikit-learn {env['sklearn']}" if env.get("sklearn") else "")
|
|
166
|
+
+ f" | {env['machine']} | fastest of {env['repeat']} runs"
|
|
167
|
+
)
|
|
168
|
+
note = "Speed-up > 1 means EvalSuite is faster. Max |difference| compares the two libraries' results."
|
|
169
|
+
return "\n".join([head, "", line(self._TITLES), *(line(c) for c in cells), "", note])
|
|
170
|
+
|
|
171
|
+
def __repr__(self) -> str:
|
|
172
|
+
return self.summary()
|
|
173
|
+
|
|
174
|
+
def to_dict(self) -> dict[str, Any]:
|
|
175
|
+
return cast(
|
|
176
|
+
"dict[str, Any]",
|
|
177
|
+
_json_safe({"environment": dict(self.environment), "rows": [dict(r) for r in self.rows]}),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def to_json(self, *, indent: Optional[int] = 2) -> str:
|
|
181
|
+
return json.dumps(self.to_dict(), indent=indent, allow_nan=False)
|
|
182
|
+
|
|
183
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
184
|
+
import pandas as pd
|
|
185
|
+
|
|
186
|
+
frame: pd.DataFrame = pd.DataFrame([dict(r) for r in self.rows])
|
|
187
|
+
return frame
|
|
188
|
+
|
|
189
|
+
def to_csv(self, path: Optional[str] = None) -> str:
|
|
190
|
+
from .core.export import csv_text
|
|
191
|
+
|
|
192
|
+
rows = [[r[h] if r[h] is not None else float("nan") for h in _HEADER] for r in self.rows]
|
|
193
|
+
text = csv_text(list(_HEADER), rows)
|
|
194
|
+
if path is not None:
|
|
195
|
+
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
196
|
+
fh.write(text)
|
|
197
|
+
return text
|
|
198
|
+
|
|
199
|
+
def to_markdown(self, *, digits: int = 3) -> str:
|
|
200
|
+
lines = ["| " + " | ".join(self._TITLES) + " |", "| --- |" + " ---: |" * (len(self._TITLES) - 1)]
|
|
201
|
+
return "\n".join(lines + ["| " + " | ".join(c) + " |" for c in self._cells(digits)])
|
|
202
|
+
|
|
203
|
+
def to_latex(self, *, digits: int = 3, caption: Optional[str] = None, label: Optional[str] = None) -> str:
|
|
204
|
+
return _latex_table(
|
|
205
|
+
[_latex_escape(t) for t in self._TITLES],
|
|
206
|
+
[[_latex_escape(x) for x in c] for c in self._cells(digits)],
|
|
207
|
+
caption=caption or "EvalSuite benchmarks.",
|
|
208
|
+
label=label,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def to_html(self, *, digits: int = 3, full: bool = False) -> str:
|
|
212
|
+
from .core.export import html_document, html_table
|
|
213
|
+
|
|
214
|
+
table = html_table(list(self._TITLES), self._cells(digits), caption="Benchmarks")
|
|
215
|
+
env = self.environment
|
|
216
|
+
meta = f"Python {env['python']}, NumPy {env['numpy']}, {env['machine']}"
|
|
217
|
+
return html_document("EvalSuite benchmarks", table, meta) if full else table
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run_benchmarks(
|
|
221
|
+
sizes: Sequence[int] = (1_000, 100_000, 1_000_000),
|
|
222
|
+
*,
|
|
223
|
+
repeat: int = 5,
|
|
224
|
+
compare_sklearn: bool = True,
|
|
225
|
+
random_state: Optional[int] = 0,
|
|
226
|
+
) -> BenchmarkResult:
|
|
227
|
+
"""Time and memory for common evaluation workloads at each size, against scikit-learn if installed."""
|
|
228
|
+
import evalsuite as es
|
|
229
|
+
|
|
230
|
+
if repeat < 1:
|
|
231
|
+
raise ValueError("repeat must be at least 1.")
|
|
232
|
+
rng = np.random.default_rng(random_state)
|
|
233
|
+
rows: list[dict[str, Any]] = []
|
|
234
|
+
sk_version: Optional[str] = None
|
|
235
|
+
if compare_sklearn:
|
|
236
|
+
try:
|
|
237
|
+
import sklearn
|
|
238
|
+
|
|
239
|
+
sk_version = sklearn.__version__
|
|
240
|
+
except ImportError:
|
|
241
|
+
compare_sklearn = False
|
|
242
|
+
for n in sizes:
|
|
243
|
+
for name, es_fn, sk_fn in _cases(int(n), rng):
|
|
244
|
+
es_t, es_mem, es_val = _measure(es_fn, repeat)
|
|
245
|
+
row: dict[str, Any] = {
|
|
246
|
+
"case": name,
|
|
247
|
+
"n": int(n),
|
|
248
|
+
"evalsuite_ms": es_t * 1000,
|
|
249
|
+
"evalsuite_peak_mb": es_mem,
|
|
250
|
+
"sklearn_ms": None,
|
|
251
|
+
"sklearn_peak_mb": None,
|
|
252
|
+
"speedup": None,
|
|
253
|
+
"max_abs_diff": None,
|
|
254
|
+
}
|
|
255
|
+
if compare_sklearn and sk_fn is not None:
|
|
256
|
+
sk_t, sk_mem, sk_val = _measure(sk_fn, repeat)
|
|
257
|
+
row.update(
|
|
258
|
+
sklearn_ms=sk_t * 1000,
|
|
259
|
+
sklearn_peak_mb=sk_mem,
|
|
260
|
+
speedup=sk_t / es_t if es_t else None,
|
|
261
|
+
max_abs_diff=float(np.max(np.abs(np.asarray(es_val, float) - np.asarray(sk_val, float)))),
|
|
262
|
+
)
|
|
263
|
+
rows.append(row)
|
|
264
|
+
env = {
|
|
265
|
+
"evalsuite": es.__version__,
|
|
266
|
+
"python": platform.python_version(),
|
|
267
|
+
"numpy": np.__version__,
|
|
268
|
+
"sklearn": sk_version,
|
|
269
|
+
"machine": f"{platform.system()} {platform.machine()}",
|
|
270
|
+
"repeat": repeat,
|
|
271
|
+
}
|
|
272
|
+
return BenchmarkResult(tuple(rows), env)
|