codex/gram-schmidt-vector-steps.py

gram-schmidt-vector-steps.py
"""Show projection subtraction and unit directions; write a PNG to CWD.

Tested with Python 3.14.4, Matplotlib 3.10.7 and NumPy 2.3.5.
Run from _media to create gram-schmidt-vector-steps.png there.
"""

from pathlib import Path

import matplotlib

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


BLUE, RED, ORANGE, TEAL = "#2465a0", "#9c3042", "#c05a10", "#13796d"


def arrow(ax, end, color, start=(0, 0), dashed=False):
    ax.annotate("", xy=end, xytext=start, arrowprops={
        "arrowstyle": "->", "color": color, "lw": 2.6,
        "linestyle": "--" if dashed else "-", "shrinkA": 0,
        "shrinkB": 0, "mutation_scale": 16}, zorder=5)


def corner(ax, origin, direction_a, direction_b):
    scale = 0.11
    a, b = scale * direction_a, scale * direction_b
    points = np.array([origin + a, origin + a + b, origin + b])
    ax.plot(*points.T, color="#59636c", lw=1.2, zorder=6)


def main():
    v1, v2 = np.array([1., 1.]), np.array([1., 0.])
    q1 = v1 / np.linalg.norm(v1)
    projection = np.dot(v2, q1) * q1
    u2 = v2 - projection
    q2 = u2 / np.linalg.norm(u2)
    assert np.allclose(projection, [0.5, 0.5], atol=1e-14)
    assert np.allclose(u2, [0.5, -0.5], atol=1e-14)
    assert np.isclose(np.linalg.norm(u2), 1 / np.sqrt(2), atol=1e-14)
    assert np.allclose(np.column_stack([q1, q2]).T @
                       np.column_stack([q1, q2]), np.eye(2), atol=1e-14)
    dependent = np.array([2., 2.])
    assert np.allclose(dependent - np.dot(dependent, q1) * q1, 0, atol=1e-14)

    plt.rcParams.update({"font.size": 12, "figure.facecolor": "white"})
    fig, axes = plt.subplots(1, 3, figsize=(11, 4.7))
    fig.subplots_adjust(left=0.055, right=0.98, top=0.77, bottom=0.20, wspace=0.35)
    for ax in axes:
        ax.set_aspect("equal")
        ax.set_facecolor("white")
        ax.set_xlim(-0.25, 1.55)
        ax.set_ylim(-1.0, 1.5)
        ax.set_xticks([0, 0.5, 1])
        ax.set_yticks([-0.5, 0, 0.5, 1])
        ax.axhline(0, color="#b8c0c6", lw=0.8)
        ax.axvline(0, color="#b8c0c6", lw=0.8)
        ax.grid(alpha=0.12)
        ax.spines[["top", "right"]].set_visible(False)
        ax.tick_params(labelsize=10)
        ax.plot(0, 0, "o", color="#253b46", ms=4, zorder=7)

    first, second, third = axes
    first.set_title("1. Original vectors", fontsize=13, pad=12)
    arrow(first, v1, BLUE)
    arrow(first, v2, RED)
    first.text(0.73, 1.15, r"$v_1=(1,1)$", color=BLUE, fontsize=11)
    first.text(0.72, -0.23, r"$v_2=(1,0)$", color=RED, fontsize=11)

    second.set_title("2. Subtract the projection", fontsize=13, pad=12)
    second.plot([0, 1.22], [0, 1.22], color=BLUE, lw=1, alpha=0.3)
    arrow(second, v2, RED)
    arrow(second, projection, TEAL)
    arrow(second, u2, ORANGE)
    arrow(second, v2, ORANGE, start=projection, dashed=True)
    corner(second, projection, -q1, q2)
    second.text(0.03, 0.67, r"$p=(1/2,1/2)$", color=TEAL, fontsize=11)
    second.text(0.54, -0.63, r"$u_2=(1/2,-1/2)$", color=ORANGE, fontsize=11)
    second.text(1.06, 0.08, r"$v_2$", color=RED)
    second.text(0.57, 1.19, r"$v_2=p+u_2$", fontsize=12)

    third.set_title("3. Divide by each length", fontsize=13, pad=12)
    angle = np.linspace(0, 2 * np.pi, 300)
    third.plot(np.cos(angle), np.sin(angle), color="#7e8993", alpha=0.5, lw=1)
    arrow(third, q1, BLUE)
    arrow(third, q2, ORANGE)
    corner(third, np.zeros(2), q1, q2)
    third.text(0.67, 0.83, r"$q_1$", color=BLUE)
    third.text(0.80, -0.73, r"$q_2$", color=ORANGE)
    third.text(0.00, 1.19, r"$\|q_1\|=\|q_2\|=1$", fontsize=12)
    fig.suptitle("Gram–Schmidt: remove overlap, then set length to one", fontsize=17, y=0.96)
    fig.text(0.5, 0.08, "All panels use equal horizontal and vertical units. "
             "The dashed arrow is the residual translated to the projection tip.",
             ha="center", fontsize=11)
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=120,
                facecolor="white", transparent=False)
    plt.close(fig)
    print("Checked: p=(1/2,1/2); u2=(1/2,-1/2); Q.T Q=I; dependent input has zero remainder.")


if __name__ == "__main__":
    main()