Time grid¶
parleml_ml4.forecasting.grid is the single authority for "what is this panel's time axis?". It
is the companion to parleml_ml4.forecasting.season, which owns
the other quantity a forecast run, and everything that reports on one, must agree on.
In short
infer_panel_freqanswers "what cadence does this panel have?" the way a fit does:pd.infer_freqper series, then a majority vote.resample_panelrolls an irregular panel (an event log) onto a grid you choose, which is what makes it forecastable.TimeGrid.resolvealways answers, falling back to an ordinal axis when no calendar can be inferred. Only diagnostics use that axis; a fit raises instead.- Pass
TimeGrid.season_freq, neverTimeGrid.step, toresolve_season_length.
It is public and importable on its own (from parleml_ml4.forecasting.grid import TimeGrid,
resample_panel) and pulls in none of the fit: no backend, no statsforecast, no LightGBM, no
AutoGluon. So a pre-fit surface can ask what grid a fit would use without running one. A
diagnostic that had to run the preprocessor and catch its exception could not tell "no
signal" from "could not start".
Three things live here:
normalize_freq/infer_panel_freq: per-seriespd.infer_freqplus a majority vote, the same rule the preprocessor applies, exposed so it can be asked.resample_panel: roll a panel onto a coarser grid. This makes an irregular event log forecastable at all, and it is the fix every "no regular spacing" error points at.TimeGrid/TimeGrid.resolve: the ladder that always produces an answer, falling back to an ordinal (observation-order) axis when no calendar can be inferred.
The ordinal axis is for diagnostics only
A fit produces a deployable model whose forecasts carry real timestamps. Dating those
on a synthetic grid would put false dates into the result frame, exports and MLflow.
So ForecastPreprocessor raises on an irregular panel, naming resample_to as the
fix, while the zero-shot Signal Check degrades to an ordinal answer and labels it. See
When there is no regular grid.
season_freq, not step
In ordinal mode TimeGrid.step is a spacing with no calendar meaning. Handing it to
resolve_season_length would mint a seven-step "week" out of a unit that is not a
day, and that number is the MASE denominator and the SeasonalNaive baseline the
verdict is measured against. Pass TimeGrid.season_freq instead. It is None in
ordinal mode, so no period is probed and the default season is 1 (non-seasonal MASE
against a random-walk baseline).
grid ¶
Time-grid resolution — the single source of truth for "what is this panel's time axis?"
Public API, deliberately, for the same reason :mod:parleml_ml4.forecasting.season is: the
frequency is a quantity a forecast run and everything that talks about a forecast run must
agree on. A pre-fit surface (the launch wizard's profile endpoint, the zero-shot Signal
Check) needs to ask "what grid would a fit use here?" and get the fit's own answer, without
importing a backend — so the question lives here rather than as a private method of
:class:~parleml_ml4.forecasting.preprocessor.ForecastPreprocessor, where the only way to ask it was
to run a transform and catch the exception. Routing on a caught exception is how a
diagnostic ends up unable to distinguish "no signal" from "could not start".
Three things, in dependency order:
- :func:
normalize_freq/ :func:infer_panel_freq— what the preprocessor has always done (per-seriespd.infer_freq+ majority vote), lifted out unchanged so it can be asked. - :func:
resample_panel— roll a panel onto a coarser grid. This is what makes an irregular event log forecastable at all, and it is the escape hatch every error message about an uninferrable frequency should point at. - :class:
TimeGrid/ :meth:TimeGrid.resolve— the ladder that always produces an answer, falling back to an ordinal (observation-order) axis when no calendar can be inferred.
The ordinal fallback is for diagnostics only. A fit produces a deployable model whose
forecasts carry real timestamps; dating those on a synthetic grid would be a lie that flows
into the result frame, exports and MLflow. :class:ForecastPreprocessor therefore still
raises on an irregular panel — it just names :attr:~parleml_ml4.config.forecasting.ForecastConfig.resample_to
as the fix now. See docs/internal/ml4/forecast_time_grid_redesign.md §3.1.
TimeGrid
dataclass
¶
The time axis a panel is modelled on.
is_ordinal
property
¶
True when the calendar was discarded in favour of observation order.
season_freq
property
¶
The alias season resolution may use — None in ordinal mode.
The one place :attr:step must not be used. An observation index has no
calendar cycle, so handing the synthetic "D" to
:func:~parleml_ml4.forecasting.season.resolve_season_length would mint a seven-step
"week" out of a unit that is not a day — and that number becomes the MASE
denominator and the SeasonalNaive baseline the verdict is measured against.
None makes the profiler probe no candidate periods, so only an explicit
caller-supplied season_length can set one and the default is 1. That is the
honest answer rather than a degraded one: at m = 1 SeasonalNaive is the
random-walk Naive baseline and MASE is its standard non-seasonal form, scaled by the
mean absolute first difference.
resolve
classmethod
¶
resolve(
df: DataFrame,
*,
resample_to: Optional[str] = None,
pinned_freq: Optional[str] = None,
unique_id_col: str = "unique_id",
ds_col: str = "ds"
) -> GridResolution
Decide the axis for df. Always succeeds.
The ladder, declared grids before inferred ones:
resample_to— a rollup defines the grid, so nothing is inferred.pinned_freq— the analyst asserted the grid.- a frequency :func:
infer_panel_freqcan read off the timestamps. - otherwise ordinal — the calendar is replaced by observation order.
Branch 0 must precede branch 2 or the two mechanisms collide on exactly the data they exist for: an irregular panel with a rollup requested would fail inference, fall to ordinal, and have its timestamps overwritten before the preprocessor could bucket them — turning "weekly totals" into "totals of seven consecutive rows".
GridResolution
dataclass
¶
GridResolution(
grid: TimeGrid,
frame: DataFrame,
real_timestamps: Optional[
dict[tuple[str, Timestamp], Timestamp]
],
)
What :meth:TimeGrid.resolve decided, and the frame to model on.
A named result rather than a tuple: two of the three fields are meaningful in only one mode, and positional unpacking hides that from the reader.
to_real ¶
The observation stamp stands for — itself on a calendar grid.
The one accessor for the ordinal mapping, so no caller has to know its key shape
or remember that it is None outside ordinal mode.
normalize_freq ¶
Canonicalise a pandas offset alias, preserving any anchor suffix.
'M' → 'ME', 'W-MON' → 'W-MON', None/'' → unchanged. Only the
deprecated spellings in :data:_FREQ_ALIAS_MAP are rewritten; everything else is
returned as given, because pandas is the authority on what an alias means and this
function is not trying to be a second one.
infer_panel_freq ¶
infer_panel_freq(
df: DataFrame,
*,
unique_id_col: str = "unique_id",
ds_col: str = "ds"
) -> Optional[str]
Majority-vote frequency across the panel's series, or None if none can be inferred.
Each series is asked independently via :func:pandas.infer_freq (series shorter than
:data:_MIN_POINTS_TO_INFER abstain rather than guess), and the alias held by the most
series wins, ties broken by name so the answer is deterministic.
None means no series has a regular spacing — an event log, an entity table, or
genuinely irregular sampling. It is a routing answer, not an error: callers decide
whether that warrants a rollup, an ordinal axis, or a refusal.
resample_panel ¶
resample_panel(
df: DataFrame,
*,
freq: str,
target_agg: str = "sum",
unique_id_col: str = "unique_id",
ds_col: str = "ds",
y_col: str = "y",
exog_cols: Sequence[str] = ()
) -> DataFrame
Roll every series onto the freq grid, aggregating within each bucket.
The transformation that turns an irregular event log into a series: "one row per
signup" becomes "signups per week". Buckets with no observation come back as NaN
rows, which the caller's fill_strategy then handles exactly as it handles any other
gap — resampling deliberately does not invent values.
target_agg picks the target's reduction from
:data:~parleml_ml4.config.forecasting._VALID_RESAMPLE_AGGS; it is the
analyst's call because it encodes what the quantity is. sum is right for flows
(sales, signups, counts) and mean for levels (temperature, price, inventory) — and
getting it wrong rescales the forecast without changing its shape, so a scale-free
metric like MASE will not catch it.
Covariates are not the analyst's call and are aggregated by dtype
(see :func:_aggregation_for). Columns absent from df are skipped, so the same
function serves the history frame (which has a target) and the future-regressor frame
(which does not).
Next: Forecaster · Forecasting guide