Imagine you've got a database of 100,000 customers. Marketing wants personalized campaigns. One strategy per customer is impossible. One strategy for all of them is wasteful. The middle ground is segmentation: group customers into clusters that share behavior, then tailor your approach to each group.
Suppose seven clusters score slightly better mathematically than five, but marketing can only run five distinct campaigns. I'd start by examining the five-cluster solution: does it preserve the differences that would change an offer or a message? Seven customer personas are little help if two will spend their lives in a slide deck. This is an illustrative decision, not a result from a customer dataset.
Decide what similarity should mean
A supervised classifier learns to reproduce labels such as "high value" or "at risk." Clustering groups customers without those labels. It can suggest patterns you hadn't chosen to name: infrequent buyers with large orders, for example, or daily visitors who never convert.
The human choices are still there. Choose spending alone and customers will look similar because they spend similar amounts. Add purchase frequency and you can distinguish the occasional big order from a steady habit. The features, time window, and distance calculation define what the algorithm can see. Start with a decision someone could act on, then choose measurements that help explain it.
K-Means: the workhorse
K-Means is the algorithm I reach for first. It's fast, the math is intuitive, and it works fine when your clusters are roughly spherical and similar in size.
The loop is short:
- Pick K initial centroids (random, or via K-means++)
- Assign each point to its nearest centroid
- Recompute each centroid as the mean of its assigned points
- Repeat 2 and 3 until nothing moves
Press play to watch an actual K-Means run on 150 synthetic points generated in four groups. All four initial centroids start clumped in one corner so you can watch the reassignment do its work. These aren't customer records; the animation shows how the algorithm settles:
Lloyd's algorithm on 150 synthetic points, with K fixed at 4. The crosses are the centroids. Each iteration moves them to the mean of their assigned points, then reassigns points to the nearest centroid. The numbers switching are 27, 11, 6, 2, 1, 1, and finally 0 at iteration 7. Inertia, the total squared distance to centroids, falls from 6,108 to 856. This shows convergence for one K; choosing K requires comparing separate fits, as in the elbow plot below.
import pandas as pd
import numpy as np
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
# Load and prepare customer data
df = pd.read_csv('customers.csv')
features = df[['recency', 'frequency', 'monetary', 'avg_session_duration',
'support_tickets', 'days_since_signup']]
# Scaling is critical - K-Means uses Euclidean distance
scaler = StandardScaler()
X = scaler.fit_transform(features)
# Fit K-Means
kmeans = KMeans(n_clusters=5, init='k-means++', n_init=10, random_state=42)
df['cluster'] = kmeans.fit_predict(X)The code expects one prepared row per customer, with missing values handled. K-Means measures straight-line distance across the feature columns. A monetary column ranging from 0 to 10,000 can dominate a frequency column ranging from 0 to 50 simply because of its units. StandardScaler centers each column and scales nonconstant columns to unit variance. It gives differences measured in standard deviations comparable weight.
That is a useful starting choice, not a business definition of importance. A support ticket and a purchase needn't deserve equal influence. Also inspect unusually large spenders before scaling: StandardScaler is sensitive to outliers. For heavily skewed positive amounts, consider a log transform and compare how it changes the resulting segments.
Choosing K: elbow method and silhouette scores
Picking K is the hardest part of K-Means. Two methods get you most of the way there.
The elbow method plots the within-cluster sum of squares (inertia) against K. As K grows, inertia drops. The "elbow" is where the drop flattens out and adding more clusters stops paying off.
import matplotlib.pyplot as plt
inertias = []
K_range = range(2, 11)
for k in K_range:
km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42)
km.fit(X)
inertias.append(km.inertia_)
plt.plot(K_range, inertias, 'bo-')
plt.xlabel('Number of Clusters (K)')
plt.ylabel('Inertia')
plt.title('Elbow Method')
plt.show()The elbow is often ambiguous on real data. The silhouette score gives a second view of separation. For each point, the silhouette coefficient compares how similar it is to its own cluster against the nearest neighboring cluster. Scores run from -1 to 1. Negative values suggest a point is closer to another group; values near 1 indicate strong separation under the chosen distance. Average across all points and you get one number to compare different K values.
from sklearn.metrics import silhouette_score
for k in K_range:
km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42)
labels = km.fit_predict(X)
score = silhouette_score(X, labels, sample_size=min(5000, len(X)), random_state=42)
print(f"K={k}: Silhouette Score = {score:.3f}")The sample_size option estimates the score on a random subset, avoiding a full pairwise comparison across the illustrative 100,000 customers. Here 5,000 is a practical example, not a universal threshold; check that smaller segments are adequately represented.
Neither score tells you whether an offer will work. Return to the five-versus-seven decision: inspect which customers split when K increases. If the extra groups need the same treatment, the higher score may buy the team extra administration. If they reveal a valuable distinction, the case for another campaign becomes much stronger.
Feature engineering: RFM analysis
Raw transactions aren't ready to cluster. The standard starting point for customer segmentation is RFM analysis:
- Recency: days since last purchase. Interpret it against the usual buying cycle.
- Frequency: number of purchases in a window. Higher means more engaged.
- Monetary: total spend. Your revenue signal.
from datetime import datetime
snapshot_date = datetime(2025, 10, 1)
transactions = pd.read_csv('transactions.csv', parse_dates=['purchase_date'])
transactions = transactions[transactions['purchase_date'] < snapshot_date]
rfm = transactions.groupby('customer_id').agg({
'purchase_date': lambda x: (snapshot_date - x.max()).days, # Recency
'order_id': 'nunique', # Frequency
'amount': 'sum' # Monetary
}).rename(columns={
'purchase_date': 'recency',
'order_id': 'frequency',
'amount': 'monetary'
})This separate example starts from transaction rows, rather than the customer-level file above. Choose a consistent observation window and handle refunds and cancellations before aggregation. A customer who bought a refrigerator six months ago needs a different reading of "recency" from someone who buys coffee every week.
RFM is a starting point. Depending on the business, I'll layer in average order value, number of product categories purchased, customer tenure, support tickets, email open rate, or session duration. More features sometimes help, but they also blow up dimensionality and add noise. Be deliberate.
Beyond K-Means: DBSCAN for non-spherical clusters
K-Means favors compact groups and can struggle with uneven sizes or elongated shapes. A large spread of casual buyers beside a tiny pocket of power users can produce an awkward partition. If that geometry describes your data, density-based clustering offers another approach.
DBSCAN (Density-Based Spatial Clustering of Applications with Noise) finds clusters of arbitrary shape based on point density. It takes two parameters:
- eps: radius of the neighborhood around each point
- min_samples: the minimum number of points needed to call a region dense
Points that don't belong to any dense region get labeled as noise, which can be useful. A noise label means the point didn't meet this density rule; it doesn't mean the customer is unimportant.
from sklearn.cluster import DBSCAN
dbscan = DBSCAN(eps=0.8, min_samples=10)
df['dbscan_cluster'] = dbscan.fit_predict(X)
n_clusters = len(set(df['dbscan_cluster'])) - (1 if -1 in df['dbscan_cluster'].values else 0)
n_noise = (df['dbscan_cluster'] == -1).sum()
print(f"Clusters: {n_clusters}, Noise points: {n_noise}")Tuning eps is the hard part. Too small and everything is noise. Too large and everything collapses into one cluster. A k-distance plot (sort the distance to the k-th nearest neighbor for each point, look for the elbow) helps identify candidate values. These example parameters need tuning on your scaled data. A single radius can also struggle when clusters have very different densities.
Interpreting and acting on clusters
Clustering isn't the end of the work, it's the beginning of the conversation with your stakeholders. For each cluster, compute summary stats:
summary = df.groupby('cluster')[['recency', 'frequency', 'monetary']].agg(['mean', 'median', 'std'])
print(summary)The summary still uses the K-Means labels; the DBSCAN alternative is stored separately. Inspect the distributions and sample customers before naming groups. The table below is an illustrative interpretation of five RFM groups, not output from the animation or a measured customer dataset:
| Cluster | Recency | Frequency | Monetary | Label |
|---|---|---|---|---|
| 0 | Low | High | High | Champions |
| 1 | Low | Low | Low | New Customers |
| 2 | High | High | High | At-Risk Loyalists |
| 3 | High | Low | Low | Lost |
| 4 | Medium | Medium | Medium | Potential Loyalists |
Those names are hypotheses about useful treatment. Low recency and low spending alone don't establish that a customer is new; check signup date before assigning that label. Champions get loyalty rewards and early access. At-Risk Loyalists get win-back campaigns before they churn. New Customers get onboarding flows. Lost customers may simply not be worth the cost to re-acquire. That's a real answer too.
Common pitfalls
Letting units choose the segments. Without deliberate scaling or weighting, dollar amounts can dominate the distance calculation. Check which columns actually drive the separation.
Throwing in too many features. Redundant or irrelevant columns can distort similarity. There is no universal 10- or 15-feature cutoff for PCA. Compare a small, interpretable feature set with any reduced representation and check what customer distinctions survive.
Treating clusters as permanent. Customer behavior moves. Review on a cadence that fits the buying cycle, perhaps monthly or quarterly, and watch how customers migrate between segments. That migration is often the most useful signal you have.
Ignoring stability. Run K-Means multiple times with different random seeds. Compare membership, not the arbitrary cluster numbers, which can swap between runs. Frequent membership changes suggest the proposed groups need closer inspection before they drive campaigns.
Put the segments to work
For the illustrative 100,000-customer problem, I'd choose five groups over seven when the extra split doesn't change a useful action. I'd keep seven in consideration if it separates customers who need different treatment and the team can deliver it. That judgment needs the profiles, their stability, and the campaign capacity alongside the statistical scores.
Then test a proposed action. For the "At-Risk Loyalists" group, randomly assign eligible customers to a win-back offer or a control group receiving the usual treatment. Decide the evaluation window and success measure beforehand, such as incremental repeat purchases or profit after discount costs. The cluster gives you a reason to try the offer; the comparison tells you whether it helped.
K-Means can turn a large customer table into groups people can examine and discuss. The useful segmentation is the one whose differences lead to better decisions, with an intervention that earns its cost. A clean silhouette plot is a good place to ask questions. Customer behavior after the campaign is where the answer starts.
