Skip to content

Commit 5dff02e

Browse files
committed
feat: drop the refit through validation for forecasting
1 parent 25c41e4 commit 5dff02e

10 files changed

Lines changed: 164 additions & 308 deletions

File tree

DashAI/back/evaluation/forecasting_holdout.py

Lines changed: 26 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -5,107 +5,44 @@
55

66

77
class ForecastingHoldoutEvaluationStrategy(SinglePartitionEvaluationStrategy):
8-
"""Holdout evaluation that treats validation as history rather than a sample.
8+
"""Holdout evaluation that records no in-sample metrics.
99
10-
Two things the ordinary holdout strategy assumes are wrong for a
11-
forecaster, and both of them are decisions about evaluation rather than
12-
about any model.
10+
One thing the ordinary holdout strategy assumes is wrong for a forecaster,
11+
and it is a decision about evaluation rather than about any model.
1312
1413
**The training partition is not scored.** Scoring it would mean asking the
1514
model about dates it was fitted on. That is an in-sample fit statistic,
1615
which is a real diagnostic but is not comparable with a forecast made
1716
several steps out; showing the two side by side in one results table
1817
invites exactly that comparison. Only validation and test are recorded.
1918
20-
**The kept model is fitted through validation.** For most tasks the
21-
validation partition is a held out sample that has to stay out of the fit.
22-
For a forecaster it is simply the most recent stretch of the series, and
23-
the stretch nearest to whatever comes next. Leaving it out makes the model
24-
reach across the whole validation window before arriving at the first test
25-
row, so the test metrics describe a longer horizon than the one being
26-
asked about.
19+
**The kept model is fitted on the training partition alone**, like every
20+
other holdout run, and nothing is fed to it afterwards. Two approaches that
21+
would have changed that were tried and dropped, both because they hand the
22+
model data from a partition it was meant to be held out from:
2723
28-
The validation metrics are still measured on a model fitted on training
29-
data alone, which is what makes them honest: they are recorded before the
30-
refit. So the two columns in the results table answer different questions,
31-
and both answer them fairly.
24+
refitting through validation before scoring test, which overwrote the
25+
fit the validation metrics came from, so the saved model could not
26+
reproduce its own results table;
3227
33-
validation metrics <- model fitted on train
34-
test metrics <- model fitted on train + validation
28+
advancing the model through the observed validation rows at predict
29+
time, which re-estimates nothing but still lets a held out partition
30+
reach the model, which no other task in DashAI does.
3531
36-
Hyperparameter search is untouched. Its trials are scored on validation,
37-
so they must not be fitted on it.
38-
"""
39-
40-
COMPATIBLE_COMPONENTS = ["ForecastingTask"]
41-
SCORED_SPLITS: tuple = (SplitEnum.VALIDATION, SplitEnum.TEST)
42-
43-
def execute(self, x, y, run, db):
44-
"""Score validation on a trial fit, then refit and score test.
45-
46-
Parameters
47-
----------
48-
x : DatasetDict
49-
Input partitions, keyed by split name.
50-
y : DatasetDict
51-
Target partitions, keyed by split name.
52-
run : Run
53-
Database model representing the current run.
54-
db : Session
55-
SQLAlchemy session used to persist metrics.
56-
57-
Returns
58-
-------
59-
tuple
60-
The trained model and the paths of any HPO plots.
61-
"""
62-
plot_paths = []
63-
model = self.model
64-
65-
model.x_data = x
66-
model.y_data = y
67-
68-
if self.optimizer and self.run_optimizable_parameters:
69-
self._report_progress(0.2, "Hyperparameter optimization")
70-
model = self._do_hpo(model, x, y, run, db)
71-
plot_paths = self._generate_hpo_plots(run)
32+
So the two columns describe different horizons, and deliberately:
7233
73-
# Fitted on training data only, so the validation score below measures
74-
# a model that has not seen the rows it is being scored on.
75-
self._report_progress(0.5, "Training")
76-
model.train(x["train"], y["train"])
34+
validation metrics <- forecasting 1..len(val) past the fit
35+
test metrics <- forecasting len(val)+1..len(val)+len(test),
36+
its own forecasts standing in for validation
7737
78-
self._report_progress(0.8, "Computing validation metrics")
79-
self._calculate_metrics_if_missing(model, run, db, SplitEnum.VALIDATION)
38+
The test column is therefore the harder question, not the same one further
39+
along. Comparing like with like over a chosen horizon is what
40+
``RollingOriginSplitter`` is for, since its ``horizon`` says outright how
41+
many steps ahead each refit is scored on.
8042
81-
# Now the model that gets kept: the same configuration, refitted with
82-
# the validation rows included, since for a series they are history.
83-
self._report_progress(0.9, "Refitting on train and validation")
84-
self._fit_final_model(model, x, y)
85-
86-
self._report_progress(0.95, "Computing test metrics")
87-
self._calculate_metrics_if_missing(model, run, db, SplitEnum.TEST)
88-
89-
return model, plot_paths
90-
91-
def _fit_final_model(self, model, x, y):
92-
"""Fit the kept model on the training and validation rows together.
93-
94-
Parameters
95-
----------
96-
model : BaseModel
97-
The model to fit.
98-
x : DatasetDict
99-
Input partitions.
100-
y : DatasetDict
101-
Target partitions.
102-
"""
103-
validation_x = x.get("validation")
104-
validation_y = y.get("validation")
105-
106-
if validation_x is None or validation_y is None or len(validation_x) == 0:
107-
model.train(x["train"], y["train"])
108-
return
43+
Hyperparameter search is untouched. Its trials are scored on validation, so
44+
they must not be fitted on it.
45+
"""
10946

