Skip to content

Commit 90f43e4

Browse files
authored
Merge pull request #22 from dawidlinek/feat/extended-evaluation
Feat/extended evaluation
2 parents 39877ef + 504a2c6 commit 90f43e4

17 files changed

Lines changed: 816 additions & 58 deletions

File tree

docs/src/content/docs/evaluators/mae.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ print(report.summary())
4545
# 0 OLS 26.0199
4646
```
4747

48-
## Creating Custom Evaluators
48+
## See Also
4949

50-
See [Extending](/epftoolbox2/reference/extending/) for how to create custom evaluators like RMSE or MAPE.
50+
- [RMSEEvaluator](/epftoolbox2/evaluators/rmse/) — Root Mean Squared Error
51+
- [rMAEEvaluator](/epftoolbox2/evaluators/rmae/) — Relative MAE (benchmark comparison)
52+
- [Extending](/epftoolbox2/reference/extending/) — Creating custom evaluators
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
title: rMAEEvaluator
3+
description: Relative Mean Absolute Error metric
4+
---
5+
6+
# rMAEEvaluator
7+
8+
Calculates relative Mean Absolute Error (rMAE) — the ratio of a model's MAE to a base model's MAE. This is a standard metric in the electricity price forecasting literature (Lago et al., 2021) for benchmarking model performance.
9+
10+
## Formula
11+
12+
rMAE = MAE(model) / MAE(base_model)
13+
14+
Where both MAEs are computed on the same data slice (same time period, same grouping).
15+
16+
**Interpretation:**
17+
- rMAE < 1 — model **outperforms** the base model
18+
- rMAE = 1 — model performs **equally** to the base model
19+
- rMAE > 1 — model **underperforms** the base model
20+
21+
## Parameters
22+
23+
| Parameter | Type | Default | Description |
24+
|-----------|------|---------|-------------|
25+
| `base_model` | str | Required | Name of the model to use as the benchmark |
26+
27+
## Basic Usage
28+
29+
```python
30+
from epftoolbox2.evaluators import rMAEEvaluator
31+
32+
evaluator = rMAEEvaluator(base_model="OLS")
33+
```
34+
35+
## In Pipeline
36+
37+
```python
38+
from epftoolbox2.pipelines import ModelPipeline
39+
from epftoolbox2.models import OLSModel, LassoCVModel
40+
from epftoolbox2.evaluators import MAEEvaluator, rMAEEvaluator
41+
from epftoolbox2.exporters import TerminalExporter
42+
43+
pipeline = (
44+
ModelPipeline()
45+
.add_model(OLSModel(predictors=predictors, name="OLS"))
46+
.add_model(LassoCVModel(predictors=predictors, cv=7, name="LassoCV"))
47+
.add_evaluator(MAEEvaluator())
48+
.add_evaluator(rMAEEvaluator(base_model="OLS"))
49+
.add_exporter(TerminalExporter())
50+
)
51+
52+
report = pipeline.run(...)
53+
print(report.summary())
54+
# model MAE rMAE
55+
# 0 OLS 26.0199 1.0000
56+
# 1 LassoCV 24.8100 0.9535
57+
```
58+
59+
The rMAE is computed per data slice, so grouped views (`by_hour`, `by_horizon`, etc.) each show the relative performance for that specific group.
60+
61+
## Notes
62+
63+
- The `base_model` name must match the `name` parameter of one of the models in the pipeline.
64+
- If the base model's MAE is zero for a given group, rMAE returns `inf`.
65+
- rMAE is serializable to YAML and works with `ModelPipeline.save()`/`load()`.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
title: RMSEEvaluator
3+
description: Root Mean Squared Error metric
4+
---
5+
6+
# RMSEEvaluator
7+
8+
Calculates Root Mean Squared Error (RMSE) between predictions and actual values. RMSE penalizes larger errors more heavily than MAE.
9+
10+
## Formula
11+
12+
RMSE = √((1/n) × Σ(yᵢ - ŷᵢ)²)
13+
14+
Where:
15+
- yᵢ = actual value
16+
- ŷᵢ = predicted value
17+
- n = number of observations
18+
19+
## Basic Usage
20+
21+
```python
22+
from epftoolbox2.evaluators import RMSEEvaluator
23+
24+
evaluator = RMSEEvaluator()
25+
```
26+
27+
## In Pipeline
28+
29+
```python
30+
from epftoolbox2.pipelines import ModelPipeline
31+
from epftoolbox2.models import OLSModel
32+
from epftoolbox2.evaluators import MAEEvaluator, RMSEEvaluator
33+
from epftoolbox2.exporters import TerminalExporter
34+
35+
pipeline = (
36+
ModelPipeline()
37+
.add_model(OLSModel(predictors=predictors, name="OLS"))
38+
.add_evaluator(MAEEvaluator())
39+
.add_evaluator(RMSEEvaluator())
40+
.add_exporter(TerminalExporter())
41+
)
42+
43+
report = pipeline.run(...)
44+
print(report.summary())
45+
# model MAE RMSE
46+
# 0 OLS 26.0199 32.4512
47+
```
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
title: CsvExporter
3+
description: Export results to a wide-format CSV
4+
---
5+
6+
# CsvExporter
7+
8+
Exports detailed prediction results to a wide-format CSV file. Each row represents a single forecast point, with per-model prediction and error columns.
9+
10+
## Parameters
11+
12+
| Parameter | Type | Default | Description |
13+
|-----------|------|---------|-------------|
14+
| `path` | str | Required | Output CSV file path |
15+
| `extra_columns` | List[str] | `[]` | Columns from the source dataset to include |
16+
17+
## Basic Usage
18+
19+
```python
20+
from epftoolbox2.exporters import CsvExporter
21+
22+
exporter = CsvExporter("results.csv")
23+
```
24+
25+
## Output Format
26+
27+
The CSV contains the following columns:
28+
29+
**Base columns:**
30+
- `run_date` — date the forecast was made
31+
- `target_date` — date being forecasted
32+
- `hour` — hour of day (0-23)
33+
- `horizon` — forecast horizon (1 to max)
34+
- `day_in_test` — day index in the test period
35+
- `actual` — actual observed value
36+
37+
**Per-model columns:**
38+
- `{model}_prediction` — model's prediction
39+
- `{model}_error` — residual (prediction - actual)
40+
41+
**Extra columns** (optional):
42+
- Any columns from the source dataset, joined by `target_date` + `hour`
43+
44+
### Example Output
45+
46+
For a pipeline with models `OLS` and `LassoCV`, and `extra_columns=["is_holiday"]`:
47+
48+
| run_date | target_date | hour | horizon | actual | OLS_prediction | OLS_error | LassoCV_prediction | LassoCV_error | is_holiday |
49+
|----------|-------------|------|---------|--------|---------------|-----------|-------------------|---------------|------------|
50+
| 2024-02-01 | 2024-02-02 | 0 | 1 | 48.50 | 45.23 | -3.27 | 46.10 | -2.40 | 0 |
51+
52+
## Extra Columns
53+
54+
Use `extra_columns` to include columns from the source dataset (e.g., calendar features, weather data). Columns are joined by matching `target_date` and `hour` from the results to the source dataset's DatetimeIndex.
55+
56+
```python
57+
exporter = CsvExporter(
58+
"results.csv",
59+
extra_columns=["is_holiday", "load_forecast", "warsaw_temperature_2m"],
60+
)
61+
```
62+
63+
If a requested column does not exist in the source dataset, it is silently skipped.
64+
65+
## In Pipeline
66+
67+
```python
68+
from epftoolbox2.pipelines import ModelPipeline
69+
from epftoolbox2.models import OLSModel, LassoCVModel
70+
from epftoolbox2.evaluators import MAEEvaluator, RMSEEvaluator
71+
from epftoolbox2.exporters import CsvExporter
72+
73+
pipeline = (
74+
ModelPipeline()
75+
.add_model(OLSModel(predictors=predictors, name="OLS"))
76+
.add_model(LassoCVModel(predictors=predictors, cv=7, name="LassoCV"))
77+
.add_evaluator(MAEEvaluator())
78+
.add_evaluator(RMSEEvaluator())
79+
.add_exporter(CsvExporter(
80+
"results.csv",
81+
extra_columns=["is_holiday", "load_forecast"],
82+
))
83+
)
84+
85+
report = pipeline.run(data=df, test_start="2024-02-01", test_end="2024-03-01", target="price", horizon=7)
86+
# Results saved to results.csv
87+
```

docs/src/content/docs/reference/api.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,19 @@ class LassoCVModel(BaseModel):
200200
## Evaluators
201201

202202
```python
203+
class Evaluator(ABC):
204+
name: str
205+
def compute(self, df: pd.DataFrame, **kwargs) -> float: ...
206+
203207
class MAEEvaluator(Evaluator):
204208
name = "MAE"
205-
def compute(self, df: pd.DataFrame) -> float: ...
209+
210+
class RMSEEvaluator(Evaluator):
211+
name = "RMSE"
212+
213+
class rMAEEvaluator(Evaluator):
214+
name = "rMAE"
215+
def __init__(self, base_model: str): ... # Name of the benchmark model
206216
```
207217

208218
---
@@ -217,4 +227,8 @@ class TerminalExporter(Exporter):
217227
class ExcelExporter(Exporter):
218228
def __init__(self, path: str, sheets: List[str] = None): ...
219229
def export(self, report: EvaluationReport) -> None: ...
230+
231+
class CsvExporter(Exporter):
232+
def __init__(self, path: str, extra_columns: List[str] = None): ...
233+
def export(self, report: EvaluationReport) -> None: ...
220234
```

