🎯 What You'll Learn

  • Understand what clustering is and how it differs from supervised classification
  • Walk through the K-Means algorithm step by step: centroid initialization, assignment, and update
  • Minimize the Within-Cluster Sum of Squares (WCSS/inertia) objective and understand why K alone doesn't minimize it
  • Choose the optimal K using the elbow method and silhouette score
  • Implement K-Means++ with scikit-learn and apply it to a real customer segmentation problem

1 What Is Clustering?

Every algorithm we've seen so far in Phases 1 and 2 — linear regression, decision trees, SVMs, gradient boosting — requires labeled training data. A human expert provided the answers (house prices, disease diagnoses, customer churn outcomes) and the algorithm learned to replicate them. This is supervised learning.

Clustering is unsupervised: you have a dataset with no labels at all, and your goal is to discover the hidden structure — the natural groupings — that exist within the data itself. No one tells the algorithm what a "group" is; the algorithm infers it purely from patterns in the feature space.

Clustering appears everywhere in practice:

  • Customer segmentation: group customers by purchase behavior to personalize marketing campaigns
  • Document clustering: group news articles or research papers by topic automatically
  • Image compression: replace each pixel's color with the nearest cluster centroid, reducing color palette size
  • Gene expression analysis: find groups of genes with similar expression patterns across conditions
  • Anomaly detection: points that belong to no cluster (or very small clusters) are likely anomalies
🔑
Clustering vs Classification

Classification predicts which pre-defined category a new point belongs to — the categories were defined by the labeling process. Clustering discovers the categories themselves from unlabelled data. The clusters have no names until a human inspects them and decides what they represent. This is both the power and the challenge of unsupervised learning.

In [1]:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs

# Simulate unlabelled data — three natural clusters
X, _ = make_blobs(n_samples=300, centers=3, cluster_std=0.8, random_state=42)

print(f"Dataset shape: {X.shape}")     # (300, 2)
print(f"No labels — purely X")

# Visualize the raw (unlabelled) data
plt.figure(figsize=(7, 5))
plt.scatter(X[:, 0], X[:, 1], alpha=0.6, color='steelblue', edgecolors='white', linewidth=0.3)
plt.title("Unlabelled Data — Can You See the Groups?")
plt.xlabel("Feature 1")
plt.ylabel("Feature 2")
plt.tight_layout()
plt.savefig('raw_data.png', dpi=150)

2 The K-Means Algorithm

K-Means is the most widely used clustering algorithm. Given a dataset of n points and a chosen number of clusters K, it finds K "centroids" (cluster centers) such that each data point is assigned to its nearest centroid.

The algorithm is beautifully simple — just four repeating steps:

  1. Choose K: Decide how many clusters you want. This is your only required input.
  2. Initialize centroids: Randomly place K centroid positions in the feature space (or use K-Means++, covered in Section 6).
  3. Assign each point to the nearest centroid: Compute the Euclidean distance from each data point to every centroid; assign the point to the centroid it is closest to.
  4. Update centroids: Move each centroid to the mean (average position) of all points currently assigned to it.
  5. Repeat steps 3–4 until no point changes its cluster assignment (convergence).
In [2]:
import numpy as np

def euclidean_distance(a, b):
    return np.sqrt(np.sum((a - b) ** 2))

def kmeans_from_scratch(X, K, max_iters=100, random_state=42):
    """Minimal K-Means implementation to illustrate the algorithm."""
    np.random.seed(random_state)
    n_samples, n_features = X.shape

    # Step 2: randomly choose K data points as initial centroids
    idx = np.random.choice(n_samples, K, replace=False)
    centroids = X[idx].copy()

    labels = np.zeros(n_samples, dtype=int)

    for iteration in range(max_iters):
        # Step 3: assign each point to nearest centroid
        new_labels = np.array([
            np.argmin([euclidean_distance(x, c) for c in centroids])
            for x in X
        ])

        # Check convergence
        if np.all(new_labels == labels):
            print(f"Converged after {iteration} iterations")
            break
        labels = new_labels

        # Step 4: update centroids to cluster mean
        for k in range(K):
            mask = labels == k
            if mask.sum() > 0:
                centroids[k] = X[mask].mean(axis=0)

    return labels, centroids

# Test on synthetic data
from sklearn.datasets import make_blobs
X, y_true = make_blobs(n_samples=300, centers=3, cluster_std=0.8, random_state=42)

