Configuration¶
config
¶
Load, validate, and freeze the run config — the single source of run behavior.
A run is one JSON file. It is validated here before anything executes,
and the frozen, normalized object is what gets logged verbatim to
run_registry.raw_config — so the config is the experiment record.
Public surface:
- RunConfig — the frozen pydantic model.
- load_config(path) -> RunConfig — read + validate a JSON file.
- estimate_workload(cfg, obs_counts=...) -> Workload — the dry-run work estimate: cells always,
fits and fold cohorts when the caller supplies per-series observation counts.
- estimate_fanout(cfg) -> Fanout — the same estimate narrowed to its four count fields.
BacktestConfig
¶
Bases: BaseModel
Time-series cross-validation. Off by default (cheapest first run).
Every field here is now honoured. The five that arrived ahead of the code that reads them —
short_series, min_folds, min_train_floor, gap and window — were declared in
one commit because run_id is a digest of the whole config: adding a field moves every
identity ever recorded, so landing them together cost one identity break instead of five.
test_declared_ahead_fields.py holds the other half of that bargain: each field reaches
the digest.
short_series decides what happens to a series too short for the requested fold grid, and
every branch of it is a different trade rather than a different amount of the same thing.
adapt (the default) holds the geometry exactly and drops the oldest folds, so a short
series is scored on the most recent window it can reach. overlap holds the fold count and
shrinks the step to buy it, which means validation windows share observations and the per-fold
scores stop being independent evidence. shrink_train holds the count and the step and lowers
the training requirement instead, never past min_train_floor — which is why that field is
required with this mode rather than optional. skip leaves any series short of the full grid
unscored, so every series on the leaderboard was measured identically. error refuses the
run outright; it is checked before any cell runs and never raises inside one, because a scoring
shortfall must never cost a forecast. min_folds is the give-up floor the first three
respect: a series that cannot reach it is left unscored rather than weakly scored.
gap and window are also honoured. gap is an embargo: training stops gap
observations before the validation window starts, so train_end + gap == val_start and the
fold measures a forecast issued with a reporting lag. It moves the training end, never the
validation window — the folds of a gap=14 run cover the same dates as the folds of a
gap=0 run, so the two are comparable — and it costs history, so a series may achieve fewer
folds under it. window is the sliding scheme's training width, defaulting to
min_train, which frees min_train to mean only the feasibility floor. Both are mirrored
into the BigQuery-native fold SQL, so the two engines score the same windows.
scheme is the one that is fully honoured, and it decides what a fold's score is a score
of. expanding and sliding refit at every origin, so a score is about a freshly-trained
model. expanding_frozen fits once and then feeds the model the observations that arrive
between origins with its parameters held fixed, which measures what refitting less often costs.
expanding_stale fits once and never tells the model what happened next, which measures how
fast it decays untouched — the only one of the three every model can answer identically, and so
the one where a cross-model leaderboard is comparing like with like. Widening this Literal moves
no existing run_id — the digest hashes dumped values, not the schema.
control_arm is what lets a refit scheme answer the frozen schemes' question without becoming
one. Both frozen schemes score a blind arm — one fit, walked forward untouched — beside their
primary arm, and expanding and sliding did not, so the run most people actually make
could not say what refitting was buying it. The arm costs one extra fit per cell, not per
fold, plus a forecast, which is why it is affordable on the default path. On expanding_stale
it is refused rather than ignored: that scheme's primary arm already is the blind arm.
See backtest.BacktestOutcome for what the two arms produce.
One asymmetry worth knowing: short_series is a Python-path policy. The BigQuery-native
models count their folds back from one global MAX(ds) rather than from each series' own last
observation, so they have no per-series grid to adapt; a short series there simply contributes
fewer scored rows. error is the exception, because it is checked against the panel before
either engine starts.
CapacityConfig
¶
Bases: BaseModel
How hard to look for room when a service says it has none — per service (G2).
"Resources are not available" is a state, not an exception: a run walks its candidate
places, and if none has room it waits and walks them again, until an attempt budget or a clock
runs out. scale_forecasting.capacity implements that; this is where a run tunes it.
Not part of the run_id digest — excluded in registry.ids._NOT_IDENTITY, and there is a
test that says so. Same rule as compute.profile.source, for the same reason: a run's
identity is what was asked for, and patience is an operational knob. If it moved the digest,
waiting longer for a GPU would fork your run id and break dedupe-on-read.
enabled: false restores the pre-retry behaviour exactly — one pass over the candidates, no
back-off — which is the escape hatch if a retry loop ever misbehaves in production. It does not
disable classification: a config fault still stops immediately and a quota ceiling is still
named as one in the ledger, because those were improvements to the diagnosis, not to the
patience.
BigQuery has no entry and that omission is deliberate (capacity.UNMANAGED_SERVICES): slot
contention is resolved BigQuery-side and surfaces as latency, not as a create that failed
somewhere and could be retried elsewhere. There is no candidate list to walk.
policy_for
¶
Resolve this config into the runtime policy for service (pure).
The shipped per-service default with any authored field laid over it, plus the
enabled: false collapse to a single pass. Raises KeyError for a service with no
candidate walk (BigQuery), because asking for its retry policy is a programming error rather
than a configuration one.
Source code in src/scale_forecasting/config.py
CapacityServicePolicy
¶
Bases: BaseModel
A partial override of one service's shipped retry policy — every field optional.
Partial on purpose. A user who wants to wait longer for a GPU should be able to say only
{"max_wall_seconds": 7200} and inherit the rest; requiring the whole policy would mean
copying four numbers they have no opinion about and silently freezing them against future
default changes. Unset fields fall through to capacity.DEFAULT_POLICIES[service].
Bounds are validated here as well as in capacity.CapacityPolicy, so a bad config fails at load
with a pydantic error naming the field rather than at provisioning time with a ValueError.
ComputeConfig
¶
Bases: BaseModel
Runtime scale, dependency delivery, and cost guardrails.
DataConfig
¶
Bases: BaseModel
Where the series come from and their shape.
source_table and the three column names are written into BigQuery SQL inside backticks
(SELECT `{ts_id_col}` ... FROM `{source_table}```) by the sizing probes, the SDK, and the
native engines. They are validated here, once, at config-load time rather than at each of
those sites: a column must be a BigQuery column name (letter or underscore, then letters,
digits, or underscores, at most 300 characters) and a table path may betable,dataset.table, orproject.dataset.table(project ids may carry hyphens) — but neither
may contain a backtick, whitespace, or a control character, which are the characters that could
end the quoted identifier early. Validation adds no field, so it moves norun_id``.
EnsembleCompute
¶
Bases: BaseModel
When the ensemble DAG node runs. Not where — there is only one where.
Distinct from EnsembleConfig, which selects the ensemble strategies. mode="barrier"
ensembles once after every base model finishes; mode="microbatch" ensembles each series as
soon as its upstream base models complete, so the ensemble overlaps the families instead of
queueing behind the slowest one.
Both modes are live. The microbatch shape was measured on Airflow in smoke 15 (2026-09-20): the ensemble task started in the same second as the four family tasks, ran 2,879 s alongside them, and finished 20 s after the last member — which is not a shape barrier mode can produce.
This model used to carry runtime, spark_mode and spark_cluster_name too. They
were accepted by the loader, documented as inert, and read by nothing: the ensemble node is
hard-wired to the driver (dag.build_dag_nodes stamps it runtime="bigquery", and
job_launch.run_ensemble blends in driver pandas, taking no cluster of its own). Because they
were still in the config digest, setting one started a new run and changed nothing about it.
They are gone rather than documented-as-inert so the surface stops offering a choice that does
not exist. Setting one now fails at load, which beats quietly starting a differently-keyed run
that behaves identically.
No run_id moved. Identity hashes model_dump(), defaults included, so dropping three
defaulted fields would ordinarily re-key every config in existence and stale the whole
validation ledger to buy a tidier surface. registry.ids._REMOVED_DEFAULTS pins the three
keys back into the digest at the values they used to carry, which is legitimate here precisely
because nothing ever read them: no id on record ever meant anything different from what it
means now.
EnsembleConfig
¶
Bases: BaseModel
Consensus across base models.
strategies is a list so several ensembles can run at once. The singular
strategy string is accepted as shorthand for a one-element list.
FamilyCompute
¶
Bases: BaseModel
A sparse per-family compute override, layered over the flat ComputeConfig defaults.
Every field is optional: an unset field inherits the run-level default (python_runtime,
Spark serverless, CPU, the flat gpu_type), so a config sets only what a family needs to
differ on. There is no native family here — native models always run in BigQuery. See
RunConfig.resolve_family_compute for how these layer onto the defaults; the block is inert
until the DAG orchestrator consumes it.
Hardware constraints (validated): only the deep_learning family may request a GPU (enforced
where the family key is known, in ComputeConfig); Dataproc Serverless offers L4 only (no
T4/A100 — use spark_mode="cluster" or runtime="ray"/"vertex"/"gce"); Spark-only
fields (spark_mode/spark_cluster_name) are rejected on runtime in ("ray", "vertex",
"gce"); VM fields (machine_type/workers) are rejected on runtime in ("spark",
"ray"); and runtime="gce" is strictly single-VM (workers cannot exceed 1).
Fanout
dataclass
¶
The count half of a Workload, kept for callers that predate it.
estimate_fanout is still in the public __init__ and still returns this, so nothing
downstream had to move. One number did change meaning: n_cells is now n_series ×
n_models, matching every other n_cells in the system (the leaderboard's, the Ray
planner's, the quota estimator's). It used to multiply by folds, which is why a two-fold dry
run reported three times the cells it would ever write a row for.
FeaturesConfig
¶
Bases: BaseModel
Optional feature engineering for the Python models.
Defaults are conservative/generic (no transform, no holidays); the shipped
example_config.json turns on holidays + log1p.
There is no lags field, and the reason is a design rule rather than an omission.
"Lag" means two different things, and only one of them can be built out here:
- 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, it belongs to the model, and
the models that need it already own it —
_lag_forecasterdoes it forlightgbm/xgboost/regression_lags, and NeuralProphet does it behindn_lags. A config cannot supply one, because nobody knows the future target; the removed field filled the horizon with a flat line at the last observation and handed the result to models that had fitted coefficients against real history. - 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 this is the gap that
exog_lagsfills.
exog_lags maps an exog column to the lags to build from it: {"promo": [1, 7]}
adds promo_lag_1 and promo_lag_7. They are ordinary columns by the time any model
sees them, so every supports_exog model can use them.
Precedence, when the two rules ever meet: the model wins. A model that lags covariates
internally sets lags_covariates_internally and is handed the unlagged columns only, so
a covariate is never lagged twice. Every shipped model leaves that flag False today.
all_covariates
property
¶
Every declared covariate column (dynamic_covariates + static_covariates),
order-preserving and deduplicated.
dynamic_covariates
property
¶
Time-varying numeric covariates (exog + future_covariates + past_covariates),
order-preserving and deduplicated.
known_future_covariates
property
¶
Time-varying covariates known across the forecast horizon (exog + future_covariates),
order-preserving and deduplicated.
HierarchyConfig
¶
Bases: BaseModel
Hierarchical and grouped time-series aggregation and coherent forecast reconciliation.
Off by default (enabled: false). When enabled, levels defines the cross-sectional
aggregation hierarchy from top to bottom (for example [["region"], ["region", "category"]]),
with the total aggregate ("__total__") prepended automatically and the bottom-level series
(data.ts_id_col) at the leaves.
Implements the Hyndman & Athanasopoulos (FPP3 Ch. 11) reconciliation projection
y_tilde = S @ G @ y_hat across eight methods:
bottom_up— sum bottom-level forecasts upward via the summing matrixS.top_down— disaggregate the top-level forecast downward by average historical proportions (FPP3 §11.2).middle_out— anchor onmiddle_level(sum upward above it, disaggregate downward below it by historical proportions within each middle-level node).ols— ordinary least squares MinT reconciliation (W_h = I).wls_struct— structural scaling WLS (W_h = diag(S @ 1)), requiring only the hierarchy structureS.wls_var— variance scaling WLS (W_h = diag(W_hat_1)) weighted by inverse residual variances.mint_shrink— Wickramasuriya et al. (2019) Minimum Trace with Schäfer-Strimmer shrinkage covariance of residuals, positive-definite even whenn_series >> T_obs.mint_nonneg— active-set non-negative Minimum Trace reconciliation guaranteeing bottom-level forecasts>= 0while preserving exact additive coherence across all levels.
HpoConfig
¶
Bases: BaseModel
Hyperparameter optimization on the aligned backtest (optional).
Off by default. When enabled, an Optuna study tunes each model's search_space on the
backtest folds and the winning params are stamped to forecast_metadata.best_params (see
scale_forecasting.hpo). HPO therefore requires backtest.enabled.
granularity is the DS-facing cost knob:
fleetwide(default) — tune each model once on asample_sizesample of series and apply the winner across all series. The only granularity affordable at the 100k hero scale: the study runs on the driver before fan-out (a handful of series ×n_trialsfits), not per cell.per_series— tune inside every cell (n_trialsfits per series). Accurate for the tail of hard series but multiplies fit cost byn_trialsfleet-wide; an explicit opt-in.
OutputConfig
¶
Bases: BaseModel
What the numbers we ship mean — the functional behind yhat and physical bounds.
Its own section rather than a field on BacktestConfig, even though both of its rules involve
the backtest, because a reader asking "how do I control the point forecast?" will not look
under backtest, and the docs are a product surface here.
Every model emits one number per future date, so something decides what that number is. For a
long time this project decided by accident: ten of sixteen models built their band from
residual quantiles, the frame assembler took the 0.5 quantile as yhat, and the shipped
forecast was silently prediction + median(in-sample residual) — un-named, un-configurable,
and applied to some models and not others. Measurement said the correction was worth keeping
(it moved fleet WAPE by 5.7%), so it is the default. This field is what turns it from an
opinion into a default: the alternative is now sayable.
yhat_raw is written alongside yhat whatever this is set to, so the choice is never
destructive and the two arms can always be compared after the fact. Physical constraints
(floor, cap, floor_col, cap_col, and integer_rounding) are applied
post-calibration so calibrate_from_oof estimates empirical residuals on unclamped raw
model outputs while yhat, yhat_lower, yhat_upper, quantiles, and scored OOF
predictions respect physical reality. See calibration.py.
ProfileConfig
¶
Bases: BaseModel
Whether to measure what a run costs before sizing it, and with how much headroom.
Sizing today is a pure cell count: n_series x n_models x n_folds, turned into nodes by a
flat cells-per-slot constant. Nothing in that arithmetic knows that a deep-learning fit and a
naive mean differ by orders of magnitude, so a fleet is provisioned for the count and not for
the work. Turning this on replaces the guess with a short instrumented pre-pass
(scale_forecasting.profiling): fit a stratified sample of series, measure what they actually
consumed, and size each family's slot from the measurement.
The two margins are deliberately different, and the asymmetry is the point: over-estimating
time buys extra slots, which costs money, while under-estimating memory OOM-kills the task,
which costs the run. So memory carries the larger headroom, and the two are applied to
different tails — memory_margin to the sample max (a slot must hold the worst case that
lands in it), time_margin to the median (a fleet is sized for typical work, and sizing
it for the worst case over-provisions every run).
Part of the run_id digest — unlike the two fields that are not (its own source, and
compute.capacity, both of which are resolved or operational rather than authored). It
changes the resource
shape rather than the numbers a run produces, so it is arguable — but the config is the
experiment record, and a run whose fleet was sized differently is not the same run for
performance purposes. Silently varying the shape under a stable id would be the worse trade.
A profile is produced by one run and consumed by later ones, which is why mode is
split into a source (what evidence to consume) and a measure (what evidence to produce)
below. Every runtime consumes: submit.sizing_properties, dataproc_cluster.cluster_sizing
and the Ray sizing paths all resolve one through profiling.source.profile_for_run.
What differs per runtime is when the measurement can be taken, and only Ray gets a choice.
spark.executor.cores and spark.task.cpus are fixed at submit (Serverless) or at create
(cluster), before any of our code runs out there, so on Spark the only evidence that can reach
a sizing decision is prior evidence — a harvest, or the shipped baseline. ray_engine can
additionally call profiling.source.resolve_profile and measure in-run, because a Ray task's
num_cpus/num_gpus is a request made against a pool that already exists. So "auto"
and "always" still differ only on the Ray path; "off" is the setting that is
load-bearing everywhere, where it also suppresses the static-arithmetic overlay.
consumes_evidence
property
¶
Should this run try to size itself from measurements? (mode="off" vetoes it.)
needs_source_resolution
property
¶
Is the source still a question rather than an answer? (i.e. must plan time lock it.)
records_measurements
property
¶
Should this run write per-cell measurements? (mode="off" vetoes measure.)
unpins_threads
property
¶
Should the fleet leave native thread pools uncapped so effective_cores is real?
ResolvedFamilyCompute
dataclass
¶
ResolvedFamilyCompute(
family,
runtime,
spark_mode,
spark_cluster_name,
hardware,
gpu_type,
machine_type=None,
workers=None,
min_workers=None,
max_workers=None,
accelerator_count=0,
gke_mode=None,
gke_cluster_name=None,
ray_mode=None,
automl_mode=None,
)
One family's effective compute after layering its override on the flat defaults (pure).
The fully-resolved plan the DAG orchestrator acts on: runtime is where the family's job
runs; spark_mode/spark_cluster_name are None unless runtime == "spark";
gke_mode/gke_cluster_name apply when runtime == "gke" (or runtime == "ray" with
ray_mode == "gke"); ray_mode applies when runtime == "ray"; automl_mode applies
when runtime == "vertex_automl"; gpu_type is None unless hardware == "gpu".
Produced by RunConfig.resolve_family_compute.
RetryResources
¶
Bases: BaseModel
What a repair may claim differently from the attempt it repairs.
A repair 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. This is where a run says how wide its repairs should be instead.
It lives here, under compute.capacity, for the reason the parent's docstring already gives:
everything under capacity is excluded from the run_id digest, and a repair that resized
itself must stay the same run. A sibling of compute.max_executors would fork the id, which
would mean the repair wrote its rows under a run nobody was looking at.
It is a config block rather than only a CLI flag for the same reason compute.max_executors
is: the unattended repair paths (airflow_tasks.retry_families, main's --retry) launch
from a config and nothing else, so a ceiling reachable only through an argument is a ceiling an
orchestrated repair can never set. An explicit max_executors= argument still wins over it
(job_launch.submit_retry) — the operator at the terminal overrides the file.
Why only this one knob, and what may join it. A repair reuses the config its run staged, so
the only settings it can vary are the ones the driver reads at launch: anything the worker
reads out of the staged 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. Of the
driver-side knobs, max_executors is the one the launchers already take as an argument
(job_launch.launch_family_job). Note its reach: it caps a Spark batch's
spark.dynamicAllocation.maxExecutors and is ignored by the Ray and in-process paths, so a
Ray family's repair still provisions from the fan-out.
RunConfig
¶
Bases: BaseModel
A complete, validated, frozen run specification.
max_horizon
property
¶
The largest horizon any predict call in this run will be asked for.
Two different horizons exist in a config and it is easy to reach for the wrong one. The
forward forecast uses data.horizon; every backtest fold predicts backtest.horizon,
which may be larger. Anything sizing itself against "the horizon" — a params validator
refusing a model that cannot emit enough steps, a context handed to a model — has to mean
the larger of the two, because the run will ask for both. A property rather than a field:
it is derived, so it stays out of model_dump and no run_id moves.
The embargo counts, and leaving it out was a bug. A fold's model stops at train_end
while its validation window starts gap observations later, so the model has to forecast
across the embargo before it reaches anything that gets scored: the Python fold asks for
gap + val_size steps and throws the first gap away, and the BigQuery fold asks its
ML.FORECAST for gap + backtest.horizon. Both engines were
already right; this number, which is supposed to describe them, was short by exactly
gap. What that cost: NeuralProphet emits exactly n_forecasts direct steps and does
not recurse, so a gap=3 run passed its params validator and then came up three steps
short at the tail of every fold.
resolve_family_compute
¶
Resolve one family's effective compute by layering its override on the flat defaults.
Pure and deterministic — the single resolver the DAG orchestrator also uses, so a config
validated at load needs no re-check at submit. family is a compute family
(statistical/ml/deep_learning/automl); native has no compute choice (it
always runs in BigQuery) and raises. Resolution rules for unset override fields:
runtime→python_runtime(or"vertex_automl"forautoml).hardware→gpuonly fordeep_learningandautoml(whencompute.use_gpuor an explicit override); every other family iscpu.- Spark:
spark_mode→serverless;spark_cluster_nameapplies only undercluster. Onray,vertex,gce,gke, andvertex_automlboth areNone. - GKE & Ray modes:
gke_mode→compute.gke_mode("job"|"ray") ongke;ray_mode→compute.ray_mode("vertex"|"gke") onray(or"gke"whenruntime == "gke"andgke_mode == "ray"). - AutoML mode:
automl_mode→compute.automl_mode("tabular_workflow"|"training_job") onvertex_automl. gpu_type(whenhardware == "gpu") → the flatcompute.gpu_type, but forced to L4 on Dataproc Serverless (no T4/A100 there). Inheriting T4/A100 on Serverless raises.machine_type→compute.machine_type(auto-resolved viaresolve_vm_machine_typefrom(hardware, gpu_type, accelerator_count)onvertex/gce/gkejob, or when explicitly set onray/sparkcluster/gkeray/vertex_automl);workers→compute.workersonvertexandgkejob,1ongce, or the family override when set onray/spark/vertex_automl;min_workers/max_workersapply on autoscaling runtimes (ray/spark/gkeray/vertex_automl).
Source code in src/scale_forecasting/config.py
2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 | |
with_available_models
¶
Return a copy with models filtered to those whose upstream packages are installed.
Raises ConfigError if none of the requested models have their required packages
installed in the current environment.
Source code in src/scale_forecasting/config.py
with_series_limit
¶
Return a copy with data.series_limit overridden (self if n_series is None).
The scale knob every submit path shares (the 10 → 100 → 1k → 100k story). Because it
changes the config, the copy yields a distinct run_id, so each scale is its own
queryable run.
Source code in src/scale_forecasting/config.py
Workload
dataclass
¶
Workload(
n_series,
n_models,
n_folds,
n_cells,
n_fits,
full_fit_equivalents,
train_rows_total,
fold_histogram,
n_unscored,
)
Dry-run estimate of the work a run will schedule.
Two halves, and the split is the point. The count half (n_series … n_cells) is a
function of the config alone, so a plain --dry-run fills it with no environment and no data
read. The geometry half (n_fits onward) depends on how long each series actually is,
which only the panel knows — so it is None/empty unless the caller supplies obs_counts
(what plan --feasibility reads). Reporting a guess there would be worse than reporting
nothing: on a ragged panel the guess is wrong in the direction that flatters the plan.
n_cells is n_series × n_models — one cell is one (series, model) pair, which is what
run_cell runs, what engines.ray_io.plan_cluster sizes against, and what the leaderboard's
n_cells counts. It deliberately does not multiply by folds: folds happen inside a
cell, and the old fold-multiplied number made a backtested run look like it scheduled three
times the work when it schedules the same work three times as deep.
This is the estimate; the run records the measurement. forecast_metadata.n_fits and
train_rows_total are the same two quantities counted at the fit site by worker.run_cell.
The two disagree on four of the six refit paths, because the estimate below assumes a fresh fit
per fold and only expanding/sliding do that — see backtest.FitTally. That gap is not
an error in either number. It is what the scheme cost or saved, and a completed run can be
asked for it.
corrected_arm_for
¶
Which corrected point-forecast arm a decision metric implies — "mean" or "median".
A metric whose loss is quadratic in the error is minimised by the mean; everything that is absolute-error-shaped, and every proper interval score, is minimised by the median. The pairing is a theorem, not a preference, so it is what a run falls back to whenever no measurement is available to do better.
One rule, two callers. calibration.select_arm uses it under point_forecast="auto" to know
which arm it is weighing raw against; RunConfig._normalize's warning about an explicit
median under a squared-error metric is the same rule read the other way. Splitting it across
those two would let a fleetwide judgement and a per-series selection disagree about what
"corrected" means, which is the kind of drift nobody notices until a leaderboard reads oddly.
The answer is declared on the metric (BaseMetric.mean_optimal), not held here as a list of
names. It used to be a frozenset of four, which was correct for the fifteen shipped metrics and
unreachable for a sixteenth: a deployment that adds a squared-error metric would have been given
the median, silently and wrongly, with nowhere to say otherwise. An unregistered name still
answers "median" — the same answer the frozenset gave it — so this reads identically for every
caller that was ever correct.
No longer what an unset output.point_forecast resolves to: with a backtest present the
default is auto, which measures the choice per series rather than deducing it fleetwide. This
function is what auto falls back to when a cell has nothing to measure.
Says nothing about whether a backtest exists — that is the caller's guard.
Source code in src/scale_forecasting/config.py
estimate_fanout
¶
The config-only cell-count estimate — estimate_workload narrowed to its four count fields.
When data.series_limit is unset, series count isn't known offline, so
n_series and n_cells are None (the CLI reports "all series").
Source code in src/scale_forecasting/config.py
estimate_workload
¶
Estimate the work a run will schedule, in cells and in fits (pure).
obs_counts is the observation count of each series that will be forecast — one entry per
series, in any order. Supply it (from SELECT ts_id, COUNT(*) … GROUP BY ts_id) and the fold
geometry is resolved exactly, per series, through backtest.fit_rows. Omit it and only the
cell counts come back; n_series then falls back to data.series_limit, which is None
for an unbounded run, and n_cells follows it.
Why fits and not just cells. A cell with two backtest folds does three fits, and the two
fold fits train on shorter windows than the final one — so the honest cost multiplier for
backtesting is full_fit_equivalents (2.94 for four years of daily history, two folds, a
28-day horizon), not n_folds + 1 (3). full_fit_equivalents is dimensionless: it is what
one cell costs relative to the same cell with backtesting off, so multiply it by n_cells to
compare two plans. n_fits and train_rows_total are absolute and both scale with models.
With backtesting off, every cell does exactly one full-history fit, so n_fits == n_cells and
the multiplier is 1.0 without needing to know a single series length.
Source code in src/scale_forecasting/config.py
load_config
¶
Read a JSON config file and return a validated, frozen RunConfig.
All failure modes (missing file, bad JSON, invalid schema) surface as a single
ConfigError with a clear message, so callers fail fast and never log an
invalid run.
Source code in src/scale_forecasting/config.py
load_config_uri
¶
Load a validated config from a local path or a gs:// URI (the portable source).
A gs:// URI is the staged config an emitted launch command references, so any
ADC-authenticated machine can re-run from it without a local file. Anything else is treated as a
filesystem path (delegates to load_config). Failure modes surface as a single ConfigError.
Source code in src/scale_forecasting/config.py
resolve_vertex_machine_type
¶
resolve_vertex_machine_type(
hardware,
gpu_type,
cpu_machine_type="auto",
gpu_machine_type="auto",
override_machine_type=None,
*,
accelerator_count=1,
)
Backward-compatible wrapper around resolve_vm_machine_type.
Source code in src/scale_forecasting/config.py
resolve_vm_machine_type
¶
resolve_vm_machine_type(
hardware,
gpu_type=None,
default_machine_type="auto",
override_machine_type=None,
*,
accelerator_count=1,
)
Resolve and validate the GCE/Vertex VM machine type for a family's hardware (pure).
Enforces GCP's three structural GPU-to-VM compatibility patterns at config load time:
* T4 (NVIDIA_TESLA_T4): attaches to n1-* in counts of {1, 2, 4} (1-2 GPUs cap
at 48 vCPUs; default n1-standard-8 for 1-2 GPUs, n1-standard-16 for 4 GPUs).
* L4 (NVIDIA_L4): bundled into g2-standard-* in counts of {1, 2, 4, 8} (1 GPU
allows g2-standard-{4,8,12,16,32}, default g2-standard-8; 2/4/8 GPUs require fixed
shapes g2-standard-{24,48,96}).
* A100 (NVIDIA_TESLA_A100, 40 GiB) & A100_80GB (NVIDIA_A100_80GB, 80 GiB):
strict 1:1 mapping from accelerator_count to a2-highgpu-{1,2,4,8}g /
a2-megagpu-16g or a2-ultragpu-{1,2,4,8}g.
Source code in src/scale_forecasting/config.py
1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 | |