IRT

Here are the packages we’re using:

# generally useful packages
library(tidyverse)
library(marginaleffects)
library(modelsummary)
library(patchwork)

# for fitting hierarchical models
library(lme4)
library(brms)  # see also rstanarm::stan_lmer()
library(cmdstanr)

# for post-processing simulations
library(tidybayes)

# preferred order (most preferred first):
library(conflicted)
conflict_prefer_all("dplyr", quiet = TRUE)
conflict_prefer_all("tidyr", quiet = TRUE)
conflict_prefer_all("tidybayes", quiet = TRUE)
conflict_prefer_all("brms", quiet = TRUE)
conflict_prefer_all("patchwork", quiet = TRUE)

IRT

This week, I introduce the idea of an IRT model.

This is just one specific type of hierarchical model.

The answers data

The answers dataset has answers from hypothetical students to real questions from a recent exam.

  • question_id_num indexes the 55 questions on the exam.
  • student_id_num indexes the 100 students who took the exam.
  • correct_answer indicates (0/1) where the student answered the question correctly.
answers <- read_csv("https://pos5747.github.io/data/example-answers.csv") |>
  glimpse()
Rows: 5,500
Columns: 3
$ student_id_num  <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
$ question_id_num <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 1…
$ correct_answer  <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1…

Grades on the exam

grades <- answers %>%
  group_by(student_id_num) %>%
  summarize(proportion_correct = mean(correct_answer)) %>%
  mutate(
    letter_grade = cut(
      proportion_correct,
      breaks = c(-Inf, 0.60, 0.63, 0.67, 0.70, 0.73, 0.77, 0.80, 0.83, 0.87, 0.90, 0.93, Inf),
      right = FALSE,  # 0.60 is a D-, not an F
      labels = c("F",  "D-", "D",  "D+", "C-", "C",  "C+", "B-", "B",  "B+", "A-", "A"),
      ordered_result = TRUE
    )
  )

grades |> filter(student_id_num <= 10)
# A tibble: 10 × 3
   student_id_num proportion_correct letter_grade
            <dbl>              <dbl> <ord>       
 1              1              0.909 A-          
 2              2              0.527 F           
 3              3              0.764 C           
 4              4              0.727 C-          
 5              5              0.964 A           
 6              6              0.818 B-          
 7              7              0.818 B-          
 8              8              0.782 C+          
 9              9              0.564 F           
10             10              0.8   B-          

Rasch / 1-PL model

Indices

  • Answers: \(n = 1, \dots, N\). (\(N = 55 \times 100 = 5{,}500\) in our data.)
  • Students: \(j = 1, \dots, J\). (\(J = 100\) in our data.)
  • Questions: \(k = 1, \dots, K\). (\(K = 55\) in our data.)

Outcome: \(y_{n} \in \{0, 1\}\) indicates whether the \(n\)th answer is correct.

\[ y_n \sim \text{Bernoulli}(\pi_n) \\ \pi_n = \operatorname{logit}^{-1} \left( \mu + \alpha_{j[n]} - \beta_{k[n]} \right). \]

Parameters

  • \(\pi_n\): the probability that answer \(n\) is correct
  • \(\alpha_{j[n]}\): the ability of student \(j\) [who is giving the \(n\)th answer].
    • Higher \(\alpha_j\) ⇒ more likely to answer correctly; more knowledgeable student.
  • \(\beta_{k[n]}\): the difficulty of question \(k\) [which is associated with the \(n\)th answer].
    • Higher \(\beta_k\) ⇒ less likely to answer correctly; harder item.
  • \(\mu\): the log-odds of a correct answer for an average student on an average question

Hierarchical Model

\[ \alpha_j \sim N(0,\sigma_\alpha^2)\\ \beta_k \sim N(0,\sigma_\beta^2) \]

Priors

\[ \sigma_\alpha \sim \text{half-Cauchy}(0,1)\\ \sigma_\beta \sim \text{half-Cauchy}(0,1) \]

\[ \mu \sim \text{Cauchy}(0,1). \]

Stan model

data {
  int<lower=1> N;  // number of answers
  int<lower=1> J;  // number of students
  int<lower=1> K;  // number of questions
  array[N] int<lower=1, upper=J> jj;  // student index for answer n
  array[N] int<lower=1, upper=K> kk;  // question index for answer n
  array[N] int<lower=0, upper=1> y;  // answer n is correct (0/1)
}

parameters {
  real mu;  // log-odds of a correct answer, average student and question
  vector[J] alpha;  // abilities; one per student
  vector[K] beta;  // difficulties; one per question
  real<lower=0> sigma_beta;  // SD of difficulties
  real<lower=0> sigma_alpha;  // SD of abilities
}

model {
  sigma_beta ~ cauchy(0, 1);  // prior for SD of difficulties
  sigma_alpha ~ cauchy(0, 1);  // prior for SD of abilities
  mu ~ cauchy(0, 1);  // prior for mu
  alpha ~ normal(0, sigma_alpha);  // abilities
  beta ~ normal(0, sigma_beta);  // difficulties
  for (n in 1:N) {
    y[n] ~ bernoulli_logit(mu + alpha[jj[n]] - beta[kk[n]]);  // likelihood
  }
}

Set up data for Stan

# make data for stan
data_list <- list(
  # upper bounds of indices
  N = nrow(answers),                 # - n in {1,...,N} indexes answers
  J = max(answers$student_id_num),   # - j in {1,...,J} indexes students
  K = max(answers$question_id_num),  # - k in {1,...,K} indexes questions
  # indices for each respondent
  jj = answers$student_id_num,   # jj is a vector of student indices for each answer
  kk = answers$question_id_num,  # kk is a vector of question indices for each answer
  # observed answers
  y = answers$correct_answer
)

jj and kk are subtle!

  • Because all students answer all questions, we have \(N = 100 \times 55 = 5,500\) answers.
  • jj and kk are both length-\(N\) vectors.
  • jj[n] tells us the student who gave the \(n\)th answer.
  • kk[n] tells us the question that goes with the \(n\)th answer.

What do these data look like?

n <- 1:5500
look_at_n <- c(1, 10, 55, 100, 1000, 2000, 5500) # answers to look at
cbind("n" = n, "jj" = data_list$jj, "kk" = data_list$kk, "y" = data_list$y)[look_at_n, ]
        n  jj kk y
[1,]    1   1  1 1
[2,]   10   1 10 1
[3,]   55   1 55 1
[4,]  100   2 45 1
[5,] 1000  19 10 0
[6,] 2000  37 20 1
[7,] 5500 100 55 0

A trick

I like to create a character string that has the Stan model, so that it’s right in my R script. (Or you can create a file.)

stan_code <- "
data {
  int<lower=1> N;  // number of answers
  int<lower=1> J;  // number of students
  int<lower=1> K;  // number of questions
  array[N] int<lower=1, upper=J> jj;  // student index for answer n
  array[N] int<lower=1, upper=K> kk;  // question index for answer n
  array[N] int<lower=0, upper=1> y;  // answer n is correct (0/1)
}

