codexpast-exam-of-the-mathematics-course-of-the-university-of-cambridge2005iiipaper-46-clustering-sketch.py
past-exam-of-the-mathematics-course-of-the-university-of-cambridge/2005/iii/paper-46-clustering-sketch.py
"""Original complete-linkage example; Python 3.14.4 / NumPy 2.3.5 / Matplotlib 3.10.7.
Output the basename PNG to CWD; respect the caller's MPLCONFIGDIR.
"""
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
POINTS = np.array([[0.,0.], [.25,.20], [2.,.15], [2.25,-.10], [1.,2.5], [1.20,2.70]])
def complete_linkage(points):
distances = np.linalg.norm(points[:,None,:]-points[None,:,:], axis=2)
clusters = {i: [i] for i in range(len(points))}
merges = []
while len(clusters) > 1:
keys = sorted(clusters)
candidates = [(float(distances[np.ix_(clusters[a],clusters[b])].max()),a,b) for pos,a in enumerate(keys) for b in keys[pos+1:]]
height,a,b = min(candidates)
node = len(points)+len(merges)
merges.append((node,a,b,height))
clusters[node] = clusters.pop(a)+clusters.pop(b)
return merges
def main():
merges = complete_linkage(POINTS)
children = {node:(a,b) for node,a,b,_ in merges}
root = merges[-1][0]
def leaves(node):
return [node] if node not in children else leaves(children[node][0])+leaves(children[node][1])
order = leaves(root)
positions = {node:(i,0.) for i,node in enumerate(order)}
colors = ["#2369a5", "#cd6325", "#3a8e46"]
fig, axes = plt.subplots(1,2, figsize=(10.8,4.5), dpi=100, facecolor="white", constrained_layout=True)
for i, point in enumerate(POINTS):
axes[0].plot(*point, "o", color=colors[i//2], ms=8)
axes[0].annotate(chr(65+i), point, xytext=(5,5), textcoords="offset points")
axes[0].set(xlabel="Coordinate 1", ylabel="Coordinate 2", title="A constructed six-point configuration", xlim=(-.3,2.7), ylim=(-.4,3.1))
axes[0].set_aspect("equal")
for node,a,b,height in merges:
xa,ha = positions[a]
xb,hb = positions[b]
axes[1].plot([xa,xa,xb,xb], [ha,height,height,hb], color="#555555", lw=1.8)
positions[node] = ((xa+xb)/2, height)
within = max(height for _,a,b,height in merges if a < len(POINTS) and b < len(POINTS))
between = min(height for _,a,b,height in merges if a >= len(POINTS) or b >= len(POINTS))
cut = (within+between)/2
axes[1].axhline(cut, ls="--", color="#b22c35", label="Three-cluster cut")
axes[1].set(xticks=range(len(POINTS)), xticklabels=[chr(65+i) for i in order], xlabel="Observations", ylabel="Complete-linkage distance", title="Merges computed from these points", ylim=(-.05,merges[-1][3]*1.17))
axes[1].legend(loc="upper left", fontsize=9)
axes[1].spines[["top", "right"]].set_visible(False)
fig.savefig(Path.cwd() / "paper-46-clustering-sketch.png", facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()