The Numpy Package#

Numpy, short for Numerical Python, is a fundamental package for scientific computing in Python. It provides efficient data structures and functions for working with multi-dimensional arrays and matrices. It also provides an interface to a large set of mathematical functions that can perform computations on arrays of data.

Another key feature of Numpy is that it interfaces with low-level languages like C and FORTRAN, making it much more efficient than raw Python code. If optimized correctly, code written with Numpy can execute almost as fast as code written in these low-level languages.

Numpy Arrays#

The most important feature of the numpy package is that is supports array-based programming, where data is organized into multi-dimensional arrays and matrices. To construct a Numpy array, we call np.array on a Python list (or nested Python lists) as follows:

import numpy as np

np_array = np.array([1,2,3,4])

print(np_array)
print(type(np_array))
[1 2 3 4]
<class 'numpy.ndarray'>

Numpy arrays are technically instances of the numpy.ndarray class, which has an instance variable shape that stores a tuple representing the shape of the array. The length of shape corresponds to the number of dimensions of the array, while the product of the elements in shape corresponds to the total number of elements in the array:

# create a 1D array:
x = np.array([1.0, 2.0, 3.0, 4.0])
print(x)
print(x.shape)

# create a 2D array (matrix):
X = np.array([
    [1,2,3],
    [4,5,6],
    [7,8,9]
])
print(X)
print(X.shape)
[1. 2. 3. 4.]
(4,)
[[1 2 3]
 [4 5 6]
 [7 8 9]]
(3, 3)

When organizing data, sometimes it is necessary to change the shape of the array. This can be done with the reshape method:

# print original shape of X:
print('X shape:', X.shape)

# reshape X to a 1D array:
X2 = X.reshape((9,))
print('X reshaped to (9,):')
print(X2)
print(X2.shape)

# reshape X to a 1x9 array:
X3 = X.reshape((1,9))
print('X reshaped to (1,9):')
print(X3)
print(X3.shape)

# reshape X to a 9x1 array:
X4 = X.reshape((9,1))
print('X reshaped to (9,1):')
print(X4)
print(X4.shape)
Hide code cell output
X shape: (3, 3)
X reshaped to (9,):
[1 2 3 4 5 6 7 8 9]
(9,)
X reshaped to (1,9):
[[1 2 3 4 5 6 7 8 9]]
(1, 9)
X reshaped to (9,1):
[[1]
 [2]
 [3]
 [4]
 [5]
 [6]
 [7]
 [8]
 [9]]
(9, 1)

Numpy also provides some functions that can easily build arrays of of different sizes. Perhaps the most useful of these is the np.linspace function, which constructs a grid of evenly spaced points.

# construct a 3x3 identity (i.e. I or "eye") matrix:
print(np.eye(3))

# construct an array of ones:
print(np.ones((2,3)))

# construct an array of zeros:
print(np.zeros((3,5)))

# construct an array of 11 equally spaced points from 0 to 1:
x = np.linspace(0.0, 1.0, 11)
print(x)

# construct an array of ones/zeros with the same shape as x:
x_ones = np.ones_like(x)
x_zeros = np.zeros_like(x)
print(x_ones)
print(x_zeros)
[[1. 0. 0.]
 [0. 1. 0.]
 [0. 0. 1.]]
[[1. 1. 1.]
 [1. 1. 1.]]
[[0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0.]]
[0.  0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]

Indexing Numpy Arrays#

Numpy also has some built-in syntax for accessing and modifying elements in arrays. This is similar to Python lists, but much more powerful. Below, we give some examples of how Numpy array indexing works:

X = np.array(range(1,10)).reshape((3,3))
print(X)

# access row 0:
print('Accessing X[0]:')
print(X[0])

# access row 0, column 2:
print('Accessing X[0,2]:')
print(X[0,2])

# access column 0:
print('Accessing X[:,0]:')
print(X[:,0])
[[1 2 3]
 [4 5 6]
 [7 8 9]]
Accessing X[0]:
[1 2 3]
Accessing X[0,2]:
3
Accessing X[:,0]:
[1 4 7]

We can also modify the values of an array as follows:

# create an array of zeros:
Z = np.zeros((3,3))
print('Before:')
print(Z)

# modify index (1,1)
Z[1,1] = 1.0

# set column 0 to be all 2s:
Z[:,0] = 2.0

# set row 2 to be the following values:
Z[2] = np.array([3.0, 4.0, 5.0])

# print resulting array:
print('After:')
print(Z)
Before:
[[0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]]
After:
[[2. 0. 0.]
 [2. 1. 0.]
 [3. 4. 5.]]

Operations on Numpy Arrays#

When we apply mathematical operations (i.e. +,-,*, etc.) to Numpy arrays with a compatible shape, the result is a Numpy array where the operation is performed elementwise. For example:

x1 = np.array([1,2,3])
x2 = np.array([4,5,6])

