Iterative Linear Solvers


Topics in Digital Heritage:
Numerical Methods for Digital Reconstruction

Jin Woo Lee

KAIST

Fall 2026

Recap

From Last Class: Newton’s Method

Recall Newton’s method for root-finding, \[x_{k+1} = x_k - \frac{f(x_k)}{f'(x_k)}\].

Its multivariate generalization for minimizing \(f(\mathbf{x})\) takes a step \[ \mathbf{x}_{k+1} = \mathbf{x}_k - \left[\nabla^2 f(\mathbf{x}_k)\right]^{-1} \nabla f(\mathbf{x}_k), \] i.e. it solves a linear system every single iteration: \[ \underbrace{\nabla^2 f(\mathbf{x}_k)}_{\mathbf{A}} \, \underbrace{(\mathbf{x}_{k+1} - \mathbf{x}_k)}_{\mathbf{v}} = -\underbrace{\nabla f(\mathbf{x}_k)}_{\mathbf{b}}. \]

Solving \(\mathbf{A}\mathbf{v} = \mathbf{b}\) can be done exactly, yet costing \(O(n^3)\).

  • Gaussian elimination, LU, Cholesky, … \(\to\) slow when \(n\) is large.

Why Look for Something Else?

  • Many \(\mathbf{A}\)’s that show up in practice (discretized images, meshes, grids, graphs) are large and sparse: mostly zeros.
  • Gaussian elimination tends to fill in those zeros as it proceeds, so we can lose the sparsity that made the problem tractable to store in the first place.
  • We often only need \(\mathbf{A}\) applied to a vector \(\mathbf{x}\), (i.e. \(\mathbf{A}\mathbf{x}\)), not \(\mathbf{A}\) itself in explicit matrix form.
  • Sometimes an approximate \(\mathbf{x}\) found in a handful of cheap steps is good enough, e.g. inside an outer loop like Newton’s method above.

Stanford Bunny (35,947 vertices)

Dragon (566,098 vertices)
  • 3D meshes with \(m>10^{4}\) vertices (\(n=3m\) number of unknowns) yield \(3m\times 3m\) sparse matrices \(\mathbf{A}\).
  • Directly solving \(\mathbf{A}\mathbf{x}=\mathbf{b}\) with cost of \(O((3m)^3)\) is prohibitive in such cases.
  • Saving \(\mathbf{A}\) in memory is wasteful, since we only need \(\mathbf{A}\mathbf{x}\) for some vector \(\mathbf{x}\).

Today: solve \(\mathbf{A}\mathbf{x}=\mathbf{b}\) by turning it into a minimization problem and attacking it iteratively, with no factorization required.

Today’s ILO

  • Explain why iterative solvers scale to large, sparse systems where direct factorization struggles.
  • Reformulate solving a symmetric positive-definite (SPD) system as an energy minimization problem, and derive/implement gradient descent.
  • Explain the intuition behind conjugate gradients (CG) and why it is guaranteed to converge in at most \(n\) steps.
  • Explain how the condition number governs convergence speed, and how preconditioning accelerates it.
  • Preview where these tools resurface: nonlinear optimization (next class) and the large sparse systems from discretized PDEs and wave propagation (later this semester).

Solving \(\mathbf{Ax=b}\) as Minimization

From Equations to Energy

Assume \(\mathbf{A}\in\mathbb{R}^{n\times n}\) is symmetric (\(\mathbf{A}^\top=\mathbf{A}\)) and positive definite (\(\mathbf{x}^\top\mathbf{A}\mathbf{x}>0\) for any \(\mathbf{x}\neq\mathbf{0}\)), i.e. SPD.

Key observation

Solutions of \(\mathbf{A}\mathbf{x}=\mathbf{b}\) are exactly the minimizers of the quadratic energy \[ f(\mathbf{x}) \equiv \frac{1}{2}\mathbf{x}^\top\mathbf{A}\mathbf{x} - \mathbf{b}^\top\mathbf{x} + c. \] Since \(\mathbf{A}\) is symmetric, \(\nabla f(\mathbf{x}) = \mathbf{A}\mathbf{x} - \mathbf{b}\), so \(\nabla f(\mathbf{x})=\mathbf{0} \iff \mathbf{A}\mathbf{x}=\mathbf{b}\).

This turns linear algebra into optimization:

  • Instead of factoring \(\mathbf{A}\), we roll downhill on the “bowl” \(f(\mathbf{x})\).

Remark. If \(\mathbf{A}\) isn’t SPD (e.g. rectangular, as in our earlier Linear Systems classes), we can fall back on the normal equations \(\mathbf{A}^\top\mathbf{A}\mathbf{x} = \mathbf{A}^\top\mathbf{b}\) (the same trick we used for least squares), though this can worsen conditioning.

Visualizing the Quadratic Energy

For \(n=2\), \(f(\mathbf{x})\) is a surface over the plane: a paraboloid bowl exactly when \(\mathbf{A}\) is SPD.

Why a bowl?

\[ f(\mathbf{x}) = f(\mathbf{x}^*) + \frac{1}{2}(\mathbf{x}-\mathbf{x}^*)^\top \mathbf{A} (\mathbf{x}-\mathbf{x}^*), \qquad \mathbf{x}^* = \mathbf{A}^{-1}\mathbf{b} \] Since \(\mathbf{A}\) is PD, the second term is strictly positive whenever \(\mathbf{x}\neq\mathbf{x}^*\), so \(f\) increases in every direction away from \(\mathbf{x}^*\).

Details

\[\begin{aligned} f(\mathbf{x}) &= \frac{1}{2}\mathbf{x}^\top\mathbf{A}\mathbf{x} - \mathbf{b}^\top\mathbf{x} + c \\ &= \frac{1}{2}\mathbf{x}^\top\mathbf{A}\mathbf{x} - (\mathbf{A}\mathbf{x}^*)^\top\mathbf{x} + c \\ &= \frac{1}{2}\mathbf{x}^\top\mathbf{A}\mathbf{x} - (\mathbf{x}^*)^\top\mathbf{A}\mathbf{x} + c \\ &= \frac{1}{2}\mathbf{x}^\top\mathbf{A}\mathbf{x} - (\mathbf{x}^*)^\top\mathbf{A}\mathbf{x} + \frac{1}{2}(\mathbf{x}^*)^\top\mathbf{A}\mathbf{x}^* - \frac{1}{2}(\mathbf{x}^*)^\top\mathbf{A}\mathbf{x}^* + c \\ &= \frac{1}{2}(\mathbf{x}-\mathbf{x}^*)^\top \mathbf{A} (\mathbf{x}-\mathbf{x}^*) + f(\mathbf{x}^*). \end{aligned}\]

\[\begin{aligned} \nabla f(\mathbf{x}^*) &= \mathbf{A}\mathbf{x}^* - \mathbf{b} = \mathbf{0} \\ \implies \mathbf{b} &= \mathbf{A}\mathbf{x}^*. \\ f(\mathbf{x}^*) &= \frac{1}{2}(\mathbf{x}^*)^\top\mathbf{A}\mathbf{x}^* - (\mathbf{x}^*)^\top\mathbf{b} + c \\ &= \frac{1}{2}(\mathbf{x}^*)^\top\mathbf{A}\mathbf{x}^* - (\mathbf{x}^*)^\top\mathbf{A}\mathbf{x}^* + c \\ &= -\frac{1}{2}(\mathbf{x}^*)^\top\mathbf{A}\mathbf{x}^* + c. \end{aligned}\]

  • The lowest point of this bowl is exactly \(\mathbf{x}^*\)
  • Minimizing \(f\) \(\iff\) solving the linear system

cf. A negative eigenvalue would give a saddle; a zero eigenvalue gives a flat trough.

Level Sets and the Shape of the Bowl

Looking at the bowl from directly above, its level sets (contours of equal \(f\) value) are ellipses. Their axes point along the eigenvectors of \(\mathbf{A}\), with lengths set by the eigenvalues:

  • Round bowl:
    • eigenvalues close together
    • easy to descend directly.
  • Stretched bowl:
    • eigenvalues far apart, i.e. large \(\text{cond}(\mathbf{A})\)
    • easy to overshoot across the short axis
    • slow to cross along the long one \(\to\) zig-zagging.

Gradient Descent

Steepest Descent on a Quadratic Bowl

Solving \(\mathbf{A}\mathbf{x}=\mathbf{b}\) is equivalent to minimizing the quadratic energy \(f(\mathbf{x})\).

At \(\mathbf{x}_{k-1}\), the residual \(\mathbf{r}_k \equiv \mathbf{b}-\mathbf{A}\mathbf{x}_{k-1} = -\nabla f(\mathbf{x}_{k-1})\) points downhill, the direction of steepest descent of the bowl \(f(\mathbf{x})\).

\[\mathbf{x}_k = \mathbf{x}_{k-1} + \alpha_k \mathbf{r}_k\]

For a quadratic \(f\), the best step size \(\alpha_k\) (minimizing \(f\) exactly along \(\mathbf{r}_k\)) has a closed form, so no one-dimensional line-search is needed: \[ \alpha_k = \frac{\mathbf{r}_k^\top \mathbf{r}_k}{\mathbf{r}_k^\top \mathbf{A} \mathbf{r}_k} \]

Since \(\mathbf{A}\) is positive definite, \(\alpha_k > 0\) always, so every step strictly decreases \(f\).

Watching Gradient Descent Descend

  • Each step turns exactly 90 degrees from the last (a property of exact line search on a quadratic).
  • \(f(\mathbf{x}_k)\) drops monotonically, but progress stalls once the path lines up with the bowl’s long axis.

Gradient Descent Algorithm



def gradient_descent(matvec, b, x0, tol=1e-8, max_iter=1000):
    x = x0.copy()
    r = b - matvec(x)                    # residual = search direction
    for k in range(max_iter):
        if norm(r) < tol * norm(b):      # stopping condition
            return x
        alpha = (r @ r) / (r @ matvec(r))  # closed-form step size
        x = x + alpha * r
        r = b - matvec(x)
    return x

\[\alpha_k = \frac{\mathbf{r}_k^\top \mathbf{r}_k}{\mathbf{r}_k^\top \mathbf{A} \mathbf{r}_k}\qquad \mathbf{x}_k = \mathbf{x}_{k-1} + \alpha_k \mathbf{r}_k\]

Notice: \(\mathbf{A}\) is only ever used through matvec(x) \(= \mathbf{A}\mathbf{x}\), never formed or factored explicitly. This is what lets it scale to huge sparse systems.

Convergence: It’s All About Conditioning

Linear convergence rate

Gradient descent converges unconditionally, but the rate depends only on the condition number \(\kappa = \text{cond}(\mathbf{A})\): \[ \frac{f(\mathbf{x}_k)-f(\mathbf{x}^*)}{f(\mathbf{x}_{k-1})-f(\mathbf{x}^*)} \;\le\; 1-\frac{1}{\kappa}. \]

Intuition: a well-conditioned \(\mathbf{A}\) has a round bowl, so steepest descent points straight at the minimum. A poorly-conditioned \(\mathbf{A}\) has a narrow valley, so steepest descent zig-zags across it, making painfully slow progress along the valley floor.

Computing the Condition Number

For SPD \(\mathbf{A}\), eigenvalues are real and positive, so \[ \kappa(\mathbf{A}) = \text{cond}(\mathbf{A}) = \frac{\lambda_{\max}(\mathbf{A})}{\lambda_{\min}(\mathbf{A})}. \]

For a general \(\mathbf{A}\) (not symmetric, not even square), replace eigenvalues with singular values (cf. SVD): \[ \kappa(\mathbf{A}) = \frac{\sigma_{\max}(\mathbf{A})}{\sigma_{\min}(\mathbf{A})}. \]

A_ex = np.array([[3.0, 0.6], [0.6, 1.5]])   # SPD, same matrix as the bowl above
eigvals = np.linalg.eigvalsh(A_ex)
print(f"kappa via eigenvalues : {eigvals.max() / eigvals.min():.4f}")
print(f"kappa via np.linalg.cond (SVD): {np.linalg.cond(A_ex):.4f}")
kappa via eigenvalues : 2.4896
kappa via np.linalg.cond (SVD): 2.4896

Such a \(\kappa\) is called the condition number of \(\mathbf{A}\).

Taxonomy of problems by condition number

  • \(\kappa \to \infty\): ill-posed/singular (\(\det(\mathbf{A})=0\); i.e., \(\not\exists\mathbf{A}^{-1}\))
  • \(\kappa \gg 1\): ill-conditioned (\(10^5\) or more; solution uniquely exists but numerically unstable to find)
  • \(\kappa \approx 1\): well-conditioned (numerically stable)
  • \(\kappa = 1\): ideal/perfectly conditioned (e.g. \(\mathbf{A}=\mathbf{I}\))

Conjugate Gradients

Why Gradient Descent Wastes Work

Zig-zagging is a symptom of a deeper issue: gradient descent may search along a direction it has already (partially) explored, undoing part of its own progress.

Idea

What if we picked \(n\) search directions \(\mathbf{v}_1,\dots,\mathbf{v}_n\) that never “interfere” with each other, and did one exact line search along each? Then we’d reach \(\mathbf{x}^*\) in at most \(n\) steps, no revisiting required.

\(\mathbf{v}_1,\mathbf{v}_2\) are far from geometrically perpendicular, yet \(\mathbf{v}_1^\top\mathbf{A}\mathbf{v}_2=0\): they are “orthogonal” with respect to \(\mathbf{A}\)’s own geometry, exactly the ellipse’s own axes-like directions.

A-Conjugate Directions

Definition: A-conjugate vectors

Two vectors \(\mathbf{v},\mathbf{w}\) are A-conjugate if \(\mathbf{v}^\top \mathbf{A} \mathbf{w} = 0\).

Proposition: conjugate directions minimize f in n steps

If \(\{\mathbf{v}_1,\dots,\mathbf{v}_n\}\) are pairwise \(\mathbf{A}\)-conjugate, line-searching along \(\mathbf{v}_1\), then \(\mathbf{v}_2\), …, then \(\mathbf{v}_n\) minimizes \(f\) in exactly \(n\) steps.

The remarkable part, generating conjugate directions without an expensive Gram-Schmidt process: such directions can be generated on the fly, from the current residual and the previous direction only, with no need to store the whole history. \[ \mathbf{v}_k = \mathbf{r}_{k-1} + \beta_k \mathbf{v}_{k-1}, \qquad \beta_k = \frac{\mathbf{r}_{k-1}^\top\mathbf{r}_{k-1}}{\mathbf{r}_{k-2}^\top\mathbf{r}_{k-2}} \]

The Conjugate Gradient Algorithm

def conjugate_gradient(matvec, b, x0, tol=1e-8, max_iter=1000):
    x = x0.copy()
    r = b - matvec(x)
    v = r.copy()                          # first direction = residual
    for k in range(max_iter):
        if norm(r) < tol * norm(b):
            return x
        Av = matvec(v)
        alpha = (r @ r) / (v @ Av)         # line search along v
        x = x + alpha * v
        r_new = r - alpha * Av             # update residual
        beta = (r_new @ r_new) / (r @ r)   # A-conjugacy "for free"
        v, r = r_new + beta * v, r_new
    return x

\[\mathbf{v}_k = \mathbf{r}_{k-1} + \beta_k \mathbf{v}_{k-1}\qquad \beta_k = \frac{\mathbf{r}_{k-1}^\top\mathbf{r}_{k-1}}{\mathbf{r}_{k-2}^\top\mathbf{r}_{k-2}}\]

  • Same cost per step as gradient descent (one matvec)
  • guaranteed to converge in at most \(n\) steps, where
    • \(n\) = number of unknowns = length of \(\mathbf{x}\) (equiv. \(\mathbf{b}\), \(\mathbf{v}\), \(\mathbf{r}\))
    • \(\mathbf{A}\in\mathbb{R}^{n\times n}\), for matvec to be well-defined

Why Bother? (Guarantees)

By construction, conjugate gradients:

  • decreases \(f(\mathbf{x}_k)\) at least as fast as gradient descent, every iteration;
  • is guaranteed to reach \(\mathbf{x}^*\) exactly within \(n\) steps (in exact arithmetic);
  • at each step, \(\mathbf{x}_k\) is the optimal point reachable within the subspace spanned by the search directions so far, with no wasted work.

Practical number of iterations needed for a target accuracy on computers

Arithmetically, CG converges in at most \(n\) steps. But in practice, it often reaches a given accuracy in \(\mathcal{O}(\sqrt{\kappa})\) iterations, versus \(\mathcal{O}(\kappa)\) for gradient descent.

\[\text{CG:}\quad \frac{\|\mathbf{x}_k - \mathbf{x}^*\|_{\mathbf{A}}}{\|\mathbf{x}_0 - \mathbf{x}^*\|_{\mathbf{A}}} \;\le\; 2\left(\frac{\sqrt{\kappa}-1}{\sqrt{\kappa}+1}\right)^k \]

  • Due to floating-point roundoff, \(\mathbf{A}\)-conjugacy is lost after many iterations, so CG may take more than \(n\) steps to converge in practice.
  • When \(n\) is large, the \(\mathcal{O}(\sqrt{\kappa})\) rule is a good practical estimate of how many iterations are needed to reach a given accuracy.
  • CG having \(\mathcal{O}(\sqrt{\kappa})\) compared to the GD with \(\mathcal{O}(\kappa)\) is a huge advantage when \(\mathbf{A}\) is ill-conditioned.
    • e.g., poorly-conditioned \(\kappa=10,000\) and \(n=100,000\): GD \(\mathcal{O}(10,000)\) \(\gg\) CG \(\mathcal{O}(100)\) iterations needed to reach a given accuracy.

Code Practice: Seeing It Converge

GD vs. CG

Scaling to Larger Systems

Dashed (GD) degrades sharply as \(\kappa\) grows; solid (CG) always converges by iteration \(n=60\), regardless of \(\kappa\), exactly as guaranteed.

Preconditioning

Rescaling the Problem

  • Both GD and CG converge unconditionally, but speed depends entirely on \(\text{cond}(\mathbf{A})\).
  • Idea: solve an equivalent but better-conditioned system.

Preconditioning

For invertible \(\mathbf{P}=\mathbf{Q}^{-1}\), solving \(\mathbf{Q}^{-1}\mathbf{A}\mathbf{x}=\mathbf{Q}^{-1}\mathbf{b}\) gives the same \(\mathbf{x}\) as \(\mathbf{A}\mathbf{x}=\mathbf{b}\).

If \(\mathbf{Q}\approx \mathbf{A}\), then \(\mathbf{Q}^{-1}\mathbf{A}\approx\mathbf{I}\), so \(\text{cond}(\mathbf{Q}^{-1}\mathbf{A}) \ll \text{cond}(\mathbf{A})\).

  • Symmetric positive-definite \(\mathbf{P}\) can be folded directly into the CG iteration.
  • This is called Preconditioned Conjugate Gradients, or PCG

What is a good preconditioner?

  • \(\mathbf{Q}\) should be a good approximation to \(\mathbf{A}\), so that \(\text{cond}(\mathbf{P}\mathbf{A})\approx1\).
  • Computing \(\mathbf{Q}^{-1}\mathbf{y}\) (solving \(\mathbf{Q}\mathbf{z}=\mathbf{y}\)) should be cheap.

Common Preconditioners

  • Jacobi (diagonal): \(\mathbf{P} = \text{diag}(1/a_{ii})\), cheap, and fixes bad scaling between rows.
  • Incomplete Cholesky: factor \(\mathbf{A}\approx\mathbf{L}_*\mathbf{L}_*^\top\) but only keep entries where \(\mathbf{A}\) is already nonzero.
  • Sparse approximate inverse: solve \(\min_{\mathbf{P}\in S}\|\mathbf{A}\mathbf{P}-\mathbf{I}\|_{\text{Fro}}\) over a sparse pattern \(S\).
  • Domain decomposition: split the problem’s graph into loosely-coupled pieces and solve each (near-)independently.

Finding a good \(\mathbf{P}\) is “as much an art as a science”: it depends on where \(\mathbf{A}\) comes from.

Demo: Jacobi Preconditioning

rng2 = np.random.default_rng(1)
n = 60
S = rng2.normal(size=(n, n)); S = S @ S.T + n * np.eye(n)
scales = 10.0 ** rng2.uniform(-2, 2, size=n)
D = np.diag(scales)
A = D @ S @ D                       # badly *scaled* SPD system
b = rng2.normal(size=n)
Adiag = np.diag(A)

def pcg(matvec, b, precond, tol=1e-10, max_iter=3000):
    x = np.zeros_like(b); r = b - matvec(x); z = precond(r); v = z.copy()
    r0n = np.linalg.norm(r); rz_old = r @ z; n_iter = 0
    for k in range(max_iter):
        if np.linalg.norm(r) < tol * r0n:
            break
        Av = matvec(v); alpha = rz_old / (v @ Av)
        x = x + alpha * v; r = r - alpha * Av
        z = precond(r); rz_new = r @ z
        v = z + (rz_new / rz_old) * v; rz_old = rz_new
        n_iter += 1
    return x, n_iter

matvec = lambda v: A @ v
_, n_plain = pcg(matvec, b, lambda r: r)                 # P = I, plain CG
_, n_jacobi = pcg(matvec, b, lambda r: r / Adiag)        # Jacobi preconditioner
print(f"cond(A)                 = {np.linalg.cond(A):.3e}")
print(f"plain CG iterations     = {n_plain}")
print(f"Jacobi-PCG iterations   = {n_jacobi}")
cond(A)                 = 8.929e+07
plain CG iterations     = 674
Jacobi-PCG iterations   = 27

Same matrix, same accuracy target: a one-line diagonal rescaling cuts the iteration count by >20x.

Beyond SPD: A Quick Tour

When \(\mathbf{A}\) Isn’t Symmetric Positive-Definite

Everything so far assumed SPD \(\mathbf{A}\).

Here is a quick catalog of extensions for other matrix types, brief pointers rather than derivations:

Method Applies when…
Jacobi / Gauss–Seidel / SOR splitting \(\mathbf{A}=\mathbf{M}-\mathbf{N}\), \(\mathbf{M}\) easy to invert
CGNR / CGNE any full-rank \(\mathbf{A}\) (via normal equations; can be slow)
MINRES / SYMMLQ symmetric but indefinite \(\mathbf{A}\)
GMRES / BiCGStab general square, invertible \(\mathbf{A}\)
LSQR least-squares systems, minimal assumptions

Rule of thumb: fewer assumptions on \(\mathbf{A}\) \(\Rightarrow\) more iterations needed to compensate.

Looking Ahead

Where You’ll See This Again

  • Next class (Specialized Optimization Methods): nonlinear conjugate gradient (Fletcher–Reeves, Polak–Ribière) reuses exactly this A-conjugate idea to minimize non-quadratic objectives.
  • ODEs: implicit time-stepping schemes solve a linear system at every time step, so an iterative solver keeps that affordable.
  • PDEs & Wave Propagation (later this semester): discretizing a Laplacian on a grid or mesh produces a huge, sparse, SPD system. You will almost never build that matrix explicitly; you’ll apply it via a stencil, exactly like matvec(v) today.

Sneak Peek: A Discrete Poisson Equation

Poisson equation \(\nabla^2 u = f\) on a 60x60 grid, with 4 point sources/sinks. We frame this in a matrix-vector form \(\mathbf{A}\mathbf{u} = \mathbf{f}\) with \(\mathbf{A}\) never built explicitly; and apply CG to solve it.

Code
def laplacian_matvec(u_flat, N):
    u = u_flat.reshape(N, N)
    lap = -4.0 * u
    lap[1:, :] += u[:-1, :]; lap[:-1, :] += u[1:, :]
    lap[:, 1:] += u[:, :-1]; lap[:, :-1] += u[:, 1:]
    return -lap.ravel()          # matrix is never built; this is just a 5-point stencil

N = 60
b_grid = np.zeros((N, N))
b_grid[N // 4, N // 4] = 6.0;         b_grid[3 * N // 4, 3 * N // 4] = -6.0
b_grid[N // 4, 3 * N // 4] = 4.0;     b_grid[3 * N // 4, N // 4] = -4.0
b_flat = b_grid.ravel()
matvec = lambda u: laplacian_matvec(u, N)
x_sol = conjugate_gradient(matvec, b_flat, np.zeros(N * N), tol=1e-8, max_iter=N * N)
res = [np.linalg.norm(b_flat - matvec(x)) for x in x_sol[::max(1, len(x_sol)//200)]]

fig, axes = plt.subplots(1, 2, figsize=(8, 3))
im = axes[0].imshow(x_sol[-1].reshape(N, N), cmap="RdBu_r",
                     vmin=-np.abs(x_sol[-1]).max(), vmax=np.abs(x_sol[-1]).max())
axes[0].set_title("Solved field $u$\n(4 point sources/sinks)"); axes[0].axis("off")
plt.colorbar(im, ax=axes[0], fraction=0.046)
axes[1].semilogy(np.array(res) / res[0], color="steelblue")
axes[1].set_xlabel("CG iteration (subsampled)"); axes[1].set_ylabel("relative residual")
axes[1].set_title(f"Same CG code, n={N*N} unknowns\nno matrix ever built")
plt.tight_layout()
plt.show()

This is literally later-this-semester’s territory (Poisson/heat-diffusion equations), solved today with the same 10-line CG loop, applied through a stencil instead of a matrix.

Code Practices

Practice!

Implement gradient descent, conjugate gradients, Jacobi-preconditioned CG, and the matrix-free Poisson solve yourself:

Open iterative_solvers_demo.ipynb

  • Part 1 and 2: gradient descent & CG from scratch, verified against np.linalg.solve.
  • Part 3: Jacobi-preconditioned CG on a badly-scaled system.
  • Part 4: matrix-free CG for a discretized Poisson equation, a preview of the PDE chapters.

Recap

Recap on Today’s ILO

  • Explain why iterative solvers scale to large, sparse systems where direct factorization struggles.
    • Direct solvers cost \(O(n^3)\) and can destroy sparsity via fill-in; iterative solvers need only \(\mathbf{A}\mathbf{v}\) products, which stay cheap and sparse.
  • Reformulate solving an SPD system as energy minimization, and derive/implement gradient descent.
    • \(f(\mathbf{x})=\frac12\mathbf{x}^\top\mathbf{A}\mathbf{x}-\mathbf{b}^\top\mathbf{x}+c\) is minimized exactly at \(\mathbf{A}\mathbf{x}=\mathbf{b}\); steepest descent with an optimal closed-form step size converges unconditionally, but can zig-zag badly.
  • Explain the intuition behind conjugate gradients and its \(\le n\)-step guarantee.
    • Searching along \(\mathbf{A}\)-conjugate directions never “undoes” earlier progress; the directions are generated cheaply from the current residual and previous direction alone.
  • Explain how conditioning governs speed, and how preconditioning accelerates it.
    • Convergence of both methods depends on \(\text{cond}(\mathbf{A})\); a good preconditioner \(\mathbf{P}\approx\mathbf{A}^{-1}\) (even something as simple as Jacobi) can cut iterations by orders of magnitude.
  • Preview: these exact tools return for nonlinear optimization (next class) and for the sparse systems generated by PDEs and wave propagation (later this semester).