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
- Core Syntax
- Common Operations
- Practical Examples
- Common Patterns
- Best Practices
- Common Mistakes
- Troubleshooting
- Related Cheat Sheets
- Official Documentation
Installation / Setup¶
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; fixrandom_state. - Choose k with silhouette + profile inspection — not the elbow alone.
- DBSCAN: tune
epsvia 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,
GaussianMixturegives 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 |
Related Cheat Sheets¶
- scikit-learn — the estimator API / KMeans usage
- ml-preprocessing — scaling before clustering
- ml-dimensionality-reduction — PCA for plotting and pre-cluster denoising
- ml-evaluation — supervised metrics contrast
- statistics — distributions behind scaling
- pandas — profiles and group-bys
- matplotlib — cluster scatter plots
Official Documentation¶
- sklearn clustering — https://scikit-learn.org/stable/modules/clustering.html
- KMeans docs — https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html
- sklearn clustering metrics — https://scikit-learn.org/stable/modules/clustering.html#clustering-evaluation
Last Updated¶
2026-09-06 — Initial version.