The Swiss Roll: SVD vs. Spectral Embedding
Math 123: Mathematical Aspects of Data Analysis — Fall 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.
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 figPlot 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()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()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.