learn-customer-retention-with-phoebe / Analyst session 2 of 8
Learn Customer Retention with Phoebe · Analyst track · Session 2 of 8

The cohort table and heatmap

Users who joined in January are a cohort. Follow them month by month - what percent came back in month 1, month 2, out to month 10 - and you get the single most information-dense artifact in retention analytics: the cohort triangle. Today you build it from raw orders in five lines of pandas, render it as a heatmap that executives can actually read, and learn the three scan directions that turn colored cells into answers.

🟠 Analyst track Data analysts · Python Build-along: pandas + seaborn The core session
0-3 · Welcome 3-15 · The cohort table 15-40 · Build the heatmap 40-45 · Q&A
Part 0

Where this session sits

Eight analyst sessions, one running artifact: a retention report your stakeholders can act on. Session a1 nailed down what "day-7 retention" even means (three different things, it turns out). Today builds the table everything else reads from. The viz gallery (a3), segmentation (a4), survival analysis (a5), CLV models (a6), and churn prediction (a7) all start from the cohort table you build here, and the capstone (a8) ships it. Work along in the companion notebook notebooks/a2_cohort_heatmap.ipynb on the repo's synthetic data, then re-run on the real Online Retail II dataset.

Live - presented in session Self-study - read after class ★ Build-along - run the code yourself Sources covered
★ What you walk out with today The canonical five-line pandas recipe for a registration-cohort retention table, a seaborn heatmap that masks immature cells instead of painting them as failures, and the three scan directions - across, down, diagonal - that let you read product changes, cohort quality, and calendar shocks straight off the colors.
Part 1 · covers registration cohorts + the frozen-history rule

One cohort, one row, frozen forever 6 min live

A registration cohort is everyone who joined in the same month. Month 0 is their join month, so month 0 retention is 100% by construction. Month 1 is the percent who came back the next calendar month, and so on. Each cohort gets one row, each age gets one column - and because young cohorts have not lived long enough to fill their late columns, the table is a triangle. The empty corner is not missing data. It is the future.

The cohort triangle: frozen history, one updating diagonal, an empty future Cohort M0 M1 M2 M3 M4 M5 Jan 100% 46% 34% 28% 25% 23% Feb 100% 48% 35% 29% 26% Mar 100% 51% 38% 31% Apr 100% 55% 40% May 100% 57% Jun 100% Solid cells: window completed. Frozen. This number never changes again, no matter what users do later. Outlined cells: current month. Still filling. The only cells that update. Empty corner: the future. June's month-5 has not happened yet. Immature - never plot it as low retention. Read down the M1 column: 46, 48, 51, 55, 57 - every new class of users sticks better than the last.
🔍 Click to zoom - the triangle shape is the point: frozen past, one live diagonal, empty future
LiveFrozen history: why a completed cohort never restates4 min▶

A retention number you can trend, compare, and set targets against is computed one way: fix the base, look forward. The base is a cohort anchored to a fixed date - everyone who registered in January, or everyone active on Aug 1. Then you look forward 1, 7, 14, 30 days (or 1 to 10 months) and count who came back. Because the base never changes and the window eventually closes, a completed cell is immutable. January's month-3 retention is the same number today, next quarter, and forever.

  • Forward-looking, cohort-based: the question is always "of the people who started at time T, how many returned within x days?" The denominator is locked at time T.
  • Frozen once complete: when the window has fully elapsed, the number is final. Your January row will never restate. A dashboard whose history rewrites itself cannot be trusted, compared, or targeted against.
  • Only the current window updates: the newest diagonal of the triangle is still filling. Mark it as partial - an outline, an asterisk, a lighter shade - so nobody reads a half-filled month as a collapse.
Anchor to the cohort and look forward, never to today and back ✓ Fixed base, forward windows Jan 1 base D1 ✓ D7 ✓ D14 ✓ D30 filling closed windows are frozen forever ✗ Moving base, backward window "today" last 7 days, recomputed daily yesterday's number is already different - nothing can be trended
🔍 Click to zoom - fix the base and look forward: closed windows freeze; a moving base never stops rewriting
Two metrics that rewrite their own history Trailing look-back ("of today's actives, how many were here 7 days ago") re-anchors to today, so the denominator shifts every single day and yesterday's number is already different. Rolling retention ("returned on day 7 or any later day") lets a user who resurfaces on day 45 rewrite day-7 history retroactively - Mixpanel's own default works this way, which is why its historical numbers keep creeping up. Both are unusable for trend reporting: a line whose past keeps moving cannot be compared or targeted against. Keep them off dashboards.
Self-studyWhy month 0 is 100% and other denominator fine print3 min read▶

The cohort table hides three denominator decisions. Make them explicitly, write them on the dashboard, and never change them silently mid-year.

  • Who counts as joined? All signups, or only activated users (completed a first core action)? Activated-base retention reads far higher and answers a different question: product stickiness versus funnel-plus-product combined. Both are legitimate. Mixing them is not.
  • What counts as active? For a game: opened a session. For ecommerce: placed an order - login-based retention is nearly meaningless for shops, since people browse without buying. Purchase-based cohorts are the ecommerce standard.
  • Which calendar? Monthly periods for a 10-month view like ours; weekly for fast products. Games use day-based windows (D1/D7/D30) since play cadence is daily - a1 covers how day boundaries and timezones can make two tools disagree on the same data.
