The Basics of Simulation
Teaching · IE 5301 Advanced Operations Research · IE 3301 Engineering Probability
Every simulation starts from one thing: numbers that look uniformly random on . This page turns those uniforms into coin tosses, binomials, exponentials, Poissons, betas, normals and geometrics, then uses them to simulate a single-server queue and compare it with the textbook formulas, and finishes by estimating areas with random points.
Python and R use different random number generators, so the two versions draw different numbers even from the same seed. The formulas they are checked against are the same.
To run it yourself: pip install numpy pandas matplotlib. Every random number comes from one
generator with a fixed seed, so a top-to-bottom run gives the numbers shown here.
To run it yourself you need only base R; there is nothing to install. set.seed(123) fixes the
random numbers, so a top-to-bottom run gives the numbers shown here.
Python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
rng = np.random.default_rng(123) R
set.seed(123)
options(repr.plot.width = 6, repr.plot.height = 4.2, repr.plot.res = 100) From uniforms to coin tosses
A toss is heads when . With , toss a coin 1,000 times and track the running share of heads. Predict: after how many tosses does the share stay within 0.05 of one half?
Python
U = rng.random(1000)
heads = (U < 0.5).astype(int)
running = heads.cumsum() / np.arange(1, 1001)
fig, ax = plt.subplots(figsize=(6, 4.2))
ax.plot(np.arange(1, 1001), running, label="running share of heads")
ax.axhline(0.5, color="0.3", linestyle="--", label="p = 0.5")
ax.set(xlabel="toss number", ylabel="share of heads", xscale="log")
ax.legend()
plt.show()
outside = np.nonzero(np.abs(running - 0.5) > 0.05)[0]
print("share after 1000 tosses:", running[-1], "| last toss outside 0.45-0.55:", outside[-1] + 1) 
share after 1000 tosses: 0.501 | last toss outside 0.45-0.55: 396
R
U <- runif(1000)
heads <- as.integer(U < 0.5)
running <- cumsum(heads) / (1:1000)
plot(1:1000, running, type = "l", log = "x", xlab = "toss number", ylab = "running share of heads")
abline(h = 0.5, col = "gray30", lty = 2)
text(1000, 0.49, "p = 0.5", adj = c(1, 1)) # a text label: legend() boxes come out mis-sized in Jupyter's R kernel
outside <- which(abs(running - 0.5) > 0.05)
cat("share after 1000 tosses:", running[1000], "| last toss outside 0.45-0.55:", max(outside), "\n") share after 1000 tosses: 0.507 | last toss outside 0.45-0.55: 43

