Simulation-based power addresses how often an analysis would detect an effect if the world matched the specification exactly. pilotr estimates this by repeatedly simulating from the ground-truth specification, fitting the analysis model and recording the proportion of significant results. Beyond power, it reports the design-analysis quantities of Gelman and Carlin (2014), namely the Type S (sign) error and the Type M (magnitude, or exaggeration) ratio.
The mixed-effects examples below use small
n_sims, and their results were precomputed with exactly the code shown and shipped with the package, so that the vignette builds quickly. For real planning, we recommendn_sims >= 200, with more replicates for stable Type S and Type M estimates.
Two-group Gaussian
The classic case has a closed-form analytic power, which the simulation matches.
spec <- build_spec(list(
name = "two_group", seed = 1, design_kind = "between", n_subject = 64,
factor_name = "group", lev1 = "control", lev2 = "treatment",
intercept = 100, effect = 5, family = "gaussian",
resp_name = "score", sigma = 10))
pw <- power_design(spec, n_sims = 500)
unlist(pw[c("power", "type_s", "type_m", "true_effect", "mean_estimate")]) power type_s type_m true_effect mean_estimate
0.504000 0.000000 1.402398 5.000000 4.987050
At roughly 50% power, the Type M ratio is well above 1. Conditional on significance, the estimated effect is exaggerated, even though the average estimate over all replicates is unbiased. This reflects the statistical-significance filter, which is precisely what design analysis is meant to expose.
Crossed mixed-effects designs
A feature that distinguishes pilotr from a marginal simulator is
power estimation for crossed by-subject and by-item designs. The R
interface fits the maximal model
y ~ cond + (1 + cond | subject) + (1 + cond | item) with
lme4/lmerTest and tests the fixed effect with
Satterthwaite degrees of freedom.
spec_c <- build_spec(list(
name = "priming", seed = 1, design_kind = "within", include_items = TRUE,
n_subject = 24, n_item = 18,
factor_name = "condition", lev1 = "related", lev2 = "unrelated",
intercept = 6, effect = 0.06,
subj_int_sd = 0.12, subj_slope_sd = 0.04, subj_corr = 0.2,
item_int_sd = 0.08, item_slope_sd = 0.02, item_corr = -0.1,
family = "shifted_lognormal", resp_name = "RT", sigma = 0.3, shift = 200))
# A tiny replicate count keeps the vignette fast. Use 200 or more for real planning.
pm <- power_mixed(spec_c, n_sims = 20)
unlist(pm[c("power", "type_s", "type_m", "n_converged")]) power type_s type_m n_converged
0.800000 0.000000 1.193659 20.000000
n_converged reports how many replicates the maximal
model actually fit. This is a useful diagnostic in its own right, since
convergence problems are common in small crossed designs, and it is the
denominator of power, which is the significant proportion
among the converged replicates.
power_mixed() carries its own simulation loop over the
portable specification, with no other power package underneath it. It
covers territory pioneered by simr (Green and
MacLeod, 2016) and mixedpower (Kumle,
Vo and Draschkow, 2021). pilotr differs in being driven by the
cross-language specification, in reporting Type S and Type M errors
alongside power and in built-in parallelisation.
A power curve
The following sweep over the number of subjects reports power at each size.
curve <- power_curve_mixed(
spec_c,
subject_ns = c(8, 12, 16, 24, 32, 44, 56),
n_sims = 50)
curve n_subject power type_m n_converged
1 8 0.16 1.998682 50
2 12 0.34 1.639186 50
3 16 0.48 1.404611 50
4 24 0.80 1.189207 50
5 32 0.86 1.129062 50
6 44 0.96 1.046713 50
7 56 0.96 1.048449 50
The sample size the curve implies
The curve is a means to an end. What the analysis is run for is the
sample size at which power reaches the target, and that is the number a
preregistration quotes. Reading it off the table or the plot judges
points whose Monte Carlo intervals overlap, and yields a bare figure
with no interval attached to it. target_n() fits the curve
and inverts the fit, so the crossing is estimated, and arrives with the
uncertainty a simulated curve carries.
n n_lo n_hi
29 25 33
Fifty replicates per point is few, and the interval says so. The fit
is a binomial regression of power on the square root of the sample size,
weighted by the replicates behind each point, and the interval is the
delta-method interval that MASS::dose.p() computes for a
fitted glm. Nothing is extrapolated: a curve that never
reaches the target within the sizes swept is refused, and the refusal
reports the range the sweep did cover.
For a curve swept over something other than sample size,
solve_curve() is the general form, with
transform = "identity" where the axis is an effect
size.
Plotting the curve and the solved size together shows what the solve has done.
# The transparent device canvas is what lets the page colour through. A ggplot
# theme alone cannot do it, since the device paints white underneath. The
# website's dark mode then inverts the figure's ink, so the axes and labels
# follow the theme and the figure carries no opaque matte.
library(ggplot2)
# Each power estimate is a proportion over the converged replicates, so it
# carries a binomial Monte Carlo standard error. The shaded band is the 95%
# interval.
curve$se <- sqrt(curve$power * (1 - curve$power) / curve$n_converged)
ggplot(curve, aes(n_subject, power)) +
geom_hline(yintercept = 0.8, linetype = 2, colour = "grey60") +
# The solved sample size and its interval. The dashed horizontal line marks
# the target and the band marks where the curve reaches it.
annotate("rect", xmin = solved$lo, xmax = solved$hi, ymin = -Inf, ymax = Inf,
fill = "grey60", alpha = .15) +
geom_vline(xintercept = solved$value, linetype = 2, colour = "grey60") +
geom_ribbon(aes(ymin = pmax(0, power - 1.96 * se),
ymax = pmin(1, power + 1.96 * se)),
alpha = .15, fill = "#2C6FB0") +
geom_line(colour = "#2C6FB0", linewidth = 0.8) +
geom_point(colour = "#2C6FB0", size = 2.6) +
scale_y_continuous(limits = c(0, 1)) +
labs(x = expression(italic(N) ~ "subjects"), y = "Power") +
theme_minimal(base_size = 12) +
# theme_minimal still paints a white plot.background over the transparent
# canvas, so both surfaces have to be cleared for the page colour to reach the
# figure. The ink is left at its default, because the website inverts the
# figure in dark mode, which turns the dark axis text light, whereas a fixed
# mid-grey would be inverted into a muddy tan.
theme(plot.background = element_rect(fill = NA, colour = NA),
panel.background = element_rect(fill = NA, colour = NA),
panel.grid = element_line(colour = "grey80"))
The shaded horizontal band along the curve is the Monte Carlo interval, the binomial standard error of each power estimate over its converged replicates widened to a 95% envelope. The vertical band is the solved sample size and its own interval, which is what the dashed target line invites the reader to guess at.
Parallel execution
Every power and precision analysis in pilotr takes a
workers argument that spreads the Monte Carlo replicates
across local cores. Each replicate takes its own seed from
replicate_seeds(), so the results are identical to a serial
run whatever the worker count, and parallelisation costs nothing in
reproducibility. The mixed-model fits dominate the running time, which
makes the speed-up close to linear in the number of cores. In a sweep
the worker pool is created once and reused across all sample sizes.
power_curve_mixed(
spec_c, subject_ns = seq(20, 60, 10), n_sims = 500, workers = 8)This design answers a serial bottleneck familiar from
simr::powerCurve(), which this package’s maintainer
previously worked around by splitting the sample-size grid across
separate jobs by hand and recombining the results afterwards (Bernabeu,
2021). In pilotr the same gain takes one argument.
A bridge to the Bayesian workflow
For a confirmatory Bayesian fit, brms_bridge() emits a
ready-to-run brms model. It provides the family, the fixed
and random-effects formula, and a weakly-informative prior set, all
derived from the same specification, so that the planning model and the
confirmatory model remain consistent.
brms_bridge(spec_c)library(brms)
fit <- brm(
RT ~ effect + (1 + effect | subject) + (1 + effect | item),
data = your_data,
family = shifted_lognormal(),
prior = c(
prior(normal(0, 2.5), class = "Intercept"),
prior(normal(0, 0.5), class = "b", coef = "effect"),
prior(normal(0, 1), class = "sd"),
prior(lkj(2), class = "cor")
),
chains = 4, iter = 4000, warmup = 2000, cores = 4,
control = list(adapt_delta = 0.95)
)
See also
The precision / ROPE vignette covers design analysis against a region of practical equivalence, where the question is whether an effect is large enough to matter.