Hi, I'm Mathew. If you've ever wondered "who are our best customers?", this one is for you. We'll answer it with RFM analysis, one of the most practical techniques in marketing analytics, using nothing but pandas.
RFM scores every customer on three questions:
- Recency: how many days since they last bought?
- Frequency: how many orders have they placed?
- Monetary: how much have they spent in total?
We'll use a real, cleaned UK online retail dataset: 391,150 transaction rows, with a Revenue column already calculated.
Step 1: Pick a snapshot date
Recency needs a "today". Rather than using the real today (which would make everyone look inactive), we use the day after the last transaction in the data:
import pandas as pd
clean = pd.read_csv("online_retail_clean.csv", parse_dates=["InvoiceDate"])
snapshot_date = clean["InvoiceDate"].max() + pd.Timedelta(days=1)
# Timestamp('2011-12-10 12:50:00')
Step 2: Build R, F and M with groupby
Each metric is one groupby on CustomerID:
recency = clean.groupby("CustomerID")["InvoiceDate"].max()
recency = (snapshot_date - recency).dt.days
frequency = clean.groupby("CustomerID")["InvoiceNo"].nunique()
monetary = clean.groupby("CustomerID")["Revenue"].sum()
rfm = pd.DataFrame(
{"Recency": recency, "Frequency": frequency, "Monetary": monetary}
).reset_index()
That gives 4,334 customers with no missing values. The distributions already tell a story. The median customer last ordered 51 days ago and has placed 2 orders, while the biggest customer has 206 orders. Monetary value ranges from 3.75 to 279,138.02, with a median of 662.57 but a mean of 2,015.97. A few huge customers pull the average way up.
Step 3: Score each metric 1 to 4
We split each metric into quartiles with pd.qcut. Two details matter:
-
Recency is reversed. Fewer days since the last order is better, so the labels run
[4, 3, 2, 1]. -
Frequency has lots of ties (plenty of customers have exactly 1 order), so
qcutcan't find clean edges. Ranking first withmethod="first"breaks the ties.
rfm["R_score"] = pd.qcut(rfm["Recency"], 4, labels=[4, 3, 2, 1]).astype(int)
rfm["F_score"] = pd.qcut(rfm["Frequency"].rank(method="first"), 4,
labels=[1, 2, 3, 4]).astype(int)
rfm["M_score"] = pd.qcut(rfm["Monetary"], 4, labels=[1, 2, 3, 4]).astype(int)
Each score now holds roughly 1,080 customers, as quartiles should. The recency cut points were 1, 18, 51, 143 and 374 days, so "R = 4" means "ordered within the last 18 days".
Step 4: Turn scores into named segments
You could just add the three scores together, but a total hides the shape. A customer scoring 4-1-1 and one scoring 2-2-2 both total 6, and you'd treat them very differently. Rules on the individual scores are more useful:
def segment_customer(row):
if row["R_score"] >= 3 and row["F_score"] >= 3 and row["M_score"] >= 3:
return "Champions"
elif row["R_score"] >= 3 and row["F_score"] >= 2:
return "Loyal Customers"
elif row["R_score"] >= 3:
return "New Customers"
elif row["R_score"] == 2:
return "At Risk"
else:
return "Lost"
rfm["Segment"] = rfm.apply(segment_customer, axis=1)
The order of the if branches matters: the first match wins, so the strictest rule goes first.
Step 5: The interesting part, customers vs revenue
segment_revenue = rfm.groupby("Segment")["Monetary"].sum()
customer_share = (rfm["Segment"].value_counts(normalize=True) * 100).round(1)
revenue_share = (segment_revenue / segment_revenue.sum() * 100).round(1)
pd.DataFrame({"Customer %": customer_share, "Revenue %": revenue_share})
| Segment | Customer % | Revenue % |
|---|---|---|
| Champions | 30.3 | 73.0 |
| At Risk | 24.8 | 12.3 |
| Lost | 24.9 | 8.0 |
| Loyal Customers | 14.0 | 5.7 |
| New Customers | 6.0 | 1.0 |
Champions are about 30% of customers but 73% of revenue. Their average profile is 17 days since last order, 9.3 orders and 4,847.80 total spend. Compare that with the Lost group: 247.8 days of silence, 1.6 orders and 645.50 spend.
What to do with this
- Champions: protect them. Early access, thank-you notes, anything that keeps them from drifting.
- At Risk: this is the group I'd look at first. They're about 12% of revenue, and the top of this list contains customers who spent more than 11,000 but haven't ordered for 56 to 114 days. A win-back campaign here is cheap and targeted.
- New Customers: average 1.0 orders. The goal is a second purchase.
- Lost: lowest priority, but worth a cheap automated email.
A few sanity checks worth running
Good analysts check their own work. The notebook confirms that the RFM table has one row per customer, that every customer has a segment, and that no Champion has only a single order (0 of them). It also checks that the score means something: average spend rises steadily from 162.40 at a combined score of 3 to 8,826.60 at 12. Frequency and Monetary are correlated at 0.55, which makes sense: people who order more spend more.
One caveat: quartile scoring is relative. These segments describe this dataset's customers compared with each other, not an absolute standard of "good".
Why this beats guessing
Before RFM, a lot of teams segment by gut feel: "big spenders" or "recent buyers". The trouble is that each of those looks at one dimension. RFM forces three views at once, which is why a customer who spent a lot two years ago (high M, terrible R) doesn't get mistaken for an active one. Because it's built from just three groupby calls and qcut, you can rebuild it in minutes on your own order data, as long as you have a customer ID, an order ID, a date and an amount. Try swapping in your own thresholds in segment_customer and see how the segment sizes move. That experiment alone teaches you a lot about your customers.
Get the full notebook, dataset and quiz
This article is the condensed version. The full lesson also has the charts (segment sizes and a recency vs spend scatter), the top Champions and At Risk lists, the country breakdown, and a quiz to test yourself.
- Full lesson: Customer Segmentation with RFM Analysis
- Watch the video walkthrough: https://www.youtube.com/watch?v=AvXkk9ltEsU
- Subscribe so you don't miss the next one: https://www.youtube.com/@KarMat-Analytics?sub_confirmation=1
Everything on the site is free. Happy analysing!
Top comments (0)