Each toss is a Bernoulli() variable, and a sum of of them is Binomial(, ). With and , do this 5,000 times and compare with the built-in binomial generator. Predict the mean and variance.
Python
X_built = (rng.random((5000, 1000)) < 0.2).sum(axis=1)
X_numpy = rng.binomial(1000, 0.2, size=5000)
print(f"built from uniforms: mean {X_built.mean():.2f}, variance {X_built.var():.1f}")
print(f"numpy's binomial: mean {X_numpy.mean():.2f}, variance {X_numpy.var():.1f}")
print("formulas: np =", 1000 * 0.2, "| np(1 - p) =", 1000 * 0.2 * 0.8) built from uniforms: mean 200.29, variance 159.3 numpy's binomial: mean 200.22, variance 160.9 formulas: np = 200.0 | np(1 - p) = 160.0
R
X_built <- rowSums(matrix(runif(5000 * 1000) < 0.2, nrow = 5000))
X_rbinom <- rbinom(5000, 1000, 0.2)
cat(sprintf("built from uniforms: mean %.2f, variance %.1f\n", mean(X_built), var(X_built)))
cat(sprintf("R's rbinom: mean %.2f, variance %.1f\n", mean(X_rbinom), var(X_rbinom)))
cat("formulas: np =", 1000 * 0.2, "| np(1 - p) =", 1000 * 0.2 * 0.8, "\n") built from uniforms: mean 200.13, variance 161.4 R's rbinom: mean 200.08, variance 162.9 formulas: np = 200 | np(1 - p) = 160
Any discrete distribution works the same way. Let , , and . Stack the probabilities into the cumulative values 0.35, 0.5, 0.9, 1 and return the first whose cumulative value reaches .
Python
values = np.array([1, 2, 3, 4])
F = np.cumsum([0.35, 0.15, 0.40, 0.10])
X = values[np.searchsorted(F, rng.random(10_000))]
print("cumulative:", F)
print(pd.Series(X).value_counts(normalize=True).sort_index().round(3).to_string()) cumulative: [0.35 0.5 0.9 1. ] 1 0.357 2 0.148 3 0.396 4 0.099
R
values <- 1:4
Fx <- cumsum(c(0.35, 0.15, 0.40, 0.10))
X <- values[findInterval(runif(10000), Fx, left.open = TRUE) + 1]
cat("cumulative:", Fx, "\n")
round(prop.table(table(X)), 3) cumulative: 0.35 0.5 0.9 1
X
1 2 3 4
0.354 0.148 0.398 0.101 Inverse transform for continuous variables
The same trick for a continuous variable: solve for . Take the density on . Its CDF is , so . The exact mean is . Predict: is a draw more likely to be above or below 0.5?
Python
X = rng.random(10_000) ** 0.4
print("mean:", X.mean().round(4), "| 5/7 =", round(5 / 7, 4), "| share above 0.5:", (X > 0.5).mean()) mean: 0.7113 | 5/7 = 0.7143 | share above 0.5: 0.817
R
X <- runif(10000)^0.4
cat("mean:", round(mean(X), 4), "| 5/7 =", round(5 / 7, 4), "| share above 0.5:", mean(X > 0.5), "\n") mean: 0.7152 | 5/7 = 0.7143 | share above 0.5: 0.8236
The exponential with rate has , so . Since is also uniform, works as well.
Python
lam = 1.0
X = -np.log(rng.random(10_000)) / lam
print(f"built: mean {X.mean():.3f}, sd {X.std():.3f} | numpy: mean {rng.exponential(1 / lam, 10_000).mean():.3f} | formula: 1/lambda = {1 / lam}") built: mean 0.993, sd 1.000 | numpy: mean 1.009 | formula: 1/lambda = 1.0
R
lam <- 1
X <- -log(runif(10000)) / lam
cat(sprintf("built: mean %.3f, sd %.3f | rexp: mean %.3f | formula: 1/lambda = %.1f\n", mean(X), sd(X), mean(rexp(10000, rate = lam)), 1 / lam)) built: mean 0.999, sd 0.988 | rexp: mean 0.983 | formula: 1/lambda = 1.0
Transformations: sums and counts of exponentials
A sum of independent Exponential() variables is Gamma(, ), with mean and variance . Here , and each row of the array is one gamma draw.
Python
n = 20
X = (-np.log(rng.random((10_000, n))) / lam).sum(axis=1)
print(f"built gamma: mean {X.mean():.2f}, variance {X.var():.2f} | formula: {n / lam}, {n / lam**2}") built gamma: mean 20.06, variance 19.85 | formula: 20.0, 20.0
R
n <- 20
X <- rowSums(matrix(-log(runif(10000 * n)) / lam, ncol = n))
cat(sprintf("built gamma: mean %.2f, variance %.2f | formula: %.1f, %.1f\n", mean(X), var(X), n / lam, n / lam^2)) built gamma: mean 20.03, variance 20.15 | formula: 20.0, 20.0
Turn it around: count how many exponential inter-arrival times fit into one unit of time. With arrival rate , that count is Poisson(2). The loop adds gaps until the clock passes 1. Predict the mean and the variance of the count.
Python
lam = 2.0
counts = []
for rep in range(10_000):
clock, k = -np.log(rng.random()) / lam, 0
while clock < 1:
k += 1
clock += -np.log(rng.random()) / lam
counts.append(k)
counts = np.array(counts)
print(f"built Poisson: mean {counts.mean():.3f}, variance {counts.var():.3f} | numpy: mean {rng.poisson(lam, 10_000).mean():.3f}") built Poisson: mean 1.981, variance 2.001 | numpy: mean 1.987
R
lam <- 2
counts <- replicate(10000, {
clock <- -log(runif(1)) / lam
k <- 0
while (clock < 1) {
k <- k + 1
clock <- clock - log(runif(1)) / lam
}
k
})
cat(sprintf("built Poisson: mean %.3f, variance %.3f | rpois: mean %.3f\n", mean(counts), var(counts), mean(rpois(10000, lam)))) built Poisson: mean 1.991, variance 2.020 | rpois: mean 1.996
Acceptance-rejection
Some CDFs cannot be inverted by hand. Beta(2, 4) has density on . Draw a candidate uniformly, and keep it with probability , where is the largest value of . Calculus puts the peak at , so . Find it numerically first, from the function on a fine grid.
Python
grid = np.linspace(0, 1, 10_001)
f_grid = 20 * grid * (1 - grid) ** 3
c = f_grid.max()
print("grid max:", round(c, 5), "at x =", grid[f_grid.argmax()], "| 135/64 =", 135 / 64) grid max: 2.10938 at x = 0.25 | 135/64 = 2.109375
R
grid <- seq(0, 1, length.out = 10001)
f_grid <- 20 * grid * (1 - grid)^3
c_max <- max(f_grid) # (not "c", which would hide R's c() function from a reader)
cat("grid max:", round(c_max, 5), "at x =", grid[which.max(f_grid)], "| 135/64 =", 135 / 64, "\n") grid max: 2.10938 at x = 0.25 | 135/64 = 2.109375
Draw 20,000 candidate pairs and keep whenever . Predict: what share of candidates is kept? (The area under is 1, and the box it sits in has area .)
Python
U1, U2 = rng.random(20_000), rng.random(20_000)
kept = U1[U2 <= 20 * U1 * (1 - U1) ** 3 / c]
print(f"kept {kept.size} of 20000 ({kept.size / 20000:.3f}) | 1/c = {1 / c:.3f}")
print(f"mean of kept: {kept.mean():.4f} | Beta(2,4) mean 2/6 = {2 / 6:.4f} | numpy's beta mean: {rng.beta(2, 4, 20_000).mean():.4f}") kept 9501 of 20000 (0.475) | 1/c = 0.474 mean of kept: 0.3316 | Beta(2,4) mean 2/6 = 0.3333 | numpy's beta mean: 0.3333
R
U1 <- runif(20000); U2 <- runif(20000)
kept <- U1[U2 <= 20 * U1 * (1 - U1)^3 / c_max]
cat(sprintf("kept %d of 20000 (%.3f) | 1/c = %.3f\n", length(kept), length(kept) / 20000, 1 / c_max))
cat(sprintf("mean of kept: %.4f | Beta(2,4) mean 2/6 = %.4f | rbeta mean: %.4f\n", mean(kept), 2 / 6, mean(rbeta(20000, 2, 4)))) kept 9490 of 20000 (0.474) | 1/c = 0.474 mean of kept: 0.3360 | Beta(2,4) mean 2/6 = 0.3333 | rbeta mean: 0.3336
Normals with the Box-Muller method
Two independent uniforms give two independent standard normals:
It is exact but slow, because of the square root, logarithm, sine and cosine. Below, is drawn against the built-in normal generator and the standard normal density.
Python
U1, U2 = rng.random(5000), rng.random(5000)
Z1 = np.sqrt(-2 * np.log(U1)) * np.cos(2 * np.pi * U2)
Z2 = np.sqrt(-2 * np.log(U1)) * np.sin(2 * np.pi * U2)
print(f"Z1: mean {Z1.mean():.3f}, sd {Z1.std():.3f} | Z2: mean {Z2.mean():.3f}, sd {Z2.std():.3f} | correlation {np.corrcoef(Z1, Z2)[0, 1]:.3f}")
Z3 = rng.standard_normal(5000)
z = np.linspace(-4, 4, 200)
fig, ax = plt.subplots(figsize=(6, 4.2))
ax.hist(Z1, bins=40, density=True, alpha=0.5, label="Box-Muller Z1")
ax.hist(Z3, bins=40, density=True, histtype="step", linewidth=1.5, label="numpy standard_normal")
ax.plot(z, np.exp(-z**2 / 2) / np.sqrt(2 * np.pi), "k--", label="N(0, 1) density")
ax.set(xlabel="z", ylabel="density")
ax.legend(fontsize=8)
plt.show()
print("share within 1.96 of 0:", np.mean(np.abs(Z1) < 1.96).round(3), "(Box-Muller),", np.mean(np.abs(Z3) < 1.96).round(3), "(numpy)") Z1: mean -0.022, sd 1.002 | Z2: mean -0.011, sd 1.012 | correlation 0.004

