Chapter 4 — Finding Hidden Groups — Clustering Methods
What This Chapter Covers
Problem: we have customer records, but nobody has already told us which buying-pattern group each customer belongs to.
Example 1 — a wholesale distributor. We have 440 customers and each customer’s annual spending on six product categories. The sales manager asks: “Do some customers buy similar combinations of products, so we could test a different bundle for each group?”
The table records amounts spent, but has no ready-made buying-pattern segment for each customer. Channel tells us whether a customer is retail or a hotel/restaurant/café; it does not tell us these spending-based groups.
Example 2 — a mall (simulated customer data). We have customers’ annual income and spending scores. The marketing manager asks: “Can we find groups with similar income and spending patterns before trying different promotions?”
There is no supplied label such as “higher income, lower spending score.” We first find groups from the features, then describe what each group looks like.
In this chapter: choose features → measure similarity → form groups → understand the groups → propose an action to test. The group labels are an output of clustering, not an answer column supplied at the start.
This chapter follows the Fall 2026 one-lecture clustering topic: naïve grouping, distance measures, K-means, and choosing the number of clusters. Section 4.6 (hierarchical clustering) is optional and is not covered in the final exam. Section practices can also be completed after class; the group Colab project will be separate.
Chapter Roadmap
| Section | Business task |
|---|---|
| 1 | Define the grouping question and choose features |
| 2 | Measure distance and standardise features |
| 3 | Understand and run K-means |
| 4 | Compare plausible K values and check limitations |
| 5 | Profile customers and propose business actions |
| 6 (Optional) | Read a hierarchical tree — not covered in the final exam |
Business Groups, Simple Rules, and Features
Define why a group would be useful before asking an algorithm to create one.
The Distributor’s Question
Business question: which wholesale customers have similar buying patterns, so the distributor can plan suitable product bundles?
One row = one customer’s annual spending record. The table contains 440 customers. A row summarises a whole year; it is not one shopping transaction or a count of items.
| Column(s) | What the values mean |
|---|---|
Channel |
Customer type: 1 = hotel/restaurant/café (Horeca); 2 = retail |
Region |
Customer location: 1 = Lisbon; 2 = Porto (Oporto); 3 = other regions |
Fresh, Milk, Grocery, Frozen |
Annual spending on fresh products, milk products, groceries and frozen products |
Detergents_Paper, Delicassen |
Annual spending on detergents/paper products and delicatessen products |
The six spending columns use monetary units (m.u.); the source does not specify a currency. Channel and Region are category codes, not spending amounts or ordered scores.
Read the first row: Channel = 2 and Region = 3 identify a retail customer in another region. Fresh = 12,669 means annual fresh-product spending of 12,669 m.u. The preview shows four of the eight columns.
The supplied CSV has eight columns: two categorical codes and six annual spending measures. Its row index is a display position, not a feature for clustering. UCI category frequencies match the file codes: Channel 1 has 298 Horeca customers and Channel 2 has 142 retail customers; Region 1 has 77 Lisbon records, Region 2 has 47 Porto records and Region 3 has 316 other-region records. The course clusters the six spending features and uses Channel/Region afterwards for interpretation. The distributor planning question is an illustrative scenario; the annual records do not provide individual transaction dates or item quantities.
Sources: UCI Wholesale Customers: data and variable descriptions
Clustering Has No Response Y
Chapter 3 regression uses an outcome Y and predictors X. Clustering uses selected features X to form groups without fitting a known outcome. The resulting cluster numbers are created by the algorithm.
Chapters 1 and 2 taught data handling, rather than themselves being supervised-learning algorithms. Channel and Region may be used later to interpret clusters, but they are excluded from this spending-based feature matrix.
Start with a Simple Business Rule
A simple business rule separates customers whose annual Grocery spending is at least 10,000 monetary units (m.u.) from the others. m.u. means monetary units: the dataset does not specify a currency, so this is not an asserted HKD or USD amount. The rule uses one spending variable and a chosen cutoff; it may miss other purchase patterns.
Render the Rule’s Boundary
The vertical line is the chosen Grocery cutoff. Customers with very different Milk spending can be placed together by this one-variable rule. Change the cutoff to see how membership changes.

Choose Features That Define Similarity
Step 1 — choose what counts as similar. Use the six annual spending columns. Exclude identifiers, Channel, Region and earlier Cluster labels.
Step 2 — put those features on comparable scales. StandardScaler() creates a scaling tool; scaler.fit_transform(wholesale[features]) learns each column’s mean and standard deviation, then standardises its values. The result X is the numerical input for clustering.
Why? Distance-based clustering can be dominated by columns with larger numerical spreads—even when all columns use the same currency. Standardisation measures each gap in standard-deviation units. The next two slides show how.
Integer channel codes represent categories, not measured distances. Check missing values, duplicates and impossible negative spending first. Creating StandardScaler() alone does not change any data: fit_transform performs the calculation. The original wholesale table remains in monetary units, which we use later to interpret cluster profiles.
StandardScaler: One Calculation, Column by Column
Example: four customers have Income = [40,000, 60,000, 40,000, 60,000] and Score = [40, 40, 60, 60].
\[z=\frac{x-\text{column mean}}{\text{column standard deviation}}\]
| Feature | Mean | SD used by the scaler | Example after scaling |
|---|---|---|---|
| Annual Income (m.u.) | 50,000 | 10,000 | 60,000 → (60,000 − 50,000) / 10,000 = 1 |
| Spending Score (points) | 50 | 10 | 40 → (40 − 50) / 10 = −1 |
Read the new values: 0 = at the column mean; +1 = one SD above; −1 = one SD below. Each varying column now has mean 0 and SD 1. These are unit-free values, not percentages or values restricted to 0–1.
The scaler works separately on each column, not across each customer row. It uses population standard deviation (ddof=0) across the supplied records. In this constructed example the squared Income deviations average 100,000,000, so SD = 10,000; Score SD = 10. Standardisation changes the units and spread, not the rank of customers within each feature. It does not force a normal distribution.
Sources: StandardScaler documentation
Why Scaling Changes Who Looks Close
Compare A = (40,000, 40) with B = (60,000, 40) and C = (40,000, 60). Use the column standard deviations from the previous example.
The point: K-means uses distances to assign customers to groups. Rescaling can change these distances and therefore the groups. In ISOM2600, always standardise the selected features with StandardScaler before fitting clustering models. This measures gaps relative to each feature’s spread; the business question still determines which features to include.
For these comparisons only one coordinate differs, so Euclidean distance equals that coordinate gap in absolute value. The raw bars share one scale within the left panel; the standardised bars share a different scale within the right panel. Standardisation is a required preprocessing step for clustering in ISOM2600. It does not guarantee meaningful clusters, remove outliers or make an irrelevant feature useful. Section 2 explains the distance formulas and standardisation calculation.
The Clustering Workflow
Define the business question → select features and inspect records → standardise features → choose K for K-means → fit, inspect and profile groups → test business actions.
Check whether the groups are distinct, stable and useful; revisit earlier choices when needed. A clustering result alone does not prove there are meaningful customer segments.
For K-means: specify the number of groups K before each fit. To compare K=3 and K=4, run the algorithm separately for each choice.
In-Class Practice: Improve the Rule
Try a Grocery cutoff of 8,000. Count both groups and compare their average Milk spending. Explain which feature the rule still ignores.
Section 1 Practice — Business Groups, Simple Rules, and Features
Work from question 1 to 10. Edit your Python box, click Run Code, review the result, then click Submit attempt. Reference answers unlock after all ten attempts are submitted. This records completion, not correctness. Each box runs independently. Progress is saved in this browser.
1. Count customers
Warm-up. Print the number of customer rows.
Write your answer, then run it.
2. Select spending features
Warm-up. Print the first two rows of the six spending columns.
Write your answer, then run it.
3. Identify coded categories
Warm-up. Print the unique Channel codes.
Write your answer, then run it.
4. Apply a simple rule
Build your skills. Count customers with Grocery at least 10,000 m.u.
Write your answer, then run it.
5. Count both rule groups
Build your skills. Print counts for both sides of the Grocery rule.
Write your answer, then run it.
6. Profile a rule
Build your skills. Print average Milk spending for both Grocery-rule groups.
Write your answer, then run it.
7. Change the rule
Build your skills. Count customers classified high at cutoffs 8,000 and 12,000.
Write your answer, then run it.
8. Check feature completeness
Challenge. Print the total missing entries in the selected feature table.
Write your answer, then run it.
9. Compare distribution spread
Challenge. Print the mean and sample SD of Fresh and Grocery.
Write your answer, then run it.
10. Separate inputs from interpretation
Challenge. Print whether Channel or Region appears in features; add a sentence about their later use.
Write your answer, then run it.
Reference answers are locked until all 10 attempts are submitted.
1. Count customers
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
print(len(wholesale))Expected output
440The UCI snapshot contains 440 customer records.
2. Select spending features
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
print(wholesale[features].head(2).to_string(index=False))Expected output
Fresh Milk Grocery Frozen Detergents_Paper Delicassen
12669 9656 7561 214 2674 1338
7057 9810 9568 1762 3293 1776Only the selected spending measures define similarity.
3. Identify coded categories
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
print(sorted(wholesale["Channel"].unique().tolist()))Expected output
[1, 2]Channel is a coded category, not an annual spending amount.
4. Apply a simple rule
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
print(int((wholesale["Grocery"] >= 10000).sum()))Expected output
117The threshold is a business rule, not an estimated cluster boundary.
5. Count both rule groups
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
rule = wholesale["Grocery"] >= 10000
print(rule.value_counts().sort_index())Expected output
Grocery
False 323
True 117
Name: count, dtype: int64Every customer is assigned using this single cutoff.
6. Profile a rule
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
wholesale["HighGrocery"] = wholesale["Grocery"] >= 10000
print(pd.Series([
wholesale.loc[wholesale["HighGrocery"] == False, "Milk"].mean(),
wholesale.loc[wholesale["HighGrocery"] == True, "Milk"].mean()
], index=["Below 10000", "At least 10000"]).round(1))Expected output
Below 10000 3268.6
At least 10000 12774.2
dtype: float64Another feature helps reveal what the simple grouping contains.
7. Change the rule
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
print(int((wholesale["Grocery"] >= 8000).sum()))
print(int((wholesale["Grocery"] >= 12000).sum()))Expected output
148
90A higher cutoff cannot increase the high-group count.
8. Check feature completeness
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
print(int(wholesale[features].isna().sum().sum()))Expected output
0A complete snapshot does not eliminate the need for checks on other datasets.
9. Compare distribution spread
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
spending = wholesale[["Fresh", "Grocery"]]
summary = pd.DataFrame([spending.mean(), spending.std()], index=["mean", "std"])
print(summary.round(1))Expected output
Fresh Grocery
mean 12000.3 7951.3
std 12647.3 9503.2Features with the same monetary units can still have very different spreads. Use the familiar mean() and std() methods separately.
10. Separate inputs from interpretation
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
print("Channel" in features, "Region" in features)
print("Use these categories after clustering to interpret the spending-based groups.")Expected output
False False
Use these categories after clustering to interpret the spending-based groups.Do not accidentally feed interpretation labels into this feature matrix.
Distance Measures and Standardisation
Calculate distances and standardise selected features before clustering.
Why Clustering Needs a Distance
To group similar customers, we need a numerical way to compare them. A distance converts differences in the selected features into one number: smaller means more similar under those features and scales.
For the example customer coordinates A=(1,1), B=(4,5), C=(1,2), Euclidean distance gives A–B=5 and A–C=1. C is closer to A than B is.
How clustering uses this number: K-means compares each customer with current centres; hierarchical clustering starts with customer-pair distances and uses a linkage rule to compare groups.
Changing the features or their scales can change who looks close. The next slide shows how to calculate the distance.
A distance is a numerical dissimilarity in the chosen feature space, not a claim that customers are similar in every business respect. These three points use illustrative feature values on comparable scales.
Euclidean Distance: A Straight-Line Gap
A and B are two customers; each customer has two features. These are illustrative feature values on comparable scales.
| Customer | Feature 1 (horizontal axis) | Feature 2 (vertical axis) |
|---|---|---|
| A | 1 | 1 |
| B | 4 | 5 |
Thus A = (1, 1) and B = (4, 5). Each pair lists one customer’s Feature 1 and Feature 2 values.
\[d(A,B)=\sqrt{(4-1)^2+(5-1)^2}=\sqrt{3^2+4^2}=5\]
Square the difference in each feature, add the squared differences, then take the square root. With more features, include one squared difference for every feature.
A row represents a customer and a column represents a feature. In a two-feature plot, one customer becomes one point. The straight segment joins those two customer points; the dashed horizontal and vertical gaps form a right triangle. The Pythagorean theorem gives its diagonal length. Feature scales matter: raw gaps in different units can dominate the distance, as the preceding scaling example showed.
A Customer Distance Illustration
Consider two customers described by Age (years) and Income (thousands of monetary units): A=(35,60) and B=(28,80). The right triangle shows coordinate gaps of 7 years and 20 thousand monetary units. The raw Euclidean distance is about 21.19 in this mixed-unit feature space.
Manhattan Distance: Add Absolute Gaps
Return to A=(1,1) and B=(4,5) from our first distance example. Manhattan distance adds the absolute coordinate gaps: |4−1|+|5−1|=3+4=7, compared with Euclidean distance 5. Distance choice matters. Standard K-means optimises squared Euclidean distances; changing to Manhattan is not merely a KMeans parameter choice.
A Larger Numerical Scale Can Dominate
Income is in currency units; Age is years; Score is an illustrative spending score from 1 to 100. The Income difference dominates a raw distance even if we want all three features to matter.
StandardScaler: Centre and Scale Each Feature
For each feature, subtract its mean and divide by its standard deviation:
\[z_i=\frac{x_i-\bar{x}}{s_0},\qquad s_0=\sqrt{\frac{\sum_{i=1}^{n}(x_i-\bar{x})^2}{n}}\quad(\text{ddof}=0).\]
Why divide by n, not n − 1? Here we are rescaling the observed data, not estimating an unknown population variance. Using n makes the standardised column’s mean 0 and its SD 1 when checked with the same ddof=0 convention.
- Statistics: sample variance uses n − 1 to correct bias when estimating population variance from a random sample.
- Preprocessing:
StandardScaleruses n as its scaling convention. This does not mean our customers are the entire population. - In pandas:
.std()defaults toddof=1; use.std(ddof=0)when checkingStandardScaler.
fit_transform learns each column’s mean and scale, then transforms its values.
The general denominator is n − ddof: ddof=0 means divide the sum of squared deviations by n; ddof=1 means divide it by n−1. The sample-SD formula is \(s_1=\sqrt{\sum(x_i-\bar{x})^2/(n-1)}\). It is the sample variance \(s_1^2\), not generally the sample SD itself, that is unbiased under the usual random-sample assumptions. With five rows, values scaled using \(s_0\) have .std(ddof=0) = 1 but .std() = \(\sqrt{5/4}\approx1.118\). Both outputs can be correct because their denominators differ. If every feature uses the same n complete rows, changing all columns from ddof=0 to ddof=1 multiplies every standardised coordinate by the same \(\sqrt{(n-1)/n}\); relative Euclidean distances and the mathematical K-means partition objective are unchanged apart from a common scale. Use the library convention consistently.
Sources: StandardScaler documentation
Scaler Attributes and Methods
scaler.mean_ and scaler.scale_ are fitted attributes. .fit_transform(...), .transform(...), and .inverse_transform(...) are methods. The trailing underscore convention marks values learned during fitting.
Render Before and After Scaling
The left panel shows the very different raw numerical ranges; the right panel shows standardised values. In ISOM2600, use the standardised features for clustering. Scaling does not make every feature equally useful.

