Mathew K Analytics

Lesson 2 · Real-World Data Analytics

Python Data Analytics #02: Customer Segmentation with RFM Analysis in Python

Video two of the hundred-video real-world data analytics series. Real Recency, Frequency, and Monetary scoring, on the real cleaned retail data from last…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Data Analytics 100, Video 2: Customer Segmentation with RFM Analysis#

  • Video two of the hundred-video real-world data analytics series.
  • Real Recency, Frequency, and Monetary scoring, on the real cleaned retail data from last video.
  • Let's get into it.

Part 1: What RFM Actually Measures#

import pandas as pd
clean = pd.read_csv('online_retail_clean.csv', parse_dates=['InvoiceDate'])
clean.shape
(391150, 9)

Part 2: Choosing a Real Snapshot Date#

snapshot_date = clean['InvoiceDate'].max() + pd.Timedelta(days=1)
snapshot_date
Timestamp('2011-12-10 12:50:00')

Part 3: Real Recency per Customer#

recency = clean.groupby('CustomerID')['InvoiceDate'].max()
recency = (snapshot_date - recency).dt.days
recency.describe()
count    4334.000000
mean       92.703046
std       100.177047
min         1.000000
25%        18.000000
50%        51.000000
75%       143.000000
max       374.000000
Name: InvoiceDate, dtype: float64

Part 4: Real Frequency per Customer#

frequency = clean.groupby('CustomerID')['InvoiceNo'].nunique()
frequency.describe()
count    4334.000000
mean        4.245962
std         7.634989
min         1.000000
25%         1.000000
50%         2.000000
75%         5.000000
max       206.000000
Name: InvoiceNo, dtype: float64

Part 5: Real Monetary Value per Customer#

monetary = clean.groupby('CustomerID')['Revenue'].sum()
monetary.describe()
count      4334.000000
mean       2015.973152
std        8903.673825
min           3.750000
25%         304.240000
50%         662.565000
75%        1631.622500
max      279138.020000
Name: Revenue, dtype: float64

Part 6: Combining Into One Real RFM Table#

rfm = pd.DataFrame({'Recency': recency, 'Frequency': frequency, 'Monetary': monetary})
rfm = rfm.reset_index()
rfm.head()
rfm.shape[0]
4334

Part 7: Confirming the Real Customer Count Lines Up#

rfm.shape[0] == clean['CustomerID'].nunique()
rfm.isna().sum().sum()
np.int64(0)

Part 8: Scoring Recency Into Real Quartiles#

rfm['R_score'] = pd.qcut(rfm['Recency'], 4, labels=[4, 3, 2, 1]).astype(int)
rfm['R_score'].value_counts().sort_index()
R_score
1    1079
2    1073
3    1058
4    1124
Name: count, dtype: int64

Part 9: Scoring Frequency Into Real Quartiles#

rfm['F_score'] = pd.qcut(rfm['Frequency'].rank(method='first'), 4, labels=[1, 2, 3, 4]).astype(int)
rfm['F_score'].value_counts().sort_index()
F_score
1    1084
2    1083
3    1083
4    1084
Name: count, dtype: int64

Part 10: Scoring Monetary Into Real Quartiles#

rfm['M_score'] = pd.qcut(rfm['Monetary'], 4, labels=[1, 2, 3, 4]).astype(int)
rfm['M_score'].value_counts().sort_index()
M_score
1    1085
2    1082
3    1083
4    1084
Name: count, dtype: int64

Part 11: A Real Combined RFM Score#

rfm['RFM_Sum'] = rfm['R_score'] + rfm['F_score'] + rfm['M_score']
rfm['RFM_Sum'].describe()
count    4334.000000
mean        7.513613
std         2.828945
min         3.000000
25%         5.000000
50%         7.000000
75%        10.000000
max        12.000000
Name: RFM_Sum, dtype: float64

Part 12: A Real Rule-Based Segment Function#

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'

Part 13: Applying the Real Segmentation#

