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

Segmentation: who is retained

a2's triangle told you WHEN people churn. It cannot tell you WHO stays or WHY - a 30% blended retention rate can hide a devoted daily core and a sea of one-and-done visitors. Today you split the average three ways: RFM scores that rank every customer by recency, frequency, and spend; behavioral cohorts that turn a retention dip into a testable cause; and the L28 power-user curve, the histogram that shows what DAU/MAU averages away.

🟡 Analyst track Data analysts · Python Build-along: RFM + L28 45 min
0-3 · Welcome 3-18 · RFM 18-40 · Behavioral cohorts & L28 40-45 · Q&A
Part 0

From when to who and why

Halfway through the track, the artifacts start feeding each other. The cohort table (a2) and viz gallery (a3) answer "are we retaining?" Today's segments answer "retaining whom?" - and the RFM table you build this session becomes the direct input for a6's buy-till-you-die CLV models, while the behavioral-cohort workflow becomes the hypothesis engine for a7's churn features. Everything runs on the repo's synthetic ecommerce orders first, then on Online Retail II. Work along in the companion notebook notebooks/a4_segmentation.ipynb.

Live - presented in session Self-study - read after class ★ Build-along - run the code yourself Sources covered
★ What you walk out with today A working RFM pipeline - quintile scores via pd.qcut, 555-style codes, four personas - with the pitfalls that bite on real data; the acquisition-vs-behavioral cohort workflow that turns "retention dipped" into "here is the lever"; and the L28 power-user curve with its two shapes and their verdicts. All three go into your running retention report.
Part 1 · covers RFM scoring and personas (Hughes 1994)

RFM: the classic that still ships 15 min live

RFM is direct-marketing technology from Arthur Hughes' 1994 playbook, and it survives because it is honest and cheap: score every customer on Recency (days since last purchase), Frequency (order count), and Monetary (total spend), each cut into quintiles, 5 best. Concatenate the three digits and a 555 is your best customer, a 111 your most gone. Personas make the codes speakable: Champions (555/554), Loyal, At-Risk (155/144 - were frequent and valuable, going quiet), Hibernating. And the same table is the exact input a6's BTYD models want: recency, frequency, and tenure per customer.

Live★ Build-along: R, F, M from raw orders to personas9 min▶

Three prompt-boxes, one pipeline. The snapshot date is fixed and written down - the frozen-history law applies to segments too: an RFM table recomputed against a drifting "today" is a segment view that quietly restates.

★ Step 1 · one row per customer: recency, frequency, monetaryimport pandas as pd orders = pd.read_csv("data/ecommerce_orders.csv", parse_dates=["order_date"]) snapshot = pd.Timestamp("2026-07-01") # FIXED date - never a moving "today" rfm = orders.groupby("customer_id").agg( last_order=("order_date", "max"), frequency=("order_date", "count"), # order count monetary=("revenue", "sum"), # total spend ) rfm["recency"] = (snapshot - rfm["last_order"]).dt.days # days since last purchase
★ Step 2 · quintile scores via qcut - note recency is scored REVERSEDrfm["R"] = pd.qcut(rfm["recency"], 5, labels=[5, 4, 3, 2, 1]).astype(int) # small = recent = best rfm["F"] = pd.qcut(rfm["frequency"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int) # rank first: spiky counts break qcut rfm["M"] = pd.qcut(rfm["monetary"], 5, labels=[1, 2, 3, 4, 5]).astype(int) rfm["code"] = rfm["R"].astype(str) + rfm["F"].astype(str) + rfm["M"].astype(str)
★ Step 3 · map codes to speakable personasdef persona(row): if row["R"] >= 5 and row["F"] >= 4: return "Champions" # 555, 554, ... if row["R"] >= 3 and row["F"] >= 3: return "Loyal" if row["R"] <= 2 and row["F"] >= 4: return "At-Risk" # 155, 144: valuable, going quiet if row["R"] <= 2 and row["F"] <= 2: return "Hibernating" return "Everyone else" rfm["persona"] = rfm.apply(persona, axis=1) rfm["persona"].value_counts()

Read the At-Risk group first, always: high frequency, low recency means proven customers drifting away - the one segment where a winback message has both a reason and an audience.