labels, centroids = kmeans_from_scratch(X, K=3)
print(f"Cluster sizes: {np.bincount(labels)}")
print(f"Centroids:\n{centroids.round(3)}")
# Converged after 5 iterations
# Cluster sizes: [100 100 100]  (approximately)
💡
Convergence Is Guaranteed — But Not to the Global Optimum

K-Means always converges because WCSS (see Section 3) decreases or stays the same at every step and is bounded below by 0. However, it may converge to a local minimum — a suboptimal assignment that depends on the random initialization. This is why sklearn runs K-Means multiple times with different seeds (n_init=10) and keeps the best result.

Watch It Run: K-Means Step by Step

K-Means is not a one-shot calculation — it's an iterative process that alternates between two steps until the assignments stop changing. The simulator below runs the exact algorithm from the code above on a small synthetic dataset with natural-looking groups. Choose a number of clusters K, then click Step repeatedly to watch centroids (the large ✕ markers) jump to the mean of their assigned points, and watch points switch color as they get reassigned to a closer centroid. Click Reset to re-randomize the starting centroids, or Run to Convergence to fast-forward to the final result.

Number of Clusters (K) 4

Iteration 0 — initial centroids placed (K-Means++ style seeding). Click Step to assign points.

3 The Objective Function: WCSS

K-Means is not just a heuristic — it is formally minimizing a mathematical objective called the Within-Cluster Sum of Squares (WCSS), also called inertia:

WCSS = Σₖ Σₓ∈Cₖ ‖x − μₖ‖²

Where Cₖ is the set of points in cluster k and μₖ is the centroid (mean) of cluster k. In plain English: for every point in every cluster, compute the squared distance to its cluster's centroid, then sum them all up. Lower WCSS means tighter, more compact clusters — a better solution.

In [3]:
import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs

X, _ = make_blobs(n_samples=300, centers=3, cluster_std=0.8, random_state=42)

# Fit K-Means with K=3
model = KMeans(n_clusters=3, init='k-means++', n_init=10, random_state=42)
model.fit(X)

print(f"Cluster labels (first 10): {model.labels_[:10]}")
print(f"Cluster centroids:\n{model.cluster_centers_.round(3)}")
print(f"WCSS (inertia): {model.inertia_:.2f}")

# Manually verify inertia
wcss = 0
for k in range(3):
    mask = model.labels_ == k
    cluster_points = X[mask]
    centroid = model.cluster_centers_[k]
    wcss += np.sum((cluster_points - centroid) ** 2)
print(f"Manual WCSS:    {wcss:.2f}")   # should match model.inertia_

# WCSS decreases as K increases — always!
print("\nWCSS vs K:")
for k in range(1, 8):
    km = KMeans(n_clusters=k, n_init=10, random_state=42)
    km.fit(X)
    print(f"  K={k}: WCSS={km.inertia_:.1f}")
⚠️
WCSS Always Decreases With More Clusters

K=n (one cluster per point) gives WCSS=0 — perfect, but useless. You cannot choose K simply by minimizing WCSS. You need an external criterion — the elbow method or silhouette score — to find a meaningful K that balances compactness with simplicity.

4 Choosing K: The Elbow Method

The elbow method is the most intuitive approach for choosing K. The idea: plot WCSS against K for a range of values (say 1 to 15). As K increases, WCSS drops steeply at first — each new cluster genuinely helps. Once K exceeds the true number of clusters, adding more clusters gives diminishing returns. The plot "elbows" at the optimal K.

In [4]:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs

# Dataset with 4 natural clusters
X, _ = make_blobs(n_samples=400, centers=4, cluster_std=0.9, random_state=42)

# Compute WCSS for K = 1 to 15
wcss_values = []
K_range = range(1, 16)

for k in K_range:
    km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42)
    km.fit(X)
    wcss_values.append(km.inertia_)
    print(f"K={k:2d}: WCSS={km.inertia_:8.1f}")

# Plot the elbow curve
plt.figure(figsize=(9, 5))
plt.plot(K_range, wcss_values, 'bo-', markersize=8, linewidth=2)
plt.axvline(x=4, color='red', linestyle='--', alpha=0.7, label='True K=4')
plt.xlabel('Number of Clusters (K)', fontsize=12)
plt.ylabel('WCSS (Inertia)', fontsize=12)
plt.title('Elbow Method — Choosing Optimal K', fontsize=14)
plt.xticks(K_range)
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig('elbow_method.png', dpi=150)

