Skip to content

Solving Linear Systems (NumPy)

Most linear algebra courses start by considering how to solve a system of linear equations.

\[ \begin{align} a_{0,0}x_0 + a_{0,1}x_0 + \cdots a_{0,n-1}x_0 & = b_0 \\ a_{1,0}x_0 + a_{1,1}x_0 + \cdots a_{1,n-1}x_0 & = b_1 \\ & \vdots\\ a_{n-1,0}x_0 + a_{n-1,1}x_0 + \cdots a_{n-1,n-1}x_0 & = b_{m-1} \\ \end{align} \]

In matrix notation, this linear system of \(n\) equations in \(n\) unknowns (\(x_0, \ldots, x_{n-1}\)), can be written as

\[ A\vec{x} = \vec{b} \]

where

\[ A = \begin{bmatrix} a_{0,0} & a_{0,1} & \cdots & a_{0,n-1}\\ a_{1,0} & a_{1,1} & \cdots & a_{1,n-1}\\ \vdots & \vdots & & \vdots\\ a_{n-1,0} & a_{n-1,1} & \cdots & a_{n-1,n-1}\\ \end{bmatrix} , \quad \vec{x} = \begin{bmatrix} x_0 \\ x_1 \\ \vdots \\ x_{n-1}\end{bmatrix} , \quad \vec{b} = \begin{bmatrix} b_0 \\ b_1 \\ \vdots \\ b_{n-1}\end{bmatrix} \]

We'll look at how to use SciPy to numerically solve linear systems corresponding to square matrices. We'll also look at how to implement row operations (Gaussian elimination).

First we import the necessary libraries.

import numpy as np
import scipy.linalg as la

scipy.linalg.solve

Let's start with a 3x3 linear system:

\[ \begin{align} x_1 + x_2 - 2x_3 & = 4 \\ 2x_2 + x_3 & = 3 \\ 2x_1 + x_2 - 5x_3 & = 7 \\ \end{align} \]

Verify (by substitution) that \(\vec{x} = [0,2,-1]\) is a solution.

To solve a (square) linear system in python we use the scipy.linalg.solve funtion.

A = np.array([[1,1,-2],[0,2,1],[2,1,-5]])
b = np.array([4,3,7])
la.solve(A,b) # (1)
  1. \(A\) must be a nonsingular square matrix to use .solve.
array([ 0.,  2., -1.])

If \(\vec{b}\) is a 1D array then .solve returns a 1D array. However, if \(\vec{b}\) is a 2D array then .solve returns the solution as a 2D array, as the following shows.

A = np.array([[1,1,-2],[0,2,1],[2,1,-5]])
b = np.array([4,3,7]).reshape(3,1)
la.solve(A,b)
array([[ 0.],
       [ 2.],
       [-1.]])

Note

scipy.linalg.solve requires the matrix to be square and invertible (\(\operatorname{det}(A) \ne 0\)).

Example

Consider another example: $$ 2x_1 + 3x_2 = 11 $$ $$ 3x_1 + 6x_2 = 7 $$

A = np.array([[2,3],[3,6]])
b = np.array([11,7])
la.solve(A,b)
array([15.        , -6.33333333])

We can verify this answer is correct by observing \(A\vec{x} = \vec{b}\).

x = la.solve(A,b)
A@x
array([11.,  7.])

Example

Consider two systems of linear equations with the same coefficient matrix: $$ 2x_1 + 3x_2 = 11 \quad \quad \quad 2x_1 + 3x_2 = -1$$ $$ 3x_1 + 6x_2 = 7 \quad \quad \quad 3x_1 + 6x_2 = -3$$ Solving these two systems individually, is equivalent to solving the matrix equation

\[AX = B\]

where

\[ A = \begin{bmatrix}2 & 3 \\ 3 & 6 \end{bmatrix} , \quad \quad X = \begin{bmatrix} \vec{x}_1 & \vec{x}_2 \end{bmatrix} , \quad \quad B = \begin{bmatrix} \vec{b}_1 & \vec{b}_2 \end{bmatrix} = \begin{bmatrix} 11 & -1 \\ 7 & -3 \end{bmatrix} \]

We can use the .solve command where the b is a matrix.

A = np.array([[2,3],[3,6]])
b = np.array([[11,-1],[7,-3]])
la.solve(A,b)
array([[15.        ,  1.        ],
       [-6.33333333, -1.        ]])

Gaussian Elimination

The general procedure learned to solve a system of linear equations is Gaussian elimination. The goal is to apply row operations to "eliminate" all the variables except for one in each equation (if possible), and this resulting linear system has the same set of solutions as the original, but is easier to solve. We generally do this on the matrix form of the system, and call this the reduced row echelon form (rref) of the matrix. The elementary row operations we use are:

Elementary Row Operations

  1. Replace row \(j\) with \(k\) times row \(i\) added to row \(j\). \(\quad R_j \leftarrow kR_i +R_j\)
  2. Multiply row \(i\) by a scalar \(k\). \(\quad R_i \leftarrow kR_i\)
  3. Swap rows \(i\) and \(j\). \(\quad R_i \leftrightarrow R_j\)

For example, consider the matrix

\[ A = \begin{bmatrix} 2 & 1 & 0 \\ 1 & 1 & -1 \\ 0 & -1 & 2 \\ \end{bmatrix} \]

Since we are working in python we will index rows starting at \(0\). In other words, an \(n\times m\) matrix has rows \(R_0\) to \(R_{n-1}\).

Here are the row operations taking it to rref:

\[ A = \begin{bmatrix} 2 & 1 & 0 \\ 1 & 1 & -1 \\ 0 & -1 & 2 \\ \end{bmatrix} \xrightarrow[]{R_0 \leftrightarrow R_1} \begin{bmatrix} 1 & 1 & -1 \\ 2 & 1 & 0 \\ 0 & -1 & 2 \\ \end{bmatrix} \xrightarrow[]{R_1 \leftarrow -2R_0+R_1} \begin{bmatrix} 1 & 1 & -1 \\ 0 & -1 & 2 \\ 0 & -1 & 2 \\ \end{bmatrix} \]
\[ \xrightarrow[]{R_2 \leftarrow -R_1+R_2} \begin{bmatrix} 1 & 1 & -1 \\ 0 & -1 & 2 \\ 0 & 0 & 0 \\ \end{bmatrix} \xrightarrow[]{R_0 \leftarrow R_1+R_0} \begin{bmatrix} 1 & 0 & 1 \\ 0 & -1 & 2 \\ 0 & 0 & 0 \\ \end{bmatrix} \xrightarrow[]{R_1 \leftarrow -R_1} \begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & -2 \\ 0 & 0 & 0 \\ \end{bmatrix} \]

NumPy (and SciPy) do not have row operations implemented. Not to worry, we can do these ourself. To make it simpler to implement we'll use elementary matrices and matrix multiplication. The main idea is as follows: to do a row operation on a matrix \(A\) (such as a row swap), we can do the row operation on the identity matrix first, then multiply this new matrix to the left of \(A\). This produces the matrix as if we had done the row operation directly to \(A\).

For example, suppose we want to do the following row operation as a multiplication.

\[ \begin{bmatrix} 1 & 1 & -1 \\ 2 & 1 & 0 \\ 0 & -1 & 2 \\ \end{bmatrix} \xrightarrow[]{R_1 \leftarrow -2R_0+R_1} \begin{bmatrix} 1 & 1 & -1 \\ 0 & -1 & 2 \\ 0 & -1 & 2 \\ \end{bmatrix} \]

First apply the row operation to the identity matrix:

\[ \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} \xrightarrow[]{R_1 \leftarrow -2R_0+R_1} \begin{bmatrix} 1 & 0 & 0 \\ -2 & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} = E \]

Take this new matrix \(E\) (called and elementary matrix) and multiply it to the left of the matrix you want to perform the row operation on:

\[ E\begin{bmatrix} 1 & 1 & -1 \\ 2 & 1 & 0 \\ 0 & -1 & 2 \\ \end{bmatrix} = \begin{bmatrix} 1 & 0 & 0 \\ -2 & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} \begin{bmatrix} 1 & 1 & -1 \\ 2 & 1 & 0 \\ 0 & -1 & 2 \\ \end{bmatrix} = \begin{bmatrix} 1 & 1 & -1 \\ 0 & -1 & 2 \\ 0 & -1 & 2 \\ \end{bmatrix} \]

Actually work through the product to see "how" it is doing the row operation. Therefore, row operations can be performed by multiplying by elementary matrices.

The following is an implementation of the three elementary row operations. Notice, the main body of each function has us performing the row operation on the identity matrix to create the matrix \(E\), then in the return statement we do the matrix multiplication. This performs the row operation on the original matrix.

Elementary Row Operations
def add_row(A,k,i,j):
    "row j <- k times row i added to row j in matrix A."
    n = A.shape[0]
    E = np.eye(n)
    if i == j:
        E[j,j] = k + 1
    else:
        E[j,i] = k
    return E @ A

def scale_row(A,k,i):
    "row i <- k times row i in matrix A"
    n = A.shape[0]
    E = np.eye(n)
    E[i,i] = k
    return E @ A

def swap_row(A,i,j):
    "row i <-> row j in matrix A"
    n = A.shape[0]
    E = np.eye(n)
    if i != j:
        E[i,i], E[i,j], E[j,i], E[j,j] = 0, 1, 1, 0
    return E @ A   

Example

We'll redo the example above, this time using our python functions.

