Skip to content

Commit 50b3ed2

Browse files
authored
test(polytomous): require bias MAE and uncertainty coverage (#1343)
* test(grm): require bias MAE and coverage recovery * test(gpcm): require bias MAE and coverage recovery * test(fipc): require bias MAE and coverage recovery * docs(changelog): record polytomous recovery calibration evidence * test(grm): label posterior-SD coverage precisely * test(gpcm): label posterior-SD coverage precisely * test(fipc): label posterior-SD coverage precisely * docs(changelog): clarify recovery coverage approximation * test(cat): require bias MAE and uncertainty coverage * docs(test): record CAT uncertainty recovery evidence
1 parent 66bec7e commit 50b3ed2

5 files changed

Lines changed: 121 additions & 24 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Strengthen polytomous recovery calibration evidence
2+
3+
## Changed
4+
5+
- Extend deterministic GRM, GPCM, CAT, and fixed-item-parameter recovery studies to require signed bias, MAE, finite positive Rust-returned posterior uncertainty, and empirical coverage of the normal-approximation interval `theta_eap ± 1.96 * theta_sd` alongside RMSE, while retaining correlation only as supplementary recovery evidence.
6+
- Preserve CAT's independent adaptive-efficiency gate on mean administered items, so uncertainty calibration and error recovery cannot mask a fallback to non-adaptive item selection.
7+
- Keep all production likelihood, marginal-ML/EM, EAP/CAT scoring, item-information/selection, stopping, and uncertainty arithmetic Rust-owned; the added Python calculations are explicit true-parameter recovery-test summaries only.

tests/test_polytomous_cat_recovery.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
"""Accuracy-and-efficiency recovery study for public polytomous CAT.
1+
"""Accuracy, uncertainty-calibration, and efficiency recovery for polytomous CAT.
22
33
A GRM bank is calibrated from synthetic responses and then passed to
44
``cat_simulate_polytomous`` for examinees with known true abilities. The test
5-
requires both acceptable theta recovery and materially fewer administered
6-
items than the full bank, so a silent fallback to non-adaptive item order can
7-
fail even when correlation remains acceptable.
5+
requires bounded bias/MAE/RMSE, calibrated Rust-returned posterior uncertainty,
6+
and materially fewer administered items than the full bank. Correlation remains
7+
supplementary: a highly correlated CAT can still be biased or understate score
8+
uncertainty, while a silent fallback to non-adaptive item order can still meet
9+
score-recovery bounds.
810
911
Dodd, De Ayala, and Koch (1995) evaluate computerized adaptive testing with
1012
polytomous IRT items and motivate measuring adaptive efficiency together with
1113
score quality. This regression uses the package's Rust-owned item-information,
12-
selection, scoring, and stopping path; the Python formula below only generates
13-
synthetic GRM responses.
14+
selection, scoring, posterior-SD, and stopping path; Python only generates the
15+
synthetic GRM responses and summarizes recovery against known simulation truth.
16+
The interval ``theta_eap ± 1.96 * theta_sd`` is an explicit normal approximation
17+
based on the returned posterior mean and posterior SD, not a claim of an exact
18+
posterior credible interval.
1419
1520
Reference
1621
---------
@@ -37,15 +42,19 @@
3742
# correlation ~0.91 using a mean of ~8.7 of 40 items -- comparable accuracy
3843
# to the full-bank GRM recovery test (~0.38/~0.92) at roughly a fifth of
3944
# the items. Margins are loose enough to tolerate a minor fast-mlsirm
40-
# version bump while still catching a real regression in either accuracy
41-
# or the adaptive-selection efficiency CAT exists to provide.
45+
# version bump while still catching a real regression in recovery,
46+
# uncertainty calibration, or the adaptive-selection efficiency CAT exists
47+
# to provide.
4248
#
4349
# MAX_MEAN_ITEMS_USED is deliberately close to the measured ~8.7 (not a
4450
# loose N_ITEMS * 0.5): the same fixture/seed with adaptive=False (random
4551
# item order) measures mean_items_used ~14.97, which still clears rmse/
4652
# correlation bounds -- so a bound of 12 is what actually catches a silent
4753
# fallback to non-adaptive selection.
4854
MAX_THETA_RMSE = 0.65
55+
MAX_THETA_MAE = 0.52
56+
MAX_ABS_THETA_BIAS = 0.20
57+
MIN_NORMAL_APPROX_COVERAGE = 0.80
4958
MIN_THETA_CORRELATION = 0.7
5059
MAX_MEAN_ITEMS_USED = 12
5160

@@ -95,15 +104,35 @@ def test_cat_recovers_theta_using_substantially_fewer_items_than_the_full_bank()
95104
seed=SEED,
96105
)
97106
theta_eap = cat_result["theta_eap"]
107+
theta_sd = cat_result["theta_sd"]
98108
n_used = cat_result["n_used"]
99109

