codex/matrix-preconditioning-steepest-descent-energy-errors.py

matrix-preconditioning-steepest-descent-energy-errors.py
"""Original exact rational steepest-descent histories for a constructed system.

Tested with Python 3.14, NumPy 2.3.5 and Matplotlib 3.10.7.
Run from _media; the output basename matches this script's basename.
"""
from fractions import Fraction as F
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

A = ((F(1), F(1, 5)), (F(1, 5), F(100)))
solution = [F(2495, 2499), F(20, 2499)]


def dot(u, v):
    return sum(a * b for a, b in zip(u, v, strict=True))


def matvec(v):
    return [dot(row, v) for row in A]


def history(diagonal, steps):
    x, r = [F(0), F(0)], [F(1), F(1)]
    initial = dot(solution, matvec(solution)) / 2
    errors = [F(1)]
    for _ in range(steps):
        if all(value == 0 for value in r):
            break
        z = [a / d for a, d in zip(r, diagonal, strict=True)]
        az = matvec(z)
        alpha = dot(r, z) / dot(z, az)
        x = [a + alpha * b for a, b in zip(x, z, strict=True)]
        r = [a - alpha * b for a, b in zip(r, az, strict=True)]
        assert r == [F(1) - v for v in matvec(x)]
        error = [a - b for a, b in zip(x, solution, strict=True)]
        errors.append(dot(error, matvec(error)) / (2 * initial))
    return errors


plain = history([F(1), F(1)], 16)
scaled = history([F(1), F(100)], 8)
assert plain == [F(81675, 85007)**k for k in range(17)]
assert scaled == [F(3267, 8500700)**k for k in range(9)]
plt.rcParams.update({'font.size': 11, 'axes.titlesize': 12, 'figure.facecolor': 'white'})
fig, axes = plt.subplots(1, 2, figsize=(9.4, 4.7), layout='constrained')
fig.suptitle('Steepest descent: exact energy-error histories', fontsize=14)
for ax, cost in zip(axes, [False, True], strict=True):
    for values, charge, label, color, marker in [
        (plain, 1, r'Unpreconditioned ($M=I$)', '#b45a35', 'o'),
        (scaled, 2 if cost else 1, r'Jacobi scaling ($M=\mathrm{diag}(1,100)$)', '#245b85', 's'),
    ]:
        indices = np.arange(len(values))
        if not cost:
            indices = indices[:9]
            values = values[:9]
        ax.semilogy(charge * indices, [float(v) for v in values], marker=marker,
                    markersize=4, color=color, linewidth=2, label=label)
    ax.set(xlabel='Declared work units' if cost else 'Iterations',
           ylabel=r'Relative energy error $E_k/E_0$', ylim=(1e-28, 2),
           title='One vs two work units per step' if cost else 'Identical starting point: x = 0')
    ax.set_xticks(np.arange(0, 17 if cost else 9, 4 if cost else 2))
    ax.set_yticks([10.0**(-i) for i in range(0, 29, 4)])
    ax.grid(alpha=0.2, which='major')
axes[0].legend(loc='lower left', fontsize=9, framealpha=1)
fig.supxlabel('One unit for A times a vector; one for a diagonal solve. Setup and vector operations excluded.\nConstructed example and declared cost model; no runtime measurements.', fontsize=10)
fig.savefig(Path(__file__).stem + '.png', dpi=120, facecolor='white', transparent=False)
plt.close(fig)