Cross-language DIF screen — Math, Pattern Matching, Shape Rotation, H&F, Memory

ImportantAI-generated — not for publication

The code and prose in this chapter were written by Claude Code (an AI coding agent) under human direction. They have not been line-by-line verified by a human and must not be published or cited as-is.

WarningPreliminary & unvetted analysis

These are exploratory analyses of preliminary, in-progress LEVANTE data. Results have not been reviewed or validated and require checking by the LEVANTE Data Coordinating Center before any use.

Purpose

A QA screen for differential item functioning across languages on the five core tasks calibrated with a multigroup_site scalar model (so item difficulties are on a common scale): Math, Pattern Matching (matrix), Shape Rotation (mental rotation), Hearts & Flowers, Memory. (ToM and TROG were done separately; the by_language tasks — Vocab, ROAR — need the anchored approach and aren’t included here.)

Method (as for TROG): fit a Rasch model separately per language on each child’s first session (German = Leipzig, Spanish = Bogotá + rural, English = Canada), center each language’s difficulties, and compare. Large cross-language gaps flag items that may be mistranslated or mis-keyed.

Code
source(here::here("common.R"))
library(mirt)
sc <- read_rds(here("data/scores_all_sites.rds"))

lang_of <- function(ds) dplyr::case_when(
  ds == "pilot_mpieva_de_main" ~ "German",
  stringr::str_detect(ds, "uniandes_co") ~ "Spanish",
  ds == "pilot_western_ca_main" ~ "English", TRUE ~ NA_character_)

fit_lang <- function(trd, L) {
  m <- trd |> filter(lang == L) |> distinct(run_id, item_uid, .keep_all = TRUE) |>
    select(run_id, item_uid, correct) |>
    pivot_wider(names_from = item_uid, values_from = correct) |>
    select(-run_id) |> as.matrix()
  m <- m[, colMeans(!is.na(m)) > 0.03, drop = FALSE]
  m <- m[, apply(m, 2, \(x) length(unique(x[!is.na(x)])) > 1), drop = FALSE]
  if (ncol(m) < 5) return(NULL)
  fit <- mirt(m, 1, itemtype = "Rasch", verbose = FALSE, technical = list(NCYCLES = 2000))
  d <- coef(fit, simplify = TRUE)$items[, "d"]
  tibble(item_uid = names(d), b_c = (-d) - mean(-d), lang = L)
}

tasks <- c("egma-math","matrix-reasoning","mental-rotation",
           "hearts-and-flowers","memory-game")
Code
dif_all <- map_dfr(tasks, function(tk) {
  fr <- sc |> filter(task_id == tk) |> mutate(lang = lang_of(dataset)) |>
    filter(!is.na(lang)) |> group_by(user_id) |> arrange(age) |> slice(1) |>
    ungroup() |> select(run_id, lang)
  trd <- load_levante_trials(task_ids = tk) |> inner_join(fr, by = "run_id") |>
    mutate(correct = as.numeric(correct))
  map_dfr(c("German","Spanish","English"), \(L) fit_lang(trd, L)) |>
    mutate(task = tk)
})
wide <- dif_all |> pivot_wider(names_from = lang, values_from = b_c) |>
  rowwise() |>
  mutate(maxgap = {v <- c_across(any_of(c("German","Spanish","English")));
                   if (sum(!is.na(v)) >= 2) diff(range(v, na.rm = TRUE)) else NA_real_}) |>
  ungroup() |>
  left_join(task_lookup |> select(task_id, task_label), by = c("task" = "task_id"))

Task-level invariance

Code
pair_r <- function(d, a, b) if (all(c(a,b) %in% names(d))) {
  p <- d |> select(all_of(c(a,b))) |> drop_na(); if (nrow(p) > 3) cor(p[[1]], p[[2]]) else NA
} else NA
wide |> group_by(task_label) |>
  group_modify(~ tibble(
    n_items   = sum(!is.na(.x$maxgap)),
    `r DE–ES` = pair_r(.x, "German","Spanish"),
    `r DE–EN` = pair_r(.x, "German","English"),
    `r ES–EN` = pair_r(.x, "Spanish","English"),
    `n items |DIF|>1` = sum(.x$maxgap > 1, na.rm = TRUE))) |>
  ungroup() |> arrange(desc(`r DE–ES`)) |>
  knitr::kable(digits = 2,
               caption = "Cross-language item-difficulty agreement (per-language Rasch)")
Cross-language item-difficulty agreement (per-language Rasch)
task_label n_items r DE–ES r DE–EN r ES–EN n items |DIF|>1
Shape Rotation 12 0.98 0.96 0.99 0
Memory 18 0.95 0.94 0.95 5
Math 168 0.88 0.85 0.92 59
Hearts and Flowers 10 0.81 0.96 0.88 1
Pattern Matching 75 0.76 0.90 0.83 16