Self-studyRFM pitfalls: what bites on real data4 min read▶
  • qcut chokes on spiky data. When half your customers have frequency = 1, quintile edges collide and pd.qcut raises duplicate-bin-edge errors. The fix is in step 2 above: rank first (rank(method="first")), then cut the ranks. You trade exactness of ties for a working pipeline.
  • Wholesalers land in Champions and eat your averages. The Online Retail II lesson from a2 returns: a handful of trade buyers who order weekly will occupy the top of every quintile and drag every per-segment average toward themselves. Inspect the top of the Champions list by hand before you report it - some of your "best customers" are businesses, not fans.
  • The snapshot date must be fixed. Recency computed against a moving "today" makes every customer drift one score-point downward per quiet week, so last month's segment counts are not comparable to this month's. Freeze the snapshot, version the table, and recompute on a declared cadence - frozen history, again.
The RFM grid: R across, F up - personas live in the corners Champ. Champ. At-Risk At-Risk Loyal Loyal Hibern. F5 F4 F3 F2 F1 R1 R2 R3 R4 R5 Champions (555/554): recent, frequent, big. Protect them; they are a6's high-P(alive) set. At-Risk (155/144): frequent but going quiet. The winback audience - proven value, fading. Hibernating: low on everything. Cheap to ignore, expensive to chase. M is the third digit, flattened here. The full cube is 5x5x5 = 125 cells. Recency runs right = more recent. At-Risk sits at high F, low R: your most valuable customers going quiet.
🔍 Click to zoom - Champions top-right, At-Risk top-left, Hibernating bottom-left: the corners are the strategy
The handoff: today's RFM table is the model input a6 expects rfm - one row per customer recency (days since last) frequency (order count) monetary (total spend) code + persona + tenure T (days since first order) the one column to add - a6 needs it BG/NBD (a6) recency, frequency, T in; P(alive) + expected orders out Gamma-Gamma (a6) expected spend per order CLV per customer expected orders x expected spend Personas stay behind in the report - the speakable layer for stakeholders, not a model input. Segment once, reuse twice: the same three columns power the buy-till-you-die CLV models in a6.
🔍 Click to zoom - three RFM columns plus tenure flow straight into a6's BG/NBD and Gamma-Gamma models; the personas stay in the report

Freeze the snapshot and recompute on a declared cadence, and the snapshots start talking to each other: crosstab January's personas against July's for the same customers and every move becomes visible. This is the monitoring layer RFM is usually missing - one table that says who upgraded, who went quiet, and where the winback budget should go.

Persona migration, Jan 2026 snapshot vs Jul 2026 snapshot (same customers) rows = Jan 2026 persona · columns = Jul 2026 persona At-Risk Champions Hibernating Loyal Regular At-Risk Champions Hibernating Loyal Regular 102 7 0 0 85 114 the alarm cell 261 0 126 29 0 6 523 3 0 93 159 ✓ 49 73 92 2 106 ✓ 295 69 130 Customers in cell 0 1-49 50-99 100-149 150-259 260-399 400+ the alarm cell rose to Champions Diagonal = stayed. Off it: 114 Champions fell to At-Risk; 159 Loyal and 106 Regular rose to Champions. Track quarter over quarter. Broad mapping here (Champions = R4+/F4+); stricter mappings shrink every cell.
🔍 Click to zoom - Jan to Jul persona migration: the diagonal stays put, the alarm cell is 114 Champions gone quiet

The same July snapshot, promoted to a dashboard row: the value_counts() output with a quarter-over-quarter delta and a revenue bar attached, one tile per persona.

Segment health tiles, Jul 2026: counts and deltas real, revenue shares illustrative AT-RISK 311 ▲ +117 QoQ - warning 12% of revenue* CHAMPIONS 539 ▲ +9 QoQ - holding 45% of revenue* HIBERNATING 867 ▲ +335 QoQ - warning 8% of revenue* LOYAL 271 ▼ -195 QoQ 20% of revenue* REGULAR 336 ▼ -266 QoQ 15% of revenue* Deltas = Jan vs Jul persona totals from the matrix above. *Revenue-share bars ILLUSTRATIVE.
🔍 Click to zoom - five personas, five tiles: At-Risk and Hibernating both growing is the warning the average hides
Part 2 · covers acquisition vs behavioral cohorts (Amplitude framing)

Acquisition tells when, behavior tells why 12 min live

a2's triangle groups users by WHEN they joined - an acquisition cohort - and it is superb at spotting that month 2 is where everyone leaves. It says nothing about why. A behavioral cohort groups by an action taken: used feature X in week 1, made a second purchase within 30 days. Compare the retention curves of the did-it and did-not groups and the dip acquires a suspect. The workflow is always the same pair: the acquisition cohort spots the dip, the behavioral cohort explains it.

