|
5 | 5 |
|
6 | 6 |
|
7 | 7 | class ForecastingHoldoutEvaluationStrategy(SinglePartitionEvaluationStrategy): |
8 | | - """Holdout evaluation that treats validation as history rather than a sample. |
| 8 | + """Holdout evaluation that records no in-sample metrics. |
9 | 9 |
|
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. |
13 | 12 |
|
14 | 13 | **The training partition is not scored.** Scoring it would mean asking the |
15 | 14 | model about dates it was fitted on. That is an in-sample fit statistic, |
16 | 15 | which is a real diagnostic but is not comparable with a forecast made |
17 | 16 | several steps out; showing the two side by side in one results table |
18 | 17 | invites exactly that comparison. Only validation and test are recorded. |
19 | 18 |
|
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: |
27 | 23 |
|
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; |
32 | 27 |
|
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. |
35 | 31 |
|
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: |
72 | 33 |
|
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 |
77 | 37 |
|
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. |
80 | 42 |
|
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 | + """ |
109 | 46 |
|
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) |
0 commit comments