Mental Rotation is essentially invariant (r ≈ 0.96–0.99 — as expected for a non-verbal task); Memory and Pattern Matching are high (≈ 0.94–0.95 / mixed); Math and Hearts & Flowers are a bit lower (≈ 0.81–0.92), reflecting a handful of items rather than systemic drift. Crucially, none approaches the broken-item extreme seen in TROG (a 6.5-logit gap).

Flagged items (largest cross-language gaps)

Code
wide |> filter(maxgap > 1) |> arrange(desc(maxgap)) |>
  mutate(across(c(German,Spanish,English,maxgap), \(x) round(x,2))) |>
  select(task_label, item_uid, German, Spanish, English, maxgap) |>
  head(20) |>
  knitr::kable(caption = "Items with > 1-logit max cross-language difficulty gap (centered)")
Items with > 1-logit max cross-language difficulty gap (centered)
task_label item_uid German Spanish English maxgap
Math math_subtract_13_7 -1.67 0.30 1.02 2.68
Math math_multiply_3_6 2.28 0.58 -0.27 2.55
Math math_compare_45_39 -0.26 NA -2.52 2.26
Math math_line_941_1000 0.41 0.14 -1.84 2.25
Pattern Matching matrix_set1_md_mat74 1.94 -0.30 1.67 2.24
Math math_multiply_8_1 0.35 -1.82 -1.53 2.17
Math math_multiply_4_6 1.24 0.59 -0.87 2.11
Math math_slider_1_10 -2.17 -0.06 -1.29 2.11
Math math_multiply_7_3 0.77 0.04 -1.29 2.06
Pattern Matching matrix_set1_md_mat54 1.91 0.68 -0.12 2.04
Math math_subtract_46_18 0.27 NA 2.30 2.02
Math math_subtract_18_10 0.00 0.18 -1.84 2.02
Math math_line_5_1000 -0.04 -0.29 -2.00 1.96
Math math_missing_2_4_6_x 0.71 0.22 -1.24 1.95
Math math_compare_146_153 -0.19 -2.12 -1.95 1.94
Math math_multiply_20_42 3.10 NA 1.20 1.91
Math math_line_8_10 -0.89 0.65 -1.21 1.86
Memory mg_forward_2grid_len3 -2.52 -1.35 -3.20 1.85
Math math_multiply_3_13 1.41 NA -0.36 1.77
Math math_identify_48 -0.70 -1.95 -2.41 1.71
Code
wide |> group_by(task_label) |>
  summarise(n_items = sum(!is.na(maxgap)),
            n_flagged_gt1 = sum(maxgap > 1, na.rm = TRUE),
            n_flagged_gt1.5 = sum(maxgap > 1.5, na.rm = TRUE),
            max_gap = round(max(maxgap, na.rm = TRUE), 2), .groups = "drop") |>
  knitr::kable(caption = "Flagged-item counts by task")
Flagged-item counts by task
task_label n_items n_flagged_gt1 n_flagged_gt1.5 max_gap
Hearts and Flowers 10 1 0 1.29
Math 168 59 22 2.68
Memory 18 5 3 1.85
Pattern Matching 75 16 4 2.24
Shape Rotation 12 0 0 0.95

DIF scatter (German vs Spanish)

Code
wide |> filter(!is.na(German), !is.na(Spanish)) |>
  ggplot(aes(x = Spanish, y = German)) +
  geom_abline(slope = 1, intercept = 0, lty = 3, color = "grey50") +
  geom_point(alpha = 0.5, color = "#268bd2", size = 1) +
  ggrepel::geom_text_repel(data = ~filter(.x, maxgap > 1.3),
    aes(label = str_remove(item_uid, "^(math_|matrix_|mrot_|mg_)")),
    size = 2.2, color = "firebrick", max.overlaps = 12) +
  facet_wrap(~task_label, scales = "free") +
  labs(x = "Item difficulty — Spanish (centered)",
       y = "Item difficulty — German (centered)",
       title = "Cross-language item difficulty: German vs Spanish",
       subtitle = "On-diagonal = invariant; labeled (red) = max pairwise gap > 1.3 logits")

Verdict

  • These five tasks are broadly cross-language invariant. Mental Rotation is near-perfect (non-verbal), Memory and Pattern Matching strong; Math and Hearts & Flowers a little lower but driven by specific items, not systemic drift.
  • No broken items on the scale of the TROG German embedding item. The largest gaps here (~1.5–2.2 logits) are worth a look but are not catastrophic; several are short Memory spans and a few Math items.
  • Hearts & Flowers: the weakest pair is German–Spanish (r ≈ 0.81), with heart_start carrying the most DIF — consistent with start trials being noisy/low-information (see tasks/hearts_and_flowers.qmd).
  • Action: spot-check the > 1.5-logit items (esp. the top Math and Pattern Matching items) for translation/key issues; otherwise these tasks are sound for cross-site use. Vocab and the ROAR by_language tasks remain to be screened with the anchored multigroup method.
Code
write_rds(wide, here::here("data/crosslang_dif_batch.rds"))