skip to content

Entity Aggregates from Events

Turning an event log into one row per user: counts, sums and distinct-counts over a chosen window, plus recency, tenure, RFM summaries and share-of-total. Interviewers open case rounds here.

on this pageshow

questions

4

How do you turn a raw event log into one row per customer for a churn model?

level: juniorimportance: must knowfreq 70%

answer

  1. the row is the account, not the event
  2. fix grain, reference date, window
  3. count, sum, mean, max, distinct
  4. timing columns: recency and tenure
  5. start from the entity roster

basics

~20 s

Choose the entity and a reference date, keep only that entity's events inside a window ending at the reference date, then collapse them into one row: counts, sums, means, maxima, distinct counts, and days since the last event.

solid answer

~50 s

Event data arrives one row per event; a churn model needs one row per account, so the first step is a reshape. I fix three things: the **entity grain** (account, user, card), the **reference date** each row is built as-of, and the **window** of history I look back over — say 90 days. Then every event belonging to that entity inside the window collapses into aggregate columns: volume (`count`, `sum`), typical size (`mean`, `median`), extremes (`max`), breadth (`distinct count of event types or categories`), and timing (`recency = reference_date - last_event_date`, `tenure = reference_date - first_event_date`). RFM is exactly this trio for a shopper: recency, frequency of orders, monetary sum. I build the row list from the entity table, not from the event log, so accounts with zero events in the window still appear with a count of zero instead of silently disappearing.

code

python · 21 lines
python
from collections import defaultdict

events = [  # (account, day_index, amount)
    ('a1', 12, 40.0), ('a1', 58, 25.0), ('a1', 96, 60.0),
    ('a2', 5, 500.0), ('a2', 9, 120.0),
]
REF_DAY, WINDOW = 100, 90        # window covers days 10..99

per_entity = defaultdict(list)
for account, day, amount in events:
    if REF_DAY - WINDOW <= day < REF_DAY:
        per_entity[account].append((day, amount))

for account in ('a1', 'a2'):            # roster, not the event log
    rows = per_entity.get(account, [])
    if not rows:
        print(account, 'recency=none frequency=0 monetary=0.0')
        continue
    recency = REF_DAY - max(day for day, _ in rows)
    print(account, 'recency=%d frequency=%d monetary=%.1f'
          % (recency, len(rows), sum(a for _, a in rows)))

go deeper

for a junior

Be ready to say out loud that the model needs one row per entity, and to name the aggregates you would compute: how many, how much, how big at most, how many distinct, and how long since the last one.

for a middle

Explain why the grain, the reference date and the window must all be stated, and how each aggregate family carries different information — a mean is not a sum, breadth is not volume.

for a senior

Show the operational side: entities with no events must still appear, grains must match between features and label, and column names should encode aggregate plus window so a reader can reconstruct the row.

for a principal

Own the question of how far to automate this. Templated aggregate generation across entity, field, event type and window produces thousands of columns cheaply; decide what that costs in compute, review and maintenance before it becomes the team's default.