Part 2 · covers the canonical pandas cohort recipe

Five lines of pandas 8 min live

The whole construction is one idea: stamp every order with the customer's first-ever month, then count distinct customers per (cohort, age) pair. Everything else is bookkeeping. We build it on the repo's synthetic orders file first - where the answer is known because we generated the churn - then you re-run the identical cells on Online Retail II.

From raw orders to retention matrix: the four-stage pipeline 1 · Raw orders customer_id, date one row per order 2 · Stamp the cohort groupby(customer) .transform("min") 3 · Age each order period = order_month - cohort_month 4 · Count + divide pivot_table nunique / M0 The one trick worth memorizing: transform("min") broadcasts each customer's first purchase date back onto every one of their rows - no merge needed. Someone who bought in Jan, Mar and Aug gets cohort = Jan on all three rows, so the Mar order lands in (Jan cohort, period 2) and the Aug order in (Jan cohort, period 7). Distinct-count per cell, divide every row by its month-0 count, and the matrix is done.
🔍 Click to zoom - stamp, age, count, divide: the entire recipe in one picture
Live★ Build-along: the recipe, line by line8 min▶

Load the repo's synthetic ecommerce orders and run each stage. Every line below is in the companion notebook with its printed output.

★ Stage 1-2 · load and stamp each order with its customer's cohort monthimport pandas as pd orders = pd.read_csv("data/ecommerce_orders.csv", parse_dates=["order_date"]) orders["order_month"] = orders["order_date"].dt.to_period("M") orders["cohort"] = ( orders.groupby("customer_id")["order_date"] .transform("min") # first-ever purchase date, broadcast to every row .dt.to_period("M") )
★ Stage 3 · age every order: months since the cohort monthorders["period"] = (orders["order_month"] - orders["cohort"]).apply(lambda d: d.n)
★ Stage 4 · count distinct customers per cell, divide by cohort sizecounts = orders.pivot_table(index="cohort", columns="period", values="customer_id", aggfunc="nunique") retention = counts.divide(counts.iloc[:, 0], axis=0) # column 0 = cohort size retention.round(2)

Read the result before you plot it. Column 0 is all 1.00 - everyone is active in their join month, by construction. Each row steps down as the cohort ages. The bottom-right is NaN, and it must stay NaN: those windows have not completed. NaN here means "not yet", never "zero".

Real world

The same five lines run a game studio's dashboard. Swap orders for session logs, month for day, and the identical stamp-age-count-divide pipeline produces D1/D7/D30 tables. Mobile publishers gate soft-launch decisions on exactly this artifact - Supercell killed Smash Land in 2015 off its soft-launch cohort tables even though player feedback was positive. The table you just built is a kill-or-scale decision instrument.

Self-studyOnline Retail II: what real data does to your clean recipe5 min read▶

The notebook's second half re-runs the pipeline on the UCI Online Retail II dataset (1,067,371 rows, a UK gift retailer, Dec 2009 to Dec 2011). It is deliberately messy, and every mess is a lesson:

  • Cancellations: invoices starting with "C" are cancellations carrying negative quantities. Keep them and your cohort sizes are wrong. Filter first.
  • Missing customers: around 135k rows have no CustomerID (guest checkouts) - even though the dataset's own documentation claims no missing values. Never trust metadata over a df.isna().sum().
  • The inflated first cohort: the earliest month sweeps in every pre-existing customer as if they were new - their real first purchase happened before the data starts. Left-censoring. Either drop the first cohort or annotate it.
  • Wholesalers: a handful of trade buyers order weekly and never churn. They are real, but they will dominate any average - segment them out in a4.
Clean-up gate before the cohort reciperetail = retail[~retail["Invoice"].astype(str).str.startswith("C")] # drop cancellations retail = retail.dropna(subset=["Customer ID"]) # drop guest checkouts retail = retail[retail["Quantity"] > 0] # paranoia: no negatives
Demo · the artifact itself

Render the heatmap properly ★ 12 min · everyone builds

A cohort table full of decimals is analyst food. The heatmap is what everyone else reads. Four choices separate a professional one from a misleading one: annotate every cell, mask the future, anchor the color scale, and use a colorblind-safe single-hue ramp.

