Customer Segmentation with k-means and PCA: Scaling, Choosing k, Stability and Deployment
Hands-on lab · IDE in your browser

Customer Segmentation with k-means and PCA: Scaling, Choosing k, Stability and Deployment

Segment 2,400 meal-kit customers without labels. Standardise and log-transform features so distances mean something, write k-means with k-means++ from scratch, choose the number of segments with silhouette and bootstrap stability, map customers onto principal components computed with the SVD, and assign new sign-ups with the original scaler and centroids.

Time
50 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit50 min · 5 stepsSession running
4 / 5 steps passingPut the segments to work · step 5 of 5
segments.py▶ Run✓ Check
# ---------- Step 5: put the segments to work ---------- def profile(df, labels):    """One row per cluster (index = cluster number): the median of every FEATURES column in its original units,    plus "share", the fraction of customers in the cluster."""    def segment_new(new_df, scaler, centroids):    """Cluster numbers for new customers, measured with the scaler and centroids learned from the original ones.""" 
TerminalOutput

The job

Tallow & Thyme sends every meal-kit customer the same emails. Marketing wants a few customer types it can write to differently. You find them in 90 days of order data, check they are real, and make them usable for tomorrow's sign-ups.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Distances that mean something

    Tallow & Thyme sells meal kits and sends every customer the same emails.

    You writeScaler()
  2. 2

    k-means

    k-means repeats two moves until nothing changes: give each customer to its nearest centroid, then move each centroid to the mean of its customers.

    You writeinit_centroids()
  3. 3

    How many segments

    Inertia always falls as k grows, since more centroids sit closer to everyone, so it cannot choose k on its own.

    You writestability()
  4. 4

    A map of the customers

    Seven columns cannot be drawn.

    You writepca()
  5. 5

    Put the segments to work

    Marketing reads segments as a table of medians in real units, not standardised numbers.

    You writeprofile()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Distances that mean something

Tallow & Thyme sells meal kits and sends every customer the same emails. Marketing wants a handful of customer types, each with its own offers. customers.csv holds 90 days of behaviour for 2,400 customers, and nobody has labelled them: this is unsupervised learning. k-means groups customers that are close together, so first decide what "close" means.

Raw, tenure_days runs into the thousands while veg_share stays between 0 and 1. In a distance, a difference of 100 days outweighs any change in diet. Standardising gives every column mean 0 and standard deviation 1. Two columns have long right tails: a few customers spend 5 times the median basket. Taking their log first stops those few customers from setting the scale.

Do this

1. Write Scaler, features() and prepare() in segments.py.

2. Run it. It clusters the raw columns and then yours, with scikit-learn's k-means, and prints each cluster's medians. Which column separates the raw clusters?

segments.py, the file you edit113 lines
"""Customer segments for Tallow & Thyme's meal kits. You write the functions marked TODO, one step at a time."""
import numpy as np
import pandas as pd
from sklearn.metrics import adjusted_rand_score, silhouette_score

FEATURES = ["orders_90d", "avg_basket_eur", "veg_share", "weekend_share", "discount_share", "tenure_days",
            "support_tickets"]
LOG_FEATURES = ["avg_basket_eur", "tenure_days"]   # long right tails: a few huge values would dominate


def load(path="customers.csv"):
    return pd.read_csv(path)


# ---------- Step 1: make distances mean something ----------

class Scaler:
    """Learns each column's mean and standard deviation from the data it is fitted on, and applies those."""

    def fit(self, X):
        # TODO (Step 1): store the column means and standard deviations in self.mean_ and self.std_
        # (a std of 0 becomes 1); return self.
        raise NotImplementedError("Step 1: write Scaler.fit()")

    def transform(self, X):
        # TODO (Step 1): (X - self.mean_) / self.std_.
        raise NotImplementedError("Step 1: write Scaler.transform()")


def features(df):
    """The FEATURES columns as a float array, with np.log applied to the LOG_FEATURES."""
    # TODO (Step 1): df[FEATURES] as floats, np.log on the LOG_FEATURES columns, .to_numpy().
    raise NotImplementedError("Step 1: write features()")