parameters {
  real mu;  // log-odds of a correct answer, average student and question
  vector[J] alpha;  // abilities; one per student
  vector[K] beta;  // difficulties; one per question
  real<lower=0> sigma_beta;  // SD of difficulties
  real<lower=0> sigma_alpha;  // SD of abilities
}

model {
  sigma_beta ~ cauchy(0, 1);  // prior for SD of difficulties
  sigma_alpha ~ cauchy(0, 1);  // prior for SD of abilities
  mu ~ cauchy(0, 1);  // prior for mu
  alpha ~ normal(0, sigma_alpha);  // abilities
  beta ~ normal(0, sigma_beta);  // difficulties
  for (n in 1:N) {
    y[n] ~ bernoulli_logit(mu + alpha[jj[n]] - beta[kk[n]]);  // likelihood
  }
}
"

Fit Stan model using {cmdstanr}

start_time <- Sys.time()

mod <- cmdstan_model(write_stan_file(stan_code))
fit <- mod$sample(data = data_list,
                  iter_warmup = 500,
                  iter_sampling = 500,
                  chains = 10,
                  parallel_chains = 10,
                  seed = 1234)
end_time <- Sys.time()
invisible(fit$draws())  # read the draws into memory so the cached fit keeps them

Using spread_draws() from {tidybayes}

fit %>%
  spread_draws(alpha[student_id_num])
# A tibble: 500,000 × 5
# Groups:   student_id_num [100]
   student_id_num alpha .chain .iteration .draw
            <int> <dbl>  <int>      <int> <int>
 1              1 1.31       1          1     1
 2              1 1.14       1          2     2
 3              1 1.49       1          3     3
 4              1 0.859      1          4     4
 5              1 1.03       1          5     5
 6              1 1.19       1          6     6
 7              1 0.452      1          7     7
 8              1 0.835      1          8     8
 9              1 1.02       1          9     9
10              1 0.682      1         10    10
# ℹ 499,990 more rows

Adding median_qi() from {tidybayes}

fit %>%
  spread_draws(alpha[student_id_num]) %>%
  median_qi()  # works like group_by() |> summarize()
# A tibble: 100 × 7
   student_id_num   alpha .lower .upper .width .point .interval
            <int>   <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>    
 1              1  0.967   0.187  1.90    0.95 median qi       
 2              2 -1.14   -1.68  -0.553   0.95 median qi       
 3              3 -0.0432 -0.660  0.652   0.95 median qi       
 4              4 -0.239  -0.830  0.428   0.95 median qi       
 5              5  1.57    0.638  2.66    0.95 median qi       
 6              6  0.277  -0.372  1.01    0.95 median qi       
 7              7  0.273  -0.395  1.00    0.95 median qi       
 8              8  0.0558 -0.563  0.723   0.95 median qi       
 9              9 -0.982  -1.57  -0.388   0.95 median qi       
10             10  0.163  -0.470  0.885   0.95 median qi       
# ℹ 90 more rows

Plot the abilities for 25 random students

Code
set.seed(1234)
fit %>%
  spread_draws(alpha[student_id_num], ndraws = 1000) %>%
  median_qi() %>%
  left_join(grades) %>%
  slice_sample(n = 25) %>%
  ggplot(aes(x = alpha, xmin = .lower, xmax = .upper,
             y = reorder(student_id_num, alpha),
             color = letter_grade)) +
  #geom_label(aes(label = scales::percent(proportion_correct, accuracy = 1)), vjust = -0.3) +
  geom_pointinterval(linewidth = 0.5, size = 0.5) +
  labs(y = "random sample of 25 students",
       color = "letter grade") +
  theme(axis.text.y = element_text(size = 12))

Using spread_draws() from {tidybayes}

spread_draws(fit, beta[question_id_num])
# A tibble: 275,000 × 5
# Groups:   question_id_num [55]
   question_id_num   beta .chain .iteration .draw
             <int>  <dbl>  <int>      <int> <int>
 1               1 -0.145      1          1     1
 2               1 -0.838      1          2     2
 3               1 -0.793      1          3     3
 4               1 -0.128      1          4     4
 5               1 -0.994      1          5     5
 6               1 -0.557      1          6     6
 7               1 -1.03       1          7     7
 8               1 -0.795      1          8     8
 9               1 -0.420      1          9     9
10               1 -0.658      1         10    10
# ℹ 274,990 more rows

Adding median_qi() from {tidybayes}

spread_draws(fit, beta[question_id_num]) |>
  median_qi()
# A tibble: 55 × 7
   question_id_num   beta  .lower .upper .width .point .interval
             <int>  <dbl>   <dbl>  <dbl>  <dbl> <chr>  <chr>    
 1               1 -0.652 -1.24   -0.108   0.95 median qi       
 2               2 -0.898 -1.53   -0.316   0.95 median qi       
 3               3 -0.140 -0.656   0.355   0.95 median qi       
 4               4 -0.255 -0.802   0.253   0.95 median qi       
 5               5 -0.135 -0.654   0.368   0.95 median qi       
 6               6  0.141 -0.351   0.627   0.95 median qi       
 7               7  0.493  0.0235  0.948   0.95 median qi       
 8               8 -0.808 -1.41   -0.273   0.95 median qi       
 9               9 -0.732 -1.31   -0.187   0.95 median qi       
10              10  0.451 -0.0280  0.909   0.95 median qi       
# ℹ 45 more rows

Now sort the questions

spread_draws(fit, beta[question_id_num]) |>
  median_qi() |>
  arrange(desc(beta))
# A tibble: 55 × 7
   question_id_num  beta .lower .upper .width .point .interval
             <int> <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>    
 1              27 1.57   1.12    2.02   0.95 median qi       
 2              28 1.52   1.08    1.98   0.95 median qi       
 3              13 0.995  0.546   1.45   0.95 median qi       
 4              49 0.995  0.549   1.45   0.95 median qi       
 5              43 0.908  0.451   1.37   0.95 median qi       
 6              46 0.865  0.403   1.31   0.95 median qi       
 7              52 0.771  0.321   1.24   0.95 median qi       
 8              35 0.726  0.291   1.18   0.95 median qi       
 9              53 0.682  0.224   1.14   0.95 median qi       
10              44 0.584  0.127   1.05   0.95 median qi       
# ℹ 45 more rows

Plot the difficulties

Code
fit %>%
  spread_draws(beta[question_id_num]) %>%
  median_qi() %>%
  ggplot(aes(y = beta, ymin = .lower, ymax = .upper,
             x = reorder(question_id_num, beta))) +
  geom_pointinterval(linewidth = 0.5, size = 0.5) +
  labs(x = "question") +
  theme(axis.text.x = element_text(size = 10))

Fitting with brm()

f <- correct_answer ~ (1 | question_id_num) + (1 | student_id_num)
brm_fit <- brm(f, data = answers, family = bernoulli, chains = 10, cores = 10,
               iter = 1000, warmup = 500, backend = "cmdstanr")

The list of output from brm() includes a fit that is just the output from $sample() above. We can use this element if we want, but the parameters have less nice names.

