Predictive Modeling and Machine Learning Fundamentals Questions
Applied predictive-modeling cases worked end to end on tabular business data. Covers choosing a model family for a business target (churn, lifetime value, lead scoring, conversion, support-ticket escalation), setting a baseline, defining the label, observation window and prediction horizon, catching leakage, sanity-checking and interpreting results for non-technical stakeholders (feature importance and its traps with correlated features, explaining a single flagged customer), handling censored outcomes with survival methods such as Kaplan-Meier and Cox models, turning model scores into thresholds and business actions with cost-sensitive reasoning, targeting for incremental effect with uplift modeling, and clustering and unsupervised methods in practice: k-means and its initialization (k-means++), empty clusters, scaling, distance choices and mini-batch variants, choosing and validating the number of clusters without labels (elbow, silhouette, stability), density-based, hierarchical and mixture-model alternatives, mixed numeric and categorical features, high-dimensional preparation and visualization, and customer segmentation carried through to stakeholder communication, testing whether segment-based targeting works, keeping segments stable over refits, and downstream use. Excludes regression and classification algorithm internals, metric and validation theory, feature engineering mechanics, and large-scale or production ML systems (including distributed or streaming clustering), which are covered elsewhere.
You have clustered customers described by dozens of features. How would you visualize the structure to sanity-check the clusters, and what can and cannot be concluded from such a picture?
Sample Answer
A picture is a sanity check on the clustering, not proof of it. Reduce the customers to two dimensions, colour them by cluster label, and look for three things: whether clusters are visibly separated, whether any cluster is split across the map or merged with another, and whether the cloud has real gaps or is one continuous blob that the algorithm cut into pieces. What you can conclude is "these groups are, or are not, obviously inconsistent with the structure in this projection". What you cannot conclude is that the clusters are real, that unseen features agree, or that distances on the map are distances between customers.
Pictures worth drawing
| Picture | What it shows | What it cannot show |
|---|---|---|
| Scatter of the first two principal components (PCA, a linear projection that keeps the directions of largest variance) coloured by cluster | Global layout: which clusters sit far apart, which overlap, how big the cloud is | Anything that lives in directions beyond the first two components |
| Scatter from a nonlinear embedding such as t-SNE or UMAP (examples of methods that preserve each point's neighbours rather than global distances) | Whether local neighbourhoods are mostly one cluster | Distances between clusters, relative cluster sizes and density: these are not reliable on such maps, and the picture changes with the method's neighbourhood-size setting (perplexity in t-SNE, n_neighbors in UMAP: roughly how many neighbours each point treats as local) |
| Cluster profile table or heatmap: each cluster's mean of each feature, in standard deviations from the overall mean (z-score) | What each cluster is, in business terms | Whether the clusters are separated, since means can differ with heavy overlap |
| Silhouette plot (per customer, how much closer it is to its own cluster than to the next nearest one, from -1 to 1) | Which clusters are tight and which customers sit on a boundary | Anything about features not used in the distance |
| Bar of cluster sizes | Tiny or giant clusters | Whether the sizes are meaningful |
Use a profile table next to a scatter: the scatter says whether the groups are separated, the profile says what they mean.
Why a 2D picture can mislead: a computed example
Twenty features, four customer groups A, B, C, D of 100 customers each. The groups differ on only three feature directions of different strength (strong: {A,B} versus {C,D}; weaker: C versus D; weakest: A versus B) and the other features are noise. The code builds the data with a fixed seed, projects with PCA, and compares centroid distances (the straight-line distance between two group averages) in the full space and in the picture. The "explained variance ratio" it prints is the share of the data's total variance (spread) that each principal component carries; the shares of all components add up to 1, so two components with 0.6 and 0.1 leave 0.3 of the spread off the map.
import numpy as np
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.metrics import silhouette_score
rng = np.random.default_rng(0)
n_per, d = 100, 20
# four customer groups A, B, C, D (labels 0-3) in 20 features
means = np.zeros((4, d))
means[[0, 1], 0], means[[2, 3], 0] = -6, 6 # axis 0: {A,B} vs {C,D}
means[2, 1], means[3, 1] = -3, 3 # axis 1: C vs D
means[0, 2], means[1, 2] = -2.5, 2.5 # axis 2: A vs B
X = np.vstack([rng.normal(means[g], 1.0, size=(n_per, d)) for g in range(4)])
y = np.repeat(np.arange(4), n_per)
pca = PCA(n_components=3).fit(X)
print("explained variance ratio PC1, PC2, PC3:", np.round(pca.explained_variance_ratio_, 3))
Z = pca.transform(X)
Z2 = Z[:, :2]
def gap(A, a, b):
return np.linalg.norm(A[y == a].mean(0) - A[y == b].mean(0))
print("centroid distance full 20D 2D picture 3D picture")
for name, (a, b) in {"A-B": (0, 1), "C-D": (2, 3), "A-C": (0, 2)}.items():
print(f" {name} {gap(X, a, b):6.2f} {gap(Z2, a, b):6.2f} {gap(Z, a, b):6.2f}")
print("silhouette of true groups, full space:", round(silhouette_score(X, y), 3))
print("silhouette of true groups, 2D picture:", round(silhouette_score(Z2, y), 3))
print("silhouette of A vs B only, 2D picture:", round(silhouette_score(Z2[y < 2], y[y < 2]), 3))
print("silhouette of A vs B only, 3D picture:", round(silhouette_score(Z[y < 2], y[y < 2]), 3))
# no structure at all: one Gaussian blob, k-means still returns 4 clusters
B = rng.normal(0, 1, size=(400, d))
km = KMeans(n_clusters=4, n_init=10, random_state=0).fit(B)
print("one blob, k=4, silhouette in full space:", round(silhouette_score(B, km.labels_), 3))
# profile of the four clusters k-means finds on the real data (z-scored means, features 0-2)
km3 = KMeans(n_clusters=4, n_init=10, random_state=0).fit(X)
Xs = (X - X.mean(0)) / X.std(0)
print("cluster sizes and z-score means on features 0, 1, 2:")
for c in np.argsort(km3.cluster_centers_[:, 0] * 10 + km3.cluster_centers_[:, 1]):
m = Xs[km3.labels_ == c][:, :3].mean(0)
print(" size", int((km3.labels_ == c).sum()), np.round(m, 2))
Output:
explained variance ratio PC1, PC2, PC3: [0.588 0.086 0.064]
centroid distance full 20D 2D picture 3D picture
A-B 5.15 0.89 5.14
C-D 6.02 5.95 6.01
A-C 12.82 12.48 12.82
silhouette of true groups, full space: 0.244
silhouette of true groups, 2D picture: 0.377
silhouette of A vs B only, 2D picture: 0.062
silhouette of A vs B only, 3D picture: 0.583
one blob, k=4, silhouette in full space: 0.039
cluster sizes and z-score means on features 0, 1, 2:
size 99 [-0.99 -0.05 -1.25]
size 101 [-0.98 0.08 1.26]
size 99 [ 0.98 -1.3 0.01]
size 101 [ 0.99 1.25 -0.04]
Reading the run:
- How to read the silhouette numbers. The scale runs from -1 to 1. A rough guide often quoted from the clustering literature (Kaufman and Rousseeuw) reads above about 0.7 as strong structure, 0.5 to 0.7 as reasonable, 0.25 to 0.5 as weak and below about 0.25 as no substantial structure. It is a guide only. Here it understates the full-space result: the groups are real, but 17 noise features dilute the score to 0.244, which sits on the "no substantial structure" boundary. The one-blob value 0.039 is a clear "nothing there", and 0.583 for A versus B in 3D is reasonable.
- The first two components keep only 0.588 + 0.086 of the variance, about 67%, so about one third of the variance is not on the map.
- Groups A and B are 5.15 apart in the full space but 0.89 apart on the 2D map: they overlap on the picture and look like one blob, yet a third component separates them (silhouette 0.062 on the 2D map for A versus B, 0.583 in 3D). A picture with "three clusters" would wrongly suggest k=3.
- The opposite error is also present. The silhouette of the true groups is 0.244 in the full 20 features but 0.377 on the 2D map: the 17 noise features add distance inside every group, and the projection throws that noise away. A clean-looking map does not mean the clustering is crisp in the space the algorithm used.
- With no structure at all (one Gaussian blob), k-means still returns four clusters; the full-space silhouette is only 0.039. A scatter coloured by those labels shows a cloud cut by lines: that look (touching, no gap, boundaries running through dense regions) is what a clustering of nothing looks like.
- The last four lines are a cluster profile: k-means on the full data returns four clusters of 99 to 101 customers, and each row gives the cluster's mean on features 0, 1 and 2 in standard deviations. The first row is low on feature 0 and low on feature 2, the second is low on feature 0 and high on feature 2, so a stakeholder can read the segments without seeing a scatter.
What a picture can and cannot support
Can support:
- A red flag: a cluster scattered in several places on the map, clusters drawn through a dense region, or a cluster that is a single outlier.
- A hypothesis for k: gaps and blobs that suggest a number to test.
- A communication aid: a profile heatmap next to the map is how a stakeholder understands the segments.
Cannot support:
- That clusters are real. Use numbers: silhouette, stability, and agreement with a held-out variable. Stability under resampling means re-clustering on random resamples of the customers or with different random seeds and measuring how much the labels agree (for example with the adjusted Rand index, where 1.0 is an identical grouping): if the groups change a lot, they are fragile. A held-out variable is a column that was not used to cluster; for example, compute the mean revenue per cluster and see whether the clusters differ on it.
- That distances on the map are distances between customers. In PCA the map is a linear shadow of the data; in t-SNE or UMAP the global layout is not reliable.
- That features not on the axes agree. Colour the same map by a feature or outcome not used in clustering to check.
Choosing PCA or a nonlinear embedding
Use PCA for reporting and when the audience needs a stable, explainable picture: the axes are weighted sums of features, the same input always gives the same map, and the explained variance ratio says how much the picture keeps (in the run above the first two components kept 0.588 + 0.086 = 0.674 of the variance, and the third component, which holds the A versus B split, was left out). Use a nonlinear embedding when clusters may be curved or nested, to check local neighbourhoods; treat it as exploration, set the neighbourhood-size setting at two or three values and see what survives, and do not read between-cluster gaps or sizes as facts. Either way, cluster on the full feature set (or a justified reduction), not on the 2D map, unless the map is the stated analysis.
Common mistakes
- Clustering on raw features of different units, then reading the picture: scale first, or one large-unit feature draws the whole map.
- Reading separation on a coloured scatter as validation when the colours came from the algorithm being tested.
- Reading t-SNE blob sizes or distances as customer counts or similarity.
- Reporting a 2D picture without the explained-variance share.
What are the main strengths and weaknesses of k-means, and how would you decide whether it is the right choice for a given segmentation problem?
Sample Answer
Direct answer
K-means is fast, simple and gives each segment a centroid (its mean customer) that is easy to describe, but it assumes clusters are compact, roundish, of similar spread, and it needs k (the number of clusters) up front, depends on the starting centroids and is pulled by outliers and by feature units. For a customer segmentation I would start with k-means on standardised, sensibly transformed features as the baseline, because stakeholders can read a centroid. I would switch method only when diagnostics show the shapes violate its assumptions.
Strengths and weaknesses
| Strength | Why it holds |
|---|---|
| Scales linearly in the number of points | Each iteration costs on the order of n×k×d operations (O(nkd)) for n points, k clusters and d features: every point is compared with every centroid across every feature |
| Interpretable output | Each cluster is summarised by its centroid, which reads like a customer profile |
| Assigns new points cheaply | Nearest centroid, so scoring tomorrow's customers is trivial |
| Well understood, widely available | Easy to explain, debug and reproduce with a fixed seed |
| Weakness | What goes wrong | Diagnostic |
|---|---|---|
| Must choose k | No label says how many | Silhouette, gap, stability across k |
| Local optimum | The result depends on initial centroids | Same inertia across seeds; use k-means++ and restarts |
| Round, similar-spread clusters only | Its boundaries are straight lines between centroids, so crescents and rings get cut, and a wide cluster next to narrow ones is split badly | Plot a 2-D projection; compare against a mixture model or a density method |
| Sensitive to scale | Large-unit features dominate distance | Compare clusters with and without standardising |
| Sensitive to outliers | A far point drags a mean, or takes a cluster for itself | Inspect cluster sizes and the farthest points |
| Hard assignment | A customer between two segments is forced into one | Look at distance gaps to the two nearest centroids, or use a mixture model for membership probabilities |
| Numeric, Euclidean | Categorical columns have no mean | Encode carefully, or use a method for mixed data |
Evidence on five datasets
A single outlier shows the mechanism first: the mean of 1, 2, 3, 4, 5 is 3, and adding one value of 100 moves it to 19.17. A centroid is a mean, so far points drag it in the same way. The program then generates five labelled datasets: three overlapping round blobs of equal spread, two crescents, three blobs on a line with one much wider than the others, and two blobs plus 30 outlier points in two ways (scattered uniformly over a wide box, or packed into one far spot). It scores k-means, a Gaussian mixture (GMM: each cluster is a Gaussian bell shape with its own centre and its own covariance, meaning its own spread and tilt, set here by covariance_type="full"; it gives soft membership, a probability of belonging to each cluster rather than a single label), and DBSCAN (density-based: a point with at least min_samples points (itself included) inside radius eps starts a dense region, regions grow into clusters, and sparse points are noise, labelled -1) against the planted labels with the adjusted Rand index (ARI: 1 is a perfect match, about 0 a random one). The outlier rows are scored on the 600 real points only, so a perfect method can reach 1.0. Features are standardised first. DBSCAN's eps is the best of six values, chosen with the labels, so its column is an optimistic upper bound that a real project cannot compute; the last column is how many clusters DBSCAN formed at that eps. Without labels, a common way to pick eps is a k-distance plot: sort every point's distance to its min_samples-th nearest neighbour (counting the point itself, as in the rule above) and set eps near the bend where distances start to climb steeply. After the table the program prints how many clusters DBSCAN formed at each eps on the round blobs, where the real points of the scattered-outlier row went, and a redraw of both outlier rows with 20 other seeds.
import numpy as np
from sklearn.cluster import KMeans, DBSCAN
from sklearn.datasets import make_blobs, make_moons
from sklearn.metrics import adjusted_rand_score
from sklearn.mixture import GaussianMixture
from sklearn.preprocessing import StandardScaler
# One outlier drags a mean: five customers, then the same five plus one extreme value
five = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
print("mean of 1..5:", five.mean(), " mean after adding a value of 100:", round(float(np.append(five, 100).mean()), 2))
rng = np.random.default_rng(0)
cases = {}
cases["round blobs, equal size"] = make_blobs(600, centers=3, cluster_std=1.0, random_state=0)
cases["two moons"] = make_moons(600, noise=0.07, random_state=0)
Xa, ya = make_blobs(900, centers=[[0, 0], [5, 0], [10, 0]], cluster_std=[0.6, 0.6, 2.2], random_state=0)
cases["unequal spread"] = (Xa, ya)
Xb, yb = make_blobs(600, centers=[[0, 0], [8, 0]], cluster_std=1.0, random_state=0)
# Outlier rows are scored on the 600 real points only, so a perfect method can reach 1.0
cases["+30 outliers, scattered"] = (np.vstack([Xb, rng.uniform(-30, 40, (30, 2))]), yb)
cases["+30 outliers, one far spot"] = (np.vstack([Xb, rng.normal([40, 40], 0.5, (30, 2))]), yb)
grid = [0.1, 0.15, 0.2, 0.3, 0.4, 0.5] # DBSCAN eps values tried, after standardising; min_samples=5
blob_dbscan, where = {}, []
print(f"{'dataset':28s} {'k-means':>8s} {'GMM':>8s} {'DBSCAN best':>12s} {'at eps':>7s} {'groups':>7s}")
for name, (X, y) in cases.items():
k = len(set(y))
real = np.arange(len(y)) # the points that have a planted label
Z = StandardScaler().fit_transform(X)
km = KMeans(k, n_init=10, random_state=0).fit_predict(Z)
gm = GaussianMixture(k, covariance_type="full", n_init=5, random_state=0).fit_predict(Z)
# ARI counts DBSCAN's noise label (-1) as one more group. "Best over the grid" uses the labels,
# so it is an optimistic upper bound that nobody has in practice.
labs = {e: DBSCAN(eps=e, min_samples=5).fit_predict(Z) for e in grid}
db = {e: adjusted_rand_score(y, labs[e][real]) for e in grid}
e_best = max(db, key=db.get)
if name.startswith("round"):
blob_dbscan = {e: len(set(l) - {-1}) for e, l in labs.items()}
if name.endswith("scattered"):
for who, lab in (("k-means", km), ("GMM", gm)):
where.append(f"scattered outliers, {who}: real points per cluster {np.bincount(lab[real], minlength=k).tolist()}, "
f"the 30 added points per cluster {np.bincount(lab[len(y):], minlength=k).tolist()}")
n_groups = len(set(labs[e_best]) - {-1})
print(f"{name:28s} {adjusted_rand_score(y, km[real]):8.3f} {adjusted_rand_score(y, gm[real]):8.3f} "
f"{db[e_best]:12.3f} {e_best:7.2f} {n_groups:7d}")
print()
print("round blobs, DBSCAN clusters by eps:", blob_dbscan)
print(*where, sep="\n")
# Are the two outlier rows typical? Redraw the blobs and the 30 added points with 20 other seeds.
for label, draw in (("scattered", lambda r: r.uniform(-30, 40, (30, 2))),
("one far spot", lambda r: r.normal([40, 40], 0.5, (30, 2)))):
km_ok = km_one = gm_ok = 0
for s in range(1, 21):
r = np.random.default_rng(s)
Xs, ys = make_blobs(600, centers=[[0, 0], [8, 0]], cluster_std=1.0, random_state=s)
Zs = StandardScaler().fit_transform(np.vstack([Xs, draw(r)]))
kl = KMeans(2, n_init=10, random_state=0).fit_predict(Zs)[:600]
gl = GaussianMixture(2, covariance_type="full", n_init=5, random_state=0).fit_predict(Zs)[:600]
km_ok += adjusted_rand_score(ys, kl) > 0.9
km_one += np.bincount(kl, minlength=2).max() == 600 # all 600 real points in one cluster
gm_ok += adjusted_rand_score(ys, gl) > 0.9
print(f"{label:12s}, 20 redraws: k-means separated the real groups in {km_ok}, "
f"put all 600 real points in one cluster in {km_one}; GMM separated them in {gm_ok}")
Output:
mean of 1..5: 3.0 mean after adding a value of 100: 19.17
dataset k-means GMM DBSCAN best at eps groups
round blobs, equal size 0.794 0.800 0.353 0.20 8
two moons 0.480 0.494 1.000 0.20 2
unequal spread 0.404 0.967 0.802 0.10 6
+30 outliers, scattered 1.000 0.000 1.000 0.30 2
+30 outliers, one far spot 0.000 0.000 1.000 0.20 3
round blobs, DBSCAN clusters by eps: {0.1: 24, 0.15: 16, 0.2: 8, 0.3: 1, 0.4: 1, 0.5: 1}
scattered outliers, k-means: real points per cluster [300, 300], the 30 added points per cluster [19, 11]
scattered outliers, GMM: real points per cluster [600, 0], the 30 added points per cluster [2, 28]
scattered , 20 redraws: k-means separated the real groups in 7, put all 600 real points in one cluster in 13; GMM separated them in 0
one far spot, 20 redraws: k-means separated the real groups in 0, put all 600 real points in one cluster in 20; GMM separated them in 0
How to read it:
- Round blobs: k-means and the GMM tie (0.794 and 0.800; the blobs overlap, so neither reaches 1). DBSCAN's best is 0.353 because the overlapping blobs have no gap for it to find: at small
epsit shatters them into many small dense pieces (24, 16 and 8 clusters at 0.10, 0.15 and 0.20, where 3 are planted, as the line under the table shows), and ateps0.3 and above it merges everything into one cluster (ARI 0.000). The assumption fits k-means, so the simple method is enough and the extra machinery buys nothing. - Crescents: k-means scores 0.480 because it can only cut with a straight boundary between centroids, while DBSCAN recovers them (1.000).
- Unequal spread: k-means 0.404, GMM 0.967. A mixture model gives each cluster its own spread, which k-means cannot.
- Outliers scattered over a wide box: the GMM scores 0.000. The printed cluster contents show why: it puts all 600 real points in one component, which also takes 2 of the 30 added points, and gives a second, very wide component to the other 28, so the real groups are never separated. A model that must explain every point can spend a component on the outliers. The same happened on all 20 redraws (GMM separated the real groups in 0 of 20). k-means scores 1.000 on the shipped dataset, but that is the lucky draw, not the rule: on the 20 redraws it separates the real groups in only 7. In the other 13 it puts all 600 real points in one cluster and spends the second centroid on the added points, because cutting the wide box of outliers removes more squared distance than separating two tight blobs. So scattered outliers can wreck k-means as well, and whether they do depends on the draw.
- Outliers packed in one far spot: now k-means scores 0.000 for the opposite reason: with k=2 it spends one centroid on the distant clump and splits nothing among the real points. This is not a draw-dependent result: on the 20 redraws k-means put all 600 real points in one cluster every time, and the GMM separated the real groups in none. DBSCAN scores 1.000 by forming a third cluster for the clump and keeping the two real groups intact. So the outlier weakness is real and depends on where the outliers are: they hurt when they are far, numerous or clumped enough to win a centroid, and the diagnostic is to look at cluster sizes and the farthest points. Apart from the two redraw lines, each row of the table comes from one generated dataset, so it illustrates the pattern and does not rank the methods.
How to decide for a segmentation problem
- Ask what the segments are for. If marketing needs a handful of nameable profiles, a centroid-based method is a natural fit.
- Look at the data. Standardise, take logs of skewed money and count features, and plot a 2-D projection. Crescents, rings and very uneven spread argue against k-means.
- Run k-means anyway as the baseline. Check silhouette, stability across seeds and subsamples, and cluster sizes.
- Compare one alternative that fixes the specific symptom: a GMM for unequal spread or overlapping groups, DBSCAN for arbitrary shapes plus noise, hierarchical clustering (repeatedly merging the two closest groups into a tree, which you can cut at any level) when a tree of nested segments is useful.
- Choose on usefulness. Keep the simplest method whose segments differ in behaviour you can act on.
What flips the choice: many outliers or unknown shape (density-based), the need for membership probabilities (mixture), or a requirement to scale to very many points with minimal cost (k-means or its mini-batch variant, which updates centroids from small random batches of points instead of all points each pass, trading a little accuracy for speed).
Common mistakes
- Treating the output as proof that clusters exist; k-means returns k clusters on any data.
- Skipping scaling.
- Switching to a fancier method without a diagnostic showing the baseline is failing.
Explain hierarchical clustering: how it builds groups, how you read a dendrogram to pick segments, and how it compares with k-means.
Sample Answer
Hierarchical clustering builds a tree of groups instead of one flat set. The common bottom-up version (agglomerative) starts with every customer as its own group and repeatedly merges the two closest groups until one group remains. The tree of merges is the dendrogram. To get segments you cut the tree at a height: every branch the cut crosses becomes a segment. Compared with k-means, you do not fix the number of segments in advance and one run gives all of them, but it does not scale to very large tables and it never revisits an early merge.
How it builds groups
- Compute the distance between every pair of customers (an n by n table).
- Treat each customer as a cluster of size 1.
- Merge the two closest clusters. Record the distance at which they merged: this is the merge height.
- Recompute the distance from the new cluster to the others using a linkage rule, then go back to step 3 until one cluster is left.
The linkage rule defines "distance between two groups of customers":
| Linkage | Distance between groups | Tends to produce |
|---|---|---|
| Single | Smallest distance between any two members | Long chains; one stray point can bridge two groups |
| Complete | Largest distance between any two members | Compact, similar-diameter groups |
| Average | Mean of all member-to-member distances | Between single and complete |
| Ward | The merge that adds the least to total within-group squared distance (the sum of squared distances from each customer to its group's centre, which is the quantity k-means minimises) | Round, similar-size groups, closest to k-means' objective |
Reading a dendrogram: a computed example
Eight customers described by annual spend (hundreds of dollars) and orders per month, in three obvious bands. The code prints the merge table (ids 0 to 7 are the customers; each merge creates the next id, 8 for step 1, 9 for step 2 and so on), the gaps between merge heights, and the cluster labels when the tree is cut into 2, 3 and 4. A second table compares linkages with k-means on two other datasets.
import numpy as np
from scipy.cluster.hierarchy import linkage, fcluster
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs, make_moons
from sklearn.metrics import adjusted_rand_score
# eight customers: (annual spend in hundreds of dollars, orders per month)
C = np.array([[1.0, 1.0], [1.5, 1.2], [1.2, 0.8], # customers 0-2: low spend, few orders
[5.0, 5.0], [5.4, 4.8], # customers 3-4: mid spend, mid orders
[9.0, 9.0], [9.5, 9.3], [8.8, 9.6]]) # customers 5-7: high spend, many orders
Z = linkage(C, method="ward") # Ward linkage on Euclidean distance
print("step merges clusters at height new size (ids 0-7 are customers; 8 and up are merged clusters)")
for i, (a, b, h, n) in enumerate(Z):
print(f"{i + 1:<5d} {int(a):>2d} + {int(b):<2d} {h:7.2f} {int(n)}")
heights = Z[:, 2]
print("gaps between consecutive merge heights:", np.round(np.diff(heights), 2).tolist())
for k in (2, 3, 4):
print(f"cut into {k} clusters:", fcluster(Z, t=k, criterion="maxclust").tolist())
# linkage choice matters: agreement with the true groups (ARI) on two datasets
Xb, yb = make_blobs(n_samples=300, centers=[[0, 0], [6, 0], [3, 5]], cluster_std=1.0, random_state=0)
Xm, ym = make_moons(n_samples=300, noise=0.05, random_state=0)
print("\nARI vs truth 3 blobs 2 crescents")
for method in ["single", "complete", "average", "ward"]:
row = []
for X, y, k in [(Xb, yb, 3), (Xm, ym, 2)]:
lab = fcluster(linkage(X, method=method), t=k, criterion="maxclust")
row.append(adjusted_rand_score(y, lab))
print(f"{method:<18s} {row[0]:.3f} {row[1]:.3f}")
rowk = [adjusted_rand_score(yb, KMeans(3, n_init=10, random_state=0).fit_predict(Xb)),
adjusted_rand_score(ym, KMeans(2, n_init=10, random_state=0).fit_predict(Xm))]
print(f"{'k-means':<18s} {rowk[0]:.3f} {rowk[1]:.3f}")
sizes = np.bincount(fcluster(linkage(Xb, method="single"), t=3, criterion="maxclust"))[1:]
print("single linkage, 3 blobs, cluster sizes at the 3-cluster cut:", sorted(sizes.tolist()))
Output:
step merges clusters at height new size (ids 0-7 are customers; 8 and up are merged clusters)
1 0 + 2 0.28 2
2 3 + 4 0.45 2
3 1 + 8 0.58 3
4 5 + 6 0.58 2
5 7 + 11 0.73 3
6 9 + 10 8.62 5
7 12 + 13 17.84 8
gaps between consecutive merge heights: [0.16, 0.13, 0.01, 0.15, 7.88, 9.22]
cut into 2 clusters: [2, 2, 2, 2, 2, 1, 1, 1]
cut into 3 clusters: [3, 3, 3, 2, 2, 1, 1, 1]
cut into 4 clusters: [4, 4, 4, 3, 3, 1, 1, 2]
ARI vs truth 3 blobs 2 crescents
single 0.000 1.000
complete 0.990 0.297
average 0.941 0.425
ward 0.941 0.241
k-means 1.000 0.241
single linkage, 3 blobs, cluster sizes at the 3-cluster cut: [1, 1, 298]
How to read it:
- The height is the cost of the merge: for Ward it is 2nAnB/(nA+nB) times the distance between the two group centres, so it grows with how far apart the groups are and with their sizes; the next section recomputes steps 1, 3 and 6 by hand. Merges at low heights join customers that are alike; a tall merge joins groups that are not.
- In the merge table, steps 1 to 5 all happen at heights of 0.73 or lower and put the eight customers into the three bands (steps 1 and 3 build the band of customers 0, 1 and 2; step 2 builds the band of 3 and 4; steps 4 and 5 build the band of 5, 6 and 7). Step 6 (height 8.62) joins the low and middle bands and step 7 (height 17.84) joins the high band to those.
- To pick segments, look for a tall gap and cut a horizontal line through it. The gaps are 7.88 (between steps 5 and 6) and 9.22 (between steps 6 and 7). A cut through the first gap gives three segments, a cut through the second gives two. Both are legitimate; the data say there are three tight bands and the bands themselves are not equally far apart, so the final number is a business decision (do we want the two lower bands treated alike?), informed by the gap, not dictated by it.
- Cutting the tree at any height gives clusters that are nested: the 3-cluster solution is the 2-cluster solution with one cluster split. k-means with k=3 and k=2 need not be nested.
Recomputing a merge height, a bar picture of the tree, and the k-means shortcut
Euclidean distance is the straight-line distance between two customers' feature vectors. Standardizing a feature means subtracting its mean and dividing by its standard deviation, so dollars and orders per month count on the same scale. For Ward linkage the merge height of groups A and B is nA+nB2nAnB×∥xˉA−xˉB∥, where nA and nB are the group sizes and xˉ is a group's centre. Its square divided by 2 is exactly how much the total within-group squared distance (the quantity k-means also minimises) rises when the two groups merge. Two single customers therefore merge at their plain distance. The code recomputes three merges of the table above by hand, draws the heights as bars (the dendrogram's vertical axis turned sideways), and tests the k-means shortcut on 3,000 customers.
import numpy as np
from scipy.cluster.hierarchy import linkage, fcluster
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from sklearn.metrics import adjusted_rand_score
C = np.array([[1.0, 1.0], [1.5, 1.2], [1.2, 0.8], [5.0, 5.0], [5.4, 4.8], [9.0, 9.0], [9.5, 9.3], [8.8, 9.6]])
Z = linkage(C, method="ward")
def ward_height(A, B):
"""Ward merge height: sqrt(2*nA*nB/(nA+nB)) times the straight-line distance between the two group centres."""
na, nb = len(A), len(B)
return np.sqrt(2 * na * nb / (na + nb)) * np.linalg.norm(A.mean(0) - B.mean(0))
print("step 1, customers 0 and 2: by hand", round(ward_height(C[[0]], C[[2]]), 2), " scipy", round(Z[0, 2], 2))
print("step 3, customer 1 joins {0,2}: by hand", round(ward_height(C[[0, 2]], C[[1]]), 2), " scipy", round(Z[2, 2], 2))
print("step 6, {0,1,2} and {3,4}: by hand", round(ward_height(C[[0, 1, 2]], C[[3, 4]]), 2), " scipy", round(Z[5, 2], 2))
inc = ward_height(C[[0]], C[[2]]) ** 2 / 2
sse = lambda P: ((P - P.mean(0)) ** 2).sum()
print("rise in total within-group squared distance at step 1:", round(sse(C[[0, 2]]) - sse(C[[0]]) - sse(C[[2]]), 3), " height squared / 2:", round(inc, 3))
print("\nmerge heights as bars (one # per 0.5 of height); a cut through either empty stretch is legitimate")
for i, h in enumerate(Z[:, 2]):
print(f"step {i + 1} {h:6.2f} " + "#" * int(round(h / 0.5)))
# tree over k-means centroids versus tree over every customer: 3000 customers, 3 true groups
X, y = make_blobs(n_samples=3000, centers=[[0, 0], [6, 0], [3, 5]], cluster_std=1.0, random_state=0)
km = KMeans(60, n_init=3, random_state=0).fit(X)
tree_c = fcluster(linkage(km.cluster_centers_, method="ward"), t=3, criterion="maxclust")
lab_centroid_tree = tree_c[km.labels_] # every customer takes the group of its centroid
lab_full_tree = fcluster(linkage(X, method="ward"), t=3, criterion="maxclust")
print("\nARI between the two 3-group answers:", round(adjusted_rand_score(lab_full_tree, lab_centroid_tree), 3))
print("ARI vs truth, tree over all 3000 customers:", round(adjusted_rand_score(y, lab_full_tree), 3), " tree over 60 centroids:", round(adjusted_rand_score(y, lab_centroid_tree), 3))
Output:
step 1, customers 0 and 2: by hand 0.28 scipy 0.28
step 3, customer 1 joins {0,2}: by hand 0.58 scipy 0.58
step 6, {0,1,2} and {3,4}: by hand 8.62 scipy 8.62
rise in total within-group squared distance at step 1: 0.04 height squared / 2: 0.04
merge heights as bars (one # per 0.5 of height); a cut through either empty stretch is legitimate
step 1 0.28 #
step 2 0.45 #
step 3 0.58 #
step 4 0.58 #
step 5 0.73 #
step 6 8.62 #################
step 7 17.84 ####################################
ARI between the two 3-group answers: 0.987
ARI vs truth, tree over all 3000 customers: 0.984 tree over 60 centroids: 0.977
Reading it:
- Step 1 joins customers 0 and 2 at (1.0,1.0) and (1.2,0.8): the distance is 0.22+0.22=0.28, and with two singletons the Ward factor is 1. Step 3 adds customer 1 to that pair: the factor is 2×2×1/3=1.155 and the centre gap is 0.5, giving 0.58. Step 6 joins a group of three and a group of two: 8.62. The hand and scipy values agree, and the squared-height check shows the cost of merging is the rise in within-group squared distance.
- The five short bars are the merges that build the three bands, and the vertical drop between the 0.73 bar and the 8.62 bar is empty space, so a horizontal cut anywhere inside it crosses three branches. A cut between 8.62 and 17.84 crosses two. Leaf order along the bottom of a real dendrogram carries no meaning.
- The shortcut works because a k-means centroid summarises a tight local group, so building the tree over 60 centroids instead of 3,000 customers keeps the large-scale nesting: the two 3-group answers agree (ARI 0.987) and both recover the truth about equally well (0.984 and 0.977). What it loses is detail inside a centroid: customers who share a centroid can never be separated by the tree, so choose a k-means count much larger than the number of segments you want, and note that plain Ward on centroids gives every centroid equal weight whatever its size.
Compared with k-means
| Hierarchical (agglomerative) | k-means | |
|---|---|---|
| Number of clusters | Chosen after seeing the tree | Fixed before the run |
| Output | A tree: all k at once, nested | One flat partition per k |
| Cost | Needs all pairwise distances: memory grows with n squared | Grows linearly with n per iteration |
| Determinism | Same result every run | Depends on the starting centres |
| Shape | Depends on linkage; single can follow chains | Round, similar-size groups |
| Early mistakes | A merge is never undone | Points are reassigned at every iteration |
| Distances | Any distance (except Ward, which assumes Euclidean) | Euclidean in practice |
Linkage changes the answer, and single linkage is the one that differs most. In the second table of the output, on three well-separated blobs, k-means scores ARI 1.000 (adjusted Rand index, agreement with the true groups; 1.0 perfect, about 0 chance), complete 0.990, and average and Ward 0.941. The gaps among those four are small and shuffle from one random draw of the blobs to the next, so treat them as equivalent here. Single linkage scores 0.000 in this draw: at the 3-cluster cut it peels off two lone points and leaves one cluster of 298. In most other draws it also splits off a lone point or two, though a few draws separate the blobs cleanly. On two crescents the order reverses: single scores 1.000 in every draw tried because it follows the chain, while k-means scores about 0.2 to 0.3 (0.241 here, as does Ward in this draw) and Ward, complete and average land well below 1 in every draw. No linkage is best everywhere.
What to recommend
Use hierarchical clustering on a few thousand customers when you want to see how segments nest and explain it to stakeholders, with Ward linkage on standardized features as the starting point. For a large table, run k-means with a few hundred clusters first and build the tree over the centroids, or cluster a random sample.
Common mistakes
- Cutting the tree at the height that gives a number the business likes, without looking at the gaps.
- Using Ward or average linkage on unscaled features.
- Using single linkage on noisy data and being surprised by one giant cluster.
- Reading the left-right order of leaves as similarity: only the merge heights carry meaning.
Your customer data mixes numeric and categorical fields. How do you handle the categorical variables before clustering, and what are the trade-offs?
Sample Answer
Clustering needs a notion of distance between customers, and a category such as "plan = Pro" has no natural distance to "plan = Basic". The standard fixes are to turn each category into 0/1 columns (one-hot encoding) and scale everything so no feature dominates, or to switch to a distance built for mixed data (Gower distance) or to an algorithm built for it (k-modes and k-prototypes, which replace the mean with the most common category). A medoid is the real customer that sits most centrally in a group; k-medoids uses medoids as centres instead of means, which works with any distance table. The default is to standardize the numbers, one-hot encode low-cardinality categories (those with few distinct levels), group rare levels, and check that the weight of the categorical block is a decision rather than an accident.
The options and their trade-offs
| Approach | What it does | Costs |
|---|---|---|
| Ordinal codes (1, 2, 3) | Maps ordered categories (plan tier, satisfaction band) to numbers; ordinal means the order is real | Wrong for unordered categories: it invents an order and a spacing |
| One-hot encoding | One 0/1 column per category level; a mismatch contributes a fixed distance | A variable with many levels adds many columns and gets a larger say; sparse (most entries are 0); high dimension |
| One-hot with weights | Scale each categorical block to the weight you intend | Weights are a judgment call and must be reviewed |
| Gower distance | Per feature: numeric difference divided by the range; categorical 0 if equal, 1 if not; then the average over features | Needs the full n by n distance table, so it suits thousands of customers, not millions; pairs with hierarchical clustering or k-medoids rather than k-means |
| k-modes / k-prototypes | Cluster centre is the most common category (and the mean for numbers in k-prototypes) | Needs a weight between the numeric and categorical parts; not in every toolkit; initialization-sensitive like k-means |
| Learned embeddings (rarely needed for a first segmentation) | Map each category to a short numeric vector learned from the data, for example a product-category level represented by 8 numbers that place similar categories near each other | More machinery; the vectors are hard to explain |
| Group rare levels and drop weak variables | Merge levels under (say) 2% of customers into "other"; remove variables unrelated to the purpose | A business judgment; always worth doing first |
What goes wrong: a computed example
300 customers fall into three true groups. Two numeric features: annual spend in dollars (large numbers, standard deviation 150 within a group) and tenure in years (small numbers). Three categorical features: plan (3 levels) and device (3 levels), both linked to the group, and country (10 levels), unrelated to the group. The code compares seven treatments by the adjusted Rand index (ARI; 1.0 means the segments match the true groups, about 0 chance level), averaged over 10 random datasets. The Gower function is also checked against a construction from SciPy distances (a mean of range-scaled absolute differences for numbers and mismatches for categories), and computed by hand for two customers. A reference line gives k-means on raw spend alone. The last two rows separate the distance from the clustering rule, because Gower has so far been scored together with average linkage (a hierarchical clustering rule that merges the two groups whose members are on average closest).
import numpy as np
from scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import pdist, squareform
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score
def make(seed, n_per=100):
rng = np.random.default_rng(seed)
y = np.repeat(np.arange(3), n_per)
spend = rng.normal(np.array([300.0, 500.0, 700.0])[y], 150.0) # dollars, large scale
tenure = rng.normal(np.array([2.0, 3.0, 4.0])[y], 1.0) # years, small scale
plan_p = np.array([[.7, .2, .1], [.2, .6, .2], [.1, .2, .7]]) # plan type depends on group
plan = np.array([rng.choice(3, p=plan_p[g]) for g in y])
device = np.array([rng.choice(3, p=plan_p[g][::-1]) for g in y]) # device type depends on group
country = rng.integers(0, 10, size=len(y)) # 10 levels, unrelated to group
return spend, tenure, plan, device, country, y
def z(a): return (a - a.mean()) / a.std()
def onehot(a): return np.eye(a.max() + 1)[a]
def gower(num, cat):
"""Mean over features of: |difference| / range for numbers, 0 or 1 (match or not) for categories."""
rng_ = num.max(0) - num.min(0)
dn = np.abs(num[:, None, :] - num[None, :, :]) / rng_
dc = (cat[:, None, :] != cat[None, :, :]).astype(float)
return np.concatenate([dn, dc], axis=2).mean(axis=2)
# check Gower against a SciPy construction on a small sample
spend, tenure, plan, device, country, y = make(0)
num = np.c_[spend, tenure]; cat = np.c_[plan, device, country]
G = gower(num[:50], cat[:50])
rngs = num[:50].max(0) - num[:50].min(0)
G_ref = squareform((pdist(num[:50] / rngs, "cityblock") + pdist(cat[:50], "hamming") * 3) / 5)
print("Gower matches the SciPy construction:", bool(np.allclose(G, G_ref)))
# three customers by hand: spend range 600 and tenure range 4 are assumed for this small illustration
a = dict(spend=300, tenure=2.0, plan="Basic", device="phone", country="DE")
b = dict(spend=500, tenure=3.0, plan="Basic", device="tablet", country="FR")
parts = [abs(a["spend"] - b["spend"]) / 600, abs(a["tenure"] - b["tenure"]) / 4,
float(a["plan"] != b["plan"]), float(a["device"] != b["device"]), float(a["country"] != b["country"])]
print("Gower terms for customers a and b:", np.round(parts, 3).tolist(), "-> distance", round(float(np.mean(parts)), 3))
def kmeans_ari(F, y, seed):
return adjusted_rand_score(y, KMeans(3, n_init=10, random_state=seed).fit_predict(F))
names = ["numbers only, standardized", "one-hot + raw dollars (no scaling)", "one-hot + standardized numbers",
"as above, one-hot scaled by 1/sqrt(2)", "Gower + average linkage",
"one-hot + standardized, average link.", "Gower without country + average link."]
res = {n: [] for n in names}
ref_spend = []
for seed in range(10):
spend, tenure, plan, device, country, y = make(seed)
num = np.c_[spend, tenure]; cat = np.c_[plan, device, country]
cat_oh = np.hstack([onehot(plan), onehot(device), onehot(country)])
zn = np.c_[z(spend), z(tenure)]
res[names[0]].append(kmeans_ari(zn, y, seed))
res[names[1]].append(kmeans_ari(np.c_[spend, tenure, cat_oh], y, seed))
res[names[2]].append(kmeans_ari(np.c_[zn, cat_oh], y, seed))
# a mismatch on a one-hot variable moves two columns by 1, i.e. squared distance 2; dividing by sqrt(2) makes it 1
res[names[3]].append(kmeans_ari(np.c_[zn, cat_oh / np.sqrt(2)], y, seed))
D = squareform(gower(num, cat), checks=False)
res[names[4]].append(adjusted_rand_score(y, fcluster(linkage(D, "average"), 3, "maxclust")))
# same linkage, Euclidean distance on the one-hot + standardized table: isolates the effect of the linkage rule
res[names[5]].append(adjusted_rand_score(y, fcluster(linkage(np.c_[zn, cat_oh], "average"), 3, "maxclust")))
# Gower again with the unrelated country column removed: isolates the effect of the useless feature
D2 = squareform(gower(num, cat[:, :2]), checks=False)
res[names[6]].append(adjusted_rand_score(y, fcluster(linkage(D2, "average"), 3, "maxclust")))
ref_spend.append(kmeans_ari(spend[:, None], y, seed)) # reference: raw spend alone, nothing else
print("\nmean ARI over 10 datasets (3 true groups, 300 customers each time)")
for n in names:
print(f" {n:40s} {np.mean(res[n]):.3f}")
print(f" (reference) k-means on raw spend alone {np.mean(ref_spend):.3f}")
# Six other sets of ten datasets (seeds 10 to 69): do the Gower and country comparisons hold up?
def set_means(seeds):
out = [[], [], [], []]
for seed in seeds:
spend, tenure, plan, device, country, y = make(seed)
num = np.c_[spend, tenure]; cat = np.c_[plan, device, country]
F = np.c_[z(spend), z(tenure), onehot(plan), onehot(device), onehot(country)]
out[0].append(adjusted_rand_score(y, fcluster(linkage(squareform(gower(num, cat), checks=False), "average"), 3, "maxclust")))
out[1].append(adjusted_rand_score(y, fcluster(linkage(squareform(gower(num, cat[:, :2]), checks=False), "average"), 3, "maxclust")))
out[2].append(adjusted_rand_score(y, fcluster(linkage(F, "average"), 3, "maxclust")))
out[3].append(kmeans_ari(F, y, seed))
return [np.mean(v) for v in out]
print("\nother sets of ten datasets: Gower | Gower without country | one-hot + std, average link. | one-hot + std, k-means")
for lo in range(10, 70, 10):
m = set_means(range(lo, lo + 10))
print(f" seeds {lo}-{lo + 9}: {m[0]:.3f} {m[1]:.3f} {m[2]:.3f} {m[3]:.3f}")
Output:
Gower matches the SciPy construction: True
Gower terms for customers a and b: [0.333, 0.25, 0.0, 1.0, 1.0] -> distance 0.517
mean ARI over 10 datasets (3 true groups, 300 customers each time)
numbers only, standardized 0.356
one-hot + raw dollars (no scaling) 0.268
one-hot + standardized numbers 0.534
as above, one-hot scaled by 1/sqrt(2) 0.438
Gower + average linkage 0.287
one-hot + standardized, average link. 0.347
Gower without country + average link. 0.301
(reference) k-means on raw spend alone 0.269
other sets of ten datasets: Gower | Gower without country | one-hot + std, average link. | one-hot + std, k-means
seeds 10-19: 0.262 0.265 0.229 0.509
seeds 20-29: 0.282 0.272 0.333 0.527
seeds 30-39: 0.268 0.307 0.342 0.524
seeds 40-49: 0.240 0.262 0.304 0.515
seeds 50-59: 0.265 0.276 0.342 0.520
seeds 60-69: 0.280 0.284 0.324 0.535
What the run shows, for this synthetic data. First the hand case: customers a and b differ by 200 dollars of spend on an assumed range of 600 (0.333), by 1 year of tenure on a range of 4 (0.25), have the same plan (0), and differ in device and country (1 and 1); the mean of the five terms is 0.517.
- Skipping scaling is costly: with raw dollars next to 0/1 columns, spend swamps everything, so neither tenure nor the categories vote (0.268, essentially the 0.269 of clustering on raw spend alone, in the reference line), worse than using both numbers standardized (0.356).
- Standardizing the numbers and adding the one-hot columns uses the categories that carry signal and gives the best score (0.534).
- Down-weighting the categories by 1/sqrt(2) (so a mismatch costs 1 instead of 2 in squared distance) lowers it to 0.438, since here two of the three categorical variables are informative. The weight is a choice, and the right one depends on whether categories deserve as much say as numbers.
- Gower distance with average-linkage hierarchical clustering scores 0.287 here, below even the numbers alone (0.356). The last two rows say why. The same average linkage on the one-hot and standardized table scores 0.347, against 0.534 for k-means on that same table, so most of the loss comes from the linkage rule on groups that overlap this much, not from Gower (that average-linkage row moves between sets of ten datasets, from 0.229 to 0.342 in the last block, but stays far below k-means on the same table, 0.509 to 0.535). Removing the unrelated country column moves Gower from 0.287 to 0.301. The last block of the output repeats the comparison on six other sets of ten datasets: Gower scores 0.240 to 0.282 with the country column and 0.262 to 0.307 without it, so dropping the column helped in six of the seven sets of ten (the shipped one and the six printed) but by at most 0.039, against a gap of about 0.25 to k-means on the one-hot table. The useless column costs Gower a little; it is not why Gower scores low. Under the same average linkage the one-hot table scored above Gower in six of the seven sets (0.347 against 0.287 in the shipped set), so this toy does not show Gower beating one-hot encoding; it shows that Gower is a way to measure distance on mixed data with no encoding step, and that the clustering rule on top still has to suit the data. Pair Gower with a rule you have checked (medoid clustering, or hierarchical clustering compared across linkages).
- A score of 0.534 is the best here and is only a reference for this generator. Real customers have no true groups to score against, so on real data judge segments by stability on resamples and by whether the profiles are usable.
Because the data and the ARI means come from one fixed generator, they illustrate the pitfalls rather than rank the methods for real customers.
Practical guidance
- Encode by type. Ordered categories become ordinal numbers. Unordered categories become one-hot columns (binary yes/no flags stay as single 0/1 columns).
- Control cardinality (the number of distinct levels in a column). Merge rare levels. A 50-level "city" column becomes a "region" column with a handful of levels.
- Scale, then weight on purpose. Standardize numeric columns (subtract the mean, divide by the standard deviation). Decide how much say the categorical block should have, and test the segmentation at two or three weights to see whether the segments change.
- Drop variables that are not part of the purpose (an ID, a country that does not matter for the campaign).
- Validate as usual: stability on resamples, silhouette (how much closer a customer is to its own group than to the next nearest, computed in whatever distance you chose), and profiles that a stakeholder can read (for categories, the share of each level in the cluster, not a mean).
Commit and flip
Start with standardized numbers plus one-hot encoding of low-cardinality categories and k-means or hierarchical clustering. Switch to Gower with hierarchical or medoid clustering when categories dominate the table and the data fits an n by n distance table. Use k-prototypes when you need k-means-style scale and have mostly categorical fields. Check the weight of the categorical block in every case.
Common mistakes
- Encoding unordered categories as 1, 2, 3 and clustering on the codes.
- One-hot encoding a 40-level column and letting it dominate the distance.
- Reporting the cluster "mean" of a one-hot column (it is a share, and must be read as one).
- Scaling the numbers but not thinking about the weight of the categories.
Two teams cluster the same customer base, one on purchase history and one on app engagement, and get completely different segments. Both look fine on internal quality measures. What do you do?
Sample Answer
Direct answer
Different segments are not a contradiction. Each team clustered a different slice of customer behaviour, so each found structure that exists in that slice. Internal quality measures judge a clustering using only the data it was fit on: the silhouette score (for each customer, how much closer they are to their own cluster than to the next nearest one, from -1 to 1) and inertia (the sum of squared distances from every point to its cluster centre, lower is tighter) only say whether the clusters are compact within the features given; they cannot say which features matter. External measures compare the clusters with something outside the clustering, such as churn. So the way out is to judge both segmentations against something outside the clustering: an outcome the business cares about, and the stability and usefulness of the segments. The likely end state is to keep both as two lenses, or to combine the feature sets, depending on what the evidence shows.
What to check, in order
- Quantify the disagreement. Cross-tabulate the two labelings (a table whose rows are one team's segments, columns the other team's, and each cell the number of customers in both) and compute the adjusted Rand index (ARI), a score of agreement between two partitions that is about 0 for independent labelings and 1 for identical ones, regardless of how the clusters are numbered.
- Validate externally. Does each segmentation separate something that matters (churn, revenue, response to an offer)? A simple measure is the share of variance in the outcome explained by segment means (called eta squared, or R2 here): predict every customer's outcome by the average of their segment and see how much of the customer-to-customer variation that removes. 0 means the segments tell you nothing, 1 means they predict perfectly.
- Check the features. Is one set dominated by a few scale-heavy features? Were features scaled, and were skewed counts transformed? Different preprocessing can create different "structure".
- Check stability of each segmentation under resampling.
- Test a combined segmentation on the union of the features, or cross the two, and see whether it explains more of the outcome.
- Decide by use. Marketing offers for buyers might use the purchase segments, lifecycle messages the engagement segments.
Worked example
3,000 customers with two independent hidden traits: spend level (3 levels) and engagement style (3 styles). The purchase team sees two features driven by spend, the app team two features driven by style. Churn depends on both traits. Each team clusters with k-means (k=3, standardized features).
import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score, silhouette_score
from sklearn.preprocessing import StandardScaler
rng = np.random.default_rng(0)
n = 3000
# Two independent hidden traits: spend level (3 levels) and engagement style (3 styles)
spend = rng.integers(0, 3, n)
style = rng.integers(0, 3, n)
purchase = np.c_[spend * 3.0, spend * 2.0].astype(float) + rng.normal(0, 0.8, (n, 2)) # 2 purchase features
app = np.c_[style * 3.0, (style == 2) * 4.0].astype(float) + rng.normal(0, 0.8, (n, 2)) # 2 app features
# Outcome (e.g. churn within 90 days) depends on both traits
logit = 1.0 - 0.8 * spend + 0.9 * (style == 0) - 0.5 * (style == 2)
p_churn = 1 / (1 + np.exp(-logit))
churn = rng.random(n) < p_churn
def cluster(F, k=3):
Z = StandardScaler().fit_transform(F)
km = KMeans(k, n_init=10, random_state=0).fit(Z)
return km.labels_, silhouette_score(Z, km.labels_)
lab_p, sil_p = cluster(purchase)
lab_a, sil_a = cluster(app)
lab_j, sil_j = cluster(np.c_[purchase, app], k=9)
print("silhouette purchase team: %.2f app team: %.2f" % (sil_p, sil_a))
print("adjusted Rand index between the two teams' segments: %.3f" % adjusted_rand_score(lab_p, lab_a))
def eta2(lab, y):
"""Share of variance in the 0/1 outcome explained by the segment means."""
means = np.array([y[lab == k].mean() for k in np.unique(lab)])
fitted = means[np.searchsorted(np.unique(lab), lab)]
return 1 - ((y - fitted) ** 2).sum() / ((y - y.mean()) ** 2).sum()
y = churn.astype(float)
print("churn explained by segment means (R^2):")
print(" purchase segments : %.3f" % eta2(lab_p, y))
print(" app segments : %.3f" % eta2(lab_a, y))
print(" both (9 segments) : %.3f" % eta2(lab_j, y))
both = lab_p * 3 + lab_a # crossing the two teams' labels gives up to 9 cells
print(" cross-tab cells : %.3f" % eta2(both, y))
ceiling = 1 - np.mean(p_churn * (1 - p_churn)) / np.var(y) # best any segmentation could do: knowing each customer's true churn probability
print(" ceiling (true probabilities known): %.3f" % ceiling)
print("ARI, 9-segment solution vs crossed labels: %.3f" % adjusted_rand_score(lab_j, both))
print("churn rate by purchase segment:", [round(float(y[lab_p == k].mean()), 2) for k in range(3)])
print("churn rate by app segment :", [round(float(y[lab_a == k].mean()), 2) for k in range(3)])
Output:
silhouette purchase team: 0.57 app team: 0.58
adjusted Rand index between the two teams' segments: -0.000
churn explained by segment means (R^2):
purchase segments : 0.075
app segments : 0.063
both (9 segments) : 0.142
cross-tab cells : 0.142
ceiling (true probabilities known): 0.142
ARI, 9-segment solution vs crossed labels: 0.992
churn rate by purchase segment: [0.59, 0.74, 0.4]
churn rate by app segment : [0.55, 0.43, 0.73]
Reading it: both teams look good internally (silhouette 0.57 and 0.58). Their segments are unrelated to each other (ARI is -0.000, effectively zero). Neither is wrong: purchase segments explain 7.5% of the variation in churn, the app segments 6.3%, and a nine-segment solution on all four features explains 14.2%, about twice as much. The churn rates by segment also differ in both views (0.40 to 0.74 across purchase segments, 0.43 to 0.73 across app segments). The segment numbers within each view are arbitrary labels. Judge those numbers against the ceiling. A 0/1 outcome is noisy even when you know every customer's true churn probability, so no segmentation can reach an R2 near 1; here the best possible is 0.142 (the "ceiling" line, computed from the probabilities that generated the churn). So 0.075 and 0.063 are 53% and 44% of what is available, and the nine-segment figure of 0.142 reaches it on this draw (a segmentation fitted to a finite sample can land a little under or over the ceiling). The joint nine-segment solution explains as much (0.142 for both) as crossing the two teams' labels, and the ARI between those two labelings is 0.992, so the check shows that the joint solution recovered essentially the same nine spend-by-style cells.
Recommendation
Do not pick a winner on internal scores. Run the outcome test; if each view explains a different part of the outcome, either keep both and say which decision each is for, or build one joint segmentation (with block weighting, meaning each feature set is scaled down so that the set with more columns does not dominate the distance, if one set has many more features) and check whether it is still small enough to act on. Ask also what the segmentation is for: a segmentation to predict churn should be judged by that, a segmentation for creative messaging by whether the groups are describable.
Pitfalls
- Treating high silhouette as proof of meaning.
- Merging the feature sets without scaling, so the set with more columns dominates the distance.
- Crossing the two segmentations naively: k1×k2 cells can be too many to act on, and some will be tiny.
Unlock Full Question Bank
Get access to all 17 Predictive Modeling and Machine Learning Fundamentals interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.