# The "elbow" at K=4 — biggest rate of change drops off after K=4
wcss_array = np.array(wcss_values)
second_derivative = np.diff(np.diff(wcss_array))
elbow_k = np.argmax(second_derivative) + 2   # +2 for offset
print(f"\nElbow detected at K = {elbow_k}")
💡
When the Elbow Is Ambiguous

Real-world data rarely has a sharp elbow. If the elbow plot looks like a smooth curve, use silhouette score (Section 5) as a second opinion. In practice, try 2–3 candidate K values from the elbow region, run silhouette analysis on each, and pick the one with the highest average silhouette score.

Here is the actual elbow curve computed on the same synthetic dataset used in the simulator above (run K-Means to convergence for each K from 1 to 8, recording the final WCSS). Notice how steeply WCSS drops for the first few values of K, then flattens out — the "elbow" marks the point of diminishing returns:

WCSS (inertia) vs K for the lesson's synthetic dataset. The elbow is marked in red.

5 Choosing K: Silhouette Score

The silhouette score is a more principled metric that measures how well each point fits its assigned cluster compared to the next-best cluster. For a single point i:

  • a(i) = mean distance from point i to all other points in the same cluster (intra-cluster cohesion)
  • b(i) = mean distance from point i to all points in the nearest different cluster (inter-cluster separation)
  • s(i) = (b(i) − a(i)) / max(a(i), b(i))

Silhouette scores range from -1 to +1: +1 means the point is perfectly placed in its cluster; 0 means it's on the boundary; -1 means it's likely in the wrong cluster. The overall silhouette score is the average over all points.

In [5]:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, silhouette_samples
from sklearn.datasets import make_blobs

X, _ = make_blobs(n_samples=400, centers=4, cluster_std=0.9, random_state=42)

# Silhouette score for K = 2 to 10
print(f"{'K':>4}  {'Silhouette Score':>18}  {'WCSS':>12}")
print("-" * 40)
silhouette_scores = []

for k in range(2, 11):
    km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42)
    labels = km.fit_predict(X)
    sil_score = silhouette_score(X, labels)
    silhouette_scores.append(sil_score)
    print(f"K={k:2d}  {sil_score:>18.4f}  {km.inertia_:>12.1f}")

# K=4 should show the highest silhouette score
best_k = np.argmax(silhouette_scores) + 2
print(f"\nBest K by silhouette: {best_k}")

# Plot silhouette scores
plt.figure(figsize=(9, 5))
plt.plot(range(2, 11), silhouette_scores, 'go-', markersize=8, linewidth=2)
plt.axvline(x=best_k, color='red', linestyle='--', alpha=0.7, label=f'Best K={best_k}')
plt.xlabel('Number of Clusters (K)', fontsize=12)
plt.ylabel('Average Silhouette Score', fontsize=12)
plt.title('Silhouette Analysis for Choosing K', fontsize=14)
plt.xticks(range(2, 11))
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig('silhouette_scores.png', dpi=150)

# Per-sample silhouette values to diagnose cluster quality
km_best = KMeans(n_clusters=best_k, init='k-means++', n_init=10, random_state=42)
labels_best = km_best.fit_predict(X)
sample_silhouette = silhouette_samples(X, labels_best)
for k in range(best_k):
    cluster_sil = sample_silhouette[labels_best == k]
    print(f"Cluster {k}: mean silhouette = {cluster_sil.mean():.4f}, "
          f"n_points = {(labels_best == k).sum()}")

6 K-Means++ Initialization

Standard K-Means chooses initial centroids uniformly at random from the data points. This can lead to poor local minima — for example, if two initial centroids land in the same natural cluster, the algorithm may never recover the true structure.

K-Means++ (Arthur & Vassilvitskii, 2007) uses a smarter initialization: each new centroid is chosen with probability proportional to its squared distance from the nearest already-chosen centroid. Points far from existing centroids are more likely to be chosen. This spreads the initial centroids across the data, dramatically reducing the chance of bad local minima.

In [6]:
import numpy as np
import time
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs

# Difficult dataset: many clusters, some tight, some loose
X, _ = make_blobs(n_samples=2000, centers=8, cluster_std=1.2, random_state=42)