print(brm_fit$fit)
Inference for Stan model: model_636ac9730bf7a0d98d68e8af455f14b9-202611030835-1-5c1928.
10 chains, each with iter=1000; warmup=500; thin=1; 
post-warmup draws per chain=500, total post-warmup draws=5000.

                                    mean se_mean    sd     2.5%      25%      50%
b_Intercept                         1.35    0.01  0.14     1.07     1.25     1.35
sd_question_id_num__Intercept       0.72    0.00  0.08     0.58     0.66     0.71
sd_student_id_num__Intercept        0.98    0.00  0.09     0.82     0.92     0.98
Intercept                           1.35    0.01  0.14     1.07     1.25     1.35
r_question_id_num[1,Intercept]      0.67    0.00  0.29     0.13     0.47     0.66
r_question_id_num[2,Intercept]      0.90    0.00  0.30     0.34     0.70     0.89
r_question_id_num[3,Intercept]      0.14    0.01  0.26    -0.34    -0.04     0.14
r_question_id_num[4,Intercept]      0.26    0.00  0.26    -0.25     0.09     0.26
r_question_id_num[5,Intercept]      0.14    0.01  0.26    -0.37    -0.04     0.14
r_question_id_num[6,Intercept]     -0.14    0.00  0.25    -0.62    -0.31    -0.15
r_question_id_num[7,Intercept]     -0.49    0.00  0.23    -0.95    -0.65    -0.50
r_question_id_num[8,Intercept]      0.82    0.00  0.31     0.25     0.61     0.81
r_question_id_num[9,Intercept]      0.74    0.00  0.29     0.17     0.54     0.73
r_question_id_num[10,Intercept]    -0.45    0.00  0.24    -0.90    -0.61    -0.45
r_question_id_num[11,Intercept]     0.32    0.00  0.27    -0.19     0.14     0.31
r_question_id_num[12,Intercept]    -0.20    0.00  0.25    -0.69    -0.37    -0.20
r_question_id_num[13,Intercept]    -1.00    0.00  0.23    -1.47    -1.16    -1.00
r_question_id_num[14,Intercept]     0.20    0.00  0.27    -0.31     0.01     0.19
r_question_id_num[15,Intercept]    -0.44    0.00  0.24    -0.90    -0.60    -0.45
r_question_id_num[16,Intercept]     0.52    0.00  0.27    -0.01     0.33     0.51
r_question_id_num[17,Intercept]     0.82    0.00  0.30     0.24     0.62     0.82
r_question_id_num[18,Intercept]     0.25    0.00  0.26    -0.26     0.07     0.25
r_question_id_num[19,Intercept]     0.66    0.00  0.29     0.11     0.46     0.66
r_question_id_num[20,Intercept]    -0.45    0.00  0.24    -0.90    -0.62    -0.45
r_question_id_num[21,Intercept]     0.59    0.00  0.28     0.05     0.40     0.58
r_question_id_num[22,Intercept]    -0.09    0.00  0.25    -0.57    -0.25    -0.09
r_question_id_num[23,Intercept]     0.60    0.00  0.28     0.06     0.40     0.59
r_question_id_num[24,Intercept]    -0.30    0.00  0.25    -0.78    -0.47    -0.30
r_question_id_num[25,Intercept]     0.08    0.00  0.25    -0.41    -0.09     0.08
r_question_id_num[26,Intercept]    -0.35    0.00  0.24    -0.83    -0.51    -0.35
r_question_id_num[27,Intercept]    -1.58    0.00  0.23    -2.04    -1.74    -1.58
r_question_id_num[28,Intercept]    -1.53    0.00  0.23    -1.99    -1.69    -1.53
r_question_id_num[29,Intercept]    -0.25    0.00  0.25    -0.73    -0.41    -0.25
r_question_id_num[30,Intercept]     1.07    0.00  0.32     0.46     0.85     1.06
r_question_id_num[31,Intercept]    -0.09    0.00  0.25    -0.57    -0.25    -0.09
r_question_id_num[32,Intercept]     0.90    0.01  0.30     0.34     0.69     0.89
r_question_id_num[33,Intercept]     1.17    0.01  0.33     0.57     0.95     1.16
r_question_id_num[34,Intercept]     0.91    0.00  0.31     0.34     0.69     0.90
r_question_id_num[35,Intercept]    -0.73    0.00  0.23    -1.20    -0.89    -0.74
r_question_id_num[36,Intercept]    -0.19    0.00  0.24    -0.66    -0.35    -0.20
r_question_id_num[37,Intercept]     0.45    0.00  0.27    -0.07     0.27     0.45
r_question_id_num[38,Intercept]    -0.14    0.00  0.24    -0.62    -0.31    -0.15
r_question_id_num[39,Intercept]    -0.45    0.00  0.24    -0.91    -0.61    -0.45
r_question_id_num[40,Intercept]     0.91    0.00  0.31     0.33     0.69     0.90
r_question_id_num[41,Intercept]     0.39    0.00  0.27    -0.14     0.20     0.39
r_question_id_num[42,Intercept]    -0.50    0.00  0.23    -0.97    -0.66    -0.51
r_question_id_num[43,Intercept]    -0.92    0.00  0.23    -1.36    -1.07    -0.91
r_question_id_num[44,Intercept]    -0.59    0.00  0.24    -1.06    -0.75    -0.59
r_question_id_num[45,Intercept]     0.90    0.00  0.30     0.34     0.69     0.90
r_question_id_num[46,Intercept]    -0.87    0.00  0.24    -1.33    -1.03    -0.87
r_question_id_num[47,Intercept]     0.20    0.00  0.26    -0.29     0.02     0.19
r_question_id_num[48,Intercept]    -0.09    0.00  0.26    -0.59    -0.26    -0.09
r_question_id_num[49,Intercept]    -1.00    0.00  0.23    -1.45    -1.16    -1.01
r_question_id_num[50,Intercept]     0.26    0.00  0.27    -0.25     0.08     0.25
r_question_id_num[51,Intercept]    -0.14    0.00  0.25    -0.62    -0.32    -0.14
r_question_id_num[52,Intercept]    -0.78    0.00  0.24    -1.24    -0.94    -0.78
r_question_id_num[53,Intercept]    -0.69    0.00  0.23    -1.14    -0.85    -0.69
r_question_id_num[54,Intercept]     0.02    0.00  0.25    -0.47    -0.15     0.02
r_question_id_num[55,Intercept]     0.08    0.00  0.26    -0.41    -0.10     0.07
r_student_id_num[1,Intercept]       0.98    0.01  0.41     0.22     0.69     0.96
r_student_id_num[2,Intercept]      -1.14    0.01  0.29    -1.72    -1.33    -1.14
r_student_id_num[3,Intercept]      -0.03    0.01  0.34    -0.68    -0.26    -0.04
r_student_id_num[4,Intercept]      -0.23    0.01  0.33    -0.87    -0.46    -0.24
r_student_id_num[5,Intercept]       1.59    0.01  0.51     0.66     1.25     1.57
r_student_id_num[6,Intercept]       0.29    0.01  0.36    -0.40     0.04     0.28
r_student_id_num[7,Intercept]       0.29    0.01  0.35    -0.36     0.05     0.29
r_student_id_num[8,Intercept]       0.06    0.01  0.34    -0.58    -0.18     0.06
r_student_id_num[9,Intercept]      -0.98    0.01  0.30    -1.57    -1.19    -0.99
r_student_id_num[10,Intercept]      0.17    0.01  0.35    -0.48    -0.07     0.17
r_student_id_num[11,Intercept]      0.53    0.01  0.37    -0.16     0.27     0.52
r_student_id_num[12,Intercept]     -0.75    0.01  0.30    -1.31    -0.95    -0.75
r_student_id_num[13,Intercept]     -0.14    0.01  0.33    -0.75    -0.36    -0.15
r_student_id_num[14,Intercept]      1.59    0.01  0.50     0.70     1.23     1.56
r_student_id_num[15,Intercept]      0.17    0.01  0.35    -0.50    -0.06     0.17
r_student_id_num[16,Intercept]     -0.41    0.01  0.30    -0.98    -0.62    -0.41
r_student_id_num[17,Intercept]     -0.75    0.01  0.30    -1.34    -0.95    -0.75
r_student_id_num[18,Intercept]      1.60    0.01  0.51     0.67     1.25     1.58
r_student_id_num[19,Intercept]      1.16    0.01  0.45     0.34     0.85     1.15
r_student_id_num[20,Intercept]      0.53    0.01  0.38    -0.20     0.28     0.52
r_student_id_num[21,Intercept]     -0.14    0.01  0.33    -0.77    -0.36    -0.14
r_student_id_num[22,Intercept]     -2.06    0.00  0.30    -2.67    -2.26    -2.05
r_student_id_num[23,Intercept]     -0.67    0.01  0.30    -1.26    -0.87    -0.67
r_student_id_num[24,Intercept]     -0.74    0.01  0.31    -1.34    -0.95    -0.75
r_student_id_num[25,Intercept]      0.18    0.01  0.35    -0.47    -0.06     0.16
r_student_id_num[26,Intercept]      2.16    0.01  0.60     1.08     1.73     2.12
r_student_id_num[27,Intercept]     -0.58    0.00  0.31    -1.18    -0.80    -0.59
r_student_id_num[28,Intercept]      0.98    0.01  0.41     0.20     0.70     0.96
r_student_id_num[29,Intercept]      1.37    0.01  0.48     0.49     1.03     1.35
r_student_id_num[30,Intercept]      0.98    0.01  0.42     0.20     0.69     0.97
r_student_id_num[31,Intercept]      0.82    0.01  0.40     0.07     0.54     0.80
r_student_id_num[32,Intercept]      1.58    0.01  0.51     0.64     1.21     1.56
r_student_id_num[33,Intercept]      0.99    0.01  0.42     0.19     0.71     0.97
r_student_id_num[34,Intercept]      0.81    0.01  0.40     0.05     0.53     0.79
r_student_id_num[35,Intercept]      1.36    0.01  0.47     0.50     1.04     1.35
r_student_id_num[36,Intercept]     -0.42    0.01  0.32    -1.02    -0.64    -0.42
r_student_id_num[37,Intercept]     -0.50    0.01  0.31    -1.09    -0.72    -0.50
r_student_id_num[38,Intercept]     -0.90    0.01  0.30    -1.47    -1.11    -0.91
r_student_id_num[39,Intercept]      0.54    0.01  0.38    -0.18     0.28     0.52
r_student_id_num[40,Intercept]     -0.50    0.01  0.31    -1.10    -0.70    -0.50
r_student_id_num[41,Intercept]     -1.36    0.01  0.29    -1.92    -1.57    -1.35
r_student_id_num[42,Intercept]      0.98    0.01  0.43     0.17     0.68     0.96
r_student_id_num[43,Intercept]     -0.42    0.01  0.31    -1.01    -0.62    -0.42
r_student_id_num[44,Intercept]      0.98    0.01  0.42     0.21     0.69     0.97
r_student_id_num[45,Intercept]      0.98    0.01  0.43     0.19     0.68     0.97
r_student_id_num[46,Intercept]      0.41    0.01  0.37    -0.26     0.15     0.40
r_student_id_num[47,Intercept]      0.41    0.01  0.37    -0.28     0.15     0.40
r_student_id_num[48,Intercept]     -0.05    0.01  0.33    -0.68    -0.27    -0.04
r_student_id_num[49,Intercept]     -1.06    0.01  0.29    -1.63    -1.26    -1.06
r_student_id_num[50,Intercept]     -1.22    0.01  0.29    -1.78    -1.41    -1.21
r_student_id_num[51,Intercept]     -0.23    0.01  0.33    -0.89    -0.45    -0.24
r_student_id_num[52,Intercept]     -0.03    0.01  0.33    -0.66    -0.26    -0.04
r_student_id_num[53,Intercept]     -1.14    0.01  0.30    -1.70    -1.34    -1.14
r_student_id_num[54,Intercept]     -0.67    0.01  0.30    -1.26    -0.87    -0.67
r_student_id_num[55,Intercept]      0.41    0.01  0.36    -0.27     0.16     0.39
r_student_id_num[56,Intercept]      0.28    0.01  0.36    -0.40     0.04     0.27
r_student_id_num[57,Intercept]      0.53    0.01  0.37    -0.16     0.29     0.52
r_student_id_num[58,Intercept]     -1.90    0.01  0.30    -2.49    -2.10    -1.90
r_student_id_num[59,Intercept]      0.82    0.01  0.41     0.06     0.54     0.80
r_student_id_num[60,Intercept]     -0.13    0.01  0.33    -0.78    -0.36    -0.14
r_student_id_num[61,Intercept]     -0.58    0.01  0.31    -1.17    -0.79    -0.59
r_student_id_num[62,Intercept]     -0.40    0.01  0.31    -1.00    -0.62    -0.40
r_student_id_num[63,Intercept]     -0.23    0.01  0.32    -0.85    -0.45    -0.24
r_student_id_num[64,Intercept]      0.18    0.01  0.34    -0.47    -0.06     0.17
r_student_id_num[65,Intercept]      1.61    0.01  0.52     0.69     1.24     1.58
r_student_id_num[66,Intercept]     -0.23    0.01  0.32    -0.85    -0.45    -0.23
r_student_id_num[67,Intercept]     -2.41    0.00  0.32    -3.03    -2.62    -2.41
r_student_id_num[68,Intercept]     -1.06    0.01  0.29    -1.62    -1.26    -1.06
r_student_id_num[69,Intercept]     -1.14    0.01  0.29    -1.70    -1.34    -1.14
r_student_id_num[70,Intercept]     -1.14    0.01  0.29    -1.68    -1.33    -1.14
r_student_id_num[71,Intercept]      0.41    0.01  0.36    -0.25     0.16     0.39
r_student_id_num[72,Intercept]     -0.33    0.01  0.31    -0.94    -0.54    -0.33
r_student_id_num[73,Intercept]      0.99    0.01  0.43     0.18     0.69     0.97
r_student_id_num[74,Intercept]     -0.41    0.01  0.32    -1.04    -0.62    -0.42
r_student_id_num[75,Intercept]     -0.23    0.01  0.32    -0.84    -0.45    -0.24
r_student_id_num[76,Intercept]     -0.24    0.01  0.32    -0.84    -0.46    -0.24
r_student_id_num[77,Intercept]      1.37    0.01  0.48     0.50     1.04     1.34
r_student_id_num[78,Intercept]     -1.29    0.01  0.29    -1.86    -1.48    -1.29
r_student_id_num[79,Intercept]      0.17    0.01  0.35    -0.49    -0.08     0.16
r_student_id_num[80,Intercept]     -0.24    0.01  0.31    -0.87    -0.44    -0.24
r_student_id_num[81,Intercept]     -0.23    0.01  0.32    -0.83    -0.45    -0.23
r_student_id_num[82,Intercept]      0.68    0.01  0.39    -0.07     0.40     0.66
r_student_id_num[83,Intercept]     -0.58    0.01  0.31    -1.18    -0.78    -0.57
r_student_id_num[84,Intercept]     -0.91    0.01  0.30    -1.49    -1.11    -0.91
r_student_id_num[85,Intercept]     -0.82    0.00  0.30    -1.41    -1.04    -0.83
r_student_id_num[86,Intercept]     -0.50    0.01  0.31    -1.10    -0.71    -0.51
r_student_id_num[87,Intercept]      1.36    0.01  0.47     0.49     1.03     1.34
r_student_id_num[88,Intercept]      0.28    0.01  0.35    -0.40     0.04     0.28
r_student_id_num[89,Intercept]      0.06    0.01  0.34    -0.59    -0.17     0.05
r_student_id_num[90,Intercept]      0.40    0.01  0.36    -0.28     0.15     0.39
r_student_id_num[91,Intercept]      1.85    0.01  0.56     0.85     1.47     1.82
r_student_id_num[92,Intercept]     -0.75    0.01  0.30    -1.32    -0.96    -0.75
r_student_id_num[93,Intercept]      0.53    0.01  0.37    -0.17     0.27     0.52
r_student_id_num[94,Intercept]     -0.04    0.01  0.33    -0.65    -0.27    -0.05
r_student_id_num[95,Intercept]     -0.22    0.01  0.32    -0.84    -0.44    -0.23
r_student_id_num[96,Intercept]     -1.67    0.00  0.29    -2.25    -1.87    -1.67
r_student_id_num[97,Intercept]     -0.67    0.01  0.30    -1.25    -0.87    -0.67
r_student_id_num[98,Intercept]     -0.14    0.01  0.33    -0.76    -0.36    -0.14
r_student_id_num[99,Intercept]      0.06    0.01  0.34    -0.59    -0.17     0.05
r_student_id_num[100,Intercept]    -0.23    0.01  0.32    -0.85    -0.45    -0.23
lprior                             -4.71    0.00  0.04    -4.80    -4.74    -4.70
lp__                            -2822.19    0.45 12.85 -2848.06 -2830.77 -2821.63
                                     75%    97.5% n_eff Rhat