Interactive Distance: Before and After Standardisation
Compare A–B with A–C before and after standardisation. The raw calculation is a diagnostic showing how numerical scales can dominate distance. For clustering in ISOM2600, use the standardised features.
Calculate Distances After Standardisation
Use the same formula on the standardised rows. A “closer” customer is closer under the selected features and scaling, not universally more similar.
Transform New Records with the Fitted Scaler
.transform() uses the existing means and scales. Fitting a new scaler on each new customer would destroy comparability. Use .inverse_transform() to recover centroids in business units.
In-Class Practice: Check the SD Convention
Print standardised means, .std(), and .std(ddof=0). Explain why the two SD outputs differ even though the scaling worked correctly.
Section 2 Practice — Distance Measures and Standardisation
Work from question 1 to 10. Edit your Python box, click Run Code, review the result, then click Submit attempt. Reference answers unlock after all ten attempts are submitted. This records completion, not correctness. Each box runs independently. Progress is saved in this browser.
1. Calculate one coordinate gap
Warm-up. Print the Income difference A minus B.
Write your answer, then run it.
2. Calculate raw Euclidean distance
Warm-up. As a before-scaling diagnostic, print the raw A–B distance rounded to three decimals. Clustering uses standardised features.
Write your answer, then run it.
3. Calculate Manhattan distance
Warm-up. As a before-scaling diagnostic, print raw A–B Manhattan distance. Clustering uses standardised features.
Write your answer, then run it.
4. Check standardised means
Build your skills. Print the three standardised means rounded to six decimals.
Write your answer, then run it.
5. Check population SD
Build your skills. Print standardised population SDs.
Write your answer, then run it.
6. Compare SD conventions
Build your skills. Print sample and population SD of standardised Age.
Write your answer, then run it.
7. Calculate standardised distance
Build your skills. Print A–B Euclidean distance on the standardised features.
Write your answer, then run it.
8. Compare two neighbours
Challenge. Print standardised A–B and A–C distances.
Write your answer, then run it.
9. Transform a new customer
Challenge. Use the existing scaler for Age=30, Income=50000, Score=70 and print the result.
Write your answer, then run it.
10. Reconstruct a scaling calculation
Challenge. Manually standardise Income with ddof=0; print whether it matches the scaled Income column.
Write your answer, then run it.
Reference answers are locked until all 10 attempts are submitted.
1. Calculate one coordinate gap
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
print(customers.loc["A", "Income"] - customers.loc["B", "Income"])Expected output
-20000Income gaps use the original currency-unit scale.
2. Calculate raw Euclidean distance
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
print(round(np.sqrt(((customers.loc["A"] - customers.loc["B"]) ** 2).sum()), 3))Expected output
20000.004Square all coordinate differences, sum, then take the root.
3. Calculate Manhattan distance
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
print(int(np.abs(customers.loc["A"] - customers.loc["B"]).sum()))Expected output
20017This sums absolute gaps rather than squared gaps.
4. Check standardised means
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
print(scaled.mean().round(6))Expected output
Age 0.0
Income 0.0
Score -0.0
dtype: float64Each varying feature is centred on zero, allowing floating-point rounding.
5. Check population SD
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
print(scaled.std(ddof=0).round(3))Expected output
Age 1.0
Income 1.0
Score 1.0
dtype: float64This matches StandardScaler's convention.
6. Compare SD conventions
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
print(round(scaled["Age"].std(), 3), round(scaled["Age"].std(ddof=0), 3))Expected output
1.118 1.0The sample denominator gives sqrt(5/4), not 1.
7. Calculate standardised distance
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
print(round(np.sqrt(((scaled.loc["A"] - scaled.loc["B"]) ** 2).sum()), 3))Expected output
1.335The same records now use standard-deviation units.
8. Compare two neighbours
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
print(round(np.sqrt(((scaled.loc["A"] - scaled.loc["B"]) ** 2).sum()), 3))
print(round(np.sqrt(((scaled.loc["A"] - scaled.loc["C"]) ** 2).sum()), 3))Expected output
1.335
3.36Similarity is conditional on the selected weighting and features.
9. Transform a new customer
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
new = pd.DataFrame({"Age": [30], "Income": [50000], "Score": [70]})
print(scaler.transform(new).round(3))Expected output
[[-0.597 -0.174 0.159]]Do not fit a separate scaler to the new record.
10. Reconstruct a scaling calculation
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
manual = (customers["Income"] - customers["Income"].mean()) / customers["Income"].std(
ddof=0
)
print(np.allclose(manual, scaled["Income"]))Expected output
TrueManual and library calculations agree when the SD conventions match.
K-Means: Assign, Average, Repeat
Understand the steps with six points before fitting the larger customer table.
A Centroid Is a Coordinate-Wise Mean
For three points shown in prepared illustrative coordinates, (20,30), (30,50), and (40,40), the centroid is (30,40). It need not be an observed customer. A centroid is the coordinate-wise mean; Python clustering fits use standardised features.
The K-Means Algorithm
The final centroids are not known at the start. To find a group mean, we need to know its members; to assign members, we need centres. K-means solves this by repeating two operations: assign customers → recompute group means.
This is an iterative algorithm: new centres lead to new assignments, which lead to new centres. Keep K fixed during one fit. Follow the loop in the original lecture diagram, then work through the four illustrated steps below.
Manual diagrams use prepared schematic coordinates to show the algorithm. Python model fits always standardise the input features first.
This flow diagram is from the original lecture PPT, page16. The following four pages retain the teaching sequence of PPT pages18–21, using the six-customer coordinates from our interactive example. Iteration means repeating assignment and update, not recursion through a function calling itself. In software, convergence tolerance and an iteration limit also govern stopping; convergence does not prove a globally best clustering.
K-Means Step 1: Choose Initial Centroids
Choose K=2 first. Each dot is a customer with two features. Start with centre 0 at A=(1,1) and centre 1 at C=(2,1). The grey customers have not yet been assigned.
These are starting guesses, not the final group means. We deliberately start both centres in the lower-left cloud so we can see why repeated updating is necessary. The software can choose starting centres automatically; here we set them for illustration.
Adapted from the original PPT four-step K-means teaching sequence. Diagrams are recomputed from the same coordinates and memberships as the six-stage interactive demonstration. These are classroom features on comparable scales. These are prepared schematic coordinates for hand calculations; they do not replace the StandardScaler step in a Python model fit.
K-Means Step 2: Assign to the Nearest Centroid
Keep the centres fixed while assigning all customers. Compare each customer’s distance to both centres. Colour the customer according to the nearer centre.
The first assignment is A,B → group 0 and C,D,E,F → group 1. C is at centre 1, so its distance to that centre is zero.
Do not move a centre after assigning just one customer. Finish this assignment step first; then compute both group means.
Adapted from the original PPT four-step K-means teaching sequence. Diagrams are recomputed from the same coordinates and memberships as the six-stage interactive demonstration. These are classroom features on comparable scales. These are prepared schematic coordinates for hand calculations; they do not replace the StandardScaler step in a Python model fit.
K-Means Step 3: Recompute the Centroids
Keep memberships fixed and average each feature within each group. The hollow circles mark the old centres; arrows point to the new means. Customer dots stay where they are.
Group 0 contains A and B:
\[\mu_0=\left(\frac{1+1}{2},\frac{1+2}{2}\right)=(1,1.5).\]
Group 1 contains C, D, E and F:
\[\mu_1=\left(\frac{2+7+8+8}{4},\frac{1+7+7+8}{4}\right)=(6.25,5.75).\]
Adapted from the original PPT four-step K-means teaching sequence. Diagrams are recomputed from the same coordinates and memberships as the six-stage interactive demonstration. These are classroom features on comparable scales. These are prepared schematic coordinates for hand calculations; they do not replace the StandardScaler step in a Python model fit.
K-Means Step 4: Reassign, Update, and Repeat
Moving a centre can change which centre is closest. With the updated centres, C is 1.118 from centre 0 and 6.374 from centre 1. C therefore switches from group 1 to group 0 (outlined in gold).
Compute the means again: A,B,C now give centre 0=(1.333,1.333); D,E,F give centre 1=(7.667,7.333). The centre update from Step 3 was not the final answer.
Check the assignments once more. Here nobody switches, so another mean calculation would give the same centres: stop. Other datasets may need more rounds.
Assign → update → assign → update → … → stable assignments and centres.
Adapted from the original PPT four-step K-means teaching sequence. Diagrams are recomputed from the same coordinates and memberships as the six-stage interactive demonstration. These are classroom features on comparable scales. These are prepared schematic coordinates for hand calculations; they do not replace the StandardScaler step in a Python model fit.
Assignment Uses the Nearest Centroid
For any assignment step, compare distances to every current centre. Use the same feature scales as the clustering fit. The point joins the nearest centre; cluster numbers only name groups.
The following Python calculation is a separate distance example with P=(2,1), centre 0=(1,1) and centre 1=(8,8). Squared distances are 1 and 85, so P joins group 0. Squaring preserves which nonnegative distance is smaller.
Compute One Assignment in Python
For point P=(2,1) and centres (1,1) and (8,8), squared distances are 1 and 85. argmin() gives the zero-based position of the smaller value, so P joins cluster 0.
Recompute Each Centre with .loc and .mean()
For this assignment, A/B/C form cluster 0 and D/E/F form cluster 1. Select each cluster separately with .loc, then use .mean() to calculate its coordinate means. Put the two mean rows in a DataFrame: these are the updated centres.
Interactive K-Means: Assign and Update
Press Play stages to watch initial centres → assignments → mean updates → stable assignments. Pause to explain a stage, use Next step to advance manually, or Reset steps to replay. Start with two centres in the same cloud. Watch customer C switch groups after the first update; update again, then check that assignments stay stable.
Fit KMeans in Python
First standardise X1 and X2 with StandardScaler. Create an estimator with K=2, a fixed random seed, and 10 initialisations. .fit_predict(X) fits the model to those standardised coordinates and returns one label per customer. The printed centres are also in standard-deviation units.
Sources: KMeans documentation
KMeans Attributes and Methods
Fitted attributes: .labels_, .cluster_centers_, .inertia_, .n_iter_. Methods: .fit(X), .fit_predict(X), .predict(new_X). Cluster IDs start at 0 in KMeans and are arbitrary labels, not ranks.
A seed makes a fit reproducible within the chosen implementation, data order, and settings. Different versions can change label numbering or details. Relabelling 0 as 1 does not change which observations belong together.
Render Fitted Groups and Centres
The model is fitted on standardised features. For this plot, scaler.inverse_transform(...) converts its centres back to the original X1/X2 coordinates, so stars and customer points use the same axes. Colour represents membership, not quality or profitability.

