Figure 8.1: The feeling you get when something is 100x faster AND you wrote less code.
Python lets us put objects of different types in the same list. Operations on those objects depend on their types, so the same operator can have different meanings. Consider the following case:
1a = [1, 1.0, '1', 'one', True, 1+0j]for item in a:2 b = item*2print(b)
1
We have a collection where every item has a different type
2
The item*2 operation depends on the type of item: for example, numbers are multiplied while strings are repeated. Python must select and execute the appropriate operation for each object.
2
2.0
11
oneone
2
(2+0j)
Processing millions of values one at a time in Python can be slow. The overhead includes interpreting the loop, selecting operations, and accessing individual Python objects. NumPy can store numerical values compactly and process whole arrays using compiled code, avoiding much of this per-element Python overhead.
Example 8.1 (Invert an image) Given the greyscale image below, invert the colors so that dark becomes light, and vice-versa.
Figure 8.2: Smiley in black and white, aka greyscale.
Solution
For this image, each greyscale intensity is an 8-bit integer between 0 (black) and 255 (white). The JPEG file stores three equal color channels; selecting one gives a 2D array of intensities. Other image formats can use different ranges and data types. We can inspect a small piece of the array:
import matplotlib.pyplot as plt1im = plt.imread("media/smiley.jpg")[..., 0]2print(im[200:210, 200:210])
1
The imread function fetches the image file and returns it as a numpy array, which we’ll learn about below.
2
Here we take a “slice” of the array to inspect the values in a small portion.
To invert the greyscale values in im using traditional for-loops, we need to do 2 nested loops to scan along each row. Just for fun, let’s also record the time it takes to do this:
from time import perf_countertstart = perf_counter()im2 = im.copy()im_max =255for i inrange(im.shape[0]):for j inrange(im.shape[1]): im2[i, j] = im_max - im[i, j]tfinal = perf_counter() - tstartprint(f"This took {tfinal} seconds")
This took 0.08523916400000076 seconds
Now we can visualize the images one above the other to confirm we achieved our objective:
Note that indexing into this 2D numpy array is different than a 2D list.
To get the first element of a 2D list we use arr[0][0]…we index into the “outer” list to get access to the “inner” list, so it’s like chaining the indices.
To get the first element of a 2D numpy array we use arr[0, 0]. We access all the dimensions at the same time.
numpy is not included in Python. We need to install it separately. If we used Anaconda to get ourselves started, then we get numpy included (along with a few hundred other packages). So despite the fact that numpy is pretty much mandatory for any numerical computing, it is still a separate package with its own “interface” that we must learn.
The main feature of numpy is that it provides a data type known as an ndarray, or “ND-array” meaning “N-Dimensional Array”. This array looks and acts a lot like a list but has one key difference:
Every element in an ndarray is stored using the same data type, called its dtype.
For numerical dtypes, NumPy can select a compiled operation for the whole array instead of choosing a Python operation separately for each element. An advanced exception is dtype=object, which stores references to Python objects; those objects can have different types and do not provide the same numerical performance benefits.
Simply putting numbers into an array does not remove the overhead of a Python for-loop. To gain the performance benefit, we usually use NumPy’s array operations and functions to do the looping in compiled code.
The downside is that using numpy is like learning a language within a language.
NoteJust In Time Compilation (JIT)
Some numerical algorithms are easier to express with explicit loops. A package called Numba can compile supported Python functions into machine code, including functions with loops over numerical arrays. This is called “just in time” compilation. Compiled versions specialize to their argument types, and the initial compilation has a cost, so performance should be measured for the intended workload.
8.1.1 Data Type of an Array
The Python type of an array object is ndarray. Its dtype is a separate attribute that describes how its elements are stored:
import numpy as np1arr = np.array([1, 2, 3], dtype=int)2print(type(arr))3print(arr.dtype)
1
We can optionally specify dtype, in this case int. If omitted, np.array infers a dtype from its input: [1, 2, 3] gives an integer array, while [1, 2, 3.0] gives a floating-point array. Other constructors have their own defaults; for example, np.zeros defaults to float.
2
type(arr) identifies the array object as an ndarray.
3
arr.dtype describes its elements. The default integer width depends on the platform and NumPy version. A dtype of int64 uses 64 bits per signed integer, with a finite range from -2**63 to 2**63 - 1.
Counting in binary is just like using familiar decimal numbers, but there are only 2 symbols (0 and 1) instead of 10 (0, 1, 2, 3, etc). This means we run out of symbols more often, so need to increment the placeholder more often. For example:
Decimal
Binary
0
0
1
1
2
10
3
11
4
100
5
101
6
110
7
111
8
1000
Bits represent 0’s and 1’s, so the more bits we use, the larger the number we can store.
When using integers, more bits means we can store larger numbers since each new bit means more place holders
Floating-point formats divide their bits between a sign, an exponent, and significant binary digits. Wider formats generally offer more precision and a wider range; they do not simply add decimal places, and many decimal fractions still cannot be stored exactly.
For example, 14,013,403 vs 1.4013403 each have 8 digits. Adding more digits lets us either count higher or have more precision, depending on whether the number is an int or a float.
Why not just use the maximum number of bits? Using fewer bits means:
We use less memory to store the data, resulting in smaller files for instance
Calculations may be faster because less data needs to move through memory, but this depends on the hardware and operation; a smaller dtype is not always faster.
NumPy lets us control the number of bits used to store data. For example, we can confirm that these arrays occupy different amounts of space:
We can convert between dtypes using the astype() method. Converting these finite floats to integers truncates toward zero; it does not round to the nearest integer:
arr = np.array([1.9, -2.9, 3.0])integers = arr.astype(int)print(integers)print(arr) # astype created a new array; the original is unchanged
[ 1 -2 3]
[ 1.9 -2.9 3. ]
Choose a dtype that can represent the values you need. Unlike Python’s integers, NumPy’s fixed-width integers can overflow during arithmetic. Floating-point arrays also have finite precision and range; calculations can produce inf or nan. Use np.isfinite or np.isnan to check for these values when needed, rather than testing equality with np.nan.
8.1.2 Elementwise Operations
One of the ways that numpy lets us avoid the use of for-loops is by changing the way mathematical operations like + and * work.
Recall that for lists, multiplication by 2 doubled the length of the list:
arr = [1, 2, 3] *2print(arr)
[1, 2, 3, 1, 2, 3]
With numpy, multiplication does actual math!:
import numpy as nparr = np.array([1, 2, 3]) *2print(arr)
[2 4 6]
This is called “elementwise” operation because it operates on each element, rather than on the whole array.
numpy also allows for elementwise operations between 2 arrays:
Here arr1 and arr2 have the same shape, so corresponding elements are paired. Arrays with different shapes can also work if their shapes are compatible with broadcasting. Having the same total number of elements alone does not guarantee compatible shapes.
The familiar arithmetic operators work elementwise for these numerical arrays:
In all cases, each of the above lines performs the stated operation using element i of arr1 and element i of arr2.
Note
For numerical arrays, these operations run compiled loops chosen for the array dtypes. Avoiding a Python-level operation for every element can make them much faster.
Example 8.2 (Inverting an image using numpy) Let’s perform the same operation on “smiley” as in Example 8.1, but using numpy's elementwise mathematical features.
Solution
import matplotlib.pyplot as pltfrom time import perf_counterim = plt.imread("media/smiley.jpg")1im = np.array(im)[..., 0]tstart = perf_counter()2im3 =255- imtfinal2 = perf_counter() - tstartassert np.array_equal(im2, im3)print(f"This took {tfinal2} seconds")
1
Here we convert im to an ndarray for demonstration purposes only, because in fact an ndarray is returned by the imread function already.
2
Because im is an ndarray the - operation will use numpy's elementwise operations.
This took 0.0003006720000087171 seconds
Comments
The measured speed-up for this run was 283.496 times. The assertion checks that both approaches produced the same image. A single timing is only illustrative: speed-ups depend on array size, hardware, and other activity on the computer. A 100x speed-up would turn one hour into 36 seconds.
NoteFor-loops are OK sometimes
In the above example, elementwise operations avoided a Python loop over individual pixels. Prefer NumPy operations for this kind of calculation on a large array, and measure performance when it matters. It is also common to loop over a batch of images, using NumPy functions inside the loop, like this:
ims = [im1, im2, im3, im4, im5]mx = []for im in ims: mx.append(im.max())
The point is that looping over a small number of items and doing big calculations on each loop is fine, common, and usually unavoidable.
8.1.3 Array Properties
8.1.3.1 Attributes
When we looked at Python containers like lists and strings, we used methods attached to them. A method operates on an object, but its behaviour may differ from a similarly named function. For example, vals.sort() changes the original list and returns None, while sorted(vals) returns a new sorted list.
ndarray objects also have many methods. They provide other attributes that describe or give access to aspects of the array, such as size and shape. Some useful attributes are listed below:
Table 8.1: List of commonly used attributes on ndarrays
Attributes
Description
ndim
Number of dimensions of the array
size
Number of elements in the array
shape
The size of the array in each dimension
dtype
Data type of elements in the array
itemsize
The size (in bytes) of each element in the array
data
The buffer containing actual elements of the array in memory
T
View of the transposed array
flags
Information about memory layout and properties such as whether the array is writable
flat
A 1-D iterator over the array
imag
The imaginary part of the array
real
The real part of the array
nbytes
Total bytes consumed by the elements of the array
The shape, size and ndim attributes are especially useful when checking dimensions. Other attributes also have routine uses: dtype describes the stored values, T transposes an array, and real and imag access its real and imaginary components.
Attributes such as arr.size are accessed without calling them. Methods use parentheses and can perform operations or calculations, such as finding the maximum or minimum. The distinction is how we access them, rather than a general rule that attribute access never performs a computation.
Note that because these methods require an actual computation on the array, it is best to store their result if it needs to be used many times:
arr = np.array([4, 3, 6])amax = arr.max()
Table 8.2: List of methods on ndarrays which calculate some property based on the array contents
Method
Description
max
Return the maximum along a given axis.
min
Return the minimum along a given axis.
trace
Sums a diagonal across two axes; by default, the main diagonal of axes 0 and 1.
sum
Return the sum of the array elements over the given axis.
cumsum
Returns cumulative sums along an axis, or over the flattened array if no axis is given.
mean
Returns the average of the array elements along given axis.
var
Returns the variance of the array elements, along given axis.
std
Returns the standard deviation of the array elements along given axis.
prod
Returns the product of the array elements over the given axis
cumprod
Returns cumulative products along an axis, or over the flattened array if no axis is given.
all
Returns True if all elements evaluate to True.
any
Returns True if any elements evaluate to True.
Example 8.3 (Compute the cumulative sum of an array) Given a 1D array, compute the cumulative sum of all the values. Compare a for-loop with the ndarray method in terms of speed.
Solution
from timeit import repeatarr = np.random.randint(0, 1000, 10000)def cumulative_sum_loop(values): total =0 prefixes = []for val in values: total = total + val prefixes.append(total)return np.array(prefixes)csum_loop = cumulative_sum_loop(arr)csum_numpy = arr.cumsum()assert np.array_equal(csum_loop, csum_numpy)# Time ten calls per repeat, repeat five times, then report the best per-call time.tend1 =min(repeat(lambda: cumulative_sum_loop(arr), number=10, repeat=5)) /10print(f"The time required per call was {tend1} seconds")
The time required per call was 0.0013884635000010804 seconds
Now time ndarray.cumsum() on the same input:
tend2 =min(repeat(lambda: arr.cumsum(), number=10, repeat=5)) /10print(f"The time required per call was {tend2} seconds")
The time required per call was 9.541300002524622e-06 seconds
Using the NumPy method resulted in a measured speed-up of 145.521 for this input and environment.
Comments
Both approaches return every prefix total, not just the final sum. We checked that their outputs agree before timing them. Repeated measurements reduce some timing noise, but the speed-up is still specific to this workload and environment.
Reductions such as max, sum, and mean use all elements by default and return a single value. Giving one axis reduces along that axis and normally removes it from the result’s shape. For example, the mean along axis 0 of a (2, 3) array has shape (3,); keepdims=True retains the reduced axis with length 1, giving (1, 3).
The other methods in Table 8.2 have different shape rules. cumsum and cumprod return running totals or products, preserving the shape when an axis is specified and flattening the array when it is omitted. trace sums a diagonal across two axes, removing both from the output shape; for a 2D array it returns one value.
Example 8.4 (Analyze array along a given axis) You have weather data sampled hourly from hour 0 through hour 12, including both endpoints. Each column contains the time t (hours), temperature T (°C), or relative humidity RH (%). Find the mean of each column over these 13 observations.
t
T
RH
0
12
75
1
12
75
2
11
75
3
11
77
4
10
77
5
10
79
6
11
78
7
12
76
8
14
72
9
16
72
10
19
70
11
20
65
12
20
65
Solution
# After reading the data from CSV file we have the following:data = [[0, 12, 75], [1, 12, 75], [2, 11, 75], [3, 11, 77], [4, 10, 77], [5, 10, 79], [6, 11, 78], [7, 12, 76], [8, 14, 72], [9, 16, 72], [10, 19, 70], [11, 20, 65], [12, 20, 65]]1data = np.array(data)2aves = data.mean(axis=0)print(aves)
1
We need to convert to an ndarray first
2
Once we have an ndarray we can call the desired method with the axis as an argument
[ 6. 13.69230769 73.53846154]
Comments
By default axis=None, which finds the mean of all entries together. That would mix unrelated quantities and units. Using axis=0 keeps the columns separate; the first result is the mean observation time, and the other two are the mean temperature and relative humidity.
8.1.4 Multidimensional Arrays and Indexing
Multidimensional arrays are very common in numerical programming. Some examples are:
A greyscale image is a 2D array as we saw in Example 8.2.
An RGB color image can be represented by a 3D array. At each pixel, an extra axis contains the red, green, and blue channel values. For example, a 200-by-200 image has shape (200, 200, 3).
A greyscale video is a 3D array. Each moment in time (a frame) is a 2D array, and time is the third axis. Of course, if the video is color, this requires an extra dimension.
3D images are common in medical applications, such as x-ray tomography (CT scans) and MRI scans. These can be thought of as a stack of 2D slices.
Figure 8.3: Fluid flow simulation through a 3D image of a fibrous electrode
As “simple humans” we can visualize 1D, 2D and 3D images and arrays quite well, but not higher. Figure 8.4 shows this progression. It also includes the definition of array “shape” as well:
Figure 8.4: Schematic visualization of 1D, 2D and 3D arrays
Note
It is actually possible to use the “list of lists” style indexing with ndarrays, as shown below:
import numpy as nparr = np.random.rand(5, 5)print(arr[0][0])print(arr[0, 0])
0.7915783410000388
0.7915783410000388
But numpy offers several special features which require the [0, 0] style indexing, so it’s necessary to learn this. For instance, we can extract a subsection of an array using:
import numpy as nparr = np.random.rand(9, 9)arr2 = arr[:3, :3]print(arr2)
Indexing an ndarray behaves a bit differently than we have seen with nested lists. Recall that indexing into a nested list (i.e. a “list of lists”) worked as follows:
Here we specify the row and col simultaneously and receive the value stored at that location.
1
Visualizing four or more dimensions is challenging. We can use nested diagrams or slices to represent the extra dimensions, as illustrated in the following example.
Example 8.5 (Thinking about higher dimensional arrays) Consider the array shown in Figure 8.5. How would you access the value of “31”?
Figure 8.5: Schematic diagram visualizing how to think about ND arrays
Make a 1D array of values from 1 through N, where N is the total number of elements in the final array. Like range, arange excludes its stopping value, N+1.
2
Reshape the array into a five-dimensional array with shape (3, 5, 3, 3, 4).
3
The value 31 is at indices (0, 0, 2, 1, 2), one index per axis. In the first block of each of the first two axes, select the third 3-by-4 slice, its second row, and its third column.
31
Comments
By default, reshape uses “C” index order: the last index changes fastest as values fill the result. With “F” (Fortran) index order, the first index changes fastest. Neither order determines where new axes are added or assigns them physical meanings such as x, y, or z. Axes are numbered 0, 1, and so on.
It is sometimes helpful to think of a multidimensional array as a sequence of values with shape information attached. NumPy uses a data buffer and information describing how indices locate elements within it; a slice need not occupy one contiguous block. We can reshape an array as follows:
We can reshape between shapes as long as the total number of elements is conserved. Reshaping may share the existing data or make a copy, depending on the array’s layout. Transposing also commonly shares data rather than copying it.
A one-dimensional array with shape (n,) has no separate row or column axis, so .T leaves its shape unchanged. Use reshape(n, 1) for a 2D column or reshape(1, n) for a 2D row.
8.1.4.2 Slicing an Array
We can also retrieve subsections of an array using the slicing notation introduced for Python sequences:
Here we select the first three rows and the fourth column (index 3) onward. The start of a slice is included and its stop is excluded, so :3 selects rows 0, 1, and 2, while 3: selects columns 3 and 4 in this array.
Unlike a list slice, a basic NumPy slice is a view: it shares data with the original array. As with list aliasing, alias = arr gives another name to the same object. Use .copy() when you want independent array data:
arr = np.arange(6)alias = arrview = arr[1:4]independent = view.copy()view[0] =99print(arr) # [0, 99, 2, 3, 4, 5]print(alias) # the same array as arrprint(independent) # [1, 2, 3]
[ 0 99 2 3 4 5]
[ 0 99 2 3 4 5]
[1 2 3]
8.1.4.3 Masking
As with mathematical functions, logical comparisons are also done elementwise:
import numpy as np1arr = np.random.rand(5, 5)2mask = arr <0.53print(mask)
1
Generate a 5-by-5 array of random numbers in [0, 1): 0 is included and 1 is excluded.
2
Perform a logical comparison to determine which values in arr are less than 0.5
3
Printing mask reveals that it contains True and False values, which are the result of the logical comparison.
Reading with a Boolean mask produces a copy: after vals = arr[mask], changing vals does not change arr. In contrast, assigning directly through arr[mask] = ... updates the selected positions of arr itself. Integer-array indexing, introduced below, also produces a copy when reading.
Example 8.6 (Use masking to write many values to an array) Find all values in an array of random numbers less than 0.5, and replace them with the negative of their square root.
Because mask has the same shape as arr, reading arr[mask] returns a 1D array with shape (k,), where k is the number of True entries. It contains only the matches, not all 25 elements. The assignment writes the k replacement values back to those locations while leaving arr with shape (5, 5).
To select individual positions from a 2D array, we can provide one list of row indices and one of column indices. NumPy pairs corresponding entries, so the following lists select five positions rather than a five-by-five block:
Example 8.7 (Using the where function) Given an array of random numbers in [0, 1), find all the locations where the value is less than 0.5 and replace them with 2x the value.
With a single condition argument, np.where(mask) returns a tuple of integer index arrays, one per axis, identifying the True locations. The tuple is ready for use in fancy indexing.
2
We can use indices directly to fetch values from arr at the specific locations as well as inserting the new values.
Often we want to create an ndarray directly. np.empty allocates an array of a given shape without initializing its numerical entries:
arr = np.empty((5, 2), dtype=float)arr[:] =0.0# assign every entry before using itprint(arr)
[[0. 0.]
[0. 0.]
[0. 0.]
[0. 0.]
[0. 0.]]
Before assignment, the contents of an empty numerical array are arbitrary and must not be treated as valid data. When we want known initial values, np.ones or np.zeros is simpler:
Often we want to combine two 1D arrays into a 2D array, or vice versa. numpy uses the term “stack” to refer to joining arrays. This refers to the fact that arrays “stack” like blocks, either beside or on top of each other.
Example 8.8 (Joining coordinate arrays) Given three separate arrays that contain the x, y and z coordinates of N points, join them into a single N-by-3 array.
Solution
N =10x, y, z = np.random.rand(3, N)
Let’s inspect the shape of these individual arrays:
print(x.shape)
(10,)
We can stack these vertically as follows:
coords = np.vstack((x, y, z))print(coords.shape)
(3, 10)
This array is not the requested shape, but we can get the transpose using T:
coords = coords.Tprint(coords.shape)
(10, 3)
Comments
Had we used hstack instead of vstack we would not have received the correct shape either:
coords = np.hstack((x, y, z))print(coords.shape)
(30,)
In this case it just created a single long array.
np.column_stack((x, y, z)) produces the requested N-by-3 array directly. Another approach is to reshape each 1D array into a 2D column, then stack the columns horizontally:
x = x.reshape([N, 1])y = y.reshape([N, 1])z = z.reshape([N, 1])coords = np.hstack((x, y, z))print(coords.shape)
(10, 3)
These variations illustrate why checking array shapes is so useful when combining arrays.
Splitting arrays into smaller ones is also fairly common. We have already seen that slice indexing can be used for this, such as:
However, it is often more convenient to use numpy's built-in functions. This makes it less likely to make mistakes for instance, and it’s also fewer lines of code:
The integer 2 requests two equal sections along axis 0. For this four-row array, each section has two rows. To specify a split position instead, use a list: np.vsplit(arr, [1]) splits before row index 1, giving sections with one and three rows. Both forms return views that share data with the original array.