Spectral Clustering: A Victory Lap

Math 123: Mathematical Aspects of Data Analysis — Fall 2026

Published

October 21, 2026

k-means can only return convex clusters, so it fails whenever the clusters are curved or wrapped around each other. Spectral clustering only asks that each cluster be well connected in a similarity graph. This page runs both through scikit-learn on three standard examples where k-means fails: concentric circles, two moons, and two interleaved spirals. For a slower look at the circles, see k-Means vs. Spectral Clustering on Two Circles.

import warnings

import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.cluster import KMeans, SpectralClustering
from sklearn.datasets import make_circles, make_moons
from sklearn.metrics import adjusted_rand_score

The data

scikit-learn has make_circles and make_moons built in. For the spirals, we put points on the curve \(t\mapsto(t\cos t,\ t\sin t)\), reflect them through the origin to get the second arm, and add Gaussian noise. Each generator also returns the true group y of every point, which we only use for scoring.

def make_spirals(n_samples=600, noise=0.25, random_state=0):
    """Two interleaved spiral arms, each about one and a quarter turns long."""
    rng = np.random.default_rng(random_state)
    t_min, t_max = np.pi / 2, np.pi / 2 + 2.5 * np.pi
    # Taking the square root spreads the points evenly along the arm.
    t = t_max * np.sqrt(rng.uniform((t_min / t_max) ** 2, 1, n_samples // 2))
    arm = np.c_[t * np.cos(t), t * np.sin(t)]
    X = np.vstack([arm, -arm]) + noise * rng.standard_normal((n_samples, 2))
    y = np.repeat([0, 1], n_samples // 2)
    return X, y


datasets = {
    "circles": make_circles(n_samples=400, factor=0.4, noise=0.05, random_state=0),
    "moons": make_moons(n_samples=400, noise=0.06, random_state=0),
    "spirals": make_spirals(n_samples=600, noise=0.25, random_state=0),
}

Fit both methods

Both methods get the same instructions: find \(2\) clusters. For spectral clustering, affinity="nearest_neighbors" builds the graph that joins each point to its \(10\) nearest neighbours, and one setting works for all three datasets. On the circles that graph has no edges between the two rings, so scikit-learn warns that the graph is not fully connected; here the warning is good news, so we silence it.

def fit_both(X):
    kmeans = KMeans(n_clusters=2, n_init=10, random_state=0).fit_predict(X)
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", UserWarning)
        spectral = SpectralClustering(n_clusters=2, affinity="nearest_neighbors",
                                      n_neighbors=10, random_state=0).fit_predict(X)
    return {"k-means": kmeans, "spectral": spectral}


labels = {name: fit_both(X) for name, (X, y) in datasets.items()}
for name, (X, y) in datasets.items():
    scores = "   ".join(f"{method} ARI {round(adjusted_rand_score(y, lab), 2) + 0.0:5.2f}"
                        for method, lab in labels[name].items())
    print(f"{name:8} {scores}")
circles  k-means ARI  0.00   spectral ARI  1.00
moons    k-means ARI  0.27   spectral ARI  1.00
spirals  k-means ARI  0.06   spectral ARI  1.00

The adjusted Rand index (ARI) is \(1\) when the clusters match the true groups exactly and about \(0\) for a random labelling. Spectral clustering scores a perfect \(1\) on all three.

The pictures

Plot the grid
INK = "#7c7c78"                               # muted colors that read in light and dark mode
GRID = "rgba(128, 128, 128, 0.28)"
COLOR = np.array(["#2a78d6", "#eb6834"])


def align(lab, y):
    """Rename the two clusters so that cluster g matches true group g as well as possible."""
    return lab if np.mean(lab == y) >= 0.5 else 1 - lab


methods = ["k-means", "spectral"]
names = list(datasets)
titles = [f"<b>{method}</b> on {name}: ARI {round(adjusted_rand_score(datasets[name][1], labels[name][method]), 2) + 0.0:.2f}"
          for method in methods for name in names]
fig = make_subplots(rows=2, cols=3, subplot_titles=titles,
                    horizontal_spacing=0.05, vertical_spacing=0.1)
for row, method in enumerate(methods, start=1):
    for col, name in enumerate(names, start=1):
        X, y = datasets[name]
        lab = align(labels[name][method], y)
        fig.add_trace(go.Scatter(
            x=X[:, 0], y=X[:, 1], mode="markers", showlegend=False,
            marker=dict(size=6, color=COLOR[lab], line=dict(color="white", width=0.6)),
            text=[f"cluster {c}, true group {g}" for c, g in zip(lab, y)],
            hovertemplate="%{text}<extra></extra>"), row=row, col=col)

# Same scale on both axes of every panel, so circles look like circles.
for i in range(1, 7):
    suffix = "" if i == 1 else str(i)
    fig.layout[f"yaxis{suffix}"].scaleanchor = f"x{suffix}"
    fig.layout[f"xaxis{suffix}"].constrain = "domain"
fig.update_xaxes(showticklabels=False, gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
fig.update_yaxes(showticklabels=False, gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
fig.update_annotations(font=dict(color=INK, size=13))
fig.update_layout(template="simple_white", paper_bgcolor="rgba(0,0,0,0)",
                  plot_bgcolor="rgba(0,0,0,0)", font=dict(color=INK, size=13),
                  height=700, margin=dict(l=20, r=20, t=50, b=20), hovermode="closest")
fig.show()
Figure 1: k-means (top row) and spectral clustering with a \(10\)-nearest-neighbour graph (bottom row) on three datasets. Colors are the clusters found. Each panel title gives the adjusted Rand index (ARI) against the true groups.

In every top panel, k-means draws one straight cut, and each side of the cut holds pieces of both true groups. In every bottom panel, spectral clustering follows the shape of the data, because points on the same ring, moon, or arm are linked by a chain of near neighbours, and almost no edges cross between groups.

The spirals are the most delicate of the three. With \(5\) neighbours the graph breaks into \(6\) pieces, and spectral clustering separates pieces instead of arms; with \(30\) neighbours there are enough edges between the arms that the ARI drops to about \(0.2\). As always with spectral clustering, the graph is the real modelling choice (see the notes on choosing hyperparameters).