Code
using DataFrames
using Distributions
using Random
using Statistics
using LinearAlgebra
using GLM
using Printfusing DataFrames
using Distributions
using Random
using Statistics
using LinearAlgebra
using GLM
using PrintfMost chapters so far use one treatment decision. Many real problems are longitudinal. Treatment changes over time, covariates respond to past treatment, and those covariates then affect future treatment. Standard regression adjustment can fail in this setting.
Related reading: The g-estimation with time-varying covariates chapter of Topics on Econometrics and Causal Inference derives the identification result and applies the parametric g-formula to a worked binary-treatment example. The LMTP framework for continuous and modified-policy interventions is covered in Longitudinal modified treatment policy (LMTP) and in the Continuous Treatments chapter.
The main tools are:
For longitudinal TMLE, use R’s ltmle package; there is no Julia equivalent yet. The g-formula and IPTW/MSM translate directly to Julia with GLM.jl.
Consider a two-period example:
\(L_1\) is the problem variable. It confounds the effect of \(A_1\) on \(Y\), so we would like to adjust for it. But it is also affected by \(A_0\), so adjusting for it blocks part of the effect of \(A_0\). Regression without \(L_1\) is confounded; regression with \(L_1\) over-controls.
Random.seed!(1)
n = 5000
L0 = randn(n)
A0 = Float64.(rand(n) .< @. 1 / (1 + exp(-(-0.5 + 0.8 * L0))))
L1 = @. 0.5 * L0 + 1.0 * A0 + randn()
A1 = Float64.(rand(n) .< @. 1 / (1 + exp(-(-0.5 + 0.8 * L1))))
Y = @. 0.5 * A0 + 0.5 * A1 + 0.5 * L1 + 0.5 * L0 + randn()
df = DataFrame(L0 = L0, A0 = A0, L1 = L1, A1 = A1, Y = Y)
# A0 has a direct effect (0.5) AND an indirect effect through L1
# (A0 -> L1 -> Y contributes 1.0 * 0.5 = 0.5), so A0's total effect is 1.0;
# A1's total effect is 0.5; always-vs-never total = 1.5.
@printf("True total effect of always-treated vs never-treated: 1.500\n\n")
naive_no_L1 = lm(@formula(Y ~ A0 + A1 + L0), df)
naive_with_L1 = lm(@formula(Y ~ A0 + A1 + L0 + L1), df)
@printf("Naive (no L1): A0=%.3f, A1=%.3f, total=%.3f\n",
coef(naive_no_L1)[2], coef(naive_no_L1)[3],
coef(naive_no_L1)[2] + coef(naive_no_L1)[3])
@printf("Naive (with L1): A0=%.3f, A1=%.3f, total=%.3f\n",
coef(naive_with_L1)[2], coef(naive_with_L1)[3],
coef(naive_with_L1)[2] + coef(naive_with_L1)[3])True total effect of always-treated vs never-treated: 1.500
Naive (no L1): A0=0.944, A1=0.850, total=1.794
Naive (with L1): A0=0.474, A1=0.478, total=0.951
Neither regression recovers the true total effect:
This is exactly the setting g-methods were designed for.
The g-formula writes the mean outcome under a treatment regime as:
\[ \mathbb{E}[Y(\bar a)] = \sum_{\bar l} \mathbb{E}[Y \mid \bar a, \bar l] \prod_{t=0}^{K} f(l_t \mid \bar a_{t-1}, \bar l_{t-1}), \tag{19.1}\]
where \(\bar a=(a_0,a_1,\ldots,a_K)\) is a treatment regime and \(\bar l\) is the covariate history. In practice we fit models for the time-varying covariates and the outcome, then simulate the data under the treatment regime.
# Step 1: Fit nuisance models
mod_L1 = lm(@formula(L1 ~ L0 + A0), df)
mod_Y = lm(@formula(Y ~ L0 + A0 + L1 + A1), df)
# Step 2: Simulate counterfactual outcomes under a regime (a0, a1).
# The L1 distribution under A0 = a0 differs from the observed L1.
function g_compute(a0::Real, a1::Real; n_mc::Int = 100)
sigma_L1 = sqrt(sum(abs2.(residuals(mod_L1))) / dof_residual(mod_L1))
Y_sim = zeros(n)
for _ in 1:n_mc
df_a0 = DataFrame(L0 = L0, A0 = fill(a0, n))
L1_sim = predict(mod_L1, df_a0) .+ sigma_L1 .* randn(n)
df_sim = DataFrame(L0 = L0, A0 = fill(a0, n),
L1 = L1_sim, A1 = fill(a1, n))
Y_sim .+= predict(mod_Y, df_sim)
end
Y_sim ./= n_mc
return mean(Y_sim)
end
Random.seed!(99)
E_Y11 = g_compute(1.0, 1.0)
E_Y00 = g_compute(0.0, 0.0)
@printf("G-formula estimate (always vs never): %.3f (true = 1.500)\n",
E_Y11 - E_Y00)G-formula estimate (always vs never): 1.483 (true = 1.500)
The important step is simulating \(L_1\) under the counterfactual treatment. We do not condition on the observed \(L_1\), because observed \(L_1\) is partly a consequence of observed treatment.
The g-formula can handle other regimes too. For example, treat only at \(t=0\):
Random.seed!(99)
E_Y10 = g_compute(1.0, 0.0)
@printf("Effect of treating only at t=0: %.3f (true = 1.0)\n",
E_Y10 - E_Y00)Effect of treating only at t=0: 1.005 (true = 1.0)
IPTW reweights observations by the inverse probability of their observed treatment history. The stabilized weight is:
\[ SW_i = \prod_{t=0}^{K} \frac{f(A_t \mid \bar A_{t-1})}{f(A_t \mid \bar A_{t-1}, \bar L_{t})}. \tag{19.2}\]
The numerator uses treatment history only. The denominator also conditions on the covariate history up to and including \(L_t\) — the covariates measured just before \(A_t\). (Skipping \(L_t\) would leave the \(L_t \to A_t\) confounding intact in the pseudo-population.)
# Numerator: P(A0) and P(A1 | A0)
n0 = glm(@formula(A0 ~ 1), df, Binomial(), LogitLink())
n1 = glm(@formula(A1 ~ A0), df, Binomial(), LogitLink())
# Denominator: P(A0 | L0) and P(A1 | A0, L0, L1)
d0 = glm(@formula(A0 ~ L0), df, Binomial(), LogitLink())
d1 = glm(@formula(A1 ~ A0 + L0 + L1), df, Binomial(), LogitLink())
p_n0 = predict(n0); p_n1 = predict(n1)
p_d0 = predict(d0); p_d1 = predict(d1)
# Probability of the OBSERVED treatment at each time
w0_num = ifelse.(df.A0 .== 1, p_n0, 1 .- p_n0)
w0_den = ifelse.(df.A0 .== 1, p_d0, 1 .- p_d0)
w1_num = ifelse.(df.A1 .== 1, p_n1, 1 .- p_n1)
w1_den = ifelse.(df.A1 .== 1, p_d1, 1 .- p_d1)
sw = (w0_num .* w1_num) ./ (w0_den .* w1_den)
@printf("Weight summary: min=%.3f, mean=%.3f, max=%.3f\n",
minimum(sw), mean(sw), maximum(sw))
# Trim extreme weights at 99th percentile. Semicolon: without it this is the
# cell's last expression and Jupyter dumps all 5000 weights into the page.
sw_trim = min.(sw, quantile(sw, 0.99));
@printf("After trimming at the 99th percentile: max=%.3f\n", maximum(sw_trim))Weight summary: min=0.284, mean=1.000, max=13.489
After trimming at the 99th percentile: max=3.370
With stabilized weights, the marginal structural model is a weighted regression of \(Y\) on treatment history:
df.sw = sw_trim
msm_fit = lm(@formula(Y ~ A0 + A1), df, wts = df.sw)
# display(), not println() -- see the note in the heterogeneous-effects chapter:
# println() on a CoefTable prints the raw constructor, not the table.
display(coeftable(msm_fit))
@printf("\nMSM total effect (always vs never): %.3f (true = 1.500)\n",
coef(msm_fit)[2] + coef(msm_fit)[3])
# For comparison: the untrimmed weights
df.sw_u = sw
msm_untrimmed = lm(@formula(Y ~ A0 + A1), df, wts = df.sw_u)
@printf("Untrimmed MSM total effect: %.3f\n",
coef(msm_untrimmed)[2] + coef(msm_untrimmed)[3])| Coef. | Std. Error | t | Pr(> | t | ) | |
|---|---|---|---|---|---|---|
| (Intercept) | -0.0498963 | 0.0288233 | -1.73 | 0.0835 | -0.106403 | 0.00661038 |
| A0 | 1.04559 | 0.0399352 | 26.18 | <1e-99 | 0.967303 | 1.12388 |
| A1 | 0.547731 | 0.0391661 | 13.98 | <1e-42 | 0.470948 | 0.624515 |
MSM total effect (always vs never): 1.593 (true = 1.500)
Untrimmed MSM total effect: 1.486
The weighted population is constructed so treatment is no longer associated with past covariates. That is why the MSM targets the total effect. Note the trimmed estimate sits a little above the truth while the untrimmed one is closer: the weight models here are correctly specified, so the untrimmed weights are consistent, and capping the top 1% of weights reintroduces a bit of confounding bias in exchange for lower variance. Trimming is a bias-variance trade, not a free lunch.
| Estimator | Strengths | Weaknesses |
|---|---|---|
| G-formula | Handles arbitrary regimes; transparent | Sensitive to model misspecification on \(L_t\) and \(Y\) |
| IPTW + MSM | Simple, intuitive; valid with correct propensity model | Variance large with extreme weights |
In practice, reporting both is useful. Agreement is reassuring; disagreement suggests model misspecification in at least one of them.
For longitudinal TMLE, use R’s ltmle package. A Julia port would be useful but does not exist yet.
Even without IPTW + MSM, the g-formula is a useful check on standard regression. If standard regression and the g-formula disagree, that gap is a warning about time-varying confounding.
Random.seed!(123)
g_total = E_Y11 - E_Y00
msm_total = coef(msm_fit)[2] + coef(msm_fit)[3]
@printf("%-30s %.3f\n", "True total effect:", 1.5)
@printf("%-30s %.3f (wrong)\n", "Standard regression (no L1):",
coef(naive_no_L1)[2] + coef(naive_no_L1)[3])
@printf("%-30s %.3f (wrong)\n", "Standard regression (with L1):",
coef(naive_with_L1)[2] + coef(naive_with_L1)[3])
@printf("%-30s %.3f (correct)\n", "G-formula:", g_total)
@printf("%-30s %.3f (correct)\n", "MSM (IPTW):", msm_total)True total effect: 1.500
Standard regression (no L1): 1.794 (wrong)
Standard regression (with L1): 0.951 (wrong)
G-formula: 1.483 (correct)
MSM (IPTW): 1.593 (correct)
The standard regressions are wrong in opposite directions; the g-methods both recover the truth. When standard and g-formula estimates diverge, the divergence is the bias.
Use g-methods when:
This includes many clinical follow-up studies and longitudinal studies of education or labor-market policies.
For a one-shot treatment, the methods from the Estimation and Nonparametric Causal Methods chapters are sufficient. The added complexity of g-methods is only justified when the time-varying confounder problem is present.
ltmle package for now.