
Topics in Digital Heritage:
Numerical Methods for Digital Reconstruction
Fall 2026
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)\).




Today: solve \(\mathbf{A}\mathbf{x}=\mathbf{b}\) by turning it into a minimization problem and attacking it iteratively, with no factorization required.
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:
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.
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}^*\).
\[\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}\]

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

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:

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\).


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.
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.

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})}. \]
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
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.
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}} \]
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}}\]
matvec)matvec to be well-definedBy construction, conjugate gradients:
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 \]


Dashed (GD) degrades sharply as \(\kappa\) grows; solid (CG) always converges by iteration \(n=60\), regardless of \(\kappa\), exactly as guaranteed.
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})\).
What is a good preconditioner?
Finding a good \(\mathbf{P}\) is “as much an art as a science”: it depends on where \(\mathbf{A}\) comes from.
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.
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.
matvec(v) today.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.
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.
Implement gradient descent, conjugate gradients, Jacobi-preconditioned CG, and the matrix-free Poisson solve yourself:
Open iterative_solvers_demo.ipynb
np.linalg.solve.
Class 6: Iterative Linear Solvers