def prepare(df):
    """(X, scaler): features(df) standardised by a Scaler fitted on them."""
    # TODO (Step 1): fit a Scaler on features(df) and return (its transform, the scaler).
    raise NotImplementedError("Step 1: write prepare()")


# ---------- Step 2: k-means ----------

def init_centroids(X, k, rng):
    """k-means++: the first centroid is a random row; each next one is a row drawn with probability proportional to
    its squared distance from the nearest centroid chosen so far."""
    raise NotImplementedError("init_centroids() arrives in Step 2")


def assign(X, centroids):
    """Index of the nearest centroid (squared Euclidean distance) for every row."""
    raise NotImplementedError("assign() arrives in Step 2")


def kmeans(X, k, seed=0, max_iter=100):
    """(labels, centroids, inertia). Alternate assign() and moving each centroid to the mean of its rows until no
    label changes; a centroid that loses all its rows keeps its position. inertia = sum of squared distances of rows
    to their centroid."""
    raise NotImplementedError("kmeans() arrives in Step 2")


def best_of(X, k, n_init=5):
    """The kmeans() result with the lowest inertia over seeds 0 .. n_init - 1."""
    raise NotImplementedError("best_of() arrives in Step 2")


# ---------- Step 3: how many segments ----------

def stability(X, k, n_boot=5):
    """Mean adjusted Rand index between best_of(X, k) labels and the labels a model fitted on a bootstrap sample
    (np.random.default_rng(b).choice(len(X), len(X)), b = 0 .. n_boot - 1) gives every row of X."""
    raise NotImplementedError("stability() arrives in Step 3")


def scan_k(X, ks):
    """{k: {"inertia", "silhouette", "stability"}} for each k; silhouette_score(X, labels, sample_size=1500,
    random_state=0) of best_of(X, k)."""
    raise NotImplementedError("scan_k() arrives in Step 3")


def choose_k(scan, min_stability=0.9):
    """The k with the highest silhouette among those whose stability is at least min_stability."""
    raise NotImplementedError("choose_k() arrives in Step 3")


K = None


# ---------- Step 4: a map of the customers ----------

def pca(X, n=2):
    """(scores, components, explained_ratio) from the SVD of the centred X: components are the top n right
    singular vectors (one per row), scores = centred X @ components.T, explained_ratio = each component's share of
    the total variance (all components, so the n values sum to less than 1)."""
    raise NotImplementedError("pca() arrives in Step 4")


def top_loadings(component, names=FEATURES, n=3):
    """The n (name, weight) pairs with the largest absolute weight in one component, largest first."""
    raise NotImplementedError("top_loadings() arrives in Step 4")


# ---------- Step 5: put the segments to work ----------

def profile(df, labels):
    """One row per cluster (index = cluster number): the median of every FEATURES column in its original units,
    plus "share", the fraction of customers in the cluster."""
    raise NotImplementedError("profile() arrives in Step 5")


def segment_new(new_df, scaler, centroids):
    """Cluster numbers for new customers, measured with the scaler and centroids learned from the original ones."""
    raise NotImplementedError("segment_new() arrives in Step 5")
Provided for you:customers.csvnew_customers.csvtry_it.py

Frequently asked questions

Why standardise features before k-means?

k-means uses Euclidean distance, so a column measured in thousands outweighs one measured between 0 and 1. Standardising gives every column the same scale; a log transform first keeps a few extreme values from dominating a skewed column.

How do you choose k in k-means?

Inertia always falls as k grows, so it cannot decide alone. Silhouette scores measure how cleanly customers separate, and bootstrap stability checks whether the same clusters come back when the data is resampled; pick the best-separated k among the stable ones.

How is PCA related to the SVD?

For centred data, the right singular vectors are the principal components and the squared singular values are proportional to the variance each explains. Projecting onto the first two components gives a 2D map that keeps as much spread as possible.

Finding customer segments in unlabelled data

Clustering is only as good as its distances, and choosing the number of clusters is a judgement that needs evidence. This lab works through both with numpy and scikit-learn. You standardise and log-transform features, write k-means with k-means++ initialisation, compare silhouette scores and bootstrap stability across k, compute PCA with the SVD and read its loadings, and assign new customers without refitting the scaler.