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.

Introduction to numerical methods and systems of linear equations

Faculty of Mechanical Engineering, University of Ljubljana

Introduction to numerical methods

When we want to simulate a chosen physical process, we usually proceed as follows:

  1. we set up a mathematical model*,

  2. we choose a numerical method and its parameters,

  3. we write a program (using built-in functions to help us),

  4. we run the computation, then analyze and evaluate the results.

* If the mathematical model can be solved analytically, numerical solution is not needed.

We try to solve a mathematical model analytically, since such a solution is not burdened with errors. For this reason, in mathematics we learn to solve systems of equations, to integrate, to differentiate, and so on. The essence of numerical methods is that we solve mathematical models numerically, that is, on the basis of discrete values. As we will learn later, the numerical approach, compared to the analytical one, allows us to solve substantially larger and more complex problems.

Round-off error

In what follows we will look at some of the limitations and challenges of the numerical approach. The first limitation is that in a computer real values are always stored with finite precision. In Python, numbers are often stored in double precision with approximately 15 significant digits.

A double-precision number in Python is called float64 and is stored in memory in binary form using 64 bits (1 bit for the sign, 11 bits for the exponent and 52 bits for the mantissa). Since the mantissa is defined using 52 binary digits, its representation can incur a relative error of at most ϵ≈2.2⋅10−16\epsilon\approx2.2\cdot 10^{-16}. This error is called the machine round-off error and can occur at every intermediate computation!

If there are many steps, the error can grow very large, and therefore it is important that its effect on the result is as small as possible!

Below is an example of more detailed information for the double-precision data type (float); for this we use the built-in module sys to call parameters and functions of the Python system (documentation):

1.0000000000000002e+16

In addition to the double-precision number, other data types are used; a good overview of the various types is given in the numpy and python documentation.

Here let us look at an example of the int8 type, which means an integer stored with 8 bits (8 bits = 1 byte). With it we can represent integers from -128 to +127 in the binary system:

"Number 1 of type <class 'numpy.int8'> stored in binary form: 1"

Method error

In addition to the round-off error, we often also encounter the method error, which we make when we replace the exact analytical procedure for solving a mathematical model with an approximate numerical one.

An important property of numerical algorithms is stability. This means that a small change in the input data causes a small change in the results. If the results change a great deal for a small change at the input, we say that the algorithm is unstable. In practice, therefore, we use stable algorithms; but we will see later that stability can also be conditioned by the input data!

We also know of the instability of the mathematical model/problem/equation; in that case we speak of ill-conditioning.

During the execution of a numerical computation, errors can propagate. As a consequence, the result of an operation is less accurate (it has fewer reliable digits) than the reliability of the computation’s data.

Let us now look at a general approach to estimating the error. We denote the exact value by rr and the approximation by a1a_1; it holds that r=a1+e1r=a_1+e_1, where e1e_1 is the error. If with a numerical algorithm we compute a substantially better approximation a2a_2, then r=a2+e2r=a_2+e_2.

Since a1+e1=a2+e2a_1+e_1=a_2+e_2, then under the assumption ∣e1∣>>∣e2∣\left|e_1\right|>>\left|e_2\right| and ∣e2∣≈0\left|e_2\right|\approx 0 we can derive a2−a1=e1−e2≈e1a_2-a_1=e_1-e_2\approx e_1.

∣a1−a2∣\left|a_1-a_2\right| is therefore a pessimistic estimate of the absolute error,

while ∣a1−a2a2∣\left|\frac{a_1-a_2}{a_2}\right| is an estimate of the relative error.

Introduction to systems of linear equations

Under the heading above we mean a system of mm linear equations (Ei,i=0,1,…,m−1E_i, i=0, 1,\dots,m-1) with nn unknowns (xj,j=0,1,…,n−1x_j, j=0,1,\dots,n-1):

