Skip to content

Forecaster

The time-series forecasting entry point. You choose one of four backends with config.backend. In auto mode Forecaster profiles each series, curates that backend's candidate models, and picks the per-series rolling-origin-CV winner, checked against a SeasonalNaive baseline. See the forecasting guide.

In short

  • Forecaster(config).fit(df) takes a long frame (unique_id, ds, y); predict(h=...) returns a long forecast frame with interval columns.
  • parleml_ml4.forecasting.season and parleml_ml4.forecasting.grid are importable on their own, so a pre-fit surface can ask what season and grid a fit would use.
  • parleml_ml4.forecasting.result owns the forecast column-name convention: use its readers instead of parsing -lo- / -hi- columns yourself.
  • parleml_ml4.forecasting.preflight checks a fit's input without fitting.

Forecaster

Forecaster(
    config: Optional[ForecastConfig] = None,
    preprocessor: Optional[ForecastPreprocessor] = None,
    *,
    outputs_dir: Optional[str] = None,
    setup_file_logger: bool = True
)

Top-level forecasting orchestrator.

Parameters

config : ForecastConfig, optional Task configuration. Defaults to ForecastConfig(). preprocessor : ForecastPreprocessor, optional Override the default preprocessor (rare). outputs_dir : str, optional, keyword-only When provided, fit() writes <outputs_dir>/{events.ndjson, manifest.json, CANCEL, ml4.log} and persists the deployable :class:FittedForecastPipeline under <outputs_dir>/deploy/forecaster/. When None (default), the fit runs in memory: no telemetry, no on-disk artefact. setup_file_logger : bool, keyword-only, default True When True and outputs_dir is provided and the root logger has no handlers, install a rotating file handler at <outputs_dir>/ml4.log. Mirrors :class:AutoSupervised's gating.

DEPLOY_ARTIFACT_NAME class-attribute instance-attribute

DEPLOY_ARTIFACT_NAME = 'forecaster'

Subdirectory inside <outputs_dir>/deploy/ that the saved :class:FittedForecastPipeline lands in. Matches AutoSupervised's deploy/<model_name> convention so the UI's deploy reader works blindly.

outputs_dir property

outputs_dir: Optional[str]

The outputs_dir passed to the constructor, or None.

Exposed so callers / the backend FastAPI wrapper can locate the events.ndjson / manifest.json / deploy/ skeleton without retaining the original argument separately.

fit

fit(
    X: ForecastInput,
    *,
    mlops: Optional["MLOpsRunSettings"] = None
) -> "Forecaster"

Fit the forecasting orchestrator.

Wraps the logical stages (preprocess / profile / fit_single or fit_auto / build_result, plus persist when outputs_dir was provided) with stage_* events. When outputs_dir is None there is no telemetry and no on-disk artefact.

Parameters

mlops : MLOpsRunSettings, optional, keyword-only When provided with mlflow.enabled true, log the deployable :class:FittedForecastPipeline as an MLflow pyfunc model (plus params / metrics / artifacts) and optionally register it to Unity Catalog. The resulting run id is exposed on :attr:mlflow_run_id_. No-op when None or disabled.

cross_validate

cross_validate(X: ForecastInput) -> DataFrame

Direct CV access — runs the rolling-origin cross-validation the fit pipeline uses for the configured backend, with the same preprocessing recipe.

Returned long-format frame includes the configured confidence levels so coverage / stability plots have data without a refit (only the stats backend produces analytic intervals; the point-only backends return point columns, exactly as they do inside fit).

The roster (BackendEntry.roster), the backend object (:meth:_backend_for_specs) and the pass itself (:func:~parleml_ml4.forecasting._cv.run_cv) are the ones :meth:fit uses, so this answers the fit's question on every backend — including the fall-back to one window when the panel is too short for the configured grid. Unlike the fit, a cross-validation failure propagates rather than being recorded.

Runs entirely on throwaway locals: a fitted forecaster's state — the shared preprocessor, the stored future frame and the hierarchy map — is never clobbered, so fit(X1) → cross_validate(X2) → to_pipeline() still persists X1's artefacts.