rfm['Segment'] = rfm.apply(segment_customer, axis=1)
rfm['Segment'].value_counts()
Segment
Champions          1315
Lost               1079
At Risk            1073
Loyal Customers     607
New Customers       260
Name: count, dtype: int64

Part 14: Real Revenue Contribution by Segment#

segment_revenue = rfm.groupby('Segment')['Monetary'].sum().sort_values(ascending=False)
segment_revenue
(segment_revenue / segment_revenue.sum() * 100).round(1)
Segment
Champions          73.0
At Risk            12.3
Lost                8.0
Loyal Customers     5.7
New Customers       1.0
Name: Monetary, dtype: float64

Part 15: Real Average Behavior by Segment#

rfm.groupby('Segment')[['Recency', 'Frequency', 'Monetary']].mean().round(1)
Recency Frequency Monetary
Segment
At Risk 84.6 2.6 1004.9
Champions 17.0 9.3 4847.8
Lost 247.8 1.6 645.5
Loyal Customers 23.3 2.2 820.0
New Customers 27.1 1.0 345.7

Part 16: Real Customers vs Real Revenue Share#

segment_customers = rfm['Segment'].value_counts()
customer_share = (segment_customers / segment_customers.sum() * 100).round(1)
revenue_share = (segment_revenue / segment_revenue.sum() * 100).round(1)
pd.DataFrame({'Customer %': customer_share, 'Revenue %': revenue_share})
Customer % Revenue %
Segment
At Risk 24.8 12.3
Champions 30.3 73.0
Lost 24.9 8.0
Loyal Customers 14.0 5.7
New Customers 6.0 1.0

Part 17: Visualizing Real Segment Sizes#

import matplotlib.pyplot as plt
order = rfm['Segment'].value_counts().index
plt.figure(figsize=(8, 5))
plt.bar(order, rfm['Segment'].value_counts()[order], color='seagreen')
plt.title('Real Customer Count by RFM Segment')
plt.ylabel('Number of Real Customers')
plt.tight_layout()
plt.savefig('rfm_segment_sizes.png', dpi=120)
plt.close()

Part 18: Visualizing Real Recency Against Monetary Value#

colors = {'Champions': 'gold', 'Loyal Customers': 'seagreen', 'New Customers': 'steelblue', 'At Risk': 'orange', 'Lost': 'gray'}
plt.figure(figsize=(9, 6))
for seg, group in rfm.groupby('Segment'):
    plt.scatter(group['Recency'], group['Monetary'], label=seg, alpha=0.6, color=colors[seg])
plt.yscale('log')
plt.xlabel('Real Recency (days since last order)')
plt.ylabel('Real Monetary Value (log scale)')
plt.title('Real Recency vs Monetary Value by Segment')
plt.legend()
plt.tight_layout()
plt.savefig('rfm_recency_monetary_scatter.png', dpi=120)
plt.close()

Part 19: Real Champions Worth Naming#

rfm[rfm['Segment'] == 'Champions'].sort_values('Monetary', ascending=False).head(10)
CustomerID Recency Frequency Monetary R_score F_score M_score RFM_Sum Segment
1689 14646 2 72 279138.02 4 4 4 12 Champions
4197 18102 1 60 259657.30 4 4 4 12 Champions
3725 17450 8 46 194390.79 4 4 4 12 Champions
1879 14911 1 198 136161.83 4 4 4 12 Champions
55 12415 24 20 124564.53 3 4 4 11 Champions
1333 14156 10 54 116560.08 4 4 4 12 Champions
3768 17511 3 31 91062.38 4 4 4 12 Champions
2700 16029 39 62 72708.09 3 4 4 11 Champions
3174 16684 4 28 66653.56 4 4 4 12 Champions
996 13694 4 50 65039.62 4 4 4 12 Champions

Part 20: Real At-Risk Customers Worth a Win-Back#

