Skip to content

fix: record the train cutoff and honour n_retrain in dry runs - #590

Open
knutdrand wants to merge 2 commits into
masterfrom
fix/backtest-dry-run-and-last-train-period
Open

knutdrand wants to merge 2 commits into
masterfrom
fix/backtest-dry-run-and-last-train-period

Conversation

@knutdrand

Copy link
Copy Markdown
Contributor

Two bugs found while reviewing #554. Both predate that PR, which only brought them into view; both are still on master.

last_train_period recorded the dataset end, not the train cutoff

run_backtest set last_train_period = dataset.period_range[-1], but the tail of that dataset is precisely the held-out window the splits forecast. On a 20-period dataset with n_periods=3, n_splits=2, stride=1 the splits forecast periods 16-19 while every BacktestForecast row was stamped with period 19 — a training cutoff after the periods predicted from it. horizon_distance in Evaluation.to_flat is derived from this field, so it came out wrong for every REST-created backtest.

Evaluation.create already uses train_set.period_range[-1]; the REST path now matches, so the two entry points agree.

chap eval --dry-run ignored n_retrain

The dry-run branch called backtest(...) without n_retrain, so it always trained once regardless of --backtest-params.n-retrain. A dry run with n_retrain=2 reported a clean smoke test having never touched the retrain-on-historic_data path the real run takes — which is the kind of failure a dry run exists to surface.

Tests

Both tests fail on master and pass with the fix:

  • test_run_backtest_records_the_train_cutoff_not_the_dataset_end asserts the invariant directly: last_train_period must precede every period forecast from it. On master it fails for every row.
  • test_eval_cmd_dry_run_retrains_n_retrain_times counts train calls through a dry run with n_retrain=2; on master the count is 1.

Both reuse existing fixtures (p_seeded_engine, weekly_full_data) and the existing _patched_eval_chain helper, which gains an optional dataset argument so a test can run the backtest against real data instead of a MagicMock.

make lint and make test are green (1487 passed, 122 skipped, 4 xfailed, 1 xpassed).

run_backtest stamped every BacktestForecast with the last period of the
full dataset, but the tail of that dataset is the held-out window the
splits forecast. The cutoff therefore landed after the periods predicted
from it, which inverts the horizon distance derived from it.
Evaluation.create already reads train_set.period_range[-1]; the REST path
now does too.

chap eval --dry-run did not forward n_retrain to backtest, so it always
trained once no matter what the real run would do. That left the
retrain-on-historic-data path unexercised by the very run meant to smoke
test it.
@knutdrand
knutdrand marked this pull request as ready for review September 11, 2026 11:57
@mortenoh

Copy link
Copy Markdown
Contributor

Reviewed read-only. Both fixes look correct and minimal — approving in spirit. Two notes on the description and one follow-up worth capturing.

last_train_period

The fix is right. train_test_generator returns train_set = periods[..split_idx] and the splits forecast everything after it, so dataset.period_range[-1] really did record a cutoff after the periods predicted from it. Matching Evaluation.create (chap_core/assessment/evaluation.py:449) so the REST and CLI paths agree is the strongest part of the change.

One correction to the PR body: horizon_distance is not derived from this field. _convert_backtest_to_flat_forecasts and max_horizon_distance both compute it from last_seen_period (chap_core/assessment/flat_representations.py:71,91), and grepping, nothing inside chap-core reads last_train_period back at all — it is effectively write-only here and surfaced to API consumers. The bug was real, but the impact is "wrong value served to clients", not "wrong metrics". Worth correcting before merge so the changelog does not overstate it.

Also: existing rows keep the old value and there is no backfill. Probably fine, but the PR should say so.

Follow-up (not a blocker)

last_train_period is documented per-forecast ("Most recent period included in the training window for this forecast", chap_core/database/tables.py:388) but is still a single scalar stamped on every row (evaluation.py:346). With n_retrain > 1, backtest() retrains on each split's expanding historic_data (prediction_evaluator.py:163), so forecasts from a retrained predictor now claim a cutoff earlier than the model actually saw. The old code was wrong in one direction; this is wrong in the other for retrained splits. Fixing it properly means threading the per-split cutoff out of backtest() — out of scope here, but this PR's own second fix makes the case more likely to occur.

Dry-run n_retrain

Straightforwardly right. The dry-run branch hand-rolls the train_test_generator + backtest call that Evaluation.create makes and had drifted by dropping one kwarg. The duplication is the underlying cause and will drift again, but deduping it is a bigger change than this PR should take.

Tests

Both are behavioural, touch no private members, and reuse existing fixtures. n_splits=4, n_retrain=2 retrains at splits 0 and 2, so == 2 is correct. The REST test asserts the invariant rather than a literal, which is the right call.

Two nits:

  • _CountingEstimator.model_information = _make_fake_estimator(None, None).model_information builds a whole MagicMock estimator to reach one real ModelTemplateInformation; constructing it directly would read better.
  • The dry-run test does not assert that the dry run skipped writing out.nc — a cheap extra assertion that would catch the branch falling through.

@mortenoh mortenoh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approved with comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants