Using Python and NumPy for Computations in Linear Algebra¶

Python is a simple but powerful programming language. Although it can be slow, NumPy provides fast routines for computations with matrices and arrays.

Notebook: If you have how to run Jupyter Notebooks, you can download the notebook here.

Let's import NumPy and some of its special routines for linear algebra, linalg:

In [1]:
import numpy as np
import numpy.linalg as la

Matrices¶

Let's create the matrix $$ A = \begin{bmatrix} 2 & 8 & 4 \\ 2 & 5 & 1 \\ 4 & 10 & -1 \end{bmatrix} $$

In [2]:
A = np.array([[2, 8, 4], [2, 5, 1], [4, 10, -1]])  # enter a list of rows (each row also a list)
A
Out[2]:
array([[ 2,  8,  4],
       [ 2,  5,  1],
       [ 4, 10, -1]])

We can also do:

In [3]:
A = np.array([2, 8, 4, 2, 5, 1, 4, 10, -1]).reshape(3,3)  # reshape as 3 by 3
A
Out[3]:
array([[ 2,  8,  4],
       [ 2,  5,  1],
       [ 4, 10, -1]])

We can also specify where the coefficients lie. For instance, to have the same matrix over $\mathbb{R}$, we can specify the data type float

In [4]:
A = np.array([2, 8, 4, 2, 5, 1, 4, 10, -1], dtype=float).reshape(3,3)  # for square matrices we can give only one dimension
A
Out[4]:
array([[ 2.,  8.,  4.],
       [ 2.,  5.,  1.],
       [ 4., 10., -1.]])

Special Matrices¶

We can create the identity matrix of any dimension:

In [5]:
np.identity(4)
Out[5]:
array([[1., 0., 0., 0.],
       [0., 1., 0., 0.],
       [0., 0., 1., 0.],
       [0., 0., 0., 1.]])

The defaut is for floats. Or, to have integers (int) entries:

In [6]:
np.identity(4, dtype=int)
Out[6]:
array([[1, 0, 0, 0],
       [0, 1, 0, 0],
       [0, 0, 1, 0],
       [0, 0, 0, 1]])

Similarly for zero matrices:

In [7]:
np.zeros((3, 4))
Out[7]:
array([[0., 0., 0., 0.],
       [0., 0., 0., 0.],
       [0., 0., 0., 0.]])
In [8]:
np.zeros((3, 4), dtype=int)
Out[8]:
array([[0, 0, 0, 0],
       [0, 0, 0, 0],
       [0, 0, 0, 0]])

We can create random matrices. By default, it produces random numbers in the interval $[0, 1]$:

In [9]:
np.random.rand(3, 4)
Out[9]:
array([[0.97779102, 0.48632828, 0.51850307, 0.46542345],
       [0.63416767, 0.16790927, 0.3300862 , 0.48055598],
       [0.21382861, 0.26235355, 0.55605216, 0.43790846]])

But we can adjust. If we want random real numbers in $[-2, 3]$:

In [10]:
min_val = -2
max_val = 3
(max_val - min_val) * np.random.rand(3, 4) + min_val
Out[10]:
array([[-1.21530534,  2.98093958,  1.36106809,  1.83274876],
       [ 2.53386589,  2.60574211,  0.86825802, -1.14340378],
       [-1.89537349,  1.45610872, -0.14157219,  0.38965759]])

For random integer entries between $-10$ and $20$:

In [11]:
np.random.randint(-10, 21, size=(3, 4))
Out[11]:
array([[ 8,  4,  1, 10],
       [11, 18,  3, 10],
       [12, 10, 19, 20]])

Complex Matrices¶

Python has complex numbers. The imaginary number $i$ is represented by 1j:

In [12]:
1j ** 2
Out[12]:
(-1+0j)

NOTE: In Python, powers are done with **, not ^! And you have to be careful, as ^ does not give you errors, it gives an unexpected behavior:

In [13]:
3^2
Out[13]:
1
In [14]:
3 ** 2
Out[14]:
9

(If you care, ^ is used for the bitwise "XOR", or exclusive or):

In [15]:
(True ^ True,
 True ^ False,
 False ^ True,
 False ^ False)
Out[15]:
(False, True, True, False)

So, we can make matrices such as:

In [16]:
A = np.array([1j/2, 0, 1/4 + 3*1j, -3]).reshape(2, 2)
A
Out[16]:
array([[ 0.  +0.5j,  0.  +0.j ],
       [ 0.25+3.j , -3.  +0.j ]])

