Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Systems of linear equations - continued

Faculty of Mechanical Engineering, University of Ljubljana

LU decomposition

To solve a system of linear equations, Gaussian elimination requires the fewest arithmetic operations.

When the coefficient matrix A\mathbf{A} does not change and only the constant vector b\mathbf{b} changes, we can avoid repeating Gaussian elimination on the coefficient matrix. By decomposing the matrix A\mathbf{A} we can reach the solution with fewer arithmetic operations. To this end we will look at the LU decomposition!

Any matrix can be written as a product of two matrices:

A=B C.\mathbf{A}=\mathbf{B}\,\mathbf{C}.

Here, there are infinitely many ways to write the matrices B\mathbf{B} and C\mathbf{C}.

In the LU decomposition we require that the matrix B\mathbf{B} is lower triangular and the matrix C\mathbf{C} is upper triangular:

A=L U.\mathbf{A}=\mathbf{L}\,\mathbf{U}.

Each of the matrices L\mathbf{L} and U\mathbf{U} has (n+1) n/2(n+1)\,n/2 nonzero elements; together that is n2+nn^2+n unknowns. The known matrix A\mathbf{A} defines n2n^2 values. So to uniquely determine the matrices L\mathbf{L} and U\mathbf{U} we are missing nn equations. Here we will use the LU decomposition, which obtains the additional equations through the condition Lii=1L_{ii}=1, i=0,1,…,n−1i=0, 1,\dots,n-1.

The system of linear equations:

Ax=b\mathbf{A}\mathbf{x}=\mathbf{b}

is therefore written using the decomposition of the matrix A\mathbf{A}:

L U x⏟y=b.\mathbf{L}\,\underbrace{\mathbf{U}\,\mathbf{x}}_{\mathbf{y}}=\mathbf{b}.

We now reach the solution of the system Ax=b\mathbf{A}\mathbf{x}=\mathbf{b} by solving two triangular systems of equations.

First we compute the vector y\mathbf{y}:

L y=b.(forward substitution)\mathbf{L}\,\mathbf{y}=\mathbf{b}.\qquad \textrm{(forward substitution)}

Once y\mathbf{y} is computed, we can determine x\mathbf{x} from:

U x=y(back substitution)\mathbf{U}\,\mathbf{x}=\mathbf{y}\qquad \textrm{(back substitution)}

.

LU decomposition of the coefficient matrix A\mathbf{A}

In what follows we will show that Gaussian elimination actually represents an LU decomposition of the coefficient matrix A\mathbf{A}. We will rely on symbolic computation, so let’s import the sympy package:

Let’s begin the demonstration with an example of symbolically written matrices L\mathbf{L} and U\mathbf{U} of dimension 3×33\times 3:

The coefficient matrix A\mathbf{A} written with the elements of the matrices L\mathbf{L} and U\mathbf{U} is therefore:

Loading...

Let’s now perform Gaussian elimination on the coefficient matrix A\mathbf{A}.

Using the first row, we perform Gaussian elimination in the first column:

Loading...

We continue in the second column:

Loading...

From the elimination above we observe:

  1. the matrix U\mathbf{U} equals the matrix we obtain if we perform Gaussian elimination on the coefficient matrix A\mathbf{A}.

  • the off-diagonal terms of L\mathbf{L} are the factors we used in Gaussian elimination.

Numerical implementation of the LU decomposition

We will look at the numerical implementation on the system defined as:

Let’s perform Gaussian elimination and store the coefficients by which we multiply the pivot row m into the matrix L at the position with the indices that the eliminated element has in the matrix A\mathbf{A}.

Step: 0
[[ 8.    -6.     3.   ]
 [ 0.     1.5   -3.75 ]
 [ 0.    -3.75   4.875]]
Step: 1
[[ 8.   -6.    3.  ]
 [ 0.    1.5  -3.75]
 [ 0.    0.   -4.5 ]]
array([[ 0. , 0. , 0. ], [-0.75 , 0. , 0. ], [ 0.375, -2.5 , 0. ]])

Let’s fill in the diagonal of L\mathbf{L}:

array([[ 1. , 0. , 0. ], [-0.75 , 1. , 0. ], [ 0.375, -2.5 , 1. ]])

Now let’s solve the lower triangular system of equations L y=b\mathbf{L}\,\mathbf{y}=\mathbf{b}:

array([-14. , 25.5, 75. ])

We continue by solving the upper triangular system U x=y\mathbf{U}\,\mathbf{x}=\mathbf{y}:

array([[ 8. , -6. , 3. ], [ 0. , 1.5 , -3.75], [ 0. , 0. , -4.5 ]])
array([-14. , 25.5, 75. ])
array([-14. , -24.66666667, -16.66666667])

