IPW survival with survatr

Inverse-probability weighting (IPW) estimates the counterfactual survival curve by reweighting the observed data so that confounders are balanced across treatment groups, then fitting a weighted marginal hazard MSM rather than a covariate-adjusted hazard. It is the second of survatr’s two Track A estimators (alongside g-computation) and a methodologically distinct route to the same estimand — when the two agree you can be more confident in the result.

This vignette covers estimator = "ipw" for a binary point treatment: the weight construction, the supported estimands and interventions, sandwich vs bootstrap inference, and weight truncation.

How survatr’s IPW differs from g-computation

g-computation models the hazard \(h(t \mid A, L)\) and standardizes over the covariate distribution. IPW instead models the treatment mechanism \(f(A \mid L)\) and reweights. survatr composes a stabilized density-ratio weight at the baseline (one row per id),

\[ w_i = \frac{f(A_i)}{f(A_i \mid L_i)}, \]

broadcasts the per-id weight onto every person-period row, and fits an intervention-free weighted marginal hazard MSM \(\text{logit}\, h(t \mid A) = \alpha(t) + \beta_A A\). Because the weighting removes confounding, the MSM needs only \(A\) and the baseline-hazard term \(\alpha(t)\) — no \(L\). The treatment model is fit by propensity_model_fn (default stats::glm); the hazard MSM by model_fn.

A confounded survival DGP

We simulate person-period data with a constant-ish discrete-time hazard, a single baseline confounder \(L\) that drives both treatment assignment and the hazard, and a genuinely protective treatment (\(\beta_A < 0\)):

\[ \begin{aligned} L_i &\sim N(0, 1) \\ A_i \mid L_i &\sim \text{Bernoulli}\!\bigl(\text{logit}^{-1}(\gamma\, L_i)\bigr) \\ \text{logit}\, h_{i,k} &= \alpha_0 + \alpha_k + \beta_A A_i + \beta_L L_i, \end{aligned} \]

with first-event truncation and rectangular padding (so every id has one row at every period). Because \(L\) confounds the \(A \to Y\) relationship, the unadjusted survival curve is biased; IPW recovers the counterfactual curve.

Code
library(causatr) # attach first so survatr's contrast() generic dispatches
library(survatr)
library(data.table)

sim_confounded <- function(n = 2000L, K = 5L, seed = 1L,
                           gamma = 0.8, alpha0 = -2, beta_A = -0.6,
                           beta_L = 0.7) {
  set.seed(seed)
  L <- rnorm(n)
  A <- rbinom(n, 1L, plogis(gamma * L)) # L drives treatment (confounding)
  alpha_t <- seq(0, 0.3, length.out = K) # mild per-period baseline drift
  rbindlist(lapply(seq_len(n), function(i) {
    h <- plogis(alpha0 + alpha_t + beta_A * A[i] + beta_L * L[i])
    Y <- rbinom(K, 1L, h)
    first <- which(Y == 1L)[1L]
    if (!is.na(first) && first < K) Y[(first + 1L):K] <- 0L
    data.table(id = i, t = seq_len(K), A = A[i], L = L[i], Y = Y)
  }))
}

pp <- sim_confounded()
head(pp, 5)
#>       id     t     A          L     Y
#>    <int> <int> <int>      <num> <int>
#> 1:     1     1     0 -0.6264538     0
#> 2:     1     2     0 -0.6264538     0
#> 3:     1     3     0 -0.6264538     0
#> 4:     1     4     0 -0.6264538     0
#> 5:     1     5     0 -0.6264538     1

Fitting the IPW hazard MSM

Pass estimator = "ipw". confounders specifies the treatment model \(f(A \mid L)\); the weighted MSM uses only \(A\).

Code
fit <- surv_fit(
  pp,
  outcome      = "Y",
  treatment    = "A",
  confounders  = ~ L,
  id           = "id",
  time         = "t",
  time_formula = ~ factor(t), # non-parametric baseline hazard
  estimator    = "ipw"
)
fit
#> <survatr_fit>
#>   Track:       A
#>   Estimator:   ipw
#>   Family:      quasibinomial
#>   Outcome:     Y
#>   Treatment:   A
#>   ID:          id
#>   Time:        t
#>   Censoring:   none
#>   N:           2000 individuals, 10000 PP rows (8056 at risk)
#>   Time grid:   [1, 5] (5 unique times)

The stabilized weights

fit$weights holds the per-person-period broadcast weight. Stabilization centres the weights near 1, and the weight is constant within an id (the baseline density ratio is broadcast onto all of that id’s rows):

