Psychometric Tools in tirt: Information, Scoring, Fit, DIF, and Mixtures

1. Overview

Once item and person parameters have been estimated, a psychometric study usually continues with a battery of follow-up analyses: evaluating measurement precision, building score-conversion tables, checking person and item fit, screening for local dependence and differential item functioning, summarizing reliability, and sometimes probing for latent subpopulations. This vignette walks through the post-estimation tools in tirt. Every function accepts the item-parameter data frame produced by binary_irt(), polytomous_irt(), or mixed_irt(), so the workflow is uniform across models.

library(tirt)

We start from a small simulated 2PL data set and a quick calibration.

set.seed(2025)
sim <- sim_irt(
  n_people = 600,
  item_structure = list(list(model = "2PL", n_items = 12))
)
#> ----------------------------------------------------------------
#> Starting Simulation for N = 600 examinees...
#> >> Ability (Theta): Generated from N(mean=0.00, sd=1.00).
#> >> Block 1: 12 items using 2PL
#>    - Discrimination (a): Default values used (Fixed at 1).
#>    - Difficulty (b): Default values used (Fixed at 0).
#> ----------------------------------------------------------------
#> Constructing final data frames...
#> Simulation Complete.
#> Summary: 12 items, 600 examinees.
#> ----------------------------------------------------------------

fit <- binary_irt(sim$resp, model = "2PL", method = "EM",
                  control = list(max_iter = 20, verbose = FALSE))
head(fit$item_params)
#>     item discrimination discrimination_se difficulty difficulty_se number
#> 1 item_1          1.010             0.108     -0.057         0.089    600
#> 2 item_2          0.946             0.105     -0.001         0.094    600
#> 3 item_3          0.974             0.106      0.073         0.092    600
#> 4 item_4          0.866             0.102     -0.099         0.102    600
#> 5 item_5          0.906             0.103     -0.070         0.098    600
#> 6 item_6          0.970             0.106      0.016         0.092    600
#>   pvalue
#> 1  0.512
#> 2  0.500
#> 3  0.485
#> 4  0.518
#> 5  0.513
#> 6  0.497

2. Item and Test Information

item_info() returns the Fisher information of each item across a grid of ability values, and test_info() sums it into the test information function together with the conditional standard error of measurement (SEM).

theta_grid <- seq(-3, 3, by = 0.5)

# Item information (items in rows, theta in columns)
info <- item_info(fit$item_params, theta = theta_grid)
round(info[1:3, ], 3)
#>           -3  -2.5    -2  -1.5    -1  -0.5     0   0.5     1   1.5     2   2.5
#> item_1 0.047 0.074 0.110 0.156 0.205 0.243 0.255 0.236 0.194 0.145 0.101 0.067
#> item_2 0.047 0.070 0.102 0.140 0.180 0.212 0.224 0.212 0.180 0.140 0.102 0.070
#> item_3 0.043 0.066 0.098 0.139 0.183 0.220 0.237 0.227 0.195 0.151 0.109 0.075
#>            3
#> item_1 0.043
#> item_2 0.047
#> item_3 0.049

# Test information function and conditional SEM
tif <- test_info(fit$item_params, theta = theta_grid)
tif
#>    theta test_info       sem reliability
#> 1   -3.0 0.5479329 1.3509408   0.3539772
#> 2   -2.5 0.8379575 1.0924184   0.4559178
#> 3   -2.0 1.2398965 0.8980640   0.5535508
#> 4   -1.5 1.7458850 0.7568193   0.6358187
#> 5   -1.0 2.2902138 0.6607878   0.6960684
#> 6   -0.5 2.7337536 0.6048119   0.7321730
#> 7    0.0 2.9109843 0.5861113   0.7443099
#> 8    0.5 2.7414786 0.6039592   0.7327260
#> 9    1.0 2.2991850 0.6594973   0.6968948
#> 10   1.5 1.7505310 0.7558143   0.6364338
#> 11   2.0 1.2393432 0.8982645   0.5534405
#> 12   2.5 0.8341527 1.0949069   0.4547891
#> 13   3.0 0.5430402 1.3570131   0.3519287

