Ensemble Driver¶
ensemble_run
¶
Execute + score the run's ensembles into the registry (the orchestration seam).
ensembler is pure — it blends the base forecasts (calculated) and fits the learned
meta-learners, but touches no GCP. This module is the thin I/O orchestrator that makes those
consensuses real: it reads the base rows, computes both families in pandas, appends the
ensemble_<s> prediction rows via the Storage Write API, and — the missing leaderboard link —
scores every ensemble pseudo-model into forecast_metadata so it appears on
v_model_leaderboard beside the base models. It is engine-agnostic (it only reads the shared
registry) and runs either inline from main.run (after both engines join, under the one
shared run_id) or standalone via python -m scale_forecasting.ensemble_run (re-ensembling an
already-completed run — see _main).
Config-keyed ensembles. Every ensemble row carries an ensemble_id =
make_ensemble_id(cfg.ensemble) — a digest of the ensemble configuration alone — so several
ensemble configs can be scored under one run_id without their ensemble_<strategy>
pseudo-models colliding. Re-running the same ensemble config lands the same ensemble_id (and
so the same cells, deduped newest-first on read); a different config lands a different
ensemble_id and sits beside the first on the leaderboard, distinctly keyed. The view groups by
(run_id, model_type, ensemble_id) so the two never merge.
Three responsibilities, in order:
- Calculated (
mean/median/inverse_error) — read the baseforecast_predictions(+forecast_metadatafor the inverse-error weights / pruning) and blend them in pandas (ensembler.combine_calculated), then append theensemble_<s>rows via the Storage Write API — the same append path the learned strategies use (noINSERT…SELECTDML).compute_engine='ensemble'. - Learned (
nnls/ridge/xgb) — read the base predictions +backtest_oof,fit_learnedon the OOF, then apply the weights in pandas (yhat = Σ wₘ·yhatₘ, renormalized over whichever base models are present per(ts_id, forecast_date)— robust when the Spark future window and the native held-out window don't overlap), and append the resultingensemble_<s>prediction rows via the Storage Write API. Each fitted meta-learner is uploaded as a GCS artifact and linked from its scored metadata row. These prediction rows are a true beyond-data forecast (the base predictions they blend are, too), so — like the base models — they carry no ground truth of their own. - Score — blend the base
backtest_oofinto an ensemble OOF with the same consensus rules (ensembler.combine_oof), append those blended rows back intobacktest_oof(same table as the base rows, keyed apart byensemble_id, so anything computed from that table — the pooled comparable leaderboard, a per-horizon coverage read — sees the consensuses and the base models on identical footing), and run the sharedmetrics.compute_metricsper(model, ts_id)→forecast_metadatarows withfold_id=NULLandcompute_engine='ensemble'. Scoring lives on the OOF window because the base predictions (and therefore every ensemble prediction) are a true beyond-data forecast with no actuals to join — so an ensemble earns its leaderboard metric on exactly the window the base models are scored on (backtest_oof, wherey_truelives). OOF carries no interval bounds, so ensemble coverage/pinball are unscored — computed as NaN and written as NULL, like every other metric this registry stores, consistent with the base models' OOF metrics. Learned consensuses are scored on the folds their meta-learner trained on (mildly optimistic, the price of stacking having no held-out-of-held-out window). Once thesefold_id IS NULLrows land, the leaderboard shows the ensembles automatically — no view change beyond theensemble_idgroup key.
Idempotency (append-only + dedupe-on-read). Every ensemble row is written through the
Write API and is keyed in (run_id, ensemble_id, ts_id, model_type), so a re-run of the same
ensemble config re-appends the same cells — correct-but-wasteful when the numbers repeat, and a
genuine conflict when they don't (xgb is a stochastic meta-learner, and a repair re-fits). Both
cases resolve the same way: every ensemble row carries a created_at, and each read dedupes to
one row per cell by ORDER BY created_at DESC NULLS LAST — see base_read_sql, and
registry.rows.cell_dedup_key for the same rule on the base tables. No pre-delete is issued because
a DELETE matching rows still in the ~90-min Write API streaming buffer is rejected for the whole
window (the constraint every cell writer lives under).
A different ensemble config keys distinctly (different ensemble_id), so it never overwrites
and never collides — both coexist.
Public surface: run_ensembles, python -m scale_forecasting.ensemble_run.
base_read_sql
¶
One run-, model- and series-scoped SELECT over a registry table (pure).
All three reads this module makes share a shape, and the model_type IN (...) clause is the
part that has to be in every one of them. Two of the tables read here — forecast_predictions
and now backtest_oof — hold the ensemble's own rows beside the base models', so a read
that omits the filter blends consensuses of consensuses on any second pass over a run: a
microbatch drain, a --force re-ensemble, a standalone re-score. Building the clause in one
place is what stops that from being a thing to remember at each call site.
extra is for a clause only one read needs (forecast_metadata wants the full-fit rows
only); ts_filter is the microbatch's AND ts_id IN UNNEST(@ts_ids), empty for a barrier.
dedupe_by names the cell grain of the table, and turns the read into "one row per cell,
newest write wins" via QUALIFY ROW_NUMBER() … ORDER BY created_at DESC NULLS LAST. Three
things downstream need this and none of them fail loudly without it:
ensembler.combine_calculated pivots with pivot_table, whose default aggfunc is
mean, so a duplicated base row is silently averaged with itself — harmless for two
identical copies, wrong the moment a repair re-fit one of them. _inverse_error_run_weights
takes an ungrouped mean of the decision metric, so a model with duplicate metadata rows is
weighted by an average of its attempts. And the blended OOF is appended back into
backtest_oof, so a duplicate on the way in becomes a duplicate on the way out.
NULLS LAST is the part that matters for old data: created_at has only had a writer on
these two tables since P10, so every row from before it is NULL, and a NULL must lose to any
real timestamp rather than sorting first and winning permanently.
Source code in src/scale_forecasting/ensemble_run.py
cross_run_copy_sql
¶
Build an idempotent SQL INSERT INTO ... SELECT * REPLACE statement that consolidates
deduplicated base-model rows from @source_run_ids into @target_run_id (pure).
Only base-model rows (ensemble_id IS NULL) matching model_list are copied, deduplicated
across @source_run_ids with newest created_at winning per dedupe_by grain. Any
(ts_id, model_type) cell already present under @target_run_id is skipped via NOT
EXISTS so re-running cross-run ensembling with additional strategies never duplicates base
rows.
Source code in src/scale_forecasting/ensemble_run.py
ensemble_cross_runs
¶
Blend base models across multiple completed runs into a consolidated cross-run ensemble.
Reads the stored RunConfig for each run in source_run_ids
(registry.reads.read_run_config), merges them via merge_configs_for_ensemble, derives
target_run_id (defaulting to make_run_id(merged_cfg)), copies deduplicated base rows
(forecast_predictions, backtest_oof, forecast_metadata) into target_run_id via
cross_run_copy_sql, records a COMPLETED header + ensemble job row in the registry,
and executes run_ensembles so the cross-run ensembles and all contributing base models
appear side-by-side on v_model_leaderboard and review.review_run. Returns
target_run_id.
Source code in src/scale_forecasting/ensemble_run.py
908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 | |
ensemble_refit_mode
¶
How this ensemble's members were scored, rolled up into one answer (pure).
Source code in src/scale_forecasting/ensemble_run.py
ensemble_scoring_basis
¶
Whether this strategy's metrics were earned with the newest fold held out (pure).
Source code in src/scale_forecasting/ensemble_run.py
merge_configs_for_ensemble
¶
Merge multiple compatible RunConfig objects into a single composite config for cross-run
ensembling (pure).
Verifies that at least two configs are supplied and that all source configs share the same
data.source_table, data.freq, data.horizon, and backtest geometry (enabled,
n_folds, horizon, step), raising ConfigError on any mismatch. The returned config uses
the first config as its base, sets models to the ordered deduplicated union of models
across all source configs, merges model_params, and enables the requested ensemble
strategies (defaulting to ["mean", "median", "inverse_error"] when neither
strategies nor any source config enables strategies).
Source code in src/scale_forecasting/ensemble_run.py
789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 | |
run_ensemble_scoring
¶
Roll the per-strategy scoring bases up into one claim for the run (pure).
None when nothing in the run fit anything — a mean/median-only ensemble has no fold to
reserve, so there is no claim to make and the header stays quiet rather than saying "holdout"
about a fit that never happened. Otherwise the weakest answer wins: one strategy that fell
back to in_sample makes the run's ensemble metrics partly in-sample, and a header that
reported the best case would be exactly the reassurance this column exists to withhold.
Source code in src/scale_forecasting/ensemble_run.py
run_ensembles
¶
Execute + score every requested ensemble for run_id into the shared registry (barrier).
A no-op when cfg.ensemble.enabled is false. Otherwise blends the calculated + learned
consensuses in pandas, appends their ensemble_<s> prediction rows via the Storage Write API,
and scores every ensemble pseudo-model into forecast_metadata (see the module docstring),
all stamped with ensemble_id = make_ensemble_id(cfg.ensemble) so multiple ensemble configs
coexist under one run_id. Raises on any failure so main.run can finalize the shared
header FAILED — mirroring how an engine error is surfaced. settings is the orchestrator's
already-resolved infra (never re-resolved here, so one identity governs the whole run).
This is the barrier trigger: it blends every series in one pass, after all base jobs have
joined. The microbatch counterpart (run_ensembles_microbatch) drains series incrementally
as each one's base set completes; both call the shared per-batch core (_ensemble_batch). They
land identical rows for the calculated strategies; the learned ones are re-fit per batch under
microbatch and so can differ — see _ensemble_batch.
Source code in src/scale_forecasting/ensemble_run.py
run_ensembles_microbatch
¶
run_ensembles_microbatch(
cfg,
run_id,
*,
settings,
poll_interval_s=15.0,
max_polls=960,
upstream_done=None,
job_id_prefix=None,
)
Execute + score ensembles for run_id incrementally, per series as it completes.
The microbatch trigger: rather than waiting for every base model on every series (the
run_ensembles barrier), this drains series in ready-batches — a series is ready once all
cfg.models base models have landed a prediction row for it (_ready_series), and each
ready-batch is blended + scored by the same core as the barrier (_ensemble_batch over that
ts_id subset), so partial consensus output lands progressively and one slow family never blocks
the series that are already complete. The loop (_drain_ready) polls every poll_interval_s
seconds until upstream_done reports the base jobs finished and no ready series remain
unprocessed (bounded by max_polls as a safety stop).
v1 default: the orchestrator calls this after the family join, so upstream_done defaults
to "always done" and the loop drains every ready series in a single pass — the streaming
semantics (per-series readiness, incremental writes) with a post-join trigger. Passing an
upstream_done that tracks live base-job completion turns it into a consumer that overlaps
base computation (the concurrent-trigger extension). Raises on any batch failure so main.run
finalizes the header FAILED.
Source code in src/scale_forecasting/ensemble_run.py
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 | |