You might be familiar with Conway’s Game of Life. It is a simple game based on the idea of modeling a dynamic system progressing over time. We progress from discrete time points \(t_i \rightarrow t_i+1\) and apply very simple rules at each time point. The game progresses on a square grid of discrete points. Each cell on the grid has eight neighbors (the four cardinal directions, plus diagonals). The progression to the next time interval is given by the following:
- If I’m alive and I have fewer than two alive neighors, I die of loneliness.
- If I’m alive and I have two or three alive neighbors, I live.
- If I’m alive and I have more than three alive neighbors, I die of starvation.
- If I’m dead and I have three (exactly) live neighbors, I become alive by spontaneous combustion.
Typically, these rules are applied by loops and conditionals. I want to show-off a lesser known corner of NumPy by looking at as_strided
from NumPy’s dark corner of stride_tricks
. We’ll get to know as_strided
today and, in a future post, we’ll play the game of life using stride tricks – woot!
Introducing Array Strides¶
Before we get to anything fancy, we should review the role of strides in NumPy. Memory for all the elements in a NumPy array are layed out as one long contiguous block of (linear) memory. We use clever tricks to give this linear block of memory the structure we need to deal with rows and columns (or more dimensions). Consider the following:
import numpy as np
arr_1d = np.arange(12)
print arr_1d
arr_2d = np.arange(12).reshape(3,4)
print arr_2d
Both of these arrays have exactly the same elements. Perhaps surprisingly, the elements can be – and are! – stored in the exactly same flat sequence of values. The following gives us a look at that.
print arr_1d.flatten()
print arr_2d.flatten()
It turns out that the real difference between the two arrays is not how they are stored in memory, but what we do with that flat block of data. And that is determined by the shape
of the array.
print arr_1d.shape
print arr_2d.shape
Let’s look at the size of the 2-D array. The value of 4 tells us that the inner most dimension (it’s the last thing in the shape) has four elements in it. This has an interesting side-effect: it determines the step sized need for the outer dimension. To get from outer dimension 0 (the first visual row) to outer dimension 1 (the second visual row), we have to walk across the 4 elements of the inner dimension.
We can apply this to give a formula to go from an multi-dimensional index arr_2d[1,2]
to the specific element we need in the flat data as it really exists in memory \([1,2]{\rightarrow}1*4+2\) More generally, that is \([row,col]{\rightarrow}(row*rowSize)+col\) or perhaps \([1,2]{\rightarrow}(row*numCols)+col\). The same sort of reasoning applies as we add more dimensions. Here, \(rowSize\) (or, equivalently, \(numCols\)) determines the "number of steps" or the stride size (in the flat array) for the outer dimension. We can look at these explicitly:
print arr_1d.strides
print arr_2d.strides
Whoa! Those aren’t at all what I led you to expect.
One other factoid: we’re looking at the lower levels (inner workings and implementation details) of the NumPy library. This means we have to start worrying about the machine representations of our data. Our arrays have dtype=np.int_
which is 64 bits per int
on modern 64-bit CPUs (aka, np.int64
). So, moving forward one data entry means moving forward 64 bits which is equivalent to 8 bytes. Dividing those values given by arr.strides
by 8 gives us "element strides" (as opposed to the "memory strides" we’ll be working with) of (1,)
for arr_1d and of (4,1)
for arr_2d. We can rewrite \([1,2]{\rightarrow}1*4+2\) as \([1,2]{\rightarrow}1*\color{red}{4}+2*\color{red}{1}\) and the "coefficient" on the indices are now \(\color{red}{(4,1)}\) which is the same as our "element strides".
In the remainder of this post, we will be working with the proper "memory strides", not these slightly more convenient "element strides".
Introducing as_strided
¶
from numpy.lib.stride_tricks import as_strided
We can use as_strided
to customize the way we walk across an array. That is, we can specify strides other than those implied by the shape of the array. Because of the flexibility in specifying the strides, we also have to tell NumPy what the resulting output shape will be. Let’s revisit the 1-D array:
print arr_1d
shapeShifted = as_strided(arr_1d, # array to monkey with
strides=(4*8,1*8), # custom step sizes
shape=(3,4)) # output shape
print shapeShifted
To recap, we told NumPy to walk across arr_1d
using steps of 1 element on the inner-most axis (walking across the visual rows) and steps of 4 elements to kick off a new row.
We can pull some other neat tricks. For example, look at what happens if we never "start a new row" (i.e., our outer stride is 0).
print arr_1d.itemsize # number of bytes per element (aka, 8)
print arr_1d.shape[0]
duplicated = as_strided(arr_1d,
strides=(0, 1*arr_1d.itemsize),
shape = (2,arr_1d.shape[0]))
print duplicated
Really? This is exactly the sort of replication that happens in broadcasting. How did that output array get filled in? Consider how we filled in duplicated[0,5]
. To fill in item [0,5]
we have to take stride length for the outer dimension times the index of the outer dimension (\(0\cdot0=0\)). We then have to take the stride length for the inner dimension times the index of the inner dimension (\(8\cdot5=40\)). Adding the two gives us a flat offset of 40
(from \(0\cdot0+8\cdot5\). When we try to fill in the second row of duplicated
, we do something similar. For duplicated[1,5]
the values are: \(0\cdot1 + 8\cdot5=40\) (note, the 0 is the same in both computations because are stride for the outer dimension is 0).
We can do something analogous to the other axis and broadcast column-wise, instead of row-wise. We use both styles (outer/column and inner/row) if we want to compute an outer product.
arr1 = np.arange(1,5)
arr2 = np.arange(5,8)
outerProdShape = arr1.shape + arr2.shape
x = as_strided(arr1,
strides=(arr1.itemsize, 0),
shape=outerProdShape) # copy oPS[0] times (arr2 shape)
y = as_strided(arr2,
strides=(0, arr2.itemsize),
shape=outerProdShape) # copy oPS[1] times (arr1 shape)
print "x:"
print x
print "y:"
print y
print "x*y:"
print x * y
This is the same answer that we get using NumPy’s built-in outer
function:
print np.outer(arr1, arr2)
You might know that we can do some outer product like tasks with broadcasting by judicious use of a new axis (np.newaxis
). Here’s an example:
print arr1[:,np.newaxis] * arr2[np.newaxis,:]
Indeed, it gives the same answer!
Now, compare arr1[:, np.newaxis]
with the custom strides we applied to arr1
: strides
took the value (arr1.itemsize, 0)
. On the inner dimension, the np.newaxis
performs the same task as the 0
stride size. No kidding!
Neighborhoods¶
Let’s see how we can apply these tricks to pick out sub-arrays.
board = np.arange(16).reshape((4,4))
print board
print board.shape
We want to pick out the four "complete" neighborhoods centered around 5, 6, 7, and 8. Let’s look at the neighborhood for 5. What is the shape of the result? 3×3. What are the strides? Well, to walk across a row is still just walking one element at a time and to get to the next row is still 4 elements at a time. These are the same as the strides in the original. The difference is we don’t take "everything", we just take a selection. Let’s see if that actually works:
neighbors = as_strided(board, shape=(3,3), strides=board.strides)
print neighbors
Ok, nice. Now, if we want all four neighborhoods, what is the output shape? We have several 3×3 results. How many? In this case, we have 2×2 of them (for each of the "center" cells). This gives a shape of (2,2,3,3) – the neighborhoods are the inner dimensions and the organization of the neighborhoods is the outer dimensions.
We know how to get one neighborhood out. That moves us across the inner two dimensions. Now we need to figure out how to get from one neighborhood to the next. Once we have the complete neighborhood for 5, we want to start the neighborhood for 6. It’s top-left neighbor is the number 1 – but this is just one element beyond where we started for the previous neighborhood (which means our stride is one element). And we can keep doing that the whole way across the board (we’ll stop based on the output shape).
After we’ve walked across the board (neighborhood by neighborhood), how do we get to the next row? There are two ways to look at it. The first is by comparing the neighborhood for 5 to the neighborhood for 9. They differ by one row. So, our stride is four elements (the number of elements per row). The other way to think about this is where we are at after we walked the neighborhoods across the previous row. We will have gone through all the neighborhoods (inner most dimensions) and the first of the outer two dimensions. We now need to move forward one row – which again, is four elements. I prefer the first explanation!
So, our strides (in terms of elements) end up being (4,0) within one neighborhood and (4,0) for progressing neighborhood to neighborhood. The total stride (element wise) is: (4,0,4,0). But, the component strides (our outer two dimensions) are the same as the strides of the board. This means that our neighborhood strides are board.strides + board.strides
.
print board.strides + board.strides
neighborhoods = as_strided(board,
shape=(2,2,3,3),
strides=board.strides+board.strides)
print neighborhoods[0,0]
print neighborhoods[-1, -1]
Generalizing¶
We can generalize this a bit to work for more dimensions. For example, we could compute the neighbors in 3-D space or in 4-D space.
The key points are that: (1) taking the neighborhood has the same number of dimensions as the input, (2) the number of neighborhoods is the length of the axis minus 2 (we don’t use the first or last elements on an axis as a neighborhood center), and (3) the new strides are just the original strides concatenated to the original strides.
def grid_nD(arr):
assert all(_len>2 for _len in arr.shape)
nDims = len(arr.shape)
newShape = [_len-2 for _len in arr.shape]
newShape.extend([3] * nDims)
print "shape:", newShape
newStrides = arr.strides + arr.strides
print "stride:", newStrides
return as_strided(arr, shape=newShape, strides=newStrides)
Let’s test that on a 2D grid, since we’re pretty familiar with that case already.
x = np.arange(25).reshape((5,5))
print x
neighborhoods = grid_nD(x)
print neighborhoods[0,0]
print neighborhoods[-1,-1]
And now let’s see how it works on a 3D grid. Here, we’ll use 64 total grid points, but write it as \(4*4*4\) to emphasize the similarity between the size of the dimensions and this cube-shaped grid.
x = np.arange(4*4*4).reshape((4,4,4))
neighborhoods = grid_nD(x)
print neighborhoods[0,0,0]
print neighborhoods[-1,-1,-1]
References for this Material¶
I made varying use of the following material to put this together:
- http://scipy-lectures.github.io/advanced/advanced_numpy/#indexing-scheme-strides
- http://chintaksheth.wordpress.com/2013/07/31/numpy-the-tricks-of-the-trade-part-ii/
- http://wiki.scipy.org/Cookbook/GameOfLifeStrides
- http://www.rigtorp.se/2011/01/01/rolling-statistics-numpy.html
Additional Resources
You can grab a copy of this notebook.
Even better, you can view it using nbviewer.
License
Unless otherwise noted, the contents of this notebook are under the following license. The code in the notebook should be considered part of the text (i.e., licensed and treated as as follows).
DrsFenner.org Blog And Notebooks by Mark and Barbara Fenner is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
Permissions beyond the scope of this license may be available at drsfenner.org/blog/about-and-contacts.