#!/usr/bin/env python # coding: utf-8 # # Random walk #
# # # Models leading to diffusion equations, see the section [diffu:app](#diffu:app), are # usually based on reasoning with *averaged* physical quantities such as # concentration, temperature, and velocity. The underlying physical # processes involve complicated microscopic movement of atoms and # molecules, but an average of a large number of molecules is performed # in a small volume before the modeling starts, and the averaged # quantity inside this volume is assigned as a point value at the # centroid of the volume. This means that concentration, temperature, # and velocity at a space-time point represent averages around the # point in a small time interval and small spatial volume. # # Random walk is a principally different kind of modeling procedure # compared to the reasoning behind partial differential equations. The # idea in random walk is to have a large number of "particles" that # undergo random movements. Averaging can then be used afterwards to # compute macroscopic quantities like concentration. The "particles" # and their random movement represent a very simplified microscopic # behavior of molecules, much simpler and computationally much more # efficient than direct [molecular simulation](https://en.wikipedia.org/wiki/Molecular_dynamics), yet the random # walk model has been very powerful to describe a wide range of # phenomena, including heat conduction, quantum mechanics, polymer # chains, population genetics, neuroscience, hazard games, and pricing # of financial instruments. # # mathcal{I}_t can be shown that random walk, when averaged, produces models that # are mathematically equivalent to diffusion equations. This is the # primary reason why we treat random walk in this chapter: two very # different algorithms (finite difference stencils and random walk) # solve the same type of problems. The simplicity of the random walk # algorithm makes it particularly attractive for solving diffusion # equations on massively parallel computers. # The exposition here is as simple as possible, and good thorough # derivation of the models is provided by Hjorth-Jensen [[hjorten]](#hjorten). # # ## Random walk in 1D #
# # Imagine that we have some particles that perform random moves, either # to the right or to the left. We may flip a coin to decide the movement # of each particle, say head implies movement to the right and tail # means movement to the left. Each move is one unit length. Physicists # use the term *random walk* for this type of movement. # The movement is also known as [drunkard's walk](https://en.wikipedia.org/wiki/The_Drunkard%27s_Walk). # You may try this yourself: flip the coin and make one step to the left # or right, and repeat the process. # # We introduce the symbol $N$ for the number of steps in a random walk. # [Figure](#diffu:randomwalk:1D:fig:ensemble) shows four different # random walks with $N=200$. # # # #
# #

Ensemble of 4 random walks, each with 200 steps.

