Averages hide everything in user analytics: growth pumps new users into the numerator and masks that older users are quietly leaving. Cohort analysis fixes this by grouping users by their start month and tracking each cohort separately. The classic output is the retention triangle — rows are cohorts, columns are months since signup, cells are the share still active.
| Read the triangle | Meaning |
|---|---|
| Down a column | Same-age comparison — are newer cohorts retaining better? (Product is improving) |
| Along a row | One cohort's decay curve — where is the steepest drop? |
| Flattening rows | A retained core exists — the curve's plateau is your product-market-fit signal |
| Rows hitting zero | Leaky bucket — growth is refilling, not compounding |
pip install pandas numpy
# ────────────────────────────────────────
import numpy as np
import pandas as pd
rng = np.random.default_rng(5)
rows = []
for cohort in range(6): # signup month
months_active = rng.geometric(0.25, 500).clip(max=8 - cohort)
for uid, life in enumerate(months_active):
rows += [(f"{cohort}-{uid}", cohort, cohort + m)
for m in range(life)]
ev = pd.DataFrame(rows, columns=['user_id', 'cohort', 'month'])
ev['age'] = ev['month'] - ev['cohort']
counts = ev.pivot_table(index='cohort', columns='age',
values='user_id', aggfunc='nunique')
print((counts.div(counts[0], axis=0) * 100).round(1))
From January to June, retention improved in both channels: search from 50.0% to 53.3%, ads from 30.0% to 32.0%. The overall rate fell from 46.0% to 38.4%, because the June cohort came mostly from ads. Reading retention without breaking it down by acquisition mix can report a decline that is really a change in who arrived.
- Recent cohorts have not yet had time to churn; averaging curves of different ages biases them.
- Retention definitions (active in month k, or in any month since) change the curve’s shape.
- Extrapolating early churn forward understates lifetime when the curve flattens (Fader & Hardie 2007).
cohorts = pd.DataFrame({'channel': ['search', 'ads'] * 2, 'month': ['Jan', 'Jan', 'Jun', 'Jun'],
'users': [800, 200, 300, 700], 'retained': [400, 60, 160, 224]})
cohorts['rate'] = cohorts.retained / cohorts.users
overall = cohorts.groupby('month')[['users', 'retained']].sum()
overall['rate'] = overall.retained / overall.usersAssumes import numpy as np and import pandas as pd (and SciPy where imported). Each snippet simulates or makes up its own data, as the comments say.
The same idea with a library
# Python — retention triangle in pandas import pandas as pd # events: user_id, event_date events['month'] = events['event_date'].dt.to_period('M') events['cohort'] = events.groupby('user_id')['month'].transform('min') events['age'] = (events['month'] - events['cohort']).apply(lambda p: p.n) counts = (events.drop_duplicates(['user_id', 'month']) .pivot_table(index='cohort', columns='age', values='user_id', aggfunc='nunique')) retention = counts.div(counts[0], axis=0) # normalize by cohort size print((retention * 100).round(1))
- P. S. Fader & B. G. S. Hardie, “How to Project Customer Retention”, Journal of Interactive Marketing 21(1), 2007
- E. H. Simpson, “The Interpretation of Interaction in Contingency Tables”, Journal of the Royal Statistical Society B 13(2), 1951
- P. J. Bickel, E. A. Hammel & J. W. O’Connell, “Sex Bias in Graduate Admissions: Data from Berkeley”, Science 187(4175), 1975
Last reviewed October 2026 · How this site checks its content · Report a mistake