Vocabulary IQ Test Validation: Item Response Theory

Description

Vocabulary is the classic marker of crystallised intelligence, the knowledge that builds up through schooling, reading and experience (Horn & Cattell, 1966). The Vocabulary IQ Test (VIQT) is a 45-item online test from the Open-Source Psychometrics Project. Each item lists five words, and the task is to pick the two words with the same meaning: in tiny, faded, new, large, big the answer is large = big. Respondents were told not to guess, that a wrong answer costs 0.35 points, and that they could answer don’t know instead.

The other tutorials on this site validate self-report questionnaires, where there are no right answers. An ability test is the natural place for item response theory (IRT): items differ in how hard they are and in how sharply they separate people who know a word from people who do not, and IRT puts items and people on one scale. This tutorial calibrates the 45 items with the mirt package (Chalmers, 2012) in 11,961 respondents. It compares the Rasch, two- and three-parameter models, shows how precise the test is at each ability level, tests item fit and differential item functioning, checks the scores against education, native language and age, and finally runs the calibrated items as a simulated computerised adaptive test.

Data, answer key and scoring

The data come from the Open-Source Psychometrics Project (VIQT_data.zip, 12,173 records collected online between July 2017 and March 2018). The file holds only the people who also completed an optional survey after the test (30 personality statements and five demographic questions), so it is a self-selected group of test takers.

Answer key. The codebook (codebook.txt in the zip) gives the correct answer of every item as a number. An answer is stored as a sum over the selected words: 1 for the first word, 2 for the second, 4, 8 and 16 for the fifth, so 3 means words 1 and 2 and 24 means words 4 and 5. Decoded this way, the codebook key gives a synonym pair for every item (see the item list below). Scoring the data with it reproduces the number of right answers that the test site computed (score_right in the data) for all 12,173 records; data/prepare_viqt.R checks this before it saves the data. The key comes from the codebook, not from the data.

Scoring. An item scores 1 when both correct words were chosen and 0 otherwise: a wrong pair, one or three words, don’t know or no answer at all. This is the number-right score the site reported. Don’t know counts as wrong, not as missing, because it is the answer the instructions asked for when a respondent did not know the pair. People answer don’t know to the items they find hard, so ignoring these answers would reward skipping hard items. The 0.35-point penalty of the site’s formula score is not used. In IRT a wrong answer and a don’t know both mean the respondent did not find the pair, and the model, not a fixed penalty, decides how much a lucky guess is worth (the three-parameter model below tests whether guessing needs its own parameter). The section Don’t know: wrong or missing? shows how much this choice matters.

Code
type <- ifelse(resp == 1, "Correct pair",
        ifelse(codes == -1, "Don't know",
        ifelse(codes == 0, "No response",
        ifelse(codes %in% c(1, 2, 4, 8, 16, 28), "One or three words", "Wrong pair"))))
type <- factor(type, levels = c("Correct pair", "Wrong pair", "Don't know",
                                "One or three words", "No response"))
knitr::kable(data.frame(table(type), 100 * as.vector(prop.table(table(type)))),
             col.names = c("Answer", "Answers", "%"), digits = 1,
             format.args = list(big.mark = ","), row.names = FALSE)
Answer Answers %
Correct pair 397,379 73.8
Wrong pair 68,100 12.7
Don’t know 72,097 13.4
One or three words 303 0.1
No response 366 0.1

The test has no built-in validity check: there are no bogus items, and the data have no flag for repeat visits from the same network. data/prepare_viqt.R therefore removes protocols that did not follow the response format, were too fast to have read the items, gave an impossible age, or repeat an earlier record exactly. A few respondents chose a single word on almost every item: they misread the task, so their zeros say nothing about their vocabulary.

Rule Excluded Remaining
At most 4 items with no response (neither an answer nor don’t know) 128 12,045
Two words chosen, as instructed, on all but at most 4 items 20 12,153
At least 2 seconds per item 10 12,163
Age between 13 and 90 10 12,163
Not a repeat (same 45 answers and demographics as an earlier record) 50 12,123
All rules together 212 11,961

Item List

Show the 45 items with their answer code and correct pair
Item Words Key code Correct pair
Q1 tiny faded new large big 24 large = big
Q2 shovel spade needle oak club 3 shovel = spade
Q3 walk rob juggle steal discover 10 rob = steal
Q4 finish embellish cap squeak talk 5 finish = cap
Q5 recall flex efface remember divest 9 recall = remember
Q6 implore fancy recant beg answer 9 implore = beg
Q7 deal claim plea recoup sale 17 deal = sale
Q8 mindful negligent neurotic lax delectable 10 negligent = lax
Q9 quash evade enumerate assist defeat 17 quash = defeat
Q10 entrapment partner fool companion mirror 10 partner = companion
Q11 junk squeeze trash punch crack 5 junk = trash
Q12 trivial crude presidential flow minor 17 trivial = minor
Q13 prattle siren couch chatter good 9 prattle = chatter
Q14 above slow over pierce what 5 above = over
Q15 assail designate arcane capitulate specify 18 designate = specify
Q16 succeed drop squeal spit fall 18 drop = fall
Q17 fly soar drink peer hop 3 fly = soar
Q18 disburse perplex muster convene feign 12 muster = convene
Q19 cistern crimp bastion leeway pleat 18 crimp = pleat
Q20 solder beguile distant reveal seduce 18 beguile = seduce
Q21 dowager matron spank fiend sire 3 dowager = matron
Q22 worldly solo inverted drunk alone 18 solo = alone
Q23 protracted standard normal florid unbalanced 6 standard = normal
Q24 admissible barbaric lackluster drab spiffy 12 lackluster = drab
Q25 related intrinsic alien steadfast pertinent 17 related = pertinent
Q26 facile annoying clicker obnoxious counter 10 annoying = obnoxious
Q27 capricious incipient galling nascent chromatic 10 incipient = nascent
Q28 noted subsidiary culinary illustrious begrudge 9 noted = illustrious
Q29 breach harmony vehement rupture acquiesce 9 breach = rupture
Q30 influence power cauterize bizarre regular 3 influence = power
Q31 silence rage anger victory love 6 rage = anger
Q32 sector mean light harsh predator 10 mean = harsh
Q33 house carnival yeast economy domicile 17 house = domicile
Q34 depression despondency forswear hysteria integrity 3 depression = despondency
Q35 memorandum catalogue bourgeois trigger note 17 memorandum = note
Q36 fulminant doohickey ligature epistle letter 24 epistle = letter
Q37 titanic equestrian niggardly promiscuous gargantuan 17 titanic = gargantuan
Q38 stanchion strumpet pole pale forstall 5 stanchion = pole
Q39 yearn reject hanker despair indolence 5 yearn = hanker
Q40 introduce terminate shatter bifurcate fork 24 bifurcate = fork
Q41 omen opulence harbinger mystic demand 5 omen = harbinger
Q42 hightail report abscond perturb surmise 5 hightail = abscond
Q43 fugacious vapid fractious querulous extemporaneous 12 fractious = querulous
Q44 cardinal pilot full trial inkling 10 pilot = trial
Q45 fixed rotund stagnant permanent shifty 9 fixed = permanent

Classical Item Analysis

Score distribution

Code
ggplot(data.frame(total), aes(x = total)) +
  geom_histogram(binwidth = 1, fill = "#2166ac", colour = "white") +
  geom_vline(xintercept = mean(total), colour = "#d6604d", linetype = "dashed") +
  labs(x = "Number right (0-45)", y = "Respondents") +
  theme_bw(base_size = 10) +
  ggtitle("Distribution of number-right scores",
          subtitle = "Dashed line: mean")

The test is easy for this sample. The mean is 33.2 of 45 (SD 8.2, median 35), the distribution is skewed to the left (skew -0.75), and 193 respondents (1.6%) answered every item correctly. A ceiling like this limits precision for the best respondents, which the IRT information curve shows directly below.

Item statistics

The proportion correct is the classical item difficulty. The point-biserial correlation relates the item to the total score; the corrected correlation relates it to the total of the other 44 items, so the item does not correlate with itself. For 0/1 items, Cronbach’s alpha is the Kuder-Richardson formula 20 (KR-20; Kuder & Richardson, 1937): \(\text{KR-20} = \frac{k}{k-1}\left(1 - \frac{\sum p_j q_j}{\sigma^2_X}\right)\).

Code
p_item <- colMeans(resp)
r_pb   <- sapply(items, function(j) cor(resp[, j], total))
r_drop <- sapply(items, function(j) cor(resp[, j], total - resp[, j]))
k      <- length(items)
kr20   <- k / (k - 1) * (1 - sum(p_item * (1 - p_item)) / mean((total - mean(total))^2))

