--- title: "Introduction to tirt: Testlet Item Response Theory" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Introduction to tirt: Testlet Item Response Theory} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` **1. Overview** The **tirt** package provides a light-version user-friendly comprehensive toolkit for **Item Response Theory (IRT)** and **Testlet Response Theory (TRT)** analysis. It supports: 1. **Data Simulation**: Generating data for simple IRT and Testlet models. 2. **Estimation**: Marginal Maximum Likelihood (MML) and Joint Maximum Likelihood (JML) estimation. 3. **Fixed Calibration**: Estimating item parameters with fixed persons, or person parameters with fixed items. 4. **Equating**: Linking scales using Mean-Mean, Mean-Sigma, and Stocking-Lord methods. This vignette walks through the primary workflows. ```{r setup} library(tirt) ``` **2. Data Simulation** Before estimating models, let's generate some synthetic data using sim_irt (standard IRT) and sim_trt (testlet structure). **2.1 Standard IRT Simulation** We generate responses for **500 examinees** on **15 items** using the *2-Parameter Logistic (2PL)* model. ```{r sim_irt} set.seed(123) # Define item structure: 1 block of 15 items using 2PL sim_data <- sim_irt( n_people = 500, item_structure = list( list(model = "2PL", n_items = 15) ), theta_mean = 0, theta_sd = 1 ) # Access responses and true parameters head(sim_data$resp) head(sim_data$true_params) ``` **2.2 Testlet Simulation** For **Testlet Response Theory**, we define blocks that share a common local dependency (Gamma). ```{r sim_trt} # Define 2 testlets with 5 items each trt_data <- sim_trt( n_people = 500, item_structure = list( list(model = "2PLT", n_items = 5, testlet_id = "T1"), list(model = "2PLT", n_items = 5, testlet_id = "T2") ) ) head(trt_data$resp) ``` **3. IRT Estimation** We can estimate parameters using **binary_irt** (for dichotomous data), **polytomous_irt** (for graded data), or **mixed_irt** (for both). Here, we estimate the parameters for the data we just simulated. ```{r binary_irt} # Estimate 2PL model # Note: max_iter set low for vignette speed; use default (100) in practice fit_2pl <- binary_irt( data = sim_data$resp, model = "2PL", method = "EM", control = list(max_iter = 20, verbose = FALSE) ) # View estimated item parameters print(head(fit_2pl$item_params)) # View model fit indices print(fit_2pl$model_fit) ``` **4. Testlet (TRT) Estimation** When items are grouped into testlets (e.g., reading passages), standard IRT assumptions are violated. tirt allows you to model this dependency using **Bi-factor** or **Testlet** models. This package allows estimations for binary and polytomous testlet models We use **trt_binary** for this example. We must define the group structure. ```{r trt_binary} # Items 1-5 are Testlet 1, Items 6-10 are Testlet 2 testlet_structure <- list( c(1, 2, 3, 4, 5), # Indices for Testlet 1 c(6, 7, 8, 9, 10) # Indices for Testlet 2 ) fit_trt <- trt_binary( data = trt_data$resp, group = testlet_structure, model = "2PLT", method = "EM", control = list(max_iter = 15, verbose = FALSE) ) # The output includes item parameters head(fit_trt$item_params) # And person parameters including the testlet specific effects (Gamma) head(fit_trt$person_params) ``` **5. Universal Estimation (Mixed standaline & testlet items)** Real-world exams often contain a mix of independent items (i.e., using standard IRT) and item bundles/testlets (i.e., using TRT). The **irt_trt** function is a universal estimator that handles both simultaneously. It can also handle mixed data types (binary and polytomous) in the same run. To use **irt_trt**, you must provide an *item_spec* data frame that maps each item to its model and testlet ID (if any). **5.1. Step 1: Simulate Mixed Structure Data** We will generate data for 1000 examinees with: 1. **10 Independent Binary Items** (2PL Model) 2. **5 Independent Polytomous Items** (GRM Model) 3. **2 Testlet Items** belonging to Testlet "T1" (2PLT Model) 4. **3 Testlet Items** belonging to Testlet "T2" (GPCMT Model) ```{r sim} cat("\n\n--- Generating Simulated Data ---\n") set.seed(2025) N <- 1000 # 20 Items Total: # 1-10: Binary (2PL) - Independent # 11-15: Polytomous (GRM, 3 cats) - Independent # 16-17: Binary (2PLT) - Testlet 1 # 18-20: Polytomous (GPCT, 3 cats) - Testlet 2 theta <- rnorm(N, 0, 1) gamma_1 <- rnorm(N, 0, 0.5) # Testlet 1 effect gamma_2 <- rnorm(N, 0, 0.6) # Testlet 2 effect resp_matrix <- matrix(NA, N, 20) item_names <- paste0("Item_", 1:20) colnames(resp_matrix) <- item_names # Define Item Parameters (True Values) a_true <- runif(20, 0.8, 1.5) b_true <- seq(-1.5, 1.5, length.out = 20) # 1. Simulate Binary Independent (1-10) for(j in 1:10) { p <- 1 / (1 + exp(-a_true[j] * (theta - b_true[j]))) resp_matrix[,j] <- rbinom(N, 1, p) } # 2. Simulate Poly Independent (11-15) - GR for(j in 11:15) { b_k <- sort(c(b_true[j] - 0.7, b_true[j] + 0.7)) p1 <- 1 / (1 + exp(-a_true[j] * (theta - b_k[1]))) p2 <- 1 / (1 + exp(-a_true[j] * (theta - b_k[2]))) # GRM Probabilities prob_0 <- 1 - p1 prob_1 <- p1 - p2 prob_2 <- p2 # Sample r <- runif(N) resp_matrix[,j] <- ifelse(r < prob_0, 0, ifelse(r < prob_0 + prob_1, 1, 2)) } # 3. Simulate Binary Testlet 1 (16-17) for(j in 16:17) { eff_theta <- theta + gamma_1 p <- 1 / (1 + exp(-a_true[j] * (eff_theta - b_true[j]))) resp_matrix[,j] <- rbinom(N, 1, p) } # 4. Simulate Poly Testlet 2 (18-20) - GPCMT for(j in 18:20) { eff_theta <- theta + gamma_2 # GPCM steps (centered at b_true) b_vec <- c(b_true[j] - 0.5, b_true[j] + 0.5) numer <- matrix(0, N, 3) numer[,1] <- 0 numer[,2] <- a_true[j] * (eff_theta - b_vec[1]) numer[,3] <- a_true[j] * (eff_theta - b_vec[1]) + a_true[j] * (eff_theta - b_vec[2]) max_n <- apply(numer, 1, max) exps <- exp(numer - max_n) probs <- exps / rowSums(exps) r <- runif(N) resp_matrix[,j] <- apply(cbind(r, probs), 1, function(x) { if(x[1] < x[2]) return(0) if(x[1] < x[2]+x[3]) return(1) return(2) }) } sim_data <- as.data.frame(resp_matrix) #view the data print(head(sim_data)) ``` **5.2. Step 2: Create the Item Specification** The item_spec data frame tells the function how to treat each item. 1. **item**: Must match column names in data. 2. **model**: The model to use (e.g., "2PL", "2PLT", "GPCMT", "RaschT", etc.). 3. **testlet**: The ID of the testlet. Use NA for independent items. ```{r} # Create Item Specification spec <- data.frame( item = item_names, model = c(rep("2PL", 10), rep("GRM", 5), rep("2PLT", 2), rep("GPCMT", 3)), testlet = c(rep(NA, 15), rep("testlet1", 2), rep("testlet2", 3)), stringsAsFactors = FALSE ) #view the spec print(spec) ``` **5.3. Step 3: Run Estimation** We run the estimation. The function automatically detects that "T1" items share a nuisance dimension (Gamma) while the others depend only on Theta. ```{r} # Run the Universal Function results <- irt_trt(sim_data, spec, method = "EM", control = list(max_iter=50, verbose=TRUE)) # Save Results item_par=results$item_params fit=results$model_fit person_par=results$person_params ``` **6. Fixed Parameter Calibration** In equating scenarios or computer-adaptive testing, we often need to estimate one set of parameters while holding the other fixed. **6.1 Fixed Item Calibration (Estimating Person Ability)** If we know the item parameters (e.g., from an item bank), we can estimate person ability. Below we first simulate items, including some items have "known parameters" and some items are "new items". ```{r simulation} # --- 1. Simulation Helper Functions --- # Function to simulate Binary 2PL sim_2pl <- function(theta, a, b) { D <- 1.7 prob <- 1 / (1 + exp(-D * a * (theta - b))) resp <- ifelse(runif(length(theta)) < prob, 1, 0) return(resp) } # Function to simulate Polytomous (GPCM structure to match fixed_item logic) sim_poly <- function(theta, a, steps) { n_cat <- length(steps) + 1 n_stud <- length(theta) # Calculate numerators for categories 0, 1, 2... # P(k) proportional to exp( sum(a*(theta - step_j)) ) probs <- matrix(0, nrow = n_stud, ncol = n_cat) probs[, 1] <- 1 # Reference category (unnormalized exp is 1 or e^0) current_sum <- 0 for (k in 2:n_cat) { # Step parameters correspond to boundaries current_sum <- current_sum + a * (theta - steps[k-1]) probs[, k] <- exp(current_sum) } # Normalize prob_sum <- rowSums(probs) probs_final <- probs / prob_sum # Random draw resp <- numeric(n_stud) for (i in 1:n_stud) { resp[i] <- sample(0:(n_cat-1), 1, prob = probs_final[i, ]) } return(resp) } # --- 2. Generate Parameters for 50 Items --- n_students <- 5497 true_theta <- rnorm(n_students, mean = 0.4, sd = 1.7) all_items_meta <- list() response_matrix <- matrix(NA, nrow = n_students, ncol = 50) colnames(response_matrix) <- paste0("Item_", 1:50) # --- Set 1: Known Dichotomous (Items 1-10) --- # Model: 2PL for (i in 1:10) { a <- runif(1, 0.8, 1.4) b <- rnorm(1, 0, 1) resp <- sim_2pl(true_theta, a, b) response_matrix[, i] <- resp all_items_meta[[i]] <- list(item = colnames(response_matrix)[i], type = "Known", model = "2PL", a = a, b = b) } # --- Set 2: Known Polytomous (Items 11-18) --- # Model: Poly (3 categories: 0, 1, 2). Requires 2 steps (d1, d2). for (i in 11:18) { a <- runif(1, 0.7, 1.2) d1 <- rnorm(1, -0.5, 0.3) d2 <- rnorm(1, 0.5, 0.3) # Steps usually ordered resp <- sim_poly(true_theta, a, c(d1, d2)) response_matrix[, i] <- resp all_items_meta[[i]] <- list(item = colnames(response_matrix)[i], type = "Known", model = "Poly", a = a, d1 = d1, d2 = d2) } # --- Set 3: Unknown Dichotomous (Items 19-40) --- # 22 Items for (i in 19:40) { a <- runif(1, 0.8, 1.4) b <- rnorm(1, 0, 1) resp <- sim_2pl(true_theta, a, b) response_matrix[, i] <- resp all_items_meta[[i]] <- list(item = colnames(response_matrix)[i], type = "Unknown", model = "2PL", a = a, b = b) } # --- Set 4: Unknown Polytomous (Items 41-50) --- # 10 Items (Varying categories: some 3 cat, some 4 cat) for (i in 41:50) { a <- runif(1, 0.7, 1.2) # Mix of 3 categories (2 steps) and 4 categories (3 steps) if (i %% 2 == 0) { steps <- c(-0.8, 0, 0.8) # 4 cats } else { steps <- c(-0.5, 0.5) # 3 cats } resp <- sim_poly(true_theta, a, steps) response_matrix[, i] <- resp all_items_meta[[i]] <- list(item = colnames(response_matrix)[i], type = "Unknown", model = "Poly", a = a, steps = steps) } #check response_df <- as.data.frame(response_matrix) head(response_df[, c(1:3, 11:13, 41:43)]) # Peak at data ``` Then we create a necessary data structure for the next calibration step. In this step, we need to specify known item with corresponding parameters. ```{r} # Create empty dataframe structure known_params_df <- data.frame( item = character(), model = character(), a = numeric(), b = numeric(), d1 = numeric(), d2 = numeric(), stringsAsFactors = FALSE ) # Populate with Known Items (1-18) for (i in 1:18) { meta <- all_items_meta[[i]] if (meta$model == "2PL") { # Add row for Binary known_params_df <- rbind(known_params_df, data.frame( item = meta$item, model = "2PL", a = meta$a, b = meta$b, d1 = NA, d2 = NA )) } else { # Add row for Poly # Note: Using "GPCM" model string as our function uses GPCM math known_params_df <- rbind(known_params_df, data.frame( item = meta$item, model = "GPCM", a = meta$a, b = NA, d1 = meta$d1, d2 = meta$d2 )) } } # Display input to verify print("Known Item Parameters Input:") print(known_params_df) ``` Finally, we can conduct a fiex-item calibration and check the final results. ```{r} # Run the calibration # We set model_default = "2PL" (this applies to unknown binary items). # The function automatically detects poly items in the unknown set based on response categories. results <- fixed_item( response_df = response_df, item_params_df = known_params_df, control = list(max_iter = 100) ) # Check that Items 1-18 are "Fixed" and match inputs. # Check that Items 19-50 are "Estimated". # Note how Poly items have step_1, step_2, etc. item=results$item_params person=results$person_params fit=results$model_fit ``` **6.2 Fixed Person Calibration (Estimating Item Difficulty)** Conversely, if we have known person abilities, we can calibrate new items. In this estimation, you can also choose to model a covariate if you believe that is releated to your latent variables. If not specified, then there will be no covariate in the modeling process. The final output also contains some classic item statistics such as p-values and mean of item scores. ```{r fix_person} # --- Example: With Package Data --- data("ela1", package = "tirt") # Select Item Responses (Cols 1-30) df_real <- ela1[, 1:30] fixed_theta <- ela1$THETA fixed_cov <- ela1$COVARIATE real_res <- fix_person(df = df_real, theta = fixed_theta, model = "2PL", covariate = fixed_cov) head(real_res) ``` **7. Equating / Linking** **equate_irt** links two scales (Form X and Form Y) using common anchor items. ```{r equate_irt} # Create dummy parameters for Form X (Base) base_params <- data.frame( item = c("Item1", "Item2", "Item3", "Item4", "Item5"), model = "2PL", a = c(1.0, 1.2, 0.9, 1.1, 1.0), b = c(-1.0, -0.5, 0.0, 0.5, 1.0), stringsAsFactors = FALSE ) # Create dummy parameters for Form Y (New) # Suppose Form Y is harder and has different discrimination scaling new_params <- data.frame( item = c("Item1", "Item2", "Item3", "Item6", "Item7"), # Items 1-3 are anchors model = "2PL", a = c(1.0, 1.2, 0.9, 1.1, 1.0) / 0.9, # Scale shift b = (c(-1.0, -0.5, 0.0, 0.8, 1.2) * 0.9) + 0.2, # Location shift stringsAsFactors = FALSE ) # Perform Equating linked <- equate_irt( base_params = base_params, new_params = new_params, methods = c("Stocking-Lord", "Mean-Mean") ) # View Linking Constants (A and B) print(linked$linking_constants) # View Transformed Parameters for Form Y (put on Form X scale) print(linked$transformed_item_params) ``` **8. Final Comment** The **tirt** package streamlines complex psychometric analyses. For detailed documentation on specific functions, please use the help command, e.g., *?binary_irt*.