share within 1.96 of 0: 0.949 (Box-Muller), 0.946 (numpy)
R
U1 <- runif(5000); U2 <- runif(5000)
Z1 <- sqrt(-2 * log(U1)) * cos(2 * pi * U2)
Z2 <- sqrt(-2 * log(U1)) * sin(2 * pi * U2)
cat(sprintf("Z1: mean %.3f, sd %.3f | Z2: mean %.3f, sd %.3f | correlation %.3f\n", mean(Z1), sd(Z1), mean(Z2), sd(Z2), cor(Z1, Z2)))
Z3 <- rnorm(5000)
hist(Z1, breaks = 40, freq = FALSE, col = adjustcolor("steelblue", 0.5), border = "white",
xlim = c(-4, 4), main = "", xlab = "z", ylab = "density")
hist(Z3, breaks = 40, freq = FALSE, col = NA, border = "darkorange", add = TRUE)
curve(dnorm(x), from = -4, to = 4, lty = 2, lwd = 1.5, add = TRUE)
text(-4, 0.4, "filled: Box-Muller Z1\noutlined: rnorm\ndashed: N(0, 1) density", adj = c(0, 1), cex = 0.8)
cat("share within 1.96 of 0:", round(mean(abs(Z1) < 1.96), 3), "(Box-Muller),", round(mean(abs(Z3) < 1.96), 3), "(rnorm)\n") Z1: mean -0.033, sd 1.001 | Z2: mean -0.012, sd 0.981 | correlation 0.004 share within 1.96 of 0: 0.951 (Box-Muller), 0.949 (rnorm)

