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.
@@ -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,4 @@
1
+ from .report import TimeSeriesReport
2
+
3
+ __all__ = ["TimeSeriesReport"]
4
+
@@ -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
+
@@ -0,0 +1,6 @@
1
+ from .segmentation import SegmentationReport
2
+ from .detection import DetectionReport
3
+ from .image_classification import ImageClassificationReport
4
+
5
+ __all__ = ["SegmentationReport", "DetectionReport", "ImageClassificationReport"]
6
+