The functions in cat_eap_common.R
and cat_eap_dichotomous.R
are adapted from the source code of the catR package by
David Magis, Gilles Raîche and Juan Ramón Barrada (Magis & Raîche,
2012; Magis & Barrada, 2017). The model parameterisations, the
formulas for the response probabilities and their derivatives, the item
information, maximum-information item selection and the trapezoidal EAP
integration all follow catR’s Pi(), Ii(),
nextItem(), eapEst(), eapSem()
and integrate.catR(), and much of the code keeps catR’s
structure line for line. The purpose of the rewrite is teaching, not
replacement: every step is written out in plain base R so that it can be
read, and catR remains the reference implementation that every function
is checked against below. catR is free software distributed under the
GNU General Public License (version 3 or later).
Adaptive testing packages like catR score examinees
using EAP (Expected A Posteriori) ability estimation, a
Bayesian method that stays numerically stable where Maximum Likelihood
breaks down: a response pattern that is all-correct, all-incorrect, or
otherwise perfectly separates the item bank has a likelihood with no
finite maximum.
This vignette covers dichotomous items, scored
right/wrong, under the four-parameter logistic (4PL)
model, which contains the 1PL, 2PL and 3PL models as special cases. working_examples/cat_eap_common.R
holds the two model-free helpers, and working_examples/cat_eap_dichotomous.R
holds the six model-specific functions. Together they implement the full
adaptive-testing pipeline in plain base R, with no runtime dependency on
catR:
| Function | File | Purpose | catR equivalent it mirrors |
|---|---|---|---|
density_function() |
cat_eap_common.R |
Normal density | stats::dnorm() |
integrate_cat() |
cat_eap_common.R |
Numerical integration (trapezoidal rule) | catR::integrate.catR() |
compute_pi_dichotomous() |
cat_eap_dichotomous.R |
4PL response probabilities and their first three derivatives | catR::Pi() |
compute_ii_dichotomous() |
cat_eap_dichotomous.R |
Item information and its first two derivatives | catR::Ii() |
compute_next_item_dichotomous() |
cat_eap_dichotomous.R |
Maximum Fisher information item selection | catR::nextItem(criterion = "MFI") |
compute_likelihood_dichotomous() |
cat_eap_dichotomous.R |
Likelihood of a 0/1 response pattern | (internal to catR::eapEst()) |
compute_eap_dichotomous() |
cat_eap_dichotomous.R |
EAP ability estimate | catR::eapEst() |
compute_eap_se_dichotomous() |
cat_eap_dichotomous.R |
EAP standard error | catR::eapSem() |
This vignette walks through the theory behind each function,
validates every one of them against its catR counterpart,
works through what the guessing and slipping parameters and unequal
discriminations do to an estimate, and then runs a small adaptive test
end to end.
library(ggplot2)
library(catR)
source(paste0(directory, "../working_examples/cat_eap_common.R"), local = knitr::knit_global())
source(paste0(directory, "../working_examples/cat_eap_dichotomous.R"), local = knitr::knit_global())Every dichotomous item response model gives the probability that an examinee with ability \(\theta\) answers item \(j\) correctly. The 4PL model writes it as
\[P_j(\theta) = c_j + (d_j-c_j)\,\frac{e^{Da_j(\theta-b_j)}}{1+e^{Da_j(\theta-b_j)}}\]
Setting \(d_j=1\) gives Birnbaum’s (1968) 3PL model; additionally setting \(c_j=0\) gives the 2PL model; additionally fixing \(a_j\) to one common value across items gives the 1PL/Rasch model. The upper asymptote \(d_j<1\) was proposed by Barton and Lord (1981) for high-ability examinees who still miss an easy item through carelessness.
cat_eap_dichotomous.R expects bank to be a
numeric matrix with one row per item and exactly four
columns, read by position: column 1 holds \(a_j\), column 2 \(b_j\), column 3 \(c_j\) and column 4 \(d_j\). A dichotomous item always has two
response categories, so the number of columns never changes and there is
no NA padding; a 1PL, 2PL or 3PL item is entered as a 4PL
item with \(c_j=0\) and/or \(d_j=1\).
compute_pi_dichotomous(), which every other function calls,
begins with bank <- rbind(bank), so a single item may
also be passed as a plain vector c(a, b, c, d).
Writing \(e_j =
\exp\big(Da_j(\theta-b_j)\big)\),
compute_pi_dichotomous() returns \(P_j\) together with its first three
derivatives with respect to \(\theta\):
\[P'_j = \frac{Da_j(d_j-c_j)\,e_j}{(1+e_j)^2} \qquad P''_j = \frac{D^2a_j^2(d_j-c_j)\,e_j(1-e_j)}{(1+e_j)^3} \qquad P'''_j = \frac{D^3a_j^3(d_j-c_j)\,e_j(e_j^2-4e_j+1)}{(1+e_j)^4}\]
The first derivative is what item information is built from; the
second and third are what the derivatives of the information are built
from. Neither maximum-information selection nor EAP scoring needs the
derivatives of the information, but they are returned for parity with
catR.
Floating-point arithmetic cannot represent a probability arbitrarily
close to 1: once \(e_j/(1+e_j)\) is
within half a unit in the last place of 1 it rounds to exactly 1. The
code therefore replaces any \(P_j\)
that is exactly 0 with \(10^{-10}\) and
any that is exactly 1 with \(1-10^{-10}\), as catR::Pi()
does. Without this, \(1-P_j\) could be
exactly zero, and the item information (which divides by \(P_j(1-P_j)\)) would become infinite. The
floor only matters for items with \(c_j=0\) or \(d_j=1\); any other item has asymptotes
strictly inside \((0,1)\).
The Fisher information of a dichotomous item is its squared slope divided by the variance of the response:
\[I_j(\theta) = \frac{P'^2_j}{P_j Q_j}, \qquad Q_j = 1-P_j\]
This is the polytomous formula \(\sum_k (\partial P_{jk}/\partial\theta)^2/P_{jk}\) with the two categories \(P_j\) and \(Q_j\), whose derivatives are \(P'_j\) and \(-P'_j\): \(P'^2_j/P_j + P'^2_j/Q_j = P'^2_j/(P_jQ_j)\). Test information is the sum of the item informations, and its reciprocal square root is the asymptotic standard error of a maximum-likelihood ability estimate.
For the 2PL (\(c_j=0\), \(d_j=1\)) the slope is \(P'_j = Da_jP_jQ_j\), so the information reduces to \(D^2a_j^2P_jQ_j\). It is symmetric around \(b_j\), peaks there with height \(D^2a_j^2/4\), and so is governed entirely by the discrimination: in a 2PL bank the high-\(a\) items are simply better items. Guessing and slipping change both the height and the location of the peak. A guessing parameter \(c_j>0\) (with \(d_j=1\)) lowers the information and moves its peak above \(b_j\), to (Birnbaum, 1968)
\[\theta_{\max} = b_j + \frac{1}{Da_j}\,\log\frac{1+\sqrt{1+8c_j}}{2}\]
because a correct answer from an examinee just below \(b_j\) may well be a lucky guess and tells the test less than it would under the 2PL. The worked example on guessing and slipping below checks this formula numerically, and also checks its mirror image for \(d_j<1\).
compute_ii_dichotomous() also returns the first two
derivatives of the information. The first is
\[I'_j = \frac{P'_j\,\big[\,2P_jQ_jP''_j - P'^2_j(Q_j-P_j)\,\big]}{P_j^2Q_j^2}\]
and the second, written out in the same form as
catR::Ii(), is
\[I''_j = \frac{2P_jQ_j\big(P''^2_j + P'_jP'''_j\big) - 2P'^2_jP''_j(Q_j-P_j)}{P_j^2Q_j^2} - \frac{3P_j^2Q_jP'^2_jP''_j - P_jP'^4_j(2Q_j-P_j)}{P_j^4Q_j^2} + \frac{3P_jQ_j^2P'^2_jP''_j - Q_jP'^4_j(Q_j-2P_j)}{P_j^2Q_j^4}\]
Both are checked against finite differences below as well as against
catR.
An adaptive test picks, at each step, the unadministered item that is
most informative at the current ability estimate: the Maximum
Fisher Information (MFI) criterion.
compute_next_item_dichotomous() computes \(I_j(\hat\theta)\) for every item not listed
in out, the indices of the items already administered, and
returns the index of the best one, its information, and the indices that
were available.
If every item had the same \(a_j\) with \(c_j=0\) and \(d_j=1\), the information curves would be copies of one another shifted by \(b_j\), and MFI would reduce to picking the item whose \(b_j\) is closest to \(\hat\theta\). In a 4PL bank the curves differ in height and in shape, so a highly discriminating item some distance away can beat a weak item right on target, and the information of every available item really does have to be computed. This contrasts with the Rating Scale Model vignette, where every item reaches the same maximum information and MFI is close to (though not exactly) a nearest-neighbour lookup on the item locations.
The randomesque argument draws at random from the \(k\) most informative items instead of
always taking the single best. This is standard exposure control: pure
MFI would give the same handful of highly discriminating items to almost
everyone. Two documented differences from catR::nextItem()
matter only for ties: when several items are exactly tied for the
maximum, catR draws one of them at random while this
function takes the first; and with randomesque above 1,
catR also keeps every item tied with the last one kept.
Assuming local independence (conditional on \(\theta\), responses to different items are independent), the likelihood of a 0/1 response pattern \(\mathbf{x}=(x_1,\ldots,x_n)\) is
\[L(\theta) = \prod_{j=1}^{n} P_j(\theta)^{x_j}\,Q_j(\theta)^{1-x_j}\]
The exponents act as a switch: a correct response contributes \(P_j\), a wrong one \(Q_j\). catR computes this
product inside eapEst() and eapSem() without
exporting it; compute_likelihood_dichotomous() exposes it
as a function of its own, and compute_eap_dichotomous() and
compute_eap_se_dichotomous() compute the same product
internally.
Maximum Likelihood fails outright for an all-correct or all-incorrect pattern, because the likelihood keeps increasing toward \(\theta=\pm\infty\) with no interior maximum. EAP (Bock & Mislevy, 1982) sidesteps this by placing a prior \(\pi(\theta)\) on ability (standard normal by default) and reporting the mean of the posterior distribution
\[\hat\theta_{EAP} = E[\theta \mid \mathbf{x}] = \frac{\displaystyle\int \theta\,\pi(\theta)\,L(\theta)\,d\theta}{\displaystyle\int \pi(\theta)\,L(\theta)\,d\theta}\]
For a response pattern \(\mathbf{x}=(x_1,\ldots,x_n)\), the likelihood and the two integrands are
\[ \begin{aligned} L(s) &= \prod_{j=1}^{n} P_j(s)^{x_j}\,[1-P_j(s)]^{1-x_j},\\ g(s) &= s\,\pi(s)\,L(s),\\ h(s) &= \pi(s)\,L(s). \end{aligned} \]
On the quadrature grid \(X=(\theta_1,\ldots,\theta_Q)\), where \(Q=\texttt{nqp}\) and \(\theta_q=\texttt{lower}+(q-1)(\texttt{upper}-\texttt{lower})/(Q-1)\), the estimate is approximated by
\[ \hat\theta_{EAP} \approx \frac{\operatorname{Trap}(X,\,g(X))}{\operatorname{Trap}(X,\,h(X))}, \]
where \(g(X)=(g(\theta_1),\ldots,g(\theta_Q))\) and
\(h(X)=(h(\theta_1),\ldots,h(\theta_Q))\).
Trap denotes the trapezoidal integration implemented by
integrate_cat() below.
\[SE(\hat\theta_{EAP}) = \sqrt{\frac{\displaystyle\int (\theta-\hat\theta_{EAP})^2\,\pi(\theta)\,L(\theta)\,d\theta}{\displaystyle\int \pi(\theta)\,L(\theta)\,d\theta}}\]
For this calculation, the numerator integrand is the squared distance from the EAP estimate times the posterior weight; the denominator is the same posterior weight used above:
\[ \begin{aligned} g_{SE}(s) &= (s-\hat\theta_{EAP})^2\,\pi(s)\,L(s),\\ h(s) &= \pi(s)\,L(s). \end{aligned} \]
Using the same quadrature grid \(X\) as above, the numerical estimate is
\[ SE(\hat\theta_{EAP}) \approx \sqrt{\frac{\operatorname{Trap}(X,\,g_{SE}(X))}{\operatorname{Trap}(X,\,h(X))}}. \]
All four integrals are finite, and both denominators are positive, for every response pattern: \(0<L(\theta)\le 1\) and the normal prior has a finite mean and variance. This is what rescues the all-correct and all-incorrect patterns, whose likelihood never shrinks to zero in one tail and so cannot be integrated on its own.
Neither integral has a closed form, so the integrands are evaluated
at nqp quadrature points \(\theta_1<\cdots<\theta_Q\) (default
33, evenly spaced from lower to upper, default
\(\pm4\)) and integrated with the
trapezoidal rule, which approximates the area between
two adjacent points by a trapezoid, the width times the average of the
two heights:
\[\int f(\theta)\,d\theta \approx \sum_{q=1}^{Q-1} (\theta_{q+1}-\theta_q)\,\frac{f(\theta_q)+f(\theta_{q+1})}{2}\]
integrate_cat() implements exactly this sum;
density_function() supplies \(\pi(\theta)\).
Which feature of a response pattern the estimate depends on is a property of the model. Under the 1PL/Rasch model the likelihood depends on the pattern only through the number correct \(\sum_j x_j\), which is a sufficient statistic for \(\theta\): two examinees with the same number correct get the same estimate whichever items they answered correctly. This is what the Rating Scale and Partial Credit Model vignettes show for their raw scores. Under the 2PL the sufficient statistic becomes the discrimination-weighted score \(\sum_j a_jx_j\): a correct answer to a highly discriminating item counts for more, and the item difficulties play no part at all. Under the 3PL and 4PL there is no simple sufficient statistic, and the whole pattern matters. A worked example below demonstrates all three cases.
The bank from the roxygen examples of
cat_eap_dichotomous.R: five items spanning the ability
range, with different discriminations, and a guessing parameter of 0.1
and an upper asymptote of 0.9 on every item.
bank <- matrix(c(1.75, 1.80, 1.18, 1.54, 1.56,
-2.55, -1.90, 0.18, 1.54, 2.56,
0.1, 0.1, 0.1, 0.1, 0.1,
0.9, 0.9, 0.9, 0.9, 0.9),
nrow = 5,
dimnames = list(1:5, c("a", "b", "c", "d")))
bank## a b c d
## 1 1.75 -2.55 0.1 0.9
## 2 1.80 -1.90 0.1 0.9
## 3 1.18 0.18 0.1 0.9
## 4 1.54 1.54 0.1 0.9
## 5 1.56 2.56 0.1 0.9
Because \(c_j = 1 - d_j\) for every item here, each curve is point-symmetric around its own \(b_j\); the section on item information shows what that does to the location of the information peak.
catRv <- seq(-3, 3, by = 0.1)
comparison <- data.frame(x = v, density_function = density_function(v), dnorm = dnorm(v))
head(comparison)## x density_function dnorm
## 1 -3.0 0.004431848 0.004431848
## 2 -2.9 0.005952532 0.005952532
## 3 -2.8 0.007915452 0.007915452
## 4 -2.7 0.010420935 0.010420935
## 5 -2.6 0.013582969 0.013582969
## 6 -2.5 0.017528300 0.017528300
## [1] TRUE
## [1] 2.775558e-17
ggplot(comparison, aes(x = x)) +
geom_line(aes(y = density_function, color = "density_function()"), linewidth = 1) +
geom_point(aes(y = dnorm, color = "dnorm()"), shape = 1) +
labs(title = "Hand-written normal density vs. dnorm()",
x = expression(theta), y = "density", color = NULL) +
theme_bw(base_size = 12)The function writes out the textbook normal density longhand. One
detail from its documentation is worth repeating: it uses
exp(1) rather than a hard-coded 2.71828, which
is accurate to only six significant digits and shifts the EAP estimate
in the seventh decimal.
x <- seq(from = -3, to = 3, length = 33)
y <- round(exp(x), 2)
c(integrate_cat = integrate_cat(x, y), integrate.catR = catR::integrate.catR(x, y))## integrate_cat integrate.catR
## 20.09437 20.09437
## [1] TRUE
Both approximate \(\int_{-3}^{3} e^x\,dx\) on the same 33-point trapezoidal grid and return the same number.
At a single ability value, the whole returned list (probabilities and
all three derivatives) matches catR::Pi():
mine <- compute_pi_dichotomous(theta = 0, bank)
theirs <- catR::Pi(th = 0, bank)
round(data.frame(mine), 6)## Pi dPi d2Pi d3Pi
## 1 0.890878 0.015781 -0.026987 0.045060
## 2 0.874659 0.044169 -0.074467 0.116770
## 3 0.457679 0.233358 0.029134 -0.157008
## 4 0.168291 0.096191 0.122843 0.121258
## 5 0.114480 0.022179 0.033347 0.048220
## [1] TRUE
Across several ability values and both metric constants, the largest absolute difference in each element:
theta_check <- c(-3, -1.2, 0, 0.7, 2.5)
pi_differences <- do.call(rbind, lapply(c(1, 1.702), function(D) {
do.call(rbind, lapply(theta_check, function(th) {
mine <- compute_pi_dichotomous(th, bank, D = D)
theirs <- catR::Pi(th, bank, D = D)
data.frame(D = D, theta = th,
t(sapply(names(mine), function(k) max(abs(mine[[k]] - theirs[[k]])))))
}))
}))
pi_differences## D theta Pi dPi d2Pi d3Pi
## 1 1.000 -3.0 0 0 0 0
## 2 1.000 -1.2 0 0 0 0
## 3 1.000 0.0 0 0 0 0
## 4 1.000 0.7 0 0 0 0
## 5 1.000 2.5 0 0 0 0
## 6 1.702 -3.0 0 0 0 0
## 7 1.702 -1.2 0 0 0 0
## 8 1.702 0.0 0 0 0 0
## 9 1.702 0.7 0 0 0 0
## 10 1.702 2.5 0 0 0 0
## [1] 0
Agreement with catR shows the code reproduces
catR; it does not by itself show that the derivative
formulas are right. A central finite difference is an independent check,
and running it at \(D=1.702\) confirms
that each derivative carries the correct power of \(D\):
h <- 1e-5
fd_pi <- do.call(rbind, lapply(c(1, 1.702), function(D) {
do.call(rbind, lapply(theta_check, function(th) {
p <- function(t) compute_pi_dichotomous(t, bank, D = D)
data.frame(D = D, theta = th,
dPi = max(abs((p(th + h)$Pi - p(th - h)$Pi) / (2 * h) - p(th)$dPi)),
d2Pi = max(abs((p(th + h)$dPi - p(th - h)$dPi) / (2 * h) - p(th)$d2Pi)),
d3Pi = max(abs((p(th + h)$d2Pi - p(th - h)$d2Pi) / (2 * h) - p(th)$d3Pi)))
}))
}))
sapply(split(fd_pi[, c("dPi", "d2Pi", "d3Pi")], fd_pi$D), function(z) max(unlist(z)))## 1 1.702
## 2.842859e-11 4.031326e-10
The discrepancies are of the size expected from the truncation and rounding error of a central difference with \(h = 10^{-5}\), at both values of \(D\). Since \(D\) only ever enters as \(Da_j\), the model at \(D = 1.702\) is the model at \(D = 1\) with every discrimination multiplied by 1.702:
bank_rescaled <- bank
bank_rescaled[, "a"] <- 1.702 * bank[, "a"]
all.equal(compute_pi_dichotomous(0.4, bank, D = 1.702),
compute_pi_dichotomous(0.4, bank_rescaled, D = 1))## [1] TRUE
theta_grid <- seq(-6, 6, by = 0.05)
prob_curves <- t(sapply(theta_grid, function(th) compute_pi_dichotomous(theta = th, bank)$Pi))
df_curves <- data.frame(theta = theta_grid, prob_curves)
names(df_curves)[-1] <- paste0("item ", 1:5, " (a=", bank[, "a"], ", b=", bank[, "b"], ")")
df_curves_long <- reshape2::melt(df_curves, id.vars = "theta",
variable.name = "item", value.name = "P")
ggplot(df_curves_long, aes(x = theta, y = P, color = item)) +
geom_hline(yintercept = c(0.1, 0.9), linetype = "dashed", color = "grey50") +
geom_line(linewidth = 1) +
labs(title = "Item response curves",
subtitle = "every item has lower asymptote c = 0.1 and upper asymptote d = 0.9 (dashed)",
x = expression(theta), y = expression(P(theta)), color = NULL) +
theme_bw(base_size = 12)Each curve rises from the guessing floor of 0.1 to the slipping ceiling of 0.9, passing through 0.5 at its own \(b_j\). Steeper curves (higher \(a\)) discriminate more sharply around their difficulty; curves shifted right are harder.
For a 2PL item with \(a=1\), \(b=0\) and \(D=1\) the exponent is just \(\theta\), so the floor can be located directly:
item_2pl <- c(a = 1, b = 0, c = 0, d = 1)
z <- c(-746, -745, -40, 36, 37, 40)
data.frame(exponent = z,
P = compute_pi_dichotomous(z, item_2pl)$Pi,
floored_at_0 = compute_pi_dichotomous(z, item_2pl)$Pi == 1e-10,
floored_at_1 = compute_pi_dichotomous(z, item_2pl)$Pi == 1 - 1e-10)## exponent P floored_at_0 floored_at_1
## 1 -746 1.000000e-10 TRUE FALSE
## 2 -745 4.940656e-324 FALSE FALSE
## 3 -40 4.248354e-18 FALSE FALSE
## 4 36 1.000000e+00 FALSE FALSE
## 5 37 1.000000e+00 FALSE TRUE
## 6 40 1.000000e+00 FALSE TRUE
# the smallest exponent at which e/(1+e) rounds to exactly 1
z_grid <- seq(36, 37, by = 0.001)
z_one <- min(z_grid[sapply(z_grid, function(z) exp(z) / (1 + exp(z)) == 1)])
c(first_exponent_rounding_to_1 = z_one, log_2_to_the_53 = log(2^53))## first_exponent_rounding_to_1 log_2_to_the_53
## 36.7400 36.7368
The two ends of the floor are very different in practice. A probability near 0 is stored with a floating-point exponent, so \(e_j/(1+e_j)\) only becomes exactly 0 when \(e_j\) itself underflows, below an exponent of about \(-745\). A probability near 1 is stored as \(1\) minus something, with only 53 bits of mantissa, so it rounds to exactly 1 as soon as the exponent reaches about 36.74, which is \(\log 2^{53}\). For a highly discriminating item with \(Da_j = 4\) that happens only 9.2 logits above \(b_j\). At that point the floor is what keeps the information finite:
e <- exp(40)
unfloored <- c(P = e / (1 + e), dP = e / (1 + e)^2)
c(unfloored_information = unname(unfloored["dP"]^2 / (unfloored["P"] * (1 - unfloored["P"]))),
floored_information = unname(compute_ii_dichotomous(40, item_2pl)$Ii))## unfloored_information floored_information
## Inf 1.804851e-25
The floor guards against rounding, not against overflow. Once the
exponent exceeds \(\log\) of the
largest double, about 709.78, \(e_j\)
itself is Inf and \(e_j/(1+e_j)\) is NaN, in
catR exactly as here:
c(compute_pi_dichotomous = unname(compute_pi_dichotomous(800, item_2pl)$Pi),
catR_Pi = unname(catR::Pi(800, item_2pl)$Pi))## compute_pi_dichotomous catR_Pi
## NaN NaN
No realistic bank and quadrature range comes near that point.
mine <- compute_ii_dichotomous(theta = 0, bank)
theirs <- catR::Ii(th = 0, bank)
round(data.frame(mine), 6)## Ii dIi d2Ii
## 1 0.002562 -0.008436 0.026928
## 2 0.017795 -0.054632 0.153846
## 3 0.219396 0.037323 -0.200239
## 4 0.066105 0.138704 0.226064
## 5 0.004853 0.013773 0.037209
## [1] TRUE
The same grid of ability values and both metric constants, for all three elements:
ii_differences <- do.call(rbind, lapply(c(1, 1.702), function(D) {
do.call(rbind, lapply(theta_check, function(th) {
mine <- compute_ii_dichotomous(th, bank, D = D)
theirs <- catR::Ii(th, bank, D = D)
data.frame(D = D, theta = th,
t(sapply(names(mine), function(k) max(abs(mine[[k]] - theirs[[k]])))))
}))
}))
ii_differences## D theta Ii dIi d2Ii
## 1 1.000 -3.0 0 0 0
## 2 1.000 -1.2 0 0 0
## 3 1.000 0.0 0 0 0
## 4 1.000 0.7 0 0 0
## 5 1.000 2.5 0 0 0
## 6 1.702 -3.0 0 0 0
## 7 1.702 -1.2 0 0 0
## 8 1.702 0.0 0 0 0
## 9 1.702 0.7 0 0 0
## 10 1.702 2.5 0 0 0
## [1] 0
The two-category form of the information, and finite-difference checks of its two derivatives at both metric constants:
pr <- compute_pi_dichotomous(0.4, bank)
all.equal(pr$dPi^2 / pr$Pi + (-pr$dPi)^2 / (1 - pr$Pi),
compute_ii_dichotomous(0.4, bank)$Ii)## [1] TRUE
fd_ii <- do.call(rbind, lapply(c(1, 1.702), function(D) {
do.call(rbind, lapply(theta_check, function(th) {
ii <- function(t) compute_ii_dichotomous(t, bank, D = D)
data.frame(D = D, theta = th,
dIi = max(abs((ii(th + h)$Ii - ii(th - h)$Ii) / (2 * h) - ii(th)$dIi)),
d2Ii = max(abs((ii(th + h)$dIi - ii(th - h)$dIi) / (2 * h) - ii(th)$d2Ii)))
}))
}))
sapply(split(fd_ii[, c("dIi", "d2Ii")], fd_ii$D), function(z) max(unlist(z)))## 1 1.702
## 6.411294e-11 1.431174e-09
info_grid <- seq(-5, 5, by = 0.02)
info <- t(sapply(info_grid, function(th) compute_ii_dichotomous(th, bank)$Ii))
df_info <- data.frame(theta = info_grid, info)
names(df_info)[-1] <- paste0("item ", 1:5, " (a=", bank[, "a"], ", b=", bank[, "b"], ")")
df_info_long <- reshape2::melt(df_info, id.vars = "theta",
variable.name = "item", value.name = "information")
ggplot(df_info_long, aes(x = theta, y = information, color = item)) +
geom_line(linewidth = 1) +
labs(title = "Item information curves",
subtitle = "different heights and widths: the discrimination sets how useful an item can be",
x = expression(theta), y = "information", color = NULL) +
theme_bw(base_size = 12)df_test <- data.frame(theta = info_grid, information = rowSums(info))
ggplot(df_test, aes(x = theta, y = information)) +
geom_line(linewidth = 1, color = "steelblue") +
labs(title = "Test information", x = expression(theta), y = "information") +
theme_bw(base_size = 12)Locating each item’s peak numerically shows how different this is from a Rasch-family bank:
peaks <- t(sapply(1:5, function(j) {
opt <- optimize(function(th) compute_ii_dichotomous(th, bank[j, , drop = FALSE])$Ii,
interval = bank[j, "b"] + c(-4, 4), maximum = TRUE, tol = 1e-10)
c(peak = opt$maximum, max_information = opt$objective)
}))
data.frame(b = bank[, "b"], a = bank[, "a"], peaks,
peak_minus_b = peaks[, "peak"] - bank[, "b"],
ratio_to_2PL_peak = peaks[, "max_information"] / (bank[, "a"]^2 / 4))## b a peak max_information peak_minus_b ratio_to_2PL_peak
## 1 -2.55 1.75 -2.55 0.490000 2.220446e-15 0.64
## 2 -1.90 1.80 -1.90 0.518400 2.220446e-15 0.64
## 3 0.18 1.18 0.18 0.222784 -9.557676e-09 0.64
## 4 1.54 1.54 1.54 0.379456 1.332268e-15 0.64
## 5 2.56 1.56 2.56 0.389376 1.332268e-15 0.64
Two things stand out. The peak heights differ from item to item, from 0.223 for item 3 to 0.518 for item 2, following the discriminations. And every peak sits at its own \(b_j\), even though \(c_j = 0.1 > 0\). The guessing parameter pushes the peak up and the slipping parameter pushes it down; with \(c_j = 1 - d_j\) the curve is point-symmetric around \(b_j\) and the two shifts cancel. The last column shows the price of the asymptotes: every item keeps the same fraction, 0.64 \(= (d_j - c_j)^2\), of the \(D^2a_j^2/4\) it would reach as a 2PL item.
mine <- compute_next_item_dichotomous(theta = 0, bank)
theirs <- catR::nextItem(bank, theta = 0, criterion = "MFI")
c(mine_item = mine$item, catR_item = theirs$item,
mine_info = unname(mine$info), catR_info = unname(theirs$info))## mine_item catR_item mine_info catR_info
## 3.000000 3.000000 0.219396 0.219396
With items already administered excluded via out:
mine <- compute_next_item_dichotomous(theta = 0, bank, out = 3)
theirs <- catR::nextItem(bank, theta = 0, out = 3, criterion = "MFI")
c(mine_item = mine$item, catR_item = theirs$item,
mine_info = unname(mine$info), catR_info = unname(theirs$info))## mine_item catR_item mine_info catR_info
## 4.00000000 4.00000000 0.06610523 0.06610523
## [1] 1 2 4 5
And across the whole ability range, with and without
out, at both metric constants:
grid <- seq(-4, 4, by = 0.05)
next_item_agreement <- do.call(rbind, lapply(c(1, 1.702), function(D) {
do.call(rbind, lapply(list(NULL, c(3, 4)), function(o) {
mine <- sapply(grid, function(th) compute_next_item_dichotomous(th, bank, out = o, D = D)$item)
theirs <- sapply(grid, function(th) catR::nextItem(bank, theta = th, out = o, D = D,
criterion = "MFI")$item)
data.frame(D = D, out = if (is.null(o)) "none" else paste(o, collapse = ","),
agreements = sum(mine == theirs), grid_points = length(grid))
}))
}))
next_item_agreement## D out agreements grid_points
## 1 1.000 none 161 161
## 2 1.000 3,4 161 161
## 3 1.702 none 161 161
## 4 1.702 3,4 161 161
MFI is not the same rule as “take the item whose \(b_j\) is closest to \(\theta\)”, which catR offers
separately as criterion = "bOpt":
mfi <- sapply(grid, function(th) compute_next_item_dichotomous(th, bank)$item)
nearest_b <- sapply(grid, function(th) which.min(abs(th - bank[, "b"])))
c(agreements = sum(mfi == nearest_b), grid_points = length(grid))## agreements grid_points
## 153 161
## theta MFI nearest_b
## 36 -2.25 2 1
## 64 -0.85 2 3
## 65 -0.80 2 3
## 94 0.65 4 3
## 95 0.70 4 3
## 96 0.75 4 3
## 97 0.80 4 3
## 98 0.85 4 3
c(bOpt_at_0.7 = catR::nextItem(bank, theta = 0.7, criterion = "bOpt")$item,
MFI_at_0.7 = compute_next_item_dichotomous(0.7, bank)$item)## bOpt_at_0.7 MFI_at_0.7
## 3 4
In every disagreement MFI passes over the nearest item for a more discriminating one farther away. Most of them involve item 3, the least discriminating item in the bank (\(a = 1.18\)): its difficulty of 0.18 is the nearest, but MFI prefers item 2 (\(a = 1.80\)) around \(\theta = -0.8\) and item 4 (\(a = 1.54\)) from \(\theta = 0.65\) upward. The remaining case sits just on item 1’s side of the midpoint between items 1 and 2, where item 2’s slightly higher discrimination (1.80 against 1.75) wins.
Finally, the documented behaviour at exact ties, with a bank whose first two items are identical:
bank_tied <- rbind(c(1.2, 0, 0, 1), c(1.2, 0, 0, 1), c(1, 2, 0, 1))
set.seed(3)
table(catR = replicate(200, catR::nextItem(bank_tied, theta = 0, criterion = "MFI")$item))## catR
## 1 2
## 99 101
table(compute_next_item_dichotomous = replicate(200, compute_next_item_dichotomous(0, bank_tied)$item))## compute_next_item_dichotomous
## 1
## 200
catR splits the tied choice between items 1 and 2 at
random; compute_next_item_dichotomous() always takes the
first. With continuous item parameters, exact ties essentially never
happen.
catR has no exported likelihood function to compare
against, so the check rebuilds the same product from catR’s
own probabilities, for several patterns and ability values:
response <- c(1, 1, 1, 0, 0)
P_catR <- catR::Pi(th = 0.3, bank)$Pi
c(compute_likelihood_dichotomous = compute_likelihood_dichotomous(theta = 0.3, bank = bank, x = response),
from_catR_Pi = prod(P_catR^response * (1 - P_catR)^(1 - response)))## compute_likelihood_dichotomous from_catR_Pi
## 0.2923057 0.2923057
likelihood_patterns <- list(c(0, 0, 0, 0, 0), c(1, 1, 1, 0, 0), c(0, 1, 0, 1, 1), c(1, 1, 1, 1, 1))
max(sapply(likelihood_patterns, function(x) sapply(theta_check, function(th) {
P <- catR::Pi(th, bank)$Pi
abs(compute_likelihood_dichotomous(th, bank, x) - prod(P^x * (1 - P)^(1 - x)))
})))## [1] 0
lik_grid <- seq(-6, 6, by = 0.05)
df_lik <- data.frame(theta = lik_grid,
"1 1 1 0 0" = sapply(lik_grid, function(th)
compute_likelihood_dichotomous(th, bank, c(1, 1, 1, 0, 0))),
"1 1 1 1 1" = sapply(lik_grid, function(th)
compute_likelihood_dichotomous(th, bank, c(1, 1, 1, 1, 1))),
check.names = FALSE)
df_lik_long <- reshape2::melt(df_lik, id.vars = "theta",
variable.name = "pattern", value.name = "likelihood")
ggplot(df_lik_long, aes(x = theta, y = likelihood, color = pattern)) +
geom_hline(yintercept = prod(bank[, "d"]), linetype = "dashed", color = "grey50") +
geom_line(linewidth = 1) +
labs(title = "Likelihood of two response patterns",
subtitle = "dashed line: the product of the upper asymptotes d",
x = expression(theta), y = "likelihood", color = "pattern") +
theme_bw(base_size = 12)The mixed pattern has an interior maximum. The all-correct pattern does not: its likelihood rises for ever, but with \(d_j = 0.9\) it levels off at \(\prod_j d_j\) rather than approaching 1:
c(likelihood_at_theta_10 = compute_likelihood_dichotomous(10, bank, c(1, 1, 1, 1, 1)),
product_of_d = prod(bank[, "d"]))## likelihood_at_theta_10 product_of_d
## 0.5904792 0.5904900
Maximum Likelihood has no finite answer for this pattern; EAP, below, still does.
patterns <- list(c(0, 0, 0, 0, 0), c(1, 0, 0, 0, 0), c(1, 1, 0, 0, 0), c(1, 1, 1, 0, 0),
c(1, 1, 1, 1, 0), c(1, 1, 1, 1, 1), c(0, 0, 1, 1, 1), c(0, 1, 0, 1, 0))
validation <- do.call(rbind, lapply(c(1, 1.702), function(D) {
do.call(rbind, lapply(patterns, function(x) {
th_mine <- compute_eap_dichotomous(bank, x, D = D)
th_catR <- catR::eapEst(bank, x, D = D)
data.frame(D = D,
pattern = paste(x, collapse = " "),
eap_mine = th_mine,
eap_catR = th_catR,
se_mine = compute_eap_se_dichotomous(th_mine, bank, x, D = D),
se_catR = catR::eapSem(th_mine, bank, x, D = D))
}))
}))
validation## D pattern eap_mine eap_catR se_mine se_catR
## 1 1.000 0 0 0 0 0 -1.42402091 -1.42402091 0.9744831 0.9744831
## 2 1.000 1 0 0 0 0 -0.82464179 -0.82464179 0.8872967 0.8872967
## 3 1.000 1 1 0 0 0 -0.29584271 -0.29584271 0.7777884 0.7777884
## 4 1.000 1 1 1 0 0 0.24285577 0.24285577 0.7881363 0.7881363
## 5 1.000 1 1 1 1 0 0.83150049 0.83150049 0.8474794 0.8474794
## 6 1.000 1 1 1 1 1 1.36074807 1.36074807 0.9199984 0.9199984
## 7 1.000 0 0 1 1 1 0.93523165 0.93523165 1.2965190 1.2965190
## 8 1.000 0 1 0 1 0 -0.08666042 -0.08666042 1.0229335 1.0229335
## 9 1.702 0 0 0 0 0 -1.57085914 -1.57085914 1.0642696 1.0642696
## 10 1.702 1 0 0 0 0 -0.89669190 -0.89669190 0.9154182 0.9154182
## 11 1.702 1 1 0 0 0 -0.38040909 -0.38040909 0.7318921 0.7318921
## 12 1.702 1 1 1 0 0 0.35888037 0.35888037 0.7383867 0.7383867
## 13 1.702 1 1 1 1 0 1.02985160 1.02985160 0.8287740 0.8287740
## 14 1.702 1 1 1 1 1 1.58181381 1.58181381 0.9396780 0.9396780
## 15 1.702 0 0 1 1 1 1.34076524 1.34076524 1.2867164 1.2867164
## 16 1.702 0 1 0 1 0 -0.15015207 -0.15015207 1.0329431 1.0329431
c(max_eap_difference = max(abs(validation$eap_mine - validation$eap_catR)),
max_se_difference = max(abs(validation$se_mine - validation$se_catR)))## max_eap_difference max_se_difference
## 2.220446e-16 2.220446e-16
## [1] TRUE
## [1] TRUE
Both the point estimate and the standard error reproduce
catR to machine precision for every pattern and both metric
constants, including the all-incorrect and all-correct patterns where
Maximum Likelihood fails outright.
Reproducing “Example 1” from the bottom of
cat_eap_dichotomous.R: a 2PL bank (\(c_j = 0\), \(d_j
= 1\)) scored against response patterns with an increasing number
of correct answers, always the easiest items first.
a_2pl <- c(0.7521, 0.8083, 1.1857, 0.5481, 0.5695)
b_2pl <- c(-1.5521, -0.9083, 0.1857, 0.5481, 1.5695)
bank_2pl <- matrix(c(a_2pl, b_2pl, rep(0, 5), rep(1, 5)), nrow = 5,
dimnames = list(1:5, c("a", "b", "c", "d")))
bank_2pl## a b c d
## 1 0.7521 -1.5521 0 1
## 2 0.8083 -0.9083 0 1
## 3 1.1857 0.1857 0 1
## 4 0.5481 0.5481 0 1
## 5 0.5695 1.5695 0 1
responses <- list(c(0, 0, 0, 0, 0),
c(1, 0, 0, 0, 0),
c(1, 1, 0, 0, 0),
c(1, 1, 1, 0, 0),
c(1, 1, 1, 1, 0),
c(1, 1, 1, 1, 1))
results <- data.frame(
n_correct = sapply(responses, sum),
theta_EAP = sapply(responses, function(x) compute_eap_dichotomous(bank_2pl, x))
)
results$SE <- mapply(function(x, th) compute_eap_se_dichotomous(th, bank_2pl, x),
responses, results$theta_EAP)
results## n_correct theta_EAP SE
## 1 0 -1.2469219 0.7962924
## 2 1 -0.7780203 0.7832647
## 3 2 -0.2881144 0.7752039
## 4 3 0.4264169 0.7810946
## 5 4 0.7648196 0.7910812
## 6 5 1.1272372 0.8046738
ggplot(results, aes(x = n_correct, y = theta_EAP)) +
geom_ribbon(aes(ymin = theta_EAP - SE, ymax = theta_EAP + SE), alpha = 0.2, fill = "steelblue") +
geom_line(color = "steelblue", linewidth = 1) +
geom_point(size = 2, color = "steelblue") +
labs(title = "EAP estimate (± 1 SE) vs. number of items correct",
x = "number of items correct (out of 5)", y = expression(hat(theta)[EAP])) +
theme_bw(base_size = 12)The estimate climbs monotonically with the number of correct responses, from -1.247 to 1.127. It never approaches the integration bounds of \(\pm4\), even for the all-incorrect and all-correct patterns: the normal prior pulls the posterior mean back toward 0. That regularisation is the reason to prefer EAP over Maximum Likelihood at the edges of the score range, and the price is a deliberate, known bias toward the prior mean.
The standard error barely moves across the range, from 0.775 to 0.805. It is smallest for 2 correct and largest for 5 correct: a mid-range pattern pins \(\theta\) down a little more tightly against this bank than a perfect or a zero score, which is compatible with a wide range of high (or low) abilities. Five items of moderate discrimination cannot shrink a standard normal prior by much, which is why the standard error stays close to 0.8 throughout.
compute_eap_se_dichotomous() takes theta as
a free argument. Passing the EAP estimate returns the posterior standard
deviation; passing anything else returns the posterior root mean squared
deviation around that point. The same 2PL bank, evaluated at every \(\theta\) from \(-3\) to \(3\) under three response patterns:
se_grid <- seq(-3, 3, by = 0.05)
se_patterns <- list("all incorrect (0 0 0 0 0)" = c(0, 0, 0, 0, 0),
"all correct (1 1 1 1 1)" = c(1, 1, 1, 1, 1),
"2 of 5 correct (1 0 1 0 0)" = c(1, 0, 1, 0, 0))
se_df <- do.call(rbind, lapply(names(se_patterns), function(nm) {
data.frame(theta = se_grid,
SE = sapply(se_grid, function(th)
compute_eap_se_dichotomous(theta = th, bank = bank_2pl, x = se_patterns[[nm]])),
pattern = nm)
}))
eap_points <- do.call(rbind, lapply(names(se_patterns), function(nm) {
th <- compute_eap_dichotomous(bank_2pl, se_patterns[[nm]])
data.frame(theta = th, SE = compute_eap_se_dichotomous(th, bank_2pl, se_patterns[[nm]]),
pattern = nm)
}))
eap_points## theta SE pattern
## 1 -1.24692189 0.7962924 all incorrect (0 0 0 0 0)
## 2 1.12723723 0.8046738 all correct (1 1 1 1 1)
## 3 -0.06158777 0.7746548 2 of 5 correct (1 0 1 0 0)
ggplot(se_df, aes(x = theta, y = SE, color = pattern)) +
geom_line(linewidth = 1) +
geom_point(data = eap_points, size = 3) +
labs(title = "Posterior RMSD vs. evaluation point, by response pattern",
subtitle = "solid points mark the EAP estimate, where each curve bottoms out",
x = expression(theta), y = "standard error", color = NULL) +
theme_bw(base_size = 12)Each curve bottoms out at its own EAP estimate. This is not a coincidence of the bank but the defining property of a mean: the mean squared deviation of a distribution around a point \(t\) is its variance plus \((t - \text{mean})^2\). The identity holds for the quadrature weights as well, to rounding error:
sapply(names(se_patterns), function(nm) {
pt <- eap_points[eap_points$pattern == nm, ]
curve <- se_df[se_df$pattern == nm, ]
max(abs(curve$SE - sqrt(pt$SE^2 + (curve$theta - pt$theta)^2)))
})## all incorrect (0 0 0 0 0) all correct (1 1 1 1 1) 2 of 5 correct (1 0 1 0 0)
## 8.881784e-16 8.881784e-16 4.440892e-16
So the curves carry no information beyond the EAP estimate and its
standard error, and the free theta argument is useful
mainly for showing what the pairing
compute_eap_se_dichotomous(compute_eap_dichotomous(bank, x), bank, x)
is doing. In practice the two functions are always called that way.
One item, \(a = 1.3\) and \(b = 0.5\), in four versions: a 2PL item, a 3PL item with guessing \(c = 0.2\), an item with slipping \(d = 0.8\) and no guessing, and an item with both.
variants <- list("2PL (c=0, d=1)" = c(1.3, 0.5, 0, 1),
"guessing (c=0.2, d=1)" = c(1.3, 0.5, 0.2, 1),
"slipping (c=0, d=0.8)" = c(1.3, 0.5, 0, 0.8),
"both (c=0.2, d=0.8)" = c(1.3, 0.5, 0.2, 0.8))
gs_grid <- seq(-4, 5, by = 0.02)
gs_curves <- do.call(rbind, lapply(names(variants), function(nm) {
it <- variants[[nm]]
data.frame(theta = gs_grid, version = nm,
P = compute_pi_dichotomous(gs_grid, it)$Pi,
information = compute_ii_dichotomous(gs_grid, it)$Ii)
}))
gs_curves$version <- factor(gs_curves$version, levels = names(variants))
gs_long <- reshape2::melt(gs_curves, id.vars = c("theta", "version"),
variable.name = "curve", value.name = "value")ggplot(gs_long, aes(x = theta, y = value, color = version)) +
geom_vline(xintercept = 0.5, linetype = "dashed", color = "grey50") +
geom_line(linewidth = 1) +
facet_wrap(~ curve, ncol = 1, scales = "free_y",
labeller = as_labeller(c(P = "response probability", information = "item information"))) +
labs(title = "What c and d do to one item (a = 1.3, b = 0.5)",
subtitle = "dashed line: b",
x = expression(theta), y = NULL, color = NULL) +
theme_bw(base_size = 12)Locating each information peak numerically and comparing it with the closed-form predictions from the theory section: \(b + \log\big((1+\sqrt{1+8c})/2\big)/(Da)\) for guessing, its mirror image \(b - \log\big((1+\sqrt{1+8(1-d)})/2\big)/(Da)\) for slipping, and \(b\) itself when \(c = 1-d\).
shift <- function(g, a, D = 1) log((1 + sqrt(1 + 8 * g)) / 2) / (D * a)
predicted <- c(0.5, 0.5 + shift(0.2, 1.3), 0.5 - shift(1 - 0.8, 1.3), 0.5)
gs_peaks <- do.call(rbind, lapply(seq_along(variants), function(i) {
it <- variants[[i]]
opt <- optimize(function(th) compute_ii_dichotomous(th, it)$Ii,
interval = c(-4, 5), maximum = TRUE, tol = 1e-10)
data.frame(version = names(variants)[i], peak = opt$maximum, predicted = predicted[i],
max_information = opt$objective,
ratio_to_2PL_peak = opt$objective / (1.3^2 / 4))
}))
gs_peaks## version peak predicted max_information ratio_to_2PL_peak
## bank 2PL (c=0, d=1) 0.5000000 0.5000000 0.4225000 1.0000000
## bank1 guessing (c=0.2, d=1) 0.7054938 0.7054938 0.2879516 0.6815422
## bank2 slipping (c=0, d=0.8) 0.2945062 0.2945062 0.2879516 0.6815422
## bank3 both (c=0.2, d=0.8) 0.5000000 0.5000000 0.1521000 0.3600000
The guessing item’s information peaks 0.205 above \(b\) and the slipping item’s the same distance below it, both where the formulas say. With both asymptotes and \(c = 1 - d\) the two shifts cancel and the peak returns to \(b\), just as in the item bank above. All three non-2PL versions lose information: 32% of the 2PL peak for \(c = 0.2\) or \(d = 0.8\) alone, and 64% with both, where the peak is \((d-c)^2 =\) 0.36 of the 2PL value. Guessing and slipping of the same size cost the same amount of information, because the slipping item is the guessing item reflected through \((b, 0.5)\).
The guessing formula is checked here for a range of \(c\) at both metric constants, as the difference between the numerical peak and the prediction:
formula_check <- expand.grid(c = c(0.05, 0.1, 0.2, 0.25, 0.35), D = c(1, 1.702))
formula_check$numerical_minus_formula <- mapply(function(g, D) {
it <- c(1.3, 0.5, g, 1)
optimize(function(th) compute_ii_dichotomous(th, it, D = D)$Ii,
interval = c(-4, 5), maximum = TRUE, tol = 1e-10)$maximum - (0.5 + shift(g, 1.3, D))
}, formula_check$c, formula_check$D)
formula_check## c D numerical_minus_formula
## 1 0.05 1.000 -1.276698e-08
## 2 0.10 1.000 -1.962512e-08
## 3 0.20 1.000 1.634635e-09
## 4 0.25 1.000 3.455876e-09
## 5 0.35 1.000 7.003635e-09
## 6 0.05 1.702 1.012235e-11
## 7 0.10 1.702 1.522737e-11
## 8 0.20 1.702 7.030289e-09
## 9 0.25 1.702 1.072704e-08
## 10 0.35 1.702 -1.011230e-08
The differences are at the level of the optimiser’s tolerance, so the
formula in the roxygen note of compute_ii_dichotomous()
holds, including its \(1/D\)
scaling.
Take every pattern with exactly two correct answers out of five (there are 10 of them) and score each under three versions of the Example 1 bank: a 1PL version with every \(a_j = 1\), the original 2PL bank, and a 3PL version with every \(a_j = 1\) and \(c_j = 0.2\).
pairs <- combn(5, 2)
two_correct <- lapply(seq_len(ncol(pairs)), function(k) {
x <- rep(0, 5)
x[pairs[, k]] <- 1
x
})
bank_1pl <- bank_2pl
bank_1pl[, "a"] <- 1
bank_3pl <- bank_1pl
bank_3pl[, "c"] <- 0.2
pattern_table <- data.frame(
pattern = sapply(two_correct, paste, collapse = " "),
weighted_score = sapply(two_correct, function(x) sum(a_2pl * x)),
EAP_1PL = sapply(two_correct, function(x) compute_eap_dichotomous(bank_1pl, x)),
EAP_2PL = sapply(two_correct, function(x) compute_eap_dichotomous(bank_2pl, x)),
EAP_3PL = sapply(two_correct, function(x) compute_eap_dichotomous(bank_3pl, x))
)
pattern_table[order(pattern_table$EAP_2PL), ]## pattern weighted_score EAP_1PL EAP_2PL EAP_3PL
## 10 0 0 0 1 1 1.1176 -0.2762533 -0.55520429 -0.8866599
## 3 1 0 0 1 0 1.3002 -0.2762533 -0.44477592 -0.5250563
## 4 1 0 0 0 1 1.3216 -0.2762533 -0.43186436 -0.6637299
## 6 0 1 0 1 0 1.3564 -0.2762533 -0.41087983 -0.5646559
## 7 0 1 0 0 1 1.3778 -0.2762533 -0.39798249 -0.7072493
## 1 1 1 0 0 0 1.5604 -0.2762533 -0.28811441 -0.3824532
## 8 0 0 1 1 0 1.7338 -0.2762533 -0.18399237 -0.6834367
## 9 0 0 1 0 1 1.7552 -0.2762533 -0.17115028 -0.8332303
## 2 1 0 1 0 0 1.9378 -0.2762533 -0.06158777 -0.4798948
## 5 0 1 1 0 0 1.9940 -0.2762533 -0.02785831 -0.5176594
c(range_1PL = diff(range(pattern_table$EAP_1PL)),
range_2PL = diff(range(pattern_table$EAP_2PL)),
range_3PL = diff(range(pattern_table$EAP_3PL)))## range_1PL range_2PL range_3PL
## 1.665335e-16 5.273460e-01 5.042068e-01
## [1] TRUE
Three models, three different answers to “what does the estimate depend on?”:
The 2PL claim can be made sharper. Under the 2PL two patterns with the same weighted score get the same estimate even when their numbers correct differ. With discriminations 0.5, 0.75 and 1.25 (all exactly representable in binary), getting the first two items right scores \(0.5 + 0.75 = 1.25\), the same as getting only the third right:
bank_w <- matrix(c(0.5, 0.75, 1.25, -1, 0, 1, 0, 0, 0, 1, 1, 1), nrow = 3,
dimnames = list(1:3, c("a", "b", "c", "d")))
bank_w_3pl <- bank_w
bank_w_3pl[, "c"] <- 0.2
data.frame(pattern = c("1 1 0", "0 0 1"),
n_correct = c(2, 1),
weighted_score = c(sum(bank_w[, "a"] * c(1, 1, 0)), sum(bank_w[, "a"] * c(0, 0, 1))),
EAP_2PL = sprintf("%.12f", c(compute_eap_dichotomous(bank_w, c(1, 1, 0)),
compute_eap_dichotomous(bank_w, c(0, 0, 1)))),
EAP_3PL = sprintf("%.12f", c(compute_eap_dichotomous(bank_w_3pl, c(1, 1, 0)),
compute_eap_dichotomous(bank_w_3pl, c(0, 0, 1)))))## pattern n_correct weighted_score EAP_2PL EAP_3PL
## 1 1 1 0 2 1.25 0.163749566152 0.027986838554
## 2 0 0 1 1 1.25 0.163749566152 -0.278655852616
Under the 2PL two correct answers and one correct answer give identical estimates to twelve decimal places; adding a guessing parameter of 0.2 to every item separates them. This is what the discrimination parameter buys and what it costs. Better items count for more, which makes better use of the data; but the estimate can no longer be explained from the score sheet alone, and two examinees with the same number correct can legitimately receive different scores.
Five items are too few for an adaptive test to choose anything, so this example generates a 40-item 4PL bank with discriminations between 0.8 and 2, difficulties spread around 0, guessing up to 0.25 and upper asymptotes between 0.9 and 1.
set.seed(2024)
n_items <- 40
bank_cat <- cbind(a = round(runif(n_items, 0.8, 2.0), 2),
b = round(rnorm(n_items, 0, 1.2), 2),
c = round(runif(n_items, 0, 0.25), 2),
d = round(runif(n_items, 0.9, 1.0), 2))
rownames(bank_cat) <- 1:n_items
head(bank_cat)## a b c d
## 1 1.80 1.37 0.24 0.91
## 2 1.19 0.38 0.02 0.98
## 3 1.62 1.25 0.20 0.98
## 4 1.64 -0.26 0.03 0.92
## 5 1.35 0.48 0.18 0.98
## 6 1.64 -1.85 0.00 0.98
At each step the loop picks the most informative unadministered item at the current estimate, simulates a response from an examinee with true ability 1, and rescores.
set.seed(1)
true_theta <- 1.0
# simulate a 0/1 response to item j from an examinee at theta
simulate_response <- function(j, theta) {
p <- compute_pi_dichotomous(theta, bank_cat[j, , drop = FALSE])$Pi
rbinom(1, 1, p)
}
theta <- 0
administered <- integer(0)
responses <- integer(0)
trace <- data.frame()
for (step in 1:20) {
sel <- compute_next_item_dichotomous(theta, bank_cat, out = administered)
theta_before <- theta
administered <- c(administered, sel$item)
responses <- c(responses, simulate_response(sel$item, true_theta))
sub_bank <- bank_cat[administered, , drop = FALSE]
theta <- compute_eap_dichotomous(sub_bank, responses)
se <- compute_eap_se_dichotomous(theta, sub_bank, responses)
trace <- rbind(trace, data.frame(step = step, theta_before = theta_before, item = sel$item,
a = bank_cat[sel$item, "a"], b = bank_cat[sel$item, "b"],
information = unname(sel$info),
response = tail(responses, 1),
theta_EAP = theta, SE = se))
}
rownames(trace) <- NULL
trace## step theta_before item a b information response theta_EAP SE
## 1 1 0.0000000 38 1.96 -0.29 0.7741458 1 0.4994706 0.8148179
## 2 2 0.4994706 28 1.78 0.80 0.5790472 1 0.9300785 0.7631718
## 3 3 0.9300785 23 1.62 0.59 0.4768977 1 1.2003800 0.6954405
## 4 4 1.2003800 3 1.62 1.25 0.4068768 0 0.9162451 0.6117131
## 5 5 0.9162451 16 1.81 0.47 0.4577606 1 1.0560148 0.5790796
## 6 6 1.0560148 27 1.54 0.39 0.3502425 0 0.7679117 0.5393279
## 7 7 0.7679117 40 1.58 -0.04 0.3854264 0 0.4756732 0.5150931
## 8 8 0.4756732 12 1.94 -0.39 0.4165912 1 0.5641784 0.4789634
## 9 9 0.5641784 2 1.19 0.38 0.3220779 1 0.6734847 0.4652500
## 10 10 0.6734847 5 1.35 0.48 0.3029440 1 0.7570965 0.4581241
## 11 11 0.7570965 14 1.42 1.45 0.2643348 0 0.6795539 0.4370484
## 12 12 0.6795539 4 1.64 -0.26 0.2486879 1 0.7342323 0.4235285
## 13 13 0.7342323 20 1.15 0.51 0.2199065 0 0.6362468 0.4106558
## 14 14 0.6362468 26 1.15 0.16 0.2011803 1 0.6809798 0.4067322
## 15 15 0.6809798 8 1.16 0.82 0.1936899 0 0.6044335 0.3956015
## 16 16 0.6044335 13 1.64 -0.61 0.1826315 1 0.6371167 0.3872493
## 17 17 0.6371167 7 1.30 -0.14 0.1770243 1 0.6778974 0.3819418
## 18 18 0.6778974 1 1.80 1.37 0.1841058 1 0.7527370 0.3893765
## 19 19 0.7527370 22 0.93 -0.01 0.1609148 1 0.7923930 0.3864475
## 20 20 0.7923930 37 0.86 1.07 0.1509293 1 0.8559116 0.3851877
catR::nextItem() is then asked the same question at
every step: the same estimate, the same items already given. The check
runs after the loop rather than inside it because
catR::nextItem() draws from the random-number stream even
when there is nothing to draw between, and calling it inside the loop
would change the simulated responses:
set.seed(1); invisible(catR::nextItem(bank_cat, theta = 0, criterion = "MFI")); after_catR <- runif(1)
set.seed(1); untouched <- runif(1)
c(runif_after_catR_nextItem = after_catR, runif_untouched = untouched)## runif_after_catR_nextItem runif_untouched
## 0.2544406 0.2655087
trace$catR_item <- sapply(seq_len(nrow(trace)), function(k)
catR::nextItem(bank_cat, theta = trace$theta_before[k],
out = administered[seq_len(k - 1)], criterion = "MFI")$item)
all(trace$item == trace$catR_item)## [1] TRUE
c(final_mine = theta,
final_catR = catR::eapEst(bank_cat[administered, ], responses),
se_mine = se,
se_catR = catR::eapSem(theta, bank_cat[administered, ], responses))## final_mine final_catR se_mine se_catR
## 0.8559116 0.8559116 0.3851877 0.3851877
ggplot(trace, aes(x = step, y = theta_EAP)) +
geom_hline(yintercept = true_theta, linetype = "dashed", color = "grey40") +
geom_ribbon(aes(ymin = theta_EAP - SE, ymax = theta_EAP + SE),
alpha = 0.2, fill = "steelblue") +
geom_line(color = "steelblue", linewidth = 1) +
geom_point(size = 2, color = "steelblue") +
annotate("text", x = 1, y = true_theta + 0.08, hjust = 0,
label = "true theta", color = "grey40", size = 3.5) +
scale_x_continuous(breaks = trace$step) +
labs(title = "Adaptive test: EAP estimate (± 1 SE) after each item",
x = "item administered", y = expression(hat(theta)[EAP])) +
theme_bw(base_size = 12)c(first_item_a = trace$a[1],
mean_a_first_5 = mean(trace$a[1:5]),
mean_a_last_5 = mean(trace$a[16:20]),
mean_a_bank = mean(bank_cat[, "a"]),
steps_where_SE_rose = sum(diff(trace$SE) > 0))## first_item_a mean_a_first_5 mean_a_last_5 mean_a_bank steps_where_SE_rose
## 1.96000 1.75800 1.30600 1.41025 1.00000
## [1] 18
catR picks the same item at every one of the 20 steps,
and the final estimate and standard error agree with
catR’s. After 20 items the estimate is 0.856 with a
standard error of 0.385, down from 0.815 after the first item. The fall
is not strictly monotone: the standard error rose 1 time(s), at step(s)
18. For dichotomous items a response that contradicts the current
estimate can widen the posterior, so a posterior standard deviation,
unlike the asymptotic \(1/\sqrt{\text{test
information}}\), need not shrink with every item.
The item choices show the discrimination at work. MFI opens with the most informative item at \(\theta = 0\), which has \(a =\) 1.96, and the first five items average \(a =\) 1.76 against a bank average of 1.41. By the last five steps the best items near the estimate are used up and the average has fallen to 1.31. Pure MFI spends the strongest items first.
Pure MFI is deterministic: every examinee starting at \(\theta = 0\) receives the same first item.
The randomesque argument draws at random from the \(k\) best instead.
set.seed(42)
exposure_1 <- table(replicate(2000, compute_next_item_dichotomous(theta = 0, bank_cat, randomesque = 1)$item))
exposure_5 <- table(replicate(2000, compute_next_item_dichotomous(theta = 0, bank_cat, randomesque = 5)$item))
exposure_1##
## 38
## 2000
##
## 4 12 18 38 40
## 396 419 382 399 404
With randomesque = 1 item 38 is chosen 2000 times out of
2000. With randomesque = 5 the load is spread over 5 items,
each chosen between 382 and 419 times. In a 4PL bank the price is higher
than in a Rasch-family bank, because the runners-up are not just
slightly off target but genuinely less informative items:
info_at_0 <- compute_ii_dichotomous(theta = 0, bank_cat)$Ii
top_5 <- sort(info_at_0, decreasing = TRUE)[1:5]
round(top_5, 3)## 38 12 40 4 18
## 0.774 0.746 0.564 0.496 0.428
c(best_item = max(info_at_0),
mean_of_top_5 = mean(top_5),
proportion_of_best = mean(top_5) / max(info_at_0))## best_item mean_of_top_5 proportion_of_best
## 0.7741458 0.6015497 0.7770497
Spreading the first item over the five best retains on average 78% of the information of always taking the single best: the standard trade-off between measurement efficiency and item security, and one that is steeper when discriminations vary.
The EAP integrals are approximations, and four arguments control them. None of these is a free choice to make casually. The all-correct pattern on the item bank is the hardest case, because its likelihood never comes back down:
x <- c(1, 1, 1, 1, 1)
settings <- rbind(
data.frame(setting = "quadrature points", value = c(11, 33, 101, 501),
theta = sapply(c(11, 33, 101, 501),
function(n) compute_eap_dichotomous(bank, x, nqp = n))),
data.frame(setting = "integration bound", value = c(3, 4, 6),
theta = sapply(c(3, 4, 6),
function(b) compute_eap_dichotomous(bank, x, lower = -b, upper = b))),
data.frame(setting = "prior SD", value = c(1, 2),
theta = sapply(c(1, 2),
function(s) compute_eap_dichotomous(bank, x, priorPar = c(0, s)))),
data.frame(setting = "metric constant D", value = c(1, 1.702),
theta = sapply(c(1, 1.702),
function(d) compute_eap_dichotomous(bank, x, D = d)))
)
settings## setting value theta
## 1 quadrature points 11.000 1.359345
## 2 quadrature points 33.000 1.360748
## 3 quadrature points 101.000 1.360896
## 4 quadrature points 501.000 1.360912
## 5 integration bound 3.000 1.305759
## 6 integration bound 4.000 1.360748
## 7 integration bound 6.000 1.363365
## 8 prior SD 1.000 1.360748
## 9 prior SD 2.000 2.521099
## 10 metric constant D 1.000 1.360748
## 11 metric constant D 1.702 1.581814
Three different kinds of effect are visible here, and they should not be confused with one another:
catR.All eight functions reproduce their catR counterparts to
machine precision: the normal density and the trapezoidal integrator in
cat_eap_common.R, and in cat_eap_dichotomous.R
the 4PL probabilities with their three derivatives, the item information
with its two derivatives, MFI item selection with and without excluded
items, the likelihood, and the two integrals that define EAP scoring.
Finite differences confirm the derivative formulas independently of
catR, at both metric constants. Together the functions run
a complete dichotomous adaptive test in a few hundred lines of base
R.
Written out, the model’s character is easy to see. The discrimination sets how useful an item can be, so information curves differ in height and MFI has to compute them all rather than look for the nearest difficulty; it also makes the response pattern, not only the number correct, drive the estimate. The guessing and slipping asymptotes lower the information and pull its peak away from \(b\), in opposite directions, in the amounts the closed-form expression predicts. The probability floor keeps the information finite when a probability rounds to 1. And the prior does what it does in every EAP model: it keeps estimates and standard errors finite for the all-correct and all-incorrect patterns that short tests produce routinely, at the cost of a known, deliberate pull toward the prior mean.
Rendered with R 4.6.1 · validated against catR 3.17