Code
w_dt <- data.table(id = pp$id, w = fit$weights)
summary(unique(w_dt)$w)
#>    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
#>  0.5234  0.7317  0.8865  0.9969  1.1358  4.7073
# the weight is constant within each id (one density ratio, broadcast)
all(w_dt[, uniqueN(w), by = id]$V1 == 1L)
#> [1] TRUE

Survival curves

contrast() produces the time-indexed counterfactual survival curve under each intervention. Interventions are constructed with causatr::static().

Code
res_surv <- contrast(
  fit,
  interventions = list(a1 = static(1), a0 = static(0)),
  times     = 1:5,
  type      = "survival",
  ci_method = "sandwich"
)
res_surv
#> <survatr_result>
#>   Type:        survival
#>   Reference:   (none)
#>   CI method:   sandwich
#>   Time grid:   [1, 5] (5 unique times)
#>   Estimates:   10 rows
#>   Contrasts:   0 rows
#> 
#>     intervention  time     s_hat   risk_hat          se  ci_lower  ci_upper
#>           <char> <int>     <num>      <num>       <num>     <num>     <num>
#>  1:           a1     1 0.9072030 0.09279696 0.007156973 0.8931756 0.9212304
#>  2:           a1     2 0.8233862 0.17661380 0.010590964 0.8026283 0.8441441
#>  3:           a1     3 0.7446961 0.25530387 0.012787401 0.7196333 0.7697590
#>  4:           a1     4 0.6866888 0.31331125 0.014221879 0.6588144 0.7145631
#>  5:           a1     5 0.6135496 0.38645037 0.015867978 0.5824490 0.6446503
#>  6:           a0     1 0.8672526 0.13274743 0.009752943 0.8481372 0.8863680
#>  7:           a0     2 0.7526093 0.24739069 0.012807617 0.7275068 0.7777118
#>  8:           a0     3 0.6498533 0.35014674 0.014731026 0.6209810 0.6787255
#>  9:           a0     4 0.5769253 0.42307471 0.015647460 0.5462568 0.6075938
#> 10:           a0     5 0.4895910 0.51040899 0.016097284 0.4580409 0.5211411
#>         n
#>     <int>
#>  1:  2000
#>  2:  2000
#>  3:  2000
#>  4:  2000
#>  5:  2000
#>  6:  2000
#>  7:  2000
#>  8:  2000
#>  9:  2000
#> 10:  2000
Code
plot(res_surv, main = "IPW survival: A = 1 vs A = 0")

IPW counterfactual survival curves under treatment versus no treatment.

Because treatment is protective, survival under a1 sits above a0.

Risk difference and risk ratio

The pairwise estimands (risk_difference, risk_ratio, rmst_difference) need a reference intervention. The risk-ratio CI is built on the log scale.

Code
res_rd <- contrast(
  fit,
  interventions = list(a1 = static(1), a0 = static(0)),
  times     = 1:5,
  type      = "risk_difference",
  reference = "a0",
  ci_method = "sandwich"
)
res_rd$contrasts[] # `[]` forces auto-print of a function-returned data.table
#>    contrast  time    estimate          se    ci_lower    ci_upper
#>      <char> <int>       <num>       <num>       <num>       <num>
#> 1: a1 vs a0     1 -0.03995047 0.007537805 -0.05472429 -0.02517664
#> 2: a1 vs a0     2 -0.07077689 0.012695880 -0.09566036 -0.04589342
#> 3: a1 vs a0     3 -0.09484287 0.016864923 -0.12789751 -0.06178823
#> 4: a1 vs a0     4 -0.10976346 0.019426444 -0.14783859 -0.07168833
#> 5: a1 vs a0     5 -0.12395862 0.021834869 -0.16675418 -0.08116306
Code
res_rr <- contrast(
  fit,
  interventions = list(a1 = static(1), a0 = static(0)),
  times     = 1:5,
  type      = "risk_ratio",
  reference = "a0",
  ci_method = "sandwich"
)
res_rr$contrasts[]
#>    contrast  time  estimate         se  ci_lower  ci_upper
#>      <char> <int>     <num>      <num>     <num>     <num>
#> 1: a1 vs a0     1 0.6990490 0.04464470 0.6168020 0.7922633
#> 2: a1 vs a0     2 0.7139064 0.04309413 0.6342486 0.8035688
#> 3: a1 vs a0     3 0.7291339 0.04126996 0.6525716 0.8146789
#> 4: a1 vs a0     4 0.7405577 0.03989667 0.6663485 0.8230314
#> 5: a1 vs a0     5 0.7571386 0.03791358 0.6863595 0.8352167