As we noted above, when the constant vector b\mathbf{b} changes, repeating Gaussian elimination is not necessary. We only need to perform forward and then back substitution. Let’s look at an example:

array([-4.33333333, -7.88888889, -4.55555556])
array([-1., 6., 7.])

Pivoting

Let’s look at the following system of equations:

If we were to perform Gaussian elimination in the first column of the matrix A\mathbf{A}:

/tmp/ipykernel_342481/3999418912.py:1: RuntimeWarning: divide by zero encountered in scalar divide
  A[1,:] - A[1,0]/A[0,0] * A[0,:]
/tmp/ipykernel_342481/3999418912.py:1: RuntimeWarning: invalid value encountered in multiply
  A[1,:] - A[1,0]/A[0,0] * A[0,:]
array([ nan, -inf, inf])

We notice that we have a problem with division by 0 in the first row. The elementary operations we can perform on the system allow swapping arbitrary rows. We can rearrange the system so that the pivot element is not equal to 0. Even so, it can happen that the pivot element we divide by has a very small value ε\varepsilon. Because this would increase the round-off error, among all the rows we choose for the pivot row the one whose pivot has the largest absolute value.

If during Gaussian elimination we swap rows so that the pivot element is the largest, we call this row pivoting or also partial pivoting. This makes Gaussian elimination numerically stable.

It can be shown that when solving a system of equations A x=b\mathbf{A}\,\mathbf{x}=\mathbf{b} in which the matrix A\mathbf{A} is diagonally dominant, row pivoting is not necessary. Solving without pivoting is numerically stable.

A square matrix A\mathbf{A} of dimension nn is diagonally dominant if the absolute value of the diagonal element of each row is greater than the sum of the absolute values of the other elements in the row:

∣Aii∣>∑j=1,j≠in∣Aij∣\left|A_{ii}\right|> \sum_{j=1, j\ne i}^n \left|A_{ij}\right|

Gaussian elimination with partial pivoting

We will look at Gaussian elimination with partial pivoting. Without partial pivoting, in the ii-th elimination step we choose row ii as the pivot. With partial pivoting we first check whether the ii-th diagonal element is, in absolute value, the largest element in column ii on or below the diagonal; if not, we swap row ii with the row below it that has the largest element in absolute value in column ii. With partial pivoting we reduce the influence of the round-off error on the result.

If we were to perform full pivoting, in addition to swapping rows we would also use swapping the order of the variables (column swapping). Full pivoting improves stability, but it is rarely used and we will not address it here.

The algorithm for Gaussian elimination with partial pivoting is therefore:

array([[ 0., -6., 6.], [-6., 6., -6.], [ 8., -6., 3.]])
Step: 0
Pivot row: [  8.  -6.   3. -14.]
[[  8.    -6.     3.   -14.  ]
 [  0.     1.5   -3.75  25.5 ]
 [  0.    -6.     6.     6.  ]]
Step: 1
Pivot row: [ 0. -6.  6.  6.]
[[  8.    -6.     3.   -14.  ]
 [  0.    -6.     6.     6.  ]
 [  0.     0.    -2.25  27.  ]]

LU decomposition with partial pivoting

Similarly to Gaussian elimination, we can also extend the LU decomposition with partial pivoting. Solving thus becomes numerically stable. In doing so we must store the information about the row swaps, which we then pass to the function for solving the corresponding triangular systems.

Let’s look at an example:

Step: 0
Pivot row: [ 8. -6.  3.]
[[ 8.   -6.    3.  ]
 [-0.75  1.5  -3.75]
 [ 0.   -6.    6.  ]]
Step: 1
Pivot row: [ 0. -6.  6.]
[[ 8.   -6.    3.  ]
 [ 0.   -6.    6.  ]
 [-0.75 -0.25 -2.25]]

In the example above we used a compact way of storing the triangular matrices L\mathbf{L} and U\mathbf{U}; each is in fact defined by 6 elements, and for the matrix L\mathbf{L} we know that the diagonal elements equal 1. The matrix lu thus contains 3×3=93\times3=9 elements:

array([[ 8. , -6. , 3. ], [ 0. , -6. , 6. ], [-0.75, -0.25, -2.25]])

On the diagonal and above the diagonal are the values of the upper triangular matrix U\mathbf{U}, while below the diagonal are the subdiagonal elements of the matrix L\mathbf{L}. When computing the solution we will take into account that the diagonal values of L\mathbf{L} equal 1.

The numeric list piv tells us how the rows were swapped, which must be taken into account when computing the solution:

