Simulation exercise: solutions

Published

September 1, 2026

This document includes only one possible solution. Yours may vary. For the simulations, your specific quantities will also vary.

Setup (given)

library(tidyverse)

set.seed(365)

counties <- left_join(
    read_csv("https://princeton-ddss.github.io/r-bootcamp/files/county_data.csv"),
    read_csv("https://princeton-ddss.github.io/r-bootcamp/files/county_population.csv"),
    join_by(GEOID == fips)
)

Part 1: the birthday problem

One room

# Without `replace = TRUE`, no two people *can* share a birthday.
room <- sample(1:365, size = 23, replace = TRUE)

# `anyDuplicated()` returns the index of the first duplicate, or `0` if there isn't one, so comparing to zero returns T/F
anyDuplicated(room) > 0
[1] FALSE
## this also works; returns T/F:
#  any(duplicated(room))

Wrap it in a function

shared_birthday <- \(k) {
    birthdays <- sample(1:365, size = k, replace = TRUE)
    anyDuplicated(birthdays) > 0
}

replicate(5, shared_birthday(23))
[1] FALSE FALSE  TRUE  TRUE FALSE

Many rooms

rooms <- replicate(10000, shared_birthday(23))

mean(rooms)
[1] 0.5071

How many replications did you need?

reps <- c(10, 100, 1000, 10000, 100000)

ests <- sapply(
    reps,
    \(r) replicate(r, shared_birthday(23)) |> mean()
)

tibble(
    replications = reps,
    estimate = ests
)
# A tibble: 5 × 2
  replications estimate
         <dbl>    <dbl>
1           10    0.6  
2          100    0.46 
3         1000    0.518
4        10000    0.505
5       100000    0.508

Sweep across room sizes

ks <- 2:60
n <- 1000

prob <- sapply(
    ks,
    \(k) replicate(n, shared_birthday(k)) |> mean()
)

birthday_curve <- tibble(
    k = ks,
    prob = prob
)

head(birthday_curve, 3)
# A tibble: 3 × 2
      k  prob
  <int> <dbl>
1     2 0    
2     3 0.008
3     4 0.013

Plot it

ggplot(birthday_curve, aes(x = k, y = prob)) +
    geom_line(linewidth = 1) +
    geom_hline(yintercept = 0.5, linetype = "dotted") +
    geom_vline(xintercept = 23, linetype = "dotted") +
    labs(
        x = "People in the room",
        y = "Pr(at least one shared birthday)",
        title = "The birthday problem, simulated"
    ) +
    theme_minimal(base_size = 15)

min(birthday_curve$k[birthday_curve$prob > 0.5])
[1] 23

Check against the exact answer

exact_prob <- function(k) 1 - prod((365 - 0:(k - 1)) / 365)

birthday_curve <- birthday_curve |>
    mutate(exact = sapply(k, exact_prob))

exact_prob(23)
[1] 0.5072972
birthday_curve |>
    pivot_longer(
        cols = c(prob, exact),
        names_to = "source",
        values_to = "p"
    ) |>
    mutate(source = if_else(
        source == "prob",
        "Simulated",
        "Exact"
    )) |>
    ggplot(aes(x = k, y = p, color = source)) + ## switching to + now
    geom_line(linewidth = 1) +
    labs(
        x = "People in the room",
        y = "Pr(at least one shared birthday)",
        color = NULL
    ) +
    theme_minimal(base_size = 15) +
    theme(legend.position = "bottom")

The two curves are hard to tell apart. The largest gap anywhere on the sweep is given by:

round(max(abs(birthday_curve$prob - birthday_curve$exact)), 3)

Part 2: sampling and the bootstrap

The population

pop_all <- counties$pop[!is.na(counties$pop)]

ggplot(tibble(pop = pop_all), aes(x = pop)) +
    geom_histogram(bins = 60) +
    scale_x_continuous(
        labels = scales::label_number(scale_cut = scales::cut_short_scale())
    ) +
    labs(x = "County population", y = "Count") +
    theme_minimal(base_size = 15)

## note that the data are extremely skewed

mean(pop_all)
[1] 104575.2
sd(pop_all)
[1] 335250.6

Sampling distributions

sizes <- c(2, 5, 30, 100, 1000)

samp <- map_dfr(sizes, \(n) {
    tibble(
        n = n,
        xbar = replicate(10000, sample(pop_all, size = n, replace = T) |> mean())
    )
})

samp |>
    mutate(n = factor(n, levels = sizes, labels = paste("n =", sizes))) |>
    ggplot(aes(x = xbar)) +
    geom_histogram(bins = 50) +
    # The x-axes are **free**, so read the axis labels rather than the apparent widths.
    facet_wrap(~n, scales = "free", nrow = 1) +
    scale_x_continuous(
        labels = scales::label_number(scale_cut = scales::cut_short_scale())
    ) +
    labs(x = "Sample mean of county population", y = "Count") +
    theme_minimal(base_size = 13) +
    theme(axis.text.x = element_text(angle = 45, hjust = 1))

The width of those distributions

samp |>
    summarize(sim_sd = sd(xbar), .by = n) |>
    mutate(
        theory = sd(pop_all) / sqrt(n),
        ratio = sim_sd / theory
    )
# A tibble: 5 × 4
      n  sim_sd  theory ratio
  <dbl>   <dbl>   <dbl> <dbl>
1     2 246003. 237058. 1.04 
2     5 147210. 149929. 0.982
3    30  60133.  61208. 0.982
4   100  33912.  33525. 1.01 
5  1000  10638.  10602. 1.00 

Every simulated spread lands within about 5% of \(\sigma/\sqrt{n}\), but n >= 1000 gets us close.

Note the \(\sqrt{\ }\): to halve your uncertainty you need four times the data.

The middle 95%

quantile(samp$xbar[samp$n == 100], c(0.025, 0.975))
     2.5%     97.5% 
 57467.46 192179.57 

95% of samples of 100 counties give a mean between those two numbers. A sample landing outside happens one time in twenty.

The bootstrap

my_counties <- counties |>
    filter(!is.na(income)) |>
    slice_sample(n = 50)
mean(my_counties$income)
[1] 65099.86
boot50 <- replicate(
    10000,
    sample(my_counties$income, size = 50, replace = T) |>
        mean()
)

ggplot(tibble(m = boot50), aes(x = m)) +
    geom_histogram(bins = 40) +
    labs(x = "Bootstrap mean income", y = "Count") +
    theme_minimal(base_size = 15)

sd(boot50)
[1] 2116.174
quantile(boot50, c(0.025, 0.975))
    2.5%    97.5% 
61125.75 69343.31