to_pipeline

to_pipeline(*, metadata: Optional[dict] = None)

Wrap this fitted forecaster into a self-contained :class:FittedForecastPipeline.

Season resolution

parleml_ml4.forecasting.season is the single authority for season_length — the number that becomes the MASE denominator, the SeasonalNaive honesty baseline and every routed seasonal order. It is public and importable on its own (from parleml_ml4.forecasting.season import resolve_season_length) and drags in none of the fit — no backend, no statsforecast, no LightGBM, no AutoGluon — so anything that needs to predict what a fit will resolve (a pre-fit profiling endpoint, a launch wizard's recommendation, a report) can call it instead of re-deriving the rule and diverging from it. It does cost pandas, deliberately: freq_offset resolves the frequency alias through pandas.tseries.frequencies.to_offset, and that resolution is the one thing no string normalisation can do correctly ('ms' is a millisecond, 'MS' is a month). Every frequency question in parleml_ml4.forecasting — this module's textbook cycle and the profiler's candidate periods — goes through that one function rather than a private copy per module.

season

Season resolution — the single source of truth for season_length (FC-3 / FC-17 / FC3-3).

Public API, deliberately. season_length is a number a forecast run and everything that talks about a forecast run must agree on, so this module is importable from outside parleml_ml4.forecasting: from parleml_ml4.forecasting.season import resolve_season_length. A caller that only wants to know "what season would a fit resolve here?" (a pre-fit wizard, a profiling endpoint, a report) gets the fit's own answer rather than a second opinion, and drags in none of the fit — no backend, no statsforecast, no LightGBM, no AutoGluon. It does cost pandas, and that is deliberate: the frequency alias is resolved through :func:pandas.tseries.frequencies.to_offset (see :func:season_length_for_freq), and the import is free here because the ml4 package __init__ chain this module already sits behind loads pandas and numpy anyway. An earlier revision promised a "stdlib-only body" — a promise the same paragraph then conceded it could not keep — and paid for it with a blanket .upper() on the alias, which is precisely how 'ms' (millisecond, what pd.infer_freq returns for a millisecond index) came to be read as 'MS' (month-start) and resolve to a season of 12. It was _season while its only consumers were siblings; a private module with an out-of-package consumer is a copy waiting to be made, and one had already been made — see :func:resolve_season_length.

Two layers, both single-sourced here:

  • :func:season_length_for_freq — the frequency → textbook seasonal-period map, keyed on the pandas offset class and multiple, never on the alias string. A per-backend copy previously diverged (the tree backend used B→7 and T/S→1 while the stats path used B→5 and T/S→60), re-introducing the very stats-vs-tree inconsistency FC-3 set out to remove.
  • :func:resolve_season_length — the full resolution order caller's value → a convincing detected peak → the frequency default, gated by the one :data:SEASON_STRENGTH_THRESHOLD, and no season at all when the profiler tested the frequency default and found nothing there. The gate is the point: an un-gated argmax over seasonality_strengths lets noise set the season. That number is the MASE denominator, the SeasonalNaive baseline the weak-series gate is defined against, and every routed seasonal order — so the Forecaster, the router, the Signal Check and the tree backend must all read it from here, or one run holds several different opinions about its own season.

Two thresholds, two questions, one quantity. Both :data:SEASON_STRENGTH_THRESHOLD (0.30, fixed, here) and :attr:ForecastConfig.multi_season_strength_threshold <parleml_ml4.config.forecasting.ForecastConfig.multi_season_strength_threshold> (0.10, configurable) are cut-offs on the same seasonal-strength number, which makes them look like one knob split in two. They are not, and the difference is what each one is allowed to decide:

  • the configurable 0.10 is a presence gate — the profiler counts how many periods clear it and sets has_multiple_seasonalities from the count. It selects a routing branch (MSTL / TBATS vs univariate), and the CV race then scores that branch against the baselines and can reject it. Being permissive costs a candidate slot, so it is safe to expose and safe to lower.
  • the fixed 0.30 is a trust gate — it decides whether a detected peak may become the season. Nothing downstream can reject that choice, because the metric that would reject it (MASE) is computed with it, and the honest baseline it would be measured against (SeasonalNaive) is built from it. A knob that can silently redefine the yardstick is not a knob.

The invariant, enforced by :func:~parleml_ml4.forecasting.router.route_stats: the presence gate may pick a branch, never a period. Every period that reaches a model spec clears the trust gate. (If 0.30 is ever promoted to config, it must be threaded through every caller listed on :func:resolve_season_length in the same change — a partially-wired knob re-creates precisely the split this module exists to close.)

season_length_for_freq

season_length_for_freq(freq: Optional[str]) -> int

Default seasonal period for a pandas frequency alias ('D'→7, 'ME'→12, 'B'→5, '15min'→96); 1 when the freq is unset, unparseable, or has no natural cycle at its own resolution (annual data, and sub-second frequencies for which this table names no cycle).

The alias is resolved with :func:pandas.tseries.frequencies.to_offset and looked up by offset class in :data:DEFAULT_SEASON_CYCLES, so every spelling of a frequency lands on the same answer: 'D'/'1D'→7, 'M'/'ME'→12, 'W'/'W-SUN'→52, 'h'/'H'→24, 'min'/'T'→60, 's'/'S'→60 — and 'ms' is a millisecond, not a month.

A multiple ('2D', '15min', '3ME' — what pd.infer_freq returns for a panel sampled every other day / quarter-hour / quarter) divides the cycle it is measured in, and only an exact division survives: '3ME'→4 and '2W'→26 are whole cycles, while '2D'→1 is deliberately non-seasonal because the weekly cycle is 3.5 steps, which is not a period. A cycle that collapses below 2 steps ('7D': the week is one step long) is not a period either. Where a class names several cycles the shortest that is still a season wins (:data:_MIN_SEASON_STEPS): 'min'→60 takes the hour, while '15min'→96 skips the 4-step hour for the day. If none is long enough, the longest that survives is used, which is what keeps 'QE'→4.

The day is exempt from that floor (:data:_DAY_IN_BASE_UNITS): on a sub-daily grid the diurnal cycle is the dominant one, and the only thing above it is the week. So '6h'→4 and '8h'→3 take the day rather than the 28- / 21-step week, and '12h'→2 takes the two-step day (the AM/PM alternation is a real cycle; the week is not a better answer for it). '15min'→96 is unchanged, because 4 steps there is the hour, not the day.

freq_offset

freq_offset(freq: Optional[str]) -> Optional[Any]

freq as a pandas DateOffset; None when unset or unparseable.

The one normaliser every frequency question in parleml_ml4.forecasting is resolved through — :func:season_length_for_freq for the textbook cycle and :func:parleml_ml4.forecasting.profiler._candidate_periods_for_freq for the probe set — which is why it is public rather than a private helper of this module. The trap it exists to close is that no amount of .upper() / suffix-stripping distinguishes 'ms' from 'MS', and to_offset does. The profiler used to inline the identical try/to_offset/ except ValueError itself; two copies of "what does this alias mean?" is exactly the split that let the season and the strengths it is chosen from disagree, so both now land here and the cross-module agreement test in test_forecasting_season.py pins the tables that sit on top.

Only ValueError is caught, because that is what to_offset raises for an alias it cannot parse. A deprecated alias ('H', 'T', 'A' — still what a stored config or an index profiled under an older pandas carries) parses fine and merely warns, so it keeps resolving to its real offset class; the day pandas removes one it becomes a ValueError, both callers degrade to "no opinion" rather than a wrong one, and the freq table in test_forecasting_season.py fails loudly.

resolve_season_length

resolve_season_length(
    season_length: Optional[int],
    freq: Optional[str],
    strengths: Optional[Mapping[int, float]] = None,
) -> int

The run's seasonal period: caller's value → a convincing detected peak → the freq default.

strengths is :attr:~parleml_ml4.forecasting.profiler.SeriesProfile.seasonality_strengths ({period: strength}: 0.0 = tested and not significant, absent = no sampled series could test it). The strongest period wins only when its strength reaches :data:SEASON_STRENGTH_THRESHOLD; otherwise :func:season_length_for_freq supplies the textbook cycle — unless strengths scores that very cycle 0.0, in which case the data was tested for it and is not seasonal, and the answer is 1. Always >= 1.

Every season decision inside parleml_ml4.forecasting ends here. The full list of callers, so a new one is added deliberately rather than by growing a fourth copy:

  • :func:~parleml_ml4.forecasting.forecaster._resolve_season_length — the MASE denominator;
  • :func:~parleml_ml4.forecasting.router.route_stats — every routed seasonal order, including the multi-seasonal branch's period list and the ETS clamp's fallback;
  • :func:~parleml_ml4.forecasting.foundation_signal.forecast_signal_check;
  • :func:~parleml_ml4.forecasting.backends.tree_backend._features.season_length_for_frame — the tree backend's lags, EWMA spans and rolling windows.

Callers outside ml4 belong on that list too, and are the reason this module is public. Any surface that tells a user what season their data has — a pre-fit profiling endpoint, a launch wizard's recommendation — is making a claim about what the fit will do, and can only keep that claim by calling this. A hand-rolled second opinion diverges immediately and invisibly: the API layer's own copy recommended no season on BME / BMS and on T / min / S where the fit resolved 12 and 60, and recommended 7 on 2D where the true weekly cycle is 3.5 steps and the fit correctly declines to seasonalise.

One caveat this docstring is careful not to overstate: strengths is caller-supplied. A caller that omits it gets the frequency default, which is only the same answer as a caller that passes strengths when no peak is convincing. The tree adapter therefore takes a strengths= argument, and its caller must pass the profile's — omitting it is a silent divergence, not a safe default.

Reading a result frame

The forecast and CV frames are Nixtla long format: one column per model alias, plus {alias}-lo-{L} / {alias}-hi-{L} for each confidence level. parleml_ml4.forecasting.result owns both halves of that convention — the readers below and the writers in parleml_ml4.forecasting.conformal — so a consumer never has to parse a column name itself. Two traps a hand-rolled parse falls into: a prefix built without the trailing hyphen matches sibling aliases, and picking "the first -lo- column" publishes the 80% band on a run configured for (80, 95).

model_columns_forecast

model_columns_forecast(df: DataFrame) -> list[str]

Return the model-alias columns of a forecast / cv-table frame (drops reserved metadata + -lo-/-hi- interval siblings).

model_columns_scores

model_columns_scores(df: DataFrame) -> list[str]

Return the model-alias columns of a cv_scores frame (one row per (unique_id, metric)).

interval_levels

interval_levels(
    df: DataFrame, alias: str
) -> tuple[int, ...]

Sorted confidence levels for which alias carries a complete lo/hi pair.

THE reader of the {alias}-lo-{L} / {alias}-hi-{L} convention (:func:~parleml_ml4.forecasting.conformal.add_conformal_intervals and the AG-TS converter own the writer). Independent copies of this parse had drifted: one built the prefix without the trailing hyphen, so "LGBM-lo-" would also match a hypothetical LGBMX alias.

A half-pair is not a level: a -lo- column with no -hi- sibling renders as a band with no upper edge, which reads as a far more confident forecast than the run produced.

interval_band

interval_band(
    df: DataFrame, alias: str, level: Optional[int] = None
) -> Optional[IntervalBand]

alias's band for level, or for the widest level it carries.

Returns the level alongside the columns because a published bound is unreadable without it — the surface this replaced picked whichever -lo- column came first in column order and published it as bare yhat_lower / yhat_upper, which at the default confidence_levels=(80, 95) meant shipping the 80% band while the UI's own selector said 95%.

Widest-by-default rather than first-by-accident: a published interval is read as a risk bound, so the conservative choice is right — and it is now a stated rule.

IntervalBand

Bases: NamedTuple

The column pair carrying one confidence level, and which level that is.

winner_counts

winner_counts() -> Counter[str]

How many series each model won — {alias: n} in first-won order.

Both the Summary tile ("LGBM x12, SeasonalNaive x3") and the MLI narrative need this, and both hand-rolled the same dict.get(m, 0) + 1 tally over winning_model_per_series. It is a derived view of this result's own field, so it belongs here — as a :class:collections.Counter, whose most_common() gives the by-count ordering the narrative wants without a second sort key.

Checking a fit's input before the fit

parleml_ml4.forecasting.preflight runs the fit's own input path (the split of history from the known-future frame, the preprocessor that fixes the grid, and align_future_frame) and stops before anything is modelled. A plan can then refuse an off-grid future timestamp, and warn about a known-future regressor that has no future rows or an interval level too few cross-validation scores can calibrate, while a person can still act on it.

preflight_forecast

preflight_forecast(
    X: Union[DataFrame, SeriesFrame], config: ForecastConfig
) -> ForecastPreflight

Run the fit's input path on X under config without fitting a model.

Raises :class:~parleml_ml4.forecasting.schema.FutureFrameError (a short, duplicated, off-grid or blank future frame) and :class:~parleml_ml4.forecasting.schema.SeriesFrameValidationError (no usable grid) exactly where :meth:~parleml_ml4.forecasting.forecaster.Forecaster.fit would.

ForecastPreflight dataclass

ForecastPreflight(
    freq: str,
    n_series: int,
    known_future: tuple[str, ...],
    series_without_future: tuple[str, ...],
    calibration_shortfalls: tuple[
        CalibrationShortfall, ...
    ],
)

What a fit of this input under this config will run on.

calibration_shortfalls

calibration_shortfalls(
    n_series: int, config: ForecastConfig
) -> tuple[CalibrationShortfall, ...]

The requested levels a panel of n_series cannot calibrate under config.

The conformal layer calibrates on the selection windows only (the most recent window is the held-out test, :func:~parleml_ml4.forecasting.splitting.selection_window_count) and pools one score per (series, window).

CalibrationShortfall dataclass

CalibrationShortfall(
    level: int,
    n_scores: int,
    scores_needed: int,
    cv_n_windows_needed: int,
)

A requested interval level the conformal layer cannot calibrate on this panel.

Which models are trivial

"Did this run beat the naive benchmark?" is a question two surfaces ask and neither can answer alone: the UI labels a model a baseline from the served catalogue's family tag, and the run Summary says "beat the baseline" from the per-series winners. They are the same fact, so one module owns it: parleml_ml4.forecasting.router.

  • BASELINE_MODEL_NAMES is the judgement (which models are trivial) and the only hand-written half: naive, seasonal_naive, historic_average, rwd, window_average, seasonal_window_average.
  • BASELINE_ALIASES is derived from the canonical alias table, so the strings that appear as keys of ForecastResult.winning_model_per_series cannot fall out of step with the names.

Both are importable without touching a backend. Import them rather than re-listing the models. The backend's beats_baseline does, counting only the series that were raced (a not_validated weak series carries the run's default label, which won nothing). A test pins the catalogue's family="baseline" tags to BASELINE_MODEL_NAMES.

