 Tutorial](/imageprocessing-awesome/build/numpy_tutorial-7781c53f2d10e2fd2eb6013c1b201e2c.jpg)
stough, 202-
Numpy is a powerful toolkit for the handling of large N-dimensional arrays or matrices, conveniently wrapped in Python. Given the relative inefficiencies of handling large lists of numbers in native Python, Numpy ndarrays have become the backbone of scientific computation in Python. In this notebook we’ll first show some time comparisons between ndarray and Python list basic functions, then explore some of the very useful Numpy methods.
This is a highly stripped-down introduction. The intent is to learn much of what we need along the way, as we find uses for some of Numpy’s functionality. However, See Numpy’s own quickstart tutorial for a more complete introduction.
Imports¶
As a matter of clarity, I usually try to include all needed imports at the beginning of any notebook or script. Of note here:
The
%matplotlib inlinemagic command starts up the matplotlib visualization package. You can read more in the Matplotlib tutorial.I import numpy and then set the random number generator’s seed. This sets the state of numpy’s RNG so that every run through this notebook will be identical. While I send the
seedmethod a constant, you could also send the current time or else nothing, and a more “random” experience will follow.
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
import sys
import dis
# Alternative to using the timeit magic command:
# from timeit import timeit
# And initialize the random number generator
# np.random.seed() # for a more random experience.
# np.random.seed(np.uint32(time.time_ns())) # Another more real random.
np.random.seed(999)Speed Comparisons¶
We know Python lists as a really easy-to-use dynamic list implementation that includes powerful object-oriented and functional interactions, where elements of a list can be of any type. This flexibility of the list type is accomplished through the use of pointers in a way that is hidden from the programmer--under the hood--but that negatively impacts performance. Numpy on the other hand constrains all elements of an ndarray to be of the same type; under the hood, numpy arrays can be stored in contiguous memory with constant access time.
A tremendous speedup results from executing numpy’s compiled library code on contiguous memory, versus Python’s native implementations that require dealing with pointer indirection and the potential resulting scattered memory access (which impacts cache performance for example).
To demonstrate this, we’ll make a Python list of random integers using numpy’s randint, a (uniformly distributed) random number generator. We’ll also make a numpy ndarray copy of that list. This copy will be in memory that is independent of the list, which we are just copying the values from.
Then we can test certain functionality on the list and ndarray collections separately, using IPython’s timeit magic command..
# Make a list of random integers in [0,10M)
lyst = [np.random.randint(0,10000000) for x in range(1000)]
np_lyst = np.array(lyst)%%timeit
min(lyst)12.6 µs ± 2.56 µs per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
The expression we’re timing is min(lyst), which is the Python function to return the minimum of any iterable of comparable types in Python. The default output of timeit tells us with some degree of certainty how much wall time calculating min(lyst) is likely to take, probably in units of microseconds . timeit computes this by taking several runs (default 7) and each time executing the expression potentially thousands of times to get to some precision in the measurement.
%%timeit
np.min(np_lyst)3.91 µs ± 668 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
You should notice a significant speedup in the np.min() result over the Python min() result. On my machine I observe ~3x speedup on a list of length 1000 (1K). In that there is always some baseline overhead cost due to the Python interpreter (as opposed to compiled library calls), you may notice that the speedup improves the larger the list size is made to be. I observed a ~12x speedup on a list size of 10K, and a 15-20x speedup beyond 100K.
np_lyst.dtypedtype('int64')# Using getsizeof to ask Python the size in bytes of a Python variable.
sys.getsizeof(lyst[0])28type(lyst[0])intOne significant caveat here is that there is a big precision reduction: the integer primitive in Python is for arbitrary magnitude integral values and requires 28 bytes, while the implicitly determined np.int64 requires only 8 bytes with a more limited magnitude range. In conjunction with the direct packing (instead of pointer packing) in memory, the ndarray variable np_lyst requires much less memory than the Python variable lyst, and likely engenders much better cache coherence (read: average memory access time). Read more about it here or here. Additionally it is possible to write your own efficient C code implementations that can be called from Python: read (much) more here.
Computing the minimum of a collection is a straightforward linear complexity problem, . We’ll now try another example, this time of sorting a list/ndarray, which you’ll remember generally has complexity .
%%timeit
sorted(lyst)76.9 µs ± 8.63 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
%%timeit
np.sort(np_lyst)5.14 µs ± 552 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
Both the sorted() and np.sort() methods are functions in the sense that they do not modify the argument collection (whether list or ndarray), but rather return a sorted copy of that collection. Alternative object-oriented in-place sort and ndarray.sort methods are also available. However, for the purposes of timing, sorting an already sorted list is pretty uninteresting. In the sorting experiment above, I observed ~2-4x speedup using the Numpy equivalent on list size 1K.
%%timeit
[4*x for x in lyst]29.8 µs ± 267 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
%%timeit
list(map(lambda x: 4*x, lyst))69 µs ± 18.8 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
%%timeit
4*np_lyst1.26 µs ± 282 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
The final timing examples above show the efficiency of Python list comprehensions and the equivalent numpy expression. The above list comprehensions (or list(map(...) create a new list object where every element of the original list has been scaled by a constant. Such Pythonic expressions execute in interpreted bytecode, which can be quite a bit slower than comparative compiled C. Using smart operator overloading, the creators of Numpy encoded equivalent functionality that executes almost entirely in C, resulting in significant speedup (e.g., ~70x on list size 10K in my observations). We can actually observe this efficiency indirectly just in the amount of bytecode involved in the competing approaches, using Python’s disassembler module.
dis.dis('[4*x for x in lyst]') 1 0 LOAD_CONST 0 (<code object <listcomp> at 0x7fd1099629d0, file "<dis>", line 1>)
2 LOAD_CONST 1 ('<listcomp>')
4 MAKE_FUNCTION 0
6 LOAD_NAME 0 (lyst)
8 GET_ITER
10 CALL_FUNCTION 1
12 RETURN_VALUE
Disassembly of <code object <listcomp> at 0x7fd1099629d0, file "<dis>", line 1>:
1 0 BUILD_LIST 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 12 (to 18)
6 STORE_FAST 1 (x)
8 LOAD_CONST 0 (4)
10 LOAD_FAST 1 (x)
12 BINARY_MULTIPLY
14 LIST_APPEND 2
16 JUMP_ABSOLUTE 4
>> 18 RETURN_VALUE
dis.dis('4*np_lyst') 1 0 LOAD_CONST 0 (4)
2 LOAD_NAME 0 (np_lyst)
4 BINARY_MULTIPLY
6 RETURN_VALUE
As an aside, ndarrays are static in that they cannot change size easily (without cost). While this is a deficiency relative to Python’s dynamic list implementation (with ammortized cost to append), this is not much of a problem usually.
Across computational workflows much more complicated than the above simple experiments demonstrate, people have found Numpy to be up to 100 times faster than native Python. To be clear, ndarrays cannot easily replace all Python lists in our lives: we are after all programming in Python! But in computationally intensive endeavors like image processing, where dealing with millions of pixels is an every second kind of thing, we will have to leverage Numpy’s massive speedups.
Numpy Essentials¶
As demonstrated above, Numpy often provides vastly superior performance for computationally intensive tasks on collections of data. Additionally, it also provides great new functionality that becomes quite useful in scientific computing contexts: index-related operations, dimension swapping, conditional filtering, etc.. In this section we’ll explore some of this functionality. We’ll also show similar Python list functionality in case that helps understanding. I’ll often refer to the official Numpy documentation.
Let’s start by making a small ndarray with random integer values for pedagogical purposes. All of the same examples could be easily extended to floating point or much larger arrays.
# Some random integers in [5, 100). With named parameters for clarity.
np_lyst = np.random.randint(low=5,high=100,size=10)
lyst = list(np_lyst)np_lystarray([80, 69, 72, 69, 16, 11, 33, 83, 95, 30])Indexing Ops¶
We’ll start with index-related operations. It is often advantageous to reorder collections (for example sorting) in a way that is reversible. To do this we need to record where each element came from. It’s easy enough to get an index along with the associated element using enumerate.
Then, let’s try finding the index of the minimum.
list(enumerate(np_lyst))[(0, np.int64(80)),
(1, np.int64(69)),
(2, np.int64(72)),
(3, np.int64(69)),
(4, np.int64(16)),
(5, np.int64(11)),
(6, np.int64(33)),
(7, np.int64(83)),
(8, np.int64(95)),
(9, np.int64(30))]# Equivalent, but kind of silly way to write it
# np.argmin(np_lyst)
np_lyst.argmin()np.int64(5)# List equivalent
# lyst.index(min(lyst)) # requires >= n ops, kind of silly
# Another approach, requiring closer to the ideal n-1 ops.
# mini = 0
# for (i,x) in enumerate(lyst[1:], start=1):
# if x < lyst[mini]:
# mini = i
# print(mini)
# Another alternative, using min with a key function.
# min(enumerate(lyst), key=lambda pair: pair[1])[0]
# A little obfuscated
mini = 0
for (i,x) in enumerate(lyst):
mini = i if x < lyst[mini] else mini
print(mini)5
As you can see, ndarray’s argmin or equivalent numpy.argmin() methods can replace a bit of Python code. See also ndarray.argmax. Also note there are often equivalent functional numpy and object-oriented ndarray methods, as in np.argmin(np_lyst) and np_lyst.argmin() above.
We are often also interested in where some condition is true, as in ‘is this element greater than 50’ or ‘is this part of an image sky.’ We can use numpy.where() to get at that kind of information:
print(np_lyst)
print(np_lyst > 50)
np.where(np_lyst > 50)[80 69 72 69 16 11 33 83 95 30]
[ True True True True False False False True True False]
(array([0, 1, 2, 3, 7, 8]),)# Pretty printed
print(' '.join([f'{x:>6d}' for x in np_lyst]))
print(' '.join([f'{str(x):>6s}' for x in np_lyst > 50]))
np.where(np_lyst > 50) 80 69 72 69 16 11 33 83 95 30
True True True True False False False True True False
(array([0, 1, 2, 3, 7, 8]),)# List equivalent
[i for i,x in enumerate(lyst) if x > 50][0, 1, 2, 3, 7, 8]In the above cells, I print out the elements of the ndarray, then the elements of a conditional array np_lyst > 50, then the output of the where() call. I also pretty-print the arrays for a little more clarity, which you can read a lot more about here.
As you can see, when where() is sent an array, it will return an array of indices where non-zero (or non-False) elements can be found. You can actually see it’s returning a tuple, which becomes relevant for multidimensional arrays--but we’ll leave that for when it’s actually relevant. A call to ndarray.nonzero on the conditional array itself is equivalent in this case:
(np_lyst > 50).nonzero()(array([0, 1, 2, 3, 7, 8]),)However where is quite a bit more powerful. We can use where to sample from two sources based on the condition argument. In the below, whenever the condition > 50 is True in np_lyst, we sample the element directly, and otherwise the square root of the element is used.
np.where(np_lyst>50, np_lyst, np.sqrt(np_lyst))array([80. , 69. , 72. , 69. , 4. ,
3.31662479, 5.74456265, 83. , 95. , 5.47722558])Multi-dimensional ndarrays¶
Every ndarray object represents a multi-dimensional matrix of data. The hardest part about dealing with such large structured collections of data is always to keep in mind any particular ndarrays shape, dimensionality, and type of data, often along with the range of the data inside. Fortuitously, this is easily accessible in Numpy arrays through attributes and simple method calls.
Let’s start simple by instantiating a couple of 2D ndarrays.
a = np.random.randint(0,20, size=(4,6))
b = np.random.randint(20,40, size=(4,6))aarray([[18, 11, 10, 8, 12, 1],
[13, 12, 3, 18, 8, 15],
[16, 18, 18, 19, 1, 7],
[ 3, 5, 19, 12, 4, 3]])barray([[35, 39, 27, 33, 31, 30],
[23, 24, 32, 22, 22, 31],
[39, 38, 36, 34, 26, 35],
[23, 31, 33, 30, 39, 24]])You can see above that a and b are 4x6 matrices. In order to get at all of the details I mention above, let’s make a reusable function
# Given an ndarray, returns a tuple of info...
def arr_info(arr):
return arr.shape, arr.dtype, arr.min(), arr.max()arr_info(a)((4, 6), dtype('int64'), np.int64(1), np.int64(19))The arr_info function here accesses and returns the arr argument’s shape and dtype attributes and calls on the min and max methods to give us a snapshot of the content. The dimensionality of arr is the length of the shape tuple returned (or also a.ndim would work). This arr_info function is so useful, we’ll source it out to common utilities.
c = a/b
print(c)
print(arr_info(c))[[0.51428571 0.28205128 0.37037037 0.24242424 0.38709677 0.03333333]
[0.56521739 0.5 0.09375 0.81818182 0.36363636 0.48387097]
[0.41025641 0.47368421 0.5 0.55882353 0.03846154 0.2 ]
[0.13043478 0.16129032 0.57575758 0.4 0.1025641 0.125 ]]
((4, 6), dtype('float64'), np.float64(0.03333333333333333), np.float64(0.8181818181818182))
There are a number of things to note about the above cell and its output:
a/bmeans element-wise division. In fact all arithmetic ops betweenndarrayobjects are element-wise.In order for element-wise to make sense, both operands (
aandb) must be broadcastable to the same shape. For example,a/4would work because4can be broadcast to 4x6 (copies of the number4).The result of
a/b(and other such ops) is in fact a newndarrayobject, which is then assigned the variable namec.The result of integer division is floating point (see
dtype('float64')above).This
arr_infofunction has been abstracted the matrix_utils module for use throughout this book.
We can slice and index an ndarray matrix in the same way we would a Python list of lists. Here, we’ll make a 3x4 matrix and view some slices of it.
a = np.arange(12).reshape(3,4)
aarray([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])arr_info(a)((3, 4), dtype('int64'), np.int64(0), np.int64(11))# List equivalent gets sillier
rows = 3
columns = 4
alist = [[x+a*columns for x in range(columns)] for a in range(rows)]
print(alist)[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]
a[:2, 1:]array([[1, 2, 3],
[5, 6, 7]])# List equivalent
[little_list[1:] for little_list in alist[:2]][[1, 2, 3], [5, 6, 7]]a[:,:2]array([[0, 1],
[4, 5],
[8, 9]])a[...,:2]array([[0, 1],
[4, 5],
[8, 9]])# List equivalent
[little_list[:2] for little_list in alist][[0, 1], [4, 5], [8, 9]]As seen above, we can slice an ndarray pretty easily (at least compared to the list equivalent).
As with Python slicing,
:kmeans up to but not including indexk, whilej:means starting at indexjand continuing through the end.:and...happen to mean the same in the above cells only becauseais 2D. For a matrix of higher dimensionality,a[...,:2]would meana[:,:,:2]ora[:,:,:,:2]or so on. That is, for all dimensions iterate over all elements until the last dimension involving a different slice.
Let’s up the dimensionality to 3. We’ll reshape the 12 elements of a to be 2x2x3 instead of 3x4.
a = a.reshape(2,2,3)
aarray([[[ 0, 1, 2],
[ 3, 4, 5]],
[[ 6, 7, 8],
[ 9, 10, 11]]])a[...,0]array([[0, 3],
[6, 9]])a[1,1,:]array([ 9, 10, 11])plt.figure()
plt.imshow(a/11)
The reshaping we did here could for example represent a 2 pixel by 2 pixel image with 3 ‘color channels.’ If the three color channels represent the amount of red, green, and blue, then each pixel would have a bluish hue since blue > green > red in the last or ‘color’ dimension. But here we’re getting ahead of ourselves.
We can also transpose dimensions or flip the order of elements within one dimension.
# Again, a is 2x2x3
aarray([[[ 0, 1, 2],
[ 3, 4, 5]],
[[ 6, 7, 8],
[ 9, 10, 11]]])a.transpose(2,0,1)array([[[ 0, 3],
[ 6, 9]],
[[ 1, 4],
[ 7, 10]],
[[ 2, 5],
[ 8, 11]]])print(arr_info(a))
print(arr_info(a.transpose(2,0,1)))((2, 2, 3), dtype('int64'), np.int64(0), np.int64(11))
((3, 2, 2), dtype('int64'), np.int64(0), np.int64(11))
plt.figure()
plt.imshow(np.flip(a/11, axis=2))
When we np.flip() axis 2 (the last of the 2x2x3 matrix) of a, we make red > green > blue. As a result each pixel has a beige hue. But again, we’re getting ahead of ourselves...
This has been a very brief introduction to some Numpy and ndarray functionality and interaction. We will pick up more as we need it; as in the malaphor, “We’ll burn that bridge when we get to it.” Again, see Numpy’s own quickstart tutorial for a more complete and abstract introduction.