A Short Supplementary NumPy Tutorial
Geometric Algorithms
Boston University
Fall 2023
Table of Contents
NumPy is a Python library for scientific computing. This means different things to different people, but in the context of CS132, NumPy is a library for doing linear algebra in Python.
One thing I want to emphasize: for the things we do in CS132, we don't have to use NumPy. We could in principle implement everything that we need in vanilla Python using 2D Python lists (i.e., lists of lists). There isn't much to learn about NumPy except the names of functions and a couple conventions. That is to say: if you know how to program in Python using 2D lists, then the jump to using NumPy is a small one.
Why, then, do we use NumPy?
- Even though we could write all the functions we need ourselves, it's nice when we don't have to.
- Even if we did write all the functions we need ourselves, our implementations would almost certainly not be as fast as the ones in the NumPy library.
- Its a useful skill to have since its a widely used library.
Importing the Library
Regardless, let's start from the beginning. As with any Python
library, we access the functions in it using an import
statement.
import numpy
It's generally good practice to only import the functions you need,
but NumPy is quite large (and has some cases of name clashes) so it's
typical to bind the name numpy
to something shorter:
import numpy as np
We can then refer to any function from the NumPy library in our own
code via dot notation: np.useful_lin_alg_function()
.
NumPy Arrays
The fundamental object of NumPy is the ndarray
. The nd
part refers
to "n-dimensional." We will only ever consider 1D or 2D arrays, but
NumPy can support any number of dimensions (e.g., if a matrix is a
rectangle of numbers, a 3D array is a rectangular prism of
numbers).
ndarray
objects are similar to Python lists, and it's common to
construct ndarray
objects from Python lists using the constructor
array
:
np.array([1, 2, 3, 4, 5])
They differ quite a bit from Python lists internally, but as we will use them, they really only differ in some of their properties and operations, which we will cover throughout this tutorial.
We will be constructing almost exclusively 1D and 2D ndarray
objects
(which I will call NumPy arrays moving forward). A 1D NumPy array can
be constructed from a 1D Python list as above. A 2D NumPy array can be
constructed from a 2D Python list (i.e., a list of lists).
np.array([[1, 2], [3, 4], [5, 6]])
Since NumPy arrays are optimized for use in linear algebra, it's not possible to construct a NumPy array from a Python list that cannot be interpreted as a matrix, i.e., when one of it's elements is not a list, or not the same length as the others.
try: a = np.array([1, [2, 3], [4, 5]]) except: print("No dice") try: a = np.array([[1, 2], [3, 4], [5, 6, 7]]) except: print("No dice")
We can use the ndim
property to determine the dimension an NumPy array.
a = np.array([1, 2, 3, 4, 5, 6]) b = np.array([[1, 2], [3, 4], [5, 6]]) assert(a.ndim == 1) assert(b.ndim == 2)
Roughly speaking, we will use 1D numpy arrays to represent (column) vectors and 2D numpy arrays to represent matrices. This is often confusing at first because we get used to thinking of column vectors as a special kind of matrix, and because 1D numpy arrays are displayed horizontally (as a row) instead of vertically (as a column). This is something we have to come to terms with.
Shape
NumPy arrays have a property called shape
which tells us the number
of entries of an array, in the case of a 1D arrays, and the number of
rows and columns (presented as a tuple) in the case of 2D arrays.
a = np.array([1, 2, 3, 4, 5, 6]) b = np.array([[1, 2], [3, 4], [5, 6]]) assert(a.shape == (6,)) assert(b.shape == (3, 2))
So if we want to represent an m x n
matrix, then we need to
construct a 2D numpy array with the shame (m, n)
. Note that the
shape of a 1D array is a 1-element tuple, so if we interpret
a.shape[0]
to be the number of its rows, as is the case for 2D
arrays, then really a 1D array represents a column.
I find it useful to keep around functions for the number of rows and columns of NumPy arrays; it can help quite a bit with readability.
a = np.array([[1, 2], [3, 4], [5, 6]]) num_of_rows = lambda a: a.shape[0] num_of_cols = lambda a: a.shape[1] assert(num_of_rows(a) == 3) assert(num_of_cols(a) == 2)
Operations
Many of the operations on vectors and matrices that we discuss in this course are built into the NumPy library. For example, addition and scaling for vectors and matrices are done via the usual binary operators.
a = np.array([[1, 2], [3, 4], [5, 6]]) b = np.array([[1, 1], [1, 1], [1, 1]]) assert(np.array_equal(a + b, np.array([[2, 3], [4, 5], [6, 7]]))) assert(np.array_equal(2 * b, np.array([[2, 2], [2, 2], [2, 2]])))
In particular, these operations are different than addition and multiplication of Python lists.
a = [[1, 2], [3, 4], [5, 6]] b = [[1, 1], [1, 1], [1, 1]] assert(a + b == [[1, 2], [3, 4], [5, 6], [1, 1], [1, 1], [1, 1]]) assert(2 * b == [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]])
An Aside. Note the use of np.array_equal
above for comparing two
arrays for equality. This should be used cautiously; we often are not
interested if two arrays are equal, but if they are sufficently close
to each other. For this we can use np.allclose
.
x = 0.3 y = 0.1 + 0.1 + 0.1 a = np.array([x, x, x]) b = np.array([y, y, y]) assert(not np.array_equal(a, b)) assert(np.allclose(a, b))
Another important thing to note here is the *
operator does not
implement matrix multiplication as we have discussed it. Rather, it
implements entry-wise multiplication, similar to addition. Standard
matrix multiplication is done via operator @
. And when applied to 1D
arrays, it is the standard dot product.
a = np.array([[1, 2], [3, 4]]) b = np.array([1, 1, 1, 1]) assert(np.array_equal(a * a, np.array([[1, 4], [9, 16]]))) assert(np.array_equal(a @ a, np.array([[7, 10], [15, 22]]))) assert(b @ b == 4)
Indexing
The simplest and most common operation on NumPy arrays is accessing their elements. We can index them in the same way we index Python lists. In particular, we access entries in a 1D array in exactly the same way we would Python lists, but there are a couple special indexing tricks for 2D arrays that we will use frequently.
We can access an individual row of a 2D array by indexing it as we normally would if it were a 2D Python list. The caveat is that a row is represented as a 1D array, which we have already noted represents a column. This is a feature, not a bug, because we often want to use a row as a column vector in later calculations.
a = np.array([[1, 2], [3, 4]]) r1 = a[0] # note the zero-indexing! assert(np.array_equal(r1, np.array([1, 2])))
We can index with tuples to access individual entries, as opposed to double indexing (though both work).
a = np.array([[1, 2], [3, 4]]) assert(a[1, 1] == 4) assert(a[1][1] == 4)
We can access columns using slicing, a commonly used tool for Python lists. The trick is that we can include a slice in a tuple that we use for indexing. So to get a column, we use the slide
:
as the first entry of the tuple we use for indexing. This says that we want the first entry of the tuple to range over all possible elements.a = np.array([[1, 2], [3, 4]]) assert(np.array_equal(a[:,0], np.array([1, 3])))
We can access multiple columns or multiple rows at a time either by indexing on lists or by slicing as above. The first is useful if we want to rearrange rows or columns. The second is useful if we want to grab a chunk of an array.
a = np.array([[1, 2, 3], [4, 5, 6]]) b = np.array([[4, 5, 6], [1, 2, 3]]) c = np.array([[4, 5], [1, 2]]) a[[0, 1]] = a[[1, 0]] assert(np.array_equal(a, b)) assert(np.array_equal(b[:,:-1], c))
In the second example, we're indexing b at a tuple of slices which says "all possible first values and all but the last possible second value." This will give you all of the columns except the last one.
Stacking
A technique we have used several times, but which has been hidden from
you behind helper functions, is stacking. We commonly need to add a row
or a column to a matrix or combine to matrices into a single
matrix. We can do this via np.hstack
and np.vstack
, which take
tuples of arrays and stacks them either horizontally or vertically
(per the name of the function). One important thing to keep in mind,
and which will be a challenge in becoming comfortable with these
functions, is that they must be applied to arrays of the same
dimension, and the sides along which we are stacking must coincide. In
particular, we cannot stack a 1D array on a 2D array, but we can
make a 1D array into a 2D array and then stack it. Here is an example
in which we horizontally stack a vector to the right of a matrix,
something we often do if we want to create an augmented matrix out of
a matrix equation.
a = np.array([[1, 2], [3, 4]]) b = np.array([5, 6]) c = np.array([[1, 2, 5], [3, 4, 6]]) b.shape = (2, 1) # we can change the shape to make it 2D assert(np.array_equal(np.hstack((a, b)), c))
Closing Remarks on Arrays
NumPy arrays are not copied on reassignment. This is something we just have to remember. If we want a copy of an array, we can use the function
np.copy
. There are other more efficient ways to manage array copying, but this will be sufficient for now.a = np.array([1, 2, 3, 4, 5]) b = a c = np.copy(a) a[0] = -1 assert(b[0] == -1) assert(c[0] == 1)
- NumPy arrays require that all of their entries are the same
type. When you construct a NumPy array, it will perform conversions
in order to make this the case. We've seen this in passing when
we've set one of the entries of a matrix to a
float
so that every entry is converted to a float. We can use the propertydtype
to determine the type of entries in an array. Except in a few outstanding circumstances, we will be using arrays with floating point entries. - NumPy array can be iterated over just like Python lists. If we want to iterate over the rows of a matrix, we just have to iterative over the elements of the array (a matrix is represented as an array of its rows). If we want to iterative over its columns we can iterative over the rows of its transpose (see below).
Cheat Sheet
There many useful functions in NumPy that cannot be put into a single short tutorial. As we close this out, here is a list of functions that may be worth frequently referring to. It's not exhaustive, but hopefully is a starting point. Eventually, you will have to read the docs yourself to learn more.
Function/Property | Description |
---|---|
np.array(l) |
NumPy array constructor |
a.ndim |
property for dimension of an array |
a.shape |
property for rows and column of an array |
a.dtype |
property for type of entries of an array |
np.copy(a) |
array copying |
a + b |
matrix/vector addition |
a * b |
matrix/vector scaling and entry-wise multiplication |
a @ b |
matrix/vector multiplication and dot product |
a.T |
transpose |
np.array_equals(a, b) |
array equality (use cautiously) |
np.allclose(a, b) |
array equality up to default tolerance |
a[i] |
row indexing |
a[:,j] |
column indexing |
a[i, j] |
entry indexing |
a[[i1, i2, i3]] |
multi-row indexing |
a[:,slice] |
column sequence indexing |
np.hstack((a, b, c)) |
horizontal matrix stacking |
np.vstack((a, b, c)) |
vertical matrix stacking |
np.zeros(shape) |
zero array |
np.ones(shape) |
all ones array |
np.eye(n) |
n x n identity matrix |
np.linalg.solve(a, b) |
solve a square system with a unique solution |
np.linalg.inv(a) |
matrix inverse |