# # # # # # # ## Statistical considerations #
# # # Let $S_k$ be the stochastic variable representing a step to the left # or to the right in step number $k$. We have that $S_k=-1$ with # probability $p$ and $S_k=1$ with probability $q=1-p$. The variable # $S_k$ is known as a [Bernoulli variable](https://en.wikipedia.org/wiki/Bernoulli_distribution). The # expectation of $S_k$ is # $$ # \E{S_k} = p\cdot (-1) + q\cdot 1 = 1 - 2p, # $$ # and the variance is # $$ # \Var{S_k} = \E{S_k^2} - \E{S_k}^2 = 1 - (1-2p)^2 = 4p(1-p)\thinspace . # $$ # The position after $k$ steps is another stochastic variable # $$ # \bar X_k = \sum_{i=0}^{k-1} S_i\thinspace . # $$ # The expected position is # $$ # \E{\bar X_k} = # \E{\sum_{i=0}^{k-1} S_i} = \sum_{i=0}^{k-1} \E{S_i}= k(1-2p)\thinspace . # $$ # All the $S_k$ variables are independent. The variance therefore becomes # $$ # \Var{\bar X_k} = \Var{\sum_{i=0}^{k-1} S_i} = \sum_{i=0}^{k-1} \Var{S_i}= # k4p(1-p)\thinspace . # $$ # We see that $\Var{\bar X_k}$ is proportional with the number of steps $k$. # For the very important case $p=q=\frac{1}{2}$, $\E{\bar X_k}=0$ and # $\Var{\bar X_k}=k$. # # # How can we estimate $\E{\bar X_k}=0$ and $\Var{\bar X_k}=N$? # We must have many random walks of the type in # [Figure](#diffu:randomwalk:1D:fig:ensemble). For a given $k$, say $k=100$, # we find all the values of $\bar X_k$, name them $\bar x_{0,k}$, $\bar x_{1,k}$, # $\bar x_{2,k}$, and so on. The empirical estimate of $\E{\bar X_k}$ is the # average, # $$ # \E{\bar X_k} \approx \frac{1}{W}\sum_{j=0}^{W-1} \bar x_{j,k}, # $$ # while an empirical estimate of $\Var{\bar X_k}$ is # $$ # \Var{\bar X_k} \approx \frac{1}{W}\sum_{j=0}^{W-1} (\bar x_{j,k})^2 - # \left(\frac{1}{W}\sum_{j=0}^{W-1} \bar x_{j,k}\right)^2\thinspace . # $$ # That is, we take the statistics for a given $K$ across the ensemble # of random walks ("vertically" in # [Figure](#diffu:randomwalk:1D:fig:ensemble)). The key quantities # to record are $\sum_i \bar x_{i,k}$ and $\sum_i \bar x_{i,k}^2$. # # ## Playing around with some code #
# # # ### Scalar code # # Python has a `random` module for drawing random numbers, and this # module has a function `uniform(a, b)` for drawing a uniformly # distributed random number in the interval $[a,b)$. If an event # happens with probability $p$, we can simulate this on the computer by # drawing a random number $r$ in $[0,1)$, because then $r\leq p$ with # probability $p$ and $r>p$ with probability $1-p$: # In[1]: import random r = random.uniform(0, 1) if r <= p: # Event happens else: # Event does not happen # A random walk with $N$ steps, starting at $x_0$, where we move # to the left with probability $p$ and to the right # with probability $1-p$ can now be implemented by # In[2]: import random, numpy as np def random_walk1D(x0, N, p): """1D random walk with 1 particle.""" # Store position in step k in position[k] position = np.zeros(N) position[0] = x0 current_pos = x0 for k in range(N-1): r = random.uniform(0, 1) if r <= p: current_pos -= 1 else: current_pos += 1 position[k+1] = current_pos return position # ### Vectorized code # # Since $N$ is supposed to be large and we want to repeat the process for # many particles, we should speed up the code as much as possible. # Vectorization is the obvious technique here: we draw all the random # numbers at once with aid of `numpy`, and then we formulate vector # operations to get rid of the loop over the steps (`k`). # The `numpy.random` module has vectorized versions of the functions in # Python's built-in `random` module. For example, `numpy.random.uniform(a, b, N)` # returns `N` random numbers uniformly distributed between `a` (included) # and `b` (not included). # # We can then make an array of all the steps in a random walk: if # the random number is less than or equal to $p$, the step is $-1$, # otherwise the step is $1$: # In[3]: r = np.random.uniform(0, 1, size=N) steps = np.where(r <= p, -1, 1) # The value of `position[k]` is the sum of all steps up to step `k`. # Such sums are often needed in vectorized algorithms and therefore # available by the `numpy.cumsum` function: # In[4]: import numpy as np np.cumsum(np.array([1,3,4,6])) # The resulting array in this demo has elements $1$, $1+3=4$, $1+3+4=8$, # and $1+3+4+6=14$. # # We can now vectorize the `random_walk1D` function: # In[5]: def random_walk1D_vec(x0, N, p): """Vectorized version of random_walk1D.""" # Store position in step k in position[k] position = np.zeros(N+1) position[0] = x0 r = np.random.uniform(0, 1, size=N) steps = np.where(r <= p, -1, 1) position[1:] = x0 + np.cumsum(steps) return position # This code runs about 10 times faster than the scalar version. # With a parallel `numpy` library, the code can also automatically take # advantage of hardware for parallel computing because each of the four # array operations can be trivially parallelized. # # # ### Fixing the random sequence # # During software development with random numbers it is advantageous to # always generate the same sequence of random numbers, as this may help # debugging processes. To fix the sequence, we set a *seed* of the random # number generator to some chosen integer, e.g., # In[6]: np.random.seed(10) # Calls to `random_walk1D_vec` give positions of the particle as # depicted in [Figure](#diffu:randomwalk:1D:code1:fig1). The particle starts # at the origin and moves with $p=\frac{1}{2}$. Since the seed is the same, # the plot to the left is just a magnification of the first 1,000 steps in # the plot to the right. # # # # #
# #

1,000 (left) and 50,000 (right) steps of a random walk.