# The most common wrong pair (or single word) of each item, as % of all respondents
top_wrong <- sapply(seq_along(items), function(j) {
  w   <- codes[codes[, j] > 0 & resp[, j] == 0, j]
  tab <- sort(table(w), decreasing = TRUE)
  sprintf("%s (%.0f%%)", decode(as.integer(names(tab)[1]), key$words[j]), 100 * tab[[1]] / n_resp)
})
names(top_wrong) <- items

item_stats <- data.frame(item = items, answer = key$answer,
                         p = 100 * p_item, dk = 100 * colMeans(codes == -1),
                         wrong = top_wrong, r_pb = r_pb, r_drop = r_drop)
cat(sprintf("KR-20 = %.3f; corrected item-total r from %.2f to %.2f\n",
            kr20, min(r_drop), max(r_drop)))
KR-20 = 0.921; corrected item-total r from 0.08 to 0.66
Code
knitr::kable(item_stats, digits = c(0, 0, 1, 1, 0, 2, 2), row.names = FALSE,
             col.names = c("Item", "Correct pair", "% correct", "% don't know",
                           "Most common wrong answer", "r item-total", "r corrected"))
Item Correct pair % correct % don’t know Most common wrong answer r item-total r corrected
Q1 large = big 99.2 0.1 tiny + faded (0%) 0.09 0.08
Q2 shovel = spade 88.7 5.1 spade + club (3%) 0.48 0.45
Q3 rob = steal 98.2 0.7 walk + juggle (0%) 0.20 0.18
Q4 finish = cap 43.0 14.1 squeak + talk (21%) 0.43 0.38
Q5 recall = remember 97.8 1.1 recall + divest (0%) 0.16 0.14
Q6 implore = beg 79.8 8.3 recant + answer (6%) 0.61 0.57
Q7 deal = sale 46.7 10.1 claim + recoup (18%) 0.16 0.10
Q8 negligent = lax 78.6 9.9 mindful + neurotic (3%) 0.64 0.61
Q9 quash = defeat 75.1 13.8 evade + defeat (4%) 0.64 0.60
Q10 partner = companion 96.6 0.5 entrapment + fool (2%) 0.11 0.09
Q11 junk = trash 97.5 0.7 squeeze + punch (0%) 0.24 0.22
Q12 trivial = minor 87.1 6.1 trivial + crude (3%) 0.56 0.53
Q13 prattle = chatter 83.5 13.4 siren + chatter (1%) 0.59 0.56
Q14 above = over 96.8 2.3 above + pierce (0%) 0.22 0.20
Q15 designate = specify 79.6 11.5 capitulate + specify (4%) 0.47 0.43
Q16 drop = fall 96.2 2.4 drop + spit (1%) 0.14 0.12
Q17 fly = soar 93.2 3.6 fly + hop (1%) 0.45 0.43
Q18 muster = convene 50.1 26.2 perplex + feign (7%) 0.58 0.54
Q19 crimp = pleat 54.4 34.7 cistern + bastion (6%) 0.63 0.59
Q20 beguile = seduce 73.1 18.0 beguile + reveal (4%) 0.66 0.63
Q21 dowager = matron 51.2 28.0 matron + sire (10%) 0.64 0.60
Q22 solo = alone 97.6 0.4 inverted + alone (1%) 0.19 0.17
Q23 standard = normal 95.5 1.7 protracted + unbalanced (1%) 0.21 0.18
Q24 lackluster = drab 80.7 11.5 barbaric + lackluster (2%) 0.69 0.66
Q25 related = pertinent 55.5 13.7 intrinsic + pertinent (14%) 0.47 0.42
Q26 annoying = obnoxious 88.3 4.3 clicker + counter (4%) 0.20 0.16
Q27 incipient = nascent 28.4 44.2 capricious + galling (10%) 0.50 0.46
Q28 noted = illustrious 63.8 24.4 noted + subsidiary (3%) 0.62 0.58
Q29 breach = rupture 81.1 9.9 harmony + acquiesce (5%) 0.51 0.48
Q30 influence = power 87.1 7.2 influence + cauterize (4%) 0.37 0.34
Q31 rage = anger 98.4 0.6 rage + victory (0%) 0.24 0.22
Q32 mean = harsh 91.3 4.0 mean + predator (1%) 0.25 0.22
Q33 house = domicile 86.1 9.0 economy + domicile (1%) 0.53 0.50
Q34 depression = despondency 71.8 13.2 depression + hysteria (6%) 0.69 0.66
Q35 memorandum = note 74.6 8.3 catalogue + note (10%) 0.45 0.41
Q36 epistle = letter 54.2 33.8 ligature + letter (7%) 0.54 0.50
Q37 titanic = gargantuan 85.5 10.6 niggardly + promiscuous (1%) 0.61 0.58
Q38 stanchion = pole 36.3 46.4 stanchion + forstall (8%) 0.55 0.51
Q39 yearn = hanker 67.7 13.8 yearn + despair (7%) 0.67 0.64
Q40 bifurcate = fork 47.9 17.2 terminate + shatter (22%) 0.58 0.54
Q41 omen = harbinger 64.8 14.4 omen + mystic (12%) 0.67 0.64
Q42 hightail = abscond 51.4 24.1 report + surmise (16%) 0.67 0.63
Q43 fractious = querulous 26.8 49.5 vapid + extemporaneous (5%) 0.51 0.47
Q44 pilot = trial 45.2 27.2 cardinal + pilot (14%) 0.44 0.39
Q45 fixed = permanent 75.7 2.8 fixed + stagnant (14%) 0.37 0.33

KR-20 is 0.92. The items cover a wide range of difficulty, from large = big (99.2% correct) to fractious = querulous (26.8%), but 38 of the 45 items are answered correctly by more than half of the sample.

The lowest item-total correlations belong to the easiest items. The 10 items answered correctly by more than 95% of the respondents have corrected correlations of only 0.08 to 0.22. This comes from the statistic rather than the items: a point-biserial correlation cannot be large when almost everyone gives the same answer. Among the items of medium difficulty (20% to 80% correct), the weakest is Q7 (deal = sale, corrected r = 0.10). Only 47% choose the keyed pair, and the most common wrong answer is claim + recoup (18%). Respondents in the top quarter of the other 44 items choose the keyed pair only somewhat more often than those in the bottom quarter (52% against 39%). Deal and sale are loose synonyms (every sale is a deal, but not every deal is a sale), so the item seems to depend more on how a respondent reads the two words than on how many words they know. Another item to watch is Q26 (annoying = obnoxious): its most common wrong answer, clicker + counter (4%), is arguably a defensible pair as well, since a hand tally counter is also called a clicker.

Dimensionality

IRT models with one latent trait assume that one ability explains the correlations between the items. Pearson correlations between 0/1 items depend on the item difficulties, so dimensionality is checked on tetrachoric correlations, which estimate the correlation between the continuous tendencies assumed to underlie each pair of right/wrong answers.

Code
tet <- psych::tetrachoric(resp)
Code
eig <- eigen(tet$rho, only.values = TRUE)$values

# Parallel analysis reference: shuffle each item independently (keeps the difficulties,
# removes every correlation) and average the eigenvalues of five shuffled data sets
set.seed(42)
eig_rand <- rowMeans(replicate(5, eigen(psych::tetrachoric(apply(resp, 2, sample))$rho,
                                        only.values = TRUE)$values))
Code
n_pa <- which(eig <= eig_rand)[1] - 1
cat(sprintf("First eigenvalue %.2f (%.0f%% of the variance), second %.2f, third %.2f; ratio first/second %.1f\nFactors above the shuffled-data reference: %d\n",
            eig[1], 100 * eig[1] / k, eig[2], eig[3], eig[1] / eig[2], n_pa))
First eigenvalue 19.29 (43% of the variance), second 3.42, third 1.66; ratio first/second 5.6
Factors above the shuffled-data reference: 3
Code
scree <- data.frame(factor = rep(1:10, 2), eigenvalue = c(eig[1:10], eig_rand[1:10]),
                    source = rep(c("Observed (tetrachoric)", "Shuffled data (mean of 5)"), each = 10))
ggplot(scree, aes(factor, eigenvalue, colour = source)) +
  geom_line() + geom_point(size = 2) +
  scale_colour_manual(values = c("#2166ac", "#d6604d"), name = NULL) +
  scale_x_continuous(breaks = 1:10) +
  labs(x = "Factor", y = "Eigenvalue") +
  theme_bw(base_size = 10) +
  theme(legend.position = "top") +
  ggtitle("Scree plot of the tetrachoric correlations")

One factor dominates: the first eigenvalue is 19.3, 5.6 times the second. Parallel analysis still keeps 3 factors, because with 11,961 respondents the eigenvalues of shuffled data are close to 1 and even trivial factors pass. The question is what the second factor is.

