Week 10 Exercises

Complete solutions open Friday, October 30 at 12:00 PM

Exercise 1 Simulate and Recover; Ordered Logit

One important way to test and develop your understanding of statistical models and quantities of interest is to simulate a fake data set with known true quantity of interest (e.g., coefficients or first difference) and then recover that quantity of interest through your estimation procedure.

In Week 9, we did this for logit. See Simulate and Recover; Logit.

Do this for the ordered logit model in the notes. As your quantity of interest, use \(\Pr(y \geq 3)\) when income equals 1.5 and x2 equals "Label 1".

  1. First, simulate a fake data set with known cutpoints, coefficients, and quantity of interest. Write this code yourself; adapt your Week 9 code. Notice that the linear predictor eta has no intercept. The cutpoints alpha take its place. In the solution, I use the values below, but you can choose different values if you like (keep 1.5 among the values of income).
    • n = 10,000.
    • income: −2.5, −1.5, −0.5, 0.5, 1.5, or 2.5, equally likely.
    • x2: "Label 1" (about 75%) or "Label 2" (about 25%).
    • Cutpoints alpha: −1, 0.5, and 2 (so four categories).
    • Coefficients: 0.4 for income; 1 for x2 = "Label 2".
    • y an ordered factor, in a data frame named data.
    • Compute the true \(\Pr(y \geq 3)\) from the true values.
  2. Second, use MASS::polr() to fit an ordered logit model to the fake data. Are the true cutpoints and coefficients inside their 95% confidence intervals? (polr() calls the cutpoints “Intercepts” and labels them 1|2, 2|3, and 3|4.)
  3. Third, use predictions() to recover the true quantity of interest. Is the true value inside the 95% CI?
  4. Fourth, adapt the code to compute the true first difference in \(\Pr(y \geq 3)\) as income moves from −1.5 to 1.5 (with x2 equal to "Label 1"). Then recover it with comparisons().
  5. Fifth, refit the model with brm(family = cumulative("logit")). Recover the cutpoints, the coefficients, and the first difference. Compare with polr() and the truth.
Hint

\(\Pr(y \geq 3)\) is the sum of the probabilities of categories 3 and 4.

Complete solution

Part 1

Your code will differ. Here is one version.

# --- simulate a fake data set ---

# set seed for reproducibility
set.seed(1234)

# set sample size
n <- 10000  # needs to be large enough so that CI isn't super wide

# create explanatory variables
## numeric variable; six brackets, like income in the anes data
income <- sample(c(-2.5, -1.5, -0.5, 0.5, 1.5, 2.5), size = n, replace = TRUE)
## qualitative variable
x2 <- sample(c("Label 1", "Label 2"),
             size = n,
             replace = TRUE,
             prob = c(0.75, 0.25))  # about 75% will be "Label 1"

# create cutpoints and coefficients
alpha <- c(-1, 0.5, 2)  # J - 1 = 3 cutpoints, so J = 4 categories
b1 <- 0.4
b2 <- 1

# linear predictor; no intercept!
eta <- b1*income + b2*(x2 == "Label 2")

# cumulative probabilities Pr(y <= j), j = 1, 2, 3
cp1 <- plogis(alpha[1] - eta)
cp2 <- plogis(alpha[2] - eta)
cp3 <- plogis(alpha[3] - eta)

# category probabilities Pr(y = j), j = 1, 2, 3, 4; one row per observation
p <- cbind(cp1, cp2 - cp1, cp3 - cp2, 1 - cp3)

# simulate outcome for ordered logit model; one draw per observation
y <- numeric(n)
for (i in 1:n) {
  y[i] <- sample(1:4, size = 1, prob = p[i, ])
}

# combine into data frame; y must be an ordered factor for polr()
data <- data.frame(y = factor(y, ordered = TRUE), income, x2)

# truth: Pr(y >= 3) when income = 1.5 and x2 = "Label 1"
eta_c <- b1*1.5  # x2 = "Label 1", so the b2 term is zero
1 - plogis(alpha[2] - eta_c)
[1] 0.5249792

The true value of \(\Pr(y \geq 3)\) is about 0.525. Notice that \(\Pr(y \geq 3) = 1 - \Pr(y \leq 2) = 1 - \operatorname{logit}^{-1}(\alpha_2 - \eta)\), so we only need the second cutpoint.