# # # # # # # ### Verification # # When we have a scalar and a vectorized code, it is always a good idea to # develop a unit test for checking that they produce the same result. # A problem in the present context is that the two versions apply two different # random number generators. For a test to be meaningful, we need to fix # the seed and use the same generator. This means that the scalar version # must either use `np.random` or have this as an option. An option # is the most flexible choice: # import random # # def random_walk1D(x0, N, p, random=random): # ... # r = random.uniform(0, 1) # # Using `random=np.random`, the `r` variable gets computed # by `np.random.uniform`, and the sequence of random numbers will be # the same as in the vectorized version that employs the same generator # (given that the seed is also the same). A proper test function may be # to check that the positions in the walk are the same in the scalar and # vectorized implementations: # In[7]: def test_random_walk1D(): # For fixed seed, check that scalar and vectorized versions # produce the same result x0 = 2; N = 4; p = 0.6 np.random.seed(10) scalar_computed = random_walk1D(x0, N, p, random=np.random) np.random.seed(10) vectorized_computed = random_walk1D_vec(x0, N, p) assert (scalar_computed == vectorized_computed).all() # Note that we employ `==` for arrays with real numbers, which is normally # an inadequate test due to rounding errors, but in the present case, # all arithmetics consists of adding or subtracting one, so these operations # are expected to have no rounding errors. Comparing two `numpy` arrays # with `==` results in a boolean array, so we need to call the `all()` # method to ensure that all elements are `True`, i.e., that all elements # in the two arrays match each other pairwise. # # # ## Equivalence with diffusion #
# # # The original random walk algorithm can be said to work with # dimensionless coordinates $\bar x_i = -N + i$, $i=0,1,\ldots, 2N+1$ # ($i\in [-N,N]$), and $\bar t_n=n$, $n=0,1,\ldots,N$. A mesh with # spacings $\Delta x$ and $\Delta t$ with dimensions can be introduced # by # $$ # x_i = X_0 + \bar x_i \Delta x,\quad t_n = \bar t_n\Delta t\thinspace . # $$ # If we implement the algorithm with dimensionless coordinates, we can just # use this rescaling to obtain the movement in a coordinate system # without unit spacings. # # Let $P^{n+1}_i$ be the probability of finding the particle at mesh point # $\bar x_i$ at time $\bar t_{n+1}$. We can reach mesh point $(i,n+1)$ in two # ways: either coming in from the left from $(i-1,n)$ or from the # right ($i+1,n)$. Each has probability $\frac{1}{2}$ (if we assume # $p=q=\frac{1}{2}$). The fundamental equation for $P^{n+1}_i$ is # #
# # $$ # \begin{equation} # P^{n+1}_i = \frac{1}{2} P^{n}_{i-1} + \frac{1}{2} P^{n}_{i+1}\thinspace . # \label{diffu:randomwalk:1D:pde:Markov} \tag{1} # \end{equation} # $$ # (This equation is easiest to understand if one looks at the random walk # as a Markov process and applies the transition probabilities, but this is # beyond scope of the present text.) # # Subtracting $P^{n}_i$ from ([1](#diffu:randomwalk:1D:pde:Markov)) results # in # $$ # P^{n+1}_i - P^{n}_i = \frac{1}{2} (P^{n}_{i-1} -2P^{n}_i + \frac{1}{2} P^{n}_{i+1})\thinspace . # $$ # Readers who have seen the Forward Euler discretization of a 1D # diffusion equation recognize this scheme as very close to such a # discretization. We have # $$ # \frac{\partial}{\partial t}P(x_i,t_{n}) # = \frac{P^{n+1}_i - P^{n}_i}{\Delta t} + \Oof{\Delta t}, # $$ # or in dimensionless coordinates # $$ # \frac{\partial}{\partial\bar t}P(\bar x_i,\bar t_n) # \approx P^{n+1}_i - P^{n}_i\thinspace . # $$ # Similarly, we have # $$ # \begin{align*} # \frac{\partial^2}{\partial x^2}P(x_i,t_n) &= # \frac{P^{n}_{i-1} -2P^{n}_i + \frac{1}{2} P^{n}_{i+1}}{\Delta x^2} # + \Oof{\Delta x^2},\\ # \frac{\partial^2}{\partial x^2}P(\bar x_i,\bar t_n) &\approx # P^{n}_{i-1} -2P^{n}_i + \frac{1}{2} P^{n}_{i+1}\thinspace . # \end{align*} # $$ # Equation ([1](#diffu:randomwalk:1D:pde:Markov)) is therefore equivalent with # the dimensionless diffusion equation # #
# # $$ # \begin{equation} # \frac{\partial P}{\partial\bar t} = \frac{1}{2} # \frac{\partial^2 P}{\partial \bar x^2}, # \label{diffu:randomwalk:1D:pde:dimless} \tag{2} # \end{equation} # $$ # or the diffusion equation # #
# # $$ # \begin{equation} # \frac{\partial P}{\partial t} = D\frac{\partial^2 P}{\partial x^2}, # \label{diffu:randomwalk:1D:pde:dim} \tag{3} # \end{equation} # $$ # with diffusion coefficient # $$ # D = \frac{\Delta x^2}{2\Delta t}\thinspace . # $$ # This derivation shows the tight link between random walk and diffusion. # If we keep track of where the particle is, and repeat the process # many times, or run the algorithms for lots of particles, the histogram # of the positions will approximate the solution of the diffusion equation # for the local probability $P^n_i$. # # Suppose all the random walks start at the origin. Then the initial # condition for the probability distribution is the Dirac delta # function $\delta(x)$. The solution of ([2](#diffu:randomwalk:1D:pde:dimless)) # can be shown to be # #
# # $$ # \begin{equation} # \bar P(\bar x,\bar t) = \frac{1}{\sqrt{4\pi\dfc t}}e^{-\frac{x^2}{4\dfc t}}, # \label{diffu:randomwalk:1D:pde:dimless:sol} \tag{4} # \end{equation} # $$ # where $\dfc = \frac{1}{2}$. # # ## Implementation of multiple walks # # Our next task is to implement an ensemble of walks (for statistics, # see the section [Statistical considerations](#diffu:randomwalk:1D:EVar)) and also provide data from # the walks such that we can compute the probabilities of the positions # as introduced in the previous section. An appropriate representation # of probabilities $P^n_i$ are histograms (with $i$ along the $x$ axis) # for a few selected values of $n$. # # To estimate the expectation and variance of the random walks, # the section [Statistical considerations](#diffu:randomwalk:1D:EVar) points to recording # $\sum_j x_{j,k}$ and $\sum_j x_{j,k}^2$, where $x_{j,k}$ is the # position at time/step level $k$ in random walk number $j$. # The histogram of positions needs the individual values $x_{i,k}$ # for all $i$ values and some selected $k$ values. # # We introduce `position[k]` to hold $\sum_j x_{j,k}$, # `position2[k]` to hold $\sum_j (x_{j,k})^2$, and # `pos_hist[i,k]` to hold $x_{i,k}$. A selection of $k$ values can be # specified by saying how many, `num_times`, and let them be equally # spaced through time: # In[8]: pos_hist_times = [(N//num_times)*i for i in range(num_times)] # This is one of the few situations where we want integer division (`//`) or # real division rounded to an integer. # # ### Scalar version # # Our scalar implementation of running `num_walks` random walks may go # like this: # In[9]: def random_walks1D(x0, N, p, num_walks=1, num_times=1, random=random): """Simulate num_walks random walks from x0 with N steps.""" position = np.zeros(N+1) # Accumulated positions position[0] = x0*num_walks position2 = np.zeros(N+1) # Accumulated positions**2 position2[0] = x0**2*num_walks # Histogram at num_times selected time points pos_hist = np.zeros((num_walks, num_times)) pos_hist_times = [(N//num_times)*i for i in range(num_times)] #print 'save hist:', post_hist_times for n in range(num_walks): num_times_counter = 0 current_pos = x0 for k in range(N): if k in pos_hist_times: #print 'save, k:', k, num_times_counter, n pos_hist[n,num_times_counter] = current_pos num_times_counter += 1 # current_pos corresponds to step k+1 r = random.uniform(0, 1) if r <= p: current_pos -= 1 else: current_pos += 1 position [k+1] += current_pos position2[k+1] += current_pos**2 return position, position2, pos_hist, np.array(pos_hist_times) # ### Vectorized version # # We have already vectorized a single random walk. The additional # challenge here is to vectorize the computation of the data for the # histogram, `pos_hist`, but given the selected steps in `pos_hist_times`, # we can find the corresponding positions by indexing with the # list `pos_hist_times`: `position[post_hist_times]`, which are to be # inserted in `pos_hist[n,:]`. # In[10]: def random_walks1D_vec1(x0, N, p, num_walks=1, num_times=1): """Vectorized version of random_walks1D.""" position = np.zeros(N+1) # Accumulated positions position2 = np.zeros(N+1) # Accumulated positions**2 walk = np.zeros(N+1) # Positions of current walk walk[0] = x0 # Histogram at num_times selected time points pos_hist = np.zeros((num_walks, num_times)) pos_hist_times = [(N//num_times)*i for i in range(num_times)] for n in range(num_walks): r = np.random.uniform(0, 1, size=N) steps = np.where(r <= p, -1, 1) walk[1:] = x0 + np.cumsum(steps) # Positions of this walk position += walk position2 += walk**2 pos_hist[n,:] = walk[pos_hist_times] return position, position2, pos_hist, np.array(pos_hist_times) # ### Improved vectorized version # # Looking at the vectorized version above, we still have one potentially # long Python loop over `n`. Normally, `num_walks` will be much larger than `N`. # The vectorization of the loop over `N` certainly speeds up the program, # but if we think of vectorization as also a way to parallelize the code, # all the independent walks (the `n` loop) can be executed in parallel. # Therefore, we should include this loop as well in the vectorized # expressions, at the expense of using more memory. # # We introduce the array `walks` to hold the $N+1$ steps of all the walks: # each row represents the steps in one walk. # In[11]: walks = np.zeros((num_walks, N+1)) # Positions of each walk walks[:,0] = x0 # Since all the steps are independent, we can just make one long # vector of enough random numbers (`N*num_walks`), translate these # numbers to $\pm 1$, then we reshape the array such that the steps # of each walk are stored in the rows. # In[12]: r = np.random.uniform(0, 1, size=N*num_walks) steps = np.where(r <= p, -1, 1).reshape(num_walks, N) # The next step is to sum up the steps in each walk. We need the # `np.cumsum` function for this, with the argument `axis=1` for # indicating a sum across the columns: # In[13]: a = np.arange(6).reshape(2,3) a # In[14]: np.cumsum(a, axis=1) # Now `walks` can be computed by # In[15]: walks[:,1:] = x0 + np.cumsum(steps, axis=1) # The `position` vector is the sum of all the walks. That is, we want to # sum all the rows, obtained by # In[16]: position = np.sum(walks, axis=0) # A corresponding expression computes the squares of the positions. # Finally, we need to compute `pos_hist`, but that is a matter of # grabbing some of the walks (according to `pos_hist_times`): # In[17]: pos_hist[:,:] = walks[:,pos_hist_times] # The complete vectorized algorithm without any loop can now be # summarized: # In[18]: def random_walks1D_vec2(x0, N, p, num_walks=1, num_times=1): """Vectorized version of random_walks1D; no loops.""" position = np.zeros(N+1) # Accumulated positions position2 = np.zeros(N+1) # Accumulated positions**2 walks = np.zeros((num_walks, N+1)) # Positions of each walk walks[:,0] = x0 # Histogram at num_times selected time points pos_hist = np.zeros((num_walks, num_times)) pos_hist_times = [(N//num_times)*i for i in range(num_times)] r = np.random.uniform(0, 1, size=N*num_walks) steps = np.where(r <= p, -1, 1).reshape(num_walks, N) walks[:,1:] = x0 + np.cumsum(steps, axis=1) position = np.sum(walks, axis=0) position2 = np.sum(walks**2, axis=0) pos_hist[:,:] = walks[:,pos_hist_times] return position, position2, pos_hist, np.array(pos_hist_times) # What is the gain of the vectorized implementations? One important gain # is that each vectorized operation can be automatically parallelized # if one applies a parallel `numpy` library like [Numba](http://numba.pydata.org). On a single CPU, however, the speed up of the vectorized operations # is also significant. With $N=1,000$ and 50,000 repeated walks, # the two vectorized versions run about 25 and 18 times faster than the scalar # version, with `random_walks1D_vec1` being fastest. # # # # # # # ### Remark on vectorized code and parallelization # # Our first attempt on vectorization removed the loop over the $N$ steps in # a single walk. However, the number of walks is usually much larger than # $N$, because of the need for accurate statistics. Therefore, we should # rather remove the loop over all walks. mathcal{I}_t turns out, from our efficiency # experiments, that the function `random_walks1D_vec2` (with no loops) is # slower than `random_walks1D_vec1`. This is a bit surprising and may be # explained by less efficiency in the statements involving very large # arrays, containing all steps for all walks at once. # # From a parallelization and improved vectorization point of view, it # would be more natural to switch the sequence of the loops in the # serial code such that the shortest loop is the outer loop: # In[19]: def random_walks1D2(x0, N, p, num_walks=1, num_times=1, ...): ... current_pos = x0 + np.zeros(num_walks) num_times_counter = -1 for k in range(N): if k in pos_hist_times: num_times_counter += 1 store_hist = True else: store_hist = False for n in range(num_walks): # current_pos corresponds to step k+1 r = random.uniform(0, 1) if r <= p: current_pos[n] -= 1 else: current_pos[n] += 1 position [k+1] += current_pos[n] position2[k+1] += current_pos[n]**2 if store_hist: pos_hist[n,num_times_counter] = current_pos[n] return position, position2, pos_hist, np.array(pos_hist_times) # The vectorized version of this code, where we just vectorize the # loop over `n`, becomes # In[20]: def random_walks1D2_vec1(x0, N, p, num_walks=1, num_times=1): """Vectorized version of random_walks1D2.""" position = np.zeros(N+1) # Accumulated positions position2 = np.zeros(N+1) # Accumulated positions**2 # Histogram at num_times selected time points pos_hist = np.zeros((num_walks, num_times)) pos_hist_times = [(N//num_times)*i for i in range(num_times)] current_pos = np.zeros(num_walks) current_pos[0] = x0 num_times_counter = -1 for k in range(N): if k in pos_hist_times: num_times_counter += 1 store_hist = True # Store histogram data for this k else: store_hist = False # Move all walks one step r = np.random.uniform(0, 1, size=num_walks) steps = np.where(r <= p, -1, 1) current_pos += steps position[k+1] = np.sum(current_pos) position2[k+1] = np.sum(current_pos**2) if store_hist: pos_hist[:,num_times_counter] = current_pos return position, position2, pos_hist, np.array(pos_hist_times) # This function runs significantly faster than the `random_walks1D_vec1` # function above, typically 1.7 times faster. The code is also more appropriate # in a parallel computing context since each vectorized statement # can work with data of size `num_walks` over the compute units, repeated `N` # times (compared with data of size `N`, repeated `num_walks` times, in # `random_walks1D_vec1`). # # The scalar code with switched loops, `random_walks1D2` runs a bit slower # than the original code in `random_walks1D`, so with the longest loop as # the inner loop, the vectorized function `random_walks1D2_vec1` # is almost 60 times faster than the scalar counterpart, while the # code `random_walks1D_vec2` without loops is only around 18 times faster. # Taking into account the very large arrays required by the latter function, # we end up with `random_walks1D2_vec1` as the preferred implementation. # # ### Test function # # During program development, it is highly recommended to carry out # computations by hand for, e.g., `N=4` and `num_walks=3`. Normally, # this is done by executing the program with these parameters and # checking with pen and paper that the computations make sense. The # next step is to use this test for correctness in a formal test # function. # # First, we need to check that the simulation of multiple random walks # reproduces the results of `random_walk1D`, `random_walk1D_vec1`, and # `random_walk1D_vec2` for the first walk, if the seed is the # same. Second, we run three random walks (`N=4`) with the scalar and # the two vectorized versions and check that the returned arrays are # identical. # # For this type of test to be successful, we must be sure that exactly # the same set of random numbers are used in the three versions, a fact # that requires the same random number generator and the same seed, of # course, but also the same sequence of computations. This is not # obviously the case with the three `random_walk1D*` functions we # have presented. The critical issue in `random_walk1D_vec1` is that the # first random numbers are used for the first walk, the second set of # random numbers is used for the second walk and so on, to be compatible # with how the random numbers are used in the function `random_walk1D`. # For the function `random_walk1D_vec2` the situation is a bit more # complicated since we generate all the random numbers at once. # However, the critical step now is the reshaping of the array returned # from `np.where`: we must reshape as `(num_walks, N)` to ensure that # the first `N` random numbers are used for the first walk, the next `N` # numbers are used for the second walk, and so on. # # We arrive at the test function below. # In[21]: def test_random_walks1D(): # For fixed seed, check that scalar and vectorized versions # produce the same result x0 = 0; N = 4; p = 0.5 # First, check that random_walks1D for 1 walk reproduces # the walk in random_walk1D num_walks = 1 np.random.seed(10) computed = random_walks1D( x0, N, p, num_walks, random=np.random) np.random.seed(10) expected = random_walk1D( x0, N, p, random=np.random) assert (computed[0] == expected).all() # Same for vectorized versions np.random.seed(10) computed = random_walks1D_vec1(x0, N, p, num_walks) np.random.seed(10) expected = random_walk1D_vec(x0, N, p) assert (computed[0] == expected).all() np.random.seed(10) computed = random_walks1D_vec2(x0, N, p, num_walks) np.random.seed(10) expected = random_walk1D_vec(x0, N, p) assert (computed[0] == expected).all() # Second, check multiple walks: scalar == vectorized num_walks = 3 num_times = N np.random.seed(10) serial_computed = random_walks1D( x0, N, p, num_walks, num_times, random=np.random) np.random.seed(10) vectorized1_computed = random_walks1D_vec1( x0, N, p, num_walks, num_times) np.random.seed(10) vectorized2_computed = random_walks1D_vec2( x0, N, p, num_walks, num_times) # positions: [0, 1, 0, 1, 2] # Can test without tolerance since everything is +/- 1 return_values = ['pos', 'pos2', 'pos_hist', 'pos_hist_times'] for s, v, r in zip(serial_computed, vectorized1_computed, return_values): msg = '%s: %s (serial) vs %s (vectorized)' % (r, s, v) assert (s == v).all(), msg for s, v, r in zip(serial_computed, vectorized2_computed, return_values): msg = '%s: %s (serial) vs %s (vectorized)' % (r, s, v) assert (s == v).all(), msg # Such test functions are indispensable for further development of the code # as we can at any time test whether the basic computations remain correct or not. # This is particularly important in stochastic simulations since without # test functions and fixed seeds, we always experience variations from run to # run, and it can be very difficult to spot bugs through averaged statistical # quantities. # # ## Demonstration of multiple walks # # Assuming now that the code works, we can just scale up the number of # steps in each walk and the number of walks. The latter influences the # accuracy of the statistical estimates. [Figure](#diffu:randomwalk:1D:fig:demo1:EX) shows the impact of the number # of walks on the expectation, which should approach zero. [Figure](#diffu:randomwalk:1D:fig:demo1:VarX) displays the corresponding # estimate of the variance of the position, which should grow linearly # with the number of steps. mathcal{I}_t does, seemingly very accurately, but # notice that the scale on the $y$ axis is so much larger than for the # expectation, so irregularities due to the stochastic nature of the # process become so much less visible in the variance plots. The # probability of finding a particle at a certain position at time (or # step) 800 is shown in [Figure](#diffu:randomwalk:1D:fig:demo1:HistX). The dashed red line is the # theoretical distribution ([4](#diffu:randomwalk:1D:pde:dimless:sol)) # arising from solving the diffusion equation # ([2](#diffu:randomwalk:1D:pde:dimless)) instead. As always, we realize # that one needs significantly more statistical samples to estimate a # histogram accurately than the expectation or variance. # # # #
# #

