true_eff <- 0.5; n <- 400
set.seed(162) # Scenario A: unconfounded
XA <- rnorm(n)
YA <- true_eff * XA + rnorm(n, 0, 1) # only X drives Y
NA_ <- rnorm(n, 0, 1) # placebo: nothing links it to X
mA <- lm(YA ~ XA); ncA <- lm(NA_ ~ XA)
sA <- coef(mA)["XA"]; ciA <- confint(mA)["XA", ]
ncA_c <- coef(ncA)["XA"]; ci_ncA <- confint(ncA)["XA", ]
set.seed(1620) # Scenario B: an unmeasured confounder U
U <- rnorm(n)
XB <- 0.9 * U + rnorm(n, 0, 0.6)
YB <- true_eff * XB + 0.9 * U + rnorm(n, 0, 1) # true effect still 0.5, plus confounding
NB <- 0.9 * U + rnorm(n, 0, 1) # placebo: driven by U, NOT by X
mB <- lm(YB ~ XB); ncB <- lm(NB ~ XB)
sB <- coef(mB)["XB"]; ciB <- confint(mB)["XB", ]
ncB_c <- coef(ncB)["XB"]; ci_ncB <- confint(ncB)["XB", ]
corrected_B <- sB - ncB_c # subtract the bias the control revealsChecking causal assumptions
The three previous posts all leaned on the same promise: that the causal graph is right, that the backdoor set is complete, that no unmeasured variable is quietly confounding the estimate. That promise is not testable. The data fix the associations, never the arrows, so no regression output can certify that adjustment closed every backdoor. What you can do is check the promise indirectly: look for footprints that hidden confounding would leave, and quantify how strong it would have to be to matter. Neither check confirms the premise. Both make it harder to fool yourself, which is the honest goal.
A negative control leaves a footprint
A negative-control outcome is a placebo response: something the treatment cannot plausibly cause, but that shares the treatment’s confounders. If the estimate is clean, the treatment should show no association with the placebo. If it does, a backdoor is open, because only confounding could link a treatment to an outcome it does not affect. Take a treatment with a true effect on the real outcome, and compare an unconfounded world with one where a hidden variable drives the treatment, the outcome, and the placebo alike.
In the unconfounded world the primary estimate is 0.576, interval [0.49, 0.67], near the true 0.5, and the negative control sits at 0.01, interval [-0.09, 0.11], astride zero. No alarm. In the confounded world the primary estimate inflates to 1.167, interval [1.06, 1.27], and the placebo, which the treatment cannot touch, comes back at 0.722, interval [0.61, 0.83], firmly above zero. That non-null placebo is the footprint of an open backdoor, visible without ever measuring the confounder.
f1 <- data.frame(
scen = factor(rep(c("unconfounded", "confounded"), each = 2), levels = c("unconfounded", "confounded")),
what = factor(rep(c("primary\nX -> Y", "negative control\nX -> N"), 2),
levels = c("primary\nX -> Y", "negative control\nX -> N")),
est = c(sA, ncA_c, sB, ncB_c),
lo = c(ciA[1], ci_ncA[1], ciB[1], ci_ncB[1]),
hi = c(ciA[2], ci_ncA[2], ciB[2], ci_ncB[2]))
ggplot(f1, aes(what, est, colour = what)) +
geom_hline(yintercept = 0, colour = line_col, linewidth = 0.5) +
geom_hline(yintercept = true_eff, linetype = "dashed", colour = ink) +
geom_errorbar(aes(ymin = lo, ymax = hi), width = 0.14, linewidth = 0.8) +
geom_point(size = 3) +
facet_wrap(~scen) +
scale_colour_manual(values = c("primary\nX -> Y" = forest, "negative control\nX -> N" = red), guide = "none") +
labs(x = NULL, y = "estimated coefficient",
title = "A negative control flags confounding the primary estimate hides",
subtitle = "dashed line: true effect (0.5); the control should be zero if unconfounded") +
theme_te()
Because the placebo carries the same confounding as the real outcome, its estimate is roughly the bias itself. Subtracting it, 1.167 minus 0.722, gives 0.445, back within reach of the true 0.5. That calibration only works when the placebo shares the confounding structure of the outcome, an assumption you bring from subject knowledge, not something the data confirm.
How much confounding would it take?
The second check quantifies fragility. The robustness value of Cinelli and Hazlett asks: what share of the residual variance in both the treatment and the outcome would an unmeasured confounder need to explain to pull the estimate down to zero? It is computed from the coefficient’s own t-statistic, so it needs no extra data, and it maps directly to a bias-adjusted estimate as the assumed confounding strength grows.
rv_from_t <- function(tval, df, q = 1){ # Cinelli & Hazlett (2020)
f <- q * abs(tval) / sqrt(df); 0.5 * (sqrt(f^4 + 4 * f^2) - f^2)
}
tA <- summary(mA)$coefficients["XA", "t value"]; rvA <- rv_from_t(tA, df.residual(mA))
tB <- summary(mB)$coefficients["XB", "t value"]; rvB <- rv_from_t(tB, df.residual(mB))
adj_curve <- function(est, se, df, r) sign(est) * (abs(est) - se * sqrt(df) * (r / sqrt(1 - r)))
seA <- summary(mA)$coefficients["XA", "Std. Error"]
seB <- summary(mB)$coefficients["XB", "Std. Error"]The unconfounded estimate has a robustness value of 0.46: a confounder would need to explain about 46% of the leftover variance in both treatment and outcome to erase it. The confounded estimate returns 0.643, higher still. That is the catch worth sitting with. The confounded estimate is larger, so on paper it looks harder to overturn; the robustness value, read on its own, would call the biased result the safer of the two. The statistic measures how much confounding would be required, never how much is present. The negative control answers that second question, and here it has already fired.
rgrid <- seq(0, 0.85, by = 0.005)
dfc <- rbind(
data.frame(r = rgrid, adj = adj_curve(sA, seA, df.residual(mA), rgrid), scen = "unconfounded"),
data.frame(r = rgrid, adj = adj_curve(sB, seB, df.residual(mB), rgrid), scen = "confounded"))
cross <- data.frame(scen = c("unconfounded", "confounded"), rv = c(rvA, rvB), y = 0,
lab = sprintf("RV = %.2f", c(rvA, rvB)))
ggplot(dfc, aes(r, adj, colour = scen)) +
geom_hline(yintercept = 0, colour = ink, linewidth = 0.4) +
geom_line(linewidth = 1) +
geom_point(data = cross, aes(rv, y, colour = scen), size = 3, inherit.aes = FALSE) +
geom_text(data = cross, aes(rv, y - 0.07, label = lab, colour = scen), size = 3.1,
inherit.aes = FALSE, show.legend = FALSE) +
scale_colour_manual(values = c("unconfounded" = green, "confounded" = red), name = NULL) +
labs(x = "assumed strength of unmeasured confounding (share of residual variance)",
y = "bias-adjusted estimate",
title = "The robustness value is where the adjusted estimate hits zero",
subtitle = "further right means more confounding is needed to overturn the estimate") +
theme_te()
The honest limit
These checks constrain the problem; they do not close it. A null negative control is reassuring only against confounders that would also disturb the placebo; a hidden variable that moves the outcome but spares the control leaves no footprint here. A large robustness value says an implausibly strong confounder would be needed, not that none exists, and as the confounded example shows, a large value can even accompany a badly biased estimate. Every one of these tools bounds the space of alternative explanations without singling out the truth, the same wall reached in Markov-equivalent structures: the data narrow what is possible and then stop. Causal claims from observational data rest on assumptions that checks can stress but not prove. Reporting a sensitivity analysis alongside the estimate, and being plain about which confounders it does and does not rule out, is what keeps the conclusion honest.
References
Arif S, MacNeil MA 2022. Ecology Letters 25(8):1741-1745 (10.1111/ele.14033).
Arif S, MacNeil MA 2023. Ecological Monographs 93(1):e1554 (10.1002/ecm.1554).
Cinelli C, Hazlett C 2020. Journal of the Royal Statistical Society Series B 82(1):39-67 (10.1111/rssb.12348).
Lipsitch M, Tchetgen Tchetgen E, Cohen T 2010. Epidemiology 21(3):383-388 (10.1097/EDE.0b013e3181d61eeb).
VanderWeele TJ, Ding P 2017. Annals of Internal Medicine 167(4):268-274 (10.7326/M16-2607).