Hierarchical Clustering (Dendrograms)
What you’ll learn
- agglomerative clustering as a sequence of merges, traced in full on eight points
- the four linkage rules, each computed by hand from the same distance matrix
- how to read a dendrogram, and why the vertical axis is the only part that carries information
- the Lance-Williams recurrence that makes all four rules one algorithm
- the measured single-versus-Ward trade-off: ARI 1.00 / 0.00 against 0.13 / 0.93
- how to choose a cut height without committing to in advance
- the cophenetic correlation, and why memory ends the party around 30,000 rows
Intuition
k-means asks you for before it will tell you anything. Hierarchical clustering refuses to commit. It builds every clustering at once — from singleton clusters down to one cluster containing everything — and hands you the whole nested family as a tree. You pick where to cut afterwards, having seen the structure.
The agglomerative (bottom-up) version is a loop of exactly three lines:
- Start with every point as its own cluster.
- Merge the two closest clusters.
- Repeat until one cluster remains.
The only difficulty is step 2, because “closest” between two sets of points is not defined by the distance between two points. That definition is the linkage rule, and it is the entire character of the algorithm.
flowchart TD A["n singleton clusters"] --> B["Compute all pairwise cluster distances
using the linkage rule"] B --> C["Merge the closest pair"] C --> D["Record the merge height in the dendrogram"] D --> E{"One cluster left?"} E -->|"no"| B E -->|"yes"| F["Full tree.
Cut it wherever you like."]
There is also a divisive (top-down) version that starts with one cluster and splits recursively. It is rarely used — the first split alone is a search — so “hierarchical clustering” in practice means agglomerative.
The math
Let and be two clusters and the point-level distance. The four standard linkage rules define the cluster-level distance :
Single linkage — the nearest pair:
Complete linkage — the furthest pair:
Average linkage — the mean over all pairs:
Ward linkage minimises the increase in within-cluster sum of squares caused by the merge. That increase has a closed form:
where and are the two centroids. SciPy reports the merge height as , so the plotted Ward distance is
Ward is the direct hierarchical analogue of k-means: both minimise within-cluster sum of squares, one greedily bottom-up and one by alternating optimisation. Ward requires Euclidean distance; the other three accept any metric.
One recurrence, four rules
Naively, every merge would require recomputing distances from scratch. The Lance-Williams recurrence avoids that. After merging and into , the distance to any other cluster is:
Writing , and for the three cluster sizes, and :
| Linkage | ||||
|---|---|---|---|---|
| Single | 0 | |||
| Complete | 0 | |||
| Average | 0 | 0 | ||
| Ward | 0 |
Single and complete differ only in the sign of — half the difference, added or subtracted. That one sign is the difference between chaining and compactness.
Worked example by hand
Eight points, labelled A through H:
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| x | 1.0 | 1.5 | 1.2 | 5.0 | 5.4 | 6.0 | 9.0 | 9.4 |
| y | 1.0 | 1.2 | 2.0 | 5.0 | 5.6 | 5.1 | 1.0 | 1.6 |
The Euclidean distance matrix, rounded to three decimals:
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| A | 0 | 0.539 | 1.020 | 5.657 | 6.366 | 6.466 | 8.000 | 8.421 |
| B | 0.539 | 0 | 0.854 | 5.166 | 5.880 | 5.955 | 7.503 | 7.910 |
| C | 1.020 | 0.854 | 0 | 4.841 | 5.532 | 5.714 | 7.864 | 8.210 |
| D | 5.657 | 5.166 | 4.841 | 0 | 0.721 | 1.005 | 5.657 | 5.561 |
| E | 6.366 | 5.880 | 5.532 | 0.721 | 0 | 0.781 | 5.841 | 5.657 |
| F | 6.466 | 5.955 | 5.714 | 1.005 | 0.781 | 0 | 5.080 | 4.880 |
| G | 8.000 | 7.503 | 7.864 | 5.657 | 5.841 | 5.080 | 0 | 0.721 |
| H | 8.421 | 7.910 | 8.210 | 5.561 | 5.657 | 4.880 | 0.721 | 0 |
Merge 1. The smallest off-diagonal entry is . Merge them into .
Now the linkage rules diverge. The new cluster needs a distance to C. From the matrix, and , so:
| Rule | Computation | |
|---|---|---|
| Single | 0.8544 | |
| Complete | 1.0198 | |
| Average | 0.9371 | |
| Ward | 1.0408 |
For the Ward figure: and , so the centroid gap is , and .
Every one of these matches SciPy exactly.
Merges 2 and 3 are the same under all four rules, because both involve singletons: and .
Here is the complete Ward trace:
| Merge | Joins | Height | Resulting cluster |
|---|---|---|---|
| 1 | A + B | 0.5385 | |
| 2 | D + E | 0.7211 | |
| 3 | G + H | 0.7211 | |
| 4 | F + | 0.9522 | |
| 5 | C + | 1.0408 | |
| 6 | + | 8.4013 | |
| 7 | + rest | 11.9220 | everything |
Notice the gap. The first five merges all happen below height 1.05; the sixth jumps to 8.40. That jump is the signal, and cutting anywhere between 1.05 and 8.40 gives three clusters: , , — exactly the three visual groups.
Reading a dendrogram
Three rules cover almost every mistake people make with these plots.
The vertical axis is the only quantitative axis. Height means merge distance. Read it.
Horizontal position is arbitrary. At each merge, either subtree can be drawn on the left. The same tree has valid drawings. Two leaves being drawn side by side means nothing unless they are joined by a low bar.
A cut is a horizontal line. The number of vertical branches it crosses is the number of clusters you get. Cut low for many small clusters, high for few large ones.
The long vertical runs are the interesting part. A branch that survives a long height interval without merging is a cluster that stayed distinct across a wide range of thresholds — evidence of real separation.
See it move
The next sketch is the one that matters: the same eight points under all four linkage rules, with the merge order and heights recomputed for each.
Watch the tallest merge as you cycle: 4.879 for single, 8.421 for complete, 6.632 for average, 11.922 for Ward. The tree’s shape changes; the data never does.
The linkage trade-off, measured
The four rules are not four flavours of the same thing. They encode genuinely different beliefs about what a cluster is, and the consequences are stark.
| Linkage | Moons (ARI) | Blobs (ARI) | Character |
|---|---|---|---|
| Single | 1.0000 | -0.0000 | Chains along dense paths; one bridging point merges two clusters |
| Complete | 0.3278 | 0.5802 | Compact, roughly equal-diameter clusters; sensitive to outliers |
| Average | 0.2069 | 0.5679 | A compromise; the most common default outside Ward |
| Ward | 0.1250 | 0.9272 | Equal-variance spherical clusters; the k-means of trees |
Single linkage’s failure on blobs is worth understanding. It merges whenever any two points are close, so a thin trail of points between two blobs is enough to fuse them — the chaining effect. On the moons, chaining is exactly right: each moon is a connected dense path. On blobs with a few scattered points, chaining swallows everything.
Choosing where to cut
Two approaches, and they answer different questions.
By . Pass n_clusters=4n_clusters=4 and let the algorithm cut wherever it must. Use this when the
number is fixed by something external.
By height. Pass a distance_thresholddistance_threshold and let the number of clusters fall out. Use this when
you want the data to decide.
To pick a height, plot the merge distances and find where they stop being cheap. On 200 points containing 4 blobs, the last eight Ward merges are at heights:
Five merges under 7, then a jump to 39.5. Anything between about 7 and 39 gives the same answer, and cutting at 20 returns exactly 4 clusters.
Cophenetic correlation
How faithfully does the tree represent the original distances? The cophenetic distance between two points is the height at which they first end up in the same cluster. Correlate those with the original pairwise distances:
On the eight-point example: single 0.9238, complete 0.9311, average 0.9398, Ward 0.9338. Average linkage wins, which is not a coincidence — average linkage optimises something close to this quantity directly. Values above about 0.75 mean the tree is a reasonable summary; well below that, the dendrogram is distorting the geometry and you should not read structure into it.
In code
import numpy as np
from sklearn.cluster import AgglomerativeClustering
X = np.array([[1.0, 1.0], [1.5, 1.2], [1.2, 2.0], [5.0, 5.0],
[5.4, 5.6], [6.0, 5.1], [9.0, 1.0], [9.4, 1.6]])
# Option A: I know how many clusters I want.
by_k = AgglomerativeClustering(n_clusters=3, linkage="ward").fit(X)
print(by_k.labels_) # [0 0 0 1 1 1 2 2]
# Option B: I know how far apart clusters must be. n_clusters must be None.
by_height = AgglomerativeClustering(
n_clusters=None, distance_threshold=4.0, linkage="ward"
).fit(X)
print(by_height.n_clusters_) # 3
# compute_distances=True populates .distances_ so you can plot the tree
tree = AgglomerativeClustering(n_clusters=3, compute_distances=True).fit(X)
print(tree.distances_.round(4)) # [0.5385 0.7211 0.7211 0.9522 1.0408 8.4013 11.922]import numpy as np
from sklearn.cluster import AgglomerativeClustering
X = np.array([[1.0, 1.0], [1.5, 1.2], [1.2, 2.0], [5.0, 5.0],
[5.4, 5.6], [6.0, 5.1], [9.0, 1.0], [9.4, 1.6]])
# Option A: I know how many clusters I want.
by_k = AgglomerativeClustering(n_clusters=3, linkage="ward").fit(X)
print(by_k.labels_) # [0 0 0 1 1 1 2 2]
# Option B: I know how far apart clusters must be. n_clusters must be None.
by_height = AgglomerativeClustering(
n_clusters=None, distance_threshold=4.0, linkage="ward"
).fit(X)
print(by_height.n_clusters_) # 3
# compute_distances=True populates .distances_ so you can plot the tree
tree = AgglomerativeClustering(n_clusters=3, compute_distances=True).fit(X)
print(tree.distances_.round(4)) # [0.5385 0.7211 0.7211 0.9522 1.0408 8.4013 11.922]For the dendrogram itself, go to SciPy — scikit-learn does not plot one.
import matplotlib.pyplot as plt
import numpy as np
from scipy.cluster.hierarchy import cophenet, dendrogram, fcluster, linkage
from scipy.spatial.distance import pdist
X = np.array([[1.0, 1.0], [1.5, 1.2], [1.2, 2.0], [5.0, 5.0],
[5.4, 5.6], [6.0, 5.1], [9.0, 1.0], [9.4, 1.6]])
labels = list("ABCDEFGH")
Z = linkage(X, method="ward")
# Z has one row per merge: [left_id, right_id, height, size_of_new_cluster]
print(Z.round(4))
print("cophenetic correlation", round(cophenet(Z, pdist(X))[0], 4)) # 0.9338
print("cut at 4.0 ->", fcluster(Z, t=4.0, criterion="distance")) # [1 1 1 3 3 3 2 2]
print("exactly 3 ->", fcluster(Z, t=3, criterion="maxclust")) # [1 1 1 3 3 3 2 2]
dendrogram(Z, labels=labels)
plt.axhline(4.0, color="red", linestyle="--")
plt.ylabel("merge distance")
plt.show()import matplotlib.pyplot as plt
import numpy as np
from scipy.cluster.hierarchy import cophenet, dendrogram, fcluster, linkage
from scipy.spatial.distance import pdist
X = np.array([[1.0, 1.0], [1.5, 1.2], [1.2, 2.0], [5.0, 5.0],
[5.4, 5.6], [6.0, 5.1], [9.0, 1.0], [9.4, 1.6]])
labels = list("ABCDEFGH")
Z = linkage(X, method="ward")
# Z has one row per merge: [left_id, right_id, height, size_of_new_cluster]
print(Z.round(4))
print("cophenetic correlation", round(cophenet(Z, pdist(X))[0], 4)) # 0.9338
print("cut at 4.0 ->", fcluster(Z, t=4.0, criterion="distance")) # [1 1 1 3 3 3 2 2]
print("exactly 3 ->", fcluster(Z, t=3, criterion="maxclust")) # [1 1 1 3 3 3 2 2]
dendrogram(Z, labels=labels)
plt.axhline(4.0, color="red", linestyle="--")
plt.ylabel("merge distance")
plt.show()linkagelinkage accepts either a raw data matrix or a condensed distance vector from pdistpdist, which is
how you use a custom metric:
from scipy.cluster.hierarchy import linkage
from scipy.spatial.distance import pdist
# Cosine distance for text; note that ward would reject this — it needs Euclidean.
Z = linkage(pdist(X_tfidf, metric="cosine"), method="average")from scipy.cluster.hierarchy import linkage
from scipy.spatial.distance import pdist
# Cosine distance for text; note that ward would reject this — it needs Euclidean.
Z = linkage(pdist(X_tfidf, metric="cosine"), method="average")Connectivity constraints
If you already know which points could plausibly belong together — pixels that touch, cities joined by a road, samples adjacent in time — pass a connectivity matrix. Merges are then only considered between connected clusters, which both encodes the domain knowledge and drops the cost substantially.
from sklearn.cluster import AgglomerativeClustering
from sklearn.neighbors import kneighbors_graph
# Only allow merges between points that are among each other's 10 nearest neighbours.
conn = kneighbors_graph(X, n_neighbors=10, include_self=False)
model = AgglomerativeClustering(n_clusters=4, connectivity=conn, linkage="ward").fit(X)from sklearn.cluster import AgglomerativeClustering
from sklearn.neighbors import kneighbors_graph
# Only allow merges between points that are among each other's 10 nearest neighbours.
conn = kneighbors_graph(X, n_neighbors=10, include_self=False)
model = AgglomerativeClustering(n_clusters=4, connectivity=conn, linkage="ward").fit(X)Without a connectivity constraint, Ward on points needs the full distance structure. At 30,000 rows that is around 900 million float64 entries, roughly 7 GB — the practical ceiling for this algorithm on a laptop.
APIsklearn.cluster.AgglomerativeClustering
Assumes
- Your linkage rule matches the cluster shape you expect
- The distance metric is meaningful (Ward additionally requires Euclidean)
- n is small enough for an O(n^2) distance structure, or you supply connectivity
Cost
- train
O(n^2 log n) in general; O(n^2) for single linkage with the SLINK algorithm- predict
not supported — there is no rule for placing an unseen point- memory
O(n^2) without a connectivity constraint — the binding limit in practice
D(A, B) — cluster-level distance under the linkage rule; coph(i, j) — height at which i and j first join
Hyperparameters that matter
linkagedefault 'ward'The whole character. Ward for blobs, single for chains and manifolds, average as a compromise.n_clustersdefault 2Where to cut. Set to None if you are using distance_threshold instead.distance_thresholddefault NoneCut by height rather than by count; n_clusters must be None.metricdefault 'euclidean'Any metric for single/complete/average. Ward accepts euclidean only.connectivitydefault NoneRestricts which merges are allowed. Encodes domain adjacency and cuts the cost.compute_distancesdefault FalsePopulates .distances_ so you can plot the dendrogram from the sklearn object.
Reach for it when
- You do not know k and want to see the whole nesting before deciding
- The domain is genuinely hierarchical — taxonomies, org charts, document topics
- n is a few thousand and a dendrogram would be a useful deliverable
- You have a custom distance metric and want any linkage but Ward
Look elsewhere when
- n is above roughly 30,000 with no connectivity constraint — memory will fail
- You need to assign new points later; refitting is the only option
- You need robustness to a single bad merge — every merge is permanent
Pitfalls
Reading meaning into left-right order. Any subtree can be flipped. Adjacent leaves are not necessarily similar; only a low joining bar means similar.
Using Ward with a non-Euclidean metric. scikit-learn raises an error; SciPy quietly computes something meaningless. Ward’s derivation is entirely about sums of squares.
Forgetting merges are permanent. Agglomerative clustering never reconsiders. One bad early merge, caused by a single outlier bridging two groups, propagates all the way up. k-means can recover from a bad start over iterations; this cannot.
Hitting the memory wall by surprise. The distance structure is fine at 5,000 rows and
fatal at 50,000. Add a connectivityconnectivity graph, sample, or switch to
DBSCAN.
Choosing single linkage without seeing the data. It is either the best rule available (ARI 1.00 on moons) or catastrophic (ARI 0.00 on blobs). Plot the dendrogram: a lopsided comb with no clear tall split means chaining has already happened.
Expecting predictpredict. There is no such method. The cluster definitions are relative to the
training set, so labelling a new point means refitting or, pragmatically, assigning it to the
nearest cluster centroid yourself.
Not scaling. Same as everywhere: distances drive every merge.
Compare
| Agglomerative | k-means | DBSCAN | |
|---|---|---|---|
| Needs k up front | no — cut afterwards | yes | no |
| Cluster shape | linkage-dependent | spherical only | any |
| Deterministic | yes | no (depends on seed) | yes |
| Handles noise | no | no | yes |
| Can label new points | no | yes | no |
| Memory | |||
| Practical ceiling | ~30k rows | millions | ~1M with an index |
| Gives a full hierarchy | yes | no | no |
Two leaves are drawn next to each other at the bottom of a dendrogram. What does that tell you?
Either subtree can be drawn on either side at every merge, so an n-leaf tree has 2^(n-1) equally valid drawings. Read the height of the bar that joins two leaves, never their horizontal proximity.
Show answer
B — Nothing on its own — horizontal order is arbitrary; only a low joining bar means similar — Either subtree can be drawn on either side at every merge, so an n-leaf tree has 2^(n-1) equally valid drawings. Read the height of the bar that joins two leaves, never their horizontal proximity.
Single linkage scored ARI 1.00 on two moons and -0.00 on three blobs. Why the reversal?
That is the chaining effect. Each moon is a connected dense path, so chaining traces it exactly. On separated blobs with scattered points, chaining links everything into one cluster and leaves the true structure undiscovered.
Show answer
B — Single linkage merges whenever ANY two points are close, which follows a dense curve perfectly and lets one bridging point fuse two blobs — That is the chaining effect. Each moon is a connected dense path, so chaining traces it exactly. On separated blobs with scattered points, chaining links everything into one cluster and leaves the true structure undiscovered.
You need Ward linkage with cosine distance for a text corpus. What happens?
Ward minimises the increase in within-cluster sum of squares, which is only meaningful under Euclidean geometry. For cosine distance use average or complete linkage.
Show answer
B — scikit-learn raises an error, because Ward's objective is defined in terms of Euclidean sums of squares — Ward minimises the increase in within-cluster sum of squares, which is only meaningful under Euclidean geometry. For cosine distance use average or complete linkage.
Your dataset has 50,000 rows and AgglomerativeClustering runs out of memory. What is the cheapest fix that keeps the method?
The O(n^2) blow-up comes from considering every pair as a merge candidate. A connectivity graph restricts candidates to actual neighbours, which drops both memory and time — and often encodes real domain structure at the same time.
Show answer
B — Pass a kneighbors_graph as the connectivity argument so only nearby merges are considered — The O(n^2) blow-up comes from considering every pair as a merge candidate. A connectivity graph restricts candidates to actual neighbours, which drops both memory and time — and often encodes real domain structure at the same time.
The cophenetic correlation of your tree is 0.42. What does that mean?
Cophenetic correlation compares each pair's original distance against the height at which they first merge. At 0.42 the tree is a poor summary of the geometry — try a different linkage before interpreting any of its branches.
Show answer
B — The tree distorts the original distances badly, so structure read off it is unreliable — Cophenetic correlation compares each pair's original distance against the height at which they first merge. At 0.42 the tree is a poor summary of the geometry — try a different linkage before interpreting any of its branches.
🧪 Try It Yourself
Exercise 1 – Four linkages, one pair of clusters
Exercise 2 – Read the linkage matrix
Exercise 3 – Cut by height instead of by k
Exercise 4 – The chaining trade-off, measured
Exercise 5 – Which tree is most faithful?
Recap
- Agglomerative clustering performs merges and hands you every clustering at once; you cut afterwards.
- The linkage rule defines set-to-set distance. On one pair from the worked matrix: single 0.8544, complete 1.0198, average 0.9371, Ward 1.0408.
- All four are instances of the Lance-Williams recurrence; single and complete differ only in the sign of .
- Ward’s tree on the eight points jumps from 1.04 to 8.40 at merge 6 — that gap is the three real groups.
- The trade-off is real and measured: single linkage scores 1.00 on moons and 0.00 on blobs; Ward scores 0.13 and 0.93.
- Cut by
distance_thresholddistance_thresholdwhen you want the data to decide ; look for the plateau in the merge heights. - Cophenetic correlation says how faithful the tree is — average linkage won at 0.9398 here.
- memory is the binding constraint: about 30,000 rows without a connectivity graph.
Exercise 6 – The linkage decides what a cluster is
Next
DBSCAN - Density-Based Clustering — an algorithm that needs no at all, finds arbitrarily shaped clusters, and is the only method in this phase that can say “this point belongs to nothing”.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
