Configuration reference¶
One run is one JSON config. It is validated into a frozen RunConfig
(src/scale_forecasting/config.py), logged verbatim to the run registry, and is the experiment
record — behavior changes come from this file, not code edits. This page documents every
field.
Two invariants apply everywhere:
- Immutable + strict. Every section is frozen and
extra="forbid"— an unknown key is a validation error, not a silent no-op. A typo fails fast with a clear message. - Config-derived identity. The
run_idis a digest of the config (make_run_id), so re-running the same config is idempotent at the logical level. Give each execution a freshrun_name(the notebooks timestamp it) if you want a distinct run.
Load + validate a file with load_config(path); every failure mode (missing file, bad JSON, invalid
schema) surfaces as a single ConfigError.
Top level — RunConfig¶
| Field | Type | Default | Purpose |
|---|---|---|---|
run_name |
str |
required | Human name for the run. |
data |
DataConfig |
required | Where the series come from and their shape. |
python_runtime |
"spark" | "ray" | "vertex" | "gce" | "gke" | "vertex_automl" |
"spark" |
Run-level default runtime for the Python model families; each family can override it (see below). |
models |
list[str] |
required (≥1) | Model names to run (see models_reference.md or playground --list). |
model_params |
dict[str, dict[str, …]] |
{} |
Per-model hyperparameters, keyed by model name — see below. |
features |
FeaturesConfig |
{} |
Optional feature engineering. |
backtest |
BacktestConfig |
{} |
Time-series cross-validation. |
output |
OutputConfig |
{} |
What the shipped yhat means — see below. |
hpo |
HpoConfig |
{} |
Hyperparameter optimization. |
ensemble |
EnsembleConfig |
{} |
Consensus across base models. |
hierarchy |
HierarchyConfig |
{} |
Hierarchical aggregation & coherent forecast reconciliation (Hyndman FPP3). |
compute |
ComputeConfig |
{} |
Runtime scale + cost guardrails. |
flowchart LR
RunCfg["RunConfig"] --> Data["data (DataConfig)<br/>source_table · freq · horizon"]
RunCfg --> Models["models + model_params<br/>37 registered models across 5 families"]
RunCfg --> Feat["features (FeaturesConfig)<br/>transform · holidays · fourier<br/>level_shift · static/future/past covariates"]
RunCfg --> BT["backtest (BacktestConfig)<br/>scheme · n_folds · gap<br/>short_series · decision_metric"]
RunCfg --> Out["output (OutputConfig)<br/>point_forecast: auto | median | mean | raw"]
RunCfg --> HPO["hpo (HpoConfig)<br/>Optuna: fleetwide | per_series"]
RunCfg --> Ens["ensemble (EnsembleConfig)<br/>mean · median · inverse_error<br/>nnls · ridge · xgb"]
RunCfg --> Hier["hierarchy (HierarchyConfig)<br/>levels · reconciliation_methods<br/>bottom_up · top_down · mint_shrink"]
RunCfg --> Comp["compute (ComputeConfig)<br/>families · ensemble · capacity · profile"]
Cross-field rules (enforced after parsing):
- Duplicate entries in
models→ error. hpo.enabledrequiresbacktest.enabled→ error otherwise (HPO tunes on folds).ensemble.enabledwithoutbacktest.enabled→ the learned strategies (nnls/ridge/xgb) are dropped with a warning (they need OOF); calculated strategies remain. Not an error.
python_runtime — the run-level default runtime for the Python model families (the native family
always runs in parallel in BigQuery, and the automl family defaults to vertex_automl):
spark(default) — Dataproc Serverless (spark_mode="serverless") or Dataproc Standard Cluster (spark_mode="cluster"). The 100k CPU workhorse; it fans out one task per(series, model)cell (series cross-joined with the family's models), so a family's job finishes in ~its slowest cell.ray— Distributed Ray on Vertex AI (ray_mode="vertex", default) or Ray-on-GKE / KubeRay (ray_mode="gke"). Supports fractional-GPU packing and autoscaling worker pools across CPU and GPU families; the Raycomputeknobs apply.vertex— Serverless Vertex AICustomJob. Provisions a dedicated single VM (workers = 1, zero Ray head-node tax or Spark driver overhead) or a multi-worker pool (workers > 1) with deterministic rank-sharded execution, contiguous BigQuery Storage Read APIrow_restrictionpushdown, LPT cell ordering, and a GCS completion barrier (SF_VERTEX_JOB_ID). When a family containsdeep_learningor global/hybrid panel models (tide,tft,tsmixer,patchtst,neuralprophet),effective_worker_countautomatically allocates 1 dedicated VM per model (max(workers, len(models))) so global/deep-learning models never contend for VRAM or RAM.gce— Direct Compute Engine Single-VM (cos-cloud/cos-stable). Runs the exact same container image andvertex_engine.pyentrypoint on a single Compute Engine VM (workersmust be omitted or1) with triple-redundant zero-orphan lifecycle guarantees: (1) GCE hypervisor hard TTL (scheduling.maxRunDuration+scheduling.instanceTerminationAction = "DELETE"), (2) guest startup scripttrap cleanup EXITREST API self-deletion +shutdown -h now, and (3) client-sidetry ... finallydeletion.gke— Google Kubernetes Engine (GKE). Executes Python families on a standing GKE cluster (compute.gke_cluster_name/SF_GKE_CLUSTER) or an ephemeral GKE cluster provisioned for the run and deleted intry ... finally. Supports two execution modes (compute.gke_mode):gke_mode = "job"(default): Launches a Kubernetesbatch/v1Indexed Job (completionMode: Indexed,completions = W,parallelism = W) runningvertex_engine.pywithJOB_COMPLETION_INDEX(0..W-1) mapped toWorkerTopology(rank=r, world_size=W, source="k8s_indexed_job"), contiguous Storage Readrow_restriction, LPT cell ordering, and dedicated per-model Pods fordeep_learning/ global models.gke_mode = "ray": Launches a Ray-on-GKE cluster (ray_engine.py,ray==2.59.0) inside the GKE namespace (using a HeadlessService+ WorkerDeployment+ HeadJob, or a KubeRayray.io/v1RayClustermanifest).vertex_automl— Gemini Enterprise / Vertex AI AutoML Forecasting & Tabular Workflow for Forecasting (automl_engine.py). Runs theautomlfamily (vertex_l2l,vertex_tide,vertex_tft,vertex_seq2seq) via Vertex AI Pipelines (automl_mode="tabular_workflow", default) or managedAutoMLForecastingTrainingJob(automl_mode="training_job"), indexingstage_1_tuning_result_artifact_uribyrun_idfor Stage-2 warm-starting and capturing both Tier 1 (forecast_metadata.fit_diagnostics["feature_attributions"]) and Tier 2 (forecast_predictions.explanations) feature attributions.
A run resolves its models into one job per family (statistical / ml / deep_learning / automl, plus
native in BigQuery), all running in parallel under one run_id. Each Python family runs on
python_runtime unless it is overridden per family via compute.families (below) — so one run
can put its statistical family on Spark, its deep-learning family on Vertex CustomJob, GCE, GKE, or Ray, and its automl family on vertex_automl. See the DAG model in
architecture.md.
data — DataConfig¶
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
source_table |
str |
required | table, dataset.table, or project.dataset.table; no backticks, whitespace, or control characters |
Source table for series (e.g. source_series_iceberg / source_series_native). Written into backticked SQL, so the shape is checked at load. |
ts_id_col |
str |
"ts_id" |
BigQuery column name ([A-Za-z_][A-Za-z0-9_]*, ≤ 300 chars) |
Series-id column. |
date_col |
str |
"ds" |
BigQuery column name | Date column. |
target_col |
str |
"y" |
BigQuery column name | Target column. |
freq |
str |
"D" |
one of D W MS ME h |
Series frequency (pandas ≥2.2 spellings). Sets the seasonal period used by the models — D=daily (period 7), W=weekly (52), MS/ME=month start/end (12), h=hourly (24). An unsupported freq is rejected at validation. |
horizon |
int |
28 |
> 0 |
Forecast horizon (steps). |
series_limit |
int | null |
null |
> 0 when set |
null = all series; an int subsets the shipped data (demo small → scale large). |
grid_policy |
"none" | "zero_fill" | "fill_zero" | "ffill" | "linear" |
"none" |
— | Pre-fit timestamp grid regularization ("zero_fill" for missing POS transaction days; "ffill" or "linear" for sensor/inventory snapshots). Applied strictly within each fold cutoff. |
trim_leading_zeros |
bool |
false |
— | Strips pre-launch leading zeros prior to the first non-zero observation in each training fold. |
snapshot_millis |
int | null |
null |
> 0 when set |
Explicit BigQuery time-travel snapshot epoch milliseconds (FOR SYSTEM_TIME AS OF TIMESTAMP_MILLIS(...)) to pin source reads across runs; null resolves fresh from the BigQuery clock at run start (run_registry.snapshot_millis). |
features — FeaturesConfig¶
| Field | Type | Default | Purpose |
|---|---|---|---|
holidays |
list[str] |
[] |
Holiday country codes to add (e.g. ["US"]). |
transform |
"none" | "log1p" | "boxcox" |
"none" |
Target transform, inverted on output. |
exog |
list[str] |
[] |
Legacy alias for future_covariates (known future exogenous columns projected from the source table). |
static_covariates |
list[str] |
[] |
Time-invariant series attributes (stat_exog_list, e.g. ["region", "category", "base_price"]) consumed by cross-series panel models (tide, tft, tsmixer). |
future_covariates |
list[str] |
[] |
Known-future time-varying regressors (futr_exog_list, e.g. ["promo_flag", "price_index", "is_holiday"]) consumed by covariate-aware statistical, ML, deep-learning, and arima_plus models. |
past_covariates |
list[str] |
[] |
Historical-only observed regressors (hist_exog_list, e.g. ["temperature", "foot_traffic"]). Strictly masked beyond the forecast origin (cutoff_date) during rolling-origin backtesting and HPO to prevent lookahead leakage; consumed natively by tide, tft, tsmixer, and neuralprophet (n_lags >= 1), or via exog_lags / cutoff carry-forward in ml, statistical, and arima_plus. |
exog_lags |
dict[str, list[int]] |
{} |
Lagged copies of declared exog / future_covariates / past_covariates columns — {"promo": [1, 7]} adds promo_lag_1 and promo_lag_7. Keys must appear in exog, future_covariates, or past_covariates; lags must be positive and distinct. There is no lags field for the target — see "Why there is no target-lag knob" below. |
on_unsupported_covariates |
"fallback" | "error" |
"fallback" |
Policy when a selected model does not support one or more configured covariate tiers: "fallback" logs a warning and strips the unsupported tier(s) (and their exog_lags) for that model; "error" fails fast in preflight with a ConfigError. |
include_univariate_baselines |
bool |
false |
Automatically appends <model>@univariate alongside every covariate-capable model in models (or scoped by ablation_models when ablation_modes is empty) so the run measures covariate lift directly in one submission. |
ablation_modes |
list[AblationMode] |
[] |
Declarative covariate and training-scope ablation ladder ("univariate", "cov_future_only", "cov_past_only", "cov_static_only", "cov_all", "local_cov_all", "local_univariate", "global_cov_all", "global_univariate"). Expands <model>@<mode> variants for each capable model and automatically skips no-op duplicates that resolve to the same effective (training_mode, cov_profile) spec. |
ablation_models |
list[str] | null |
null |
Optional subset of base models in models to expand ablation_modes on (null expands across all capable models in models). |
fourier |
bool |
false |
Fourier seasonality terms. |
level_shift |
bool |
false |
Detect one abrupt regime change and add it as a level_shift step dummy. |
trading_days |
bool |
false |
Adds calendar business-day counts (trading_days_in_period) for monthly/weekly series. |
censored_col |
str | null |
null |
Source column flagging stockout or capacity-censored observations (1/True); treats flagged dates as missing actuals and imputes uncensored demand within each training fold. |
outlier_policy |
"none" | "winsorize" | "interpolate" | "flag" | "stl_resid" |
"none" |
Winsorizes ("winsorize"), linearly interpolates ("interpolate"), or flags ("flag") unflagged historical spikes exceeding outlier_sigma seasonal MADs (or STL residuals under "stl_resid") while preserving active promo_flag / is_holiday rows. |
outlier_sigma |
float |
3.5 |
Robust seasonal-MAD threshold (≥ 1.5) for outlier_policy. |
regime_routing |
bool |
false |
Classifies each series via the Syntetos–Boylan–Croston (SBC) matrix (smooth, erratic, intermittent, lumpy) and skips smooth continuous models on intermittent/lumpy series when intermittent-capable models are present. |
What each option produces (features.py):
transform— reshapes the target before fitting and inverts it on output:none— identity (no constraint ony).log1p—log(1+y)forward,expm1back. Tames multiplicative growth / right-skew. Requiresy >= -1. Stateless.boxcox— a power transform whose λ is fit per series by maximum likelihood; the same λ is reused across backtest folds and the final fit. Strongest variance-stabilizer of the three. Requires strictly positivey(y > 0).holidays— adds a singleis_holidayflag (1.0 on holiday dates) from theholidayspackage for each ISO country code (e.g.["US", "GB"]). The same calendar feeds the BigQuery-native models, so holiday handling matches across runtimes. An unknown code fails fast.exog_lags— for each declaredexogcolumn and each integerL, adds a{column}_lag_{L}column (that covariate shifted backLsteps). Use it when a driver acts on the target with a delay: a promotion that lifts demand the following week, a price change that takes a month to bite. Values must be positive and distinct, and the key must already be inexog— all three are checked at config load, not in the first cell.- The first
Lrows of history have noL-step-ago value, so the frame's head is dropped rather than filled: at a maximum lag of 28, a series trains on its observations from day 29 onward. A filled value at the head is a fabricated coefficient everywhere. - Over the forecast horizon each lag is read off its own source column's timeline. For the
first
Lsteps that is a genuine past observation; from there it is whatever the unlagged covariate itself resolves to — a real forward value if your source table extends past the cutoff, otherwise the same recency stand-in the unlagged column gets. A lagged covariate is exactly as knowable as the covariate it lags, and never more. fourier— adds sine/cosine yearly seasonality terms (order 3 → 6 columns). Smooth periodic signal for the regression-based models.exog— passes named driver columns straight from the source table through to the models that accept exogenous regressors.level_shift— detects a single abrupt regime change in the series and adds onelevel_shiftcolumn:0before the changepoint,1from it onward, and1across the whole forecast horizon. It is a step, not a spike — that persistence is exactly what distinguishes a level shift from an outlier, and it lets a regression model absorb the jump as one coefficient instead of fitting the average of two regimes and staying biased for the entire horizon. Detection is a single-changepoint scan standardized by a robust (MAD-based) noise estimate, accepted only above 3σ; below that the column is all zeros, because a spurious regressor on one series in ten thousand is a bad forecast nobody reviews. Worth turning on when your history contains re-baselinings, store openings/closings, or a unit-of-measure change — the shipped example data contains them by construction (data_gen.generatorplants one per series with archetype-specific probability).
Why there is no target-lag knob¶
There is no features.lags, and that is a design rule rather than a gap. "Lag" means two different
things, and only one of them can be built in a config:
- A lag of the target is only honest at predict time if the model rolls its own forecasts
forward one step at a time. That is recursion, and it belongs to the model. The models that need
it already own it:
lightgbm,xgboostandregression_lagsshare a recursive lag forecaster with fixed depths(1, 2, 3, 7, 14, 28), andneuralprophethas autoregression behindmodel_params.neuralprophet.n_lags. A config cannot supply one, because nobody knows the future target — the only way to fill those horizon rows is to invent them. - A lag of a covariate is honest, because a covariate's future is as knowable as the covariate
itself. No model in the suite lags one internally, so that is the gap
exog_lagsfills.
Setting lags in features fails at config validation with a clear error message directing you to exog_lags for lagged covariates or model_params.neuralprophet.n_lags for autoregressive target lags.
Precedence, if the two ever meet. The model wins. A model that lags its covariates internally
sets lags_covariates_internally on its class and is handed the unlagged columns only, so a
covariate is never lagged twice — once by the config and once by the model. Every shipped model
leaves that flag off, which is correct rather than pending: the tree models and NeuralProphet lag
the target, and prophet/sarimax/ucm take covariates contemporaneously.
How these features are valued over the forecast horizon. A model is fit on history and then
asked to predict dates it has never seen, so the same feature columns have to exist for those
dates too (features.build_future_features). Most of them are a deterministic function of the
date and are therefore recomputed exactly at the future dates — is_holiday reflects the
holidays that actually fall in the horizon, and the Fourier terms continue the real seasonal
phase. level_shift is carried forward as 1.
The one exception is exog, which is genuinely unknown until the future arrives: those
columns fall back to the most recent horizon observed rows, so an exog-driven forecast is
indicative rather than authoritative. To get a real forward-looking exog path, extend your
source table past the target cutoff with the driver values (a price plan, a promo calendar, a
published index) — the read picks them up with no config change.
exog_lags columns inherit whichever of those two cases applies to their source. A
{column}_lag_L value is a genuine past observation for the first L horizon steps, and from
step L onward it reads exactly what the unlagged column resolved to L steps earlier — real if
you extended the table, the recency stand-in if you did not. Nothing is fabricated that the
covariate did not already carry, which is why lagging a covariate is offered and lagging the
target is not.
backtest — BacktestConfig¶
Off by default (cheapest first run). Turn it on to get an OOF metric panel — and it's a prerequisite for HPO and learned ensembles.
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
enabled |
bool |
false |
— | Turn backtesting on. |
scheme |
"expanding" | "sliding" | "expanding_frozen" | "expanding_stale" |
"expanding" |
— | How the model is carried between fold origins. See Backtest schemes below. |
n_folds |
int |
3 |
≥ 1 |
Number of folds. |
horizon |
int |
28 |
> 0 |
Per-fold forecast horizon. Linked to data.horizon — see Two horizons below. |
step |
int |
28 |
> 0 |
Step between folds. |
min_train |
int |
180 |
> 0 |
Minimum training length. |
decision_metric |
see below | "wape" |
— | Metric folds are judged on. |
short_series |
"adapt" | "overlap" | "shrink_train" | "skip" | "error" |
"adapt" |
— | What a series too short for the fold grid gets. See Short series below. |
min_folds |
int |
1 |
≥ 1, ≤ n_folds |
Fewer achievable folds than this and the series is left unscored rather than weakly scored. |
min_train_floor |
int \| null |
null |
> 0 |
The hard training-length minimum shrink_train may not cross. Required with that policy, and rejected without it. |
gap |
int |
0 |
≥ 0 |
The embargo: observations discarded between train and validation, for a known reporting lag. See The embargo below. |
window |
int \| null |
null |
> 0 |
A fixed sliding training width, decoupled from min_train. Defaults to min_train. |
control_arm |
bool |
false |
rejected on expanding_stale |
Also score a blind, never-refreshed arm on a refit scheme. See The control arm below. |
This section is the field reference. For the reasoning behind it — how folds are anchored, why the newest fold fits nothing, what a short series loses, and what each scheme actually measures — see backtesting.md.
scheme — how the training window moves (backtest.py).
Folds are anchored from the end of each series: the latest fold validates on the final horizon
points, and each earlier fold steps its validation window back by step. The two schemes differ only
in where training starts:
expanding(default) — training uses all history from the series start up to each fold's validation point. The train window grows fold to fold. Best default: every fold sees maximum history.sliding— training uses a fixed-width window of the lastwindowobservations immediately before each fold, andwindowdefaults tomin_train. Older history is dropped. Use when the series' behavior drifts and recent history is more representative than old history.
n_folds, horizon, step, min_train and gap lay the folds out together, and a series needs at
least min_train + gap + horizon + (n_folds−1)·step observations to be scored on all of them.
Two horizons¶
A config carries two of them and they are easy to confuse. data.horizon is how far the shipped
forecast reaches. backtest.horizon is how far each fold predicts before it is scored. They
are separate fields on purpose — you can score a model over seven steps while shipping twenty-eight
— and setting them apart is legal on every engine.
A mismatch earns a warning, in either direction, because the leaderboard then ranks models over
a horizon the run does not deliver, and a model that wins at seven steps is not automatically the
one to trust at twenty-eight. The warning names both fields; nothing is rewritten, because a config
that repaired itself would move its own run_id and land in the registry describing a run you did
not ask for.
How many steps a fold really asks for is backtest.gap + backtest.horizon, not
backtest.horizon alone. A fold's model stops training at its cutoff, and with an embargo the
scored window starts gap observations later — so the model has to forecast across the embargo
before it reaches anything that gets scored, and the first gap values are thrown away. This is
the number that sizes everything: the Python engines are handed it as their horizon, and each
BigQuery-native fold model is created with OPTIONS(horizon = gap + backtest.horizon) while the
final model stays at data.horizon. BigQuery bakes that number into the trained model and rejects
an ML.FORECAST asking for more, so each model is created for exactly what its own forecast asks.
(timesfm is the exception that needs no accounting: AI.FORECAST has no training step, so there
is no ceiling to fit under.)
A shorter series is scored on fewer folds; it never loses its forecast. Backtesting scores a
model — it does not produce the forecast — so a scoring shortfall costs only the score. The fold
grid shrinks to whatever the series supports, dropping the oldest folds first (so every series
is scored on the most recent window it can reach) and keeping the survivors' original fold_ids, so
a fold is never renumbered by the series that happens to be short. A series too short for even one
fold is fit and forecast unscored.
fold_id is an ordinal within one series' own plan, and it is counted back from that series' last
observation — so on a ragged panel two series' fold 3 are two different date windows, and the
BigQuery-native path (which counts back from one global MAX(ds)) numbers differently again. Each
out-of-fold row therefore also carries cutoff_date, the last date the model was allowed to see.
That is what identifies a fold when rows have to be compared across series or across engines, and
it is what the ensemble joins on — see
output_schemas.md.
The newest fold is reserved from every fit. Fold n_folds − 1 — the one with the smallest
step-back — trains no stacker, sets no inverse_error weight, and is never in a hyperparameter
search's objective. It is still scored, like every other fold; it is simply not fit on, so the
things this run learns are judged on a window they never saw. That is why n_folds: 1 is worth a
second thought before you pick it: it is legal, it works, and it leaves nothing to reserve, so
every learned weight and every tuned parameter is scored on the fold it was chosen by. The run says
so rather than hiding it — ensemble_scoring / hpo_scoring read in_sample, and the run header
carries the same claim under job_telemetry.$.scoring. n_folds: 2 is the smallest setting that
buys an honest number.
Three columns on forecast_metadata record how the scoring went, separately from how the cell went:
| Column | Meaning |
|---|---|
backtest_status |
full | reduced | unscored | failed, or NULL when backtesting was never asked for. |
n_folds_achieved |
Folds actually scored. This is the column that makes a leaderboard readable across a ragged panel — two series with the same WAPE are not comparable if one was scored on five folds and the other on one. |
backtest_note |
Why it was not full: the shortfall arithmetic, or the exception. |
All three NULL is the one case where a NULL metric panel is not a shortfall. Without them, a
reduced backtest and a full one look identical through the metric columns, and an unscored series
looks exactly like a run with backtesting switched off.
Features are built once and a fresh model is fit per fold, so no state leaks across folds and
train_end + gap == val_start always (no leakage; at the default gap of 0 the two are adjacent).
gap — the embargo. Raising gap opens a band of observations between the end of training and
the start of validation that are neither trained on nor scored. That is how you measure a forecast
issued with a reporting lag: if the last fortnight of actuals is never in the warehouse when the
model runs, then scoring a model that trained right up to the cutoff flatters it. Set gap: 14 and
each fold's model stops learning fourteen days earlier.
Two things about how it is applied are worth knowing, because they are what make the numbers usable:
- The embargo moves the training end, not the validation window. A
gap: 14run scores exactly the same dates as the same config atgap: 0, so the two runs are directly comparable and the difference between them is the cost of the lag. The alternative — pushing validation later — would have changed both the model and the test set at once. - It costs history. Each fold gives up
gapobservations it would otherwise have trained on, which is whygapis in the feasibility formula above: a short series achieves fewer folds at a larger embargo.
The BigQuery-native path mirrors this exactly. It forecasts gap + horizon points from its cutoff
and then discards the first gap of them before joining to actuals, so horizon_step means the
same thing on both engines: position 1 is the first scored point, not the first point the model
emitted.
Short series¶
A panel of real series is ragged, so some of them will not hold the fold grid you asked for. There
is no universally right answer to that, because every answer gives something up. short_series
is where you say which thing you would rather lose, and the five policies are listed here in the
order of how much they cost you:
short_series |
What it does for a series too short for the grid | What you give up |
|---|---|---|
adapt (default) |
Scores it on the folds it can support, dropping the oldest ones. | Folds. A short series is ranked on thinner evidence than a long one. |
overlap |
Narrows step until all n_folds fit — the widest step that works, so the least overlap that works. |
Fold independence. The validation windows share observations, so the fold scores are no longer independent evidence. |
shrink_train |
Lowers min_train — as little as the shortfall demands — until all n_folds fit, never below min_train_floor. |
Training history. Early folds are fit on less data than you said a model needs. |
skip |
Leaves the series out of the backtest entirely. | The series. It is still fit and forecast; it just never appears on a leaderboard. |
error |
Refuses the whole run before it starts. | The run. Nothing is produced until you change the geometry or the policy. |
Three things are worth knowing about how these behave together.
min_foldsis the give-up floor, and it judges the result, not the starting point. A rescue policy gets to try first; if what it achieved is still belowmin_folds, the series is left unscored rather than ranked on evidence too thin to rank it. The default of1is exactly today's behaviour — score anything that supports at least one fold.- A rescue is a rescue, not a rewrite.
overlapandshrink_trainchange nothing at all for a series that was long enough already, so a panel of mixed lengths still lays its long series out the wayadaptwould. - Nothing here can cost you a forecast. Backtesting scores a model; it does not produce the
forecast. Every policy except
errorstill fits and forecasts the series, anderrorrefuses at plan time — before any cell exists — rather than failing cells one at a time. A series that ends up unscored getsbacktest_status = 'unscored'and abacktest_notenaming the policy that left it that way; its forecast is written either way.
When a policy adjusts the geometry, the cell's backtest_status is reduced and its
backtest_note names the policy and the trade. reduced means "not the geometry the config asked
for", so overlap and shrink_train produce the otherwise-impossible pair of reduced with
n_folds_achieved equal to the requested n_folds — the signal that the full fold count was
bought with something other than folds.
short_series is a Python-path policy. The BigQuery-native models count their folds back from
a single global MAX(ds) in SQL, so there is no per-series grid for a policy to adapt. error is
the exception, because it is checked against the panel at submit time rather than per cell — one
aggregation before anything is provisioned — so it stops a native run too. Each Python engine
re-checks it on the panel it read, which is what covers the launch paths that do not go through
the submit verb: a staged config run from an emitted command, or a Composer task.
Backtest schemes¶
scheme decides what a fold's score is a score of. The four answers are different numbers, not
cheaper approximations of one number, so pick the one that matches the question you are asking.
scheme |
What happens at each origin | The question it answers |
|---|---|---|
expanding (default) |
A fresh model is fit on all history up to the cutoff. | How good is this model when freshly trained? |
sliding |
A fresh model is fit on a fixed-width window (default min_train). |
Same, but with a bounded memory. |
expanding_frozen |
One fit on the oldest fold's window, then handed the observations that arrived since, parameters held fixed. | What does refitting less often cost me? |
expanding_stale |
One fit, and the model is never told what happened next. | How fast does this decay if nobody touches it? |
Fold geometry is identical across all four except sliding, which is the only one with a
fixed-width training window. What changes is how the model is carried between origins.
Ten of the sixteen Python models can absorb a new observation without re-estimating, so they can
answer expanding_frozen; the rest refit for that fold and record backtest_refit = 'unsupported'
on forecast_metadata rather than pretending otherwise. Every model answers expanding_stale,
which is what makes it the scheme where a cross-model leaderboard compares like with like. The
BigQuery-native models always report per_fold — CREATE MODEL is the only way to fit them.
An ensemble row inherits backtest_refit from the members its blend was built from: their mode
when they agree, and mixed when they do not. A blend is only as frozen as its least-frozen
member, and mixed is how you see that a consensus sitting on an expanding_frozen leaderboard
is not comparable to the single-model rows around it.
The control arm — what is the refitting buying you?¶
Both frozen schemes additionally score a control arm: the same fit walked forward blind, on the
same dates. It costs a forecast, not a fit. It lands in backtest_oof.yhat_stale per row, and is
summarised per cell as forecast_metadata.staleness_gap — the blind arm's loss minus the primary
arm's, under this run's decision_metric, positive when never refreshing the model hurts.
Set backtest.control_arm: true to get that same second arm on expanding or sliding. The
question — how much of my accuracy is the refitting rather than the model? — is one the default
scheme could not answer before: asking it meant switching to a frozen scheme, which changes what
the primary arm measures, so you got the counterfactual and lost the number you came for. With the
flag, the primary arm is untouched (backtest_refit still reads per_fold, and not one shipped
number moves) and yhat_stale is simply filled in beside it.
It costs one extra fit per cell, on the oldest fold's window, plus a forecast per fold — not a
fit per fold — which is what makes it affordable on the path most runs take. A model with no blind
seam keeps its scheme and leaves the column NULL. On expanding_stale the flag is refused, not
ignored: that scheme's primary arm already is the blind model, so the control arm would be the
same model twice and the gap would be zero by construction.
decision_metric — what folds are judged on¶
Definitions live in
metrics/ (err = yhat − y_true). This single choice drives
fold selection, HPO's objective, inverse_error weighting, and prune_threshold.
The twenty-one below are what ships (full mathematical definitions in
metrics_reference.md). They are not a closed set: the field accepts
any metric the metric registry knows, and a deployment adds one by dropping a file into
src/scale_forecasting/metrics/ — see adding_a_metric.md. An unregistered
name fails validation with the registered ones listed.
| Metric | Definition | Notes |
|---|---|---|
mae |
mean(|err|) | Mean absolute error. Scale-dependent. |
rmse |
√mean(err²) | Penalizes large misses more than mae. |
mse |
mean(err²) | Squared error; rmse without the root. |
mape |
mean(|err| / |y_true|) | % error. NaN if any y_true == 0. |
smape |
mean(2|err| / (|y_true|+|yhat|)) | Symmetric %; bounded, handles zeros gracefully. |
wape |
Σ|err| / Σ|y_true| | Weighted absolute % error — the scale-safe default. NaN only if the series sums to 0. |
mase |
mae / mae(naïve-1-step) | Scaled vs. a naïve forecast; needs training history. <1 beats naïve. The canonical Hyndman–Koehler definition, whose naïve is always the one-step one — mase_seasonal below is an additional metric, not a correction to this one. |
rmsse |
rmse / rmse(naïve-1-step) | Squared analog of mase; needs training history. |
bias |
mean(err) | Mean error — sign shows over/under-forecast. HPO minimizes |bias|. |
coverage |
fraction of y_true inside [lower, upper] |
Needs prediction intervals; want it near the nominal level. |
pinball |
avg quantile loss at the 0.1 / 0.9 bounds | Needs prediction intervals; scores interval sharpness+calibration. |
mase_seasonal |
mae / mae(seasonal naïve) | mase against y_{t−m} instead of y_{t−1}, where m is the freq's cycle (7 daily, 12 monthly, 24 hourly). Needs training history longer than one cycle. |
maape |
mean(arctan(|err| / |y_true|)) | mape that survives zeros — a zero actual contributes π/2 instead of NaN-ing the window. Range [0, π/2]. |
interval_score |
mean Winkler score at α = 0.2 | Needs prediction intervals; width plus a penalty for each miss. Lower is better. |
interval_width |
mean(upper − lower) | Needs prediction intervals; sharpness only, in the units of the series. |
ope |
|Σy_true − Σyhat| / |Σy_true| | Overall percentage error on cumulative horizon volume. |
rmsle |
√mean((ln(1+y_true) − ln(1+yhat))²) | Root mean squared logarithmic error. NaN if any actual or forecast < 0. |
msse |
mse / mse(naïve-1-step) | Mean squared scaled error (rmsse²); needs training history. |
msis |
interval_score / mae(seasonal naïve) | M4 competition Mean Scaled Interval Score; needs prediction intervals and training history. |
r2 |
1 − Σerr² / Σ(y_true − ȳ)² | Coefficient of determination (higher is better; 1.0 is perfect). |
cv |
rmse / ȳ | Coefficient of variation of RMSE normalized by evaluation window mean. |
Pick wape (default) or smape for a robust scale-independent choice; maape instead of mape
on intermittent-demand series that hit zero; mase/rmsse/msse to beat a naïve baseline, or
mase_seasonal when the series is strongly seasonal and the one-step naïve is too easy to beat.
The five interval metrics — coverage, pinball, interval_score, interval_width, msis — only
mean something when you care about the prediction bands, and ensemble OOF has no intervals, so all
five read NaN for ensembles.
What "needs training history" means for mase, rmsse, msse, mase_seasonal, and msis. All
five divide the error by the average step of a naïve forecast over the training data, so which history goes in
decides the number. Every engine uses the same rule: the fold's own training window — the
observations at or before that fold's cutoff_date, and under backtest.scheme: sliding only the
last window (default min_train) of them. The window the fold is scored on is never in its own denominator, so a
mase from a Python model and a mase from a BigQuery-native model for the same series are
answers to the same question and can sit in the same leaderboard column. An ensemble is scaled the
same way, at the cutoff its blended rows carry. One consequence worth knowing: because each fold
has a different training window, the per-fold mase values a run rolls up are each scaled slightly
differently — that is what makes them honest, and it is why mase across runs with different fold
geometry is not a like-for-like comparison.
On the interval scores: coverage and interval_width are each half of the story and
each trivially gamed — an infinitely wide band covers everything, a zero-width one is maximally
sharp. interval_score (or msis when comparing across series of different scales) is the number
that combines them, which is why it is the sensible decision_metric if intervals are what you are
ranking on. The other two are worth reading alongside it because they say how a model got its
score. Whichever you pick, read it next to forecast_metadata.interval_source: a model that
computed its own interval and one whose band was manufactured from its residuals are not scored on
the same thing (see output_schemas.md).
Which direction is better is not the same answer for every metric. Eighteen of the twenty-one are
errors, so smaller is better. coverage and r2 are higher-is-better (1.0 is ideal). bias is signed, so
what you want is near zero — a large negative bias is exactly as wrong as a large positive one.
Three places in the system have to rank things by the chosen metric — the HPO objective, the
inverse_error ensemble weights, and prune_threshold — and each of them needs the same
translation into a single "smaller is better" number. metrics.METRIC_DIRECTION states the
direction for each metric and metrics.loss_of applies it: an error passes straight through,
coverage becomes 1 − coverage, bias becomes |bias|, and a metric that could not be computed
becomes infinity so it ranks last rather than first. That one map is why picking a different
decision_metric does not require thinking about which of the three consumers handles it correctly.
Two caveats the direction map deliberately does not try to fix. Coverage is really best at its
nominal level, not at 1.0 — a band wide enough to cover everything scores perfectly here — and
interval_width on its own rewards a band of zero width. Neither is a good decision_metric alone;
interval_score is the one that trades them off.
output — OutputConfig¶
Controls point-forecast calibration, physical domain guardrails (floor, cap, integer_rounding), monotone multi-percentile fans (quantiles), and Newsvendor asymmetric cost targets (underage_cost, overage_cost). See Forecaster workbench (docs/forecaster_workbench.md) for practical supply-chain and finance examples.
| Field | Type | Default | Purpose |
|---|---|---|---|
point_forecast |
"raw" | "median" | "mean" | "auto" |
"auto" with a backtest, "median" without |
Which arm ships in yhat. |
floor |
float | null |
null |
Lower physical bound applied to yhat, yhat_lower, yhat_upper, and all quantiles (e.g. 0.0 for non-negative demand). |
cap |
float | null |
null |
Upper physical ceiling applied to yhat, intervals, and quantiles (must be ≥ floor). |
floor_col |
str | null |
null |
Source column supplying dynamic per-series/per-date lower bounds. |
cap_col |
str | null |
null |
Source column supplying dynamic per-series/per-date capacity ceilings. |
integer_rounding |
"none" | "round" | "ceil" | "floor" | "cumulative_round" |
"none" |
Discrete unit rounding applied after interval calibration ("cumulative_round" preserves multi-step horizon totals via error-diffusion rounding). |
quantiles |
list[float] |
[] |
Additional empirical percentiles $\tau \in (0, 1)$ (e.g. [0.1, 0.25, 0.5, 0.75, 0.9, 0.95]) emitted in quantiles with Chernozhukov monotone rearrangement. |
target_quantile |
float | null |
null |
Explicit service-level percentile $\tau \in (0, 1)$ to emit as the primary yhat point forecast. |
underage_cost |
float | null |
null |
Unit stockout / lost-margin cost $c_u > 0$; paired with overage_cost to set yhat to the Newsvendor critical fractile $\tau^* = c_u / (c_u + c_o)$. |
overage_cost |
float | null |
null |
Unit holding / spoilage / markdown cost $c_o > 0$; paired with underage_cost. |
Every model emits one number per future date, so something decides what that number is:
raw— the model's own output, untouched.median— that output plus the median residual. Minimises absolute error, which is what most of the metric panel measures.mean— plus the mean residual. Minimises squared error, and drivesbiasto zero by construction. Requiresbacktest.enabled: the mean shift is estimated from out-of-fold residuals and there is no in-sample equivalent for a model that builds its band from quantiles. Asking for it without a backtest is an error, not a silent downgrade.auto— decide per series and model, from that cell's own held-out folds. The three above are one setting for the whole fleet; this one is the admission that a fleet is not uniform. Also requiresbacktest.enabled, for the same reason and more sharply: there is nothing to decide from otherwise. See Letting each series choose below.
yhat_raw and yhat_adjusted are both written to forecast_predictions and backtest_oof
whatever you set here, so the choice is never destructive — you can re-score a finished run on the
other arm without re-fitting anything.
A backtest earns auto. Leave point_forecast unset and a run with backtesting on resolves to
auto — the arm is measured per series and model rather than assigned to the whole fleet. Without a
backtest there is nothing to measure from, so it resolves to median. The resolution happens at
parse time and lands in the serialized config, so the run_id records what was asked for rather
than an instruction to decide later; auto stays as written, because its answer is per cell and no
single arm can stand for it.
Why not the fleetwide rule? Because it is a theorem about the expected case and the fleet is
not the expected case. The pairing is real — the median minimises absolute error, the mean minimises
squared error — and it is still what an auto cell falls back to when it has too few folds to
choose, and still what warns you if you pair an explicit median with a squared-error metric. But
measured at 3 folds across ten models, per-series selection landed fleet RMSE at 28.77 against
30.54 for the fleetwide rule, and MAE at 18.75 against 19.32. The case that settled it: under a
squared-error metric the fleetwide rule picks mean, and mean scored worse than applying no
correction at all (30.54 against 30.05). A default that is beaten by doing nothing is not a
default.
Why this field exists. Residual calibration shifts a model's forecast by its median or mean residual (improving fleet WAPE by ~5.7% across biased series), while leaving unbiased series untouched under auto. sf.calibration_report(run_id) reports what the calibration choice contributed on your panel.
Letting each series choose — point_forecast: "auto"¶
The 5.7% figure above is a fleet average, and a fleet average is the thing that hides the series it
does not apply to. The residual correction helps a model that is genuinely biased on a given series
and hurts one that is not, because on an unbiased series the shift is fitted noise. auto asks the
question per cell instead of once for everybody: for each series-and-model pair, does the corrected
arm beat the raw arm on that cell's own held-out folds?
The rule. The corrected arm keeps its place only if it wins on a strict majority of the cell's
held-out folds, with a minimum of three folds. Fewer than three, or no usable backtest at all,
and the fleetwide arm applies unchanged. A tie goes to raw.
Three things about that rule are worth stating because none of them is the obvious choice:
- There is no margin threshold, although the natural design has one ("switch only if the correction wins by more than 5%"). At three folds it cannot work. In a simulation with no real effect at all, the pooled margin's 90th percentile is 0.113 — an 11% apparent improvement out of pure noise — and under a real effect the margin stays negative until the bias is roughly half a standard deviation, so the two distributions overlap almost completely. Every threshold tried (2%, 5%, 10%) came out worse than no threshold at every fold count. Counting how many folds agree is the robust form of the same question, and it measured better.
- Three folds, not five. Two is not merely weak, it is wrong in a known direction: with two folds the comparison has a single fold to grade on and grades the correction on the residuals it was fitted from, which flatters it systematically. Three is where the evidence becomes honest, and it is also where selection posts its largest gain — a minimum of five would have discarded that.
- A tie goes to
raw. Not a coin flip: in the folds where the two arms genuinely cannot be told apart, the corrected arm is still carrying the estimation variance of a shift it did not need, so equal measured loss is not equal expected loss. Sending ties the other way was measured and cost about 3% of fleet error.
What it costs and what it buys. One extra pass over the out-of-fold residuals per cell, no re-fitting. On a ten-model fleet it beat both fleetwide arms at every fold count tried, on both the absolute-error and squared-error pairings, capturing roughly 45% of the gap to an oracle that knows the answer in advance.
What lands in the record. auto is the one arm that stays unresolved in the serialized config,
because the resolution is per cell — the run_id records that selection was asked for, and
forecast_metadata records what each cell did with it:
point_forecast_source names the arm that shipped, and point_forecast_decision says how it got
there (auto-raw, auto-corrected, auto-few-folds, auto-no-backtest, and configured on a run
that named an arm). sf.calibration_report(run_id) rolls that up per model as raw_arm_rate — the
share of a model's series that ended up on the raw arm, which is 0 or 1 under a fleetwide setting
and anything in between under auto.
model_params — hyperparameters you set yourself¶
HPO searches for hyperparameters. model_params is the other half of that surface: the place to
state them, when you already know what you want and would rather not pay for a search.
"model_params": {
"neuralprophet": {"n_lags": 28, "n_forecasts": 28, "learning_rate": 0.01},
"xgboost": {"max_depth": 6, "n_estimators": 400}
}
Keyed by model name, then by that model's own parameter names. Values may be a scalar (bool, int,
float, str, null) or a flat list of scalars — anything that survives a JSON round-trip, since
run_id is a digest of the serialized config. NaN and ±inf are rejected at parse time for
exactly that reason: json.dumps writes them as bare NaN / Infinity, which is not JSON, and a
digest nobody else's parser can reproduce is not an identity.
Where an authored value takes effect, and what beats it. Your block is the layer underneath
everything: it is applied at the cell, and it is also applied underneath every HPO trial, so a
study tunes the same model the run will actually fit. Where the two name the same key, HPO wins —
pinning epochs on a model whose search space also searches epochs means the trial's value is used
and your pin is ignored, because a study that scored one value and shipped another would publish a
metric that does not belong to the fitted model. Keys the search space does not name are untouched,
which is the common case: an authored n_lags survives a tuned learning_rate. With HPO off, your
block is simply the params, filling in over each model's own defaults.
Unknown model names are refused before anything is provisioned, not at parse time. A block keyed
by a name no model is registered under is a typo, and a typo here is silent — the params just never
reach a model. It is not checked in config.py because that would mean importing the model registry
on the job-submission path, where the model stack is deliberately absent (eager model imports there
have broken a live run before). It is checked instead at plan time, on the paths that are about to
spend: a direct run, a staged launch, and the Airflow DAG's first task. A block for a model your
models list does not select is a warning, not an error — it has no effect.
Unknown parameter names still pass: each model reads the specific keys it knows and ignores the
rest. A model may also refuse a combination it cannot honour — NeuralProphet, for example, rejects
n_lags > 0 with an n_forecasts below the run's longest horizon, because in autoregressive mode it
emits exactly n_forecasts direct steps and does not recurse to fill a longer request. That refusal
happens at plan time too, so a config that would return a horizon of NaN costs nothing instead of a
fleet-hour.
Model variants (<base_model>@<tag>) & per-variant covariate overrides¶
Any registered model can appear multiple times in models under distinct <base_model>@<tag> identifiers (for example ["xgboost", "xgboost@univariate", "xgboost@promo_only", "tide", "tide@local_cov_all"]), and each variant can carry its own parameter and covariate overrides in model_params:
- Reserved ablation tags (
@univariate,@cov_future_only,@cov_past_only,@cov_static_only,@cov_all,@local_cov_all,@local_univariate,@global_cov_all,@global_univariate) automatically filter the run's covariate tiers and/or switch a hybrid model'straining_mode("local"vs."global") without requiring amodel_paramsblock. - Custom tags (
@promo_only,@shallow,@lag14, etc.) inheritmodel_params["<base_model>"]merged withmodel_params["<base_model>@<tag>"]. Insidemodel_params["<base_model>@<tag>"], six reserved covariate/scope keys control the variant's inputs before hyperparameters are passed to the estimator: training_mode:"local"|"global"(overrides a hybrid model's training scope for that variant)use_covariates:falseforces a strict univariate fit (cov_profile = "univariate")future_covariates,past_covariates,static_covariates: explicit per-variant column lists overridingfeatures.*_covariatesexog_lags: explicit per-variant lag dictionary (dict[str, list[int]])
Inspect the resolved orthogonal (scope_capability, training_mode, cov_profile, covariates_used, model_spec_hash) matrix offline before launching via Forecaster(cfg).variant_matrix().
hpo — HpoConfig¶
Optional Optuna tuning on the aligned backtest. Requires backtest.enabled.
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
enabled |
bool |
false |
— | Turn HPO on. |
engine |
"optuna" |
"optuna" |
— | HPO engine. |
n_trials |
int |
20 |
> 0 |
Trials per study. |
granularity |
"fleetwide" | "per_series" |
"fleetwide" |
— | The cost knob — see below. |
sample_size |
int |
20 |
> 0 |
Fleetwide only: how many series to tune on before applying the winner fleet-wide. |
Fleetwide vs. per-series — the cost trade-off. fleetwide (default) tunes each model once,
on a driver-side sample of sample_size series, then applies the winning hyperparameters to the
whole fleet — one study per model, cost independent of series count. per_series tunes inside
every cell — the best accuracy a model can reach on each series, but n_trials studies per
series, so cost scales with the fleet. Start fleetwide; reach for per-series only when a model's
optimal hyperparameters genuinely vary series to series and the accuracy is worth the spend.
sample_size: 20 means twenty series drawn from the whole fleet, not twenty per anything — the
winner from those twenty is what every series in the run is fitted with.
Tuned hyperparameters flow to the workers through the engine, not through the config — so HPO
never shifts the config-derived run_id, keeping runs reproducible and idempotent.
Held-out fold reservation during HPO. When backtest.n_folds >= 2, the newest fold (n_folds - 1) is automatically reserved from the Optuna search objective (hpo_scoring = "held_out"), so the holdout fold in v_model_leaderboard_comparable evaluates tuned and untuned models on an untouched validation window. With n_folds: 1, there is no second fold to reserve and hpo_scoring records "in_sample".
ensemble — EnsembleConfig¶
Consensus across the base models (scored onto the same leaderboard). See running_and_reviewing.md for the re-ensemble workflow.
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
enabled |
bool |
false |
— | Turn ensembling on. |
strategies |
list |
["median"] |
each a known strategy | Consensus & winner strategies (run several at once). |
prune_threshold |
float |
0.0 |
≥ 0.0 |
Drop base models weaker than this before blending. |
top_k |
int | null |
null |
≥ 1 when set |
Keep only the top-$K$ lowest-error base models per series before blending. |
parsimony_margin |
float |
0.0 |
≥ 0.0 |
Relative improvement margin (0.01 = 1%) a complex model must beat the simplest baseline (naive_seasonal / theta) by under "champion". |
variant_policy |
"all" | "best_per_base_model" | "primary_only" |
"all" |
— | Controls how <base_model>@<tag> variants enter the ensemble candidate pool: "all" includes every variant, "best_per_base_model" keeps only the lowest-OOF-error variant per (ts_id, base_model) so collinear ablation siblings do not dilute equal-weight blends, and "primary_only" restricts blending to untagged primary models. |
strategies ∈ calculated mean, median, inverse_error (no backtest needed) and backtest-driven / learned
champion, horizon_specialist, nnls, ridge, xgb (need backtest.enabled). A bare "strategy": "nnls" is accepted as shorthand
for "strategies": ["nnls"]. Run several at once — each produces its own ensemble_<strategy> rows (with calibrated prediction intervals and all 5 interval metrics)
and earns a line on the leaderboard next to the base models.
What each strategy does (implementation in
ensembler.py):
| Strategy | Kind | How the blend is formed | Needs |
|---|---|---|---|
mean |
calculated | Unweighted arithmetic mean of the base forecasts, row by row. Missing models are skipped and weights renormalize over those present. | — |
median |
calculated | Unweighted row-wise median — robust to a single wild base forecast. | — |
inverse_error |
calculated | Weighted mean where each model's weight ∝ 1 / its error, normalized to sum to 1. For the future forecast the error is the mean decision_metric from forecast_metadata; for the on-fold (OOF) score it's each series' own WAPE. A zero-error model captures the weight; if no error is usable it degrades to mean. |
(weights sharpen with backtest) |
champion |
winner selection | Selects the single best base model per series (one-hot weight 1.0) based on OOF decision_metric, subject to parsimony_margin. |
backtest.enabled |
horizon_specialist |
step-wise winner | Selects the lowest-error base model at each lead-time step $h = 1 \dots H$ per series (e.g., statistical short-horizon + ML/DL long-horizon). | backtest.enabled |
nnls |
learned | Non-negative least squares meta-learner: solves for weights ≥ 0 that best reconstruct the truth from the base models' out-of-fold predictions. No intercept; weights can't go negative. | backtest.enabled |
ridge |
learned | L2-regularized linear regression (closed-form, α=1.0) over the same OOF matrix. Weights may be negative (a model can be a corrective term). | backtest.enabled |
xgb |
learned | Gradient-boosted meta-learner (XGBRegressor, 200 trees, depth 3) fit on the OOF matrix; captures non-linear interactions between base models. Reported "weights" are its normalized feature importances. |
backtest.enabled, xgboost installed |
The learned / winner strategies train only on backtest_oof rows (never in-sample), so leakage
is structurally impossible — which is exactly why they require backtest.enabled (without it they're
dropped at config-load with a warning, not an error). Each fitted meta-learner is pickled to a GCS
artifact for lineage.
prune_threshold applies only to the calculated strategies: when > 0, any base model whose
mean backtest.decision_metric is worse than the threshold is dropped from the blend fleet-wide
before combining. 0.0 (default) prunes nothing.
"Worse than" is measured as a loss (metrics.loss_of, above), so the comparison reads the same way
whichever metric you chose: a threshold of 0.3 on wape drops models above 0.3, and the same
0.3 on coverage drops models that cover less than 70% of actuals. A model with no scored rows at
all is kept — absence of evidence is not evidence of a bad model, and pruning on it would silently
empty the blend on a run where the metric frame came back short.
hierarchy — HierarchyConfig¶
Off by default ({"enabled": false}, elided from the run_id digest when left at its defaults).
Turn it on to build bottom-up aggregated series across any hierarchy or grouped specification and
produce coherent reconciled forecasts ($\tilde{y_h} = S G \hat{y_h}$)
following Hyndman & Athanasopoulos (Forecasting: Principles and Practice, 3rd ed., Ch. 11).
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
enabled |
bool |
false |
— | Enable hierarchical aggregation and post-forecast reconciliation. |
levels |
list[list[str]] |
[] |
non-empty when enabled=true |
Grouping paths defining upper-level nodes (e.g. [["region_id"], ["region_id", "category_id"]]). An empty inner list [] (automatically included) represents the top-level "__total__" node. Supports both strict nested hierarchies and crossed/grouped structures ([["region_id"], ["category_id"]]). |
reconciliation_methods |
list[ReconciliationMethod] |
["bottom_up", "wls_struct", "mint_shrink"] |
non-empty when enabled=true |
Reconciliation operators applied to base forecasts. Each method emits reconciled cells named <base_model>_<method> alongside the untouched base cells. |
middle_level |
list[str] | null |
null |
must appear in levels when "middle_out" is selected |
Grouping path serving as the anchor level for "middle_out" reconciliation. |
Supported reconciliation_methods (reconciliation.py):
| Method | Kind | Mapping Matrix $G$ | Description |
|---|---|---|---|
bottom_up |
Single-level | $[0 \mid I_{n_b}]$ | Sums bottom-level forecasts directly up the summing matrix $S$. Preserves bottom-level dynamics with zero information loss. |
top_down |
Single-level | $[p \mid 0]$ | Disaggregates the top-level (__total__) forecast using historical average proportions $p_i = \bar{y_i} / \bar{y_{\text{Total}}}$. |
middle_out |
Single-level | Anchored at middle_level |
Aggregates upwards from middle_level via bottom_up and disaggregates downwards to bottom nodes using historical proportions within each middle-level parent. |
ols |
Optimal ($\text{MinT}$) | $(S^\top S)^{-1} S^\top$ | Ordinary Least Squares reconciliation ($W_h = I_n$). |
wls_struct |
Optimal ($\text{MinT}$) | $(S^\top W_{\text{struct}}^{-1} S)^{-1} S^\top W_{\text{struct}}^{-1}$ | Structural Weighted Least Squares ($W_{\text{struct}} = \operatorname{diag}(S \mathbf{1})$); requires no historical residuals. |
wls_var |
Optimal ($\text{MinT}$) | $(S^\top W_{\text{var}}^{-1} S)^{-1} S^\top W_{\text{var}}^{-1}$ | Variance-weighted Least Squares using diagonal in-sample / out-of-fold 1-step residual variances. |
mint_shrink |
Optimal ($\text{MinT}$) | $(S^\top W_{\text{shr}}^{-1} S)^{-1} S^\top W_{\text{shr}}^{-1}$ | Minimum Trace with analytical Schäfer-Strimmer shrinkage of the residual covariance toward its diagonal variance target, ensuring positive-definiteness even when $n_{\text{nodes}} \gg T$. |
mint_nonneg |
Constrained Optimal ($\text{MinT} \ge 0$) | Active-set projection on $W_{\text{shr}}^{-1}$ | Non-negative Minimum Trace reconciliation: projects negative bottom-level MinT allocations onto the $\tilde{b_t} \ge 0$ simplex and sums upward via $S \tilde{b_t}$. |
compute — ComputeConfig¶
Runtime scale, dependency delivery, and cost guardrails. Defaults are tuned for a first run; the Ray knobs only matter for a family that runs on Ray.
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
families |
dict[family → FamilyCompute] |
{} |
keys ∈ statistical/ml/deep_learning/automl |
Per-family runtime/hardware overrides (see below). |
ensemble |
EnsembleCompute |
{} |
— | When the ensemble node runs relative to the families (see below). |
max_parallelism |
int |
1000 |
> 0 |
Max parallel tasks. |
bucket_target_cells |
int |
8 |
> 0 |
Target cells per Spark bucket (shuffle-partition sizing). |
max_executors |
int \| null |
null |
> 0 |
Operator ceiling on the Spark fleet — most executors a batch may scale to, most workers a cluster may hold. null sizes to the fan-out alone, which at 100k series asks for hundreds and is rejected outright by a regional CPU quota. Budget for concurrency: a run's families submit together. |
machine_family |
"auto" | "n1" | "n2" | "n2d" | "e2" | "c2" |
"auto" |
— | GCE machine family for a Dataproc cluster's master + CPU workers ("auto" = n1). No-op on Serverless and on GPU workers — see below. |
spark_deps |
"packed_venv" | "container" |
"packed_venv" |
— | How a Dataproc cluster family gets its dependencies. "container" raises: it is a Serverless mechanism. See cluster_deps._resolve_cluster_deps. |
persist_models |
bool |
false |
— | Persist each fitted model as a GCS artifact (lineage). |
reuse_prior_runs |
bool |
false |
— | Snapshot-verified cross-run cell borrowing: before launching compute, queries BigQuery for prior COMPLETED cells matching (source_table, snapshot_millis, ts_id, model_type, model_spec_hash) and copies matching rows from forecast_metadata, forecast_predictions, and backtest_oof (stamping borrowed_from_run_id), narrowing or skipping family compute jobs when all cells are satisfied. Overridden when --force is passed. |
reuse_from_run_id |
str | null |
null |
— | Optional explicit donor run_id to restrict reuse_prior_runs borrowing to (null searches the most recent matching COMPLETED run per (model_type, model_spec_hash)). |
use_gpu |
bool |
false |
— | Legacy. Fleet-wide "put the deep-learning family on GPU", on every runtime — Ray, Vertex, GCE, Serverless and a Dataproc cluster alike. Prefer families.deep_learning.hardware; see below. |
gpu_type |
"T4" | "L4" | "A100" | "A100_80GB" |
"T4" |
— | Accelerator type when use_gpu=true or hardware="gpu" (prefer families.<f>.gpu_type). |
gpu_fraction |
"auto" | float |
"auto" |
float ∈ (0, 1] |
"auto" = profile-driven fractional GPU, else a fixed fraction. |
budget_usd |
float |
50.0 |
≥ 0.0 |
Cost guardrail (USD). |
ray_cluster_name |
str | null |
null |
— | Reuse a standing Ray cluster by name; null = ephemeral. |
ray_mode |
"vertex" | "gke" |
"vertex" |
— | Execution backend when runtime="ray": "vertex" runs on Vertex AI Managed Ray; "gke" routes the Ray cluster onto Google Kubernetes Engine (gke_mode="ray"). |
gke_mode |
"job" | "ray" |
"job" |
— | Execution mode when runtime="gke": "job" launches a Kubernetes batch/v1 Indexed Job (vertex_engine.py); "ray" launches a Ray-on-GKE cluster (ray_engine.py). |
gke_cluster_name |
str | null |
null |
— | Reuse a standing GKE cluster by name (or SF_GKE_CLUSTER); null provisions an ephemeral GKE cluster per run/hardware kind and deletes it on exit. |
gke_namespace |
str |
"default" |
— | Kubernetes namespace for GKE Indexed Jobs and Ray-on-GKE workloads (overridable via SF_GKE_NAMESPACE). |
automl_mode |
"tabular_workflow" | "training_job" |
"tabular_workflow" |
— | Execution mode when runtime="vertex_automl": "tabular_workflow" launches the glass-box Kubeflow Pipeline (google-cloud-pipeline-components) with Stage-1/Stage-2 tuning reuse; "training_job" launches a managed AutoMLForecastingTrainingJob (vertex_l2l only). |
ray_regions |
list[str] | null |
null |
— | Priority-ordered candidate regions for the ephemeral cluster. |
ray_head_machine_type |
str |
"n1-standard-16" |
— | Head-node type (don't drop below, or job submit hangs). |
ray_cpu_machine_type |
str |
"n1-standard-8" |
— | CPU worker-pool machine type (overridden when machine_type is explicitly set). |
ray_gpu_machine_type |
str |
"n1-standard-8" |
— | GPU worker-pool machine type (overridden when machine_type is explicitly set; auto-derived from gpu_type when left at default). |
accelerator_count |
int |
1 |
T4 ∈ {1,2,4}; L4 ∈ {1,2,4,8}; A100 ∈ {1,2,4,8,16}; A100_80GB ∈ {1,2,4,8} |
GPUs per GPU worker node across ray, vertex, gce, and gke. |
ray_target_cells_per_slot |
int |
8 |
> 0 |
Cells one worker slot chews before a node is added. |
ray_max_nodes |
int |
16 |
> 0 |
Shared per-pool ceiling; the fallback when a pool's own max (max_workers or ray_*_max_nodes) is unset. |
ray_autoscale |
bool |
true |
— | Autoscale each worker pool between its min/max (default). false restores fixed-size sizing (the derived node_count, no autoscaling spec). |
ray_cpu_min_nodes |
int |
1 |
> 0 |
CPU pool floor (overridden when min_workers is set). Low = shrink when the queue drains. |
ray_cpu_max_nodes |
int | null |
null |
> 0 |
CPU pool ceiling (overridden when max_workers is set); null falls back to ray_max_nodes. Raise to grow under load. |
ray_gpu_min_nodes |
int |
1 |
> 0 |
GPU pool floor (overridden when min_workers is set). Low = shrink idle accelerators. |
ray_gpu_max_nodes |
int | null |
null |
> 0 |
GPU pool ceiling (overridden when max_workers is set); null falls back to ray_max_nodes. Cap independently for cost. |
gpu_calibration_samples |
int |
3 |
> 0 |
Series to profile for auto gpu_fraction. |
gpu_safety_margin |
float |
1.3 |
> 1.0 |
Headroom multiplier on measured peak GPU memory. |
ray_read_mode |
"driver_collect" | "ray_data" |
"driver_collect" |
— | Ray source reader: the proven Storage Read client, or ray.data.read_bigquery (same Storage Read API, opt-in). |
read_max_streams |
int |
0 |
≥ 0 |
Max Storage Read streams for the source read, shared by the Spark connector (maxParallelism), Ray's driver_collect reader (max_stream_count), and vertex_engine (max_stream_count). 0 lets the server size it from the table; a positive value caps read parallelism (e.g. to fit a slot/quota budget). Inert for the ray_data path and BigQuery-native models. See reading_source_data.md. |
machine_type |
str |
"auto" |
validated against (hardware, gpu_type, accelerator_count) |
Unified GCE machine type across Vertex AI CustomJob (runtime="vertex"), Compute Engine (runtime="gce"), Google Kubernetes Engine (runtime="gke"), and Ray (runtime="ray"). "auto" resolves to "n2-standard-8" on vertex/gce/gke CPU (or ray_*_machine_type on ray), "n1-standard-8" / "n1-standard-16" on T4, "g2-standard-{8,24,48,96}" on L4, "a2-highgpu-{1,2,4,8}g" / "a2-megagpu-16g" on A100, and "a2-ultragpu-{1,2,4,8}g" on A100_80GB. Rejected on spark serverless (which is vCPU/RAM shaped). (Legacy vertex_machine_type and vertex_gpu_machine_type remain accepted aliases.) |
workers |
int |
1 |
≥ 1 (1 on gce) |
Unified fixed worker/replica count across runtimes: number of worker VMs/Pods for a Vertex AI CustomJob (runtime="vertex") or GKE Indexed Job / Ray-on-GKE (runtime="gke") family (1 = single dedicated VM/Pod; > 1 = multi-worker pool or Indexed Job with completions=workers and parallelism=workers, sharding local cells via contiguous BigQuery Storage Read API row_restriction or sharding models deterministically by worker rank; automatically expanded to max(workers, len(models)) for deep_learning / global models so each gets 1 dedicated VM/Pod; must be 1 when runtime="gce"), or fallback worker ceiling on ray and spark when max_workers is unset. (Legacy vertex_workers remains an accepted alias.) |
min_workers |
int | null |
null |
> 0, ≤ max_workers |
Unified autoscaling floor for runtime="ray" (overrides ray_cpu_min_nodes / ray_gpu_min_nodes) and runtime="spark". Rejected on runtime="vertex", runtime="gce", and runtime="gke", which use fixed workers. |
max_workers |
int | null |
null |
> 0, ≥ min_workers |
Unified autoscaling ceiling for runtime="ray" (overrides ray_cpu_max_nodes / ray_gpu_max_nodes) and runtime="spark" (overrides max_executors). Rejected on runtime="vertex", runtime="gce", and runtime="gke", which use fixed workers. |
Note on GCE VM hypervisor TTL: Single-VM Compute Engine jobs (
runtime="gce") enforce a hard hypervisor-level TTL (scheduling.maxRunDurationwithinstanceTerminationAction="DELETE") via theSF_GCE_MAX_RUN_DURATION_Senvironment variable (default21600seconds / 6 hours, minimum300seconds). LikeSF_CLUSTER_MAX_AGE, it is an operational environment variable rather than aRunConfigfield so adjusting it does not changerun_id.
machine_family — one knob, three deliberate boundaries. It selects the GCE family for a
Dataproc cluster's master and CPU workers (worker_machine_type / master_machine_type), and
"auto" resolves to n1 — today's shipped shape, so an existing config renders an identical
cluster. What it does not do is as important:
- It does not pick a size. Cores are fixed (master 4, workers 8) because the profiler derives the executor shape from the machine; letting you set both gives two knobs that can disagree.
- It does not reach GPU workers. The accelerator dictates the machine — a T4 is an add-on card
that only attaches to
n1, an L4 is bundled insideg2, and A100s requirea2— so a family override there would ask GCE for a shape it does not sell. A run spanning both hardware kinds gets its CPU workers on your family and its GPU workers on the accelerator's. - It does nothing on Serverless, Ray, Vertex CustomJob, GCE, or GKE. Serverless has no machine concept at all (its shape is
executor cores/memory properties); the Ray pools, Vertex CustomJob, GCE, and GKE have their own explicit
machine_type(andray_*_machine_type) knobs.
The offered families are exactly those resources can price (_MEMORY_PER_CORE_GIB), so the
sizing plan stays honest for whichever you pick — choosing n2 moves the worker from 30 GiB to
32 GiB and the executor split follows. An unlisted family is rejected at config load rather than at
cluster create.
Autoscaling (default): each Ray worker pool scales between its own [min, max]; a null pool
max resolves to max_workers (or ray_*_max_nodes / ray_max_nodes). Config validation requires min ≤ resolved max per pool. The
initial pool size stays a deterministic function of the config (fan-out ÷
ray_target_cells_per_slot, clamped into the bounds), so the run remains reproducible — the whole
spec is hashed into run_id and stamped to run_registry.job_telemetry. Set ray_autoscale=false
to opt out and get a fixed-size pool instead (no autoscaling spec at all) — worth reaching for when
you want a cluster whose cost is flat and predictable rather than demand-shaped. See the Ray runtime
in architecture.md.
compute.capacity — how hard to look for room¶
"Resources are not available" is a state, not an exception. When a create fails for want of
machines the launcher walks its candidate places (Ray: ray_regions; Vertex CustomJob: candidate regions; GCE / GKE / Dataproc cluster: the
zone/region fallback map; Serverless: the region), and if none has room it backs off and walks them
again until a budget runs out. While it waits the family's run_jobs row reads AWAITING_CAPACITY
and carries a ledger of every attempt; on exhaustion the row is FAILED with
failure_reason = CAPACITY_EXHAUSTED. See
troubleshooting.md for reading it.
{"enabled": false} (top level) collapses every service to a single pass with no back-off — the
pre-retry behaviour exactly. Otherwise each service takes a partial override: name only the
number you have an opinion about and inherit the rest.
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
enabled |
bool |
true |
— | false = one pass over the candidates, no back-off, all services. Beats an authored max_passes. |
preflight |
bool |
true |
— | Read each candidate region's quota before the first create, and skip or clamp accordingly. See below. |
ray | dataproc_cluster | dataproc_serverless | vertex | gce | gke |
object |
{} |
— | Per-service partial override; unset fields inherit the shipped default below. |
retry |
object |
{} |
— | How wide a repair runs — not a capacity policy, but the same digest-excluded surface. See below. |
preflight is the cheap half of the same problem: retrying is for a region that is temporarily
full, and a preflight is for one that was never going to work. It reads the region's allowance,
drops a region that cannot host even the minimum (recorded as a hard ceiling, no create attempted),
and lowers this run's pool ceilings — or, on the Dataproc-cluster path, its physical worker count —
to what a smaller region will grant rather than failing there. It applies to Ray on Vertex, Vertex AI CustomJob, Compute Engine (gce), Google Kubernetes Engine (gke), and to
ephemeral Dataproc clusters, each read against its own service's meters. It only ever lowers, and
it never touches run_id. --quota prints the same report without
launching — see Quota and scale. Set it false
only if the runner service account lacks serviceusage.services.get; the check degrades to silence
on any read failure, so a missing permission costs you the diagnostic, not the run.
Per-service fields, all optional, 0 disables that bound:
| Field | Type | ray |
dataproc_cluster |
dataproc_serverless |
vertex |
gce |
gke |
Purpose |
|---|---|---|---|---|---|---|---|---|
max_attempts |
int ≥ 0 |
6 |
8 |
10 |
8 |
8 |
8 |
Total attempts across all candidates. |
max_wall_seconds |
float ≥ 0 |
3600 |
2700 |
1800 |
1800 |
1800 |
2700 |
Clock budget for the whole walk. |
max_passes |
int ≥ 0 |
unbounded | unbounded | unbounded | unbounded | unbounded | unbounded | Full sweeps of the candidate list. |
backoff_seconds |
float ≥ 0 |
120 |
60 |
30 |
60 |
60 |
60 |
First wait after a fruitless pass. |
backoff_multiplier |
float ≥ 1.0 |
2.0 |
2.0 |
2.0 |
2.0 |
2.0 |
2.0 |
Growth per pass. |
backoff_max_seconds |
float ≥ 0 |
600 |
300 |
120 |
300 |
300 |
300 |
Cap on the wait. |
The services differ because provisioning latencies do. A Vertex Ray GPU provision costs ~12 minutes per attempt,
so it gets the fewest tries and the longest patience; a Serverless batch or Vertex CustomJob is rejected much faster, so
retrying is cheap and the clock is the bound that matters.
BigQuery has no entry, deliberately. Slot contention is resolved BigQuery-side and surfaces as latency, not as a create that failed somewhere else it could be tried. There is no candidate list to walk.
None of this moves the run_id. Patience is an operational knob, not part of what was asked
for — same rule as compute.profile.source. If it hashed into the digest, waiting longer for a GPU
would fork your run identity and break dedupe-on-read.
"compute": {
"capacity": {
"ray": {"max_wall_seconds": 7200},
"dataproc_serverless": {"max_attempts": 20}
}
}
compute.capacity.retry — how wide a repair runs¶
A repair (--retry, Forecaster.retry, or the emitted DAG's retry node) re-submits a handful of
cells out of a run that was sized for all of them. The fleet arithmetic does not know that: it sizes
from the config's fan-out, so a forty-cell repair of a hundred-thousand-cell family asks for the
hundred-thousand-cell fleet and pays for it.
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
max_executors |
int | null |
null |
> 0 |
Ceiling on the repair's Spark batch (spark.dynamicAllocation.maxExecutors). null = size from the fan-out, as a first attempt does. |
It is here rather than beside compute.max_executors because a repair that resized itself must stay
the same run: everything under compute.capacity is excluded from the run_id digest, and a
forked id would mean the repair wrote its rows under a run nobody is looking at.
It is in the config rather than only on --max-executors because the unattended repair paths launch
from a config and nothing else — a ceiling reachable only through a flag is one an orchestrated
repair can never set. An explicit --max-executors still wins; the operator passing it is looking at
the run, and the config was written before the run failed.
Two limits worth knowing. It caps a Spark batch and is ignored by the Ray and in-process paths,
so a Ray family's repair still provisions from the fan-out. And it is the only resource a repair can
vary: a repair reuses the config its run staged, so anything the worker reads out of that config
(bucket_target_cells, max_parallelism, the model list, the data block) is the same bytes for the
repair as for the attempt, by construction.
compute.profile — measured compute profiling¶
Sizing is otherwise a pure cell count, divided by a flat cells-per-slot constant. The count is
and folds are not in it — a cell is one model fitted to one series, and a backtested cell is still one cell; it just does more fits inside itself. What folds do change is the work per cell:
— the + 1 being the final fit on all of the history, which is the forecast you actually ship.
That is the same arithmetic quota and scale plans a run
with. estimate_workload goes one step further when it can see series lengths: the fold fits train
on shorter windows than the final one, so it also reports full_fit_equivalents, which is what a
backtested cell costs relative to the same cell with backtesting off. On four years of daily history
with a 28-day horizon that comes out at 2.94 at two folds and 3.862 at three, against the
3.0 and 4.0 that n_folds + 1 would imply. Run --feasibility to see it for your own panel.
None of that arithmetic can know that a deep-learning fit and a naive mean differ by orders of
magnitude, so the fleet is provisioned for the count rather than for the work. compute.profile is
the machinery for replacing that guess with a measurement.
Think of it as the general form of gpu_calibration_samples / gpu_safety_margin above, which
already do exactly this for one axis (GPU bytes), one model, one runtime.
How compute.profile works¶
Two things are true at once:
Fleet shaping is on, everywhere, and it does not need a measurement. Every Spark job carries
a derived properties overlay: executor cores, heap and memoryOverhead, the dynamic-allocation band,
spark.task.cpus bounded by the accelerator, matching thread pins, and — on a Dataproc cluster — a
worker count derived from the run's fan-out. That arithmetic runs from the config alone. Setting
mode = "off" is what turns it off, and that is the escape hatch to reach for if a fleet shape ever
misbehaves in production.
Measuring inside the run that needs it does not work on Spark, and structurally cannot.
spark.executor.cores and spark.task.cpus are fixed at submit (Serverless) or at create (cluster),
before any of our code reaches the cluster; and the submit host is deliberately lean, carrying no
model stack to fit with. A same-run pre-pass has nowhere to run that is both early enough to matter
and equipped to measure anything. Only the Ray engine can do it — it requests per-task num_cpus /
num_gpus in-run against an autoscaling pool — and with the defaults below (mode = "auto",
min_cells = 1000) it does not fire for a small run. So on Spark, "auto" and "always" size
identically today; only "off" changes anything.
So measurement is decoupled from consumption: one run produces the evidence, a later run is sized
from it. That is what measure is. Every cell of an ordinary run records what it cost — three
cheap probes around a fit that was happening anyway — onto forecast_metadata. A completed run_id
is therefore a measured cost model you can point a bigger run at. It is on by default, because the
run you wish you had measured is always the one you already finished.
Three knobs, three questions. mode is the master switch — whether the fleet arithmetic runs at
all, and the threshold the in-run Ray pre-pass compares against. measure decides what evidence this
run produces. source decides what evidence it consumes. They are orthogonal because the
questions are: a run can harvest without consuming (the first one ever), consume without harvesting
(a one-off resize), or do both (the ordinary case, and the default). mode = "off" vetoes all of it,
so there is still exactly one switch that makes the whole feature inert.
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
mode |
"off" | "auto" | "always" |
"auto" |
— | off disables both the derived fleet overlay and any measurement — the escape hatch, and the only value that changes a Spark run today. auto measures when the cell count reaches min_cells; always measures unconditionally. Both currently affect the Ray path only. |
samples |
int |
8 |
> 0 |
Series fitted in the pre-pass, spread across length/complexity strata. |
min_cells |
int |
1000 |
> 0 |
The threshold auto compares the cell count against. |
memory_margin |
float |
1.3 |
> 1.0 |
Headroom on the measured max, which sizes the slot. |
time_margin |
float |
1.2 |
> 1.0 |
Headroom on the measured median, which sizes the fleet. |
measure |
"off" | "harvest" | "controlled" |
"harvest" |
— | What evidence this run produces. See the table below. |
source |
"auto" | "baseline" | "none" | "<run_id>" |
"auto" |
keyword or a well-formed run_id | What evidence this run consumes. See the precedence chain below. |
measure |
What the run does | Use it when |
|---|---|---|
"harvest" |
Records per-cell CPU time, absolute process memory, peak device bytes, the thread cap and n_obs onto forecast_metadata. Changes nothing about how the run executes. |
Always — this is the default. |
"controlled" |
Harvest, plus the Spark fleet leaves the native thread pools uncapped so a fit's measured effective_cores reflects the library instead of the cap. |
A deliberate calibration run, and never a production one: the executors are knowingly oversubscribed, so the run is slower and its shape is not a real run's shape. The translation carries a note saying so. |
"off" |
Writes NULL in all five columns. | You have a reason to skip three probes per fit. |
source |
Where the numbers come from | Provenance basis |
|---|---|---|
"auto" |
The best-matched completed run whose harvest matches this run's runtime and data signature; failing that, the shipped baseline; failing that, static config. Resolved at plan time and written into the staged config as a concrete run_id, so the run records what it actually sized from rather than a search that might resolve differently tomorrow. |
measured / reference |
"<run_id>" |
That run's harvest, whatever its signature. Naming a run is a decision, so it is honoured — a drifted signature comes back as a warning, not a substitution. | measured / reference |
"baseline" |
The shipped, versioned reference measurements — see below. Real numbers, taken on reference hardware and reference data, never on yours. | reference |
"none" |
Nothing is consulted; size from declared config. | — |
The precedence, outside-in: explicit compute settings > compute.profile.source > shipped
baseline > static config. Anything you set by hand always wins; the evidence only fills what you
left to be derived, and the static floor is the behaviour this product shipped with.
Every resolved profile carries a provenance block naming its basis, the run_id and timestamp
behind it, the data signature it was measured on, and any drift warnings. measured means the
evidence matches this run's signature on every axis both sides can see; reference means measured,
but not on your data. That third value exists because a pinned profile from months ago on a
different table is worse than no profile at all — precisely because it looks authoritative. A
mismatch is never silent, and never fatal: sizing off drifted evidence still beats sizing off none.
If BigQuery is unreachable when the source is resolved, the run logs a warning and falls through the rest of the chain — which today means the shipped baseline. Evidence is an optimisation; a registry hiccup must not stop a run from submitting.
What ships in the baseline. It was cut from a real 100,000-series run on Ray (ray_100k,
recorded in validation.md): daily series of 1,460 observations, four models across
the statistical and ml families. It therefore sizes those two families and not
deep_learning, and it carries no GPU bound — that run had neither. An unmeasured family resolves
to nothing rather than to a guess, so a run with a deep-learning family gets the static arithmetic
for it and measured numbers for the rest. The number it is really there for is
slot_cores: 1: every one of those fits measured single-threaded, and that is a property of the
libraries rather than of your panel, so it transfers in a way a memory bound does not.
What the resolved profile actually changes. Only the memory axis. The executor cores, the thread
pins, the warm initialExecutors, the device-aware spark.task.cpus and the worker/executor counts
all follow from the fan-out and the machine type alone and are emitted with or without evidence.
Memory is the one thing that cannot be derived — a Serverless executor's shape is fixed at submit
and a cluster's at create, both before any of our code runs — so with no profile the memory
properties are simply not emitted and the platform's defaults stand, exactly as before.
Reading back what a run decided. The whole decision is stamped into the run header's
job_telemetry, one entry per family job under sizing.<family>, and surfaced by v_run_summary
as its sizing column: the fleet the arithmetic asked for, what that became in platform settings,
and the profile it was sized off (with the provenance naming whose run supplied the evidence). So
"why was this run this shape" is a query against the registry rather than a hunt through driver
logs — see output_schemas.md.
Why the two margins differ, and why they apply to different tails. Over-estimating time buys extra slots, which costs money; under-estimating memory OOM-kills the task, which costs the run. Asymmetric risk, asymmetric margin — so memory carries the wider one. They also attach to different statistics on purpose: a slot must hold the worst series that lands in it (max), while a fleet is sized for typical work (median). Sizing the fleet off the worst case over-provisions every run; sizing memory off the median OOM-kills it. Using one tail for both is the mistake the split exists to prevent.
compute.profile (except compute.profile.source, which is resolved provenance) is part of the run_id digest, like everything else under compute. See System Validation Ledger for live runs across Spark, Dataproc clusters, Vertex AI Ray, Vertex CustomJob, GCE, and GKE using this sizing overlay.
compute.families — per-family runtime & hardware¶
By default every Python family runs on the run-level python_runtime on CPU. compute.families maps
a family name to a FamilyCompute that overrides that placement for that family only — the lever
behind "one run, a job per family, each on its own runtime". Every FamilyCompute field is optional;
an unset field inherits the run-level default. The native family is never listed here (it always
runs in BigQuery).
This block is canonical; the flat compute.use_gpu / compute.gpu_type pair is legacy. Both
still work and neither is deprecated, but they are the coarse version of what families says
precisely. use_gpu is read by RunConfig.resolve_family_compute, the single resolver every
runtime goes through, so it puts the deep_learning family on an accelerator on Ray, Vertex AI CustomJob, GCE, GKE, Dataproc
Serverless, and a Dataproc cluster alike — an L4 on Serverless, a T4 (or L4) on Vertex CustomJob / GCE / GKE, and a T4 on the other two. It never
touches any other family: hardware is "cpu" for statistical and ml no matter what the flag
says.
Resolution is a single layering, and the per-family value always wins:
| Asked for | Resolves to |
|---|---|
families.deep_learning.hardware set |
that value — use_gpu is ignored for this family |
unset, use_gpu: true |
"gpu" for deep_learning, "cpu" for everything else |
unset, use_gpu: false (default) |
"cpu" |
gpu_type unset on a GPU family |
compute.gpu_type — except on Serverless, which is forced to L4 and raises if a T4 was named |
So use_gpu: true with families.deep_learning.hardware: "cpu" is not a contradiction the product
has to guess at: the family runs on CPU and no accelerator is provisioned for it at all. That is
smoke 20's whole job, and it is the reason the two settings can coexist safely. Prefer the
per-family form in new configs — it says which family, which runtime and which device in one place,
and it is the form the plan-time report and the device audit speak.
Before reaching for hardware: "gpu" at all, read
what that setting guarantees.
It guarantees placement, not utilisation, and for neuralprophet at its shipped defaults the
measured answer is CPU.
| Field | Type | Options | Purpose |
|---|---|---|---|
runtime |
str |
"spark" | "ray" | "vertex" | "gce" | "gke" | "vertex_automl" |
Runtime for this family (overrides python_runtime). |
spark_mode |
str |
"serverless" | "cluster" |
Spark launch mode (Spark only). "cluster" runs on a Dataproc cluster — needed for a T4 or A100 GPU on Spark. |
spark_cluster_name |
str |
— | Reuse an existing Dataproc cluster by name (requires spark_mode="cluster"). |
gke_mode |
str |
"job" | "ray" |
Execution mode when runtime="gke": "job" runs a Kubernetes batch/v1 Indexed Job (vertex_engine.py); "ray" runs Ray-on-GKE (ray_engine.py). |
gke_cluster_name |
str |
— | Reuse an existing GKE cluster by name for this family (runtime="gke" or ray_mode="gke"). |
ray_mode |
str |
"vertex" | "gke" |
Execution backend when runtime="ray": "vertex" runs on Vertex AI Managed Ray; "gke" routes the Ray cluster onto GKE (gke_mode="ray"). |
automl_mode |
str |
"tabular_workflow" | "training_job" |
Execution mode when runtime="vertex_automl": "tabular_workflow" runs the glass-box Vertex AI Pipeline (vertex_l2l, vertex_tide, vertex_tft, vertex_seq2seq); "training_job" runs the managed SDK trainer for the same four models (AutoMLForecastingTrainingJob, TimeSeriesDenseEncoderForecastingTrainingJob, TemporalFusionTransformerForecastingTrainingJob, SequenceToSequencePlusForecastingTrainingJob). The training_job call shape is bound against the installed SDK by a unit test; only tabular_workflow has a live row in validation.md. |
hardware |
str |
"cpu" | "gpu" |
Hardware profile for this family (GPU only for deep_learning and automl). |
gpu_type |
str |
"T4" | "L4" | "A100" | "A100_80GB" |
GPU type when hardware="gpu". |
accelerator_count |
int |
≥ 1 |
Per-VM GPU count override for this family (T4 ∈ {1,2,4}; L4 ∈ {1,2,4,8}; A100 ∈ {1,2,4,8,16}; A100_80GB ∈ {1,2,4,8}). |
machine_type |
str |
"auto" or GCE machine type |
Per-family GCE machine type override across vertex, gce, gke, ray, and vertex_automl (rejected on spark serverless). Validated against (hardware, gpu_type, accelerator_count). |
workers |
int |
≥ 1 (1 on gce) |
Per-family fixed worker/replica count across vertex, gke, ray, spark, and vertex_automl (must be omitted or 1 on single-VM runtime="gce"). |
min_workers |
int |
≥ 1 |
Per-family autoscaling floor for runtime="ray" or runtime="spark" (rejected on vertex, gce, gke, and vertex_automl). |
max_workers |
int |
≥ 1 (≥ min_workers) |
Per-family autoscaling ceiling for runtime="ray" or runtime="spark" (rejected on vertex, gce, gke, and vertex_automl). |
Cross-field rules (enforced at config-load):
spark_mode/spark_cluster_nameare valid only whenruntime="spark";spark_cluster_namerequiresspark_mode="cluster".gke_modeis valid only whenruntime="gke".ray_modeis valid only whenruntime="ray".automl_modeis valid only whenruntime="vertex_automl".gke_cluster_namerequiresruntime="gke"orray_mode="gke".runtime="gce"is a single-VM runtime (workersmust be omitted or1).min_workers/max_workersare valid only on autoscaling runtimes (ray,spark);vertex,gce,gke, andvertex_automlrequire fixedworkers.machine_typeis rejected whenruntime="spark"andspark_mode="serverless".- A GPU (
hardware="gpu",gpu_type, oraccelerator_count) is allowed only for thedeep_learningandautomlfamilies. - Dataproc Serverless supports
L4only (T4,A100, andA100_80GBrequirespark_mode="cluster"orruntime in ("ray", "vertex", "gce", "gke", "vertex_automl")). hardware="cpu"withgpu_typeoraccelerator_countset → error (drop it or sethardware="gpu").
A GPU plan is checked again at plan time, before anything is provisioned. Two separate things happen there and the difference between them matters. A plan that would buy a device nothing can be scheduled onto is refused outright — that has one correct answer and the remedy is always a config edit, so there is no override for it. A plan where a device will be used but will barely be touched only warns, because that is a cost judgement rather than a mistake.
The warning is the one you are most likely to see, and it is worth reading rather than dismissing.
While tide, tft, tsmixer, and patchtst are deep PyTorch architectures (gpu_useful=True),
neuralprophet at its shipped defaults (n_lags unset) is a shallow network of a few hundred trend and Fourier parameters. Measured across 31,356
fits on live T4s: peak device memory 50–78 KB against a 17 GB card, and cpu_seconds / fit_seconds
between 0.93 and 0.996. The card is attached, the tensors are on it, and it is doing essentially
nothing — a CPU run being billed as a GPU run. Setting hardware: "cpu" costs nothing in accuracy
and runs faster at half the cost; turning on autoregression with model_params.neuralprophet.n_lags
increases network complexity when deep autoregressive lags are needed.
The same report fires in reverse: use_gpu: true with no deep-learning model selected means no job
resolves to GPU hardware at all, so the flag does nothing except make the config read as a GPU run.
You will see these lines on a dry run, on --quota, from the SDK's .dag, and in the submit log.
At runtime, hardware enforces the device contract. A cell selects its device outright from
two facts that have to agree: the job must have been provisioned onto GPU hardware, and this
family must resolve to hardware="gpu" in your config.
The first fact is not in the config, because a config cannot know it — the same file runs on a
workstation with no card and on a Vertex Ray cluster with eight. The submitter states it on the job
it creates, as a --provisioned-hardware gpu driver argument plus the matching Spark
executorEnv / Ray runtime_env entry so the executors and task workers hear it too. You will see
it in the emitted gcloud command for a GPU batch; nothing else in the command changes. There is
nothing to set: run locally, from the SDK, from a notebook, or on any CPU job and the argument is
simply absent, which selects auto behaviour.
Two consequences worth knowing. A family set to hardware="gpu" on a job that really was
provisioned for GPUs, but whose worker cannot see a CUDA device, fails that cell immediately
with a message naming the family and the field to change, instead of quietly fitting on the CPU for
the rest of the fleet-hour. And the driver-side fits — the sizing pre-pass (compute.profile) and
every HPO trial — stay on auto deliberately, because they run on a Ray head node or a Spark
driver, which has no accelerator.
How many clusters a run creates. One per hardware kind among its ephemeral cluster families,
not one per run — a Dataproc cluster has exactly one worker machine type, so it is a CPU cluster or a
GPU cluster and cannot be both. A run whose cluster families are all CPU gets one cluster named
sf-cluster-<run_id>; a run mixing CPU and GPU families gets sf-cluster-<run_id>-cpu and
sf-cluster-<run_id>-gpu, each sized only for the families that land on it. That is why a GPU
deep_learning family does not make the rest of the run pay for accelerators it never uses.
Ray is deliberately different: a Vertex Ray cluster carries separate CPU and GPU worker pools, so a mixed run shares one Ray cluster. The asymmetry is the hardware, not an inconsistency.
Cluster lifetime. A cluster the run creates (no spark_cluster_name) is deleted when the run
ends — every one of them, including on a failure part-way through creating the second. Each also
carries server-side bounds so it cannot outlive the orchestrator that made it: it
self-deletes after 30 min idle or 24 h total, whichever comes first. Those are backstops for
the case where the run process is killed before its teardown runs — when teardown works they never
fire. Override with SF_CLUSTER_IDLE_TTL / SF_CLUSTER_MAX_AGE (seconds; 0 disables a bound).
They are environment, not config, so changing them does not change your run_id.
A cluster named by spark_cluster_name gets neither — the run does not create it and does not own
when it ends, so reclaiming it is yours to do.
How long the launcher waits, and what happens when it stops. Separately from those server-side
bounds, the process that submitted the job blocks until the job is terminal. That wait is patience,
not a cost control — the launcher going away stops no meter — so it is set high, at 24 hours,
and is moved with SF_CLUSTER_JOB_WAIT_S (seconds). Environment, not config: it does not change
your run_id.
If the wait does run out, the launcher leaves the cluster running and tells you where it is.
That is deliberate. A wait expiring says only that the launcher stopped looking, and the job it
stopped looking at is very likely still fitting; deleting its cluster at that moment would be the
launcher destroying its own healthy run. Follow the job with --probe, and the idle and max-age
bounds above reclaim the cluster once it finishes or goes quiet.
A job that genuinely wedges is caught by a different mechanism, one that watches output instead of
the clock: a job that has written no forecast rows after SF_STALL_GRACE_S (45 minutes by default,
0 to switch it off) is cancelled. A single written row is proof of life and stands the watch down
for the rest of the run. Both Dataproc Serverless batches and Dataproc cluster jobs are guarded by
this watchdog.
Serverless has the same patience setting for the same reason, as SF_BATCH_JOB_WAIT_S (also 24
hours, also overridable per submit with --wait-timeout).
"compute": {
"families": {
"deep_learning": { "runtime": "ray", "hardware": "gpu", "gpu_type": "T4" }
}
}
That routes the deep-learning family (e.g. neuralprophet) to Ray-on-GPU while the statistical and ml
families stay on the default Spark runtime and the native family runs in BigQuery — four families,
three runtimes, one run_id. See configs/per_family_runtimes_demo.json.
If you are copying one of these, copy the CPU one.
configs/per_family_runtimes_cpu_demo.json
is the same four-family, three-runtime split with "hardware": "cpu" on the deep-learning family —
the two files differ in that one word, so they read as a diff. The CPU one is the better starting
point: on both accelerator A/B runs it finished faster than its GPU twin at identical accuracy and
roughly half the cost. See what hardware: "gpu" actually buys you.
compute.ensemble — when the consensus is computed¶
compute.families says where each base family runs. compute.ensemble says when the node that
blends their predictions runs. It is a separate block because it answers a separate question, and it
is not the same thing as the top-level ensemble (EnsembleConfig, above), which picks which
blending strategies to compute.
| Field | Type | Default | Constraint | Purpose |
|---|---|---|---|---|
mode |
"barrier" | "microbatch" |
"barrier" |
— | Blend once after every family finishes, or drain series as they become ready. |
microbatch_interval_s |
float |
60.0 |
> 0 |
Seconds between readiness polls in microbatch mode. Inert in barrier. |
Those are the only two fields, and there is no choice of where the ensemble runs. The node executes via BigQuery plus driver-side pandas — it reads predictions, blends them, and writes rows through the Storage Write API, requiring no separate Spark or Ray cluster of its own.
The two modes differ in when, not in what. Both run on the driver, both write the same
ensemble_<strategy> prediction and leaderboard rows, and both honour the rule that a series is
blended only once every configured base model has landed for it — so a family that fails leaves no
series ready and produces no ensembles either way.
barrier waits for the whole fan-out, then blends in one pass. It is the default because it is the
simpler thing to reason about and it costs nothing when the families finish at similar times.
microbatch starts the ensemble node alongside the families and drains series incrementally,
polling every microbatch_interval_s. It is worth setting when one family is much slower than the
rest, because the blending work then happens during that family's tail instead of queueing behind
it. The shape is visible in a real task graph: in smoke 15 on Airflow (2026-09-20) the ensemble
task started in the same second as the four family tasks, ran 2,879 s beside them, and ended 20 s
after the last member finished.
A shorter interval reaps series sooner and issues more BigQuery readiness queries; 60 s is a deliberate middle for runs measured in tens of minutes. Below a few seconds you are paying query cost to discover nothing has changed.
A minimal config¶
{
"run_name": "my first run",
"python_runtime": "spark",
"data": { "source_table": "source_series_iceberg", "horizon": 28, "series_limit": 100 },
"models": ["theta", "holtwinters", "arima_plus"],
"features": { "holidays": ["US"] }
}
theta/holtwinters run on Spark; arima_plus runs in BigQuery — both under one run_id, in
parallel. See configs/ for worked examples (demo and 100k),
running_and_reviewing.md to submit and review one, and
output_schemas.md for the tables the run writes to (the whole config lands
verbatim in run_registry.raw_config).