Estimated expected value for 1000 steps, using 100 walks (upper left), 10,000 (upper right), 100,000 (lower left), and 1,000,000 (lower right).

# # # # # # # #
# #

Estimated variance over 1000 steps, using 100 walks (upper left), 10,000 (upper right), 100,000 (lower left), and 1,000,000 (lower right).

# # # # # # # #
# #

Estimated probability distribution at step 800, using 100 walks (upper left), 10,000 (upper right), 100,000 (lower left), and 1,000,000 (lower right).

# # # # # # # ## Ascii visualization of 1D random walk #
# # # If we want to study (very) long time series of random walks, it can be # convenient to plot the position in a terminal window with the time axis # pointing downwards. The module `avplotter` in SciTools has a class `Plotter` # for plotting functions in the terminal window with the aid of ascii symbols # only. Below is the code required to visualize a simple random walk, # starting at the origin, and considered over # when the point $x=-1$ is reached. We use a spacing $\Delta x = 0.05$ (so # $x=-1$ corresponds to $i=-20$). # In[22]: get_ipython().run_line_magic('matplotlib', 'inline') def run_random_walk(): from scitools.avplotter import Plotter import time, numpy as np p = Plotter(-1, 1, width=75) # Horizontal axis: 75 chars wide dx = 0.05 np.random.seed(10) x = 0 while True: random_step = 1 if np.random.random() > 0.5 else -1 x = x + dx*random_step if x < -1: break # Destination reached! print p.plot(0, x) # Allow Ctrl+c to abort the simulation try: time.sleep(0.1) # Wait for interrupt except KeyboardInterrupt: print 'Interrupted by Ctrl+c' break # Observe that we implement an infinite loop, but allow a smooth interrupt # of the program by `Ctrl+c` through Python's `KeyboardInterrupt` # exception. This is a useful recipe that can be used in many occasions! # # The output looks typically like # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # * | # # Positions beyond the limits of the $x$ axis appear with a value. # [A long file](http://bit.ly/1UbULeH) # contains the complete ascii plot corresponding to the # function `run_random_walk` above. # # # ## Random walk as a stochastic equation #
# # # The (dimensionless) position in a random walk, $\bar X_k$, can be expressed as # a stochastic difference equation: # #
# # $$ # \begin{equation} # \bar X_k = \bar X_{k-1} + s, \quad x_0=0, # \label{diffu:randomwalk:1D:ode:x} \tag{5} # \end{equation} # $$ # where $s$ is a [Bernoulli variable](https://en.wikipedia.org/wiki/Bernoulli_distribution), # taking on the two values $s=-1$ and $s=1$ # with equal probability: # $$ # \hbox{P}(s=1)=\frac{1}{2},\quad \hbox{P}(s=-1)=\frac{1}{2}\thinspace . # $$ # The $s$ variable in a step is independent of the $s$ variable in other steps. # # The difference equation expresses essentially the sum of independent # Bernoulli variables. Because of the central limit theorem, $X_k$, # will then be normally distributed with expectation $k\E{s}$ and # $k\Var{s}$. The expectation and variance of a Bernoulli variable with # values $r=0$ and $r=1$ are $p$ and $p(1-p)$, respectively. # The variable $s=2r-1$ then has expectation # $2\E{r}-1=2p-1=0$ and variance $2^2\Var{r}=4p(1-p)=1$. The position # $X_k$ is normally distributed with zero expectation and variance $k$, # as we found in the section [Statistical considerations](#diffu:randomwalk:1D:EVar). # # The central limit theorem tells that as long as $k$ is not small, # the distribution of $X_k$ remains the same if # we replace the Bernoulli variable $s$ by any other stochastic variable with # the same expectation and variance. In particular, we may let $s$ be a # standardized Gaussian variable (zero mean, unit variance). # # # # # # # Dividing ([5](#diffu:randomwalk:1D:ode:x)) by $\Delta t$ gives # $$ # \frac{\bar X_k - \bar X_{k-1}}{\Delta t} = \frac{1}{\Delta t} s\thinspace . # $$ # In the limit $\Delta t\rightarrow 0$, $s/\Delta t$ approaches a white noise # stochastic process. # With $\bar X(t)$ as the continuous process in the limit # $\Delta t\rightarrow 0$ ($X_k\rightarrow X(t_k)$), # we formally get the stochastic differential equation # #
# # $$ # \begin{equation} # d\bar X = dW, # \label{_auto1} \tag{6} # \end{equation} # $$ # where $W(t)$ is a [Wiener process](https://en.wikipedia.org/wiki/Wiener_process). Then $X$ is also a # Wiener process. mathcal{I}_t follows from the stochastic ODE $dX=dW$ that the # probability distribution of $X$ is given by the [Fokker-Planck # equation](https://en.wikipedia.org/wiki/Fokker-Planck_equation) # ([2](#diffu:randomwalk:1D:pde:dimless)). In other words, the key # results for random walk we found earlier can alternatively be # derived via a stochastic ordinary differential equation and its # related Fokker-Planck equation. # # # # ## Random walk in 2D # # The most obvious generalization of 1D random walk to two spatial # dimensions is to allow movements to the north, east, south, and west, # with equal probability $\frac{1}{4}$. # In[23]: def random_walk2D(x0, N, p, random=random): """2D random walk with 1 particle and N moves: N, E, W, S.""" # Store position in step k in position[k] d = len(x0) position = np.zeros((N+1, d)) position[0,:] = x0 current_pos = np.array(x0, dtype=float) for k in range(N): r = random.uniform(0, 1) if r <= 0.25: current_pos += np.array([0, 1]) # Move north elif 0.25 < r <= 0.5: current_pos += np.array([1, 0]) # Move east elif 0.5 < r <= 0.75: current_pos += np.array([0, -1]) # Move south else: current_pos += np.array([-1, 0]) # Move west position[k+1,:] = current_pos return position # The left plot in [Figure](#diffu:randomwalk:2D:fig:rect_vs_diag) provides # an example on 200 steps with this kind of walk. We may refer to this walk # as a walk on a *rectangular mesh* as we move from any spatial # mesh point $(i,j)$ to one of its four neighbors in the rectangular directions: # $(i+1,j)$, $(i-1,j)$, $(i,j+1)$, or $(i,j-1)$. # # # #
# #