# Where does the test measure most precisely?
tif$theta[which.max(tif$test_info)]
#> [1] 0

3. Summed-Score to Theta Conversion Table

Operational programs report scale scores from raw (summed) scores. score_table() produces the conversion using the Lord-Wingersky recursion, supporting expected a posteriori ("EAP"), weighted likelihood ("WLE"), and maximum likelihood ("MLE") scoring.

# EAP conversion table (0 to 12 correct)
score_table(fit$item_params, method = "EAP")
#>    summed_score  theta    se
#> 1             0 -1.845 0.643
#> 2             1 -1.468 0.605
#> 3             2 -1.131 0.572
#> 4             3 -0.826 0.548
#> 5             4 -0.541 0.532
#> 6             5 -0.269 0.522
#> 7             6 -0.004 0.519
#> 8             7  0.262 0.522
#> 9             8  0.534 0.531
#> 10            9  0.819 0.547
#> 11           10  1.124 0.571
#> 12           11  1.461 0.604
#> 13           12  1.840 0.643

# Maximum-likelihood conversion
score_table(fit$item_params, method = "MLE")
#>    summed_score  theta    se
#> 1             0 -4.000    NA
#> 2             1 -2.471 1.096
#> 3             2 -1.654 0.810
#> 4             3 -1.129 0.679
#> 5             4 -0.713 0.625
#> 6             5 -0.348 0.595
#> 7             6 -0.004 0.588
#> 8             7  0.339 0.595
#> 9             8  0.703 0.624
#> 10            9  1.117 0.678
#> 11           10  1.639 0.783
#> 12           11  2.449 1.054
#> 13           12  4.000    NA

4. Person Fit

person_fit() computes the lz standardized log-likelihood index to identify examinees whose response patterns are unlikely under the model.

pf <- person_fit(sim$resp, fit$item_params, fit$person_params)
head(pf)
#>   person n_items  theta loglik     lz  flag
#> 1      1      12  0.668 -7.585  0.102 FALSE
#> 2      2      12  0.325 -8.121  0.069 FALSE
#> 3      3      12  0.044 -8.410 -0.763 FALSE
#> 4      4      12  0.399 -8.259 -0.259 FALSE
#> 5      5      12  0.406 -8.186 -0.159 FALSE
#> 6      6      12 -1.591 -5.518  0.012 FALSE

# Number of examinees flagged as potentially misfitting
sum(pf$flag, na.rm = TRUE)
#> [1] 0

5. Item Fit

item_fit() returns the infit and outfit mean-square statistics and their standardized versions, for dichotomous and polytomous items alike.

item_fit(sim$resp, fit$item_params)
#>       item   n outfit outfit_z infit infit_z
#> 1   item_1 600  0.861   -3.893 0.892  -3.746
#> 2   item_2 600  0.875   -3.783 0.903  -3.518
#> 3   item_3 600  0.863   -3.989 0.901  -3.517
#> 4   item_4 600  0.894   -3.476 0.916  -3.257
#> 5   item_5 600  0.887   -3.550 0.909  -3.431
#> 6   item_6 600  0.871   -3.779 0.899  -3.621
#> 7   item_7 600  0.871   -3.849 0.902  -3.530
#> 8   item_8 600  0.848   -3.991 0.880  -3.998
#> 9   item_9 600  0.891   -3.482 0.911  -3.392
#> 10 item_10 600  0.834   -4.241 0.876  -4.036
#> 11 item_11 600  0.829   -4.278 0.875  -4.012
#> 12 item_12 600  0.869   -3.843 0.900  -3.561

6. Local Dependence (Yen’s Q3)

ld_stats() computes Yen’s Q3 residual correlations for every item pair. Large positive values signal that a pair depends on something beyond the common trait, which is the situation a testlet model is designed to absorb.

