learn-rfm-modeling-with-phoebe / Session 2 of 6
Learn RFM Modeling with Phoebe · Session 2 of 6

The cut decides the segment

Same 93,358 Olist customers as last week, same three columns, same snapshot date. Tonight nothing moves except the knife. Put it at the mean and 1.28 percent of customers are "best". Rank everyone into fifths and feed the scores into the most-copied tutorial map, and 16.1 percent are Champions. Under that map and under Klaviyo's published default, nine in ten of those Champions bought exactly once. Nobody changed; the cut did. A segment is not a property of a customer, it is a product of the customer and the knife, and the knife is a decision somebody made, often by accepting a default.

🟡 Core Analysts, growth and CRM teams Python, pandas 45 min
0-3 · Welcome 3-18 · Five knives, one pile 18-40 · Build-along: five cuts on 93,358 customers 40-45 · Q&A
Part 0

Where we start

Session 1 ended with a table: one row per customer, R, F and M, and a knife at the mean of each column. The mean is where most tutorials put it, and it is one of at least five defensible places. Each place produces a different set of people called "high", and downstream every action table, every win-back list and every dashboard tile inherits that choice without saying so. Tonight is four short ideas, where the knife can go, what a column full of ties does to it, what the popular taxonomies feed on, and what happens when the pile is 300 people instead of 93,000, and then 22 minutes of running all five cuts on the same table and reading the differences.

🎫 Clear all three quiz questions to stamp your passport Live = presented in the 45 minutes Self-study = the full depth, after class ★ Build-along = run the code yourself

★ What you walk out with tonight. Five cuts you can run on any RFM table in twenty lines, a one-function report that prints who each cut calls best and how many of them bought once, the reason pd.qcut refuses your frequency column and what the usual fix silently does, and the question to ask of any marketing tool before trusting its Champions tile: "where is the knife, and what does it do with ties?"

Part 1 · covers where the knife can go

Five honest places for one knife

6 min live

Take the spend column. 93,358 numbers, mean R$ 165.20, median R$ 107.78, and the mean sits above the median because a long tail of big baskets drags it right. Every cut below is a legitimate answer to "who spends a lot?", and each one lands on a different share of the same people.

ONE SPEND COLUMN, FIVE KNIVES 93,358 customers · every knife is a defensible answer to "who spends a lot?" median R$ 108 50% above rank thirds top 33% above mean R$ 165 29.3% above rank fifths top 20% above R$ 300, the owner's line about the top tenth the tail: top 10% of customers 38.3% of revenue spend per customer, R$ 97% of these people bought once Same pile. Move only the knife and "big spend" is half the customers, a third, 29 percent, a fifth or a tenth. All five are right.
🔍 Click to zoom - the mean sits to the right of the median because the tail pulls it, so "above the mean" is already a minority
Livepd.cut fixes the edges, pd.qcut fixes the counts4 min▶

There are two families of knife and pandas has a function for each. pd.cut takes edges you choose: the mean, the median, a round R$ 300, a 180-day line. The shares that land in each bin are whatever the data makes them, and on a skewed column they are lopsided. pd.qcut takes counts you choose: thirds, fifths, tenths. It then puts the edges wherever it has to so the bins come out equal, which on a skewed column means the edges crowd into the dense part and one bin at the top spans everything from "a bit above typical" to the largest order in the file.

Two mechanics to hold onto. Both functions build left-open, right-closed intervals by default, so a customer sitting exactly on an edge falls into the lower bin; if your threshold is "R$ 300 or more counts as big", pass right=False or shift the edge. And recency is scored in reverse: fewer days is better, so the labels for R run labels=[5, 4, 3, 2, 1] while F and M run [1, 2, 3, 4, 5]. Forgetting the reversal is the most common RFM bug in the wild, and it produces a Champions segment made of people who have not bought in a year.

The five placements tonight, on the spend column: the mean (R$ 165.20, 29.3 percent above it), the median (R$ 107.78, 50 percent above it by construction), rank thirds (the top 33 percent by construction), rank fifths (the top 20 percent by construction), and a merchant threshold, a round number the owner would actually write on a whiteboard. On Olist a R$ 300 line takes roughly the top tenth of customers. Five knives, five shares, one column.

