rm(list=ls())
library(parallel)
library(dplyr)
library(metalite.ae)
library(risks)
n_cores <- max(1, detectCores() - 1) # leave one core free
scenarios <- list(
NULLS = list(scen=1, perarm=16, stratprevalence=0.2, effectip=0.45,effectplus=0.0,effectplb=0.45),
SCEN1 = list(scen=2, perarm=16, stratprevalence=0.1, effectip=0.45,effectplus=0.1,effectplb=0.1),
SCEN2 = list(scen=3, perarm=16, stratprevalence=0.2, effectip=0.45,effectplus=0.1,effectplb=0.1),
SCEN3 = list(scen=4, perarm=16, stratprevalence=0.3, effectip=0.45,effectplus=0.1,effectplb=0.1),
SCEN4 = list(scen=5, perarm=16, stratprevalence=0.4, effectip=0.45,effectplus=0.1,effectplb=0.1),
SCEN5 = list(scen=6, perarm=16, stratprevalence=0.3, effectip=0.4,effectplus=0.2,effectplb=0.15),
SCEN6 = list(scen=7, perarm=16, stratprevalence=0.3, effectip=0.35,effectplus=0.1,effectplb=0.15)
)
simulate_once <- function(params,
seed){
if (!is.null(seed)) set.seed(seed)
scen <- params$scen
perarm <- params$perarm
stratprevalence <- params$stratprevalence
effectip <- params$effectip
effectplus <- params$effectplus
effectplb <- params$effectplb
# sample size per arm
samparm <- perarm
# prevalence of stratification
stratum <- stratprevalence
# --- Simulation Parameters ---
# n[1]: stratum 1
# n[2]: stratum 2
# IP and PBO sizes per stratum
n1 <- c(samparm-ceiling(samparm*stratum), ceiling(samparm*stratum));
n2 <- c(samparm-ceiling(samparm*stratum), ceiling(samparm*stratum));
# effect size for IP and Placebo
# subjects with biology use history have better effect + ~x%
effectplb <- effectplb
effectip <- effectip
effectplus <- effectplus
p1 <- c(effectip, effectip+effectplus);
p2 <- rep(effectplb,2)
true_rd <- p1[1]*(1-stratum)+p1[2]*stratum-effectplb
# Storage for widths
widths <- data.frame(MH_cover = numeric(1), MH_width = numeric(1), MH_rej_h0 = numeric(1),
MN_cover = numeric(1), MN_width = numeric(1), MN_rej_h0 = numeric(1),
Fish_rej_h0 = numeric(1))
if (exists("simdata")) {rm(list="simdata")}
simdata <- data.frame(
treatment = c(rep("TRT", n1[1]),
rep("TRT", n1[2]),
rep("PLB", n2[1]),
rep("PLB", n2[2])),
strat = c(rep("LEVEL 1", n1[1]),
rep("LEVEL 2", n1[2]),
rep("LEVEL 1", n2[1]),
rep("LEVEL 2", n2[2])),
probuni = runif(samparm*2)
)
simdata <- simdata %>% mutate(resp=case_when(
treatment=="TRT" & strat == "LEVEL 1" & probuni <= p1[1] ~ 1,
treatment=="TRT" & strat == "LEVEL 2" & probuni <= p1[2] ~ 1,
treatment=="PLB" & probuni <= p2[1] ~ 1,
TRUE ~ 0
)
)
# prepare frequency table for fisher
# Create a contingency table
table_data <- table(simdata$treatment, simdata$resp)
# Perform Fisher's Exact Test
test_result <- fisher.test(table_data)
fshp <- test_result$p.value<0.05
# extend to datasets for calling R package --- MH
mh <- risks::rr_rd_mantel_haenszel(
data=simdata,
exposure=treatment,
outcome=resp,
confounders=strat,
estimand = c("rd"),
conf.level = 0.95
)
mhcover <- mh$conf.low<=true_rd & true_rd<=mh$conf.high
cmhtest = mantelhaen.test(x = simdata$treatment, y = simdata$resp, z = simdata$strat)
mhrej <- cmhtest$p.value<0.05
# extend to datasets for calling R package --- MN with CMH weights
mn <- rate_compare(
data=simdata,
resp ~ factor(treatment, levels = c("PLB","TRT")),
strata = strat,
delta = 0,
weight = "cmh",
test = "two.sided",
alpha = 0.05
)
mncover <- mn$lower<=true_rd & true_rd<=mn$upper
mnrej <- mn$p<0.05
# MN
widths$MN_width[1] <- mn$upper - mn$lower
widths$MN_cover[1] <- mncover
widths$MN_rej_h0[1] <- mnrej
# MH Sato
widths$MH_width[1] <- mh$conf.high - mh$conf.low
widths$MH_cover[1] <- mhcover
widths$MH_rej_h0[1] <- mhrej
widths$Fish_rej_h0 <- fshp
widths$TrueRD <- true_rd
return(widths)
}
run_scenario_parallel <- function(params, n_iter, n_cores) {
cl <- makeCluster(n_cores)
on.exit(stopCluster(cl))
# Export the function to worker nodes
clusterExport(cl, varlist = c("simulate_once"), envir = environment())
# Load dplyr in each worker environment
clusterEvalQ(cl, {
library(dplyr)
library(risks)
library(metalite.ae)
})
# Set different RNG streams for each worker
clusterSetRNGStream(cl, 123) # 123 is the seed to initialize RNG, ensuring variation for each worker
# Run simulations in parallel (each worker gets a different stream)
res <- parLapply(
cl,
X = seq_len(n_iter),
fun = function(i) simulate_once(params, seed = i) # passing a unique seed per iteration
)
# Combine results into a matrix
do.call(rbind, res)
}
n_iter <- 1000
results <- lapply(
scenarios,
run_scenario_parallel,
n_iter = n_iter,
n_cores = n_cores
)
calculate_col_means <- function(df, id) {
col_means <- colMeans(df, na.rm = TRUE) # Calculate column means
col_means <- as.data.frame(t(col_means)) # Transpose to match the row format
col_means$ID <- id # Add the ID as a column
return(col_means)
}
# Apply the function to each dataframe in the list and bind the results together
all <- lapply(names(results), function(id) {
calculate_col_means(results[[id]], id) # Pass the dataframe and its ID
})
# Combine all results into one final data frame
final_results <- do.call(rbind, all)
print(final_results)Analyzing Binary Outcomes in a Trial With Small Sample Sizes
We will be conducting a pilot Monte Carlo simulation study to evaluate and compare the performance of three statistical methods for analyzing binary outcomes in a randomized clinical trial with small sample sizes and stratification (maybe seen in small biotech trials???).
The Statistical Objective
The Risk Difference (RD) will be estimated between an Investigational Product (IP/TRT) and Placebo (PLB), adjusting for a stratification factor (LEVEL1 vs. LEVEL2).
We will be comparing three specific approaches:
Miettinen-Nurminen (MN): Stratified Risk Difference using CMH weights (via metalite.ae in R).
Mantel-Haenszel (MH): Stratified Risk Difference and Sato’s confidence intervals (via risks and stats).
Fisher’s Exact Test: Unstratified analysis (ignoring the stratum).
Simulation Design
Seven scenarios (NULLS, SCEN1–SCEN6) are defined varying the following parameters:
Sample Size: Fixed at a very small number (perarm = 16, total N = 32).
Stratum Prevalence: Varying from 10% to 40%.
Effect Sizes:
Treatment (TRT): Base effect (effectip) + an additional benefit if the subject is allocated to a specific stratum (effectplus).
Placebo (PLB): Fixed effect (effectplb).
The Workflow
Data Generation (simulate_once)
For each iteration, we generate a clinical trial dataset:
Allocation: Split the 16 subjects per arm into different strata.
Outcome Generation: Generate binary responses (resp) using runif based on the defined probabilities.
- Note: Assume the Placebo rate is constant across strata, but the Treatment rate increases by effectplus in one specific stratum.
True Estimand: Calculate the true_rd (True Risk Difference) as a weighted average of the risk differences in the two strata.
Statistical Analysis
Inside the loop, run the three tests on the generated data:
Fisher’s Exact Test:
Calculates a p-value ignoring the strata.
Metric: Rejection rate (p < 0.05).
Mantel-Haenszel (MH):
Calculates the stratified RD point estimate and CI.
Metrics: CI Width, Coverage Probability (does CI contain true_rd?), and Rejection rate (mantelhaen.test p < 0.05).
Miettinen-Nurminen (MN; not exact the naïve methods proposed in 1985):
Calculates stratified RD using rate_compare with weight = “cmh” (proposed by Kaifeng Lu).
Metrics: CI Width, Coverage Probability, and Rejection rate.
Execution (run_scenario_parallel)
Use Parallel Computing (parallel package) to run >=1000 simulations (n_iter) for each scenario.
Correctly handle Random Number Generation (RNG) in parallel by using clusterSetRNGStream and passing a unique seed i to every worker.
Aggregation
- Finally, take the column means of results. This gives us the average performance (e.g., “Power” or “Type I Error” depending on the scenario, and average CI width) for each method under each scenario.
Measuring
MH_cover / MN_cover: How often the 95% CI captures the true difference. (Is the method valid?)
MH_width / MN_width: How precise are the estimates? (Narrower is better, provided coverage is maintained).
MH_rej_h0 / MN_rej_h0 / Fish_rej_h0:
If True RD = 0 (Scenario NULLS): This measures Type I Error (False Positive Rate).
If True RD \(\neq\) 0 (Scenarios SCEN1-6): This measures Power.
R Program
Reference
Miettinen, C.W. (2008). Nurminen Method for Confidence Intervals of the Difference in Binomial Proportions from Stratified 2 x 2 Samples.
Appendix: An Update Regarding PROC FREQ on SAS VIYA
LTS 2023.10 (NOV 2023)
The new COMMONRISKDIFF(CL=MN) option in the TABLES statement provides Miettinen-Nurminen confidence limits for the common risk difference, which is the overall difference in binomial proportions for stratified 2 × 2 tables.
The COMMONRISKDIFF(CL=MNMH) option provides confidence limits that are computed by the Miettinen-Nurminen method with Mantel-Haenszel weights (Lu 2008). The PLOTS=RISKDIFFPLOT(COMMON=MN) option enables you to display Miettinen-Nurminen confidence limits for the common risk difference in risk difference plots.