Implementing Gaussian Elimination

Implementing Gaussian Elimination#

We start with an implementation of Gaussian elimination as described previously.

Caution

A feature of this implementation is that the input A and b are changed by this routine, and on output they reflect the row-echelon form. This is done to save memory.

We will print the steps along the way

import numpy as np
def gauss_elim(A, b, *, quiet=False, pivot=True):
    """ perform gaussian elimination with pivoting, solving A x = b.

        A is an NxN matrix, x and b are an N-element vectors.  Note: A
        and b are changed upon exit to be in upper triangular (row
        echelon) form """

    assert b.ndim == 1, "ERROR: b should be a vector"

    N = len(b)
    assert A.shape == (N, N), "ERROR: A should be square with each dim of same length as b"

    x = np.zeros((N), dtype=A.dtype)

    if not quiet:
        print_Ab(A, b)

    # main loop over rows
    for k in range(N):

        if not quiet:
            print(f"working on row {k}")

        if pivot:
            # find the pivot row based on the size of column k -- only consider
            # the rows >= k (then add k to the index so it is 0-based)
            row_max = np.argmax(np.abs(A[k:, k])) + k

            if row_max != k:
                # swap the row with the largest element in the current column
                # with the current row (pivot) -- do this with b too!
                A[[k, row_max], :] = A[[row_max, k], :]
                b[[k, row_max]] = b[[row_max, k]]
                if not quiet:
                    print("pivoted, updated system:")
                    print_Ab(A, b)

        # do the forward-elimination for all rows below the current
        for i in range(k+1, N):
            coeff = A[i, k] / A[k, k]

            for j in range(k+1, N):
                A[i, j] += -A[k, j] * coeff

            A[i, k] = 0.0
            b[i] += -coeff * b[k]
            
            # check if the row is all zeros -- singular
            if np.abs(A[i, :]).max() == 0:
                if not quiet:
                    print("singular")
                    print_Ab(A, b)
                raise ValueError("matrix is singular")

        if not quiet:
            print_Ab(A, b)

    # back-substitution

    # last solution is easy
    x[N-1] = b[N-1] / A[N-1, N-1]

    for i in reversed(range(N-1)):
        bsum = b[i]
        for j in range(i+1, N):
            bsum += -A[i, j] * x[j]
        x[i] = bsum / A[i, i]

    return x

This routine prints the augmented matrix \(({\bf A} | {\bf b})\).

def print_Ab(A, b):
    """printout the matrix A and vector b in a pretty fashion."""

    N = len(b)

    space = 8*" "
    top_str = "⎧" + N*" {:>7.03f} " + "⎫" + space + "⎧" + " {:6.3f} " + "⎫"
    sid_str = "⎪" + N*" {:>7.03f} " + "⎪" + space + "⎪" + " {:6.3f} " + "⎪"
    bot_str = "⎩" + N*" {:>7.03f} " + "⎭" + space + "⎩" + " {:6.3f} " + "⎭"

    for i in range(N):
        if i == 0:
            pstr = top_str
        elif i == N-1:
            pstr = bot_str
        else:
            pstr = sid_str
        out = tuple(A[i, :]) + (b[i],)
        print(pstr.format(*out))
    print(" ")

Now we can test this out.

Important

Be sure to create your arrays as floating point arrays. If we don’t specify, and just include integers in the initialization, then the array will be an integer array, and we will truncate all divisions and won’t get the right answer.

A = np.array([[1, 1, 1],
              [-1, 2, 0],
              [2, 0, 1]], dtype=np.float64)

b = np.array([6, 3, 5], dtype=np.float64)

# since our method changes A and b as it works, we'll send in copies
x = gauss_elim(A.copy(), b.copy())
⎧   1.000    1.000    1.000 ⎫        ⎧  6.000 ⎫
⎪  -1.000    2.000    0.000 ⎪        ⎪  3.000 ⎪
⎩   2.000    0.000    1.000 ⎭        ⎩  5.000 ⎭
 
