Find wholesale buyer types
- Case study 7
- 100 min
- Optional · 1 case study needed for the certificate
Case study 7 of 7 · Machine Learning: From Problem to Reliable ModelFind wholesale buyer types
1. Understand machine learning
2. Frame the prediction problem
3. Evaluate supervised models
4. Build the first models
5. Try other model families
6. Tune and finish the model
7. Understand and choose the model
8. Prepare the model for handoff
What you will be able to do
Find out whether a food wholesaler’s customers buy the way their labels say. The spending data is so skewed that the clustering with the best silhouette is useless. You fix it with a log and scaling, and you draw the customers in two dimensions with PCA. Then you compare the clusters with the label the business already uses. The interesting result is in the customers where the two disagree. With only 440 customers, you also learn to say how far you can trust a result.
The notebook has every code block of this page, in the same order, with three tasks and their checks. Its setup cells install the packages and download the data (7 KB), so it needs no local setup. It runs in under a minute. Read the explanations here, and run the code there.
Before you startWhat to know and to have ready
What you need to know
- k-means, scaling, inertia, silhouette and PCA, from Unsupervised exploration.
- Pipelines, from Preprocess with a pipeline.
- Slices and why a small slice is unreliable, from Slice analysis.
- The log before scaling, the stability check, the adjusted Rand index (ARI) and the experiment log for clustering, from Segment online-shop customers. If you skipped it, read that section first: this page uses the same tools.
What you need on your computer (only for the local route; the notebook needs none of it)
- A Python environment with pandas 3.0.6, scikit-learn 1.9.1 and matplotlib, such as the
.venvof your course project from Define a baseline: Set up your project. - A folder for case studies, for example
~/projects/case-studies. Open the notebook there. Its setup cell makes a folderfind-wholesale-buyer-typesand downloads the data into it. To start again, delete that folder and run the notebook from the top.
Tested on macOS with Python 3.14.6, pandas 3.0.6, scikit-learn 1.9.1 and matplotlib 3.11.2, in Jupyter. Windows, Colab and Kaggle are not tested.
The situation
Section titled “The situation”A food wholesaler in Portugal delivers to two kinds of business. Inês, the sales manager, runs two delivery plans:
- The kitchen route: small deliveries several times a week, mostly fresh and frozen food, for hotels, restaurants and cafés. The trade calls this channel Horeca (hotel, restaurant, café).
- The shelf route: one large delivery a week of groceries, milk, detergents and paper, for shops (the Retail channel).
Today the customer file decides the route: every customer has a channel label, Horeca or Retail. Inês has a doubt. “Some cafés sell groceries too, and some shops have a fresh counter. Do our customers really buy the way their label says?” A wrong route has a cost. A café on the shelf route gets its fresh food too late and buys it elsewhere. A shop on the kitchen route pays for deliveries it does not need.
Rui, a sales representative, has a shortcut: “Let the computer group the customers, and take the grouping with the best score.” This case study shows why that shortcut fails here.
Inês and Rui are made up. The data is real: one year of spending of 440 customers of a wholesale distributor in Portugal.
What you will build
Section titled “What you will build”You will build a buyer-type list. Each customer gets a type from what they buy, not from their label. The list marks the customers whose type and label disagree, for Inês’s team to check. You will also write a short, honest note on how far you can trust the result.
The case study uses these skills:
- k-means, PCA and the hints for choosing k (Unsupervised exploration);
- the log before scaling, stability and ARI (Segment online-shop customers);
- a pipeline for the preprocessing (Preprocess with a pipeline);
- slices with their counts (Slice analysis);
- the existing label as the baseline: what the business already does (Define a baseline).
The data
Section titled “The data”The data is Wholesale customers from the UCI Machine Learning Repository. Margarida Cardoso published it under the Creative Commons Attribution 4.0 licence (CC BY 4.0).
Cardoso, M. (2013). Wholesale customers [Dataset]. UCI Machine Learning Repository. https://doi.org/10.24432/C5030X. CC BY 4.0.
The course keeps a fixed copy in its labs repository. It is the source file, compressed, with no other change; the dataset card has the details. One row is one customer, with what they spent in one year on six kinds of product:
| Column | What it means |
|---|---|
Channel |
1 = Horeca (hotel, restaurant, café), 2 = Retail (shop). The label the business uses today. |
Region |
1 = Lisbon, 2 = Oporto, 3 = other regions. |
Fresh, Milk, Grocery, Frozen |
Spending in one year on each kind of product. |
Detergents_Paper |
Spending on cleaning products and paper goods. |
Delicassen |
Spending on delicatessen (the source’s spelling). |
The source gives spending in “monetary units” (m.u.) and does not name the currency or the year.
customers = pd.read_csv("data/wholesale_customers.csv.gz")SPEND = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]print(customers.shape, "| missing values:", customers.isna().sum().sum(), "| repeated rows:", customers.duplicated().sum())customers.head()The output:
(440, 8) | missing values: 0 | repeated rows: 0 Channel Region Fresh Milk Grocery Frozen Detergents_Paper Delicassen0 2 3 12669 9656 7561 214 2674 13381 2 3 7057 9810 9568 1762 3293 17762 2 3 6353 8808 7684 2405 3516 78443 1 3 13265 1196 4221 6404 507 17884 2 3 22615 5410 7198 3915 1777 5185What this tells us. The data is small and clean: 440 customers, no missing values, no repeated rows. Customer 0 is a shop (channel 2) that spent 12,669 m.u. on fresh food and 7,561 on groceries in the year. Customer 3 is a Horeca business with large fresh and frozen spending and little on detergents and paper, as you would expect from a kitchen.
The codes 1, 2 and 3 are hard to read, so give them names:
customers["channel"] = customers["Channel"].map({1: "Horeca", 2: "Retail"})customers["region"] = customers["Region"].map({1: "Lisbon", 2: "Oporto", 3: "Other"})pd.crosstab(customers["channel"], customers["region"], margins=True)The output:
region Lisbon Oporto Other AllchannelHoreca 59 28 211 298Retail 18 19 105 142All 77 47 316 440What this tells us. Two in three customers are Horeca (298). Most customers are outside Lisbon and Oporto (316). Some slices are small: only 18 shops in Lisbon and 19 in Oporto. Any result for one of these slices rests on a handful of customers.
Frame the problem
Section titled “Frame the problem”- The unit is one customer, because the delivery plan is per customer.
- There is no target. Nobody knows the “true” buyer type. The clustering looks for types in the spending alone.
- The channel label stays out of the clustering. If it went in, the clusters would copy it, and you could not learn where the spending disagrees with it. You keep it sealed, and you compare the clusters with it once, at the end, like a test set.
- Region stays out too. It is not a buying pattern. You use it only to check the results in each region.
- Small data. 440 customers from one year is little. Every result needs a check of how stable it is, and the conclusions are tentative: a list for Inês’s team to verify, not a decision.
Explore what the decision needs
Section titled “Explore what the decision needs”Distances decide k-means, so the first question is how the spending is spread:
summary = customers[SPEND].describe().T[["min", "25%", "50%", "75%", "max"]].round(0)summary["skew"] = customers[SPEND].skew().round(1)summaryThe output:
min 25% 50% 75% max skewFresh 3.0 3128.0 8504.0 16934.0 112151.0 2.6Milk 55.0 1533.0 3627.0 7190.0 73498.0 4.1Grocery 3.0 2153.0 4756.0 10656.0 92780.0 3.6Frozen 25.0 742.0 1526.0 3554.0 60869.0 5.9Detergents_Paper 3.0 257.0 816.0 3922.0 40827.0 3.6Delicassen 3.0 408.0 966.0 1820.0 47943.0 11.2What this tells us. Every column has a long tail. The median customer spends 4,756 m.u. on groceries, but the largest spends 92,780, almost 20 times more. Delicatessen is the most extreme (skew 11.2): the median is 966 and the maximum 47,943. At the other end, some customers spend only 3 m.u. a year on a category. The chart shows groceries as recorded and after a log.
Text description of the chart
A histogram with a very long tail to the right. 226 customers spend under 5,000 m.u., 97 spend 5,000 to 10,000 and 55 spend 10,000 to 15,000. Then the bars shrink: 24, 18, 8, 5 and 2 customers. Only 5 customers spend more than 45,000, the largest 92,780.
Text description of the chart
A histogram with one main hill. Most customers are between 3 and 4.5 on the log scale (1,000 to 30,000 m.u.); the highest bar is 3.25 to 3.5, with 91 customers. A few customers are lower, between 2 and 3. One customer is alone at about 0.5: a spending of 3 m.u.
The log gives every customer a fair place. It also has a cost: tiny values become the new extremes. Keep that in mind for the section on outliers.
Some columns also move together:
customers[SPEND].corr().round(2)The output:
Fresh Milk Grocery Frozen Detergents_Paper DelicassenFresh 1.00 0.10 -0.01 0.35 -0.10 0.24Milk 0.10 1.00 0.73 0.12 0.66 0.41Grocery -0.01 0.73 1.00 -0.04 0.92 0.21Frozen 0.35 0.12 -0.04 1.00 -0.13 0.39Detergents_Paper -0.10 0.66 0.92 -0.13 1.00 0.07Delicassen 0.24 0.41 0.21 0.39 0.07 1.00What this tells us. Grocery and detergents and paper move almost together (0.92), and milk goes with both (0.73 and 0.66). These are the goods of the shelf route. Fresh and frozen go together a little (0.35): the kitchen goods. Two strongly linked columns count almost twice in a distance. That is not wrong, but it gives the shelf goods more weight. The variation in the notebook tests it.
Today’s rule: the channel label
Section titled “Today’s rule: the channel label”The baseline is what the business does today: plan by channel. How differently do the two channels buy?
share = customers[SPEND].div(customers[SPEND].sum(axis=1), axis=0)print("Median spend per year, by channel:")print(customers.groupby("channel")[SPEND].median())print("\nMedian share of a customer's spend, by channel:")print(share.groupby(customers["channel"]).median().round(3))The output:
Median spend per year, by channel: Fresh Milk Grocery Frozen Detergents_Paper DelicassenchannelHoreca 9581.5 2157.0 2684.0 2057.5 385.5 821.0Retail 5993.5 7812.0 12390.0 1081.0 5614.5 1350.0
Median share of a customer's spend, by channel: Fresh Milk Grocery Frozen Detergents_Paper DelicassenchannelHoreca 0.492 0.101 0.137 0.099 0.019 0.04Retail 0.138 0.223 0.339 0.028 0.155 0.03What this tells us. The label works on average. The median Horeca customer spends 49.2% of its budget on fresh food and 1.9% on detergents and paper. The median shop spends 33.9% on groceries and 15.5% on detergents and paper. A share is the part of one customer’s spending that goes to one category; it shows what a customer buys, apart from how much. The question is whether every customer follows the average of its label.
First try: k-means on the raw spending
Section titled “First try: k-means on the raw spending”Rui’s shortcut: run k-means on the spending as recorded, and choose the k with the best silhouette.
from sklearn.cluster import KMeansfrom sklearn.metrics import adjusted_rand_score, silhouette_score
def try_k(X, ks=range(2, 7)): """Silhouette and cluster sizes for each k.""" for k in ks: labels = KMeans(n_clusters=k, n_init=20, random_state=0).fit_predict(X) print(f"k {k}: silhouette {silhouette_score(X, labels):.3f} " f"sizes {sorted(np.bincount(labels).tolist(), reverse=True)}")
try_k(customers[SPEND].to_numpy())The output:
k 2: silhouette 0.512 sizes [375, 65]k 3: silhouette 0.478 sizes [330, 60, 50]k 4: silhouette 0.387 sizes [276, 95, 58, 11]k 5: silhouette 0.373 sizes [226, 104, 81, 22, 7]k 6: silhouette 0.375 sizes [227, 113, 71, 22, 5, 2]What this tells us. k = 2 has a silhouette of 0.512, higher than any value in the online-shop case study. Rui is pleased. But look at the sizes: 375 and 65, and for larger k there are clusters of 7, 5 and 2 customers. What do the two clusters of k = 2 mean?
raw_labels = KMeans(n_clusters=2, n_init=20, random_state=0).fit_predict(customers[SPEND].to_numpy())raw_profile = customers.groupby(raw_labels)[SPEND].median()raw_profile["customers"] = np.bincount(raw_labels)raw_profile["total"] = customers[SPEND].sum(axis=1).groupby(raw_labels).median()raw_profileThe output:
Fresh Milk Grocery Frozen Detergents_Paper Delicassen customers total0 30624.0 4411.0 5428.0 3443.0 632.0 1824.0 65 52700.01 6758.0 3354.0 4563.0 1383.0 836.0 806.0 375 24689.0Common mistake
The silhouette trap
Symptom: the clustering with the best silhouette (0.512) splits off 65 customers from the other 375. The 65 spend a lot on fresh food (a median of 30,624 m.u.). Groceries, milk and detergents hardly differ between the two clusters. Scaling without a log is even “better”:
from sklearn.preprocessing import FunctionTransformer, StandardScaler
scaled_only = StandardScaler().fit_transform(customers[SPEND])try_k(scaled_only, ks=[2])k 2: silhouette 0.567 sizes [391, 49]A silhouette of 0.567, and the 49 customers are the biggest buyers of milk, groceries and detergents.
Cause: silhouette rewards clusters that are far apart. With a long tail, there is an easy way to make clusters far apart: put the few giants on one side and everybody else on the other. The score is high because the giants are far from everybody, not because there are two kinds of buyer. Inês already knows her biggest customers. A split by size does not tell her which route a customer needs.
Fix: decide first what “similar” must mean for the decision. Here two customers are similar when they buy a similar mix, whatever their size. Take the log before scaling, so that a difference of 10 times counts the same in every column and at every size. Then use silhouette only to compare options that all make sense, and always read the profile and the sizes.
Consequence in real work: a team reports “two customer types, silhouette 0.57”, and the types are “big” and “the rest”. The number looks scientific, and nothing in it warns you.
Log and scale
Section titled “Log and scale”Now the same search with a log and scaling in one pipeline, as in the online-shop case study:
from sklearn.pipeline import make_pipeline
prepare = make_pipeline(FunctionTransformer(np.log1p), StandardScaler())X = prepare.fit_transform(customers[SPEND])print("skew after the log:", np.log1p(customers[SPEND]).skew().round(1).to_dict())try_k(X)The output:
skew after the log: {'Fresh': -1.6, 'Milk': -0.2, 'Grocery': -0.7, 'Frozen': -0.4, 'Detergents_Paper': -0.2, 'Delicassen': -1.1}k 2: silhouette 0.290 sizes [252, 188]k 3: silhouette 0.259 sizes [212, 147, 81]k 4: silhouette 0.188 sizes [132, 129, 118, 61]k 5: silhouette 0.192 sizes [100, 100, 96, 90, 54]k 6: silhouette 0.193 sizes [113, 90, 84, 82, 51, 20]What this tells us. The long right tails are gone. Fresh (−1.6) and delicatessen (−1.1) now lean left, because a few tiny values became extreme on the log scale. The silhouette values are much lower (0.290 at k = 2), but the clusters now have sizes that could be real types: 252 and 188. A lower silhouette here does not mean worse clusters. It means that real buyers overlap, and the method no longer cheats with the giants. k = 2 is still the best of these options.
Is k = 2 stable? Use the stability check from the online-shop case study, with 20 runs on random 80% samples:
def stability(X, k, runs=20): """Mean agreement (ARI) between k-means runs fitted on different random 80% samples.""" rng = np.random.default_rng(0) labels = [] for run in range(runs): rows = rng.choice(len(X), int(0.8 * len(X)), replace=False) labels.append(KMeans(n_clusters=k, n_init=20, random_state=run).fit(X[rows]).predict(X)) return np.mean([adjusted_rand_score(a, b) for i, a in enumerate(labels) for b in labels[i + 1:]])
for k in range(2, 7): print(f"k {k}: stability {stability(X, k):.3f}")The output:
k 2: stability 0.921k 3: stability 0.795k 4: stability 0.673k 5: stability 0.654k 6: stability 0.605What this tells us. Only k = 2 is stable (0.921). From k = 3 on, a different 80% of the customers gives noticeably different clusters. 440 customers are not enough data for finer types. So you choose k = 2. It has the best silhouette of the sensible options, it is the only stable result, and Inês has two delivery plans to fill.
The two buyer types
Section titled “The two buyer types”Fit the pipeline with k = 2, and name each cluster from its profile. The cluster with the higher median grocery spending is the shelf type:
segmenter = make_pipeline(FunctionTransformer(np.log1p), StandardScaler(), KMeans(n_clusters=2, n_init=20, random_state=0))customers["cluster"] = segmenter.fit_predict(customers[SPEND])shelf = customers.groupby("cluster")["Grocery"].median().idxmax()customers["buyer_type"] = np.where(customers["cluster"] == shelf, "shelf", "kitchen")profile = customers.groupby("buyer_type")[SPEND].median()profile["customers"] = customers["buyer_type"].value_counts()profileThe output:
Fresh Milk Grocery Frozen Detergents_Paper Delicassen customersbuyer_typekitchen 10347.5 1871.0 2406.0 2225.0 327.5 749.5 252shelf 5406.5 7690.5 11527.0 1061.5 4495.0 1388.5 188What this tells us. Inês reads the profile and agrees with the names. Kitchen buyers (252) spend twice as much on fresh food and frozen food as the others, and little on detergents and paper. Shelf buyers (188) spend about four times as much on groceries and milk and more than ten times as much on detergents and paper. The two profiles look very much like the Horeca and Retail medians. The clustering found the two kinds of business without seeing the label.
A picture: PCA
Section titled “A picture: PCA”Six columns cannot be drawn on a page. PCA, from Unsupervised exploration, finds the two directions that keep the most spread. Before you trust the picture, read what each direction means:
from sklearn.decomposition import PCA
pca = PCA(n_components=2).fit(X)print("share of the spread kept:", pca.explained_variance_ratio_.round(3))pd.DataFrame(pca.components_, index=["PC1", "PC2"], columns=SPEND).round(2)The output:
share of the spread kept: [0.441 0.272] Fresh Milk Grocery Frozen Detergents_Paper DelicassenPC1 -0.11 0.54 0.57 -0.14 0.55 0.21PC2 0.59 0.13 -0.01 0.59 -0.07 0.53What this tells us. The picture keeps 44.1% + 27.2% = 71.3% of the spread, more than in the customer example of Unsupervised exploration (57.5%). The two directions have clear meanings:
- PC1 is “shelf goods”: it grows with milk (0.54), groceries (0.57) and detergents and paper (0.55). A customer far to the right buys a lot of shop goods.
- PC2 is “kitchen goods”: it grows with fresh (0.59), frozen (0.59) and delicatessen (0.53). A customer high up buys a lot of food to cook.
The chart shows all 440 customers in these two directions. The colour combines the label and the buyer type.
- Horeca, kitchen buyer (244)
- Retail, shelf buyer (134)
- Horeca, buys like a shop (54)
- Retail, buys like a kitchen (8)
Text description of the chart
A scatter plot of 440 customers. The horizontal axis, PC1, grows with milk, grocery and detergents-and-paper spending, from about −6 to 4. The vertical axis, PC2, grows with fresh, frozen and delicatessen spending, from about −5 to 4. The Horeca kitchen buyers (244) fill the left half, with a mean PC1 of −1.18. The Retail shelf buyers (134) fill the right part, with a mean PC1 of 1.81. The 54 Horeca customers who buy like shops sit in the middle and right, mixed with the shops, with a mean PC1 of 0.87. The 8 Retail customers who buy like kitchens sit near the middle, left of the border. A few customers with tiny spending are far out at the bottom and the left. There is no empty gap between the two types: one cloud runs from left to right.
What this tells us. The main difference between customers is left to right: how much of their buying is shelf goods. k-means cut the cloud in two along that direction. There is no empty space between the types, so a customer near the border could go either way. The colours show where the label and the type disagree: the 54 Horeca customers on the shelf side are the interesting group.
Compare with the channel label
Section titled “Compare with the channel label”Now open the label you kept sealed. How well do the two buyer types agree with Horeca and Retail?
print(pd.crosstab(customers["buyer_type"], customers["channel"], margins=True))print("ARI with the channel:", round(adjusted_rand_score(customers["channel"], customers["buyer_type"]), 3))The output:
channel Horeca Retail Allbuyer_typekitchen 244 8 252shelf 54 134 188All 298 142 440ARI with the channel: 0.514What this tells us. The agreement is real but not complete. 244 of 298 Horeca customers (81.9%) buy like kitchens, and 134 of 142 shops (94.4%) buy like shops. The ARI is 0.514: well above chance (0), far from identical (1). For comparison, the raw clustering with the best silhouette had an ARI of −0.031 with the channel: no agreement at all. Disagreement is not a failure here. The label says what kind of business a customer is, and the type says how it buys. 62 customers (54 + 8) buy unlike their label, and those are worth a look.
The customers who buy unlike their label
Section titled “The customers who buy unlike their label”Put the label and the type together, and compare the shares of spending:
customers["group"] = customers["channel"] + " / " + customers["buyer_type"]groups = pd.concat([customers["group"].value_counts().rename("customers"), customers[SPEND].sum(axis=1).groupby(customers["group"]).median().rename("total"), share.groupby(customers["group"]).median().round(2)], axis=1)groups.sort_values("customers", ascending=False)The output:
customers total Fresh Milk Grocery Frozen Detergents_Paper DelicassengroupHoreca / kitchen 244 19636.5 0.53 0.08 0.12 0.12 0.02 0.04Retail / shelf 134 37293.0 0.12 0.23 0.36 0.03 0.16 0.03Horeca / shelf 54 27646.5 0.19 0.22 0.29 0.04 0.07 0.05Retail / kitchen 8 31763.5 0.46 0.12 0.18 0.13 0.03 0.03What this tells us.
- 54 Horeca customers buy like shops. Fresh food is only 19% of their spending, against 53% for a typical Horeca customer. Groceries (29%) and milk (22%) are close to the shops’ shares. They are also larger than the typical Horeca customer (a median of 27,647 m.u. against 19,637). Inês has a guess: “Hotels with a shop or a minibar, and cafés that sell groceries.” Together they spend 2,070,985 m.u., 14.2% of all spending. Today they are all on the kitchen route.
- 8 shops buy like kitchens. Fresh food is 46% of their spending. They may be greengrocers or delicatessens. Eight customers is too few to say more.
This is the business finding. The label is not wrong: these businesses are hotels, restaurants and cafés. But their delivery needs follow what they buy.
A slice by region
Section titled “A slice by region”Is the finding the same in each region? Count the groups:
pd.crosstab(customers["group"], customers["region"], margins=True)The output:
region Lisbon Oporto Other AllgroupHoreca / kitchen 46 26 172 244Horeca / shelf 13 2 39 54Retail / kitchen 2 2 4 8Retail / shelf 16 17 101 134All 77 47 316 440What this tells us. Shop-like Horeca customers are in every region: 13 of 59 Horeca customers in Lisbon (22%), 39 of 211 in the other regions (18%), and 2 of 28 in Oporto (7%). The Oporto number rests on 2 customers, so it can be chance. With these counts, no region needs its own plan.
How much does the result depend on the choices?
Section titled “How much does the result depend on the choices?”Clusters depend on the choices you made: k, the columns, the treatment of extreme customers. With 440 customers, check each one.
The number of clusters
Section titled “The number of clusters”The channel was sealed while you chose k. Now that the choice is made, you can look back and see how the agreement changes with k:
for k in range(2, 7): labels = KMeans(n_clusters=k, n_init=20, random_state=0).fit_predict(X) print(f"k {k}: ARI with the channel {adjusted_rand_score(customers['channel'], labels):.3f} " f"Retail share per cluster {pd.Series(customers['Channel'] == 2).groupby(labels).mean().round(2).tolist()}")print(f"raw spend, k 2: ARI with the channel {adjusted_rand_score(customers['channel'], raw_labels):.3f}")The output:
k 2: ARI with the channel 0.514 Retail share per cluster [0.03, 0.71]k 3: ARI with the channel 0.346 Retail share per cluster [0.59, 0.62, 0.01]k 4: ARI with the channel 0.280 Retail share per cluster [0.01, 0.66, 0.8, 0.05]k 5: ARI with the channel 0.212 Retail share per cluster [0.0, 0.06, 0.87, 0.7, 0.11]k 6: ARI with the channel 0.200 Retail share per cluster [0.18, 0.01, 0.87, 0.07, 0.15, 0.84]raw spend, k 2: ARI with the channel -0.031- Silhouette, raw spending
- Silhouette, log + scale
- ARI with channel, raw
- ARI with channel, log + scale
Text description of the chart
Four lines against k from 2 to 6. Silhouette on raw spending is the highest line: 0.512 at k = 2, falling to 0.375 at k = 6. Silhouette after log and scaling is lower: 0.290 at k = 2, 0.259 at k = 3, then about 0.19. The ARI with the channel on raw spending is −0.031 at k = 2, 0.153 at k = 3, 0.329 at k = 4, 0.223 at k = 5 and 0.183 at k = 6. The ARI after log and scaling is 0.514 at k = 2 and falls steadily: 0.346, 0.280, 0.212 and 0.200.
What this tells us. The best silhouette and the most meaningful clusters point in opposite directions. On raw spending, the highest silhouette (k = 2) comes with no agreement at all (−0.031). No raw clustering gets above 0.329. With the log, k = 2 agrees best with the channel (0.514). Larger k does not find new kinds of business. Each extra cluster still has either mostly shops or almost no shops; it only cuts the same two types into smaller parts.
What does the third cluster of k = 3 add?
three = KMeans(n_clusters=3, n_init=20, random_state=0).fit_predict(X)k3_profile = customers.groupby(three)[SPEND].median()k3_profile["customers"] = np.bincount(three)k3_profile["Retail"] = pd.crosstab(three, customers["channel"])["Retail"]k3_profileThe output:
Fresh Milk Grocery Frozen Detergents_Paper Delicassen customers Retail0 1454.0 6264.0 10487.0 388.0 4196.0 436.0 81 481 12126.0 7184.0 9965.0 2005.0 3378.0 2005.0 147 912 9635.0 1598.5 2151.0 2151.0 274.5 686.0 212 3What this tells us. The kitchen buyers stay almost unchanged (212 customers, only 3 shops). The shelf side splits in two. 81 customers buy almost no fresh or frozen food, and 147 large customers buy a lot of everything. That is a split by size and by the fresh counter, not a new delivery plan. With a stability of 0.795, it is also less reliable. Inês keeps two types.
Extreme customers
Section titled “Extreme customers”The log made tiny spending values extreme. How many customers are far out, and do they change the clusters?
extreme = (np.abs(X) > 3).any(axis=1)print(f"{extreme.sum()} customers have a value more than 3 SDs from the mean, after the log")print("their smallest yearly spend in one category:", sorted(customers.loc[extreme, SPEND].min(axis=1).tolist()))refit = make_pipeline(FunctionTransformer(np.log1p), StandardScaler(), KMeans(n_clusters=2, n_init=20, random_state=0))without = refit.fit_predict(customers.loc[~extreme, SPEND])print("agreement (ARI) with the clusters of all 440, on the other", (~extreme).sum(), "customers:", round(adjusted_rand_score(customers.loc[~extreme, "cluster"], without), 3))The output:
18 customers have a value more than 3 SDs from the mean, after the logtheir smallest yearly spend in one category: [3, 3, 3, 3, 3, 3, 3, 3, 7, 7, 9, 11, 18, 23, 25, 37, 56, 239]agreement (ARI) with the clusters of all 440, on the other 422 customers: 0.972What this tells us. After the log, the extreme customers are not the giants any more. They are customers who spend almost nothing on one category: eight of them spent 3 m.u. in a year. Such a value may be a real “never buys it”, or a data problem. Without these 18 customers, the other 422 get almost the same clusters (ARI 0.972). So the result does not depend on them. Inês should still ask about the customers with 3 m.u.: a category that a customer never buys from you may be one they buy elsewhere.
Your turn: one column less
Section titled “Your turn: one column less”Grocery and detergents and paper move together (0.92), so together they count almost twice. Change one thing: cluster on the five other columns, without Detergents_Paper. Before you run it, predict: does the agreement with the channel go up or down? Do more or fewer Horeca customers become shelf buyers? The notebook has the task and a check.
Show the answer
five = [c for c in SPEND if c != "Detergents_Paper"]variant = make_pipeline(FunctionTransformer(np.log1p), StandardScaler(), KMeans(n_clusters=2, n_init=20, random_state=0))labels = variant.fit_predict(customers[five])shelf_five = customers.groupby(labels)["Grocery"].median().idxmax()type_five = np.where(labels == shelf_five, "shelf", "kitchen")print("ARI with the channel:", round(adjusted_rand_score(customers["channel"], type_five), 3))horeca_shelf_five = int(((customers["channel"] == "Horeca") & (type_five == "shelf")).sum())print("Horeca customers of the shelf type:", horeca_shelf_five)ARI with the channel: 0.392Horeca customers of the shelf type: 72The agreement falls from 0.514 to 0.392, and 72 Horeca customers become shelf buyers instead of 54. Detergents and paper are the clearest sign of a shop: kitchens hardly buy them (1.9% of a typical Horeca customer’s spending). Without that column, the border moves. So the list of “Horeca customers who buy like shops” depends on the columns you choose. This is one more reason to treat the 54 as a list to check, not as a fact. The 54 who are shelf buyers with all six columns are the stronger cases.
The experiment log
Section titled “The experiment log”As in the online-shop case study, the log has the clustering columns: silhouette, stability and the result.
from datetime import date
log = pd.DataFrame([ ("E01", "Today's rule: the channel label", None, None, "Horeca 298, Retail 142", "baseline", "What the sales team uses now."), ("E02", "k-means on raw spend, k=2", 0.512, None, "sizes 375 / 65", "reject", "Splits off big Fresh buyers; not a buying pattern."), ("E03", "k-means, scaled only, k=2", 0.567, None, "sizes 391 / 49", "reject", "Splits off the largest customers."), ("E04", "k-means, log + scale, k=2", 0.290, 0.921, "kitchen 252 / shelf 188", "keep", "Two buying patterns a manager can use."), ("E05", "k-means, log + scale, k=3", 0.259, None, "adds 147 large all-round buyers", "reject", "A size split; no new delivery plan."), ("E06", "Check: buyer type against channel", None, None, "ARI 0.514; 54 Horeca buy like shops", "keep", "The finding for the sales team."),], columns=["id", "change", "silhouette", "stability", "result", "decision", "reason"])log.insert(1, "date", date.today().isoformat())log.to_csv("experiment_log.csv", index=False)log[["id", "change", "decision"]]The output:
id change decision0 E01 Today's rule: the channel label baseline1 E02 k-means on raw spend, k=2 reject2 E03 k-means, scaled only, k=2 reject3 E04 k-means, log + scale, k=2 keep4 E05 k-means, log + scale, k=3 reject5 E06 Check: buyer type against channel keepWhat this tells us. The two rejected rows with the highest silhouettes (E02 and E03) are the most useful lines in the log. They record why a higher score was not the better answer, which is exactly what a reviewer will ask about.
Recommendations
Section titled “Recommendations”The recommendation for Inês, in her words. All numbers are from one year of 440 customers, so the recommendations are a list to check, not a decision.
- Keep two delivery plans, and plan by buying, not only by label. The two buyer types match the two routes, and they are stable (0.921).
- Ask the 54 Horeca customers who buy like shops whether the shelf route suits them. They spend 19% of their budget on fresh food, against 53% for a typical Horeca customer, and 14.2% of all spending. A sales representative should call them first: some may need both routes.
- Ask the 8 shops that buy like kitchens whether they need fresh deliveries more often. They spend 46% on fresh food.
- Do not plan finer types yet. With three or more clusters the results change from sample to sample (stability 0.795 and lower), and the extra clusters split by size.
- Look at the customers who spend almost nothing in one category. Eight spent 3 m.u. in a year. A category they never buy from you may be one they buy elsewhere.
The notebook writes the list for the sales team:
deliverable = customers[["channel", "region", *SPEND, "buyer_type"]].copy()deliverable["review"] = np.where(customers["group"].isin(["Horeca / shelf", "Retail / kitchen"]), "buys unlike its channel", "")deliverable.to_csv("buyer_types.csv", index_label="customer")print(deliverable["review"].value_counts().to_string())The output:
review 378buys unlike its channel 62What this tells us. buyer_types.csv has all 440 customers with their type, and 62 are marked for review. The 378 customers whose type matches their label have an empty review column.
What not to use the types for. Do not change a customer’s channel label because of its type: the label says what the business is, the type says how it buys. Do not use the types for prices or credit. Do not move a customer to another route without asking them.
Limits and next steps
Section titled “Limits and next steps”- Small data. 440 customers, one year. Two types are stable; finer types are not. A difference in a slice of 8 or 28 customers can be chance.
- No time. There are no dates and no number of orders. Delivery frequency, the thing Inês plans, is not in the data. The types show what customers buy, not how often they need it.
- Size and mix are mixed. The log reduces the effect of size, but large customers still tend to be shelf buyers. Clustering on the shares of spending is another option. It agrees less with the channel (ARI 0.295); see Go further.
- The columns matter. Without detergents and paper, 72 Horeca customers become shelf buyers instead of 54.
- The label is a fact of unknown age. The source does not say how or when the channel was recorded.
Next steps: get the order dates, to measure how often each customer orders. Repeat the clustering on next year’s data. Ask the 62 customers in a short call; their answers are the best check of the types.
Make it a portfolio piece
Section titled “Make it a portfolio piece”This case study shows an employer something rare: that you can recognise a misleading metric and explain why you did not follow it.
What to put in the repository:
README.md, with five short parts:- Problem: “Find buyer types among a food wholesaler’s customers, and check them against the existing channel label.”
- Data and licence: Wholesale customers, UCI, CC BY 4.0, with the citation.
- Approach: the skew, the silhouette trap (E02 and E03 of the log), log + scale + k-means, k = 2 chosen with silhouette and stability, the PCA picture with the meaning of each axis.
- Results with the baseline: the crosstab with the channel (ARI 0.514), the 54 Horeca customers who buy like shops and their profile.
- Limits: small data, no dates, the effect of the columns.
- The notebook with its outputs,
experiment_log.csvandbuyer_types.csv. - A short note on use: what the types are for (delivery planning, a list to check), and what they are not for (labels, prices, credit).
How to describe it in an interview, in two sentences: “I clustered 440 wholesale customers by their spending and showed that the clustering with the best silhouette only separated the largest customers. After a log and scaling, two stable buyer types matched the existing channel label for 378 customers, and the 54 hotels, restaurants and cafés that buy like shops became a list for the sales team to check.”
Go further:
- Cluster on the shares of spending instead of the amounts, and compare the agreement with the channel.
- Try a Gaussian mixture model, which gives each customer a probability for each type, and list the customers near the border.
- Use the channel as a label for a classifier (logistic regression), and compare the misclassified customers with the 62 of this case study.
Knowledge check
Section titled “Knowledge check”Check your understanding
Rui says: the raw clustering has a silhouette of 0.512 and the log-scaled one only 0.290, so the raw one is better. What do you answer?
Show the answer
A higher silhouette means clusters that are further apart, not more useful ones. On raw spending, k-means separates 65 big buyers of fresh food from the other 375. Scaling without a log separates the 49 largest customers. Both are splits by size, which Inês already knows. The raw split’s agreement with the channel is −0.031, no better than chance. After a log and scaling, the two clusters are buying patterns (kitchen and shelf). They are stable (0.921), and they agree with the channel (ARI 0.514). Silhouette compares options only when all of them measure the kind of similarity the decision needs.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Why did you keep the channel label out of the clustering, and what would happen if you put it in?
Show the answer
The question was whether customers buy the way their label says. If the label goes in as a column, the clusters copy part of it. Then the comparison with the label is no longer a fair check, because the method saw the answer. The label would pull the 54 Horeca customers who buy like shops toward the Horeca cluster, and they might disappear. Keeping the label sealed until the end works like a test set: the clustering cannot have learned from it.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Inês asks: can I move the 54 Horeca customers to the shelf route next week? What do you say, and why?
Show the answer
Not straight away. The result comes from 440 customers and one year, with no order dates. The list also depends on the columns: without detergents and paper, the number becomes 72. The 54 are good candidates with a clear profile (19% fresh against 53% for a typical Horeca customer). So the next step is a call from a sales representative, to ask which deliveries they need. Some may need both routes. Treat the clustering as a way to find whom to ask, not as the decision.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
In this case study you looked for buyer types among 440 real wholesale customers:
- The silhouette trap: on raw spending, the best silhouette (0.512, and 0.567 with scaling only) came from splitting off the largest customers. The raw split did not agree with the channel at all (ARI −0.031).
- Log and scale: after a log, two buyer types appeared, kitchen (252) and shelf (188). They were the only stable choice (0.921), and a lower silhouette (0.290) was the honest number.
- PCA with meanings: PC1 is shelf goods and PC2 kitchen goods; the picture keeps 71.3% of the spread.
- The sealed label as a check: the types agree with Horeca and Retail (ARI 0.514). 54 Horeca customers buy like shops; they make 14.2% of all spending, and today they are all on the kitchen route.
- Tentative conclusions: more clusters were unstable, and extreme customers did not change the result (0.972). One column less moved the list from 54 to 72. So the result is a list to check, not a decision.
You have now finished the case studies of this course. Next is the final assignment: one new decision for Larkfield. You use every module and hand over a classifier that another person can run and trust.
Next: Final assignment