![]() | Text provided under a Creative Commons Attribution license, CC-BY. All code is made available under the FSF-approved MIT license. (c) Kyle T. Mandli |
Note: This material largely follows the text “Numerical Linear Algebra” by Trefethen and Bau (SIAM, 1997) and is meant as a guide and supplement to the material presented there.
import matplotlib.pyplot as plt
import numpy
%matplotlib inlineGaussian Elimination¶
Gaussian elimination is the process where one transforms a matrix or linear system through a series of operations into one that is at the very least upper triangular (it is often also presented as including the operation that transforms completely to diagonal). These series of operations can be written as a sequence of successive matrix-matrix multiplications by lower triangular matrices. Letting these “elimination matrices” be and the resulting upper triangular matrix be we can write this as
Labeling the successive operations as allows us to move to the other side of the equation and we see that in fact what we have done is computed another factorization of the matrix called the factorization.
The first step is to remove the values in the first column below the diagonal, to do this we can multiply by the matrix
To construct , we first define the element as the “Pivot” and the component of as the negative of the “multiplier”
which is the number, that, when multiplied by the pivot and subtracted from the element puts a zero in the position. Put another way each row of subtracts some multiple of the first row from each row (except the first which remains the same) that put’s a zero below the diagonal and changes all the other elements.
Once the first column is zero’d out below the diagonal, we can repeat the process for the smaller matrix starting at the element
The next step is to remove the values below the diagonal of the second column. This can be done with
We can actually easily invert (but don’t really need to as you will find that is just a lower triangular matrix with 1 on the diagonal and the lower triangular elements are just the multipliers in their respective positions i.e.
in the and finally can write as
Put another way, the matrix just shows how the rows of can be constructed from the rows of
Solving ¶
Given our factorization it is now straightforward to solve the linear system in just a few steps
Substitute in our original equation to get
Define and solve using forward substitution.
Solve using backward substitution.
Forward Substitution¶
For forward substitution we proceed from the first row and progress downwards through the matrix. We can then consider the general th row with
noting that we are using the fact that the matrix has 1 on its diagonal. We can now solve for as
Backward Substitution¶
Backwards substitution requires us to move from the last row of and move upwards. We can consider again the general th row with
We can now solve for as
Unfortunately, in most cases this will actually be unstable and can fail on even well-conditioned matrices
Pivoting¶
As you may recall from your linear algebra course pivoting of the rows and columns of is often an important addition to Gaussian elimination to ensure that we can in fact factorize the matrix. As a simple example take the matrix
Without switching the rows, Gaussian elimination would fail at the first step!
Just consider the solution by Gaussian elimination of the two identical problems
on a finite precision machine where .
In principle any row and column can be pivoted so that at each step we have the maximum value being used (on the diagonal) to perform the operations that compose the matrices . In practice however we restrict ourselves to only pivoting rows of the matrix (called partial pivoting).
The basic algorithm just adds an extra step of finding the largest element in any column in rows , and swapping row for row . Row swaps can also be accomplished by multiplying by a permutation matrix which just reorders the rows of the identity matrix
Consider again the example from above and switch the 1st and 3rd rows using the criteria that we always want to use the largest value to do perform the reduction. Defining the first permutation matrix as
Now defining the first as
Again examining the remaining values in column 2 the maximum value lies in row 4 so we want to interchange this with the second row (note that we do not want to move the first row as that will bring non-zero values into the first column below the diagonal).
Finally
LU Factorization with Partial Pivoting¶
Due to the nature of the pivot matrices we can disentangle them from the matrices . Right now we have
where what we want is
It turns out we can easily do this by
(Moreover, because Permutation matrices are unitary matrices, then so no inverses need be computed) These new matrices can easily be computed and it turns out that the matrices have the same structure with the rows permuted accordingly.
In general then the factorization with partial pivoting of the above example can be written as
Some code for the LU with partial pivoting (translated from the matlab, courtesy of Cleve Moler)¶
Here is an implementation of an “in-place” algorithm transforming a matrix in place from to a matrix where the diagonal and upper elements are replaced with and the strictly lower diagonal part of A is replaced with the multipliers in . This routine also returns a permuted index vector such that
A little hand-drawn animation of this algorithm, may go a long way to understanding it.
def lutx(A):
"""LUTX Triangular factorization, textbook version
translated to python from the original Matlab from Cleve Moler
L,U,p = lutx(A) produces a unit lower triangular matrix L,
an upper triangular matrix U, and a permutation vector p,
so that L*U = A(p,:)
Parameters:
-----------
A: ndarray
square numpy array
Returns:
--------
L: ndarray NxN
Lower triangular matrix with 1's on the diagonal and multipliers in place
U: ndarray NxN
Upper triangular matrix with Pivots on the diagonal
p: ndarray size(N)
permutation array
"""
n, m = A.shape
assert n == m
# make a deep copy of the original matrix into U
U = A.copy()
p = numpy.array(range(n))
for k in range(n - 1):
# Find index of largest element below diagonal in k-th column
m = numpy.argmax(numpy.abs(U[k:, k])) + k
# Skip elimination if column is zero
if U[m, k] != 0.0:
# Swap pivot row
if m != k:
U[[k, m], :] = U[[m, k], :]
p[[k, m]] = p[[m, k]]
# Compute multipliers in place
U[k + 1 : n, k] /= U[k, k]
# Update the remainder of the matrix
U[k + 1 : n, k + 1 : n] -= numpy.outer(U[k + 1 : n, k], U[k, k + 1 : n])
# Separate result and return
L = numpy.tril(U, -1) + numpy.eye(n)
U = numpy.triu(U)
return L, U, pLet’s apply this to our example problem¶
A = numpy.array(
[
[2.0, 1.0, 1.0, 0.0],
[4.0, 3.0, 3.0, 1.0],
[8.0, 7.0, 9.0, 5.0],
[6.0, 7.0, 9.0, 8.0],
]
)L, U, p = lutx(A)
print("L=\n{}".format(L))
print("U=\n{}".format(U))
print("p={}".format(p))import scipy.linalg as la
P, LL, UU = la.lu(A)
print("L=\n{}".format(LL))
print("U=\n{}".format(UU))
print("P={}".format(P.T))print("A=\n{}\n".format(A))
print("p = {}".format(p))print("PA=\n{}".format(A[p, :]))print("LU=\n{}".format(L.dot(U)))And let’s use this to solve ¶
Given our factorization it is now straightforward to solve the linear system in just a few steps
Multiply both sides by to yield and substitute to get
Define and solve using forward substitution.
Solve using backward substitution.
Here we’ll just use numpy.linalg.solve to solve the individual triangular systems but we could use more efficient forward and backward substitution codes.
x = numpy.random.rand(4)
b = A.dot(x)
L, U, p = lutx(A)
c = numpy.linalg.solve(L, b[p])
xp = numpy.linalg.solve(U, c)
print(x)
print(xp)
numpy.testing.assert_allclose(x, xp)
print("success")x = numpy.linalg.solve(A, b)
print(x)computational Costs¶
Gaussian elimination with partial pivoting is an extremely robust algorithm even for ill-conditioned matrices, however it is not cheap. With a little work, it is easy to show that for a dense matrix, the decomposition takes operations, while the two forward and back substitutions take in total operations.
For the large (but sparse) matrices that often arise from numerical PDE’s, there are a host of other iterative methods that, under certain circumstances can solve linear systems in many fewer flops. But that is another story.
LU vs the matrix inverse ¶
Formally, one can solve using the or as
which will formally have operations (which is about 3 times slower than the LU). However, in general, one rarely needs to calculate and for many matrices arising from 2-pt BVP’s it is a particulary bad idea as even if is sparse (e.g. tridiagonal), the inverse can often be dense. The LU decomposition however, will preserve the sparsity structure for banded matrices.
We will demonstrate this here using the simplest 2-pt boundary value problem (the 1-D Poisson problem)
or in its discrete form
where is a tridiagonal finite difference matrix, is the discrete solution and is the discrete approximation to the RHS.
Reusing our sparse matrix code from the ODE BVP notebook
from scipy.sparse import identity, lil_matrix, spdiags
from scipy.sparse.linalg import inv, splu, spsolve
from fdcoeffV import fdcoeffV
def D2(x, bcs=["dirichlet", "dirichlet"]):
"""
Assemble a general sparse second-order finite-difference approximation to d/dx^2 on a possibly irregular mesh
First and last rows are set by string bcs
parameters:
-----------
x: numpy.array
mesh coordinates
bcs: list of strings for boundary conditions e.g [left_string, right_string] where
the strings can be either 'dirichlet' or 'neumann'
"""
N = len(x)
A = lil_matrix((N, N))
if bcs[0] == "dirichlet":
A[0, 0] = 1.0
elif bcs[0] == "neumann":
A[0, 0:3] = fdcoeffV(1, x[0], x[:3])
else:
raise ValueError("no known BC type for left boundary {}".format(bcs[0]))
if bcs[1] == "dirichlet":
A[-1, -1] = 1.0
elif bcs[1] == "neumann":
A[-1, -3:] = fdcoeffV(1, x[-1], x[-3:])
else:
raise ValueError("no known BC type for right boundary {}".format(bcs[1]))
for i in range(1, N - 1):
A[i, i - 1 : i + 2] = fdcoeffV(2, x[i], x[i - 1 : i + 2])
return A.tocsc()
def RHS(x, f, bvalues):
"""Set the rhs vector
parameters
----------
x: numpy.array
mesh coordinates
f: callable
rhs function for interior points called on f(x[1:-2])
bvalues: numpy.array (len 2)
values for boundary conditions (either dirichlet or neumann)
"""
N = len(x)
rhs = numpy.empty(N)
rhs[[0, N - 1]] = bvalues
rhs[1:-1] = f(x[1:-1])
return rhsWe’ll set up a small -point Finite Difference problem
N = 101
x = numpy.linspace(0, 1.0, N)
A = D2(x)
func = lambda x: x
f = RHS(x, func, [0.0, 0.0])
u = spsolve(A, f)fig = plt.figure(figsize=(16, 6))
axes = fig.add_subplot(1, 2, 1)
axes.spy(A)
axes.set_title("Sparsity of $A$")
axes = fig.add_subplot(1, 2, 2)
axes.plot(x, u, "bo-")
axes.grid()
axes.set_xlabel("$x$")
axes.set_ylabel("$u$")
axes.set_title("Solution $u$")
plt.show()Visualizing and ¶
scipy.sparse also includes routines for calculating the inverse and permuted decompositions of sparse matrices using the parallel sparse package SuperLU
B = splu(A, permc_spec="NATURAL")
Ainv = inv(A)fig = plt.figure(figsize=(18, 6))
axes = fig.add_subplot(1, 3, 1)
axes.spy(Ainv)
axes.set_title("Sparsity of $A^{{-1}}$, nnz={}".format(Ainv.nnz))
axes = fig.add_subplot(1, 3, 2)
axes.spy(B.L)
axes.set_title("Sparsity of $B.L$, nnz={}".format(B.L.nnz))
axes = fig.add_subplot(1, 3, 3)
axes.spy(B.U)
axes.set_title("Sparsity of $B.U$, nnz={}".format(B.U.nnz))
plt.show()Check¶
And don’t forget to check that we get the same answer...
err = (Ainv.dot(f) - B.solve(f)) / numpy.finfo("float").eps
# print(err)fig = plt.figure(figsize=(18, 6))
axes = fig.add_subplot(1, 1, 1)
axes.plot(range(len(err)), err, "o-")
axes.grid()
axes.set_xlabel("index")
axes.set_ylabel("err$/\epsilon_{mach}$")
axes.set_title("Error")
plt.show()Timing¶
%timeit inv(A)
%timeit Ainv.dot(f)%timeit B = splu(A)
%timeit B.solve(f)