docs/src/content/docs/reference/extending.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,42 +130,44 @@ from epftoolbox2.evaluators.base import Evaluator
130130
import pandas as pd
131131
import numpy as np
132132

133-
class RMSEEvaluator(Evaluator):
134-
name = "RMSE"
135-
136-
def compute(self, df: pd.DataFrame) -> float:
137-
return np.sqrt(((df["prediction"] - df["actual"]) ** 2).mean())
138-
139-
140133
class MAPEEvaluator(Evaluator):
141134
name = "MAPE"
142135

143-
def compute(self, df: pd.DataFrame) -> float:
136+
def compute(self, df: pd.DataFrame, **kwargs) -> float:
144137
return ((df["prediction"] - df["actual"]).abs() / df["actual"].abs()).mean() * 100
145138

146139

147140
class sMAPEEvaluator(Evaluator):
148141
name = "sMAPE"
149142

150-
def compute(self, df: pd.DataFrame) -> float:
143+
def compute(self, df: pd.DataFrame, **kwargs) -> float:
151144
numerator = (df["prediction"] - df["actual"]).abs()
152145
denominator = (df["prediction"].abs() + df["actual"].abs()) / 2
153146
return (numerator / denominator).mean() * 100
154147
```
155148

149+
### Evaluator Interface
150+
151+
Every evaluator must implement:
152+
153+
- **`name`** — class attribute used as the column header in reports
154+
- **`compute(self, df, **kwargs) -> float`** — aggregate metric over a DataFrame with `prediction` and `actual` columns
155+
156+
The `**kwargs` may include `model_dfs` — a dict of all model DataFrames for the current data slice, used by cross-model evaluators like `rMAEEvaluator`.
157+
156158
### Using Custom Evaluators
157159

158160
```python
159161
from epftoolbox2.pipelines import ModelPipeline
160162
from epftoolbox2.models import OLSModel
161-
from epftoolbox2.evaluators import MAEEvaluator
163+
from epftoolbox2.evaluators import MAEEvaluator, RMSEEvaluator
162164
from epftoolbox2.exporters import TerminalExporter
163165