Code
efa2 <- fa(tet$rho, nfactors = 2, n.obs = n_resp, fm = "minres", rotate = "oblimin")
L2 <- unclass(efa2$loadings)
easy_f <- which.max(cor(L2, p_item))             # the factor whose loadings rise with p
colnames(L2)[easy_f]  <- "Easy-item factor"
colnames(L2)[-easy_f] <- "Main factor"
phi2 <- efa2$Phi[1, 2]

load2 <- melt(L2, varnames = c("item", "factor"), value.name = "loading")
load2$p <- p_item[as.character(load2$item)]
ggplot(load2, aes(x = 100 * p, y = loading)) +
  geom_hline(yintercept = 0, colour = "grey60") +
  geom_point(colour = "#2166ac", size = 2) +
  geom_text_repel(aes(label = sub("Q", "", item)), size = 2.6, seed = 1, max.overlaps = 30) +
  facet_wrap(~ factor) +
  labs(x = "% correct", y = "Loading (oblimin)") +
  theme_bw(base_size = 10) +
  ggtitle("Two-factor solution of the tetrachoric correlations",
          subtitle = "Each point is an item (labelled by number)")

Code
easy_items <- items[L2[, "Easy-item factor"] >= 0.30 & L2[, "Easy-item factor"] > L2[, "Main factor"]]
weak_both  <- items[apply(L2, 1, max) < 0.30]
native <- !is.na(df_viqt$engnat) & df_viqt$engnat == "Yes"
eig_native <- eigen(psych::tetrachoric(resp[native, ])$rho, only.values = TRUE)$values
Code
cat(sprintf("Items loading at least .30 on the easy-item factor, and more than on the main factor: %d (%.0f%% to %.0f%% correct)\nItems loading below .30 on both factors: %s\nFactor correlation: %.2f\nNative speakers only: first eigenvalue %.2f, second %.2f\n",
            length(easy_items), 100 * min(p_item[easy_items]), 100 * max(p_item[easy_items]),
            paste(weak_both, collapse = ", "), phi2, eig_native[1], eig_native[2]))
Items loading at least .30 on the easy-item factor, and more than on the main factor: 13 (88% to 99% correct)
Items loading below .30 on both factors: Q7
Factor correlation: 0.53
Native speakers only: first eigenvalue 17.48, second 4.12

The two factors do not split the items by content: both hold synonym pairs. They split them by difficulty. The 13 items that load at least .30 on the second factor, and more than on the first, are all among the 14 easiest items of the test (88% to 99% correct), and the two factors correlate 0.53. Q7 (deal = sale) loads weakly on both. A tetrachoric correlation between two items that almost everyone gets right rests on the few people who fail them, and slips or lucky guesses move it a lot. Factors that sort binary items by difficulty have been known in item factor analysis for a long time (Carroll, 1945). The second factor is not a native-language effect either: among native English speakers alone the second eigenvalue is still 4.1. Whether the IRT model, which handles difficulty directly, needs a second dimension is tested with its own fit statistics below.

Item Response Theory

Three models

All three models give the probability that a person with ability \(\theta\) answers item \(j\) correctly as a logistic curve:

\[P_j(\theta) = c_j + (1 - c_j)\,\frac{1}{1 + \exp[-a_j(\theta - b_j)]}\]

  • Rasch (1PL) (Rasch, 1960): items differ only in difficulty \(b_j\), the ability at which the probability is 50%; all items share one slope and \(c_j = 0\).
  • 2PL (Birnbaum, 1968): each item also has its own discrimination \(a_j\), the steepness of the curve.
  • 3PL: adds a lower asymptote \(c_j\), the chance that someone with very low ability gets the item right. Picking one of the ten possible pairs at random is right 1 time in 10, but the test discouraged guessing. The lower asymptote is notoriously hard to estimate, so it gets a weak prior, a beta(2, 17) distribution with mean 2/19 ≈ .11, close to that chance rate (the expbeta prior in mirt).

Ability follows a normal distribution with mean 0; its SD is fixed to 1 in the 2PL and 3PL, while the Rasch model estimates it in place of the common slope. The models are fitted by marginal maximum likelihood with the EM algorithm (for the 3PL, maximum a posteriori because of the prior).

Code
fit_rasch <- mirt(resp, 1, itemtype = "Rasch", verbose = FALSE)
fit_2pl   <- mirt(resp, 1, itemtype = "2PL", verbose = FALSE)
fit_3pl   <- mirt(resp, mirt.model("F = 1-45\nPRIOR = (1-45, g, expbeta, 2, 17)"),
                  itemtype = "3PL", accelerate = "squarem", verbose = FALSE)

models <- list(Rasch = fit_rasch, `2PL` = fit_2pl, `3PL (prior on c)` = fit_3pl)
comparison <- data.frame(model     = names(models),
                         converged = sapply(models, extract.mirt, "converged"),
                         npar      = sapply(models, extract.mirt, "nest"),
                         logLik    = sapply(models, extract.mirt, "logLik"),
                         AIC       = sapply(models, extract.mirt, "AIC"),
                         BIC       = sapply(models, extract.mirt, "BIC"))
knitr::kable(comparison, digits = 0, format.args = list(big.mark = ","), row.names = FALSE,
             col.names = c("Model", "Converged", "Parameters", "Log-likelihood", "AIC", "BIC"))
Model Converged Parameters Log-likelihood AIC BIC
Rasch TRUE 46 -199,515 399,122 399,462
2PL TRUE 90 -192,493 385,166 385,831
3PL (prior on c) TRUE 135 -192,259 384,788 385,786
Code
lr    <- 2 * (comparison$logLik[2] - comparison$logLik[1])
lr_df <- comparison$npar[2] - comparison$npar[1]
lr_p  <- pchisq(lr, lr_df, lower.tail = FALSE)
cat(sprintf("Likelihood ratio, Rasch against 2PL: chi-square = %.0f, df = %d, p %s\n",
            lr, lr_df, if (lr_p < .001) "< .001" else sprintf("= %.3f", lr_p)))
Likelihood ratio, Rasch against 2PL: chi-square = 14044, df = 44, p < .001

The 2PL is far better than the Rasch model (BIC lower by 13,631): the discriminations range from 0.20 to 3.54, so equal slopes cannot hold. The 3PL lowers BIC by a further 45 points, much less, and it changes the scores very little (the correlation between 2PL and 3PL abilities is shown below). Most lower asymptotes are small (median 0.06); the largest are Q33 (house = domicile, c = 0.37) and Q35 (memorandum = note, c = 0.28). These are items that can be solved by elimination: someone who does not know domicile can still see that carnival, yeast and economy have nothing to do with each other or with house, and pair the two words that are left. The 2PL is used from here on: it fits much better than the Rasch model, and the third parameter adds little.

Overall fit

The likelihood-ratio test compares models with each other; whether a model fits the data at all is tested with the limited-information statistic \(M_2\) (Maydeu-Olivares & Joe, 2006), which compares the observed proportions correct of every item and every pair of items with the model’s predictions.

Code
m2_tab <- rbind(Rasch = M2(fit_rasch), `2PL` = M2(fit_2pl))
knitr::kable(data.frame(model = rownames(m2_tab), m2_tab[, c("M2", "df", "RMSEA", "SRMSR", "TLI", "CFI")]),
             digits = c(0, 0, 0, 3, 3, 3, 3), row.names = FALSE, format.args = list(big.mark = ","),
             col.names = c("Model", "M2", "df", "RMSEA", "SRMSR", "TLI", "CFI"))
Model M2 df RMSEA SRMSR TLI CFI
Rasch 33,426 989 0.052 0.088 0.947 0.947
2PL 11,109 945 0.030 0.030 0.982 0.983

With 11,961 respondents the \(M_2\) test rejects both models, as any exact test of a model would at this sample size, so the approximate indices are the ones to read. The 2PL fits well (RMSEA 0.030, SRMSR 0.030, CFI 0.983); the Rasch model is clearly worse on every index (SRMSR 0.088).

Local independence

If one ability explains the items, the residuals of any two items (observed minus expected score) should be uncorrelated once ability is taken into account. Yen’s (1984) Q3 is that residual correlation. Its average is slightly negative by construction, so Christensen, Makransky and Horton (2017) suggest judging each Q3 against the average and flagging values more than 0.2 above it.

Code
q3 <- residuals(fit_2pl, type = "Q3", verbose = FALSE)
q3_pairs <- q3[lower.tri(q3)]
cat(sprintf("Q3: mean %.3f, min %.2f, max %.2f; pairs more than 0.2 above the mean: %d of %d\n",
            mean(q3_pairs), min(q3_pairs), max(q3_pairs),
            sum(q3_pairs - mean(q3_pairs) > 0.2), length(q3_pairs)))
