Cluster Analysis on Real Customer Data: K-Means vs Hierarchical

Find the customer segments nobody labelled: 440 real wholesale clients, a default clustering that scores well and finds only the whales, and the log-scale fix that a held-back label confirms. No code.

Dataset: wholesale_customers.csv: 440 real clients of a Portuguese food distributor, with one year of spending on fresh, milk, grocery, frozen, detergents & paper and delicatessen, plus sales channel and region. Source: Cardoso (2014), UCI Machine Learning Repository, CC BY 4.0. Download the CSV and follow along in Stratum. Every figure below is a real screenshot from this file.

A food wholesaler, 440 clients, and one year of spending per client across six product lines. There are no segments and no labels, and the job is to find the customer groups anyway.

K-means clusters of 440 wholesale customers on the first two principal components, two segments labelled 97% hotels-restaurants-cafés and 71% retail
Two segments found with no labels. Afterwards, one turned out to be 97% hotels, restaurants and cafés.

The file also has a Channel column (hotel/restaurant/café vs retail) and a Region. Neither is used as an input. Channel is the answer key: we set it aside and bring it back at the end to check whether the clusters found something real.

Step 1 — Look before you cluster

Analyze ▸ Summary on the six spend columns shows the shape. Grocery averages 7,951 a year, but the median customer spends 4,756. The biggest delicatessen buyer spends ~50× the median. Skewness runs from 2.6 (fresh) to 11.2 (delicatessen).

Histogram of annual grocery spend, heavily right-skewed
Grocery spend: a wall at the left and a long thin tail. Every spend column looks like this.

Step 2 — The default k-means scores well and finds nothing useful

Analyze ▸ Data Mining ▸ Clustering, the six raw columns, K-Means, two clusters. Stratum standardizes by default and runs ten k-means++ restarts from a fixed seed, so the result is reproducible. The legend reports a mean silhouette ≈ 0.57, which by the usual rule of thumb is strong structure, and the Elbow view's silhouette peaks at k = 2 as well.

Then switch to View = Profile, which prints what kmeans() prints in R plus a silhouette per cluster. The sizes are ~390 vs ~40–50, and the small group's own Mean Silhouette is about 0.01 against 0.64 for the crowd. The headline 0.57 belongs to the crowd; the "segment" scores zero. In original units the profile says who it is: mean grocery spend 27,597 against 5,489, five times everyone else. These are the giant grocery accounts in the tail. The Fit section puts Between / Total at 26.0%.

With Standardize off it gets worse: fresh food carries 45% of the raw variance, so k-means splits on fresh and nothing else (65 vs 375).

K-means on raw spend: one large cluster and a small cluster of extreme customers
Raw scale: a high silhouette, but the split is just the whales against everyone else.
Clustering Profile report for raw spend: cluster sizes with mean silhouette near zero for the small cluster, cluster means, and Between / Total of 26%
The Profile view: the small cluster's own silhouette is about zero. The high average is the crowd.
The silhouette trap. On skewed data a high mean silhouette often means a few extreme points sit far from the crowd. Read it per cluster: a "segment" scoring zero is not a segment.

Step 3 — Put spending on the right scale

