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.seasonandparleml_ml4.forecasting.gridare importable on their own, so a pre-fit surface can ask what season and grid a fit would use.parleml_ml4.forecasting.resultowns the forecast column-name convention: use its readers instead of parsing-lo-/-hi-columns yourself.parleml_ml4.forecasting.preflightchecks 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
¶
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
¶
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 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 ¶
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 ¶
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 usedB→7 andT/S→1 while the stats path usedB→5 andT/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-gatedargmaxoverseasonality_strengthslets 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_seasonalitiesfrom 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 ¶
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 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 ¶
Return the model-alias columns of a forecast / cv-table frame
(drops reserved metadata + -lo-/-hi- interval siblings).
model_columns_scores ¶
Return the model-alias columns of a cv_scores frame
(one row per (unique_id, metric)).
interval_levels ¶
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 ¶
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
¶
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_NAMESis the judgement (which models are trivial) and the only hand-written half:naive,seasonal_naive,historic_average,rwd,window_average,seasonal_window_average.BASELINE_ALIASESis derived from the canonical alias table, so the strings that appear as keys ofForecastResult.winning_model_per_seriescannot 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