Q3: mean -0.015, min -0.21, max 0.14; pairs more than 0.2 above the mean: 0 of 990
Code
q3_max <- which(q3 == max(q3_pairs) & lower.tri(q3), arr.ind = TRUE)

ggplot(data.frame(q3 = q3_pairs), aes(q3)) +
  geom_histogram(binwidth = 0.01, fill = "#2166ac", colour = "white") +
  geom_vline(xintercept = mean(q3_pairs) + 0.2, colour = "#d6604d", linetype = "dashed") +
  labs(x = "Q3 residual correlation", y = "Item pairs") +
  theme_bw(base_size = 10) +
  ggtitle("Residual correlations of all 990 item pairs (2PL)",
          subtitle = "Dashed line: mean + 0.2")

Is one dimension enough?

Code
fit_hard <- mirt(resp[, setdiff(items, easy_items)], 1, itemtype = "2PL", verbose = FALSE)
fs <- fscores(fit_2pl, full.scores.SE = TRUE)       # EAP ability and its standard error
theta <- fs[, "F1"]
r_hard <- cor(theta, fscores(fit_hard)[, 1])
cat(sprintf("Correlation of 2PL abilities from all 45 items with abilities from the %d items outside the easy-item factor: %.3f\nCorrelation of 2PL and 3PL abilities: %.3f\n",
            k - length(easy_items), r_hard, cor(theta, theta_3pl)))
Correlation of 2PL abilities from all 45 items with abilities from the 32 items outside the easy-item factor: 0.997
Correlation of 2PL and 3PL abilities: 0.997

With 11,961 respondents, even small departures from one dimension can be detected, as the tetrachoric analysis did; in practice one dimension is enough. The 2PL reproduces the item pairs closely (SRMSR 0.030), no pair of items shares much residual variance (largest Q3 0.14, for Q24 and Q17), and abilities estimated without the 13 easy items correlate 0.997 with abilities from all 45. The easy-item factor of the tetrachoric analysis does not change who scores high or low.

Item parameters

Code
itf <- itemfit(fit_2pl, fit_stats = "S_X2")
par_tab <- data.frame(item = items, answer = key$answer, a = pars[, "a"], b = pars[, "b"],
                      c3 = c3, sx2 = itf$S_X2, df = itf$df.S_X2,
                      rmsea = itf$RMSEA.S_X2, p = itf$p.S_X2)
knitr::kable(par_tab, digits = c(0, 0, 2, 2, 2, 1, 0, 3, 4), row.names = FALSE,
             col.names = c("Item", "Correct pair", "a (2PL)", "b (2PL)", "c (3PL)",
                           "S-X2", "df", "RMSEA", "p"))
Item Correct pair a (2PL) b (2PL) c (3PL) S-X2 df RMSEA p
Q1 large = big 0.78 -6.53 0.06 36.2 35 0.002 0.4112
Q2 shovel = spade 1.88 -1.65 0.07 53.7 33 0.007 0.0130
Q3 rob = steal 1.34 -3.60 0.06 46.0 36 0.005 0.1234
Q4 finish = cap 0.94 0.36 0.03 146.2 33 0.017 0.0000
Q5 recall = remember 0.92 -4.55 0.06 30.1 38 0.000 0.8167
Q6 implore = beg 2.21 -1.06 0.14 44.3 31 0.006 0.0570
Q7 deal = sale 0.20 0.66 0.06 33.8 37 0.000 0.6176
Q8 negligent = lax 2.42 -0.98 0.04 78.9 30 0.012 0.0000
Q9 quash = defeat 2.24 -0.86 0.05 64.5 30 0.010 0.0003
Q10 partner = companion 0.47 -7.32 0.06 66.6 39 0.008 0.0038
Q11 junk = trash 1.45 -3.17 0.06 56.4 36 0.007 0.0165
Q12 trivial = minor 2.32 -1.42 0.06 47.8 31 0.007 0.0274
Q13 prattle = chatter 2.36 -1.22 0.07 61.4 31 0.009 0.0009
Q14 above = over 1.08 -3.66 0.06 34.4 37 0.000 0.5895
Q15 designate = specify 1.32 -1.35 0.07 40.4 34 0.004 0.2087
Q16 drop = fall 0.58 -5.87 0.06 52.1 39 0.005 0.0786
Q17 fly = soar 2.30 -1.88 0.07 56.7 31 0.008 0.0033
Q18 muster = convene 1.70 0.00 0.03 34.3 30 0.003 0.2681
Q19 crimp = pleat 2.05 -0.14 0.03 27.6 30 0.000 0.5928
Q20 beguile = seduce 2.49 -0.76 0.06 43.0 30 0.006 0.0589
Q21 dowager = matron 2.29 -0.04 0.04 43.5 29 0.006 0.0406
Q22 solo = alone 1.11 -3.86 0.06 48.7 37 0.005 0.0951
Q23 standard = normal 0.85 -3.95 0.06 35.6 38 0.000 0.5824
Q24 lackluster = drab 3.54 -0.97 0.03 42.9 27 0.007 0.0265
Q25 related = pertinent 1.05 -0.26 0.05 75.1 33 0.010 0.0000
Q26 annoying = obnoxious 0.48 -4.41 0.06 294.9 39 0.023 0.0000
Q27 incipient = nascent 1.79 0.79 0.05 136.2 28 0.018 0.0000
Q28 noted = illustrious 1.90 -0.48 0.16 181.8 31 0.020 0.0000
Q29 breach = rupture 1.55 -1.32 0.05 63.2 34 0.008 0.0017
Q30 influence = power 1.08 -2.12 0.06 42.8 37 0.004 0.2355
Q31 rage = anger 1.89 -3.02 0.06 94.8 31 0.013 0.0000
Q32 mean = harsh 0.80 -3.26 0.06 50.3 38 0.005 0.0874
Q33 house = domicile 1.99 -1.44 0.37 160.9 32 0.018 0.0000
Q34 depression = despondency 2.86 -0.68 0.05 102.8 29 0.015 0.0000
Q35 memorandum = note 1.13 -1.19 0.28 74.9 34 0.010 0.0001
Q36 epistle = letter 1.51 -0.16 0.18 182.2 32 0.020 0.0000
Q37 titanic = gargantuan 2.78 -1.25 0.05 37.9 30 0.005 0.1531
Q38 stanchion = pole 1.86 0.48 0.03 23.5 28 0.000 0.7056
Q39 yearn = hanker 2.51 -0.56 0.03 26.3 29 0.000 0.6090
Q40 bifurcate = fork 1.69 0.08 0.05 35.1 30 0.004 0.2392
Q41 omen = harbinger 2.45 -0.47 0.03 44.9 30 0.006 0.0391
Q42 hightail = abscond 2.55 -0.04 0.02 63.6 28 0.010 0.0001
Q43 fractious = querulous 2.00 0.82 0.02 67.0 26 0.011 0.0000
Q44 pilot = trial 0.99 0.24 0.05 60.1 33 0.008 0.0027
Q45 fixed = permanent 0.86 -1.52 0.07 57.1 35 0.007 0.0106

The discrimination \(a\) is the slope of the curve at its steepest point, on the logistic scale (no 1.7 scaling constant). The most discriminating item is Q24 (lackluster = drab, a = 3.54), and the least discriminating is again Q7 (deal = sale, a = 0.20). Items with very low discrimination get extreme difficulty estimates: Q10 (partner = companion) has b = -7.3, far below any respondent, because its curve is so flat that it reaches 50% only there.

Item characteristic curves

Six items with contrasting properties. The curves are the 2PL predictions; the points are the observed proportions correct in 20 equal-sized groups of respondents ordered by their estimated ability, a visual check of fit.

Code
pick <- c("Easiest"                = items[which.max(p_item)],
          "Hardest"                = items[which.min(p_item)],
          "Most discriminating"    = rownames(pars)[which.max(pars[, "a"])],
          "Least discriminating"   = rownames(pars)[which.min(pars[, "a"])],
          "Worst fit (S-X2 RMSEA)" = itf$item[which.max(itf$RMSEA.S_X2)],
          "Largest c in the 3PL"   = names(which.max(c3)))
pick <- pick[!duplicated(pick)]
panel <- setNames(paste0(names(pick), ": ", item_lab[pick]), pick)

grid <- seq(-4, 3, by = 0.05)
curves <- do.call(rbind, lapply(pick, function(j)
  data.frame(item = j, theta = grid,
             p = probtrace(extract.item(fit_2pl, j), grid)[, 2])))

groups <- cut(theta, unique(quantile(theta, seq(0, 1, 0.05))), include.lowest = TRUE)
points <- do.call(rbind, lapply(pick, function(j)
  data.frame(item = j, theta = tapply(theta, groups, mean), p = tapply(resp[, j], groups, mean))))