E0:A0,0 x0+A0,1 x1+…+A0,n−1 xn−1=b0E1:A1,0 x0+A1,1 x1+…+A1,n−1 xn−1=b1⋮⋮Em−1:Am−1,0 x0+Am−1,1 x1+…+Am−1,n−1 xn−1=bm−1.\begin{array}{rlllllllll} E_0: & A_{0,0}\,x_0 &+&A_{0,1}\,x_1&+& \ldots &+&A_{0,n-1}\,x_{n-1}&=&b_0\\ E_1: & A_{1,0}\,x_0 &+&A_{1,1}\,x_1&+& \ldots &+&A_{1,n-1}\,x_{n-1}&=&b_1\\ \vdots && &&& \vdots\\ E_{m-1}: & A_{m-1,0}\,x_0&+&A_{m-1,1}\,x_1&+& \ldots &+&A_{m-1,n-1}\,x_{n-1}&=&b_{m-1}.\\ \end{array}

The coefficients Ai,jA_{i,j} and bib_i are known numbers.

If the right-hand side equals zero, that is bi=0b_i=0, we call the system homogeneous, otherwise the system is nonhomogeneous.

A system of equations can also be written in matrix form:

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

where A\mathbf{A} and b\mathbf{b} are a known matrix and vector, while the vector x\mathbf{x} is unknown. We call the matrix A\mathbf{A} the coefficient matrix, the vector b\mathbf{b} the constant vector (also: the vector of free terms, or the right-hand-side column vector), and x\mathbf{x} the vector of unknowns. If we add the vector b\mathbf{b} as a column to the matrix A\mathbf{A}, we obtain the so-called augmented matrix, which we denote [A∣b][\mathbf{A}|\mathbf{b}].

A note on notation:

  • scalar variables are written in italics, e.g.: a,Aa, A,

  • vector variables are written in lowercase bold, e.g.: a\mathbf{a},

  • matrix variables are written in uppercase bold, e.g.: A\mathbf{A}.

On the solution of a system of linear equations

Loading...

If we perform elementary operations on a system of linear equations:

  • multiplying any equation by a constant (different from zero),

  • changing the order of the equations,

  • adding one equation (multiplied by a constant) to another equation,

we do not change the solution of the system and obtain an equivalent system of equations.

Using elementary operations on the rows of the matrix A\mathbf{A}, we can transform it into the so-called reduced row echelon form:

  1. if there are zero rows, they are at the bottom of the matrix,

  2. the first nonzero element lies to the right of the first nonzero elements of the preceding rows,

  3. the first nonzero element in a row is called the pivot and is equal to 1,

  4. the pivot is the only nonzero element in its column.

The rank of a matrix represents the number of nonzero rows in the reduced row echelon form of the matrix; the number of nonzero rows represents the number of linearly independent equations and equals the number of pivot elements. The rank of a matrix is therefore equal to the number of linearly independent rows of the matrix. Transposing a matrix does not change its rank, so the rank of a matrix also equals the number of linearly independent columns of the matrix.

Example of transforming the matrix A\mathbf{A}:

array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

Clearly the nonzero element A[0,0] has the value 1 and is the pivot element. We multiply the first row A[0,:] by -4 and add the product to the second row A[1,:]-4A[0,:]:

array([[ 1, 2, 3], [ 0, -3, -6], [ 7, 8, 9]])

We do similarly for the third row:

array([[ 1, 2, 3], [ 0, -3, -6], [ 0, -6, -12]])

We now divide the second row by A[1,1] to obtain the pivot:

array([[ 1, 2, 3], [ 0, 1, 2], [ 0, -6, -12]])

We subtract the second row from the others, so that in the second column we get zeros everywhere except for the value 1 in the second row:

array([[ 1, 0, -1], [ 0, 1, 2], [ 0, -6, -12]])
array([[ 1, 0, -1], [ 0, 1, 2], [ 0, 0, 0]])

We have two nonzero rows; the matrix A has two pivots and represents two linearly independent equations. The rank of the matrix is 2.

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

The rank of a matrix can also be determined using the numpy function numpy.linalg.matrix_rank (documentation):

matrix_rank(M, tol=None)

where M is the matrix whose rank we are looking for, and tol is an optional parameter that sets the threshold below which values in the algorithm are treated as equal to zero.

np.int64(2)

If r=rank(A)=rank([A∣b])r=\textbf{rank}(\mathbf{A})=\textbf{rank}([\mathbf{A}|\mathbf{b}]), then a solution exists (we also say that the system is consistent).