q3 <- ld_stats(sim$resp, fit$item_params)
round(q3[1:5, 1:5], 3)
#>        item_1 item_2 item_3 item_4 item_5
#> item_1  1.000 -0.039 -0.004 -0.067 -0.040
#> item_2 -0.039  1.000 -0.098  0.001 -0.155
#> item_3 -0.004 -0.098  1.000 -0.056 -0.105
#> item_4 -0.067  0.001 -0.056  1.000 -0.018
#> item_5 -0.040 -0.155 -0.105 -0.018  1.000

# Largest absolute residual correlation
attr(q3, "max_abs_q3")
#> [1] 0.1841257

7. Differential Item Functioning

dif() screens dichotomous items for DIF using the Mantel-Haenszel procedure (with the ETS delta effect size and A/B/C flags) and logistic regression (which separates uniform and non-uniform DIF). Here we plant DIF in item 3.

resp_dif <- sim$resp
grp <- rep(c("Reference", "Focal"), each = 300)
flip <- grp == "Focal" & resp_dif[[3]] == 1
resp_dif[[3]][flip] <- rbinom(sum(flip), 1, 0.55)

dif(resp_dif, group = grp)[, c("item", "MH_delta", "ETS_class", "LR_p")]
#>       item MH_delta ETS_class   LR_p
#> 1   item_1    0.175         A 0.6064
#> 2   item_2   -0.235         A 0.1189
#> 3   item_3    1.910         C 0.0000
#> 4   item_4   -0.587         A 0.2569
#> 5   item_5    0.599         A 0.3319
#> 6   item_6    0.155         A 0.3628
#> 7   item_7    0.434         A 0.4699
#> 8   item_8   -0.781         A 0.1236
#> 9   item_9   -0.820         A 0.2681
#> 10 item_10   -0.144         A 0.9380
#> 11 item_11   -0.262         A 0.0020
#> 12 item_12   -0.475         A 0.7313

8. Reliability

reliability() reports the empirical (marginal) reliability from person estimates and their standard errors, the model-based marginal reliability from the test information function, and classical Cronbach’s alpha from the raw responses.

reliability(person_params = fit$person_params,
            data = sim$resp,
            item_params = fit$item_params)
#>                   Index    Value
#> 1 empirical_reliability   0.7256
#> 2  marginal_reliability   0.6886
#> 3        cronbach_alpha   0.7070
#> 4               n_items  12.0000
#> 5             n_persons 600.0000

9. Test Characteristic Curve

tcc() returns the expected score of each item and the test characteristic curve, which maps ability onto the number-correct metric.

curves <- tcc(fit$item_params, theta = seq(-3, 3, by = 1))
curves$test_curve
#>   theta expected_score
#> 1    -3      0.6204589
#> 2    -2      1.5004155
#> 3    -1      3.2870410
#> 4     0      6.0113887
#> 5     1      8.7381246
#> 6     2     10.5220784
#> 7     3     11.3938127

10. Multidimensional and Mixed Simulation

Two new simulators extend the data-generation toolkit. sim_mirt() produces compensatory multidimensional data (aligned with mirt_binary()), and sim_tirt() produces forms that mix independent items with testlets (aligned with irt_trt()).

