Skip to content

Temporal encoders (lags)

When your rows are timestamped events grouped by an entity — transactions per account, clicks per user, claims per policy — the strongest signal is often how this event compares to that entity's recent history. The temporal encoders build those per-entity look-backs: lags, exponentially-weighted moving averages, rolling aggregates, recency, and velocity.

In short

  • Temporal features are opt-in: set cfg.temporal.enabled = True, an event-time column, the entity keys and the numeric columns to look back on.
  • Every look-back is per entity: lag 1 is the previous event of the same account, never the row above it in the table.
  • Lags, EWMA and rolling windows count prior events; time_windows and _TSLAST measure wall-clock time.
  • A row never sees its own value (min_lag = 1), and a frozen training tail gives a serve-time row the same look-back it had in training.
  • Column count grows fast with lags, ewma_halflives, aggregates and aggregate_windows. Keep the lists short on wide tables.

Not the forecasting subsystem

These are supervised FE features for a tabular classification/regression task — one engineered column per row, consumed like any other feature. They are distinct from ml4's forecasting subsystem, which predicts a future series.

Forecasting builds its own lag / rolling / EWM features, but through mlforecast inside the tree backend, where the data is a regular panel and the features feed a recursive model. The two cannot share an implementation: these encoders scatter results back to the caller's row order for a scikit-learn ColumnTransformer, which mlforecast's panel transforms cannot do.

What "N events ago" means

Lags are per entity, not global.

When lags=[1] and entity_columns=["account_id"], lag 1 for a row is the previous event of that same account, ordered by event_time_column. Events from other accounts never enter the look-back, even if they sit immediately above the row in the raw table.

Setting Interpretation of lag (k)
entity_columns=["account_id"] The (k)-th prior event of that account
entity_columns=["customer_id", "device_id"] The (k)-th prior event of that (customer, device) pair
entity_columns=[] / no keys resolved The (k)-th prior event in the whole table as one series (rarely desirable)

The same per-entity rule applies to EWMA, rolling aggregates, recency (_TSLAST), and velocity (_CNT_T…). Clock-time windows (time_windows) still count only that entity's prior events inside the trailing horizon.

Worked example

Fraud scoring: each row is a card transaction.

transaction_id account_id event_ts amount is_fraud
T1 A 10:00 20 0
T2 B 10:02 15 0
T3 A 10:05 50 0
T4 A 10:20 900 ?

Configuration sketch:

cfg.temporal.enabled = True
cfg.temporal.event_time_column = "event_ts"
cfg.temporal.entity_columns = ["account_id"]
cfg.temporal.lag_features = ["amount"]
cfg.temporal.lags = [1, 2]
cfg.temporal.min_lag = 1
cfg.temporal.ewma_halflives = [3.0]
cfg.temporal.aggregates = ["mean", "max"]
cfg.temporal.aggregate_windows = [2]
cfg.temporal.time_windows = ["1h"]
cfg.temporal.add_time_since_last = True

For T4 (account A, amount 900), history is taken only from A's prior rows T1 → T3. Account B's T2 is ignored for A's features:

Feature Value for T4 Why
amount_LAG_1 50 Previous A event (T3)
amount_LAG_2 20 Two A events back (T1)
amount_EWMA_3 ~decay-weighted mean of A's past amounts EWMA over A's shifted series
amount_ROLL_MEAN_W2 35 Mean of the last 2 prior A amounts (20, 50)
amount_ROLL_MAX_W2 50 Max of those two
event_ts_TSLAST 900 seconds Time since T3
event_ts_CNT_T1h 2 Count of A's prior events in the trailing hour (T1, T3)

If lag 1 were global (table order), T4's previous row would be T3 still — but T3's previous row would wrongly become B's T2. With per-entity grouping, T3's lag 1 is T1.

Enabling temporal features

Temporal features are opt-in. The single temporal_lags step (GLOBAL, UNSUPERVISED_FITTED) is configured through cfg.temporal (TemporalEncodersConfig):

cfg.temporal.enabled = True
cfg.temporal.event_time_column = "event_ts"      # None → first data.time_domain col
cfg.temporal.entity_columns = ["account_id"]     # None → [data.key1, data.key2]
cfg.temporal.lag_features = ["amount"]           # None → all data.num_features

If event_time_column resolves to nothing (no explicit column and no time_domain), the step is skipped with a warning. When entity_columns is left None, the encoder groups by the declared identity keys key1 / key2; an empty entity list treats the whole frame as one series.

These three are shielded from feature selection — while the section is on