array([2, 0, 1])

Let’s now also determine the solution (the code for computing the solution is in the module tools.py)

array([ -3.66666667, -14.11111111, -16.44444444])

Checking the solution:

array([-14., 36., 6.])

The SciPy module

The SciPy module is built on the numpy module and contains many different high-level programs/modules/functions. The theoretical background of the modules is of course various numerical algorithms; we will get to know some of them within this textbook as well. A good source for these numerical algorithms in connection with SciPy is the documentation. For an excellent introduction to SciPy you can watch the YouTube video: SciPy Tutorial (2022): For Physicists, Engineers, and Mathematicians.

A brief overview of the module hierarchy:

In the following lectures we will look at some of the submodules in more detail.

Let’s look at how the LU decomposition is implemented within SciPy:

The function scipy.linalg.lu_factor (documentation):

lu_factor(a, overwrite_a=False, check_finite=True)

requires the input of a coefficient matrix (or a list of coefficient matrices) a; overwrite_a, if True, overwrites the value of a with the result of the decomposition (this can be important to save memory and increase speed). The function lu_factor returns a tuple (lu, piv):

  • lu - the L\mathbf{L} \ U\mathbf{U} matrix (a matrix of the same dimension as a, but below the diagonal it contains the elements of L\mathbf{L}, while the remaining elements define U\mathbf{U}; the diagonal elements of L\mathbf{L} have values 1.

  • piv - the pivot indices, representing a permutation matrix P: row i of the matrix a was swapped with row piv[i] (at each step the previous state is taken into account, slightly different logic than in our function LU_decomposition_pivoting).

lu_factor is used together with the function scipy.linalg.lu_solve (documentation), which gives us the solution of the system:

lu_solve(lu_and_piv, b, trans=0, overwrite_b=False, check_finite=True)

lu_and_piv is the tuple of the result (lu, piv) from lu_factor, b is the constant vector (or a list of vectors). The other parameters are optional.

Let’s look at how to use it:

array([[ 8. , -6. , 3. ], [ 0. , -6. , 6. ], [-0.75, -0.25, -2.25]])
array([2, 2, 2], dtype=int32)

Let’s obtain the solution:

array([ -3.66666667, -14.11111111, -16.44444444])

Let’s check the validity of the solution:

array([-14., 36., 6.])

Computing the inverse matrix

The inverse matrix of a square matrix A\textbf{A} of order n×nn\times n is denoted by A−1\textbf{A}^{-1}. It is a matrix of order n×nn\times n, such that

A A−1=A−1 A=I,\mathbf{A}\,\mathbf{A}^{-1} = \mathbf{A}^{-1}\,\mathbf{A} = \mathbf{I},

where I\mathbf{I} is the identity matrix.

The most efficient way to compute the inverse of a matrix A\mathbf{A} is to solve the matrix equation:

A X=I.\mathbf{A}\,\mathbf{X}=\mathbf{I}.

The matrix X\mathbf{X} is the inverse of the matrix A\mathbf{A}: A−1=X\mathbf{A}^{-1}=\mathbf{X}.

Computing the inverse matrix is therefore equal to solving nn systems of nn linear equations:

A xi=bi,i=0,1,2,…,n−1,\mathbf{A}\,\mathbf{x}_i=\mathbf{b}_i,\qquad i=0,1,2,\dots,n-1,

where bi\mathbf{b}_i is the ii-th column of the matrix B=I\mathbf{B}=\mathbf{I}.

Computational cost: we perform the LU decomposition on the matrix A\mathbf{A} (computational cost of order n3n^3) and then find the solution for each xi\mathbf{x}_i (2 n22\,n^2 arithmetic operations for each ii). The total computational cost is therefore still of order n3n^3. (If we were to perform Gaussian elimination nn times, the cost would be of order n4n^4.)

(array([[ 8. , -6. , 3. ], [ 0. , -6. , 6. ], [-0.75, -0.25, -2.25]]), array([2, 2, 2], dtype=int32))

Here the point of the LU decomposition now becomes apparent; we compute the decomposition only once and then, for each constant vector, find the solution by solving two triangular systems. If we were to solve the systems by Gaussian elimination, we would need (n3+n2) n(n^3+n^2)\,n arithmetic operations.

Let’s compute the inverse of the matrix A\textbf{A}. First we prepare the identity matrix using np.identity():

array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]])

Then we solve the system of equations for each column of the identity matrix:

The solution is therefore:

array([[-0.16666667, -0.16666667, 0. ], [-0.27777778, -0.44444444, -0.33333333], [-0.11111111, -0.44444444, -0.33333333]])