curves$panel <- factor(panel[curves$item], levels = panel)
points$panel <- factor(panel[points$item], levels = panel)
ggplot(curves, aes(theta, p)) +
  geom_line(colour = "#2166ac", linewidth = 0.9) +
  geom_point(data = points, colour = "#d6604d", size = 1.8) +
  facet_wrap(~ panel, ncol = 3, labeller = label_wrap_gen(width = 32)) +
  scale_y_continuous(limits = c(0, 1)) +
  labs(x = expression("Ability " * theta), y = "P(correct)") +
  theme_bw(base_size = 9) +
  ggtitle("Item characteristic curves (2PL) with observed proportions")

The easiest item is right for almost everyone, so its curve only rises at the far left. The hardest item reaches 50% at θ = 0.82, less than one SD above the average respondent: the test has no truly hard items. The most discriminating item separates people within a narrow band of ability, while the least discriminating one is nearly flat. The observed proportions for the worst-fitting item rise at the lowest abilities and then stay near 90% instead of approaching 1, which the item-fit section below explains. For the item with the largest lower asymptote in the 3PL, the lowest group of respondents lies slightly above the 2PL curve and the next groups slightly below it, the S-shape that a lower asymptote describes. The 3PL’s c = 0.37 itself, though, only dominates the curve below about θ = -2, where only 1.8% of the respondents are, which is one reason lower asymptotes are hard to estimate.

Information and precision

Each item contributes information \(I_j(\theta) = a_j^2 P_j(\theta)[1 - P_j(\theta)]\), which peaks at \(\theta = b_j\) with height \(a_j^2/4\). Test information is the sum over items, and the standard error of an ability estimate is \(SE(\theta) = 1/\sqrt{I(\theta)}\). Because ability has variance 1 in this sample, a standard error of 0.30 corresponds to a reliability of about \(1 - 0.30^2 = .91\).

Code
grid_info <- seq(-4, 4, by = 0.01)
item_info <- do.call(rbind, lapply(items, function(j)
  data.frame(item = j, theta = grid_info, info = iteminfo(extract.item(fit_2pl, j), grid_info))))
item_info$highlight <- item_info$item %in% pick
peaks <- do.call(rbind, lapply(split(item_info[item_info$highlight, ], item_info$item[item_info$highlight]),
                               function(d) d[which.max(d$info), ]))

ggplot(item_info, aes(theta, info, group = item)) +
  geom_line(data = item_info[!item_info$highlight, ], colour = "grey75", linewidth = 0.4) +
  geom_line(data = item_info[item_info$highlight, ], colour = "#2166ac", linewidth = 0.9) +
  geom_label_repel(data = peaks, aes(label = item_lab[item]), size = 2.7, seed = 3,
                   min.segment.length = 0, box.padding = 0.4) +
  labs(x = expression("Ability " * theta), y = "Item information") +
  theme_bw(base_size = 10) +
  ggtitle("Item information curves",
          subtitle = "Blue: the six items of the previous figure; grey: the other 39")

Code
test_info <- testinfo(fit_2pl, matrix(grid_info))
se_curve  <- 1 / sqrt(test_info)
precise   <- range(grid_info[se_curve <= 0.30])
peak_info <- grid_info[which.max(test_info)]
rel_emp   <- empirical_rxx(fs)
rel_marg  <- marginal_rxx(fit_2pl)
cat(sprintf("Information peaks at theta = %.2f (I = %.1f, SE = %.2f); SE <= 0.30 from theta = %.2f to %.2f\nRespondents in that range: %.1f%%; above it: %.1f%%\nEmpirical reliability %.3f, marginal reliability %.3f\n",
            peak_info, max(test_info), min(se_curve), precise[1], precise[2],
            100 * mean(theta >= precise[1] & theta <= precise[2]), 100 * mean(theta > precise[2]),
            rel_emp, rel_marg))
Information peaks at theta = -0.87 (I = 25.2, SE = 0.20); SE <= 0.30 from theta = -2.03 to 0.48
Respondents in that range: 65.7%; above it: 32.7%
Empirical reliability 0.914, marginal reliability 0.909
Code
scale_se <- 10
ggplot(data.frame(theta = grid_info, info = test_info, se = se_curve), aes(theta)) +
  annotate("rect", xmin = precise[1], xmax = precise[2], ymin = -Inf, ymax = Inf,
           fill = "grey90") +
  geom_line(aes(y = info, colour = "Test information"), linewidth = 1) +
  geom_line(aes(y = se * scale_se, colour = "Standard error"), linewidth = 1) +
  geom_hline(yintercept = 0.30 * scale_se, colour = "#d6604d", linetype = "dotted") +
  scale_colour_manual(values = c(`Test information` = "#2166ac", `Standard error` = "#d6604d"), name = NULL) +
  scale_y_continuous(name = "Test information",
                     sec.axis = sec_axis(~ . / scale_se, name = "Standard error")) +
  coord_cartesian(ylim = c(0, 30)) +
  labs(x = expression("Ability " * theta)) +
  theme_bw(base_size = 10) +
  theme(legend.position = "top") +
  ggtitle("Test information and conditional standard error (2PL)",
          subtitle = "Shaded: SE at or below 0.30 (dotted line)")

Code
wright <- rbind(data.frame(panel = "Respondents (EAP ability)", value = theta),
                data.frame(panel = "Items (2PL difficulty b)", value = pars[, "b"]))
wright$panel <- factor(wright$panel, levels = c("Respondents (EAP ability)", "Items (2PL difficulty b)"))
ggplot(wright, aes(value, fill = panel)) +
  geom_histogram(binwidth = 0.2, colour = "white", boundary = 0) +
  facet_grid(panel ~ ., scales = "free_y") +
  scale_fill_manual(values = c("#2166ac", "#d6604d"), guide = "none") +
  labs(x = expression("Ability " * theta * " / difficulty b"), y = "Count") +
  theme_bw(base_size = 10) +
  ggtitle("Person-item map: where the respondents and the items are")

The test is most precise below the average respondent: information peaks at θ = -0.87, where the standard error is 0.20, and the standard error stays at or below 0.30 from θ = -2.03 to 0.48. Above that it rises quickly, to 0.53 at θ = 1.5, because only 7 items have a difficulty above 0 and the hardest has b = 0.82. 33% of the respondents are above the precise range. Averaged over the sample, the scores are reliable (empirical reliability 0.91, marginal reliability 0.91, close to KR-20), but the average hides a test that measures roughly the lower two thirds of this sample much better than the top third. A version of the VIQT for able adults would need harder items, not more items.

Item fit

The S-X2 statistic (Orlando & Thissen, 2000) compares, for each number-right score group, the observed proportion correct on an item with the proportion the model predicts.

Code
cat(sprintf("Items with p < .05: %d of %d; p < .001: %d\nRMSEA of S-X2: median %.3f, max %.3f\n",
            sum(itf$p.S_X2 < .05), k, sum(itf$p.S_X2 < .001),
            median(itf$RMSEA.S_X2), max(itf$RMSEA.S_X2)))
Items with p < .05: 26 of 45; p < .001: 15
RMSEA of S-X2: median 0.007, max 0.023
Code
worst <- itf[order(-itf$RMSEA.S_X2), ][1:3, ]
knitr::kable(data.frame(item = worst$item, answer = key$answer[match(worst$item, items)],
                        a = pars[worst$item, "a"], sx2 = worst$S_X2, df = worst$df.S_X2,
                        rmsea = worst$RMSEA.S_X2),
             digits = c(0, 0, 2, 1, 0, 3), row.names = FALSE,
             col.names = c("Item", "Correct pair", "a", "S-X2", "df", "RMSEA"))
Item Correct pair a S-X2 df RMSEA
Q26 annoying = obnoxious 0.48 294.9 39 0.023
Q28 noted = illustrious 1.90 181.8 31 0.020
Q36 epistle = letter 1.51 182.2 32 0.020
Code
# Where the misfit is: observed minus 2PL-expected proportion correct in tenths of ability
tenth <- cut(theta, quantile(theta, seq(0, 1, 0.1)), include.lowest = TRUE, labels = 1:10)
resid_tenth <- t(sapply(worst$item, function(j)
  tapply(resp[, j] - probtrace(extract.item(fit_2pl, j), theta)[, 2], tenth, mean)))
knitr::kable(data.frame(item = worst$item, resid_tenth, check.names = FALSE), digits = 2,
             row.names = FALSE, col.names = c("Item", paste("Tenth", 1:10)))
