←Back to the bookChapter 127 · Customer Segmentation & Targeting
▶ Open in Colab⬇ Download notebookView on GitHub
Chapter 127 · Case Study: Customer Segmentation & Targeting
Case Study: Customer Segmentation & Targeting
Combining unsupervised clustering with a supervised propensity model to run a smarter marketing campaign: segment customers by RFM and engagement, score each one's chance of responding, target by rank, and measure the lift and ROI over an untargeted campaign. Library-first with scikit-learn, and heavily visual.
Statistics, Data Science and AI: A Visual Handbook · John Fisher · 2026

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.

In [1]:
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/"
In [2]:
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)
STEP 1 · DEFINE THE OBJECTIVE
Spend the campaign budget where it pays
Target the customers most worth reaching.

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.

STEP 2 · COLLECT THE DATA
A customer table
One row per customer.
In [3]:
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)
Out[3]:
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.

STEP 3 · INSPECT
Size, response rate, gaps
How many customers, how many respond?
In [4]:
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.

STEP 4 · CLEAN
Tidy up
Dedupe; leave gaps for the pipelines.
In [5]:
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 impute them inside their pipelines. The clustering is descriptive and imputes on the full table, while the propensity model imputes on its training data only. After cleaning, 4,200 customers remain.

STEP 5 · VISUALIZE
The RFM landscape
Customers spread out along value and recency.
In [6]:
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())
No description has been provided for this image
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.

STEP 6 · SEGMENT (UNSUPERVISED)
Discover the customer types
Cluster on RFM + engagement, then name the groups.
In [7]:
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: np.float64(0.423), 3: np.float64(0.405), 4: np.float64(0.402), 5: np.float64(0.378), 6: np.float64(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
No description has been provided for this image

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.

STEP 7 · SPLIT
Train the propensity model honestly
Hold out customers for an unbiased score.
In [8]:
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.

STEP 8 · PROPENSITY MODEL (SUPERVISED)
Score every customer's chance to respond
A calibrated probability per person.
In [9]:
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
No description has been provided for this image

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.

STEP 9 · TARGET (GAINS & LIFT)
The payoff of targeting
Rank by propensity; reach the best first.
In [10]:
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]))
No description has been provided for this image
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.

STEP 10 · ROI
How many should we actually contact?
Put dollars on it: cost per contact, value per response.
In [11]:
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')
No description has been provided for this image
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 dollars 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 28%, 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.

STEP 11 · INTERPRET
What drives response, and where the value sits
The two halves meet.
In [12]:
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='Strongest predictors of 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('strongest response predictors:', ', '.join(coefs.tail(3).index[::-1]))
No description has been provided for this image
strongest response predictors: 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 going with higher 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. The coefficients are associations in past campaigns, so together they tell the marketer whom to contact and what those customers share, and only a holdout test shows what contacting them adds.

STEP 12 · DEPLOY ON NEW DATA
Score a new customer, decide the action
Segment, propensity, and a contact decision.
In [13]:
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 130 covers running this in production.

Segmentation and targeting, in one view¶

  1. Segment first (unsupervised): K-means on RFM + engagement found four nameable customer types with response rates from about 1% to 65%.
  2. Score next (supervised): a propensity model (ROC-AUC about 0.87) ranks each customer's chance of responding.
  3. Target by rank: the gains curve shows the top 20% of customers hold about 63% of responders, roughly 3x lift over random.
  4. Decide by ROI: with cost and value per contact, the profit curve picks the optimal fraction (about the top 28%), far beating a blanket campaign.
  5. Combine both: segments describe who responds; 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.


Statistics, Data Science and AI: A Visual Handbook · © 2026 John Fisher
←Back to the bookChapter 127 · Customer Segmentation & Targeting