Assign a New Record
.predict() assigns a new point to a fitted centre; it does not update the centres. Transform the new record using the same fitted scaler before prediction. Do not fit a new scaler to it.
In-Class Practice: Explain the Object
Print the shape of .cluster_centers_, count members per cluster, and assign (7.5,7.5) after transforming it with the fitted scaler. Explain which operation would retrain the centres.
Section 3 Practice — K-Means: Assign, Average, Repeat
Work from question 1 to 10. Edit your Python box, click Run Code, review the result, then click Submit attempt. Reference answers unlock after all ten attempts are submitted. This records completion, not correctness. Each box runs independently. Progress is saved in this browser.
1. Count labels
Warm-up. Print the number of fitted labels.
Write your answer, then run it.
2. Read centre shape
Warm-up. Print the centre array shape.
Write your answer, then run it.
3. Count group sizes
Warm-up. Print sorted cluster membership counts.
Write your answer, then run it.
4. Calculate a manual centroid
Build your skills. Print the coordinate means for A,B,C.
Write your answer, then run it.
5. Read the objective
Build your skills. Print inertia rounded to three decimals.
Write your answer, then run it.
6. Assign a new nearby point
Build your skills. Transform (1.5,1.5) with the fitted scaler, then predict its cluster.
Write your answer, then run it.
7. Check a new point manually
Build your skills. Transform (1.5,1.5) with the fitted scaler. Calculate its squared distances to both standardised centres, then print the nearest-centre index.
Write your answer, then run it.
8. Verify group means
Challenge. Print whether the original-coordinate group means equal the inverse-transformed centres in label order.
Write your answer, then run it.
9. Reconstruct WCSS
Challenge. Use each standardised row in X and its assigned centre to reconstruct WCSS.
Write your answer, then run it.
10. Distinguish assignment and refitting
Challenge. Save centres, predict a new point, and print whether centres stayed unchanged.
Write your answer, then run it.
Reference answers are locked until all 10 attempts are submitted.
1. Count labels
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
print(len(km.labels_))Expected output
6There is one label per training point.
2. Read centre shape
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
print(km.cluster_centers_.shape)Expected output
(2, 2)K=2 and two features gives a 2 by 2 array.
3. Count group sizes
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
print(points["Cluster"].value_counts().sort_index())Expected output
Cluster
0 3
1 3
Name: count, dtype: int64Label values are arbitrary; counts describe the fitted partition.
4. Calculate a manual centroid
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
print(points.loc[["A", "B", "C"], ["X1", "X2"]].mean().round(3))Expected output
X1 1.333
X2 1.333
dtype: float64A centre is the mean of assigned coordinates.
5. Read the objective
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
print(round(km.inertia_, 3))Expected output
0.275Inertia is WCSS, a sum of squared Euclidean gaps.
6. Assign a new nearby point
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
new = pd.DataFrame({"X1": [1.5], "X2": [1.5]})
print(km.predict(scaler.transform(new)).tolist())Expected output
[0]Prediction uses the fitted scaler and the existing standardised centres.
7. Check a new point manually
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
new = pd.DataFrame({"X1": [1.5], "X2": [1.5]})
p = scaler.transform(new)[0]
d2 = ((km.cluster_centers_ - p) ** 2).sum(axis=1)
print(d2.round(3).tolist())
print(int(d2.argmin()))Expected output
[0.006, 7.4]
0This reproduces the nearest-centre decision in the standardised fitting space.
8. Verify group means
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
means = pd.DataFrame([
points.loc[points["Cluster"] == 0, ["X1", "X2"]].mean(),
points.loc[points["Cluster"] == 1, ["X1", "X2"]].mean()
], index=[0, 1])
print(np.allclose(means.to_numpy(), scaler.inverse_transform(km.cluster_centers_)))Expected output
TrueThe means and inverse-transformed centres correspond to each final group in the same original-coordinate units.
9. Reconstruct WCSS
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
assigned = km.cluster_centers_[km.labels_]
wcss = ((X - assigned) ** 2).sum()
print(round(wcss, 3))Expected output
0.275The reconstructed value matches inertia_.
10. Distinguish assignment and refitting
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
before = km.cluster_centers_.copy()
km.predict(scaler.transform(pd.DataFrame({"X1": [20], "X2": [20]})))
print(np.allclose(before, km.cluster_centers_))Expected output
Truepredict does not retrain even when the new point is far away.
Choosing K and Checking the Result
Compare fit, separation, stability, and business usefulness rather than treating one plot as proof.
WCSS: What Does Each Symbol Mean?
WCSS = Within-Cluster Sum of Squares. Measure how tightly customers sit around their own group centres.
\[\mathrm{WCSS}=\sum_{i=1}^{n}\sum_{j=1}^{p}\left(x_{ij}-\mu_{c_i,j}\right)^2\]
| Symbol | Meaning in our customer data |
|---|---|
| \(n\) and \(i\) | n customers in total; i identifies one customer (one row) |
| \(p\) and \(j\) | p selected features; j identifies one feature (one column) |
| \(x_{ij}\) | Customer i’s value of feature j, in the scale used for clustering |
| \(c_i\) | The cluster assigned to customer i; with KMeans, one of 0, …, K−1 |
| \(\mu_{c_i,j}\) | Feature j of customer i’s assigned centre: the mean in that cluster |
| \((x_{ij}-\mu_{c_i,j})^2\) | Squared gap for this customer and this feature |
| \(\sum_j\) then \(\sum_i\) | Add across features for one customer, then across all customers |
Only use the assigned centre: do not add distances from a customer to every centre. K is the number of clusters; each customer belongs to one of them.
The shorter vector notation is \(\mathrm{WCSS}=\sum_{i=1}^{n}\lVert\mathbf{x}_i-\boldsymbol\mu_{c_i}\rVert^2\). Here \(\mathbf{x}_i\) is customer i’s vector of p features, \(\boldsymbol\mu_{c_i}\) is its assigned cluster’s centre vector, and the squared Euclidean norm means the sum of the p squared coordinate differences. There is no final square root: WCSS sums squared distances. For fixed centres, assign each customer to the nearest centre; for fixed assignments, choosing each group mean minimises its squared gaps. These are the two alternating K-means operations.
Calculate WCSS: Square Each Assigned Gap
Within-Cluster Sum of Squares (WCSS) adds the squared distances from customers to their assigned centres. The diagram uses one group and one feature: its centre is 0, and WCSS = 4 + 1 + 9 = 14.
The Python code applies exactly the same rule to our earlier six-customer, two-feature K-means fit. assigned selects the correct centre for each row; subtract, square and sum across all rows and features. The result equals km.inertia_.
Smaller WCSS means more compact groups in the same feature space. Raw and standardised WCSS use different scales, so do not compare their numbers directly. More groups can reduce WCSS without making the segmentation more useful.
The best possible WCSS is nonincreasing as K rises, because an extra centre cannot worsen the optimum. Separately fitted heuristic KMeans runs can land in local solutions, so observed fits need not follow the ideal trend perfectly. WCSS alone does not penalise adding groups.
Explore the Simulated Mall Customers
The original notebook creates 200 customers with Age, income in thousands of illustrative currency units, and a Spending Score. We retain its records, but omit its known construction labels from the feature table.
These records are constructed, not a sample from an identified mall. The visible patterns make algorithm behaviour easy to study; they do not establish how many real customer segments a mall has.
Render an Elbow Plot
Fit several K values and plot WCSS. A bend suggests diminishing improvement, but may be unclear or suggest several plausible choices. Compare neighbouring K values with business profiles and group sizes.

Interactive K: Compare the Partition
Choose K=2,3,4,5, or 6 for the same standardised mall features. Compare group counts, WCSS, and centre positions in the original units. More colours do not automatically mean a better business segmentation.
Extension: Separation with a Silhouette Score
A silhouette score compares within-group and nearest-other-group distances. Values near +1 suggest separation; near 0 suggest boundary overlap; negative values suggest a point closer to another group. The mean is one diagnostic, not a unique answer for K.
The score needs at least two represented groups and fewer groups than observations. It depends on the chosen features, scaling, and distance. A high score does not prove that clusters match business types.
Sources: Silhouette score
Render Neighbouring Choices
These three fits use the same standardised features and show the original-unit coordinates. K=3 merges patterns that K=5 separates; K=6 subdivides a pattern. Decide whether those distinctions support different actions.

Initialisation and Stability
Initialisation = choosing the K starting centres before assigning customers. The assign → update cycle improves a starting solution; different starts can end at different solutions. The first centres can therefore affect the final result.
init="k-means++" chooses starting centres that tend to be spread out. It still uses randomness; it does not guarantee the best possible solution.
n_init=10 means 10 complete attempts on the same data with the same K, each beginning from an initial centre configuration. Each attempt can contain many assign/update iterations. Keep the completed attempt with the smallest WCSS. It does not mean 10 clusters or 10 updates.
Read the code: K = 5 groups; 10 starts per fit. Fit A uses seed 42; fit B uses seed 7. Compare their final WCSS, then inspect the memberships and profiles.
K-means alternates nearest-centre assignment and coordinate-mean updates. This improves its within-cluster objective, but the objective is not jointly convex in assignments and centres: convergence does not establish the global minimum. Multiple starts reduce the chance of relying on one poor local solution. The fitted .n_iter_ reports the iterations used by the selected run, whereas n_init controls how many starts are tried. Explicit n_init=10 avoids relying on changing library defaults. With the same input order, library/environment and seed, results are reproducible; a fixed seed does not make the result optimal. Different seeds may still converge to the same solution.
Sources: KMeans parameters
Reproducible Does Not Mean Stable
Reproducible: repeat the same procedure with the same data, settings and random seed, and obtain the same result. random_state=42 makes this convenient; 42 is not a special or better seed.
Stable: the main customer patterns remain similar under reasonable changes. A fixed seed alone cannot demonstrate this.
| Check | What to look for |
|---|---|
| Change the seed; keep data, scaling and K fixed | Similar WCSS, group sizes, memberships and original-unit profiles |
| Compare nearby K values | Which groups split or merge? Do the extra groups support a useful action? |
| Keep features standardised and inspect extreme records | Are the conclusions driven by units or a few unusual customers? |
Compare memberships, not ID numbers. A group called 0 in one run can be called 2 in another with exactly the same customers. Similar WCSS alone also does not prove the memberships are the same.
If the substantive patterns change sharply, describe the segmentation as sensitive and investigate before using it for business decisions.
Stability is an empirical assessment, not a guarantee of future validity. Changing K deliberately changes the number of groups, so identical membership is not the goal of that check; inspect the persistence of meaningful profiles and the consequences of splitting or merging. A justified change of scaling changes the definition of similarity, so explain any interpretation changes. Formal resampling and partition-comparison scores are optional extensions, not required for this beginner lesson.
Outliers and Shape Limitations
An outlier is a customer far from most other customers in the chosen feature space. K-means uses means and squared distances, so unusual values can have a large influence.
Why the square matters: a distance of 10 contributes 100 to WCSS; a distance of 1 contributes only 1. One far-away point can therefore outweigh many small gaps.
Our example: A–F originally form two compact groups. Add G=(25,25) and keep K=2. Standardise both features before fitting. In this fit, one centre is used for G alone, while A–F are combined. The plot converts centres back to the original coordinates. K-means has used a scarce centre to reduce the large squared error from G.
Check before acting: is G a data-entry error or a genuine unusual customer? Correct verified errors; investigate valid extremes. StandardScaler does not remove outliers, and unusual does not automatically mean wrong.
The illustration holds the six original coordinates and K=2 fixed, adding just G=(25,25). Before adding G, each cloud has three members. After fitting with the same random_state=42 and n_init=10, G forms its own group and the other six form another. Depending on its position, an outlier can instead pull a shared group mean toward itself. Both effects follow from minimising squared gaps. This is sensitivity of the model to the chosen data, not evidence that G is fraudulent or unimportant.