A consistent system has:

  • exactly one solution when the number of unknowns nn equals the rank rr (the solution is independent), and

  • infinitely many solutions when the rank rr is less than the number of unknowns nn (the solution depends on n−rn-r parameters).

First we will restrict ourselves to a system of m=nm=n linear equations with nn unknowns where n=rn=r:

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

Under the above conditions the coefficient matrix A\mathbf{A} is nonsingular (∣A∣≠0|\mathbf{A}|\neq 0) and the system has the solution:

x=A−1 b.\mathbf{x}=\mathbf{A^{-1}}\,\mathbf{b}.

Let us look at an example of a system where the equations are linearly dependent (r<nr<n):

array([[1, 2, 1], [2, 4, 2]])

Using the numpy library, let us now look at the rank of the coefficient matrix and of the augmented matrix, as well as the determinant, using numpy.linalg.det (documentation):

det(a)

where a is the matrix (or list of matrices) whose determinant we are looking for; the function det returns the determinant (or list of determinants).

'rank(A)=1, rank(Ab)=1, number of unknowns: 2, det(A)=0.0'

Let us also look at an example where there is no solution at all (an inconsistent system):

array([[1, 2, 1], [2, 4, 1]])
'rank(A)=1, rank(Ab)=2, number of unknowns: 2, det(A)=0.0'

Norm and conditioning of systems of equations

A numerical problem is ill-conditioned if a small change in the data causes a large change in the result. In the case of a small change in the data that causes a small change in the results, the problem is well-conditioned.

A system of equations is usually well-conditioned if the absolute values of the diagonal elements of the coefficient matrix are large compared to the absolute values of the off-diagonal elements.

For a system of linear equations A x=b\mathbf{A}\,\mathbf{x}=\mathbf{b} we can compute the condition number:

cond(A)=∣∣A∣∣ ∣∣A−1∣∣.\textrm{cond}(\textbf{A})=||\textbf{A}||\,||\textbf{A}^{-1}||.

By ∣∣A∣∣||\textbf{A}|| we denote the norm of the matrix.

There are several ways to compute the norm; let us state two:

  • the Euclidean norm (also the Frobenius norm):

∣∣A∣∣e=∑i=1n∑j=1nAij2||\textbf{A}||_e=\sqrt{\sum_{i=1}^n\sum_{j=1}^nA_{ij}^2}
  • the row-sum norm, also called the infinity norm:

∣∣A∣∣∞=max⁡1≤i≤n∑j=1n∣Aij∣||\textbf{A}||_{\infty}=\max_{1\le i\le n}\sum_{j=1}^n |A_{ij}|

We compute the conditioning with the built-in function numpy.linalg.cond (documentation):

cond(x, p=None)

which takes two parameters: the matrix x and the optional norm type p (the default type is None; in that case the 2-norm is used (the ratio of the largest and smallest singular values); for the Frobenius norm you must pass p='fro').

If the condition number is small, then the matrix is well-conditioned, and conversely - for ill-conditioning the condition number increases greatly.

Unfortunately, computing the conditioning of a matrix is numerically relatively demanding.

Example of an ill-conditioned matrix

We will look at an ill-conditioned system, where with a tiny change to the coefficient matrix we will cause a large change in the solution.

The coefficient matrix:

np.float64(400002.0000028382)

The constant vector:

array([[ 1. , 1. , 3. ], [ 1. , 1.00001, -3. ]])

Let us check the rank and the determinant:

'rank(A)=2, rank(Ab)=2, number of unknowns: 2, det(A)=1.000000000006551e-05'

We subtract the first equation from the second:

array([[ 1.e+00, 1.e+00, 3.e+00], [ 0.e+00, 1.e-05, -6.e+00]])

We determine x1:

np.float64(-599999.9999960692)

It remains to determine x0:

np.float64(600002.9999960692)

We slightly change the coefficient matrix and repeat the solving:

np.float64(40002.00007491187)

We repeat the computation:

We compare the two solutions:

[np.float64(600002.9999960692), np.float64(-599999.9999960692)]
[np.float64(60003.00000000661), np.float64(-60000.00000000661)]