TWO FAMILIES OF KNIFE, ONE COLUMN: RECENCY IN DAYS ruler runs 0 to 715 days · 1 px = about 1 day pd.cut · you choose the edges, the counts fall where they fall 50 100 200 300 365 10,662 8,819 23,013 21,159 8,733 20,972 customers past the last edge become NaN 22.5 percent of the file, silently pd.qcut · you choose the counts, the edges fall where they fall 94 179 270 384 18,802 18,757 18,723 18,471 18,605, spanning 384 to 715 days Fixed edges give bins you can name and rows you can lose. Equal counts give bins you can compare and edges nobody chose.
🔍 Click to zoom - the hatched tail is the tutorial's own edges applied to a file that runs past them, and the loss is a fifth of the customers
Self-studyWhy mean and median disagree on spend: skew2 min▶

If spend were symmetric the mean and the median would sit on the same number and "above the mean" would be half the customers. On Olist the mean is R$ 165 and the median R$ 108. The gap is the tail: a minority of big baskets pulls the arithmetic average up while leaving the middle customer where she was. The top 10 percent of customers by spend hold 38.3 percent of revenue, and every one of those large numbers pushes the mean right.

The consequence for the knife: "above the mean" on a skewed column is not "the top half", it is a minority, 29.3 percent here, and the more skewed the column the smaller that minority gets. The median is immune to the tail by construction. Neither is wrong. The mean answers "who spends more than the average customer's share of the money"; the median answers "who is in the upper half". Write down which question you are asking before choosing the knife, and expect the two to disagree more on spend than on recency, because money has a longer tail than time does.

Real world

Two analysts, one table. One cuts spend at the mean, the other at the median, both by habit. In the first report "high spend" is 29 percent of customers; in the second it is 50 percent. The dashboard shows both numbers in the same week and the marketing lead asks which one is broken. Neither is. Both analysts made a defensible choice, and the argument that followed was the cost of not writing the choice into the code where a diff would have ended it in one line.

Part 2 · covers ties

Identical customers, different scores

5 min live

Frequency on Olist is 97.0 percent ones. Ask pandas for fifths and it refuses, honestly, because there is no place to put four edges inside a column that is almost entirely the same value. The usual fix makes the error go away. It does not make the problem go away; it hides it inside the row order of your database.

ROW ORDER DECIDES: rank(method="first") ON A COLUMN THAT IS 97% THE SAME VALUE 90,557 identical one-time buyers, F = 1, in database row order scored 1 scored 5 score 1 18,672 once score 2 all bought once score 3 all bought once score 4 all bought once score 5 15,871 once + 2,801 repeat same customer, different score - the only difference between the two circled figures is which row they sit in Equal-count bins on a tied column are not a ranking. They are a shuffle, and the top fifth is whoever the database happened to store last.
🔍 Click to zoom - every repeat buyer does land in score 5, and so do 15,871 people who are indistinguishable from the ones scored 1
LiveRow order decides: identical customers, different scores3 min▶

Run pd.qcut(rfm["F"], 5) on Olist and pandas raises ValueError: Bin edges must be unique: [1, 1, 1, 1, 1, 15]. Read the edges: the 20th, 40th, 60th and 80th percentiles of frequency are all 1, because 97 percent of the column is 1. There is no honest place to put four knives. The error is pandas telling you the truth about your data.

The fix every tutorial reaches for is to rank first: pd.qcut(rfm["F"].rank(method="first"), 5). method="first" breaks ties by order of appearance, so the 90,557 identical one-time buyers get 90,557 different ranks, the edges become unique, and the error disappears. What has actually happened is that a column with almost no information has been given five equal bins by row order. On Olist that scores 18,672 identical one-time buyers a 1 and 15,871 identical one-time buyers a 5. The 2,801 repeat buyers all land in the top fifth too, which is correct, but they share it with fifteen thousand people who are no different from the ones in the bottom fifth.

Every downstream Champion built on that score is decided by that row order. It is the whole reason the vendor maps in Part 3 crown one-time buyers.

Self-studyWhat the fix is actually for2 min▶

The rank trick is not wrong in itself. It exists for spiky columns with real variety: a frequency column where most values are distinct but a few hundred customers share a value that happens to sit on a quintile edge. There, ranking breaks a tie among a few people and moves an edge by a few rows, which is a fair price for equal bins. The trick becomes arbitrary only when the tie is most of the column, as it is on any one-time-buyer base, which is most retail data.