Instrumental feature selection ranks a column on its worth as a feature, and _temporal_steps reads all three by name, so a pruned entity_columns / event_time_column breaks the step and a pruned lag_features name silently takes its whole <col>_LAG_* / _EWMA_* / rolling family with it. The planner therefore rescues them from the prune — but only when temporal.enabled is True, because that is the only state in which anything reads them. With the section off they are ordinary columns and the prune treats them as such.

In the UI these three live under Column roles, not the template

event_time_column, entity_columns and lag_features name specific dataset columns, so they cannot belong to a dataset-agnostic template. The schema marks them x-hidden (schema._COLUMN_BOUND_FIELDS) and the app authors them on the experiment's Column roles surface instead. The template's Temporal encoders pane owns everything else — the master switch, the lag / EWMA / window shapes, dropout — which is why it shows no column pickers.

Parameter reference

Full defaults also live in TemporalEncodersConfig.

Scope and switches

Parameter Default Meaning
enabled False Master switch. Off ⇒ the step is not planned.
event_time_column None Event timestamp. None ⇒ first data.time_domain column.
entity_columns None Grouping keys for look-backs. None ⇒ [key1, key2] when set.
lag_features None Numeric columns to lag / EWMA / roll. None ⇒ all data.num_features.

Lags — previous values of a feature

Parameter Default Meaning
lags [1, 2, 3, 7, 14] Offsets in prior events of the entity (not wall-clock units). Emits <col>_LAG_<k>. Sorted and deduped.
min_lag 1 Leakage guard. Lags strictly below this are dropped. 1 forbids using the current row's own value; 0 would allow it and is almost never wanted.

Default offsets cover immediate history (1–3) and weekly-shaped recency (7, 14) when entities tend to produce roughly one event per day. On bursty event logs, prefer denser short lags plus velocity windows rather than large event-count lags alone.

EWMA — smoothed recent level

Parameter Default Meaning
ewma_halflives [3.0, 7.0, 30.0] Half-lives in lag units (events). One column per half-life per lag feature: <col>_EWMA_<hl>.

Short half-lives react quickly to a sudden spike; long ones describe the entity's baseline. Computed on the already-shifted (leak-safe) series.

Rolling aggregates — stats over the last N prior events

Parameter Default Meaning
aggregates ["mean", "min", "max", "std"] Stats over each window. Full menu: mean, min, max, median, sum, std, skew, kurtosis.
aggregate_windows [7, 14, 30] Window sizes in event count (not clock time). Emits <col>_ROLL_<AGG>_W<w>.
min_periods 1 Minimum prior events before a rolling value is defined; fewer ⇒ NaN / fill_value.

Feature-count explosion

Column count scales as len(lag_features) × (len(lags) + len(ewma_halflives) + len(aggregates)×len(aggregate_windows)) plus a few entity-level recency/velocity columns. Keep aggregate_windows small (≤ ~5) on wide tables.

Velocity and recency — entity-level, clock-time

These are not per lag feature. They are emitted once per entity/time axis and are comparatively cheap.

Parameter Default Meaning
time_windows ["1h", "24h", "7d"] Trailing pandas offset strings. Emits <time>_CNT_T<window> = count of the entity's prior events in that window (closed='left').
add_time_since_last True Emits <time>_TSLAST = seconds since the entity's previous event.

Contrast:

Knob Unit of look-back Typical question
lags / aggregate_windows N events of this entity "What was the last amount?" / "Mean of last 7 txns?"
time_windows / _TSLAST Wall-clock time "How many events in the last hour?" / "How long since the last one?"

Missing history and regularization

Parameter Default Meaning
fill_value None Value for look-backs that reach past the start of an entity's history. None keeps NaN (preferred for LightGBM / XGBoost / CatBoost). Use a number (e.g. 0.0) for estimators that cannot ingest NaN.
dropout_mode "off" Stochastic lag-column dropout at fit: "off", "dependent", or "independent".
dropout_prob 0.0 Per-column drop probability when dropout is on; 0.1–0.2 is typical.

Dropout takes no seed of its own: coins are derived from the run's execution.seed. Changing that seed changes which lag columns are dropped; replaying the same seed reproduces them. See Reproducibility & seeds.

Leaving early-history gaps as NaN is deliberate: trees can treat "no history yet" as its own informative state for new entities.

The feature families

Emission order is lags → EWMA → rolling → recency → velocity.