b_Intercept                         1.44     1.63   581 1.02
sd_question_id_num__Intercept       0.77     0.90   968 1.01
sd_student_id_num__Intercept        1.04     1.16   911 1.01
Intercept                           1.44     1.63   581 1.02
r_question_id_num[1,Intercept]      0.86     1.26  3827 1.00
r_question_id_num[2,Intercept]      1.10     1.52  4262 1.00
r_question_id_num[3,Intercept]      0.31     0.66  2547 1.00
r_question_id_num[4,Intercept]      0.45     0.78  2811 1.00
r_question_id_num[5,Intercept]      0.32     0.66  2741 1.00
r_question_id_num[6,Intercept]      0.03     0.35  3120 1.00
r_question_id_num[7,Intercept]     -0.33    -0.03  2883 1.00
r_question_id_num[8,Intercept]      1.02     1.46  3917 1.00
r_question_id_num[9,Intercept]      0.93     1.32  3896 1.00
r_question_id_num[10,Intercept]    -0.29     0.02  2898 1.00
r_question_id_num[11,Intercept]     0.50     0.85  3572 1.00
r_question_id_num[12,Intercept]    -0.03     0.29  3039 1.00
r_question_id_num[13,Intercept]    -0.85    -0.55  3071 1.00
r_question_id_num[14,Intercept]     0.38     0.74  3534 1.00
r_question_id_num[15,Intercept]    -0.28     0.02  2960 1.00
r_question_id_num[16,Intercept]     0.70     1.06  3761 1.00
r_question_id_num[17,Intercept]     1.02     1.44  3695 1.00
r_question_id_num[18,Intercept]     0.43     0.77  3635 1.00
r_question_id_num[19,Intercept]     0.85     1.24  4147 1.00
r_question_id_num[20,Intercept]    -0.29     0.02  2995 1.00
r_question_id_num[21,Intercept]     0.77     1.16  3958 1.00
r_question_id_num[22,Intercept]     0.08     0.41  2617 1.00
r_question_id_num[23,Intercept]     0.78     1.16  3578 1.00
r_question_id_num[24,Intercept]    -0.13     0.17  2925 1.00
r_question_id_num[25,Intercept]     0.24     0.58  3141 1.00
r_question_id_num[26,Intercept]    -0.19     0.13  2761 1.00
r_question_id_num[27,Intercept]    -1.42    -1.13  3373 1.00
r_question_id_num[28,Intercept]    -1.37    -1.09  2958 1.00
r_question_id_num[29,Intercept]    -0.09     0.25  2756 1.00
r_question_id_num[30,Intercept]     1.29     1.71  4103 1.00
r_question_id_num[31,Intercept]     0.08     0.41  3109 1.00
r_question_id_num[32,Intercept]     1.10     1.52  3277 1.00
r_question_id_num[33,Intercept]     1.38     1.86  3509 1.00
r_question_id_num[34,Intercept]     1.11     1.54  4040 1.00
r_question_id_num[35,Intercept]    -0.57    -0.27  3028 1.00
r_question_id_num[36,Intercept]    -0.03     0.30  3220 1.00
r_question_id_num[37,Intercept]     0.64     0.98  3417 1.00
r_question_id_num[38,Intercept]     0.02     0.34  2827 1.00
r_question_id_num[39,Intercept]    -0.29     0.03  2863 1.00
r_question_id_num[40,Intercept]     1.11     1.54  4163 1.00
r_question_id_num[41,Intercept]     0.57     0.92  3328 1.00
r_question_id_num[42,Intercept]    -0.35    -0.04  2470 1.00
r_question_id_num[43,Intercept]    -0.76    -0.46  3082 1.00
r_question_id_num[44,Intercept]    -0.43    -0.13  2725 1.00
r_question_id_num[45,Intercept]     1.10     1.52  4078 1.00
r_question_id_num[46,Intercept]    -0.71    -0.40  2549 1.00
r_question_id_num[47,Intercept]     0.37     0.73  3699 1.00
r_question_id_num[48,Intercept]     0.09     0.41  3082 1.00
r_question_id_num[49,Intercept]    -0.85    -0.54  2974 1.00
r_question_id_num[50,Intercept]     0.43     0.82  3066 1.00
r_question_id_num[51,Intercept]     0.02     0.36  2658 1.00
r_question_id_num[52,Intercept]    -0.62    -0.31  2962 1.00
r_question_id_num[53,Intercept]    -0.53    -0.24  2659 1.00
r_question_id_num[54,Intercept]     0.19     0.54  2977 1.00
r_question_id_num[55,Intercept]     0.25     0.59  3154 1.00
r_student_id_num[1,Intercept]       1.24     1.84  4580 1.00
r_student_id_num[2,Intercept]      -0.94    -0.59  3135 1.00
r_student_id_num[3,Intercept]       0.19     0.64  2997 1.00
r_student_id_num[4,Intercept]      -0.01     0.42  3624 1.00
r_student_id_num[5,Intercept]       1.90     2.68  4522 1.00
r_student_id_num[6,Intercept]       0.52     1.03  4290 1.00
r_student_id_num[7,Intercept]       0.51     1.01  3814 1.00
r_student_id_num[8,Intercept]       0.29     0.77  3360 1.00
r_student_id_num[9,Intercept]      -0.78    -0.40  3125 1.00
r_student_id_num[10,Intercept]      0.40     0.88  3644 1.00
r_student_id_num[11,Intercept]      0.78     1.26  3887 1.00
r_student_id_num[12,Intercept]     -0.54    -0.15  3237 1.00
r_student_id_num[13,Intercept]      0.08     0.52  3671 1.00
r_student_id_num[14,Intercept]      1.92     2.64  5126 1.00
r_student_id_num[15,Intercept]      0.40     0.87  3585 1.00
r_student_id_num[16,Intercept]     -0.20     0.21  2860 1.00
r_student_id_num[17,Intercept]     -0.55    -0.13  3137 1.00
r_student_id_num[18,Intercept]      1.93     2.66  4084 1.00
r_student_id_num[19,Intercept]      1.45     2.08  3903 1.00
r_student_id_num[20,Intercept]      0.78     1.30  3138 1.00
r_student_id_num[21,Intercept]      0.07     0.53  3631 1.00
r_student_id_num[22,Intercept]     -1.86    -1.46  3905 1.00
r_student_id_num[23,Intercept]     -0.46    -0.08  3404 1.00
r_student_id_num[24,Intercept]     -0.55    -0.13  2938 1.00
r_student_id_num[25,Intercept]      0.40     0.90  3747 1.00
r_student_id_num[26,Intercept]      2.53     3.46  4567 1.00
r_student_id_num[27,Intercept]     -0.37     0.06  4107 1.00
r_student_id_num[28,Intercept]      1.24     1.85  4919 1.00
r_student_id_num[29,Intercept]      1.68     2.35  4931 1.00
r_student_id_num[30,Intercept]      1.26     1.84  3639 1.00
r_student_id_num[31,Intercept]      1.09     1.62  4292 1.00
r_student_id_num[32,Intercept]      1.92     2.67  3804 1.00
r_student_id_num[33,Intercept]      1.25     1.85  4770 1.00
r_student_id_num[34,Intercept]      1.08     1.64  3912 1.00
r_student_id_num[35,Intercept]      1.66     2.32  5014 1.00
r_student_id_num[36,Intercept]     -0.20     0.23  3176 1.00
r_student_id_num[37,Intercept]     -0.30     0.13  3278 1.00
r_student_id_num[38,Intercept]     -0.71    -0.32  2886 1.00
r_student_id_num[39,Intercept]      0.79     1.30  4452 1.00
r_student_id_num[40,Intercept]     -0.29     0.11  3656 1.00
r_student_id_num[41,Intercept]     -1.16    -0.81  3056 1.00
r_student_id_num[42,Intercept]      1.25     1.88  4668 1.00
r_student_id_num[43,Intercept]     -0.21     0.19  3665 1.00
r_student_id_num[44,Intercept]      1.25     1.86  3896 1.00
r_student_id_num[45,Intercept]      1.26     1.84  4775 1.00
r_student_id_num[46,Intercept]      0.66     1.17  4182 1.00
r_student_id_num[47,Intercept]      0.65     1.14  3804 1.00
r_student_id_num[48,Intercept]      0.18     0.58  4192 1.00
r_student_id_num[49,Intercept]     -0.87    -0.49  3128 1.00
r_student_id_num[50,Intercept]     -1.02    -0.64  3148 1.00
r_student_id_num[51,Intercept]     -0.01     0.43  3730 1.00
r_student_id_num[52,Intercept]      0.18     0.62  3767 1.00
r_student_id_num[53,Intercept]     -0.94    -0.54  3004 1.00
r_student_id_num[54,Intercept]     -0.47    -0.05  3295 1.00
r_student_id_num[55,Intercept]      0.64     1.16  3822 1.00
r_student_id_num[56,Intercept]      0.52     1.03  3411 1.00
r_student_id_num[57,Intercept]      0.76     1.28  3744 1.00
r_student_id_num[58,Intercept]     -1.70    -1.32  3479 1.00
r_student_id_num[59,Intercept]      1.08     1.69  3198 1.00
r_student_id_num[60,Intercept]      0.09     0.53  3528 1.00
r_student_id_num[61,Intercept]     -0.37     0.04  3773 1.00
r_student_id_num[62,Intercept]     -0.19     0.21  3375 1.00
r_student_id_num[63,Intercept]     -0.01     0.41  3755 1.00
r_student_id_num[64,Intercept]      0.40     0.88  3647 1.00
r_student_id_num[65,Intercept]      1.94     2.72  4523 1.00
r_student_id_num[66,Intercept]     -0.02     0.41  3710 1.00
r_student_id_num[67,Intercept]     -2.19    -1.78  4607 1.00
r_student_id_num[68,Intercept]     -0.87    -0.47  3120 1.00
r_student_id_num[69,Intercept]     -0.95    -0.58  2630 1.00
r_student_id_num[70,Intercept]     -0.94    -0.57  2777 1.00
r_student_id_num[71,Intercept]      0.64     1.16  4110 1.00
r_student_id_num[72,Intercept]     -0.12     0.30  3436 1.00
r_student_id_num[73,Intercept]      1.27     1.89  4853 1.00
r_student_id_num[74,Intercept]     -0.20     0.22  3638 1.00
r_student_id_num[75,Intercept]     -0.03     0.40  3495 1.00
r_student_id_num[76,Intercept]     -0.01     0.40  2747 1.00
r_student_id_num[77,Intercept]      1.67     2.40  4290 1.00
r_student_id_num[78,Intercept]     -1.09    -0.73  2907 1.00
r_student_id_num[79,Intercept]      0.40     0.86  3591 1.00
r_student_id_num[80,Intercept]     -0.04     0.40  3268 1.00
r_student_id_num[81,Intercept]     -0.01     0.41  3129 1.00
r_student_id_num[82,Intercept]      0.94     1.49  3561 1.00
r_student_id_num[83,Intercept]     -0.37     0.02  3313 1.00
r_student_id_num[84,Intercept]     -0.70    -0.31  3501 1.00
r_student_id_num[85,Intercept]     -0.62    -0.22  3670 1.00
r_student_id_num[86,Intercept]     -0.29     0.12  2815 1.00
r_student_id_num[87,Intercept]      1.66     2.34  5651 1.00
r_student_id_num[88,Intercept]      0.52     0.99  3812 1.00
r_student_id_num[89,Intercept]      0.28     0.74  3426 1.00
r_student_id_num[90,Intercept]      0.64     1.12  4335 1.00
r_student_id_num[91,Intercept]      2.21     3.06  5019 1.00
r_student_id_num[92,Intercept]     -0.54    -0.17  3055 1.00
r_student_id_num[93,Intercept]      0.78     1.28  3687 1.00
r_student_id_num[94,Intercept]      0.17     0.61  3780 1.00
r_student_id_num[95,Intercept]     -0.01     0.44  3447 1.00
r_student_id_num[96,Intercept]     -1.47    -1.10  3467 1.00
r_student_id_num[97,Intercept]     -0.46    -0.07  3050 1.00
r_student_id_num[98,Intercept]      0.09     0.53  2450 1.00
r_student_id_num[99,Intercept]      0.27     0.76  3444 1.00
r_student_id_num[100,Intercept]    -0.02     0.42  3311 1.00
lprior                             -4.68    -4.63   819 1.01
lp__                            -2813.30 -2798.58   825 1.01