Counting trials: geometric and negative binomial
Repeat Bernoulli() trials until the first success; is the number of failures plus the one success. With , the mean is .
numpy's geometric counts trials the same way.
R's rgeom counts only the failures, so add 1 to compare.
Python
p = 0.2
K = []
for rep in range(10_000):
k = 1
while rng.random() > p:
k += 1
K.append(k)
print(f"built geometric: mean {np.mean(K):.3f} | numpy: {rng.geometric(p, 10_000).mean():.3f} | 1/p = {1 / p}") built geometric: mean 4.876 | numpy: 5.017 | 1/p = 5.0
R
p <- 0.2
K <- replicate(10000, {
k <- 1
while (runif(1) > p) k <- k + 1
k
})
cat(sprintf("built geometric: mean %.3f | rgeom + 1: %.3f | 1/p = %.1f\n", mean(K), mean(rgeom(10000, p) + 1), 1 / p)) built geometric: mean 4.982 | rgeom + 1: 4.994 | 1/p = 5.0
Keep going until successes and count all the trials: negative binomial, mean .
numpy's negative_binomial counts only the failures, so add to compare.
R's rnbinom counts only the failures, so add to compare.
Python
r = 2
trials = []
for rep in range(10_000):
successes, k = 0, 0
while successes < r:
k += 1
if rng.random() <= p:
successes += 1
trials.append(k)
print(f"built: mean {np.mean(trials):.3f} | numpy + r: {(rng.negative_binomial(r, p, 10_000) + r).mean():.3f} | r/p = {r / p}") built: mean 9.985 | numpy + r: 9.905 | r/p = 10.0
R
r <- 2
trials <- replicate(10000, {
successes <- 0
k <- 0
while (successes < r) {
k <- k + 1
if (runif(1) <= p) successes <- successes + 1
}
k
})
cat(sprintf("built: mean %.3f | rnbinom + r: %.3f | r/p = %.1f\n", mean(trials), mean(rnbinom(10000, r, p) + r), r / p)) built: mean 10.057 | rnbinom + r: 9.875 | r/p = 10.0
Queueing metrics
The usual measures of a queue, which carry over to most simulations:
- : average number of jobs in the system; : average number waiting in the queue
- : average time a job spends in the system; : average time waiting in the queue
- utilisation (share of time the server is busy) and throughput (jobs finished per unit time)
- the distributions of waiting time, system size and queue size, not just their averages
For an M/M/1 queue (exponential arrivals at rate , one exponential server at rate , ) the averages have formulas. A busy period (from an arrival at an empty system until it next empties) also has a mean:
With and : , , and and the mean busy period are both .
An M/M/1 queue, simulated
Simulate it event by event. With jobs present, the next event comes after an exponential time at rate (or if the system is empty), and it is an arrival with probability . Between events, add (time elapsed) to a running area; the area divided by the clock is the time-average . Run to time 500, ten times. Predict: will all ten estimates of land within 0.1 of 0.4286?
Python
lam, mu = 0.3, 1.0
rho = lam / mu
horizon, reps = 500, 10
L_sim, busy_share, busy_period = [], [], []
for rep in range(reps):
clock, n_jobs, area, busy, n_busy_periods = 0.0, 0, 0.0, 0.0, 0
while clock < horizon:
rate = lam + mu if n_jobs > 0 else lam
dt = rng.exponential(1 / rate)
area += n_jobs * dt
if n_jobs > 0:
busy += dt
clock += dt
if n_jobs == 0:
n_jobs, n_busy_periods = 1, n_busy_periods + 1 # an arrival starts a busy period
elif rng.random() < lam / rate:
n_jobs += 1 # an arrival
else:
n_jobs -= 1 # a departure
L_sim.append(area / clock)
busy_share.append(busy / clock)
busy_period.append(busy / n_busy_periods)
print("L from each run:", np.round(L_sim, 3)) L from each run: [0.259 0.359 0.383 0.523 0.451 0.46 0.496 0.473 0.455 0.391]
R
lam <- 0.3; mu <- 1
rho <- lam / mu
horizon <- 500; reps <- 10
L_sim <- busy_share <- busy_period <- numeric(reps)
for (rep in 1:reps) {
clock <- 0; n_jobs <- 0; area <- 0; busy <- 0; n_busy_periods <- 0
while (clock < horizon) {
rate <- if (n_jobs > 0) lam + mu else lam
dt <- rexp(1, rate)
area <- area + n_jobs * dt
if (n_jobs > 0) busy <- busy + dt
clock <- clock + dt
if (n_jobs == 0) {
n_jobs <- 1; n_busy_periods <- n_busy_periods + 1 # an arrival starts a busy period
} else if (runif(1) < lam / rate) {
n_jobs <- n_jobs + 1 # an arrival
} else {
n_jobs <- n_jobs - 1 # a departure
}
}
L_sim[rep] <- area / clock
busy_share[rep] <- busy / clock
busy_period[rep] <- busy / n_busy_periods
}
cat("L from each run:", round(L_sim, 3), "\n") L from each run: 0.358 0.478 0.416 0.509 0.263 0.269 0.539 0.383 0.299 0.512
Now the averages over the ten runs beside the formulas. comes from Little's law, , applied to each run.
Python
pd.DataFrame({
"formula": [rho / (1 - rho), 1 / (mu - lam), rho, 1 / (mu - lam)],
"simulated (mean of 10)": [np.mean(L_sim), np.mean(L_sim) / lam, np.mean(busy_share), np.mean(busy_period)],
"sd across runs": [np.std(L_sim, ddof=1), np.std(L_sim, ddof=1) / lam, np.std(busy_share, ddof=1), np.std(busy_period, ddof=1)],
}, index=["L", "W", "utilisation", "mean busy period"]).round(4) | formula | simulated (mean of 10) | sd across runs | |
|---|---|---|---|
| L | 0.4286 | 0.4249 | 0.0778 |
| W | 1.4286 | 1.4164 | 0.2592 |
| utilisation | 0.3000 | 0.3015 | 0.0444 |
| mean busy period | 1.4286 | 1.4268 | 0.1933 |
R
round(data.frame(
"formula" = c(rho / (1 - rho), 1 / (mu - lam), rho, 1 / (mu - lam)),
"simulated (mean of 10)" = c(mean(L_sim), mean(L_sim) / lam, mean(busy_share), mean(busy_period)),
"sd across runs" = c(sd(L_sim), sd(L_sim) / lam, sd(busy_share), sd(busy_period)),
row.names = c("L", "W", "utilisation", "mean busy period"), check.names = FALSE
), 4) formula simulated (mean of 10) sd across runs L 0.4286 0.4028 0.1043 W 1.4286 1.3426 0.3478 utilisation 0.3000 0.2846 0.0563 mean busy period 1.4286 1.3395 0.2372
Monte Carlo integration
Estimate (exactly ) by throwing points into a box and counting the share that land under the curve. The box spans from 0.01 to 1 and from 0 to the function's largest value on that range, found on a grid as before. Predict: with 1,000 points, how far from 1.8 will the estimate be?
Python
a, b = 0.01, 1.0
height = (np.linspace(a, b, 10_001) ** -0.5).max()
x = rng.uniform(a, b, 1000)
y = rng.uniform(0, height, 1000)
hit = y < x ** -0.5
print(f"box height {height:.2f} | hits {hit.sum()} of 1000 | estimate {(b - a) * height * hit.mean():.4f} | exact 1.8")
fig, ax = plt.subplots(figsize=(6, 4.2))
ax.scatter(x[hit], y[hit], s=6, marker="o", label="under the curve (hit)")
ax.scatter(x[~hit], y[~hit], s=8, marker="x", label="above the curve (miss)")
xs = np.linspace(a, b, 300)
ax.plot(xs, xs ** -0.5, "k-", label=r"$y = x^{-0.5}$")
ax.set(xlabel="x", ylabel="y")
ax.legend(fontsize=8)
plt.show() box height 10.00 | hits 195 of 1000 | estimate 1.9305 | exact 1.8

