|
| 1 | +"""Holdout evaluation for models that forecast a series from its own history.""" |
| 2 | + |
| 3 | +from DashAI.back.core.enums.metrics import SplitEnum |
| 4 | +from DashAI.back.evaluation.holdout import SinglePartitionEvaluationStrategy |
| 5 | + |
| 6 | + |
| 7 | +class ForecastingHoldoutEvaluationStrategy(SinglePartitionEvaluationStrategy): |
| 8 | + """Holdout evaluation that treats validation as history rather than a sample. |
| 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. |
| 13 | +
|
| 14 | + **The training partition is not scored.** Scoring it would mean asking the |
| 15 | + model about dates it was fitted on. That is an in-sample fit statistic, |
| 16 | + which is a real diagnostic but is not comparable with a forecast made |
| 17 | + several steps out; showing the two side by side in one results table |
| 18 | + invites exactly that comparison. Only validation and test are recorded. |
| 19 | +
|
| 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. |
| 27 | +
|
| 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. |
| 32 | +
|
| 33 | + validation metrics <- model fitted on train |
| 34 | + test metrics <- model fitted on train + validation |
| 35 | +
|
| 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) |
| 72 | + |
| 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"]) |
| 77 | + |
| 78 | + self._report_progress(0.8, "Computing validation metrics") |
| 79 | + self._calculate_metrics_if_missing(model, run, db, SplitEnum.VALIDATION) |
| 80 | + |
| 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 |
| 109 | + |
| 110 | + extend = type(model)._extend |
| 111 | + model.train(extend(x["train"], validation_x), extend(y["train"], validation_y)) |
0 commit comments