A = np.array([[2,1,0],[1,1,-1],[0,-1,2]])
print(A)
print("Swap rows 0 and 1:")
A1 = swap_row(A,0,1)
print(A1)
print("Add -2 times row 0 to row 1:")
A2 = add_row(A1,-2,0,1)
print(A2)
print("Add -1 times row 1 to row 2:")
A3 = add_row(A2,-1,1,2)
print(A3)
print("Add 1 times row 1 to row 0:")
A4 = add_row(A3,1,1,0)
print(A4)
print("Multiply row 1 by -1:")
A5 = scale_row(A4,-1,1)
print(A5)
[[ 2  1  0]
 [ 1  1 -1]
 [ 0 -1  2]]
Swap rows 0 and 1:
[[ 1.  1. -1.]
 [ 2.  1.  0.]
 [ 0. -1.  2.]]
Add -2 times row 0 to row 1:
[[ 1.  1. -1.]
 [ 0. -1.  2.]
 [ 0. -1.  2.]]
Add -1 times row 1 to row 2:
[[ 1.  1. -1.]
 [ 0. -1.  2.]
 [ 0.  0.  0.]]
Add 1 times row 1 to row 0:
[[ 1.  0.  1.]
 [ 0. -1.  2.]
 [ 0.  0.  0.]]
Multiply row 1 by -1:
[[ 1.  0.  1.]
 [ 0.  1. -2.]
 [ 0.  0.  0.]]

Solving a linear system with Gaussian Elimination

We'll consider the following linear system which has more unknowns than equations. This means the coefficient matrix will not be square. Therefore, in this case we couldn't use .solve even if we wanted to.

\[ \begin{align} 2x_1 + 4x_3 +4x_4 & = 14 \\ -x_1 - x_3 & = -3 \\ -3x_1 + 6x_4 & = 3 \\ \end{align} \]

Write it as an augmented matrix:

\[ \left[\begin{array}{@{}cccc|c@{}} 2 & 0 & 4 & 4 & 14\\ -1 & 0 & -1 & 0 & -3\\ -3 & 0 & 0 & 6 & 3\\ \end{array}\right] \]

Use row operations to take it to rref.

A = np.array([[2,0,4,4],[-1,0,-1,0],[-3,0,0,6]])
b = np.array([14,-3,3]).reshape(3,1)
aug_mat = np.hstack([A,b]) # (1)
print(aug_mat)
  1. .hstack allows us to horizontally stack two arrays to form the augmented matrix.
[[ 2  0  4  4 14]
 [-1  0 -1  0 -3]
 [-3  0  0  6  3]]
A1 = swap_row(aug_mat,0,1)
print(A1)
[[-1.  0. -1.  0. -3.]
 [ 2.  0.  4.  4. 14.]
 [-3.  0.  0.  6.  3.]]
A2 = add_row(A1,2,0,1)
A3 = add_row(A2,-3,0,2)
A4 = scale_row(A3,-1,0)
print(A4)
[[ 1.  0.  1.  0.  3.]
 [ 0.  0.  2.  4.  8.]
 [ 0.  0.  3.  6. 12.]]
A5 = scale_row(A4,1/3,2)
print(A5)
[[1. 0. 1. 0. 3.]
 [0. 0. 2. 4. 8.]
 [0. 0. 1. 2. 4.]]
A6 = add_row(A5,-2,2,1)
A7 = add_row(A6,-1,2,0)
print(A7)
[[ 1.  0.  0. -2. -1.]
 [ 0.  0.  0.  0.  0.]
 [ 0.  0.  1.  2.  4.]]
A8 = swap_row(A7,1,2)
print(A8)
[[ 1.  0.  0. -2. -1.]
 [ 0.  0.  1.  2.  4.]
 [ 0.  0.  0.  0.  0.]]

At this point we have reduced the matrix to rref. We see there are two free variables (\(x_2\) and \(x_4\)). The general solution is:

\[ \vec{x} = \begin{bmatrix} -1 \\ 0 \\ 4 \\ 0\end{bmatrix} + s\begin{bmatrix} 0 \\ 1 \\ 0 \\ 0\end{bmatrix} + t\begin{bmatrix} 2 \\ 0 \\ -2\\ 1\end{bmatrix} , \quad \text{ for } s, t\in \mathbb{R} \]

rref

NumPy (SciPy) does not have a built-in function to put a matrix in reduced row echelon form (rref). However, we can implement our own.

To put a matrix into reduced row echelon form, we define rref. rref returns a tuple of two elements. The first is the reduced row echelon form, and the second is a tuple of indices of the pivot columns. The length of this tuple is the rank of the matrix.

