CSCI 1109 — Practical Data Science
By the end of this module, you should be able to:
scikit-learn, both with and without feature scaling, and compare the resulting clusters.So far, our models have had labels:
Now we flip the question:
What if we only have features, no labels, and we want to discover structure?
Clustering is a core unsupervised task:
Clustering.
\(k\)-means clustering.
Choose a number of clusters \(k\) (hyperparameter).
Algorithm finds:
Objective (informally):
Put points with nearby neighbours, and place each centroid in the “middle” of its cluster.
Centroid.
Within-cluster sum of squares (WCSS; ‘distortion’).
Formally, in \(k\)-means we choose:
to minimize the within-cluster sum of squares:
\[ \min_{\mu_1,\dots,\mu_k,\,z_1,\dots,z_n} \sum_{i=1}^n \left\| x_i - \mu_{z_i} \right\|^2 \]
Non-trivial but important fact.
KMeans, the attribute n_init controls the number of iterations we run, from which we choose the best result.how separated are the clusters?
So far we’ve looked at k-means geometry and some sanity checks.
The silhouette score is an internal metric that tries to answer:
For each point, am I closer to my own cluster than to any other cluster?
For a given point \(i\):
Then we compare \(a(i)\) vs \(b(i)\):

a(i) ≈ 0.55, b(i) ≈ 3.81
For each point \(i\), the silhouette value is:
\[ \boxed{s(i) = \frac{b(i) - a(i)}{\max\{a(i), b(i)\}}} \]
where:
\(a(i)\) = average distance from \(i\) to its own cluster
\(b(i)\) = smallest average distance from \(i\) to points in any other cluster
\(s(i)\) is always between \(-1\) and \(+1\).
We often look at the average silhouette score over all points:
\[ \bar{s} = \frac{1}{n} \sum_{i=1}^n s(i) \]
In code (via 🔗sklearn.metrics.silhouette_score), we pass in:
Scenario. You have data for hospital patients:
age (0–100),systolic_bp (80–220),home_postal_code (string),diagnosis_code (categorical),lab_risk_score (continuous).You feed all columns directly into k-means using Euclidean distance on raw columns.
What goes wrong?
diagnosis_code and postal_code are not meaningful in this geometry.Better approach?
This anti-example highlights:
We’ll simulate a small “customer” dataset:
annual_spend (in $),visits_per_month (in counts),and try to discover 3 customer segments using k-means.
import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
import matplotlib.pyplot as plt
rng = np.random.default_rng(1109)
# Simulate three rough groups
n_per_cluster = 80
group1 = np.column_stack([
rng.normal(500, 50, n_per_cluster), # low spend
rng.normal(2, 0.5, n_per_cluster) # few visits
])
group2 = np.column_stack([
rng.normal(1500, 100, n_per_cluster), # medium spend
rng.normal(4, 0.7, n_per_cluster) # moderate visits
])
group3 = np.column_stack([
rng.normal(3000, 200, n_per_cluster), # high spend
rng.normal(6, 1.0, n_per_cluster) # many visits
])
data = np.vstack([group1, group2, group3])
customers = pd.DataFrame(data, columns=["annual_spend", "visits_per_month"])
customers.head()| annual_spend | visits_per_month | |
|---|---|---|
| 0 | 491.491400 | 1.839378 |
| 1 | 524.150273 | 2.645292 |
| 2 | 510.448798 | 1.900917 |
| 3 | 498.516362 | 1.163426 |
| 4 | 409.563898 | 1.749586 |
| annual_spend | visits_per_month | |
|---|---|---|
| cluster_raw | ||
| 0 | 1495.0 | 4.0 |
| 1 | 2996.6 | 5.8 |
| 2 | 500.4 | 1.9 |
Now plot:
fig, ax = plt.subplots()
scatter = ax.scatter(
customers["annual_spend"],
customers["visits_per_month"],
c=customers["cluster_raw"]
)
centers = kmeans_raw.cluster_centers_
ax.scatter(centers[:, 0], centers[:, 1], marker="X", s=200, edgecolor="black")
ax.set_xlabel("Annual spend ($)")
ax.set_ylabel("Visits per month")
ax.set_title("k-means on raw features")
plt.show()
Note
This looks okay because our features were designed nicely.
Suppose we accidentally record:
annual_spend in thousands of dollars;visits_per_month unchanged.Now annual_spend has much smaller numerical range.
customers_bad = customers.copy()
customers_bad["annual_spend_k"] = customers_bad["annual_spend"] / 1000
kmeans_bad = KMeans(n_clusters=3, n_init=10, random_state=1109)
customers_bad["cluster_bad"] = kmeans_bad.fit_predict(
customers_bad[["annual_spend_k", "visits_per_month"]]
)
customers_bad.groupby("cluster_bad")[["annual_spend_k", "visits_per_month"]].mean().round(2)| annual_spend_k | visits_per_month | |
|---|---|---|
| cluster_bad | ||
| 0 | 2.95 | 6.00 |
| 1 | 0.54 | 1.97 |
| 2 | 1.64 | 4.05 |
fig, ax = plt.subplots()
scatter = ax.scatter(
customers_bad["annual_spend_k"],
customers_bad["visits_per_month"],
c=customers_bad["cluster_bad"]
)
centers_bad = kmeans_bad.cluster_centers_
ax.scatter(centers_bad[:, 0], centers_bad[:, 1], marker="X", s=200, edgecolor="black")
ax.set_xlabel("Annual spend (thousands of $)")
ax.set_xlim(0,9)
ax.set_ylabel("Visits per month")
ax.set_title("k-means with distorted scaling")
plt.show()
What often happens conceptually
visits_per_month; annual_spend_k contributes much less.This is why we need feature scaling.
pipe_scaled = Pipeline([
("scaler", StandardScaler()),
("kmeans", KMeans(n_clusters=3, n_init=10, random_state=1109))
])
customers["cluster_scaled"] = pipe_scaled.fit_predict(
customers[["annual_spend", "visits_per_month"]]
)
customers.groupby("cluster_scaled")[["annual_spend", "visits_per_month"]].mean().round(1)| annual_spend | visits_per_month | |
|---|---|---|
| cluster_scaled | ||
| 0 | 1495.0 | 4.0 |
| 1 | 2996.6 | 5.8 |
| 2 | 500.4 | 1.9 |
fig, ax = plt.subplots()
scatter = ax.scatter(
customers["annual_spend"],
customers["visits_per_month"],
c=customers["cluster_scaled"]
)
centers_scaled = pipe_scaled.named_steps["kmeans"].cluster_centers_
# Need to inverse-transform centers back to original scale for plotting:
centers_scaled_orig = pipe_scaled.named_steps["scaler"].inverse_transform(centers_scaled)
ax.scatter(centers_scaled_orig[:, 0], centers_scaled_orig[:, 1],
marker="X", s=200, edgecolor="black")
ax.set_xlabel("Annual spend ($)")
ax.set_ylabel("Visits per month")
ax.set_title("k-means with standardization")
plt.show()
Takeaways
StandardScaler for k-means on mixed-scale numeric features.We’ll use the classic palmer penguins dataset (via seaborn):
flipper_length_mm, body_mass_g).Adelie, Chinstrap, 🔗Gentoo) — but we’ll pretend we don’t see them at clustering time.| flipper_length_mm | body_mass_g | |
|---|---|---|
| 0 | 181.0 | 3750.0 |
| 1 | 186.0 | 3800.0 |
| 2 | 195.0 | 3250.0 |
| 4 | 193.0 | 3450.0 |
| 5 | 190.0 | 3650.0 |
We’ll cluster into \(k = 3\) groups and see how they line up with species.
| species | cluster | |
|---|---|---|
| 0 | Adelie | 0 |
| 1 | Adelie | 0 |
| 2 | Adelie | 0 |
| 4 | Adelie | 0 |
| 5 | Adelie | 0 |
| species | Adelie | Chinstrap | Gentoo |
|---|---|---|---|
| cluster | |||
| 0 | 116 | 45 | 0 |
| 1 | 30 | 23 | 34 |
| 2 | 0 | 0 | 85 |
✏️ Think
We can also compute a silhouette score as a crude internal metric.
0.4760097141728845
Interpretation.
Tip
Silhouette is an internal metric; whether these clusters are useful depends on the domain question.
Unlike supervised learning, we often don’t have labels. Evaluation strategies:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
# -----------------------------
# Simulate a small 2D dataset
# -----------------------------
rng = np.random.default_rng(1109)
n_per_cluster = 80
centers = np.array([
[0.0, 0.0],
[4.0, 0.5],
[0.5, 4.0]
])
points = []
for cx, cy in centers:
pts = rng.normal(loc=[cx, cy], scale=[0.6, 0.6], size=(n_per_cluster, 2))
points.append(pts)
X = np.vstack(points)
# -----------------------------
# Compute WCSS for k = 1..8
# -----------------------------
Ks = range(1, 9)
wcss = []
for k in Ks:
model = KMeans(n_clusters=k, n_init=20, random_state=1109)
model.fit(X)
wcss.append(model.inertia_) # inertia_ is the WCSS
# -----------------------------
# Plot elbow curve
# -----------------------------
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(list(Ks), wcss, marker="o")
ax.set_xlabel("k (number of clusters)")
ax.set_ylabel("Within-cluster sum of squares (WCSS)")
ax.set_title("Elbow method for choosing k")
ax.grid(alpha=0.3)
# Optional: annotate a visible elbow around k=3
elbow_k = 3
elbow_wcss = wcss[elbow_k - 1]
ax.scatter([elbow_k], [elbow_wcss], s=80, edgecolor="black")
ax.annotate(
"possible elbow",
xy=(elbow_k, elbow_wcss),
xytext=(elbow_k + 0.4, elbow_wcss * 1.1),
arrowprops=dict(arrowstyle="->", lw=1),
fontsize=9,
)
plt.tight_layout()
plt.show()
Before running \(k\)-means on a dataset:
StandardScaler or MinMaxScaler in a pipeline.k-means++ (scikit-learn’s default) and multiple runs (n_init).Clustering often gets applied to people:
Risks:
Lightweight safeguards:
Beyond toy customer segments and penguins, k-means underlies:
In later courses, you may see:
which address some limitations of \(k\)-means (non-spherical clusters, varying density, etc.).
Conceptual MCQ.
You run k-means on a dataset with two features: age (0–100) and income (0–200,000). You do no scaling. Which is most likely?
A. Clusters mostly reflect differences in age.
B. Clusters mostly reflect differences in income.
C. Both features influence clusters equally.
D. k-means fails to converge.
Short answer.
In one sentence, explain what the k-means algorithm is optimizing.
Calculation-ish.
Suppose a cluster currently contains the 1D points \(\{2, 4, 6, 8\}\). What is the centroid in 1D, and why is this the best choice for k-means?
Diagnosis MCQ.
You run k-means with \(k=4\) on standardized features and get a silhouette score near 0.02. Scatterplots show heavily overlapping clusters. What is the best immediate interpretation?
A. There is no structure; the data are purely random noise.
B. k=4 is perfect; low silhouette is expected after scaling.
C. The clusters are weak/overlapping; you might need a different k or a different algorithm.
D. The model overfit; you must regularize more.