Let’s check the solution:

array([[ 1.00000000e+00, 0.00000000e+00, 1.11022302e-16], [ 2.22044605e-16, 1.00000000e+00, -1.11022302e-16], [-1.66533454e-16, 0.00000000e+00, 1.00000000e+00]])

Solution using NumPy:

array([[-0.16666667, -0.16666667, 0. ], [-0.27777778, -0.44444444, -0.33333333], [-0.11111111, -0.44444444, -0.33333333]])

With appropriate use of functions from the SciPy module we can reach the solution even faster. Into the function lu_solve we can insert a vector b\mathbf{b} or a matrix B\mathbf{B}, each column ii of which represents a new constant vector bi\mathbf{b}_i.

array([[-0.16666667, -0.16666667, 0. ], [-0.27777778, -0.44444444, -0.33333333], [-0.11111111, -0.44444444, -0.33333333]])

Solving overdetermined systems

Loading...

When we solve a system of mm linear equations with nn unknowns and m>nm>n holds and the rank of the augmented matrix [A ∣ b][\mathbf{A}\,|\,\mathbf{b}] equals n+1n+1, we have an overdetermined (inconsistent) system.

An overdetermined (also inconsistent system):

A x=b,\mathbf{A}\,\mathbf{x}=\mathbf{b},

has no solution. However, we can find the best approximate solution using the least-squares method.

The sum of squares of the residuals is defined by the dot product:

∥r∥2=(A x−b)T (A x−b)\left\lVert r\right\rVert ^2=(\mathbf{A}\,\mathbf{x}-\mathbf{b})^T\,(\mathbf{A}\,\mathbf{x}-\mathbf{b})

which we reshape into:

∥r∥2=xTATAx−2bTAx+bTb,\left\lVert r\right\rVert^2=\mathbf{x}^T\mathbf{A}^T\mathbf{A}\mathbf{x}-2\mathbf{b}^T\mathbf{A}\mathbf{x}+\mathbf{b}^T\mathbf{b},

where we have used the fact that, due to the scalar value, bTAx=(bT(Ax))T=(Ax)Tb\mathbf{b}^T\mathbf{A}\mathbf{x}=(\mathbf{b}^T(\mathbf{A}\mathbf{x}))^T=(\mathbf{A}\mathbf{x})^T\mathbf{b}.

The solution of the equation, the gradient of the sum of squares, determines its minimum:

∇x ∥r∥2=2ATA x−2AT b=0\nabla_x\,\left\lVert r\right\rVert^2=2\mathbf{A}^T\mathbf{A}\,\mathbf{x}-2\mathbf{A}^T\,\mathbf{b}=0

Thus, from the normal equation:

ATA x=AT b\mathbf{A}^T\mathbf{A}\,\mathbf{x}=\mathbf{A}^T\,\mathbf{b}

we determine the best approximate solution:

x=(ATA)−1AT b.\mathbf{x}=\left(\mathbf{A}^T\mathbf{A}\right)^{-1}\mathbf{A}^T\,\mathbf{b}.

By introducing the pseudo-inverse matrix:

A+=(ATA)−1AT\mathbf{A}^+=\left(\mathbf{A}^T\mathbf{A}\right)^{-1}\mathbf{A}^T

of the matrix A\mathbf{A}, the solution of the overdetermined system is written:

x=A+ b.\mathbf{x}=\mathbf{A}^+\,\mathbf{b}.

The procedure presented above is relatively simple, but it is numerically demanding and in some cases can be ill-conditioned; in practice it is recommended that the pseudo-inverse matrix be computed using the functions:

  • numpy.linalg.pinv from the numpy module (documentation),

  • The functions pinv or pinvh from the scipy.linalg module (the choice depends on the problem being addressed; see the documentation),

which are based on better numerical methods.

Example of a system with a unique solution:

array([1., 2.])

Now let’s make an overdetermined system (the function numpy.vstack (documentation) stacks lists by columns, numpy.random.seed (documentation) resets the random number generator to the seed value seed, while numpy.random.normal (documentation) generates a normally distributed list of length size and standard deviation scale):

array([[1.01764052, 2.00400157], [2.00978738, 3.02240893], [1.01867558, 1.99022722], [2.00950088, 2.99848643], [0.99896781, 2.00410599], [2.00144044, 3.01454274]])
array([5.00761038, 8.00121675, 5.00443863, 8.00333674, 5.01494079, 7.99794842])

Now let’s solve the overdetermined system:

array([0.94512271, 2.02675939])

We see that the overdetermined system with random values (simulation of measurement noise) gives a similar result to the solution without noise. If we were to increase the noise level, the deviation from the unique solution would increase.