The test before applying it: rfm["F"].value_counts(normalize=True).head(). If one value holds more than a fifth of the rows, fifths on that column are not fifths, whatever pandas returns, and the honest move is a cut with chosen edges (once, twice, three or more) or a different dimension altogether. The full quintile build, the personas it feeds, and the persona-migration crosstab between two snapshots live in the retention course, Retention a4; the mechanics are taught there and not repeated here. Tonight is about what the mechanics do to a base like this one.

Real world

The platform default. A marketing platform ships quintile RFM scoring out of the box and a shop with a one-time-buyer base turns it on. The scoring runs without complaint, because the platform's implementation breaks ties the same way the tutorials do. Its "top fifth by frequency" tile is then filled by whichever one-time buyers were stored last, a segment that changes every time the database is re-indexed and that nobody can explain to the owner. The tile is not lying about the arithmetic. It is lying about what a fifth means on this base.

Part 3 · covers the taxonomies the cuts feed

Eight groups, six groups, eleven groups

5 min live

A cut produces scores; a taxonomy turns scores into names. Two cuts per axis give 8 codes and 8 groups. Klaviyo's published rule, three levels per axis, gives 27 codes collapsed to six groups. Fifths per axis give 125 codes, which Putler's much-copied table collapses to eleven. On the same 93,358 people, read what each one calls "Champions".

THREE TAXONOMIES, ONE POPULATION rows are recency, columns are frequency and spend · hatched = what each map calls best Mean split 2 x 2 x 2 · 8 groups Klaviyo's rule 3 x 3 x 3 · 27 codes to 6 Fifths into Putler 5 x 5 x 5 · 125 codes to 11 F + M F + M F + M R R R 1.28% · 0% once "High value" = code 111 13.9% · 91% once "Champions" = 333, 332, 323 16.1% · 92% once "Champions" = R 4-5, F+M 4-5 HOW TO READ first number: share of 93,358 customers the map calls best second: how many of those bought exactly once, ever More cells did not find more Champions. Rank-based cells on a tied column found row order and called it loyalty.
🔍 Click to zoom - the two right-hand grids crown ten times as many people as the left one, and nine in ten of them bought once
Cut and mapCellsCalled "best"Share of customersOf whom bought exactly once
Mean split, eight groups8High value (111)1.28%0%
Klaviyo's rule, six groups27, collapsed to 6Champions (333, 332, 323)13.9%91.0%
Fifths into Putler's eleven125, collapsed to 11Champions (R 4-5, F+M 4-5)16.1%92.1%
THE TUTORIAL ROUTE: FIFTHS, THEN A KNIFE AT 3, THEN EIGHT GROUPS 1 2 3 4 5 rank each column into fifths, 1 to 5 above 3 = "high" 4 or 5 becomes 1 1 to 3 becomes 0 "011" = Key win-back the same eight names a rank split at the top two fifths per axis FIFTHS, THEN COLLAPSE · on the 93,358 111 High value: 6.92% of whom 84.8 percent bought exactly once 011 Key win-back: 9.80%, of whom 86.3 percent bought once MEAN SPLIT · same people 111 High value: 1.28% of whom nobody bought once 011 Key win-back: 0.79%, every one a repeat buyer Same eight names, same three digits. The knife at 3 on a ranked, 97-percent-tied column is what fills the corners with first-time buyers.
🔍 Click to zoom - the collapse step looks like a simplification of the fifths and is really a second knife, placed at the top two fifths by construction
LiveThe tutorial route: fifths first, then collapse to eight3 min▶

Most RFM tutorials, including the one many analysts learn from, do not put the knife at the mean. They rank each column into fifths with pd.qcut, then collapse: a score of 4 or 5 becomes a 1, a score of 1 to 3 becomes a 0, and the three bits are read as the eight groups of session 1. It looks like the same taxonomy with a tidier scoring step in front of it.

It is a different knife. Collapsing at 3 is a rank split at the top two fifths of each axis, by construction, so 40 percent of customers are "high" on every column regardless of the column's shape. On frequency that is impossible to do honestly: 97 percent of the column is the value 1, so the fifths come from row order (Part 2), and the collapse then promotes about two fifths of identical one-time buyers to "frequent". On Olist the route calls 6.92 percent of customers High value, and 84.8 percent of them bought exactly once; its Key win-back group is 9.8 percent of customers and 86.3 percent of those bought once. The mean split on the same people: 1.28 percent High value with nobody who bought once, 0.79 percent Key win-back and every one a repeat buyer.

