--- title: "Monte Carlo simulation studies with mcstatsim" author: "Imad El Badisy" date: "`r Sys.Date()`" output: rmarkdown::pdf_document: toc: true toc_depth: 2 number_sections: true vignette: > %\VignetteIndexEntry{Monte Carlo simulation studies with mcstatsim} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 6.5, fig.height = 3.8, fig.align = "center", dpi = 110 ) options(digits = 4) set.seed(1) has_ggplot2 <- requireNamespace("ggplot2", quietly = TRUE) # vignette code runs with at most 2 workers (the limit R CMD check enforces) sim_cores <- 2L ``` # Overview A Monte Carlo simulation study repeats the same experiment many times under several data-generating scenarios, then summarises how an estimator or a test behaves across those repetitions. `mcstatsim` organises such a study around a single higher-order function, `runsim()`: * you write the **one replication** as an ordinary R function of its parameters, * you list the **scenarios** as the rows of a data frame, * `runsim()` maps the function over `scenarios x replications`, in parallel, and returns one tidy data frame of replicate-level results. Everything else in the package operates on that data frame: `summarise_sim()` collapses it into a performance table, the `calc_*` functions compute the individual performance measures with Monte Carlo standard errors, and a handful of helpers and plots support diagnosis. The only hard dependency is `functionals`. `ggplot2` is used by the plotting helpers and is optional; this vignette skips the figures if it is not installed. ```{r load} library(mcstatsim) ``` # A first simulation Suppose we want to check the bias of the sample mean as an estimator of a normal mean. One replication draws a sample and returns the estimate: ```{r first-sim} sim_mean <- function(n, mu) { x <- rnorm(n, mean = mu) data.frame(estimate = mean(x)) } ``` The scenarios are a data frame whose column names match the arguments of `sim_func`: ```{r first-grid} grid <- expand.grid(n = c(10, 50), mu = c(0, 1)) grid ``` Run 1000 replications per scenario: ```{r first-run} res <- runsim( n = 1000, grid_params = grid, sim_func = sim_mean, seed = 101, num_cores = sim_cores, show_progress = FALSE ) head(res) ``` The result has one row per replication per scenario. `runsim()` prepends the scenario columns (`n`, `mu`) that `sim_func` did not itself return, and adds an `ID` column with the replication index. Now summarise: ```{r first-summary} summarise_sim( res, by = c("n", "mu"), measures = list(bias = ~ calc_bias(estimate, mu[1])) ) ``` `bias` is the estimated bias and `bias_mcse` its Monte Carlo standard error: the bias is within a couple of standard errors of zero in every scenario, as it should be for an unbiased estimator. # The `sim_func` contract `sim_func` must 1. take arguments that are a subset of the `grid_params` column names, 2. carry out exactly **one** replication, and 3. return a `data.frame` (usually one row; several rows are allowed and are all kept). It should **not** set a seed internally (see the next section) and should **not** draw plots. Anything constant within a scenario, such as the true parameter value, can be returned as a column so it is available to `summarise_sim()`: ```{r contract} sim_ci <- function(n, mu) { x <- rnorm(n, mean = mu) est <- mean(x) se <- sd(x) / sqrt(n) crit <- qt(0.975, df = n - 1) data.frame( est = est, lo = est - crit * se, hi = est + crit * se, truth = mu # constant within a scenario, handy for summarise_sim() ) } ``` The grid is normally built with `expand.grid()` for a full factorial design, but any data frame works, so you can also supply a hand-picked list of scenarios. # Reproducibility Passing `seed` makes a run reproducible **and** independent of the number of cores. Internally `runsim()` creates one independent `"L'Ecuyer-CMRG"` random number stream per job and assigns streams by position, so a given `(replication, scenario)` cell always draws from the same stream: ```{r repro} a <- runsim(200, grid, sim_mean, seed = 7, num_cores = 1, show_progress = FALSE) b <- runsim(200, grid, sim_mean, seed = 7, num_cores = 2, show_progress = FALSE) identical(a$estimate, b$estimate) ``` The seed used is stored on the result: ```{r repro-attr} attr(a, "seed") ``` Without `seed`, `runsim()` leaves the session RNG untouched and successive runs differ. Do not call `set.seed()` inside `sim_func`: it would tie every replication of a scenario to the same stream and destroy the independence the Monte Carlo standard errors rely on. # When a replication fails Long studies often have a few replications where a model does not converge or a solver errors. By default `runsim()` stops on the first error (`on_error = "stop"`). With `on_error = "warn"` or `"omit"` the failing jobs are dropped and recorded instead: ```{r on-error} flaky <- function(n, mu) { x <- rnorm(n, mu) if (mean(x) < -0.5) stop("solver failed to converge") data.frame(est = mean(x)) } res_f <- runsim(300, expand.grid(n = 15, mu = c(-1, 0, 1)), flaky, seed = 4, num_cores = sim_cores, show_progress = FALSE, on_error = "omit") nrow(res_f) ``` The failures are attached as an attribute and `failure_summary()` aggregates them per scenario: ```{r failure-summary} failure_summary(res_f) ``` Warnings raised inside `sim_func` are captured too (parallel back ends would otherwise swallow them) and land in `attr(res_f, "warnings")`. # Resuming a long run For studies that take hours, pass `save_path`. `runsim()` then runs the replications in chunks and rewrites an `.rds` checkpoint after each chunk. Calling `runsim()` again with the same `save_path` and matching `n`, `grid_params` and `seed` loads the completed replications and runs only the rest: ```{r checkpoint} ckpt <- tempfile(fileext = ".rds") # first call: imagine this is interrupted partway through r1 <- runsim(400, grid, sim_mean, seed = 9, save_path = ckpt, checkpoint_every = 50, num_cores = sim_cores, show_progress = FALSE) # a later call with the same settings resumes instead of restarting r2 <- runsim(400, grid, sim_mean, seed = 9, save_path = ckpt, checkpoint_every = 50, num_cores = sim_cores, show_progress = FALSE) identical(r1$estimate, r2$estimate) file.remove(ckpt) ``` Because streams are keyed by position, a resumed run reproduces an uninterrupted one exactly. # From replicates to a results table `summarise_sim(data, by, measures)` groups the replicate-level data by the scenario columns in `by` and evaluates each element of `measures` per group. A measure is either * a one-sided formula whose right-hand side is evaluated with the group's columns in scope, e.g. `~ calc_coverage(lo, hi, truth[1])`, or * a function of the group data frame, e.g. `function(d) calc_coverage(d$lo, d$hi, d$truth[1])`. A quantity that is constant within a scenario (a true parameter value) is a column of length equal to the number of replications, so index it with `[1]`. ```{r summarise} res_ci <- runsim(1000, expand.grid(n = c(15, 60), mu = 0), sim_ci, seed = 202, num_cores = sim_cores, show_progress = FALSE) perf <- summarise_sim( res_ci, by = "n", measures = list( bias = ~ calc_bias(est, truth[1]), coverage = ~ calc_coverage(lo, hi, truth[1]), width = ~ calc_width(lo, hi) ) ) perf ``` Each `calc_*` measure contributes an estimate column and a `*_mcse` column. The `n_sim` column reports the number of distinct replications behind each row. ## Rounding The result-producing functions take a `digits` argument (default `4`). It rounds the returned numbers for readability; `summarise_sim()` rounds the measure columns only, leaving the `by` columns and `n_sim` alone. Pass `digits = NULL` for full precision: ```{r digits} summarise_sim(res_ci, by = "n", measures = list(bias = ~ calc_bias(est, truth[1])), digits = NULL) ``` Measures built from `calc_*` are already rounded to those functions' own `digits`; write `~ calc_bias(est, truth[1], digits = NULL)` inside the measure if you need more precision than that. # The performance-measure functions Each `calc_*` function takes vectors of replicate-level quantities and returns a named list with the measure and its Monte Carlo standard error. | Function | Inputs | Returns | |---|---|---| | `calc_bias(est, true)` | estimates, true value | `bias`, `bias_mcse` | | `calc_variance(est)` | estimates | `variance`, `variance_mcse` | | `calc_mse(est, true)` | estimates, true value | `mse`, `mse_mcse` | | `calc_rmse(est, true)` | estimates, true value | `rmse`, `rmse_mcse` | | `calc_coverage(lo, hi, true)` | interval bounds, true value | `coverage`, `coverage_mcse` | | `calc_width(lo, hi)` | interval bounds | `width`, `width_mcse` | | `calc_rejection_rate(p, alpha)` | p-values | `rejection_rate`, `rejection_rate_mcse` | | `calc_relative_bias(est, true)` | estimates, non-zero true value | `rel_bias`, `rel_bias_mcse` | | `calc_relative_mse(est, true)` | estimates, non-zero true value | `rel_mse`, `rel_mse_mcse` | | `calc_relative_rmse(est, true)` | estimates, non-zero true value | `rel_rmse`, `rel_rmse_mcse` | They can be used directly, outside a simulation: ```{r calc-direct} set.seed(3) theta_hat <- rnorm(500, mean = 2.1, sd = 0.4) calc_bias(theta_hat, true_param = 2) calc_rmse(theta_hat, true_param = 2) ``` # Numerical diagnostics `check_estimates()` scans the numeric columns of a result for `NA`, `NaN` and `Inf`, per scenario, so a handful of blown-up replications are not folded silently into a summary: ```{r check-estimates} divergent <- function(n, mu) { x <- rnorm(n, mu) data.frame(est = mean(x), ratio = mu / (mu - 1)) # Inf when mu == 1 } res_d <- runsim(50, expand.grid(n = 20, mu = c(0, 1, 2)), divergent, seed = 5, num_cores = sim_cores, show_progress = FALSE) check_estimates(res_d, by = "mu") ``` `mcse_target()` answers "how many replications do I need?". Given the Monte Carlo standard error reached so far and the replication count that produced it, it returns the count needed for a target standard error: ```{r mcse-target} mcse_target(mcse_current = perf$coverage_mcse, n_current = perf$n_sim, target = 0.005) ``` # Visual diagnostics The three plotting helpers return `ggplot` objects and require the suggested `ggplot2` package. ```{r plot-data, include = FALSE} grid2 <- expand.grid(n = c(15, 60), dist = c("normal", "lognormal"), stringsAsFactors = FALSE) sim_two <- function(n, dist) { x <- if (dist == "normal") rnorm(n) else rlnorm(n) - exp(0.5) est <- mean(x) se <- sd(x) / sqrt(n) crit <- qt(0.975, df = n - 1) data.frame(est = est, lo = est - crit * se, hi = est + crit * se, truth = 0) } res2 <- runsim(1500, grid2, sim_two, seed = 303, num_cores = sim_cores, show_progress = FALSE) perf2 <- summarise_sim( res2, by = c("n", "dist"), measures = list(coverage = ~ calc_coverage(lo, hi, truth[1])) ) ``` `plot_convergence()` shows the running mean of a quantity against the replication index, with a Monte Carlo standard-error band, one panel per scenario. It shows whether a run used enough replications: ```{r plot-convergence, eval = has_ggplot2, fig.height = 4.4} plot_convergence(res2, "est", by = c("n", "dist")) ``` `plot_zip()` draws the zip plot of Morris, White and Crowther (2019). Within each scenario the confidence intervals are sorted by how extreme their z-statistic is and coloured by whether they cover; a well-calibrated procedure fills the panel up to the nominal line in the "covers" colour: ```{r plot-zip, eval = has_ggplot2, fig.height = 4} plot_zip(res2[res2$n == 15, ], "lo", "hi", "truth", by = "dist") ``` `plot_performance()` turns a `summarise_sim()` table into a point-range of a measure and its Monte Carlo error across the design: ```{r plot-performance, eval = has_ggplot2} plot_performance(perf2, "coverage", x = "n", colour = "dist", ref = 0.95) ``` # Case study: robust location estimators We compare three estimators of a symmetric centre, the sample mean, the 20% trimmed mean and the median, when a fraction `eps` of the data is replaced by a wide contaminating normal. The true centre is 0 for all three. ```{r case-sim} sim_location <- function(n, eps, estimator) { contaminated <- rbinom(n, 1, eps) == 1 x <- rnorm(n) x[contaminated] <- rnorm(sum(contaminated), sd = 8) est <- switch(estimator, mean = mean(x), trimmed = mean(x, trim = 0.2), median = median(x) ) data.frame(estimate = est, truth = 0) } design <- expand.grid( n = 40, eps = c(0, 0.05, 0.15), estimator = c("mean", "trimmed", "median"), stringsAsFactors = FALSE ) ``` ```{r case-run} sim_res <- runsim(2000, design, sim_location, seed = 2024, num_cores = sim_cores, show_progress = FALSE) ``` ```{r case-summary} perf_loc <- summarise_sim( sim_res, by = c("estimator", "eps"), measures = list( bias = ~ calc_bias(estimate, truth[1]), rmse = ~ calc_rmse(estimate, truth[1]) ) ) perf_loc[order(perf_loc$eps, perf_loc$rmse), ] ``` All three estimators are essentially unbiased (the contamination is symmetric), but their root mean squared error diverges as contamination grows: at `eps = 0.15` the sample mean is far worse than the trimmed mean and the median, while at `eps = 0` the mean is (slightly) the most efficient. This bias-free efficiency trade-off is the classic argument for robust estimators. ```{r case-plot, eval = has_ggplot2} plot_performance(perf_loc, "rmse", x = "eps", colour = "estimator") ``` # Practical notes * **Cores.** `num_cores` defaults to one less than the machine's core count. On Windows the parallel back end still works; very short replications may run faster with `num_cores = 1` because of worker start-up cost. * **Chunking.** `checkpoint_every` trades disk writes against lost work on a crash. Use a small value for expensive replications, a larger one for cheap ones. * **RNG hygiene.** Let `runsim()` own the randomness through `seed`. If a replication needs several independent random components, just draw them in sequence inside `sim_func`; they share the job's stream and stay independent across replications. * **Keep plotting out of `sim_func`.** Return numbers from `sim_func` and draw figures afterwards from the result data frame. * **Standard errors.** Report the `*_mcse` columns alongside every performance estimate, and use `mcse_target()` before committing to a replication count. # Reference Morris, T. P., White, I. R., and Crowther, M. J. (2019). Using simulation studies to evaluate statistical methods. *Statistics in Medicine*, 38(11), 2074-2102.