Sandwich vs bootstrap inference

The IPW sandwich is a two-stage stacked estimating-equation variance: it accounts for estimation of the treatment model by subtracting a treatment-model correction from the naive weights-as-known sandwich. For stabilized weights this makes the SE narrower than treating the weights as fixed. The nonparametric bootstrap resamples ids (all of an id’s rows together) and refits both the treatment model and the hazard MSM per replicate; the two agree closely.

Code
res_boot <- contrast(
  fit,
  interventions = list(a1 = static(1), a0 = static(0)),
  times     = 1:5,
  type      = "risk_difference",
  reference = "a0",
  ci_method = "bootstrap",
  n_boot    = 200L,
  seed      = 42L
)

# Sandwich vs bootstrap SE at each period.
data.table(
  time    = res_rd$contrasts$time,
  se_sand = res_rd$contrasts$se,
  se_boot = res_boot$contrasts$se
)[]
#>     time     se_sand     se_boot
#>    <int>       <num>       <num>
#> 1:     1 0.007537805 0.007584255
#> 2:     2 0.012695880 0.013047872
#> 3:     3 0.016864923 0.017315078
#> 4:     4 0.019426444 0.020022203
#> 5:     5 0.021834869 0.022523821

Dynamic interventions

Beyond static(), IPW supports dynamic() rules: each id is assigned a treatment value as a function of its baseline covariates. The rule receives the data and the treatment vector and returns the assigned treatment.

Code
res_dyn <- contrast(
  fit,
  interventions = list(
    treat_high = dynamic(function(data, trt) as.integer(data$L > 0)),
    none       = static(0)
  ),
  times     = 1:5,
  type      = "risk_difference",
  reference = "none",
  ci_method = "sandwich"
)
res_dyn$contrasts[]
#>              contrast  time    estimate          se    ci_lower    ci_upper
#>                <char> <int>       <num>       <num>       <num>       <num>
#> 1: treat_high vs none     1 -0.01923615 0.003688285 -0.02646506 -0.01200724
#> 2: treat_high vs none     2 -0.03407907 0.006212123 -0.04625461 -0.02190353
#> 3: treat_high vs none     3 -0.04566684 0.008252062 -0.06184058 -0.02949310
#> 4: treat_high vs none     4 -0.05285111 0.009501985 -0.07147465 -0.03422756
#> 5: treat_high vs none     5 -0.05968607 0.010671253 -0.08060135 -0.03877080

Weight truncation

When the propensity model yields extreme density ratios (near-positivity violations, strong confounding), the IPW estimator can be unstable. The trim argument on surv_fit() winsorizes the per-id stabilized weights at a quantile (Cole & Hernán 2008) before they are broadcast onto the person-period rows; the resolved cutoff is held fixed and reused by the sandwich variance.

Code
fit_trim <- surv_fit(
  pp, "Y", "A", ~ L, "id", "t",
  time_formula = ~ factor(t), estimator = "ipw",
  trim = 0.99 # winsorize at the 99th percentile of the weights
)

c(max_raw = max(fit$weights), max_trim = max(fit_trim$weights),
  cutoff = fit_trim$trim_threshold)
#>  max_raw max_trim   cutoff 
#> 4.707337 2.467578 2.467578

trim = NULL (the default) applies no truncation; an untrimmed fit records trim_threshold = NA.

What IPW rejects

survatr’s IPW path is deliberately narrow for now and rejects unsupported combinations with classed errors rather than fitting something misleading:

Trigger Error class
Continuous treatment survatr_ipw_treatment_unsupported
Treatment varying within id survatr_ipw_time_varying_treatment
Constant treatment (no variation → undefined weights) survatr_ipw_no_treatment_variation
ipsi() intervention survatr_ipw_ipsi_deferred
External weights = composed with IPW (transport) survatr_ipw_external_weights

For a time-varying treatment, use estimator = "ice" — see vignette("ice").

References

Hernán MA, Robins JM (2025). Causal Inference: What If. Chapman & Hall/CRC. Chapter 17 (survival analysis via IP weighting and standardization).

Robins JM, Hernán MA, Brumback B (2000). Marginal structural models and causal inference in epidemiology. Epidemiology 11:550–560.

Cole SR, Hernán MA (2008). Constructing inverse probability weights for marginal structural models. American Journal of Epidemiology 168:656–664.