Random walks in 2D with 200 steps: rectangular mesh (left) and diagonal mesh (right).

# # # # # # # ## Random walk in any number of space dimensions # # From a programming point of view, especially when implementing a random # walk in any number of dimensions, it is more natural to consider a walk # in the diagonal directions NW, NE, SW, and SE. On a two-dimensional spatial mesh # it means that we go from $(i,j)$ to either $(i+1,j+1)$, $(i-1,j+1)$, # $(i+1,j-1)$, or $(i-1,j-1)$. We can with such a *diagonal mesh* # (see right plot in [Figure](#diffu:randomwalk:2D:fig:rect_vs_diag)) # draw a Bernoulli variable for the step in each spatial direction and # trivially write code that works in any number of spatial directions: # In[24]: def random_walkdD(x0, N, p, random=random): """Any-D (diagonal) random walk with 1 particle and N moves.""" # Store position in step k in position[k] d = len(x0) position = np.zeros((N+1, d)) position[0,:] = x0 current_pos = np.array(x0, dtype=float) for k in range(N): for i in range(d): r = random.uniform(0, 1) if r <= p: current_pos[i] -= 1 else: current_pos[i] += 1 position[k+1,:] = current_pos return position # A vectorized version is desired. We follow the ideas from the section [Playing around with some code](#diffu:randomwalk:1D:code1), but each step is now a vector in $d$ # spatial dimensions. We therefore need to draw $Nd$ random numbers in `r`, # compute steps in the various directions through `np.where(r <=p, -1, 1)` # (each step being $-1$ or $1$), # and then we can reshape this array to an $N\times d$ array of step # *vectors*. Doing an `np.cumsum` summation along axis 0 will add # the vectors, as this demo shows: # In[25]: a = np.arange(6).reshape(3,2) a # In[26]: np.cumsum(a, axis=0) # With such summation of step vectors, we get all the positions to be # filled in the `position` array: # In[27]: def random_walkdD_vec(x0, N, p): """Vectorized version of random_walkdD.""" d = len(x0) # Store position in step k in position[k] position = np.zeros((N+1,d)) position[0] = np.array(x0, dtype=float) r = np.random.uniform(0, 1, size=N*d) steps = np.where(r <= p, -1, 1).reshape(N,d) position[1:,:] = x0 + np.cumsum(steps, axis=0) return position # # #
# #