Family Config Output pattern Example
Lags lags <col>_LAG_<k> amount_LAG_1
EWMA ewma_halflives <col>_EWMA_<hl> amount_EWMA_7
Rolling aggregates × aggregate_windows <col>_ROLL_<AGG>_W<w> amount_ROLL_MEAN_W7
Recency add_time_since_last <time>_TSLAST event_ts_TSLAST
Velocity time_windows <time>_CNT_T<window> event_ts_CNT_T24h
cfg.temporal.aggregates = ["mean", "std"]
cfg.temporal.aggregate_windows = [7, 30]         # N-event windows
cfg.temporal.ewma_halflives = [7.0, 30.0]
cfg.temporal.time_windows = ["1h", "24h"]        # trailing clock windows
cfg.temporal.add_time_since_last = True

Event-time ordering

Within each entity the frame is sorted by the event-time column (a stable mergesort), lags are computed as grouped shifts, and EWMA / rolling run per-group before the result is realigned to the original row order. You do not need to pre-sort your data — the encoder orders each entity internally.

Datetime resolution

Velocity windows are evaluated in the time column's own datetime64 resolution, so a far-future sentinel such as 9999-12-31 or an archival date centuries back counts correctly.

Details: why the column's own resolution matters

pandas 2 preserves whatever resolution the source used, and parquet / arrow hand back [s] / [ms] / [us] columns routinely, while only [ns] can represent 1677-09-21 … 2262-04-11. So a datetime64[s] column may legitimately hold 9999-12-31 (the usual ERP "no end date" sentinel) or an archival date centuries back. Forcing such a column to nanoseconds would wrap those instants silently: the entity's events would stop being in ascending order, and the counts, computed by binary search over that order, would come out wrong with no error raised. Reading the column's own resolution, and converting the window down into it, keeps them right.

The window is floored to whole ticks of that resolution, which is exact for a left-closed window: a 500ms window over a second-resolution column counts nothing, because such a column has no events half a second apart. The rules are shared with the unsupervised epoch axis: one implementation, parleml_ml4._epoch.

Timezones: absolute instants, applied not stripped

The event-time column is normalised once, to tz-naive UTC instants, before anything sorts it, differences it, or reads its resolution. The offset is applied, never stripped: 10:00+01:00 and 10:00+02:00 are an hour apart, not simultaneous.

Details: calendar encoders, mixed offsets and DST

Applying the offset is the deliberate opposite of the calendar encoders, which keep the clock face as written because _HOUR is about local human behaviour. Recency and velocity are elapsed-time quantities, so they need the instant.

Normalising is also what lets a mixed-offset column work at all. Two different UTC offsets in one column (a DST-varying export, ordinary data rather than dirt) is the one shape pandas can only type as object, and neither .dt arithmetic nor a resolution read exists on an object column. Without normalising, _TSLAST and the velocity windows would fail on such a column while plain lags on the same frame work. Across the European DST switch, 23:00+01:00 → 03:30+02:00 is a real 3.5-hour gap, not the 4.5 hours the two written clock faces suggest, and a 4h window straddles it. The serve batch is put on the same axis before it is joined to the frozen training tail, so the two concatenate as one datetime column rather than collapsing back to object.

Leak-safety

Two mechanisms make these features safe:

  1. min_lag (default 1) — the minimum look-back offset. A row never sees its own value: lags below min_lag are dropped, and the EWMA / rolling series are computed on the already-shifted signal. Velocity counts use a strictly-prior (side="left", closed="left") window, so the current event is never counted. Setting min_lag = 0 would include the current event and is warned against.
  2. A frozen training tail — because the step is UNSUPERVISED_FITTED, at fit time it remembers, per entity, the last few observations (the maximum look-back any configured feature needs). At transform time that frozen tail is prepended to the serve batch, features are computed over the combined time-ordered frame, and only the serve rows are returned. So a single row scored at serve time gets exactly the same look-back it would have gotten in training — no cold-start discontinuity, no peeking at other serve rows.

Practical setup checklist

  1. Set identity keys (data.key1 / key2, or explicit entity_columns). Without them, look-backs collapse to one global series.
  2. Ensure an event-time column exists (event_time_column or time_domain).
  3. Prefer an explicit lag_features list over lagging every numeric column.
  4. Start with defaults; shrink aggregate_windows / aggregates if the FE matrix balloons.
  5. Keep fill_value=None for tree models; set a numeric fill only when required.
  6. Leave dropout_mode="off" unless you are deliberately regularising a search.
  7. For serving SLAs and deployment knobs that are unrelated to these features, see Deployment — temporal encoders do not set latency budgets.

See also

Next: Stateful encoders · Forecasting