Part 2

# fit ordered logit model
fit <- MASS::polr(y ~ income + x2, data = data, Hess = TRUE)

# estimates and 95% cis for the coefficients and the cutpoints
est <- c(coef(fit), fit$zeta)
se <- sqrt(diag(vcov(fit)))
cbind(truth = c(b1, b2, alpha),
      estimate = est,
      lower = est - 1.96*se,
      upper = est + 1.96*se) |>
  round(3)
          truth estimate  lower  upper
income      0.4    0.409  0.387  0.431
x2Label 2   1.0    0.923  0.839  1.006
1|2        -1.0   -1.000 -1.051 -0.950
2|3         0.5    0.511  0.464  0.558
3|4         2.0    2.018  1.956  2.080

All five true values are inside their 95% confidence intervals. The true value of b2 (i.e., 1) is only barely inside its interval (about 0.84 to 1.01); across many simulated data sets, we’d expect the interval to miss the true value about 5% of the time.

Part 3

# recover Pr(y >= 3) when income = 1.5 and x2 = "Label 1"
predictions(fit,
            newdata = datagrid(income = 1.5, x2 = "Label 1"),
            hypothesis = "b3 + b4 = 0")

 Hypothesis Estimate Std. Error    z Pr(>|z|)   S 2.5 % 97.5 %
    b3+b4=0    0.526    0.00709 74.2   <0.001 Inf 0.512   0.54

Type: probs

The estimate is about 0.526, and the 95% CI runs from about 0.512 to 0.540. The true value (about 0.525) is inside the interval, as it usually will be.

Part 4

# truth: fd in Pr(y >= 3) as income moves from -1.5 to 1.5; x2 = "Label 1"
pr_hi <- 1 - plogis(alpha[2] - b1*1.5)
pr_lo <- 1 - plogis(alpha[2] - b1*(-1.5))
pr_hi - pr_lo
[1] 0.2752393
# recover this fd
comparisons(fit,
            variables = list(income = c(-1.5, 1.5)),
            newdata = datagrid(x2 = "Label 1"),
            hypothesis = "b3 + b4 = 0")

 Hypothesis Estimate Std. Error    z Pr(>|z|)      S 2.5 % 97.5 %
    b3+b4=0     0.28    0.00746 37.6   <0.001 1025.9 0.266  0.295

Type: probs

The true first difference is about 0.275. The estimate is about 0.280, and the 95% CI runs from about 0.266 to 0.295. The true value is inside the interval.

Part 5

# fit the same ordered logit with brm()
fit_brm_ordered <- brm(y ~ income + x2,
                       data = data,
                       family = cumulative("logit"),
                       backend = "cmdstanr",
                       chains = 4,
                       cores = 4,
                       seed = 1234)
# posterior means and 95% credible intervals, next to the truth and polr()
cbind(truth = c(alpha, b1, b2),
      polr = c(fit$zeta, coef(fit)),
      fixef(fit_brm_ordered)[, c("Estimate", "Q2.5", "Q97.5")]) |>
  round(3)
          truth   polr Estimate   Q2.5  Q97.5
1|2        -1.0 -1.000   -1.000 -1.050 -0.952
2|3         0.5  0.511    0.511  0.465  0.557
3|4         2.0  2.018    2.018  1.958  2.080
income      0.4  0.409    0.409  0.387  0.432
x2Label 2   1.0  0.923    0.922  0.843  1.003
# recover the fd with the brm() fit
comparisons(fit_brm_ordered,
            variables = list(income = c(-1.5, 1.5)),
            newdata = datagrid(x2 = "Label 1"),
            hypothesis = "b3 + b4 = 0")

 Hypothesis Estimate 2.5 % 97.5 %
    b3+b4=0     0.28 0.266  0.295

Type: response

The posterior means are very close to the polr() estimates. All five true values are inside the 95% credible intervals.

The first difference is 0.28 (95% credible interval is [0.266, 0.295]). Truth (0.275) is inside the interval.

Exercise 2 Feelings Toward Donald Trump

The dataset here from the 2016 ANES has data on feeling thermometer ratings of Donald Trump and a social_group variable that indicates a race-sex-degree triplet.

