Skip to main content
Scikit-Learn intermediate Lesson 9 of 12

Scikit-Learn Clustering

Group unlabeled data with K-Means, DBSCAN, and hierarchical clustering — and evaluate cluster quality without ground truth labels.

Real-World Scenario

An e-commerce company wants to segment 500,000 customers for targeted marketing. They have purchase history, browsing behavior, and demographics. K-Means segments customers into behavioral groups (high-value loyalists, deal-hunters, casual browsers). DBSCAN identifies unusual shopping patterns as potential fraud. Both are clustering problems.

K-Means Clustering

import numpy as np
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score
from sklearn.datasets import make_blobs
import pandas as pd

rng = np.random.default_rng(42)

# Simulated customer feature matrix
n_customers = 2000
X = np.column_stack([
    rng.exponential(500, n_customers),        # avg_purchase_value
    rng.poisson(8, n_customers),              # num_purchases
    rng.exponential(30, n_customers),         # days_since_last_purchase
    rng.uniform(0, 1, n_customers),           # email_open_rate
])
feature_names = ["avg_value", "n_purchases", "days_inactive", "email_open_rate"]

# Scale features — K-Means is distance-based; scaling is essential
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# ─── Find optimal K using Silhouette Score ─────────────────────────────────

silhouette_scores = {}
inertias = {}

for k in range(2, 11):
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = km.fit_predict(X_scaled)
    silhouette_scores[k] = silhouette_score(X_scaled, labels)
    inertias[k] = km.inertia_

print("K vs Silhouette Score:")
for k, score in silhouette_scores.items():
    bar = "█" * int(score * 30)
    print(f"  K={k}: {score:.4f} {bar}")

best_k = max(silhouette_scores, key=silhouette_scores.get)
print(f"\nBest K: {best_k} (silhouette={silhouette_scores[best_k]:.4f})")

# ─── Train final model with best K ────────────────────────────────────────

km_final = KMeans(n_clusters=best_k, random_state=42, n_init=10)
labels   = km_final.fit_predict(X_scaled)

df = pd.DataFrame(X, columns=feature_names)
df["cluster"] = labels

# Cluster profiles — interpret what each cluster represents
profile = df.groupby("cluster").agg({
    "avg_value":       "mean",
    "n_purchases":     "mean",
    "days_inactive":   "mean",
    "email_open_rate": "mean",
})
profile["size"] = df["cluster"].value_counts().sort_index()
print("\nCluster Profiles:")
print(profile.round(1))

DBSCAN — Density-Based Clustering

import numpy as np
from sklearn.cluster import DBSCAN
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import make_moons

# DBSCAN excels on non-spherical clusters
X, _ = make_moons(n_samples=500, noise=0.1, random_state=42)

# eps: maximum distance between two points in the same neighborhood
# min_samples: minimum points to form a dense region (core point)
db = DBSCAN(eps=0.15, min_samples=5)
labels = db.fit_predict(X)

n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
n_noise    = (labels == -1).sum()

print(f"Clusters found: {n_clusters}")   # should be 2
print(f"Noise points:   {n_noise}")       # outliers labeled -1

# Parameter sensitivity — run a grid to understand eps and min_samples
from sklearn.metrics import silhouette_score

best_score = -1
best_params = {}

for eps in np.arange(0.05, 0.5, 0.05):
    for min_s in [3, 5, 10]:
        db_test = DBSCAN(eps=eps, min_samples=min_s)
        lbls = db_test.fit_predict(X)
        n_c  = len(set(lbls)) - (1 if -1 in lbls else 0)
        if n_c >= 2 and n_c <= 10:
            try:
                score = silhouette_score(X, lbls)
                if score > best_score:
                    best_score  = score
                    best_params = {"eps": eps, "min_samples": min_s}
            except ValueError:
                pass

print(f"\nBest DBSCAN params: {best_params}, Silhouette: {best_score:.4f}")

Hierarchical Clustering

import numpy as np
from sklearn.cluster import AgglomerativeClustering
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score
from scipy.cluster.hierarchy import dendrogram, linkage
from scipy.spatial.distance import pdist

rng = np.random.default_rng(42)
X = rng.standard_normal((200, 4))

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# Agglomerative clustering — no need to choose K upfront (use dendrogram to decide)
# Linkage methods: ward (minimizes variance), complete, average, single
for linkage_method in ["ward", "complete", "average"]:
    model = AgglomerativeClustering(n_clusters=4, linkage=linkage_method)
    labels = model.fit_predict(X_scaled)
    score  = silhouette_score(X_scaled, labels)
    print(f"linkage={linkage_method:8s}: silhouette={score:.4f}")

