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:
import numpy as np
import numpy.linalg as la
Let's create the matrix $$ A = \begin{bmatrix} 2 & 8 & 4 \\ 2 & 5 & 1 \\ 4 & 10 & -1 \end{bmatrix} $$
A = np.array([[2, 8, 4], [2, 5, 1], [4, 10, -1]]) # enter a list of rows (each row also a list)
A
array([[ 2, 8, 4],
[ 2, 5, 1],
[ 4, 10, -1]])
We can also do:
A = np.array([2, 8, 4, 2, 5, 1, 4, 10, -1]).reshape(3,3) # reshape as 3 by 3
A
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
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
array([[ 2., 8., 4.],
[ 2., 5., 1.],
[ 4., 10., -1.]])
We can create the identity matrix of any dimension:
np.identity(4)
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:
np.identity(4, dtype=int)
array([[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]])
Similarly for zero matrices:
np.zeros((3, 4))
array([[0., 0., 0., 0.],
[0., 0., 0., 0.],
[0., 0., 0., 0.]])
np.zeros((3, 4), dtype=int)
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]$:
np.random.rand(3, 4)
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]$:
min_val = -2
max_val = 3
(max_val - min_val) * np.random.rand(3, 4) + min_val
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$:
np.random.randint(-10, 21, size=(3, 4))
array([[ 8, 4, 1, 10],
[11, 18, 3, 10],
[12, 10, 19, 20]])
Python has complex numbers. The imaginary number $i$ is represented by 1j:
1j ** 2
(-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:
3^2
1
3 ** 2
9
(If you care, ^ is used for the bitwise "XOR", or exclusive or):
(True ^ True,
True ^ False,
False ^ True,
False ^ False)
(False, True, True, False)
So, we can make matrices such as:
A = np.array([1j/2, 0, 1/4 + 3*1j, -3]).reshape(2, 2)
A
array([[ 0. +0.5j, 0. +0.j ],
[ 0.25+3.j , -3. +0.j ]])
We can use dtype=complex to force complex data type:
A = np.arange(12, dtype=complex).reshape(3, 4)
A
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]])
We can add matrices:
A = np.random.randint(-10, 10, size=(2,3))
B = np.random.randint(-10, 10, size=(2,3))
A
array([[ 8, 6, 1],
[-6, -6, 4]])
B
array([[ 3, 1, 8],
[ 7, 4, -9]])
A + B
array([[11, 7, 9],
[ 1, -2, -5]])
We can also do scalar multiplication:
5 * A
array([[ 40, 30, 5],
[-30, -30, 20]])
We can multiply matrices, but we need to use @, and not *!
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)
A
array([[ 2, 8, 4],
[ 2, 5, 1],
[ 4, 10, -1]])
B
array([[1, 2],
[0, 1],
[1, 1]])
A @ B
array([[ 6, 16],
[ 3, 10],
[ 3, 17]])
The * is used for entrywise multiplication!
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)
C = np.arange(9, dtype=int).reshape(3, 3)
C
array([[0, 1, 2],
[3, 4, 5],
[6, 7, 8]])
A * C
array([[ 0, 8, 8],
[ 6, 20, 5],
[24, 70, -8]])
We can transpose:
A.T
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):
la.det(A)
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:
la.inv(A)
array([[-0.83333333, 2.66666667, -0.66666667],
[ 0.33333333, -1. , 0.33333333],
[ 0. , 0.66666667, -0.33333333]])
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:
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:
A = np.array([2, 8, 4, 2, 5, 1, 4, 10, -1]) .reshape(3, 3)
A
array([[ 2, 8, 4],
[ 2, 5, 1],
[ 4, 10, -1]])
rref(A)
array([[ 1., 0., 0.],
[ 0., 1., 0.],
[-0., -0., 1.]])
We don't have "explicit" vectors, as with Sage, but we can still have one-dimensional arrays.
v = np.array([1, 2, 3])
We can transpose to multiply on the right:
A @ v.T
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:
v @ A
array([18, 48, 3])
We can perform row operations.
Remember that Python starts indexing from $0$, not $1$
A = np.arange(9).reshape(3, 3)
A
array([[0, 1, 2],
[3, 4, 5],
[6, 7, 8]])
Let's add $-3$ times the first row to the second:
A[1] += -3 * A[0] # or A[1] = A[1] - 3 * A[0]
A
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$:
A[0] *= 5
A
array([[ 0, 5, 10],
[ 3, 1, -1],
[ 6, 7, 8]])
Let's swap the first and third rows:
A[[0, 2]] = A[[2, 0]]
A
array([[ 6, 7, 8],
[ 3, 1, -1],
[ 0, 5, 10]])
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.
A = np.arange(12).reshape(3, 4)
A
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
B = np.arange(2, 14).reshape(3, 4)
B
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.
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)
True
So, $A$ and $B$ are row equivalent.
Let's check another matrix:
C = (np.arange(12) ** 2).reshape(3, 4)
C
array([[ 0, 1, 4, 9],
[ 16, 25, 36, 49],
[ 64, 81, 100, 121]])
precision = 1e-9
np.all(np.abs(rref(A) - rref(C)) < precision)
False
So $A$ and $C$ are not row equivalent.
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:
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:
A = np.arange(12).reshape(4, 3)
A
array([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11]])
row_space(A)
array([[ 1., 0., -1.],
[ 0., 1., 2.]])
column_space(A)
array([[ 1., 0., -1., -2.],
[ 0., 1., 2., 3.]])
rank(A)
2
nullspace(A)
array([[ 1., -2., 1.]])
nullity(A)
1
We cannot solve system "symbolically" with NumPy. But one could use SymPy.
We can also solve systems using matrices:
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:
la.solve(A, u)
array([11., -4., 3.])
It's basically equivalent to:
la.inv(A) @ u.T
array([11., -4., 3.])
But it does not work on other cases:
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
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:
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.
solve(A, u)
(array([11., -4., 3.]), [])
Here is an example with no solution (same $B \cdot \vec{x} = \vec{v}$ above):
solve(B, v)
(None, [])
Here is an example with infinitely many solution (same $B \cdot \vec{x} = \vec{w}$ above):
solve(B, w)
(array([1., 0.]), array([[ 1., -1.]]))