Samples were drawn using NUTS(diag_e) at Tue Nov 03 08:35:29 2026.
For each parameter, n_eff is a crude measure of effective sample size,
and Rhat is the potential scale reduction factor on split chains (at 
convergence, Rhat=1).

We can use the {tidybayes} functions on the brm() output

# brm()'s "r_question_id_num" plays the role of our "beta"
spread_draws(brm_fit, r_question_id_num[question_id_num, ]) |>
  median_qi() |>
  arrange(desc(-r_question_id_num))
# A tibble: 55 × 7
   question_id_num r_question_id_num .lower .upper .width .point .interval
             <int>             <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>    
 1              27            -1.58   -2.04 -1.13    0.95 median qi       
 2              28            -1.53   -1.99 -1.09    0.95 median qi       
 3              49            -1.01   -1.45 -0.537   0.95 median qi       
 4              13            -1.00   -1.47 -0.549   0.95 median qi       
 5              43            -0.914  -1.36 -0.465   0.95 median qi       
 6              46            -0.871  -1.33 -0.402   0.95 median qi       
 7              52            -0.778  -1.24 -0.309   0.95 median qi       
 8              35            -0.736  -1.20 -0.270   0.95 median qi       
 9              53            -0.690  -1.14 -0.237   0.95 median qi       
