The Swiss Roll: SVD vs. Spectral Embedding

Math 123: Mathematical Aspects of Data Analysis — Fall 2026

Published

November 3, 2026

The swiss roll is a flat rectangle of points rolled up in \(\Rbb^3\), so it has only two degrees of freedom: how far along the roll a point is, and how high up. This page asks two methods to find those two coordinates: the SVD, which can only project onto a plane, and a spectral embedding of a nearest-neighbour graph. For why we expect the SVD to fail here, see Lecture 17.

import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.manifold import SpectralEmbedding

The data

We start from a rectangle with coordinates \((s,h)\) and roll it up along the spiral \(t\mapsto(t\cos t,\ t\sin t)\). The spiral has speed \(\sqrt{1+t^2}\approx t\), so \(s=(t^2-t_0^2)/2\) is (nearly) the distance travelled along the roll, and sampling \(s\) uniformly spreads the points evenly over the surface. We keep \((s,h)\) only to color the plots.

def make_swiss_roll(n_samples=2500, height=70.0, noise=0.3, random_state=0):
    """Points on a rolled-up rectangle in R^3, with their flat coordinates (s, h)."""
    rng = np.random.default_rng(random_state)
    t0, t1 = 1.5 * np.pi, 4.5 * np.pi           # one and a half turns of the spiral
    s = rng.uniform(0, (t1**2 - t0**2) / 2, n_samples)
    h = rng.uniform(0, height, n_samples)
    t = np.sqrt(t0**2 + 2 * s)
    X = np.c_[t * np.cos(t), h, t * np.sin(t)] + noise * rng.standard_normal((n_samples, 3))
    return X, s, h


X, s, h = make_swiss_roll()
print(X.shape)
(2500, 3)
Figure style
INK = "#7c7c78"                               # muted colors that read in light and dark mode
GRID = "rgba(128, 128, 128, 0.28)"
COLORSCALE = "Viridis"
HOVER = [f"s = {a:.1f}, h = {b:.1f}" for a, b in zip(s, h)]


def style(fig, height):
    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=height, margin=dict(l=20, r=20, t=50, b=20), hovermode="closest")
    return fig
Plot the roll
fig = go.Figure(go.Scatter3d(
    x=X[:, 0], y=X[:, 1], z=X[:, 2], mode="markers",
    marker=dict(size=2.5, color=s, colorscale=COLORSCALE,
                colorbar=dict(title="s", thickness=12, len=0.6)),
    text=HOVER, hovertemplate="%{text}<extra></extra>"))
axis = dict(showticklabels=False, title="", gridcolor=GRID, zerolinecolor=GRID,
            showbackground=False)
fig.update_layout(scene=dict(xaxis=axis, yaxis=axis, zaxis=axis, aspectmode="data",
                             camera=dict(eye=dict(x=1.6, y=-1.2, z=0.6))))
style(fig, height=520).show()
Figure 1: The swiss roll in \(\Rbb^3\), colored by the distance \(s\) along the roll. Drag to rotate.

Two embeddings in the plane

The SVD keeps the coordinates of each centered point along the top two right singular vectors. The spectral embedding joins each point to its \(15\) nearest neighbours and keeps two eigenvectors of the normalized Laplacian of that graph. Eigenvectors are only determined up to sign, so we flip each coordinate to increase with \(s\) or \(h\); this changes nothing but the orientation of the picture.

Xc = X - X.mean(axis=0)
U, sigma, Vt = np.linalg.svd(Xc, full_matrices=False)
Y_svd = Xc @ Vt[:2].T

Y_spec = SpectralEmbedding(n_components=2, affinity="nearest_neighbors",
                           n_neighbors=15, random_state=0).fit_transform(X)
for j, target in enumerate([s, h]):
    if np.corrcoef(Y_spec[:, j], target)[0, 1] < 0:
        Y_spec[:, j] *= -1

print("singular values / sqrt(n):", np.round(sigma / np.sqrt(len(X)), 2))
singular values / sqrt(n): [20.25  7.75  6.69]
Plot the three panels
panels = {
    "<b>unrolled</b> (the truth)": np.c_[s, h],
    "<b>SVD</b> (best plane)": Y_svd,
    "<b>spectral embedding</b>": Y_spec,
}
fig = make_subplots(rows=1, cols=3, subplot_titles=list(panels), horizontal_spacing=0.05)
for col, Y in enumerate(panels.values(), start=1):
    fig.add_trace(go.Scatter(
        x=Y[:, 0], y=Y[:, 1], mode="markers", showlegend=False,
        marker=dict(size=4, color=s, colorscale=COLORSCALE, showscale=(col == 3),
                    colorbar=dict(title="s", thickness=12, len=0.8)),
        text=HOVER, hovertemplate="%{text}<extra></extra>"), row=1, col=col)

# Same scale on both axes of every panel, so distances are not distorted.
for i in range(1, 4):
    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)
style(fig, height=440).show()
Figure 2: Left: the rectangle before it was rolled up. Middle: the projection onto the top two right singular vectors. Right: the spectral embedding of the \(15\)-nearest-neighbour graph. Every panel is colored by the distance \(s\) along the roll.

The SVD can only look at the roll from one direction, so the layers of the roll land on top of each other: in the middle panel, points with very different values of \(s\) sit side by side. No choice of plane avoids this, because the roll is not close to any plane. The spectral embedding only uses which points are near each other on the surface, and it lays the colors out from left to right in the same order as the flat rectangle. Hover over points in the middle and right panels to compare their \((s,h)\) values.