Compute lag features across all series, or within groups of series, using SQL-style RANGE semantics.Most lag transforms in
mlforecast are computed independently per
series: a rolling mean for series A only ever sees A’s own
history. That works well when every series carries enough signal on its
own, but it breaks down in two common situations:
- Short-history series. A newly launched product, store, or SKU has too little history for per-series rolling statistics to stabilise.
- Cross-series signals. Total demand across a brand, region, or category at a given timestamp is often a stronger feature than any one series’ lag.
global_=True
(across all series) or groupby=["col", ...] (within a static-feature
group) to any rolling, expanding, seasonal, or exponentially weighted
transform, you ask mlforecast to compute the statistic over a bucket
of series aggregated by timestamp. The result is a single value per
timestamp per bucket that every series in the bucket then receives as a
feature.
Data setup
brand
column so we have something to group by.
Global features with global_=True
Set global_=True on any built-in lag transform to compute the
statistic across all series, aggregated by timestamp. Every series
receives the same value at a given timestamp.
global_rolling_mean_lag1_window_size7 is identical for
every unique_id at a given ds: it’s the rolling mean of the
pooled observations across all series, lagged by one day. The
feature name is automatically prefixed with global_ to make the
pooling explicit.
Group features with groupby=[...]
Use groupby to compute the statistic within each level of one or
more static features. Series in the same group share the feature value
at each timestamp; series in different groups get different values.
Any column used in groupby must be declared as a static feature when
fitting.
A share one rolling-mean value; series within
brand B share another. The column name is prefixed with
groupby_brand_ to record which static feature drove the pooling.
RANGE semantics: staggered series and gaps
Pooled transforms useRANGE BETWEEN ... PRECEDING semantics, the
same model SQL window functions use. The window is defined by
timestamp distance, not row position, and only actual
observations are aggregated — no synthetic zeros are injected for
series that haven’t started yet.
Concretely, with RollingMean(window_size=2, global_=True) over the
data below:
unique_id | ds | y |
|---|---|---|
| a | 1 | 1.0 |
| a | 2 | 2.0 |
| a | 3 | 3.0 |
| b | 2 | 20.0 |
| b | 3 | 30.0 |
b starts at ds=2; it does not contribute a phantom zero
at ds=1. At each timestamp the window looks back over the last two
days of real values across all series, applied at lag 1.
validate_data
section below.
min_samples in pooled mode
There’s an important semantic divergence between local and pooled modes:
- Local mode (per series):
min_samplesis capped atwindow_sizebycoreforecast. It controls how many non-NaN values in the window the series needs. - Pooled mode (
global_=Trueorgroupby=...):min_samplescounts the total non-NaN observations across all series in the bucket, with no capping atwindow_size.
RollingMean(window_size=1, min_samples=2, groupby=['brand']) only
produces a value when at least two series in the brand contributed an
observation in the window — useful to suppress noise from sparsely
populated groups.
The default when min_samples=None also differs by mode. Everywhere
else it defaults to window_size, but in local partition mode
(partition_by without global_/groupby, covered below) it defaults
to 1: the window spans window_size calendar steps while only
same-partition observations count toward min_samples, so requiring a
full window is rarely attainable —
RollingMean(7, partition_by=['promo']) on a panel with interleaved
promo days would otherwise be almost entirely NaN. The default of 1
matches SQL RANGE window semantics, where the result is NULL only for
empty windows. When partition_by is combined with global_ or
groupby, the default remains window_size, counted across all series
in the (group, partition) bucket — pass min_samples explicitly if you
want a different threshold.
Aggregating rows per timestamp with time_agg
By default a pooled transform treats every observation as an individual
sample: RollingMean(window_size=7, groupby=['brand']) pools all rows
of the brand that fall in the window and averages them, weighting a
timestamp by how many series reported on it. Sometimes you instead want
to first collapse all rows sharing a timestamp into a single value — for
example the brand’s daily total — and then apply the transform over
that per-timestamp series.
time_agg does exactly that. Set it to one of 'sum', 'count',
'mean', 'min', or 'max' and each (bucket, timestamp) is reduced
to one value before the rolling/expanding/seasonal/EWM statistic runs:
time_agg='sum'— rolling mean of the brand’s daily sums (total demand)time_agg='count'— e.g. expanding max of the number of active series per daytime_agg='max'— rolling max of the daily peak across the brand
time_agg requires global_=True or groupby=[...] (optionally
combined with partition_by). It is rejected in local or
partition_by-only mode, where each (bucket, timestamp) already has a
single row and the aggregation would be a no-op. With time_agg=None,
each observation remains an individual pooled sample.
ExponentiallyWeightedMean is the exception: its time_agg defaults to
'mean', and None is not accepted.
- With
time_agg,min_samplescounts observed timestamps in the window (each timestamp contributes at most one aggregated value), not rows.RollingMean(window_size=3, min_samples=3, groupby=['brand'], time_agg='sum')needs three distinct timestamps of history, regardless of how many series reported on each. RollingMean(..., time_agg='mean')differs fromRollingMean(...)withouttime_agg: the former is an unweighted mean of the per-timestamp means, the latter a row-weighted pooled mean (they coincide only when every timestamp has the same number of observations). OnExponentiallyWeightedMean,time_agg='mean'is the default because pooled EWM consumes each timestamp’s bucket-aggregate mean exactly once.
Disabling validation: the validate_data=False warning
Pooled transforms are correctness-sensitive to timestamp gaps because
they rely on the actual timestamps when computing RANGE windows. If
you bypass validation (validate_data=False) and your data has gaps,
the features will be silently wrong.
To make this hazard explicit, mlforecast emits a UserWarning when
you combine validate_data=False with any pooled transform:
Pooling with partition_by
partition_by=[col, ...] splits a pooled bucket further along a
dynamic column — typically a feature whose value changes over time
(e.g. promo, regime, store_format flips). Each unique combination
of partition values gets its own bucket; rolling/expanding statistics
are computed per bucket.
Unlike groupby, partition_by columns do not need to be static.
They can vary across timestamps for the same unique_id. At prediction
time you must supply their future values via X_df.
Three combinations are supported:
| Combination | Bucket key |
|---|---|
partition_by=[...] alone | (unique_id, *partition_values) — local partition, one bucket per series-partition pair |
global_=True, partition_by=[...] | (*partition_values) — nonlocal, aggregates across all series sharing the same partition values |
groupby=[...], partition_by=[...] | (*group_values, *partition_values) — nonlocal, aggregates within each group, separately per partition |
global_ and groupby (which are mutually exclusive),
partition_by composes with either one — or stands alone.
Let’s add a time-varying promo column to our panel and walk through
the three combinations.
Local partition: per-(id, partition_vals) buckets
Whenpartition_by is used alone (no global_, no groupby), each
(unique_id, partition_value) combination becomes its own bucket. The
rolling window is computed within that bucket only — so a series sees a
different rolling history depending on which promo regime it is
currently in.
partby_promo_ to indicate the partition.
Each row’s feature is the 7-day rolling mean of y lagged by 1,
restricted to rows of the same series with the same promo value.
Global + partition_by: cross-series aggregates within each partition
Combine with global_=True to aggregate across all series, but
separately for each promo value:
promo=0 at a given timestamp sees the same
global_partby_promo_... value; every series with promo=1 sees a
different (also shared) value.
Groupby + partition_by: group aggregates within each partition
Combine with groupby=[...] to aggregate per group (e.g. per brand),
separately for each partition value:
(brand, promo). There are at most
n_brands × n_promo_values buckets.
Dynamic partition values at prediction time
Because partition columns are not static, you must supply their future values when forecasting. Pass anX_df containing unique_id, ds,
and every partition column for every horizon step:
- If
X_dfintroduces a partition value never seen during fit, mlforecast creates a fresh bucket on the fly. The first few predictions for that bucket may beNaNif its history is shorter thanwindow_size/min_samples. - Subset prediction (
predict(ids=[...])) is rejected when nonlocalpartition_byis in play: the missing series would still contribute to the bucket aggregates, so partial subsets are ambiguous. - Partition columns are automatically excluded from
static_featureseven whenstatic_features=None. They must come fromX_dfat predict time.
Understanding ordinals and parent calendars
Apartition_by bucket only contains the rows where the partition value
is active for a series. When the partition is sparse — e.g. promo=1 is
only on at timestamps [1, 3, 5] while the parent calendar (the global
or group scope) is [1, 2, 3, 4, 5] — the bucket’s ordinals come from
the parent calendar:
- Bucket has observations at parent positions
[0, 2, 4], not[0, 1, 2]. RollingMean(window_size=2)at parent position 4 looks back to positions[3, 4]. The bucket has no observation at position 3, so only position 4 contributes to the window.
RANGE BETWEEN ... semantics across gaps: the window
is defined in time, not in number of rows. Without parent-calendar
ordinals, a partition with gaps would silently collapse to row-based
semantics, mixing observations across non-adjacent timestamps.
Aligned-end requirement
All pooled modes —global_, groupby, and partition_by (including
local, alone) — require every series to end at the same timestamp.
This is checked at fit and predict time and raises if violated:
partition_by shares this requirement because parent calendars
(per-id, for local mode) must advance in lockstep during recursive
prediction. If your series have ragged ends, pre-align them (pad to a
common last timestamp, or drop series that end early) before fitting.
Exponentially Weighted Mean with partitions
ExponentiallyWeightedMean with partition_by has subtly different
semantics from a regular EWM:
- The decay is applied across the bucket’s observed timestamp aggregates (one contribution per timestamp, regardless of how many rows the aggregate covered).
- Timestamps where the partition bucket has no observation are skipped — they do not contribute to the running EWM and do not advance the decay step.
UserWarning when you construct an
ExponentiallyWeightedMean with partition_by to make this explicit.
keep_last_n and pooled history
keep_last_n controls how much history is carried into the recursive
prediction loop. It applies to pooled transforms too, per state (one
state per (mode, groupby, partition_by) combination):
- A state whose transforms are all finite-window —
Lag, anyRolling*orSeasonalRolling*, andOffset/Combinebuilt from them — is trimmed to its lastmax(keep_last_n, W_state)parent-calendar ordinals, whereW_stateis the state’s widest window. The dropped prefix can never enter a window, so predictions are unchanged. - A state containing any unbounded transform —
Expanding*orExponentiallyWeightedMean— keeps its full history. Unlike the local (coreforecast) path, pooled expanding/EWM carry no running accumulator: they recompute over the entire aggregate vectors at every step, so trimming them would change predictions.
keep_last_n is set explicitly or inferred — when
you leave it unset it is inferred as the largest window across all
transforms — mirroring how the per-series arrays used by local
transforms are trimmed.
Divergence from local transforms
Pooled trimming is floored at the state’s widest window (W_state).
A local rolling transform survives an explicit keep_last_n smaller
than its window because coreforecast keeps a separate per-transform
window buffer. Pooled mode has no such buffer — the per-timestamp
aggregates are the buffer — so a keep_last_n below a pooled window
is raised to that window for the affected state, keeping predictions
correct. When keep_last_n is left to be inferred it already equals the
largest window, so the floor never changes anything.
Occurrence lookups with LookupLag
LookupLag returns the target from the previous matching occurrence
within each (unique_id, partition_by...) bucket, regardless of the
calendar gap between occurrences. This is useful for moving events —
e.g. “last year’s Easter value” — where the relevant previous target is
not a fixed calendar lag.
partition_by is required and defines the buckets. Because it is a
pooled transform, the partition columns may change over time and must be
supplied via X_df at prediction. Since the lookup is computed inside
mlforecast, it stays aligned with any target transforms
(e.g. LocalStandardScaler).
Constraints to keep in mind
global_=Trueandgroupby=[...]are mutually exclusive on the same transform. Use two separate transforms if you want both feature families.partition_bycomposes with either one, or stands alone.- Columns in
groupbymust be declared instatic_featureswhen you callfit/preprocess. Columns inpartition_bymust not be — they are dynamic and supplied viaX_dfat prediction. min_samples=0in pooled mode produces NaN for timestamps with no observations in the window and triggers a warning at construction time — prefermin_samples=1if you want “as soon as there is any observation”.- All pooled modes —
global_,groupby, andpartition_by(including local, alone) — require series to end at the same timestamp. - Subset prediction (
predict(ids=[...])) is rejected when nonlocal pooled transforms are in play: omitted series would still contribute to the bucket aggregates. - All supported rolling, expanding, seasonal-rolling, and
exponentially weighted transforms accept
global_,groupby, andpartition_by.OffsetandCombinedelegate to their wrapped transforms. - Performance:
RollingQuantile,ExpandingQuantileand theSeasonalRolling*transforms have no aggregate-cache fast path in pooled modes. They fall back to a row-level pass whose cost grows withunique timestamps × bucket rowsat fit, and aggregates are rebuilt at every recursive prediction step. For large panels prefer the mean/std/min/max/EWM transforms, which use cached per-timestamp aggregates. - Memory: finite-window pooled states
(
Lag/Rolling*/SeasonalRolling*) are trimmed underkeep_last_njust like the per-series arrays (see thekeep_last_nand pooled history section above); a state containing anExpanding*/ExponentiallyWeightedMeantransform keeps the full training history because it recomputes over all of it at predict. Eachpredictcall also backs up the pooled state per model to isolate recursive mutations. Expect higher peak memory with unbounded pooled transforms on large panels. time_agg('sum'/'count'/'mean'/'min'/'max') pre-aggregates rows sharing a timestamp within each bucket before the transform runs; it requiresglobal_orgroupbyand counts observed timestamps formin_samples. All pooled-capable transforms support it, including the quantile and seasonal ones (via the row-level path).
End-to-end example
Pooled transforms behave like any other lag transform in the fullMLForecast lifecycle: fit, predict, and cross_validation all
carry the pooled features through automatically.
Where to next
- The general Lag transformations guide
covers the built-in transforms,
Combine,Offset, and custom numba-based transforms. - The
mlforecast.lag_transformsAPI reference documents every parameter, includingglobal_,groupby, andmin_samples, for each transform class.

