This vignette walks through the complete workflow:
fitted model -> sensitivity path -> confoundsens object -> plots -> report
using a real, publicly available dataset.
The darfur data (Hazlett, 2020), distributed with the
sensemakr package, come from a survey of Darfurian
refugees in eastern Chad. The question is whether being directly harmed
in the violence (directlyharmed) changed attitudes toward
peace (peacefactor). Following Cinelli and Hazlett (2020),
we adjust for age, occupation, past voting, household size, sex, and
village fixed effects.
library(confoundvis)
data("darfur", package = "sensemakr")
fit <- lm(peacefactor ~ directlyharmed + age + farmer_dar + herder_dar +
pastvoted + hhsize_darfur + female + village, data = darfur)
coef(summary(fit))["directlyharmed", ]
#> Estimate Std. Error t value Pr(>|t|)
#> 0.0973158193 0.0232565378 4.1844499848 0.0000318234A sensitivity path is the treatment estimate as a function
of the strength \(\lambda\) of a
hypothetical omitted confounder. confoundvis computes paths
for two regression frameworks directly from the fitted model.
Partial \(R^2\) (Cinelli and Hazlett, 2020). \(\lambda\) is the partial \(R^2\) of the confounder with both treatment and outcome:
p_r2 <- sens_path_lm(fit, treatment = "directlyharmed",
lambda = seq(0, 0.3, by = 0.001))
p_r2
#> <confoundsens>
#> n : 301
#> lambda : [0, 0.3]
#> theta : [-0.136029, 0.0973158]
#> level : <none>
#> se : yes
#> t : yes
#> frame : partial_r2 (from lm)Impact threshold (Frank, 2000). \(\lambda\) is the impact \(k = r_{DU}\,r_{YU}\); the path is the adjusted partial correlation:
it <- itcv_lm(fit, treatment = "directlyharmed",
lambda = seq(0, 0.3, by = 0.001))
c(r = it$r, r_crit = it$r_crit, ITCV = it$itcv,
ITCV_unconditional = it$itcv_unconditional)
#> r r_crit ITCV ITCV_unconditional
#> 0.14789555 0.06997985 0.08377850 0.04486840Both results are confoundsens objects, so everything
downstream is the same regardless of framework.
The dotted line is the null. The band is a pointwise 95% interval for the bias-adjusted estimate.
On the ITCV path the dashed line marks the critical correlation; the path crosses it exactly at the ITCV.
A threshold alone does not say whether an omitted confounder that
strong is plausible. The usual aid is to compare it with observed
covariates. covariate_impacts() computes, for every
covariate term, its partial associations with treatment and outcome.
imp <- covariate_impacts(fit, "directlyharmed", metric = "partial_r2")
imp[order(-imp$impact), c("covariate", "term_df", "r2dz.x", "r2yz.dx",
"bias_index")]
#> covariate term_df r2dz.x r2yz.dx bias_index
#> 7 village 485 4.169266e-01 0.4242888431 0.5508064275
#> 6 female 1 9.081065e-03 0.1090339154 0.0316104106
#> 1 age 1 1.115766e-03 0.0080242581 0.0029938602
#> 4 pastvoted 1 1.489741e-03 0.0050684125 0.0027498882
#> 3 herder_dar 1 7.898770e-03 0.0002521486 0.0014168700
#> 5 hhsize_darfur 1 1.740140e-04 0.0005195193 0.0003006981
#> 2 farmer_dar 1 4.917499e-06 0.0024364946 0.0001094601The sensitivity Love plot places each covariate on the chosen metric
and draws the threshold. With metric = "partial_r2", a
covariate to the right of the line would, if an omitted confounder were
equally strong, bring the point estimate to zero.
The ITCV metric uses signed products of partial correlations and is
defined for single-column terms; the multi-column village
term is dropped with a message.
imp_it <- covariate_impacts(fit, "directlyharmed", metric = "itcv")
#> Multi-column terms have no ITCV impact (NA); use metric = "partial_r2" to include them.
plot_sensitivity_love(imp_it)The same object supplies benchmarks for the contour plot. Passing the
itcv_lm() result draws the exact significance boundary
(solid) next to the product reference \(|r_{YU} r_{DU}| = \text{ITCV}\) (dashed).
The two agree only for confounders whose two correlations have equal
magnitude; elsewhere the exact boundary decides. Pairs that no
correlation matrix can produce are shaded as infeasible.
plot_sensitivity_contour(itcv = it,
benchmarks = imp_it[!is.na(imp_it$r_yu), ],
limits = c(-0.5, 0.5))sens_report(p_r2)
#> Sensitivity report (framework: partial_r2; source: lm)
#> Treatment: directlyharmed
#> Estimate 0.0973 (SE 0.0233, df 783); robustness value RV = 0.139.
#> Confounder strength (lambda): Confounder partial R2 with treatment and
#> outcome.
#> Starting at 0.0973, the point estimate reaches the null at lambda =
#> 0.139; significance (alpha = 0.05) is lost at lambda = 0.0763.
#> These values describe how strong an omitted confounder would need to
#> be. They do not show whether such a confounder exists, and they rest
#> on the framework's own modelling assumptions. Judge plausibility
#> against substantive knowledge and observed benchmarks (see
#> covariate_impacts()).
sens_report(it$path)
#> Sensitivity report (framework: itcv; source: lm)
#> Treatment: directlyharmed
#> Partial correlation r = 0.148; critical r = 0.07; ITCV = 0.0838.
#> Confounder strength (lambda): Confounder impact k = r(D,U) x r(Y,U).
#> Starting at 0.148, the point estimate reaches the null at lambda =
#> 0.148; significance (alpha = 0.05) is lost at lambda = 0.0838.
#> These values describe how strong an omitted confounder would need to
#> be. They do not show whether such a confounder exists, and they rest
#> on the framework's own modelling assumptions. Judge plausibility
#> against substantive knowledge and observed benchmarks (see
#> covariate_impacts()).robustness_points() returns the same numbers as a data
frame for tables. On the partial \(R^2\) path, the null crossing is the
robustness value and the significance crossing is the robustness value
at \(\alpha = 0.05\) reported by
sensemakr.
If an analysis was run with another package, convert its result instead of recomputing it.
s <- sensemakr::sensemakr(fit, treatment = "directlyharmed",
benchmark_covariates = "female", kd = 1:3)
p_sm <- from_sensemakr(s, lambda = seq(0, 0.3, by = 0.001))
robustness_points(p_sm)
#> level theta_start lambda_null lambda_sig sig_rule
#> 1 pooled 0.09731582 0.1387762 0.07625786 t, df = 782
unlist(s$sensitivity_stats[c("rv_q", "rv_qa")])
#> rv_q rv_qa
#> 0.13877635 0.07625797k <- suppressWarnings(suppressMessages(
konfound::konfound(fit, directlyharmed, to_return = "raw_output")))
p_kf <- from_konfound(k, lambda = seq(0, 0.3, by = 0.001))
c(confoundvis = it$itcv, konfound_itcvGz = k$itcvGz)
#> confoundvis konfound_itcvGz
#> 0.0837785 0.0837785For binary or ratio-scale outcomes, E-value results convert to a path on the log risk-ratio scale that crosses the null at the E-value:
e <- EValue::evalues.RR(est = 1.8, lo = 1.3, hi = 2.5)
p_ev <- from_evalue(e, lambda = seq(1, 4, by = 0.01))
plot_robustness_curve(p_ev, points = FALSE)For a matched-pair study, sens_path_pairs() computes the
path under Rosenbaum’s sensitivity model, in which two matched units may
differ in their odds of treatment by at most a factor \(\Gamma\). The path stores, for each \(\Gamma\), the deviate equivalent to the
upper bound on the one-sided P-value, so the same crossing, plotting,
and reporting functions apply. The werfel data in
DOS2 compare DNA damage in 39 welders and 39 controls
matched for age and smoking.
data("werfel", package = "DOS2")
d <- werfel$serpc_p - werfel$cerpc_p
p_gam <- sens_path_pairs(d, gamma = seq(1, 6, by = 0.01))
robustness_points(p_gam)
#> level theta_start lambda_null lambda_sig sig_rule
#> 1 pooled 4.535374 NA 3.86761 theta threshold
sens_report(p_gam)
#> Sensitivity report (framework: rosenbaum; source: pairs)
#> Matched pairs: 39 (39 with nonzero difference); Wilcoxon signed rank
#> statistic = 715; sensitivity value Gamma = 3.87 (one-sided alpha =
#> 0.05).
#> Confounder strength (lambda): Gamma (odds of differential treatment
#> assignment).
#> The upper-bound P-value reaches 0.05 at Gamma = 3.87; it stays below
#> 0.5 for Gamma <= 6.
#> These values describe how strong an omitted confounder would need to
#> be. They do not show whether such a confounder exists, and they rest
#> on the framework's own modelling assumptions. Judge plausibility
#> against substantive knowledge and, where available, observed
#> benchmarks.
plot_robustness_curve(p_gam, points = FALSE)The result agrees with DOS2::senWilcox(). For a binary
outcome, use method = "binary", which applies the exact
bound for McNemar’s statistic.
Each framework describes an omitted confounder with two parameters,
one for its association with treatment and one for its association with
the outcome. A path is one line through that plane.
sens_surface() returns the whole plane with the boundaries
at which the conclusion changes, computed from the framework’s published
formula. The partial \(R^2\) surface is
the plane of sensemakr::ovb_contour_plot(); benchmarks from
covariate_impacts() are placed on it automatically.
s_r2 <- sens_surface(p_r2)
s_r2
#> <confoundsurf>
#> framework : partial_r2
#> a (x-axis): Partial R2 of U with treatment [0, 0.416329]
#> b (y-axis): Partial R2 of U with outcome [0, 0.416329]
#> value : Adjusted estimate
#> boundaries: Null (point estimate = 0); Significance (alpha = 0.05); Significance, reversed direction (alpha = 0.05)
plot_sensitivity_surface(s_r2, benchmarks = head(imp[order(-imp$impact), ], 4))For a matched-pair study, the surface shows the amplification of
\(\Gamma\) (Rosenbaum and Silber,
2009): every pair \((\Lambda, \Delta)\)
on the solid curve corresponds to the sensitivity value, as in
sensitivitymv::amplify().
The size of a shaded zone depends on the axis ranges and carries no inferential meaning; the surface is read only through its boundaries.
sens_table() collects the decision information of
several paths, each in its own units. It is intended for reporting
several analyses side by side, not for ranking them: a robustness value,
an impact threshold, and a value of \(\Gamma\) measure different things.
sens_table(partial_r2 = p_r2, itcv = it$path, sensemakr = p_sm)
#> <confoundvis_table>
#> path framework source
#> partial_r2 partial_r2 lm
#> itcv itcv lm
#> sensemakr partial_r2 sensemakr
#> parameter summary_name
#> Confounder partial R2 with treatment and outcome RV (point null)
#> Confounder impact k = r(D,U) x r(Y,U) ITCV (conditional)
#> Confounder partial R2 with treatment and outcome RV (point null)
#> summary_value theta_start lambda_null lambda_sig sig_rule
#> 0.13880 0.09732 0.1388 0.07626 t, df = 782
#> 0.08378 0.14790 0.1479 0.08378 theta threshold
#> 0.13880 0.09732 0.1388 0.07626 t, df = 782
#>
#> Each row keeps its own sensitivity parameter (column `parameter`); lambda values are comparable only within a row.confoundvis is a presentation layer. Its computations
reproduce the published formulas of each framework (the test suite
checks them against sensemakr,
konfound, and EValue), and its
contribution is a shared object and a shared set of displays. It
inherits every assumption of the framework that generated a path. In
particular:
Rosenbaum, P. R., & Silber, J. H. (2009). Amplification of sensitivity analysis in matched observational studies. Journal of the American Statistical Association, 104(488), 1398–1405.
Cinelli, C., & Hazlett, C. (2020). Making sense of sensitivity: Extending omitted variable bias. Journal of the Royal Statistical Society: Series B, 82(1), 39–67.
Frank, K. A. (2000). Impact of a confounding variable on a regression coefficient. Sociological Methods & Research, 29(2), 147–194.
Frank, K. A., Maroulis, S. J., Duong, M. Q., & Kelcey, B. M. (2013). What would it take to change an inference? Educational Evaluation and Policy Analysis, 35(4), 437–460.
Hazlett, C. (2020). Angry or weary? How violence impacts attitudes toward peace among Darfurian refugees. Journal of Conflict Resolution, 64(5), 844–870.
Rosenbaum, P. R. (2002). Observational Studies (2nd ed.). Springer.
VanderWeele, T. J., & Ding, P. (2017). Sensitivity analysis in observational research: Introducing the E-value. Annals of Internal Medicine, 167(4), 268–274.