We can use dtype=complex to force complex data type:

In [17]:
A = np.arange(12, dtype=complex).reshape(3, 4)
A
Out[17]:
array([[ 0.+0.j,  1.+0.j,  2.+0.j,  3.+0.j],
       [ 4.+0.j,  5.+0.j,  6.+0.j,  7.+0.j],
       [ 8.+0.j,  9.+0.j, 10.+0.j, 11.+0.j]])

Matrix Operations¶

We can add matrices:

In [18]:
A = np.random.randint(-10, 10, size=(2,3))
B = np.random.randint(-10, 10, size=(2,3))
In [19]:
A
Out[19]:
array([[ 8,  6,  1],
       [-6, -6,  4]])
In [20]:
B
Out[20]:
array([[ 3,  1,  8],
       [ 7,  4, -9]])
In [21]:
A + B
Out[21]:
array([[11,  7,  9],
       [ 1, -2, -5]])

We can also do scalar multiplication:

In [22]:
5 * A
Out[22]:
array([[ 40,  30,   5],
       [-30, -30,  20]])

We can multiply matrices, but we need to use @, and not *!

In [23]:
A = np.array([2, 8, 4, 2, 5, 1, 4, 10, -1]).reshape(3, 3)
B = np.array([1, 2, 0, 1, 1, 1]).reshape(3, 2)
In [24]:
A
Out[24]:
array([[ 2,  8,  4],
       [ 2,  5,  1],
       [ 4, 10, -1]])
In [25]:
B
Out[25]:
array([[1, 2],
       [0, 1],
       [1, 1]])
In [26]:
A @ B
Out[26]:
array([[ 6, 16],
       [ 3, 10],
       [ 3, 17]])

The * is used for entrywise multiplication!

In [27]:
A * B
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[27], line 1
----> 1 A * B

ValueError: operands could not be broadcast together with shapes (3,3) (3,2) 
In [28]:
C = np.arange(9, dtype=int).reshape(3, 3)
C
Out[28]:
array([[0, 1, 2],
       [3, 4, 5],
       [6, 7, 8]])
In [29]:
A * C
Out[29]:
array([[ 0,  8,  8],
       [ 6, 20,  5],
       [24, 70, -8]])

We can transpose:

In [30]:
A.T
Out[30]:
array([[ 2,  2,  4],
       [ 8,  5, 10],
       [ 4,  1, -1]])

