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.
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.
You segmented customers into four groups and want to target each with a different campaign. How do you test whether segment-based targeting actually improves conversion?
Sample Answer
Run a randomized experiment inside each segment. Inside every segment, randomly split customers into two arms: one receives the campaign built for that segment (tailored), the other receives the single best-guess campaign for everyone (generic). Targeting "works" only if tailored beats generic by enough to pay for the extra effort. The gain is the lift: tailored conversion rate minus generic conversion rate, in percentage points (pt), so 5.0% rising to 5.5% is a lift of 0.5 pt. Only a random split can show it, because conversion rates already differ between segments for reasons unrelated to the campaign.
Why not just compare conversion rates
| Tempting comparison | Why it misleads |
|---|---|
| Conversion of segment A versus segment B | The segments differ in baseline behaviour. That is why they are segments. It says nothing about the campaign. |
| Conversion this month (tailored) versus last month (generic) | Season, pricing, product changes and the novelty of any new message move conversion with no help from targeting. |
| Customers who got a tailored campaign versus customers who did not | Someone chose who got it. Chosen customers differ from the rest in ways that also predict conversion (selection bias). |
Randomization removes all three because, on average, the two arms differ only in the campaign.
The design
- Assign segments first, using only data from before the test. Never re-cluster using post-campaign behaviour: that bakes the outcome into the segment label.
- Randomize customers within each segment into: tailored, generic, and (if the question includes "does any campaign beat nothing") a small no-contact holdout. The same customer stays in the same arm for the whole test. Randomizing within segment (a stratified design) guarantees every segment has both arms. The no-contact holdout is a group deliberately sent nothing, so you can see what happens without any campaign.
- Pre-register the primary metric (write down, before the test starts, the metric, the counting window, the sample size and the decision rule, so they cannot drift to fit the result). Conversion per customer assigned (not per customer who opened the email, which depends on the arm), counted in a fixed window after the send. If tailored offers cost more, use incremental profit per customer: the extra conversions tailoring brings, times the margin on each, minus the extra campaign cost. Guardrails (metrics that must not get worse): unsubscribe and complaint rates.
- Check the randomization. Arms should be the planned size (a mismatch is a sample ratio mismatch, a sign of a bug such as a campaign that fails to send to some customers) and balanced on pre-period conversion and spend.
- Analyse as assigned. Compare conversion rates between arms for each segment with a two-proportion z-test and an interval for the difference. The z-test divides the gap between the two rates by its standard error (the amount the gap would wobble from sampling alone); a large ratio means the gap is unlikely to be chance, and the p-value is the chance of a gap at least this big if the two campaigns really did the same. The interval for the difference is the range of lifts that the data are consistent with. Report one pooled estimate (a single lift for all segments combined) as the headline, and per-segment estimates as the detail.
- Decide by the lift, not by significance alone. The decision rule is stated before the test: roll out tailoring in a segment when the lift's interval is above the break-even lift (the lift at which the extra profit just covers the extra cost: extra cost per customer divided by margin per conversion), and keep a small permanent holdout so the effect can be re-measured. For example, if a conversion is worth 40 in margin and the tailored campaign costs 0.30 more per customer, the break-even lift is 0.30 / 40 = 0.0075, or 0.75 pt (the program prints this). In the simulation below the true lifts of 1 pt and 2 pt in segments 3 and 4 clear that bar and the 0 pt of segments 1 and 2 do not.
To ask whether the segmentation itself matters (as opposed to tailored creative), add a mismatched arm in which customers get another segment's campaign. Tailored beating mismatched shows that the segments are different in a way the campaigns exploit.
Sample size: the part that decides whether the test can answer
The code computes the customers needed per arm for a two-sided two-proportion test at 0.8 power (the chance of detecting a lift of the stated size if it exists). The formula is
n=lift2(z1−α/2+zpower)2[p0(1−p0)+p1(1−p1)]where p0 is the generic conversion rate, p1=p0+lift, and the two z values are the standard-normal cut-offs for the false-alarm level and the power. Why it has this shape: the gap between two observed rates wobbles by a standard error of [p0(1−p0)+p1(1−p1)]/n (each rate's variance, added, since the rates are not pooled), and to see a true lift above that wobble with the chosen error rates the lift must be at least (z1−α/2+zpower) standard errors. Solving for n gives the formula: more noise (variance) needs more customers, and halving the lift needs four times as many. The first printed lines trace the headline figure by hand: 1.9600 and 0.8416 give (1.9600+0.8416)2=7.8489; the variance term is 0.05×0.95+0.055×0.945=0.099475; the squared lift is 0.0052=0.000025; and 7.8489×0.099475/0.000025=31230.7, rounded up to 31,231. Then it simulates a programme: four segments of 10,000 customers per arm, where tailoring truly helps in segments 3 and 4 and not in segments 1 and 2. "Tailored%" and "generic%" in the printed table are that one trial's observed conversion; the "true lift" labels in the last block are the simulation's settings. It uses a Bonferroni threshold for the segment tests (the usual 0.05 divided by the number of tests, here 4, so 0.0125) so that testing four segments keeps the chance of any false win across the four at or below 5%: each of the four tests gets a quarter of the 5% false-alarm budget (Bonferroni).
import numpy as np
from scipy.stats import norm
def n_per_arm(p0, lift, alpha=0.05, power=0.8):
"""Customers per arm for a two-sided two-proportion z-test (unpooled variance)."""
p1 = p0 + lift
za, zb = norm.ppf(1 - alpha / 2), norm.ppf(power)
var = p0 * (1 - p0) + p1 * (1 - p1)
return int(np.ceil((za + zb) ** 2 * var / lift ** 2))
# the 31,231 by hand: baseline 5%, lift 0.5 pt, alpha 0.05, power 0.8
p0_, lift_ = 0.05, 0.005
za_, zb_ = norm.ppf(0.975), norm.ppf(0.8)
var_ = p0_ * (1 - p0_) + (p0_ + lift_) * (1 - p0_ - lift_)
print("z(0.975)=%.4f z(0.80)=%.4f (za+zb)^2=%.4f variance terms=%.6f lift^2=%.6f" % (za_, zb_, (za_ + zb_) ** 2, var_, lift_ ** 2))
print("n per arm = %.4f x %.6f / %.6f = %.1f -> rounded up %d" % ((za_ + zb_) ** 2, var_, lift_ ** 2, (za_ + zb_) ** 2 * var_ / lift_ ** 2, n_per_arm(0.05, 0.005)))
# break-even lift: margin per conversion 40, extra cost of the tailored campaign 0.30 per customer
margin, extra_cost = 40.0, 0.30
print("break-even lift = %.2f / %.2f = %.4f = %.2f percentage points\n" % (extra_cost, margin, extra_cost / margin, 100 * extra_cost / margin))
print("customers per arm, baseline 5%, power 0.8, two-sided")
for lift in [0.005, 0.01, 0.02]:
print(f" lift +{lift*100:.1f} pt: alpha 0.05 -> {n_per_arm(0.05, lift):6d} alpha 0.0125 (4 segment tests) -> {n_per_arm(0.05, lift, alpha=0.0125):6d}")
print("segment 3 converts at 8%%, lift +1.0 pt, alpha 0.0125 -> %d per arm" % n_per_arm(0.08, 0.01, alpha=0.0125))
# Simulated programme: 4 segments, 20,000 customers each, randomized 50/50 tailored vs generic.
base = np.array([0.03, 0.05, 0.08, 0.04]) # conversion under the generic campaign
lift = np.array([0.000, 0.000, 0.010, 0.020]) # true gain from tailoring (segment 3 and 4 only)
n = 10_000 # per arm per segment
def one_trial(rng):
g = rng.binomial(n, base) # conversions, generic arm
t = rng.binomial(n, base + lift) # conversions, tailored arm
pg, pt = g / n, t / n
diff = pt - pg
se = np.sqrt(pt * (1 - pt) / n + pg * (1 - pg) / n)
p = 2 * (1 - norm.cdf(np.abs(diff / se)))
pooled = pt.mean() - pg.mean() # equal-size segments: simple mean is the weighted mean
se_pool = np.sqrt(np.sum(se ** 2)) / 4
p_pool = 2 * (1 - norm.cdf(abs(pooled / se_pool)))
return diff, p, pooled, p_pool, pg, pt
rng = np.random.default_rng(0)
diff, p, pooled, p_pool, pg, pt = one_trial(rng)
print("\nOne trial: segment generic% tailored% diff(pt) p-value")
for s in range(4):
print(f" {s+1} {100*pg[s]:5.2f} {100*pt[s]:5.2f} {100*diff[s]:5.2f} {p[s]:.4f}")
print(f"pooled diff {100*pooled:.2f} pt, p = {p_pool:.4f}")
# Many trials: how often does each test reject?
R = 20000
rej = np.zeros(4); rej_pool = 0
for _ in range(R):
d, pp, po, ppool, _, _ = one_trial(rng)
rej += pp < 0.0125
rej_pool += ppool < 0.05
print(f"\nAcross {R} repeated trials, share of trials where the test rejects 'no difference':")
for s in range(4):
print(f" segment {s+1} (true lift {100*lift[s]:.1f} pt), alpha 0.0125: {rej[s]/R:.3f}")
print(f" pooled across segments (true mean lift {100*lift.mean():.2f} pt), alpha 0.05: {rej_pool/R:.3f}")
Output:
z(0.975)=1.9600 z(0.80)=0.8416 (za+zb)^2=7.8489 variance terms=0.099475 lift^2=0.000025
n per arm = 7.8489 x 0.099475 / 0.000025 = 31230.7 -> rounded up 31231
break-even lift = 0.30 / 40.00 = 0.0075 = 0.75 percentage points
customers per arm, baseline 5%, power 0.8, two-sided
lift +0.5 pt: alpha 0.05 -> 31231 alpha 0.0125 (4 segment tests) -> 44371
lift +1.0 pt: alpha 0.05 -> 8155 alpha 0.0125 (4 segment tests) -> 11586
lift +2.0 pt: alpha 0.05 -> 2210 alpha 0.0125 (4 segment tests) -> 3140
segment 3 converts at 8%, lift +1.0 pt, alpha 0.0125 -> 17340 per arm
One trial: segment generic% tailored% diff(pt) p-value
1 3.22 3.05 -0.17 0.4903
2 5.02 5.14 0.12 0.6992
3 8.06 8.46 0.40 0.3042
4 3.95 6.16 2.21 0.0000
pooled diff 0.64 pt, p = 0.0001
Across 20000 repeated trials, share of trials where the test rejects 'no difference':
segment 1 (true lift 0.0 pt), alpha 0.0125: 0.012
segment 2 (true lift 0.0 pt), alpha 0.0125: 0.012
segment 3 (true lift 1.0 pt), alpha 0.0125: 0.515
segment 4 (true lift 2.0 pt), alpha 0.0125: 1.000
pooled across segments (true mean lift 0.75 pt), alpha 0.05: 0.997
Reading the run:
- Detecting a 0.5 percentage-point lift on a 5% baseline needs 31,231 customers per arm in one comparison, and 44,371 per arm at the stricter segment threshold. A 2-point lift needs 2,210 and 3,140.
- In the single trial, the pooled result is clear (0.64 pt, p = 0.0001) and segment 4 is clear (+2.21 pt), while segment 3, whose true lift is 1 point, shows +0.40 pt with p = 0.3042: no conclusion.
- Across 20,000 repeated trials, the pooled test rejects "no gain" in 0.997 of them, segment 4 in 1.000, but segment 3 in only 0.515: at 10,000 per arm a true 1-point gain in a segment is detected about half the time. Segments 1 and 2, with no true lift, are flagged in 0.012 of trials each, close to the 0.0125 each segment test is allowed, which is what the Bonferroni threshold is for. So per-segment results from a test sized for the pooled effect are weak evidence, and a "no significant lift in segment 3" is not evidence of no lift.
- What to do when a segment is under-powered, as segment 3 is here: size that segment's arms for the smallest lift worth acting on before launching (segment 3 converts at 8% under the generic campaign, and for a 1.0 pt lift at the stricter threshold it needs 17,340 customers per arm, against the 10,000 used here; the table above is for a 5% baseline and shows 11,586, so each segment needs its own baseline in the formula); or merge segments that should respond alike; or call the segment's result exploratory and re-test it. Do not read "not significant" as "no effect".
- The pooled estimate answers "is targeting better overall", and has more power than any single segment. It can hide the pattern that two segments gain and two do not, which is why the per-segment intervals are reported.
(The simulation assumes independent customers, fixed true conversion rates and equal arms of 10,000 per segment. Real data adds repeat purchasers, seasonality and arms that differ in deliverability.)
Pitfalls
- Testing many segments and subgroups, then reporting the one that won.
- Ending the test early when the interim result looks good (it inflates false positives; fix the sample size in advance or use a sequential method, a procedure with a planned schedule of early looks and corrected thresholds).
- Letting a segment's customers appear in two campaigns at once, or letting the segments leak across arms (a customer who receives a household member's email).
- Measuring clicks rather than conversions, or conversion over a window shorter than the buying cycle.
- Treating a significant lift as a business win without the cost of producing four creatives and four pipelines.
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.
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.
Why does feature scaling matter before running k-means? Give a concrete example of an unscaled feature distorting the clusters and how you would choose a scaling approach.
Sample Answer
Direct answer
K-means groups points by Euclidean distance, and Euclidean distance adds up squared differences across features in their raw units. A feature measured in thousands (annual spend in dollars) therefore contributes far more to every distance than one measured in single digits (visits per month), even when the small-unit feature is the one that separates real groups. Scaling puts features on comparable footing so each gets a say. The scaling choice depends on each feature's distribution: standardise (subtract the mean, divide by the standard deviation) for roughly symmetric features, use a robust or log scaling for outliers and heavy skew, and weight features deliberately if some matter more.
Why units decide the clusters
The distance between two points a and b with features indexed by j is
d(a,b)=j∑(aj−bj)2.Each feature's gap enters squared and in its own units. If one feature ranges over thousands, its squared gaps are millions while another feature's are tens, so the second feature barely influences which centroid is nearest.
Concrete example with numbers
The program builds 200 customers in two planted groups. Light users make about 4 visits a month and heavy users about 12. Annual spend is drawn from the same distribution for both groups (mean 8000, standard deviation 2500), so spend carries no group information. It prints three distances, then fits 2-cluster k-means under four treatments and reports the adjusted Rand index (ARI, 1 for a perfect match with the planted groups, about 0 for a random labelling), the standard deviation of each column after scaling, and the inertia (total squared distance of points to their group's mean, which k-means tries to make small) of the planted split against the split k-means found. A last line shows what standardising does to a rare 0/1 column.
import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
rng = np.random.default_rng(0)
n = 200
# Two planted groups of customers: light users (about 4 visits/month) and heavy users (about 12).
group = np.repeat([0, 1], n // 2)
visits = np.where(group == 0, rng.normal(4, 1.0, n), rng.normal(12, 1.5, n))
# Annual spend (dollars) varies a lot within BOTH groups and carries no group information.
spend = rng.normal(8000, 2500, n)
X = np.column_stack([visits, spend])
# Two customers who are alike on spend but in different groups, and the reverse
a, b, c = X[0], X[n - 1], X[1]
print("customer a (light):", np.round(a, 1), " b (heavy):", np.round(b, 1), " c (light):", np.round(c, 1))
print("distance a-b (different groups):", round(float(np.linalg.norm(a - b)), 1))
print("distance a-c (same group) :", round(float(np.linalg.norm(a - c)), 1))
def inertia(Z, lab):
return sum(((Z[lab == g] - Z[lab == g].mean(axis=0)) ** 2).sum() for g in np.unique(lab))
print("\nscaler ARI vs planted groups std of [visits, spend] after scaling inertia: planted split vs split found")
for name, scaler in [("none", None), ("StandardScaler", StandardScaler()),
("MinMaxScaler", MinMaxScaler()), ("RobustScaler", RobustScaler())]:
Z = X if scaler is None else scaler.fit_transform(X)
lab = KMeans(2, n_init=10, random_state=0).fit_predict(Z)
print(f"{name:15s} {adjusted_rand_score(group, lab):6.3f} {str(np.round(Z.std(axis=0), 3).tolist()):22s}"
f" {inertia(Z, group):7.1f} vs {inertia(Z, lab):7.1f}")
# A rare 0/1 column (1% of rows are 1) after standardising
flag = np.zeros(n); flag[:2] = 1
print("\nrare flag, std", round(float(flag.std()), 4), "-> value of a 1 after standardising:",
round(float(StandardScaler().fit_transform(flag[:, None])[0, 0]), 2))
Output:
customer a (light): [4.1000e+00 7.0989e+03] b (heavy): [ 12.7 8127.6] c (light): [3.9000e+00 9.4588e+03]
distance a-b (different groups): 1028.7
distance a-c (same group) : 2359.9
scaler ARI vs planted groups std of [visits, spend] after scaling inertia: planted split vs split found
none -0.005 [4.107, 2499.132] 1249072247.8 vs 464502941.5
StandardScaler 0.980 [1.0, 1.0] 216.9 vs 215.3
MinMaxScaler 0.980 [0.307, 0.166] 7.1 vs 7.0
RobustScaler -0.005 [0.521, 0.69] 99.9 vs 89.8
rare flag, std 0.0995 -> value of a 1 after standardising: 9.95
Customer a (4.1 visits, 7099 dollars) is a light user. Customer b (12.7 visits, 8128 dollars) is a heavy user, a completely different kind of customer, yet its distance to a is 1028.7. Customer c is another light user, the same kind as a, and its distance to a is 2359.9, more than twice as large. Almost the entire distance is dollars: the 8.6 visit gap barely registers. Unscaled k-means splits the customers by spend, which is noise, and the ARI is -0.005. After standardising, both columns have standard deviation 1.0 and the split follows visits, with ARI 0.980. Min-max scaling (map each column to the range 0 to 1) also recovers it, at 0.980.
RobustScaler (subtract the median, divide by the interquartile range, the 25th-to-75th-percentile spread) fails here at -0.005, and the last two columns show why. k-means does not look for the planted groups; it looks for the split with the smallest inertia. After robust scaling the planted split has inertia 99.9 but the split k-means found (by spend) has 89.8, so by its own objective the spend split is the better one. The reason is the scaled spreads: visits has spread 0.521 and spend 0.69, so spend still has the larger spread. The visits column holds two groups, so its middle half runs from one group to the other and its interquartile range is wide, which shrinks the scaled column. After standardising or min-max scaling the planted split wins instead (216.9 against 215.3 for standardising is a near tie because the split k-means found is the planted one with a few customers moved, and ARI 0.980 confirms that). Robust scaling is designed for outliers, not for a feature whose spread comes from the very groups you want to find, so no scaler is automatically safe; check the post-scaling spreads.
The last line of the output is the rare-column case from the table below. A 0/1 column in which 1% of rows are 1 has standard deviation 0.01×0.99≈0.0995, so after standardising each 1 becomes (1−0.01)/0.0995≈9.95, about ten standard deviations from the mean. One such customer sits ten units from everyone else on that column, while a typical continuous column puts most customers within two or three units, so the rare flag can pull a cluster toward itself.
How to choose a scaling approach
| Situation | Scaling | Reason |
|---|---|---|
| Roughly symmetric, few outliers | Standardisation (z-score) | Equal unit variance per feature |
| Bounded features, no extreme values | Min-max | Keeps a fixed 0 to 1 range |
| Extreme outliers | Clip values beyond a chosen percentile (winsorising), or use robust scaling (the median and interquartile range, which one extreme value barely moves), then check the spreads | One huge value can set the scale for the whole column under min-max and shrink everyone else |
| Right-skewed money or counts | Log transform (log1p is log(1+x), safe when a value is 0), then standardise (z-score: subtract the mean, divide by the standard deviation) | Stops a long tail from dominating distances |
| Binary or one-hot columns | Consider the weight; standardising a rare 0/1 column inflates it (a 1% column becomes about 10 on the scaled axis, as in the output) | Each category should not outweigh a continuous feature by accident |
| Features that should count more | Scale, then multiply by a chosen weight | Makes the importance an explicit decision |
Fit the scaler on the data used to fit the clusters, save it with the centroids, and reuse it to assign new customers, so the same customer gets the same segment tomorrow.
Pitfalls
- Scaling is not neutral: standardising gives every feature equal say, including noisy ones, so drop or down-weight features you would not defend.
- Always inspect the clusters in original units afterwards; stakeholders read dollars, not z-scores.
- Compare the result with and without scaling on the same seed; if they differ, the unscaled one was reflecting units.
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.