10              44            -0.591  -1.06 -0.127   0.95 median qi       
# ℹ 45 more rows

Notice anything different?

And {marginaleffects}!

p <- predictions(brm_fit)
ggplot(p, aes(y = estimate, x = reorder(question_id_num, estimate))) +
  geom_point(alpha = 0.2) +
  geom_line(aes(group = student_id_num), alpha = 0.2) +
  labs(x = "question") +
  theme(axis.text.x = element_text(size = 10))

Fitting with glmer()

# fit model
f <- correct_answer ~ (1 | question_id_num) + (1 | student_id_num)
lmer_fit <- glmer(f, data = answers, family = binomial)

# grab "difficulties"
ranef(lmer_fit)$question_id_num |>
  as_tibble(rownames = "question_id_num") |>
  rename(beta = "(Intercept)") |>
  arrange(beta)
# A tibble: 55 × 2
   question_id_num   beta
   <chr>            <dbl>
 1 27              -1.56 
 2 28              -1.51 
 3 13              -1.00 
 4 49              -1.00 
 5 43              -0.916
 6 46              -0.873
 7 52              -0.785
 8 35              -0.740
 9 53              -0.695
10 44              -0.604
# ℹ 45 more rows

And more {marginaleffects}!

p <- predictions(lmer_fit)
ggplot(p, aes(y = estimate, x = reorder(question_id_num, estimate))) +
  geom_point(alpha = 0.2) +
  geom_line(aes(group = student_id_num), alpha = 0.2) +
  labs(x = "question") +
  theme(axis.text.x = element_text(size = 10))

