Skip to content

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_freq answers "what cadence does this panel have?" the way a fit does: pd.infer_freq per series, then a majority vote.
  • resample_panel rolls an irregular panel (an event log) onto a grid you choose, which is what makes it forecastable.
  • TimeGrid.resolve always 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, never TimeGrid.step, to resolve_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-series pd.infer_freq plus 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-series pd.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

TimeGrid(kind: GridKind, step: str)

The time axis a panel is modelled on.

is_ordinal property

is_ordinal: bool

True when the calendar was discarded in favour of observation order.

season_freq property

season_freq: Optional[str]

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:

  1. resample_to — a rollup defines the grid, so nothing is inferred.
  2. pinned_freq — the analyst asserted the grid.
  3. a frequency :func:infer_panel_freq can read off the timestamps.
  4. 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

to_real(unique_id: Any, stamp: Timestamp) -> Timestamp

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

normalize_freq(freq: Optional[str]) -> Optional[str]

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