The set is wider than the two baselines the router always races. Naive and SeasonalNaive join every auto-mode race; the other four race only when the profile calls for them or you pin one. Any of the six winning means the run did not beat the benchmark.

Prediction intervals

enrich_with_intervals

enrich_with_intervals(
    forecast: DataFrame,
    cv_table: DataFrame,
    *,
    levels: Optional[tuple[int, ...]],
    scales: SeriesScales,
    winning_model_per_series: Optional[
        Mapping[Any, str]
    ] = None,
    gap: int = 0,
    floor: Optional[float] = None
) -> IntervalReport

Band a point-only forecast and report what could not be honoured.

THE single implementation behind both Forecaster.predict and FittedForecastPipeline.predict. Columns that already carry analytic intervals (the stats backend) are left untouched, so a natively-banded backend is unaffected. scales is the run's :class:SeriesScales (see :func:add_conformal_intervals).

Empty inputs are not an error — no levels requested, no residuals available, or an empty forecast all mean "nothing to add", and the missing-level report still runs so a caller that asked for bands and got none is told.

floor (:func:~parleml_ml4.forecasting.floor.resolve_floor) is applied last, to every point and every bound — native or conformal — so this is also the one place every published forecast is floored.

IntervalReport

Bases: NamedTuple

The banded frame plus everything a caller must disclose about it.