What if no partial pooling?

# fit model
f <- correct_answer ~ factor(question_id_num) + factor(student_id_num)
glm_fit <- glm(f, data = answers, family = binomial)

# marginaleffects
p1 <- predictions(glm_fit)
ggplot(p1, aes(y = estimate, x = reorder(question_id_num, estimate))) +
  geom_point(alpha = 0.2) +
  geom_line(aes(group = student_id_num), alpha = 0.2) +
  labs(x = "question") +
  theme(axis.text.x = element_text(size = 10))

A plot of the partial pooling

Code
comb <- bind_rows(
  mutate(p, model = "glmer"),
  mutate(p1, model = "glm")) |>
mutate(question_id_num = reorder(factor(question_id_num), estimate))

ggplot(comb, aes(x = estimate, y = model)) +
  geom_point(alpha = 0.2) +
  geom_line(aes(group = rowid), alpha = 0.2) +
  facet_wrap(vars(question_id_num)) +
  theme_minimal(base_size = 12)

What if we gave each student 5 random questions?

  • If we give each student 5 random questions, this is really unfair! Some students will get hard questions. Other students will get easy questions.
  • BUT! We can use an IRT model to estimate the difficulty of each question and the ability of each student.
  • Even if the students don’t answer the same questions, we can put them on the same scale.
  • This won’t work super-great, each student answers only 5 questions and each question is asked only about 9 times. But it will help a little!
set.seed(1234)
random <- answers |>
  group_by(student_id_num) |>
  slice_sample(n = 5) %>%
  ungroup() %>%
  glimpse()
Rows: 500
Columns: 3
$ student_id_num  <dbl> 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5…
$ question_id_num <dbl> 28, 16, 22, 37, 44, 47, 9, 5, 38, 16, 4, 34, 39, 22, 26, 6, 15, …
$ correct_answer  <dbl> 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1…
Code
full_results <- answers |>
  group_by(student_id_num) |>
  summarize(score_on_full_exam = mean(correct_answer))

random_results <- random %>%
  group_by(student_id_num) |>
  summarize(score_on_random5_exam = mean(correct_answer))

comb <- full_results |>
  left_join(random_results)

print(comb)
# A tibble: 100 × 3
   student_id_num score_on_full_exam score_on_random5_exam
            <dbl>              <dbl>                 <dbl>
 1              1              0.909                   0.8
 2              2              0.527                   0.6
 3              3              0.764                   1  
 4              4              0.727                   0.8
 5              5              0.964                   1  
 6              6              0.818                   0.8
 7              7              0.818                   1  
 8              8              0.782                   1  
 9              9              0.564                   0.4
10             10              0.8                     0.8
# ℹ 90 more rows
# make a data frame with all observed and unobserved combos
# - each student *could have* been asked 55 questions; 50 answers are missing
all <- crossing(student_id_num = random$student_id_num,
                question_id_num = random$question_id_num) |>
  glimpse()