Neither is the wrong taxonomy. The eight names are identical. What differs is where the knife sits and whether the column can bear it, and that is a decision to write down, not inherit. Session 3 runs both on the same 4,000 people side by side.

LiveMore cells is not more truth3 min▶

The mean split's High value corner is small because "frequent" means above the mean of 1.033 orders, which is two or more, and only 3.0 percent of Olist customers ever got there. Zero percent of High value bought once, by construction. It is a tiny group, and it is a true one.

Klaviyo's rule scores recency by calendar (3 if the last order was within 180 days, 2 within 365, else 1) and frequency and spend by rank thirds. Rank thirds on a 97-percent-ones column is the Part 2 row-order problem with three bins instead of five: two thirds of the one-time buyers are handed an F of 2 or 3 by row order. Champions are the codes 333, 332 and 323, which lands on 13.9 percent of customers, and 91.0 percent of them bought once. Putler's table takes fifths on all three axes, averages F and M, and calls R 4-5 with F+M 4-5 Champions: 16.1 percent, 92.1 percent of them one-time buyers. On this base the two vendor maps agree with each other and disagree with the data.

Replication note. Neither Klaviyo nor Putler documents how its live product breaks ties on a column that is 97 percent the same value. The figures above use the pandas rank(method="first") convention, which is what every tutorial that "fixes" the duplicate-edge error does. They are a property of that convention on this base, not a claim about either product's output on your account. Ask your vendor the tie question directly; the honest answer is worth more than the segment tile.

Self-studyKlaviyo's and Putler's published rules, quoted4 min▶

Klaviyo (help centre article 17797937793179). Recency: 3 if the customer bought within the last 180 days, 2 if within 365 days, 1 otherwise. Frequency and monetary: each ranked into thirds, 3 for the top third. The 27 three-digit codes collapse into six groups:

GroupCodes
Champions333, 332, 323
Loyal321, 322, 331, 232, 233
Recent312, 313, 311, 222, 223
Needs attention213, 221, 123, 132, 133
At risk231, 212, 122, 131, 211
Inactive111, 112, 113, 121

On Olist that produces Recent 21.6 percent, Loyal 21.5 percent, At risk 17.3 percent, Needs attention 15.5 percent, Champions 13.9 percent. Needs attention, the natural win-back audience, is 96.7 percent one-time buyers.

Putler (putler.com/rfm-analysis). Each of R, F and M ranked into fifths, 5 best; F and M averaged into one score; eleven segments by ranges of R and F+M: Champions R 4-5 and F+M 4-5; Loyal R 2-5 and F+M 3-5; Potential loyalist R 3-5 and F+M 1-3; New R 4-5 and F+M 0-1; Promising R 3-4 and F+M 0-1; Needs attention R 2-3 and F+M 2-3; About to sleep R 2-3 and F+M 0-2; At risk R 0-2 and F+M 2-5; Can't lose R 0-1 and F+M 4-5; Hibernating R 1-2 and F+M 1-2; Lost R 0-2 and F+M 0-2. The published ranges overlap, so a customer can match more than one; the replication takes the first match reading top to bottom, and the build-along says so in the code.

On Olist that produces Loyal 31.6 percent, Potential loyalist 24.3 percent, At risk 19.2 percent, Champions 16.1 percent, and four of the eleven segments empty: New, Promising, Can't lose and Lost never fill because averaging F and M fifths never reaches the corner values when 97 percent of F is tied. At risk plus Can't lose, the win-back audience, is 97.6 percent one-time buyers.

Real world

The Champions tile. A shop switches on an RFM dashboard with Klaviyo-style defaults and the tile says 14 percent of customers are Champions. The team plans a loyalty tier around them. Someone counts how many of those Champions have a second order in the system and the answer is roughly one in ten. The tile was computed correctly from its own rule; the rule was written for a base where thirds mean something. The question that would have saved the quarter is not "is the maths right" but "what does the top third of a column that is 97 percent ones contain".

Part 4 · covers small N

When the pile is 300 people

3 min live

Everything so far was on 93,358 rows, where a 1.28 percent group is still 1,195 people. Now a constructed case: a WeChat community shop, a 私域 (private-domain community) group of 300 customers, one of whom is a household that buys for a whole residential compound. The shop is invented. The shape is not, and it breaks every rank-based cut on the page.