R
a <- 0.01; b <- 1
height <- max(seq(a, b, length.out = 10001)^-0.5)
x <- runif(1000, a, b)
y <- runif(1000, 0, height)
hit <- y < x^-0.5
cat(sprintf("box height %.2f | hits %d of 1000 | estimate %.4f | exact 1.8\n", height, sum(hit), (b - a) * height * mean(hit)))
plot(x[hit], y[hit], pch = 16, cex = 0.4, col = "steelblue", xlim = c(a, b), ylim = c(0, height), xlab = "x", ylab = "y")
points(x[!hit], y[!hit], pch = 4, cex = 0.5, col = "darkorange")
curve(x^-0.5, from = a, to = b, add = TRUE)
title(main = "dots: under the curve (hit) crosses: above the curve (miss)\nsolid line: y = x^(-0.5)", cex.main = 0.85, font.main = 1) box height 10.00 | hits 189 of 1000 | estimate 1.8711 | exact 1.8

The same idea for a semicircle: is half the area of the unit circle, . The box height is the function's largest value on the range, rounded up. Use a million points this time.
Python
a, b = -1.0, 1.0
height = np.ceil(np.sqrt(1 - np.linspace(a, b, 2001) ** 2).max())
x = rng.uniform(a, b, 1_000_000)
y = rng.uniform(0, height, 1_000_000)
estimate = (b - a) * height * np.mean(y < np.sqrt(1 - x ** 2))
print(f"box height {height:g} | estimate {estimate:.5f} | pi/2 = {np.pi / 2:.5f}") box height 1 | estimate 1.57148 | pi/2 = 1.57080
R
a <- -1; b <- 1
height <- ceiling(max(sqrt(1 - seq(a, b, length.out = 2001)^2)))
x <- runif(1e6, a, b)
y <- runif(1e6, 0, height)
estimate <- (b - a) * height * mean(y < sqrt(1 - x^2))
cat(sprintf("box height %g | estimate %.5f | pi/2 = %.5f\n", height, estimate, pi / 2)) box height 1 | estimate 1.57155 | pi/2 = 1.57080
Questions
- The ten simulated values of are spread out even though the model never changes. Would running one simulation to time 5,000 or ten to time 500 give you a better estimate, and why?
- Raise to 0.9 in the queue and rerun. What happens to and to the spread across runs?
- Acceptance-rejection threw away about half its candidates. What would happen to that share if were set larger than the true maximum, and would the kept values still be Beta(2, 4)?