results = []
for init_method in ['random', 'k-means++']:
    wcss_list = []
    times = []
    for seed in range(20):
        t0 = time.time()
        km = KMeans(
            n_clusters=8,
            init=init_method,
            n_init=1,          # single init to measure variance
            max_iter=300,
            random_state=seed
        )
        km.fit(X)
        wcss_list.append(km.inertia_)
        times.append(time.time() - t0)
    results.append({
        'init': init_method,
        'mean_wcss': np.mean(wcss_list),
        'std_wcss': np.std(wcss_list),
        'min_wcss': np.min(wcss_list),
        'mean_time': np.mean(times)
    })
    print(f"\n{init_method}:")
    print(f"  Mean WCSS: {np.mean(wcss_list):.1f} ± {np.std(wcss_list):.1f}")
    print(f"  Best WCSS: {np.min(wcss_list):.1f}")
    print(f"  Worst WCSS: {np.max(wcss_list):.1f}")

# K-Means++ production usage (recommended defaults)
km_best = KMeans(
    n_clusters=8,
    init='k-means++',    # smarter initialization (default in sklearn)
    n_init=10,           # run 10 times, keep best result
    max_iter=300,        # maximum iterations per run
    tol=1e-4,            # convergence tolerance
    random_state=42
)
km_best.fit(X)
print(f"\nProduction KMeans++ (n_init=10): WCSS = {km_best.inertia_:.1f}")
🔑
K-Means++ Is the Default in Scikit-learn

Since sklearn 0.24, init='k-means++' is the default. Unless you have a specific reason to use random initialization, always keep this default. The theoretical guarantee of K-Means++ is that its expected WCSS is at most O(log K) times the optimal WCSS — much better than random.

7 Full Implementation with Scikit-learn

Here is a complete, production-quality K-Means workflow: preprocessing, fitting, evaluating, and visualizing clusters.

In [7]:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score
from sklearn.datasets import make_blobs

# ── Step 1: Prepare data ──
X, y_true = make_blobs(n_samples=500, centers=4, cluster_std=0.8, random_state=42)

# Step 2: Scale features (CRITICAL — K-Means uses Euclidean distance)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# ── Step 3: Fit K-Means ──
km = KMeans(
    n_clusters=4,
    init='k-means++',
    n_init=10,
    random_state=42
)
km.fit(X_scaled)

# ── Step 4: Inspect results ──
print("=== K-Means Results ===")
print(f"Labels (first 10):  {km.labels_[:10]}")
print(f"Inertia (WCSS):     {km.inertia_:.4f}")
print(f"Iterations to conv: {km.n_iter_}")
print(f"Cluster sizes:      {np.bincount(km.labels_)}")
print(f"Silhouette score:   {silhouette_score(X_scaled, km.labels_):.4f}")

# ── Step 5: Centroids in original feature space ──
centroids_original = scaler.inverse_transform(km.cluster_centers_)
print(f"\nCentroids (original scale):\n{centroids_original.round(3)}")

# ── Step 6: Predict cluster for new data ──
X_new = np.array([[0.5, 1.2], [-3.1, 2.8]])
X_new_scaled = scaler.transform(X_new)
new_labels = km.predict(X_new_scaled)
print(f"\nNew point clusters: {new_labels}")

# ── Step 7: Visualize ──
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
colors = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3']

# True clusters
for k in range(4):
    mask = y_true == k
    axes[0].scatter(X[mask, 0], X[mask, 1], c=colors[k], alpha=0.6, label=f'True {k}')
axes[0].set_title('True Clusters (for reference)')
axes[0].legend()

# K-Means clusters
for k in range(4):
    mask = km.labels_ == k
    axes[1].scatter(X_scaled[mask, 0], X_scaled[mask, 1], c=colors[k], alpha=0.6, label=f'Cluster {k}')
axes[1].scatter(km.cluster_centers_[:, 0], km.cluster_centers_[:, 1],
                c='black', marker='X', s=200, zorder=5, label='Centroids')
axes[1].set_title('K-Means Clusters (scaled space)')
axes[1].legend()
plt.tight_layout()
plt.savefig('kmeans_result.png', dpi=150)

8 Limitations of K-Means

K-Means works beautifully on globular, well-separated clusters of similar size. It struggles — and fails badly — in several common real-world situations:

Limitation Why it fails Solution
Non-convex shapes (crescents, rings) Assigns by Euclidean distance to centroid — can't follow curved manifolds DBSCAN (Lesson 29)
Different cluster sizes Centroid-based assignment favours equal-sized clusters Gaussian Mixture Models (Lesson 30)
Different cluster densities Dense small cluster may be split to balance another large cluster DBSCAN, HDBSCAN (Lesson 29)
Unscaled features Large-scale features dominate Euclidean distance Always use StandardScaler first
Outliers Outliers get absorbed into clusters and pull centroids away from true centers Remove outliers first; or use K-Medoids
K must be specified No automatic K discovery DBSCAN, HDBSCAN (no K needed)
In [8]:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.datasets import make_moons, make_circles