# all math operations are elementwise:
print(x1 + x2)
print(x1 - x2)
print(x1 * x2)

# scalar operations are also elementwise:
print('\nScalar operations:')
print(x1 - 1)
print(x1 * 2)
print(x1**2)
print(np.sqrt(x1))
print(-x1)
[5 7 9]
[-3 -3 -3]
[ 4 10 18]

Scalar operations:
[0 1 2]
[2 4 6]
[1 4 9]
[1.         1.41421356 1.73205081]
[-1 -2 -3]

Note

In order for an operation to be applied to two Numpy arrays, the shape of one array must be broadcastable to the other. Arrays with the same shape are always broadcastable. To learn more about what broadcastable means, see the Numpy tutorial on broadcasting.

Reducing Operations:#

Numpy supports reduction operations such as min, max, mean and sum, which can be applied to all elements in an array or only along a specified dimension. For example:

# create example matrix:
X = np.array(range(9)).reshape(3,3)
print('X array:')
print(X)

# compute the mean of all elements:
print(np.mean(X))

# compute the mean along each column:
print(np.mean(X, axis=0))

# compute the sum along each row:
print(np.sum(X, axis=1))

# compute the min/max values:
print(np.min(X), np.max(X))
X array:
[[0 1 2]
 [3 4 5]
 [6 7 8]]
4.0
[3. 4. 5.]
[ 3 12 21]
0 8

Matrix Operations#

For 2D Numpy array that represents a matrix, Numpy also supports many different operations in its linalg package. For example:

# generate a matrix:
A = np.array(range(1,10)).reshape(3,3)
print(A)

# generate a diagonal matrix:
X = np.diag([1,10,100])
print(X)

# matrix transpose:
print(A.T)

# matrix inverse:
print(np.linalg.inv(X))
Hide code cell output
[[1 2 3]
 [4 5 6]
 [7 8 9]]
[[  1   0   0]
 [  0  10   0]
 [  0   0 100]]
[[1 4 7]
 [2 5 8]
 [3 6 9]]
[[1.   0.   0.  ]
 [0.   0.1  0.  ]
 [0.   0.   0.01]]

Important

If you are in need of a review of matrices, vectors, and other linear algebra content, see the Mathematics Review Section.

We can also compute matrix-matrix products, matrix-vector products, and vector-vector products with the reserved @ operator:

# compute a matrix-matrix product:
print('A @ X:')
print(A @ X)

# compute a matrix-vector product:
b = np.array([1,10,100])
print('A @ b:')
print(A @ b)

# compute a vector dot product (inner product):
print('b dot b:')
print(np.dot(b,b))

# compute vector outer product:
print('b outer b:')
print(np.outer(b,b))

# compute vector norm:
print('|b|:')
print(np.linalg.norm(b))
Hide code cell output
A @ X:
[[  1  20 300]
 [  4  50 600]
 [  7  80 900]]
A @ b:
[321 654 987]
b dot b:
10101
b outer b:
[[    1    10   100]
 [   10   100  1000]
 [  100  1000 10000]]
|b|:
100.50373127401788

Some other useful matrix operators are supported, such as solvers for eigenvalues, eigenvectors, and determinants. Kronecker products are also supported:

# eigenvalue decomposition of a square matrix:
# (if A is symmetric, use linalg.eigh instead)
eigvals, eigvects = np.linalg.eig(A)
print(eigvects)
print(eigvals)

# matrix determinant:
print(np.linalg.det(A))

# Kronecker (tensor) product:
np.kron(A,np.diag([1,10]))
Hide code cell output
[[-0.23197069 -0.78583024  0.40824829]
 [-0.52532209 -0.08675134 -0.81649658]
 [-0.8186735   0.61232756  0.40824829]]
[ 1.61168440e+01 -1.11684397e+00 -1.30367773e-15]
0.0
array([[ 1,  0,  2,  0,  3,  0],
       [ 0, 10,  0, 20,  0, 30],
       [ 4,  0,  5,  0,  6,  0],
       [ 0, 40,  0, 50,  0, 60],
       [ 7,  0,  8,  0,  9,  0],
       [ 0, 70,  0, 80,  0, 90]])

Exercises#

Exercise 1: Solving a Linear System

Write some Python that solves for the vector \(\mathbf{x}\) in the matrix equation \(\mathbf{A}\mathbf{x} = \mathbf{b}\), where \(\mathbf{A}\) is a square matrix stored in a Numpy array A and \(\mathbf{b}\) is a vector stored in a Numpy array b. Verify that \(\mathbf{x}\) is the correct solution by computing \(\mathbf{A}\mathbf{x}\) and comparing the result with \(\mathbf{b}\).


Hint: Recall from linear algebra that the solution to a linear system is \(\mathbf{x} = \mathbf{A}^{-1}\mathbf{b}\), so you may need to use the np.linalg.inv function.

