The Ellipse Picture of the SVD

Math 123: Mathematical Aspects of Data Analysis — Fall 2026

Published

September 24, 2026

This page is meant to be read alongside Homework 3, 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).

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))
singular values: [2.1232 0.763 ]
v_1 = [0.7258 0.6879]   v_2 = [-0.6879  0.7258]
u_1 = [0.871  0.4914]   u_2 = [-0.4914  0.871 ]

Drag the slider in Figure 1 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.

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()
Figure 1: 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.

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 \(\bfA_1=\sigma_1\bfu_1\bfv_1^\top\).

Drag the slider in Figure 2 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.

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()
Figure 2: 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.