110-
extend = type(model)._extend
111-
model.train(extend(x["train"], validation_x), extend(y["train"], validation_y))
47+
COMPATIBLE_COMPONENTS = ["ForecastingTask"]
48+
SCORED_SPLITS: tuple = (SplitEnum.VALIDATION, SplitEnum.TEST)

DashAI/back/evaluation/holdout.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,7 @@ def evaluate(self, model, input_dataset, output_dataset, metric):
156156
output_dataset["validation"], is_fit=False
157157
)
158158

159-
# Calculate metric for train and validation data each trial. The
160-
# training partition is skipped for a strategy that does not score it,
161-
# which for a forecaster is not a preference: predicting on dates it
162-
# was fitted on is refused, so asking would fail every trial.
159+
# Calculate metric for train and validation data each trial.
163160
if SplitEnum.TRAIN in self.SCORED_SPLITS:
164161
model.calculate_metrics(split=SplitEnum.TRAIN, level=LevelEnum.TRIAL)
165162
model.calculate_metrics(split=SplitEnum.VALIDATION, level=LevelEnum.TRIAL)

DashAI/back/job/predict_job.py

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ def _run_prediction_pipeline(
3030
train_dataset: "DashAIDataset",
3131
loaded_dataset: "DashAIDataset",
3232
model_session: ModelSession,
33-
history_dataset: "DashAIDataset" = None,
3433
) -> Tuple["DashAIDataset", Any]:
3534
"""Run shared prediction steps from prepared input data to final predictions.
3635
@@ -46,13 +45,6 @@ def _run_prediction_pipeline(
4645
The rows to predict.
4746
model_session : ModelSession
4847
The session declaring the input and output columns.
49-
history_dataset : DashAIDataset, optional
50-
Every row of the dataset the requested ones came from, target column
51-
included. A forecasting model is advanced through the rows that were
52-
observed between the end of its fit and the window being asked about,
53-
so it forecasts from the last thing that actually happened rather than
54-
across a stretch it has to invent. Models that do not read a history
55-
never see it.
5648
5749
Returns
5850
-------
@@ -62,20 +54,7 @@ def _run_prediction_pipeline(
6254
import numpy as np
6355

6456
prepared_dataset = loaded_dataset.select_columns(model_session.input_columns)
65-
66-
predict_kwargs = {}
67-
output_column = model_session.output_columns[0]
68-
if (
69-
history_dataset is not None
70-
and getattr(trained_model, "ACCEPTS_HISTORY", False)
71-
and output_column in history_dataset.column_names
72-
):
73-
predict_kwargs["history"] = (
74-
history_dataset.select_columns(model_session.input_columns),
75-
history_dataset.select_columns([output_column]),
76-
)
77-
78-
y_pred_proba = np.array(trained_model.predict(prepared_dataset, **predict_kwargs))
57+
y_pred_proba = np.array(trained_model.predict(prepared_dataset))
7958
y_pred = task.process_predictions(
8059
train_dataset, y_pred_proba, model_session.output_columns[0]
8160
)
@@ -471,12 +450,10 @@ def run(
471450

472451
try:
473452
# Load or create prediction dataset
474-
history_dataset = None
475453
if dataset_id:
476454
loaded_dataset: "DashAIDataset" = load_dataset(
477455
str(Path(f"{dataset.file_path}/dataset/"))
478456
)
479-
history_dataset = loaded_dataset
480457
if row_indexes is not None:
481458
loaded_dataset = loaded_dataset.select(row_indexes)
482459
else:
@@ -494,7 +471,6 @@ def run(
494471
train_dataset=train_dataset,
495472
loaded_dataset=loaded_dataset,
496473
model_session=model_session,
497-
history_dataset=history_dataset,
498474
)
499475

500476
except ValueError as ve:

DashAI/back/models/forecasting/arima.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,6 @@ def train(
250250
)
251251

252252
with warnings.catch_warnings():
253-
# statsmodels warns that it is assuming evenly spaced observations
254-
# because no date index was supplied. That is the assumption this
255-
# model makes on purpose, so the warning says nothing new.
256253
warnings.simplefilter("ignore")
257254
self._result = _ARIMA(series, order=(self.p, self.d, self.q)).fit()
258255

@@ -261,18 +258,17 @@ def train(
261258
self._fitted = True
262259
return self
263260

264-
def predict(self, x: "DashAIDataset") -> "np.ndarray":
265-
"""Forecast forward from the end of the training series.
261+
def _forecast(self, steps: int) -> "np.ndarray":
262+
"""Forecast the next ``steps`` periods after the end of the history.
266263
267264
Parameters
268265
----------
269-
x : DashAIDataset
270-
The rows to forecast, whose dates say how far ahead each one is.
266+
steps : int
267+
How many periods to forecast.
271268
272269
Returns
273270
-------
274271
np.ndarray
275-
One forecast value per requested row.
272+
One value per period, in order.
276273
"""
277-
self._require_fitted()
278-
return self._forecast_at(x, lambda steps: self._result.forecast(steps=steps))
274+
return self._result.forecast(steps=steps)

0 commit comments

Comments
 (0)