simulate_accounts <- function(n_accounts = 20000, propensity = c(0.05, 0.10, 0.25, 0.50), force_Z = NULL,
full_compliance = FALSE, one_profile = FALSE) {
accounts <- tibble(
account = seq_len(n_accounts),
day = sample.int(length(propensity), n_accounts, replace = TRUE, prob = rev(seq_along(propensity))),
n_profiles = if (one_profile) 1L else pmin(1L + rpois(n_accounts, 1.2), 5L),
quality = rnorm(n_accounts, 0, 0.5), # shared by the account's profiles
engagement = rnorm(n_accounts, 0, 0.5) # account part of engagement
) |>
mutate(Z = rbinom(n(), 1, propensity[day]), Z = if (is.null(force_Z)) Z else force_Z)
accounts[rep(accounts$account, accounts$n_profiles), ] |>
mutate(
profile = sequence(accounts$n_profiles),
kids = rbinom(n(), 1, if_else(profile == 1, 0.05, 0.3)),
device = sample(c("tv", "mobile", "web"), n(), replace = TRUE, prob = c(0.5, 0.35, 0.15)),
engagement_p = engagement + rnorm(n(), 0, 0.8), # the profile's engagement, not observed
x1 = rlnorm(n(), 0.7 - 0.12 * day - 0.6 * kids + 0.3 * engagement_p, 0.6),
x2 = rgamma(n(), 2, 1) * (1.2 - 0.15 * day),
complier = rbinom(n(), 1, plogis(-0.3 + 0.5 * log(x1) + 0.6 * engagement_p - 0.8 * kids)),
T = if (full_compliance) Z else Z * complier,
y = 0.5 + 0.8 * x1 + 0.1 * x2 + 0.5 * engagement_p + quality +
T * (0.3 + 0.15 * x1 - 0.2 * kids) + rnorm(n(), 0, 0.5 + 0.3 * x1),
D = 1 + rpois(n(), exp(1 + 0.4 * log(x1) + 0.3 * engagement_p + 0.2 * (device == "tv")) * (1 + 0.15 * T)),
N = rpois(n(), D * 0.08 * exp(-0.4 * T * (1 + 0.5 * (device == "mobile")) + 0.4 * quality - 0.2 * engagement_p))
)
}
propensity <- c(0.05, 0.10, 0.25, 0.50)
x_cols <- c("x1", "x2", "kids", "mobile", "web")
x_ref <- c(x1 = 2, x2 = 2, kids = 0, mobile = 0, web = 0)
y_cols <- c("y", "N", "D", "TN", "TD")
d <- length(x_cols) + 1 # intercept and k covariates
o <- length(y_cols) # number of outcome columns
prepare <- function(profiles, ramp = TRUE) {
e_hat <- profiles |>
distinct(account, day, Z) |>
group_by(day) |>
summarise(e_hat = mean(Z))
profiles |>
left_join(e_hat, by = "day") |>
mutate(w = if (ramp) if_else(Z == 1, 1 / e_hat, 1 / (1 - e_hat)) else 1, # inverse propensity weight
mobile = as.numeric(device == "mobile"), web = as.numeric(device == "web"),
TN = T * N, TD = T * D)
}
centered <- function(df) {
r <- cbind(1, sweep(as.matrix(df[x_cols]), 2, x_ref))
colnames(r) <- c("1", paste0(x_cols, "_c"))
r
}
design <- function(df) {
r <- centered(df)
list(M_1 = cbind(r, `colnames<-`(df$Z * r, paste0("Z*", colnames(r)))), # first stage: instruments
M_2 = cbind(r, `colnames<-`(df$T * r, paste0("T*", colnames(r))))) # second stage: regressors
}
fit_rows <- function(df) {
m <- design(df)
Y <- as.matrix(df[y_cols])
M1_W_M2 <- crossprod(m$M_1 * df$w, m$M_2)
beta <- solve(M1_W_M2, crossprod(m$M_1 * df$w, Y))
eps <- Y - m$M_2 %*% beta
inner <- do.call(cbind, map(seq_len(o), \(l) rowsum(m$M_1 * (df$w * eps[, l]), df$account)))
M1_W_M2_inv <- kronecker(diag(o), solve(M1_W_M2))
list(beta = beta, V = M1_W_M2_inv %*% crossprod(inner) %*% t(M1_W_M2_inv))
}
complier_mean <- function(df) {
if (all(df$T == df$Z)) return(colSums(df$w * df[x_cols]) / sum(df$w))
arm <- df |>
group_by(Z) |>
summarise(across(all_of(x_cols), \(v) sum(w * T * v) / sum(w)), T = sum(w * T) / sum(w))
unlist(arm[arm$Z == 1, x_cols] - arm[arm$Z == 0, x_cols]) / (arm$T[arm$Z == 1] - arm$T[arm$Z == 0])
}
gradient <- function(f, theta, kappa = 1e-20) {
map_dbl(seq_along(theta), \(i) Im(f(theta + 1i * kappa * (seq_along(theta) == i))) / kappa)
}
effect_functions <- function(x_C, x_ref, cols) {
delta <- c(rep(0, length(x_ref) + 1), 1, x_C - x_ref)
line <- \(beta, col) sum(delta * beta[, col])
effect_list <- list(tau_LATE = \(beta) line(beta, "y"))
if ("TN" %in% cols) effect_list$tau_R <- \(beta) line(beta, "TN") / line(beta, "TD") -
(line(beta, "TN") - line(beta, "N")) / (line(beta, "TD") - line(beta, "D"))
effect_list
}
effects <- function(fit, x_C, x_ref) {
effect_list <- effect_functions(x_C, x_ref, colnames(fit$beta))
theta <- as.vector(fit$beta)
map_dfr(names(effect_list), \(nm) {
f <- \(th) effect_list[[nm]](matrix(th, nrow(fit$beta), dimnames = list(NULL, colnames(fit$beta))))
grad <- gradient(f, theta)
tibble(effect = nm, estimate = Re(f(theta)), se = sqrt(drop(t(grad) %*% fit$V %*% grad)))
})
}
set.seed(2026)
profiles <- simulate_accounts()
df <- prepare(profiles)
fit <- fit_rows(df)
x_C <- complier_mean(df)
css <- effects(fit, x_C, x_ref)
css