source(here::here("common.R"))
scores <- read_rds(here::here("data", "scores_all_sites.rds"))
trials <- load_levante_trials(task_ids = "egma-math")Math (EGMA) — trial-level investigation of the apparent regression-to-mean in Bogotá
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.
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
01 flagged that Bogotá math shows a striking pattern where high T1 scores shrink toward the mean at T2 — Mike’s “regression to the mean” question. We test this at the trial level. Key fact uncovered while preparing this notebook: every two-wave Bogotá math kid is non-adaptive at T1 and adaptive at T2. So before we can claim “RTM,” we have to rule out the much more boring explanation that the CAT just changed the item set.
Two-wave sample structure
math_bog <- scores |>
filter(dataset == "pilot_uniandes_co_bogota", task_id == "egma-math") |>
group_by(user_id) |> arrange(age) |>
mutate(wave = row_number()) |> ungroup() |>
filter(wave <= 2)
math_bog |>
group_by(user_id) |>
filter(n_distinct(wave) == 2) |>
ungroup() |>
count(wave, adaptive, name = "n_runs") |>
knitr::kable()| wave | adaptive | n_runs |
|---|---|---|
| 1 | FALSE | 234 |
| 2 | TRUE | 234 |
Every two-wave kid: T1 non-adaptive, T2 adaptive. The “RTM” diagnosis has to contend with this confound up front.
The pattern Mike spotted
pairs <- math_bog |>
group_by(user_id) |>
filter(n_distinct(wave) == 2) |>
summarise(score_t1 = score[wave == 1],
score_t2 = score[wave == 2],
age_t1 = age[wave == 1],
age_t2 = age[wave == 2],
adapt_t1 = adaptive[wave == 1],
adapt_t2 = adaptive[wave == 2],
run_id_t1 = run_id[wave == 1],
run_id_t2 = run_id[wave == 2],
.groups = "drop") |>
mutate(t1_quartile = ntile(score_t1, 4) |>
factor(labels = c("Q1 (low)", "Q2", "Q3", "Q4 (high)")))
pairs |>
ggplot(aes(x = score_t1, y = score_t2, color = t1_quartile)) +
geom_abline(slope = 1, intercept = 0, lty = 3, color = "grey50") +
geom_point(alpha = 0.5) +
geom_smooth(method = "lm", se = TRUE, color = "black") +
scale_color_brewer(palette = "RdYlBu", direction = -1) +
labs(x = "Score T1 (non-adaptive)", y = "Score T2 (adaptive)",
title = "Math T1 vs T2 in Bogotá",
subtitle = "Every kid: T1 non-adapt → T2 adapt. Note compression toward middle.",
color = "T1 quartile")
fit <- lm(score_t2 ~ score_t1, data = pairs)
broom::tidy(fit) |>
knitr::kable(digits = 3,
caption = "Galton's regression coefficients (T2 ~ T1)")| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | 0.652 | 0.082 | 7.993 | 0 |
| score_t1 | 0.807 | 0.050 | 16.062 | 0 |
A slope below 1 (which we see) is the textbook RTM signature: high T1 scorers move down at T2 and low T1 scorers move up. But here it could equally be “the CAT scored everyone differently at T2.” Let’s separate the two.
Trial-level: did the high-T1 kids see harder content at T2?
The math trial table doesn’t carry a calibrated difficulty column (it’s NA for this task), so we use item_group as a proxy. Math items are grouped into 12 sub-domains: addition, subtraction, multiplication, fractions, number-line sliders, etc. The harder content (multiplication, fractions, slider) lives at the top of the bank; counting and identification at the bottom.
math_trials <- trials |>
filter(dataset == "pilot_uniandes_co_bogota", task_id == "egma-math")
trials_with_quartile <- bind_rows(
math_trials |> filter(run_id %in% pairs$run_id_t1) |>
mutate(wave = "T1 (non-adapt)") |>
inner_join(pairs |> select(run_id = run_id_t1, t1_quartile),
by = "run_id"),
math_trials |> filter(run_id %in% pairs$run_id_t2) |>
mutate(wave = "T2 (adapt)") |>
inner_join(pairs |> select(run_id = run_id_t2, t1_quartile),
by = "run_id")
)
# Order item_groups from easy to hard (rough developmental ordering)
ig_order <- c("count", "ns_count", "identify", "ns_identify",
"compare", "add", "subtract", "missing",
"multiply", "fraction", "line", "slider")
trials_with_quartile |>
mutate(item_group = factor(item_group, levels = ig_order)) |>
count(t1_quartile, wave, item_group) |>
group_by(t1_quartile, wave) |>
mutate(pct = n / sum(n)) |>
ungroup() |>
ggplot(aes(x = item_group, y = pct, fill = wave)) +
geom_col(position = "dodge") +
facet_wrap(~t1_quartile, ncol = 4) +
scale_fill_manual(values = c(`T1 (non-adapt)` = "#268bd2",
`T2 (adapt)` = "#d33682")) +
scale_y_continuous(labels = scales::percent) +
labs(x = NULL, y = "Share of trials in item group",
title = "Composition of items seen at each wave, by T1 quartile",
subtitle = "Item groups ordered roughly easy→hard",
fill = NULL) +
theme(legend.position = "bottom",
axis.text.x = element_text(angle = 45, hjust = 1))
trials_with_quartile |>
count(t1_quartile, wave, item_group) |>
pivot_wider(names_from = wave, values_from = n, values_fill = 0L) |>
arrange(t1_quartile, item_group) |>
knitr::kable()| t1_quartile | item_group | T1 (non-adapt) | T2 (adapt) |
|---|---|---|---|
| Q1 (low) | add | 454 | 145 |
| Q1 (low) | compare | 438 | 353 |
| Q1 (low) | fraction | 71 | 0 |
| Q1 (low) | identify | 1008 | 826 |
| Q1 (low) | line | 107 | 594 |
| Q1 (low) | missing | 330 | 507 |
| Q1 (low) | multiply | 92 | 0 |
| Q1 (low) | slider | 2016 | 0 |
| Q1 (low) | subtract | 175 | 65 |
| Q2 | add | 1082 | 67 |
| Q2 | compare | 559 | 195 |
| Q2 | fraction | 39 | 0 |
| Q2 | identify | 1119 | 522 |
| Q2 | line | 112 | 1178 |
| Q2 | missing | 532 | 295 |
| Q2 | multiply | 171 | 10 |
| Q2 | slider | 1591 | 0 |
| Q2 | subtract | 517 | 97 |
| Q3 | add | 1236 | 68 |
| Q3 | compare | 565 | 139 |
| Q3 | fraction | 21 | 6 |
| Q3 | identify | 1130 | 300 |
| Q3 | line | 143 | 1520 |
| Q3 | missing | 557 | 214 |
| Q3 | multiply | 161 | 37 |
| Q3 | slider | 858 | 0 |
| Q3 | subtract | 792 | 86 |
| Q4 (high) | add | 1364 | 33 |
| Q4 (high) | compare | 570 | 124 |
| Q4 (high) | fraction | 16 | 35 |
| Q4 (high) | identify | 1154 | 290 |
| Q4 (high) | line | 366 | 1620 |
| Q4 (high) | missing | 561 | 168 |
| Q4 (high) | multiply | 264 | 99 |
| Q4 (high) | slider | 601 | 0 |
| Q4 (high) | subtract | 1193 | 67 |
Trial-level accuracy by wave and quartile
trials_with_quartile |>
group_by(t1_quartile, wave) |>
summarise(pct_correct = mean(correct, na.rm = TRUE),
n_trials = n(),
n_kids = n_distinct(user_id),
.groups = "drop") |>
ggplot(aes(x = t1_quartile, y = pct_correct, fill = wave)) +
geom_col(position = "dodge") +
scale_fill_manual(values = c(`T1 (non-adapt)` = "#268bd2",
`T2 (adapt)` = "#d33682")) +
scale_y_continuous(labels = scales::percent) +
labs(x = "T1 quartile", y = "Trial-level accuracy",
title = "Per-trial accuracy by wave",
subtitle = "If T2 were harder for high-T1 kids, their accuracy would drop",
fill = NULL) +
theme(legend.position = "bottom")
Item overlap between T1 and T2
If non-adaptive T1 and adaptive T2 sampled completely different items, then the “RTM” effect can’t be attributed to within-item performance change.
items_t1 <- math_trials |> filter(run_id %in% pairs$run_id_t1) |>
pull(item_uid) |> unique() |> na.omit()
items_t2 <- math_trials |> filter(run_id %in% pairs$run_id_t2) |>
pull(item_uid) |> unique() |> na.omit()
tibble(
metric = c("Unique items in T1 (non-adapt)",
"Unique items in T2 (adapt)",
"Items in both",
"T1-only", "T2-only"),
value = c(length(items_t1), length(items_t2),
length(intersect(items_t1, items_t2)),
length(setdiff(items_t1, items_t2)),
length(setdiff(items_t2, items_t1)))
) |> knitr::kable()| metric | value |
|---|---|
| Unique items in T1 (non-adapt) | 191 |
| Unique items in T2 (adapt) | 143 |
| Items in both | 136 |
| T1-only | 55 |
| T2-only | 7 |
Within-kid: same items at both waves
For the subset of items that DO overlap, did the same kids answer them consistently across waves? This is the cleanest within-individual change estimate — no mode shift, no item shift.
shared_items <- intersect(items_t1, items_t2)
t1_responses <- math_trials |>
filter(run_id %in% pairs$run_id_t1, item_uid %in% shared_items) |>
select(run_id, item_uid, correct_t1 = correct) |>
inner_join(pairs |> select(user_id, run_id = run_id_t1),
by = "run_id") |>
select(user_id, item_uid, correct_t1)
t2_responses <- math_trials |>
filter(run_id %in% pairs$run_id_t2, item_uid %in% shared_items) |>
select(run_id, item_uid, correct_t2 = correct) |>
inner_join(pairs |> select(user_id, run_id = run_id_t2),
by = "run_id") |>
select(user_id, item_uid, correct_t2)
same_items <- t1_responses |>
inner_join(t2_responses, by = c("user_id", "item_uid")) |>
inner_join(pairs |> select(user_id, t1_quartile, score_t1),
by = "user_id")
cat("(kid, item) pairs answered at BOTH waves:", nrow(same_items), "\n")(kid, item) pairs answered at BOTH waves: 4482
cat("Unique kids with any overlap:", n_distinct(same_items$user_id), "\n")Unique kids with any overlap: 234
cat("Median overlap items per kid:\n")Median overlap items per kid:
same_items |> count(user_id) |> pull(n) |> summary() |> print() Min. 1st Qu. Median Mean 3rd Qu. Max.
1.00 10.00 17.00 19.15 27.00 57.00
if (nrow(same_items) > 0) {
same_items |>
group_by(t1_quartile) |>
summarise(
n_obs = n(),
pct_t1 = mean(correct_t1, na.rm = TRUE),
pct_t2 = mean(correct_t2, na.rm = TRUE),
delta = pct_t2 - pct_t1,
.groups = "drop"
) |>
knitr::kable(digits = 3,
caption = "Accuracy on items seen at BOTH waves, by T1 quartile")
same_items |>
group_by(t1_quartile) |>
summarise(pct_t1 = mean(correct_t1, na.rm = TRUE),
pct_t2 = mean(correct_t2, na.rm = TRUE),
.groups = "drop") |>
pivot_longer(c(pct_t1, pct_t2), names_to = "wave", values_to = "pct") |>
ggplot(aes(x = t1_quartile, y = pct, fill = wave)) +
geom_col(position = "dodge") +
scale_fill_manual(values = c(pct_t1 = "#268bd2", pct_t2 = "#d33682"),
labels = c("T1 (non-adapt)", "T2 (adapt)")) +
scale_y_continuous(labels = scales::percent) +
labs(x = "T1 quartile",
y = "Accuracy on items seen at BOTH waves",
title = "Within-item accuracy comparison",
subtitle = "Cleaner than the global RTM plot — no item/mode confound",
fill = NULL) +
theme(legend.position = "bottom")
}
Accuracy by item group and wave
If high-T1 kids are seeing harder content at T2, their per-item-group accuracy on shared groups should stay similar (since each group spans a narrow difficulty range), but the marginal mean shifts because the mix of items shifted.
trials_with_quartile |>
mutate(item_group = factor(item_group, levels = ig_order)) |>
group_by(t1_quartile, wave, item_group) |>
summarise(acc = mean(correct, na.rm = TRUE), n = n(), .groups = "drop") |>
filter(n >= 20) |>
ggplot(aes(x = item_group, y = acc, fill = wave)) +
geom_col(position = "dodge") +
facet_wrap(~t1_quartile, ncol = 4) +
scale_fill_manual(values = c(`T1 (non-adapt)` = "#268bd2",
`T2 (adapt)` = "#d33682")) +
scale_y_continuous(labels = scales::percent) +
labs(x = NULL, y = "Accuracy",
title = "Per-item-group accuracy by wave",
subtitle = "Within group, accuracy should be roughly stable if the kid hasn't actually changed",
fill = NULL) +
theme(legend.position = "bottom",
axis.text.x = element_text(angle = 45, hjust = 1))
Verdict
slope <- coef(lm(score_t2 ~ score_t1, data = pairs))[2]
marginal <- trials_with_quartile |>
group_by(t1_quartile, wave) |>
summarise(acc = mean(correct, na.rm = TRUE), .groups = "drop") |>
pivot_wider(names_from = wave, values_from = acc,
names_prefix = "acc_")
within_item <- if (exists("same_items") && nrow(same_items) > 0) {
same_items |>
group_by(t1_quartile) |>
summarise(n = n(),
pct_t1 = mean(correct_t1, na.rm = TRUE),
pct_t2 = mean(correct_t2, na.rm = TRUE),
delta = pct_t2 - pct_t1,
.groups = "drop")
} else NULL
marginal |> knitr::kable(digits = 3,
caption = "Marginal trial accuracy by wave (all items each kid saw)")| t1_quartile | acc_T1 (non-adapt) | acc_T2 (adapt) |
|---|---|---|
| Q1 (low) | 0.542 | 0.699 |
| Q2 | 0.735 | 0.727 |
| Q3 | 0.838 | 0.736 |
| Q4 (high) | 0.909 | 0.764 |
if (!is.null(within_item)) {
within_item |> knitr::kable(digits = 3,
caption = "Within-item trial accuracy by wave (items each kid saw at BOTH waves)")
}| t1_quartile | n | pct_t1 | pct_t2 | delta |
|---|---|---|---|---|
| Q1 (low) | 1403 | 0.728 | 0.831 | 0.103 |
| Q2 | 1124 | 0.821 | 0.878 | 0.057 |
| Q3 | 888 | 0.839 | 0.903 | 0.064 |
| Q4 (high) | 1067 | 0.820 | 0.893 | 0.073 |
Diagnosis
The plot in § The pattern Mike spotted shows the classic RTM signature: Galton slope ≈ 0.2, high-T1 kids drop ~2 θ at T2, low-T1 kids rise ~2 θ. But the trial-level evidence shows this is not within-individual regression to the mean; it’s the CAT serving different items.
Three facts together make the case:
Item composition shifts dramatically by quartile at T2. For Q4 (high T1) kids, the CAT decided their ability warrants number-line problems (“line”) — those go from ~5% of trials at T1 to ~64% at T2. The non-adaptive T1 form rarely tested them on this category. So their T2 score is dominated by a category they hadn’t been measured on before.
Marginal trial accuracy looks like the score story. Q4 kids drop from 92% correct at T1 to 76% at T2; Q1 kids rise from 54% to 70%. But that’s the marginal across very different item mixes.
Within-item accuracy tells the opposite story. On the 136 items shared between T1 and T2, every quartile is more accurate at T2: Q1 +11 pts, Q2 +5 pts, Q3 +7 pts, Q4 +7 pts. There is real growth in every quartile — the apparent decline at the top is purely the CAT routing.
Implications for 02 / 03
- The Bogotá math T1 vs T2 IRT θ comparison cannot be interpreted as growth while every kid is NA→A. The within-item growth estimate (~+7%-points on shared items for high-T1 kids) is the more honest summary.
- For 02 / 03, treat Bogotá math two-wave θ deltas as uninterpretable until either (a) the CAT items get folded into a unified IRT calibration that’s stable across modes, or (b) a fixed-form retest is added.
- The CAT routing decision is consistent — high-T1 kids really are measured as higher-ability by the CAT, which then shipped them developmentally appropriate harder content. This isn’t a bug in the CAT; it’s just incompatible with the naive longitudinal interpretation.
Sister problem worth flagging to the data coordinating center: T1’s non-adapt form sampled ~7% “slider” items and 0% “line”; T2’s CAT sampled 0% “slider” and lots of “line”. These look like related number-line constructs. If they were renamed between releases (rather than being genuinely different items), the calibration won’t recover from the relabel.