SVD Example — Low-Rank Approximation

This example builds a 4×6 matrix with low-rank structure and decomposes it using eigensystems.svd(). Rank-1, rank-2, and rank-3 truncated approximations are computed, and reconstruction errors show how quickly the singular values capture the matrix’s energy.

Example Code

"""IMSL SVD example: singular value decomposition and low-rank approximation.

Demonstrates:
- Create a 4x6 image-like matrix (low-rank structure).
- Compute the full SVD using :func:`eigensystems.svd`.
- Build rank-1, rank-2, and rank-3 approximations.
- Plot original matrix and approximations as heatmaps.
- Bar chart of singular values.
- Save to test_output/example_imsl_svd.svg

Outputs:
- Printed table of singular values and reconstruction errors.
- SVG figure saved to test_output/.
- Console output captured to docs/_generated_text/svd_output.txt
"""
from __future__ import annotations

from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

from eigensystems import svd


def run_demo_svd() -> dict:
    """Run IMSL SVD example: low-rank approximation of a 4x6 matrix.

    Constructs a matrix with clear low-rank structure (sum of outer products),
    computes the full SVD via :func:`eigensystems.svd`, and shows how
    successive rank-k truncations reconstruct the original data.

    Args:
        None

    Returns:
        dict: Result dictionary with keys ``singular_values`` (np.ndarray),
            ``errors`` (list[float]), and ``plot_path`` (str).
    """
    rng = np.random.default_rng(42)

    # Build a rank-3 matrix: sum of three outer products
    u_true = np.array([[1, 0, 0], [0, 1, 0], [1, 1, 0], [0, 0, 1]], dtype=float)
    v_true = np.array([
        [2, 1, 0, 1, 0, 0],
        [0, 1, 3, 0, 1, 0],
        [1, 0, 0, 2, 0, 1],
    ], dtype=float)
    A = u_true @ v_true + 0.1 * rng.standard_normal((4, 6))

    U, s, Vh = svd(A)

    print("\nIMSL SVD Example: Low-Rank Approximation")
    print(f"Matrix shape: {A.shape}   (rank ≈ 3)")
    print("-" * 48)
    print(f"{'Index':<8} {'Singular value':>18} {'Energy %':>10}")
    print("-" * 48)
    total_energy = np.sum(s ** 2)
    for i, sv in enumerate(s):
        energy_pct = 100.0 * sv ** 2 / total_energy
        print(f"  σ{i+1:<5} {sv:>18.6f} {energy_pct:>10.2f}%")
    print("-" * 48)

    errors = []
    approx_matrices = {}
    for rank in (1, 2, 3):
        A_k = (U[:, :rank] * s[:rank]) @ Vh[:rank, :]
        err = float(np.linalg.norm(A - A_k, ord="fro") / np.linalg.norm(A, ord="fro"))
        errors.append(err)
        approx_matrices[rank] = A_k
        print(f"  Rank-{rank} approx  Frobenius relative error: {err:.6f}")

    output_dir = Path("test_output")
    output_dir.mkdir(parents=True, exist_ok=True)
    plot_path = output_dir / "example_imsl_svd.svg"

    vmin, vmax = A.min(), A.max()
    fig, axes = plt.subplots(2, 3, figsize=(13, 8))
    fig.suptitle("IMSL SVD: Low-Rank Approximations of a 4×6 Matrix", fontsize=12)

    # Row 0: original + rank-1 + rank-2
    for col, (title, mat) in enumerate([
        ("Original A", A),
        ("Rank-1 Approx", approx_matrices[1]),
        ("Rank-2 Approx", approx_matrices[2]),
    ]):
        ax = axes[0, col]
        im = ax.imshow(mat, vmin=vmin, vmax=vmax, cmap="RdYlBu_r", aspect="auto")
        ax.set_title(title, fontsize=10)
        ax.set_xlabel("Column")
        ax.set_ylabel("Row")
        fig.colorbar(im, ax=ax, shrink=0.8)

    # Row 1: rank-3 + error plot + singular values bar chart
    ax = axes[1, 0]
    im = ax.imshow(approx_matrices[3], vmin=vmin, vmax=vmax, cmap="RdYlBu_r", aspect="auto")
    ax.set_title("Rank-3 Approx", fontsize=10)
    ax.set_xlabel("Column")
    ax.set_ylabel("Row")
    fig.colorbar(im, ax=ax, shrink=0.8)

    ax = axes[1, 1]
    ax.bar([1, 2, 3], errors, color="#0369a1", edgecolor="#075985", alpha=0.85)
    ax.set_xticks([1, 2, 3])
    ax.set_xticklabels(["Rank-1", "Rank-2", "Rank-3"])
    ax.set_ylabel("Relative Frobenius error")
    ax.set_title("Reconstruction Error vs Rank", fontsize=10)
    ax.set_ylim(0, max(errors) * 1.2)

    ax = axes[1, 2]
    idx = np.arange(1, len(s) + 1)
    ax.bar(idx, s, color="#be123c", edgecolor="#9f1239", alpha=0.85)
    ax.set_xticks(idx)
    ax.set_xticklabels([f{i}" for i in idx])
    ax.set_ylabel("Singular value")
    ax.set_title("Singular Values", fontsize=10)

    fig.tight_layout()
    fig.savefig(plot_path, format="svg")
    plt.close(fig)
    print(f"\nPlot saved: {plot_path}")

    return {
        "singular_values": s,
        "errors": errors,
        "plot_path": str(plot_path),
    }


if __name__ == "__main__":
    run_demo_svd()

Plot Output

Heatmaps of original and rank-k approximations with singular value bar chart

Top row: original 4×6 matrix and rank-1, rank-2 approximations. Bottom row: rank-3 approximation, relative Frobenius error per rank, and bar chart of all singular values.

Console Output

IMSL SVD Example: Low-Rank Approximation
Matrix shape: (4, 6)   (rank Γëê 3)
------------------------------------------------
Index        Singular value   Energy %
------------------------------------------------
  σ1               5.545273      71.68%
  σ2               3.156083      23.22%
  σ3               1.472810       5.06%
  σ4               0.144370       0.05%
------------------------------------------------
  Rank-1 approx  Frobenius relative error: 0.532195
  Rank-2 approx  Frobenius relative error: 0.225938
  Rank-3 approx  Frobenius relative error: 0.022042

Plot saved: test_output\example_imsl_svd.svg