Skip to content

ML Clustering

Group data without labels. Clustering is the workhorse of customer segmentation, anomaly pre-screening, and exploratory analysis — but unlike supervised models there are no ground-truth metrics, so validation is a craft, not a function call.

Table of Contents

Installation / Setup

pip install scikit-learn scipy    # or: uv add scikit-learn scipy

Main tools: sklearn.cluster (KMeans, DBSCAN, AgglomerativeClustering, ...), scipy.cluster.hierarchy (dendrograms), sklearn.metrics (silhouette/davies_bouldin), sklearn.preprocessing (scaling).

Core Syntax

Choosing an algorithm

Algorithm Idea When to use Cost
KMeans k spherical centers Large data, rough globular groups, k known-ish fast, O(n·k)
MiniBatchKMeans KMeans on batches Millions of rows fastest
AgglomerativeClustering Hierarchical merge Small data, want a dendrogram, k unknown O(n²)
DBSCAN Density-connected regions Arbitrary shapes, outliers present fast-ish
HDBSCAN Density w/ hierarchy Robust shapes + noise, variable density slower
GaussianMixture Soft clusters (probabilities) Overlapping groups, want P(cluster|x) medium
BisectingKMeans Top-down splitting KMeans quality + hierarchy fast
from sklearn.cluster import KMeans

km = KMeans(n_clusters=4, random_state=0, n_init=10).fit(X_scaled)
labels = km.labels_            # cluster id per row
centers = km.cluster_centers_  # interpretable prototype per cluster

The scaling prerequisite

from sklearn.preprocessing import StandardScaler

X_scaled = StandardScaler().fit_transform(X)   # or RobustScaler for outliers
km = KMeans(n_clusters=4, random_state=0, n_init=10).fit(X_scaled)

Distance-based: a column measured in dollars will dominate one in years. Scale before clustering (unless the raw scale is meaningful, e.g. geocoordinates).

Common Operations

Choosing k — elbow + silhouette

from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
import numpy as np

inertia, sil = [], []
K = range(2, 11)
for k in K:
    km = KMeans(n_clusters=k, random_state=0, n_init=10).fit(X_scaled)
    inertia.append(km.inertia_)
    sil.append(silhouette_score(X_scaled, km.labels_))

# elbow: where inertia stops dropping steeply
# silhouette: pick k maximizing the score (range -1..1, >0.5 strong)
best_k = K[int(np.argmax(sil))]
print(f"best by silhouette: k={best_k}")
Metric What it rewards Range
inertia_ Compact clusters (sum of squared distances) ≥ 0, lower better
silhouette_score Separation + compactness −1..1, higher better
davies_bouldin_score Ratio of within-cluster to between-cluster scatter ≥ 0, lower better
calinski_harabasz_score Between/within variance ratio higher better

DBSCAN — density clusters + noise

from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors

# eps: knee in the k-distance plot
nn = NearestNeighbors(n_neighbors=5).fit(X_scaled)
dist, _ = nn.kneighbors(X_scaled)
kdist = np.sort(dist[:, -1])              # plot this — knee ≈ good eps

db = DBSCAN(eps=0.5, min_samples=10).fit(X_scaled)
print(db.labels_)          # -1 = noise point, otherwise cluster id
print("noise fraction:", (db.labels_ == -1).mean().round(3))

min_samples ≈ 2 × dimensions for noisy data; eps from the k-distance knee. Label −1 rows are outliers — DBSCAN gives you anomaly detection for free.

Hierarchical + dendrogram

from scipy.cluster.hierarchy import linkage, dendrogram, fcluster
import matplotlib.pyplot as plt

Z = linkage(X_scaled, method="ward")
dendrogram(Z, truncate_mode="level", p=5)
plt.title("Hierarchical clustering dendrogram")
plt.show()

labels = fcluster(Z, t=4, criterion="maxclust")   # cut the tree at 4 clusters

Practical Examples

Example 1 — Customer segmentation workflow

import pandas as pd
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler

feats = ["tenure", "monthly_spend", "support_calls", "n_products"]
X = df[feats]
X_scaled = StandardScaler().fit_transform(X)

km = KMeans(n_clusters=4, random_state=0, n_init=10).fit(X_scaled)
df["segment"] = km.labels_