Exercise 2: Eigendecomposition:

Here’s an exercise for the physicists:

Any square matrix \(\mathbf{A}\) that is non-defective can be written in the form:

\[\mathbf{A} = \mathbf{P} \Lambda \mathbf{P}^{-1}\]

where \(\Lambda\) is a diagonal matrix containing the eigenvalues of \(\mathbf{A}\), and \(\mathbf{P}\) is a matrix whose columns are the corresponding eigenvectors of \(\mathbf{A}\). When \(\mathbf{A}\) is factorized in this form it is called an eigendecomposition, and it is an important result that is often used in physics and quantum mechanics. Do the following:

First, solve for \(\mathbf{P}\) and \(\Lambda\) using np.linalg.eig for the following matrix:

\[\begin{split}\mathbf{A} = \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix}\end{split}\]

Using the @ operator, verify numerically that the identity above holds. Does the largest entry of \(\Lambda\) look familiar?

Next, do the same thing, but with the matrix:

\[\begin{split}\mathbf{H} = \begin{bmatrix} 0 & 1 & 0\\ 1 & 0 & 1 \\ 0 & 1 & 0 \end{bmatrix}\end{split}\]

Since \(\mathbf{H}\) is symmetric (more generally, Hermitian), use np.linalg.eigh instead of np.linalg.eig to get better numerical precision.

Solutions#

Exercise 1: Solving a Linear System:#

Hide code cell content
# dimension of the system:
N = 4

# generate a random  NxN matrix A:
A = np.random.normal(0,1,size=(N,N))

# generate a random 10x10 target vector b:
b = np.random.normal(0,1,size=N)

# print A and b:
print('A:')
print(A)
print('b:', b)

# solve for x:
x = np.linalg.inv(A) @ b

# print x:
print('x:', x)

# print Ax (for comparison with b):
print('Ax:', A @ x)
A:
[[-0.81020641 -1.10888892 -0.65139703  0.06359388]
 [ 0.28866345 -1.46287924  0.19778661  0.6243446 ]
 [-1.28755927 -0.15065755  1.01188253  1.0282345 ]
 [-0.88730439 -0.05901126  0.3026608  -1.007025  ]]
b: [-0.17142181  0.10082843 -1.10914809  0.62361016]
x: [ 0.39624422 -0.33053472  0.24760421 -0.87460991]
Ax: [-0.17142181  0.10082843 -1.10914809  0.62361016]

Exercise 2: Eigendecomposition:#

Hide code cell content
#------------------------------------
# Part one:
#------------------------------------

# given matrix A:
A = np.array([ [1,1], [1,0] ])

# diagonalize A:
lambda_diag, P = np.linalg.eig(A)
Lambda = np.diag(lambda_diag)

# print P:
print('P:')
print(P)

# print Λ:
print('Λ:')
print(Lambda)

# convert the eigenvalues into a diagonal matrix:
Lambda = np.diag(lambda_diag)

print('P Λ P^(-1):')
print(P @ Lambda @ np.linalg.inv(P))


#------------------------------------
# Part Two:
#------------------------------------
print('----------------------------')

# given matrix X:
X = np.array([
    [ 0, 1, 0 ],
    [ 1, 0, 1 ],
    [ 0, 1, 0 ]
])

# diagonalize X using eigh (for Hermitian matrices):
lambda_diag, P = np.linalg.eigh(X)
Lambda = np.diag(lambda_diag)

# print P:
print('P:')
print(P)

# print Λ:
print('Λ:')
print(Lambda)

# convert the eigenvalues into a diagonal matrix:
Lambda = np.diag(lambda_diag)

print('P Λ P^(-1):')
print(P @ Lambda @ np.linalg.inv(P))
P:
[[ 0.85065081 -0.52573111]
 [ 0.52573111  0.85065081]]
Λ:
[[ 1.61803399  0.        ]
 [ 0.         -0.61803399]]
P Λ P^(-1):
[[ 1.00000000e+00  1.00000000e+00]
 [ 1.00000000e+00 -1.04608535e-16]]
----------------------------
P:
[[-5.00000000e-01  7.07106781e-01  5.00000000e-01]
 [ 7.07106781e-01  1.04083409e-16  7.07106781e-01]
 [-5.00000000e-01 -7.07106781e-01  5.00000000e-01]]
Λ:
[[-1.41421356e+00  0.00000000e+00  0.00000000e+00]
 [ 0.00000000e+00  1.04083409e-17  0.00000000e+00]
 [ 0.00000000e+00  0.00000000e+00  1.41421356e+00]]
P Λ P^(-1):
[[ 2.29934717e-17  1.00000000e+00  1.01498095e-16]
 [ 1.00000000e+00 -2.48983133e-16  1.00000000e+00]
 [ 3.00549228e-16  1.00000000e+00  3.79053851e-16]]