BaseModel¶
base_model
¶
The model interface every model file implements.
This is the linchpin of the one-model-one-file rule and the factory. Every Python model
is a subclass of BaseModel living in its own file that ends with register(...);
the factory (models/__init__.py) builds {name: class} at import. Adding a model is
a new file plus one register call — no edits anywhere else.
Models never read global config: everything they need at fit/predict time arrives through
ModelContext. Models that don't emit their own prediction intervals
(supports_native_intervals = False) call BaseModel.residual_intervals so every
model still returns the canonical frame with ordered bounds.
Public surface: BaseModel, ModelContext, register (plus the _REGISTRY the
factory reads, and the Runtime/Family type aliases).
BaseModel
¶
Bases: ABC
Base class for every forecasting model.
Source code in src/scale_forecasting/models/base_model.py
advance_origin
¶
Move the forecast origin periods steps forward blind — no new actuals.
The seam behind backtest.scheme="expanding_stale": the model's already-fitted curve is
evaluated at later dates and never told what actually happened in between, so its score
measures staleness, not skill. It cannot notice a level shift. That is a real question
("how fast does a fitted model decay if nobody touches it?") and a different one from
recondition's, which is why the two never share a leaderboard slice.
periods is absolute — counted from the last observation the fit saw, not from
wherever the origin currently is. So the driver sets it per fold instead of accumulating,
and setting it twice is idempotent. 0 restores the as-fitted origin.
X_gap is exog over the skipped span, for the models whose forecast is a recursion that
has to be rolled through it. Handing it over is not leakage: exog is known-in-advance by
construction here — features.build_future_features fabricates it for the forward forecast
too. Only the target is withheld.
Unlike recondition, this one has a real default: it records the offset and the gap exog,
and each model honours them in predict (the base helper _forecast_index does the date
half). There is nothing model-specific left to override, so a model opts in by setting
supports_extrapolate rather than by reimplementing the same four lines sixteen times.
Source code in src/scale_forecasting/models/base_model.py
device_used
¶
After fit, where did the parameters actually end up — "cuda", "cpu", None?
The evidence half of the GPU contract, and it has to come from the fitted object rather
than from what the run asked for. ModelContext.device records the request; a request
is not a receipt, and the whole reason this contract exists is that for twenty-one jobs
the two silently disagreed.
None is the default and the honest answer for every model with no tensor library under
it: a statsmodels fit does not run "on the CPU" in any sense worth recording, it simply has
no device concept. Do not return "cpu" to mean "not applicable" — a reader auditing a
GPU family needs to tell "this model looked and found no device" from "this model cannot
answer". Override only where the library can be asked; see gpu_capable.
Called once per cell after the final fit, and it must never raise — a probe that sank a good forecast would be worse than no probe.
Source code in src/scale_forecasting/models/base_model.py
diagnostics
¶
Whatever the fitting library reports about this fit. Default: nothing.
Called once per cell after the final full-history fit, and written to
forecast_metadata.fit_diagnostics. Examples of what belongs here: AIC or
log-likelihood, the (p,d,q) an auto_arima selected, XGBoost's best_iteration,
a changepoint count, the number of epochs an early stop actually ran.
Why these are not metrics, and why that distinction is load-bearing. A scored metric is
a pure function of (y_true, yhat, y_train, bounds) that the framework computes, so it
means the same thing for all sixteen models and a leaderboard can rank on it. A diagnostic
is whatever the library happened to expose: it exists for some models and not others, and
where two models both report an "AIC" the two numbers are not on a comparable scale. Put
one in the metric panel and you get a leaderboard column that looks uniform and is not —
which is exactly the failure the framework-computes-metrics rule exists to prevent. So this
is a bag in its own JSON column, next to best_params, which is already the precedent
for per-model numbers nobody aggregates across models. Nothing here reaches the panel, the
leaderboard projection, or decision_metric.
Key names may not collide with a metric name. best_params and the metric panel are
read off one forecast_metadata row, and a diagnostic called mase would make "which
mase is this" a real question the first time anything flattens the JSON beside the columns.
worker drops a colliding key and logs it rather than failing the cell — see
worker._collect_diagnostics.
Best-effort, never fatal. Anything raised here is caught and the cell simply reports no
diagnostics, on the same reasoning as serialize: a forecast that was produced must not be
thrown away because a library could not describe how it was produced. Return plain
JSON-serializable scalars — the bag is written with json.dumps into a BigQuery JSON
column, so a numpy scalar or a fitted object is dropped (with a warning) rather than
allowed to fail the append for every other cell in the same batch. Cast to
float/int/str here and the value survives.
Source code in src/scale_forecasting/models/base_model.py
explain
¶
Per-horizon-step local feature attributions ({"baseline_score": ..., ...}).
Optional Tier-2 explainability hook: returns a list of horizon JSON-serializable
dictionaries (one per forecast step), or None when the model does not emit per-step
local attributions. Written to forecast_predictions.explanations. Series/model-level
global feature importances live in diagnostics()["feature_attributions"] (Tier 1,
written to forecast_metadata.fit_diagnostics).
Source code in src/scale_forecasting/models/base_model.py
fit
abstractmethod
¶
Fit on one series. y is indexed by ds (datetime64); X is aligned exog or None.
fit_panel
¶
Fit a single global or hybrid model across a panel of {ts_id: (y, X)}.
Source code in src/scale_forecasting/models/base_model.py
get_params
¶
Resolved params actually used (post-HPO). Logged to forecast_metadata.best_params.
gpu_useful
classmethod
¶
Would a device do meaningful work at these authored hyperparameters? Default: no.
gpu_capable says a device can be used; this says it would be worth paying for. The
two came apart when they were measured: across 31,356 NeuralProphet fits on live T4s, peak
device memory was 50–78 KB against a card holding 17 GB and cpu_seconds / fit_seconds
sat at 0.93–0.996. The model was on the device the whole time and the device was doing
essentially nothing, because at the shipped defaults the network is a few hundred trend and
Fourier parameters. Every GPU run in the ledger was, in substance, a CPU run.
Authored hyperparameters only — no ModelContext, no config. Building a plan-time
context would duplicate worker._model_context (which does real work), and no model's
device use depends on the frequency or the holiday frame. Widen the signature later if a
model genuinely needs the horizon; that is an internal change, not a config change.
This never feeds routing or provisioning. Consuming it at
engines.ray_io.split_gpu_cpu_models would empty the GPU pool on every config shipped
today while the submitter still bought the cards — manufacturing the exact
provisioned-but-unrouted incoherence dag.check_hardware_coherence exists to refuse. It
feeds one thing: a warning (dag.gpu_usefulness_report).
Source code in src/scale_forecasting/models/base_model.py
is_available
classmethod
¶
Whether this model's upstream Python package is installed in the current environment.
Probed without importing it (errors.is_importable), so listing 37 models costs nothing and
a dotted name whose parent is absent (google.cloud.bigquery on a core-only install)
reads as "not installed" rather than raising.
Source code in src/scale_forecasting/models/base_model.py
predict
abstractmethod
¶
Return the canonical prediction frame in original units.
Source code in src/scale_forecasting/models/base_model.py
predict_panel
¶
predict_panel(
horizon,
future_exog_map=None,
quantiles=DEFAULT_QUANTILES,
transform_lambdas=None,
)
Return {ts_id: canonical_prediction_frame} in original units for a fitted panel.
Source code in src/scale_forecasting/models/base_model.py
recondition
¶
Take in observations that arrived after the current origin, without re-estimating.
This is the seam behind backtest.scheme="expanding_frozen": the model is fit once on the
oldest fold's window and then walked forward, so each fold's score answers "what does
refitting less often cost me?" rather than "how good is this model freshly trained?".
Parameters stay exactly as fitted; only what the model is conditioned on moves.
y_new is the new observations only — not the whole window. That is
statsmodels' own append contract, and it is the contract worth matching because
getting it wrong there is silent: appending a span twice corrupts the state filter and
raises nothing. Models that would rather hold the whole history (the naives, croston,
the lag models) accumulate it themselves in one line, and those are exactly the models where
the operation is cheap and hard to get wrong.
Default: refuse. A model that has no way to absorb an observation without re-estimating
should say so rather than approximate, and supports_recondition is how the driver knows
in advance.
Source code in src/scale_forecasting/models/base_model.py
require_available
classmethod
¶
Raise an actionable ModelError if this model's optional package is not installed.
Source code in src/scale_forecasting/models/base_model.py
residual_intervals
¶
Empirical residual-quantile prediction intervals.
For models without native intervals: fit() records residuals via
_set_residuals, and this adds their empirical quantiles to the point
forecast so bounds are naturally ordered (lower ≤ yhat ≤ upper) for any monotone
quantile set. Falls back to a point-mass band (bounds == yhat) if no residuals
were recorded.
Source code in src/scale_forecasting/models/base_model.py
scope_capability
classmethod
¶
Classify this model's cross-series training scope capability.
Source code in src/scale_forecasting/models/base_model.py
search_space
classmethod
¶
HPO search space (optional; used only when hpo.enabled). Default: no search.
serialize
¶
Serialize the fitted model for artifact persistence.
Returns the bytes to store as the cell's model_artifact — the registry writer
uploads them under <warehouse>/artifacts/<run_id>/ and stamps the GCS ObjectRef
onto the forecast_metadata row (lineage). Default: pickle the instance.
Override to use a model-native format (e.g. Booster.save_model) or return
None to opt out for models cheap enough to refit. Called only when the run sets
persist_models; a None return (or a raised error, which the worker catches)
simply means no artifact for that cell — persistence never sinks a forecast.
Source code in src/scale_forecasting/models/base_model.py
supported_training_modes
classmethod
¶
Allowed training_mode values ("local", "global", "hybrid").
Source code in src/scale_forecasting/models/base_model.py
unsupported_covariate_tiers
classmethod
¶
Which covariate tiers configured in features this model does not support.
Source code in src/scale_forecasting/models/base_model.py
validate_params
classmethod
¶
Refuse an authored model_params block this model cannot honour. Default: accept all.
Called at plan time, before anything is provisioned, so a parameter combination that would produce a horizon of NaNs costs nothing instead of a fleet-hour. Override when a model has a constraint that is only knowable from its own library's behaviour — the point of the seam is that such knowledge stays in the model's file rather than leaking into the config layer.
max_horizon is the largest horizon this run will ask predict for: the forward
horizon and the backtest horizon, whichever is greater. Raise ConfigError, not
ModelError — nothing has been fitted, the config is what is wrong.
Source code in src/scale_forecasting/models/base_model.py
ModelContext
dataclass
¶
ModelContext(
freq,
horizon,
seed=0,
holidays=None,
transform="none",
transform_lambda=None,
device="auto",
past_covariates=(),
static_covariates=None,
)
Per-run context handed to every model so it never reads global config.
register
¶
Register a model class under its name. Returns the class so it
doubles as a decorator. Raises on a missing or duplicate name.