100-
rmse = float(np.sqrt(np.mean((theta_eap - true_theta) ** 2)))
110+
assert np.all(np.isfinite(theta_sd))
111+
assert np.all(theta_sd > 0.0)
112+
113+
error = theta_eap - true_theta
114+
bias = float(np.mean(error))
115+
mae = float(np.mean(np.abs(error)))
116+
rmse = float(np.sqrt(np.mean(error**2)))
117+
normal_approx_coverage = float(
118+
np.mean(np.abs(error) <= 1.96 * theta_sd)
119+
)
101120
correlation = float(np.corrcoef(theta_eap, true_theta)[0, 1])
102121
mean_items_used = float(n_used.mean())
103122

123+
assert abs(bias) < MAX_ABS_THETA_BIAS, (
124+
f"CAT theta bias {bias:.3f} exceeded ±{MAX_ABS_THETA_BIAS}"
125+
)
126+
assert mae < MAX_THETA_MAE, (
127+
f"CAT theta MAE {mae:.3f} exceeded {MAX_THETA_MAE}"
128+
)
104129
assert rmse < MAX_THETA_RMSE, (
105130
f"CAT theta RMSE {rmse:.3f} exceeded {MAX_THETA_RMSE}"
106131
)
132+
assert normal_approx_coverage >= MIN_NORMAL_APPROX_COVERAGE, (
133+
"CAT theta normal-approximation coverage "
134+
f"{normal_approx_coverage:.3f} below {MIN_NORMAL_APPROX_COVERAGE}"
135+
)
107136
assert correlation > MIN_THETA_CORRELATION, (
108137
f"CAT theta correlation {correlation:.3f} below {MIN_THETA_CORRELATION}"
109138
)

tests/test_polytomous_fipc_recovery.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@
3636
# RMSE ~0.33 and correlation ~0.94, with the FIPC-estimated period-2 mean
3737
# landing at ~0.37 against a true realized mean of ~0.43 (the shift is
3838
# substantially recovered, not perfectly -- sampling noise on 250 people).
39-
# An independent free refit of the same period-2 data, by contrast,
40-
# re-centers to an estimated mean of ~0.01 -- essentially erasing the
41-
# shift, exactly the failure mode fixed-bank scoring is intended to expose.
39+
# Bias/MAE and normal-approximation coverage based on the Rust-returned
40+
# posterior SD are required independently of correlation so fixed-bank
41+
# recovery cannot pass on rank ordering alone.
4242
MAX_THETA_RMSE = 0.6
43+
MAX_THETA_MAE = 0.5
44+
MAX_ABS_THETA_BIAS = 0.20
45+
MIN_THETA_NORMAL_APPROX_COVERAGE = 0.80
4346
MIN_THETA_CORRELATION = 0.75
4447
MIN_FIPC_MEAN_SHIFT_DETECTED = PERIOD_2_TRUE_MEAN_SHIFT * 0.5
4548
MAX_INDEPENDENT_REFIT_MEAN_SHIFT_DETECTED = PERIOD_2_TRUE_MEAN_SHIFT * 0.35
@@ -107,14 +110,34 @@ def test_fipc_recovers_period_two_mean_shift_that_an_independent_refit_would_hid
107110
# Fixed-bank scoring: period 2 uses period 1's item parameters unchanged.
108111
fipc_scored = score_polytomous(responses_period_2, period_1_fit)
109112
fipc_theta_eap = fipc_scored["theta_eap"]
110-
111-
rmse = float(np.sqrt(np.mean((fipc_theta_eap - theta_period_2) ** 2)))
113+
fipc_theta_sd = fipc_scored["theta_sd"]
114+
115+
error = fipc_theta_eap - theta_period_2
116+
bias = float(np.mean(error))
117+
mae = float(np.mean(np.abs(error)))
118+
rmse = float(np.sqrt(np.mean(error**2)))
119+
normal_approx_coverage = float(
120+
np.mean(np.abs(error) <= 1.96 * fipc_theta_sd)
121+
)
112122
correlation = float(np.corrcoef(fipc_theta_eap, theta_period_2)[0, 1])
113123
fipc_detected_shift = float(fipc_theta_eap.mean())
114124

