Problem
You have customer records, documents or embedding vectors and no labels. You want to know whether they fall into natural groups, how many, and which point belongs where, so you can segment customers, deduplicate near-copies, compress a dataset into a few representatives, or build the coarse partitions that a vector index searches.
"Natural groups" is not one well-defined thing, which is why there are several algorithms. Should a group be a compact ball around a centre, a dense region of any shape, or a bump in a probability density? Should every point belong somewhere, or can some be noise? Can a point belong partly to two groups? And with no labels to score against, how do you even know you got it right? Each algorithm below answers these questions differently.
Intuition
k-means is the "post offices" picture: place k offices so that the total squared walking distance from every house to its nearest office is as small as possible. Alternate two easy steps: every house goes to its nearest office, then every office moves to the middle of its houses. Repeat until nothing moves. It always finds compact, roughly round groups, even when the data has none.
DBSCAN is the "crowds" picture: a point standing in a crowd (enough neighbours within a fixed radius) is a core point, crowds that touch merge into one cluster, people at the edge of a crowd join it, and loners are noise. It finds groups of any shape and refuses to force outliers into a cluster, but needs one density scale for the whole dataset.
A Gaussian mixture is the "overlapping spotlights" picture: the data was drawn from k bell-shaped blobs, each with its own centre, shape and size. A point between two spotlights is lit by both, so it belongs, say, 70% to one and 30% to the other. That is a soft version of k-means that also learns each cluster's spread and orientation.
Both runs are fixed points of Lloyd's algorithm: no point wants to switch centre and no centre wants to move. Only one is the good answer, which is why the starting centres matter.
Mechanics
k-means and Lloyd's algorithm
Given points x1 … xn and a number k, k-means looks for centres μ1 … μk minimizing the inertia, the within-cluster sum of squares:
inertia = Σi minj ‖xi − μj‖²
Finding the exact minimum is NP-hard, so everyone uses Lloyd's algorithm, a local search with two steps:
- Assign: give each point the label of its nearest centre (a nearest-neighbour query against k points).
- Update: move each centre to the mean of its points, the position that minimizes that cluster's squared distances.
Each step can only lower the inertia or leave it unchanged, and there are finitely many ways to label the points, so the loop stops at a fixed point. That is a local minimum: the picture above shows one with inertia seven times the best.
Each iteration costs O(n·k·d). scikit-learn stops when the centres' total squared shift falls below tol times the data's mean per-feature variance, or after max_iter = 300 rounds; an empty cluster gets re-seeded with a far-away point.
Its algorithm="elkan" option gives the same answer faster on some data by using the triangle inequality to skip distance computations.
k-means++ seeding
Arthur and Vassilvitskii pick starting centres one at a time: the first uniformly at random, then each next centre with probability proportional to D(x)², the squared distance from x to its nearest already-chosen centre:
P(choose x) = D(x)² / Σx′ D(x′)²
Points far from every current centre, likely in an uncovered cluster, are favoured, while outliers are only occasionally picked because they are few. The seeding alone is guaranteed to be within O(log k) of the optimal inertia in expectation, and Lloyd's iterations then improve it.
scikit-learn uses a greedy variant: at each step it draws 2 + ⌊ln k⌋ candidates and keeps the one that lowers the inertia most. Because restarts are still cheap insurance, KMeans runs n_init seedings and keeps the lowest inertia; with k-means++ the default n_init="auto" means one run, with random seeding ten.
Choosing k
Inertia always falls as k grows (k = n gives zero), so it cannot choose k by itself. Three common tools:
- Elbow: plot inertia against k and look for the point where adding a cluster stops paying off much. Simple, but the elbow is often vague.
- Silhouette: for each point, a = mean distance to the other points in its own cluster, b = mean distance to the points of the nearest other cluster, and s = (b − a) / max(a, b). s is near 1 for a well-placed point, near 0 on a boundary, negative if it is probably in the wrong cluster; a point alone in its cluster gets 0. Pick the k with the highest mean s.
- BIC for mixtures: BIC = −2 · log-likelihood + p · ln n, where p counts free parameters. More components raise the likelihood but pay ln n per parameter; pick the lowest BIC.
Domain knowledge beats all three. If you need 5 customer segments for 5 sales teams, k is 5. The worked example below shows the three criteria agreeing, which happens when the clusters really are separate blobs; on real data they often disagree.
DBSCAN
Ester, Kriegel, Sander and Xu define clusters by density with two parameters: a radius ε (eps) and a count min_samples.
- A core point has at least min_samples points, itself included, within distance ε.
- A border point is not core but lies within ε of a core point.
- Noise is everything else, labelled −1.
A cluster is a maximal set of core points connected through ε-neighbourhoods, plus their border points. scikit-learn computes every point's ε-neighbourhood with a radius query, marks the core points, then runs a depth-first search from each unlabelled core point, expanding only through core points.
There is no k: the number of clusters falls out of the density. Clusters can have any shape. The catch is the single ε: clusters of very different densities cannot all be captured at once, and memory can grow as O(n²) when ε is large. HDBSCAN (in sklearn.cluster) varies the density threshold to fix that.
A border point within ε of two clusters' core points goes to whichever cluster the search reaches first.
Gaussian mixtures and EM
A Gaussian mixture models the data's density as a weighted sum of k Gaussians with weights πj, means μj and covariances Σj:
p(x) = Σj πj · N(x; μj, Σj)
Maximizing the likelihood directly is awkward because each point's cluster is unknown. Expectation–maximization (Dempster, Laird and Rubin) alternates two steps, the soft analogue of Lloyd's:
- E-step: compute each point's responsibilities, the posterior probability of each component, rij = πj N(xi; μj, Σj) / Σl πl N(xi; μl, Σl), in log space for stability.
- M-step: re-estimate each component from responsibility-weighted points: Nj = Σi rij, πj = Nj/n, μj = Σi rij xi / Nj, Σj = Σi rij(xi − μj)(xi − μj)ᵀ / Nj, plus a small
reg_covaron the diagonal so a collapsing component cannot reach zero variance.
Each round never lowers the log-likelihood, and like Lloyd's it stops at a local optimum. scikit-learn initializes the responsibilities from a k-means run by default and stops when the mean log-likelihood changes by less than tol = 10⁻³.
With a full covariance per component in d dimensions, the parameter count is k·d(d+1)/2 + k·d + (k − 1); covariance_type can be "diag", "tied" or "spherical" to cut it.
k-means is the limit of a mixture with equal weights and shared spherical covariances shrinking to zero, where responsibilities harden to 0 or 1.
Evaluating a clustering
With no ground truth, use internal scores that reward tight, well-separated clusters: the silhouette above, the Davies–Bouldin index (lower is better) or the Calinski–Harabasz score (higher is better). All of them favour round, convex clusters, so they will rate DBSCAN's correct answer on curved data poorly. When labels exist for a sample, use external scores. The adjusted Rand index (ARI) counts pairs of points that both clusterings put together or apart, corrected so a random labelling scores about 0 and an identical one scores 1, whatever the cluster names; normalized mutual information is the information-theoretic alternative. Finally, look at the clusters: plot them, read samples from each, and check whether they are stable across seeds and subsamples.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
KMeans |
n_clusters 8, init "k-means++", n_init "auto", max_iter 300, tol 1e-4; cluster_centers_ (k, d), labels_ (n,), inertia_ | The estimator. fit runs n_init seedings and keeps the lowest inertia. |
_kmeans.py 1191-1561, 1502-1538 |
_kmeans_plusplus |
n_local_trials = 2 + ⌊ln k⌋ | Greedy k-means++: sample candidates by D², keep the one that lowers the potential most. | _kmeans.py 180-283 |
_kmeans_single_lloyd, lloyd_iter_chunked_dense |
X (n, d), centers (k, d) → labels, inertia, centers | Lloyd's loop; one Cython pass does the assign and the update, and the loop stops when the squared centre shift is within tolerance. | _kmeans.py 630-760, _k_means_lloyd.pyx 23-165 |
_tolerance, _inertia_dense |
tol × mean(var(X, axis=0)); Σ wi‖xi − μlabel‖² | The scale-aware stopping threshold and the weighted inertia. | _kmeans.py 285-294, _k_means_common.pyx 94-124 |
DBSCAN.fit, dbscan_inner |
eps 0.5, min_samples 5 (self included); labels_ with −1 for noise, core_sample_indices_ | Radius-neighbour query, core test n_neighbors >= min_samples, then a depth-first search that expands only through core points. |
_dbscan.py 397-476, _dbscan_inner.pyx 11-41 |
GaussianMixture |
n_components 1, covariance_type "full", tol 1e-3, reg_covar 1e-6, max_iter 100, init_params "kmeans"; weights_ (k,), means_ (k, d), covariances_ (k, d, d) | The mixture estimator; predict_proba returns responsibilities. |
_gaussian_mixture.py 556-1005 |
_e_step, _m_step, EM loop |
log_resp (n, k) | E-step via log-sum-exp; M-step via _estimate_gaussian_parameters; the loop stops when the lower bound changes by less than tol. |
_base.py 314-333, _gaussian_mixture.py 883-901, _base.py 251-292 |
_n_parameters, bic |
full: k·d(d+1)/2 + k·d + k − 1 | −2 · score · n + p · ln n, lower is better. | _gaussian_mixture.py 945-980 |
silhouette_samples, silhouette_score, adjusted_rand_score |
X (n, d), labels (n,) → (n,) or float | (b − a)/max(a, b) per point, 0 for singletons; the mean; pair-counting agreement with chance correction. | _unsupervised.py 211-323, 59-146, _supervised.py 360-472 |
Why does the k-means update step use the mean, and not, say, the median?
Because the objective is squared Euclidean distance, and the point minimizing Σ‖x − μ‖² over a set is exactly its mean (set the gradient 2Σ(μ − x) to zero). Minimizing plain distances instead would call for the geometric median, and absolute distances per coordinate for the coordinate-wise median: that is k-medians, a different algorithm that is less sensitive to outliers.
Worked example
Data. 36 points, 12 drawn from each of three Gaussian blobs centred at (2.0, 2.0), (7.0, 2.5), (4.5, 7.0) with standard deviation 0.7, rounded to one decimal (seed 4). DBSCAN also gets the two outliers (9.2, 8.6) and (0.6, 8.8).
Lloyd from a bad start. Start with centres at points 0 (2.0, 2.3), 25 (4.3, 7.9), 30 (3.8, 7.9): one in the bottom-left blob and two in the top blob.
| Iteration | Centre 1 | Centre 2 | Centre 3 | Inertia |
|---|---|---|---|---|
| 0 | (2.00, 2.30) | (4.30, 7.90) | (3.80, 7.90) | 333.96 |
| 1 | (4.58, 2.10) | (4.59, 7.14) | (3.85, 7.50) | 165.53 |
| 2 | (4.58, 2.10) | (4.77, 6.93) | (4.04, 7.58) | 164.47 |
| 3 | (4.58, 2.10) | (4.82, 6.82) | (4.12, 7.58) | 164.34 |
The first assignment sends all 24 bottom points to centre 1, which moves to (4.58, 2.10), halfway between the two bottom blobs. The two top centres split the top blob. Inertia falls every round and settles at 164.34: a fixed point, and a poor one. Its ARI against the true blobs is 0.422.
k-means++ from point 0. With the first centre at (2.0, 2.3), the points in the far blobs have the largest D²; the bottom-left blob's points have D² below 4 and together get only 0.017 of the probability. Drawing u = 0.62 picks point 27 (5.0, 7.0) (its own probability 0.045); with D² now measured to the nearer of two centres, u = 0.35 picks point 16 (8.3, 1.9). One seed lands in each blob, and Lloyd's algorithm needs 1 update to reach inertia 23.49, with centres (2.13, 1.96), (4.47, 7.20), (7.02, 2.25) and ARI 1.000.
Choosing k. Best of 10 k-means++ runs for each k, and a full-covariance mixture initialized from it:
| k | Inertia | Mean silhouette | Mixture parameters | Mean log-likelihood | BIC |
|---|---|---|---|---|---|
| 1 | 375.4 | — | 5 | −4.473 | 340.0 |
| 2 | 167.6 | 0.574 | 11 | −3.314 | 278.1 |
| 3 | 23.5 | 0.789 | 17 | −2.642 | 251.1 |
| 4 | 18.3 | 0.661 | 23 | −2.519 | 263.8 |
| 5 | 14.1 | 0.540 | 29 | −2.318 | 270.8 |
Inertia drops by 144.1 from k = 2 to 3 but only 5.2 from 3 to 4: a sharp elbow at 3. The silhouette peaks at k = 3 and the BIC bottoms out at k = 3, even though the log-likelihood keeps rising: the extra 6 parameters per component cost 6 · ln 36 = 21.5 BIC each. At k = 3 the clusters' mean silhouettes are 0.747, 0.820, 0.799, and the worst single point scores 0.549.
Gaussian mixture. EM started from the bad k-means labels climbs from a mean log-likelihood of −3.226 to −3.163 and stops there, another local optimum. Started from the good labels, it reaches −2.642. Here is what the good mixture says about three new points, next to the hard k-means answer:
| Point | k-means cluster | P(bottom left) | P(top) | P(bottom right) |
|---|---|---|---|---|
| (2.5, 2.5) | bottom left | 1.000 | 0.000 | 0.000 |
| (4.1, 2.2) | bottom left | 0.692 | 0.000 | 0.308 |
| (3.5, 5.0) | top | 0.409 | 0.591 | 0.000 |
(4.1, 2.2) is nearer the bottom-left centre, so k-means assigns it there with full confidence; the mixture gives the bottom-right blob 0.308 because that blob is wider along x (variance 0.464 against 0.206).
DBSCAN with ε = 1.0 and min_samples = 4 on all 38 points finds 3 clusters with 30 core points, 5 border points and 3 noise points: both outliers, and point 7 (2.4, 3.6), the bottom-left blob's straggler, with only 1 point within ε (itself). Against the true blobs its ARI is 0.959, the gap being that one straggler.
Curved clusters. On two interleaved half-circles of 10 points each, the best of 10 k-means runs cuts straight across both (ARI 0.113), while DBSCAN with ε = 1.0 and min_samples = 3 follows each arc (ARI 1.000).
DBSCAN never had to be told how many clusters there were, and it left the outliers out instead of dragging a centre toward them.
Implementation
import numpy as np
from sklearn.cluster import KMeans, DBSCAN
from sklearn.mixture import GaussianMixture
from sklearn.metrics import silhouette_score, adjusted_rand_score
from sklearn.preprocessing import StandardScaler
X = StandardScaler().fit_transform(X_raw) # distances need comparable feature scales
for k in range(2, 8):
km = KMeans(n_clusters=k, n_init="auto", random_state=0).fit(X)
gm = GaussianMixture(n_components=k, covariance_type="full", random_state=0).fit(X)
print(k, km.inertia_, silhouette_score(X, km.labels_), gm.bic(X))
km = KMeans(n_clusters=3, n_init="auto", random_state=0).fit(X)
db = DBSCAN(eps=0.3, min_samples=5).fit(X) # label -1 = noise
gm = GaussianMixture(n_components=3, random_state=0).fit(X)
soft = gm.predict_proba(X) # (n, 3) responsibilities
if y_sample is not None: # labels for a sample, if you have them
print(adjusted_rand_score(y_sample, km.labels_[sample_idx]))
def lloyd(X, centers, iters=100): # the algorithm itself, in NumPy
for _ in range(iters):
d2 = ((X[:, None, :] - centers[None]) ** 2).sum(-1) # (n, k)
labels = d2.argmin(1)
new = np.array([X[labels == j].mean(0) if (labels == j).any() else centers[j]
for j in range(len(centers))])
if np.allclose(new, centers):
break
centers = new
return labels, centers, d2.min(1).sum() # inertia
Scale the features first: k-means and DBSCAN use raw Euclidean distance, so a feature measured in thousands drowns out one measured in fractions (see feature preprocessing). A common way to set DBSCAN's ε is to sort every point's distance to its min_samples-th nearest neighbour and pick the distance where that curve bends upward.
For millions of points, MiniBatchKMeans updates centres from small random batches at some cost in inertia.
Tradeoffs
| Method | Needs k? | Cluster shape | Noise | Assignment | Cost |
|---|---|---|---|---|---|
| k-means | yes | convex, similar sizes (Voronoi cells) | every point assigned; outliers pull centres | hard | O(n·k·d) per iteration |
| DBSCAN | no (ε, min_samples) | any shape, one density | labelled −1 | hard | a radius query per point; up to O(n²) memory |
| Gaussian mixture | yes (BIC helps) | ellipses of any orientation | every point assigned, with a likelihood | soft | O(n·k·d²) per iteration, plus d×d inversions |
| Hierarchical (agglomerative) | no (cut the tree) | depends on the linkage | no | hard, with a dendrogram | O(n²) memory or more |
- k-means is the default for speed and simplicity and for "compress into k representatives" jobs such as colour quantization and the coarse quantizer of a vector index, where convex cells are exactly what you want.
- DBSCAN suits spatial data and anomaly detection, where noise matters and the shapes are irregular. In high dimensions distances concentrate, so a good ε is hard to find; reduce dimensions first (with PCA, say).
- Gaussian mixtures give probabilities and a density you can sample from or use for anomaly scores, but full covariances need many points per component and can collapse onto a few points without
reg_covar. - All of them find clusters in random noise. Check stability across seeds and subsamples, and prefer a clustering that serves a decision over one that merely scores well.
You run k-means with k = 3 on data that is really one big round blob. What do you get, and which criterion warns you?
You get three wedge-shaped pieces of the blob, because Lloyd's algorithm always returns k groups. Inertia drops smoothly with no elbow, the silhouette scores are low (points near the cuts are close to two centres), and a mixture's BIC prefers one component, since extra components add parameters without explaining much more.
Connections
Read first
- Nearest neighbors intro
Read next
- Vector indexes advanced
Nearest neighbours supplies k-means' assign step and DBSCAN's radius queries; vector indexes use k-means to partition embeddings for IVF and to build product-quantization codebooks; PCA is the usual step before clustering high-dimensional data.
References
- Lloyd. Least Squares Quantization in PCM. IEEE Transactions on Information Theory 1982. doi:10.1109/TIT.1982.1056489
- Arthur and Vassilvitskii. k-means++: The Advantages of Careful Seeding. SODA 2007.
- Ester, Kriegel, Sander and Xu. A Density-Based Algorithm for Discovering Clusters in Large Spatial Databases with Noise. KDD 1996.
- Dempster, Laird and Rubin. Maximum Likelihood from Incomplete Data via the EM Algorithm. Journal of the Royal Statistical Society B 1977.
- Rousseeuw. Silhouettes: a Graphical Aid to the Interpretation and Validation of Cluster Analysis. Journal of Computational and Applied Mathematics 1987. doi:10.1016/0377-0427(87)90125-7
- Hubert and Arabie. Comparing Partitions. Journal of Classification 1985. doi:10.1007/BF01908075
- Schwarz. Estimating the Dimension of a Model. Annals of Statistics 1978. doi:10.1214/aos/1176344136
- Campello, Moulavi and Sander. Density-Based Clustering Based on Hierarchical Density Estimates. PAKDD 2013.
- scikit-learn 1.9.1:
KMeans,DBSCAN,GaussianMixture,silhouette_score.