def rref(A, tol=1.0e-12):
    ''' input: a matrix (2D array)
       output: rref form of the matrix, and a tuple of pivot columns
    '''   
    m, n = A.shape
    i, j = 0, 0
    jb = [] # list of pivot columns

    while i < m and j < n:
        # Find value and index of largest element in the remainder of column j
        k = np.argmax(np.abs(A[i:m, j])) + i
        p = np.abs(A[k, j])
        if p <= tol:
            # The column is negligible, zero it out
            A[i:m, j] = 0.0
            j += 1
        else:
            # Remember the column index
            jb.append(j)
            if i != k:
                # Swap the i-th and k-th rows
                A[[i, k], j:n] = A[[k, i], j:n]
            # Divide the pivot row i by the pivot element A[i, j]
            A[i, j:n] = A[i, j:n] / A[i, j]
            # Subtract multiples of the pivot row from all the other rows
            for k in range(m):
                if k != i:
                    A[k, j:n] -= A[k, j] * A[i, j:n]
            i += 1
            j += 1
    # Finished
    return A, tuple(jb)

Example

Compute the rref of the matrix

\[ \begin{bmatrix} 2 & 1 & 0 \\ 1 & 1 & -1 \\ 0 & -1 & 2 \\ \end{bmatrix} \]
A = np.array([[2.,1,0],[1,1,-1],[0,-1,2]])
rref(A)
(array([[ 1.,  0.,  1.],
        [ 0.,  1., -2.],
        [ 0.,  0.,  0.]]),
 (0, 1))

Example

Compute the rref of the matrix

\[ \begin{bmatrix} 2 & 0 & 4 & 4 \\ -1 & 0 & -1 & 0 \\ -3 & 0 & 0 & 6 \\ \end{bmatrix} \]
A = np.array([[2.,0,4,4],[-1,0,-1,0],[-3,0,0,6]])
rref(A)
(array([[ 1,  0,  0, -2],
        [ 0,  0,  1,  2],
        [ 0,  0,  0,  0]]),
 (0, 2))

Example

For the linear system, find the rref of the augmented matrix.

\[ \begin{bmatrix} 2&0&4&4\\-1&0&-1&0\\-3&0&0&6 \end{bmatrix} \vec{x} = \begin{bmatrix} 14\\-3\\3 \end{bmatrix} \]
A = np.array([[2,0,4,4],[-1,0,-1,0],[-3,0,0,6]])
x = np.array([-1,2,4,0])
b = np.array([14,-3,3]).reshape(3,1)
aug_mat = np.hstack([A,b])
print(aug_mat)
rref(aug_mat)
[[ 2  0  4  4 14]
 [-1  0 -1  0 -3]
 [-3  0  0  6  3]]

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

Computing an Inverse with Gaussian Elimination

Recall, if we have an invertible matrix \(A\), then we can compute the inverse using Gaussian elimination as follows:

\[ [A | I] \xrightarrow[\text{to rref}]{\text{gaussian elim}} [I|B] \]

Matrix \(B\) is the inverse of \(A\).

Note

If you are wondering why the inverse matrix can be computed in this way, it comes back to elementary matrices. Suppose \(n\) row operations are applied to take \(A\) to the identity \(I\). Let \(E_1, E_2, \ldots E_n\) be the \(n\) elementary matrices corresponding to these row operations. Then, since row operations could be done by multiplying by elementary matrices, it follows that

\[ E_n\cdots E_2 E_1 A = I \]

Therefore, multiplying \(A^{-1}\) to the right or each side:

\[ A^{-1} = E_n\cdots E_2 E_1 I \]

This means \(A^{-1}\) is obtained by doing the row operations on \(I\).

Example

Let's find the inverse of

\[ A = \begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix} \]
A = np.array([[1,2],[3,4]])
I = np.eye(2)
aug_mat = np.hstack([A,I])
print(aug_mat)
[[1. 2. 1. 0.]
 [3. 4. 0. 1.]]
B = add_row(aug_mat,-3,0,1)
print(B)
[[ 1.  2.  1.  0.]
 [ 0. -2. -3.  1.]]
B = add_row(B,1,1,0)
B = scale_row(B,-0.5,1)
print(B)
[[ 1.   0.  -2.   1. ]
 [ 0.   1.   1.5 -0.5]]

Therefore, we have

\[ A^{-1} = \begin{bmatrix} -2 & 1 \\ 1.5 & -0.5 \end{bmatrix} \]

We can verify we found the inverse by checking that \(A A^{-1} = I\).

A_inv = B[:,2:]
A @ A_inv
array([[1., 0.],
       [0., 1.]])

Nullspace

The nullspace of a matrix can be computed with scipy.linalg.null_space. This returns an orthonormal basis for the nullspace.

A = np.array([[1,0,0,1],[1,0,1,0]])
la.null_space(A)
array([[-0.33333333, -0.47140452],
       [-0.81649658,  0.57735027],
       [ 0.33333333,  0.47140452],
       [ 0.33333333,  0.47140452]])