anes_ft <- read_csv("https://pos5747.github.io/files/anes-ft-groups.csv") |>
  glimpse()

I’m interested in the feelings of each group toward Donald Trump. You can see that the sample averages and sample sizes vary a lot across the groups.1

library(tinytable)
anes_ft |> group_by(social_group) |>
  summarize(`Number of Respondents` = n(),
            `Sample Average` = mean(ft_donald_trump)) |>
  rename(`Social Group` = social_group) |>
  arrange(desc(`Number of Respondents`)) |>
  tt(digits = 1)
Social Group Number of Respondents Sample Average
White; Female; No College Degree 784 53
White; Male; No College Degree 675 57
White; Female; College Degree 594 34
White; Male; College Degree 544 42
Other; Female; No College Degree 213 29
Other; Male; No College Degree 213 38
Black; Female; No College Degree 142 21
Other; Male; College Degree 116 36
Black; Male; No College Degree 101 26
Other; Female; College Degree 96 27
Black; Female; College Degree 64 12
Black; Male; College Degree 28 18
White; Other; No College Degree 4 31
Other; Other; No College Degree 3 16
White; Other; College Degree 2 8

Use a hierarchical model to estimate the average for each group.

Complete solution
f <- ft_donald_trump ~ (1 | social_group)
fit_trump <- brm(f, data = anes_ft, chains = 4, cores = 4, backend = "cmdstanr", seed = 1234)
# sample size and sample average for each group
group_summaries <- anes_ft |>
  summarize(n = n(), sample_average = mean(ft_donald_trump), .by = social_group)

# hierarchical estimate for each group, next to the sample average
p_trump <- predictions(fit_trump, newdata = datagrid(social_group = unique)) |>
  as_tibble() |>
  select(social_group, estimate, conf.low, conf.high) |>
  left_join(group_summaries, by = "social_group") |>
  arrange(n)
p_trump
# A tibble: 15 × 6
   social_group                 estimate conf.low conf.high     n sample_average
   <chr>                           <dbl>    <dbl>     <dbl> <int>          <dbl>
 1 White; Other; College Degree     26.3   -0.659      50.8     2            8  
 2 Other; Other; No College De…     26.5    2.38       49.1     3           15.7
 3 White; Other; No College De…     31.6    9.36       52.4     4           31.2
 4 Black; Male; College Degree      20.3    8.48       31.8    28           17.7
 5 Black; Female; College Degr…     13.2    5.65       21.4    64           11.6
 6 Other; Female; College Degr…     27.1   20.8        33.6    96           26.7
 7 Black; Male; No College Deg…     26.8   20.8        33.1   101           26.5
 8 Other; Male; College Degree      35.8   30.0        41.7   116           36.0
 9 Black; Female; No College D…     21.8   16.6        27.2   142           21.4
10 Other; Female; No College D…     29.5   25.0        33.9   213           29.4
11 Other; Male; No College Deg…     38.3   34.0        42.6   213           38.5
12 White; Male; College Degree      42.3   39.6        45.1   544           42.5
13 White; Female; College Degr…     33.8   31.1        36.3   594           33.8
14 White; Male; No College Deg…     56.4   54.0        58.9   675           56.7
15 White; Female; No College D…     52.3   50.1        54.7   784           52.6
  • Small groups are pulled toward the overall average. “White; Other; College Degree” (n = 2) has a sample average of 8 but an estimate of 26.
  • Large groups barely move. “White; Female; No College Degree” (n = 784): 52.6 and 52.3.

Exercise 3 Red State, Blue State: Part 6

In Part 4, we fit a separate logit in each state using CES data. Now, we’ll fit one hierarchical model to all 50 states. Each state will have its own intercept, and we’ll model the 50 intercepts using a normal distribution.

This is the varying-intercept model behind Figure 3 of Gelman et al. (2007, p. 355) [pdf].

rs_state_median_income is a state-level measure of income that varies only across states. This is state median household income, rescaled to mean 0 and SD 0.5 across the 50 states.

# load data; 50 states
ces <- read_csv("https://pos5747.github.io/files/red-state-part4.csv")

# make a state-level data set
states <- ces |>
  distinct(state_name, state_abbr, state_median_income) |> 
  mutate(rs_state_median_income = arm::rescale(state_median_income)) |> 
  glimpse()