125+
assert np.all(np.isfinite(fipc_theta_sd))
126+
assert np.all(fipc_theta_sd > 0.0)
127+
assert abs(bias) < MAX_ABS_THETA_BIAS, (
128+
f"FIPC period-2 theta absolute bias {abs(bias):.3f} exceeded "
129+
f"{MAX_ABS_THETA_BIAS}"
130+
)
131+
assert mae < MAX_THETA_MAE, (
132+
f"FIPC period-2 theta MAE {mae:.3f} exceeded {MAX_THETA_MAE}"
133+
)
115134
assert rmse < MAX_THETA_RMSE, (
116135
f"FIPC period-2 theta RMSE {rmse:.3f} exceeded {MAX_THETA_RMSE}"
117136
)
137+
assert normal_approx_coverage >= MIN_THETA_NORMAL_APPROX_COVERAGE, (
138+
f"FIPC period-2 theta mean±1.96 posterior-SD coverage "
139+
f"{normal_approx_coverage:.3f} below {MIN_THETA_NORMAL_APPROX_COVERAGE}"
140+
)
118141
assert correlation > MIN_THETA_CORRELATION, (
119142
f"FIPC period-2 theta correlation {correlation:.3f} "
120143
f"below {MIN_THETA_CORRELATION}"

tests/test_polytomous_gpcm_recovery.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
SEED = 20260101
2828

2929
# A real run with these exact parameters/seed measures RMSE ~0.30 and
30-
# correlation ~0.95 -- stronger recovery than the GRM test's ~0.38/~0.92
31-
# at the same sample size. The margins below stay loose enough to tolerate
32-
# a minor fast-mlsirm version bump while still catching a real recovery
33-
# regression.
30+
# correlation ~0.95. Bias/MAE and normal-approximation coverage based on the
31+
# Rust-returned posterior SD are asserted separately so correlation remains
32+
# supplementary rather than standing in for calibration.
3433
MAX_THETA_RMSE = 0.55
34+
MAX_THETA_MAE = 0.45
35+
MAX_ABS_THETA_BIAS = 0.15
36+
MIN_THETA_NORMAL_APPROX_COVERAGE = 0.80
3537
MIN_THETA_CORRELATION = 0.8
3638

3739

@@ -65,13 +67,30 @@ def test_gpcm_recovers_true_theta_within_expected_rmse() -> None:
6567

6668
scored = score_polytomous(responses, fit)
6769
theta_eap = scored["theta_eap"]
70+
theta_sd = scored["theta_sd"]
6871

69-
rmse = float(np.sqrt(np.mean((theta_eap - true_theta) ** 2)))
72+
error = theta_eap - true_theta
73+
bias = float(np.mean(error))
74+
mae = float(np.mean(np.abs(error)))
75+
rmse = float(np.sqrt(np.mean(error**2)))
76+
normal_approx_coverage = float(np.mean(np.abs(error) <= 1.96 * theta_sd))
7077
correlation = float(np.corrcoef(theta_eap, true_theta)[0, 1])
7178

79+
assert np.all(np.isfinite(theta_sd))
80+
assert np.all(theta_sd > 0.0)
81+
assert abs(bias) < MAX_ABS_THETA_BIAS, (
82+
f"theta recovery absolute bias {abs(bias):.3f} exceeded {MAX_ABS_THETA_BIAS}"
83+
)
84+
assert mae < MAX_THETA_MAE, (
85+
f"theta recovery MAE {mae:.3f} exceeded {MAX_THETA_MAE}"
86+
)
7287
assert rmse < MAX_THETA_RMSE, (
7388
f"theta recovery RMSE {rmse:.3f} exceeded {MAX_THETA_RMSE}"
7489
)
90+
assert normal_approx_coverage >= MIN_THETA_NORMAL_APPROX_COVERAGE, (
91+
f"theta mean±1.96 posterior-SD coverage {normal_approx_coverage:.3f} below "
92+
f"{MIN_THETA_NORMAL_APPROX_COVERAGE}"
93+
)
7594
assert correlation > MIN_THETA_CORRELATION, (
7695
f"theta recovery correlation {correlation:.3f} below {MIN_THETA_CORRELATION}"
7796
)

tests/test_polytomous_grm_recovery.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@
2929

3030
# Reasonable bounds for a 12-item, 4-category GRM test at this sample size:
3131
# a real run with these exact parameters/seed measures RMSE ~0.38 and
32-
# correlation ~0.92, comfortably inside literature-typical recovery for a
33-
# test this length. The margins below are loose enough to tolerate a minor
34-
# fast-mlsirm version bump while still catching an actual estimation
35-
# regression (e.g. RMSE blowing up past ~1 std or correlation collapsing).
32+
# correlation ~0.92. Bias/MAE and normal-approximation coverage based on the
33+
# Rust-returned posterior SD are asserted separately so correlation remains
34+
# supplementary rather than standing in for calibration.
3635
MAX_THETA_RMSE = 0.6
36+
MAX_THETA_MAE = 0.5
37+
MAX_ABS_THETA_BIAS = 0.15
38+
MIN_THETA_NORMAL_APPROX_COVERAGE = 0.80
3739
MIN_THETA_CORRELATION = 0.75
3840

3941

@@ -85,13 +87,30 @@ def test_grm_recovers_true_theta_within_expected_rmse() -> None:
8587

8688
scored = score_polytomous(responses, fit)
8789
theta_eap = scored["theta_eap"]
90+
theta_sd = scored["theta_sd"]
8891

89-
rmse = float(np.sqrt(np.mean((theta_eap - true_theta) ** 2)))
92+
error = theta_eap - true_theta
93+
bias = float(np.mean(error))
94+
mae = float(np.mean(np.abs(error)))
95+
rmse = float(np.sqrt(np.mean(error**2)))
96+
normal_approx_coverage = float(np.mean(np.abs(error) <= 1.96 * theta_sd))
9097
correlation = float(np.corrcoef(theta_eap, true_theta)[0, 1])
9198

99+
assert np.all(np.isfinite(theta_sd))
100+
assert np.all(theta_sd > 0.0)
101+
assert abs(bias) < MAX_ABS_THETA_BIAS, (
102+
f"theta recovery absolute bias {abs(bias):.3f} exceeded {MAX_ABS_THETA_BIAS}"
103+
)
104+
assert mae < MAX_THETA_MAE, (
105+
f"theta recovery MAE {mae:.3f} exceeded {MAX_THETA_MAE}"
106+
)
92107
assert rmse < MAX_THETA_RMSE, (
93108
f"theta recovery RMSE {rmse:.3f} exceeded {MAX_THETA_RMSE}"
94109
)
110+
assert normal_approx_coverage >= MIN_THETA_NORMAL_APPROX_COVERAGE, (
111+
f"theta mean±1.96 posterior-SD coverage {normal_approx_coverage:.3f} below "
112+
f"{MIN_THETA_NORMAL_APPROX_COVERAGE}"
113+
)
95114
assert correlation > MIN_THETA_CORRELATION, (
96115
f"theta recovery correlation {correlation:.3f} below {MIN_THETA_CORRELATION}"
97116
)

0 commit comments

Comments
 (0)