Item Tenth 1 Tenth 2 Tenth 3 Tenth 4 Tenth 5 Tenth 6 Tenth 7 Tenth 8 Tenth 9 Tenth 10
Q26 -0.10 0.06 0.05 0.04 0.03 0.00 -0.02 -0.03 -0.03 -0.01
Q28 0.04 0.01 -0.08 -0.09 -0.03 -0.01 0.04 0.03 0.03 0.02
Q36 0.04 0.04 -0.04 -0.06 -0.07 -0.09 -0.03 0.04 0.07 0.06
Code
q26_correct <- tapply(resp[, "Q26"], tenth, mean)
q26_clicker <- tapply(codes[, "Q26"] == 20, tenth, mean)        # 20 = words 3 and 5: clicker + counter

26 of the 45 items misfit at p < .05. With 11,961 respondents, this test detects deviations far too small to matter, so the size of the misfit is the better guide. The RMSEA of S-X2 is at most 0.023, so every item follows its curve closely. Even for the three worst items, the observed proportion correct in any tenth of the respondents (ordered by ability) is at most 10 percentage points away from the 2PL prediction (second table).

The three worst items misfit for reasons that can be read from their content. On Q26 (annoying = obnoxious) the proportion correct reaches 90% in the second-lowest tenth of ability and stays between 88% and 93% from there on. Meanwhile the alternative pair clicker + counter grows from 3% in the lowest tenth to 7% at its highest: able respondents see a second answer. Q28 and Q36 have, after Q33 and Q35, the largest lower asymptotes in the 3PL (c = 0.16 and 0.18). Low scorers get them right a little more often, and middle scorers a little less often, than the 2PL predicts. The misfit is real but small. For Q26 it points to the wording of the item; for Q28 and Q36 it is the lower asymptote that the 2PL leaves out, which costs little given how close the 2PL and 3PL scores are.

Don’t know: wrong or missing?

Code
omitted <- rowSums(codes <= 0)                      # don't know or no response
resp_na <- resp
resp_na[codes <= 0] <- NA                            # omitted answers treated as missing
theta_na <- fscores(fit_2pl, response.pattern = resp_na)[, "F1"]
shift <- tapply(theta_na - theta, cut(omitted, c(-1, 0, 2, 5, 10, 45),
                                      labels = c("0", "1-2", "3-5", "6-10", "11 or more")), mean, na.rm = TRUE)
cat(sprintf("Respondents who omitted at least one item: %.0f%%; median omitted: %d\nCorrelation of abilities (omitted = wrong vs omitted = missing): %.3f\nRespondents who omitted every item (no score when omitted = missing): %d\nAcross items, correlation of %% don't know with %% correct: %.2f\n",
            100 * mean(omitted > 0), median(omitted), cor(theta, theta_na, use = "complete.obs"),
            sum(is.na(theta_na)), cor(colMeans(codes == -1), p_item)))
Respondents who omitted at least one item: 78%; median omitted: 4
Correlation of abilities (omitted = wrong vs omitted = missing): 0.900
Respondents who omitted every item (no score when omitted = missing): 1
Across items, correlation of % don't know with % correct: -0.89
Code
knitr::kable(data.frame(omitted = names(shift), n = as.vector(table(cut(omitted, c(-1, 0, 2, 5, 10, 45)))),
                        shift = as.vector(shift)),
             digits = 2, row.names = FALSE, format.args = list(big.mark = ","),
             col.names = c("Items omitted", "Respondents", "Mean change in ability when omitted = missing"))
Items omitted Respondents Mean change in ability when omitted = missing
0 2,621 0.00
1-2 2,339 0.19
3-5 2,327 0.37
6-10 2,195 0.52
11 or more 2,479 0.85

78% of the respondents used don’t know (or left an item blank) at least once, so the choice matters. Abilities from the two scorings correlate 0.90. The more items someone omitted, the more their score would rise if omissions were ignored: by 0.19 SD on average for one or two omitted items, and by 0.85 SD for 11 or more. Omissions are not random. Across items, the share of don’t know answers correlates -0.89 with the proportion correct, so people omit the hard items. Treating omissions as missing would reward exactly the people who declined to answer the hard items, so the number-right scoring is kept. Its cost is that two people with the same knowledge can score differently if one is more willing to answer when unsure, and the instruction not to guess makes such differences likely.

Validity

The ability estimates (EAP scores from the 2PL) should differ between groups in the ways vocabulary is known to differ: more education, English as a native language and greater age (vocabulary keeps growing through most of adulthood) should all go with higher scores.

Education

Code
d_val <- data.frame(theta, df_viqt[, c("age", "gender", "engnat", "education")])
summ <- function(d, by) {
  out <- do.call(data.frame, aggregate(theta ~ ., data = d[, c("theta", by)],
                 FUN = function(v) c(n = length(v), mean = mean(v), se = sd(v) / sqrt(length(v)))))
  names(out) <- c(by, "n", "mean", "se")
  out
}
edu_all   <- cbind(group = "All respondents", summ(d_val, "education"))
edu_adult <- cbind(group = "Aged 25 or older", summ(d_val[d_val$age >= 25, ], "education"))
edu <- rbind(edu_all, edu_adult)
edu$group <- factor(edu$group, levels = c("All respondents", "Aged 25 or older"))
knitr::kable(edu[, c("group", "education", "n", "mean")], digits = 2, row.names = FALSE,
             format.args = list(big.mark = ","), col.names = c("Group", "Education", "n", "Mean ability"))
Group Education n Mean ability
All respondents Less than high school 1,068 -0.67
All respondents High school 3,974 -0.26
All respondents University degree 4,273 0.15
All respondents Graduate degree 2,628 0.42
Aged 25 or older Less than high school 169 -0.17
Aged 25 or older High school 1,765 0.14
Aged 25 or older University degree 3,246 0.28
Aged 25 or older Graduate degree 2,441 0.50
Code
ggplot(edu, aes(x = education, y = mean, colour = group)) +
  geom_pointrange(aes(ymin = mean - 1.96 * se, ymax = mean + 1.96 * se),
                  position = position_dodge(width = 0.4)) +
  geom_line(aes(group = group), position = position_dodge(width = 0.4)) +
  scale_colour_manual(values = c("#2166ac", "#d6604d"), name = NULL) +
  labs(x = NULL, y = expression("Mean ability " * theta * " (95% CI)")) +
  theme_bw(base_size = 10) +
  theme(legend.position = "top") +
  ggtitle("Vocabulary ability by completed education")

Mean ability rises with every step of education, from -0.67 (less than high school) to 0.42 (graduate degree). Many respondents without a high-school diploma are teenagers who have not finished school yet, so the comparison is repeated for respondents aged 25 or older. The order stays the same, and the gap between the lowest and highest groups shrinks from 1.09 to 0.67 SD.

Native language

Code
has_lang <- !is.na(d_val$engnat)
d_lang <- cohen_d(d_val$theta[has_lang], droplevels(d_val$engnat[has_lang]))
cat(sprintf("Native speakers: %s, mean %.2f; non-native: %s, mean %.2f; d = %.2f\n",
            big(sum(d_val$engnat == "Yes", na.rm = TRUE)), mean(theta[d_val$engnat %in% "Yes"]),
            big(sum(d_val$engnat == "No", na.rm = TRUE)), mean(theta[d_val$engnat %in% "No"]), d_lang))
Native speakers: 9,220, mean 0.23; non-native: 2,676, mean -0.78; d = 1.17
Code
ggplot(d_val[has_lang, ], aes(x = theta, fill = engnat)) +
  geom_density(alpha = 0.45, colour = NA) +
  scale_fill_manual(values = c(Yes = "#2166ac", No = "#d6604d"),
                    labels = c(Yes = "Native English speaker", No = "Non-native"), name = NULL) +
  labs(x = expression("Ability " * theta), y = "Density") +
  theme_bw(base_size = 10) +
  theme(legend.position = "top") +
  ggtitle("Vocabulary ability by native language")

Native English speakers score much higher than non-native speakers (d = 1.17), as expected for an English vocabulary test.

Age

Code
age_breaks <- c(12, 17, 21, 25, 30, 40, 50, 60, 70, 90)
age_labels <- c("13-17", "18-21", "22-25", "26-30", "31-40", "41-50", "51-60", "61-70", "71-90")
d_val$age_band <- cut(d_val$age, age_breaks, labels = age_labels)
age_tab <- summ(d_val[has_lang, ], c("age_band", "engnat"))
r_age <- cor(d_val$age, theta)
r_age_native <- cor(d_val$age[d_val$engnat %in% "Yes"], theta[d_val$engnat %in% "Yes"])
cat(sprintf("Correlation of age with ability: %.2f (native speakers only: %.2f)\n", r_age, r_age_native))
Correlation of age with ability: 0.47 (native speakers only: 0.47)
Code
ggplot(age_tab, aes(x = age_band, y = mean, colour = engnat, group = engnat)) +
  geom_line() +
  geom_pointrange(aes(ymin = mean - 1.96 * se, ymax = mean + 1.96 * se)) +
  scale_colour_manual(values = c(Yes = "#2166ac", No = "#d6604d"),
                      labels = c(Yes = "Native English speaker", No = "Non-native"), name = NULL) +
  labs(x = "Age", y = expression("Mean ability " * theta * " (95% CI)")) +
  theme_bw(base_size = 10) +
  theme(legend.position = "top") +
  ggtitle("Vocabulary ability by age")

