--- title: "Advanced Item Selection: Content Balancing, Exposure Control, and Shadow CAT" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Advanced Item Selection: Content Balancing, Exposure Control, and Shadow CAT} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(cdCAT) set.seed(42) ``` ## Overview This vignette covers three advanced item selection features available in `cdCAT`: | Feature | Parameter | Purpose | |---|---|---| | Content balancing | `content`, `content_prop` | Keep domain coverage proportional to a blueprint | | Exposure control | `exposure` | Limit overuse of specific items | | Shadow CAT | `constr_fun` | Enforce arbitrary test assembly constraints | All three can be combined with any adaptive criterion (PWKL, KL, MPWKL, SHE) and work through the same `CdcatSession` interface. --- ## Shared Item Bank All examples in this vignette use the same 12-item DINA bank with three content domains and two attributes. ```{r item-bank} # Q-matrix: 12 items x 2 attributes # Items 1-4: domain "Algebra" (attribute 1 only) # Items 5-8: domain "Geometry" (attribute 2 only) # Items 9-12: domain "Mixed" (both attributes) Q <- matrix(c( 1, 0, # item 1 1, 0, # item 2 1, 0, # item 3 1, 0, # item 4 0, 1, # item 5 0, 1, # item 6 0, 1, # item 7 0, 1, # item 8 1, 1, # item 9 1, 1, # item 10 1, 1, # item 11 1, 1 # item 12 ), nrow = 12, ncol = 2, byrow = TRUE) slip <- c(0.10, 0.12, 0.08, 0.11, # Algebra 0.10, 0.09, 0.12, 0.11, # Geometry 0.10, 0.11, 0.09, 0.12) # Mixed guess <- c(0.20, 0.18, 0.22, 0.19, # Algebra 0.20, 0.21, 0.18, 0.20, # Geometry 0.15, 0.17, 0.16, 0.18) # Mixed items <- cdcat_items( q_matrix = Q, model = "DINA", slip = slip, guess = guess ) # Content domain vector (one label per item) content <- c( rep("Algebra", 4), rep("Geometry", 4), rep("Mixed", 4) ) print(items) ``` --- ## 1. Content Balancing ### Concept Without content balancing, the adaptive algorithm selects whichever item maximises the criterion score, which can exhaust one domain while leaving others barely represented. Content balancing enforces a **blueprint** (target proportions per domain) by restricting each selection step to the most under-represented domain (Kingsbury & Zara, 1991). At each step, `cdCAT` computes the **gap** for every domain: ``` gap_d = target_proportion_d - observed_proportion_d ``` The domain with the largest gap becomes the candidate pool for that step. If no candidate items belong to that domain, the full pool is used as a safe fallback. ### Setup ```{r content-setup} # Target: 33% from each domain content_prop <- c( Algebra = 1/3, Geometry = 1/3, Mixed = 1/3 ) ``` ### Running a session with content balancing ```{r content-session} session_cb <- CdcatSession$new( items = items, criterion = "PWKL", method = "MAP", min_items = 9L, # force all items to be administered for illustration max_items = 9L, content = content, content_prop = content_prop ) print(session_cb) # Simulate a respondent who masters both attributes simulated_responses <- c(1, 1, 1, 1, # Algebra items (correct) 0, 0, 0, 0, # Geometry items (incorrect) 1, 0, 1, 0) # Mixed items (mixed) repeat { item <- session_cb$next_item() if (item == 0) break session_cb$update(item, simulated_responses[item]) } res_cb <- session_cb$result() ``` ### Inspecting the domain distribution ```{r content-results} domain_counts <- table(content[res_cb$administered]) domain_prop <- round(domain_counts / res_cb$n_items, 2) cat("Items administered:", res_cb$administered, "\n") cat("Domain counts :\n") print(domain_counts) cat("Domain proportions:\n") print(domain_prop) cat("Target proportions:", round(content_prop, 2), "\n") ``` With `min_items = max_items = 9L` and a perfect 1/3 blueprint, each domain contributes exactly 3 items regardless of criterion scores. ### `apply_content_balancing()` directly You can also call the function outside a session, for example to inspect which items would be selected at a given state: ```{r content-direct} # After administering items 1 and 2 (both Algebra), # the gap favours Geometry or Mixed candidates <- apply_content_balancing( candidate_items = 3:12, administered = c(1L, 2L), content = content, content_prop = content_prop ) cat("Filtered candidates:", candidates, "\n") cat("Their domains :", content[candidates], "\n") ``` --- ## 2. Exposure Control ### Concept Adaptive tests tend to overuse a small subset of highly informative items, which can compromise test security and statistical properties. `cdCAT` supports two exposure control methods: | Method | Trigger | Mechanism | |---|---|---| | **Sympson-Hetter** | all `exposure` values in `[0, 1]` | Each item has an acceptance probability; best item is kept only if it passes a random draw | | **Randomesque** | all `exposure` values `>= 1` | At position k, a random draw is made from the top-`exposure[k]` candidates | Both methods accept a numeric vector of length J (one entry per item). ### 2a. Sympson-Hetter Values close to 1 let an item pass almost always; values close to 0 make it rarely selected. ```{r sh-session} # Items 9-12 (Mixed) are very informative; limit their exposure to 60% exposure_sh <- rep(0.9, 12) exposure_sh[9:12] <- 0.8 session_sh <- CdcatSession$new( items = items, criterion = "PWKL", method = "MAP", min_items = 6L, max_items = 6L, exposure = exposure_sh ) print(session_sh) repeat { item <- session_sh$next_item() if (item == 0) break session_sh$update(item, simulated_responses[item]) } res_sh <- session_sh$result() cat("Items administered:", res_sh$administered, "\n") cat("Estimated profile :", res_sh$alpha_hat, "\n") ``` ### 2b. Randomesque `exposure[k]` controls how many top-scoring items are pooled for a random draw when selecting the k-th item. `exposure[k] = 1` is identical to greedy selection; `exposure[k] = 3` means the 3 best items compete equally. ```{r rq-session} # At positions 1-3 draw from top-3; positions 4-6 draw from top-2 exposure_rq <- rep(1L, 12) exposure_rq[1:3] <- 3L exposure_rq[4:6] <- 2L session_rq <- CdcatSession$new( items = items, criterion = "PWKL", method = "MAP", min_items = 6L, max_items = 6L, exposure = exposure_rq ) print(session_rq) repeat { item <- session_rq$next_item() if (item == 0) break session_rq$update(item, simulated_responses[item]) } res_rq <- session_rq$result() cat("Items administered:", res_rq$administered, "\n") cat("Estimated profile :", res_rq$alpha_hat, "\n") ``` ### Using exposure control functions directly ```{r exposure-direct} # Sympson-Hetter: item 10 has score 0.9 but only 20% acceptance probability scores <- c(0.4, 0.6, 0.7, 0.9, 0.3, 0.5) available <- 7:12 # Global exposure vector (length = total items in bank) p_sh <- rep(0.9, 12) p_sh[10] <- 0.2 # item with score 0.9 set.seed(123) selected <- apply_sympson_hetter(scores, available, p_sh) cat("Selected item (Sympson-Hetter):", selected, "\n") # Randomesque: draw from top-2 selected_rq <- apply_randomesque(scores, available, n = 2L) cat("Selected item (Randomesque) :", selected_rq, "\n") ``` --- ## 3. Shadow CAT ### Concept Shadow CAT (van der Linden, 2005) builds a **shadow test** at each step: a complete test form that satisfies all assembly constraints and contains the next item to be administered. This allows complex combinatorial constraints (maximum-information subject to content, enemy items, item overlap limits, etc.) to be enforced through integer programming. In `cdCAT`, shadow mode is activated by supplying a `constr_fun`. The function receives the full-bank criterion scores and returns the index of the next item: ```r constr_fun <- function(scores, items, administered) { # scores : numeric vector length J, one score per item # items : cdcat_items object (Q-matrix, parameters, ...) # administered : integer vector of already-administered item indices # return : single integer -- index of the next item } ``` `cdCAT` is solver-agnostic: any optimisation library (`lpSolve`, `ROI`, `ompr`, ...) can be used inside `constr_fun`. ### Example 1 -- Greedy shadow (no external solver) The simplest shadow function just picks the highest-scoring non-administered item -- equivalent to standard greedy, but written in the shadow API: ```{r shadow-greedy} greedy_shadow <- function(scores, items, administered) { scores[administered] <- -Inf which.max(scores) } session_shadow_greedy <- CdcatSession$new( items = items, criterion = "PWKL", method = "MAP", min_items = 6L, max_items = 6L, constr_fun = greedy_shadow ) print(session_shadow_greedy) ``` ### Example 2 -- Content and overlap constraints (no solver) A more realistic shadow function enforces: 1. No more than 2 items from the same domain in any 4-item window. 2. Items 3 and 7 are "enemy items" -- they cannot both appear. ```{r shadow-custom} make_constrained_shadow <- function(content, enemy_pairs) { function(scores, items, administered) { J <- items$n_items available <- setdiff(seq_len(J), administered) if (length(available) == 0) return(NA_integer_) # --- Enemy item constraint for (pair in enemy_pairs) { if (pair[1] %in% administered) available <- setdiff(available, pair[2]) if (pair[2] %in% administered) available <- setdiff(available, pair[1]) } if (length(available) == 0) available <- setdiff(seq_len(J), administered) # fallback # --- Domain cap: at most 2 items per domain in any window of 4 if (length(administered) > 0) { domain_counts <- table(content[administered]) capped_domains <- names(domain_counts[domain_counts >= 2]) if (length(capped_domains) > 0 && length(available) > 1) { filtered <- available[!content[available] %in% capped_domains] if (length(filtered) > 0) available <- filtered } } # --- Select highest-scoring item from filtered pool available[which.max(scores[available])] } } constr_fn <- make_constrained_shadow( content = content, enemy_pairs = list(c(3L, 7L)) # items 3 and 7 cannot coexist ) session_shadow <- CdcatSession$new( items = items, criterion = "PWKL", method = "MAP", min_items = 8L, max_items = 8L, constr_fun = constr_fn ) repeat { item <- session_shadow$next_item() if (item == 0) break session_shadow$update(item, simulated_responses[item]) } res_shadow <- session_shadow$result() cat("Items administered:", res_shadow$administered, "\n") cat("Domains :", content[res_shadow$administered], "\n") # Verify enemy constraint: items 3 and 7 do not coexist has_3 <- 3L %in% res_shadow$administered has_7 <- 7L %in% res_shadow$administered cat("Enemy pair (3, 7) both present:", has_3 & has_7, "\n") ``` ### Example 3 -- LP-based shadow test with `lpSolve` When `lpSolve` is available, you can solve the full integer programme at each step. The constraint function receives scores as the objective vector: ```{r shadow-lp, eval=FALSE} # This example requires: install.packages("lpSolve") make_lp_shadow <- function(content, content_prop, n_items_total) { function(scores, items, administered) { J <- items$n_items resp <- integer(J) resp[administered] <- 1L # Build constraint matrix # Row 1: total items == n_items_total # Rows 2-4: domain proportions (each domain gets floor(n_items_total/3) items) n_per_domain <- floor(n_items_total / length(content_prop)) domains <- names(content_prop) n_constr <- 1L + length(domains) lhs <- matrix(0, nrow = n_constr, ncol = J) dirs <- character(n_constr) rhs <- numeric(n_constr) # Already-administered items must stay lhs <- rbind(lhs, resp) dirs <- c(dirs, "==") rhs <- c(rhs, sum(resp)) # Row 1: total items lhs[1, ] <- 1 dirs[1] <- "==" rhs[1] <- n_items_total # Rows 2+: per-domain counts for (i in seq_along(domains)) { lhs[i + 1L, content == domains[i]] <- 1 dirs[i + 1L] <- ">=" rhs[i + 1L] <- n_per_domain } obj <- scores obj[administered] <- obj[administered] * resp[administered] out <- lpSolve::lp( direction = "max", objective.in = obj, const.mat = lhs, const.dir = dirs, const.rhs = rhs, all.bin = TRUE ) if (out$status != 0L) stop("lpSolve could not find a feasible solution.") solution <- out$solution solution[administered] <- 0 if (sum(solution) == 0L) return(NA_integer_) as.integer(which.max(solution * scores)) } } session_lp <- CdcatSession$new( items = items, criterion = "PWKL", method = "MAP", min_items = 9L, max_items = 9L, constr_fun = make_lp_shadow(content, content_prop, n_items_total = 9L) ) repeat { item <- session_lp$next_item() if (item == 0) break session_lp$update(item, simulated_responses[item]) } res_lp <- session_lp$result() cat("Items administered:", res_lp$administered, "\n") cat("Domains :", content[res_lp$administered], "\n") ``` --- ## 4. Combining Features Content balancing and exposure control can be combined in the same session. Shadow mode bypasses both (the constraint function is responsible for all assembly requirements). ```{r combined} # Content balancing + Sympson-Hetter exposure exposure_combined <- rep(0.9, 12) exposure_combined[9:12] <- 0.5 # limit Mixed items session_combined <- CdcatSession$new( items = items, criterion = "PWKL", method = "MAP", min_items = 6L, max_items = 6L, content = content, content_prop = content_prop, exposure = exposure_combined ) print(session_combined) repeat { item <- session_combined$next_item() if (item == 0) break session_combined$update(item, simulated_responses[item]) } res_combined <- session_combined$result() cat("Items administered:", res_combined$administered, "\n") cat("Domains :", content[res_combined$administered], "\n") cat("Estimated profile :", res_combined$alpha_hat, "\n") ``` --- ## Summary | Feature | Key parameter | When to use | |---|---|---| | Content balancing | `content` + `content_prop` | Blueprint-driven assessments | | Sympson-Hetter | `exposure` in `[0,1]` | Probabilistic item-level exposure limits | | Randomesque | `exposure >= 1` | Position-level top-n random draw | | Shadow CAT | `constr_fun` | Arbitrary combinatorial constraints, LP-based assembly | ## References Kingsbury, G. G., & Zara, A. R. (1991). A comparison of procedures for content-sensitive item selection in computerized adaptive testing. *Applied Measurement in Education*, 4(3), 241--261. Sympson, J. B., & Hetter, R. D. (1985). *Controlling item-exposure rates in computerized adaptive testing*. Proceedings of the 27th annual meeting of the Military Testing Association (pp. 973--977). van der Linden, W. J. (2005). *Linear models for optimal test design*. Springer.