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_windowsand_TSLASTmeasure 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,aggregatesandaggregate_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:
min_lag(default1) — the minimum look-back offset. A row never sees its own value: lags belowmin_lagare 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. Settingmin_lag = 0would include the current event and is warned against.- 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¶
- Set identity keys (
data.key1/key2, or explicitentity_columns). Without them, look-backs collapse to one global series. - Ensure an event-time column exists (
event_time_columnortime_domain). - Prefer an explicit
lag_featureslist over lagging every numeric column. - Start with defaults; shrink
aggregate_windows/aggregatesif the FE matrix balloons. - Keep
fill_value=Nonefor tree models; set a numeric fill only when required. - Leave
dropout_mode="off"unless you are deliberately regularising a search. - For serving SLAs and deployment knobs that are unrelated to these features, see Deployment — temporal encoders do not set latency budgets.
See also¶
- FE overview & loci — where
temporal_lagssits - Forecasting — the separate future-series subsystem
- Stateful encoders — the entity fan-out fraud twins
- Full parameter reference
- Reproducibility & seeds — lag-dropout seeding
Next: Stateful encoders · Forecasting