codex/inverse-problem-blur-and-regularization.py

inverse-problem-blur-and-regularization.py
"""Write inverse-problem-blur-and-regularization.png into cwd.

Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Run from _media; only NumPy, Matplotlib and the standard library are used.
"""

from pathlib import Path

import matplotlib

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


def main():
    a = np.array([[0.55, 0.45], [0.45, 0.55]])
    truth = np.array([1.0, 0.0])
    clean = a @ truth
    noisy = clean + np.array([0.01, -0.01])
    alpha = 0.01
    direct = np.linalg.solve(a, noisy)
    regularized = np.linalg.solve(a.T @ a + alpha * np.eye(2), a.T @ noisy)
    np.testing.assert_allclose(clean, [0.55, 0.45])
    np.testing.assert_allclose(direct, [1.1, -0.1])
    np.testing.assert_allclose(regularized, [803 / 1010, 197 / 1010])
    np.testing.assert_allclose(np.linalg.cond(a), 10)

    plt.rcParams.update({"font.size": 11, "axes.titlesize": 13})
    fig, axes = plt.subplots(1, 2, figsize=(10.4, 4.9), facecolor="white")
    fig.subplots_adjust(left=0.075, right=0.975, bottom=0.23, top=0.76, wspace=0.32)
    fig.suptitle("Two pixels: inversion amplifies contrast noise", y=0.98, fontsize=16)
    fig.text(0.5, 0.875, "A = [[0.55, 0.45], [0.45, 0.55]]    clean data (0.55, 0.45) → noisy data (0.56, 0.44)", ha="center")
    colors = ["#1765a8", "#bf5b16"]

    ax = axes[0]
    estimates = np.stack([truth, direct, regularized])
    groups = np.arange(3)
    for j in range(2):
        xs = groups + (j - 0.5) * 0.32
        bars = ax.bar(xs, estimates[:, j], width=0.30, color=colors[j], label=f"Pixel {j + 1}")
        for bar, value in zip(bars, estimates[:, j]):
            ax.text(bar.get_x() + bar.get_width() / 2, value + (0.035 if value >= 0 else -0.055), f"{value:.3f}", ha="center", va="bottom" if value >= 0 else "top", fontsize=10)
    ax.axhline(0, color="#555555", lw=0.8)
    ax.set_xticks(groups, ["True image", "Direct inverse", "Tikhonov\nα = 0.01"])
    ax.set_ylim(-0.26, 1.35)
    ax.set_ylabel("Reconstructed pixel value")
    ax.set_title("Less noise amplification, more bias")
    ax.legend(loc="upper right", frameon=False, fontsize=10)
    ax.set_axisbelow(True)
    ax.grid(axis="y", color="#e3e7eb")

    ax = axes[1]
    sigma = np.geomspace(0.005, 2, 400)
    attenuation = sigma**2 / (sigma**2 + alpha)
    ax.semilogx(sigma, attenuation, color="#5c378c", lw=2.4)
    for s, text, offset in [(0.1, "Difference mode\nσ = 0.1: retain 50%", (12, -40)), (1.0, "Mean mode\nσ = 1: retain 99.01%", (-225, -10))]:
        phi = s**2 / (s**2 + alpha)
        ax.plot([s, s], [0, phi], color="#999999", ls="--", lw=1)
        ax.scatter(s, phi, s=55, color="#5c378c", zorder=3)
        ax.annotate(text, (s, phi), xytext=offset, textcoords="offset points", fontsize=10, arrowprops={"arrowstyle": "->", "color": "#555555"} if s == 1 else None)
    ax.set_ylim(0, 1.13)
    ax.set_xlim(sigma[0], sigma[-1])
    ax.set_xlabel("Singular value σ (log scale)")
    ax.set_ylabel("Fraction of exact solution retained")
    ax.set_title(r"Attenuation $\phi_\alpha(\sigma)=\sigma^2/(\sigma^2+\alpha)$")
    ax.grid(color="#e3e7eb")
    for ax in axes:
        ax.set_facecolor("white")
        ax.spines[["top", "right"]].set_visible(False)
    fig.text(0.5, 0.045, "Penalty convention: ‖Ax − y‖² + α‖x‖².    Here α = 0.01 equals λ² with λ = 0.1.", ha="center", fontsize=11)
    fig.savefig(Path.cwd() / "inverse-problem-blur-and-regularization.png", dpi=110, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()