We can take determinants (using NumPy's llinalg routines, to which we gave the la shortcut when importing it):

In [31]:
la.det(A)
Out[31]:
17.999999999999996

Note that even though $A$ had integer coefficients (and so the determinant is an integer as well), the result is a float!

We can invert, if possible:

In [32]:
la.inv(A)
Out[32]:
array([[-0.83333333,  2.66666667, -0.66666667],
       [ 0.33333333, -1.        ,  0.33333333],
       [ 0.        ,  0.66666667, -0.33333333]])

Echelon Form¶

As far as I can see, there is no routine in NumPy for producing the reduced row echelon form of a matrix, so we write our own:

In [33]:
def clear_col(M, i, j, precision):
    """
    Given matrix M and position (i, j), if M[i, j] != 0, then make it 1,
    and make all entries above and below it equal to 0.
    Since we are dearling with floats, add a precision for comparison.
    """
    if M[i, j] == 0:
        raise ValueError("Element equal to zero!")

    # copy the matrix, as to non change the original!
    N = M.copy()

    # make entry 1
    N[i] *= 1/N[i, j]

    # clear
    for row in range(N.shape[0]):
        if row == i or np.abs(N[row, j]) < precision:
            continue
        N[row] += -N[row, j] * N[i]
    return N


def rref(M, precision=1e-9, type=np.float64, lastcol=None):
    """
    Given matrix M and a last column lastcol, perform row operations
    to put only up to the lastcol part in reduced row echelon form.
    Since we are performing division, we need to convert to floats.  (Defaut: np.float64)
    Since we are dearling with floats, add a precision for comparison.  (Default: 9 digits)
    """
    # make sure the entries are floats (also makes unnecessary to use copy)
    N = M.astype(type)

    # Make the default last column the real last one
    if lastcol is None:
        lastcol = N.shape[1]

    # main part
    cur_row = 0
    n_rows = N.shape[0]
    for j in range(lastcol):
        for i in range(cur_row, n_rows):
            if np.abs(N[i, j]) > precision:
                if i != cur_row:
                    # N[i], N[cur_row] = N[cur_row], N[i]
                    N[[i, cur_row]] = N[[cur_row, i]]
                N = clear_col(N, cur_row, j, precision)
                cur_row += 1
    return N

Now we can use it:

In [34]:
A = np.array([2, 8, 4, 2, 5, 1, 4, 10, -1]) .reshape(3, 3)
A
Out[34]:
array([[ 2,  8,  4],
       [ 2,  5,  1],
       [ 4, 10, -1]])
In [35]:
rref(A)
Out[35]:
array([[ 1.,  0.,  0.],
       [ 0.,  1.,  0.],
       [-0., -0.,  1.]])

Vectors¶

We don't have "explicit" vectors, as with Sage, but we can still have one-dimensional arrays.

In [36]:
v = np.array([1, 2, 3])

We can transpose to multiply on the right:

In [37]:
A @ v.T
Out[37]:
array([30, 15, 21])

Note that the result is a 1-dimensional array (vector), not a column matrix!

We can multiply on the right as well:

In [38]:
v @ A
Out[38]:
array([18, 48,  3])

Row Operations¶

We can perform row operations.

Remember that Python starts indexing from $0$, not $1$

In [39]:
A = np.arange(9).reshape(3, 3)
A
Out[39]:
array([[0, 1, 2],
       [3, 4, 5],
       [6, 7, 8]])

Let's add $-3$ times the first row to the second:

In [40]:
A[1] += -3 * A[0]  # or A[1] = A[1] - 3 * A[0]
A
Out[40]:
array([[ 0,  1,  2],
       [ 3,  1, -1],
       [ 6,  7,  8]])

Note that this changes the original matrix!

Let's multiply the first row by $5$:

In [41]:
A[0] *= 5
A
Out[41]:
array([[ 0,  5, 10],
       [ 3,  1, -1],
       [ 6,  7,  8]])

Let's swap the first and third rows:

In [42]:
A[[0, 2]] = A[[2, 0]]
A
Out[42]:
array([[ 6,  7,  8],
       [ 3,  1, -1],
       [ 0,  5, 10]])

Row Equivalency¶

There is no way to check directly if two matrices are row equivalent, i.e., one can be obtained from the other by row operations. But we can still do it: $A$ and $B$ are row equivalent if and only if they have the same reduced row echelon form.

In [43]:
A = np.arange(12).reshape(3, 4)
A
Out[43]:
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
In [44]:
B = np.arange(2, 14).reshape(3, 4)
B
Out[44]:
array([[ 2,  3,  4,  5],
       [ 6,  7,  8,  9],
       [10, 11, 12, 13]])

Since we are dealing with floats, we have to be careful with equality, as the approximation errors might make elements that should be equal not quite equal. So, we check if the absolute value of the difference of values is smaller than some chosen precision.

In [45]:
precision = 1e-9  # same as 10 ** (-9)
# check if all entries of the difference of the two matrices are less than the given precision
np.all(np.abs(rref(A) - rref(B)) < precision)
Out[45]:
True

So, $A$ and $B$ are row equivalent.

Let's check another matrix:

In [46]:
C = (np.arange(12) ** 2).reshape(3, 4)
C
Out[46]:
array([[  0,   1,   4,   9],
       [ 16,  25,  36,  49],
       [ 64,  81, 100, 121]])
In [47]:
precision = 1e-9
np.all(np.abs(rref(A) - rref(C)) < precision)
Out[47]:
False

So $A$ and $C$ are not row equivalent.

Matrix Spaces¶

Let's start with a $4 \times 3$ matrix $A$:

Unlike with Sage, we cannot ask for the row space directly, column space, etc., so let's introduce functions for those. First, a note about computing the nullspace. Suppose the we have a matrix with reduced row echelon form: $$ E = \begin{bmatrix} 1 & 0 & 2 & 3 & 0 & 0 \\ 0 & 1 & -5 & 2 & 0 & 0 \\ 0 & 0 & 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 0 & 0 & 1 \end{bmatrix}. $$ For each row with no leading in the corresponding column, say, $i$-th row and column, we add a row with $-\vec{e}_i$. In our example, we introduce $-\vec{e}_3$ and $-\vec{e}_4$ as third and fourth rows.

$$ E = \begin{bmatrix} 1 & 0 & 2 & 3 & 0 & 0 \\ 0 & 1 & -5 & 2 & 0 & 0 \\ \color{red} 0 & \color{red} 0 & \color{red} -1 & \color{red} 0 & \color{red} 0 & \color{red} 0 \\ \color{red} 0 & \color{red} 0 & \color{red} 0 & \color{red} -1 & \color{red} 0 & \color{red} 0 \\ 0 & 0 & 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 0 & 0 & 1 \end{bmatrix}. $$

Now, the column with leading negative ones gives a bases for the nullspace:

$$ E = \begin{bmatrix} 1 & 0 & \color{blue} 2 & \color{blue} 3 & 0 & 0 \\ 0 & 1 & \color{blue} -5 & \color{blue} 2 & 0 & 0 \\ 0 & 0 & \color{blue} -1 & \color{blue} 0 & 0 & 0 \\ 0 & 0 & \color{blue} 0 & \color{blue} -1 & 0 & 0 \\ 0 & 0 & \color{blue} 0 & \color{blue} 0 & 1 & 0 \\ 0 & 0 & \color{blue} 0 & \color{blue} 0 & 0 & 1 \end{bmatrix}. $$

So, in this case, the nullspace is generated by $\{{\color{blue} (2, -5, -1, 0, 0, 0)}, {\color{blue} (3, 2, 0, -1, 0, 0)} \}$.

We use this idea to produce the code for the nullspace below:

In [48]:
def remove_zero_rows(A, precision=1e-9):
    """
    Remove rows of zeros.
    """
    # produce a "mask": True for non-zero entries, False for zero entries
    TF = np.abs(A) > precision

    # return rows where we have a True entry
    return A[np.any(TF, axis=1)]


def find_leaing_ones(E, precision=1e-9):
    """
    Given matrix E in reduced row echelon form, returns a list with the positions of leading ones.
    """
    # produce a "mask": True for non-zero entries, False for zero entries
    TF = np.abs(E) > precision

    # drop rows with only False
    TF = TF[np.any(TF, axis=1)]

    res = []
    for i, row in enumerate(TF):
        # argmax gives the index/argument for first True (leading 1 position) of the row
        res.append((i, np.argmax(row)))
    return res


def rank(A, precision=1e-9):
    """
    Rank of a given matrix A.
    """
    return len(find_leaing_ones(rref(A, precision=precision), precision=precision))


def nullity(A, precision=1e-9):
    """
    Nullitty of given matrix A.
    """
    return A.shape[1] - rank(A, precision=precision)


def row_space(A, precision=1e-9):
    """
    Given a matrix A, reutrns the basis of the row space of A, as rows of a matrix.
    """
    return remove_zero_rows(rref(A, precision=precision), precision=precision)


def column_space(A, precision=1e-9):
    """
    Given a matrix A, reutrns the basis of the column space of A, as *rows* of a matrix.
    """
    return row_space(A.T, precision=precision)


def nullspace(A, precision=1e-9):
    """
    Given A, returns a basis for the nullspace as a matrix.
    """
    E = remove_zero_rows(rref(A, precision=precision), precision=precision)

    n_rows, n_cols = E.shape
    if n_rows == n_cols:
        # E should be the identity, so the nullspace is trivial.
        return []


    # Negative of identity.  We'll replace the rows with rows from E when appropriate.
    I = -np.identity(n_cols)

    # keep track of rows of E
    E_row = 0

    # columns with leading 1's
    lead_cols = [x[1] for x in find_leaing_ones(E)]
    for i, row in enumerate(I):
        if i in lead_cols:
            # if we have leading 1, use E's row
            I[i] = E[E_row]
            E_row += 1

    # set a matrix of right dimensions for the result
    res = np.zeros((n_cols - n_rows, n_cols))

    # select the relevant columns (with "leading -1's")
    for i, col in enumerate([j for j in range(n_cols) if j not in lead_cols]):
        res[i] = I[:, col]

    # make the base "nice"
    return rref(res, precision=precision)

Let's test it:

In [49]:
A = np.arange(12).reshape(4, 3)
A
Out[49]:
array([[ 0,  1,  2],
       [ 3,  4,  5],
       [ 6,  7,  8],
       [ 9, 10, 11]])
In [50]:
row_space(A)
Out[50]:
array([[ 1.,  0., -1.],
       [ 0.,  1.,  2.]])
In [51]:
column_space(A)
Out[51]:
array([[ 1.,  0., -1., -2.],
       [ 0.,  1.,  2.,  3.]])
In [52]:
rank(A)
Out[52]:
2
In [53]:
nullspace(A)
Out[53]:
array([[ 1., -2.,  1.]])
In [54]:
nullity(A)
Out[54]:
1

Solving Numerical Linear Symbolically¶

We cannot solve system "symbolically" with NumPy. But one could use SymPy.

Solving Numerical Linear Using Matrices¶

We can also solve systems using matrices:

In [55]:
A = np.array([2, 8, 4, 2, 5, 1, 4, 10, -1], dtype=np.float64).reshape(3, 3)
u = np.array([2, 5, 1], dtype=np.float64)

If the matrix is invertible, then we can use la.solve:

In [56]:
la.solve(A, u)
Out[56]:
array([11., -4.,  3.])

It's basically equivalent to:

In [57]:
la.inv(A) @ u.T
Out[57]:
array([11., -4.,  3.])

But it does not work on other cases:

In [58]:
B = np.ones((2, 2))
v = np.array([1, 2], dtype=np.float64)
la.solve(B,v)
---------------------------------------------------------------------------
LinAlgError                               Traceback (most recent call last)
Cell In[58], line 3
      1 B = np.ones((2, 2))
      2 v = np.array([1, 2], dtype=np.float64)
----> 3 la.solve(B,v)

File ~/.local/lib/python3.11/site-packages/numpy/linalg/linalg.py:409, in solve(a, b)
    407 signature = 'DD->D' if isComplexType(t) else 'dd->d'
    408 extobj = get_linalg_error_extobj(_raise_linalgerror_singular)
--> 409 r = gufunc(a, b, signature=signature, extobj=extobj)
    411 return wrap(r.astype(result_t, copy=False))

File ~/.local/lib/python3.11/site-packages/numpy/linalg/linalg.py:112, in _raise_linalgerror_singular(err, flag)
    111 def _raise_linalgerror_singular(err, flag):
--> 112     raise LinAlgError("Singular matrix")

LinAlgError: Singular matrix
In [59]:
w = np.array([1, 1], dtype=np.float64)
la.solve(B,v)
---------------------------------------------------------------------------
LinAlgError                               Traceback (most recent call last)
Cell In[59], line 2
      1 w = np.array([1, 1], dtype=np.float64)
----> 2 la.solve(B,v)

File ~/.local/lib/python3.11/site-packages/numpy/linalg/linalg.py:409, in solve(a, b)
    407 signature = 'DD->D' if isComplexType(t) else 'dd->d'
    408 extobj = get_linalg_error_extobj(_raise_linalgerror_singular)
--> 409 r = gufunc(a, b, signature=signature, extobj=extobj)
    411 return wrap(r.astype(result_t, copy=False))

File ~/.local/lib/python3.11/site-packages/numpy/linalg/linalg.py:112, in _raise_linalgerror_singular(err, flag)
    111 def _raise_linalgerror_singular(err, flag):
--> 112     raise LinAlgError("Singular matrix")

LinAlgError: Singular matrix

Let's write our own again:

In [64]:
def solve(A, v, precision=1e-9):
    """
    Given a matrix A and vector v (or compatible sizes), solve for A * x = b.
    It gives back a tuple (x, M), where x is a particular solution, and M is a
    matrix that has as rows a basis for the nullspace of A.
    """
    n_rows, n_cols = A.shape

    # create the augmented matrix
    B = np.zeros((n_rows, n_cols + 1))
    B[:, :-1] = A
    B[:, -1] = v.T

    # put it in RREF and  split it back
    E = rref(B, precision=precision)
    AA = E[:, :-1]
    vv = E[:, -1]

    # check for inconsistency
    for row, value in zip(AA, vv):
        # True if zero, False if not
        TF = np.abs(row) < precision
        if np.all(TF) and (np.abs(value) > precision):
            return (None, [])

    # positions of leading ones
    particular_sol = np.zeros(n_cols)
    lead_cols = [x[1] for x in find_leaing_ones(AA, precision=precision)]

    # find particular solution
    for j in range(n_cols):
        if j in lead_cols:
            particular_sol[j] = vv[j]

    # return result (with nullspace)
    return (particular_sol, nullspace(AA, precision=precision))

Let's use to solve the same $A \cdot \vec{x} = \vec{u}$ above.

In [61]:
solve(A, u)
Out[61]:
(array([11., -4.,  3.]), [])

Here is an example with no solution (same $B \cdot \vec{x} = \vec{v}$ above):

In [62]:
solve(B, v)
Out[62]:
(None, [])

Here is an example with infinitely many solution (same $B \cdot \vec{x} = \vec{w}$ above):

In [63]:
solve(B, w)
Out[63]:
(array([1., 0.]), array([[ 1., -1.]]))