model-eval-toolkit 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.
- evalreport/__init__.py +28 -0
- evalreport/__version__.py +2 -0
- evalreport/classification/__init__.py +4 -0
- evalreport/classification/report.py +319 -0
- evalreport/clustering/__init__.py +4 -0
- evalreport/clustering/report.py +174 -0
- evalreport/core/base_report.py +479 -0
- evalreport/core/entrypoints.py +97 -0
- evalreport/core/task_inference.py +180 -0
- evalreport/nlp/__init__.py +5 -0
- evalreport/nlp/text_classification.py +21 -0
- evalreport/nlp/text_generation.py +202 -0
- evalreport/ranking/__init__.py +3 -0
- evalreport/ranking/report.py +274 -0
- evalreport/regression/__init__.py +4 -0
- evalreport/regression/report.py +173 -0
- evalreport/timeseries/__init__.py +4 -0
- evalreport/timeseries/report.py +211 -0
- evalreport/vision/__init__.py +6 -0
- evalreport/vision/detection.py +359 -0
- evalreport/vision/image_classification.py +25 -0
- evalreport/vision/segmentation.py +140 -0
- model_eval_toolkit-0.1.0.dist-info/METADATA +339 -0
- model_eval_toolkit-0.1.0.dist-info/RECORD +27 -0
- model_eval_toolkit-0.1.0.dist-info/WHEEL +5 -0
- model_eval_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- model_eval_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Iterable, List, Optional
|
|
6
|
+
|
|
7
|
+
import matplotlib
|
|
8
|
+
matplotlib.use("Agg")
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
import numpy as np
|
|
11
|
+
from sklearn.metrics import (
|
|
12
|
+
mean_absolute_error,
|
|
13
|
+
mean_squared_error,
|
|
14
|
+
median_absolute_error,
|
|
15
|
+
r2_score,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from ..core.base_report import BaseReport
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _as_float_array(x: Optional[Iterable[Any]]) -> Optional[np.ndarray]:
|
|
22
|
+
if x is None:
|
|
23
|
+
return None
|
|
24
|
+
arr = np.asarray(list(x), dtype=float)
|
|
25
|
+
return arr
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_float(x: Any) -> Any:
|
|
29
|
+
try:
|
|
30
|
+
if isinstance(x, (np.floating, np.integer)):
|
|
31
|
+
return x.item()
|
|
32
|
+
if isinstance(x, float) and (np.isnan(x) or np.isinf(x)):
|
|
33
|
+
return None
|
|
34
|
+
return float(x)
|
|
35
|
+
except Exception:
|
|
36
|
+
return x
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RegressionReport(BaseReport):
|
|
41
|
+
y_true: Optional[Iterable[Any]] = None
|
|
42
|
+
y_pred: Optional[Iterable[Any]] = None
|
|
43
|
+
|
|
44
|
+
def _compute_metrics(self) -> None:
|
|
45
|
+
y_true = _as_float_array(self.y_true)
|
|
46
|
+
y_pred = _as_float_array(self.y_pred)
|
|
47
|
+
if y_true is None or y_pred is None:
|
|
48
|
+
raise ValueError("RegressionReport requires y_true and y_pred.")
|
|
49
|
+
|
|
50
|
+
mse = mean_squared_error(y_true, y_pred)
|
|
51
|
+
rmse = float(np.sqrt(mse))
|
|
52
|
+
|
|
53
|
+
self.metrics["mae"] = _safe_float(mean_absolute_error(y_true, y_pred))
|
|
54
|
+
self.metrics["mse"] = _safe_float(mse)
|
|
55
|
+
self.metrics["rmse"] = _safe_float(rmse)
|
|
56
|
+
self.metrics["r2"] = _safe_float(r2_score(y_true, y_pred))
|
|
57
|
+
self.metrics["median_ae"] = _safe_float(median_absolute_error(y_true, y_pred))
|
|
58
|
+
|
|
59
|
+
# MAPE (skip samples where |y_true| ~ 0; undefined if none left)
|
|
60
|
+
valid = np.abs(y_true) >= 1e-12
|
|
61
|
+
if valid.any():
|
|
62
|
+
rel = np.abs((y_true[valid] - y_pred[valid]) / np.abs(y_true[valid]))
|
|
63
|
+
self.metrics["mape"] = _safe_float(float(np.mean(rel) * 100.0))
|
|
64
|
+
else:
|
|
65
|
+
self.metrics["mape"] = None
|
|
66
|
+
|
|
67
|
+
# Mean error (bias)
|
|
68
|
+
self.metrics["mean_error"] = _safe_float(float(np.mean(y_pred - y_true)))
|
|
69
|
+
|
|
70
|
+
def _generate_plots(self) -> None:
|
|
71
|
+
y_true = _as_float_array(self.y_true)
|
|
72
|
+
y_pred = _as_float_array(self.y_pred)
|
|
73
|
+
if y_true is None or y_pred is None:
|
|
74
|
+
self.plots = {}
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
root = self.output_dir or Path("reports")
|
|
78
|
+
plot_dir = root / "evalreport_plots"
|
|
79
|
+
plot_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
plots: dict[str, str] = {}
|
|
81
|
+
|
|
82
|
+
# Residual plot (residual vs predicted)
|
|
83
|
+
try:
|
|
84
|
+
residuals = y_pred - y_true
|
|
85
|
+
plt.figure(figsize=(4, 3))
|
|
86
|
+
plt.scatter(y_pred, residuals, alpha=0.7)
|
|
87
|
+
plt.axhline(0, color="red", linestyle="--")
|
|
88
|
+
plt.xlabel("Predicted")
|
|
89
|
+
plt.ylabel("Residual (y_pred - y_true)")
|
|
90
|
+
plt.title("Residual Plot")
|
|
91
|
+
path = plot_dir / "regression_residuals.png"
|
|
92
|
+
plt.tight_layout()
|
|
93
|
+
plt.savefig(path)
|
|
94
|
+
plt.close()
|
|
95
|
+
plots["residual_plot"] = str(path)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
# Predicted vs Actual
|
|
100
|
+
try:
|
|
101
|
+
plt.figure(figsize=(4, 3))
|
|
102
|
+
plt.scatter(y_true, y_pred, alpha=0.7)
|
|
103
|
+
min_v = float(min(np.min(y_true), np.min(y_pred)))
|
|
104
|
+
max_v = float(max(np.max(y_true), np.max(y_pred)))
|
|
105
|
+
plt.plot([min_v, max_v], [min_v, max_v], "k--", label="Ideal")
|
|
106
|
+
plt.xlabel("Actual")
|
|
107
|
+
plt.ylabel("Predicted")
|
|
108
|
+
plt.title("Predicted vs Actual")
|
|
109
|
+
plt.legend()
|
|
110
|
+
path = plot_dir / "regression_pred_vs_actual.png"
|
|
111
|
+
plt.tight_layout()
|
|
112
|
+
plt.savefig(path)
|
|
113
|
+
plt.close()
|
|
114
|
+
plots["predicted_vs_actual"] = str(path)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Error distribution
|
|
119
|
+
try:
|
|
120
|
+
residuals = y_pred - y_true
|
|
121
|
+
plt.figure(figsize=(4, 3))
|
|
122
|
+
plt.hist(residuals, bins=20, alpha=0.8)
|
|
123
|
+
plt.xlabel("Residual")
|
|
124
|
+
plt.ylabel("Count")
|
|
125
|
+
plt.title("Error Distribution")
|
|
126
|
+
path = plot_dir / "regression_error_distribution.png"
|
|
127
|
+
plt.tight_layout()
|
|
128
|
+
plt.savefig(path)
|
|
129
|
+
plt.close()
|
|
130
|
+
plots["error_distribution"] = str(path)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
self.plots = plots
|
|
135
|
+
|
|
136
|
+
def _generate_insights(self) -> None:
|
|
137
|
+
y_true = _as_float_array(self.y_true)
|
|
138
|
+
y_pred = _as_float_array(self.y_pred)
|
|
139
|
+
if y_true is None or y_pred is None:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
insights: List[str] = []
|
|
143
|
+
|
|
144
|
+
err = y_pred - y_true
|
|
145
|
+
mean_err = float(np.mean(err))
|
|
146
|
+
if abs(mean_err) > 0:
|
|
147
|
+
direction = "positive" if mean_err > 0 else "negative"
|
|
148
|
+
insights.append(f"Predictions show {direction} bias (mean error={mean_err:.4g}).")
|
|
149
|
+
|
|
150
|
+
# Outlier influence heuristic: large 95th percentile absolute error
|
|
151
|
+
abs_err = np.abs(err)
|
|
152
|
+
p95 = float(np.percentile(abs_err, 95))
|
|
153
|
+
med = float(np.median(abs_err))
|
|
154
|
+
if med > 0 and (p95 / med) >= 5:
|
|
155
|
+
insights.append(
|
|
156
|
+
f"Errors have heavy tail (p95 abs error≈{p95:.4g} vs median≈{med:.4g}); check outliers."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self.insights = insights
|
|
160
|
+
|
|
161
|
+
# Descriptions for key metrics shown in HTML/PDF
|
|
162
|
+
self.metric_descriptions.update(
|
|
163
|
+
{
|
|
164
|
+
"mae": "Mean absolute error; average absolute deviation between predictions and true values.",
|
|
165
|
+
"mse": "Mean squared error; penalizes larger errors more strongly.",
|
|
166
|
+
"rmse": "Root mean squared error; error in the same units as the target.",
|
|
167
|
+
"r2": "Coefficient of determination; proportion of variance explained by the model.",
|
|
168
|
+
"median_ae": "Median absolute error; robust to outliers in the error distribution.",
|
|
169
|
+
"mape": "Mean absolute percentage error; average relative error as a percentage.",
|
|
170
|
+
"mean_error": "Signed average error; indicates systematic over- or under-prediction.",
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Iterable, List, Optional
|
|
6
|
+
|
|
7
|
+
import matplotlib
|
|
8
|
+
|
|
9
|
+
matplotlib.use("Agg")
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..core.base_report import BaseReport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _as_float_1d(x: Optional[Iterable[Any]]) -> Optional[np.ndarray]:
|
|
17
|
+
if x is None:
|
|
18
|
+
return None
|
|
19
|
+
arr = np.asarray(list(x), dtype=float).reshape(-1)
|
|
20
|
+
return arr
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _as_1d(x: Optional[Iterable[Any]]) -> Optional[np.ndarray]:
|
|
24
|
+
if x is None:
|
|
25
|
+
return None
|
|
26
|
+
return np.asarray(list(x))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TimeSeriesReport(BaseReport):
|
|
31
|
+
y_true: Optional[Iterable[Any]] = None
|
|
32
|
+
y_pred: Optional[Iterable[Any]] = None
|
|
33
|
+
timestamps: Optional[Iterable[Any]] = None
|
|
34
|
+
rolling_window: int = 5
|
|
35
|
+
|
|
36
|
+
def _compute_metrics(self) -> None:
|
|
37
|
+
y_true = _as_float_1d(self.y_true)
|
|
38
|
+
y_pred = _as_float_1d(self.y_pred)
|
|
39
|
+
ts = _as_1d(self.timestamps)
|
|
40
|
+
if y_true is None or y_pred is None or ts is None:
|
|
41
|
+
raise ValueError("TimeSeriesReport requires y_true, y_pred, and timestamps.")
|
|
42
|
+
if not (len(y_true) == len(y_pred) == len(ts)):
|
|
43
|
+
raise ValueError("TimeSeriesReport requires y_true, y_pred, timestamps with equal length.")
|
|
44
|
+
if len(y_true) < 2:
|
|
45
|
+
raise ValueError("TimeSeriesReport requires at least 2 samples.")
|
|
46
|
+
|
|
47
|
+
err = y_pred - y_true
|
|
48
|
+
abs_err = np.abs(err)
|
|
49
|
+
|
|
50
|
+
mae = float(np.mean(abs_err))
|
|
51
|
+
mse = float(np.mean(err**2))
|
|
52
|
+
rmse = float(np.sqrt(mse))
|
|
53
|
+
|
|
54
|
+
denom = np.where(np.abs(y_true) < 1e-12, np.nan, np.abs(y_true))
|
|
55
|
+
mape = float(np.nanmean(abs_err / denom) * 100.0)
|
|
56
|
+
if np.isnan(mape):
|
|
57
|
+
mape = None # undefined when all targets are ~0
|
|
58
|
+
|
|
59
|
+
smape_denom = np.abs(y_true) + np.abs(y_pred)
|
|
60
|
+
smape = float(np.mean(np.where(smape_denom < 1e-12, np.nan, 2.0 * abs_err / smape_denom)) * 100.0)
|
|
61
|
+
if np.isnan(smape):
|
|
62
|
+
smape = None
|
|
63
|
+
|
|
64
|
+
mean_forecast_error = float(np.mean(err))
|
|
65
|
+
|
|
66
|
+
# Rolling RMSE
|
|
67
|
+
w = int(self.rolling_window)
|
|
68
|
+
w = max(2, min(w, len(y_true)))
|
|
69
|
+
rolling = []
|
|
70
|
+
for i in range(w - 1, len(y_true)):
|
|
71
|
+
window_err = err[i - w + 1 : i + 1]
|
|
72
|
+
rolling.append(float(np.sqrt(np.mean(window_err**2))))
|
|
73
|
+
|
|
74
|
+
# Cache the rolling window actually used so plots align with metrics.
|
|
75
|
+
self._cached_rolling_window = w
|
|
76
|
+
|
|
77
|
+
rolling_rmse_last = float(rolling[-1]) if rolling else None
|
|
78
|
+
rolling_rmse_mean = float(np.mean(rolling)) if rolling else None
|
|
79
|
+
|
|
80
|
+
self.metrics.update(
|
|
81
|
+
{
|
|
82
|
+
"mae": mae,
|
|
83
|
+
"mse": mse,
|
|
84
|
+
"rmse": rmse,
|
|
85
|
+
"mape": mape,
|
|
86
|
+
"smape": smape,
|
|
87
|
+
"mean_forecast_error": mean_forecast_error,
|
|
88
|
+
"rolling_rmse_mean": rolling_rmse_mean,
|
|
89
|
+
"rolling_rmse_last": rolling_rmse_last,
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Store for plotting
|
|
95
|
+
self._cached_ts = ts
|
|
96
|
+
self._cached_y_true = y_true
|
|
97
|
+
self._cached_y_pred = y_pred
|
|
98
|
+
self._cached_rolling_rmse = rolling
|
|
99
|
+
|
|
100
|
+
def _generate_plots(self) -> None:
|
|
101
|
+
y_true = _as_float_1d(self.y_true)
|
|
102
|
+
y_pred = _as_float_1d(self.y_pred)
|
|
103
|
+
ts = _as_1d(self.timestamps)
|
|
104
|
+
if y_true is None or y_pred is None or ts is None:
|
|
105
|
+
self.plots = {}
|
|
106
|
+
return
|
|
107
|
+
if len(y_true) != len(y_pred) or len(y_true) != len(ts):
|
|
108
|
+
self.plots = {}
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
root = self.output_dir or Path("reports")
|
|
112
|
+
plot_dir = root / "evalreport_plots"
|
|
113
|
+
plot_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
# Ensure chronological order
|
|
116
|
+
order = np.argsort(ts)
|
|
117
|
+
ts_sorted = ts[order]
|
|
118
|
+
y_true_sorted = y_true[order]
|
|
119
|
+
y_pred_sorted = y_pred[order]
|
|
120
|
+
residuals = y_pred_sorted - y_true_sorted
|
|
121
|
+
|
|
122
|
+
# Rolling RMSE aligns with timestamps after (w-1) offset.
|
|
123
|
+
rolling = getattr(self, "_cached_rolling_rmse", None)
|
|
124
|
+
w = getattr(self, "_cached_rolling_window", max(2, int(self.rolling_window)))
|
|
125
|
+
if len(ts_sorted) >= w:
|
|
126
|
+
rolling_ts = ts_sorted[w - 1 :]
|
|
127
|
+
else:
|
|
128
|
+
# Extremely short series; align lengths defensively.
|
|
129
|
+
rolling_ts = ts_sorted[: len(rolling)] if rolling is not None else ts_sorted
|
|
130
|
+
|
|
131
|
+
plots: dict[str, str] = {}
|
|
132
|
+
|
|
133
|
+
# Actual vs Forecast
|
|
134
|
+
plt.figure(figsize=(6, 4))
|
|
135
|
+
plt.plot(ts_sorted, y_true_sorted, label="Actual", linewidth=2)
|
|
136
|
+
plt.plot(ts_sorted, y_pred_sorted, label="Forecast", linewidth=2, linestyle="--")
|
|
137
|
+
plt.xlabel("Time")
|
|
138
|
+
plt.ylabel("Value")
|
|
139
|
+
plt.title("Actual vs Forecast")
|
|
140
|
+
plt.legend()
|
|
141
|
+
path = plot_dir / "timeseries_actual_vs_forecast.png"
|
|
142
|
+
plt.tight_layout()
|
|
143
|
+
plt.savefig(path)
|
|
144
|
+
plt.close()
|
|
145
|
+
plots["actual_vs_forecast"] = str(path)
|
|
146
|
+
|
|
147
|
+
# Residuals over time
|
|
148
|
+
plt.figure(figsize=(6, 3.8))
|
|
149
|
+
plt.plot(ts_sorted, residuals, label="Residual", color="#D55E00")
|
|
150
|
+
plt.axhline(0, color="black", linewidth=1)
|
|
151
|
+
plt.xlabel("Time")
|
|
152
|
+
plt.ylabel("Residual (y_pred - y_true)")
|
|
153
|
+
plt.title("Residuals over time")
|
|
154
|
+
plt.legend()
|
|
155
|
+
path = plot_dir / "timeseries_residuals_over_time.png"
|
|
156
|
+
plt.tight_layout()
|
|
157
|
+
plt.savefig(path)
|
|
158
|
+
plt.close()
|
|
159
|
+
plots["residuals_over_time"] = str(path)
|
|
160
|
+
|
|
161
|
+
# Rolling RMSE
|
|
162
|
+
if rolling is not None and len(rolling) > 0:
|
|
163
|
+
plt.figure(figsize=(6, 3.8))
|
|
164
|
+
plt.plot(rolling_ts, rolling, label="Rolling RMSE", color="#0072B2")
|
|
165
|
+
plt.xlabel("Time")
|
|
166
|
+
plt.ylabel("RMSE")
|
|
167
|
+
plt.title(f"Rolling RMSE (window={self.rolling_window})")
|
|
168
|
+
plt.legend()
|
|
169
|
+
path = plot_dir / "timeseries_rolling_rmse.png"
|
|
170
|
+
plt.tight_layout()
|
|
171
|
+
plt.savefig(path)
|
|
172
|
+
plt.close()
|
|
173
|
+
plots["rolling_rmse"] = str(path)
|
|
174
|
+
|
|
175
|
+
self.plots = plots
|
|
176
|
+
|
|
177
|
+
def _generate_insights(self) -> None:
|
|
178
|
+
y_true = _as_float_1d(self.y_true)
|
|
179
|
+
y_pred = _as_float_1d(self.y_pred)
|
|
180
|
+
if y_true is None or y_pred is None:
|
|
181
|
+
self.insights = []
|
|
182
|
+
return
|
|
183
|
+
err = y_pred - y_true
|
|
184
|
+
mean_err = float(np.mean(err))
|
|
185
|
+
insights: List[str] = []
|
|
186
|
+
if abs(mean_err) > 0:
|
|
187
|
+
direction = "over" if mean_err > 0 else "under"
|
|
188
|
+
insights.append(f"Forecasts show systematic {direction}-prediction bias (mean error={mean_err:.4g}).")
|
|
189
|
+
|
|
190
|
+
rolling_mean = self.metrics.get("rolling_rmse_mean")
|
|
191
|
+
rolling_last = self.metrics.get("rolling_rmse_last")
|
|
192
|
+
if isinstance(rolling_mean, (int, float)) and isinstance(rolling_last, (int, float)) and rolling_mean:
|
|
193
|
+
if rolling_last > 1.2 * rolling_mean:
|
|
194
|
+
insights.append("Recent errors increased vs earlier windows; model may be drifting.")
|
|
195
|
+
elif rolling_last < 0.8 * rolling_mean:
|
|
196
|
+
insights.append("Recent errors improved vs earlier windows.")
|
|
197
|
+
|
|
198
|
+
self.insights = insights
|
|
199
|
+
|
|
200
|
+
self.metric_descriptions.update(
|
|
201
|
+
{
|
|
202
|
+
"mae": "Mean absolute error; average absolute deviation between forecast and truth.",
|
|
203
|
+
"rmse": "Root mean squared error; penalizes larger errors more.",
|
|
204
|
+
"mape": "Mean absolute percentage error; undefined if true values are all ~0.",
|
|
205
|
+
"smape": "Symmetric MAPE; more stable when scaling varies between truth and prediction.",
|
|
206
|
+
"mean_forecast_error": "Signed average error; indicates systematic over- or under-forecasting.",
|
|
207
|
+
"rolling_rmse_mean": "Average RMSE across rolling windows (overall stability).",
|
|
208
|
+
"rolling_rmse_last": "Most recent rolling RMSE (latest stability).",
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
|