A CONSTRUCTED SHOP: 300 CUSTOMERS, ONE HOUSEHOLD BUYING FOR A COMPOUND invented for the shape · the dots stand for 300 people, not counted one by one median stays put mean, dragged right one buyer's spend moves it, and moves every "above the mean" verdict with it one household buying for a compound spend 300 customers fifths = 60 people a bin 5% of 300 = 15 people, not a segment the owner knows every one of them by name Small N: the median or a threshold the owner wrote down, plus what the owner knows. Rank-based fifths are precision the data does not have.
🔍 Click to zoom - the household is real business, not an outlier to delete, and it is exactly why the mean is the wrong knife here
LiveSixty people a bin, and one of them is a building3 min▶

Say the shop has 300 customers in its community group. Fifths put 60 in each bin, thirds put 100. A "Champions" segment of 5 percent is 15 people. Percentages below that are not segments; they are a short list of names the owner could recite. Any rank-based cut on 300 rows manufactures a precision the pile does not have, and the tie problem from Part 2 is worse, not better, because a community shop's frequency column is often a handful of values shared by dozens.

Then the household. One member buys for an entire compound, so her spend is many times anyone else's. The mean of the spend column is dragged toward her, and "above the mean" is suddenly a group of a few dozen people who happen to be on the near side of one buyer's total. The median does not move; a threshold the owner would write down ("regulars are anyone over 500 yuan a quarter") does not move either. The honest cut at this size is a median or a merchant threshold, plus owner knowledge: the owner already knows which twenty customers matter, and the model's job is to confirm and organise that, not to overrule it with fifths.

Your numbers will differ, and no output is printed for this shop anywhere on the page, because it is invented. What transfers is the check: divide your N by the number of bins before you choose the knife, and if the answer is a room of people rather than a population, cut with edges you can defend out loud.

Real world

This shop is invented, and the pattern is everywhere in 私域 (private-domain community). Community selling on WeChat runs at exactly this scale: a few hundred contacts in a group, one seller, orders taken in chat. One member routinely buys for neighbours, a dormitory or a family compound, because that is how group buying works, and her total is a business line, not a data error. Every tutorial cut assumes a population; this is a room. Vendor claims that private-traffic repeat rates run several times higher than public-platform rates circulate widely and are not verified by any primary source read for this page, so treat them as marketing until you have measured your own room.

Build-along

Five cuts on the same 93,358 customers

★ 22 min · everyone builds

Start from the table session 1 saved, rfm_2018-09-01.csv, one row per customer with R, F and M. Nine steps: one report function, then six knives through it (mean, median, fixed edges, Klaviyo thirds, fifths into Putler, fifths then collapse), then a table that puts the four "Champions" shares side by side. Every number in a comment is what the code prints on Olist; if yours differs, the difference is the knife.

STEP 5 OF LAST WEEK'S PIPELINE, EXPLODED INTO FIVE KNIVES rfm_2018-09-01.csv 93,358 rows · R, F, M from session 1, unchanged 2 · mean split, 8 groups 111 High value 1.28% · 0% once 3 · median split, 8 groups 111 High value 1.46% · 011 1.18% 4 + 6 · fifths, then Putler's 11 Champions 16.1% · 92.1% once 5 · Klaviyo's rule, 27 codes to 6 Champions 13.9% · 91.0% once merchant thresholds the owner's numbers · session 3 bench the knife, five times 7 · one comparison table three "Champions" shares and how many bought once Steps 1 to 4 of the pipeline never change. Everything downstream of the knife is a different table each time.
🔍 Click to zoom - the report function in step 1 is what makes five knives comparable; without it you would have five printouts and an argument

Load the saved table and write the report once. Every cut below produces a Series of group labels aligned to the table; the report takes that Series plus the labels that mean "best" and "win back", and prints the same four things every time.

★ Step 1 · one report for every knifeimport pandas as pd rfm = pd.read_csv("rfm_2018-09-01.csv", index_col=0) # R, F, M from session 1 print(len(rfm), (rfm["F"] == 1).mean().round(3)) # 93358 0.970 def cut_report(rfm, scores, top_rule, winback_rule): """scores: a Series of group labels, same index as rfm. top_rule / winback_rule: the labels that count as best / win-back.""" is_top = scores.isin(top_rule) is_win = scores.isin(winback_rule) print(f"top group {is_top.mean():.2%} of customers, " f"{rfm.loc[is_top, 'M'].sum() / rfm['M'].sum():.2%} of revenue") print(f"bought once {(rfm.loc[is_top, 'F'] == 1).mean():.1%} of the top group") print(f"win-back {is_win.mean():.2%} of customers, {is_win.sum():,} people")