The two-cohort workflow: spot the dip, split by behavior, price the gap 1 · Triangle spots a dip month-1 cliff 2 · Split by behavior 2nd purchase in 30d? yes no two groups, same month 3 · Overlay - the gap the gap = the story 4 · The lever gap x cohort size x ARPU = the business case for the nudge Correlation caveat, said out loud: the gap shows association, not proof - maybe committed people both buy twice AND stay. The behavioral cohort's job is to generate the hypothesis and size the prize; the experiment (drive second purchases, measure the curves move) is what confirms it. a7 turns these same splits into churn-model features. Acquisition cohorts answer WHEN people churn; behavioral cohorts answer WHY. You need both, in that order.
🔍 Click to zoom - dip, split, gap, lever: the workflow that turns a triangle into a roadmap item
Live★ Build-along: one behavioral split, two overlaid curves7 min▶

The ecommerce classic: does a second purchase within 30 days predict long-run retention? Flag each customer, then run a2's recipe once per group and overlay.

★ Flag the behavior, then compare retention curves per groupfirst = orders.groupby("customer_id")["order_date"].min().rename("first_order") o = orders.join(first, on="customer_id") o["days_since_first"] = (o["order_date"] - o["first_order"]).dt.days second_in_30 = (o[o["days_since_first"].between(1, 30)] .groupby("customer_id").size() > 0) o["repeat30"] = o["customer_id"].map(second_in_30).fillna(False) o["order_month"] = o["order_date"].dt.to_period("M") o["cohort"] = o["first_order"].dt.to_period("M") o["period"] = (o["order_month"] - o["cohort"]).apply(lambda d: d.n) curves = (o.groupby(["repeat30", "period"])["customer_id"] .nunique().unstack(0)) curves = curves.divide(curves.iloc[0], axis=1) # period 0 = 100% per group curves.plot(figsize=(10, 5), ylim=(0, 1), title="Retention: second purchase within 30d - yes vs no")

The vertical distance between the two lines at month 6 IS the business case for driving second purchases: multiply the gap by cohort size and ARPU and you have the number that gets the email flow funded. The gap sizes the prize; the experiment proves the mechanism.

Part 3 · covers the L28 power-user curve (Facebook growth via a16z)

L28: the power-user curve 10 min live

Take one 28-day window and plot a histogram: how many users were active exactly 1 day, 2 days, up to all 28. Why 28 and not 30? Four exact weeks, so every user sees the same weekday mix - this is the same trick behind games reporting D28, and the fix previewed in a1 for stickiness averages. Invented on Facebook's growth team, popularized through a16z's "power user curve" essay, and the shape is the whole verdict.

Two L28 shapes, two verdicts 1 day 28 days The smile A hardcore daily segment exists. Verdict: hooked core. 1 day 28 days Slumped left Shallow engagement, whatever DAU/MAU says. No core. Same DAU/MAU ratio can produce either picture - the histogram is the anti-average.
🔍 Click to zoom - smile = a daily core exists; slumped-left = nobody is hooked, whatever the ratio says
Live★ Build-along: the L28 histogram and stickiness by persona6 min▶
★ L28 in four lines - one fixed 28-day windowwindow = sessions[sessions["date"].between("2026-06-01", "2026-06-28")] days_active = window.groupby("user_id")["date"].nunique() days_active.value_counts().sort_index().plot.bar( figsize=(10, 4), color="#C2410C", title="L28: users by active days")

Then close the loop with Part 1: compute stickiness per RFM persona instead of one blended ratio. One small table replaces a quarter of arguments:

★ DAU/MAU per persona - the blended ratio, decomposedsess = sessions.join(rfm["persona"], on="user_id") month = sess[sess["date"].between("2026-06-01", "2026-06-28")] by_seg = month.groupby("persona").agg( mau=("user_id", "nunique"), user_days=("user_id", "count"), ) by_seg["dau_avg"] = by_seg["user_days"] / 28 by_seg["stickiness"] = by_seg["dau_avg"] / by_seg["mau"] by_seg.sort_values("stickiness", ascending=False)
PersonaWhat the stickiness row typically shows
ChampionsHighest by far - the daily-habit end of the histogram lives here
LoyalHealthy, steady - the middle of the smile
At-RiskFalling month over month - the early-warning row
HibernatingNear zero - they are the tall day-1 bar on the left

