---
title: "The Ellipse Picture of the SVD"
subtitle: "Math 123: Mathematical Aspects of Data Analysis — Fall 2026"
date: 2026-09-24
number-sections: false
jupyter: python3
format:
html:
toc: true
toc-depth: 2
code-line-numbers: true
code-tools: true
out-width: "100%"
column: page
execute:
echo: true
draft: false
---
This page is meant to be read alongside [Homework 3, Problem 4](../../homeworks/homework-03.qmd#problem-4).
Take any $\bfA\in\Rbb^{2\times2}$ of rank $2$, with SVD $\bfA=\sigma_1\bfu_1\bfv_1^\top+\sigma_2\bfu_2\bfv_2^\top$ ([SVD](../../lectures/lecture-04.qmd#thm-svd)).
```{python}
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
A = np.array([[1.6, 1.0],
[0.3, 1.2]])
U, s, Vt = np.linalg.svd(A)
V = Vt.T
# Singular vectors are only determined up to sign; flip pairs to point up and right.
for j, flip in enumerate([V[0, 0] < 0, V[1, 1] < 0]):
if flip:
V[:, j], U[:, j] = -V[:, j], -U[:, j]
print("singular values:", np.round(s, 4))
print("v_1 =", np.round(V[:, 0], 4), " v_2 =", np.round(V[:, 1], 4))
print("u_1 =", np.round(U[:, 0], 4), " u_2 =", np.round(U[:, 1], 4))
```
Drag the slider in @fig-ellipse to move a unit vector $\bfx$ around the circle on the left.
On the right, $\bfA\bfx$ traces out the ellipse with semi-axes $\sigma_1\bfu_1$ and $\sigma_2\bfu_2$.
Watch where $\bfx$ is when $\bfA\bfx$ reaches the ends of the long axis, and when it reaches the ends of the short axis.
```{python}
#| label: fig-ellipse
#| fig-cap: "Left: the unit circle and the right singular vectors v_1, v_2. Right: its image under A, an ellipse with semi-axes sigma_1 u_1 and sigma_2 u_2."
#| fig-alt: "Two square panels. On the left, a solid unit circle with two perpendicular solid arrows v1 (blue) and v2 (orange) and a round movable point x. On the right, a dotted tilted ellipse with a dotted blue arrow sigma1 u1 along its long axis, a dotted orange arrow sigma2 u2 along its short axis, and a triangular point Ax moving on the ellipse. Each panel has its own legend below it. A slider below controls the angle of x."
#| code-fold: true
#| code-summary: "Plot the circle and its image"
# The same muted palette as the other examples, which reads correctly against
# both the light and the dark version of this site.
INK = "#7c7c78"
GRID = "rgba(128, 128, 128, 0.28)"
BLUE, ORANGE = "#2a78d6", "#eb6834"
t = np.linspace(0, 2 * np.pi, 361)
circle = np.vstack([np.cos(t), np.sin(t)]) # unit circle, one point per column
ellipse = A @ circle
# Each panel gets its own legend. The left panel is drawn solid with round markers,
# and the right panel dotted with triangles, while colors pair each v_j with sigma_j u_j.
LEFT = dict(legend="legend", dash="solid", symbol="circle")
RIGHT = dict(legend="legend2", dash="dot", symbol="triangle-up")
def curve(pts, name, style):
"""A closed curve through the columns of pts."""
return go.Scatter(x=pts[0], y=pts[1], mode="lines", name=name, legend=style["legend"],
line=dict(color=INK, width=2, dash=style["dash"]), hoverinfo="skip")
def arrow(tip, color, name, style):
"""An arrow from the origin to tip."""
return go.Scatter(
x=[0, tip[0]], y=[0, tip[1]], mode="lines+markers", name=name, legend=style["legend"],
line=dict(color=color, width=3, dash=style["dash"]),
marker=dict(symbol="arrow", angleref="previous", size=[0, 14], color=color),
)
def dot(p, name, style):
"""A single marker at the point p."""
return go.Scatter(x=[p[0]], y=[p[1]], mode="markers", name=name, legend=style["legend"],
marker=dict(color=INK, size=12, symbol=style["symbol"],
line=dict(color="white", width=1)))
fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.1,
subplot_titles=["unit vectors x", "images Ax"])
fig.add_trace(curve(circle, "unit circle", LEFT), row=1, col=1)
fig.add_trace(arrow(V[:, 0], BLUE, "v<sub>1</sub>", LEFT), row=1, col=1)
fig.add_trace(arrow(V[:, 1], ORANGE, "v<sub>2</sub>", LEFT), row=1, col=1)
fig.add_trace(curve(ellipse, "ellipse A(circle)", RIGHT), row=1, col=2)
fig.add_trace(arrow(s[0] * U[:, 0], BLUE, "σ<sub>1</sub>u<sub>1</sub>", RIGHT), row=1, col=2)
fig.add_trace(arrow(s[1] * U[:, 1], ORANGE, "σ<sub>2</sub>u<sub>2</sub>", RIGHT), row=1, col=2)
# The moving points, which the slider restyles.
fig.add_trace(dot([1, 0], "x", LEFT), row=1, col=1)
fig.add_trace(dot(A @ [1, 0], "Ax", RIGHT), row=1, col=2)
i_x, i_Ax = len(fig.data) - 2, len(fig.data) - 1
steps = []
for deg in range(0, 360, 5):
x = np.array([np.cos(np.deg2rad(deg)), np.sin(np.deg2rad(deg))])
Ax = A @ x
steps.append(dict(
method="restyle", label=f"{deg}°",
args=[{"x": [[x[0]], [Ax[0]]], "y": [[x[1]], [Ax[1]]]}, [i_x, i_Ax]],
))
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),
margin=dict(l=60, r=30, t=40, b=50),
height=520,
hovermode="closest",
legend=dict(orientation="h", y=-0.12, x=0),
legend2=dict(orientation="h", y=-0.12, x=0.55),
sliders=[dict(active=0, steps=steps, pad=dict(t=90),
currentvalue=dict(prefix="angle of x: "))],
)
for col, lim in [(1, 1.3), (2, 1.1 * s[0])]:
fig.update_xaxes(range=[-lim, lim], constrain="domain", gridcolor=GRID, linecolor=GRID,
zerolinecolor=GRID, row=1, col=col)
fig.update_yaxes(range=[-lim, lim], scaleanchor="x" if col == 1 else "x2", scaleratio=1,
gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID, row=1, col=col)
fig.show()
```
## Letting $\sigma_2$ shrink to zero
Let's investigate what happens to the ellipse as $\bfA$'s smallest singular value vanishes; i.e. it approaches rank 1!
In the following, we keep $\sigma_1$, $\bfu_1,\bfu_2$, and $\bfv_1,\bfv_2$ fixed, and let $\sigma_2$ decrease from $\sigma_1$ down to $0$ in $\bfA=\sigma_1\bfu_1\bfv_1^\top+\sigma_2\bfu_2\bfv_2^\top$.
At $\sigma_2=0$ what is left is the [truncated SVD](../../lectures/lecture-04.qmd#def-truncated-svd) $\bfA_1=\sigma_1\bfu_1\bfv_1^\top$.
Drag the slider in @fig-collapse from left to right.
The twelve marked points on the circle are spaced $30^\circ$ apart, starting from $\bfv_1$, and the triangles on the right are their images.
- At $\sigma_2=\sigma_1$ the image is a circle of radius $\sigma_1$.
- As $\sigma_2$ decreases the short semi-axis $\sigma_2\bfu_2$ shrinks.
- At $\sigma_2=0$ the ellipse has collapsed onto the segment from $-\sigma_1\bfu_1$ to $\sigma_1\bfu_1$.
That is, when $\sigma_2$ vanishes, its range literally collapses onto a line!
It's image falls from 2D to 1D; in other words the dimension of its range decreases from 2 to 1.
I hope it's clear now, how the singular values are morally an interpolation for intermediate states between rank changes.
```{python}
#| label: fig-collapse
#| fig-cap: "Left: the unit circle, the right singular vectors v_1, v_2, and twelve sample unit vectors. Right: their images as sigma_2 decreases from sigma_1 to 0, with sigma_1, u_1, u_2, v_1, v_2 held fixed. The ellipse collapses onto the segment from -sigma_1 u_1 to sigma_1 u_1."
#| fig-alt: "Two square panels. On the left, a solid unit circle with perpendicular solid arrows v1 (blue) and v2 (orange) and twelve round points spaced evenly around the circle. On the right, a dotted blue arrow sigma1 u1, a dotted orange arrow sigma2 u2, a dotted ellipse, and twelve triangular points on it. A slider below decreases sigma2 from sigma1 to 0; the orange arrow shrinks, the ellipse flattens onto the line through u1, and the triangles pair up on that line, with two of them at the origin."
#| code-fold: true
#| code-summary: "Plot the ellipse as sigma_2 shrinks"
def A_with(sig2):
"""The matrix with the same singular vectors and sigma_1 as A, but singular value sig2 in place of sigma_2."""
return s[0] * np.outer(U[:, 0], V[:, 0]) + sig2 * np.outer(U[:, 1], V[:, 1])
# Twelve unit vectors, at angles 0, 30, ..., 330 degrees from v_1 toward v_2.
degs = np.arange(0, 360, 30)
theta = np.deg2rad(degs)
X = np.outer(V[:, 0], np.cos(theta)) + np.outer(V[:, 1], np.sin(theta))
labels = [f"{d}° from v<sub>1</sub>" for d in degs]
def dots(pts, name, style, prefix):
"""Markers at the columns of pts, labeled by the angle of the matching x."""
return go.Scatter(x=pts[0], y=pts[1], mode="markers", name=name, legend=style["legend"],
text=labels, hovertemplate=prefix + "%{text}<extra></extra>",
marker=dict(color=INK, size=10, symbol=style["symbol"],
line=dict(color="white", width=1)))
sig2_vals = np.linspace(s[0], 0, 41)
B = A_with(sig2_vals[0])
fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.1,
subplot_titles=["unit vectors x", "images Ax"])
fig.add_trace(curve(circle, "unit circle", LEFT), row=1, col=1)
fig.add_trace(arrow(V[:, 0], BLUE, "v<sub>1</sub>", LEFT), row=1, col=1)
fig.add_trace(arrow(V[:, 1], ORANGE, "v<sub>2</sub>", LEFT), row=1, col=1)
fig.add_trace(dots(X, "sample x", LEFT, "x at "), row=1, col=1)
fig.add_trace(arrow(s[0] * U[:, 0], BLUE, "σ<sub>1</sub>u<sub>1</sub>", RIGHT), row=1, col=2)
# The traces that change with sigma_2, which the slider restyles.
fig.add_trace(curve(B @ circle, "ellipse A(circle)", RIGHT), row=1, col=2)
fig.add_trace(arrow(sig2_vals[0] * U[:, 1], ORANGE, "σ<sub>2</sub>u<sub>2</sub>", RIGHT),
row=1, col=2)
fig.add_trace(dots(B @ X, "images Ax", RIGHT, "A x, for x at "), row=1, col=2)
moving = list(range(len(fig.data) - 3, len(fig.data)))
steps = []
for sig2 in sig2_vals:
B = A_with(sig2)
E, Y, tip = B @ circle, B @ X, sig2 * U[:, 1]
steps.append(dict(
method="restyle", label=f"{sig2:.2f}",
args=[{"x": [np.round(E[0], 4).tolist(), [0, tip[0]], Y[0].tolist()],
"y": [np.round(E[1], 4).tolist(), [0, tip[1]], Y[1].tolist()],
# A zero-length arrow has no direction, so hide its head at sigma_2 = 0.
"marker.size": [0, [0, 14 if sig2 > 0 else 0], 10]}, moving],
))
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),
margin=dict(l=60, r=30, t=40, b=50),
height=520,
hovermode="closest",
legend=dict(orientation="h", y=-0.12, x=0),
legend2=dict(orientation="h", y=-0.12, x=0.55),
sliders=[dict(active=0, steps=steps, pad=dict(t=90),
currentvalue=dict(prefix="σ₂ = "))],
)
for col, lim in [(1, 1.3), (2, 1.1 * s[0])]:
fig.update_xaxes(range=[-lim, lim], constrain="domain", gridcolor=GRID, linecolor=GRID,
zerolinecolor=GRID, row=1, col=col)
fig.update_yaxes(range=[-lim, lim], scaleanchor="x" if col == 1 else "x2", scaleratio=1,
gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID, row=1, col=col)
fig.show()
```