# Two correlated dimensions, simple structure
mdat <- sim_mirt(
  n_people = 400,
  dimension = 2,
  Sigma = matrix(c(1, 0.4, 0.4, 1), 2, 2),
  item_structure = list(
    list(model = "M2PL", n_items = 6, dims = 1),
    list(model = "M2PL", n_items = 6, dims = 2)
  )
)
#> ----------------------------------------------------------------
#> Starting Multidimensional Simulation (N = 400, D = 2)...
#> >> Ability (Theta): Generated from MVN with mean 0.00 and supplied Sigma.
#> >> Block 1: 6 items (M2PL), loading on dim(s) 1
#> >> Block 2: 6 items (M2PL), loading on dim(s) 2
#> ----------------------------------------------------------------
#> Constructing final data frames...
#> Simulation Complete.
#> Summary: 12 items, 400 examinees, 2 dimensions.
#> ----------------------------------------------------------------
head(mdat$true_params)
#>   item_id block model categories    a_Dim1 a_Dim2          d guessing
#> 1  item_1     1  M2PL          2 0.8192317      0  0.8491260        0
#> 2  item_2     1  M2PL          2 1.7109341      0  0.5145288        0
#> 3  item_3     1  M2PL          2 1.7031725      0 -1.0493497        0
#> 4  item_4     1  M2PL          2 0.9961762      0  1.4578654        0
#> 5  item_5     1  M2PL          2 1.5794204      0  0.5032779        0
#> 6  item_6     1  M2PL          2 1.2982607      0  1.3152575        0
# Independent items plus two testlets
tdat <- sim_tirt(
  n_people = 400,
  item_structure = list(
    list(model = "2PL",  n_items = 6),
    list(model = "2PLT", n_items = 4, testlet_id = "P1", testlet_var = 0.6),
    list(model = "GPCT", n_items = 3, categories = 3, testlet_id = "P2")
  )
)
#> ================================================================
#>    STARTING MIXED IRT/TRT SIMULATION (N = 400)
#> ================================================================
#> >> Ability (Theta): Generated from N(mean=0.00, sd=1.00).
#> >> Testlet Effects (Gamma):
#>    - Testlet 'P1': Generated Gamma ~ N(0, 0.60) [User Var].
#>    - Testlet 'P2': Generated Gamma ~ N(0, 0.50) [Default].
#> >> Block 1: 6 items (Model: 2PL, Independent)
#> >> Block 2: 4 items (Model: 2PLT, Testlet: P1)
#> >> Block 3: 3 items (Model: GPCT, Testlet: P2)
#> ================================================================
#> Constructing final data frames...
#> Simulation Complete.
#> Summary: 13 items (2 testlets), 400 examinees.
#> ================================================================
tdat$true_item_params[, c("item_id", "model", "testlet")]
#>    item_id model testlet
#> 1   item_1   2PL    <NA>
#> 2   item_2   2PL    <NA>
#> 3   item_3   2PL    <NA>
#> 4   item_4   2PL    <NA>
#> 5   item_5   2PL    <NA>
#> 6   item_6   2PL    <NA>
#> 7   item_7  2PLT      P1
#> 8   item_8  2PLT      P1
#> 9   item_9  2PLT      P1
#> 10 item_10  2PLT      P1
#> 11 item_11  GPCT      P2
#> 12 item_12  GPCT      P2
#> 13 item_13  GPCT      P2

11. Mixture (Latent-Class) IRT

mixture_irt() fits a mixture Rasch or 2PL model, in which the population is a blend of latent classes with class-specific item parameters.

set.seed(11)
N <- 300; J <- 8
b1 <- seq(-1.5, 1.5, length.out = J); b2 <- rev(b1)
theta <- rnorm(N); cls <- rep(1:2, each = N / 2)
rmat <- matrix(0, N, J)
for (i in 1:N) {
  b <- if (cls[i] == 1) b1 else b2
  rmat[i, ] <- rbinom(J, 1, 1 / (1 + exp(-(theta[i] - b))))
}
mdf <- as.data.frame(rmat); names(mdf) <- paste0("I", 1:J)

mix <- mixture_irt(mdf, n_class = 2, model = "Rasch",
                   control = list(max_iter = 40, verbose = FALSE))
mix$class_params
#>   class proportion
#> 1     1     0.4873
#> 2     2     0.5127
mix$model_fit
#>           Index     Value
#> 1 LogLikelihood -1533.969
#> 2           AIC  3101.938
#> 3           BIC  3164.902
#> 4       n_class     2.000
#> 5       entropy     0.613

12. Final Comment

Together with the estimation, calibration, and equating functions described in the companion vignette, these tools cover the routine post-estimation workflow of an operational testing program. For details on any function, use the help system, for example ?score_table.