Four random walks with 5000 steps in 2D.

# # # # # # ## Multiple random walks in any number of space dimensions # # As we did in 1D, we extend one single walk to a number of walks (`num_walks` # in the code). # # ### Scalar code # # As always, we start with implementing the scalar case: # In[28]: def random_walksdD(x0, N, p, num_walks=1, num_times=1, random=random): """Simulate num_walks random walks from x0 with N steps.""" d = len(x0) position = np.zeros((N+1, d)) # Accumulated positions position2 = np.zeros((N+1, d)) # Accumulated positions**2 # Histogram at num_times selected time points pos_hist = np.zeros((num_walks, num_times, d)) pos_hist_times = [(N//num_times)*i for i in range(num_times)] for n in range(num_walks): num_times_counter = 0 current_pos = np.array(x0, dtype=float) for k in range(N): if k in pos_hist_times: pos_hist[n,num_times_counter,:] = current_pos num_times_counter += 1 # current_pos corresponds to step k+1 for i in range(d): r = random.uniform(0, 1) if r <= p: current_pos[i] -= 1 else: current_pos[i] += 1 position [k+1,:] += current_pos position2[k+1,:] += current_pos**2 return position, position2, pos_hist, np.array(pos_hist_times) # ### Vectorized code # # Significant speed-ups can be obtained by vectorization. We get rid of # the loops in the previous function and arrive at the following vectorized # code. # In[29]: def random_walksdD_vec(x0, N, p, num_walks=1, num_times=1): """Vectorized version of random_walks1D; no loops.""" d = len(x0) position = np.zeros((N+1, d)) # Accumulated positions position2 = np.zeros((N+1, d)) # Accumulated positions**2 walks = np.zeros((num_walks, N+1, d)) # Positions of each walk walks[:,0,:] = x0 # Histogram at num_times selected time points pos_hist = np.zeros((num_walks, num_times, d)) pos_hist_times = [(N//num_times)*i for i in range(num_times)] r = np.random.uniform(0, 1, size=N*num_walks*d) steps = np.where(r <= p, -1, 1).reshape(num_walks, N, d) walks[:,1:,:] = x0 + np.cumsum(steps, axis=1) position = np.sum(walks, axis=0) position2 = np.sum(walks**2, axis=0) pos_hist[:,:,:] = walks[:,pos_hist_times,:] return position, position2, pos_hist, np.array(pos_hist_times) # # # # # # # # #