import numpy as np
import matplotlib.pyplot as plt
try:
import executable_engineering as exe
except ImportError:
%pip install -q executable_engineering
import executable_engineering as exeThe Intuition¶
Stationary methods are a class of iterative solvers that repeatedly apply the exact same mathematical operation at every iteration.
What does it mean for a vector to satisfy ? It means that all the elements in are perfectly consistent with each other. If we guess a solution where one element is inconsistent, we can algebraically solve for it based on the other elements!
The Algorithm:
Start with a random guess for .
For each iterative step, identify a single element in and force it to be consistent with the others.
Check the magnitude of the residual (how well the new guess solves the system).
Repeat until the residual falls below a specified tolerance.
Jacobi Iteration¶
Let’s formalize this by writing out the linear system in index form:
If we extract the -th row, we can isolate and solve for the diagonal term :
This equation explicitly updates to be consistent with all the other elements from the previous iteration .
Matrix Formulation¶
Since we proceed row-by-row, we can vectorize this operation by pulling apart the coefficient matrix into its diagonal , strictly lower triangular , and strictly upper triangular components:
NB: This is not a matrix decomposition; we are literally just pulling it apart (blank elements are zeros):
The algorithm becomes:
The Jacobi algorithm elegantly becomes:
(Note: Because the new vector requires the entire previous vector , we must store both in memory. However, because each element update is independent of the others in the same step, Jacobi iteration is incredibly easy to parallelize!)
Jacobi iterations are guaranteed to converge if the matrix is diagonally dominant.
A = np.array([[4, 1], [1, 3]])
b = np.array([5, 6])
x = np.array([0., 0.])
iterations = [x.copy()]
tol = 1e-4
print('The true answer is:', np.linalg.solve(A, b))
print(f'Initial Guess: {x} | Residual: {np.linalg.norm(A@x-b):.4f}\n')
iteration_count = 0
while np.linalg.norm(A@x-b) > tol and iteration_count < 100:
x = exe.jacobi_step(A, b, x)
iterations.append(x.copy())
iteration_count += 1
print(f'Iteration {iteration_count:2d}: {x} | Residual: {np.linalg.norm(A@x-b):.4f}')
fig = exe.visualize_convergence_2d(A, b, iterations, surface_type='residual')
fig.show()Gauss-Seidel¶
Gauss-Seidel operates on the exact same premise as Jacobi, but with one critical optimization: as soon as we calculate a new element , we immediately use it to calculate the next elements in the same iteration!
If we move downwards through the rows, the -th element is updated using the already-updated rows above it, and the old rows below it:
Matrix Formulation¶
Written in matrix form, we simply move the lower triangular matrix to the left side since those elements are already updated:
Because we are modifying in-place as we go, we only need to store a single vector in memory!
Gauss-Seidel is guaranteed to converge if the matrix is diagonally dominant or symmetric positive definite.
(Note: While Gauss-Seidel is vastly more memory-efficient and generally converges faster than Jacobi, the in-place updates mean each calculation depends on the previous one, making it extremely difficult to parallelize.)
A = np.array([[4, 1], [1, 3]])
b = np.array([5, 6])
tol = 1e-4
print('The true answer is:', np.linalg.solve(A, b))
# We can now use our generalized wrapper function
x_final, iterations = exe.iter_solve(A, b, exe.gauss_seidel_step, tol=tol, track_history=True)
fig = exe.visualize_convergence_2d(A, b, iterations, surface_type='residual')
fig.show() Successive Over Relaxation (SOR)¶
The convergence of Gauss-Seidel can be accelerated through relaxation. Instead of simply accepting the new value, we take a weighted average of the newly calculated value and the old value using a relaxation parameter :
If : The method is exactly Gauss-Seidel.
If : The method is under-relaxed (converges more smoothly but slower).
If : The method is over-relaxed (convergence is artificially accelerated).
Choosing the Relaxation Parameter¶
SOR with a well-chosen will drastically outperform Gauss-Seidel. However, finding the optimal dynamically is non-trivial.
One heuristic approach involves monitoring the difference between successive iterations . After iterations, you can estimate the optimal relaxation factor as:
A = np.array([[4, 1], [1, 3]])
b = np.array([5, 6])
tol = 1e-4
print('The true answer is:', np.linalg.solve(A, b))
# We can now use our generalized wrapper function
x_final, iterations = exe.iter_solve(A, b, exe.sor_step, tol=tol, track_history=True)
fig = exe.visualize_convergence_2d(A, b, iterations, surface_type='residual')
fig.show() Performance Benchmarking¶
How do these stationary methods stack up against a highly optimized direct solver like scipy.linalg.solve?
Let’s generate large diagonally-dominant matrices and time the execution of our iterative loops versus the direct solver. Try changing the ‘n’ field in the code below!
n = 4 # Size of the matrix
A = exe.create_diagonally_dominant_matrix(n)
b = np.random.rand(n) # Create a random right-hand side vector
print('LU')
%timeit -n1 -r1 np.linalg.solve(A, b)
print('\nJacobi')
%timeit -n1 -r1 exe.iter_solve(A, b, exe.jacobi_step)
print('\nGauss-Seidel')
%timeit -n1 -r1 exe.iter_solve(A, b, exe.gauss_seidel_step)
print('\nSOR')
%timeit -n1 -r1 exe.iter_solve(A, b, exe.sor_step)