164166
pipeline = (
165167
ModelPipeline()
166168
.add_model(OLSModel(predictors=predictors, name="OLS"))
167169
.add_evaluator(MAEEvaluator())
168-
.add_evaluator(RMSEEvaluator()) # Custom
170+
.add_evaluator(RMSEEvaluator())
169171
.add_evaluator(MAPEEvaluator()) # Custom
170172
.add_exporter(TerminalExporter())
171173
)

epftoolbox2/evaluators/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from .base import Evaluator
22
from .mae import MAEEvaluator
3+
from .rmae import rMAEEvaluator
4+
from .rmse import RMSEEvaluator
35

4-
__all__ = ["Evaluator", "MAEEvaluator"]
6+
__all__ = ["Evaluator", "MAEEvaluator", "RMSEEvaluator", "rMAEEvaluator"]

epftoolbox2/evaluators/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ class Evaluator(ABC):
66
name: str
77

88
@abstractmethod
9-
def compute(self, df: pd.DataFrame) -> float:
9+
def compute(self, df: pd.DataFrame, **kwargs) -> float:
1010
pass

epftoolbox2/evaluators/mae.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
class MAEEvaluator(Evaluator):
66
name = "MAE"
77

8-
def compute(self, df: pd.DataFrame) -> float:
8+
def compute(self, df: pd.DataFrame, **kwargs) -> float:
99
return (df["prediction"] - df["actual"]).abs().mean()

epftoolbox2/evaluators/rmae.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import math
2+
from typing import Dict
3+
4+
import pandas as pd
5+
from .base import Evaluator
6+
7+
8+
class rMAEEvaluator(Evaluator):
9+
name = "rMAE"
10+
11+
def __init__(self, base_model: str):
12+
self.base_model = base_model
13+
14+
def compute(self, df: pd.DataFrame, **kwargs) -> float:
15+
model_dfs: Dict[str, pd.DataFrame] = kwargs.get("model_dfs", {})
16+
if not model_dfs:
17+
raise ValueError(
18+
f"rMAE base model '{self.base_model}' not found in pipeline models."
19+
)
20+
if self.base_model not in model_dfs:
21+
return math.nan
22+
base_df = model_dfs[self.base_model]
23+
if base_df.empty:
24+
return math.nan
25+
base_mae = (base_df["prediction"] - base_df["actual"]).abs().mean()
26+
if base_mae == 0:
27+
return float("inf")
28+
model_mae = (df["prediction"] - df["actual"]).abs().mean()
29+
return model_mae / base_mae

0 commit comments

Comments
 (0)