Rows: 5,500
Columns: 2
$ student_id_num  <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
$ question_id_num <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 1…
# fit irt model to observed answer
f <- correct_answer ~ (1 | question_id_num) + (1 | student_id_num)
random_lmer_fit <- glmer(f, data = random, family = binomial)

# fill in "answers" to the unanswered questions
modeled_answers <- all %>%
  mutate(pred = predict(random_lmer_fit, newdata = ., type = "response")) |>
  left_join(random) |>
  mutate(filled_correct_answer = case_when(is.na(correct_answer) ~ pred,
                                           TRUE ~ correct_answer)) |>
  glimpse()
Rows: 5,500
Columns: 5
$ student_id_num        <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
$ question_id_num       <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,…
$ pred                  <dbl> 0.757, 0.792, 0.837, 0.830, 0.797, 0.706, 0.707, 0.823, 0.…
$ correct_answer        <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
$ filled_correct_answer <dbl> 0.757, 0.792, 0.837, 0.830, 0.797, 0.706, 0.707, 0.823, 0.…
# compute scores
modeled_results <- modeled_answers |>
  group_by(student_id_num) |>
  summarize(modeled_score_on_random5_exam = mean(filled_correct_answer)) |>
  glimpse()
Rows: 100
Columns: 2
$ student_id_num                <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,…
$ modeled_score_on_random5_exam <dbl> 0.773, 0.673, 0.851, 0.788, 0.838, 0.768, 0.846, 0…
comb <- full_results |>
  left_join(random_results) |>
  left_join(modeled_results) |>
  mutate(score_error = score_on_random5_exam - score_on_full_exam,
         modeled_score_error = modeled_score_on_random5_exam - score_on_full_exam) |>
  glimpse()
Rows: 100
Columns: 6
$ student_id_num                <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,…
$ score_on_full_exam            <dbl> 0.909, 0.527, 0.764, 0.727, 0.964, 0.818, 0.818, 0…
$ score_on_random5_exam         <dbl> 0.8, 0.6, 1.0, 0.8, 1.0, 0.8, 1.0, 1.0, 0.4, 0.8, …
$ modeled_score_on_random5_exam <dbl> 0.773, 0.673, 0.851, 0.788, 0.838, 0.768, 0.846, 0…
$ score_error                   <dbl> -0.1091, 0.0727, 0.2364, 0.0727, 0.0364, -0.0182, …
$ modeled_score_error           <dbl> -0.13642, 0.14599, 0.08777, 0.06033, -0.12518, -0.…

Modeling reduces median abs. error by about 30%.

median(abs(comb$score_error))
[1] 0.109
median(abs(comb$modeled_score_error))
[1] 0.0762
median(abs(comb$modeled_score_error))/median(abs(comb$score_error)) - 1
[1] -0.302
Code
gg1 <- ggplot(comb, aes(x = score_on_full_exam, y = score_on_random5_exam)) +
  geom_jitter(height = 0.01, width = 0.01, alpha = 0.8, shape = 21) +
  geom_abline(intercept = 0, slope = 1)

gg2 <- ggplot(comb, aes(x = score_on_full_exam, y = modeled_score_on_random5_exam)) +
  geom_jitter(height = 0.01, width = 0.01, alpha = 0.8, shape = 21) +
  geom_abline(intercept = 0, slope = 1)

gg1 + gg2 &
  scale_y_continuous(limits = c(0, 1))

  • If you knew you were the worst student in the class, what method would you want the instructor to use? Best? Median?
  • Why does this happen?!? Is it good?

2-PL model

Indices

  • Answers: \(n = 1, \dots, N\). (\(N = 55 \times 100 = 5{,}500\) in our data.)
  • Students: \(j = 1, \dots, J\). (\(J = 100\) in our data.)
  • Questions: \(k = 1, \dots, K\). (\(K = 55\) in our data.)

Outcome: \(y_{n} \in \{0, 1\}\) indicates whether the \(n\)th answer is correct.

\[ y_n \sim \text{Bernoulli}(\pi_n) \\ \pi_n = \operatorname{logit}^{-1} \left( \gamma_{k[n]} \left( \alpha_{j[n]} - \beta_{k[n]} \right) \right). \]

Parameters

  • \(\pi_n\): the probability that answer \(n\) is correct
  • \(\alpha_{j[n]}\): the ability of student \(j\) [who is giving the \(n\)th answer]
    • Higher \(\alpha_j\) ⇒ more likely to answer correctly; more knowledgeable student
  • \(\beta_{k[n]}\): the difficulty of question \(k\) [which is associated with the \(n\)th answer]
    • Higher \(\beta_k\) ⇒ less likely to answer correctly; harder item
  • \(\gamma_{k[n]}\): the discrimination of question \(k\)
    • Larger \(\gamma_k\) ⇒ steeper item response curve; item better differentiates high- vs low-ability students

Exercise

Fit a 2-PL model to these data.

Identifiability in the 2-PL Model

Original

student \(j\) question \(k\) \(\alpha_j\) \(\gamma_k\) \(\beta_k\) \(\pi_{jk}\)
1 1 0.5 1.0 -1.0 0.818
1 2 0.5 1.0 0.0 0.622
1 3 0.5 1.0 1.0 0.378
2 1 1.0 1.0 -1.0 0.881
2 2 1.0 1.0 0.0 0.731
2 3 1.0 1.0 1.0 0.500
3 1 1.5 1.0 -1.0 0.924
3 2 1.5 1.0 0.0 0.818
3 3 1.5 1.0 1.0 0.622

New Parameters, Same Probabilities

Transformation: \(\alpha_j'=-2\,\alpha_j,\quad \beta_k'=-2\,\beta_k,\quad \gamma_k'=-\tfrac{1}{2}\,\gamma_k\)

student \(j\) question \(k\) \(\alpha_j'\) \(\gamma_k'\) \(\beta_k'\) \(\pi_{jk}\)
1 1 -1.0 -0.5 2.0 0.818
1 2 -1.0 -0.5 0.0 0.622
1 3 -1.0 -0.5 -2.0 0.378
2 1 -2.0 -0.5 2.0 0.881
2 2 -2.0 -0.5 0.0 0.731
2 3 -2.0 -0.5 -2.0 0.500
3 1 -3.0 -0.5 2.0 0.924
3 2 -3.0 -0.5 0.0 0.818
3 3 -3.0 -0.5 -2.0 0.622

Note

Key Idea:
Flipping the sign and scale of the latent ability (and compensating in the item and discrimination) parameters leaves the predicted probabilities \(\pi_{jk}\) identical.

\(\Rightarrow\) The 2-PL model is invariant to linear transformations of the latent ability scale:

\[ \alpha_j' = c(\alpha_j - d), \quad \gamma_k' = \frac{\gamma_k}{c}, \quad \beta_k' = c(\beta_k - d). \]

To identify the model, we fix:

  • Location: \(E[\alpha_j]=0\)
  • Scale: \(\operatorname{Var}[\alpha_j]=1\)
  • Direction: \(\gamma_k > 0\)

We could also allow \(\gamma_k\) to be negative or positive and instead fix the sign of one of the \(\alpha_j\).