We find that a tiny change in one coefficient of the coefficient matrix caused a large change in the result. A small change in the data cannot be avoided, because of how the data is stored in the computer.

Numerical solution of systems of linear equations

We will look at two approaches to solving systems of linear equations that are in principle different:

A) the direct approach: we perform elementary operations on the system of equations, by which we transform the system into one that is easier to solve,

B) the iterative approach: we choose an initial approximation and then iteratively improve the approximation.

Gaussian elimination

Assume that we are solving a system of nn equations for nn unknowns that has rank nn. Such a system has a unique solution.

Gaussian elimination is one of the direct methods, since by means of elementary row operations we reduce the system of equations to an upper-triangular system (below the main diagonal in the augmented matrix the values are zero).

First we prepare the augmented coefficient matrix:

[A∣b]=[A0,0A0,1⋯A0,n−1b0A1,0A1,1⋯A1,n−1b1⋮⋮⋱⋮⋮An−1,0An−1,1⋯An−1,n−1bn−1]\begin{bmatrix} \mathbf{A}|\mathbf{b} \end{bmatrix}= \left[\begin{array}{cccc|c} A_{0,0}&A_{0,1}&\cdots & A_{0,n-1} & b_0\\ A_{1,0}&A_{1,1}&\cdots & A_{1,n-1} & b_1\\ \vdots&\vdots&\ddots & \vdots & \vdots\\ A_{n-1,0}&A_{n-1,1}&\cdots & A_{n-1,n-1} & b_{n-1}\\ \end{array}\right]

We will look at Gaussian elimination through an example:

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

Step 0: we multiply the first row by Ab[1,0]/Ab[0,0]=-6/8 and subtract it from the second:

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

Next we multiply the first row by Ab[2,0]/Ab[0,0]=3/8 and subtract it from the third:

array([[ 8. , -6. , 3. , -14. ], [ 0. , 1.5 , -3.75 , 25.5 ], [ 0. , -3.75 , 4.875, 11.25 ]])

Step 1: we multiply the second row by Ab[2,1]/Ab[1,1]=-3.75/1.5 and subtract it from the third:

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

Using the prepared module modules/gauss_demo.py we can also animate the procedure: Gaussian elimination

We have obtained an upper-triangular matrix and Gaussian elimination is finished. We can compute the solution, that is, determine the vector of unknowns xx by back substitution.

From the last row of the upper-triangular matrix we compute x2x_2:

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

Using the second-to-last row we compute x1x_1:

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

Using the first row we then compute x0x_0:

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

Let us check the solution:

array([-3.55271368e-15, 7.10542736e-15, 7.10542736e-15])

Summary of Gaussian elimination

In the module tools.py we save the functions:

The algorithm by which we compute the solution from an upper-triangular system of equations U x=b\mathbf{U}\,\mathbf{x}=\mathbf{b} is called back substitution; U\mathbf{U} is an upper-triangular matrix.

If we were to solve the system L x=b\mathbf{L}\,\mathbf{x}=\mathbf{b} where L\mathbf{L} is a lower-triangular matrix, we would call this method forward substitution.

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

Numerical complexity

We assess numerical complexity by the number of mathematical operations required for the computation. To solve nn linear equations with Gaussian elimination, we thus need approximately n3/3n^3/3 mathematical operations. To determine the unknowns x\mathbf{x} we need an additional approximately n2n^2 operations.

In Gaussian elimination we carried out the elimination only for the terms below the diagonal; if we continued the elimination and carried it out also for the terms above the diagonal, we would perform the so-called Gauss-Jordan elimination, for which, however, we need approximately n3/2n^3/2 operations in total, i.e., an additional approximately n3/6n^3/6 (which is considered the main drawback of this method).

Using the numpy library

Solving a system of linear equations with numpy.linalg.solve (documentation):

solve(a, b)

where a is the coefficient matrix (or list of matrices) and b is the constant vector (or list of vectors). The function returns the vector (or list of vectors) of solutions.

array([-14. , -24.66666667, -16.66666667])

Additional

Example of symbolic solving of a system of linear equations with sympy

Loading...
Loading...
Loading...