Files
tte-surgery/pvisim/R/estimators.R
T
chendun1995andClaude Opus 5.5 fc1461d5b5 Truths, estimators (naive, landmark, CCW, sequential; physician variants V1-V4) and simulated RCTs
- truths.R: interventional truths by common random numbers (A, B1, B2, sequential ITT)
- estimators.R: weighted Aalen-Johansen with influence-function SEs (patient / physician cluster)
- rct.R: realistic and ideal trials, ITT and as-treated; RCT ITT truths
- scenario effects tuned in W0: harm +5.1 pp, benefit-then-harm -1.2 pp at 24 mo, +3.1 at 60

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:01:13 -04:00

303 lines
15 KiB
R

# Estimators (plan sections 7 and 9; D13-D15, D26, D28).
#
# One machinery for every observational design: inverse-probability weights from
# pooled logistic models of elective PVI, then a weighted Aalen-Johansen estimator of
# the cumulative incidence of the outcome (death competing), nonparametric in time.
# Standard errors come from the influence function of the weighted Aalen-Johansen
# estimator with the weights treated as known (usually conservative for IPW; checked
# against a physician-cluster bootstrap, D28). Summed by patient for the naive SE, by
# physician for the cluster-robust SE. Clones and person-trials of one patient are summed
# together, so cloning and stacking are accounted for.
BASE_COVS <- c("age", "female", "black", "year", "diabetes", "ckd", "chf", "copd", "prior_cvd",
"smoking", "frailty_index", "obl")
VARIANTS <- c("V1_ignore", "V2_cluster", "V3_random", "V4_within")
# ---- inputs -------------------------------------------------------------------------
#' Person-month analysis table: eligibility for elective PVI and the PVI decision.
#'
#' A row is month t for a patient who survived month t's events and death and had no
#' MALE and no PVI before the decision in month t: the risk set of the PVI model.
pvi_risk_set <- function(rec) {
p <- rec$patients
pm <- merge(rec$pm, p[, c("id", "phys_id", BASE_COVS, "t_pvi", "pvi_elective", "t_male", "t_death"),
with = FALSE], by = "id")
pm <- pm[(is.na(t_death) | t_death > t) & (is.na(t_male) | t_male > t) & (is.na(t_pvi) | t_pvi >= t)]
pm[, pvi := as.integer(!is.na(t_pvi) & t_pvi == t)]
sev0 <- rec$pm[t == 0, .(id, sev0 = sev_obs)]
pm <- merge(pm, sev0, by = "id")
pm[, `:=`(win = as.integer(t < rec$par$size$window), tt = pmin(t, 12))]
pm[]
}
# ---- treatment models ------------------------------------------------------------------
#' Fit P(PVI at t | eligible, history) and return predictions for `newdata`.
#'
#' The decision window (months 0..W-1) and the deferred period are fitted as separate
#' models: practice inside and after the window differs, and a pooled model would
#' misspecify the window probabilities that the CCW and sequential weights depend on.
#' variant: V1/V2 no physician terms; V3 physician random intercept (glmer, nAGQ = 0);
#' V4 physician fixed effects (fixest). Under V4 a physician with no PVI in a phase has
#' no estimable intercept for it; the maximum-likelihood probability there is 0.
fit_pvi <- function(data, newdata, variant) {
out <- numeric(nrow(newdata))
for (w in c(1L, 0L)) {
i_new <- newdata$win == w
if (!any(i_new)) next
d <- data[win == w]
nd <- newdata[i_new]
tt_levels <- if (w == 1L) "factor(tt)" else "splines::ns(t, df = 4)"
out[i_new] <- fit_pvi_phase(d, nd, variant, tt_levels)
}
out
}
fit_pvi_phase <- function(d, nd, variant, time_terms) {
covs <- c(BASE_COVS, "sev_obs", "sev0")
if (variant == "V4_within") covs <- setdiff(covs, "obl")
rhs <- paste(c(covs, time_terms), collapse = " + ")
if (variant %in% c("V1_ignore", "V2_cluster")) {
f <- as.formula(paste("~", rhs))
X <- model.matrix(f, d)
fit <- fastglm::fastglm(X, d$pvi, family = binomial(), method = 2)
b <- coef(fit); b[is.na(b)] <- 0
Xn <- model.matrix(f, nd)
return(as.vector(plogis(Xn %*% b)))
}
if (variant == "V3_random") {
fit <- lme4::glmer(as.formula(paste("pvi ~", rhs, "+ (1 | phys_id)")), data = d,
family = binomial(), nAGQ = 0, control = lme4::glmerControl(calc.derivs = FALSE))
return(as.vector(predict(fit, newdata = nd, type = "response", allow.new.levels = TRUE)))
}
if (variant == "V4_within") {
fit <- fixest::feglm(as.formula(paste("pvi ~", rhs, "| phys_id")), data = d,
family = binomial(), notes = FALSE, warn = FALSE)
pr <- as.vector(predict(fit, newdata = nd, type = "response"))
pr[is.na(pr)] <- 0
return(pr)
}
stop("unknown variant ", variant)
}
#' Physicians with variation in early (window) PVI: the V4 analysis population.
physicians_with_variation <- function(rec) {
W <- rec$par$size$window
x <- rec$patients[, .(e = sum(pvi_elective %in% TRUE & t_pvi < W), n = .N), by = phys_id]
x[e > 0 & e < n, phys_id]
}
# ---- weighted Aalen-Johansen with influence functions --------------------------------
#' Cumulative incidence at `horizon` from person-month rows.
#'
#' @param d data.table with id, phys_id, arm, t, w (weight for the row's risk set),
#' ev (1 if the outcome occurs in month t), dth (1 if death without the outcome in t).
#' @return list(F = named vector of cumulative incidence by arm at the horizon,
#' inf = data.table(id, phys_id, arm, inf) of influence-function contributions summed
#' by patient, curve = cumulative incidence by arm and month).
aj_cif <- function(d, horizon = 60L) {
d <- d[t < horizon & w > 0]
s <- d[, .(R = sum(w), E = sum(w * ev), D = sum(w * dth)), keyby = .(arm, t)]
s[, `:=`(he = E / R, hd = D / R)]
s[, h := he + hd]
s[, S_prev := shift(cumprod(1 - h), fill = 1), by = arm]
s[, Fcum := cumsum(S_prev * he), by = arm]
s[, FH := Fcum[.N], by = arm]
s[, `:=`(a = S_prev - (FH - Fcum) / (1 - h), b = -(FH - Fcum) / (1 - h))]
s[!is.finite(a), a := 0]; s[!is.finite(b), b := 0]
d <- s[d, on = .(arm, t), .(id, phys_id, arm, x = w * ((ev - he) * a + (dth - hd) * b) / R)]
inf <- d[, .(inf = sum(x)), by = .(id, phys_id, arm)]
FH <- s[, .(FH = FH[1]), by = arm]
list(F = setNames(FH$FH, FH$arm), inf = inf, curve = s[, .(arm, t, cuminc = Fcum)])
}
#' Risk difference arm1 - arm0 with naive (patient) and cluster-robust (physician) SEs.
rd_from_aj <- function(aj, arm1, arm0) {
x <- dcast(aj$inf, id + phys_id ~ arm, value.var = "inf", fill = 0)
diff <- x[[arm1]] - x[[arm0]]
se_pat <- sqrt(sum(diff^2))
se_phys <- sqrt(sum(tapply(diff, x$phys_id, sum)^2))
data.table(est = aj$F[[arm1]] - aj$F[[arm0]], se_patient = se_pat, se_cluster = se_phys,
risk1 = aj$F[[arm1]], risk0 = aj$F[[arm0]])
}
# outcome and death indicators for a person-month table that has columns t_out, t_death
mark_events <- function(d) {
d[, ev := as.integer(!is.na(t_out) & t_out == t)]
d[, dth := as.integer(ev == 0L & !is.na(t_death) & t_death == t)]
d
}
# person-months from `start` to the outcome, death or the horizon, one row per month
expand_followup <- function(p, start, horizon = 60L) {
end <- pmin(ifelse(is.na(p$t_out), horizon - 1L, p$t_out),
ifelse(is.na(p$t_death), horizon - 1L, p$t_death), horizon - 1L)
keep <- end >= start
p <- p[keep]; st <- if (length(start) > 1) start[keep] else rep(start, nrow(p)); end <- end[keep]
nrep <- end - st + 1L
d <- p[rep(seq_len(nrow(p)), nrep)]
d[, t := st[rep(seq_along(nrep), nrep)] + sequence(nrep) - 1L]
mark_events(d)
}
# ---- designs --------------------------------------------------------------------------
#' For the within-physician variant, restrict to physicians whose patients include both
#' early PVI and no early PVI (positivity); the estimand changes accordingly (plan 7).
restrict_variant <- function(rec, variant) {
if (variant != "V4_within") return(rec)
keep <- physicians_with_variation(rec)
rec$patients <- rec$patients[phys_id %in% keep]
rec$pm <- rec$pm[id %in% rec$patients$id]
rec
}
#' Run one design under one physician variant for several outcomes.
#' Returns one row per outcome (and contrast), with the SE that the variant reports:
#' patient-level for V1, physician-cluster-robust for V2-V4.
run_design <- function(rec, design, variant, outcomes = c(male = "t_male", disease = "t_disease",
nco1 = "t_nco1", nco2 = "t_nco2")) {
rec <- restrict_variant(rec, variant)
rs <- if (design %in% c("ccw", "seq")) pvi_risk_set(rec) else NULL
phat <- if (design == "ccw") fit_pvi(rs, rs, variant) else NULL
out <- lapply(names(outcomes), function(o) {
r <- switch(design,
naive = cbind(contrast = "early_none", est_naive(rec, outcomes[[o]], variant)),
landmark = cbind(contrast = "early_none", est_landmark(rec, outcomes[[o]], variant)),
ccw = est_ccw(rec, outcomes[[o]], variant, rs = copy(rs), phat = phat),
seq = cbind(contrast = "now_not", est_seq(rec, outcomes[[o]], variant, rs = copy(rs))))
r[, `:=`(outcome = o, design = design, variant = variant, n = nrow(rec$patients))]
use_patient <- variant == "V1_ignore"
r[, se := if (use_patient) se_patient else se_cluster]
r
})
rbindlist(out, fill = TRUE)
}
#' Naive "ever early PVI" design: exposure = PVI in months 0..W-1, follow-up from month 0,
#' baseline propensity score. Immortal time is present by construction.
est_naive <- function(rec, outcome = "t_male", variant = "V1_ignore", horizon = 60L) {
W <- rec$par$size$window
p <- copy(rec$patients)
p[, `:=`(t_out = get(outcome), arm = ifelse(!is.na(t_pvi) & t_pvi < W, "early", "none"))]
p <- merge(p, rec$pm[t == 0, .(id, sev_obs)], by = "id")
p[, pvi := as.integer(arm == "early")]
ps <- fit_pvi_ps(p, variant, covs = c(BASE_COVS, "sev_obs")) # sev_obs is the month-0 value here
p[, w := ifelse(arm == "early", 1 / ps, 1 / (1 - ps))]
d <- expand_followup(p[!is.na(w)], 0L, horizon)
rd_from_aj(aj_cif(d, horizon), "early", "none")
}
# a point-treatment propensity score with the same covariates and physician variants
fit_pvi_ps <- function(p, variant, covs = c(BASE_COVS, "sev_obs", "sev0")) {
if (variant == "V4_within") covs <- setdiff(covs, "obl")
rhs <- paste(covs, collapse = " + ")
if (variant %in% c("V1_ignore", "V2_cluster")) {
X <- model.matrix(as.formula(paste("~", rhs)), p)
fit <- fastglm::fastglm(X, p$pvi, family = binomial(), method = 2)
return(as.vector(plogis(X %*% coef(fit))))
}
if (variant == "V3_random") {
fit <- lme4::glmer(as.formula(paste("pvi ~", rhs, "+ (1 | phys_id)")), data = p,
family = binomial(), nAGQ = 0, control = lme4::glmerControl(calc.derivs = FALSE))
return(as.vector(predict(fit, type = "response")))
}
fit <- fixest::feglm(as.formula(paste("pvi ~", rhs, "| phys_id")), data = p, family = binomial(),
notes = FALSE, warn = FALSE)
as.vector(predict(fit, newdata = p, type = "response"))
}
#' Landmark at month W (D13): patients alive and MALE-free at the start of month W;
#' exposure = PVI in months 0..W-1; covariates at diagnosis and at month W-1;
#' outcome counted in months W..horizon-1.
est_landmark <- function(rec, outcome = "t_male", variant = "V1_ignore", horizon = 60L) {
W <- rec$par$size$window
p <- copy(rec$patients)[(is.na(t_death) | t_death >= W) & (is.na(t_male) | t_male >= W)]
p[, `:=`(t_out = get(outcome), arm = ifelse(!is.na(t_pvi) & t_pvi < W, "early", "none"))]
p <- p[is.na(t_out) | t_out >= W]
p <- merge(p, rec$pm[t == W - 1L, .(id, sev_obs)], by = "id")
p <- merge(p, rec$pm[t == 0, .(id, sev0 = sev_obs)], by = "id")
p[, pvi := as.integer(arm == "early")]
ps <- fit_pvi_ps(p, variant)
p[, w := ifelse(arm == "early", 1 / ps, 1 / (1 - ps))]
d <- expand_followup(p[!is.na(w)], W, horizon)
rd_from_aj(aj_cif(d, horizon), "early", "none")
}
#' Clone-censor-weight (D10, D26). Arms: A (PVI by month W-1), B1 (no PVI in months
#' 0..W-1), B2 (no elective PVI ever). A clone is censored after the PVI decision of the
#' month in which it deviates; weights are 1 / P(not censored through the previous month).
est_ccw <- function(rec, outcome = "t_male", variant = "V1_ignore", horizon = 60L,
comparators = c("B1", "B2"), rs = NULL, phat = NULL) {
W <- rec$par$size$window
if (is.null(rs)) rs <- pvi_risk_set(rec)
if (is.null(phat)) phat <- fit_pvi(rs, rs, variant)
rs[, ph := phat]
rs <- rs[!is.na(ph)]
# everyone eligible at diagnosis is cloned, including patients with a MALE event or
# death in month 0 (events in the grace period count in both arms)
p <- copy(rec$patients)
p[, t_out := get(outcome)]
base <- expand_followup(p, 0L, horizon)
# P(PVI) for each eligible month; months where a patient is not at risk of PVI have
# probability 0 of deviating
base <- rs[, .(id, t, ph)][base, on = .(id, t)]
base[is.na(ph), ph := 0]
base[, `:=`(tp = fifelse(is.na(t_pvi), Inf, as.numeric(t_pvi)), el = pvi_elective %in% TRUE)]
out <- list()
# arm A: censored after month W-1 if no PVI by then (and still at risk of PVI then)
a <- copy(base)[, arm := "A"]
cens_A <- a[t == W - 1L & ph > 0 & tp > W - 1L, id] # deviated at month W-1
a <- a[!(id %in% cens_A & t > W - 1L)]
pA <- base[t == W - 1L & ph > 0, .(id, pA = ph)]
a <- pA[a, on = "id"]
a[, w := ifelse(t > W - 1L & !is.na(pA) & tp == W - 1L, 1 / pA, 1)]
out$A <- a[, .(id, phys_id, arm, t, w, ev, dth)]
for (cmp in comparators) {
b <- copy(base)[, arm := cmp]
last <- if (cmp == "B1") W - 1L else horizon
b <- b[!(el & tp <= last & t > tp)] # censored after month tp
b[, q := ifelse(t <= last, 1 - ph, 1)]
b[, w := 1 / shift(cumprod(q), fill = 1), by = id]
out[[cmp]] <- b[, .(id, phys_id, arm, t, w, ev, dth)]
}
aj <- aj_cif(rbindlist(out), horizon)
rbindlist(lapply(comparators, function(cmp) cbind(contrast = paste0("A_", cmp), rd_from_aj(aj, "A", cmp))))
}
#' Sequential trials, ITT (D14): a trial at each month m = 0..W-1 among patients eligible
#' for PVI at m; treatment = PVI at m; propensity score from covariates at m; outcome
#' counted in months m+1..horizon-1; trial RDs pooled with weights = number eligible.
est_seq <- function(rec, outcome = "t_male", variant = "V1_ignore", horizon = 60L, rs = NULL) {
W <- rec$par$size$window
if (is.null(rs)) rs <- pvi_risk_set(rec)
trials <- rs[t < W]
trials[, tt := t] # trial-month indicators in the PS model
ps <- fit_pvi(trials, trials, variant)
trials[, ps := ps]
trials <- trials[!is.na(ps)]
trials[, `:=`(m = t, arm = ifelse(pvi == 1L, "pvi", "no"),
w = ifelse(pvi == 1L, 1 / ps, 1 / (1 - ps)))]
p <- rec$patients[, .(id, t_out = get(outcome))]
trials <- p[trials, on = "id"]
trials <- trials[is.na(t_out) | t_out > m]
res <- list(); infs <- list()
for (mm in 0:(W - 1)) {
tr <- trials[m == mm]
d <- expand_followup(tr[, .(id, phys_id, arm, w, t_out, t_death)], mm + 1L, horizon)
aj <- aj_cif(d, horizon)
x <- dcast(aj$inf, id + phys_id ~ arm, value.var = "inf", fill = 0)
res[[mm + 1L]] <- data.table(m = mm, n = nrow(tr), est = aj$F[["pvi"]] - aj$F[["no"]])
infs[[mm + 1L]] <- data.table(id = x$id, phys_id = x$phys_id, m = mm, inf = x$pvi - x$no)
}
res <- rbindlist(res); infs <- rbindlist(infs)
res[, wt := n / sum(n)]
infs <- res[, .(m, wt)][infs, on = "m"]
pi <- infs[, .(inf = sum(wt * inf)), by = .(id, phys_id)]
data.table(est = sum(res$wt * res$est), se_patient = sqrt(sum(pi$inf^2)),
se_cluster = sqrt(sum(tapply(pi$inf, pi$phys_id, sum)^2)), risk1 = NA_real_, risk0 = NA_real_)
}