The mean split, as a function of the centre. Same as last week, but the centre is a parameter so step 3 can swap it in one word. Recency is reversed.

★ Step 2 · mean splitdef eight(rfm, centre): r = (rfm["R"] < centre(rfm["R"])).astype(int) # fewer days is the 1 f = (rfm["F"] > centre(rfm["F"])).astype(int) m = (rfm["M"] > centre(rfm["M"])).astype(int) return r.astype(str) + f.astype(str) + m.astype(str) code_mean = eight(rfm, pd.Series.mean) cut_report(rfm, code_mean, top_rule=["111"], winback_rule=["011"]) # top group 1.28% of customers, 3.06% of revenue # bought once 0.0% of the top group # win-back 0.79% of customers, ... people

The median split. One word changes. The frequency median is 1, so "frequent" still means two or more; the spend knife drops from R$ 165 to R$ 108 and every group that has a 1 in the third digit grows.

★ Step 3 · median splitcode_median = eight(rfm, pd.Series.median) print((code_median.value_counts(normalize=True) * 100).round(2)) # 000 25.50 100 24.14 101 23.95 001 23.40 # 111 1.46 011 1.18 110 0.18 010 0.18 cut_report(rfm, code_median, top_rule=["111"], winback_rule=["011"]) # the win-back group 011 is 1.18% here against 0.79% at the mean: same people, bigger audience

Fifths on frequency: the error, then the fix, then what the fix did. Let the error print. Then rank first, and cross-tabulate the real order count against the score it was given.

★ Step 4 · qcut refuses, rank "fixes" ittry: pd.qcut(rfm["F"], 5) except ValueError as e: print(e) # Bin edges must be unique: [1, 1, 1, 1, 1, 15] ... rfm["Fq"] = pd.qcut(rfm["F"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int) print(pd.crosstab(rfm["F"].clip(upper=3), rfm["Fq"])) # row F=1: 18,672 customers scored 1 ... 15,871 scored 5, by row order alone # rows F=2 and F=3+: every repeat buyer sits in Fq 5, sharing it with those 15,871

Fixed edges with pd.cut, and the row it quietly drops. The tutorial edges for recency are 0, 50, 100, 200, 300 and 365 days. On a file whose recency runs to 715 days, every customer past the last edge becomes NaN, and nothing warns you.

★ Step 5 · pd.cut on recency, the tutorial edgesedges = [0, 50, 100, 200, 300, 365] rfm["R_bin"] = pd.cut(rfm["R"], edges) # left-open, right-closed: (0, 50], (50, 100] ... print(rfm["R_bin"].value_counts().sort_index()) # (0, 50] 10662 # (50, 100] 8819 # (100, 200] 23013 # (200, 300] 21159 # (300, 365] 8733 print(rfm["R_bin"].isna().sum()) # 20972 customers past 365 days: NaN, 22.5% of the file # the same column with counts fixed instead of edges: fifths, edges wherever the data puts them rfm["R_score"] = pd.qcut(rfm["R"], 5, labels=[5, 4, 3, 2, 1]) # reversed: fewest days = 5 print(pd.qcut(rfm["R"], 5, retbins=True)[1].round()) # [2, 94, 179, 270, 384, 715] print(rfm["R_score"].value_counts().sort_index()) # about 18,700 in each fifth

Klaviyo's rule. Recency by calendar, 180 and 365 days; frequency and spend by rank thirds, with the same rank trick; then the published dictionary from 27 codes to six groups.

★ Step 6 · Klaviyo's sixKLAVIYO = { "Champions": ["333", "332", "323"], "Loyal": ["321", "322", "331", "232", "233"], "Recent": ["312", "313", "311", "222", "223"], "Needs attention": ["213", "221", "123", "132", "133"], "At risk": ["231", "212", "122", "131", "211"], "Inactive": ["111", "112", "113", "121"], } TO_GROUP = {code: g for g, codes in KLAVIYO.items() for code in codes} def thirds(s): return pd.qcut(s.rank(method="first"), 3, labels=[1, 2, 3]).astype(int) def klaviyo(rfm): Rk = pd.Series(1, index=rfm.index) Rk[rfm["R"] <= 365] = 2 Rk[rfm["R"] <= 180] = 3 code = Rk.astype(str) + thirds(rfm["F"]).astype(str) + thirds(rfm["M"]).astype(str) return code.map(TO_GROUP) kl = klaviyo(rfm) cut_report(rfm, kl, top_rule=["Champions"], winback_rule=["Needs attention"]) # top group 13.90% of customers ... # bought once 91.0% of the top group # win-back 15.50% of customers ... (96.7% of them bought once)

