Dictionary of Applied Machine Learning · clustering

clustering — Python demo

Numerical companion to the entry clustering: it recomputes what the entry states and prints one line per check

Numerical companion to the glossary entry 'clustering'. A photograph taken in the Wachau (clustering_wachau_photo.jpg, the author's own, 640 x 480 px) is cut into 96 x 128 patches of 5 x 5 pixels. The 12288 patches are the data points, and the feature vector of a patch holds its mean red, green and blue values. The clusters are surface types (sky, cloud, shaded forest, sunlit slopes, rock), and painting each patch by its cluster segments the photograph.

Run it without installing anything:
uv run https://dictionaryofml.org/terms/clustering.py
uv downloads this script and the pinned NumPy and Matplotlib it needs, then runs it; the script fetches any input file it uses. To keep the output files, download clustering.py into a folder and run uv run clustering.py there. With NumPy and Matplotlib already installed, python3 clustering.py, from any directory — it writes its output files into the current directory. Fixed seeds, so the printed numbers reproduce exactly. Download clustering.py · Notebook · Open in Colab

The script, block by block

One cell per block of the script: the code, and what that code printed when it last ran here

setup

"""Five clustering methods segment a photograph of the Wachau by patch
color: k-means (also on a coreset), a GMM, DBSCAN, spectral clustering on
the nearest-neighbor graph of the colors, and convex clustering.

Purpose
-------
Numerical companion to the glossary entry 'clustering'.  A photograph
taken in the Wachau (clustering_wachau_photo.jpg, the author's own,
640 x 480 px) is cut into 96 x 128 patches of 5 x 5 pixels.  The 12288
patches are the data points, and the feature vector of a patch holds its
mean red, green and blue values.  The clusters are surface types (sky,
cloud, shaded forest, sunlit slopes, rock), and painting each patch by
its cluster segments the photograph.

The demo checks the entry's claims, paragraph by paragraph: k-means
with k = 5 assigns every patch to the nearest of five cluster centroids
(five representative colors) and painting compresses the photograph to
five colors; k-means on a coreset, a small weighted subset of the
patches, gives nearly the same clustering error as k-means on all
patches; the degrees of belonging of a GMM sum to one and about one in
ten patches has none above 0.8; DBSCAN with eps = 0.03 and m* = 32 joins
sky and cloud through a chain of intermediate colors, so its two largest
clusters hold most of the patches, and the rare mixed colors along the
horizon are noise; on the 10-nearest-neighbor graph of the colors,
spectral clustering yields clusters that are connected in the graph; the
number of clusters of convex clustering falls along the path of its
penalty parameter, and one value on the path leaves about five clusters
of 80 or more patches.

Deterministic: k-means and the GMM are initialized by splitting the
patches into equal parts ordered by brightness, the k-means step of
spectral clustering by farthest-first seeding from the first patch, and
the coreset is every tenth patch in brightness order (no randomness).
Kept light on purpose: the distances, the neighbor graph, the new
feature vectors of spectral clustering and the convex-clustering solves
all work on blocks of rows or on the edge list, so no matrix with one row
and one column per patch is ever formed.

Self-contained: numpy + matplotlib only.

Blocks
------
[B-patches]  Cut the photograph into 96 x 128 patches with mean-RGB
             feature vectors; check the counts.
[B-families] The three design choices need no computation; this block
             only records the number of clusters handed to the methods.
[B-kmeans]   k-means with k = 5: check the nearest-centroid assignment,
             the five-color compression, and that k-means on a coreset
             of 1229 weighted patches nearly matches the full run.
[B-gmm]      A GMM with k = 5, hard-assigned by the largest degree of
             belonging: check that the degrees of belonging sum to one
             and that 5-20% of the patches have none above 0.8.
[B-dbscan]   DBSCAN in color space with eps = 0.03 and m* = 32: check
             that every cluster is a chain of core points at most eps
             apart, that noise patches are not within eps of a core
             point, and that the two largest clusters hold most patches.
[B-graph]    The 10-nearest-neighbor graph of the colors: spectral
             clustering with k = 5 (eigenvectors of the Laplacian
             matrix, then k-means); check that the clusters are
             connected in the graph.
[B-convex]   Convex clustering with the edge weights of that graph along
             a path of penalty parameters: check that the number of
             clusters falls along the path and that one value leaves
             about five clusters of 80 or more patches.
[B-plot]     One painted copy of the photograph per method (each patch in
             the mean color of its cluster, white lines between clusters,
             DBSCAN noise in black), plus the preview.

Outputs
-------
clustering_wachau_photo.jpg : input -- the photograph (640 x 480 px)
clustering_photo_kmeans.png, _gmm.png, _dbscan.png, _spectral.png,
_convex.png : the patches painted by cluster, for the entry's figure
clustering.png : preview (checking only) -- the photograph and its five
                 segmentations
"""

from pathlib import Path

import urllib.request

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.image import imread

OUT_DIR = Path(__file__).parent

ASSET_URL = "https://dictionaryofml.org/demoassets/"


def demo_asset(name):
    """Path to the input file `name`, fetched once if it is not already here.

    Inside the repository the committed copy is used (pythondemos/ first,
    then assets/), so a run there reproduces the committed outputs exactly.
    Run from anywhere else -- from the copy published on dictionaryofml.org,
    say -- the file is downloaded and cached beside the script, so the demo
    is runnable without the repository.
    """
    for cand in (OUT_DIR / name, OUT_DIR.parent / "assets" / name):
        if cand.exists():
            return cand
    cached = OUT_DIR / name
    print(f"    fetching {name} from {ASSET_URL}{name}")
    with urllib.request.urlopen(ASSET_URL + name, timeout=180) as resp:
        cached.write_bytes(resp.read())
    return cached

report = []                         # collects (check name, pass/fail) pairs


def check(name, ok):                # records and prints one verification
    report.append((name, bool(ok)))
    print(f"  [{'ok' if ok else 'FAIL'}] {name}")
  photograph cut into 96 x 128 = 12288 patches of 5 px; feature vector = mean (R, G, B) in [0, 1]

B-patches

Cut the photograph into 96 x 128 patches with mean-RGB feature vectors; check the counts.

PHOTO_PATCH = 5                     # side length of a patch in pixels
PER_PATCH = (10 // PHOTO_PATCH) ** 2   # 4: patches per former 10 x 10 patch
BIG = 20 * PER_PATCH                # a cluster of this many patches counts as large
snap = np.asarray(imread(demo_asset("clustering_wachau_photo.jpg")),
                  dtype=float) / 255.0
p_rows, p_cols = snap.shape[0] // PHOTO_PATCH, snap.shape[1] // PHOTO_PATCH
colors = snap[:p_rows * PHOTO_PATCH, :p_cols * PHOTO_PATCH].reshape(
    p_rows, PHOTO_PATCH, p_cols, PHOTO_PATCH, 3).mean(axis=(1, 3))
C = colors.reshape(-1, 3)                       # feature vectors: mean RGB
print(f"  photograph cut into {p_rows} x {p_cols} = {len(C)} patches of "
      f"{PHOTO_PATCH} px; feature vector = mean (R, G, B) in [0, 1]")
check("[B-patches] 96 x 128 = 12288 patches, each with a three-number feature vector",
      C.shape == (12288, 3) and (p_rows, p_cols) == (96, 128))
  [ok] [B-patches] 96 x 128 = 12288 patches, each with a three-number feature vector
  k = 5 clusters for k-means, the GMM and spectral clustering; DBSCAN and convex clustering determine their number of clusters themselves
  k-means with k = 5: cluster sizes [2453, 3201, 1996, 2149, 2489], centroid brightness [0.15, 0.32, 0.47, 0.62, 0.79], clustering error 0.0165

B-families

The three design choices need no computation; this block only records the number of clusters handed to the methods.

K = 5
print(f"  k = {K} clusters for k-means, the GMM and spectral clustering; DBSCAN "
      f"and convex clustering determine their number of clusters themselves")

B-kmeans

k-means with k = 5: check the nearest-centroid assignment, the five-color compression, and that k-means on a coreset of 1229 weighted patches nearly matches the full run.

def assign(A, cents):
    return ((A[:, None, :] - cents) ** 2).sum(axis=2).argmin(axis=1)


def lloyd(A, cents, weights=None):
    """Lloyd's algorithm: alternate nearest-centroid assignment and
    (weighted) averaging until the assignment no longer changes."""
    w = np.ones(len(A)) if weights is None else weights
    lab = assign(A, cents)
    for _ in range(300):
        new = np.stack([(w[lab == c, None] * A[lab == c]).sum(axis=0) / w[lab == c].sum()
                        if (lab == c).any() else cents[c] for c in range(len(cents))])
        new_lab = assign(A, new)
        if np.array_equal(new_lab, lab) and np.allclose(new, cents):
            return new_lab, new
        lab, cents = new_lab, new
    return lab, cents


def brightness_start(A, k):
    """Deterministic start: k equal parts of the points ordered by brightness."""
    parts = np.array_split(np.argsort(A.sum(axis=1)), k)
    return np.stack([A[g].mean(axis=0) for g in parts])


def clustering_error(A, cents):
    return float(((A - cents[assign(A, cents)]) ** 2).sum(axis=1).mean())


lab_km, cents_km = lloyd(C, brightness_start(C, K))
bright = cents_km.mean(axis=1)
err_km = clustering_error(C, cents_km)
print(f"  k-means with k = {K}: cluster sizes {np.bincount(lab_km).tolist()}, "
      f"centroid brightness {np.round(np.sort(bright), 2).tolist()}, "
      f"clustering error {err_km:.4f}")
check("[B-kmeans] every patch is assigned to the cluster centroid nearest in "
      "Euclidean distance", np.array_equal(lab_km, assign(C, cents_km)))
check("[B-kmeans] the five cluster centroids span bright (sky) to dark (forest)",
      bright.max() - bright.min() > 0.4)
check("[B-kmeans] painting each patch in the color of its centroid leaves five colors",
      len(np.unique(cents_km[lab_km], axis=0)) == K)

# a coreset: every tenth patch in brightness order, each weighted by 10
order = np.argsort(C.sum(axis=1))
core_idx = order[::10]
S, w_S = C[core_idx], np.full(len(core_idx), 10.0)
lab_S, cents_S = lloyd(S, brightness_start(S, K), w_S)
err_S = clustering_error(C, cents_S)
worst = 0.0
for trial in range(20):                              # random centroid choices
    cents = C[(trial * 613 + np.arange(K) * 997) % len(C)]
    full = clustering_error(C, cents)
    sub = float((w_S * ((S - cents[assign(S, cents)]) ** 2).sum(axis=1)).sum()
                / w_S.sum())
    worst = max(worst, abs(sub - full) / full)
print(f"  coreset of {len(S)} weighted patches: weighted clustering error within "
      f"{100 * worst:.1f}% of the full one over 20 centroid choices; k-means on "
      f"the coreset gives clustering error {err_S:.4f} on all patches")
check("[B-kmeans] the coreset's weighted clustering error is within 15% of the "
      "full one for every tried choice of the centroids", worst < 0.15)
check("[B-kmeans] k-means on the coreset is within 5% of k-means on all patches",
      err_S < 1.05 * err_km)
  [ok] [B-kmeans] every patch is assigned to the cluster centroid nearest in Euclidean distance
  [ok] [B-kmeans] the five cluster centroids span bright (sky) to dark (forest)
  [ok] [B-kmeans] painting each patch in the color of its centroid leaves five colors
  coreset of 1229 weighted patches: weighted clustering error within 2.9% of the full one over 20 centroid choices; k-means on the coreset gives clustering error 0.0166 on all patches
  [ok] [B-kmeans] the coreset's weighted clustering error is within 15% of the full one for every tried choice of the centroids
  [ok] [B-kmeans] k-means on the coreset is within 5% of k-means on all patches
  GMM with k = 5: cluster sizes [2408, 3968, 1460, 1942, 2510]; 12% of the patches have no degree of belonging above 0.8

B-gmm

A GMM with k = 5, hard-assigned by the largest degree of belonging: check that the degrees of belonging sum to one and that 5-20% of the patches have none above 0.8.

def normal_pdf(A, mean, cov):
    d = A.shape[1]
    diff = A - mean
    quad = np.einsum("ij,jk,ik->i", diff, np.linalg.inv(cov), diff)
    return np.exp(-0.5 * quad) / np.sqrt(((2.0 * np.pi) ** d)
                                         * np.linalg.det(cov))


def em_gmm(A, k, iters=300):
    """EM for a GMM with k components; returns hard labels and posteriors."""
    d = A.shape[1]
    parts = np.array_split(np.argsort(A.sum(axis=1)), k)
    mu = np.stack([A[g].mean(axis=0) for g in parts])
    sig = np.stack([np.cov(A[g].T) + 1e-5 * np.eye(d) for g in parts])
    pk = np.full(k, 1.0 / k)
    for _ in range(iters):
        joint = np.stack([pk[c] * normal_pdf(A, mu[c], sig[c])
                          for c in range(k)])
        post = joint / np.maximum(joint.sum(axis=0), 1e-300)
        wgt = post.sum(axis=1)
        pk = wgt / len(A)
        mu = (post @ A) / wgt[:, None]
        sig = np.stack([(post[c][:, None] * (A - mu[c])).T @ (A - mu[c])
                        / wgt[c] + 1e-5 * np.eye(d) for c in range(k)])
    return post.argmax(axis=0), post


lab_gmm, post = em_gmm(C, K)
unsure = float((post.max(axis=0) < 0.8).mean())
print(f"  GMM with k = {K}: cluster sizes {np.bincount(lab_gmm).tolist()}; "
      f"{unsure:.0%} of the patches have no degree of belonging above 0.8")
check("[B-gmm] the degrees of belonging of each patch sum to one",
      np.allclose(post.sum(axis=0), 1.0))
check("[B-gmm] between 5% and 20% of the patches have no degree of belonging above 0.8",
      0.05 <= unsure <= 0.20)
  [ok] [B-gmm] the degrees of belonging of each patch sum to one
  [ok] [B-gmm] between 5% and 20% of the patches have no degree of belonging above 0.8
  DBSCAN with eps = 0.03, m* = 32: 6 clusters of sizes [6445, 4360, 160, 116, 41, 26], 9% noise

B-dbscan

DBSCAN in color space with eps = 0.03 and m* = 32: check that every cluster is a chain of core points at most eps apart, that noise patches are not within eps of a core point, and that the two largest clusters hold most patches.

def dbscan(within, core):
    """Clusters as chains of core points at most eps apart; -1 marks noise."""
    lab = -np.ones(len(core), dtype=int)
    current = 0
    for seed in range(len(core)):
        if lab[seed] != -1 or not core[seed]:
            continue
        lab[seed] = current
        stack = [seed]
        while stack:
            u = stack.pop()
            if not core[u]:
                continue
            for v in np.flatnonzero(within[u]):
                if lab[v] == -1:
                    lab[v] = current
                    stack.append(v)
        current += 1
    return lab


def components(mask, adjacency):
    """Number of connected components of the subgraph induced by mask."""
    seen = np.zeros(len(mask), dtype=bool)
    count = 0
    for s in np.flatnonzero(mask):
        if seen[s]:
            continue
        count += 1
        stack = [s]
        seen[s] = True
        while stack:
            u = stack.pop()
            for v in np.flatnonzero(adjacency[u] & mask & ~seen):
                seen[v] = True
                stack.append(v)
    return count


EPS, MIN_PTS = 0.03, 8 * PER_PATCH              # m* = 32: 8 per 10 x 10 patch, scaled
NEIGHBORS = 10                                  # for the graph built below
BLOCK = 1024                                    # patches per block of distances


def neighborhoods(A, eps, neighbors, block=BLOCK):
    """The eps-neighborhood of every patch and its nearest neighbors, one
    block of rows at a time: the distances of a block are formed, used and
    dropped, so the full distance matrix is never held."""
    m = len(A)
    sq = (A ** 2).sum(axis=1)               # |a - b|^2 = |a|^2 + |b|^2 - 2 a.b
    near = np.zeros((m, m), dtype=bool)     # which patches lie within eps
    idx = np.empty((m, neighbors), dtype=np.int64)
    knn_d = np.empty((m, neighbors))
    for start in range(0, m, block):
        stop = min(start + block, m)
        d = np.sqrt(np.maximum(sq[start:stop, None] + sq[None, :]
                               - 2.0 * (A[start:stop] @ A.T), 0.0))
        near[start:stop] = d <= eps
        d[np.arange(stop - start), np.arange(start, stop)] = np.inf   # not itself
        # a full row sort, not a partial selection: patches of uniform sky
        # have exactly equal distances, and the two rules then keep different
        # neighbors, which moves a few patches between spectral clusters
        take = np.argsort(d, axis=1)[:, :neighbors]
        idx[start:stop] = take
        knn_d[start:stop] = np.take_along_axis(d, take, axis=1)
    return near, idx, knn_d


within, knn_idx, knn_d = neighborhoods(C, EPS, NEIGHBORS)
core = within.sum(axis=1) >= MIN_PTS            # counting the point itself
lab_db = dbscan(within, core)
n_db = lab_db.max() + 1
sizes_db = np.bincount(lab_db[lab_db >= 0])
noise = lab_db < 0
print(f"  DBSCAN with eps = {EPS}, m* = {MIN_PTS}: {n_db} clusters of sizes "
      f"{sorted(sizes_db.tolist(), reverse=True)}, {noise.mean():.0%} noise")
check("[B-dbscan] every cluster is connected through a chain of core points at "
      "most eps apart",
      all(components((lab_db == c) & core, within) == 1 for c in range(n_db)))
check("[B-dbscan] no noise patch is a core point or within eps of one",
      not core[noise].any() and not within[noise][:, core].any())
check("[B-dbscan] sky with cloud and the land: the two largest clusters hold over "
      "80% of the patches, 1-30% are noise",
      np.sort(sizes_db)[-2:].sum() > 0.8 * len(C) and 0.01 <= noise.mean() <= 0.30)
  [ok] [B-dbscan] every cluster is connected through a chain of core points at most eps apart
  [ok] [B-dbscan] no noise patch is a core point or within eps of one
  [ok] [B-dbscan] sky with cloud and the land: the two largest clusters hold over 80% of the patches, 1-30% are noise
  spectral clustering on the 10-nearest-neighbor color graph (75830 edges, 1 connected component(s)): cluster sizes [1831, 2671, 4826, 2, 2958], 5 of 5 clusters connected in the graph

B-graph

The 10-nearest-neighbor graph of the colors: spectral clustering with k = 5 (eigenvectors of the Laplacian matrix, then k-means); check that the clusters are connected in the graph.

def knn_graph(A, idx, knn_dist):
    """Symmetrized nearest-neighbor graph with Gaussian edge weights, held as
    an edge list (plus the adjacency indicator the connectivity checks read)."""
    m = len(A)
    rows = np.repeat(np.arange(m), idx.shape[1])
    pairs = np.unique(np.stack([np.minimum(rows, idx.ravel()),
                                np.maximum(rows, idx.ravel())], axis=1), axis=0)
    ei, ej = pairs[:, 0], pairs[:, 1]
    bw = float(np.median(knn_dist))
    w = np.exp(-((A[ei] - A[ej]) ** 2).sum(axis=1) / (2.0 * bw ** 2))
    adjacency = np.zeros((m, m), dtype=bool)
    adjacency[ei, ej] = True
    adjacency |= adjacency.T
    return adjacency, ei, ej, w


def farthest_first(A, k):
    """Deterministic start: the first point, then always the farthest one."""
    chosen = [0]
    for _ in range(k - 1):
        d2 = ((A[:, None, :] - A[chosen]) ** 2).sum(axis=2).min(axis=1)
        chosen.append(int(d2.argmax()))
    return A[chosen]


adj, edge_i, edge_j, edge_w = knn_graph(C, knn_idx, knn_d)


def spectral_embedding(ei, ej, w, m, k, steps=600):
    """The k eigenvectors of the normalized Laplacian with the smallest
    eigenvalues.  They are the k largest of the normalized adjacency operator,
    found by the Lanczos method with full reorthogonalization: every step is
    one pass over the edge list, so the normalized Laplacian is never written
    out as a matrix.  Six hundred steps bring the residual of every returned
    eigenvector below 1e-4; at three hundred it is 1e-3, which is not enough
    to separate these five eigenvalues, all of which lie within 1e-4 of one."""
    deg = (np.bincount(ei, weights=w, minlength=m)
           + np.bincount(ej, weights=w, minlength=m))
    scale = 1.0 / np.sqrt(deg)

    def normalized_adjacency(x):
        y = scale * x
        return scale * (np.bincount(ei, weights=w * y[ej], minlength=m)
                        + np.bincount(ej, weights=w * y[ei], minlength=m))

    gen = np.random.default_rng(0)
    Q = np.zeros((m, steps))
    Q[:, 0] = gen.standard_normal(m)
    Q[:, 0] /= np.linalg.norm(Q[:, 0])
    diag, offdiag, used = np.zeros(steps), np.zeros(steps), steps
    for i in range(steps):
        v = normalized_adjacency(Q[:, i])
        diag[i] = Q[:, i] @ v
        v -= Q[:, :i + 1] @ (Q[:, :i + 1].T @ v)     # full reorthogonalization
        v -= Q[:, :i + 1] @ (Q[:, :i + 1].T @ v)     # twice is enough
        nrm = np.linalg.norm(v)
        if i + 1 == steps or nrm < 1e-10:
            used = i + 1
            break
        offdiag[i] = nrm
        Q[:, i + 1] = v / nrm
    tri = (np.diag(diag[:used]) + np.diag(offdiag[:used - 1], 1)
           + np.diag(offdiag[:used - 1], -1))
    vals, rot = np.linalg.eigh(tri)
    return Q[:, :used] @ rot[:, np.argsort(vals)[::-1][:k]]


emb = spectral_embedding(edge_i, edge_j, edge_w, len(C), K)   # k new feature entries per node
emb /= np.linalg.norm(emb, axis=1, keepdims=True)
lab_sp, _ = lloyd(emb, farthest_first(emb, K))
n_comp = components(np.ones(len(C), bool), adj)
connected_sp = sum(components(lab_sp == c, adj) == 1 for c in range(K))
print(f"  spectral clustering on the {NEIGHBORS}-nearest-neighbor color graph "
      f"({int(adj.sum() // 2)} edges, {n_comp} connected component(s)): cluster "
      f"sizes {np.bincount(lab_sp).tolist()}, {connected_sp} of {K} clusters "
      f"connected in the graph")
check("[B-graph] the new feature vectors have k entries, one row per node",
      emb.shape == (len(C), K))
check("[B-graph] at least four of the five spectral clusters are connected in the graph",
      connected_sp >= 4)
  [ok] [B-graph] the new feature vectors have k entries, one row per node
  [ok] [B-graph] at least four of the five spectral clusters are connected in the graph
  convex clustering path (alpha: clusters / clusters of 80+ patches): 0.05: 4551/0, 0.0901: 3062/3, 0.162: 2045/16, 0.292: 1361/34, 0.527: 863/43, 0.949: 557/41, 1.71: 317/35, 3.08: 191/16, 5.55: 124/10, 10: 94/7, 18: 65/4, 32.5: 49/3, 58.5: 38/3, 105: 32/3, 190: 24/2, 342: 19/2, 616: 15/2, 1.11e+03: 12/2, 2e+03: 11/2
  chosen alpha = 18: 65 clusters of sizes [6644, 4471, 602, 397, 20, 17, 15, 13, 10, 7, 7, 6, 4, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

B-convex

Convex clustering with the edge weights of that graph along a path of penalty parameters: check that the number of clusters falls along the path and that one value leaves about five clusters of 80 or more patches.

def label_components(m, ei, ej):
    """Connected-component label of every node, from a list of edges, by
    union-find: no node-by-node adjacency matrix is formed."""
    parent = list(range(m))

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    for a, b in zip(ei.tolist(), ej.tolist()):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[ra] = rb
    roots = np.array([find(x) for x in range(m)])
    _, first, inverse = np.unique(roots, return_index=True, return_inverse=True)
    order = np.empty(len(first), dtype=int)            # label by first appearance
    order[np.argsort(first)] = np.arange(len(first))
    return order[inverse]


def convex_clustering(A, i_idx, j_idx, a_edge, alphas, rho=1.0, iters=200,
                      cg_iters=25, cg_tol=1e-6):
    """Minimize sum_r ||x_r - w_r||^2 + alpha sum_edges W_rr' ||w_r - w_r'||
    by alternating updates with one splitting variable per edge (Chi and
    Lange, 2015), warm-started along the path of alphas.  Returns, per
    alpha, the labels of the clusters of patches that share a centroid.
    The centroid update solves (2 I + rho E^T E) w = b by conjugate
    gradients on the edge list, so the matrix is never formed or inverted."""
    m, d = A.shape
    bins_i = (i_idx[:, None] * d + np.arange(d)).ravel()   # (node, channel) bins
    bins_j = (j_idx[:, None] * d + np.arange(d)).ravel()

    def edge_gather(X):
        """E^T (X_i - X_j) for a value per edge, as a value per node.  One
        bincount over a flattened (edge, channel) index does all channels at
        once, which halves the cost of the inner loop."""
        flat = X.ravel()
        return (np.bincount(bins_i, weights=flat, minlength=m * d).reshape(m, d)
                - np.bincount(bins_j, weights=flat, minlength=m * d).reshape(m, d))

    degree = (np.bincount(i_idx, minlength=m)
              + np.bincount(j_idx, minlength=m)).astype(float)
    precond = 1.0 / (2.0 + rho * degree)[:, None]    # the system's diagonal

    def system(X):
        """(2 I + rho E^T E) X."""
        return 2.0 * X + rho * edge_gather(X[i_idx] - X[j_idx])

    def solve(B, X):
        """Conjugate gradients with the diagonal preconditioner, warm-started
        at X (one run per color channel).  The warm start is the previous
        centroid update, which is close, so the relative residual falls below
        the tolerance in a handful of steps."""
        R = B - system(X)
        Z = precond * R
        Pdir = Z.copy()
        rz = (R * Z).sum(axis=0)
        goal = cg_tol ** 2 * np.maximum((B * B).sum(axis=0), 1e-300)
        for _ in range(cg_iters):
            if np.all((R * R).sum(axis=0) <= goal):
                break
            Q = system(Pdir)
            step = rz / np.maximum((Pdir * Q).sum(axis=0), 1e-300)
            X = X + step * Pdir
            R = R - step * Q
            Z = precond * R
            rz_next = (R * Z).sum(axis=0)
            Pdir = Z + (rz_next / np.maximum(rz, 1e-300)) * Pdir
            rz = rz_next
        return X

    V = A[i_idx] - A[j_idx]
    U = np.zeros_like(V)
    Wc = A.copy()
    out = []
    for alpha in alphas:
        for _ in range(iters):
            Wc = solve(2.0 * A + rho * edge_gather(V - U), Wc)   # centroid update
            Z = Wc[i_idx] - Wc[j_idx] + U
            nz = np.maximum(np.linalg.norm(Z, axis=1), 1e-12)
            V = np.maximum(0.0, 1.0 - alpha * a_edge / (rho * nz))[:, None] * Z
            U = U + Wc[i_idx] - Wc[j_idx] - V
        keep = np.linalg.norm(V, axis=1) == 0.0                  # fused edges
        out.append((float(alpha), label_components(m, i_idx[keep], j_idx[keep])))
    return out


ALPHAS = np.geomspace(0.05, 2000.0, 19)
path = convex_clustering(C, edge_i, edge_j, edge_w, ALPHAS)
totals = [int(lab.max() + 1) for _, lab in path]
big = [int((np.bincount(lab) >= BIG).sum()) for _, lab in path]
# the point on the path where about k clusters of BIG or more patches remain;
# the rest are small clusters of a few patches each (ties: the larger alpha)
alpha_cc, lab_cc = min(path, key=lambda t: (
    abs(int((np.bincount(t[1]) >= BIG).sum()) - K), -t[0]))
sizes_cc = sorted(np.bincount(lab_cc).tolist(), reverse=True)
print(f"  convex clustering path (alpha: clusters / clusters of {BIG}+ patches): "
      + ", ".join(f"{a:.3g}: {t}/{b}" for (a, _), t, b in zip(path, totals, big)))
print(f"  chosen alpha = {alpha_cc:.3g}: {len(sizes_cc)} clusters of sizes {sizes_cc}")
check("[B-convex] the number of clusters falls along the path from thousands "
      "to at most twenty",
      totals[0] > 2000 and totals[-1] <= 20 and all(
          a >= b for a, b in zip(totals, totals[1:])))
check(f"[B-convex] a penalty parameter on the path leaves about k clusters of {BIG} "
      "or more patches",
      abs(sum(sz >= BIG for sz in sizes_cc) - K) <= 1)
  [ok] [B-convex] the number of clusters falls along the path from thousands to at most twenty
  [ok] [B-convex] a penalty parameter on the path leaves about k clusters of 80 or more patches

B-plot

One painted copy of the photograph per method (each patch in the mean color of its cluster, white lines between clusters, DBSCAN noise in black), plus the preview.

def paint_photo(lab, name, zoom=4):
    """Each patch in the mean color of its cluster, white lines between
    clusters, DBSCAN noise in black."""
    lab2 = lab.reshape(p_rows, p_cols)
    img = np.zeros((p_rows, p_cols, 3))
    for c in np.unique(lab):
        img[lab2 == c] = C[lab == c].mean(axis=0) if c >= 0 else 0.0
    big = img.repeat(zoom, axis=0).repeat(zoom, axis=1)
    above = np.zeros((p_rows, p_cols), dtype=bool)
    above[1:] = lab2[1:] != lab2[:-1]
    left = np.zeros((p_rows, p_cols), dtype=bool)
    left[:, 1:] = lab2[:, 1:] != lab2[:, :-1]
    for r, c in zip(*np.nonzero(above)):
        big[r * zoom, c * zoom:(c + 1) * zoom] = 1.0
    for r, c in zip(*np.nonzero(left)):
        big[r * zoom:(r + 1) * zoom, c * zoom] = 1.0
    plt.imsave(OUT_DIR / name, np.clip(big, 0.0, 1.0))
    return big


painted = {m: paint_photo(lab, f"clustering_photo_{m}.png")
           for m, lab in (("kmeans", lab_km), ("gmm", lab_gmm),
                          ("dbscan", lab_db), ("spectral", lab_sp),
                          ("convex", lab_cc))}

fig, axes = plt.subplots(1, 6, figsize=(23, 3.6))
axes[0].imshow(snap)
axes[0].set_title("photograph (Wachau)")
for ax, m, title in zip(axes[1:], ("kmeans", "gmm", "dbscan", "spectral", "convex"),
                        ("k-means, k = 5", "GMM, k = 5",
                         f"DBSCAN, eps = {EPS}, m* = {MIN_PTS} (noise black)",
                         "spectral clustering, k = 5",
                         f"convex clustering, alpha = {alpha_cc:.3g}")):
    ax.imshow(np.clip(painted[m], 0, 1))
    ax.set_title(title)
for ax in axes:
    ax.set_xlabel("pixel column")
    ax.set_ylabel("pixel row")
fig.tight_layout()
fig.savefig(OUT_DIR / "clustering.png", dpi=150)
plt.close(fig)
check("[B-plot] one painted copy of the photograph per method",
      all((OUT_DIR / f"clustering_photo_{m}.png").exists() for m in painted))

passed = sum(1 for _, ok in report if ok)
print(f"\n{passed}/{len(report)} checks pass")
  [ok] [B-plot] one painted copy of the photograph per method

16/16 checks pass
Preview figure produced by clustering.py
The preview figure the block B-plot writes when the script runs