# Reading a dendrogram to choose K
# The linkage matrix encodes the merge history
Z = linkage(X_scaled, method="ward")
# Cut the dendrogram at a threshold that gives meaningful clusters
# Rule of thumb: cut where the gap between merge distances is largest
print("\nTop 10 merge distances (look for a large gap):")
print(np.sort(Z[:, 2])[-10:].round(3))

Evaluating Cluster Quality

import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import (
    silhouette_score,
    calinski_harabasz_score,
    davies_bouldin_score,
)
from sklearn.datasets import make_blobs
from sklearn.preprocessing import StandardScaler

X, y_true = make_blobs(n_samples=500, centers=4, cluster_std=1.5, random_state=42)

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

km = KMeans(n_clusters=4, random_state=42, n_init=10)
labels = km.fit_predict(X_scaled)

# Internal cluster metrics — don't require ground truth labels

# Silhouette: how similar a point is to its cluster vs the nearest other cluster
# Range: [-1, 1], higher is better
sil = silhouette_score(X_scaled, labels)
print(f"Silhouette score:          {sil:.4f}   (higher = better, max 1.0)")

# Calinski-Harabász: ratio of between-cluster to within-cluster dispersion
# Higher is better; no upper bound
ch = calinski_harabasz_score(X_scaled, labels)
print(f"Calinski-Harabász score:   {ch:.1f}  (higher = better)")

# Davies-Bouldin: average similarity of each cluster to its most similar cluster
# Lower is better, minimum is 0
db = davies_bouldin_score(X_scaled, labels)
print(f"Davies-Bouldin index:      {db:.4f}  (lower = better, min 0)")

# Per-sample silhouette to find poorly assigned points
from sklearn.metrics import silhouette_samples
sample_scores = silhouette_samples(X_scaled, labels)
for cluster_id in range(4):
    mask = labels == cluster_id
    cluster_sil = sample_scores[mask].mean()
    n_negative  = (sample_scores[mask] < 0).sum()
    print(f"Cluster {cluster_id}: avg_sil={cluster_sil:.3f}, "
          f"negative={n_negative}/{mask.sum()} (poorly placed points)")

Real-World: Customer Segmentation Pipeline

import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score

rng = np.random.default_rng(42)
n = 5000

# Simulate customer features
customers = pd.DataFrame({
    "customer_id":     range(n),
    "total_spend":     rng.lognormal(6.5, 1.0, n),
    "num_orders":      rng.poisson(6, n) + 1,
    "avg_order_value": rng.lognormal(4.5, 0.8, n),
    "days_since_last": rng.exponential(45, n),
    "email_open_rate": rng.beta(2, 5, n),
    "return_rate":     rng.beta(1, 10, n),
})

features = ["total_spend", "num_orders", "avg_order_value",
            "days_since_last", "email_open_rate", "return_rate"]

X = customers[features].values
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# Choose K via silhouette
best_k, best_sil = 2, -1
for k in range(2, 9):
    sil = silhouette_score(X_scaled, KMeans(k, n_init=5, random_state=42).fit_predict(X_scaled))
    if sil > best_sil:
        best_k, best_sil = k, sil

km = KMeans(n_clusters=best_k, n_init=10, random_state=42)
customers["segment"] = km.fit_predict(X_scaled)

# Name segments based on profiles
profile = customers.groupby("segment")[features].mean()
print(profile.round(1))

# Assign business-friendly names by highest spending segments
spend_rank = profile["total_spend"].rank(ascending=False).astype(int)
segment_names = {
    spend_rank[spend_rank == 1].index[0]: "High-Value Champions",
    spend_rank[spend_rank == 2].index[0]: "Loyal Customers",
    spend_rank[spend_rank == 3].index[0]: "Potential Loyalists",
    spend_rank[spend_rank == 4].index[0]: "At-Risk Customers",
}
customers["segment_name"] = customers["segment"].map(segment_names).fillna("Other")
print("\nSegment distribution:")
print(customers["segment_name"].value_counts())

Frequently Asked Questions

How do I choose the number of clusters K in K-Means?
Three methods: (1) Elbow method — plot inertia vs K and find where improvement slows. (2) Silhouette score — measures how similar each point is to its cluster vs other clusters; higher is better, peaks at the right K. (3) Domain knowledge — if you're segmenting customers, you might know you want 4–6 segments for practical reasons.
When should I use DBSCAN over K-Means?
Use DBSCAN when clusters have arbitrary shapes (not spherical), the number of clusters is unknown, or you need to identify and label outliers. K-Means assumes spherical clusters and requires K upfront. DBSCAN finds clusters of arbitrary shape automatically and marks low-density points as noise.