---
title: "A Complete Simulation Study with causalsim"
output:
rmarkdown::html_vignette:
toc: true
toc_depth: 3
vignette: >
%\VignetteIndexEntry{A Complete Simulation Study with causalsim}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r setup, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>",
fig.width = 7,
fig.height = 4.5,
out.width = "100%"
)
```
```{css, echo = FALSE}
.callout {
background-color: #f0f4f8;
border-left: 4px solid #3a7ebf;
padding: 0.75em 1em;
margin: 1.25em 0;
border-radius: 0 4px 4px 0;
}
.callout-warn {
background-color: #fff8e1;
border-left: 4px solid #f0a500;
padding: 0.75em 1em;
margin: 1.25em 0;
border-radius: 0 4px 4px 0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
margin: 1em 0;
}
th {
background-color: #3a7ebf;
color: white;
padding: 0.5em 0.75em;
text-align: left;
}
td {
padding: 0.4em 0.75em;
border-bottom: 1px solid #dde;
}
tr:nth-child(even) td {
background-color: #f5f7fa;
}
```
## Overview
Evaluating causal estimators requires knowing the answer. `causalsim` gives you
that by making the data-generating process explicit: you specify the structural
model, the package simulates data from it, and you measure how well any estimator
recovers the truth you specified.
If you only need a single dataset with known ground truth, `causalsim()` does it
in one call:
```r
data <- causalsim(n = 500, n_confounders = 1, effect = 2, seed = 1L)
head(data)
```
The columns `.tau` and `.p` carry the ground truth for whatever analysis you run
on the data. This vignette goes further: defining a DGP explicitly, evaluating
estimator performance over many replications, and sweeping across a parameter
grid.
### What this vignette covers
| Step | Function | What it does |
|---|---|---|
| 0 | `causalsim()` | Simulate one dataset in a single call |
| 1 | `causalsim_dgp()` | Define the structural model and true ATE |
| 2 | `causalsim_draw()` | Simulate one dataset and inspect it |
| 3 | `causalsim_eval()` | Measure estimator performance over many replications |
| 4 | `causalsim_eval_grid()` | Sweep over sample sizes and confounding levels |
```{r library}
library(causalsim)
```
---
## Step 1: Define the DGP
The structural model for this study is:
$$
W \sim N(0,1), \quad
A \mid W \sim \text{Bernoulli}\!\left(\text{logistic}(0.5\,W)\right), \quad
Y = 2A + 0.5W + \varepsilon, \quad
\varepsilon \sim N(0,1)
$$
In `causalsim_dgp()` terms: one standard-normal confounder, a constant effect of
2, moderate propensity confounding (logistic coefficient 0.5), and a moderate
baseline shift.
```{r dgp}
dgp <- causalsim_dgp(
n = 500,
n_confounders = 1,
effect = 2,
propensity = "moderate",
baseline = "moderate"
)
dgp
```
The true ATE is exact when `effect` is a scalar; no Monte Carlo approximation
is needed. For function-valued effects, `causalsim_dgp()` approximates the ATE
via 10,000 Monte Carlo draws at construction time.
---
## Step 2: Inspect a Draw
`causalsim_draw()` simulates one dataset from the DGP. The columns `.tau` and
`.p` are the individual causal effect and propensity score, diagnostic metadata
that is not available in real observational data.
```{r draw}
dat <- causalsim_draw(dgp, seed = 1L)
head(dat)
```
With moderate confounding, the treated and control groups differ on the
pre-treatment covariate:
```{r confounding-check}
aggregate(W ~ A, data = dat, FUN = mean)
```
That difference is exactly what makes naive regression biased. Any estimator
that omits `W` will absorb part of its association with `Y` into the treatment
coefficient.
---
## Step 3: Define Estimators
An estimator is any function that accepts the data frame returned by
`causalsim_draw()` and returns a named numeric vector. The `ci_lower` and
`ci_upper` fields are optional but enable coverage and power metrics.
```{r estimators}
# Naive: regresses Y on A only, omits the confounder
naive_est <- function(data) {
fit <- lm(Y ~ A, data = data)
est <- coef(fit)[["A"]]
se <- sqrt(vcov(fit)["A", "A"])
c(estimate = est, ci_lower = est - 1.96 * se, ci_upper = est + 1.96 * se)
}
# OLS: adjusts for the observed confounder W
ols_est <- function(data) {
fit <- lm(Y ~ A + W, data = data)
est <- coef(fit)[["A"]]
se <- sqrt(vcov(fit)["A", "A"])
c(estimate = est, ci_lower = est - 1.96 * se, ci_upper = est + 1.96 * se)
}
```
Named lists are also accepted, so the following is equivalent:
```{r estimator-list, eval = FALSE}
ols_est <- function(data) {
fit <- lm(Y ~ A + W, data = data)
ci <- confint(fit)["A", ]
list(
estimate = coef(fit)["A"],
ci_lower = ci[1],
ci_upper = ci[2]
)
}
```
---
## Step 4: Evaluate Each Estimator
`causalsim_eval()` runs `reps` independent replications and returns a tidy
summary of bias, RMSE, coverage, and power with Monte Carlo standard errors.
```{r eval-naive}
eval_naive <- causalsim_eval(dgp, naive_est, reps = 300L, seed = 1L)
eval_naive
```
```{r eval-ols}
eval_ols <- causalsim_eval(dgp, ols_est, reps = 300L, seed = 1L)
eval_ols
```
The naive estimator's bias is substantial: treatment is positively correlated
with $W$, which also raises $Y$ through the baseline, so the unadjusted
coefficient absorbs part of that association. OLS eliminates the bias by
conditioning on $W$. Coverage for the naive estimator falls well below the
nominal 95% because the confidence intervals are centered on the wrong value.
`summary()` adds the full distribution of per-replication estimates, and
`plot()` shows it as a histogram:
```{r summary-ols}
summary(eval_ols)
```
```{r plot-ols, fig.cap = "Distribution of OLS estimates over 300 replications. Solid line: true ATE. Dashed line: mean estimate."}
plot(eval_ols)
```
---
## Step 5: Vary Sample Size with `causalsim_eval_grid()`
`causalsim_eval_grid()` evaluates an estimator over the Cartesian product of the
supplied parameter values, returning a tidy data frame of metrics for each cell.
Here we vary `n` across four levels to track how the OLS estimator's precision
improves with more data.
```{r grid-n}
grid_n <- causalsim_eval_grid(
dgp = dgp,
estimator = ols_est,
vary = list(n = c(100L, 250L, 500L, 1000L)),
reps = 300L,
metrics = c("bias", "rmse"),
seed = 1L
)
grid_n
```
RMSE roughly halves as $n$ quadruples, consistent with $\sqrt{n}$-rate
convergence for OLS in a correctly specified model. Bias stays near zero at
every sample size.
---
## Step 6: Vary Confounding Strength
Varying the `propensity` preset shows how bias scales with confounding. Because
`causalsim_eval_grid()` accepts one estimator at a time, we run it separately and
combine the results.
```{r grid-confounding}
conf_levels <- list(propensity = c("low", "moderate", "high"))
grid_naive <- causalsim_eval_grid(dgp, naive_est,
vary = conf_levels,
reps = 300L,
metrics = "bias",
seed = 1L)
grid_ols <- causalsim_eval_grid(dgp, ols_est,
vary = conf_levels,
reps = 300L,
metrics = "bias",
seed = 1L)
comparison <- rbind(
cbind(estimator = "naive", grid_naive$results),
cbind(estimator = "ols", grid_ols$results)
)
comparison <- comparison[order(comparison$propensity, comparison$estimator), ]
rownames(comparison) <- NULL
comparison
```
Naive bias grows proportionally with confounding strength. OLS remains near zero
across all three levels because $W$ is observed and included in the model.
---
## Step 7: Heterogeneous Effects (Effect Modifiers)
So far the treatment effect has been constant. Real effects often vary across
subgroups. Declare a covariate with `role = "effect_modifier"` and reference it
in a function passed to `effect`:
```{r het-dgp}
het_dgp <- causalsim_dgp(
n = 4000,
covariates = list(
W = causalsim_covar("normal", role = "confounder"),
V = causalsim_covar("binary", role = "effect_modifier", prob = 0.5)
),
effect = function(V) 2 + 3 * V, # effect is 2 when V = 0, 5 when V = 1
propensity = function(W) plogis(0.5 * W),
baseline = function(W) W
)
het_dgp
```
The function passed to `effect` is what activates the modifier. A covariate
labelled `effect_modifier` but never referenced by `effect` is inert, and
`causalsim_dgp()` warns when that happens — so the role can never silently do
nothing.
Ground truth is carried per unit in `.tau`, so the subgroup effects are known
exactly:
```{r het-truth}
d <- causalsim_draw(het_dgp, seed = 1L)
tapply(d$.tau, d$V, mean) # 2 for V = 0, 5 for V = 1
```
An estimator that ignores the modifier recovers only the overall average
effect, while one that interacts treatment with `V` recovers the subgroup
effects:
```{r het-estimators}
overall <- lm(Y ~ A + W, data = d) # assumes a constant effect
interact <- lm(Y ~ A * V + W, data = d) # allows the effect to vary with V
c(
average = coef(overall)[["A"]],
subgroup_v0 = coef(interact)[["A"]],
subgroup_v1 = coef(interact)[["A"]] + coef(interact)[["A:V"]]
)
```
The constant-effect model lands near the true ATE (`r round(het_dgp$true_ate, 2)`),
but hides the heterogeneity; the interaction model recovers both subgroup
effects. This is the setup for benchmarking CATE / heterogeneous-effect
estimators (causal forests, meta-learners): because the true subgroup effects
are known, any estimator's recovery of them can be scored.
---
## Where to go next
This workflow (define, evaluate, grid) scales to more complex settings. A few
directions:
| Goal | How |
|---|---|
| Just generate data | Use `causalsim()` for a single dataset in one call |
| Heterogeneous effects | See Step 7 — declare an `effect_modifier` and pass a function to `effect` |
| Non-normal covariates | Use `causalsim_covar("binary")` or `causalsim_covar("uniform")` in `covariates` |
| Multiple confounders | Set `n_confounders = 3` or pass named `covariates` |
| Custom covariate structure | Mix `n_confounders` with explicit `covariates = list(...)` |
See `?causalsim`, `?causalsim_dgp`, and `?causalsim_covar` for the full API.
---
## Session info
```{r session-info}
sessionInfo()
```