The data and some of the examples are derivatives of the sources
noted in data/README.md (plus a couple more).
What we’ve got as the dependent variable is the percentage change in GOP support from 2012 to 2016.
Rows: 3,111
Columns: 11
$ gop_support_change <dbl> 1.1, 0.0, 8.1, 5.3, 3.9, 3.1, 5.1, 5.8, 8.6, 9.2, 3…
$ pop_change <dbl> 1.5, 9.8, -2.1, -1.8, 0.7, -1.4, -3.1, -2.3, -0.3, …
$ age_65_plus <dbl> 13.8, 18.7, 16.5, 14.8, 17.0, 14.9, 18.0, 16.0, 18.…
$ black <dbl> 18.7, 9.6, 47.6, 22.1, 1.8, 70.1, 44.0, 21.1, 39.5,…
$ hispanic <dbl> 2.7, 4.6, 4.5, 2.1, 8.7, 7.5, 1.2, 3.5, 2.0, 1.5, 7…
$ hs_grad <dbl> 85.6, 89.1, 73.7, 77.5, 77.0, 67.8, 76.3, 78.6, 75.…
$ undergrad <dbl> 20.9, 27.7, 13.4, 12.1, 12.1, 12.5, 14.0, 16.1, 11.…
$ homeownership_rate <dbl> 76.8, 72.6, 67.7, 79.0, 81.0, 74.3, 70.3, 68.7, 67.…
$ median_home_value <dbl> 136200, 168600, 89200, 90500, 117100, 70600, 74700,…
$ median_income <dbl> 53682, 50221, 32911, 36447, 44145, 32033, 29918, 39…
$ poverty_rate <dbl> 12.1, 13.9, 26.7, 18.1, 15.8, 21.6, 28.4, 21.9, 24.…
The independent variables (all continuous): pop_change,
age_65_plus, black, hispanic,
hs_grad, undergrad,
homeownership_rate, median_home_value,
median_income, poverty_rate.
lm()You could think about what model to use, but as an exemplar we are
more interested in comparing notes than in the actual analysis. We’ll
therefore proceed to lm().
Call:
lm(formula = gop_support_change ~ ., data = d)
Residuals:
Min 1Q Median 3Q Max
-40.268 -4.292 -0.703 3.870 52.981
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) -1.088e+01 4.161e+00 -2.614 0.00898 **
pop_change -2.693e-01 3.920e-02 -6.869 7.78e-12 ***
age_65_plus 2.066e-01 4.508e-02 4.583 4.76e-06 ***
black -1.095e-01 1.161e-02 -9.434 < 2e-16 ***
hispanic -1.526e-01 1.283e-02 -11.890 < 2e-16 ***
hs_grad 2.606e-01 3.697e-02 7.051 2.19e-12 ***
undergrad -7.169e-01 3.000e-02 -23.895 < 2e-16 ***
homeownership_rate -1.572e-04 2.509e-02 -0.006 0.99500
median_home_value -1.748e-05 3.007e-06 -5.815 6.70e-09 ***
median_income 1.541e-04 3.166e-05 4.866 1.19e-06 ***
poverty_rate 2.275e-01 4.416e-02 5.151 2.75e-07 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 7.482 on 3100 degrees of freedom
Multiple R-squared: 0.4929, Adjusted R-squared: 0.4912
F-statistic: 301.3 on 10 and 3100 DF, p-value: < 2.2e-16
On this fit, a one-percentage-point higher Hispanic population share is associated with about \(-0.15\) points of GOP-support change (holding other covariates fixed), with a conventional \(p\)-value far below \(0.001\). The Bayesian sections ask what a full posterior says about the same slope.
rstanarm::stan_glm()Useful references: stan_glm, continuous outcomes, bayesplot MCMC, Stan warnings.
rstanarm emulates familiar R model formulas but samples
with Stan. Current default priors are autoscaled Student-\(t\) / normal families (not the older fixed
normal(0, 10) / normal(0, 5) values from early
docs). Leaving priors unspecified is still a valid weakly informative
choice.
stan_glm
family: gaussian [identity]
formula: gop_support_change ~ .
observations: 3111
predictors: 11
------
Median MAD_SD
(Intercept) -10.965 4.088
pop_change -0.269 0.039
age_65_plus 0.206 0.044
black -0.109 0.012
hispanic -0.152 0.012
hs_grad 0.261 0.037
undergrad -0.716 0.030
homeownership_rate 0.001 0.025
median_home_value 0.000 0.000
median_income 0.000 0.000
poverty_rate 0.227 0.043
Auxiliary parameter(s):
Median MAD_SD
sigma 7.480 0.097
------
* For help interpreting the printed output see ?print.stanreg
* For info on the priors used see ?prior_summary.stanreg
Computed from 4000 by 3111 log-likelihood matrix.
Estimate SE
elpd_loo -10685.3 72.7
p_loo 19.0 2.5
looic 21370.6 145.4
------
MCSE of elpd_loo is 0.1.
MCSE and ESS estimates assume independent draws (r_eff=1).
All Pareto k estimates are good (k < 0.7).
See help('pareto-k-diagnostic') for details.
diag1 <- tibble(
max_rhat = max(rhat(post1), na.rm = TRUE),
min_neff_ratio = min(neff_ratio(post1), na.rm = TRUE)
)
diag1# A tibble: 1 × 2
max_rhat min_neff_ratio
<dbl> <dbl>
1 1.00 0.618
draws <- as.data.frame(as.matrix(post1))
colnames(draws)[1] <- "intercept"
ggplot(d, aes(x = hispanic, y = gop_support_change)) +
geom_point(size = 0.3, color = "#003366", alpha = 0.35) +
geom_abline(
data = draws, aes(intercept = intercept, slope = hispanic),
color = "skyblue", linewidth = 0.2, alpha = 0.05
) +
geom_abline(
intercept = coef(post1)["(Intercept)"],
slope = coef(post1)["hispanic"],
color = "skyblue4", linewidth = 1
) +
theme_minimal()mcmc_intervals() defaults to 50% (thick) and 90% (thin)
central intervals with the point at the posterior median.
slope_pars <- setdiff(names(coef(post1)), c("(Intercept)"))
mcmc_intervals(
as.matrix(post1),
pars = slope_pars,
prob = 0.5,
prob_outer = 0.9
)[1] -0.1520994
10% 50% 90%
-0.168 -0.152 -0.136
Conditional on the data and this model, the posterior for the Hispanic slope is tightly concentrated near \(-0.15\): a 99% central interval is roughly \(-0.17\) to \(-0.13\).
Age \(65+\) and poverty rate pull toward positive GOP-support change in this period; bachelor’s share pulls negative, with a narrower central mass.
The density overlay shows mild mismatch in the bulk (medians slightly off), which could be remedied e.g. by changing our priors.
rstan)Writing the likelihood and priors in Stan makes the model explicit.
Below is one centered/scaled design with Student-\(t\) priors (stan/mlr.stan).
Flat-prior versions of this regression mix poorly on modern Stan; weakly
informative priors are the pedagogical default here.
X_scaled <- scale(as.matrix(d |> select(-gop_support_change)))
stan_dat <- list(
n = nrow(d),
k = ncol(X_scaled),
X = X_scaled,
Y = d$gop_support_change
)post2 <- stan(
file = "stan/mlr.stan",
data = stan_dat,
chains = 4,
iter = 2000,
warmup = 1000,
seed = 42,
refresh = 0,
control = list(adapt_delta = 0.95)
)sum2 <- summary(post2)$summary
diag2 <- tibble(
max_rhat = max(sum2[, "Rhat"], na.rm = TRUE),
min_n_eff = min(sum2[, "n_eff"], na.rm = TRUE)
)
diag2# A tibble: 1 × 2
max_rhat min_n_eff
<dbl> <dbl>
1 1.00 1756.
beta_pars <- paste0("beta[", seq_len(stan_dat$k), "]")
color_scheme_set("yellow")
mcmc_intervals(post2, pars = beta_pars) +
scale_y_discrete(labels = colnames(X_scaled))color_scheme_set("yellow")
mcmc_areas(post2, pars = c("beta[4]", "beta[7]")) +
labs(title = "Posterior Distributions") +
scale_y_discrete(labels = c("hispanic", "homeownership_rate"))Slopes here are in scaled-predictor units, so
magnitudes are not directly comparable to the stan_glm
coefficients on the raw scale. The qualitative signs for Hispanic and
homeownership still line up.
Johnson quantile-parameterized distributions (J-QPD) and the
generalized lambda distribution (GLD) let you state priors through
quantiles rather than a named family. Helpers live in
stan/quantile_functions.stan and
R/GLD_helpers.R.
Draw \(S\) prior predictive \(R^2\) values:
\[ R_s^2=\frac{\frac{1}{N-1}\sum_{n=1}^{N}(\tilde\mu_n-\bar\mu)^2}{\tilde\sigma^2+\frac{1}{N-1}\sum_{n=1}^{N}(\tilde\mu_n-\bar\mu)^2}. \]
rstan::expose_stan_functions("stan/quantile_functions.stan")
source("R/GLD_helpers.R")
X_c <- model.matrix(gop_support_change ~ ., data = d)[, -1, drop = FALSE]
X_c <- sweep(X_c, 2, colMeans(X_c), `-`)m_alpha <- -10.9
s_alpha <- 4.1
lowers <- c(
PC = -0.0005, A = 0.0001, B = -0.0004, H = -0.0004, HS = 0.0023,
U = -0.0009, HR = -0.0003, MHV = -0.0003, MI = -0.0003, PR = -0.0004
)
medians <- c(
PC = -0.0004, A = 0.00015, B = -0.0003, H = -0.0003, HS = 0.0025,
U = -0.0008, HR = -0.0002, MHV = -0.0002, MI = -0.0002, PR = -0.0003
)
uppers <- c(
PC = -0.0003, A = 0.0002, B = -0.0002, H = -0.0002, HS = 0.0027,
U = -0.0007, HR = -0.0001, MHV = -0.0001, MI = -0.0001, PR = -0.0002
)
ninety <- c(
PC = -0.0002, A = 0.00025, B = -0.0001, H = -0.0001, HS = 0.0029,
U = -0.0006, HR = 0.00, MHV = 0.00, MI = 0.00, PR = -0.0001
)
a_s <- lapply(seq_along(medians), function(i) {
GLD_solver(lowers[i], medians[i], uppers[i], ninety[i], alpha = 0.9)
})
names(a_s) <- names(medians)
q_sigma <- c(lower = 0.25, median = 0.5, upper = 1)These quantile targets are deliberately tight and inspired by the
stan_glm posterior from the preceding chunks.
feat <- c("pop_change", "age_65_plus", "black", "hispanic", "hs_grad",
"undergrad", "homeownership_rate", "median_home_value",
"median_income", "poverty_rate")
keys <- c("PC", "A", "B", "H", "HS", "U", "HR", "MHV", "MI", "PR")
R_squared <- replicate(1000, {
alpha_ <- rnorm(1, mean = m_alpha, sd = s_alpha)
betas <- vapply(seq_along(keys), function(j) {
k <- keys[j]
GLD_rng(
medians[k],
IQR = uppers[k] - lowers[k],
asymmetry = a_s[[k]][1],
steepness = a_s[[k]][2]
)
}, numeric(1))
mu_ <- as.numeric(alpha_ + X_c[, feat, drop = FALSE] %*% betas)
sigma_ <- JQPDS_rng(lower_bound = 0, alpha = 0.25, quantiles = q_sigma)
numer <- var(mu_)
numer / (sigma_^2 + numer)
})
summary(R_squared) Min. 1st Qu. Median Mean 3rd Qu. Max.
0.1082 0.9938 0.9989 0.9796 0.9998 1.0000
stan_lm)post4 <- stan_lm(
gop_support_change ~ .,
data = d,
prior = R2(location = median(R_squared), what = "median"),
prior_intercept = normal(location = m_alpha, scale = s_alpha, autoscale = FALSE),
seed = 42,
refresh = 0
)stan_lm
family: gaussian [identity]
formula: gop_support_change ~ .
observations: 3111
predictors: 11
------
Median MAD_SD
(Intercept) -10.95 2.89
pop_change -0.27 0.04
age_65_plus 0.20 0.04
black -0.11 0.01
hispanic -0.15 0.01
hs_grad 0.26 0.03
undergrad -0.72 0.03
homeownership_rate 0.00 0.02
median_home_value 0.00 0.00
median_income 0.00 0.00
poverty_rate 0.23 0.04
Auxiliary parameter(s):
Median MAD_SD
R2 0.49 0.01
log-fit_ratio 0.00 0.01
sigma 7.47 0.10
------
* For help interpreting the printed output see ?print.stanreg
* For info on the priors used see ?prior_summary.stanreg
diag4 <- tibble(
max_rhat = max(rhat(post4), na.rm = TRUE),
min_neff_ratio = min(neff_ratio(post4), na.rm = TRUE)
)
diag4# A tibble: 1 × 2
max_rhat min_neff_ratio
<dbl> <dbl>
1 1.00 0.591
elpd_diff se_diff
post4 0.0 0.0
post1 -0.3 0.2
ppc_intervals(
y = d$gop_support_change,
yrep = posterior_predict(post4),
x = d$hispanic
) +
xlab("hispanic")m4 <- as.matrix(post4)
slope_names <- setdiff(colnames(m4), c("(Intercept)", "sigma", "R2", "log-fit_ratio"))
slope_names <- slope_names[slope_names %in% colnames(d)]
posterior_quantiles <- purrr::map_dfr(slope_names, function(nm) {
q <- quantile(m4[, nm], probs = c(0.025, 0.25, 0.5, 0.75, 0.975))
tibble(
term = nm,
q025 = unname(q[1]),
q25 = unname(q[2]),
q50 = unname(q[3]),
q75 = unname(q[4]),
q975 = unname(q[5])
)
})
posterior_quantiles# A tibble: 10 × 6
term q025 q25 q50 q75 q975
<chr> <dbl> <dbl> <dbl> <dbl> <dbl>
1 pop_change -0.344 -0.294 -0.268 -0.241 -0.190
2 age_65_plus 0.120 0.175 0.205 0.234 0.293
3 black -0.132 -0.117 -0.109 -0.102 -0.0865
4 hispanic -0.176 -0.160 -0.152 -0.144 -0.129
5 hs_grad 0.200 0.240 0.261 0.282 0.322
6 undergrad -0.774 -0.736 -0.716 -0.696 -0.660
7 homeownership_rate -0.0462 -0.0152 0.000473 0.0159 0.0472
8 median_home_value -0.0000234 -0.0000194 -0.0000174 -0.0000154 -0.0000116
9 median_income 0.0000947 0.000133 0.000153 0.000175 0.000213
10 poverty_rate 0.153 0.202 0.228 0.254 0.302
Three fits on the same county panel:
stan_glm — default weakly informative
priors, full posterior, LOO, PPC.stan_lm($R^2$) —
explicit Student-\(t\) priors on a
scaled design, then a quantile-elicited \(R^2\) prior that folds GLD / J-QPD into
rstanarm.It may not be quite trivial to “guess” the prior stuff using quantile functions. But the tradeoff is that we can dispense with pre-defined distributions — a cleverer approach, but more difficult to implement.