Ability rises steadily with age (r = 0.47). Among native speakers it climbs from -0.67 at ages 13-17 to a peak of 0.82 at 61-70 and levels off after that. Non-native speakers rise with age too, and the gap between the two groups is present at every age. This fits the view of vocabulary as crystallised knowledge that grows through most of adult life (Horn & Cattell, 1966). The data are cross-sectional and self-selected, so the curve mixes ageing with differences between generations and between the kinds of people of each age who take an online vocabulary test.

Differential item functioning by native language

An item shows differential item functioning (DIF) when people of equal ability but from different groups have different chances of answering it correctly. The Mantel-Haenszel procedure (Holland & Thayer, 1988) compares native and non-native speakers within each number-right score group and expresses the common odds ratio \(\alpha_{MH}\) on the ETS delta scale, \(D = -2.35 \ln \alpha_{MH}\). Negative values mean the item is harder for non-native speakers than their total score predicts. ETS classifies items as A (negligible: |D| < 1 or not significant), C (large: |D| ≥ 1.5 and significantly above 1) or B (moderate, everything in between) (Dorans & Holland, 1993).

Code
focal <- df_viqt$engnat[has_lang] == "No"
score <- total[has_lang]
mh <- t(sapply(items, function(j) {
  y <- resp[has_lang, j]
  A <- tapply(y == 1 & !focal, score, sum); B <- tapply(y == 0 & !focal, score, sum)
  C <- tapply(y == 1 & focal, score, sum);  D <- tapply(y == 0 & focal, score, sum)
  N <- A + B + C + D
  R <- A * D / N; S <- B * C / N; P <- (A + D) / N; Q <- (B + C) / N
  # Robins-Breslow-Greenland variance of log(alpha_MH)
  v <- sum(P * R) / (2 * sum(R)^2) + sum(P * S + Q * R) / (2 * sum(R) * sum(S)) + sum(Q * S) / (2 * sum(S)^2)
  c(D = -2.35 * log(sum(R) / sum(S)), SE = 2.35 * sqrt(v))
}))
mh <- data.frame(item = items, answer = key$answer, mh)
mh$ets <- ifelse(abs(mh$D) < 1 | abs(mh$D) / mh$SE < 1.96, "A",
          ifelse(abs(mh$D) >= 1.5 & (abs(mh$D) - 1) / mh$SE > 1.645, "C", "B"))
mh$direction <- ifelse(mh$ets == "A", "Negligible (A)",
                ifelse(mh$D < 0, "Easier for native speakers", "Easier for non-native speakers"))
print(table(ETS = mh$ets, direction = mh$direction))
   direction
ETS Easier for native speakers Easier for non-native speakers Negligible (A)
  A                          0                              0             26
  B                          1                              2              0
  C                          8                              8              0
Code
mh$label <- factor(item_lab[mh$item], levels = item_lab[mh$item[order(mh$D)]])
ggplot(mh, aes(x = D, y = label, colour = direction)) +
  annotate("rect", xmin = -1, xmax = 1, ymin = -Inf, ymax = Inf, fill = "grey93") +
  geom_vline(xintercept = c(-1.5, 1.5), linetype = "dashed", colour = "grey50") +
  geom_vline(xintercept = 0, colour = "grey60") +
  geom_linerange(aes(xmin = D - 1.96 * SE, xmax = D + 1.96 * SE)) +
  geom_point(size = 2) +
  scale_colour_manual(values = c(`Easier for native speakers` = "#2166ac",
                                 `Easier for non-native speakers` = "#d6604d",
                                 `Negligible (A)` = "grey55"), name = NULL) +
  labs(x = "MH D-DIF (95% CI)", y = NULL) +
  theme_bw(base_size = 9) +
  theme(legend.position = "top") +
  ggtitle("Mantel-Haenszel DIF, native against non-native English speakers",
          subtitle = "Grey band: |D| < 1 (A); dashed: |D| = 1.5 (C threshold)")

Code
top_lang <- head(sort(table(df_viqt$country[df_viqt$engnat %in% "No"]), decreasing = TRUE), 8)
cat("Most common countries of non-native speakers:",
    paste0(names(top_lang), " (", top_lang, ")", collapse = ", "), "\n")
Most common countries of non-native speakers: US (375), IN (181), PH (171), DE (145), CA (109), GB (96), NL (73), FR (72) 

16 items show large (C) DIF and 3 moderate (B) DIF, in both directions: of the C items, 8 are harder for non-native speakers than their total score predicts and 8 are easier. The non-native group is diverse; the most common countries are the United States, India, the Philippines and Germany. The pattern is still clear. Items that favour native speakers mostly turn on everyday or informal English words (soar, drab, hanker, crimp, spade, hightail): fly = soar, lackluster = drab, yearn = hanker, crimp = pleat, depression = despondency, shovel = spade, rob = steal, hightail = abscond. Items that favour non-native speakers mostly turn on words of Latin origin that learners meet in school and in formal writing, and that have close relatives in many European languages: incipient = nascent, house = domicile, epistle = letter, bifurcate = fork, partner = companion, memorandum = note, solo = alone, noted = illustrious (compare French domicile, épître, naissant or Spanish domicilio, epístola, naciente). Not every item fits the pattern (despondency is Latinate but favours native speakers), but the split between learned and everyday vocabulary is plain.

DIF of this size does not mean these items are faulty. It means a vocabulary score carries a different mix of word knowledge for native and non-native speakers, and comparisons across the two groups should keep that in mind. Because the DIF runs both ways, it largely cancels in the total score. The matching score contains the DIF items themselves, so the classification is approximate.

The same question in IRT terms: a two-group 2PL in mirt in which the 26 A items are anchors (equal parameters in both groups), the B and C items are free in each group, and the non-native group gets its own mean and variance on the native speakers’ scale.

Code
anchors <- mh$item[mh$ets == "A"]
lang <- factor(as.character(df_viqt$engnat[has_lang]), levels = c("Yes", "No"))  # natives = reference
fit_mg <- multipleGroup(resp[has_lang, ], 1, group = lang, itemtype = "2PL",
                        invariance = c(anchors, "free_means", "free_var"), verbose = FALSE)
mg_no <- coef(fit_mg, simplify = TRUE)$No
cat(sprintf("Non-native speakers on the native speakers' scale: mean %.2f, SD %.2f\n",
            mg_no$means[1], sqrt(mg_no$cov[1, 1])))
Non-native speakers on the native speakers' scale: mean -1.17, SD 1.05
Code
show <- c(c_native$item[1], c_nonnative$item[1])
dif_curves <- do.call(rbind, lapply(c("Yes", "No"), function(g) {
  mod <- extract.group(fit_mg, g)
  do.call(rbind, lapply(show, function(j)
    data.frame(group = g, item = item_lab[j], theta = grid,
               p = probtrace(extract.item(mod, j), grid)[, 2])))
}))
dif_curves$item <- factor(dif_curves$item, levels = item_lab[show])
ggplot(dif_curves, aes(theta, p, colour = group)) +
  geom_line(linewidth = 1) +
  facet_wrap(~ item) +
  scale_colour_manual(values = c(Yes = "#2166ac", No = "#d6604d"), breaks = c("Yes", "No"),
                      labels = c(Yes = "Native English speaker", No = "Non-native"), name = NULL) +
  labs(x = expression("Ability " * theta * " (native speakers' scale)"), y = "P(correct)") +
  theme_bw(base_size = 10) +
  theme(legend.position = "top") +
  ggtitle("The two items with the largest DIF, by group (multiple-group 2PL)")

At the same ability, a non-native speaker is much less likely to know that fly = soar than a native speaker, and much more likely to know that incipient = nascent. Measured on the anchor items, the non-native speakers are 1.17 SD below the native speakers, close to the difference in single-group scores (d = 1.17), so the conclusion of the native-language comparison above does not depend on the DIF items.

Adaptive Testing: A Simulation

A calibrated item bank can be given adaptively. A computerised adaptive test (CAT) chooses each next item to be as informative as possible at the current ability estimate and stops once the estimate is precise enough. The CAT with IRT and EAP section of this site builds the adaptive-testing functions in base R, adapted from the catR package (Magis & Raîche, 2012). Here the dichotomous versions run on the VIQT bank, with the 2PL parameters as a 4PL bank with \(c = 0\) and \(d = 1\). Each CAT starts at θ = 0, selects items by maximum Fisher information, estimates ability by EAP (normal prior, 33 quadrature points from -4 to 4; Bock & Mislevy, 1982), and stops when the standard error reaches 0.30 or the 45 items run out.

