One of the simplest non-trivial examples of something interesting to do with Markov chains (that’s pretty easy to visualise and understand) is working out how many moves (on average) it takes to complete a game of snakes and ladders? This selection of an example is somewhat intentional because it’s the same example as the wikipedia entry for absorbing Markov chains, and because it’s just hard enough to actually justify solving the problem properly.
A full description of absorbing Markov chains is available on wikipedia, which is a near verbatim transcription from Kemeny and Snell’s book (also available online). In short, a (Discrete Time, Finite State) Markov chain is a system that describes the evolution of probability of going from one state to another in an instantaneous transition. Transitions between states are modelled using a stochastic matrix (a matrix where the rows must sum to one).
The wikipedia article on snakes and ladders quotes the solution as 39.6, for the Milton Bradley version of Chutes and Ladders. That’s a useful baseline for comparison, because the board layout is easy to find online. It’s also worth noting that the page for absorbing Markov chains quotes the solution as 39.2.
Each of the states in the chain represents a location on the board, and we’ll have one extra state (state 0) that represents the initial state off the board. That means we can conveniently number those state 0 - 100 (inclusive), where 0 is the initial state off the board, and 100 is the terminal state.
Ignoring (initially) the chutes and ladders, a transition from a state results in you advancing by the number of squares on a six sided dice. From any state $x$, you can reach states $x+1, x+2, …, x+6$, unless such a transition would take you off the board (beyond state 100), where the transition is invalid (which means you stay where you are). The probability of each of these transitions is 1⁄6, except for the edge case where the move is invalid - in those cases a small adjustment is needed to keep the player in that states.
def snakes_and_ladders_matrix():
n = 101
A = np.zeros((n, n))
for i in xrange(6):
A += np.diag(np.ones(n-1-i),i+1)
# work out the cells that are reachable for every initial cell
b = np.sum(A, axis=1)
# update the transition matrix to have a self-edge for every missing edge
A += np.diag(6 - b)
# normalize
A /= 6.
return A
A = snakes_and_ladders_matrix()
We can verify that the resulting matrix is a valid (right) stochastic matrix trivially - and it’s always a good idea to enforce constraints like this when working with Markov Chains.
np.testing.assert_allclose( 1, np.sum(A, axis=1) )
So for the game with no snakes or ladders, this is enough to show how many moves it take to finish the game. The equation for mean number of steps can be written in terms of the fundamental matrix $N$ and the identity matrix $I$:
$$ N = (I-Q)^{-1} $$
The equation is then
$$ t = N \mathbf{1} $$
Where $\mathbf{1}$ is the column vector of ones. While it’s possible to solve the equation like this, but only just, it’s not an effective way to use a computer to solve the equation. Solutions to linear equations on a computer are solved in the form:
$$ A x = b $$
But our unknown is on the wrong side of the equation. Somewhat conveniently, multiplying through by $I-Q$ gives:
$$ (I-Q) t = \mathbf{1} $$
This means we can solve the equation:
Q = A[:-1,:-1]
I = np.identity(Q.shape[0])
o = np.ones((Q.shape[0],1))
result = np.linalg.solve(I-Q, o)
print 'mean turns to complete (without snakes and ladders)', result[0, 0]
Which gives 33.3, which is close enough to give confidence that we’re in the ballpark when we add the snakes and ladders.
ladders = np.array([
[1, 38], [4, 14], [9, 31], [21,42],
[28,84], [36,44], [51,67], [71,91],
[80,100]
])
snakes = np.array([
[98, 78], [95, 75], [93, 73], [87, 24],
[64, 60], [62, 19], [56, 53], [49, 11],
[48, 26], [16, 6],
])
def snakes_and_ladders_matrix():
n = 101
A = np.zeros((n, n))
for i in xrange(6):
A += np.diag(np.ones(n-1-i),i+1)
# work out the cells that are reachable for every initial cell
b = np.sum(A, axis=1)
# update the transition matrix to have a self-edge for every missing edge
A += np.diag(6 - b)
edges = np.vstack([ladders, snakes])
for src, dst in edges:
b = A[:, src].copy()
A[:, dst] += b
A[:, src] = 0
A /= 6.
return A
A = snakes_and_ladders_matrix()
assert_allclose( 1, np.sum(A, axis=1) )
And running the code gives the solution
Q = A[:-1,:-1]
I = np.identity(Q.shape[0])
o = np.ones((Q.shape[0],1))
result = np.linalg.solve(I-Q, o)
print 'mean turns to complete', result[0, 0]
Gives 39.598