One blended DAU/MAU hides all four rows. The segment table is the honest version - and the At-Risk row falling is a churn alarm you get months before it shows in the triangle.

Personas and activity are the same customers seen twice. Map the July snapshot onto the R x F plane and the persona zones from Part 1 light up as density - including the wholesaler dots the pitfalls card warned you about.

The Jul snapshot on the R x F plane: personas are density zones 64 43 26 23 340 Champions 118 At-Risk 86 40 38 199 60 49 52 Loyal 48 44 176 126 46 34 23 331 Hibernating 234 55 41 28 F5 F4 F3 F2 F1 R1 R2 R3 R4 R5 wholesalers - segment them out 3-4 trade buyers pinned at F5 R5 Customers per cell 1-29 30-59 60-99 100-179 180-299 300+ persona zone border 2,324 customers in the Jul snapshot: the Hibernating corner is densest at 867; Champions hold 539. Density computed from the repo snapshot - the companion notebook draws the exact version.
🔍 Click to zoom - the Jul snapshot as R x F density: Hibernating corner densest, wholesalers pinned at F5 R5
Real world

Same chart, two industries. For a mobile game, the L28 smile IS the whale pipeline: only 2-5% of players ever pay, and payers emerge from the hardcore daily core on the right of the histogram - retention precedes monetization, so a studio with no smile has no monetization future either. For ecommerce the segmentation punchline is concentration: returning customers are roughly 21% of the customer base but drive about 44% of revenue - which is why the Champions and At-Risk corners of the RFM grid, a quarter of your customers, deserve most of your retention budget.

Homework

Try it yourself - this week ◐ 30 min total

Sources covered

What this session draws on

Segmentation is the oldest craft in retention analytics - RFM predates the web - standardized by a few durable sources. Everything above is the working core of:

✓Arthur Hughes - RFM quintile scoring and personas (1994 heritage)Part 1 · codes, personas, the qcut build
✓Amplitude - behavioral cohorting (acquisition spots, behavior explains)Part 2 · the two-cohort workflow
✓a16z "The Power User Curve" (Li Jin) - the L28 readingPart 3 · smile vs slumped-left
◐Published ecommerce benchmarks (repeat-customer concentration, payer economics)Real-world callout · full money framing in a6 and s1
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why is recency the one RFM dimension scored in reverse?

Frequency and monetary reward big numbers; recency rewards small ones - a customer seen 3 days ago is more alive than one seen 300 days ago. Reversing the labels (labels=[5,4,3,2,1]) keeps all three digits pointing the same way, so 555 reads as best on every axis.

2 · Acquisition cohorts vs behavioral cohorts: which one answers WHY retention dipped?

Acquisition cohorts group by WHEN users joined, so they locate the dip in time. Behavioral cohorts group by WHAT users did, so the curve gap points at a candidate cause - and its size prices the fix. The workflow is always: acquisition spots it, behavioral explains it, an experiment confirms it.

3 · Your L28 histogram is a smile: tall at 1 active day, tall again at 25-28. What does it mean?

The right-hand hump is the point: a real population of users shows up nearly every day. Most products only have the left hump (slumped-left = no core). For a game, that daily core is where the 2-5% of payers come from; for any product it is the segment worth studying, protecting, and growing.

Analyst session 4 cheat sheet · pin this

RFMRecency / Frequency / Monetary, each qcut into quintiles, 5 best, codes concatenated: 555 = Champions. Hughes 1994, still shipping.
Recency is reversedSmaller = better, so labels run [5,4,3,2,1]. Keeps every digit pointing the same way; 555 reads best on all three axes.
The personasChampions (555/554) protect; At-Risk (155/144) = frequent but fading, the winback audience; Hibernating = low everything; Loyal = the middle.
RFM pitfallsSpiky counts break qcut - rank first. Wholesalers invade Champions - inspect by hand. Snapshot date fixed, never a moving "today".
Two cohort typesAcquisition (when they joined) tells WHEN churn happens; behavioral (what they did) tells WHY. Spot the dip, then split by behavior.
The gap is the caseOverlay did-it vs did-not curves: gap x cohort size x ARPU = the funded roadmap item. Association only - the experiment confirms.
L28Histogram of active days per 28-day window (4 exact weeks kills weekday mix). Smile = daily core exists; slumped-left = shallow, whatever DAU/MAU says.
Feeds forwardThe RFM table is a6's BTYD input (recency/frequency/tenure); behavioral splits become a7's churn features. Segment once, reuse twice.