## The shape problem Classical models expect a rectangular table: one row per thing you score, one column per feature, one label per row. Event data does not arrive that way. A clickstream, an order table, an authorisation log or a SaaS audit trail is long and thin — each row is one thing that happened at one instant, roughly `(entity_id, timestamp, event_type, value)`. A busy account contributes four thousand rows; a quiet one contributes three. You cannot train "will this account churn next month?" on that table, because the unit of the question (the account) is not the unit of the row (the event). **Entity aggregation** is the reshape that fixes it: every event belonging to one entity collapses into a single row of summary numbers, and that row gets one label. ## Three decisions before any arithmetic **1. The entity grain.** What is one row? An account, a user inside an account, a payment card, a store-item pair. Pick the grain the prediction is made at and the grain the label is defined at — they must match. If the business acts on accounts but events carry user ids, you aggregate users up to the account. **2. The reference date.** Every aggregate is computed *as of* a moment. For training rows this is the snapshot date the label is measured after; at scoring time it is the moment you score. Naming it explicitly is what makes the feature reproducible: `days since last login` is meaningless until you say "since when". **3. The window.** How far back the aggregate reaches: last 7, 30 or 90 days, or all history. Use a half-open interval — include events with `window_start <= t < reference_date` — so the row is built only from what was knowable at that instant, and so an event exactly on the boundary is counted once rather than twice across adjacent windows. ## The aggregate families Once the grain, reference date and window are fixed, the aggregates themselves are a small vocabulary applied to every numeric field and every event type: - **Volume** — `count` of events, `sum` of amounts. "How much did this entity do?" - **Typical value** — `mean` or `median` amount per event. "How big is a typical action?" Note this is volume divided by count, so it carries different information than either. - **Extremes** — `max` (and sometimes `min`). A single unusually large transaction is often the signal, and a mean hides it. - **Breadth** — `distinct count`: how many merchant categories a cardholder touched in the last 90 days, how many features a SaaS account used. Breadth and volume are genuinely different: one account may fire a thousand events against a single feature, another two hundred across twelve. - **Timing** — `recency = reference_date - last_event_date` and `tenure = reference_date - first_event_date`. Recency is usually the strongest single column in a churn or reactivation model, because disengagement shows up as silence long before it shows up as a change in volume. Tenure separates "quiet because new" from "quiet because leaving". - **Composition** — share-of-total: this title's spend divided by the entity's total spend that month. **RFM** is simply three of these chosen together for a shopper: Recency (days since last order), Frequency (order count in the window) and Monetary (total spend in the window). It is a good default starting set precisely because it covers timing, volume and value with three columns. The vocabulary multiplies out: aggregate x field x event type x window. A 30-day window over a SaaS event log easily yields session count, distinct features touched, sum of seconds in app, max session length and days since last login — five columns from one raw log. ## Two construction traps **Losing the silent entities.** If you build the feature table by grouping the event log, any entity with no events in the window is not in the result at all. Those are frequently the most interesting rows. Start from the entity roster and left-join the aggregates, so a silent account appears with a count of zero. **Mixing grains.** A common bug is labelling each *event* with the entity's outcome and training on that — a heavy user then contributes thousands of correlated rows and dominates the loss, while a quiet user contributes almost nothing. One entity, one row, one label. ## What good looks like A clean aggregate table is documented by its three parameters — grain, reference date, window — and every column name states which aggregate over which window it is, for example `orders_count_30d` or `days_since_last_order`. Anyone reading a row can then say exactly what was known, about whom, and when.

  • Counts alone are not separating churners. Which aggregate would you add first?
    A timing column — days since the last event. Disengagement shows up as silence before it shows up as a drop in volume, so recency usually separates churners better than any count. I would add tenure alongside it, because a low count from a two-week-old account means something completely different from a low count from a three-year-old one.
  • The events carry user ids but the label is defined at account level. How do you aggregate?
    Aggregate twice: summarise events per user, then roll users up to the account with account-level aggregates over those summaries — number of active users, max user session count, share of the account's activity coming from its busiest user. Rolling straight from events to accounts loses the per-user structure, which is often where the churn signal lives.
  • Why do you build the aggregate row from a half-open window rather than an inclusive one?
    A half-open interval, window start inclusive and reference date exclusive, means each event belongs to exactly one window when you slide the snapshot forward, so nothing is double counted. It also draws a hard line at the reference moment, which makes it obvious that everything in the row was knowable then.

It is the difference between a bank statement and a monthly summary: the statement lists every transaction, the summary tells you how many, how much, the largest, and how long since the last one. The model reads the summary.

saying these in an interview costs you the question

  • Feeds the raw event table to the model unchanged
  • Trains on one row per event with the entity's label copied down
  • Aggregates over all history with no window or reference date stated
  • Builds the table by grouping events, so silent entities vanish
  • Reports only totals and never a days-since-last-event column

context

open as a page

Why treat the aggregation window length for entity features as a hyperparameter?

level: middleimportance: should knowfreq 50%

basics

~20 s

Window length trades freshness against stability, and the right value depends on how fast the behaviour changes. A 7-day window reacts quickly but is sparse and noisy; a 90-day window is stable but dilutes recent change. Tune it on validation data rather than guessing.

open as a page

An account has no events in the 90-day window — which of its aggregates are zero and which are missing?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Counts and sums are genuinely zero — nothing happened, and that is an observation. Means, maxima and shares are undefined because there is nothing to summarise, and recency has no value at all. Encode those with a sentinel plus a companion flag, never a silent zero.

open as a page

Why add share-of-total and per-day rate features next to raw per-entity event counts?

level: middleimportance: nice to knowfreq 32%

basics

~20 s

A raw count mixes how active an entity is overall with how it splits that activity and how long it was observed. Dividing by the entity's total gives composition, and dividing by days observed gives intensity, so the model can separate a heavy user from a lopsided one.

open as a page