Segment online-shop customers
- Case study 6
- 120 min
- Optional · 1 case study needed for the certificate
Case study 6 of 7 · Machine Learning: From Problem to Reliable ModelSegment online-shop customers
1. Understand machine learning
2. Frame the prediction problem
3. Evaluate supervised models
4. Build the first models
5. Try other model families
6. Tune and finish the model
7. Understand and choose the model
8. Prepare the model for handoff
What you will be able to do
Turn two years of real online-shop invoices into customer segments that a CRM team can act on. Most of the work is cleaning. Then you group the customers with k-means, and you choose the number of groups with three kinds of evidence. Next comes the check that most tutorials skip: did the segments predict what the customers did in the next six months? At the end, you compare the clusters with a simple score that anyone can compute, and you decide which one the team should use.
The notebook has every code block of this page, in the same order, with three tasks and their checks. Its setup cells install the packages and download the data (15 MB), so it needs no local setup. It runs in about one minute on a laptop. Read the explanations here, and run the code there.
Before you startWhat to know and to have ready
What you need to know
- Clustering, and why a person must judge the groups, from Unsupervised learning.
- k-means, scaling before clustering, inertia and silhouette, from Unsupervised exploration.
- Pipelines, from Preprocess with a pipeline.
- The sealed test set, from Train, validation and test sets, and ROC AUC, from The precision–recall trade-off.
- The experiment log, from Machine learning is iterative.
- Why a simpler method can be the better choice, from When a simple model is better.
What you need on your computer (only for the local route; the notebook needs none of it)
- A Python environment with pandas 3.0.6, scikit-learn 1.9.1 and matplotlib. The
.venvof your course project from Define a baseline: Set up your project has them. Ifpython -c "import sklearn"fails, activate the environment again. - A new, empty folder for this case study, for example
~/projects/case-studies. Open the notebook there. Its setup cell makes a foldersegment-online-shop-customersand downloads the data into it. To start again, delete that folder and run the notebook from the top.
Tested on macOS with Python 3.14.6, pandas 3.0.6, scikit-learn 1.9.1 and matplotlib 3.11.2, in Jupyter. Windows, Colab and Kaggle are not tested.
The situation
Section titled “The situation”A UK online shop sells gifts and homeware. Many of its customers are small shops that buy to resell, so a good customer orders again and again. Hannah, the shop’s CRM lead, plans the second half of the year. She has three tools and a fixed budget for each:
- VIP care for about 750 customers: free delivery and a named contact person. It is expensive, so it must go to the customers who really are the most valuable.
- A win-back offer: a 10% voucher by email for customers who have gone quiet. A voucher sent to a customer who would buy anyway is lost margin.
- Nurturing: a monthly email with new products, for everyone else. It is cheap.
Each mistake has a cost. VIP care for a customer who leaves anyway wastes money. A best customer who gets nothing may feel ignored and leave. A voucher for a loyal customer gives away margin for nothing. Owen, the finance manager, asks one question of every segment: “If I spend money on this group, will they buy more than the others?”
Hannah, Owen and their plans are made up for this case study. The data is real: it is the shop’s own invoice data, published for research.
What you will build
Section titled “What you will build”You will build a customer segmentation: one row per customer, with a segment name for Hannah’s email tool. You also give evidence that the segments mean something. A segment is a group of customers that get the same treatment. You make the segments from three numbers per customer, the classic RFM summary: how recently they bought, how often, and how much. You check the segments against what the customers did in the next six months, and you compare them with a simple score.
The case study uses these skills from the course:
- checking and cleaning data before any model;
- k-means after scaling, with inertia and silhouette (Unsupervised exploration);
- a pipeline that learns the scaling from the data (Preprocess with a pipeline);
- a baseline to beat (Define a baseline);
- an evaluation on data that stays sealed until the end (Train, validation and test sets);
- the choice between a complex and a simple method (When a simple model is better).
The data
Section titled “The data”The data is Online Retail II from the UCI Machine Learning Repository. It has every invoice line of the shop from 1 December 2009 to 9 December 2011. Daqing Chen published it under the Creative Commons Attribution 4.0 licence (CC BY 4.0), which allows reuse with credit.
Chen, D. (2012). Online Retail II [Dataset]. UCI Machine Learning Repository. https://doi.org/10.24432/C5CG6D. CC BY 4.0.
The course keeps a fixed copy in its labs repository. The source is an Excel file with two sheets, one per year. The course joined them into one compressed CSV file and changed nothing else. The dataset card lists the changes, as the licence requires.
One row is one invoice line: one product on one invoice. The columns:
| Column | What it means |
|---|---|
Invoice |
The invoice number. A number that starts with C is a cancellation: the customer returned the goods. |
StockCode |
The product code, usually five digits. |
Description |
The product name. |
Quantity |
Units on the line; negative on a return. |
InvoiceDate |
Date and time of the invoice. |
Price |
Price of one unit, in pounds (£). |
Customer ID |
The customer’s number. |
Country |
The customer’s country. |
Load it. Invoice and StockCode are read as text, because some of them contain letters.
lines = pd.read_csv("data/online_retail_ii.csv.gz", dtype={"Invoice": str, "StockCode": str}, parse_dates=["InvoiceDate"])print(f"{len(lines):,} lines, {lines.shape[1]} columns")print("from", lines["InvoiceDate"].min(), "to", lines["InvoiceDate"].max())lines.head(4)The output:
1,067,371 lines, 8 columnsfrom 2009-12-01 07:45:00 to 2011-12-09 12:50:00 Invoice StockCode Description Quantity InvoiceDate Price Customer ID Country0 489434 85048 15CM CHRISTMAS GLASS BALL 20 LIGHTS 12 2009-12-01 07:45:00 6.95 13085.0 United Kingdom1 489434 79323P PINK CHERRY LIGHTS 12 2009-12-01 07:45:00 6.75 13085.0 United Kingdom2 489434 79323W WHITE CHERRY LIGHTS 12 2009-12-01 07:45:00 6.75 13085.0 United Kingdom3 489434 22041 RECORD FRAME 7" SINGLE SIZE 48 2009-12-01 07:45:00 2.10 13085.0 United KingdomWhat this tells us. There are a little over a million lines, over two years and nine days. The first four lines are one invoice: customer 13085 bought four products at 07:45 on 1 December 2009. Customer ID shows as 13085.0, a decimal number. That is a sign of missing values: pandas stores a column of whole numbers with gaps as decimals.
First checks
Section titled “First checks”Before you build anything, count the problems. Each line below asks one question.
missing_id = lines["Customer ID"].isna()returns = lines["Invoice"].str.startswith("C")print(f"no customer ID: {missing_id.sum():>8,} lines ({missing_id.mean():.1%})")print(f"exact repeated lines: {lines.duplicated().sum():>8,}")print(f"return lines (C...): {returns.sum():>8,}")print(f"quantity below 0: {(lines['Quantity'] < 0).sum():>8,}")print(f"price 0 or below: {(lines['Price'] <= 0).sum():>8,}")print(f"countries: {lines['Country'].nunique()}, United Kingdom {(lines['Country'] == 'United Kingdom').mean():.1%}")The output:
no customer ID: 243,007 lines (22.8%)exact repeated lines: 34,335return lines (C...): 19,494quantity below 0: 22,950price 0 or below: 6,207countries: 43, United Kingdom 91.9%What this tells us. Almost a quarter of the lines have no customer. A segment is a group of customers, so these lines cannot be used. 34,335 lines are exact copies of another line, in every column. There are more lines with a negative quantity (22,950) than return lines (19,494), so some negative quantities are not returns. Most customers are in the United Kingdom. Each problem needs a decision, and the next checks find the reasons.
Why are there so many exact repeats? Look at the days on which they happen:
repeated = lines[lines.duplicated()]in_overlap = repeated["InvoiceDate"].between("2010-12-01", "2010-12-09 23:59")print(f"{len(repeated):,} repeated lines; {in_overlap.sum():,} of them are from 1 to 9 December 2010")The output:
34,335 repeated lines; 22,844 of them are from 1 to 9 December 2010What this tells us. Two thirds of the repeats are in nine days. The source file has one sheet per year, and both sheets contain 1 to 9 December 2010. So these lines are the same sales, recorded twice by the way the file was made. The other 11,491 repeats are spread over the two years. They are probably the same product scanned twice on one invoice, but nobody can be sure. You remove all exact repeats, and you write this choice down.
Next, the product codes. A product code has five digits, sometimes with a letter. Look at the codes that do not:
odd = lines[~lines["StockCode"].str.match(r"^\d{5}")]print(f"{len(odd):,} lines with a stock code that does not start with five digits")(odd.groupby("StockCode") .agg(lines=("Invoice", "size"), example=("Description", "first"), price=("Price", "median")) .sort_values("lines", ascending=False).head(12))The output:
6,093 lines with a stock code that does not start with five digits lines example priceStockCodePOST 2122 POSTAGE 18.000DOT 1446 DOTCOM POSTAGE 154.180M 1421 Manual 12.680C2 282 CARRIAGE 50.000D 177 Discount 19.000S 104 SAMPLES 29.995BANK CHARGES 102 Bank Charges 42.120ADJUST 67 Adjustment by john on 26/01/2010 16 49.350AMAZONFEE 43 AMAZON FEE 6497.470DCGS0058 31 MISO PRETTY GUM 0.830gift_0001_20 29 Dotcomgiftshop Gift Voucher £20.00 17.020gift_0001_30 29 Dotcomgiftshop Gift Voucher £30.00 25.530What this tells us. Most of these lines are not goods. They are postage, carriage, manual corrections, discounts, bank charges, marketplace fees and gift vouchers. They are real money, but they say nothing about what a customer buys. An Amazon fee of £6,497 on one line would make a customer look rich. DCGS0058 is different: it is a real product (chewing gum) with an unusual code. So you cannot remove “every code that is not five digits”. Instead, you list the non-product codes by name. The dataset card has the full list.
Last, look at the largest quantities:
huge = lines["Quantity"].abs() > 50_000lines.loc[huge, ["Invoice", "StockCode", "Description", "Quantity", "Price", "Customer ID", "InvoiceDate"]]The output:
Invoice StockCode Description Quantity Price Customer ID InvoiceDate587080 541431 23166 MEDIUM CERAMIC TOP STORAGE JAR 74215 1.04 12346.0 2011-01-18 10:01:00587085 C541433 23166 MEDIUM CERAMIC TOP STORAGE JAR -74215 1.04 12346.0 2011-01-18 10:17:001065882 581483 23843 PAPER CRAFT , LITTLE BIRDIE 80995 2.08 16446.0 2011-12-09 09:15:001065883 C581484 23843 PAPER CRAFT , LITTLE BIRDIE -80995 2.08 16446.0 2011-12-09 09:27:00What this tells us. Customer 12346 ordered 74,215 storage jars for £77,184 and cancelled the order 16 minutes later. Customer 16446 did the same with 80,995 paper crafts. These are mistakes, not purchases. If you remove the returns and keep the orders, customer 12346 becomes one of the shop’s biggest customers. So returns must stay in the data, and spend must be net: purchases minus returns.
Clean the lines
Section titled “Clean the lines”Now each problem gets a decision. Hannah agrees with these rules, because each one follows from her question: “Which registered customers buy goods from us, and how much?”
| Problem | Decision | Reason |
|---|---|---|
| Exact repeated lines | Remove | Most are the overlap of the two sheets; a repeat would count a sale twice. |
| No customer ID | Remove | The line cannot be linked to a customer, and Hannah cannot contact a customer without an ID. |
| Non-product codes | Remove | Postage, fees and vouchers are not purchases of goods. |
| Price 0 or below | Remove | Free items and accounting corrections, not sales. |
Returns (C…) |
Keep | Spend must be net of returns, or mistaken orders count as sales. |
The code records each step, so that the report can say how many lines each rule removed:
NOT_PRODUCTS = ["POST", "DOT", "C2", "C3", "M", "m", "D", "S", "B", "BANK CHARGES", "ADJUST", "ADJUST2", "AMAZONFEE", "CRUK", "TEST001", "TEST002", "GIFT"]steps = []
def keep(df, rows_to_keep, reason): """Keep some lines, and record how many were removed and why.""" steps.append({"removed because": reason, "lines removed": int((~rows_to_keep).sum()), "lines left": int(rows_to_keep.sum())}) return df[rows_to_keep]
clean = keep(lines, ~lines.duplicated(), "exact repeat of another line")clean = keep(clean, clean["Customer ID"].notna(), "no customer ID")product = ~clean["StockCode"].isin(NOT_PRODUCTS) & ~clean["StockCode"].str.startswith("gift_")clean = keep(clean, product, "not a product (postage, fee, voucher)")clean = keep(clean, clean["Price"] > 0, "price 0 or below")pd.DataFrame(steps)The output:
removed because lines removed lines left0 exact repeat of another line 34335 10330361 no customer ID 235151 7978852 not a product (postage, fee, voucher) 3641 7942443 price 0 or below 61 794183What this tells us. 794,183 lines are left, 74.4% of the start. The order of the steps changes the counts. For example, 6,207 lines had a price of 0 or below. Only 61 of them are left at the last step, because almost all had no customer ID. So report the counts in the order you applied the rules. The lines without a customer ID are the biggest loss. They may be guest buyers who never registered, so the segments describe registered customers only. That goes in the limits.
Now add two columns: the customer ID as text, and the amount of each line in pounds. Then check that the returns behave as expected.
clean = clean.assign(customer=clean["Customer ID"].astype(int).astype(str), amount=clean["Quantity"] * clean["Price"])returns = clean["Invoice"].str.startswith("C")print(f"{clean['customer'].nunique():,} customers, {clean['Invoice'].nunique():,} invoices")print(f"return lines: {returns.sum():,}; all with a negative quantity: {(clean.loc[returns, 'Quantity'] < 0).all()}")print(f"negative quantity but not a return: {((clean['Quantity'] < 0) & ~returns).sum()}")print("customer 12346, net amount per invoice (£):")print(clean[clean["customer"] == "12346"].groupby("Invoice")["amount"].sum().round(2).to_string())The output:
5,875 customers, 43,877 invoicesreturn lines: 17,587; all with a negative quantity: Truenegative quantity but not a return: 0customer 12346, net amount per invoice (£):Invoice499763 27.05513774 142.31541431 77183.60C541433 -77183.60What this tells us. The clean data has 5,875 customers. Every return line has a negative quantity, and no other line does. The 3,456 negative lines that were not returns (22,950 − 19,494) were all among the removed lines. Customer 12346’s mistaken order and its return cancel out exactly, so the customer’s net spend is £27.05 + £142.31 = £169.36.
Frame the problem
Section titled “Frame the problem”Clustering has no target, so the questions of Translate a need change a little. Here are the answers for Hannah’s plan.
- The unit is one customer, because Hannah acts on customers, not on invoices.
- The decision time is the day she makes the plan. In this case study it is 1 June 2011: the snapshot date. Everything you compute for a customer uses only the lines before that date, as if today were 1 June 2011.
- The check. There is no label to compare with. But the data continues after the snapshot date. So you can see what the customers really did in the next six months, June to November 2011. This is the clustering version of a sealed test set. You build and choose everything on the history, and you open the future once, at the end. The nine days of December 2011 are left out, so the window is exactly six months.
- Leaks. Any number computed from lines after 1 June would leak the future into the segments. The RFM function below takes only the history.
- Sensitive columns. There are no names, ages or addresses. Customer IDs are numbers that the shop gave, and you must not try to identify anyone.
Countrydoes not go into the segments. You use it only to check the segments in each market.
Split the clean lines at the snapshot date:
SNAPSHOT = pd.Timestamp("2011-06-01")FUTURE_END = pd.Timestamp("2011-12-01")history = clean[clean["InvoiceDate"] < SNAPSHOT]future = clean[(clean["InvoiceDate"] >= SNAPSHOT) & (clean["InvoiceDate"] < FUTURE_END)]for name, part in [("history", history), ("future", future)]: print(f"{name:8} {len(part):>7,} lines, {part['customer'].nunique():,} customers, " f"{part['InvoiceDate'].min():%Y-%m-%d} to {part['InvoiceDate'].max():%Y-%m-%d}")print(f"not used: {(clean['InvoiceDate'] >= FUTURE_END).sum():,} lines from 1 to 9 December 2011")The output:
history 541,106 lines, 4,932 customers, 2009-12-01 to 2011-05-31future 235,767 lines, 3,489 customers, 2011-06-01 to 2011-11-30not used: 17,310 lines from 1 to 9 December 2011What this tells us. The history is 18 months and 4,932 customers. The future is six months and 3,489 customers. Some of them are new customers, who have no history and cannot get a segment yet. From now until the forward check, do not look at future.
Recency, frequency and monetary value
Section titled “Recency, frequency and monetary value”A shopkeeper who knows her customers asks three questions about each one. When did they last buy? How often do they buy? How much do they spend? These are the three RFM numbers. Direct marketing used them long before machine learning:
- Recency: days from the last purchase to the snapshot date. Small is good.
- Frequency: the number of separate invoices (orders), not counting returns.
- Monetary value: net spend in pounds, purchases minus returns.
A tiny example shows how they come from the lines. Customer A ordered on 10 May (£100) and on 20 May (£50), and returned £20 of goods on 25 May. At a snapshot of 1 June, A’s recency is 12 days (from 20 May) and the frequency is 2. The monetary value is £100 + £50 − £20 = £130.
def rfm_table(history, snapshot): """One row per customer: recency, frequency and monetary value at the snapshot date.""" purchases = history[~history["Invoice"].str.startswith("C")] table = purchases.groupby("customer").agg(last_purchase=("InvoiceDate", "max"), frequency=("Invoice", "nunique")) table["recency"] = (snapshot - table["last_purchase"]).dt.days table["monetary"] = history.groupby("customer")["amount"].sum().round(2) # purchases minus returns return table[["recency", "frequency", "monetary"]]
rfm = rfm_table(history, SNAPSHOT)print(f"{len(rfm):,} customers with a purchase; net spend 0 or below: {(rfm['monetary'] <= 0).sum()}")rfm = rfm[rfm["monetary"] > 0]rfm.describe().round(1)The output:
4,908 customers with a purchase; net spend 0 or below: 15 recency frequency monetarycount 4893.0 4893.0 4893.0mean 167.4 5.2 2287.8std 141.2 9.9 10202.8min 0.0 1.0 1.625% 43.0 1.0 308.050% 145.0 3.0 724.075% 242.0 6.0 1869.1max 546.0 234.0 383393.4What this tells us. 24 of the 4,932 customers in the history have only returns: they bought before the data starts, so they get no RFM row. 15 more returned as much as they bought. With a net spend of 0 or below, they are not customers to segment. That leaves 4,893 customers. The .round(2) matters. Without it, a customer whose purchases and returns cancel out can have a net spend of 0.00000000000001 and pass the filter. Now compare the mean and the median (50%) of each column. The median customer spent £724, but the mean is £2,288, and the largest customer spent £383,393.
Explore what the decision needs
Section titled “Explore what the decision needs”Hannah’s question is about the few customers who matter most. So the one view that matters is how unequal the customers are:
print("skew:", rfm.skew().round(1).to_dict())top_tenth = rfm["monetary"].nlargest(len(rfm) // 10).sum() / rfm["monetary"].sum()print(f"the top 10% of customers by spend made {top_tenth:.1%} of the spend")print(f"bought only once: {(rfm['frequency'] == 1).mean():.1%} of customers")The output:
skew: {'recency': 0.8, 'frequency': 10.0, 'monetary': 24.2}the top 10% of customers by spend made 61.4% of the spendbought only once: 31.3% of customersWhat this tells us. Skew measures how lopsided a column is: 0 is balanced, and a large positive value means a long tail of very large values. Frequency (10.0) and spend (24.2) have very long tails. One customer in ten made 61.4% of the spend, and almost one in three bought only once. This is good news for Hannah: a small group really is worth special care. It is bad news for k-means, which uses distances. A customer with £383,393 is so far from everybody else that the distances would be about spend only. The next sections deal with this.
A simple baseline: the quartile RFM score
Section titled “A simple baseline: the quartile RFM score”Before any clustering, build the method that a marketing team would use without machine learning. Sort the customers by each of the three numbers and cut them into four equal groups (quartiles). Give 0 points for the worst quarter and 3 for the best. Recent is good, so recency is sorted the other way. The RFM score is the sum: 0 to 9 points. It is the same idea as the points score in When a simple model is better: anyone can compute it and explain it.
def quartile(values): """0 for the lowest quarter of the customers, 3 for the highest.""" return pd.qcut(values.rank(method="first"), 4, labels=False)
rfm["rfm_score"] = quartile(-rfm["recency"]) + quartile(rfm["frequency"]) + quartile(rfm["monetary"])rfm["rfm_score"].value_counts().sort_index().to_frame().TThe output:
rfm_score 0 1 2 3 4 5 6 7 8 9count 376 528 572 537 514 513 433 418 432 570What this tells us. Every score from 0 to 9 has 376 to 572 customers. 570 customers score 9: recent, frequent and big spenders. rank(method="first") breaks ties. 31.3% of the customers have a frequency of 1, so without it the quarters could not be equal. This score is the baseline. The clusters must be at least as useful as the score, or Hannah should use the score.
Prepare the numbers for k-means
Section titled “Prepare the numbers for k-means”k-means puts customers into groups by distance, so two things must change first:
- Take the log. The log of a number grows with its number of digits: £100, £1,000 and £10,000 become 4.6, 6.9 and 9.2. A customer with 100 times more spend is now a few units away, not a million pounds away.
np.log1pcomputes log(1 + x), so that a recency of 0 days still works. - Scale. After the log, the three columns still have different units.
StandardScalerputs each column in standard deviations, as in Unsupervised exploration.
The scaler learns a mean and a standard deviation from the data, so both steps go in a pipeline. FunctionTransformer turns a plain function, here np.log1p, into a pipeline step:
from sklearn.cluster import KMeansfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import FunctionTransformer, StandardScaler
RFM = ["recency", "frequency", "monetary"]prepare = make_pipeline(FunctionTransformer(np.log1p), StandardScaler())X = prepare.fit_transform(rfm[RFM])print("skew after the log:", np.log1p(rfm[RFM]).skew().round(1).to_dict())pd.DataFrame(X, columns=RFM).describe().round(2).loc[["mean", "std", "min", "max"]]The output:
skew after the log: {'recency': -1.0, 'frequency': 1.1, 'monetary': 0.2} recency frequency monetarymean -0.00 0.00 -0.00std 1.00 1.00 1.00min -3.53 -1.00 -4.30max 1.35 5.34 4.66What this tells us. After the log, the skew of spend falls from 24.2 to 0.2, and the skew of frequency from 10.0 to 1.1. After scaling, every column has a mean of 0 and a standard deviation of 1. The extremes are now about 5 standard deviations from the mean, not hundreds. Recency now leans the other way (−1.0), because the log stretches the small values. That is acceptable: no single customer decides the distances any more.
Choose the number of segments
Section titled “Choose the number of segments”k-means needs k, the number of clusters, before it starts. Unsupervised exploration showed two hints. Inertia is the total squared distance to the centres; it always falls when k grows. Silhouette goes from −1 to 1, and a high value means clear, separate groups. Here you add a third hint: stability.
Hannah cannot build a plan on segments that change a lot when the data changes a little. So fit k-means 10 times, each time on a different random 80% of the customers, and assign every customer with each fit. Then compare the 10 results in pairs with the adjusted Rand index (ARI). ARI measures how much two groupings agree. 1 means that they put exactly the same customers together; 0 means no more agreement than chance. The cluster numbers do not matter, only who is with whom.
from sklearn.metrics import adjusted_rand_score, silhouette_score
def stability(X, k, runs=10): """Mean agreement (ARI) between k-means runs fitted on different random 80% samples.""" rng = np.random.default_rng(0) labels = [] for run in range(runs): rows = rng.choice(len(X), int(0.8 * len(X)), replace=False) labels.append(KMeans(n_clusters=k, n_init=10, random_state=run).fit(X[rows]).predict(X)) return np.mean([adjusted_rand_score(a, b) for i, a in enumerate(labels) for b in labels[i + 1:]])
for k in range(2, 9): km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X) print(f"k {k}: inertia {km.inertia_:4.0f} silhouette {silhouette_score(X, km.labels_):.3f} " f"stability {stability(X, k):.3f} sizes {sorted(np.bincount(km.labels_).tolist(), reverse=True)}")The output:
k 2: inertia 7213 silhouette 0.456 stability 0.970 sizes [3155, 1738]k 3: inertia 5443 silhouette 0.349 stability 0.930 sizes [2143, 1845, 905]k 4: inertia 4209 silhouette 0.378 stability 0.964 sizes [1990, 1424, 761, 718]k 5: inertia 3552 silhouette 0.327 stability 0.866 sizes [1532, 1334, 902, 627, 498]k 6: inertia 3108 silhouette 0.328 stability 0.922 sizes [1338, 1208, 811, 577, 527, 432]k 7: inertia 2814 silhouette 0.288 stability 0.717 sizes [1103, 1001, 759, 627, 534, 509, 360]k 8: inertia 2552 silhouette 0.285 stability 0.891 sizes [1065, 966, 739, 604, 504, 432, 420, 163]Each run of the stability check also uses another random start (random_state=run), so stability shows whether the result depends on the seed. The same numbers as charts:
Text description of the chart
A line chart of inertia against k from 2 to 8. Inertia falls from 7,213 at k = 2 to 5,443 at k = 3 and 4,209 at k = 4. After that the falls are smaller: 3,552, 3,108, 2,814 and 2,552 at k = 8. The steps are 1,770 and 1,234, then 657, 444, 294 and 262: the bend is at k = 4.
Text description of the chart
Two lines against k from 2 to 8. Silhouette is highest at k = 2 (0.456), falls to 0.349 at k = 3, rises to 0.378 at k = 4, then falls to about 0.33 at k = 5 and 6 and 0.29 at k = 7 and 8. Stability is 0.970 at k = 2, 0.930 at k = 3, 0.964 at k = 4, 0.866 at k = 5, 0.922 at k = 6, 0.717 at k = 7 and 0.891 at k = 8.
What this tells us. The three hints point the same way:
- Inertia falls by 1,770 and 1,234 in the first two steps, then by only 657. The bend (the “elbow”) is at k = 4.
- Silhouette is highest at k = 2 (0.456). But two groups would only be “active” and “not active”, and Hannah has three tools. Among the larger values of k, k = 4 has a local peak (0.378). All values are below 0.5, so the groups touch: these are useful cuts through one cloud, not islands.
- Stability is high at k = 4 (0.964): the same customers end up together, whichever 80% you fit on. At k = 5 it falls to 0.866, and at k = 7 to 0.717.
So you choose k = 4. The data did not decide alone. It said “4 is a good cut”, and the business said “4 groups fit our three tools, plus one group that gets almost nothing”.
Fit the segments
Section titled “Fit the segments”Now fit the full pipeline with k = 4. Then look at the profile of each cluster: the median of each number, and the share of all spend.
segmenter = make_pipeline(FunctionTransformer(np.log1p), StandardScaler(), KMeans(n_clusters=4, n_init=10, random_state=0))rfm["cluster"] = segmenter.fit_predict(rfm[RFM])profile = rfm.groupby("cluster").agg(customers=("recency", "size"), recency=("recency", "median"), frequency=("frequency", "median"), monetary=("monetary", "median"), spend=("monetary", "sum"))profile["share_of_spend"] = profile["spend"] / profile["spend"].sum()profile.drop(columns="spend").round(3)The output:
customers recency frequency monetary share_of_spendcluster0 718 22.0 3.0 715.055 0.0561 1424 174.0 4.0 1358.085 0.2292 761 19.0 13.0 4854.140 0.6583 1990 238.5 1.0 282.585 0.057What this tells us. Each cluster is a clear kind of customer:
- Cluster 2, 761 customers (15.6%): their last order was 19 days before the snapshot; they ordered 13 times and spent £4,854 each. They made 65.8% of all spend.
- Cluster 0, 718 customers: bought recently (22 days) but only 3 times, for £715.
- Cluster 1, 1,424 customers: 4 orders and £1,358, but the last one 174 days ago, almost six months.
- Cluster 3, 1,990 customers (40.7%): one order, £283, 238 days ago. They made only 5.7% of the spend.
On another computer the cluster numbers may be in another order. The groups are the same; the numbers 0 to 3 are only names.
Hannah names the segments
Section titled “Hannah names the segments”You show Hannah the profile. She knows these customers, and she gives each cluster a name that says what to do:
| Cluster | Hannah’s name | Her reading | Her plan |
|---|---|---|---|
| 2 | Best customers | “Our regular resellers. Losing one of them hurts.” | VIP care |
| 0 | Recent occasional buyers | “New or small shops that bought recently. They could grow.” | Nurture |
| 1 | Quiet regulars | “They used to order every few months, and then stopped. Worth an offer.” | Win-back offer |
| 3 | Lapsed one-time buyers | “One order, long ago. Most will not come back.” | A cheap reminder only |
The code gives the names from the profile, not from the cluster numbers, so it gives the right name on any computer:
names = {profile["frequency"].idxmax(): "Best customers", profile["recency"].idxmax(): "Lapsed one-time buyers"}rest = profile.drop(index=list(names))names[rest["recency"].idxmin()] = "Recent occasional buyers"names[rest["recency"].idxmax()] = "Quiet regulars"rfm["segment"] = rfm["cluster"].map(names)profile.index = profile.index.map(names)profile.drop(columns="spend").round(3)The output:
customers recency frequency monetary share_of_spendclusterRecent occasional buyers 718 22.0 3.0 715.055 0.056Quiet regulars 1424 174.0 4.0 1358.085 0.229Best customers 761 19.0 13.0 4854.140 0.658Lapsed one-time buyers 1990 238.5 1.0 282.585 0.057What this tells us. These are the same groups as before, now in Hannah’s words. A name is a summary, not a rule: “one-time” is the median, and some lapsed buyers ordered two or three times.
The chart shows 500 of the customers (a fixed random sample) by two of the three numbers.
- Best customers
- Recent occasional
- Quiet regulars
- Lapsed one-time
Text description of the chart
A scatter plot. The horizontal axis is recency, 0 to 550 days. The vertical axis is net spend on a log scale, from £10 to £100,000. The best customers (78 in the sample) are at the top left. Nine in ten have a recency under 50 days. Their spend runs from £1,600 to £79,000, mostly £2,000 to £12,000. The recent occasional buyers (81) are also at the left, with a recency under 75 days, but lower: mostly £270 to £1,500. The quiet regulars (125) are in the middle, mostly from 70 to 260 days, at £650 to £3,000. The lapsed one-time buyers (216) are at the right and at the bottom. Most have a recency of 85 to 460 days, and nine in ten spent under £600. The groups touch; their borders also depend on frequency, which the chart does not show.
The best customers and the recent occasional buyers both bought recently; spend and frequency separate them. The quiet regulars and the lapsed buyers both stopped; the lapsed buyers spent less and ordered once.
The forward check: the next six months
Section titled “The forward check: the next six months”Now open the sealed part. For each customer: did they buy again between June and November 2011, and how much did they spend? This is the test of Owen’s question. If the segments mean something, the best customers should come back more often and spend more than the others.
future_spend = future.groupby("customer")["amount"].sum()future_buyers = future.loc[~future["Invoice"].str.startswith("C"), "customer"].unique()rfm["came_back"] = rfm.index.isin(future_buyers)rfm["future_spend"] = future_spend.reindex(rfm.index).fillna(0)check = rfm.groupby("segment").agg(customers=("came_back", "size"), came_back=("came_back", "mean"), future_spend=("future_spend", "mean"), total=("future_spend", "sum"))check["share_of_future_spend"] = check["total"] / check["total"].sum()print(f"came back overall: {rfm['came_back'].mean():.1%}; " f"new customers in the six months, not segmented: {len(set(future_buyers) - set(rfm.index)):,}")check.drop(columns="total").sort_values("future_spend").round(3)The output:
came back overall: 52.0%; new customers in the six months, not segmented: 919 customers came_back future_spend share_of_future_spendsegmentLapsed one-time buyers 1990 0.272 120.313 0.058Recent occasional buyers 718 0.666 486.751 0.084Quiet regulars 1424 0.577 536.349 0.184Best customers 761 0.926 3665.521 0.673Text description of the chart
A column chart with four columns. Lapsed one-time buyers: 27.2% came back. Recent occasional buyers: 66.6%. Quiet regulars: 57.7%. Best customers: 92.6%.
What this tells us. The segments hold. 92.6% of the best customers bought again, against 27.2% of the lapsed buyers. The best customers spent £3,666 each, on average, and made 67.3% of the six months’ spend of the segmented customers. Before the snapshot they made 65.8%. So VIP care goes to the right people.
Two results need care. First, 57.7% of the quiet regulars came back without any offer. About 820 of the 1,424 would have bought anyway, so a voucher for all of them gives away margin to most. Second, 919 new customers bought in the six months. No segmentation built on the past can include them; they need their own welcome plan.
The forward check is one check, done once. It is honest because nothing in the segments used the future lines. If you now change k and look again, the future becomes a validation set, and you need a new, later period for the test.
Clusters against the simple score
Section titled “Clusters against the simple score”Is the clustering worth its complexity? Look at the simple RFM score on the same future:
rfm.groupby("rfm_score").agg(customers=("came_back", "size"), came_back=("came_back", "mean"), future_spend=("future_spend", "mean")).round(3)The output:
customers came_back future_spendrfm_score0 376 0.120 40.3171 528 0.244 104.0642 572 0.304 134.2403 537 0.363 211.3304 514 0.490 307.5695 513 0.507 317.5186 433 0.628 513.2537 418 0.734 863.7098 432 0.850 1097.7549 570 0.956 4391.723Text description of the chart
A line that rises almost evenly from 12.0% at a score of 0 to 95.6% at a score of 9. The values are 12.0, 24.4, 30.4, 36.3, 49.0, 50.7, 62.8, 73.4, 85.0 and 95.6 percent.
What this tells us. The score puts the customers in a clear order: every extra point raises the come-back rate. Customers with 9 points came back at 95.6% and spent £4,392 each, more than the best-customer cluster.
Now compare the two methods on the same questions. First, how well does each one rank the customers by “came back”? Use ROC AUC from The precision–recall trade-off: the chance that a customer who came back ranks above one who did not. The segments are ranked by their past spend, from the profile, not from the future. Second, Hannah’s real decision: VIP care for 761 customers. Compare the best-customer cluster with the 761 customers with the highest score.
from sklearn.metrics import roc_auc_score
value_rank = rfm["segment"].map(profile["monetary"].rank()) # the segments in order of past spendrankings = {"4 segments": value_rank, "RFM score 0-9": rfm["rfm_score"], "recency alone": -rfm["recency"]}for name, ranking in rankings.items(): print(f"{name:14} ROC AUC for 'came back': {roc_auc_score(rfm['came_back'], ranking):.3f}")
best = rfm[rfm["segment"] == "Best customers"]top_by_score = rfm.sort_values(["rfm_score", "monetary"], ascending=False).head(len(best))for name, group in [("Best customers", best), ("top by RFM score", top_by_score)]: print(f"{name:17} {len(group)} customers, came back {group['came_back'].mean():.1%}, " f"mean future spend £{group['future_spend'].mean():,.0f}, " f"{group['future_spend'].sum() / rfm['future_spend'].sum():.1%} of the future spend")print("customers on both lists:", len(best.index.intersection(top_by_score.index)))The output:
4 segments ROC AUC for 'came back': 0.738RFM score 0-9 ROC AUC for 'came back': 0.791recency alone ROC AUC for 'came back': 0.764Best customers 761 customers, came back 92.6%, mean future spend £3,666, 67.3% of the future spendtop by RFM score 761 customers, came back 93.3%, mean future spend £3,697, 67.9% of the future spendcustomers on both lists: 682What this tells us. The simple score is as good as the clusters, or better:
- For ranking, the score (0.791) beats the four segments (0.738). Recency alone (0.764) also beats them. Part of this is not a fair contest: four groups give only four levels, and the score has ten.
- For the VIP decision, the two lists are almost the same: 682 of the 761 customers are on both. The score’s list came back slightly more often (93.3% against 92.6%) and spent slightly more.
So what did the clustering add? It found the four groups and their borders in the data, and it gave Hannah four kinds of customer to name. The score cannot name a “quiet regular”. But to choose who gets VIP care, the score does the same job, and Owen can check it in a spreadsheet. This is the lesson of When a simple model is better. When the simple method is as good, it is the better choice, because more people can understand, check and maintain it.
The recommendation uses both. The segment names make the plan; the score chooses the VIP list and ranks the customers inside a segment.
Errors and slices
Section titled “Errors and slices”A segment describes a group, not a person. Two kinds of surprise show this, and one slice checks the markets:
country = clean.groupby("customer")["Country"].agg(lambda c: c.mode()[0])rfm["market"] = np.where(rfm.index.map(country) == "United Kingdom", "UK", "other")slices = rfm.groupby(["segment", "market"]).agg(customers=("came_back", "size"), came_back=("came_back", "mean"))print(slices.unstack().round(3))best_gone = (rfm["segment"] == "Best customers") & ~rfm["came_back"]lapsed_back = (rfm["segment"] == "Lapsed one-time buyers") & rfm["came_back"]print(f"Best customers who did not come back: {best_gone.sum()}; lapsed buyers who came back: {lapsed_back.sum()}")The output:
customers came_backmarket UK other UK othersegmentBest customers 699 62 0.924 0.952Lapsed one-time buyers 1851 139 0.271 0.295Quiet regulars 1288 136 0.577 0.574Recent occasional buyers 653 65 0.662 0.708Best customers who did not come back: 56; lapsed buyers who came back: 542What this tells us.
- The segments work the same in both markets. The come-back rates in the UK and elsewhere differ by 4.6 points at most. The “other” slices are small (62 to 139 customers), so a difference of a few points there can be chance. No market needs its own segments.
- 56 best customers did not come back. They are the most expensive error: VIP care was planned for them, and they left anyway. Hannah’s team should call them; the reason may be something that no column shows.
- 542 lapsed buyers came back. That is 27.2% of the group: too many to ignore, too few to justify an expensive offer for all 1,990. A cheap email is the right size of effort.
Failure case: clustering without the log
Section titled “Failure case: clustering without the log”Common mistake
Clusters that only find the giants
Symptom: Owen tries the pipeline without the log step. k-means runs without a warning, and the profile looks like this:
no_log = make_pipeline(StandardScaler(), KMeans(n_clusters=4, n_init=10, random_state=0))wrong = rfm[RFM].assign(cluster=no_log.fit_predict(rfm[RFM]))wrong.groupby("cluster").agg(customers=("recency", "size"), recency=("recency", "median"), frequency=("frequency", "median"), monetary=("monetary", "median")) customers recency frequency monetarycluster0 2543 47.0 5.0 1411.071 2315 246.0 2.0 400.952 32 7.0 70.0 42475.773 3 14.0 100.0 354579.84Two clusters have 32 and 3 customers. The other 4,858 customers are cut only into “recent” and “not recent”.
Cause: scaling does not remove a long tail. After StandardScaler, the largest spend is still more than 30 standard deviations from the mean. k-means makes a cluster around the few giants, because one giant far away adds more to the inertia than many ordinary customers.
Fix: take the log before you scale, in the same pipeline: make_pipeline(FunctionTransformer(np.log1p), StandardScaler(), KMeans(...)). Then check the cluster sizes. A cluster with a handful of members, when you expected groups of hundreds, is a warning sign.
Consequence in real work: a team presents “four customer types”, and two of them are 35 customers that the sales team already knows by name. The large middle of the customer base gets one plan for everyone.
The experiment log
Section titled “The experiment log”Keep a log of every attempt, as in Machine learning is iterative. For clustering, some columns change. There is no CV score, so you record the silhouette, the stability and a short result, such as the cluster sizes or the forward check.
from datetime import date
log = pd.DataFrame([ ("E01", "Baseline: quartile RFM score 0-9", None, None, "ROC AUC 0.791; top 761: 93.3% came back", "baseline", "Simple, and a person can compute it."), ("E02", "k-means k=4, scaled, no log", None, None, "two clusters of 32 and 3 customers", "reject", "A few very large customers decide the clusters."), ("E03", "k-means, log + scale, k=2 to 8", "k=2 0.456, k=4 0.378", "k=4 0.964", None, "keep", "k=4 is stable and has four actions."), ("E04", "k-means k=5, log + scale", 0.327, 0.866, None, "reject", "A middle group from Best and Quiet; no new action."), ("E05", "Final: k=4, checked on Jun-Nov 2011", 0.378, 0.964, "Best 92.6% came back, Lapsed 27.2%", "keep", "The segments hold in the next six months."),], columns=["id", "change", "silhouette", "stability", "result", "decision", "reason"])log.insert(1, "date", date.today().isoformat())log.to_csv("experiment_log.csv", index=False)log[["id", "change", "decision"]]The output:
id change decision0 E01 Baseline: quartile RFM score 0-9 baseline1 E02 k-means k=4, scaled, no log reject2 E03 k-means, log + scale, k=2 to 8 keep3 E04 k-means k=5, log + scale reject4 E05 Final: k=4, checked on Jun-Nov 2011 keepWhat this tells us. The log holds the whole story in five rows, including the attempts that failed. E04 is the variation that you try in the notebook. With k = 5, a middle group of good regulars splits off from the best customers and the quiet regulars. No new action follows from it.
Recommendations
Section titled “Recommendations”You write the recommendation for Hannah and Owen in their words. The numbers come from the history to 1 June 2011 and the check on June to November 2011.
- VIP care for the 761 best customers. They are 15.6% of the customers and made 65.8% of the spend. 92.6% came back, and they made 67.3% of the next six months’ spend. To choose exactly who, use the RFM score: it gives the same list for 682 of them and is easy to check.
- Call the best customers who stop ordering. 56 of them did not come back. A best customer with no order in 60 days should get a call; do not wait for the next segmentation.
- Test the win-back offer before you send it to all quiet regulars. 57.7% came back without an offer. Send the voucher to a random half of the 1,424, and compare the two halves after three months. Only that comparison shows whether the voucher changes what customers do.
- Nurture the 718 recent occasional buyers. Two in three came back. A monthly email with new products costs little, and some of them will become best customers.
- Send only a cheap reminder to the 1,990 lapsed one-time buyers. 27.2% came back, and they spent £120 each on average.
- Make a welcome plan for new customers. 919 customers bought for the first time in the six months. They have no segment until they have a history.
What not to use the segments for. Do not use them to judge one customer: 56 best customers left, and 542 lapsed customers came back. Do not use them for credit limits or prices; they were built for marketing effort only. Do not reuse these segments on later data without building them again, because the borders depend on the snapshot date.
The notebook writes the list for Hannah’s email tool:
deliverable = rfm[RFM + ["rfm_score", "segment"]].reset_index()deliverable.to_csv("segments.csv", index=False)print(deliverable["segment"].value_counts().to_string())deliverable.head(3)The output:
segmentLapsed one-time buyers 1990Quiet regulars 1424Best customers 761Recent occasional buyers 718 customer recency frequency monetary rfm_score segment0 12346 133 3 169.36 3 Lapsed one-time buyers1 12347 54 4 2434.96 7 Quiet regulars2 12348 56 4 1388.40 6 Quiet regularsWhat this tells us. segments.csv has one row per customer, with the three numbers, the score and the segment. Look at customer 12346: a frequency of 3 and a segment called “one-time”. One of the three orders was the mistaken order of 74,215 jars, which still counts as a visit. The limits below come back to this.
Limits and next steps
Section titled “Limits and next steps”The limits, in the order Hannah should know them:
- Registered customers only. 22.8% of the lines have no customer ID. If those are guest buyers, they are a large part of the shop that the segments do not see.
- Segments describe; they do not show cause. The forward check shows who came back, not what an offer would change. Only a test with a control group can show the effect of an offer (recommendation 3).
- One snapshot, one season. The history ends on 1 June, and the check covers the months before Christmas, the shop’s busiest season. A snapshot on 1 December would give other borders. Build the segments again every quarter, and check each one on the next quarter.
- Cleaning choices change the numbers. Removing all exact repeats may remove some real double orders. A cancelled order still counts as an order: customer 12346 has a frequency of 3, but one of those orders was a mistake.
- The clusters overlap. Silhouette values below 0.5 mean that customers near a border could go either way. The stability check shows that most customers stay in the same segment.
Next steps: add the product mix, because a customer who buys only Christmas goods behaves differently. Add the time between orders. Repeat the forward check from a second snapshot date.
Make it a portfolio piece
Section titled “Make it a portfolio piece”This case study can become a public project. It shows an employer the whole process: messy data, decisions, a model, an honest check and a business recommendation.
What to put in the repository:
README.md, with five short parts:- Problem: “Segment an online shop’s customers for VIP care, win-back offers and nurturing.”
- Data and licence: Online Retail II, UCI, CC BY 4.0, with the citation and a link to the dataset card.
- Approach: the cleaning table, RFM at a snapshot date, log + scale + k-means, and k chosen with inertia, silhouette and stability.
- Results with the baseline: the forward-check table and the comparison with the RFM score (ROC AUC 0.791 against 0.738; the same VIP list for 682 of 761 customers).
- Limits: the list above, in your words.
- The notebook, run from the top, with its outputs.
experiment_log.csvandsegments.csv. The notebook writes both into the foldersegment-online-shop-customers.- A short model card for the segmentation, as in Document use boundaries. Give the intended use (marketing effort) and what it is not for (credit, prices, judging one customer). Add the data, the check, the limits, and when to build it again.
Do not put the data file in your repository. Link to the UCI page and to the course copy, and keep the attribution.
How to describe it in an interview, in two sentences: “I segmented 4,893 customers of a real online shop by recency, frequency and spend, after cleaning a million invoice lines. In the next six months, 92.6% of the best customers came back against 27.2% of the lapsed ones, and a simple quartile score chose the same VIP list, so I recommended the simpler method for that decision.”
Go further:
- Repeat the whole analysis at a second snapshot date (for example 1 December 2010), and compare the segments.
- Add a fourth number, such as the average number of days between orders, and see whether the segments change.
- Try a Gaussian mixture model instead of k-means, and compare the forward check.
- Predict each customer’s spend in the next six months with a regression model, and compare the ranking with the segments and the score.
Knowledge check
Section titled “Knowledge check”Check your understanding
Why must spend be net of returns in this data, and what happens to customer 12346 if you remove the return lines instead?
Show the answer
Customer 12346 ordered 74,215 storage jars for £77,184 and cancelled the order 16 minutes later. With net spend, the order and its return cancel out, and the customer’s spend is £169.36. If you remove the return lines and keep the order, the customer looks like one of the shop’s biggest buyers. That customer could then get VIP care. Net spend, purchases minus returns, measures what the customer really bought.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Silhouette is highest at k = 2 (0.456). Why did you choose k = 4, and what evidence besides silhouette did you use?
Show the answer
Two groups would only say “active” and “not active”, and Hannah has three tools plus a group that gets almost nothing. k = 4 has a local peak of silhouette (0.378), and the inertia bends at k = 4. The segments are also stable: the same customers end up together on different 80% samples (ARI 0.964, against 0.866 at k = 5). The data suggests good cuts; the business decides which one is useful. All silhouette values are below 0.5, so the groups touch.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Owen says: the quartile RFM score did as well as the clusters, so the clustering was a waste of time. What do you answer?
Show the answer
For the VIP list, Owen is right. The score ranks come-back better (ROC AUC 0.791 against 0.738) and gives the same list for 682 of 761 customers. So the team should use the score for that decision. But the clustering found the four kinds of customer and their borders in the data. It gave Hannah groups to name and plan for, such as the quiet regulars. The score alone does not say which customers need which treatment. Use the segments for the plan and the score for ranking inside it. The comparison was possible only because you built the baseline first.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
In this case study you turned 1,067,371 invoice lines into segments that a CRM team can use:
- Cleaning was most of the work. You removed 34,335 exact repeats (most from two overlapping sheets), 235,151 lines without a customer, 3,641 non-product lines and 61 lines with a price of 0 or below, each with its reason. Returns stayed, so that spend is net.
- RFM at a snapshot date: 4,893 customers, with the history before 1 June 2011 only. Spend is very skewed (the top 10% made 61.4% of it), so you took the log before scaling.
- Choosing k with three kinds of evidence: the inertia bend, a local silhouette peak (0.378) and stability across samples (0.964) all pointed to k = 4. The business agreed.
- The forward check: 92.6% of the best customers bought again in the next six months, against 27.2% of the lapsed buyers.
- The simple score was as good: the quartile RFM score ranked come-back better (0.791 against 0.738). It chose the same VIP list for 682 of 761 customers.
Next, a smaller dataset with a different trap: wholesale customers whose spending is so skewed that the best silhouette points to a useless answer.