working on row 0
pivoted, updated system:
⎧   2.000    0.000    1.000 ⎫        ⎧  5.000 ⎫
⎪  -1.000    2.000    0.000 ⎪        ⎪  3.000 ⎪
⎩   1.000    1.000    1.000 ⎭        ⎩  6.000 ⎭
 
⎧   2.000    0.000    1.000 ⎫        ⎧  5.000 ⎫
⎪   0.000    2.000    0.500 ⎪        ⎪  5.500 ⎪
⎩   0.000    1.000    0.500 ⎭        ⎩  3.500 ⎭
 
working on row 1
⎧   2.000    0.000    1.000 ⎫        ⎧  5.000 ⎫
⎪   0.000    2.000    0.500 ⎪        ⎪  5.500 ⎪
⎩   0.000    0.000    0.250 ⎭        ⎩  0.750 ⎭
 
working on row 2
⎧   2.000    0.000    1.000 ⎫        ⎧  5.000 ⎫
⎪   0.000    2.000    0.500 ⎪        ⎪  5.500 ⎪
⎩   0.000    0.000    0.250 ⎭        ⎩  0.750 ⎭
 
print(x)
[1. 2. 3.]

To ensure that we got the right answer, we can compare \({\bf A}{\bf x}\) and \({\bf b}\). We can use the python matrix multiplication operator, @:

print(A @ x - b)
[0. 0. 0.]

\(4\times 4\) example#

Let’s try another, larger example

A = np.array([[1., 2., 3., 4.],
              [5., 1., 6., -1.],
              [10., 2., 13., -2.],
              [4., 10., -2., -5.]])
b = np.array([3., 2., 4., 1.])
x = gauss_elim(A.copy(), b.copy())
⎧   1.000    2.000    3.000    4.000 ⎫        ⎧  3.000 ⎫
⎪   5.000    1.000    6.000   -1.000 ⎪        ⎪  2.000 ⎪
⎪  10.000    2.000   13.000   -2.000 ⎪        ⎪  4.000 ⎪
⎩   4.000   10.000   -2.000   -5.000 ⎭        ⎩  1.000 ⎭
 
working on row 0
pivoted, updated system:
⎧  10.000    2.000   13.000   -2.000 ⎫        ⎧  4.000 ⎫
⎪   5.000    1.000    6.000   -1.000 ⎪        ⎪  2.000 ⎪
⎪   1.000    2.000    3.000    4.000 ⎪        ⎪  3.000 ⎪
⎩   4.000   10.000   -2.000   -5.000 ⎭        ⎩  1.000 ⎭
 
⎧  10.000    2.000   13.000   -2.000 ⎫        ⎧  4.000 ⎫
⎪   0.000    0.000   -0.500    0.000 ⎪        ⎪  0.000 ⎪
⎪   0.000    1.800    1.700    4.200 ⎪        ⎪  2.600 ⎪
⎩   0.000    9.200   -7.200   -4.200 ⎭        ⎩ -0.600 ⎭
 
working on row 1
pivoted, updated system:
⎧  10.000    2.000   13.000   -2.000 ⎫        ⎧  4.000 ⎫
⎪   0.000    9.200   -7.200   -4.200 ⎪        ⎪ -0.600 ⎪
⎪   0.000    1.800    1.700    4.200 ⎪        ⎪  2.600 ⎪
⎩   0.000    0.000   -0.500    0.000 ⎭        ⎩  0.000 ⎭
 
⎧  10.000    2.000   13.000   -2.000 ⎫        ⎧  4.000 ⎫
⎪   0.000    9.200   -7.200   -4.200 ⎪        ⎪ -0.600 ⎪
⎪   0.000    0.000    3.109    5.022 ⎪        ⎪  2.717 ⎪
⎩   0.000    0.000   -0.500    0.000 ⎭        ⎩  0.000 ⎭
 
