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?

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 property dtype 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