The companion vignette EAP Ability Estimation From Scratch builds the whole EAP pipeline for dichotomous items scored right/wrong under the 4PL model. This vignette does the same job for polytomous items — a Likert scale scored 0, 1, 2, 3, 4 — under Andrich’s Rating Scale Model (RSM), and adds the two pieces an adaptive test actually needs on top of scoring: item information and next-item selection.
working_examples/cat_eap_rsm.R
implements the full pipeline in eight small functions, in base R, with
no dependency on catR:
| Function | Purpose | catR equivalent it mirrors |
|---|---|---|
density_function() |
Normal density | stats::dnorm() |
integrate_cat() |
Numerical integration (trapezoidal rule) | catR::integrate.catR() |
Pi_rsm() |
Category response probabilities + derivatives | catR::Pi(model="RSM") |
Ii_rsm() |
Item information | catR::Ii(model="RSM") |
next_item_rsm() |
Maximum Fisher information item selection | catR::nextItem(criterion="MFI") |
likelihood_rsm() |
Likelihood of a polytomous response pattern | (internal to catR) |
eap_est_rsm() |
EAP ability estimate | catR::eapEst(model="RSM") |
eap_se_rsm() |
EAP standard error | catR::eapSem(model="RSM") |
This vignette walks through the theory behind each function,
validates every one of them against its catR counterpart,
and then uses them to run a small adaptive test end to end.
A dichotomous model needs one curve per item: the probability of a correct response. A polytomous item scored \(0,1,\ldots,m\) needs \(m+1\) curves — one per category — that sum to 1 at every ability level. The Rating Scale Model builds those curves from a step-by-step argument: to land in category \(k\) you must have passed each of the \(k\) thresholds below it.
\[P(X_j = k \mid \theta) = \frac{\exp\left[\sum_{t=1}^{k} D\big(\theta - (\lambda_j + \delta_t)\big)\right]}{\sum_{h=0}^{m}\exp\left[\sum_{t=1}^{h} D\big(\theta - (\lambda_j + \delta_t)\big)\right]}\]
with the empty sum for \(k=0\) taken to be zero.
That shared-threshold assumption is the whole point of the model, and it is what separates the RSM from the Partial Credit Model (PCM). Under the PCM each item gets its own set of thresholds \(\delta_{j1},\ldots,\delta_{jm}\); under the RSM the rating scale is assumed to work the same way for every item — “agree” means the same distance above an item’s location whether the item is easy or hard. This is the natural assumption for a questionnaire where every item shares one response format (“strongly disagree” … “strongly agree”), and it buys a large reduction in the number of parameters: \(n + m\) instead of \(n \times m\).
The layout cat_eap_rsm.R expects encodes this directly:
column 1 of bank holds \(\lambda_j\), columns 2 to \(m+1\) hold \(\delta_1,\ldots,\delta_m\) — and those
threshold columns are identical down every row. An item
with fewer categories than the widest item is padded with
NA.
For a dichotomous item, Fisher information is the familiar \(I_j(\theta) = P'^2 / \big(P(1-P)\big)\). The polytomous generalisation sums the same quantity over categories:
\[I_j(\theta) = \sum_{k=0}^{m} \frac{\big[\partial P_{jk}(\theta)/\partial\theta\big]^2}{P_{jk}(\theta)}\]
These are not two different formulas. For \(m=1\) the two categories are \(P\) and \(1-P\), with derivatives \(P'\) and \(-P'\), so the sum collapses to \(P'^2/P + P'^2/(1-P) =
P'^2/\big(P(1-P)\big)\) — the dichotomous case exactly. This
is why Pi_rsm() returns the derivatives alongside the
probabilities: Ii_rsm() is a one-line consumer of them.
Test information is the sum of item informations, and
its reciprocal square root is the asymptotic standard error of a
maximum-likelihood ability estimate.
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. Because the RSM gives every item the same thresholds, every item information curve is the same function shifted by \(\lambda_j\): \(I_j(\theta) = f(\theta - \lambda_j)\) for one common \(f\). Two consequences, both verified numerically below:
next_item_rsm() also exposes a randomesque
argument: drawing at random from the \(k\) most informative items rather than
always taking the single best. This is standard exposure control — pure
MFI would administer the same handful of items to almost everyone, which
is both a security problem and a waste of the bank.
Under local independence, the likelihood of a response pattern \(\mathbf{x} = (x_1,\ldots,x_n)\), with each \(x_i \in \{0,\ldots,m\}\), is
\[L(\theta) = \prod_{i=1}^{n} P_{i,x_i}(\theta)\]
This is the polytomous counterpart of \(\prod P_i^{x_i}(1-P_i)^{1-x_i}\). The
dichotomous version uses the exponents \(x_i\) and \(1-x_i\) as a switch to pick one of
two probabilities; with more than two categories there is nothing to
switch between, so the response simply indexes the right column of the
probability matrix. The x[i]+1 in the code is index
bookkeeping: categories start at 0, R columns start at 1.
Exactly as in the dichotomous case, a prior \(\pi(\theta)\) on ability (standard normal by default) turns the likelihood into a posterior, and EAP reports its mean and standard deviation:
\[\hat\theta_{EAP} = \frac{\displaystyle\int \theta\,\pi(\theta)\,L(\theta)\,d\theta}{\displaystyle\int \pi(\theta)\,L(\theta)\,d\theta} \qquad 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}}\]
Both integrals are evaluated on a grid of nqp quadrature
points (default 33) spanning lower to upper
(default \(\pm 4\)) and integrated with
the trapezoidal rule. The motivation is unchanged: Maximum Likelihood
has no finite maximum for a pattern in which every response sits in the
lowest or the highest category, whereas the normal prior guarantees both
integrals converge.
The RSM has no discrimination parameter — it is a member of the Rasch family. Writing out \(L(\theta)\) for the RSM shows that \(\theta\) enters the numerator only through \(D\,\theta \sum_i x_i\): the response pattern touches the likelihood only through its total score. The raw sum score is therefore a sufficient statistic for \(\theta\), and two examinees with the same total get the identical EAP estimate no matter which items they endorsed. This is a sharp contrast with the 4PL vignette, where differing discriminations make the pattern itself informative, and it is demonstrated to twelve decimal places below.
A five-item, five-category bank: \(\lambda_j\) varies across items, the four thresholds are common to all of them.
lambdaj <- c(-0.560, -0.230, 1.559, 0.071, 0.129)
delta1 <- rep( 1.715, 5)
delta2 <- rep( 0.461, 5)
delta3 <- rep(-1.265, 5)
delta4 <- rep(-0.687, 5)
bank <- matrix(c(lambdaj, delta1, delta2, delta3, delta4), nrow = 5,
dimnames = list(1:5, c("lambdaj", "delta1", "delta2", "delta3", "delta4")))
bank## lambdaj delta1 delta2 delta3 delta4
## 1 -0.560 1.715 0.461 -1.265 -0.687
## 2 -0.230 1.715 0.461 -1.265 -0.687
## 3 1.559 1.715 0.461 -1.265 -0.687
## 4 0.071 1.715 0.461 -1.265 -0.687
## 5 0.129 1.715 0.461 -1.265 -0.687
Note the threshold columns are constant down each column — that is the rating scale assumption made concrete.
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
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
## cat0 cat1 cat2 cat3 cat4
## Item1 0.088271 0.027810 0.030705 0.190454 0.662760
## Item2 0.237320 0.053754 0.042666 0.190264 0.475996
## Item3 0.954031 0.036115 0.004791 0.003570 0.001493
## Item4 0.456043 0.076446 0.044907 0.148204 0.274400
## Item5 0.500987 0.079248 0.043929 0.136808 0.239028
## [1] TRUE
## [1] TRUE
The derivatives matter as much as the probabilities —
Ii_rsm() is built entirely from them — so both are checked.
The metric constant is checked too, since D enters every
exponent:
c(Pi = all.equal(Pi_rsm(0.7, bank, D = 1.702)$Pi,
catR::Pi(0.7, bank, model = "RSM", D = 1.702)$Pi),
dPi = all.equal(Pi_rsm(0.7, bank, D = 1.702)$dPi,
catR::Pi(0.7, bank, model = "RSM", D = 1.702)$dPi))## Pi dPi
## TRUE TRUE
theta_grid <- seq(-6, 6, by = 0.1)
curves <- t(sapply(theta_grid, function(th) Pi_rsm(theta = th, bank)$Pi[1, ]))
df_curves <- data.frame(theta = theta_grid, curves)
names(df_curves)[-1] <- paste0("category ", 0:4)
df_long <- reshape2::melt(df_curves, id.vars = "theta",
variable.name = "category", value.name = "P")
ggplot(df_long, aes(x = theta, y = P, color = category)) +
geom_line(linewidth = 1) +
labs(title = "Category response curves, item 1",
subtitle = "probability of each of the five response categories as a function of ability",
x = expression(theta), y = expression(P(theta)), color = NULL) +
theme_bw(base_size = 12)These five curves sum to 1 at every \(\theta\), and the extremes behave as they must: category 0 dominates at low ability, category 4 at high ability. The middle three, however, never dominate anywhere — the section on threshold ordering below explains why, because this is a property of the bank rather than of the model.
data.frame(mine = Ii_rsm(theta = 0, bank)$Ii,
catR = as.numeric(catR::Ii(th = 0, bank, model = "RSM")$Ii))## mine catR
## 1 1.5020380 1.5020380
## 2 2.7204563 2.7204563
## 3 0.1074066 0.1074066
## 4 3.0614345 3.0614345
## 5 2.9586254 2.9586254
## [1] TRUE
info_grid <- seq(-4, 4, by = 0.05)
info <- t(sapply(info_grid, function(th) Ii_rsm(th, bank)$Ii))
df_info <- data.frame(theta = info_grid, info)
names(df_info)[-1] <- paste0("item ", 1:5, " (λ=", lambdaj, ")")
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 = "identical in shape, shifted only by the item location",
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)The claim that every curve is one common function shifted by \(\lambda_j\) is easy to check directly — locate each peak numerically:
peaks <- t(sapply(1:5, function(j) {
opt <- optimize(function(th) Ii_rsm(th, bank[j, , drop = FALSE])$Ii,
interval = c(-6, 6), maximum = TRUE)
c(peak = opt$maximum, max_information = opt$objective)
}))
data.frame(lambdaj = lambdaj, peaks, offset = peaks[, "peak"] - lambdaj)## lambdaj peak max_information offset
## 1 -0.560 -0.5532804 3.113338 0.006719555
## 2 -0.230 -0.2232778 3.113338 0.006722239
## 3 1.559 1.5657125 3.113338 0.006712512
## 4 0.071 0.0777152 3.113338 0.006715197
## 5 0.129 0.1357230 3.113338 0.006723003
Every item reaches the same maximum information, at a point a constant offset above its own \(\lambda_j\). The bank has no strong and weak items — only items aimed at different parts of the scale.
mine <- next_item_rsm(theta = 0, bank)
theirs <- catR::nextItem(bank, model = "RSM", theta = 0, criterion = "MFI")
c(mine_item = mine$item, catR_item = theirs$item,
mine_info = mine$info, catR_info = theirs$info)## mine_item catR_item mine_info catR_info
## 4.000000 4.000000 3.061434 3.061434
With items already administered excluded via out:
c(mine = next_item_rsm(theta = 1.5, bank, out = c(4, 5))$item,
catR = catR::nextItem(bank, model = "RSM", theta = 1.5, out = c(4, 5),
criterion = "MFI")$item)## mine catR
## 3 3
And the nearest-neighbour shortcut, checked across the whole ability range:
grid <- seq(-4, 4, by = 0.05)
mfi <- sapply(grid, function(th) which.max(Ii_rsm(th, bank)$Ii))
nn <- sapply(grid, function(th) which.min(abs(th - lambdaj)))
c(agreements = sum(mfi == nn), grid_points = length(grid))## agreements grid_points
## 159 161
## [1] 0.10 0.85
MFI and “pick the item whose \(\lambda_j\) is closest to \(\theta\)” agree almost everywhere. The two disagreements are not errors: the information peak sits a constant ~0.0067 above \(\lambda_j\), so the exact rule is nearest-neighbour on \(\lambda_j\) relative to \(\theta - 0.0067\), and the two rules can only differ inside a band that thin around the midpoint between two adjacent item locations.
catR has no exported likelihood function to compare
against, so the check reconstructs the same product from
catR’s own probability matrix:
response <- c(4, 3, 2, 1, 0)
P_catR <- catR::Pi(th = 0.3, bank, model = "RSM")$Pi
manual <- prod(sapply(1:5, function(i) P_catR[i, response[i] + 1]))
c(likelihood_rsm = likelihood_rsm(theta = 0.3, bank = bank, x = response),
from_catR_Pi = manual)## likelihood_rsm from_catR_Pi
## 1.873222e-05 1.873222e-05
lik_grid <- seq(-4, 4, by = 0.05)
df_lik <- data.frame(theta = lik_grid,
likelihood = sapply(lik_grid, function(th)
likelihood_rsm(th, bank, response)))
ggplot(df_lik, aes(x = theta, y = likelihood)) +
geom_line(linewidth = 1, color = "steelblue") +
labs(title = "Likelihood of the response pattern (4, 3, 2, 1, 0)",
x = expression(theta), y = "likelihood") +
theme_bw(base_size = 12)patterns <- list(c(0, 0, 0, 0, 0), c(1, 1, 1, 1, 1), c(2, 2, 2, 2, 2),
c(3, 3, 3, 3, 3), c(4, 4, 4, 4, 4), c(4, 3, 2, 1, 0))
validation <- do.call(rbind, lapply(patterns, function(x) {
th_mine <- eap_est_rsm(bank, x)
th_catR <- catR::eapEst(bank, x, model = "RSM")
data.frame(pattern = paste(x, collapse = " "),
eap_mine = th_mine,
eap_catR = th_catR,
se_mine = eap_se_rsm(th_mine, bank, x),
se_catR = catR::eapSem(th_mine, bank, x, model = "RSM"))
}))
validation## pattern eap_mine eap_catR se_mine se_catR
## 1 0 0 0 0 0 -1.31916068 -1.31916068 0.5754749 0.5754749
## 2 1 1 1 1 1 -0.41842987 -0.41842987 0.3354186 0.3354186
## 3 2 2 2 2 2 0.09165378 0.09165378 0.3218065 0.3218065
## 4 3 3 3 3 3 0.74387510 0.74387510 0.4137023 0.4137023
## 5 4 4 4 4 4 1.86908014 1.86908014 0.5508972 0.5508972
## 6 4 3 2 1 0 0.09165378 0.09165378 0.3218065 0.3218065
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
## 0.000000e+00 1.110223e-16
Both the point estimate and the standard error reproduce
catR to machine precision across every pattern, including
the two extreme ones where Maximum Likelihood would have failed
outright.
Five response patterns, all summing to 10, all endorsing completely different items:
same_total <- list(c(2, 2, 2, 2, 2), c(4, 3, 2, 1, 0), c(0, 1, 2, 3, 4),
c(4, 4, 2, 0, 0), c(3, 3, 2, 1, 1))
data.frame(pattern = sapply(same_total, paste, collapse = " "),
raw_score = sapply(same_total, sum),
theta_EAP = sprintf("%.12f", sapply(same_total, function(x) eap_est_rsm(bank, x))))## pattern raw_score theta_EAP
## 1 2 2 2 2 2 10 0.091653777567
## 2 4 3 2 1 0 10 0.091653777567
## 3 0 1 2 3 4 10 0.091653777567
## 4 4 4 2 0 0 10 0.091653777567
## 5 3 3 2 1 1 10 0.091653777567
Identical to twelve decimal places. This is the Rasch-family property in action: with no discrimination parameter, \(\theta\) enters the likelihood only through the total score, so the pattern carries no information beyond its sum. Whether the examinee gave a middling answer to every item or a maximal answer to some and a minimal answer to others makes no difference at all to the estimate.
This is genuinely a modelling choice, not a limitation to work around. It is what makes RSM scores easy to explain and easy to defend — the score sheet and the model agree — and it is exactly what a 2PL/4PL model gives up in exchange for letting better items count for more.
uniform <- list(c(0, 0, 0, 0, 0), c(1, 1, 1, 1, 1), c(2, 2, 2, 2, 2),
c(3, 3, 3, 3, 3), c(4, 4, 4, 4, 4))
results <- data.frame(
raw_score = sapply(uniform, sum),
theta_EAP = sapply(uniform, function(x) eap_est_rsm(bank, x))
)
results$SE <- mapply(function(x, th) eap_se_rsm(th, bank, x), uniform, results$theta_EAP)
results## raw_score theta_EAP SE
## 1 0 -1.31916068 0.5754749
## 2 5 -0.41842987 0.3354186
## 3 10 0.09165378 0.3218065
## 4 15 0.74387510 0.4137023
## 5 20 1.86908014 0.5508972
ggplot(results, aes(x = raw_score, 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. raw score",
x = "raw score (out of 20)", y = expression(hat(theta)[EAP])) +
theme_bw(base_size = 12)The estimate rises monotonically with the raw score and never approaches the integration bounds of \(\pm 4\), even for an all-minimum or an all-maximum pattern — the prior pulls the posterior mean back toward zero. That regularisation is the whole 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.
Note also that the standard error is largest at the extremes. A perfect or a zero score is compatible with a wide range of high (or low) abilities, so the posterior stays broad; a mid-range score pins \(\theta\) down much more tightly against this bank.
eap_se_rsm() 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, which is necessarily larger.
se_grid <- seq(-3, 3, by = 0.05)
se_patterns <- list("all lowest (0 0 0 0 0)" = c(0, 0, 0, 0, 0),
"all highest (4 4 4 4 4)" = c(4, 4, 4, 4, 4),
"mixed (4 3 2 1 0)" = c(4, 3, 2, 1, 0))
se_df <- do.call(rbind, lapply(names(se_patterns), function(nm) {
data.frame(theta = se_grid,
SE = sapply(se_grid, function(th)
eap_se_rsm(theta = th, bank = bank, x = se_patterns[[nm]])),
pattern = nm)
}))
eap_points <- do.call(rbind, lapply(names(se_patterns), function(nm) {
th <- eap_est_rsm(bank, se_patterns[[nm]])
data.frame(theta = th, SE = eap_se_rsm(th, bank, se_patterns[[nm]]), pattern = nm)
}))
eap_points## theta SE pattern
## 1 -1.31916068 0.5754749 all lowest (0 0 0 0 0)
## 2 1.86908014 0.5508972 all highest (4 4 4 4 4)
## 3 0.09165378 0.3218065 mixed (4 3 2 1 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 is minimised exactly at its own EAP estimate — that is the
defining property of a mean as a least-squares summary of a
distribution. In practice the two functions are always paired,
eap_se_rsm(eap_est_rsm(bank, x), bank, x), and the free
theta argument is useful mainly for showing what the
pairing is doing.
Everything above assembles into an adaptive loop. At each step, pick the most informative unadministered item at the current estimate, collect a response, rescore, repeat.
set.seed(1)
true_theta <- 1.0
# simulate a response to item j from an examinee at true_theta
simulate_response <- function(j, theta) {
p <- Pi_rsm(theta, bank[j, , drop = FALSE])$Pi[1, ]
sample(0:(length(p) - 1), size = 1, prob = p)
}
theta <- 0
administered <- integer(0)
responses <- integer(0)
trace <- data.frame()
for (step in 1:5) {
sel <- next_item_rsm(theta, bank, out = administered)
administered <- c(administered, sel$item)
responses <- c(responses, simulate_response(sel$item, true_theta))
sub_bank <- bank[administered, , drop = FALSE]
theta <- eap_est_rsm(sub_bank, responses)
se <- eap_se_rsm(theta, sub_bank, responses)
trace <- rbind(trace, data.frame(step = step, item = sel$item,
lambdaj = bank[sel$item, 1],
information = sel$info,
response = tail(responses, 1),
theta_EAP = theta, SE = se))
}
trace## step item lambdaj information response theta_EAP SE
## 1 1 4 0.071 3.0614345 4 0.8477370 0.6921008
## 2 2 5 0.129 1.0283671 4 1.0943270 0.6323577
## 3 3 3 1.559 1.7794852 0 0.8268936 0.4615481
## 4 4 2 -0.230 0.4439297 3 0.7069395 0.4202238
## 5 5 1 -0.560 0.2744511 4 0.7438751 0.4137023
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)The standard error shrinks as items accumulate. Item selection also behaves as the theory predicts — starting from \(\theta = 0\) the loop first reaches for the items whose \(\lambda_j\) sits nearest zero, then moves outward as the estimate settles.
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.
##
## 4
## 2000
##
## 2 4 5
## 679 657 664
With randomesque = 1 the same item is chosen 2000 times
out of 2000. With randomesque = 3 the load is spread evenly
over the three best-targeted items. The cost is small:
info_at_0 <- Ii_rsm(theta = 0, bank)$Ii
c(best_item = max(info_at_0),
mean_of_top_3 = mean(sort(info_at_0, decreasing = TRUE)[1:3]),
proportion_of_best = mean(sort(info_at_0, decreasing = TRUE)[1:3]) / max(info_at_0))## best_item mean_of_top_3 proportion_of_best
## 3.0614345 2.9135054 0.9516798
Spreading exposure over the three best items retains about 95% of the information of always taking the single best — the standard trade-off between measurement efficiency and item security.
The thresholds in this bank are \(1.715, 0.461, -1.265, -0.687\) — not increasing. Disordered thresholds are common in real data and mean that some categories are never the most likely response at any ability level.
modal_category <- function(b) {
th <- seq(-8, 8, by = 0.02)
sort(unique(sapply(th, function(t) which.max(Pi_rsm(t, b)$Pi[1, ])))) - 1
}
bank_ordered <- bank
bank_ordered[, 2:5] <- matrix(rep(sort(bank[1, 2:5]), each = 5), nrow = 5)
list(disordered = modal_category(bank),
ordered = modal_category(bank_ordered))## $disordered
## [1] 0 4
##
## $ordered
## [1] 0 1 2 3 4
With the thresholds as given, only categories 0 and 4 are ever modal — the model says the middle three responses are never the single most likely answer for anybody. Sorting the thresholds into increasing order makes every category modal over some stretch of the scale:
curves_for <- function(b, label) {
g <- seq(-6, 6, by = 0.05)
m <- t(sapply(g, function(th) Pi_rsm(th, b)$Pi[1, ]))
d <- data.frame(theta = g, m)
names(d)[-1] <- paste0("category ", 0:4)
out <- reshape2::melt(d, id.vars = "theta",
variable.name = "category", value.name = "P")
out$thresholds <- label
out
}
both <- rbind(curves_for(bank, "as given (disordered)"),
curves_for(bank_ordered, "sorted (ordered)"))
ggplot(both, aes(x = theta, y = P, color = category)) +
geom_line(linewidth = 0.9) +
facet_wrap(~ thresholds) +
labs(title = "Category response curves, item 1",
subtitle = "effect of threshold ordering on whether middle categories are ever modal",
x = expression(theta), y = expression(P(theta)), color = NULL) +
theme_bw(base_size = 12)The information consequence runs the other way:
g <- seq(-8, 8, by = 0.02)
c(peak_test_information_disordered = max(sapply(g, function(t) sum(Ii_rsm(t, bank)$Ii))),
peak_test_information_ordered = max(sapply(g, function(t) sum(Ii_rsm(t, bank_ordered)$Ii))))## peak_test_information_disordered peak_test_information_ordered
## 10.469730 4.138598
Disordered thresholds concentrate the scale’s discriminating power into effectively fewer, wider steps, which raises peak information — the item behaves more like a sharp dichotomy than a graded scale. Ordered thresholds spread information over a broader ability range instead. Higher peak information is not automatically better here: it is bought by the middle categories doing no distinct work, which is usually a sign the response format is not being used as intended rather than a well-targeted item.
Items need not all share a category count. A four-point item in a
five-point bank pads its unused threshold with NA, and its
responses are then scored 0 to 3.
## lambdaj delta1 delta2 delta3 delta4
## 1 -0.560 1.715 0.461 -1.265 -0.687
## 2 -0.230 1.715 0.461 -1.265 -0.687
## 3 1.559 1.715 0.461 -1.265 -0.687
## 4 0.071 1.715 0.461 -1.265 -0.687
## 5 0.129 1.715 0.461 -1.265 NA
## cat0 cat1 cat2 cat3 cat4
## Item1 0.088271 0.027810 0.030705 0.190454 0.662760
## Item2 0.237320 0.053754 0.042666 0.190264 0.475996
## Item3 0.954031 0.036115 0.004791 0.003570 0.001493
## Item4 0.456043 0.076446 0.044907 0.148204 0.274400
## Item5 0.658351 0.104140 0.057728 0.179781 NA
Item 5’s cat4 is NA — the category does not
exist — and its remaining four probabilities still sum to 1. Scoring
works unchanged:
response_mixed <- c(4, 3, 2, 1, 3) # item 5 can only be scored 0 to 3
th <- eap_est_rsm(bank_mixed, response_mixed)
c(eap_mine = th,
eap_catR = catR::eapEst(bank_mixed, response_mixed, model = "RSM"),
se_mine = eap_se_rsm(th, bank_mixed, response_mixed),
se_catR = catR::eapSem(th, bank_mixed, response_mixed, model = "RSM"))## eap_mine eap_catR se_mine se_catR
## 0.6159947 0.6159947 0.3986684 0.3986684
The EAP integrals are approximations, and four arguments control them. None of these is a free choice to make casually.
x <- c(4, 4, 4, 4, 4) # the hardest case for numerical integration
rbind(
data.frame(setting = "quadrature points", value = c(11, 33, 101, 501),
theta = sapply(c(11, 33, 101, 501),
function(n) eap_est_rsm(bank, x, nqp = n))),
data.frame(setting = "integration bound", value = c(3, 4, 6),
theta = sapply(c(3, 4, 6),
function(b) eap_est_rsm(bank, x, lower = -b, upper = b))),
data.frame(setting = "prior SD", value = c(1, 2),
theta = sapply(c(1, 2),
function(s) eap_est_rsm(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) eap_est_rsm(bank, x, D = d)))
)## setting value theta
## 1 quadrature points 11.000 1.862764
## 2 quadrature points 33.000 1.869080
## 3 quadrature points 101.000 1.869194
## 4 quadrature points 501.000 1.869207
## 5 integration bound 3.000 1.825106
## 6 integration bound 4.000 1.869080
## 7 integration bound 6.000 1.871100
## 8 prior SD 1.000 1.869080
## 9 prior SD 2.000 2.569259
## 10 metric constant D 1.000 1.869080
## 11 metric constant D 1.702 1.947546
Three different kinds of effect are visible here, and they should not be confused with one another:
catR.All eight functions in cat_eap_rsm.R reproduce their
catR counterparts to machine precision: the normal density,
the trapezoidal integrator, RSM category probabilities and their
derivatives, item information, MFI item selection, and the two integrals
that define EAP scoring. Together they run a complete polytomous
adaptive test in a few hundred lines of base R.
Seeing it written out makes the model’s character visible in a way a package call does not. The RSM’s shared thresholds mean every item information curve is the same curve shifted along the ability axis — no item is more informative than any other, only better targeted, which turns adaptive item selection into a nearest-neighbour lookup. Its Rasch-family structure makes the raw score a sufficient statistic, so the response pattern contributes nothing beyond its total. And the prior does the same job here as in the dichotomous case: it keeps estimates and standard errors finite for the all-minimum and all-maximum patterns that a real rating scale produces constantly, at the cost of a known, deliberate pull toward the prior mean.
Rendered with R 4.6.1 · validated against catR 3.17