working on row 2
⎧  10.000    2.000   13.000   -2.000 ⎫        ⎧  4.000 ⎫
⎪   0.000    9.200   -7.200   -4.200 ⎪        ⎪ -0.600 ⎪
⎪   0.000    0.000    3.109    5.022 ⎪        ⎪  2.717 ⎪
⎩   0.000    0.000    0.000    0.808 ⎭        ⎩  0.437 ⎭
 
working on row 3
⎧  10.000    2.000   13.000   -2.000 ⎫        ⎧  4.000 ⎫
⎪   0.000    9.200   -7.200   -4.200 ⎪        ⎪ -0.600 ⎪
⎪   0.000    0.000    3.109    5.022 ⎪        ⎪  2.717 ⎪
⎩   0.000    0.000    0.000    0.808 ⎭        ⎩  0.437 ⎭
 
x
array([0.47186147, 0.18181818, 0.        , 0.54112554])
A @ x - b
array([0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 6.66133815e-16])

Singular example#

changing A[2, 2] from 13 to 12 makes the system singular, since row 2 is just double row 1.

A = np.array([[1., 2., 3., 4.],
              [5., 1., 6., -1.],
              [10., 2., 12., -2.],
              [4., 10., -2., -5.]])
b = np.array([3., 2., 4., 1.])

x = gauss_elim(A, b)
⎧   1.000    2.000    3.000    4.000 ⎫        ⎧  3.000 ⎫
⎪   5.000    1.000    6.000   -1.000 ⎪        ⎪  2.000 ⎪
⎪  10.000    2.000   12.000   -2.000 ⎪        ⎪  4.000 ⎪
⎩   4.000   10.000   -2.000   -5.000 ⎭        ⎩  1.000 ⎭
 
working on row 0
pivoted, updated system:
⎧  10.000    2.000   12.000   -2.000 ⎫        ⎧  4.000 ⎫
⎪   5.000    1.000    6.000   -1.000 ⎪        ⎪  2.000 ⎪
⎪   1.000    2.000    3.000    4.000 ⎪        ⎪  3.000 ⎪
⎩   4.000   10.000   -2.000   -5.000 ⎭        ⎩  1.000 ⎭
 
singular
⎧  10.000    2.000   12.000   -2.000 ⎫        ⎧  4.000 ⎫
⎪   0.000    0.000    0.000    0.000 ⎪        ⎪  0.000 ⎪
⎪   1.000    2.000    3.000    4.000 ⎪        ⎪  3.000 ⎪
⎩   4.000   10.000   -2.000   -5.000 ⎭        ⎩  1.000 ⎭
 
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[11], line 7
      1 A = np.array([[1., 2., 3., 4.],
      2               [5., 1., 6., -1.],
      3               [10., 2., 12., -2.],
      4               [4., 10., -2., -5.]])
      5 b = np.array([3., 2., 4., 1.])
----> 7 x = gauss_elim(A, b)

Cell In[2], line 53, in gauss_elim(A, b, quiet, pivot)
     51             print("singular")
     52             print_Ab(A, b)
---> 53         raise ValueError("matrix is singular")
     55 if not quiet:
     56     print_Ab(A, b)

ValueError: matrix is singular

Roundoff example#

Let’s try our example with roundoff both with and without pivoting

eps = 1.e-15

A = np.array([[eps, 1.0, 1.0],
              [-1.0, 2.0, 0.0],
              [2.0, 0.0, 1.0]])
b = np.array([6.0, 3.0, 5.0])
x = gauss_elim(A.copy(), b.copy(), quiet=True)
x
array([0.33333333, 1.66666667, 4.33333333])
x = gauss_elim(A.copy(), b.copy(), quiet=True, pivot=False)
x
array([1.77635684, 2.        , 4.        ])

we see that with \(\epsilon \rightarrow 0\), without pivoting, we get a very poor solution.