We can also determine the pseudo-inverse matrix ourselves and check the difference with the built-in function:

array([[ 4.44089210e-16, 3.44169138e-15, -1.44328993e-15, 3.88578059e-15, -2.66453526e-15, 3.21964677e-15], [ 4.44089210e-16, -1.05471187e-15, 1.55431223e-15, -9.99200722e-16, 2.10942375e-15, -1.38777878e-15]])

Iterative methods

We often encounter large systems of linear equations whose coefficient matrix has few nonzero elements (such matrices are called sparse).

Iterative methods perform very well when solving such systems of linear equations; their advantages compared to direct methods are:

  • arithmetic operations are performed only on the nonzero elements (despite the iterative solving, there can be fewer of them)

  • the required memory space can be incomparably smaller.

Gauss-Seidel method

In what follows we will look at the idea of the Gauss-Seidel iterative method. First we write the system of equations A x=b\mathbf{A}\,\mathbf{x}=\mathbf{b} as:

∑j=0n−1Aijxj=bii=0,1,…,n−1.\sum_{j=0}^{n-1} A_{ij}x_j=b_i\qquad{}i=0,1,\dots,n-1.

Suppose we are in step k−1k-1 of the iterative method and the approximations xj(k−1)x_{j}^{(k-1)} (j=0,1,…,n−1j=0,1,\dots, n-1) are known. From the sum above we isolate the term ii:

Aii xi(k−1)+∑j=0,j≠in−1Aijxj(k−1)=bii=0,1,…,n−1.A_{ii}\,x_{i}^{(k-1)} +\sum_{j=0, j\ne i}^{n-1} A_{ij}x_{j}^{(k-1)}=b_i\qquad{}i=0,1,\dots,n-1.

Since the approximations xj(k−1)x_{j}^{(k-1)} do not exactly satisfy the linear problem, we can determine a new approximation xi(k)x_{i}^{(k)} from the equation above:

xi(k)=1Aii(bi−∑j=0i−1Aijxj(k)−∑j=i+1n−1Aijxj(k−1))x_{i}^{(k)} =\frac{1}{A_{ii}}\left(b_i-\sum_{j=0}^{i-1} A_{ij}x_{j}^{(k)}-\sum_{j=i+1}^{n-1} A_{ij}x_{j}^{(k-1)}\right)

We have split the sum into two parts and, for computing the ii-th term, taken into account the terms with index smaller than ii already determined in the kk-th iteration.

We stop the iterative approach when we reach the desired accuracy of the solution ϵ\epsilon:

∣∣xi(k)−xi(k−1)∣∣<ϵ||x_{i}^{(k)}-x_{i}^{(k-1)}||<\epsilon

Example

Initial approximation:

array([0., 0., 0.])

Let’s prepare the matrix A\mathbf{A} without the diagonal elements (Why? Try to answer below, once we have performed the iterations.)

array([[ 0., -1., 1.], [-1., 0., -1.], [ 0., -1., 0.]])

Let’s perform the iterations:

xi(k)=1Aii(bi−∑j=0i−1Aijxj(k)−∑j=i+1n−1Aijxj(k−1))x_{i}^{(k)} =\frac{1}{A_{ii}}\left(b_i-\sum_{j=0}^{i-1} A_{ij}x_{j}^{(k)}-\sum_{j=i+1}^{n-1} A_{ij}x_{j}^{(k-1)}\right)

(Since we will write the values into x right away, there is no need to split the sum into two parts)

-----iteration 0-----
Approximation for element 0 [-1.75  0.    0.  ]
Approximation for element 1 [-1.75        5.70833333  0.        ]
Approximation for element 2 [-1.75        5.70833333  1.95138889]
Norm 6.281360365408393
-----iteration 1-----
Approximation for element 0 [-1.28038194  5.70833333  1.95138889]
Approximation for element 1 [-1.28038194  6.11183449  1.95138889]
Approximation for element 2 [-1.28038194  6.11183449  2.01863908]
Norm 0.6227976321231091
-----iteration 2-----
Approximation for element 0 [-1.23835057  6.11183449  2.01863908]
Approximation for element 1 [-1.23835057  6.13004808  2.01863908]
Approximation for element 2 [-1.23835057  6.13004808  2.02167468]
Norm 0.04590845211691733

Let’s check the solution

array([-14.01517799, 35.9969644 , 6. ])

The method works well if the matrix is diagonally dominant (there are, however, methods that work even when the matrix is not diagonally dominant; see e.g.: J. Petrišič, Reševanje enačb, 1996, p. 149: Conjugate gradient method).