# add rescaled state income to the voters
ces <- ces |>
  left_join(select(states, state_name, rs_state_median_income), by = "state_name") |> 
  glimpse()
  1. Use brm() to fit the logit vote_rep ~ rs_state_median_income + (1 | state_name). Use family = bernoulli, backend = "cmdstanr", iter = 10000, and warmup = 2000. Check R-hat and the ESS.
  2. Use predictions() to compute Pr(Trump), with a 90% CI, for a voter in each state. Plot these 50 estimates and their CIs against state_median_income, labeled with the state abbreviations.
  3. Add a line for a typical state (i.e., a state whose intercept is exactly what state income predicts). Use predictions() with re_formula = NA over a range of state incomes.
  4. Interpret the coefficient for rs_state_median_income. What is the vertical distance between a state’s point and the line?
  5. Gelman et al. (2007) say that richer states lean Democratic. Does this model show this for 2024?
Complete solution

Part 1

fit_ces <- brm(vote_rep ~ rs_state_median_income + (1 | state_name),
               data = ces,
               family = bernoulli,
               backend = "cmdstanr",
               iter = 10000,
               warmup = 2000,
               chains = 4,
               cores = 4,
               seed = 1234)
summary(fit_ces)
 Family: bernoulli 
  Links: mu = logit 
Formula: vote_rep ~ rs_state_median_income + (1 | state_name) 
   Data: ces (Number of observations: 48582) 
  Draws: 4 chains, each with iter = 10000; warmup = 2000; thin = 1;
         total post-warmup draws = 32000

Multilevel Hyperparameters:
~state_name (Number of levels: 50) 
              Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
sd(Intercept)     0.26      0.03     0.20     0.33 1.00     4869    10000

Regression Coefficients:
                       Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS
Intercept                 -0.26      0.04    -0.33    -0.18 1.00     2892
rs_state_median_income    -0.44      0.08    -0.60    -0.29 1.00     3742
                       Tail_ESS
Intercept                  6547
rs_state_median_income     7277

Draws were sampled using sample(hmc). For each parameter, Bulk_ESS
and Tail_ESS are effective sample size measures, and Rhat is the potential
scale reduction factor on split chains (at convergence, Rhat = 1).

R-hat is 1.00; smallest bulk ESS above 2,000.

Part 2

# pr(trump) with a 90% ci in each state; one row per state
p_ces <- predictions(fit_ces, newdata = states, conf_level = 0.9) |>
  as_tibble()
gg_ces <- ggplot(p_ces, aes(x = state_median_income, y = estimate)) +
  geom_errorbar(aes(ymin = conf.low, ymax = conf.high), width = 0, color = "grey70") +
  geom_text(aes(label = state_abbr), size = 3) +
  labs(x = "State median household income ($1,000s)",
       y = "Pr(Trump)\n(90% CI)")
gg_ces

Part 3

# state incomes from the poorest state to the richest, rescaled like rs_state_median_income
# (by hand: arm::rescale() would use the grid's own mean and sd, not the states')
line_grid <- tibble(state_median_income = seq(min(states$state_median_income),
                                              max(states$state_median_income),
                                              length.out = 50)) |>
  mutate(rs_state_median_income = (state_median_income - mean(states$state_median_income)) /
           (2 * sd(states$state_median_income)))

# a typical state: every state's deviation set to zero
line_ces <- predictions(fit_ces, newdata = line_grid, re_formula = NA, conf_level = 0.9) |>
  as_tibble()
# add the line for a typical state to the plot from part 2
gg_ces +
  geom_ribbon(data = line_ces, aes(ymin = conf.low, ymax = conf.high), alpha = 0.2) +
  geom_line(data = line_ces)

Part 4

  • The slope coefficient for rs_state_median_income is −0.44. Richer states have lower Pr(Trump). This is the slope of the line (but on the logit scale; the plot is on the probability scale.)
  • Distance from the line: the part of the state’s intercept that state income doesn’t explain (again, on the probability scale, however). These distances are the “varying” part of the varying intercept.

Part 5

  • Yes. The coefficient is negative, and its 95% CI (−0.60 to −0.29) excludes zero.

Exercise 4 Red State, Blue State: Part 7