Why Cluster Shape Matters for K-Means
K-means asks: which centre is closest? It represents each group by one mean point. This works naturally for compact, roughly round groups in the selected feature space.
Long or curved groups: points far apart along one meaningful shape may be nearer to another centre. K-means can split that shape or join parts of different shapes. Its nearest-centre boundaries are straight in two dimensions, so they cannot trace arbitrary curved groups.
Different spreads or sizes: a broad group can contribute many large squared gaps. K-means may split it to reduce WCSS, even when a smaller nearby group then gets merged with another.
What to do: inspect a scatter plot and the resulting memberships, check feature scaling, and consider another clustering method if the geometry does not match the business question. Increasing K may reduce WCSS without solving the interpretation problem.
The round-group description is a geometric tendency of the squared-Euclidean, one-centre representation, not a probabilistic assumption of exactly spherical normal populations. Similar size and spread often make examples easier for K-means, but unequal size does not guarantee failure. The diagrams show intended shapes in constructed examples, not fitted outcomes or established real customer types. A two-dimensional projection is only a partial view when clustering uses more features.
Choose a Useful, Defensible K
There is no single number that chooses K for us. Compare several candidates using the same customers, features and scaling. Then explain why the chosen groups are useful.
Read this example: moving from K=4 to K=5 reduces WCSS by about 36.94; moving from K=5 to K=6 reduces it by only 4.19. That makes K=5 worth examining, but does not prove it is the right answer.
Ask three questions: Are groups reasonably compact? What customer differences do they describe? Can we use those differences? For example, if two new groups would receive exactly the same offer, the extra split may add little value.
A clear conclusion would be: “We will try K=5 because it captures useful income/score differences without the extra split at K=6. We will check the profiles and stability before using the groups.” This is a proposed decision for the simulated example, not a verified business result.
The original mall construction has five designed patterns, which helps illustrate the algorithm. That construction fact is not evidence that K=5 will be right for the wholesale case or a new customer database.
In-Class Practice: Compare K=4 and K=5
Fit both choices, compare WCSS and membership counts, and describe a tradeoff. Keep the features and scaling identical.
Section 4 Practice — Choosing K and Checking the Result
Work from question 1 to 10. Edit your Python box, click Run Code, review the result, then click Submit attempt. Reference answers unlock after all ten attempts are submitted. This records completion, not correctness. Each box runs independently. Progress is saved in this browser.
1. Read feature shape
Warm-up. Print X.shape.
Write your answer, then run it.
2. Fit a candidate K
Warm-up. Fit K=3 and print inertia.
Write your answer, then run it.
3. Count a candidate’s groups
Warm-up. Fit K=3 and print membership counts.
Write your answer, then run it.
4. Compare WCSS at two K values
Build your skills. Print WCSS for K=3 and K=5.
Write your answer, then run it.
5. Create elbow values
Build your skills. Print WCSS for K=1,2,3,4.
Write your answer, then run it.
6. Calculate a silhouette
Build your skills. Fit K=5 and print the mean silhouette score.
Write your answer, then run it.
7. Compare seeds
Build your skills. Print K=5 inertia with seeds 42 and 7.
Write your answer, then run it.
8. Inspect a small group
Challenge. Fit K=6 and print the smallest cluster size.
Write your answer, then run it.
9. Calculate average squared distance
Challenge. Fit K=5 on the standardised features X. Print WCSS and the mean squared distance per customer (WCSS divided by the number of customers).
Write your answer, then run it.
10. Avoid a trivial K decision
Challenge. Fit K=1 and K equal to the number of distinct feature rows; print their WCSS. Explain why minimum WCSS alone is insufficient.
Write your answer, then run it.
Reference answers are locked until all 10 attempts are submitted.
1. Read feature shape
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
print(X.shape)Expected output
(200, 2)Two selected features define the standardised clustering space.
2. Fit a candidate K
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
print(round(km.inertia_, 3))Expected output
143.145WCSS describes this fitted solution and feature space.
3. Count a candidate's groups
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
labels = KMeans(n_clusters=3, random_state=42, n_init=10).fit_predict(X)
print(pd.Series(labels).value_counts().sort_index())Expected output
0 51
1 42
2 107
Name: count, dtype: int64Inspect whether every proposed group is operationally meaningful.
4. Compare WCSS at two K values
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
a = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
b = KMeans(n_clusters=5, random_state=42, n_init=10).fit(X)
print(round(a.inertia_, 3), round(b.inertia_, 3))Expected output
143.145 37.976Lower WCSS with more groups does not by itself choose the business solution.
5. Create elbow values
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
values = [
KMeans(n_clusters=k, random_state=42, n_init=10).fit(X).inertia_
for k in range(1, 5)
]
print([round(v, 2) for v in values])Expected output
[400.0, 237.0, 143.15, 74.92]The comprehension repeats fits across candidate K values.
6. Calculate a silhouette
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
from sklearn.metrics import silhouette_score
labels = KMeans(n_clusters=5, random_state=42, n_init=10).fit_predict(X)
print(round(silhouette_score(X, labels), 3))Expected output
0.611This measures separation in the same standardised feature space.
7. Compare seeds
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
a = KMeans(n_clusters=5, random_state=42, n_init=10).fit(X)
b = KMeans(n_clusters=5, random_state=7, n_init=10).fit(X)
print(round(a.inertia_, 3), round(b.inertia_, 3))Expected output
37.976 37.976Similar objectives do not guarantee identical numeric label IDs.
8. Inspect a small group
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
labels = KMeans(n_clusters=6, random_state=42, n_init=10).fit_predict(X)
print(int(pd.Series(labels).value_counts().min()))Expected output
17A small group may be meaningful, unstable, or outlier-driven; investigate.
9. Calculate average squared distance
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
km = KMeans(n_clusters=5, random_state=42, n_init=10).fit(X)
print(round(km.inertia_, 3))
print(round(km.inertia_ / len(X), 6))Expected output
37.976
0.189882WCSS sums squared distances in the standardised feature space; dividing by the number of customers gives the mean squared distance per customer.
10. Avoid a trivial K decision
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
one = KMeans(n_clusters=1, random_state=42, n_init=10).fit(X)
distinct = len(np.unique(X, axis=0))
all_rows = KMeans(n_clusters=distinct, random_state=42, n_init=1).fit(X)
print(round(one.inertia_, 3), round(all_rows.inertia_, 3))
print(
"One group per distinct record can make WCSS zero without creating a useful segmentation."
)Expected output
400.0 0.0
One group per distinct record can make WCSS zero without creating a useful segmentation.The objective alone favours excessive subdivision; business usefulness must enter the decision.
Customer Profiles and Business Actions
Describe who is in each group, connect its centroid to its average features, and use that description to propose an action.
What Is a Cluster Profile?
K-means gives us a group number for each customer. A number such as “group 0” does not tell a manager what those customers are like.
A cluster profile is a short description of the customers in one group: how many there are, what their measured features are like, and how they differ from other groups. Here, we start with average annual spending in each product category.
Why do this? The sales manager needs to know which products a group tends to buy before proposing a suitable offer. “Group 0” gives no such information; “45 customers with higher average Grocery and Milk spending” does.
Profiling describes groups after clustering. It does not fit a second model or create new group assignments.
From Customers to a Centroid
A centroid is one point whose coordinates are the group’s feature means. For two features, average each column separately.
In this small illustration, group 0 contains A=(2,4), B=(4,4), C=(3,7), where coordinates are (Milk, Grocery) annual spending in thousands of monetary units.
\[ \text{Centroid of group 0} =\left(\frac{2+4+3}{3},\frac{4+4+7}{3}\right) =(3,5). \]
The star therefore represents mean Milk spending of 3,000 and mean Grocery spending of 5,000 per customer per year. It may lie between customers: it is not an actual customer, and not every customer spends these amounts.
A Profile Is a Picture of the Group
“Picture” means a numerical description, not a photograph or a new customer. We can show the same group as a point on a scatter plot, a row of means, or a bar chart.
| Small illustration | Customers | Mean Milk | Mean Grocery | Plain description |
|---|---|---|---|---|
| Group 0 | 3 | 3,000 | 5,000 | Lower spending in these two categories |
| Group 1 | 3 | 9,000 | 11,000 | Higher spending in these two categories |
Connection: the two means in each row are that group’s centroid coordinates in original units. The profile adds context: group size, units and a description. Later we can also add the spread of values or the mix of customer types.
Use: compare these measured patterns to propose different offers. Limit: the averages do not tell us that every member is the same, or that the higher-spending group is more profitable.
Real Customers: Fit Groups, Then Count Members
Now apply the idea to the distributor’s 440 customers and six annual spending categories. We use K=3 as a working example; this does not establish that three is best.
Purpose of this code: attach a group label to every customer, then find how many customers each profile will describe.
| Code | What it does | Why we need it |
|---|---|---|
fit_transform(...) |
Standardises the six spending columns | Makes distances comparable |
km.fit_predict(X) |
Fits K-means and returns one label per row | Identifies which customers belong together |
wholesale["Cluster"] = ... |
Saves each row’s label | Lets us select a group afterwards |
value_counts().sort_index() |
Counts members in label order | Shows how many customers support each summary |
Read the result: group 0 has 45 customers; group 1 has 393; group 2 has 2. IDs 0, 1 and 2 are names, not a ranking. Next we calculate what each group buys.
Build the Profile: Select Customers, Then Average
Purpose of this code: turn the labels into a readable table of average purchases. We already know each customer’s group; we are describing those groups, not fitting them again.
wholesale["Cluster"] == 0finds group 0’s rows..loc[..., features]keeps those customers and the six original spending columns.group0is a table of 45 customers × 6 features.group0.mean()averages each column separately: six numbers, one group’s mean spending profile. Repeat for groups 1 and 2.pd.DataFrame(...)stacks the three summaries: one row per group, one column per category.profileis simply our chosen Python variable name.
Interpret: group 0 averages 28,656 m.u. on Grocery and 19,386 m.u. on Milk per customer per year, versus group 1’s 5,535 and 4,115. These are original-unit centroid coordinates for this converged K-means fit, not group totals.
The loading, scaling and fitting lines at the start recreate the same working segmentation so this Run box works independently. The new task begins at group0 = … . Each .mean() returns one mean per selected column; DataFrame uses the category names as column labels. round(0) changes only the displayed precision. A profile is an interpretation summary, not a pandas method or a second fitted model.
Centroid and Profile: The Same Means in Different Units
We already have the profile from .loc and .mean(). Why show another method? K-means has also stored the group means in km.cluster_centers_, but it fitted them in standardised units.
Same group, same average pattern, two coordinate scales: the model uses standardised coordinates for distance; the manager needs annual spending amounts. scaler.inverse_transform(...) converts the stored centres to those original units.
\[\text{Original-unit centre}=\text{column mean}+\text{standardised centre}\times\text{column SD}.\]
Here “column mean” and “column SD” come from all 440 customers used to fit the scaler, not only the chosen group. Use that same fitted scaler.
Read the result: group 0’s Grocery coordinate becomes about 28,656 m.u., matching the profile calculated directly from its customers. This is an alternative way to obtain the feature means; it adds no new customers, groups or business evidence.
Do not call fit_transform on the centres: that would learn a new unrelated scaling. Cluster ID is retained by row order in the centre table; sorting centres without carrying the corresponding ID can mislabel the profiles.
Inspect the Two-Customer Group
A profile is a summary. Check the customers behind it before treating it as a segment for a campaign. Group 2 contains only two customers, compared with 45 and 393 in the other groups.
Its mean Frozen spending is about 48,702 m.u. per customer per year. A large mean from two records is different evidence from a large mean shared by hundreds of customers.
Purpose of this code: value_counts() obtains group sizes; idxmin() finds the smallest group’s ID; .loc prints its actual customer records. Read the two rows to see what their average hides.
Check for verified data errors or genuine unusual customers. They may need individual attention; the profile alone does not justify a mass campaign or the name “VIP.”
The group sizes are computed from the supplied snapshot and fixed settings. Do not name the tiny group VIP without profitability or relationship data. Change the seed, compare neighbouring K values and inspect extreme records while keeping the selected features standardised.
Render Original-Unit Spending Profiles
Purpose of this code: show the original-unit profile table as bars, so the manager can read actual average spending amounts. profile.T places categories on the horizontal axis; .plot.bar() draws one bar per group in each category.
Now read the same profiles in money amounts. The horizontal axis lists product categories. The vertical axis is mean annual spending per customer, in monetary units. The three bar colours represent the three groups.
For Grocery, group 0’s bar is about 28,656, compared with group 1’s 5,535. This tells the sales team how the groups’ average purchases differ.
Check who is behind each bar: group 0 has 45 customers, group 1 has 393, and group 2 has only 2. A tall bar for group 2 describes those two customers; it does not establish a large market opportunity.

