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

Run it with python3 clustering.py, from any directory — it writes its output files into the current directory. Requires NumPy and Matplotlib only, and uses fixed seeds, so the printed numbers reproduce exactly. Download clustering.py · Notebook · Open in Colab

Preview figure produced by clustering.py
The preview figure the script writes when it runs

Source

#!/usr/bin/env python3
"""
clustering.py — Foreground/background segmentation of the cow image via
hard clustering (k-means) and soft clustering (Gaussian mixture model).

Pixels of ``assets/CowsAustria.jpg`` are used as a 3-dimensional dataset
in RGB space. Two clusters are fit:

    1. k-means  (hard assignment: each pixel → one cluster)
    2. GMM/EM   (soft assignment: each pixel → posterior over clusters)

The script writes two PNGs into ``pythondemos/`` for quick preview and a
single preview PDF showing original / hard / soft side by side.

Run from the repo root:

    python3 pythondemos/clustering.py
"""
from __future__ import annotations

from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.image import imread

REPO = Path(__file__).resolve().parent.parent
IMG_PATH = REPO / "assets" / "BergSee.jpg"
OUT_DIR = REPO / "pythondemos"

# Export the PNG panels at 300 ppi for their printed size. Each panel is
# placed in a 0.31\textwidth minipage (\textwidth = 390pt), so its printed
# width is 0.31 * 390 / 72.27 in; at 300 ppi that is ~502 px. Saving the
# full-resolution source (2816 px wide) embedded ~30x more pixels than the
# page can show and dominated the book PDF size.
TEXTWIDTH_PT = 390.0
PT_PER_INCH = 72.27
PANEL_FRACTION = 0.31
TARGET_DPI = 300
MAX_WIDTH_PX = round(PANEL_FRACTION * TEXTWIDTH_PT / PT_PER_INCH * TARGET_DPI)

RNG = np.random.default_rng(0)