rfm[rfm['Segment'] == 'At Risk'].sort_values('Monetary', ascending=False).head(10)
CustomerID Recency Frequency Monetary R_score F_score M_score RFM_Sum Segment
459 12939 64 8 11581.80 2 4 4 10 At Risk
50 12409 79 3 11072.67 2 3 4 9 At Risk
2812 16180 100 8 10254.18 2 4 4 10 At Risk
324 12744 56 4 9120.39 2 3 4 9 At Risk
1903 14952 60 11 8099.49 2 4 4 10 At Risk
73 12435 80 2 7829.89 2 2 4 8 At Risk
3219 16745 87 17 7180.70 2 4 4 10 At Risk
519 13027 114 6 6912.00 2 4 4 10 At Risk
3150 16652 59 7 6773.97 2 4 4 10 At Risk
2814 16182 72 4 6617.65 2 3 4 9 At Risk

Part 21: Saving the Real RFM Table#

rfm.to_csv('online_retail_rfm.csv', index=False)
reloaded_rfm = pd.read_csv('online_retail_rfm.csv')
reloaded_rfm.shape == rfm.shape
True

Part 22: One Last Real Sanity Check#

rfm['Segment'].isna().sum()
rfm.groupby('Segment').size().sum() == rfm.shape[0]
np.True_

Part 23: Real Correlation Between Recency, Frequency, and Monetary#

rfm[['Recency', 'Frequency', 'Monetary']].corr().round(2)
Recency Frequency Monetary
Recency 1.00 -0.26 -0.12
Frequency -0.26 1.00 0.55
Monetary -0.12 0.55 1.00

Part 24: Does a Higher Real RFM Score Actually Mean More Spend?#

rfm.groupby('RFM_Sum')['Monetary'].mean().round(1)
RFM_Sum
3      162.4
4      251.6
5      367.8
6      673.1
7      722.6
8     1105.4
9     1363.4
10    2279.3
11    3768.2
12    8826.6
Name: Monetary, dtype: float64

Part 25: Real Distribution of the Combined Score#

plt.figure(figsize=(8, 5))
plt.hist(rfm['RFM_Sum'], bins=range(3, 14), color='slateblue', edgecolor='white')
plt.xlabel('Real Combined RFM Score')
plt.ylabel('Number of Real Customers')
plt.title('Real Distribution of Combined RFM Scores')
plt.tight_layout()
plt.savefig('rfm_score_distribution.png', dpi=120)
plt.close()

Part 26: Where the Real Champions Actually Are#

customer_country = clean.groupby('CustomerID')['Country'].first()
rfm_with_country = rfm.merge(customer_country, on='CustomerID')
rfm_with_country[rfm_with_country['Segment'] == 'Champions']['Country'].value_counts().head(5)
Country
United Kingdom    1190
France              35
Germany             33
Belgium             10
Spain                7
Name: count, dtype: int64

Part 27: The Real Quartile Boundaries Behind Each Score#

_, r_bins = pd.qcut(rfm['Recency'], 4, labels=[4, 3, 2, 1], retbins=True)
r_bins.round(1)
array([  1.,  18.,  51., 143., 374.])

Part 28: Real Median vs Real Mean Spend by Segment#

rfm.groupby('Segment')['Monetary'].median().round(1)
rfm.groupby('Segment')['Monetary'].mean().round(1)
Segment
At Risk            1004.9
Champions          4847.8
Lost                645.5
Loyal Customers     820.0
New Customers       345.7
Name: Monetary, dtype: float64

Part 29: A Real Edge Case Worth Knowing#

one_order_champions = rfm[(rfm['Segment'] == 'Champions') & (rfm['Frequency'] == 1)]
one_order_champions.shape[0]
0

Part 30: Reconciling Row Counts One Last Time#

rfm_with_country.shape[0] == rfm.shape[0]
True

Wrap-Up: What You Learned#

  • RFM genuinely scores every real customer on Recency, Frequency, and Monetary value, each split into real quartiles.
  • Combining the three real individual scores into rule-based segments beats using just the real combined total alone.
  • In this real dataset, a small real Champions segment genuinely drives a hugely disproportionate share of real revenue.
  • The real At Risk segment is the real highest-value group still worth an active win-back effort.
  • Next video: real market basket analysis, finding which real products actually get bought together.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.