Render Category Profiles in Comparable Units
Purpose of this code: draw the mean-feature table as colours so we can compare each group’s buying pattern across all six categories. It visualises the existing centroids; it does not fit new groups.
Read one square at a time. Rows are customer groups; columns are product categories. A square shows whether that group’s average spending is above or below the average for all 440 customers in that category.
Red / positive: above the category average. Blue / negative: below it. Near white / zero: close to it. A value of +1 means one category SD above the overall mean; it does not mean one monetary unit.
Start with group 0’s Grocery square, then compare its Milk square. Next read across group 1. This helps us compare buying patterns across categories with very different spending ranges.
The colour bar is limited to −2 to +2: values beyond those limits share the end colour. Colour alone does not show their exact magnitude, and red does not mean higher profit.

Extension: Compare Absolute and Compressed Spending
Optional: what if a few very large spending amounts drive the groups? log(1+x) compresses large values. For example, a rise from 1,000 to 10,000 is much smaller on a log scale than in raw monetary units.
The left panel fits standardised spending. The right panel first applies log(1+x), then standardises and fits K-means again. Customers can change colour because we changed how spending differences are measured.
Both plots display the same Grocery and Milk axes on log scales so we can see the points. Changing the plot axes alone would not change the fitted groups; changing the model inputs does.
Compare the group sizes and the original spending profiles before deciding whether this alternative helps. It changes the business question; it is not an automatic improvement.
The six-category fits are displayed through two original monetary variables on log axes. Those plotting axes are the same in both panels; the change in membership comes from the fitting transformation. This is an extension applying Chapter 2 feature transformations, not a requirement to transform every clustering problem.

Interpret with External Categories Afterwards
Do our spending groups mostly contain shops, or hotels/restaurants/cafés? We did not use Channel to create the groups. We can now use it to help describe them.
pd.crosstab(Channel, Cluster) makes a count table. Rows are customer types: 1 = hotel/restaurant/café; 2 = retail. Columns are the fitted clusters. A cell counts customers belonging to both its row type and its column group.
The second table uses normalize="columns": divide each count by that cluster’s total. Each column adds to 1, so it shows the customer-type mix within that group.
A spending group can contain both types. That is not automatically an error: we grouped customers by spending, not by their business label.
Compare the Simulated Mall Customer Profiles
Use the same reading method for the mall example. This constructed dataset uses two features: annual income (in thousands of illustrative currency units) and a spending score from 1 to 100.
For each group, select its customers, calculate mean income and mean score, then count its members. Compare the mean values with the other groups.
A group with relatively high income and low score can be called “higher income, lower spending score.” That name tells us exactly what was measured.
Do not rename it “disloyal customers” or “low-profit customers”: neither loyalty nor profit is in this table. A descriptive name is enough.
Render Mall Profiles with Original-Unit Centres
Each dot is one customer; each star is one group’s average position. Read income from left to right and spending score from bottom to top.
A star near the upper right means that group has relatively high average income and high average score. A star near the lower right means high income but low score. Nearby dots need not have exactly the average values.
The star may lie between customers rather than on a dot. It is a mean, not necessarily an actual person. Income and score help describe customers; they do not directly measure the profit an offer would generate.