Putler's eleven. Fifths on all three, F and M averaged, half scores rounded up, then the published ranges. The ranges overlap, so the function reads top to bottom and the first match wins; that is the convention, and it is written where the next reader can see it.

★ Step 7 · fifths into Putler's mapdef fifths(s, labels): return pd.qcut(s.rank(method="first"), 5, labels=labels).astype(int) def putler_name(R, FM): # published ranges overlap; first match wins, top to bottom if R >= 4 and FM >= 4: return "Champions" if R >= 2 and FM >= 3: return "Loyal" if R >= 3 and FM <= 3: return "Potential loyalist" if R >= 4 and FM <= 1: return "New" if R >= 3 and FM <= 1: return "Promising" if R in (2, 3) and FM in (2, 3): return "Needs attention" if R in (2, 3) and FM <= 2: return "About to sleep" if R <= 2 and FM >= 2: return "At risk" if R <= 1 and FM >= 4: return "Can't lose" if R in (1, 2) and FM in (1, 2): return "Hibernating" return "Lost" def putler(rfm): Rq = fifths(rfm["R"], [5, 4, 3, 2, 1]) # reversed Fq = fifths(rfm["F"], [1, 2, 3, 4, 5]) Mq = fifths(rfm["M"], [1, 2, 3, 4, 5]) FM = (Fq + Mq + 1) // 2 # average, halves round up return pd.Series([putler_name(r, fm) for r, fm in zip(Rq, FM)], index=rfm.index) pt = putler(rfm) cut_report(rfm, pt, top_rule=["Champions"], winback_rule=["At risk", "Can't lose"]) # top group 16.10% of customers ... # bought once 92.1% of the top group # win-back ... (97.6% of them bought once) print(pt.value_counts()) # 7 of 11 non-empty: New, Promising, Can't lose, Lost never fill

The tutorial route: fifths, then collapse to eight. Score every column 1 to 5 by rank, call a score above 3 "high", read the bits as the same eight groups. It is a rank split at the top two fifths of each axis, so on frequency it inherits step 4's row-order scoring before the collapse.

★ Step 8 · fifths, then collapsedef collapse(rfm): Rq = fifths(rfm["R"], [5, 4, 3, 2, 1]) Fq = fifths(rfm["F"], [1, 2, 3, 4, 5]) Mq = fifths(rfm["M"], [1, 2, 3, 4, 5]) hi = lambda q: (q > 3).astype(int).astype(str) # 4 or 5 -> "1", 1 to 3 -> "0" return hi(Rq) + hi(Fq) + hi(Mq) code_collapse = collapse(rfm) cut_report(rfm, code_collapse, top_rule=["111"], winback_rule=["011"]) # top group 6.92% of customers, 13.1% of revenue # bought once 84.8% of the top group # win-back 011 9.80% of customers ... (86.3% of them bought once) # Compare the mean split: 111 was 1.28% with nobody who bought once, 011 was 0.79% and all repeat buyers.

One table, four "Champions". The same people, four shares, and a column that says how many of the best never came back.

★ Step 9 · the comparisonrows = [] for name, scores, top in [("Mean split, 8 groups", code_mean, ["111"]), ("Fifths then collapse, 8", code_collapse, ["111"]), ("Klaviyo's rule, 6", kl, ["Champions"]), ("Fifths into Putler", pt, ["Champions"])]: is_top = scores.isin(top) rows.append({"cut": name, "called best %": round(is_top.mean() * 100, 2), "of whom bought once %": round((rfm.loc[is_top, "F"] == 1).mean() * 100, 1)}) print(pd.DataFrame(rows).to_string(index=False)) # cut called best % of whom bought once % # Mean split, 8 groups 1.28 0.0 # Fifths then collapse, 8 6.92 84.8 # Klaviyo's rule, 6 13.90 91.0 # Fifths into Putler 16.10 92.1