# interpret each cluster in ORIGINAL units (unscale for reading)
profile = df.groupby("segment")[feats].mean().round(2)
print(profile)
print(df.groupby("segment").size())

Reading the profile table is the actual deliverable — name clusters from it ("high-value quiet", "at-risk heavy-support", ...).

Example 2 — Scatter-plot clusters (2D view)

import matplotlib.pyplot as plt
from sklearn.decomposition import PCA

pca = PCA(n_components=2).fit_transform(X_scaled)
plt.scatter(pca[:, 0], pca[:, 1], c=km.labels_, cmap="tab10", s=12, alpha=0.6)
plt.xlabel("PC1"); plt.ylabel("PC2"); plt.colorbar(label="cluster")

PCA reduces to 2D only for visualization; cluster in the full scaled space.

Example 3 — Anomaly pre-screen with DBSCAN

db = DBSCAN(eps=0.6, min_samples=8).fit(X_scaled)
mask_noise = db.labels_ == -1
print(f"{mask_noise.sum()} suspicious rows ({mask_noise.mean():.1%})")
suspicious = df[mask_noise]

Common Patterns

Pattern: cluster-then-supervise

# 1) cluster unlabeled data
df["segment"] = KMeans(n_clusters=5, random_state=0, n_init=10).fit_predict(X_scaled)

# 2) use segments as features or targets downstream
X["segment"] = df["segment"]
# or: train a classifier to assign segments to new customers automatically

Pattern: stable, named segments in production

# fit once, persist the scaler + model, name clusters after analysis
import joblib
joblib.dump({"scaler": scaler, "kmeans": km, "names": names_map}, "segments.joblib")

def segment_customer(row):          # for the API/service layer
    z = loaded["scaler"].transform([row])
    return loaded["names"][loaded["kmeans"].predict(z)[0]]

Pattern: picking k is a dialogue, not a formula

1. elbow on inertia          → coarse candidates
2. silhouette curve          → refine candidate k
3. inspect cluster profiles  → are segments actionable and distinct?
4. stability check           → refit on a 70% subsample; do profiles persist?

The business question ("do these segments get different treatment?") outranks any score.

Best Practices

  • Scale features (StandardScaler/RobustScaler) unless units are comparable by design.
  • Run n_init=10 (or "auto") so KMeans isn't a local-minimum lottery; fix random_state.
  • Choose k with silhouette + profile inspection — not the elbow alone.
  • DBSCAN: tune eps via k-distance plot, min_samples ≈ 2·dim; expect noise (that's a feature).
  • Interpret in original units via group-by means; scaled space is for fitting only.
  • Watch cluster sizes: a 3-row "segment" is usually an outlier artifact.
  • Validate stability by refitting on subsets and comparing profiles.
  • For overlapping segments, GaussianMixture gives soft memberships (predict_proba).

Common Mistakes

Mistake Fix
Clustering unscaled raw features Standardize first
KMeans on high-dimensional sparse or categorical data Reduce dims / encode then scale; consider other algorithms
Judging k purely by inertia elbow Add silhouette + cluster-profile sanity
Forgetting n_init → unstable k every run n_init=10, random_state=0
Reading cluster centers from scaled space Unscale or group by original features
DBSCAN with default eps=0.5 on arbitrary data Tune eps from the k-distance knee
Treating labels as ground truth Clusters are exploratory — verify with domain expert
PCA-clustering in 2D then assuming 2D structure Cluster in full space; PCA only for plotting
Using one random split on clusters Refit on subsamples to check stability

Troubleshooting

Symptom Cause / Fix
One giant cluster + tiny fragments k too small or scaling off; raise k or inspect eps (DBSCAN)
Every row is noise (−1) in DBSCAN eps too small — raise toward the k-distance knee
Inertia huge / clusters meaningless Unscaled features dominate — scale first
Different runs → different segments Missing random_state/n_init
Silhouette near 0 No natural grouping in this feature space — revisit features
Clusters don't match business intuition Rarely a bug — talk to the domain owner, adjust features/k
KMeans on millions of rows too slow MiniBatchKMeans
Memory blowup on hierarchical clustering Use AgglomerativeClustering(n_clusters=k) with compute_full_tree=False or switch to KMeans

Official Documentation

Last Updated

2026-09-06 — Initial version.