40 Sensitivity Analysis/ Robustness Check
40.1 Specification curve
- also known as Specification robustness graph or coefficient stability plot
Resources
40.1.1 starbility
- Recommend
Installation
devtools::install_github('https://github.com/AakaashRao/starbility')
library(starbility)
Example by the package’s author
library(tidyverse)
library(starbility)
library(lfe)
data("diamonds")
set.seed(43)
indices = sample(1:nrow(diamonds),
replace = F,
size = round(nrow(diamonds) / 20))
diamonds = diamonds[indices, ]
Plot different combinations of controls
# If you want to make the diamond dimensions as base control
base_controls = c(
'Diamond dimensions' = 'x + y + z' # include all variables under 1 dimension
)
perm_controls = c(
'Depth' = 'depth',
'Table width' = 'table'
)
nonperm_fe_controls = c(
'Clarity FE (granular)' = 'clarity',
'Clarity FE (binary)' = 'high_clarity'
)
# Adding fixed effects
nonperm_fe_controls = c(
'Clarity FE (granular)' = 'clarity',
'Clarity FE (binary)' = 'high_clarity'
)
# Adding instrumental variables
instruments = 'x+y+z'
# clustering and weights
diamonds$sample_weights = runif(n = nrow(diamonds))
# robust standard errors
starb_felm_custom = function(spec, data, rhs, ...) {
spec = as.formula(spec)
model = lfe::felm(spec, data=data) %>% broom::tidy()
row = which(model$term==rhs)
coef = model[row, 'estimate'] %>% as.numeric()
se = model[row, 'std.error'] %>% as.numeric()
p = model[row, 'p.value'] %>% as.numeric()
# 99% confidence interval
z = qnorm(0.995)
# one-tailed test
return(c(coef, p/2, coef+z*se, coef-z*se))
}
plots = stability_plot(
data = diamonds,
lhs = 'price',
rhs = 'carat',
error_geom = 'ribbon', # make the plot more aesthetics
# error_geom = 'none', # if you don't want ribbon (i.e., error bar)
model = starb_felm_custom,
cluster = 'cut',
weights = 'sample_weights',
# iv = instruments,
perm = perm_controls,
base = base_controls,
# perm_fe = perm_fe_controls,
# if you want to include fixed effects sequentially (not all combinations)
# (e.g., you want to test country or state fixed effect, not both )
# nonperm_fe = nonperm_fe_controls,
# fe_always = F, # if you want to have a model without any Fixed Effects
# sort "asc", "desc", or by fixed effects: "asc-by-fe" or "desc-by-fe"
sort = "asc-by-fe",
# if you have less variables and want more aesthetics
# control_geom = 'circle',
# point_size = 2,
# control_spacing = 0.3,
# error_alpha = 0.2, # change alpha of the error geom
# point_size = 1.5, # change the size of the coefficient points
# control_text_size = 10, # change the size of the control labels
# coef_ylim = c(-5000, 35000), # change the endpoints of the y-axis
# trip_top = 3, # change the spacing between the two panels
rel_height = 0.6
)
plots
# add comments
# replacement_coef_panel = plots[[1]] +
# scale_y_reverse() +
# theme(panel.grid.minor = element_blank()) +
# geom_vline(xintercept = 41,
# linetype = 'dashed',
# alpha = 0.4) +
# annotate(
# geom = 'label',
# x = 52,
# y = 30000,
# label = 'What a great\nspecification!',
# alpha = 0.75
# )
#
# combine_plots(replacement_coef_panel,
# plots[[2]],
# rel_height = 0.6)
Note:
- \(p < 0.01\): red
- \(p < 0.05\): green
- \(p < 0.1\): blue
- \(p > 0.1\): black
# Step 1: Control Grid
diamonds$high_clarity = diamonds$clarity %in% c('VS1','VVS2','VVS1','IF')
base_controls = c(
'Diamond dimensions' = 'x + y + z'
)
perm_controls = c(
'Depth' = 'depth',
'Table width' = 'table'
)
perm_fe_controls = c(
'Cut FE' = 'cut',
'Color FE' = 'color'
)
nonperm_fe_controls = c(
'Clarity FE (granular)' = 'clarity',
'Clarity FE (binary)' = 'high_clarity'
)
grid1 = stability_plot(data = diamonds,
lhs = 'price',
rhs = 'carat',
perm = perm_controls,
base = base_controls,
perm_fe = perm_fe_controls,
nonperm_fe = nonperm_fe_controls,
run_to=2)
knitr::kable(grid1 %>% head(10))
Diamond dimensions | Depth | Table width | Cut FE | Color FE | np_fe |
---|---|---|---|---|---|
1 | 0 | 0 | 0 | 0 | |
1 | 1 | 0 | 0 | 0 | |
1 | 0 | 1 | 0 | 0 | |
1 | 1 | 1 | 0 | 0 | |
1 | 0 | 0 | 1 | 0 | |
1 | 1 | 0 | 1 | 0 | |
1 | 0 | 1 | 1 | 0 | |
1 | 1 | 1 | 1 | 0 | |
1 | 0 | 0 | 0 | 1 | |
1 | 1 | 0 | 0 | 1 |
# Step 2: Get model expression
grid2 = stability_plot(grid = grid1,
data=diamonds,
lhs='price',
rhs='carat',
perm=perm_controls,
base=base_controls,
run_from=2,
run_to=3)
knitr::kable(grid2 %>% head(10))
Diamond dimensions | Depth | Table width | np_fe | expr |
---|---|---|---|---|
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 |
1 | 0 | 1 | 0 | price~carat+x+y+z+table|0|0|0 |
1 | 1 | 1 | 0 | price~carat+x+y+z+depth+table|0|0|0 |
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 |
1 | 0 | 1 | 0 | price~carat+x+y+z+table|0|0|0 |
1 | 1 | 1 | 0 | price~carat+x+y+z+depth+table|0|0|0 |
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 |
# Step 3: Estimate models
grid3 = stability_plot(grid = grid2,
data=diamonds,
lhs='price',
rhs='carat',
perm=perm_controls,
base=base_controls,
run_from=3,
run_to=4)
knitr::kable(grid3 %>% head(10))
Diamond dimensions | Depth | Table width | np_fe | expr | coef | p | error_high | error_low |
---|---|---|---|---|---|---|---|---|
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 | 10461.86 | p<0.01 | 11031.84 | 9891.876 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 | 10808.25 | p<0.01 | 11388.81 | 10227.683 |
1 | 0 | 1 | 0 | price~carat+x+y+z+table|0|0|0 | 10423.42 | p<0.01 | 10992.00 | 9854.849 |
1 | 1 | 1 | 0 | price~carat+x+y+z+depth+table|0|0|0 | 10851.31 | p<0.01 | 11428.58 | 10274.037 |
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 | 10461.86 | p<0.01 | 11031.84 | 9891.876 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 | 10808.25 | p<0.01 | 11388.81 | 10227.683 |
1 | 0 | 1 | 0 | price~carat+x+y+z+table|0|0|0 | 10423.42 | p<0.01 | 10992.00 | 9854.849 |
1 | 1 | 1 | 0 | price~carat+x+y+z+depth+table|0|0|0 | 10851.31 | p<0.01 | 11428.58 | 10274.037 |
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 | 10461.86 | p<0.01 | 11031.84 | 9891.876 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 | 10808.25 | p<0.01 | 11388.81 | 10227.683 |
# Step 4: Get dataframe to draw
dfs = stability_plot(grid = grid3,
data=diamonds,
lhs='price',
rhs='carat',
perm=perm_controls,
base=base_controls,
run_from=4,
run_to=5)
coef_grid = dfs[[1]]
control_grid = dfs[[2]]
knitr::kable(coef_grid %>% head(10))
Diamond dimensions | Depth | Table width | np_fe | expr | coef | p | error_high | error_low | model |
---|---|---|---|---|---|---|---|---|---|
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 | 10461.86 | p<0.01 | 11031.84 | 9891.876 | 1 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 | 10808.25 | p<0.01 | 11388.81 | 10227.683 | 2 |
1 | 0 | 1 | 0 | price~carat+x+y+z+table|0|0|0 | 10423.42 | p<0.01 | 10992.00 | 9854.849 | 3 |
1 | 1 | 1 | 0 | price~carat+x+y+z+depth+table|0|0|0 | 10851.31 | p<0.01 | 11428.58 | 10274.037 | 4 |
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 | 10461.86 | p<0.01 | 11031.84 | 9891.876 | 5 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 | 10808.25 | p<0.01 | 11388.81 | 10227.683 | 6 |
1 | 0 | 1 | 0 | price~carat+x+y+z+table|0|0|0 | 10423.42 | p<0.01 | 10992.00 | 9854.849 | 7 |
1 | 1 | 1 | 0 | price~carat+x+y+z+depth+table|0|0|0 | 10851.31 | p<0.01 | 11428.58 | 10274.037 | 8 |
1 | 0 | 0 | 0 | price~carat+x+y+z|0|0|0 | 10461.86 | p<0.01 | 11031.84 | 9891.876 | 9 |
1 | 1 | 0 | 0 | price~carat+x+y+z+depth|0|0|0 | 10808.25 | p<0.01 | 11388.81 | 10227.683 | 10 |
# Step 5: plot the sensitivity graph
panels = stability_plot(data = diamonds,
lhs='price',
rhs='carat',
coef_grid = coef_grid,
control_grid = control_grid,
run_from=5,
run_to=6)
stability_plot(data = diamonds,
lhs='price',
rhs='carat',
coef_panel = panels[[1]],
control_panel = panels[[2]],
run_from = 6,
run_to = 7)
In step 2, we can modify to use other function (e.g., glm
)
diamonds$above_med_price = as.numeric(diamonds$price > median(diamonds$price))
base_controls = c('Diamond dimensions' = 'x + y + z')
perm_controls = c('Depth' = 'depth',
'Table width' = 'table',
'Clarity' = 'clarity')
lhs_var = 'above_med_price'
rhs_var = 'carat'
grid1 = stability_plot(
data = diamonds,
lhs = lhs_var,
rhs = rhs_var,
perm = perm_controls,
base = base_controls,
fe_always = F,
run_to = 2
)
# Create control part of formula
base_perm = c(base_controls, perm_controls)
grid1$expr = apply(grid1[, 1:length(base_perm)], 1,
function(x)
paste(base_perm[names(base_perm)[which(x == 1)]],
collapse = '+'))
# Complete formula with LHS and RHS variables
grid1$expr = paste(lhs_var, '~', rhs_var, '+', grid1$expr, sep = '')
knitr::kable(grid1 %>% head(10))
Diamond dimensions | Depth | Table width | Clarity | np_fe | expr |
---|---|---|---|---|---|
1 | 0 | 0 | 0 | above_med_price~carat+x + y + z | |
1 | 1 | 0 | 0 | above_med_price~carat+x + y + z+depth | |
1 | 0 | 1 | 0 | above_med_price~carat+x + y + z+table | |
1 | 1 | 1 | 0 | above_med_price~carat+x + y + z+depth+table | |
1 | 0 | 0 | 1 | above_med_price~carat+x + y + z+clarity | |
1 | 1 | 0 | 1 | above_med_price~carat+x + y + z+depth+clarity | |
1 | 0 | 1 | 1 | above_med_price~carat+x + y + z+table+clarity | |
1 | 1 | 1 | 1 | above_med_price~carat+x + y + z+depth+table+clarity |
# customer function for the logit model
starb_logit = function(spec, data, rhs, ...) {
spec = as.formula(spec)
model = glm(spec, data=data, family='binomial', weights=data$weight) %>%
broom::tidy()
row = which(model$term==rhs)
coef = model[row, 'estimate'] %>% as.numeric()
se = model[row, 'std.error'] %>% as.numeric()
p = model[row, 'p.value'] %>% as.numeric()
return(c(coef, p, coef+1.96*se, coef-1.96*se))
}
stability_plot(grid = grid1,
data = diamonds,
lhs = lhs_var,
rhs = rhs_var,
model = starb_logit,
perm = perm_controls,
base = base_controls,
fe_always = F,
run_from=3)
For getting other specification (e.g., different CI)
library(margins)
starb_logit_enhanced = function(spec, data, rhs, ...) {
# Unpack ...
l = list(...)
get_mfx = ifelse(is.null(l$get_mfx), F, T) # Set a default to F
spec = as.formula(spec)
if (get_mfx) {
model = glm(spec, data=data, family='binomial', weights=data$weight) %>%
margins() %>%
summary
row = which(model$factor==rhs)
coef = model[row, 'AME'] %>% as.numeric()
se = model[row, 'SE'] %>% as.numeric()
p = model[row, 'p'] %>% as.numeric()
} else {
model = glm(spec, data=data, family='binomial', weights=data$weight) %>%
broom::tidy()
row = which(model$term==rhs)
coef = model[row, 'estimate'] %>% as.numeric()
se = model[row, 'std.error'] %>% as.numeric()
p = model[row, 'p.value'] %>% as.numeric()
}
z = qnorm(0.995)
return(c(coef, p, coef+z*se, coef-z*se))
}
stability_plot(grid = grid1,
data = diamonds,
lhs = lhs_var,
rhs = rhs_var,
model = starb_logit_enhanced,
get_mfx = T,
perm = perm_controls,
base = base_controls,
fe_always = F,
run_from = 3)
To get your customized plot
dfs = stability_plot(grid = grid1,
data = diamonds,
lhs = lhs_var,
rhs = rhs_var,
model = starb_logit_enhanced,
get_mfx = T,
perm = perm_controls,
base = base_controls,
fe_always = F,
run_from = 3,
run_to = 5)
coef_grid_logit = dfs[[1]]
control_grid_logit = dfs[[2]]
min_space = 0.5
coef_plot = ggplot2::ggplot(coef_grid_logit, aes(
x = model,
y = coef,
shape = p,
group = p
)) +
geom_linerange(aes(ymin = error_low, ymax = error_high), alpha = 0.75) +
geom_point(size = 5, aes(col = p, fill = p), alpha = 1) +
viridis::scale_color_viridis(discrete = TRUE, option = "D") +
scale_shape_manual(values = c(15, 17, 18, 19)) +
theme_classic() +
geom_hline(yintercept = 0, linetype = 'dotted') +
ggtitle('A custom coefficient stability plot!') +
labs(subtitle = "Error bars represent 99% confidence intervals") +
theme(
axis.text.x = element_blank(),
axis.title = element_blank(),
axis.ticks.x = element_blank()
) +
coord_cartesian(xlim = c(1 - min_space, max(coef_grid_logit$model) + min_space),
ylim = c(-0.1, 1.6)) +
guides(fill = F, shape = F, col = F)
control_plot = ggplot(control_grid_logit) +
geom_point(aes(x = model, y = y, fill=value), shape=23, size=4) +
scale_fill_manual(values=c('#FFFFFF', '#000000')) +
guides(fill=F) +
scale_y_continuous(breaks = unique(control_grid_logit$y),
labels = unique(control_grid_logit$key),
limits=c(min(control_grid_logit$y)-1, max(control_grid_logit$y)+1)) +
scale_x_continuous(breaks=c(1:max(control_grid_logit$model))) +
coord_cartesian(xlim=c(1-min_space, max(control_grid_logit$model)+min_space)) +
theme_classic() +
theme(panel.grid.major.y = element_blank(),
panel.grid.minor.y = element_blank(),
axis.title = element_blank(),
axis.text.y = element_text(size=10),
axis.ticks = element_blank(),
axis.line = element_blank())
cowplot::plot_grid(coef_plot, control_plot, rel_heights=c(1,0.5),
align='v', ncol=1, axis='b')
To get different model specification (e.g., probit vs. logit)
starb_probit = function(spec, data, rhs, ...) {
# Unpack ...
l = list(...)
get_mfx = ifelse(is.null(l$get_mfx), F, T) # Set a default to F
spec = as.formula(spec)
if (get_mfx) {
model = glm(
spec,
data = data,
family = binomial(link = 'probit'),
weights = data$weight
) %>%
margins() %>%
summary
row = which(model$factor == rhs)
coef = model[row, 'AME'] %>% as.numeric()
se = model[row, 'SE'] %>% as.numeric()
p = model[row, 'p'] %>% as.numeric()
} else {
model = glm(
spec,
data = data,
family = binomial(link = 'probit'),
weights = data$weight
) %>%
broom::tidy()
row = which(model$term == rhs)
coef = model[row, 'estimate'] %>% as.numeric()
se = model[row, 'std.error'] %>% as.numeric()
p = model[row, 'p.value'] %>% as.numeric()
}
z = qnorm(0.995)
return(c(coef, p, coef + z * se, coef - z * se))
}
probit_dfs = stability_plot(
grid = grid1,
data = diamonds,
lhs = lhs_var,
rhs = rhs_var,
model = starb_probit,
get_mfx = T,
perm = perm_controls,
base = base_controls,
fe_always = F,
run_from = 3,
run_to = 5
)
# We'll put the probit DFs on the left,
#so we need to adjust the model numbers accordingly
# so the probit and logit DFs don't plot on top of one another!
coef_grid_probit = probit_dfs[[1]] %>%
mutate(model = model + max(coef_grid_logit$model))
control_grid_probit = probit_dfs[[2]] %>%
mutate(model = model + max(control_grid_logit$model))
coef_grid = bind_rows(coef_grid_logit, coef_grid_probit)
control_grid = bind_rows(control_grid_logit, control_grid_probit)
panels = stability_plot(
coef_grid = coef_grid,
control_grid = control_grid,
data = diamonds,
lhs = lhs_var,
rhs = rhs_var,
perm = perm_controls,
base = base_controls,
fe_always = F,
run_from = 5,
run_to = 6
)
coef_plot = panels[[1]] + geom_vline(xintercept = 8.5,
linetype = 'dashed',
alpha = 0.8) +
annotate(
geom = 'label',
x = 4.25,
y = 1.8,
label = 'Logit models',
size = 6,
fill = '#D3D3D3',
alpha = 0.7
) +
annotate(
geom = 'label',
x = 12.75,
y = 1.8,
label = 'Probit models',
size = 6,
fill = '#D3D3D3',
alpha = 0.7
) +
coord_cartesian(ylim = c(-0.5, 1.9))
control_plot = panels[[2]] + geom_vline(xintercept = 8.5,
linetype = 'dashed',
alpha = 0.8)
cowplot::plot_grid(
coef_plot,
control_plot,
rel_heights = c(1, 0.5),
align = 'v',
ncol = 1,
axis = 'b'
)
40.1.2 rdfanalysis
- Not recommend
Installation
devtools::install_github("joachim-gassen/rdfanalysis")
Example by the package’s author
library(rdfanalysis)
load(url("https://joachim-gassen.github.io/data/rdf_ests.RData"))
plot_rdf_spec_curve(ests, "est", "lb", "ub")
Shiny app for readers to explore
design <- define_design(steps = c("read_data",
"select_idvs",
"treat_extreme_obs",
"specify_model",
"est_model"),
rel_dir = "vignettes/case_study_code")
shiny_rdf_spec_curve(ests, list("est", "lb", "ub"),
design, "vignettes/case_study_code",
"https://joachim-gassen.github.io/data/wb_new.csv")
40.2 Coefficient stability
Coefficient stability can be evident against omitted variable bias.
But coefficient stability alone can be misleading, but combing with \(R^2\) movement, it can become informative.
Packages
mplot
: graphical Model stability and Variable Selectionrobomit
: Robustness checks for omitted variable bias (implementation of
library(robomit)
# estimate beta
o_beta(
y = "mpg", # dependent variable
x = "wt", # independent treatment variable
con = "hp + qsec", # related control variables
delta = 1, # delta
R2max = 0.9, # maximum R-square
type = "lm", # model type
data = mtcars # dataset
)
#> # A tibble: 10 × 2
#> Name Value
#> <chr> <dbl>
#> 1 beta* -2.00
#> 2 (beta*-beta controlled)^2 5.56
#> 3 Alternative Solution 1 -7.01
#> 4 (beta[AS1]-beta controlled)^2 7.05
#> 5 Uncontrolled Coefficient -5.34
#> 6 Controlled Coefficient -4.36
#> 7 Uncontrolled R-square 0.753
#> 8 Controlled R-square 0.835
#> 9 Max R-square 0.9
#> 10 delta 1
40.3 Omitted Variable Bias Quantification
To quantify the bias needed to change the substantive conclusion from a causal inference study.
library(konfound)
pkonfound(
est_eff = 5,
std_err = 2,
n_obs = 1000,
n_covariates = 5
)
#> Robustness of Inference to Replacement (RIR):
#> To invalidate an inference, 21.506 % of the estimate would have to be due to bias.
#> This is based on a threshold of 3.925 for statistical significance (alpha = 0.05).
#>
#> To invalidate an inference, 215 observations would have to be replaced with cases
#> for which the effect is 0 (RIR = 215).
#>
#> See Frank et al. (2013) for a description of the method.
#>
#> Citation: Frank, K.A., Maroulis, S., Duong, M., and Kelcey, B. (2013).
#> What would it take to change an inference?
#> Using Rubin's causal model to interpret the
#> robustness of causal inferences.
#> Education, Evaluation and
#> Policy Analysis, 35 437-460.
pkonfound(
est_eff = 5,
std_err = 2,
n_obs = 1000,
n_covariates = 5,
to_return = "thresh_plot"
)
pkonfound(
est_eff = 5,
std_err = 2,
n_obs = 1000,
n_covariates = 5,
to_return = "corr_plot"
)