From Profile to a Testable Action
What could the distributor try for group 0? Its 45 customers average about 28,656 m.u. on Grocery and 19,386 m.u. on Milk. A combined Grocery–Milk offer is one idea worth testing.
Keep three statements separate:
- What we observed: this group already buys relatively more Grocery and Milk than the large group 1.
- What we propose: offer some suitable customers a Grocery–Milk bundle.
- What we need to learn: does the offer increase margin after the discount and campaign costs?
For a pilot, randomly assign eligible customers to an offer group and a comparison group where feasible, then compare results over the same period. Higher sales alone are not enough if the discount removes the extra margin.
Clustering suggests whom we might study. It does not show that an offer will work.
Compare pilot and suitable comparison customers, account for customer size and existing purchases, and assess campaign cost. Annual category spending does not show whether purchases would have happened without the offer.
In-Class Practice: A Sales Manager’s Memo
Write a short note to the distributor’s sales manager. Use the original spending amounts, not standardised coordinates.
- Find the group with the highest mean Grocery spending.
- Report its customer count and its average Grocery and Milk spending, with units.
- Give the group a plain descriptive name.
- Suggest one offer to test and one result to measure.
Use this structure: “Group __ contains __ customers. On average, each spends __ on Grocery and __ on Milk per year. We could test __ and measure __.” Do not promise that the offer will increase profit.
Section 5 Practice — Customer Profiles and Business Actions
Work from question 1 to 10. Edit your Python box, click Run Code, review the result, then click Submit attempt. Reference answers unlock after all ten attempts are submitted. This records completion, not correctness. Each box runs independently. Progress is saved in this browser.
1. Count fitted groups
Warm-up. Print membership counts.
Write your answer, then run it.
2. Profile Grocery
Warm-up. Print average Grocery spending per group.
Write your answer, then run it.
3. Profile two categories
Warm-up. Build a profile table: one row per group, containing mean annual Milk and Grocery spending per customer (m.u.).
Write your answer, then run it.
4. Read original-unit centres
Build your skills. Inverse-transform the centres and print the first two categories.
Write your answer, then run it.
5. Identify a descriptive group
Build your skills. Print the cluster with the highest mean Grocery spending.
Write your answer, then run it.
6. Check group context
Build your skills. For the highest-mean-Grocery group, print its size and mean Milk spending.
Write your answer, then run it.
7. Inspect channel mix
Build your skills. Print a Channel-by-Cluster cross-tabulation.
Write your answer, then run it.
8. Inspect proportions
Challenge. Print the proportion of each Channel within each Cluster.
Write your answer, then run it.
9. Check centres against profiles
Challenge. Print whether inverse-transformed centres match group means in label order.
Write your answer, then run it.
10. Write an evidence-based proposal
Challenge. Print the high-Grocery group’s profile, followed by a bundle-pilot proposal that avoids claiming proven profit.
Write your answer, then run it.
Reference answers are locked until all 10 attempts are submitted.
1. Count fitted groups
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
print(wholesale["Cluster"].value_counts().sort_index())Expected output
Cluster
0 45
1 393
2 2
Name: count, dtype: int64Group sizes help interpret an average.
2. Profile Grocery
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
print(pd.Series([
wholesale.loc[wholesale["Cluster"] == 0, "Grocery"].mean(),
wholesale.loc[wholesale["Cluster"] == 1, "Grocery"].mean(),
wholesale.loc[wholesale["Cluster"] == 2, "Grocery"].mean()
], index=[0, 1, 2]).round(1))Expected output
0 28656.1
1 5535.0
2 16898.0
dtype: float64The amounts are annual m.u., not standardised scores.
3. Profile two categories
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
print(pd.DataFrame([
wholesale.loc[wholesale["Cluster"] == 0, ["Milk", "Grocery"]].mean(),
wholesale.loc[wholesale["Cluster"] == 1, ["Milk", "Grocery"]].mean(),
wholesale.loc[wholesale["Cluster"] == 2, ["Milk", "Grocery"]].mean()
], index=[0, 1, 2]).round(1))Expected output
Milk Grocery
0 19386.4 28656.1
1 4115.1 5535.0
2 30367.0 16898.0Each row describes the average purchases of that group. For this converged K-means fit, these two original-unit means are two coordinates of its centroid. Add group size before using the summary in a business memo.
4. Read original-unit centres
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
centres = pd.DataFrame(scaler.inverse_transform(km.cluster_centers_), columns=features)
print(centres[["Fresh", "Milk"]].round(1))Expected output
Fresh Milk
0 10440.9 19386.4
1 12062.9 4115.1
2 34782.0 30367.0Use the fitted scaler rather than fitting a new one.
5. Identify a descriptive group
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
profile = pd.DataFrame([
wholesale.loc[wholesale["Cluster"] == 0, features].mean(),
wholesale.loc[wholesale["Cluster"] == 1, features].mean(),
wholesale.loc[wholesale["Cluster"] == 2, features].mean()
], index=[0, 1, 2])
print(int(profile["Grocery"].idxmax()))Expected output
0The ID is a label selected by a measured profile, not a ranking built into KMeans.
6. Check group context
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
profile = pd.DataFrame([
wholesale.loc[wholesale["Cluster"] == 0, features].mean(),
wholesale.loc[wholesale["Cluster"] == 1, features].mean(),
wholesale.loc[wholesale["Cluster"] == 2, features].mean()
], index=[0, 1, 2])
chosen = profile["Grocery"].idxmax()
print(int((wholesale["Cluster"] == chosen).sum()))
print(round(profile.loc[chosen, "Milk"], 1))Expected output
45
19386.4A supported memo reports both amount and group size.
7. Inspect channel mix
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
print(pd.crosstab(wholesale["Channel"], wholesale["Cluster"]))Expected output
Cluster 0 1 2
Channel
1 1 295 2
2 44 98 0Channel is used after fitting to interpret the groups.
8. Inspect proportions
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
print(pd.crosstab(wholesale["Channel"], wholesale["Cluster"], normalize="columns").round(3))Expected output
Cluster 0 1 2
Channel
1 0.022 0.751 1.0
2 0.978 0.249 0.0Column normalisation answers the within-cluster composition question.
9. Check centres against profiles
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
centres = scaler.inverse_transform(km.cluster_centers_)
means = pd.DataFrame([
wholesale.loc[wholesale["Cluster"] == 0, features].mean(),
wholesale.loc[wholesale["Cluster"] == 1, features].mean(),
wholesale.loc[wholesale["Cluster"] == 2, features].mean()
], index=[0, 1, 2]).to_numpy()
print(np.allclose(centres, means))Expected output
TrueCoordinate means and centre values agree for this converged fit.
10. Write an evidence-based proposal
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
profile = pd.DataFrame([
wholesale.loc[wholesale["Cluster"] == 0, features].mean(),
wholesale.loc[wholesale["Cluster"] == 1, features].mean(),
wholesale.loc[wholesale["Cluster"] == 2, features].mean()
], index=[0, 1, 2])
chosen = profile["Grocery"].idxmax()
print(profile.loc[chosen, ["Milk", "Grocery"]].round(1))
print("Test a Milk–Grocery bundle and measure incremental margin before rollout.")Expected output
Milk 19386.4
Grocery 28656.1
Name: 0, dtype: float64
Test a Milk–Grocery bundle and measure incremental margin before rollout.The action is a testable hypothesis, not a causal conclusion from clustering.
Hierarchical Clustering: One Customer Story
Optional — not covered in the final exam.
Follow five customers through four merges, read their tree, and choose a cut.
Build Groups by Joining Two at a Time
Our manager has five customers, A–E. Hierarchical clustering starts with one group per customer and repeatedly joins two groups.
Five customers need four merges to become one group. We keep the whole history as a tree, then choose how many final groups to use.
A Group Needs a Rule for Comparing Distances
Distance compares two customers. Linkage compares two groups.
Suppose A and B are already together. To compare {A,B} with C, we have two customer gaps: A–C=6 and B–C=5.
A linkage rule turns those gaps into one group-comparison value. The next three slides show three possible rules.
Single Linkage Uses the Nearest Pair
Single linkage uses the smallest cross-group customer distance.
For {A,B} versus C, the two gaps are 6 and 5.
Single linkage = min(6,5) = 5. Only the closest pair determines this group comparison.
Complete Linkage Uses the Farthest Pair
Complete linkage uses the largest cross-group customer distance.
For {A,B} versus C, the two gaps are 6 and 5.
Complete linkage = max(6,5) = 6. Even the farthest pair matters. We use this rule for the rest of the example.
Average Linkage Uses All Cross-Group Pairs
Average linkage uses the mean of all cross-group customer distances.
For {A,B} versus C, there are two pairs: A–C and B–C.
Average linkage = (6+5)/2 = 5.5. Both pairs contribute equally here.
Read Our Five-Customer Distance Table
Each cell compares its row customer with its column customer. Smaller means more similar. The diagonal is zero; each pair appears twice.
| A | B | C | D | E | |
|---|---|---|---|---|---|
| A | 0 | 2 | 6 | 10 | 9 |
| B | 2 | 0 | 5 | 9 | 8 |
| C | 6 | 5 | 0 | 4 | 5 |
| D | 10 | 9 | 4 | 0 | 3 |
| E | 9 | 8 | 5 | 3 | 0 |
We use these same five customers and same distances on every remaining slide.
The supplied distances retain the five-customer teaching example. They are not asserted to come from a particular set of Euclidean feature coordinates.
Merge 1: Join A and B at Height 2
At the start, each group contains one customer. Its comparison with another group is simply that customer-pair distance.
The smallest distance is A–B=2, so join them first.
We now have four groups: {A,B}, {C}, {D}, {E}.
Merge 2: Update the Gaps, Then Join D and E
Use complete linkage to update the new group’s comparisons:
| Current groups | Group-comparison value |
|---|---|
| {A,B} and C | max(6,5) = 6 |
| {A,B} and D | max(10,9) = 10 |
| {A,B} and E | max(9,8) = 9 |
| C and D | 4 |
| C and E | 5 |
| D and E | 3 |
The smallest current value is 3. Join D+E. Three groups remain: {A,B}, {C}, {D,E}.
Merge 3: C Joins {D,E} at Height 5
Compare the three remaining groups using the farthest cross-group pair:
| Current groups | Complete linkage |
|---|---|
| {A,B} and C | max(6,5) = 6 |
| C and {D,E} | max(4,5) = 5 |
| {A,B} and {D,E} | max(10,9,9,8) = 10 |
The smallest of 6, 5 and 10 is 5. Join C with {D,E}.
The merge is at 5, even though C–D is 4: complete linkage also checks C–E=5.
Merge 4: Join the Final Two Groups at Height 10
Only {A,B} and {C,D,E} remain. Complete linkage checks all six cross-group customer pairs:
| C | D | E | |
|---|---|---|---|
| A | 6 | 10 | 9 |
| B | 5 | 9 | 8 |
The largest is A–D=10, so the final merge height is 10. All five customers now belong to one group.
A Dendrogram Draws the Four Merges
A dendrogram is a picture of the merge history. Start at the customer names and follow branches upward.
Each horizontal join is one merge; its height is the complete-linkage distance. Read the four heights: 2, 3, 5, 10.
The left-to-right customer order only makes the tree easier to draw. It is not a ranking.
A Horizontal Cut Chooses the Final Groups
Keep joins below the cut. Customers connected below it belong together.
At height 4, A+B and D+E have joined, but C has not joined either group.
We obtain three groups: {A,B}, {C}, {D,E}.
Move the Cut and Watch the Groups Change
Use the same five-customer tree. Before moving the cut, predict who will stay together.
Try 1 → 4 → 6 → 11. You should see 5 → 3 → 2 → 1 groups.
Changing the cut reuses the same tree; it does not run the merging procedure again.
One Row of Z Records One Merge
Python calls the stored merge history Z, a linkage matrix. Five customers require four rows.
Read the first row [0,1,2,2]:
| Entry | Meaning |
|---|---|
| Left = 0 | Customer A |
| Right = 1 | Customer B |
| Height = 2 | Join them at distance 2 |
| Count = 2 | The new group contains 2 customers |
Original customer IDs 0–4 mean A–E. They are labels, not ranks.
A New Node ID Refers to an Earlier Group
After a merge, Python gives the new group a node ID. Original customers are 0–4; new groups are 5–8.
| New node | Group it represents |
|---|---|
| 5 | {A,B} |
| 6 | {D,E} |
| 7 | {C,D,E} |
| 8 | {A,B,C,D,E} |
The third row [2,6,5,3] therefore means: join customer C (node 2) with group {D,E} (node 6), at height 5, making a group of 3 customers.
Save the Merge Record Once, Reuse It
Z is a compact record of the whole tree. Each row refers to customers or earlier groups by ID.
We can reuse Z to draw the tree or choose a different cut, without computing the merges again. Rebuild it only if the customers, distances or linkage rule change.
Python Computes the Merge Record We Worked Out
The code repeats our complete-linkage calculations and prints Z. The four merge heights should be 2, 3, 5, 10.
pd.read_csv(...) loads our distance table. squareform(...) repackages its upper-half pair distances for Python’s clustering function. It does not calculate new distances.
linkage(..., method="complete") performs the merges and returns their record. The printed column names help us read it.
The square table contains both A–B and B–A. squareform takes each unique pair once. Passing the full two-dimensional distance table directly to linkage would make it treat the rows as original feature vectors, rather than supplied pairwise distances.
Python Draws the Saved Tree
dendrogram(Z, ...) draws the merges already stored in Z. It does not perform a new clustering.
labels=... adds customer names. ax.axhline(4, ...) draws our horizontal cut at height 4.
Check the picture: the cut leaves {A,B}, {C}, {D,E}. Change the cut line to 6 and predict the result before running.

