# 1) compute sigma_b from ICC (on latent logit scale):
icc_to_sigma <- function(rho){
if(rho<=0) return(0)
sigma_b <- sqrt( (rho * (pi^2/3)) / (1 - rho) )
return(sigma_b)
}
# 1b) convert a PROPORTION-scale ICC into the equivalent LATENT (logit-scale) ICC.
#
# These are two different quantities and must not be used interchangeably:
# - proportion scale: the ordinary correlation between two individuals' binary (0/1)
# outcomes in the same cluster. This is what the DEFF formula in chapter 1 needs,
# what published ICC tables report, and what our pilot data give us.
# - latent scale: rho = sigma_b^2 / (sigma_b^2 + pi^2/3), i.e. the ICC of the
# unobserved continuous logistic variable underlying the binary outcome. This is
# what icc_to_sigma() inverts to get the random-intercept SD for the simulation.
#
# For a cluster-specific probability p_j = plogis(qlogis(p0) + u_j), the induced
# proportion-scale ICC is Var(p_j) / (E[p_j] * (1 - E[p_j])). We compute that by
# numerical integration over u_j ~ N(0, sigma_b) and invert it for the latent rho.
# Deterministic (no simulation), so results are exactly reproducible.
latent_icc_to_prop <- function(rho_latent, p0){
if(rho_latent <= 0) return(0)
sigma_b <- icc_to_sigma(rho_latent)
b0 <- qlogis(p0)
Ep <- integrate(function(u) plogis(b0 + u) * dnorm(u, 0, sigma_b),
-10*sigma_b, 10*sigma_b)$value
Ep2 <- integrate(function(u) plogis(b0 + u)^2 * dnorm(u, 0, sigma_b),
-10*sigma_b, 10*sigma_b)$value
(Ep2 - Ep^2) / (Ep * (1 - Ep))
}
prop_icc_to_latent <- function(icc_prop, p0){
if(icc_prop <= 0) return(0)
uniroot(function(r) latent_icc_to_prop(r, p0) - icc_prop,
interval = c(1e-6, 0.95), tol = 1e-9)$root
}
# 1c) The conversion above assumes u_j is NORMAL. Our conservative default distribution is
# gamma (skewed), and a skewed u_j with the same SD induces a DIFFERENT proportion-scale ICC:
# at sigma_b = 0.771 the normal gives ICC 0.100 but the gamma gives only 0.069. Simply reusing
# the normal-based sigma_b therefore silently simulates a lower ICC than requested, i.e. the
# "conservative" skewed scenario would in fact be run at a more favourable ICC.
#
# We therefore solve for sigma_b separately for each distribution, so that "icc = 0.10" means
# a realised proportion-scale ICC of 0.10 whichever u_j distribution is used. Both moments are
# obtained by quadrature over the actual distribution of u_j, so this stays deterministic.
icc_prop_given_sigma <- function(sigma_b, p0, dist = c("normal","gamma","uniform")){
dist <- match.arg(dist)
if(sigma_b <= 0) return(0)
b0 <- qlogis(p0)
mom <- function(k){
if(dist == "normal"){
integrate(function(u) plogis(b0 + u)^k * dnorm(u, 0, sigma_b),
-10*sigma_b, 10*sigma_b)$value
} else if(dist == "gamma"){
# u_j = sigma_b * (a - 2)/sqrt(2), with a ~ Gamma(shape = 2, scale = 1)
integrate(function(a) plogis(b0 + sigma_b*(a-2)/sqrt(2))^k * dgamma(a, shape=2, scale=1),
0, Inf)$value
} else {
cut <- sqrt(3) * sigma_b
integrate(function(u) plogis(b0 + u)^k / (2*cut), -cut, cut)$value
}
}
Ep <- mom(1); Ep2 <- mom(2)
(Ep2 - Ep^2) / (Ep * (1 - Ep))
}
# sigma_b that yields the requested PROPORTION-scale ICC under the given u_j distribution
sigma_b_for_icc <- function(icc_prop, p0, dist = "normal"){
if(icc_prop <= 0) return(0)
uniroot(function(s) icc_prop_given_sigma(s, p0, dist) - icc_prop,
interval = c(1e-6, 10), tol = 1e-10)$root
}
# 1d) MARGINAL (population-average) prevalence implied by a linear predictor, E[plogis(lp + u_j)]
marginal_p <- function(lp, sigma_b, dist = c("normal","gamma","uniform")){
dist <- match.arg(dist)
if(sigma_b <= 0) return(plogis(lp))
if(dist == "normal"){
integrate(function(u) plogis(lp+u)*dnorm(u, 0, sigma_b), -10*sigma_b, 10*sigma_b)$value
} else if(dist == "gamma"){
integrate(function(a) plogis(lp + sigma_b*(a-2)/sqrt(2))*dgamma(a, shape=2, scale=1), 0, Inf)$value
} else {
cut <- sqrt(3)*sigma_b
integrate(function(u) plogis(lp+u)/(2*cut), -cut, cut)$value
}
}
# 1e) Choose beta0 and beta1 so the MARGINAL prevalences are exactly p0 and p1.
# This matters more than it looks. Setting beta0 = qlogis(p0) and beta1 = log(OR) makes p0 and p1 the CLUSTER-SPECIFIC probabilities (those of a cluster with u_j = 0). Because the logistic link is non-linear, averaging over u_j pulls the marginal prevalences toward 0.5: at ICC 0.10 the nominal "0.75 -> 0.50, 25 pp" actually generates a marginal 0.726 -> 0.500, i.e. only 22.6 pp.
calibrate_marginal <- function(p0, p1, sigma_b, dist = "normal"){
b0 <- uniroot(function(b) marginal_p(b, sigma_b, dist) - p0, c(-20, 20), tol = 1e-10)$root
b1 <- uniroot(function(b) marginal_p(b0 + b, sigma_b, dist) - p1, c(-20, 20), tol = 1e-10)$root
c(beta0 = b0, beta1 = b1)
}
# 2) compute beta0 for given control prevalence p0
p_to_beta0 <- function(p0){
qlogis(p0)
}
# 3) given p0 and p1, compute OR on the cluster-specific log-odds scale
p0_p1_to_OR <- function(p0, p1){
odds0 <- p0 / (1 - p0)
odds1 <- p1 / (1 - p1)
odds1 / odds0
}
# 4) generate random cluster-level u_j for the three distributions
generate_u <- function(n_clusters, sigma_b, dist = c("normal","gamma","uniform")){
dist <- match.arg(dist)
if(sigma_b == 0) return(rep(0, n_clusters))
if(dist == "normal"){
return(rnorm(n_clusters, mean=0, sd = sigma_b))
} else if(dist == "gamma"){
# they used Gamma(shape=2, scale=1) then standardized to mean 0 and sd sigma_b
a <- rgamma(n_clusters, shape=2, scale=1)
# a has mean 2, var 2. Standardize: (a - 2)/sqrt(2) then scale to sigma_b
return(sigma_b * (a - 2)/sqrt(2))
} else if(dist == "uniform"){
cut <- sqrt(3) * sigma_b
return(runif(n_clusters, min = -cut, max = cut))
}
}
# 5) generate cluster sizes with target mean m and CV. Implementation follows their negative-binomial based approach and enforces minimum cluster size of 3.
generate_cluster_sizes <- function(n_clusters, m, CV){
if(CV == 0){
return(rep(m, n_clusters))
}
s <- CV * m
# We want delta = m_j - 2 to follow NegBin with mean (m-2) and variance s^2
mu_delta <- m - 2
var_delta <- s^2
if(var_delta <= mu_delta){
# Negative Binomial requires variance > mean, so this parameterization is impossible.
# NOTE: this is the branch that actually runs at our design values (m = 40, CV = 0.1:
# var_delta = 16 is well below mu_delta = 38), i.e. the NB path documented in chapter 2.1 is never used here. We fall back to a uniform around m. The half-width must be sqrt(3)*s, not 1.5*s: a uniform on m +/- w has SD w/sqrt(3), so w = 1.5*s gives SD 0.87*s and under-delivers the requested CV (0.088 instead of 0.100).
w <- sqrt(3) * s
out <- pmax(3, round(runif(n_clusters, m - w, m + w)))
return(out)
}
size_nb <- (mu_delta^2) / (var_delta - mu_delta) # see formula above
prob_nb <- mu_delta / var_delta # see formula above
# rnbinom in R uses size, prob; mean = size*(1-prob)/prob, but with this param it matches
delta <- rnbinom(n_clusters, size = size_nb, prob = prob_nb)
m_j <- 2 + delta
m_j[m_j < 3] <- 3 # enforce min 3 (generating 2+delta ensures >=2, we bump to 3)
return(m_j)
}
# Parameters for single simulated dataset
n_clusters <- 26
m_mean <- 40
CV <- 0.1
p0 <- 0.75
p1 <- 0.50
OR <- p0_p1_to_OR(p0, p1) # compute OR from p0 and p1
icc <- 0.10 # ICC on the PROPORTION scale (pilot data); sigma_b derived for the chosen re_dist below
re_dist <- "uniform"
# Simulate
set.seed(20250809)
sigma_b <- sigma_b_for_icc(icc, p0, re_dist)
u_j <- generate_u(n_clusters, sigma_b, dist = re_dist)
sizes <- generate_cluster_sizes(n_clusters, m_mean, CV)
arm_assign <- sample(rep(0:1, length.out = n_clusters))
# beta0/beta1 calibrated so the MARGINAL prevalences really are p0 and p1 (see helper 1e above)
betas <- calibrate_marginal(p0, p1, sigma_b, re_dist)
beta0 <- betas["beta0"]
beta1 <- betas["beta1"]
y <- integer(n_clusters)
for(j in seq_len(n_clusters)){ # iterate over each cluster
# create the linear predictor (NOTE: beta1 turns 0 if arm0, and 1 * beta1 if arm1)
linpred <- beta0 + beta1 * arm_assign[j] + u_j[j]
# apply the inverse logit (logistic function) to convert log-odds to probability
p_j <- plogis(linpred)
# Simulate the number of successes in cluster j
y[j] <- rbinom(1, size = sizes[j], prob = p_j)
}
df_sim <- data.frame(cluster = seq_len(n_clusters),
arm = arm_assign,
size = sizes,
y = y)
df_sim