def save_png_300dpi(img01: np.ndarray, path: Path, max_width_px: int = MAX_WIDTH_PX) -> None:
    """Save an (H, W, 3) array in [0,1] as PNG, downscaled so its printed
    width (MAX_WIDTH_PX) is TARGET_DPI ppi. Pillow is matplotlib's PNG
    backend (imread/imsave already rely on it), so this adds no dependency."""
    from PIL import Image
    rgb8 = (np.clip(img01, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
    im = Image.fromarray(rgb8)
    if im.width > max_width_px:
        new_h = round(im.height * max_width_px / im.width)
        im = im.resize((max_width_px, new_h), Image.LANCZOS)
    im.save(path)


def _kmeanspp_init(X: np.ndarray, k: int) -> np.ndarray:
    """k-means++ seeding: spreads initial centroids across the dataset."""
    n = len(X)
    centroids = [X[RNG.integers(n)]]
    for _ in range(k - 1):
        d2 = np.min(
            ((X[:, None, :] - np.stack(centroids)[None, :, :]) ** 2).sum(-1),
            axis=1,
        )
        probs = d2 / d2.sum()
        centroids.append(X[RNG.choice(n, p=probs)])
    return np.stack(centroids)


def kmeans(X: np.ndarray, k: int, n_iter: int = 20) -> tuple[np.ndarray, np.ndarray]:
    """Lloyd's algorithm with k-means++ seeding."""
    centroids = _kmeanspp_init(X, k).copy()
    for _ in range(n_iter):
        d2 = ((X[:, None, :] - centroids[None, :, :]) ** 2).sum(-1)
        labels = d2.argmin(axis=1)
        for c in range(k):
            mask = labels == c
            if mask.any():
                centroids[c] = X[mask].mean(axis=0)
    return centroids, labels


def gmm_em(X: np.ndarray, k: int, n_iter: int = 40) -> np.ndarray:
    """Fit a diagonal-covariance GMM via EM. Returns soft assignments (n,k)."""
    n, d = X.shape
    idx = RNG.choice(n, size=k, replace=False)
    mu = X[idx].copy()
    var = np.tile(X.var(axis=0) + 1e-3, (k, 1))
    pi = np.full(k, 1.0 / k)
    for _ in range(n_iter):
        # E-step: log N(x | mu_c, diag(var_c))
        log_p = np.empty((n, k))
        for c in range(k):
            diff = X - mu[c]
            log_p[:, c] = (
                np.log(pi[c] + 1e-12)
                - 0.5 * np.sum(np.log(2 * np.pi * var[c]))
                - 0.5 * np.sum(diff ** 2 / var[c], axis=1)
            )
        log_p -= log_p.max(axis=1, keepdims=True)
        resp = np.exp(log_p)
        resp /= resp.sum(axis=1, keepdims=True)
        # M-step
        nk = resp.sum(axis=0) + 1e-12
        pi = nk / n
        mu = (resp.T @ X) / nk[:, None]
        for c in range(k):
            diff = X - mu[c]
            var[c] = (resp[:, c, None] * diff ** 2).sum(axis=0) / nk[c] + 1e-4
    return resp


def main() -> None:
    img = imread(IMG_PATH).astype(np.float32) / 255.0
    H, W, _ = img.shape
    # Downsample for speed; label every pixel at the end.
    step = 4
    small = img[::step, ::step].reshape(-1, 3)

    K = 2

    # ---- hard clustering ----
    centroids, _ = kmeans(small, k=K, n_iter=25)
    X = img.reshape(-1, 3)
    d2 = ((X[:, None, :] - centroids[None, :, :]) ** 2).sum(-1)
    hard_labels = d2.argmin(axis=1)
    # Pick one cluster to highlight: the brightest centroid, which
    # corresponds to the sky/mountain region.
    target = int(np.argmax(centroids.sum(axis=1)))
    # Hard: keep the original RGB for pixels assigned to the target
    # cluster; black out the rest.
    hard_mask = (hard_labels == target).reshape(H, W, 1).astype(np.float32)
    hard_img = img * hard_mask

    # ---- soft clustering ----
    # Fit a GMM on the full image, then soften the posteriors with a
    # temperature so that the visualization shows graded membership
    # rather than a near-binary assignment.
    # Fit the GMM, then inflate the per-component variances to widen
    # each Gaussian and re-score with the posterior formula. Larger
    # variances give softer (more uniform) posteriors, producing
    # smoother transitions between clusters.
    resp = gmm_em(X, k=K, n_iter=25)
    # Recover component means and recompute posteriors with inflated
    # diagonal covariance (same variance in each component for the
    # visualization — we only need softer posteriors, not a better
    # fit).
    nk = resp.sum(axis=0) + 1e-12
    mu = (resp.T @ X) / nk[:, None]
    pi = nk / nk.sum()
    var = np.full((K, 3), 0.25)  # wide, uniform covariance
    log_p = np.empty((len(X), K))
    for c in range(K):
        diff = X - mu[c]
        log_p[:, c] = (
            np.log(pi[c] + 1e-12)
            - 0.5 * np.sum(np.log(2 * np.pi * var[c]))
            - 0.5 * np.sum(diff ** 2 / var[c], axis=1)
        )
    log_p -= log_p.max(axis=1, keepdims=True)
    resp = np.exp(log_p)
    resp /= resp.sum(axis=1, keepdims=True)
    # Align the GMM component order with k-means centroids by matching
    # GMM means to the k-means centroids (greedy match).
    mu_full = np.stack([
        (resp[:, c, None] * X).sum(axis=0)
        / (resp[:, c].sum() + 1e-12)
        for c in range(K)
    ])
    perm = []
    used = set()
    for c in range(K):
        order = np.argsort(np.linalg.norm(mu_full - centroids[c], axis=1))
        for j in order:
            if j not in used:
                perm.append(j)
                used.add(j)
                break
    resp = resp[:, perm]
    # Soft: modulate each pixel's original RGB by the posterior
    # probability that it belongs to the target cluster.
    soft_weight = resp[:, target].reshape(H, W, 1)
    soft_img = img * soft_weight

    save_png_300dpi(img, OUT_DIR / "clustering_original.png")
    save_png_300dpi(hard_img, OUT_DIR / "clustering_hard.png")
    save_png_300dpi(soft_img, OUT_DIR / "clustering_soft.png")

    # ---- preview PDF ----
    fig, axes = plt.subplots(1, 3, figsize=(10, 3.2))
    axes[0].imshow(img)
    axes[0].set_title("original")
    axes[1].imshow(np.clip(hard_img, 0, 1))
    axes[1].set_title(f"hard: k-means (k={K})")
    axes[2].imshow(np.clip(soft_img, 0, 1))
    axes[2].set_title(f"soft: GMM posterior (k={K})")
    for ax in axes:
        ax.set_xticks([])
        ax.set_yticks([])
    fig.tight_layout()
    fig.savefig(OUT_DIR / "clustering.png", dpi=110)

    print(f"wrote {OUT_DIR/'clustering.png'}")
    print(f"wrote {OUT_DIR/'clustering_original.png'}")
    print(f"wrote {OUT_DIR/'clustering_hard.png'}")
    print(f"wrote {OUT_DIR/'clustering_soft.png'}")


if __name__ == "__main__":
    main()