This is a post-hoc (real-data) simulation: the CAT gives each of 300 randomly chosen respondents the items it selects, and uses the answers that person actually gave to those items. The same respondents calibrated the items, which makes the results somewhat optimistic.

Code
source(paste0(directory, "R/cat_eap_common.R"))
source(paste0(directory, "R/cat_eap_dichotomous.R"))
bank <- cbind(a = pars[, "a"], b = pars[, "b"], c = 0, d = 1)

run_cat <- function(x, se_stop = 0.30) {
  given <- integer(0); est <- 0; se <- Inf
  while (se > se_stop && length(given) < nrow(bank)) {
    given <- c(given, compute_next_item_dichotomous(est, bank, out = given)$item)
    est   <- compute_eap_dichotomous(bank[given, , drop = FALSE], x[given])
    se    <- compute_eap_se_dichotomous(est, bank[given, , drop = FALSE], x[given])
  }
  c(theta = est, se = se, items = length(given))
}

set.seed(42)
sim <- sample(n_resp, 300)
cat_res <- as.data.frame(t(sapply(sim, function(i) run_cat(resp[i, ]))))
cat_res$full_mirt <- theta[sim]
cat_res$full_base <- sapply(sim, function(i) compute_eap_dichotomous(bank, resp[i, ]))
cat_res$reached   <- cat_res$se <= 0.30
cat(sprintf("Full-test EAP, base-R functions against mirt: largest difference %.4f\n",
            max(abs(cat_res$full_base - cat_res$full_mirt))))
Full-test EAP, base-R functions against mirt: largest difference 0.0363
Code
cat(sprintf("Reached SE <= 0.30: %.0f%% of simulees; items used: median %.0f, mean %.1f\nCorrelation of CAT and full-test abilities: %.3f\n",
            100 * mean(cat_res$reached), median(cat_res$items), mean(cat_res$items),
            cor(cat_res$theta, cat_res$full_mirt)))
Reached SE <= 0.30: 68% of simulees; items used: median 13, mean 22.7
Correlation of CAT and full-test abilities: 0.985
Code
cat_res$band <- cut(cat_res$full_mirt, c(-Inf, -1, 0, precise[2], Inf),
                    labels = c("below -1", "-1 to 0", sprintf("0 to %.2f", precise[2]),
                               sprintf("above %.2f", precise[2])))
cat_tab <- do.call(rbind, lapply(split(cat_res, cat_res$band), function(d)
  data.frame(n = nrow(d), reached = 100 * mean(d$reached), median_items = median(d$items),
             mean_abs_diff = mean(abs(d$theta - d$full_mirt)))))
knitr::kable(data.frame(band = rownames(cat_tab), cat_tab), digits = c(0, 0, 0, 0, 2), row.names = FALSE,
             col.names = c("Full-test ability", "Simulees", "% reaching SE 0.30",
                           "Median items", "Mean |CAT - full|"))
Full-test ability Simulees % reaching SE 0.30 Median items Mean |CAT - full|
below -1 43 93 10 0.14
-1 to 0 93 100 9 0.18
0 to 0.48 61 100 13 0.15
above 0.48 103 11 45 0.00
Code
ggplot(cat_res, aes(x = full_mirt, y = items, colour = reached)) +
  geom_vline(xintercept = precise[2], linetype = "dashed", colour = "grey50") +
  geom_point(alpha = 0.7, size = 1.8, position = position_jitter(height = 0.3, width = 0, seed = 1)) +
  scale_colour_manual(values = c(`TRUE` = "#2166ac", `FALSE` = "#d6604d"), breaks = c("TRUE", "FALSE"),
                      labels = c(`TRUE` = "SE 0.30 reached", `FALSE` = "All 45 items used, SE above 0.30"),
                      name = NULL) +
  labs(x = expression("Full-test ability " * theta), y = "Items administered") +
  theme_bw(base_size = 10) +
  theme(legend.position = "top") +
  ggtitle("Length of the simulated adaptive test",
          subtitle = "Dashed line: upper end of the range where the full test has SE <= 0.30")

The base-R EAP functions agree with mirt’s full-test scores to within 0.04 (the two use different quadrature grids). In the middle of the bank’s range the adaptive test is short: for simulees with full-test ability between -1 and 0 it reaches a standard error of 0.30 after a median of 9 items, 20% of the test. Towards the edges it needs more items. Above θ = 0.48, where even the full test has a standard error above 0.30, only 11% of the 103 simulees reach the target (EAP standard errors include the prior, so a few just above the line make it). The rest take all 45 items and end where the full test would. At the bottom the shortage is milder: 4 of the 7 simulees below θ = -2.03 reach the target. Overall, 68% of the simulees reach the target, those who do need a median of 10 items, and CAT and full-test abilities correlate 0.985. An adaptive version of the VIQT would save most items for average and below-average respondents, but it cannot fix the shortage of hard items; that needs new items.

Summary

Evidence Result
Answer key Codebook key reproduces the site’s number-right score for all 12,173 records
Classical reliability KR-20 = 0.92; corrected item-total r 0.08-0.66 (lowest for the easiest items; weakest of medium difficulty: Q7 deal = sale)
Dimensionality First tetrachoric eigenvalue 19.3 vs 3.4; second factor = 13 of the 14 easiest items; abilities without them r = 0.997
Model choice 2PL over Rasch (BIC lower by 13,631); 3PL adds little (abilities r = 0.997 with 2PL)
Overall fit (2PL) RMSEA 0.030, SRMSR 0.030, CFI 0.983; largest Q3 0.14
Precision Empirical reliability 0.91; SE <= 0.30 for theta -2.03 to 0.48; 33% of respondents above that range
Item fit 26 of 45 items p < .05 for S-X2, but RMSEA at most 0.023
Education Mean ability rises from -0.67 (less than high school) to 0.42 (graduate degree); same order at 25+
Native language Native speakers higher, d = 1.17; 1.17 SD on DIF-free anchor items
Age r = 0.47; native speakers peak at 61-70
DIF by native language 16 C items (8 easier for native, 8 for non-native speakers): everyday vs Latin-derived words
Adaptive testing (simulation) SE 0.30 reached by 68% after a median 10 items; r = 0.985 with the full test

References

Birnbaum, A. (1968). Some latent trait models and their use in inferring an examinee’s ability. In F. M. Lord & M. R. Novick, Statistical theories of mental test scores (pp. 397-479). Addison-Wesley.

Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability in a microcomputer environment. Applied Psychological Measurement, 6(4), 431-444.

Carroll, J. B. (1945). The effect of difficulty and chance success on correlations between items or between tests. Psychometrika, 10(1), 1-19.

Chalmers, R. P. (2012). mirt: A multidimensional item response theory package for the R environment. Journal of Statistical Software, 48(6), 1-29.

Christensen, K. B., Makransky, G., & Horton, M. (2017). Critical values for Yen’s Q3: Identification of local dependence in the Rasch model using residual correlations. Applied Psychological Measurement, 41(3), 178-194.

Dorans, N. J., & Holland, P. W. (1993). DIF detection and description: Mantel-Haenszel and standardization. In P. W. Holland & H. Wainer (Eds.), Differential item functioning (pp. 35-66). Lawrence Erlbaum.

Holland, P. W., & Thayer, D. T. (1988). Differential item performance and the Mantel-Haenszel procedure. In H. Wainer & H. I. Braun (Eds.), Test validity (pp. 129-145). Lawrence Erlbaum.

Horn, J. L., & Cattell, R. B. (1966). Refinement and test of the theory of fluid and crystallized general intelligences. Journal of Educational Psychology, 57(5), 253-270.

Kuder, G. F., & Richardson, M. W. (1937). The theory of the estimation of test reliability. Psychometrika, 2(3), 151-160.

Magis, D., & Raîche, G. (2012). Random generation of response patterns under computerized adaptive testing with the R package catR. Journal of Statistical Software, 48(8), 1-31.

Maydeu-Olivares, A., & Joe, H. (2006). Limited information goodness-of-fit testing in multidimensional contingency tables. Psychometrika, 71(4), 713-732.

Orlando, M., & Thissen, D. (2000). Likelihood-based item-fit indices for dichotomous item response theory models. Applied Psychological Measurement, 24(1), 50-64.

Rasch, G. (1960). Probabilistic models for some intelligence and attainment tests. Danish Institute for Educational Research.

Yen, W. M. (1984). Effects of local item dependence on the fit and equating performance of the three-parameter logistic model. Applied Psychological Measurement, 8(2), 125-145.

Data: Open-Source Psychometrics Project, openpsychometrics.org/_rawdata.