Live★ Build-along: the seaborn call and its four deliberate choices6 min▶
★ The heatmap, done rightimport matplotlib.pyplot as plt import seaborn as sns fig, ax = plt.subplots(figsize=(11, 6)) sns.heatmap( retention, annot=True, fmt=".0%", # 1: numbers on every cell - color alone is not data mask=retention.isnull(), # 2: future cells stay BLANK, not painted as 0% vmin=0, vmax=0.5, # 3: anchor the scale - one loyal cohort must not wash it out cmap="Oranges", # 4: single-hue, perceptually ordered, colorblind-safe linewidths=1, linecolor="white", cbar_kws={"format": lambda x, _: f"{x:.0%}"}, ax=ax, ) ax.set_title("Monthly cohort retention - registration cohorts, purchase-based") ax.set_xlabel("Months since first purchase") ax.set_ylabel("Cohort (first-purchase month)") plt.tight_layout()
  • annot + fmt=".0%": the number is the data; color is only the index that guides the eye to it.
  • mask: an unmasked NaN renders at the colormap floor - the darkest cell on the chart - and every executive will read the future as a catastrophe. This is the single most common cohort-chart bug in the wild.
  • vmin/vmax: without anchors, seaborn rescales to the data every refresh, so this month's chart is not comparable to last month's. Frozen history deserves a frozen color scale.
  • cmap: sequential single-hue (Oranges, Blues) or perceptually uniform (viridis, cividis). Never jet or rainbow - they invent boundaries that are not in the data - and never red-green encodings, which about 8% of men cannot separate.
Three scan directions, three different questions answered Across a row One cohort aging: its retention curve laid flat. Shape = diagnosis. Down a column Every cohort at the same age. Darker going down = newer cohorts stick better. Along a diagonal One calendar month. A diagonal stripe = an outage, price change, or season hit all. A vertical stripe instead means a lifecycle event: something hits every cohort at the same AGE - like a month-2 paywall.
🔍 Click to zoom - across = aging, down = product improvement, diagonal = calendar shocks

Run the heatmap cell on the synthetic data. Find the month-1 column and read it downward: is the product improving for newer cohorts?

Delete the mask argument and re-run. Watch the empty corner turn into the darkest cells on the chart. Put it back. Now you have seen the bug you will spend your career catching in other people's decks.

Delete vmin/vmax and re-run. The colors shift even though not one number changed. Anchors back in.

Re-run the whole pipeline on Online Retail II after the clean-up gate. Say out loud what the first cohort's row means, knowing it is left-censored.

Real world

The flattening row that bought a company. Dollar Shave Club's cohort rows flattened at roughly 50% six-month retention - transaction data later confirmed by Second Measure showed it led its whole category. Investors read that flattening as durable revenue, and the story ended in a $1B acquisition by Unilever. Same artifact you just built, rendered the same way.

Homework

Try it yourself - this week ◐ 30 min total

Sources covered

What this session draws on

Cohort analysis has no single certificate - it is a craft standardized by the analytics tools and a handful of durable essays. This page teaches the working core of:

✓The canonical pandas cohort recipe (Towards Data Science / community standard)Part 2 · stamp, age, count, divide
✓Lenny's Newsletter - "Measuring cohort retention" (Olga Berezovsky)Part 1 · denominator discipline, active = core value action
✓Amplitude retention docs - bounded vs unbounded, immature bucketsPart 1 · the frozen-history rationale
◐Reforge - cohort analysis basics (triangle reading)Demo · scan directions; curve shapes get full treatment in a3
◐UCI Online Retail II dataset documentationSelf-study card · the quirks are the curriculum
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Your January cohort's month-2 retention was 34% when you reported it in April. It is now August. What is January's month-2 retention?

Forward-looking cohort retention locks the base (January joiners) and the window (month 2). Once March ended, every fact the number depends on is in the past. "Came back later" is rolling retention - the metric that rewrites its own history.

2 · You forget the mask argument and NaN cells render at the colormap floor. What does your VP see?

Unmasked NaN takes the darkest end of the scale, so windows that simply have not happened yet read as 0% retention. Mask with retention.isnull() so immature cells stay blank - "not yet" is not "zero".

3 · Month-1 retention read down the column: Jan 46%, Feb 48%, Mar 51%, Apr 55%. What is this telling you?

Down a column every cohort is compared at the SAME age, so cohort-over-cohort improvement is the cleanest read of whether changes since January actually work. (One cohort recovering would show across its row; a calendar effect would show on a diagonal.)

Analyst session 2 cheat sheet · pin this

Registration cohortEveryone who joined in the same month. Month 0 = join month = 100% by construction. One cohort, one row, forever.
The frozen-history ruleFix the base, look forward x days. Completed windows are immutable; only the current window updates, visibly marked partial.
Keep off dashboardsTrailing look-back (denominator moves daily) and rolling "day N or later" retention (history rewrites itself). Neither can be trended.
The recipecohort = groupby(customer).transform("min") -> period = order_month - cohort -> pivot_table nunique -> divide by column 0.
The four heatmap choicesannot every cell · mask the future · anchor vmin/vmax · single-hue colorblind-safe cmap. Break one and the chart lies.
Three scan directionsAcross a row = one cohort aging. Down a column = product improving? Diagonal = calendar shock (vertical stripe = lifecycle event).
NaN means not-yetThe empty triangle corner is the future, never zero. Unmasked NaN painted dark is the most common cohort-chart bug in the wild.
Real data gatesOnline Retail II: drop C-invoices, drop missing CustomerID, mind the left-censored first cohort, segment out wholesalers.