fig, axes = plt.subplots(2, 2, figsize=(12, 10))

datasets = [
    ('Moons (non-convex)', make_moons(n_samples=300, noise=0.05, random_state=42)[0]),
    ('Circles (concentric)', make_circles(n_samples=300, noise=0.05, factor=0.5, random_state=42)[0]),
]

for row, (name, X_fail) in enumerate(datasets):
    # K-Means result
    km = KMeans(n_clusters=2, n_init=10, random_state=42)
    labels = km.fit_predict(X_fail)
    colors = ['#e41a1c' if l == 0 else '#377eb8' for l in labels]

    axes[row, 0].scatter(X_fail[:, 0], X_fail[:, 1], c=colors, alpha=0.7)
    axes[row, 0].scatter(km.cluster_centers_[:, 0], km.cluster_centers_[:, 1],
                         c='black', marker='X', s=200, zorder=5)
    axes[row, 0].set_title(f'K-Means on {name} — FAILS')

    # Show what K-Means "should" find
    # (correct assignment using true labels for illustration)
    from sklearn.datasets import make_moons, make_circles
    if row == 0:
        _, y_true = make_moons(n_samples=300, noise=0.05, random_state=42)
    else:
        _, y_true = make_circles(n_samples=300, noise=0.05, factor=0.5, random_state=42)
    colors_true = ['#e41a1c' if l == 0 else '#377eb8' for l in y_true]
    axes[row, 1].scatter(X_fail[:, 0], X_fail[:, 1], c=colors_true, alpha=0.7)
    axes[row, 1].set_title(f'True Structure — what we want')

plt.suptitle("K-Means Fails on Non-Convex Shapes → Use DBSCAN Instead", fontsize=13)
plt.tight_layout()
plt.savefig('kmeans_limitations.png', dpi=150)
🌍

Real-World Spotlight: Customer Segmentation with RFM Analysis

One of the most impactful applications of K-Means in business is customer segmentation using RFM analysis: Recency (days since last purchase), Frequency (number of purchases in a period), and Monetary value (total spend). These three features capture a customer's engagement and value in a compact, interpretable way.

In [9]:
import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score
import matplotlib.pyplot as plt

np.random.seed(42)
n = 2000

# Simulate RFM data for 2000 customers
df = pd.DataFrame({
    'customer_id': range(n),
    'recency':    np.concatenate([
        np.random.randint(1,  15, 500),   # Champions: bought very recently
        np.random.randint(60, 120, 500),  # At Risk: haven't bought in a while
        np.random.randint(1,  30, 500),   # New: just joined
        np.random.randint(120, 365, 500), # Hibernating: not seen in months
    ]),
    'frequency':  np.concatenate([
        np.random.randint(20, 50, 500),   # Champions: buy often
        np.random.randint(5,  15, 500),   # At Risk: used to buy often
        np.random.randint(1,   5, 500),   # New: few purchases
        np.random.randint(1,   5, 500),   # Hibernating: rarely bought
    ]),
    'monetary':   np.concatenate([
        np.random.uniform(500, 2000, 500),  # Champions: high spenders
        np.random.uniform(200, 800,  500),  # At Risk: medium
        np.random.uniform(50,  300,  500),  # New: low (just starting)
        np.random.uniform(10,  200,  500),  # Hibernating: low
    ]).round(2)
})

print("RFM Dataset:")
print(df[['recency', 'frequency', 'monetary']].describe().round(2))

# ── Scale features ──
features = ['recency', 'frequency', 'monetary']
scaler = StandardScaler()
X_rfm = scaler.fit_transform(df[features])

# ── Choose K with elbow + silhouette ──
wcss, sil_scores = [], []
K_range = range(2, 9)
for k in K_range:
    km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42)
    labels = km.fit_predict(X_rfm)
    wcss.append(km.inertia_)
    sil_scores.append(silhouette_score(X_rfm, labels))

print("\nK  WCSS      Silhouette")
for k, w, s in zip(K_range, wcss, sil_scores):
    marker = " ← best" if s == max(sil_scores) else ""
    print(f"K={k}: {w:8.1f}  {s:.4f}{marker}")

