Customer Segmentation & Targeting: who to reach, and why¶
A marketing team has a budget to run one campaign. Blasting everyone wastes money on people who will never respond; the art is choosing whom to contact. This case study combines the two halves of machine learning: unsupervised clustering to discover natural customer segments, and a supervised propensity model to score each customer's chance of responding. Then we measure the payoff the way a business does, with a lift/gains chart and an ROI curve against an untargeted campaign. Library-first with scikit-learn.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import seaborn as sns # seaborn = high-level statistical plots (heatmaps, pairplots, count/bar plots)
from matplotlib.colors import ListedColormap
EM="#4338ca"; DEEP="#3730a3"; LIGHT="#c7d2fe"; INK="#1a2138"; GRID="#e6e9f2"; RED="#ef4444"; AMBER="#d97706"; GREEN="#059669"; BLUE="#2563eb"; PUR="#9333ea"; GREY="#94a3b8"; SLATE="#475569"; ORG="#4338ca"; CYAN="#0891b2"
plt.rcParams.update({"figure.facecolor":"white","axes.facecolor":"white","figure.dpi":110,"font.size":11,
"axes.edgecolor":GRID,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,"axes.spines.top":False,
"axes.spines.right":False,"axes.titlesize":12,"axes.titleweight":"bold","legend.frameon":False})
sns.set_style("whitegrid")
BASE="https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.metrics import silhouette_score, roc_auc_score, average_precision_score
from sklearn.linear_model import LogisticRegression
import warnings; warnings.filterwarnings('ignore'); pd.set_option('display.max_columns', 40)
The goal, in two parts. First, understand the customer base by grouping it into a few meaningful segments (who are our customers?). Second, act: build a model that scores each customer's propensity to respond, so the campaign contacts the most promising people first. Success is not accuracy, it is profit, more responses per dollar spent than a blanket mailing would earn.
try: df = pd.read_csv('../../data/marketing_campaign.csv')
except FileNotFoundError: df = pd.read_csv(BASE + 'marketing_campaign.csv')
print('raw shape:', df.shape); df.head(3)
raw shape: (4230, 9)
| customer_id | recency_days | frequency | monetary | tenure_months | email_opens_90d | prior_responses | age | responded | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | C40765 | 18 | 15 | 1865.0 | 78 | 38 | 2 | 60.0 | 0 |
| 1 | C40862 | 31 | 9 | 2324.0 | 59 | 33 | 3 | 42.0 | 1 |
| 2 | C40749 | 15 | 28 | 385.0 | 67 | 25 | 4 | 45.0 | 1 |
Classic RFM plus engagement. Each customer has Recency (days since last purchase), Frequency, and Monetary value, the RFM trio marketers live by, plus tenure, email opens, prior campaign responses, and age. The responded column records whether they responded to the last campaign, which the propensity model will learn to predict.
print('customers:', len(df), '| overall response rate: %.1f%%' % (df.responded.mean()*100))
print('missing:', dict(df[['monetary','age']].isna().sum()))
print('duplicate ids:', df.customer_id.duplicated().sum())
customers: 4230 | overall response rate: 20.9%
missing: {'monetary': np.int64(41), 'age': np.int64(25)}
duplicate ids: 30
A moderately rare, valuable event. About 21% of customers responded last time. That is the untargeted baseline to beat: if we contact a random person, roughly one in five responds. The whole point of targeting is to do far better than that per contact. A few missing values and duplicates need a quick clean.
df = df.drop_duplicates('customer_id').reset_index(drop=True)
feats = ['recency_days','frequency','monetary','tenure_months','email_opens_90d','prior_responses','age']
print('after dedupe:', df.shape, '| response rate still %.1f%%' % (df.responded.mean()*100))
after dedupe: (4200, 9) | response rate still 20.9%
Minimal, deliberate cleaning. We remove duplicate customers and otherwise leave the missing monetary and age values in place, both the clustering and the model will impute them inside their pipelines, on training data only. After cleaning, 4,200 customers remain.
fig, ax = plt.subplots(1, 3, figsize=(14,3.8))
ax[0].hist(df.recency_days, bins=40, color=EM); ax[0].set(title='Recency (days since last purchase)', ylabel='customers')
ax[1].hist(df.frequency, bins=30, color=DEEP); ax[1].set(title='Frequency (purchases)')
ax[2].hist(df.monetary.dropna(), bins=40, color=BLUE); ax[2].set(title='Monetary (total spend)')
plt.tight_layout(); plt.show()
print('response rate by email engagement:')
print(df.assign(opens_bucket=pd.cut(df.email_opens_90d,[-1,2,10,25,60])).groupby('opens_bucket').responded.mean().round(3).to_string())
response rate by email engagement: opens_bucket (-1, 2] 0.013 (2, 10] 0.075 (10, 25] 0.195 (25, 60] 0.671
Heterogeneous customers. The distributions are skewed, most customers are low-frequency and low-spend, with a long tail of valuable ones. And response is far from uniform: it climbs steeply with email engagement. That heterogeneity is exactly why a single average is useless and why we segment first.
seg_pipe = Pipeline([('impute', SimpleImputer(strategy='median')), ('scale', StandardScaler())])
Xs = seg_pipe.fit_transform(df[feats])
sil = {k: silhouette_score(Xs, KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(Xs)) for k in range(2,7)}
print('silhouette by k:', {k: round(v,3) for k,v in sil.items()})
km = KMeans(n_clusters=4, n_init=10, random_state=0).fit(Xs); df['segment'] = km.labels_
# name each cluster from its profile (labels are unsupervised, so name by behavior)
prof = df.groupby('segment')[feats].mean()
names = {prof.monetary.idxmax(): 'Champions', prof.recency_days.idxmax(): 'Lapsed / at-risk'}
rest = [s for s in prof.index if s not in names]
rs = prof.loc[rest].frequency.sort_values(ascending=False)
names[rs.index[0]] = 'Loyal regulars'; names[rs.index[1]] = 'New browsers'
df['segment_name'] = df.segment.map(names)
summary = df.groupby('segment_name').agg(n=('customer_id','size'), recency=('recency_days','mean'), frequency=('frequency','mean'), monetary=('monetary','mean'), opens=('email_opens_90d','mean'), response_rate=('responded','mean')).round(1)
print(summary.to_string())
fig, ax = plt.subplots(1, 2, figsize=(13,4.4))
P = PCA(n_components=2).fit_transform(Xs)
for name,c in zip(['Champions','Loyal regulars','New browsers','Lapsed / at-risk'], [EM,BLUE,AMBER,GREY]):
m = df.segment_name==name; ax[0].scatter(P[m,0], P[m,1], s=8, c=c, alpha=0.5, label=name)
ax[0].set(title='Customer segments (PCA view)', xlabel='PC1', ylabel='PC2'); ax[0].legend(markerscale=2, fontsize=8)
(df.groupby('segment_name').responded.mean()*100).reindex(['Champions','Loyal regulars','New browsers','Lapsed / at-risk']).plot.bar(ax=ax[1], color=[EM,BLUE,AMBER,GREY])
ax[1].set(title='Response rate by segment', ylabel='% responded'); ax[1].tick_params(axis='x', rotation=20)
plt.tight_layout(); plt.show()
silhouette by k: {2: 0.423, 3: 0.405, 4: 0.402, 5: 0.378, 6: 0.324}
n recency frequency monetary opens response_rate
segment_name
Champions 960 18.2 22.2 2314.7 30.4 0.6
Lapsed / at-risk 1043 298.9 3.0 418.8 2.0 0.0
Loyal regulars 1143 77.0 11.1 970.2 13.5 0.1
New browsers 1054 35.8 6.2 309.0 10.0 0.1
Four customer types, discovered without labels. K-means on the standardized RFM and engagement features finds four clear groups, and profiling their averages lets us name them: Champions (recent, frequent, high-spend, engaged), Loyal regulars, New browsers, and Lapsed / at-risk. The silhouette score gently favors fewer clusters, but four is the actionable choice, each maps to a distinct, nameable customer, a reminder that k is often a business decision, not just a statistical one. Crucially, response rate swings enormously across segments, from about 1% for the lapsed group to 65% for Champions, so who a customer is already says a lot about whether they will respond.
X = df[feats]; y = df['responded']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.30, random_state=0, stratify=y)
print('train:', len(X_train), 'customers |', y_train.sum(), 'responders')
print('test :', len(X_test), 'customers |', y_test.sum(), 'responders (used to judge the campaign)')
train: 2940 customers | 615 responders test : 1260 customers | 264 responders (used to judge the campaign)
Segments describe; the propensity model decides. Segmentation grouped everyone; now we build a supervised model that scores each customer's probability of responding, which is what actually drives the contact list. We hold out 30% of customers to measure the targeting honestly, exactly as in the earlier case studies.
model = Pipeline([('impute', SimpleImputer(strategy='median')), ('scale', StandardScaler()), ('clf', LogisticRegression(max_iter=2000))]).fit(X_train, y_train)
prob = model.predict_proba(X_test)[:,1]
print('propensity model ROC-AUC: %.3f | PR-AUC: %.3f' % (roc_auc_score(y_test, prob), average_precision_score(y_test, prob)))
fig, ax = plt.subplots(figsize=(7.6,4.2))
ax.hist(prob[y_test==0], bins=30, color=GREY, alpha=0.7, label='did not respond')
ax.hist(prob[y_test==1], bins=30, color=EM, alpha=0.75, label='responded')
ax.set(title='The model separates responders from non-responders', xlabel='predicted response probability', ylabel='customers'); ax.legend()
plt.tight_layout(); plt.show()
propensity model ROC-AUC: 0.866 | PR-AUC: 0.661
A good ranker. The logistic propensity model earns a ROC-AUC around 0.87, it reliably scores responders higher than non-responders. The histogram shows the separation: responders (indigo) pile up at higher predicted probabilities. We do not need a perfect classifier here, only a good ranking, because targeting is about ordering customers from most to least promising.
order = np.argsort(-prob)
gains = np.cumsum(y_test.values[order]) / y_test.sum()
pct = np.arange(1, len(order)+1) / len(order)
fig, ax = plt.subplots(1, 2, figsize=(12,4.6))
ax[0].plot(pct*100, gains*100, color=EM, lw=2.6, label='targeted by model')
ax[0].plot([0,100],[0,100], '--', color=GREY, label='random (untargeted)')
ax[0].set(title='Cumulative gains: responders captured', xlabel='% of customers contacted', ylabel='% of responders reached'); ax[0].legend(loc='lower right')
lift = gains / pct
ax[1].plot(pct*100, lift, color=PUR, lw=2.6); ax[1].axhline(1, color=GREY, ls='--', label='random = 1x')
ax[1].set(title='Lift over an untargeted campaign', xlabel='% of customers contacted', ylabel='lift (x better than random)'); ax[1].legend()
plt.tight_layout(); plt.show()
for f in [0.1, 0.2, 0.3]:
k = int(f*len(order)); print('contact top %2d%% -> reach %2.0f%% of responders (%.1fx lift)' % (f*100, gains[k-1]*100, lift[k-1]))
contact top 10% -> reach 36% of responders (3.6x lift) contact top 20% -> reach 63% of responders (3.1x lift) contact top 30% -> reach 78% of responders (2.6x lift)
This is why targeting works. Rank customers by propensity and contact the top of the list first. The cumulative gains curve bows far above the diagonal: contacting the top 20% of customers reaches about 63% of all responders, more than three times what a random 20% would reach. The lift curve says the same thing directly, the best-scored customers are about 3x likelier to respond than a random pick. The campaign captures most of the value while contacting a fraction of the list.
CONTACT_COST = 5 # cost to reach one customer
RESPONSE_VALUE = 25 # profit from one response
n = len(order); resp_sorted = y_test.values[order]
profit = np.cumsum(resp_sorted)*RESPONSE_VALUE - np.arange(1, n+1)*CONTACT_COST
best_k = int(np.argmax(profit)) + 1
fig, ax = plt.subplots(figsize=(7.8,4.4))
ax.plot(pct*100, profit, color=EM, lw=2.4)
ax.axvline(best_k/n*100, color=RED, ls='--', label=f'optimal: contact top {best_k/n*100:.0f}%')
ax.axhline(0, color=GREY, lw=1)
ax.set(title='Profit vs how much of the list we contact', xlabel='% of customers contacted', ylabel='campaign profit ($)'); ax.legend()
plt.tight_layout(); plt.show()
everyone = y_test.sum()*RESPONSE_VALUE - n*CONTACT_COST
print('optimal: contact top %.0f%% -> profit $%d' % (best_k/n*100, profit[best_k-1]))
print('contact everyone -> profit $%d' % everyone)
print('do nothing -> profit $0')
optimal: contact top 28% -> profit $3275 contact everyone -> profit $300 do nothing -> profit $0
The profit curve names the number. With each contact costing $5 and each response worth $25, contacting a customer only pays off if their response probability clears 1 in 5. The profit curve rises as we work down the ranked list, then falls once we start paying to reach unlikely responders. Its peak, contacting roughly the top 25%, earns far more than either extreme: about ten times the profit of blasting everyone, and blasting everyone barely beats doing nothing. Targeting turned a marginal campaign into a clearly profitable one.
coefs = pd.Series(model.named_steps['clf'].coef_[0], index=feats).sort_values()
fig, ax = plt.subplots(1, 2, figsize=(13,4.4))
coefs.plot.barh(ax=ax[0], color=[RED if c>0 else GREEN for c in coefs.values]); ax[0].axvline(0, color=INK)
ax[0].set(title='What drives response (log-odds)', xlabel='logistic coefficient')
df_test = X_test.copy(); df_test['prob'] = prob; df_test['segment_name'] = df.loc[X_test.index, 'segment_name']
(df_test.groupby('segment_name').prob.mean()).reindex(['Champions','Loyal regulars','New browsers','Lapsed / at-risk']).plot.bar(ax=ax[1], color=[EM,BLUE,AMBER,GREY])
ax[1].set(title='Average propensity by segment', ylabel='mean predicted probability'); ax[1].tick_params(axis='x', rotation=20)
plt.tight_layout(); plt.show()
print('top response drivers:', ', '.join(coefs.tail(3).index[::-1]))
top response drivers: email_opens_90d, prior_responses, frequency
Segments and scores tell one story. The propensity model leans on email engagement, prior responses, frequency, and spend (all raising response odds), and on recency (recent buyers respond, dormant ones do not), the same traits that define the Champions segment. Averaging the propensity score within each segment closes the loop: Champions carry by far the highest average propensity, Lapsed the lowest. The unsupervised segments and the supervised scores agree, and together they tell the marketer not just who to contact but why.
import joblib
joblib.dump(model, 'propensity_model.joblib'); joblib.dump(km, 'segment_model.joblib')
clf = joblib.load('propensity_model.joblib'); segmenter = joblib.load('segment_model.joblib')
TARGET_THRESHOLD = CONTACT_COST / RESPONSE_VALUE # contact only if expected value is positive
new_customers = pd.DataFrame([
{'recency_days':15,'frequency':24,'monetary':2600,'tenure_months':64,'email_opens_90d':34,'prior_responses':4,'age':41}, # looks like a Champion
{'recency_days':310,'frequency':2,'monetary':300,'tenure_months':50,'email_opens_90d':1,'prior_responses':0,'age':55}]) # looks lapsed
seg_labels = segmenter.predict(seg_pipe.transform(new_customers))
for i, row in new_customers.iterrows():
p = clf.predict_proba(new_customers.iloc[[i]])[0,1]
action = 'CONTACT (expected profit positive)' if p >= TARGET_THRESHOLD else 'skip (not worth the cost)'
print(f'customer {i+1}: segment "{names[seg_labels[i]]}", response probability {p:.0%} -> {action}')
print('\nin production: score the whole base nightly, contact everyone above the value threshold,')
print('run a holdout control group to measure real incremental lift, and refresh segments as behavior shifts.')
customer 1: segment "Champions", response probability 79% -> CONTACT (expected profit positive) customer 2: segment "Lapsed / at-risk", response probability 1% -> skip (not worth the cost) in production: score the whole base nightly, contact everyone above the value threshold, run a holdout control group to measure real incremental lift, and refresh segments as behavior shifts.
From analysis to a campaign list. The saved pipelines turn a new customer record into an action in one pass: assign a segment, compute a response probability, and contact only if the expected value (probability times response value) beats the contact cost. The Champion-like customer is an obvious contact; the lapsed one is not worth the postage. In production the base is scored regularly, and, importantly, a holdout control group left uncontacted lets the team measure the campaign's incremental lift, the true test of whether targeting paid off. Chapter 126 covers running this in production.
Segmentation and targeting, in one view¶
- Segment first (unsupervised): K-means on RFM + engagement found four nameable customer types with response rates from about 1% to 65%.
- Score next (supervised): a propensity model (ROC-AUC about 0.87) ranks each customer's chance of responding.
- Target by rank: the gains curve shows the top 20% of customers hold about 63% of responders, roughly 3x lift over random.
- Decide by ROI: with cost and value per contact, the profit curve picks the optimal fraction (about the top 25%), far beating a blanket campaign.
- Combine both: segments explain who and why; propensity decides whom to contact, and a holdout control proves the lift.
Unsupervised learning tells you who your customers are; supervised learning tells you which ones to act on. Targeting is where they meet.