Worked example — a Markov chain & an M/M/1 queue, simulated vs. solved
A complete, runnable mini-project that ties together the stochastic-process half of the course: build a discrete-time Markov chain, solve for its stationary distribution two independent ways, then simulate a continuous-time M/M/1 queue and check the measured metrics against the closed-form theory and Little's law.
Goal. Take two of the course's central models — the discrete-time Markov chain (sessions 8–9) and the M/M/1 queue built on the exponential distribution and the Poisson process (sessions 10–12) — and treat them the way a working computer scientist would: write down the probability, compute the exact answer, then build a simulator and confirm the two agree. When the long-run average of a noisy simulation lands on the number an eigenvector predicted, the theory stops being abstract.
The project is deliberately small and self-contained: roughly 120 lines of numpy, no external state, fully reproducible from a fixed seed. It is exactly the kind of "small prototype with details about construction" the syllabus asks for as group work, and the metrics it produces are the ones listed under Queuing theory · performance metrics.
On this page: 1 · the probability · 2 · design · 3 · implementation · 4 · results · 5 · learning outcomes · 6 · extensions · 7 · references
1. The probability
Everything the code computes can be written on one page. We split it into the discrete-time chain and the continuous-time queue.
1a Discrete-time Markov chain
A Markov chain on a finite state space $\mathcal{S}=\{1,\dots,n\}$ is governed by a row-stochastic transition matrix $P$, where $P_{ij}=\Pr(X_{t+1}=j \mid X_t=i)$. The Markov property says the future depends on the present only — not the path taken to get there:
If the row vector $\mu_t$ holds the distribution over states at time $t$, then one step forward is just a vector–matrix product, $\mu_{t+1} = \mu_t P$, and $k$ steps is $\mu_{t+k} = \mu_t P^{k}$. For an irreducible, aperiodic chain this converges to a unique stationary distribution $\pi$ — the row vector fixed by $P$:
Equivalently, $\pi^\top$ is the left eigenvector of $P$ (the eigenvector of $P^\top$) for eigenvalue $1$.
1b Exponential inter-arrivals & the Poisson process
The queue runs in continuous time. Customers arrive as a Poisson process of rate $\lambda$, which is equivalent to saying the gaps between arrivals are i.i.d. $\mathrm{Exponential}(\lambda)$. Service times are also $\mathrm{Exponential}(\mu)$. The exponential is the only continuous distribution that is memoryless:
Memorylessness is what makes the queue a (continuous-time) Markov chain on the number of customers in the system: how long the current arrival or service has already taken tells you nothing about how much longer it will take.
1c The M/M/1 queue & Little's law
M/M/1 = Markovian arrivals, Markovian service, 1 server, infinite FIFO buffer. Define the utilization (traffic intensity) $\rho=\lambda/\mu$. The queue is stable only when $\rho<1$; otherwise work arrives faster than it can be cleared and the line grows without bound. For a stable M/M/1 queue the steady-state metrics have clean closed forms:
$L$ is the mean number in the system, $L_q$ the mean number waiting, $W$ the mean time in system, and $W_q$ the mean waiting time before service. These four are not independent — they are stitched together by Little's law, which holds for essentially any stable queueing system, M/M/1 or not:
2. Design
Two modules, each "compute then corroborate". The chain is solved algebraically and confirmed by iteration; the queue is solved by formula and confirmed by event-driven simulation.
The chain. We use a 4-state "web-navigation / PageRank-lite" model: four pages of a tiny site, with $P_{ij}$ the probability a reader on page $i$ clicks through to page $j$. The stationary $\pi$ is then the long-run share of visits each page receives — a stripped-down PageRank.
The queue. A single request-handler (one server, e.g. a web worker) processes jobs FIFO. Jobs arrive Poisson($\lambda$) and take Exponential($\mu$) to serve. We pick $\lambda=1.2$, $\mu=1.5$ so that $\rho=0.8$ — busy but stable, the regime where queues actually bite.
Why event-driven. Rather than stepping a tiny clock, we generate all inter-arrival and service times up front and compute each customer's start/departure with the Lindley recursion $d_k = \max(a_k,\, d_{k-1}) + s_k$. Then $L$ comes from time-integrating the number-in-system over a merged event timeline — an $O(n\log n)$ sort, no fixed time step, no discretization error.
3. Step-by-step implementation
Real, runnable NumPy. Paste the blocks in order into one file (mm1_markov.py) and run it; the printed output is reproduced verbatim in section 4.
3.1 Build the chain & solve $\pi$ by eigenvector
Define the transition matrix and assert each row sums to 1. The stationary distribution is the eigenvector of $P^\top$ whose eigenvalue is closest to 1, renormalized to sum to one.
# mm1_markov.py — part 1 of 3
import numpy as np
# 4-page "web navigation" chain: P[i,j] = P(go to page j | on page i)
P = np.array([
[0.10, 0.40, 0.30, 0.20],
[0.20, 0.10, 0.40, 0.30],
[0.25, 0.25, 0.10, 0.40],
[0.30, 0.30, 0.30, 0.10],
])
assert np.allclose(P.sum(axis=1), 1.0) # rows are probability distributions
def stationary_eig(P):
"""pi P = pi <=> pi is the left eigenvector of P for eigenvalue 1."""
vals, vecs = np.linalg.eig(P.T) # left eigvecs of P = eigvecs of P^T
i = np.argmin(np.abs(vals - 1.0)) # pick eigenvalue closest to 1
pi = np.real(vecs[:, i])
return pi / pi.sum() # normalise to a distribution
pi_eig = stationary_eig(P)
3.2 Solve $\pi$ again by power iteration
Start from any distribution and repeatedly apply $\mu \leftarrow \mu P$. For an irreducible aperiodic chain this converges geometrically to $\pi$. This is the algorithm behind PageRank.
# mm1_markov.py — part 1 (cont.)
def stationary_power(P, tol=1e-14, max_iter=100_000):
"""Repeatedly push a distribution through P until it stops moving."""
n = P.shape[0]
mu = np.full(n, 1.0 / n) # start uniform
for _ in range(max_iter):
nxt = mu @ P
if np.max(np.abs(nxt - mu)) < tol:
return nxt
mu = nxt
return mu
pi_pow = stationary_power(P)
print("pi (eigenvector) :", np.round(pi_eig, 6))
print("pi (power iter) :", np.round(pi_pow, 6))
print("agree? :", np.allclose(pi_eig, pi_pow))
print("pi P == pi ? :", np.allclose(pi_pow @ P, pi_pow))
3.3 Analytic M/M/1 metrics
The closed forms from section 1c, written once:
# mm1_markov.py — part 2 of 3
def mm1_theory(lam, mu):
"""Closed-form steady-state metrics for a stable M/M/1 queue (rho < 1)."""
assert lam < mu, "unstable: arrivals outpace service (rho >= 1)"
rho = lam / mu
return {
"rho": rho, # utilization = P(server busy)
"L": rho / (1 - rho), # mean number in system
"Lq": rho**2 / (1 - rho), # mean number waiting
"W": 1 / (mu - lam), # mean time in system
"Wq": rho / (mu - lam), # mean wait before service
}
lam, mu = 1.2, 1.5 # rho = 0.8: busy but stable
theory = mm1_theory(lam, mu)
3.4 Simulate the queue (event-driven, Lindley recursion)
Draw all inter-arrival and service times, accumulate arrival epochs, then walk the Lindley recursion to get each customer's service-start and departure. From those we read $W$ and $W_q$ directly; for $L$ we integrate the number-in-system over a merged, sorted timeline of $+1$ (arrival) and $-1$ (departure) events.
# mm1_markov.py — part 3 of 3
def simulate_mm1(lam, mu, n_cust=200_000, seed=42):
"""Event-driven FIFO single-server queue. Returns measured metrics."""
rng = np.random.default_rng(seed)
inter = rng.exponential(1.0 / lam, n_cust) # gaps ~ Exp(lam) (Poisson arrivals)
serve = rng.exponential(1.0 / mu, n_cust) # service ~ Exp(mu)
arrive = np.cumsum(inter) # arrival epochs a_k
start = np.empty(n_cust)
depart = np.empty(n_cust)
for k in range(n_cust): # Lindley: d_k = max(a_k, d_{k-1}) + s_k
s = arrive[k] if k == 0 else max(arrive[k], depart[k - 1])
start[k] = s
depart[k] = s + serve[k]
W = (depart - arrive).mean() # time in system
Wq = (start - arrive).mean() # time waiting
# time-average L: integrate #-in-system over a merged event timeline
t = np.concatenate([arrive, depart])
d = np.concatenate([np.ones(n_cust), -np.ones(n_cust)])
order = np.argsort(t, kind="mergesort") # stable sort of all events
t, d = t[order], d[order]
n_in = np.cumsum(d) # number in system after each event
dt = np.diff(t)
L = np.sum(n_in[:-1] * dt) / (t[-1] - t[0])
util = serve.sum() / depart[-1] # fraction of time server is busy
return {"rho": util, "L": L, "Lq": L - util, "W": W, "Wq": Wq}
sim = simulate_mm1(lam, mu)
# --- compare sim vs theory, and verify Little's law from measured numbers ---
print(f"{'metric':<6}{'theory':>12}{'sim':>12}")
for k in ["rho", "L", "Lq", "W", "Wq"]:
print(f"{k:<6}{theory[k]:>12.4f}{sim[k]:>12.4f}")
print(f"Little's law L = lam*W : {lam * sim['W']:.4f} vs L_sim {sim['L']:.4f}")
4. Results
Output of one run (seed 42, 200,000 customers). The chain's two solvers agree to machine precision; the queue simulation lands on the analytic metrics and satisfies Little's law.
4a Stationary distribution of the chain
pi (eigenvector) : [0.217292 0.256799 0.2714 0.254509] pi (power iter) : [0.217292 0.256799 0.2714 0.254509] agree? : True pi P == pi ? : True
Both methods give the same $\pi$. Page 3 is the most-visited (≈27.1%), page 1 the least (≈21.7%) — the long-run "PageRank" of this little site. Power iteration reached this from a uniform start in a handful of steps:
| step $t$ | $\mu_1$ | $\mu_2$ | $\mu_3$ | $\mu_4$ |
|---|---|---|---|---|
| 1 | 0.1000 | 0.4000 | 0.3000 | 0.2000 |
| 2 | 0.2250 | 0.2150 | 0.2800 | 0.2800 |
| 3 | 0.2195 | 0.2655 | 0.2655 | 0.2495 |
| 5 | 0.2175 | 0.2568 | 0.2709 | 0.2548 |
| 8 | 0.2173 | 0.2568 | 0.2714 | 0.2545 |
| $\infty$ ($\pi$) | 0.2173 | 0.2568 | 0.2714 | 0.2545 |
4b M/M/1 queue — simulation vs. theory
metric theory sim rho 0.8000 0.7990 L 4.0000 3.9757 Lq 3.2000 3.1767 W 3.3333 3.3110 Wq 2.6667 2.6450 Little's law L = lam*W : 3.9731 vs L_sim 3.9757
| metric | symbol | theory | simulated | rel. error |
|---|---|---|---|---|
| Utilization | $\rho$ | 0.8000 | 0.7990 | 0.1% |
| Mean number in system | $L$ | 4.0000 | 3.9757 | 0.6% |
| Mean number waiting | $L_q$ | 3.2000 | 3.1767 | 0.7% |
| Mean time in system | $W$ | 3.3333 | 3.3110 | 0.7% |
| Mean wait before service | $W_q$ | 2.6667 | 2.6450 | 0.8% |
5. Mapping to learning outcomes
How each piece of the project exercises a syllabus learning objective and the sessions behind it.
- LO 1Fundamental concepts — random variables & distributions. Inter-arrival and service times are drawn from the exponential distribution; the chain's rows are categorical distributions. Exercises the discrete/continuous foundation of sessions 2 and 10. (continuous demo)
- LO 2Model stochastic processes — Markov chains & Poisson processes. The core of the project: a transition matrix, its stationary distribution, and a Poisson arrival stream feeding the queue. Directly exercises sessions 8, 9, 11. (sessions 8–11)
- LO 2Steady-state / long-run behaviour. Solving $\pi P=\pi$ two ways is exactly session 8's "solving for steady-state probabilities"; the M/M/1 metrics are session 12's "performance metrics: waiting time, queue length".
- LO 4Apply probabilistic models to computing problems. The chain is a PageRank-lite web model; the queue is a request-handler capacity model. Both are concrete CS systems, demonstrating probabilistic thinking applied to algorithms and systems.
- protoGroup-work deliverable. A self-contained, reproducible prototype with documented construction — matching the "small prototype with details about construction" assessment criterion.
6. Extensions
Natural next steps, each a small change to the same code.
- Continuous-time Markov chain (CTMC). Replace the discrete transition matrix with a rate matrix $Q$ and solve $\pi Q = 0$ instead of $\pi P=\pi$. The M/M/1 queue is itself a CTMC on the state "number in system" with birth rate $\lambda$ and death rate $\mu$ — deriving $\pi_k=(1-\rho)\rho^k$ from $Q$ recovers $L=\rho/(1-\rho)$ analytically.
- M/M/c — multiple servers. Add $c$ parallel servers and compare one fast server to several slow ones at equal total capacity. Requires the Erlang-C formula for $W_q$; the simulator change is a small priority-queue of server-free times.
- Full PageRank. Add a damping factor $\alpha$ and a teleport vector: $P' = \alpha P + (1-\alpha)\,\tfrac{1}{n}\mathbf{1}\mathbf{1}^\top$. Power iteration on $P'$ is the real PageRank; this guarantees irreducibility even on a web graph with dangling pages.
- Absorbing chains. Add an absorbing state and compute expected time to absorption via the fundamental matrix $N=(I-Q)^{-1}$ — exactly session 9's "expected time to absorption".
- Confidence on the estimates. Run the simulation across many seeds and put a CLT-based confidence interval around each measured metric — connecting back to the confidence-intervals demo.
7. References
- Sheldon M. Ross. Introduction to Probability Models, 12th ed. — chapters on Markov chains, the exponential distribution & the Poisson process, and queueing theory (M/M/1, Little's law). ISBN 9780128143476 (course-recommended text)
- Daphne Koller & Nir Friedman. Probabilistic Graphical Models: Principles and Techniques — background on stationary distributions and inference. ISBN 9780262013192 (course-recommended text)
- J. R. Norris. Markov Chains, Cambridge University Press — rigorous treatment of irreducibility, aperiodicity and convergence to stationarity. ISBN 9780521633963
- L. Page, S. Brin, R. Motwani, T. Winograd (1999). The PageRank Citation Ranking: Bringing Order to the Web — the stationary distribution of a web-navigation chain.
- J. D. C. Little (1961). A Proof for the Queuing Formula $L=\lambda W$, Operations Research 9(3).
- Course syllabus — Probability for Computing Science (PCS-CSAI.2.M.A), sessions 8–12.