Spending is multiplicative. Going from 1,000 to 2,000 is the same change in behavior as going from 10,000 to 20,000, and a log scale treats them that way. Data ▸ Add Column… with ln([Fresh]), and the same for the other five columns. The smallest value in the file is 3, so a plain natural log is exact and nobody drops out (with zeros you'd use log1p(); here it gives the identical partition). Grocery skewness goes from 3.59 to −0.74.

Re-run the clustering on the six log columns and switch to View = Elbow:

k12345
WCSS2,6341,8441,5551,3891,268
Mean silhouette—0.290.260.190.19
Elbow chart: within-cluster sum of squares by k with mean silhouette, peaking at k = 2
WCSS falls hardest from one cluster to two, and the legend reads "Peak silhouette at k = 2."

At k = 2 the groups are ~252 and ~188, two real segments instead of a whale group and a crowd. In the Profile view the overall silhouette fell from 0.57 to 0.29, but per cluster it reads 0.31 and 0.26: both segments hold together. And Between / Total rose from 26.0% to 30.0%. Both runs standardize six columns, so the total sum of squares is 2,634 in each and the ratios compare directly. The clusters explain more on the log scale with a lower silhouette, which is what overlapping groups of real businesses look like.

Clustering Profile report for log spend: two clusters of 252 and 188 with mean silhouettes 0.31 and 0.26, standardized cluster centers, and Between / Total of 30%
Log scale: both clusters hold together, and Between / Total rises to 30%.

Step 4 — Ask a different algorithm: Ward hierarchical

On the same card, set Method to Hierarchical. Linkage defaults to Ward, which minimizes the same within-cluster variance as k-means but builds the groups bottom-up. The tallest gap in the dendrogram sits under the final merge. Cutting there gives 234 / 206. (For contrast, single linkage cut at two peels off just one customer.)

Ward dendrogram of 440 customers cut into two colored clusters
Ward's dendrogram: the tallest gap is at the two-cluster cut.

Hierarchical has the same Profile view: Ward's two groups show the same pattern as k-means, with Between / Total 27.1%, a little under k-means, which optimises exactly that ratio.

Write both assignments to the sheet with Add Cluster Column… and cross them in a contingency table. About 396 of 440 customers (90%) fall in the same group under both methods.

Step 5 — What separates the segments? PCA

Principal components on the six log columns: PC1 = 44.0% loads on grocery, detergents & paper and milk (a stock-the-shelves axis); PC2 = 27.1% loads on fresh, frozen and delicatessen (a cook-the-food axis). Together that's 71% of the variation.

PCA biplot of log spend colored by cluster; the two clusters separate along PC1
Colored by cluster, the segments separate along PC1 (grocery/milk/detergent) and barely differ on PC2.

Back on the clustering card, Profile with Units: Standardized gives the cluster centers in standard deviations of log spend, the same numbers as R's kmeans(scale(log(x)), 2)$centers:

Center (SD from average)FreshMilkGroceryFrozenDet. & paperDeli
"Kitchen" segment+0.22−0.60−0.64+0.26−0.66−0.15
"Shelf" segment−0.30+0.81+0.86−0.35+0.88+0.20

In money terms (geometric means, computed outside Stratum), the kitchen segment spends about 8,600 on fresh and 290 on detergents & paper; the shelf segment about 12,100 on grocery and 4,000 on detergents & paper.

Step 6 — Validate against the label it never saw

Now bring back the answer key. Contingency table: cluster down the side, Channel across, row percentages.

Hotel / restaurant / caféRetail
Kitchen segment244 (96.8%)8
Shelf segment54134 (71.3%)
Contingency table of cluster by sales channel with row percentages and Cramér's V
χ² ≈ 228.5, p ≈ 10⁻⁵¹, Cramér's V ≈ 0.72, against a label the clustering never saw.

The clusters match Channel for 86% of customers, against 68% for always guessing the bigger channel. Two comparisons back this up. The raw-scale clustering, with its 0.57 silhouette and a small cluster scoring zero, only reaches V ≈ 0.4: the best-looking score matched the business worst. And as a negative control, the same segments against Region give V = 0.05, p = 0.54. The segments reflect what customers buy, not where they are.

Validate with the table. A contingency table and Cramér's V show where the clusters disagree with the label, which a single agreement index can't. For reference, scikit-learn's Adjusted Rand Index for this partition against Channel is 0.51.

Step 7 — Two questions anyone can use

A segmentation is only useful if someone can assign a new customer to it. A decision tree with the cluster as response, the six original spend columns as predictors, Max Depth = 2 and pruning off, then View = Rules:

LeafRuleNPredicted% Correct
1Detergents_Paper ≤ 1,535 and Milk ≤ 7,677250Kitchen96.0
2Detergents_Paper ≤ 1,535 and Milk > 7,67716Shelf81.3
3Detergents_Paper > 1,535 and Milk ≤ 2,295.510Kitchen70.0
4Detergents_Paper > 1,535 and Milk > 2,295.5164Shelf98.8
Decision tree Rules report: four leaves split on detergents and paper spend, then milk, with counts and percent correct
The whole segmentation as rules. Leaves 1 and 4 cover 414 of 440 customers.

The first question alone (detergents & paper above 1,535 a year?) sorts ~93% of customers correctly. With Validation = 30% the tree regrows on the rest and lands in the low 90s on customers it never saw.

What we actually did

We saw the default clustering score well while finding only the whales (a cluster with a silhouette of zero), and fixed the scale with logs: Between / Total went up as the silhouette went down. Elbow and silhouette both chose two clusters, and Ward agreed with k-means for nine customers in ten. PCA and the standardized profile explained the split, a held-back label validated it at V ≈ 0.72 while Region showed nothing, and a two-question tree, printed as rules, turned it into something sales can use. That's a full cluster analysis on real data, no code.

The dataset is free and so is the trial. Download Stratum, load wholesale_customers.csv, and try k = 3 to see whether the hotels that shop like retailers split out on their own.
case-study clustering k-means hierarchical pca