Fit the same hierarchical model from Exercise 3 using the much smaller ANES data set. Create the plot showing the state-level estimates as state income varies. Compare the results to the larger CES dataset.

# load data; add rescaled state income from part 6
anes <- read_csv("https://pos5747.github.io/files/red-state-part5.csv") |>
  left_join(select(states, state_name, rs_state_median_income), by = "state_name")
Complete solution
fit_anes <- brm(vote_rep ~ rs_state_median_income + (1 | state_name),
                data = anes,
                family = bernoulli,
                backend = "cmdstanr",
                iter = 10000,
                warmup = 2000,
                chains = 4,
                cores = 4,
                seed = 1234)
summary(fit_anes)
 Family: bernoulli 
  Links: mu = logit 
Formula: vote_rep ~ rs_state_median_income + (1 | state_name) 
   Data: anes (Number of observations: 2445) 
  Draws: 4 chains, each with iter = 10000; warmup = 2000; thin = 1;
         total post-warmup draws = 32000

Multilevel Hyperparameters:
~state_name (Number of levels: 50) 
              Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
sd(Intercept)     0.23      0.07     0.09     0.37 1.00    11147    12201

Regression Coefficients:
                       Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS
Intercept                 -0.18      0.06    -0.29    -0.06 1.00    27276
rs_state_median_income    -0.61      0.12    -0.85    -0.37 1.00    28790
                       Tail_ESS
Intercept                 23063
rs_state_median_income    23136

Draws were sampled using sample(hmc). For each parameter, Bulk_ESS
and Tail_ESS are effective sample size measures, and Rhat is the potential
scale reduction factor on split chains (at convergence, Rhat = 1).
# pr(trump) with a 90% ci in each state, and the line; same grids as part 6
p_anes <- predictions(fit_anes, newdata = states, conf_level = 0.9) |>
  as_tibble()
line_anes <- predictions(fit_anes, newdata = line_grid, re_formula = NA, conf_level = 0.9) |>
  as_tibble()
# stack the two surveys
p_both <- bind_rows(list(CES = p_ces, ANES = p_anes), .id = "survey") |>
  mutate(survey = factor(survey, levels = c("CES", "ANES")))
line_both <- bind_rows(list(CES = line_ces, ANES = line_anes), .id = "survey") |>
  mutate(survey = factor(survey, levels = c("CES", "ANES")))

ggplot(p_both, aes(x = state_median_income, y = estimate)) +
  geom_ribbon(data = line_both, aes(ymin = conf.low, ymax = conf.high), alpha = 0.2) +
  geom_line(data = line_both) +
  geom_errorbar(aes(ymin = conf.low, ymax = conf.high), width = 0, color = "grey70") +
  geom_text(aes(label = state_abbr), size = 2.5) +
  facet_wrap(vars(survey)) +
  labs(x = "State median household income ($1,000s)",
       y = "Pr(Trump)\n(90% CI)")

# the coefficient in each survey, with 90% intervals
coefs <- "b_rs_state_median_income"
posterior_summary(fit_ces, variable = coefs, probs = c(0.05, 0.95)) |> round(3)
                         Estimate Est.Error     Q5    Q95
b_rs_state_median_income    -0.44     0.078 -0.569 -0.313
posterior_summary(fit_anes, variable = coefs, probs = c(0.05, 0.95)) |> round(3)
                         Estimate Est.Error     Q5    Q95
b_rs_state_median_income   -0.614     0.122 -0.811 -0.411
# median width of the 90% cis
tibble(survey = c("CES", "ANES"),
       median_width = c(median(p_ces$conf.high - p_ces$conf.low),
                        median(p_anes$conf.high - p_anes$conf.low)))
# A tibble: 2 × 2
  survey median_width
  <chr>         <dbl>
1 CES          0.0585
2 ANES         0.150 
  • The surveys agree on the overall pattern. Richer states, lower Pr(Trump). Slope: −0.61 (ANES) and −0.44 (CES), with overlapping 90% intervals.
  • ANES CIs are about 2.5 times as wide (median 0.150 vs. 0.059).
  • ANES points sit close to the line. About 50 respondents per state on average, so each state’s intercept is pulled most of the way to what state income predicts. For the CES, which has about 1,000 per state on average, the points are more scattered.

Footnotes

  1. Three of the possible groups have zero observations.↩︎