Both the fit path (:class:~parleml_ml4.forecasting.forecaster.Forecaster) and the serve path (:class:~parleml_ml4.forecasting.pipeline.FittedForecastPipeline) get exactly this from :func:enrich_with_intervals. They differ in one respect only: the fit has a failures dict to write breadcrumbs into and a deployed artefact does not. So this returns the facts; each caller decides where to put the messages.

add_conformal_intervals

add_conformal_intervals(
    forecast: DataFrame,
    cv_table: DataFrame,
    *,
    levels: tuple[int, ...],
    scales: SeriesScales,
    winning_model_per_series: Optional[
        Mapping[Any, str]
    ] = None,
    gap: int = 0,
    floor: Optional[float] = None
) -> ConformalBands

Add conformal -lo-/-hi- bands to forecast.

For every point-only model-alias column in forecast that has holdout residuals in cv_table, add {alias}-lo-{L} / {alias}-hi-{L} for each level in levels. Columns that already carry intervals and columns absent from cv_table are left untouched.

scales (:func:seasonal_naive_scales) scales each residual at its window's cutoff and each forecast row at its origin: a forecast with a cutoff column is a backtest window and is banded from its own cutoff, anything else from the series' last observation. Residuals whose scale is zero or undefined leave the pooled set; a series whose own scale is gets no band and is reported in :attr:ConformalBands.unscaled_series.