Read the Diagram Before Writing Code
Using the same five-customer tree, explain why C joins {D,E} at height 5.
Point to the two relevant customer distances, name the linkage rule, and explain the maximum. Then complete the Section 6 practice on the course main page.
Section 6 Practice — Hierarchical Clustering: One Customer Story
Optional practice — Section 4.6 is not covered in the final exam.
Work from question 1 to 10. Edit your Python box, click Run Code, review the result, then click Submit attempt. Reference answers unlock after all ten attempts are submitted. This records completion, not correctness. Each box runs independently. Progress is saved in this browser.
1. Count merge rows
Warm-up. Print Z.shape.
Write your answer, then run it.
2. Read the first height
Warm-up. Print the first merge height.
Write your answer, then run it.
3. Read the final height
Warm-up. Print the last merge height.
Write your answer, then run it.
4. Read the final count
Build your skills. Print the final customer count.
Write your answer, then run it.
5. Cut before any merge
Build your skills. At height1, print the number of groups.
Write your answer, then run it.
6. Make three groups
Build your skills. At height4, print one group label per customer.
Write your answer, then run it.
7. Make two groups
Build your skills. At height6, print one group label per customer.
Write your answer, then run it.
8. Join everyone
Challenge. At height11, print the number of groups.
Write your answer, then run it.
9. Compare the three rules
Challenge. For {A,B} versus C, print single, complete and average linkage using gaps6and5.
Write your answer, then run it.
10. Read a group merge
Challenge. Print the third row of Z and explain it.
Write your answer, then run it.
Reference answers are locked until all 10 attempts are submitted.
1. Count merge rows
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
print(Z.shape)Expected output
(4, 4)Five customers require four merges; each merge stores four entries.
2. Read the first height
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
print(Z[0, 2])Expected output
2.0A+B merge at2; Python index2 selects the third column.
3. Read the final height
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
print(Z[-1, 2])Expected output
10.0The final two groups join at10; -1 selects the last row.
4. Read the final count
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
print(int(Z[-1, 3]))Expected output
5The last group contains all five customers.
5. Cut before any merge
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
labels = fcluster(Z, t=1, criterion="distance")
print(pd.Series(labels).nunique())Expected output
5All five customers remain separate.
6. Make three groups
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
labels = fcluster(Z, t=4, criterion="distance")
print(pd.Series(labels, index=distance_table.index))Expected output
Customer
A 1
B 1
C 3
D 2
E 2
dtype: int32The groups are {A,B}, {C}, {D,E}; numeric labels are arbitrary.
7. Make two groups
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
labels = fcluster(Z, t=6, criterion="distance")
print(pd.Series(labels, index=distance_table.index))Expected output
Customer
A 1
B 1
C 2
D 2
E 2
dtype: int32The groups are {A,B} and {C,D,E}.
8. Join everyone
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
labels = fcluster(Z, t=11, criterion="distance")
print(pd.Series(labels).nunique())Expected output
1Above the last merge, one group contains all five customers.
9. Compare the three rules
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
gaps = pd.Series([6, 5])
print(gaps.min(), gaps.max(), gaps.mean())Expected output
5 6 5.5The three group comparisons are5,6and5.5.
10. Read a group merge
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
print(Z[2])Expected output
[2. 6. 5. 3.][2,6,5,3] joins C with {D,E}, at height5, containing3customers.
Chapter Summary
| Decision | Python tools | Check before claiming success |
|---|---|---|
| Define similarity | Choose feature columns | Units, identifiers, business purpose |
| Inspect records | .isna(), .describe() |
Completeness, spread, unusual records |
| Standardise selected features | StandardScaler |
ddof=0; weighting interpretation |
| Fit groups | KMeans, .fit_predict() |
Seed, initialisations, arbitrary IDs |
| Choose K for K-means | .inertia_, elbow, silhouette |
Sizes, stability, useful distinctions |
| Profile members | .loc, .mean(), .value_counts() |
Original units, supported names |
| Propose actions | A measurable pilot | Profiles do not prove campaign effects |
| Optional extension (not covered in the final exam): inspect a tree | linkage, dendrogram, fcluster |
Linkage meaning, cut heights, ties |
A useful segmentation is a documented business choice: features + scaling + algorithm + K/cut + profiles + a plan to check the action.
Quiz Review
Work from question 1 to 10. Edit your Python box, click Run Code, review the result, then click Submit attempt. Reference answers unlock after all ten attempts are submitted. This records completion, not correctness. Each box runs independently. Progress is saved in this browser.
Question 1
Count wholesale customers whose Grocery spending is at least 10,000 m.u.
Write your answer, then run it.
Question 2
For A=(1,1), B=(4,5), print Euclidean and Manhattan distances.
Write your answer, then run it.
Question 3
Manually standardise classroom Income using ddof=0 and compare with StandardScaler.
Write your answer, then run it.
Question 4
Transform (1.5,1.5) with the fitted scaler, then print its distances to the standardised centres and its assigned cluster.
Write your answer, then run it.
Question 5
Reconstruct WCSS for the six-point fitted model using its standardised input X.
Write your answer, then run it.
Question 6
Print mall WCSS for K=4 and K=5 and a sentence explaining why the smaller value does not settle the business choice.
Write your answer, then run it.
Question 7
Print wholesale original-unit mean Milk and Grocery spending for each working group.
Write your answer, then run it.
Question 8
Inverse-transform wholesale centroids and verify that they match group means.
Write your answer, then run it.
Question 9
Print the largest wholesale group size and a cautious business proposal.
Write your answer, then run it.
Question 10
Optional — not covered in the final exam. For the five-customer complete-linkage tree, print group counts at cut heights 1,4,11.
Write your answer, then run it.
Reference answers are locked until all 10 attempts are submitted.
Question 1
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
print(int((wholesale["Grocery"] >= 10000).sum()))Expected output
117A simple threshold rule uses one chosen feature.
Question 2
import numpy as np
a = np.array([1, 1])
b = np.array([4, 5])
print(np.sqrt(((a - b) ** 2).sum()))
print(np.abs(a - b).sum())Expected output
5.0
7Euclidean uses squared gaps and a square root; Manhattan sums absolute gaps.
Question 3
import pandas as pd
import numpy as np
customers = pd.DataFrame({"Age": [35, 28, 60, 45, 22],
"Income": [60000, 80000, 30000, 55000, 40000],
"Score": [75, 85, 20, 60, 90]}, index=["A", "B", "C", "D", "E"])
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaled = pd.DataFrame(scaler.fit_transform(customers),
index=customers.index, columns=customers.columns)
manual = (customers["Income"] - customers["Income"].mean()) / customers["Income"].std(
ddof=0
)
print(np.allclose(manual, scaled["Income"]))Expected output
TrueThe population SD convention must match.
Question 4
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
new = pd.DataFrame({"X1": [1.5], "X2": [1.5]})
p = scaler.transform(new)[0]
d = np.sqrt(((km.cluster_centers_ - p) ** 2).sum(axis=1))
print(d.round(3).tolist())
print(km.predict(scaler.transform(pd.DataFrame({"X1": [1.5], "X2": [1.5]}))).tolist())Expected output
[0.076, 2.72]
[0]The assigned centre is the nearest in the selected feature space.
Question 5
import pandas as pd
import numpy as np
points = pd.DataFrame({"X1": [1, 1, 2, 7, 8, 8],
"X2": [1, 2, 1, 7, 7, 8]},
index=["A", "B", "C", "D", "E", "F"])
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
scaler = StandardScaler()
X = scaler.fit_transform(points[["X1", "X2"]])
km = KMeans(n_clusters=2, random_state=42, n_init=10)
points["Cluster"] = km.fit_predict(X)
assigned = km.cluster_centers_[km.labels_]
print(round(((X - assigned) ** 2).sum(), 3))Expected output
0.275Use standardised X and squared distances to each assigned standardised centre.
Question 6
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
mall = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-mall-classroom.csv",
index_col="CustomerID")
features = ["Annual_Income", "Spending_Score"]
scaler = StandardScaler()
X = scaler.fit_transform(mall[features])
a = KMeans(n_clusters=4, random_state=42, n_init=10).fit(X)
b = KMeans(n_clusters=5, random_state=42, n_init=10).fit(X)
print(round(a.inertia_, 3), round(b.inertia_, 3))
print("Also inspect profiles, sizes, stability, and actionable differences.")Expected output
74.922 37.976
Also inspect profiles, sizes, stability, and actionable differences.More groups generally reduce the fitted objective without proving usefulness.
Question 7
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
print(pd.DataFrame([
wholesale.loc[wholesale["Cluster"] == 0, ["Milk", "Grocery"]].mean(),
wholesale.loc[wholesale["Cluster"] == 1, ["Milk", "Grocery"]].mean(),
wholesale.loc[wholesale["Cluster"] == 2, ["Milk", "Grocery"]].mean()
], index=[0, 1, 2]).round(1))Expected output
Milk Grocery
0 19386.4 28656.1
1 4115.1 5535.0
2 30367.0 16898.0Profiles return to business units.
Question 8
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
centres = scaler.inverse_transform(km.cluster_centers_)
means = pd.DataFrame([
wholesale.loc[wholesale["Cluster"] == 0, features].mean(),
wholesale.loc[wholesale["Cluster"] == 1, features].mean(),
wholesale.loc[wholesale["Cluster"] == 2, features].mean()
], index=[0, 1, 2]).to_numpy()
print(np.allclose(centres, means))Expected output
TrueRetain fitted scaler and cluster-label order.
Question 9
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
wholesale = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-wholesale.csv")
features = ["Fresh", "Milk", "Grocery", "Frozen", "Detergents_Paper", "Delicassen"]
scaler = StandardScaler()
X = scaler.fit_transform(wholesale[features])
km = KMeans(n_clusters=3, random_state=42, n_init=10)
wholesale["Cluster"] = km.fit_predict(X)
print(int(wholesale["Cluster"].value_counts().max()))
print(
"Use the measured category profile to propose a pilot and measure incremental margin."
)Expected output
393
Use the measured category profile to propose a pilot and measure incremental margin.A descriptive grouping supports a hypothesis, not a proven campaign effect.
Question 10
import pandas as pd
import numpy as np
from scipy.spatial.distance import squareform
from scipy.cluster.hierarchy import linkage, fcluster
distance_table = pd.read_csv(
"https://busanalytics-book.pages.dev/data/chapter4-five-customer-distances.csv",
index_col="Customer")
pair_distances = squareform(distance_table.to_numpy())
Z = linkage(pair_distances, method="complete")
counts = [len(np.unique(fcluster(Z, t=h, criterion="distance"))) for h in [1, 4, 11]]
print(counts)Expected output
[5, 3, 1]A higher tree cut merges groups; this is extension material.


