EduFlow

Section 5 · Beyond the Basics

Clustering and PCA

21 min3 min readPython exercise

Lesson at a glance

Group customers with k-means, profile the clusters to see whether they mean anything, and compress many correlated columns into a few with PCA.

  1. 1k-means in two moves
  2. 2Segmenting Cartwheel’s customers
  3. 3PCA: fewer columns, most of the information

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Not every question comes with labels. Cartwheel’s marketing team asks: “What kinds of customers do we have?” Nobody has tagged customers with types, so there’s no target to predict. Unsupervised learning looks for structure in the features alone. The two tools you’ll use most are clustering, which groups similar rows, and dimensionality reduction, which summarises many columns with a few.

k-means in two moves

k-means needs one decision from you: the number of clusters, k. Then it repeats two steps:

  1. Assign: put each customer in the cluster whose centre is nearest.
  2. Update: move each centre to the average of the customers assigned to it.

When assignments stop changing, it’s done. Different starting centres can give different results, so scikit-learn runs the whole thing n_init times and keeps the tightest solution.

Left panel: points coloured by their nearest of two centres, shown as large rings. Right panel: each centre has moved to the middle of its assigned points. The two steps repeat until the assignments stop changing. 1. assign to nearest centre 2. move centre to the mean
One round of k-means

Because “nearest” is a distance, the same lesson from k-nearest neighbours applies: scale first, or total_spent in thousands decides everything.

Segmenting Cartwheel’s customers

Use behaviour columns only and leave churned out: a segmentation should describe customers, not their outcome. You’ll look at churn afterwards to see whether the segments mean anything.

Python
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

churn = pd.read_csv("churn.csv")
behaviour = ["orders", "total_spent", "avg_order_value", "days_since_last_order",
             "tenure_days", "mobile_share"]
X = StandardScaler().fit_transform(churn[behaviour])
for k in range(2, 7):
    labels = KMeans(n_clusters=k, n_init=10, random_state=42).fit_predict(X)
    print(f"k={k}  silhouette {silhouette_score(X, labels):.3f}")

segments = make_pipeline(StandardScaler(), KMeans(n_clusters=4, n_init=10, random_state=42))
churn["segment_id"] = segments.fit_predict(churn[behaviour])
profile = churn.groupby("segment_id").agg(
    customers=("orders", "size"), orders=("orders", "mean"),
    avg_order=("avg_order_value", "mean"), days_since=("days_since_last_order", "mean"),
    tenure=("tenure_days", "mean"), churn_rate=("churned", "mean"))
print(profile.round(2))

First, the honest part. The silhouette score measures how much closer each customer is to its own cluster than to the next one, from -1 to 1. Scores around 0.25 for every k mean the customers don’t fall into crisp natural groups; they form a continuum. That’s typical for behavioural data. It doesn’t make clustering useless, but it means k is your choice, made for usefulness, not a fact you discovered.

Then the useful part. Profiling the four clusters gives segments a marketer can name:

  • Lapsed occasionals (129): two orders, last one about 310 days ago. 79% churn.
  • Big-ticket buyers (41): few orders averaging $600. 66% churn.
  • Regulars (79): eight orders, the last about 100 days ago. 33% churn.
  • New and recent (149): short tenure, ordered about two months ago. 44% churn.

Churn rates differ sharply between segments even though k-means never saw churned. That’s evidence the segments capture something real, and it gives marketing four different conversations to have instead of one.

k-means isn’t the only option. It assumes roughly round clusters of similar size, an assumption that is usually good enough for behavioural segments. Hierarchical clustering (AgglomerativeClustering) shows how groups merge at different levels, and DBSCAN finds dense regions of any shape and labels outliers as noise. Start with k-means for segmentation; switch when its assumptions clearly don’t fit your data.

PCA: fewer columns, most of the information

Many datasets have dozens of correlated columns. The breast cancer data has 30 measurements of cell nuclei, many of them variations on size. Principal component analysis finds new axes, each a weighted mix of the original columns, ordered by how much of the data’s variation they capture. Keep the first few and you have a compact summary.

Python
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
pca = make_pipeline(StandardScaler(), PCA()).fit(X)
ratios = pca[-1].explained_variance_ratio_
print("first two components keep:", ratios[:2].round(3), "=", round(ratios[:2].sum(), 3))
print("components needed for 90%:", int(np.argmax(np.cumsum(ratios) >= 0.90)) + 1)

coords = make_pipeline(StandardScaler(), PCA(n_components=2)).fit_transform(X)
for label, name in [(0, "malignant"), (1, "benign")]:
    print(name, "average position:", coords[y == label].mean(axis=0).round(2))

Two components keep 63% of the variation in 30 scaled columns, and seven keep 90%. Plot the two coordinates and malignant and benign tumours sit on opposite sides of the first axis, even though PCA never saw the labels. That’s why a 2D PCA plot is a standard first look at any wide dataset.

Both techniques here are classic and cheap. The next lesson is about models that learn their own features from raw inputs: neural networks.

Key takeaways

  • k-means alternates two steps, assign each point to its nearest centre and move each centre to the mean of its points, until nothing changes.
  • Clustering uses distances, so scale features first, and set n_init and random_state for repeatable results.
  • Clusters are only useful if you can describe them; profile each one with group means before giving it a name.
  • A low silhouette score means the data has no crisp natural groups; segments can still be useful, but they’re a choice, not a discovery.
  • PCA finds new axes that capture the most variance; explained_variance_ratio_ tells you how much each component keeps.

Further reading

Check your understanding

3 questions

Question 1 of 3

You run k-means on raw churn columns without scaling. Which column will dominate the clusters?

Question 2 of 3

Silhouette scores for k = 2 to 7 all sit between 0.25 and 0.27. What does that tell you?

Question 3 of 3

PCA on the 30 breast cancer features gives explained_variance_ratio_ of 0.44 and 0.19 for the first two components. What does a 2D plot of them show?

Practice

Python exercise

Segments and a compressed view

  1. Fix segments so the customers are scaled before k-means (4 clusters, n_init=10, random_state=42), and get one label per customer in labels.
  2. churn_by_cluster: the churn rate of each cluster, as a Series indexed by cluster label. highest_rate: the largest of those rates.
  3. wine_var_2: the share of variance that the first two principal components keep for the wine dataset’s 13 features, after standard scaling.

Data files: churn.csv

import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.datasets import load_wine
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

churn = pd.read_csv("churn.csv")
behaviour = ["orders", "total_spent", "avg_order_value", "days_since_last_order",
             "tenure_days", "mobile_share"]

# 1. Segment customers into 4 clusters: StandardScaler, then KMeans(n_clusters=4, n_init=10, random_state=42)
segments = make_pipeline(KMeans(n_clusters=4, n_init=10, random_state=42))
labels = segments.fit_predict(churn[behaviour])

# 2. Churn rate per cluster (a Series indexed by cluster label), and the highest rate
churn_by_cluster = None
highest_rate = None

# 3. PCA on the wine data (13 scaled features): share of variance kept by the first 2 components
X_wine, y_wine = load_wine(return_X_y=True)
wine_var_2 = None
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • segments scales before clustering
  • labels hold the four expected segments
  • churn_by_cluster and highest_rate are correct
  • wine_var_2 is the variance kept by two components