When winning_model_per_series (a {unique_id: alias} map) is given, bands are attached only to the per-series winning alias.

gap is the run's cv_gap embargo, needed to label each residual with its true forecast distance — see :func:_residual_steps.

floor is the run's zero floor (:func:~parleml_ml4.forecasting.floor.resolve_floor): the bands written here are raised to it, so a coverage check scores the floored band the run publishes.

Returns a :class:ConformalBands — the banded frame, the levels whose calibration set was too small to place them, which series those levels failed on, and the series no band could be placed on. All of it comes out of this one pass, so a caveat cannot describe a residual set the bands were not drawn from, and a caller cannot take the bands without being handed it.

ConformalBands

Bases: NamedTuple

What :func:add_conformal_intervals produced, and what it could not honour.

The two travel together on purpose. A band whose calibration set was too small to place its quantile is still emitted — a conservative interval beats none — so the only thing separating the reader from a number that looks calibrated is :attr:uncalibrated_levels. Returning it alongside the frame makes it impossible to band without being handed the caveat.

Every conformal score and band is expressed in each series' seasonal-naive error — the MASE denominator — at its origin:

seasonal_naive_scales

seasonal_naive_scales(
    history: DataFrame,
    cutoffs: DataFrame,
    *,
    season_length: int
) -> SeriesScales

:class:SeriesScales of history at every cutoffs origin and at its end.

history is the long training frame [unique_id, ds, y, ...] the backtest ran on; cutoffs carries [unique_id, cutoff] (a CV table will do — duplicates are dropped). Each origin's scale is computed the way utilsforecast computes the MASE scale — the history truncated to ds <= origin, the seasonal-naive forecast y[t - season_length], the MAE per (series, origin) — so a residual divided by it is that window's MASE contribution, and the forecast origin is each series' last observation.

SeriesScales

Bases: NamedTuple

Each series' seasonal-naive scale — what its conformal scores are divided by.

The MASE denominator (the scale :func:utilsforecast.losses.mase divides by): the mean absolute error of the in-sample seasonal-naive forecast over a series' history up to an origin. NaN where the history before the origin holds no complete seasonal pair, and zero on a history that never moved — either way that series has no scale there and cannot be put on the pooled one.

Next: Time grid · Forecasting guide