Keep the cut in the filename too. segments_2018-09-01_mean.csv, segments_2018-09-01_klaviyo.csv. A segment table without its knife written on it is the Part 1 argument waiting to happen again next quarter, and session 6 will need both the date and the cut to compare two runs honestly.

Homework

Before session 3

30 min
  1. Run all five cuts on your own table, or on Olist if you do not have one. Use the step 1 report unchanged so the five printouts line up, and keep the printouts.
  2. Find out which cut your marketing tool uses. Open its documentation, not its dashboard, and write down the knife: fixed days or ranks for recency, thirds or fifths or thresholds for the rest, and whether it says anything at all about ties. If it does not, write "ties: undocumented".
  3. Count how many of your "Champions" bought once under each of the five cuts. On Olist the answers are 0 percent, 0 percent, 91 percent, 92 percent and whatever your own thresholds give. Yours will differ; the spread between them is the point.
  4. Bring the numbers to session 3. A 4,000-customer sample of this table goes on a bench where six cuts are computed live, and the win-back audience each one draws gets costed at R$ 0.30 a message.
Check yourself

Three questions

Clear all three and session 2 gets stamped in your passport.

1 · pd.qcut(rfm["F"], 5) raises "Bin edges must be unique: [1, 1, 1, 1, 1, 15]" on Olist. What is going on, and what does the usual rank(method="first") fix do?

The error is pandas being honest about a column with almost no information in it. Ranking first makes the error go away by turning row order into a score, and every rank-based segment downstream inherits that arbitrariness. Check value_counts before reaching for fifths; if one value holds more than a fifth of the rows, fifths are not fifths.

2 · On a base where 97 percent of customers bought once, Klaviyo's rule calls 13.9 percent Champions and Putler's map calls 16.1 percent Champions, and about nine in ten of each are one-time buyers. Why?

More cells is not more truth. The top third or top fifth of a column that is 97 percent the same value contains row order, not loyalty, and both maps then read that score as if it meant something. The replication note matters here: neither vendor documents its tie handling, so this is a property of the standard pandas convention on this base, not a claim about either product's live output.

3 · Key win-back (011) is 0.79 percent of Olist customers with the knife at the mean and 1.18 percent with it at the median. Which statement is right?

Same people, same date, one knife moved on one column. The mean sits right of the median on a skewed spend column, so "above the mean" is a stricter test than "above the median" and every group with a 1 in the third digit is smaller under it. Session 3 puts a price on the difference: a bigger win-back audience costs more messages and reaches a lower share of true repeat buyers.

Sources covered

What this session teaches, and where it came from

✓Five cuts on the Olist populationBrazilian E-Commerce Public Dataset by Olist, Kaggle, CC BY-NC-SA 4.0; mean, median, thirds, fifths and the vendor maps all recomputed on 93,358 customers in this build, snapshot 2018-09-01
✓pandas cut and qcut behaviourRight-closed intervals, the duplicate-edge error text and the rank(method="first") tie-break as documented and as run; the 18,672 and 15,871 counts measured here
✓Klaviyo's RFM thresholdsQuoted from Klaviyo help centre article 17797937793179: 180 and 365 days for recency, thirds for frequency and spend, six groups with their code lists; tie handling is not documented and the page says so
✓Putler's eleven-segment tableSegment names and score ranges quoted from putler.com/rfm-analysis; overlapping ranges resolved first-match top to bottom, stated in the code; four segments empty on this base
◐The WeChat community shop, N = 300Constructed and labelled as such; no output printed for it. The private-traffic repeat-rate multiple that circulates is an industry claim and is labelled unverified

Every fact on this page, and its verification tier, is recorded in the course's source map.

Session 2 cheat sheet · pin this

cut fixes edges, qcut fixes countsRight-closed by default. Recency labels reversed: [5, 4, 3, 2, 1].
Five knives on spendMedian R$ 108 (50%) · thirds (33%) · mean R$ 165 (29.3%) · fifths (20%) · owner's R$ 300 (about 10%).
Check ties before fifthsvalue_counts first. One value over a fifth of the rows means fifths are row order.
Row order, in numbers18,672 identical F = 1 customers scored 1; 15,871 scored 5. Same people.
Three "Champions"Mean split 1.28%, 0% once · Klaviyo 13.9%, 91% once · Putler 16.1%, 92% once.
Small NDivide N by bins first. 5% of 300 is 15 names. Median or owner threshold, plus the owner.