# ── Fit optimal K=4 ──
km_final = KMeans(n_clusters=4, init='k-means++', n_init=10, random_state=42)
df['segment'] = km_final.fit_predict(X_rfm)

# ── Interpret segments by computing cluster means ──
segment_profiles = df.groupby('segment')[features].mean().round(1)
segment_profiles['count'] = df.groupby('segment').size()
print("\nSegment Profiles (original scale):")
print(segment_profiles.sort_values('recency'))

# ── Name the segments ──
# Low recency + high frequency + high monetary → Champions
# High recency + medium everything → At Risk
# Low recency + low frequency → New Customers
# Very high recency + low everything → Hibernating
segment_names = {
    segment_profiles['recency'].idxmin(): 'Champions',
    segment_profiles['monetary'].idxmax(): 'High Value',
    segment_profiles['recency'].idxmax(): 'Hibernating',
}
# Fill remaining
for s in range(4):
    if s not in segment_names:
        segment_names[s] = 'New / Growing'

df['segment_name'] = df['segment'].map(segment_names)
print("\nSegment distribution:")
print(df['segment_name'].value_counts())

# ── Visualize: Recency vs Monetary, colored by segment ──
colors = {'Champions': '#2ecc71', 'High Value': '#3498db',
          'New / Growing': '#f39c12', 'Hibernating': '#e74c3c'}
plt.figure(figsize=(9, 6))
for seg, grp in df.groupby('segment_name'):
    plt.scatter(grp['recency'], grp['monetary'], label=seg,
                alpha=0.4, c=colors.get(seg, 'gray'), s=20)
plt.xlabel('Recency (days since last purchase)')
plt.ylabel('Monetary Value ($)')
plt.title('Customer Segments — RFM K-Means (K=4)')
plt.legend()
plt.tight_layout()
plt.savefig('rfm_segments.png', dpi=150)

# ── Business actions per segment ──
actions = {
    'Champions':     'Reward with loyalty program; ask for reviews',
    'High Value':    'Upsell premium products; priority support',
    'New / Growing': 'Onboarding emails; first-purchase incentives',
    'Hibernating':   'Win-back campaign: "We miss you" 30% discount'
}
print("\nMarketing Actions:")
for seg, action in actions.items():
    print(f"  {seg}: {action}")

This pipeline transforms raw transaction logs into actionable customer intelligence. The "Champions" segment typically represents 15–20% of customers but 50–60% of revenue — making it critical to retain them. The "Hibernating" segment is the prime win-back opportunity. Without clustering, these segments would require manual rule-creation by a business analyst; K-Means discovers them automatically from the data.

✍️ Practice Exercises

  1. Load the Iris dataset (4 features, 3 known classes). Apply K-Means with K=3 after scaling. Compare cluster assignments to the true species labels using adjusted_rand_score from sklearn.metrics. How well did K-Means recover the true structure?
  2. Run the elbow method on the Iris dataset (K=1 to 10). Does the elbow clearly indicate K=3? Then run silhouette analysis — does it agree?
  3. Use make_moons(n_samples=300, noise=0.05) to create a crescent dataset. Apply K-Means with K=2 and visualize the result. Explain in one paragraph why K-Means fails here.
  4. Implement mini-batch K-Means using sklearn.cluster.MiniBatchKMeans on a dataset of 100,000 points. Compare WCSS and runtime against standard KMeans. What trade-off are you making?
▶ Hints
In [10]:
from sklearn.datasets import load_iris
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import adjusted_rand_score, silhouette_score

iris = load_iris()
X_iris = StandardScaler().fit_transform(iris.data)

km = KMeans(n_clusters=3, init='k-means++', n_init=10, random_state=42)
labels = km.fit_predict(X_iris)
print(f"ARI: {adjusted_rand_score(iris.target, labels):.4f}")
# ARI close to 1.0 = perfect recovery; 0 = random; negative = worse than random

📚 Primary Source for This Lesson

scikit-learn: K-Means Clustering
The official guide covers the algorithm, initialization methods, mini-batch variant, and complexity analysis. For the K-Means++ initialization paper, see Arthur & Vassilvitskii (2007) "k-means++: The Advantages of Careful Seeding" (SODA 2007). For silhouette analysis, see Rousseeuw (1987) "Silhouettes: A graphical aid to the interpretation and validation of cluster analysis."

💬 Getting very different clusters each run? Unsure why you need to scale? Confused by silhouette values? Paste your code and describe what you expected — your AI tutor can walk through the algorithm and explain exactly what's happening with your data.