diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d9c64e04..9ed1c3c63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: "3.12" # Rust toolchain is required so the maturin build compiles the primary @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: "3.12" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: "3.12" # pip is version-pinned so the toolchain used to resolve the fuzz extras @@ -75,7 +75,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: "3.12" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8358bfb04..8953d0b0d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,7 +24,7 @@ jobs: - language: python steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a + - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a + - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 diff --git a/AGENTS.md b/AGENTS.md index 56457d2a5..3d3b4ae3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,12 +133,11 @@ Guidance for ANY agent (Claude, Codex, Cursor, opencode, ...) working in this re ### Code exploration -- Use CodeGraph before grep/find, ripgrep, or broad file reads whenever code - needs to be located or understood. If the repository root does not yet have - a `.codegraph/` index, initialize it with `codegraph init .` first, then use - `codegraph explore ""` (or the code-review-graph MCP tools). Keep the - index refreshed for the current checkout so callers, callees, and impact are - derived from the active PR head rather than stale source. +- No `.codegraph/` index exists in this repo today, so use normal search + (grep/find, ripgrep) to locate and understand code. If a `.codegraph/` index + is later added at the repo root, prefer CodeGraph + (`codegraph explore ""`, or the code-review-graph MCP tools) BEFORE + grep/find — it surfaces callers/callees/impact that text search misses. ### This repo's role in the ecosystem diff --git a/CHANGELOG.md b/CHANGELOG.md index a22d0bf73..d7d502a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2084 +1,9 @@ # Changelog -- Fixed-bank item/test information and CAT information selection now default - to a Rust wgpu kernel, retain an explicit Rust f64 CPU path, and accept the - same `device="auto"|"gpu"|"cpu"` contract as EAP serving. Non-finite f32 - results are discarded in favor of the finite CPU reference. -- Plausible-value posterior reduction and seeded sampling now use the same - GPU-preferred device contract. Unsupported GPU sizes or results fall back to - a deterministic Rust f64 implementation with fixed contiguous CPU shards. -- EAPsum table recursion, posterior moments, and respondent lookup now remain - in Rust and prefer wgpu. Explicit CPU execution retains the f64 reference; - unavailable or unsupported GPU work falls back to fixed contiguous Rust CPU - workers instead of performing respondent score aggregation in Python. - ## Unreleased ### Changed -- Rust EAP scoring now defaults to GPU-preferred `auto` execution in the core, - PyO3 binding, and serving API. The f64 CPU reduction remains available via - `device="cpu"`; an explicit unavailable `device="gpu"` request now warns - before falling back instead of silently running on the CPU. Serving EAP now - requires the compiled Rust core instead of silently bypassing device policy - through the NumPy reference implementation. - -### Security - -- **Input-validation hardening at the untrusted boundaries** (Strix scan - findings on PR #160). All are denial-of-service / data-poisoning guards for - a library that may be exposed as a scoring/fitting service: - - Population labels (`group_id`/`cluster_id`) are now validated and - **compacted to contiguous ids** in `fit.py` and `inference.py`, so the - group/cluster count is the number of *distinct* labels (≤ `n_persons`) - rather than `max(label)+1` — sparse ids like `[0, 1e9]` no longer force - billion-row population allocations. Negative, non-integer, non-finite, and - wrong-length labels are rejected. - - `FitConfig.validate()` bounds `latent_dim` (≤ `MAX_LATENT_DIM = 8`), - `xi_points` (≤ `1_000_000`), `max_iter` (≤ `100_000`), `n_restarts` - (≤ `1_000`), and `m_steps` (≤ `1_000`), and rejects **non-finite** - `learning_rate`/`init_gamma`/`eps_distance`/`tolerance`/`gradient_clip` - (a bare `x <= 0` comparison lets `NaN`/`Inf` through) — blocking both - memory/CPU exhaustion from extreme sizes and NaN-poisoned fits. - - `plausible_values` bounds `n_draws` (1..`MAX_DRAWS = 100_000`), and - `serving_prior` bounds `n_dims` (1..64) for direct callers. - - `load_serving_bundle` parses JSON in **strict mode** (rejects `NaN`/ - `Infinity` literals) and runs a full `_validate_bundle` structural + - finiteness check (consistent `n_items`/`n_dims`/`latent_dim`, bounded - sizes, in-range `factor_id`, finite `alpha`/`b`/`zeta`/`tau`/`eps_distance`, - supported quadrature); `score_respondents` and `plausible_values` validate - the bundle at entry, so oversized dimensions (e.g. `n_items = 1e12`) and - non-finite parameters can no longer trigger multi-terabyte allocations or - NaN scores. - - `plausible_values` now enforces the binary response domain (0/1, finite) - that `score_respondents` already required. - - `validate_judge` validates judge/human/baseline/subgroup labels (1-D, - equal length, finite, integer, `0 ≤ label < k`) **before** the `uint32` - conversion, instead of silently truncating floats or wrapping negatives. - - Regression tests in `tests/test_security_hardening.py` cover each finding. -- **Second-pass hardening** (Strix re-scan of PR #160, 11 findings) extends the - same DoS/data-poisoning guards to the paper-feature surface added in this PR: - - `preprocessing.irtree_expand` bounds the dense expansion - (`persons * items * nodes ≤ 50_000_000`) before allocating, and validates - `node_dims` (finite, non-negative, integer-valued) before the `int64` cast. - - `validation._validate_labels` rejects labels above `uint32` max before the - narrowing cast, and `validate_judge` requires the `human_human` baseline to - match the paired sample size. - - `inference.observed_information` caps the finite-difference Hessian at - `5_000` parameters (it is `O(n²)` memory **and** `O(n²)` objective calls), - and `oakes_standard_errors` validates `factor_id` (1-D, one-per-item, - finite, non-negative, integer) before deriving `n_dims`. - - `serving._validate_bundle` rejects tensor Gauss-Hermite grids that would - allocate `q_xi ** latent_dim > 1_000_000` points; `estimators.marginal`'s - `_xi_grid` carries the same bound for direct callers. - - `linking.link_fixed_item_parameters` rejects duplicate/fractional/negative/ - non-finite anchor indices, non-2-D `theta`, non-finite item parameters, and - non-finite computed linking coefficients. -- **Third-pass hardening** (Strix re-scan of `b5d9d90`, 11 real findings; the - 12th — "incomplete package release" — was a scanner artifact of its - PR-scope-only checkout, verified: every named module exists and - `import fast_mlsirm` succeeds) **plus a proactive boundary audit** that found - 6 more Python issues Strix had not surfaced: - - `serving.score_respondents`/`plausible_values` bound the dense respondent - matrix (`rows x n_items`); `serving._validate_bundle` now bounds the - scoring-table product (`max(n_items, n_dims) x q_theta x q_xi**latent_dim` — - a 55+ GB allocation otherwise) and validates the bundle `population` block - (`serving_prior` computed `sqrt(1 + sigma_u**2)` on an unvalidated, fully - attacker-controlled `sigma_u` → `TypeError`/`OverflowError` crash or silent - `Inf`/`NaN` score poisoning). - - `linking.link_fixed_item_parameters` range-checks anchor indices on the - float **before** the `int64` cast (`uint64` max silently wrapped to `-1`, - selecting the last item) and requires a positive linking scale; - `linking.irt_link` validates slope/intercept finiteness and slope - positivity before the Nelder-Mead core (a `NaN` would panic it). - - `validation.validate_judge` bounds the category count `k` (drives a dense - `k x k` confusion matrix) and **compacts** sparse `subgroup` labels (the - core loops `0..max(label)+1`, an O(4e9) CPU-DoS from one sparse id). - - `preprocessing.irtree_expand` switched from a 50M-element ceiling (400 MB, - boundary-inclusive) to a 64 MiB byte budget; `config.MLS2PLMConfig.validate` - bounds simulation dimensions and the `n_persons x n_items` cell product; - `config.FitConfig.validate` bounds aggregate optimizer work - (`max_iter x n_restarts`); `estimators.marginal.fit_marginal_numpy` bounds - declared population counts (`n_groups`/`n_clusters <= n_persons`) and the - EM working set; `inference.observed_information` rejects non-finite `step`; - `inference.oakes_standard_errors` and every `fitstats` public entry bound - `n_dims` derived from an untrusted `factor_id` (a shared `_validate_factor_id` - guard); `fit.py` validates anchor/covariate array shapes and finiteness - before the Rust marginal core. - - Rust-core backstops for the same audit (defense in depth, active once the - extension is rebuilt): `fitstats::s_x2` rejects non-dichotomous observed - responses (a non-0/1 value indexed the summed-score table out of bounds → - panic); `fitstats::infit_outfit` validates `theta`/`xi` lengths before - indexing; `scoring::validate_prior` rejects non-finite prior `mean`/`sd` - (a `NaN` `sd` passed the bare `sd <= 0` check). -- `factor::validate_corr` now rejects off-diagonal correlations outside - `[-1, 1]` (impl-review finding): an impossible value like `1e308` passed - the old finiteness/symmetry checks and panicked inside the eigen sort - instead of returning an error (affected `minres_fa`, `omega_total_1f`, - and the new `glb_fa`); regression-tested. - -### Added - -- **Kingsbury-Zara constrained CAT (CCAT) content balancing** - (`fast_mlsirm.ccat_select`; in `mlsirm_core::exposure`). Single-step - content-balanced item selection: eligible groups with zero administered - items have priority, otherwise the eligible group with the maximal - target-minus-empirical-proportion discrepancy is chosen; within the chosen - group the unadministered item with maximal logistic 3PL Fisher information - `a^2 (Q/P) ((P-c)/(1-c))^2` is selected. Ties go to the lowest index - (documented deterministic deviation from catR's random tie-break). - Kingsbury & Zara (1989, doi:10.1207/s15324818ame0204_6) itself NOT read - (paywalled); the rule is implemented as reproduced by the R catR package - (`nextItem.R` `cbControl` branch; READ), and the information formula was - verified against catR `Ii.R`/`Pi.R`. Pinned oracles computed in exact - arithmetic by the adversarial spec review. -- **Owen approximate Bayesian sequential CAT** (`fast_mlsirm.owen_update`, - `fast_mlsirm.owen_cat`; in `mlsirm_core::exposure`). Closed-form - normal-approximation posterior moment updates for the 3PNO model - (`P = c + (1-c)Phi(a(theta-b))`) and a sequential driver with Owen's - b-matching selection (`argmin |b_i - mu|`, ties to the lowest index) and - posterior-variance stopping rule (plus a `test_length` cap). Owen (1975) - itself NOT read (paywalled); formulas implemented as reproduced by - van der Linden (1998, Research Report 98-01, Appendix A.1-A.6) and - cross-checked against the R `irt` package `est_ability_owen.cpp`; pinned - oracles verified against exact-posterior numerical integration (~1e-13) - by the adversarial spec review. - -- **Chang-Ying KL global-information CAT selection** - (`fast_mlsirm.kl_information`, `fast_mlsirm.kl_select`; in - `mlsirm_core::exposure`). Kullback-Leibler item index as the UNNORMALIZED - area of the pointwise Bernoulli divergence (expectation under the - provisional `theta0`) over `[theta0 - delta, theta0 + delta]` via composite - Simpson (2048 panels), and next-item selection with the paper's shrinking - half-width `delta = r / sqrt(n_administered)` (requires `n >= 1`; `r = 3` - default per Study 1). Administered items keep their computed index; masking - applies to selection only. Small-delta Fisher limit - `I(theta0) * delta^3 / 3` anchored by test. Paper READ (Chang & Ying, 1996, - doi:10.1177/014662169602000303); cross-checked against catR `KL.R`. - -- **Raju ICC-area DIF** (`fast_mlsirm.raju_area`; in `mlsirm_core::dif`). - Parametric signed/unsigned area between two logistic ICCs on a common - scale, with Raju's delta-method Z tests. Signed area `h = b_F - b_R` - (positive = harder for focal); unsigned `h = |H|` from Raju's closed form - via a numerically stable softplus, with a continuous equal-slope fallback - `|b_F - b_R|`; common-guessing 3PL reports `h`/`se` scaled by `(1 - c)` - with the Z from unscaled quantities. Primary papers NOT read (Raju 1988, - Psychometrika 53(4), 495-502, doi:10.1007/BF02294403; Raju 1990, APM - 14(2), 197-207, doi:10.1177/014662169001400208 — both paywalled); formula - oracle is the difR source (`RajuZ.R`, `difRaju.R`; Magis et al. 2010, - Behavior Research Methods 42, 847-862, doi:10.3758/BRM.42.3.847 — code - read in full, package paper not read). Both areas and all four - delta-method partials re-derived by hand and verified against numeric - quadrature/finite differences in adversarial spec review; documented difR - divergences: its gradient is the uniform negation of dH (variance- - equivalent) and its `exp(Y)==Inf` overflow branch carries the sign - opposite to the closed-form positive-side limit (unreachable here via - softplus + `|H|`). Monte-Carlo (500 reps, parametric asymptotic): - signed test holds nominal level and power; the unsigned Z is measurably - anti-conservative under an exact equal-slope null (~.14 at nominal .05), - documented in the API rather than hidden. - -- **Velicer minimum average partial (MAP) test** (`fast_mlsirm.velicer_map`, - `velicer_map_from_data`; in `mlsirm_core::factor`). Component-retention - test of Velicer (1976, Psychometrika 41(3), 321-327, - doi:10.1007/BF02293557 — NOT read; formula - support is the read implementations below): PCA loadings from the - eigendecomposition of R, partial covariance `C* = R - A_m A_m'` rescaled - to a partial correlation matrix, and `f2[m]` = mean squared off-diagonal - partial correlation for `m = 0..max_m` (with the `m = 0` baseline being - R itself); retained components = the `m` at the minimum. Also computes - the revised elementwise fourth-power criterion `f4` (Velicer, Eaton, & - Fava, 2000, in Goffin & Helmes, Problems and Solutions in Human - Assessment, 41-71 — not read; attributed per O'Connor's code comments). - Algorithm and retention rule verified against Brian O'Connor's canonical - MAP programs (map.m and map.sps, oconnor-psych.ok.ubc.ca/nfactors — read - in full; O'Connor, 2000, Behavior Research Methods, Instruments, & - Computers 32(3), 396-402, paper itself not read) and psych VSS.R `map()` - (Revelle, 2025 — read). Documented divergences found by adversarial - review: `fungible::faMAP` prints a 1-based row position (off by one vs - O'Connor's count — not reproduced); `EFA.dimensions::MAP` now uses matrix - powers for the fourth-power criterion, conflicting with O'Connor's - elementwise form (unresolved from primary literature; we follow - O'Connor). Rows with singular partial-covariance normalization (e.g. - identity R for `m >= 1`) are NaN and excluded from the argmin. Rust core - with PyO3 binding and thin NumPy wrapper; Harman-8 full-vector oracle - parity (independent NumPy transcription), identity guard, and a - 500-replication Monte-Carlo recovery test (`#[ignore]`). -- **a-stratified multistage CAT item selection** (`fast_mlsirm.a_stratified`; - in `mlsirm_core::exposure`). Simulation of Chang & Ying's (1999) - a-stratified design: the pool is split into `n_strata` contiguous strata by - ascending discrimination `a` (stable sort, near-equal sizes with the first - `n mod K` strata one item larger — repository choice; catR places the - remainder last), the test is partitioned into matching stages, and within - the active stratum the next item is `argmin |b_i - theta_hat|` - (b-matching, ties to the lowest original index). The b-matching selection - rule and ascending-a strata are confirmed from Barrada, Mazuela, & Olea - (2006, Psicothema 18(1), 156-159 — read in full); Chang & Ying (1999, - Applied Psychological Measurement 23(3), 211-222) is cited as the design's - origin from its abstract. Interim EAP on a uniform grid and the initial - `theta_hat = 0` are repository choices (the paper used ML-based interim - estimation). Returns per-item exposure rates, stratum assignment, stage - lengths, and theta RMSE/bias; the per-stratum counting identity - `sum_{i in stratum k} P(A_i) = stage_lengths[k]` holds exactly and is - regression-tested against returned values. Stratum-level b-blocking - (Chang, Qian, & Ying, 2001, "a-stratified multistage computerized adaptive - testing with b blocking", Applied Psychological Measurement 25(4), - 333-341 — not read; excluded per adversarial spec review) is out of - scope. Rust core with PyO3 binding - and thin NumPy wrapper; mutation-audited tests plus a 500-replication - Monte-Carlo comparison (`#[ignore]`) showing lower exposure imbalance - (summed squared deviation from the uniform rate `L/n`) than - max-information selection. -- **Sympson-Hetter item-exposure control** (`fast_mlsirm.sympson_hetter`; - in `mlsirm_core::exposure`). Iterative Monte-Carlo calibration of the - exposure-control parameters `k_i = P(A_i | S_i)` for dichotomous 3PL - max-information CAT with interim EAP: per-encounter uniform gate - (administer iff `u <= k_i`, rejected items blocked for the remainder of - that simulee's test), update `k_i <- min(1, r_max / P(S_i))` (Barrada, - Olea, & Ponsoda, 2007, Eq. 1-3; algorithm confirmed from Georgiadou, - Triantafillou, & Economides, 2007 — both read in full; Sympson & Hetter, - 1985, cited as origin, not read). The stopping rule - `max P(A) <= r_max + tol` is a practical criterion, not a convergence - theorem (van der Linden, 2003, abstract); the returned `k` is always the - vector that produced the reported final-cycle rates. Feasibility bound - `r_max >= test_length/n_items` (exact counting identity - `sum_i P(A_i) = test_length`, derived here) is enforced; the bound is - necessary, not sufficient — a tight `r_max` near the bound may still - exhaust the pool mid-test and fail with the documented error. `r_max = 1` - reduces exactly to unconstrained max-info CAT (no exposure RNG - consumed); an exhausted pool raises an error (repository policy, not a - classical prescription). Adversarially spec-verified before - implementation (REDUCED SCOPE: no theta-stratified variants, no - forced-administration fallback, no "classical iteration count" claim); - a 500-rep Monte Carlo calibration run (`#[ignore]`); four executed - mutation kills (gate flip, `P(A)` update denominator, no-blocking — - killed by divergence/non-termination — and swapped selection/exposure - bookkeeping). -- **Selection utility analysis** (`fast_mlsirm.selection_utility` / - `taylor_russell`; in `mlsirm_core::utility`; transcribed from CRAN - iopsych 0.90.1 `utilityBcg`/`trModel`/`ux`, read in full — Goebl, - Jones, & Beatty, 2016; the original Taylor & Russell, 1939, Naylor & Shine, 1965, - and Cronbach & Gleser, 1965 sources were not read and are cited as - attributed). Formulas under the standard bivariate-normal selection - model: selection intensity `ux = phi(xc)/sr`, Naylor-Shine selected-group - criterion mean `pux = rxy*ux`, BCG utility gain - `n*period*sdy*pux - cost_total` (the iopsych `cost` argument is - documented here as a TOTAL cost — iopsych labels it per-applicant but - never multiplies by `n`), and Taylor-Russell success ratio - `P(Y>yc | X>xc) = Q(xc,yc,rxy)/sr` with the bivariate-normal upper tail - `Q` evaluated by a conditional-normal Gauss-Legendre integral (~1e-15 - vs scipy's BVN CDF at moderate `|rxy|` during adversarial spec review, - better than 1e-6 across the whole accepted `|rxy|` range — - regression-tested; the committed oracle generator is - `tests/oracles/oracle_utility.py`; the iopsych `qa/(qa+qb)` form was - proven equal to `Q/sr` algebraically and numerically). Adversarially spec-verified before implementation; five - scipy-pinned oracle fixtures including negative validity; rho=0 - analytic anchor (success == base rate); strict rxy monotonicity; a - 500-rep x 20,000-person Monte Carlo recovery run (`#[ignore]`; success - ratio within 4.3e-5, pux within 3.8e-4); four executed mutation kills - (dropped `rxy` in pux, sign flip in the Q integrand, `1-sr` denominator - in ux, sr/br role swap). Documented identity limitation: the mutant - `Q(h,k) -> Q(k,h)` alone is output-identical everywhere by BVN exchange - symmetry — no test claims to kill it; cutoff-role bugs are anchored by - the role-swap kill instead. Post-implementation adversarial review - hardening: sub-ulp ratios (where `1.0 - v` rounds to 1.0) are rejected - instead of returning NaN/silent zeros; the BVN panel width scales with - `sqrt(1 - rho^2)` so `|rxy|` near 1 stays accurate (Err beyond - `sqrt(1-rxy^2) < 1e-4`); `q_joint` is bounded by `min(sr, br)`; all - three regression-tested against scipy `quad` oracles. -- **Factor-analytic greatest lower bound** (`fast_mlsirm.glb_fa` / - `glb_fa_from_data`; in `mlsirm_core::factor::glb_fa_corr`; transcribed - from CRAN psych `glbs.R` `glb.fa`, read in full — Revelle, 2025; NOT the - algebraic glb of `glb.algebraic`, which requires an SDP solver; Sijtsma, - 2009, not read). Algorithm: 1-factor minres fit, eigenvalues of `R` with - the diagonal replaced by the model communalities, `nf` = count of - positive eigenvalues with psych's single df-based decrement, then - `glb = sum(rr)/sum(R)` with `diag(rr)` from an `nf`-factor refit. - Verified against a pinned independent scipy oracle on a 9-variable - 2-factor population matrix (glb to 1e-5), a sampled 6-variable matrix - and a df-adjustment fixture (both df = 0 saturated fits, wider bands - documented), plus a 500-rep Monte Carlo run (`#[ignore]`; observed mean - glb 0.863 vs population omega 0.830 — the expected upward bias of glb - under multi-factor detection is documented, not hidden). Three executed - mutation kills (skipped diagonal substitution, 1-factor communalities in - the ratio, dropped df decrement). -- **Person separation reliability** (`fast_mlsirm.separation_reliability`; - in `mlsirm_core::reliability`; transcribed from CRAN eRm `SepRel.R`, read - in full — Mair et al., 2025; the statistic is attributed there to Wright - & Stone, 1999, not read). `R = (SSD - MSE)/SSD` with `SSD = var(measures)` - (n-1 denominator) and `MSE = mean(se^2)`, unclamped; plus the hand-derived - separation index `G = sqrt((SSD - MSE)/MSE)` (adjusted true SD over RMSE, - `G^2 = R/(1-R)`; not in the read source). eRm's extreme-score/NA - filtering is documented as caller responsibility. Verified against a - pinned numpy fixture (SSD, MSE, R, G at 12 decimals), a negative-R path, - and a 500-rep Monte Carlo recovery (`#[ignore]`; population R = 0.8 - recovered to 0.01); three executed mutation kills (swapped numerator, - population variance, unsquared se). -- **Minres (ULS) exploratory factor analysis and McDonald's omega_total - (1-factor)** (`fast_mlsirm.minres_fa`, `minres_fa_from_data`, - `omega_total_1f`, `omega_total_1f_from_data`; in `mlsirm_core::factor`; - line-by-line transcription of CRAN psych `fa.R`'s minres path — Revelle, - 2025, read; McDonald, 1999, cited-not-read, the omega formula is - hand-derived from the standardized 1-factor model). Uniquenesses are - box-constrained to `[0.005, 1]` and optimized by projected - Barzilai-Borwein descent with an Armijo safeguard and finite-difference - fallback (psych's `FAgr.minres` direction is not the exact gradient of - the lower-triangle objective — a verified limitation); convergence is - certified by a finite-difference box-KKT check whose maximum violation - is returned (`kkt_violation`). Loadings are unrotated, columns in - descending-eigenvalue order with column sums >= 0. REDUCED SCOPE: no - rotation, no Schmid-Leiman / omega_hierarchical, no ML/WLS/GLS, no - factor scores. Tests pin parity at 5e-5 against an independent scipy - L-BFGS-B transcription oracle, anchor the absolute objective value of a - deliberately misfitting 1-factor fit (the only assert that can kill the - lower-triangle-vs-all-off-diagonal x2 mutation — disclosed), verify - rank-1 exact recovery and structure invariants, execute four mutation - kills, and include a 500-rep `#[ignore]` Monte Carlo recovery study. -- **Generalizability theory G/D studies for crossed designs** - (`fast_mlsirm.gtheory_pi`, `fast_mlsirm.gtheory_pio`; in - `mlsirm_core::gtheory`; Huebner & Lucht, 2019, read in full — the EMS - inversions the paper defers to Brennan, 2001, and Shavelson & Webb, - 1991, both cited-not-read, are hand-derived and numerically verified - against the paper's published Tables 3-6). One-facet `p x i` and - two-facet `p x i x o` random-effects ANOVA variance components, plus - D-study relative/absolute error variances, generalizability coefficient - E-rho^2, and dependability Phi over proposed facet sizes. Negative raw - components are reported as-is in `var_raw` and clamped to zero in `var` - for the D study (documented clamped-ANOVA implementation policy); - coefficients are NaN when their denominator is <= 1e-12. Rust tests - reproduce the paper's worked examples at full precision, add - independent RNG-pinned fixtures (including a natural negative-component - anchor), executed mutation kills (M1/M3/M5/M6), and a 500-rep - `#[ignore]` Monte-Carlo recovery test. -- **Livingston-Lewis classification accuracy and consistency** - (`fast_mlsirm.livingston_lewis`; in `mlsirm_core::classification`; - Livingston & Lewis, 1995, as implemented in Haakstad's CRAN - `betafunctions` 1.9.0 source `LL.CA` in `R/classification.R`, read line - by line — the original article was not consulted directly; Hanson, 1991, - four-parameter beta moment fit, as cited in Haakstad, 2022). From a - single administration: effective test length - `((m-min)(max-m) - r s^2)/(s^2 (1-r))`, true-score raw moments via the - factorial-moment identity on the unrounded-ETL scale, four-parameter - beta method-of-moments fit with a two-parameter [0, 1] fail-safe, then - accuracy cells (tp/fp/tf/ff), sensitivity/specificity, consistency - cells, and Cohen's kappa under a binomial observed-score model with - `N = round(ETL)`. Integrals use singularity-safe composite - Gauss-Legendre quadrature (power substitution when a shape parameter is - below one; endpoint-graded panels otherwise), verified against - `scipy.integrate.quad` replication literals at 1e-7. Divergences - (documented in the module): a single round-ties-even threshold - `k = round(N c)` in both the accuracy and consistency blocks (the oracle - mixes `round` in accuracy with `floor` in consistency, making its - consistency cells asymmetric; here `p_ij == p_ji` by construction); - pass = observed score >= cut is the positive class (the oracle labels - fail as positive); the fail-safe also engages on numerically invalid - four-parameter fits (the oracle only checks out-of-bounds support); hard - errors instead of NA/NaN propagation for invalid inputs, while the - conditional ratios (sensitivity, specificity, kappa) are an explicit - `NaN` when their margin or chance denominator vanishes (e.g. a cut - outside the fitted beta support). -- **Cronbach alpha + Feldt exact-F confidence interval** - (`fast_mlsirm.cronbach_alpha`, `fast_mlsirm.feldt_alpha_ci`; in - `mlsirm_core::reliability`; Feldt, 1965, as cited in and implemented by - Revelle's CRAN `psych` 2.6.5 source `alpha.ci` in `R/alpha.R`, read line - by line; Cronbach, 1951, covariance form verified against the same - source). `cronbach_alpha` computes the raw-covariance form - `p/(p-1) * (1 - tr(C)/sum(C))`; `feldt_alpha_ci` inverts the pivot - `(1-alpha)/(1-alpha_hat) ~ F(n-1, (n-1)(p-1))` into a two-sided interval - (`lower = 1-(1-alpha_hat)*qF(1-delta/2)`, upper mirrored) plus the - implied average inter-item correlation `r_bar`. The F quantile is - computed in-crate via a Lentz continued-fraction regularized incomplete - beta and bisection (verified against `scipy.stats.f.ppf` fixture - literals at 1e-9). Bounds are not clamped; negative alpha is accepted - into the CI, matching psych. Divergences (documented in the module): - raw-data input only, zero-variance items rejected, confidence `level` - argument instead of `p.val`, hard errors instead of NA. -- **ten Berge & Zegers mu reliability series** (`fast_mlsirm.tenberge_mu`; - in `mlsirm_core::reliability`; ten Berge & Zegers, 1978, as cited in and - implemented by Revelle's CRAN `psych` 2.6.5 source `tenberge.R`, read - line by line). On the Pearson correlation matrix with `Vt = sum(R)`, - off-diagonal power sums `S_k`, and `c = p/(p-1)` on the innermost radical - only: `mu0 = c*S_1/Vt` (= coefficient alpha = Guttman lambda3), - `mu1 = (S_1 + sqrt(c*S_2))/Vt` (= Guttman lambda2), `mu2` and `mu3` nest - one and two further radicals over `S_4` and `S_8`. The series ordering - `mu0 <= mu1 <= mu2 <= mu3` follows from Cauchy-Schwarz over the `p*(p-1)` - off-diagonal cells and is asserted on crate outputs. Divergences from - psych (documented in the module): raw-data input only (no - correlation-matrix passthrough, no `use = "pairwise"`), hard errors on - degenerate input, `S_1` summed directly to avoid cancellation. Verified - against an independent NumPy replication on two fixtures pinned at - `1e-9`, exact-identity cross-checks against `guttman_lambdas`, and a - 500-replication tau-equivalent Monte Carlo (`#[ignore]`). -- **Guttman lambda reliability coefficients** (`fast_mlsirm.guttman_lambdas`; - new `mlsirm_core::reliability`; Guttman, 1945, as cited in and implemented - by Revelle's CRAN `psych` 2.6.5 sources `guttman.R`/`splitHalf.R`/`smc.R`, - read line by line). On the Pearson correlation matrix: lambda1-lambda3 - (lambda3 = coefficient alpha), lambda5 (best covariance column), lambda6 - (squared multiple correlations via a plain symmetric inverse), plus - split-half summaries — lambda4 (best split), beta (worst split, floored at - 0), and the mean split over all `C(p, floor(p/2))` subsets when that count - fits the `n_sample_splits` budget (psych's brute-force cutoff 15000), - otherwise over LCG-sampled splits. Declared divergences (documented in the - module): no `check.keys` auto-reversal, absolute split-half correlations - in both branches (psych's sampled branch is signed), hard error on - singular correlation matrices instead of psych's pseudoinverse, crate-LCG - sampling (psych-inspired, not bit-identical to any R run), and duplicate - sampled subsets allowed. Verified against an independent NumPy replication - (`np.corrcoef` + `np.linalg.inv` + `itertools.combinations`) on three - fixtures (even-p exhaustive, odd-p exhaustive, sampled) pinned at `1e-9`, - plus a 500-replication tau-equivalent Monte Carlo (`#[ignore]`) recovering - the analytic sum-score reliability within 0.01. -- **Horn's parallel analysis** (`fast_mlsirm.parallel_analysis`; new - `mlsirm_core::parallel`; Horn, 1965, and Glorfeld, 1995, as cited in and - implemented by Dinno's CRAN `paran` 1.5.6 sources, read line by line). - PCA path: eigenvalues of the observed Pearson correlation matrix (cyclic - Jacobi, eigenvalues only, hard error on non-convergence) are adjusted by - the sampling bias `random_eigenvalue - 1` estimated from `n_iterations` - standard-normal data sets of the same shape; components are retained - while the adjusted eigenvalue stays above 1, scanning left to right and - stopping at the first failure (later resurgences do not count, matching - paran's loop-and-break). `centile = 0` uses the per-position mean - benchmark; `1..=99` uses that upper centile via the R type-7 quantile - (Glorfeld's conservative variant). Deliberate divergences (documented in - the module): PCA only (paran's `cfa` generalized-inverse path is out of - scope), a single deterministic crate-LCG random stream (paran-inspired, - not bit-identical to any R run), and narrowed guards (`n_persons >= 3`, - `n_items >= 2`, finite complete data, positive column variance, explicit - `n_iterations`; the Python wrapper supplies paran's `30 * n_items` - default). Fixture literals verified against an independent NumPy - replication that mirrors the LCG stream exactly. - -- **IRT classification accuracy and consistency** - (`fast_mlsirm.rudner_classification`, `fast_mlsirm.lee_classification`; new - `mlsirm_core::classification`; Rudner, 2001, 2005; Lee, 2010, as cited in - Lathrop's CRAN `cacIRT` sources). Rudner's normal-approximation method - treats the observed score at ability theta as N(theta, sem^2) and reports - per-cut and simultaneous accuracy/consistency, conditional and marginal - (weights normalized internally; uniform weights reproduce cacIRT's - person-level `Rud.P`, quadrature weights the distribution-level `Rud.D`). - Lee's summed-score method replaces the normal approximation with the exact - Lord-Wingersky (1984) score distribution reused from - `mlsirm_core::scoring`; raw cuts split scores at `ceil(cut)` and the true - category is the raw-score interval containing the expected true score. - Category intervals are left-closed everywhere (cacIRT's `Lee.D` alone is - right-closed — documented divergence); item probabilities must lie - strictly inside (0, 1) (rejecting P == 0 is stricter than the oracle, - which only breaks at P == 1); simultaneous outputs are always populated - (cacIRT emits them only for two or more cuts). Polytomous Lee, `np.cac`, - and the MLE/SEM ability helpers are out of scope. Rudner outputs inherit - the crate `erfc` accuracy (|err| < 1.2e-7); Lee outputs are exact to f64 - rounding. For LLM-as-a-Judge quality management this quantifies how - reliably a judge's cut score separates pass from fail. Rust-only numerics; - the Python wrapper validates and marshals. Tests pin marginals and - conditionals against literals from an independent NumPy transcription - (exact `math.erf`, own recursion), anchor left-closed cuts with a theta - exactly on a cut and a dyadic true score exactly on a raw cut, use - unnormalized weights and a non-integer raw cut as mutation anchors (four - mutation spot-checks killed), and include a 500-replication ignored Monte - Carlo ordering long informative tests above short noisy ones. -- **Confirmatory DETECT dimensionality analysis** (`fast_mlsirm.detect_analysis`; - new `mlsirm_core::detect`; Zhang & Stout, 1999, as cited in Robitzsch, 2024). - Estimates pairwise conditional covariances of binary items with sum-score - conditioning — the bias-corrected average of the total-score and pair - rest-score conditionings, per-group ML covariance aggregated with - group-frequency weights — and computes the DETECT, ASSI, RATIO, MADCOV100, - and MCOV100 indices against a known item clustering (labels opaque, - equality-only). Transcribed line-by-line from the CRAN `sirt` R sources - (`detect.index.R`, `ccov.np.R`, `ccov_np_compute_ccov_sum_score.R`, - `conf.detect.R`); matches the explicit `ccov.np(use_sum_score=TRUE, - scale_score=FALSE)` path — the kernel-smoothed default, missing data - (sirt pairwise-deletes), sqrt(N)-weighted variants (coincide with - unweighted under complete data), exploratory cluster search, and polytomous - DETECT are documented as out of scope. All-zero conditional covariances - (RATIO `0/0`, NaN in R) are rejected with an error. For LLM-as-a-Judge - item-quality management this diagnoses whether a rubric partition of judge - items behaves as distinct dimensions. Rust-only numerics; the Python - wrapper validates and marshals. Tests pin all five indices and every - per-pair conditional covariance against literals from an independent NumPy - transcription (which cannot discriminate the z-standardized default path, - since unique-value grouping is invariant to monotone transforms — the scope - statement pins that contract), plus hostile `i64::MIN`/`i64::MAX` labels - and a 500-replication Monte Carlo (`#[ignore]`) separating 2D simple - structure from unidimensional data. -- **Haberman subscore added-value analysis** (`fast_mlsirm.subscore_analysis`; - new `mlsirm_core::subscores`; Haberman, 2008, as cited in Sinharay, 2010). - For each subscale of a disjoint, exhaustive item partition computes the - PRMSEs of the three classical-test-theory true-subscore estimators — from - the observed subscore (`= Cronbach alpha`), from the observed total - (`rho^2(s_t, x_t) * alpha_x` with the true-score covariance row sum over - subscore columns only), and from both jointly (Wainer-style augmentation via - `tau`/`beta`/`gamma`) — plus per-person estimator matrices, the - `(K+1)^2` score correlation matrix, disattenuated subscore correlations, and - added-value decisions (Haberman's `PRMSE_s > PRMSE_x`; Sinharay's 2010 - `+ 0.01` margin for augmentation, labeled — CRAN `CTTsub`'s relative rule is - documented but not implemented). Formulas verified against the Appendix of - Sinharay (2010, ETS RR-10-16) and the CRAN `subscore` R source read - line-by-line; degenerate samples (alpha outside `(0, 1]`, zero variance, - subscore collinear with the total) are rejected instead of propagating NaN. - For LLM-as-a-Judge item-quality management this decides whether per-domain - judge subscores add diagnostic value over the overall score. Rust-only - numerics; the Python wrapper validates and marshals. Tests pin every - reported statistic against literals from an independent NumPy transcription - of the R semantics on an asymmetric fixture with mixed added-value - outcomes, include rejection tests for the structural and degeneracy guards - (the defensive computed-PRMSE-range guard is not separately exercised), a - conditional dominance - sweep on guard-passing random data, and a 500-rep `#[ignore]` Monte Carlo - MSE comparison; three mutation spot-checks (dropped `m/(m-1)`, rowsum - including the total column, `tau` numerator sign flip) were run and killed. -- **Kernel-smoothing nonparametric IRT** (`fast_mlsirm.ksirt_analysis`; new - `mlsirm_core::ksirt`; Ramsay, 1991, as cited in Mazza et al., 2014). - Estimates option characteristic curves by Nadaraya-Watson kernel regression - (gaussian/quadratic/uniform kernels) of option indicators on rank-based - ordinal ability estimates `qnorm(rank/(n+1))`, on an equally spaced - evaluation grid, with Silverman-rule default bandwidths, plus expected item - score and expected total score curves. Formulas verified against the - KernSmoothIRT JSS paper (Mazza et al., 2014, Sections 2-2.3) and the - KernSmoothIRT R/C++ package source read line-by-line; standard errors and - cross-validation bandwidth selection are deliberately out of scope (the R - implementation's SE accumulator is order-dependent and unverifiable from - read sources). For LLM-as-a-Judge item-quality management this reveals - non-monotone or poorly discriminating evaluation items without a parametric - model. Rust-only numerics; the Python wrapper validates and marshals. Tests - pin a hand-computed 4-person fixture (rank->theta qnorm literals, grid - endpoints, Silverman constant), enforce structural invariants - (row-sums-to-one with positive denominators, compact-support zeros, - zero-denominator fallback), and include a 500-replication Monte Carlo - recovery study (`#[ignore]`) under normal and skewed ability generation - using the rank-invariance composition oracle. -- **Mokken scale analysis** (`fast_mlsirm.mokken_analysis`; new - `mlsirm_core::mokken`; Mokken, 1971, as cited in van der Ark, 2007). - Computes the Loevinger scalability coefficients `Hij`, `Hi`, `H` and their - Mokken Z statistics, and partitions items into Mokken scales with the - automated item selection procedure (AISP, "search normal"), with sample - statistics and selection mechanics verified line-by-line against the mokken - R package source (van der Ark, 2007; Straat et al., 2013): `Hij = - S_ij/Smax_ij` with `Smax` from the comonotone (sorted-column) coupling, - `Hi`/`H` as ratios of pairwise sums, and per-scale Bonferroni-adjusted Z - gates. For LLM-as-a-Judge item-quality management this flags evaluation - items that fail to scale (label 0) and detects multidimensional item pools - before parametric calibration. Complete integer data required (dichotomous - or polytomous). Rust-only numerics; the Python wrapper validates and - marshals. Tests include a brute-force covariance oracle, an exact Guttman - `H = 1` anchor, a hand-computed Z fixture, a Z-gate anchor whose deletion - seeds a spurious scale (this test caught a real sign error in the normal - quantile during development), a hand-constructed Criterion-1 design whose - negative-`Hij` exclusion is the only active gate (mutation-verified), a - two-cluster AISP recovery, and an `#[ignore]` 500-replicate Monte Carlo - (normal + skewed traits). -- **Many-Facet Rasch Model (MFRM) rater-severity calibration** (`fast_mlsirm.fit_facets`; - new `mlsirm_core::facets`; Linacre, 1989; Eckes, 2015). Fits - `ln[P(k)/P(k-1)] = theta_p - d_i - c_j - f_k` — the rating scale model - (Andrich, 1978) with a rater facet — to a `persons x items x raters` array with - NaN-missing sparse judging plans. For LLM-as-a-Judge calibration this puts each - judge's severity `c_j` on a common logit scale adjusted for item difficulty and - respondent ability. Estimation is marginal-ML EM on a Gauss-Hermite grid - (Bock & Aitkin, 1981), NOT Linacre's JMLE, and the docs say so: estimates match - the Facets program only up to the JMLE-vs-MMLE difference. Identification: - `theta ~ N(0,1)`, severities and thresholds centered to sum 0 - (`n_parameters = I + (J-1) + (K-2)`). Reports Linacre's connectedness - diagnostic via union-find over the person-mediated item∪rater co-observation - graph; `connected=False` means cross-component severity comparisons rest - solely on the shared trait prior, not the rating design. Rust-only numerics; - the Python wrapper validates and marshals. Tests include FD gradient anchors, - the J=1 RSM-reduction identity, asymmetric-severity recovery, sparse and - disconnected designs, and an `#[ignore]` 500-replicate Monte Carlo - (normal + skew-normal traits) bounding severity bias and RMSE; a gradient - sign-flip mutant was verified to fail 4 tests. -- **Warm's weighted-likelihood ability estimation for POLYTOMOUS items** (`fast_mlsirm.score_wle_poly`; - new `score_wle_poly` in `mlsirm_core::scoring`; Warm, 1989). The library already had the full - polytomous model family and polytomous EAP scoring, but its only bias-reduced ML ability estimator was - dichotomous-only. EAP shrinks toward the population mean, which is exactly what individual score - reporting must not do, so this closes a real gap rather than adding a third way to do the same thing. - Solves `dlnL/dtheta + J/(2I) = 0` with `I = sum_k P'_k^2 / P_k` and `J = sum_k P'_k P''_k / P_k` - accumulated over the person's observed items — the exact generalization of the shipped dichotomous - `sum_i P' P''/(P Q)`, which is its two-category case. `J` is computed DIRECTLY, never as a derivative - of `I`. GRM and GPCM; PCM is the GPCM path at `slope = 1`. RSM is deliberately NOT supported and the - code says why: its fitted `(delta, shared tau)` parameterization is not convertible through any - exposed API, since `rsm_logprobs` builds the equivalent intercepts internally and does not return - them. - **Verification status is stated in the code, because it bounds what may be claimed.** That the - polytomous Warm correction is `J/(2I)` with `J = sum_k P'P''/P` is confirmed from the `catR` package's - SOURCE, not from a primary paper — and `catR` keeps its Jeffreys-prior branch as a separate - expression, so the two estimators are kept distinct here too (Magis & Raîche, 2012). Penfield and - Bergeron (2005) treat the GPCM but their equations were not obtainable, so nothing here rests on them - and they are not cited as a source. Separately, and PROVED in-repository rather than taken from a - source: `J = I'` holds exactly for both shipped families. From `I' = 2J - T` one gets - `J - I' = -E[l' l'']`, which vanishes because the GPCM's `l''` is category-free and the GRM's sum - telescopes through `v_0 = v_K = 0`; checked numerically at 80-digit precision against fully numeric - derivatives (relative `|J - I'| <= 1.1e-30`). The WLE therefore coincides with the Jeffreys modal - estimate for these two families — but the identity is used ONLY as a test oracle and never as an - implementation shortcut, because it fails for a graded model with per-boundary slopes and for the 3PL, - both of which are pinned as negative controls. - **Numerics.** Per-category quantities are formed division-free from the sigmoids — GPCM - `P'_k/P_k = a(k - E)`, `P''_k/P_k = (P'_k/P_k)^2 - a^2 Var(k)`; GRM `P'_k/P_k = a(1 - s_k - s_{k+1})`, - `P''_k/P_k = (P'_k/P_k)^2 - a^2(v_k + v_{k+1})` — so no category probability ever appears in a - denominator and no probability floor is needed anywhere. The resulting GRM information is algebraically - identical to the shipped `poly_item_information`, via `v_k - v_{k+1} = P_k(1 - s_k - s_{k+1})`. - **Both polytomous log-likelihoods are log-concave, yet the weighted objective is genuinely - multimodal**, because the Warm weight is not: a 3-item GPCM bank in the test suite has stationary - points at `+0.0988`, `+0.3774` and `+1.3314` while `max lnL'' = -5.6e-5 < 0`, so a solver that brackets - the first sign change from the left errs by 1.23 logits (2.36 on the GRM fixture). The global grid - scan of `score_wle` is therefore reused unchanged, including its refusal to return an unresolved mode - beyond 65,536 intervals; the grid demand additionally scales with `n_cat - 1`, documented as a derived - worst-case margin for which no wrong-mode counterexample was reproduced. - **Guards.** Eleven anchors, the important ones mutation-verified with the measured result recorded. - `J == I'` is pinned with BOTH sides coming from different crate code paths (the accumulator versus a - central difference of the shipped `poly_item_information`), and the reference magnitudes are pinned to - 1e-5 rather than merely asserted non-zero, so a zeroed or sign-flipped `jterm` fails. `K = 2` - reproduces the dichotomous `score_wle` for both families — non-discriminating as a design argument, - but the only anchor that catches a layout transpose, a `cat_params` stride bug or a missing - chain-rule `a`. The two global-mode fixtures assert the returned value is the dominant mode and not - the leftmost stationary point; a mutation that stops the scan at the first rise of `Phi` fails eight - of the ten polytomous tests, and the narrower "take the leftmost stationary point" substitution fails - the two global-mode fixtures specifically. The estimating equation is re-derived from finite - differences of the log-probability routines alone, and the all-lowest/all-highest patterns are - asserted finite alongside a check that the UNWEIGHTED score really does keep a constant sign there, - so the "the MLE diverges" premise is verified rather than assumed. - **One coverage limit is stated rather than papered over.** Because `J = I'` is EXACT for both shipped - families, an implementation that replaced `J` with a numerical derivative of `I` would be - behaviour-preserving and NO polytomous test can detect it — a mutation confirmed to leave the whole - polytomous suite green. The discriminating anchors for that substitution live in the dichotomous - suite, where a lower asymptote breaks the identity. The accompanying test therefore documents that the - identity is family-specific (exhibiting a per-boundary-slope graded model and a 3PL where it fails, - with measured relative gaps of 0.92/1.17 and 0.47) and is labelled a lemma about the ORACLE, not a - test of the code. - **Also corrects an error in the dichotomous WLE documentation shipped earlier in this release**: it - claimed `J` coincides with `I'/2` for the 2PL/Rasch. The correct statement is `J = I'` exactly there, - from `I' = 2J - T` with `T = sum_i P_i'^3 (1 - 2 P_i)/(P_i Q_i)^2`, and `T = J` only when - `c = 0, d = 1` — which is why the weight is `sqrt(I)`. Fixed in the Rust, PyO3 and Python docstrings; - the historical entry below is left as written. The identity is now PINNED by - `wle_information_derivative_identity`, because the first attempt at this correction was itself wrong - (it dropped the `(1 - 2 P)` factor from `T`, giving a value ~5x off at the 2PL) and nothing caught it. - A formula asserted in prose and checked by no test is how that happens twice. - -- **Uniform SIBTEST, the regression-corrected observed-score DIF procedure** (`fast_mlsirm.sibtest`; - extends `mlsirm_core::dif`; Shealy & Stout, 1993). The third observed-score DIF procedure in the - module and the only one that corrects the MATCHING CRITERION itself. Mantel-Haenszel and the logistic - sweep both match on an observed number-correct score, which is unreliable: under IMPACT — a genuine - group difference in ability — two examinees from different groups with the same OBSERVED score do not - have the same expected TRUE score, because each regresses toward their own group's mean. Matching on - the raw score therefore compares non-equivalent examinees. Item purification, added earlier in this - release, cannot substitute for this: it changes WHICH items form the criterion, not the regression of - true score on observed score, so a perfectly purified criterion is still biased. SIBTEST transports - each group's conditional mean from that group's own Kelley-regressed true score - `V*_Gk = [Xbar_G + alpha_G (k - Xbar_G)] / n_valid` to the unweighted midpoint of the two, using a - per-level central difference taken over each group's OWN true-score scale at adjacent OBSERVED level - positions, and compares the transported means under combined-sample weights renormalized over the - retained strata. The valid and studied subtests are DISJOINT by construction — the opposite of the - item-included Mantel-Haenszel default (Donoghue, Holland & Thayer, 1993), and a property of the - estimator rather than an option. Per-group coefficient alpha is reported on every row because the - correction divides by it. - **The headline finding is unflattering and is documented as such.** Measured against - `mantel_haenszel_dif` on identical simulated data, 500 replications per cell, no DIF planted so every - rejection is a false positive: at zero impact MH holds .044 and SIBTEST .056; at impact 1.0 MH holds - .046 while SIBTEST reaches **.086**. SIBTEST over-rejects in both cells and by roughly double under - impact — the opposite of the ordering its motivation suggests. This is a property of the 1993 - estimator rather than a transcription slip (the closed-form anchors reproduce the TRANSCRIBED FORMULAS - in exact rational arithmetic; `mirt` itself was never executed, so no cross-implementation agreement - is claimed), whose standard error treats the ESTIMATED regression correction as fixed and so never - charges the correction's own noise to the variance. It is precisely what Jiang and Stout's (1998) - paper — "Improved Type I error control and reduced estimation bias for DIF detection using SIBTEST" — - was written to fix, and that two-segment estimator is NOT implemented here. The docs accordingly - recommend Mantel-Haenszel or the logistic sweep for routine screening and position SIBTEST as the way - to obtain the regression-corrected *estimand*, reading `beta_uni` as an effect size rather than - trusting `p_value` as a calibrated test. A 500-replication Monte-Carlo test pins that finding so the - claim cannot rot. - **Sign.** `beta_uni > 0` means harder for the FOCAL group — the OPPOSITE orientation to `mh_d_dif` - and `std_p_dif` in the same module, kept rather than harmonised because published `|beta_uni|` - cut-offs assume it, and asserted in both directions by a cross-module anchor whose product assertion - would catch a future refactor that "harmonises" both conventions at once. - **Provenance, stated because it bounds what the code may claim.** Every formula is transcribed from - the reference implementation (Chalmers, 2012; the `SIBTEST` routine of `mirt`), which attributes them - to Shealy and Stout (1993); the primary text was not consulted, so no comment cites the 1993 equations - directly. One deliberate DIVERGENCE from that reference is marked in the code: where a neighbouring - group-by-level cell is empty it imputes the mean to zero and feeds that fabricated value into the - central difference, producing a finite but meaningless slope; this implementation drops the level. - **Scope deliberately reduced after a spec-verification pass.** Crossing-SIBTEST is NOT built: - Chalmers (2018) shows Li and Stout's (1996) hypothesis test is insufficient, no normal-theory referral - for a crossing statistic is valid, and `logistic_dif`'s `S x G` interaction already covers crossing - DIF against a standard 1-df null. No A/B/C letter class, because published cut-offs disagree and none - was verified against its primary source — the same decision already taken for `delta_r2_uniform`. No - purified variant, because purification needs a practical-significance predicate that no verified - cut-off supports, and it would shorten the valid subtest and so lower the very reliability the - correction divides by. - **Guards.** Two CLOSED-FORM acceptance anchors, both derived in exact rational arithmetic and - re-derived independently before the tests were written, assert to 1e-12: a single-stratum fixture - (`beta = -1/10`, `sigma^2 = 23/1950`, `X^2 = 39/46`) and a five-level fixture pinning the weighting - (`beta = -16993/88000`). The second is mandatory because the first is structurally blind to every - weighting question — one retained stratum always carries weight 1 — and because the UNCORRECTED beta - is `+0.11` under both weighting schemes, so the same assertion on an uncorrected statistic would prove - nothing. Both were mutation-verified: substituting the observed mean for the true-score subtrahend - yields `+0.26` (a sign flip), caught by both anchors; focal-group weights yield `-0.039932`, caught - ONLY by the multi-level anchor. A third anchor pins a NON-CONTIGUOUS level vector to `1/128`, where - both the hardcoded `2*alpha/n_valid` denominator and arithmetic `k +/- 1` indexing return `1/64` — - every other fixture has contiguous levels, so without it both mutants survive the whole suite. - Further anchors pin the strict `j_min` inequality on BOTH sides of its conjunction, all four corners - of the empty-neighbour guard, the alpha gate on all four degenerate forms, per-group alpha against - the direct KR-20 definition on a fixture whose groups differ in reliability (a pooled alpha survives - any fixture where they do not), Benjamini-Hochberg in both directions plus `fdr_q` plumbing, and the - disjointness of the criterion. That last one asserts the exact invariant rather than a proxy: - complementing the studied item's column sends every conditional mean to `1 - Ybar` and every slope to - `-M`, so `beta_uni` is exactly NEGATED and `se_beta` exactly invariant, while at least one other item - must move — a criterion that ignored the data would be flip-invariant too. - -- **Iterative item purification for the observed-score DIF procedures** - (`fast_mlsirm.mantel_haenszel_dif_purified`, `logistic_dif_purified`; extends `mlsirm_core::dif`; - Candell & Drasgow, 1988; Clauser et al., 1993; Holland & Thayer, 1988; Lord, 1980). Both DIF - procedures added earlier in this release match examinees on the observed total score, which is - itself built from the items under test — so when DIF items push a group's total in a CONSISTENT - direction, the matching criterion is biased and clean items inherit spurious DIF (a false-positive - inflation documented at both entry points). Purification breaks that circularity by re-running the - sweep with the criterion rebuilt from the currently-unflagged ANCHOR set only: round 0 is the - ordinary all-items sweep, each later round drops the flagged items from the criterion, and the loop - stops when the flagged set comes back UNCHANGED from one round to the next (`converged = true`) or the - round cap is hit. That is a stability test, not general cycle detection: a flagged set that oscillates - between two states runs to the cap and is reported as `converged = false`, which is the honest answer - rather than a spurious fixed point. The studied item is always added back into its own matching score - even when it is not in the anchor, so every item is matched on `anchor UNION {studied}` — item-included - matching is what makes the null-DIF condition hold (Holland & Thayer, 1988; Zwick, 1990), and it also - makes round 0 identical to the unpurified sweep by construction: round 0 passes no anchor at all and so - dispatches to the very same code path rather than to an all-true mask that merely evaluates the same. - `PurifyConfig { max_rounds, min_anchor_items }` bounds the loop and refuses to purify below a usable - anchor length (default 4), returning the last valid round with `converged = false` rather than a - criterion built from a handful of items. Nothing is sized from the caller-supplied item count before - that first sweep, since dimension validation lives inside the sweep and the count is untrusted at the - FFI boundary. - **Interpretation limits, documented at the API.** Purification REDUCES contamination, it does not - remove it: the anchor is itself estimated, so the residual bias depends on how well round 0 separated - the bank. More importantly the returned p-values carry **no Benjamini-Hochberg or Type-I guarantee** — - the item set was selected using the same data, so the procedure is a screening device for flagging, - not a calibrated test; the reported statistics must not be quoted as if they came from a single - pre-registered sweep. Mantel-Haenszel purification also inherits MH's crossing-DIF blind spot and - cannot repair it — an item MH never flags stays in the anchor every round and keeps contaminating the - "purified" criterion. That blind spot is a property of the SIGNED AREA between the two curves over the - matched ability distribution (Wang & Su, 2004) rather than of non-uniform DIF as such: a crossing at - the centre of that distribution cancels and is invisible, while the same item with its crossing off - centre leaves a net difference MH detects, so MH purification is unreliable rather than uniformly blind - under non-uniform DIF. `logistic_dif_purified`, whose interaction term tests the crossing directly, is - the variant to use there. **Guards.** A contamination fixture plants unidirectional DIF and asserts, in order, the - PRECONDITION that the unpurified sweep really does false-flag clean items (so the test cannot pass on - a fixture with nothing to fix), that purification strictly reduces those false flags, that the true - positives are retained and are the items that left the anchor, and that the sweep statistics numerically - changed; a clean bank asserts round 0 reproduces the shipped sweep EXACTLY (`n_anchor == n_items`, - `rounds == 0`); and the cap and short-anchor exits are each pinned to report `converged = false` - instead of silently returning a degenerate criterion. An adversarial implementation review then found - those flag-counting fixtures could not see the arithmetic underneath them, and two structural anchors - were added, each mutation-verified to fail on the defect it targets. (i) The returned `rows` must equal - a fresh sweep against the returned `anchor`, swept over round caps, anchor floors and both matching - conventions (which also covers `exclude_studied_item = true`, previously untested under purification): - returning an earlier round's rows while reporting the final anchor is the highest-severity failure mode - of a purification loop and is invisible to a "did it flag the right items" test, because intermediate - rounds usually flag the same items. (ii) The purified row for an item must equal the ORDINARY sweep run - on a reduced test consisting of exactly `anchor UNION {studied}` — an independent reference rather than - the implementation's own arithmetic — checked for both a non-anchor item (the add-back branch) and an - anchor item, with a deliberately NON-CONTIGUOUS anchor so an index-map error cannot hide behind a - prefix. The anchor predicate is also pinned directly on all four ETS classes, since no simulated bank - distinguishes "B or C" from "not A" (a clean 2PL never produces `Undefined`). The same review caught a - key collision in the Python bindings: `logistic_dif_purified` wrote the loop's scalar convergence flag - over `logistic_dif`'s PER-ITEM `converged` array, destroying it at the boundary — the loop flag is now - `purify_converged` on both entry points — and found that both Python docstrings were written as - `"""..."""` + a module constant, which is a `BinOp` rather than a constant expression, so the compiler - never filled `__doc__` and the entire "not a calibrated test" caveat was invisible to `help()`. - -- **Zumbo logistic-regression DIF, with non-uniform detection** (`fast_mlsirm.logistic_dif`; extends - `mlsirm_core::dif`; Zumbo, 1999; Swaminathan & Rogers, 1990). Regresses each item response on the - observed matching score `S`, the group `G`, and their interaction in three NESTED logistic models - (`M0: b0 + b1 S`; `M1: + b2 G`; `M2: + b3 (S x G)`), fitted by IRLS/Newton. This closes the known blind - spot of the Mantel-Haenszel procedure added earlier in this release: a stratified odds-ratio test can - only see a *consistent* group advantage, so **crossing (non-uniform) DIF is invisible to it**, while - the interaction term detects it directly. The 2-df `chi2_total = 2[ll(M2) - ll(M0)]` is the primary - omnibus DIF decision (the value Benjamini-Hochberg adjusts); the 1-df components are descriptive - follow-ups, and the module documents that `chi2_uniform = 2[ll(M1) - ll(M0)]` tests `b2` *assuming* - `b3 = 0` — it is not the group term of the full model and is uninterpretable when non-uniform DIF is - present, so the hierarchical entry order `S -> G -> S x G` is load-bearing. The effect size is the - Nagelkerke (1991) pseudo-`R^2` change `delta_r2 = R2_N(M2) - R2_N(M0)` (with `ll_null` the - intercept-only fit, and all four models fitted on one identical subsample so the normalizer is - comparable), classified by Jodoin & Gierl (2001) — A `< 0.035`, B, C `>= 0.070` — and forced to A - whenever the omnibus test is not BH-significant. The uniform-only `delta_r2_uniform` is reported - without a letter class because those cut-offs were calibrated on the 2-df quantity; the more - conservative Zumbo & Thomas (1997) cut-offs are documented as an alternative. **Robustness.** The - matching score is mean-centered (the chi-squares are invariant, but the raw total leaves the `S x G` - Gram near-singular); the design is rank-checked; the Newton step uses the *checked* solver and - step-halving with a coefficient bound, so (quasi-)separation, a rank-deficient design, or - non-convergence yield `NaN` statistics with `converged = false` and are never BH-flagged (a constant - item is rejected outright). **Guards.** A SATURATED-DESIGN anchor (two-level score x binary group) - pins the IRLS, log-likelihood, omnibus chi-square and Nagelkerke effect size against closed-form - binomial arithmetic, plus the exact decomposition `chi2_uniform + chi2_nonuniform == chi2_total`; and - the discriminating anchor plants a crossing item whose ICCs intersect at the common group ability - mean, asserting the logistic test flags the interaction (with the uniform component non-significant) - on the very item Mantel-Haenszel classifies as negligible, while a plain b-shift item shows the - reverse pattern; the Jodoin-Gierl classifier is additionally pinned at its boundaries. Spec-verified - (GO-WITH-MUST-FIXES applied), and an adversarial implementation review then fixed four further root - causes: a NaN chi-square silently becoming `p = 1.0` in `chi2_sf` (which both misreported "no DIF" and - let unfittable items dilute the Benjamini-Hochberg denominator), a convergence backstop that certified - a bound-truncated separated fit as converged, a minimum-sample floor far too weak for the - four-parameter model, and the unpinned classifier. Convergence uses the standard GLM relative-deviance - test paired with a coefficient-bound separation check, since near the optimum the attainable score - floor exceeds any usable absolute gradient tolerance. Same matching-criterion contamination as - Mantel-Haenszel (see `logistic_dif_purified`), plus the logit-linearity-in-`S` assumption, both documented. - -- **Rasch conditional maximum likelihood + Andersen's LR test** (`fast_mlsirm.fit_rasch_cml`, - `andersen_lr_test`; new `mlsirm_core::rasch_cml`; Andersen, 1970, 1972, 1973). CML estimation of the - dichotomous Rasch item difficulties: conditioning each response pattern on its raw score (the - sufficient statistic for ability) ELIMINATES the person parameters, so the difficulties are estimated - without any assumption on the ability distribution (Rasch's specific objectivity) and consistently at - fixed test length — unlike the marginal-ML path (which must posit a `theta` distribution) or joint ML - (inconsistent). The conditional log-likelihood - `ln L_c = -sum_i s_i beta_i - sum_r n_r ln gamma_r(eps)` uses the elementary symmetric functions - `gamma_r`; the ESF and its per-item/per-pair derivatives are computed by the numerically stable - SUMMATION algorithm (a fresh forward pass `gamma_r += eps_j gamma_{r-1}` over the relevant item - subset), avoiding the cancellation-prone subtractive difference recursion (Verhelst, Glas & van der - Sluis, 1984). Newton on `beta` with sum-zero identification and a reduced-system solve; standard - errors from the pseudoinverse of the conditional information; persons scoring `0` or `k` are dropped. - Andersen's (1973) conditional likelihood-ratio test partitions the persons, fits CML within each group - and pooled, and refers `2[sum_g llc_g - llc_pooled]` to `chi^2((G-1)(k-1))`. **Guards.** The - summation-algorithm ESF (and its leave-one-out / leave-two-out passes) match brute-force subset sums; - a deterministic finite-difference anchor pins the CML gradient AND Hessian (catching the - `d eps/d beta = -eps` sign); the DEFINING person-distribution-free property is the primary anchor — the - same `beta_hat` is recovered whether `theta` is `N(0,1)` or strongly right-skewed (a value-recovery - test alone cannot separate CML from JML); and the Andersen LR does not over-reject Rasch data but - rejects a planted group-specific difficulty shift, with the `df` and upper tail pinned. Spec-verified - (GO-WITH-MUST-FIXES applied: the summation ESF over the difference recursion, dropping `r=0/k` persons, - sum-zero centering, the reduced-Hessian SE, and reuse of `solve_small`/`chi2_sf`). An adversarial - implementation review (faithfulness clean) then hardened two edge cases: `andersen_lr_test` surfaces a - `converged` flag so a stalled fit's clamped `lr = 0` is not misread as a clean non-rejection, and the - Python binding caps `n_groups` at 256 (u8 label range). Complete-data only; polytomous and missing-data - CML are deferred. Exposed to Python as `fit_rasch_cml` and `andersen_lr_test`. - -- **Warm's weighted likelihood estimation of ability** (`fast_mlsirm.score_wle`; - `mlsirm_core::scoring::score_wle`; Warm, 1989). The bias-reduced maximum-likelihood ability estimator - for unidimensional dichotomous items (2PL/3PL/4PL): it solves the weighted-likelihood estimating - equation `dlnL/dtheta + J(theta)/(2 I(theta)) = 0` with the Warm correction - `J = sum_i P_i' P_i''/(P_i Q_i)` computed DIRECTLY. Crucially `J` is *not* `I'(theta)/2` — the two - coincide only for the 2PL/Rasch (`c=0, d=1`), where the weight is `sqrt(I)` (the Jeffreys prior); for - the 3PL/4PL the second derivative carries `1-2s` while the information derivative carries `1-2P`, so a - `sqrt(I)`-weighted estimator applies the wrong correction. Warm's estimator removes the leading - `O(1/n)` MLE bias and — unlike the MLE, which is `+/-infinity` for the all-correct / all-incorrect - pattern — yields a FINITE estimate for every response pattern. The estimate is the GLOBAL maximizer of - the weighted log-likelihood (whose derivative is the estimating function), located by a grid scan plus - a local root refinement — robust to the 3PL/4PL case where the weighted likelihood is multimodal - (Samejima, 1973; Yen, Burket & Sykes, 1991) and a single bracketed root can select the wrong mode; - it is clamped and flagged when the finite root falls beyond `theta_bound`, and a person with no - observed items returns `NaN`. It reuses `item_information_4pl` for `I(theta)`; the SE is `1/sqrt(I)`. - **Guards.** An estimating-equation root anchor is verified by INDEPENDENT finite-difference - derivatives of `P` (so a `J` sign error in the analytic `P' P''` is not shared) across the 2PL, 3PL, - and Rasch; a 2PL finiteness anchor confirms the perfect/zero patterns give finite, interior estimates - with correct > incorrect; a monotonicity anchor confirms the estimate is nondecreasing in the - number-correct score; a global-mode anchor confirms the multimodal-3PL worst case returns the dominant - mode (`theta ~ -4.13`, ~10x more probable) rather than a minor root; an all-missing person returns - `NaN`; and a `#[ignore]` >=500-rep Monte-Carlo confirms Warm's headline result — the WLE aggregate - `|bias|` (~0.04) is an order of magnitude smaller than the boundary-clamped MLE's (~0.50), the gap - widening at extreme abilities. Spec-verified (GO-WITH-MUST-FIXES: `J`-not-`I'`, the `I~0` division - guard, plain natural-scale `a/b/c/d` rather than the log-alpha `ItemBank`); an adversarial - implementation review then caught and fixed two defects the initial tests missed — the 3PL/4PL - multimodality (a single bracketed bisection could return a non-dominant root; replaced by the - global-mode grid search) and an all-missing person silently returning `theta = 0` (now `NaN`). - Polytomous WLE (Penfield & Bergeron, 2005) is deferred. Exposed to Python as `score_wle` returning - `theta`/`se`/`boundary`. - -- **Mantel-Haenszel differential item functioning** (`fast_mlsirm.mantel_haenszel_dif`; new - `mlsirm_core::dif`; Holland & Thayer, 1988). The observed-score, calibration-free DIF procedure — the - complement to the parametric IRT-LR DIF (`dif_polytomous`): no item response model is fitted. - Examinees are matched on the number-correct total (thin matching, studied item **included** by - default per Donoghue, Holland & Thayer, 1993; `exclude_studied_item=True` uses the rest score), and - per item the common odds ratio `alpha_MH = (sum_m A_m D_m / T_m)/(sum_m B_m C_m / T_m)` and the - continuity-corrected MH chi-square `max(0, |sum A_m - sum E(A_m)| - 0.5)^2 / sum Var(A_m)` (with the - hypergeometric `Var(A_m) = n_Rm n_Fm m1_m m0_m / (T_m^2(T_m-1))`, referred to `chi^2(1)`) are computed - over the DIF-informative strata (all four `2 x 2` marginal totals positive). Reported on the **ETS - delta metric** `MH_D-DIF = -2.35 ln(alpha_MH)` (negative = harder for the focal group) with the - Robins-Breslow-Greenland (1986) standard error, the **ETS A/B/C** severity classification (Zieky, - 1993; A if not significant at .05 or `|D-DIF| < 1.0`, C if `|D-DIF| >= 1.5` and `|D-DIF| - 1.645 SE > - 1.0`, B otherwise — or `Undefined`/`"U"` when there are no informative strata or a degenerate odds - ratio, *not* the affirmative "A"), and the **standardized P-DIF** companion (Dorans & Kulick, 1986) - `sum_m n_Fm (P_Fm - P_Rm) / sum_m n_Fm` (focal minus reference, so its sign agrees with `MH_D-DIF`). - Benjamini-Hochberg controls the across-item FDR; the p-value reuses `fitstats::chi2_sf`. **Guards.** A - two-stratum hand-computed anchor pins `alpha_MH`, the continuity-corrected chi-square, `MH_D-DIF`, the - RBG SE, `STD-P-DIF`, and the C label; a no-DIF symmetry anchor returns `alpha_MH = 1`, zero delta, and - class A; a degenerate/perfect-separation case returns NaN statistics and `Undefined` (never A); and a - planted uniform-DIF simulation flags the DIF item (class B/C, correct delta sign) while classifying - the clean items A and agreeing with the parametric IRT-LR DIF on the flagged item. Because the MH - chi-square is over-powered at large N and the studied item mildly contaminates the matching total, the - A/B/C classification (not the raw significance) is the practical-significance guard — documented, with - item purification (since shipped as `mantel_haenszel_dif_purified`) and SIBTEST (Shealy & Stout, 1993) - noted as future work. Spec-verified - (GO-WITH-MUST-FIXES: STD-P-DIF sign, `Var_m > 0` stratum gate, degenerate-odds guards, zero-clamped - continuity numerator). - -- **Dimension-agnostic IRT model API.** Item families are named by their - response function rather than by UIRT/MIRT dimensionality: - `fit_2pl`/`TwoPlFit`, `fit_grm`/`GrmFit`, and - `fit_nominal`/`NominalResponseFit`. A single `model=` argument follows the - R `mirt` convention (Chalmers, 2012): `model=1` denotes the unrestricted - one-factor model, while `model=models.confirmatory(loading_pattern)` carries - a confirmatory loading structure and derives its dimension count. The fitted - result retains `n_dims` only as a derived read-only property of its model - specification. Numeric exploratory requests above one factor fail explicitly; - the Rust estimators do not yet implement unrestricted multidimensional loading - rotation/identification, so a confirmatory anchor pattern is never relabeled - as exploratory. The previous brand-new `*_mirt` entry points and module names - were removed rather than retained as misleading aliases. See - `python/fast_mlsirm/models.py` for the verified Chalmers (2012) APA reference - and DOI. - -- **Correlated latent factors for MH-RM** (`fit_mhrm(..., estimate_corr=True)`; Cai, 2010b confirmatory - item factor analysis). Completes the MH-RM to a free latent CORRELATION matrix `Phi` (unit diagonal, - `theta ~ MVN(0, Phi)`) rather than orthogonal factors. The Metropolis acceptance prior becomes - `-0.5 (theta*^T Phi^{-1} theta* - theta^T Phi^{-1} theta)` (the symmetric proposal cancels; `Phi^{-1}` - is recomputed by Cholesky each cycle), and the `D(D-1)/2` free off-diagonal correlations ascend the - Gaussian-prior objective `Q(Phi) = -0.5[log|Phi| + tr(Phi^{-1} C)]` (`C` the imputed second moment, - RAW/uncentered — `E[theta]=0` is fixed by identification) by a per-cycle Robbins-Monro GRADIENT step - `offdiag += gain_k * sigma_grad(Phi, C)`, kept positive-definite by BACKTRACKING (halve the step - until the rebuilt `Phi` is PD). This REUSES the `twopl.rs` correlation machinery verbatim - (`build_corr`, `sigma_grad`, `chol_lower`, `sym_inv_logdet`, `flip_corr_dim`, now `pub(crate)`), the - same helpers `fit_2pl`'s deterministic ECM correlation step uses — so the `Phi` estimation is shared, - not duplicated. The per-dimension reflection flips the correlation off-diagonals for the flipped - dimension (`corr(theta_d, theta_k) -> -corr`) together with the loading column and trait chain, so - the reported `Phi` is consistent with the canonicalized signs. `estimate_corr=False` (default) keeps - `Phi = I` and is BIT-IDENTICAL to the previous orthogonal fit (the acceptance prior branches to the - original per-dimension `||theta*||^2 - ||theta||^2` on the same RNG stream). It is a gradient-RM (not - Cai's Newton-preconditioned) covariance update — documented as such; it still converges almost surely - to the same `Phi` root, only the (un-curvature-adapted) rate differs. **Guards.** A recovery test - recovers an exchangeable `Phi` off-diagonal at a POSITIVE (`rho=0.4`), a near-PD-boundary - (`D=3, rho=0.5`), and a NEGATIVE (`rho=-0.5`) correlation within Monte-Carlo tolerance, confirming the - recovered matrix stays a valid PD correlation matrix; `estimate_corr=False` yields exactly the - identity; and a `#[ignore]` 500-rep Monte-Carlo at the near-boundary `D=3, rho=0.5` reports the - correlation RMSE/bias and would surface a persistent PD-backtracking stall. Exposed to Python as the - `estimate_corr` argument and the `corr` field of `MhrmFit`. - -- **Polytomous (GPCM) response family for MH-RM** (`fit_mhrm(..., family="gpcm", n_cat=K)`; Muraki, - 1992, generalized partial credit model estimated by the Cai, 2010 MH-RM). Extends the - stochastic-approximation confirmatory item factor analysis from binary items to ordered polytomous - items, scaling high-dimensional POLYTOMOUS IFA to a latent dimensionality where the deterministic - `fit_gpcm` (Gauss-Hermite / QMC EM) is infeasible. Each item keeps a SINGLE multidimensional - discrimination `a_i` (free on the confirmatory loading pattern) and gains `K-1` free UNORDERED step - intercepts: `base_i = sum_{d in S_i} a_id theta_d` (NO intercept), `P(Y=k) = softmax_k(k*base_i + - step_ik)` (`step_i0 = 0` pinned). The MH imputation likelihood is the inline log-softmax of the - observed category (no per-node allocation), and the per-item RM step uses the **closed-form - multinomial complete-data Hessian** `H = sum_p J_p^T (diag(P) - P P^T) J_p` (data-independent given - `theta`, where the design row `J_p[k]` is `d psi_k / d param`: `k*theta_pd` for slope `a_id`, `[k==j]` - for `step_j`) as BOTH the Robbins-Monro preconditioner AND the Louis positive term — NOT the BHHH - score cross-product (which is the term Louis subtracts, so `H_BHHH - sum s s^T = 0` would give a - degenerate SE). The complete-data score `sum_p J_p^T ([k==y_p] - P)` equals the deterministic - `gpcm.rs`'s `[g_base*theta_d; g_intercepts]` with the integer scores fixed (`g_scores` dropped — what - makes it GPCM, not nominal). The per-dimension reflection flips only the slope column and the trait - chain — the UNORDERED steps are left INVARIANT (`base = k*sum a_d theta_d` is invariant under the - joint `(a, theta)` sign flip), exactly as the deterministic `gpcm.rs`. `family="2pl"` (default) keeps - the binary path **BIT-IDENTICAL** (the closed-form `log_sigmoid` score and `sum w X X^T` information - are unchanged on the same RNG stream). GRM (Samejima cumulative-logit, ordered thresholds) is - DEFERRED: its thresholds must stay strictly decreasing, which the deterministic `grm.rs` maintains by - a backtracking line search a single stochastic RM Newton step cannot replicate (the standard path is a - softplus threshold-gap reparametrization — future work). An adversarial implementation review found - and fixed two defects the initial tests missed: the output/SE routing keyed on `n_free_cat == 1` as a - "is 2PL" proxy, which mis-collapsed a legal `Gpcm { n_cat = 2 }` fit's single step into the 2PL - `intercept`/`se_intercept` fields (now keyed on the model family); and the declared `MHRM_MAX_CAT` - category cap was never enforced (an unbounded `n_cat` allocation vector — now validated). **Guards.** A - deterministic finite-difference - anchor pins the GPCM score AND the exact-multinomial information against the complete-data GPCM - log-likelihood on an asymmetric cross-loader with a NEGATIVE loading and NON-MONOTONE steps (a sign - flip, a transposed/dropped design slot, an over-collapsed step block, or BHHH-as-information all fail - it), with an independent per-person score outer-product re-sum pinning the sign of the Louis - missing-information subtraction; a `D=1` reduction test agrees with `poly::fit_poly_unidim(Gpcm)` - (Bock-Aitkin quadrature) within Monte-Carlo tolerance; a `D=5` recovery (GH/QMC infeasible) recovers - loadings, steps, and the negative cross-loader with correct sign; a reflection-FIRES test witnesses - the canonicalization flipping a negative anchor while leaving the steps un-swept; the validation - rejects out-of-range responses and any never-observed category (an unidentified step); and a - `#[ignore]` 500-rep Monte-Carlo (normal + right-skew traits, `D=2` and `D=5`, `K=3`) reports the - loading/step RMSE and bias. Exposed to Python as the `family`/`n_cat` arguments and the - `step`/`se_step`/`n_cat` fields of `MhrmFit`. - -- **High-dimensional confirmatory 2PL by Metropolis-Hastings Robbins-Monro** (Cai, 2010). - `fit_mhrm(responses, model=...)` fits the general compensatory multidimensional 2PL - (`P(X_ij = 1 | theta_j) = sigmoid(sum_{d in S_i} a_id theta_jd + b_i)`, `theta ~ MVN(0, I_D)`) — the - same model as `fit_2pl` — by a STOCHASTIC-approximation EM that scales to a latent dimensionality - where the deterministic `q^D` Gauss-Hermite grid and the QMC E-step of `fit_2pl` are infeasible - (`n_dims` up to 64). Each cycle (1) IMPUTES each person's `theta` by a short PERSISTENT - (warm-started) symmetric random-walk Metropolis chain from its current posterior - `pi_j(theta) prop phi(theta; 0, I) prod_i P_i(y_ij | theta)` — the acceptance ratio is the pure - Metropolis posterior ratio (the symmetric proposal cancels), the proposal SD is auto-tuned toward a - target acceptance during burn-in, and the chain carries across cycles so no per-cycle burn-in is - needed; and (2) takes one Robbins-Monro stochastic-Newton step - `xi <- xi + gain_k Gamma_k^{-1} s_k` on the complete-data score `s_k` (Fisher's identity gives an - unbiased-in-the-limit Monte-Carlo estimate of the marginal score) and the RM-smoothed information - `Gamma_k = Gamma_{k-1} + gain_k (H_k - Gamma_{k-1})`. Because the item blocks are conditionally - independent given `theta`, the score, information, and RM step are BLOCK-DIAGONAL by item, and the - per-item work is the CLOSED-FORM logistic gradient `X'(y - P)` and information `X'WX` — no - quadrature, `D`-independent per-node cost (reusing `mmle::{log_sigmoid, sigmoid_stable}` and - `poly::solve_small`). The gain follows a constant-gain burn-in (a Metropolis-Hastings stochastic EM - that random-walks into the MLE neighbourhood) then a decreasing `gain_k = 1/(k - k0)^alpha` - (`sum gain = inf`, `sum gain^2 < inf`, Robbins & Monro 1951) that converges almost surely to a - marginal-score root. Convergence is WINDOWED (the running mean of `||xi^(k) - xi^(k-1)||` over the - last `w` cycles falls below `tol`) — MH-RM iterates are non-monotone by design, so no - likelihood-decrease guard is used. **Identification.** Unit trait variances fix the loading scale, - `E[theta] = 0` the intercepts, and a PURE single-dimension anchor item per dimension pins the - rotation; the per-dimension reflection `(a_i.d, theta_d) -> (-a_i.d, -theta_d)` is likelihood- - invariant, and because the stochastic iterates could otherwise drift between the two mirror modes - and corrupt the RM RUNNING AVERAGE of the loadings, the canonical sign (largest pure anchor - positive) is enforced IN-LOOP every cycle — flipping the loading column, the persistent `theta` - chain, and the averaged trait together — and once more at the end (mutation-verified: disabling the - flip makes the reflection-fires test fail on all three sign checks). Loadings are UNCONSTRAINED so - reverse-keyed / negative cross-loadings are representable. **Standard errors.** The Louis (1982) - identity `I_obs = E[-d^2 l_c] - Var[d l_c]` gives per-item observed-information SEs, accumulated by - a parallel RM filter (`sum_p (w_p - r_p^2) X_p X_p'`) over the convergence stage; where a - finite-sample Louis block is not positive-definite the block falls back to the complete-data - (Fisher) information (a conservative SE). **Guards.** A deterministic finite-difference anchor pins - the per-item score and information against numerical derivatives of the complete-data logistic - log-likelihood on an ASYMMETRIC D=2 cross-loader with a negative loading (catching sign, layout, and - dims-map bugs a centered value-recovery test would not); the D=1 fit agrees with the established - deterministic unidimensional MMLE (`mmle::fit_mmle_2pl`) within Monte-Carlo tolerance; a **D=6** - recovery (3 pure anchors per dimension + a negative cross-loader, GH/QMC infeasible) recovers the - loadings and per-dimension traits; the reflection-fires test drives a weak reverse-keyed pure anchor - against a strong positive cross-loader so raw MH-RM lands the anchor negative and canonicalization - must fire; and `validate` rejects rotationally-degenerate patterns, non-binary responses, and - `burn_in >= max_cycles`. This first release fits the ORTHOGONAL 2PL (`Sigma = I`); a free latent - correlation matrix and the polytomous item families are natural extensions of the same loop. Compute - lives in `mlsirm_core::mhrm::fit_mhrm`; exposed to Python as `fit_mhrm` / `MhrmFit` via the - `model=` specification API. - -- **Confirmatory MULTIDIMENSIONAL generalized partial credit model** (Muraki, 1992). - `fit_gpcm(responses, n_cat, model=...)` fits ORDERED polytomous categories with a SINGLE - multidimensional discrimination vector per item and INTEGER category scores, completing the - polytomous-MIRT trio (`fit_nominal` / `fit_grm` / `fit_gpcm`). Item `i` has a free slope `a_i` (free - on the confirmatory 0/1 loading pattern from `model=models.confirmatory(...)`, items x D) and - `n_cat-1` category step intercepts `gamma_i`, with `psi_k = k * (sum_{d in S_i} a_id theta_d) + - gamma_i,k`, `gamma_i,0 = 0` pinned, and `P(Y_i = k | theta) = softmax_k(psi_k)`, `theta ~ MVN(0, - I_D)`. This is the `a_ikd = k a_id` INTEGER-scoring restriction of the multidimensional nominal - model in a distinct single-slope parametrization — NOT a mode of `fit_nominal` (which optimizes free - per-category slopes), so it warrants its own estimator; and it is the ADJACENT-category-logit - counterpart of the cumulative `fit_grm`. Unlike the GRM's thresholds, the GPCM steps are UNORDERED - (the softmax is finite for any real `gamma`, so no ordering constraint exists or is imposed). It - reduces to the unidimensional GPCM (`poly::fit_poly_unidim(PolyModel::Gpcm)`) at `D = 1` (within - optimizer tolerance and up to reflection — NOT bit-exact, because `fit_poly_unidim` forces `a > 0` - via a `log a` parametrization while the confirmatory model uses an UNCONSTRAINED slope so - reverse-keyed / negative cross-loadings are representable). Estimated by Bock-Aitkin marginal MLE - over the D-dim latent grid, REUSING the compensatory-MIRT node machinery (`nodes::build_xi_nodes`): - `node_rule = "gh"` uses the `q^D` Gauss-Hermite grid (`D <= 3`), `"qmc"`/`"mc"` use `xi_points` - Halton / Monte-Carlo draws (`D <= 6`, Jank 2005 QMC-EM), and the GPCM softmax cell of - `poly::gpcm_logprobs` / `gpcm_node_gradient`. The per-item M-step is a finite-difference-Hessian - Newton over `[a_{d0}..a_{d,L-1}, gamma_1..gamma_{M-1}]`, byte-for-byte the ascent of - `poly::m_step_item` (ridge = Hessian conditioning only, not a prior), with the GPCM node gradient - chained to the multidimensional slope (`d/da_id = sum_node g_base theta_d`, `d/dgamma_j = sum_node - g_intercepts[j]`). Category scores are FIXED integers `0..n_cat-1` (that fixity is what makes the - model GPCM rather than nominal), so the free per-category slope gradient returned by the shared cell - (`g_scores`) is DROPPED — only the single `base` slope and the step intercepts are estimated. Init is - `gamma_k = ln(freq_k / freq_0)` (a plain marginal log-odds, NOT a cumulative GRM-style boundary). EM - uses the SIGNED monotonic-decrease stopping guard (a likelihood decrease errors, not the - compensatory MIRT's `.abs()` check). **Identification.** Unit trait variances + a PURE - single-dimension anchor item per dimension pin the rotation to the coordinate axes; the per-dimension - reflection `(a_i.d, theta_d) -> (-a_i.d, -theta_d)` leaves `base` — hence every step and category - probability — INVARIANT, so it is CANONICALIZED (as for the GRM / compensatory MIRT, and unlike the - nominal, whose per-category slopes make the anchor sign ambiguous): dimension `d` is flipped so its - largest-magnitude pure anchor loads positively, negating that dimension's slope column AND the trait - `theta_d` but NOT the steps. `validate` rejects a rotationally-degenerate pattern (no pure anchor), - an out-of-range category, and ANY unobserved category for an item, with a `nodes x items x n_cat` - count-table cap and the rule-dependent D / q / xi_points bounds. **Guards.** The D=1 anchor recovers - `fit_poly_unidim(Gpcm)`'s slope and steps within tolerance; a deterministic finite-difference anchor - pins every per-(dimension, step) gradient slot on a fixed node set at D=2 (GH) AND D=4 (Halton) with - a NON-IDENTITY dims map, M>=4 categories, deliberately NON-MONOTONE step values (unordered steps have - no ordering canary, so the anchor exercises the free-step estimator directly) and distinct random - per-category counts; because that FD anchor is map-invariant, a SEPARATE deterministic - objective-value assertion at D=4 (dims `[0,2,3]`) pins the node-column dims map by computing - `base = sum_t a_t node[dim_t]` and the GPCM log-probabilities BY HAND with LITERAL integer scores and - matching the estimator's internal value to `< 1e-9` (the QMC path is never exercised by the D<=3 - recovery / MC); a reflection-FIRES test is constructed so the RAW EM mode lands the pure anchor - NEGATIVE (a WEAK reverse-keyed pure anchor plus a STRONG positively-keyed cross-loader that dominates - the dim0 orientation), so canonicalization MUST fire — asserting the anchor ends positive, the - co-loader ends negative, the trait axis is sign-flipped (theta correlates negatively with the truth - on the reflected dimension), and the steps are unchanged; mutation-verified (disabling the flip fails - all three sign checks). A D=2 recovery carries a genuinely NEGATIVE cross-loader on a - positively-anchored dimension (asserted `< -margin`) and recovers the unordered steps by RMSE. A - Monte-Carlo (`D in {2, 3}`, pure anchors + sign-varied cross-loaders, `n_cat = 4`, GH `q = 15/11`, - `N = 2500/2000`) recovers the loadings near-unbiased under a normal trait (loading RMSE ~0.08-0.09, - bias ~0.00-0.01; step RMSE ~0.06-0.07) with the expected mild attenuation under a - per-dimension-standardized right-skew trait (loading RMSE ~0.10-0.11, bias ~-0.04; step RMSE ~0.14), - per-dimension trait EAP correlation ~0.74-0.77 and 100% convergence, EM monotone every replication - (40-replication pilot; the committed `#[ignore]` test runs 500). Compute lives in - `mlsirm_core::gpcm::fit_gpcm`; exposed to Python as `fit_gpcm` / `GpcmFit`. - -- **Confirmatory MULTIDIMENSIONAL graded response model** (Samejima, 1969; Muraki & Carlson, 1995). - `fit_grm(responses, n_cat, model=...)` fits ORDERED polytomous categories with a SINGLE - multidimensional discrimination vector per item and ordered category boundaries: item `i` has a - free slope `a_i` (free on the confirmatory 0/1 `loading_pattern`, items x D) and `n_cat-1` ORDERED - boundary intercepts `beta_i`, with `P(Y_i >= k | theta) = sigmoid(sum_{d in S_i} a_id theta_d + - beta_i,{k-1})`, `theta ~ MVN(0, I_D)`. This is the ORDERED counterpart of the multidimensional - nominal model and the polytomous generalization of the compensatory MIRT; it reduces to the - unidimensional GRM (`poly::fit_poly_unidim(PolyModel::Grm)`) at `D = 1` (within optimizer tolerance - and up to reflection — NOT bit-exact, because `fit_poly_unidim` forces `a > 0` via a `log a` - parametrization while the confirmatory model uses an UNCONSTRAINED slope so reverse-keyed / negative - cross-loadings are representable). Estimated by Bock-Aitkin marginal MLE over the D-dim latent grid, - REUSING the compensatory-MIRT node machinery (`nodes::build_xi_nodes`): `node_rule = "gh"` uses the - `q^D` Gauss-Hermite grid (`D <= 3`), `"qmc"`/`"mc"` use `xi_points` Halton / Monte-Carlo draws - (`D <= 6`, Jank 2005 QMC-EM), and the GRM cumulative-logit cell of `poly::grm_logprobs` / - `grm_node_gradient`. The per-item M-step is a finite-difference-Hessian Newton over - `[a_{d0}..a_{d,L-1}, beta_1..beta_{M-1}]`, byte-for-byte the ascent of `poly::m_step_item` (ridge = - Hessian conditioning only, not a prior), with the GRM node gradient chained to the multidimensional - slope (`d/da_id = sum_node g_base theta_d`, `d/dbeta_j = sum_node g_thr[j]`). The ORDERED-threshold - constraint is maintained WITHOUT an explicit reparametrization: every adjacent boundary pair is a - middle category whose log-probability goes non-finite the instant the pair inverts (`0*NaN=NaN` so a - zero expected count cannot mask it), so the backtracking line search — which rejects any non-finite - step — keeps `beta` fully ordered by adjacency + transitivity. EM uses the SIGNED - monotonic-decrease stopping guard (a likelihood decrease errors, not the compensatory MIRT's - `.abs()` check). **Identification.** Unit trait variances + ordered thresholds + a PURE - single-dimension anchor item per dimension pin the rotation to the coordinate axes; the - per-dimension reflection `(a_i.d, theta_d) -> (-a_i.d, -theta_d)` leaves `base` — hence every - threshold and category probability — INVARIANT, so it is CANONICALIZED (unlike the nominal, whose - per-category slopes make the anchor sign ambiguous): dimension `d` is flipped so its - largest-magnitude pure anchor loads positively, negating that dimension's slopes AND the trait - `theta_d` but NOT the thresholds. `validate` rejects a rotationally-degenerate pattern (no pure - anchor), an out-of-range category, and ANY unobserved category for an item (a GRM boundary would - diverge), with a `nodes x items x n_cat` count-table cap and the rule-dependent D / q / xi_points - bounds. **Guards.** The D=1 anchor recovers `fit_poly_unidim(Grm)`'s slope and thresholds within - tolerance (all-positive DGP, the domain where its `log a` is correctly specified); a deterministic - finite-difference anchor pins every per-(dimension, threshold) gradient slot on a fixed node set at - D=2 (GH) AND D=4 (Halton) with a NON-IDENTITY dims map, M>=4 categories, STRICTLY-DECREASING - thresholds (gaps >> the FD step, since the GRM cell NaNs on an inverted boundary) and distinct - random per-category counts; because that FD anchor is map-invariant, a SEPARATE deterministic - objective-value assertion at D=4 (dims `[0,2,3]`) pins the node-column dims map by computing - `base = sum_t a_t node[dim_t]` and the GRM log-probabilities BY HAND and matching the estimator's - internal value to `< 1e-9` (the QMC path is never exercised by the D<=3 recovery / MC); a - reflection-FIRES test drives a reverse-keyed largest pure anchor and asserts it ends positive, a - co-loader ends negative, and the thresholds are unchanged and still ordered; a D=2 recovery carries - a genuinely NEGATIVE cross-loader on a positively-anchored dimension (asserted `< -margin`) with - strictly-ordered recovered thresholds. A Monte-Carlo (`D in {2, 3}`, pure anchors + sign-varied - cross-loaders, `n_cat = 3`, GH `q = 15/11`, `N = 2500/2000`) recovers the loadings near-unbiased - under a normal trait (loading RMSE ~0.10, bias ~0.00-0.01; threshold RMSE ~0.05-0.06) with the - expected mild attenuation under a per-dimension-standardized right-skew trait (RMSE ~0.17/0.18, - bias ~-0.12/-0.13), per-dimension trait EAP correlation ~0.63-0.70 and 100% convergence, EM - monotone and thresholds ordered every replication (40-replication pilot; the committed `#[ignore]` - test runs 500). Compute lives in `mlsirm_core::grm::fit_grm`; exposed to Python as - `fit_grm` / `GrmFit`. -- **Confirmatory MULTIDIMENSIONAL nominal response model** (Bock, 1972; Thissen, Cai, & Bock, - 2010). `fit_nominal(responses, n_cat, model=...)` fits unordered polytomous categories - with CATEGORY-SPECIFIC multidimensional discrimination: category `k` of item `i` has a free slope - vector `a_ik` (free on the confirmatory 0/1 `loading_pattern`, items x D) and intercept `c_ik`, - and `P(Y_i = k | theta) = softmax_k(sum_{d in S_i} a_ikd theta_d + c_ik)` with the baseline - category `0` pinned `a_i0 = 0, c_i0 = 0`, `theta ~ MVN(0, I_D)`. This generalizes the - unidimensional `poly::fit_nominal` to D latent dimensions, and reduces to it EXACTLY at `D = 1` - (the same general free-`a_k` parametrization). Estimated by Bock-Aitkin marginal MLE (EM) over the - D-dimensional latent grid, REUSING the compensatory-MIRT integration machinery: `node_rule = "gh"` - uses the `q^D` Gauss-Hermite product grid (`D <= 3`); `"qmc"`/`"mc"` use `xi_points` Halton / - Monte-Carlo draws (`D <= 6`), the quasi-Monte-Carlo EM of Jank (2005). The per-item M-step is a - Newton on the concave multinomial-logit complete-data objective, byte-for-byte the - finite-difference-Hessian ascent of `poly::nominal_m_step` (the ridge is Hessian conditioning only, - NOT a parameter prior, so the fit is genuine MML and the D=1 reduction is bit-exact), generalized - so the softmax residual `resid_k = r_k - n P_k` drives `d/dc_ik = sum_node resid_k` and - `d/da_ikd = sum_node resid_k theta_d`. EM uses `fit_nominal`'s relative-tolerance stopping with a - SIGNED monotonic-decrease guard (a likelihood decrease errors, rather than the compensatory MIRT's - `.abs()` check which would accept one as convergence). **Identification.** Baseline category + - unit trait variances + a PURE single-dimension anchor item per dimension pin the rotation to the - coordinate axes: a pure anchor forces every one of its category slopes onto the axis, so an - orthogonal trait rotation must send that axis to `+-e_d`, and the confirmatory labels forbid axis - permutation — leaving only a per-dimension reflection `(a_i.d, theta_d) -> (-a_i.d, -theta_d)`, - which (as in `fit_nominal`) is NOT canonicalized; recovery is assessed up to it. `validate` rejects - a rotationally-degenerate pattern (no pure anchor), an out-of-range category, and — a guard - `fit_nominal` lacks — ANY unobserved category for an item (its intercept would diverge and its D - slopes be unidentified), plus a `nodes x items x n_cat` count-table cap and the rule-dependent - D / q / xi_points bounds. **Guards.** The D=1 anchor reproduces `fit_nominal`'s scores/intercepts - and whole loglik trace bit-exactly (< 1e-9); a deterministic finite-difference anchor pins EVERY - per-(category, dimension) gradient component on a fixed node set at D=2 (GH) AND D=4 (Halton) with - a NON-IDENTITY dims map and distinct random per-category counts (catching a category<->dimension - transposition the D=1 reduction cannot see); a D=2 recovery carries a genuinely NEGATIVE - cross-loader slope AND two OPPOSITE-sign sibling categories on the same dimension (catching a - collapse of the free per-category slopes to a shared scalar discrimination); and baseline / - off-pattern entries are asserted EXACTLY `0.0` with a free-parameter-count invariant. A - Monte-Carlo (`D in {2, 3}`, pure anchors + sign-varied cross-loaders, `n_cat = 3`, GH - `q = 15/11`, `N = 2500/2000`, assessed up to per-dimension reflection) recovers the category - slopes near-unbiased under a normal trait (slope RMSE ~0.12 at `D = 2` / ~0.13 at `D = 3`, bias - ~0.00-0.01) with the expected mild attenuation under a per-dimension-standardized right-skew trait - (RMSE ~0.21/0.22, bias ~-0.09), per-dimension trait EAP correlation ~0.61-0.67 and 100% - convergence, EM monotone every replication (the figures are a 40-replication pilot; the committed - `#[ignore]` test runs 500). Compute lives in - `mlsirm_core::nominal::fit_nominal`; exposed to Python as `fit_nominal` / - `NominalResponseFit`. - -- **Confirmatory compensatory multidimensional 2PL (MIRT), orthogonal or correlated** - (Reckase, 2009; Bock, Gibbons, & Muraki, 1988). - `fit_2pl(responses, model=...)` fits - a general COMPENSATORY multidimensional 2PL in which an item may load FREELY on several - latent dimensions, which trade off ADDITIVELY inside a single logit: - `P(X_ij=1 | theta_j) = sigmoid(sum_{d in S_i} a_id theta_jd + b_i)`, `theta_j ~ MVN(0, I_D)`, - where `S_i` is item `i`'s loading set from a 0/1 confirmatory pattern (items x dimensions). - This is Reckase's compensatory M2PL / the full-information item factor model, distinct from - the existing simple-structure `Mirt` (one dimension per item) and the orthogonal bifactor - (one primary + one general per item): arbitrary within-item cross-loadings break the - simple-structure quadrature factorization, so it is a dedicated estimator (standalone - `mlsirm_core::twopl`) with the full `q^D` product Gauss-Hermite grid (`D <= 3`). Estimated by - marginal-ML EM: the E-step is streamed per person (no `N x q^D` posterior materialized), and - each item M-step is an `(n_i + 1)`-dimensional Newton generalizing `fit_mmle_2pl`'s 2x2 — the - ridged, positive-definite `-Hessian` block solved by Gaussian elimination with a backtracking - line search that keeps the marginal loglik monotone. Loadings are **not** constrained - non-negative (reverse-keyed and suppressor cross-loadings are representable); the - per-dimension sign is fixed by a reflection anchor. **Latent traits:** `theta ~ MVN(0, - Sigma)` — orthogonal (`Sigma = I`) by default, or with `estimate_corr = true` the - inter-factor **correlation matrix is estimated**: the standard grid is mapped through - `chol(Sigma)` (`theta_g = L z_g`, a measure-preserving change of variables that reuses the - product-GH weights and the item M-step verbatim), and the `D(D-1)/2` free correlations ascend - the Gaussian-prior objective `-0.5[log|Sigma| + tr(Sigma^{-1} C)]` (`C` the posterior second - moment, accumulated via the per-node marginal mass so it adds nothing to the E-step order) - with backtracking + a full-matrix positive-definite guard, keeping EM monotone; the reflection - anchor also negates the flipped dimension's correlation off-diagonals. A deterministic - finite-difference anchor pins the correlation gradient (`D=2` and `D=3`); a known-`Sigma` - (`rho=0.5`) recovery with a reflection-triggering negative anchor confirms the sign flip; and - a 500-rep MC recovers the correlations essentially UNBIASED against the realized sample - correlation (correlation RMSE ~0.035-0.05, bias ~0.0005 under the normal model / ~0.017 under - the NORTA right-skew arm), 100% convergence with every fitted `Sigma` strictly interior. - `D > 3` (coarser GH or QMC) remains deferred. Identification is enforced by - `validate`: every dimension must have a PURE single-loading anchor item, so - rotationally-degenerate patterns (e.g. all-ones) are rejected rather than returning a point - on a non-identified ridge. Verified with the N(0,I) grid-moment identities, a DETERMINISTIC - finite-difference anchor pinning the full item gradient AND the off-diagonal cross-Hessian - (the local->pattern-dimension map) to `< 1e-4`, an exact reduction to `fit_mmle_2pl` at `D=1` - (`gh_rule(41)` is the same grid; loadings/intercepts agree to `< 1e-2`), and a non-trivial - `D=2` recovery with asymmetric loadings INCLUDING genuinely negative ones (recovered with - correct sign). A 500-replication Monte-Carlo (`D in {2,3}`, `N = 3000/2000`, confirmatory - pattern with pure anchors + cross-loaders) recovers the loadings essentially UNBIASED under - the correctly-specified normal trait (loading RMSE ~0.10 at `D=2` / ~0.12 at `D=3`, bias - ~0.006) and shows the expected mild loading attenuation under a per-dimension-standardized - right-skew trait (shape misspecification; RMSE ~0.12/0.16, bias ~-0.06/-0.10), with - per-dimension trait EAP correlation ~0.67-0.72 and 100% convergence, EM monotone every - replication. Exposed to Python as `fit_2pl` / `TwoPlFit`. -- **`D > 3` confirmatory compensatory MIRT via quasi-Monte-Carlo EM** (Jank, 2005). The - compensatory MIRT above was capped at `D <= 3` by its `q^D` Gauss-Hermite product grid; - `fit_2pl` now takes a `node_rule` (`"gh"` default, or `"qmc"`/`"mc"`) that swaps - the E-step integration nodes for a **Halton quasi-Monte-Carlo** (or seeded Monte-Carlo) rule, - reaching `D = 4, 5, 6` (the Halton prime axes). This is Jank's (2005) QMC-EM: the E-step integral - `int p(x|theta) phi(theta) dtheta` is evaluated at `xi_points` points drawn from the prior - (Halton radical inverse mapped through the inverse-normal CDF, equal weights `1/xi_points`) - instead of the product grid, and the node set is built ONCE before the EM loop, so the per-item - `(n_i+1)`-dim Newton M-step and the correlated-`Sigma` ECM step are byte-for-byte the same code on - the swapped nodes. The reused node generator (`mlsirm_core::nodes::build_xi_nodes`, shared with the - marginal QMC-EM family) is parity-tested; its Gauss-Hermite arm is bit-identical to the existing - product grid, so the `"gh"` path is unchanged bit-for-bit and every prior MIRT test passes verbatim. - Both the orthogonal and the correlated-`Sigma` (Cholesky node-map `theta_g = L z_g`) paths carry - over to `D > 3`. **Monotonicity.** With `Sigma = I` the nodes never move, so the orthogonal fit is - monotone in the QMC-approximated marginal likelihood; the correlated `Sigma` M-step reparametrizes - the node cloud, so that fit is monotone only up to the QMC quadrature error (overall ascent with - per-step wobble ~1e-5 relative that shrinks as `xi_points` grows) — use the orthogonal path or a - larger `xi_points` when strict monotonicity matters. Validation is rule-dependent: `"gh"` keeps - `D <= 3` and the `q^D <= 200_000` node cap; `"qmc"`/`"mc"` cap `D <= 6` (the Monte-Carlo node - builder has no internal cap, so this bound is its sole guard) and bound `xi_points` - (`1..=200_000`, with checked `xi_points * n_items` and `xi_points * n_dims` allocations); `q` - applies only to `"gh"`, `xi_points`/`xi_seed` only to `"qmc"`/`"mc"`. **Guards.** Beyond the - reused-grid regression, a deterministic layout pin asserts `build_xi_nodes(Halton).grid[j*D+k] == - inv_normal_cdf(radical_inverse(j+1, prime_k))` at `D = 4` (independently fixing the prime-to-axis - assignment, the index skip, and the row-major layout that a value-recovery test cannot see); the - QMC weights are pinned to `-ln(n)` (invisible to every fit-level test since they cancel in the - self-normalized posterior); a deterministic finite-difference anchor pins the analytic gradient and - full cross-Hessian on a FIXED Halton grid at `D = 4` with a non-identity `dims` map; the reduction - anchor is TWO-SIDED (a `D = 2` QMC fit agrees with the GH fit within QMC error AND differs - bit-wise, so a silent GH fallback is caught); and the reflection anchor is exercised with a - reverse-keyed largest anchor. **Accuracy.** A Monte-Carlo (`D in {4, 5}`, confirmatory pattern with - pure anchors + alternating-sign cross-loaders, Halton `xi_points = 4000/6000`, `N = 2000/1500`) - under a correctly-specified normal trait recovers the loadings near-unbiased (loading RMSE ~0.13 at - `D = 4` / ~0.17 at `D = 5`, bias ~0.01) and shows the expected mild attenuation under a - per-dimension-standardized right-skew trait (shape misspecification; RMSE ~0.16/0.21, bias - ~-0.07/-0.09), with per-dimension trait EAP correlation ~0.58-0.64 and 100% convergence, EM - monotone every replication (the reported figures are a 50-replication pilot; the committed - `#[ignore]` test runs 500). QMC carries an `O(N^{-1} (log N)^D)` finite-node bias that grows with - `D` (the higher-prime Halton axes degrade), so `D = 5, 6` and the correlated `Sigma` off-diagonals - need materially larger `xi_points`; `xi_seed` (nonzero by default) applies a Cranley-Patterson - shift that partly de-correlates the higher axes. Exposed to Python as `fit_2pl(..., - node_rule=, xi_points=, xi_seed=)`. -- **Shared-Q sequential G-DINA for polytomous responses** (Ma & de la Torre, 2016; - Tutz, 1990). `fit_seq_gdina(responses, q_matrix)` fits ordered polytomous cognitive - diagnosis by the sequential (continuation-ratio) model: each ordered *step* - `k in 1..=M_i` of item `i` has a continuation probability `s_ik(l) = P(X_i >= k | X_i - >= k-1, reduced class l)` that is a saturated G-DINA over the item's `2^{K_i}` reduced - attribute classes, and the category probabilities are the sequential decomposition - `P(X_i = k | l) = (prod_{v<=k} s_iv(l))(1 - s_{i,k+1}(l))` with the stop sentinel - `s_{i,M_i+1} = 0` (top category has no trailing factor — never eps-clamped, so its - probability carries no spurious bias). Because the sequential likelihood factorizes - into independent per-step Bernoullis on the at-risk set, the M-step is the closed-form - saturated ratio `s_ik(l) = R_ik(l)/I_ik(l)` with `R` = expected count reaching category - `>= k` and `I` = expected count reaching `>= k-1` — exactly `fit_gdina`'s saturated step - on continuation counts. The population is a free profile distribution `pi_c`; `M_i` is - derived as each item's maximum observed category (an item stuck at category 0 is - rejected; a zero-frequency *interior* category is fine — it just means `s_{i,k+1} ~ 1`). - With one step per item (binary data) it reduces to `fit_gdina` **bit-for-bit** (shared - monotone init, identical E-step logprobs and closed-form ratio; a regression test - asserts the whole loglik trace and step probs agree to `< 1e-12`). Deterministic anchors - pin the sequential core with no Monte-Carlo noise: the category-probability identity - (`P(0)=1-a, P(1)=a(1-b), P(2)=a*b`, non-centered) and the at-risk-count identity - (responses `{0,1,1,2}` -> `s_1 = 3/4, s_2 = 1/3`, exercising the `{>=k}/{>=k-1}` - denominator). A 500-replication Monte-Carlo (K=3, mix of M=2/M=3 items, N=2500, under - BOTH a normal and a right-skew higher-order attribute distribution) recovers the model - with category-probability RMSE ~0.020, at-risk-mass-weighted step RMSE ~0.020, and - attribute-classification agreement ~0.97 — essentially identical across the normal and - skew conditions, because the free `pi_c` nests the higher-order-implied distribution - (no prior misspecification). **Scope:** this is the *shared item-level Q-vector* - sequential G-DINA — every step of an item is a saturated G-DINA over the SAME required - attributes; it is a restriction of Ma & de la Torre's general per-step (`q_ik`) model, - whose step-distinct attribute requirements are a deferred non-goal (supply each item's - Q-vector as the union of its steps' attributes). Compute lives in - `mlsirm_core::cdm::fit_seq_gdina` (reuses `reduce_class`, the profile-grid posterior, - and the saturated closed-form ratio); exposed to Python as `fit_seq_gdina` with the - `SeqGdinaFit` wrapper (`item_step_prob` / `item_cat_prob` ragged accessors). -- **Per-step-Q sequential G-DINA — the full restricted-Q model** (Ma & de la Torre, - 2016). `fit_seq_gdina_qr(responses, step_q, n_steps)` lifts the restriction above: each - ordered *step* `k` of item `i` carries its OWN attribute requirement `q_ik` (the paper's - headline generality — step 1 may need attribute A, step 2 need A AND B), supplied as a - row-major `(sum_i M_i) x K` restricted Q-matrix `Q_r`. The sequential factorization is - unchanged, so each step is still an independent saturated Bernoulli on its at-risk set - and the closed-form ratio `s = R/I` is still the exact complete-data MLE — but now each - step's success is a saturated G-DINA over ITS OWN `2^{|q_ik|}` reduced classes. **Storage - is union-class-indexed and lossless:** response probabilities depend only on the item's - UNION `u_i = OR_k q_ik`, so the E-step posterior and the category probabilities are - indexed by the `2^{|u_i|}` union reduced class (no `N x 2^K` materialization), while each - step's own reduced class is computed DIRECTLY from the full profile `c` via - `reduce_class(c, q_ik)` — never a union-mask AND, which would silently mis-gather the - renumbered set bits. Step probabilities are stored step-row-major (`spo` over `sum_i M_i` - rows, width `2^{|q_ik|}` each; `step_off[i]` per item, `step_kq[g] = |q_ik|`), category - probabilities item-major over the union class. **Reduction guard:** giving every step of - an item the item's Q reproduces `fit_seq_gdina` BIT-EXACTLY (layout-aware cell compare of - the transposed step tables plus direct compare of the class-major category probs and the - whole loglik trace — difference exactly `0`). A structural anchor (step 1 `q={A}`, step 2 - `q={A,B}`) asserts the per-step block widths are `2` and `4` (not one collapsed union - block) and `n_parameters` reflects the per-step widths — a discrimination value recovery - alone cannot make, since an over-collapse to the union would still fit — while recovering - a large step-2 B-contrast (`s_2(A1,B0)=0.20` vs `s_2(A1,B1)=0.80`, gap >= 0.4) that the - shared-Q model cannot represent. `validate` rejects an all-zero step row (a step measuring - nothing), an attribute required by no step (all-zero union column), and `n_steps[i]` not - equal to both the declared step count and the maximum observed category, with checked - `(sum_i M_i) * K` and `2^{|u_i|}` allocations and the same `K` cap. A 500-replication - Monte-Carlo (K=3, step-distinct M=2/M=3 items plus single-attribute M=1 identification - items pinning each dimension, N, under BOTH a normal and a right-skew higher-order - attribute distribution) recovers the model with at-risk-mass-weighted step-probability - RMSE ~0.017, category-probability RMSE ~0.018, and attribute-classification agreement - ~0.972 — essentially identical across the normal and skew conditions (the free `pi_c` - nests the higher-order-implied distribution), 100% convergence with every replication - finite and on the simplex. Compute lives in `mlsirm_core::cdm::fit_seq_gdina_qr`; exposed - to Python as `fit_seq_gdina_qr` with the `SeqGdinaQrFit` wrapper (`item_step_prob` ragged - accessor over the per-step layout). The shared-Q `fit_seq_gdina` is retained as the - convenience special case. -- **Higher-order G-DINA** (de la Torre & Douglas, 2004; de la Torre, 2011). - `fit_ho_gdina(responses, q_matrix)` fits the saturated G-DINA item model (each - item's reduced attribute-mastery classes get a free success probability) under a - *higher-order structural attribute prior*: a continuous trait `theta ~ N(0,1)` - drives mastery, `P(alpha_k=1 | theta) = sigmoid(a_k theta + d_k)`, with attributes - conditionally independent given the trait. It generalizes `fit_ho_cdm` (which - restricts the item model to DINA slip/guess) and constrains `fit_gdina`'s free - class distribution to the `2K`-parameter structured family. Estimated by - marginal-ML EM over the joint `(alpha, theta)` grid: because the item response is - conditionally independent of the trait given the attributes, the saturated item - M-step `p_il = R_il/I_il` marginalizes the trait out exactly (reusing `fit_gdina`'s - closed form), and the structural step is `K` independent 2PL calibrations of - attribute mastery on the trait (reusing `fit_ho_cdm`'s Newton). The higher-order - parameters are identified for `K >= 3`. Validated by a non-trivial anchor (a free - saturated fit of DINA-patterned data recovers the DINA identity-link `delta` - *and* the higher-order parameters), an independent-attribute pi-recovery check, and - a 500-replication Monte-Carlo study (K=3, N=1500) — the saturated item - probabilities recover with mass-weighted RMSE ~0.02 and attribute agreement > 0.9 - under both a normal and a skewed trait distribution. Extends `mlsirm_core::cdm` - (reuses `reduce_class`, `mobius_inverse_inplace`, `newton_attr_2pl`, - `ho_pi_from_params`). Exposed to Python through PyO3 as `fit_ho_gdina` with the - `HoGdinaFit` wrapper. - -- **Rating Scale Model** (Andrich, 1978). `fit_rsm(responses)` fits the Rasch-family - polytomous model for items on a common rating scale (e.g. Likert): every item has - its own location `delta_i`, but the `K-1` category thresholds `tau_k` are *shared - across all items* — `ln[P(X=k)/P(X=k-1)] = theta - delta_i - tau_k`, `theta ~ - N(0,1)`. This is a constrained partial-credit model (the PCM/GPCM in `poly.rs` / - `mixed.rs` have item-specific thresholds); at `K=2` it reduces exactly to the Rasch - model. Implemented as the GPCM cell with slope 1 and the structured intercept - `-k*delta_i - sum_{m<=k} tau_m` (reusing `poly::gpcm_logprobs`), fit by marginal-ML - EM with a monotone ECM M-step: a per-item Newton for the locations, then a joint - Newton for the shared thresholds aggregated over items — both with a backtracking - line search that guarantees the marginal likelihood ascends — followed by - re-centering the thresholds to sum to zero (the model is invariant under - `tau -> tau - c`, `delta -> delta - c`). A 500-replication Monte-Carlo study (J=12, - K=5, N=1000) recovers the item locations and the shared thresholds tightly and the - trait with correlation > 0.85 under both a normal and a skewed trait distribution. - New `mlsirm_core::rsm` module; exposed to Python through PyO3 as `fit_rsm` with the - `RsmFit` wrapper. - -- **Continuous Response Model** (Samejima, 1973) — the library's first estimator - for a *continuous* bounded response (all other models are binary, polytomous, - response-time, or cognitive-diagnosis). `fit_crm(responses)` fits Samejima's CRM, - the limit of the graded response model as the number of ordered categories grows - without bound. Operationally (Wang & Zeng, 1998), the logit of a response - `Z in (0,1)` is conditionally normal and linear in the trait: - `logit(Z_ij) | theta_j ~ N(a_i theta_j + d_i, sigma_i^2)`, `theta ~ N(0,1)`. The - working `(slope a_i, intercept d_i, residual sd sigma_i)` map to the classic - `(discrimination alpha_i = a_i/sigma_i, difficulty b_i = -d_i/a_i, scale - gamma_i = a_i)`, all reported. Estimated by marginal-ML EM over a Gauss-Hermite - trait grid with a **closed-form** weighted-least-squares item M-step (regress the - transformed response on the trait under the posterior, then the residual - variance) — the exact profile MLE, no Newton iteration. Continuous responses are - information-rich, so a 500-replication Monte-Carlo study (J=15, N=500) recovers - the item parameters tightly and the trait with correlation > 0.9 under both a - normal and a skewed trait distribution. New `mlsirm_core::crm` module (reuses the - `quadrature::gh_rule` grid); exposed to Python through PyO3 as `fit_crm` with the - `CrmFit` wrapper. The `Z -> logit` Jacobian is a data-only constant, so the - reported log-likelihood is in the transformed metric. - -- **Higher-order structured attribute prior for cognitive diagnosis** (de la Torre - & Douglas, 2004). `fit_ho_cdm(responses, q_matrix, model="dina"|"dino")` fits a - DINA/DINO model whose `2^K` attribute-class distribution, instead of being free - (as in `fit_cdm`), is *structured* by a continuous higher-order trait - `theta ~ N(0,1)`: `P(alpha_k=1 | theta) = sigmoid(a_k theta + d_k)` with attributes - conditionally independent given the trait. This replaces the `2^K - 1` free class - probabilities with `2K` interpretable attribute parameters (slope `a_k`, - intercept `d_k`). Estimated by marginal-ML EM over the joint `(alpha, theta)` grid: - the item slip/guess M-step is unchanged, and the population update becomes `K` - independent 2PL calibrations of attribute mastery on the trait (reusing the - `fit_mmle_2pl` Newton with expected node counts). The implied class distribution, - per-person trait EAP, MAP profile, and marginal attribute mastery are returned. - The observed-data likelihood depends on `(a_k, d_k)` only through the implied class - distribution, so the higher-order parameters are a genuine, identified restriction - only for `K >= 3` (at `K <= 2` only the class distribution and the attribute - classification are identified); `attr_slope` is anchored non-negative. A - 500-replication Monte-Carlo study (higher-order DINA, K=3, N=1000) recovers the - attribute parameters and classification under both a correctly-specified normal - trait and a mis-specified skewed trait. Extends `mlsirm_core::cdm` — reuses the - DINA gate, `update_item`, and `mmle::GH_NODES`/`GH_WEIGHTS`. Exposed to Python - through PyO3 as `fit_ho_cdm` with the `HoCdmFit` wrapper. - -- **Item-level cognitive-diagnosis model selection by the Wald test** (de la - Torre, 2011). `gdina_wald_selection(responses, q_matrix, alpha=0.05)` tests, for - each item, whether the saturated G-DINA can be replaced by a more parsimonious - reduced model. The candidates are exact *linear restrictions* of the - identity-link parameters `delta = M^{-1} P` (`P` the reduced-class success - probabilities): **DINA** (conjunctive — only the intercept and the top-order - interaction free), **DINO** (disjunctive — the non-intercept coordinates tied - onto one line `delta_S = (-1)^{|S|+1} Delta`, a general non-coordinate - restriction), **A-CDM** (additive on the identity link — all interaction - coordinates zero), **LLM** (linear logistic model — additive on the *logit* link), - and **R-RUM** (reduced reparameterized unified model — additive on the *log* link). - The Wald statistic `W = (R delta)' (R Sigma_delta R')^{-1} (R delta) ~ chi^2(df)` - restricts the identity-link `delta = M^{-1} P` for DINA/DINO/A-CDM and the - transformed `delta^h = M^{-1} h(P)` for LLM (`h = logit`) and R-RUM (`h = log`). - For the identity link `Sigma_delta = M^{-1} Var(P) M^{-T}` with - `Var(P_l) = P_l(1-P_l)/I_l`; for a transformed link the first-order delta method - gives `Var(h(P_l)) = h'(P_l)^2 Var(P_l)` (LLM `1/(I_l P_l(1-P_l))`, R-RUM - `(1-P_l)/(I_l P_l)`), sharing the same Möbius sandwich. All three covariances (and - the two transformed deltas) accumulate in one pass over the shared Möbius columns - `c_l = M^{-1} e_l` (reusing `mobius_inverse_inplace`); the expected reduced-class - counts `I_l` come from one posterior pass. Per item the fewest-parameter model not - rejected at `alpha` is selected (DINA and DINO cost two parameters; A-CDM, LLM and - R-RUM each cost `1 + K`, so ties are broken by the larger p-value), else the - saturated G-DINA. The covariance uses complete-data (expected) rather than - observed information, so the test is mildly liberal — a 500-replication - Monte-Carlo study (K=2, N=3000, strong attribute identification) confirms Type I - error near nominal under both uniform and correlated/skew attribute distributions - (Type I at `alpha=0.05`: DINA/DINO/A-CDM/LLM/R-RUM all within ~0.059–0.083) with - power 0.98–1.000 against false over-restrictive or wrong-link models — including the - cross-link cases (A-CDM and R-RUM rejected under LLM truth ~1.000/0.98, LLM rejected - under R-RUM truth 1.000), verifying the link transform is faithful rather than - cosmetic. A - non-centered anchor test drives this home: truths additive on *only* one of the - three links (identity/logit/log) are each recovered as their own model while the - other two additive models are rejected. Extends `mlsirm_core::cdm` (reuses - `fit_gdina`, `reduce_class`, `posterior_row_gdina`, `mobius_inverse_inplace`, and - `fitstats::chi2_sf`). Exposed to Python through `gdina_wald_selection` / - `WaldModelSelection` (both generic in the model count, so the two new candidates - flow through unchanged). Deferred: the incomplete-data (observed-information) - covariance. - -- **Empirical Q-matrix validation by the PVAF method** (de la Torre & Chiu, - 2016). `validate_q_matrix(responses, provisional_q, epsilon=0.95)` checks and - corrects the attribute-by-item Q-matrix of a cognitive-diagnosis model. Each - candidate q-vector groups the `2^K` latent attribute classes into masters vs. - non-masters of its required attributes; the *proportion of variance accounted - for* is `PVAF(q) = zeta^2(q) / zeta^2_full`, the share of the item's - across-class success-probability variance that grouping captures. Per item the - method returns the q-vector with the **fewest** required attributes whose - `PVAF >= epsilon` — an under-specified provisional q falls short and is - enlarged, an over-specified one is trimmed because a smaller vector already - clears the cutoff. The class weights and identified attribute labels come from - a G-DINA fit under the provisional Q; each item's *saturated* success - probability over all `2^K` classes is then recovered nonparametrically from - the fitted posteriors, so a mis-specified item's true dependence is exposed by - the attributes the *other* items identify (the method assumes the provisional - Q is mostly correct). Extends `mlsirm_core::cdm` — reuses the G-DINA - `reduce_class` collapse and posterior pass; the exhaustive q-vector search is - `O(J * 4^K)`, so `K` is capped at 10. Validated by an anchor (the true Q - validates to itself), over-/under-specification correction, and a - 500-replication Monte-Carlo Q-recovery study (K=3, J=15, N=1000): under a - uniform attribute distribution the exact q-vector is recovered for 98.1% of - items (attribute TPR 0.996, FPR 0.012), and under a correlated/skew - higher-order distribution for 93.5% (TPR 0.982, FPR 0.035). Exposed to Python - through PyO3 as `validate_q_matrix` with the `QMatrixValidation` wrapper. - Deferred: the stepwise Wald item-level model-selection test (de la Torre, - 2011) and sequential/iterative Q-matrix re-estimation. - -- **Testlet response model** (Bradlow, Wainer, & Wang, 1999; Wang, Bradlow, & - Wainer, 2002). `fit_testlet(responses, testlet_id, model="rasch"|"2pl")` models the - local dependence induced when items share a common stimulus (a reading passage): each - item in testlet `d` carries a person-specific random effect `gamma_{j,d} ~ N(0, - sigma^2_d)`, so `P(X=1) = sigmoid(a_i(theta_j - b_i - gamma_{j,d(i)}))`. The per-testlet - variance `sigma^2_d` is the estimand of interest — a large value flags strong - within-bundle dependence; all `sigma^2_d = 0` is the ordinary conditional-independence - 2PL/Rasch model. A dedicated estimator (not the general bifactor): because each item - depends on `theta` and exactly one testlet effect, the marginal likelihood **factors** - into a `theta`-outer / per-testlet-`gamma`-inner nested Gauss-Hermite quadrature whose - per-person cost is independent of the number of testlets `D` (vs the bifactor's - exponential `D`-dimensional grid), and it reports `sigma^2_d` directly rather than only - per-item loadings. The item M-step reuses `fit_mmle_2pl`'s Newton on the effective node - `t_g - sigma_d*u_h`; the closed-form variance update `sigma^2_d <- sigma^2_d * mean_j - E[u_d^2 | y_j]` is accelerated with SQUAREM (Varadhan & Roland, 2008; monotone, with a - plain-EM fallback) to tame the slow variance-component convergence. Singleton testlets - (whose variance is non-identified) are pinned to 0. Compute lives in - `mlsirm_core::testlet::fit_testlet`; the shared Newton and Gauss-Hermite table make the - `sigma^2 -> 0` case reduce **bit-exactly** to `fit_mmle_2pl` (the reduction anchor, - asserted `< 1e-12`). Also anchored: a no-spurious-LD check (pure-2PL data recovers - `sigma^2 ~ 0`), a strong-LD recovery with a log-likelihood gain over the naive 2PL fit, - singleton pinning, and a monotone-ascent guard. A Bradlow-Wainer-Wang-style - 500-replication Monte-Carlo (Rasch testlet, N=1000, D=4) under normal and skewed - ability recovers the testlet variances near-unbiasedly (RMSE ~0.093, `|bias| <= 0.007`) - and the item difficulties (RMSE ~0.09), with every replication converging. Exposed via - PyO3 as `fit_testlet` with the - `TestletFit` Python wrapper. (In the 2PL testlet the discrimination `a_i` and the - testlet SD `sigma_d` both scale the dependence via `a_i*sigma_d` and separate only - weakly, so the Rasch testlet is the well-identified default.) Deferred: polytomous and - 3PL testlets, covariate/second-order structure, and the original paper's fully-Bayesian - MCMC estimator. - -- **Linear Logistic Test Model (LLTM)** (Fischer, 1973). An *explanatory* Rasch - model: `fit_lltm(responses, q_design)` decomposes each item's easiness (the package's - additive sign convention; Fischer difficulty is its negative) into a - linear combination of `K` basic cognitive-operation parameters through a fixed - weight matrix `Q` (`b_i = c + Σ_k q_ik·η_k`), rather than estimating `J` free item - easinesses. With `K << J` parameters it tests whether a small set of cognitive - operations *explains* the item parameters. Estimated by marginal-ML EM: the - E-step is the Rasch node posterior over the shared Gauss-Hermite rule; the M-step is - a `K`-dimensional chain-rule Newton — the per-item Rasch easiness gradient/Hessian - aggregated through the design (`g_η = Qᵀg_b`, `H_η = Qᵀ diag(h_b) Q + ridge`, solved - with the shared dense `solve_small`). A free grand-mean easiness intercept is fit by - default. The classic likelihood-ratio test of LLTM vs the saturated Rasch model - (`2·(ll_Rasch − ll_LLTM) ~ χ²(J − K − 1)`) is computed inline (the Rasch reference is - the same engine run with `Q = I`). **Identification is validated, not assumed**: the - effective design (including the intercept column) must have full column rank for `η` - to be identified, so a rank-deficient `Q` (e.g. one whose rows sum to a constant, - colliding with the intercept) is rejected rather than papered over by the Newton - ridge. Compute lives in `mlsirm_core::lltm::fit_lltm`; because the M-step reuses - `mmle`'s Rasch Newton and Gauss-Hermite table, the `Q = I` case reduces - **bit-exactly** to a Rasch fit — anchored two ways: a single M-step is bit-identical - (`==`) to `J` independent per-item Rasch Newton steps, and a full `Q = I` fit matches - a single-class Rasch mixture fit to `< 1e-10`. A 500-replication Monte-Carlo - (J=30, K=5, N=1500) under normal and skewed ability recovers the basic parameters - (RMSE/bias) and induced easinesses, and validates the LR test (Type I when the - restriction holds, power when it is violated off-model). Exposed via PyO3 as - `fit_lltm` with the `LltmFit` Python wrapper. This is the marginal-ML / `N(0,1)` - operationalization of Fischer's conditional-ML LLTM. It is a repository-specific - estimator choice, and finite-sample equality with Fischer's conditional-ML item - estimates is not asserted. Deferred: conditional-ML estimation, LLTM for 2PL/polytomous - models, and random-weights / LLRA extensions. - -- **Mixed Rasch / mixture IRT** (Rost, 1990; Rost & von Davier, 1995). A new - paradigm for unobserved population heterogeneity: `fit_mixture(responses, - n_classes, model="rasch"|"2pl")` models the population as a mixture of `C` latent - classes, each with its OWN item parameters and a mixing weight `pi_c`, detecting - qualitatively different response strategies a single-class model cannot represent. - Within a class, responses follow a Rasch (discrimination fixed at 1) or 2PL model - with `theta ~ N(0,1)`, estimated by marginal-ML EM: the E-step forms the joint - posterior over (class, ability node) via one max-shift log-sum-exp over the `C·Q` - Gauss-Hermite grid; the per-class item M-step reuses the exact penalized Newton - step of `fit_mmle_2pl` (weighted by the class responsibility); the mixing weights - update to the mean posterior class membership. Because the mixture likelihood is - multimodal, `n_starts > 1` runs random restarts (start 0 is a deterministic warm - start) and keeps the highest-likelihood fit; classes are returned in a canonical - order (mixing weight descending, ties by mean difficulty ascending) to tame label - switching. Compute lives in `mlsirm_core::mixture::fit_mixture`; the shared Newton / - Gauss-Hermite table with `fit_mmle_2pl` makes the `C = 1` case reduce **bit-exactly** - to the verified single-class 2PL estimator — the reduction anchor, asserted to - `< 1e-12`. Also anchored: a two-class difficulty-reversal recovery (the canonical - Rost two-strategy structure), permutation-matched, plus a monotone-ascent guard. A - 500-replication Monte-Carlo (C=2, J=15, N=1500, reversal truth) under normal and - skewed ability recovers the class difficulties (permutation-matched RMSE), mixing - proportions, and class membership (MAP accuracy + label-invariant Adjusted Rand - Index; Hubert & Arabie, 1985). Exposed via PyO3 as `fit_mixture` with the - `MixtureFit` Python wrapper. This repository combines Rost's latent-class structure - with a fixed-standard-normal, Bock-Aitkin marginal-ML EM estimator. It differs from - the conditional-ML estimators in Rost (1990) and psychomix (Frick et al., 2012), so - no exact finite-sample item-contrast equivalence is claimed. Deferred: free per-class - ability variance, automatic model selection - over `C` (AIC/BIC/ICL from the returned `n_parameters`/`loglik_trace`), and - concomitant-variable mixing. - -- **Generalized DINA (G-DINA), the saturated cognitive-diagnosis framework** - (de la Torre, 2011). `fit_gdina(responses, q_matrix)` fits the general model of - which DINA, DINO, A-CDM, LLM, and R-RUM are constrained special cases. For an - item requiring `K_i` attributes, each of its `2^{K_i}` *reduced* attribute-mastery - classes gets a **free** success probability `p_il = P(X_i = 1 | reduced class l)`, - estimated by marginal-ML EM over the `2^K` profiles. The E-step reuses the DINA - profile-grid posterior; the closed-form saturated M-step is - `p_il = R_il / I_il` (expected correct / expected total in reduced class `l`) — - exactly DINA's two-cell slip/guess step generalized to `2^{K_i}` cells (de la - Torre, 2011, Eq. 10). The identity-link parameters `item_delta` (intercept, main - effects, all interactions) are recovered from the fitted probabilities by an - in-place signed subset Möbius transform `delta = M^{-1} p` (no matrix inverse), so - the constrained submodels are readable off the `delta` pattern — DINA leaves only - the intercept and the highest-order interaction nonzero; A-CDM zeroes the - interactions. Item parameters are stored ragged (CSR: `item_off` + flat - `item_prob`/`item_delta`) since `2^{K_i}` varies per item; the box constraint - `0 <= p_il <= 1` holds for free (`0 <= R_il <= I_il`). The saturated estimator is - otherwise order-unconstrained: Q-matrix identifiability does not make the - all-mastered class largest, and the separate Hong, Chang, and Tsai (2016) - subset-lattice order restriction is not implemented. - Compute lives in `mlsirm_core::cdm::fit_gdina`, extending the DINA module without - touching the shipped DINA core; exposed via PyO3 as `fit_gdina` with the `GdinaFit` - Python wrapper. Correctness is anchored by a brute-force likelihood identity - (log-space path == naive enumeration to `1e-12`), a **DINA-reduction crux anchor** - (DINA-generated data recovers `p_il = g_i` for every non-top class and `1 - s_i` - at the top, with the exact DINA `delta` pattern), a DINO-reduction anchor, an - A-CDM additivity anchor (fitted interactions negligible relative to main effects), - a Möbius round-trip identity, an exhaustive `reduce_class` bit-packing check, and a - deterministic limit. A de la Torre (2011)-style 500-replication Monte-Carlo (K=5, - J=30, N=1000) with a stochastic higher-order attribute distribution (de la Torre & - Douglas, 2004) under normal and skewed abilities recovers `p_il` (mass-weighted - RMSE) and attribute classification accuracy. Deferred: LLM/R-RUM logit/log-link - submodels, item-level model-selection Wald tests, Q-matrix validation, and full - subset-lattice isotonic monotonicity (Hong et al., 2016). - -- **Cognitive diagnosis models: DINA and DINO** (Junker & Sijtsma, 2001; de la - Torre, 2009; Templin & Henson, 2006). A new discrete-attribute paradigm - alongside the continuous-trait family: `fit_cdm(responses, q_matrix, - model="dina"|"dino")` classifies each respondent's binary attribute-mastery - profile `alpha in {0,1}^K` against a Q-matrix of item-attribute requirements. - The ideal response is the conjunctive AND gate `eta = prod_k alpha_k^{q_k}` - (DINA — mastery of all required attributes) or the disjunctive OR gate - `eta = 1 - prod_k (1-alpha_k)^{q_k}` (DINO — any required attribute), and the - observed response adds a per-item slip `s_i = P(X=0|mastered)` and guess - `g_i = P(X=1|not mastered)`, `P(X=1|alpha) = (1-s_i)^{eta}(g_i)^{1-eta}`. - Estimation is marginal-ML EM over the `2^K` profiles with a free profile - distribution: the E-step posterior is accumulated over the discrete profile - grid (a bitwise gate test replaces the continuous quadrature), the item M-step - is **closed form** (`s_i = 1 - R1_i/I1_i` = expected fraction of masters - answering wrong; `g_i = R0_i/I0_i` = non-masters answering right; de la Torre, - 2009, Eqs. 9-10), and the population step is a mean of the posteriors. The - monotonicity/identification constraint `1 - s_i > g_i` is enforced by the exact - constrained boundary maximiser; missing cells are dropped under MAR. Persons - are classified by the posterior-mode profile (`map_profile`) and marginal - attribute-mastery probabilities (`attr_prob`, attribute EAP). All compute runs - in the Rust core (`mlsirm_core::cdm::fit_cdm`) with the `2^K` profile grid - bit-encoded (no `N*L` storage; streaming E-step); DINA and DINO share one - estimator differing only in the one-line gate mask. Correctness is anchored by - a brute-force likelihood identity (log-space path == naive enumeration to - `1e-12`), a deterministic `s=g=0` limit (exact pattern recovery), a - DINA==DINO gate-equivalence identity on single-attribute items, and a K=1 - reduction to a 2-class latent-class model. A de la Torre (2009)-style - 500-replication Monte-Carlo (K=5, J=30, N=1000) recovers slip/guess with mean - RMSE 0.013-0.024 and negligible bias (`|bias| < 3e-4`) and attains attribute - classification agreement 0.99 (s=g=0.1) / 0.95 (s=g=0.2), pattern-wise 0.96 / - 0.76. Deferred: the general G-DINA/saturated CDM, Q-matrix estimation, and - structured (higher-order) attribute priors. - -- **Polytomous response models (GRM / GPCM), unidimensional.** A complete - fit -> score -> information subsystem: `fit_polytomous(responses, n_cat, - model="grm"|"gpcm")` fits the graded response model (Samejima; the default) - or the generalized partial credit model (Muraki) by Bock-Aitkin marginal-EM; - `score_polytomous(responses, fit)` returns EAP trait scores and posterior - SDs; `information_polytomous(fit, theta)` returns item and test Fisher - information curves. `NaN` responses are treated as missing and marginalized - out of each person's likelihood and posterior. All numerical work — the category cells, the residual - M-step gradient, the Newton item update, the EAP reduction, and the - information — runs in the Rust core (`mlsirm_core::poly`: - `grm_logprobs`/`gpcm_logprobs` + `*_node_gradient` + `fit_poly_unidim` + - `score_poly_eap` + `poly_item_information`), exposed via PyO3; the NumPy - `category_logprobs`/`grm_category_logprobs`/`gpcm_node_gradient`/ - `fit_gpcm_numpy` are parity references held to `<= 1e-12` (both cells) / - recovery agreement (fitter). GRM is - chosen as the identification-clean default for the latent-space family — the - single interaction term enters every cumulative logit as a shared shift, with - no forced category scaling (design rationale and literature basis in - `docs/papers/gpcm-nominal-design-spec.md`). The latent-space polytomous - extension (the same cell inside the marginal `(theta, xi)` quadrature) is the - next milestone. - -- **Polytomous computerized adaptive testing** (Dodd, De Ayala & Koch, 1995). - `cat_simulate_polytomous(true_theta, fit)` simulates an adaptive test over a - fitted GRM/GPCM bank: items are selected by maximum Fisher information at the - running EAP trait, responses are generated at the true trait, and the trait + - posterior SD are re-estimated after each item, stopping at an SE threshold (or - a fixed length). Returns per-simulee `theta_eap`, `theta_sd`, and `n_used`. - Compute in Rust (`mlsirm_core::poly::poly_cat_simulate`, plus - `poly_cat_next_item`), composing the existing item information and EAP scoring. - Validated by a 500-simulee Monte-Carlo: a variable-length CAT recovers the - trait to RMSE 0.29 (normal) / 0.33 (skew) using ~9.7 of 40 bank items, and at - a fixed length of 12 maximum-information selection beats random (RMSE 0.27 vs - 0.33 normal; 0.30 vs 0.40 skew). - -- **Polytomous person fit** (Drasgow, Levine & Williams, 1985; Snijders, 2001). - `person_fit_polytomous(responses, fit)` returns the standardized - log-likelihood `l_z` and its estimated-trait correction `l_z*` (per person, - at the EAP trait) plus `theta_eap` and a boolean `flagged`, for a fitted - GRM/GPCM — the ordered-category generalization of the binary l_z. Compute in - Rust (`mlsirm_core::poly::poly_person_fit`), reusing the poly cells with a - central-difference trait score. Validated by an exact reduction to the binary - `person_fit` l_z at `n_cat = 2` (`<1e-6`) and a 500-replication Monte-Carlo: - under model respondents `l_z*` is ~N(0,1) (mean −0.15, sd 1.04, Type I 0.08 - at a 20-item test), and inconsistent responders are flagged with power 0.86. - -- **Nominal categories model** (Bock, 1972; Thissen, Cai & Bock, 2010). - `fit_nominal_polytomous(responses, n_cat)` fits the unidimensional nominal - model `P(Y=k|θ) = softmax_k(a_k·θ + c_k)` with a free scoring function `a_k` - and intercept `c_k` per category, identified by `a_0 = c_0 = 0` and - `θ ~ N(0,1)`, returning a `NominalFit` (`scores`, `intercepts`, `loglik`). - The generalized partial credit model is the special case `a_k = a·k`, so the - nominal model nests it. Compute in Rust (`mlsirm_core::poly::fit_nominal`), - reusing the softmax cell and its residual gradient. The parameterization and - identification were adversarially verified against the source chapter. - Validated by the GPCM nesting (loglik ≥ the GPCM fit, recovered scores linear - in `k`) and a 500-replication recovery Monte-Carlo (per-item sign alignment): - under a matched `N(0,1)` ability the score RMSE is 0.15 with |bias| 0.01 - (near-unbiased), degrading to RMSE 0.44 / |bias| 0.39 under a skewed - population. - -- **Polytomous item-pair local dependence** (Chen & Thissen, 1997; Liu & - Maydeu-Olivares, 2013). `local_dependence_polytomous(responses, fit)` returns, - for every item pair of a fitted GRM/GPCM, the Pearson `X²` and likelihood-ratio - `G²` comparing the observed `K×K` contingency table to the model-implied joint - under local independence, with `df = (K-1)²`, the χ² p-value, Cramér's V, and - the largest standardized cell residual — the ordered-category generalization - of the binary pairwise χ² and the pair-level complement to item-level S-X² and - test-level M2. Compute in Rust (`mlsirm_core::fitstats::poly_local_dependence`). - Validated by a deterministic K=2 reduction to a from-scratch 2×2 χ² and a - 500-replication Monte-Carlo at fitted parameters: locally-independent pairs are - calibrated (X²/df = 0.84, Type I 0.03 — conservative, as the papers note), - while an injected 2-item testlet is localized to that pair (X²/df = 10.9, power - 1.00). - -- **Polytomous IRT likelihood-ratio DIF** (Thissen, Steinberg & Wainer, 1993; - Woehr & Meriac, 2010). `dif_polytomous(responses, group_id, n_cat)` runs a - two-group DIF sweep for GRM/GPCM items: it fits a *compact* model (all items - group-invariant) once, then per studied item an *augmented* model (that item's - parameters freed per group) with every other item as the anchor, and refers - `LR = 2·Δloglik` to `χ²((n_groups−1)·n_cat)`. Each non-reference group's latent - distribution `N(μ_g, σ_g²)` is estimated in **both** models (group 0 pinned to - `N(0,1)`), so genuine ability differences between groups (impact) are absorbed - rather than mistaken for DIF. Returns per-item `lr`, `df`, `p_value`, - `flagged_bh` (Benjamini-Hochberg FDR), and `effect_size` (the across-group - range of the item's mean category location). Compute in Rust - (`mlsirm_core::poly::fit_poly_multigroup` — a Bock-Zimowski multi-group - marginal EM whose per-item M-step reuses the single-group Newton step on each - group's nodes/expected-counts stacked, the concatenation being exactly the - Bock-Zimowski pooling — driving `poly_dif_sweep`). Validated by a 500-rep - Monte-Carlo with impact (focal `θ~N(0.5, 1.2²)`), two-group GPCM, `K=3`: - under no DIF the test is calibrated (Type I 0.042, `mean(LR)=2.92≈df=3`), an - injected uniform difficulty shift is detected with power 0.996 and a - non-uniform slope difference with power 0.920, while a skewed focal population - inflates Type I only mildly (0.057); a structural check confirms the augmented - fit never falls below the compact one and recovers the focal `μ, σ`. - -- **Response-time person fit** (van der Linden & Guo, 2008; Sinharay, 2018). - `rt_person_fit` flags aberrant response-time patterns — rapid guessing, item - preknowledge — under a fitted lognormal RT model. It profiles each person's speed - by ML, so the sum of squared standardized log-time residuals - `W_j = sum_i [alpha_i (ln T_ij - (beta_i - tau_hat_j))]^2` is *exactly* - `chi2(n_j - 1)` (an orthogonal-projection identity — the estimated-speed - correction is a clean loss of one degree of freedom, the RT analogue of `l_z*`, - with no asymptotic drift). It returns the aggregate `W`/p-value, a Wilson-Hilferty - standardized `l_t`, and per-item studentized residuals plus one-sided too-fast - flags. It detects speed *inconsistency across items*, not a uniform speed level - (the profile absorbs it). Compute in Rust (`rt::rt_person_fit`, reusing - `fitstats::chi2_sf`); exposed via PyO3 and Python. Validated by an exact identity - anchor (at true parameters the residuals are `N(0,1)` and `W` is `chi2(n)` with - known speed, `chi2(n-1)` once profiled, to within Monte-Carlo error) and a - 500-replication Monte-Carlo: Type I sits on nominal (0.05, exact — no - finite-length conservatism), rapid-guessing and preknowledge responders are - detected with power ~1.0 under both normal and skew speed, the flag is robust to - the speed-distribution shape (it conditions on within-item residuals), and the - tampered items are recalled at ~99%. Deferred: an EAP-plug-in mode (statistically - inferior — it mis-calibrates the chi-square) and multivariate RT aberrance. - -- **Joint speed-accuracy hierarchical model** (van der Linden, 2007, Level 2). A - new `mlsirm_core::rt_joint` module and the public `fit_speed_accuracy` — the - person-level layer that ties ability `theta` (from an accuracy 2PL model) to - speed `tau` (from the lognormal RT model) through a bivariate-normal person - distribution `(theta, tau) ~ N2(0, [[1, rho*sigma_tau], [rho*sigma_tau, - sigma_tau^2]])`, with the accuracy responses and log-times conditionally - independent given `(theta, tau)`. The headline output is `rho`, the ability-speed - correlation. This is the two-stage estimator: item parameters are held fixed and - the person covariance `(rho, sigma_tau)` is estimated by marginal ML over a 2-D - Gauss-Hermite grid built by Cholesky-mapping the standard nodes through - `Sigma_P`, with an exact constrained EM M-step (`c = S12/S11`, - `v = S22 - S12^2(S11-1)/S11^2`). The reported `rho` is the consistent marginal-ML - correlation, not the shrinkage-attenuated correlation of the two separate EAPs. - Compute in Rust (`rt_joint::fit_speed_accuracy_covariance`); exposed via PyO3 and - Python. Validated by an exact identity anchor (at `rho = 0` the 2-D grid - log-likelihood factorizes into the sum of the two 1-D grids to `< 1e-10`), a - reduction anchor (true independence returns `rho ~ 0`), monotone EM, and a - 500-replication Monte-Carlo recovering `rho in {0, 0.5, -0.5}` with essentially - zero bias (bias `< 0.001`, RMSE ~0.03-0.04) and `sigma_tau` to RMSE ~0.008. - Deferred: the one-step full-information MMLE, 3PL guessing, and item-parameter- - uncertainty propagation into SE(rho). - -- **Lognormal response-time model** (van der Linden, 2007). A new - `mlsirm_core::rt` module and the public `fit_response_times` — the speed-side - analogue of the 2PL for item response *times*, opening a response-time modality - alongside the accuracy models. For person `j` (latent speed `tau_j`) and item - `i` (time intensity `beta_i`, time discrimination `alpha_i`), - `ln(T_ij) ~ Normal(beta_i - tau_j, 1/alpha_i^2)`; item parameters and the speed - SD are estimated by marginal-ML EM with `tau ~ Normal(0, sigma_tau^2)`, and speed - is scored by EAP. Because the model is conditionally Gaussian with a unit loading - on `tau`, the speed posterior, marginal likelihood, and EAP are all *exact closed - forms* (matrix-determinant / Sherman-Morrison), so the estimator needs neither - quadrature nor a line search — the EM is exact `O(nnz)` coordinate ascent. The - log-time metric identifies the speed scale (so `sigma_tau` is estimated, not - fixed) and only the location is pinned (`mu_tau = 0`). Compute in Rust; exposed - via PyO3 and Python; missing/non-positive times are marginalized per person. - Validated by an exact identity anchor (the closed-form marginal log-likelihood - equals a dense multivariate-normal log-pdf to `< 1e-9`), a reduction anchor - (`sigma_tau -> 0` collapses to the per-item lognormal MLE), and a 500-replication - Monte-Carlo: under both normal and a *misspecified* skew speed population the item - parameters stay essentially unbiased (RMSE `alpha` 0.067 / `beta` 0.027, bias - `beta` -0.0001 under skew) with speed recovered at corr 0.92, demonstrating that - the level-1 RT item parameters are estimable independently of the speed - distribution's shape. Deferred: the joint speed-accuracy hierarchical layer, - Louis-standard-error information, and RT bank linking. - -- **Standard errors of equating** (Kolen & Brennan, 2014, ch. 7; Efron & - Tibshirani, 1993). `equating_standard_errors` reports the per-score-point - sampling error of the equated score for the equivalent-groups design, by two - routes. The nonparametric **bootstrap** (`route="bootstrap"`) resamples - examinees per group independently with replacement at the observed sample sizes, - re-equates each of `n_boot` replicates through the existing equating code, and - returns the per-score bootstrap SD and a percentile confidence interval — it - works for every method including equipercentile, which has no simple analytic - SEE. The **delta-method** (`route="analytic"`) returns the closed-form - normal-theory SE for mean equating (`sigma_x^2/n_x + sigma_y^2/n_y`, constant in - `x`) and linear equating (`sigma_y^2 (1 + z^2/2)(1/n_x + 1/n_y)`, - `z = (x-mu_x)/sigma_x`). Compute in Rust (`equating::bootstrap_see` / - `analytic_see`); exposed via PyO3 and Python. Validated by the analytic-Linear - agreeing with the bootstrap-Linear SEE within Monte-Carlo tolerance, the Mean - SEE being constant, a `1/sqrt(N)` shrink and seed-determinism check, and a - 500-replication Monte-Carlo confirming the bootstrap SE recovers the *true* - sampling SD of `e_Y(x)` (from an outer fresh-sample Monte-Carlo) — interior - ratio in [0.95, 1.08] for equipercentile. Deferred: NEAT bootstrap SEE, analytic - equipercentile/kernel SEE. - -- **Tucker & Levine linear NEAT equating** (Kolen & Brennan, 2014, §4.3–4.4; - Brennan, 2006). `equate_neat_linear` adds the linear observed-score methods for - the common-item non-equivalent-groups design, alongside the existing chained / - frequency-estimation equipercentile NEAT. Each forms synthetic-population - moments of the two forms (weighted by `w1`) from a group total-on-anchor slope - `gamma` — Tucker uses the regression slope `Cov(total, V)/Var(V)`; Levine uses - the congeneric effective-length ratio, which differs for an internal anchor - (`Var(total)/Cov`) versus an external one (`(Var(total)+Cov)/(Var(V)+Cov)`) — - then equates linearly. Compute in Rust (`equating::equate_neat_linear`); exposed - via PyO3 and Python. Validated by the exact reduction to equivalent-groups - linear equating under equal anchor moments (all four Tucker/Levine × - internal/external variants, any `w1`, to `< 1e-9`), a hand-computed check that - pins the internal-vs-external Levine gamma against an independent oracle, and a - 500-replication Monte-Carlo under a common-regression generative model - (equated-score interior RMSE 0.39 → 0.19 from `N = 1000` to `4000`, ratio 2.02 ≈ - √4; max bias 0.051 → 0.034). Deferred: Levine true-score equating, Braun-Holland. - -- **Kernel equating + log-linear presmoothing** (von Davier, Holland & Thayer, - 2004; Holland & Thayer, 2000). Two enhancements to the equating module. - `loglinear_smooth(counts, degree)` presmooths a score-frequency distribution by - Poisson-ML log-linear fitting (on an orthonormal polynomial design over a - centered/scaled score, Newton with step-halving), preserving the first `degree` - sample moments exactly while damping sampling noise; it returns AIC/BIC so a - caller can select the degree, and saturated at `degree = k` it reproduces the - raw relative frequencies. `equate_observed_scores_kernel` adds a Gaussian-kernel - continuization (von Davier's `F_h(x) = Σ_j r_j Φ((x − a x_j − (1−a)μ)/(a h))`, - bandwidth by the penalty method) and optional per-form presmoothing to the - equipercentile family, behind a single extended entry point whose uniform-kernel - path reproduces the existing equipercentile bit-for-bit. Compute in Rust - (`equating::loglinear_smooth` / `equate_eg_ext`); exposed via PyO3 and Python. - Validated by exact-identity anchors — uniform-kernel equating equals the - equipercentile to `< 1e-12`; presmoothing preserves the first `T` moments to - `< 1e-8` and reproduces `rel_freq` when saturated; the Gaussian-kernel - self-equate is the identity, a large bandwidth drives kernel equating to linear - to `< 1e-4`, and the continuized density preserves the discrete mean and - variance — plus a 500-replication Monte-Carlo against the population - Gaussian-kernel transform (interior RMSE 0.53 → 0.26 from `N = 1000` to `4000`, - ratio 2.03 ≈ √4; max bias 0.049 → 0.020). Deferred: bivariate presmoothing, - kernel-NEAT, and analytic standard errors. - -- **Observed-score equating** (Kolen & Brennan, 2014). A new - `mlsirm_core::equating` module and the public `equate_observed_scores` / - `equate_neat` — the raw-score complement to the IRT scale linking (`irt_link`). - Equivalent-groups mean, linear, and equipercentile equating (percentile-rank - matching with the Kolen-Brennan uniform-kernel continuization, equated scores - kept real-valued), and the common-item non-equivalent-groups (NEAT) design via - chained equipercentile and frequency-estimation (post-stratification) - equipercentile. The attainable min/max are computed on relative-frequency - vectors; the frequency-estimation synthetic densities are renormalized so a - poorly overlapping anchor degrades toward each group's own marginal rather than - corrupting the cdf. Compute in Rust; exposed via PyO3 and a Python - `equating.py` (`EquateResult`). Validated by three exact identities — the - equipercentile self-equate is the identity to `< 1e-9` (including the low - boundary at `x = 0`), mean/linear recover a known integer-affine transform to - `< 1e-9`, and both NEAT methods collapse to EG equipercentile under equal - anchor distributions to `< 1e-9` — plus a 500-replication Monte-Carlo against a - deterministic Lord-Wingersky population equating: the empirical equipercentile - converges at the expected rate (interior RMSE 0.53 at `N = 1000` → 0.26 at - `N = 4000`, ratio 1.99 ≈ √4; max bias 0.068 → 0.031). Deferred (each a drop-in - behind the density/table interface): Tucker/Levine linear NEAT, log-linear - presmoothing, and Gaussian-kernel equating (von Davier et al., 2004). - -- **Nonparametric polytomous person fit U3poly** (Emons, 2008; van der Flier, - 1982). `u3_person_fit_polytomous(responses, n_cat)` computes van der Flier's - `U3` person-fit statistic generalized to ordered polytomous items — a - *model-free* index: each item-step response function `P(Y_i >= m)` is estimated - by its sample proportion, turned into a logit weight, and a person's observed - weighted score is compared to the largest and smallest weighted scores - attainable at that person's total score (the conditioning group), giving - `U3 in [0, 1]` (1 = maximally popularity-inconsistent). The attainable min/max - bounds are computed by exact min-plus / max-plus DP (not the flat "sum of the - top-k weights" shortcut, which over-counts once an unused category breaks - within-item monotonicity). `u3_cutoff_polytomous(fit, n_persons)` returns a - simulated `1 - alpha` critical value by parametric bootstrap (U3poly has no - usable analytic null; Emons used simulated critical values). Compute in Rust - (`mlsirm_core::poly::u3_poly_person_fit` + `u3_poly_bootstrap_cutoff`). - Validated by an exact `n_cat = 2` reduction to a from-scratch van der Flier `U3` - (max abs diff `< 1e-10`) and a 500-replication Monte-Carlo (GPCM, `K = 5`, - `n = 600`): the simulated cutoff calibrates the marginal flag rate under a - matched population (Type I 0.052 normal / 0.054 skew) and detects careless - responders with power ~1.00; the per-total-score-group flag-rate deviation - (0.066 normal / 0.083 skew) is reported to make transparent that a single - pooled cutoff cannot fully condition on the total score. Complements the - parametric `l_z`/`l_z*` (`person_fit_polytomous`) with a distribution-free - screen. - -- **Polytomous M2 limited-information goodness-of-fit** (Maydeu-Olivares & Joe, - 2014). `m2_polytomous(responses, fit)` returns the test-level M2 statistic, - `df`, `p_value`, RMSEA2 (with a 90% interval), and SRMSR for a fitted GRM/GPCM - — the ordered-category generalization of the binary M2 (`m2_stat`). It uses - the cumulative marginals `P(Y_i>=c)` and `P(Y_i>=c, Y_j>=d)` (the same M2 as - the paper's category-equality form) and reduces **exactly** to the binary - `m2_rmsea2` at `n_cat = 2`. Compute in Rust (`mlsirm_core::fitstats::poly_m2`), - reusing the one-Cholesky residual-projection solve. `df = n(K-1) + - C(n,2)(K-1)² - nK`. Validated by the exact `K=2` reduction (GRM and GPCM) and - a 500-replication Monte-Carlo: under a matched `N(0,1)` ability `mean(M2)/df = - 0.99` with Type I error 0.05 (nominal), and under a skewed population `M2` - inflates 4× with power 1.00. - -- **Generalized S-X² item fit for polytomous models** (Kang & Chen, 2008, 2011). - `item_fit_polytomous(responses, fit)` returns the per-item summed-score - chi-square, `df`, `p_value`, and retained cell count for a fitted GRM/GPCM, - extending the binary Orlando-Thissen S-X²: persons are grouped by summed - score, and the model-expected category proportions come from the generalized - Lord-Wingersky recursion (Thissen, Pommerich, Billeaud & Williams, 1995) with - the leave-one-out summed-score distribution. Boundary score groups are merged - and adjacent categories collapsed to a minimum expected frequency. Compute in - Rust (`mlsirm_core::poly::poly_s_x2`), exposed via PyO3. Validated to reduce - **exactly** to the trusted binary `fitstats::s_x2` at `n_cat = 2` (GRM and - GPCM, statistic and df), and — at the true generating parameters — to track - its reference chi-square (`E[S-X²] ≈ Σ cells`) for both the GPCM (2008) and - GRM (2011) families. - -- **Marginal (MMLE-EM) estimation for the full latent-space family.** - `fit(estimator="mmle")` now fits `MIRT`/`MLS2PLM`/`MLSRM` (and `ULS2PLM`/ - `ULSRM` under a population structure) by Bock-Aitkin-style marginal EM: - person latents `(theta, xi)` are integrated over Gauss-Hermite grids — - tractable via the simple-structure conditional factorization — with a - Fisher-preconditioned GEM M-step and the Jeon et al. (2021) LSIRM priors as - MAP penalties (`PenaltyConfig::lsirm_prior`). Rust core - (`mlsirm_core::marginal`) with a NumPy mirror - (`fast_mlsirm.estimators.marginal`) held to 1e-9 end-of-run parity - (`tests/test_marginal_parity.py`); design and paper basis in - `docs/mmle_marginal_lsirm_design.md`. -- **Estimation-level multigroup and multilevel population structures** for the - marginal estimator: `fit(..., group_id=...)` (Bock-Zimowski group trait - means/SDs, common items, pinned reference group) and - `fit(..., cluster_id=...)` (Fox-Glas random intercept, `sigma_u`/ICC - estimated). Results surface on `FitResult.population` and persist through - `save_fit_result`; the CLI `fit` command gains `--estimator`, `--group-id`, - `--cluster-id`, `--q-theta`, `--q-xi`, `--q-u`, and `--tolerance`. -- **wgpu E-step kernels for the marginal estimator** - (`mlsirm_core::gpu_marginal`): the E-step hot path runs in f32 on the GPU - with the same race-free slot-ownership reduction as the JML kernels, cutting - a 31k-person multilevel E-step iteration from ~110 s (CPU f64) to ~5 s on a - laptop RTX 3050 Ti; the M-step and final EAP pass stay on the CPU in f64, - and hosts without an adapter fall back to the CPU path unchanged. -- **Likelihood-based fit statistics** (`fast_mlsirm.fitstats`): Orlando-Thissen - S-X² via the Lord-Wingersky recursion generalized to the joint `(theta, xi)` - grid (chi-square tail without SciPy), Benjamini-Hochberg FDR control, - Drasgow `l_z` and Snijders `l_z*` person fit with the MAP `r_0` correction, - and infit/outfit at the marginal EAPs. -- **M2 limited-information goodness-of-fit** (`fast_mlsirm.fitstats.m2`; - Maydeu-Olivares & Joe 2005/2006, Cai & Hansen 2013): the M2 statistic on the - univariate + bivariate residual margins, its df and χ² tail p-value, the - RMSEA2 approximate-fit index with a 90% noncentral-χ² confidence interval, - and the bivariate SRMSR (Maydeu-Olivares 2013). Every model-implied margin - (and the up-to-4th-order entries of the multinomial residual covariance - `Xi_2`) is computed exactly by the local-independence factorization over the - `(theta, xi)` node set — `pi_S = Σ_c w_c ∏_{i∈S} P_i(c)` — the same - factorization the E-step already uses (Cai-Hansen); the derivative matrix - `Delta_2` is central-differenced from the node moments and the quadratic form - is evaluated through one Cholesky of `Xi_2` (never an explicit inverse). Rust - core (`mlsirm_core::fitstats::m2_rmsea2`, kind-aware) with a NumPy reference - held to 1e-6 parity; well-specified-vs-local-dependence calibration tests in - both suites. -- **GPU EAP scoring kernel** (`mlsirm_core::gpu_marginal::score_eap_gpu`, WGSL - `score_pass`): Bock-Mislevy (1982) EAP scoring on the wgpu path, one thread - per person (race-free — each person owns its output slots, unlike the E-step - reduction), reusing the same `cell_l` binary-sparsity table decomposition. - Exposed as an **opt-in** device on `score_eap_device(..., Device::Gpu)` and - through PyO3 `score_bank_eap(..., device=...)` and - `serving.score_respondents(..., device="gpu")`; the default stays the exact - f64 CPU reduction, so precision-sensitive paths and serving parity are - unchanged. f32 kernel, GPU-vs-CPU parity ≤ 2e-3 verified on-device - (`gpu_eap_matches_cpu_reduction`); falls back to CPU with no adapter or when - `n_dims`/`latent_dim > 8`. Extends GPU offload from the E-step to the 31k- - person serving hot path (project compute policy: all math in Rust, GPU where - it pays). -- **IRT scale linking for common-item designs** (`fast_mlsirm.irt_link`; - `mlsirm_core::linking`): the moment methods (mean/mean, mean/sigma) and the - characteristic-curve methods of Haebara (1980) and Stocking & Lord (1983) for - putting a separately-calibrated new form onto the reference scale - (`theta_old = A·theta_new + B`), motivated by the mixed-format / multi-study - linking papers in the corpus (Kim & Lee 2006; Yao & Boughton 2009; Brossman & - Lee 2013). The characteristic-curve loss is minimized by a self-contained - Nelder-Mead over `(A, B)` from the mean/sigma start, integrated over a - standard-normal Gauss-Hermite grid. Rust compute path; recovery tests for all - four methods in both suites. (Complements the existing anchor-based - `link_fixed_item_parameters` and the FIPC serving path.) -- **Item screening pipeline** (`fast_mlsirm.select_items`): iterative - fit → flag → remove → refit with sparse / S-X²-BH / mean-square band / - low-discrimination / map-isolation flags, an `l_z*` person screen, a - per-dimension item floor, and a full audit trail. -- **Serving bundle + frozen-parameter scoring** (`fast_mlsirm.serving`): - schema-versioned JSON bundle of the calibrated item parameters and - population block, and `score_respondents()` EAP scoring of new response - payloads with items frozen — the fixed-parameter serving pattern used by - the downstream importance-assessment API. `fast-mlsirm score` scores a JSON - payload (or `.npy` matrix) against a bundle from the command line. - -- **QMC-EM and MC-EM integration rules** for the marginal estimator - (`FitConfig(xi_rule="qmc"|"mc", xi_points=..., xi_seed=...)`): the - latent-space integral runs on Halton low-discrepancy points (randomized-QMC - shift optional; Jank 2005) or seeded Monte Carlo draws (Wei & Tanner 1990; - Meng & Schilling 1996) instead of the tensor Gauss-Hermite grid — enabling - `latent_dim > 3` and better error scaling per node. Both constructions are - deterministic and bit-mirrored across the Rust/NumPy backends. -- **Rust scoring module** (`mlsirm_core::scoring`, exposed via - `_core.score_bank_eap` / `score_bank_map` / `eapsum_tables`): EAP - (Bock & Mislevy 1982), MAP (posterior Newton with observed-information - SEs), and summed-score EAP conversion tables via the Lord-Wingersky - recursion (Thissen et al. 1995; Cai 2015), all under per-dimension - `N(mean_d, sd_d^2)` priors that cover single, multigroup - (`mu_g, sigma_g`) and multilevel populations (conditional - `N(u_hat_c, 1)` or marginal `N(0, sqrt(1 + sigma_u^2))`). - `score_respondents(..., method="eap"|"map"|"eapsum", prior=...)` and the - bundle's embedded `eapsum_tables` expose these to serving. -- **Fit statistics moved to the Rust core** (`mlsirm_core::fitstats`): S-X², - Benjamini-Hochberg, `l_z`/`l_z*`, infit/outfit now compute in Rust - (`fast_mlsirm.fitstats` delegates; the NumPy bodies remain the parity - reference/fallback). S-X² gains the `rms_residual` practical-significance - effect size (Sinharay & Haberman 2014) and `select_items` gates its flag on - `sx2_min_effect`; the mean-square gate now uses infit only (outfit is - reported, not gating — it explodes under very low pass rates); the person - screen threshold is configurable and the Snijders `r_0` correction is - centered on the population prior mean (cluster intercepts / group means). -- **Fixed Item Parameter Calibration** (`fit(..., anchors=...)`): anchored - items stay frozen (optionally `tau` too) while new items and a freed - population mean/SD are estimated — the multiple-cycle prior-update (MWU-MEM - style) variant Kim (2006) found robust; latent-space orientation inherits - from the anchors (no PCA re-alignment). **Concurrent calibration** is the - existing multigroup path with structural missingness (Hanson & Béguin - 2002), covered by a dedicated recovery test. - -### Changed - -- `estimator="mmle"` with a spatial/multidimensional model now fits (routed to - the marginal estimator) instead of raising `NotImplementedError`; plain - `ULS2PLM`/`ULSRM` without a population structure keep the legacy - unidimensional fast path and its exact previous behavior. - - Exposed the Rust MMLE-EM estimator (`mlsirm_core::mmle::fit_mmle_2pl`) through the PyO3 binding as `fast_mlsirm._core.fit_mmle_2pl`, so `fit(estimator="mmle")` now runs on the Rust core when the extension is built diff --git a/Cargo.toml b/Cargo.toml index 9892bffc6..76de23942 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,17 +2,3 @@ resolver = "2" members = ["crates/mlsirm-core"] exclude = ["crates/fast-mlsirm-py"] - -# Repository-owned OpenCode coverage contract. Deliberately ignored statistical -# studies remain runnable with `cargo test --release -- --ignored`, but their -# test implementations are not production code and live in the repository's -# `tests/unit` tree, which llvm-cov excludes from production-source reporting. -# Every compiled production line remains in scope. -[workspace.metadata.opencode.coverage] -minimum_lines = 100 - -# The suite contains high-dimensional numerical recovery tests. Optimizing test -# binaries keeps the trusted coverage job inside its 900-second command limit -# without shrinking ordinary test data or changing production compilation. -[profile.test] -opt-level = 2 diff --git a/crates/fast-mlsirm-py/Cargo.lock b/crates/fast-mlsirm-py/Cargo.lock index ef26f71e6..958118221 100644 --- a/crates/fast-mlsirm-py/Cargo.lock +++ b/crates/fast-mlsirm-py/Cargo.lock @@ -652,9 +652,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "pollster" -version = "1.0.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "portable-atomic" diff --git a/crates/fast-mlsirm-py/src/lib.rs b/crates/fast-mlsirm-py/src/lib.rs index e62d1bd8a..f33a7d94c 100644 --- a/crates/fast-mlsirm-py/src/lib.rs +++ b/crates/fast-mlsirm-py/src/lib.rs @@ -1,144 +1,6 @@ use std::collections::HashMap; -use mlsirm_core::agreement::validate_scoring as core_validate_scoring; -use mlsirm_core::equating::{ - analytic_see as core_analytic_see, bootstrap_see as core_bootstrap_see, - equate_eg as core_equate_eg, equate_eg_ext as core_equate_eg_ext, - equate_neat as core_equate_neat, equate_neat_linear as core_equate_neat_linear, - loglinear_smooth as core_loglinear_smooth, AnchorKind, Continuization, EgSmoothOptions, - EquateMethod, EquateResult, NeatLinearMethod, NeatMethod, SeeResult, -}; -use mlsirm_core::fitstats::{ - infit_outfit as core_infit_outfit, leniency_residuals as core_leniency_residuals, - m2_rmsea2 as core_m2, person_fit as core_person_fit, poly_local_dependence as core_poly_ld, - poly_m2 as core_poly_m2, s_x2 as core_s_x2, SX2Config, -}; -use mlsirm_core::linking::{irt_link as core_irt_link, LinkMethod}; -use mlsirm_core::marginal::{ - fit_marginal_full as core_fit_marginal_full, Anchors, ItemCovariate, MarginalConfig, - PopulationSpec, XiRuleKind, -}; -use mlsirm_core::nodes::XiRule; - -use mlsirm_core::cdm::{ - fit_cdm as core_fit_cdm, fit_gdina as core_fit_gdina, fit_ho_cdm as core_fit_ho_cdm, - fit_ho_gdina as core_fit_ho_gdina, fit_seq_gdina as core_fit_seq_gdina, - fit_seq_gdina_qr as core_fit_seq_gdina_qr, gdina_wald_selection as core_gdina_wald_selection, - validate_q_matrix as core_validate_q_matrix, CdmConfig, CdmModel, -}; -use mlsirm_core::classification::{ - lee_classification as core_lee_classification, livingston_lewis as core_livingston_lewis, - rudner_classification as core_rudner_classification, ClassificationResult, -}; -use mlsirm_core::crm::fit_crm as core_fit_crm; -use mlsirm_core::detect::detect_analysis as core_detect_analysis; -use mlsirm_core::dif::{ - logistic_dif as core_logistic_dif, logistic_dif_purified as core_logistic_purified, - mantel_haenszel_dif as core_mh_dif, mantel_haenszel_dif_purified as core_mh_purified, - raju_area as core_raju_area, sibtest as core_sibtest, LogisticDifConfig, LogisticDifRow, - MhDifConfig, MhDifRow, PurifyConfig, SibtestConfig, -}; -use mlsirm_core::exposure::{ - a_stratified as core_a_stratified, ccat_select as core_ccat_select, - kl_information as core_kl_information, kl_select as core_kl_select, owen_cat as core_owen_cat, - owen_update as core_owen_update, sympson_hetter as core_sympson_hetter, AStratifiedConfig, - SympsonHetterConfig, -}; -use mlsirm_core::facets::fit_facets as core_fit_facets; -use mlsirm_core::factor::{ - glb_fa_corr as core_glb_fa_corr, glb_fa_data as core_glb_fa_data, - minres_fa_corr as core_minres_fa_corr, minres_fa_data as core_minres_fa_data, - omega_total_1f_corr as core_omega_total_1f_corr, - omega_total_1f_data as core_omega_total_1f_data, velicer_map_corr as core_velicer_map_corr, - velicer_map_data as core_velicer_map_data, MinresFaResult, -}; -use mlsirm_core::fitstats::{ - adjusted_chi2_pairs as core_adjusted_chi2_pairs, - person_fit_resampling as core_person_fit_resampling, - residual_item_fit as core_residual_item_fit, tcc_drift as core_tcc_drift, -}; -use mlsirm_core::gpcm::{fit_gpcm as core_fit_gpcm, GpcmConfig}; -use mlsirm_core::grm::{fit_grm as core_fit_grm, GrmConfig}; -use mlsirm_core::gtheory::{ - gtheory_pi as core_gtheory_pi, gtheory_pio as core_gtheory_pio, GTheoryDStudyRow, -}; -use mlsirm_core::ksirt::{ksirt as core_ksirt, KsirtKernel}; -use mlsirm_core::lltm::{fit_lltm as core_fit_lltm, LltmConfig}; -use mlsirm_core::mhrm::{fit_mhrm as core_fit_mhrm, MhrmConfig, MhrmModel}; -use mlsirm_core::mixed::{fit_mixed_items as core_fit_mixed_items, MixedItemKind, MixedItemSpec}; -use mlsirm_core::mixture::{fit_mixture as core_fit_mixture, MixtureConfig, MixtureModel}; use mlsirm_core::mmle::{fit_mmle_2pl as core_fit_mmle_2pl, MmleConfig}; -use mlsirm_core::mokken::{aisp as core_mokken_aisp, coef_h as core_mokken_coef_h}; -use mlsirm_core::nominal::{fit_nominal as core_fit_nominal_model, NominalConfig}; -use mlsirm_core::parallel::parallel_analysis as core_parallel_analysis; -use mlsirm_core::poly::{ - fit_nominal as core_fit_nominal, fit_poly_unidim as core_fit_poly_unidim, - gpcm_logprobs as core_gpcm_logprobs, grm_logprobs as core_grm_logprobs, - poly_cat_simulate as core_poly_cat_simulate, poly_dif_sweep as core_poly_dif, - poly_information_curves as core_poly_information_curves, - poly_person_fit as core_poly_person_fit, poly_s_x2 as core_poly_s_x2, - score_poly_eap as core_score_poly_eap, u3_poly_bootstrap_cutoff as core_u3_poly_cutoff, - u3_poly_person_fit as core_u3_poly_person_fit, PolyModel, -}; -use mlsirm_core::poly_marginal::fit_poly_lsirm as core_fit_poly_lsirm; -use mlsirm_core::rasch_cml::{ - andersen_lr_test as core_andersen_lr, fit_rasch_cml as core_fit_rasch_cml, -}; -use mlsirm_core::reliability::guttman_lambdas as core_guttman_lambdas; -use mlsirm_core::reliability::tenberge_mu as core_tenberge_mu; -use mlsirm_core::reliability::{ - cronbach_alpha as core_cronbach_alpha, feldt_alpha_ci as core_feldt_alpha_ci, - separation_reliability as core_separation_reliability, -}; -use mlsirm_core::rsm::fit_rsm as core_fit_rsm; -use mlsirm_core::rt::{ - fit_rt_lognormal as core_fit_rt, rt_person_fit as core_rt_person_fit, RtConfig, -}; -use mlsirm_core::rt_joint::{fit_speed_accuracy_covariance as core_fit_sa, SpeedAccuracyConfig}; -use mlsirm_core::scoring::{ - bank_information_device as core_bank_information_device, - cat_next_item_device as core_cat_next_item_device, - eapsum_tables_device as core_eapsum_tables_device, - empirical_reliability_device as core_empirical_reliability_device, - plausible_values_device as core_plausible_values_device, - score_eap_device as core_score_eap_device, score_eapsum_device as core_score_eapsum_device, - score_map as core_score_map, score_wle as core_score_wle, - score_wle_poly as core_score_wle_poly, EapSumTable, ItemBank, PriorSpec, -}; -use mlsirm_core::subscores::subscores as core_subscores; -use mlsirm_core::testlet::{fit_testlet as core_fit_testlet, TestletConfig, TestletModel}; -use mlsirm_core::twopl::{fit_2pl as core_fit_2pl, TwoPlConfig}; -use mlsirm_core::utility::{ - selection_utility as core_selection_utility, taylor_russell as core_taylor_russell, -}; - -fn parse_poly_model(model: &str) -> PyResult { - match model.to_lowercase().as_str() { - "grm" => Ok(PolyModel::Grm), - "gpcm" => Ok(PolyModel::Gpcm), - other => Err(PyValueError::new_err(format!( - "model must be grm or gpcm, got {other}" - ))), - } -} - -fn poly_responses(y: &[i64], observed: Option<&[bool]>, n_cat: usize) -> PyResult> { - if observed.is_some_and(|o| o.len() != y.len()) { - return Err(PyValueError::new_err( - "observed must have the same length as responses", - )); - } - let mut yv = Vec::with_capacity(y.len()); - for (idx, &v) in y.iter().enumerate() { - if observed.map_or(true, |o| o[idx]) && (v < 0 || v as usize >= n_cat) { - return Err(PyValueError::new_err( - "observed responses must be integer categories in 0..n_cat-1", - )); - } - yv.push(if v < 0 { 0 } else { v as usize }); - } - Ok(yv) -} use mlsirm_core::{ neg_loglik_and_grad_device as core_neg_loglik_and_grad_device, Device, ModelConfig, ModelType, Params, PenaltyConfig, @@ -297,5976 +159,16 @@ fn fit_mmle_2pl( "y and observed must both have length n_persons * n_items", )); } - let cfg = MmleConfig { - max_iter, - tol, - ..MmleConfig::default() - }; + let cfg = MmleConfig { max_iter, tol, ..MmleConfig::default() }; let res = core_fit_mmle_2pl(y_slice, observed_slice, n_persons, n_items, &cfg); Ok((res.a, res.b, res.theta, res.loglik_trace, res.converged)) } -/// Marginal-EM fit of a DINA/DINO cognitive diagnosis model (`mlsirm_core::cdm`). -/// `y`/`observed` are row-major `n_persons * n_items`; `q_matrix` is row-major -/// `n_items * n_attributes` with 0/1 entries; `model` is "dina" or "dino". Returns -/// a dict with `slip`, `guess`, `profile_prob` (`2^K`), `map_profile` (bit-encoded, -/// per person), `attr_prob` (`n_persons * n_attributes`), `loglik_trace`, `n_iter`, -/// `converged` and `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, q_matrix, n_persons, n_items, n_attributes, model = "dina", max_iter = 500, tol = 1e-6))] -fn fit_cdm( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - q_matrix: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_attributes: usize, - model: &str, - max_iter: usize, - tol: f64, -) -> PyResult> { - let gate = match model { - "dina" | "DINA" => CdmModel::Dina, - "dino" | "DINO" => CdmModel::Dino, - other => { - return Err(PyValueError::new_err(format!( - "model must be 'dina' or 'dino'; got {other}" - ))) - } - }; - let q: Vec = q_matrix - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("q_matrix entries must be 0 or 1")), - }) - .collect::>()?; - let cfg = CdmConfig { - max_iter, - tol, - ..CdmConfig::default() - }; - let res = core_fit_cdm( - y.as_slice()?, - observed.as_slice()?, - &q, - n_persons, - n_items, - n_attributes, - gate, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("model", model)?; - out.set_item("slip", res.slip)?; - out.set_item("guess", res.guess)?; - out.set_item("profile_prob", res.profile_prob)?; - out.set_item("map_profile", res.map_profile)?; - out.set_item("attr_prob", res.attr_prob)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Marginal-EM fit of the saturated G-DINA model (`mlsirm_core::cdm::fit_gdina`). -/// `y`/`observed` are row-major `n_persons * n_items`; `q_matrix` is row-major -/// `n_items * n_attributes` with 0/1 entries. Item parameters are ragged (CSR): item -/// `i` owns `item_prob`/`item_delta` slice `[item_off[i]..item_off[i+1])` of width -/// `2^{K_i}`. Returns a dict with `item_off`, `item_prob`, `item_delta`, `k_required`, -/// `profile_prob`, `map_profile`, `attr_prob`, `loglik_trace`, `n_iter`, `converged`, -/// `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, q_matrix, n_persons, n_items, n_attributes, max_iter = 500, tol = 1e-6))] -fn fit_gdina( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - q_matrix: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_attributes: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let q: Vec = q_matrix - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("q_matrix entries must be 0 or 1")), - }) - .collect::>()?; - let cfg = CdmConfig { - max_iter, - tol, - ..CdmConfig::default() - }; - let res = core_fit_gdina( - y.as_slice()?, - observed.as_slice()?, - &q, - n_persons, - n_items, - n_attributes, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("item_off", res.item_off)?; - out.set_item("item_prob", res.item_prob)?; - out.set_item("item_delta", res.item_delta)?; - out.set_item("k_required", res.k_required)?; - out.set_item("profile_prob", res.profile_prob)?; - out.set_item("map_profile", res.map_profile)?; - out.set_item("attr_prob", res.attr_prob)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Shared-Q sequential (continuation-ratio) G-DINA for ordered polytomous responses -/// (Ma & de la Torre, 2016; `mlsirm_core::cdm::fit_seq_gdina`). Every step of an item is a -/// saturated G-DINA over the SAME required attributes (Q row `i`); this is a restriction of -/// the general per-step `q_ik` model — step-distinct attribute requirements are a deferred -/// non-goal, so supply each item's Q-vector as the UNION of its steps' required attributes. -/// `y` holds ordered -/// integer categories `0..=M_i` where observed (`M_i` = max observed category per item); -/// `observed`/`q_matrix` are row-major `n_persons*n_items` / `n_items*n_attributes`. -/// Item parameters are ragged, CLASS-MAJOR CSR: item `i` owns `step_prob` slice -/// `[s_off[i]..s_off[i+1])` (`s_ik(l)` at `s_off[i] + l*M_i + (k-1)`) and `cat_prob` -/// slice `[cat_off[i]..cat_off[i+1])` (`P(X_i=x|l)` at `cat_off[i] + l*(M_i+1) + x`). -/// Returns a dict with `s_off`, `step_prob`, `cat_off`, `cat_prob`, `max_cat`, -/// `k_required`, `profile_prob`, `map_profile`, `attr_prob`, `loglik_trace`, `n_iter`, -/// `converged`, `termination_reason`, `final_loglik_change`, -/// `final_relative_loglik_change`, `stopping_tolerance`, `n_parameters`. -/// Per-step-Q sequential G-DINA (Ma & de la Torre, 2016; `mlsirm_core::cdm::fit_seq_gdina_qr`), -/// the full restricted-Q model where each ordered STEP has its own attribute requirement. -/// `step_q` is row-major `(sum_i n_steps[i]) * n_attributes` (0/1); `n_steps[i] = M_i` (the step -/// count, which must equal item `i`'s maximum observed category), so step `k` of item `i` is row -/// `step_off[i] + (k-1)` with `step_off = cumsum(n_steps)`. Generalizes `fit_seq_gdina` (which is -/// this with every step sharing the item's Q). Step probs are STEP-ROW-major: -/// `step_prob[spo[step_off[i]+(k-1)] + l]` (`l` the reduced class under `q_ik`, width -/// `2^{|q_ik|}`); category probs are union-class-major: -/// `cat_prob[cat_off[i] + uc*(M_i+1) + x]` over the item's union `2^{K^u_i}` classes. Returns a -/// dict with `step_off`, `spo`, `step_prob`, `step_kq`, `cat_off`, `cat_prob`, `max_cat`, -/// `union_k`, `profile_prob`, `map_profile`, `attr_prob`, `loglik_trace`, `n_iter`, `converged`, -/// `termination_reason`, `final_loglik_change`, `final_relative_loglik_change`, -/// `stopping_tolerance`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, step_q, n_steps, n_persons, n_items, n_attributes, max_iter = 500, tol = 1e-6))] -fn fit_seq_gdina_qr( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - step_q: PyReadonlyArray1<'_, i64>, - n_steps: Vec, - n_persons: usize, - n_items: usize, - n_attributes: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let sq: Vec = step_q - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("step_q entries must be 0 or 1")), - }) - .collect::>()?; - let cfg = CdmConfig { - max_iter, - tol, - ..CdmConfig::default() - }; - let res = core_fit_seq_gdina_qr( - y.as_slice()?, - observed.as_slice()?, - &sq, - &n_steps, - n_persons, - n_items, - n_attributes, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("step_off", res.step_off)?; - out.set_item("spo", res.spo)?; - out.set_item("step_prob", res.step_prob)?; - out.set_item("step_kq", res.step_kq)?; - out.set_item("cat_off", res.cat_off)?; - out.set_item("cat_prob", res.cat_prob)?; - out.set_item("max_cat", res.max_cat)?; - out.set_item("union_k", res.union_k)?; - out.set_item("profile_prob", res.profile_prob)?; - out.set_item("map_profile", res.map_profile)?; - out.set_item("attr_prob", res.attr_prob)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item( - "final_relative_loglik_change", - res.final_relative_loglik_change, - )?; - out.set_item("stopping_tolerance", res.stopping_tolerance)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, q_matrix, n_persons, n_items, n_attributes, max_iter = 500, tol = 1e-6))] -fn fit_seq_gdina( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - q_matrix: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_attributes: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let q: Vec = q_matrix - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("q_matrix entries must be 0 or 1")), - }) - .collect::>()?; - let cfg = CdmConfig { - max_iter, - tol, - ..CdmConfig::default() - }; - let res = core_fit_seq_gdina( - y.as_slice()?, - observed.as_slice()?, - &q, - n_persons, - n_items, - n_attributes, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("s_off", res.s_off)?; - out.set_item("step_prob", res.step_prob)?; - out.set_item("cat_off", res.cat_off)?; - out.set_item("cat_prob", res.cat_prob)?; - out.set_item("max_cat", res.max_cat)?; - out.set_item("k_required", res.k_required)?; - out.set_item("profile_prob", res.profile_prob)?; - out.set_item("map_profile", res.map_profile)?; - out.set_item("attr_prob", res.attr_prob)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item( - "final_relative_loglik_change", - res.final_relative_loglik_change, - )?; - out.set_item("stopping_tolerance", res.stopping_tolerance)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Empirical Q-matrix validation by the PVAF method (de la Torre & Chiu, 2016; -/// `mlsirm_core::cdm::validate_q_matrix`). `y`/`observed` are row-major -/// `n_persons * n_items`; `provisional_q` is row-major `n_items * n_attributes` -/// with 0/1 entries, each item loading at least one attribute. `epsilon` is the -/// PVAF cutoff (0.95 typical). Returns a dict with `suggested_q` (row-major -/// `n_items * n_attributes`), `suggested_pvaf`, `provisional_pvaf`, `flagged`, -/// `n_attributes`, `epsilon`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, provisional_q, n_persons, n_items, n_attributes, epsilon = 0.95, max_iter = 500, tol = 1e-6))] -fn validate_q_matrix( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - provisional_q: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_attributes: usize, - epsilon: f64, - max_iter: usize, - tol: f64, -) -> PyResult> { - let q: Vec = provisional_q - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err( - "provisional_q entries must be 0 or 1", - )), - }) - .collect::>()?; - let cfg = CdmConfig { - max_iter, - tol, - ..CdmConfig::default() - }; - let res = core_validate_q_matrix( - y.as_slice()?, - observed.as_slice()?, - &q, - n_persons, - n_items, - n_attributes, - epsilon, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - let suggested: Vec = res.suggested_q.iter().map(|&v| v as i64).collect(); - out.set_item("suggested_q", suggested)?; - out.set_item("suggested_pvaf", res.suggested_pvaf)?; - out.set_item("provisional_pvaf", res.provisional_pvaf)?; - out.set_item("flagged", res.flagged)?; - out.set_item("n_attributes", res.n_attributes)?; - out.set_item("epsilon", res.epsilon)?; - out.set_item("calibration_n_iter", res.calibration_n_iter)?; - out.set_item("calibration_max_iter", res.calibration_max_iter)?; - out.set_item( - "calibration_termination_reason", - res.calibration_termination_reason, - )?; - out.set_item( - "calibration_final_loglik_change", - res.calibration_final_loglik_change, - )?; - out.set_item("calibration_tol", res.calibration_tol)?; - Ok(out.into()) -} - -/// Item-level CDM model selection by the Wald test (de la Torre & Lee, 2013; -/// `mlsirm_core::cdm::gdina_wald_selection`). `y`/`observed` are row-major -/// `n_persons * n_items`; `q_matrix` row-major `n_items * n_attributes` (0/1). -/// Each item's saturated G-DINA is Wald-tested against the reduced DINA, DINO, and -/// A-CDM models; `alpha` is the test level. Returns a dict with `models` (candidate -/// names), `wald_stat`/`wald_df`/`p_value` (row-major `n_items * n_models`), -/// `selected` (per item: model index or -1 for the saturated G-DINA), `alpha`. -/// A nonconverged saturated calibration raises `ValueError`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, q_matrix, n_persons, n_items, n_attributes, alpha = 0.05, max_iter = 500, tol = 1e-6))] -fn gdina_wald_selection( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - q_matrix: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_attributes: usize, - alpha: f64, - max_iter: usize, - tol: f64, -) -> PyResult> { - let q: Vec = q_matrix - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("q_matrix entries must be 0 or 1")), - }) - .collect::>()?; - let cfg = CdmConfig { - max_iter, - tol, - ..CdmConfig::default() - }; - let res = core_gdina_wald_selection( - y.as_slice()?, - observed.as_slice()?, - &q, - n_persons, - n_items, - n_attributes, - alpha, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("models", res.models)?; - out.set_item("wald_stat", res.wald_stat)?; - out.set_item("wald_df", res.wald_df)?; - out.set_item("p_value", res.p_value)?; - out.set_item("selected", res.selected)?; - out.set_item("alpha", res.alpha)?; - Ok(out.into()) -} - -/// Higher-order DINA/DINO fit (de la Torre & Douglas, 2004; -/// `mlsirm_core::cdm::fit_ho_cdm`). `y`/`observed` are row-major `n_persons * -/// n_items`; `q_matrix` row-major `n_items * n_attributes` (0/1); `model` is "dina" -/// or "dino". Attribute mastery is structured by a continuous trait -/// `theta ~ N(0,1)`, `P(alpha_k=1|theta)=sigmoid(attr_slope_k*theta+attr_intercept_k)`. -/// Returns a dict with `model`, `slip`, `guess`, `attr_slope` (K), `attr_intercept` -/// (K), `profile_prob` (implied, 2^K), `theta` (N), `map_profile`, `attr_prob` -/// (`N*K`), `loglik_trace`, `n_iter`, `converged`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, q_matrix, n_persons, n_items, n_attributes, model = "dina", max_iter = 500, tol = 1e-6))] -fn fit_ho_cdm( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - q_matrix: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_attributes: usize, - model: &str, - max_iter: usize, - tol: f64, -) -> PyResult> { - let gate = match model { - "dina" | "DINA" => CdmModel::Dina, - "dino" | "DINO" => CdmModel::Dino, - other => { - return Err(PyValueError::new_err(format!( - "model must be 'dina' or 'dino'; got {other}" - ))) - } - }; - let q: Vec = q_matrix - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("q_matrix entries must be 0 or 1")), - }) - .collect::>()?; - let cfg = CdmConfig { - max_iter, - tol, - ..CdmConfig::default() - }; - let res = core_fit_ho_cdm( - y.as_slice()?, - observed.as_slice()?, - &q, - n_persons, - n_items, - n_attributes, - gate, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("model", model)?; - out.set_item("slip", res.slip)?; - out.set_item("guess", res.guess)?; - out.set_item("attr_slope", res.attr_slope)?; - out.set_item("attr_intercept", res.attr_intercept)?; - out.set_item("profile_prob", res.profile_prob)?; - out.set_item("theta", res.theta)?; - out.set_item("map_profile", res.map_profile)?; - out.set_item("attr_prob", res.attr_prob)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("max_iter", res.max_iter)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item("stopping_tolerance", res.stopping_tolerance)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Higher-order G-DINA fit (de la Torre & Douglas, 2004 x de la Torre, 2011; -/// `mlsirm_core::cdm::fit_ho_gdina`). The saturated G-DINA item model under a -/// higher-order structural attribute prior `theta ~ N(0,1)`. `y`/`observed` are -/// row-major `n_persons * n_items` (0/1); `q_matrix` row-major `n_items * -/// n_attributes` (0/1). Returns a dict with the ragged CSR `item_off`, `item_prob`, -/// `item_delta`, `k_required`; `attr_slope`/`attr_intercept` (K); `profile_prob` -/// (implied, 2^K); `theta`; `map_profile`; `attr_prob` (`N*K`); `loglik_trace`, -/// `n_iter`, `converged`, `termination_reason`, `final_loglik_change`, -/// `final_relative_loglik_change`, `stopping_tolerance`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, q_matrix, n_persons, n_items, n_attributes, max_iter = 500, tol = 1e-6))] -fn fit_ho_gdina( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - q_matrix: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_attributes: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let q: Vec = q_matrix - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("q_matrix entries must be 0 or 1")), - }) - .collect::>()?; - let cfg = CdmConfig { - max_iter, - tol, - ..CdmConfig::default() - }; - let res = core_fit_ho_gdina( - y.as_slice()?, - observed.as_slice()?, - &q, - n_persons, - n_items, - n_attributes, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("item_off", res.item_off)?; - out.set_item("item_prob", res.item_prob)?; - out.set_item("item_delta", res.item_delta)?; - out.set_item("k_required", res.k_required)?; - out.set_item("attr_slope", res.attr_slope)?; - out.set_item("attr_intercept", res.attr_intercept)?; - out.set_item("profile_prob", res.profile_prob)?; - out.set_item("theta", res.theta)?; - out.set_item("map_profile", res.map_profile)?; - out.set_item("attr_prob", res.attr_prob)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item( - "final_relative_loglik_change", - res.final_relative_loglik_change, - )?; - out.set_item("stopping_tolerance", res.stopping_tolerance)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Confirmatory compensatory multidimensional 2PL (Reckase, 2009; Bock, Gibbons, & Muraki, -/// 1988; `mlsirm_core::twopl::fit_2pl`). Each item may load FREELY on several -/// latent dimensions `theta ~ MVN(0, Sigma)` that trade off additively in the logit: -/// `P(X=1) = sigmoid(sum_d L_id a_id theta_d + b_i)`. `loading_pattern` is a row-major -/// `n_items * n_dims` 0/1 pattern; each dimension needs a pure single-loading anchor item -/// (identification; the all-ones pattern is rejected). With `estimate_corr = False` the -/// factors are ORTHOGONAL (`Sigma = I`); with `estimate_corr = True` the inter-factor -/// correlation matrix is estimated (Cholesky node-map + a monotone ECM step). `node_rule` picks -/// the E-step quadrature: `"gh"` (Gauss-Hermite product grid, `n_dims <= 3`) or `"qmc"`/`"mc"` -/// (Halton QMC / Monte-Carlo with `xi_points` prior draws, `n_dims <= 6`; Jank, 2005). `q` -/// applies to `"gh"` only; `xi_points`/`xi_seed` to `"qmc"`/`"mc"` only. Returns a dict -/// with `loading` (row-major `n_items * n_dims`, `0` off-pattern), `intercept`, `theta` -/// (`n_persons * n_dims` EAP), `n_dims`, `corr` (row-major `n_dims * n_dims`, identity when not -/// estimated), `loglik_trace`, `n_iter`, `converged`, `termination_reason`, -/// `final_loglik_change`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, loading_pattern, n_persons, n_items, n_dims, q = 21, estimate_corr = false, max_iter = 500, tol = 1e-6, node_rule = "gh", xi_points = 4000, xi_seed = 0x9E37_79B9_7F4A_7C15))] -fn fit_2pl( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - loading_pattern: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_dims: usize, - q: usize, - estimate_corr: bool, - max_iter: usize, - tol: f64, - node_rule: &str, - xi_points: usize, - xi_seed: u64, -) -> PyResult> { - let pattern: Vec = loading_pattern - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err( - "loading_pattern entries must be 0 or 1", - )), - }) - .collect::>()?; - // `node_rule`: "gh" (Gauss-Hermite product grid, D<=3) or "qmc"/"mc" (Halton/Monte-Carlo, - // D<=6). q applies only to "gh"; xi_points/xi_seed only to the QMC/MC rules. - let xi_rule = XiRuleKind::parse(node_rule) - .ok_or_else(|| PyValueError::new_err("node_rule must be one of ['gh', 'qmc', 'mc']"))?; - let cfg = TwoPlConfig { - max_iter, - tol, - q, - estimate_corr, - xi_rule, - xi_points, - xi_seed, - ..TwoPlConfig::default() - }; - let res = core_fit_2pl( - y.as_slice()?, - observed.as_slice()?, - &pattern, - n_persons, - n_items, - n_dims, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("loading", res.loading)?; - out.set_item("intercept", res.intercept)?; - out.set_item("theta", res.theta)?; - out.set_item("n_dims", res.n_dims)?; - out.set_item("corr", res.corr)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Confirmatory MULTIDIMENSIONAL 2PL by Metropolis-Hastings Robbins-Monro (Cai, 2010; -/// `mlsirm_core::mhrm::fit_mhrm`). A STOCHASTIC-approximation EM that scales confirmatory item factor -/// analysis to a latent dimensionality where the deterministic Gauss-Hermite / QMC E-steps of -/// `fit_2pl` are infeasible: each cycle imputes `theta` by a short persistent random-walk Metropolis -/// chain, then takes one Robbins-Monro stochastic-Newton step on the block-diagonal (per-item) -/// complete-data score/information. Orthogonal factors (`Sigma = I`). `y` is a row-major -/// `n_persons * n_items` binary array; `observed` an optional bool mask (missing dropped MAR). -/// The response family is chosen by `model`: `"2pl"` (binary, DEFAULT) or `"gpcm"` (ordered -/// polytomous generalized partial credit model, Muraki, 1992, with `n_cat` categories `0..n_cat`). -/// For GPCM `base_i = sum_d a_id theta_d` (NO intercept) and `P(Y=k) = softmax_k(k*base_i + step_ik)` -/// with `n_cat - 1` free UNORDERED step intercepts per item, estimated by the SAME MH-RM machinery -/// with the closed-form multinomial Hessian as the Robbins-Monro preconditioner and Louis -/// information. Returns a dict with `loading` (row-major `n_items * n_dims`, `0` off-pattern, -/// reflection-canonicalized), `intercept` (`n_items`; EMPTY for GPCM), `step` (row-major -/// `n_items * (n_cat - 1)` GPCM step intercepts; EMPTY for the 2PL), `n_cat`, `theta` -/// (`n_persons * n_dims` trait EAP), `n_dims`, `corr`, `se_loading`/`se_intercept`/`se_step` (Louis -/// observed-information SEs; empty when `estimate_se = false`), `acceptance_rate`, `n_cycles`, -/// `converged`, `termination_reason`, `final_param_change`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, loading_pattern, n_persons, n_items, n_dims, max_cycles = 2000, burn_in = 200, mh_steps = 5, proposal_sd = 1.0, target_accept = 0.30, tol = 1e-3, seed = 0x9E37_79B9_7F4A_7C15, estimate_se = true, estimate_corr = false, model = "2pl", n_cat = 2))] -fn fit_mhrm( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - observed: Option>, - loading_pattern: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_dims: usize, - max_cycles: usize, - burn_in: usize, - mh_steps: usize, - proposal_sd: f64, - target_accept: f64, - tol: f64, - seed: u64, - estimate_se: bool, - estimate_corr: bool, - model: &str, - n_cat: usize, -) -> PyResult> { - let model_kind = match model { - "2pl" | "2PL" => MhrmModel::TwoPl, - "gpcm" | "GPCM" => { - if n_cat < 2 { - return Err(PyValueError::new_err("n_cat must be >= 2 for the GPCM")); - } - MhrmModel::Gpcm { n_cat } - } - other => { - return Err(PyValueError::new_err(format!( - "model must be '2pl' or 'gpcm', got '{other}'" - ))) - } - }; - let yy: Vec = y - .as_slice()? - .iter() - .map(|&v| { - usize::try_from(v) - .map_err(|_| PyValueError::new_err("y responses must be non-negative")) - }) - .collect::>()?; - let pattern: Vec = loading_pattern - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err( - "loading_pattern entries must be 0 or 1", - )), - }) - .collect::>()?; - let obs_vec: Option> = match &observed { - Some(o) => Some(o.as_slice()?.to_vec()), - None => None, - }; - let cfg = MhrmConfig { - max_cycles, - burn_in, - mh_steps, - proposal_sd, - target_accept, - tol, - seed, - estimate_se, - estimate_corr, - model: model_kind, - ..MhrmConfig::default() - }; - let res = core_fit_mhrm( - &yy, - obs_vec.as_deref(), - &pattern, - n_persons, - n_items, - n_dims, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("loading", res.loading)?; - out.set_item("intercept", res.intercept)?; - out.set_item("step", res.step)?; - out.set_item("n_cat", res.n_cat)?; - out.set_item("theta", res.theta)?; - out.set_item("n_dims", res.n_dims)?; - out.set_item("corr", res.corr)?; - out.set_item("se_loading", res.se_loading)?; - out.set_item("se_intercept", res.se_intercept)?; - out.set_item("se_step", res.se_step)?; - out.set_item("acceptance_rate", res.acceptance_rate)?; - out.set_item("n_cycles", res.n_cycles)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_param_change", res.final_param_change)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Confirmatory MULTIDIMENSIONAL nominal response model (Bock, 1972; Thissen, Cai, & Bock, 2010; -/// `mlsirm_core::nominal::fit_nominal`). Each item's `n_cat` UNORDERED categories get a -/// free multidimensional discrimination `a_ikd` (free on the confirmatory `loading_pattern`, items x -/// n_dims 0/1) and intercept `c_ik`, with the baseline category `0` pinned to `0`: -/// `P(Y=k|theta) = softmax_k(sum_d a_ikd theta_d + c_ik)`, `theta ~ MVN(0, I)`. Reduces to -/// `fit_nominal` at `n_dims = 1`. `node_rule` picks the E-step quadrature: `"gh"` (`n_dims <= 3`) or -/// `"qmc"`/`"mc"` (Halton/Monte-Carlo, `n_dims <= 6`). `y` is a row-major `n_persons * n_items` -/// integer category array; `observed` an optional bool mask (missing dropped MAR). Returns a dict -/// with `slope` (row-major `n_items * n_cat * n_dims`, baseline/off-pattern `0`), `intercept` -/// (`n_items * n_cat`), `theta` (`n_persons * n_dims` EAP), `n_dims`, `n_cat`, `loglik_trace`, -/// `n_iter`, `converged`, `termination_reason`, `final_loglik_change`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, loading_pattern, n_persons, n_items, n_dims, n_cat, q = 21, max_iter = 500, tol = 1e-6, node_rule = "gh", xi_points = 4000, xi_seed = 0x9E37_79B9_7F4A_7C15))] -fn fit_nominal_model( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - observed: Option>, - loading_pattern: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - q: usize, - max_iter: usize, - tol: f64, - node_rule: &str, - xi_points: usize, - xi_seed: u64, -) -> PyResult> { - let yy: Vec = y - .as_slice()? - .iter() - .map(|&v| { - usize::try_from(v) - .map_err(|_| PyValueError::new_err("y categories must be non-negative")) - }) - .collect::>()?; - let pattern: Vec = loading_pattern - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err( - "loading_pattern entries must be 0 or 1", - )), - }) - .collect::>()?; - let obs_vec: Option> = match &observed { - Some(o) => Some(o.as_slice()?.to_vec()), - None => None, - }; - let xi_rule = XiRuleKind::parse(node_rule) - .ok_or_else(|| PyValueError::new_err("node_rule must be one of ['gh', 'qmc', 'mc']"))?; - let cfg = NominalConfig { - max_iter, - tol, - q, - xi_rule, - xi_points, - xi_seed, - ..NominalConfig::default() - }; - let res = core_fit_nominal_model( - &yy, - obs_vec.as_deref(), - &pattern, - n_persons, - n_items, - n_dims, - n_cat, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("slope", res.slope)?; - out.set_item("intercept", res.intercept)?; - out.set_item("theta", res.theta)?; - out.set_item("n_dims", res.n_dims)?; - out.set_item("n_cat", res.n_cat)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Confirmatory MULTIDIMENSIONAL graded response model (Samejima, 1969; Muraki & Carlson, 1995; -/// `mlsirm_core::grm::fit_grm`). Each item's `n_cat` ORDERED categories share a SINGLE -/// multidimensional discrimination `a_i` (free on the confirmatory `loading_pattern`, items x -/// n_dims 0/1) and have `n_cat-1` ORDERED boundary intercepts `beta_i`: -/// `P(Y>=k|theta) = sigmoid(sum_d a_id theta_d + beta_i,{k-1})`, `theta ~ MVN(0, I)`. Reduces to -/// `fit_poly_unidim(GRM)` at `n_dims = 1`. `node_rule` picks the E-step quadrature: `"gh"` -/// (`n_dims <= 3`) or `"qmc"`/`"mc"` (Halton/Monte-Carlo, `n_dims <= 6`). `y` is a row-major -/// `n_persons * n_items` integer-category array; `observed` an optional bool mask (missing dropped -/// MAR). Returns a dict with `slope` (row-major `n_items * n_dims`, `0` off-pattern, -/// reflection-canonicalized), `threshold` (`n_items * (n_cat-1)`, strictly decreasing per item), -/// `theta` (`n_persons * n_dims` EAP), `n_dims`, `n_cat`, `loglik_trace`, `n_iter`, `converged`, -/// `termination_reason`, `final_loglik_change`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, loading_pattern, n_persons, n_items, n_dims, n_cat, q = 21, max_iter = 500, tol = 1e-6, node_rule = "gh", xi_points = 4000, xi_seed = 0x9E37_79B9_7F4A_7C15))] -fn fit_grm( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - observed: Option>, - loading_pattern: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - q: usize, - max_iter: usize, - tol: f64, - node_rule: &str, - xi_points: usize, - xi_seed: u64, -) -> PyResult> { - let yy: Vec = y - .as_slice()? - .iter() - .map(|&v| { - usize::try_from(v) - .map_err(|_| PyValueError::new_err("y categories must be non-negative")) - }) - .collect::>()?; - let pattern: Vec = loading_pattern - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err( - "loading_pattern entries must be 0 or 1", - )), - }) - .collect::>()?; - let obs_vec: Option> = match &observed { - Some(o) => Some(o.as_slice()?.to_vec()), - None => None, - }; - let xi_rule = XiRuleKind::parse(node_rule) - .ok_or_else(|| PyValueError::new_err("node_rule must be one of ['gh', 'qmc', 'mc']"))?; - let cfg = GrmConfig { - max_iter, - tol, - q, - xi_rule, - xi_points, - xi_seed, - ..GrmConfig::default() - }; - let res = core_fit_grm( - &yy, - obs_vec.as_deref(), - &pattern, - n_persons, - n_items, - n_dims, - n_cat, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("slope", res.slope)?; - out.set_item("threshold", res.threshold)?; - out.set_item("theta", res.theta)?; - out.set_item("n_dims", res.n_dims)?; - out.set_item("n_cat", res.n_cat)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Confirmatory MULTIDIMENSIONAL generalized partial credit model fit (Muraki, 1992; -/// `mlsirm_core::gpcm::fit_gpcm`). Ordered polytomous categories with a SINGLE discrimination -/// vector per item and INTEGER category scores: `P(Y = k | theta) = softmax_k(k * sum_d a_id -/// theta_d + step_ik)`, `theta ~ MVN(0, I)`. The `n_cat-1` step intercepts are UNORDERED (the -/// softmax is finite for any values). `node_rule` selects the E-step grid: `"gh"` (Gauss-Hermite, -/// `n_dims <= 3`) or `"qmc"`/`"mc"` (Halton/Monte-Carlo, `n_dims <= 6`). `y` is a row-major -/// `n_persons * n_items` integer-category array; `observed` an optional bool mask (missing dropped -/// MAR). Returns a dict with `slope` (row-major `n_items * n_dims`, `0` off-pattern, -/// reflection-canonicalized), `step` (`n_items * (n_cat-1)`, unordered), `theta` -/// (`n_persons * n_dims` EAP), `n_dims`, `n_cat`, `loglik_trace`, `n_iter`, `converged`, -/// `termination_reason`, `final_loglik_change`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, loading_pattern, n_persons, n_items, n_dims, n_cat, q = 21, max_iter = 500, tol = 1e-6, node_rule = "gh", xi_points = 4000, xi_seed = 0x9E37_79B9_7F4A_7C15))] -fn fit_gpcm( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - observed: Option>, - loading_pattern: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - q: usize, - max_iter: usize, - tol: f64, - node_rule: &str, - xi_points: usize, - xi_seed: u64, -) -> PyResult> { - let yy: Vec = y - .as_slice()? - .iter() - .map(|&v| { - usize::try_from(v) - .map_err(|_| PyValueError::new_err("y categories must be non-negative")) - }) - .collect::>()?; - let pattern: Vec = loading_pattern - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err( - "loading_pattern entries must be 0 or 1", - )), - }) - .collect::>()?; - let obs_vec: Option> = match &observed { - Some(o) => Some(o.as_slice()?.to_vec()), - None => None, - }; - let xi_rule = XiRuleKind::parse(node_rule) - .ok_or_else(|| PyValueError::new_err("node_rule must be one of ['gh', 'qmc', 'mc']"))?; - let cfg = GpcmConfig { - max_iter, - tol, - q, - xi_rule, - xi_points, - xi_seed, - ..GpcmConfig::default() - }; - let res = core_fit_gpcm( - &yy, - obs_vec.as_deref(), - &pattern, - n_persons, - n_items, - n_dims, - n_cat, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("slope", res.slope)?; - out.set_item("step", res.step)?; - out.set_item("theta", res.theta)?; - out.set_item("n_dims", res.n_dims)?; - out.set_item("n_cat", res.n_cat)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (responses, observed, n_persons, n_items, q_theta = 41, max_iter = 500, tol = 1e-6))] -fn fit_crm( - py: Python<'_>, - responses: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - n_items: usize, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let res = core_fit_crm( - responses.as_slice()?, - observed.as_slice()?, - n_persons, - n_items, - q_theta, - max_iter, - tol, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("slope", res.slope)?; - out.set_item("intercept", res.intercept)?; - out.set_item("resid_sd", res.resid_sd)?; - out.set_item("discrimination", res.discrimination)?; - out.set_item("difficulty", res.difficulty)?; - out.set_item("theta", res.theta)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_delta", res.final_delta)?; - out.set_item("stopping_tolerance", res.stopping_tolerance)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Rating Scale Model fit (Andrich, 1978; `mlsirm_core::rsm::fit_rsm`). `y`/`observed` -/// are row-major `n_persons * n_items` with categories `0..n_cat-1`. Every item has -/// its own location, but the `n_cat-1` category thresholds are shared across items: -/// `ln[P(k)/P(k-1)] = theta - item_location_i - threshold_k`, `theta ~ N(0,1)`. -/// Returns a dict with `item_location` (`n_items`), `thresholds` (`n_cat-1`, centered), -/// `theta` (per-person EAP), `loglik_trace`, `n_iter`, `converged`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, n_persons, n_items, n_cat, q_theta = 41, max_iter = 500, tol = 1e-6))] -fn fit_rsm( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - n_items: usize, - n_cat: usize, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let yy: Vec = y - .as_slice()? - .iter() - .map(|&v| { - if v >= 0 { - Ok(v as usize) - } else { - Err(PyValueError::new_err( - "y must be non-negative category indices", - )) - } - }) - .collect::>()?; - let obs = observed.as_slice()?; - let res = core_fit_rsm( - &yy, - Some(obs), - n_persons, - n_items, - n_cat, - q_theta, - max_iter, - tol, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("item_location", res.item_location)?; - out.set_item("thresholds", res.thresholds)?; - out.set_item("theta", res.theta)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Many-Facet Rasch Model fit (Linacre, 1989; `mlsirm_core::facets::fit_facets`). -/// `y`/`observed` are row-major `n_persons * n_items * n_raters` (rater fastest) -/// with categories `0..n_cat-1`. Adjacent-category log-odds: -/// `ln[P(k)/P(k-1)] = theta - item_difficulty_i - rater_severity_j - threshold_k`, -/// `theta ~ N(0,1)`; severities and thresholds are centered to sum 0. Returns a -/// dict with `item_difficulty` (`n_items`), `rater_severity` (`n_raters`), -/// `thresholds` (`n_cat-1`), `theta` (per-person EAP), `loglik_trace`, `n_iter`, -/// `converged`, `connected` (design-linking flag), `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, n_persons, n_items, n_raters, n_cat, q_theta = 41, max_iter = 500, tol = 1e-6))] -fn fit_facets( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - n_items: usize, - n_raters: usize, - n_cat: usize, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let yy: Vec = y - .as_slice()? - .iter() - .map(|&v| { - if v >= 0 { - Ok(v as usize) - } else { - Err(PyValueError::new_err( - "y must be non-negative category indices", - )) - } - }) - .collect::>()?; - let obs = observed.as_slice()?; - let res = core_fit_facets( - &yy, - Some(obs), - n_persons, - n_items, - n_raters, - n_cat, - q_theta, - max_iter, - tol, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("item_difficulty", res.item_difficulty)?; - out.set_item("rater_severity", res.rater_severity)?; - out.set_item("thresholds", res.thresholds)?; - out.set_item("theta", res.theta)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("connected", res.connected)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Mokken scalability coefficients (`mlsirm_core::mokken::coef_h`). -/// `x` is a row-major complete `n_persons * n_items` integer score matrix. -/// Returns a dict with `hij`/`zij` (flattened `J*J`, NaN diagonal), `hi`, -/// `zi` (`J`), and scalars `h`, `z`. Sample statistics follow the mokken R -/// package (van der Ark, 2007, https://doi.org/10.18637/jss.v020.i11). -#[pyfunction] -#[pyo3(signature = (x, n_persons, n_items))] -fn mokken_coef_h( - py: Python<'_>, - x: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, -) -> PyResult> { - let res = - core_mokken_coef_h(x.as_slice()?, n_persons, n_items).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("hij", res.hij)?; - out.set_item("hi", res.hi)?; - out.set_item("h", res.h)?; - out.set_item("zij", res.zij)?; - out.set_item("zi", res.zi)?; - out.set_item("z", res.z)?; - Ok(out.into()) -} - -/// Mokken automated item selection procedure (`mlsirm_core::mokken::aisp`, -/// the "search normal" algorithm of the mokken R package). Returns per-item -/// scale labels: 0 = unscalable, 1, 2, ... in formation order. `c` is the -/// scalability lower bound (rule of thumb 0.3), `alpha` the nominal -/// significance level. -#[pyfunction] -#[pyo3(signature = (x, n_persons, n_items, c = 0.3, alpha = 0.05))] -fn mokken_aisp( - x: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - c: f64, - alpha: f64, -) -> PyResult> { - core_mokken_aisp(x.as_slice()?, n_persons, n_items, c, alpha).map_err(PyValueError::new_err) -} - -/// Kernel-smoothing nonparametric option characteristic curves -/// (`mlsirm_core::ksirt`; Ramsay, 1991, as cited in Mazza, Punzo, & -/// McGuire, 2014, https://doi.org/10.18637/jss.v058.i06). `x` is a -/// row-major complete `n_persons * n_items` pre-scored response matrix. -/// Returns a dict with `theta` (`N`), `grid` (`Q`), `bandwidth` (`J`), and -/// per-item lists `options`, `occ` (flattened `m_j * Q`, row-major by -/// option), `expected` (`Q`), plus `expected_total` (`Q`). -#[pyfunction] -#[pyo3(signature = (x, n_persons, n_items, kernel = "gaussian", nevalpoints = 51, bandwidth = None))] -fn ksirt_occ( - py: Python<'_>, - x: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, - kernel: &str, - nevalpoints: usize, - bandwidth: Option>, -) -> PyResult> { - let flat = x.as_slice()?; - if flat.len() != n_persons * n_items { - return Err(PyValueError::new_err(format!( - "x has {} entries, expected n_persons * n_items = {}", - flat.len(), - n_persons * n_items - ))); - } - let kern = match kernel { - "gaussian" => KsirtKernel::Gaussian, - "quadratic" => KsirtKernel::Quadratic, - "uniform" => KsirtKernel::Uniform, - other => { - return Err(PyValueError::new_err(format!( - "unknown kernel '{other}' (expected gaussian, quadratic, or uniform)" - ))) - } - }; - let rows: Vec> = (0..n_persons) - .map(|i| flat[i * n_items..(i + 1) * n_items].to_vec()) - .collect(); - let res = core_ksirt(&rows, kern, nevalpoints, bandwidth.as_deref()) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta", res.theta)?; - out.set_item("grid", res.grid)?; - out.set_item("bandwidth", res.bandwidth)?; - out.set_item("expected_total", res.expected_total)?; - let options: Vec> = res.items.iter().map(|it| it.options.clone()).collect(); - let occ: Vec> = res - .items - .iter() - .map(|it| it.occ.iter().flatten().copied().collect()) - .collect(); - let expected: Vec> = res.items.iter().map(|it| it.expected.clone()).collect(); - out.set_item("options", options)?; - out.set_item("occ", occ)?; - out.set_item("expected", expected)?; - Ok(out.into()) -} - -/// Haberman subscore added-value analysis (`mlsirm_core::subscores`; -/// Haberman, 2008, as cited in Sinharay, 2010, -/// ETS RR-10-16). `x` is a row-major complete `n_persons * n_items` -/// scored response matrix; `groups[j]` in `0..K` assigns item `j` to a -/// subscale. Returns a dict with per-subscale `alpha`, `prmse_s`, -/// `prmse_x`, `prmse_sx`, `tau`, `beta`, `gamma`, `added_value_s`, -/// `added_value_sx`, `alpha_total`, the `(K+1)^2` flattened `corr`, the -/// `K*K` flattened `disattenuated_corr` (NaN diagonal), and the `n*K` -/// flattened estimator matrices `observed`, `subscore_s`, `subscore_x`, -/// `subscore_sx` plus `total` (`n`). -#[pyfunction] -fn subscore_analysis( - py: Python<'_>, - x: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, - groups: Vec, -) -> PyResult> { - let flat = x.as_slice()?; - // Validate BEFORE allocating rows: unchecked n_persons * n_items can - // wrap on 64-bit (e.g. 2^63 * 2 == 0, matching an empty array) and then - // panic with capacity overflow inside the row allocation. - if n_persons < 3 || n_items < 4 || groups.len() != n_items { - return Err(PyValueError::new_err( - "need n_persons >= 3, n_items >= 4, and one group index per item", - )); - } - let expected = n_persons - .checked_mul(n_items) - .ok_or_else(|| PyValueError::new_err("n_persons * n_items overflows"))?; - if flat.len() != expected { - return Err(PyValueError::new_err(format!( - "x has {} entries, expected n_persons * n_items = {expected}", - flat.len(), - ))); - } - let rows: Vec> = (0..n_persons) - .map(|i| flat[i * n_items..(i + 1) * n_items].to_vec()) - .collect(); - let res = core_subscores(&rows, &groups).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("alpha", res.alpha)?; - out.set_item("alpha_total", res.alpha_total)?; - out.set_item("prmse_s", res.prmse_s)?; - out.set_item("prmse_x", res.prmse_x)?; - out.set_item("prmse_sx", res.prmse_sx)?; - out.set_item("tau", res.tau)?; - out.set_item("beta", res.beta)?; - out.set_item("gamma", res.gamma)?; - out.set_item("added_value_s", res.added_value_s)?; - out.set_item("added_value_sx", res.added_value_sx)?; - out.set_item("total", res.total)?; - let flatten = |m: Vec>| -> Vec { m.into_iter().flatten().collect() }; - out.set_item("corr", flatten(res.corr))?; - out.set_item("disattenuated_corr", flatten(res.disattenuated_corr))?; - out.set_item("observed", flatten(res.observed))?; - out.set_item("subscore_s", flatten(res.subscore_s))?; - out.set_item("subscore_x", flatten(res.subscore_x))?; - out.set_item("subscore_sx", flatten(res.subscore_sx))?; - Ok(out.into()) -} - -/// Confirmatory DETECT dimensionality analysis (Zhang & Stout, 1999, as -/// implemented by CRAN sirt's sum-score `scale_score=FALSE` path). `x` is a -/// flattened row-major `n_persons * n_items` binary (0/1, no missing) -/// response matrix; `cluster[j]` is the opaque integer cluster label of item -/// `j`. Returns a dict with `detect`, `assi`, `ratio`, `madcov100`, -/// `mcov100`, `n_pairs`, and the per-pair `pair_i`, `pair_j`, `ccov`. -#[pyfunction] -fn detect_analysis( - py: Python<'_>, - x: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, - cluster: Vec, -) -> PyResult> { - let flat = x.as_slice()?; - // Validate BEFORE any allocation: unchecked n_persons * n_items can wrap - // on 64-bit and then panic with capacity overflow. - if n_persons < 2 || n_items < 2 || cluster.len() != n_items { - return Err(PyValueError::new_err( - "need n_persons >= 2, n_items >= 2, and one cluster label per item", - )); - } - let expected = n_persons - .checked_mul(n_items) - .ok_or_else(|| PyValueError::new_err("n_persons * n_items overflows"))?; - if flat.len() != expected { - return Err(PyValueError::new_err(format!( - "x has {} entries, expected n_persons * n_items = {expected}", - flat.len(), - ))); - } - let res = - core_detect_analysis(flat, n_persons, n_items, &cluster).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("detect", res.detect)?; - out.set_item("assi", res.assi)?; - out.set_item("ratio", res.ratio)?; - out.set_item("madcov100", res.madcov100)?; - out.set_item("mcov100", res.mcov100)?; - out.set_item("n_pairs", res.n_pairs)?; - out.set_item("pair_i", res.pair_i)?; - out.set_item("pair_j", res.pair_j)?; - out.set_item("ccov", res.ccov)?; - Ok(out.into()) -} - -fn classification_result_to_dict( - py: Python<'_>, - res: ClassificationResult, -) -> PyResult> { - let out = pyo3::types::PyDict::new(py); - out.set_item("per_cut_accuracy", res.per_cut_accuracy)?; - out.set_item("per_cut_consistency", res.per_cut_consistency)?; - out.set_item("simultaneous_accuracy", res.simultaneous_accuracy)?; - out.set_item("simultaneous_consistency", res.simultaneous_consistency)?; - out.set_item("conditional_accuracy", res.conditional_accuracy)?; - out.set_item("conditional_consistency", res.conditional_consistency)?; - out.set_item( - "conditional_simultaneous_accuracy", - res.conditional_simultaneous_accuracy, - )?; - out.set_item( - "conditional_simultaneous_consistency", - res.conditional_simultaneous_consistency, - )?; - Ok(out.into()) -} - -/// Rudner (2001, 2005) normal-approximation classification accuracy and -/// consistency (`mlsirm_core::classification`). All slices are per -/// evaluation point except `cutscores` (strictly increasing theta cuts). -#[pyfunction] -fn rudner_classification( - py: Python<'_>, - theta: PyReadonlyArray1<'_, f64>, - sem: PyReadonlyArray1<'_, f64>, - weights: PyReadonlyArray1<'_, f64>, - cutscores: Vec, -) -> PyResult> { - let res = core_rudner_classification( - theta.as_slice()?, - sem.as_slice()?, - weights.as_slice()?, - &cutscores, - ) - .map_err(PyValueError::new_err)?; - classification_result_to_dict(py, res) -} - -/// Lee (2010, as implemented in CRAN cacIRT) summed-score classification -/// accuracy and consistency (`mlsirm_core::classification`). `probs` is a -/// flattened row-major `n_points * n_items` matrix of correct-response -/// probabilities strictly inside (0, 1); `cutscores` are raw-score cuts. -#[pyfunction] -fn lee_classification( - py: Python<'_>, - probs: PyReadonlyArray1<'_, f64>, - n_points: usize, - n_items: usize, - weights: PyReadonlyArray1<'_, f64>, - cutscores: Vec, -) -> PyResult> { - let res = core_lee_classification( - probs.as_slice()?, - n_points, - n_items, - weights.as_slice()?, - &cutscores, - ) - .map_err(PyValueError::new_err)?; - classification_result_to_dict(py, res) -} - -/// Livingston & Lewis (1995, as implemented in CRAN betafunctions 1.9.0 -/// `LL.CA`) classification accuracy and consistency from a single test -/// administration (`mlsirm_core::classification`). `scores` are raw -/// observed scores in `[min_score, max_score]`; `reliability` is in (0, 1) -/// and `cut` is strictly inside `(min_score, max_score)`. Pass = observed -/// score >= cut. -#[pyfunction] -fn livingston_lewis( - py: Python<'_>, - scores: PyReadonlyArray1<'_, f64>, - reliability: f64, - min_score: f64, - max_score: f64, - cut: f64, -) -> PyResult> { - let res = core_livingston_lewis(scores.as_slice()?, reliability, min_score, max_score, cut) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("effective_test_length", res.effective_test_length)?; - out.set_item("etl_rounded", res.etl_rounded)?; - out.set_item("lower", res.lower)?; - out.set_item("upper", res.upper)?; - out.set_item("alpha", res.alpha)?; - out.set_item("beta", res.beta)?; - out.set_item("used_two_parameter", res.used_two_parameter)?; - out.set_item("p_tp", res.p_tp)?; - out.set_item("p_fp", res.p_fp)?; - out.set_item("p_tf", res.p_tf)?; - out.set_item("p_ff", res.p_ff)?; - out.set_item("accuracy", res.accuracy)?; - out.set_item("sensitivity", res.sensitivity)?; - out.set_item("specificity", res.specificity)?; - out.set_item("p_ii", res.p_ii)?; - out.set_item("p_ij", res.p_ij)?; - out.set_item("p_ji", res.p_ji)?; - out.set_item("p_jj", res.p_jj)?; - out.set_item("consistency", res.consistency)?; - out.set_item("chance_consistency", res.chance_consistency)?; - out.set_item("kappa", res.kappa)?; - Ok(out.into()) -} - -/// One-facet crossed `p x i` generalizability analysis -/// (`mlsirm_core::gtheory`; Huebner & Lucht, 2019, Tables 3-4). `x` is a -/// flattened row-major `n_p x n_i` score matrix; `n_i_prime` lists the -/// proposed D-study item counts. -#[pyfunction] -fn gtheory_pi( - py: Python<'_>, - x: PyReadonlyArray1<'_, f64>, - n_p: usize, - n_i: usize, - n_i_prime: Vec, -) -> PyResult> { - let res = - core_gtheory_pi(x.as_slice()?, n_p, n_i, &n_i_prime).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("df", res.df.to_vec())?; - out.set_item("ss", res.ss.to_vec())?; - out.set_item("ms", res.ms.to_vec())?; - out.set_item("var_raw", res.var_raw.to_vec())?; - out.set_item("var", res.var.to_vec())?; - out.set_item("d_study", d_study_rows_to_py(py, &res.d_study)?)?; - Ok(out.into()) -} - -/// Two-facet crossed `p x i x o` generalizability analysis -/// (`mlsirm_core::gtheory`; Huebner & Lucht, 2019, Tables 5-6). `x` is -/// flattened `x[p*n_i*n_o + i*n_o + o]`; `n_prime` lists proposed -/// `(n_i', n_o')` D-study pairs. Component order in all arrays: -/// (p, i, o, pi, po, io, pio). -#[pyfunction] -fn gtheory_pio( - py: Python<'_>, - x: PyReadonlyArray1<'_, f64>, - n_p: usize, - n_i: usize, - n_o: usize, - n_prime: Vec<(usize, usize)>, -) -> PyResult> { - let res = - core_gtheory_pio(x.as_slice()?, n_p, n_i, n_o, &n_prime).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("df", res.df.to_vec())?; - out.set_item("ss", res.ss.to_vec())?; - out.set_item("ms", res.ms.to_vec())?; - out.set_item("var_raw", res.var_raw.to_vec())?; - out.set_item("var", res.var.to_vec())?; - out.set_item("d_study", d_study_rows_to_py(py, &res.d_study)?)?; - Ok(out.into()) -} - -fn d_study_rows_to_py( - py: Python<'_>, - rows: &[GTheoryDStudyRow], -) -> PyResult>> { - rows.iter() - .map(|r| { - let d = pyo3::types::PyDict::new(py); - d.set_item("n_i_prime", r.n_i_prime)?; - d.set_item("n_o_prime", r.n_o_prime)?; - d.set_item("rel_error_var", r.rel_error_var)?; - d.set_item("abs_error_var", r.abs_error_var)?; - d.set_item("generalizability", r.generalizability)?; - d.set_item("dependability", r.dependability)?; - Ok(d.into()) - }) - .collect() -} - -fn minres_fa_to_py(py: Python<'_>, res: &MinresFaResult) -> PyResult> { - let out = pyo3::types::PyDict::new(py); - out.set_item("loadings", res.loadings.to_vec())?; - out.set_item("uniquenesses", res.uniquenesses.to_vec())?; - out.set_item("communalities", res.communalities.to_vec())?; - out.set_item("objective", res.objective)?; - out.set_item("kkt_violation", res.kkt_violation)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - Ok(out.into()) -} - -/// Minres (ULS) exploratory factor analysis (`mlsirm_core::factor`; -/// transcription of psych fa.R, fm = "minres"). `corr` is a flattened -/// row-major `p x p` correlation matrix. `loadings` in the result dict is -/// flattened row-major `p x n_factors` (unrotated, column sums >= 0). -#[pyfunction] -fn minres_fa( - py: Python<'_>, - corr: PyReadonlyArray1<'_, f64>, - p: usize, - n_factors: usize, -) -> PyResult> { - let res = core_minres_fa_corr(corr.as_slice()?, p, n_factors).map_err(PyValueError::new_err)?; - minres_fa_to_py(py, &res) -} - -/// [`minres_fa`] from raw data (`n x p` flattened row-major; Pearson -/// correlations computed internally, complete data required). -#[pyfunction] -fn minres_fa_from_data( - py: Python<'_>, - data: PyReadonlyArray1<'_, f64>, - n: usize, - p: usize, - n_factors: usize, -) -> PyResult> { - let res = - core_minres_fa_data(data.as_slice()?, n, p, n_factors).map_err(PyValueError::new_err)?; - minres_fa_to_py(py, &res) -} - -/// McDonald's omega_total for the 1-factor case from a correlation matrix -/// (`mlsirm_core::factor`): `(sum lambda)^2 / ((sum lambda)^2 + sum psi)` -/// on a 1-factor minres fit. Returns the omega plus the embedded fit dict. -#[pyfunction] -fn omega_total_1f( - py: Python<'_>, - corr: PyReadonlyArray1<'_, f64>, - p: usize, -) -> PyResult> { - let res = core_omega_total_1f_corr(corr.as_slice()?, p).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("omega_total", res.omega_total)?; - out.set_item("fa", minres_fa_to_py(py, &res.fa)?)?; - Ok(out.into()) -} - -/// [`omega_total_1f`] from raw data (`n x p` flattened row-major). -#[pyfunction] -fn omega_total_1f_from_data( - py: Python<'_>, - data: PyReadonlyArray1<'_, f64>, - n: usize, - p: usize, -) -> PyResult> { - let res = core_omega_total_1f_data(data.as_slice()?, n, p).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("omega_total", res.omega_total)?; - out.set_item("fa", minres_fa_to_py(py, &res.fa)?)?; - Ok(out.into()) -} - -/// Factor-analytic greatest lower bound to reliability (psych glb.fa -/// transcription in `mlsirm_core::factor::glb_fa_corr`; NOT the algebraic -/// glb, which needs an SDP solver). Returns {glb, communalities, nf}. -#[pyfunction] -fn glb_fa( - py: Python<'_>, - corr: PyReadonlyArray1<'_, f64>, - p: usize, -) -> PyResult> { - let res = core_glb_fa_corr(corr.as_slice()?, p).map_err(PyValueError::new_err)?; - glb_fa_to_py(py, &res) -} - -/// [`glb_fa`] from raw data (`n x p` flattened row-major, complete data). -#[pyfunction] -fn glb_fa_from_data( - py: Python<'_>, - data: PyReadonlyArray1<'_, f64>, - n: usize, - p: usize, -) -> PyResult> { - let res = core_glb_fa_data(data.as_slice()?, n, p).map_err(PyValueError::new_err)?; - glb_fa_to_py(py, &res) -} - -fn glb_fa_to_py( - py: Python<'_>, - res: &mlsirm_core::factor::GlbFaResult, -) -> PyResult> { - let out = pyo3::types::PyDict::new(py); - out.set_item("glb", res.glb)?; - out.set_item( - "communalities", - numpy::PyArray1::from_slice(py, &res.communalities), - )?; - out.set_item("nf", res.nf)?; - Ok(out.into()) -} - -/// Velicer's minimum average partial (MAP) test -/// (`mlsirm_core::factor::velicer_map_corr`). `corr` is a flattened -/// row-major `p x p` correlation matrix; rows `m = 0..=max_m`. Returns -/// {f2, f4, retained_f2, retained_f4}; invalid rows (singular partial -/// covariance normalization, e.g. identity R for m >= 1) are NaN and -/// excluded from the retained-count argmin. -/// -/// Retained count follows O'Connor's canonical programs (`m` at the -/// minimum, with `m = 0` = the unpartialed baseline); note -/// `fungible::faMAP` prints a 1-based row position (off by one). The -/// fourth-power (revised) criterion uses ELEMENTWISE fourth powers per -/// O'Connor's code; see the core docs for the unresolved -/// `EFA.dimensions` matrix-power conflict. -/// -/// References (APA 7th ed.): -/// -/// Velicer, W. F. (1976). Determining the number of components from the -/// matrix of partial correlations. *Psychometrika, 41*(3), 321-327. -/// https://doi.org/10.1007/BF02293557 (Not read; formula support is the -/// read O'Connor map.m/map.sps programs and psych VSS.R map().) -/// O'Connor, B. P. (2000). SPSS and SAS programs for determining the -/// number of components using parallel analysis and Velicer's MAP test. -/// *Behavior Research Methods, Instruments, & Computers, 32*(3), -/// 396-402. https://doi.org/10.3758/BF03200807 (Programs read; paper -/// not read.) -/// Velicer, W. F., Eaton, C. A., & Fava, J. L. (2000). Construct -/// explication through factor or component analysis. In R. D. Goffin & -/// E. Helmes (Eds.), *Problems and solutions in human assessment* -/// (pp. 41-71). Kluwer. (Not read; fourth-power origin per O'Connor's -/// code comments.) -/// Revelle, W. (2025). *psych: Procedures for psychological, -/// psychometric, and personality research* (Version 2.6.5) [R package]. -/// https://CRAN.R-project.org/package=psych (VSS.R map() read.) -#[pyfunction] -fn velicer_map( - py: Python<'_>, - corr: PyReadonlyArray1<'_, f64>, - p: usize, - max_m: usize, -) -> PyResult> { - let res = core_velicer_map_corr(corr.as_slice()?, p, max_m).map_err(PyValueError::new_err)?; - velicer_map_to_py(py, &res) -} - -/// [`velicer_map`] from raw data (`n x p` flattened row-major, complete -/// data; Pearson correlations computed internally). -#[pyfunction] -fn velicer_map_from_data( - py: Python<'_>, - data: PyReadonlyArray1<'_, f64>, - n: usize, - p: usize, - max_m: usize, -) -> PyResult> { - let res = - core_velicer_map_data(data.as_slice()?, n, p, max_m).map_err(PyValueError::new_err)?; - velicer_map_to_py(py, &res) -} - -fn velicer_map_to_py( - py: Python<'_>, - res: &mlsirm_core::factor::VelicerMapResult, -) -> PyResult> { - let out = pyo3::types::PyDict::new(py); - out.set_item("f2", numpy::PyArray1::from_slice(py, &res.f2))?; - out.set_item("f4", numpy::PyArray1::from_slice(py, &res.f4))?; - out.set_item("retained_f2", res.retained_f2)?; - out.set_item("retained_f4", res.retained_f4)?; - Ok(out.into()) -} - -/// Brogden-Cronbach-Gleser selection utility with Naylor-Shine selected-group -/// mean (`mlsirm_core::utility::selection_utility`; verified against the CRAN -/// iopsych 0.90.1 utilityBcg/ux source and a scipy oracle). Returns -/// {xc, ux, pux, utility_gain}. -#[pyfunction] -fn selection_utility( - py: Python<'_>, - n: f64, - sdy: f64, - rxy: f64, - sr: f64, - cost_total: f64, - period: f64, -) -> PyResult> { - let res = core_selection_utility(n, sdy, rxy, sr, cost_total, period) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("xc", res.xc)?; - out.set_item("ux", res.ux)?; - out.set_item("pux", res.pux)?; - out.set_item("utility_gain", res.utility_gain)?; - Ok(out.into()) -} - -/// Taylor-Russell (1939) success ratio under the standard bivariate-normal -/// selection model (`mlsirm_core::utility::taylor_russell`). Returns -/// {success_ratio, base_rate, q_joint}. -#[pyfunction] -fn taylor_russell(py: Python<'_>, rxy: f64, sr: f64, br: f64) -> PyResult> { - let res = core_taylor_russell(rxy, sr, br).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("success_ratio", res.success_ratio)?; - out.set_item("base_rate", res.base_rate)?; - out.set_item("q_joint", res.q_joint)?; - Ok(out.into()) -} - -/// Sympson-Hetter item-exposure-control calibration for max-info CAT -/// (`mlsirm_core::exposure::sympson_hetter`; algorithm confirmed against -/// Georgiadou, Triantafillou, & Economides, 2007, and Barrada, Olea, & -/// Ponsoda, 2007, Eq. 1-3). Returns {k, exposure, selection, max_exposure, -/// n_iter, converged, history_max_exposure}. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -fn py_sympson_hetter( - py: Python<'_>, - a: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - c: PyReadonlyArray1<'_, f64>, - r_max: f64, - test_length: usize, - n_simulees: usize, - max_iter: usize, - tol: f64, - seed: u64, - q_theta: usize, -) -> PyResult> { - let cfg = SympsonHetterConfig { - r_max, - test_length, - n_simulees, - max_iter, - tol, - seed, - q_theta, - }; - let res = core_sympson_hetter(a.as_slice()?, b.as_slice()?, c.as_slice()?, &cfg) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("k", numpy::PyArray1::from_slice(py, &res.k))?; - out.set_item("exposure", numpy::PyArray1::from_slice(py, &res.exposure))?; - out.set_item("selection", numpy::PyArray1::from_slice(py, &res.selection))?; - out.set_item("max_exposure", res.max_exposure)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item( - "history_max_exposure", - numpy::PyArray1::from_slice(py, &res.history_max_exposure), - )?; - Ok(out.into()) -} - -/// a-stratified multistage CAT simulation -/// (`mlsirm_core::exposure::a_stratified`). -/// Returns {exposure, max_exposure, stratum, stage_lengths, theta_rmse, -/// theta_bias}. -/// -/// Source status: design after Chang & Ying (1999), cited from its -/// abstract only; the b-matching selection rule and ascending-a strata are -/// confirmed from Barrada, Mazuela, & Olea (2006), read in full. Full -/// references and repository-choice labels are in the core rustdoc -/// (`mlsirm_core::exposure::a_stratified`) and the Python docstring -/// (`fast_mlsirm.exposure.a_stratified`). -/// -/// References: -/// Barrada, J. R., Mazuela, P., & Olea, J. (2006). Maximum information -/// stratification method for controlling item exposure in computerized -/// adaptive testing. Psicothema, 18(1), 156-159. -/// Chang, H.-H., & Ying, Z. (1999). a-stratified multistage computerized -/// adaptive testing. Applied Psychological Measurement, 23(3), 211-222. -/// https://doi.org/10.1177/01466219922031338 -#[pyfunction] -fn py_a_stratified( - py: Python<'_>, - a: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - c: PyReadonlyArray1<'_, f64>, - n_strata: usize, - test_length: usize, - n_simulees: usize, - seed: u64, - q_theta: usize, -) -> PyResult> { - let cfg = AStratifiedConfig { - n_strata, - test_length, - n_simulees, - seed, - q_theta, - }; - let res = core_a_stratified(a.as_slice()?, b.as_slice()?, c.as_slice()?, &cfg) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("exposure", numpy::PyArray1::from_slice(py, &res.exposure))?; - out.set_item("max_exposure", res.max_exposure)?; - out.set_item("stratum", res.stratum)?; - out.set_item("stage_lengths", res.stage_lengths)?; - out.set_item("theta_rmse", res.theta_rmse)?; - out.set_item("theta_bias", res.theta_bias)?; - Ok(out.into()) -} - -/// Chang-Ying (1996) Kullback-Leibler information index and CAT item -/// selection (`mlsirm_core::exposure::{kl_information, kl_select}`). -/// `kl_information` returns the UNNORMALIZED area of the pointwise Bernoulli -/// KL divergence over `[theta0 - delta, theta0 + delta]`; `kl_select` uses -/// `delta = r / sqrt(n_administered)` (requires `n_administered >= 1`) and -/// returns {index, selected, delta}. Full sources and the contract are in -/// the core rustdoc. -/// -/// References: -/// Chang, H.-H., & Ying, Z. (1996). A global information approach to -/// computerized adaptive testing. Applied Psychological Measurement, 20(3), -/// 213-229. https://doi.org/10.1177/014662169602000303 -#[pyfunction] -fn py_kl_information( - py: Python<'_>, - a: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - c: PyReadonlyArray1<'_, f64>, - theta0: f64, - delta: f64, -) -> PyResult>> { - let v = core_kl_information(a.as_slice()?, b.as_slice()?, c.as_slice()?, theta0, delta) - .map_err(PyValueError::new_err)?; - Ok(numpy::PyArray1::from_slice(py, &v).into()) -} - -/// See `py_kl_information`; `administered` is a boolean mask over the pool. -#[pyfunction] -fn py_kl_select( - py: Python<'_>, - a: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - c: PyReadonlyArray1<'_, f64>, - administered: PyReadonlyArray1<'_, bool>, - theta0: f64, - n_administered: usize, - r: f64, -) -> PyResult> { - let res = core_kl_select( - a.as_slice()?, - b.as_slice()?, - c.as_slice()?, - administered.as_slice()?, - theta0, - n_administered, - r, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("index", numpy::PyArray1::from_slice(py, &res.index))?; - out.set_item("selected", res.selected)?; - out.set_item("delta", res.delta)?; - Ok(out.into()) -} - -/// Owen (1975) approximate Bayesian single-item posterior update for the -/// 3PNO model (`mlsirm_core::exposure::owen_update`). Returns the updated -/// `(mu, sig2)` normal-approximation posterior moments. Owen (1975) itself -/// was NOT read (paywalled); the formulas are implemented as reproduced by -/// van der Linden (1998, Appendix Eqs. A.1-A.6) and cross-checked against -/// the R `irt` package `src/est_ability_owen.cpp`. See the core rustdoc for -/// the full citation-governance note. -/// -/// References: -/// Owen, R. J. (1975). A Bayesian sequential procedure for quantal response -/// in the context of adaptive mental testing. Journal of the American -/// Statistical Association, 70(350), 351-356. -/// https://doi.org/10.1080/01621459.1975.10479871 -/// van der Linden, W. J. (1998). Bayesian item selection criteria for -/// adaptive testing (Research Report 98-01). University of Twente. -#[pyfunction] -fn py_owen_update( - a: f64, - b: f64, - c: f64, - correct: bool, - mu: f64, - sig2: f64, -) -> PyResult<(f64, f64)> { - core_owen_update(a, b, c, correct, mu, sig2).map_err(PyValueError::new_err) -} - -/// Owen (1975) sequential CAT driver (`mlsirm_core::exposure::owen_cat`): -/// b-matching selection (argmin |b_i - mu|, ties to the lowest index), -/// Owen posterior updates, optional posterior-variance stopping. `responses` -/// is a full-pool 0/1 vector consulted for whichever item is selected. -/// Returns {administered, mu_trace, sig2_trace, mu, sig2}. -#[pyfunction] -#[pyo3(signature = (a, b, c, responses, mu0, sig2_0, test_length, sig2_stop=None))] -#[allow(clippy::too_many_arguments)] -fn py_owen_cat( - py: Python<'_>, - a: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - c: PyReadonlyArray1<'_, f64>, - responses: PyReadonlyArray1<'_, u8>, - mu0: f64, - sig2_0: f64, - test_length: usize, - sig2_stop: Option, -) -> PyResult> { - let res = core_owen_cat( - a.as_slice()?, - b.as_slice()?, - c.as_slice()?, - responses.as_slice()?, - mu0, - sig2_0, - test_length, - sig2_stop, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("administered", res.administered)?; - out.set_item("mu_trace", numpy::PyArray1::from_slice(py, &res.mu_trace))?; - out.set_item( - "sig2_trace", - numpy::PyArray1::from_slice(py, &res.sig2_trace), - )?; - out.set_item("mu", res.mu)?; - out.set_item("sig2", res.sig2)?; - Ok(out.into()) -} - -/// Kingsbury & Zara (1989) constrained CAT (CCAT) content-balanced item -/// selection (`mlsirm_core::exposure::ccat_select`). The primary source was -/// NOT read (paywalled; https://doi.org/10.1207/s15324818ame0204_6); the rule -/// is implemented as reproduced by the R catR package (`nextItem.R`, -/// `cbControl`): any eligible group with zero administered items has -/// priority, otherwise the eligible group with the maximal -/// target-minus-empirical-proportion discrepancy wins; within the chosen -/// group the most informative unadministered item (logistic 3PL Fisher -/// information, no D constant) is selected. Ties go to the lowest index -/// (documented deviation from catR's random tie-break). Returns -/// {selected, group, discrepancy, info}. -/// -/// References: -/// Kingsbury, G. G., & Zara, A. R. (1989). Procedures for selecting items -/// for computerized adaptive tests. Applied Measurement in Education, 2(4), -/// 359-375. https://doi.org/10.1207/s15324818ame0204_6 -/// Magis, D., & Raiche, G. (2012). Random generation of response patterns -/// under computerized adaptive testing with the R package catR. Journal of -/// Statistical Software, 48(8), 1-31. https://doi.org/10.18637/jss.v048.i08 -#[pyfunction] -#[allow(clippy::too_many_arguments)] -fn py_ccat_select( - py: Python<'_>, - a: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - c: PyReadonlyArray1<'_, f64>, - groups: PyReadonlyArray1<'_, usize>, - targets: PyReadonlyArray1<'_, f64>, - administered: PyReadonlyArray1<'_, bool>, - theta0: f64, -) -> PyResult> { - let res = core_ccat_select( - a.as_slice()?, - b.as_slice()?, - c.as_slice()?, - groups.as_slice()?, - targets.as_slice()?, - administered.as_slice()?, - theta0, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("selected", res.selected)?; - out.set_item("group", res.group)?; - out.set_item( - "discrepancy", - numpy::PyArray1::from_slice(py, &res.discrepancy), - )?; - out.set_item("info", numpy::PyArray1::from_slice(py, &res.info))?; - Ok(out.into()) -} - -/// Horn's parallel analysis for principal-component retention -/// (`mlsirm_core::parallel`; oracle: CRAN paran 1.5.6, PCA path). `data` is -/// a flattened row-major `n_persons * n_items` matrix; `centile` is 0 for -/// the mean benchmark or 1..=99 for Glorfeld's upper-centile variant. -#[pyfunction] -fn parallel_analysis( - py: Python<'_>, - data: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, - n_iterations: usize, - centile: u32, - seed: u64, -) -> PyResult> { - let res = core_parallel_analysis( - data.as_slice()?, - n_persons, - n_items, - n_iterations, - centile, - seed, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("retained", res.retained)?; - out.set_item("eigenvalues", res.eigenvalues)?; - out.set_item("random_eigenvalues", res.random_eigenvalues)?; - out.set_item("bias", res.bias)?; - out.set_item("adjusted_eigenvalues", res.adjusted_eigenvalues)?; - Ok(out.into()) -} - -/// Guttman (1945) lambda reliability coefficients plus split-half summaries -/// (`mlsirm_core::reliability`; oracle: CRAN psych 2.6.5 `guttman`/ -/// `splitHalf`). `data` is a flattened row-major `n_persons * n_items` -/// matrix of complete finite scores. `n_sample_splits` bounds the split -/// enumeration (exhaustive when C(p, floor(p/2)) fits the budget, else -/// LCG-sampled with `seed`). -#[pyfunction] -fn guttman_lambdas( - py: Python<'_>, - data: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, - n_sample_splits: usize, - seed: u64, -) -> PyResult> { - let res = core_guttman_lambdas(data.as_slice()?, n_persons, n_items, n_sample_splits, seed) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("lambda1", res.lambda1)?; - out.set_item("lambda2", res.lambda2)?; - out.set_item("lambda3", res.lambda3)?; - out.set_item("lambda4", res.lambda4)?; - out.set_item("lambda5", res.lambda5)?; - out.set_item("lambda6", res.lambda6)?; - out.set_item("beta", res.beta)?; - out.set_item("mean_split", res.mean_split)?; - out.set_item("n_splits", res.n_splits)?; - out.set_item("exhaustive", res.exhaustive)?; - Ok(out.into()) -} - -/// ten Berge & Zegers (1978) mu0-mu3 reliability lower bounds -/// (`mlsirm_core::reliability`; oracle: CRAN psych 2.6.5 `tenberge.R`). -/// `data` is a flattened row-major `n_persons * n_items` matrix of complete -/// finite scores. -#[pyfunction] -fn tenberge_mu( - py: Python<'_>, - data: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, -) -> PyResult> { - let res = - core_tenberge_mu(data.as_slice()?, n_persons, n_items).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("mu0", res.mu0)?; - out.set_item("mu1", res.mu1)?; - out.set_item("mu2", res.mu2)?; - out.set_item("mu3", res.mu3)?; - Ok(out.into()) -} - -/// Cronbach's (1951) coefficient alpha from raw data (covariance form; -/// `mlsirm_core::reliability`). `data` is a flattened row-major -/// `n_persons * n_items` matrix of complete finite scores. -#[pyfunction] -fn cronbach_alpha( - data: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, -) -> PyResult { - core_cronbach_alpha(data.as_slice()?, n_persons, n_items).map_err(PyValueError::new_err) -} - -/// Feldt (1965) exact-F confidence interval for coefficient alpha -/// (`mlsirm_core::reliability`; oracle: CRAN psych 2.6.5 `alpha.ci`). -/// `level` is the two-sided confidence level, e.g. 0.95. -#[pyfunction] -fn feldt_alpha_ci( - py: Python<'_>, - alpha: f64, - n_persons: usize, - n_items: usize, - level: f64, -) -> PyResult> { - let res = - core_feldt_alpha_ci(alpha, n_persons, n_items, level).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("alpha", res.alpha)?; - out.set_item("lower", res.lower)?; - out.set_item("upper", res.upper)?; - out.set_item("r_bar", res.r_bar)?; - out.set_item("df1", res.df1)?; - out.set_item("df2", res.df2)?; - Ok(out.into()) -} - -/// Person separation reliability `(SSD - MSE) / SSD` -/// (`mlsirm_core::reliability`; transcribed from CRAN eRm `SepRel.R`). -/// Returns a dict with `sep_rel`, `ssd`, `mse`, `sep_index`. -#[pyfunction] -fn separation_reliability( - py: Python<'_>, - measures: PyReadonlyArray1<'_, f64>, - se: PyReadonlyArray1<'_, f64>, -) -> PyResult> { - let res = core_separation_reliability(measures.as_slice()?, se.as_slice()?) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("sep_rel", res.sep_rel)?; - out.set_item("ssd", res.ssd)?; - out.set_item("mse", res.mse)?; - out.set_item("sep_index", res.sep_index)?; - Ok(out.into()) -} -/// 1990). `y`/`observed` are row-major `n_persons * n_items`; `model` is "rasch" or -/// "2pl". `n_classes` latent classes each get their own item parameters. Returns a dict -/// with `a`/`b` (class-major `C*J`), `pi` (`C`), `class_posterior` (`N*C`), `map_class` -/// (`N`), `theta` (`N`), `loglik_trace`, `n_iter`, `converged`, `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, n_persons, n_items, n_classes, model = "rasch", n_starts = 1, max_iter = 500, tol = 1e-6, seed = 0x2545F491))] -fn fit_mixture( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - n_items: usize, - n_classes: usize, - model: &str, - n_starts: usize, - max_iter: usize, - tol: f64, - seed: u64, -) -> PyResult> { - let within = match model { - "rasch" | "Rasch" | "RASCH" => MixtureModel::Rasch, - "2pl" | "2PL" | "twopl" | "TwoPl" => MixtureModel::TwoPl, - other => { - return Err(PyValueError::new_err(format!( - "model must be 'rasch' or '2pl'; got {other}" - ))) - } - }; - let cfg = MixtureConfig { - max_iter, - tol, - n_starts, - seed, - ..MixtureConfig::default() - }; - let res = core_fit_mixture( - y.as_slice()?, - observed.as_slice()?, - n_persons, - n_items, - n_classes, - within, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("model", model)?; - out.set_item("n_classes", res.n_classes)?; - out.set_item("a", res.a)?; - out.set_item("b", res.b)?; - out.set_item("pi", res.pi)?; - out.set_item("class_posterior", res.class_posterior)?; - out.set_item("map_class", res.map_class)?; - out.set_item("theta", res.theta)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Marginal-EM fit of the Linear Logistic Test Model (`mlsirm_core::lltm`, Fischer, -/// 1973). `y`/`observed` are row-major `n_persons * n_items`; `q_design` is row-major -/// `n_items * n_basic` (real operation weights). In the crate's additive sign -/// convention, item easiness is `b_i = c + sum_k q_ik eta_k` (Fischer difficulty is -/// `-b_i`). Returns a dict with `eta` (K), `intercept`, `b` (J induced), `theta` -/// (N), `loglik_trace`, `n_iter`, `converged`, `n_parameters`, and (when `compute_lr`) -/// the LR test of LLTM vs Rasch: `loglik_rasch`, `lr_stat`, `lr_df`, `lr_p`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, q_design, n_persons, n_items, n_basic, fit_intercept = true, compute_lr = true, max_iter = 500, tol = 1e-6))] -fn fit_lltm( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - q_design: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, - n_basic: usize, - fit_intercept: bool, - compute_lr: bool, - max_iter: usize, - tol: f64, -) -> PyResult> { - let cfg = LltmConfig { - max_iter, - tol, - fit_intercept, - compute_lr, - ..LltmConfig::default() - }; - let res = core_fit_lltm( - y.as_slice()?, - observed.as_slice()?, - q_design.as_slice()?, - n_persons, - n_items, - n_basic, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("eta", res.eta)?; - out.set_item("intercept", res.intercept)?; - out.set_item("b", res.b)?; - out.set_item("theta", res.theta)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("n_parameters", res.n_parameters)?; - out.set_item("loglik_rasch", res.loglik_rasch)?; - out.set_item("lr_stat", res.lr_stat)?; - out.set_item("lr_df", res.lr_df)?; - out.set_item("lr_p", res.lr_p)?; - Ok(out.into()) -} - -/// Marginal-EM fit of the testlet response model (`mlsirm_core::testlet`, Bradlow, -/// Wainer, & Wang, 1999). `y`/`observed` are row-major `n_persons * n_items`; -/// `testlet_id[i]` is item `i`'s testlet in `0..n_testlets`; `model` is "rasch" or -/// "2pl". Returns a dict with `a`/`b`/`beta` (per item), `sigma2` (per testlet — the -/// local-dependence estimand), `theta`, `loglik_trace`, `n_iter`, `converged`, -/// `n_parameters`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, observed, testlet_id, n_persons, n_items, n_testlets, model = "rasch", max_iter = 500, tol = 1e-6, q_gamma = 21, estimate_sigma = true, init_sigma2 = 0.5))] -fn fit_testlet( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - testlet_id: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_testlets: usize, - model: &str, - max_iter: usize, - tol: f64, - q_gamma: usize, - estimate_sigma: bool, - init_sigma2: f64, -) -> PyResult> { - let within = match model { - "rasch" | "Rasch" | "RASCH" => TestletModel::Rasch, - "2pl" | "2PL" | "twopl" | "TwoPl" => TestletModel::TwoPl, - other => { - return Err(PyValueError::new_err(format!( - "model must be 'rasch' or '2pl'; got {other}" - ))) - } - }; - let tid: Vec = testlet_id - .as_slice()? - .iter() - .map(|&v| { - if v < 0 { - Err(PyValueError::new_err( - "testlet_id entries must be non-negative", - )) - } else { - Ok(v as usize) - } - }) - .collect::>()?; - let cfg = TestletConfig { - max_iter, - tol, - q_gamma, - estimate_sigma, - init_sigma2, - ..TestletConfig::default() - }; - let res = core_fit_testlet( - y.as_slice()?, - observed.as_slice()?, - &tid, - n_persons, - n_items, - n_testlets, - within, - &cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("model", model)?; - out.set_item("a", res.a)?; - out.set_item("b", res.b)?; - out.set_item("beta", res.beta)?; - out.set_item("sigma2", res.sigma2)?; - out.set_item("theta", res.theta)?; - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("final_loglik_change", res.final_loglik_change)?; - out.set_item("n_parameters", res.n_parameters)?; - Ok(out.into()) -} - -/// Marginal (MMLE-EM) calibration of the latent-space model family -/// (`mlsirm_core::marginal`). `pop_kind` is "single", "multigroup" or -/// "multilevel"; `pop_id` carries the per-person group/cluster index (ignored -/// for "single"). Returns a dict of the fitted quantities. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, - observed, - factor_id, - n_persons, - n_items, - n_dims, - latent_dim, - model, - eps_distance, - pop_kind = "single", - pop_id = None, - n_pop = 0, - q_theta = 21, - q_xi = 11, - q_u = 15, - max_iter = 200, - tol = 1e-5, - m_steps = 4, - lambda_b = 0.25, - lambda_alpha = 1.0, - mu_alpha = 0.5, - lambda_zeta = 1.0, - lambda_tau = 1.0, - mu_tau = 0.5, - device = "cpu", - xi_rule = "gh", - xi_points = 256, - xi_seed = 0, - anchor_fixed = None, - anchor_alpha = None, - anchor_b = None, - anchor_zeta = None, - anchor_tau = None, - zero_inflation = false, - covariate_w = None, - covariate_init_delta = 0.0, -))] -fn fit_marginal( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - factor_id: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_dims: usize, - latent_dim: usize, - model: &str, - eps_distance: f64, - pop_kind: &str, - pop_id: Option>, - n_pop: usize, - q_theta: usize, - q_xi: usize, - q_u: usize, - max_iter: usize, - tol: f64, - m_steps: usize, - lambda_b: f64, - lambda_alpha: f64, - mu_alpha: f64, - lambda_zeta: f64, - lambda_tau: f64, - mu_tau: f64, - device: &str, - xi_rule: &str, - xi_points: usize, - xi_seed: u64, - anchor_fixed: Option>, - anchor_alpha: Option>, - anchor_b: Option>, - anchor_zeta: Option>, - anchor_tau: Option, - zero_inflation: bool, - covariate_w: Option>, - covariate_init_delta: f64, -) -> PyResult> { - let device = Device::parse(device) - .ok_or_else(|| PyValueError::new_err("device must be one of ['cpu', 'gpu', 'auto']"))?; - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: parse_model_type(model)?, - eps_distance, - }; - let factors = convert_factor_id(factor_id.as_slice()?, n_dims)?; - let ids: Option> = match &pop_id { - Some(arr) => Some( - arr.as_slice()? - .iter() - .map(|&v| { - usize::try_from(v) - .map_err(|_| PyValueError::new_err("population ids must be >= 0")) - }) - .collect::>>()?, - ), - None => None, - }; - let pop = match pop_kind { - "single" => PopulationSpec::Single, - "singlefree" => PopulationSpec::SingleFree, - "multigroup" => PopulationSpec::Multigroup { - group_id: ids.ok_or_else(|| PyValueError::new_err("multigroup requires pop_id"))?, - n_groups: n_pop, - }, - "multilevel" => PopulationSpec::Multilevel { - cluster_id: ids.ok_or_else(|| PyValueError::new_err("multilevel requires pop_id"))?, - n_clusters: n_pop, - }, - _ => { - return Err(PyValueError::new_err( - "pop_kind must be one of ['single', 'multigroup', 'multilevel']", - )) - } - }; - let rule = XiRuleKind::parse(xi_rule) - .ok_or_else(|| PyValueError::new_err("xi_rule must be one of ['gh', 'qmc', 'mc']"))?; - let mcfg = MarginalConfig { - q_theta, - q_xi, - q_u, - max_iter, - tol, - m_steps, - xi_rule: rule, - xi_points, - xi_seed, - zero_inflation, - ..MarginalConfig::default() - }; - let penalty = PenaltyConfig { - lambda_b, - lambda_alpha, - mu_alpha, - lambda_zeta, - lambda_tau, - mu_tau, - ..PenaltyConfig::lsirm_prior() - }; - let anchors: Option = match (&anchor_fixed, &anchor_alpha, &anchor_b, &anchor_zeta) { - (None, None, None, None) => None, - (Some(f), Some(a), Some(b_arr), Some(z)) => Some(Anchors { - fixed: f.as_slice()?.to_vec(), - alpha: a.as_slice()?.to_vec(), - b: b_arr.as_slice()?.to_vec(), - zeta: z.as_slice()?.to_vec(), - tau: anchor_tau, - }), - _ => { - return Err(PyValueError::new_err( - "anchors require anchor_fixed, anchor_alpha, anchor_b and anchor_zeta together", - )) - } - }; - let covariate: Option = match &covariate_w { - Some(w) => Some(ItemCovariate { - w: w.as_slice()?.to_vec(), - init_delta: covariate_init_delta, - }), - None => None, - }; - let res = core_fit_marginal_full( - y.as_slice()?, - observed.as_slice()?, - &factors, - &config, - &pop, - &mcfg, - &penalty, - device, - anchors.as_ref(), - covariate.as_ref(), - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("alpha", res.alpha)?; - out.set_item("b", res.b)?; - out.set_item("zeta", res.zeta)?; - out.set_item("tau", res.tau)?; - out.set_item("theta_eap", res.theta_eap)?; - out.set_item("theta_sd", res.theta_sd)?; - out.set_item("xi_eap", res.xi_eap)?; - out.set_item("mu", res.mu)?; - out.set_item("sigma", res.sigma)?; - out.set_item("sigma_u", res.sigma_u)?; - out.set_item("u_eap", res.u_eap)?; - out.set_item("n_parameters", res.n_parameters)?; - out.set_item("delta", res.delta)?; - out.set_item("pi_zero", res.pi_zero)?; - out.set_item("zero_responsibility", res.zero_responsibility)?; - if let Some(&ll) = res.loglik_trace.last() { - let ic = mlsirm_core::fitstats::information_criteria(ll, res.n_parameters, n_persons); - let icd = pyo3::types::PyDict::new(py); - icd.set_item("aic", ic.aic)?; - icd.set_item("bic", ic.bic)?; - icd.set_item("aicc", ic.aicc)?; - icd.set_item("sabic", ic.sabic)?; - icd.set_item("caic", ic.caic)?; - icd.set_item("n_parameters", ic.n_parameters)?; - icd.set_item("n", ic.n)?; - out.set_item("ic", icd)?; - } - out.set_item("loglik_trace", res.loglik_trace)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - Ok(out.into()) -} - -fn parse_xi_rule(name: &str, q_xi: usize, xi_points: usize, xi_seed: u64) -> PyResult { - match XiRuleKind::parse(name) { - Some(XiRuleKind::GaussHermite) => Ok(XiRule::GaussHermite { q_xi }), - Some(XiRuleKind::Halton) => Ok(XiRule::Halton { - n: xi_points, - shift_seed: xi_seed, - }), - Some(XiRuleKind::MonteCarlo) => Ok(XiRule::MonteCarlo { - n: xi_points, - seed: xi_seed.max(1), - }), - None => Err(PyValueError::new_err( - "xi_rule must be one of ['gh', 'qmc', 'mc']", - )), - } -} - -macro_rules! bank_from_args { - ($alpha:expr, $b:expr, $zeta:expr, $tau:expr, $factor_id:expr, $model:expr, - $n_dims:expr, $latent_dim:expr, $eps:expr, $factors:ident, $bank:ident) => { - let $factors = convert_factor_id($factor_id.as_slice()?, $n_dims)?; - let $bank = ItemBank { - alpha: $alpha.as_slice()?, - b: $b.as_slice()?, - zeta: $zeta.as_slice()?, - tau: $tau, - factor_id: &$factors, - model_type: parse_model_type($model)?, - n_dims: $n_dims, - latent_dim: $latent_dim, - eps_distance: $eps, - }; - }; -} - -/// EAP scoring of response vectors against frozen item parameters. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, prior_mean, prior_sd, q_theta = 21, xi_rule = "gh", q_xi = 11, - xi_points = 256, xi_seed = 0, device = "auto", -))] -fn score_bank_eap( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - q_theta: usize, - xi_rule: &str, - q_xi: usize, - xi_points: usize, - xi_seed: u64, - device: &str, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let rule = parse_xi_rule(xi_rule, q_xi, xi_points, xi_seed)?; - let dev = Device::parse(device) - .ok_or_else(|| PyValueError::new_err(format!("unknown device: {device}")))?; - let res = core_score_eap_device( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - &prior, - q_theta, - rule, - dev, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta_eap", res.theta_eap)?; - out.set_item("theta_sd", res.theta_sd)?; - out.set_item("xi_eap", res.xi_eap)?; - out.set_item("loglik", res.loglik)?; - Ok(out.into()) -} - -/// MAP scoring (posterior Newton) against frozen item parameters. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, prior_mean, prior_sd, max_iter = 100, tol = 1e-6, -))] -fn score_bank_map( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - max_iter: usize, - tol: f64, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let res = core_score_map( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - &prior, - max_iter, - tol, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta_map", res.theta_map)?; - out.set_item("theta_se", res.theta_se)?; - out.set_item("xi_map", res.xi_map)?; - out.set_item("log_posterior", res.log_posterior)?; - out.set_item("converged", res.converged)?; - Ok(out.into()) -} - -/// Warm's (1989) weighted-likelihood ability estimates for a unidimensional dichotomous test (Rust -/// compute path). The bias-reduced maximum-likelihood estimator: solves -/// `dlnL/dtheta + J(theta)/(2 I(theta)) = 0` with `J = sum_i P_i' P_i''/(P_i Q_i)` (computed directly; -/// it equals `I'` for the 2PL/Rasch only, and is neither `I'` nor `I'/2` for the 3PL/4PL), yielding a -/// FINITE estimate for the all-correct / -/// all-incorrect patterns where the MLE diverges. `a`/`b`/`c`/`d` are per-item NATURAL-scale parameters -/// (`a` the slope, NOT log-alpha) with `0 <= c_i < d_i <= 1` (2PL: `c=0, d=1`); `y`/`observed` are -/// row-major `n_persons * n_items` (`0/1`; missing items dropped per person). Returns a dict with -/// `theta` (`n_persons`), `se` (`1/sqrt(I)`), and `boundary` (root clamped to `+/- theta_bound`). -/// -/// Reference (APA 7th ed.): -/// Warm, T. A. (1989). Weighted likelihood estimation of ability in item response theory. -/// Psychometrika, 54(3), 427-450. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (a, b, c, d, y, observed, n_persons, n_items, theta_bound = 20.0, tol = 1e-8))] -fn score_wle( - py: Python<'_>, - a: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - c: PyReadonlyArray1<'_, f64>, - d: PyReadonlyArray1<'_, f64>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - n_items: usize, - theta_bound: f64, - tol: f64, -) -> PyResult> { - if a.as_slice()?.len() != n_items { - return Err(PyValueError::new_err("a length must equal n_items")); - } - let res = core_score_wle( - a.as_slice()?, - b.as_slice()?, - c.as_slice()?, - d.as_slice()?, - y.as_slice()?, - observed.as_slice()?, - n_persons, - theta_bound, - tol, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta", res.theta)?; - out.set_item("se", res.se)?; - out.set_item("boundary", res.boundary)?; - Ok(out.into()) -} - -/// Zumbo (1999) logistic-regression DIF (Rust compute path; Swaminathan & Rogers, 1990). Regresses each -/// item response on the observed matching score, the group, and their interaction in three nested -/// logistic models, separating UNIFORM from NON-UNIFORM (crossing) DIF — the latter is invisible to the -/// Mantel-Haenszel procedure. `y` is a row-major `n_persons * n_items` `0/1` array; `group` is length -/// `n_persons` with `0` = reference, `1` = focal. Returns a dict of per-item arrays: `item`, -/// `chi2_uniform`/`p_uniform` and `chi2_nonuniform`/`p_nonuniform` (1 df each, DESCRIPTIVE and -/// unadjusted), `chi2_total`/`p_total` (2 df, the PRIMARY omnibus test that Benjamini-Hochberg adjusts), -/// `delta_r2` (Nagelkerke `R2(M2) - R2(M0)`), `delta_r2_uniform` (uncalibrated descriptive), -/// `jg_class` (Jodoin & Gierl, 2001 `"A"`/`"B"`/`"C"`, or `"U"` when undefined), `flagged_bh`, and -/// `converged`. A failed fit (separation, rank-deficient design, no convergence) reports NaN statistics, -/// `converged=False`, and is never flagged. -/// -/// References (APA 7th ed.): -/// Jodoin, M. G., & Gierl, M. J. (2001). Evaluating Type I error and power rates using an effect size -/// measure with the logistic regression procedure for DIF detection. Applied Measurement in -/// Education, 14(4), 329-349. -/// Swaminathan, H., & Rogers, H. J. (1990). Detecting differential item functioning using logistic -/// regression procedures. Journal of Educational Measurement, 27(4), 361-370. -/// Zumbo, B. D. (1999). A handbook on the theory and methods of differential item functioning (DIF). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, group, n_persons, n_items, exclude_studied_item = false, fdr_q = 0.05, max_iter = 50))] -fn logistic_dif( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - group: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - exclude_studied_item: bool, - fdr_q: f64, - max_iter: usize, -) -> PyResult> { - let yv = binary_u8(y.as_slice()?)?; - let gv: Vec = group - .as_slice()? - .iter() - .map(|&g| match g { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err( - "group labels must be 0 (reference) or 1 (focal)", - )), - }) - .collect::>()?; - let cfg = LogisticDifConfig { - exclude_studied_item, - fdr_q, - max_iter, - }; - let rows = - core_logistic_dif(&yv, &gv, n_persons, n_items, &cfg).map_err(PyValueError::new_err)?; - Ok(logistic_rows_dict(py, &rows)?.into()) -} - -/// Per-item arrays for a logistic-regression DIF sweep, shared by the plain and purified entry points. -fn logistic_rows_dict<'py>( - py: Python<'py>, - rows: &[LogisticDifRow], -) -> PyResult> { - let out = pyo3::types::PyDict::new(py); - out.set_item("item", rows.iter().map(|r| r.item).collect::>())?; - out.set_item( - "chi2_uniform", - rows.iter().map(|r| r.chi2_uniform).collect::>(), - )?; - out.set_item( - "p_uniform", - rows.iter().map(|r| r.p_uniform).collect::>(), - )?; - out.set_item( - "chi2_nonuniform", - rows.iter().map(|r| r.chi2_nonuniform).collect::>(), - )?; - out.set_item( - "p_nonuniform", - rows.iter().map(|r| r.p_nonuniform).collect::>(), - )?; - out.set_item( - "chi2_total", - rows.iter().map(|r| r.chi2_total).collect::>(), - )?; - out.set_item( - "p_total", - rows.iter().map(|r| r.p_total).collect::>(), - )?; - out.set_item( - "delta_r2", - rows.iter().map(|r| r.delta_r2).collect::>(), - )?; - out.set_item( - "delta_r2_uniform", - rows.iter().map(|r| r.delta_r2_uniform).collect::>(), - )?; - out.set_item( - "jg_class", - rows.iter().map(|r| r.jg_class.as_str()).collect::>(), - )?; - out.set_item( - "flagged_bh", - rows.iter().map(|r| r.flagged_bh).collect::>(), - )?; - out.set_item( - "converged", - rows.iter().map(|r| r.converged).collect::>(), - )?; - Ok(out) -} - -/// Attach the purification-loop metadata to a per-item row dict. -/// -/// The loop's scalar convergence flag is `purify_converged`, NOT `converged`: `logistic_rows_dict` -/// already publishes a per-item `converged` array (did each item's IRLS fit succeed), and -/// `PyDict::set_item` overwrites, so reusing the name silently destroyed a length-`J` array and -/// returned a bare `bool` under a key the caller expects to be indexable. The two flags answer -/// different questions and both are needed, so they get different names on BOTH entry points — the -/// Mantel-Haenszel dict has no `converged` key of its own, but an asymmetric spelling would be its own -/// trap. -fn purify_meta( - out: &pyo3::Bound<'_, pyo3::types::PyDict>, - anchor: Vec, - n_anchor: usize, - rounds: usize, - converged: bool, - termination_reason: &str, -) -> PyResult<()> { - debug_assert!( - !out.contains("purify_converged").unwrap_or(false), - "purification metadata would overwrite an existing per-item key" - ); - out.set_item("anchor", anchor)?; - out.set_item("n_anchor", n_anchor)?; - out.set_item("rounds", rounds)?; - out.set_item("purify_converged", converged)?; - out.set_item("purify_termination_reason", termination_reason)?; - Ok(()) -} - -/// Convert an `i64` response slice to `0/1` bytes, rejecting anything else. -fn binary_u8(slice: &[i64]) -> PyResult> { - slice - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("responses must be 0 or 1")), - }) - .collect() -} - -/// Rasch conditional maximum likelihood item difficulties (Rust compute path; Andersen, 1970, 1972). -/// Conditioning each response pattern on its raw score (the sufficient statistic for ability) eliminates -/// the person parameters, so the difficulties are estimated without any ability-distribution assumption -/// and consistently at fixed test length. `y` is a row-major `n_persons * n_items` complete `0/1` array -/// (persons scoring `0` or `n_items` are dropped). Returns a dict with `beta` (sum-zero item -/// difficulties), `se` (from the pseudoinverse of the conditional information), `loglik`, `n_iter`, -/// `converged`, and `n_used`. -/// -/// Reference (APA 7th ed.): -/// Andersen, E. B. (1972). The numerical solution of a set of conditional estimation equations. -/// Journal of the Royal Statistical Society: Series B, 34(1), 42-54. -#[pyfunction] -#[pyo3(signature = (y, n_persons, n_items, max_iter = 100, tol = 1e-8))] -fn fit_rasch_cml( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let yv = binary_u8(y.as_slice()?)?; - let res = core_fit_rasch_cml(&yv, n_persons, n_items, max_iter, tol) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("beta", res.beta)?; - out.set_item("se", res.se)?; - out.set_item("loglik", res.loglik)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("n_used", res.n_used)?; - Ok(out.into()) -} - -/// Andersen's (1973) conditional likelihood-ratio test of Rasch fit (Rust compute path). Partitions the -/// persons by `group` (labels `0..n_groups`), fits CML within each group and pooled, and refers -/// `LR = 2[sum_g llc_g - llc_pooled]` to `chi^2((n_groups - 1)(n_items - 1))`; a significant `LR` -/// rejects invariance of the item difficulties across the split. Returns a dict with `lr`, `df`, -/// `p_value`, and `n_used` (per-group retained counts). -/// -/// Reference (APA 7th ed.): -/// Andersen, E. B. (1973). A goodness of fit test for the Rasch model. Psychometrika, 38(1), 123-140. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, group, n_groups, n_persons, n_items, max_iter = 100, tol = 1e-8))] -fn andersen_lr_test( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - group: PyReadonlyArray1<'_, i64>, - n_groups: usize, - n_persons: usize, - n_items: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - if n_groups > 256 { - return Err(PyValueError::new_err("n_groups must be <= 256")); - } - let yv = binary_u8(y.as_slice()?)?; - let gv: Vec = group - .as_slice()? - .iter() - .map(|&g| { - if g < 0 || g as usize >= n_groups { - Err(PyValueError::new_err("group labels must be in 0..n_groups")) - } else { - Ok(g as u8) - } - }) - .collect::>()?; - let res = core_andersen_lr(&yv, &gv, n_groups, n_persons, n_items, max_iter, tol) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("lr", res.lr)?; - out.set_item("df", res.df)?; - out.set_item("p_value", res.p_value)?; - out.set_item("n_used", res.n_used)?; - out.set_item("converged", res.converged)?; - Ok(out.into()) -} - -/// Summed-score EAP conversion tables (Lord-Wingersky / Thissen et al. 1995). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, eps_distance, - prior_mean, prior_sd, q_theta = 21, xi_rule = "gh", q_xi = 11, xi_points = 256, - xi_seed = 0, device = "auto", -))] -fn eapsum_tables( - py: Python<'_>, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - q_theta: usize, - xi_rule: &str, - q_xi: usize, - xi_points: usize, - xi_seed: u64, - device: &str, -) -> PyResult>> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let rule = parse_xi_rule(xi_rule, q_xi, xi_points, xi_seed)?; - let device = Device::parse(device) - .ok_or_else(|| PyValueError::new_err("device must be one of ['cpu', 'gpu', 'auto']"))?; - let tables = core_eapsum_tables_device(&bank, &prior, q_theta, rule, device) - .map_err(PyValueError::new_err)?; - let mut out = Vec::new(); - for t in tables { - let d = pyo3::types::PyDict::new(py); - d.set_item("dim", t.dim)?; - d.set_item("n_items_dim", t.n_items_dim)?; - d.set_item("score_prob", t.score_prob)?; - d.set_item("eap", t.eap)?; - d.set_item("sd", t.sd)?; - out.push(d.into()); - } - Ok(out) -} - -/// Apply EAPsum tables to complete dichotomous response vectors in Rust. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, factor_id, n_dims, table_offsets, table_eap, table_sd, - device = "auto", -))] -fn score_eapsum( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - factor_id: PyReadonlyArray1<'_, i64>, - n_dims: usize, - table_offsets: PyReadonlyArray1<'_, i64>, - table_eap: PyReadonlyArray1<'_, f64>, - table_sd: PyReadonlyArray1<'_, f64>, - device: &str, -) -> PyResult> { - let factors: Vec = factor_id - .as_slice()? - .iter() - .map(|&value| { - usize::try_from(value) - .map_err(|_| PyValueError::new_err("factor_id values must be non-negative")) - }) - .collect::>()?; - let offsets: Vec = table_offsets - .as_slice()? - .iter() - .map(|&value| { - usize::try_from(value) - .map_err(|_| PyValueError::new_err("table offsets must be non-negative")) - }) - .collect::>()?; - let eap = table_eap.as_slice()?; - let sd = table_sd.as_slice()?; - if offsets.len() != n_dims + 1 || offsets.first() != Some(&0) { - return Err(PyValueError::new_err( - "table_offsets must have length n_dims + 1 and start at zero", - )); - } - if offsets.windows(2).any(|pair| pair[1] <= pair[0]) - || offsets.last() != Some(&eap.len()) - || eap.len() != sd.len() - { - return Err(PyValueError::new_err( - "table offsets must be strictly increasing and end at the table value length", - )); - } - let tables: Vec = (0..n_dims) - .map(|dim| { - let start = offsets[dim]; - let end = offsets[dim + 1]; - EapSumTable { - dim, - n_items_dim: end - start - 1, - score_prob: Vec::new(), - eap: eap[start..end].to_vec(), - sd: sd[start..end].to_vec(), - } - }) - .collect(); - let device = Device::parse(device) - .ok_or_else(|| PyValueError::new_err("device must be one of ['cpu', 'gpu', 'auto']"))?; - let result = core_score_eapsum_device( - y.as_slice()?, - observed.as_slice()?, - n_persons, - &factors, - n_dims, - &tables, - device, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta_eap", result.theta_eap)?; - out.set_item("theta_sd", result.theta_sd)?; - out.set_item("n_observed", result.n_observed)?; - Ok(out.into()) -} - -/// Orlando-Thissen S-X2 with the large-N practical-significance effect size. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, prior_mean, prior_sd, q_theta = 21, xi_rule = "gh", q_xi = 11, - xi_points = 256, xi_seed = 0, min_expected = 1.0, fdr_q = 0.05, min_effect = 0.0, - person_weight = None, -))] -fn s_x2_stat( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - q_theta: usize, - xi_rule: &str, - q_xi: usize, - xi_points: usize, - xi_seed: u64, - min_expected: f64, - fdr_q: f64, - min_effect: f64, - person_weight: Option>, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let cfg = SX2Config { - q_theta, - xi_rule: parse_xi_rule(xi_rule, q_xi, xi_points, xi_seed)?, - min_expected, - fdr_q, - min_effect, - }; - let weight_storage = match &person_weight { - Some(w) => Some(w.as_slice()?.to_vec()), - None => None, - }; - let res = core_s_x2( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - &prior, - &cfg, - weight_storage.as_deref(), - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("statistic", res.statistic)?; - out.set_item("g2_statistic", res.g2_statistic)?; - out.set_item("df", res.df)?; - out.set_item("p_value", res.p_value)?; - out.set_item("g2_p_value", res.g2_p_value)?; - out.set_item("rms_residual", res.rms_residual)?; - out.set_item("flagged_bh", res.flagged_bh)?; - out.set_item("n_score_groups", res.n_score_groups)?; - Ok(out.into()) -} - -/// Per-person observed-vs-expected pass-rate residuals (Rust compute path). -#[pyfunction] -#[pyo3(signature = (y, observed, prob, n_persons))] -fn leniency_residuals_stat( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - prob: PyReadonlyArray1<'_, f64>, - n_persons: usize, -) -> PyResult> { - let res = core_leniency_residuals( - y.as_slice()?, - observed.as_slice()?, - prob.as_slice()?, - n_persons, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("residual", res.residual)?; - out.set_item("observed_mean", res.observed_mean)?; - out.set_item("expected_mean", res.expected_mean)?; - out.set_item("n_observed", res.n_observed)?; - out.set_item("mean", res.mean)?; - out.set_item("sd", res.sd)?; - out.set_item("abs_p95", res.abs_p95)?; - Ok(out.into()) -} - -/// IRT scale linking (moment / Haebara / Stocking-Lord) for a common-item -/// design. `theta`/`weight` are used by the characteristic-curve methods. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (a_old, b_old, a_new, b_new, theta, weight, method = "stocking_lord"))] -fn irt_link( - py: Python<'_>, - a_old: PyReadonlyArray1<'_, f64>, - b_old: PyReadonlyArray1<'_, f64>, - a_new: PyReadonlyArray1<'_, f64>, - b_new: PyReadonlyArray1<'_, f64>, - theta: PyReadonlyArray1<'_, f64>, - weight: PyReadonlyArray1<'_, f64>, - method: &str, -) -> PyResult> { - let m = LinkMethod::parse(method) - .ok_or_else(|| PyValueError::new_err(format!("unknown linking method: {method}")))?; - let res = core_irt_link( - a_old.as_slice()?, - b_old.as_slice()?, - a_new.as_slice()?, - b_new.as_slice()?, - theta.as_slice()?, - weight.as_slice()?, - m, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("slope", res.slope)?; - out.set_item("intercept", res.intercept)?; - out.set_item("criterion", res.criterion)?; - out.set_item("n_iter", res.n_iter)?; - out.set_item("converged", res.converged)?; - out.set_item("termination_reason", res.termination_reason)?; - out.set_item("max_iter", res.max_iter)?; - out.set_item("final_objective_span", res.final_objective_span)?; - out.set_item("objective_tolerance", res.objective_tolerance)?; - out.set_item("final_parameter_span", res.final_parameter_span)?; - out.set_item("parameter_tolerance", res.parameter_tolerance)?; - Ok(out.into()) -} - -fn equate_result_dict(py: Python<'_>, res: EquateResult) -> PyResult> { - let out = pyo3::types::PyDict::new(py); - out.set_item("x_scores", res.x_scores)?; - out.set_item("y_equivalents", res.y_equivalents)?; - out.set_item("mu_x", res.mu_x)?; - out.set_item("sigma_x", res.sigma_x)?; - out.set_item("mu_y", res.mu_y)?; - out.set_item("sigma_y", res.sigma_y)?; - out.set_item("mu_eq", res.mu_eq)?; - out.set_item("sigma_eq", res.sigma_eq)?; - out.set_item("slope", res.slope)?; - out.set_item("intercept", res.intercept)?; - out.set_item("n_x", res.n_x)?; - out.set_item("n_y", res.n_y)?; - out.set_item("h_x", res.h_x)?; - out.set_item("h_y", res.h_y)?; - Ok(out.into()) -} - -/// Univariate log-linear presmoothing of a score-frequency distribution (Rust -/// compute path; Holland & Thayer, 2000). `counts` are raw frequencies over -/// scores 0..=k; `degree` moments are preserved. Returns a dict with the smoothed -/// `probs`, `log_lik`, `aic`, `bic`, `moments`, `converged`, `iters`. -#[pyfunction] -#[pyo3(signature = (counts, degree = 6))] -fn loglinear_smooth( - py: Python<'_>, - counts: PyReadonlyArray1<'_, f64>, - degree: usize, -) -> PyResult> { - let fit = core_loglinear_smooth(counts.as_slice()?, degree).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("probs", fit.probs)?; - out.set_item("log_lik", fit.log_lik)?; - out.set_item("aic", fit.aic)?; - out.set_item("bic", fit.bic)?; - out.set_item("moments", fit.moments)?; - out.set_item("converged", fit.converged)?; - out.set_item("iters", fit.iters)?; - out.set_item("termination_reason", fit.termination_reason)?; - out.set_item("final_gradient_max", fit.final_gradient_max)?; - out.set_item("gradient_tolerance", fit.gradient_tolerance)?; - Ok(out.into()) -} - -/// Equipercentile-family EG equating with optional log-linear presmoothing and a -/// choice of continuization kernel (Rust compute path; Kolen & Brennan, 2014; von -/// Davier et al., 2004). `continuization` is "uniform" (equipercentile) or -/// "gaussian" (kernel). `smooth_degree_x`/`_y` presmooth each form (None = raw); -/// `bandwidth_x`/`_y` fix the Gaussian bandwidth (None = penalty-selected). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (x_scores, y_scores, k_x, k_y, continuization = "uniform", smooth_degree_x = None, smooth_degree_y = None, bandwidth_x = None, bandwidth_y = None))] -fn equate_observed_scores_ext( - py: Python<'_>, - x_scores: PyReadonlyArray1<'_, f64>, - y_scores: PyReadonlyArray1<'_, f64>, - k_x: usize, - k_y: usize, - continuization: &str, - smooth_degree_x: Option, - smooth_degree_y: Option, - bandwidth_x: Option, - bandwidth_y: Option, -) -> PyResult> { - let cont = Continuization::parse(continuization).ok_or_else(|| { - PyValueError::new_err(format!("unknown continuization: {continuization}")) - })?; - let res = core_equate_eg_ext( - x_scores.as_slice()?, - y_scores.as_slice()?, - k_x, - k_y, - EgSmoothOptions { - continuization: cont, - smooth_degree_x, - smooth_degree_y, - bandwidth_x, - bandwidth_y, - }, - ) - .map_err(PyValueError::new_err)?; - equate_result_dict(py, res) -} - -/// Equivalent-groups observed-score equating of form X onto form Y (Rust compute -/// path; Kolen & Brennan, 2014). `method` is "mean", "linear", or -/// "equipercentile". Returns a dict with the conversion table and moments. -#[pyfunction] -#[pyo3(signature = (x_scores, y_scores, k_x, k_y, method = "equipercentile"))] -fn equate_observed_scores( - py: Python<'_>, - x_scores: PyReadonlyArray1<'_, f64>, - y_scores: PyReadonlyArray1<'_, f64>, - k_x: usize, - k_y: usize, - method: &str, -) -> PyResult> { - let m = EquateMethod::parse(method) - .ok_or_else(|| PyValueError::new_err(format!("unknown equating method: {method}")))?; - let res = core_equate_eg(x_scores.as_slice()?, y_scores.as_slice()?, k_x, k_y, m) - .map_err(PyValueError::new_err)?; - equate_result_dict(py, res) -} - -/// NEAT (common-item non-equivalent groups) observed-score equating (Rust compute -/// path; Kolen & Brennan, 2014). Population 1 takes X + anchor V, population 2 -/// takes Y + anchor V. `method` is "chained" or "frequency_estimation"; `w1` is -/// the population-1 synthetic weight (FE only). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (x_total, x_anchor, y_total, y_anchor, k_x, k_y, k_v, method = "chained", w1 = 0.5))] -fn equate_neat( - py: Python<'_>, - x_total: PyReadonlyArray1<'_, f64>, - x_anchor: PyReadonlyArray1<'_, f64>, - y_total: PyReadonlyArray1<'_, f64>, - y_anchor: PyReadonlyArray1<'_, f64>, - k_x: usize, - k_y: usize, - k_v: usize, - method: &str, - w1: f64, -) -> PyResult> { - let m = NeatMethod::parse(method) - .ok_or_else(|| PyValueError::new_err(format!("unknown NEAT method: {method}")))?; - let res = core_equate_neat( - x_total.as_slice()?, - x_anchor.as_slice()?, - y_total.as_slice()?, - y_anchor.as_slice()?, - k_x, - k_y, - k_v, - w1, - m, - ) - .map_err(PyValueError::new_err)?; - equate_result_dict(py, res) -} - -/// Tucker & Levine linear observed-score NEAT equating (Rust compute path; Kolen -/// & Brennan, 2014). `method` is "tucker" or "levine"; `anchor_kind` is "internal" -/// or "external" (affects Levine only). `w1` is the population-1 synthetic weight. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (x_total, x_anchor, y_total, y_anchor, k_x, k_y, method = "tucker", anchor_kind = "internal", w1 = 0.5))] -fn equate_neat_linear( - py: Python<'_>, - x_total: PyReadonlyArray1<'_, f64>, - x_anchor: PyReadonlyArray1<'_, f64>, - y_total: PyReadonlyArray1<'_, f64>, - y_anchor: PyReadonlyArray1<'_, f64>, - k_x: usize, - k_y: usize, - method: &str, - anchor_kind: &str, - w1: f64, -) -> PyResult> { - let m = NeatLinearMethod::parse(method) - .ok_or_else(|| PyValueError::new_err(format!("unknown linear NEAT method: {method}")))?; - let ak = AnchorKind::parse(anchor_kind) - .ok_or_else(|| PyValueError::new_err(format!("unknown anchor kind: {anchor_kind}")))?; - let res = core_equate_neat_linear( - x_total.as_slice()?, - x_anchor.as_slice()?, - y_total.as_slice()?, - y_anchor.as_slice()?, - k_x, - k_y, - w1, - m, - ak, - ) - .map_err(PyValueError::new_err)?; - equate_result_dict(py, res) -} - -fn see_result_dict(py: Python<'_>, res: SeeResult) -> PyResult> { - let out = pyo3::types::PyDict::new(py); - out.set_item("x_scores", res.x_scores)?; - out.set_item("y_equivalents", res.y_equivalents)?; - out.set_item("se", res.se)?; - out.set_item("ci_lo", res.ci_lo)?; - out.set_item("ci_hi", res.ci_hi)?; - out.set_item("n_boot", res.n_boot)?; - out.set_item("ci_level", res.ci_level)?; - Ok(out.into()) -} - -/// Nonparametric bootstrap standard errors of equating for the EG design (Rust -/// compute path; Kolen & Brennan, 2014, ch. 7). Resamples examinees per group -/// independently and re-equates; works for "mean"/"linear"/"equipercentile". -/// Returns a dict with per-score `se`, `ci_lo`, `ci_hi`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (x_scores, y_scores, k_x, k_y, method = "equipercentile", n_boot = 1000, ci_level = 0.95, seed = 0))] -fn bootstrap_see( - py: Python<'_>, - x_scores: PyReadonlyArray1<'_, f64>, - y_scores: PyReadonlyArray1<'_, f64>, - k_x: usize, - k_y: usize, - method: &str, - n_boot: usize, - ci_level: f64, - seed: u64, -) -> PyResult> { - let m = EquateMethod::parse(method) - .ok_or_else(|| PyValueError::new_err(format!("unknown equating method: {method}")))?; - let res = core_bootstrap_see( - x_scores.as_slice()?, - y_scores.as_slice()?, - k_x, - k_y, - m, - n_boot, - ci_level, - seed, - ) - .map_err(PyValueError::new_err)?; - see_result_dict(py, res) -} - -/// Closed-form delta-method standard errors of equating for the "mean"/"linear" -/// EG methods (Rust compute path; Kolen & Brennan, 2014). Errors on -/// equipercentile (use `bootstrap_see`). -#[pyfunction] -#[pyo3(signature = (x_scores, y_scores, k_x, k_y, method = "linear", ci_level = 0.95))] -fn analytic_see( - py: Python<'_>, - x_scores: PyReadonlyArray1<'_, f64>, - y_scores: PyReadonlyArray1<'_, f64>, - k_x: usize, - k_y: usize, - method: &str, - ci_level: f64, -) -> PyResult> { - let m = EquateMethod::parse(method) - .ok_or_else(|| PyValueError::new_err(format!("unknown equating method: {method}")))?; - let res = core_analytic_see( - x_scores.as_slice()?, - y_scores.as_slice()?, - k_x, - k_y, - m, - ci_level, - ) - .map_err(PyValueError::new_err)?; - see_result_dict(py, res) -} - -/// GPCM/nominal softmax cell log-probabilities at one node (parity surface for -/// the NumPy `category_logprobs` reference). -#[pyfunction] -#[pyo3(signature = (base, scores, intercepts))] -fn gpcm_cell_logprobs( - base: f64, - scores: PyReadonlyArray1<'_, f64>, - intercepts: PyReadonlyArray1<'_, f64>, -) -> PyResult> { - Ok(core_gpcm_logprobs( - base, - scores.as_slice()?, - intercepts.as_slice()?, - )) -} - -/// GRM cumulative-logit cell log-probabilities at one node. -#[pyfunction] -#[pyo3(signature = (base, thresholds))] -fn grm_cell_logprobs(base: f64, thresholds: PyReadonlyArray1<'_, f64>) -> PyResult> { - Ok(core_grm_logprobs(base, thresholds.as_slice()?)) -} - -/// Unidimensional polytomous marginal-EM fit (Rust compute path). `model` is -/// "grm" (default) or "gpcm"; `y` holds integer categories `0..n_cat-1`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, observed = None, model = "grm", q_theta = 21, max_iter = 80, tol = 1e-6))] -fn fit_poly_unidim( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - observed: Option>, - model: &str, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let fit = core_fit_poly_unidim( - &yv, obs, n_persons, n_items, n_cat, m, q_theta, max_iter, tol, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("slope", fit.slope)?; - out.set_item("cat_params", fit.cat_params)?; - out.set_item("loglik", fit.loglik)?; - out.set_item("n_iter", fit.n_iter)?; - out.set_item("converged", fit.converged)?; - out.set_item("termination_reason", fit.termination_reason)?; - out.set_item("loglik_trace", fit.loglik_trace)?; - out.set_item("final_delta", fit.final_delta)?; - out.set_item("stopping_tolerance", fit.stopping_tolerance)?; - Ok(out.into()) -} - -/// Unidimensional nominal categories model fit (Rust compute path). Returns a -/// dict with `scores` and `intercepts` (each `n_items` lists of `n_cat-1` free -/// values, baseline `a_0=c_0=0`), plus `loglik`/`n_iter`. -/// -/// References (APA 7th ed.): -/// Bock, R. D. (1972). Estimating item parameters and latent ability when -/// responses are scored in two or more nominal categories. Psychometrika, -/// 37(1), 29-51. https://doi.org/10.1007/BF02291411 -/// Thissen, D., Cai, L., & Bock, R. D. (2010). The nominal categories item -/// response model. In Handbook of polytomous item response theory models -/// (pp. 43-75). Routledge. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, observed = None, q_theta = 21, max_iter = 200, tol = 1e-6))] -fn fit_nominal( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - observed: Option>, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let fit = core_fit_nominal(&yv, obs, n_persons, n_items, n_cat, q_theta, max_iter, tol) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("scores", fit.scores)?; - out.set_item("intercepts", fit.intercepts)?; - out.set_item("loglik", fit.loglik)?; - out.set_item("n_iter", fit.n_iter)?; - out.set_item("converged", fit.converged)?; - out.set_item("termination_reason", fit.termination_reason)?; - out.set_item("loglik_trace", fit.loglik_trace)?; - out.set_item("final_delta", fit.final_delta)?; - out.set_item("stopping_tolerance", fit.stopping_tolerance)?; - Ok(out.into()) -} - -/// Polytomous person-fit l_z / l_z* (Rust compute path). Returns a dict with -/// per-person `lz`, `lz_star`, `theta_eap`, and `flagged` (l_z* < threshold). -/// -/// References (APA 7th ed.): -/// Drasgow, F., Levine, M. V., & Williams, E. A. (1985). Appropriateness -/// measurement with polychotomous item response models and standardized -/// indices. British Journal of Mathematical and Statistical Psychology, -/// 38(1), 67-86. https://doi.org/10.1111/j.2044-8317.1985.tb00817.x -/// Snijders, T. A. B. (2001). Asymptotic null distribution of person fit -/// statistics with estimated person parameter. Psychometrika, 66(3), -/// 331-342. https://doi.org/10.1007/BF02294437 -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, slope, cat_params, observed = None, model = "grm", q_theta = 21, prior_mean = 0.0, prior_sd = 1.0, flag_threshold = -1.645))] -fn poly_person_fit( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - observed: Option>, - model: &str, - q_theta: usize, - prior_mean: f64, - prior_sd: f64, - flag_threshold: f64, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let res = core_poly_person_fit( - &yv, - obs, - n_persons, - n_items, - n_cat, - slope.as_slice()?, - cat_params.as_slice()?, - m, - q_theta, - prior_mean, - prior_sd, - flag_threshold, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("lz", res.lz)?; - out.set_item("lz_star", res.lz_star)?; - out.set_item("theta_eap", res.theta_eap)?; - out.set_item("flagged", res.flagged)?; - Ok(out.into()) -} - -/// Simulate a polytomous computerized adaptive test (Rust compute path). Returns -/// a dict with per-simulee `theta_eap`, `theta_sd` (final CAT SE), and `n_used`. -/// -/// References (APA 7th ed.): -/// Dodd, B. G., De Ayala, R. J., & Koch, W. R. (1995). Computerized adaptive -/// testing with polytomous items. Applied Psychological Measurement, 19(1), -/// 5-22. https://doi.org/10.1177/014662169501900103 -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (true_theta, slope, cat_params, n_items, n_cat, model = "grm", q_theta = 21, se_threshold = 0.3, min_items = 5, max_items = 30, adaptive = true, seed = 0))] -fn poly_cat_simulate( - py: Python<'_>, - true_theta: PyReadonlyArray1<'_, f64>, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - n_items: usize, - n_cat: usize, - model: &str, - q_theta: usize, - se_threshold: f64, - min_items: usize, - max_items: usize, - adaptive: bool, - seed: u64, -) -> PyResult> { - let m = parse_poly_model(model)?; - let res = core_poly_cat_simulate( - true_theta.as_slice()?, - slope.as_slice()?, - cat_params.as_slice()?, - n_items, - n_cat, - m, - q_theta, - se_threshold, - min_items, - max_items, - adaptive, - seed, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta_eap", res.theta_eap)?; - out.set_item("theta_sd", res.theta_sd)?; - out.set_item("n_used", res.n_used)?; - Ok(out.into()) -} - -/// Warm's (1989) weighted-likelihood ability estimates for a unidimensional POLYTOMOUS test, GRM or -/// GPCM (Rust compute path). The polytomous counterpart of `score_wle`: solves -/// `dlnL/dtheta + J(theta)/(2 I(theta)) = 0` with `I = sum_k P'_k^2 / P_k` and -/// `J = sum_k P'_k P''_k / P_k` summed over the person's observed items — the exact generalization of -/// the dichotomous `sum_i P' P''/(P Q)`, which is its two-category case. `J` is computed DIRECTLY, not -/// as a derivative of `I`. -/// -/// Unlike `score_poly_eap` this applies NO prior, so the estimate is not shrunk toward a population -/// mean — the usual requirement when reporting individual scores. It stays FINITE for the all-lowest -/// and all-highest response patterns, where the maximum-likelihood estimate diverges. -/// -/// PCM is this GPCM path with `slope = 1`. RSM is NOT supported: its fitted `(delta, shared tau)` -/// parameterization is not convertible through any exposed API. -/// -/// The correction is confirmed against the `catR` package's source rather than a primary paper; see -/// the core `score_wle_poly` docs for the full verification status, including the in-repository proof -/// that `J = I'` holds for both shipped families (used only as a test oracle, never as a shortcut). -/// -/// `y` is row-major `n_persons * n_items` with categories in `0..n_cat`; `cat_params` is flattened -/// `n_items * (n_cat - 1)`. Returns a dict with `theta`, `se` and `boundary`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, slope, cat_params, observed = None, model = "grm", theta_bound = 20.0, tol = 1e-8))] -fn score_wle_poly( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - observed: Option>, - model: &str, - theta_bound: f64, - tol: f64, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let out_scores = core_score_wle_poly( - &yv, - obs, - n_persons, - n_items, - n_cat, - slope.as_slice()?, - cat_params.as_slice()?, - m, - theta_bound, - tol, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta", out_scores.theta)?; - out.set_item("se", out_scores.se)?; - out.set_item("boundary", out_scores.boundary)?; - Ok(out.into()) -} - -/// EAP trait scores from polytomous responses given fitted item parameters -/// (Rust compute path). Returns a dict with `theta_eap` and `theta_sd`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, slope, cat_params, observed = None, model = "grm", q_theta = 21))] -fn score_poly_eap( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - observed: Option>, - model: &str, - q_theta: usize, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let (eap, sd) = core_score_poly_eap( - &yv, - obs, - n_persons, - n_items, - n_cat, - slope.as_slice()?, - cat_params.as_slice()?, - m, - q_theta, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta_eap", eap)?; - out.set_item("theta_sd", sd)?; - Ok(out.into()) -} - -/// Polytomous item information curves: flattened `n_theta * n_items` I_i(theta). -#[pyfunction] -#[pyo3(signature = (theta, slope, cat_params, n_items, n_cat, model = "grm"))] -fn poly_information_curves( - theta: PyReadonlyArray1<'_, f64>, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - n_items: usize, - n_cat: usize, - model: &str, -) -> PyResult> { - let m = parse_poly_model(model)?; - core_poly_information_curves( - theta.as_slice()?, - slope.as_slice()?, - cat_params.as_slice()?, - n_items, - n_cat, - m, - ) - .map_err(PyValueError::new_err) -} - -/// Generalized S-X2 polytomous item fit (Rust compute path). Returns a dict with -/// per-item `statistic`, `df`, `p_value`, and `n_cells` (the retained cell count, -/// the reference df at KNOWN parameters). -/// -/// References (APA 7th ed.): -/// Kang, T., & Chen, T. T. (2008). Performance of the generalized S-X² item -/// fit index for polytomous IRT models. Journal of Educational Measurement, -/// 45(4), 391-406. https://doi.org/10.1111/j.1745-3984.2008.00070.x -/// Kang, T., & Chen, T. T. (2011). Performance of the generalized S-X² item -/// fit index for the graded response model. Asia Pacific Education Review, -/// 12(1), 89-96. https://doi.org/10.1007/s12564-010-9082-4 -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, slope, cat_params, observed = None, model = "grm", q_theta = 21, min_expected = 1.0))] -fn poly_item_fit_sx2( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - observed: Option>, - model: &str, - q_theta: usize, - min_expected: f64, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let res = core_poly_s_x2( - &yv, - obs, - n_persons, - n_items, - n_cat, - slope.as_slice()?, - cat_params.as_slice()?, - m, - q_theta, - min_expected, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("statistic", res.statistic)?; - out.set_item("df", res.df)?; - out.set_item("p_value", res.p_value)?; - out.set_item("n_cells", res.n_cells)?; - Ok(out.into()) -} - -/// Latent-space polytomous LSIRM fit (Rust compute path). Returns a dict of -/// item parameters (`slope`, `cat_params`, `zeta`) and person scores -/// (`theta_eap`, `theta_sd`, `xi_eap`), plus `loglik`/`n_iter`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, latent_dim, observed = None, model = "grm", q_theta = 11, q_xi = 11, max_iter = 60, tol = 1e-5))] -fn fit_poly_lsirm( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - latent_dim: usize, - observed: Option>, - model: &str, - q_theta: usize, - q_xi: usize, - max_iter: usize, - tol: f64, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let fit = core_fit_poly_lsirm( - &yv, obs, n_persons, n_items, n_cat, latent_dim, m, q_theta, q_xi, max_iter, tol, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("slope", fit.slope)?; - out.set_item("cat_params", fit.cat_params)?; - out.set_item("zeta", fit.zeta)?; - out.set_item("theta_eap", fit.theta_eap)?; - out.set_item("theta_sd", fit.theta_sd)?; - out.set_item("xi_eap", fit.xi_eap)?; - out.set_item("loglik", fit.loglik)?; - out.set_item("n_iter", fit.n_iter)?; - Ok(out.into()) -} - -/// Per-item mixed-format marginal MLE (Rust multithreaded CPU path). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, - n_persons, - n_items, - item_models, - n_categories, - observed = None, - latent_dim = 2, - q_theta = 21, - q_xi = 7, - max_iter = 100, - tol = 1e-5, - n_threads = 0 -))] -fn fit_mixed_items( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - item_models: Vec, - n_categories: PyReadonlyArray1<'_, i64>, - observed: Option>, - latent_dim: usize, - q_theta: usize, - q_xi: usize, - max_iter: usize, - tol: f64, - n_threads: usize, -) -> PyResult> { - let raw_y = y.as_slice()?; - let expected_len = n_persons - .checked_mul(n_items) - .ok_or_else(|| PyValueError::new_err("n_persons * n_items overflow"))?; - if raw_y.len() != expected_len { - return Err(PyValueError::new_err( - "y must have length n_persons * n_items", - )); - } - if item_models.len() != n_items { - return Err(PyValueError::new_err( - "item_models length must match n_items", - )); - } - let raw_categories = n_categories.as_slice()?; - if raw_categories.len() != n_items { - return Err(PyValueError::new_err( - "n_categories length must match n_items", - )); - } - let yv = raw_y - .iter() - .map(|&value| { - if value < 0 { - Err(PyValueError::new_err( - "responses must be non-negative integer categories", - )) - } else { - Ok(value as usize) - } - }) - .collect::>>()?; - let specs = item_models - .iter() - .zip(raw_categories) - .enumerate() - .map(|(item, (model, &n_cat))| { - if n_cat < 2 { - return Err(PyValueError::new_err(format!( - "item {item}: n_categories must be >= 2" - ))); - } - let kind = MixedItemKind::parse(model).map_err(PyValueError::new_err)?; - Ok(MixedItemSpec { - kind, - n_categories: n_cat as usize, - }) - }) - .collect::>>()?; - let mask = observed - .as_ref() - .map(|values| values.as_slice()) - .transpose()?; - let fit = core_fit_mixed_items( - &yv, mask, n_persons, n_items, &specs, latent_dim, q_theta, q_xi, max_iter, tol, n_threads, - ) - .map_err(PyValueError::new_err)?; - - let out = pyo3::types::PyDict::new(py); - let items = pyo3::types::PyList::empty(py); - for estimate in fit.items { - let item = pyo3::types::PyDict::new(py); - item.set_item("model", estimate.kind.as_str())?; - item.set_item("n_categories", estimate.n_categories)?; - item.set_item("slope", estimate.slope)?; - item.set_item("intercepts", estimate.intercepts)?; - item.set_item("thresholds", estimate.thresholds)?; - item.set_item("scores", estimate.scores)?; - item.set_item("location", estimate.location)?; - item.set_item("lower_asymptote", estimate.lower_asymptote)?; - item.set_item("upper_asymptote", estimate.upper_asymptote)?; - item.set_item("zeta", estimate.zeta)?; - items.append(item)?; - } - out.set_item("items", items)?; - out.set_item("theta_eap", fit.theta_eap)?; - out.set_item("theta_sd", fit.theta_sd)?; - out.set_item("xi_eap", fit.xi_eap)?; - out.set_item("latent_dim", fit.latent_dim)?; - out.set_item("loglik", fit.loglik)?; - out.set_item("loglik_trace", fit.loglik_trace)?; - out.set_item("n_iter", fit.n_iter)?; - out.set_item("converged", fit.converged)?; - out.set_item("termination_reason", fit.termination_reason)?; - out.set_item("n_threads", fit.n_threads)?; - Ok(out.into()) -} - -/// Lognormal response-time model (van der Linden, 2007; Rust compute path). -/// `times` is `n_persons * n_items` row-major raw response times (`> 0` where -/// observed). Returns a dict with item `alpha`/`beta`, `sigma_tau`, per-person -/// `tau_eap`/`tau_sd`, `loglik`, `n_iter`, `converged`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (times, observed, n_persons, n_items, max_iter = 500, tol = 1e-6, var_floor = 1e-4, sigma_floor = 1e-4, fix_sigma_tau = None))] -fn fit_rt_lognormal( - py: Python<'_>, - times: PyReadonlyArray1<'_, f64>, - observed: Option>, - n_persons: usize, - n_items: usize, - max_iter: usize, - tol: f64, - var_floor: f64, - sigma_floor: f64, - fix_sigma_tau: Option, -) -> PyResult> { - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let cfg = RtConfig { - max_iter, - tol, - var_floor, - sigma_floor, - fix_sigma_tau, - }; - let fit = core_fit_rt(times.as_slice()?, obs, n_persons, n_items, cfg) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("alpha", fit.alpha)?; - out.set_item("beta", fit.beta)?; - out.set_item("mu_tau", fit.mu_tau)?; - out.set_item("sigma_tau", fit.sigma_tau)?; - out.set_item("tau_eap", fit.tau_eap)?; - out.set_item("tau_sd", fit.tau_sd)?; - out.set_item("loglik", fit.loglik)?; - out.set_item("loglik_trace", fit.loglik_trace)?; - out.set_item("n_iter", fit.n_iter)?; - out.set_item("converged", fit.converged)?; - out.set_item("termination_reason", fit.termination_reason)?; - out.set_item("final_loglik_change", fit.final_loglik_change)?; - Ok(out.into()) -} - -/// van der Linden (2007) Level-2 joint speed-accuracy person covariance (two-stage; -/// item params fixed). `responses` (0/1) and `times` (`> 0` where observed) are -/// row-major `n_persons * n_items`; `a`/`b` are the 2PL raw slope/intercept, -/// `alpha`/`beta` the lognormal time discrimination/intensity. Returns a dict with -/// `rho`, `sigma_tau`, `s_theta2`, per-person `theta_eap`/`tau_eap`, `loglik`, -/// `n_iter`, `converged`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (responses, times, observed, a, b, alpha, beta, n_persons, n_items, q = 21, max_iter = 500, tol = 1e-6, fix_sigma_tau = None))] -fn fit_speed_accuracy_covariance( - py: Python<'_>, - responses: PyReadonlyArray1<'_, f64>, - times: PyReadonlyArray1<'_, f64>, - observed: Option>, - a: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - alpha: PyReadonlyArray1<'_, f64>, - beta: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, - q: usize, - max_iter: usize, - tol: f64, - fix_sigma_tau: Option, -) -> PyResult> { - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let cfg = SpeedAccuracyConfig { - q, - max_iter, - tol, - fix_sigma_tau, - ..Default::default() - }; - let fit = core_fit_sa( - responses.as_slice()?, - times.as_slice()?, - obs, - a.as_slice()?, - b.as_slice()?, - alpha.as_slice()?, - beta.as_slice()?, - n_persons, - n_items, - cfg, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("rho", fit.rho)?; - out.set_item("sigma_tau", fit.sigma_tau)?; - out.set_item("s_theta2", fit.s_theta2)?; - out.set_item("theta_eap", fit.theta_eap)?; - out.set_item("tau_eap", fit.tau_eap)?; - out.set_item("loglik", fit.loglik)?; - out.set_item("loglik_trace", fit.loglik_trace)?; - out.set_item("n_iter", fit.n_iter)?; - out.set_item("converged", fit.converged)?; - out.set_item("termination_reason", fit.termination_reason)?; - out.set_item("final_loglik_change", fit.final_loglik_change)?; - Ok(out.into()) -} - -/// Response-time person fit (van der Linden & Guo, 2008; Rust compute path). -/// `times` (`> 0` where observed) is row-major `n_persons * n_items`; `alpha`/`beta` -/// come from a fitted lognormal RT model. Returns a dict with per-person `w` -/// (`chi2(n-1)`), `df`, `l_t`, `p_value`, `flagged`, `tau_ml`, and -/// `n_persons*n_items` `z_resid`/`item_flag`. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (times, observed, n_persons, n_items, alpha, beta, alpha_level = 0.05, z_fast = 1.645))] -fn rt_person_fit( - py: Python<'_>, - times: PyReadonlyArray1<'_, f64>, - observed: Option>, - n_persons: usize, - n_items: usize, - alpha: PyReadonlyArray1<'_, f64>, - beta: PyReadonlyArray1<'_, f64>, - alpha_level: f64, - z_fast: f64, -) -> PyResult> { - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let res = core_rt_person_fit( - times.as_slice()?, - obs, - n_persons, - n_items, - alpha.as_slice()?, - beta.as_slice()?, - alpha_level, - z_fast, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("w", res.w)?; - out.set_item("df", res.df)?; - out.set_item("l_t", res.l_t)?; - out.set_item("p_value", res.p_value)?; - out.set_item("flagged", res.flagged)?; - out.set_item("tau_ml", res.tau_ml)?; - out.set_item("z_resid", res.z_resid)?; - out.set_item("item_flag", res.item_flag)?; - Ok(out.into()) -} - -/// M2 limited-information goodness-of-fit with RMSEA2 (+90% CI) and SRMSR. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, prior_mean, prior_sd, q_theta = 21, xi_rule = "gh", q_xi = 11, - xi_points = 256, xi_seed = 0, -))] -fn m2_stat( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - q_theta: usize, - xi_rule: &str, - q_xi: usize, - xi_points: usize, - xi_seed: u64, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let rule = parse_xi_rule(xi_rule, q_xi, xi_points, xi_seed)?; - let res = core_m2( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - &prior, - q_theta, - rule, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("m2", res.m2)?; - out.set_item("df", res.df)?; - out.set_item("p_value", res.p_value)?; - out.set_item("rmsea2", res.rmsea2)?; - out.set_item("rmsea2_ci_lower", res.rmsea2_ci_lower)?; - out.set_item("rmsea2_ci_upper", res.rmsea2_ci_upper)?; - out.set_item("srmsr", res.srmsr)?; - out.set_item("null_m2", res.null_m2)?; - out.set_item("null_df", res.null_df)?; - out.set_item("cfi", res.cfi)?; - out.set_item("tli", res.tli)?; - out.set_item("n_moments", res.n_moments)?; - out.set_item("n_parameters", res.n_parameters)?; - out.set_item("n_complete", res.n_complete)?; - Ok(out.into()) -} - -/// Polytomous M2 limited-information goodness-of-fit (Rust compute path) for a -/// fitted unidimensional GRM/GPCM. Returns m2, df, p_value, rmsea2 (+90% CI), -/// srmsr, null-model M2/df, CFI/TLIRT, and the bookkeeping counts. -/// -/// References (APA 7th ed.): -/// Maydeu-Olivares, A., & Joe, H. (2014). Assessing approximate fit in -/// categorical data analysis. Multivariate Behavioral Research, 49(4), -/// 305-328. https://doi.org/10.1080/00273171.2014.911075 -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, slope, cat_params, observed = None, model = "grm", q_theta = 21))] -fn poly_m2( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - observed: Option>, - model: &str, - q_theta: usize, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let res = core_poly_m2( - &yv, - obs, - n_persons, - n_items, - n_cat, - slope.as_slice()?, - cat_params.as_slice()?, - m, - q_theta, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("m2", res.m2)?; - out.set_item("df", res.df)?; - out.set_item("p_value", res.p_value)?; - out.set_item("rmsea2", res.rmsea2)?; - out.set_item("rmsea2_ci_lower", res.rmsea2_ci_lower)?; - out.set_item("rmsea2_ci_upper", res.rmsea2_ci_upper)?; - out.set_item("srmsr", res.srmsr)?; - out.set_item("null_m2", res.null_m2)?; - out.set_item("null_df", res.null_df)?; - out.set_item("cfi", res.cfi)?; - out.set_item("tli", res.tli)?; - out.set_item("n_moments", res.n_moments)?; - out.set_item("n_parameters", res.n_parameters)?; - out.set_item("n_complete", res.n_complete)?; - Ok(out.into()) -} - -/// Polytomous item-pair local-dependence diagnostics (Rust compute path). -/// Returns a dict of per-pair arrays (`item_i`, `item_j`, `x2`, `g2`, `p_value`, -/// `cramers_v`, `max_abs_std_resid`, `n_pair`) plus the shared `df = (K-1)^2`. -/// -/// References (APA 7th ed.): -/// Chen, W.-H., & Thissen, D. (1997). Local dependence indexes for item pairs -/// using item response theory. Journal of Educational and Behavioral -/// Statistics, 22(3), 265-289. https://doi.org/10.3102/10769986022003265 -/// Liu, Y., & Maydeu-Olivares, A. (2013). Local dependence diagnostics in IRT -/// modeling of binary data. Educational and Psychological Measurement, -/// 73(2), 254-274. https://doi.org/10.1177/0013164412453841 -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, n_persons, n_items, n_cat, slope, cat_params, observed = None, model = "grm", q_theta = 21))] -fn poly_local_dependence( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - observed: Option>, - model: &str, - q_theta: usize, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let res = core_poly_ld( - &yv, - obs, - n_persons, - n_items, - n_cat, - slope.as_slice()?, - cat_params.as_slice()?, - m, - q_theta, - ) - .map_err(PyValueError::new_err)?; - let item_i: Vec = res.pairs.iter().map(|&(i, _)| i).collect(); - let item_j: Vec = res.pairs.iter().map(|&(_, j)| j).collect(); - let out = pyo3::types::PyDict::new(py); - out.set_item("item_i", item_i)?; - out.set_item("item_j", item_j)?; - out.set_item("x2", res.x2)?; - out.set_item("g2", res.g2)?; - out.set_item("df", res.df)?; - out.set_item("p_value", res.p_value)?; - out.set_item("cramers_v", res.cramers_v)?; - out.set_item("max_abs_std_resid", res.max_abs_std_resid)?; - out.set_item("n_pair", res.n_pair)?; - Ok(out.into()) -} - -/// Likelihood-ratio DIF sweep for polytomous items via two-group marginal EM -/// (Rust compute path). Fits a compact model (all items group-invariant) once, -/// then per studied item an augmented model (that item freed per group); -/// `LR = 2 dloglik ~ chi2((n_groups-1) * n_cat)`. Impact (genuine group ability -/// differences) is absorbed by estimating each group's latent distribution in -/// both models. Returns a dict of per-item arrays (`item`, `lr`, `df`, -/// `p_value`, `flagged_bh`, `effect_size`). -/// -/// References (APA 7th ed.): -/// Thissen, D., Steinberg, L., & Wainer, H. (1993). Detection of differential -/// item functioning using the parameters of item response models. In P. W. -/// Holland & H. Wainer (Eds.), Differential item functioning (pp. 67-113). -/// Erlbaum. -/// Woehr, D. J., & Meriac, J. P. (2010). Using polytomous item response theory -/// to examine differential item and test functioning. In N. T. Tippins & -/// S. Adler (Eds.), Technology-enhanced assessment of talent (pp. 199-229). -/// Jossey-Bass. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, group_id, n_groups, n_persons, n_items, n_cat, observed = None, - model = "gpcm", studied_items = None, q_theta = 21, max_iter = 200, tol = 1e-5, fdr_q = 0.05, -))] -fn poly_dif( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - group_id: PyReadonlyArray1<'_, i64>, - n_groups: usize, - n_persons: usize, - n_items: usize, - n_cat: usize, - observed: Option>, - model: &str, - studied_items: Option>, - q_theta: usize, - max_iter: usize, - tol: f64, - fdr_q: f64, -) -> PyResult> { - let m = parse_poly_model(model)?; - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let gid: Vec = group_id - .as_slice()? - .iter() - .map(|&g| { - if g < 0 { - Err(PyValueError::new_err("group_id must be non-negative")) - } else { - Ok(g as usize) - } - }) - .collect::>()?; - let studied_storage: Option> = match &studied_items { - Some(s) => Some( - s.as_slice()? - .iter() - .map(|&j| { - if j < 0 { - Err(PyValueError::new_err("studied_items must be non-negative")) - } else { - Ok(j as usize) - } - }) - .collect::>()?, - ), - None => None, - }; - let rows = core_poly_dif( - &yv, - obs, - &gid, - n_groups, - n_persons, - n_items, - n_cat, - m, - studied_storage.as_deref(), - q_theta, - max_iter, - tol, - fdr_q, - ) - .map_err(PyValueError::new_err)?; - let item: Vec = rows.iter().map(|r| r.item).collect(); - let lr: Vec = rows.iter().map(|r| r.lr).collect(); - let df: Vec = rows.iter().map(|r| r.df).collect(); - let p_value: Vec = rows.iter().map(|r| r.p_value).collect(); - let flagged: Vec = rows.iter().map(|r| r.flagged_bh).collect(); - let effect: Vec = rows.iter().map(|r| r.effect_size).collect(); - let out = pyo3::types::PyDict::new(py); - out.set_item("item", item)?; - out.set_item("lr", lr)?; - out.set_item("df", df)?; - out.set_item("p_value", p_value)?; - out.set_item("flagged_bh", flagged)?; - out.set_item("effect_size", effect)?; - Ok(out.into()) -} - -/// Mantel-Haenszel differential item functioning (Rust compute path; Holland & Thayer, 1988). The -/// observed-score, calibration-free DIF test: examinees are matched on the number-correct total -/// (studied item included by default; `exclude_studied_item=True` uses the rest score), and per item a -/// common odds ratio `alpha_MH` is estimated across the `2 x 2` (group x response) tables. `y` is a -/// row-major `n_persons * n_items` `0/1` array; `group` is length `n_persons` with `0` = reference and -/// `1` = focal. Returns a dict of per-item arrays: `item`, `alpha_mh`, `chi2_mh`, `p_value` (chi2 df 1), -/// `mh_d_dif` (ETS delta `-2.35 ln(alpha_MH)`, negative = harder for the focal group), `se_d_dif` -/// (Robins-Breslow-Greenland), `std_p_dif` (Dorans & Kulick, 1986, focal minus reference), `ets_class` -/// (ETS `"A"`/`"B"`/`"C"`, or `"U"` when undefined), and `flagged_bh` (Benjamini-Hochberg at `fdr_q`). -/// NaN statistics / `"U"` mean the item had no DIF-informative strata or a degenerate odds ratio. -/// -/// References (APA 7th ed.): -/// Dorans, N. J., & Kulick, E. (1986). Demonstrating the utility of the standardization approach to -/// assessing unexpected differential item performance on the Scholastic Aptitude Test. Journal of -/// Educational Measurement, 23(4), 355-368. -/// Holland, P. W., & Thayer, D. T. (1988). Differential item performance and the Mantel-Haenszel -/// procedure. In H. Wainer & H. I. Braun (Eds.), Test validity (pp. 129-145). Erlbaum. -/// Robins, J., Breslow, N., & Greenland, S. (1986). Estimators of the Mantel-Haenszel variance -/// consistent in both sparse data and large-strata limiting models. Biometrics, 42(2), 311-323. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, group, n_persons, n_items, exclude_studied_item = false, fdr_q = 0.05))] -fn mantel_haenszel_dif( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - group: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - exclude_studied_item: bool, - fdr_q: f64, -) -> PyResult> { - let yv: Vec = y - .as_slice()? - .iter() - .map(|&v| match v { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err("y responses must be 0 or 1")), - }) - .collect::>()?; - let gv: Vec = group - .as_slice()? - .iter() - .map(|&g| match g { - 0 => Ok(0u8), - 1 => Ok(1u8), - _ => Err(PyValueError::new_err( - "group labels must be 0 (reference) or 1 (focal)", - )), - }) - .collect::>()?; - let cfg = MhDifConfig { - exclude_studied_item, - fdr_q, - }; - let rows = core_mh_dif(&yv, &gv, n_persons, n_items, &cfg).map_err(PyValueError::new_err)?; - Ok(mh_rows_dict(py, &rows)?.into()) -} - -/// Uniform SIBTEST (Rust compute path; Shealy & Stout, 1993, as implemented in Chalmers, 2012 — the -/// primary text was not consulted, see the core module notes). The third observed-score DIF procedure, -/// and the only one that corrects the MATCHING CRITERION for measurement error: under impact, two -/// examinees from different groups with the same observed score have different expected TRUE scores, so -/// each group's conditional mean is transported from its own Kelley-regressed true score to a common -/// target before being compared. Item purification cannot substitute for this — it fixes which items -/// are in the criterion, not the regression of true score on observed score. -/// -/// Each item in turn is the studied subtest; the valid subtest is every OTHER item, always disjoint. -/// Returns per-item arrays `item`, `beta_uni`, `se_beta`, `b_uni`, `p_value`, `alpha_ref`, -/// `alpha_focal`, `n_strata_used`, `flagged_bh`. -/// -/// SIGN WARNING: `beta_uni > 0` means harder for the FOCAL group — the OPPOSITE orientation to -/// `mantel_haenszel_dif`'s `mh_d_dif` and `std_p_dif`, which go negative in that same case. -#[pyfunction] -#[pyo3(signature = (y, group, n_persons, n_items, fdr_q = 0.05, j_min = 5))] -fn sibtest( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - group: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - fdr_q: f64, - j_min: usize, -) -> PyResult> { - let yv = binary_u8(y.as_slice()?)?; - let gv = binary_u8(group.as_slice()?)?; - let cfg = SibtestConfig { fdr_q, j_min }; - let rows = core_sibtest(&yv, &gv, n_persons, n_items, &cfg).map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("item", rows.iter().map(|r| r.item).collect::>())?; - out.set_item( - "beta_uni", - rows.iter().map(|r| r.beta_uni).collect::>(), - )?; - out.set_item( - "se_beta", - rows.iter().map(|r| r.se_beta).collect::>(), - )?; - out.set_item("b_uni", rows.iter().map(|r| r.b_uni).collect::>())?; - out.set_item( - "p_value", - rows.iter().map(|r| r.p_value).collect::>(), - )?; - out.set_item( - "alpha_ref", - rows.iter().map(|r| r.alpha_ref).collect::>(), - )?; - out.set_item( - "alpha_focal", - rows.iter().map(|r| r.alpha_focal).collect::>(), - )?; - out.set_item( - "n_strata_used", - rows.iter().map(|r| r.n_strata_used).collect::>(), - )?; - out.set_item( - "flagged_bh", - rows.iter().map(|r| r.flagged_bh).collect::>(), - )?; - Ok(out.into()) -} - -/// Raju (1988/1990) signed/unsigned ICC-area DIF for 2PL (or common-c 3PL) -/// items already linked to a common scale (Rust compute path; formula oracle: -/// difR `RajuZ.R`/`difRaju.R` READ in full — the primary Raju papers were NOT -/// read; both areas and all delta-method partials were re-derived by hand and -/// quadrature/FD-verified in adversarial spec review; see the core module). -/// -/// Signed: `h = b_foc - b_ref` (positive = harder for focal). Unsigned: -/// `h = |H|`, the total area between the ICCs. `z = H / se(H)` from unscaled -/// quantities; for common-c 3PL, `h` and `se` are reported scaled by `(1-c)`. -/// Returns arrays `h`, `se`, `z`, `p_value` plus `dif_items` and `signed`. -#[pyfunction] -#[pyo3(signature = (a_ref, b_ref, se_a_ref, se_b_ref, cov_ab_ref, a_foc, b_foc, se_a_foc, se_b_foc, cov_ab_foc, guess = None, signed = false, alpha = 0.05))] -#[allow(clippy::too_many_arguments)] -fn raju_area( - py: Python<'_>, - a_ref: PyReadonlyArray1<'_, f64>, - b_ref: PyReadonlyArray1<'_, f64>, - se_a_ref: PyReadonlyArray1<'_, f64>, - se_b_ref: PyReadonlyArray1<'_, f64>, - cov_ab_ref: PyReadonlyArray1<'_, f64>, - a_foc: PyReadonlyArray1<'_, f64>, - b_foc: PyReadonlyArray1<'_, f64>, - se_a_foc: PyReadonlyArray1<'_, f64>, - se_b_foc: PyReadonlyArray1<'_, f64>, - cov_ab_foc: PyReadonlyArray1<'_, f64>, - guess: Option>, - signed: bool, - alpha: f64, -) -> PyResult> { - let guess_vec = match &guess { - Some(g) => Some(g.as_slice()?.to_vec()), - None => None, - }; - let res = core_raju_area( - a_ref.as_slice()?, - b_ref.as_slice()?, - se_a_ref.as_slice()?, - se_b_ref.as_slice()?, - cov_ab_ref.as_slice()?, - a_foc.as_slice()?, - b_foc.as_slice()?, - se_a_foc.as_slice()?, - se_b_foc.as_slice()?, - cov_ab_foc.as_slice()?, - guess_vec.as_deref(), - signed, - alpha, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("h", numpy::PyArray1::from_slice(py, &res.h))?; - out.set_item("se", numpy::PyArray1::from_slice(py, &res.se))?; - out.set_item("z", numpy::PyArray1::from_slice(py, &res.z))?; - out.set_item("p_value", numpy::PyArray1::from_slice(py, &res.p_value))?; - out.set_item("dif_items", res.dif_items)?; - out.set_item("signed", res.signed)?; - Ok(out.into()) -} - -/// Per-item arrays for a Mantel-Haenszel sweep, shared by the plain and purified entry points. -fn mh_rows_dict<'py>( - py: Python<'py>, - rows: &[MhDifRow], -) -> PyResult> { - let out = pyo3::types::PyDict::new(py); - out.set_item("item", rows.iter().map(|r| r.item).collect::>())?; - out.set_item( - "alpha_mh", - rows.iter().map(|r| r.alpha_mh).collect::>(), - )?; - out.set_item( - "chi2_mh", - rows.iter().map(|r| r.chi2_mh).collect::>(), - )?; - out.set_item( - "p_value", - rows.iter().map(|r| r.p_value).collect::>(), - )?; - out.set_item( - "mh_d_dif", - rows.iter().map(|r| r.mh_d_dif).collect::>(), - )?; - out.set_item( - "se_d_dif", - rows.iter().map(|r| r.se_d_dif).collect::>(), - )?; - out.set_item( - "std_p_dif", - rows.iter().map(|r| r.std_p_dif).collect::>(), - )?; - out.set_item( - "ets_class", - rows.iter() - .map(|r| r.ets_class.as_str()) - .collect::>(), - )?; - out.set_item( - "flagged_bh", - rows.iter().map(|r| r.flagged_bh).collect::>(), - )?; - Ok(out) -} - -/// Mantel-Haenszel DIF with an ITERATIVELY PURIFIED matching criterion (Rust compute path; Candell & -/// Drasgow, 1988; Clauser, Mazor & Hambleton, 1993). The criterion is rebuilt from the currently -/// unflagged (anchor) items and the sweep re-run until the flagged set stabilises or `max_rounds` is -/// reached, which reduces the contamination the raw number-correct total suffers when it contains the -/// very items under test. Returns the same per-item arrays as `mantel_haenszel_dif` plus `anchor` -/// (bool per item), `n_anchor`, `rounds`, `purify_converged` (scalar), and -/// `purify_termination_reason`. -/// -/// IMPORTANT: the anchor is selected from the same data that is then tested against it, so the returned -/// p-values are conditional on a data-dependent selection. They are NOT guaranteed super-uniform under -/// the null and Benjamini-Hochberg does NOT control the FDR at `fdr_q` for a purified sweep — treat -/// `flagged_bh` here as a screening device. Purification reduces rather than removes contamination and -/// can fail when DIF is unbalanced in direction (Wang & Su, 2004); Mantel-Haenszel is also blind to -/// crossing DIF, so a purely non-uniform item stays in the anchor and keeps contaminating it. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, group, n_persons, n_items, exclude_studied_item = false, fdr_q = 0.05, max_rounds = 3, min_anchor_items = 4))] -fn mantel_haenszel_dif_purified( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - group: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - exclude_studied_item: bool, - fdr_q: f64, - max_rounds: usize, - min_anchor_items: usize, -) -> PyResult> { - let yv = binary_u8(y.as_slice()?)?; - let gv = binary_u8(group.as_slice()?)?; - let cfg = MhDifConfig { - exclude_studied_item, - fdr_q, - }; - let purify = PurifyConfig { - max_rounds, - min_anchor_items, - }; - let res = core_mh_purified(&yv, &gv, n_persons, n_items, &cfg, &purify) - .map_err(PyValueError::new_err)?; - let out = mh_rows_dict(py, &res.rows)?; - purify_meta( - &out, - res.anchor, - res.n_anchor, - res.rounds, - res.converged, - res.termination_reason, - )?; - Ok(out.into()) -} - -/// Zumbo logistic-regression DIF with an ITERATIVELY PURIFIED matching criterion (Rust compute path). -/// Same purification loop as `mantel_haenszel_dif_purified`, with the flag taken from `jg_class` (the -/// 2-df omnibus test). Returns the `logistic_dif` per-item arrays — including its PER-ITEM `converged` -/// array — plus `anchor`, `n_anchor`, `rounds`, scalar `purify_converged`, and -/// `purify_termination_reason`. The same caveat -/// applies: the anchor is data-selected, so the p-values carry no FDR guarantee and the flags are a -/// screening device. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (y, group, n_persons, n_items, exclude_studied_item = false, fdr_q = 0.05, max_iter = 50, max_rounds = 3, min_anchor_items = 4))] -fn logistic_dif_purified( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - group: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - exclude_studied_item: bool, - fdr_q: f64, - max_iter: usize, - max_rounds: usize, - min_anchor_items: usize, -) -> PyResult> { - let yv = binary_u8(y.as_slice()?)?; - let gv = binary_u8(group.as_slice()?)?; - let cfg = LogisticDifConfig { - exclude_studied_item, - fdr_q, - max_iter, - }; - let purify = PurifyConfig { - max_rounds, - min_anchor_items, - }; - let res = core_logistic_purified(&yv, &gv, n_persons, n_items, &cfg, &purify) - .map_err(PyValueError::new_err)?; - let out = logistic_rows_dict(py, &res.rows)?; - purify_meta( - &out, - res.anchor, - res.n_anchor, - res.rounds, - res.converged, - res.termination_reason, - )?; - Ok(out.into()) -} - -/// Nonparametric polytomous person-fit U3poly (Rust compute path). Generalizes -/// van der Flier's U3 to ordered polytomous items via sample item-step response -/// functions; no fitted IRT model. Returns a dict of per-person arrays -/// (`u3poly` in [0,1], `total_score`, `flagged`); NaN where undefined. `cutoff` -/// (see `u3_bootstrap_cutoff`) flags `u3poly >= cutoff`. -/// -/// References (APA 7th ed.): -/// Emons, W. H. M. (2008). Nonparametric person-fit analysis of polytomous -/// item scores. Applied Psychological Measurement, 32(3), 224-247. -/// https://doi.org/10.1177/0146621607302479 -#[pyfunction] -#[pyo3(signature = (y, n_persons, n_items, n_cat, observed = None, cutoff = None))] -fn u3_person_fit( - py: Python<'_>, - y: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_cat: usize, - observed: Option>, - cutoff: Option, -) -> PyResult> { - let obs = observed.as_ref().map(|o| o.as_slice()).transpose()?; - let yv = poly_responses(y.as_slice()?, obs, n_cat)?; - let res = core_u3_poly_person_fit(&yv, obs, n_persons, n_items, n_cat, cutoff) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("u3poly", res.u3poly)?; - out.set_item("total_score", res.total_score)?; - out.set_item("flagged", res.flagged)?; - Ok(out.into()) -} - -/// Simulated (1-alpha) critical value for `u3_person_fit` via a parametric -/// bootstrap from a fitted GRM/GPCM at theta ~ N(0,1) (Rust compute path). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = (n_persons, n_items, n_cat, slope, cat_params, model = "gpcm", alpha = 0.05, n_rep = 200, seed = 0))] -fn u3_bootstrap_cutoff( - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: PyReadonlyArray1<'_, f64>, - cat_params: PyReadonlyArray1<'_, f64>, - model: &str, - alpha: f64, - n_rep: usize, - seed: u64, -) -> PyResult { - let m = parse_poly_model(model)?; - core_u3_poly_cutoff( - n_persons, - n_items, - n_cat, - slope.as_slice()?, - cat_params.as_slice()?, - m, - alpha, - n_rep, - seed, - ) - .map_err(PyValueError::new_err) -} - -/// l_z / Snijders l_z* person fit at EAP estimates. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, theta, xi, prior_mean = None, flag_threshold = -1.645, -))] -fn person_fit_stat( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - theta: PyReadonlyArray1<'_, f64>, - xi: PyReadonlyArray1<'_, f64>, - prior_mean: Option>, - flag_threshold: f64, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let pm_storage = match &prior_mean { - Some(v) => v.as_slice()?.to_vec(), - None => Vec::new(), - }; - let res = core_person_fit( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - theta.as_slice()?, - xi.as_slice()?, - &pm_storage, - flag_threshold, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("lz", res.lz)?; - out.set_item("lz_star", res.lz_star)?; - out.set_item("flagged", res.flagged)?; - Ok(out.into()) -} - -/// Per-item infit/outfit mean squares at EAP estimates. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, theta, xi, -))] -fn infit_outfit_stat( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - theta: PyReadonlyArray1<'_, f64>, - xi: PyReadonlyArray1<'_, f64>, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let res = core_infit_outfit( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - theta.as_slice()?, - xi.as_slice()?, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("infit", res.infit)?; - out.set_item("outfit", res.outfit)?; - Ok(out.into()) -} - -/// Machine-scoring validation gates (Williamson, Xi & Breyer 2012). -#[pyfunction] -#[pyo3(signature = (auto, human, k, human_a = None, human_b = None, subgroup = None))] -fn validate_scoring( - py: Python<'_>, - auto: PyReadonlyArray1<'_, u32>, - human: PyReadonlyArray1<'_, u32>, - k: usize, - human_a: Option>, - human_b: Option>, - subgroup: Option>, -) -> PyResult> { - let hh_storage = match (&human_a, &human_b) { - (Some(a), Some(b)) => Some((a.as_slice()?.to_vec(), b.as_slice()?.to_vec())), - (None, None) => None, - _ => { - return Err(PyValueError::new_err( - "human_a and human_b must be provided together", - )) - } - }; - let sg_storage = match &subgroup { - Some(g) => Some(g.as_slice()?.to_vec()), - None => None, - }; - let verdict = core_validate_scoring( - auto.as_slice()?, - human.as_slice()?, - k, - hh_storage - .as_ref() - .map(|(a, b)| (a.as_slice(), b.as_slice())), - sg_storage.as_deref(), - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - let gates = pyo3::types::PyList::empty(py); - for g in &verdict.gates { - let gd = pyo3::types::PyDict::new(py); - gd.set_item("name", g.name)?; - gd.set_item("value", g.value)?; - gd.set_item("threshold", g.threshold)?; - gd.set_item("pass", g.pass)?; - gates.append(gd)?; - } - out.set_item("gates", gates)?; - out.set_item("exact_agreement", verdict.exact_agreement)?; - out.set_item("adjacent_agreement", verdict.adjacent_agreement)?; - out.set_item("pass", verdict.pass)?; - Ok(out.into()) -} - -/// Vuong non-nested model comparison from casewise log-likelihoods -/// (Schneider et al. 2019). -#[pyfunction] -#[pyo3(signature = (loglik_a, loglik_b, k_a, k_b, bic_correction = true))] -fn vuong_nonnested( - py: Python<'_>, - loglik_a: PyReadonlyArray1<'_, f64>, - loglik_b: PyReadonlyArray1<'_, f64>, - k_a: usize, - k_b: usize, - bic_correction: bool, -) -> PyResult> { - let res = mlsirm_core::fitstats::vuong_nonnested( - loglik_a.as_slice()?, - loglik_b.as_slice()?, - k_a, - k_b, - bic_correction, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("z", res.z)?; - out.set_item("p_two_sided", res.p_two_sided)?; - out.set_item("omega", res.omega)?; - out.set_item("mean_diff", res.mean_diff)?; - Ok(out.into()) -} - -/// Yen Q3 residual correlations and a descriptive mean absolute residual -/// cross-product. The legacy `gddm` key remains as an explicitly documented -/// compatibility alias; it is not the published Levy-Svetina GDDM. -#[pyfunction] -fn dimensionality_residuals( - py: Python<'_>, - resid: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_items: usize, -) -> PyResult> { - let res = - mlsirm_core::fitstats::dimensionality_residuals(resid.as_slice()?, n_persons, n_items) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("q3", res.q3)?; - out.set_item("q3_max_abs", res.q3_max_abs)?; - out.set_item("q3_mean_abs", res.q3_mean_abs)?; - out.set_item( - "mean_abs_residual_cross_product", - res.mean_abs_residual_cross_product, - )?; - out.set_item("gddm", res.gddm)?; - Ok(out.into()) -} - -/// Oakes-identity observed-information SEs for a fitted marginal model -/// (Pritikin 2017). Population parameters are conditioned on, not -/// differentiated. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, factor_id, n_persons, n_items, n_dims, latent_dim, model, - eps_distance, alpha, b, zeta, tau, pop_kind = "single", pop_id = None, - n_pop = 0, mu = None, sigma = None, sigma_u = 0.0, q_theta = 21, q_xi = 11, - q_u = 15, xi_rule = "gh", xi_points = 256, xi_seed = 0, lambda_b = 0.25, - lambda_alpha = 1.0, mu_alpha = 0.5, lambda_zeta = 1.0, lambda_tau = 1.0, - mu_tau = 0.5, h = 1e-5, -))] -fn oakes_standard_errors( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - factor_id: PyReadonlyArray1<'_, i64>, - n_persons: usize, - n_items: usize, - n_dims: usize, - latent_dim: usize, - model: &str, - eps_distance: f64, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - pop_kind: &str, - pop_id: Option>, - n_pop: usize, - mu: Option>, - sigma: Option>, - sigma_u: f64, - q_theta: usize, - q_xi: usize, - q_u: usize, - xi_rule: &str, - xi_points: usize, - xi_seed: u64, - lambda_b: f64, - lambda_alpha: f64, - mu_alpha: f64, - lambda_zeta: f64, - lambda_tau: f64, - mu_tau: f64, - h: f64, -) -> PyResult> { - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: parse_model_type(model)?, - eps_distance, - }; - let factors = convert_factor_id(factor_id.as_slice()?, n_dims)?; - let ids: Option> = match &pop_id { - Some(arr) => Some( - arr.as_slice()? - .iter() - .map(|&v| { - usize::try_from(v) - .map_err(|_| PyValueError::new_err("population ids must be >= 0")) - }) - .collect::>>()?, - ), - None => None, - }; - let pop = match pop_kind { - "single" => PopulationSpec::Single, - "singlefree" => PopulationSpec::SingleFree, - "multigroup" => PopulationSpec::Multigroup { - group_id: ids.ok_or_else(|| PyValueError::new_err("multigroup requires pop_id"))?, - n_groups: n_pop, - }, - "multilevel" => PopulationSpec::Multilevel { - cluster_id: ids.ok_or_else(|| PyValueError::new_err("multilevel requires pop_id"))?, - n_clusters: n_pop, - }, - _ => { - return Err(PyValueError::new_err( - "pop_kind must be one of ['single', 'singlefree', 'multigroup', 'multilevel']", - )) - } - }; - let rule = XiRuleKind::parse(xi_rule) - .ok_or_else(|| PyValueError::new_err("xi_rule must be one of ['gh', 'qmc', 'mc']"))?; - let mcfg = MarginalConfig { - q_theta, - q_xi, - q_u, - xi_rule: rule, - xi_points, - xi_seed, - ..MarginalConfig::default() - }; - let penalty = PenaltyConfig { - lambda_b, - lambda_alpha, - mu_alpha, - lambda_zeta, - lambda_tau, - mu_tau, - ..PenaltyConfig::lsirm_prior() - }; - let mu_v = match &mu { - Some(v) => v.as_slice()?.to_vec(), - None => Vec::new(), - }; - let sigma_v = match &sigma { - Some(v) => v.as_slice()?.to_vec(), - None => Vec::new(), - }; - let res = mlsirm_core::oakes::observed_information_oakes( - y.as_slice()?, - observed.as_slice()?, - &factors, - &config, - &pop, - &mcfg, - &penalty, - alpha.as_slice()?, - b.as_slice()?, - zeta.as_slice()?, - tau, - &mu_v, - &sigma_v, - sigma_u, - h, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("labels", res.labels)?; - out.set_item("se", res.se)?; - out.set_item("information", res.information)?; - Ok(out.into()) -} - -/// Item/test information at supplied (theta, xi) points (Magis 2013 4PL -/// formula, c=0/d=1 logistic case; Lord test-information tradition). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - theta, xi, n_points, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, device = "auto", -))] -fn bank_information( - py: Python<'_>, - theta: PyReadonlyArray1<'_, f64>, - xi: PyReadonlyArray1<'_, f64>, - n_points: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - device: &str, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let device = Device::parse(device) - .ok_or_else(|| PyValueError::new_err("device must be one of ['cpu', 'gpu', 'auto']"))?; - let (item_info, test_info) = - core_bank_information_device(&bank, theta.as_slice()?, xi.as_slice()?, n_points, device) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("item_info", item_info)?; - out.set_item("test_info", test_info)?; - Ok(out.into()) -} - -/// One adaptive-EAP CAT step. Bock and Mislevy (1982) support EAP scoring; -/// Wang et al. (2010) support multidimensional CAT with information selection. -/// Largest-posterior-SD dimension targeting is a repository policy. -/// -/// # References -/// -/// Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability in -/// a microcomputer environment. *Applied Psychological Measurement, 6*(4), -/// 431–444. -/// -/// Wang, C.-S., Kuo, C.-L., & Chao, C.-Y. (2010). A multidimensional -/// computerized adaptive testing system for enhancing the Chinese as second -/// language proficiency test. In N. E. Mastorakis, V. Mladenov, Z. Bojkovic, -/// & S. Kartalopoulos (Eds.), *Selected topics in education and educational -/// technology* (pp. 245–252). WSEAS Press. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, administered, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, prior_mean, prior_sd, q_theta = 21, xi_rule = "gh", q_xi = 11, - xi_points = 256, xi_seed = 0, device = "auto", -))] -fn cat_next_item( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - administered: PyReadonlyArray1<'_, bool>, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - q_theta: usize, - xi_rule: &str, - q_xi: usize, - xi_points: usize, - xi_seed: u64, - device: &str, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let rule = parse_xi_rule(xi_rule, q_xi, xi_points, xi_seed)?; - let device = Device::parse(device) - .ok_or_else(|| PyValueError::new_err("device must be one of ['cpu', 'gpu', 'auto']"))?; - let step = core_cat_next_item_device( - &bank, - y.as_slice()?, - administered.as_slice()?, - &prior, - q_theta, - rule, - device, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("theta_eap", step.theta_eap)?; - out.set_item("theta_sd", step.theta_sd)?; - out.set_item("xi_eap", step.xi_eap)?; - out.set_item("target_dim", step.target_dim)?; - out.set_item("ranked_items", step.ranked_items)?; - out.set_item("ranked_info", step.ranked_info)?; - Ok(out.into()) -} - -/// Posterior plausible values (Marsman et al., 2016), sampled on the -/// repository's fixed-bank quadrature grid without item-parameter uncertainty. -/// -/// # References -/// -/// Marsman, M., Maris, G., Bechger, T., & Glas, C. (2016). What can we learn -/// from plausible values? *Psychometrika, 81*(2), 274–289. -/// -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, prior_mean, prior_sd, q_theta = 21, xi_rule = "gh", q_xi = 11, - xi_points = 256, xi_seed = 0, n_draws = 5, seed = 1, device = "auto", -))] -fn plausible_values( - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - q_theta: usize, - xi_rule: &str, - q_xi: usize, - xi_points: usize, - xi_seed: u64, - n_draws: usize, - seed: u64, - device: &str, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let rule = parse_xi_rule(xi_rule, q_xi, xi_points, xi_seed)?; - let device = Device::parse(device) - .ok_or_else(|| PyValueError::new_err("device must be one of ['cpu', 'gpu', 'auto']"))?; - core_plausible_values_device( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - &prior, - q_theta, - rule, - n_draws, - seed, - device, - ) - .map_err(PyValueError::new_err) -} - -/// Residual item fit (Haberman, Sinharay & Chon 2013). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, theta, xi, n_bins = 10, -))] -fn residual_item_fit( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - theta: PyReadonlyArray1<'_, f64>, - xi: PyReadonlyArray1<'_, f64>, - n_bins: usize, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let res = core_residual_item_fit( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - theta.as_slice()?, - xi.as_slice()?, - n_bins, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("max_abs_z", res.max_abs_z)?; - out.set_item("p_value", res.p_value)?; - out.set_item("n_bins", res.n_bins)?; - Ok(out.into()) -} - -/// Repository-specific exploratory pairwise adjusted chi2/df ratios. -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, prior_mean, prior_sd, q_theta = 21, xi_rule = "gh", q_xi = 11, - xi_points = 256, xi_seed = 0, -))] -fn adjusted_chi2_pairs( - py: Python<'_>, - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - q_theta: usize, - xi_rule: &str, - q_xi: usize, - xi_points: usize, - xi_seed: u64, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let rule = parse_xi_rule(xi_rule, q_xi, xi_points, xi_seed)?; - let res = core_adjusted_chi2_pairs( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - &prior, - q_theta, - rule, - ) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("ratio", res.ratio)?; - out.set_item("mean_ratio", res.mean_ratio)?; - out.set_item("max_ratio", res.max_ratio)?; - Ok(out.into()) -} - -/// Fixed-estimate Monte Carlo person-fit p-values inspired by Sinharay (2016). -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - y, observed, n_persons, alpha, b, zeta, tau, factor_id, model, n_dims, latent_dim, - eps_distance, theta, xi, prior_mean = None, n_replicates = 200, seed = 1, -))] -fn person_fit_resampling( - y: PyReadonlyArray1<'_, f64>, - observed: PyReadonlyArray1<'_, bool>, - n_persons: usize, - alpha: PyReadonlyArray1<'_, f64>, - b: PyReadonlyArray1<'_, f64>, - zeta: PyReadonlyArray1<'_, f64>, - tau: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - theta: PyReadonlyArray1<'_, f64>, - xi: PyReadonlyArray1<'_, f64>, - prior_mean: Option>, - n_replicates: usize, - seed: u64, -) -> PyResult> { - bank_from_args!( - alpha, - b, - zeta, - tau, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors, - bank - ); - let pm = match &prior_mean { - Some(v) => v.as_slice()?.to_vec(), - None => Vec::new(), - }; - core_person_fit_resampling( - &bank, - y.as_slice()?, - observed.as_slice()?, - n_persons, - theta.as_slice()?, - xi.as_slice()?, - &pm, - n_replicates, - seed, - ) - .map_err(PyValueError::new_err) -} - -/// Repository-specific fixed-threshold, backward-elimination TCC drift screen. -/// -/// This heuristic is motivated by the TCC-difference objective of Guo et al. -/// (2015), but it does not implement their alternating entry/removal procedure -/// or locally optimal linking-set search. -/// -/// # References -/// -/// Guo, R., Zheng, Y., & Chang, H. H. (2015). A stepwise test characteristic -/// curve method to detect item parameter drift. *Journal of Educational -/// Measurement, 52*(3), 280–300. https://doi.org/10.1111/jedm.12077 -#[pyfunction] -#[allow(clippy::too_many_arguments)] -#[pyo3(signature = ( - alpha_old, b_old, zeta_old, tau_old, alpha_new, b_new, zeta_new, tau_new, - factor_id, model, n_dims, latent_dim, eps_distance, prior_mean, prior_sd, - q_theta = 21, xi_rule = "gh", q_xi = 11, xi_points = 256, xi_seed = 0, - threshold = 0.05, -))] -fn tcc_drift( - py: Python<'_>, - alpha_old: PyReadonlyArray1<'_, f64>, - b_old: PyReadonlyArray1<'_, f64>, - zeta_old: PyReadonlyArray1<'_, f64>, - tau_old: f64, - alpha_new: PyReadonlyArray1<'_, f64>, - b_new: PyReadonlyArray1<'_, f64>, - zeta_new: PyReadonlyArray1<'_, f64>, - tau_new: f64, - factor_id: PyReadonlyArray1<'_, i64>, - model: &str, - n_dims: usize, - latent_dim: usize, - eps_distance: f64, - prior_mean: PyReadonlyArray1<'_, f64>, - prior_sd: PyReadonlyArray1<'_, f64>, - q_theta: usize, - xi_rule: &str, - q_xi: usize, - xi_points: usize, - xi_seed: u64, - threshold: f64, -) -> PyResult> { - bank_from_args!( - alpha_old, - b_old, - zeta_old, - tau_old, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors_old, - bank_old - ); - bank_from_args!( - alpha_new, - b_new, - zeta_new, - tau_new, - factor_id, - model, - n_dims, - latent_dim, - eps_distance, - factors_new, - bank_new - ); - let prior = PriorSpec { - mean: prior_mean.as_slice()?.to_vec(), - sd: prior_sd.as_slice()?.to_vec(), - }; - let rule = parse_xi_rule(xi_rule, q_xi, xi_points, xi_seed)?; - let res = core_tcc_drift(&bank_old, &bank_new, &prior, q_theta, rule, threshold) - .map_err(PyValueError::new_err)?; - let out = pyo3::types::PyDict::new(py); - out.set_item("drifted", res.drifted)?; - out.set_item("area_trace", res.area_trace)?; - out.set_item("iterations", res.iterations)?; - out.set_item("max_iterations", res.max_iterations)?; - out.set_item("termination_reason", res.termination_reason)?; - Ok(out.into()) -} - -/// Empirical (marginal) EAP reliability per trait dimension from the posterior -/// variance decomposition of Bechger et al. (2003); report it alongside model -/// fit as advised by Stanley and Edwards (2016). -/// -/// # References -/// -/// Bechger, T. M., Maris, G., Verstralen, H. H. F. M., & Béguin, A. A. (2003). -/// Using classical test theory in combination with item response theory. -/// *Applied Psychological Measurement, 27*(5), 319–334. -/// -/// -/// Stanley, L. M., & Edwards, M. C. (2016). Reliability and model fit. -/// *Educational and Psychological Measurement, 76*(6), 976–985. -/// -#[pyfunction(signature = (theta_eap, theta_sd, n_persons, n_dims, device = "auto"))] -fn empirical_reliability( - theta_eap: PyReadonlyArray1<'_, f64>, - theta_sd: PyReadonlyArray1<'_, f64>, - n_persons: usize, - n_dims: usize, - device: &str, -) -> PyResult> { - let device = Device::parse(device) - .ok_or_else(|| PyValueError::new_err("device must be one of ['cpu', 'gpu', 'auto']"))?; - core_empirical_reliability_device( - theta_eap.as_slice()?, - theta_sd.as_slice()?, - n_persons, - n_dims, - device, - ) - .map_err(PyValueError::new_err) -} - #[pymodule] #[pyo3(name = "_core")] fn fast_mlsirm_core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(neg_loglik_and_grad, m)?)?; m.add_function(wrap_pyfunction!(fit_mmle_2pl, m)?)?; - m.add_function(wrap_pyfunction!(fit_cdm, m)?)?; - m.add_function(wrap_pyfunction!(fit_gdina, m)?)?; - m.add_function(wrap_pyfunction!(validate_q_matrix, m)?)?; - m.add_function(wrap_pyfunction!(gdina_wald_selection, m)?)?; - m.add_function(wrap_pyfunction!(fit_ho_cdm, m)?)?; - m.add_function(wrap_pyfunction!(fit_ho_gdina, m)?)?; - m.add_function(wrap_pyfunction!(fit_seq_gdina, m)?)?; - m.add_function(wrap_pyfunction!(fit_seq_gdina_qr, m)?)?; - m.add_function(wrap_pyfunction!(fit_2pl, m)?)?; - m.add_function(wrap_pyfunction!(fit_mhrm, m)?)?; - m.add_function(wrap_pyfunction!(fit_nominal_model, m)?)?; - m.add_function(wrap_pyfunction!(fit_grm, m)?)?; - m.add_function(wrap_pyfunction!(fit_gpcm, m)?)?; - m.add_function(wrap_pyfunction!(fit_crm, m)?)?; - m.add_function(wrap_pyfunction!(fit_rsm, m)?)?; - m.add_function(wrap_pyfunction!(fit_facets, m)?)?; - m.add_function(wrap_pyfunction!(mokken_coef_h, m)?)?; - m.add_function(wrap_pyfunction!(mokken_aisp, m)?)?; - m.add_function(wrap_pyfunction!(ksirt_occ, m)?)?; - m.add_function(wrap_pyfunction!(subscore_analysis, m)?)?; - m.add_function(wrap_pyfunction!(detect_analysis, m)?)?; - m.add_function(wrap_pyfunction!(rudner_classification, m)?)?; - m.add_function(wrap_pyfunction!(lee_classification, m)?)?; - m.add_function(wrap_pyfunction!(livingston_lewis, m)?)?; - m.add_function(wrap_pyfunction!(gtheory_pi, m)?)?; - m.add_function(wrap_pyfunction!(gtheory_pio, m)?)?; - m.add_function(wrap_pyfunction!(minres_fa, m)?)?; - m.add_function(wrap_pyfunction!(minres_fa_from_data, m)?)?; - m.add_function(wrap_pyfunction!(omega_total_1f, m)?)?; - m.add_function(wrap_pyfunction!(omega_total_1f_from_data, m)?)?; - m.add_function(wrap_pyfunction!(glb_fa, m)?)?; - m.add_function(wrap_pyfunction!(glb_fa_from_data, m)?)?; - m.add_function(wrap_pyfunction!(velicer_map, m)?)?; - m.add_function(wrap_pyfunction!(velicer_map_from_data, m)?)?; - m.add_function(wrap_pyfunction!(selection_utility, m)?)?; - m.add_function(wrap_pyfunction!(taylor_russell, m)?)?; - m.add_function(wrap_pyfunction!(parallel_analysis, m)?)?; - m.add_function(wrap_pyfunction!(py_sympson_hetter, m)?)?; - m.add_function(wrap_pyfunction!(py_a_stratified, m)?)?; - m.add_function(wrap_pyfunction!(py_kl_information, m)?)?; - m.add_function(wrap_pyfunction!(py_kl_select, m)?)?; - m.add_function(wrap_pyfunction!(py_owen_update, m)?)?; - m.add_function(wrap_pyfunction!(py_owen_cat, m)?)?; - m.add_function(wrap_pyfunction!(py_ccat_select, m)?)?; - m.add_function(wrap_pyfunction!(guttman_lambdas, m)?)?; - m.add_function(wrap_pyfunction!(tenberge_mu, m)?)?; - m.add_function(wrap_pyfunction!(cronbach_alpha, m)?)?; - m.add_function(wrap_pyfunction!(feldt_alpha_ci, m)?)?; - m.add_function(wrap_pyfunction!(separation_reliability, m)?)?; - m.add_function(wrap_pyfunction!(fit_mixture, m)?)?; - m.add_function(wrap_pyfunction!(fit_lltm, m)?)?; - m.add_function(wrap_pyfunction!(fit_testlet, m)?)?; - m.add_function(wrap_pyfunction!(fit_marginal, m)?)?; - m.add_function(wrap_pyfunction!(score_bank_eap, m)?)?; - m.add_function(wrap_pyfunction!(score_bank_map, m)?)?; - m.add_function(wrap_pyfunction!(eapsum_tables, m)?)?; - m.add_function(wrap_pyfunction!(score_eapsum, m)?)?; - m.add_function(wrap_pyfunction!(s_x2_stat, m)?)?; - m.add_function(wrap_pyfunction!(leniency_residuals_stat, m)?)?; - m.add_function(wrap_pyfunction!(m2_stat, m)?)?; - m.add_function(wrap_pyfunction!(poly_m2, m)?)?; - m.add_function(wrap_pyfunction!(poly_local_dependence, m)?)?; - m.add_function(wrap_pyfunction!(poly_dif, m)?)?; - m.add_function(wrap_pyfunction!(mantel_haenszel_dif, m)?)?; - m.add_function(wrap_pyfunction!(sibtest, m)?)?; - m.add_function(wrap_pyfunction!(raju_area, m)?)?; - m.add_function(wrap_pyfunction!(logistic_dif, m)?)?; - m.add_function(wrap_pyfunction!(mantel_haenszel_dif_purified, m)?)?; - m.add_function(wrap_pyfunction!(logistic_dif_purified, m)?)?; - m.add_function(wrap_pyfunction!(score_wle, m)?)?; - m.add_function(wrap_pyfunction!(fit_rasch_cml, m)?)?; - m.add_function(wrap_pyfunction!(andersen_lr_test, m)?)?; - m.add_function(wrap_pyfunction!(u3_person_fit, m)?)?; - m.add_function(wrap_pyfunction!(u3_bootstrap_cutoff, m)?)?; - m.add_function(wrap_pyfunction!(irt_link, m)?)?; - m.add_function(wrap_pyfunction!(equate_observed_scores, m)?)?; - m.add_function(wrap_pyfunction!(equate_neat, m)?)?; - m.add_function(wrap_pyfunction!(equate_neat_linear, m)?)?; - m.add_function(wrap_pyfunction!(bootstrap_see, m)?)?; - m.add_function(wrap_pyfunction!(analytic_see, m)?)?; - m.add_function(wrap_pyfunction!(equate_observed_scores_ext, m)?)?; - m.add_function(wrap_pyfunction!(loglinear_smooth, m)?)?; - m.add_function(wrap_pyfunction!(person_fit_stat, m)?)?; - m.add_function(wrap_pyfunction!(infit_outfit_stat, m)?)?; - m.add_function(wrap_pyfunction!(validate_scoring, m)?)?; - m.add_function(wrap_pyfunction!(vuong_nonnested, m)?)?; - m.add_function(wrap_pyfunction!(dimensionality_residuals, m)?)?; - m.add_function(wrap_pyfunction!(oakes_standard_errors, m)?)?; - m.add_function(wrap_pyfunction!(bank_information, m)?)?; - m.add_function(wrap_pyfunction!(cat_next_item, m)?)?; - m.add_function(wrap_pyfunction!(plausible_values, m)?)?; - m.add_function(wrap_pyfunction!(residual_item_fit, m)?)?; - m.add_function(wrap_pyfunction!(adjusted_chi2_pairs, m)?)?; - m.add_function(wrap_pyfunction!(person_fit_resampling, m)?)?; - m.add_function(wrap_pyfunction!(tcc_drift, m)?)?; - m.add_function(wrap_pyfunction!(empirical_reliability, m)?)?; - m.add_function(wrap_pyfunction!(gpcm_cell_logprobs, m)?)?; - m.add_function(wrap_pyfunction!(grm_cell_logprobs, m)?)?; - m.add_function(wrap_pyfunction!(fit_poly_unidim, m)?)?; - m.add_function(wrap_pyfunction!(fit_nominal, m)?)?; - m.add_function(wrap_pyfunction!(poly_person_fit, m)?)?; - m.add_function(wrap_pyfunction!(poly_cat_simulate, m)?)?; - m.add_function(wrap_pyfunction!(score_poly_eap, m)?)?; - m.add_function(wrap_pyfunction!(score_wle_poly, m)?)?; - m.add_function(wrap_pyfunction!(poly_information_curves, m)?)?; - m.add_function(wrap_pyfunction!(poly_item_fit_sx2, m)?)?; - m.add_function(wrap_pyfunction!(fit_poly_lsirm, m)?)?; - m.add_function(wrap_pyfunction!(fit_mixed_items, m)?)?; - m.add_function(wrap_pyfunction!(fit_rt_lognormal, m)?)?; - m.add_function(wrap_pyfunction!(fit_speed_accuracy_covariance, m)?)?; - m.add_function(wrap_pyfunction!(rt_person_fit, m)?)?; Ok(()) } @@ -6277,9 +179,8 @@ fn parse_model_type(model: &str) -> PyResult { "MLSRM" => Ok(ModelType::Mlsrm), "ULS2PLM" => Ok(ModelType::Uls2plm), "ULSRM" => Ok(ModelType::Ulsrm), - "BIFAC2PLM" => Ok(ModelType::Bifac2plm), _ => Err(PyValueError::new_err( - "model must be one of ['MIRT', 'MLS2PLM', 'MLSRM', 'ULS2PLM', 'ULSRM', 'BIFAC2PLM']", + "model must be one of ['MIRT', 'MLS2PLM', 'MLSRM', 'ULS2PLM', 'ULSRM']", )), } } diff --git a/crates/mlsirm-core/src/agreement.rs b/crates/mlsirm-core/src/agreement.rs deleted file mode 100644 index 3b88a1de0..000000000 --- a/crates/mlsirm-core/src/agreement.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Machine-scoring validation statistics and acceptance gates. -//! -//! Implements the operational criteria of Williamson, Xi & Breyer (2012), "A -//! Framework for Evaluation and Use of Automated Scoring" (Educational -//! Measurement: Issues and Practice 31(1), 2-13) for validating an automated -//! scorer (here: an LLM-as-a-Judge) against human ratings: -//! -//! - quadratic-weighted kappa `κ_w = 1 - Σ w_ij O_ij / Σ w_ij E_ij` with -//! `w_ij = (i-j)^2/(K-1)^2` (Fleiss & Cohen 1973); gate `κ_w >= .70` -//! (collapses to Cohen's unweighted kappa for binary labels); -//! - Pearson r on the paired scores; gate `r >= .70`; -//! - degradation vs a human-human baseline `Δ = stat_hh - stat_ah`; gate -//! `Δ <= .10`; -//! - standardized mean difference `SMD = (M_auto - M_human)/SD_human`; gate -//! `|SMD| <= .15` overall and `<= .10` within every subgroup; -//! - exact (and adjacent) agreement: reported, explicitly NOT a gate. - -/// Cross-tabulate two label vectors with values in `0..k`. -fn joint_counts(a: &[u32], b: &[u32], k: usize) -> Result, String> { - if a.len() != b.len() || a.is_empty() { - return Err("paired label vectors must be non-empty and equal-length".into()); - } - let mut table = vec![0.0_f64; k * k]; - for (&x, &y) in a.iter().zip(b) { - if x as usize >= k || y as usize >= k { - return Err(format!("labels must be in 0..{k}")); - } - table[x as usize * k + y as usize] += 1.0; - } - Ok(table) -} - -/// Weighted kappa with weights `w_ij = (i-j)^2/(K-1)^2` (quadratic; K >= 2). -/// For `k = 2` this equals Cohen's unweighted kappa. -pub fn quadratic_weighted_kappa(a: &[u32], b: &[u32], k: usize) -> Result { - if k < 2 { - return Err("kappa needs at least 2 categories".into()); - } - let table = joint_counts(a, b, k)?; - let n = a.len() as f64; - let mut row = vec![0.0_f64; k]; - let mut col = vec![0.0_f64; k]; - for i in 0..k { - for j in 0..k { - row[i] += table[i * k + j]; - col[j] += table[i * k + j]; - } - } - let denom_w = ((k - 1) * (k - 1)) as f64; - let (mut num, mut den) = (0.0_f64, 0.0_f64); - for i in 0..k { - for j in 0..k { - let w = ((i as f64 - j as f64) * (i as f64 - j as f64)) / denom_w; - num += w * table[i * k + j] / n; - den += w * (row[i] / n) * (col[j] / n); - } - } - if den <= 0.0 { - return Err("degenerate marginals: expected weighted disagreement is zero".into()); - } - Ok(1.0 - num / den) -} - -/// Cohen's unweighted kappa. -pub fn cohen_kappa(a: &[u32], b: &[u32], k: usize) -> Result { - let table = joint_counts(a, b, k)?; - let n = a.len() as f64; - let mut po = 0.0_f64; - let mut row = vec![0.0_f64; k]; - let mut col = vec![0.0_f64; k]; - for i in 0..k { - po += table[i * k + i] / n; - for j in 0..k { - row[i] += table[i * k + j]; - col[j] += table[i * k + j]; - } - } - let pe: f64 = (0..k).map(|i| (row[i] / n) * (col[i] / n)).sum(); - if (1.0 - pe).abs() < 1e-12 { - return Err("degenerate marginals: chance agreement is 1".into()); - } - Ok((po - pe) / (1.0 - pe)) -} - -/// Pearson product-moment correlation of paired scores. -pub fn pearson_r(a: &[f64], b: &[f64]) -> Result { - if a.len() != b.len() || a.len() < 2 { - return Err("paired score vectors must be equal-length with n >= 2".into()); - } - let n = a.len() as f64; - let ma = a.iter().sum::() / n; - let mb = b.iter().sum::() / n; - let (mut sab, mut saa, mut sbb) = (0.0_f64, 0.0_f64, 0.0_f64); - for (&x, &y) in a.iter().zip(b) { - sab += (x - ma) * (y - mb); - saa += (x - ma) * (x - ma); - sbb += (y - mb) * (y - mb); - } - if saa <= 0.0 || sbb <= 0.0 { - return Err("zero variance in one of the score vectors".into()); - } - Ok(sab / (saa.sqrt() * sbb.sqrt())) -} - -/// Standardized mean difference, standardized on the HUMAN score SD: -/// `(M_auto - M_human) / SD_human` (Williamson et al. criterion E). -pub fn smd(auto: &[f64], human: &[f64]) -> Result { - if auto.len() != human.len() || human.len() < 2 { - return Err("paired score vectors must be equal-length with n >= 2".into()); - } - let n = human.len() as f64; - let mh = human.iter().sum::() / n; - let ma = auto.iter().sum::() / n; - let var_h = human.iter().map(|&v| (v - mh) * (v - mh)).sum::() / n; - if var_h <= 0.0 { - return Err("human scores have zero variance".into()); - } - Ok((ma - mh) / var_h.sqrt()) -} - -/// Proportion of exact matches, and matches within +/- 1 category. -pub fn agreement_rates(a: &[u32], b: &[u32]) -> Result<(f64, f64), String> { - if a.len() != b.len() || a.is_empty() { - return Err("paired label vectors must be non-empty and equal-length".into()); - } - let n = a.len() as f64; - let exact = a.iter().zip(b).filter(|(&x, &y)| x == y).count() as f64 / n; - let adjacent = a - .iter() - .zip(b) - .filter(|(&x, &y)| (x as i64 - y as i64).abs() <= 1) - .count() as f64 - / n; - Ok((exact, adjacent)) -} - -/// One gate outcome: the statistic, its threshold, and whether it passed. -#[derive(Clone, Debug)] -pub struct Gate { - pub name: &'static str, - pub value: f64, - pub threshold: f64, - pub pass: bool, -} - -/// Conjunctive validation verdict per Williamson et al. (2012). -#[derive(Clone, Debug)] -pub struct ValidationVerdict { - pub gates: Vec, - /// Reported-only statistics (exact/adjacent agreement). - pub exact_agreement: f64, - pub adjacent_agreement: f64, - pub pass: bool, -} - -/// Run the conjunctive acceptance gates on paired (auto, human) labels in -/// `0..k`. `human_human` optionally supplies a double-scored baseline -/// (pairs of human labels) for the degradation criterion; `subgroup` labels -/// each observation for the fairness SMD. -pub fn validate_scoring( - auto: &[u32], - human: &[u32], - k: usize, - human_human: Option<(&[u32], &[u32])>, - subgroup: Option<&[u32]>, -) -> Result { - let auto_f: Vec = auto.iter().map(|&v| v as f64).collect(); - let human_f: Vec = human.iter().map(|&v| v as f64).collect(); - let mut gates = Vec::new(); - - let qwk = quadratic_weighted_kappa(auto, human, k)?; - gates.push(Gate { - name: "qwk", - value: qwk, - threshold: 0.70, - pass: qwk >= 0.70, - }); - let r = pearson_r(&auto_f, &human_f)?; - gates.push(Gate { - name: "pearson_r", - value: r, - threshold: 0.70, - pass: r >= 0.70, - }); - let s = smd(&auto_f, &human_f)?; - gates.push(Gate { - name: "smd", - value: s, - threshold: 0.15, - pass: s.abs() <= 0.15, - }); - - if let Some((h1, h2)) = human_human { - let hh = quadratic_weighted_kappa(h1, h2, k)?; - let degradation = hh - qwk; - gates.push(Gate { - name: "degradation", - value: degradation, - threshold: 0.10, - pass: degradation <= 0.10, - }); - } - - if let Some(groups) = subgroup { - if groups.len() != auto.len() { - return Err("subgroup labels must match the paired vectors".into()); - } - let n_groups = groups.iter().map(|&g| g as usize).max().unwrap_or(0) + 1; - let mut worst: f64 = 0.0; - for g in 0..n_groups { - let idx: Vec = (0..groups.len()) - .filter(|&i| groups[i] as usize == g) - .collect(); - if idx.len() < 2 { - continue; - } - let ga: Vec = idx.iter().map(|&i| auto_f[i]).collect(); - let gh: Vec = idx.iter().map(|&i| human_f[i]).collect(); - let Ok(gs) = smd(&ga, &gh) else { - continue; - }; - if gs.abs() > worst.abs() { - worst = gs; - } - } - gates.push(Gate { - name: "subgroup_smd", - value: worst, - threshold: 0.10, - pass: worst.abs() <= 0.10, - }); - } - - let (exact, adjacent) = agreement_rates(auto, human)?; - let pass = gates.iter().all(|g| g.pass); - Ok(ValidationVerdict { - gates, - exact_agreement: exact, - adjacent_agreement: adjacent, - pass, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/agreement_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/cdm.rs b/crates/mlsirm-core/src/cdm.rs deleted file mode 100644 index 32648f7d4..000000000 --- a/crates/mlsirm-core/src/cdm.rs +++ /dev/null @@ -1,3386 +0,0 @@ -//! Cognitive diagnosis models: DINA (AND gate) and DINO (OR gate) by marginal-ML -//! EM over the `2^K` binary attribute-mastery profiles. -//! -//! Each respondent `j` has a binary profile `alpha_j in {0,1}^K` (mastery of `K` -//! skills); a `J x K` Q-matrix specifies which attributes each item requires. The -//! ideal (latent) response is -//! -//! * DINA: `eta_ij = prod_k alpha_jk^{q_ik}` — 1 iff the person masters ALL of the -//! item's required attributes (conjunctive / AND gate); -//! * DINO: `eta_ij = 1 - prod_k (1 - alpha_jk)^{q_ik}` — 1 iff the person masters -//! ANY required attribute (disjunctive / OR gate). -//! -//! The observed response adds a per-item slip `s_i = P(X=0 | eta=1)` and guess -//! `g_i = P(X=1 | eta=0)`: -//! -//! ```text -//! P(X_ij = 1 | alpha_j) = (1 - s_i)^{eta_ij} * g_i^{1 - eta_ij} -//! ``` -//! -//! Estimation is marginal ML by EM over the `L = 2^K` profiles with a free -//! (unstructured) mixing distribution `pi_c = P(alpha = alpha_c)`. The item M-step -//! is closed form (slip = expected fraction of masters answering wrong; guess = -//! expected fraction of non-masters answering right), and the population step is a -//! column-mean of the posteriors. Persons are classified by their posterior mode -//! (MAP profile) and marginal attribute probabilities (attribute EAP). -//! -//! The fixed Q-matrix names the attribute dimensions, so there is no free label -//! switching to align (unlike the continuous latent space in `marginal.rs`). This -//! does **not** by itself identify `s`, `g`, and `pi`: nonzero Q rows and columns -//! are only structural sanity checks, and full DINA identifiability requires -//! stronger Q-matrix conditions (Gu & Xu, 2019). This implementation rejects the -//! degenerate zero-row/zero-column cases but does not certify global -//! identifiability; callers remain responsible for an appropriate study design. -//! -//! The saturated G-DINA ([`fit_gdina`]), empirical Q-matrix validation -//! ([`validate_q_matrix`]), item-level model selection ([`gdina_wald_selection`]), -//! and the higher-order structured attribute prior ([`fit_ho_cdm`], de la Torre & -//! Douglas, 2004) build on this DINA/DINO core in the same module. Deferred -//! (explicit non-goals): full Q-matrix *estimation* and global identifiability -//! certification. -//! -//! References (APA 7th ed.): -//! - de la Torre, J. (2009). DINA model and parameter estimation: A didactic. -//! *Journal of Educational and Behavioral Statistics, 34*(1), 115–130. -//! -//! - Gu, Y., & Xu, G. (2019). The sufficient and necessary condition for the -//! identifiability and estimability of the DINA model. *Psychometrika, 84*(2), -//! 468–483. -//! - Junker, B. W., & Sijtsma, K. (2001). Cognitive assessment models with few -//! assumptions, and connections with nonparametric item response theory. -//! *Applied Psychological Measurement, 25*(3), 258–272. -//! -//! - Templin, J. L., & Henson, R. A. (2006). Measurement of psychological disorders -//! using cognitive diagnosis models. *Psychological Methods, 11*(3), 287–305. -//! - -/// Cognitive-diagnosis gate: `Dina` = conjunctive (AND), `Dino` = disjunctive (OR). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CdmModel { - Dina, - Dino, -} - -/// EM configuration. Defaults follow the crate's marginal-ML conventions. -#[derive(Clone, Copy, Debug)] -pub struct CdmConfig { - /// Maximum EM iterations. - pub max_iter: usize, - /// Convergence tolerance on `|delta loglik|`. - pub tol: f64, - /// Clamp for slip/guess and floor for `pi_c` (avoids `ln 0`). - pub eps: f64, - /// Interior back-off from the monotonicity boundary `g = 1 - s`. - pub mono_backoff: f64, - /// Initial slip. - pub init_slip: f64, - /// Initial guess. - pub init_guess: f64, - /// Keep the previous slip (resp. guess) when the expected master (resp. - /// non-master) count for an item falls below this — no information to update it. - pub count_floor: f64, -} - -impl Default for CdmConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - eps: 1e-6, - mono_backoff: 1e-3, - init_slip: 0.2, - init_guess: 0.2, - count_floor: 1e-8, - } - } -} - -/// Fitted DINA/DINO parameters and person classifications. -#[derive(Clone, Debug)] -pub struct CdmResult { - pub model: CdmModel, - /// Per-item slip `s_i`, length `J`. - pub slip: Vec, - /// Per-item guess `g_i`, length `J`. - pub guess: Vec, - /// Population mixing proportions `pi_c`, length `2^K`, sum 1. - pub profile_prob: Vec, - /// Bit-encoded MAP profile per person (`argmax_c P(alpha_c | X_j)`), length `N`. - pub map_profile: Vec, - /// Marginal `P(alpha_jk = 1 | X_j)`, row-major `N x K`. - pub attr_prob: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// `2*J + (2^K - 1)`. - pub n_parameters: usize, -} - -fn validate( - y: &[f64], - observed: &[bool], - q_matrix: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - cfg: &CdmConfig, -) -> Result<(), String> { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - // L = 2^K drives both the eta table (J*L) and the O(N*J*L) E-step; cap K at 15. - if !(1..=15).contains(&n_attributes) { - return Err(format!( - "n_attributes must be in 1..=15 (L = 2^K grid + O(N*J*L) cost); got {n_attributes}" - )); - } - if cfg.max_iter == 0 { - return Err("max_iter must be positive".into()); - } - if !cfg.tol.is_finite() || cfg.tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - if !cfg.eps.is_finite() || !(0.0 < cfg.eps && cfg.eps < 0.5) { - return Err("eps must be finite and in (0, 0.5)".into()); - } - if !cfg.mono_backoff.is_finite() || cfg.mono_backoff <= 2.0 * cfg.eps || cfg.mono_backoff >= 1.0 - { - return Err("mono_backoff must be finite, greater than 2 * eps, and less than 1".into()); - } - if !cfg.init_slip.is_finite() || !(cfg.eps..=1.0 - cfg.eps).contains(&cfg.init_slip) { - return Err("init_slip must be finite and in [eps, 1 - eps]".into()); - } - if !cfg.init_guess.is_finite() || !(cfg.eps..=1.0 - cfg.eps).contains(&cfg.init_guess) { - return Err("init_guess must be finite and in [eps, 1 - eps]".into()); - } - if cfg.init_slip + cfg.init_guess >= 1.0 { - return Err("init_slip + init_guess must be less than 1".into()); - } - if !cfg.count_floor.is_finite() || cfg.count_floor < 0.0 { - return Err("count_floor must be finite and non-negative".into()); - } - // checked_mul mirrors fit_mmle_2pl: a wrapped product could otherwise pass the - // length check and let the E-step index out of bounds on adversarial dimensions. - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells || observed.len() != n_cells { - return Err("y and observed must have length n_persons * n_items".into()); - } - let n_q = crate::checked_mul_usize(n_items, n_attributes, "Q-matrix size overflows usize")?; - if q_matrix.len() != n_q { - return Err("q_matrix must have length n_items * n_attributes".into()); - } - for (idx, &v) in y.iter().enumerate() { - if observed[idx] && v != 0.0 && v != 1.0 { - return Err(format!("y[{idx}] must be 0 or 1 where observed; got {v}")); - } - } - for (idx, &v) in q_matrix.iter().enumerate() { - if v != 0 && v != 1 { - return Err(format!("q_matrix[{idx}] must be 0 or 1; got {v}")); - } - } - // An entirely unobserved item has no likelihood contribution, so its slip and - // guess would merely echo the initial values while being reported as estimates. - for i in 0..n_items { - if !(0..n_persons).any(|p| observed[p * n_items + i]) { - return Err(format!("item {i} has no observed responses")); - } - } - // Every Q row nonzero: an all-zero row gives qmask==0, so DINA eta == 1 and - // DINO eta == 0 for all profiles — the item measures nothing. - for i in 0..n_items { - if !(0..n_attributes).any(|k| q_matrix[i * n_attributes + k] != 0) { - return Err(format!( - "q_matrix row {i} is all-zero (item measures no attribute)" - )); - } - } - // Every Q column nonzero: an attribute measured by no item carries zero data - // information. This is necessary structural validation, not a certificate of - // the stronger Q-matrix conditions required for global identifiability. - for k in 0..n_attributes { - if !(0..n_items).any(|i| q_matrix[i * n_attributes + k] != 0) { - return Err(format!( - "q_matrix column {k} is all-zero (attribute measured by no item)" - )); - } - } - Ok(()) -} - -/// Normalized posterior over the `L` profiles for person `j`; returns `ln P(X_j)`. -/// `post` is filled in place (reused scratch across persons — no `N*L` storage). -/// `lp1[i*2 + b]` / `lp0[i*2 + b]` are `ln P(X=1|eta=b)` / `ln P(X=0|eta=b)`. -#[allow(clippy::too_many_arguments)] -fn posterior_row( - j: usize, - y: &[f64], - observed: &[bool], - n_items: usize, - l: usize, - eta: &[u8], - lp1: &[f64], - lp0: &[f64], - log_pi: &[f64], - post: &mut [f64], -) -> f64 { - for (c, slot) in post.iter_mut().enumerate().take(l) { - let mut acc = log_pi[c]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let b = eta[i * l + c] as usize; - let yy = y[idx]; - acc += yy * lp1[i * 2 + b] + (1.0 - yy) * lp0[i * 2 + b]; - } - } - *slot = acc; // log-numerator, exponentiated below - } - let m = post[..l].iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0; - for c in 0..l { - denom += (post[c] - m).exp(); - } - for c in 0..l { - post[c] = (post[c] - m).exp() / denom; - } - m + denom.ln() -} - -/// Closed-form item M-step (de la Torre, 2009, Eqs. 9-10) with the monotonicity -/// projection. `i1/r1/i0/r0` are the expected master/non-master and correct-count -/// cells for item `i`. -#[allow(clippy::too_many_arguments)] -fn update_item( - i: usize, - i1: &[f64], - r1: &[f64], - i0: &[f64], - r0: &[f64], - s: &mut [f64], - g: &mut [f64], - cfg: &CdmConfig, -) { - // Unconstrained maximisers of Q_i = R1 ln(1-s) + (I1-R1) ln s - // + R0 ln g + (I0-R0) ln(1-g): - // s_i = 1 - R1_i/I1_i (masters answering wrong), g_i = R0_i/I0_i (non-masters right). - // Count guard: an item with ~no expected mass in a group carries no information - // for that parameter, so keep the previous value (mirrors the mmle singular break). - let mut si = if i1[i] > cfg.count_floor { - 1.0 - r1[i] / i1[i] - } else { - s[i] - }; - let mut gi = if i0[i] > cfg.count_floor { - r0[i] / i0[i] - } else { - g[i] - }; - // Monotonicity / identification 1 - s_i > g_i (equivalently s_i + g_i < 1). If - // violated, the exact constrained maximiser is on the boundary g = 1 - s, where - // Q_i collapses to one binomial with maximiser pbar_i = (R1+R0)/(I1+I0); back off - // by mono_backoff to stay in the open feasible set. When this fires at least one - // group carried mass (else si,gi kept the previous, whose sum is < 1 by the - // invariant), so I1+I0 > 0. - // ponytail: open-set back-off makes this a GEM step, not a strict M-maximiser; - // it lands mono_backoff/2 interior to the boundary. Never fires under an - // identifiable Q with literature-grade (s,g); a stricter inner 1-D search buys - // nothing here. - if si + gi >= 1.0 { - let pbar = (r1[i] + r0[i]) / (i1[i] + i0[i]); - si = (1.0 - pbar) - cfg.mono_backoff / 2.0; - gi = pbar - cfg.mono_backoff / 2.0; - } - s[i] = si.clamp(cfg.eps, 1.0 - cfg.eps); - g[i] = gi.clamp(cfg.eps, 1.0 - cfg.eps); -} - -/// Fit DINA/DINO by marginal EM. `y` and `observed` are row-major `N*J` (`y` in -/// {0,1}); `q_matrix` is row-major `J*K` (entries in {0,1}). Missing cells -/// (`observed == false`) are dropped from both the E-step likelihood and the M-step -/// counts (MAR), mirroring `fit_mmle_2pl`. Returns `Err` on malformed input. -#[allow(clippy::too_many_arguments)] -pub fn fit_cdm( - y: &[f64], - observed: &[bool], - q_matrix: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - model: CdmModel, - cfg: &CdmConfig, -) -> Result { - validate(y, observed, q_matrix, n_persons, n_items, n_attributes, cfg)?; - let l = 1usize << n_attributes; - - // Per-item required-attribute bitmask (Q is fixed across EM). - let mut qmask = vec![0usize; n_items]; - for i in 0..n_items { - let mut mask = 0usize; - for k in 0..n_attributes { - if q_matrix[i * n_attributes + k] != 0 { - mask |= 1 << k; - } - } - qmask[i] = mask; - } - // Ideal response eta_ic in {0,1} as one bitwise test; the two gates differ here only. - let mut eta = vec![0u8; n_items * l]; - for i in 0..n_items { - for c in 0..l { - eta[i * l + c] = match model { - CdmModel::Dina => ((c & qmask[i]) == qmask[i]) as u8, - CdmModel::Dino => ((c & qmask[i]) != 0) as u8, - }; - } - } - - let mut s = vec![cfg.init_slip; n_items]; - let mut g = vec![cfg.init_guess; n_items]; - let mut pi = vec![1.0 / l as f64; l]; // deterministic uniform prior - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - - let mut post = vec![0.0f64; l]; - let mut lp1 = vec![0.0f64; n_items * 2]; - let mut lp0 = vec![0.0f64; n_items * 2]; - let mut log_pi = vec![0.0f64; l]; - - let refresh_tables = |s: &[f64], g: &[f64], lp1: &mut [f64], lp0: &mut [f64]| { - for i in 0..n_items { - let sc = s[i].clamp(cfg.eps, 1.0 - cfg.eps); - let gc = g[i].clamp(cfg.eps, 1.0 - cfg.eps); - lp1[i * 2 + 1] = (1.0 - sc).ln(); // master, correct - lp0[i * 2 + 1] = sc.ln(); // master, wrong - lp1[i * 2] = gc.ln(); // non-master, correct - lp0[i * 2] = (1.0 - gc).ln(); // non-master, wrong - } - }; - - for _ in 0..cfg.max_iter { - refresh_tables(&s, &g, &mut lp1, &mut lp0); - for c in 0..l { - log_pi[c] = pi[c].ln(); - } - - // E-step: accumulate the four eta x response expected-count cells on the fly. - let mut i1 = vec![0.0f64; n_items]; - let mut r1 = vec![0.0f64; n_items]; - let mut i0 = vec![0.0f64; n_items]; - let mut r0 = vec![0.0f64; n_items]; - let mut pi_new = vec![0.0f64; l]; - let mut total_ll = 0.0; - for j in 0..n_persons { - total_ll += posterior_row( - j, y, observed, n_items, l, &eta, &lp1, &lp0, &log_pi, &mut post, - ); - for c in 0..l { - pi_new[c] += post[c]; - } - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - // pbar = P(eta_ij = 1 | X_j) = posterior mass that j masters item i. - let mut pbar = 0.0; - for c in 0..l { - if eta[i * l + c] == 1 { - pbar += post[c]; - } - } - let yy = y[idx]; - i1[i] += pbar; - r1[i] += yy * pbar; - i0[i] += 1.0 - pbar; - r0[i] += yy * (1.0 - pbar); - } - } - } - loglik_trace.push(total_ll); - - // The likelihood just evaluated belongs to the current parameters. Stop - // before another M-step so the returned parameters and trace endpoint agree. - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - if (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - - // M-step: closed-form items, then population as floored, renormalized mean posterior. - for i in 0..n_items { - update_item(i, &i1, &r1, &i0, &r0, &mut s, &mut g, cfg); - } - let nf = n_persons as f64; - let mut z = 0.0; - for c in 0..l { - pi[c] = (pi_new[c] / nf).max(cfg.eps); - z += pi[c]; - } - for c in 0..l { - pi[c] /= z; - } - n_iter += 1; - } - - // Final classification: one recompute pass at the converged parameters. - refresh_tables(&s, &g, &mut lp1, &mut lp0); - for c in 0..l { - log_pi[c] = pi[c].ln(); - } - let mut map_profile = vec![0u32; n_persons]; - let mut attr_prob = vec![0.0f64; n_persons * n_attributes]; - let mut final_ll = 0.0; - for j in 0..n_persons { - final_ll += posterior_row( - j, y, observed, n_items, l, &eta, &lp1, &lp0, &log_pi, &mut post, - ); - let mut best = 0usize; - for c in 1..l { - if post[c] > post[best] { - best = c; - } - } - map_profile[j] = best as u32; - for k in 0..n_attributes { - let mut p = 0.0; - for c in 0..l { - if (c >> k) & 1 == 1 { - p += post[c]; - } - } - attr_prob[j * n_attributes + k] = p; - } - } - // A max-iteration exit occurs immediately after an M-step, so record the - // likelihood of those returned parameters. On convergence the final E-step - // already supplied the same endpoint. - if !converged { - loglik_trace.push(final_ll); - } - - Ok(CdmResult { - model, - slip: s, - guess: g, - profile_prob: pi, - map_profile, - attr_prob, - loglik_trace, - n_iter, - converged, - n_parameters: 2 * n_items + (l - 1), - }) -} - -// --------------------------------------------------------------------------- -// Generalized DINA (G-DINA; de la Torre, 2011) -// --------------------------------------------------------------------------- - -/// Fitted saturated G-DINA model (de la Torre, 2011). Item parameters are stored -/// ragged in CSR layout: item `i` owns the slice `[item_off[i]..item_off[i+1])` of -/// width `2^{K_i}` (`K_i` = number of attributes item `i` requires), indexed by the -/// reduced attribute-mastery class. `item_prob[item_off[i] + l]` is the success -/// probability `P(X_i = 1 | reduced class l)`; `item_delta` is the same slice under -/// the identity link (`delta = M^{-1} p`: intercept, main effects, interactions). -#[derive(Clone, Debug)] -pub struct GdinaResult { - /// CSR offsets, length `n_items + 1`; item `i` width = `2^{K_i}`. - pub item_off: Vec, - /// Saturated success probabilities `p_il`, CSR-flat. - pub item_prob: Vec, - /// Identity-link parameters `delta_iS = M^{-1} p_i`, same CSR layout. - pub item_delta: Vec, - /// Number of required attributes `K_i` per item (to interpret the ragged rows). - pub k_required: Vec, - /// Population mixing proportions over the full `2^K` grid, sum 1. - pub profile_prob: Vec, - /// Bit-encoded MAP profile per person, length `N`. - pub map_profile: Vec, - /// Marginal `P(alpha_jk = 1 | X_j)`, row-major `N x K`. - pub attr_prob: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// `sum_i 2^{K_i} + (2^K - 1)`. - pub n_parameters: usize, -} - -/// Bit-encoded reduced attribute-mastery class of full profile `c` for an item with -/// required-attribute bitmask `qmask`: gather the mastery bits at the set positions -/// of `qmask`, packed LSB-ascending in ascending attribute order. This generalizes -/// the DINA gate — `reduce_class(c, qmask) == 2^{K_i} - 1` iff `(c & qmask) == qmask` -/// (all required attributes mastered). The bit convention is load-bearing: it must be -/// identical across `reduce_class`, the design matrix, and `mobius_inverse_inplace`. -#[inline] -fn reduce_class(c: usize, qmask: usize) -> usize { - let (mut l, mut m, mut q) = (0usize, 0u32, qmask); - while q != 0 { - let k = q.trailing_zeros(); - l |= ((c >> k) & 1) << m; - q &= q - 1; // clear lowest set bit - m += 1; - } - l -} - -/// In-place identity-link transform `delta = M^{-1} p` (signed subset Möbius), where -/// `M[l][S] = [(l & S) == S]` is the reduced-class superset (zeta) design. For each -/// required-attribute bit, subtract the value of the pattern without that bit; -/// `k_star` = the item's required-attribute count (`v.len() == 2^{k_star}`). This is -/// the exact inverse of the zeta subset-sum, computed in `O(k_star * 2^{k_star})` -/// without materializing or inverting any matrix. -fn mobius_inverse_inplace(v: &mut [f64], k_star: u32) { - for t in 0..k_star { - let bit = 1usize << t; - for s in 0..v.len() { - if s & bit != 0 { - v[s] -= v[s ^ bit]; - } - } - } -} - -/// Per-person posterior over the full `2^K` profiles for G-DINA; returns `ln P(X_j)`. -/// Mirrors [`posterior_row`] but indexes the ragged per-item CSR success-probability -/// tables through `red` (reduced-class index) + `item_off`. -#[allow(clippy::too_many_arguments)] -fn posterior_row_gdina( - j: usize, - y: &[f64], - observed: &[bool], - n_items: usize, - l_full: usize, - red: &[u16], - log_p1: &[f64], - log_p0: &[f64], - item_off: &[usize], - log_pi: &[f64], - post: &mut [f64], -) -> f64 { - for (c, slot) in post.iter_mut().enumerate().take(l_full) { - let mut acc = log_pi[c]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let cell = item_off[i] + red[i * l_full + c] as usize; - let yy = y[idx]; - acc += yy * log_p1[cell] + (1.0 - yy) * log_p0[cell]; - } - } - *slot = acc; - } - let m = post[..l_full] - .iter() - .cloned() - .fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0; - for c in 0..l_full { - denom += (post[c] - m).exp(); - } - for c in 0..l_full { - post[c] = (post[c] - m).exp() / denom; - } - m + denom.ln() -} - -/// Fit the saturated G-DINA model (de la Torre, 2011) by marginal-ML EM over the -/// `2^K` attribute profiles. Every reduced attribute-mastery class of every item gets -/// a free success probability; DINA, DINO, A-CDM, LLM and R-RUM are all constrained -/// special cases that can be read off the fitted identity-link `item_delta` pattern -/// (they are not refit here — see the module deferred-scope note). -/// -/// The E-step and population update are the same profile-grid EM as [`fit_cdm`]; only -/// the item conditional and M-step generalize: the closed-form saturated maximiser is -/// `p_il = R_il / I_il` (expected correct / expected total in reduced class `l`), -/// exactly [`fit_cdm`]'s two-cell slip/guess step generalized to `2^{K_i}` classes. -/// The box constraint `0 <= p_il <= 1` holds for free (`0 <= R_il <= I_il`). This -/// saturated estimator does **not** impose order restrictions: Q-matrix -/// identifiability does not imply that mastering more required attributes raises the -/// success probability, and an all-mastered class need not have the largest estimate. -/// Subset-lattice order-restricted estimation is a separate model choice described by -/// Hong et al. (2016) and is not implemented here. `y`/`observed` are row-major -/// `N*J`, `q_matrix` row-major `J*K`; missing cells are dropped (MAR). -/// -/// References (APA 7th ed.): -/// de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, -/// 76*(2), 179-199. https://doi.org/10.1007/s11336-011-9207-7 -/// Hong, C.-Y., Chang, Y.-W., & Tsai, R.-C. (2016). Estimation of generalized -/// DINA model with order restrictions. *Journal of Classification, 33*(3), -/// 460-484. https://doi.org/10.1007/s00357-016-9215-5 -/// Ma, W., & de la Torre, J. (2020). GDINA: An R package for cognitive diagnosis -/// modeling. *Journal of Statistical Software, 93*(14), 1-26. -/// https://doi.org/10.18637/jss.v093.i14 -#[allow(clippy::too_many_arguments)] -pub fn fit_gdina( - y: &[f64], - observed: &[bool], - q_matrix: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - cfg: &CdmConfig, -) -> Result { - validate(y, observed, q_matrix, n_persons, n_items, n_attributes, cfg)?; - let l_full = 1usize << n_attributes; - - // Per-item required-attribute bitmask and count K_i. - let mut qmask = vec![0usize; n_items]; - let mut k_required = vec![0u32; n_items]; - for i in 0..n_items { - let mut mask = 0usize; - for k in 0..n_attributes { - if q_matrix[i * n_attributes + k] != 0 { - mask |= 1 << k; - } - } - qmask[i] = mask; - k_required[i] = mask.count_ones(); - } - - // Ragged CSR: item i owns [item_off[i]..item_off[i+1]) of width L_i = 2^{K_i}. - let mut item_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - item_off[i + 1] = item_off[i] + (1usize << k_required[i]); - } - let total = item_off[n_items]; - - // Reduced-class index of every (item, full-profile) pair, precomputed once. `u16` - // holds any class index (max `2^{K_i} - 1 <= 2^15 - 1`) because `validate` caps - // K <= 15; raising that cap past 15 would require widening this element type. - let mut red = vec![0u16; n_items * l_full]; - for i in 0..n_items { - for c in 0..l_full { - red[i * l_full + c] = reduce_class(c, qmask[i]) as u16; - } - } - - // Monotone init: p rises with the count of mastered required attributes, from - // init_guess (none) to 1 - init_slip (all); endpoints match DINA's (g, 1-s). - let mut p = vec![0.0f64; total]; - for i in 0..n_items { - let ki = k_required[i] as f64; // >= 1 (validate rejects all-zero Q rows) - for l in 0..(item_off[i + 1] - item_off[i]) { - let frac = (l.count_ones() as f64) / ki; - p[item_off[i] + l] = cfg.init_guess + (1.0 - cfg.init_slip - cfg.init_guess) * frac; - } - } - let mut pi = vec![1.0 / l_full as f64; l_full]; - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - - let mut post = vec![0.0f64; l_full]; - let mut log_p1 = vec![0.0f64; total]; - let mut log_p0 = vec![0.0f64; total]; - let mut log_pi = vec![0.0f64; l_full]; - - let refresh = |p: &[f64], log_p1: &mut [f64], log_p0: &mut [f64]| { - for x in 0..total { - let pc = p[x].clamp(cfg.eps, 1.0 - cfg.eps); - log_p1[x] = pc.ln(); - log_p0[x] = (1.0 - pc).ln(); - } - }; - - for _ in 0..cfg.max_iter { - refresh(&p, &mut log_p1, &mut log_p0); - for c in 0..l_full { - log_pi[c] = pi[c].ln(); - } - - // E-step: scatter expected reduced-class counts I_il / R_il over the posterior. - let mut ii = vec![0.0f64; total]; - let mut rr = vec![0.0f64; total]; - let mut pi_new = vec![0.0f64; l_full]; - let mut total_ll = 0.0; - for j in 0..n_persons { - total_ll += posterior_row_gdina( - j, y, observed, n_items, l_full, &red, &log_p1, &log_p0, &item_off, &log_pi, - &mut post, - ); - for c in 0..l_full { - pi_new[c] += post[c]; - } - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let (off, yy) = (item_off[i], y[idx]); - for c in 0..l_full { - let cell = off + red[i * l_full + c] as usize; - ii[cell] += post[c]; - rr[cell] += yy * post[c]; - } - } - } - } - loglik_trace.push(total_ll); - - // The likelihood just evaluated belongs to the current parameters. Stop - // before another M-step so the returned parameters and trace endpoint agree. - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - if (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - - // M-step: saturated closed form p_il = R_il / I_il (de la Torre, 2011, Eq. 10). - // Box 0<=p<=1 is free (0<=R<=I); count_floor keeps a class's previous value - // when the posterior gives it ~no mass (empty reduced class). - for x in 0..total { - if ii[x] > cfg.count_floor { - p[x] = (rr[x] / ii[x]).clamp(cfg.eps, 1.0 - cfg.eps); - } - } - let nf = n_persons as f64; - let mut z = 0.0; - for c in 0..l_full { - pi[c] = (pi_new[c] / nf).max(cfg.eps); - z += pi[c]; - } - for c in 0..l_full { - pi[c] /= z; - } - n_iter += 1; - } - - // Classification pass (mirrors fit_cdm's tail; duplicated to keep the tested DINA - // core untouched). - refresh(&p, &mut log_p1, &mut log_p0); - for c in 0..l_full { - log_pi[c] = pi[c].ln(); - } - let mut map_profile = vec![0u32; n_persons]; - let mut attr_prob = vec![0.0f64; n_persons * n_attributes]; - let mut final_ll = 0.0; - for j in 0..n_persons { - final_ll += posterior_row_gdina( - j, y, observed, n_items, l_full, &red, &log_p1, &log_p0, &item_off, &log_pi, &mut post, - ); - let mut best = 0usize; - for c in 1..l_full { - if post[c] > post[best] { - best = c; - } - } - map_profile[j] = best as u32; - for k in 0..n_attributes { - let mut pk = 0.0; - for c in 0..l_full { - if (c >> k) & 1 == 1 { - pk += post[c]; - } - } - attr_prob[j * n_attributes + k] = pk; - } - } - - // A max-iteration exit occurs immediately after an M-step, so record the - // likelihood of those returned parameters. On convergence the final E-step - // already supplied the same endpoint. - if !converged { - loglik_trace.push(final_ll); - } - - // Identity-link parameters delta = M^{-1} p, per item slice. - let mut item_delta = p.clone(); - for i in 0..n_items { - mobius_inverse_inplace(&mut item_delta[item_off[i]..item_off[i + 1]], k_required[i]); - } - - Ok(GdinaResult { - item_off, - item_prob: p, - item_delta, - k_required, - profile_prob: pi, - map_profile, - attr_prob, - loglik_trace, - n_iter, - converged, - n_parameters: total + (l_full - 1), - }) -} - -fn ensure_gdina_converged(res: &GdinaResult, cfg: &CdmConfig) -> Result<(), String> { - if res.converged { - return Ok(()); - } - let final_delta = res - .loglik_trace - .windows(2) - .last() - .map(|w| (w[1] - w[0]).abs()) - .unwrap_or(f64::INFINITY); - Err(format!( - concat!( - "G-DINA calibration did not converge after {} of {} M-steps: ", - "final |delta loglik| = {:.6e} (tol = {:.6e})" - ), - res.n_iter, cfg.max_iter, final_delta, cfg.tol - )) -} - -/// Result of [`validate_q_matrix`] (de la Torre & Chiu, 2016). Per item, the -/// method suggests the smallest attribute vector whose PVAF reaches the cutoff. -#[derive(Clone, Debug)] -pub struct QValidationResult { - pub n_attributes: usize, - /// The suggested (validated) Q-matrix, row-major `J x K`, entries 0/1. - pub suggested_q: Vec, - /// PVAF of the suggested q-vector, per item (in `[0, 1]`). - pub suggested_pvaf: Vec, - /// PVAF of the caller's provisional q-vector, per item (for comparison). - pub provisional_pvaf: Vec, - /// `true` where the suggested q-vector differs from the provisional one. - pub flagged: Vec, - /// The PVAF cutoff used. - pub epsilon: f64, - /// M-steps completed by the provisional G-DINA calibration. - pub calibration_n_iter: usize, - /// Configured M-step limit for the provisional calibration. - pub calibration_max_iter: usize, - /// Successful calibration stop reason (currently `"tolerance_met"`). - pub calibration_termination_reason: &'static str, - /// Absolute final change in the provisional calibration log-likelihood. - pub calibration_final_loglik_change: f64, - /// Absolute log-likelihood-change tolerance used by the calibration. - pub calibration_tol: f64, -} - -/// Empirical Q-matrix validation by the PVAF (proportion of variance accounted -/// for) method of de la Torre and Chiu (2016). -/// -/// The G-DINA item response function `P(alpha_c)` varies across the `2^K` latent -/// attribute classes. A candidate q-vector groups those classes into equivalence -/// classes (masters vs. non-masters of each *required* attribute), and its -/// captured variance is -/// -/// ```text -/// zeta^2(q) = sum_l W_l (Pbar_l(q) - Pbar)^2, PVAF(q) = zeta^2(q) / zeta^2_full -/// ``` -/// -/// where `Pbar_l(q)` is the population-weighted mean success probability within -/// reduced class `l` under `q`, `W_l` its total weight, `Pbar` the item's overall -/// mean, and `zeta^2_full` the total across-class variance (the saturated -/// reference). PVAF is monotone in `q` and equals 1 at the full attribute vector. -/// For each item the method returns the q-vector with the **fewest** required -/// attributes whose `PVAF >= epsilon` (ties broken by larger PVAF): an -/// under-specified provisional q falls short of the cutoff and is enlarged, an -/// over-specified one is trimmed because a smaller vector already reaches it. -/// -/// The class weights `pi_c` and the reference IRF are read off a G-DINA fit with -/// the **provisional** Q ([`fit_gdina`]): that structural model identifies the -/// attribute labels (which latent bit is which attribute), and each item's -/// *saturated* success probability over all `2^K` classes, -/// `p_{i,c} = E[X_i | alpha_c]`, is then recovered nonparametrically from the -/// fitted posteriors (expected-correct / expected-count per full class). Because a -/// mis-specified item's responses still correlate with the attributes recovered -/// from the *other* items, its saturated IRF exposes the attributes it truly -/// depends on — so an under-specified provisional q shows `PVAF < epsilon` and is -/// enlarged. The method assumes the provisional Q is mostly correct (enough items -/// to identify the attributes). `y`/`observed` are row-major `N*J` (missing -/// dropped, MAR); `provisional_q` is row-major `J*K`, entries 0/1, each item -/// loading at least one attribute. Cost is `O(J * 4^K)` for the exhaustive -/// q-vector search, so `K` is capped at 10. Validation returns an error rather -/// than computing PVAF from an unconverged provisional G-DINA calibration. -/// -/// References (APA 7th ed.): -/// de la Torre, J., & Chiu, C.-Y. (2016). A general method of empirical Q-matrix -/// validation. *Psychometrika, 81*(2), 253–273. -/// -/// de la Torre, J. (2008). An empirically based method of Q-matrix validation for -/// the DINA model: Development and applications. *Journal of Educational -/// Measurement, 45*(4), 343-362. https://doi.org/10.1111/j.1745-3984.2008.00069.x -#[allow(clippy::too_many_arguments)] -pub fn validate_q_matrix( - y: &[f64], - observed: &[bool], - provisional_q: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - epsilon: f64, - cfg: &CdmConfig, -) -> Result { - if n_attributes == 0 || n_attributes > 10 { - return Err("n_attributes must be in 1..=10 for the PVAF q-vector search".into()); - } - if !(epsilon > 0.0 && epsilon <= 1.0) { - return Err("epsilon must be in (0, 1]".into()); - } - if provisional_q.len() != n_items * n_attributes { - return Err("provisional_q must have length n_items * n_attributes".into()); - } - for &v in provisional_q { - if v > 1 { - return Err("provisional_q entries must be 0 or 1".into()); - } - } - - for i in 0..n_items { - if (0..n_attributes).all(|k| provisional_q[i * n_attributes + k] == 0) { - return Err("each provisional_q row must load at least one attribute".into()); - } - } - - let l_full = 1usize << n_attributes; - let full_mask = l_full - 1; - - // Fit the structural G-DINA under the provisional Q (identifies the attribute - // labels; also validates y/observed shapes and the config). - let res = fit_gdina( - y, - observed, - provisional_q, - n_persons, - n_items, - n_attributes, - cfg, - )?; - ensure_gdina_converged(&res, cfg)?; - let calibration_final_loglik_change = res - .loglik_trace - .windows(2) - .last() - .map(|w| (w[1] - w[0]).abs()) - .unwrap_or(f64::INFINITY); - - // Recover each item's SATURATED IRF over all 2^K full classes and the class - // weights pi_c from one posterior pass at the fitted parameters. The provisional - // fit's own item probabilities are only over reduced classes; the saturated IRF - // p_{i,c} = E[X_i | alpha_c] over full classes is what PVAF needs. - let mut qmask = vec![0usize; n_items]; - for i in 0..n_items { - for k in 0..n_attributes { - if provisional_q[i * n_attributes + k] != 0 { - qmask[i] |= 1 << k; - } - } - } - let total = res.item_off[n_items]; - let mut red = vec![0u16; n_items * l_full]; - for i in 0..n_items { - for c in 0..l_full { - red[i * l_full + c] = reduce_class(c, qmask[i]) as u16; - } - } - let mut log_p1 = vec![0.0f64; total]; - let mut log_p0 = vec![0.0f64; total]; - for x in 0..total { - let pc = res.item_prob[x].clamp(cfg.eps, 1.0 - cfg.eps); - log_p1[x] = pc.ln(); - log_p0[x] = (1.0 - pc).ln(); - } - let log_pi: Vec = res - .profile_prob - .iter() - .map(|v| v.max(cfg.eps).ln()) - .collect(); - - let mut icount = vec![0.0f64; n_items * l_full]; // I_{i,c} expected count - let mut rcount = vec![0.0f64; n_items * l_full]; // R_{i,c} expected correct - let mut pi_c = vec![0.0f64; l_full]; - let mut post = vec![0.0f64; l_full]; - for j in 0..n_persons { - posterior_row_gdina( - j, - y, - observed, - n_items, - l_full, - &red, - &log_p1, - &log_p0, - &res.item_off, - &log_pi, - &mut post, - ); - for c in 0..l_full { - pi_c[c] += post[c]; - } - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let yy = y[idx]; - for c in 0..l_full { - icount[i * l_full + c] += post[c]; - rcount[i * l_full + c] += yy * post[c]; - } - } - } - } - let ntot: f64 = pi_c.iter().sum(); - let pi: Vec = pi_c.iter().map(|v| v / ntot).collect(); - - let mut suggested_q = vec![0u8; n_items * n_attributes]; - let mut suggested_pvaf = vec![0.0f64; n_items]; - let mut provisional_pvaf = vec![0.0f64; n_items]; - let mut flagged = vec![false; n_items]; - - // Reusable per-mask group accumulators (sized to the largest reduced class set). - let mut num = vec![0.0f64; l_full]; - let mut den = vec![0.0f64; l_full]; - let mut p_full = vec![0.0f64; l_full]; - - for i in 0..n_items { - // Saturated IRF: p_{i,c} = R_{i,c} / I_{i,c}; empty classes (~zero weight) - // fall back to the overall item mean so they never distort the variance. - let mut mean_num = 0.0f64; - let mut mean_den = 0.0f64; - for c in 0..l_full { - let ic = icount[i * l_full + c]; - if ic > cfg.count_floor { - p_full[c] = (rcount[i * l_full + c] / ic).clamp(0.0, 1.0); - mean_num += rcount[i * l_full + c]; - mean_den += ic; - } - } - let item_mean = if mean_den > 0.0 { - mean_num / mean_den - } else { - 0.0 - }; - for c in 0..l_full { - if icount[i * l_full + c] <= cfg.count_floor { - p_full[c] = item_mean; - } - } - let p_c: &[f64] = &p_full; - - // Overall mean and total across-class variance (saturated reference). - let mut pbar = 0.0f64; - for c in 0..l_full { - pbar += pi[c] * p_c[c]; - } - let mut var_tot = 0.0f64; - for c in 0..l_full { - let d = p_c[c] - pbar; - var_tot += pi[c] * d * d; - } - - // PVAF of a candidate q-vector (bit-mask of required attributes). - let mut pvaf_of = |mask: usize| -> f64 { - if var_tot <= cfg.eps { - return 0.0; // non-discriminating item: no variance to explain - } - let lred = 1usize << mask.count_ones(); - for l in 0..lred { - num[l] = 0.0; - den[l] = 0.0; - } - for c in 0..l_full { - let l = reduce_class(c, mask); - num[l] += pi[c] * p_c[c]; - den[l] += pi[c]; - } - let mut var_q = 0.0f64; - for l in 0..lred { - if den[l] > 0.0 { - let d = num[l] / den[l] - pbar; - var_q += den[l] * d * d; - } - } - (var_q / var_tot).clamp(0.0, 1.0) - }; - - let prov_mask = { - let mut m = 0usize; - for k in 0..n_attributes { - if provisional_q[i * n_attributes + k] != 0 { - m |= 1 << k; - } - } - m - }; - debug_assert_ne!(prov_mask, 0, "provisional rows were validated above"); - provisional_pvaf[i] = pvaf_of(prov_mask); - - if var_tot <= cfg.eps { - // Uninformative item: cannot be validated. Keep the provisional vector. - for k in 0..n_attributes { - suggested_q[i * n_attributes + k] = provisional_q[i * n_attributes + k]; - } - suggested_pvaf[i] = 0.0; - flagged[i] = false; - continue; - } - - // Search for the fewest-attribute q-vector reaching the cutoff. The full - // vector always qualifies (PVAF == 1), so a solution always exists; ties on - // attribute count are broken by the larger PVAF, then the smaller mask. - let mut best_mask = full_mask; - let mut best_pvaf = 1.0f64; - let mut best_pc = n_attributes as u32; - for mask in 1..l_full { - let pv = pvaf_of(mask); - if pv + 1e-12 >= epsilon { - let pc = (mask as u32).count_ones(); - if pc < best_pc || (pc == best_pc && pv > best_pvaf + 1e-12) { - best_mask = mask; - best_pvaf = pv; - best_pc = pc; - } - } - } - - for k in 0..n_attributes { - suggested_q[i * n_attributes + k] = ((best_mask >> k) & 1) as u8; - } - suggested_pvaf[i] = best_pvaf; - flagged[i] = best_mask != prov_mask; - } - - Ok(QValidationResult { - n_attributes, - suggested_q, - suggested_pvaf, - provisional_pvaf, - flagged, - epsilon, - calibration_n_iter: res.n_iter, - calibration_max_iter: cfg.max_iter, - calibration_termination_reason: "tolerance_met", - calibration_final_loglik_change, - calibration_tol: cfg.tol, - }) -} - -/// Result of [`gdina_wald_selection`] (de la Torre & Lee, 2013). Per item, each -/// candidate reduced model is Wald-tested against the saturated G-DINA, and -/// `selected` names the most parsimonious model not rejected at level `alpha`. -#[derive(Clone, Debug)] -pub struct WaldSelectionResult { - /// Candidate reduced models (`["dina", "dino", "acdm"]`); DINA and DINO cost two - /// parameters, A-CDM costs `1 + K_i`. - pub models: Vec, - /// Wald statistic per `(item, model)`, row-major `n_items * n_models`; `NaN` - /// where the test is undefined (an item requiring `< 2` attributes). - pub wald_stat: Vec, - /// Degrees of freedom per `(item, model)`, row-major (`0` when undefined). - pub wald_df: Vec, - /// Upper-tail p-value per `(item, model)`, row-major (`NaN` where undefined). - pub p_value: Vec, - /// Selected model index into `models` per item, or `-1` for the saturated - /// G-DINA (all reduced models rejected, or the item requires `< 2` attributes). - pub selected: Vec, - pub alpha: f64, -} - -fn select_wald_model(df: &[usize], p_value: &[f64], alpha: f64, k: usize) -> i64 { - let param_count = |model: usize| if model <= 1 { 2 } else { 1 + k }; - let mut best: Option = None; - for model in 0..p_value.len() { - if df[model] == 0 { - continue; - } - let candidate_p = p_value[model]; - if candidate_p.is_finite() && candidate_p > alpha { - best = match best { - None => Some(model), - Some(current) => { - let (current_n, candidate_n) = (param_count(current), param_count(model)); - if candidate_n < current_n - || (candidate_n == current_n && candidate_p > p_value[current]) - { - Some(model) - } else { - Some(current) - } - } - }; - } - } - best.map_or(-1, |model| model as i64) -} - -/// Item-level cognitive-diagnosis model selection by the Wald test (de la Torre & -/// Lee, 2013). For each item the saturated G-DINA is compared with reduced models that -/// are exact linear restrictions of the reduced-class success probabilities `P` (the -/// `2^{K_i}` values; `M[l][S] = [S subseteq l]` the subset-sum design, see [`fit_gdina`]). -/// DINA/DINO/A-CDM restrict the identity-link parameters `delta = M^{-1} P`; LLM and -/// R-RUM are additive on the logit and log links, so they restrict the transformed -/// parameters `delta^h = M^{-1} h(P)`: -/// -/// - **DINA** (purely conjunctive): only the intercept `delta_0` and the top -/// interaction `delta_{1..K}` are free; the middle `2^{K_i} - 2` coordinates are 0. -/// - **DINO** (purely disjunctive): the non-intercept coordinates are tied onto one -/// line `delta_S = (-1)^{|S|+1} Delta` (a general, non-coordinate linear -/// restriction with `df = 2^{K_i} - 2`). -/// - **A-CDM** (additive on the identity link): all interaction coordinates (`|S| >= 2`) -/// are 0, leaving the intercept and `K_i` main effects. -/// - **LLM** (linear logistic model; additive on the logit link): the interaction -/// coordinates of `delta^{logit} = M^{-1} logit(P)` are 0 (`df = 2^{K_i} - 1 - K_i`). -/// - **R-RUM** (reduced reparameterized unified model; additive on the log link): the -/// interaction coordinates of `delta^{log} = M^{-1} log(P)` are 0 (same `df`). -/// -/// The Wald statistic for the restriction `R delta = 0` (`df = rank(R)`) is -/// `W = (R delta)^T (R Sigma_delta R^T)^{-1} (R delta) ~ chi^2(df)` under the reduced -/// model; for the coordinate restrictions (DINA, A-CDM, LLM, R-RUM) `R` selects -/// coordinates and `R Sigma_delta R^T` is the corresponding *block* of `Sigma_delta`, -/// while DINO uses a general sparse `R`. Under the complete-data model each -/// `P_hat_l = R_l / I_l` is a binomial proportion over the disjoint persons of reduced -/// class `l`, so `Var(P_hat) = diag(P_l (1 - P_l) / I_l)` is *exact* there (`I_l` = -/// expected count in reduced class `l`). For the identity link -/// `Sigma_delta = M^{-1} Var(P_hat) M^{-T}`; for a transformed link `h` the first-order -/// delta method gives `Var(h(P_hat_l)) = h'(P_hat_l)^2 Var(P_hat_l)`, so LLM uses -/// `h' = 1/(P(1-P))` (`Var = 1/(I_l P_l(1-P_l))`) and R-RUM `h' = 1/P` -/// (`Var = (1-P_l)/(I_l P_l)`), with the same Mobius sandwich. This estimator uses -/// complete-data (expected) rather than observed information; by the missing-information -/// principle `I_complete >= I_observed`, so `Sigma_delta` is under-estimated and the test -/// is mildly **liberal** (Type I `>=` alpha), the gap shrinking with `N` and with item -/// discrimination (small slip/guess). Per item the fewest-parameter model with -/// `p > alpha` is selected (DINA and DINO cost two parameters; A-CDM, LLM and R-RUM each -/// cost `1 + K_i`, so ties are broken by the larger p-value); if all reduced models are -/// rejected, the saturated G-DINA. -/// -/// `y`/`observed` are row-major `N*J`; `q_matrix` row-major `J*K` (0/1). Deferred: the -/// incomplete-data (observed-information) covariance. A nonconverged saturated G-DINA -/// calibration is rejected rather than used to form Wald statistics from unfinished -/// parameters. -/// -/// References (APA 7th ed.): -/// de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, -/// 76*(2), 179–199. https://doi.org/10.1007/s11336-011-9207-7 -/// de la Torre, J., & Lee, Y.-S. (2013). Evaluating the Wald test for item-level -/// comparison of saturated and reduced models in cognitive diagnosis. *Journal -/// of Educational Measurement, 50*(4), 355–373. -/// https://doi.org/10.1111/jedm.12022 -/// Ma, W., Iaconangelo, C., & de la Torre, J. (2016). Model similarity, model -/// selection, and attribute classification. *Applied Psychological Measurement, -/// 40*(3), 200–217. https://doi.org/10.1177/0146621615621717 -#[allow(clippy::too_many_arguments)] -pub fn gdina_wald_selection( - y: &[f64], - observed: &[bool], - q_matrix: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - alpha: f64, - cfg: &CdmConfig, -) -> Result { - if !(alpha > 0.0 && alpha < 1.0) { - return Err("alpha must be in (0, 1)".into()); - } - let l_full = 1usize << n_attributes; - - // Saturated G-DINA under the given Q (also validates y/observed/q shapes+config). - let res = fit_gdina(y, observed, q_matrix, n_persons, n_items, n_attributes, cfg)?; - ensure_gdina_converged(&res, cfg)?; - - // Reduced-class index tables (mirror fit_gdina), then one posterior pass to - // recover the expected reduced-class counts I_l (the Var(P_l) denominators). - let mut qmask = vec![0usize; n_items]; - for i in 0..n_items { - for k in 0..n_attributes { - if q_matrix[i * n_attributes + k] != 0 { - qmask[i] |= 1 << k; - } - } - } - let total = res.item_off[n_items]; - let mut red = vec![0u16; n_items * l_full]; - for i in 0..n_items { - for c in 0..l_full { - red[i * l_full + c] = reduce_class(c, qmask[i]) as u16; - } - } - let mut log_p1 = vec![0.0f64; total]; - let mut log_p0 = vec![0.0f64; total]; - for x in 0..total { - let pc = res.item_prob[x].clamp(cfg.eps, 1.0 - cfg.eps); - log_p1[x] = pc.ln(); - log_p0[x] = (1.0 - pc).ln(); - } - let log_pi: Vec = res - .profile_prob - .iter() - .map(|v| v.max(cfg.eps).ln()) - .collect(); - let mut icount = vec![0.0f64; total]; // I_l, CSR layout matching item_prob - let mut post = vec![0.0f64; l_full]; - for j in 0..n_persons { - posterior_row_gdina( - j, - y, - observed, - n_items, - l_full, - &red, - &log_p1, - &log_p0, - &res.item_off, - &log_pi, - &mut post, - ); - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - for c in 0..l_full { - icount[res.item_off[i] + red[i * l_full + c] as usize] += post[c]; - } - } - } - } - - let models = vec![ - "dina".to_string(), - "dino".to_string(), - "acdm".to_string(), - "llm".to_string(), - "rrum".to_string(), - ]; - let n_models = models.len(); - let mut wald_stat = vec![f64::NAN; n_items * n_models]; - let mut wald_df = vec![0usize; n_items * n_models]; - let mut p_value = vec![f64::NAN; n_items * n_models]; - let mut selected = vec![-1i64; n_items]; - - for i in 0..n_items { - let k = res.k_required[i] as usize; - if k < 2 { - continue; // no interactions: DINA = A-CDM = saturated, nothing to test - } - let off = res.item_off[i]; - let w = res.item_off[i + 1] - off; // 2^k - let p = &res.item_prob[off..off + w]; - let delta = &res.item_delta[off..off + w]; - let ic = &icount[off..off + w]; - - // Sigma_delta = sum_l v_l c_l c_l^T, c_l = M^{-1} e_l (Mobius applied to the - // l-th unit vector = column l of M^{-1}). The per-class variance v_l depends on - // the link on which the reduced model is additive. On the identity link - // (DINA/DINO/A-CDM) v_l = Var(P_l) = P_l(1-P_l)/I_l. On a transformed link h the - // delta method gives Var(h(P_l)) = h'(P_l)^2 Var(P_l): LLM uses the logit - // (h' = 1/(P(1-P)) -> v_l = 1/(I_l P_l(1-P_l))) and R-RUM the log - // (h' = 1/P -> v_l = (1-P_l)/(I_l P_l)). The Mobius columns c_l are shared across - // links, so all three covariances accumulate in one pass. An empty reduced class - // (I_l ~ 0) is floored so its variance is finite-but-huge, making any delta - // touching it effectively untestable (conservative) rather than NaN. - let mut sigma = vec![vec![0.0f64; w]; w]; // identity link (DINA/DINO/A-CDM) - let mut sigma_logit = vec![vec![0.0f64; w]; w]; // logit link (LLM) - let mut sigma_log = vec![vec![0.0f64; w]; w]; // log link (R-RUM) - for l in 0..w { - // Floor at count_floor (an empty class -> huge, conservative variance) - // and additionally at a strictly positive constant so a wholly-unobserved - // item under a `count_floor == 0` config cannot divide by zero. - let denom = ic[l].max(cfg.count_floor).max(1e-12); - let pl = p[l].clamp(cfg.eps, 1.0 - cfg.eps); // guard the logit/log transforms - let base = pl * (1.0 - pl); // P_l(1-P_l) > 0 under the clamp - let v = base / denom; // identity-link Var(P_l), matches the reduced-model baseline - debug_assert!( - v > 0.0, - "clamped probabilities and positive counts imply variance" - ); - let v_logit = 1.0 / (denom * base); // (1/base)^2 * base/denom - let v_log = (1.0 - pl) / (denom * pl); // (1/P_l)^2 * base/denom - let mut c = vec![0.0f64; w]; - c[l] = 1.0; - mobius_inverse_inplace(&mut c, k as u32); - for a in 0..w { - let ca = c[a]; - if ca == 0.0 { - continue; - } - let (vca, vca_logit, vca_log) = (v * ca, v_logit * ca, v_log * ca); - for b in 0..w { - let cb = c[b]; - sigma[a][b] += vca * cb; - sigma_logit[a][b] += vca_logit * cb; - sigma_log[a][b] += vca_log * cb; - } - } - } - // Link-transformed deltas delta^h = M^{-1} h(P) restricted by LLM (logit) and - // R-RUM (log); the identity-link `delta` above serves DINA/DINO/A-CDM. - let mut delta_logit = vec![0.0f64; w]; - let mut delta_log = vec![0.0f64; w]; - for l in 0..w { - let pl = p[l].clamp(cfg.eps, 1.0 - cfg.eps); - delta_logit[l] = (pl / (1.0 - pl)).ln(); - delta_log[l] = pl.ln(); - } - mobius_inverse_inplace(&mut delta_logit, k as u32); - mobius_inverse_inplace(&mut delta_log, k as u32); - - let full = w - 1; - // Restriction rows in the subset-index layout: each row is a sparse linear - // combination sum_j coeff_j * delta_{S_j} that a reduced model sets to zero. - // DINA and A-CDM are coordinate restrictions (single unit entry per row); DINO - // is a general restriction that ties the non-intercept deltas onto one line - // delta_S = (-1)^{|S|+1} * Delta (reference coordinate s=1, so Delta = delta_1). - let restriction_rows = |model: usize| -> Vec> { - match model { - // DINA: intercept and top interaction free, middle coordinates zero. - 0 => (0..w) - .filter(|&s| s != 0 && s != full) - .map(|s| vec![(s, 1.0)]) - .collect(), - // DINO: delta_S - (-1)^{|S|+1} delta_1 = 0 for every S != {empty, ref=1}. - 1 => (0..w) - .filter(|&s| s != 0 && s != 1) - .map(|s| { - let sign = if (s as u32).count_ones() % 2 == 1 { - 1.0 - } else { - -1.0 - }; - vec![(s, 1.0), (1usize, -sign)] - }) - .collect(), - // A-CDM / LLM / R-RUM: all interaction coordinates zero. The three share - // this restriction pattern but on different links (identity/logit/log), - // so they differ only in which (delta, Sigma) pair the caller feeds in. - _ => (0..w) - .filter(|&s| (s as u32).count_ones() >= 2) - .map(|s| vec![(s, 1.0)]) - .collect(), - } - }; - - for m in 0..n_models { - let rows = restriction_rows(m); - let df = rows.len(); - wald_df[i * n_models + m] = df; - debug_assert!( - df > 0, - "items with at least two attributes have restrictions" - ); - // DINA/DINO/A-CDM restrict the identity-link delta; LLM restricts the - // logit-link delta and R-RUM the log-link delta, each with the matching - // delta-method covariance. - let (dvec, svec): (&[f64], &[Vec]) = match m { - 3 => (&delta_logit, &sigma_logit), - 4 => (&delta_log, &sigma_log), - _ => (delta, &sigma), - }; - // R*delta and R*Sigma*R^T; relative ridge for a well-posed solve. - let mut rd = vec![0.0f64; df]; - let mut sr = vec![vec![0.0f64; df]; df]; - for a in 0..df { - for &(ca, va) in &rows[a] { - rd[a] += va * dvec[ca]; - } - } - for a in 0..df { - for b in 0..df { - let mut acc = 0.0f64; - for &(ca, va) in &rows[a] { - for &(cb, vb) in &rows[b] { - acc += va * vb * svec[ca][cb]; - } - } - sr[a][b] = acc; - } - } - let diag_sum: f64 = (0..df).map(|a| sr[a][a]).sum(); - let ridge = 1e-9 * (diag_sum / df as f64).max(1e-300); - for a in 0..df { - sr[a][a] += ridge; - } - // W = (R delta)^T (R Sigma R^T)^{-1} (R delta): solve (R Sigma R^T) x = R delta. - let x = crate::poly::solve_small(sr, rd.clone()); - let wstat = (0..df).map(|a| rd[a] * x[a]).sum::().max(0.0); - wald_stat[i * n_models + m] = wstat; - p_value[i * n_models + m] = crate::fitstats::chi2_sf(wstat, df as f64); - } - - // Fewest-parameter reduced model not rejected (DINA=2, DINO=2, A-CDM=1+K); - // ties (DINA vs DINO) broken by the larger p-value; else the saturated G-DINA. - selected[i] = select_wald_model( - &wald_df[i * n_models..(i + 1) * n_models], - &p_value[i * n_models..(i + 1) * n_models], - alpha, - k, - ); - } - - Ok(WaldSelectionResult { - models, - wald_stat, - wald_df, - p_value, - selected, - alpha, - }) -} - -/// Mild Gaussian ridge on the higher-order attribute parameters, mirroring -/// `fit_mmle_2pl`'s ridge so the per-attribute Newton stays well-posed. -const HO_RIDGE: f64 = 1e-3; - -/// Result of [`fit_ho_cdm`] (de la Torre & Douglas, 2004). The `2^K` class -/// distribution is not free: it is generated by the higher-order trait through -/// `attr_slope`/`attr_intercept`, and `profile_prob` is the *implied* marginal. -#[derive(Clone, Debug)] -pub struct HoCdmResult { - pub model: CdmModel, - /// Per-item slip `s_i` and guess `g_i`. - pub slip: Vec, - pub guess: Vec, - /// Higher-order attribute slope `a_k` (discrimination on the trait), length `K`. - pub attr_slope: Vec, - /// Higher-order attribute intercept `d_k` (easiness), length `K`. - pub attr_intercept: Vec, - /// Implied marginal class probabilities `pi_c` (length `2^K`, sum 1). - pub profile_prob: Vec, - /// Per-person EAP higher-order trait score. - pub theta: Vec, - /// Bit-encoded MAP profile per person. - pub map_profile: Vec, - /// Marginal `P(alpha_jk = 1 | X_j)`, row-major `N x K`. - pub attr_prob: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// Configured outer EM iteration limit. - pub max_iter: usize, - /// Stable public reason for termination: `tolerance_met` or `max_iter_reached`. - pub termination_reason: &'static str, - /// Last observed-data log-likelihood increment at the returned parameters. - pub final_loglik_change: f64, - /// Requested absolute log-likelihood-change stopping tolerance. - pub stopping_tolerance: f64, - /// `2*J + 2*K`. - pub n_parameters: usize, -} - -/// Marginal class probabilities `pi_c = integral P(alpha_c | theta) phi(theta) dtheta` -/// implied by the higher-order parameters, on the 41-node Gauss-Hermite grid. With -/// every slope zero this is exactly the independent-attribute Bernoulli product -/// `prod_k sigmoid(d_k)^{alpha_ck} (1 - sigmoid(d_k))^{1 - alpha_ck}` (theta drops out). -fn ho_pi_from_params(attr_slope: &[f64], attr_intercept: &[f64], n_attributes: usize) -> Vec { - use crate::mmle::{log_sigmoid, GH_NODES, GH_WEIGHTS}; - let l = 1usize << n_attributes; - let q = GH_NODES.len(); - let mut logp = vec![0.0f64; n_attributes * q]; - let mut log1mp = vec![0.0f64; n_attributes * q]; - for k in 0..n_attributes { - for (qi, &node) in GH_NODES.iter().enumerate() { - let z = attr_slope[k] * node + attr_intercept[k]; - logp[k * q + qi] = log_sigmoid(z); - log1mp[k * q + qi] = log_sigmoid(-z); - } - } - let mut pi = vec![0.0f64; l]; - for (c, pic) in pi.iter_mut().enumerate() { - let mut acc = 0.0f64; - for (qi, &w) in GH_WEIGHTS.iter().enumerate() { - let mut lp = 0.0f64; - for k in 0..n_attributes { - lp += if (c >> k) & 1 == 1 { - logp[k * q + qi] - } else { - log1mp[k * q + qi] - }; - } - acc += w * lp.exp(); - } - *pic = acc; - } - pi -} - -/// Newton step for one attribute's higher-order 2PL `sigmoid(a*theta + d)` from the -/// expected node counts `r[q]` (masters) and `w[q]` (total) at the Gauss-Hermite -/// nodes. Arithmetically identical to `fit_mmle_2pl`'s inner `(a, b)` Newton. -fn newton_attr_2pl(mut a: f64, mut d: f64, r: &[f64], w: &[f64], newton_iter: usize) -> (f64, f64) { - use crate::mmle::{log_sigmoid, sigmoid_stable, GH_NODES}; - let q_value = |aa: f64, dd: f64| -> f64 { - GH_NODES - .iter() - .enumerate() - .map(|(qi, &node)| { - let z = aa * node + dd; - r[qi] * log_sigmoid(z) + (w[qi] - r[qi]) * log_sigmoid(-z) - }) - .sum() - }; - for _ in 0..newton_iter { - let (mut g_a, mut g_d, mut h_aa, mut h_dd, mut h_ad) = (0.0, 0.0, 0.0, 0.0, 0.0); - for (qi, &node) in GH_NODES.iter().enumerate() { - let p = sigmoid_stable(a * node + d); - let ww = w[qi] * p * (1.0 - p); - let resid = r[qi] - w[qi] * p; - g_a += resid * node; - g_d += resid; - h_aa -= ww * node * node; - h_dd -= ww; - h_ad -= ww * node; - } - g_a -= HO_RIDGE * a; - g_d -= HO_RIDGE * d; - h_aa -= HO_RIDGE; - h_dd -= HO_RIDGE; - let det = h_aa * h_dd - h_ad * h_ad; - debug_assert!( - det >= HO_RIDGE * HO_RIDGE, - "the positive ridge keeps the information matrix nonsingular" - ); - let da = (h_dd * g_a - h_ad * g_d) / det; - let dd = (h_aa * g_d - h_ad * g_a) / det; - let (old_a, old_d) = (a, d); - let old_q = q_value(old_a, old_d); - let mut step = 1.0f64; - let mut accepted = false; - // Near-separated expected counts can make an undamped Newton step overshoot. - // Backtrack on the unpenalized EM auxiliary function so the numerical ridge - // cannot make the reported marginal log-likelihood move backwards. - for _ in 0..30 { - // A zero loading is the independent-attribute boundary of the - // higher-order model. Keep the identification anchor non-negative, - // but do not exclude that valid boundary with an arbitrary epsilon. - let cand_a = (old_a - step * da).clamp(0.0, 10.0); - let cand_d = old_d - step * dd; - let cand_q = q_value(cand_a, cand_d); - if cand_q.is_finite() && cand_q >= old_q - 1e-12 { - a = cand_a; - d = cand_d; - accepted = true; - break; - } - step *= 0.5; - } - if !accepted { - break; - } - if (a - old_a).abs() + (d - old_d).abs() < 1e-8 { - break; - } - } - (a, d) -} - -/// Fit the higher-order DINA/DINO model of de la Torre and Douglas (2004) using this -/// crate's marginal EM over the joint `(alpha_c, theta_q)` grid. The source paper -/// estimates the model by Bayesian MCMC; quadrature EM is the implementation choice -/// here, not an algorithm claimed by that paper. A continuous higher-order trait -/// `theta ~ N(0,1)` structures attribute mastery, -/// `P(alpha_k = 1 | theta) = sigmoid(a_k theta + d_k)` with attributes conditionally -/// independent given `theta`, so the `2^K` class distribution is a `2K`-parameter -/// structured family rather than the free distribution of [`fit_cdm`]. The item part -/// (slip/guess DINA or DINO gate) is unchanged; the population update is replaced by -/// `K` independent 2PL calibrations of attribute mastery on `theta`. -/// -/// `y`/`observed` are row-major `N*J` (`y` in {0,1}); `q_matrix` is row-major `J*K`. -/// Missing cells (MAR) are dropped. With every `a_k = 0` the class prior reduces to -/// independent attributes; the trait `theta` fixes its own scale via the `N(0,1)` -/// prior. Reuses `mmle::GH_NODES/GH_WEIGHTS` (41-node) and the DINA item M-step. -/// -/// The observed-data likelihood depends on `(a_k, d_k)` only through the implied -/// class distribution `pi_c`, so the higher-order parameters are a genuine -/// restriction (and identified) only for `K >= 3` (`2K` structural parameters vs a -/// `2^K - 1`-dimensional simplex); at `K <= 2` they are over-parameterized and only -/// `pi_c` (and the attribute classification) is identified. `attr_slope` is anchored -/// non-negative (`a_k >= 1e-3`), the standard orientation that the trait raises every -/// attribute's mastery. -/// -/// References (APA 7th ed.): -/// de la Torre, J., & Douglas, J. A. (2004). Higher-order latent trait models for -/// cognitive diagnosis. *Psychometrika, 69*(3), 333-353. -/// -#[allow(clippy::too_many_arguments)] -pub fn fit_ho_cdm( - y: &[f64], - observed: &[bool], - q_matrix: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - model: CdmModel, - cfg: &CdmConfig, -) -> Result { - use crate::mmle::{log_sigmoid, GH_NODES, GH_WEIGHTS}; - validate(y, observed, q_matrix, n_persons, n_items, n_attributes, cfg)?; - let l = 1usize << n_attributes; - let q = GH_NODES.len(); - let log_w: Vec = GH_WEIGHTS.iter().map(|w| w.ln()).collect(); - - // Ideal-response gate (same as fit_cdm). - let mut qmask = vec![0usize; n_items]; - for i in 0..n_items { - for k in 0..n_attributes { - if q_matrix[i * n_attributes + k] != 0 { - qmask[i] |= 1 << k; - } - } - } - let mut eta = vec![0u8; n_items * l]; - for i in 0..n_items { - for c in 0..l { - eta[i * l + c] = match model { - CdmModel::Dina => ((c & qmask[i]) == qmask[i]) as u8, - CdmModel::Dino => ((c & qmask[i]) != 0) as u8, - }; - } - } - - let mut s = vec![cfg.init_slip; n_items]; - let mut g = vec![cfg.init_guess; n_items]; - let mut a = vec![1.0f64; n_attributes]; - let mut d = vec![0.0f64; n_attributes]; - - let mut lp1 = vec![0.0f64; n_items * 2]; - let mut lp0 = vec![0.0f64; n_items * 2]; - let refresh = |s: &[f64], g: &[f64], lp1: &mut [f64], lp0: &mut [f64]| { - for i in 0..n_items { - let sc = s[i].clamp(cfg.eps, 1.0 - cfg.eps); - let gc = g[i].clamp(cfg.eps, 1.0 - cfg.eps); - lp1[i * 2 + 1] = (1.0 - sc).ln(); - lp0[i * 2 + 1] = sc.ln(); - lp1[i * 2] = gc.ln(); - lp0[i * 2] = (1.0 - gc).ln(); - } - }; - - // Build the structural class-log-prior table logPalpha[c*q + qi] for the current - // (a, d), plus the marginal logL_j(c) for one person, then the joint posterior. - let structural_table = |a: &[f64], d: &[f64]| -> Vec { - let mut logp = vec![0.0f64; n_attributes * q]; - let mut log1mp = vec![0.0f64; n_attributes * q]; - for k in 0..n_attributes { - for (qi, &node) in GH_NODES.iter().enumerate() { - let z = a[k] * node + d[k]; - logp[k * q + qi] = log_sigmoid(z); - log1mp[k * q + qi] = log_sigmoid(-z); - } - } - let mut logpa = vec![0.0f64; l * q]; - for c in 0..l { - for qi in 0..q { - let mut lp = 0.0f64; - for k in 0..n_attributes { - lp += if (c >> k) & 1 == 1 { - logp[k * q + qi] - } else { - log1mp[k * q + qi] - }; - } - logpa[c * q + qi] = lp; - } - } - logpa - }; - - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - let mut post = vec![0.0f64; l * q]; // reused joint-posterior scratch - - for _ in 0..cfg.max_iter { - refresh(&s, &g, &mut lp1, &mut lp0); - let logpa = structural_table(&a, &d); - - // E-step over the joint (c, q) grid. - let mut i1 = vec![0.0f64; n_items]; - let mut r1 = vec![0.0f64; n_items]; - let mut i0 = vec![0.0f64; n_items]; - let mut r0 = vec![0.0f64; n_items]; - let mut wq = vec![0.0f64; q]; // node mass W_q - let mut rkq = vec![0.0f64; n_attributes * q]; // masters per (k, q) - let mut total_ll = 0.0; - for j in 0..n_persons { - // logL_j(c) then joint = logL + logPalpha + log_w. - for c in 0..l { - let mut ll = 0.0f64; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let b = eta[i * l + c] as usize; - let yy = y[idx]; - ll += yy * lp1[i * 2 + b] + (1.0 - yy) * lp0[i * 2 + b]; - } - } - for qi in 0..q { - post[c * q + qi] = ll + logpa[c * q + qi] + log_w[qi]; - } - } - let mx = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - mx).exp(); - } - total_ll += mx + denom.ln(); - for v in post.iter_mut() { - *v = (*v - mx).exp() / denom; - } - // marginal class posterior -> item counts (same as fit_cdm) - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let mut pbar = 0.0f64; - for c in 0..l { - if eta[i * l + c] == 1 { - // sum over q of post(c,q) restricted to masters of item i - let base = c * q; - for v in &post[base..base + q] { - pbar += v; - } - } - } - let yy = y[idx]; - i1[i] += pbar; - r1[i] += yy * pbar; - i0[i] += 1.0 - pbar; - r0[i] += yy * (1.0 - pbar); - } - } - // node mass + structural masters - for qi in 0..q { - let mut wnode = 0.0f64; - for c in 0..l { - let p = post[c * q + qi]; - wnode += p; - let mut cc = c; - let mut k = 0; - while cc != 0 { - if cc & 1 == 1 { - rkq[k * q + qi] += p; - } - cc >>= 1; - k += 1; - } - } - wq[qi] += wnode; - } - } - loglik_trace.push(total_ll); - - // Converge check before the M-step so returned params match the trace endpoint. - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - if (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - - // M-step: items (slip/guess), then structure (per-attribute 2PL). - for i in 0..n_items { - update_item(i, &i1, &r1, &i0, &r0, &mut s, &mut g, cfg); - } - for k in 0..n_attributes { - // 25 inner Newton steps (mirrors fit_mmle_2pl's default newton_iter). - let (ak, dk) = newton_attr_2pl(a[k], d[k], &rkq[k * q..(k + 1) * q], &wq, 25); - a[k] = ak; - d[k] = dk; - } - n_iter += 1; - } - - // Final classification / theta pass at the converged parameters. - refresh(&s, &g, &mut lp1, &mut lp0); - let logpa = structural_table(&a, &d); - let mut map_profile = vec![0u32; n_persons]; - let mut attr_prob = vec![0.0f64; n_persons * n_attributes]; - let mut theta = vec![0.0f64; n_persons]; - let mut final_ll = 0.0; - for j in 0..n_persons { - for c in 0..l { - let mut ll = 0.0f64; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let b = eta[i * l + c] as usize; - let yy = y[idx]; - ll += yy * lp1[i * 2 + b] + (1.0 - yy) * lp0[i * 2 + b]; - } - } - for qi in 0..q { - post[c * q + qi] = ll + logpa[c * q + qi] + log_w[qi]; - } - } - let mx = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - mx).exp(); - } - final_ll += mx + denom.ln(); - for v in post.iter_mut() { - *v = (*v - mx).exp() / denom; - } - // MAP profile (over marginal class posterior), attribute marginals, theta EAP. - let (mut best, mut best_p) = (0usize, f64::NEG_INFINITY); - for c in 0..l { - let mut pc = 0.0f64; - for v in &post[c * q..c * q + q] { - pc += v; - } - if pc > best_p { - best_p = pc; - best = c; - } - for k in 0..n_attributes { - if (c >> k) & 1 == 1 { - attr_prob[j * n_attributes + k] += pc; - } - } - } - map_profile[j] = best as u32; - for qi in 0..q { - let mut wnode = 0.0f64; - for c in 0..l { - wnode += post[c * q + qi]; - } - theta[j] += wnode * GH_NODES[qi]; - } - } - if !converged { - loglik_trace.push(final_ll); - } - let final_loglik_change = loglik_trace - .windows(2) - .last() - .map(|pair| pair[1] - pair[0]) - .unwrap_or(f64::NAN); - let termination_reason = if converged { - "tolerance_met" - } else { - "max_iter_reached" - }; - - let profile_prob = ho_pi_from_params(&a, &d, n_attributes); - Ok(HoCdmResult { - model, - slip: s, - guess: g, - attr_slope: a, - attr_intercept: d, - profile_prob, - theta, - map_profile, - attr_prob, - loglik_trace, - n_iter, - converged, - max_iter: cfg.max_iter, - termination_reason, - final_loglik_change, - stopping_tolerance: cfg.tol, - n_parameters: 2 * n_items + 2 * n_attributes, - }) -} - -/// Result of [`fit_ho_gdina`] (higher-order G-DINA). Combines the saturated per-item -/// reduced-class probabilities of [`fit_gdina`] (CSR-laid-out) with the higher-order -/// structural attribute parameters of [`fit_ho_cdm`]. -#[derive(Clone, Debug)] -pub struct HoGdinaResult { - pub item_off: Vec, - pub item_prob: Vec, - pub item_delta: Vec, - pub k_required: Vec, - /// Higher-order attribute slope `a_k` and intercept `d_k`, length `K`. - pub attr_slope: Vec, - pub attr_intercept: Vec, - /// Implied marginal class probabilities `pi_c` (length `2^K`). - pub profile_prob: Vec, - pub theta: Vec, - pub map_profile: Vec, - pub attr_prob: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// Stable public reason for termination: `tolerance_met` or `max_iter_reached`. - pub termination_reason: &'static str, - /// Last observed-data log-likelihood increment at the returned parameters. - pub final_loglik_change: f64, - /// Last scale-free increment `|delta log L| / (1 + |log L_previous|)`. - pub final_relative_loglik_change: f64, - /// Requested relative log-likelihood stopping tolerance. - pub stopping_tolerance: f64, - /// `sum_i 2^{K_i} + 2*K`. - pub n_parameters: usize, -} - -/// Fit the higher-order G-DINA model by marginal-ML EM over the joint -/// `(alpha_c, theta_q)` grid: the saturated G-DINA item model of [`fit_gdina`] (each -/// reduced attribute-mastery class of each item gets a free success probability) -/// under the higher-order structural attribute prior of [`fit_ho_cdm`] (a continuous -/// trait `theta ~ N(0,1)` drives mastery, `P(alpha_k=1|theta) = sigmoid(a_k theta + -/// d_k)`, attributes conditionally independent given `theta`). This is the -/// higher-order form of the general G-DINA framework: it generalizes [`fit_ho_cdm`] -/// (which restricts the item model to DINA slip/guess) and constrains [`fit_gdina`]'s -/// free class distribution to the `2K`-parameter structured family. -/// -/// The item response is conditionally independent of `theta` given `alpha`, so the -/// saturated item M-step `p_il = R_il / I_il` marginalizes `theta` out (it uses only -/// the marginal class posterior), exactly as [`fit_gdina`]; the structural M-step is -/// `K` independent 2PL calibrations of attribute mastery on `theta`, exactly as -/// [`fit_ho_cdm`]. Identification mirrors [`fit_ho_cdm`] (`theta ~ N(0,1)` fixes the -/// scale, `a_k` anchored non-negative, higher-order parameters identified for -/// `K >= 3`) and [`fit_gdina`] (the Q-matrix must identify the saturated item probs). -/// This implementation stops on the scale-free observed-data likelihood change -/// `|delta log L| / (1 + |log L_previous|) < tol`; this numerical rule is a package -/// choice rather than a claim from the cited Bayesian source estimator. -/// -/// References (APA 7th ed.): -/// de la Torre, J., & Douglas, J. A. (2004). Higher-order latent trait models for -/// cognitive diagnosis. *Psychometrika, 69*(3), 333-353. -/// https://doi.org/10.1007/BF02295640 -/// de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, -/// 76*(2), 179-199. https://doi.org/10.1007/s11336-011-9207-7 -#[allow(clippy::too_many_arguments)] -pub fn fit_ho_gdina( - y: &[f64], - observed: &[bool], - q_matrix: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - cfg: &CdmConfig, -) -> Result { - use crate::mmle::{log_sigmoid, GH_NODES, GH_WEIGHTS}; - validate(y, observed, q_matrix, n_persons, n_items, n_attributes, cfg)?; - if n_attributes < 3 { - return Err("higher-order G-DINA requires at least 3 attributes for identified structural parameters".into()); - } - let l = 1usize << n_attributes; - let q = GH_NODES.len(); - let log_w: Vec = GH_WEIGHTS.iter().map(|w| w.ln()).collect(); - - // Saturated-item CSR layout (verbatim from fit_gdina). - let mut qmask = vec![0usize; n_items]; - let mut k_required = vec![0u32; n_items]; - for i in 0..n_items { - let mut mask = 0usize; - for k in 0..n_attributes { - if q_matrix[i * n_attributes + k] != 0 { - mask |= 1 << k; - } - } - qmask[i] = mask; - k_required[i] = mask.count_ones(); - } - let mut item_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - item_off[i + 1] = item_off[i] + (1usize << k_required[i]); - } - let total = item_off[n_items]; - let mut red = vec![0u16; n_items * l]; - for i in 0..n_items { - for c in 0..l { - red[i * l + c] = reduce_class(c, qmask[i]) as u16; - } - } - let mut p = vec![0.0f64; total]; - for i in 0..n_items { - let ki = k_required[i] as f64; - for li in 0..(item_off[i + 1] - item_off[i]) { - let frac = (li.count_ones() as f64) / ki; - p[item_off[i] + li] = cfg.init_guess + (1.0 - cfg.init_slip - cfg.init_guess) * frac; - } - } - - // Higher-order structural parameters (verbatim from fit_ho_cdm). - let mut a = vec![1.0f64; n_attributes]; - let mut d = vec![0.0f64; n_attributes]; - let structural_table = |a: &[f64], d: &[f64]| -> Vec { - let mut logp = vec![0.0f64; n_attributes * q]; - let mut log1mp = vec![0.0f64; n_attributes * q]; - for k in 0..n_attributes { - for (qi, &node) in GH_NODES.iter().enumerate() { - let z = a[k] * node + d[k]; - logp[k * q + qi] = log_sigmoid(z); - log1mp[k * q + qi] = log_sigmoid(-z); - } - } - let mut logpa = vec![0.0f64; l * q]; - for c in 0..l { - for qi in 0..q { - let mut lp = 0.0f64; - for k in 0..n_attributes { - lp += if (c >> k) & 1 == 1 { - logp[k * q + qi] - } else { - log1mp[k * q + qi] - }; - } - logpa[c * q + qi] = lp; - } - } - logpa - }; - - let mut log_p1 = vec![0.0f64; total]; - let mut log_p0 = vec![0.0f64; total]; - let refresh_p = |p: &[f64], log_p1: &mut [f64], log_p0: &mut [f64]| { - for x in 0..total { - let pc = p[x].clamp(cfg.eps, 1.0 - cfg.eps); - log_p1[x] = pc.ln(); - log_p0[x] = (1.0 - pc).ln(); - } - }; - - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - let mut post = vec![0.0f64; l * q]; - let mut pc = vec![0.0f64; l]; // marginal class posterior scratch - - for _ in 0..cfg.max_iter { - refresh_p(&p, &mut log_p1, &mut log_p0); - let logpa = structural_table(&a, &d); - - let mut ii = vec![0.0f64; total]; - let mut rr = vec![0.0f64; total]; - let mut wq = vec![0.0f64; q]; - let mut rkq = vec![0.0f64; n_attributes * q]; - let mut total_ll = 0.0; - for j in 0..n_persons { - for c in 0..l { - let mut ll = 0.0f64; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let cell = item_off[i] + red[i * l + c] as usize; - let yy = y[idx]; - ll += yy * log_p1[cell] + (1.0 - yy) * log_p0[cell]; - } - } - for qi in 0..q { - post[c * q + qi] = ll + logpa[c * q + qi] + log_w[qi]; - } - } - let mx = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - mx).exp(); - } - total_ll += mx + denom.ln(); - for v in post.iter_mut() { - *v = (*v - mx).exp() / denom; - } - // marginal class posterior (theta integrated out) -> saturated item counts - for c in 0..l { - let mut s = 0.0f64; - for v in &post[c * q..c * q + q] { - s += v; - } - pc[c] = s; - } - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let yy = y[idx]; - for c in 0..l { - let cell = item_off[i] + red[i * l + c] as usize; - ii[cell] += pc[c]; - rr[cell] += yy * pc[c]; - } - } - } - // structural node mass + masters (same as fit_ho_cdm) - for qi in 0..q { - let mut wnode = 0.0f64; - for c in 0..l { - let pv = post[c * q + qi]; - wnode += pv; - let mut cc = c; - let mut k = 0; - while cc != 0 { - if cc & 1 == 1 { - rkq[k * q + qi] += pv; - } - cc >>= 1; - k += 1; - } - } - wq[qi] += wnode; - } - } - loglik_trace.push(total_ll); - - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - let delta = loglik_trace[n - 1] - loglik_trace[n - 2]; - let relative_delta = delta.abs() / (1.0 + loglik_trace[n - 2].abs()); - if relative_delta < cfg.tol { - converged = true; - break; - } - } - - // M-step: saturated item probs (closed form) then per-attribute 2PL Newton. - for x in 0..total { - if ii[x] > cfg.count_floor { - p[x] = (rr[x] / ii[x]).clamp(cfg.eps, 1.0 - cfg.eps); - } - } - for k in 0..n_attributes { - let (ak, dk) = newton_attr_2pl(a[k], d[k], &rkq[k * q..(k + 1) * q], &wq, 25); - a[k] = ak; - d[k] = dk; - } - n_iter += 1; - } - - // Final classification / theta pass at the returned parameters. - refresh_p(&p, &mut log_p1, &mut log_p0); - let logpa = structural_table(&a, &d); - let mut map_profile = vec![0u32; n_persons]; - let mut attr_prob = vec![0.0f64; n_persons * n_attributes]; - let mut theta = vec![0.0f64; n_persons]; - let mut final_ll = 0.0; - for j in 0..n_persons { - for c in 0..l { - let mut ll = 0.0f64; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let cell = item_off[i] + red[i * l + c] as usize; - let yy = y[idx]; - ll += yy * log_p1[cell] + (1.0 - yy) * log_p0[cell]; - } - } - for qi in 0..q { - post[c * q + qi] = ll + logpa[c * q + qi] + log_w[qi]; - } - } - let mx = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - mx).exp(); - } - final_ll += mx + denom.ln(); - for v in post.iter_mut() { - *v = (*v - mx).exp() / denom; - } - let (mut best, mut best_p) = (0usize, f64::NEG_INFINITY); - for c in 0..l { - let mut cpost = 0.0f64; - for v in &post[c * q..c * q + q] { - cpost += v; - } - if cpost > best_p { - best_p = cpost; - best = c; - } - for k in 0..n_attributes { - if (c >> k) & 1 == 1 { - attr_prob[j * n_attributes + k] += cpost; - } - } - } - map_profile[j] = best as u32; - for qi in 0..q { - let mut wnode = 0.0f64; - for c in 0..l { - wnode += post[c * q + qi]; - } - theta[j] += wnode * GH_NODES[qi]; - } - } - if !converged { - loglik_trace.push(final_ll); - } - let final_loglik_change = loglik_trace - .windows(2) - .last() - .map(|pair| pair[1] - pair[0]) - .unwrap_or(f64::NAN); - let final_relative_loglik_change = loglik_trace - .windows(2) - .last() - .map(|pair| (pair[1] - pair[0]).abs() / (1.0 + pair[0].abs())) - .unwrap_or(f64::NAN); - let termination_reason = if converged { - "tolerance_met" - } else { - "max_iter_reached" - }; - - // Identity-link parameters delta = M^{-1} p, per item slice. - let mut item_delta = p.clone(); - for i in 0..n_items { - mobius_inverse_inplace(&mut item_delta[item_off[i]..item_off[i + 1]], k_required[i]); - } - let profile_prob = ho_pi_from_params(&a, &d, n_attributes); - - Ok(HoGdinaResult { - item_off, - item_prob: p, - item_delta, - k_required, - attr_slope: a, - attr_intercept: d, - profile_prob, - theta, - map_profile, - attr_prob, - loglik_trace, - n_iter, - converged, - termination_reason, - final_loglik_change, - final_relative_loglik_change, - stopping_tolerance: cfg.tol, - n_parameters: total + 2 * n_attributes, - }) -} - -/// Result of [`fit_seq_gdina`] (Ma & de la Torre, 2016): the shared-Q sequential -/// (continuation-ratio) G-DINA for ordered polytomous responses. -/// -/// Ragged, CLASS-MAJOR CSR. Item `i` has `M_i = max_cat[i]` ordered steps over -/// `2^{K_i}` reduced attribute classes: -/// `step_prob[s_off[i] + l * M_i + (k-1)] = s_ik(l) = P(X_i >= k | X_i >= k-1, class l)` -/// for step `k in 1..=M_i` and reduced class `l`; the implied category probabilities are -/// `cat_prob[cat_off[i] + l * (M_i + 1) + x] = P(X_i = x | class l)` for `x in 0..=M_i`. -#[derive(Clone, Debug)] -pub struct SeqGdinaResult { - /// Per-item step-prob block offsets into `step_prob` (length `n_items + 1`). - pub s_off: Vec, - /// Continuation probabilities `s_ik(l)`, class-major ragged (see struct doc). - pub step_prob: Vec, - /// Per-item category-prob block offsets into `cat_prob` (length `n_items + 1`). - pub cat_off: Vec, - /// Implied category probabilities `P(X_i = x | class l)`, class-major ragged. - pub cat_prob: Vec, - /// Maximum observed category `M_i` (number of ordered steps) per item. - pub max_cat: Vec, - /// Required-attribute count `K_i` per item. - pub k_required: Vec, - /// Free profile distribution `pi_c` (length `2^K`, sums to 1). - pub profile_prob: Vec, - /// Bit-encoded MAP profile per person. - pub map_profile: Vec, - /// Marginal `P(alpha_jk = 1 | X_j)`, row-major `N x K`. - pub attr_prob: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// Stable public reason for termination: `tolerance_met` or `max_iter_reached`. - pub termination_reason: &'static str, - /// Last observed-data log-likelihood increment at the returned parameters. - pub final_loglik_change: f64, - /// Last scale-free increment `|delta log L| / (1 + |log L_previous|)`. - pub final_relative_loglik_change: f64, - /// Requested absolute log-likelihood stopping tolerance. - pub stopping_tolerance: f64, - /// `sum_i M_i * 2^{K_i}` step probs `+ (2^K - 1)` free profile probs. - pub n_parameters: usize, -} - -/// Category cap for the sequential G-DINA: an ordered item may have at most this many -/// categories (`0..=SEQ_MAX_CAT`). Bounds the ragged `(M_i + 1) * 2^{K_i}` allocation -/// against an adversarial category label, mirroring the `K <= 15` cap on the profile grid. -const SEQ_MAX_CAT: usize = 50; - -/// Sequential category probabilities from a single reduced class's step (continuation) -/// probabilities `s = [s_1, .., s_M]` (Ma & de la Torre, 2016; Tutz, 1990): -/// `P(X=0) = 1 - s_1`, `P(X=k) = (prod_{v<=k} s_v)(1 - s_{k+1})` for `1 <= k < M`, and -/// `P(X=M) = prod_{v<=M} s_v` (the top category has NO trailing continuation factor — the -/// stop sentinel `s_{M+1} = 0` makes `1 - s_{M+1} = 1`, so it must not be routed through -/// any eps clamp). The `M + 1` probabilities telescope to 1 for any `s in [0, 1]^M`, so -/// the sequential form is a valid multinomial for free (no simplex projection needed). -#[cfg(test)] -pub(crate) fn seq_category_probs(steps: &[f64]) -> Vec { - let m = steps.len(); - let mut probs = vec![0.0f64; m + 1]; - let mut cum = 1.0f64; // prod_{v < k} s_v - for k in 1..=m { - let sk = steps[k - 1]; - probs[k - 1] = cum * (1.0 - sk); // P(X = k-1) = (prod_{v no trailing factor -} - -/// Scatter one weighted response `x` into a reduced class's per-step at-risk (`I`) and -/// advanced (`R`) count cells (`i_cells`/`r_cells` length `M`, contiguous per class). Step -/// `k` is *at risk* when `x >= k-1` and *advanced* when `x >= k`, so `I[k-1] += w` for -/// `k <= x+1` and `R[k-1] += w` for `k <= x`. This is the sequential factorization's -/// Bernoulli bookkeeping: conditional on reaching category `k-1`, advancing to `>= k` is -/// `Bernoulli(s_ik)`, independent across steps, so the saturated MLE is `s_ik = R/I`. -#[inline] -fn seq_scatter_counts(x: usize, w: f64, m: usize, i_cells: &mut [f64], r_cells: &mut [f64]) { - let kmax = m.min(x + 1); // steps with x >= k-1 (at risk); x < k-1 for larger k - for k in 1..=kmax { - i_cells[k - 1] += w; - if k <= x { - r_cells[k - 1] += w; // advanced past step k (x >= k) - } - } -} - -/// Validate polytomous sequential-CDM input and return the per-item maximum observed -/// category `M_i` (the number of ordered steps). Unlike [`validate`], responses are -/// ordered integers `0..=M_i` (not just 0/1); `M_i` is *derived from the data* (max -/// observed category), and an item whose observed max is `< 1` (never leaves category 0) -/// measures nothing and is rejected, mirroring the all-zero-Q-row and unobserved-item -/// rejections. A zero-frequency *interior* category is NOT rejected: in a -/// continuation-ratio model it simply means `s_{i,k+1}(l) ~ 1` and is legitimate. -#[allow(clippy::too_many_arguments)] -fn validate_seq_gdina( - y: &[f64], - observed: &[bool], - q_matrix: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - cfg: &CdmConfig, -) -> Result, String> { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(1..=15).contains(&n_attributes) { - return Err(format!( - "n_attributes must be in 1..=15 (L = 2^K grid + O(N*J*L) cost); got {n_attributes}" - )); - } - if cfg.max_iter == 0 { - return Err("max_iter must be positive".into()); - } - if !cfg.tol.is_finite() || cfg.tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - if !cfg.eps.is_finite() || !(0.0 < cfg.eps && cfg.eps < 0.5) { - return Err("eps must be finite and in (0, 0.5)".into()); - } - if !cfg.init_slip.is_finite() || !(cfg.eps..=1.0 - cfg.eps).contains(&cfg.init_slip) { - return Err("init_slip must be finite and in [eps, 1 - eps]".into()); - } - if !cfg.init_guess.is_finite() || !(cfg.eps..=1.0 - cfg.eps).contains(&cfg.init_guess) { - return Err("init_guess must be finite and in [eps, 1 - eps]".into()); - } - if cfg.init_slip + cfg.init_guess >= 1.0 { - return Err("init_slip + init_guess must be less than 1".into()); - } - if !cfg.count_floor.is_finite() || cfg.count_floor < 0.0 { - return Err("count_floor must be finite and non-negative".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells || observed.len() != n_cells { - return Err("y and observed must have length n_persons * n_items".into()); - } - let n_q = crate::checked_mul_usize(n_items, n_attributes, "Q-matrix size overflows usize")?; - if q_matrix.len() != n_q { - return Err("q_matrix must have length n_items * n_attributes".into()); - } - for (idx, &v) in y.iter().enumerate() { - if observed[idx] && (!v.is_finite() || v < 0.0 || v.fract() != 0.0) { - return Err(format!( - "y[{idx}] must be a non-negative integer category where observed; got {v}" - )); - } - } - for (idx, &v) in q_matrix.iter().enumerate() { - if v != 0 && v != 1 { - return Err(format!("q_matrix[{idx}] must be 0 or 1; got {v}")); - } - } - // Per item: at least one observed response, and a maximum observed category >= 1 - // (an item stuck at category 0 measures nothing). M_i = max observed category. - let mut max_cat = vec![0u32; n_items]; - for i in 0..n_items { - let mut any = false; - let mut mi = 0u32; - for p in 0..n_persons { - let idx = p * n_items + i; - if observed[idx] { - any = true; - mi = mi.max(y[idx] as u32); - } - } - if !any { - return Err(format!("item {i} has no observed responses")); - } - if mi < 1 { - return Err(format!( - "item {i} never leaves category 0 (max observed category 0; measures nothing)" - )); - } - if mi as usize > SEQ_MAX_CAT { - return Err(format!( - "item {i} max category {mi} exceeds SEQ_MAX_CAT = {SEQ_MAX_CAT}" - )); - } - max_cat[i] = mi; - } - for i in 0..n_items { - if !(0..n_attributes).any(|k| q_matrix[i * n_attributes + k] != 0) { - return Err(format!( - "q_matrix row {i} is all-zero (item measures no attribute)" - )); - } - } - for k in 0..n_attributes { - if !(0..n_items).any(|i| q_matrix[i * n_attributes + k] != 0) { - return Err(format!( - "q_matrix column {k} is all-zero (attribute measured by no item)" - )); - } - } - Ok(max_cat) -} - -/// Fit the **shared-Q sequential (continuation-ratio) G-DINA** for ordered polytomous -/// responses (Ma & de la Torre, 2016) by marginal-ML EM over the `2^K` attribute -/// profiles. -/// -/// For item `i` with maximum observed category `M_i`, each ordered *step* -/// `k in 1..=M_i` has a free continuation probability that is a saturated G-DINA over the -/// item's `2^{K_i}` reduced attribute classes: -/// `s_ik(l) = P(X_i >= k | X_i >= k-1, reduced class l)`. The category probabilities are -/// the sequential decomposition `P(X_i = k | l) = (prod_{v<=k} s_iv(l))(1 - s_{i,k+1}(l))` -/// with the stop sentinel `s_{i,M_i+1} = 0`. The population is a free profile distribution -/// `pi_c` (as in [`fit_gdina`]; the higher-order structural prior is the alternative -/// offered by [`fit_ho_cdm`]/[`fit_ho_gdina`]). -/// -/// **Scope (restriction).** This is the *shared item-level Q-vector* sequential G-DINA: -/// every step of item `i` is a saturated G-DINA over the SAME required attributes (Q row -/// `i`), each with its own step-specific probability table. It is a restriction of the -/// general per-step (per-category) `q_ik` model of Ma & de la Torre (2016), whose headline -/// feature is *step-distinct* attribute requirements (e.g. step 1 needs attribute A, step 2 -/// needs A and B). Use [`fit_seq_gdina_qr`] when the steps need distinct Q-vectors. For this -/// shared-Q entry point, supply the item Q-vector as the UNION of every step's required -/// attributes so no step depends on an attribute outside it (any step that truly needs only -/// a subset is still representable — its table is flat in the irrelevant attribute). -/// -/// Estimation reuses the CDM machinery: the closed-form saturated M-step -/// `s_ik(l) = R_ik(l) / I_ik(l)` where `R = expected count reaching category >= k` and -/// `I = expected count reaching >= k-1` in reduced class `l` (the sequential likelihood -/// factorizes into independent per-step Bernoullis on the at-risk set, so this ratio is the -/// exact complete-data MLE — [`fit_gdina`]'s saturated step on continuation counts). An -/// unreached step in a reduced class (`I ~ 0`) keeps its previous value (`count_floor` -/// guard) — that cell is non-identified and inert, since only response patterns that cross -/// the step depend on it. With `M_i = 1` for every item the model is exactly [`fit_gdina`]. -/// -/// `y`/`observed` are row-major `N*J` (`y` holds ordered integer categories `0..=M_i` where -/// observed; `M_i` is derived as the maximum observed category); `q_matrix` is row-major -/// `J*K` (0/1). Missing cells are dropped (MAR). Returns `Err` on malformed input. The -/// nonzero Q-row/Q-column guards are necessary sanity checks, not a certificate of global -/// model identifiability; callers must provide an identified design and inspect `converged`. -/// Convergence uses the absolute observed-data log-likelihood increment and is checked -/// before another M-step, so the trace endpoint and returned parameters agree. The stable -/// termination reason, signed and relative terminal increments, completed M-step count, and -/// requested tolerance are returned explicitly. -/// -/// References (APA 7th ed.): -/// Ma, W., & de la Torre, J. (2016). A sequential cognitive diagnosis model for -/// polytomous responses. *British Journal of Mathematical and Statistical Psychology, -/// 69*(3), 253-275. https://doi.org/10.1111/bmsp.12070 -/// Tutz, G. (1990). Sequential item response models with an ordered response. *British -/// Journal of Mathematical and Statistical Psychology, 43*(1), 39-55. -/// https://doi.org/10.1111/j.2044-8317.1990.tb00925.x -/// de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, 76*(2), -/// 179-199. https://doi.org/10.1007/s11336-011-9207-7 -#[allow(clippy::too_many_arguments)] -pub fn fit_seq_gdina( - y: &[f64], - observed: &[bool], - q_matrix: &[u8], - n_persons: usize, - n_items: usize, - n_attributes: usize, - cfg: &CdmConfig, -) -> Result { - let max_cat = validate_seq_gdina(y, observed, q_matrix, n_persons, n_items, n_attributes, cfg)?; - let l_full = 1usize << n_attributes; - - // Per-item required-attribute bitmask and K_i. - let mut qmask = vec![0usize; n_items]; - let mut k_required = vec![0u32; n_items]; - for i in 0..n_items { - let mut mask = 0usize; - for k in 0..n_attributes { - if q_matrix[i * n_attributes + k] != 0 { - mask |= 1 << k; - } - } - qmask[i] = mask; - k_required[i] = mask.count_ones(); - } - - // Ragged CLASS-MAJOR CSR: item i owns M_i * 2^{K_i} step probs (class l, step k at - // s_off[i] + l*M_i + (k-1)) and (M_i+1) * 2^{K_i} category log-probs. - let mut s_off = vec![0usize; n_items + 1]; - let mut cat_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - let rw = 1usize << k_required[i]; - let m = max_cat[i] as usize; - s_off[i + 1] = s_off[i] + m * rw; - cat_off[i + 1] = cat_off[i] + (m + 1) * rw; - } - let total_steps = s_off[n_items]; - let total_cats = cat_off[n_items]; - - // Reduced-class index of every (item, full-profile) pair. - let mut red = vec![0u16; n_items * l_full]; - for i in 0..n_items { - for c in 0..l_full { - red[i * l_full + c] = reduce_class(c, qmask[i]) as u16; - } - } - - // Monotone init: each step's probability rises with the count of mastered required - // attributes, from init_guess (none) to 1 - init_slip (all). At M_i = 1 this is exactly - // fit_gdina's `p` init, which (with the shared refresh/M-step below) makes M=1 reduce to - // fit_gdina bit-for-bit. - let mut s = vec![0.0f64; total_steps]; - for i in 0..n_items { - let ki = k_required[i] as f64; // >= 1 - let rw = 1usize << k_required[i]; - let m = max_cat[i] as usize; - for l in 0..rw { - let frac = (l.count_ones() as f64) / ki; - let val = cfg.init_guess + (1.0 - cfg.init_slip - cfg.init_guess) * frac; - for k in 0..m { - s[s_off[i] + l * m + k] = val; - } - } - } - let mut pi = vec![1.0 / l_full as f64; l_full]; - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - - let mut post = vec![0.0f64; l_full]; - let mut clp = vec![0.0f64; total_cats]; // category log-probs, class-major - let mut log_pi = vec![0.0f64; l_full]; - - // Fill category log-probs from step probs: clp[cat_off[i] + l*(M+1) + x] = ln P(X=x|l). - // The M_i real step probs are eps-clamped; the stop sentinel is NOT clamped (top - // category = cumulative log-product with no trailing 1 - s_{M+1} factor). - let refresh = |s: &[f64], clp: &mut [f64]| { - for i in 0..n_items { - let rw = 1usize << k_required[i]; - let m = max_cat[i] as usize; - let (so, co) = (s_off[i], cat_off[i]); - for l in 0..rw { - let (sbase, cbase) = (so + l * m, co + l * (m + 1)); - seq_category_logprobs_into( - &s[sbase..sbase + m], - cfg.eps, - &mut clp[cbase..cbase + (m + 1)], - ); - } - } - }; - - for _ in 0..cfg.max_iter { - refresh(&s, &mut clp); - for c in 0..l_full { - log_pi[c] = pi[c].ln(); - } - - let mut i_acc = vec![0.0f64; total_steps]; - let mut r_acc = vec![0.0f64; total_steps]; - let mut pi_new = vec![0.0f64; l_full]; - let mut total_ll = 0.0f64; - for j in 0..n_persons { - // E-step posterior over the 2^K profiles using the category log-probs. - for c in 0..l_full { - let mut acc = log_pi[c]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let m1 = max_cat[i] as usize + 1; - let l = red[i * l_full + c] as usize; - let x = y[idx] as usize; - acc += clp[cat_off[i] + l * m1 + x]; - } - } - post[c] = acc; - } - let mmax = post[..l_full] - .iter() - .cloned() - .fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for c in 0..l_full { - denom += (post[c] - mmax).exp(); - } - for c in 0..l_full { - post[c] = (post[c] - mmax).exp() / denom; - } - total_ll += mmax + denom.ln(); - - for c in 0..l_full { - pi_new[c] += post[c]; - } - // M-step counts: scatter each response into its at-risk/advanced step cells. - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let m = max_cat[i] as usize; - let x = y[idx] as usize; - let so = s_off[i]; - for c in 0..l_full { - let l = red[i * l_full + c] as usize; - let base = so + l * m; - seq_scatter_counts( - x, - post[c], - m, - &mut i_acc[base..base + m], - &mut r_acc[base..base + m], - ); - } - } - } - } - loglik_trace.push(total_ll); - - // Converged-check before the M-step: returned params match the trace endpoint. - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - if (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - - // M-step: saturated per-step closed form s_ik(l) = R/I. Empty at-risk cell keeps - // its previous value (non-identified, inert). Box 0<=s<=1 is free (0<=R<=I). - for x in 0..total_steps { - if i_acc[x] > cfg.count_floor { - s[x] = (r_acc[x] / i_acc[x]).clamp(cfg.eps, 1.0 - cfg.eps); - } - } - let nf = n_persons as f64; - let mut z = 0.0f64; - for c in 0..l_full { - pi[c] = (pi_new[c] / nf).max(cfg.eps); - z += pi[c]; - } - for c in 0..l_full { - pi[c] /= z; - } - n_iter += 1; - } - - // Classification pass + category probabilities from the final step probs. - refresh(&s, &mut clp); - for c in 0..l_full { - log_pi[c] = pi[c].ln(); - } - let mut map_profile = vec![0u32; n_persons]; - let mut attr_prob = vec![0.0f64; n_persons * n_attributes]; - let mut final_ll = 0.0f64; - for j in 0..n_persons { - for c in 0..l_full { - let mut acc = log_pi[c]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let m1 = max_cat[i] as usize + 1; - let l = red[i * l_full + c] as usize; - let x = y[idx] as usize; - acc += clp[cat_off[i] + l * m1 + x]; - } - } - post[c] = acc; - } - let mmax = post[..l_full] - .iter() - .cloned() - .fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for c in 0..l_full { - denom += (post[c] - mmax).exp(); - } - for c in 0..l_full { - post[c] = (post[c] - mmax).exp() / denom; - } - final_ll += mmax + denom.ln(); - let mut best = 0usize; - for c in 1..l_full { - if post[c] > post[best] { - best = c; - } - } - map_profile[j] = best as u32; - for k in 0..n_attributes { - let mut pk = 0.0; - for c in 0..l_full { - if (c >> k) & 1 == 1 { - pk += post[c]; - } - } - attr_prob[j * n_attributes + k] = pk; - } - } - if !converged { - loglik_trace.push(final_ll); - } - - let cat_prob: Vec = clp.iter().map(|v| v.exp()).collect(); - let final_loglik_change = loglik_trace - .windows(2) - .last() - .map(|pair| pair[1] - pair[0]) - .unwrap_or(f64::NAN); - let final_relative_loglik_change = loglik_trace - .windows(2) - .last() - .map(|pair| (pair[1] - pair[0]).abs() / (1.0 + pair[0].abs())) - .unwrap_or(f64::NAN); - let termination_reason = if converged { - "tolerance_met" - } else { - "max_iter_reached" - }; - - Ok(SeqGdinaResult { - s_off, - step_prob: s, - cat_off, - cat_prob, - max_cat, - k_required, - profile_prob: pi, - map_profile, - attr_prob, - loglik_trace, - n_iter, - converged, - termination_reason, - final_loglik_change, - final_relative_loglik_change, - stopping_tolerance: cfg.tol, - n_parameters: total_steps + (l_full - 1), - }) -} - -/// Result of [`fit_seq_gdina_qr`] (Ma & de la Torre, 2016): the PER-STEP-Q sequential -/// (continuation-ratio) G-DINA, where each ordered step has its own attribute requirement -/// `q_ik` (the restricted Q-matrix `Q_r`). Step probabilities are STEP-ROW-major: step row -/// `g = step_off[i] + (k-1)` (item `i`, step `k`) owns `2^{|q_ik|}` reduced classes at -/// `step_prob[spo[g] + l]`, `l` the reduced class of the profile under `q_ik`. Category -/// probabilities are UNION-class-major: item `i`'s union `u_i = OR_k q_ik` has `2^{K^u_i}` -/// classes and `cat_prob[cat_off[i] + uc*(M_i+1) + x] = P(X_i = x | union class uc)`. -#[derive(Clone, Debug)] -pub struct SeqGdinaQrResult { - /// Per-item offsets into the step-row arrays (`spo`, `step_kq`), length `n_items + 1`. - pub step_off: Vec, - /// Per-step-row offsets into `step_prob` (length `sum_i M_i + 1`). - pub spo: Vec, - /// Continuation probabilities `s_ik(l)`, step-row-major (see struct doc). - pub step_prob: Vec, - /// Required-attribute count `|q_ik|` per step row (length `sum_i M_i`). - pub step_kq: Vec, - /// Per-item category-prob block offsets into `cat_prob` (length `n_items + 1`). - pub cat_off: Vec, - /// Implied category probabilities `P(X_i = x | union class uc)`, union-class-major. - pub cat_prob: Vec, - /// Number of ordered steps `M_i` per item. - pub max_cat: Vec, - /// Union required-attribute count `K^u_i = |OR_k q_ik|` per item. - pub union_k: Vec, - /// Free profile distribution `pi_c` (length `2^K`, sums to 1). - pub profile_prob: Vec, - /// Bit-encoded MAP profile per person. - pub map_profile: Vec, - /// Marginal `P(alpha_jk = 1 | X_j)`, row-major `N x K`. - pub attr_prob: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: &'static str, - pub final_loglik_change: f64, - pub final_relative_loglik_change: f64, - pub stopping_tolerance: f64, - /// `sum_{i,k} 2^{|q_ik|}` step probs `+ (2^K - 1)` free profile probs. - pub n_parameters: usize, -} - -/// Validate per-step-Q sequential-CDM input and return `(n_steps as usize vector)`. `step_q` -/// is row-major `(sum_i n_steps[i]) x K` (0/1); `n_steps[i] = M_i` is the declared step count -/// of item `i`. Rejects: shape/overflow, non-0/1 or non-integer data, a step measuring nothing -/// (all-zero `q_ik` row), an attribute measured by NO step of any item (all-zero column over the -/// union), an item with no observed responses, and `M_i != ` the maximum OBSERVED category -/// (a declared step no one reaches, or data beyond the declared steps). -#[allow(clippy::too_many_arguments)] -fn validate_seq_gdina_qr( - y: &[f64], - observed: &[bool], - step_q: &[u8], - n_steps: &[usize], - n_persons: usize, - n_items: usize, - n_attributes: usize, - cfg: &CdmConfig, -) -> Result<(), String> { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(1..=15).contains(&n_attributes) { - return Err(format!( - "n_attributes must be in 1..=15 (L = 2^K grid + O(N*J*L) cost); got {n_attributes}" - )); - } - if cfg.max_iter == 0 { - return Err("max_iter must be positive".into()); - } - if !cfg.tol.is_finite() || cfg.tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - if !cfg.eps.is_finite() || !(0.0 < cfg.eps && cfg.eps < 0.5) { - return Err("eps must be finite and in (0, 0.5)".into()); - } - if !cfg.init_slip.is_finite() || !(cfg.eps..=1.0 - cfg.eps).contains(&cfg.init_slip) { - return Err("init_slip must be finite and in [eps, 1 - eps]".into()); - } - if !cfg.init_guess.is_finite() || !(cfg.eps..=1.0 - cfg.eps).contains(&cfg.init_guess) { - return Err("init_guess must be finite and in [eps, 1 - eps]".into()); - } - if cfg.init_slip + cfg.init_guess >= 1.0 { - return Err("init_slip + init_guess must be less than 1".into()); - } - if !cfg.count_floor.is_finite() || cfg.count_floor < 0.0 { - return Err("count_floor must be finite and non-negative".into()); - } - if n_steps.len() != n_items { - return Err("n_steps must have length n_items".into()); - } - // Total step rows and the step_q length, both via checked arithmetic. - let mut total_step_rows = 0usize; - for (i, &m) in n_steps.iter().enumerate() { - if m < 1 { - return Err(format!( - "item {i} has n_steps < 1 (an item must leave category 0)" - )); - } - if m > SEQ_MAX_CAT { - return Err(format!( - "item {i} n_steps {m} exceeds SEQ_MAX_CAT = {SEQ_MAX_CAT}" - )); - } - total_step_rows = - crate::checked_add_usize(total_step_rows, m, "sum of n_steps overflows usize")?; - } - let n_sq = - crate::checked_mul_usize(total_step_rows, n_attributes, "step-Q size overflows usize")?; - if step_q.len() != n_sq { - return Err("step_q must have length sum(n_steps) * n_attributes".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells || observed.len() != n_cells { - return Err("y and observed must have length n_persons * n_items".into()); - } - for (idx, &v) in y.iter().enumerate() { - if observed[idx] && (!v.is_finite() || v < 0.0 || v.fract() != 0.0) { - return Err(format!( - "y[{idx}] must be a non-negative integer category where observed; got {v}" - )); - } - } - for (idx, &v) in step_q.iter().enumerate() { - if v != 0 && v != 1 { - return Err(format!("step_q[{idx}] must be 0 or 1; got {v}")); - } - } - // Each declared step measures at least one attribute (no all-zero step-q row). - for g in 0..total_step_rows { - if !(0..n_attributes).any(|k| step_q[g * n_attributes + k] != 0) { - return Err(format!( - "step row {g} is all-zero (a step measuring no attribute)" - )); - } - } - // Every attribute is required by at least one step of some item (union column non-empty). - for k in 0..n_attributes { - if !(0..total_step_rows).any(|g| step_q[g * n_attributes + k] != 0) { - return Err(format!( - "attribute {k} is required by no step (all-zero column; non-identified)" - )); - } - } - // Per item: at least one observed response, and the maximum observed category equals the - // declared step count M_i (so every declared step has a globally non-empty at-risk set and - // no observed category exceeds the declared steps). - let mut step_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - step_off[i + 1] = step_off[i] + n_steps[i]; - } - for i in 0..n_items { - let mut any = false; - let mut mi = 0u32; - for p in 0..n_persons { - let idx = p * n_items + i; - if observed[idx] { - any = true; - mi = mi.max(y[idx] as u32); - } - } - if !any { - return Err(format!("item {i} has no observed responses")); - } - if mi as usize != n_steps[i] { - return Err(format!( - "item {i}: max observed category {mi} != declared n_steps {} (a declared step is \ - unreached, or data exceeds the declared steps)", - n_steps[i] - )); - } - } - Ok(()) -} - -/// Fit the **per-step-Q sequential (continuation-ratio) G-DINA** for ordered polytomous -/// responses (Ma & de la Torre, 2016), the full restricted-Q model in which each ordered STEP -/// `k` of item `i` is a saturated G-DINA over its OWN required attributes `q_ik` (step 1 may -/// need attribute A, step 2 need A and B, etc.). -/// -/// Generalizes the shared-Q [`fit_seq_gdina`]: when every step of an item shares the item's -/// Q-vector this reduces to it exactly. Step `k`'s continuation probability -/// `s_ik(l) = P(X_i >= k | X_i >= k-1, reduced class of alpha under q_ik)` is free per step -/// reduced class; the category probability is the unchanged sequential product -/// `P(X_i = x | alpha) = (prod_{v<=x} s_iv)(1 - s_{i,x+1})`. Estimated by marginal-ML EM with -/// the closed-form saturated step ratio `s_ik(l) = R/I` (reached >= k over reached >= k-1 in -/// step `k`'s reduced class `l`) — the sequential likelihood still factorizes into independent -/// per-step Bernoullis, so this is the exact complete-data MLE. Free profile distribution `pi_c`. -/// -/// Each step's reduced class is computed DIRECTLY from the full profile (`reduce_class(c, -/// q_ik)`); the item's UNION class `reduce_class(c, OR_k q_ik)` indexes the category -/// probabilities. `y`/`observed` are row-major `N*J` (ordered integer categories `0..=M_i`); -/// `step_q` is row-major `(sum_i n_steps[i]) * K` (0/1), step `k` of item `i` at row -/// `step_off[i] + (k-1)`; `n_steps[i] = M_i` (the number of steps, which must equal item `i`'s -/// maximum observed category). Missing cells are dropped (MAR). Nonzero step-Q rows and -/// columns are necessary sanity checks, not a certificate of global model identifiability; -/// callers must provide an identified design and inspect `converged`. -/// -/// References (APA 7th ed.): -/// Ma, W., & de la Torre, J. (2016). A sequential cognitive diagnosis model for polytomous -/// responses. *British Journal of Mathematical and Statistical Psychology, 69*(3), -/// 253-275. https://doi.org/10.1111/bmsp.12070 -/// de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, 76*(2), -/// 179-199. https://doi.org/10.1007/s11336-011-9207-7 -#[allow(clippy::too_many_arguments)] -pub fn fit_seq_gdina_qr( - y: &[f64], - observed: &[bool], - step_q: &[u8], - n_steps: &[usize], - n_persons: usize, - n_items: usize, - n_attributes: usize, - cfg: &CdmConfig, -) -> Result { - validate_seq_gdina_qr( - y, - observed, - step_q, - n_steps, - n_persons, - n_items, - n_attributes, - cfg, - )?; - let l_full = 1usize << n_attributes; - - // Per-item offsets into the step-row arrays; total step rows = sum_i M_i. - let mut step_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - step_off[i + 1] = step_off[i] + n_steps[i]; - } - let n_rows = step_off[n_items]; - - // Per step row: its own attribute mask, |q_ik|, reduced-class width, and step_prob offset. - let mut step_qmask = vec![0usize; n_rows]; - let mut step_kq = vec![0u32; n_rows]; - let mut spo = vec![0usize; n_rows + 1]; - for g in 0..n_rows { - let mut mask = 0usize; - for k in 0..n_attributes { - if step_q[g * n_attributes + k] != 0 { - mask |= 1 << k; - } - } - step_qmask[g] = mask; - step_kq[g] = mask.count_ones(); - spo[g + 1] = spo[g] + (1usize << step_kq[g]); - } - let total_steps = spo[n_rows]; - - // Each step's reduced class as a function of the FULL profile (mirror shared-Q's `red`; - // this avoids the union-renumber / bit-gather hazard entirely). - let mut step_red = vec![0u16; n_rows * l_full]; - for g in 0..n_rows { - for c in 0..l_full { - step_red[g * l_full + c] = reduce_class(c, step_qmask[g]) as u16; - } - } - - // Per item: union mask u_i, union width, union reduced class of each profile, category - // offsets (union-class-major: (M_i+1) * 2^{K^u_i}). - let mut union_k = vec![0u32; n_items]; - let mut union_rw = vec![0usize; n_items]; - let mut red_u = vec![0u16; n_items * l_full]; - let mut cat_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - let mut u = 0usize; - for g in step_off[i]..step_off[i + 1] { - u |= step_qmask[g]; - } - union_k[i] = u.count_ones(); - union_rw[i] = 1usize << union_k[i]; - for c in 0..l_full { - red_u[i * l_full + c] = reduce_class(c, u) as u16; - } - cat_off[i + 1] = cat_off[i] + (n_steps[i] + 1) * union_rw[i]; - } - let total_cats = cat_off[n_items]; - - // Monotone init per step row, using the STEP's own |q_ik| in the denominator so the - // shared-Q case reproduces fit_seq_gdina's init exactly. - let mut s = vec![0.0f64; total_steps]; - for g in 0..n_rows { - let kq = step_kq[g] as f64; // >= 1 (validate rejects all-zero step rows) - let rw = 1usize << step_kq[g]; - for l in 0..rw { - let frac = (l.count_ones() as f64) / kq; - s[spo[g] + l] = cfg.init_guess + (1.0 - cfg.init_slip - cfg.init_guess) * frac; - } - } - let mut pi = vec![1.0 / l_full as f64; l_full]; - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - - let mut post = vec![0.0f64; l_full]; - let mut clp = vec![0.0f64; total_cats]; // union-class category log-probs - let mut log_pi = vec![0.0f64; l_full]; - let mut sbuf = vec![0.0f64; *n_steps.iter().max().unwrap_or(&1)]; // per-item step gather - - // Fill union-class category log-probs by walking the full profile grid: each step's own - // reduced class comes from step_red[g][c]; multiple profiles map to the same union class and - // (because q_ik subset of u_i) give the same gathered step vector, so the writes agree. - let refresh = |s: &[f64], clp: &mut [f64], sbuf: &mut [f64]| { - for i in 0..n_items { - let m = n_steps[i]; - let m1 = m + 1; - let co = cat_off[i]; - for c in 0..l_full { - let uc = red_u[i * l_full + c] as usize; - debug_assert!(uc < union_rw[i], "union class (clp) within bound"); - for v in 0..m { - let g = step_off[i] + v; - let l_v = step_red[g * l_full + c] as usize; - debug_assert!( - l_v < (1usize << step_kq[g]), - "step class (step_prob) within bound" - ); - sbuf[v] = s[spo[g] + l_v]; - } - seq_category_logprobs_into( - &sbuf[..m], - cfg.eps, - &mut clp[co + uc * m1..co + uc * m1 + m1], - ); - } - } - }; - - for _ in 0..cfg.max_iter { - refresh(&s, &mut clp, &mut sbuf); - for c in 0..l_full { - log_pi[c] = pi[c].ln(); - } - - let mut i_acc = vec![0.0f64; total_steps]; - let mut r_acc = vec![0.0f64; total_steps]; - let mut pi_new = vec![0.0f64; l_full]; - let mut total_ll = 0.0f64; - for j in 0..n_persons { - // E-step posterior over the 2^K profiles via the union-class category log-probs. - for c in 0..l_full { - let mut acc = log_pi[c]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let m1 = n_steps[i] + 1; - let uc = red_u[i * l_full + c] as usize; - let x = y[idx] as usize; - acc += clp[cat_off[i] + uc * m1 + x]; - } - } - post[c] = acc; - } - let mmax = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - mmax).exp(); - } - total_ll += mmax + denom.ln(); - for v in post.iter_mut() { - *v = (*v - mmax).exp() / denom; - } - for c in 0..l_full { - pi_new[c] += post[c]; - } - // M-step counts: scatter PER STEP into its own (step_row, step-class) cell. - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let x = y[idx] as usize; - let m = n_steps[i]; - for c in 0..l_full { - let pc = post[c]; - for v in 1..=m { - let g = step_off[i] + (v - 1); - let l_v = step_red[g * l_full + c] as usize; - let cell = spo[g] + l_v; - if x >= v - 1 { - i_acc[cell] += pc; // at risk for step v - if x >= v { - r_acc[cell] += pc; // advanced past step v - } - } - } - } - } - } - } - loglik_trace.push(total_ll); - - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - if (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - - for x in 0..total_steps { - if i_acc[x] > cfg.count_floor { - s[x] = (r_acc[x] / i_acc[x]).clamp(cfg.eps, 1.0 - cfg.eps); - } - } - let nf = n_persons as f64; - let mut z = 0.0f64; - for c in 0..l_full { - pi[c] = (pi_new[c] / nf).max(cfg.eps); - z += pi[c]; - } - for c in 0..l_full { - pi[c] /= z; - } - n_iter += 1; - } - - // Classification pass + category probabilities from the final step probs. - refresh(&s, &mut clp, &mut sbuf); - for c in 0..l_full { - log_pi[c] = pi[c].ln(); - } - let mut map_profile = vec![0u32; n_persons]; - let mut attr_prob = vec![0.0f64; n_persons * n_attributes]; - let mut final_ll = 0.0f64; - for j in 0..n_persons { - for c in 0..l_full { - let mut acc = log_pi[c]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let m1 = n_steps[i] + 1; - let uc = red_u[i * l_full + c] as usize; - let x = y[idx] as usize; - acc += clp[cat_off[i] + uc * m1 + x]; - } - } - post[c] = acc; - } - let mmax = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - mmax).exp(); - } - for v in post.iter_mut() { - *v = (*v - mmax).exp() / denom; - } - final_ll += mmax + denom.ln(); - let mut best = 0usize; - for c in 1..l_full { - if post[c] > post[best] { - best = c; - } - } - map_profile[j] = best as u32; - for k in 0..n_attributes { - let mut pk = 0.0; - for c in 0..l_full { - if (c >> k) & 1 == 1 { - pk += post[c]; - } - } - attr_prob[j * n_attributes + k] = pk; - } - } - if !converged { - loglik_trace.push(final_ll); - } - - let cat_prob: Vec = clp.iter().map(|v| v.exp()).collect(); - let max_cat: Vec = n_steps.iter().map(|&m| m as u32).collect(); - let final_loglik_change = loglik_trace - .windows(2) - .last() - .map(|pair| pair[1] - pair[0]) - .unwrap_or(f64::NAN); - let final_relative_loglik_change = loglik_trace - .windows(2) - .last() - .map(|pair| (pair[1] - pair[0]).abs() / (1.0 + pair[0].abs())) - .unwrap_or(f64::NAN); - let termination_reason = if converged { - "tolerance_met" - } else { - "max_iter_reached" - }; - - Ok(SeqGdinaQrResult { - step_off, - spo, - step_prob: s, - step_kq, - cat_off, - cat_prob, - max_cat, - union_k, - profile_prob: pi, - map_profile, - attr_prob, - loglik_trace, - n_iter, - converged, - termination_reason, - final_loglik_change, - final_relative_loglik_change, - stopping_tolerance: cfg.tol, - n_parameters: total_steps + (l_full - 1), - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/cdm_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/classification.rs b/crates/mlsirm-core/src/classification.rs deleted file mode 100644 index f53431a83..000000000 --- a/crates/mlsirm-core/src/classification.rs +++ /dev/null @@ -1,727 +0,0 @@ -//! IRT classification accuracy and consistency for cut-score decisions. -//! -//! Two estimators of how reliably a test classifies respondents against one -//! or more cut scores: -//! -//! - **Rudner's normal-approximation method** ([`rudner_classification`]): -//! the observed score for a respondent with true ability `theta` is taken -//! as `N(theta, se(theta)^2)`; classification accuracy is the normal mass -//! of the interval containing `theta`, and consistency is the sum of -//! squared interval masses (the probability that two independent parallel -//! administrations land in the same category). -//! - **Lee's summed-score method** ([`lee_classification`]): the exact -//! summed-score distribution `f(x | theta)` from the Lord-Wingersky (1984) -//! recursion ([`crate::scoring::lord_wingersky`]) replaces the normal -//! approximation; categories are raw-score ranges `ceil(c_k) .. -//! ceil(c_{k+1}) - 1` and the true category is the one whose interval -//! contains the expected true score `sum_j P_j(theta)`. -//! -//! # Verified sources -//! -//! - Rudner (2001, READ in full): accuracy for a single cut — -//! `P(observed > c | theta)` is the normal tail beyond `(c - theta) / -//! se(theta)` with `se(theta) = 1 / sqrt(I(theta))` (eqs. 1-4), aggregated -//! over the ability distribution (eqs. 5-8). -//! - Rudner (2005, READ in full): the K-category generalization — -//! `P(obs in [a, b] | theta) = Phi((b - theta)/se) - Phi((a - theta)/se)` -//! (eq. 1), summed over true-score intervals. -//! - CRAN `cacIRT` 1.4 R sources (Lathrop, READ line by line: `Rud.P.R`, -//! `Rud.D.R`, `Lee.P.R`, `Lee.D.R`, `recursive.raw.R`, `class.Rud.R`, -//! `class.Lee.R`): the oracle for the CONSISTENCY formulas (sum of squared -//! category masses), the per-cut vs simultaneous split, the person-level -//! (unweighted mean) vs distribution-level (quadrature-weighted mean) -//! aggregation, and the Lee raw-score mechanics (`bang = ceiling(cuts)`, -//! category k = raw scores `bang_k .. bang_{k+1} - 1`). -//! - Neither Rudner paper contains a classification-consistency formula; the -//! consistency implementation follows the cacIRT source, and the concept is -//! attributed to Lee (2010) as cited in Lathrop's package (Lee 2010 itself -//! NOT read — paywalled). -//! -//! # Divergences from the cacIRT oracle (deliberate, tested) -//! -//! 1. Category intervals are LEFT-CLOSED everywhere (`theta == cut` or -//! `expected true score == cut` classifies into the upper category), -//! matching `Rud.P`/`Rud.D`/`Lee.P` (R `cut(..., right = FALSE)`). -//! cacIRT's `Lee.D` alone uses right-closed intervals; the two differ only -//! when a value lands exactly on a cut. -//! 2. `P == 1` item probabilities break cacIRT's `recursive.raw` (its -//! `Z = P/Q` hazard divides by zero); we reject `P` outside the open -//! interval `(0, 1)` at the trust boundary instead of silently clamping -//! (`gen.rec.raw` clamps to `[1e-4, 1 - 1e-4]`). Rejecting `P == 0` is a -//! stricter policy than the oracle (which tolerates it), not parity. -//! 3. cacIRT emits the "Simultaneous" outputs only for two or more cuts; we -//! always populate them (for one cut they coincide with the per-cut -//! values — an identity anchored by multi-cut test fixtures). -//! 4. Lee raw cuts are restricted to `(0, n_items]` after `ceil`-mapping to -//! strictly increasing integer boundaries; cuts inside -//! `(n_items, n_items + 1)` would produce an empty top category (a -//! reversed slice in the R oracle) and are rejected. -//! -//! The normal CDF uses this crate's `erfc` rational approximation -//! (|error| < 1.2e-7); Rudner outputs inherit that accuracy. Lee outputs -//! involve no normal CDF and are exact to f64 rounding. -//! -//! # References -//! -//! Lathrop, Q. N. (2015). *cacIRT: Classification accuracy and consistency -//! under item response theory* (Version 1.4) \[R package\]. CRAN. -//! -//! -//! Lee, W.-C. (2010). Classification consistency and accuracy for complex -//! assessments using item response theory. *Journal of Educational -//! Measurement, 47*(1), 1-17. (As cited in Lathrop, 2015; not read.) -//! -//! Lord, F. M., & Wingersky, M. S. (1984). Comparison of IRT true-score and -//! equipercentile observed-score "equatings". *Applied Psychological -//! Measurement, 8*(4), 453-461. https://doi.org/10.1177/014662168400800409 -//! -//! Rudner, L. M. (2001). Computing the expected proportions of misclassified -//! examinees. *Practical Assessment, Research & Evaluation, 7*(14). -//! https://doi.org/10.7275/an9m-2035 -//! -//! Rudner, L. M. (2005). Expected classification accuracy. *Practical -//! Assessment, Research & Evaluation, 10*(13). -//! https://doi.org/10.7275/56a5-6b14 - -use crate::fitstats::erfc; -use crate::scoring::lord_wingersky; - -/// Classification accuracy/consistency summary shared by both methods. -/// -/// `m` cuts and `n` evaluation points (persons or quadrature nodes). -/// Marginal values are means over points weighted by the normalized input -/// weights; conditional values are per point. -#[derive(Clone, Debug)] -pub struct ClassificationResult { - /// Marginal accuracy per cut (`m`), each cut treated as its own - /// two-category problem. - pub per_cut_accuracy: Vec, - /// Marginal consistency per cut (`m`). - pub per_cut_consistency: Vec, - /// Marginal accuracy of the full `m + 1`-category classification. - pub simultaneous_accuracy: f64, - /// Marginal consistency of the full classification. - pub simultaneous_consistency: f64, - /// Row-major `m x n` conditional accuracy. - pub conditional_accuracy: Vec, - /// Row-major `m x n` conditional consistency. - pub conditional_consistency: Vec, - /// Per-point simultaneous accuracy (`n`). - pub conditional_simultaneous_accuracy: Vec, - /// Per-point simultaneous consistency (`n`). - pub conditional_simultaneous_consistency: Vec, -} - -/// Standard normal CDF via the crate's `erfc` approximation. -fn phi(z: f64) -> f64 { - 0.5 * erfc(-z / std::f64::consts::SQRT_2) -} - -fn validate_weights(weights: &[f64], n: usize) -> Result, String> { - if weights.len() != n { - return Err("weights length must match the number of evaluation points".into()); - } - let mut total = 0.0; - for &w in weights { - if !w.is_finite() || w < 0.0 { - return Err("weights must be finite and non-negative".into()); - } - total += w; - } - if !total.is_finite() { - return Err("weights sum overflows f64".into()); - } - if total <= 0.0 { - return Err("weights must not all be zero".into()); - } - Ok(weights.iter().map(|&w| w / total).collect()) -} - -fn validate_cuts(cutscores: &[f64]) -> Result<(), String> { - if cutscores.is_empty() { - return Err("at least one cutscore is required".into()); - } - for &c in cutscores { - if !c.is_finite() { - return Err("cutscores must be finite".into()); - } - } - if cutscores.windows(2).any(|w| w[0] >= w[1]) { - return Err("cutscores must be strictly increasing".into()); - } - Ok(()) -} - -/// Left-closed category index: number of cuts `c` with `value >= c`. -fn category(value: f64, cutscores: &[f64]) -> usize { - cutscores.iter().filter(|&&c| value >= c).count() -} - -/// Aggregate per-point conditional values into a [`ClassificationResult`]. -/// -/// `masses(i, cuts)` returns the `cuts.len() + 1` category probability masses -/// at point `i`; `true_value(i)` is the value classified against the cuts. -fn assemble( - n: usize, - cutscores: &[f64], - wn: &[f64], - masses: impl Fn(usize, &[f64]) -> Vec, - true_value: impl Fn(usize) -> f64, -) -> ClassificationResult { - let m = cutscores.len(); - let mut ca = vec![0.0; m * n]; - let mut cc = vec![0.0; m * n]; - let mut csa = vec![0.0; n]; - let mut csc = vec![0.0; n]; - for i in 0..n { - let v = true_value(i); - for (j, &c) in cutscores.iter().enumerate() { - let ms = masses(i, std::slice::from_ref(&c)); - let k = category(v, std::slice::from_ref(&c)); - ca[j * n + i] = ms[k]; - cc[j * n + i] = ms.iter().map(|p| p * p).sum(); - } - let ms = masses(i, cutscores); - let k = category(v, cutscores); - csa[i] = ms[k]; - csc[i] = ms.iter().map(|p| p * p).sum(); - } - let wmean = |row: &[f64]| row.iter().zip(wn).map(|(x, w)| x * w).sum::(); - ClassificationResult { - per_cut_accuracy: (0..m).map(|j| wmean(&ca[j * n..(j + 1) * n])).collect(), - per_cut_consistency: (0..m).map(|j| wmean(&cc[j * n..(j + 1) * n])).collect(), - simultaneous_accuracy: wmean(&csa), - simultaneous_consistency: wmean(&csc), - conditional_accuracy: ca, - conditional_consistency: cc, - conditional_simultaneous_accuracy: csa, - conditional_simultaneous_consistency: csc, - } -} - -/// Rudner normal-approximation classification accuracy and consistency. -/// -/// `theta[i]` are true abilities (persons or quadrature nodes), `sem[i] > 0` -/// the conditional standard errors of measurement, `weights[i] >= 0` the -/// aggregation weights (uniform weights reproduce cacIRT's person-level -/// `Rud.P` `rowMeans`; quadrature weights reproduce the distribution-level -/// `Rud.D` `weighted.mean` — normalization is internal, so unnormalized -/// weights are accepted). `cutscores` must be finite and strictly increasing. -/// -/// Accuracy per Rudner (2001, eqs. 1-3; 2005, eq. 1); consistency per the -/// cacIRT source (see module docs). -pub fn rudner_classification( - theta: &[f64], - sem: &[f64], - weights: &[f64], - cutscores: &[f64], -) -> Result { - let n = theta.len(); - if n == 0 { - return Err("at least one evaluation point is required".into()); - } - if sem.len() != n { - return Err("theta and sem lengths differ".into()); - } - if theta.iter().any(|v| !v.is_finite()) { - return Err("theta must be finite".into()); - } - if sem.iter().any(|v| !v.is_finite() || *v <= 0.0) { - return Err("sem must be finite and positive".into()); - } - validate_cuts(cutscores)?; - let wn = validate_weights(weights, n)?; - - let masses = |i: usize, cuts: &[f64]| -> Vec { - let (t, s) = (theta[i], sem[i]); - let mut out = Vec::with_capacity(cuts.len() + 1); - let mut lower = 0.0; // Phi(-inf) - for &c in cuts { - let upper = phi((c - t) / s); - out.push(upper - lower); - lower = upper; - } - out.push(1.0 - lower); - out - }; - Ok(assemble(n, cutscores, &wn, masses, |i| theta[i])) -} - -/// Lee summed-score classification accuracy and consistency (dichotomous). -/// -/// `probs` is row-major `n_points x n_items` with `P(X_j = 1 | theta_i)` -/// strictly inside `(0, 1)` — model-agnostic: any binary-response IRF may -/// produce it. `weights` aggregate points as in [`rudner_classification`]. -/// `cutscores` are raw-score cuts in `(0, n_items]`; a summed score `x` -/// falls above cut `c` iff `x >= ceil(c)`, and the `ceil`-mapped boundaries -/// must be strictly increasing. The point's true category is the raw-score -/// interval containing its expected true score `sum_j P_ij` (left-closed). -/// -/// The summed-score distribution comes from -/// [`crate::scoring::lord_wingersky`]; mechanics follow cacIRT's -/// `Lee.P`/`Lee.D` (see module docs for the divergences). -pub fn lee_classification( - probs: &[f64], - n_points: usize, - n_items: usize, - weights: &[f64], - cutscores: &[f64], -) -> Result { - if n_points == 0 || n_items == 0 { - return Err("at least one evaluation point and one item are required".into()); - } - let cells = n_points - .checked_mul(n_items) - .ok_or_else(|| "n_points * n_items overflows".to_string())?; - if probs.len() != cells { - return Err("probs length must equal n_points * n_items".into()); - } - n_points - .checked_mul(n_items + 1) - .ok_or_else(|| "summed-score table size overflows".to_string())?; - if probs - .iter() - .any(|p| !p.is_finite() || *p <= 0.0 || *p >= 1.0) - { - return Err("probs must lie strictly inside (0, 1)".into()); - } - validate_cuts(cutscores)?; - for &c in cutscores { - if c <= 0.0 || c > n_items as f64 { - return Err("raw cutscores must lie in (0, n_items]".into()); - } - } - let bang: Vec = cutscores.iter().map(|&c| c.ceil() as usize).collect(); - if bang.windows(2).any(|w| w[0] >= w[1]) { - return Err("ceil-mapped raw cutscores must be strictly increasing".into()); - } - let wn = validate_weights(weights, n_points)?; - - // f(x | theta_i): lord_wingersky treats each point as one "node"; feed it - // per point so the table stays (n_items + 1) x 1. - let sc = n_items + 1; - let mut dist = vec![0.0; n_points * sc]; - let mut ts = vec![0.0; n_points]; - for i in 0..n_points { - let row = &probs[i * n_items..(i + 1) * n_items]; - ts[i] = row.iter().sum(); - let f = lord_wingersky(row, n_items, 1); - dist[i * sc..(i + 1) * sc].copy_from_slice(&f); - } - - let masses = |i: usize, cuts: &[f64]| -> Vec { - let f = &dist[i * sc..(i + 1) * sc]; - let mut bounds = Vec::with_capacity(cuts.len() + 2); - bounds.push(0usize); - bounds.extend(cuts.iter().map(|&c| c.ceil() as usize)); - bounds.push(sc); - bounds - .windows(2) - .map(|w| f[w[0]..w[1]].iter().sum()) - .collect() - }; - Ok(assemble(n_points, cutscores, &wn, masses, |i| ts[i])) -} - -// ===================== Livingston & Lewis (1995) ===================== - -/// Livingston-Lewis classification consistency and accuracy summary. -/// -/// Orientation: "pass" means observed score `>= cut`. Accuracy cells are -/// joint proportions over (true state, observed state) and sum to 1; -/// consistency cells are joint proportions over two hypothetical independent -/// administrations, normalized to sum to 1 (`i` = fail, `j` = pass). -#[derive(Clone, Debug)] -pub struct LivingstonLewisResult { - /// Unrounded effective test length (Livingston & Lewis, 1995, as - /// implemented in betafunctions' `ETL`). - pub effective_test_length: f64, - /// `round-ties-even(effective_test_length)`, the `N` used in all - /// binomial integrals (mirrors R `round()`). - pub etl_rounded: u64, - /// Lower location of the fitted beta true-score distribution. - pub lower: f64, - /// Upper location of the fitted beta true-score distribution. - pub upper: f64, - /// First shape parameter of the fitted beta true-score distribution. - pub alpha: f64, - /// Second shape parameter of the fitted beta true-score distribution. - pub beta: f64, - /// True iff the two-parameter fail-safe replaced the four-parameter fit. - pub used_two_parameter: bool, - /// P(true pass, observed pass). - pub p_tp: f64, - /// P(true fail, observed pass) — false positive. - pub p_fp: f64, - /// P(true fail, observed fail). - pub p_tf: f64, - /// P(true pass, observed fail) — false negative. - pub p_ff: f64, - /// `p_tp + p_tf`. - pub accuracy: f64, - /// `p_tp / (p_tp + p_ff)` — P(observed pass | true pass); `NaN` when - /// the true-pass margin vanishes (cut outside the fitted support). - pub sensitivity: f64, - /// `p_tf / (p_tf + p_fp)` — P(observed fail | true fail); `NaN` when - /// the true-fail margin vanishes. - pub specificity: f64, - /// Consistency cell: fail on both administrations. - pub p_ii: f64, - /// Consistency cell: fail then pass (equals `p_ji` by construction here). - pub p_ij: f64, - /// Consistency cell: pass then fail. - pub p_ji: f64, - /// Consistency cell: pass on both administrations. - pub p_jj: f64, - /// `p_ii + p_jj` — expected agreement between two administrations. - pub consistency: f64, - /// Chance agreement `(p_ii+p_ij)(p_ii+p_ji) + (p_ij+p_jj)(p_ji+p_jj)`. - pub chance_consistency: f64, - /// Cohen's kappa `(p - p_c) / (1 - p_c)`; `NaN` when `p_c == 1`. - pub kappa: f64, -} - -/// Gauss-Legendre nodes/weights on `[-1, 1]` by Newton iteration on the -/// Legendre polynomial recurrence (Press et al., 2007, sec. 4.6 `gauleg`; -/// transcribed from the textbook algorithm and verified in the test suite -/// against the exact 2-node rule and polynomial-exactness identities). -fn gauss_legendre(n: usize) -> (Vec, Vec) { - let mut x = vec![0.0; n]; - let mut w = vec![0.0; n]; - let m = n.div_ceil(2); - for i in 0..m { - // Initial guess: Chebyshev approximation to the i-th root. - let mut z = (std::f64::consts::PI * (i as f64 + 0.75) / (n as f64 + 0.5)).cos(); - let mut pp = 0.0; - for _ in 0..100 { - let mut p1 = 1.0; - let mut p2 = 0.0; - for j in 0..n { - let p3 = p2; - p2 = p1; - let jf = j as f64; - p1 = ((2.0 * jf + 1.0) * z * p2 - jf * p3) / (jf + 1.0); - } - pp = n as f64 * (z * p1 - p2) / (z * z - 1.0); - let z1 = z; - z = z1 - p1 / pp; - if (z - z1).abs() < 1e-15 { - break; - } - } - x[i] = -z; - x[n - 1 - i] = z; - w[i] = 2.0 / ((1.0 - z * z) * pp * pp); - w[n - 1 - i] = w[i]; - } - (x, w) -} - -/// `integral_{t0}^{t1} t^(a-1) (1-t)^(b-1) g(t) dt / B(a, b)`. Endpoint -/// singularities (shape < 1) are absorbed exactly by power substitutions -/// (spec rev 2): on `t <= 1/2` with `a < 1` substitute `v = t^a` so -/// `t^(a-1) dt = dv / a`; on `t >= 1/2` with `b < 1` symmetrically -/// `w = (1-t)^b`. For shape >= 1 the integrand is bounded and integrated -/// directly (substituting there would introduce a `v^(1/a)` derivative kink -/// at 0 and LOSE accuracy — measured 2e-5 mass error on a smooth -/// alpha ~ 8.5 case). Each piece: 64-node Gauss-Legendre over 8 -/// subintervals, geometrically graded toward the splitting endpoint on the -/// direct path to handle the unbounded `t^(a-1)` derivative for -/// 1 < shape < 2. -fn beta_weighted_integral(a: f64, b: f64, t0: f64, t1: f64, g: impl Fn(f64) -> f64) -> f64 { - let ln_b = crate::fitstats::ln_gamma(a) + crate::fitstats::ln_gamma(b) - - crate::fitstats::ln_gamma(a + b); - let (nodes, weights) = gauss_legendre(64); - let gl = |lo: f64, hi: f64, f: &dyn Fn(f64) -> f64| -> f64 { - let c = 0.5 * (lo + hi); - let h = 0.5 * (hi - lo); - nodes - .iter() - .zip(&weights) - .map(|(z, wt)| wt * f(c + h * z)) - .sum::() - * h - }; - // Uniform 8-subinterval composite. - let composite = |lo: f64, hi: f64, f: &dyn Fn(f64) -> f64| -> f64 { - if hi <= lo { - return 0.0; - } - let n_sub = 8; - let h = (hi - lo) / n_sub as f64; - (0..n_sub) - .map(|s| gl(lo + s as f64 * h, lo + (s + 1) as f64 * h, f)) - .sum() - }; - // Composite geometrically graded toward `lo` (ratio 4 per level). - let graded = |lo: f64, hi: f64, f: &dyn Fn(f64) -> f64| -> f64 { - if hi <= lo { - return 0.0; - } - let mut total = 0.0; - let mut right = hi; - let len = hi - lo; - for lev in 1..8 { - let left = lo + len * 4.0_f64.powi(-lev); - total += gl(left, right, f); - right = left; - } - total + gl(lo, right, f) - }; - let mut total = 0.0; - // Left piece: t in [t0, min(t1, 1/2)]. - let tl = t1.min(0.5); - if t0 < tl { - if a < 1.0 { - let f = |v: f64| { - let t = v.powf(1.0 / a); - (1.0 - t).powf(b - 1.0) * g(t) / a - }; - total += composite(t0.powf(a), tl.powf(a), &f); - } else { - let f = |t: f64| t.powf(a - 1.0) * (1.0 - t).powf(b - 1.0) * g(t); - total += graded(t0, tl, &f); - } - } - // Right piece: t in [max(t0, 1/2), t1]. - let tr = t0.max(0.5); - if tr < t1 { - if b < 1.0 { - let f = |wv: f64| { - let t = 1.0 - wv.powf(1.0 / b); - t.powf(a - 1.0) * g(t) / b - }; - total += composite((1.0 - t1).powf(b), (1.0 - tr).powf(b), &f); - } else { - let f = |t: f64| t.powf(a - 1.0) * (1.0 - t).powf(b - 1.0) * g(t); - // graded toward t1 (the potentially singular-derivative end): - // mirror through u = t0 + t1 - t is unnecessary — grade by - // integrating the reflected function. - let fr = |u: f64| f(tr + t1 - u); - total += graded(tr, t1, &fr); - } - } - total / ln_b.exp() -} - -/// Falling factorial `a (a-1) ... (a-r+1)` with the oracle's clamp: for the -/// data path the caller zeroes values below `r` (betafunctions `dfac`). -fn falling_factorial(a: f64, r: u32) -> f64 { - (0..r).map(|j| a - j as f64).product() -} - -/// Livingston-Lewis (1995) classification accuracy and consistency for one -/// cut score, from observed scores and a reliability estimate. -/// -/// Pipeline (spec `ll_spec.md` rev 2; each step verified line-by-line against -/// the CRAN betafunctions 1.9.0 sources, the only obtainable oracle — the -/// 1995 paper itself is paywalled and was NOT read): -/// -/// 1. Effective test length `ETL = ((m-min)(max-m) - r s^2) / (s^2 (1-r))` -/// with sample variance (`ddof = n-1`); integrals use -/// `N = round_ties_even(ETL)`, moment estimation uses the unrounded ETL -/// (matching `LL.CA`'s call order). -/// 2. True-score raw moments by the binomial factorial-moment identity -/// (`HB.tsm` with Lord's k = 0): `m_i = mean(ff(x', i)) / ff(ETL, i)` on -/// `x' = (x-min)/(max-min) * ETL`, `ff(x', i) := 0` when `x' < i`. -/// 3. Four-parameter beta moment fit (`Beta.4p.fit`); fail-safe to the -/// standard two-parameter fit (`AMS`/`BMS` at `l=0, u=1`) when the 4P -/// solution has `l < 0`, `u > 1`, or is numerically invalid (the last is -/// a documented divergence: the oracle only checks the location bounds). -/// 4. Accuracy cells `integral f(p) * BinTail(p)` split at the rescaled cut; -/// consistency cells `integral f(p) * Tail * Tail` (Hanson-style, per the -/// oracle's own docs; Hanson 1991 NOT read). Passing threshold -/// `k = round_ties_even(N c)` is used in BOTH blocks — a documented -/// divergence from the oracle, which mixes `round` (accuracy) and `floor` -/// (consistency) and is therefore asymmetric in `p_ij`/`p_ji`; under the -/// single threshold `p_ij == p_ji` by construction. -/// -/// Orientation: pass = observed `>= cut`; the oracle's `caStats` labels fail -/// as "positive", so its sensitivity is this function's specificity. -/// -/// # References -/// -/// Haakstad, H. (2023). *betafunctions: Functions for working with two- and -/// four-parameter beta probability distributions* (Version 1.9.0) -/// \[R package\]. CRAN. -/// -/// Hanson, B. A. (1991). *Method of moments estimates for the four-parameter -/// beta compound binomial model and the calculation of classification -/// consistency indexes* (ACT Research Report 91-5). (As cited in Haakstad, -/// 2023; not read.) -/// -/// Livingston, S. A., & Lewis, C. (1995). Estimating the consistency and -/// accuracy of classifications based on test scores. *Journal of Educational -/// Measurement, 32*(2), 179-197. -/// https://doi.org/10.1111/j.1745-3984.1995.tb00462.x (As implemented in -/// Haakstad, 2023; the paper itself was not obtainable.) -/// -/// Press, W. H., Teukolsky, S. A., Vetterling, W. T., & Flannery, B. P. -/// (2007). *Numerical recipes: The art of scientific computing* (3rd ed.). -/// Cambridge University Press. (Sec. 4.6 Gauss-Legendre `gauleg`.) -pub fn livingston_lewis( - scores: &[f64], - reliability: f64, - min: f64, - max: f64, - cut: f64, -) -> Result { - let n = scores.len(); - if n < 10 { - return Err("at least 10 observed scores are required".into()); - } - if !min.is_finite() || !max.is_finite() || min >= max { - return Err("min and max must be finite with min < max".into()); - } - if scores - .iter() - .any(|x| !x.is_finite() || *x < min || *x > max) - { - return Err("scores must be finite and within [min, max]".into()); - } - if !cut.is_finite() || cut <= min || cut >= max { - return Err("cut must be strictly inside (min, max)".into()); - } - if !reliability.is_finite() || reliability <= 0.0 || reliability >= 1.0 { - return Err("reliability must be in the open interval (0, 1)".into()); - } - let nf = n as f64; - let mean = scores.iter().sum::() / nf; - let s2 = scores.iter().map(|x| (x - mean).powi(2)).sum::() / (nf - 1.0); - if s2 <= 0.0 { - return Err("observed-score variance must be positive".into()); - } - let etl = ((mean - min) * (max - mean) - reliability * s2) / (s2 * (1.0 - reliability)); - if !etl.is_finite() || etl < 2.0 { - return Err(format!( - "effective test length {etl:.4} is not usable (needs >= 2); \ - check the reliability and score bounds" - )); - } - // True-score raw moments (HB.tsm, k = 0) on the unrounded ETL scale. - let mut m = [0.0_f64; 5]; - for (i, mi) in m.iter_mut().enumerate().skip(1) { - let r = i as u32; - let num = scores - .iter() - .map(|x| { - let xp = (x - min) / (max - min) * etl; - if xp < r as f64 { - 0.0 - } else { - falling_factorial(xp, r) - } - }) - .sum::() - / nf; - *mi = num / falling_factorial(etl, r); - } - let m1 = m[1]; - let ts2 = m[2] - m1 * m1; - if !(ts2 > 0.0) { - return Err("estimated true-score variance is not positive".into()); - } - let g3 = (m[3] - 3.0 * m1 * m[2] + 2.0 * m1.powi(3)) / ts2.powf(1.5); - let g4 = (m[4] - 4.0 * m1 * m[3] + 6.0 * m1 * m1 * m[2] - 3.0 * m1.powi(4)) / (ts2 * ts2); - // Four-parameter beta moment fit with two-parameter fail-safe. - let mut used_two_parameter = true; - let (mut a, mut b, mut lower, mut upper) = (f64::NAN, f64::NAN, 0.0, 1.0); - let rr = 6.0 * (g4 - g3 * g3 - 1.0) / (6.0 + 3.0 * g3 * g3 - 2.0 * g4); - let d = - 1.0 - 24.0 * (rr + 1.0) / ((rr + 2.0) * (rr + 3.0) * g4 - 3.0 * (rr - 6.0) * (rr + 1.0)); - if d.is_finite() && d >= 0.0 { - let sq = d.sqrt(); - let (a4, b4) = if g3 < 0.0 { - (rr / 2.0 * (1.0 + sq), rr / 2.0 * (1.0 - sq)) - } else { - (rr / 2.0 * (1.0 - sq), rr / 2.0 * (1.0 + sq)) - }; - if a4.is_finite() && b4.is_finite() && a4 > 0.0 && b4 > 0.0 { - let spread = (ts2 * (a4 + b4 + 1.0)).sqrt() / (a4 * b4).sqrt(); - let l4 = m1 - a4 * spread; - let u4 = m1 + b4 * spread; - if l4 >= 0.0 && u4 <= 1.0 { - a = a4; - b = b4; - lower = l4; - upper = u4; - used_two_parameter = false; - } - } - } - if used_two_parameter { - let scale = m1 * (1.0 - m1) / ts2 - 1.0; - a = m1 * scale; - b = (1.0 - m1) * scale; - lower = 0.0; - upper = 1.0; - } - if !a.is_finite() || !b.is_finite() || a <= 0.0 || b <= 0.0 { - return Err("beta true-score fit produced invalid shape parameters".into()); - } - let n_int = etl.round_ties_even(); - let nn = n_int as u64; - let c = (cut - min) / (max - min); - let k = (n_int * c).round_ties_even(); - // P(X <= k-1 | N, p) via the binomial-beta CDF identity - // P(X <= m) = I_{1-p}(N-m, m+1) with m = k-1. - let fail_prob = |t: f64| -> f64 { - let p = (lower + (upper - lower) * t).clamp(0.0, 1.0); - if k <= 0.0 { - 0.0 - } else if k - 1.0 >= n_int { - 1.0 - } else { - crate::reliability::inc_beta(n_int - k + 1.0, k, 1.0 - p) - } - }; - // x-domain cut mapped to the beta t-domain (density is zero outside). - let tc = ((c - lower) / (upper - lower)).clamp(0.0, 1.0); - let p_tp = beta_weighted_integral(a, b, tc, 1.0, |t| 1.0 - fail_prob(t)); - let p_fp = beta_weighted_integral(a, b, 0.0, tc, |t| 1.0 - fail_prob(t)); - let p_ff = beta_weighted_integral(a, b, tc, 1.0, &fail_prob); - let p_tf = beta_weighted_integral(a, b, 0.0, tc, &fail_prob); - let p_ii_raw = beta_weighted_integral(a, b, 0.0, 1.0, |t| fail_prob(t).powi(2)); - let p_ij_raw = beta_weighted_integral(a, b, 0.0, 1.0, |t| fail_prob(t) * (1.0 - fail_prob(t))); - let p_jj_raw = beta_weighted_integral(a, b, 0.0, 1.0, |t| (1.0 - fail_prob(t)).powi(2)); - let tot = p_ii_raw + 2.0 * p_ij_raw + p_jj_raw; - if !(tot > 0.0) { - return Err("consistency integrals degenerated to zero mass".into()); - } - let p_ii = p_ii_raw / tot; - let p_ij = p_ij_raw / tot; - let p_jj = p_jj_raw / tot; - let consistency = p_ii + p_jj; - let chance_consistency = (p_ii + p_ij) * (p_ii + p_ij) + (p_ij + p_jj) * (p_ij + p_jj); - // Conditional ratios are undefined when their margin (or the chance - // correction) vanishes, e.g. a cut outside the fitted beta support; - // return an explicit NaN rather than an unstable near-0/0 quotient. - let ratio = |num: f64, den: f64| if den > 1e-12 { num / den } else { f64::NAN }; - Ok(LivingstonLewisResult { - effective_test_length: etl, - etl_rounded: nn, - lower, - upper, - alpha: a, - beta: b, - used_two_parameter, - p_tp, - p_fp, - p_tf, - p_ff, - accuracy: p_tp + p_tf, - sensitivity: ratio(p_tp, p_tp + p_ff), - specificity: ratio(p_tf, p_tf + p_fp), - p_ii, - p_ij, - p_ji: p_ij, - p_jj, - consistency, - chance_consistency, - kappa: ratio(consistency - chance_consistency, 1.0 - chance_consistency), - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/classification_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/crm.rs b/crates/mlsirm-core/src/crm.rs deleted file mode 100644 index eac674e7e..000000000 --- a/crates/mlsirm-core/src/crm.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! Continuous Response Model (Samejima, 1973) by marginal-ML EM. -//! -//! The CRM is the limit of the graded response model as the number of ordered -//! categories grows without bound, for an item scored on a *continuous* bounded -//! scale. Operationally (Wang & Zeng, 1998), the logit of the bounded response is -//! conditionally normal and linear in the latent trait: for a response -//! `Z_ij in (0,1)`, the transform `X_ij = ln(Z_ij / (1 - Z_ij))` satisfies -//! -//! ```text -//! X_ij | theta_j ~ Normal( a_i * theta_j + d_i , sigma_i^2 ), theta_j ~ N(0,1) -//! ``` -//! -//! with item slope `a_i` (loading of the transformed response on the trait), -//! intercept `d_i`, and residual standard deviation `sigma_i`. This is Samejima's -//! CRM in the logit metric; the classic operating-characteristic parameters map as -//! `a_i = gamma_i`, `d_i = -gamma_i b_i`, `sigma_i = gamma_i / alpha_i`, so the fit -//! reports the derived **discrimination** `alpha_i = a_i / sigma_i` and -//! **difficulty** `b_i = -d_i / a_i` alongside the working `(a, d, sigma)`. -//! -//! The `Z -> X` Jacobian `ln|dX/dZ| = -ln(Z(1-Z))` is constant in the item -//! parameters, so it is omitted from the EM (it only shifts the reported -//! transformed-space log-likelihood by a data-only constant). The item M-step is a -//! closed-form weighted least squares (regress `X` on `theta` under the posterior) -//! plus a residual-variance update — no Newton iteration. -//! -//! Only the continuous-response data type is new; the quadrature, EM bookkeeping, -//! and identification (`theta ~ N(0,1)` fixes the scale) mirror the crate's other -//! marginal-ML fits. Convergence requires a finite, non-decreasing observed-data -//! log-likelihood and a signed final increment no larger than -//! `tol * (1 + |previous log-likelihood|)` (Dempster et al., 1977; Wu, 1983). -//! -//! # References (APA 7th ed.) -//! Samejima, F. (1973). Homogeneous case of the continuous response model. -//! *Psychometrika, 38*(2), 203-219. https://doi.org/10.1007/BF02291114 -//! Wang, T., & Zeng, L. (1998). Item parameter estimation for a continuous response -//! model using an EM algorithm. *Applied Psychological Measurement, 22*(4), -//! 333-344. https://doi.org/10.1177/014662169802200402 -//! Dempster, A. P., Laird, N. M., & Rubin, D. B. (1977). Maximum likelihood from -//! incomplete data via the EM algorithm. *Journal of the Royal Statistical Society: -//! Series B (Methodological), 39*(1), 1-22. -//! https://doi.org/10.1111/j.2517-6161.1977.tb01600.x -//! Wu, C. F. J. (1983). On the convergence properties of the EM algorithm. -//! *The Annals of Statistics, 11*(1), 95-103. -//! https://doi.org/10.1214/aos/1176346060 - -/// Upper bound on caller-controlled EM iterations, shared with the Python API. -const CRM_MAX_ITER: usize = 100_000; - -/// Fitted continuous response model (Samejima, 1973). `slope`/`intercept`/`resid_sd` -/// are the working `(a_i, d_i, sigma_i)` of the logit-normal form; `discrimination` -/// and `difficulty` are the derived Samejima `(alpha_i, b_i)` (`b_i` is `NaN` for a -/// non-discriminating item whose slope is ~0, where difficulty is undefined). -/// `theta` is the per-person EAP trait. -#[derive(Clone, Debug)] -pub struct CrmResult { - pub slope: Vec, - pub intercept: Vec, - pub resid_sd: Vec, - pub discrimination: Vec, - pub difficulty: Vec, - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// Why fitting stopped: `"tolerance"` or `"max_iter"`. - pub termination_reason: String, - /// Signed final observed-data log-likelihood increment. - pub final_delta: f64, - /// Effective observed-data log-likelihood increment required for convergence. - pub stopping_tolerance: f64, - /// `3 * n_items` (slope, intercept, residual sd per item). - pub n_parameters: usize, -} - -#[derive(Clone, Copy)] -struct CrmWlsStats { - s1: f64, - sth: f64, - sthth: f64, - sx: f64, - sxth: f64, - sxx: f64, -} - -fn contextualize_crm_update( - update: Result, String>, - item: usize, -) -> Result, String> { - match update { - Ok(value) => Ok(value), - Err(message) => Err(format!("{message} for item {item}")), - } -} - -fn checked_crm_delta( - current: f64, - previous: Option, - tol: f64, -) -> Result, String> { - if !current.is_finite() { - return Err("CRM observed-data log-likelihood became non-finite".into()); - } - let Some(previous) = previous else { - return Ok(None); - }; - let delta = current - previous; - let stopping_tolerance = tol * (1.0 + previous.abs()); - let monotone_slack = 32.0 * f64::EPSILON * (1.0 + previous.abs()); - if delta < -monotone_slack { - return Err(format!( - "CRM EM log-likelihood decreased by {delta:e}, beyond numerical slack {monotone_slack:e}" - )); - } - Ok(Some(( - delta, - stopping_tolerance, - delta <= stopping_tolerance, - ))) -} - -fn crm_wls_update(stats: CrmWlsStats, eps: f64) -> Result, String> { - let det = stats.sthth * stats.s1 - stats.sth * stats.sth; - if det.abs() < 1e-12 { - return Ok(None); - } - let slope = (stats.sxth * stats.s1 - stats.sth * stats.sx) / det; - let intercept = (stats.sthth * stats.sx - stats.sth * stats.sxth) / det; - let resid = (stats.sxx - slope * stats.sxth - intercept * stats.sx) / stats.s1; - if !slope.is_finite() || !intercept.is_finite() || !resid.is_finite() { - return Err("CRM M-step produced non-finite values".into()); - } - Ok(Some((slope, intercept, resid.max(eps * eps).sqrt()))) -} - -fn reflect_crm_loadings(loadings: &mut [f64]) { - if loadings.iter().sum::() < 0.0 { - loadings.iter_mut().for_each(|loading| *loading = -*loading); - } -} - -fn crm_difficulty(slope: f64, intercept: f64) -> f64 { - if slope.abs() > 1e-6 { - -intercept / slope - } else { - f64::NAN - } -} - -/// Fit the continuous response model (Samejima, 1973) by marginal-ML EM. -/// `responses` is row-major `n_persons * n_items` with entries in `(0, 1)` -/// (values are clamped to `[eps, 1-eps]` before the logit transform); `observed` -/// marks non-missing cells (dropped under MAR). `theta ~ N(0,1)` on the -/// `q_theta`-node Gauss-Hermite grid. Returns `Err` on malformed input. -#[allow(clippy::too_many_arguments)] -pub fn fit_crm( - responses: &[f64], - observed: &[bool], - n_persons: usize, - n_items: usize, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must both be positive".into()); - } - if !(1..=CRM_MAX_ITER).contains(&max_iter) { - return Err(format!("max_iter must be in 1..={CRM_MAX_ITER}")); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - let n_parameters = 3usize - .checked_mul(n_items) - .ok_or_else(|| "3 * n_items overflows usize".to_string())?; - let expected = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows usize".to_string())?; - if responses.len() != expected { - return Err("responses must have length n_persons * n_items".into()); - } - if observed.len() != expected { - return Err("observed must have length n_persons * n_items".into()); - } - let mut item_observed = vec![0usize; n_items]; - for (idx, &z) in responses.iter().enumerate() { - if observed[idx] && (!z.is_finite() || z <= 0.0 || z >= 1.0) { - return Err("observed responses must lie in the open interval (0, 1)".into()); - } - if observed[idx] { - item_observed[idx % n_items] += 1; - } - } - if let Some(item) = item_observed.iter().position(|&count| count == 0) { - return Err(format!("item {item} has no observed responses")); - } - let (nodes, weights) = crate::quadrature::gh_rule(q_theta) - .ok_or_else(|| format!("unsupported q_theta {q_theta}"))?; - let q = nodes.len(); - let log_w: Vec = weights.iter().map(|w| w.ln()).collect(); - let eps = 1e-6; - let ln_2pi = (2.0 * std::f64::consts::PI).ln(); - - // Logit-transform the observed responses once. - let mut x = vec![0.0f64; n_persons * n_items]; - for idx in 0..responses.len() { - if observed[idx] { - let z = responses[idx].clamp(eps, 1.0 - eps); - x[idx] = (z / (1.0 - z)).ln(); - } - } - - // Init: unit loading; intercept = item mean of X; residual sd = item sd of X with - // the unit-trait variance removed (floored) so the loading has room to explain it. - let mut a = vec![1.0f64; n_items]; - let mut d = vec![0.0f64; n_items]; - let mut sigma = vec![1.0f64; n_items]; - for i in 0..n_items { - let (mut s1, mut sx, mut sxx) = (0.0f64, 0.0f64, 0.0f64); - for j in 0..n_persons { - let idx = j * n_items + i; - if observed[idx] { - s1 += 1.0; - sx += x[idx]; - sxx += x[idx] * x[idx]; - } - } - if s1 > 0.0 { - let mean = sx / s1; - let var = (sxx / s1 - mean * mean).max(eps); - d[i] = mean; - sigma[i] = (var - 1.0).max(0.25 * var).sqrt(); - } - } - - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - let mut post = vec![0.0f64; q]; - - for _ in 0..max_iter { - // Per-item expected sufficient statistics for the weighted regression. - let mut s1 = vec![0.0f64; n_items]; - let mut sth = vec![0.0f64; n_items]; - let mut sthth = vec![0.0f64; n_items]; - let mut sx = vec![0.0f64; n_items]; - let mut sxth = vec![0.0f64; n_items]; - let mut sxx = vec![0.0f64; n_items]; - let mut total_ll = 0.0f64; - let log_sigma: Vec = sigma.iter().map(|s| s.ln()).collect(); - - for j in 0..n_persons { - for (qi, &node) in nodes.iter().enumerate() { - let mut acc = log_w[qi]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let r = (x[idx] - a[i] * node - d[i]) / sigma[i]; - acc += -0.5 * ln_2pi - log_sigma[i] - 0.5 * r * r; - } - } - post[qi] = acc; - } - let mx = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - mx).exp(); - } - total_ll += mx + denom.ln(); - for v in post.iter_mut() { - *v = (*v - mx).exp() / denom; - } - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let xij = x[idx]; - for (qi, &node) in nodes.iter().enumerate() { - let p = post[qi]; - s1[i] += p; - sth[i] += p * node; - sthth[i] += p * node * node; - sx[i] += p * xij; - sxth[i] += p * xij * node; - sxx[i] += p * xij * xij; - } - } - } - } - let convergence = checked_crm_delta(total_ll, loglik_trace.last().copied(), tol)?; - loglik_trace.push(total_ll); - - // Converge check before the M-step so returned params match the trace endpoint. - if let Some((_, _, reached_tolerance)) = convergence { - if reached_tolerance { - converged = true; - break; - } - } - - // M-step: closed-form WLS of X on theta, then the residual variance. - for i in 0..n_items { - let stats = CrmWlsStats { - s1: s1[i], - sth: sth[i], - sthth: sthth[i], - sx: sx[i], - sxth: sxth[i], - sxx: sxx[i], - }; - if let Some((ai, di, sigma_i)) = - contextualize_crm_update(crm_wls_update(stats, eps), i)? - { - a[i] = ai; - d[i] = di; - sigma[i] = sigma_i; - } - } - n_iter += 1; - } - - // Reflection convention: make the average loading non-negative (theta -> -theta, - // a -> -a leaves the model invariant), so recovery is comparable to a - // positive-loading generating truth. - reflect_crm_loadings(&mut a); - - // Final person EAP pass at the (possibly sign-flipped) converged parameters; the - // flipped slopes yield the correspondingly reflected trait, keeping the fit - // invariant. - let log_sigma: Vec = sigma.iter().map(|s| s.ln()).collect(); - let mut theta = vec![0.0f64; n_persons]; - let mut final_ll = 0.0f64; - for j in 0..n_persons { - for (qi, &node) in nodes.iter().enumerate() { - let mut acc = log_w[qi]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let r = (x[idx] - a[i] * node - d[i]) / sigma[i]; - acc += -0.5 * ln_2pi - log_sigma[i] - 0.5 * r * r; - } - } - post[qi] = acc; - } - let mx = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - mx).exp(); - } - final_ll += mx + denom.ln(); - let mut m = 0.0f64; - for (qi, &node) in nodes.iter().enumerate() { - m += (post[qi] - mx).exp() / denom * node; - } - theta[j] = m; - } - if !converged { - let previous = *loglik_trace - .last() - .expect("positive max_iter always produces a CRM log-likelihood endpoint"); - let (_, _, reached_tolerance) = checked_crm_delta(final_ll, Some(previous), tol)? - .expect("a previous endpoint always produces convergence evidence"); - loglik_trace.push(final_ll); - converged = reached_tolerance; - } - - let final_delta = loglik_trace[loglik_trace.len() - 1] - loglik_trace[loglik_trace.len() - 2]; - let stopping_tolerance = tol * (1.0 + loglik_trace[loglik_trace.len() - 2].abs()); - let termination_reason = if converged { "tolerance" } else { "max_iter" }; - - let discrimination: Vec = (0..n_items).map(|i| a[i] / sigma[i]).collect(); - // Samejima difficulty b = -d/a is undefined for a non-discriminating item - // (slope ~ 0); report NaN there rather than a misleading blow-up. - let difficulty: Vec = (0..n_items).map(|i| crm_difficulty(a[i], d[i])).collect(); - - Ok(CrmResult { - slope: a, - intercept: d, - resid_sd: sigma, - discrimination, - difficulty, - theta, - loglik_trace, - n_iter, - converged, - termination_reason: termination_reason.to_string(), - final_delta, - stopping_tolerance, - n_parameters, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/crm_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/detect.rs b/crates/mlsirm-core/src/detect.rs deleted file mode 100644 index f0ba166a1..000000000 --- a/crates/mlsirm-core/src/detect.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Confirmatory DETECT dimensionality analysis (Zhang & Stout, 1999) via -//! conditional-covariance estimation with sum-score conditioning. -//! -//! Given a persons x items matrix of binary (0/1) responses and a known item -//! clustering, this module estimates the pairwise conditional covariances of -//! the items and computes five DETECT-family indices: -//! -//! ```text -//! ccov_ij = ( ccov(S) + ccov(R_ij) ) / 2 bias-corrected estimate -//! delta_ij = +1 if cluster(i) == cluster(j) else -1 -//! -//! DETECT = 100 * mean( ccov_ij * delta_ij ) -//! ASSI = mean( sign(ccov_ij) * delta_ij ) sign(0) = 0 -//! RATIO = sum( ccov_ij * delta_ij ) / sum |ccov_ij| -//! MADCOV100 = 100 * mean |ccov_ij| -//! MCOV100 = 100 * mean( ccov_ij ) -//! ``` -//! -//! where `S_p = sum_k X_pk` is the raw total score, `R_ij,p = S_p - X_pi - -//! X_pj` is the pair rest score, and for a conditioning score `c`: -//! -//! ```text -//! ccov(c) = sum_s w_s * MLcov_s(X_i, X_j), w_s = #{p : c_p = s} / N -//! ``` -//! -//! with `MLcov_s` the maximum-likelihood covariance (divide by group size -//! `n_s`, not `n_s - 1`) within the group of persons whose conditioning score -//! equals `s`. A singleton group contributes covariance 0. -//! -//! # Verified sources -//! -//! Formulas were transcribed line by line from the CRAN `sirt` R package -//! sources: `detect.index.R` (the five indices and the `delta` sign -//! convention), `ccov.np.R` (sum-score conditioning, pair rest score -//! `score - X_i - X_j`, bias correction as the average of the total-score and -//! rest-score estimates), `ccov_np_compute_ccov_sum_score.R` (per-group ML -//! covariance aggregated with group-frequency weights recomputed from each -//! conditioning vector), and `conf.detect.R` (driver wiring). The original -//! DETECT papers (Zhang & Stout, 1999a, 1999b; Stout et al., 1996; Zhang, -//! 2007) were NOT read (paywalled) and are cited only as cited in the `sirt` -//! documentation. -//! -//! # Scope: which `sirt` path this matches -//! -//! This implements the explicit non-default oracle path -//! `ccov.np(data, use_sum_score = TRUE, scale_score = FALSE, bias_corr = -//! TRUE)`, equivalently `conf.detect(..., smooth = FALSE, use_sum_score = -//! TRUE)`: the conditioning score is the RAW integer row sum. The `sirt` -//! DEFAULT (`scale_score = TRUE`) z-standardizes the row sum and rounds it to -//! 3 decimals before grouping; because grouping by unique values is invariant -//! to strictly monotone transforms, both paths agree whenever the rounding -//! merges no groups, but parity is only claimed for the raw-score path. -//! Kernel-smoothed conditioning (`smooth = TRUE`), exploratory cluster -//! search (`expl.detect`), polytomous DETECT (Zhang, 2007), and multiple -//! conditioning scores are out of scope. -//! -//! # Divergences from the R package (deliberate) -//! -//! - Missing responses are rejected. `sirt` allows them with pairwise -//! deletion per item pair (and computes the total score with `rowSums` -//! WITHOUT `na.rm`, so any-missing rows get `NA` scores even for complete -//! pairs); v1 requires complete binary data. -//! - When every conditional covariance is exactly zero, R's RATIO is -//! `0/0 = NaN`; this implementation returns an error instead of a silent NaN. -//! - Because missing data are rejected, every pair has the same person count, -//! so `sirt`'s `sqrt(N)`-weighted index variants coincide with the -//! unweighted ones and are not computed. -//! -//! Interpretation thresholds quoted in the `sirt` documentation (as cited -//! there from Jang & Roussos, 2007, and Zhang, 2007): DETECT < 0.2 suggests -//! essential unidimensionality and DETECT >= 1.0 sizeable multidimensionality -//! relative to the supplied partition. These are conventions, not enforced. -//! -//! In LLM-as-a-Judge item-quality management this diagnoses whether a rubric -//! partition of judge items behaves as distinct dimensions (positive DETECT -//! with a coherent partition) or as a single dimension (DETECT near zero). -//! -//! # References (APA 7th ed.) -//! -//! Jang, E. E., & Roussos, L. (2007). An investigation into the -//! dimensionality of TOEFL using conditional covariance-based nonparametric -//! approach. *Journal of Educational Measurement, 44*(1), 1-21. (as cited in -//! Robitzsch, 2024) -//! -//! Robitzsch, A. (2024). *sirt: Supplementary item response theory models* -//! (R package). https://CRAN.R-project.org/package=sirt -//! -//! Stout, W., Habing, B., Douglas, J., & Kim, H. R. (1996). Conditional -//! covariance-based nonparametric multidimensionality assessment. *Applied -//! Psychological Measurement, 20*(4), 331-354. (as cited in Robitzsch, 2024) -//! -//! Zhang, J. (2007). Conditional covariance theory and DETECT for polytomous -//! items. *Psychometrika, 72*(1), 69-91. (as cited in Robitzsch, 2024) -//! -//! Zhang, J., & Stout, W. (1999a). Conditional covariance structure of -//! generalized compensatory multidimensional items. *Psychometrika, 64*(2), -//! 129-152. (as cited in Robitzsch, 2024) -//! -//! Zhang, J., & Stout, W. (1999b). The theoretical DETECT index of -//! dimensionality and its application to approximate simple structure. -//! *Psychometrika, 64*(2), 213-249. (as cited in Robitzsch, 2024) - -/// Result of a confirmatory DETECT analysis. -#[derive(Debug, Clone)] -pub struct DetectResult { - /// DETECT index (x100 scale). - pub detect: f64, - /// Approximate simple structure index (unscaled, in [-1, 1]). - pub assi: f64, - /// RATIO index (unscaled, in [-1, 1]). - pub ratio: f64, - /// Mean absolute conditional covariance (x100 scale). - pub madcov100: f64, - /// Mean conditional covariance (x100 scale). - pub mcov100: f64, - /// Number of item pairs `I (I - 1) / 2`. - pub n_pairs: usize, - /// First item index of each pair, `i < j`, row-major order. - pub pair_i: Vec, - /// Second item index of each pair. - pub pair_j: Vec, - /// Bias-corrected conditional covariance per pair. - pub ccov: Vec, -} - -/// ML covariance of `(x_i, x_j)` within one conditioning group given by -/// `idx` (divide by group size, not size - 1). Singleton groups return 0. -fn ml_cov_group(xi: &[f64], xj: &[f64], idx: &[usize]) -> f64 { - let n = idx.len(); - if n < 2 { - return 0.0; - } - let nf = n as f64; - let (mut mi, mut mj) = (0.0, 0.0); - for &p in idx { - mi += xi[p]; - mj += xj[p]; - } - mi /= nf; - mj /= nf; - let mut s = 0.0; - for &p in idx { - s += (xi[p] - mi) * (xj[p] - mj); - } - s / nf -} - -/// Aggregate conditional covariance over the groups of a conditioning score -/// vector: `sum_s w_s * MLcov_s` with `w_s` the group frequency / N. The -/// group table is recomputed from `score` on every call (the rest-score pass -/// must NOT reuse total-score weights; `ccov_np_compute_ccov_sum_score.R` -/// builds `wgt_score` from the supplied vector). -fn ccov_sum_score(score: &[i64], xi: &[f64], xj: &[f64]) -> f64 { - let n = score.len() as f64; - let mut order: Vec = (0..score.len()).collect(); - order.sort_unstable_by_key(|&p| score[p]); - ccov_sum_score_presorted(score, xi, xj, n, &order) -} - -/// Aggregate conditional covariance using a pre-sorted person ordering. -/// `order` must already be sorted by ascending `score` value; `n` is -/// `score.len() as f64`. This avoids re-sorting `score` on every pair for -/// vectors whose sort order is constant across pairs (i.e. the total score). -fn ccov_sum_score_presorted(score: &[i64], xi: &[f64], xj: &[f64], n: f64, order: &[usize]) -> f64 { - let mut total = 0.0; - let mut start = 0; - while start < order.len() { - let s = score[order[start]]; - let mut end = start + 1; - while end < order.len() && score[order[end]] == s { - end += 1; - } - let group = &order[start..end]; - let w = group.len() as f64 / n; - total += w * ml_cov_group(xi, xj, group); - start = end; - } - total -} - -/// Confirmatory DETECT analysis of a binary response matrix. -/// -/// `responses` is row-major `n_persons x n_items` with entries exactly 0.0 or -/// 1.0 (missing data rejected). `cluster` assigns one label per item; labels -/// are opaque (compared for equality only). -pub fn detect_analysis( - responses: &[f64], - n_persons: usize, - n_items: usize, - cluster: &[i64], -) -> Result { - if n_persons < 2 { - return Err("detect: need at least 2 persons".to_string()); - } - if n_items < 2 { - return Err("detect: need at least 2 items".to_string()); - } - let expected = n_persons - .checked_mul(n_items) - .ok_or_else(|| "detect: n_persons * n_items overflows".to_string())?; - if responses.len() != expected { - return Err(format!( - "detect: responses length {} != n_persons {} x n_items {}", - responses.len(), - n_persons, - n_items - )); - } - if cluster.len() != n_items { - return Err(format!( - "detect: cluster length {} != n_items {}", - cluster.len(), - n_items - )); - } - for &x in responses { - if x != 0.0 && x != 1.0 { - return Err( - "detect: responses must be exactly 0 or 1 (missing data not supported)" - .to_string(), - ); - } - } - let n_pairs = n_items - .checked_mul(n_items - 1) - .map(|m| m / 2) - .ok_or_else(|| "detect: pair count overflows".to_string())?; - - // Column-extract items and raw total scores (integers, exact in f64->i64). - let mut items: Vec> = vec![vec![0.0; n_persons]; n_items]; - let mut total: Vec = vec![0; n_persons]; - for p in 0..n_persons { - for i in 0..n_items { - let x = responses[p * n_items + i]; - items[i][p] = x; - total[p] += x as i64; - } - } - - let mut pair_i = Vec::with_capacity(n_pairs); - let mut pair_j = Vec::with_capacity(n_pairs); - let mut ccov = Vec::with_capacity(n_pairs); - let mut rest: Vec = vec![0; n_persons]; - // Precompute the total-score person ordering once; it is the same for - // every pair (total is invariant across pairs). The rest-score must still - // be re-sorted per pair because it changes with (i, j). - let n_persons_f = n_persons as f64; - let mut total_order: Vec = (0..n_persons).collect(); - total_order.sort_unstable_by_key(|&p| total[p]); - for i in 0..n_items { - for j in (i + 1)..n_items { - let (xi, xj) = (&items[i], &items[j]); - for p in 0..n_persons { - rest[p] = total[p] - xi[p] as i64 - xj[p] as i64; - } - // Bias correction (ccov.np.R:96-104): average of the covariance - // conditioned on the total score and on the pair rest score. - let c1 = ccov_sum_score_presorted(&total, xi, xj, n_persons_f, &total_order); - let c2 = ccov_sum_score(&rest, xi, xj); - pair_i.push(i); - pair_j.push(j); - ccov.push(0.5 * (c1 + c2)); - } - } - - let m = n_pairs as f64; - let (mut sum_cd, mut sum_abs, mut sum_c, mut sum_sd) = (0.0, 0.0, 0.0, 0.0); - for k in 0..n_pairs { - let c = ccov[k]; - let delta = if cluster[pair_i[k]] == cluster[pair_j[k]] { - 1.0 - } else { - -1.0 - }; - sum_cd += c * delta; - sum_abs += c.abs(); - sum_c += c; - // R's sign(): sign(0) = 0. - let sg = if c > 0.0 { - 1.0 - } else if c < 0.0 { - -1.0 - } else { - 0.0 - }; - sum_sd += sg * delta; - } - if sum_abs == 0.0 { - return Err( - "detect: all conditional covariances are zero; RATIO is undefined (0/0)" - .to_string(), - ); - } - Ok(DetectResult { - detect: 100.0 * sum_cd / m, - assi: sum_sd / m, - ratio: sum_cd / sum_abs, - madcov100: 100.0 * sum_abs / m, - mcov100: 100.0 * sum_c / m, - n_pairs, - pair_i, - pair_j, - ccov, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/detect_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/dif.rs b/crates/mlsirm-core/src/dif.rs deleted file mode 100644 index c91cc4c90..000000000 --- a/crates/mlsirm-core/src/dif.rs +++ /dev/null @@ -1,1759 +0,0 @@ -//! Observed-score differential item functioning by the Mantel-Haenszel procedure. -//! -//! The Mantel-Haenszel (MH) DIF statistic (Holland & Thayer, 1988) tests whether a dichotomous item -//! functions differently for a *reference* and a *focal* group after matching examinees on an observed -//! proficiency score (the number-correct total). It is the observed-score, calibration-free complement -//! to the parametric IRT likelihood-ratio DIF ([`crate::poly::poly_dif_sweep`]): no item response model -//! is fitted; examinees are stratified by matching score and a common odds ratio is estimated across the -//! resulting `2 x 2` (group by response) tables. -//! -//! For a studied item, at each matching level `m` the `2 x 2` table is -//! -//! | | correct | incorrect | total | -//! |------------|---------|-----------|--------| -//! | reference | `A_m` | `B_m` | `n_Rm` | -//! | focal | `C_m` | `D_m` | `n_Fm` | -//! | total | `m1_m` | `m0_m` | `T_m` | -//! -//! and, summing over the DIF-informative strata (all four marginal totals positive, so the -//! hypergeometric variance is positive): -//! -//! - **Common odds ratio** `alpha_MH = (sum_m A_m D_m / T_m) / (sum_m B_m C_m / T_m)`. -//! - **MH chi-square** (continuity-corrected, referred to `chi^2(1)`) -//! `chi2_MH = max(0, |sum_m A_m - sum_m E(A_m)| - 0.5)^2 / sum_m Var(A_m)`, with -//! `E(A_m) = n_Rm m1_m / T_m` and `Var(A_m) = n_Rm n_Fm m1_m m0_m / (T_m^2 (T_m - 1))`. -//! - **ETS delta metric** `MH_D-DIF = -2.35 ln(alpha_MH)` (negative = harder for the focal group), -//! with the Robins-Breslow-Greenland (1986) standard error `SE = 2.35 sqrt(Var(ln alpha_MH))`. -//! - **ETS A/B/C classification** (Zieky, 1993; Dorans & Holland, 1993): A (negligible) if `chi2_MH` is -//! not significant at .05 or `|MH_D-DIF| < 1.0`; C (large) if `|MH_D-DIF| >= 1.5` and `|MH_D-DIF|` is -//! significantly above 1.0 (`|MH_D-DIF| - 1.645 SE > 1.0`); B otherwise. -//! - **Standardized P-DIF** (Dorans & Kulick, 1986) `STD_P-DIF = sum_m n_Fm (P_Fm - P_Rm) / sum_m n_Fm` -//! (focal minus reference, so its sign agrees with `MH_D-DIF`) as a companion effect size. -//! -//! Matching uses the total number-correct *including* the studied item by default (thin matching, the -//! ETS standard; Donoghue, Holland & Thayer, 1993, show it is less biased than the rest score); an -//! option matches on the rest score (studied item excluded), recomputing the strata per item. MH is a -//! *uniform*-DIF detector and is known to miss crossing (non-uniform) DIF that the IRT-LR test catches. -//! -//! # References (APA 7th ed.) -//! -//! Donoghue, J. R., Holland, P. W., & Thayer, D. T. (1993). A Monte Carlo study of factors that affect -//! the Mantel-Haenszel and standardization measures of differential item functioning. In P. W. -//! Holland & H. Wainer (Eds.), *Differential item functioning* (pp. 137-166). Erlbaum. -//! Dorans, N. J., & Holland, P. W. (1993). DIF detection and description: Mantel-Haenszel and -//! standardization. In P. W. Holland & H. Wainer (Eds.), *Differential item functioning* (pp. -//! 35-66). Erlbaum. -//! Dorans, N. J., & Kulick, E. (1986). Demonstrating the utility of the standardization approach to -//! assessing unexpected differential item performance on the Scholastic Aptitude Test. *Journal of -//! Educational Measurement, 23*(4), 355-368. https://doi.org/10.1111/j.1745-3984.1986.tb00255.x -//! Holland, P. W., & Thayer, D. T. (1988). Differential item performance and the Mantel-Haenszel -//! procedure. In H. Wainer & H. I. Braun (Eds.), *Test validity* (pp. 129-145). Erlbaum. -//! Robins, J., Breslow, N., & Greenland, S. (1986). Estimators of the Mantel-Haenszel variance -//! consistent in both sparse data and large-strata limiting models. *Biometrics, 42*(2), 311-323. -//! https://doi.org/10.2307/2531052 -//! Zieky, M. (1993). Practical questions in the use of DIF statistics in test development. In P. W. -//! Holland & H. Wainer (Eds.), *Differential item functioning* (pp. 337-347). Erlbaum. - -use crate::fitstats::{benjamini_hochberg, chi2_sf}; -use crate::lltm::{gram_full_rank, solve_small_checked}; -use crate::mmle::{log_sigmoid, sigmoid_stable}; - -/// ETS delta-metric transform constant (`4 / 1.7`): `MH_D-DIF = -DELTA_SCALE * ln(alpha_MH)`. -pub const DELTA_SCALE: f64 = 2.35; -/// Two-sided significance level for the MH chi-square in the ETS A/B/C rule. -const ALPHA_SIG: f64 = 0.05; -/// One-sided normal critical value for the C-boundary test that `|MH_D-DIF|` exceeds 1.0. -const Z_ONE_SIDED_05: f64 = 1.645; -/// Maximum `n_persons * n_items` cells (denial-of-service guard for a service boundary). -const MAX_CELLS: usize = 200_000_000; - -/// ETS DIF severity classification (Zieky, 1993). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum EtsClass { - /// Negligible DIF. - A, - /// Moderate DIF. - B, - /// Large DIF. - C, - /// Undefined — no DIF-informative strata, or a degenerate common odds ratio (`0`/`inf`) so the - /// delta statistic cannot be computed. NOT the same as A (negligible), which is an affirmative - /// claim requiring data. - Undefined, -} - -impl EtsClass { - /// Single-letter code (`"A"`/`"B"`/`"C"`/`"U"`) for serialization. - pub fn as_str(self) -> &'static str { - match self { - EtsClass::A => "A", - EtsClass::B => "B", - EtsClass::C => "C", - EtsClass::Undefined => "U", - } - } -} - -/// One studied item's Mantel-Haenszel DIF result. `NaN` statistics / `Undefined` class mean the item -/// had no DIF-informative strata or a degenerate common odds ratio (perfect group-by-response -/// separation). -pub struct MhDifRow { - pub item: usize, - /// MH common odds ratio (`NaN` if degenerate). - pub alpha_mh: f64, - /// Continuity-corrected MH chi-square, `chi^2(1)` (`NaN` if no informative strata). - pub chi2_mh: f64, - /// Upper-tail `p`-value of `chi2_mh` (`NaN` if no informative strata). - pub p_value: f64, - /// ETS delta-metric DIF `-2.35 ln(alpha_MH)`; negative = harder for the focal group (`NaN` if - /// degenerate). - pub mh_d_dif: f64, - /// Robins-Breslow-Greenland standard error of `mh_d_dif` (`NaN` if degenerate). - pub se_d_dif: f64, - /// Standardized P-DIF (Dorans & Kulick, 1986), focal minus reference (`NaN` if no stratum has both - /// groups present). - pub std_p_dif: f64, - /// ETS A/B/C (or Undefined) classification. - pub ets_class: EtsClass, - /// Benjamini-Hochberg FDR rejection flag on `p_value` across the swept items. - pub flagged_bh: bool, -} - -/// Configuration for [`mantel_haenszel_dif`]. -#[derive(Clone, Copy)] -pub struct MhDifConfig { - /// Match on the rest score (studied item excluded) instead of the total including the studied item. - /// The item-included default is the ETS standard (Donoghue, Holland & Thayer, 1993). - pub exclude_studied_item: bool, - /// Benjamini-Hochberg FDR level for the across-item flag. - pub fdr_q: f64, -} - -impl Default for MhDifConfig { - fn default() -> Self { - Self { - exclude_studied_item: false, - fdr_q: 0.05, - } - } -} - -/// Per-item MH statistics computed from a stratified sample (the calibration-free core, exposed for the -/// deterministic anchor). `resp` and `group` are length `n_persons` (`group`: `0` reference, `1` focal); -/// `matching[p]` is examinee `p`'s matching level in `0..n_levels`. -pub(crate) struct MhItemStats { - pub alpha_mh: f64, - pub chi2_mh: f64, - pub p_value: f64, - pub mh_d_dif: f64, - pub se_d_dif: f64, - pub std_p_dif: f64, - pub ets_class: EtsClass, -} - -pub(crate) fn mh_item_stats( - resp: &[u8], - group: &[u8], - matching: &[usize], - n_levels: usize, -) -> MhItemStats { - // Per-stratum 2x2 cell counts: a=ref-correct, b=ref-incorrect, c=focal-correct, d=focal-incorrect. - let (mut a, mut b, mut c, mut d) = ( - vec![0u64; n_levels], - vec![0u64; n_levels], - vec![0u64; n_levels], - vec![0u64; n_levels], - ); - for p in 0..resp.len() { - let m = matching[p]; - match (group[p], resp[p]) { - (0, 1) => a[m] += 1, - (0, _) => b[m] += 1, - (_, 1) => c[m] += 1, - (_, _) => d[m] += 1, - } - } - - // MH accumulators over DIF-informative strata (all four marginals > 0 => Var(A_m) > 0), plus the - // Robins-Breslow-Greenland variance terms; STD-P-DIF accumulates over the weaker "both groups - // present" gate (an all-correct/all-incorrect stratum contributes 0 to the difference but still - // carries focal weight in the Dorans-Kulick standardization). - let (mut sum_ad, mut sum_bc, mut sum_a, mut sum_e, mut sum_var) = (0.0, 0.0, 0.0, 0.0, 0.0); - let (mut spr, mut spsqr, mut sqs) = (0.0, 0.0, 0.0); - let (mut sum_w, mut sum_wdiff) = (0.0, 0.0); - let mut any_mh = false; - for m in 0..n_levels { - let (am, bm, cm, dm) = (a[m] as f64, b[m] as f64, c[m] as f64, d[m] as f64); - let n_rm = am + bm; - let n_fm = cm + dm; - if n_rm > 0.0 && n_fm > 0.0 { - // STD-P-DIF: focal-minus-reference proportion correct, focal-weighted. - sum_w += n_fm; - sum_wdiff += n_fm * (cm / n_fm - am / n_rm); - } - let m1 = am + cm; - let m0 = bm + dm; - let t = n_rm + n_fm; - if n_rm > 0.0 && n_fm > 0.0 && m1 > 0.0 && m0 > 0.0 { - // t >= 2 here (both group totals >= 1), so t - 1 >= 1 and Var(A_m) > 0. - any_mh = true; - let r = am * dm / t; - let s = bm * cm / t; - sum_ad += r; - sum_bc += s; - sum_a += am; - sum_e += n_rm * m1 / t; - sum_var += n_rm * n_fm * m1 * m0 / (t * t * (t - 1.0)); - let pp = (am + dm) / t; - let qq = (bm + cm) / t; - spr += pp * r; - spsqr += pp * s + qq * r; - sqs += qq * s; - } - } - - let (chi2, pval) = if any_mh && sum_var > 0.0 { - let num = ((sum_a - sum_e).abs() - 0.5).max(0.0); - let chi2 = num * num / sum_var; - (chi2, chi2_sf(chi2, 1.0)) - } else { - (f64::NAN, f64::NAN) - }; - // alpha_MH degenerate (0 or inf) when either running sum is 0 -> delta undefined. - let alpha = if sum_ad > 0.0 && sum_bc > 0.0 { - sum_ad / sum_bc - } else { - f64::NAN - }; - let d_dif = if alpha.is_finite() && alpha > 0.0 { - -DELTA_SCALE * alpha.ln() - } else { - f64::NAN - }; - // RBG denominators sum_ad (= sum R_m) and sum_bc (= sum S_m) are both > 0 exactly when alpha is - // finite and positive, so a finite d_dif always has a finite SE. - let se = if sum_ad > 0.0 && sum_bc > 0.0 { - let var_ln = spr / (2.0 * sum_ad * sum_ad) - + spsqr / (2.0 * sum_ad * sum_bc) - + sqs / (2.0 * sum_bc * sum_bc); - DELTA_SCALE * var_ln.sqrt() - } else { - f64::NAN - }; - let std_p = if sum_w > 0.0 { - sum_wdiff / sum_w - } else { - f64::NAN - }; - let ets_class = classify(d_dif, se, pval); - - MhItemStats { - alpha_mh: alpha, - chi2_mh: chi2, - p_value: pval, - mh_d_dif: d_dif, - se_d_dif: se, - std_p_dif: std_p, - ets_class, - } -} - -/// ETS A/B/C rule (Zieky, 1993). Undefined when the delta statistic could not be computed. -fn classify(d_dif: f64, se: f64, p: f64) -> EtsClass { - if !d_dif.is_finite() { - return EtsClass::Undefined; - } - let significant = p.is_finite() && p < ALPHA_SIG; - let abs_d = d_dif.abs(); - if !significant || abs_d < 1.0 { - EtsClass::A - } else if abs_d >= 1.5 && se.is_finite() && abs_d - Z_ONE_SIDED_05 * se > 1.0 { - EtsClass::C - } else { - EtsClass::B - } -} - -/// Shared input validation for the observed-score DIF entry points: shapes, the `0/1` response and -/// group domains, both groups present, and the FDR level. -fn validate_dif_inputs( - y: &[u8], - group: &[u8], - n_persons: usize, - n_items: usize, - cfg: &MhDifConfig, -) -> Result<(), String> { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - let cells = n_persons - .checked_mul(n_items) - .ok_or("n_persons * n_items overflow")?; - if cells > MAX_CELLS { - return Err(format!( - "n_persons * n_items = {cells} exceeds the cap {MAX_CELLS}" - )); - } - if y.len() != cells { - return Err(format!("y has {} entries; expected {cells}", y.len())); - } - if group.len() != n_persons { - return Err(format!( - "group has {} entries; expected {n_persons}", - group.len() - )); - } - if y.iter().any(|&v| v > 1) { - return Err("y responses must be 0 or 1".into()); - } - if group.iter().any(|&g| g > 1) { - return Err("group labels must be 0 (reference) or 1 (focal)".into()); - } - let (mut has_ref, mut has_focal) = (false, false); - for &g in group { - if g == 0 { - has_ref = true; - } else { - has_focal = true; - } - } - if !has_ref || !has_focal { - return Err("both a reference (0) and a focal (1) group must be present".into()); - } - if !cfg.fdr_q.is_finite() || cfg.fdr_q <= 0.0 || cfg.fdr_q > 1.0 { - return Err("fdr_q must be in (0, 1]".into()); - } - Ok(()) -} - -/// Mantel-Haenszel DIF sweep (Holland & Thayer, 1988) over the dichotomous items of a two-group sample. -/// -/// `y` is a row-major `n_persons * n_items` `0/1` response array; `group` is length `n_persons` with -/// `0` = reference and `1` = focal (both must be present). Every item is swept against the -/// total-score matching variable; Benjamini-Hochberg controls the FDR at `cfg.fdr_q`. Returns one -/// [`MhDifRow`] per item. -pub fn mantel_haenszel_dif( - y: &[u8], - group: &[u8], - n_persons: usize, - n_items: usize, - cfg: &MhDifConfig, -) -> Result, String> { - mh_sweep(y, group, n_persons, n_items, cfg, None) -} - -/// Per-person base score for the matching criterion: the full number-correct total when `anchor` is -/// `None` (the shipped behaviour), else the anchor-subtest total. -fn base_scores(y: &[u8], n_persons: usize, n_items: usize, anchor: Option<&[bool]>) -> Vec { - (0..n_persons) - .map(|p| { - (0..n_items) - .filter(|&j| anchor.map_or(true, |m| m[j])) - .map(|j| y[p * n_items + j] as usize) - .sum() - }) - .collect() -} - -/// Matching score for studied item `i` from its `base` (see [`base_scores`]). The criterion is the sum -/// over `anchor UNION {i}`: the studied item is added back only when it is NOT itself an anchor item, -/// so an anchor item is never double-counted. `exclude_studied_item` then removes it again, giving the -/// pure rest/anchor score. With `anchor = None` this reduces exactly to the shipped total-score rule. -#[inline] -fn matching_for_item(base: usize, yi: usize, in_anchor: bool, exclude_studied: bool) -> usize { - let mut s = base; - if !in_anchor { - s += yi; - } - if exclude_studied { - s -= yi; - } - s -} - -/// The Mantel-Haenszel sweep, optionally against a purified (anchor-only) matching criterion. -/// `anchor = None` reproduces [`mantel_haenszel_dif`] exactly. -fn mh_sweep( - y: &[u8], - group: &[u8], - n_persons: usize, - n_items: usize, - cfg: &MhDifConfig, - anchor: Option<&[bool]>, -) -> Result, String> { - validate_dif_inputs(y, group, n_persons, n_items, cfg)?; - - let base = base_scores(y, n_persons, n_items, anchor); - // Reusable per-item response and matching-level buffers. - let mut resp = vec![0u8; n_persons]; - let mut matching = vec![0usize; n_persons]; - // Any anchor-based score is bounded by n_items (an item added back was excluded from the anchor), - // and empty strata are skipped by the marginal gates in `mh_item_stats`, so one level count serves - // every item and every anchor. - let n_levels = n_items + 1; - - let mut rows: Vec = Vec::with_capacity(n_items); - for i in 0..n_items { - let in_anchor = anchor.map_or(true, |m| m[i]); - for p in 0..n_persons { - let yi = y[p * n_items + i]; - resp[p] = yi; - matching[p] = - matching_for_item(base[p], yi as usize, in_anchor, cfg.exclude_studied_item); - } - let st = mh_item_stats(&resp, group, &matching, n_levels); - rows.push(MhDifRow { - item: i, - alpha_mh: st.alpha_mh, - chi2_mh: st.chi2_mh, - p_value: st.p_value, - mh_d_dif: st.mh_d_dif, - se_d_dif: st.se_d_dif, - std_p_dif: st.std_p_dif, - ets_class: st.ets_class, - flagged_bh: false, - }); - } - - let pvals: Vec = rows.iter().map(|r| r.p_value).collect(); - let flags = benjamini_hochberg(&pvals, cfg.fdr_q); - for (r, &f) in rows.iter_mut().zip(&flags) { - r.flagged_bh = f; - } - Ok(rows) -} - -// ===================== Zumbo (1999) logistic regression DIF ========================== -// -// The logistic-regression DIF procedure (Swaminathan & Rogers, 1990; Zumbo, 1999) regresses the item -// response on the observed matching score, the group, and their interaction, in three NESTED models: -// -// ```text -// M0: logit P(Y=1) = b0 + b1 S -// M1: logit P(Y=1) = b0 + b1 S + b2 G (adds the group main effect) -// M2: logit P(Y=1) = b0 + b1 S + b2 G + b3 (S x G) (adds the interaction) -// ``` -// -// The 2-df omnibus `chi2_total = 2[ll(M2) - ll(M0)]` is the PRIMARY Swaminathan-Rogers / Zumbo DIF -// decision. The 1-df components are DESCRIPTIVE follow-ups: `chi2_nonuniform = 2[ll(M2) - ll(M1)]` is -// the unambiguous test of the interaction `b3`, but `chi2_uniform = 2[ll(M1) - ll(M0)]` tests `b2` in a -// model that ASSUMES `b3 = 0` — it is *not* the test of the group term in the full model M2, and it is -// not interpretable as "uniform DIF" when non-uniform DIF is present (under a crossing item, M1 is -// misspecified and `b2` absorbs a data-dependent mixture of the two effects). The hierarchical entry -// order `S -> G -> S x G` is therefore load-bearing: reversing it changes the uniform component. -// Benjamini-Hochberg is applied to the omnibus `p_total` only; the component p-values are unadjusted. -// -// Effect size: the Nagelkerke (1991) pseudo-`R^2` change `delta_r2 = R2_N(M2) - R2_N(M0)`, with -// `R2_CS(M) = 1 - exp(2(ll_null - ll(M))/n)` and `R2_N(M) = R2_CS(M) / (1 - exp(2 ll_null / n))` -// (`ll_null` is the INTERCEPT-ONLY fit, not M0). Items are classified by the Jodoin & Gierl (2001) -// thresholds on that quantity — A (negligible) `< 0.035`, B `0.035..0.070`, C (large) `>= 0.070` — and -// only when the omnibus test is BH-significant (a non-significant item is A by definition). The older -// Zumbo & Thomas (1997) cut-offs (`0.13` / `0.26`) are considerably more conservative on the same -// quantity. The uniform-only `delta_r2_uniform` is reported as an UNCALIBRATED descriptive number and -// carries no letter class: the Jodoin-Gierl cut-offs were calibrated on the 2-df quantity. -// -// Caveats, same as the Mantel-Haenszel path above: the studied item is INCLUDED in the matching score -// by default, and this entry point does no purification, so its criterion carries the same -// contamination (see `logistic_dif_purified` and the purification notes further down). -// Logistic-regression DIF additionally assumes the logit is LINEAR in the matching score — curvature in -// the true regression, or group differences in the score distribution interacting with that curvature, -// can be absorbed by the `S x G` term, so a non-uniform flag is not by itself evidence of crossing ICCs. -// -// # References (APA 7th ed.) -// -// Jodoin, M. G., & Gierl, M. J. (2001). Evaluating Type I error and power rates using an effect size -// measure with the logistic regression procedure for DIF detection. *Applied Measurement in -// Education, 14*(4), 329-349. https://doi.org/10.1207/S15324818AME1404_2 -// Nagelkerke, N. J. D. (1991). A note on a general definition of the coefficient of determination. -// *Biometrika, 78*(3), 691-692. https://doi.org/10.1093/biomet/78.3.691 -// Swaminathan, H., & Rogers, H. J. (1990). Detecting differential item functioning using logistic -// regression procedures. *Journal of Educational Measurement, 27*(4), 361-370. -// https://doi.org/10.1111/j.1745-3984.1990.tb00754.x -// Zumbo, B. D. (1999). *A handbook on the theory and methods of differential item functioning (DIF)*. -// Directorate of Human Resources Research and Evaluation, Department of National Defense. -// Zumbo, B. D., & Thomas, D. R. (1997). *A measure of effect size for a model-based approach for -// studying DIF*. Prince George, Canada: University of Northern British Columbia, Edgeworth -// Laboratory for Quantitative Behavioral Science. - -/// Jodoin & Gierl (2001) `delta_r2` boundary between negligible (A) and moderate (B) DIF. -pub const JG_MODERATE: f64 = 0.035; -/// Jodoin & Gierl (2001) `delta_r2` boundary between moderate (B) and large (C) DIF. -pub const JG_LARGE: f64 = 0.070; -/// Coefficient-magnitude bound; exceeding it signals (quasi-)separation rather than a real fit. -const LOGIT_COEF_BOUND: f64 = 30.0; -/// Minimum persons for a logistic DIF fit: the full model M2 has four parameters, and the conventional -/// floor of ~5 observations per parameter keeps small-sample separation from masquerading as a result. -const MIN_LOGIT_N: usize = 20; - -/// One studied item's logistic-regression DIF result. All statistics are `NaN` and `converged` is -/// `false` when the item's nested fits failed (separation, a rank-deficient design, or no convergence); -/// such an item is never BH-flagged and is classified `Undefined`. -pub struct LogisticDifRow { - pub item: usize, - /// `2[ll(M1) - ll(M0)]`, `chi^2(1)`. DESCRIPTIVE: tests `b2` assuming `b3 = 0`. - pub chi2_uniform: f64, - /// Unadjusted upper-tail `p` for `chi2_uniform`. - pub p_uniform: f64, - /// `2[ll(M2) - ll(M1)]`, `chi^2(1)`: the test of the interaction `b3` (non-uniform DIF). - pub chi2_nonuniform: f64, - /// Unadjusted upper-tail `p` for `chi2_nonuniform`. - pub p_nonuniform: f64, - /// `2[ll(M2) - ll(M0)]`, `chi^2(2)`: the PRIMARY omnibus DIF test. - pub chi2_total: f64, - /// Upper-tail `p` for `chi2_total` (the value Benjamini-Hochberg adjusts). - pub p_total: f64, - /// Nagelkerke `R2_N(M2) - R2_N(M0)`: the Zumbo (1999) DIF effect size. - pub delta_r2: f64, - /// Nagelkerke `R2_N(M1) - R2_N(M0)`: UNCALIBRATED descriptive value, carries no letter class. - pub delta_r2_uniform: f64, - /// Jodoin & Gierl (2001) A/B/C class on `delta_r2`; forced to `A` when the omnibus test is not - /// BH-significant, and `Undefined` when the fits failed. - pub jg_class: EtsClass, - /// Benjamini-Hochberg rejection on `p_total` across the swept items. - pub flagged_bh: bool, - /// `true` only if all four nested fits converged. - pub converged: bool, -} - -/// Configuration for [`logistic_dif`]. -#[derive(Clone, Copy)] -pub struct LogisticDifConfig { - /// Match on the rest score (studied item excluded) instead of the item-included total. - pub exclude_studied_item: bool, - /// Benjamini-Hochberg FDR level applied to `p_total`. - pub fdr_q: f64, - /// Maximum IRLS/Newton iterations per nested model. - pub max_iter: usize, -} - -impl Default for LogisticDifConfig { - fn default() -> Self { - Self { - exclude_studied_item: false, - fdr_q: 0.05, - max_iter: 50, - } - } -} - -/// Logistic log-likelihood `sum_p [y log sigmoid(eta) + (1-y) log sigmoid(-eta)]`, computed with the -/// stable `log_sigmoid` so a separated fit saturates smoothly instead of producing `NaN`. -fn logit_loglik(x: &[f64], y: &[f64], n: usize, m: usize, b: &[f64]) -> f64 { - let mut ll = 0.0; - for p in 0..n { - let mut eta = 0.0; - for c in 0..m { - eta += x[p * m + c] * b[c]; - } - ll += if y[p] > 0.5 { - log_sigmoid(eta) - } else { - log_sigmoid(-eta) - }; - } - ll -} - -/// IRLS / Newton logistic regression with step-halving. `x` is the row-major `n x m` design, `init` the -/// warm start. Returns `(coefficients, loglik)`, or `None` on a singular information matrix, a -/// coefficient blow-up (separation), or failure to converge within `max_iter`. -fn logit_fit( - x: &[f64], - y: &[f64], - n: usize, - m: usize, - init: &[f64], - max_iter: usize, -) -> Option<(Vec, f64)> { - let mut b = init.to_vec(); - let mut ll = logit_loglik(x, y, n, m, &b); - if !ll.is_finite() { - return None; - } - let mut ll_prev = f64::NEG_INFINITY; - for _ in 0..max_iter { - // score X'(y - mu) and information X'WX (the NEGATIVE Hessian, positive definite) - let mut grad = vec![0.0f64; m]; - let mut info = vec![vec![0.0f64; m]; m]; - for p in 0..n { - let mut eta = 0.0; - for c in 0..m { - eta += x[p * m + c] * b[c]; - } - let mu = sigmoid_stable(eta); - let w = mu * (1.0 - mu); - let r = y[p] - mu; - for a in 0..m { - let xa = x[p * m + a]; - grad[a] += xa * r; - for c in 0..m { - info[a][c] += w * xa * x[p * m + c]; - } - } - } - // Newton ascent: b += (X'WX)^{-1} X'(y - mu); break (never fall back to a gradient step) on a - // singular information matrix, which is what separation produces. - let step = solve_small_checked(info, grad)?; - let mut scale = 1.0f64; - let mut advanced = false; - for _ in 0..40 { - let cand: Vec = (0..m).map(|c| b[c] + scale * step[c]).collect(); - if cand - .iter() - .any(|v| !v.is_finite() || v.abs() > LOGIT_COEF_BOUND) - { - scale *= 0.5; - continue; - } - let ll_c = logit_loglik(x, y, n, m, &cand); - if ll_c.is_finite() && ll_c >= ll - 1e-12 { - b = cand; - ll = ll_c; - advanced = true; - break; - } - scale *= 0.5; - } - if !advanced { - return None; // could not ascend: (quasi-)separation - } - // Standard GLM convergence on the RELATIVE log-likelihood change. A gradient tolerance cannot be - // used here: near the optimum the per-step likelihood gain falls below the f64 resolution of - // `ll` itself, so the attainable score floor is ~sqrt(info * eps * |ll|) — for n in the - // thousands that is O(1e-5), far above any absolute threshold worth calling "converged". - if (ll - ll_prev).abs() <= 1e-10 * (ll.abs() + 0.1) { - // ... but a fit pinned against the coefficient bound is SEPARATED, not converged: its - // likelihood also stops changing as the coefficients run away, so the relative criterion - // alone would certify a non-existent MLE. - if b.iter().any(|v| v.abs() >= 0.99 * LOGIT_COEF_BOUND) { - return None; - } - return Some((b, ll)); - } - ll_prev = ll; - } - None // iterations exhausted without meeting the convergence criterion -} - -/// Per-item nested-model statistics (before the BH-dependent classification). -struct LogitStats { - chi2_uniform: f64, - chi2_nonuniform: f64, - chi2_total: f64, - delta_r2: f64, - delta_r2_uniform: f64, - converged: bool, -} - -const LOGIT_UNDEFINED: LogitStats = LogitStats { - chi2_uniform: f64::NAN, - chi2_nonuniform: f64::NAN, - chi2_total: f64::NAN, - delta_r2: f64::NAN, - delta_r2_uniform: f64::NAN, - converged: false, -}; - -/// Fit the four nested models (intercept-only null, M0, M1, M2) for one item on ONE identical -/// person subsample, warm-starting each from the previous fit so the nested log-likelihoods are -/// monotone, and return the LR components and Nagelkerke effect sizes. -fn logistic_item_stats( - resp: &[f64], - score: &[f64], - group: &[f64], - n: usize, - max_iter: usize, -) -> LogitStats { - // M2 has four parameters. At `n` barely above that the data are (quasi-)separated with high - // probability, and a separated fit that happens to terminate would be reported as a converged, - // significant result; require the conventional minimum of ~5 observations per parameter. - if n < MIN_LOGIT_N { - return LOGIT_UNDEFINED; - } - // An item with no response variation (everyone correct or everyone incorrect) carries no DIF - // information at all: every model is separated, the intercept diverges, and `ll_null -> 0` makes the - // Nagelkerke normalizer degenerate. Reject it up front rather than letting a bounded, half-converged - // separated fit produce a meaningful-looking result. - let n_correct = resp.iter().filter(|&&v| v > 0.5).count(); - if n_correct == 0 || n_correct == n { - return LOGIT_UNDEFINED; - } - // Mean-center the matching score: the chi-squares are invariant to this affine reparameterization, - // but the raw total puts the S x G column on a J^2 scale against the ones-column and leaves the - // 4 x 4 Gram near-singular. - let sbar = score.iter().sum::() / n as f64; - let mut x = vec![0.0f64; n * 4]; - for p in 0..n { - let s = score[p] - sbar; - x[p * 4] = 1.0; - x[p * 4 + 1] = s; - x[p * 4 + 2] = group[p]; - x[p * 4 + 3] = s * group[p]; - } - // Design rank check on the full M2 design (constant score column, a group with no variation, or an - // S x G column collinear with G all show up here) rather than relying on the solver failing later. - let mut gram = vec![vec![0.0f64; 4]; 4]; - let mut maxg = 0.0f64; - for a in 0..4 { - for c in 0..4 { - let mut s = 0.0; - for p in 0..n { - s += x[p * 4 + a] * x[p * 4 + c]; - } - gram[a][c] = s; - maxg = maxg.max(s.abs()); - } - } - if !gram_full_rank(&mut gram, 4, 1e-9 * maxg.max(1e-300)) { - return LOGIT_UNDEFINED; - } - // Column-sliced designs for the nested models (M0/M1/M2 take the first 2/3/4 columns). - let sub = |m: usize| -> Vec { - let mut d = vec![0.0f64; n * m]; - for p in 0..n { - d[p * m..(p + 1) * m].copy_from_slice(&x[p * 4..p * 4 + m]); - } - d - }; - let (x1, x2c, x3, x4) = (sub(1), sub(2), sub(3), sub(4)); - let warm = |prev: &[f64], m: usize| -> Vec { - let mut v = vec![0.0f64; m]; - v[..prev.len()].copy_from_slice(prev); - v - }; - let (b_null, ll_null) = match logit_fit(&x1, resp, n, 1, &[0.0], max_iter) { - Some(r) => r, - None => return LOGIT_UNDEFINED, - }; - let (b0, ll0) = match logit_fit(&x2c, resp, n, 2, &warm(&b_null, 2), max_iter) { - Some(r) => r, - None => return LOGIT_UNDEFINED, - }; - let (b1, ll1) = match logit_fit(&x3, resp, n, 3, &warm(&b0, 3), max_iter) { - Some(r) => r, - None => return LOGIT_UNDEFINED, - }; - let (_b2, ll2) = match logit_fit(&x4, resp, n, 4, &warm(&b1, 4), max_iter) { - Some(r) => r, - None => return LOGIT_UNDEFINED, - }; - // Nagelkerke normalizer: 1 - exp(2 ll_null / n). An item answered identically by everyone has - // ll_null = 0, making this 0 and R2_CS a 0/0 - report undefined rather than a spurious 0. - // A successful intercept-only fit contains both response classes. Together - // with the public cell cap, that makes this Nagelkerke normalizer strictly - // positive and bounded away from floating-point zero. - let denom = 1.0 - (2.0 * ll_null / n as f64).exp(); - let r2n = |ll: f64| (1.0 - (2.0 * (ll_null - ll) / n as f64).exp()) / denom; - LogitStats { - chi2_uniform: (2.0 * (ll1 - ll0)).max(0.0), - chi2_nonuniform: (2.0 * (ll2 - ll1)).max(0.0), - chi2_total: (2.0 * (ll2 - ll0)).max(0.0), - delta_r2: r2n(ll2) - r2n(ll0), - delta_r2_uniform: r2n(ll1) - r2n(ll0), - converged: true, - } -} - -/// Jodoin & Gierl (2001) classification, conditional on omnibus significance. -fn jg_classify(delta_r2: f64, significant: bool) -> EtsClass { - if !delta_r2.is_finite() { - return EtsClass::Undefined; - } - if !significant { - return EtsClass::A; // non-significant => negligible by definition - } - if delta_r2 >= JG_LARGE { - EtsClass::C - } else if delta_r2 >= JG_MODERATE { - EtsClass::B - } else { - EtsClass::A - } -} - -/// Zumbo (1999) logistic-regression DIF sweep over the dichotomous items of a two-group sample. -/// -/// Unlike [`mantel_haenszel_dif`], which is a stratified odds-ratio test sensitive only to UNIFORM DIF, -/// this procedure separates uniform from NON-UNIFORM (crossing) DIF through the `score x group` -/// interaction. `y` is a row-major `n_persons * n_items` `0/1` array; `group` is length `n_persons` -/// with `0` = reference and `1` = focal. Returns one [`LogisticDifRow`] per item; see the module notes -/// above for the interpretation of the 1-df components and the effect-size classification. -pub fn logistic_dif( - y: &[u8], - group: &[u8], - n_persons: usize, - n_items: usize, - cfg: &LogisticDifConfig, -) -> Result, String> { - logistic_sweep(y, group, n_persons, n_items, cfg, None) -} - -/// The logistic-regression sweep, optionally against a purified (anchor-only) matching criterion. -/// `anchor = None` reproduces [`logistic_dif`] exactly. -fn logistic_sweep( - y: &[u8], - group: &[u8], - n_persons: usize, - n_items: usize, - cfg: &LogisticDifConfig, - anchor: Option<&[bool]>, -) -> Result, String> { - if cfg.max_iter == 0 { - return Err("max_iter must be >= 1".into()); - } - let mh_cfg = MhDifConfig { - exclude_studied_item: cfg.exclude_studied_item, - fdr_q: cfg.fdr_q, - }; - validate_dif_inputs(y, group, n_persons, n_items, &mh_cfg)?; - - let base = base_scores(y, n_persons, n_items, anchor); - let gf: Vec = group.iter().map(|&g| g as f64).collect(); - let mut resp = vec![0.0f64; n_persons]; - let mut score = vec![0.0f64; n_persons]; - - let mut rows: Vec = Vec::with_capacity(n_items); - for i in 0..n_items { - let in_anchor = anchor.map_or(true, |m| m[i]); - for p in 0..n_persons { - let yi = y[p * n_items + i]; - resp[p] = yi as f64; - score[p] = - matching_for_item(base[p], yi as usize, in_anchor, cfg.exclude_studied_item) as f64; - } - let st = logistic_item_stats(&resp, &score, &gf, n_persons, cfg.max_iter); - // A failed fit must yield a NaN p-value, NOT 1.0. `chi2_sf` maps a NaN statistic to 1.0 - // (`f64::max` ignores NaN, so `NaN.max(0.0) == 0.0` and the survival function is 1 there), which - // would both contradict the NaN contract and — because 1.0 is finite — make Benjamini-Hochberg - // COUNT the unfittable item in `m`, shrinking the threshold and costing power on real DIF items. - let sf = |c: f64, df: f64| { - if c.is_finite() { - chi2_sf(c, df) - } else { - f64::NAN - } - }; - rows.push(LogisticDifRow { - item: i, - chi2_uniform: st.chi2_uniform, - p_uniform: sf(st.chi2_uniform, 1.0), - chi2_nonuniform: st.chi2_nonuniform, - p_nonuniform: sf(st.chi2_nonuniform, 1.0), - chi2_total: st.chi2_total, - p_total: sf(st.chi2_total, 2.0), - delta_r2: st.delta_r2, - delta_r2_uniform: st.delta_r2_uniform, - jg_class: EtsClass::Undefined, - flagged_bh: false, - converged: st.converged, - }); - } - // BH on the omnibus p only; NaN p-values (failed fits) are skipped by benjamini_hochberg. - let pvals: Vec = rows.iter().map(|r| r.p_total).collect(); - let flags = benjamini_hochberg(&pvals, cfg.fdr_q); - for (r, &f) in rows.iter_mut().zip(&flags) { - r.flagged_bh = f; - r.jg_class = jg_classify(r.delta_r2, f); - } - Ok(rows) -} - -// ===================== Iterative item purification ========================== -// -// Both sweeps above match on the number-correct total, which CONTAINS the items being tested. Items -// with DIF therefore contaminate the matching criterion, inflating the Type I error rate for clean -// items and attenuating power for genuine ones. Purification rebuilds the criterion from the currently -// UNFLAGGED (anchor) items and re-runs the sweep, iterating until the flagged set stops changing: -// Candell and Drasgow (1988) proposed iterating to stability, while the single-pass "two-stage" -// procedure traces to Lord (1980) and Holland and Thayer's (1988) recommendation of one re-run. Gains -// past the first round or two are small (Clauser, Mazor & Hambleton, 1993; Fidalgo, Mellenbergh & -// Muniz, 2000), hence a small default round cap. -// -// The criterion for a studied item is the sum over `anchor UNION {studied}` — item-included matching is -// what makes the null-DIF condition hold (Holland & Thayer, 1988; Zwick, 1990) — so a flagged item is -// removed from every OTHER item's criterion but still scored against itself plus the anchor. -// -// IMPORTANT LIMITS, none of which purification removes: -// -// - The final anchor is SELECTED FROM THE SAME DATA that is then tested against it, so the reported -// p-values are conditional on a data-dependent selection. They are not guaranteed super-uniform under -// the null, and Benjamini-Hochberg does NOT control the FDR at `fdr_q` for a purified sweep. Purified -// flags are a SCREENING device, not an error-rate guarantee. -// - Purification REDUCES rather than removes contamination, and can fail outright when DIF is -// unbalanced in direction (Wang & Su, 2004): anchor quality dominates. -// - Mantel-Haenszel's blind spot for crossing DIF is inherited, and purification cannot repair it: an -// item MH does not flag stays in the anchor every round and keeps contaminating the "purified" -// criterion. The blindness is NOT a property of non-uniform DIF as such but of the signed area -// between the two curves over the matched ability distribution (Wang & Su, 2004): a crossing at the -// centre of that distribution cancels and is invisible, while the same item with its crossing off -// centre leaves a net signed difference that MH detects (empirically: `a_ref = 2.0` vs `a_foc = 0.4` -// with equal `b`, standard-normal ability in both groups, crossing at `theta = 0` gives `D-DIF` about -// 0.00 and class `A`; moving the crossing to `theta = +0.8` gives `D-DIF` about +1.80 and class `C`). -// So MH purification is unreliable, not uniformly blind, whenever non-uniform DIF is plausible — use -// the logistic variant, whose interaction term tests the crossing directly. -// - The two procedures do not inherit matched Type I control: `MhDifRow::ets_class` is conditioned on -// the raw .05 MH p-value while `LogisticDifRow::jg_class` is conditioned on the BH flag. -// -// # References (APA 7th ed.) -// -// Candell, G. L., & Drasgow, F. (1988). An iterative procedure for linking metrics and assessing item -// bias in item response theory. *Applied Psychological Measurement, 12*(3), 253-260. -// https://doi.org/10.1177/014662168801200304 -// Clauser, B., Mazor, K., & Hambleton, R. K. (1993). The effects of purification of the matching -// criterion on the identification of DIF using the Mantel-Haenszel procedure. *Applied Measurement -// in Education, 6*(4), 269-279. https://doi.org/10.1207/s15324818ame0604_2 -// Fidalgo, A. M., Mellenbergh, G. J., & Muniz, J. (2000). Effects of amount of DIF, test length, and -// purification type on robustness and power of Mantel-Haenszel procedures. *Methods of Psychological -// Research Online, 5*(3), 43-53. -// Lord, F. M. (1980). *Applications of item response theory to practical testing problems*. Erlbaum. -// Zwick, R. (1990). When do item response function and Mantel-Haenszel definitions of differential item -// functioning coincide? *Journal of Educational Statistics, 15*(3), 185-197. -// https://doi.org/10.3102/10769986015003185 -// Wang, W.-C., & Su, Y.-H. (2004). Effects of average signed area between two item characteristic -// curves and test purification procedures on the DIF detection via the Mantel-Haenszel method. -// *Applied Measurement in Education, 17*(2), 113-144. -// https://doi.org/10.1207/s15324818ame1702_2 - -/// Configuration for the purification loop wrapping a DIF sweep. -#[derive(Clone, Copy)] -pub struct PurifyConfig { - /// Maximum purification rounds after the initial full-test sweep. Gains past one or two rounds are - /// small, so this is deliberately small rather than open-ended. - pub max_rounds: usize, - /// Minimum anchor items required to keep purifying. A short number-correct criterion is coarse and - /// unreliable, which itself inflates Mantel-Haenszel Type I error (Donoghue, Holland & Thayer, - /// 1993); there is no canonical minimum in the literature, so this is a guard, not a standard. - pub min_anchor_items: usize, -} - -impl Default for PurifyConfig { - fn default() -> Self { - Self { - max_rounds: 3, - min_anchor_items: 4, - } - } -} - -/// Outcome of a purified DIF sweep: the final per-item rows plus what the purification actually did. -/// -/// The `p_value`/`flagged_bh` fields of `rows` are conditional on `anchor`, which was selected from the -/// same data — see the module notes: they do NOT carry an FDR guarantee and are a screening device. -pub struct PurifiedDif { - /// Per-item rows from the final sweep (against the `anchor` criterion below). - pub rows: Vec, - /// The anchor mask the final rows were computed against (`true` = used in the matching criterion). - pub anchor: Vec, - /// Anchor items in `anchor`. - pub n_anchor: usize, - /// Purification rounds performed after the initial full-test sweep (`0` = no purification applied). - pub rounds: usize, - /// `true` when the flagged set stabilised; `false` when the round cap was hit (including an - /// oscillating flag set) or purification stopped on the anchor guards, in which case `rows` are - /// simply the last computed round. - pub converged: bool, - /// `stable_flag_set`, `max_rounds_reached`, or `insufficient_anchor_items`. - pub termination_reason: &'static str, -} - -/// Generic purification loop. `sweep` runs a DIF sweep against an anchor mask (`None` = the whole -/// test); `is_flagged` decides which rows are removed from the next round's criterion. -/// -/// Round 0 passes `None`, i.e. it dispatches to the *same* code path as the unpurified entry point -/// rather than to an all-`true` mask that merely evaluates the same way. That is also why nothing is -/// sized from a caller-supplied item count before the first sweep: `n_items` is untrusted at the FFI -/// boundary, and every dimension check lives inside the sweep. The anchor mask is allocated from the -/// returned row count, so it can only be as large as a validated sweep. -fn purify_loop( - cfg: &PurifyConfig, - mut sweep: impl FnMut(Option<&[bool]>) -> Result, String>, - is_flagged: impl Fn(&R) -> bool, -) -> Result, String> { - if cfg.max_rounds == 0 { - return Err("max_rounds must be >= 1".into()); - } - let mut rows = sweep(None)?; - let mut flagged: Vec = rows.iter().map(&is_flagged).collect(); - let mut anchor = vec![true; flagged.len()]; - - let mut rounds = 0usize; - let mut converged = false; - let mut termination_reason = "max_rounds_reached"; - while rounds < cfg.max_rounds { - let next: Vec = flagged.iter().map(|&f| !f).collect(); - if next == anchor { - converged = true; // flagged set stable: the criterion would not change - termination_reason = "stable_flag_set"; - break; - } - let n_anchor = next.iter().filter(|&&a| a).count(); - // Guard BEFORE sweeping: never match on an empty or uselessly short criterion. A constant - // anchor total would also put everyone in one stratum. - // ponytail: a 1-2 item anchor is equally meaningless but only the configured floor is checked. - if n_anchor < cfg.min_anchor_items.max(1) { - termination_reason = "insufficient_anchor_items"; - break; // keep the last usable rows; converged stays false - } - anchor = next; - rows = sweep(Some(&anchor))?; - rounds += 1; - let new_flagged: Vec = rows.iter().map(&is_flagged).collect(); - if new_flagged == flagged { - converged = true; - termination_reason = "stable_flag_set"; - break; - } - flagged = new_flagged; - } - let n_anchor = anchor.iter().filter(|&&a| a).count(); - Ok(PurifiedDif { - rows, - anchor, - n_anchor, - rounds, - converged, - termination_reason, - }) -} - -/// An item is removed from the purified criterion only on PRACTICAL significance (`B` or `C`). -/// Deliberately not `class != A`: `Undefined` is also `!= A`, and an unfittable item carries no evidence -/// of DIF, so purging it would shrink the anchor for free. Deliberately not the raw BH flag either: the -/// Mantel-Haenszel chi-square is over-powered at large N (see [`EtsClass`], whose whole purpose is that -/// practical-significance screen), which is exactly the regime where purification matters. -#[inline] -fn purify_flagged(class: EtsClass) -> bool { - matches!(class, EtsClass::B | EtsClass::C) -} - -/// [`mantel_haenszel_dif`] with an iteratively purified matching criterion (see the module notes on -/// purification, including why the resulting p-values carry no FDR guarantee). -pub fn mantel_haenszel_dif_purified( - y: &[u8], - group: &[u8], - n_persons: usize, - n_items: usize, - cfg: &MhDifConfig, - purify: &PurifyConfig, -) -> Result, String> { - purify_loop( - purify, - |anchor| mh_sweep(y, group, n_persons, n_items, cfg, anchor), - |r: &MhDifRow| purify_flagged(r.ets_class), - ) -} - -/// [`logistic_dif`] with an iteratively purified matching criterion. The purification flag is taken -/// from `jg_class`, i.e. from the 2-df omnibus test that Benjamini-Hochberg already targets. -pub fn logistic_dif_purified( - y: &[u8], - group: &[u8], - n_persons: usize, - n_items: usize, - cfg: &LogisticDifConfig, - purify: &PurifyConfig, -) -> Result, String> { - purify_loop( - purify, - |anchor| logistic_sweep(y, group, n_persons, n_items, cfg, anchor), - |r: &LogisticDifRow| purify_flagged(r.jg_class), - ) -} - -// ============================ SIBTEST (uniform) ============================== -// -// The third observed-score DIF procedure in this module, and the only one that corrects the MATCHING -// CRITERION ITSELF rather than the comparison built on top of it. -// -// Mantel-Haenszel and the logistic sweep both match on an observed number-correct score. That score is -// unreliable, and under IMPACT (a real group difference in ability) two examinees from different groups -// with the same OBSERVED score do not have the same EXPECTED TRUE score — each regresses toward its own -// group mean. Matching on the raw observed score therefore compares non-equivalent examinees and -// produces DIF statistics for items that have none. Item purification cannot repair this: the defect is -// the regression of true score on observed score, not which items are in the sum, so a perfectly -// purified criterion is still biased. SIBTEST transports each group's conditional mean from its own -// estimated true score to a common target before comparing, which is the entire point of the procedure. -// -// The correction, per retained matching level `k` (`R` = reference/group 0, `F` = focal/group 1): -// -// - `V*_Gk = [Xbar_G + alpha_G (k - Xbar_G)] / n_valid` — Kelley's regressed estimate of group `G`'s -// true valid-subtest score at observed level `k`, with `alpha_G` that group's coefficient alpha -// (KR-20) on the valid subtest and `Xbar_G` its mean valid score. -// - `V*_k = (V*_Rk + V*_Fk) / 2` — the common target, an UNWEIGHTED midpoint. -// - `M_Gj = (Ybar_G[j+1] - Ybar_G[j-1]) / (V*_G[j+1] - V*_G[j-1])` — a per-level central difference over -// the group's OWN true-score scale, taken at adjacent OBSERVED level positions (not at `k +/- 1`: -// levels with no examinees are absent, so arithmetic on `k` would silently use the wrong spacing). -// - `Ybar*_Gk = Ybar_Gk + M_Gj (V*_k - V*_Gk)` — BOTH endpoints of the transport are true-score -// quantities. Subtracting the OBSERVED mean here instead would collapse the whole correction to -// `(M_R - M_F)(V*_k - k)`, which vanishes exactly in the null-DIF-with-impact case the method exists -// to fix. -// - `beta_uni = sum_k p_k (Ybar*_Rk - Ybar*_Fk)` with `p_k` the COMBINED-sample proportion at level `k`, -// renormalized over the retained strata. -// - `se_beta = sqrt(sum_k p_k^2 [s2_Fk/J_Fk + s2_Rk/J_Rk])`, `b_uni = beta_uni / se_beta`, referred to -// `chi^2(1)` as `b_uni^2` (identical to the two-sided normal test, and reuses `chi2_sf`). -// -// SIGN, and it is the OPPOSITE of the rest of this module: `beta_uni > 0` means the item is HARDER FOR -// THE FOCAL GROUP, because the estimator is reference-minus-focal. `mh_d_dif` and `std_p_dif` above are -// focal-oriented and go NEGATIVE in that same situation. The orientation is kept rather than harmonised -// because published `|beta_uni|` cut-offs assume it; a differently-signed quantity carrying the name -// `beta_uni` would be the larger error. Cross-method comparisons must flip one of the two. -// -// WHEN TO PREFER IT — and the honest answer is "rarely, on the evidence measured here". This -// implementation was compared against `mantel_haenszel_dif` on the same simulated data, 500 -// replications per cell, 2PL, no DIF planted, so every rejection is a false positive. These are the -// exact cells `sibtest_type_i_error_exceeds_mantel_haenszel_under_impact` runs and the rates it -// prints, so the table is regenerable from the repo rather than quoted from a vanished study: -// -// impact n(per group) items MH Type I SIBTEST Type I -// 0.0 1000 5 .044 .056 -// 1.0 1000 5 .046 .086 -// -// SIBTEST's Type I error is ABOVE nominal in both cells and roughly DOUBLE Mantel-Haenszel's under -// impact — the opposite of the ordering one might expect from the motivation above. The cause is the -// next bullet: the correction is estimated but its variance is not propagated, so `se_beta` is -// optimistic and the correction's own noise is charged to the signal. This is a property of the 1993 -// estimator rather than a transcription slip — the closed-form anchors reproduce the TRANSCRIBED -// FORMULAS in exact rational arithmetic (`mirt` itself was never executed; see the provenance note -// below) — and it is precisely what Jiang and Stout's (1998) paper, titled "Improved Type I error -// control and reduced estimation bias for DIF detection using SIBTEST", was written to fix. Prefer -// `mantel_haenszel_dif` or `logistic_dif` for routine screening. Reach for this when you specifically -// want the regression-corrected estimand, and read `beta_uni` as an effect size rather than trusting -// `p_value` as a calibrated test. -// -// KNOWN LIMITATIONS, none of them silent: -// - `se_beta` treats the regression correction as FIXED. It is estimated (through `alpha_G` and the -// local slopes) and that estimation error is NOT propagated, so the standard error is optimistic and -// the test over-rejects — see the measured table above. -// - The shipped correction is the single linear one. Jiang and Stout's (1998) two-segment piecewise -// correction is a different, later estimator and is not implemented. -// - No guessing correction. -// - Dichotomous only (`validate_dif_inputs` rejects responses above 1). -// - No effect-size letter class. Published cut-offs disagree across sources and none was verified -// against its primary text, so the raw effect size ships uncalibrated — the same decision already -// taken for `delta_r2_uniform` above. -// - No purified variant. `purify_loop` composes with this in a few lines, but purification needs a -// PRACTICAL-significance predicate and there is no verified cut-off to build one from; flagging on -// the BH flag alone would contradict this module's own reasoning that the test is over-powered at -// large N. Purification would also shorten the valid subtest, lowering the very `alpha_G` the -// correction divides by. Deliberately omitted, not overlooked. -// - Crossing (non-uniform) DIF is NOT covered. Crossing-SIBTEST was evaluated and deliberately not -// built: Chalmers (2018) shows Li and Stout's (1996) hypothesis test is insufficient, and no -// normal-theory referral for a crossing statistic is valid. Use `logistic_dif`, whose `S x G` -// interaction tests crossing directly against a standard 1-df null. -// -// PROVENANCE OF THE FORMULAS ABOVE — stated plainly because it bounds what this code may claim. Every -// equation is transcribed from the reference implementation (Chalmers, 2012; the `SIBTEST` routine of -// the `mirt` package), which attributes them to Shealy and Stout (1993). THE PRIMARY TEXT WAS NOT -// CONSULTED, and neither was `mirt` EXECUTED: the transcription was made by reading its source. The -// closed-form anchors therefore verify this code against the transcribed formulas, NOT against `mirt` -// output — no cross-implementation agreement is claimed anywhere, and the empty-neighbour divergence -// below means exact agreement would not hold on all inputs even if it were tested. Where this -// implementation diverges from that reference it is marked in the code. -// -// # References (APA 7th ed.) -// -// Chalmers, R. P. (2012). mirt: A multidimensional item response theory package for the R environment. -// *Journal of Statistical Software, 48*(6), 1-29. https://doi.org/10.18637/jss.v048.i06 -// Chalmers, R. P. (2018). Improving the crossing-SIBTEST statistic for detecting non-uniform DIF. -// *Psychometrika, 83*(2), 376-386. https://doi.org/10.1007/s11336-017-9583-8 -// DeMars, C. E. (2009). Modification of the Mantel-Haenszel and logistic regression DIF procedures to -// incorporate the SIBTEST regression correction. *Journal of Educational and Behavioral Statistics, -// 34*(2), 149-170. https://doi.org/10.3102/1076998607313923 -// Jiang, H., & Stout, W. (1998). Improved Type I error control and reduced estimation bias for DIF -// detection using SIBTEST. *Journal of Educational and Behavioral Statistics, 23*(4), 291-322. -// https://doi.org/10.3102/10769986023004291 -// Li, H.-H., & Stout, W. (1996). A new procedure for detection of crossing DIF. *Psychometrika, 61*(4), -// 647-677. https://doi.org/10.1007/BF02294041 -// Shealy, R., & Stout, W. (1993). A model-based standardization approach that separates true -// bias/DIF from group ability differences and detects test bias/DTF as well as item bias/DIF. -// *Psychometrika, 58*(2), 159-194. https://doi.org/10.1007/BF02294572 - -/// Configuration for [`sibtest`]. -#[derive(Clone, Copy)] -pub struct SibtestConfig { - /// Benjamini-Hochberg FDR level for the across-item flag. - pub fdr_q: f64, - /// Minimum examinees per group per matching level, STRICTLY exceeded for a level to be retained. - pub j_min: usize, -} - -impl Default for SibtestConfig { - fn default() -> Self { - Self { - fdr_q: 0.05, - j_min: 5, - } - } -} - -/// One studied item's uniform SIBTEST result. `NaN` statistics mean the item carried no usable strata -/// or a degenerate reliability — never a silent `0.0` (which would read as an affirmative no-DIF claim). -pub struct SibtestRow { - pub item: usize, - /// `beta_uni`, the regression-corrected weighted mean difference. **Positive = harder for the FOCAL - /// group** (reference minus focal), the OPPOSITE of `mh_d_dif`/`std_p_dif` in this same module. - pub beta_uni: f64, - /// Standard error of `beta_uni` (treats the regression correction as fixed; see the module notes). - pub se_beta: f64, - /// `beta_uni / se_beta`; its square is referred to `chi^2(1)`. - pub b_uni: f64, - /// Upper-tail `p`-value of `b_uni^2` on `chi^2(1)`. - pub p_value: f64, - /// Coefficient alpha of the valid subtest in the reference group. Exposed because the correction - /// DIVIDES by it: a low or unstable alpha inflates the local slope and hence the correction. - pub alpha_ref: f64, - /// Coefficient alpha of the valid subtest in the focal group. - pub alpha_focal: f64, - /// Matching levels that survived the retention rule. - pub n_strata_used: usize, - /// Benjamini-Hochberg FDR rejection flag on `p_value` across the swept items. - pub flagged_bh: bool, -} - -/// One matching level's sufficient statistics for [`sibtest_stats`]. Raw moments rather than -/// correct-counts, so the core computes its own unbiased within-cell variance. -pub(crate) struct SibCell { - pub level: usize, - pub j_r: u64, - pub sum_y_r: f64, - pub sum_y2_r: f64, - pub j_f: u64, - pub sum_y_f: f64, - pub sum_y2_f: f64, -} - -pub(crate) struct SibtestStats { - pub beta_uni: f64, - pub se_beta: f64, - pub b_uni: f64, - pub p_value: f64, - pub n_strata_used: usize, -} - -const SIB_UNDEFINED: SibtestStats = SibtestStats { - beta_uni: f64::NAN, - se_beta: f64::NAN, - b_uni: f64::NAN, - p_value: f64::NAN, - n_strata_used: 0, -}; - -/// Coefficient alpha (KR-20) of the valid subtest within one group: -/// `alpha = k/(k-1) * (1 - sum_j var(y_j) / var(sum_j y_j))`. -/// -/// Biased and unbiased variances cancel in the ratio, so both arguments must merely use the SAME -/// convention. Returns a non-finite value on a degenerate subtest, which the caller gates on. -#[inline] -fn coefficient_alpha(item_var_sum: f64, total_var: f64, n_valid: usize) -> f64 { - let k = n_valid as f64; - (k / (k - 1.0)) * (1.0 - item_var_sum / total_var) -} - -/// Uniform SIBTEST from one item's per-level sufficient statistics (the calibration-free core, exposed -/// for the deterministic anchors). `cells` must be sorted by ascending `level` and contain only levels -/// OBSERVED in the combined sample. -pub(crate) fn sibtest_stats( - cells: &[SibCell], - alpha_r: f64, - alpha_f: f64, - n_valid: usize, - j_min: u64, -) -> SibtestStats { - // The correction divides by alpha through the local slope, so a non-positive or non-finite - // reliability makes the whole statistic meaningless. Reject rather than clamp. - if !(alpha_r.is_finite() && alpha_r > 0.0 && alpha_f.is_finite() && alpha_f > 0.0) { - return SIB_UNDEFINED; - } - // Central differences need an interior level, so at least three observed levels must exist. - if n_valid < 2 || cells.len() < 3 { - return SIB_UNDEFINED; - } - - let (mut n_r, mut n_f, mut sx_r, mut sx_f) = (0u64, 0u64, 0.0f64, 0.0f64); - for c in cells { - n_r += c.j_r; - n_f += c.j_f; - sx_r += c.level as f64 * c.j_r as f64; - sx_f += c.level as f64 * c.j_f as f64; - } - if n_r == 0 || n_f == 0 { - return SIB_UNDEFINED; - } - let (xbar_r, xbar_f) = (sx_r / n_r as f64, sx_f / n_f as f64); - - let nv = n_valid as f64; - // Kelley's regressed true-score estimate, on the proportion-correct scale of the valid subtest. - let vstar = |xbar: f64, alpha: f64, level: usize| (xbar + alpha * (level as f64 - xbar)) / nv; - let ybar = |sum: f64, j: u64| sum / j as f64; - // Unbiased within-cell variance from raw moments. - let cell_var = |sum: f64, sum2: f64, j: u64| { - let jf = j as f64; - (sum2 - sum * sum / jf) / (jf - 1.0) - }; - - // A level is retained when both groups are populated well enough for a variance AND both - // neighbouring positions can supply a slope. `j_min` is exceeded STRICTLY. - let retained = |j: usize| -> bool { - // Endpoints have no two-sided neighbours; this also keeps `j - 1` and `j + 1` in range below. - if j == 0 || j + 1 == cells.len() { - return false; - } - let c = &cells[j]; - if !(c.j_r > j_min && c.j_f > j_min) { - return false; - } - if !(cell_var(c.sum_y_r, c.sum_y2_r, c.j_r) > 0.0 - && cell_var(c.sum_y_f, c.sum_y2_f, c.j_f) > 0.0) - { - return false; - } - // DIVERGENCE from the reference implementation, deliberate: it imputes an empty group-by-level - // cell's mean to 0.0 and feeds that fabricated zero into the neighbouring central difference, - // producing a finite but meaningless slope. Drop the level instead. - let (lo, hi) = (&cells[j - 1], &cells[j + 1]); - lo.j_r >= 1 && lo.j_f >= 1 && hi.j_r >= 1 && hi.j_f >= 1 - }; - - // Pass 1: the renormalizing constant for the combined-sample weights. - let mut w_total = 0.0f64; - let mut n_strata_used = 0usize; - for j in 0..cells.len() { - if retained(j) { - w_total += (cells[j].j_r + cells[j].j_f) as f64; - n_strata_used += 1; - } - } - if n_strata_used == 0 || !(w_total > 0.0) { - return SIB_UNDEFINED; - } - - // Pass 2: accumulate the estimate and its variance. - let (mut beta, mut var_beta) = (0.0f64, 0.0f64); - for j in 0..cells.len() { - if !retained(j) { - continue; - } - let (c, lo, hi) = (&cells[j], &cells[j - 1], &cells[j + 1]); - let p_k = (c.j_r + c.j_f) as f64 / w_total; - - let vr = vstar(xbar_r, alpha_r, c.level); - let vf = vstar(xbar_f, alpha_f, c.level); - let target = 0.5 * (vr + vf); - - // Central difference over each group's OWN true-score scale, at adjacent OBSERVED positions. - // The denominator is `alpha_G * (level[j+1] - level[j-1]) / n_valid`, which is NOT - // `2 * alpha_G / n_valid` unless the levels happen to be contiguous. - let m_r = (ybar(hi.sum_y_r, hi.j_r) - ybar(lo.sum_y_r, lo.j_r)) - / (vstar(xbar_r, alpha_r, hi.level) - vstar(xbar_r, alpha_r, lo.level)); - let m_f = (ybar(hi.sum_y_f, hi.j_f) - ybar(lo.sum_y_f, lo.j_f)) - / (vstar(xbar_f, alpha_f, hi.level) - vstar(xbar_f, alpha_f, lo.level)); - - let ystar_r = ybar(c.sum_y_r, c.j_r) + m_r * (target - vr); - let ystar_f = ybar(c.sum_y_f, c.j_f) + m_f * (target - vf); - beta += p_k * (ystar_r - ystar_f); - - let s2_r = cell_var(c.sum_y_r, c.sum_y2_r, c.j_r); - let s2_f = cell_var(c.sum_y_f, c.sum_y2_f, c.j_f); - var_beta += p_k * p_k * (s2_f / c.j_f as f64 + s2_r / c.j_r as f64); - } - - let se_beta = var_beta.sqrt(); - if !beta.is_finite() || !(se_beta > 0.0) || !se_beta.is_finite() { - return SIB_UNDEFINED; - } - let b_uni = beta / se_beta; - // Guard BEFORE squaring: a finite-but-huge ratio overflows to `inf`, and `chi2_sf(inf, .)` is NaN. - let p_value = if b_uni.is_finite() { - chi2_sf(b_uni * b_uni, 1.0) - } else { - f64::NAN - }; - SibtestStats { - beta_uni: beta, - se_beta, - b_uni, - p_value, - n_strata_used, - } -} - -/// Uniform SIBTEST sweep (Shealy & Stout, 1993, as implemented in Chalmers, 2012 — see the module -/// notes, including the provenance statement and why `beta_uni`'s sign is the opposite of -/// `mh_d_dif`'s). -/// -/// Each item in turn is the studied subtest `S = {i}`; the valid subtest `V` is every OTHER item, so -/// `V` and `S` are DISJOINT by construction. That is a property of the estimator rather than a -/// configuration option, and it is the opposite of the item-included Mantel-Haenszel default above -/// (Donoghue, Holland & Thayer, 1993), which is equally deliberate there. -pub fn sibtest( - y: &[u8], - group: &[u8], - n_persons: usize, - n_items: usize, - cfg: &SibtestConfig, -) -> Result, String> { - validate_dif_inputs( - y, - group, - n_persons, - n_items, - &MhDifConfig { - exclude_studied_item: false, - fdr_q: cfg.fdr_q, - }, - )?; - // The valid subtest is every other item, and coefficient alpha needs at least two of them. - if n_items < 3 { - return Err("sibtest requires n_items >= 3 (the valid subtest needs >= 2 items)".into()); - } - if cfg.j_min < 2 { - return Err("j_min must be >= 2 (a within-level variance needs two examinees)".into()); - } - - let base = base_scores(y, n_persons, n_items, None); - let n_valid = n_items - 1; - let j_min = cfg.j_min as u64; - - // Per-group per-item response moments, for the coefficient-alpha numerator. One pass, reused by - // every item: the numerator for item `i` is the total minus item `i`'s own contribution. - let mut n_g = [0u64; 2]; - let mut item_sum = [vec![0.0f64; n_items], vec![0.0f64; n_items]]; - for p in 0..n_persons { - let g = group[p] as usize; - n_g[g] += 1; - for i in 0..n_items { - item_sum[g][i] += y[p * n_items + i] as f64; - } - } - if n_g[0] == 0 || n_g[1] == 0 { - return Err("both groups must be present".into()); - } - // Population (biased) variance throughout; the convention cancels in alpha's ratio. - let item_var: Vec<[f64; 2]> = (0..n_items) - .map(|i| { - let mut v = [0.0f64; 2]; - for g in 0..2 { - // A 0/1 item's mean square equals its mean, so var = mean - mean^2. - let m = item_sum[g][i] / n_g[g] as f64; - v[g] = m - m * m; - } - v - }) - .collect(); - let item_var_total: [f64; 2] = [ - item_var.iter().map(|v| v[0]).sum(), - item_var.iter().map(|v| v[1]).sum(), - ]; - - let mut rows: Vec = Vec::with_capacity(n_items); - // `level = base - y_i` lies in `0..n_items`, so one dense accumulator serves every item. - let n_levels = n_items; - let mut acc: Vec<[f64; 6]> = vec![[0.0; 6]; n_levels]; - for i in 0..n_items { - for slot in acc.iter_mut() { - *slot = [0.0; 6]; - } - // Rest-score moments for alpha's denominator, accumulated in the same pass as the cells. - let mut rest_sum = [0.0f64; 2]; - let mut rest_sq = [0.0f64; 2]; - for p in 0..n_persons { - let g = group[p] as usize; - let yi = y[p * n_items + i] as f64; - let level = base[p] - y[p * n_items + i] as usize; - let s = &mut acc[level]; - let off = if g == 0 { 0 } else { 3 }; - s[off] += 1.0; - s[off + 1] += yi; - s[off + 2] += yi * yi; - let rest = level as f64; - rest_sum[g] += rest; - rest_sq[g] += rest * rest; - } - let cells: Vec = (0..n_levels) - .filter(|&l| acc[l][0] + acc[l][3] > 0.0) - .map(|l| SibCell { - level: l, - j_r: acc[l][0] as u64, - sum_y_r: acc[l][1], - sum_y2_r: acc[l][2], - j_f: acc[l][3] as u64, - sum_y_f: acc[l][4], - sum_y2_f: acc[l][5], - }) - .collect(); - - let mut alpha = [f64::NAN; 2]; - for g in 0..2 { - let n = n_g[g] as f64; - let mean = rest_sum[g] / n; - let total_var = rest_sq[g] / n - mean * mean; - alpha[g] = coefficient_alpha(item_var_total[g] - item_var[i][g], total_var, n_valid); - } - - let st = sibtest_stats(&cells, alpha[0], alpha[1], n_valid, j_min); - rows.push(SibtestRow { - item: i, - beta_uni: st.beta_uni, - se_beta: st.se_beta, - b_uni: st.b_uni, - p_value: st.p_value, - alpha_ref: alpha[0], - alpha_focal: alpha[1], - n_strata_used: st.n_strata_used, - flagged_bh: false, - }); - } - - let pvals: Vec = rows.iter().map(|r| r.p_value).collect(); - for (r, f) in rows.iter_mut().zip(benjamini_hochberg(&pvals, cfg.fdr_q)) { - r.flagged_bh = f; - } - Ok(rows) -} - -// ===================== Raju (1988/1990) ICC area DIF ========================== -// -// Closed-form signed and unsigned areas between two logistic ICCs, with Raju's -// delta-method Z tests. The item parameters must already be on a COMMON scale; -// use [`crate::linking::irt_link`] (e.g. `LinkMethod::MeanSigma`) beforehand — -// linking is deliberately not re-implemented here. -// -// SOURCE STATUS. The primary papers were NOT read (paywalled): -// Raju, N. S. (1988). The area between two item characteristic curves. -// *Psychometrika, 53*(4), 495-502. https://doi.org/10.1007/BF02294403 -// Raju, N. S. (1990). Determining the significance of estimated signed and -// unsigned areas between two item response functions. *Applied -// Psychological Measurement, 14*(2), 197-207. -// https://doi.org/10.1177/014662169001400208 -// The formula oracle is difR's `RajuZ.R` / `difRaju.R` (READ in full): -// Magis, D., Beland, S., Tuerlinckx, F., & De Boeck, P. (2010). A general -// framework and an R package for the detection of dichotomous differential -// item functioning. *Behavior Research Methods, 42*, 847-862. -// https://doi.org/10.3758/BRM.42.3.847 (package paper NOT read; code READ). -// Both areas and all four partial derivatives were additionally re-derived by -// hand and verified against numeric quadrature / finite differences during -// adversarial spec review; difR's gradient is the uniform NEGATION of grad H -// (variance-equivalent), and difR's `exp(Y)==Inf` overflow branch carries the -// sign opposite to the closed-form positive-side limit (moot here: this -// implementation uses a stable softplus and exposes only |H| for the unsigned -// statistic). - -/// Raju ICC-area DIF output. One entry per item. -#[derive(Debug, Clone)] -pub struct RajuAreaResult { - /// Area statistic. Signed: `h = b_F - b_R = integral (P_R - P_F) dtheta` - /// under `P_G(theta) = logistic(a_G (theta - b_G))` — positive means the - /// item is HARDER for the focal group. Unsigned: `h = |H| >= 0`, the total - /// area `integral |P_F - P_R| dtheta`. For 3PL, scaled by `(1 - c)`. - pub h: Vec, - /// Delta-method standard error of `h` (scaled by `(1 - c)` for 3PL). - pub se: Vec, - /// `Z = H / se(H)` from the UNSCALED quantities (difR convention; the - /// `(1 - c)` factor cancels). Signed for `signed = true`, else `>= 0`. - pub z: Vec, - /// Two-sided `p = 2 (1 - Phi(|z|))`. - pub p_value: Vec, - /// Items with `|z| > z_{1 - alpha/2}` (0-based). - pub dif_items: Vec, - /// Which statistic was computed. - pub signed: bool, -} - -/// Numerically stable `ln(1 + e^y)`. -fn softplus(y: f64) -> f64 { - if y > 0.0 { - y + (-y).exp().ln_1p() - } else { - y.exp().ln_1p() - } -} - -/// Raju's (1988) signed/unsigned area DIF test with Raju's (1990) delta-method -/// Z statistics (difR `RajuZ` oracle; see the section comment for source -/// status — primary papers NOT read, difR code READ, formulas re-derived and -/// quadrature/FD-verified in adversarial spec review). -/// -/// ICC convention: `P_G(theta) = 1 / (1 + exp(-a_G (theta - b_G)))`. Verified -/// identity (hand-derived; pinned by a quadrature test): -/// `integral (P_R - P_F) dtheta = b_F - b_R`, so the signed statistic -/// `h = b_F - b_R` is positive when the item is harder for the focal group. -/// -/// Unsigned area (Raju 1988 closed form, hand-re-derived via the single -/// crossing point): with `Y = a_F a_R (b_F - b_R) / (a_F - a_R)`, -/// `H = 2 (a_F - a_R) / (a_F a_R) * ln(1 + e^Y) - (b_F - b_R)` and -/// `h = |H| = integral |P_F - P_R| dtheta`. The two-sided equal-slope limit of -/// the signed auxiliary `H` is not unique (`Y -> +inf` gives `+(b_F - b_R)`, -/// `Y -> -inf` gives `-(b_F - b_R)`); only `|H| -> |b_F - b_R|` is continuous, -/// which is why only `|H|` is exposed. Exact `a_F == a_R` uses that limit with -/// the b-only variance (slope partials vanish). -/// -/// Variance (delta method, independent group calibrations; partials FD-verified): -/// `grad H = (2(L - Y s)/a_F^2, 2s - 1, 2(Y s - L)/a_R^2, 1 - 2s)` in the order -/// `(a_F, b_F, a_R, b_R)` with `s = logistic(Y)`, `L = softplus(Y)`; -/// `Var(H) = sum_G [A_G^2 se(a_G)^2 + B_G^2 se(b_G)^2 + 2 A_G B_G cov_G]`. -/// The signed path uses only the difficulty SEs: `Var = se(b_F)^2 + se(b_R)^2`. -/// -/// `guess`: optional per-item pseudo-guessing `c` COMMON to both groups -/// (3PL with unequal group `c` has no closed form here and is rejected by -/// contract); `h` and `se` are reported scaled by `(1 - c)`, `z` is the -/// unscaled ratio (difR convention). -/// -/// Flags `dif_items` at `|z| > z_{1 - alpha/2}`. Caveat (Monte-Carlo verified -/// in this crate's test suite): under an exact null with equal true slopes the -/// unsigned `|H|` statistic is anti-conservative (its leading term is -/// quadratic, so the normal-theory Z over-rejects); the signed test is exact -/// there. Prefer the signed test when uniform DIF is the alternative. -#[allow(clippy::too_many_arguments)] -pub fn raju_area( - a_ref: &[f64], - b_ref: &[f64], - se_a_ref: &[f64], - se_b_ref: &[f64], - cov_ab_ref: &[f64], - a_foc: &[f64], - b_foc: &[f64], - se_a_foc: &[f64], - se_b_foc: &[f64], - cov_ab_foc: &[f64], - guess: Option<&[f64]>, - signed: bool, - alpha: f64, -) -> Result { - let n = a_ref.len(); - if n == 0 { - return Err("raju_area: no items".into()); - } - let same_len = [ - b_ref.len(), - se_a_ref.len(), - se_b_ref.len(), - cov_ab_ref.len(), - a_foc.len(), - b_foc.len(), - se_a_foc.len(), - se_b_foc.len(), - cov_ab_foc.len(), - ] - .iter() - .all(|&l| l == n); - if !same_len { - return Err("raju_area: parameter slices must all have the same length".into()); - } - if let Some(c) = guess { - if c.len() != n { - return Err("raju_area: guess length mismatch".into()); - } - if c.iter() - .any(|&v| !v.is_finite() || !(0.0..1.0).contains(&v)) - { - return Err("raju_area: pseudo-guessing c must be finite in [0, 1)".into()); - } - } - if !(alpha > 0.0 && alpha < 1.0) { - return Err("raju_area: alpha must be in (0, 1)".into()); - } - for i in 0..n { - let finite = [ - a_ref[i], - b_ref[i], - se_a_ref[i], - se_b_ref[i], - cov_ab_ref[i], - a_foc[i], - b_foc[i], - se_a_foc[i], - se_b_foc[i], - cov_ab_foc[i], - ] - .iter() - .all(|v| v.is_finite()); - if !finite { - return Err(format!("raju_area: non-finite parameter at item {i}")); - } - if a_ref[i] <= 0.0 || a_foc[i] <= 0.0 { - return Err(format!("raju_area: discriminations must be > 0 (item {i})")); - } - if se_a_ref[i] < 0.0 || se_b_ref[i] < 0.0 || se_a_foc[i] < 0.0 || se_b_foc[i] < 0.0 { - return Err(format!( - "raju_area: standard errors must be >= 0 (item {i})" - )); - } - // Cauchy-Schwarz / PSD bound per group: |cov(a, b)| <= se_a * se_b. - // Without this, an invalid covariance can still leave the assembled - // variance positive and silently corrupt the SE and Z. - if cov_ab_ref[i].abs() > se_a_ref[i] * se_b_ref[i] - || cov_ab_foc[i].abs() > se_a_foc[i] * se_b_foc[i] - { - return Err(format!( - "raju_area: |cov_ab| exceeds se_a * se_b (item {i}); the group \ - sampling covariance matrix is not positive semi-definite" - )); - } - } - - let mut h = vec![0.0; n]; - let mut se = vec![0.0; n]; - let mut z = vec![0.0; n]; - let mut p_value = vec![0.0; n]; - for i in 0..n { - let delta = b_foc[i] - b_ref[i]; - // Unscaled (H, Var(H)); the (1 - c) report scaling happens at the end. - let (h_raw, var) = if signed { - (delta, se_b_foc[i] * se_b_foc[i] + se_b_ref[i] * se_b_ref[i]) - } else if a_foc[i] == a_ref[i] { - // Equal-slope limit: |H| -> |delta|; slope partials vanish, so the - // variance is the b-only delta method (|dH/db_F| = |dH/db_R| = 1). - ( - delta.abs(), - se_b_foc[i] * se_b_foc[i] + se_b_ref[i] * se_b_ref[i], - ) - } else { - let (af, ar) = (a_foc[i], a_ref[i]); - let d = af - ar; - let y = af * ar * delta / d; - let l = softplus(y); - let s = 1.0 / (1.0 + (-y).exp()); - let h_aux = 2.0 * d / (af * ar) * l - delta; - // grad H in (a_F, b_F, a_R, b_R); FD-verified in spec review. - let g_af = 2.0 * (l - y * s) / (af * af); - let g_bf = 2.0 * s - 1.0; - let g_ar = 2.0 * (y * s - l) / (ar * ar); - let g_br = 1.0 - 2.0 * s; - let v = g_af * g_af * se_a_foc[i] * se_a_foc[i] - + g_bf * g_bf * se_b_foc[i] * se_b_foc[i] - + 2.0 * g_af * g_bf * cov_ab_foc[i] - + g_ar * g_ar * se_a_ref[i] * se_a_ref[i] - + g_br * g_br * se_b_ref[i] * se_b_ref[i] - + 2.0 * g_ar * g_br * cov_ab_ref[i]; - (h_aux.abs(), v) - }; - if !var.is_finite() || var <= 0.0 { - return Err(format!( - "raju_area: assembled variance is not positive at item {i} \ - (check covariances against the standard errors)" - )); - } - let sd = var.sqrt(); - let zi = h_raw / sd; - let scale = guess.map_or(1.0, |c| 1.0 - c[i]); - h[i] = scale * h_raw; - se[i] = scale * sd; - z[i] = zi; - // p = 2 (1 - Phi(|z|)) = erfc(|z| / sqrt(2)). - p_value[i] = crate::fitstats::erfc(zi.abs() / std::f64::consts::SQRT_2); - } - let z_crit = crate::mokken::normal_upper_quantile(alpha / 2.0); - let dif_items = (0..n).filter(|&i| z[i].abs() > z_crit).collect(); - Ok(RajuAreaResult { - h, - se, - z, - p_value, - dif_items, - signed, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/dif_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/equating.rs b/crates/mlsirm-core/src/equating.rs deleted file mode 100644 index 52e80e452..000000000 --- a/crates/mlsirm-core/src/equating.rs +++ /dev/null @@ -1,1311 +0,0 @@ -//! Observed-score equating (Kolen & Brennan, 2014, *Test Equating, Scaling, and -//! Linking*, 3rd ed.): the raw-score → raw-score complement to the parameter -//! linking in [`crate::linking`]. This module covers the equivalent-groups (EG) -//! design — mean, linear, and equipercentile equating — and the common-item -//! non-equivalent-groups (NEAT) design via chained equipercentile and -//! frequency-estimation (post-stratification) equipercentile. -//! -//! All continuization uses the Kolen-Brennan uniform-kernel convention (an -//! integer score `x` occupies the interval `[x-0.5, x+0.5)`; the discrete cdf is -//! interpolated linearly within it). Equated scores `e_Y(x)` are kept -//! real-valued (unrounded); producing an integer conversion table is left to the -//! caller. -//! -//! # References (APA 7th ed.) -//! -//! Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and linking: -//! Methods and practices* (3rd ed.). Springer. -//! https://doi.org/10.1007/978-1-4939-0317-7 -//! -//! Deferred to future work (each is a drop-in behind the density/table interface -//! here): Tucker/Levine linear NEAT (K&B §4.3–4.4), log-linear presmoothing -//! (K&B ch. 3), and Gaussian-kernel equating (von Davier, Holland & Thayer, 2004). - -/// Result of an equating: the conversion table `y_equivalents[i] = e_Y(x_scores[i])` -/// (unrounded), the form moments, the moments of the equated scores under form -/// X's distribution, and — for the moment methods only — the linear -/// `slope`/`intercept` (`NaN` for equipercentile / NEAT). The `mu_x`/`sigma_x`/ -/// `mu_y`/`sigma_y` fields are the raw form marginals for EG and chained equating, -/// but the *synthetic-population* moments for frequency estimation and for the -/// Tucker/Levine linear methods (which equate synthetic populations, not the raw -/// marginals) — so do not compare their moment fields against a chained or EG -/// result's field-for-field. -#[derive(Clone, Debug)] -pub struct EquateResult { - pub x_scores: Vec, - pub y_equivalents: Vec, - pub mu_x: f64, - pub sigma_x: f64, - pub mu_y: f64, - pub sigma_y: f64, - pub mu_eq: f64, - pub sigma_eq: f64, - pub slope: f64, - pub intercept: f64, - pub n_x: usize, - pub n_y: usize, - /// Gaussian-kernel bandwidths actually used for form X / form Y; `NaN` for the - /// uniform-kernel (equipercentile) and moment methods. - pub h_x: f64, - pub h_y: f64, -} - -/// Equivalent-groups equating method. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum EquateMethod { - Mean, - Linear, - Equipercentile, -} - -impl EquateMethod { - pub fn parse(name: &str) -> Option { - match name.to_ascii_lowercase().replace(['-', '_'], "").as_str() { - "mean" | "m" => Some(EquateMethod::Mean), - "linear" | "lin" | "l" => Some(EquateMethod::Linear), - "equipercentile" | "equip" | "ep" => Some(EquateMethod::Equipercentile), - _ => None, - } - } -} - -/// NEAT (common-item non-equivalent groups) equating method. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NeatMethod { - ChainedEquipercentile, - FrequencyEstimation, -} - -impl NeatMethod { - pub fn parse(name: &str) -> Option { - match name.to_ascii_lowercase().replace(['-', '_'], "").as_str() { - "chained" | "chainedequipercentile" | "ce" => Some(NeatMethod::ChainedEquipercentile), - "frequencyestimation" | "fe" | "poststratification" => { - Some(NeatMethod::FrequencyEstimation) - } - _ => None, - } - } -} - -// --- discrete-frequency utilities (none of this exists elsewhere in the crate) --- - -/// Relative-frequency vector `g(0..=k)` from raw integer scores. Errors on empty -/// input or a score outside `0..=k`. -fn rel_freq(scores: &[f64], k: usize) -> Result, String> { - if scores.is_empty() { - return Err("score vector must be non-empty".into()); - } - let mut freq = vec![0.0_f64; k + 1]; - for &s in scores { - if !s.is_finite() { - return Err("scores must be finite".into()); - } - if s < -0.5 || s >= k as f64 + 0.5 { - return Err(format!("score {s} outside 0..={k}")); - } - // bin to the category whose [c-0.5, c+0.5) interval contains s - freq[(s + 0.5).floor() as usize] += 1.0; - } - let n = scores.len() as f64; - for f in freq.iter_mut() { - *f /= n; - } - Ok(freq) -} - -/// Discrete cdf `F(x) = sum_{u<=x} g(u)`; the top cell is pinned to 1.0 to absorb -/// floating-point drift so the inverse always finds a bracketing score. -fn cdf(g: &[f64]) -> Vec { - let mut f = vec![0.0_f64; g.len()]; - let mut acc = 0.0_f64; - for (i, &gi) in g.iter().enumerate() { - acc += gi; - f[i] = acc; - } - if let Some(last) = f.last_mut() { - *last = 1.0; - } - f -} - -/// Population mean and standard deviation of a score distribution `g`. -fn moments(g: &[f64]) -> (f64, f64) { - let mean: f64 = g.iter().enumerate().map(|(x, &p)| x as f64 * p).sum(); - let var: f64 = g - .iter() - .enumerate() - .map(|(x, &p)| (x as f64 - mean).powi(2) * p) - .sum(); - (mean, var.max(0.0).sqrt()) -} - -/// Mean/SD of the equated scores `y_eq` weighted by form X's distribution `gx`. -fn weighted_moments(y_eq: &[f64], gx: &[f64]) -> (f64, f64) { - let mean: f64 = y_eq.iter().zip(gx).map(|(&y, &w)| y * w).sum(); - let var: f64 = y_eq - .iter() - .zip(gx) - .map(|(&y, &w)| (y - mean).powi(2) * w) - .sum(); - (mean, var.max(0.0).sqrt()) -} - -/// Percentile rank `P(x)` (K&B eq. 2.3), the uniform-kernel continuization of the -/// discrete cdf. `x` may be real (needed by chained equating). Returns a value in -/// `[0, 100]`. -fn perc_rank(g: &[f64], f: &[f64], k: usize, x: f64) -> f64 { - if x < -0.5 { - return 0.0; - } - if x >= k as f64 + 0.5 { - return 100.0; - } - let xstar = x.round() as usize; // in 0..=k over the valid interval - let f_lo = if xstar == 0 { 0.0 } else { f[xstar - 1] }; - 100.0 * (f_lo + (x - (xstar as f64 - 0.5)) * g[xstar]) -} - -/// Inverse percentile rank `P^{-1}(p*)` (K&B eqs. 2.4/2.5), real-valued in -/// `[-0.5, k+0.5]`. The lower form is used throughout: it interpolates linearly -/// inside the bracketing score's interval — including at the low boundary -/// (`p* <= 100*F(0)` maps to `(p*/100)/g(0) - 0.5`, NOT a hard clamp to `-0.5`), -/// which is what makes the self-equating identity exact at `x = 0`. -fn perc_rank_inv(f: &[f64], k: usize, p_star: f64) -> f64 { - if p_star <= 0.0 { - return -0.5; - } - if p_star >= 100.0 { - return k as f64 + 0.5; - } - let pp = p_star / 100.0; - // smallest integer score x_u with F(x_u) > pp - let mut x_u = k; - for (i, &fi) in f.iter().enumerate() { - if fi > pp { - x_u = i; - break; - } - } - let f_lo = if x_u == 0 { 0.0 } else { f[x_u - 1] }; - let g_u = f[x_u] - f_lo; - // The selected cell satisfies F(x_u) > pp >= F(x_u-1), hence g_u > 0. - (pp - f_lo) / g_u + (x_u as f64 - 0.5) -} - -/// Equipercentile equivalents `e_Y(x) = P_Y^{-1}(P_X(x))` for `x = 0..=k_x`. -fn equipercentile(gx: &[f64], gy: &[f64], k_x: usize, k_y: usize) -> Vec { - let fx = cdf(gx); - let fy = cdf(gy); - (0..=k_x) - .map(|x| perc_rank_inv(&fy, k_y, perc_rank(gx, &fx, k_x, x as f64))) - .collect() -} - -/// Normalized bivariate density table `(k_s+1) x (k_v+1)` (row-major, score by -/// anchor) from paired total/anchor score vectors. -fn bivariate(total: &[f64], anchor: &[f64], k_s: usize, k_v: usize) -> Result, String> { - if total.len() != anchor.len() { - return Err("total and anchor vectors must have equal length".into()); - } - if total.is_empty() { - return Err("score vectors must be non-empty".into()); - } - let mut tab = vec![0.0_f64; (k_s + 1) * (k_v + 1)]; - for (&s, &v) in total.iter().zip(anchor) { - if !s.is_finite() || !v.is_finite() { - return Err("scores must be finite".into()); - } - if s < -0.5 || s >= k_s as f64 + 0.5 || v < -0.5 || v >= k_v as f64 + 0.5 { - return Err("bivariate score out of range".into()); - } - let si = (s + 0.5).floor() as usize; - let vi = (v + 0.5).floor() as usize; - tab[si * (k_v + 1) + vi] += 1.0; - } - let n = total.len() as f64; - for t in tab.iter_mut() { - *t /= n; - } - Ok(tab) -} - -/// Equivalent-groups (or single-group) observed-score equating of form X onto -/// form Y. `x_scores`/`y_scores` are raw integer total scores; `k_x`/`k_y` are the -/// maximum possible scores (number of items). See the module docs for the method -/// definitions. -pub fn equate_eg( - x_scores: &[f64], - y_scores: &[f64], - k_x: usize, - k_y: usize, - method: EquateMethod, -) -> Result { - if k_x == 0 || k_y == 0 { - return Err("k_x and k_y must be positive".into()); - } - let gx = rel_freq(x_scores, k_x)?; - let gy = rel_freq(y_scores, k_y)?; - let (mu_x, sigma_x) = moments(&gx); - let (mu_y, sigma_y) = moments(&gy); - - let (y_eq, slope, intercept) = match method { - EquateMethod::Mean => { - let b = mu_y - mu_x; - ((0..=k_x).map(|x| x as f64 + b).collect::>(), 1.0, b) - } - EquateMethod::Linear => { - if sigma_x <= 0.0 { - return Err("linear equating needs a positive SD on form X".into()); - } - let a = sigma_y / sigma_x; - let b = mu_y - a * mu_x; - ( - (0..=k_x).map(|x| a * x as f64 + b).collect::>(), - a, - b, - ) - } - EquateMethod::Equipercentile => (equipercentile(&gx, &gy, k_x, k_y), f64::NAN, f64::NAN), - }; - let (mu_eq, sigma_eq) = weighted_moments(&y_eq, &gx); - Ok(EquateResult { - x_scores: (0..=k_x).map(|x| x as f64).collect(), - y_equivalents: y_eq, - mu_x, - sigma_x, - mu_y, - sigma_y, - mu_eq, - sigma_eq, - slope, - intercept, - n_x: x_scores.len(), - n_y: y_scores.len(), - h_x: f64::NAN, - h_y: f64::NAN, - }) -} - -/// NEAT (common-item non-equivalent groups) equating. Population 1 takes form X -/// plus the anchor V (`x_total`, `x_anchor`); population 2 takes form Y plus the -/// anchor V (`y_total`, `y_anchor`). `w1` is the synthetic-population weight for -/// population 1 (`w2 = 1 - w1`); it is used only by frequency estimation and -/// ignored by chained equating. -#[allow(clippy::too_many_arguments)] -pub fn equate_neat( - x_total: &[f64], - x_anchor: &[f64], - y_total: &[f64], - y_anchor: &[f64], - k_x: usize, - k_y: usize, - k_v: usize, - w1: f64, - method: NeatMethod, -) -> Result { - if k_x == 0 || k_y == 0 || k_v == 0 { - return Err("k_x, k_y, k_v must be positive".into()); - } - if x_total.len() != x_anchor.len() || y_total.len() != y_anchor.len() { - return Err("total and anchor vectors must have equal length within each group".into()); - } - let gx = rel_freq(x_total, k_x)?; - let gy = rel_freq(y_total, k_y)?; - let (mu_x, sigma_x) = moments(&gx); - let (mu_y, sigma_y) = moments(&gy); - - let y_eq = match method { - NeatMethod::ChainedEquipercentile => { - // X -> V in population 1, then V -> Y in population 2 (K&B §5.2). The - // intermediate v is real, so the real-argument percentile rank on the - // pop-2 anchor distribution is required. - let fx = cdf(&gx); - let gv1 = rel_freq(x_anchor, k_v)?; - let fv1 = cdf(&gv1); - let gv2 = rel_freq(y_anchor, k_v)?; - let fv2 = cdf(&gv2); - let fy = cdf(&gy); - (0..=k_x) - .map(|x| { - let v = perc_rank_inv(&fv1, k_v, perc_rank(&gx, &fx, k_x, x as f64)); - perc_rank_inv(&fy, k_y, perc_rank(&gv2, &fv2, k_v, v)) - }) - .collect::>() - } - NeatMethod::FrequencyEstimation => { - if !(0.0..=1.0).contains(&w1) { - return Err("w1 must be in [0, 1]".into()); - } - let w2 = 1.0 - w1; - let n1 = bivariate(x_total, x_anchor, k_x, k_v)?; - let n2 = bivariate(y_total, y_anchor, k_y, k_v)?; - let stride1 = k_v + 1; - // anchor marginals and form marginals - let mut h1 = vec![0.0_f64; k_v + 1]; - let mut h2 = vec![0.0_f64; k_v + 1]; - let mut f1 = vec![0.0_f64; k_x + 1]; - let mut g2 = vec![0.0_f64; k_y + 1]; - for x in 0..=k_x { - for v in 0..=k_v { - let p = n1[x * stride1 + v]; - f1[x] += p; - h1[v] += p; - } - } - for y in 0..=k_y { - for v in 0..=k_v { - let p = n2[y * stride1 + v]; - g2[y] += p; - h2[v] += p; - } - } - // synthetic-population densities (K&B §5.3), skipping anchor points a - // group never observed (division guard) - let mut f_s = vec![0.0_f64; k_x + 1]; - for x in 0..=k_x { - let mut cross = 0.0_f64; - for v in 0..=k_v { - if h1[v] > 0.0 { - cross += (n1[x * stride1 + v] / h1[v]) * h2[v]; - } - } - f_s[x] = w1 * f1[x] + w2 * cross; - } - let mut g_s = vec![0.0_f64; k_y + 1]; - for y in 0..=k_y { - let mut cross = 0.0_f64; - for v in 0..=k_v { - if h2[v] > 0.0 { - cross += (n2[y * stride1 + v] / h2[v]) * h1[v]; - } - } - g_s[y] = w1 * cross + w2 * g2[y]; - } - // Frequency estimation is undefined when the two groups share no - // anchor score: every cross term drops out and the synthetic density - // collapses (to naive EG, or — at w1 in {0,1} — to an all-zero vector - // that would silently yield a boundary-only conversion table). Refuse - // rather than return a silently-wrong result. - let overlap = (0..=k_v).any(|v| h1[v] > 0.0 && h2[v] > 0.0); - if !overlap { - return Err( - "frequency estimation needs overlapping anchor support between the two groups" - .into(), - ); - } - // Partial non-overlap only drops the un-estimable anchor points; - // renormalize the surviving mass so the cdf/boundary logic stays valid - // (FE then operates on the shared anchor support). - renormalize(&mut f_s); - renormalize(&mut g_s); - let y_eq = equipercentile(&f_s, &g_s, k_x, k_y); - // report the synthetic moments actually equated - let (msx, ssx) = moments(&f_s); - let (msy, ssy) = moments(&g_s); - let (mu_eq, sigma_eq) = weighted_moments(&y_eq, &f_s); - return Ok(EquateResult { - x_scores: (0..=k_x).map(|x| x as f64).collect(), - y_equivalents: y_eq, - mu_x: msx, - sigma_x: ssx, - mu_y: msy, - sigma_y: ssy, - mu_eq, - sigma_eq, - slope: f64::NAN, - intercept: f64::NAN, - n_x: x_total.len(), - n_y: y_total.len(), - h_x: f64::NAN, - h_y: f64::NAN, - }); - } - }; - let (mu_eq, sigma_eq) = weighted_moments(&y_eq, &gx); - Ok(EquateResult { - x_scores: (0..=k_x).map(|x| x as f64).collect(), - y_equivalents: y_eq, - mu_x, - sigma_x, - mu_y, - sigma_y, - mu_eq, - sigma_eq, - slope: f64::NAN, - intercept: f64::NAN, - n_x: x_total.len(), - n_y: y_total.len(), - h_x: f64::NAN, - h_y: f64::NAN, - }) -} - -fn renormalize(v: &mut [f64]) { - let s: f64 = v.iter().sum(); - if s > 0.0 { - for x in v.iter_mut() { - *x /= s; - } - } -} - -// ===================== Tucker / Levine linear NEAT equating ===================== - -/// Linear observed-score NEAT equating method (the linear counterpart to the -/// chained/frequency-estimation equipercentile NEAT methods). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NeatLinearMethod { - /// Tucker (equal total-on-anchor regression across populations). - Tucker, - /// Levine observed-score (classical-congeneric assumption). - LevineObserved, -} - -impl NeatLinearMethod { - pub fn parse(name: &str) -> Option { - match name.to_ascii_lowercase().replace(['-', '_'], "").as_str() { - "tucker" | "t" => Some(NeatLinearMethod::Tucker), - "levine" | "levineobserved" | "l" => Some(NeatLinearMethod::LevineObserved), - _ => None, - } - } -} - -/// Whether the anchor items count toward the total score (internal) or are a -/// separate section (external). Affects only the Levine gamma; Tucker is -/// anchor-kind-invariant. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum AnchorKind { - Internal, - External, -} - -impl AnchorKind { - pub fn parse(name: &str) -> Option { - match name.to_ascii_lowercase().replace(['-', '_'], "").as_str() { - "internal" | "int" => Some(AnchorKind::Internal), - "external" | "ext" => Some(AnchorKind::External), - _ => None, - } - } -} - -/// Population moments of paired vectors `(mean_a, var_a, mean_b, var_b, cov)` with -/// the `N`-denominator convention (matching [`moments`]). -fn paired_moments(a: &[f64], b: &[f64]) -> (f64, f64, f64, f64, f64) { - let n = a.len() as f64; - let ma = a.iter().sum::() / n; - let mb = b.iter().sum::() / n; - let va = a.iter().map(|&x| (x - ma).powi(2)).sum::() / n; - let vb = b.iter().map(|&x| (x - mb).powi(2)).sum::() / n; - let cov = a - .iter() - .zip(b) - .map(|(&x, &y)| (x - ma) * (y - mb)) - .sum::() - / n; - (ma, va, mb, vb, cov) -} - -/// Tucker & Levine linear observed-score equating for the NEAT (common-item -/// non-equivalent groups) design (Kolen & Brennan, 2014, §4.3–4.4) — the linear -/// counterpart to [`equate_neat`]'s equipercentile methods. Population 1 takes -/// form X plus the anchor V (`x_total`, `x_anchor`); population 2 takes form Y plus -/// the anchor V (`y_total`, `y_anchor`). Each method forms synthetic-population -/// moments of X and Y (weighted by `w1`/`w2 = 1-w1`) using a group total-on-anchor -/// slope `gamma`, then equates linearly. Tucker uses the regression slope -/// `Cov(total, V)/Var(V)`; Levine uses the congeneric effective-length ratio, -/// which differs for an `Internal` anchor (`Var(total)/Cov`) versus an `External` -/// one (`(Var(total)+Cov)/(Var(V)+Cov)`). With equal anchor moments in the two -/// groups all variants collapse to the equivalent-groups linear equating. -/// -/// # References (APA 7th ed.) -/// -/// Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and linking: -/// Methods and practices* (3rd ed.). Springer. -/// https://doi.org/10.1007/978-1-4939-0317-7 -/// -/// Brennan, R. L. (2006). *Chained linear equating* (CASMA Technical Report No. 3). -/// Center for Advanced Studies in Measurement and Assessment, University of Iowa. -#[allow(clippy::too_many_arguments)] -pub fn equate_neat_linear( - x_total: &[f64], - x_anchor: &[f64], - y_total: &[f64], - y_anchor: &[f64], - k_x: usize, - k_y: usize, - w1: f64, - method: NeatLinearMethod, - anchor_kind: AnchorKind, -) -> Result { - if k_x == 0 || k_y == 0 { - return Err("k_x and k_y must be positive".into()); - } - if x_total.len() != x_anchor.len() || y_total.len() != y_anchor.len() { - return Err("total and anchor vectors must have equal length within each group".into()); - } - if x_total.is_empty() || y_total.is_empty() { - return Err("score vectors must be non-empty".into()); - } - if !(0.0..=1.0).contains(&w1) { - return Err("w1 must be in [0, 1]".into()); - } - if x_total - .iter() - .chain(x_anchor) - .chain(y_total) - .chain(y_anchor) - .any(|v| !v.is_finite()) - { - return Err("scores must be finite".into()); - } - let (m1x, v1x, m1v, v1v, cov1) = paired_moments(x_total, x_anchor); - let (m2y, v2y, m2v, v2v, cov2) = paired_moments(y_total, y_anchor); - if v1v <= 0.0 || v2v <= 0.0 { - return Err("anchor variance must be positive in both groups".into()); - } - let (g1, g2) = match method { - NeatLinearMethod::Tucker => (cov1 / v1v, cov2 / v2v), - NeatLinearMethod::LevineObserved => { - if cov1 <= 0.0 || cov2 <= 0.0 { - return Err( - "Levine equating needs a positive total-anchor covariance in both groups" - .into(), - ); - } - match anchor_kind { - AnchorKind::Internal => (v1x / cov1, v2y / cov2), - AnchorKind::External => ((v1x + cov1) / (v1v + cov1), (v2y + cov2) / (v2v + cov2)), - } - } - }; - let w2 = 1.0 - w1; - let dmu = m1v - m2v; - let dv = v1v - v2v; - let mu_sx = m1x - w2 * g1 * dmu; - let mu_sy = m2y + w1 * g2 * dmu; - let var_sx = v1x - w2 * g1 * g1 * dv + w1 * w2 * g1 * g1 * dmu * dmu; - let var_sy = v2y + w1 * g2 * g2 * dv + w1 * w2 * g2 * g2 * dmu * dmu; - if var_sx <= 0.0 || var_sy <= 0.0 { - return Err("synthetic variance is non-positive (degenerate equating)".into()); - } - let a = var_sy.sqrt() / var_sx.sqrt(); - let b = mu_sy - a * mu_sx; - let y_eq: Vec = (0..=k_x).map(|x| a * x as f64 + b).collect(); - Ok(EquateResult { - x_scores: (0..=k_x).map(|x| x as f64).collect(), - y_equivalents: y_eq, - mu_x: mu_sx, - sigma_x: var_sx.sqrt(), - mu_y: mu_sy, - sigma_y: var_sy.sqrt(), - // the linear conversion maps the synthetic X moments onto the synthetic Y - // moments exactly, so the equated-score moments are (mu_sy, sigma_sy) - mu_eq: mu_sy, - sigma_eq: var_sy.sqrt(), - slope: a, - intercept: b, - n_x: x_total.len(), - n_y: y_total.len(), - h_x: f64::NAN, - h_y: f64::NAN, - }) -} - -// ===================== standard errors of equating ===================== - -/// Per-score-point standard errors of equating ([`bootstrap_see`] / -/// [`analytic_see`]): the sampling error of the conversion `y_equivalents[x]`. -pub struct SeeResult { - pub x_scores: Vec, - /// Point estimate `e_Y(x)` from equating the full original sample. - pub y_equivalents: Vec, - /// Standard error of equating at each score point. - pub se: Vec, - pub ci_lo: Vec, - pub ci_hi: Vec, - /// Bootstrap replicate count (0 for the analytic route). - pub n_boot: usize, - pub ci_level: f64, -} - -/// Type-7 (linear-interpolated, NumPy-default) quantile of a pre-sorted slice. -fn quantile_type7(sorted: &[f64], p: f64) -> f64 { - let n = sorted.len(); - if n == 1 { - return sorted[0]; - } - let h = (n as f64 - 1.0) * p; - let lo = h.floor() as usize; - let frac = h - lo as f64; - if lo + 1 < n { - sorted[lo] + frac * (sorted[lo + 1] - sorted[lo]) - } else { - sorted[n - 1] - } -} - -/// Nonparametric bootstrap standard errors of equating for the equivalent-groups -/// design (Kolen & Brennan, 2014, ch. 7; Efron & Tibshirani, 1993). Resamples -/// examinees **with replacement, per group independently, at the observed sample -/// sizes** (the two forms are given to separate random samples, so their sampling -/// errors are independent), re-equates each of `n_boot` replicates via -/// [`equate_eg`] unchanged, and returns the per-score bootstrap SD (divisor -/// `n_boot - 1`) and a percentile confidence interval at `ci_level`. Works for all -/// three EG methods, including equipercentile (which has no simple analytic SEE). -/// -/// # References (APA 7th ed.) -/// -/// Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and linking: -/// Methods and practices* (3rd ed.). Springer. -/// https://doi.org/10.1007/978-1-4939-0317-7 -/// -/// Efron, B., & Tibshirani, R. J. (1993). *An introduction to the bootstrap*. -/// Chapman & Hall. -#[allow(clippy::too_many_arguments)] -pub fn bootstrap_see( - x_scores: &[f64], - y_scores: &[f64], - k_x: usize, - k_y: usize, - method: EquateMethod, - n_boot: usize, - ci_level: f64, - seed: u64, -) -> Result { - if !(0.0 < ci_level && ci_level < 1.0) { - return Err("ci_level must be in (0, 1)".into()); - } - if n_boot < 2 { - return Err("n_boot must be >= 2".into()); - } - let point = equate_eg(x_scores, y_scores, k_x, k_y, method)?; - let (nx, ny) = (x_scores.len(), y_scores.len()); - let ncol = k_x + 1; - let rep_cells = crate::checked_mul_usize( - n_boot, - ncol, - "n_boot * (k_x + 1) exceeds the bootstrap buffer size", - )?; - let mut reps = vec![0.0_f64; rep_cells]; - let mut st = seed.max(1); - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut xb = vec![0.0_f64; nx]; - let mut yb = vec![0.0_f64; ny]; - for b in 0..n_boot { - for v in xb.iter_mut() { - *v = x_scores[((u() * nx as f64) as usize).min(nx - 1)]; - } - for v in yb.iter_mut() { - *v = y_scores[((u() * ny as f64) as usize).min(ny - 1)]; - } - let r = equate_eg(&xb, &yb, k_x, k_y, method)?; - reps[b * ncol..(b + 1) * ncol].copy_from_slice(&r.y_equivalents); - } - let alpha = 1.0 - ci_level; - let mut se = vec![0.0_f64; ncol]; - let mut ci_lo = vec![0.0_f64; ncol]; - let mut ci_hi = vec![0.0_f64; ncol]; - let mut col = vec![0.0_f64; n_boot]; - for x in 0..ncol { - for b in 0..n_boot { - col[b] = reps[b * ncol + x]; - } - let mean = col.iter().sum::() / n_boot as f64; - let var = col.iter().map(|&v| (v - mean).powi(2)).sum::() / (n_boot as f64 - 1.0); - se[x] = var.sqrt(); - col.sort_by(|a, b| a.partial_cmp(b).unwrap()); - ci_lo[x] = quantile_type7(&col, alpha / 2.0); - ci_hi[x] = quantile_type7(&col, 1.0 - alpha / 2.0); - } - Ok(SeeResult { - x_scores: point.x_scores, - y_equivalents: point.y_equivalents, - se, - ci_lo, - ci_hi, - n_boot, - ci_level, - }) -} - -/// Closed-form delta-method (normal-theory) standard errors of equating for the -/// Mean and Linear equivalent-groups methods (Kolen & Brennan, 2014, ch. 7; -/// Braun & Holland, 1982). With `z = (x - mu_x)/sigma_x`, -/// `Var[e_Y(x)] = sigma_x^2/n_x + sigma_y^2/n_y` (Mean, constant in `x`) or -/// `sigma_y^2 (1 + z^2/2)(1/n_x + 1/n_y)` (Linear); the interval is the -/// asymptotic-normal `point +/- z_c * SE`. Errors on equipercentile — bootstrap -/// that with [`bootstrap_see`]. Exact only for approximately normal score -/// distributions. -pub fn analytic_see( - x_scores: &[f64], - y_scores: &[f64], - k_x: usize, - k_y: usize, - method: EquateMethod, - ci_level: f64, -) -> Result { - if !(0.0 < ci_level && ci_level < 1.0) { - return Err("ci_level must be in (0, 1)".into()); - } - if method == EquateMethod::Equipercentile { - return Err( - "analytic_see supports only Mean and Linear; use bootstrap_see for equipercentile" - .into(), - ); - } - let res = equate_eg(x_scores, y_scores, k_x, k_y, method)?; - let (nx, ny) = (res.n_x as f64, res.n_y as f64); - let (sx, sy) = (res.sigma_x, res.sigma_y); - if sx <= 0.0 { - return Err("analytic SEE needs a positive SD on form X".into()); - } - let z_c = crate::nodes::inv_normal_cdf(1.0 - (1.0 - ci_level) / 2.0); - let mut se = vec![0.0_f64; k_x + 1]; - let mut ci_lo = vec![0.0_f64; k_x + 1]; - let mut ci_hi = vec![0.0_f64; k_x + 1]; - for x in 0..=k_x { - let var = if method == EquateMethod::Mean { - sx * sx / nx + sy * sy / ny - } else { - // Equipercentile was rejected above, so the remaining method is Linear. - let z = (x as f64 - res.mu_x) / sx; - sy * sy * (1.0 + z * z / 2.0) * (1.0 / nx + 1.0 / ny) - }; - se[x] = var.sqrt(); - ci_lo[x] = res.y_equivalents[x] - z_c * se[x]; - ci_hi[x] = res.y_equivalents[x] + z_c * se[x]; - } - Ok(SeeResult { - x_scores: res.x_scores, - y_equivalents: res.y_equivalents, - se, - ci_lo, - ci_hi, - n_boot: 0, - ci_level, - }) -} - -// ===================== log-linear presmoothing ===================== - -/// Result of [`loglinear_smooth`]. -pub struct LoglinearFit { - /// Smoothed relative-frequency density (sums to 1). - pub probs: Vec, - /// Poisson log-likelihood (up to the `-sum ln(N_x!)` constant, so it is - /// comparable across degrees on the *same* data, not across datasets). - pub log_lik: f64, - pub aic: f64, - pub bic: f64, - /// Fitted raw moments on the scaled score `u = x/k`, orders `1..=degree`; - /// equal to the sample moments to numerical precision (the defining property). - pub moments: Vec, - pub converged: bool, - pub iters: usize, - /// Stopping rule that ended Newton iteration: `gradient_tolerance`, - /// `log_likelihood_tolerance`, `line_search_stalled`, or `max_iter`. - pub termination_reason: String, - /// Maximum absolute Poisson score component at the returned coefficients. - pub final_gradient_max: f64, - /// Scale-adjusted score tolerance used for the convergence decision. - pub gradient_tolerance: f64, -} - -/// Orthonormal polynomial design matrix `B` of shape `(k+1) x (degree+1)` over the -/// scores `0..=k`: column `j` spans degree `j`, `B^T B = I`. Built by modified -/// Gram-Schmidt on the Vandermonde of a *centered/scaled* score `u = 2x/k - 1` -/// (raw-`x` powers are catastrophically ill-conditioned for `k` in the tens). -fn ortho_poly_design(k: usize, degree: usize) -> Vec> { - let n = k + 1; - let t = degree + 1; - let u: Vec = (0..n) - .map(|x| { - if k == 0 { - 0.0 - } else { - 2.0 * x as f64 / k as f64 - 1.0 - } - }) - .collect(); - let mut cols: Vec> = (0..t) - .map(|j| u.iter().map(|&ui| ui.powi(j as i32)).collect()) - .collect(); - for j in 0..t { - for i in 0..j { - let dot: f64 = (0..n).map(|r| cols[j][r] * cols[i][r]).sum(); - for r in 0..n { - cols[j][r] -= dot * cols[i][r]; - } - } - let norm: f64 = (0..n).map(|r| cols[j][r] * cols[j][r]).sum::().sqrt(); - if norm > 0.0 { - for r in 0..n { - cols[j][r] /= norm; - } - } - } - (0..n) - .map(|r| (0..t).map(|j| cols[j][r]).collect()) - .collect() -} - -/// Univariate log-linear presmoothing of a score-frequency distribution (Holland & -/// Thayer, 2000; Kolen & Brennan, 2014, ch. 3): fits `log m_x = (B beta)_x` by -/// Poisson ML, so the smoothed density preserves the first `degree` sample moments -/// exactly while damping sampling noise. `counts` are raw frequencies over scores -/// `0..=k` (length `k+1`); `degree` is the number of moments preserved -/// (`degree = k` reproduces the raw relative frequencies). -/// -/// # References (APA 7th ed.) -/// -/// Holland, P. W., & Thayer, D. T. (2000). Univariate and bivariate loglinear -/// models for discrete test score distributions. *Journal of Educational and -/// Behavioral Statistics, 25*(2), 133–183. https://doi.org/10.3102/10769986025002133 -pub fn loglinear_smooth(counts: &[f64], degree: usize) -> Result { - let n_cells = counts.len(); - if n_cells < 2 { - return Err("counts must cover at least two scores".into()); - } - let k = n_cells - 1; - if degree < 1 || degree > k { - return Err("degree must be in 1..=k".into()); - } - if counts.iter().any(|&c| !c.is_finite() || c < 0.0) { - return Err("counts must be finite and non-negative".into()); - } - let total: f64 = counts.iter().sum(); - if total <= 0.0 { - return Err("counts must sum to a positive total".into()); - } - let t = degree + 1; - let b = ortho_poly_design(k, degree); - let eta_of = |beta: &[f64], x: usize| -> f64 { (0..t).map(|j| b[x][j] * beta[j]).sum() }; - let ll = |beta: &[f64]| -> f64 { - (0..n_cells) - .map(|x| { - let e = eta_of(beta, x); - counts[x] * e - e.exp() - }) - .sum() - }; - let mut beta = vec![0.0_f64; t]; - let mut converged = false; - let mut iters = 0usize; - let mut termination_reason = "max_iter"; - let mut prev_ll = ll(&beta); - // Scale-free tolerances: the gradient B^T(counts-m) is O(N) and the - // log-likelihood O(N), so absolute floors would never be reached for large - // samples (spuriously reporting non-convergence). Test relative to the total. - let gtol = 1e-9 * total.max(1.0); - const MAX_IT: usize = 50; - for it in 0..MAX_IT { - iters = it + 1; - let m: Vec = (0..n_cells).map(|x| eta_of(&beta, x).exp()).collect(); - let grad: Vec = (0..t) - .map(|j| (0..n_cells).map(|x| b[x][j] * (counts[x] - m[x])).sum()) - .collect(); - let gmax = grad.iter().fold(0.0_f64, |a, &g| a.max(g.abs())); - if gmax < gtol { - converged = true; - termination_reason = "gradient_tolerance"; - break; - } - let mut hess = vec![vec![0.0_f64; t]; t]; - for a in 0..t { - for c in a..t { - let v: f64 = (0..n_cells).map(|x| b[x][a] * m[x] * b[x][c]).sum(); - hess[a][c] = v; - hess[c][a] = v; - } - } - let step = crate::poly::solve_small(hess, grad); - let ll_tol = 1e-12 * prev_ll.abs().max(1.0); - let mut lambda = 1.0_f64; - let mut accepted = false; - for _ in 0..30 { - let trial: Vec = (0..t).map(|j| beta[j] + lambda * step[j]).collect(); - let llt = ll(&trial); - if llt >= prev_ll - ll_tol { - beta = trial; - // a step with negligible relative improvement means the fit has - // plateaued at the optimum — treat as converged, not stalled - if llt - prev_ll <= ll_tol { - converged = true; - termination_reason = "log_likelihood_tolerance"; - } - prev_ll = llt; - accepted = true; - break; - } - lambda *= 0.5; - } - if !accepted { - termination_reason = "line_search_stalled"; - break; - } - if converged { - break; - } - } - let m: Vec = (0..n_cells).map(|x| eta_of(&beta, x).exp()).collect(); - let final_gradient_max = (0..t) - .map(|j| { - (0..n_cells) - .map(|x| b[x][j] * (counts[x] - m[x])) - .sum::() - .abs() - }) - .fold(0.0_f64, f64::max); - let msum: f64 = m.iter().sum(); - let probs: Vec = m.iter().map(|&mx| mx / msum).collect(); - let log_lik = ll(&beta); - let p = t as f64; - let aic = -2.0 * log_lik + 2.0 * p; - let bic = -2.0 * log_lik + p * total.ln(); - let moments: Vec = (1..=degree) - .map(|j| { - (0..n_cells) - .map(|x| { - let u = if k == 0 { 0.0 } else { x as f64 / k as f64 }; - u.powi(j as i32) * probs[x] - }) - .sum() - }) - .collect(); - Ok(LoglinearFit { - probs, - log_lik, - aic, - bic, - moments, - converged, - iters, - termination_reason: termination_reason.into(), - final_gradient_max, - gradient_tolerance: gtol, - }) -} - -// ===================== Gaussian-kernel equating ===================== - -/// Continuization kernel for the equipercentile family. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Continuization { - /// Kolen-Brennan uniform kernel (linear cdf interpolation) — the default, - /// identical to [`equate_eg`]'s equipercentile. - Uniform, - /// Gaussian kernel (von Davier, Holland & Thayer, 2004). - Gaussian, -} - -impl Continuization { - pub fn parse(name: &str) -> Option { - match name.to_ascii_lowercase().replace(['-', '_'], "").as_str() { - "uniform" | "kb" | "equipercentile" => Some(Continuization::Uniform), - "gaussian" | "kernel" | "normal" => Some(Continuization::Gaussian), - _ => None, - } - } -} - -/// Options for [`equate_eg_ext`]: continuization kernel, optional per-form -/// log-linear presmoothing degree, and optional fixed Gaussian bandwidths (`None` -/// = penalty-selected). -pub struct EgSmoothOptions { - pub continuization: Continuization, - pub smooth_degree_x: Option, - pub smooth_degree_y: Option, - pub bandwidth_x: Option, - pub bandwidth_y: Option, -} - -fn norm_pdf(z: f64) -> f64 { - (-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt() -} -fn norm_cdf(z: f64) -> f64 { - 0.5 * crate::fitstats::erfc(-z / std::f64::consts::SQRT_2) -} -fn kernel_a(sig2: f64, h: f64) -> f64 { - (sig2 / (sig2 + h * h)).sqrt() -} -fn kernel_cdf(r: &[f64], mu: f64, sig2: f64, h: f64, x: f64) -> f64 { - let a = kernel_a(sig2, h); - let ah = a * h; - r.iter() - .enumerate() - .map(|(j, &rj)| rj * norm_cdf((x - a * j as f64 - (1.0 - a) * mu) / ah)) - .sum() -} -fn kernel_pdf(r: &[f64], mu: f64, sig2: f64, h: f64, x: f64) -> f64 { - let a = kernel_a(sig2, h); - let ah = a * h; - r.iter() - .enumerate() - .map(|(j, &rj)| rj * norm_pdf((x - a * j as f64 - (1.0 - a) * mu) / ah) / ah) - .sum() -} -fn kernel_dpdf(r: &[f64], mu: f64, sig2: f64, h: f64, x: f64) -> f64 { - let a = kernel_a(sig2, h); - let ah = a * h; - r.iter() - .enumerate() - .map(|(j, &rj)| { - let z = (x - a * j as f64 - (1.0 - a) * mu) / ah; - rj * (-z) * norm_pdf(z) / (ah * ah) - }) - .sum() -} -/// `G_h^{-1}(p)` by safeguarded Newton (bisection fallback); `F_h` is strictly -/// increasing with full support, so the root is unique. -fn kernel_inv(r: &[f64], mu: f64, sig2: f64, h: f64, p: f64, k: usize) -> f64 { - let mut lo = -0.5_f64; - let mut hi = k as f64 + 0.5; - let mut guard = 0; - while kernel_cdf(r, mu, sig2, h, lo) > p && guard < 200 { - lo -= 1.0; - guard += 1; - } - guard = 0; - while kernel_cdf(r, mu, sig2, h, hi) < p && guard < 200 { - hi += 1.0; - guard += 1; - } - let mut x = 0.5 * (lo + hi); - for _ in 0..100 { - let fx = kernel_cdf(r, mu, sig2, h, x) - p; - if fx.abs() < 1e-10 { - break; - } - if fx > 0.0 { - hi = x; - } else { - lo = x; - } - let d = kernel_pdf(r, mu, sig2, h, x); - let xn = if d > 1e-12 { x - fx / d } else { f64::NAN }; - x = if xn > lo && xn < hi { - xn - } else { - 0.5 * (lo + hi) - }; - } - x -} -fn kernel_equate( - rx: &[f64], - ry: &[f64], - mu_x: f64, - s2x: f64, - mu_y: f64, - s2y: f64, - k_x: usize, - k_y: usize, - h_x: f64, - h_y: f64, -) -> Vec { - (0..=k_x) - .map(|x| { - let p = kernel_cdf(rx, mu_x, s2x, h_x, x as f64); - kernel_inv(ry, mu_y, s2y, h_y, p, k_y) - }) - .collect() -} -/// von Davier penalty: squared density mismatch at the score points plus a -/// unit penalty for each local density valley (an under-smoothing signature). -fn kernel_penalty(r: &[f64], mu: f64, sig2: f64, h: f64, k: usize) -> f64 { - let delta = 1e-3; - let mut pen = 0.0_f64; - for j in 0..=k { - let xj = j as f64; - let d = r[j] - kernel_pdf(r, mu, sig2, h, xj); - pen += d * d; - let a_ind = kernel_dpdf(r, mu, sig2, h, xj - delta) < 0.0; - let b_ind = kernel_dpdf(r, mu, sig2, h, xj + delta) > 0.0; - if a_ind && b_ind { - pen += 1.0; - } - } - pen -} -fn expanded_upper_bandwidth(best_h: f64, upper: f64) -> f64 { - if best_h >= upper - 1e-9 { - 2.0 * upper - } else { - upper - } -} -/// Penalty-optimal bandwidth: coarse grid to bracket the (non-smooth) valley -/// indicator, then golden-section refinement to grid resolution (heuristic — any -/// `h` preserves the mean/variance, so this only tunes smoothing, not validity). -fn optimal_bandwidth(r: &[f64], mu: f64, sig2: f64, k: usize) -> f64 { - let mut lo = 0.1_f64; - let mut hi = 3.0_f64; - let n_grid = 40usize; - let mut best_h = lo; - let mut best_p = f64::INFINITY; - for i in 0..=n_grid { - let h = lo + (hi - lo) * i as f64 / n_grid as f64; - let p = kernel_penalty(r, mu, sig2, h, k); - if p < best_p { - best_p = p; - best_h = h; - } - } - if best_h <= lo + 1e-9 { - lo = 0.02; - } - hi = expanded_upper_bandwidth(best_h, hi); - let cell = (hi - lo) / n_grid as f64; - let mut a = (best_h - cell).max(lo); - let mut b = (best_h + cell).min(hi); - let gr = (5.0_f64.sqrt() - 1.0) / 2.0; - let mut c = b - gr * (b - a); - let mut d = a + gr * (b - a); - let mut fc = kernel_penalty(r, mu, sig2, c, k); - let mut fd = kernel_penalty(r, mu, sig2, d, k); - for _ in 0..30 { - if fc < fd { - b = d; - d = c; - fd = fc; - c = b - gr * (b - a); - fc = kernel_penalty(r, mu, sig2, c, k); - } else { - a = c; - c = d; - fc = fd; - d = a + gr * (b - a); - fd = kernel_penalty(r, mu, sig2, d, k); - } - } - // The penalty is non-unimodal (a discontinuous valley indicator), so - // golden-section can land in a worse cell than the grid already found; keep - // whichever the penalty actually rates lower. - let h_g = 0.5 * (a + b); - if kernel_penalty(r, mu, sig2, h_g, k) <= best_p { - h_g - } else { - best_h - } -} - -fn density(scores: &[f64], k: usize, smooth: Option) -> Result, String> { - let g = rel_freq(scores, k)?; - match smooth { - None => Ok(g), - Some(t) => { - let n = scores.len() as f64; - let counts: Vec = g.iter().map(|&p| p * n).collect(); - let fit = loglinear_smooth(&counts, t)?; - if !fit.converged { - return Err(format!( - "log-linear presmoothing did not converge: reason={}, iterations={}, max|score|={:.3e}, tolerance={:.3e}", - fit.termination_reason, - fit.iters, - fit.final_gradient_max, - fit.gradient_tolerance - )); - } - // A finite-input fit can only be marked converged after a finite - // gradient or log-likelihood stopping check; its normalized - // probabilities are therefore finite here. - Ok(fit.probs) - } - } -} - -fn validate_optional_bandwidth(value: Option, name: &str) -> Result<(), String> { - match value { - Some(value) if !value.is_finite() || value <= 0.0 => { - Err(format!("{name} must be positive and finite")) - } - _ => Ok(()), - } -} - -fn bandwidth_or_optimal(value: Option, r: &[f64], mu: f64, sig2: f64, k: usize) -> f64 { - match value { - Some(value) => value, - None => optimal_bandwidth(r, mu, sig2, k), - } -} - -/// Equipercentile-family equivalent-groups equating with optional log-linear -/// presmoothing and a choice of continuization kernel (Kolen & Brennan, 2014; von -/// Davier, Holland & Thayer, 2004). With `Continuization::Uniform` and no -/// smoothing this is identical to [`equate_eg`]'s equipercentile method; -/// `Continuization::Gaussian` uses the Gaussian-kernel continuization, resolving -/// each form's bandwidth by the penalty method unless one is fixed in `opts`. -/// -/// # References (APA 7th ed.) -/// -/// von Davier, A. A., Holland, P. W., & Thayer, D. T. (2004). *The kernel method -/// of test equating*. Springer. https://doi.org/10.1007/b97446 -pub fn equate_eg_ext( - x_scores: &[f64], - y_scores: &[f64], - k_x: usize, - k_y: usize, - opts: EgSmoothOptions, -) -> Result { - if k_x == 0 || k_y == 0 { - return Err("k_x and k_y must be positive".into()); - } - let gx = density(x_scores, k_x, opts.smooth_degree_x)?; - let gy = density(y_scores, k_y, opts.smooth_degree_y)?; - let (mu_x, sigma_x) = moments(&gx); - let (mu_y, sigma_y) = moments(&gy); - - let (y_eq, h_x, h_y) = match opts.continuization { - // the uniform kernel ignores bandwidth entirely, so it is not validated here - Continuization::Uniform => (equipercentile(&gx, &gy, k_x, k_y), f64::NAN, f64::NAN), - Continuization::Gaussian => { - for (h, nm) in [ - (opts.bandwidth_x, "bandwidth_x"), - (opts.bandwidth_y, "bandwidth_y"), - ] { - validate_optional_bandwidth(h, nm)?; - } - let (s2x, s2y) = (sigma_x * sigma_x, sigma_y * sigma_y); - if s2x <= 0.0 || s2y <= 0.0 { - return Err("gaussian kernel equating needs a positive SD on both forms".into()); - } - let hx = bandwidth_or_optimal(opts.bandwidth_x, &gx, mu_x, s2x, k_x); - let hy = bandwidth_or_optimal(opts.bandwidth_y, &gy, mu_y, s2y, k_y); - ( - kernel_equate(&gx, &gy, mu_x, s2x, mu_y, s2y, k_x, k_y, hx, hy), - hx, - hy, - ) - } - }; - let (mu_eq, sigma_eq) = weighted_moments(&y_eq, &gx); - Ok(EquateResult { - x_scores: (0..=k_x).map(|x| x as f64).collect(), - y_equivalents: y_eq, - mu_x, - sigma_x, - mu_y, - sigma_y, - mu_eq, - sigma_eq, - slope: f64::NAN, - intercept: f64::NAN, - n_x: x_scores.len(), - n_y: y_scores.len(), - h_x, - h_y, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/equating_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/exposure.rs b/crates/mlsirm-core/src/exposure.rs deleted file mode 100644 index 6e0c34a4f..000000000 --- a/crates/mlsirm-core/src/exposure.rs +++ /dev/null @@ -1,1268 +0,0 @@ -//! Item-exposure control designs for computerized adaptive testing: -//! Sympson-Hetter calibration ([`sympson_hetter`]) and the a-stratified -//! multistage selection design ([`a_stratified`]; see its docs below). -//! -//! Implements the unconditional Sympson-Hetter (1985) probabilistic exposure -//! filter and its iterative calibration for a fixed-length, unidimensional, -//! dichotomous (3PL; 2PL when `c = 0`) maximum-information CAT. -//! -//! Source status: the original Sympson & Hetter (1985) proceedings paper was -//! NOT readable and is cited as attributed. Every implemented rule below was -//! verified against readable secondary sources that restate the algorithm: -//! Georgiadou, Triantafillou & Economides (2007, p. 12: the `P(S)`, `P(A|S)`, -//! `P(A) = P(A|S) P(S)` decomposition, the uniform-draw gate, blocking a -//! rejected item for the rest of the examinee's test, and iterative -//! calibration on a sample from a typical ability distribution) and Barrada, -//! Olea & Ponsoda (2007, Eq. 1-3: the target `max_i P(A_i) <= r_max` and the -//! update `k_i <- 1` if `P(S_i) <= r_max`, else `k_i <- r_max / P(S_i)`), -//! plus the mirtCAT R source (Chalmers, 2016; implementation evidence for -//! the per-encounter uniform draw and item invalidation). -//! -//! Algorithm (one calibration cycle): -//! -//! 1. Simulate `n_simulees` examinees with `theta ~ N(0, 1)` (the target -//! population; the normal default is an implementation choice also used by -//! Barrada et al., 2007, not a requirement of the method). -//! 2. Each examinee takes a fixed-length test: at each step the not-yet -//! administered, not-blocked item with maximum Fisher information at the -//! current interim EAP is SELECTED; a fresh `u ~ U(0, 1)` is drawn and the -//! item is ADMINISTERED iff `u <= k_i`, otherwise it is blocked for the -//! remainder of that examinee's test and the next-best item is considered. -//! 3. After the cycle, with `P(S_i)` and `P(A_i)` the per-simulee selection -//! and administration proportions: if `max_i P(A_i) <= r_max + tol` the -//! calibration has reached its target and stops; otherwise -//! `k_i <- min(1, r_max / P(S_i))` when `P(S_i) > r_max`, else `k_i <- 1` -//! (Barrada et al., 2007, Eq. 3), and the next cycle runs. -//! -//! Convergence is NOT guaranteed by the method (van der Linden, 2003, -//! abstract: the iterative process "does not guarantee admissibility"); -//! the result reports `converged` plus the per-cycle max-exposure history and -//! the calibration loop is bounded by `max_iter`. -//! -//! Exhausted-pool policy (explicit repository choice, not a rule from the -//! read sources): if every remaining item is rejected before the test reaches -//! `test_length`, the run fails with an error rather than force-administering -//! the last selected item. -//! -//! Feasibility (derived here from the counting identity, not from a source): -//! each simulee is administered exactly `test_length` items, so -//! `sum_i P(A_i) = test_length` and `max_i P(A_i) >= test_length / n_items`; -//! `r_max` below that bound is rejected as infeasible. The bound is -//! NECESSARY, not sufficient: at or near `r_max = test_length / n_items` -//! every item must be administered on nearly every selection, so the -//! stochastic gate can still exhaust the pool mid-test and the run then -//! fails with the documented pool-exhausted error rather than force- -//! administering an item (see the exhausted-pool policy above). -//! -//! When `r_max >= 1` every `k_i` stays 1, the uniform gate is skipped -//! entirely (no exposure RNG is consumed), and the procedure reduces exactly -//! to unconstrained maximum-information CAT. -//! -//! The interim/final ability estimate is EAP with an `N(0, 1)` prior on a -//! uniform grid over `[-4, 4]`; item information reuses -//! [`crate::scoring::item_information_4pl`]. -//! -//! # References -//! -//! Barrada, J. R., Olea, J., & Ponsoda, V. (2007). Methods for restricting -//! maximum exposure rate in computerized adaptive testing. *Methodology, -//! 3*(1), 14-23. -//! -//! Chalmers, R. P. (2016). Generating adaptive and non-adaptive test -//! interfaces for multidimensional item response theory applications. -//! *Journal of Statistical Software, 71*(5), 1-38. -//! (mirtCAT; the package R source -//! was read as implementation evidence.) -//! -//! Georgiadou, E., Triantafillou, E., & Economides, A. A. (2007). A review of -//! item exposure control strategies for computerized adaptive testing -//! developed from 1983 to 2005. *Journal of Technology, Learning, and -//! Assessment, 5*(8). -//! -//! Sympson, J. B., & Hetter, R. D. (1985). Controlling item-exposure rates in -//! computerized adaptive testing. In *Proceedings of the 27th annual meeting -//! of the Military Testing Association* (pp. 973-977). Navy Personnel -//! Research and Development Center. (As cited in Georgiadou et al., 2007, and -//! Barrada et al., 2007; not read.) -//! -//! van der Linden, W. J. (2003). Some alternatives to Sympson-Hetter -//! item-exposure control in computerized adaptive testing. *Journal of -//! Educational and Behavioral Statistics, 28*(3), 249-265. (Abstract only -//! was read.) - -use crate::scoring::item_information_4pl; - -/// Configuration for [`sympson_hetter`] calibration. -#[derive(Clone, Debug)] -pub struct SympsonHetterConfig { - /// Target maximum exposure rate `r_max` in `(0, 1]`. - pub r_max: f64, - /// Fixed test length `L` (items administered per simulee). - pub test_length: usize, - /// Simulees per calibration cycle. - pub n_simulees: usize, - /// Maximum calibration cycles (the method does not guarantee - /// convergence; van der Linden, 2003, abstract). - pub max_iter: usize, - /// Monte-Carlo tolerance on the stopping rule `max P(A) <= r_max + tol`. - pub tol: f64, - /// RNG seed (deterministic LCG; the crate's inline PRNG idiom). - pub seed: u64, - /// EAP quadrature points over `[-4, 4]`. - pub q_theta: usize, -} - -impl Default for SympsonHetterConfig { - fn default() -> Self { - Self { - r_max: 0.25, - test_length: 20, - n_simulees: 1000, - max_iter: 20, - tol: 0.02, - seed: 20250724, - q_theta: 41, - } - } -} - -/// Result of a Sympson-Hetter calibration run. The returned `k` is always -/// the vector that produced the reported final-cycle rates (the Eq. 3 update -/// is skipped after the last cycle). -#[derive(Clone, Debug)] -pub struct SympsonHetterResult { - /// Final exposure-control parameters `k_i = P(A_i | S_i)`, in `(0, 1]`. - pub k: Vec, - /// Administration rates `P(A_i)` from the final cycle. - pub exposure: Vec, - /// Selection rates `P(S_i)` from the final cycle. - pub selection: Vec, - /// `max_i P(A_i)` from the final cycle. - pub max_exposure: f64, - /// Calibration cycles actually run. - pub n_iter: usize, - /// `max_exposure <= r_max + tol` reached within `max_iter` cycles. - pub converged: bool, - /// `max_i P(A_i)` after each cycle. - pub history_max_exposure: Vec, -} - -// Deterministic LCG + Box-Muller (the crate's inline PRNG idiom; see mhrm.rs). -struct Lcg(u64); -impl Lcg { - #[inline] - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - #[inline] - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -#[inline] -fn p3pl(theta: f64, a: f64, b: f64, c: f64) -> f64 { - c + (1.0 - c) / (1.0 + (-a * (theta - b)).exp()) -} - -/// EAP over a uniform grid on [-4, 4] with an N(0,1) prior, given the -/// administered responses so far (standard posterior-mean point estimate on -/// a discrete grid; the uniform grid is a repository implementation choice). -/// -/// `scratch` is a caller-supplied reusable buffer (at least `grid.len()` -/// capacity); it is cleared and overwritten on each call so that no heap -/// allocation is performed inside the hot simulee loop. -fn eap_interim( - a: &[f64], - b: &[f64], - c: &[f64], - responses: &[(usize, f64)], - grid: &[f64], - log_prior: &[f64], - scratch: &mut Vec, -) -> f64 { - scratch.clear(); - scratch.extend_from_slice(log_prior); - for (q, &t) in grid.iter().enumerate() { - for &(i, y) in responses { - let p = p3pl(t, a[i], b[i], c[i]).clamp(1e-12, 1.0 - 1e-12); - scratch[q] += if y > 0.5 { p.ln() } else { (1.0 - p).ln() }; - } - } - let m = scratch.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut num = 0.0; - let mut den = 0.0; - for (q, &t) in grid.iter().enumerate() { - let w = (scratch[q] - m).exp(); - num += w * t; - den += w; - } - num / den -} - -/// Calibrate Sympson-Hetter exposure-control parameters by iterative CAT -/// simulation. `a`, `b`, `c` are 3PL item parameters (`c = 0` gives 2PL). -/// See the module docs for the algorithm, sources, and policies. -pub fn sympson_hetter( - a: &[f64], - b: &[f64], - c: &[f64], - cfg: &SympsonHetterConfig, -) -> Result { - let n_items = a.len(); - if b.len() != n_items || c.len() != n_items { - return Err("a, b, c must have equal lengths".into()); - } - if n_items == 0 { - return Err("item pool is empty".into()); - } - if a.iter().any(|v| !v.is_finite() || *v <= 0.0) { - return Err("discriminations a must be finite and positive".into()); - } - if b.iter().any(|v| !v.is_finite()) { - return Err("difficulties b must be finite".into()); - } - if c.iter().any(|v| !v.is_finite() || *v < 0.0 || *v >= 1.0) { - return Err("guessing c must be finite and in [0, 1)".into()); - } - if !cfg.r_max.is_finite() || cfg.r_max <= 0.0 || cfg.r_max > 1.0 { - return Err("r_max must be in (0, 1]".into()); - } - if cfg.test_length == 0 || cfg.test_length > n_items { - return Err("test_length must be in 1..=n_items".into()); - } - // Counting identity (derived here): sum_i P(A_i) = test_length, so - // max_i P(A_i) >= test_length / n_items; a smaller r_max is infeasible. - if cfg.r_max < cfg.test_length as f64 / n_items as f64 { - return Err(format!( - "r_max = {} is infeasible: max exposure cannot fall below test_length/n_items = {}", - cfg.r_max, - cfg.test_length as f64 / n_items as f64 - )); - } - if cfg.n_simulees == 0 { - return Err("n_simulees must be positive".into()); - } - if cfg.max_iter == 0 { - return Err("max_iter must be positive".into()); - } - if !cfg.tol.is_finite() || cfg.tol < 0.0 { - return Err("tol must be finite and non-negative".into()); - } - if cfg.q_theta < 3 { - return Err("q_theta must be at least 3".into()); - } - - let grid: Vec = (0..cfg.q_theta) - .map(|q| -4.0 + 8.0 * q as f64 / (cfg.q_theta - 1) as f64) - .collect(); - let log_prior: Vec = grid.iter().map(|&t| -0.5 * t * t).collect(); - - let mut k = vec![1.0_f64; n_items]; - let mut rng = Lcg(cfg.seed.wrapping_mul(2654435761).wrapping_add(1)); - let mut history = Vec::with_capacity(cfg.max_iter); - let mut exposure = vec![0.0; n_items]; - let mut selection = vec![0.0; n_items]; - let mut converged = false; - let mut n_iter = 0; - // Reusable scratch buffer for eap_interim: allocated once, reused across - // all simulees and all administered items to avoid per-call heap allocation. - let mut eap_scratch: Vec = Vec::with_capacity(cfg.q_theta); - - for cycle in 0..cfg.max_iter { - n_iter += 1; - let mut s_count = vec![0u64; n_items]; - let mut a_count = vec![0u64; n_items]; - - for _p in 0..cfg.n_simulees { - let theta_true = rng.normal(); - let mut usable = vec![true; n_items]; // not administered, not blocked - let mut responses: Vec<(usize, f64)> = Vec::with_capacity(cfg.test_length); - let mut theta_hat = 0.0; - let mut administered = 0usize; - - while administered < cfg.test_length { - // SELECT: max information among usable items. - let mut best: Option = None; - let mut best_info = f64::NEG_INFINITY; - for i in 0..n_items { - if !usable[i] { - continue; - } - let p = p3pl(theta_hat, a[i], b[i], c[i]); - let info = item_information_4pl(a[i], p, c[i], 1.0); - if info > best_info { - best_info = info; - best = Some(i); - } - } - let Some(s) = best else { - // Explicit policy (see module docs): fail, do not force. - return Err( - "item pool exhausted before reaching test_length (all remaining items rejected)" - .into(), - ); - }; - s_count[s] += 1; - // GATE: skip the draw entirely when k = 1 so r_max >= 1 - // consumes no exposure RNG and reduces exactly to - // unconstrained max-info CAT. - let admit = k[s] >= 1.0 || rng.next_f64() <= k[s]; - usable[s] = false; // administered or blocked either way - if !admit { - continue; - } - a_count[s] += 1; - let p_true = p3pl(theta_true, a[s], b[s], c[s]); - let y = if rng.next_f64() < p_true { 1.0 } else { 0.0 }; - responses.push((s, y)); - administered += 1; - theta_hat = eap_interim(a, b, c, &responses, &grid, &log_prior, &mut eap_scratch); - } - } - - let n = cfg.n_simulees as f64; - for i in 0..n_items { - selection[i] = s_count[i] as f64 / n; - exposure[i] = a_count[i] as f64 / n; - } - let max_exposure = exposure.iter().cloned().fold(0.0_f64, f64::max); - history.push(max_exposure); - - if max_exposure <= cfg.r_max + cfg.tol { - converged = true; - break; - } - // Barrada et al. (2007), Eq. 3. Skipped after the final cycle so the - // returned k is always the vector that PRODUCED the reported rates. - if cycle + 1 < cfg.max_iter { - for i in 0..n_items { - k[i] = if selection[i] > cfg.r_max { - (cfg.r_max / selection[i]).min(1.0) - } else { - 1.0 - }; - } - } - } - - let max_exposure = *history.last().expect("at least one cycle ran"); - Ok(SympsonHetterResult { - k, - exposure, - selection, - max_exposure, - n_iter, - converged, - history_max_exposure: history, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/exposure_tests.rs"] -mod tests; - -// ===================== a-stratified multistage CAT ===================== - -/// Configuration for [`a_stratified`] simulation. -#[derive(Clone, Debug)] -pub struct AStratifiedConfig { - /// Number of strata `K` (also the number of stages). - pub n_strata: usize, - /// Fixed test length `L` (items administered per simulee). - pub test_length: usize, - /// Number of simulees in the evaluation run. - pub n_simulees: usize, - /// RNG seed (deterministic LCG; the crate's inline PRNG idiom). - pub seed: u64, - /// EAP quadrature points over `[-4, 4]`. - pub q_theta: usize, -} - -impl Default for AStratifiedConfig { - fn default() -> Self { - Self { - n_strata: 4, - test_length: 20, - n_simulees: 1000, - seed: 20250724, - q_theta: 41, - } - } -} - -/// Result of an a-stratified CAT simulation run. -#[derive(Clone, Debug)] -pub struct AStratifiedResult { - /// Administration rates `P(A_i) = count_i / n_simulees`. - pub exposure: Vec, - /// `max_i P(A_i)`. - pub max_exposure: f64, - /// Stratum index (`0..n_strata`, ascending discrimination) per item. - pub stratum: Vec, - /// Items administered in each stage (`sum = test_length`). - pub stage_lengths: Vec, - /// RMSE of the final EAP against the simulated true thetas. - pub theta_rmse: f64, - /// Mean of `theta_hat - theta_true`. - pub theta_bias: f64, -} - -/// a-stratified multistage CAT item-selection design (Chang & Ying, 1999), -/// evaluated by simulation. -/// -/// Source status: the Chang & Ying (1999) full text was NOT read; the -/// high-level design (early stages administer low-discrimination items, -/// later stages higher-discrimination items) was confirmed from the -/// publisher abstract, and the explicit selection rule was confirmed from -/// Barrada, Mazuela & Olea (2006, read in full), which restates the AS -/// method: sort the pool ascending by `a`, form contiguous strata, and at -/// each step administer the eligible item minimizing `|b_i - theta_hat|` -/// (b-matching, NOT maximum information — the point of the design is that -/// max-info greedily overexposes high-`a` items). -/// -/// Algorithm: -/// 1. Sort items ascending by `(a_i, index)` (stable tie-break; the index -/// tie-break is a repository choice). Partition the sorted order into -/// `K` contiguous strata; stratum sizes are near-equal with the first -/// `n mod K` strata one larger (remainder placement is a repository -/// choice — sources leave the partition sizes to the designer). -/// 2. Partition `test_length` into `K` stage lengths the same way (the -/// near-equal split is a repository choice; the paper-supported rule is -/// only that stage `k` draws exclusively from stratum `k`, in order). -/// 3. Within stage `k`, administer the not-yet-administered item of stratum -/// `k` minimizing `|b_i - theta_hat|`; ties break to the lowest original -/// item index (repository choice). -/// 4. Simulate `y ~ Bernoulli(P_3PL(theta_true))` with `theta_true ~ -/// N(0, 1)` and update `theta_hat` by interim EAP after each item. The -/// EAP estimator, its grid, and the initial `theta_hat = 0` are -/// repository implementation choices for parity with [`sympson_hetter`]; -/// they are NOT claimed as Chang & Ying's estimator (their simulations -/// used ML-based estimation). -/// -/// `c` is deliberately unused in stratification and selection (AS ignores -/// the guessing parameter; Barrada et al., 2006) but is used in the -/// simulated 3PL responses and the EAP update. -/// -/// Deferred (out of scope): b-blocking stratification (Chang, Qian & Ying, -/// 2001) and maximum-information stratification variants. -/// -/// Exact per-stratum counting identity (derived here): every simulee takes -/// exactly `stage_lengths[k]` items from stratum `k`, so -/// `sum_{i in stratum k} P(A_i) = stage_lengths[k]` and globally -/// `sum_i P(A_i) = test_length`. -/// -/// # References -/// -/// Barrada, J. R., Mazuela, P., & Olea, J. (2006). Maximum information -/// stratification method for controlling item exposure in computerized -/// adaptive testing. *Psicothema, 18*(1), 156-159. (Read in full; source -/// for the AS selection and stratification rules as restated there.) -/// -/// Chang, H.-H., & Ying, Z. (1999). a-Stratified multistage computerized -/// adaptive testing. *Applied Psychological Measurement, 23*(3), 211-222. -/// (Abstract only was read; -/// cited as the origin of the design.) -/// -/// Chang, H.-H., Qian, J., & Ying, Z. (2001). a-Stratified multistage -/// computerized adaptive testing with b blocking. *Applied Psychological -/// Measurement, 25*(4), 333-341. (Not read; cited only to name the deferred -/// b-blocking extension.) -pub fn a_stratified( - a: &[f64], - b: &[f64], - c: &[f64], - cfg: &AStratifiedConfig, -) -> Result { - let n_items = a.len(); - if b.len() != n_items || c.len() != n_items { - return Err("a, b, c must have equal lengths".into()); - } - if n_items == 0 { - return Err("item pool is empty".into()); - } - if a.iter().any(|v| !v.is_finite() || *v <= 0.0) { - return Err("discriminations a must be finite and positive".into()); - } - if b.iter().any(|v| !v.is_finite()) { - return Err("difficulties b must be finite".into()); - } - if c.iter().any(|v| !v.is_finite() || *v < 0.0 || *v >= 1.0) { - return Err("guessing c must be finite and in [0, 1)".into()); - } - if cfg.test_length == 0 || cfg.test_length > n_items { - return Err("test_length must be in 1..=n_items".into()); - } - if cfg.n_strata == 0 || cfg.n_strata > cfg.test_length { - return Err( - "n_strata must be in 1..=test_length (every stage administers >= 1 item)".into(), - ); - } - if cfg.n_simulees == 0 { - return Err("n_simulees must be positive".into()); - } - if cfg.q_theta < 3 { - return Err("q_theta must be at least 3".into()); - } - - let k_strata = cfg.n_strata; - // Near-equal partition with the first (remainder) parts one larger - // (repository choice; documented above). - let part = |total: usize| -> Vec { - let q = total / k_strata; - let r = total % k_strata; - (0..k_strata).map(|s| q + usize::from(s < r)).collect() - }; - let stratum_sizes = part(n_items); - let stage_lengths = part(cfg.test_length); - for s in 0..k_strata { - if stratum_sizes[s] < stage_lengths[s] { - return Err(format!( - "stratum {} has {} items but stage {} needs {}: pool cannot support this design", - s, stratum_sizes[s], s, stage_lengths[s] - )); - } - } - - // Stable ascending sort by (a, original index). - let mut order: Vec = (0..n_items).collect(); - order.sort_by(|&i, &j| a[i].partial_cmp(&a[j]).expect("finite a").then(i.cmp(&j))); - let mut stratum = vec![0usize; n_items]; - let mut members: Vec> = Vec::with_capacity(k_strata); - { - let mut pos = 0usize; - for (s, &sz) in stratum_sizes.iter().enumerate() { - let slice = &order[pos..pos + sz]; - for &i in slice { - stratum[i] = s; - } - members.push(slice.to_vec()); - pos += sz; - } - } - - let grid: Vec = (0..cfg.q_theta) - .map(|q| -4.0 + 8.0 * q as f64 / (cfg.q_theta - 1) as f64) - .collect(); - let log_prior: Vec = grid.iter().map(|&t| -0.5 * t * t).collect(); - - let mut rng = Lcg(cfg.seed.wrapping_mul(2654435761).wrapping_add(1)); - let mut a_count = vec![0u64; n_items]; - let mut eap_scratch: Vec = Vec::with_capacity(cfg.q_theta); - let mut sq_err = 0.0; - let mut bias = 0.0; - - for _p in 0..cfg.n_simulees { - let theta_true = rng.normal(); - let mut used = vec![false; n_items]; - let mut responses: Vec<(usize, f64)> = Vec::with_capacity(cfg.test_length); - let mut theta_hat = 0.0; - - for (stage, &len) in stage_lengths.iter().enumerate() { - for _ in 0..len { - // SELECT: b-matching within the active stratum only. - let mut best: Option = None; - let mut best_d = f64::INFINITY; - for &i in &members[stage] { - if used[i] { - continue; - } - let d = (b[i] - theta_hat).abs(); - let better = match best { - None => true, - Some(j) => d < best_d || (d == best_d && i < j), - }; - if better { - best_d = d; - best = Some(i); - } - } - let s = best.expect("stratum sizes validated >= stage lengths"); - used[s] = true; - a_count[s] += 1; - let p_true = p3pl(theta_true, a[s], b[s], c[s]); - let y = if rng.next_f64() < p_true { 1.0 } else { 0.0 }; - responses.push((s, y)); - theta_hat = eap_interim(a, b, c, &responses, &grid, &log_prior, &mut eap_scratch); - } - } - let e = theta_hat - theta_true; - sq_err += e * e; - bias += e; - } - - let n = cfg.n_simulees as f64; - let exposure: Vec = a_count.iter().map(|&x| x as f64 / n).collect(); - let max_exposure = exposure.iter().cloned().fold(0.0_f64, f64::max); - Ok(AStratifiedResult { - exposure, - max_exposure, - stratum, - stage_lengths, - theta_rmse: (sq_err / n).sqrt(), - theta_bias: bias / n, - }) -} - -// ===================== Kullback-Leibler (global information) item selection ===================== -// -// Chang and Ying's (1996) global-information criterion for CAT item selection. -// -// Source status: the original paper WAS consulted (Chang & Ying, 1996, -// Definitions 2.1-2.2 define item/test KL information with the expectation -// taken under the true parameter and note non-negativity and non-symmetry; -// Equation 17 constructs the interval index around the provisional estimate; -// Equation 18 motivates the shrinking half-width `delta_n = r / sqrt(n)`, with -// `r = 3` used in their Study 1). The Bernoulli form below was additionally -// verified against the catR implementation (Magis & Raiche, 2012, `KL.R`), -// which computes `P(th0) log[P(th0)/P(th)] + Q(th0) log[Q(th0)/Q(th)]`. -// -// Contract (explicit): [`kl_information`] returns the UNNORMALIZED area -// `integral_{theta0-delta}^{theta0+delta} K_i(theta || theta0) d theta`, not the -// interval average. For a common `delta` across items the argmax is identical; -// the pinned oracle constants in the tests are areas. -// -// Small-delta connection to Fisher information (derived here, verified -// independently by the adversarial spec review): with `u = theta - theta0`, -// `K(theta || theta0) = (1/2) I(theta0) u^2 + O(u^3)`, so the area is -// `I(theta0) delta^3 / 3 + O(delta^5)` -- the criterion collapses to local -// (Fisher) maximum-information selection as the interval shrinks. -// -// Integration is composite Simpson with 2048 panels (an implementation choice, -// not from the paper; error O(h^4) is ~1e-12 at CAT-scale half-widths, well -// inside the 1e-9 oracle tolerance used by the tests). -// -// References (APA 7th): -// Chang, H.-H., & Ying, Z. (1996). A global information approach to -// computerized adaptive testing. *Applied Psychological Measurement, -// 20*(3), 213-229. https://doi.org/10.1177/014662169602000303 -// Magis, D., & Raiche, G. (2012). Random generation of response patterns -// under computerized adaptive testing with the R package catR. *Journal -// of Statistical Software, 48*(8), 1-31. https://doi.org/10.18637/jss.v048.i08 - -/// Result of [`kl_select`]. -#[derive(Clone, Debug)] -pub struct KlSelectResult { - /// KL information index (unnormalized area) per pool item. Administered - /// items keep their computed index (masking applies to selection only). - pub index: Vec, - /// Selected item: argmax of `index` over non-administered items - /// (ties broken toward the LOWEST index, deterministically). - pub selected: usize, - /// The half-width actually used: `r / sqrt(n_administered)`. - pub delta: f64, -} - -/// Numerically stable `ln(1 + exp(z))` (softplus). -#[inline] -fn softplus(z: f64) -> f64 { - if z > 0.0 { - z + (-z).exp().ln_1p() - } else { - z.exp().ln_1p() - } -} - -/// `(ln P, ln Q)` for one 3PL item, computed in log space so extreme -/// `a * (theta - b)` never saturates: `Q = (1 - c) * sigma(-z)` gives -/// `ln Q = ln(1 - c) - softplus(z)` exactly, and for `c = 0` -/// `ln P = -softplus(-z)`. For `c > 0`, `P >= c` is bounded away from 0, so -/// the direct log is stable. -#[inline] -fn ln_pq_3pl(a: f64, b: f64, c: f64, theta: f64) -> (f64, f64) { - let z = a * (theta - b); - let ln_q = (1.0 - c).ln() - softplus(z); - let ln_p = if c == 0.0 { - -softplus(-z) - } else { - (c + (1.0 - c) / (1.0 + (-z).exp())).ln() - }; - (ln_p, ln_q) -} - -/// Pointwise Bernoulli KL divergence `K_i(theta || theta0)` for one 3PL item: -/// expectation under `theta0` (Chang & Ying, 1996, Definition 2.1). Computed -/// as `P0 (ln P0 - ln P) + Q0 (ln Q0 - ln Q)` entirely in log space -- no -/// probability clamping, so high-discrimination tails are integrated exactly -/// (a hard clamp at 1e-12 was measured to underestimate the -/// `a = 20, delta = 3` area by ~30%; see the pinned extreme-oracle test). -#[inline] -fn kl_pointwise(a: f64, b: f64, c: f64, theta: f64, theta0: f64) -> f64 { - let (ln_p0, ln_q0) = ln_pq_3pl(a, b, c, theta0); - let (ln_p, ln_q) = ln_pq_3pl(a, b, c, theta); - // Zero-weight terms contribute 0 by the KL convention `0 ln 0 = 0`; - // without the guard a saturated log pair (ln_q0 = ln_q = -inf at - // extreme finite `a * (theta - b)`) yields `0 * NaN`. - let term = |w: f64, d: f64| if w == 0.0 { 0.0 } else { w * d }; - term(ln_p0.exp(), ln_p0 - ln_p) + term(ln_q0.exp(), ln_q0 - ln_q) -} - -/// Chang-Ying (1996) KL information index for every item: the unnormalized -/// area of `K_i(theta || theta0)` over `[theta0 - delta, theta0 + delta]` -/// (Eq. 17 form; see the section comment for the exact contract and sources). -/// -/// `a`, `b`, `c` are 3PL parameters (`c = 0` gives 2PL). Errors on empty or -/// mismatched inputs, non-finite values, `a <= 0`, `c` outside `[0, 1)`, or -/// non-finite/non-positive `delta`. -pub fn kl_information( - a: &[f64], - b: &[f64], - c: &[f64], - theta0: f64, - delta: f64, -) -> Result, String> { - let n = a.len(); - if n == 0 { - return Err("kl_information: empty item pool".to_string()); - } - if b.len() != n || c.len() != n { - return Err(format!( - "kl_information: length mismatch (a={}, b={}, c={})", - n, - b.len(), - c.len() - )); - } - if !theta0.is_finite() { - return Err("kl_information: theta0 must be finite".to_string()); - } - if !delta.is_finite() || delta <= 0.0 { - return Err("kl_information: delta must be finite and > 0".to_string()); - } - for i in 0..n { - if !a[i].is_finite() || !b[i].is_finite() || !c[i].is_finite() { - return Err(format!("kl_information: non-finite parameter (item {i})")); - } - if a[i] <= 0.0 { - return Err(format!("kl_information: a must be > 0 (item {i})")); - } - if !(0.0..1.0).contains(&c[i]) { - return Err(format!("kl_information: c must be in [0, 1) (item {i})")); - } - } - // Composite Simpson, 2048 panels (even), over [theta0 - delta, theta0 + delta]. - const PANELS: usize = 2048; - let h = 2.0 * delta / PANELS as f64; - let mut out = Vec::with_capacity(n); - for i in 0..n { - let (ai, bi, ci) = (a[i], b[i], c[i]); - let mut s = kl_pointwise(ai, bi, ci, theta0 - delta, theta0) - + kl_pointwise(ai, bi, ci, theta0 + delta, theta0); - for k in 1..PANELS { - let t = theta0 - delta + k as f64 * h; - let w = if k % 2 == 1 { 4.0 } else { 2.0 }; - s += w * kl_pointwise(ai, bi, ci, t, theta0); - } - out.push(s * h / 3.0); - } - Ok(out) -} - -/// Select the next CAT item by the Chang-Ying (1996) KL criterion: -/// `argmax_i KL_i(theta0)` over non-administered items, with half-width -/// `delta = r / sqrt(n_administered)` (Eq. 18 rule; the paper's Study 1 uses -/// `r = 3`). Requires `n_administered >= 1` -- the shrinking-interval rule is -/// undefined at `n = 0`; for a first-item or fixed-interval variant call -/// [`kl_information`] with an explicit `delta` and take the argmax yourself. -pub fn kl_select( - a: &[f64], - b: &[f64], - c: &[f64], - administered: &[bool], - theta0: f64, - n_administered: usize, - r: f64, -) -> Result { - if administered.len() != a.len() { - return Err(format!( - "kl_select: administered mask length {} != pool size {}", - administered.len(), - a.len() - )); - } - if n_administered == 0 { - return Err( - "kl_select: n_administered must be >= 1 (delta = r / sqrt(n) is undefined at n = 0; \ - use kl_information with an explicit delta for the first item)" - .to_string(), - ); - } - if !r.is_finite() || r <= 0.0 { - return Err("kl_select: r must be finite and > 0".to_string()); - } - if administered.iter().all(|&u| u) { - return Err("kl_select: all items already administered".to_string()); - } - let delta = r / (n_administered as f64).sqrt(); - let index = kl_information(a, b, c, theta0, delta)?; - let mut selected = usize::MAX; - let mut best = f64::NEG_INFINITY; - for (i, &v) in index.iter().enumerate() { - if !administered[i] && v > best { - best = v; - selected = i; - } - } - Ok(KlSelectResult { - index, - selected, - delta, - }) -} - -// ===================== Owen (1975) approximate Bayesian sequential CAT ===================== -// -// Owen's restricted-Bayes sequential procedure for the three-parameter -// normal-ogive model: after each response the exact posterior is replaced by -// a normal with the posterior's exact first two moments, giving closed-form -// mean/variance recursions. -// -// Source status: the original JASA article (Owen, 1975) was NOT read (behind -// a paywall at review time). Every formula below was verified by the -// adversarial spec review against (a) van der Linden (1998), whose Appendix -// reproduces Owen's update equations A.1-A.6 including the guessing -// parameter, and (b) the open-source `irt` R package implementation -// (`src/est_ability_owen.cpp`, lines 31-56), and (c) an independent -// derivation via the joint-Gaussian representation `Y = a(theta - b) + Z`, -// `Z ~ N(0, 1)`: conditioning on `{Y > 0}` / `{Y < 0}` yields the -// truncated-bivariate-normal moment identities used here. High-precision -// numerical integration of the exact posterior confirmed all three pinned -// oracle cases to ~1e-13. -// -// Model: `P(X = 1 | theta) = c + (1 - c) Phi(a (theta - b))` with prior -// `theta ~ N(mu, sig2)`. Let `s2 = 1/a^2 + sig2`, `s = sqrt(s2)`, and -// `D = (mu - b)/s`. -// -// Correct response (`A = c + (1 - c) Phi(D)`, `K = (1 - c) phi(D) / A`): -// `mu' = mu + (sig2 / s) K` -// `sig2' = sig2 - (sig2^2 / s2) K (K + D)` -// Incorrect response (`c` cancels after normalization; `L = phi(D) / (1 - Phi(D))`): -// `mu' = mu - (sig2 / s) L` -// `sig2' = sig2 - (sig2^2 / s2) L (L - D)` -// -// Item selection is Owen's popular rule -- administer the unadministered item -// whose difficulty is closest to the current posterior mean (`|b_i - mu|`; -// argmin over a finite pool, ties to the lowest index). Minimum expected -// posterior variance is a distinct, more expensive criterion van der Linden -// (1998) attributes to Owen only as an alternative; it is NOT implemented -// here. Owen's stopping rule is a posterior-variance threshold; the fixed -// test length is an implementation cap, not the paper's rule. -// -// References (APA 7th): -// Owen, R. J. (1975). A Bayesian sequential procedure for quantal response in -// the context of adaptive mental testing. *Journal of the American -// Statistical Association, 70*(350), 351-356. -// https://doi.org/10.1080/01621459.1975.10479871 (NOT read; historical target) -// van der Linden, W. J. (1998). *Bayesian item selection criteria for adaptive -// testing* (Research Report 98-01). University of Twente. (ERIC ED424235; -// Appendix Eqs. A.1-A.6 verified) -// Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability in -// a microcomputer environment. *Applied Psychological Measurement, 6*(4), -// 431-444. https://doi.org/10.1177/014662168200600405 (READ; framing of -// posterior-mean estimate and variance-based termination) - -/// Result of [`owen_cat`]. -#[derive(Clone, Debug)] -pub struct OwenCatResult { - /// Items administered, in administration order. - pub administered: Vec, - /// Posterior mean after each administered item. - pub mu_trace: Vec, - /// Posterior variance after each administered item. - pub sig2_trace: Vec, - /// Final posterior mean (Owen's ability estimate). - pub mu: f64, - /// Final posterior variance. - pub sig2: f64, -} - -/// Standard normal CDF via the crate's `erfc` approximation (|error| < 1.2e-7; -/// see `fitstats::erfc`). Oracle tests use tolerances wider than this bound. -#[inline] -fn norm_cdf(z: f64) -> f64 { - 0.5 * crate::fitstats::erfc(-z / std::f64::consts::SQRT_2) -} - -#[inline] -fn norm_pdf(z: f64) -> f64 { - (-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt() -} - -/// One Owen (1975) posterior moment update for a single 3PNO item. Returns -/// `(mu', sig2')`. See the section comment for the exact formulas, their -/// verification status, and references. -/// -/// Errors on non-finite inputs, `a <= 0`, `c` outside `[0, 1)`, -/// `sig2 <= 0`, or when the update degenerates (non-finite or non-positive -/// `sig2'`) because the prior mean sits so deep in the response's -/// improbable tail that the normal approximation breaks down (e.g. an -/// incorrect response at `D >~ 27`, where `Phi(-D)` underflows to zero). -pub fn owen_update( - a: f64, - b: f64, - c: f64, - response: bool, - mu: f64, - sig2: f64, -) -> Result<(f64, f64), String> { - if !a.is_finite() || a <= 0.0 { - return Err(format!("owen_update: a must be finite positive, got {a}")); - } - if !b.is_finite() { - return Err("owen_update: b must be finite".to_string()); - } - if !c.is_finite() || !(0.0..1.0).contains(&c) { - return Err(format!("owen_update: c must be in [0, 1), got {c}")); - } - if !mu.is_finite() { - return Err("owen_update: mu must be finite".to_string()); - } - if !sig2.is_finite() || sig2 <= 0.0 { - return Err(format!( - "owen_update: sig2 must be finite positive, got {sig2}" - )); - } - let s2 = 1.0 / (a * a) + sig2; - let s = s2.sqrt(); - let d = (mu - b) / s; - let (new_mu, new_sig2) = if response { - let big_a = c + (1.0 - c) * norm_cdf(d); - let k = (1.0 - c) * norm_pdf(d) / big_a; - (mu + (sig2 / s) * k, sig2 - (sig2 * sig2 / s2) * k * (k + d)) - } else { - // 1 - Phi(d) computed as Phi(-d) = 0.5 erfc(d / sqrt 2): the erfc - // approximation carries an exact exp(-z^2) factor, so the tail is - // relatively accurate, whereas `1.0 - norm_cdf(d)` cancels - // catastrophically for d >~ 5 (impl-review finding: at d = 8 the - // subtractive form inflated sig2' by ~5x instead of shrinking it). - let l = norm_pdf(d) / norm_cdf(-d); - (mu - (sig2 / s) * l, sig2 - (sig2 * sig2 / s2) * l * (l - d)) - }; - if !new_mu.is_finite() || !new_sig2.is_finite() || new_sig2 <= 0.0 { - return Err(format!( - "owen_update: degenerate posterior (mu' = {new_mu}, sig2' = {new_sig2}); \ - inputs are too extreme for the normal approximation" - )); - } - Ok((new_mu, new_sig2)) -} - -/// Owen (1975) sequential CAT against a caller-supplied full-pool response -/// vector: repeatedly administer the unadministered item minimizing -/// `|b_i - mu|` (ties to the lowest index), update the posterior moments with -/// [`owen_update`], and stop after `test_length` items or as soon as the -/// posterior variance drops to `sig2_stop` or below (Owen's variance-threshold -/// stopping rule), whichever comes first. -/// -/// `responses[i]` must be 0 or 1 and is consulted only if item `i` is -/// administered. Errors on empty/mismatched inputs, invalid item parameters, -/// invalid prior, `test_length` of 0 or exceeding the pool, or a -/// non-finite/non-positive `sig2_stop`. -pub fn owen_cat( - a: &[f64], - b: &[f64], - c: &[f64], - responses: &[u8], - mu0: f64, - sig2_0: f64, - test_length: usize, - sig2_stop: Option, -) -> Result { - let n = a.len(); - if n == 0 { - return Err("owen_cat: empty item pool".to_string()); - } - if b.len() != n || c.len() != n || responses.len() != n { - return Err(format!( - "owen_cat: length mismatch (a={}, b={}, c={}, responses={})", - n, - b.len(), - c.len(), - responses.len() - )); - } - for i in 0..n { - if !a[i].is_finite() || a[i] <= 0.0 { - return Err(format!("owen_cat: a[{i}] must be finite positive")); - } - if !b[i].is_finite() { - return Err(format!("owen_cat: b[{i}] must be finite")); - } - if !c[i].is_finite() || !(0.0..1.0).contains(&c[i]) { - return Err(format!("owen_cat: c[{i}] must be in [0, 1)")); - } - if responses[i] > 1 { - return Err(format!("owen_cat: responses[{i}] must be 0 or 1")); - } - } - if !mu0.is_finite() { - return Err("owen_cat: mu0 must be finite".to_string()); - } - if !sig2_0.is_finite() || sig2_0 <= 0.0 { - return Err("owen_cat: sig2_0 must be finite positive".to_string()); - } - if test_length == 0 || test_length > n { - return Err(format!( - "owen_cat: test_length must be in 1..={n}, got {test_length}" - )); - } - if let Some(t) = sig2_stop { - if !t.is_finite() || t <= 0.0 { - return Err("owen_cat: sig2_stop must be finite positive".to_string()); - } - } - - let mut administered = Vec::with_capacity(test_length); - let mut mu_trace = Vec::with_capacity(test_length); - let mut sig2_trace = Vec::with_capacity(test_length); - let mut used = vec![false; n]; - let (mut mu, mut sig2) = (mu0, sig2_0); - for _ in 0..test_length { - let mut best: Option<(f64, usize)> = None; - for i in 0..n { - if used[i] { - continue; - } - let dist = (b[i] - mu).abs(); - if best.map_or(true, |(bd, _)| dist < bd) { - best = Some((dist, i)); - } - } - let (_, item) = best.expect("test_length <= n guarantees a free item"); - used[item] = true; - let (m, v) = owen_update(a[item], b[item], c[item], responses[item] == 1, mu, sig2)?; - mu = m; - sig2 = v; - administered.push(item); - mu_trace.push(mu); - sig2_trace.push(sig2); - if sig2_stop.is_some_and(|t| sig2 <= t) { - break; - } - } - Ok(OwenCatResult { - administered, - mu_trace, - sig2_trace, - mu, - sig2, - }) -} - -// ===================== Kingsbury-Zara (1989) constrained CAT content balancing ===================== -// -// Kingsbury and Zara (1989) introduced constrained CAT (C-CAT) procedures -// for selecting items under content-area constraints. CITATION GOVERNANCE: -// the primary full text was NOT read (not obtainable in this environment); -// the exact content-balancing rule implemented here follows the catR -// `nextItem` documentation and source reproduction of the Kingsbury-Zara -// content-balancing control (READ: catR/R/nextItem.R, cbControl branch, and -// the nextItem manual page): first cover any content group with zero -// administered items, then select from the eligible group maximizing -// `target_prop - empirical_prop`, and finally choose the most informative -// item within that group. catR breaks group/item ties RANDOMLY; this -// implementation deterministically takes the LOWEST index (documented -// deviation for reproducible tests). Skipping groups with no unadministered -// item is an implementation safety rule, not verified K&Z text. -// -// The logistic 3PL Fisher information used within the chosen group, -// I_i(theta) = a_i^2 * (Q_i / P_i) * ((P_i - c_i) / (1 - c_i))^2, -// was verified against catR's `Ii.R` (I = dP^2 / (P Q)) with `Pi.R`'s -// P/dP at D = 1, d = 1, which reduce algebraically to this form -// (adversarial spec review against catR formulas). -// -// References: -// Kingsbury, G. G., & Zara, A. R. (1989). Procedures for selecting items -// for computerized adaptive tests. Applied Measurement in Education, 2(4), -// 359-375. https://doi.org/10.1207/s15324818ame0204_6 (NOT read; rule per -// the catR reproduction cited below) -// Magis, D., & Raiche, G. (2012). Random generation of response patterns -// under computerized adaptive testing with the R package catR. Journal of -// Statistical Software, 48(8), 1-31. https://doi.org/10.18637/jss.v048.i08 - -/// Result of one Kingsbury-Zara constrained-CAT selection step. -#[derive(Debug, Clone)] -pub struct CcatSelectResult { - /// Selected item index (unadministered, inside `group`). - pub selected: usize, - /// Content group the selection was constrained to. - pub group: usize, - /// Per-group `target - empirical` discrepancies (diagnostics; the - /// zero-coverage priority rule may override the argmax of this vector). - pub discrepancy: Vec, - /// Logistic 3PL Fisher information at `theta0` for ALL items - /// (administered items keep their value; masking applies to selection - /// only, matching `kl_select`). - pub info: Vec, -} - -/// One Kingsbury-Zara content-balanced CAT selection step. See the section -/// comment for the exact rule, its source status, and references. -/// -/// `groups[i]` is the content area of item `i` (must be `< targets.len()`); -/// `targets` are strictly positive proportions summing to 1 (tol 1e-8). -/// Errors on empty/mismatched inputs, invalid item parameters, non-finite -/// `theta0`, invalid targets, or when no group has an unadministered item. -pub fn ccat_select( - a: &[f64], - b: &[f64], - c: &[f64], - groups: &[usize], - targets: &[f64], - administered: &[bool], - theta0: f64, -) -> Result { - let n = a.len(); - if n == 0 { - return Err("ccat_select: empty item pool".to_string()); - } - if b.len() != n || c.len() != n || groups.len() != n || administered.len() != n { - return Err(format!( - "ccat_select: length mismatch (a: {n}, b: {}, c: {}, groups: {}, administered: {})", - b.len(), - c.len(), - groups.len(), - administered.len() - )); - } - let n_groups = targets.len(); - if n_groups == 0 { - return Err("ccat_select: targets must be non-empty".to_string()); - } - if !theta0.is_finite() { - return Err("ccat_select: theta0 must be finite".to_string()); - } - let mut tsum = 0.0; - for (g, &t) in targets.iter().enumerate() { - if !t.is_finite() || t <= 0.0 { - return Err(format!( - "ccat_select: targets must be finite positive, got targets[{g}] = {t}" - )); - } - tsum += t; - } - if (tsum - 1.0).abs() > 1e-8 { - return Err(format!("ccat_select: targets must sum to 1, got {tsum}")); - } - for i in 0..n { - if !a[i].is_finite() || a[i] <= 0.0 { - return Err(format!( - "ccat_select: a must be finite positive, got a[{i}] = {}", - a[i] - )); - } - if !b[i].is_finite() { - return Err(format!("ccat_select: b[{i}] must be finite")); - } - if !c[i].is_finite() || !(0.0..1.0).contains(&c[i]) { - return Err(format!( - "ccat_select: c must be in [0, 1), got c[{i}] = {}", - c[i] - )); - } - if groups[i] >= n_groups { - return Err(format!( - "ccat_select: groups[{i}] = {} out of range for {n_groups} targets", - groups[i] - )); - } - } - - // Administered counts and eligibility (a group is eligible if it still - // has at least one unadministered item). - let mut k_g = vec![0usize; n_groups]; - let mut eligible = vec![false; n_groups]; - let mut k = 0usize; - for i in 0..n { - if administered[i] { - k_g[groups[i]] += 1; - k += 1; - } else { - eligible[groups[i]] = true; - } - } - if !eligible.iter().any(|&e| e) { - return Err("ccat_select: all items administered".to_string()); - } - - let discrepancy: Vec = (0..n_groups) - .map(|g| targets[g] - if k > 0 { k_g[g] as f64 / k as f64 } else { 0.0 }) - .collect(); - - // catR rule: any eligible group with zero administered items has - // priority (lowest index = documented deterministic substitute for - // catR's random tie); otherwise the eligible group with the maximal - // target-minus-empirical discrepancy wins (strict > keeps the lowest - // index on ties). - let group = match (0..n_groups).find(|&g| eligible[g] && k_g[g] == 0) { - Some(g) => g, - None => { - let mut best: Option<(f64, usize)> = None; - for g in 0..n_groups { - if eligible[g] && best.map_or(true, |(bd, _)| discrepancy[g] > bd) { - best = Some((discrepancy[g], g)); - } - } - best.expect("at least one eligible group").1 - } - }; - - // Logistic 3PL Fisher information at theta0 for every item, computed in - // log space for numerical robustness (impl-review rounds 1-2 findings; - // regression-tested): the naive q/p * r^2 form produced NaN via inf * 0 - // at logistic underflow (P -> c, including subnormal c) and spurious - // +inf from multiplication order at extreme a, and a pointwise p == 0 - // guard masked genuinely informative extreme items. Algebra: with - // z = a(theta0 - b) and L = sigmoid(z), P = c + (1 - c) L, so - // r = (P - c)/(1 - c) = L exactly and - // I = a^2 (1 - c)(1 - L) L^2 / (c + (1 - c) L); when c = 0 this reduces - // to I = a^2 L (1 - L). ln L = -softplus(-z) and ln(1-L) = -softplus(z) - // are stable for all finite (or overflowed-to-inf) z. A genuinely - // astronomical information (a >= ~1e155 near theta0 = b) still - // overflows to +inf, which orders correctly in the argmax below. - let softplus = |x: f64| { - if x > 0.0 { - x + (-x).exp().ln_1p() - } else { - x.exp().ln_1p() - } - }; - let info: Vec = (0..n) - .map(|i| { - let z = a[i] * (theta0 - b[i]); - let ln_l = -softplus(-z); - let ln_1ml = -softplus(z); - let ln_i = if c[i] == 0.0 { - 2.0 * a[i].ln() + ln_l + ln_1ml - } else { - // p >= c > 0, so ln(p) is finite even when L underflows. - let p = c[i] + (1.0 - c[i]) * ln_l.exp(); - 2.0 * a[i].ln() + (1.0 - c[i]).ln() + ln_1ml + 2.0 * ln_l - p.ln() - }; - ln_i.exp() - }) - .collect(); - - // Most informative unadministered item within the chosen group; strict > - // keeps the lowest index on ties. - let mut best: Option<(f64, usize)> = None; - for i in 0..n { - if groups[i] == group && !administered[i] && best.map_or(true, |(bi, _)| info[i] > bi) { - best = Some((info[i], i)); - } - } - let selected = best.expect("chosen group is eligible").1; - - Ok(CcatSelectResult { - selected, - group, - discrepancy, - info, - }) -} diff --git a/crates/mlsirm-core/src/facets.rs b/crates/mlsirm-core/src/facets.rs deleted file mode 100644 index b3876efa0..000000000 --- a/crates/mlsirm-core/src/facets.rs +++ /dev/null @@ -1,627 +0,0 @@ -//! Many-Facet Rasch Model (MFRM; Linacre, 1989) by marginal-ML EM. -//! -//! The MFRM extends the rating scale model with a rater facet: each rating of -//! person `p` on item `i` by rater `j` follows the adjacent-category log-odds -//! -//! ```text -//! ln[ P(Y_pij = k | theta) / P(Y_pij = k-1 | theta) ] -//! = theta_p - d_i - c_j - f_k, k = 1..K-1, -//! ``` -//! -//! with item difficulty `d_i`, rater severity `c_j`, and category thresholds -//! `f_k` shared across items and raters (the rating-scale form of Linacre's -//! model). The cumulative predictor is `psi_k = k*theta - k*(d_i + c_j) - T_k` -//! with `T_k = sum_{m<=k} f_m`, `psi_0 = 0`, `P(Y=k) = softmax_k(psi)` — exactly -//! the RSM cell ([`crate::rsm::rsm_logprobs`]) with location `d_i + c_j`, which -//! this module reuses. At `n_raters = 1` (severity centered to 0) the model -//! reduces to the RSM. -//! -//! Verified formulation: the adjacent-category identity -//! `psi_k - psi_{k-1} = theta - d_i - c_j - f_k` was re-derived here and -//! adversarially checked; it matches the published Linacre (1989) rating-scale -//! MFRM form as documented by Eckes (2015). We did **not** reproduce Linacre's -//! JMLE estimator: Facets-style JMLE is replaced by marginal ML (Bock & Aitkin, -//! 1981) with `theta ~ N(0,1)` on a Gauss-Hermite grid, matching this crate's -//! estimation contract (see `rsm.rs`, `mixed.rs`). Parameter estimates are -//! therefore comparable to Facets output only up to the JMLE-vs-MMLE difference. -//! -//! Identification: the probabilities are invariant under -//! `f_m -> f_m - c, d_i -> d_i + c` and under `c_j -> c_j - c, d_i -> d_i + c`; -//! the trait scale is fixed by `theta ~ N(0,1)`. Both shift redundancies are -//! removed by centering `sum_m f_m = 0` and `sum_j c_j = 0`, leaving -//! `n_items + (n_raters - 1) + (n_cat - 2)` free parameters. -//! -//! Connectedness: Linacre's connectedness requirement concerns *design* -//! linking. We report a `connected` flag from a union-find over facet elements -//! (items and raters), joining every element that appears in the same person's -//! observed cells. When `connected == false`, severity/difficulty comparisons -//! across components are anchored only by the shared `theta ~ N(0,1)` -//! assumption (model-prior linking), not by the rating design itself. -//! -//! # References (APA 7th ed.) -//! Bock, R. D., & Aitkin, M. (1981). Marginal maximum likelihood estimation of -//! item parameters: Application of an EM algorithm. *Psychometrika, 46*(4), -//! 443-459. https://doi.org/10.1007/BF02293801 -//! Eckes, T. (2015). *Introduction to many-facet Rasch measurement* (2nd ed.). -//! Peter Lang. https://doi.org/10.3726/978-3-653-04844-5 -//! Linacre, J. M. (1989). *Many-facet Rasch measurement*. MESA Press. -//! Andrich, D. (1978). A rating formulation for ordered response categories. -//! *Psychometrika, 43*(4), 561-573. https://doi.org/10.1007/BF02293814 - -use crate::poly::solve_small; -use crate::rsm::rsm_logprobs; - -const FACETS_MAX_CAT: usize = 64; -const FACETS_MAX_ITER: usize = 100_000; -const FACETS_MAX_CELLS: usize = 60_000_000; - -/// Fitted many-facet Rasch model (Linacre, 1989). `item_difficulty` is `d_i`; -/// `rater_severity` the centered `c_j` (`sum = 0`, higher = harsher); -/// `thresholds` the `K-1` common category thresholds (centered, `sum = 0`); -/// `theta` the per-person EAP trait. `connected` is the design-linking flag -/// (see module docs). -#[derive(Clone, Debug)] -pub struct FacetsResult { - pub item_difficulty: Vec, - pub rater_severity: Vec, - pub thresholds: Vec, - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - pub connected: bool, - /// `n_items + (n_raters - 1) + (n_cat - 2)`. - pub n_parameters: usize, -} - -/// Fit the many-facet Rasch model (Linacre, 1989) by marginal-ML EM. `y` is -/// `n_persons * n_items * n_raters` row-major (rater fastest) categories -/// `0..n_cat-1`; `observed` marks scored cells (sparse judging plans allowed; -/// `None` = fully crossed). Ability `theta ~ N(0,1)` on the `q_theta`-node -/// Gauss-Hermite grid. -#[allow(clippy::too_many_arguments)] -pub fn fit_facets( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_raters: usize, - n_cat: usize, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> Result { - if !(2..=FACETS_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={FACETS_MAX_CAT}")); - } - if n_persons < 1 || n_items < 1 || n_raters < 1 { - return Err("n_persons, n_items and n_raters must be >= 1".into()); - } - if !(1..=FACETS_MAX_ITER).contains(&max_iter) { - return Err(format!("max_iter must be in 1..={FACETS_MAX_ITER}")); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and > 0".into()); - } - let n_pairs = - crate::checked_mul_usize(n_items, n_raters, "n_items * n_raters overflows usize")?; - let n_cells = - crate::checked_mul_usize(n_persons, n_pairs, "n_persons * n_items * n_raters overflows")?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items * n_raters".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items * n_raters".into()); - } - } - for (idx, &cat) in y.iter().enumerate() { - if observed.map_or(true, |o| o[idx]) && cat >= n_cat { - return Err("response category out of range 0..n_cat-1".into()); - } - } - let is_obs = |p: usize, pair: usize| observed.map_or(true, |o| o[p * n_pairs + pair]); - for i in 0..n_items { - if !(0..n_persons) - .any(|p| (0..n_raters).any(|j| is_obs(p, i * n_raters + j))) - { - return Err(format!("item {i} has no observed responses")); - } - } - for j in 0..n_raters { - if !(0..n_persons).any(|p| (0..n_items).any(|i| is_obs(p, i * n_raters + j))) { - return Err(format!("rater {j} has no observed responses")); - } - } - let (nodes, weights) = crate::quadrature::gh_rule(q_theta) - .ok_or_else(|| format!("unsupported q_theta {q_theta}"))?; - let qn = nodes.len(); - let count_cells = n_pairs - .checked_mul(qn) - .and_then(|c| c.checked_mul(n_cat)) - .ok_or_else(|| "pair * node * category table size overflows usize".to_string())?; - if count_cells > FACETS_MAX_CELLS { - return Err(format!( - "count table {count_cells} cells exceeds the cap {FACETS_MAX_CELLS}" - )); - } - let log_w: Vec = weights.iter().map(|w| w.ln()).collect(); - let kb = n_cat - 1; - - let connected = design_connected(n_persons, n_items, n_raters, &is_obs); - - // Init: item difficulty from the item mean category (as in rsm.rs), rater - // severity and thresholds at 0. - let mut d = vec![0.0f64; n_items]; - let mut c = vec![0.0f64; n_raters]; - let mut f = vec![0.0f64; kb]; - for i in 0..n_items { - let (mut s, mut cnt) = (0.0f64, 0.0f64); - for p in 0..n_persons { - for j in 0..n_raters { - if is_obs(p, i * n_raters + j) { - s += y[p * n_pairs + i * n_raters + j] as f64; - cnt += 1.0; - } - } - } - if cnt > 0.0 { - let mean = s / cnt / kb as f64; - d[i] = ((1.0 - mean).clamp(0.02, 0.98) / mean.clamp(0.02, 0.98)).ln(); - } - } - - let mut it = 0usize; - let mut converged = false; - let mut loglik_trace: Vec = Vec::new(); - - while it < max_iter { - // Per-pair cell log-probs at each node (RSM cell, location d_i + c_j). - let item_lp = pair_logprobs(&d, &c, &f, nodes, n_items, n_raters, n_cat); - // E-step: posteriors -> expected counts r[pair][node][k]. - let mut r = vec![vec![0.0f64; qn * n_cat]; n_pairs]; - let mut ll = 0.0f64; - let mut log_node = vec![0.0f64; qn]; - for p in 0..n_persons { - log_node[..qn].copy_from_slice(&log_w[..qn]); - for pair in 0..n_pairs { - if !is_obs(p, pair) { - continue; - } - let yc = y[p * n_pairs + pair]; - for nd in 0..qn { - log_node[nd] += item_lp[pair][nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for nd in 0..qn { - denom += (log_node[nd] - mx).exp(); - } - ll += mx + denom.ln(); - for pair in 0..n_pairs { - if !is_obs(p, pair) { - continue; - } - let yc = y[p * n_pairs + pair]; - for nd in 0..qn { - r[pair][nd * n_cat + yc] += (log_node[nd] - mx).exp() / denom; - } - } - } - - loglik_trace.push(ll); - it += 1; - if loglik_trace.len() > 1 { - let nn = loglik_trace.len(); - if (loglik_trace[nn - 1] - loglik_trace[nn - 2]).abs() - < tol * (1.0 + loglik_trace[nn - 2].abs()) - { - converged = true; - break; - } - } - - // CM-1: per-item Newton on d_i (c, f fixed), aggregated over raters. - // g = -sum_{j,nd,k} k*(r - n*P); h = -sum n*Var(score) < 0 (score = k). - for i in 0..n_items { - for _ in 0..25 { - let (mut g, mut h) = (0.0f64, 0.0f64); - for j in 0..n_raters { - let pair = i * n_raters + j; - location_score_terms( - d[i] + c[j], - &f, - &r[pair], - nodes, - n_cat, - &mut g, - &mut h, - ); - } - if h >= 0.0 { - break; - } - let step = g / h; - let cur = item_ell_d(i, &d, &c, &f, &r, nodes, n_raters, n_cat); - let mut al = 1.0f64; - let mut accepted = false; - for _ in 0..24 { - let cand = d[i] - al * step; - let mut dc = d.clone(); - dc[i] = cand; - if item_ell_d(i, &dc, &c, &f, &r, nodes, n_raters, n_cat) >= cur - 1e-12 { - d[i] = cand; - accepted = true; - break; - } - al *= 0.5; - } - if !accepted || (al * step).abs() < 1e-9 { - break; - } - } - } - - // CM-2: per-rater Newton on c_j (d, f fixed), aggregated over items — - // same algebra as CM-1 by the d<->c symmetry of the location d_i + c_j. - for j in 0..n_raters { - for _ in 0..25 { - let (mut g, mut h) = (0.0f64, 0.0f64); - for i in 0..n_items { - let pair = i * n_raters + j; - location_score_terms( - d[i] + c[j], - &f, - &r[pair], - nodes, - n_cat, - &mut g, - &mut h, - ); - } - if h >= 0.0 { - break; - } - let step = g / h; - let cur = rater_ell_c(j, &d, &c, &f, &r, nodes, n_items, n_raters, n_cat); - let mut al = 1.0f64; - let mut accepted = false; - for _ in 0..24 { - let cand = c[j] - al * step; - let mut cc = c.clone(); - cc[j] = cand; - if rater_ell_c(j, &d, &cc, &f, &r, nodes, n_items, n_raters, n_cat) - >= cur - 1e-12 - { - c[j] = cand; - accepted = true; - break; - } - al *= 0.5; - } - if !accepted || (al * step).abs() < 1e-9 { - break; - } - } - } - - // CM-3: joint Newton on the common thresholds f (d, c fixed), - // aggregated over all (item, rater) pairs; FD Hessian of the gradient. - for _ in 0..25 { - let g = f_gradient(&f, &d, &c, &r, nodes, n_items, n_raters, n_cat); - let mut hess = vec![vec![0.0f64; kb]; kb]; - let eps = 1e-5; - for jj in 0..kb { - let mut fp = f.clone(); - fp[jj] += eps; - let gj = f_gradient(&fp, &d, &c, &r, nodes, n_items, n_raters, n_cat); - for a in 0..kb { - hess[a][jj] = (gj[a] - g[a]) / eps; - } - } - for a in 0..kb { - for b in 0..kb { - hess[a][b] = 0.5 * (hess[a][b] + hess[b][a]); - } - hess[a][a] -= 1e-8; - } - let step = solve_small(hess, g.clone()); - let cur = total_ell(&d, &c, &f, &r, nodes, n_items, n_raters, n_cat); - let mut al = 1.0f64; - let mut accepted = false; - let mut max_step = 0.0f64; - for _ in 0..24 { - let cand: Vec = (0..kb).map(|m| f[m] - al * step[m]).collect(); - if total_ell(&d, &c, &cand, &r, nodes, n_items, n_raters, n_cat) >= cur - 1e-12 { - max_step = (0..kb).map(|m| (al * step[m]).abs()).fold(0.0, f64::max); - f = cand; - accepted = true; - break; - } - al *= 0.5; - } - if !accepted || max_step < 1e-9 { - break; - } - } - - // Re-center: f_m -> f_m - cf shifts T_k by -k*cf, compensated by - // d_i -> d_i + cf (psi_k regains -k*cf through the k*(d+c) term). - let cf = f.iter().sum::() / kb as f64; - for fm in f.iter_mut() { - *fm -= cf; - } - for di in d.iter_mut() { - *di += cf; - } - // c_j -> c_j - cc, d_i -> d_i + cc leaves every location d_i + c_j fixed. - let cc = c.iter().sum::() / n_raters as f64; - for cj in c.iter_mut() { - *cj -= cc; - } - for di in d.iter_mut() { - *di += cc; - } - } - - // Final person EAP pass at the returned parameters. - let item_lp = pair_logprobs(&d, &c, &f, nodes, n_items, n_raters, n_cat); - let mut theta = vec![0.0f64; n_persons]; - let mut final_ll = 0.0f64; - let mut log_node = vec![0.0f64; qn]; - for p in 0..n_persons { - log_node[..qn].copy_from_slice(&log_w[..qn]); - for pair in 0..n_pairs { - if !is_obs(p, pair) { - continue; - } - let yc = y[p * n_pairs + pair]; - for nd in 0..qn { - log_node[nd] += item_lp[pair][nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for nd in 0..qn { - denom += (log_node[nd] - mx).exp(); - } - final_ll += mx + denom.ln(); - let mut m = 0.0f64; - for (nd, &node) in nodes.iter().enumerate() { - m += (log_node[nd] - mx).exp() / denom * node; - } - theta[p] = m; - } - if !converged { - loglik_trace.push(final_ll); - } - - Ok(FacetsResult { - item_difficulty: d, - rater_severity: c, - thresholds: f, - theta, - loglik_trace, - n_iter: it, - converged, - connected, - n_parameters: n_items + (n_raters - 1) + (n_cat - 2), - }) -} - -/// Per-pair RSM cell log-prob tables: `out[i*n_raters + j][nd*n_cat + k]`. -fn pair_logprobs( - d: &[f64], - c: &[f64], - f: &[f64], - nodes: &[f64], - n_items: usize, - n_raters: usize, - n_cat: usize, -) -> Vec> { - let qn = nodes.len(); - let mut out = vec![vec![0.0f64; qn * n_cat]; n_items * n_raters]; - for i in 0..n_items { - for j in 0..n_raters { - let pair = i * n_raters + j; - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, d[i] + c[j], f); - out[pair][nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - } - } - out -} - -/// Accumulate the location gradient/Hessian terms of one (pair) count block: -/// `g += -sum_{nd,k} k*(r - n*P)`, `h += -sum_nd n*Var(score)`. Shared by the -/// `d_i` and `c_j` Newton steps (`d ln P_k / d location = -k + E[score]`). -fn location_score_terms( - location: f64, - f: &[f64], - r_pair: &[f64], - nodes: &[f64], - n_cat: usize, - g: &mut f64, - h: &mut f64, -) { - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, location, f); - let mut n = 0.0f64; - for k in 0..n_cat { - n += r_pair[nd * n_cat + k]; - } - if n <= 0.0 { - continue; - } - let (mut e1, mut e2) = (0.0f64, 0.0f64); - for k in 0..n_cat { - let pk = lp[k].exp(); - let kf = k as f64; - e1 += kf * pk; - e2 += kf * kf * pk; - *g += -kf * (r_pair[nd * n_cat + k] - n * pk); - } - *h += -n * (e2 - e1 * e1); - } -} - -/// Expected complete-data log-lik of the cells involving item `i` (its row of -/// rater pairs) — the objective ascended by the `d_i` line search. -#[allow(clippy::too_many_arguments)] -fn item_ell_d( - i: usize, - d: &[f64], - c: &[f64], - f: &[f64], - r: &[Vec], - nodes: &[f64], - n_raters: usize, - n_cat: usize, -) -> f64 { - (0..n_raters) - .map(|j| pair_ell(d[i] + c[j], f, &r[i * n_raters + j], nodes, n_cat)) - .sum() -} - -/// Expected complete-data log-lik of the cells involving rater `j` — the -/// objective ascended by the `c_j` line search. -#[allow(clippy::too_many_arguments)] -fn rater_ell_c( - j: usize, - d: &[f64], - c: &[f64], - f: &[f64], - r: &[Vec], - nodes: &[f64], - n_items: usize, - n_raters: usize, - n_cat: usize, -) -> f64 { - (0..n_items) - .map(|i| pair_ell(d[i] + c[j], f, &r[i * n_raters + j], nodes, n_cat)) - .sum() -} - -/// `sum_nd sum_k r[nd][k] * log P(k | theta_nd; location, f)` for one pair. -fn pair_ell(location: f64, f: &[f64], r_pair: &[f64], nodes: &[f64], n_cat: usize) -> f64 { - let mut acc = 0.0f64; - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, location, f); - for k in 0..n_cat { - let rc = r_pair[nd * n_cat + k]; - if rc != 0.0 { - acc += rc * lp[k]; - } - } - } - acc -} - -/// Total expected complete-data log-lik over all pairs (for the shared-`f` -/// line search). -#[allow(clippy::too_many_arguments)] -fn total_ell( - d: &[f64], - c: &[f64], - f: &[f64], - r: &[Vec], - nodes: &[f64], - n_items: usize, - n_raters: usize, - n_cat: usize, -) -> f64 { - let mut acc = 0.0f64; - for i in 0..n_items { - for j in 0..n_raters { - acc += pair_ell(d[i] + c[j], f, &r[i * n_raters + j], nodes, n_cat); - } - } - acc -} - -/// Gradient of the expected complete-data objective w.r.t. the common -/// thresholds: `g_m = -sum_{i,j,nd} sum_{k>=m} (r - n*P)` (0-indexed `m` for -/// `f_{m+1}`); suffix-residual form as in `rsm::tau_gradient`. -#[allow(clippy::too_many_arguments)] -fn f_gradient( - f: &[f64], - d: &[f64], - c: &[f64], - r: &[Vec], - nodes: &[f64], - n_items: usize, - n_raters: usize, - n_cat: usize, -) -> Vec { - let kb = f.len(); - let mut g = vec![0.0f64; kb]; - for i in 0..n_items { - for j in 0..n_raters { - let pair = i * n_raters + j; - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, d[i] + c[j], f); - let mut n = 0.0f64; - for k in 0..n_cat { - n += r[pair][nd * n_cat + k]; - } - if n <= 0.0 { - continue; - } - let mut suffix = 0.0f64; - for k in (1..n_cat).rev() { - suffix += r[pair][nd * n_cat + k] - n * lp[k].exp(); - g[k - 1] += -suffix; - } - } - } - } - g -} - -/// Design-linking flag: union-find over facet elements (`n_items` item nodes, -/// then `n_raters` rater nodes), joining every element observed for the same -/// person. `true` iff all items and raters form one component. Persons anchor -/// components to the trait scale only through `theta ~ N(0,1)` (module docs). -fn design_connected( - n_persons: usize, - n_items: usize, - n_raters: usize, - is_obs: &dyn Fn(usize, usize) -> bool, -) -> bool { - let n = n_items + n_raters; - let mut parent: Vec = (0..n).collect(); - fn find(parent: &mut [usize], mut x: usize) -> usize { - while parent[x] != x { - parent[x] = parent[parent[x]]; - x = parent[x]; - } - x - } - for p in 0..n_persons { - let mut first: Option = None; - for i in 0..n_items { - for j in 0..n_raters { - if !is_obs(p, i * n_raters + j) { - continue; - } - for node in [i, n_items + j] { - match first { - None => first = Some(node), - Some(anchor) => { - let (ra, rb) = (find(&mut parent, anchor), find(&mut parent, node)); - parent[rb] = ra; - } - } - } - } - } - } - let root = find(&mut parent, 0); - (1..n).all(|x| find(&mut parent, x) == root) -} - -#[cfg(test)] -#[path = "../../../tests/unit/facets_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/factor.rs b/crates/mlsirm-core/src/factor.rs deleted file mode 100644 index 2d24352cf..000000000 --- a/crates/mlsirm-core/src/factor.rs +++ /dev/null @@ -1,818 +0,0 @@ -//! Minimum-residual (minres/ULS) exploratory factor analysis and -//! McDonald's omega_total for the unidimensional (1-factor) case. -//! -//! # Sources actually read -//! -//! - READ: Revelle's CRAN psych `R/fa.R` (fetched from -//! raw.githubusercontent.com/cran/psych/master/R/fa.R, 2026-07-23), the -//! minres path: `fit.residuals`, `fit`, `FAgr.minres`, `FAout.wls`, and -//! the smc-based start values. Transcribed line by line. -//! - READ (prior work in this crate): psych `smc.R` conventions, already -//! mirrored by `reliability::invert_symmetric` + clamping for lambda6. -//! - NOT READ: McDonald (1999). The omega_total formula below is -//! hand-derived from the unidimensional standardized factor model -//! (derivation in the docs of [`omega_total_1f_corr`]) and matches what -//! secondary sources attribute to McDonald; we cite it as -//! "as cited in Revelle, 2025". -//! -//! # Algorithm (psych fa.R, minres path) -//! -//! Optimization variable: uniquenesses `psi in [0.005, 1]^p` (correlation -//! metric). Start: `1 - smc_j`. Objective: replace `diag(S)` by `1 - psi`, -//! eigendecompose, clamp eigenvalues `< eps` to `100*eps`, form the -//! rank-`nf` model `Lambda Lambda'` from the top eigenpairs, and sum the -//! squared strictly-lower-triangle residuals of `S - model` -//! (`fit.residuals`, `fm = "minres"`). -//! -//! Search direction: psych's `FAgr.minres`, -//! `g = diag(Lambda Lambda' + diag(psi) - S)` with eigenvalue clamp -//! `max(., 0)`. VERIFIED LIMITATION (adversarial spec review): this is NOT -//! the exact analytic gradient of the lower-triangle objective — -//! finite-difference signs can disagree. It is therefore used only as a -//! cheap search direction inside a projected Barzilai-Borwein descent with -//! an Armijo safeguard and a central finite-difference fallback direction. -//! Convergence is asserted on the ACTUAL objective via a finite-difference -//! box-KKT check (interior: `|fd_j| <= tol`; at the lower bound: -//! `fd_j >= -tol`; at the upper bound: `fd_j <= tol`); the maximum -//! violation is returned as [`MinresFaResult::kkt_violation`] so tests can -//! read it. -//! -//! Final loadings (`FAout.wls`): eigendecompose `S - diag(psi_hat)`, -//! `Lambda = V_{1:nf} diag(sqrt(max(lambda_k, 0)))`, columns in descending -//! eigenvalue order. Sign convention (ours, documented): each column is -//! flipped so its sum is `>= 0`. Loadings are orthogonal (unrotated); no -//! rotation of any kind is implemented. -//! -//! # Divergences from psych (documented) -//! -//! - Optimizer: psych uses `optim(..., method = "L-BFGS-B")`; we use -//! projected BB descent + Armijo as above. Tests assert agreement with an -//! independent scipy L-BFGS-B transcription oracle (same algorithm family -//! as R's optim; NOT claimed bit-identical to any R run) at 1e-5. -//! - Upper bound: psych's `upper = max(smc, 1)` can exceed 1 for a -//! covariance input; only correlation input is supported here, so the -//! box is `[0.005, 1]`. -//! - No rotation, no factor scores, no ML/WLS/GLS/minchi methods, no -//! omega_hierarchical (would need oblimin + Schmid-Leiman; out of scope -//! per the REDUCED-SCOPE spec decision). -//! -//! # References (APA 7th ed.) -//! -//! Revelle, W. (2025). *psych: Procedures for psychological, psychometric, -//! and personality research* (Version 2.6.5) [R package]. -//! https://CRAN.R-project.org/package=psych -//! -//! McDonald, R. P. (1999). *Test theory: A unified treatment*. Erlbaum. -//! (As cited in Revelle, 2025; not read.) - -use crate::parallel::correlation_matrix; -use crate::reliability::invert_symmetric; - -const LOWER: f64 = 0.005; -const UPPER: f64 = 1.0; -const KKT_TOL: f64 = 1e-6; -const MAX_ITER: usize = 2000; -const JACOBI_MAX_SWEEPS: usize = 128; -const JACOBI_TOL: f64 = 1e-12; - -/// Minres factor-analysis output. `loadings` is `p x nf` row-major, -/// unrotated, columns in descending-eigenvalue order, column sums >= 0. -#[derive(Debug, Clone)] -pub struct MinresFaResult { - pub loadings: Vec, - /// Estimated uniquenesses `psi_hat` (box-constrained to [0.005, 1]). - pub uniquenesses: Vec, - /// Communalities `h2_j = sum_k loading_{jk}^2`. - pub communalities: Vec, - /// Final value of the minres objective (sum of squared strictly - /// lower-triangle residuals). - pub objective: f64, - /// Maximum finite-difference box-KKT violation of the actual objective - /// at the solution (see module docs). Small (< ~1e-6) iff converged. - pub kkt_violation: f64, - pub n_iter: usize, - pub converged: bool, -} - -/// McDonald's omega_total for a 1-factor minres solution. -#[derive(Debug, Clone)] -pub struct OmegaResult { - /// `(sum lambda)^2 / ((sum lambda)^2 + sum psi)`. - pub omega_total: f64, - pub fa: MinresFaResult, -} - -/// Symmetric Jacobi eigendecomposition returning descending eigenvalues -/// with matching eigenvectors (columns of `v`, row-major `p x p`). Same -/// sweep scheme as `parallel::symmetric_eigenvalues_desc`, extended with -/// vector accumulation. -fn symmetric_eigen_desc(matrix: &[f64], p: usize) -> Result<(Vec, Vec), String> { - let mut a = matrix.to_vec(); - let mut v = vec![0.0; p * p]; - for i in 0..p { - v[i * p + i] = 1.0; - } - for _ in 0..JACOBI_MAX_SWEEPS { - let mut off = 0.0_f64; - for i in 0..p { - for j in (i + 1)..p { - off = off.max(a[i * p + j].abs()); - } - } - if off < JACOBI_TOL { - let mut idx: Vec = (0..p).collect(); - idx.sort_by(|&x, &y| { - a[y * p + y] - .partial_cmp(&a[x * p + x]) - .expect("eigenvalues are finite") - }); - let ev: Vec = idx.iter().map(|&i| a[i * p + i]).collect(); - let mut vs = vec![0.0; p * p]; - for (col, &i) in idx.iter().enumerate() { - for r in 0..p { - vs[r * p + col] = v[r * p + i]; - } - } - return Ok((ev, vs)); - } - for i in 0..p { - for j in (i + 1)..p { - let aij = a[i * p + j]; - if aij.abs() < JACOBI_TOL { - continue; - } - let theta = (a[j * p + j] - a[i * p + i]) / (2.0 * aij); - let sign = if theta >= 0.0 { 1.0 } else { -1.0 }; - let t = sign / (theta.abs() + (theta * theta + 1.0).sqrt()); - let c = 1.0 / (t * t + 1.0).sqrt(); - let s = t * c; - for k in 0..p { - let aik = a[i * p + k]; - let ajk = a[j * p + k]; - a[i * p + k] = c * aik - s * ajk; - a[j * p + k] = s * aik + c * ajk; - } - for k in 0..p { - let aki = a[k * p + i]; - let akj = a[k * p + j]; - a[k * p + i] = c * aki - s * akj; - a[k * p + j] = s * aki + c * akj; - } - for k in 0..p { - let vki = v[k * p + i]; - let vkj = v[k * p + j]; - v[k * p + i] = c * vki - s * vkj; - v[k * p + j] = s * vki + c * vkj; - } - } - } - } - Err("Jacobi eigenvalue iteration did not converge".into()) -} - -/// Top-`nf` loadings from `S` with diagonal replaced by `1 - psi` -/// (psych `fit.residuals` eigen step; eigenvalue floor `100 * eps`). -fn loadings_at( - s: &[f64], - psi: &[f64], - p: usize, - nf: usize, - floor_eps: bool, -) -> Result, String> { - let mut sstar = s.to_vec(); - for j in 0..p { - sstar[j * p + j] = 1.0 - psi[j]; - } - let (ev, vecs) = symmetric_eigen_desc(&sstar, p)?; - let mut lambda = vec![0.0; p * nf]; - for k in 0..nf { - let e = if floor_eps { - if ev[k] < f64::EPSILON { - 100.0 * f64::EPSILON - } else { - ev[k] - } - } else { - ev[k].max(0.0) - }; - let root = e.sqrt(); - for r in 0..p { - lambda[r * nf + k] = vecs[r * p + k] * root; - } - } - Ok(lambda) -} - -/// psych `fit.residuals` (fm = "minres"): sum of squared strictly -/// lower-triangle residuals of `S - Lambda Lambda'`. -fn objective(s: &[f64], psi: &[f64], p: usize, nf: usize) -> Result { - let l = loadings_at(s, psi, p, nf, true)?; - let mut f = 0.0; - for i in 1..p { - for j in 0..i { - let mut m = 0.0; - for k in 0..nf { - m += l[i * nf + k] * l[j * nf + k]; - } - let r = s[i * p + j] - m; - f += r * r; - } - } - Ok(f) -} - -/// psych `FAgr.minres` search direction (NOT the exact gradient of -/// `objective`; see module docs). -fn psych_direction(s: &[f64], psi: &[f64], p: usize, nf: usize) -> Result, String> { - let l = loadings_at(s, psi, p, nf, false)?; - let mut g = vec![0.0; p]; - for j in 0..p { - let mut m = 0.0; - for k in 0..nf { - m += l[j * nf + k] * l[j * nf + k]; - } - g[j] = m + psi[j] - s[j * p + j]; - } - Ok(g) -} - -/// Finite-difference gradient of the actual objective. -/// -/// Uses central differences in the interior of `[LOWER, UPPER]^p`. When -/// `psi[j]` is within `h` of a bound, falls back to a one-sided difference -/// that stays inside the feasible box, avoiding infeasible objective -/// evaluations that would bias the gradient estimate (and therefore -/// `kkt_violation`) at the boundary. -fn fd_gradient(s: &[f64], psi: &[f64], p: usize, nf: usize) -> Result, String> { - let mut g = vec![0.0; p]; - let mut w = psi.to_vec(); - for j in 0..p { - let h = 1e-7 * psi[j].abs().max(1.0); - let orig = w[j]; - if orig - h < LOWER { - // Forward difference: orig + h stays feasible. - let f0 = objective(s, &w, p, nf)?; - w[j] = orig + h; - let fp = objective(s, &w, p, nf)?; - w[j] = orig; - g[j] = (fp - f0) / h; - } else if orig + h > UPPER { - // Backward difference: orig - h stays feasible. - let f0 = objective(s, &w, p, nf)?; - w[j] = orig - h; - let fm = objective(s, &w, p, nf)?; - w[j] = orig; - g[j] = (f0 - fm) / h; - } else { - // Central difference (interior). - w[j] = orig + h; - let fp = objective(s, &w, p, nf)?; - w[j] = orig - h; - let fm = objective(s, &w, p, nf)?; - w[j] = orig; - g[j] = (fp - fm) / (2.0 * h); - } - } - Ok(g) -} - -/// Max finite-difference box-KKT violation at `psi` (see module docs). -fn kkt_violation(s: &[f64], psi: &[f64], p: usize, nf: usize) -> Result { - let g = fd_gradient(s, psi, p, nf)?; - let mut worst = 0.0_f64; - for j in 0..p { - let at_lower = psi[j] <= LOWER + 1e-10; - let at_upper = psi[j] >= UPPER - 1e-10; - let v = if at_lower { - (-g[j]).max(0.0) - } else if at_upper { - g[j].max(0.0) - } else { - g[j].abs() - }; - worst = worst.max(v); - } - Ok(worst) -} - -fn clamp_box(v: f64) -> f64 { - v.clamp(LOWER, UPPER) -} - -fn validate_corr(s: &[f64], p: usize, what: &str) -> Result<(), String> { - if p < 3 { - return Err(format!("{what}: need at least 3 variables")); - } - let pp = p - .checked_mul(p) - .ok_or_else(|| format!("{what}: dimension overflow (p = {p})"))?; - if s.len() != pp { - return Err(format!( - "{what}: expected a {p} x {p} correlation matrix, got length {}", - s.len() - )); - } - if s.iter().any(|v| !v.is_finite()) { - return Err(format!("{what}: correlation matrix must be finite")); - } - for i in 0..p { - if (s[i * p + i] - 1.0).abs() > 1e-8 { - return Err(format!( - "{what}: diagonal must be 1 (correlation metric); got {} at {i}", - s[i * p + i] - )); - } - for j in 0..i { - if (s[i * p + j] - s[j * p + i]).abs() > 1e-8 { - return Err(format!("{what}: matrix must be symmetric")); - } - // Impossible correlations overflow downstream eigen/minres - // arithmetic (impl-review finding); reject at the boundary. - if s[i * p + j].abs() > 1.0 + 1e-8 { - return Err(format!( - "{what}: off-diagonal {} at ({i}, {j}) outside [-1, 1]", - s[i * p + j] - )); - } - } - } - Ok(()) -} - -/// Minres factor analysis of a correlation matrix (psych fa.R, fm = -/// "minres"; see module docs for the transcription and divergences). -/// `corr` is row-major `p x p`; `1 <= nf < p`. -pub fn minres_fa_corr(corr: &[f64], p: usize, nf: usize) -> Result { - validate_corr(corr, p, "minres_fa")?; - if nf == 0 || nf >= p { - return Err("minres_fa: need 1 <= n_factors < n_variables".to_string()); - } - - // Start psi = 1 - smc (psych: diag(S) - smc(S)); errors on a singular - // correlation matrix, matching the guttman_lambdas precedent. - let rinv = invert_symmetric(corr, p) - .map_err(|e| format!("minres_fa: smc start values need an invertible matrix: {e}"))?; - let mut psi: Vec = (0..p) - .map(|j| { - let smc = (1.0 - 1.0 / rinv[j * p + j]).clamp(0.0, 1.0); - clamp_box(1.0 - smc) - }) - .collect(); - - let mut f = objective(corr, &psi, p, nf)?; - let mut g = psych_direction(corr, &psi, p, nf)?; - let mut prev_psi = psi.clone(); - let mut prev_g = g.clone(); - let mut step = 1.0_f64; - let mut n_iter = 0; - for iter in 0..MAX_ITER { - n_iter = iter + 1; - // Armijo backtracking along the projected psych direction; central - // FD direction as fallback when the psych direction fails (it is - // not the exact gradient — see module docs). - let mut accepted = false; - for pass in 0..2 { - let dir = if pass == 0 { - g.clone() - } else { - fd_gradient(corr, &psi, p, nf)? - }; - let mut t = step; - for _ in 0..60 { - let trial: Vec = (0..p).map(|j| clamp_box(psi[j] - t * dir[j])).collect(); - let moved: f64 = (0..p).map(|j| (trial[j] - psi[j]).abs()).sum(); - if moved <= 1e-15 { - break; - } - let ft = objective(corr, &trial, p, nf)?; - let decrease: f64 = (0..p).map(|j| dir[j] * (psi[j] - trial[j])).sum(); - if ft <= f - 1e-4 * decrease.max(0.0) && ft < f { - prev_psi.copy_from_slice(&psi); - prev_g.copy_from_slice(&g); - psi = trial; - f = ft; - accepted = true; - break; - } - t *= 0.5; - } - if accepted { - break; - } - } - if !accepted { - break; - } - g = psych_direction(corr, &psi, p, nf)?; - // Barzilai-Borwein step for the next iteration. - let mut ss = 0.0; - let mut sy = 0.0; - for j in 0..p { - let sj = psi[j] - prev_psi[j]; - ss += sj * sj; - sy += sj * (g[j] - prev_g[j]); - } - step = if sy.abs() > 1e-300 { - (ss / sy).abs().clamp(1e-8, 1e4) - } else { - 1.0 - }; - let moved: f64 = (0..p).map(|j| (psi[j] - prev_psi[j]).abs()).sum(); - if moved < 1e-13 { - break; - } - } - - let kkt = kkt_violation(corr, &psi, p, nf)?; - let loadings_raw = loadings_at(corr, &psi, p, nf, false)?; - // Sign convention: column sums >= 0. - let mut loadings = loadings_raw; - for k in 0..nf { - let colsum: f64 = (0..p).map(|r| loadings[r * nf + k]).sum(); - if colsum < 0.0 { - for r in 0..p { - loadings[r * nf + k] = -loadings[r * nf + k]; - } - } - } - let communalities: Vec = (0..p) - .map(|r| (0..nf).map(|k| loadings[r * nf + k].powi(2)).sum()) - .collect(); - Ok(MinresFaResult { - loadings, - uniquenesses: psi, - communalities, - objective: f, - kkt_violation: kkt, - n_iter, - converged: kkt < KKT_TOL, - }) -} - -/// Minres factor analysis of raw data (`n x p` row-major); computes the -/// Pearson correlation matrix first (same helper as parallel analysis). -pub fn minres_fa_data( - data: &[f64], - n: usize, - p: usize, - nf: usize, -) -> Result { - let np = n - .checked_mul(p) - .ok_or_else(|| format!("minres_fa: dimension overflow (n = {n}, p = {p})"))?; - if data.len() != np { - return Err(format!( - "minres_fa: data length {} does not match n * p = {np}", - data.len(), - )); - } - if data.iter().any(|v| !v.is_finite()) { - return Err("minres_fa: data must be finite (complete data required)".into()); - } - if n < 3 { - return Err("minres_fa: need at least 3 observations".into()); - } - let r = correlation_matrix(data, n, p)?; - minres_fa_corr(&r, p, nf) -} - -/// McDonald's omega_total for the unidimensional case, from a 1-factor -/// minres fit of a correlation matrix. -/// -/// Hand-derivation (self-contained; McDonald, 1999, as cited in Revelle, -/// 2025 — original not read): standardized 1-factor model -/// `X_j = lambda_j F + e_j` with `Var(F) = 1`, independent errors -/// `Var(e_j) = psi_j`. The common-score part of the unit-weight total is -/// `(sum_j lambda_j) F` with variance `(sum lambda)^2`; the error variance -/// is `sum psi`. Hence -/// -/// ```text -/// omega_total = (sum lambda)^2 / ((sum lambda)^2 + sum psi) -/// ``` -pub fn omega_total_1f_corr(corr: &[f64], p: usize) -> Result { - let fa = minres_fa_corr(corr, p, 1)?; - let lsum: f64 = fa.loadings.iter().sum(); - let psum: f64 = fa.uniquenesses.iter().sum(); - let den = lsum * lsum + psum; - let omega_total = if den <= 1e-12 { - f64::NAN - } else { - lsum * lsum / den - }; - Ok(OmegaResult { omega_total, fa }) -} - -/// [`omega_total_1f_corr`] from raw data (`n x p` row-major). -pub fn omega_total_1f_data(data: &[f64], n: usize, p: usize) -> Result { - let np = n - .checked_mul(p) - .ok_or_else(|| format!("omega_total_1f: dimension overflow (n = {n}, p = {p})"))?; - if data.len() != np { - return Err(format!( - "omega_total_1f: data length {} does not match n * p = {np}", - data.len(), - )); - } - if data.iter().any(|v| !v.is_finite()) { - return Err("omega_total_1f: data must be finite".into()); - } - if n < 3 { - return Err("omega_total_1f: need at least 3 observations".into()); - } - let r = correlation_matrix(data, n, p)?; - omega_total_1f_corr(&r, p) -} - -/// Factor-analytic greatest lower bound to reliability (psych `glb.fa`). -#[derive(Debug, Clone, PartialEq)] -pub struct GlbFaResult { - /// `sum(rr) / sum(R)` where `rr` is `R` with its diagonal replaced by - /// the `nf`-factor minres communalities. - pub glb: f64, - /// Communalities from the `nf`-factor fit. - pub communalities: Vec, - /// Number of factors actually fitted (after the df adjustment). - pub nf: usize, -} - -/// Factor-analytic greatest lower bound to reliability, transcribed from -/// CRAN psych `glbs.R` `glb.fa` (read in full; Revelle, 2025): -/// -/// 1. 1-factor minres fit; `values` = eigenvalues of `R` with the diagonal -/// replaced by the 1-factor model communalities (psych `fa()$values` -/// for `fm = "minres"`, traced in fa.R). -/// 2. `nf` = number of `values > 0` (strict). -/// 3. `df = p(p-1)/2 - nf*p + nf(nf-1)/2`; if `df < 0`, `nf -= 1` (single -/// decrement, exactly as psych — not a loop). -/// 4. Refit minres with `nf` factors; `glb = sum(rr)/sum(R)` with -/// `diag(rr)` = the `nf`-factor communalities. -/// -/// This is NOT the true (algebraic) greatest lower bound: psych's -/// `glb.algebraic` solves a semidefinite program via Rcsdp (read; out of -/// scope here). Sijtsma (2009) and ten Berge & Socan (2004) discuss the -/// glb but were not read; the implementation claim is the psych `glb.fa` -/// transcription alone. -/// -/// Divergences from psych (documented): input must already be a -/// correlation matrix (psych accepts rectangular data via -/// `cor(use = "pairwise")` and square covariance via `cov2cor`; use -/// [`glb_fa_data`] for the complete-data Pearson path); no `key` -/// reverse-scoring (caller responsibility); hard errors instead of R -/// error/warning strings; if the adjusted `nf` reaches 0 or `p` this -/// returns an error (psych's behavior there is unconfirmed from the read -/// source). -/// -/// References (APA 7th ed.): -/// Revelle, W. (2025). psych: Procedures for psychological, psychometric, -/// and personality research (Version 2.6.5) [R package]. -/// https://CRAN.R-project.org/package=psych -pub fn glb_fa_corr(corr: &[f64], p: usize) -> Result { - let f1 = minres_fa_corr(corr, p, 1)?; - // S = R with diag replaced by 1-factor communalities. - let mut s = corr.to_vec(); - for j in 0..p { - s[j * p + j] = f1.communalities[j]; - } - let (values, _) = symmetric_eigen_desc(&s, p)?; - let mut nf = values.iter().filter(|&&v| v > 0.0).count(); - if nf >= 1 { - let df = (p * (p - 1) / 2) as i64 - (nf * p) as i64 + (nf * (nf - 1) / 2) as i64; - if df < 0 { - nf -= 1; - } - } - if nf < 1 || nf >= p { - return Err(format!( - "glb_fa: adjusted factor count {nf} outside 1..{p} (unsupported input)" - )); - } - let fnf = minres_fa_corr(corr, p, nf)?; - let mut sum_rr = 0.0; - let mut sum_r = 0.0; - for i in 0..p { - for j in 0..p { - let v = corr[i * p + j]; - sum_r += v; - sum_rr += if i == j { fnf.communalities[i] } else { v }; - } - } - if sum_r.abs() <= 1e-8 { - return Err(format!( - "glb_fa: sum(R) too close to zero ({sum_r}); reliability ratio undefined" - )); - } - Ok(GlbFaResult { - glb: sum_rr / sum_r, - communalities: fnf.communalities, - nf, - }) -} - -/// [`glb_fa_corr`] from raw data (`n x p` row-major, complete and finite); -/// computes the Pearson correlation matrix first. -pub fn glb_fa_data(data: &[f64], n: usize, p: usize) -> Result { - let np = n - .checked_mul(p) - .ok_or_else(|| format!("glb_fa: dimension overflow (n = {n}, p = {p})"))?; - if data.len() != np { - return Err(format!( - "glb_fa: data length {} does not match n * p = {np}", - data.len(), - )); - } - if data.iter().any(|v| !v.is_finite()) { - return Err("glb_fa: data must be finite (complete data required)".into()); - } - if n < 3 { - return Err("glb_fa: need at least 3 observations".into()); - } - let r = correlation_matrix(data, n, p)?; - glb_fa_corr(&r, p) -} - -/// Velicer's minimum average partial (MAP) test output. -/// -/// `f2[m]` / `f4[m]` for `m = 0..=max_m`: the average squared (resp. -/// elementwise fourth-power) off-diagonal partial correlation after -/// partialing out the first `m` principal components. Rows where the -/// partial covariance has a non-positive or non-finite diagonal entry are -/// invalid and stored as `NaN`; they are excluded from the argmin. -#[derive(Debug, Clone, PartialEq)] -pub struct VelicerMapResult { - pub f2: Vec, - pub f4: Vec, - /// Number of components retained by the original (squared) criterion: - /// the `m` (0-based, `m = 0` = baseline `R` itself) minimizing `f2`. - pub retained_f2: usize, - /// Retained count by the revised fourth-power criterion. - pub retained_f4: usize, -} - -/// Velicer's minimum average partial (MAP) test on a correlation matrix. -/// -/// Algorithm (O'Connor's canonical MATLAB/SPSS MAP programs, READ; -/// cross-checked against psych `VSS.R` `map()`, READ): -/// -/// 1. Eigendecompose `R`; PCA loadings `A = V diag(sqrt(lambda))` with -/// columns in descending-eigenvalue order. -/// 2. Baseline `m = 0`: `f2[0] = (sum(R.^2) - p) / (p(p-1))`, i.e. the -/// mean squared off-diagonal of `R` itself; `f4[0]` likewise with -/// fourth powers. -/// 3. For `m = 1..=max_m`: partial covariance `C* = R - A_m A_m'` -/// (`A_m` = first `m` columns), rescale -/// `R* = D^{-1/2} C* D^{-1/2}` with `D = diag(C*)`, then -/// `f2[m] = (sum(R*.^2) - p) / (p(p-1))` and `f4[m]` with fourth -/// powers. If any `diag(C*)` entry is `<= 1e-12` or non-finite the row -/// is invalid (`NaN`) — e.g. `R = I` after one component. -/// 4. Retained components = the `m` attaining the minimum over valid rows -/// (O'Connor: `nfacts = s - 1` with row 1 being `m = 0`). -/// -/// Compatibility note (adversarial spec review): `fungible::faMAP` returns -/// `which.min(fm)`, a 1-based row POSITION whose first row is the `m = 0` -/// baseline — off by one relative to O'Connor's canonical count; that -/// convention is a bug and is NOT reproduced here. psych's internal -/// `map()` omits the `m = 0` baseline entirely and returns raw values -/// only. -/// -/// Fourth-power caveat: the elementwise fourth power follows O'Connor's -/// code and `fungible::faMAP` (both READ). `EFA.dimensions::MAP` has since -/// switched to matrix powers claiming the elementwise form was wrong; the -/// Velicer, Eaton & Fava (2000) chapter was NOT read, so this conflict is -/// unresolved from primary literature — we follow O'Connor's de facto -/// standard code path. -/// -/// `max_m` must satisfy `max_m <= p - 1` (canonical upper bound); -/// `max_m = 0` computes the baseline only. -/// -/// References (APA 7th ed.): -/// -/// Velicer, W. F. (1976). Determining the number of components from the -/// matrix of partial correlations. *Psychometrika, 41*(3), 321-327. -/// https://doi.org/10.1007/BF02293557 (Not read; formula support is the -/// read implementations below.) -/// O'Connor, B. P. (2000). SPSS and SAS programs for determining the -/// number of components using parallel analysis and Velicer's MAP test. -/// *Behavior Research Methods, Instruments, & Computers, 32*(3), -/// 396-402. https://doi.org/10.3758/BF03200807 (Paper not read; the -/// accompanying map.m / map.sps programs were read in full.) -/// Velicer, W. F., Eaton, C. A., & Fava, J. L. (2000). Construct -/// explication through factor or component analysis: A review and -/// evaluation of alternative procedures for determining the number of -/// factors or components. In R. D. Goffin & E. Helmes (Eds.), *Problems -/// and solutions in human assessment* (pp. 41-71). Kluwer. (Not read; -/// cited as the origin of the fourth-power revision per O'Connor's code -/// comments.) -/// Revelle, W. (2025). *psych: Procedures for psychological, psychometric, -/// and personality research* (Version 2.6.5) [R package]. -/// https://CRAN.R-project.org/package=psych (VSS.R `map()` read.) -pub fn velicer_map_corr(corr: &[f64], p: usize, max_m: usize) -> Result { - validate_corr(corr, p, "velicer_map")?; - if max_m > p - 1 { - return Err(format!( - "velicer_map: max_m = {max_m} exceeds canonical bound p - 1 = {}", - p - 1 - )); - } - let (eigval, eigvec) = symmetric_eigen_desc(corr, p)?; - // Loadings A = V diag(sqrt(lambda)); materially negative eigenvalues - // mean the input is not a correlation matrix of anything. Check the - // full spectrum (a trailing negative eigenvalue is just as disqualifying - // as a leading one, even when max_m = 0). - if eigval.iter().any(|&l| l < -1e-8) { - return Err("velicer_map: correlation matrix has a materially negative eigenvalue".into()); - } - let denom = (p * (p - 1)) as f64; - let mut f2 = vec![f64::NAN; max_m + 1]; - let mut f4 = vec![f64::NAN; max_m + 1]; - let (mut s2, mut s4) = (0.0, 0.0); - for i in 0..p { - for j in 0..p { - if i != j { - let v = corr[i * p + j]; - s2 += v * v; - s4 += v * v * v * v; - } - } - } - f2[0] = s2 / denom; - f4[0] = s4 / denom; - for m in 1..=max_m { - // C* = R - A_m A_m' - let mut c = corr.to_vec(); - for i in 0..p { - for j in 0..p { - let mut dot = 0.0; - for k in 0..m { - dot += eigvec[i * p + k] * eigvec[j * p + k] * eigval[k].max(0.0); - } - c[i * p + j] -= dot; - } - } - let diag_ok = (0..p).all(|i| { - let d = c[i * p + i]; - d.is_finite() && d > 1e-12 - }); - if !diag_ok { - continue; // invalid row stays NaN (e.g. identity R after 1 pc) - } - let d: Vec = (0..p).map(|i| 1.0 / c[i * p + i].sqrt()).collect(); - let (mut s2, mut s4) = (0.0, 0.0); - for i in 0..p { - for j in 0..p { - if i != j { - let v = c[i * p + j] * d[i] * d[j]; - s2 += v * v; - s4 += v * v * v * v; - } - } - } - f2[m] = s2 / denom; - f4[m] = s4 / denom; - } - let argmin_valid = |f: &[f64]| -> usize { - let mut best = 0usize; - for (m, &v) in f.iter().enumerate() { - if v.is_finite() && (!f[best].is_finite() || v < f[best]) { - best = m; - } - } - best - }; - Ok(VelicerMapResult { - retained_f2: argmin_valid(&f2), - retained_f4: argmin_valid(&f4), - f2, - f4, - }) -} - -/// [`velicer_map_corr`] from raw data (`n x p` row-major, complete and -/// finite); computes the Pearson correlation matrix first. -pub fn velicer_map_data( - data: &[f64], - n: usize, - p: usize, - max_m: usize, -) -> Result { - let np = n - .checked_mul(p) - .ok_or_else(|| format!("velicer_map: dimension overflow (n = {n}, p = {p})"))?; - if data.len() != np { - return Err(format!( - "velicer_map: data length {} does not match n * p = {np}", - data.len(), - )); - } - if data.iter().any(|v| !v.is_finite()) { - return Err("velicer_map: data must be finite (complete data required)".into()); - } - if n < 3 { - return Err("velicer_map: need at least 3 observations".into()); - } - let r = correlation_matrix(data, n, p)?; - velicer_map_corr(&r, p, max_m) -} - -#[cfg(test)] -#[path = "../../../tests/unit/factor_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/fitstats.rs b/crates/mlsirm-core/src/fitstats.rs deleted file mode 100644 index 34a054a54..000000000 --- a/crates/mlsirm-core/src/fitstats.rs +++ /dev/null @@ -1,2886 +0,0 @@ -//! Item- and person-fit statistics on the Rust core (the compute path; the -//! NumPy implementations in `python/fast_mlsirm/fitstats.py` are the parity -//! reference and fallback). -//! -//! - S-X² (Orlando & Thissen 2000) and S-G² (Sinharay & Lu 2008) with the -//! Lord-Wingersky recursion on the joint `(theta, xi)` node set, per trait -//! dimension, with score-group collapsing and — because the statistic is -//! over-powered at large `N` — a practical-significance effect size: the -//! `N_s`-weighted RMS of the observed-minus-expected proportions (cf. -//! Sinharay & Haberman 2014, "How often is the misfit of item response -//! theory models practically significant?"). -//! - `l_z` (Drasgow, Levine & Williams 1985) and `l_z*` (Snijders 2001, MAP -//! `r_0 = -(theta - prior_mean)` correction) at EAP estimates with the -//! latent-space position fixed at its EAP. -//! - Infit/outfit mean squares at the EAP estimates. -//! - Chi-square upper tail via the regularized upper incomplete gamma -//! (no external dependencies). - -use crate::model_exec_flags; -use crate::nodes::{build_xi_nodes, XiRule}; -use crate::scoring::{ - lord_wingersky, validate_bank, validate_dichotomous_responses, validate_prior, ItemBank, - PriorSpec, -}; - -fn at_least_tiny(value: f64, tiny: f64) -> f64 { - if value.abs() < tiny { - tiny - } else { - value - } -} - -fn xlogx_over_y(x: f64, y: f64) -> f64 { - if x == 0.0 { - 0.0 - } else { - x * (x / y).ln() - } -} - -/// Regularized upper incomplete gamma `Q(a, x)` (Numerical Recipes 6.2). -fn gammainc_upper_reg(a: f64, x: f64) -> f64 { - if x < 0.0 || a <= 0.0 { - return f64::NAN; - } - if x == 0.0 { - return 1.0; - } - if x < a + 1.0 { - let mut ap = a; - let mut total = 1.0 / a; - let mut delta = total; - for _ in 0..500 { - ap += 1.0; - delta *= x / ap; - total += delta; - if delta.abs() < total.abs() * 1e-15 { - break; - } - } - let p = total * (-x + a * x.ln() - ln_gamma(a)).exp(); - (1.0 - p).clamp(0.0, 1.0) - } else { - let tiny = 1e-300; - let mut b = x + 1.0 - a; - let mut c = 1.0 / tiny; - let mut d = 1.0 / b; - let mut h = d; - for i in 1..500 { - let an = -(i as f64) * (i as f64 - a); - b += 2.0; - d = at_least_tiny(an * d + b, tiny); - c = at_least_tiny(b + an / c, tiny); - d = 1.0 / d; - let delta = d * c; - h *= delta; - if (delta - 1.0).abs() < 1e-15 { - break; - } - } - (h * (-x + a * x.ln() - ln_gamma(a)).exp()).clamp(0.0, 1.0) - } -} - -/// Lanczos log-gamma (g = 7, n = 9), |error| < 1e-13 on the positive axis. -pub(crate) fn ln_gamma(x: f64) -> f64 { - const COEF: [f64; 9] = [ - 0.99999999999980993, - 676.5203681218851, - -1259.1392167224028, - 771.32342877765313, - -176.61502916214059, - 12.507343278686905, - -0.13857109526572012, - 9.9843695780195716e-6, - 1.5056327351493116e-7, - ]; - if x < 0.5 { - // reflection - let pi = std::f64::consts::PI; - return (pi / (pi * x).sin()).ln() - ln_gamma(1.0 - x); - } - let x = x - 1.0; - let mut acc = COEF[0]; - for (i, &c) in COEF.iter().enumerate().skip(1) { - acc += c / (x + i as f64); - } - let t = x + 7.5; - 0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + acc.ln() -} - -/// `P(Chi2_df >= x)`. -pub fn chi2_sf(x: f64, df: f64) -> f64 { - if df <= 0.0 { - return f64::NAN; - } - gammainc_upper_reg(df / 2.0, x.max(0.0) / 2.0) -} - -/// Benjamini-Hochberg step-up rejection mask at FDR level `q` (NaNs skipped). -pub fn benjamini_hochberg(p_values: &[f64], q: f64) -> Vec { - let mut idx: Vec = (0..p_values.len()) - .filter(|&i| p_values[i].is_finite()) - .collect(); - let m = idx.len(); - let mut reject = vec![false; p_values.len()]; - if m == 0 { - return reject; - } - idx.sort_by(|&a, &b| p_values[a].partial_cmp(&p_values[b]).unwrap()); - let mut k_max: Option = None; - for (rank, &i) in idx.iter().enumerate() { - if p_values[i] <= q * ((rank + 1) as f64) / (m as f64) { - k_max = Some(rank); - } - } - if let Some(k) = k_max { - for &i in &idx[..=k] { - reject[i] = true; - } - } - reject -} - -pub struct LeniencyResidualResult { - pub residual: Vec, - pub observed_mean: Vec, - pub expected_mean: Vec, - pub n_observed: Vec, - pub mean: f64, - pub sd: f64, - pub abs_p95: f64, -} - -fn linear_quantile(sorted: &[f64], q: f64) -> f64 { - if sorted.is_empty() { - return f64::NAN; - } - if sorted.len() == 1 { - return sorted[0]; - } - let clamped = q.clamp(0.0, 1.0); - let pos = clamped * (sorted.len() - 1) as f64; - let lo = pos.floor() as usize; - let hi = pos.ceil() as usize; - if lo == hi { - sorted[lo] - } else { - let w = pos - lo as f64; - sorted[lo] * (1.0 - w) + sorted[hi] * w - } -} - -/// Per-person observed-vs-expected pass-rate residuals. -/// -/// This is the Rust compute kernel behind the response-bias proxy adaptation: -/// `leniency_i = mean_j(y_ij) - mean_j(p_ij)` over observed cells only. -/// It is intentionally descriptive and does not identify the tridimensional -/// MRFA acquiescence / social-desirability factors in Ferrando et al. (2009). -/// -/// # References -/// -/// Ferrando, P. J., Lorenzo-Seva, U., & Chico, E. (2009). A general -/// factor-analytic procedure for assessing response bias in questionnaire -/// measures. *Structural Equation Modeling: A Multidisciplinary Journal, -/// 16*(2), 364-381. https://doi.org/10.1080/10705510902751374 -pub fn leniency_residuals( - y: &[f64], - observed: &[bool], - prob: &[f64], - n_persons: usize, -) -> Result { - if n_persons == 0 { - return Err("n_persons must be positive".into()); - } - if y.len() != observed.len() || y.len() != prob.len() { - return Err("y, observed, and prob must share the same length".into()); - } - if y.len() % n_persons != 0 { - return Err("response length must be divisible by n_persons".into()); - } - let n_items = y.len() / n_persons; - if n_items == 0 { - return Err("n_items must be positive".into()); - } - if y.iter() - .zip(observed.iter()) - .any(|(&response, &seen)| seen && !(response == 0.0 || response == 1.0)) - { - return Err("observed responses must be 0/1 for leniency residuals".into()); - } - if prob - .iter() - .zip(observed.iter()) - .any(|(&value, &seen)| seen && !(value.is_finite() && (0.0..=1.0).contains(&value))) - { - return Err("observed probabilities must be finite and in [0, 1]".into()); - } - - let mut residual = vec![0.0_f64; n_persons]; - let mut observed_mean = vec![0.0_f64; n_persons]; - let mut expected_mean = vec![0.0_f64; n_persons]; - let mut n_observed = vec![0usize; n_persons]; - let mut summary_values = Vec::with_capacity(n_persons); - for p in 0..n_persons { - let row = p * n_items; - let mut obs_sum = 0.0_f64; - let mut exp_sum = 0.0_f64; - let mut n = 0usize; - for i in 0..n_items { - let idx = row + i; - if !observed[idx] { - continue; - } - obs_sum += y[idx]; - exp_sum += prob[idx]; - n += 1; - } - n_observed[p] = n; - if n > 0 { - let obs = obs_sum / n as f64; - let exp = exp_sum / n as f64; - observed_mean[p] = obs; - expected_mean[p] = exp; - residual[p] = obs - exp; - summary_values.push(residual[p]); - } - } - - let mean = if summary_values.is_empty() { - 0.0 - } else { - summary_values.iter().sum::() / summary_values.len() as f64 - }; - let sd = if summary_values.is_empty() { - 0.0 - } else { - let var = summary_values - .iter() - .map(|value| { - let delta = *value - mean; - delta * delta - }) - .sum::() - / summary_values.len() as f64; - var.sqrt() - }; - let abs_p95 = if summary_values.is_empty() { - 0.0 - } else { - let mut abs_values: Vec = summary_values.iter().map(|value| value.abs()).collect(); - abs_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - linear_quantile(&abs_values, 0.95) - }; - Ok(LeniencyResidualResult { - residual, - observed_mean, - expected_mean, - n_observed, - mean, - sd, - abs_p95, - }) -} - -pub struct SX2Result { - pub statistic: Vec, - pub g2_statistic: Vec, - pub df: Vec, - pub p_value: Vec, - pub g2_p_value: Vec, - /// `N_s`-weighted RMS of `(O_s - E_s)` — the practical-significance - /// effect size guarding against over-powered flags at large N. - pub rms_residual: Vec, - pub flagged_bh: Vec, - pub n_score_groups: Vec, -} - -#[derive(Clone, Copy)] -pub struct SX2Config { - pub q_theta: usize, - pub xi_rule: XiRule, - pub min_expected: f64, - pub fdr_q: f64, - /// Flag only when BH-significant AND `rms_residual >= min_effect`. - pub min_effect: f64, -} - -impl Default for SX2Config { - fn default() -> Self { - Self { - q_theta: 21, - xi_rule: XiRule::GaussHermite { q_xi: 11 }, - min_expected: 1.0, - fdr_q: 0.05, - min_effect: 0.0, - } - } -} - -/// Item success probabilities on the joint node set, plus node weights and -/// theta values, for one prior. -#[allow(clippy::type_complexity)] -fn icc_nodes( - bank: &ItemBank<'_>, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, -) -> Result<(Vec, Vec, Vec, usize), String> { - let (free_alpha, uses_space) = model_exec_flags(bank.model_type); - let kind = crate::interaction_kind(bank.model_type); - let n_items = bank.b.len(); - let (t_nodes, t_weights) = crate::quadrature::require_gh_rule(q_theta, "quadrature size")?; - let (x_grid, x_logw) = if uses_space { - let nodes = build_xi_nodes(xi_rule, bank.latent_dim)?; - (nodes.grid, nodes.logw) - } else { - (vec![0.0; bank.latent_dim], vec![0.0_f64]) - }; - let n_x = x_logw.len(); - let cell = q_theta * n_x; - let gamma = if kind == crate::InteractionKind::Distance { - bank.tau.exp() - } else { - 0.0 - }; - let _ = uses_space; - let mut probs = vec![0.0_f64; n_items * cell]; - let mut weights = vec![0.0_f64; cell]; - let mut theta_by_dim = vec![0.0_f64; bank.n_dims * cell]; - for (t, &node_t) in t_nodes.iter().enumerate() { - for x in 0..n_x { - let c = t * n_x + x; - weights[c] = (t_weights[t].ln() + x_logw[x]).exp(); - for d in 0..bank.n_dims { - theta_by_dim[d * cell + c] = prior.mean[d] + prior.sd[d] * node_t; - } - } - } - for i in 0..n_items { - let d = bank.factor_id[i]; - let a = if free_alpha { bank.alpha[i].exp() } else { 1.0 }; - for (t, _) in t_nodes.iter().enumerate() { - for x in 0..n_x { - let c = t * n_x + x; - let mut eta = a * theta_by_dim[d * cell + c] + bank.b[i]; - match kind { - crate::InteractionKind::None => {} - crate::InteractionKind::Distance => { - let mut dist2 = bank.eps_distance; - for k in 0..bank.latent_dim { - let diff = x_grid[x * bank.latent_dim + k] - - bank.zeta[i * bank.latent_dim + k]; - dist2 += diff * diff; - } - eta -= gamma * dist2.sqrt(); - } - crate::InteractionKind::Inner => { - for k in 0..bank.latent_dim { - eta += bank.zeta[i * bank.latent_dim + k] - * x_grid[x * bank.latent_dim + k]; - } - } - } - let bounded_eta = eta.clamp(-700.0, 700.0); - probs[i * cell + c] = 1.0 / (1.0 + (-bounded_eta).exp()); - } - } - } - Ok((probs, weights, theta_by_dim, cell)) -} - -/// Orlando-Thissen S-X² and Sinharay-Lu S-G² per item (summed scores within -/// each trait dimension). -/// Persons with missing responses inside a dimension are excluded from that -/// dimension's observed table; `person_weight` (0/1) can screen aberrant -/// respondents out of the flagging statistics. -/// -/// # References -/// -/// Orlando, M., & Thissen, D. (2000). Likelihood-based item-fit indices for -/// dichotomous item response theory models. *Applied Psychological Measurement, -/// 24*(1), 50–64. -/// -/// Sinharay, S., & Lu, Y. (2008). A further look at the correlation between -/// item parameters and item fit statistics. *Journal of Educational -/// Measurement, 45*(1), 1–15. -#[allow(clippy::too_many_arguments)] -pub fn s_x2( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - cfg: &SX2Config, - person_weight: Option<&[f64]>, -) -> Result { - let n_items = validate_bank(bank)?; - validate_prior(prior, bank.n_dims)?; - if !cfg.min_expected.is_finite() || cfg.min_expected <= 0.0 { - return Err("min_expected must be finite and positive".into()); - } - if !cfg.fdr_q.is_finite() || cfg.fdr_q <= 0.0 || cfg.fdr_q > 1.0 { - return Err("fdr_q must be finite and in (0, 1]".into()); - } - if !cfg.min_effect.is_finite() || cfg.min_effect < 0.0 { - return Err("min_effect must be finite and non-negative".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells || observed.len() != y.len() { - return Err("y and observed must both have length n_persons * n_items".into()); - } - // The summed-score table is indexed by `sum(y as usize)` and sized n_d+1, so a - // non-dichotomous observed value would index out of bounds (panic). S-X2 is a - // dichotomous-item statistic; reject anything but 0/1 on observed cells. - if y.iter() - .zip(observed) - .any(|(&v, &o)| o && v != 0.0 && v != 1.0) - { - return Err("s_x2 requires dichotomous (0/1) observed responses".into()); - } - if let Some(w) = person_weight { - if w.len() != n_persons { - return Err("person_weight length must match n_persons".into()); - } - if w.iter() - .any(|&value| !value.is_finite() || (value != 0.0 && value != 1.0)) - { - return Err("person_weight must contain only finite 0/1 values".into()); - } - } - let (probs, weights, _theta, cell) = icc_nodes(bank, prior, cfg.q_theta, cfg.xi_rule)?; - let n_free_base = if matches!( - bank.model_type, - crate::ModelType::Mlsrm | crate::ModelType::Ulsrm - ) { - 1 - } else { - 2 - }; - let n_free = n_free_base - + if matches!(bank.model_type, crate::ModelType::Mirt) { - 0 - } else { - bank.latent_dim - }; - - let mut out = SX2Result { - statistic: vec![f64::NAN; n_items], - g2_statistic: vec![f64::NAN; n_items], - df: vec![f64::NAN; n_items], - p_value: vec![f64::NAN; n_items], - g2_p_value: vec![f64::NAN; n_items], - rms_residual: vec![f64::NAN; n_items], - flagged_bh: vec![false; n_items], - n_score_groups: vec![0; n_items], - }; - - for d in 0..bank.n_dims { - let items: Vec = (0..n_items).filter(|&i| bank.factor_id[i] == d).collect(); - let n_d = items.len(); - if n_d < 2 { - continue; - } - // persons complete on this dimension (and not screened out) - let mut persons: Vec = Vec::new(); - for p in 0..n_persons { - let w_ok = person_weight.map(|w| w[p] > 0.0).unwrap_or(true); - if w_ok && items.iter().all(|&i| observed[p * n_items + i]) { - persons.push(p); - } - } - if persons.is_empty() { - continue; - } - // observed counts by summed score - let mut obs_n = vec![0.0_f64; n_d + 1]; - let mut obs_r = vec![vec![0.0_f64; n_d + 1]; n_d]; - for &p in &persons { - let score: usize = items.iter().map(|&i| y[p * n_items + i] as usize).sum(); - obs_n[score] += 1.0; - for (li, &i) in items.iter().enumerate() { - obs_r[li][score] += y[p * n_items + i]; - } - } - // node-level probabilities for the dimension's items - let mut p_flat = vec![0.0_f64; n_d * cell]; - for (row, &i) in items.iter().enumerate() { - p_flat[row * cell..(row + 1) * cell].copy_from_slice(&probs[i * cell..(i + 1) * cell]); - } - let s_all = lord_wingersky(&p_flat, n_d, cell); - let denom: Vec = (0..=n_d) - .map(|s| (0..cell).map(|c| s_all[s * cell + c] * weights[c]).sum()) - .collect(); - for (li, &i) in items.iter().enumerate() { - // leave-one-out score distribution - let mut rest = vec![0.0_f64; (n_d - 1) * cell]; - let mut row = 0; - for (lj, &_j) in items.iter().enumerate() { - if lj != li { - rest[row * cell..(row + 1) * cell] - .copy_from_slice(&p_flat[lj * cell..(lj + 1) * cell]); - row += 1; - } - } - let s_rest = lord_wingersky(&rest, n_d - 1, cell); - let mut e = vec![f64::NAN; n_d + 1]; - for s in 1..n_d { - let num: f64 = (0..cell) - .map(|c| p_flat[li * cell + c] * s_rest[(s - 1) * cell + c] * weights[c]) - .sum(); - if denom[s] > 0.0 { - e[s] = num / denom[s]; - } - } - // collapse adjacent score groups to the minimum expected count - let mut groups: Vec<(f64, f64, f64)> = Vec::new(); - let (mut acc_n, mut acc_r, mut acc_e) = (0.0_f64, 0.0_f64, 0.0_f64); - for s in 1..n_d { - if !e[s].is_finite() { - continue; - } - acc_n += obs_n[s]; - acc_r += obs_r[li][s]; - acc_e += obs_n[s] * e[s]; - if acc_n > 0.0 && acc_e >= cfg.min_expected && (acc_n - acc_e) >= cfg.min_expected { - groups.push((acc_n, acc_r, acc_e)); - acc_n = 0.0; - acc_r = 0.0; - acc_e = 0.0; - } - } - if acc_n > 0.0 { - if let Some(last) = groups.last_mut() { - last.0 += acc_n; - last.1 += acc_r; - last.2 += acc_e; - } else { - groups.push((acc_n, acc_r, acc_e)); - } - } - let (mut x2, mut g2, mut n_grp) = (0.0_f64, 0.0_f64, 0usize); - let (mut rss, mut n_tot) = (0.0_f64, 0.0_f64); - for &(gn, gr, ge) in &groups { - if gn <= 0.0 { - continue; - } - let e_prop = ge / gn; - if e_prop <= 0.0 || e_prop >= 1.0 { - continue; - } - let o_prop = gr / gn; - x2 += gn * (o_prop - e_prop) * (o_prop - e_prop) / (e_prop * (1.0 - e_prop)); - g2 += 2.0 - * gn - * (xlogx_over_y(o_prop, e_prop) + xlogx_over_y(1.0 - o_prop, 1.0 - e_prop)); - rss += gn * (o_prop - e_prop) * (o_prop - e_prop); - n_tot += gn; - n_grp += 1; - } - out.statistic[i] = x2; - out.g2_statistic[i] = g2; - out.n_score_groups[i] = n_grp; - out.rms_residual[i] = if n_tot > 0.0 { - (rss / n_tot).sqrt() - } else { - f64::NAN - }; - let df = n_grp as f64 - n_free as f64; - if df >= 1.0 { - out.df[i] = df; - out.p_value[i] = chi2_sf(x2, df); - out.g2_p_value[i] = chi2_sf(g2, df); - } - } - } - let bh = benjamini_hochberg(&out.p_value, cfg.fdr_q); - for i in 0..n_items { - out.flagged_bh[i] = - bh[i] && out.rms_residual[i].is_finite() && out.rms_residual[i] >= cfg.min_effect; - } - Ok(out) -} - -pub struct PersonFitResult { - /// Row-major `n_persons x n_dims`. - pub lz: Vec, - pub lz_star: Vec, - pub flagged: Vec, -} - -/// `l_z` / `l_z*` per person and trait dimension at the EAP estimates -/// (`theta` row-major `n_persons x n_dims`, `xi` row-major -/// `n_persons x latent_dim`); `prior_mean` per (person, dim) or empty for 0. -/// -/// # References -/// -/// Drasgow, F., Levine, M. V., & Williams, E. A. (1985). Appropriateness -/// measurement with polychotomous item response models and standardized indices. -/// *British Journal of Mathematical and Statistical Psychology, 38*(1), 67–86. -/// -/// -/// Snijders, T. A. B. (2001). Asymptotic null distribution of person fit -/// statistics with estimated person parameter. *Psychometrika, 66*(3), 331–342. -/// -#[allow(clippy::too_many_arguments)] -pub fn person_fit( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - theta: &[f64], - xi: &[f64], - prior_mean: &[f64], - flag_threshold: f64, -) -> Result { - let (free_alpha, uses_space) = model_exec_flags(bank.model_type); - let n_items = validate_bank(bank)?; - let (n_dims, latent_dim) = (bank.n_dims, bank.latent_dim); - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - if theta.len() != n_persons * n_dims || xi.len() != n_persons * latent_dim { - return Err("theta/xi shapes must match n_persons".into()); - } - if !prior_mean.is_empty() && prior_mean.len() != n_persons * n_dims { - return Err("prior_mean must be empty or n_persons x n_dims".into()); - } - if theta.iter().any(|value| !value.is_finite()) || xi.iter().any(|value| !value.is_finite()) { - return Err("theta and xi must be finite".into()); - } - if prior_mean.iter().any(|value| !value.is_finite()) { - return Err("prior_mean must be finite".into()); - } - if !flag_threshold.is_finite() { - return Err("flag_threshold must be finite".into()); - } - let kind = crate::interaction_kind(bank.model_type); - let gamma = if kind == crate::InteractionKind::Distance { - bank.tau.exp() - } else { - 0.0 - }; - let _ = uses_space; - let mut lz = vec![f64::NAN; n_persons * n_dims]; - let mut lz_star = vec![f64::NAN; n_persons * n_dims]; - let mut flagged = vec![false; n_persons]; - - for p in 0..n_persons { - for d in 0..n_dims { - let (mut w_stat, mut var_l) = (0.0_f64, 0.0_f64); - let (mut num_c, mut den_c) = (0.0_f64, 0.0_f64); - let mut items_pd: Vec<(f64, f64, f64)> = Vec::new(); // (w_i, a_i, pv) - let mut n_obs = 0usize; - for i in 0..n_items { - if bank.factor_id[i] != d || !observed[p * n_items + i] { - continue; - } - let a = if free_alpha { bank.alpha[i].exp() } else { 1.0 }; - let mut eta = a * theta[p * n_dims + d] + bank.b[i]; - match kind { - crate::InteractionKind::None => {} - crate::InteractionKind::Distance => { - let mut dist2 = bank.eps_distance; - for k in 0..latent_dim { - let diff = xi[p * latent_dim + k] - bank.zeta[i * latent_dim + k]; - dist2 += diff * diff; - } - eta -= gamma * dist2.sqrt(); - } - crate::InteractionKind::Inner => { - for k in 0..latent_dim { - eta += bank.zeta[i * latent_dim + k] * xi[p * latent_dim + k]; - } - } - } - let prob = (1.0 / (1.0 + (-eta).exp())).clamp(1e-12, 1.0 - 1e-12); - let w_i = (prob / (1.0 - prob)).ln(); - let pv = prob * (1.0 - prob); - let yy = y[p * n_items + i]; - w_stat += (yy - prob) * w_i; - var_l += pv * w_i * w_i; - num_c += a * pv * w_i; - den_c += a * pv * a; - items_pd.push((w_i, a, pv)); - n_obs += 1; - } - if n_obs < 2 { - continue; - } - if var_l > 0.0 { - lz[p * n_dims + d] = w_stat / var_l.sqrt(); - } - let c = if den_c > 0.0 { num_c / den_c } else { 0.0 }; - let mut tau2 = 0.0_f64; - for &(w_i, a, pv) in &items_pd { - let w_tilde = w_i - c * a; - tau2 += w_tilde * w_tilde * pv; - } - tau2 /= n_obs as f64; - let pm = if prior_mean.is_empty() { - 0.0 - } else { - prior_mean[p * n_dims + d] - }; - let r0 = -(theta[p * n_dims + d] - pm); - if tau2 > 0.0 { - lz_star[p * n_dims + d] = (w_stat + c * r0) / ((n_obs as f64).sqrt() * tau2.sqrt()); - } - } - let min_star = (0..n_dims) - .map(|d| lz_star[p * n_dims + d]) - .filter(|v| v.is_finite()) - .fold(f64::INFINITY, f64::min); - flagged[p] = min_star < flag_threshold; - } - Ok(PersonFitResult { - lz, - lz_star, - flagged, - }) -} - -pub struct InfitOutfit { - pub infit: Vec, - pub outfit: Vec, -} - -/// Per-item infit/outfit mean squares at the EAP estimates. -pub fn infit_outfit( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - theta: &[f64], - xi: &[f64], -) -> Result { - let (free_alpha, uses_space) = model_exec_flags(bank.model_type); - let n_items = validate_bank(bank)?; - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - if theta.len() != n_persons * bank.n_dims || xi.len() != n_persons * bank.latent_dim { - return Err( - "theta/xi must have lengths n_persons * n_dims / n_persons * latent_dim".into(), - ); - } - let kind = crate::interaction_kind(bank.model_type); - let gamma = if kind == crate::InteractionKind::Distance { - bank.tau.exp() - } else { - 0.0 - }; - let _ = uses_space; - let mut resid2_sum = vec![0.0_f64; n_items]; - let mut z2_sum = vec![0.0_f64; n_items]; - let mut var_sum = vec![0.0_f64; n_items]; - let mut counts = vec![0.0_f64; n_items]; - for p in 0..n_persons { - for i in 0..n_items { - if !observed[p * n_items + i] { - continue; - } - let d = bank.factor_id[i]; - let a = if free_alpha { bank.alpha[i].exp() } else { 1.0 }; - let mut eta = a * theta[p * bank.n_dims + d] + bank.b[i]; - match kind { - crate::InteractionKind::None => {} - crate::InteractionKind::Distance => { - let mut dist2 = bank.eps_distance; - for k in 0..bank.latent_dim { - let diff = xi[p * bank.latent_dim + k] - bank.zeta[i * bank.latent_dim + k]; - dist2 += diff * diff; - } - eta -= gamma * dist2.sqrt(); - } - crate::InteractionKind::Inner => { - for k in 0..bank.latent_dim { - eta += bank.zeta[i * bank.latent_dim + k] * xi[p * bank.latent_dim + k]; - } - } - } - let prob = (1.0 / (1.0 + (-eta).exp())).clamp(1e-12, 1.0 - 1e-12); - let v = prob * (1.0 - prob); - let r2 = (y[p * n_items + i] - prob) * (y[p * n_items + i] - prob); - resid2_sum[i] += r2; - z2_sum[i] += r2 / v; - var_sum[i] += v; - counts[i] += 1.0; - } - } - let infit = (0..n_items) - .map(|i| { - if var_sum[i] > 0.0 { - resid2_sum[i] / var_sum[i] - } else { - f64::NAN - } - }) - .collect(); - let outfit = (0..n_items) - .map(|i| { - if counts[i] > 0.0 { - z2_sum[i] / counts[i] - } else { - f64::NAN - } - }) - .collect(); - Ok(InfitOutfit { infit, outfit }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/fitstats_tests.rs"] -mod tests; - -/// Information criteria for marginal (MML) fits — the standard indices whose -/// comparative behavior for IRT model selection is studied in Kang, Cohen & -/// Sung (2009): AIC, BIC (favored in their comparisons for dichotomous-kernel -/// models), corrected AIC, sample-size-adjusted BIC, and consistent AIC. -/// `n` is the number of persons (the marginal-likelihood sampling unit). -#[derive(Clone, Copy, Debug)] -pub struct InformationCriteria { - pub loglik: f64, - pub n_parameters: usize, - pub n: usize, - pub aic: f64, - pub bic: f64, - pub aicc: f64, - pub sabic: f64, - pub caic: f64, -} - -pub fn information_criteria(loglik: f64, n_parameters: usize, n: usize) -> InformationCriteria { - let k = n_parameters as f64; - let nf = n as f64; - let dev = -2.0 * loglik; - let aic = dev + 2.0 * k; - InformationCriteria { - loglik, - n_parameters, - n, - aic, - bic: dev + k * nf.ln(), - aicc: if nf - k - 1.0 > 0.0 { - aic + 2.0 * k * (k + 1.0) / (nf - k - 1.0) - } else { - f64::NAN - }, - sabic: dev + k * ((nf + 2.0) / 24.0).ln(), - caic: dev + k * (nf.ln() + 1.0), - } -} - -#[cfg(test)] -#[path = "../../../tests/unit/fitstats_ic_tests.rs"] -mod ic_tests; - -/// Vuong (1989) test for non-nested model comparison from casewise marginal -/// log-likelihoods (Schneider et al., 2020): with -/// `m_i = l_i^A - l_i^B`, `omega^2 = Var(m)`, -/// `z = (sum m_i - correction) / (sqrt(n) * omega)`; the Schwarz correction -/// `(k_A - k_B)/2 * ln n` yields the BIC-adjusted variant. Positive z favors -/// model A. The pre-test of distinguishability (`omega^2 = 0`, weighted -/// chi-square tail) is not implemented here — inspect `omega` directly. -/// -/// # References -/// -/// Schneider, L., Chalmers, R. P., Debelak, R., & Merkle, E. C. (2020). Model -/// selection of nested and non-nested item response models using Vuong tests. -/// *Multivariate Behavioral Research, 55*(5), 664–684. -/// -/// -/// Vuong, Q. H. (1989). Likelihood ratio tests for model selection and -/// non-nested hypotheses. *Econometrica, 57*(2), 307–333. -/// -#[derive(Clone, Copy, Debug)] -pub struct VuongResult { - pub z: f64, - pub p_two_sided: f64, - pub omega: f64, - pub mean_diff: f64, -} - -pub fn vuong_nonnested( - loglik_a: &[f64], - loglik_b: &[f64], - k_a: usize, - k_b: usize, - bic_correction: bool, -) -> Result { - if loglik_a.len() != loglik_b.len() || loglik_a.len() < 2 { - return Err("casewise log-likelihood vectors must be equal-length with n >= 2".into()); - } - if loglik_a - .iter() - .chain(loglik_b) - .any(|value| !value.is_finite()) - { - return Err("casewise log-likelihoods must be finite".into()); - } - let n = loglik_a.len() as f64; - let m: Vec = loglik_a - .iter() - .zip(loglik_b) - .map(|(&a, &b)| a - b) - .collect(); - if m.iter().any(|value| !value.is_finite()) { - return Err("casewise log-likelihood differences must be finite".into()); - } - let mean = m.iter().sum::() / n; - let var = m.iter().map(|&v| (v - mean) * (v - mean)).sum::() / n; - if !mean.is_finite() || !var.is_finite() { - return Err("casewise log-likelihood moments must be finite".into()); - } - if var <= 0.0 { - return Err("models are indistinguishable on this sample (omega^2 = 0)".into()); - } - let omega = var.sqrt(); - let correction = if bic_correction { - (k_a as f64 - k_b as f64) / 2.0 * n.ln() - } else { - 0.0 - }; - let z = (m.iter().sum::() - correction) / (n.sqrt() * omega); - if !z.is_finite() { - return Err("Vuong z statistic is non-finite for these inputs".into()); - } - // two-sided normal tail via the complementary error function relation: - // p = 2 * (1 - Phi(|z|)) = erfc(|z| / sqrt(2)) - let p = erfc(z.abs() / std::f64::consts::SQRT_2); - Ok(VuongResult { - z, - p_two_sided: p, - omega, - mean_diff: mean, - }) -} - -/// Complementary error function (Numerical Recipes rational approximation; -/// |error| < 1.2e-7 — adequate for p-value reporting). -pub(crate) fn erfc(x: f64) -> f64 { - let z = x.abs(); - let t = 1.0 / (1.0 + 0.5 * z); - let ans = t - * (-z * z - 1.26551223 - + t * (1.00002368 - + t * (0.37409196 - + t * (0.09678418 - + t * (-0.18628806 - + t * (0.27886807 - + t * (-1.13520398 - + t * (1.48851587 + t * (-0.82215223 + t * 0.17087277))))))))) - .exp(); - if x >= 0.0 { - ans - } else { - 2.0 - ans - } -} - -/// Yen's Q3 residual correlations and a repository-specific mean absolute -/// residual cross-product. `resid` is the row-major `n_persons x n_items` -/// matrix `y - P_hat` at EAP estimates, with NaN for missing cells. -/// -/// The cross-product summary is not Levy and Svetina's (2011) GDDM, which is -/// defined from model-based covariance in a posterior-predictive framework. -/// -/// # References -/// -/// Yen, W. M. (1984). Effects of local item dependence on the fit and equating -/// performance of the three-parameter logistic model. *Applied Psychological -/// Measurement, 8*(2), 125–145. https://doi.org/10.1177/014662168400800201 -/// -/// Levy, R., & Svetina, D. (2011). A generalized dimensionality discrepancy -/// measure for dimensionality assessment in multidimensional item response -/// theory. *British Journal of Mathematical and Statistical Psychology, 64*(2), -/// 208–232. https://doi.org/10.1348/000711010X500483 -#[derive(Clone, Debug)] -pub struct DimResidResult { - /// Off-diagonal Q3 values (upper triangle, row-major pair order). - pub q3: Vec, - pub q3_max_abs: f64, - pub q3_mean_abs: f64, - /// Mean of `abs(mean(e_i * e_j))` over pairs with at least three cases. - pub mean_abs_residual_cross_product: f64, - /// Backward-compatible alias for `mean_abs_residual_cross_product`. - /// This field is not the published Levy-Svetina GDDM. - pub gddm: f64, -} - -pub fn dimensionality_residuals( - resid: &[f64], - n_persons: usize, - n_items: usize, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be positive".into()); - } - let n_cells = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows usize".to_string())?; - if resid.len() != n_cells { - return Err("resid must be n_persons x n_items".into()); - } - if resid.iter().any(|value| value.is_infinite()) { - return Err("resid entries must be finite or NaN for missing cells".into()); - } - let mut q3 = Vec::with_capacity(n_items * (n_items - 1) / 2); - let (mut max_abs, mut sum_abs) = (0.0_f64, 0.0_f64); - let mut gddm_sum = 0.0_f64; - let mut gddm_cnt = 0.0_f64; - for i in 0..n_items { - for j in (i + 1)..n_items { - let (mut sxy, mut sxx, mut syy, mut sx, mut sy, mut n) = - (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); - for p in 0..n_persons { - let a = resid[p * n_items + i]; - let b = resid[p * n_items + j]; - if a.is_nan() || b.is_nan() { - continue; - } - sxy += a * b; - sxx += a * a; - syy += b * b; - sx += a; - sy += b; - n += 1.0; - } - if n < 3.0 { - q3.push(f64::NAN); - continue; - } - let cov = sxy / n - (sx / n) * (sy / n); - let vx = sxx / n - (sx / n) * (sx / n); - let vy = syy / n - (sy / n) * (sy / n); - let r = if vx > 0.0 && vy > 0.0 { - cov / (vx * vy).sqrt() - } else { - f64::NAN - }; - q3.push(r); - if r.is_finite() { - sum_abs += r.abs(); - if r.abs() > max_abs { - max_abs = r.abs(); - } - } - // Repository-specific descriptive residual cross-product. - gddm_sum += (sxy / n).abs(); - gddm_cnt += 1.0; - } - } - let n_finite = q3.iter().filter(|v| v.is_finite()).count(); - let mean_abs_residual_cross_product = if gddm_cnt > 0.0 { - gddm_sum / gddm_cnt - } else { - f64::NAN - }; - Ok(DimResidResult { - q3_max_abs: if n_finite > 0 { max_abs } else { f64::NAN }, - q3_mean_abs: if n_finite > 0 { - sum_abs / n_finite as f64 - } else { - f64::NAN - }, - mean_abs_residual_cross_product, - gddm: mean_abs_residual_cross_product, - q3, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/fitstats_vuong_tests.rs"] -mod vuong_tests; - -/// Repository-specific residual item-fit screen: bin persons by EAP score on -/// the item's dimension, compare observed proportions against the mean model -/// probability in each bin, and standardize: -/// `z_bin = (obs - exp) / sqrt(exp (1 - exp) / n_bin)`. Reported per item as -/// the maximum |z| over bins and its Bonferroni-adjusted normal p-value. -/// -/// This plug-in EAP-bin diagnostic follows the residual-analysis motivation of -/// Haberman et al. (2013), but it does **not** reproduce their comparison of a -/// maximum-likelihood item response function with an alternative ratio -/// estimate or their covariance-standardized residual statistic. EAP -/// shrinkage can make this repository-specific approximation less reliable in -/// short tests; S-X2 is available as an alternative. -/// -/// # References -/// -/// Haberman, S. J., Sinharay, S., & Chon, K. H. (2013). Assessing item fit for -/// unidimensional item response theory models using residuals from estimated -/// item response functions. *Psychometrika, 78*(3), 417–440. -/// -pub struct ResidualFitResult { - pub max_abs_z: Vec, - pub p_value: Vec, - pub n_bins: usize, -} - -#[allow(clippy::too_many_arguments)] -pub fn residual_item_fit( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - theta: &[f64], - xi: &[f64], - n_bins: usize, -) -> Result { - let (free_alpha, uses_space) = crate::model_exec_flags(bank.model_type); - let n_items = validate_bank(bank)?; - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be positive".into()); - } - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - let theta_cells = - crate::checked_mul_usize(n_persons, bank.n_dims, "n_persons * n_dims overflows usize")?; - let xi_cells = crate::checked_mul_usize( - n_persons, - bank.latent_dim, - "n_persons * latent_dim overflows usize", - )?; - if theta.len() != theta_cells || xi.len() != xi_cells { - return Err("theta/xi shapes must match n_persons".into()); - } - if theta.iter().chain(xi).any(|value| !value.is_finite()) { - return Err("theta and xi scores must be finite".into()); - } - if n_bins < 2 { - return Err("n_bins must be >= 2".into()); - } - let minimum_observations = - crate::checked_mul_usize(n_bins, 5, "n_bins * minimum bin size overflows usize")?; - if n_persons < minimum_observations { - return Err("n_bins requires at least five persons per bin".into()); - } - let kind = crate::interaction_kind(bank.model_type); - let gamma = if kind == crate::InteractionKind::Distance { - bank.tau.exp() - } else { - 0.0 - }; - let _ = uses_space; - let mut max_abs_z = vec![f64::NAN; n_items]; - let mut p_value = vec![f64::NAN; n_items]; - for i in 0..n_items { - let d = bank.factor_id[i]; - // persons observed on item i, sorted by their EAP on dim d - let mut idx: Vec = (0..n_persons) - .filter(|&p| observed[p * n_items + i]) - .collect(); - if idx.len() < minimum_observations { - continue; - } - idx.sort_by(|&a, &b| { - theta[a * bank.n_dims + d] - .partial_cmp(&theta[b * bank.n_dims + d]) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let a = if free_alpha { bank.alpha[i].exp() } else { 1.0 }; - let mut worst = 0.0_f64; - let bin_size = idx.len() / n_bins; - for bin in 0..n_bins { - let lo = bin * bin_size; - let hi = if bin == n_bins - 1 { - idx.len() - } else { - (bin + 1) * bin_size - }; - let members = &idx[lo..hi]; - let (mut obs_sum, mut exp_sum) = (0.0_f64, 0.0_f64); - for &p in members { - obs_sum += y[p * n_items + i]; - let mut eta = a * theta[p * bank.n_dims + d] + bank.b[i]; - match kind { - crate::InteractionKind::None => {} - crate::InteractionKind::Distance => { - let mut dist2 = bank.eps_distance; - for k in 0..bank.latent_dim { - let diff = - xi[p * bank.latent_dim + k] - bank.zeta[i * bank.latent_dim + k]; - dist2 += diff * diff; - } - eta -= gamma * dist2.sqrt(); - } - crate::InteractionKind::Inner => { - for k in 0..bank.latent_dim { - eta += bank.zeta[i * bank.latent_dim + k] * xi[p * bank.latent_dim + k]; - } - } - } - if !eta.is_finite() { - return Err("item linear predictors must be finite".into()); - } - exp_sum += 1.0 / (1.0 + (-eta).exp()); - } - let n_bin = members.len() as f64; - let e = (exp_sum / n_bin).clamp(1e-9, 1.0 - 1e-9); - let z = (obs_sum / n_bin - e) / (e * (1.0 - e) / n_bin).sqrt(); - if z.abs() > worst { - worst = z.abs(); - } - } - max_abs_z[i] = worst; - // Bonferroni over bins on the two-sided normal tail - let p_one = erfc(worst / std::f64::consts::SQRT_2); - p_value[i] = (p_one * n_bins as f64).min(1.0); - } - Ok(ResidualFitResult { - max_abs_z, - p_value, - n_bins, - }) -} - -/// Exploratory adjusted chi-square-to-df ratios for item pairs. -/// -/// Each upper-triangle pair uses a model-implied 2x2 table, then rescales its -/// Pearson statistic to a reference sample size of 3000: -/// `adj = ((chi2 - df) * 3000 / N + df) / df`, with `df = 3`. This pairwise -/// construction is specific to this repository. Tay and Drasgow (2012) -/// evaluated the earlier mean adjusted chi-square/df tradition, found a fixed -/// cutoff such as 3 insufficient across sample sizes and test lengths, and -/// recommended a parametric bootstrap. This function does not implement that -/// bootstrap and its ratios must not be treated as source-backed hypothesis -/// tests or universal local-dependence flags. -/// -/// # References -/// -/// Tay, L., & Drasgow, F. (2012). Adjusting the adjusted chi-square/df ratio -/// statistic for dichotomous item response theory analyses: Does the model fit? -/// *Educational and Psychological Measurement, 72*(3), 510–528. -/// -pub struct AdjustedChi2Result { - /// Upper-triangle pair values, row-major pair order. - pub ratio: Vec, - pub mean_ratio: f64, - pub max_ratio: f64, -} - -#[allow(clippy::too_many_arguments)] -pub fn adjusted_chi2_pairs( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, -) -> Result { - let n_items = validate_bank(bank)?; - if n_items < 2 { - return Err("adjusted pairwise fit requires at least two items".into()); - } - if n_persons == 0 { - return Err("adjusted pairwise fit requires at least one person".into()); - } - validate_prior(prior, bank.n_dims)?; - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - let (probs, weights, _theta, cell) = icc_nodes(bank, prior, q_theta, xi_rule)?; - let pair_count = - crate::checked_mul_usize(n_items, n_items - 1, "item-pair count overflows usize")? / 2; - let mut ratio = Vec::with_capacity(pair_count); - let (mut sum, mut max, mut count) = (0.0_f64, f64::NEG_INFINITY, 0usize); - for i in 0..n_items { - for j in (i + 1)..n_items { - // model-implied joint cell probabilities (marginal over the grid) - let (mut p11, mut p10, mut p01) = (0.0_f64, 0.0_f64, 0.0_f64); - for c in 0..cell { - let pi = probs[i * cell + c]; - let pj = probs[j * cell + c]; - p11 += weights[c] * pi * pj; - p10 += weights[c] * pi * (1.0 - pj); - p01 += weights[c] * (1.0 - pi) * pj; - } - let p00 = (1.0 - p11 - p10 - p01).max(1e-12); - // observed joint counts over persons observed on both items - let (mut o11, mut o10, mut o01, mut o00, mut n) = - (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); - for p in 0..n_persons { - if !observed[p * n_items + i] || !observed[p * n_items + j] { - continue; - } - let (yi, yj) = (y[p * n_items + i], y[p * n_items + j]); - n += 1.0; - if yi == 1.0 && yj == 1.0 { - o11 += 1.0; - } else if yi == 1.0 { - o10 += 1.0; - } else if yj == 1.0 { - o01 += 1.0; - } else { - o00 += 1.0; - } - } - if n < 20.0 { - ratio.push(f64::NAN); - continue; - } - let mut chi2 = 0.0_f64; - for (o, e) in [(o11, p11), (o10, p10), (o01, p01), (o00, p00)] { - let expc = (e * n).max(1e-9); - chi2 += (o - expc) * (o - expc) / expc; - } - let df = 3.0; - let adj = ((chi2 - df) * 3000.0 / n + df) / df; - ratio.push(adj); - sum += adj; - if adj > max { - max = adj; - } - count += 1; - } - } - Ok(AdjustedChi2Result { - ratio, - mean_ratio: if count > 0 { - sum / count as f64 - } else { - f64::NAN - }, - max_ratio: if count > 0 { max } else { f64::NAN }, - }) -} - -/// Fixed-estimate Monte Carlo calibration inspired by resampling-based PFA. -/// -/// For each person, this repository-specific approximation simulates response -/// vectors conditional on the supplied EAP estimates, recomputes `l_z*` at -/// those same estimates, and reports the add-one-smoothed lower-tail frequency -/// `P(l_z*_rep <= l_z*_obs)`. It does not re-estimate ability for each -/// replicate, so it is not Sinharay's complete generalized procedure and does -/// not guarantee nominal Type-I error control. -/// -/// # References -/// -/// Sinharay, S. (2016). Assessment of person fit using resampling-based -/// approaches. *Journal of Educational Measurement, 53*(1), 63–85. -/// -#[allow(clippy::too_many_arguments)] -pub fn person_fit_resampling( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - theta: &[f64], - xi: &[f64], - prior_mean: &[f64], - n_replicates: usize, - seed: u64, -) -> Result, String> { - let (free_alpha, uses_space) = crate::model_exec_flags(bank.model_type); - let n_items = bank.b.len(); - const MAX_REPLICATES: usize = 10_000; - const MAX_WORK_CELLS: usize = 200_000_000; - if n_persons == 0 { - return Err("person-fit resampling requires at least one person".into()); - } - if !(1..=MAX_REPLICATES).contains(&n_replicates) { - return Err(format!("n_replicates must be in 1..={MAX_REPLICATES}")); - } - let work_cells = n_persons - .checked_mul(n_items) - .and_then(|cells| cells.checked_mul(n_replicates)) - .ok_or("person-fit resampling work size overflows usize")?; - if work_cells > MAX_WORK_CELLS { - return Err(format!( - "person-fit resampling exceeds the {MAX_WORK_CELLS}-cell work limit" - )); - } - let base = person_fit(bank, y, observed, n_persons, theta, xi, prior_mean, -1.645)?; - let kind = crate::interaction_kind(bank.model_type); - let gamma = if kind == crate::InteractionKind::Distance { - bank.tau.exp() - } else { - 0.0 - }; - let _ = uses_space; - let mut state = seed; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut p_values = vec![f64::NAN; n_persons]; - let mut y_rep = vec![0.0_f64; n_items]; - let mut obs_rep = vec![false; n_items]; - for p in 0..n_persons { - // observed lz*: the minimum across dimensions (matches the flag rule) - let obs_stat = (0..bank.n_dims) - .map(|d| base.lz_star[p * bank.n_dims + d]) - .filter(|v| v.is_finite()) - .fold(f64::INFINITY, f64::min); - if !obs_stat.is_finite() { - continue; - } - let mut count_leq = 0usize; - let mut count_valid = 0usize; - for _ in 0..n_replicates { - for i in 0..n_items { - obs_rep[i] = observed[p * n_items + i]; - if !obs_rep[i] { - y_rep[i] = 0.0; - continue; - } - let d = bank.factor_id[i]; - let a = if free_alpha { bank.alpha[i].exp() } else { 1.0 }; - let mut eta = a * theta[p * bank.n_dims + d] + bank.b[i]; - match kind { - crate::InteractionKind::None => {} - crate::InteractionKind::Distance => { - let mut dist2 = bank.eps_distance; - for k in 0..bank.latent_dim { - let diff = - xi[p * bank.latent_dim + k] - bank.zeta[i * bank.latent_dim + k]; - dist2 += diff * diff; - } - eta -= gamma * dist2.sqrt(); - } - crate::InteractionKind::Inner => { - for k in 0..bank.latent_dim { - eta += bank.zeta[i * bank.latent_dim + k] * xi[p * bank.latent_dim + k]; - } - } - } - let prob = 1.0 / (1.0 + (-eta).exp()); - y_rep[i] = if unif() < prob { 1.0 } else { 0.0 }; - } - let pm: Vec = if prior_mean.is_empty() { - Vec::new() - } else { - prior_mean[p * bank.n_dims..(p + 1) * bank.n_dims].to_vec() - }; - let rep = person_fit( - bank, - &y_rep, - &obs_rep, - 1, - &theta[p * bank.n_dims..(p + 1) * bank.n_dims], - &xi[p * bank.latent_dim..(p + 1) * bank.latent_dim], - &pm, - -1.645, - ) - .expect("replicated data preserve the already-validated person-fit shapes"); - let rep_stat = (0..bank.n_dims) - .map(|d| rep.lz_star[d]) - .filter(|v| v.is_finite()) - .fold(f64::INFINITY, f64::min); - let valid = rep_stat.is_finite(); - count_valid += usize::from(valid); - count_leq += usize::from(valid && rep_stat <= obs_stat); - } - if count_valid > 0 { - // add-one smoothing keeps p in (0, 1] - p_values[p] = (count_leq as f64 + 1.0) / (count_valid as f64 + 1.0); - } - } - Ok(p_values) -} - -/// Repository-specific backward-elimination screen for TCC drift. -/// -/// Given two calibrations of a common item set on the same scale (for example, -/// FIPC-linked), this function computes the weighted area between their TCCs -/// and repeatedly removes the active item with the largest unsigned ICC-area -/// contribution. It stops when the TCC area is at most `threshold` or only two -/// items remain. -/// -/// This is not the complete stepwise TCC method of Guo et al. (2015). Their -/// procedure alternates item-entry and item-removal steps, updates the linking -/// set, and stops at a locally optimal set without a predetermined critical -/// value. This implementation never re-enters an excluded item and uses a -/// repository-defined fixed stopping threshold, so its output is a heuristic -/// screen rather than the paper's source-backed flagging procedure. -/// -/// # References -/// -/// Guo, R., Zheng, Y., & Chang, H. H. (2015). A stepwise test characteristic -/// curve method to detect item parameter drift. *Journal of Educational -/// Measurement, 52*(3), 280–300. https://doi.org/10.1111/jedm.12077 -pub struct TccDriftResult { - /// Items flagged as drifted, in removal order. - pub drifted: Vec, - /// Weighted TCC area per removal round (before each removal). - pub area_trace: Vec, - /// Number of item-removal iterations completed. - pub iterations: usize, - /// Maximum possible removals before only two items remain. - pub max_iterations: usize, - /// Exact stopping condition: `threshold_met` or `minimum_items_reached`. - pub termination_reason: &'static str, -} - -#[allow(clippy::too_many_arguments)] -pub fn tcc_drift( - bank_old: &ItemBank<'_>, - bank_new: &ItemBank<'_>, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, - threshold: f64, -) -> Result { - if !threshold.is_finite() || threshold < 0.0 { - return Err("threshold must be finite and non-negative".into()); - } - let n_items = bank_old.b.len(); - if bank_new.b.len() != n_items { - return Err("both calibrations must cover the same item set".into()); - } - let (p_old, weights, _t, cell) = icc_nodes(bank_old, prior, q_theta, xi_rule)?; - let (p_new, _w2, _t2, cell2) = icc_nodes(bank_new, prior, q_theta, xi_rule)?; - if cell != cell2 { - return Err("calibrations must share the quadrature configuration".into()); - } - let mut active = vec![true; n_items]; - let mut drifted = Vec::new(); - let mut area_trace = Vec::new(); - let termination_reason = loop { - // weighted area between TCCs over active items - let mut area = 0.0_f64; - let mut per_item = vec![0.0_f64; n_items]; - for c in 0..cell { - let mut diff_sum = 0.0_f64; - for i in 0..n_items { - if active[i] { - diff_sum += p_new[i * cell + c] - p_old[i * cell + c]; - } - } - area += weights[c] * diff_sum.abs(); - for i in 0..n_items { - if active[i] { - per_item[i] += weights[c] * (p_new[i * cell + c] - p_old[i * cell + c]).abs(); - } - } - } - area_trace.push(area); - if area <= threshold { - break "threshold_met"; - } - if active.iter().filter(|&&a| a).count() <= 2 { - break "minimum_items_reached"; - } - let worst = (0..n_items) - .filter(|&i| active[i]) - .max_by(|&a, &b| { - per_item[a] - .partial_cmp(&per_item[b]) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .unwrap(); - active[worst] = false; - drifted.push(worst); - }; - let iterations = drifted.len(); - Ok(TccDriftResult { - drifted, - area_trace, - iterations, - max_iterations: n_items.saturating_sub(2), - termination_reason, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/fitstats_batch3_tests.rs"] -mod batch3_tests; - -/// Chen & Thissen (1997) local-dependence indices for item pairs: the signed -/// LD X2 — the pairwise 2x2 Pearson chi-square against the model-implied joint -/// probabilities, given the sign of the observed-vs-expected phi correlation — -/// plus the signed likelihood-ratio G2 variant. These are the signed raw X2/G2 -/// scales, not standard-normal transforms. Values above about 10 on the X2 -/// scale or repeated same-sign clusters indicate local dependence the latent -/// structure does not absorb. -/// -/// # References (APA 7th ed.) -/// -/// Chen, W.-H., & Thissen, D. (1997). Local dependence indexes for item pairs -/// using item response theory. *Journal of Educational and Behavioral -/// Statistics, 22*(3), 265–289. https://doi.org/10.3102/10769986022003265 -pub struct LdIndexResult { - /// Upper-triangle signed X2 per pair (row-major pair order). - pub x2_signed: Vec, - /// Upper-triangle signed G2 per pair. - pub g2_signed: Vec, -} - -fn phi_2x2(p11: f64, p10: f64, p01: f64, p00: f64) -> Option { - let denominator = ((p11 + p10) * (p01 + p00) * (p11 + p01) * (p10 + p00)).sqrt(); - if denominator.is_finite() && denominator > 0.0 { - Some((p11 * p00 - p10 * p01) / denominator) - } else { - None - } -} - -#[allow(clippy::too_many_arguments)] -pub fn ld_indices( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, -) -> Result { - let n_items = validate_bank(bank)?; - validate_prior(prior, bank.n_dims)?; - if n_items < 2 { - return Err("local-dependence indices need at least 2 items".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells || observed.len() != y.len() { - return Err("y and observed must both have length n_persons * n_items".into()); - } - if y.iter().zip(observed).any(|(&value, &is_observed)| { - is_observed && (!value.is_finite() || (value != 0.0 && value != 1.0)) - }) { - return Err("observed responses must be 0 or 1".into()); - } - let (probs, weights, _theta, cell) = icc_nodes(bank, prior, q_theta, xi_rule)?; - let n_pairs = n_items * (n_items - 1) / 2; - let mut x2_signed = Vec::with_capacity(n_pairs); - let mut g2_signed = Vec::with_capacity(n_pairs); - for i in 0..n_items { - for j in (i + 1)..n_items { - let (mut p11, mut p10, mut p01, mut p00) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); - for c in 0..cell { - let pi = probs[i * cell + c]; - let pj = probs[j * cell + c]; - p11 += weights[c] * pi * pj; - p10 += weights[c] * pi * (1.0 - pj); - p01 += weights[c] * (1.0 - pi) * pj; - p00 += weights[c] * (1.0 - pi) * (1.0 - pj); - } - let (mut o11, mut o10, mut o01, mut o00, mut n) = - (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); - for p in 0..n_persons { - if !observed[p * n_items + i] || !observed[p * n_items + j] { - continue; - } - let (yi, yj) = (y[p * n_items + i], y[p * n_items + j]); - n += 1.0; - if yi == 1.0 && yj == 1.0 { - o11 += 1.0; - } else if yi == 1.0 { - o10 += 1.0; - } else if yj == 1.0 { - o01 += 1.0; - } else { - o00 += 1.0; - } - } - if n < 20.0 { - x2_signed.push(f64::NAN); - g2_signed.push(f64::NAN); - continue; - } - let observed_phi = phi_2x2(o11 / n, o10 / n, o01 / n, o00 / n); - let expected_phi = phi_2x2(p11, p10, p01, p00); - let (Some(observed_phi), Some(expected_phi)) = (observed_phi, expected_phi) else { - x2_signed.push(f64::NAN); - g2_signed.push(f64::NAN); - continue; - }; - let (mut x2, mut g2) = (0.0_f64, 0.0_f64); - for (o, e) in [(o11, p11), (o10, p10), (o01, p01), (o00, p00)] { - let expc = (e * n).max(1e-9); - x2 += (o - expc) * (o - expc) / expc; - if o > 0.0 { - g2 += 2.0 * o * (o / expc).ln(); - } - } - // Chen-Thissen direction: compare the phi correlations of the - // observed and model-implied 2x2 tables, not a single cell. - let sign = if observed_phi >= expected_phi { - 1.0 - } else { - -1.0 - }; - x2_signed.push(sign * x2); - g2_signed.push(sign * g2); - } - } - Ok(LdIndexResult { - x2_signed, - g2_signed, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/fitstats_ld_tests.rs"] -mod ld_tests; - -// --------------------------------------------------------------------------- -// M2 limited-information goodness-of-fit (Maydeu-Olivares & Joe 2005, 2006; -// Cai & Hansen 2013 for the hierarchical/bifactor factorization) with the -// RMSEA2 approximate-fit index, its noncentral-chi-square confidence interval, -// and the standardized root-mean-square residual (SRMSR; Maydeu-Olivares 2013) -// over the bivariate margins. -// -// The residual vector stacks the univariate and bivariate model-vs-observed -// margins. Both the residuals and their multinomial covariance Xi_2 are exact -// under local independence, because every model-implied joint margin factors -// over the quadrature nodes: pi_S = sum_c w_c * prod_{i in S} P_i(c). The -// derivative matrix Delta_2 = d pi / d beta is taken by central differences of -// the same node moments. The quadratic form -// M2 = N * e' [ Xi^-1 - Xi^-1 D (D' Xi^-1 D)^-1 D' Xi^-1 ] e -// is evaluated through one Cholesky factorization of Xi (never an explicit -// inverse): u = Xi^-1 e, W = Xi^-1 D, A = D'W, g = W'e, solve A z = g, then -// M2 = N ( e'u - g'z ). -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug)] -pub struct M2Result { - pub m2: f64, - pub df: f64, - pub p_value: f64, - pub rmsea2: f64, - pub rmsea2_ci_lower: f64, - pub rmsea2_ci_upper: f64, - pub srmsr: f64, - /// M2 for the fitted complete-independence (zero-factor) baseline model. - pub null_m2: f64, - /// Degrees of freedom for the complete-independence baseline model. - pub null_df: f64, - /// Comparative fit index based on target and null-model M2 statistics. - pub cfi: f64, - /// Tucker-Lewis index for IRT (TLIRT) based on target and null-model M2. - pub tli: f64, - pub n_moments: usize, - pub n_parameters: usize, - pub n_complete: usize, -} - -/// One free item parameter, addressed for the finite-difference Delta. -#[derive(Clone, Copy)] -enum M2Param { - B(usize), - Alpha(usize), - Zeta(usize, usize), - Tau, -} - -fn m2_parameters( - n_items: usize, - free_alpha: bool, - uses_space: bool, - latent_dim: usize, - tau_free: bool, -) -> Vec { - let mut params = Vec::new(); - for i in 0..n_items { - params.push(M2Param::B(i)); - if free_alpha { - params.push(M2Param::Alpha(i)); - } - if uses_space { - for k in 0..latent_dim { - params.push(M2Param::Zeta(i, k)); - } - } - } - if tau_free { - params.push(M2Param::Tau); - } - params -} - -fn m2_param_value( - param: M2Param, - alpha: &[f64], - b: &[f64], - zeta: &[f64], - tau: f64, - latent_dim: usize, -) -> f64 { - match param { - M2Param::B(i) => b[i], - M2Param::Alpha(i) => alpha[i], - M2Param::Zeta(i, k) => zeta[i * latent_dim + k], - M2Param::Tau => tau, - } -} - -fn set_m2_param( - param: M2Param, - value: f64, - alpha: &mut [f64], - b: &mut [f64], - zeta: &mut [f64], - tau: &mut f64, - latent_dim: usize, -) { - match param { - M2Param::B(i) => b[i] = value, - M2Param::Alpha(i) => alpha[i] = value, - M2Param::Zeta(i, k) => zeta[i * latent_dim + k] = value, - M2Param::Tau => *tau = value, - } -} - -fn srmsr_from_sum(sum_squared: f64, count: usize) -> f64 { - if count > 0 { - (sum_squared / count as f64).sqrt() - } else { - f64::NAN - } -} - -fn comparative_fit_metrics(m2: f64, df: f64, null_m2: f64, null_df: f64) -> (f64, f64) { - let cfi_denom = null_m2 - null_df; - let cfi = if null_m2 > m2 && cfi_denom > 0.0 { - (1.0 - (m2 - df) / cfi_denom).clamp(0.0, 1.0) - } else { - f64::NAN - }; - let null_ratio = null_m2 / null_df; - let tli_denom = null_ratio - 1.0; - let tli = if null_m2 > m2 && tli_denom.abs() > 1e-12 { - (null_ratio - m2 / df) / tli_denom - } else { - f64::NAN - }; - (cfi, tli) -} - -/// In-place lower-triangular Cholesky with an adaptive ridge; leaves the factor -/// in the lower triangle of `a` (row-major n x n) and zeros the upper triangle. -fn cholesky_lower(a: &mut [f64], n: usize) -> Result<(), String> { - let diag_mean = (0..n).map(|i| a[i * n + i]).sum::() / n.max(1) as f64; - let base = diag_mean.abs().max(1e-12) * 1e-10; - let orig = a.to_vec(); - for attempt in 0..8 { - if attempt > 0 { - a.copy_from_slice(&orig); - let ridge = base * (10.0_f64).powi(attempt as i32); - for i in 0..n { - a[i * n + i] += ridge; - } - } - let mut ok = true; - for j in 0..n { - let mut d = a[j * n + j]; - for k in 0..j { - d -= a[j * n + k] * a[j * n + k]; - } - if d <= 0.0 { - ok = false; - break; - } - let ljj = d.sqrt(); - a[j * n + j] = ljj; - for i in (j + 1)..n { - let mut sdot = a[i * n + j]; - for k in 0..j { - sdot -= a[i * n + k] * a[j * n + k]; - } - a[i * n + j] = sdot / ljj; - } - } - if ok { - for j in 0..n { - for i in 0..j { - a[i * n + j] = 0.0; - } - } - return Ok(()); - } - } - Err("matrix is not positive definite (degenerate margins?)".into()) -} - -/// Solve `L L' x = b` for the lower factor `l` (row-major n x n). -fn chol_solve(l: &[f64], n: usize, b: &[f64]) -> Vec { - let mut y = vec![0.0_f64; n]; - for i in 0..n { - let mut sdot = b[i]; - for k in 0..i { - sdot -= l[i * n + k] * y[k]; - } - y[i] = sdot / l[i * n + i]; - } - let mut x = vec![0.0_f64; n]; - for i in (0..n).rev() { - let mut sdot = y[i]; - for k in (i + 1)..n { - sdot -= l[k * n + i] * x[k]; - } - x[i] = sdot / l[i * n + i]; - } - x -} - -/// Evaluate the M2 projected quadratic form without forming either inverse. -fn projected_m2( - e: &[f64], - delta: &[f64], - mut xi: Vec, - n_moments: usize, - n_parameters: usize, - n: f64, -) -> Result { - let s = n_moments; - let p = n_parameters; - cholesky_lower(&mut xi, s)?; - let u = chol_solve(&xi, s, e); // Xi^-1 e - let mut w = vec![0.0_f64; s * p]; // Xi^-1 Delta - let mut col_b = vec![0.0_f64; s]; - for col in 0..p { - for row in 0..s { - col_b[row] = delta[row * p + col]; - } - let wc = chol_solve(&xi, s, &col_b); - for row in 0..s { - w[row * p + col] = wc[row]; - } - } - let mut amat = vec![0.0_f64; p * p]; // Delta' Xi^-1 Delta - let mut g = vec![0.0_f64; p]; // Delta' Xi^-1 e - for r in 0..p { - for c in 0..p { - let mut acc = 0.0; - for row in 0..s { - acc += delta[row * p + r] * w[row * p + c]; - } - amat[r * p + c] = acc; - } - let mut gg = 0.0; - for row in 0..s { - gg += w[row * p + r] * e[row]; - } - g[r] = gg; - } - cholesky_lower(&mut amat, p)?; - let z = chol_solve(&amat, p, &g); - let quad: f64 = (0..s).map(|a| e[a] * u[a]).sum(); - let adj: f64 = (0..p).map(|r| g[r] * z[r]).sum(); - Ok((n * (quad - adj)).max(0.0)) -} - -/// Central chi-square CDF via the survival function. -#[inline] -fn chi2_cdf(x: f64, df: f64) -> f64 { - 1.0 - chi2_sf(x, df) -} - -fn finish_ncchi2_mixture(weighted: f64, normalizer: f64, converged: bool) -> f64 { - if converged { - (weighted / normalizer).clamp(0.0, 1.0) - } else { - f64::NAN - } -} - -/// Noncentral chi-square CDF from a mode-centered Poisson mixture. Centering the -/// recurrence avoids underflow of the `exp(-lam / 2)` starting weight for large -/// noncentralities (Benton & Krishnamoorthy, 2003). -/// -/// Benton, D., & Krishnamoorthy, K. (2003). Computing discrete mixtures of -/// continuous distributions: Noncentral chi-square, noncentral *t* and the -/// distribution of the square of the sample multiple correlation coefficient. -/// *Computational Statistics & Data Analysis, 43*(2), 249-267. -/// https://doi.org/10.1016/S0167-9473(02)00283-9 -fn ncchi2_cdf(x: f64, df: f64, lam: f64) -> f64 { - if lam <= 0.0 { - return chi2_cdf(x, df); - } - if !(x.is_finite() && df.is_finite() && lam.is_finite()) { - return f64::NAN; - } - let half = 0.5 * lam; - let mode = half.floor() as usize; - let mut weighted = chi2_cdf(x, df + 2.0 * mode as f64); - let mut normalizer = 1.0_f64; - - let mut weight = 1.0_f64; - let mut j = mode; - while j > 0 { - weight *= j as f64 / half; - j -= 1; - normalizer += weight; - weighted += weight * chi2_cdf(x, df + 2.0 * j as f64); - if weight <= 1e-15 * normalizer { - break; - } - } - - weight = 1.0; - j = mode; - let mut converged = false; - for _ in 0..100_000 { - j += 1; - weight *= half / j as f64; - normalizer += weight; - weighted += weight * chi2_cdf(x, df + 2.0 * j as f64); - if weight <= 1e-15 * normalizer { - converged = true; - break; - } - } - finish_ncchi2_mixture(weighted, normalizer, converged) -} - -/// Smallest noncentrality `lam` with `ncchi2_cdf(x, df, lam) = target` (the CDF -/// is monotone decreasing in `lam`); returns 0 if already unattainable. -fn nc_lambda_for(x: f64, df: f64, target: f64) -> f64 { - if chi2_cdf(x, df) <= target { - return 0.0; - } - let mut hi = 1.0_f64; - while ncchi2_cdf(x, df, hi) > target && hi < 1e8 { - hi *= 2.0; - } - solve_decreasing_root(0.0, hi, target, &|lambda| ncchi2_cdf(x, df, lambda)) -} - -fn solve_decreasing_root( - mut lo: f64, - mut hi: f64, - target: f64, - evaluate: &dyn Fn(f64) -> f64, -) -> f64 { - if evaluate(hi) > target { - return f64::NAN; - } - for _ in 0..200 { - let mid = 0.5 * (lo + hi); - if evaluate(mid) > target { - lo = mid; - } else { - hi = mid; - } - if hi - lo <= 1e-12 * (1.0 + mid) { - break; - } - } - 0.5 * (lo + hi) -} - -/// Integrate simple-structure item-set margins over independent trait -/// dimensions conditional on the common latent-space node. `icc_nodes` stores -/// one trait axis because each item loads on one factor; products spanning -/// distinct factors must therefore be integrated separately, not evaluated at -/// the same trait node. -fn factorized_trait_moments( - probs: &[f64], - weights: &[f64], - q_theta: usize, - factor_id: &[usize], - n_dims: usize, - item_sets: &[Vec], -) -> Vec { - let n_x = weights.len() / q_theta; - let cell = weights.len(); - let mut trait_weights = vec![0.0_f64; q_theta]; - let mut space_weights = vec![0.0_f64; n_x]; - for t in 0..q_theta { - for x in 0..n_x { - let weight = weights[t * n_x + x]; - trait_weights[t] += weight; - space_weights[x] += weight; - } - } - item_sets - .iter() - .map(|set| { - let mut total = 0.0_f64; - for x in 0..n_x { - let mut conditional = 1.0_f64; - for d in 0..n_dims { - if !set.iter().any(|&item| factor_id[item] == d) { - continue; - } - let mut margin = 0.0_f64; - for t in 0..q_theta { - let mut product = 1.0_f64; - for &item in set { - if factor_id[item] == d { - product *= probs[item * cell + t * n_x + x]; - } - } - margin += trait_weights[t] * product; - } - conditional *= margin; - } - total += space_weights[x] * conditional; - } - total - }) - .collect() -} - -/// M2 statistic (order-2 residuals), df, p-value, RMSEA2 (+ 90% CI), and the -/// bivariate SRMSR for a fitted dichotomous item bank on the `(theta, xi)` -/// node set. Complete cases only (M2 assumes a single sample size N). -/// -/// ponytail: Xi is s x s (s = n + n(n-1)/2) so the build is O(s^2 * nodes) and -/// the Cholesky O(s^3); this is a one-shot diagnostic, not a hot path. For very -/// large banks prefer S-X2 (already streaming) and read M2 as an overall check. -pub fn m2_rmsea2( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, -) -> Result { - let n_items = bank.b.len(); - if n_items < 3 { - return Err("M2 needs at least 3 items".into()); - } - if y.len() != n_persons * n_items || observed.len() != y.len() { - return Err("y and observed must both have length n_persons * n_items".into()); - } - let (free_alpha, uses_space) = model_exec_flags(bank.model_type); - let kind = crate::interaction_kind(bank.model_type); - - // moment layout: [0..n) univariate, then bivariate pairs (i < j) - let mut moment_items: Vec> = (0..n_items).map(|i| vec![i]).collect(); - let mut pairs: Vec<(usize, usize)> = Vec::new(); - for i in 0..n_items { - for j in (i + 1)..n_items { - pairs.push((i, j)); - moment_items.push(vec![i, j]); - } - } - let s = moment_items.len(); - - // free item parameters (Delta columns), matching the estimator's count - let tau_free = kind == crate::InteractionKind::Distance && uses_space; - let params = m2_parameters(n_items, free_alpha, uses_space, bank.latent_dim, tau_free); - let p = params.len(); - if s <= p { - return Err(format!( - "M2 df non-positive: {s} moments <= {p} parameters (need more items)" - )); - } - - // observed margins on complete cases - let mut complete: Vec = Vec::with_capacity(n_persons); - for pp in 0..n_persons { - if (0..n_items).all(|i| observed[pp * n_items + i]) { - complete.push(pp); - } - } - let n_c = complete.len(); - if n_c < p + 2 { - return Err(format!("too few complete cases for M2: {n_c}")); - } - let n_f = n_c as f64; - let mut p_obs = vec![0.0_f64; s]; - for &pp in &complete { - for i in 0..n_items { - if y[pp * n_items + i] != 0.0 { - p_obs[i] += 1.0; - } - } - for (idx, &(i, j)) in pairs.iter().enumerate() { - if y[pp * n_items + i] != 0.0 && y[pp * n_items + j] != 0.0 { - p_obs[n_items + idx] += 1.0; - } - } - } - for v in p_obs.iter_mut() { - *v /= n_f; - } - - // node probabilities at the fitted parameters + node weights - let (probs0, weights, _theta, _cell) = icc_nodes(bank, prior, q_theta, xi_rule)?; - let model_moments = |probs: &[f64]| -> Vec { - factorized_trait_moments( - probs, - &weights, - q_theta, - bank.factor_id, - bank.n_dims, - &moment_items, - ) - }; - let pi_set = |probs: &[f64], set: &[usize]| -> f64 { - factorized_trait_moments( - probs, - &weights, - q_theta, - bank.factor_id, - bank.n_dims, - &[set.to_vec()], - )[0] - }; - let mom0 = model_moments(&probs0); - let e: Vec = (0..s).map(|a| p_obs[a] - mom0[a]).collect(); - - // Delta_2 (s x p, row-major) by central differences of the node moments - let alpha0 = bank.alpha.to_vec(); - let b0 = bank.b.to_vec(); - let zeta0 = bank.zeta.to_vec(); - let tau0 = bank.tau; - let probs_for = - |alpha: &[f64], b: &[f64], zeta: &[f64], tau: f64| -> Result, String> { - let tb = ItemBank { - alpha, - b, - zeta, - tau, - factor_id: bank.factor_id, - model_type: bank.model_type, - n_dims: bank.n_dims, - latent_dim: bank.latent_dim, - eps_distance: bank.eps_distance, - }; - let (pr, _w, _t, _c) = icc_nodes(&tb, prior, q_theta, xi_rule)?; - Ok(pr) - }; - let mut delta = vec![0.0_f64; s * p]; - let ld = bank.latent_dim; - for (col, param) in params.iter().enumerate() { - let base = m2_param_value(*param, &alpha0, &b0, &zeta0, tau0, ld); - let h = 1e-4 * (1.0 + base.abs()); - let mut a = alpha0.clone(); - let mut b = b0.clone(); - let mut z = zeta0.clone(); - let mut t = tau0; - set_m2_param(*param, base + h, &mut a, &mut b, &mut z, &mut t, ld); - let mom_plus = model_moments(&probs_for(&a, &b, &z, t)?); - set_m2_param(*param, base - h, &mut a, &mut b, &mut z, &mut t, ld); - let mom_minus = model_moments(&probs_for(&a, &b, &z, t)?); - let inv = 0.5 / h; - for row in 0..s { - delta[row * p + col] = (mom_plus[row] - mom_minus[row]) * inv; - } - } - - // Xi_2: multinomial covariance of the stacked margins (union margins exact - // via the local-independence factorization) - let mut xi = vec![0.0_f64; s * s]; - for a in 0..s { - for b in a..s { - let mut u = moment_items[a].clone(); - for &m in &moment_items[b] { - if !u.contains(&m) { - u.push(m); - } - } - let cov = pi_set(&probs0, &u) - mom0[a] * mom0[b]; - xi[a * s + b] = cov; - xi[b * s + a] = cov; - } - } - - // M2 = N ( e'Xi^-1 e - (D'Xi^-1 e)'(D'Xi^-1 D)^-1 (D'Xi^-1 e) ) - let m2 = projected_m2(&e, &delta, xi, s, p, n_f)?; - let df = (s - p) as f64; - let p_value = chi2_sf(m2, df); - let denom = df * (n_f - 1.0); - let rmsea2 = ((m2 - df).max(0.0) / denom).sqrt(); - let rmsea2_ci_lower = (nc_lambda_for(m2, df, 0.95) / denom).sqrt(); - let rmsea2_ci_upper = (nc_lambda_for(m2, df, 0.05) / denom).sqrt(); - - // bivariate SRMSR over residual phi-correlations - let mut ssum = 0.0_f64; - let mut cnt = 0usize; - for (idx, &(i, j)) in pairs.iter().enumerate() { - let (pi, pj, pij) = (p_obs[i], p_obs[j], p_obs[n_items + idx]); - let (mi, mj, mij) = (mom0[i], mom0[j], mom0[n_items + idx]); - let dobs = pi * (1.0 - pi) * pj * (1.0 - pj); - let dmod = mi * (1.0 - mi) * mj * (1.0 - mj); - if dobs > 1e-12 && dmod > 1e-12 { - let robs = (pij - pi * pj) / dobs.sqrt(); - let rmod = (mij - mi * mj) / dmod.sqrt(); - ssum += (robs - rmod) * (robs - rmod); - cnt += 1; - } - } - let srmsr = srmsr_from_sum(ssum, cnt); - - // Fit a zero-factor / complete-independence baseline to the same complete - // cases. Its free item margins reproduce the observed univariate margins; - // all higher-order moments are products of those margins. The analytic - // Jacobian below uses the margins themselves as the parameterization (the - // projected statistic is invariant to a full-rank reparameterization). - let null_p = n_items; - let null_mom: Vec = moment_items - .iter() - .map(|set| set.iter().map(|&i| p_obs[i]).product()) - .collect(); - let null_e: Vec = (0..s).map(|a| p_obs[a] - null_mom[a]).collect(); - let mut null_delta = vec![0.0_f64; s * null_p]; - for (row, set) in moment_items.iter().enumerate() { - for &col in set { - let derivative: f64 = set - .iter() - .filter(|&&i| i != col) - .map(|&i| p_obs[i]) - .product(); - null_delta[row * null_p + col] = derivative; - } - } - let mut null_xi = vec![0.0_f64; s * s]; - for a in 0..s { - for b in a..s { - let mut union = moment_items[a].clone(); - for &item in &moment_items[b] { - if !union.contains(&item) { - union.push(item); - } - } - let union_moment: f64 = union.iter().map(|&i| p_obs[i]).product(); - let cov = union_moment - null_mom[a] * null_mom[b]; - null_xi[a * s + b] = cov; - null_xi[b * s + a] = cov; - } - } - let null_m2 = projected_m2(&null_e, &null_delta, null_xi, s, null_p, n_f)?; - let null_df = (s - null_p) as f64; - let (cfi, tli) = comparative_fit_metrics(m2, df, null_m2, null_df); - - Ok(M2Result { - m2, - df, - p_value, - rmsea2, - rmsea2_ci_lower, - rmsea2_ci_upper, - srmsr, - null_m2, - null_df, - cfi, - tli, - n_moments: s, - n_parameters: p, - n_complete: n_c, - }) -} - -/// Per-item-pair local-dependence diagnostics (Chen & Thissen 1997). -#[derive(Clone, Debug)] -pub struct PolyLdResult { - /// Upper-triangle pair order `(i, j)`, `i < j`. - pub pairs: Vec<(usize, usize)>, - /// Pearson LD chi-square per pair. - pub x2: Vec, - /// Likelihood-ratio (G²) LD statistic per pair. - pub g2: Vec, - /// Reference degrees of freedom `(K-1)²` shared by all pairs. - pub df: f64, - /// Upper-tail p-value of `x2` on `χ²(df)`. - pub p_value: Vec, - /// Cramér's V effect size `sqrt(X² / (N(K-1)))` per pair. - pub cramers_v: Vec, - /// Largest standardized cell residual `|O-E|/sqrt(E)` per pair. - pub max_abs_std_resid: Vec, - /// Pairwise-complete sample size per pair. - pub n_pair: Vec, -} - -fn validate_optional_observed_length( - observed: Option<&[bool]>, - expected: usize, -) -> Result<(), String> { - if observed.is_some_and(|values| values.len() != expected) { - Err("observed must have length n_persons * n_items".into()) - } else { - Ok(()) - } -} - -fn validate_observed_categories( - y: &[usize], - observed: Option<&[bool]>, - n_cat: usize, -) -> Result<(), String> { - if y.iter() - .enumerate() - .any(|(idx, &value)| observed.map_or(true, |o| o[idx]) && value >= n_cat) - { - Err("observed response categories must be < n_cat".into()) - } else { - Ok(()) - } -} - -/// Local-dependence diagnostics for every item pair of a fitted unidimensional -/// GRM/GPCM (Chen & Thissen, 1997), the ordered-category generalization of the -/// binary pairwise chi-square in [`adjusted_chi2_pairs`]. For each pair `(i,j)` -/// it compares the observed `K×K` contingency table against the model-implied -/// joint under local independence, -/// `E_ab = N · Σ_t w_t P_i(a|θ_t) P_j(b|θ_t)`, reporting the Pearson `X²` and -/// likelihood-ratio `G²` with `df = (K-1)²`, the χ² p-value, Cramér's V, and the -/// largest standardized cell residual. Pairwise-complete cases (a person -/// observed on both items of a pair) enter that pair's table; a pair with too -/// few cases yields `NaN`. A significant `X²` beyond the fitted model flags -/// residual association (a violated conditional-independence assumption). -/// -/// # References (APA 7th ed.) -/// -/// Chen, W.-H., & Thissen, D. (1997). Local dependence indexes for item pairs -/// using item response theory. *Journal of Educational and Behavioral -/// Statistics, 22*(3), 265–289. https://doi.org/10.3102/10769986022003265 -/// -/// Liu, Y., & Maydeu-Olivares, A. (2013). Local dependence diagnostics in IRT -/// modeling of binary data. *Educational and Psychological Measurement, -/// 73*(2), 254–274. https://doi.org/10.1177/0013164412453841 -#[allow(clippy::too_many_arguments)] -pub fn poly_local_dependence( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: &[f64], - cat_params: &[f64], - model: crate::poly::PolyModel, - q_theta: usize, -) -> Result { - use crate::poly::{gpcm_logprobs, grm_logprobs, validate_poly_item_parameters, PolyModel}; - if n_items < 2 { - return Err("local dependence needs at least 2 items".into()); - } - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - let expected_len = crate::checked_mul_usize( - n_persons, - n_items, - "n_persons * n_items exceeds the response buffer size", - )?; - if y.len() != expected_len { - return Err("y must have length n_persons * n_items".into()); - } - validate_optional_observed_length(observed, y.len())?; - validate_poly_item_parameters(slope, cat_params, n_items, n_cat, model)?; - validate_observed_categories(y, observed, n_cat)?; - let z = n_cat - 1; - - // per-item, per-node category probabilities P_i(a | theta_t) - let (nodes, weights) = crate::quadrature::require_gh_rule(q_theta, "quadrature size")?; - let qn = nodes.len(); - let mut probs = vec![0.0_f64; n_items * qn * n_cat]; - for i in 0..n_items { - let a = slope[i]; - let cp = &cat_params[i * z..(i + 1) * z]; - for (t, &theta) in nodes.iter().enumerate() { - let base = a * theta; - let lp = match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0_f64; n_cat]; - intercepts[1..].copy_from_slice(cp); - gpcm_logprobs(base, &scores, &intercepts) - } - PolyModel::Grm => grm_logprobs(base, cp), - }; - let off = (i * qn + t) * n_cat; - for a2 in 0..n_cat { - probs[off + a2] = lp[a2].exp(); - } - } - } - - let is_obs = |pp: usize, i: usize| observed.map_or(true, |o| o[pp * n_items + i]); - // Chen & Thissen (1997) / mirt reference: the two-way independence df, - // df = cells - independence-model params = K² - (2K-1) = (K-1)² (binary -> 1). - // With FITTED item parameters the marginal MLE absorbs the univariate margins, - // leaving the (K-1)² association dof; the reference is heuristic/conservative - // (both papers note the null is stochastically smaller than χ²), so read it as - // a diagnostic screen for residual association, not an exact test. - let df = (z * z) as f64; - let mut out = PolyLdResult { - pairs: Vec::new(), - x2: Vec::new(), - g2: Vec::new(), - df, - p_value: Vec::new(), - cramers_v: Vec::new(), - max_abs_std_resid: Vec::new(), - n_pair: Vec::new(), - }; - let min_n = 2 * n_cat * n_cat; // enough for a non-degenerate K x K table - - for i in 0..n_items { - for j in (i + 1)..n_items { - // model-implied joint P(Y_i=a, Y_j=b) marginalized over theta - let mut pj = vec![0.0_f64; n_cat * n_cat]; - for t in 0..qn { - let wt = weights[t]; - let io = (i * qn + t) * n_cat; - let jo = (j * qn + t) * n_cat; - for a in 0..n_cat { - let pia = wt * probs[io + a]; - for b in 0..n_cat { - pj[a * n_cat + b] += pia * probs[jo + b]; - } - } - } - // observed K x K counts (pairwise-complete) - let mut o = vec![0.0_f64; n_cat * n_cat]; - let mut n_ij = 0usize; - for pp in 0..n_persons { - if is_obs(pp, i) && is_obs(pp, j) { - o[y[pp * n_items + i] * n_cat + y[pp * n_items + j]] += 1.0; - n_ij += 1; - } - } - out.pairs.push((i, j)); - out.n_pair.push(n_ij); - if n_ij < min_n { - out.x2.push(f64::NAN); - out.g2.push(f64::NAN); - out.p_value.push(f64::NAN); - out.cramers_v.push(f64::NAN); - out.max_abs_std_resid.push(f64::NAN); - continue; - } - let nf = n_ij as f64; - let (mut x2, mut g2, mut maxr) = (0.0_f64, 0.0_f64, 0.0_f64); - for cell in 0..n_cat * n_cat { - let e = nf * pj[cell]; - if e > 1e-12 { - let d = o[cell] - e; - x2 += d * d / e; - let sr = (d / e.sqrt()).abs(); - if sr > maxr { - maxr = sr; - } - if o[cell] > 0.0 { - g2 += 2.0 * o[cell] * (o[cell] / e).ln(); - } - } - } - out.x2.push(x2); - out.g2.push(g2); - out.p_value.push(chi2_sf(x2, df)); - out.cramers_v.push((x2 / (nf * z as f64)).sqrt()); - out.max_abs_std_resid.push(maxr); - } - } - Ok(out) -} - -/// Polytomous M2 / RMSEA2 limited-information goodness of fit for a fitted -/// unidimensional GRM or GPCM, the ordered-category generalization of -/// [`m2_rmsea2`]. Uses the CUMULATIVE marginal form: univariate -/// `m_i(c) = P(Y_i >= c)` for `c = 1..K-1` and bivariate -/// `m_ij(c,d) = P(Y_i >= c, Y_j >= d)` for `i < j`, `c,d = 1..K-1` — provably the -/// same M2 statistic as Maydeu-Olivares & Joe's category-equality form (the two -/// moment vectors differ by a fixed invertible block map `T` under which -/// `M2 = N e'[Ξ⁻¹ − Ξ⁻¹Δ(Δ'Ξ⁻¹Δ)⁻¹Δ'Ξ⁻¹]e` is invariant), and it reduces -/// EXACTLY to [`m2_rmsea2`] at `K = 2`. Model moments factor over the -/// `q_theta`-node `N(0,1)` grid by local independence -/// (`m_ij = Σ_t w_t S_i(c|t) S_j(d|t)`, `S_i(c|t) = P(Y_i >= c | θ_t)`); `Δ` is a -/// central-difference Jacobian; the multinomial covariance `Ξ` uses the nesting -/// rule `1{Y_i>=c}·1{Y_i>=c'} = 1{Y_i>=max(c,c')}` (max-threshold collapse). The -/// statistic reuses the same one-Cholesky solve as the binary path. Complete -/// cases only. `df = Q − P` with `Q = n(K-1) + C(n,2)(K-1)²`, `P = n·K`. -/// -/// RMSEA2 uses the denominator `df·(N−1)` (as [`m2_rmsea2`] and the `mirt` -/// package); Maydeu-Olivares & Joe (2014, Eq. 14) instead scale by `N·df` — the -/// two differ negligibly and only in RMSEA2 and its interval, not in M2, df, or -/// the p-value. -/// -/// # References (APA 7th ed.) -/// -/// Maydeu-Olivares, A., & Joe, H. (2014). Assessing approximate fit in -/// categorical data analysis. *Multivariate Behavioral Research, 49*(4), -/// 305–328. https://doi.org/10.1080/00273171.2014.911075 -/// -/// Maydeu-Olivares, A. (2013). Goodness-of-fit assessment of item response -/// theory models. *Measurement: Interdisciplinary Research and Perspectives, -/// 11*(3), 71–101. https://doi.org/10.1080/15366367.2013.831680 -#[allow(clippy::too_many_arguments)] -pub fn poly_m2( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: &[f64], - cat_params: &[f64], - model: crate::poly::PolyModel, - q_theta: usize, -) -> Result { - use crate::poly::{gpcm_logprobs, grm_logprobs, validate_poly_item_parameters, PolyModel}; - if n_items < 3 { - return Err("M2 needs at least 3 items".into()); - } - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - let expected_len = crate::checked_mul_usize( - n_persons, - n_items, - "n_persons * n_items exceeds the response buffer size", - )?; - if y.len() != expected_len { - return Err("y must have length n_persons * n_items".into()); - } - validate_optional_observed_length(observed, y.len())?; - validate_poly_item_parameters(slope, cat_params, n_items, n_cat, model)?; - validate_observed_categories(y, observed, n_cat)?; - - let z = n_cat - 1; // highest threshold index - // moment layout: item-major univariate (i,c), then bivariate pairs (i> = Vec::new(); - for i in 0..n_items { - for c in 1..=z { - moment_cons.push(vec![(i, c)]); - } - } - let base_biv = moment_cons.len(); // = n_items * z - let mut pairs: Vec<(usize, usize)> = Vec::new(); - for i in 0..n_items { - for j in (i + 1)..n_items { - pairs.push((i, j)); - for c in 1..=z { - for d in 1..=z { - moment_cons.push(vec![(i, c), (j, d)]); - } - } - } - } - let s = moment_cons.len(); // Q - let p = n_items * n_cat; // slope + (K-1) cat params per item - if s <= p { - return Err(format!( - "M2 df non-positive: {s} moments <= {p} parameters (need more items)" - )); - } - - // complete cases (M2 assumes a single sample size N) - let is_obs = |pp: usize, i: usize| observed.map_or(true, |o| o[pp * n_items + i]); - let mut complete: Vec = Vec::with_capacity(n_persons); - for pp in 0..n_persons { - if (0..n_items).all(|i| is_obs(pp, i)) { - complete.push(pp); - } - } - let n_c = complete.len(); - if n_c < p + 2 { - return Err(format!("too few complete cases for M2: {n_c}")); - } - let n_f = n_c as f64; - - // observed cumulative margins - let mut p_hat = vec![0.0_f64; s]; - for &pp in &complete { - for (a, cons) in moment_cons.iter().enumerate() { - if cons.iter().all(|&(i, c)| y[pp * n_items + i] >= c) { - p_hat[a] += 1.0; - } - } - } - for v in p_hat.iter_mut() { - *v /= n_f; - } - - // cumulative-probability tensor S[(i*qn+t)*z + (c-1)] = P(Y_i >= c | theta_t) - let (nodes, weights) = crate::quadrature::require_gh_rule(q_theta, "quadrature size")?; - let qn = nodes.len(); - let build_cum = |slope: &[f64], cat_params: &[f64]| -> Vec { - let mut sc = vec![0.0_f64; n_items * qn * z]; - for i in 0..n_items { - let a = slope[i]; - let cp = &cat_params[i * z..(i + 1) * z]; - for (t, &theta) in nodes.iter().enumerate() { - let base = a * theta; - let lp = match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0_f64; n_cat]; - intercepts[1..].copy_from_slice(cp); - gpcm_logprobs(base, &scores, &intercepts) - } - PolyModel::Grm => grm_logprobs(base, cp), - }; - // P(Y>=c) = sum_{k>=c} P(Y=k), accumulated from the top category down - let off = (i * qn + t) * z; - let mut acc = 0.0_f64; - for c in (1..=z).rev() { - acc += lp[c].exp(); - sc[off + (c - 1)] = acc; - } - } - } - sc - }; - // model marginal over a distinct-item constraint list (local independence) - let cum_joint = |sc: &[f64], cons: &[(usize, usize)]| -> f64 { - (0..qn) - .map(|t| { - let mut pr = weights[t]; - for &(i, c) in cons { - pr *= sc[(i * qn + t) * z + (c - 1)]; - } - pr - }) - .sum() - }; - let model_moments = - |sc: &[f64]| -> Vec { moment_cons.iter().map(|cons| cum_joint(sc, cons)).collect() }; - - let s0 = build_cum(slope, cat_params); - let mom0 = model_moments(&s0); - let e: Vec = (0..s).map(|a| p_hat[a] - mom0[a]).collect(); - - // guard degenerate moments (empty/boundary category => Xi singular, df invalid) - for (a, &m) in mom0.iter().enumerate() { - if m * (1.0 - m) < 1e-10 { - return Err(format!( - "degenerate moment {a} (empty/boundary category); M2 df invalid" - )); - } - } - - // Delta (s x p) by central differences; columns per item: slope then z cat params - let mut params: Vec<(usize, isize)> = Vec::new(); // (item, -1 = slope else cat index) - for i in 0..n_items { - params.push((i, -1)); - for m in 0..z as isize { - params.push((i, m)); - } - } - let mut delta = vec![0.0_f64; s * p]; - for (col, &(pi, which)) in params.iter().enumerate() { - let mut sl = slope.to_vec(); - let mut cp = cat_params.to_vec(); - let base = if which < 0 { - sl[pi] - } else { - cp[pi * z + which as usize] - }; - let h = 1e-4 * (1.0 + base.abs()); - if which < 0 { - sl[pi] = base + h; - } else { - cp[pi * z + which as usize] = base + h; - } - let mom_plus = model_moments(&build_cum(&sl, &cp)); - if which < 0 { - sl[pi] = base - h; - } else { - cp[pi * z + which as usize] = base - h; - } - let mom_minus = model_moments(&build_cum(&sl, &cp)); - let inv = 0.5 / h; - for row in 0..s { - delta[row * p + col] = (mom_plus[row] - mom_minus[row]) * inv; - } - } - - // Xi: multinomial covariance of the cumulative margins. Cumulative indicators - // nest within an item (1{Y_i>=c}1{Y_i>=c'} = 1{Y_i>=max}), so merge the two - // constraint lists keeping the LARGER threshold per shared item. - let mut xi = vec![0.0_f64; s * s]; - for a in 0..s { - for b in a..s { - let mut merged = moment_cons[a].clone(); - for &(j, thr) in &moment_cons[b] { - if let Some(slot) = merged.iter_mut().find(|(i, _)| *i == j) { - slot.1 = slot.1.max(thr); - } else { - merged.push((j, thr)); - } - } - let cov = cum_joint(&s0, &merged) - mom0[a] * mom0[b]; - xi[a * s + b] = cov; - xi[b * s + a] = cov; - } - } - - // M2 = N ( e'Xi^-1 e - (D'Xi^-1 e)'(D'Xi^-1 D)^-1 (D'Xi^-1 e) ) - let m2 = projected_m2(&e, &delta, xi, s, p, n_f)?; - let df = (s - p) as f64; - let p_value = chi2_sf(m2, df); - let denom = df * (n_f - 1.0); - let rmsea2 = ((m2 - df).max(0.0) / denom).sqrt(); - let rmsea2_ci_lower = (nc_lambda_for(m2, df, 0.95) / denom).sqrt(); - let rmsea2_ci_upper = (nc_lambda_for(m2, df, 0.05) / denom).sqrt(); - - // first-order (c=d=1) bivariate SRMSR - let uni1 = |i: usize| i * z; // (i, c=1) - let biv11 = |idx: usize| base_biv + idx * z * z; // (idx, c=1, d=1) - let (mut ssum, mut cnt) = (0.0_f64, 0usize); - for (idx, &(i, j)) in pairs.iter().enumerate() { - let (pi, pj, pij) = (p_hat[uni1(i)], p_hat[uni1(j)], p_hat[biv11(idx)]); - let (mi, mj, mij) = (mom0[uni1(i)], mom0[uni1(j)], mom0[biv11(idx)]); - let dobs = pi * (1.0 - pi) * pj * (1.0 - pj); - let dmod = mi * (1.0 - mi) * mj * (1.0 - mj); - if dobs > 1e-12 && dmod > 1e-12 { - let robs = (pij - pi * pj) / dobs.sqrt(); - let rmod = (mij - mi * mj) / dmod.sqrt(); - ssum += (robs - rmod) * (robs - rmod); - cnt += 1; - } - } - let srmsr = srmsr_from_sum(ssum, cnt); - - // Complete-independence baseline. Each item's K-1 cumulative margins are - // free and reproduce the observed univariate cumulative margins; joint - // cumulative moments factor across items. This is the polytomous analogue - // of the zero-factor baseline used by the dichotomous M2 path. - let null_p = n_items * z; - let null_mom: Vec = moment_cons - .iter() - .map(|cons| cons.iter().map(|&(i, c)| p_hat[i * z + (c - 1)]).product()) - .collect(); - let null_e: Vec = (0..s).map(|a| p_hat[a] - null_mom[a]).collect(); - let mut null_delta = vec![0.0_f64; s * null_p]; - for (row, cons) in moment_cons.iter().enumerate() { - for &(item, threshold) in cons { - let derivative: f64 = cons - .iter() - .filter(|&&(i, c)| i != item || c != threshold) - .map(|&(i, c)| p_hat[i * z + (c - 1)]) - .product(); - null_delta[row * null_p + item * z + (threshold - 1)] = derivative; - } - } - let mut null_xi = vec![0.0_f64; s * s]; - for a in 0..s { - for b in a..s { - let mut merged = moment_cons[a].clone(); - for &(item, threshold) in &moment_cons[b] { - if let Some(slot) = merged.iter_mut().find(|(i, _)| *i == item) { - slot.1 = slot.1.max(threshold); - } else { - merged.push((item, threshold)); - } - } - let union_moment: f64 = merged - .iter() - .map(|&(i, c)| p_hat[i * z + (c - 1)]) - .product(); - let cov = union_moment - null_mom[a] * null_mom[b]; - null_xi[a * s + b] = cov; - null_xi[b * s + a] = cov; - } - } - let null_m2 = projected_m2(&null_e, &null_delta, null_xi, s, null_p, n_f)?; - let null_df = (s - null_p) as f64; - let (cfi, tli) = comparative_fit_metrics(m2, df, null_m2, null_df); - - Ok(M2Result { - m2, - df, - p_value, - rmsea2, - rmsea2_ci_lower, - rmsea2_ci_upper, - srmsr, - null_m2, - null_df, - cfi, - tli, - n_moments: s, - n_parameters: p, - n_complete: n_c, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/fitstats_m2_branch_tests.rs"] -mod m2_branch_tests; diff --git a/crates/mlsirm-core/src/gpcm.rs b/crates/mlsirm-core/src/gpcm.rs deleted file mode 100644 index 259288a68..000000000 --- a/crates/mlsirm-core/src/gpcm.rs +++ /dev/null @@ -1,645 +0,0 @@ -//! Confirmatory MULTIDIMENSIONAL generalized partial credit model (Muraki, 1992), the -//! ADJACENT-CATEGORY-LOGIT ordered polytomous model. Completes the polytomous multidimensional -//! trio alongside [`crate::nominal`] (unordered softmax) and [`crate::grm`] -//! (cumulative-logit). -//! -//! Each item `i` has `n_cat` ORDERED categories, a SINGLE multidimensional discrimination vector -//! `a_i` (free on the confirmatory 0/1 `loading_pattern`, items x D), and `n_cat - 1` free step -//! intercepts `step_i`. With INTEGER category scores `k = 0..n_cat-1` and `base_i = sum_{d in S_i} -//! a_id theta_d`, the linear predictor is `psi_ik = k * base_i + step_ik` (`step_i0 = 0` pinned) and -//! `P(Y_i = k | theta) = softmax_k(psi_ik)` — exactly `gpcm_logprobs(base_i, [0..n_cat-1], -//! [0, step_i1, .., step_i,{n_cat-1}])`. `theta ~ MVN(0, I_D)`. -//! -//! GPCM is the `a_ikd = k a_id` integer-scoring restriction of the multidimensional nominal (Bock's -//! free per-category slopes), but with a strictly smaller, single-slope-vector parametrization the -//! free-slope [`crate::nominal::fit_nominal`] cannot express as a mode. Unlike the GRM the -//! softmax is finite for ANY step values, so there is NO ordering constraint on the steps. Reduces -//! to `poly::fit_poly_unidim(PolyModel::Gpcm)` at `D = 1` within optimizer tolerance and up to -//! reflection (NOT bit-exact: `fit_poly_unidim` forces `a > 0` via a `log a` parametrization, while -//! the confirmatory model uses an UNCONSTRAINED slope so reverse-keyed / negative cross-loadings are -//! representable). -//! -//! **Estimation.** Bock-Aitkin marginal MLE (EM) over the `D`-dim latent grid, reusing the MIRT node -//! machinery (`nodes::build_xi_nodes`, `node_rule` gh/qmc/mc, so `D <= 3` uses Gauss-Hermite and -//! `D = 4..6` uses the Halton quasi-Monte-Carlo EM of Jank, 2005). Fixed node set before the EM loop -//! (monotone; `theta ~ MVN(0, I)`). The per-item M-step is an FD-Hessian Newton over -//! `[a_{d0}..a_{d,L-1}, step_1..step_{M-1}]` (`L = |S_i|`), byte-for-byte the ascent of -//! `poly::m_step_item` (ridge = Hessian conditioning only), with the GPCM node gradient chained to -//! the multidimensional slope: `d/da_id = sum_node g_base theta_d`, `d/dstep_j = sum_node -//! g_intercepts[j]` where `(g_intercepts, g_base, g_scores) = gpcm_node_gradient(base, -//! [0..M-1], [0,step..], counts_node)`. The integer scores are FIXED, so `g_scores` is DROPPED — -//! this is what makes the model the GPCM (fixed scoring) rather than the nominal (free scoring). EM -//! uses the SIGNED monotonic-decrease stopping guard (a decrease errors, not `.abs()`). -//! -//! **Identification.** Unit trait variances fix the per-dimension slope scale; `E[theta] = 0` the -//! step level; the integer scores fix the ordering/spacing. A PURE single-dimension anchor item per -//! dimension pins the rotation. The per-dimension reflection `(a_i.d, theta_d) -> (-a_i.d, -//! -theta_d)` leaves `base` — hence every `psi_ik = k base + step_ik` and category probability — -//! INVARIANT, so it is CANONICALIZED (single slope per item makes the anchor sign unambiguous): -//! dimension `d` is flipped so its largest-magnitude pure anchor loads positively, negating that -//! dimension's slopes AND `theta_d` but NOT the steps. -//! -//! # References (APA 7th ed.) -//! -//! Muraki, E. (1992). A generalized partial credit model: Application of an EM algorithm. *Applied -//! Psychological Measurement, 16*(2), 159–176. https://doi.org/10.1177/014662169201600206 -//! -//! Reckase, M. D. (2009). *Multidimensional item response theory*. Springer. -//! https://doi.org/10.1007/978-0-387-89976-3 -//! -//! Jank, W. (2005). Quasi-Monte Carlo sampling to improve the efficiency of Monte Carlo EM. -//! *Computational Statistics & Data Analysis, 48*(4), 685–701. https://doi.org/10.1016/j.csda.2004.03.019 - -use crate::marginal::XiRuleKind; -use crate::nodes::{build_xi_nodes, XiRule}; -use crate::poly::{gpcm_logprobs, gpcm_node_gradient, solve_small}; -use crate::quadrature::SUPPORTED_Q; - -const GP_MAX_NODES: usize = 200_000; -const GP_MAX_COUNT_CELLS: usize = 60_000_000; -const GP_MAX_DIMS: usize = 3; -const GP_MAX_DIMS_QMC: usize = 6; -const GP_MAX_CAT: usize = 64; -const GP_MAX_ITER: usize = 100_000; - -/// Configuration for [`fit_gpcm`]. -#[derive(Clone, Copy, Debug)] -pub struct GpcmConfig { - pub max_iter: usize, - pub tol: f64, - /// Gauss-Hermite nodes per dimension (used only for `xi_rule = GaussHermite`). - pub q: usize, - /// Newton (FD-Hessian) ridge — Hessian CONDITIONING only, NOT a parameter prior. - pub ridge: f64, - /// Inner Newton iterations per item M-step. - pub newton_iter: usize, - pub xi_rule: XiRuleKind, - pub xi_points: usize, - pub xi_seed: u64, -} - -impl Default for GpcmConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - q: 21, - ridge: 1e-8, - newton_iter: 10, - xi_rule: XiRuleKind::GaussHermite, - xi_points: 4000, - xi_seed: 0x9E37_79B9_7F4A_7C15, - } - } -} - -/// Result of [`fit_gpcm`]. -#[derive(Clone, Debug)] -pub struct GpcmResult { - pub n_dims: usize, - pub n_cat: usize, - /// Item discrimination slopes `a_id`, row-major `n_items * n_dims` (exactly `0.0` off-pattern). - /// Per-dimension reflection-canonicalized so each dimension's largest pure anchor is positive. - pub slope: Vec, - /// Category step intercepts `step_ik`, row-major `n_items * (n_cat - 1)` (`k = 1..n_cat-1`; - /// UNORDERED — the GPCM softmax is valid for any values). - pub step: Vec, - /// Per-person trait EAP `E[theta_jd | X_j]`, row-major `n_persons * n_dims`. - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub final_loglik_change: f64, - /// `sum_i (|S_i| + (n_cat - 1))` free item parameters. - pub n_parameters: usize, -} - -#[allow(clippy::too_many_arguments)] -fn validate( - y: &[usize], - observed: Option<&[bool]>, - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - cfg: &GpcmConfig, -) -> Result { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(2..=GP_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={GP_MAX_CAT}; got {n_cat}")); - } - if !(1..=GP_MAX_ITER).contains(&cfg.max_iter) { - return Err(format!("max_iter must be in 1..={GP_MAX_ITER}")); - } - if !cfg.tol.is_finite() || cfg.tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - if !cfg.ridge.is_finite() || cfg.ridge <= 0.0 { - return Err("ridge must be finite and positive".into()); - } - let n_nodes = match cfg.xi_rule { - XiRuleKind::GaussHermite => { - if !(1..=GP_MAX_DIMS).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={GP_MAX_DIMS} for the Gauss-Hermite grid; use \ - node_rule qmc/mc for D up to {GP_MAX_DIMS_QMC}" - )); - } - if !SUPPORTED_Q.contains(&cfg.q) { - return Err(format!("q must be one of {SUPPORTED_Q:?}; got {}", cfg.q)); - } - let mut n = 1usize; - for _ in 0..n_dims { - // SUPPORTED_Q and the three-dimension bound cap this at 41^3 = 68,921. - n *= cfg.q; - } - n - } - XiRuleKind::Halton | XiRuleKind::MonteCarlo => { - if !(1..=GP_MAX_DIMS_QMC).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={GP_MAX_DIMS_QMC} for the Halton/MonteCarlo rules" - )); - } - if !(1..=GP_MAX_NODES).contains(&cfg.xi_points) { - return Err(format!( - "xi_points must be in 1..={GP_MAX_NODES}; got {}", - cfg.xi_points - )); - } - cfg.xi_points - } - }; - let cells = n_nodes - .checked_mul(n_items) - .and_then(|v| v.checked_mul(n_cat)) - .ok_or_else(|| "node * item * category count-table size overflows usize".to_string())?; - if cells > GP_MAX_COUNT_CELLS { - return Err(format!( - "count table {cells} cells exceeds the cap {GP_MAX_COUNT_CELLS}; reduce nodes/items/categories" - )); - } - let n_cells = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows usize".to_string())?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - // The count-table cap above bounds n_items, while validation bounds n_dims, so this product - // cannot overflow after those checks succeed. - let n_l = n_items * n_dims; - if loading_pattern.len() != n_l { - return Err("loading_pattern must have length n_items * n_dims".into()); - } - for (idx, &v) in loading_pattern.iter().enumerate() { - if v != 0 && v != 1 { - return Err(format!("loading_pattern[{idx}] must be 0 or 1; got {v}")); - } - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - for p in 0..n_persons { - for i in 0..n_items { - if is_obs(p, i) && y[p * n_items + i] >= n_cat { - return Err("observed response categories must be < n_cat".into()); - } - } - } - for i in 0..n_items { - if !(0..n_dims).any(|d| loading_pattern[i * n_dims + d] != 0) { - return Err(format!( - "item {i} loads no dimension (all-zero loading_pattern row)" - )); - } - let mut seen = vec![false; n_cat]; - let mut any = false; - for p in 0..n_persons { - if is_obs(p, i) { - any = true; - seen[y[p * n_items + i]] = true; - } - } - if !any { - return Err(format!("item {i} has no observed responses")); - } - if let Some(k) = (0..n_cat).find(|&k| !seen[k]) { - return Err(format!( - "item {i} category {k} is never observed (unidentified GPCM step); every declared \ - category must be observed" - )); - } - } - for d in 0..n_dims { - let has_pure = (0..n_items).any(|i| { - loading_pattern[i * n_dims + d] != 0 - && (0..n_dims) - .filter(|&d2| loading_pattern[i * n_dims + d2] != 0) - .count() - == 1 - }); - if !has_pure { - return Err(format!( - "dimension {d} has no pure single-loading anchor item (needed for identification)" - )); - } - } - Ok(n_nodes) -} - -/// Negative expected complete-data log-lik and its gradient for ONE item of the multidimensional -/// GPCM. `params = [a_{d0}..a_{d,L-1}, step_1..step_{M-1}]` (`L = dims.len()`, `M = n_cat`). `base = -/// sum_t a_t * theta_{dims[t]}`; the softmax uses FIXED integer scores `[0..M-1]` and intercepts -/// `[0, step_1, .., step_{M-1}]`. `d/da_t = sum_node g_base * theta_{dims[t]}`, `d/dstep_j = sum_node -/// g_intercepts[j]`; `g_scores` is DROPPED because the scores are fixed (this is the GPCM, not the -/// nominal). -fn gpcm_item_neg_ll_grad( - params: &[f64], - dims: &[usize], - nodes: &[f64], - n_dims: usize, - counts: &[Vec], - n_cat: usize, -) -> (f64, Vec) { - let l = dims.len(); - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0f64; n_cat]; - intercepts[1..].copy_from_slice(¶ms[l..]); // step_1..step_{M-1}; intercepts[0] = 0 pinned - let mut ll = 0.0f64; - let mut grad = vec![0.0f64; params.len()]; - for (nd, cnt) in counts.iter().enumerate() { - let mut base = 0.0f64; - for (t, &d) in dims.iter().enumerate() { - base += params[t] * nodes[nd * n_dims + d]; - } - let lp = gpcm_logprobs(base, &scores, &intercepts); - ll += cnt.iter().zip(&lp).map(|(r, l2)| r * l2).sum::(); - let (g_ic, g_base, _g_sc) = gpcm_node_gradient(base, &scores, &intercepts, cnt); - for (t, &d) in dims.iter().enumerate() { - grad[t] += g_base * nodes[nd * n_dims + d]; - } - for (j, gj) in g_ic.iter().enumerate() { - grad[l + j] += gj; - } - } - (-ll, grad.iter().map(|v| -v).collect()) -} - -/// Newton M-step for one item — mirrors `poly::m_step_item` (FD Hessian, ridge conditioning, -/// backtracking line search), generalized to the multidimensional slope. The GPCM softmax is finite -/// for any parameters, so (unlike the GRM) the line search needs no ordered-boundary safeguard. -#[allow(clippy::too_many_arguments)] -fn gpcm_m_step( - mut params: Vec, - dims: &[usize], - nodes: &[f64], - n_dims: usize, - counts: &[Vec], - n_cat: usize, - ridge: f64, - n_newton: usize, -) -> Vec { - let np = params.len(); - for _ in 0..n_newton { - let (f0, g) = gpcm_item_neg_ll_grad(¶ms, dims, nodes, n_dims, counts, n_cat); - let grad_norm = g.iter().map(|v| v * v).sum::().sqrt(); - if !f0.is_finite() || !grad_norm.is_finite() || grad_norm < 1e-9 { - break; - } - let h = 1e-5; - let mut hess = vec![vec![0.0f64; np]; np]; - for j in 0..np { - let mut pj = params.clone(); - pj[j] += h; - let (_f2, gj) = gpcm_item_neg_ll_grad(&pj, dims, nodes, n_dims, counts, n_cat); - for r in 0..np { - hess[r][j] = (gj[r] - g[r]) / h; - } - } - for r in 0..np { - for c in 0..np { - hess[r][c] = 0.5 * (hess[r][c] + hess[c][r]); - } - hess[r][r] += ridge; - } - let mut step = solve_small(hess, g.clone()); - let mut directional = g.iter().zip(&step).map(|(gi, si)| gi * si).sum::(); - if !step.iter().all(|s| s.is_finite()) || directional <= 0.0 { - step = g.clone(); - directional = grad_norm * grad_norm; - } - let mut max_step = step.iter().map(|s| s.abs()).fold(0.0f64, f64::max); - if max_step > 2.0 { - for s in &mut step { - *s *= 2.0 / max_step; - } - directional = g.iter().zip(&step).map(|(gi, si)| gi * si).sum(); - max_step = 2.0; - } - let mut alpha = 1.0f64; - let mut accepted = false; - for _ in 0..25 { - let candidate: Vec = params - .iter() - .zip(&step) - .map(|(value, direction)| value - alpha * direction) - .collect(); - let (candidate_f, _) = - gpcm_item_neg_ll_grad(&candidate, dims, nodes, n_dims, counts, n_cat); - if candidate_f.is_finite() && candidate_f <= f0 - 1e-4 * alpha * directional { - params = candidate; - accepted = true; - break; - } - alpha *= 0.5; - } - if !accepted || alpha * max_step < 1e-9 { - break; - } - } - params -} - -fn checked_em_loglik_change( - current: f64, - previous: Option, - iteration: usize, -) -> Result, String> { - if !current.is_finite() { - return Err(format!( - "non-finite observed-data log-likelihood at iteration {iteration}" - )); - } - let Some(previous) = previous else { - return Ok(None); - }; - let change = current - previous; - let monotonicity_tolerance = 32.0 * f64::EPSILON * (1.0 + previous.abs()); - if change < -monotonicity_tolerance { - return Err(format!( - "EM observed-data log-likelihood decreased at iteration {iteration}: delta={change:.6e}" - )); - } - Ok(Some(change)) -} - -/// Fit the confirmatory MULTIDIMENSIONAL generalized partial credit model (Muraki, 1992) by -/// Bock-Aitkin marginal MLE. See the module docs for the model, estimation, and identification. -/// `y`/`observed` are row-major `n_persons * n_items` (`y` ordered categories `0..n_cat-1`, missing -/// cells dropped MAR); `loading_pattern` is row-major `n_items * n_dims` in `{0,1}`. Returns `Err` -/// on malformed / rotationally-underidentified / unobserved-category input. -#[allow(clippy::too_many_arguments)] -pub fn fit_gpcm( - y: &[usize], - observed: Option<&[bool]>, - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - cfg: &GpcmConfig, -) -> Result { - let _n_nodes = validate( - y, - observed, - loading_pattern, - n_persons, - n_items, - n_dims, - n_cat, - cfg, - )?; - - let xi_rule = match cfg.xi_rule { - XiRuleKind::GaussHermite => XiRule::GaussHermite { q_xi: cfg.q }, - XiRuleKind::Halton => XiRule::Halton { - n: cfg.xi_points, - shift_seed: cfg.xi_seed, - }, - XiRuleKind::MonteCarlo => XiRule::MonteCarlo { - n: cfg.xi_points, - seed: cfg.xi_seed.max(1), - }, - }; - let xn = build_xi_nodes(xi_rule, n_dims)?; - let (nodes, logw) = (xn.grid, xn.logw); - let qn = logw.len(); - let m1 = n_cat - 1; // step count - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - - let dims_of: Vec> = (0..n_items) - .map(|i| { - (0..n_dims) - .filter(|&d| loading_pattern[i * n_dims + d] != 0) - .collect() - }) - .collect(); - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - - // Init: slope = 1.0 on the item's FIRST loaded dim (0 elsewhere); step_k = log(freq_k / freq_0) - // (the GPCM baseline log-odds, NON-cumulative) — exactly fit_poly_unidim's GPCM init. - let mut params: Vec> = Vec::with_capacity(n_items); - for i in 0..n_items { - let l = dims_of[i].len(); - let mut p = vec![0.0f64; l + m1]; - p[0] = 1.0; - let mut freq = vec![1e-3f64; n_cat]; - for pp in 0..n_persons { - if is_obs(pp, i) { - freq[y[pp * n_items + i]] += 1.0; - } - } - let tot: f64 = freq.iter().sum(); - for f in freq.iter_mut() { - *f /= tot; - } - for k in 1..n_cat { - p[l + (k - 1)] = (freq[k] / freq[0]).ln(); - } - params.push(p); - } - - let mut loglik_trace: Vec = Vec::with_capacity(cfg.max_iter + 1); - let mut converged = false; - let mut n_iter = 0usize; - let mut termination_reason = "max_iter_reached".to_string(); - let mut final_loglik_change = f64::NAN; - let mut theta = vec![0.0f64; n_persons * n_dims]; - let mut log_node = vec![0.0f64; qn]; - - let fill_lp = |params: &[Vec]| -> Vec> { - let mut all_lp: Vec> = Vec::with_capacity(n_items); - for i in 0..n_items { - let l = dims_of[i].len(); - let mut intercepts = vec![0.0f64; n_cat]; - intercepts[1..].copy_from_slice(¶ms[i][l..]); - let mut lp_i = vec![0.0f64; qn * n_cat]; - for nd in 0..qn { - let mut base = 0.0f64; - for (t, &d) in dims_of[i].iter().enumerate() { - base += params[i][t] * nodes[nd * n_dims + d]; - } - let lp = gpcm_logprobs(base, &scores, &intercepts); - lp_i[nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - all_lp.push(lp_i); - } - all_lp - }; - - loop { - let all_lp = fill_lp(¶ms); - let mut counts = vec![vec![vec![0.0f64; n_cat]; qn]; n_items]; - let mut ll = 0.0f64; - for p in 0..n_persons { - log_node.copy_from_slice(&logw); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - let lp = &all_lp[i]; - for nd in 0..qn { - log_node[nd] += lp[nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in log_node.iter() { - denom += (v - mx).exp(); - } - ll += mx + denom.ln(); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - counts[i][nd][yc] += (log_node[nd] - mx).exp() / denom; - } - } - } - let previous = loglik_trace.last().copied(); - let change = checked_em_loglik_change(ll, previous, n_iter)?; - loglik_trace.push(ll); - - if let Some(change) = change { - let prev = previous.expect("change requires a previous log-likelihood"); - final_loglik_change = change; - let stop_tol = cfg.tol * (1.0 + prev.abs()); - if final_loglik_change <= stop_tol { - converged = true; - termination_reason = "tolerance_met".to_string(); - break; - } - } - if n_iter == cfg.max_iter { - break; - } - - for i in 0..n_items { - params[i] = gpcm_m_step( - params[i].clone(), - &dims_of[i], - &nodes, - n_dims, - &counts[i], - n_cat, - cfg.ridge, - cfg.newton_iter, - ); - } - n_iter += 1; - } - - // Final EAP pass. - { - let all_lp = fill_lp(¶ms); - for p in 0..n_persons { - log_node.copy_from_slice(&logw); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - let lp = &all_lp[i]; - for nd in 0..qn { - log_node[nd] += lp[nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in log_node.iter() { - denom += (v - mx).exp(); - } - for nd in 0..qn { - let post = (log_node[nd] - mx).exp() / denom; - for d in 0..n_dims { - theta[p * n_dims + d] += post * nodes[nd * n_dims + d]; - } - } - } - } - - // Assemble dense slope (n_items * n_dims) + steps (n_items * (n_cat-1)). - let mut slope = vec![0.0f64; n_items * n_dims]; - let mut step = vec![0.0f64; n_items * m1]; - let mut n_parameters = 0usize; - for i in 0..n_items { - let l = dims_of[i].len(); - n_parameters += l + m1; - for (t, &d) in dims_of[i].iter().enumerate() { - slope[i * n_dims + d] = params[i][t]; - } - step[i * m1..(i + 1) * m1].copy_from_slice(¶ms[i][l..]); - } - - // Per-dimension reflection canonicalization: flip dimension d (its slopes on every item AND - // theta_d) so its largest-|slope| PURE anchor loads positively. base — hence psi and every step - // — is invariant under the joint flip, so steps are NOT touched. - for d in 0..n_dims { - let mut anchor: Option = None; - let mut best = 0.0f64; - for i in 0..n_items { - let is_pure = dims_of[i].len() == 1 && dims_of[i][0] == d; - if is_pure && slope[i * n_dims + d].abs() > best { - best = slope[i * n_dims + d].abs(); - anchor = Some(i); - } - } - let ai = anchor.expect("validation guarantees a pure anchor for every dimension"); - if slope[ai * n_dims + d] < 0.0 { - for i in 0..n_items { - slope[i * n_dims + d] = -slope[i * n_dims + d]; - } - for p in 0..n_persons { - theta[p * n_dims + d] = -theta[p * n_dims + d]; - } - } - } - - Ok(GpcmResult { - n_dims, - n_cat, - slope, - step, - theta, - loglik_trace, - n_iter, - converged, - termination_reason, - final_loglik_change, - n_parameters, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/gpcm_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/gpu_eapsum.rs b/crates/mlsirm-core/src/gpu_eapsum.rs deleted file mode 100644 index e8b8758b2..000000000 --- a/crates/mlsirm-core/src/gpu_eapsum.rs +++ /dev/null @@ -1,710 +0,0 @@ -//! wgpu Lord-Wingersky recursion, EAPsum moment reduction, and table lookup. -//! -//! Fixed-bank probabilities and request validation stay in the shared Rust -//! core. The GPU performs the score-distribution recursion and posterior -//! reductions, or applies already validated conversion tables. Returning -//! `None` lets the public dispatch use the parallel f64 CPU reference. - -use std::sync::OnceLock; - -use bytemuck::{Pod, Zeroable}; -use wgpu::util::DeviceExt; - -use crate::scoring::EapSumTable; - -const WORKGROUP_SIZE: u32 = 64; -const MAX_ITEMS_PER_DIM: usize = 256; - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct TableUniforms { - n_dims: u32, - q_t: u32, - n_x: u32, - cell: u32, - n_scores: u32, - _pad0: u32, - _pad1: u32, - _pad2: u32, -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct LookupUniforms { - n_persons: u32, - n_items: u32, - n_dims: u32, - _pad0: u32, -} - -const TABLE_SHADER: &str = r#" -struct TableUniforms { - n_dims: u32, - q_t: u32, - n_x: u32, - cell: u32, - n_scores: u32, - _pad0: u32, - _pad1: u32, - _pad2: u32, -}; - -@group(0) @binding(0) var U: TableUniforms; -@group(0) @binding(1) var dim_item_offsets: array; -@group(0) @binding(2) var dim_items: array; -@group(0) @binding(3) var score_offsets: array; -@group(0) @binding(4) var logp1: array; -@group(0) @binding(5) var t_logw: array; -@group(0) @binding(6) var x_logw: array; -@group(0) @binding(7) var t_nodes: array; -@group(0) @binding(8) var prior_mean: array; -@group(0) @binding(9) var prior_sd: array; -@group(0) @binding(10) var score_dims: array; -@group(0) @binding(11) var score_values: array; -@group(0) @binding(12) var score_dist: array; -@group(0) @binding(13) var score_prob: array; -@group(0) @binding(14) var eap: array; -@group(0) @binding(15) var posterior_sd: array; - -@compute @workgroup_size(64) -fn recursion_pass(@builtin(global_invocation_id) gid: vec3) { - let flat = gid.x; - let count = U.n_dims * U.cell; - if (flat >= count) { return; } - let dim = flat / U.cell; - let cell_index = flat % U.cell; - let begin = dim_item_offsets[dim]; - let end = dim_item_offsets[dim + 1u]; - let n_items_dim = end - begin; - var dist: array; - for (var score = 0u; score <= n_items_dim; score = score + 1u) { - dist[score] = 0.0; - } - dist[0] = 1.0; - for (var position = begin; position < end; position = position + 1u) { - let item = dim_items[position]; - let probability = exp(logp1[item * U.cell + cell_index]); - let seen = position - begin; - var score = seen + 1u; - loop { - let stay = dist[score] * (1.0 - probability); - var up = 0.0; - if (score > 0u) { - up = dist[score - 1u] * probability; - } - dist[score] = stay + up; - if (score == 0u) { break; } - score = score - 1u; - } - } - let base = score_offsets[dim]; - for (var score = 0u; score <= n_items_dim; score = score + 1u) { - score_dist[(base + score) * U.cell + cell_index] = dist[score]; - } -} - -@compute @workgroup_size(64) -fn reduction_pass(@builtin(global_invocation_id) gid: vec3) { - let output_index = gid.x; - if (output_index >= U.n_scores) { return; } - let dim = score_dims[output_index]; - let score = score_values[output_index]; - var p0 = 0.0; - var m1 = 0.0; - var m2 = 0.0; - for (var cell_index = 0u; cell_index < U.cell; cell_index = cell_index + 1u) { - let t = cell_index / U.n_x; - let x = cell_index % U.n_x; - let weight = exp(t_logw[t] + x_logw[x]); - let theta = prior_mean[dim] + prior_sd[dim] * t_nodes[t]; - let value = weight * score_dist[(score_offsets[dim] + score) * U.cell + cell_index]; - p0 = p0 + value; - m1 = m1 + value * theta; - m2 = m2 + value * theta * theta; - } - score_prob[output_index] = p0; - if (p0 > 0.0) { - let mean = m1 / p0; - eap[output_index] = mean; - posterior_sd[output_index] = sqrt(max(0.0, m2 / p0 - mean * mean)); - } else { - eap[output_index] = prior_mean[dim]; - posterior_sd[output_index] = prior_sd[dim]; - } -} -"#; - -const LOOKUP_SHADER: &str = r#" -struct LookupUniforms { - n_persons: u32, - n_items: u32, - n_dims: u32, - _pad0: u32, -}; - -@group(0) @binding(0) var U: LookupUniforms; -@group(0) @binding(1) var responses: array; -@group(0) @binding(2) var factor_id: array; -@group(0) @binding(3) var table_offsets: array; -@group(0) @binding(4) var table_eap: array; -@group(0) @binding(5) var table_sd: array; -@group(0) @binding(6) var theta_out: array; -@group(0) @binding(7) var sd_out: array; - -@compute @workgroup_size(64) -fn lookup_pass(@builtin(global_invocation_id) gid: vec3) { - let flat = gid.x; - let count = U.n_persons * U.n_dims; - if (flat >= count) { return; } - let person = flat / U.n_dims; - let dim = flat % U.n_dims; - var score = 0u; - for (var item = 0u; item < U.n_items; item = item + 1u) { - if (factor_id[item] == dim) { - score = score + u32(responses[person * U.n_items + item]); - } - } - let table_index = table_offsets[dim] + score; - theta_out[flat] = table_eap[table_index]; - sd_out[flat] = table_sd[table_index]; -} -"#; - -struct GpuContext { - device: wgpu::Device, - queue: wgpu::Queue, - table_layout: wgpu::BindGroupLayout, - recursion_pipeline: wgpu::ComputePipeline, - reduction_pipeline: wgpu::ComputePipeline, - lookup_layout: wgpu::BindGroupLayout, - lookup_pipeline: wgpu::ComputePipeline, -} - -static CONTEXT: OnceLock> = OnceLock::new(); - -fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry { - wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - } -} - -fn layout( - device: &wgpu::Device, - label: &'static str, - storage_count: u32, - writable_from: u32, -) -> wgpu::BindGroupLayout { - let mut entries = vec![wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }]; - for binding in 1..=storage_count { - entries.push(storage_entry(binding, binding < writable_from)); - } - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some(label), - entries: &entries, - }) -} - -fn pipeline( - device: &wgpu::Device, - module: &wgpu::ShaderModule, - layout: &wgpu::BindGroupLayout, - label: &'static str, - entry_point: &'static str, -) -> wgpu::ComputePipeline { - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some(label), - bind_group_layouts: &[Some(layout)], - immediate_size: 0, - }); - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(label), - layout: Some(&pipeline_layout), - module, - entry_point: Some(entry_point), - compilation_options: wgpu::PipelineCompilationOptions::default(), - cache: None, - }) -} - -impl GpuContext { - fn init() -> Option { - let instance = wgpu::Instance::default(); - let adapter = - pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) - .ok()?; - let adapter_limits = adapter.limits(); - if adapter_limits.max_storage_buffers_per_shader_stage < 15 - || adapter_limits.max_uniform_buffers_per_shader_stage < 1 - { - return None; - } - let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("mlsirm-eapsum"), - required_limits: adapter_limits, - ..Default::default() - })) - .ok()?; - let table_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("mlsirm-eapsum-table"), - source: wgpu::ShaderSource::Wgsl(TABLE_SHADER.into()), - }); - let lookup_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("mlsirm-eapsum-lookup"), - source: wgpu::ShaderSource::Wgsl(LOOKUP_SHADER.into()), - }); - let table_layout = layout(&device, "mlsirm-eapsum-table-layout", 15, 12); - let lookup_layout = layout(&device, "mlsirm-eapsum-lookup-layout", 7, 6); - let recursion_pipeline = pipeline( - &device, - &table_module, - &table_layout, - "mlsirm-eapsum-recursion", - "recursion_pass", - ); - let reduction_pipeline = pipeline( - &device, - &table_module, - &table_layout, - "mlsirm-eapsum-reduction", - "reduction_pass", - ); - let lookup_pipeline = pipeline( - &device, - &lookup_module, - &lookup_layout, - "mlsirm-eapsum-lookup", - "lookup_pass", - ); - Some(Self { - device, - queue, - table_layout, - recursion_pipeline, - reduction_pipeline, - lookup_layout, - lookup_pipeline, - }) - } - - fn get() -> Option<&'static Self> { - CONTEXT.get_or_init(Self::init).as_ref() - } -} - -pub(crate) struct GpuEapSumTableInputs<'a> { - pub n_items: usize, - pub n_dims: usize, - pub q_t: usize, - pub n_x: usize, - pub factor_id: &'a [usize], - pub logp1: &'a [f64], - pub t_logw: &'a [f64], - pub x_logw: &'a [f64], - pub t_nodes: &'a [f64], - pub prior_mean: &'a [f64], - pub prior_sd: &'a [f64], -} - -pub(crate) struct GpuEapSumLookupInputs<'a> { - pub y: &'a [f64], - pub n_persons: usize, - pub n_items: usize, - pub n_dims: usize, - pub factor_id: &'a [usize], - pub tables: &'a [EapSumTable], -} - -fn checked_f32(values: &[f64]) -> Option> { - values - .iter() - .map(|&value| { - let converted = value as f32; - converted.is_finite().then_some(converted) - }) - .collect() -} - -fn storage(device: &wgpu::Device, data: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: None, - contents: data, - usage, - }) -} - -fn storage_f32(device: &wgpu::Device, values: &[f32]) -> wgpu::Buffer { - let placeholder = [0.0_f32]; - storage( - device, - bytemuck::cast_slice(if values.is_empty() { - &placeholder - } else { - values - }), - wgpu::BufferUsages::STORAGE, - ) -} - -fn storage_u32(device: &wgpu::Device, values: &[u32]) -> wgpu::Buffer { - let placeholder = [0_u32]; - storage( - device, - bytemuck::cast_slice(if values.is_empty() { - &placeholder - } else { - values - }), - wgpu::BufferUsages::STORAGE, - ) -} - -fn output(device: &wgpu::Device, len: usize, label: &'static str) -> wgpu::Buffer { - device.create_buffer(&wgpu::BufferDescriptor { - label: Some(label), - size: (len.max(1) * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - mapped_at_creation: false, - }) -} - -fn read_f32( - device: &wgpu::Device, - queue: &wgpu::Queue, - source: &wgpu::Buffer, - len: usize, -) -> Option> { - let size = (len.max(1) * std::mem::size_of::()) as u64; - let readback = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("eapsum-readback"), - size, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - encoder.copy_buffer_to_buffer(source, 0, &readback, 0, size); - queue.submit([encoder.finish()]); - readback.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::PollType::wait_indefinitely()).ok()?; - let view = readback.slice(..).get_mapped_range().ok()?; - let values: &[f32] = bytemuck::cast_slice(&view); - let result = values.iter().take(len).map(|&value| value as f64).collect(); - drop(view); - readback.unmap(); - Some(result) -} - -fn buffer_fits(limits: &wgpu::Limits, len: usize) -> bool { - let Some(bytes) = len.checked_mul(std::mem::size_of::()) else { - return false; - }; - bytes as u64 <= limits.max_buffer_size - && bytes <= limits.max_storage_buffer_binding_size as usize -} - -pub(crate) fn eapsum_tables_gpu(inputs: &GpuEapSumTableInputs<'_>) -> Option> { - let cell = inputs.q_t.checked_mul(inputs.n_x)?; - let work_count = inputs.n_dims.checked_mul(cell)?; - let mut dim_item_offsets = Vec::with_capacity(inputs.n_dims + 1); - let mut dim_items = Vec::with_capacity(inputs.n_items); - let mut score_offsets = Vec::with_capacity(inputs.n_dims + 1); - let mut score_dims = Vec::new(); - let mut score_values = Vec::new(); - dim_item_offsets.push(0_u32); - score_offsets.push(0_u32); - for dim in 0..inputs.n_dims { - let items: Vec = (0..inputs.n_items) - .filter(|&item| inputs.factor_id[item] == dim) - .collect(); - if items.len() > MAX_ITEMS_PER_DIM { - return None; - } - for item in items { - dim_items.push(u32::try_from(item).ok()?); - } - dim_item_offsets.push(u32::try_from(dim_items.len()).ok()?); - let n_scores_dim = dim_items.len() - dim_item_offsets[dim] as usize + 1; - for score in 0..n_scores_dim { - score_dims.push(u32::try_from(dim).ok()?); - score_values.push(u32::try_from(score).ok()?); - } - score_offsets.push(u32::try_from(score_dims.len()).ok()?); - } - let n_scores = score_dims.len(); - let score_dist_len = n_scores.checked_mul(cell)?; - let context = GpuContext::get()?; - let limits = context.device.limits(); - let recursion_groups = u32::try_from(work_count).ok()?.div_ceil(WORKGROUP_SIZE); - let reduction_groups = u32::try_from(n_scores).ok()?.div_ceil(WORKGROUP_SIZE); - if recursion_groups > limits.max_compute_workgroups_per_dimension - || reduction_groups > limits.max_compute_workgroups_per_dimension - || [ - inputs.factor_id.len(), - inputs.logp1.len(), - inputs.t_logw.len(), - inputs.x_logw.len(), - inputs.t_nodes.len(), - inputs.prior_mean.len(), - inputs.prior_sd.len(), - dim_item_offsets.len(), - dim_items.len(), - score_offsets.len(), - score_dims.len(), - score_values.len(), - score_dist_len, - n_scores, - ] - .into_iter() - .any(|len| !buffer_fits(&limits, len)) - { - return None; - } - let converted = [ - inputs.logp1, - inputs.t_logw, - inputs.x_logw, - inputs.t_nodes, - inputs.prior_mean, - inputs.prior_sd, - ] - .into_iter() - .map(checked_f32) - .collect::>>()?; - let device = &context.device; - let queue = &context.queue; - let uniforms = TableUniforms { - n_dims: u32::try_from(inputs.n_dims).ok()?, - q_t: u32::try_from(inputs.q_t).ok()?, - n_x: u32::try_from(inputs.n_x).ok()?, - cell: u32::try_from(cell).ok()?, - n_scores: u32::try_from(n_scores).ok()?, - _pad0: 0, - _pad1: 0, - _pad2: 0, - }; - let uniform_buffer = storage( - device, - bytemuck::bytes_of(&uniforms), - wgpu::BufferUsages::UNIFORM, - ); - let dim_item_offsets = storage_u32(device, &dim_item_offsets); - let dim_items = storage_u32(device, &dim_items); - let score_offsets_buffer = storage_u32(device, &score_offsets); - let logp1 = storage_f32(device, &converted[0]); - let t_logw = storage_f32(device, &converted[1]); - let x_logw = storage_f32(device, &converted[2]); - let t_nodes = storage_f32(device, &converted[3]); - let prior_mean = storage_f32(device, &converted[4]); - let prior_sd = storage_f32(device, &converted[5]); - let score_dims = storage_u32(device, &score_dims); - let score_values = storage_u32(device, &score_values); - let score_dist = output(device, score_dist_len, "eapsum-score-dist"); - let score_prob = output(device, n_scores, "eapsum-score-prob"); - let eap = output(device, n_scores, "eapsum-eap"); - let posterior_sd = output(device, n_scores, "eapsum-sd"); - let buffers = [ - (0, &uniform_buffer), - (1, &dim_item_offsets), - (2, &dim_items), - (3, &score_offsets_buffer), - (4, &logp1), - (5, &t_logw), - (6, &x_logw), - (7, &t_nodes), - (8, &prior_mean), - (9, &prior_sd), - (10, &score_dims), - (11, &score_values), - (12, &score_dist), - (13, &score_prob), - (14, &eap), - (15, &posterior_sd), - ]; - let entries: Vec<_> = buffers - .iter() - .map(|(binding, buffer)| wgpu::BindGroupEntry { - binding: *binding, - resource: buffer.as_entire_binding(), - }) - .collect(); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("mlsirm-eapsum-table-bind-group"), - layout: &context.table_layout, - entries: &entries, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&context.recursion_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups(recursion_groups, 1, 1); - } - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&context.reduction_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups(reduction_groups, 1, 1); - } - queue.submit([encoder.finish()]); - let probabilities = read_f32(device, queue, &score_prob, n_scores)?; - let means = read_f32(device, queue, &eap, n_scores)?; - let sds = read_f32(device, queue, &posterior_sd, n_scores)?; - let mut result = Vec::with_capacity(inputs.n_dims); - for dim in 0..inputs.n_dims { - let start = score_offsets[dim] as usize; - let end = score_offsets[dim + 1] as usize; - let mut score_prob = probabilities[start..end].to_vec(); - let eap = means[start..end].to_vec(); - let sd = sds[start..end].to_vec(); - let total = score_prob.iter().sum::(); - if !total.is_finite() - || (total - 1.0).abs() > 5.0e-4 - || score_prob - .iter() - .any(|&value| !value.is_finite() || value < 0.0) - || eap.iter().any(|&value| !value.is_finite()) - || sd.iter().any(|&value| !value.is_finite() || value < 0.0) - { - return None; - } - // Preserve the public probability-table invariant after f32 reduction. - // The recursion and moment work stays on the GPU; this bounded host - // packaging step removes only accumulated roundoff from the score - // marginal and pins the final entry to the remaining unit mass. - for value in &mut score_prob { - *value /= total; - } - if let Some((last, prefix)) = score_prob.split_last_mut() { - *last = 1.0 - prefix.iter().sum::(); - } - if score_prob.iter().any(|&value| value < 0.0) { - return None; - } - result.push(EapSumTable { - dim, - n_items_dim: end - start - 1, - score_prob, - eap, - sd, - }); - } - Some(result) -} - -pub(crate) fn score_eapsum_gpu(inputs: &GpuEapSumLookupInputs<'_>) -> Option<(Vec, Vec)> { - let output_len = inputs.n_persons.checked_mul(inputs.n_dims)?; - if output_len == 0 { - return None; - } - let mut table_offsets = vec![0_u32; inputs.n_dims + 1]; - let mut table_eap = Vec::new(); - let mut table_sd = Vec::new(); - let mut by_dim: Vec> = (0..inputs.n_dims).map(|_| None).collect(); - for table in inputs.tables { - by_dim[table.dim] = Some(table); - } - for dim in 0..inputs.n_dims { - let table = by_dim[dim]?; - table_eap.extend_from_slice(&table.eap); - table_sd.extend_from_slice(&table.sd); - table_offsets[dim + 1] = u32::try_from(table_eap.len()).ok()?; - } - let responses = checked_f32(inputs.y)?; - let table_eap_f32 = checked_f32(&table_eap)?; - let table_sd_f32 = checked_f32(&table_sd)?; - let factor_id: Vec = inputs - .factor_id - .iter() - .map(|&dim| u32::try_from(dim).ok()) - .collect::>()?; - let context = GpuContext::get()?; - let limits = context.device.limits(); - let workgroups = u32::try_from(output_len).ok()?.div_ceil(WORKGROUP_SIZE); - if workgroups > limits.max_compute_workgroups_per_dimension - || [ - responses.len(), - factor_id.len(), - table_offsets.len(), - table_eap_f32.len(), - table_sd_f32.len(), - output_len, - ] - .into_iter() - .any(|len| !buffer_fits(&limits, len)) - { - return None; - } - let device = &context.device; - let queue = &context.queue; - let uniforms = LookupUniforms { - n_persons: u32::try_from(inputs.n_persons).ok()?, - n_items: u32::try_from(inputs.n_items).ok()?, - n_dims: u32::try_from(inputs.n_dims).ok()?, - _pad0: 0, - }; - let uniform_buffer = storage( - device, - bytemuck::bytes_of(&uniforms), - wgpu::BufferUsages::UNIFORM, - ); - let responses = storage_f32(device, &responses); - let factor_id = storage_u32(device, &factor_id); - let table_offsets = storage_u32(device, &table_offsets); - let table_eap = storage_f32(device, &table_eap_f32); - let table_sd = storage_f32(device, &table_sd_f32); - let theta_out = output(device, output_len, "eapsum-theta"); - let sd_out = output(device, output_len, "eapsum-theta-sd"); - let buffers = [ - (0, &uniform_buffer), - (1, &responses), - (2, &factor_id), - (3, &table_offsets), - (4, &table_eap), - (5, &table_sd), - (6, &theta_out), - (7, &sd_out), - ]; - let entries: Vec<_> = buffers - .iter() - .map(|(binding, buffer)| wgpu::BindGroupEntry { - binding: *binding, - resource: buffer.as_entire_binding(), - }) - .collect(); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("mlsirm-eapsum-lookup-bind-group"), - layout: &context.lookup_layout, - entries: &entries, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&context.lookup_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups(workgroups, 1, 1); - } - queue.submit([encoder.finish()]); - let theta = read_f32(device, queue, &theta_out, output_len)?; - let sd = read_f32(device, queue, &sd_out, output_len)?; - if theta.iter().any(|value| !value.is_finite()) - || sd.iter().any(|value| !value.is_finite() || *value < 0.0) - { - return None; - } - Some((theta, sd)) -} diff --git a/crates/mlsirm-core/src/gpu_marginal.rs b/crates/mlsirm-core/src/gpu_marginal.rs deleted file mode 100644 index 1dd0eaaa9..000000000 --- a/crates/mlsirm-core/src/gpu_marginal.rs +++ /dev/null @@ -1,963 +0,0 @@ -//! wgpu f32 kernels for the marginal-EM E-step hot path. -//! -//! The E-step dominates marginal fitting (measured ~110 s/iteration on CPU f64 -//! for a 31k-person multilevel fit). This module offloads it with the same -//! race-free slot-ownership reduction strategy as `gpu.rs`: -//! -//! * `lp_pass` — one thread per (person, context): streams the per-dimension -//! online log-sum-exp over the trait nodes and writes `logz[(p,s,d,x)]` and -//! the person log-marginal `lp[(p,s)]`. -//! * `nbar_pass` — one thread per (context, dim, t, x) grid slot: reduces the -//! posterior over persons (reads `logz`/`lp`, recomputes the cheap -//! per-person cell value from the sparse positive/missing lists). -//! * `rbar_pass` / `mbar_pass` — one thread per (context, item, t, x): reduces -//! over the item-major positive (resp. missing) person lists. -//! -//! Kernels run in f32 (WGSL has no f64); accumulation noise is ~1e-4 relative, -//! which perturbs the EM trajectory but not the fixed point materially. The -//! driver in `marginal.rs` therefore uses the GPU only for E-step iterations -//! and always runs the final EAP pass (and the M-step) on the CPU in f64. When -//! no adapter is present, `e_step_gpu` returns `None` and the caller falls -//! back to the CPU E-step — behaviour identical, CI-safe. - -use std::sync::OnceLock; - -use bytemuck::{Pod, Zeroable}; -use wgpu::util::DeviceExt; - -use crate::ModelConfig; - -const WORKGROUP_SIZE: u32 = 64; -/// Compile-time bound for the per-invocation streaming buffers; validated at -/// dispatch (q_theta <= 41 by table construction). -const MAX_QT: usize = 41; - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct Uniforms { - n_persons: u32, - n_items: u32, - n_dims: u32, - n_ctx: u32, - q_t: u32, - n_x: u32, - /// 1 when every person spans every context (multilevel), 0 when each - /// person has exactly one context (single/multigroup). - all_ctx: u32, - _pad: u32, -} - -const SHADER: &str = r#" -struct Uniforms { - n_persons: u32, - n_items: u32, - n_dims: u32, - n_ctx: u32, - q_t: u32, - n_x: u32, - all_ctx: u32, - _pad: u32, -}; - -@group(0) @binding(0) var U: Uniforms; -@group(0) @binding(1) var logp0: array; -@group(0) @binding(2) var logp1: array; -@group(0) @binding(3) var c0: array; -@group(0) @binding(4) var t_logw: array; -@group(0) @binding(5) var x_logw: array; -@group(0) @binding(6) var factor_id: array; -@group(0) @binding(7) var ctx_of_person: array; -@group(0) @binding(8) var pos_off: array; -@group(0) @binding(9) var pos_items: array; -@group(0) @binding(10) var miss_off: array; -@group(0) @binding(11) var miss_items: array; -@group(0) @binding(12) var logz: array; -@group(0) @binding(13) var lp: array; -@group(0) @binding(14) var w_outer: array; -@group(0) @binding(15) var out_acc: array; -@group(0) @binding(16) var item_off: array; -@group(0) @binding(17) var item_persons: array; - -const MAX_QT: u32 = 41u; - -fn cell_l(p: u32, s: u32, d: u32, t: u32, x: u32) -> f32 { - let cell = U.q_t * U.n_x; - var v = c0[(s * U.n_dims + d) * cell + t * U.n_x + x]; - for (var j = pos_off[p]; j < pos_off[p + 1u]; j = j + 1u) { - let i = pos_items[j]; - if (factor_id[i] == d) { - let idx = (s * U.n_items + i) * cell + t * U.n_x + x; - v = v + logp1[idx] - logp0[idx]; - } - } - for (var j = miss_off[p]; j < miss_off[p + 1u]; j = j + 1u) { - let i = miss_items[j]; - if (factor_id[i] == d) { - let idx = (s * U.n_items + i) * cell + t * U.n_x + x; - v = v - logp0[idx]; - } - } - return v; -} - -@compute @workgroup_size(64) -fn lp_pass(@builtin(global_invocation_id) gid: vec3) { - let idx = gid.x; - let total = U.n_persons * U.n_ctx; - if (idx >= total) { return; } - let p = idx / U.n_ctx; - let s = idx % U.n_ctx; - if (U.all_ctx == 0u && ctx_of_person[p] != s) { return; } - - // per-x accumulator for sum_d logz — streamed, then lse over x. - var mx = -3.4e38; - var sx = 0.0; - for (var x = 0u; x < U.n_x; x = x + 1u) { - var sum_d = x_logw[x]; - for (var d = 0u; d < U.n_dims; d = d + 1u) { - // online log-sum-exp over t - var m = -3.4e38; - var acc = 0.0; - for (var t = 0u; t < U.q_t; t = t + 1u) { - let v = t_logw[t] + cell_l(p, s, d, t, x); - if (v > m) { - acc = acc * exp(m - v) + 1.0; - m = v; - } else { - acc = acc + exp(v - m); - } - } - let z = m + log(acc); - logz[((p * U.n_ctx + s) * U.n_dims + d) * U.n_x + x] = z; - sum_d = sum_d + z; - } - if (sum_d > mx) { - sx = sx * exp(mx - sum_d) + 1.0; - mx = sum_d; - } else { - sx = sx + exp(sum_d - mx); - } - } - lp[p * U.n_ctx + s] = mx + log(sx); -} - -@compute @workgroup_size(64) -fn nbar_pass(@builtin(global_invocation_id) gid: vec3) { - let idx = gid.x; - let cell = U.q_t * U.n_x; - let total = U.n_ctx * U.n_dims * cell; - if (idx >= total) { return; } - let s = idx / (U.n_dims * cell); - let rem = idx % (U.n_dims * cell); - let d = rem / cell; - let t = (rem % cell) / U.n_x; - let x = (rem % cell) % U.n_x; - - var acc = 0.0; - for (var p = 0u; p < U.n_persons; p = p + 1u) { - if (U.all_ctx == 0u && ctx_of_person[p] != s) { continue; } - let w = w_outer[s * U.n_persons + p]; - if (w < 1e-14) { continue; } - var sum_d = x_logw[x]; - for (var dd = 0u; dd < U.n_dims; dd = dd + 1u) { - sum_d = sum_d + logz[((p * U.n_ctx + s) * U.n_dims + dd) * U.n_x + x]; - } - let px = exp(sum_d - lp[p * U.n_ctx + s]); - let lz = logz[((p * U.n_ctx + s) * U.n_dims + d) * U.n_x + x]; - let pt = exp(t_logw[t] + cell_l(p, s, d, t, x) - lz); - acc = acc + w * px * pt; - } - out_acc[idx] = acc; -} - -// One thread per (ctx, item, t, x); reduces over the item-major person list -// (positives for rbar, missing for mbar — the host binds the matching list). -@compute @workgroup_size(64) -fn item_pass(@builtin(global_invocation_id) gid: vec3) { - let idx = gid.x; - let cell = U.q_t * U.n_x; - let total = U.n_ctx * U.n_items * cell; - if (idx >= total) { return; } - let s = idx / (U.n_items * cell); - let rem = idx % (U.n_items * cell); - let i = rem / cell; - let t = (rem % cell) / U.n_x; - let x = (rem % cell) % U.n_x; - let d = factor_id[i]; - - var acc = 0.0; - for (var j = item_off[i]; j < item_off[i + 1u]; j = j + 1u) { - let p = item_persons[j]; - if (U.all_ctx == 0u && ctx_of_person[p] != s) { continue; } - let w = w_outer[s * U.n_persons + p]; - if (w < 1e-14) { continue; } - var sum_d = x_logw[x]; - for (var dd = 0u; dd < U.n_dims; dd = dd + 1u) { - sum_d = sum_d + logz[((p * U.n_ctx + s) * U.n_dims + dd) * U.n_x + x]; - } - let px = exp(sum_d - lp[p * U.n_ctx + s]); - let lz = logz[((p * U.n_ctx + s) * U.n_dims + d) * U.n_x + x]; - let pt = exp(t_logw[t] + cell_l(p, s, d, t, x) - lz); - acc = acc + w * px * pt; - } - out_acc[idx] = acc; -} -"#; - -struct GpuContext { - device: wgpu::Device, - queue: wgpu::Queue, - pipeline_lp: wgpu::ComputePipeline, - pipeline_nbar: wgpu::ComputePipeline, - pipeline_item: wgpu::ComputePipeline, - layout: wgpu::BindGroupLayout, - pipeline_score: wgpu::ComputePipeline, - score_layout: wgpu::BindGroupLayout, -} - -static CONTEXT: OnceLock> = OnceLock::new(); - -fn context() -> Option<&'static GpuContext> { - CONTEXT - .get_or_init(|| { - let instance = wgpu::Instance::default(); - let adapter = - pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - ..Default::default() - })) - .ok()?; - let (device, queue) = - pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("mlsirm-marginal-gpgpu"), - // The adapter's real limits: the 18-binding layout and the - // large logz buffer exceed the downlevel defaults. - required_limits: adapter.limits(), - ..Default::default() - })) - .ok()?; - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("mlsirm-marginal-estep"), - source: wgpu::ShaderSource::Wgsl(SHADER.into()), - }); - let entries: Vec = (0..18) - .map(|binding| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: if binding == 0 { - wgpu::BufferBindingType::Uniform - } else if matches!(binding, 12 | 13 | 15) { - wgpu::BufferBindingType::Storage { read_only: false } - } else { - wgpu::BufferBindingType::Storage { read_only: true } - }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }) - .collect(); - let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("mlsirm-marginal-layout"), - entries: &entries, - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("mlsirm-marginal-pipeline-layout"), - bind_group_layouts: &[Some(&layout)], - immediate_size: 0, - }); - let make = |entry: &str| { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(entry), - layout: Some(&pipeline_layout), - module: &shader, - entry_point: Some(entry), - compilation_options: wgpu::PipelineCompilationOptions::default(), - cache: None, - }) - }; - let score_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("mlsirm-score"), - source: wgpu::ShaderSource::Wgsl(SCORE_SHADER.into()), - }); - let score_entries: Vec = (0..19) - .map(|binding| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: if binding == 0 { - wgpu::BufferBindingType::Uniform - } else if binding >= 15 { - wgpu::BufferBindingType::Storage { read_only: false } - } else { - wgpu::BufferBindingType::Storage { read_only: true } - }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }) - .collect(); - let score_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("mlsirm-score-layout"), - entries: &score_entries, - }); - let score_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("mlsirm-score-pl"), - bind_group_layouts: &[Some(&score_layout)], - immediate_size: 0, - }); - let pipeline_score = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("score_pass"), - layout: Some(&score_pl), - module: &score_shader, - entry_point: Some("score_pass"), - compilation_options: wgpu::PipelineCompilationOptions::default(), - cache: None, - }); - Some(GpuContext { - pipeline_lp: make("lp_pass"), - pipeline_nbar: make("nbar_pass"), - pipeline_item: make("item_pass"), - pipeline_score, - score_layout, - device, - queue, - layout, - }) - }) - .as_ref() -} - -/// Inputs shared by every dispatch of one E-step. -pub(crate) struct GpuEStepInputs<'a> { - pub logp0: &'a [f64], - pub logp1: &'a [f64], - pub c0: &'a [f64], - pub t_logw: &'a [f64], - pub x_logw: &'a [f64], - pub factor_id: &'a [usize], - /// Person's own context (single: 0, multigroup: group). Ignored when - /// `all_ctx` (multilevel). - pub ctx_of_person: &'a [u32], - pub all_ctx: bool, - pub n_ctx: usize, - pub pos_off: &'a [u32], - pub pos_items: &'a [u32], - pub miss_off: &'a [u32], - pub miss_items: &'a [u32], - /// Item-major positives: CSR over items -> person ids. - pub item_pos_off: &'a [u32], - pub item_pos_persons: &'a [u32], - /// Item-major missing cells. - pub item_miss_off: &'a [u32], - pub item_miss_persons: &'a [u32], -} - -/// Outputs of the person pass, needed by the caller to build cluster -/// posteriors before the accumulation dispatches. -pub(crate) struct GpuEStepOutputs { - /// Person log-marginals (kept for future consumers; the adapter derives - /// its log-likelihood inside `w_outer_fn`). - #[allow(dead_code)] - pub lp: Vec, - pub nbar: Vec, - pub rbar: Vec, - pub mbar: Vec, -} - -fn as_f32(v: &[f64]) -> Vec { - v.iter().map(|&x| x as f32).collect() -} - -fn storage(device: &wgpu::Device, data: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { - // wgpu rejects zero-sized bindings; pad empty inputs to one element. - let padded: &[u8] = if data.is_empty() { &[0u8; 4] } else { data }; - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: None, - contents: padded, - usage, - }) -} - -/// Run one full E-step on the GPU. -/// -/// `w_outer_fn` is called after the lp pass with the downloaded `lp` values -/// (f64, shape persons x n_ctx) and must return the outer weights (context- -/// major, shape n_ctx x persons): cluster posteriors for multilevel, all-ones -/// (own context) otherwise. Returns `None` when no GPU adapter is available. -pub(crate) fn e_step_gpu( - config: &ModelConfig, - inputs: &GpuEStepInputs<'_>, - w_outer_fn: &mut dyn FnMut(&[f64]) -> Vec, -) -> Option { - let ctx = context()?; - let (n_persons, n_items, n_dims) = (config.n_persons, config.n_items, config.n_dims); - let q_t = inputs.t_logw.len(); - let n_x = inputs.x_logw.len(); - let n_ctx = inputs.n_ctx; - if q_t > MAX_QT { - return None; - } - let cell = q_t * n_x; - - let uniforms = Uniforms { - n_persons: n_persons as u32, - n_items: n_items as u32, - n_dims: n_dims as u32, - n_ctx: n_ctx as u32, - q_t: q_t as u32, - n_x: n_x as u32, - all_ctx: inputs.all_ctx as u32, - _pad: 0, - }; - let device = &ctx.device; - let queue = &ctx.queue; - - use wgpu::BufferUsages as BU; - let u_buf = storage(device, bytemuck::bytes_of(&uniforms), BU::UNIFORM); - let logp0 = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.logp0)), - BU::STORAGE, - ); - let logp1 = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.logp1)), - BU::STORAGE, - ); - let c0 = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.c0)), - BU::STORAGE, - ); - let t_logw = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.t_logw)), - BU::STORAGE, - ); - let x_logw = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.x_logw)), - BU::STORAGE, - ); - let fid: Vec = inputs.factor_id.iter().map(|&d| d as u32).collect(); - let fid_buf = storage(device, bytemuck::cast_slice(&fid), BU::STORAGE); - let ctx_person = storage( - device, - bytemuck::cast_slice(inputs.ctx_of_person), - BU::STORAGE, - ); - let pos_off = storage(device, bytemuck::cast_slice(inputs.pos_off), BU::STORAGE); - let pos_items = storage(device, bytemuck::cast_slice(inputs.pos_items), BU::STORAGE); - let miss_off = storage(device, bytemuck::cast_slice(inputs.miss_off), BU::STORAGE); - let miss_items = storage(device, bytemuck::cast_slice(inputs.miss_items), BU::STORAGE); - - let logz_size = (n_persons * n_ctx * n_dims * n_x * 4) as u64; - let logz = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("logz"), - size: logz_size, - usage: BU::STORAGE, - mapped_at_creation: false, - }); - let lp_size = (n_persons * n_ctx * 4) as u64; - let lp = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("lp"), - size: lp_size, - usage: BU::STORAGE | BU::COPY_SRC, - mapped_at_creation: false, - }); - // Placeholder single-element buffers for bindings unused by a pass. The - // read-only and read-write slots need distinct buffers — binding one - // buffer with both usages in a single dispatch is a validation error. - let dummy_ro = storage(device, bytemuck::cast_slice(&[0.0f32]), BU::STORAGE); - let dummy_rw = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("dummy-rw"), - size: 4, - usage: BU::STORAGE, - mapped_at_creation: false, - }); - let dummy_u32 = storage(device, bytemuck::cast_slice(&[0u32, 0u32]), BU::STORAGE); - - let bind = |w_outer: &wgpu::Buffer, - out_acc: &wgpu::Buffer, - item_off: &wgpu::Buffer, - item_persons: &wgpu::Buffer| { - let entries = [ - (0, &u_buf), - (1, &logp0), - (2, &logp1), - (3, &c0), - (4, &t_logw), - (5, &x_logw), - (6, &fid_buf), - (7, &ctx_person), - (8, &pos_off), - (9, &pos_items), - (10, &miss_off), - (11, &miss_items), - (12, &logz), - (13, &lp), - (14, w_outer), - (15, out_acc), - (16, item_off), - (17, item_persons), - ] - .map( - |(binding, buffer): (u32, &wgpu::Buffer)| wgpu::BindGroupEntry { - binding, - resource: buffer.as_entire_binding(), - }, - ); - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &ctx.layout, - entries: &entries, - }) - }; - - // --- Pass 1: lp / logz --- - let bg = bind(&dummy_ro, &dummy_rw, &dummy_u32, &dummy_u32); - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&ctx.pipeline_lp); - pass.set_bind_group(0, &bg, &[]); - let total = (n_persons * n_ctx) as u32; - pass.dispatch_workgroups(total.div_ceil(WORKGROUP_SIZE), 1, 1); - } - let lp_read = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("lp-read"), - size: lp_size, - usage: BU::MAP_READ | BU::COPY_DST, - mapped_at_creation: false, - }); - encoder.copy_buffer_to_buffer(&lp, 0, &lp_read, 0, lp_size); - queue.submit([encoder.finish()]); - lp_read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::PollType::wait_indefinitely()).ok()?; - let lp_host: Vec = { - let view = lp_read.slice(..).get_mapped_range().ok()?; - let floats: &[f32] = bytemuck::cast_slice(&view); - floats.iter().map(|&v| v as f64).collect() - }; - lp_read.unmap(); - - // Cluster posteriors (or all-ones) computed on the host in f64. - let w_outer_host = w_outer_fn(&lp_host); - debug_assert_eq!(w_outer_host.len(), n_ctx * n_persons); - let w_outer = storage( - device, - bytemuck::cast_slice(&as_f32(&w_outer_host)), - BU::STORAGE, - ); - - let run_reduce = |pipeline: &wgpu::ComputePipeline, - total: usize, - item_off: &wgpu::Buffer, - item_persons: &wgpu::Buffer| - -> Option> { - let out_size = (total * 4) as u64; - let out = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("acc-out"), - size: out_size, - usage: BU::STORAGE | BU::COPY_SRC, - mapped_at_creation: false, - }); - let bg = bind(&w_outer, &out, item_off, item_persons); - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.dispatch_workgroups((total as u32).div_ceil(WORKGROUP_SIZE), 1, 1); - } - let read = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("acc-read"), - size: out_size, - usage: BU::MAP_READ | BU::COPY_DST, - mapped_at_creation: false, - }); - encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out_size); - queue.submit([encoder.finish()]); - read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::PollType::wait_indefinitely()).ok()?; - let view = read.slice(..).get_mapped_range().ok()?; - let floats: &[f32] = bytemuck::cast_slice(&view); - let host: Vec = floats.iter().map(|&v| v as f64).collect(); - drop(view); - read.unmap(); - Some(host) - }; - - // --- Pass 2: nbar --- - let nbar = run_reduce( - &ctx.pipeline_nbar, - n_ctx * n_dims * cell, - &dummy_u32, - &dummy_u32, - )?; - - // --- Pass 3: rbar (item-major positives) --- - let ipo = storage( - device, - bytemuck::cast_slice(inputs.item_pos_off), - BU::STORAGE, - ); - let ipp = storage( - device, - bytemuck::cast_slice(inputs.item_pos_persons), - BU::STORAGE, - ); - let rbar = run_reduce(&ctx.pipeline_item, n_ctx * n_items * cell, &ipo, &ipp)?; - - // --- Pass 4: mbar (item-major missing) — skipped when nothing is missing. - let mbar = if inputs.item_miss_persons.is_empty() { - vec![0.0; n_ctx * n_items * cell] - } else { - let imo = storage( - device, - bytemuck::cast_slice(inputs.item_miss_off), - BU::STORAGE, - ); - let imp = storage( - device, - bytemuck::cast_slice(inputs.item_miss_persons), - BU::STORAGE, - ); - run_reduce(&ctx.pipeline_item, n_ctx * n_items * cell, &imo, &imp)? - }; - - Some(GpuEStepOutputs { - lp: lp_host, - nbar, - rbar, - mbar, - }) -} - -// --------------------------------------------------------------------------- -// GPU EAP scoring (Bock & Mislevy 1982). One thread per person, race-free: -// each person owns its output slots, so no atomics / slot ownership (unlike -// the E-step). Reuses the same `cell_l` binary-sparsity decomposition. f32, -// so parity with the f64 CPU path is ~1e-4. -// --------------------------------------------------------------------------- - -const SCORE_SHADER: &str = r#" -struct SU { - n_persons: u32, - n_items: u32, - n_dims: u32, - latent_dim: u32, - q_t: u32, - n_x: u32, - _p0: u32, - _p1: u32, -}; - -@group(0) @binding(0) var U: SU; -@group(0) @binding(1) var logp0: array; -@group(0) @binding(2) var logp1: array; -@group(0) @binding(3) var c0: array; -@group(0) @binding(4) var t_logw: array; -@group(0) @binding(5) var x_logw: array; -@group(0) @binding(6) var t_nodes: array; -@group(0) @binding(7) var x_grid: array; -@group(0) @binding(8) var prior_mean: array; -@group(0) @binding(9) var prior_sd: array; -@group(0) @binding(10) var factor_id: array; -@group(0) @binding(11) var pos_off: array; -@group(0) @binding(12) var pos_items: array; -@group(0) @binding(13) var miss_off: array; -@group(0) @binding(14) var miss_items: array; -@group(0) @binding(15) var theta_eap: array; -@group(0) @binding(16) var theta_sd: array; -@group(0) @binding(17) var xi_eap: array; -@group(0) @binding(18) var loglik: array; - -fn cell_l(p: u32, d: u32, t: u32, x: u32) -> f32 { - let cell = U.q_t * U.n_x; - var v = c0[d * cell + t * U.n_x + x]; - for (var j = pos_off[p]; j < pos_off[p + 1u]; j = j + 1u) { - let i = pos_items[j]; - if (factor_id[i] == d) { - let idx = i * cell + t * U.n_x + x; - v = v + logp1[idx] - logp0[idx]; - } - } - for (var j = miss_off[p]; j < miss_off[p + 1u]; j = j + 1u) { - let i = miss_items[j]; - if (factor_id[i] == d) { - let idx = i * cell + t * U.n_x + x; - v = v - logp0[idx]; - } - } - return v; -} - -@compute @workgroup_size(64) -fn score_pass(@builtin(global_invocation_id) gid: vec3) { - let p = gid.x; - if (p >= U.n_persons) { return; } - - // pass A: person log-marginal lp - var mx = -3.4e38; - var sx = 0.0; - for (var x = 0u; x < U.n_x; x = x + 1u) { - var sum_d = x_logw[x]; - for (var d = 0u; d < U.n_dims; d = d + 1u) { - var m = -3.4e38; - var acc = 0.0; - for (var t = 0u; t < U.q_t; t = t + 1u) { - let v = t_logw[t] + cell_l(p, d, t, x); - if (v > m) { acc = acc * exp(m - v) + 1.0; m = v; } else { acc = acc + exp(v - m); } - } - sum_d = sum_d + (m + log(acc)); - } - if (sum_d > mx) { sx = sx * exp(mx - sum_d) + 1.0; mx = sum_d; } else { sx = sx + exp(sum_d - mx); } - } - let lp = mx + log(sx); - loglik[p] = lp; - - // pass B: posterior moments - var te: array; - var tm2: array; - var xe: array; - for (var d = 0u; d < U.n_dims; d = d + 1u) { te[d] = 0.0; tm2[d] = 0.0; } - for (var k = 0u; k < U.latent_dim; k = k + 1u) { xe[k] = 0.0; } - for (var x = 0u; x < U.n_x; x = x + 1u) { - var zbuf: array; - var sum_d = x_logw[x]; - for (var d = 0u; d < U.n_dims; d = d + 1u) { - var m = -3.4e38; - var acc = 0.0; - for (var t = 0u; t < U.q_t; t = t + 1u) { - let v = t_logw[t] + cell_l(p, d, t, x); - if (v > m) { acc = acc * exp(m - v) + 1.0; m = v; } else { acc = acc + exp(v - m); } - } - let z = m + log(acc); - zbuf[d] = z; - sum_d = sum_d + z; - } - let px = exp(sum_d - lp); - for (var k = 0u; k < U.latent_dim; k = k + 1u) { - xe[k] = xe[k] + px * x_grid[x * U.latent_dim + k]; - } - for (var d = 0u; d < U.n_dims; d = d + 1u) { - for (var t = 0u; t < U.q_t; t = t + 1u) { - let theta = prior_mean[d] + prior_sd[d] * t_nodes[t]; - let pt = exp(t_logw[t] + cell_l(p, d, t, x) - zbuf[d]); - te[d] = te[d] + px * pt * theta; - tm2[d] = tm2[d] + px * pt * theta * theta; - } - } - } - for (var d = 0u; d < U.n_dims; d = d + 1u) { - theta_eap[p * U.n_dims + d] = te[d]; - let vv = tm2[d] - te[d] * te[d]; - theta_sd[p * U.n_dims + d] = sqrt(max(vv, 0.0)); - } - for (var k = 0u; k < U.latent_dim; k = k + 1u) { - xi_eap[p * U.latent_dim + k] = xe[k]; - } -} -"#; - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct ScoreUniforms { - n_persons: u32, - n_items: u32, - n_dims: u32, - latent_dim: u32, - q_t: u32, - n_x: u32, - _p0: u32, - _p1: u32, -} - -/// Flattened inputs for `score_eap_gpu` (built CPU-side, reusing the same -/// tables/grids/response index as the CPU scoring path). -pub(crate) struct GpuScoreInputs<'a> { - pub n_persons: usize, - pub n_items: usize, - pub n_dims: usize, - pub latent_dim: usize, - pub q_t: usize, - pub n_x: usize, - pub logp0: &'a [f64], - pub logp1: &'a [f64], - pub c0: &'a [f64], - pub t_logw: &'a [f64], - pub x_logw: &'a [f64], - pub t_nodes: &'a [f64], - pub x_grid: &'a [f64], - pub prior_mean: &'a [f64], - pub prior_sd: &'a [f64], - pub factor_id: &'a [usize], - pub pos_off: &'a [u32], - pub pos_items: &'a [u32], - pub miss_off: &'a [u32], - pub miss_items: &'a [u32], -} - -pub(crate) struct GpuScoreOutputs { - pub theta_eap: Vec, - pub theta_sd: Vec, - pub xi_eap: Vec, - pub loglik: Vec, -} - -/// EAP scoring on the GPU; `None` when no adapter is present or the model -/// exceeds the fixed kernel bounds (n_dims, latent_dim <= 8; q_t <= 41). -pub(crate) fn score_eap_gpu(inp: &GpuScoreInputs<'_>) -> Option { - let ctx = context()?; - if inp.n_dims > 8 || inp.latent_dim > 8 || inp.q_t > MAX_QT { - return None; - } - let device = &ctx.device; - let queue = &ctx.queue; - use wgpu::BufferUsages as BU; - - let uniforms = ScoreUniforms { - n_persons: inp.n_persons as u32, - n_items: inp.n_items as u32, - n_dims: inp.n_dims as u32, - latent_dim: inp.latent_dim as u32, - q_t: inp.q_t as u32, - n_x: inp.n_x as u32, - _p0: 0, - _p1: 0, - }; - let u_buf = storage(device, bytemuck::bytes_of(&uniforms), BU::UNIFORM); - let logp0 = storage( - device, - bytemuck::cast_slice(&as_f32(inp.logp0)), - BU::STORAGE, - ); - let logp1 = storage( - device, - bytemuck::cast_slice(&as_f32(inp.logp1)), - BU::STORAGE, - ); - let c0 = storage(device, bytemuck::cast_slice(&as_f32(inp.c0)), BU::STORAGE); - let t_logw = storage( - device, - bytemuck::cast_slice(&as_f32(inp.t_logw)), - BU::STORAGE, - ); - let x_logw = storage( - device, - bytemuck::cast_slice(&as_f32(inp.x_logw)), - BU::STORAGE, - ); - let t_nodes = storage( - device, - bytemuck::cast_slice(&as_f32(inp.t_nodes)), - BU::STORAGE, - ); - let x_grid = storage( - device, - bytemuck::cast_slice(&as_f32(inp.x_grid)), - BU::STORAGE, - ); - let prior_mean = storage( - device, - bytemuck::cast_slice(&as_f32(inp.prior_mean)), - BU::STORAGE, - ); - let prior_sd = storage( - device, - bytemuck::cast_slice(&as_f32(inp.prior_sd)), - BU::STORAGE, - ); - let fid: Vec = inp.factor_id.iter().map(|&d| d as u32).collect(); - let fid_buf = storage(device, bytemuck::cast_slice(&fid), BU::STORAGE); - let pos_off = storage(device, bytemuck::cast_slice(inp.pos_off), BU::STORAGE); - let pos_items = storage(device, bytemuck::cast_slice(inp.pos_items), BU::STORAGE); - let miss_off = storage(device, bytemuck::cast_slice(inp.miss_off), BU::STORAGE); - let miss_items = storage(device, bytemuck::cast_slice(inp.miss_items), BU::STORAGE); - - let mk_out = |n: usize| { - device.create_buffer(&wgpu::BufferDescriptor { - label: Some("score-out"), - size: (n.max(1) * 4) as u64, - usage: BU::STORAGE | BU::COPY_SRC, - mapped_at_creation: false, - }) - }; - let theta_eap = mk_out(inp.n_persons * inp.n_dims); - let theta_sd = mk_out(inp.n_persons * inp.n_dims); - let xi_eap = mk_out(inp.n_persons * inp.latent_dim); - let loglik = mk_out(inp.n_persons); - - let entries = [ - (0, &u_buf), - (1, &logp0), - (2, &logp1), - (3, &c0), - (4, &t_logw), - (5, &x_logw), - (6, &t_nodes), - (7, &x_grid), - (8, &prior_mean), - (9, &prior_sd), - (10, &fid_buf), - (11, &pos_off), - (12, &pos_items), - (13, &miss_off), - (14, &miss_items), - (15, &theta_eap), - (16, &theta_sd), - (17, &xi_eap), - (18, &loglik), - ] - .map( - |(binding, buffer): (u32, &wgpu::Buffer)| wgpu::BindGroupEntry { - binding, - resource: buffer.as_entire_binding(), - }, - ); - let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &ctx.score_layout, - entries: &entries, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&ctx.pipeline_score); - pass.set_bind_group(0, &bg, &[]); - pass.dispatch_workgroups((inp.n_persons as u32).div_ceil(WORKGROUP_SIZE), 1, 1); - } - queue.submit([encoder.finish()]); - - let read = |buf: &wgpu::Buffer, n: usize| -> Option> { - let sz = (n.max(1) * 4) as u64; - let rb = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("score-read"), - size: sz, - usage: BU::MAP_READ | BU::COPY_DST, - mapped_at_creation: false, - }); - let mut enc = device.create_command_encoder(&Default::default()); - enc.copy_buffer_to_buffer(buf, 0, &rb, 0, sz); - queue.submit([enc.finish()]); - rb.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::PollType::wait_indefinitely()).ok()?; - let view = rb.slice(..).get_mapped_range().ok()?; - let floats: &[f32] = bytemuck::cast_slice(&view); - let host: Vec = floats.iter().take(n).map(|&v| v as f64).collect(); - drop(view); - rb.unmap(); - Some(host) - }; - - Some(GpuScoreOutputs { - theta_eap: read(&theta_eap, inp.n_persons * inp.n_dims)?, - theta_sd: read(&theta_sd, inp.n_persons * inp.n_dims)?, - xi_eap: read(&xi_eap, inp.n_persons * inp.latent_dim)?, - loglik: read(&loglik, inp.n_persons)?, - }) -} diff --git a/crates/mlsirm-core/src/gpu_plausible.rs b/crates/mlsirm-core/src/gpu_plausible.rs deleted file mode 100644 index 40741e8dd..000000000 --- a/crates/mlsirm-core/src/gpu_plausible.rs +++ /dev/null @@ -1,562 +0,0 @@ -//! wgpu posterior reduction and sampling for plausible values. -//! -//! The CPU builds the same fixed-bank probability tables used by EAP scoring -//! and the deterministic uniform stream. The GPU performs the expensive -//! person/grid posterior reductions and categorical selections. Returning -//! `None` keeps the public path able to fall back to the f64 CPU reference. - -use std::sync::OnceLock; - -use bytemuck::{Pod, Zeroable}; -use wgpu::util::DeviceExt; - -const WORKGROUP_SIZE: u32 = 64; - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct PlausibleUniforms { - n_persons: u32, - n_items: u32, - n_dims: u32, - q_t: u32, - n_x: u32, - n_draws: u32, - _pad0: u32, - _pad1: u32, -} - -const SHADER: &str = r#" -struct PlausibleUniforms { - n_persons: u32, - n_items: u32, - n_dims: u32, - q_t: u32, - n_x: u32, - n_draws: u32, - _pad0: u32, - _pad1: u32, -}; - -@group(0) @binding(0) var U: PlausibleUniforms; -@group(0) @binding(1) var logp0: array; -@group(0) @binding(2) var logp1: array; -@group(0) @binding(3) var c0: array; -@group(0) @binding(4) var t_logw: array; -@group(0) @binding(5) var x_logw: array; -@group(0) @binding(6) var t_nodes: array; -@group(0) @binding(7) var prior_mean: array; -@group(0) @binding(8) var prior_sd: array; -@group(0) @binding(9) var factor_id: array; -@group(0) @binding(10) var pos_off: array; -@group(0) @binding(11) var pos_items: array; -@group(0) @binding(12) var miss_off: array; -@group(0) @binding(13) var miss_items: array; -@group(0) @binding(14) var random_uniforms: array; -@group(0) @binding(15) var log_zdx: array; -@group(0) @binding(16) var samples: array; - -fn response_loglik(person: u32, dim: u32, t: u32, x: u32) -> f32 { - let cell = U.q_t * U.n_x; - var value = c0[dim * cell + t * U.n_x + x]; - for (var j = miss_off[person]; j < miss_off[person + 1u]; j = j + 1u) { - let item = miss_items[j]; - if (factor_id[item] == dim) { - let node = item * cell + t * U.n_x + x; - value = value - logp0[node]; - } - } - for (var j = pos_off[person]; j < pos_off[person + 1u]; j = j + 1u) { - let item = pos_items[j]; - if (factor_id[item] == dim) { - let node = item * cell + t * U.n_x + x; - value = value + logp1[node] - logp0[node]; - } - } - return value; -} - -@compute @workgroup_size(64) -fn posterior_pass(@builtin(global_invocation_id) gid: vec3) { - let flat = gid.x; - let count = U.n_persons * U.n_dims * U.n_x; - if (flat >= count) { return; } - let x = flat % U.n_x; - let pd = flat / U.n_x; - let dim = pd % U.n_dims; - let person = pd / U.n_dims; - var maximum = -3.402823e38; - for (var t = 0u; t < U.q_t; t = t + 1u) { - let value = t_logw[t] + response_loglik(person, dim, t, x); - maximum = max(maximum, value); - } - var total = 0.0; - for (var t = 0u; t < U.q_t; t = t + 1u) { - let value = t_logw[t] + response_loglik(person, dim, t, x); - total = total + exp(value - maximum); - } - log_zdx[flat] = maximum + log(total); -} - -fn x_log_weight(person: u32, x: u32) -> f32 { - var value = x_logw[x]; - for (var dim = 0u; dim < U.n_dims; dim = dim + 1u) { - value = value + log_zdx[(person * U.n_dims + dim) * U.n_x + x]; - } - return value; -} - -fn finite(value: f32) -> bool { - return value == value && abs(value) < 3.402823e38; -} - -fn write_invalid(person: u32, draw: u32) { - let invalid = bitcast(0x7fc00000u); - for (var dim = 0u; dim < U.n_dims; dim = dim + 1u) { - samples[(person * U.n_draws + draw) * U.n_dims + dim] = invalid; - } -} - -@compute @workgroup_size(64) -fn sample_pass(@builtin(global_invocation_id) gid: vec3) { - let flat = gid.x; - let count = U.n_persons * U.n_draws; - if (flat >= count) { return; } - let draw = flat % U.n_draws; - let person = flat / U.n_draws; - let random_base = flat * (U.n_dims + 1u); - - var x_maximum = -3.402823e38; - for (var x = 0u; x < U.n_x; x = x + 1u) { - x_maximum = max(x_maximum, x_log_weight(person, x)); - } - if (!finite(x_maximum)) { - write_invalid(person, draw); - return; - } - var x_total = 0.0; - for (var x = 0u; x < U.n_x; x = x + 1u) { - x_total = x_total + exp(x_log_weight(person, x) - x_maximum); - } - if (!finite(x_total) || x_total <= 0.0) { - write_invalid(person, draw); - return; - } - let x_target = random_uniforms[random_base] * x_total; - var x_acc = 0.0; - var x_sel = U.n_x - 1u; - for (var x = 0u; x < U.n_x; x = x + 1u) { - x_acc = x_acc + exp(x_log_weight(person, x) - x_maximum); - if (x_target <= x_acc) { - x_sel = x; - break; - } - } - - for (var dim = 0u; dim < U.n_dims; dim = dim + 1u) { - var t_maximum = -3.402823e38; - for (var t = 0u; t < U.q_t; t = t + 1u) { - let value = t_logw[t] + response_loglik(person, dim, t, x_sel); - t_maximum = max(t_maximum, value); - } - if (!finite(t_maximum)) { - write_invalid(person, draw); - return; - } - var t_total = 0.0; - for (var t = 0u; t < U.q_t; t = t + 1u) { - let value = t_logw[t] + response_loglik(person, dim, t, x_sel); - t_total = t_total + exp(value - t_maximum); - } - if (!finite(t_total) || t_total <= 0.0) { - write_invalid(person, draw); - return; - } - let t_target = random_uniforms[random_base + 1u + dim] * t_total; - var t_acc = 0.0; - var t_sel = U.q_t - 1u; - for (var t = 0u; t < U.q_t; t = t + 1u) { - let value = t_logw[t] + response_loglik(person, dim, t, x_sel); - t_acc = t_acc + exp(value - t_maximum); - if (t_target <= t_acc) { - t_sel = t; - break; - } - } - samples[(person * U.n_draws + draw) * U.n_dims + dim] = - prior_mean[dim] + prior_sd[dim] * t_nodes[t_sel]; - } -} -"#; - -struct GpuContext { - device: wgpu::Device, - queue: wgpu::Queue, - layout: wgpu::BindGroupLayout, - posterior_pipeline: wgpu::ComputePipeline, - sample_pipeline: wgpu::ComputePipeline, -} - -static CONTEXT: OnceLock> = OnceLock::new(); - -fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry { - wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - } -} - -impl GpuContext { - fn init() -> Option { - let instance = wgpu::Instance::default(); - let adapter = - pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) - .ok()?; - let adapter_limits = adapter.limits(); - if adapter_limits.max_storage_buffers_per_shader_stage < 16 - || adapter_limits.max_uniform_buffers_per_shader_stage < 1 - { - return None; - } - let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("mlsirm-plausible-values"), - required_limits: adapter_limits, - ..Default::default() - })) - .ok()?; - let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("mlsirm-plausible-values"), - source: wgpu::ShaderSource::Wgsl(SHADER.into()), - }); - let mut entries = vec![wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }]; - for binding in 1..=16u32 { - entries.push(storage_entry(binding, binding <= 14)); - } - let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("mlsirm-plausible-values-layout"), - entries: &entries, - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("mlsirm-plausible-values-pipeline-layout"), - bind_group_layouts: &[Some(&layout)], - immediate_size: 0, - }); - let make_pipeline = |entry_point: &str| { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(entry_point), - layout: Some(&pipeline_layout), - module: &module, - entry_point: Some(entry_point), - compilation_options: wgpu::PipelineCompilationOptions::default(), - cache: None, - }) - }; - Some(Self { - posterior_pipeline: make_pipeline("posterior_pass"), - sample_pipeline: make_pipeline("sample_pass"), - device, - queue, - layout, - }) - } - - fn get() -> Option<&'static Self> { - CONTEXT.get_or_init(Self::init).as_ref() - } -} - -pub(crate) struct GpuPlausibleInputs<'a> { - pub n_persons: usize, - pub n_items: usize, - pub n_dims: usize, - pub q_t: usize, - pub n_x: usize, - pub n_draws: usize, - pub logp0: &'a [f64], - pub logp1: &'a [f64], - pub c0: &'a [f64], - pub t_logw: &'a [f64], - pub x_logw: &'a [f64], - pub t_nodes: &'a [f64], - pub prior_mean: &'a [f64], - pub prior_sd: &'a [f64], - pub factor_id: &'a [usize], - pub pos_off: &'a [u32], - pub pos_items: &'a [u32], - pub miss_off: &'a [u32], - pub miss_items: &'a [u32], - pub random_uniforms: &'a [f64], -} - -fn checked_f32(values: &[f64]) -> Option> { - values - .iter() - .map(|&value| { - let converted = value as f32; - converted.is_finite().then_some(converted) - }) - .collect() -} - -fn storage(device: &wgpu::Device, data: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: None, - contents: data, - usage, - }) -} - -fn storage_f32(device: &wgpu::Device, values: &[f32]) -> wgpu::Buffer { - let placeholder = [0.0_f32]; - let data = if values.is_empty() { - &placeholder[..] - } else { - values - }; - storage( - device, - bytemuck::cast_slice(data), - wgpu::BufferUsages::STORAGE, - ) -} - -fn storage_u32(device: &wgpu::Device, values: &[u32]) -> wgpu::Buffer { - let placeholder = [0_u32]; - let data = if values.is_empty() { - &placeholder[..] - } else { - values - }; - storage( - device, - bytemuck::cast_slice(data), - wgpu::BufferUsages::STORAGE, - ) -} - -fn output(device: &wgpu::Device, len: usize, label: &'static str) -> wgpu::Buffer { - device.create_buffer(&wgpu::BufferDescriptor { - label: Some(label), - size: (len.max(1) * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - mapped_at_creation: false, - }) -} - -fn read_f32( - device: &wgpu::Device, - queue: &wgpu::Queue, - source: &wgpu::Buffer, - len: usize, -) -> Option> { - let size = (len.max(1) * std::mem::size_of::()) as u64; - let readback = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("plausible-values-readback"), - size, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - encoder.copy_buffer_to_buffer(source, 0, &readback, 0, size); - queue.submit([encoder.finish()]); - readback.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::PollType::wait_indefinitely()).ok()?; - let view = readback.slice(..).get_mapped_range().ok()?; - let values: &[f32] = bytemuck::cast_slice(&view); - let result = values.iter().take(len).map(|&value| value as f64).collect(); - drop(view); - readback.unmap(); - Some(result) -} - -fn buffer_fits(limits: &wgpu::Limits, len: usize) -> bool { - let Some(bytes) = len.checked_mul(std::mem::size_of::()) else { - return false; - }; - bytes as u64 <= limits.max_buffer_size - && bytes <= limits.max_storage_buffer_binding_size as usize -} - -/// Run plausible-value posterior reduction and draws on a usable GPU. -/// Returns `None` for unavailable hardware, unsupported sizes, non-finite f32 -/// inputs, or an invalid GPU result so the caller can use the f64 CPU path. -pub(crate) fn plausible_values_gpu(inputs: &GpuPlausibleInputs<'_>) -> Option> { - if inputs.n_persons == 0 || inputs.n_draws == 0 { - return None; - } - let posterior_count = inputs - .n_persons - .checked_mul(inputs.n_dims)? - .checked_mul(inputs.n_x)?; - let draw_count = inputs.n_persons.checked_mul(inputs.n_draws)?; - let output_count = draw_count.checked_mul(inputs.n_dims)?; - let random_count = draw_count.checked_mul(inputs.n_dims.checked_add(1)?)?; - if posterior_count > u32::MAX as usize - || draw_count > u32::MAX as usize - || output_count > u32::MAX as usize - || inputs.n_persons > u32::MAX as usize - || inputs.n_items > u32::MAX as usize - || inputs.n_dims > u32::MAX as usize - || inputs.q_t > u32::MAX as usize - || inputs.n_x > u32::MAX as usize - || inputs.n_draws > u32::MAX as usize - || inputs.random_uniforms.len() != random_count - { - return None; - } - let context = GpuContext::get()?; - let limits = context.device.limits(); - let posterior_workgroups = (posterior_count as u32).div_ceil(WORKGROUP_SIZE); - let sample_workgroups = (draw_count as u32).div_ceil(WORKGROUP_SIZE); - if posterior_workgroups > limits.max_compute_workgroups_per_dimension - || sample_workgroups > limits.max_compute_workgroups_per_dimension - { - return None; - } - let f64_inputs = [ - inputs.logp0, - inputs.logp1, - inputs.c0, - inputs.t_logw, - inputs.x_logw, - inputs.t_nodes, - inputs.prior_mean, - inputs.prior_sd, - inputs.random_uniforms, - ]; - if f64_inputs - .iter() - .any(|values| !buffer_fits(&limits, values.len())) - || [ - inputs.factor_id.len(), - inputs.pos_off.len(), - inputs.pos_items.len(), - inputs.miss_off.len(), - inputs.miss_items.len(), - ] - .into_iter() - .any(|len| !buffer_fits(&limits, len)) - || !buffer_fits(&limits, posterior_count) - || !buffer_fits(&limits, output_count) - { - return None; - } - let converted: Vec> = f64_inputs - .iter() - .map(|values| checked_f32(values)) - .collect::>()?; - let factor_id: Vec = inputs - .factor_id - .iter() - .map(|&dim| u32::try_from(dim).ok()) - .collect::>()?; - - let device = &context.device; - let queue = &context.queue; - let uniforms = PlausibleUniforms { - n_persons: inputs.n_persons as u32, - n_items: inputs.n_items as u32, - n_dims: inputs.n_dims as u32, - q_t: inputs.q_t as u32, - n_x: inputs.n_x as u32, - n_draws: inputs.n_draws as u32, - _pad0: 0, - _pad1: 0, - }; - let uniform_buffer = storage( - device, - bytemuck::bytes_of(&uniforms), - wgpu::BufferUsages::UNIFORM, - ); - let logp0 = storage_f32(device, &converted[0]); - let logp1 = storage_f32(device, &converted[1]); - let c0 = storage_f32(device, &converted[2]); - let t_logw = storage_f32(device, &converted[3]); - let x_logw = storage_f32(device, &converted[4]); - let t_nodes = storage_f32(device, &converted[5]); - let prior_mean = storage_f32(device, &converted[6]); - let prior_sd = storage_f32(device, &converted[7]); - let factor_id = storage_u32(device, &factor_id); - let pos_off = storage_u32(device, inputs.pos_off); - let pos_items = storage_u32(device, inputs.pos_items); - let miss_off = storage_u32(device, inputs.miss_off); - let miss_items = storage_u32(device, inputs.miss_items); - let random_uniforms = storage_f32(device, &converted[8]); - let log_zdx = output(device, posterior_count, "plausible-values-log-zdx"); - let samples = output(device, output_count, "plausible-values-output"); - - let buffers = [ - (0, &uniform_buffer), - (1, &logp0), - (2, &logp1), - (3, &c0), - (4, &t_logw), - (5, &x_logw), - (6, &t_nodes), - (7, &prior_mean), - (8, &prior_sd), - (9, &factor_id), - (10, &pos_off), - (11, &pos_items), - (12, &miss_off), - (13, &miss_items), - (14, &random_uniforms), - (15, &log_zdx), - (16, &samples), - ]; - let entries: Vec<_> = buffers - .iter() - .map(|(binding, buffer)| wgpu::BindGroupEntry { - binding: *binding, - resource: buffer.as_entire_binding(), - }) - .collect(); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("mlsirm-plausible-values-bind-group"), - layout: &context.layout, - entries: &entries, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&context.posterior_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups(posterior_workgroups, 1, 1); - } - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&context.sample_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups(sample_workgroups, 1, 1); - } - queue.submit([encoder.finish()]); - - let values = read_f32(device, queue, &samples, output_count)?; - if values.iter().enumerate().any(|(index, &value)| { - if !value.is_finite() { - return true; - } - let dim = index % inputs.n_dims; - !inputs.t_nodes.iter().any(|&node| { - let expected = inputs.prior_mean[dim] + inputs.prior_sd[dim] * node; - (value - expected).abs() <= 2.0e-5 * (1.0 + expected.abs()) - }) - }) { - return None; - } - Some(values) -} diff --git a/crates/mlsirm-core/src/gpu_scoring.rs b/crates/mlsirm-core/src/gpu_scoring.rs deleted file mode 100644 index dfd5baa47..000000000 --- a/crates/mlsirm-core/src/gpu_scoring.rs +++ /dev/null @@ -1,558 +0,0 @@ -//! wgpu kernels for fixed-bank scoring diagnostics. -//! -//! This module owns the accelerator implementation of item and test -//! information and empirical reliability. The parallel f64 implementation in -//! `scoring.rs` remains the hardware-independent fallback and numerical -//! reference. - -use std::sync::OnceLock; - -use bytemuck::{Pod, Zeroable}; -use wgpu::util::DeviceExt; - -const WORKGROUP_SIZE: u32 = 64; - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct InformationUniforms { - n_points: u32, - n_items: u32, - n_dims: u32, - latent_dim: u32, - free_alpha: u32, - interaction_kind: u32, - _pad0: u32, - _pad1: u32, -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct ReliabilityUniforms { - n_persons: u32, - n_dims: u32, - _pad0: u32, - _pad1: u32, -} - -const SHADER: &str = r#" -struct InformationUniforms { - n_points: u32, - n_items: u32, - n_dims: u32, - latent_dim: u32, - free_alpha: u32, - interaction_kind: u32, - _pad0: u32, - _pad1: u32, -}; - -@group(0) @binding(0) var U: InformationUniforms; -@group(0) @binding(1) var alpha: array; -@group(0) @binding(2) var intercept: array; -@group(0) @binding(3) var zeta: array; -// [gamma, eps_distance] -@group(0) @binding(4) var scalars: array; -@group(0) @binding(5) var factor_id: array; -@group(0) @binding(6) var theta: array; -@group(0) @binding(7) var xi: array; -@group(0) @binding(8) var item_info: array; -@group(0) @binding(9) var test_info: array; - -fn logistic(x: f32) -> f32 { - if (x >= 0.0) { - return 1.0 / (1.0 + exp(-x)); - } - let ex = exp(x); - return ex / (1.0 + ex); -} - -fn information_at(point: u32, item: u32) -> f32 { - let dim = factor_id[item]; - var a = 1.0; - if (U.free_alpha != 0u) { - a = exp(alpha[item]); - } - var eta = a * theta[point * U.n_dims + dim] + intercept[item]; - if (U.interaction_kind == 1u) { - var dist2 = scalars[1]; - for (var k = 0u; k < U.latent_dim; k = k + 1u) { - let diff = xi[point * U.latent_dim + k] - - zeta[item * U.latent_dim + k]; - dist2 = dist2 + diff * diff; - } - eta = eta - scalars[0] * sqrt(dist2); - } else if (U.interaction_kind == 2u) { - for (var k = 0u; k < U.latent_dim; k = k + 1u) { - eta = eta + zeta[item * U.latent_dim + k] - * xi[point * U.latent_dim + k]; - } - } - let probability = logistic(eta); - return a * a * probability * (1.0 - probability); -} - -@compute @workgroup_size(64) -fn item_information_pass(@builtin(global_invocation_id) gid: vec3) { - let flat = gid.x; - let count = U.n_points * U.n_items; - if (flat >= count) { return; } - let point = flat / U.n_items; - let item = flat % U.n_items; - item_info[flat] = information_at(point, item); -} - -@compute @workgroup_size(64) -fn test_information_pass(@builtin(global_invocation_id) gid: vec3) { - let flat = gid.x; - let count = U.n_points * U.n_dims; - if (flat >= count) { return; } - let point = flat / U.n_dims; - let dim = flat % U.n_dims; - var total = 0.0; - for (var item = 0u; item < U.n_items; item = item + 1u) { - if (factor_id[item] == dim) { - total = total + item_info[point * U.n_items + item]; - } - } - test_info[flat] = total; -} -"#; - -const RELIABILITY_SHADER: &str = r#" -struct ReliabilityUniforms { - n_persons: u32, - n_dims: u32, - _pad0: u32, - _pad1: u32, -}; - -@group(0) @binding(0) var U: ReliabilityUniforms; -@group(0) @binding(1) var theta_eap: array; -@group(0) @binding(2) var theta_sd: array; -@group(0) @binding(3) var reliability: array; - -@compute @workgroup_size(64) -fn empirical_reliability_pass(@builtin(global_invocation_id) gid: vec3) { - let dim = gid.x; - if (dim >= U.n_dims) { return; } - let n = f32(U.n_persons); - var mean = 0.0; - for (var person = 0u; person < U.n_persons; person = person + 1u) { - mean = mean + theta_eap[person * U.n_dims + dim]; - } - mean = mean / n; - var variance = 0.0; - var mse = 0.0; - for (var person = 0u; person < U.n_persons; person = person + 1u) { - let cell = person * U.n_dims + dim; - let centered = theta_eap[cell] - mean; - variance = variance + centered * centered; - mse = mse + theta_sd[cell] * theta_sd[cell]; - } - variance = variance / n; - mse = mse / n; - let denominator = variance + mse; - if (denominator > 0.0) { - reliability[dim] = variance / denominator; - } else { - reliability[dim] = bitcast(0x7fc00000u); - } -} -"#; - -struct GpuContext { - device: wgpu::Device, - queue: wgpu::Queue, - layout: wgpu::BindGroupLayout, - item_pipeline: wgpu::ComputePipeline, - test_pipeline: wgpu::ComputePipeline, - reliability_pipeline: wgpu::ComputePipeline, -} - -static CONTEXT: OnceLock> = OnceLock::new(); - -fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry { - wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - } -} - -impl GpuContext { - fn init() -> Option { - let instance = wgpu::Instance::default(); - let adapter = - pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) - .ok()?; - let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("mlsirm-scoring-information"), - required_limits: adapter.limits(), - ..Default::default() - })) - .ok()?; - let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("mlsirm-scoring-information"), - source: wgpu::ShaderSource::Wgsl(SHADER.into()), - }); - let mut entries = vec![wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }]; - for binding in 1..=9u32 { - entries.push(storage_entry(binding, binding <= 7)); - } - let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("mlsirm-scoring-information-layout"), - entries: &entries, - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("mlsirm-scoring-information-pipeline-layout"), - bind_group_layouts: &[Some(&layout)], - immediate_size: 0, - }); - let make_pipeline = |entry_point: &str| { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(entry_point), - layout: Some(&pipeline_layout), - module: &module, - entry_point: Some(entry_point), - compilation_options: wgpu::PipelineCompilationOptions::default(), - cache: None, - }) - }; - let reliability_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("mlsirm-empirical-reliability"), - source: wgpu::ShaderSource::Wgsl(RELIABILITY_SHADER.into()), - }); - let reliability_pipeline = - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("empirical_reliability_pass"), - layout: None, - module: &reliability_module, - entry_point: Some("empirical_reliability_pass"), - compilation_options: wgpu::PipelineCompilationOptions::default(), - cache: None, - }); - Some(Self { - item_pipeline: make_pipeline("item_information_pass"), - test_pipeline: make_pipeline("test_information_pass"), - reliability_pipeline, - device, - queue, - layout, - }) - } - - fn get() -> Option<&'static Self> { - CONTEXT.get_or_init(Self::init).as_ref() - } -} - -pub(crate) struct GpuInformationInputs<'a> { - pub n_points: usize, - pub n_items: usize, - pub n_dims: usize, - pub latent_dim: usize, - pub free_alpha: bool, - /// 0 = none, 1 = distance, 2 = inner product. - pub interaction_kind: u32, - pub gamma: f64, - pub eps_distance: f64, - pub alpha: &'a [f64], - pub b: &'a [f64], - pub zeta: &'a [f64], - pub factor_id: &'a [usize], - pub theta: &'a [f64], - pub xi: &'a [f64], -} - -pub(crate) struct GpuInformationOutputs { - pub item_info: Vec, - pub test_info: Vec, -} - -fn as_f32(values: &[f64]) -> Vec { - values.iter().map(|&value| value as f32).collect() -} - -fn checked_f32(values: &[f64]) -> Option> { - values - .iter() - .map(|&value| { - let converted = value as f32; - converted.is_finite().then_some(converted) - }) - .collect() -} - -fn buffer_fits(limits: &wgpu::Limits, len: usize) -> bool { - let Some(bytes) = len.checked_mul(std::mem::size_of::()) else { - return false; - }; - bytes as u64 <= limits.max_buffer_size - && bytes <= limits.max_storage_buffer_binding_size as usize -} - -fn storage(device: &wgpu::Device, data: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: None, - contents: data, - usage, - }) -} - -fn output(device: &wgpu::Device, len: usize) -> wgpu::Buffer { - device.create_buffer(&wgpu::BufferDescriptor { - label: Some("scoring-information-output"), - size: (len.max(1) * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - mapped_at_creation: false, - }) -} - -fn read_f32( - device: &wgpu::Device, - queue: &wgpu::Queue, - source: &wgpu::Buffer, - len: usize, -) -> Option> { - let size = (len.max(1) * std::mem::size_of::()) as u64; - let readback = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("scoring-information-readback"), - size, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - encoder.copy_buffer_to_buffer(source, 0, &readback, 0, size); - queue.submit([encoder.finish()]); - readback.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::PollType::wait_indefinitely()).ok()?; - let view = readback.slice(..).get_mapped_range().ok()?; - let values: &[f32] = bytemuck::cast_slice(&view); - let result = values.iter().take(len).map(|&value| value as f64).collect(); - drop(view); - readback.unmap(); - Some(result) -} - -/// Compute item and per-dimension test information on a usable GPU. -/// Returns `None` when no adapter is available, the flattened outputs exceed -/// WGSL's u32 indexing range, or f32 arithmetic produces invalid information. -pub(crate) fn bank_information_gpu( - inputs: &GpuInformationInputs<'_>, -) -> Option { - if inputs.n_points == 0 || inputs.n_items == 0 { - return None; - } - let item_count = inputs.n_points.checked_mul(inputs.n_items)?; - let test_count = inputs.n_points.checked_mul(inputs.n_dims)?; - if item_count > u32::MAX as usize - || test_count > u32::MAX as usize - || inputs.n_items > u32::MAX as usize - || inputs.n_dims > u32::MAX as usize - || inputs.latent_dim > u32::MAX as usize - { - return None; - } - let context = GpuContext::get()?; - let device = &context.device; - let queue = &context.queue; - use wgpu::BufferUsages as BU; - - let uniforms = InformationUniforms { - n_points: inputs.n_points as u32, - n_items: inputs.n_items as u32, - n_dims: inputs.n_dims as u32, - latent_dim: inputs.latent_dim as u32, - free_alpha: u32::from(inputs.free_alpha), - interaction_kind: inputs.interaction_kind, - _pad0: 0, - _pad1: 0, - }; - let uniform_buffer = storage(device, bytemuck::bytes_of(&uniforms), BU::UNIFORM); - let alpha = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.alpha)), - BU::STORAGE, - ); - let intercept = storage(device, bytemuck::cast_slice(&as_f32(inputs.b)), BU::STORAGE); - let zeta = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.zeta)), - BU::STORAGE, - ); - let scalars = [inputs.gamma as f32, inputs.eps_distance as f32]; - let scalars = storage(device, bytemuck::cast_slice(&scalars), BU::STORAGE); - let factor_id: Vec = inputs.factor_id.iter().map(|&dim| dim as u32).collect(); - let factor_id = storage(device, bytemuck::cast_slice(&factor_id), BU::STORAGE); - let theta = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.theta)), - BU::STORAGE, - ); - let xi = storage( - device, - bytemuck::cast_slice(&as_f32(inputs.xi)), - BU::STORAGE, - ); - let item_info = output(device, item_count); - let test_info = output(device, test_count); - - let buffers = [ - (0, &uniform_buffer), - (1, &alpha), - (2, &intercept), - (3, &zeta), - (4, &scalars), - (5, &factor_id), - (6, &theta), - (7, &xi), - (8, &item_info), - (9, &test_info), - ]; - let entries: Vec<_> = buffers - .iter() - .map(|(binding, buffer)| wgpu::BindGroupEntry { - binding: *binding, - resource: buffer.as_entire_binding(), - }) - .collect(); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("mlsirm-scoring-information-bind-group"), - layout: &context.layout, - entries: &entries, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&context.item_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups((item_count as u32).div_ceil(WORKGROUP_SIZE), 1, 1); - } - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&context.test_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups((test_count as u32).div_ceil(WORKGROUP_SIZE), 1, 1); - } - queue.submit([encoder.finish()]); - - let item_info = read_f32(device, queue, &item_info, item_count)?; - let test_info = read_f32(device, queue, &test_info, test_count)?; - if item_info - .iter() - .chain(&test_info) - .any(|&value| !value.is_finite() || value < 0.0) - { - return None; - } - Some(GpuInformationOutputs { - item_info, - test_info, - }) -} - -/// Compute empirical EAP reliability on a usable GPU. Returns `None` when the -/// request exceeds WebGPU bounds, f64 inputs cannot be represented as finite -/// f32 values, no adapter is available, or the kernel produces an invalid -/// reliability value. -pub(crate) fn empirical_reliability_gpu( - theta_eap: &[f64], - theta_sd: &[f64], - n_persons: usize, - n_dims: usize, -) -> Option> { - let cell_count = n_persons.checked_mul(n_dims)?; - if n_persons == 0 - || n_dims == 0 - || theta_eap.len() != cell_count - || theta_sd.len() != cell_count - || n_persons > u32::MAX as usize - || n_dims > u32::MAX as usize - { - return None; - } - let context = GpuContext::get()?; - let limits = context.device.limits(); - let workgroups = (n_dims as u32).div_ceil(WORKGROUP_SIZE); - if workgroups > limits.max_compute_workgroups_per_dimension - || !buffer_fits(&limits, cell_count) - || !buffer_fits(&limits, n_dims) - { - return None; - } - let theta_eap = checked_f32(theta_eap)?; - let theta_sd = checked_f32(theta_sd)?; - let device = &context.device; - let queue = &context.queue; - use wgpu::BufferUsages as BU; - let uniforms = ReliabilityUniforms { - n_persons: n_persons as u32, - n_dims: n_dims as u32, - _pad0: 0, - _pad1: 0, - }; - let uniform_buffer = storage(device, bytemuck::bytes_of(&uniforms), BU::UNIFORM); - let theta_eap = storage(device, bytemuck::cast_slice(&theta_eap), BU::STORAGE); - let theta_sd = storage(device, bytemuck::cast_slice(&theta_sd), BU::STORAGE); - let reliability = output(device, n_dims); - let layout = context.reliability_pipeline.get_bind_group_layout(0); - let buffers = [ - (0, &uniform_buffer), - (1, &theta_eap), - (2, &theta_sd), - (3, &reliability), - ]; - let entries: Vec<_> = buffers - .iter() - .map(|(binding, buffer)| wgpu::BindGroupEntry { - binding: *binding, - resource: buffer.as_entire_binding(), - }) - .collect(); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("mlsirm-empirical-reliability-bind-group"), - layout: &layout, - entries: &entries, - }); - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_compute_pass(&Default::default()); - pass.set_pipeline(&context.reliability_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups(workgroups, 1, 1); - } - queue.submit([encoder.finish()]); - - let values = read_f32(device, queue, &reliability, n_dims)?; - if values.iter().any(|&value| { - !(value.is_nan() || (value.is_finite() && (0.0..=1.000_001).contains(&value))) - }) { - return None; - } - Some( - values - .into_iter() - .map(|value| { - if value.is_nan() { - value - } else { - value.clamp(0.0, 1.0) - } - }) - .collect(), - ) -} diff --git a/crates/mlsirm-core/src/grm.rs b/crates/mlsirm-core/src/grm.rs deleted file mode 100644 index a23da2ad5..000000000 --- a/crates/mlsirm-core/src/grm.rs +++ /dev/null @@ -1,663 +0,0 @@ -//! Confirmatory MULTIDIMENSIONAL graded response model (Samejima, 1969; Muraki & Carlson, 1995), -//! the ORDERED-category counterpart of [`crate::nominal::fit_nominal`] and the polytomous -//! generalization of the compensatory MIRT ([`crate::twopl::fit_2pl`]). -//! -//! Each item `i` has `n_cat` ORDERED categories, a SINGLE multidimensional discrimination vector -//! `a_i` (free on the confirmatory 0/1 `loading_pattern`, items x D), and `n_cat - 1` ORDERED -//! category boundary intercepts `beta_i`. The cumulative boundaries are -//! `P(Y_i >= k | theta) = sigmoid(sum_{d in S_i} a_id theta_d + beta_i,{k-1})` (`k = 1..n_cat-1`), -//! and the category probability is the adjacent difference — exactly -//! `grm_logprobs(base, beta_i)` with `base = sum_{d in S_i} a_id theta_d`. `theta ~ MVN(0, I_D)`. -//! Valid probabilities require the boundaries to be STRICTLY DECREASING -//! (`beta_i,0 > beta_i,1 > ... > beta_i,{M-2}`). -//! -//! At `D = 1` with `S_i = {0}` this is `poly::fit_poly_unidim(PolyModel::Grm)` — but WITHIN optimizer -//! tolerance and up to a reflection, not bit-exact: `fit_poly_unidim` forces `a > 0` via a `log a` -//! parametrization, whereas the confirmatory multidimensional model uses an UNCONSTRAINED slope so -//! that reverse-keyed / negative cross-loadings are representable (the compensatory-MIRT choice). -//! -//! **Estimation.** Bock-Aitkin marginal MLE (EM) over the `D`-dim latent grid, reusing the MIRT node -//! machinery (`nodes::build_xi_nodes`, `node_rule` gh/qmc/mc, so `D <= 3` uses Gauss-Hermite and -//! `D = 4..6` uses the Halton quasi-Monte-Carlo EM of Jank, 2005). The node set is built ONCE before -//! the EM loop; because `theta ~ MVN(0, I)` never reparametrizes the nodes, EM is monotone. The -//! per-item M-step is a finite-difference-Hessian Newton over `[a_{d0}..a_{d,L-1}, beta_1..beta_{M-1}]` -//! (`L = |S_i|`), byte-for-byte the ascent of `poly::m_step_item` (ridge = Hessian conditioning only, -//! NOT a prior), with the GRM node gradient chained to the multidimensional slope: -//! `d/da_id = sum_node g_base theta_d`, `d/dbeta_j = sum_node g_thr[j]` where -//! `(g_base, g_thr) = grm_node_gradient(base, beta, counts_node)`. The backtracking line search -//! REJECTS any step that makes the objective non-finite, which is exactly how the ordered-threshold -//! constraint is maintained WITHOUT an explicit reparametrization: every adjacent boundary pair -//! `(beta_{k-1}, beta_k)` is the middle category `k`'s only source, whose log-probability is `NaN` -//! (via `ln(-expm1(beta_k - beta_{k-1}))`) the instant `beta_{k-1} <= beta_k`, and `0 * NaN = NaN` -//! so a zero expected count cannot mask it — adjacency + transitivity therefore make a finite -//! objective imply a fully ordered `beta`. -//! -//! **Identification.** Unit trait variances fix the per-dimension slope scale; ordered thresholds fix -//! the category direction; a PURE single-dimension anchor item per dimension pins the rotation to the -//! coordinate axes (one slope per item, so identical to the compensatory MIRT). The per-dimension -//! reflection `(a_i.d, theta_d) -> (-a_i.d, -theta_d)` leaves `base` — and therefore every threshold -//! and category probability — INVARIANT, so it is CANONICALIZED (unlike the nominal, whose -//! per-category slopes make the anchor sign ambiguous): dimension `d` is flipped so its -//! largest-magnitude pure anchor loads positively, negating that dimension's slopes AND the person -//! trait `theta_d`, but NOT the thresholds. -//! -//! # References (APA 7th ed.) -//! -//! Samejima, F. (1969). Estimation of latent ability using a response pattern of graded scores. -//! *Psychometrika, 34*(S1), 1-97. https://doi.org/10.1007/BF03372160 -//! -//! Muraki, E., & Carlson, J. E. (1995). Full-information factor analysis for polytomous item -//! responses. *Applied Psychological Measurement, 19*(1), 73-90. -//! https://doi.org/10.1177/014662169501900109 -//! -//! Reckase, M. D. (2009). *Multidimensional item response theory*. Springer. -//! https://doi.org/10.1007/978-0-387-89976-3 -//! -//! Jank, W. (2005). Quasi-Monte Carlo sampling to improve the efficiency of Monte Carlo EM. -//! *Computational Statistics & Data Analysis, 48*(4), 685-701. https://doi.org/10.1016/j.csda.2004.03.019 - -use crate::marginal::XiRuleKind; -use crate::nodes::{build_xi_nodes, XiRule}; -use crate::poly::{grm_logprobs, grm_node_gradient, solve_small}; -use crate::quadrature::SUPPORTED_Q; - -const GM_MAX_NODES: usize = 200_000; -const GM_MAX_COUNT_CELLS: usize = 60_000_000; -const GM_MAX_DIMS: usize = 3; -const GM_MAX_DIMS_QMC: usize = 6; -const GM_MAX_CAT: usize = 64; -const GM_MAX_ITER: usize = 100_000; - -/// Configuration for [`fit_grm`]. -#[derive(Clone, Copy, Debug)] -pub struct GrmConfig { - pub max_iter: usize, - pub tol: f64, - /// Gauss-Hermite nodes per dimension (used only for `xi_rule = GaussHermite`). - pub q: usize, - /// Newton (FD-Hessian) ridge — Hessian CONDITIONING only, NOT a parameter prior (matches - /// `poly::m_step_item`'s `1e-8`). - pub ridge: f64, - /// Inner Newton iterations per item M-step. - pub newton_iter: usize, - pub xi_rule: XiRuleKind, - pub xi_points: usize, - pub xi_seed: u64, -} - -impl Default for GrmConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - q: 21, - ridge: 1e-8, - newton_iter: 10, - xi_rule: XiRuleKind::GaussHermite, - xi_points: 4000, - xi_seed: 0x9E37_79B9_7F4A_7C15, - } - } -} - -/// Result of [`fit_grm`]. -#[derive(Clone, Debug)] -pub struct GrmResult { - pub n_dims: usize, - pub n_cat: usize, - /// Item discrimination slopes `a_id`, row-major `n_items * n_dims` (exactly `0.0` off-pattern). - /// Per-dimension reflection-canonicalized so each dimension's largest pure anchor is positive. - pub slope: Vec, - /// Ordered boundary intercepts `beta_ik`, row-major `n_items * (n_cat - 1)` (strictly - /// decreasing within each item). - pub threshold: Vec, - /// Per-person trait EAP `E[theta_jd | X_j]`, row-major `n_persons * n_dims`. - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub final_loglik_change: f64, - /// `sum_i (|S_i| + (n_cat - 1))` free item parameters. - pub n_parameters: usize, -} - -#[allow(clippy::too_many_arguments)] -fn validate( - y: &[usize], - observed: Option<&[bool]>, - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - cfg: &GrmConfig, -) -> Result { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(2..=GM_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={GM_MAX_CAT}; got {n_cat}")); - } - if !(1..=GM_MAX_ITER).contains(&cfg.max_iter) { - return Err(format!("max_iter must be in 1..={GM_MAX_ITER}")); - } - if !cfg.tol.is_finite() || cfg.tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - if !cfg.ridge.is_finite() || cfg.ridge <= 0.0 { - return Err("ridge must be finite and positive".into()); - } - let n_nodes = match cfg.xi_rule { - XiRuleKind::GaussHermite => { - if !(1..=GM_MAX_DIMS).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={GM_MAX_DIMS} for the Gauss-Hermite grid; use \ - node_rule qmc/mc for D up to {GM_MAX_DIMS_QMC}" - )); - } - if !SUPPORTED_Q.contains(&cfg.q) { - return Err(format!("q must be one of {SUPPORTED_Q:?}; got {}", cfg.q)); - } - let mut n = 1usize; - for _ in 0..n_dims { - // SUPPORTED_Q and the three-dimension bound cap this at 41^3 = 68,921. - n *= cfg.q; - } - n - } - XiRuleKind::Halton | XiRuleKind::MonteCarlo => { - if !(1..=GM_MAX_DIMS_QMC).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={GM_MAX_DIMS_QMC} for the Halton/MonteCarlo rules" - )); - } - if !(1..=GM_MAX_NODES).contains(&cfg.xi_points) { - return Err(format!( - "xi_points must be in 1..={GM_MAX_NODES}; got {}", - cfg.xi_points - )); - } - cfg.xi_points - } - }; - let cells = n_nodes - .checked_mul(n_items) - .and_then(|v| v.checked_mul(n_cat)) - .ok_or_else(|| "node * item * category count-table size overflows usize".to_string())?; - if cells > GM_MAX_COUNT_CELLS { - return Err(format!( - "count table {cells} cells exceeds the cap {GM_MAX_COUNT_CELLS}; reduce nodes/items/categories" - )); - } - let n_cells = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows usize".to_string())?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - // The count-table cap above bounds n_items, while validation bounds n_dims. - let n_l = n_items * n_dims; - if loading_pattern.len() != n_l { - return Err("loading_pattern must have length n_items * n_dims".into()); - } - for (idx, &v) in loading_pattern.iter().enumerate() { - if v != 0 && v != 1 { - return Err(format!("loading_pattern[{idx}] must be 0 or 1; got {v}")); - } - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - for p in 0..n_persons { - for i in 0..n_items { - if is_obs(p, i) && y[p * n_items + i] >= n_cat { - return Err("observed response categories must be < n_cat".into()); - } - } - } - for i in 0..n_items { - if !(0..n_dims).any(|d| loading_pattern[i * n_dims + d] != 0) { - return Err(format!( - "item {i} loads no dimension (all-zero loading_pattern row)" - )); - } - let mut seen = vec![false; n_cat]; - let mut any = false; - for p in 0..n_persons { - if is_obs(p, i) { - any = true; - seen[y[p * n_items + i]] = true; - } - } - if !any { - return Err(format!("item {i} has no observed responses")); - } - if let Some(k) = (0..n_cat).find(|&k| !seen[k]) { - return Err(format!( - "item {i} category {k} is never observed (unidentified GRM boundary); every declared \ - category must be observed" - )); - } - } - for d in 0..n_dims { - let has_pure = (0..n_items).any(|i| { - loading_pattern[i * n_dims + d] != 0 - && (0..n_dims) - .filter(|&d2| loading_pattern[i * n_dims + d2] != 0) - .count() - == 1 - }); - if !has_pure { - return Err(format!( - "dimension {d} has no pure single-loading anchor item (needed for identification)" - )); - } - } - Ok(n_nodes) -} - -/// Negative expected complete-data log-lik and its gradient for ONE item of the multidimensional -/// GRM. `params = [a_{d0}..a_{d,L-1}, beta_1..beta_{M-1}]` (`L = dims.len()`, `M = n_cat`); the slope -/// block precedes the `M-1` ordered boundary intercepts. `base = sum_t a_t * theta_{dims[t]}`; -/// `d/da_t = sum_node g_base * theta_{dims[t]}`, `d/dbeta_j = sum_node g_thr[j]`, chaining the GRM -/// node gradient. At `D = 1` (`L = 1`) the slope is the single `a` (unconstrained, vs -/// `poly::item_neg_ll_grad`'s `log a`). -fn grm_item_neg_ll_grad( - params: &[f64], - dims: &[usize], - nodes: &[f64], - n_dims: usize, - counts: &[Vec], - n_cat: usize, -) -> (f64, Vec) { - let l = dims.len(); - let beta = ¶ms[l..]; // M-1 boundary intercepts - debug_assert_eq!( - beta.len(), - n_cat - 1, - "GRM param layout: L slopes + (n_cat-1) thresholds" - ); - let mut ll = 0.0f64; - let mut grad = vec![0.0f64; params.len()]; - for (nd, cnt) in counts.iter().enumerate() { - let mut base = 0.0f64; - for (t, &d) in dims.iter().enumerate() { - base += params[t] * nodes[nd * n_dims + d]; - } - let lp = grm_logprobs(base, beta); - ll += cnt.iter().zip(&lp).map(|(r, l2)| r * l2).sum::(); - let (g_base, g_thr) = grm_node_gradient(base, beta, cnt); - for (t, &d) in dims.iter().enumerate() { - grad[t] += g_base * nodes[nd * n_dims + d]; - } - for (j, gj) in g_thr.iter().enumerate() { - grad[l + j] += gj; - } - } - (-ll, grad.iter().map(|v| -v).collect()) -} - -/// Newton M-step for one item — mirrors `poly::m_step_item` (FD Hessian, ridge conditioning, -/// backtracking line search), generalized to the multidimensional slope. The line search rejects any -/// step whose objective is non-finite, which keeps `beta` strictly ordered (see the module docs). -#[allow(clippy::too_many_arguments)] -fn grm_m_step( - mut params: Vec, - dims: &[usize], - nodes: &[f64], - n_dims: usize, - counts: &[Vec], - n_cat: usize, - ridge: f64, - n_newton: usize, -) -> Vec { - let np = params.len(); - for _ in 0..n_newton { - let (f0, g) = grm_item_neg_ll_grad(¶ms, dims, nodes, n_dims, counts, n_cat); - let grad_norm = g.iter().map(|v| v * v).sum::().sqrt(); - if !f0.is_finite() || !grad_norm.is_finite() || grad_norm < 1e-9 { - break; - } - let h = 1e-5; - let mut hess = vec![vec![0.0f64; np]; np]; - for j in 0..np { - let mut pj = params.clone(); - pj[j] += h; - let (_f2, gj) = grm_item_neg_ll_grad(&pj, dims, nodes, n_dims, counts, n_cat); - for r in 0..np { - hess[r][j] = (gj[r] - g[r]) / h; - } - } - for r in 0..np { - for c in 0..np { - hess[r][c] = 0.5 * (hess[r][c] + hess[c][r]); - } - hess[r][r] += ridge; - } - let mut step = solve_small(hess, g.clone()); - let mut directional = g.iter().zip(&step).map(|(gi, si)| gi * si).sum::(); - // A boundary-crossing FD-Hessian column can yield a non-finite step; fall back to the - // (finite) gradient direction — this also protects the ordered-threshold constraint. - if !step.iter().all(|s| s.is_finite()) || directional <= 0.0 { - step = g.clone(); - directional = grad_norm * grad_norm; - } - let mut max_step = step.iter().map(|s| s.abs()).fold(0.0f64, f64::max); - if max_step > 2.0 { - for s in &mut step { - *s *= 2.0 / max_step; - } - directional = g.iter().zip(&step).map(|(gi, si)| gi * si).sum(); - max_step = 2.0; - } - let mut alpha = 1.0f64; - let mut accepted = false; - for _ in 0..25 { - let candidate: Vec = params - .iter() - .zip(&step) - .map(|(value, direction)| value - alpha * direction) - .collect(); - let (candidate_f, _) = - grm_item_neg_ll_grad(&candidate, dims, nodes, n_dims, counts, n_cat); - if candidate_f.is_finite() && candidate_f <= f0 - 1e-4 * alpha * directional { - params = candidate; - accepted = true; - break; - } - alpha *= 0.5; - } - if !accepted || alpha * max_step < 1e-9 { - break; - } - } - params -} - -fn checked_em_loglik_change( - current: f64, - previous: Option, - iteration: usize, -) -> Result, String> { - if !current.is_finite() { - return Err(format!( - "non-finite observed-data log-likelihood at iteration {iteration}" - )); - } - let Some(previous) = previous else { - return Ok(None); - }; - let change = current - previous; - let monotonicity_tolerance = 32.0 * f64::EPSILON * (1.0 + previous.abs()); - if change < -monotonicity_tolerance { - return Err(format!( - "EM observed-data log-likelihood decreased at iteration {iteration}: delta={change:.6e}" - )); - } - Ok(Some(change)) -} - -/// Fit the confirmatory MULTIDIMENSIONAL graded response model (Samejima, 1969; Muraki & Carlson, -/// 1995) by Bock-Aitkin marginal MLE. See the module docs for the model, estimation, and -/// identification. `y`/`observed` are row-major `n_persons * n_items` (`y` ordered categories -/// `0..n_cat-1`, missing cells dropped MAR); `loading_pattern` is row-major `n_items * n_dims` in -/// `{0,1}`. Returns `Err` on malformed / rotationally-underidentified / unobserved-category input. -#[allow(clippy::too_many_arguments)] -pub fn fit_grm( - y: &[usize], - observed: Option<&[bool]>, - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - cfg: &GrmConfig, -) -> Result { - let _n_nodes = validate( - y, - observed, - loading_pattern, - n_persons, - n_items, - n_dims, - n_cat, - cfg, - )?; - - let xi_rule = match cfg.xi_rule { - XiRuleKind::GaussHermite => XiRule::GaussHermite { q_xi: cfg.q }, - XiRuleKind::Halton => XiRule::Halton { - n: cfg.xi_points, - shift_seed: cfg.xi_seed, - }, - XiRuleKind::MonteCarlo => XiRule::MonteCarlo { - n: cfg.xi_points, - seed: cfg.xi_seed.max(1), - }, - }; - let xn = build_xi_nodes(xi_rule, n_dims)?; - let (nodes, logw) = (xn.grid, xn.logw); - let qn = logw.len(); - let m1 = n_cat - 1; // boundary count - - let dims_of: Vec> = (0..n_items) - .map(|i| { - (0..n_dims) - .filter(|&d| loading_pattern[i * n_dims + d] != 0) - .collect() - }) - .collect(); - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - - // Init: slope = 1.0 on the item's FIRST loaded dim (0 elsewhere); beta_k = logit(P(Y>=k)) - // cumulative-from-top, ordered DECREASING — exactly fit_poly_unidim's GRM init (base=theta at D=1). - let mut params: Vec> = Vec::with_capacity(n_items); - for i in 0..n_items { - let l = dims_of[i].len(); - let mut p = vec![0.0f64; l + m1]; - p[0] = 1.0; // slope on the first loaded dim - let mut freq = vec![1e-3f64; n_cat]; - for pp in 0..n_persons { - if is_obs(pp, i) { - freq[y[pp * n_items + i]] += 1.0; - } - } - let tot: f64 = freq.iter().sum(); - for f in freq.iter_mut() { - *f /= tot; - } - let mut cum = 0.0f64; - for k in (1..n_cat).rev() { - cum += freq[k]; - let c = cum.clamp(1e-4, 1.0 - 1e-4); - p[l + (k - 1)] = (c / (1.0 - c)).ln(); - } - params.push(p); - } - - let mut loglik_trace: Vec = Vec::with_capacity(cfg.max_iter + 1); - let mut converged = false; - let mut n_iter = 0usize; - let mut termination_reason = "max_iter_reached".to_string(); - let mut final_loglik_change = f64::NAN; - let mut theta = vec![0.0f64; n_persons * n_dims]; - - let mut log_node = vec![0.0f64; qn]; - - // Compute per-item node x category log-probs into `all_lp[i]` (reused by E-step and EAP pass). - let fill_lp = |params: &[Vec]| -> Vec> { - let mut all_lp: Vec> = Vec::with_capacity(n_items); - for i in 0..n_items { - let l = dims_of[i].len(); - let beta = ¶ms[i][l..]; - let mut lp_i = vec![0.0f64; qn * n_cat]; - for nd in 0..qn { - let mut base = 0.0f64; - for (t, &d) in dims_of[i].iter().enumerate() { - base += params[i][t] * nodes[nd * n_dims + d]; - } - let lp = grm_logprobs(base, beta); - lp_i[nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - all_lp.push(lp_i); - } - all_lp - }; - - loop { - let all_lp = fill_lp(¶ms); - let mut counts = vec![vec![vec![0.0f64; n_cat]; qn]; n_items]; - let mut ll = 0.0f64; - for p in 0..n_persons { - log_node.copy_from_slice(&logw); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - let lp = &all_lp[i]; - for nd in 0..qn { - log_node[nd] += lp[nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in log_node.iter() { - denom += (v - mx).exp(); - } - ll += mx + denom.ln(); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - counts[i][nd][yc] += (log_node[nd] - mx).exp() / denom; - } - } - } - let previous = loglik_trace.last().copied(); - let change = checked_em_loglik_change(ll, previous, n_iter)?; - loglik_trace.push(ll); - - // Stopping: relative tolerance + SIGNED monotonic-decrease guard (not the .abs() check, - // which would accept a likelihood DECREASE as convergence). - if let Some(change) = change { - let prev = previous.expect("change requires a previous log-likelihood"); - final_loglik_change = change; - let stop_tol = cfg.tol * (1.0 + prev.abs()); - if final_loglik_change <= stop_tol { - converged = true; - termination_reason = "tolerance_met".to_string(); - break; - } - } - if n_iter == cfg.max_iter { - break; - } - - for i in 0..n_items { - params[i] = grm_m_step( - params[i].clone(), - &dims_of[i], - &nodes, - n_dims, - &counts[i], - n_cat, - cfg.ridge, - cfg.newton_iter, - ); - } - n_iter += 1; - } - - // Final EAP pass. - { - let all_lp = fill_lp(¶ms); - for p in 0..n_persons { - log_node.copy_from_slice(&logw); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - let lp = &all_lp[i]; - for nd in 0..qn { - log_node[nd] += lp[nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in log_node.iter() { - denom += (v - mx).exp(); - } - for nd in 0..qn { - let post = (log_node[nd] - mx).exp() / denom; - for d in 0..n_dims { - theta[p * n_dims + d] += post * nodes[nd * n_dims + d]; - } - } - } - } - - // Assemble dense slope (n_items * n_dims) + thresholds (n_items * (n_cat-1)). - let mut slope = vec![0.0f64; n_items * n_dims]; - let mut threshold = vec![0.0f64; n_items * m1]; - let mut n_parameters = 0usize; - for i in 0..n_items { - let l = dims_of[i].len(); - n_parameters += l + m1; - for (t, &d) in dims_of[i].iter().enumerate() { - slope[i * n_dims + d] = params[i][t]; - } - threshold[i * m1..(i + 1) * m1].copy_from_slice(¶ms[i][l..]); - } - - // Per-dimension reflection canonicalization: flip dimension d (its slopes on every item AND - // theta_d) so its largest-|slope| PURE anchor loads positively. `base` — hence every threshold — - // is invariant under the joint flip, so thresholds are NOT touched (module docs). - for d in 0..n_dims { - let mut anchor: Option = None; - let mut best = 0.0f64; - for i in 0..n_items { - let is_pure = dims_of[i].len() == 1 && dims_of[i][0] == d; - if is_pure && slope[i * n_dims + d].abs() > best { - best = slope[i * n_dims + d].abs(); - anchor = Some(i); - } - } - let ai = anchor.expect("validation guarantees a pure anchor for every dimension"); - if slope[ai * n_dims + d] < 0.0 { - for i in 0..n_items { - slope[i * n_dims + d] = -slope[i * n_dims + d]; - } - for p in 0..n_persons { - theta[p * n_dims + d] = -theta[p * n_dims + d]; - } - } - } - - let ll = *loglik_trace.last().expect("EM trace is never empty"); - let _ = ll; - Ok(GrmResult { - n_dims, - n_cat, - slope, - threshold, - theta, - loglik_trace, - n_iter, - converged, - termination_reason, - final_loglik_change, - n_parameters, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/grm_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/gtheory.rs b/crates/mlsirm-core/src/gtheory.rs deleted file mode 100644 index 04c95c6bc..000000000 --- a/crates/mlsirm-core/src/gtheory.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! Generalizability theory G-study / D-study for balanced complete crossed -//! designs: one-facet `p x i` and two-facet `p x i x o`. -//! -//! # Verified sources -//! -//! - **READ IN FULL**: Huebner, A., & Lucht, M. (2019). Generalizability -//! theory in R. *Practical Assessment, Research, and Evaluation*, 24, -//! Article 5. https://doi.org/10.7275/5065-gc10 — provides the model -//! equations (their eqs. 1-2), the D-study component scaling (their -//! Table 1), the relative/absolute error variances sigma^2(delta) and -//! sigma^2(Delta) (their Table 2), the coefficients E-rho^2 and Phi -//! (their eqs. 6-7 with sigma^2(tau) = sigma^2(p) for fully random -//! designs), and complete worked examples with data (their Appendix and -//! Tables 3-6) against which this implementation is tested. -//! - **NOT read**: Brennan (2001) and Shavelson & Webb (1991), the classic -//! texts to which Huebner & Lucht defer the ANOVA/EMS derivations. They -//! are cited below only "as cited in Huebner & Lucht (2019)". -//! -//! # Derivation status of the EMS inversions -//! -//! Huebner & Lucht (2019) do not print the mean-square-to-variance-component -//! formulas. The inversions used here were HAND-DERIVED from the standard -//! expected mean squares of the fully random balanced two-/three-way ANOVA -//! without replication, e.g. for `p x i`: -//! -//! ```text -//! E[MS_p] = sigma2_pi + n_i * sigma2_p -//! E[MS_i] = sigma2_pi + n_p * sigma2_i -//! E[MS_pi] = sigma2_pi -//! ``` -//! -//! and for `p x i x o`: -//! -//! ```text -//! E[MS_p] = s_pio + n_o*s_pi + n_i*s_po + n_i*n_o*s_p (and symmetric) -//! E[MS_pi] = s_pio + n_o*s_pi (and symmetric) -//! E[MS_pio] = s_pio -//! ``` -//! -//! These inversions were verified numerically against every published table -//! of Huebner & Lucht (2019): Table 3/4 (`p x i`, pi_dat) and Table 5/6 -//! (`p x i x o`, pio_cross_dat) reproduce to the papers' printed rounding; -//! see the tests. No claim is made that the derivation matches the unread -//! Brennan (2001) text beyond this numerical agreement. -//! -//! # Formulas (Huebner & Lucht, 2019) -//! -//! D study for proposed sizes `n_i'` (and `n_o'`): each G-study component is -//! divided by the product of the primed sizes of the facets it involves -//! (their Table 1). Then (their Table 2, eqs. 6-7): -//! -//! - `p x i`: `sigma2(delta) = s_pi/n_i'`, -//! `sigma2(Delta) = s_i/n_i' + s_pi/n_i'`. -//! - `p x i x o`: `sigma2(delta) = s_pi/n_i' + s_po/n_o' + s_pio/(n_i' n_o')`, -//! `sigma2(Delta) = sigma2(delta) + s_i/n_i' + s_o/n_o' + s_io/(n_i' n_o')`. -//! - `E-rho^2 = s_p / (s_p + sigma2(delta))`, -//! `Phi = s_p / (s_p + sigma2(Delta))`. -//! -//! # Negative-variance policy (implementation policy, NOT from the paper) -//! -//! ANOVA variance-component estimators can be negative. The paper's examples -//! avoid this by construction. This module reports BOTH the raw ANOVA -//! estimates and component-wise clamped-at-zero copies; all D-study -//! quantities are computed from the CLAMPED components. This "clamped-ANOVA" -//! policy is not a joint constrained estimator; for data with negative raw -//! components the D-study outputs are policy outputs, not reproductions of -//! any published pipeline. `E-rho^2`/`Phi` are `NaN` when their denominator -//! is <= 1e-12. -//! -//! # Scope -//! -//! Balanced complete data only (`NaN` input is an error). Nested designs -//! (`i:p`, `p x (i:o)`), unbalanced data, fixed/mixed facets, and standard -//! errors of the components are out of scope. -//! -//! # References -//! -//! Huebner, A., & Lucht, M. (2019). Generalizability theory in R. -//! *Practical Assessment, Research, and Evaluation*, 24, Article 5. -//! https://doi.org/10.7275/5065-gc10 -//! -//! Brennan, R. L. (2001). *Generalizability theory*. Springer. (As cited in -//! Huebner & Lucht, 2019; not read.) -//! -//! Shavelson, R. J., & Webb, N. M. (1991). *Generalizability theory: A -//! primer*. Sage. (As cited in Huebner & Lucht, 2019; not read.) - -/// One D-study column: proposed facet sizes with the resulting error -/// variances and coefficients (Huebner & Lucht, 2019, Tables 4 and 6). -#[derive(Debug, Clone)] -pub struct GTheoryDStudyRow { - /// Proposed number of items `n_i'`. - pub n_i_prime: usize, - /// Proposed number of occasions `n_o'` (1 for the one-facet design, - /// where it is unused). - pub n_o_prime: usize, - /// Relative error variance `sigma^2(delta)`. - pub rel_error_var: f64, - /// Absolute error variance `sigma^2(Delta)`. - pub abs_error_var: f64, - /// Generalizability coefficient `E-rho^2` (eq. 6). `NaN` when the - /// denominator is <= 1e-12. - pub generalizability: f64, - /// Index of dependability `Phi` (eq. 7). `NaN` when the denominator is - /// <= 1e-12. - pub dependability: f64, -} - -/// G-study + D-study output for the one-facet crossed `p x i` design. -#[derive(Debug, Clone)] -pub struct GTheoryPiResult { - /// Degrees of freedom for (p, i, pi): `n_p-1`, `n_i-1`, - /// `(n_p-1)(n_i-1)`. - pub df: [f64; 3], - /// Sums of squares for (p, i, pi). - pub ss: [f64; 3], - /// Mean squares for (p, i, pi). - pub ms: [f64; 3], - /// Raw ANOVA variance-component estimates for (p, i, pi); may be - /// negative. - pub var_raw: [f64; 3], - /// Component-wise `max(., 0)` of `var_raw`; used for all D-study - /// quantities (clamped-ANOVA policy, see module docs). - pub var: [f64; 3], - /// One row per requested `n_i'`. - pub d_study: Vec, -} - -/// G-study + D-study output for the two-facet crossed `p x i x o` design. -/// Component order everywhere: (p, i, o, pi, po, io, pio). -#[derive(Debug, Clone)] -pub struct GTheoryPioResult { - /// Degrees of freedom in component order. - pub df: [f64; 7], - /// Sums of squares in component order. - pub ss: [f64; 7], - /// Mean squares in component order. - pub ms: [f64; 7], - /// Raw ANOVA variance-component estimates; may be negative. - pub var_raw: [f64; 7], - /// Component-wise `max(., 0)` of `var_raw`; used for all D-study - /// quantities (clamped-ANOVA policy, see module docs). - pub var: [f64; 7], - /// One row per requested `(n_i', n_o')` pair. - pub d_study: Vec, -} - -const DENOM_EPS: f64 = 1e-12; - -fn coef(var_p: f64, err: f64) -> f64 { - let den = var_p + err; - if den <= DENOM_EPS { - f64::NAN - } else { - var_p / den - } -} - -fn validate(x: &[f64], expected_len: usize, what: &str) -> Result<(), String> { - if x.len() != expected_len { - return Err(format!( - "{what}: expected {expected_len} scores, got {}", - x.len() - )); - } - if x.iter().any(|v| !v.is_finite()) { - return Err(format!( - "{what}: non-finite score; complete balanced data required" - )); - } - Ok(()) -} - -/// Generalizability analysis for the one-facet crossed `p x i` design -/// (Huebner & Lucht, 2019, "One-facet p x i design" section). -/// -/// `x` is row-major `n_p x n_i` (person-major); `n_i_prime` lists the -/// proposed D-study item counts. -pub fn gtheory_pi( - x: &[f64], - n_p: usize, - n_i: usize, - n_i_prime: &[usize], -) -> Result { - if n_p < 2 || n_i < 2 { - return Err("gtheory_pi: need at least 2 persons and 2 items".to_string()); - } - validate(x, n_p * n_i, "gtheory_pi")?; - if n_i_prime.iter().any(|&n| n == 0) { - return Err("gtheory_pi: n_i_prime entries must be >= 1".to_string()); - } - - let (fp, fi) = (n_p as f64, n_i as f64); - let grand = x.iter().sum::() / (fp * fi); - let mut pm = vec![0.0; n_p]; - let mut im = vec![0.0; n_i]; - for p in 0..n_p { - for i in 0..n_i { - pm[p] += x[p * n_i + i]; - im[i] += x[p * n_i + i]; - } - } - for m in pm.iter_mut() { - *m /= fi; - } - for m in im.iter_mut() { - *m /= fp; - } - - let ss_p = fi * pm.iter().map(|m| (m - grand).powi(2)).sum::(); - let ss_i = fp * im.iter().map(|m| (m - grand).powi(2)).sum::(); - let mut ss_pi = 0.0; - for p in 0..n_p { - for i in 0..n_i { - ss_pi += (x[p * n_i + i] - pm[p] - im[i] + grand).powi(2); - } - } - - let df = [fp - 1.0, fi - 1.0, (fp - 1.0) * (fi - 1.0)]; - let ss = [ss_p, ss_i, ss_pi]; - let ms = [ss[0] / df[0], ss[1] / df[1], ss[2] / df[2]]; - // Hand-derived EMS inversion (see module docs); verified against - // Huebner & Lucht (2019) Table 3. - let var_raw = [(ms[0] - ms[2]) / fi, (ms[1] - ms[2]) / fp, ms[2]]; - let var = var_raw.map(|v| v.max(0.0)); - - let d_study = n_i_prime - .iter() - .map(|&n| { - let fni = n as f64; - // Table 1: components divided by n_i'; Table 2: delta = s(pI), - // Delta = s(I) + s(pI). - let rel = var[2] / fni; - let abs = var[1] / fni + var[2] / fni; - GTheoryDStudyRow { - n_i_prime: n, - n_o_prime: 1, - rel_error_var: rel, - abs_error_var: abs, - generalizability: coef(var[0], rel), - dependability: coef(var[0], abs), - } - }) - .collect(); - - Ok(GTheoryPiResult { - df, - ss, - ms, - var_raw, - var, - d_study, - }) -} - -/// Generalizability analysis for the two-facet crossed `p x i x o` design -/// (Huebner & Lucht, 2019, "Two-facet p x i x o design" section). -/// -/// `x` is row-major `x[p*n_i*n_o + i*n_o + o]` (person-major, then item, -/// then occasion). `n_prime` lists the proposed `(n_i', n_o')` pairs. -pub fn gtheory_pio( - x: &[f64], - n_p: usize, - n_i: usize, - n_o: usize, - n_prime: &[(usize, usize)], -) -> Result { - if n_p < 2 || n_i < 2 || n_o < 2 { - return Err("gtheory_pio: need at least 2 levels per facet".to_string()); - } - validate(x, n_p * n_i * n_o, "gtheory_pio")?; - if n_prime.iter().any(|&(a, b)| a == 0 || b == 0) { - return Err("gtheory_pio: n_prime entries must be >= 1".to_string()); - } - - let (fp, fi, fo) = (n_p as f64, n_i as f64, n_o as f64); - let total = fp * fi * fo; - let at = |p: usize, i: usize, o: usize| x[p * n_i * n_o + i * n_o + o]; - let grand = x.iter().sum::() / total; - - let mut mp = vec![0.0; n_p]; - let mut mi = vec![0.0; n_i]; - let mut mo = vec![0.0; n_o]; - let mut mpi = vec![0.0; n_p * n_i]; - let mut mpo = vec![0.0; n_p * n_o]; - let mut mio = vec![0.0; n_i * n_o]; - for p in 0..n_p { - for i in 0..n_i { - for o in 0..n_o { - let v = at(p, i, o); - mp[p] += v; - mi[i] += v; - mo[o] += v; - mpi[p * n_i + i] += v; - mpo[p * n_o + o] += v; - mio[i * n_o + o] += v; - } - } - } - for m in mp.iter_mut() { - *m /= fi * fo; - } - for m in mi.iter_mut() { - *m /= fp * fo; - } - for m in mo.iter_mut() { - *m /= fp * fi; - } - for m in mpi.iter_mut() { - *m /= fo; - } - for m in mpo.iter_mut() { - *m /= fi; - } - for m in mio.iter_mut() { - *m /= fp; - } - - let ss_p = fi * fo * mp.iter().map(|m| (m - grand).powi(2)).sum::(); - let ss_i = fp * fo * mi.iter().map(|m| (m - grand).powi(2)).sum::(); - let ss_o = fp * fi * mo.iter().map(|m| (m - grand).powi(2)).sum::(); - let mut ss_pi = 0.0; - for p in 0..n_p { - for i in 0..n_i { - ss_pi += (mpi[p * n_i + i] - mp[p] - mi[i] + grand).powi(2); - } - } - ss_pi *= fo; - let mut ss_po = 0.0; - for p in 0..n_p { - for o in 0..n_o { - ss_po += (mpo[p * n_o + o] - mp[p] - mo[o] + grand).powi(2); - } - } - ss_po *= fi; - let mut ss_io = 0.0; - for i in 0..n_i { - for o in 0..n_o { - ss_io += (mio[i * n_o + o] - mi[i] - mo[o] + grand).powi(2); - } - } - ss_io *= fp; - // Direct three-way residual sum (algebraically equal to - // SS_total - all other SS, but immune to the catastrophic cancellation - // the subtraction form shows for large-offset data). - let mut ss_pio = 0.0; - for p in 0..n_p { - for i in 0..n_i { - for o in 0..n_o { - ss_pio += (at(p, i, o) - mpi[p * n_i + i] - mpo[p * n_o + o] - mio[i * n_o + o] - + mp[p] - + mi[i] - + mo[o] - - grand) - .powi(2); - } - } - } - - let df = [ - fp - 1.0, - fi - 1.0, - fo - 1.0, - (fp - 1.0) * (fi - 1.0), - (fp - 1.0) * (fo - 1.0), - (fi - 1.0) * (fo - 1.0), - (fp - 1.0) * (fi - 1.0) * (fo - 1.0), - ]; - let ss = [ss_p, ss_i, ss_o, ss_pi, ss_po, ss_io, ss_pio]; - let mut ms = [0.0; 7]; - for k in 0..7 { - ms[k] = ss[k] / df[k]; - } - // Hand-derived EMS inversion (see module docs); verified against - // Huebner & Lucht (2019) Table 5. - let var_raw = [ - (ms[0] - ms[3] - ms[4] + ms[6]) / (fi * fo), - (ms[1] - ms[3] - ms[5] + ms[6]) / (fp * fo), - (ms[2] - ms[4] - ms[5] + ms[6]) / (fp * fi), - (ms[3] - ms[6]) / fo, - (ms[4] - ms[6]) / fi, - (ms[5] - ms[6]) / fp, - ms[6], - ]; - let var = var_raw.map(|v| v.max(0.0)); - - let d_study = n_prime - .iter() - .map(|&(ni, no)| { - let (fni, fno) = (ni as f64, no as f64); - // Table 2: delta = s(pI) + s(pO) + s(pIO); - // Delta = delta + s(I) + s(O) + s(IO). - let rel = var[3] / fni + var[4] / fno + var[6] / (fni * fno); - let abs = rel + var[1] / fni + var[2] / fno + var[5] / (fni * fno); - GTheoryDStudyRow { - n_i_prime: ni, - n_o_prime: no, - rel_error_var: rel, - abs_error_var: abs, - generalizability: coef(var[0], rel), - dependability: coef(var[0], abs), - } - }) - .collect(); - - Ok(GTheoryPioResult { - df, - ss, - ms, - var_raw, - var, - d_study, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/gtheory_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/ksirt.rs b/crates/mlsirm-core/src/ksirt.rs deleted file mode 100644 index 240436acc..000000000 --- a/crates/mlsirm-core/src/ksirt.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Kernel-smoothing nonparametric item response theory (Ramsay-style OCCs). -//! -//! Estimates option characteristic curves (OCCs) by Nadaraya-Watson kernel -//! regression of option indicators on rank-based ordinal ability surrogates, -//! the approach popularized by Ramsay (1991, as cited in Mazza et al., 2014) -//! and implemented in TestGraf and the R package KernSmoothIRT. -//! -//! # Verified sources (citation discipline) -//! -//! Every formula below was verified against sources actually read: -//! -//! - Mazza, Punzo, and McGuire (2014), Sections 2, 2.1, 2.2, 2.3 (full PDF -//! read): rank transform `r_i = rank(t_i)/(n+1)`, ordinal ability -//! `theta_i = F^{-1}(r_i)`, Nadaraya-Watson weights, Gaussian / -//! quadratic / uniform kernels, Silverman bandwidth (their Eq. 5), and -//! the expected item score `e_j(theta) = sum_l x_{jl} p_{jl}(theta)`. -//! - KernSmoothIRT 1.0.3 R/C++ source, read line by line -//! (github.com/cran/KernSmoothIRT): `R/ksIRT.R` (ties.method="first", -//! denominator `n+1`, grid endpoints `F^{-1}(1/(n+1))` to -//! `F^{-1}(n/(n+1))` with 51 default points, `h = 1.06 * sigma * n^{-1/5}` -//! with `sigma = 1` for the normal ability metric) and -//! `src/smoother3.cpp` (per-subject NW smoothing; zero-denominator -//! fallback returns all-zero weights). -//! -//! Ramsay (1991) itself was NOT obtainable and is cited only through Mazza -//! et al. (2014); no formula here is attributed to it directly. -//! -//! # Deliberate scope reductions and divergences -//! -//! - Pointwise standard errors are omitted: the R package's `stderr` -//! accumulates `p(1-p)` from a *partially summed* running estimate -//! (smoother3.cpp lines 148-150, order-dependent), and the JSS paper's -//! Eq. 6 uses a different per-subject form; neither yields a closed form -//! verifiable from the read sources, so v1 ships without SEs. -//! - Cross-validation bandwidth selection is omitted (the R implementation -//! subsamples 10% of subjects at random, making it nondeterministic). -//! - Option lists are reported in ascending score order, whereas R keeps -//! first-seen order; the estimated curves are unaffected. -//! - Responses must be complete and pre-scored (numeric option scores); -//! missing-data handling, answer keys, DIF groups, and non-normal ability -//! metrics are out of scope for v1. -//! -//! # References -//! -//! Mazza, A., Punzo, A., & McGuire, B. (2014). KernSmoothIRT: An R package -//! for kernel smoothing in item response theory. *Journal of Statistical -//! Software, 58*(6), 1-34. https://doi.org/10.18637/jss.v058.i06 -//! -//! Nadaraya, E. A. (1964). On estimating regression. *Theory of Probability -//! & Its Applications, 9*(1), 141-142. (As cited in Mazza et al., 2014.) -//! -//! Ramsay, J. O. (1991). Kernel smoothing approaches to nonparametric item -//! characteristic curve estimation. *Psychometrika, 56*(4), 611-630. -//! https://doi.org/10.1007/BF02294494 (As cited in Mazza et al., 2014.) -//! -//! Silverman, B. W. (1986). *Density estimation for statistics and data -//! analysis*. Chapman & Hall. (As cited in Mazza et al., 2014.) -//! -//! Watson, G. S. (1964). Smooth regression analysis. *Sankhya A, 26*(4), -//! 359-372. (As cited in Mazza et al., 2014.) - -use crate::mokken::normal_upper_quantile; - -/// Kernel function for the Nadaraya-Watson smoother. -/// -/// Formulas verified against Mazza et al. (2014, Section 2) and -/// smoother3.cpp: Gaussian `exp(-u^2/2)`, quadratic `(1-u^2)` on `[-1,1]`, -/// uniform indicator on `[-1,1]`. Multiplicative kernel constants cancel in -/// the NW normalization, so the unnormalized forms match the R package. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KsirtKernel { - Gaussian, - Quadratic, - Uniform, -} - -impl KsirtKernel { - fn eval(self, u: f64) -> f64 { - match self { - KsirtKernel::Gaussian => (-0.5 * u * u).exp(), - KsirtKernel::Quadratic => { - if u.abs() <= 1.0 { - 1.0 - u * u - } else { - 0.0 - } - } - KsirtKernel::Uniform => { - if u.abs() <= 1.0 { - 1.0 - } else { - 0.0 - } - } - } - } -} - -/// Per-item kernel-smoothing output. -#[derive(Debug, Clone)] -pub struct KsirtItem { - /// Distinct observed option scores, ascending. - pub options: Vec, - /// `options.len() x grid.len()` option characteristic curves; row `l` - /// gives `p_hat_{jl}(theta_s)` over the evaluation grid. Rows sum to 1 - /// at any grid point with a positive weight denominator and to 0 where - /// all kernel weights vanish (compact-support kernels far from data). - pub occ: Vec>, - /// Expected item score curve `sum_l x_{jl} * p_hat_{jl}(theta_s)`. - pub expected: Vec, -} - -/// Result of [`ksirt`]. -#[derive(Debug, Clone)] -pub struct KsirtResult { - /// Ordinal ability surrogates `Phi^{-1}(rank(t_i)/(n+1))`, subject order. - pub theta: Vec, - /// Evaluation grid (equally spaced, `Phi^{-1}(1/(n+1))` to - /// `Phi^{-1}(n/(n+1))`). - pub grid: Vec, - /// Per-item bandwidths actually used. - pub bandwidth: Vec, - /// Per-item OCC estimates. - pub items: Vec, - /// Expected total score curve (sum of per-item expected curves). - pub expected_total: Vec, -} - -/// Kernel smoothing of option characteristic curves. -/// -/// `x[i][j]` is the observed (pre-scored, finite) response of subject `i` -/// to item `j`; the distinct values of column `j` form the option set. -/// `bandwidth` overrides the Silverman default `1.06 * n^{-1/5}` (per-item -/// values, all > 0). See the module docs for the algorithm and sources. -pub fn ksirt( - x: &[R], - kernel: KsirtKernel, - nevalpoints: usize, - bandwidth: Option<&[f64]>, -) -> Result -where - R: AsRef<[f64]>, -{ - let n = x.len(); - if n < 2 { - return Err("ksirt requires at least 2 subjects".to_string()); - } - let k = x[0].as_ref().len(); - if k == 0 { - return Err("ksirt requires at least 1 item".to_string()); - } - for (i, row) in x.iter().enumerate() { - let row = row.as_ref(); - if row.len() != k { - return Err(format!( - "ragged response matrix: row {i} has {} items, expected {k}", - row.len() - )); - } - for (j, &v) in row.iter().enumerate() { - if !v.is_finite() { - return Err(format!("non-finite response at subject {i}, item {j}")); - } - } - } - if nevalpoints < 2 { - return Err("nevalpoints must be at least 2".to_string()); - } - let h: Vec = match bandwidth { - Some(b) => { - if b.len() != k { - return Err(format!( - "bandwidth length {} does not match {} items", - b.len(), - k - )); - } - if b.iter().any(|&v| !(v > 0.0) || !v.is_finite()) { - return Err("bandwidths must be finite and positive".to_string()); - } - b.to_vec() - } - None => { - // Silverman rule, sigma = 1 on the normal ability metric - // (Mazza et al., 2014, Eq. 5; ksIRT.R lines 171-179). - let hs = 1.06 * (n as f64).powf(-0.2); - vec![hs; k] - } - }; - - // Step 1: total scores -> ranks (ties by first occurrence, matching - // R's ties.method="first"; ksIRT.R line 121) -> normal quantiles. - let totals: Vec = x.iter().map(|row| row.as_ref().iter().sum()).collect(); - let mut order: Vec = (0..n).collect(); - // stable sort keeps original subject order within ties => "first" - order.sort_by(|&a, &b| totals[a].partial_cmp(&totals[b]).unwrap()); - let mut rank = vec![0usize; n]; - for (pos, &subj) in order.iter().enumerate() { - rank[subj] = pos + 1; - } - let np1 = (n + 1) as f64; - // Phi^{-1}(r) = normal_upper_quantile(1 - r): the helper returns z with - // P(N(0,1) > z) = p, so upper tail 1-r gives the lower quantile at r. - let theta: Vec = rank - .iter() - .map(|&r| normal_upper_quantile(1.0 - r as f64 / np1)) - .collect(); - - // Step 2: evaluation grid (ksIRT.R lines 134-141). - let lim1 = normal_upper_quantile(1.0 - 1.0 / np1); - let lim2 = normal_upper_quantile(1.0 - n as f64 / np1); - let q = nevalpoints; - let step = (lim2 - lim1) / (q - 1) as f64; - let grid: Vec = (0..q).map(|s| lim1 + step * s as f64).collect(); - - // Steps 3-4: per grid point, NW weights shared across the item's - // options (smoother3.cpp lines 76-154, incl. zero-denominator fallback). - let mut items = Vec::with_capacity(k); - let mut expected_total = vec![0.0; q]; - for j in 0..k { - let mut options: Vec = x.iter().map(|row| row.as_ref()[j]).collect(); - options.sort_by(|a, b| a.partial_cmp(b).unwrap()); - options.dedup(); - let option_index: Vec = x - .iter() - .enumerate() - .map(|(i, row)| { - let value = row.as_ref()[j]; - options - .binary_search_by(|option| { - option.partial_cmp(&value).expect( - "internal invariant violation: ksirt compares only finite \ - responses validated at function entry", - ) - }) - .unwrap_or_else(|_| { - panic!( - "option for subject {i} item {j} value {value} not found \ - in sorted/deduped options (internal invariant violation)" - ) - }) - }) - .collect(); - let m = options.len(); - let mut occ = vec![vec![0.0; q]; m]; - let mut expected = vec![0.0; q]; - for s in 0..q { - let kw: Vec = theta - .iter() - .map(|&t| kernel.eval((grid[s] - t) / h[j])) - .collect(); - let denom: f64 = kw.iter().sum(); - if denom <= 0.0 { - continue; // all weights zero: occ stays 0 (R fallback) - } - for (&l, &w) in option_index.iter().zip(&kw) { - occ[l][s] += w / denom; - } - for l in 0..m { - expected[s] += options[l] * occ[l][s]; - } - expected_total[s] += expected[s]; - } - items.push(KsirtItem { - options, - occ, - expected, - }); - } - - Ok(KsirtResult { - theta, - grid, - bandwidth: h, - items, - expected_total, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/ksirt_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/lib.rs b/crates/mlsirm-core/src/lib.rs index d6963c8b3..09c97c23e 100644 --- a/crates/mlsirm-core/src/lib.rs +++ b/crates/mlsirm-core/src/lib.rs @@ -1,76 +1,10 @@ -pub mod agreement; -pub mod cdm; -pub mod crm; -pub mod classification; -pub mod detect; -pub mod dif; -pub mod equating; -pub mod exposure; -pub mod facets; -pub mod factor; -pub mod fitstats; -pub mod gpcm; -pub mod grm; -pub mod gtheory; -pub mod ksirt; -pub mod linking; -pub mod lltm; -pub mod marginal; -pub mod mhrm; -pub mod mixed; -pub mod mixture; pub mod mmle; -pub mod mokken; -pub mod nodes; -pub mod nominal; -pub mod oakes; -pub mod parallel; -pub mod poly; -pub mod poly_marginal; -pub(crate) mod quadrature; -pub mod rasch_cml; -pub mod reliability; -pub mod rsm; -pub mod rt; -pub mod rt_joint; -pub mod scoring; -pub mod subscores; -pub mod testlet; -pub mod twopl; -pub mod utility; - -/// Checked size arithmetic shared by public-input validators. -/// -/// Keeping the overflow branch in one non-generic function avoids creating a -/// separate, partially covered closure for every validated buffer product. -pub(crate) fn checked_mul_usize(a: usize, b: usize, message: &str) -> Result { - match a.checked_mul(b) { - Some(value) => Ok(value), - None => Err(message.to_owned()), - } -} -/// Checked addition companion to [`checked_mul_usize`]. -pub(crate) fn checked_add_usize(a: usize, b: usize, message: &str) -> Result { - match a.checked_add(b) { - Some(value) => Ok(value), - None => Err(message.to_owned()), - } -} - -// cargo-llvm-cov runs in CPU-only CI against the repository-owned line-coverage -// baseline. Keep the hardware-backed wgpu module in normal builds, and cover -// the deterministic CPU fallback contract during coverage builds. +// cargo-llvm-cov runs in CPU-only CI while enforcing 100% line coverage. Keep +// the hardware-backed wgpu module in normal builds, and cover the deterministic +// CPU fallback contract during coverage builds. #[cfg(all(feature = "gpu", not(coverage)))] mod gpu; -#[cfg(all(feature = "gpu", not(coverage)))] -pub(crate) mod gpu_eapsum; -#[cfg(all(feature = "gpu", not(coverage)))] -pub(crate) mod gpu_marginal; -#[cfg(all(feature = "gpu", not(coverage)))] -pub(crate) mod gpu_plausible; -#[cfg(all(feature = "gpu", not(coverage)))] -pub(crate) mod gpu_scoring; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ModelType { Mirt, @@ -78,42 +12,6 @@ pub enum ModelType { Mlsrm, Uls2plm, Ulsrm, - /// Full-information dichotomous bifactor (Gibbons & Hedeker 1992; Cai, - /// Yang & Hansen 2011) as the inner-product interaction kind: - /// `eta = a_i theta_d(i) + b_i + dot(zeta_i, x)` with `x ~ MVN(0, I)` the - /// general factor(s); at `latent_dim = 1`, `zeta_i` is the general-factor - /// loading `lambda_i`. Marginal (MMLE) estimation only. - /// - /// # References - /// - /// Gibbons, R. D., & Hedeker, D. R. (1992). Full-information item bi-factor - /// analysis. *Psychometrika, 57*(3), 423–436. - /// https://doi.org/10.1007/BF02295430 - /// - /// Cai, L., Yang, J. S., & Hansen, M. (2011). Generalized full-information - /// item bifactor analysis. *Psychological Methods, 16*(3), 221–248. - /// https://doi.org/10.1037/a0023350 - Bifac2plm, -} - -/// The item-person interaction kind a model places on the latent-space axis. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum InteractionKind { - /// No interaction term (MIRT). - None, - /// Distance: `- exp(tau) * ||x - zeta_i||` (the LSIRM family). - Distance, - /// Inner product: `+ dot(zeta_i, x)` (bifactor / bilinear family). - Inner, -} - -/// Interaction kind of a model (shared by every eta evaluation site). -pub fn interaction_kind(model_type: ModelType) -> InteractionKind { - match model_type { - ModelType::Mirt => InteractionKind::None, - ModelType::Bifac2plm => InteractionKind::Inner, - _ => InteractionKind::Distance, - } } /// Execution device for the likelihood/gradient hot path. @@ -153,15 +51,6 @@ pub(crate) fn model_exec_flags(model_type: ModelType) -> (bool, bool) { (free_alpha, uses_space) } -/// Guard for numeric paths that only implement the distance kind (the JML -/// objective and its GPU kernels): `Bifac2plm` is marginal-only. -pub(crate) fn assert_distance_kind(model_type: ModelType) { - debug_assert!( - !matches!(model_type, ModelType::Bifac2plm), - "BIFAC2PLM is supported by the marginal estimator only" - ); -} - /// Compute the negative log-likelihood and gradients on the requested device. /// /// `Device::Cpu` runs the scalar reference implementation. `Device::Gpu` and @@ -266,27 +155,6 @@ impl Default for PenaltyConfig { } } -impl PenaltyConfig { - /// MAP penalties equal to the default LSIRM priors of Jeon et al. (2021) - /// and the `lsirm12pl` package: `beta_i ~ N(0, 4)`, `log alpha_i ~ - /// N(0.5, 1)`, `zeta_i ~ MVN(0, I)`, `log gamma ~ N(0.5, 1)`. Used by the - /// marginal (MMLE) estimator, where the person-side penalties are moot - /// (persons are integrated out) and the item-side priors prevent slope - /// collapse and latent-space blow-up on sparse items. - pub fn lsirm_prior() -> Self { - Self { - lambda_theta: 0.0, - lambda_xi: 0.0, - lambda_zeta: 1.0, - lambda_b: 0.25, - lambda_alpha: 1.0, - lambda_tau: 1.0, - mu_alpha: 0.5, - mu_tau: 0.5, - } - } -} - #[derive(Clone, Debug)] pub struct Params { pub theta: Vec, @@ -315,7 +183,6 @@ pub fn neg_loglik_and_grad( config: &ModelConfig, penalty: &PenaltyConfig, ) -> (f64, Gradients, f64) { - assert_distance_kind(config.model_type); assert_eq!(y.len(), config.n_persons * config.n_items); assert_eq!(factor_id.len(), config.n_items); if let Some(m) = mask { @@ -435,17 +302,282 @@ fn softplus(x: f64) -> f64 { } #[cfg(test)] -#[path = "../../../tests/unit/lib_tests.rs"] -mod tests; +mod tests { + use super::*; + + fn config() -> ModelConfig { + ModelConfig { + n_persons: 2, + n_items: 2, + n_dims: 1, + latent_dim: 2, + model_type: ModelType::Mls2plm, + eps_distance: 1e-8, + } + } -#[cfg(test)] -#[path = "../../../tests/unit/lib_additional_tests.rs"] -mod additional_tests; + fn params() -> Params { + Params { + theta: vec![0.2, -0.4], + alpha: vec![0.1, -0.2], + b: vec![0.3, -0.1], + xi: vec![0.1, 0.2, -0.2, 0.4], + zeta: vec![0.0, -0.1, 0.3, -0.4], + tau: 0.2, + } + } -#[cfg(test)] -#[path = "../../../tests/unit/marginal_recovery_tests.rs"] -mod marginal_recovery_tests; + #[test] + fn single_item_matches_manual_nll() { + let cfg = ModelConfig { + n_persons: 1, + n_items: 1, + n_dims: 1, + latent_dim: 1, + model_type: ModelType::Mls2plm, + eps_distance: 1e-8, + }; + let p = Params { + theta: vec![0.5], + alpha: vec![0.0], + b: vec![0.1], + xi: vec![0.2], + zeta: vec![-0.3], + tau: 0.0, + }; + let penalty = PenaltyConfig { + lambda_theta: 0.0, + lambda_xi: 0.0, + lambda_zeta: 0.0, + lambda_b: 0.0, + lambda_alpha: 0.0, + lambda_tau: 0.0, + mu_alpha: 0.0, + mu_tau: 0.0, + }; + let (got, _, _) = neg_loglik_and_grad(&[1.0], None, &[0], &p, &cfg, &penalty); + let r = ((0.2_f64 - -0.3_f64).powi(2) + 1e-8).sqrt(); + let eta = 0.5 + 0.1 - r; + let expected = softplus(eta) - eta; + assert!((got - expected).abs() < 1e-12); + } + + #[test] + fn gradient_matches_finite_difference_for_tau() { + let cfg = config(); + let p = params(); + let penalty = PenaltyConfig::default(); + let y = vec![1.0, 0.0, 0.0, 1.0]; + let (base, grad, _) = neg_loglik_and_grad(&y, None, &[0, 0], &p, &cfg, &penalty); + + let h = 1e-6; + let mut plus = p.clone(); + plus.tau += h; + let (obj_plus, _, _) = neg_loglik_and_grad(&y, None, &[0, 0], &plus, &cfg, &penalty); + let finite_diff = (obj_plus - base) / h; + assert!((finite_diff - grad.tau).abs() < 1e-5); + } + + #[test] + fn mask_excludes_entries() { + let cfg = config(); + let p = params(); + let penalty = PenaltyConfig::default(); + let y = vec![1.0, 0.0, 0.0, 1.0]; + let mask = vec![true, true, true, false]; + + let (objective, grad, loglik) = + neg_loglik_and_grad(&y, Some(&mask), &[0, 0], &p, &cfg, &penalty); + + assert!(objective.is_finite()); + assert!(loglik.is_finite()); + assert_eq!(grad.theta.len(), cfg.n_persons * cfg.n_dims); + } + + #[test] + fn device_parse_accepts_known_names() { + assert_eq!(Device::parse("cpu"), Some(Device::Cpu)); + assert_eq!(Device::parse("GPU"), Some(Device::Gpu)); + assert_eq!(Device::parse(" Auto "), Some(Device::Auto)); + assert_eq!(Device::parse("cuda"), None); + } + + #[test] + fn device_auto_matches_cpu_on_fallback() { + // On machines/CI without a GPU adapter, Auto/Gpu must fall back to the + // CPU path and reproduce it bit-for-bit. When a GPU is present the f32 + // kernels are exercised instead and this asserts close (not exact) + // agreement, which is the guarantee we make for the GPGPU path. + let cfg = config(); + let p = params(); + let penalty = PenaltyConfig::default(); + let y = vec![1.0, 0.0, 0.0, 1.0]; + let mask = vec![true, true, true, false]; + + let (cpu_obj, cpu_grad, cpu_ll) = + neg_loglik_and_grad_device(Device::Cpu, &y, Some(&mask), &[0, 0], &p, &cfg, &penalty); + for device in [Device::Auto, Device::Gpu] { + let (obj, grad, ll) = + neg_loglik_and_grad_device(device, &y, Some(&mask), &[0, 0], &p, &cfg, &penalty); + assert!( + (obj - cpu_obj).abs() < 1e-4, + "objective mismatch for {device:?}" + ); + assert!((ll - cpu_ll).abs() < 1e-4, "loglik mismatch for {device:?}"); + assert!((grad.tau - cpu_grad.tau).abs() < 1e-4); + for (a, b) in grad.theta.iter().zip(&cpu_grad.theta) { + assert!((a - b).abs() < 1e-4); + } + for (a, b) in grad.xi.iter().zip(&cpu_grad.xi) { + assert!((a - b).abs() < 1e-4); + } + for (a, b) in grad.zeta.iter().zip(&cpu_grad.zeta) { + assert!((a - b).abs() < 1e-4); + } + } + } + + #[test] + fn device_gpu_handles_absent_mask() { + // Exercises the `mask: None` host-side path (dense all-observed matrix) + // through the device entry point. On a GPU-equipped host this drives the + // GPGPU kernels with the `None` mask branch; on a GPU-less host it is the + // CPU fallback. Either way the device result must match the CPU reference + // computed the same way. + let cfg = config(); + let p = params(); + let penalty = PenaltyConfig::default(); + let y = vec![1.0, 0.0, 0.0, 1.0]; + + let (cpu_obj, cpu_grad, cpu_ll) = + neg_loglik_and_grad_device(Device::Cpu, &y, None, &[0, 0], &p, &cfg, &penalty); + for device in [Device::Auto, Device::Gpu] { + let (obj, grad, ll) = + neg_loglik_and_grad_device(device, &y, None, &[0, 0], &p, &cfg, &penalty); + assert!( + (obj - cpu_obj).abs() < 1e-4, + "objective mismatch for {device:?}" + ); + assert!((ll - cpu_ll).abs() < 1e-4, "loglik mismatch for {device:?}"); + assert!((grad.tau - cpu_grad.tau).abs() < 1e-4); + assert_eq!(grad.b.len(), cpu_grad.b.len()); + } + } + + #[cfg(all(feature = "gpu", not(coverage)))] + #[test] + fn finish_device_prefers_gpu_result_when_present() { + // When the GPU adapter produced a result, `finish_device` must return it + // verbatim without invoking the CPU fallback, for both Gpu and Auto. + let cfg = config(); + let p = params(); + let penalty = PenaltyConfig::default(); + let y = vec![1.0, 0.0, 0.0, 1.0]; + let sentinel = Gradients { + theta: vec![1.0, 2.0], + alpha: vec![3.0, 4.0], + b: vec![5.0, 6.0], + xi: vec![7.0, 8.0, 9.0, 10.0], + zeta: vec![11.0, 12.0, 13.0, 14.0], + tau: 42.0, + }; + for device in [Device::Gpu, Device::Auto] { + let (obj, grad, ll) = finish_device( + device, + Some((123.0, sentinel.clone(), -7.0)), + &y, + None, + &[0, 0], + &p, + &cfg, + &penalty, + ); + assert_eq!(obj, 123.0); + assert_eq!(ll, -7.0); + assert_eq!(grad.tau, 42.0); + assert_eq!(grad.b, vec![5.0, 6.0]); + } + } + + #[cfg(all(feature = "gpu", not(coverage)))] + #[test] + fn finish_device_falls_back_to_cpu_when_gpu_absent() { + // When the GPU produced no result, `finish_device` must reproduce the CPU + // reference. `Device::Gpu` additionally emits the fallback warning while + // `Device::Auto` stays silent; both must return the CPU numbers. + let cfg = config(); + let p = params(); + let penalty = PenaltyConfig::default(); + let y = vec![1.0, 0.0, 0.0, 1.0]; + let mask = vec![true, true, true, false]; + let expected = neg_loglik_and_grad(&y, Some(&mask), &[0, 0], &p, &cfg, &penalty); + for device in [Device::Gpu, Device::Auto] { + let (obj, grad, ll) = + finish_device(device, None, &y, Some(&mask), &[0, 0], &p, &cfg, &penalty); + assert_eq!(obj, expected.0); + assert_eq!(ll, expected.2); + assert_eq!(grad.tau, expected.1.tau); + } + } + + #[test] + fn mirt_ignores_latent_space_terms() { + let mut cfg = config(); + cfg.model_type = ModelType::Mirt; + let p = params(); + let penalty = PenaltyConfig::default(); + let y = vec![1.0, 0.0, 0.0, 1.0]; + + let (objective, grad, loglik) = neg_loglik_and_grad(&y, None, &[0, 0], &p, &cfg, &penalty); + + assert!(objective.is_finite()); + assert!(loglik.is_finite()); + assert_eq!(grad.tau, 0.0); + assert!(grad.xi.iter().all(|value| *value == 0.0)); + assert!(grad.zeta.iter().all(|value| *value == 0.0)); + } +} #[cfg(test)] -#[path = "../../../tests/unit/proptest_neg_loglik_tests.rs"] -mod proptest_neg_loglik_tests; +mod additional_tests { + use super::*; + + #[test] + fn test_mask_and_mirt() { + let params = Params { + theta: vec![0.0], + alpha: vec![0.0], + b: vec![0.0], + xi: vec![0.0], + zeta: vec![0.0], + tau: 0.0, + }; + let config = ModelConfig { + n_persons: 1, + n_items: 1, + n_dims: 1, + latent_dim: 1, + eps_distance: 1e-12, + model_type: ModelType::Mirt, + }; + let penalty = PenaltyConfig { + lambda_theta: 0.0, + lambda_b: 0.0, + lambda_alpha: 0.0, + lambda_xi: 0.0, + lambda_zeta: 0.0, + lambda_tau: 0.0, + mu_alpha: 0.0, + mu_tau: 0.0, + }; + let y = vec![1.0]; + let mask = vec![false]; + let (obj, _, _) = neg_loglik_and_grad(&y, Some(&mask), &[0], ¶ms, &config, &penalty); + assert_eq!(obj, 0.0); + + let mask_true = vec![true]; + let (obj_mirt, _, _) = + neg_loglik_and_grad(&y, Some(&mask_true), &[0], ¶ms, &config, &penalty); + assert!(obj_mirt > 0.0); + } +} diff --git a/crates/mlsirm-core/src/linking.rs b/crates/mlsirm-core/src/linking.rs deleted file mode 100644 index 00aa5d5d3..000000000 --- a/crates/mlsirm-core/src/linking.rs +++ /dev/null @@ -1,414 +0,0 @@ -//! IRT scale linking for separately-calibrated common-item designs (Kolen & -//! Brennan 2014, ch. 6): the moment methods (mean/mean, mean/sigma) and the -//! characteristic-curve methods of Haebara (1980) and Stocking & Lord (1983). -//! Motivated by the mixed-format/multi-study linking papers in the corpus -//! (Kim & Lee 2006; Yao & Boughton 2009; Brossman & Lee 2013) — this module -//! covers the unidimensional 2PL/1PL common-item case those procedures reduce -//! to for the serving scale. -//! -//! Convention: new-form abilities relate to the old (reference) scale by -//! `theta_old = A * theta_new + B`. Item parameters are carried in the engine's -//! `eta = a*theta + b` form (a = slope, b = intercept). Substituting -//! `theta_new = (theta_old - B) / A` transforms a new-form item onto the old -//! scale as -//! a* = a_new / A, b* = b_new - (a_new / A) * B -//! (equivalently the classical `a_O = a_N/A`, `b_O = A b_N + B` on the -//! slope/difficulty parameterization, with difficulty `-b/a`). -//! -//! # References (APA 7th ed.) -//! -//! Haebara, T. (1980). Equating logistic ability scales by a weighted least -//! squares method. *Japanese Psychological Research, 22*(3), 144–149. -//! https://doi.org/10.4992/psycholres1954.22.144 -//! -//! Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and -//! linking: Methods and practices* (3rd ed.). Springer. -//! https://doi.org/10.1007/978-1-4939-0317-7 -//! -//! Stocking, M. L., & Lord, F. M. (1983). Developing a common metric in item -//! response theory. *Applied Psychological Measurement, 7*(2), 201–210. -//! https://doi.org/10.1177/014662168300700208 - -const NM_MAX_ITER: usize = 500; -const NM_OBJECTIVE_RTOL: f64 = 1e-14; -const NM_PARAMETER_RTOL: f64 = 1e-10; - -/// Linking coefficients `theta_old = slope * theta_new + intercept`. -#[derive(Clone, Copy, Debug)] -pub struct LinkResult { - pub slope: f64, - pub intercept: f64, - /// Objective at the solution (0 for the moment methods, which are closed - /// form; the characteristic-curve loss for Haebara / Stocking-Lord). - pub criterion: f64, - pub n_iter: usize, - /// `true` for a closed-form moment solution or when both Nelder–Mead - /// simplex stopping criteria are met. - pub converged: bool, - /// `closed_form`, `tolerance_met`, or `max_iter_reached`. - pub termination_reason: &'static str, - pub max_iter: usize, - pub final_objective_span: f64, - pub objective_tolerance: f64, - pub final_parameter_span: f64, - pub parameter_tolerance: f64, -} - -/// Linking method. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LinkMethod { - MeanMean, - MeanSigma, - Haebara, - StockingLord, -} - -impl LinkMethod { - pub fn parse(name: &str) -> Option { - match name.to_ascii_lowercase().replace(['-', '_'], "").as_str() { - "meanmean" | "mm" => Some(LinkMethod::MeanMean), - "meansigma" | "ms" => Some(LinkMethod::MeanSigma), - "haebara" | "hb" => Some(LinkMethod::Haebara), - "stockinglord" | "sl" => Some(LinkMethod::StockingLord), - _ => None, - } - } -} - -#[inline] -fn p2pl(a: f64, b: f64, theta: f64) -> f64 { - 1.0 / (1.0 + (-(a * theta + b)).exp()) -} - -fn mean(x: &[f64]) -> f64 { - x.iter().sum::() / x.len() as f64 -} - -fn sd(x: &[f64]) -> f64 { - let m = mean(x); - (x.iter().map(|v| (v - m) * (v - m)).sum::() / x.len() as f64).sqrt() -} - -/// Closed-form moment coefficients. `difficulty_i = -b_i / a_i`. -fn moment( - a_old: &[f64], - b_old: &[f64], - a_new: &[f64], - b_new: &[f64], - sigma: bool, -) -> Result<(f64, f64), String> { - let d_old: Vec = a_old.iter().zip(b_old).map(|(&a, &b)| -b / a).collect(); - let d_new: Vec = a_new.iter().zip(b_new).map(|(&a, &b)| -b / a).collect(); - let slope = if sigma { - let (so, sn) = (sd(&d_old), sd(&d_new)); - if !(so > 0.0 && sn > 0.0) { - return Err( - "mean/sigma linking requires non-zero difficulty spread on both scales".into(), - ); - } - so / sn - } else { - // mean/mean uses the discriminations: a_O = a_N / A - let mo = mean(a_old); - mean(a_new) / mo - }; - let intercept = mean(&d_old) - slope * mean(&d_new); - if !(slope.is_finite() && slope > 0.0 && intercept.is_finite()) { - return Err("linking coefficients must be finite with a positive slope".into()); - } - Ok((slope, intercept)) -} - -/// Characteristic-curve objective at `(slope A, intercept B)`. -fn cc_objective( - slope: f64, - intercept: f64, - a_old: &[f64], - b_old: &[f64], - a_new: &[f64], - b_new: &[f64], - theta: &[f64], - weight: &[f64], - stocking_lord: bool, -) -> f64 { - if !(slope > 1e-6) || !slope.is_finite() || !intercept.is_finite() { - return 1e18; - } - let n_items = a_old.len(); - let mut total = 0.0; - for (q, &th) in theta.iter().enumerate() { - if stocking_lord { - let mut tcc_old = 0.0; - let mut tcc_new = 0.0; - for i in 0..n_items { - tcc_old += p2pl(a_old[i], b_old[i], th); - let a_star = a_new[i] / slope; - let b_star = b_new[i] - a_star * intercept; - tcc_new += p2pl(a_star, b_star, th); - } - let d = tcc_old - tcc_new; - total += weight[q] * d * d; - } else { - let mut acc = 0.0; - for i in 0..n_items { - let a_star = a_new[i] / slope; - let b_star = b_new[i] - a_star * intercept; - let d = p2pl(a_old[i], b_old[i], th) - p2pl(a_star, b_star, th); - acc += d * d; - } - total += weight[q] * acc; - } - } - total -} - -#[derive(Clone, Copy, Debug)] -struct NelderMeadResult { - x: [f64; 2], - objective: f64, - n_iter: usize, - converged: bool, - final_objective_span: f64, - objective_tolerance: f64, - final_parameter_span: f64, - parameter_tolerance: f64, -} - -fn link_termination_reason(converged: bool) -> &'static str { - if converged { - "tolerance_met" - } else { - "max_iter_reached" - } -} - -fn simplex_diagnostics( - simplex: &[[f64; 2]; 3], - fval: &[f64; 3], - best: usize, - worst: usize, -) -> (f64, f64, f64, f64) { - let objective_span = (fval[worst] - fval[best]).abs(); - let parameter_span = simplex - .iter() - .map(|vertex| { - (vertex[0] - simplex[best][0]) - .abs() - .max((vertex[1] - simplex[best][1]).abs()) - }) - .fold(0.0, f64::max); - let objective_tolerance = NM_OBJECTIVE_RTOL * (1.0 + fval[best].abs()); - let parameter_scale = simplex[best][0].abs().max(simplex[best][1].abs()); - let parameter_tolerance = NM_PARAMETER_RTOL * (1.0 + parameter_scale); - ( - objective_span, - objective_tolerance, - parameter_span, - parameter_tolerance, - ) -} - -/// Nelder–Mead minimization of a 2-parameter objective from `x0`. -fn nelder_mead(f: &dyn Fn(f64, f64) -> f64, x0: [f64; 2]) -> NelderMeadResult { - // simplex vertices - let mut simplex = [ - x0, - [x0[0] + 0.10 * (1.0 + x0[0].abs()), x0[1]], - [x0[0], x0[1] + 0.10 * (1.0 + x0[1].abs())], - ]; - let mut fval = [ - f(simplex[0][0], simplex[0][1]), - f(simplex[1][0], simplex[1][1]), - f(simplex[2][0], simplex[2][1]), - ]; - let (alpha, gamma, rho, sigma) = (1.0, 2.0, 0.5, 0.5); - let mut iters = 0; - let mut converged = false; - for it in 0..NM_MAX_ITER { - iters = it + 1; - // order vertices by value - let mut order = [0usize, 1, 2]; - order.sort_by(|&i, &j| fval[i].total_cmp(&fval[j])); - let (lo, mid, hi) = (order[0], order[1], order[2]); - let (objective_span, objective_tolerance, parameter_span, parameter_tolerance) = - simplex_diagnostics(&simplex, &fval, lo, hi); - if objective_span <= objective_tolerance && parameter_span <= parameter_tolerance { - converged = true; - break; - } - // centroid of the two best - let cen = [ - 0.5 * (simplex[lo][0] + simplex[mid][0]), - 0.5 * (simplex[lo][1] + simplex[mid][1]), - ]; - // reflection - let refl = [ - cen[0] + alpha * (cen[0] - simplex[hi][0]), - cen[1] + alpha * (cen[1] - simplex[hi][1]), - ]; - let f_refl = f(refl[0], refl[1]); - if f_refl < fval[lo] { - // expansion - let exp = [ - cen[0] + gamma * (refl[0] - cen[0]), - cen[1] + gamma * (refl[1] - cen[1]), - ]; - let f_exp = f(exp[0], exp[1]); - if f_exp < f_refl { - simplex[hi] = exp; - fval[hi] = f_exp; - } else { - simplex[hi] = refl; - fval[hi] = f_refl; - } - } else if f_refl < fval[mid] { - simplex[hi] = refl; - fval[hi] = f_refl; - } else { - // contraction - let con = [ - cen[0] + rho * (simplex[hi][0] - cen[0]), - cen[1] + rho * (simplex[hi][1] - cen[1]), - ]; - let f_con = f(con[0], con[1]); - if f_con < fval[hi] { - simplex[hi] = con; - fval[hi] = f_con; - } else { - // shrink toward the best - for &v in &[mid, hi] { - simplex[v] = [ - simplex[lo][0] + sigma * (simplex[v][0] - simplex[lo][0]), - simplex[lo][1] + sigma * (simplex[v][1] - simplex[lo][1]), - ]; - fval[v] = f(simplex[v][0], simplex[v][1]); - } - } - } - } - let mut order = [0usize, 1, 2]; - order.sort_by(|&i, &j| fval[i].total_cmp(&fval[j])); - let (best, worst) = (order[0], order[2]); - let (final_objective_span, objective_tolerance, final_parameter_span, parameter_tolerance) = - simplex_diagnostics(&simplex, &fval, best, worst); - NelderMeadResult { - x: simplex[best], - objective: fval[best], - n_iter: iters, - converged, - final_objective_span, - objective_tolerance, - final_parameter_span, - parameter_tolerance, - } -} - -/// Link a separately-calibrated new form onto the old (reference) scale using -/// common items. `theta`/`weight` are the quadrature the characteristic-curve -/// methods integrate over (ignored by the moment methods). -#[allow(clippy::too_many_arguments)] -pub fn irt_link( - a_old: &[f64], - b_old: &[f64], - a_new: &[f64], - b_new: &[f64], - theta: &[f64], - weight: &[f64], - method: LinkMethod, -) -> Result { - let n = a_old.len(); - if n < 2 || b_old.len() != n || a_new.len() != n || b_new.len() != n { - return Err("need >= 2 common items and matching-length parameter slices".into()); - } - if a_old - .iter() - .chain(a_new) - .any(|&a| !a.is_finite() || a <= 0.0) - { - return Err("slopes must be positive and finite".into()); - } - if b_old.iter().chain(b_new).any(|b| !b.is_finite()) { - return Err("intercepts must be finite".into()); - } - match method { - LinkMethod::MeanMean | LinkMethod::MeanSigma => { - let (slope, intercept) = - moment(a_old, b_old, a_new, b_new, method == LinkMethod::MeanSigma)?; - Ok(LinkResult { - slope, - intercept, - criterion: 0.0, - n_iter: 0, - converged: true, - termination_reason: "closed_form", - max_iter: 0, - final_objective_span: 0.0, - objective_tolerance: 0.0, - final_parameter_span: 0.0, - parameter_tolerance: 0.0, - }) - } - LinkMethod::Haebara | LinkMethod::StockingLord => { - if theta.len() != weight.len() || theta.is_empty() { - return Err("theta and weight must be non-empty and equal length".into()); - } - if theta.iter().any(|value| !value.is_finite()) { - return Err("theta nodes must be finite".into()); - } - if weight - .iter() - .any(|value| !value.is_finite() || *value < 0.0) - { - return Err("quadrature weights must be finite and non-negative".into()); - } - let weight_sum: f64 = weight.iter().sum(); - if !(weight_sum.is_finite() && weight_sum > 0.0) { - return Err("quadrature weights must have a finite positive sum".into()); - } - let normalized_weight: Vec = - weight.iter().map(|value| value / weight_sum).collect(); - let sl = method == LinkMethod::StockingLord; - // Prefer the mean/sigma start, but mean/mean remains identifiable - // when one difficulty distribution has zero spread. - let (a0, b0) = moment(a_old, b_old, a_new, b_new, true) - .or_else(|_| moment(a_old, b_old, a_new, b_new, false))?; - let optimization = nelder_mead( - &|slope, intercept| { - cc_objective( - slope, - intercept, - a_old, - b_old, - a_new, - b_new, - theta, - &normalized_weight, - sl, - ) - }, - [a0, b0], - ); - Ok(LinkResult { - slope: optimization.x[0], - intercept: optimization.x[1], - criterion: optimization.objective, - n_iter: optimization.n_iter, - converged: optimization.converged, - termination_reason: link_termination_reason(optimization.converged), - max_iter: NM_MAX_ITER, - final_objective_span: optimization.final_objective_span, - objective_tolerance: optimization.objective_tolerance, - final_parameter_span: optimization.final_parameter_span, - parameter_tolerance: optimization.parameter_tolerance, - }) - } - } -} - -#[cfg(test)] -#[path = "../../../tests/unit/linking_tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "../../../tests/unit/linking_branch_tests.rs"] -mod branch_tests; diff --git a/crates/mlsirm-core/src/lltm.rs b/crates/mlsirm-core/src/lltm.rs deleted file mode 100644 index 940e9b8fd..000000000 --- a/crates/mlsirm-core/src/lltm.rs +++ /dev/null @@ -1,590 +0,0 @@ -//! Linear Logistic Test Model (LLTM; Fischer, 1973): an *explanatory* Rasch model in -//! which the package's `J` item easinesses (the negatives of Fischer difficulties) are -//! not free but a fixed linear image of `K` basic (cognitive-operation) parameters -//! through a known weight matrix `Q` (`J x K`, -//! `q_ik` = how many times operation `k` is engaged by item `i`): -//! -//! ```text -//! b_i = c + sum_k q_ik * eta_k, P(x_ij = 1 | theta_j) = sigmoid(theta_j + b_i) -//! ``` -//! -//! with an optional free normalization constant `c` (grand-mean easiness). With -//! `K << J` parameters, LLTM tests whether a small set of cognitive operations -//! *explains* the item difficulties; the likelihood-ratio test against the saturated -//! Rasch model (2·(ll_Rasch − ll_LLTM) ~ χ²(J − K − intercept)) is its classic use. -//! -//! Sign convention: this crate is EASINESS-additive (`eta = a·theta + b` with `a ≡ 1`, -//! consistent with `mmle`/`mixture`), so `eta_k`/`b_i` are easinesses and are directly -//! comparable across the crate. Fischer's classic difficulty form -//! `P = sigmoid(theta − beta)` is a one-line reporting transform: operation difficulty -//! `delta_k = -eta_k`, item difficulty `beta_i = -b_i`. -//! -//! Identification. Two indeterminacies, both resolved: (1) location — `theta ~ N(0,1)` -//! pins the metric directly (no eta-normalization needed), so `c` is estimable as the -//! grand-mean easiness; (2) linear-form injectivity — `eta` is identified iff the map -//! `eta -> Q eta` is injective, i.e. `Q` (and the augmented `[1 | Q]` when the intercept -//! is on) has full COLUMN rank. `validate` rejects a rank-deficient design (e.g. a Q -//! whose rows sum to a constant, which collides with the intercept) rather than letting -//! the Newton ridge paper over a non-identified model. -//! -//! Provenance: Fischer's (1973, 1995) canonical LLTM uses conditional maximum -//! likelihood. This crate instead uses a fixed `N(0,1)` ability distribution and the -//! marginal-ML EM strategy of Bock and Aitkin (1981). That is a repository-specific -//! estimator choice: no finite-sample equivalence to Fischer's conditional-ML item -//! estimates is asserted. Within this marginal engine, `Q = I` reduces exactly to the -//! crate's Rasch implementation. -//! -//! Deferred (non-goals): conditional-ML estimation, LLTM for 2PL/polytomous models, -//! LLRA / random-weights extensions, person-side covariate design, analytic SE(eta) -//! (the `K x K` `-H_eta` at the fixed point is the observed-information block). -//! -//! References (APA 7th ed.): -//! - Fischer, G. H. (1973). The linear logistic test model as an instrument in -//! educational research. *Acta Psychologica, 37*(6), 359–374. -//! -//! - Fischer, G. H. (1995). The linear logistic test model. In G. H. Fischer & I. W. -//! Molenaar (Eds.), *Rasch models: Foundations, recent developments, and -//! applications* (pp. 131–155). Springer. -//! - Bock, R. D., & Aitkin, M. (1981). Marginal maximum likelihood estimation of item -//! parameters: Application of an EM algorithm. *Psychometrika, 46*(4), 443–459. -//! - -use crate::fitstats::chi2_sf; -use crate::mmle::{log_sigmoid, sigmoid_stable, GH_NODES, GH_WEIGHTS}; - -/// Solve `H x = g` for small dense `H` by Gauss-Jordan with partial pivoting; returns -/// `None` on a singular system. Same arithmetic as `poly::solve_small` (so a diagonal -/// `H` — the `Q = I` case — yields `g[i]/h[i][i]` bit-exactly), but the Newton M-step -/// breaks on `None` rather than taking `poly::solve_small`'s gradient-direction -/// fallback, which would be downhill for this maximization (matches `mmle`/`mixture`). -pub(crate) fn solve_small_checked(mut h: Vec>, mut g: Vec) -> Option> { - let n = g.len(); - for col in 0..n { - let mut piv = col; - for r in col + 1..n { - if h[r][col].abs() > h[piv][col].abs() { - piv = r; - } - } - if h[piv][col].abs() < 1e-12 { - return None; - } - h.swap(col, piv); - g.swap(col, piv); - for r in 0..n { - if r == col { - continue; - } - let f = h[r][col] / h[col][col]; - for c in col..n { - h[r][c] -= f * h[col][c]; - } - g[r] -= f * g[col]; - } - } - Some((0..n).map(|i| g[i] / h[i][i]).collect()) -} - -/// EM configuration for the LLTM estimator. -#[derive(Clone, Copy, Debug)] -pub struct LltmConfig { - pub max_iter: usize, - /// Convergence tolerance on `|delta loglik|`; `0.0` is permitted (runs the full - /// `max_iter`) — required for the exact `Q = I` reduction anchor. - pub tol: f64, - /// Ridge on the basic parameters (MAP `N(0, 1/ridge)`); equals the `mmle`/`mixture` - /// `ridge_b` at `Q = I`. - pub ridge: f64, - /// Inner Newton steps per M-step. - pub newton_iter: usize, - /// Fit a free grand-mean easiness intercept `c` (prepended ones-column of the design). - pub fit_intercept: bool, - /// Also run the `Q = I` Rasch fit and report the LR test of LLTM vs Rasch. - pub compute_lr: bool, -} - -impl Default for LltmConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - ridge: 1e-3, - newton_iter: 25, - fit_intercept: true, - compute_lr: true, - } - } -} - -/// Fitted LLTM. `eta` are the basic-operation easinesses (Fischer difficulty = `-eta`); -/// `b` the induced item easinesses `c + Q eta`. -#[derive(Clone, Debug)] -pub struct LltmResult { - pub eta: Vec, - /// Grand-mean easiness `c` (`NaN` when `fit_intercept == false`). - pub intercept: f64, - pub b: Vec, - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// `K + fit_intercept`. - pub n_parameters: usize, - /// Marginal loglik of the `Q = I` Rasch fit (`NaN` if `!compute_lr`). - pub loglik_rasch: f64, - /// `2·(ll_Rasch − ll_LLTM)`, floored at 0 (`NaN` if `!compute_lr`). - pub lr_stat: f64, - /// `J − K − fit_intercept`. - pub lr_df: usize, - /// LR p-value `chi2_sf(lr_stat, lr_df)` (`NaN` if `!compute_lr` or `lr_df == 0`). - pub lr_p: f64, -} - -/// Build the effective design `D` (`J x M`): `[1 | Q]` when `fit_intercept`, else `Q`. -fn build_design( - q_design: &[f64], - n_items: usize, - n_basic: usize, - fit_intercept: bool, -) -> (Vec, usize) { - let intc = fit_intercept as usize; - let m = n_basic + intc; - let mut d = vec![0.0f64; n_items * m]; - for i in 0..n_items { - if fit_intercept { - d[i * m] = 1.0; - } - for k in 0..n_basic { - d[i * m + intc + k] = q_design[i * n_basic + k]; - } - } - (d, m) -} - -/// Pivoted Gaussian elimination on the `M x M` Gram: true iff every pivot exceeds -/// `thresh` (i.e. the design has full column rank). -pub(crate) fn gram_full_rank(g: &mut [Vec], m: usize, thresh: f64) -> bool { - for col in 0..m { - let mut piv = col; - for r in col + 1..m { - if g[r][col].abs() > g[piv][col].abs() { - piv = r; - } - } - if g[piv][col].abs() < thresh { - return false; - } - g.swap(col, piv); - for r in col + 1..m { - let f = g[r][col] / g[col][col]; - for c in col..m { - g[r][c] -= f * g[col][c]; - } - } - } - true -} - -fn validate( - y: &[f64], - observed: &[bool], - q_design: &[f64], - n_persons: usize, - n_items: usize, - n_basic: usize, - cfg: &LltmConfig, -) -> Result<(), String> { - if n_persons < 1 || n_items < 1 || n_basic < 1 { - return Err("n_persons, n_items and n_basic must be >= 1".into()); - } - if cfg.max_iter == 0 { - return Err("max_iter must be positive".into()); - } - if cfg.newton_iter == 0 { - return Err("newton_iter must be positive".into()); - } - // tol == 0.0 is allowed (runs the full max_iter; needed for the Q=I anchor). - if !cfg.tol.is_finite() || cfg.tol < 0.0 { - return Err("tol must be finite and non-negative".into()); - } - if !cfg.ridge.is_finite() || cfg.ridge < 0.0 { - return Err("ridge must be finite and non-negative".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells || observed.len() != n_cells { - return Err("y and observed must have length n_persons * n_items".into()); - } - let n_q = crate::checked_mul_usize(n_items, n_basic, "n_items * n_basic overflows usize")?; - if q_design.len() != n_q { - return Err("q_design must have length n_items * n_basic".into()); - } - for (idx, &v) in y.iter().enumerate() { - if observed[idx] && v != 0.0 && v != 1.0 { - return Err(format!("y[{idx}] must be 0 or 1 where observed; got {v}")); - } - } - for &v in q_design.iter() { - if !v.is_finite() { - return Err("q_design entries must be finite".into()); - } - } - for i in 0..n_items { - if !(0..n_persons).any(|p| observed[p * n_items + i]) { - return Err(format!("item {i} has no observed responses")); - } - } - // Full-column-rank check on the effective design (identification is a design - // property, checked here — not papered over by the Newton ridge). - let (d, m) = build_design(q_design, n_items, n_basic, cfg.fit_intercept); - if m > n_items { - return Err(format!( - "design has more columns ({m}) than items ({n_items}); eta not identified" - )); - } - for a in 0..m { - if (0..n_items).all(|i| d[i * m + a] == 0.0) { - return Err(format!("design column {a} is all-zero")); - } - } - let mut gram = vec![vec![0.0f64; m]; m]; - let mut maxg = 0.0f64; - for a in 0..m { - for cc in 0..m { - let mut s = 0.0; - for i in 0..n_items { - s += d[i * m + a] * d[i * m + cc]; - } - gram[a][cc] = s; - maxg = maxg.max(s.abs()); - } - } - if !gram_full_rank(&mut gram, m, 1e-9 * maxg.max(1e-300)) { - return Err( - "design matrix (with intercept) is column-rank-deficient; eta is not identified".into(), - ); - } - Ok(()) -} - -/// Rasch easiness init: `b_i = logit(clamp(item proportion, 0.02, 0.98))` (identical to -/// `mmle`/`mixture`), load-bearing for the exact `Q = I` reduction. -fn init_b(y: &[f64], observed: &[bool], n_persons: usize, n_items: usize) -> Vec { - let mut b = vec![0.0f64; n_items]; - for i in 0..n_items { - let (mut num, mut den) = (0.0, 0.0); - for p in 0..n_persons { - let idx = p * n_items + i; - if observed[idx] { - num += y[idx]; - den += 1.0; - } - } - let prop = if den > 0.0 { - (num / den).clamp(0.02, 0.98) - } else { - 0.5 - }; - b[i] = (prop / (1.0 - prop)).ln(); - } - b -} - -/// Induced item easinesses `b = D · params`. -fn induced_b(design: &[f64], m: usize, n_items: usize, params: &[f64]) -> Vec { - (0..n_items) - .map(|i| (0..m).map(|a| design[i * m + a] * params[a]).sum()) - .collect() -} - -/// Least-squares projection of `b_init` onto the design column space: solve -/// `(DᵀD) params = Dᵀ b_init`. At `Q = I` the Gram is the identity so `params = b_init`. -fn ls_project(design: &[f64], m: usize, n_items: usize, b_init: &[f64]) -> Vec { - let mut gram = vec![vec![0.0f64; m]; m]; - let mut rhs = vec![0.0f64; m]; - for a in 0..m { - for i in 0..n_items { - rhs[a] += design[i * m + a] * b_init[i]; - } - for cc in 0..=a { - let mut s = 0.0; - for i in 0..n_items { - s += design[i * m + a] * design[i * m + cc]; - } - gram[a][cc] = s; - gram[cc][a] = s; - } - } - // The Gram is non-singular under a validated full-rank design; fall back to the - // Rasch init if it ever is not. - match solve_small_checked(gram, rhs) { - Some(projected) => projected, - None => b_init.to_vec(), - } -} - -/// Log response tables for all (node, item): `log_p1 = log σ(θ_q + b_i)`. -fn build_log_tables(b: &[f64], n_items: usize, log_p1: &mut [f64], log_p0: &mut [f64]) { - for (qi, &node) in GH_NODES.iter().enumerate() { - for i in 0..n_items { - let eta = node + b[i]; - log_p1[qi * n_items + i] = log_sigmoid(eta); - log_p0[qi * n_items + i] = log_sigmoid(-eta); - } - } -} - -/// Fill `post[0..Q]` with person `p`'s node posterior; returns `ln P(x_p)`. -#[allow(clippy::too_many_arguments)] -fn person_posterior( - p: usize, - y: &[f64], - observed: &[bool], - n_items: usize, - q: usize, - log_w: &[f64], - log_p1: &[f64], - log_p0: &[f64], - post: &mut [f64], -) -> f64 { - for (qi, slot) in post.iter_mut().enumerate().take(q) { - let mut acc = log_w[qi]; - for i in 0..n_items { - let idx = p * n_items + i; - if observed[idx] { - let yy = y[idx]; - acc += yy * log_p1[qi * n_items + i] + (1.0 - yy) * log_p0[qi * n_items + i]; - } - } - *slot = acc; - } - let mx = post[..q].iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0; - for &v in post[..q].iter() { - denom += (v - mx).exp(); - } - for v in post[..q].iter_mut() { - *v = (*v - mx).exp() / denom; - } - mx + denom.ln() -} - -/// One M-step: `newton_iter` chain-rule Newton steps on `params` from the fixed -/// expected counts. `g = Dᵀ g_b`, `H = Dᵀ diag(h_b) D + ridge`, `params -= H⁻¹ g`. At -/// `Q = I` `H` is diagonal and this is exactly the per-item Rasch Newton. -#[allow(clippy::too_many_arguments)] -fn newton_mstep( - design: &[f64], - m: usize, - n_items: usize, - q: usize, - n_iq: &[f64], - r_iq: &[f64], - mut params: Vec, - ridge: f64, - newton_iter: usize, -) -> Vec { - for _ in 0..newton_iter { - let b = induced_b(design, m, n_items, ¶ms); - let mut g_b = vec![0.0f64; n_items]; - let mut h_b = vec![0.0f64; n_items]; - for i in 0..n_items { - for qi in 0..q { - let p = sigmoid_stable(GH_NODES[qi] + b[i]); - let n = n_iq[i * q + qi]; - let w = n * p * (1.0 - p); - g_b[i] += r_iq[i * q + qi] - n * p; - h_b[i] -= w; - } - } - let mut g = vec![0.0f64; m]; - for a in 0..m { - for i in 0..n_items { - g[a] += design[i * m + a] * g_b[i]; - } - } - let mut h = vec![vec![0.0f64; m]; m]; - for a in 0..m { - for cc in 0..=a { - let mut s = 0.0; - for i in 0..n_items { - s += design[i * m + a] * design[i * m + cc] * h_b[i]; - } - h[a][cc] = s; - h[cc][a] = s; - } - } - for a in 0..m { - g[a] -= ridge * params[a]; - h[a][a] -= ridge; - } - // Break on a singular Hessian (matches mmle/mixture): a gradient-direction - // fallback step would be downhill for this maximization. - let delta = match solve_small_checked(h, g) { - Some(d) => d, - None => break, - }; - let mut maxd = 0.0f64; - for a in 0..m { - params[a] -= delta[a]; - maxd = maxd.max(delta[a].abs()); - } - if maxd < 1e-8 { - break; - } - } - params -} - -/// Marginal-EM Rasch fit over a fixed design `D` (`J x M`, row-major). Returns -/// `(params, b, theta, loglik_trace, n_iter, converged)`. Mirrors `mixture::run_em`: -/// convergence is checked before the M-step; a final-E-step loglik is pushed on a -/// max-iter exit so the returned params match the trace endpoint. -fn run_em_lltm( - y: &[f64], - observed: &[bool], - design: &[f64], - n_persons: usize, - n_items: usize, - m: usize, - cfg: &LltmConfig, -) -> (Vec, Vec, Vec, Vec, usize, bool) { - let q = GH_NODES.len(); - let log_w: Vec = GH_WEIGHTS.iter().map(|w| w.ln()).collect(); - let b_init = init_b(y, observed, n_persons, n_items); - let mut params = ls_project(design, m, n_items, &b_init); - - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - let mut post = vec![0.0f64; q]; - let mut log_p1 = vec![0.0f64; q * n_items]; - let mut log_p0 = vec![0.0f64; q * n_items]; - - for _ in 0..cfg.max_iter { - let b = induced_b(design, m, n_items, ¶ms); - build_log_tables(&b, n_items, &mut log_p1, &mut log_p0); - let mut n_iq = vec![0.0f64; n_items * q]; - let mut r_iq = vec![0.0f64; n_items * q]; - let mut total_ll = 0.0; - for p in 0..n_persons { - total_ll += person_posterior( - p, y, observed, n_items, q, &log_w, &log_p1, &log_p0, &mut post, - ); - for i in 0..n_items { - let idx = p * n_items + i; - if observed[idx] { - let yy = y[idx]; - for qi in 0..q { - let pv = post[qi]; - n_iq[i * q + qi] += pv; - r_iq[i * q + qi] += yy * pv; - } - } - } - } - loglik_trace.push(total_ll); - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - if (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - params = newton_mstep( - design, - m, - n_items, - q, - &n_iq, - &r_iq, - params, - cfg.ridge, - cfg.newton_iter, - ); - n_iter += 1; - } - - // Final pass at the converged params: theta EAP + final loglik. - let b = induced_b(design, m, n_items, ¶ms); - build_log_tables(&b, n_items, &mut log_p1, &mut log_p0); - let mut theta = vec![0.0f64; n_persons]; - let mut final_ll = 0.0; - for p in 0..n_persons { - final_ll += person_posterior( - p, y, observed, n_items, q, &log_w, &log_p1, &log_p0, &mut post, - ); - theta[p] = (0..q).map(|qi| post[qi] * GH_NODES[qi]).sum(); - } - if !converged { - loglik_trace.push(final_ll); - } - (params, b, theta, loglik_trace, n_iter, converged) -} - -/// Fit the Linear Logistic Test Model (Fischer, 1973) by marginal EM. `y`/`observed` -/// are row-major `N*J` (`y` in {0,1}); `q_design` is row-major `J*K` (real weights). -/// Missing cells (`observed == false`) are dropped (MAR). When `cfg.compute_lr`, the -/// `Q = I` Rasch fit is run too and the LR test of LLTM vs Rasch is reported. -pub fn fit_lltm( - y: &[f64], - observed: &[bool], - q_design: &[f64], - n_persons: usize, - n_items: usize, - n_basic: usize, - cfg: &LltmConfig, -) -> Result { - validate(y, observed, q_design, n_persons, n_items, n_basic, cfg)?; - let intc = cfg.fit_intercept as usize; - let (design, m) = build_design(q_design, n_items, n_basic, cfg.fit_intercept); - - let (params, b, theta, loglik_trace, n_iter, converged) = - run_em_lltm(y, observed, &design, n_persons, n_items, m, cfg); - let ll_lltm = *loglik_trace.last().unwrap(); - let intercept = if cfg.fit_intercept { - params[0] - } else { - f64::NAN - }; - let eta = params[intc..].to_vec(); - - let lr_df = n_items - n_basic - intc; - let (loglik_rasch, lr_stat, lr_p) = if cfg.compute_lr { - // Rasch reference = LLTM(Q = I_J, no intercept) through the same engine. - let mut id = vec![0.0f64; n_items * n_items]; - for i in 0..n_items { - id[i * n_items + i] = 1.0; - } - let rcfg = LltmConfig { - fit_intercept: false, - compute_lr: false, - ..*cfg - }; - let (.., rtrace, _, _) = run_em_lltm(y, observed, &id, n_persons, n_items, n_items, &rcfg); - let ll_r = *rtrace.last().unwrap(); - let stat = (2.0 * (ll_r - ll_lltm)).max(0.0); - (ll_r, stat, chi2_sf(stat, lr_df as f64)) - } else { - (f64::NAN, f64::NAN, f64::NAN) - }; - - Ok(LltmResult { - eta, - intercept, - b, - theta, - loglik_trace, - n_iter, - converged, - n_parameters: n_basic + intc, - loglik_rasch, - lr_stat, - lr_df, - lr_p, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/lltm_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/marginal.rs b/crates/mlsirm-core/src/marginal.rs deleted file mode 100644 index b19f854dc..000000000 --- a/crates/mlsirm-core/src/marginal.rs +++ /dev/null @@ -1,2390 +0,0 @@ -//! Marginal maximum likelihood (MMLE) via EM for the latent-space model family, -//! with optional multigroup and multilevel population structures. -//! -//! Person latents `(theta_p in R^D, xi_p in R^K)` are random effects integrated -//! out by Gauss-Hermite quadrature; item quantities `(alpha, b, zeta, tau)` are -//! structural parameters (Bock & Aitkin 1981 extended to the simple-structure -//! latent-space contract; see docs/mmle_marginal_lsirm_design.md). Tractability -//! rests on the simple-structure factorization: conditional on `xi_p` the trait -//! dimensions are independent, so the per-person integral costs -//! `Q_xi^K * (Q_u) * sum_d Q_theta` instead of `Q^(1+D+K)`. -//! -//! Population structures (mutually exclusive): -//! - Single: `theta_pd ~ N(0,1)`. -//! - Multigroup (Bock & Zimowski 1997): `theta_pd ~ N(mu_gd, sigma_gd^2)`, -//! common item parameters, reference group 0 pinned at `N(0,1)`. -//! - Multilevel (Fox & Glas 2001 random intercept): -//! `theta_pd = sigma_u * u_c + e_pd`, `u_c ~ N(0,1)` shared within cluster -//! `c`, `e_pd ~ N(0,1)`; `sigma_u` estimated. -//! -//! The M-step is generalized EM: a few Armijo-backtracked gradient ascent steps -//! per item on the expected complete-data log-likelihood (plus the L2 penalties -//! of `PenaltyConfig`, i.e. MAP-flavored MMLE that keeps sparse items finite), -//! then a backtracked Newton step for the global `tau`, then closed-form -//! population-moment updates. Every step is deterministic — the Rust<->NumPy -//! parity contract for this estimator is exact algorithm equality. -//! -//! Zero inflation is a repository-specific Bernoulli response-pattern -//! adaptation of the structural-zero mixture template described for count -//! models by Perumean-Chaney et al. (2013); that article does not present an -//! IRT estimator. Engager/IRT-component scores are conditional on membership -//! in that component, while multilevel `u_eap` integrates over the full mixture. -//! -//! Perumean-Chaney, S. E., Morgan, C., McDowall, D., & Aban, I. (2013). -//! Zero-inflated and overdispersed: What's one to do? *Journal of Statistical -//! Computation and Simulation, 83*(9), 1671–1683. -//! https://doi.org/10.1080/00949655.2012.668550 - -use crate::nodes::{build_xi_nodes, XiRule}; -use crate::quadrature::gh_rule; -use crate::{ - interaction_kind, model_exec_flags, Device, InteractionKind, ModelConfig, ModelType, - PenaltyConfig, -}; - -#[derive(Clone, Debug)] -pub enum PopulationSpec { - Single, - /// One population with FREE `(mu_d, sigma_d)` — the Fixed Item Parameter - /// Calibration setting (Kim 2006): identification comes from anchored - /// items, so `fit_marginal` requires `anchors` with this variant. - SingleFree, - /// `group_id[p] in 0..n_groups`; group 0 is the fixed `N(0,1)` reference. - Multigroup { - group_id: Vec, - n_groups: usize, - }, - /// `cluster_id[p] in 0..n_clusters`. - Multilevel { - cluster_id: Vec, - n_clusters: usize, - }, -} - -/// Context-varying item covariate with one estimated coefficient -/// (Debeer & Janssen 2013 linear item-position effect): -/// `eta_pi += delta * w[s(p) * n_items + i]`, `delta` estimated by a Newton -/// coordinate in the M-step. `w` must vary within an item across contexts -/// (e.g. booklet groups) — an item-constant covariate is collinear with `b_i`. -#[derive(Clone, Debug)] -pub struct ItemCovariate { - /// Row-major `n_ctx x n_items` covariate values (n_ctx = groups for - /// multigroup; must be 1 x n_items only when a single context exists). - pub w: Vec, - /// Starting value for the coefficient. - pub init_delta: f64, -} - -/// Fixed-item anchors for FIPC (Kim 2006, the MWU-MEM-style variant: the -/// population moments update on every EM cycle while anchored item -/// parameters stay frozen at their supplied values). -#[derive(Clone, Debug)] -pub struct Anchors { - /// `fixed[i]` freezes item `i` at the supplied values. - pub fixed: Vec, - /// Full-length arrays; only entries with `fixed[i]` are read. - pub alpha: Vec, - pub b: Vec, - /// Row-major `n_items x latent_dim`. - pub zeta: Vec, - /// Freeze the global `tau` (log gamma) at this value (from the anchor - /// calibration) instead of re-estimating it. - pub tau: Option, -} - -#[derive(Clone, Copy, Debug)] -pub struct MarginalConfig { - /// Gauss-Hermite nodes for each trait dimension (must be a supported rule). - pub q_theta: usize, - /// Gauss-Hermite nodes per latent-space axis (tensor grid of `q_xi^K`). - pub q_xi: usize, - /// Gauss-Hermite nodes for the multilevel random intercept. - pub q_u: usize, - pub max_iter: usize, - /// Convergence: absolute change of the marginal log-likelihood. - pub tol: f64, - /// Gradient-ascent steps per item per M-step. - pub m_steps: usize, - /// Initial radius of the deterministic item-position circle init. - pub init_zeta_radius: f64, - /// Initial `sigma_u` (multilevel only). - pub init_sigma_u: f64, - /// Latent-space integration rule: tensor Gauss-Hermite (`q_xi` per axis), - /// Halton QMC (QMC-EM, Jank 2005), or seeded Monte Carlo (MCEM, - /// Wei & Tanner 1990). `q_xi` is ignored for the QMC/MC rules. - pub xi_rule: XiRuleKind, - /// Point count for the Halton/MonteCarlo rules. - pub xi_points: usize, - /// Halton random-shift seed (0 = unshifted) / Monte Carlo seed. - pub xi_seed: u64, - /// Zero-inflated mixture (cf. Perumean-Chaney et al. 2013 for the ZI - /// count-model template): a structural-zero latent class produces - /// all-zero response patterns with probability `pi`, estimated by EM; - /// `L_p = pi * 1[y_p == 0] + (1 - pi) * L_IRT(y_p)`. - pub zero_inflation: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum XiRuleKind { - GaussHermite, - Halton, - MonteCarlo, -} - -impl XiRuleKind { - /// Parse a case-insensitive rule name: gh / qmc (halton) / mc. - pub fn parse(name: &str) -> Option { - match name.trim().to_ascii_lowercase().as_str() { - "gh" | "gauss-hermite" | "gausshermite" => Some(XiRuleKind::GaussHermite), - "qmc" | "halton" => Some(XiRuleKind::Halton), - "mc" | "montecarlo" | "monte-carlo" => Some(XiRuleKind::MonteCarlo), - _ => None, - } - } -} - -impl Default for MarginalConfig { - fn default() -> Self { - Self { - q_theta: 21, - q_xi: 11, - q_u: 15, - max_iter: 200, - tol: 1e-5, - m_steps: 4, - init_zeta_radius: 0.5, - init_sigma_u: 0.3, - xi_rule: XiRuleKind::GaussHermite, - xi_points: 256, - xi_seed: 0, - zero_inflation: false, - } - } -} - -/// Free-parameter count of a marginal fit: item parameters (respecting -/// anchors), the global tau, and the population parameters. Used by the -/// information criteria (Kang, Cohen & Sung 2009). -pub fn n_free_parameters( - config: &ModelConfig, - pop: &PopulationSpec, - anchors: Option<&Anchors>, -) -> usize { - let (free_alpha, uses_space) = model_exec_flags(config.model_type); - let per_item = 1 + usize::from(free_alpha) + if uses_space { config.latent_dim } else { 0 }; - let n_free_items = match anchors { - Some(a) => a.fixed.iter().filter(|&&f| !f).count(), - None => config.n_items, - }; - let tau_free = interaction_kind(config.model_type) == InteractionKind::Distance - && uses_space - && anchors.and_then(|a| a.tau).is_none(); - let pop_params = match pop { - PopulationSpec::Single => 0, - PopulationSpec::SingleFree => 2 * config.n_dims, - PopulationSpec::Multigroup { n_groups, .. } => { - 2 * config.n_dims * n_groups.saturating_sub(1) - } - PopulationSpec::Multilevel { .. } => 1, - }; - n_free_items * per_item + usize::from(tau_free) + pop_params -} - -#[derive(Clone, Debug)] -pub struct MarginalResult { - /// Free-parameter count (items + tau + population), for model selection. - pub n_parameters: usize, - pub alpha: Vec, - pub b: Vec, - /// Item positions, row-major `n_items x latent_dim`, PCA-aligned. - pub zeta: Vec, - pub tau: f64, - /// EAP trait scores, row-major `n_persons x n_dims`. - pub theta_eap: Vec, - /// Posterior SDs matching `theta_eap`. - pub theta_sd: Vec, - /// EAP person positions, row-major `n_persons x latent_dim`, PCA-aligned. - pub xi_eap: Vec, - /// Multigroup: `n_groups x n_dims` trait means (empty otherwise). - pub mu: Vec, - /// Multigroup: `n_groups x n_dims` trait SDs (empty otherwise). - pub sigma: Vec, - /// Multilevel: random-intercept SD (0 otherwise). - pub sigma_u: f64, - /// Multilevel: EAP cluster intercepts (empty otherwise). - pub u_eap: Vec, - /// Item-covariate coefficient (0 when no covariate was supplied). - pub delta: f64, - /// Zero-inflation mixing weight (0 when the mixture is disabled). - pub pi_zero: f64, - /// Posterior structural-zero responsibility per person (empty when the - /// mixture is disabled). - pub zero_responsibility: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, -} - -#[inline] -fn log_sigmoid(x: f64) -> f64 { - if x >= 0.0 { - -(-x).exp().ln_1p() - } else { - x - x.exp().ln_1p() - } -} - -#[inline] -fn sigmoid(x: f64) -> f64 { - if x >= 0.0 { - 1.0 / (1.0 + (-x).exp()) - } else { - let ex = x.exp(); - ex / (1.0 + ex) - } -} - -/// Population contexts: the trait value plugged into `eta` is -/// `theta(t, s, d) = shift[s*D+d] + scale[s*D+d] * t`. -pub(crate) struct Contexts { - pub(crate) n_ctx: usize, - pub(crate) shift: Vec, - pub(crate) scale: Vec, - /// Multilevel: standard-normal u nodes and log-weights; empty otherwise. - pub(crate) u_nodes: Vec, - pub(crate) u_logw: Vec, -} - -fn build_contexts( - pop: &PopulationSpec, - mu: &[f64], - sigma: &[f64], - sigma_u: f64, - n_dims: usize, - q_u: usize, -) -> Contexts { - match pop { - PopulationSpec::Single => Contexts { - n_ctx: 1, - shift: vec![0.0; n_dims], - scale: vec![1.0; n_dims], - u_nodes: Vec::new(), - u_logw: Vec::new(), - }, - PopulationSpec::SingleFree => Contexts { - n_ctx: 1, - shift: mu.to_vec(), - scale: sigma.to_vec(), - u_nodes: Vec::new(), - u_logw: Vec::new(), - }, - PopulationSpec::Multigroup { n_groups, .. } => Contexts { - n_ctx: *n_groups, - shift: mu.to_vec(), - scale: sigma.to_vec(), - u_nodes: Vec::new(), - u_logw: Vec::new(), - }, - PopulationSpec::Multilevel { .. } => { - let (nodes, weights) = gh_rule(q_u).expect("validated earlier"); - let mut shift = vec![0.0_f64; q_u * n_dims]; - let mut scale = vec![1.0_f64; q_u * n_dims]; - for (v, &node) in nodes.iter().enumerate() { - for d in 0..n_dims { - shift[v * n_dims + d] = sigma_u * node; - scale[v * n_dims + d] = 1.0; - } - } - Contexts { - n_ctx: q_u, - shift, - scale, - u_nodes: nodes.to_vec(), - u_logw: weights.iter().map(|w| w.ln()).collect(), - } - } - } -} - -/// Item-response tables and their per-dimension all-zero baseline. -/// `logp1`/`logp0` are `[ctx][item][t][x]` flattened; `c0` is `[ctx][dim][t][x]`. -pub(crate) struct Tables { - pub(crate) logp1: Vec, - pub(crate) logp0: Vec, - pub(crate) c0: Vec, -} - -pub(crate) struct Grids { - pub(crate) t_nodes: Vec, - pub(crate) t_logw: Vec, - pub(crate) x_grid: Vec, - pub(crate) x_logw: Vec, - pub(crate) q_t: usize, - pub(crate) n_x: usize, -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn eta_at_kind( - alpha: &[f64], - b: &[f64], - zeta: &[f64], - tau: f64, - free_alpha: bool, - kind: InteractionKind, - latent_dim: usize, - eps_distance: f64, - i: usize, - theta: f64, - x_node: &[f64], -) -> f64 { - let a = if free_alpha { alpha[i].exp() } else { 1.0 }; - let mut eta = a * theta + b[i]; - match kind { - InteractionKind::None => {} - InteractionKind::Distance => { - let mut dist2 = eps_distance; - for k in 0..latent_dim { - let diff = x_node[k] - zeta[i * latent_dim + k]; - dist2 += diff * diff; - } - eta -= tau.exp() * dist2.sqrt(); - } - InteractionKind::Inner => { - for k in 0..latent_dim { - eta += zeta[i * latent_dim + k] * x_node[k]; - } - } - } - eta -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn build_tables( - alpha: &[f64], - b: &[f64], - zeta: &[f64], - tau: f64, - config: &ModelConfig, - factor_id: &[usize], - ctx: &Contexts, - grids: &Grids, -) -> Tables { - build_tables_offset(alpha, b, zeta, tau, config, factor_id, ctx, grids, None) -} - -/// [`build_tables`] with an optional per-(context, item) additive offset on -/// the linear predictor (the covariate term `delta * w[s, i]`). -#[allow(clippy::too_many_arguments)] -pub(crate) fn build_tables_offset( - alpha: &[f64], - b: &[f64], - zeta: &[f64], - tau: f64, - config: &ModelConfig, - factor_id: &[usize], - ctx: &Contexts, - grids: &Grids, - offset: Option<&[f64]>, -) -> Tables { - let (free_alpha, _uses_space) = model_exec_flags(config.model_type); - let kind = interaction_kind(config.model_type); - let (n_items, n_dims, latent_dim) = (config.n_items, config.n_dims, config.latent_dim); - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - let mut logp1 = vec![0.0_f64; ctx.n_ctx * n_items * cell]; - let mut logp0 = vec![0.0_f64; ctx.n_ctx * n_items * cell]; - let mut c0 = vec![0.0_f64; ctx.n_ctx * n_dims * cell]; - for s in 0..ctx.n_ctx { - for i in 0..n_items { - let d = factor_id[i]; - let off = offset.map(|o| o[s * n_items + i]).unwrap_or(0.0); - let (shift, scale) = (ctx.shift[s * n_dims + d], ctx.scale[s * n_dims + d]); - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = shift + scale * node_t; - for x in 0..n_x { - let eta = off - + eta_at_kind( - alpha, - b, - zeta, - tau, - free_alpha, - kind, - latent_dim, - config.eps_distance, - i, - theta, - &grids.x_grid[x * latent_dim..(x + 1) * latent_dim], - ); - let idx = (s * n_items + i) * cell + t * n_x + x; - logp1[idx] = log_sigmoid(eta); - logp0[idx] = log_sigmoid(-eta); - c0[(s * n_dims + d) * cell + t * n_x + x] += logp0[idx]; - } - } - } - } - Tables { logp1, logp0, c0 } -} - -/// Per-person response index: positives and missing cells, item-major. -pub(crate) struct ResponseIndex { - pub(crate) pos: Vec>, - pub(crate) miss: Vec>, -} - -pub(crate) fn index_responses( - y: &[f64], - observed: &[bool], - n_persons: usize, - n_items: usize, -) -> ResponseIndex { - let mut pos = vec![Vec::new(); n_persons]; - let mut miss = vec![Vec::new(); n_persons]; - for p in 0..n_persons { - for i in 0..n_items { - let idx = p * n_items + i; - if !observed[idx] { - miss[p].push(i); - } else if y[idx] == 1.0 { - pos[p].push(i); - } - } - } - ResponseIndex { pos, miss } -} - -/// Build the person work buffer `l[d][t][x]` for person `p` in context `s` -/// and reduce it: returns (per-(d,x) logsumexp over t into `log_zdx`, and the -/// person log-marginal for this context). -#[allow(clippy::too_many_arguments)] -pub(crate) fn person_pass( - p: usize, - s: usize, - tables: &Tables, - resp: &ResponseIndex, - factor_id: &[usize], - n_dims: usize, - n_items: usize, - grids: &Grids, - l_buf: &mut [f64], - log_zdx: &mut [f64], -) -> f64 { - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - l_buf[..n_dims * cell].copy_from_slice(&tables.c0[s * n_dims * cell..(s + 1) * n_dims * cell]); - for &i in &resp.miss[p] { - let d = factor_id[i]; - let src = (s * n_items + i) * cell; - for c in 0..cell { - l_buf[d * cell + c] -= tables.logp0[src + c]; - } - } - for &i in &resp.pos[p] { - let d = factor_id[i]; - let src = (s * n_items + i) * cell; - for c in 0..cell { - l_buf[d * cell + c] += tables.logp1[src + c] - tables.logp0[src + c]; - } - } - // logsumexp over t for each (d, x) - for d in 0..n_dims { - for x in 0..n_x { - let mut max = f64::NEG_INFINITY; - for t in 0..q_t { - let v = grids.t_logw[t] + l_buf[d * cell + t * n_x + x]; - if v > max { - max = v; - } - } - let mut sum = 0.0; - for t in 0..q_t { - sum += (grids.t_logw[t] + l_buf[d * cell + t * n_x + x] - max).exp(); - } - log_zdx[d * n_x + x] = max + sum.ln(); - } - } - // logsumexp over x of (log w_x + sum_d log_zdx) - let mut max = f64::NEG_INFINITY; - for x in 0..n_x { - let mut v = grids.x_logw[x]; - for d in 0..n_dims { - v += log_zdx[d * n_x + x]; - } - if v > max { - max = v; - } - } - let mut sum = 0.0; - for x in 0..n_x { - let mut v = grids.x_logw[x]; - for d in 0..n_dims { - v += log_zdx[d * n_x + x]; - } - sum += (v - max).exp(); - } - max + sum.ln() -} - -/// Zero-inflation mixture pieces for one person-context evaluation: given the -/// IRT-side log-marginal `lp_irt`, returns -/// (mixture log-marginal, IRT-class posterior weight `1 - r`). -#[inline] -fn zi_mix(lp_irt: f64, all_zero: bool, log_pi: f64, log_1m_pi: f64) -> (f64, f64) { - if !all_zero { - return (log_1m_pi + lp_irt, 1.0); - } - let a = log_pi; - let b = log_1m_pi + lp_irt; - let m = a.max(b); - let lp = m + ((a - m).exp() + (b - m).exp()).ln(); - ((lp), (b - lp).exp()) -} - -/// Multilevel posteriors at fixed parameters. -/// -/// The cluster posterior integrates the structural-zero mixture for every -/// person. The per-person context posterior is additionally conditioned on -/// that person belonging to the engager/IRT component, matching the public -/// scoring contract for zero-inflated calibrations. -pub(crate) fn multilevel_context_posteriors( - lp_irt: &[f64], - all_zero: &[bool], - cluster_id: &[usize], - n_clusters: usize, - u_logw: &[f64], - pi_zero: Option, -) -> (Vec, Vec) { - let n_persons = cluster_id.len(); - let q_u = u_logw.len(); - debug_assert_eq!(lp_irt.len(), n_persons * q_u); - debug_assert_eq!(all_zero.len(), n_persons); - - let (log_pi, log_1m_pi) = match pi_zero { - Some(pi) => (pi.ln(), (1.0 - pi).ln()), - None => (f64::NEG_INFINITY, 0.0), - }; - let mut lp_mix = vec![0.0_f64; n_persons * q_u]; - let mut log_irt_adjust = vec![0.0_f64; n_persons * q_u]; - for p in 0..n_persons { - for v in 0..q_u { - let idx = p * q_u + v; - if pi_zero.is_some() { - let (mixed, _) = zi_mix(lp_irt[idx], all_zero[p], log_pi, log_1m_pi); - lp_mix[idx] = mixed; - // Replacing this person's mixture contribution with its IRT - // contribution conditions its context posterior on engager - // membership. Keep this in log space to avoid underflow. - log_irt_adjust[idx] = log_1m_pi + lp_irt[idx] - mixed; - } else { - lp_mix[idx] = lp_irt[idx]; - } - } - } - - let mut log_cluster = vec![0.0_f64; n_clusters * q_u]; - for c in 0..n_clusters { - log_cluster[c * q_u..(c + 1) * q_u].copy_from_slice(u_logw); - } - for p in 0..n_persons { - let c = cluster_id[p]; - for v in 0..q_u { - log_cluster[c * q_u + v] += lp_mix[p * q_u + v]; - } - } - - let normalize = |row: &[f64]| -> Vec { - let max = row.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let sum: f64 = row.iter().map(|&value| (value - max).exp()).sum(); - row.iter().map(|&value| (value - max).exp() / sum).collect() - }; - let mut cluster_post = vec![0.0_f64; n_clusters * q_u]; - for c in 0..n_clusters { - let post = normalize(&log_cluster[c * q_u..(c + 1) * q_u]); - cluster_post[c * q_u..(c + 1) * q_u].copy_from_slice(&post); - } - - let mut engager_context_post = vec![0.0_f64; n_persons * q_u]; - for p in 0..n_persons { - let c = cluster_id[p]; - if pi_zero.is_some() { - let row: Vec = (0..q_u) - .map(|v| log_cluster[c * q_u + v] + log_irt_adjust[p * q_u + v]) - .collect(); - let post = normalize(&row); - engager_context_post[p * q_u..(p + 1) * q_u].copy_from_slice(&post); - } else { - engager_context_post[p * q_u..(p + 1) * q_u] - .copy_from_slice(&cluster_post[c * q_u..(c + 1) * q_u]); - } - } - (cluster_post, engager_context_post) -} - -/// E-step accumulators (per context, on the (t, x) grid). -struct EStep { - /// `[ctx][dim][t][x]` expected person counts. - nbar: Vec, - /// `[ctx][item][t][x]` expected positive counts. - rbar: Vec, - /// `[ctx][item][t][x]` expected missing-cell corrections. - mbar: Vec, - /// Marginal (unpenalized) log-likelihood. - loglik: f64, - /// Zero-inflation: expected structural-zero class memberships per person - /// (empty when disabled). - zi_resp: Vec, - /// Multilevel: `E[u_c^2 | Y]` summed over clusters (in u-node units of the - /// standard normal, i.e. before scaling by `sigma_u`). - sum_e_v2: f64, - /// Multilevel: cluster posteriors over u nodes, `[cluster][v]`. - cluster_post: Vec, -} - -/// Accumulate person `p`'s posterior (weighted by `w_outer`, the cluster -/// posterior weight for multilevel or 1.0 otherwise) into the E-step arrays. -#[allow(clippy::too_many_arguments)] -fn accumulate_person( - p: usize, - s: usize, - w_outer: f64, - resp: &ResponseIndex, - factor_id: &[usize], - n_dims: usize, - n_items: usize, - grids: &Grids, - l_buf: &[f64], - log_zdx: &[f64], - log_lp: f64, - estep: &mut EStep, - post_buf: &mut [f64], -) { - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - // post_x(x) = exp(log w_x + sum_d log_zdx - log_lp) - // post(d,t,x) = post_x(x) * exp(log w_t + l - log_zdx) - for x in 0..n_x { - let mut lx = grids.x_logw[x] - log_lp; - for d in 0..n_dims { - lx += log_zdx[d * n_x + x]; - } - let px = lx.exp(); - for d in 0..n_dims { - for t in 0..q_t { - let pt = - (grids.t_logw[t] + l_buf[d * cell + t * n_x + x] - log_zdx[d * n_x + x]).exp(); - post_buf[d * cell + t * n_x + x] = w_outer * px * pt; - } - } - } - let base = s * n_dims * cell; - for c in 0..n_dims * cell { - estep.nbar[base + c] += post_buf[c]; - } - for &i in &resp.pos[p] { - let d = factor_id[i]; - let dst = (s * n_items + i) * cell; - for c in 0..cell { - estep.rbar[dst + c] += post_buf[d * cell + c]; - } - } - for &i in &resp.miss[p] { - let d = factor_id[i]; - let dst = (s * n_items + i) * cell; - for c in 0..cell { - estep.mbar[dst + c] += post_buf[d * cell + c]; - } - } -} - -/// Route one E-step through the requested device. `Cpu` is the deterministic -/// f64 reference; `Gpu`/`Auto` run the wgpu f32 kernels when an adapter is -/// present (`Gpu` warns on fallback, `Auto` is silent). Accumulation noise on -/// the GPU is ~1e-4 relative — the M-step and the final EAP pass always stay -/// on the CPU in f64. -#[allow(clippy::too_many_arguments)] -fn e_step_device( - device: Device, - tables: &Tables, - resp: &ResponseIndex, - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - ctx: &Contexts, - grids: &Grids, - zi: Option<(f64, &[bool])>, -) -> EStep { - match device { - Device::Cpu => e_step(tables, resp, factor_id, config, pop, ctx, grids, zi), - Device::Gpu | Device::Auto => { - #[cfg(all(feature = "gpu", not(coverage)))] - { - match e_step_gpu_adapter(tables, resp, factor_id, config, pop, ctx, grids, zi) { - Some(estep) => return estep, - None => { - if matches!(device, Device::Gpu) { - eprintln!( - "fast-mlsirm: GPU device requested but no usable GPU adapter \ - was found; falling back to the CPU implementation." - ); - } - } - } - } - e_step(tables, resp, factor_id, config, pop, ctx, grids, zi) - } - } -} - -/// Bridge the CPU-side E-step data model onto the wgpu kernels. -#[cfg(all(feature = "gpu", not(coverage)))] -#[allow(clippy::too_many_arguments)] -fn e_step_gpu_adapter( - tables: &Tables, - resp: &ResponseIndex, - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - ctx: &Contexts, - grids: &Grids, - zi: Option<(f64, &[bool])>, -) -> Option { - let (n_persons, n_items, n_dims) = (config.n_persons, config.n_items, config.n_dims); - let cell = grids.q_t * grids.n_x; - // The logz buffer scales with n_persons * n_ctx * n_dims * n_x; refuse - // allocations past ~1 GiB (large QMC point sets on multilevel fits) and - // let the caller fall back to the CPU E-step instead of a device error. - let logz_bytes = n_persons - .saturating_mul(ctx.n_ctx) - .saturating_mul(n_dims) - .saturating_mul(grids.n_x) - .saturating_mul(4); - if logz_bytes > (1usize << 30) { - return None; - } - // Person-major CSR lists. - let build_csr = |lists: &[Vec]| { - let mut off = Vec::with_capacity(lists.len() + 1); - let mut items = Vec::new(); - off.push(0u32); - for l in lists { - items.extend(l.iter().map(|&i| i as u32)); - off.push(items.len() as u32); - } - (off, items) - }; - let (pos_off, pos_items) = build_csr(&resp.pos); - let (miss_off, miss_items) = build_csr(&resp.miss); - // Item-major person lists. - let invert = |lists: &[Vec]| { - let mut per_item: Vec> = vec![Vec::new(); n_items]; - for (p, l) in lists.iter().enumerate() { - for &i in l { - per_item[i].push(p as u32); - } - } - let mut off = Vec::with_capacity(n_items + 1); - let mut persons = Vec::new(); - off.push(0u32); - for l in &per_item { - persons.extend_from_slice(l); - off.push(persons.len() as u32); - } - (off, persons) - }; - let (item_pos_off, item_pos_persons) = invert(&resp.pos); - let (item_miss_off, item_miss_persons) = invert(&resp.miss); - - let (all_ctx, ctx_of_person): (bool, Vec) = match pop { - PopulationSpec::Single | PopulationSpec::SingleFree => (false, vec![0u32; n_persons]), - PopulationSpec::Multigroup { group_id, .. } => { - (false, group_id.iter().map(|&g| g as u32).collect()) - } - PopulationSpec::Multilevel { .. } => (true, vec![0u32; n_persons]), - }; - - let inputs = crate::gpu_marginal::GpuEStepInputs { - logp0: &tables.logp0, - logp1: &tables.logp1, - c0: &tables.c0, - t_logw: &grids.t_logw, - x_logw: &grids.x_logw, - factor_id, - ctx_of_person: &ctx_of_person, - all_ctx, - n_ctx: ctx.n_ctx, - pos_off: &pos_off, - pos_items: &pos_items, - miss_off: &miss_off, - miss_items: &miss_items, - item_pos_off: &item_pos_off, - item_pos_persons: &item_pos_persons, - item_miss_off: &item_miss_off, - item_miss_persons: &item_miss_persons, - }; - - let mut loglik = 0.0_f64; - let mut sum_e_v2 = 0.0_f64; - let n_ctx = ctx.n_ctx; - let (log_pi, log_1m_pi) = match zi { - Some((pi, _)) => (pi.ln(), (1.0 - pi).ln()), - None => (f64::NEG_INFINITY, 0.0), - }; - let mut zi_resp = if zi.is_some() { - vec![0.0_f64; n_persons] - } else { - Vec::new() - }; - let mut w_outer_fn = |lp: &[f64]| -> Vec { - let mut w = vec![0.0_f64; n_ctx * n_persons]; - match pop { - PopulationSpec::Single | PopulationSpec::SingleFree => { - for p in 0..n_persons { - let (lp_mix, w_irt) = match zi { - Some((_, all_zero)) => { - zi_mix(lp[p * n_ctx], all_zero[p], log_pi, log_1m_pi) - } - None => (lp[p * n_ctx], 1.0), - }; - loglik += lp_mix; - if zi.is_some() { - zi_resp[p] = 1.0 - w_irt; - } - w[p] = w_irt; - } - } - PopulationSpec::Multigroup { group_id, .. } => { - for p in 0..n_persons { - let s = group_id[p]; - let (lp_mix, w_irt) = match zi { - Some((_, all_zero)) => { - zi_mix(lp[p * n_ctx + s], all_zero[p], log_pi, log_1m_pi) - } - None => (lp[p * n_ctx + s], 1.0), - }; - loglik += lp_mix; - if zi.is_some() { - zi_resp[p] = 1.0 - w_irt; - } - w[s * n_persons + p] = w_irt; - } - } - PopulationSpec::Multilevel { - cluster_id, - n_clusters, - } => { - // mixture applies per (person, u-node) - let mut lp_mix_v = vec![0.0_f64; n_persons * n_ctx]; - let mut w_irt_v = vec![1.0_f64; n_persons * n_ctx]; - for p in 0..n_persons { - for v in 0..n_ctx { - let (m, wi) = match zi { - Some((_, all_zero)) => { - zi_mix(lp[p * n_ctx + v], all_zero[p], log_pi, log_1m_pi) - } - None => (lp[p * n_ctx + v], 1.0), - }; - lp_mix_v[p * n_ctx + v] = m; - w_irt_v[p * n_ctx + v] = wi; - } - } - let mut log_cluster = vec![0.0_f64; n_clusters * n_ctx]; - for c in 0..*n_clusters { - log_cluster[c * n_ctx..(c + 1) * n_ctx].copy_from_slice(&ctx.u_logw); - } - for p in 0..n_persons { - let c = cluster_id[p]; - for v in 0..n_ctx { - log_cluster[c * n_ctx + v] += lp_mix_v[p * n_ctx + v]; - } - } - let mut post = vec![0.0_f64; n_clusters * n_ctx]; - for c in 0..*n_clusters { - let row = &log_cluster[c * n_ctx..(c + 1) * n_ctx]; - let max = row.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let sum: f64 = row.iter().map(|&v| (v - max).exp()).sum(); - loglik += max + sum.ln(); - for v in 0..n_ctx { - let pw = (row[v] - max).exp() / sum; - post[c * n_ctx + v] = pw; - sum_e_v2 += pw * ctx.u_nodes[v] * ctx.u_nodes[v]; - } - } - for p in 0..n_persons { - let c = cluster_id[p]; - for v in 0..n_ctx { - let pw = post[c * n_ctx + v]; - if zi.is_some() { - zi_resp[p] += pw * (1.0 - w_irt_v[p * n_ctx + v]); - } - w[v * n_persons + p] = pw * w_irt_v[p * n_ctx + v]; - } - } - } - } - w - }; - - let out = crate::gpu_marginal::e_step_gpu(config, &inputs, &mut w_outer_fn)?; - debug_assert_eq!(out.nbar.len(), n_ctx * n_dims * cell); - Some(EStep { - nbar: out.nbar, - rbar: out.rbar, - mbar: out.mbar, - loglik, - zi_resp, - sum_e_v2, - cluster_post: Vec::new(), - }) -} - -/// Full deterministic E-step over all persons (CPU f64 reference). -#[allow(clippy::too_many_arguments)] -fn e_step( - tables: &Tables, - resp: &ResponseIndex, - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - ctx: &Contexts, - grids: &Grids, - zi: Option<(f64, &[bool])>, -) -> EStep { - let (n_persons, n_items, n_dims) = (config.n_persons, config.n_items, config.n_dims); - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - let mut estep = EStep { - nbar: vec![0.0; ctx.n_ctx * n_dims * cell], - rbar: vec![0.0; ctx.n_ctx * n_items * cell], - mbar: vec![0.0; ctx.n_ctx * n_items * cell], - loglik: 0.0, - zi_resp: if zi.is_some() { - vec![0.0; n_persons] - } else { - Vec::new() - }, - sum_e_v2: 0.0, - cluster_post: Vec::new(), - }; - let (log_pi, log_1m_pi) = match zi { - Some((pi, _)) => (pi.ln(), (1.0 - pi).ln()), - None => (f64::NEG_INFINITY, 0.0), - }; - let mut l_buf = vec![0.0_f64; n_dims * cell]; - let mut log_zdx = vec![0.0_f64; n_dims * n_x]; - let mut post_buf = vec![0.0_f64; n_dims * cell]; - - match pop { - PopulationSpec::Single | PopulationSpec::SingleFree => { - for p in 0..n_persons { - let lp = person_pass( - p, - 0, - tables, - resp, - factor_id, - n_dims, - n_items, - grids, - &mut l_buf, - &mut log_zdx, - ); - let (lp_mix, w_irt) = match zi { - Some((_, all_zero)) => zi_mix(lp, all_zero[p], log_pi, log_1m_pi), - None => (lp, 1.0), - }; - estep.loglik += lp_mix; - if zi.is_some() { - estep.zi_resp[p] = 1.0 - w_irt; - } - accumulate_person( - p, - 0, - w_irt, - resp, - factor_id, - n_dims, - n_items, - grids, - &l_buf, - &log_zdx, - lp, - &mut estep, - &mut post_buf, - ); - } - } - PopulationSpec::Multigroup { group_id, .. } => { - for p in 0..n_persons { - let s = group_id[p]; - let lp = person_pass( - p, - s, - tables, - resp, - factor_id, - n_dims, - n_items, - grids, - &mut l_buf, - &mut log_zdx, - ); - let (lp_mix, w_irt) = match zi { - Some((_, all_zero)) => zi_mix(lp, all_zero[p], log_pi, log_1m_pi), - None => (lp, 1.0), - }; - estep.loglik += lp_mix; - if zi.is_some() { - estep.zi_resp[p] = 1.0 - w_irt; - } - accumulate_person( - p, - s, - w_irt, - resp, - factor_id, - n_dims, - n_items, - grids, - &l_buf, - &log_zdx, - lp, - &mut estep, - &mut post_buf, - ); - } - } - PopulationSpec::Multilevel { - cluster_id, - n_clusters, - } => { - let q_u = ctx.n_ctx; - // Pass 1: per-person conditional marginals log L_p(v). - let mut lp_v = vec![0.0_f64; n_persons * q_u]; - let mut w_irt_v = vec![1.0_f64; n_persons * q_u]; - for p in 0..n_persons { - for v in 0..q_u { - let lp_irt = person_pass( - p, - v, - tables, - resp, - factor_id, - n_dims, - n_items, - grids, - &mut l_buf, - &mut log_zdx, - ); - let (lp_mix, w_irt) = match zi { - Some((_, all_zero)) => zi_mix(lp_irt, all_zero[p], log_pi, log_1m_pi), - None => (lp_irt, 1.0), - }; - lp_v[p * q_u + v] = lp_mix; - w_irt_v[p * q_u + v] = w_irt; - } - } - // Cluster posteriors over u nodes. - let mut log_cluster = vec![0.0_f64; n_clusters * q_u]; - for c in 0..*n_clusters { - for v in 0..q_u { - log_cluster[c * q_u + v] = ctx.u_logw[v]; - } - } - for p in 0..n_persons { - let c = cluster_id[p]; - for v in 0..q_u { - log_cluster[c * q_u + v] += lp_v[p * q_u + v]; - } - } - estep.cluster_post = vec![0.0_f64; n_clusters * q_u]; - for c in 0..*n_clusters { - let row = &log_cluster[c * q_u..(c + 1) * q_u]; - let max = row.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let sum: f64 = row.iter().map(|&v| (v - max).exp()).sum(); - estep.loglik += max + sum.ln(); - for v in 0..q_u { - let post = ((row[v] - max).exp()) / sum; - estep.cluster_post[c * q_u + v] = post; - estep.sum_e_v2 += post * ctx.u_nodes[v] * ctx.u_nodes[v]; - } - } - // Pass 2: accumulate expected counts weighted by cluster posteriors - // (times the IRT-class responsibility under zero inflation). - for p in 0..n_persons { - let c = cluster_id[p]; - for v in 0..q_u { - let mut w_outer = estep.cluster_post[c * q_u + v]; - if zi.is_some() { - estep.zi_resp[p] += w_outer * (1.0 - w_irt_v[p * q_u + v]); - w_outer *= w_irt_v[p * q_u + v]; - } - if w_outer < 1e-14 { - continue; - } - let lp = person_pass( - p, - v, - tables, - resp, - factor_id, - n_dims, - n_items, - grids, - &mut l_buf, - &mut log_zdx, - ); - accumulate_person( - p, - v, - w_outer, - resp, - factor_id, - n_dims, - n_items, - grids, - &l_buf, - &log_zdx, - lp, - &mut estep, - &mut post_buf, - ); - } - } - } - } - estep -} - -/// Crate-internal bridge for the Oakes SE module: posterior expected counts. -pub(crate) struct EStepCounts { - pub(crate) nbar: Vec, - pub(crate) rbar: Vec, - pub(crate) mbar: Vec, -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn build_contexts_pub( - pop: &PopulationSpec, - mu: &[f64], - sigma: &[f64], - sigma_u: f64, - n_dims: usize, - q_u: usize, -) -> Contexts { - build_contexts(pop, mu, sigma, sigma_u, n_dims, q_u) -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn e_step_pub( - tables: &Tables, - resp: &ResponseIndex, - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - ctx: &Contexts, - grids: &Grids, -) -> EStepCounts { - let estep = e_step(tables, resp, factor_id, config, pop, ctx, grids, None); - EStepCounts { - nbar: estep.nbar, - rbar: estep.rbar, - mbar: estep.mbar, - } -} - -/// Expected complete-data log-likelihood contribution of one item (plus its L2 -/// penalties), used by the M-step line searches. -#[allow(clippy::too_many_arguments)] -fn item_q( - i: usize, - alpha_i: f64, - b_i: f64, - zeta_i: &[f64], - tau: f64, - estep: &EStep, - ctx: &Contexts, - grids: &Grids, - config: &ModelConfig, - factor_id: &[usize], - penalty: &PenaltyConfig, - offset: Option<&[f64]>, -) -> f64 { - let (free_alpha, uses_space) = model_exec_flags(config.model_type); - let kind = interaction_kind(config.model_type); - let (n_items, n_dims, latent_dim) = (config.n_items, config.n_dims, config.latent_dim); - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - let d = factor_id[i]; - let mut q = 0.0; - for s in 0..ctx.n_ctx { - let off = offset.map(|o| o[s * n_items + i]).unwrap_or(0.0); - let (shift, scale) = (ctx.shift[s * n_dims + d], ctx.scale[s * n_dims + d]); - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = shift + scale * node_t; - for x in 0..n_x { - let idx = t * n_x + x; - let n = estep.nbar[(s * n_dims + d) * cell + idx] - - estep.mbar[(s * n_items + i) * cell + idx]; - let r = estep.rbar[(s * n_items + i) * cell + idx]; - if n <= 0.0 && r <= 0.0 { - continue; - } - let eta = off - + eta_at_kind( - &[alpha_i], - &[b_i], - zeta_i, - tau, - free_alpha, - kind, - latent_dim, - config.eps_distance, - 0, - theta, - &grids.x_grid[x * latent_dim..(x + 1) * latent_dim], - ); - q += r * log_sigmoid(eta) + (n - r) * log_sigmoid(-eta); - } - } - } - q -= 0.5 * penalty.lambda_b * b_i * b_i; - if free_alpha { - let da = alpha_i - penalty.mu_alpha; - q -= 0.5 * penalty.lambda_alpha * da * da; - } - if uses_space { - let z2: f64 = zeta_i.iter().map(|z| z * z).sum(); - q -= 0.5 * penalty.lambda_zeta * z2; - } - q -} - -/// One M-step over items: a few Armijo-backtracked gradient ascent steps each -/// (generalized EM — each accepted step increases the expected complete-data -/// objective). -#[allow(clippy::too_many_arguments)] -fn m_step_items( - alpha: &mut [f64], - b: &mut [f64], - zeta: &mut [f64], - tau: f64, - estep: &EStep, - ctx: &Contexts, - grids: &Grids, - config: &ModelConfig, - factor_id: &[usize], - penalty: &PenaltyConfig, - m_steps: usize, - fixed: Option<&[bool]>, - offset: Option<&[f64]>, -) { - let (free_alpha, uses_space) = model_exec_flags(config.model_type); - let kind = interaction_kind(config.model_type); - let (n_items, n_dims, latent_dim) = (config.n_items, config.n_dims, config.latent_dim); - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - let gamma = tau.exp(); - for i in 0..n_items { - if fixed.map(|f| f[i]).unwrap_or(false) { - continue; - } - let d = factor_id[i]; - let mut zeta_i: Vec = zeta[i * latent_dim..(i + 1) * latent_dim].to_vec(); - let mut cur_q = item_q( - i, alpha[i], b[i], &zeta_i, tau, estep, ctx, grids, config, factor_id, penalty, offset, - ); - for _ in 0..m_steps { - // Analytic gradient of the expected complete-data objective, plus - // the diagonal expected (Fisher) information used as a - // preconditioner — plain gradient steps scale poorly across the - // mixed (alpha, b, zeta) curvature and stall the slope updates. - let a = if free_alpha { alpha[i].exp() } else { 1.0 }; - let (mut g_alpha, mut g_b) = (0.0_f64, 0.0_f64); - let mut g_zeta = vec![0.0_f64; latent_dim]; - let (mut i_alpha, mut i_b) = (0.0_f64, 0.0_f64); - let mut i_zeta = vec![0.0_f64; latent_dim]; - for s in 0..ctx.n_ctx { - let off = offset.map(|o| o[s * n_items + i]).unwrap_or(0.0); - let (shift, scale) = (ctx.shift[s * n_dims + d], ctx.scale[s * n_dims + d]); - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = shift + scale * node_t; - for x in 0..n_x { - let idx = t * n_x + x; - let n = estep.nbar[(s * n_dims + d) * cell + idx] - - estep.mbar[(s * n_items + i) * cell + idx]; - let r = estep.rbar[(s * n_items + i) * cell + idx]; - if n <= 0.0 && r <= 0.0 { - continue; - } - let x_node = &grids.x_grid[x * latent_dim..(x + 1) * latent_dim]; - let mut dist = 1.0; - let eta = { - let mut e = off + a * theta + b[i]; - match kind { - InteractionKind::None => {} - InteractionKind::Distance => { - let mut dist2 = config.eps_distance; - for k in 0..latent_dim { - let diff = x_node[k] - zeta_i[k]; - dist2 += diff * diff; - } - dist = dist2.sqrt(); - e -= gamma * dist; - } - InteractionKind::Inner => { - for k in 0..latent_dim { - e += zeta_i[k] * x_node[k]; - } - } - } - e - }; - let prob = sigmoid(eta); - let resid = r - n * prob; - let info = (n * prob * (1.0 - prob)).max(0.0); - g_b += resid; - i_b += info; - if free_alpha { - let deta = a * theta; - g_alpha += resid * deta; - i_alpha += info * deta * deta; - } - if uses_space { - for k in 0..latent_dim { - // model_exec_flags guarantees a spatial model uses either the - // distance or inner-product interaction; None has uses_space=false. - let deta = if kind == InteractionKind::Distance { - gamma * (x_node[k] - zeta_i[k]) / dist - } else { - x_node[k] - }; - g_zeta[k] += resid * deta; - i_zeta[k] += info * deta * deta; - } - } - } - } - } - g_b -= penalty.lambda_b * b[i]; - if free_alpha { - g_alpha -= penalty.lambda_alpha * (alpha[i] - penalty.mu_alpha); - } - if uses_space { - for k in 0..latent_dim { - g_zeta[k] -= penalty.lambda_zeta * zeta_i[k]; - } - } - // Preconditioned ascent direction d = g / (I + lambda), a damped - // Fisher-scoring step per coordinate. - let d_b = g_b / (i_b + penalty.lambda_b + 1e-8); - let d_alpha = g_alpha / (i_alpha + penalty.lambda_alpha + 1e-8); - let d_zeta: Vec = (0..latent_dim) - .map(|k| g_zeta[k] / (i_zeta[k] + penalty.lambda_zeta + 1e-8)) - .collect(); - let mut slope = g_b * d_b + g_alpha * d_alpha; - for k in 0..latent_dim { - slope += g_zeta[k] * d_zeta[k]; - } - if slope < 1e-20 { - break; - } - let mut step = 1.0_f64; - let mut accepted = false; - for _ in 0..30 { - let cand_b = b[i] + step * d_b; - let cand_alpha = if free_alpha { - alpha[i] + step * d_alpha - } else { - alpha[i] - }; - let cand_zeta: Vec = (0..latent_dim) - .map(|k| zeta_i[k] + step * d_zeta[k]) - .collect(); - let cand_q = item_q( - i, cand_alpha, cand_b, &cand_zeta, tau, estep, ctx, grids, config, factor_id, - penalty, offset, - ); - if cand_q > cur_q + 1e-4 * step * slope { - b[i] = cand_b; - if free_alpha { - alpha[i] = cand_alpha.clamp(-6.0, 3.0); - } - zeta_i = cand_zeta; - cur_q = cand_q; - accepted = true; - break; - } - step *= 0.5; - } - if !accepted { - break; - } - } - zeta[i * latent_dim..(i + 1) * latent_dim].copy_from_slice(&zeta_i); - } -} - -/// Global `tau` (log gamma) update: backtracked Newton-like step on the summed -/// expected objective. Skipped for models without the latent space. -#[allow(clippy::too_many_arguments)] -fn m_step_tau( - alpha: &[f64], - b: &[f64], - zeta: &[f64], - tau: &mut f64, - estep: &EStep, - ctx: &Contexts, - grids: &Grids, - config: &ModelConfig, - factor_id: &[usize], - penalty: &PenaltyConfig, - offset: Option<&[f64]>, -) { - if interaction_kind(config.model_type) != InteractionKind::Distance { - return; - } - let total_q = |tau_c: f64| -> f64 { - let mut q = 0.0; - for i in 0..config.n_items { - q += item_q( - i, - alpha[i], - b[i], - &zeta[i * config.latent_dim..(i + 1) * config.latent_dim], - tau_c, - estep, - ctx, - grids, - config, - factor_id, - penalty, - offset, - ); - } - // item_q already contains per-item penalties; add the tau penalty once. - let dt = tau_c - penalty.mu_tau; - q - 0.5 * penalty.lambda_tau * dt * dt - }; - // Analytic gradient and expected-information Hessian in tau. - let (free_alpha, _) = model_exec_flags(config.model_type); - let (n_items, n_dims, latent_dim) = (config.n_items, config.n_dims, config.latent_dim); - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - let gamma = tau.exp(); - let (mut grad, mut info) = (0.0_f64, 0.0_f64); - for i in 0..n_items { - let d = factor_id[i]; - let a = if free_alpha { alpha[i].exp() } else { 1.0 }; - for s in 0..ctx.n_ctx { - let off = offset.map(|o| o[s * n_items + i]).unwrap_or(0.0); - let (shift, scale) = (ctx.shift[s * n_dims + d], ctx.scale[s * n_dims + d]); - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = shift + scale * node_t; - for x in 0..n_x { - let idx = t * n_x + x; - let n = estep.nbar[(s * n_dims + d) * cell + idx] - - estep.mbar[(s * n_items + i) * cell + idx]; - let r = estep.rbar[(s * n_items + i) * cell + idx]; - if n <= 0.0 && r <= 0.0 { - continue; - } - let x_node = &grids.x_grid[x * latent_dim..(x + 1) * latent_dim]; - let mut dist2 = config.eps_distance; - for k in 0..latent_dim { - let diff = x_node[k] - zeta[i * latent_dim + k]; - dist2 += diff * diff; - } - let dist = dist2.sqrt(); - let eta = off + a * theta + b[i] - gamma * dist; - let prob = sigmoid(eta); - let resid = r - n * prob; - let deta = -gamma * dist; - grad += resid * deta; - info += n * prob * (1.0 - prob) * deta * deta; - } - } - } - } - grad -= penalty.lambda_tau * (*tau - penalty.mu_tau); - info += penalty.lambda_tau; - if info <= 0.0 { - return; - } - let dir = grad / info; - let cur = total_q(*tau); - let mut step = 1.0_f64; - for _ in 0..20 { - let cand = (*tau + step * dir).clamp(-10.0, 5.0); - if total_q(cand) > cur { - *tau = cand; - return; - } - step *= 0.5; - } -} - -/// Newton update for the covariate coefficient `delta` on the expected -/// complete-data log-likelihood (`d eta / d delta = w[s, i]`); backtracked to -/// guarantee a GEM ascent step. -#[allow(clippy::too_many_arguments)] -fn m_step_delta( - alpha: &[f64], - b: &[f64], - zeta: &[f64], - tau: f64, - delta: &mut f64, - w_cov: &[f64], - estep: &EStep, - ctx: &Contexts, - grids: &Grids, - config: &ModelConfig, - factor_id: &[usize], - penalty: &PenaltyConfig, -) { - let (free_alpha, _) = model_exec_flags(config.model_type); - let kind = interaction_kind(config.model_type); - let (n_items, n_dims, latent_dim) = (config.n_items, config.n_dims, config.latent_dim); - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - let eval_q = |delta_c: f64| -> f64 { - let offsets: Vec = w_cov.iter().map(|&w| delta_c * w).collect(); - let mut q = 0.0; - for i in 0..n_items { - q += item_q( - i, - alpha[i], - b[i], - &zeta[i * latent_dim..(i + 1) * latent_dim], - tau, - estep, - ctx, - grids, - config, - factor_id, - penalty, - Some(&offsets), - ); - } - q - }; - let (mut grad, mut info) = (0.0_f64, 0.0_f64); - for i in 0..n_items { - let d = factor_id[i]; - for s in 0..ctx.n_ctx { - let w_si = w_cov[s * n_items + i]; - if w_si == 0.0 { - continue; - } - let off = *delta * w_si; - let (shift, scale) = (ctx.shift[s * n_dims + d], ctx.scale[s * n_dims + d]); - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = shift + scale * node_t; - for x in 0..n_x { - let idx = t * n_x + x; - let n = estep.nbar[(s * n_dims + d) * cell + idx] - - estep.mbar[(s * n_items + i) * cell + idx]; - let r = estep.rbar[(s * n_items + i) * cell + idx]; - if n <= 0.0 && r <= 0.0 { - continue; - } - let eta = off - + eta_at_kind( - alpha, - b, - zeta, - tau, - free_alpha, - kind, - latent_dim, - config.eps_distance, - i, - theta, - &grids.x_grid[x * latent_dim..(x + 1) * latent_dim], - ); - let prob = sigmoid(eta); - grad += (r - n * prob) * w_si; - info += n * prob * (1.0 - prob) * w_si * w_si; - } - } - } - } - if info <= 0.0 { - return; - } - let dir = grad / info; - let cur = eval_q(*delta); - let mut step = 1.0_f64; - for _ in 0..20 { - let cand = (*delta + step * dir).clamp(-10.0, 10.0); - if eval_q(cand) > cur { - *delta = cand; - return; - } - step *= 0.5; - } -} - -/// Rotate `zeta` (and `xi_eap`) so the principal axes of the item configuration -/// align with the coordinate axes (rotation/reflection identifiability; see -/// design doc §4). Deterministic: Jacobi eigen-decomposition of the uncentered -/// second-moment matrix, sign fixed so each axis's largest-|coordinate| item is -/// positive. -fn pca_align(zeta: &mut [f64], xi: &mut [f64], n_items: usize, n_persons: usize, k: usize) { - if k < 2 { - // Only the reflection is free: fix the sign convention. - if k == 1 { - let (mut max_abs, mut sign) = (0.0_f64, 1.0_f64); - for i in 0..n_items { - if zeta[i].abs() > max_abs { - max_abs = zeta[i].abs(); - sign = if zeta[i] >= 0.0 { 1.0 } else { -1.0 }; - } - } - if sign < 0.0 { - zeta.iter_mut().for_each(|z| *z = -*z); - xi.iter_mut().for_each(|z| *z = -*z); - } - } - return; - } - // Uncentered K x K second moment of zeta. - let mut m = vec![0.0_f64; k * k]; - for i in 0..n_items { - for r in 0..k { - for c in 0..k { - m[r * k + c] += zeta[i * k + r] * zeta[i * k + c]; - } - } - } - // Jacobi rotations (K is small: 2 or 3). - let mut rot = vec![0.0_f64; k * k]; - for r in 0..k { - rot[r * k + r] = 1.0; - } - for _ in 0..50 { - let (mut p, mut q, mut off) = (0, 1, 0.0_f64); - for r in 0..k { - for c in (r + 1)..k { - if m[r * k + c].abs() > off { - off = m[r * k + c].abs(); - p = r; - q = c; - } - } - } - if off < 1e-12 { - break; - } - let theta_ang = 0.5 * (2.0 * m[p * k + q]).atan2(m[p * k + p] - m[q * k + q]); - let (c, s) = (theta_ang.cos(), theta_ang.sin()); - for r in 0..k { - let (mrp, mrq) = (m[r * k + p], m[r * k + q]); - m[r * k + p] = c * mrp + s * mrq; - m[r * k + q] = -s * mrp + c * mrq; - } - for col in 0..k { - let (mpc, mqc) = (m[p * k + col], m[q * k + col]); - m[p * k + col] = c * mpc + s * mqc; - m[q * k + col] = -s * mpc + c * mqc; - } - for r in 0..k { - let (rp, rq) = (rot[r * k + p], rot[r * k + q]); - rot[r * k + p] = c * rp + s * rq; - rot[r * k + q] = -s * rp + c * rq; - } - } - // Order columns by descending eigenvalue (diagonal of m). - let mut order: Vec = (0..k).collect(); - order.sort_by(|&a2, &b2| { - m[b2 * k + b2] - .partial_cmp(&m[a2 * k + a2]) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let apply = |data: &mut [f64], n_rows: usize| { - for row in 0..n_rows { - let mut new = vec![0.0_f64; k]; - for (out_c, &src_c) in order.iter().enumerate() { - for r in 0..k { - new[out_c] += data[row * k + r] * rot[r * k + src_c]; - } - } - data[row * k..(row + 1) * k].copy_from_slice(&new); - } - }; - apply(zeta, n_items); - apply(xi, n_persons); - // Sign convention per axis. - for c in 0..k { - let (mut max_abs, mut sign) = (0.0_f64, 1.0_f64); - for i in 0..n_items { - if zeta[i * k + c].abs() > max_abs { - max_abs = zeta[i * k + c].abs(); - sign = if zeta[i * k + c] >= 0.0 { 1.0 } else { -1.0 }; - } - } - if sign < 0.0 { - for i in 0..n_items { - zeta[i * k + c] = -zeta[i * k + c]; - } - for p in 0..n_persons { - xi[p * k + c] = -xi[p * k + c]; - } - } - } -} - -fn validate( - y: &[f64], - observed: &[bool], - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - mcfg: &MarginalConfig, -) -> Result<(), String> { - let n = config - .n_persons - .checked_mul(config.n_items) - .ok_or("n_persons * n_items overflows")?; - if y.len() != n || observed.len() != n { - return Err("y and observed must both have length n_persons * n_items".into()); - } - if factor_id.len() != config.n_items { - return Err("factor_id length must match number of items".into()); - } - if factor_id.iter().any(|&d| d >= config.n_dims) { - return Err("factor_id values must be in 0..n_dims-1".into()); - } - if matches!(config.model_type, ModelType::Uls2plm | ModelType::Ulsrm) && config.n_dims != 1 { - return Err("unidimensional models require n_dims == 1".into()); - } - if config.n_dims == 0 || config.latent_dim == 0 { - return Err("parameter dimensions must be positive".into()); - } - if config.latent_dim > 6 { - return Err("marginal estimator supports latent_dim <= 6".into()); - } - if matches!(mcfg.xi_rule, XiRuleKind::GaussHermite) && config.latent_dim > 3 { - return Err( - "tensor Gauss-Hermite supports latent_dim <= 3; use xi_rule Halton/MonteCarlo".into(), - ); - } - if !config.eps_distance.is_finite() || config.eps_distance <= 0.0 { - return Err("eps_distance must be positive and finite".into()); - } - let mut required_q = vec![mcfg.q_theta, mcfg.q_u]; - if matches!(mcfg.xi_rule, XiRuleKind::GaussHermite) { - required_q.push(mcfg.q_xi); - } - for q in required_q { - if gh_rule(q).is_none() { - return Err(format!( - "unsupported quadrature size {q}; supported: {:?}", - crate::quadrature::SUPPORTED_Q - )); - } - } - if matches!(mcfg.xi_rule, XiRuleKind::Halton | XiRuleKind::MonteCarlo) && mcfg.xi_points == 0 { - return Err("xi_points must be >= 1 for the Halton/MonteCarlo rules".into()); - } - if y.iter() - .zip(observed) - .any(|(&v, &o)| o && v != 0.0 && v != 1.0) - { - return Err("observed responses must be 0 or 1".into()); - } - match pop { - PopulationSpec::Single | PopulationSpec::SingleFree => {} - PopulationSpec::Multigroup { group_id, n_groups } => { - if group_id.len() != config.n_persons { - return Err("group_id length must match n_persons".into()); - } - if *n_groups == 0 || group_id.iter().any(|&g| g >= *n_groups) { - return Err("group_id values must be in 0..n_groups-1".into()); - } - } - PopulationSpec::Multilevel { - cluster_id, - n_clusters, - } => { - if cluster_id.len() != config.n_persons { - return Err("cluster_id length must match n_persons".into()); - } - if *n_clusters == 0 || cluster_id.iter().any(|&c| c >= *n_clusters) { - return Err("cluster_id values must be in 0..n_clusters-1".into()); - } - } - } - Ok(()) -} - -pub(crate) fn validate_inputs( - y: &[f64], - observed: &[bool], - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - mcfg: &MarginalConfig, -) -> Result<(), String> { - validate(y, observed, factor_id, config, pop, mcfg) -} - -/// Marginal EM calibration for the latent-space model family. -/// -/// `y`/`observed` are row-major `n_persons * n_items`; missing cells (where -/// `observed` is false) are excluded from every product — MAR-safe by -/// construction. The `device` routes the E-step hot path: `Cpu` runs the f64 -/// scalar reference; `Gpu`/`Auto` use the wgpu f32 kernels when an adapter is -/// present and otherwise fall back to the CPU path. -#[allow(clippy::too_many_arguments)] -pub fn fit_marginal( - y: &[f64], - observed: &[bool], - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - mcfg: &MarginalConfig, - penalty: &PenaltyConfig, - device: Device, -) -> Result { - fit_marginal_anchored( - y, observed, factor_id, config, pop, mcfg, penalty, device, None, - ) -} - -/// [`fit_marginal`] with optional fixed-item anchors (FIPC, Kim 2006). -/// A single-free population must supply at least two fixed items for every -/// simple-structure trait dimension; this is a necessary identification guard -/// for estimating both that dimension's mean and standard deviation. -#[allow(clippy::too_many_arguments)] -pub fn fit_marginal_anchored( - y: &[f64], - observed: &[bool], - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - mcfg: &MarginalConfig, - penalty: &PenaltyConfig, - device: Device, - anchors: Option<&Anchors>, -) -> Result { - fit_marginal_full( - y, observed, factor_id, config, pop, mcfg, penalty, device, anchors, None, - ) -} - -/// [`fit_marginal_anchored`] plus an optional context-varying item covariate. -#[allow(clippy::too_many_arguments)] -pub fn fit_marginal_full( - y: &[f64], - observed: &[bool], - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - mcfg: &MarginalConfig, - penalty: &PenaltyConfig, - device: Device, - anchors: Option<&Anchors>, - covariate: Option<&ItemCovariate>, -) -> Result { - validate(y, observed, factor_id, config, pop, mcfg)?; - if let Some(a) = anchors { - let (n_items, latent_dim) = (config.n_items, config.latent_dim); - if a.fixed.len() != n_items - || a.alpha.len() != n_items - || a.b.len() != n_items - || a.zeta.len() != n_items * latent_dim - { - return Err("anchors arrays must match n_items (zeta: n_items * latent_dim)".into()); - } - if !a.fixed.iter().any(|&f| f) { - return Err("anchors provided but no item is fixed".into()); - } - for i in 0..n_items { - if a.fixed[i] - && (!a.alpha[i].is_finite() - || !a.b[i].is_finite() - || !a.zeta[i * latent_dim..(i + 1) * latent_dim] - .iter() - .all(|v| v.is_finite())) - { - return Err("fixed anchor alpha/b/zeta values must be finite".into()); - } - } - if a.tau.is_some_and(|tau| !tau.is_finite()) { - return Err("fixed anchor tau must be finite".into()); - } - if matches!(pop, PopulationSpec::SingleFree) { - let mut fixed_per_dim = vec![0usize; config.n_dims]; - for (i, &fixed) in a.fixed.iter().enumerate() { - if fixed { - fixed_per_dim[factor_id[i]] += 1; - } - } - if let Some((d, &count)) = fixed_per_dim - .iter() - .enumerate() - .find(|(_, count)| **count < 2) - { - return Err(format!( - "PopulationSpec::SingleFree requires at least two fixed anchor items per trait dimension; dimension {d} has {count}" - )); - } - } - } - if matches!(pop, PopulationSpec::SingleFree) && anchors.is_none() { - return Err("PopulationSpec::SingleFree (FIPC) requires anchors for identification".into()); - } - let n_ctx_expected = match pop { - PopulationSpec::Multigroup { n_groups, .. } => *n_groups, - PopulationSpec::Multilevel { .. } => 0, // covariate + multilevel unsupported - _ => 1, - }; - if let Some(cov) = covariate { - if n_ctx_expected == 0 { - return Err("item covariates with a multilevel structure are not supported".into()); - } - if cov.w.len() != n_ctx_expected * config.n_items { - return Err("covariate w must be n_ctx x n_items (contexts = groups)".into()); - } - // identification: w must vary within at least one item across contexts - // OR the model must anchor b (single-context covariates are collinear - // with b_i). - if n_ctx_expected == 1 && anchors.is_none() { - return Err( - "a single-context item covariate is collinear with b; use multigroup \ - contexts (booklets) or anchors" - .into(), - ); - } - } - let (_, uses_space) = model_exec_flags(config.model_type); - let (n_persons, n_items, n_dims, latent_dim) = ( - config.n_persons, - config.n_items, - config.n_dims, - config.latent_dim, - ); - - let (t_nodes, t_weights) = gh_rule(mcfg.q_theta).expect("validated"); - let (x_grid, x_logw) = if uses_space { - let rule = match mcfg.xi_rule { - XiRuleKind::GaussHermite => XiRule::GaussHermite { q_xi: mcfg.q_xi }, - XiRuleKind::Halton => XiRule::Halton { - n: mcfg.xi_points, - shift_seed: mcfg.xi_seed, - }, - XiRuleKind::MonteCarlo => XiRule::MonteCarlo { - n: mcfg.xi_points, - seed: mcfg.xi_seed.max(1), - }, - }; - let nodes = build_xi_nodes(rule, latent_dim)?; - (nodes.grid, nodes.logw) - } else { - // MIRT: a single dummy latent-space node at the origin with weight 1. - (vec![0.0; latent_dim], vec![0.0]) - }; - let grids = Grids { - t_nodes: t_nodes.to_vec(), - t_logw: t_weights.iter().map(|w| w.ln()).collect(), - n_x: x_logw.len(), - x_grid, - x_logw, - q_t: mcfg.q_theta, - }; - - // --- Initialization (deterministic) --- - let mut alpha = vec![0.0_f64; n_items]; - let mut b = vec![0.0_f64; n_items]; - for i in 0..n_items { - let (mut num, mut den) = (0.0, 0.0); - for p in 0..n_persons { - let idx = p * n_items + i; - if observed[idx] { - num += y[idx]; - den += 1.0; - } - } - let prop: f64 = if den > 0.0 { - (num / den).clamp(0.02, 0.98) - } else { - 0.5 - }; - b[i] = (prop / (1.0 - prop)).ln(); - } - let mut zeta = vec![0.0_f64; n_items * latent_dim]; - if uses_space && interaction_kind(config.model_type) == InteractionKind::Inner { - // positive-manifold start for loadings: a mixed-sign circle init can - // lock items into a sign-split local optimum - for v in zeta.iter_mut() { - *v = mcfg.init_zeta_radius; - } - } else if uses_space { - for i in 0..n_items { - let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n_items.max(1) as f64); - zeta[i * latent_dim] = mcfg.init_zeta_radius * angle.cos(); - if latent_dim >= 2 { - zeta[i * latent_dim + 1] = mcfg.init_zeta_radius * angle.sin(); - } - if latent_dim >= 3 { - zeta[i * latent_dim + 2] = mcfg.init_zeta_radius * (2.0 * angle).cos() * 0.5; - } - } - } - let mut tau = if interaction_kind(config.model_type) == InteractionKind::Distance { - 0.0 - } else { - -30.0 - }; - let (n_groups, n_clusters) = match pop { - PopulationSpec::Multigroup { n_groups, .. } => (*n_groups, 0), - PopulationSpec::Multilevel { n_clusters, .. } => (0, *n_clusters), - PopulationSpec::Single => (0, 0), - PopulationSpec::SingleFree => (1, 0), - }; - let mut mu = vec![0.0_f64; n_groups * n_dims]; - let mut sigma = vec![1.0_f64; n_groups * n_dims]; - if let Some(a) = anchors { - for i in 0..n_items { - if a.fixed[i] { - alpha[i] = a.alpha[i]; - b[i] = a.b[i]; - zeta[i * latent_dim..(i + 1) * latent_dim] - .copy_from_slice(&a.zeta[i * latent_dim..(i + 1) * latent_dim]); - } - } - if let Some(t) = a.tau { - tau = t; - } - } - let mut sigma_u = if n_clusters > 0 { - mcfg.init_sigma_u - } else { - 0.0 - }; - - let resp = index_responses(y, observed, n_persons, n_items); - // Zero inflation: a person is a structural-zero candidate when every - // OBSERVED response is 0 (persons with no observations stay candidates). - let all_zero: Vec = (0..n_persons).map(|p| resp.pos[p].is_empty()).collect(); - let mut pi_zero = if mcfg.zero_inflation { - let frac = all_zero.iter().filter(|&&z| z).count() as f64 / n_persons.max(1) as f64; - (0.5 * frac).clamp(1e-4, 0.98) - } else { - 0.0 - }; - let mut zero_responsibility: Vec = Vec::new(); - let mut delta = covariate.map(|c| c.init_delta).unwrap_or(0.0); - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - - for _ in 0..mcfg.max_iter { - let ctx = build_contexts(pop, &mu, &sigma, sigma_u, n_dims, mcfg.q_u); - let offsets: Option> = covariate.map(|c| c.w.iter().map(|&w| delta * w).collect()); - let tables = build_tables_offset( - &alpha, - &b, - &zeta, - tau, - config, - factor_id, - &ctx, - &grids, - offsets.as_deref(), - ); - let zi = if mcfg.zero_inflation { - Some((pi_zero, all_zero.as_slice())) - } else { - None - }; - let estep = e_step_device( - device, &tables, &resp, factor_id, config, pop, &ctx, &grids, zi, - ); - loglik_trace.push(estep.loglik); - if mcfg.zero_inflation { - zero_responsibility = estep.zi_resp.clone(); - } - - // The likelihood just evaluated belongs to the current parameters. - // Stop before another M-step so the returned model, trace endpoint, - // information criteria, and zero-inflation responsibilities agree. - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - if (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < mcfg.tol { - converged = true; - break; - } - } - - if mcfg.zero_inflation { - let mean_resp = estep.zi_resp.iter().sum::() / n_persons.max(1) as f64; - pi_zero = mean_resp.clamp(0.0, 0.999); - } - - // M-step: items, then tau, then population parameters. - m_step_items( - &mut alpha, - &mut b, - &mut zeta, - tau, - &estep, - &ctx, - &grids, - config, - factor_id, - penalty, - mcfg.m_steps, - anchors.map(|a| a.fixed.as_slice()), - offsets.as_deref(), - ); - if anchors.and_then(|a| a.tau).is_none() { - m_step_tau( - &alpha, - &b, - &zeta, - &mut tau, - &estep, - &ctx, - &grids, - config, - factor_id, - penalty, - offsets.as_deref(), - ); - } - if let Some(cov) = covariate { - m_step_delta( - &alpha, &b, &zeta, tau, &mut delta, &cov.w, &estep, &ctx, &grids, config, - factor_id, penalty, - ); - } - match pop { - PopulationSpec::Single => {} - PopulationSpec::SingleFree | PopulationSpec::Multigroup { .. } => { - let cell = grids.q_t * grids.n_x; - let g_start = if matches!(pop, PopulationSpec::SingleFree) { - 0 - } else { - 1 - }; - for g in g_start..n_groups { - for d in 0..n_dims { - let (shift, scale) = (mu[g * n_dims + d], sigma[g * n_dims + d]); - let (mut w_sum, mut m1, mut m2) = (0.0_f64, 0.0_f64, 0.0_f64); - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = shift + scale * node_t; - for x in 0..grids.n_x { - let w = estep.nbar[(g * n_dims + d) * cell + t * grids.n_x + x]; - w_sum += w; - m1 += w * theta; - m2 += w * theta * theta; - } - } - if w_sum > 1e-10 { - let mean = m1 / w_sum; - let var = (m2 / w_sum - mean * mean).max(0.01); - mu[g * n_dims + d] = mean; - sigma[g * n_dims + d] = var.sqrt().clamp(0.1, 10.0); - } - } - } - } - PopulationSpec::Multilevel { .. } => { - // validate() guarantees a positive cluster count for this variant. - let e_v2 = estep.sum_e_v2 / n_clusters as f64; - // theta = sigma_u * v + e; EM update of the intercept scale. - sigma_u = (sigma_u * sigma_u * e_v2).sqrt().clamp(0.0, 10.0); - } - } - n_iter += 1; - } - - // --- Final EAP pass with the converged parameters --- - let ctx = build_contexts(pop, &mu, &sigma, sigma_u, n_dims, mcfg.q_u); - let final_offsets: Option> = - covariate.map(|c| c.w.iter().map(|&w| delta * w).collect()); - let tables = build_tables_offset( - &alpha, - &b, - &zeta, - tau, - config, - factor_id, - &ctx, - &grids, - final_offsets.as_deref(), - ); - if !converged { - let zi = if mcfg.zero_inflation { - Some((pi_zero, all_zero.as_slice())) - } else { - None - }; - let final_estep = e_step_device( - device, &tables, &resp, factor_id, config, pop, &ctx, &grids, zi, - ); - loglik_trace.push(final_estep.loglik); - let n = loglik_trace.len(); - if n > 1 && (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < mcfg.tol { - converged = true; - } - if mcfg.zero_inflation { - zero_responsibility = final_estep.zi_resp; - } - } - let cell = grids.q_t * grids.n_x; - let mut l_buf = vec![0.0_f64; n_dims * cell]; - let mut log_zdx = vec![0.0_f64; n_dims * grids.n_x]; - let mut theta_eap = vec![0.0_f64; n_persons * n_dims]; - let mut theta_m2 = vec![0.0_f64; n_persons * n_dims]; - let mut xi_eap = vec![0.0_f64; n_persons * latent_dim]; - let mut u_eap = vec![0.0_f64; n_clusters]; - - // Cluster posteriors for the final parameters (multilevel), plus each - // person's context posterior conditional on engager/IRT membership. - let mut engager_context_post: Vec = Vec::new(); - match pop { - PopulationSpec::Multilevel { - cluster_id, - n_clusters, - } => { - let q_u = ctx.n_ctx; - let mut lp_irt = vec![0.0_f64; n_persons * q_u]; - for p in 0..n_persons { - for v in 0..q_u { - lp_irt[p * q_u + v] = person_pass( - p, - v, - &tables, - &resp, - factor_id, - n_dims, - n_items, - &grids, - &mut l_buf, - &mut log_zdx, - ); - } - } - let (post, engager_post) = multilevel_context_posteriors( - &lp_irt, - &all_zero, - cluster_id, - *n_clusters, - &ctx.u_logw, - if mcfg.zero_inflation { - Some(pi_zero) - } else { - None - }, - ); - engager_context_post = engager_post; - for c in 0..*n_clusters { - for v in 0..q_u { - u_eap[c] += post[c * q_u + v] * sigma_u * ctx.u_nodes[v]; - } - } - } - _ => {} - } - - for p in 0..n_persons { - let (contexts, weights): (Vec, Vec) = match pop { - PopulationSpec::Single | PopulationSpec::SingleFree => (vec![0], vec![1.0]), - PopulationSpec::Multigroup { group_id, .. } => (vec![group_id[p]], vec![1.0]), - PopulationSpec::Multilevel { .. } => { - let q_u = ctx.n_ctx; - ( - (0..q_u).collect(), - engager_context_post[p * q_u..(p + 1) * q_u].to_vec(), - ) - } - }; - for (&s, &w_outer) in contexts.iter().zip(&weights) { - if w_outer < 1e-14 { - continue; - } - let lp = person_pass( - p, - s, - &tables, - &resp, - factor_id, - n_dims, - n_items, - &grids, - &mut l_buf, - &mut log_zdx, - ); - for x in 0..grids.n_x { - let mut lx = grids.x_logw[x] - lp; - for d in 0..n_dims { - lx += log_zdx[d * grids.n_x + x]; - } - let px = w_outer * lx.exp(); - for k in 0..latent_dim { - xi_eap[p * latent_dim + k] += px * grids.x_grid[x * latent_dim + k]; - } - for d in 0..n_dims { - let (shift, scale) = (ctx.shift[s * n_dims + d], ctx.scale[s * n_dims + d]); - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = shift + scale * node_t; - let pt = (grids.t_logw[t] + l_buf[d * cell + t * grids.n_x + x] - - log_zdx[d * grids.n_x + x]) - .exp(); - theta_eap[p * n_dims + d] += px * pt * theta; - theta_m2[p * n_dims + d] += px * pt * theta * theta; - } - } - } - } - } - let theta_sd: Vec = theta_eap - .iter() - .zip(&theta_m2) - .map(|(&m, &m2)| (m2 - m * m).max(0.0).sqrt()) - .collect(); - - if uses_space && anchors.is_none() { - // With anchors the latent-space orientation is inherited from the - // anchor calibration; re-aligning would break comparability. - pca_align(&mut zeta, &mut xi_eap, n_items, n_persons, latent_dim); - } - - Ok(MarginalResult { - n_parameters: n_free_parameters(config, pop, anchors) - + usize::from(mcfg.zero_inflation) - + usize::from(covariate.is_some()), - alpha, - b, - zeta, - tau, - theta_eap, - theta_sd, - xi_eap, - mu, - sigma, - sigma_u, - u_eap, - delta, - pi_zero, - zero_responsibility, - loglik_trace, - n_iter, - converged, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/marginal_xirule_parse_tests.rs"] -mod xirule_parse_tests; - -#[cfg(test)] -#[path = "../../../tests/unit/marginal_covariate_interaction_tests.rs"] -mod covariate_interaction_tests; - -#[cfg(test)] -#[path = "../../../tests/unit/marginal_em_endpoint_tests.rs"] -mod em_endpoint_tests; diff --git a/crates/mlsirm-core/src/mhrm.rs b/crates/mlsirm-core/src/mhrm.rs deleted file mode 100644 index eb4de9f6c..000000000 --- a/crates/mlsirm-core/src/mhrm.rs +++ /dev/null @@ -1,1132 +0,0 @@ -//! Metropolis-Hastings Robbins-Monro (MH-RM) estimation of the confirmatory multidimensional 2PL -//! (Cai, 2010a, 2010b) — a STOCHASTIC-approximation EM that scales item factor analysis to a latent -//! dimensionality where the deterministic Gauss-Hermite (`q^D`) and quasi-Monte-Carlo E-steps of -//! [`crate::twopl::fit_2pl`] become infeasible. -//! -//! The model is the same general compensatory 2PL as [`crate::twopl`]: -//! -//! ```text -//! P(X_ij = 1 | theta_j) = sigmoid( sum_{d in S_i} a_id theta_jd + b_i ), theta_j ~ MVN(0, I_D) -//! ``` -//! -//! but the marginal likelihood integral over `theta_j` is not quadratured. Instead each cycle `k`: -//! -//! 1. **I-step (stochastic imputation).** For each person a short symmetric random-walk Metropolis -//! chain draws `theta_j` from its current posterior `pi_j(theta) proportional to phi(theta; 0, I) -//! prod_i P_i(y_ij | theta)`; the chain is PERSISTENT (warm-started from the previous cycle's -//! draw), so no per-cycle burn-in is needed and a handful of sweeps suffice. The proposal SD `c` -//! is tuned during burn-in toward a target acceptance rate. -//! 2. **RM step (stochastic approximation).** By Fisher's identity the imputed traits give an -//! unbiased-in-the-limit Monte-Carlo estimate `s_k` of the marginal score and a complete-data -//! information `H_k`; a Robbins-Monro recursion smooths `H_k` into `Gamma_k` and takes a single -//! Newton-like step `xi <- xi + gain_k * Gamma_k^{-1} s_k`. The gain follows a constant-gain -//! burn-in (`Metropolis-Hastings stochastic EM` that random-walks into the MLE neighbourhood) then -//! a decreasing `gain_k = 1/(k - k0)^alpha` (`sum gain = inf`, `sum gain^2 < inf`) that converges -//! almost surely to a marginal-score root (Robbins & Monro, 1951; Cai, 2010a). -//! -//! Because the item blocks are conditionally independent given `theta`, the score, information, and -//! RM step are BLOCK-DIAGONAL by item, so the per-item work reuses the closed-form logistic gradient -//! `X'(y - P)` and information `X'WX` directly (no quadrature, `D`-independent per-node cost). Per-item -//! observed-information standard errors follow the Louis (1982) identity -//! `I_obs = E[-d^2 l_c] - Var[d l_c]`, approximated by a parallel RM filter over the convergence stage -//! that subtracts the UNCENTERED per-person score cross-product `sum_p (y - P)^2 X X'` from the -//! complete-data information (the standard single-imputation `m = 1` form). This is NOT the exact -//! Louis missing information: only the AGGREGATE observed score vanishes at the root, so the -//! per-person score means are not removed and a leading-order PSD term is retained — where the block -//! stays positive-definite the resulting SE is CONSERVATIVE (mildly upward-biased), and where the -//! over-subtraction would leave it non-PD the block falls back to the complete-data (Fisher) -//! information. Exact per-person centering would need `m > 1` imputations per cycle (a follow-up). -//! -//! **Identification.** Unit trait variances fix the loading scale, `E[theta] = 0` the intercepts, and -//! a PURE single-dimension anchor item per dimension pins the rotation to the coordinate axes. The -//! remaining per-dimension reflection `(a_i.d, theta_d) -> (-a_i.d, -theta_d)` leaves the likelihood -//! invariant; because the stochastic iterates could otherwise drift between the two mirror modes and -//! corrupt the Robbins-Monro RUNNING AVERAGE of the loadings, the canonical sign (each dimension's -//! largest pure anchor loads positive) is enforced IN-LOOP every cycle — flipping the loading column, -//! the persistent `theta` chain, and the averaged trait together — and once more at the end. -//! -//! **Correlated factors (`estimate_corr`).** With `estimate_corr = false` (default) the factors are -//! ORTHOGONAL (`theta ~ MVN(0, I)`) and the fit is bit-identical to a run with the flag off. With -//! `estimate_corr = true` a free latent CORRELATION matrix `Phi` (unit diagonal) is estimated (Cai, -//! 2010b confirmatory item factor analysis): the MH acceptance prior uses `Phi^{-1}` (recomputed by -//! Cholesky each cycle) and the free off-diagonals ascend the Gaussian-prior objective -//! `Q(Phi) = -0.5[log|Phi| + tr(Phi^{-1} C)]` (`C` the imputed second moment) by a per-cycle -//! Robbins-Monro gradient step, PD-backtracked, reusing the `twopl.rs` correlation machinery -//! (`build_corr`, `sigma_grad`, `chol_lower`, `sym_inv_logdet`, `flip_corr_dim`). The polytomous item -//! families (reusing the `poly.rs` cell gradients as the complete-data score) are a natural extension -//! of the same MH-RM loop. -//! -//! # References (APA 7th ed.) -//! -//! Cai, L. (2010a). High-dimensional exploratory item factor analysis by a Metropolis-Hastings -//! Robbins-Monro algorithm. *Psychometrika, 75*(1), 33–57. https://doi.org/10.1007/s11336-009-9136-x -//! -//! Cai, L. (2010b). Metropolis-Hastings Robbins-Monro algorithm for confirmatory item factor -//! analysis. *Journal of Educational and Behavioral Statistics, 35*(3), 307–335. -//! https://doi.org/10.3102/1076998609353115 -//! -//! Robbins, H., & Monro, S. (1951). A stochastic approximation method. *The Annals of Mathematical -//! Statistics, 22*(3), 400–407. https://doi.org/10.1214/aoms/1177729586 -//! -//! Louis, T. A. (1982). Finding the observed information matrix when using the EM algorithm. *Journal -//! of the Royal Statistical Society: Series B (Methodological), 44*(2), 226–233. -//! https://doi.org/10.1111/j.2517-6161.1982.tb01203.x - -use crate::mmle::{log_sigmoid, sigmoid_stable}; -use crate::poly::solve_small; -use crate::twopl::{build_corr, chol_lower, flip_corr_dim, sigma_grad, sym_inv_logdet}; - -/// Maximum latent dimensions (MH-RM's whole point is high `D`; this only bounds the per-person -/// proposal work and the `D x D`-ish per-item blocks against pathological inputs). -const MHRM_MAX_DIMS: usize = 64; -/// Maximum persons/items product guard on the response allocation. -const MHRM_MAX_CELLS: usize = 200_000_000; -/// Symmetric loading clamp (loadings are NOT floored positive — reverse-keyed / suppressor -/// cross-loadings are representable; the reflection anchor fixes only the global per-dimension sign). -const MHRM_A_BOUND: f64 = 10.0; -/// Maximum polytomous response categories (bounds the per-item softmax work). -const MHRM_MAX_CAT: usize = 64; - -/// Item response family fitted by [`fit_mhrm`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MhrmModel { - /// Binary two-parameter logistic (default). The per-item parameter block is - /// `[a_d for d in S_i, b_i]` and `base_i = sum_d a_id theta_d + b_i`. - TwoPl, - /// Generalized partial credit model (Muraki, 1992) with `n_cat` ordered categories and INTEGER - /// scores `0..n_cat-1`. The per-item block is `[a_d for d in S_i, step_i1..step_i,{n_cat-1}]` - /// (the baseline step `step_i0 = 0` is pinned); `base_i = sum_d a_id theta_d` carries NO - /// intercept — the steps are the softmax category intercepts, `psi_k = k*base_i + step_ik`, - /// `P(Y=k) = softmax_k(psi_k)`. `n_cat >= 2` (`n_cat = 2` is mathematically the 2PL but is fit on - /// the polytomous softmax path, not the exact `log_sigmoid` path). - Gpcm { n_cat: usize }, -} - -impl MhrmModel { - /// Number of free CATEGORY parameters per item (2PL: the single intercept; GPCM: `n_cat - 1` - /// step intercepts). - #[inline] - fn n_free_cat(self) -> usize { - match self { - MhrmModel::TwoPl => 1, - MhrmModel::Gpcm { n_cat } => n_cat - 1, - } - } - /// Response category count (`2` for the binary 2PL). - #[inline] - fn n_cat(self) -> usize { - match self { - MhrmModel::TwoPl => 2, - MhrmModel::Gpcm { n_cat } => n_cat, - } - } -} - -/// Configuration for [`fit_mhrm`]. -#[derive(Clone, Copy, Debug)] -pub struct MhrmConfig { - /// Maximum MH-RM cycles. - pub max_cycles: usize, - /// Constant-gain burn-in cycles `k0` (a Metropolis-Hastings stochastic EM; the proposal SD is - /// tuned here and the decreasing gain starts at `k0 + 1`). - pub burn_in: usize, - /// Metropolis sweeps per person per cycle (`T_MH`; the persistent warm-started chain keeps this - /// small). - pub mh_steps: usize, - /// Initial random-walk proposal SD `c` (`theta* = theta + c * N(0, I)`). - pub proposal_sd: f64, - /// Adapt `c` toward `target_accept` during burn-in (frozen afterwards). - pub adapt_proposal: bool, - /// Target Metropolis acceptance rate for the burn-in proposal tuning (random-walk optimum is - /// ~0.234 in high `D`; the useful band is ~0.2-0.5). - pub target_accept: f64, - /// Constant gain used during burn-in (`gain_k = burn_in_gain` for `k <= burn_in`). - pub burn_in_gain: f64, - /// Decreasing-gain exponent `alpha` in `gain_k = 1/(k - k0)^alpha` (Robbins-Monro needs - /// `alpha in (0.5, 1]`; `1.0` is the canonical `1/(k - k0)`). - pub gain_exponent: f64, - /// Convergence window `w`: stop when the running mean of `||xi^(k) - xi^(k-1)||` over the last - /// `w` post-burn-in cycles falls below `tol` (MH-RM iterates are non-monotone, so a - /// likelihood-decrease guard is NOT used). - pub window: usize, - /// Convergence tolerance on the windowed mean parameter change. - pub tol: f64, - /// Ridge added to the RM information diagonal before the per-item solve (conditioning only). - pub ridge: f64, - /// Accumulate the Louis (1982) observed-information standard errors over the convergence stage. - pub estimate_se: bool, - /// Estimate a free latent CORRELATION matrix `Phi` (`theta ~ MVN(0, Phi)`, unit diagonal; - /// Cai 2010b confirmatory item factor analysis). When `false` (default), `Phi = I` (orthogonal - /// factors) exactly — the orthogonal path is BIT-IDENTICAL to a fit with this off. When `true`, - /// the MH acceptance prior uses `Phi^{-1}` and the free off-diagonals ascend the Gaussian-prior - /// objective by a per-cycle Robbins-Monro gradient step (PD-backtracked). - pub estimate_corr: bool, - /// Item response family: `TwoPl` (default, binary) or `Gpcm { n_cat }` (Muraki 1992 ordered - /// polytomous). The `TwoPl` path is unchanged / bit-identical. - pub model: MhrmModel, - /// PRNG seed (deterministic given the seed). - pub seed: u64, -} - -impl Default for MhrmConfig { - fn default() -> Self { - Self { - max_cycles: 2000, - burn_in: 200, - mh_steps: 5, - proposal_sd: 1.0, - adapt_proposal: true, - target_accept: 0.30, - burn_in_gain: 1.0, - gain_exponent: 1.0, - window: 30, - tol: 1e-3, - ridge: 1e-6, - estimate_se: true, - estimate_corr: false, - model: MhrmModel::TwoPl, - seed: 0x9E37_79B9_7F4A_7C15, - } - } -} - -/// Result of [`fit_mhrm`]. -#[derive(Clone, Debug)] -pub struct MhrmResult { - /// Free loadings `a_id`, row-major `J x D` (exactly `0.0` where `L_id = 0`), per-dimension - /// reflection-canonicalized so each dimension's largest pure anchor loads positive. - pub loading: Vec, - /// Item intercepts `b_i`, length `J` (the 2PL category parameter; EMPTY for GPCM — see `step`). - pub intercept: Vec, - /// GPCM category step intercepts `step_ik`, row-major `J x (n_cat - 1)` (UNORDERED; EMPTY for the - /// binary 2PL, which uses `intercept`). - pub step: Vec, - /// Response category count (`2` for the binary 2PL). - pub n_cat: usize, - /// Per-person trait EAP (Monte-Carlo mean of the imputed draws over the convergence stage), - /// row-major `N x D`. - pub theta: Vec, - pub n_dims: usize, - /// Latent correlation matrix `Phi`, row-major `D x D` (identity when `estimate_corr` is `false`; - /// unit diagonal, estimated off-diagonals otherwise). - pub corr: Vec, - /// Louis (1982) block-diagonal (per-item) observed-information standard errors for the loadings, - /// row-major `J x D` (`0.0` off-pattern; empty when `estimate_se` is `false`). Computed from the - /// uncentered `m = 1` observed information (see the module docs): where the block is - /// positive-definite the SE is mildly CONSERVATIVE (upward-biased); where the missing-information - /// subtraction leaves it non-PD the block falls back to the complete-data (Fisher) information, - /// which OMITS the missing information and so is a mild UNDER-estimate there. - pub se_loading: Vec, - /// Standard errors for the 2PL intercepts, length `J` (empty for GPCM / when `estimate_se` is - /// `false`). - pub se_intercept: Vec, - /// Standard errors for the GPCM step intercepts, row-major `J x (n_cat - 1)` (empty for the 2PL / - /// when `estimate_se` is `false`). - pub se_step: Vec, - /// Final tuned Metropolis acceptance rate. - pub acceptance_rate: f64, - pub n_cycles: usize, - pub converged: bool, - /// `converged` or `max_cycles_reached`. - pub termination_reason: String, - /// Windowed mean parameter change at termination. - pub final_param_change: f64, - /// `#{L_id = 1}` loadings `+ J * (n_cat - 1)` category parameters (`+ D(D-1)/2` free - /// correlations when `estimate_corr`). For the binary 2PL `n_cat - 1 = 1` (one intercept per item). - pub n_parameters: usize, -} - -/// Deterministic LCG + Box-Muller normal (the crate's inline PRNG idiom; production because the MH -/// sampler runs inside the fit, not in tests). -struct Lcg(u64); -impl Lcg { - #[inline] - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - #[inline] - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -/// `log P(y | theta)` for one item given its loaded-dimension parameters and the model family. -#[inline] -fn item_logp( - model: MhrmModel, - params_i: &[f64], - dims_i: &[usize], - theta_p: &[f64], - y: usize, -) -> f64 { - let l = dims_i.len(); - match model { - MhrmModel::TwoPl => { - let mut base = params_i[l]; // intercept b_i is the last slot - for (t, &d) in dims_i.iter().enumerate() { - base += params_i[t] * theta_p[d]; - } - if y == 1 { - log_sigmoid(base) - } else { - log_sigmoid(-base) - } - } - MhrmModel::Gpcm { n_cat } => { - // base = sum_d a_id theta_d (NO intercept); the steps are params_i[l..l+n_cat-1]. - let mut base = 0.0; - for (t, &d) in dims_i.iter().enumerate() { - base += params_i[t] * theta_p[d]; - } - gpcm_logp_at(base, ¶ms_i[l..l + n_cat - 1], y, n_cat) - } - } -} - -/// Scalar `log P(Y = y | theta)` for one GPCM item WITHOUT allocating the full category vector (the -/// MH I-step calls this `O(N * mh_steps * n_items)` per cycle): `psi_k = k*base + step_k` -/// (`step_0 = 0`), `logP(y) = psi_y - logsumexp_k psi_k`. -#[inline] -fn gpcm_logp_at(base: f64, steps: &[f64], y: usize, n_cat: usize) -> f64 { - let psi = |k: usize| -> f64 { (k as f64) * base + if k == 0 { 0.0 } else { steps[k - 1] } }; - let mut m = f64::NEG_INFINITY; - for k in 0..n_cat { - let p = psi(k); - if p > m { - m = p; - } - } - let mut se = 0.0; - for k in 0..n_cat { - se += (psi(k) - m).exp(); - } - psi(y) - (m + se.ln()) -} - -/// Robbins-Monro gain at cycle `k`: the constant `burn_in_gain` during the burn-in -/// (`k <= burn_in`), then the decreasing `1/(k - burn_in)^gain_exponent` (so `sum gain = inf`, -/// `sum gain^2 < inf`; the first convergence-stage cycle `k = burn_in + 1` has gain `1.0`). -#[inline] -pub(crate) fn gain_at(k: usize, burn_in: usize, burn_in_gain: f64, gain_exponent: f64) -> f64 { - if k <= burn_in { - burn_in_gain - } else { - 1.0 / ((k - burn_in) as f64).powf(gain_exponent) - } -} - -/// Per-item complete-data score, complete-data (expected) information `H` (the RM Newton -/// preconditioner), and the Louis missing-information contribution `H - sum_p s_p s_p'`, all at the -/// imputed traits over the observed persons for item `i`. Returns `(score[p_i], H[p_i * p_i], -/// louis[p_i * p_i])`. -/// -/// - `TwoPl`: `X_p = [theta_pd for d in S_i, 1]`, score `= sum_p X_p (y_p - P_p)`, `H = sum_p w_p X_p -/// X_p'` (`w_p = P_p(1-P_p)`, the exact 2PL Hessian), `louis = sum_p (w_p - r_p^2) X_p X_p'`; -/// `p_i = |S_i| + 1`. -/// - `Gpcm { n_cat }`: the CLOSED-FORM multinomial Hessian `H = sum_p J_p' (diag(P) - PP') J_p` -/// (data-INDEPENDENT given theta), where the design row `J_p[k]` is `d psi_k / d param` -/// (`psi_k = k*base + step_k`): `d/d a_id = k*theta_pd`, `d/d step_j = [k == j]`. The score is -/// `sum_p J_p' resid_p` (`resid_pk = [k == y_p] - P_pk`, `g_scores` implicitly dropped since the -/// integer scores are fixed), and `louis = H - sum_p s_p s_p'` (exactly the binary structure). -/// `p_i = |S_i| + (n_cat - 1)` (slopes then the `n_cat-1` step intercepts). -#[allow(clippy::too_many_arguments)] -pub(crate) fn item_score_info( - model: MhrmModel, - params_i: &[f64], - dims_i: &[usize], - theta: &[f64], - y: &[usize], - observed: Option<&[bool]>, - i: usize, - n_persons: usize, - n_items: usize, - n_dims: usize, -) -> (Vec, Vec, Vec) { - let li = dims_i.len(); - match model { - MhrmModel::TwoPl => { - let pi = li + 1; - let mut s = vec![0.0f64; pi]; - let mut hmat = vec![0.0f64; pi * pi]; - let mut hobs = vec![0.0f64; pi * pi]; - let mut x = vec![0.0f64; pi]; - for p in 0..n_persons { - if !observed.map_or(true, |o| o[p * n_items + i]) { - continue; - } - let mut base = params_i[li]; - for (t, &d) in dims_i.iter().enumerate() { - base += params_i[t] * theta[p * n_dims + d]; - } - let pp = sigmoid_stable(base); - let resid = y[p * n_items + i] as f64 - pp; - let w = pp * (1.0 - pp); - let r2 = resid * resid; - for (t, &d) in dims_i.iter().enumerate() { - x[t] = theta[p * n_dims + d]; - } - x[li] = 1.0; - for a in 0..pi { - s[a] += resid * x[a]; - for b in 0..pi { - let xx = x[a] * x[b]; - hmat[a * pi + b] += w * xx; - hobs[a * pi + b] += (w - r2) * xx; - } - } - } - (s, hmat, hobs) - } - MhrmModel::Gpcm { n_cat } => { - let nf = n_cat - 1; - let pi = li + nf; - let mut s = vec![0.0f64; pi]; - let mut hmat = vec![0.0f64; pi * pi]; - let mut hobs = vec![0.0f64; pi * pi]; - // reusable per-person buffers: design J (n_cat x pi), category probs, score, u = J' P - let mut jmat = vec![0.0f64; n_cat * pi]; - let mut pvec = vec![0.0f64; n_cat]; - let mut sc = vec![0.0f64; pi]; - let mut u = vec![0.0f64; pi]; - for p in 0..n_persons { - if !observed.map_or(true, |o| o[p * n_items + i]) { - continue; - } - let mut base = 0.0; - for (t, &d) in dims_i.iter().enumerate() { - base += params_i[t] * theta[p * n_dims + d]; - } - // softmax P over psi_k = k*base + step_k (step_0 = 0) - let mut m = f64::NEG_INFINITY; - for k in 0..n_cat { - let psi = (k as f64) * base + if k == 0 { 0.0 } else { params_i[li + k - 1] }; - pvec[k] = psi; - if psi > m { - m = psi; - } - } - let mut denom = 0.0; - for k in 0..n_cat { - pvec[k] = (pvec[k] - m).exp(); - denom += pvec[k]; - } - for k in 0..n_cat { - pvec[k] /= denom; - } - let yy = y[p * n_items + i]; - // design J[k][a] = d psi_k / d param_a: a-slots k*theta_d, step-slot (li+k-1) = 1 - // (off-diagonal step slots stay 0 -- never written, jmat init 0). - for k in 0..n_cat { - for (t, &d) in dims_i.iter().enumerate() { - jmat[k * pi + t] = (k as f64) * theta[p * n_dims + d]; - } - if k >= 1 { - jmat[k * pi + (li + k - 1)] = 1.0; - } - } - // score sc[a] = sum_k J[k][a] resid_k, resid_k = [k==yy] - P_k; u[a] = sum_k J[k][a] P_k - for a in 0..pi { - let mut sacc = 0.0; - let mut uacc = 0.0; - for k in 0..n_cat { - let jka = jmat[k * pi + a]; - sacc += jka * ((if k == yy { 1.0 } else { 0.0 }) - pvec[k]); - uacc += jka * pvec[k]; - } - sc[a] = sacc; - u[a] = uacc; - s[a] += sacc; - } - // H[a][b] = sum_k J[k][a] P_k J[k][b] - u[a] u[b] ( = J'(diag P - P P')J ); - // louis = H - s_p s_p' - for a in 0..pi { - for b in 0..pi { - let mut jpj = 0.0; - for k in 0..n_cat { - jpj += jmat[k * pi + a] * pvec[k] * jmat[k * pi + b]; - } - let hp = jpj - u[a] * u[b]; - hmat[a * pi + b] += hp; - hobs[a * pi + b] += hp - sc[a] * sc[b]; - } - } - } - (s, hmat, hobs) - } - } -} - -fn backtracked_corr_step(offdiag: &[f64], gain: f64, gradient: &[f64], n_dims: usize) -> Vec { - let mut scale = 1.0; - for _ in 0..12 { - let candidate: Vec = offdiag - .iter() - .zip(gradient) - .map(|(&value, &direction)| value + gain * scale * direction) - .collect(); - if chol_lower(&build_corr(&candidate, n_dims), n_dims).is_some() { - return candidate; - } - scale *= 0.5; - } - offdiag.to_vec() -} - -fn flip_corr_if_estimated(offdiag: &mut [f64], n_dims: usize, dimension: usize, enabled: bool) { - if enabled { - flip_corr_dim(offdiag, n_dims, dimension); - } -} - -#[allow(clippy::too_many_arguments)] -fn canonicalize_final_dimension( - dimension: usize, - n_dims: usize, - n_items: usize, - n_persons: usize, - dims_of: &[Vec], - loading: &mut [f64], - theta: &mut [f64], - offdiag: &mut [f64], - estimate_corr: bool, -) { - let anchor = (0..n_items) - .filter(|&item| dims_of[item].len() == 1 && dims_of[item][0] == dimension) - .max_by(|&left, &right| { - loading[left * n_dims + dimension] - .abs() - .partial_cmp(&loading[right * n_dims + dimension].abs()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - if anchor.is_some_and(|item| loading[item * n_dims + dimension] < 0.0) { - for item in 0..n_items { - loading[item * n_dims + dimension] = -loading[item * n_dims + dimension]; - } - for person in 0..n_persons { - theta[person * n_dims + dimension] = -theta[person * n_dims + dimension]; - } - flip_corr_if_estimated(offdiag, n_dims, dimension, estimate_corr); - } -} - -fn standard_error_from_variance(variance: f64) -> f64 { - if variance.is_finite() && variance > 0.0 { - variance.sqrt() - } else { - f64::NAN - } -} - -#[allow(clippy::too_many_arguments)] -fn validate( - y: &[usize], - observed: Option<&[bool]>, - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - cfg: &MhrmConfig, -) -> Result<(), String> { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(1..=MHRM_MAX_DIMS).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={MHRM_MAX_DIMS}; got {n_dims}" - )); - } - let cells = n_persons - .checked_mul(n_items) - .ok_or("n_persons * n_items overflow")?; - if cells > MHRM_MAX_CELLS { - return Err(format!( - "n_persons * n_items = {cells} exceeds the cap {MHRM_MAX_CELLS}" - )); - } - if y.len() != cells { - return Err(format!("y has {} entries; expected {cells}", y.len())); - } - if let Some(o) = observed { - if o.len() != cells { - return Err(format!( - "observed has {} entries; expected {cells}", - o.len() - )); - } - } - if loading_pattern.len() != n_items * n_dims { - return Err(format!( - "loading_pattern has {} entries; expected {}", - loading_pattern.len(), - n_items * n_dims - )); - } - if loading_pattern.iter().any(|&v| v > 1) { - return Err("loading_pattern entries must be 0 or 1".into()); - } - // every observed response is in 0..n_cat (0/1 for the binary 2PL, 0..K-1 for GPCM). The upper - // bound makes MHRM_MAX_CAT live: the per-item softmax buffers and the coverage `seen` vectors - // below both allocate on the order of n_cat, so an unbounded n_cat is a DoS/OOM vector. - let n_cat = cfg.model.n_cat(); - if !(2..=MHRM_MAX_CAT).contains(&n_cat) { - return Err(format!( - "model n_cat must be in 2..={MHRM_MAX_CAT}; got {n_cat}" - )); - } - for p in 0..n_persons { - for i in 0..n_items { - let seen = observed.map_or(true, |o| o[p * n_items + i]); - if seen && y[p * n_items + i] >= n_cat { - return Err(format!( - "responses must be in 0..{n_cat} where observed; found {}", - y[p * n_items + i] - )); - } - } - } - // GPCM: every declared category must be observed at least once per item, else the - // corresponding step intercept is unidentified (Muraki, 1992). Mirrors gpcm.rs; the - // binary 2PL path skips this (an all-0 or all-1 item is still a valid regularized cell). - if matches!(cfg.model, MhrmModel::Gpcm { .. }) { - for i in 0..n_items { - let mut seen = vec![false; n_cat]; - let mut any = false; - for p in 0..n_persons { - if observed.map_or(true, |o| o[p * n_items + i]) { - any = true; - seen[y[p * n_items + i]] = true; - } - } - if !any { - return Err(format!("item {i} has no observed responses")); - } - if let Some(k) = (0..n_cat).find(|&k| !seen[k]) { - return Err(format!( - "item {i} category {k} is never observed (unidentified GPCM step); \ - every declared category must be observed" - )); - } - } - } - // every item loads at least one dimension; every dimension has a PURE single-dimension anchor - for i in 0..n_items { - if (0..n_dims).all(|d| loading_pattern[i * n_dims + d] == 0) { - return Err(format!( - "item {i} loads no dimension (all-zero pattern row)" - )); - } - } - for d in 0..n_dims { - let has_pure_anchor = (0..n_items).any(|i| { - loading_pattern[i * n_dims + d] == 1 - && (0..n_dims) - .filter(|&d2| loading_pattern[i * n_dims + d2] == 1) - .count() - == 1 - }); - if !has_pure_anchor { - return Err(format!( - "dimension {d} has no pure single-dimension anchor item (rotation not identified)" - )); - } - } - if cfg.max_cycles == 0 || cfg.burn_in >= cfg.max_cycles { - return Err("require 0 < burn_in < max_cycles".into()); - } - if cfg.mh_steps == 0 { - return Err("mh_steps must be positive".into()); - } - if !cfg.proposal_sd.is_finite() || cfg.proposal_sd <= 0.0 { - return Err("proposal_sd must be finite and positive".into()); - } - if !(0.0..=1.0).contains(&cfg.target_accept) { - return Err("target_accept must be in [0, 1]".into()); - } - if !cfg.burn_in_gain.is_finite() || cfg.burn_in_gain <= 0.0 { - return Err("burn_in_gain must be finite and positive".into()); - } - if !(0.5..=1.0).contains(&cfg.gain_exponent) { - return Err("gain_exponent must be in [0.5, 1.0] (Robbins-Monro)".into()); - } - if cfg.window == 0 { - return Err("window must be positive".into()); - } - if !cfg.tol.is_finite() || cfg.tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - if !cfg.ridge.is_finite() || cfg.ridge <= 0.0 { - return Err("ridge must be finite and positive".into()); - } - Ok(()) -} - -/// Fit the confirmatory multidimensional 2PL by Metropolis-Hastings Robbins-Monro (Cai, 2010b). -/// -/// `y` is a row-major `n_persons * n_items` binary (`0/1`) response array; `observed` an optional -/// row-major bool mask (missing dropped MAR). `loading_pattern` is a row-major `n_items * n_dims` -/// 0/1 confirmatory pattern; every dimension needs a pure single-dimension anchor item. -#[allow(clippy::too_many_arguments)] -pub fn fit_mhrm( - y: &[usize], - observed: Option<&[bool]>, - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - cfg: &MhrmConfig, -) -> Result { - validate( - y, - observed, - loading_pattern, - n_persons, - n_items, - n_dims, - cfg, - )?; - - let seen = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - let dims_of: Vec> = (0..n_items) - .map(|i| { - (0..n_dims) - .filter(|&d| loading_pattern[i * n_dims + d] == 1) - .collect() - }) - .collect(); - - let n_cat = cfg.model.n_cat(); - let n_free_cat = cfg.model.n_free_cat(); - // Output routing is keyed on the model FAMILY, not on `n_free_cat`: a GPCM with `n_cat == 2` also - // has `n_free_cat == 1`, and its single step must still land in `step`/`se_step` (not the 2PL - // `intercept`/`se_intercept`) to honor the family-based MhrmResult contract. - let is_2pl = matches!(cfg.model, MhrmModel::TwoPl); - // Init: loadings 1.0 on loaded dims; category parameters from the item's observed category - // frequencies — 2PL: a single log-odds intercept; GPCM: plain per-category log-odds steps - // `step_k = ln(freq_k / freq_0)` (Laplace-smoothed marginal log-odds, matching gpcm.rs init). - let mut params: Vec> = Vec::with_capacity(n_items); - for i in 0..n_items { - let li = dims_of[i].len(); - let mut pv = vec![1.0f64; li]; - match cfg.model { - MhrmModel::TwoPl => { - let mut n_obs = 0usize; - let mut n_pos = 0usize; - for p in 0..n_persons { - if seen(p, i) { - n_obs += 1; - n_pos += usize::from(y[p * n_items + i] == 1); - } - } - let pbar = ((n_pos as f64) + 0.5) / ((n_obs as f64) + 1.0); // Laplace-smoothed - pv.push((pbar / (1.0 - pbar)).ln()); - } - MhrmModel::Gpcm { .. } => { - let mut freq = vec![0.5f64; n_cat]; // Laplace prior - for p in 0..n_persons { - if seen(p, i) { - freq[y[p * n_items + i]] += 1.0; - } - } - for k in 1..n_cat { - pv.push((freq[k] / freq[0]).ln()); - } - } - } - params.push(pv); - } - - // Per-item RM information Gamma_i (flat p_i x p_i, p_i = |S_i| + n_free_cat), init to identity - // (PD); Louis accumulator. `n_free_cat` = 1 for the 2PL (a single intercept) or `n_cat-1` for GPCM. - let mut gamma: Vec> = dims_of - .iter() - .map(|d| { - let p = d.len() + n_free_cat; - let mut m = vec![0.0f64; p * p]; - for a in 0..p { - m[a * p + a] = 1.0; - } - m - }) - .collect(); - let mut gamma_obs: Vec> = dims_of - .iter() - .map(|d| vec![0.0f64; (d.len() + n_free_cat) * (d.len() + n_free_cat)]) - .collect(); - - let mut theta = vec![0.0f64; n_persons * n_dims]; // persistent MH chain state - let mut theta_sum = vec![0.0f64; n_persons * n_dims]; // convergence-stage accumulation - let mut theta_count = 0usize; - - // Latent correlation Phi (Cai 2010b): free off-diagonals + its inverse (precomputed per cycle for - // the MH acceptance prior). When estimate_corr is false, phi_inv stays None so the acceptance - // prior is the bit-identical orthogonal -0.5(||theta*||^2 - ||theta||^2). - let n_off = n_dims * (n_dims - 1) / 2; - let mut offdiag = vec![0.0f64; n_off]; - let mut phi_inv: Option> = if cfg.estimate_corr { - let mut m = vec![0.0f64; n_dims * n_dims]; - for a in 0..n_dims { - m[a * n_dims + a] = 1.0; - } - Some(m) - } else { - None - }; - - let mut rng = Lcg(cfg.seed | 1); - let mut c = cfg.proposal_sd; - let mut converged = false; - let mut n_cycles = 0usize; - let mut final_change = 0.0f64; - let mut acceptance_rate = 0.0f64; - let mut recent: Vec = Vec::with_capacity(cfg.window); - - let mut thstar = vec![0.0f64; n_dims]; - for k in 1..=cfg.max_cycles { - n_cycles = k; - - // ---- I-step: persistent random-walk Metropolis imputation ---- - let mut accepts = 0usize; - let mut trials = 0usize; - for p in 0..n_persons { - for _ in 0..cfg.mh_steps { - // propose theta* = theta + c N(0, I) (identical RNG stream in both prior branches) - for d in 0..n_dims { - thstar[d] = theta[p * n_dims + d] + c * rng.normal(); - } - // prior quadratic-form difference: correlated theta*'Phi^-1 theta* - theta'Phi^-1 theta, - // or the bit-identical orthogonal ||theta*||^2 - ||theta||^2 when phi_inv is None. - let quad = match phi_inv.as_ref() { - Some(pinv) => { - let mut q = 0.0; - for a in 0..n_dims { - for b in 0..n_dims { - q += pinv[a * n_dims + b] - * (thstar[a] * thstar[b] - - theta[p * n_dims + a] * theta[p * n_dims + b]); - } - } - q - } - None => { - let mut q = 0.0; - for d in 0..n_dims { - let cur = theta[p * n_dims + d]; - q += thstar[d] * thstar[d] - cur * cur; - } - q - } - }; - let mut lr = -0.5 * quad; - for i in 0..n_items { - if !seen(p, i) { - continue; - } - let yy = y[p * n_items + i]; - lr += item_logp(cfg.model, ¶ms[i], &dims_of[i], &thstar, yy) - - item_logp( - cfg.model, - ¶ms[i], - &dims_of[i], - &theta[p * n_dims..(p + 1) * n_dims], - yy, - ); - } - trials += 1; - if lr >= 0.0 || rng.next_f64() < lr.exp() { - for d in 0..n_dims { - theta[p * n_dims + d] = thstar[d]; - } - accepts += 1; - } - } - } - acceptance_rate = accepts as f64 / trials.max(1) as f64; - if cfg.adapt_proposal && k <= cfg.burn_in { - // multiplicative proposal tuning toward the target acceptance rate - let adj = 1.0 + 0.5 * (acceptance_rate - cfg.target_accept); - c = (c * adj.clamp(0.7, 1.4)).clamp(1e-3, 20.0); - } - - // ---- RM step: per-item stochastic score/information + Newton update ---- - let gain = gain_at(k, cfg.burn_in, cfg.burn_in_gain, cfg.gain_exponent); - let mut change2 = 0.0f64; - for i in 0..n_items { - let li = dims_of[i].len(); - let pi = li + n_free_cat; - let (s, hmat, hobs) = item_score_info( - cfg.model, - ¶ms[i], - &dims_of[i], - &theta, - y, - observed, - i, - n_persons, - n_items, - n_dims, - ); - // RM information recursion Gamma_i += gain (H_k - Gamma_i) - for idx in 0..pi * pi { - gamma[i][idx] += gain * (hmat[idx] - gamma[i][idx]); - } - // solve (Gamma_i + ridge I) delta = s - let mut a2: Vec> = (0..pi) - .map(|a| (0..pi).map(|b| gamma[i][a * pi + b]).collect()) - .collect(); - for a in 0..pi { - a2[a][a] += cfg.ridge; - } - let delta = solve_small(a2, s.clone()); - for t in 0..pi { - let step = gain * delta[t]; - params[i][t] += step; - change2 += step * step; - } - // clamp only the SLOPE slots (0..|S_i|); the intercept/steps are unbounded - for t in 0..li { - params[i][t] = params[i][t].clamp(-MHRM_A_BOUND, MHRM_A_BOUND); - } - // Louis observed-information accumulation over the convergence stage - if cfg.estimate_se && k > cfg.burn_in { - for idx in 0..pi * pi { - gamma_obs[i][idx] += gain * (hobs[idx] - gamma_obs[i][idx]); - } - } - } - - // ---- in-loop reflection sign fix (keep the RM average in one mirror mode) ---- - for d in 0..n_dims { - let mut anchor: Option = None; - let mut best = 0.0f64; - for i in 0..n_items { - if dims_of[i].len() == 1 && dims_of[i][0] == d { - let a = params[i][0].abs(); - if a > best { - best = a; - anchor = Some(i); - } - } - } - let ai = anchor.expect("validate() guarantees a pure anchor for every dimension"); - // the pure anchor's slope is params[ai][0] (its sole loaded dim is d) - if params[ai][0] < 0.0 { - for i in 0..n_items { - if let Some(t) = dims_of[i].iter().position(|&dd| dd == d) { - params[i][t] = -params[i][t]; - // Keep the RM information accumulators in the SAME mirror mode as the - // loadings: `theta_pd -> -theta_pd` negates row t and column t of the - // outer products `X_p X_p^T` (the (t, t) diagonal is `theta_pd^2`, - // invariant). Without this, a post-burn-in flip would blend +/- oriented - // off-diagonals into the Louis SE accumulator (gamma_obs). - let pi = dims_of[i].len() + n_free_cat; - for a in 0..pi { - if a != t { - gamma[i][a * pi + t] = -gamma[i][a * pi + t]; - gamma[i][t * pi + a] = -gamma[i][t * pi + a]; - gamma_obs[i][a * pi + t] = -gamma_obs[i][a * pi + t]; - gamma_obs[i][t * pi + a] = -gamma_obs[i][t * pi + a]; - } - } - } - } - for p in 0..n_persons { - theta[p * n_dims + d] = -theta[p * n_dims + d]; - theta_sum[p * n_dims + d] = -theta_sum[p * n_dims + d]; - } - // theta_d -> -theta_d negates corr(theta_d, theta_k); keep Phi consistent - // with the flipped chain BEFORE Phi^{-1} is recomputed below. - flip_corr_if_estimated(&mut offdiag, n_dims, d, cfg.estimate_corr); - } - } - - // ---- correlation Phi Robbins-Monro update (Cai, 2010b) ---- - if cfg.estimate_corr && n_off > 0 { - // Sample second moment C = (1/N) sum_p theta_p theta_p^T at the imputed traits. RAW / - // uncentered: E[theta] = 0 is fixed by identification, so this IS the covariance; do NOT - // standardize to a correlation (that would double-apply the unit-diagonal constraint and - // bias Phi). Matches twopl::fit_2pl's C exactly. - let mut cmat = vec![0.0f64; n_dims * n_dims]; - for p in 0..n_persons { - for a in 0..n_dims { - let ta = theta[p * n_dims + a]; - for b in 0..n_dims { - cmat[a * n_dims + b] += ta * theta[p * n_dims + b]; - } - } - } - for x in cmat.iter_mut() { - *x /= n_persons as f64; - } - let phi = build_corr(&offdiag, n_dims); - // ponytail: BARE gradient Robbins-Monro step on the free off-diagonals (ascent of the - // Gaussian-prior objective Q(Phi) = -0.5[log|Phi| + tr(Phi^{-1} C)]), NOT Cai's - // Newton-preconditioned covariance update. The RM gain still gives a.s. convergence to the - // same Phi root; only the (un-curvature-adapted) rate differs. Upgrade path: precondition - // by the Q off-diagonal Hessian if the rate matters. PD is kept by BACKTRACKING the step - // (halve until the rebuilt Phi is positive-definite), preferred over a full reject to - // avoid frozen cycles near the PD boundary at high |rho|. - if let Some(g) = sigma_grad(&phi, &cmat, n_dims) { - offdiag = backtracked_corr_step(&offdiag, gain, &g, n_dims); - } - // recompute Phi^{-1} for the next cycle's I-step (keep previous if somehow non-PD) - if let Some((inv, _)) = sym_inv_logdet(&build_corr(&offdiag, n_dims), n_dims) { - phi_inv = Some(inv); - } - } - - // ---- convergence-stage trait accumulation + windowed stopping ---- - if k > cfg.burn_in { - for idx in 0..n_persons * n_dims { - theta_sum[idx] += theta[idx]; - } - theta_count += 1; - } - let change = change2.sqrt(); - recent.push(change); - if recent.len() > cfg.window { - recent.remove(0); - } - // Report the same windowed statistic that defines convergence. Returning only the most - // recent stochastic step can exceed `tol` even when the window mean legitimately converged. - final_change = recent.iter().sum::() / recent.len() as f64; - if k > cfg.burn_in && recent.len() == cfg.window { - if final_change < cfg.tol { - converged = true; - break; - } - } - } - - // ---- assemble outputs ---- - let mut loading = vec![0.0f64; n_items * n_dims]; - // 2PL: `intercept` (length J); GPCM: `step` (row-major J x (n_cat-1)); the unused one stays empty. - let mut intercept = if is_2pl { - vec![0.0f64; n_items] - } else { - Vec::new() - }; - let mut step = if is_2pl { - Vec::new() - } else { - vec![0.0f64; n_items * n_free_cat] - }; - for i in 0..n_items { - let li = dims_of[i].len(); - for (t, &d) in dims_of[i].iter().enumerate() { - loading[i * n_dims + d] = params[i][t]; - } - if is_2pl { - intercept[i] = params[i][li]; - } else { - for j in 0..n_free_cat { - step[i * n_free_cat + j] = params[i][li + j]; - } - } - } - let mut theta_eap = theta_sum - .iter() - .map(|v| v / theta_count as f64) - .collect::>(); - - // final reflection canonicalization (idempotent given the in-loop fix; also aligns theta_eap) - for d in 0..n_dims { - canonicalize_final_dimension( - d, - n_dims, - n_items, - n_persons, - &dims_of, - &mut loading, - &mut theta_eap, - &mut offdiag, - cfg.estimate_corr, - ); - } - let corr = build_corr(&offdiag, n_dims); - - // Louis SEs: SE = sqrt(diag((Gamma_obs + ridge I)^{-1})) per item block - let (mut se_loading, mut se_intercept, mut se_step) = (Vec::new(), Vec::new(), Vec::new()); - if cfg.estimate_se { - se_loading = vec![0.0f64; n_items * n_dims]; - if is_2pl { - se_intercept = vec![0.0f64; n_items]; - } else { - se_step = vec![0.0f64; n_items * n_free_cat]; - } - for i in 0..n_items { - let li = dims_of[i].len(); - let pi = li + n_free_cat; - let block = |src: &[f64]| -> Vec> { - let mut m: Vec> = (0..pi) - .map(|a| (0..pi).map(|b| src[a * pi + b]).collect()) - .collect(); - for a in 0..pi { - m[a][a] += cfg.ridge; - } - m - }; - let diag_inv = |m: &[Vec]| -> Vec { - (0..pi) - .map(|t| { - let mut e = vec![0.0f64; pi]; - e[t] = 1.0; - solve_small(m.to_vec(), e)[t] - }) - .collect::>() - }; - // Louis observed information; if any variance is non-PD, fall back to the complete-data - // (Fisher) information block for the whole item (conservative SE). - let obs = block(&gamma_obs[i]); - let mut var = diag_inv(&obs); - if var.iter().any(|v| !v.is_finite() || *v <= 0.0) { - var = diag_inv(&block(&gamma[i])); - } - for t in 0..pi { - let se = standard_error_from_variance(var[t]); - if t < li { - se_loading[i * n_dims + dims_of[i][t]] = se; - } else if is_2pl { - se_intercept[i] = se; - } else { - se_step[i * n_free_cat + (t - li)] = se; - } - } - } - } - - let n_free_loadings = loading_pattern.iter().filter(|&&v| v == 1).count(); - let n_corr = if cfg.estimate_corr { n_off } else { 0 }; - Ok(MhrmResult { - loading, - intercept, - step, - n_cat, - theta: theta_eap, - n_dims, - corr, - se_loading, - se_intercept, - se_step, - acceptance_rate, - n_cycles, - converged, - termination_reason: if converged { - "converged" - } else { - "max_cycles_reached" - } - .into(), - final_param_change: final_change, - n_parameters: n_free_loadings + n_items * n_free_cat + n_corr, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/mhrm_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/mixed.rs b/crates/mlsirm-core/src/mixed.rs deleted file mode 100644 index 190418f16..000000000 --- a/crates/mlsirm-core/src/mixed.rs +++ /dev/null @@ -1,1197 +0,0 @@ -//! Mixed-format marginal maximum-likelihood calibration. -//! -//! Each item keeps its own conditional response function while all items share -//! the same standard-normal trait distribution. LSIRM items additionally share -//! a standard-normal latent-space coordinate; non-spatial items are constant on -//! that integration axis and therefore integrate it out exactly. -//! -//! The heterogeneous likelihood is the product of the item-specific cells, as -//! in the random-coefficients multinomial-logit framework and `mirt`'s per-item -//! `itemtype` contract. The ideal-point, GGUM, nominal, and LSIRM formulas are -//! not blended into a surrogate common formula. -//! GGUM observed-category probabilities pair the two subjective categories -//! `z` and `M-z` and use the model's symmetric threshold sequence (Roberts et -//! al., 2000). -//! -//! # References -//! -//! Adams, R. J., Wilson, M., & Wang, W.-C. (1997). The multidimensional random -//! coefficients multinomial logit model. *Applied Psychological Measurement, -//! 21*(1), 1–23. https://doi.org/10.1177/0146621697211001 -//! -//! Barton, M. A., & Lord, F. M. (1981). An upper asymptote for the -//! three-parameter logistic item-response model. *ETS Research Report Series, -//! 1981*(1), i–8. https://doi.org/10.1002/j.2333-8504.1981.tb01255.x -//! -//! Bock, R. D. (1972). Estimating item parameters and latent ability when -//! responses are scored in two or more nominal categories. *Psychometrika, -//! 37*(1), 29–51. https://doi.org/10.1007/BF02291411 -//! -//! Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping -//! unobserved item-respondent interactions: A latent space item response model -//! with interaction map. *Psychometrika, 86*(2), 378–403. -//! https://doi.org/10.1007/s11336-021-09762-5 -//! -//! Masters, G. N. (1982). A Rasch model for partial credit scoring. -//! *Psychometrika, 47*(2), 149–174. https://doi.org/10.1007/BF02296272 -//! -//! Maydeu-Olivares, A., Hernández, A., & McDonald, R. P. (2006). A -//! multidimensional ideal point item response theory model for binary data. -//! *Multivariate Behavioral Research, 41*(4), 445–472. -//! https://doi.org/10.1207/s15327906mbr4104_2 -//! -//! Roberts, J. S., Donoghue, J. R., & Laughlin, J. E. (1998). The generalized -//! graded unfolding model: A general parametric item response model for -//! unfolding graded responses. *ETS Research Report Series, 1998*(2), i–53. -//! https://doi.org/10.1002/j.2333-8504.1998.tb01781.x -//! -//! Roberts, J. S., Donoghue, J. R., & Laughlin, J. E. (2000). A general item -//! response theory model for unfolding unidimensional polytomous responses. -//! *Applied Psychological Measurement, 24*(1), 3–32. -//! https://doi.org/10.1177/01466216000241001 -//! -//! Shim, H., Bonifay, W., & Wiedermann, W. (2023). Parsimonious asymmetric -//! item response theory modeling with the complementary log-log link. -//! *Behavior Research Methods, 55*(1), 200–219. -//! https://doi.org/10.3758/s13428-022-01824-5 -//! -//! Tutz, G. (1990). Sequential item response models with an ordered response. -//! *British Journal of Mathematical and Statistical Psychology, 43*(1), -//! 39–55. https://doi.org/10.1111/j.2044-8317.1990.tb00925.x - -use std::thread; - -use crate::poly::{gpcm_logprobs, grm_logprobs, solve_small}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MixedItemKind { - Rasch, - TwoPl, - ThreePl, - ThreePlUpper, - FourPl, - Cll, - Grm, - Pcm, - Gpcm, - Sequential, - Tutz, - Nominal, - Ideal, - Ggum, - Lsirm, - LsirmGrm, - LsirmGpcm, -} - -impl MixedItemKind { - pub fn parse(value: &str) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "rasch" | "1pl" => Ok(Self::Rasch), - "2pl" | "dichotomous" | "binary" => Ok(Self::TwoPl), - "3pl" => Ok(Self::ThreePl), - "3plu" | "upper_3pl" => Ok(Self::ThreePlUpper), - "4pl" => Ok(Self::FourPl), - "cll" | "complementary_log_log" => Ok(Self::Cll), - "grm" | "graded" => Ok(Self::Grm), - "pcm" | "partial_credit" => Ok(Self::Pcm), - "gpcm" => Ok(Self::Gpcm), - "sequential" => Ok(Self::Sequential), - "tutz" => Ok(Self::Tutz), - "nominal" | "nrm" => Ok(Self::Nominal), - "ideal" | "ideal_point" => Ok(Self::Ideal), - "ggum" => Ok(Self::Ggum), - "lsirm" | "lsirm_2pl" => Ok(Self::Lsirm), - "lsirm_grm" => Ok(Self::LsirmGrm), - "lsirm_gpcm" => Ok(Self::LsirmGpcm), - other => Err(format!( - "unsupported mixed item model {other:?}; expected one of: rasch, 2pl, 3pl, 3plu, 4pl, cll, grm, pcm, gpcm, sequential, tutz, nominal, ideal, ggum, lsirm, lsirm_grm, lsirm_gpcm" - )), - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Rasch => "rasch", - Self::TwoPl => "2pl", - Self::ThreePl => "3pl", - Self::ThreePlUpper => "3plu", - Self::FourPl => "4pl", - Self::Cll => "cll", - Self::Grm => "grm", - Self::Pcm => "pcm", - Self::Gpcm => "gpcm", - Self::Sequential => "sequential", - Self::Tutz => "tutz", - Self::Nominal => "nominal", - Self::Ideal => "ideal", - Self::Ggum => "ggum", - Self::Lsirm => "lsirm", - Self::LsirmGrm => "lsirm_grm", - Self::LsirmGpcm => "lsirm_gpcm", - } - } - - fn is_spatial(self) -> bool { - matches!(self, Self::Lsirm | Self::LsirmGrm | Self::LsirmGpcm) - } - - fn has_free_slope(self) -> bool { - matches!( - self, - Self::TwoPl - | Self::ThreePl - | Self::ThreePlUpper - | Self::FourPl - | Self::Grm - | Self::Gpcm - | Self::Sequential - | Self::Ideal - | Self::Ggum - | Self::Lsirm - | Self::LsirmGrm - | Self::LsirmGpcm - ) - } - - fn requires_binary(self) -> bool { - matches!( - self, - Self::Rasch - | Self::TwoPl - | Self::ThreePl - | Self::ThreePlUpper - | Self::FourPl - | Self::Cll - | Self::Ideal - | Self::Lsirm - ) - } -} - -#[derive(Clone, Debug)] -pub struct MixedItemSpec { - pub kind: MixedItemKind, - pub n_categories: usize, -} - -#[derive(Clone, Debug)] -pub struct MixedItemEstimate { - pub kind: MixedItemKind, - pub n_categories: usize, - pub slope: Option, - pub intercepts: Vec, - pub thresholds: Vec, - pub scores: Vec, - pub location: Option, - pub lower_asymptote: Option, - pub upper_asymptote: Option, - pub zeta: Vec, -} - -#[derive(Clone, Debug)] -pub struct MixedFit { - pub items: Vec, - pub theta_eap: Vec, - pub theta_sd: Vec, - pub xi_eap: Vec, - pub latent_dim: usize, - pub loglik: f64, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub n_threads: usize, -} - -#[derive(Clone)] -struct Grid { - theta: Vec, - theta_logw: Vec, - xi: Vec, - xi_logw: Vec, - latent_dim: usize, - n_xi: usize, -} - -impl Grid { - fn cell(&self) -> usize { - self.theta.len() * self.n_xi - } -} - -fn tensor_grid(q_xi: usize, latent_dim: usize) -> Result<(Vec, Vec), String> { - let (nodes, weights) = - crate::quadrature::gh_rule(q_xi).ok_or_else(|| format!("unsupported q_xi {q_xi}"))?; - let n_xi = nodes - .len() - .checked_pow(latent_dim as u32) - .ok_or("q_xi ** latent_dim overflow")?; - if n_xi > 200_000 { - return Err("q_xi ** latent_dim exceeds the tensor-grid limit".into()); - } - let mut grid = vec![0.0; n_xi * latent_dim]; - let mut logw = vec![0.0; n_xi]; - for idx in 0..n_xi { - let mut rem = idx; - for d in 0..latent_dim { - let j = rem % nodes.len(); - rem /= nodes.len(); - grid[idx * latent_dim + d] = nodes[j]; - logw[idx] += weights[j].ln(); - } - } - Ok((grid, logw)) -} - -fn build_grid( - specs: &[MixedItemSpec], - latent_dim: usize, - q_theta: usize, - q_xi: usize, -) -> Result { - let (theta, theta_w) = crate::quadrature::gh_rule(q_theta) - .ok_or_else(|| format!("unsupported q_theta {q_theta}"))?; - let spatial = specs.iter().any(|s| s.kind.is_spatial()); - let (xi, xi_logw, used_dim) = if spatial { - if !(1..=3).contains(&latent_dim) { - return Err("latent_dim must be in 1..=3 when LSIRM items are present".into()); - } - let (x, w) = tensor_grid(q_xi, latent_dim)?; - (x, w, latent_dim) - } else { - (Vec::new(), vec![0.0], 0) - }; - Ok(Grid { - theta: theta.to_vec(), - theta_logw: theta_w.iter().map(|w| w.ln()).collect(), - xi, - n_xi: xi_logw.len(), - xi_logw, - latent_dim: used_dim, - }) -} - -fn ordered_values(raw: &[f64]) -> Vec { - if raw.is_empty() { - return Vec::new(); - } - let mut values = Vec::with_capacity(raw.len()); - values.push(raw[0]); - for &log_gap in &raw[1..] { - let gap = log_gap.clamp(-8.0, 5.0).exp().max(1e-4); - values.push(values.last().copied().unwrap() - gap); - } - values -} - -fn ordered_raw(values: &[f64]) -> Vec { - if values.is_empty() { - return Vec::new(); - } - let mut raw = Vec::with_capacity(values.len()); - raw.push(values[0]); - for pair in values.windows(2) { - raw.push((pair[0] - pair[1]).max(1e-4).ln()); - } - raw -} - -fn logaddexp(a: f64, b: f64) -> f64 { - let m = a.max(b); - m + ((a - m).exp() + (b - m).exp()).ln() -} - -fn softmax_log(scores: &[f64]) -> Vec { - let m = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max); - let z: f64 = scores.iter().map(|v| (v - m).exp()).sum(); - scores.iter().map(|v| v - m - z.ln()).collect() -} - -fn logistic(value: f64) -> f64 { - if value >= 0.0 { - 1.0 / (1.0 + (-value).exp()) - } else { - let exp_value = value.exp(); - exp_value / (1.0 + exp_value) - } -} - -fn logit(probability: f64) -> f64 { - let probability = probability.clamp(1e-6, 1.0 - 1e-6); - (probability / (1.0 - probability)).ln() -} - -fn binary_logprobs(probability: f64) -> Vec { - let probability = probability.clamp(1e-15, 1.0 - 1e-15); - vec![(-probability).ln_1p(), probability.ln()] -} - -fn asymptotes(kind: MixedItemKind, params: &[f64]) -> (f64, f64) { - match kind { - MixedItemKind::ThreePl => (logistic(params[2]), 1.0), - MixedItemKind::ThreePlUpper => (0.0, logistic(params[2])), - MixedItemKind::FourPl => { - let lower = logistic(params[2]); - let upper = lower + (1.0 - lower) * logistic(params[3]); - (lower, upper) - } - _ => (0.0, 1.0), - } -} - -fn sequential_logprobs(base: f64, transitions: &[f64]) -> Vec { - let mut out = Vec::with_capacity(transitions.len() + 1); - let mut reached = 0.0; - for &intercept in transitions { - let eta = base + intercept; - let log_pass = -logaddexp(0.0, -eta); - let log_stop = -logaddexp(0.0, eta); - out.push(reached + log_stop); - reached += log_pass; - } - out.push(reached); - out -} - -fn distance(xi: &[f64], zeta: &[f64]) -> f64 { - let d2 = xi - .iter() - .zip(zeta) - .map(|(x, z)| (x - z) * (x - z)) - .sum::(); - (d2 + 1e-8).sqrt() -} - -fn item_logprobs( - spec: &MixedItemSpec, - params: &[f64], - theta: f64, - xi: &[f64], - latent_dim: usize, -) -> Vec { - let k = spec.n_categories; - match spec.kind { - MixedItemKind::Rasch => { - let probability = logistic(theta - params[0]); - binary_logprobs(probability) - } - MixedItemKind::TwoPl => { - let a = params[0].clamp(-5.0, 4.0).exp(); - gpcm_logprobs(a * theta, &[0.0, 1.0], &[0.0, params[1]]) - } - MixedItemKind::ThreePl | MixedItemKind::ThreePlUpper | MixedItemKind::FourPl => { - let a = params[0].clamp(-5.0, 4.0).exp(); - let core = logistic(a * theta + params[1]); - let (lower, upper) = asymptotes(spec.kind, params); - binary_logprobs(lower + (upper - lower) * core) - } - MixedItemKind::Cll => { - let exp_eta = (theta - params[0]).clamp(-40.0, 40.0).exp(); - binary_logprobs(-(-exp_eta).exp_m1()) - } - MixedItemKind::Grm => { - let a = params[0].clamp(-5.0, 4.0).exp(); - grm_logprobs(a * theta, &ordered_values(¶ms[1..])) - } - MixedItemKind::Pcm => { - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0; k]; - intercepts[1..].copy_from_slice(¶ms[..k - 1]); - gpcm_logprobs(theta, &scores, &intercepts) - } - MixedItemKind::Gpcm => { - let a = params[0].clamp(-5.0, 4.0).exp(); - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0; k]; - intercepts[1..].copy_from_slice(¶ms[1..k]); - gpcm_logprobs(a * theta, &scores, &intercepts) - } - MixedItemKind::Nominal => { - let c = k - 1; - let mut scores = vec![0.0; k]; - let mut intercepts = vec![0.0; k]; - scores[1..].copy_from_slice(¶ms[..c]); - intercepts[1..].copy_from_slice(¶ms[c..2 * c]); - gpcm_logprobs(theta, &scores, &intercepts) - } - MixedItemKind::Sequential => { - let a = params[0].clamp(-5.0, 4.0).exp(); - sequential_logprobs(a * theta, ¶ms[1..]) - } - MixedItemKind::Tutz => sequential_logprobs(theta, params), - MixedItemKind::Ideal => { - let a = params[0].clamp(-5.0, 4.0).exp(); - let z = a * (theta - params[1]); - let p1 = (-0.5 * z * z).exp().clamp(1e-15, 1.0 - 1e-15); - vec![(-p1).ln_1p(), p1.ln()] - } - MixedItemKind::Ggum => { - let a = params[0].clamp(-5.0, 4.0).exp(); - let delta = params[1]; - let thresholds = ordered_values(¶ms[2..]); - let c = k - 1; - let m = 2 * c + 1; - let mut tau = vec![0.0; m + 1]; - tau[1..=c].copy_from_slice(&thresholds); - for z in 1..=c { - tau[m - z + 1] = -thresholds[z - 1]; - } - let mut cumulative_tau = 0.0; - let mut log_f = Vec::with_capacity(m + 1); - for (w, &tau_w) in tau.iter().enumerate() { - cumulative_tau += tau_w; - log_f.push(a * (w as f64 * (theta - delta) - cumulative_tau)); - } - let paired: Vec = (0..=c).map(|z| logaddexp(log_f[z], log_f[m - z])).collect(); - softmax_log(&paired) - } - MixedItemKind::Lsirm => { - let a = params[0].clamp(-5.0, 4.0).exp(); - let cat_n = k - 1; - let zeta = ¶ms[1 + cat_n..1 + cat_n + latent_dim]; - let base = a * theta - distance(xi, zeta); - gpcm_logprobs(base, &[0.0, 1.0], &[0.0, params[1]]) - } - MixedItemKind::LsirmGrm => { - let a = params[0].clamp(-5.0, 4.0).exp(); - let zeta = ¶ms[k..k + latent_dim]; - let base = a * theta - distance(xi, zeta); - grm_logprobs(base, &ordered_values(¶ms[1..k])) - } - MixedItemKind::LsirmGpcm => { - let a = params[0].clamp(-5.0, 4.0).exp(); - let zeta = ¶ms[k..k + latent_dim]; - let base = a * theta - distance(xi, zeta); - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0; k]; - intercepts[1..].copy_from_slice(¶ms[1..k]); - gpcm_logprobs(base, &scores, &intercepts) - } - } -} - -fn parameter_count(spec: &MixedItemSpec, latent_dim: usize) -> usize { - match spec.kind { - MixedItemKind::Rasch | MixedItemKind::Cll => 1, - MixedItemKind::TwoPl | MixedItemKind::Ideal => 2, - MixedItemKind::ThreePl | MixedItemKind::ThreePlUpper => 3, - MixedItemKind::FourPl => 4, - MixedItemKind::Grm | MixedItemKind::Gpcm => spec.n_categories, - MixedItemKind::Pcm | MixedItemKind::Tutz => spec.n_categories - 1, - MixedItemKind::Sequential => spec.n_categories, - MixedItemKind::Nominal => 2 * (spec.n_categories - 1), - MixedItemKind::Ggum => 2 + spec.n_categories - 1, - MixedItemKind::Lsirm | MixedItemKind::LsirmGrm | MixedItemKind::LsirmGpcm => { - spec.n_categories + latent_dim - } - } -} - -fn initial_params( - spec: &MixedItemSpec, - freq: &[f64], - item: usize, - n_items: usize, - latent_dim: usize, -) -> Vec { - let k = spec.n_categories; - let mut p = vec![0.0; parameter_count(spec, latent_dim)]; - match spec.kind { - MixedItemKind::Rasch => { - p[0] = (freq[0] / freq[1]).ln(); - } - MixedItemKind::TwoPl | MixedItemKind::Lsirm => { - p[1] = (freq[1] / freq[0]).ln(); - } - MixedItemKind::ThreePl | MixedItemKind::ThreePlUpper | MixedItemKind::FourPl => { - let observed = freq[1].clamp(1e-4, 1.0 - 1e-4); - let lower = if matches!(spec.kind, MixedItemKind::ThreePl | MixedItemKind::FourPl) { - 0.05 - } else { - 0.0 - }; - let upper = if matches!( - spec.kind, - MixedItemKind::ThreePlUpper | MixedItemKind::FourPl - ) { - 0.95 - } else { - 1.0 - }; - p[1] = logit((observed - lower) / (upper - lower)); - if spec.kind == MixedItemKind::ThreePl { - p[2] = logit(lower); - } else if spec.kind == MixedItemKind::ThreePlUpper { - p[2] = logit(upper); - } else { - p[2] = logit(lower); - p[3] = logit((upper - lower) / (1.0 - lower)); - } - } - MixedItemKind::Cll => { - let probability = freq[1].clamp(1e-6, 1.0 - 1e-6); - p[0] = -(-(-probability).ln_1p()).ln(); - } - MixedItemKind::Grm | MixedItemKind::LsirmGrm => { - let mut thresholds = vec![0.0; k - 1]; - let mut cumulative = 0.0; - for category in (1..k).rev() { - cumulative += freq[category]; - let c = cumulative.clamp(1e-4, 1.0 - 1e-4); - thresholds[category - 1] = (c / (1.0 - c)).ln(); - } - p[1..k].copy_from_slice(&ordered_raw(&thresholds)); - } - MixedItemKind::Pcm => { - for category in 1..k { - p[category - 1] = (freq[category] / freq[0]).ln(); - } - } - MixedItemKind::Gpcm | MixedItemKind::LsirmGpcm => { - for category in 1..k { - p[category] = (freq[category] / freq[0]).ln(); - } - } - MixedItemKind::Nominal => { - let c = k - 1; - for category in 1..k { - p[category - 1] = category as f64; - p[c + category - 1] = (freq[category] / freq[0]).ln(); - } - } - MixedItemKind::Sequential => { - for transition in 1..k { - let reached: f64 = freq[transition - 1..].iter().sum(); - let passed: f64 = freq[transition..].iter().sum(); - p[transition] = logit(passed / reached); - } - } - MixedItemKind::Tutz => { - for transition in 1..k { - let reached: f64 = freq[transition - 1..].iter().sum(); - let passed: f64 = freq[transition..].iter().sum(); - p[transition - 1] = logit(passed / reached); - } - } - MixedItemKind::Ideal => { - p[0] = 0.0; - p[1] = if freq[1] < 0.4 { 1.0 } else { 0.0 }; - } - MixedItemKind::Ggum => { - p[0] = 0.0; - p[1] = 0.0; - let thresholds: Vec = (0..k - 1).map(|j| 1.0 - 0.5 * j as f64).collect(); - p[2..].copy_from_slice(&ordered_raw(&thresholds)); - } - } - if spec.kind.is_spatial() { - let start = p.len() - latent_dim; - let angle = 2.0 * std::f64::consts::PI * item as f64 / n_items.max(1) as f64; - p[start] = 0.5 * angle.cos(); - if latent_dim > 1 { - p[start + 1] = 0.5 * angle.sin(); - } - if latent_dim > 2 { - p[start + 2] = 0.25 * (2.0 * angle).cos(); - } - } - p -} - -fn item_table(spec: &MixedItemSpec, params: &[f64], grid: &Grid) -> Vec { - let mut table = vec![0.0; grid.cell() * spec.n_categories]; - for (t, &theta) in grid.theta.iter().enumerate() { - for x in 0..grid.n_xi { - let xi = if grid.latent_dim == 0 { - &[][..] - } else { - &grid.xi[x * grid.latent_dim..(x + 1) * grid.latent_dim] - }; - let lp = item_logprobs(spec, params, theta, xi, grid.latent_dim); - let node = t * grid.n_xi + x; - table[node * spec.n_categories..(node + 1) * spec.n_categories].copy_from_slice(&lp); - } - } - table -} - -fn build_tables(specs: &[MixedItemSpec], params: &[Vec], grid: &Grid) -> Vec> { - specs - .iter() - .zip(params) - .map(|(spec, p)| item_table(spec, p, grid)) - .collect() -} - -#[derive(Debug)] -struct EStep { - loglik: f64, - counts: Vec>, -} - -fn empty_counts(specs: &[MixedItemSpec], cell: usize) -> Vec> { - specs - .iter() - .map(|spec| vec![0.0; cell * spec.n_categories]) - .collect() -} - -#[allow(clippy::too_many_arguments)] -fn e_step_range( - y: &[usize], - observed: &[bool], - n_items: usize, - specs: &[MixedItemSpec], - tables: &[Vec], - grid: &Grid, - start: usize, - end: usize, -) -> EStep { - let cell = grid.cell(); - let mut counts = empty_counts(specs, cell); - let mut log_node = vec![0.0; cell]; - let mut loglik = 0.0; - for person in start..end { - for t in 0..grid.theta.len() { - for x in 0..grid.n_xi { - log_node[t * grid.n_xi + x] = grid.theta_logw[t] + grid.xi_logw[x]; - } - } - for item in 0..n_items { - if !observed[person * n_items + item] { - continue; - } - let response = y[person * n_items + item]; - let k = specs[item].n_categories; - for node in 0..cell { - log_node[node] += tables[item][node * k + response]; - } - } - let mx = log_node.iter().copied().fold(f64::NEG_INFINITY, f64::max); - let denom: f64 = log_node.iter().map(|v| (v - mx).exp()).sum(); - loglik += mx + denom.ln(); - for item in 0..n_items { - if !observed[person * n_items + item] { - continue; - } - let response = y[person * n_items + item]; - let k = specs[item].n_categories; - for node in 0..cell { - counts[item][node * k + response] += (log_node[node] - mx).exp() / denom; - } - } - } - EStep { loglik, counts } -} - -#[allow(clippy::too_many_arguments)] -fn e_step( - y: &[usize], - observed: &[bool], - n_persons: usize, - n_items: usize, - specs: &[MixedItemSpec], - tables: &[Vec], - grid: &Grid, - n_threads: usize, -) -> EStep { - let workers = n_threads.min(n_persons).max(1); - if workers == 1 || n_persons < 256 { - return e_step_range(y, observed, n_items, specs, tables, grid, 0, n_persons); - } - let chunk = n_persons.div_ceil(workers); - let mut partials = thread::scope(|scope| { - let mut handles = Vec::new(); - for worker in 0..workers { - let start = worker * chunk; - let end = (start + chunk).min(n_persons); - handles.push(scope.spawn(move || { - e_step_range(y, observed, n_items, specs, tables, grid, start, end) - })); - } - handles - .into_iter() - .map(|h| h.join().expect("mixed E-step worker panicked")) - .collect::>() - }); - let mut out = EStep { - loglik: 0.0, - counts: empty_counts(specs, grid.cell()), - }; - for partial in partials.drain(..) { - out.loglik += partial.loglik; - for (dst_item, src_item) in out.counts.iter_mut().zip(partial.counts) { - for (dst, src) in dst_item.iter_mut().zip(src_item) { - *dst += src; - } - } - } - out -} - -fn item_objective(spec: &MixedItemSpec, params: &[f64], grid: &Grid, counts: &[f64]) -> f64 { - let table = item_table(spec, params, grid); - -counts.iter().zip(table).map(|(r, lp)| r * lp).sum::() -} - -fn numeric_gradient(spec: &MixedItemSpec, params: &[f64], grid: &Grid, counts: &[f64]) -> Vec { - let mut grad = vec![0.0; params.len()]; - for j in 0..params.len() { - let h = 1e-5 * (1.0 + params[j].abs()); - let mut plus = params.to_vec(); - let mut minus = params.to_vec(); - plus[j] += h; - minus[j] -= h; - grad[j] = (item_objective(spec, &plus, grid, counts) - - item_objective(spec, &minus, grid, counts)) - / (2.0 * h); - } - grad -} - -fn clamp_params(spec: &MixedItemSpec, values: &mut [f64], latent_dim: usize) { - for value in values.iter_mut() { - *value = value.clamp(-12.0, 12.0); - } - if spec.kind.has_free_slope() { - values[0] = values[0].clamp(-5.0, 4.0); - } - if spec.kind.is_spatial() { - let start = values.len() - latent_dim; - for value in &mut values[start..] { - *value = value.clamp(-6.0, 6.0); - } - } -} - -fn symmetrize_and_ridge(hessian: &mut [Vec], ridge: f64) { - let mut row = 0; - while row < hessian.len() { - let mut col = row + 1; - while col < hessian.len() { - let average = 0.5 * (hessian[row][col] + hessian[col][row]); - hessian[row][col] = average; - hessian[col][row] = average; - col += 1; - } - hessian[row][row] += ridge; - row += 1; - } -} - -fn m_step_item( - spec: &MixedItemSpec, - start: &[f64], - grid: &Grid, - counts: &[f64], - max_steps: usize, -) -> Vec { - let mut params = start.to_vec(); - for _ in 0..max_steps { - let f0 = item_objective(spec, ¶ms, grid, counts); - let grad = numeric_gradient(spec, ¶ms, grid, counts); - let grad_norm = grad.iter().map(|g| g * g).sum::().sqrt(); - if !f0.is_finite() || !grad_norm.is_finite() || grad_norm < 1e-6 { - break; - } - let n = params.len(); - let mut hessian = vec![vec![0.0; n]; n]; - for j in 0..n { - let h = 2e-4 * (1.0 + params[j].abs()); - let mut shifted = params.clone(); - shifted[j] += h; - let next_grad = numeric_gradient(spec, &shifted, grid, counts); - for row in 0..n { - hessian[row][j] = (next_grad[row] - grad[row]) / h; - } - } - symmetrize_and_ridge(&mut hessian, 1e-4); - let mut step = solve_small(hessian, grad.clone()); - if !step.iter().all(|s| s.is_finite()) - || grad.iter().zip(&step).map(|(g, s)| g * s).sum::() <= 0.0 - { - step = grad.clone(); - } - let max_abs = step.iter().map(|s| s.abs()).fold(0.0_f64, f64::max); - if max_abs > 2.0 { - for s in &mut step { - *s *= 2.0 / max_abs; - } - } - let mut alpha = 1.0; - let directional = grad.iter().zip(&step).map(|(g, s)| g * s).sum::(); - let mut accepted = false; - for _ in 0..24 { - let mut candidate: Vec = params - .iter() - .zip(&step) - .map(|(p, s)| p - alpha * s) - .collect(); - clamp_params(spec, &mut candidate, grid.latent_dim); - let fc = item_objective(spec, &candidate, grid, counts); - if fc.is_finite() && fc <= f0 - 1e-4 * alpha * directional { - params = candidate; - accepted = true; - break; - } - alpha *= 0.5; - } - if !accepted || alpha * max_abs < 1e-7 { - break; - } - } - params -} - -fn m_step( - specs: &[MixedItemSpec], - params: &[Vec], - grid: &Grid, - counts: &[Vec], - n_threads: usize, -) -> Vec> { - let n_items = specs.len(); - let workers = n_threads.min(n_items).max(1); - if workers == 1 || n_items < 4 { - return (0..n_items) - .map(|i| m_step_item(&specs[i], ¶ms[i], grid, &counts[i], 6)) - .collect(); - } - let chunk = n_items.div_ceil(workers); - let mut pieces = thread::scope(|scope| { - let mut handles = Vec::new(); - for worker in 0..workers { - let start = worker * chunk; - let end = (start + chunk).min(n_items); - handles.push(scope.spawn(move || { - let fitted = (start..end) - .map(|i| m_step_item(&specs[i], ¶ms[i], grid, &counts[i], 6)) - .collect::>(); - (start, fitted) - })); - } - handles - .into_iter() - .map(|h| h.join().expect("mixed M-step worker panicked")) - .collect::>() - }); - pieces.sort_by_key(|(start, _)| *start); - pieces.into_iter().flat_map(|(_, fitted)| fitted).collect() -} - -fn public_estimate(spec: &MixedItemSpec, params: &[f64], latent_dim: usize) -> MixedItemEstimate { - let k = spec.n_categories; - let mut out = MixedItemEstimate { - kind: spec.kind, - n_categories: k, - slope: None, - intercepts: Vec::new(), - thresholds: Vec::new(), - scores: Vec::new(), - location: None, - lower_asymptote: None, - upper_asymptote: None, - zeta: Vec::new(), - }; - match spec.kind { - MixedItemKind::Rasch => { - out.slope = Some(1.0); - out.location = Some(params[0]); - } - MixedItemKind::TwoPl => { - out.slope = Some(params[0].exp()); - out.intercepts = vec![params[1]]; - } - MixedItemKind::ThreePl | MixedItemKind::ThreePlUpper | MixedItemKind::FourPl => { - out.slope = Some(params[0].exp()); - out.intercepts = vec![params[1]]; - let (lower, upper) = asymptotes(spec.kind, params); - out.lower_asymptote = Some(lower); - out.upper_asymptote = Some(upper); - } - MixedItemKind::Cll => { - out.slope = Some(1.0); - out.location = Some(params[0]); - } - MixedItemKind::Grm => { - out.slope = Some(params[0].exp()); - out.thresholds = ordered_values(¶ms[1..]); - } - MixedItemKind::Pcm => { - out.slope = Some(1.0); - out.intercepts = params.to_vec(); - } - MixedItemKind::Gpcm => { - out.slope = Some(params[0].exp()); - out.intercepts = params[1..k].to_vec(); - } - MixedItemKind::Nominal => { - let c = k - 1; - out.scores = params[..c].to_vec(); - out.intercepts = params[c..2 * c].to_vec(); - } - MixedItemKind::Sequential => { - out.slope = Some(params[0].exp()); - out.intercepts = params[1..].to_vec(); - } - MixedItemKind::Tutz => { - out.slope = Some(1.0); - out.intercepts = params.to_vec(); - } - MixedItemKind::Ideal => { - out.slope = Some(params[0].exp()); - out.location = Some(params[1]); - } - MixedItemKind::Ggum => { - out.slope = Some(params[0].exp()); - out.location = Some(params[1]); - out.thresholds = ordered_values(¶ms[2..]); - } - MixedItemKind::Lsirm => { - out.slope = Some(params[0].exp()); - out.intercepts = vec![params[1]]; - out.zeta = params[params.len() - latent_dim..].to_vec(); - } - MixedItemKind::LsirmGrm => { - out.slope = Some(params[0].exp()); - out.thresholds = ordered_values(¶ms[1..k]); - out.zeta = params[params.len() - latent_dim..].to_vec(); - } - MixedItemKind::LsirmGpcm => { - out.slope = Some(params[0].exp()); - out.intercepts = params[1..k].to_vec(); - out.zeta = params[params.len() - latent_dim..].to_vec(); - } - } - out -} - -#[allow(clippy::too_many_arguments)] -fn final_scores( - y: &[usize], - observed: &[bool], - n_persons: usize, - n_items: usize, - specs: &[MixedItemSpec], - tables: &[Vec], - grid: &Grid, -) -> (Vec, Vec, Vec) { - let cell = grid.cell(); - let mut theta_eap = vec![0.0; n_persons]; - let mut theta_sd = vec![0.0; n_persons]; - let mut xi_eap = vec![0.0; n_persons * grid.latent_dim]; - let mut log_node = vec![0.0; cell]; - for person in 0..n_persons { - for t in 0..grid.theta.len() { - for x in 0..grid.n_xi { - log_node[t * grid.n_xi + x] = grid.theta_logw[t] + grid.xi_logw[x]; - } - } - for item in 0..n_items { - if !observed[person * n_items + item] { - continue; - } - let response = y[person * n_items + item]; - let k = specs[item].n_categories; - for node in 0..cell { - log_node[node] += tables[item][node * k + response]; - } - } - let mx = log_node.iter().copied().fold(f64::NEG_INFINITY, f64::max); - let denom: f64 = log_node.iter().map(|v| (v - mx).exp()).sum(); - let mut m1 = 0.0; - let mut m2 = 0.0; - for t in 0..grid.theta.len() { - for x in 0..grid.n_xi { - let post = (log_node[t * grid.n_xi + x] - mx).exp() / denom; - m1 += post * grid.theta[t]; - m2 += post * grid.theta[t] * grid.theta[t]; - for d in 0..grid.latent_dim { - xi_eap[person * grid.latent_dim + d] += post * grid.xi[x * grid.latent_dim + d]; - } - } - } - theta_eap[person] = m1; - theta_sd[person] = (m2 - m1 * m1).max(0.0).sqrt(); - } - (theta_eap, theta_sd, xi_eap) -} - -fn assess_loglik_update(current: f64, candidate: f64) -> Result { - if !candidate.is_finite() { - return Err("non_finite_loglik"); - } - let change = candidate - current; - let monotone_slack = 1e-8 * (1.0 + current.abs()); - if change < -monotone_slack { - return Err("non_monotone_update"); - } - Ok(change) -} - -fn contextualize_mixed_update(update: Result) -> Result { - match update { - Ok(change) => Ok(change), - Err(reason) => Err(format!("mixed-format EM update failed: {reason}")), - } -} - -#[allow(clippy::too_many_arguments)] -pub fn fit_mixed_items( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - specs: &[MixedItemSpec], - latent_dim: usize, - q_theta: usize, - q_xi: usize, - max_iter: usize, - tol: f64, - requested_threads: usize, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("responses must contain at least one person and one item".into()); - } - let expected_len = n_persons - .checked_mul(n_items) - .ok_or("n_persons * n_items overflow")?; - if y.len() != expected_len { - return Err("y must have length n_persons * n_items".into()); - } - if specs.len() != n_items { - return Err("item specification count must match n_items".into()); - } - if let Some(mask) = observed { - if mask.len() != y.len() { - return Err("observed must have length n_persons * n_items".into()); - } - } - if max_iter == 0 || !tol.is_finite() || tol <= 0.0 { - return Err("max_iter must be positive and tol must be finite and > 0".into()); - } - for (item, spec) in specs.iter().enumerate() { - if spec.n_categories < 2 { - return Err(format!("item {item}: n_categories must be >= 2")); - } - if spec.kind.requires_binary() && spec.n_categories != 2 { - return Err(format!( - "item {item}: {} requires exactly 2 categories", - spec.kind.as_str() - )); - } - let mut seen = vec![false; spec.n_categories]; - for person in 0..n_persons { - let index = person * n_items + item; - if observed.is_none_or(|m| m[index]) { - let response = y[index]; - if response >= spec.n_categories { - return Err(format!( - "item {item}: observed response {response} is outside 0..{}", - spec.n_categories - 1 - )); - } - seen[response] = true; - } - } - if seen.iter().filter(|&&present| present).count() < 2 { - return Err(format!( - "item {item}: at least two observed categories are required" - )); - } - } - let observed_owned; - let observed = if let Some(mask) = observed { - mask - } else { - observed_owned = vec![true; y.len()]; - &observed_owned - }; - let grid = build_grid(specs, latent_dim, q_theta, q_xi)?; - let auto_threads = thread::available_parallelism().map_or(1, |n| n.get()); - let n_threads = if requested_threads == 0 { - auto_threads - } else { - requested_threads.min(auto_threads) - } - .clamp(1, n_persons.max(1)); - - let mut params = Vec::with_capacity(n_items); - for (item, spec) in specs.iter().enumerate() { - let mut freq = vec![1e-3; spec.n_categories]; - for person in 0..n_persons { - let index = person * n_items + item; - if observed[index] { - freq[y[index]] += 1.0; - } - } - let total: f64 = freq.iter().sum(); - for value in &mut freq { - *value /= total; - } - params.push(initial_params(spec, &freq, item, n_items, grid.latent_dim)); - } - - let mut tables = build_tables(specs, ¶ms, &grid); - let mut state = e_step( - y, observed, n_persons, n_items, specs, &tables, &grid, n_threads, - ); - assess_loglik_update(state.loglik, state.loglik) - .map_err(|_| "initial mixed-format log-likelihood is not finite")?; - let mut trace = vec![state.loglik]; - let mut converged = false; - let mut termination_reason = "max_iter_reached".to_string(); - let mut completed = 0; - for iteration in 1..=max_iter { - let candidate = m_step(specs, ¶ms, &grid, &state.counts, n_threads); - let candidate_tables = build_tables(specs, &candidate, &grid); - let candidate_state = e_step( - y, - observed, - n_persons, - n_items, - specs, - &candidate_tables, - &grid, - n_threads, - ); - let change = - contextualize_mixed_update(assess_loglik_update(state.loglik, candidate_state.loglik))?; - params = candidate; - tables = candidate_tables; - state = candidate_state; - trace.push(state.loglik); - completed = iteration; - if change.abs() <= tol * (1.0 + state.loglik.abs()) { - converged = true; - termination_reason = "converged".to_string(); - break; - } - } - - let (theta_eap, theta_sd, xi_eap) = - final_scores(y, observed, n_persons, n_items, specs, &tables, &grid); - let items = specs - .iter() - .zip(¶ms) - .map(|(spec, p)| public_estimate(spec, p, grid.latent_dim)) - .collect(); - Ok(MixedFit { - items, - theta_eap, - theta_sd, - xi_eap, - latent_dim: grid.latent_dim, - loglik: state.loglik, - loglik_trace: trace, - n_iter: completed, - converged, - termination_reason, - n_threads, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/mixed_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/mixture.rs b/crates/mlsirm-core/src/mixture.rs deleted file mode 100644 index 9f2975996..000000000 --- a/crates/mlsirm-core/src/mixture.rs +++ /dev/null @@ -1,645 +0,0 @@ -//! Mixed Rasch / mixture IRT (Rost, 1990; Rost & von Davier, 1995): the population -//! is a mixture of `C` latent classes, each with its OWN item parameters and a mixing -//! weight `pi_c`. Within class `c`, responses follow a unidimensional IRT model -//! (`Rasch` fixes `a_ic = 1`; `TwoPl` frees `a_ic`) with ability `theta ~ N(0,1)` -//! fixed per class, estimated by marginal-ML EM over the shared Gauss-Hermite rule. -//! This detects unobserved population heterogeneity — qualitatively different response -//! strategies that a single-class model cannot represent. -//! -//! The E-step forms the joint posterior over (class `c`, ability node `q`); the M-step -//! updates each class's item parameters with the SAME per-item Newton step as -//! [`crate::mmle::fit_mmle_2pl`] (weighted by the class posterior), plus the mixing -//! proportions `pi_c = mean posterior class membership`. The `C = 1` `TwoPl` case at -//! `tol = 0.0` reduces bit-exactly to `fit_mmle_2pl` (the reduction anchor); the -//! `tol = 0.0` is what makes both run the full `max_iter` despite their differing -//! convergence-check placement. -//! -//! Identification. Two problems, both handled: (1) the within-class metric is pinned -//! by `theta_c ~ N(0,1)` with all `b_ic` (and `a_ic`) free — the same standard-normal -//! prior `fit_mmle_2pl` assumes, so every `b_ic` is directly comparable across classes -//! on one metric. (2) Label switching: classes are exchangeable, so the OUTPUT is put -//! in a canonical order (mixing weight descending, ties broken by mean difficulty -//! ascending); recovery studies must additionally match classes by permutation. -//! -//! Provenance. Rost's (1990) original estimator used conditional ML within class with -//! a saturated raw-score distribution; `psychomix` likewise fits Rasch mixtures by -//! conditional ML (Frick, Strobl, Leisch, & Zeileis, 2012). This crate instead combines -//! Rost's latent-class structure with a fixed-standard-normal, Bock-Aitkin marginal-ML -//! EM estimator. That estimator is a repository-specific operationalization, not an -//! assertion that its finite-sample item estimates equal the conditional-ML estimates. -//! -//! Deferred (explicit non-goals): free per-class ability variance `sigma_c`; automatic -//! model selection over `C` (the result returns `n_parameters`/`loglik_trace`, so -//! AIC/BIC/ICL are caller one-liners); concomitant-variable (covariate) mixing; GPU -//! offload of the E-step. -//! -//! References (APA 7th ed.): -//! - Rost, J. (1990). Rasch models in latent classes: An integration of two approaches -//! to item analysis. *Applied Psychological Measurement, 14*(3), 271–282. -//! -//! - Rost, J., & von Davier, M. (1995). Mixture distribution Rasch models. In G. H. -//! Fischer & I. W. Molenaar (Eds.), *Rasch models: Foundations, recent developments, -//! and applications* (pp. 257–268). Springer. -//! -//! - Bock, R. D., & Aitkin, M. (1981). Marginal maximum likelihood estimation of item -//! parameters. *Psychometrika, 46*(4), 443–459. -//! - Frick, H., Strobl, C., Leisch, F., & Zeileis, A. (2012). Flexible Rasch mixture -//! models with package psychomix. *Journal of Statistical Software, 48*(7), 1–25. -//! -//! - McLachlan, G. J., & Peel, D. (2000). *Finite mixture models*. Wiley. - -use crate::mmle::{fit_mmle_2pl, log_sigmoid, sigmoid_stable, MmleConfig, GH_NODES, GH_WEIGHTS}; - -const MIXTURE_MAX_CLASSES: usize = 64; -const MIXTURE_MAX_STARTS: usize = 1_000; -const MIXTURE_MAX_ITER: usize = 100_000; -const MIXTURE_MAX_AGGREGATE_ITERS: usize = 10_000_000; -const MIXTURE_MAX_BUFFER_CELLS: usize = 60_000_000; - -/// Within-class IRT model: `Rasch` fixes `a_ic = 1` (Rost, 1990); `TwoPl` frees `a_ic`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MixtureModel { - Rasch, - TwoPl, -} - -/// EM configuration for the mixture-IRT estimator. -#[derive(Clone, Copy, Debug)] -pub struct MixtureConfig { - pub max_iter: usize, - /// Convergence tolerance on `|delta loglik|`. `0.0` is permitted (runs the full - /// `max_iter`) — required for the bit-exact `C = 1` reduction anchor. - pub tol: f64, - pub ridge_a: f64, - pub ridge_b: f64, - pub newton_iter: usize, - /// Random restarts for the multimodal mixture likelihood (start 0 is the - /// deterministic warm start). Kept the run with the highest final loglik. - pub n_starts: usize, - /// Class separation for the warm start / perturbation scale for restarts. - pub start_spread: f64, - /// Floor for `pi_c` (avoids `ln 0`). - pub pi_floor: f64, - /// Seed for the restart perturbations (unused when `n_starts == 1`). - pub seed: u64, -} - -impl Default for MixtureConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - ridge_a: 1e-3, - ridge_b: 1e-3, - newton_iter: 25, - n_starts: 1, - start_spread: 1.0, - pi_floor: 1e-6, - seed: 0x2545F491, - } - } -} - -/// Fitted mixture-IRT model. Item parameters are class-major (`a[c*J + i]`). Classes -/// are in canonical order (mixing weight descending, ties by mean difficulty ascending). -#[derive(Clone, Debug)] -pub struct MixtureResult { - pub model: MixtureModel, - pub n_classes: usize, - /// Per-class item discriminations, class-major `a[c*J + i]` (all 1.0 for `Rasch`). - pub a: Vec, - /// Per-class item difficulties, class-major `b[c*J + i]`. - pub b: Vec, - /// Mixing proportions, length `C`, sum 1. - pub pi: Vec, - /// Class responsibilities `P(class c | x_j)`, row-major `N x C`. - pub class_posterior: Vec, - /// MAP class per person, length `N`. - pub map_class: Vec, - /// Mixture EAP ability per person, length `N`. - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// `C*(k*J) + (C-1)`, `k = 2` (TwoPl) | `1` (Rasch). - pub n_parameters: usize, -} - -fn validate( - y: &[f64], - observed: &[bool], - n_persons: usize, - n_items: usize, - n_classes: usize, - cfg: &MixtureConfig, -) -> Result<(), String> { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(1..=MIXTURE_MAX_CLASSES).contains(&n_classes) { - return Err(format!("n_classes must be in 1..={MIXTURE_MAX_CLASSES}")); - } - if !(1..=MIXTURE_MAX_ITER).contains(&cfg.max_iter) { - return Err(format!("max_iter must be in 1..={MIXTURE_MAX_ITER}")); - } - // tol == 0.0 is allowed (runs the full max_iter; needed for the C=1 anchor). - if !cfg.tol.is_finite() || cfg.tol < 0.0 { - return Err("tol must be finite and non-negative".into()); - } - if cfg.newton_iter == 0 { - return Err("newton_iter must be positive".into()); - } - if !(1..=MIXTURE_MAX_STARTS).contains(&cfg.n_starts) { - return Err(format!("n_starts must be in 1..={MIXTURE_MAX_STARTS}")); - } - if !cfg.ridge_a.is_finite() || cfg.ridge_a < 0.0 { - return Err("ridge_a must be finite and non-negative".into()); - } - if !cfg.ridge_b.is_finite() || cfg.ridge_b < 0.0 { - return Err("ridge_b must be finite and non-negative".into()); - } - if !cfg.start_spread.is_finite() || cfg.start_spread < 0.0 { - return Err("start_spread must be finite and non-negative".into()); - } - if !cfg.pi_floor.is_finite() || !(0.0 < cfg.pi_floor && cfg.pi_floor < 1.0 / n_classes as f64) { - return Err("pi_floor must be finite and in (0, 1/n_classes)".into()); - } - if n_classes > u32::MAX as usize { - return Err("n_classes must fit in the u32 map_class representation".into()); - } - let aggregate_iterations = n_classes - .checked_mul(cfg.n_starts) - .and_then(|work| work.checked_mul(cfg.max_iter)) - .ok_or_else(|| "n_classes * n_starts * max_iter overflows usize".to_string())?; - if aggregate_iterations > MIXTURE_MAX_AGGREGATE_ITERS { - return Err(format!( - "mixture work {aggregate_iterations} exceeds the cap {MIXTURE_MAX_AGGREGATE_ITERS}" - )); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - let class_items = - crate::checked_mul_usize(n_classes, n_items, "n_classes * n_items overflows usize")?; - crate::checked_mul_usize(n_classes, GH_NODES.len(), "class-node size overflows")?; - let class_item_nodes = - crate::checked_mul_usize(class_items, GH_NODES.len(), "class-item-node overflow")?; - let person_classes = - crate::checked_mul_usize(n_persons, n_classes, "person-class size overflows")?; - for (label, cells) in [ - ("class-item-node", class_item_nodes), - ("person-class", person_classes), - ] { - if cells > MIXTURE_MAX_BUFFER_CELLS { - return Err(format!( - "{label} buffer {cells} cells exceeds the cap {MIXTURE_MAX_BUFFER_CELLS}" - )); - } - } - let doubled = crate::checked_mul_usize(class_items, 2, "parameter count overflows")?; - crate::checked_add_usize(doubled, n_classes - 1, "parameter count overflows")?; - if y.len() != n_cells || observed.len() != n_cells { - return Err("y and observed must have length n_persons * n_items".into()); - } - for (idx, &v) in y.iter().enumerate() { - if observed[idx] && v != 0.0 && v != 1.0 { - return Err(format!("y[{idx}] must be 0 or 1 where observed; got {v}")); - } - } - for i in 0..n_items { - if !(0..n_persons).any(|p| observed[p * n_items + i]) { - return Err(format!("item {i} has no observed responses")); - } - } - Ok(()) -} - -/// One penalized Newton item update, shared with (and bit-identical to) the per-item -/// step of [`crate::mmle::fit_mmle_2pl`]. `n_row`/`r_row` are the item's expected node -/// counts (length Q). `fix_slope` holds `a = a0 = 1` (Rasch). The `C = 1` reduction -/// anchor enforces that this stays in lockstep with `fit_mmle_2pl`. -fn newton_item_2pl( - n_row: &[f64], - r_row: &[f64], - a0: f64, - b0: f64, - fix_slope: bool, - newton_iter: usize, - ridge_a: f64, - ridge_b: f64, -) -> (f64, f64) { - let (mut ai, mut bi) = (a0, b0); - for _ in 0..newton_iter { - let (mut g_a, mut g_b, mut h_aa, mut h_bb, mut h_ab) = (0.0, 0.0, 0.0, 0.0, 0.0); - for (qi, &node) in GH_NODES.iter().enumerate() { - let p_correct = sigmoid_stable(ai * node + bi); - let n = n_row[qi]; - let w = n * p_correct * (1.0 - p_correct); - let resid = r_row[qi] - n * p_correct; - g_a += resid * node; - g_b += resid; - h_aa -= w * node * node; - h_bb -= w; - h_ab -= w * node; - } - if fix_slope { - // 1-D Newton on b with a held at 1 (Rasch): b -= g_b / h_bb. - g_b -= ridge_b * bi; - h_bb -= ridge_b; - if h_bb.abs() < 1e-12 { - break; - } - let db = g_b / h_bb; - bi -= db; - if db.abs() < 1e-8 { - break; - } - } else { - g_a -= ridge_a * ai; - g_b -= ridge_b * bi; - h_aa -= ridge_a; - h_bb -= ridge_b; - let det = h_aa * h_bb - h_ab * h_ab; - if det.abs() < 1e-12 { - break; - } - let da = (h_bb * g_a - h_ab * g_b) / det; - let db = (h_aa * g_b - h_ab * g_a) / det; - ai = (ai - da).clamp(1e-3, 10.0); - bi -= db; - if da.abs() + db.abs() < 1e-8 { - break; - } - } - } - (ai, bi) -} - -/// Marginal-ML item-proportion init identical to `fit_mmle_2pl` (a = 1, b = logit of -/// the clamped item proportion). Load-bearing for the bit-exact C=1 anchor. -fn init_mmle_like(y: &[f64], observed: &[bool], n_persons: usize, n_items: usize) -> Vec { - let mut b = vec![0.0f64; n_items]; - for i in 0..n_items { - let (mut num, mut den) = (0.0, 0.0); - for p in 0..n_persons { - let idx = p * n_items + i; - if observed[idx] { - num += y[idx]; - den += 1.0; - } - } - let prop = if den > 0.0 { - (num / den).clamp(0.02, 0.98) - } else { - 0.5 - }; - b[i] = (prop / (1.0 - prop)).ln(); - } - b -} - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } -} - -/// Run marginal EM from a given init to convergence; classes returned UNORDERED. -#[allow(clippy::too_many_arguments)] -fn run_em( - y: &[f64], - observed: &[bool], - n_persons: usize, - n_items: usize, - n_classes: usize, - model: MixtureModel, - mut a: Vec, - mut b: Vec, - mut pi: Vec, - cfg: &MixtureConfig, -) -> MixtureResult { - let q = GH_NODES.len(); - let log_w: Vec = GH_WEIGHTS.iter().map(|w| w.ln()).collect(); - let fix_slope = model == MixtureModel::Rasch; - let cq = n_classes * q; - - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - let mut post = vec![0.0f64; cq]; - - let build_tables = |a: &[f64], b: &[f64], log_p1: &mut [f64], log_p0: &mut [f64]| { - for c in 0..n_classes { - for (qi, &node) in GH_NODES.iter().enumerate() { - for i in 0..n_items { - let eta = a[c * n_items + i] * node + b[c * n_items + i]; - log_p1[(c * q + qi) * n_items + i] = log_sigmoid(eta); - log_p0[(c * q + qi) * n_items + i] = log_sigmoid(-eta); - } - } - } - }; - // Fill `post` with the joint (class, node) posterior for person j; returns ln P(x_j). - let person_posterior = - |j: usize, log_p1: &[f64], log_p0: &[f64], log_pi: &[f64], post: &mut [f64]| -> f64 { - for c in 0..n_classes { - for qi in 0..q { - let mut acc = log_pi[c] + log_w[qi]; - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let yy = y[idx]; - acc += yy * log_p1[(c * q + qi) * n_items + i] - + (1.0 - yy) * log_p0[(c * q + qi) * n_items + i]; - } - } - post[c * q + qi] = acc; - } - } - let m = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0; - for v in post.iter() { - denom += (v - m).exp(); - } - for v in post.iter_mut() { - *v = (*v - m).exp() / denom; - } - m + denom.ln() - }; - - let mut log_p1 = vec![0.0f64; cq * n_items]; - let mut log_p0 = vec![0.0f64; cq * n_items]; - - for _ in 0..cfg.max_iter { - build_tables(&a, &b, &mut log_p1, &mut log_p0); - let log_pi: Vec = pi.iter().map(|p| p.ln()).collect(); - - let mut n_cnt = vec![0.0f64; n_classes * n_items * q]; - let mut r_cnt = vec![0.0f64; n_classes * n_items * q]; - let mut pi_new = vec![0.0f64; n_classes]; - let mut total_ll = 0.0; - for j in 0..n_persons { - total_ll += person_posterior(j, &log_p1, &log_p0, &log_pi, &mut post); - for c in 0..n_classes { - let mut r_jc = 0.0; - for qi in 0..q { - r_jc += post[c * q + qi]; - } - pi_new[c] += r_jc; - } - for c in 0..n_classes { - for i in 0..n_items { - let idx = j * n_items + i; - if observed[idx] { - let yy = y[idx]; - let base = (c * n_items + i) * q; - for qi in 0..q { - let pv = post[c * q + qi]; - n_cnt[base + qi] += pv; - r_cnt[base + qi] += yy * pv; - } - } - } - } - } - loglik_trace.push(total_ll); - - // Convergence check BEFORE the M-step so the returned params match the trace endpoint. - if loglik_trace.len() > 1 { - let n = loglik_trace.len(); - if (loglik_trace[n - 1] - loglik_trace[n - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - - for c in 0..n_classes { - for i in 0..n_items { - let base = (c * n_items + i) * q; - let (ai, bi) = newton_item_2pl( - &n_cnt[base..base + q], - &r_cnt[base..base + q], - a[c * n_items + i], - b[c * n_items + i], - fix_slope, - cfg.newton_iter, - cfg.ridge_a, - cfg.ridge_b, - ); - a[c * n_items + i] = ai; - b[c * n_items + i] = bi; - } - } - let nf = n_persons as f64; - let mut z = 0.0; - for c in 0..n_classes { - pi[c] = (pi_new[c] / nf).max(cfg.pi_floor); - z += pi[c]; - } - for c in 0..n_classes { - pi[c] /= z; - } - n_iter += 1; - } - - // Final pass: class responsibilities, MAP class, mixture EAP at the converged params. - build_tables(&a, &b, &mut log_p1, &mut log_p0); - let log_pi: Vec = pi.iter().map(|p| p.ln()).collect(); - let mut class_posterior = vec![0.0f64; n_persons * n_classes]; - let mut map_class = vec![0u32; n_persons]; - let mut theta = vec![0.0f64; n_persons]; - let mut final_ll = 0.0; - for j in 0..n_persons { - final_ll += person_posterior(j, &log_p1, &log_p0, &log_pi, &mut post); - let (mut best, mut best_r) = (0usize, -1.0); - let mut th = 0.0; - for c in 0..n_classes { - let mut r_jc = 0.0; - for qi in 0..q { - let pv = post[c * q + qi]; - r_jc += pv; - th += pv * GH_NODES[qi]; - } - class_posterior[j * n_classes + c] = r_jc; - if r_jc > best_r { - best_r = r_jc; - best = c; - } - } - map_class[j] = best as u32; - theta[j] = th; - } - if !converged { - loglik_trace.push(final_ll); - } - - let k = if fix_slope { 1 } else { 2 }; - MixtureResult { - model, - n_classes, - a, - b, - pi, - class_posterior, - map_class, - theta, - loglik_trace, - n_iter, - converged, - n_parameters: n_classes * (k * n_items) + (n_classes - 1), - } -} - -/// Reorder classes into the canonical public order: mixing weight descending, ties by -/// mean difficulty ascending. -fn canonical_order(res: MixtureResult) -> MixtureResult { - let (c, j) = (res.n_classes, res.b.len() / res.n_classes.max(1)); - let mean_b: Vec = (0..c) - .map(|cc| res.b[cc * j..(cc + 1) * j].iter().sum::() / j as f64) - .collect(); - let mut order: Vec = (0..c).collect(); - order.sort_by(|&x, &y| { - res.pi[y] - .partial_cmp(&res.pi[x]) - .unwrap_or(std::cmp::Ordering::Equal) - .then( - mean_b[x] - .partial_cmp(&mean_b[y]) - .unwrap_or(std::cmp::Ordering::Equal), - ) - }); - let mut inv = vec![0usize; c]; // inv[old] = new position - for (new_pos, &old) in order.iter().enumerate() { - inv[old] = new_pos; - } - let (mut a2, mut b2, mut pi2) = (vec![0.0; res.a.len()], vec![0.0; res.b.len()], vec![0.0; c]); - for (new_pos, &old) in order.iter().enumerate() { - a2[new_pos * j..(new_pos + 1) * j].copy_from_slice(&res.a[old * j..(old + 1) * j]); - b2[new_pos * j..(new_pos + 1) * j].copy_from_slice(&res.b[old * j..(old + 1) * j]); - pi2[new_pos] = res.pi[old]; - } - let n = res.map_class.len(); - let mut cp2 = vec![0.0; res.class_posterior.len()]; - for jj in 0..n { - for (new_pos, &old) in order.iter().enumerate() { - cp2[jj * c + new_pos] = res.class_posterior[jj * c + old]; - } - } - let map2: Vec = res - .map_class - .iter() - .map(|&m| inv[m as usize] as u32) - .collect(); - MixtureResult { - a: a2, - b: b2, - pi: pi2, - class_posterior: cp2, - map_class: map2, - ..res - } -} - -/// Fit a mixture IRT model (Rost, 1990) by marginal EM. `y`/`observed` are row-major -/// `N*J` (`y` in {0,1}); missing cells (`observed == false`) are dropped (MAR). For -/// `C = 1` a single deterministic start is used (`cfg.n_starts` is ignored); with -/// `TwoPl` and `tol = 0.0` it reduces bit-exactly to -/// [`crate::mmle::fit_mmle_2pl`]. For `C >= 2` the fit runs -/// `cfg.n_starts` restarts (start 0 is a deterministic warm start) and keeps the run -/// with the highest final log-likelihood. Classes are returned in canonical order. -pub fn fit_mixture( - y: &[f64], - observed: &[bool], - n_persons: usize, - n_items: usize, - n_classes: usize, - model: MixtureModel, - cfg: &MixtureConfig, -) -> Result { - validate(y, observed, n_persons, n_items, n_classes, cfg)?; - - if n_classes == 1 { - // Bit-exact single-start reduction to fit_mmle_2pl: a = 1, b = logit(prop). - let b0 = init_mmle_like(y, observed, n_persons, n_items); - let a0 = vec![1.0f64; n_items]; - let res = run_em( - y, - observed, - n_persons, - n_items, - 1, - model, - a0, - b0, - vec![1.0], - cfg, - ); - return Ok(canonical_order(res)); - } - - // Warm start from a single-class 2PL fit (its difficulties seed every class). - let warm = fit_mmle_2pl( - y, - observed, - n_persons, - n_items, - &MmleConfig { - max_iter: cfg.max_iter, - tol: 1e-4, - ridge_a: cfg.ridge_a, - ridge_b: cfg.ridge_b, - newton_iter: cfg.newton_iter, - }, - ); - let fix_slope = model == MixtureModel::Rasch; - - let mut best: Option = None; - for start in 0..cfg.n_starts { - let mut a = vec![0.0f64; n_classes * n_items]; - let mut b = vec![0.0f64; n_classes * n_items]; - for c in 0..n_classes { - for i in 0..n_items { - a[c * n_items + i] = if fix_slope { 1.0 } else { warm.a[i] }; - } - } - if start == 0 { - // Deterministic warm start: centered per-class difficulty shift. - for c in 0..n_classes { - let delta = cfg.start_spread * (c as f64 - (n_classes as f64 - 1.0) / 2.0); - for i in 0..n_items { - b[c * n_items + i] = warm.b[i] + delta; - } - } - } else { - // Random restart: per-(class, item) perturbation to explore reordering-type - // class structure that a global difficulty shift cannot reach. - let mut rng = Lcg(cfg.seed ^ (start as u64).wrapping_mul(0x9E3779B97F4A7C15)); - for c in 0..n_classes { - for i in 0..n_items { - b[c * n_items + i] = - warm.b[i] + cfg.start_spread * (2.0 * rng.next_f64() - 1.0); - } - } - } - let pi = vec![1.0 / n_classes as f64; n_classes]; - let res = run_em( - y, observed, n_persons, n_items, n_classes, model, a, b, pi, cfg, - ); - let ll = *res.loglik_trace.last().unwrap(); - if best - .as_ref() - .is_none_or(|bst| ll > *bst.loglik_trace.last().unwrap()) - { - best = Some(res); - } - } - Ok(canonical_order(best.unwrap())) -} - -#[cfg(test)] -#[path = "../../../tests/unit/mixture_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/mmle.rs b/crates/mlsirm-core/src/mmle.rs index d522f7d05..26957abea 100644 --- a/crates/mlsirm-core/src/mmle.rs +++ b/crates/mlsirm-core/src/mmle.rs @@ -15,7 +15,7 @@ /// (weights divided by their sum), so this table is bit-identical to the /// default quadrature of the NumPy reference in /// `python/fast_mlsirm/estimators/mmle.py` — the Rust<->NumPy parity contract. -pub(crate) const GH_NODES: [f64; 41] = [ +const GH_NODES: [f64; 41] = [ -11.614937254337464, -10.647536786319334, -9.843433249157995, @@ -58,7 +58,7 @@ pub(crate) const GH_NODES: [f64; 41] = [ 10.647536786319334, 11.614937254337464, ]; -pub(crate) const GH_WEIGHTS: [f64; 41] = [ +const GH_WEIGHTS: [f64; 41] = [ 2.2578639565831077e-30, 8.308558938782659e-26, 2.7468912285223205e-22, @@ -123,18 +123,12 @@ pub struct MmleConfig { impl Default for MmleConfig { fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - ridge_a: 1e-3, - ridge_b: 1e-3, - newton_iter: 25, - } + Self { max_iter: 500, tol: 1e-6, ridge_a: 1e-3, ridge_b: 1e-3, newton_iter: 25 } } } #[inline] -pub(crate) fn log_sigmoid(x: f64) -> f64 { +fn log_sigmoid(x: f64) -> f64 { if x >= 0.0 { -(-x).exp().ln_1p() } else { @@ -143,7 +137,7 @@ pub(crate) fn log_sigmoid(x: f64) -> f64 { } #[inline] -pub(crate) fn sigmoid_stable(x: f64) -> f64 { +fn sigmoid_stable(x: f64) -> f64 { if x >= 0.0 { 1.0 / (1.0 + (-x).exp()) } else { @@ -179,11 +173,7 @@ pub fn fit_mmle_2pl( den += 1.0; } } - let prop = if den > 0.0 { - (num / den).clamp(0.02, 0.98) - } else { - 0.5 - }; + let prop = if den > 0.0 { (num / den).clamp(0.02, 0.98) } else { 0.5 }; b[i] = (prop / (1.0 - prop)).ln(); } @@ -211,8 +201,7 @@ pub fn fit_mmle_2pl( let idx = p * n_items + i; if observed[idx] { let yy = y[idx]; - acc += - yy * log_p1[qi * n_items + i] + (1.0 - yy) * log_p0[qi * n_items + i]; + acc += yy * log_p1[qi * n_items + i] + (1.0 - yy) * log_p0[qi * n_items + i]; } } *item = acc; @@ -299,16 +288,111 @@ pub fn fit_mmle_2pl( } let n_iter = loglik_trace.len(); - MmleResult { - a, - b, - theta, - loglik_trace, - n_iter, - converged, - } + MmleResult { a, b, theta, loglik_trace, n_iter, converged } } #[cfg(test)] -#[path = "../../../tests/unit/mmle_tests.rs"] -mod tests; +mod tests { + use super::*; + + struct Lcg(u64); + impl Lcg { + fn next_f64(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) + } + fn normal(&mut self) -> f64 { + let u1 = self.next_f64().max(1e-12); + let u2 = self.next_f64(); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() + } + } + + fn corr(x: &[f64], y: &[f64]) -> f64 { + let n = x.len() as f64; + let mx = x.iter().sum::() / n; + let my = y.iter().sum::() / n; + let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); + for i in 0..x.len() { + sxy += (x[i] - mx) * (y[i] - my); + sxx += (x[i] - mx).powi(2); + syy += (y[i] - my).powi(2); + } + sxy / (sxx.sqrt() * syy.sqrt()) + } + + #[test] + fn recovers_2pl_under_30pct_missing() { + let mut rng = Lcg(12345); + let (n_persons, n_items) = (800usize, 20usize); + let a_true: Vec = (0..n_items).map(|_| 0.7 + 1.3 * rng.next_f64()).collect(); + let b_true: Vec = (0..n_items).map(|_| -1.5 + 3.0 * rng.next_f64()).collect(); + let theta_true: Vec = (0..n_persons).map(|_| rng.normal()).collect(); + + let mut y = vec![0.0_f64; n_persons * n_items]; + let mut observed = vec![true; n_persons * n_items]; + for p in 0..n_persons { + for i in 0..n_items { + let idx = p * n_items + i; + let eta = a_true[i] * theta_true[p] + b_true[i]; + let prob = 1.0 / (1.0 + (-eta).exp()); + y[idx] = if rng.next_f64() < prob { 1.0 } else { 0.0 }; + if rng.next_f64() < 0.30 { + observed[idx] = false; + } + } + } + + let res = fit_mmle_2pl(&y, &observed, n_persons, n_items, &MmleConfig::default()); + assert!(res.converged, "EM should converge"); + for w in res.loglik_trace.windows(2) { + assert!(w[1] >= w[0] - 1e-6, "loglik decreased: {} -> {}", w[0], w[1]); + } + assert!(corr(&res.a, &a_true) > 0.85, "a recovery too low"); + assert!(corr(&res.b, &b_true) > 0.9, "b recovery too low"); + assert!(corr(&res.theta, &theta_true) > 0.8, "theta recovery too low"); + } + + #[test] + fn all_missing_person_row_is_tolerated() { + let (n_persons, n_items) = (3usize, 4usize); + let y = vec![1.0; n_persons * n_items]; + let mut observed = vec![true; n_persons * n_items]; + for i in 0..n_items { + observed[i] = false; + } + let res = fit_mmle_2pl(&y, &observed, n_persons, n_items, &MmleConfig::default()); + assert!(res.theta.iter().all(|t| t.is_finite())); + assert!(res.theta[0].abs() < 1e-6, "all-missing person should shrink to prior mean 0"); + } + + #[test] + fn newton_tolerates_singular_hessian_without_ridge() { + // An item that nobody observed carries zero Fisher information. With the + // ridge disabled the per-item Newton Hessian is exactly singular, so the + // solver must hit the `det.abs() < 1e-12` guard and break out of the + // Newton loop instead of dividing by (near-)zero. This exercises the + // singular-Hessian branch in fit_mmle_2pl. + let (n_persons, n_items) = (6usize, 3usize); + let mut y = vec![0.0_f64; n_persons * n_items]; + let mut observed = vec![true; n_persons * n_items]; + for p in 0..n_persons { + // Items 0 and 1 carry a varied, informative response pattern. + y[p * n_items] = (p % 2) as f64; + y[p * n_items + 1] = ((p / 2) % 2) as f64; + // Item 2 is never observed -> zero information for its Newton step. + observed[p * n_items + 2] = false; + } + let cfg = + MmleConfig { ridge_a: 0.0, ridge_b: 0.0, max_iter: 50, ..MmleConfig::default() }; + let res = fit_mmle_2pl(&y, &observed, n_persons, n_items, &cfg); + + assert!(res.a.iter().all(|v| v.is_finite()), "item slopes must stay finite"); + assert!(res.b.iter().all(|v| v.is_finite()), "item intercepts must stay finite"); + assert!(res.theta.iter().all(|t| t.is_finite()), "abilities must stay finite"); + // The zero-information item keeps its initial (a = 1, b = 0) because the + // Newton step breaks on the singular Hessian before any update applies. + assert_eq!(res.a[2], 1.0, "unobserved item slope must stay at its initial value"); + assert_eq!(res.b[2], 0.0, "unobserved item intercept must stay at its initial value"); + } +} diff --git a/crates/mlsirm-core/src/mokken.rs b/crates/mlsirm-core/src/mokken.rs deleted file mode 100644 index 78fa54432..000000000 --- a/crates/mlsirm-core/src/mokken.rs +++ /dev/null @@ -1,377 +0,0 @@ -//! Mokken scale analysis: Loevinger scalability coefficients and the -//! automated item selection procedure (AISP). -//! -//! Implements, for a complete integer response matrix (dichotomous or -//! polytomous), the sample scalability coefficients -//! -//! ```text -//! Hij = S_ij / Smax_ij -//! Hi = sum_{j != i} S_ij / sum_{j != i} Smax_ij -//! H = sum_{i < j} S_ij / sum_{i < j} Smax_ij -//! ``` -//! -//! where `S` is the sample covariance matrix (denominator N-1) and -//! `Smax_ij = cov(sort(X_i), sort(X_j))` is the maximum covariance -//! attainable given the two items' marginal score distributions — the -//! comonotone (sorted-sorted) coupling maximizes `sum x_p y_p` by the -//! rearrangement inequality, and the means are marginal-fixed, so it -//! maximizes the covariance; the N-1 denominators cancel in every ratio. -//! -//! `Hi` uses the ratio of PAIRWISE sums, exactly as the mokken R package -//! computes it (`coefHTiny`). Verified caveat: this is NOT generally equal -//! to a "max Cov(X_j, R_-j) holding the realized rest-score marginal fixed" -//! reading of van der Ark (2007, Eq. 2) — counterexample: X1=X2=[0,0,1,1], -//! X3=[0,1,0,1] gives fixed-marginal max 1/3 for item 1 but pairwise-sum -//! denominator 2/3. The pairwise-sum form is the de-facto MSA standard and -//! is what this module implements. -//! -//! Mokken's Z statistics (null hypothesis of inter-item independence) follow -//! the mokken package's `coefZ` (`type.z = "Z"`): -//! -//! ```text -//! Zij = S_ij * sqrt(N-1) / sqrt(s_ii * s_jj) -//! Zi = (sum_{j != i} S_ij) * sqrt(N-1) / sqrt(sum_{j != i} s_ii * s_jj) -//! Z = (sum_{i < j} S_ij) * sqrt(N-1) / sqrt(sum_{i < j} s_ii * s_jj) -//! ``` -//! -//! The AISP ("search normal") partitions items into Mokken scales: a start -//! pair maximizing `Hij` among pairs significantly positive (`|Zij| >= Z_c`) -//! with pair `H >= c`, then repeatedly adds the free item that (1) has no -//! negative `Hij` with any selected item (nonnegative allowed), (2) has -//! within-augmented-set `Hi >= c`, (3) has `Zi >= Z_c`, and (4) maximizes the -//! augmented set's total `H`; the scale closes when the best augmented-set -//! `H < c`, and further scales are formed from leftover items. The -//! significance level is Bonferroni-adjusted per scale as -//! `alpha / (K1*(K1-1)/2 + sum of later step candidate counts)`, with the -//! candidate-count vector resetting at each new scale, matching -//! `search.normal.R` (`adjusted.alpha`). -//! -//! Verification status: the coefficient definitions, rules of thumb, and the -//! Mokken-scale definition (all inter-item covariances nonnegative in the -//! selection sense and `Hi >= c > 0`) were read in van der Ark (2007) and -//! Straat et al. (2013); the exact sample statistics, Z forms, tie-breaking, -//! and AISP mechanics were verified line-by-line against the mokken R package -//! source (CRAN, `R/internalFunctions.R::coefHTiny`, `R/coefZ.R`, -//! `R/search.normal.R`). Mokken (1971) and Sijtsma & Molenaar (2002) were NOT -//! read directly; claims from them are relayed via the above sources. No -//! primary-source derivation of the Z normal approximation was verified; -//! it is implementation-verified only. -//! -//! References (APA 7th ed.): -//! - van der Ark, L. A. (2007). Mokken scale analysis in R. *Journal of -//! Statistical Software, 20*(11), 1-19. https://doi.org/10.18637/jss.v020.i11 -//! - Straat, J. H., van der Ark, L. A., & Sijtsma, K. (2013). Comparing -//! optimization algorithms for item selection in Mokken scale analysis. -//! *Journal of Classification, 30*(1), 75-99. -//! https://doi.org/10.1007/s00357-013-9122-y -//! - Mokken, R. J. (1971). *A theory and procedure of scale analysis*. -//! De Gruyter. (as cited in van der Ark, 2007, and Straat et al., 2013) -//! - Sijtsma, K., & Molenaar, I. W. (2002). *Introduction to nonparametric -//! item response theory*. Sage. (as cited in Straat et al., 2013) - -/// Scalability coefficients and Mokken Z statistics for one item set. -#[derive(Debug, Clone)] -pub struct MokkenH { - /// Row-major `n_items x n_items`; `hij[i*J + j] = Hij`, diagonal = NaN. - pub hij: Vec, - /// Per-item scalability `Hi`. - pub hi: Vec, - /// Total scale coefficient `H`. - pub h: f64, - /// Row-major `n_items x n_items` Mokken Z; diagonal = NaN. - pub zij: Vec, - /// Per-item Z. - pub zi: Vec, - /// Total Z. - pub z: f64, -} - -fn validate(x: &[i64], n_persons: usize, n_items: usize) -> Result<(), String> { - if n_persons < 3 { - return Err("mokken requires at least 3 persons".to_string()); - } - if n_items < 2 { - return Err("mokken requires at least 2 items".to_string()); - } - let expected = crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if x.len() != expected { - return Err(format!( - "responses length {} != n_persons*n_items {}", - x.len(), - expected - )); - } - if x.iter().any(|&v| v < 0) { - return Err("scores must be nonnegative integers".to_string()); - } - Ok(()) -} - -/// Pairwise machinery shared by `coef_h` and `aisp`: covariance matrix `s`, -/// sorted-column max-covariance matrix `smax`, and per-column variances -/// (diagonal of `s`). All with denominator N-1. -fn pairwise(x: &[i64], n_persons: usize, n_items: usize) -> Result<(Vec, Vec), String> { - let n = n_persons as f64; - let j = n_items; - // column means and centered columns; sorted centered columns for smax - let mut cols: Vec> = Vec::with_capacity(j); - let mut sorted: Vec> = Vec::with_capacity(j); - for it in 0..j { - let mut c: Vec = (0..n_persons).map(|p| x[p * j + it] as f64).collect(); - let mean = c.iter().sum::() / n; - for v in c.iter_mut() { - *v -= mean; - } - let mut s = c.clone(); - s.sort_by(|a, b| a.partial_cmp(b).expect("finite")); - cols.push(c); - sorted.push(s); - } - let denom = n - 1.0; - let mut s = vec![0.0; j * j]; - let mut smax = vec![0.0; j * j]; - for a in 0..j { - for b in a..j { - let cov = cols[a] - .iter() - .zip(cols[b].iter()) - .map(|(u, v)| u * v) - .sum::() - / denom; - let cmx = sorted[a] - .iter() - .zip(sorted[b].iter()) - .map(|(u, v)| u * v) - .sum::() - / denom; - s[a * j + b] = cov; - s[b * j + a] = cov; - smax[a * j + b] = cmx; - smax[b * j + a] = cmx; - } - if s[a * j + a] <= 0.0 { - return Err(format!("item {a} has zero variance")); - } - } - Ok((s, smax)) -} - -/// H and Z coefficients for the item subset `idx` (crate-internal; `idx` -/// indexes into the full `s`/`smax` matrices of width `j_full`). -fn h_subset(s: &[f64], smax: &[f64], j_full: usize, idx: &[usize], n_persons: usize) -> (Vec, f64, Vec, f64) { - let k = idx.len(); - let sqn = ((n_persons - 1) as f64).sqrt(); - let mut hi = vec![0.0; k]; - let mut zi = vec![0.0; k]; - let (mut num, mut den, mut vsum) = (0.0, 0.0, 0.0); - for (a, &ia) in idx.iter().enumerate() { - let (mut na, mut da, mut va) = (0.0, 0.0, 0.0); - for &ib in idx.iter() { - if ia == ib { - continue; - } - na += s[ia * j_full + ib]; - da += smax[ia * j_full + ib]; - va += s[ia * j_full + ia] * s[ib * j_full + ib]; - } - hi[a] = na / da; - zi[a] = na * sqn / va.sqrt(); - num += na; - den += da; - vsum += va; - } - // each unordered pair counted twice in the row sums - (hi, num / den, zi, (num / 2.0) * sqn / (vsum / 2.0).sqrt()) -} - -/// Compute `Hij`, `Hi`, `H` and the Mokken Z statistics for a complete -/// `n_persons x n_items` row-major integer score matrix. -pub fn coef_h(x: &[i64], n_persons: usize, n_items: usize) -> Result { - validate(x, n_persons, n_items)?; - let (s, smax) = pairwise(x, n_persons, n_items)?; - let j = n_items; - let sqn = ((n_persons - 1) as f64).sqrt(); - let mut hij = vec![f64::NAN; j * j]; - let mut zij = vec![f64::NAN; j * j]; - for a in 0..j { - for b in 0..j { - if a != b { - hij[a * j + b] = s[a * j + b] / smax[a * j + b]; - zij[a * j + b] = s[a * j + b] * sqn / (s[a * j + a] * s[b * j + b]).sqrt(); - } - } - } - let all: Vec = (0..j).collect(); - let (hi, h, zi, z) = h_subset(&s, &smax, j, &all, n_persons); - Ok(MokkenH { hij, hi, h, zij, zi, z }) -} - -/// Standard-normal upper quantile via inverse complementary error function -/// (Acklam-style rational approximation; |error| < 1.15e-9, sufficient for -/// an alpha cut-off). Returns z such that P(N(0,1) > z) = p. -pub(crate) fn normal_upper_quantile(p: f64) -> f64 { - // invert the CDF at 1 - p using Peter Acklam's approximation - let q = 1.0 - p; - debug_assert!(q > 0.0 && q < 1.0); - const A: [f64; 6] = [ - -3.969683028665376e+01, - 2.209460984245205e+02, - -2.759285104469687e+02, - 1.383577518672690e+02, - -3.066479806614716e+01, - 2.506628277459239e+00, - ]; - const B: [f64; 5] = [ - -5.447609879822406e+01, - 1.615858368580409e+02, - -1.556989798598866e+02, - 6.680131188771972e+01, - -1.328068155288572e+01, - ]; - const C: [f64; 6] = [ - -7.784894002430293e-03, - -3.223964580411365e-01, - -2.400758277161838e+00, - -2.549732539343734e+00, - 4.374664141464968e+00, - 2.938163982698783e+00, - ]; - const D: [f64; 4] = [ - 7.784695709041462e-03, - 3.224671290700398e-01, - 2.445134137142996e+00, - 3.754408661907416e+00, - ]; - let plow = 0.02425; - // standard Acklam sign convention: lower branch yields negative values, - // central passes through, upper is the negated lower expression. - if q < plow { - let r = (-2.0 * q.ln()).sqrt(); - (((((C[0] * r + C[1]) * r + C[2]) * r + C[3]) * r + C[4]) * r + C[5]) - / ((((D[0] * r + D[1]) * r + D[2]) * r + D[3]) * r + 1.0) - } else if q <= 1.0 - plow { - let r = q - 0.5; - let t = r * r; - (((((A[0] * t + A[1]) * t + A[2]) * t + A[3]) * t + A[4]) * t + A[5]) * r - / (((((B[0] * t + B[1]) * t + B[2]) * t + B[3]) * t + B[4]) * t + 1.0) - } else { - let r = (-2.0 * (1.0 - q).ln()).sqrt(); - -((((((C[0] * r + C[1]) * r + C[2]) * r + C[3]) * r + C[4]) * r + C[5]) - / ((((D[0] * r + D[1]) * r + D[2]) * r + D[3]) * r + 1.0)) - } -} - -/// Automated item selection procedure (Mokken's "search normal" AISP). -/// -/// Returns a per-item scale label: 0 = unscalable, 1, 2, ... in formation -/// order. `c` is the scalability lower bound (rule of thumb 0.3); `alpha` the -/// nominal significance level (default 0.05 in the literature). -pub fn aisp( - x: &[i64], - n_persons: usize, - n_items: usize, - c: f64, - alpha: f64, -) -> Result, String> { - validate(x, n_persons, n_items)?; - if !(0.0..1.0).contains(&c) { - return Err("lower bound c must be in [0, 1)".to_string()); - } - if !(alpha > 0.0 && alpha < 1.0) { - return Err("alpha must be in (0, 1)".to_string()); - } - let (s, smax) = pairwise(x, n_persons, n_items)?; - let j = n_items; - let sqn = ((n_persons - 1) as f64).sqrt(); - let hij = |a: usize, b: usize| s[a * j + b] / smax[a * j + b]; - let zij = |a: usize, b: usize| s[a * j + b] * sqn / (s[a * j + a] * s[b * j + b]).sqrt(); - - let mut in_set = vec![0u32; j]; - let mut scale = 0u32; - loop { - scale += 1; - let free: Vec = (0..j).filter(|&i| in_set[i] == 0).collect(); - if free.len() < 2 { - break; - } - // Bonferroni accumulation: k_counts[0] = K1 = #free at scale start; - // later entries are candidate counts of each add step (resets per scale). - let k1 = free.len() as f64; - let mut k_rest = 0.0f64; - let z_c = |k_rest: f64| { - let adj = alpha / (k1 * (k1 - 1.0) * 0.5 + k_rest); - normal_upper_quantile(adj) - }; - // start pair: max Hij among free pairs with |Zij| >= Z_c. Ties mirror - // mokken's eps rule (search.normal.R subtracts row*1e-10 where row is - // the LARGER member index): smaller larger-member index wins, then - // smaller smaller-member index. - let zc0 = z_c(0.0); - let mut best: Option<(usize, usize, f64)> = None; - for (ai, &a) in free.iter().enumerate() { - for &b in free.iter().skip(ai + 1) { - if zij(a, b).abs() < zc0 { - continue; - } - let h = hij(a, b); - let better = match best { - None => true, - Some((ba, bb, bh)) => h > bh || (h == bh && (b, a) < (bb, ba)), - }; - if better { - best = Some((a, b, h)); - } - } - } - let Some((a0, b0, h0)) = best else { break }; - // pair Hi == Hij for both members; require >= c - if h0 < c { - break; - } - let mut selected = vec![a0, b0]; - in_set[a0] = scale; - in_set[b0] = scale; - // add loop - loop { - let candidates: Vec = (0..j) - .filter(|&i| in_set[i] == 0) - .filter(|&i| selected.iter().all(|&sj| hij(i, sj) >= 0.0)) - .collect(); - if candidates.is_empty() { - break; - } - k_rest += candidates.len() as f64; - let zc = z_c(k_rest); - let mut best_h = f64::NEG_INFINITY; - let mut best_item = None; - for &cand in &candidates { - let mut aug = selected.clone(); - aug.push(cand); - let (hi, h_total, zi, _) = h_subset(&s, &smax, j, &aug, n_persons); - // candidate is last in aug - if hi[aug.len() - 1] < c { - continue; - } - if zi[aug.len() - 1] < zc { - continue; - } - if h_total > best_h { - best_h = h_total; - best_item = Some(cand); - } - } - match best_item { - Some(it) if best_h >= c => { - in_set[it] = scale; - selected.push(it); - } - _ => break, - } - } - } - Ok(in_set) -} - -#[cfg(test)] -#[path = "../../../tests/unit/mokken_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/nodes.rs b/crates/mlsirm-core/src/nodes.rs deleted file mode 100644 index a5ab250b0..000000000 --- a/crates/mlsirm-core/src/nodes.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Latent-space integration node sets shared by the marginal estimator and -//! the scoring module. -//! -//! Three constructions for the `xi in R^K` integral (the trait margins stay -//! on 1-D Gauss-Hermite): -//! -//! * `gh_tensor` — tensor-product Gauss-Hermite grid (`q^K` nodes). Exact for -//! near-polynomial integrands, exponential in `K`; the default for `K <= 3`. -//! * `halton` — Quasi-Monte Carlo: Halton low-discrepancy points mapped -//! through the inverse normal CDF, weights `1/N`. Error `O(N^-1 (log N)^K)` -//! vs `O(N^-1/2)` for plain MC (Jank 2005, QMC-EM). An optional Cranley- -//! Patterson random shift gives randomized QMC. -//! * `mc` — plain Monte Carlo EM draws (Wei & Tanner 1990; Meng & Schilling -//! 1996 for item factor analysis): seeded, reproducible standard-normal -//! points, weights `1/N`. -//! -//! All node sets are deterministic given their parameters — the Rust<->NumPy -//! parity contract extends to the QMC/MC constructions (same Halton radical -//! inverse, same inverse-CDF coefficients, same generator). - -use crate::quadrature::gh_rule; - -/// A weighted node set for the latent-space integral: `grid` is row-major -/// `n x latent_dim`, `logw` the log integration weights (summing to ~1). -pub struct XiNodes { - pub grid: Vec, - pub logw: Vec, -} - -/// Repository resource limits mirrored by Python's `FitConfig` validation. -pub const MAX_XI_POINTS: usize = 1_000_000; -pub const MAX_XI_LATENT_DIM: usize = 8; - -/// How to build the latent-space node set. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum XiRule { - /// Tensor Gauss-Hermite with `q_xi` nodes per axis. - GaussHermite { q_xi: usize }, - /// Halton QMC with `n` points; `shift_seed` != 0 applies a Cranley- - /// Patterson random shift (randomized QMC). - Halton { n: usize, shift_seed: u64 }, - /// Seeded Monte Carlo with `n` standard-normal points. - MonteCarlo { n: usize, seed: u64 }, -} - -pub fn build_xi_nodes(rule: XiRule, latent_dim: usize) -> Result { - if !(1..=MAX_XI_LATENT_DIM).contains(&latent_dim) { - return Err(format!( - "latent_dim must be in 1..={MAX_XI_LATENT_DIM} for latent-space nodes" - )); - } - match rule { - XiRule::GaussHermite { q_xi } => { - let (nodes, weights) = - gh_rule(q_xi).ok_or_else(|| format!("unsupported quadrature size {q_xi}"))?; - if latent_dim > 3 { - return Err( - "tensor Gauss-Hermite supports latent_dim <= 3; use Halton/MonteCarlo".into(), - ); - } - let n = q_xi - .checked_pow(latent_dim as u32) - .ok_or("Gauss-Hermite node count overflows usize")?; - let grid_len = n - .checked_mul(latent_dim) - .ok_or("Gauss-Hermite grid length overflows usize")?; - let mut grid = vec![0.0_f64; grid_len]; - let mut logw = vec![0.0_f64; n]; - for j in 0..n { - let mut rem = j; - for k in 0..latent_dim { - let idx = rem % q_xi; - rem /= q_xi; - grid[j * latent_dim + k] = nodes[idx]; - logw[j] += weights[idx].ln(); - } - } - Ok(XiNodes { grid, logw }) - } - XiRule::Halton { n, shift_seed } => { - let grid_len = checked_stochastic_grid_len("Halton", n, latent_dim)?; - if latent_dim > HALTON_PRIMES.len() { - return Err(format!( - "Halton rule supports latent_dim <= {}", - HALTON_PRIMES.len() - )); - } - let mut shift = vec![0.0_f64; latent_dim]; - if shift_seed != 0 { - let mut state = shift_seed; - for s in shift.iter_mut() { - *s = lcg_uniform(&mut state); - } - } - let mut grid = vec![0.0_f64; grid_len]; - for j in 0..n { - for k in 0..latent_dim { - // skip the first point (index j+1) — Halton index 0 is 0. - let mut u = radical_inverse(j as u64 + 1, HALTON_PRIMES[k]) + shift[k]; - if u >= 1.0 { - u -= 1.0; - } - grid[j * latent_dim + k] = inv_normal_cdf(u.clamp(1e-12, 1.0 - 1e-12)); - } - } - Ok(XiNodes { - grid, - logw: vec![-(n as f64).ln(); n], - }) - } - XiRule::MonteCarlo { n, seed } => { - let grid_len = checked_stochastic_grid_len("MonteCarlo", n, latent_dim)?; - let mut state = seed.max(1); - let mut grid = vec![0.0_f64; grid_len]; - for v in grid.iter_mut() { - // Box-Muller on LCG uniforms (deterministic, mirrored in NumPy). - *v = normal_draw(&mut state); - } - Ok(XiNodes { - grid, - logw: vec![-(n as f64).ln(); n], - }) - } - } -} - -fn checked_stochastic_grid_len(rule: &str, n: usize, latent_dim: usize) -> Result { - if n == 0 { - return Err(format!("{rule} rule needs n >= 1")); - } - if n > MAX_XI_POINTS { - return Err(format!( - "{rule} rule supports at most {MAX_XI_POINTS} points; got {n}" - )); - } - n.checked_mul(latent_dim) - .ok_or_else(|| format!("{rule} grid length overflows usize")) -} - -const HALTON_PRIMES: [u64; 6] = [2, 3, 5, 7, 11, 13]; - -/// Van der Corput radical inverse of `i` in base `b`. -fn radical_inverse(mut i: u64, b: u64) -> f64 { - let mut inv = 0.0_f64; - let mut f = 1.0 / b as f64; - while i > 0 { - inv += (i % b) as f64 * f; - i /= b; - f /= b as f64; - } - inv -} - -#[inline] -fn lcg_next(state: &mut u64) -> u64 { - *state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - *state -} - -#[inline] -fn lcg_uniform(state: &mut u64) -> f64 { - (lcg_next(state) >> 11) as f64 / (1u64 << 53) as f64 -} - -fn normal_draw(state: &mut u64) -> f64 { - let u1 = lcg_uniform(state).max(1e-12); - let u2 = lcg_uniform(state); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() -} - -/// Acklam's rational approximation to the standard-normal inverse CDF -/// (relative error < 1.15e-9; the same coefficients are used by the NumPy -/// reference for parity). -pub fn inv_normal_cdf(p: f64) -> f64 { - const A: [f64; 6] = [ - -3.969683028665376e+01, - 2.209460984245205e+02, - -2.759285104469687e+02, - 1.383577518672690e+02, - -3.066479806614716e+01, - 2.506628277459239e+00, - ]; - const B: [f64; 5] = [ - -5.447609879822406e+01, - 1.615858368580409e+02, - -1.556989798598866e+02, - 6.680131188771972e+01, - -1.328068155288572e+01, - ]; - const C: [f64; 6] = [ - -7.784894002430293e-03, - -3.223964580411365e-01, - -2.400758277161838e+00, - -2.549732539343734e+00, - 4.374664141464968e+00, - 2.938163982698783e+00, - ]; - const D: [f64; 4] = [ - 7.784695709041462e-03, - 3.224671290700398e-01, - 2.445134137142996e+00, - 3.754408661907416e+00, - ]; - const P_LOW: f64 = 0.02425; - if !(0.0..=1.0).contains(&p) { - return f64::NAN; - } - if p < P_LOW { - let q = (-2.0 * p.ln()).sqrt(); - (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) - / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0) - } else if p <= 1.0 - P_LOW { - let q = p - 0.5; - let r = q * q; - (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q - / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0) - } else { - let q = (-2.0 * (1.0 - p).ln()).sqrt(); - -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) - / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0) - } -} - -#[cfg(test)] -#[path = "../../../tests/unit/nodes_tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "../../../tests/unit/nodes_coverage_branch_tests.rs"] -mod coverage_branch_tests; diff --git a/crates/mlsirm-core/src/nominal.rs b/crates/mlsirm-core/src/nominal.rs deleted file mode 100644 index a4478c33d..000000000 --- a/crates/mlsirm-core/src/nominal.rs +++ /dev/null @@ -1,683 +0,0 @@ -//! Confirmatory MULTIDIMENSIONAL nominal response model (Bock, 1972; Thissen, Cai & Bock, 2010), -//! generalizing the unidimensional [`crate::poly::fit_nominal`] to `D` latent dimensions. -//! -//! Each item `i` has `n_cat` UNORDERED categories. Category `k` gets a free multidimensional -//! discrimination (slope) vector `a_ik` and intercept `c_ik`; the linear predictor for category `k` -//! is `eta_ik(theta) = sum_{d in S_i} a_ikd theta_d + c_ik` and the category probability is the -//! softmax `P(Y_i = k | theta) = softmax_k(eta_ik)`, with the baseline category `0` pinned -//! `a_i0 = 0`, `c_i0 = 0`. `S_i` is item `i`'s loading set (a 0/1 confirmatory pattern, items x D): -//! a category slope `a_ikd` is free only for `d in S_i`. `theta ~ MVN(0, I_D)`. -//! -//! At `D = 1` with `S_i = {0}` this is EXACTLY `poly::fit_nominal` (`eta_ik = a_ik theta + c_ik`, -//! the same general free-`a_k` parametrization — NOT the `a * s_k` scoring-contrast form). -//! -//! **Estimation.** Bock-Aitkin marginal MLE (EM) over the `D`-dimensional latent grid, reusing the -//! integration-node machinery of the compensatory MIRT (`nodes::build_xi_nodes`): `node_rule = "gh"` -//! uses the `q^D` Gauss-Hermite product grid (`D <= 3`); `"qmc"`/`"mc"` use `xi_points` Halton / -//! Monte-Carlo draws (`D <= 6`), the quasi-Monte-Carlo EM of Jank (2005). The node set is built ONCE -//! before the EM loop, and — because `theta ~ MVN(0, I)` never reparametrizes the nodes (unlike the -//! correlated-`Sigma` MIRT) — EM is strictly monotone in the (QMC-)approximated marginal likelihood. -//! The per-item M-step is a Newton on the concave multinomial-logit complete-data objective, byte-for- -//! byte the finite-difference-Hessian ascent of `poly::nominal_m_step` (ridge is Hessian conditioning -//! only, NOT a parameter prior, so the fit is genuine MML), generalized so the softmax residual -//! `resid_k = r_k - n P_k` drives `d/dc_ik = sum_node resid_k` and `d/da_ikd = sum_node resid_k theta_d`. -//! -//! **Identification.** Baseline category `a_i0 = c_i0 = 0` fixes the softmax reference; unit trait -//! variances fix the per-dimension slope scale and `E[theta] = 0` the intercept level; a PURE -//! single-dimension anchor item per dimension (`S_i = {d}`) pins the rotation to the coordinate axes -//! (a pure anchor forces every category slope onto axis `d`, so an orthogonal trait rotation must map -//! axis `d` to `+-e_d`; the confirmatory labels forbid axis permutation, leaving only per-dimension -//! reflection). The reflection `(a_i.d, theta_d) -> (-a_i.d, -theta_d)` is NOT canonicalized (as in -//! `fit_nominal`); parameters are identified up to it, and recovery is assessed up to per-dimension -//! reflection. -//! -//! # References (APA 7th ed.) -//! -//! Bock, R. D. (1972). Estimating item parameters and latent ability when responses are scored in two -//! or more nominal categories. *Psychometrika, 37*(1), 29-51. https://doi.org/10.1007/BF02291411 -//! -//! Thissen, D., Cai, L., & Bock, R. D. (2010). The nominal categories item response model. In M. L. -//! Nering & R. Ostini (Eds.), *Handbook of polytomous item response theory models* (pp. 43-75). -//! Routledge. -//! -//! Reckase, M. D. (2009). *Multidimensional item response theory*. Springer. -//! https://doi.org/10.1007/978-0-387-89976-3 -//! -//! Jank, W. (2005). Quasi-Monte Carlo sampling to improve the efficiency of Monte Carlo EM. -//! *Computational Statistics & Data Analysis, 48*(4), 685-701. https://doi.org/10.1016/j.csda.2004.03.019 - -use crate::marginal::XiRuleKind; -use crate::nodes::{build_xi_nodes, XiRule}; -use crate::poly::{gpcm_logprobs, solve_small}; -use crate::quadrature::SUPPORTED_Q; - -/// Maximum integration node count (bounds the `nodes x J x n_cat` count table) for BOTH the `Q^D` -/// grid and the `xi_points` QMC/MC point set. -const NM_MAX_NODES: usize = 200_000; -/// Total expected-count-table cap (`nodes * n_items * n_cat` f64s) — the memory the E-step allocates. -const NM_MAX_COUNT_CELLS: usize = 60_000_000; -/// Max latent dimensions for the Gauss-Hermite product grid (`D > 3` uses Halton/MonteCarlo). -const NM_MAX_DIMS: usize = 3; -/// Max latent dimensions for the Halton/MonteCarlo rules (= `HALTON_PRIMES.len()` in `nodes`; the -/// MonteCarlo builder has no internal cap, so this is its sole guard). -const NM_MAX_DIMS_QMC: usize = 6; -/// Sanity cap on the number of categories. -const NM_MAX_CAT: usize = 64; -/// Upper bound on caller-controlled EM iterations. -const NM_MAX_ITER: usize = 100_000; - -/// Configuration for [`fit_nominal`]. Defaults mirror the compensatory MIRT and `fit_nominal`. -#[derive(Clone, Copy, Debug)] -pub struct NominalConfig { - pub max_iter: usize, - pub tol: f64, - /// Gauss-Hermite nodes per dimension (used only for `xi_rule = GaussHermite`). - pub q: usize, - /// Newton (FD-Hessian) ridge — Hessian CONDITIONING only, NOT a parameter prior (so the fit - /// stays MML and reduces to `fit_nominal`). Matches `nominal_m_step`'s `1e-8`. - pub ridge: f64, - /// Inner Newton iterations per item M-step (matches `fit_nominal`'s `10`). - pub newton_iter: usize, - /// Integration node rule; `"gh"` (`D <= 3`), or `Halton`/`MonteCarlo` (`D <= 6`). - pub xi_rule: XiRuleKind, - /// QMC/MC point count (used only for `Halton`/`MonteCarlo`). - pub xi_points: usize, - /// Halton Cranley-Patterson shift seed / Monte-Carlo seed (nonzero by default). - pub xi_seed: u64, -} - -impl Default for NominalConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - q: 21, - ridge: 1e-8, - newton_iter: 10, - xi_rule: XiRuleKind::GaussHermite, - xi_points: 4000, - xi_seed: 0x9E37_79B9_7F4A_7C15, - } - } -} - -/// Result of [`fit_nominal`]. -#[derive(Clone, Debug)] -pub struct NominalResult { - pub n_dims: usize, - pub n_cat: usize, - /// Category slopes `a_ikd`, row-major `n_items * n_cat * n_dims`. The baseline category - /// (`k = 0`) and off-pattern entries (`d not in S_i`) are exactly `0.0`. - pub slope: Vec, - /// Category intercepts `c_ik`, row-major `n_items * n_cat` (baseline `k = 0` is exactly `0.0`). - pub intercept: Vec, - /// Per-person trait EAP `E[theta_jd | X_j]`, row-major `n_persons * n_dims`. - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub final_loglik_change: f64, - /// `sum_i (n_cat - 1) * (|S_i| + 1)` free item parameters. - pub n_parameters: usize, -} - -#[allow(clippy::too_many_arguments)] -fn validate( - y: &[usize], - observed: Option<&[bool]>, - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - cfg: &NominalConfig, -) -> Result { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(2..=NM_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={NM_MAX_CAT}; got {n_cat}")); - } - if !(1..=NM_MAX_ITER).contains(&cfg.max_iter) { - return Err(format!("max_iter must be in 1..={NM_MAX_ITER}")); - } - if !cfg.tol.is_finite() || cfg.tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - if !cfg.ridge.is_finite() || cfg.ridge <= 0.0 { - return Err("ridge must be finite and positive".into()); - } - // Rule-dependent dimension bound + node-count cap. GH caps at NM_MAX_DIMS (Q^D blows up); the - // QMC/MC rules cap at NM_MAX_DIMS_QMC and bound xi_points instead. `q` is used only by GH. - let n_nodes = match cfg.xi_rule { - XiRuleKind::GaussHermite => { - if !(1..=NM_MAX_DIMS).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={NM_MAX_DIMS} for the Gauss-Hermite grid; use \ - node_rule qmc/mc for D up to {NM_MAX_DIMS_QMC}" - )); - } - if !SUPPORTED_Q.contains(&cfg.q) { - return Err(format!("q must be one of {SUPPORTED_Q:?}; got {}", cfg.q)); - } - let mut n = 1usize; - for _ in 0..n_dims { - // SUPPORTED_Q and the three-dimension bound cap this at 41^3 = 68,921. - n *= cfg.q; - } - n - } - XiRuleKind::Halton | XiRuleKind::MonteCarlo => { - if !(1..=NM_MAX_DIMS_QMC).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={NM_MAX_DIMS_QMC} for the Halton/MonteCarlo rules" - )); - } - if !(1..=NM_MAX_NODES).contains(&cfg.xi_points) { - return Err(format!( - "xi_points must be in 1..={NM_MAX_NODES}; got {}", - cfg.xi_points - )); - } - cfg.xi_points - } - }; - // The expected-count table is n_nodes * n_items * n_cat f64s (a factor n_cat larger than the - // MIRT Bernoulli table): bound it with checked multiplies. - let cells = n_nodes - .checked_mul(n_items) - .and_then(|v| v.checked_mul(n_cat)) - .ok_or_else(|| "node * item * category count-table size overflows usize".to_string())?; - if cells > NM_MAX_COUNT_CELLS { - return Err(format!( - "count table {cells} cells exceeds the cap {NM_MAX_COUNT_CELLS}; reduce nodes/items/categories" - )); - } - let n_cells = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows usize".to_string())?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - // The count-table cap above bounds n_items, while validation bounds n_dims. - let n_l = n_items * n_dims; - if loading_pattern.len() != n_l { - return Err("loading_pattern must have length n_items * n_dims".into()); - } - for (idx, &v) in loading_pattern.iter().enumerate() { - if v != 0 && v != 1 { - return Err(format!("loading_pattern[{idx}] must be 0 or 1; got {v}")); - } - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - for p in 0..n_persons { - for i in 0..n_items { - if is_obs(p, i) && y[p * n_items + i] >= n_cat { - return Err("observed response categories must be < n_cat".into()); - } - } - } - // Every item loads >= 1 dimension; every DECLARED category is observed for the item (an - // unobserved category — interior or top — drives its intercept to -inf and leaves its D slopes - // unidentified, so it is rejected rather than fit; fit_nominal does not guard this). - for i in 0..n_items { - if !(0..n_dims).any(|d| loading_pattern[i * n_dims + d] != 0) { - return Err(format!( - "item {i} loads no dimension (all-zero loading_pattern row)" - )); - } - let mut seen = vec![false; n_cat]; - let mut any = false; - for p in 0..n_persons { - if is_obs(p, i) { - any = true; - seen[y[p * n_items + i]] = true; - } - } - if !any { - return Err(format!("item {i} has no observed responses")); - } - if let Some(k) = (0..n_cat).find(|&k| !seen[k]) { - return Err(format!( - "item {i} category {k} is never observed (under-identified nominal category); \ - every declared category must be observed" - )); - } - } - // Identification: every dimension needs a PURE single-loading anchor item. - for d in 0..n_dims { - let has_pure = (0..n_items).any(|i| { - loading_pattern[i * n_dims + d] != 0 - && (0..n_dims) - .filter(|&d2| loading_pattern[i * n_dims + d2] != 0) - .count() - == 1 - }); - if !has_pure { - return Err(format!( - "dimension {d} has no pure single-loading anchor item (needed for identification)" - )); - } - } - Ok(n_nodes) -} - -/// Negative expected complete-data log-lik and its gradient for ONE item of the multidimensional -/// nominal model. `params` is the item's free vector laid out as -/// `[a_{1,d0}, a_{1,d1}, .., a_{1,d_{L-1}}, a_{2,d0}, .., a_{K-1,d_{L-1}}, c_1, .., c_{K-1}]` -/// (`L = dims.len()`, `K = n_cat`), so the slopes are category-major over the loaded dims followed -/// by the intercepts. At `D = 1`, `L = 1` this is exactly `nominal_item_neg_ll_grad`'s -/// `[a_1..a_{K-1}, c_1..c_{K-1}]`. `nodes` is row-major `n_nodes * n_dims`; `counts[nd]` the expected -/// category counts at node `nd`. Softmax residual `resid_k = r_k - n P_k` gives -/// `d/dc_k = resid_k`, `d/da_kd = resid_k * theta_d`. -fn nm_item_neg_ll_grad( - params: &[f64], - dims: &[usize], - nodes: &[f64], - n_dims: usize, - counts: &[Vec], - n_cat: usize, -) -> (f64, Vec) { - let z = n_cat - 1; // free non-baseline categories - let l = dims.len(); - let sbase = vec![0.0f64; n_cat]; // gpcm_logprobs scores are irrelevant when base = 0 - let mut ll = 0.0f64; - let mut grad = vec![0.0f64; params.len()]; - let mut eta = vec![0.0f64; n_cat]; - let n_nodes = counts.len(); - for nd in 0..n_nodes { - // eta_k = sum_{d in S} a_kd * theta_d + c_k, with eta_0 = 0 (baseline). - eta[0] = 0.0; - for k in 1..n_cat { - let mut e = params[z * l + (k - 1)]; // c_k - let base = (k - 1) * l; - for (t, &d) in dims.iter().enumerate() { - e += params[base + t] * nodes[nd * n_dims + d]; - } - eta[k] = e; - } - let lp = gpcm_logprobs(0.0, &sbase, &eta); // log softmax(eta) - ll += counts[nd] - .iter() - .zip(&lp) - .map(|(r, l2)| r * l2) - .sum::(); - let n: f64 = counts[nd].iter().sum(); - // residual and gradient accumulation - for k in 1..n_cat { - let resid = counts[nd][k] - n * lp[k].exp(); - grad[z * l + (k - 1)] += resid; // d/dc_k - let base = (k - 1) * l; - for (t, &d) in dims.iter().enumerate() { - grad[base + t] += resid * nodes[nd * n_dims + d]; // d/da_kd - } - } - } - (-ll, grad.iter().map(|v| -v).collect()) -} - -/// Newton M-step for one item — byte-for-byte the finite-difference-Hessian ascent of -/// `poly::nominal_m_step` (ridge is Hessian conditioning only), generalized to the multidimensional -/// gradient. At `D = 1`, `dims = [0]` this reproduces `nominal_m_step` exactly. -#[allow(clippy::too_many_arguments)] -fn nm_m_step( - mut params: Vec, - dims: &[usize], - nodes: &[f64], - n_dims: usize, - counts: &[Vec], - n_cat: usize, - ridge: f64, - n_newton: usize, -) -> Vec { - let np = params.len(); - for _ in 0..n_newton { - let (f0, g) = nm_item_neg_ll_grad(¶ms, dims, nodes, n_dims, counts, n_cat); - let grad_norm = g.iter().map(|v| v * v).sum::().sqrt(); - if !f0.is_finite() || !grad_norm.is_finite() || grad_norm < 1e-9 { - break; - } - let h = 1e-5; - let mut hess = vec![vec![0.0f64; np]; np]; - for j in 0..np { - let mut pj = params.clone(); - pj[j] += h; - let (_f2, gj) = nm_item_neg_ll_grad(&pj, dims, nodes, n_dims, counts, n_cat); - for r in 0..np { - hess[r][j] = (gj[r] - g[r]) / h; - } - } - for r in 0..np { - for c in 0..np { - hess[r][c] = 0.5 * (hess[r][c] + hess[c][r]); - } - hess[r][r] += ridge; - } - let mut step = solve_small(hess, g.clone()); - let mut directional = g.iter().zip(&step).map(|(gi, si)| gi * si).sum::(); - if !step.iter().all(|s| s.is_finite()) || directional <= 0.0 { - step = g.clone(); - directional = grad_norm * grad_norm; - } - let mut max_step = step.iter().map(|s| s.abs()).fold(0.0f64, f64::max); - if max_step > 2.0 { - for s in &mut step { - *s *= 2.0 / max_step; - } - directional = g.iter().zip(&step).map(|(gi, si)| gi * si).sum(); - max_step = 2.0; - } - let mut alpha = 1.0f64; - let mut accepted = false; - for _ in 0..25 { - let candidate: Vec = params - .iter() - .zip(&step) - .map(|(value, direction)| value - alpha * direction) - .collect(); - let (candidate_f, _) = - nm_item_neg_ll_grad(&candidate, dims, nodes, n_dims, counts, n_cat); - if candidate_f.is_finite() && candidate_f <= f0 - 1e-4 * alpha * directional { - params = candidate; - accepted = true; - break; - } - alpha *= 0.5; - } - if !accepted || alpha * max_step < 1e-9 { - break; - } - } - params -} - -fn checked_em_loglik_change( - current: f64, - previous: Option, - iteration: usize, -) -> Result, String> { - if !current.is_finite() { - return Err(format!( - "non-finite observed-data log-likelihood at iteration {iteration}" - )); - } - let Some(previous) = previous else { - return Ok(None); - }; - let change = current - previous; - let monotonicity_tolerance = 32.0 * f64::EPSILON * (1.0 + previous.abs()); - if change < -monotonicity_tolerance { - return Err(format!( - "EM observed-data log-likelihood decreased at iteration {iteration}: delta={change:.6e}" - )); - } - Ok(Some(change)) -} - -/// Fit the confirmatory MULTIDIMENSIONAL nominal response model (Bock, 1972; Thissen, Cai & Bock, -/// 2010) by Bock-Aitkin marginal MLE. See the module docs for the model, estimation, and -/// identification. `y`/`observed` are row-major `n_persons * n_items` (`y` categories `0..n_cat-1`, -/// missing cells dropped under MAR); `loading_pattern` is row-major `n_items * n_dims` in `{0,1}`. -/// Returns `Err` on malformed / rotationally-underidentified / unobserved-category input. -#[allow(clippy::too_many_arguments)] -pub fn fit_nominal( - y: &[usize], - observed: Option<&[bool]>, - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - cfg: &NominalConfig, -) -> Result { - let _n_nodes = validate( - y, - observed, - loading_pattern, - n_persons, - n_items, - n_dims, - n_cat, - cfg, - )?; - - // Build the latent-integral node set once (fixed-node QMC-EM; monotone since theta ~ N(0,I)). - let xi_rule = match cfg.xi_rule { - XiRuleKind::GaussHermite => XiRule::GaussHermite { q_xi: cfg.q }, - XiRuleKind::Halton => XiRule::Halton { - n: cfg.xi_points, - shift_seed: cfg.xi_seed, - }, - XiRuleKind::MonteCarlo => XiRule::MonteCarlo { - n: cfg.xi_points, - seed: cfg.xi_seed.max(1), - }, - }; - let xn = build_xi_nodes(xi_rule, n_dims)?; - let (nodes, logw) = (xn.grid, xn.logw); - let qn = logw.len(); - let z = n_cat - 1; - - // Per-item loaded-dimension lists S_i and free-parameter vectors. - let dims_of: Vec> = (0..n_items) - .map(|i| { - (0..n_dims) - .filter(|&d| loading_pattern[i * n_dims + d] != 0) - .collect() - }) - .collect(); - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - - // Init: category slope = k on the item's FIRST loaded dim (0 on the others); intercept = - // log(freq_k / freq_0). At D = 1 (single loaded dim) this is fit_nominal's a_k = k, c_k init. - let mut params: Vec> = Vec::with_capacity(n_items); - for i in 0..n_items { - let l = dims_of[i].len(); - let mut p = vec![0.0f64; z * l + z]; - let mut freq = vec![1e-3f64; n_cat]; - for pp in 0..n_persons { - if is_obs(pp, i) { - freq[y[pp * n_items + i]] += 1.0; - } - } - let tot: f64 = freq.iter().sum(); - for f in freq.iter_mut() { - *f /= tot; - } - for k in 1..n_cat { - p[(k - 1) * l] = k as f64; // slope on the first loaded dim - p[z * l + (k - 1)] = (freq[k] / freq[0]).ln(); // c_k - } - params.push(p); - } - - let mut loglik_trace: Vec = Vec::with_capacity(cfg.max_iter + 1); - let mut converged = false; - let mut n_iter = 0usize; - let mut termination_reason = "max_iter_reached".to_string(); - let mut final_loglik_change = f64::NAN; - let mut theta = vec![0.0f64; n_persons * n_dims]; - - // reused buffers - let mut item_lp = vec![0.0f64; qn * n_cat]; // per-item, reused - let mut eta = vec![0.0f64; n_cat]; - let sbase = vec![0.0f64; n_cat]; - let mut log_node = vec![0.0f64; qn]; - - loop { - // Node x category log-probs per item. - let mut all_lp: Vec> = Vec::with_capacity(n_items); - for i in 0..n_items { - let l = dims_of[i].len(); - for nd in 0..qn { - eta[0] = 0.0; - for k in 1..n_cat { - let mut e = params[i][z * l + (k - 1)]; - let base = (k - 1) * l; - for (t, &d) in dims_of[i].iter().enumerate() { - e += params[i][base + t] * nodes[nd * n_dims + d]; - } - eta[k] = e; - } - let lp = gpcm_logprobs(0.0, &sbase, &eta); - item_lp[nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - all_lp.push(item_lp.clone()); - } - - // Streamed E-step: per person, posterior over nodes; expected category counts. - let mut counts = vec![vec![vec![0.0f64; n_cat]; qn]; n_items]; - let mut ll = 0.0f64; - for p in 0..n_persons { - log_node.copy_from_slice(&logw); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - let lp = &all_lp[i]; - for nd in 0..qn { - log_node[nd] += lp[nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in log_node.iter() { - denom += (v - mx).exp(); - } - ll += mx + denom.ln(); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - counts[i][nd][yc] += (log_node[nd] - mx).exp() / denom; - } - } - } - let previous = loglik_trace.last().copied(); - let change = checked_em_loglik_change(ll, previous, n_iter)?; - loglik_trace.push(ll); - - // Stopping: fit_nominal's RELATIVE tolerance + signed monotonic-decrease guard (NOT the - // MIRT .abs() check, which would accept a likelihood DECREASE as convergence). - if let Some(change) = change { - let prev = previous.expect("change requires a previous log-likelihood"); - final_loglik_change = change; - let stop_tol = cfg.tol * (1.0 + prev.abs()); - if final_loglik_change <= stop_tol { - converged = true; - termination_reason = "tolerance_met".to_string(); - break; - } - } - if n_iter == cfg.max_iter { - break; - } - - // M-step: per item, Newton mirroring nominal_m_step (multidimensional gradient). - for i in 0..n_items { - params[i] = nm_m_step( - params[i].clone(), - &dims_of[i], - &nodes, - n_dims, - &counts[i], - n_cat, - cfg.ridge, - cfg.newton_iter, - ); - } - n_iter += 1; - } - - // Final EAP pass under the returned parameters. - { - let mut all_lp: Vec> = Vec::with_capacity(n_items); - for i in 0..n_items { - let l = dims_of[i].len(); - for nd in 0..qn { - eta[0] = 0.0; - for k in 1..n_cat { - let mut e = params[i][z * l + (k - 1)]; - let base = (k - 1) * l; - for (t, &d) in dims_of[i].iter().enumerate() { - e += params[i][base + t] * nodes[nd * n_dims + d]; - } - eta[k] = e; - } - let lp = gpcm_logprobs(0.0, &sbase, &eta); - item_lp[nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - all_lp.push(item_lp.clone()); - } - for p in 0..n_persons { - log_node.copy_from_slice(&logw); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - let lp = &all_lp[i]; - for nd in 0..qn { - log_node[nd] += lp[nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in log_node.iter() { - denom += (v - mx).exp(); - } - for nd in 0..qn { - let post = (log_node[nd] - mx).exp() / denom; - for d in 0..n_dims { - theta[p * n_dims + d] += post * nodes[nd * n_dims + d]; - } - } - } - } - - // Assemble the dense (n_items * n_cat * n_dims) slope tensor + (n_items * n_cat) intercepts, - // with the baseline category and off-pattern entries exactly 0.0. - let mut slope = vec![0.0f64; n_items * n_cat * n_dims]; - let mut intercept = vec![0.0f64; n_items * n_cat]; - let mut n_parameters = 0usize; - for i in 0..n_items { - let l = dims_of[i].len(); - n_parameters += z * (l + 1); - for k in 1..n_cat { - intercept[i * n_cat + k] = params[i][z * l + (k - 1)]; - let base = (k - 1) * l; - for (t, &d) in dims_of[i].iter().enumerate() { - slope[(i * n_cat + k) * n_dims + d] = params[i][base + t]; - } - } - } - - let ll = *loglik_trace.last().expect("EM trace is never empty"); - let _ = ll; - Ok(NominalResult { - n_dims, - n_cat, - slope, - intercept, - theta, - loglik_trace, - n_iter, - converged, - termination_reason, - final_loglik_change, - n_parameters, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/nominal_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/oakes.rs b/crates/mlsirm-core/src/oakes.rs deleted file mode 100644 index 98ec40c01..000000000 --- a/crates/mlsirm-core/src/oakes.rs +++ /dev/null @@ -1,457 +0,0 @@ -//! Item-parameter standard errors for the marginal estimator via Oakes' -//! identity (Oakes 1999), the method Pritikin (2017, Cogent Psychology, -//! "A comparison of parameter covariance estimation methods for item response -//! models in an expectation-maximization framework") recommends over the -//! supplemented-EM family: at the MML solution -//! -//! `d^2 l / d xi d xi' = d^2 Q(xi | xi0) / d xi d xi' -//! + d^2 Q(xi | xi0) / d xi d xi0' at xi0 = xi` -//! -//! The first term (the M-step Hessian at a FIXED posterior) is differenced -//! from the analytic Q-gradient without re-running the E-step; the second -//! (cross) term needs one E-step per perturbed coordinate — `k + 1` E-steps -//! total, versus `2k` for a central difference of the marginal score. -//! -//! Scope: item-side parameters plus `tau` (per-item `alpha`/`b`/`zeta` as the -//! model frees them), conditional on the fitted population parameters; the -//! penalized (MAP) curvature is used, matching the estimator's objective. -//! Anchors, zero inflation and covariates are not supported here. E-steps run -//! on the CPU in f64 — finite differences would drown in the f32 GPU noise. -//! -//! # References -//! -//! Oakes, D. (1999). Direct calculation of the information matrix via the EM -//! algorithm. *Journal of the Royal Statistical Society Series B: Statistical -//! Methodology, 61*(2), 479–482. https://doi.org/10.1111/1467-9868.00188 -//! -//! Pritikin, J. N. (2017). A comparison of parameter covariance estimation -//! methods for item response models in an expectation-maximization framework. -//! *Cogent Psychology, 4*(1), Article 1279435. -//! https://doi.org/10.1080/23311908.2017.1279435 - -use crate::marginal::{ - build_contexts_pub as build_contexts, build_tables, e_step_pub as e_step, index_responses, - validate_inputs, Contexts, EStepCounts, Grids, MarginalConfig, PopulationSpec, XiRuleKind, -}; -use crate::nodes::build_xi_nodes; -use crate::quadrature::gh_rule; -use crate::{model_exec_flags, ModelConfig, PenaltyConfig}; - -pub struct OakesResult { - /// Parameter labels in vector order (`alpha:i`, `b:i`, `zeta:i:k`, `tau`). - pub labels: Vec, - /// Standard errors (sqrt of the diagonal of the inverse information). - pub se: Vec, - /// Observed (penalized) information matrix, row-major `k x k`. - pub information: Vec, -} - -struct ParamVec { - free_alpha: bool, - uses_space: bool, - /// tau occupies a slot only for the distance interaction kind. - tau_free: bool, - n_items: usize, - latent_dim: usize, -} - -impl ParamVec { - fn len(&self) -> usize { - let per_item = - 1 + usize::from(self.free_alpha) + if self.uses_space { self.latent_dim } else { 0 }; - self.n_items * per_item + usize::from(self.tau_free) - } - - fn labels(&self) -> Vec { - let mut out = Vec::new(); - for i in 0..self.n_items { - if self.free_alpha { - out.push(format!("alpha:{i}")); - } - out.push(format!("b:{i}")); - if self.uses_space { - for k in 0..self.latent_dim { - out.push(format!("zeta:{i}:{k}")); - } - } - } - if self.tau_free { - out.push("tau".into()); - } - out - } - - fn pack(&self, alpha: &[f64], b: &[f64], zeta: &[f64], tau: f64) -> Vec { - let mut v = Vec::with_capacity(self.len()); - for i in 0..self.n_items { - if self.free_alpha { - v.push(alpha[i]); - } - v.push(b[i]); - if self.uses_space { - for k in 0..self.latent_dim { - v.push(zeta[i * self.latent_dim + k]); - } - } - } - if self.tau_free { - v.push(tau); - } - v - } - - fn unpack(&self, v: &[f64]) -> (Vec, Vec, Vec, f64) { - let mut alpha = vec![0.0_f64; self.n_items]; - let mut b = vec![0.0_f64; self.n_items]; - let mut zeta = vec![0.0_f64; self.n_items * self.latent_dim]; - let mut cursor = 0usize; - for i in 0..self.n_items { - if self.free_alpha { - alpha[i] = v[cursor]; - cursor += 1; - } - b[i] = v[cursor]; - cursor += 1; - if self.uses_space { - for k in 0..self.latent_dim { - zeta[i * self.latent_dim + k] = v[cursor]; - cursor += 1; - } - } - } - let tau = if self.tau_free { v[cursor] } else { -30.0 }; - (alpha, b, zeta, tau) - } -} - -#[inline] -fn sigmoid(x: f64) -> f64 { - if x >= 0.0 { - 1.0 / (1.0 + (-x).exp()) - } else { - let ex = x.exp(); - ex / (1.0 + ex) - } -} - -/// Analytic gradient of the penalized expected complete-data log-likelihood -/// `Q(xi | posterior counts)` with respect to the packed parameter vector. -#[allow(clippy::too_many_arguments)] -fn q_gradient( - pv: &ParamVec, - xi: &[f64], - counts: &EStepCounts, - ctx: &Contexts, - grids: &Grids, - config: &ModelConfig, - factor_id: &[usize], - penalty: &PenaltyConfig, -) -> Vec { - let (alpha, b, zeta, tau) = pv.unpack(xi); - let (free_alpha, uses_space) = (pv.free_alpha, pv.uses_space); - let kind = crate::interaction_kind(config.model_type); - let (n_items, n_dims, latent_dim) = (config.n_items, config.n_dims, config.latent_dim); - let (q_t, n_x) = (grids.q_t, grids.n_x); - let cell = q_t * n_x; - let gamma = tau.exp(); - let mut g = vec![0.0_f64; pv.len()]; - let mut cursor = 0usize; - let mut g_tau = 0.0_f64; - for i in 0..n_items { - let d = factor_id[i]; - let a = if free_alpha { alpha[i].exp() } else { 1.0 }; - let (mut g_alpha, mut g_b) = (0.0_f64, 0.0_f64); - let mut g_zeta = vec![0.0_f64; latent_dim]; - for s in 0..ctx.n_ctx { - let (shift, scale) = (ctx.shift[s * n_dims + d], ctx.scale[s * n_dims + d]); - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = shift + scale * node_t; - for x in 0..n_x { - let idx = t * n_x + x; - let n = counts.nbar[(s * n_dims + d) * cell + idx] - - counts.mbar[(s * n_items + i) * cell + idx]; - let r = counts.rbar[(s * n_items + i) * cell + idx]; - if n <= 0.0 && r <= 0.0 { - continue; - } - let mut eta = a * theta + b[i]; - let mut dist = 1.0; - match kind { - crate::InteractionKind::None => {} - crate::InteractionKind::Distance => { - let mut dist2 = config.eps_distance; - for k in 0..latent_dim { - let diff = - grids.x_grid[x * latent_dim + k] - zeta[i * latent_dim + k]; - dist2 += diff * diff; - } - dist = dist2.sqrt(); - eta -= gamma * dist; - } - crate::InteractionKind::Inner => { - for k in 0..latent_dim { - eta += zeta[i * latent_dim + k] * grids.x_grid[x * latent_dim + k]; - } - } - } - let resid = r - n * sigmoid(eta); - g_b += resid; - if free_alpha { - g_alpha += resid * a * theta; - } - if uses_space { - for k in 0..latent_dim { - let deta = if kind == crate::InteractionKind::Distance { - gamma - * (grids.x_grid[x * latent_dim + k] - zeta[i * latent_dim + k]) - / dist - } else { - debug_assert_eq!(kind, crate::InteractionKind::Inner); - grids.x_grid[x * latent_dim + k] - }; - g_zeta[k] += resid * deta; - } - if kind == crate::InteractionKind::Distance { - g_tau += resid * (-gamma * dist); - } - } - } - } - } - g_b -= penalty.lambda_b * b[i]; - if free_alpha { - g_alpha -= penalty.lambda_alpha * (alpha[i] - penalty.mu_alpha); - g[cursor] = g_alpha; - cursor += 1; - } - g[cursor] = g_b; - cursor += 1; - if uses_space { - for k in 0..latent_dim { - g[cursor] = g_zeta[k] - penalty.lambda_zeta * zeta[i * latent_dim + k]; - cursor += 1; - } - } - } - if pv.tau_free { - g[cursor] = g_tau - penalty.lambda_tau * (tau - penalty.mu_tau); - } - g -} - -/// Invert a symmetric positive-definite matrix in place (Gauss-Jordan with -/// partial pivoting). Returns None when (numerically) singular. -fn invert(mut m: Vec, k: usize) -> Option> { - let mut inv = vec![0.0_f64; k * k]; - for i in 0..k { - inv[i * k + i] = 1.0; - } - for col in 0..k { - let mut piv = col; - for r in (col + 1)..k { - if m[r * k + col].abs() > m[piv * k + col].abs() { - piv = r; - } - } - if m[piv * k + col].abs() < 1e-12 { - return None; - } - if piv != col { - for c in 0..k { - m.swap(col * k + c, piv * k + c); - inv.swap(col * k + c, piv * k + c); - } - } - let d = m[col * k + col]; - for c in 0..k { - m[col * k + c] /= d; - inv[col * k + c] /= d; - } - for r in 0..k { - if r != col { - let f = m[r * k + col]; - if f != 0.0 { - for c in 0..k { - m[r * k + c] -= f * m[col * k + c]; - inv[r * k + c] -= f * inv[col * k + c]; - } - } - } - } - } - Some(inv) -} - -fn invert_information(information: Vec, k: usize) -> Result, String> { - invert(information, k) - .ok_or_else(|| "observed information is singular; SEs unavailable".to_string()) -} - -/// Observed-information standard errors via Oakes' identity at the fitted -/// parameters. `h` is the finite-difference step (default 1e-5 scaled). -#[allow(clippy::too_many_arguments)] -pub fn observed_information_oakes( - y: &[f64], - observed: &[bool], - factor_id: &[usize], - config: &ModelConfig, - pop: &PopulationSpec, - mcfg: &MarginalConfig, - penalty: &PenaltyConfig, - alpha: &[f64], - b: &[f64], - zeta: &[f64], - tau: f64, - mu: &[f64], - sigma: &[f64], - sigma_u: f64, - h: f64, -) -> Result { - if !h.is_finite() || h <= 0.0 { - return Err("Oakes finite-difference step h must be positive and finite".into()); - } - if mcfg.zero_inflation { - return Err("Oakes SEs with the zero-inflated mixture are not supported yet".into()); - } - validate_inputs(y, observed, factor_id, config, pop, mcfg)?; - if matches!(pop, PopulationSpec::SingleFree) { - return Err("Oakes SEs with fixed-item anchors are not supported yet".into()); - } - let expected_zeta = config - .n_items - .checked_mul(config.latent_dim) - .ok_or("n_items * latent_dim overflows")?; - if alpha.len() != config.n_items || b.len() != config.n_items || zeta.len() != expected_zeta { - return Err("alpha/b must match n_items and zeta must match n_items * latent_dim".into()); - } - if alpha - .iter() - .chain(b) - .chain(zeta) - .chain(std::iter::once(&tau)) - .any(|value| !value.is_finite()) - { - return Err("Oakes parameters must be finite".into()); - } - match pop { - PopulationSpec::Multigroup { n_groups, .. } => { - let expected = n_groups - .checked_mul(config.n_dims) - .ok_or("n_groups * n_dims overflows")?; - if mu.len() != expected || sigma.len() != expected { - return Err("multigroup mu/sigma must match n_groups * n_dims".into()); - } - if mu.iter().any(|value| !value.is_finite()) - || sigma - .iter() - .any(|value| !value.is_finite() || *value <= 0.0) - { - return Err("multigroup mu must be finite and sigma positive finite".into()); - } - } - PopulationSpec::Multilevel { .. } => { - if !sigma_u.is_finite() || sigma_u < 0.0 { - return Err("multilevel sigma_u must be finite and non-negative".into()); - } - } - PopulationSpec::Single | PopulationSpec::SingleFree => {} - } - let (free_alpha, uses_space) = model_exec_flags(config.model_type); - let pv = ParamVec { - free_alpha, - uses_space, - tau_free: crate::interaction_kind(config.model_type) == crate::InteractionKind::Distance, - n_items: config.n_items, - latent_dim: config.latent_dim, - }; - let k = pv.len(); - let (t_nodes, t_weights) = - gh_rule(mcfg.q_theta).ok_or_else(|| "unsupported q_theta".to_string())?; - let (x_grid, x_logw) = if uses_space { - let rule = match mcfg.xi_rule { - XiRuleKind::GaussHermite => crate::nodes::XiRule::GaussHermite { q_xi: mcfg.q_xi }, - XiRuleKind::Halton => crate::nodes::XiRule::Halton { - n: mcfg.xi_points, - shift_seed: mcfg.xi_seed, - }, - XiRuleKind::MonteCarlo => crate::nodes::XiRule::MonteCarlo { - n: mcfg.xi_points, - seed: mcfg.xi_seed.max(1), - }, - }; - let nodes = build_xi_nodes(rule, config.latent_dim)?; - (nodes.grid, nodes.logw) - } else { - (vec![0.0; config.latent_dim], vec![0.0]) - }; - let grids = Grids { - t_nodes: t_nodes.to_vec(), - t_logw: t_weights.iter().map(|w| w.ln()).collect(), - n_x: x_logw.len(), - x_grid, - x_logw, - q_t: mcfg.q_theta, - }; - let ctx = build_contexts(pop, mu, sigma, sigma_u, config.n_dims, mcfg.q_u); - let resp = index_responses(y, observed, config.n_persons, config.n_items); - - let estep_at = |xi_vec: &[f64]| -> EStepCounts { - let (a0, b0, z0, t0) = pv.unpack(xi_vec); - let tables = build_tables(&a0, &b0, &z0, t0, config, factor_id, &ctx, &grids); - e_step(&tables, &resp, factor_id, config, pop, &ctx, &grids) - }; - - let xi0 = pv.pack(alpha, b, zeta, tau); - let counts0 = estep_at(&xi0); - - // Term A: M-step Hessian — central FD of the Q-gradient over xi at the - // FIXED base posterior (no E-steps). - let mut info = vec![0.0_f64; k * k]; - for j in 0..k { - let hj = h * (1.0 + xi0[j].abs()); - let mut xp = xi0.clone(); - xp[j] += hj; - let mut xm = xi0.clone(); - xm[j] -= hj; - let gp = q_gradient(&pv, &xp, &counts0, &ctx, &grids, config, factor_id, penalty); - let gm = q_gradient(&pv, &xm, &counts0, &ctx, &grids, config, factor_id, penalty); - for c in 0..k { - info[j * k + c] += (gp[c] - gm[c]) / (2.0 * hj); - } - } - // Term B: cross derivative — forward FD over xi0 (one E-step per - // coordinate), gradient evaluated at the base xi. - let g0 = q_gradient( - &pv, &xi0, &counts0, &ctx, &grids, config, factor_id, penalty, - ); - for j in 0..k { - let hj = h * (1.0 + xi0[j].abs()); - let mut x0p = xi0.clone(); - x0p[j] += hj; - let counts_p = estep_at(&x0p); - let gp = q_gradient( - &pv, &xi0, &counts_p, &ctx, &grids, config, factor_id, penalty, - ); - for c in 0..k { - info[j * k + c] += (gp[c] - g0[c]) / hj; - } - } - // observed information = -(A + B), symmetrized - let mut sym = vec![0.0_f64; k * k]; - for r in 0..k { - for c in 0..k { - sym[r * k + c] = -0.5 * (info[r * k + c] + info[c * k + r]); - } - } - let inv = invert_information(sym.clone(), k)?; - let se: Vec = (0..k).map(|j| inv[j * k + j].max(0.0).sqrt()).collect(); - Ok(OakesResult { - labels: pv.labels(), - se, - information: sym, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/oakes_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/parallel.rs b/crates/mlsirm-core/src/parallel.rs deleted file mode 100644 index bc12ee701..000000000 --- a/crates/mlsirm-core/src/parallel.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Horn's parallel analysis for principal-component retention. -//! -//! Compares the eigenvalues of the Pearson correlation matrix of an observed -//! `n x p` data matrix against the mean (or a Glorfeld upper-centile) of -//! eigenvalues obtained from correlation matrices of `n_iterations` random -//! standard-normal data sets of the same shape. Each observed eigenvalue is -//! adjusted by the sampling bias `random_eigenvalue - 1`, and components are -//! retained while the adjusted eigenvalue stays above 1 (left-to-right scan; -//! the first failure stops retention even if later adjusted values rise back -//! above 1, matching the oracle's loop-and-break logic). -//! -//! # Verified sources -//! -//! - CRAN `paran` 1.5.6 R source (Dinno, READ line by line: `paran.R`): the -//! oracle for the full pipeline — observed correlation eigenvalues, random -//! benchmark (`RndEv` = per-position mean, or `quantile(..., centile/100)` -//! which is R type-7), `Bias = RndEv - 1`, `AdjEv = Ev - Bias`, and the -//! retention scan (`for` loop with `break` at the first `AdjEv <= 1`). -//! - `paran` man page (READ): documents the PCA adjustment -//! `lambda_p - (mean lambda^r_p - 1)` and the "adjusted eigenvalue > 1" -//! retention criterion; attributes the method to Horn (1965) and the -//! centile variant to Glorfeld (1995). -//! - Horn (1965) and Glorfeld (1995) themselves were NOT read (paywalled); -//! all attribution is as cited in and implemented by `paran`. -//! -//! # Divergences from the paran oracle (deliberate, documented) -//! -//! 1. **PCA path only.** paran's `cfa = TRUE` common-factor path (which -//! subtracts a generalized-inverse diagonal from `R`) is out of scope. -//! 2. **RNG.** Random data sets are drawn from this crate's deterministic -//! LCG + Box-Muller idiom in a single stream seeded once; results are -//! paran-inspired but NOT bit-identical to any R run. (paran's own -//! `set.seed(seed*k)` at `paran.R` line 35 references the loop index `k` -//! before it is defined, so the man page's per-iteration reseeding claim -//! is not faithfully implemented by the oracle either.) -//! 3. **Guards narrowed.** The oracle feeds `cor()`/`eigen()` whatever it is -//! given; this implementation rejects `n_persons < 3`, `n_items < 2`, -//! non-finite cells, zero-variance columns, `n_iterations == 0`, and -//! `centile > 99` with explicit errors. -//! 4. `iterations = 0` does NOT default to `30 * p` here; the core is -//! explicit and callers supply the default. -//! -//! Eigenvalues are computed with a cyclic Jacobi sweep (eigenvalues only); -//! non-convergence after the sweep cap is a hard error, never a silent -//! truncation. -//! -//! # References -//! -//! Dinno, A. (2018). *paran: Horn's test of principal components/factors* -//! (Version 1.5.6) \[R package\]. CRAN. -//! -//! -//! Glorfeld, L. W. (1995). An improvement on Horn's parallel analysis -//! methodology for selecting the correct number of factors to retain. -//! *Educational and Psychological Measurement, 55*(3), 377–393. -//! (not read; as cited in -//! Dinno, 2018) -//! -//! Horn, J. L. (1965). A rationale and a test for the number of factors in -//! factor analysis. *Psychometrika, 30*(2), 179–185. -//! (not read; as cited in Dinno, 2018) - -/// Outputs of Horn's parallel analysis, all vectors of length `n_items` in -/// descending observed-eigenvalue order. -#[derive(Debug, Clone)] -pub struct ParallelAnalysisResult { - /// Number of components retained (adjusted eigenvalue > 1 until the - /// first failure). - pub retained: usize, - /// Eigenvalues of the observed correlation matrix, descending. - pub eigenvalues: Vec, - /// Random-data benchmark eigenvalues (mean or centile per position). - pub random_eigenvalues: Vec, - /// `random_eigenvalues - 1`, the estimated sampling bias. - pub bias: Vec, - /// `eigenvalues - bias`. - pub adjusted_eigenvalues: Vec, -} - -const JACOBI_MAX_SWEEPS: usize = 100; -const JACOBI_TOL: f64 = 1e-12; - -/// Horn's parallel analysis (PCA path of `paran`, see module docs). -/// -/// * `data` — row-major `n_persons x n_items` matrix. -/// * `n_iterations` — number of random data sets (must be >= 1; callers -/// wanting the paran default should pass `30 * n_items`). -/// * `centile` — 0 for the mean benchmark (Horn, as implemented by paran), -/// or 1..=99 for Glorfeld's upper-centile variant (R type-7 quantile). -/// * `seed` — LCG seed for the random benchmark (0 is mapped to 1). -pub fn parallel_analysis( - data: &[f64], - n_persons: usize, - n_items: usize, - n_iterations: usize, - centile: u32, - seed: u64, -) -> Result { - if n_persons < 3 { - return Err("parallel analysis needs n_persons >= 3".into()); - } - if n_items < 2 { - return Err("parallel analysis needs n_items >= 2".into()); - } - if n_iterations == 0 { - return Err("n_iterations must be >= 1".into()); - } - if centile > 99 { - return Err("centile must be 0 (mean) or in 1..=99".into()); - } - let cells = n_persons - .checked_mul(n_items) - .ok_or("data dimensions overflow usize")?; - if data.len() != cells { - return Err(format!( - "data length {} does not match n_persons * n_items = {cells}", - data.len() - )); - } - if data.iter().any(|v| !v.is_finite()) { - return Err("data must be finite (no NaN/inf; complete data required)".into()); - } - - let corr = correlation_matrix(data, n_persons, n_items)?; - let eigenvalues = symmetric_eigenvalues_desc(&corr, n_items)?; - - // Random benchmark: n_iterations standard-normal data sets from a single - // deterministic LCG stream (crate idiom; see module docs, divergence 2). - let mut state = seed.max(1); - let sim_len = n_iterations - .checked_mul(n_items) - .ok_or("n_iterations * n_items overflows usize")?; - let mut sim = vec![0.0_f64; sim_len]; - let mut rand_data = vec![0.0_f64; cells]; - for k in 0..n_iterations { - for cell in rand_data.iter_mut() { - *cell = normal_draw(&mut state); - } - let rc = correlation_matrix(&rand_data, n_persons, n_items) - .map_err(|e| format!("random benchmark iteration {k}: {e}"))?; - let ev = symmetric_eigenvalues_desc(&rc, n_items) - .map_err(|e| format!("random benchmark iteration {k}: {e}"))?; - sim[k * n_items..(k + 1) * n_items].copy_from_slice(&ev); - } - - let mut random_eigenvalues = vec![0.0_f64; n_items]; - let mut column = vec![0.0_f64; n_iterations]; - for q in 0..n_items { - for k in 0..n_iterations { - column[k] = sim[k * n_items + q]; - } - random_eigenvalues[q] = if centile == 0 { - column.iter().sum::() / n_iterations as f64 - } else { - type7_quantile(&mut column, f64::from(centile) / 100.0) - }; - } - - let bias: Vec = random_eigenvalues.iter().map(|r| r - 1.0).collect(); - let adjusted_eigenvalues: Vec = - eigenvalues.iter().zip(&bias).map(|(e, b)| e - b).collect(); - let retained = retained_count(&adjusted_eigenvalues); - - Ok(ParallelAnalysisResult { - retained, - eigenvalues, - random_eigenvalues, - bias, - adjusted_eigenvalues, - }) -} - -/// Left-to-right retention scan (paran.R lines 250-267): retain components -/// until the first adjusted eigenvalue <= 1; later resurgences do not count. -pub(crate) fn retained_count(adjusted: &[f64]) -> usize { - for (i, a) in adjusted.iter().enumerate() { - if *a <= 1.0 { - return i; - } - } - adjusted.len() -} - -/// Pearson correlation matrix of the columns of a row-major `n x p` matrix. -pub(crate) fn correlation_matrix(data: &[f64], n: usize, p: usize) -> Result, String> { - let mut means = vec![0.0_f64; p]; - for row in 0..n { - for (j, m) in means.iter_mut().enumerate() { - *m += data[row * p + j]; - } - } - for m in means.iter_mut() { - *m /= n as f64; - } - // Column sums of squared deviations. - let mut ss = vec![0.0_f64; p]; - for row in 0..n { - for j in 0..p { - let d = data[row * p + j] - means[j]; - ss[j] += d * d; - } - } - for (j, s) in ss.iter().enumerate() { - if !s.is_finite() || !means[j].is_finite() { - return Err(format!( - "column {j} magnitude overflows the correlation computation" - )); - } - if *s <= 0.0 { - return Err(format!( - "column {j} has zero variance; Pearson correlation undefined" - )); - } - } - let mut corr = vec![0.0_f64; p * p]; - for i in 0..p { - corr[i * p + i] = 1.0; - for j in (i + 1)..p { - let mut sxy = 0.0_f64; - for row in 0..n { - sxy += (data[row * p + i] - means[i]) * (data[row * p + j] - means[j]); - } - let r = sxy / (ss[i].sqrt() * ss[j].sqrt()); - if !r.is_finite() { - return Err(format!( - "correlation of columns {i},{j} is not finite (data magnitude overflow)" - )); - } - corr[i * p + j] = r; - corr[j * p + i] = r; - } - } - Ok(corr) -} - -/// Eigenvalues of a symmetric `p x p` matrix by cyclic Jacobi rotations, -/// sorted descending. Errors if the off-diagonal has not converged below -/// `JACOBI_TOL` within `JACOBI_MAX_SWEEPS` sweeps. -fn symmetric_eigenvalues_desc(matrix: &[f64], p: usize) -> Result, String> { - let mut a = matrix.to_vec(); - for _ in 0..JACOBI_MAX_SWEEPS { - let mut off = 0.0_f64; - for i in 0..p { - for j in (i + 1)..p { - off = off.max(a[i * p + j].abs()); - } - } - if off < JACOBI_TOL { - let mut ev: Vec = (0..p).map(|i| a[i * p + i]).collect(); - ev.sort_by(|x, y| y.partial_cmp(x).expect("eigenvalues are finite")); - return Ok(ev); - } - for i in 0..p { - for j in (i + 1)..p { - let aij = a[i * p + j]; - if aij.abs() < JACOBI_TOL { - continue; - } - let theta = (a[j * p + j] - a[i * p + i]) / (2.0 * aij); - let sign = if theta >= 0.0 { 1.0 } else { -1.0 }; - let t = sign / (theta.abs() + (theta * theta + 1.0).sqrt()); - let c = 1.0 / (t * t + 1.0).sqrt(); - let s = t * c; - for k in 0..p { - let aik = a[i * p + k]; - let ajk = a[j * p + k]; - a[i * p + k] = c * aik - s * ajk; - a[j * p + k] = s * aik + c * ajk; - } - for k in 0..p { - let aki = a[k * p + i]; - let akj = a[k * p + j]; - a[k * p + i] = c * aki - s * akj; - a[k * p + j] = s * aki + c * akj; - } - } - } - } - Err("Jacobi eigenvalue iteration did not converge".into()) -} - -/// R type-7 quantile of `values` (reordered in place by the sort). -fn type7_quantile(values: &mut [f64], prob: f64) -> f64 { - values.sort_by(|x, y| x.partial_cmp(y).expect("eigenvalues are finite")); - let h = (values.len() - 1) as f64 * prob; - let lo = h.floor() as usize; - let hi = h.ceil() as usize; - if lo == hi { - values[lo] - } else { - values[lo] + (h - lo as f64) * (values[hi] - values[lo]) - } -} - -#[inline] -pub(crate) fn lcg_uniform(state: &mut u64) -> f64 { - *state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (*state >> 11) as f64 / (1u64 << 53) as f64 -} - -/// Box-Muller normal on LCG uniforms (crate idiom, mirrored in the NumPy -/// fixture script for the pinned test literals). -pub(crate) fn normal_draw(state: &mut u64) -> f64 { - let u1 = lcg_uniform(state).max(1e-12); - let u2 = lcg_uniform(state); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() -} - -#[cfg(test)] -#[path = "../../../tests/unit/parallel_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/poly.rs b/crates/mlsirm-core/src/poly.rs deleted file mode 100644 index e0c48d9ed..000000000 --- a/crates/mlsirm-core/src/poly.rs +++ /dev/null @@ -1,2433 +0,0 @@ -//! Polytomous item-response cells and their expected-complete-data gradients, -//! the Rust compute path for the polytomous LSIRM extension -//! (see `docs/papers/gpcm-nominal-design-spec.md` and its literature -//! resolution). All numerical work lives here; the NumPy functions in -//! `fast_mlsirm.estimators.marginal` are parity references only. -//! -//! Two response families over a shared linear predictor `base = a*theta + -//! interaction(x)`: -//! -//! - **GRM** (Samejima 1969, cumulative logit) — the identification-clean -//! default for the LSIRM family: the single latent-space interaction enters -//! every cumulative logit as one shared shift inside `base`, so nothing -//! cancels and no category scaling is forced. -//! - **GPCM** (Muraki 1992, adjacent-category softmax) — an option for -//! partial-credit scoring; the category-constant term cancels in the softmax, -//! so the space term enters category-score-scaled (a documented consequence). - -pub(crate) const POLY_MAX_CAT: usize = 64; -pub(crate) const POLY_MAX_ITER: usize = 100_000; - -fn validate_observed_categories( - y: &[usize], - observed: Option<&[bool]>, - n_cat: usize, -) -> Result<(), String> { - for (idx, &value) in y.iter().enumerate() { - if observed.map_or(true, |o| o[idx]) && value >= n_cat { - return Err("observed response categories must be < n_cat".into()); - } - } - Ok(()) -} - -pub(crate) fn validate_poly_item_parameters( - slope: &[f64], - cat_params: &[f64], - n_items: usize, - n_cat: usize, - model: PolyModel, -) -> Result<(), String> { - let expected_cat_params = crate::checked_mul_usize( - n_items, - n_cat - 1, - "n_items * (n_cat - 1) exceeds the category-parameter buffer size", - )?; - if slope.len() != n_items || cat_params.len() != expected_cat_params { - return Err("slope/cat_params must match n_items and n_cat".into()); - } - if slope - .iter() - .chain(cat_params.iter()) - .any(|value| !value.is_finite()) - { - return Err("slope/cat_params must be finite".into()); - } - if model == PolyModel::Grm { - for thresholds in cat_params.chunks_exact(n_cat - 1) { - if thresholds.windows(2).any(|pair| pair[0] <= pair[1]) { - return Err("GRM thresholds must be strictly decreasing within each item".into()); - } - } - } - Ok(()) -} - -#[inline] -fn log_sigmoid(x: f64) -> f64 { - if x >= 0.0 { - -(-x).exp().ln_1p() - } else { - x - x.exp().ln_1p() - } -} - -/// GRM category log-probabilities for one node. `thresholds` holds the `K-1` -/// cumulative boundary intercepts `beta_k` (ordered *decreasing* for a valid -/// distribution); `base` is the shared person-item linear predictor. Returns -/// `log P(Y = k)` for `k = 0..K-1`. `P(Y>=k) = sigmoid(base + beta_k)`. -/// Middle-category differences use an algebraically equivalent factorization -/// that remains finite when both cumulative logits are in the same extreme -/// tail. -/// -/// # References -/// -/// Samejima, F. (1969). Estimation of latent ability using a response pattern -/// of graded scores. *Psychometrika, 34*(S1), 1–97. -/// https://doi.org/10.1007/BF03372160 -pub fn grm_logprobs(base: f64, thresholds: &[f64]) -> Vec { - let kb = thresholds.len(); // number of boundaries = K-1 - let mut out = vec![0.0_f64; kb + 1]; - if kb == 0 { - out[0] = 0.0; - return out; - } - // category 0: 1 - sigmoid(base + beta_0) = sigmoid(-(base + beta_0)) - out[0] = log_sigmoid(-(base + thresholds[0])); - // middle categories 1..K-2: P = sigmoid(base+beta_{k-1}) - sigmoid(base+beta_k) - for k in 1..kb { - let upper = base + thresholds[k - 1]; - let lower = base + thresholds[k]; - // sigmoid(upper) - sigmoid(lower) - // = sigmoid(upper) * sigmoid(-lower) * (1 - exp(lower - upper)). - // `-expm1` preserves a narrow category and avoids subtracting two - // rounded log-sigmoids in the same extreme tail. - out[k] = log_sigmoid(upper) + log_sigmoid(-lower) + (-(lower - upper).exp_m1()).ln(); - } - // top category K-1: sigmoid(base + beta_{K-2}) - out[kb] = log_sigmoid(base + thresholds[kb - 1]); - out -} - -/// Gradient of the expected complete-data log-likelihood `sum_k r_k log P(Y=k)` -/// at one node for the GRM cell. Returns `(g_base, g_thresholds)` where -/// `g_thresholds[j]` is the derivative wrt boundary intercept `beta_j`. -pub fn grm_node_gradient(base: f64, thresholds: &[f64], counts: &[f64]) -> (f64, Vec) { - let kb = thresholds.len(); - let mut g_t = vec![0.0_f64; kb]; - let mut g_base = 0.0_f64; - if kb == 0 { - return (0.0, g_t); - } - let log_p = grm_logprobs(base, thresholds); - // Evaluate v/P in log space. Directly exponentiating a valid tail category - // can underflow P to zero even though its score contribution is finite. - for j in 0..kb { - let eta = base + thresholds[j]; - let log_v = log_sigmoid(eta) + log_sigmoid(-eta); - // d q / d s_j = r_{j+1}/P_{j+1} - r_j/P_j (boundary j sits between cats j and j+1) - let right = if counts[j + 1] == 0.0 { - 0.0 - } else { - counts[j + 1] * (log_v - log_p[j + 1]).exp() - }; - let left = if counts[j] == 0.0 { - 0.0 - } else { - counts[j] * (log_v - log_p[j]).exp() - }; - g_t[j] = right - left; - g_base += right - left; - } - (g_base, g_t) -} - -/// GPCM/nominal unified softmax cell. `scores[0] = intercepts[0] = 0` (baseline -/// category pinned). `psi_k = scores[k]*base + intercepts[k]`; returns the -/// stable `log softmax_k(psi)` for `k = 0..K-1`. Nests binary 2PL at `K=2`, -/// `scores=[0,1]`, `intercepts=[0,b]` (then `logP_1 = log_sigmoid(base+b)`). -pub fn gpcm_logprobs(base: f64, scores: &[f64], intercepts: &[f64]) -> Vec { - let k = scores.len(); - let mut psi = vec![0.0_f64; k]; - let mut m = f64::NEG_INFINITY; - for c in 0..k { - psi[c] = scores[c] * base + intercepts[c]; - if psi[c] > m { - m = psi[c]; - } - } - let mut sum = 0.0_f64; - for c in 0..k { - sum += (psi[c] - m).exp(); - } - let log_z = m + sum.ln(); - psi.iter().map(|&p| p - log_z).collect() -} - -/// Gradient of `sum_k r_k log P(Y=k)` at one node for the GPCM/nominal cell. -/// Returns `(g_intercepts, g_base, g_scores)` for the free coordinates -/// (`k = 1..K-1`); `g_intercepts[m-1] = resid_m`, `g_base = sum_k s_k*resid_k`, -/// `g_scores[m-1] = resid_m * base`, with `resid_k = r_k - n*P_k`. -pub fn gpcm_node_gradient( - base: f64, - scores: &[f64], - intercepts: &[f64], - counts: &[f64], -) -> (Vec, f64, Vec) { - let k = scores.len(); - let p: Vec = gpcm_logprobs(base, scores, intercepts) - .iter() - .map(|&l| l.exp()) - .collect(); - let n: f64 = counts.iter().sum(); - let resid: Vec = (0..k).map(|c| counts[c] - n * p[c]).collect(); - let g_intercepts: Vec = resid[1..].to_vec(); - let g_base: f64 = (0..k).map(|c| scores[c] * resid[c]).sum(); - let g_scores: Vec = resid[1..].iter().map(|&r| r * base).collect(); - (g_intercepts, g_base, g_scores) -} - -/// Polytomous response family for the unidimensional fitter. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum PolyModel { - /// Cumulative-logit Graded Response Model (Samejima) — the LSIRM-family default. - Grm, - /// Adjacent-category softmax Generalized Partial Credit Model (Muraki). - Gpcm, -} - -/// Result of [`fit_poly_unidim`]. `slope[i]` is item `i`'s discrimination `a_i`; -/// `cat_params[i]` holds the `K-1` free category parameters (GPCM additive -/// intercepts, or GRM cumulative thresholds). `n_iter` counts completed M-steps; -/// therefore `loglik_trace` contains `n_iter + 1` observed-data likelihoods and -/// its endpoint is evaluated at the returned parameters. -pub struct PolyFit { - pub slope: Vec, - pub cat_params: Vec>, - pub loglik: f64, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub loglik_trace: Vec, - pub final_delta: f64, - pub stopping_tolerance: f64, -} - -/// Solve `H x = g` for small dense `H` (K x K) by Gauss elimination with partial -/// pivoting. Returns `g` unchanged if singular (degenerate M-step step). -pub(crate) fn solve_small(mut h: Vec>, mut g: Vec) -> Vec { - let n = g.len(); - for col in 0..n { - let mut piv = col; - for r in col + 1..n { - if h[r][col].abs() > h[piv][col].abs() { - piv = r; - } - } - if h[piv][col].abs() < 1e-12 { - return g; // singular: fall back to gradient step - } - h.swap(col, piv); - g.swap(col, piv); - for r in 0..n { - if r == col { - continue; - } - let f = h[r][col] / h[col][col]; - for c in col..n { - h[r][c] -= f * h[col][c]; - } - g[r] -= f * g[col]; - } - } - (0..n).map(|i| g[i] / h[i][i]).collect() -} - -fn stabilized_newton_step( - mut step: Vec, - gradient: &[f64], - gradient_norm: f64, -) -> (Vec, f64, f64) { - let mut directional = gradient.iter().zip(&step).map(|(g, s)| g * s).sum::(); - if !step.iter().all(|value| value.is_finite()) || directional <= 0.0 { - step = gradient.to_vec(); - directional = gradient_norm * gradient_norm; - } - let max_step = step.iter().map(|value| value.abs()).fold(0.0_f64, f64::max); - if max_step <= 2.0 { - return (step, directional, max_step); - } - for value in &mut step { - *value *= 2.0 / max_step; - } - directional = gradient.iter().zip(&step).map(|(g, s)| g * s).sum(); - (step, directional, 2.0) -} - -fn checked_em_delta( - current: f64, - previous: Option, - tolerance: f64, - iteration: usize, -) -> Result, String> { - if !current.is_finite() { - return Err(format!( - "non-finite observed-data log-likelihood at iteration {iteration}" - )); - } - let Some(previous) = previous else { - return Ok(None); - }; - let delta = current - previous; - let stopping_tolerance = tolerance * (1.0 + previous.abs()); - let monotonic_tolerance = 32.0 * f64::EPSILON * (1.0 + previous.abs()); - if delta < -monotonic_tolerance { - return Err(format!( - "EM observed-data log-likelihood decreased at iteration {iteration}: \ - delta={delta:.6e}, monotonic_tolerance={monotonic_tolerance:.6e}" - )); - } - Ok(Some((delta, stopping_tolerance))) -} - -/// Negative expected complete-data log-lik and its gradient for one item over -/// the quadrature nodes. `params = [log_a, cat_1..cat_{K-1}]`; `counts[node]` is -/// the length-`K` expected category-count vector at that node. -fn item_neg_ll_grad( - params: &[f64], - nodes: &[f64], - counts: &[Vec], - model: PolyModel, -) -> (f64, Vec) { - let k = counts[0].len(); - let a = params[0].exp(); - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ll = 0.0_f64; - let mut grad = vec![0.0_f64; params.len()]; - for (nd, &theta) in nodes.iter().enumerate() { - let base = a * theta; - match model { - PolyModel::Gpcm => { - let mut intercepts = vec![0.0_f64; k]; - intercepts[1..].copy_from_slice(¶ms[1..]); - let lp = gpcm_logprobs(base, &scores, &intercepts); - ll += counts[nd].iter().zip(&lp).map(|(r, l)| r * l).sum::(); - let (g_ic, g_base, _g_sc) = - gpcm_node_gradient(base, &scores, &intercepts, &counts[nd]); - for m in 0..k - 1 { - grad[1 + m] += g_ic[m]; - } - grad[0] += g_base * base; // d base / d log_a = a*theta = base - } - PolyModel::Grm => { - let thr = ¶ms[1..]; - let lp = grm_logprobs(base, thr); - ll += counts[nd].iter().zip(&lp).map(|(r, l)| r * l).sum::(); - let (g_base, g_t) = grm_node_gradient(base, thr, &counts[nd]); - for m in 0..k - 1 { - grad[1 + m] += g_t[m]; - } - grad[0] += g_base * base; - } - } - } - (-ll, grad.iter().map(|v| -v).collect()) -} - -/// Newton M-step for one item: a few steps with a finite-difference Hessian on -/// the analytic gradient (the parameter count `K` is small). -fn m_step_item( - mut params: Vec, - nodes: &[f64], - counts: &[Vec], - model: PolyModel, - n_newton: usize, -) -> Vec { - let np = params.len(); - for _ in 0..n_newton { - let (f0, g) = item_neg_ll_grad(¶ms, nodes, counts, model); - let grad_norm = g.iter().map(|v| v * v).sum::().sqrt(); - if !f0.is_finite() || !grad_norm.is_finite() || grad_norm < 1e-9 { - break; - } - let h = 1e-5; - let mut hess = vec![vec![0.0_f64; np]; np]; - for j in 0..np { - let mut pj = params.clone(); - pj[j] += h; - let (_f2, gj) = item_neg_ll_grad(&pj, nodes, counts, model); - for r in 0..np { - hess[r][j] = (gj[r] - g[r]) / h; - } - } - // symmetrize + ridge for a well-posed solve - for r in 0..np { - for c in 0..np { - hess[r][c] = 0.5 * (hess[r][c] + hess[c][r]); - } - hess[r][r] += 1e-8; - } - let (step, directional, max_step) = - stabilized_newton_step(solve_small(hess, g.clone()), &g, grad_norm); - let mut alpha = 1.0_f64; - let mut accepted = false; - for _ in 0..25 { - let candidate: Vec = params - .iter() - .zip(&step) - .map(|(value, direction)| value - alpha * direction) - .collect(); - let (candidate_f, _) = item_neg_ll_grad(&candidate, nodes, counts, model); - if candidate_f.is_finite() && candidate_f <= f0 - 1e-4 * alpha * directional { - params = candidate; - accepted = true; - break; - } - alpha *= 0.5; - } - if !accepted || alpha * max_step < 1e-9 { - break; - } - } - params -} - -/// Unidimensional polytomous marginal MLE via Bock-Aitkin EM (no latent-space -/// interaction) — the Rust compute path validating the [`PolyModel`] cells in a -/// full EM loop. `y` is `n_persons * n_items`, row-major, categories `0..n_cat-1` -/// (complete data). `theta ~ N(0,1)` on the `q_theta`-node Gauss-Hermite grid. -/// -/// Convergence is checked on the observed-data log likelihood evaluated at the -/// same parameters that are returned. The M-step uses backtracking so that a -/// Newton proposal must improve its expected complete-data objective; a small -/// negative observed-data increment beyond floating-point roundoff is treated as -/// an algorithm error rather than as convergence (Dempster et al., 1977; Wu, -/// 1983). -/// -/// # References -/// -/// Dempster, A. P., Laird, N. M., & Rubin, D. B. (1977). Maximum likelihood from -/// incomplete data via the EM algorithm. *Journal of the Royal Statistical -/// Society: Series B (Methodological), 39*(1), 1–22. -/// https://doi.org/10.1111/j.2517-6161.1977.tb01600.x -/// -/// Wu, C. F. J. (1983). On the convergence properties of the EM algorithm. *The -/// Annals of Statistics, 11*(1), 95–103. -/// https://doi.org/10.1214/aos/1176346060 -#[allow(clippy::too_many_arguments)] -pub fn fit_poly_unidim( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - model: PolyModel, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(2..=POLY_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={POLY_MAX_CAT}")); - } - if !(1..=POLY_MAX_ITER).contains(&max_iter) { - return Err(format!("max_iter must be in 1..={POLY_MAX_ITER}")); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and > 0".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - validate_observed_categories(y, observed, n_cat)?; - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - let (nodes, weights) = crate::quadrature::require_gh_rule(q_theta, "q_theta")?; - let log_w: Vec = weights.iter().map(|w| w.ln()).collect(); - let qn = nodes.len(); - - // init: log_a = 0; category params from base rates (GPCM) / cumulative rates (GRM) - let mut params = vec![vec![0.0_f64; n_cat]; n_items]; - for i in 0..n_items { - let mut freq = vec![1e-3_f64; n_cat]; - for p in 0..n_persons { - if is_obs(p, i) { - freq[y[p * n_items + i]] += 1.0; - } - } - let tot: f64 = freq.iter().sum(); - for f in freq.iter_mut() { - *f /= tot; - } - match model { - PolyModel::Gpcm => { - for k in 1..n_cat { - params[i][k] = (freq[k] / freq[0]).ln(); - } - } - PolyModel::Grm => { - // beta_k = logit(P(Y >= k)); cumulative from the top, ordered decreasing - let mut cum = 0.0_f64; - for k in (1..n_cat).rev() { - cum += freq[k]; - let c = cum.clamp(1e-4, 1.0 - 1e-4); - params[i][k] = (c / (1.0 - c)).ln(); - } - } - } - } - - let mut it = 0; - let mut converged = false; - let mut termination_reason = "max_iter".to_owned(); - let mut final_delta = f64::INFINITY; - let mut stopping_tolerance = f64::INFINITY; - let mut loglik_trace = Vec::with_capacity(max_iter + 1); - loop { - // per-item cell log-probs at each node: item_lp[i][node*n_cat + k] - let mut item_lp = vec![vec![0.0_f64; qn * n_cat]; n_items]; - for i in 0..n_items { - let a = params[i][0].exp(); - for (nd, &theta) in nodes.iter().enumerate() { - let base = a * theta; - let lp = match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0_f64; n_cat]; - intercepts[1..].copy_from_slice(¶ms[i][1..]); - gpcm_logprobs(base, &scores, &intercepts) - } - PolyModel::Grm => grm_logprobs(base, ¶ms[i][1..]), - }; - item_lp[i][nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - } - // E-step: posteriors + expected counts r[i][node][k] - let mut counts = vec![vec![vec![0.0_f64; n_cat]; qn]; n_items]; - let mut ll = 0.0; - let mut log_node = vec![0.0_f64; qn]; - for p in 0..n_persons { - for nd in 0..qn { - log_node[nd] = log_w[nd]; - } - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - log_node[nd] += item_lp[i][nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0_f64; - for nd in 0..qn { - denom += (log_node[nd] - mx).exp(); - } - ll += mx + denom.ln(); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - let post = (log_node[nd] - mx).exp() / denom; - counts[i][nd][yc] += post; - } - } - } - if let Some((delta, threshold)) = - checked_em_delta(ll, loglik_trace.last().copied(), tol, it)? - { - final_delta = delta; - stopping_tolerance = threshold; - if final_delta <= stopping_tolerance { - loglik_trace.push(ll); - converged = true; - termination_reason = "tolerance".to_owned(); - break; - } - } - loglik_trace.push(ll); - if it == max_iter { - break; - } - // M-step per item. The next loop evaluates the observed likelihood at - // these exact parameters before either convergence or max_iter return. - for i in 0..n_items { - params[i] = m_step_item(params[i].clone(), nodes, &counts[i], model, 10); - } - it += 1; - } - - let ll = *loglik_trace.last().expect("EM trace is never empty"); - let slope: Vec = (0..n_items).map(|i| params[i][0].exp()).collect(); - let cat_params: Vec> = params.iter().map(|p| p[1..].to_vec()).collect(); - Ok(PolyFit { - slope, - cat_params, - loglik: ll, - n_iter: it, - converged, - termination_reason, - loglik_trace, - final_delta, - stopping_tolerance, - }) -} - -/// Result of [`fit_nominal`]. Per item, `scores[i]` holds the `K-1` free -/// category scoring values `a_1..a_{K-1}` and `intercepts[i]` the `K-1` free -/// intercepts `c_1..c_{K-1}` (the baseline category is pinned `a_0 = c_0 = 0`). -pub struct NominalFit { - pub scores: Vec>, - pub intercepts: Vec>, - pub loglik: f64, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub loglik_trace: Vec, - pub final_delta: f64, - pub stopping_tolerance: f64, -} - -/// Negative expected complete-data log-lik and gradient for one item of the -/// nominal model. `params = [a_1..a_{K-1}, c_1..c_{K-1}]` (2(K-1) free values); -/// the cell is `softmax_k(a_k·θ + c_k)` with `a_0 = c_0 = 0`. The gradient reuses -/// the softmax residual `r_k − n·P_k`: `∂/∂c_k = residual_k`, -/// `∂/∂a_k = residual_k·θ`, summed over the quadrature nodes. -fn nominal_item_neg_ll_grad( - params: &[f64], - nodes: &[f64], - counts: &[Vec], - n_cat: usize, -) -> (f64, Vec) { - let z = n_cat - 1; - let mut scores = vec![0.0_f64; n_cat]; - let mut intercepts = vec![0.0_f64; n_cat]; - for m in 0..z { - scores[m + 1] = params[m]; - intercepts[m + 1] = params[z + m]; - } - let mut ll = 0.0_f64; - let mut grad = vec![0.0_f64; 2 * z]; - for (nd, &theta) in nodes.iter().enumerate() { - let lp = gpcm_logprobs(theta, &scores, &intercepts); - ll += counts[nd].iter().zip(&lp).map(|(r, l)| r * l).sum::(); - let (g_int, _g_base, g_sc) = gpcm_node_gradient(theta, &scores, &intercepts, &counts[nd]); - for m in 0..z { - grad[m] += g_sc[m]; // d / d a_{m+1} - grad[z + m] += g_int[m]; // d / d c_{m+1} - } - } - (-ll, grad.iter().map(|v| -v).collect()) -} - -/// Newton M-step for one nominal item (finite-difference Hessian on the analytic -/// gradient), mirroring [`m_step_item`]. -fn nominal_m_step( - mut params: Vec, - nodes: &[f64], - counts: &[Vec], - n_cat: usize, - n_newton: usize, -) -> Vec { - let np = params.len(); - for _ in 0..n_newton { - let (f0, g) = nominal_item_neg_ll_grad(¶ms, nodes, counts, n_cat); - let grad_norm = g.iter().map(|v| v * v).sum::().sqrt(); - if !f0.is_finite() || !grad_norm.is_finite() || grad_norm < 1e-9 { - break; - } - let h = 1e-5; - let mut hess = vec![vec![0.0_f64; np]; np]; - for j in 0..np { - let mut pj = params.clone(); - pj[j] += h; - let (_f2, gj) = nominal_item_neg_ll_grad(&pj, nodes, counts, n_cat); - for r in 0..np { - hess[r][j] = (gj[r] - g[r]) / h; - } - } - for r in 0..np { - for c in 0..np { - hess[r][c] = 0.5 * (hess[r][c] + hess[c][r]); - } - hess[r][r] += 1e-8; - } - let (step, directional, max_step) = - stabilized_newton_step(solve_small(hess, g.clone()), &g, grad_norm); - let mut alpha = 1.0_f64; - let mut accepted = false; - for _ in 0..25 { - let candidate: Vec = params - .iter() - .zip(&step) - .map(|(value, direction)| value - alpha * direction) - .collect(); - let (candidate_f, _) = nominal_item_neg_ll_grad(&candidate, nodes, counts, n_cat); - if candidate_f.is_finite() && candidate_f <= f0 - 1e-4 * alpha * directional { - params = candidate; - accepted = true; - break; - } - alpha *= 0.5; - } - if !accepted || alpha * max_step < 1e-9 { - break; - } - } - params -} - -/// Unidimensional nominal categories model (Bock, 1972; Thissen, Cai & Bock, -/// 2010) by Bock-Aitkin marginal MLE. Each item has a free scoring function -/// `a_k` and intercept `c_k` per category, `P(Y=k|θ) = softmax_k(a_k·θ + c_k)`, -/// identified by the baseline constraint `a_0 = c_0 = 0` with `θ ~ N(0,1)`. The -/// GPCM is the special case `a_k = a·k` (integer-linear scoring), so the nominal -/// fit nests it; parameters are identified up to a joint reflection -/// `(a_k, θ) → (−a_k, −θ)`. `y` is `n_persons * n_items` row-major, categories -/// `0..n_cat-1`; reuses the softmax cell and residual gradient of the GPCM path. -/// -/// # References (APA 7th ed.) -/// -/// Bock, R. D. (1972). Estimating item parameters and latent ability when -/// responses are scored in two or more nominal categories. *Psychometrika, -/// 37*(1), 29–51. https://doi.org/10.1007/BF02291411 -/// -/// Thissen, D., Cai, L., & Bock, R. D. (2010). The nominal categories item -/// response model. In M. L. Nering & R. Ostini (Eds.), *Handbook of -/// polytomous item response theory models* (pp. 43–75). Routledge. -#[allow(clippy::too_many_arguments)] -pub fn fit_nominal( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(2..=POLY_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={POLY_MAX_CAT}")); - } - if !(1..=POLY_MAX_ITER).contains(&max_iter) { - return Err(format!("max_iter must be in 1..={POLY_MAX_ITER}")); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and > 0".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - let z = n_cat - 1; - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - for p in 0..n_persons { - for i in 0..n_items { - if is_obs(p, i) && y[p * n_items + i] >= n_cat { - return Err("observed response categories must be < n_cat".into()); - } - } - } - for i in 0..n_items { - if !(0..n_persons).any(|p| is_obs(p, i)) { - return Err(format!("item {i} has no observed responses")); - } - } - let (nodes, weights) = crate::quadrature::require_gh_rule(q_theta, "q_theta")?; - let log_w: Vec = weights.iter().map(|w| w.ln()).collect(); - let qn = nodes.len(); - - // init: integer (GPCM-like) scores a_k = k; intercepts from base rates - let mut params = vec![vec![0.0_f64; 2 * z]; n_items]; - for i in 0..n_items { - let mut freq = vec![1e-3_f64; n_cat]; - for p in 0..n_persons { - if is_obs(p, i) { - freq[y[p * n_items + i]] += 1.0; - } - } - let tot: f64 = freq.iter().sum(); - for f in freq.iter_mut() { - *f /= tot; - } - for m in 0..z { - params[i][m] = (m + 1) as f64; // a_{m+1} - params[i][z + m] = (freq[m + 1] / freq[0]).ln(); // c_{m+1} - } - } - - let mut it = 0; - let mut converged = false; - let mut termination_reason = "max_iter".to_owned(); - let mut final_delta = f64::INFINITY; - let mut stopping_tolerance = f64::INFINITY; - let mut loglik_trace = Vec::with_capacity(max_iter + 1); - loop { - let mut item_lp = vec![vec![0.0_f64; qn * n_cat]; n_items]; - for i in 0..n_items { - let mut scores = vec![0.0_f64; n_cat]; - let mut intercepts = vec![0.0_f64; n_cat]; - for m in 0..z { - scores[m + 1] = params[i][m]; - intercepts[m + 1] = params[i][z + m]; - } - for (nd, &theta) in nodes.iter().enumerate() { - let lp = gpcm_logprobs(theta, &scores, &intercepts); - item_lp[i][nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - } - let mut counts = vec![vec![vec![0.0_f64; n_cat]; qn]; n_items]; - let mut ll = 0.0; - let mut log_node = vec![0.0_f64; qn]; - for p in 0..n_persons { - for nd in 0..qn { - log_node[nd] = log_w[nd]; - } - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - log_node[nd] += item_lp[i][nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0_f64; - for nd in 0..qn { - denom += (log_node[nd] - mx).exp(); - } - ll += mx + denom.ln(); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - let post = (log_node[nd] - mx).exp() / denom; - counts[i][nd][yc] += post; - } - } - } - if let Some((delta, threshold)) = - checked_em_delta(ll, loglik_trace.last().copied(), tol, it)? - { - final_delta = delta; - stopping_tolerance = threshold; - if final_delta <= stopping_tolerance { - loglik_trace.push(ll); - converged = true; - termination_reason = "tolerance".to_owned(); - break; - } - } - loglik_trace.push(ll); - if it == max_iter { - break; - } - for i in 0..n_items { - params[i] = nominal_m_step(params[i].clone(), nodes, &counts[i], n_cat, 10); - } - it += 1; - } - - let ll = *loglik_trace.last().expect("EM trace is never empty"); - let scores: Vec> = params.iter().map(|p| p[0..z].to_vec()).collect(); - let intercepts: Vec> = params.iter().map(|p| p[z..2 * z].to_vec()).collect(); - Ok(NominalFit { - scores, - intercepts, - loglik: ll, - n_iter: it, - converged, - termination_reason, - loglik_trace, - final_delta, - stopping_tolerance, - }) -} - -/// Per-person polytomous person-fit result. -pub struct PolyPersonFit { - /// Standardized log-likelihood `l_z` per person. - pub lz: Vec, - /// Snijders (2001) `l_z*` corrected for the estimated trait. - pub lz_star: Vec, - /// EAP trait estimate used (per person). - pub theta_eap: Vec, - /// `l_z* < flag_threshold` (aberrant / misfitting response pattern). - pub flagged: Vec, -} - -/// Person-fit statistics for polytomous responses under a fitted GRM/GPCM: the -/// standardized log-likelihood `l_z` (Drasgow, Levine & Williams, 1985) and its -/// estimated-trait correction `l_z*` (Snijders, 2001), evaluated at the EAP -/// trait. With `l_0 = Σ_i log P_i(y_i|θ̂)`, `E = Σ_i Σ_k P_ik log P_ik`, and -/// `V = Σ_i (Σ_k P_ik (log P_ik)² − (Σ_k P_ik log P_ik)²)`, -/// `l_z = (l_0 − E) / √V`; `l_z*` subtracts the covariance of the log-likelihood -/// with the trait score (`c = ΣCov / ΣI`, `τ² = V − (ΣCov)²/ΣI`) and adds the -/// MAP prior score `r_0 = −(θ̂ − μ)/σ²`. The score derivative `∂/∂θ log P_ik` is -/// taken by central difference, so the routine is model-agnostic. This reduces -/// exactly to the binary [`crate::fitstats::person_fit`] `l_z` at `n_cat = 2`. -/// Low (negative) values flag aberrant patterns. -/// -/// # References (APA 7th ed.) -/// -/// Drasgow, F., Levine, M. V., & Williams, E. A. (1985). Appropriateness -/// measurement with polychotomous item response models and standardized -/// indices. *British Journal of Mathematical and Statistical Psychology, -/// 38*(1), 67–86. https://doi.org/10.1111/j.2044-8317.1985.tb00817.x -/// -/// Snijders, T. A. B. (2001). Asymptotic null distribution of person fit -/// statistics with estimated person parameter. *Psychometrika, 66*(3), -/// 331–342. https://doi.org/10.1007/BF02294437 -#[allow(clippy::too_many_arguments)] -pub fn poly_person_fit( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: &[f64], - cat_params: &[f64], - model: PolyModel, - q_theta: usize, - prior_mean: f64, - prior_sd: f64, - flag_threshold: f64, -) -> Result { - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - validate_poly_item_parameters(slope, cat_params, n_items, n_cat, model)?; - if !(prior_sd > 0.0) { - return Err("prior_sd must be positive".into()); - } - let z = n_cat - 1; - let (theta_eap, _sd) = score_poly_eap( - y, observed, n_persons, n_items, n_cat, slope, cat_params, model, q_theta, - )?; - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - let cell = |i: usize, theta: f64| -> Vec { - let a = slope[i]; - let cp = &cat_params[i * z..(i + 1) * z]; - let base = a * theta; - match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; n_cat]; - ic[1..].copy_from_slice(cp); - gpcm_logprobs(base, &scores, &ic) - } - PolyModel::Grm => grm_logprobs(base, cp), - } - }; - let h = 1e-4; - let mut lz = vec![f64::NAN; n_persons]; - let mut lz_star = vec![f64::NAN; n_persons]; - let mut flagged = vec![false; n_persons]; - for p in 0..n_persons { - let th = theta_eap[p]; - let (mut w, mut sv, mut sc, mut si) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); - let mut n_obs = 0usize; - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let lp = cell(i, th); - let lpp = cell(i, th + h); - let lpm = cell(i, th - h); - let (mut mu, mut e2, mut cov, mut info) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); - for k in 0..n_cat { - let pk = lp[k].exp(); - let lgk = lp[k]; - let dk = (lpp[k] - lpm[k]) / (2.0 * h); // d/dtheta log P_ik - mu += pk * lgk; - e2 += pk * lgk * lgk; - cov += pk * lgk * dk; - info += pk * dk * dk; - } - w += lp[y[p * n_items + i]] - mu; - sv += e2 - mu * mu; - sc += cov; - si += info; - n_obs += 1; - } - if n_obs < 2 || sv <= 0.0 { - continue; - } - lz[p] = w / sv.sqrt(); - let c = if si > 1e-12 { sc / si } else { 0.0 }; - let r0 = -(th - prior_mean) / (prior_sd * prior_sd); - let tau2 = sv - if si > 1e-12 { sc * sc / si } else { 0.0 }; - if tau2 > 1e-12 { - lz_star[p] = (w + c * r0) / tau2.sqrt(); - flagged[p] = lz_star[p] < flag_threshold; - } - } - Ok(PolyPersonFit { - lz, - lz_star, - theta_eap, - flagged, - }) -} - -/// Maximum-Fisher-information next-item selection for a polytomous CAT: returns -/// the index of the not-yet-`administered` bank item with the largest item -/// information at the current trait estimate `theta`, or `None` if all items are -/// administered. Reuses [`poly_item_information`]. -pub fn poly_cat_next_item( - theta: f64, - administered: &[bool], - slope: &[f64], - cat_params: &[f64], - n_items: usize, - n_cat: usize, - model: PolyModel, -) -> Option { - let z = n_cat - 1; - let mut best: Option = None; - let mut best_val = f64::NEG_INFINITY; - for i in 0..n_items { - if administered[i] { - continue; - } - let info = poly_item_information(theta, slope[i], &cat_params[i * z..(i + 1) * z], model); - if info > best_val { - best_val = info; - best = Some(i); - } - } - best -} - -/// Result of [`poly_cat_simulate`] — per simulee, the final EAP trait, its -/// posterior SD (the CAT standard error), and the number of items administered. -pub struct PolyCatResult { - pub theta_eap: Vec, - pub theta_sd: Vec, - pub n_used: Vec, -} - -/// Simulate a polytomous computerized adaptive test (Dodd, De Ayala & Koch, -/// 1995) for each true trait in `true_theta`, over a fixed GRM/GPCM item bank. -/// Items are picked by maximum Fisher information at the running EAP estimate -/// (or at random when `adaptive = false`, a baseline), responses are generated -/// at the simulee's true trait, and the trait + posterior SD are re-estimated -/// after each item via [`score_poly_eap`]. Administration stops once at least -/// `min_items` are given and the posterior SD falls below `se_threshold`, or at -/// `max_items`. Set `se_threshold = 0` with `min_items = max_items` for a -/// fixed-length CAT. -/// -/// # References (APA 7th ed.) -/// -/// Dodd, B. G., De Ayala, R. J., & Koch, W. R. (1995). Computerized adaptive -/// testing with polytomous items. *Applied Psychological Measurement, 19*(1), -/// 5–22. https://doi.org/10.1177/014662169501900103 -#[allow(clippy::too_many_arguments)] -pub fn poly_cat_simulate( - true_theta: &[f64], - slope: &[f64], - cat_params: &[f64], - n_items: usize, - n_cat: usize, - model: PolyModel, - q_theta: usize, - se_threshold: f64, - min_items: usize, - max_items: usize, - adaptive: bool, - seed: u64, -) -> Result { - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - validate_poly_item_parameters(slope, cat_params, n_items, n_cat, model)?; - if true_theta.iter().any(|value| !value.is_finite()) { - return Err("true_theta must be finite".into()); - } - if n_items < 2 { - return Err("CAT needs a bank of at least 2 items".into()); - } - let z = n_cat - 1; - let n_sim = true_theta.len(); - let max_it = max_items.min(n_items); - let mut st = seed.max(1); - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let cell = |i: usize, theta: f64| -> Vec { - let base = slope[i] * theta; - let cp = &cat_params[i * z..(i + 1) * z]; - match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; n_cat]; - ic[1..].copy_from_slice(cp); - gpcm_logprobs(base, &scores, &ic) - } - PolyModel::Grm => grm_logprobs(base, cp), - } - }; - let mut theta_eap = vec![0.0_f64; n_sim]; - let mut theta_sd = vec![0.0_f64; n_sim]; - let mut n_used = vec![0usize; n_sim]; - for s in 0..n_sim { - let tt = true_theta[s]; - let mut administered = vec![false; n_items]; - let mut y = vec![0usize; n_items]; - let mut th = 0.0_f64; - let mut se = f64::INFINITY; - let mut count = 0usize; - while count < max_it { - if count >= min_items && se < se_threshold { - break; - } - let item = if adaptive { - poly_cat_next_item(th, &administered, slope, cat_params, n_items, n_cat, model) - .expect("count < max_items.min(n_items) leaves an unadministered item") - } else { - let remaining: Vec = (0..n_items).filter(|&i| !administered[i]).collect(); - remaining[((u() * remaining.len() as f64) as usize).min(remaining.len() - 1)] - }; - // simulate the response at the true trait - let lp = cell(item, tt); - let draw = u(); - let (mut acc, mut cat) = (0.0_f64, n_cat - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - administered[item] = true; - y[item] = cat; - count += 1; - let (eap, sd) = score_poly_eap( - &y, - Some(&administered), - 1, - n_items, - n_cat, - slope, - cat_params, - model, - q_theta, - ) - .expect( - "validated item parameters and generated in-range categories must remain scoreable", - ); - th = eap[0]; - se = sd[0]; - } - theta_eap[s] = th; - theta_sd[s] = se; - n_used[s] = count; - } - Ok(PolyCatResult { - theta_eap, - theta_sd, - n_used, - }) -} - -/// Result of [`fit_poly_multigroup`]. `slope`/`cat_params` are the parameters -/// common across groups; when an item is studied, `studied_slope`/`studied_cat` -/// hold its per-group parameters (length `n_groups`). `mu`/`sigma` are the -/// per-group latent means/SDs (reference group 0 pinned to `N(0,1)`). -pub struct TwoGroupPolyFit { - pub slope: Vec, - pub cat_params: Vec>, - pub studied_slope: Vec, - pub studied_cat: Vec>, - pub mu: Vec, - pub sigma: Vec, - pub loglik: f64, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub loglik_trace: Vec, - pub final_delta: f64, - pub stopping_tolerance: f64, -} - -#[derive(Clone, Copy)] -enum MultigroupEmStatus { - First, - Continue { delta: f64, tolerance: f64 }, - Converged { delta: f64, tolerance: f64 }, - NonFinite, - NonMonotone, -} - -fn multigroup_em_status(current: f64, previous: Option, tol: f64) -> MultigroupEmStatus { - if !current.is_finite() { - return MultigroupEmStatus::NonFinite; - } - let Some(previous) = previous else { - return MultigroupEmStatus::First; - }; - let delta = current - previous; - let tolerance = tol * (1.0 + previous.abs()); - let monotonic_tolerance = 32.0 * f64::EPSILON * (1.0 + previous.abs()); - if delta < -monotonic_tolerance { - MultigroupEmStatus::NonMonotone - } else if delta <= tolerance { - MultigroupEmStatus::Converged { delta, tolerance } - } else { - MultigroupEmStatus::Continue { delta, tolerance } - } -} - -#[allow(clippy::too_many_arguments)] -fn record_multigroup_em_status( - status: MultigroupEmStatus, - current: f64, - trace: &mut Vec, - converged: &mut bool, - termination_reason: &mut String, - final_delta: &mut f64, - stopping_tolerance: &mut f64, -) -> bool { - match status { - MultigroupEmStatus::NonFinite => { - *termination_reason = "non_finite".to_owned(); - true - } - MultigroupEmStatus::NonMonotone => { - trace.push(current); - *termination_reason = "non_monotone".to_owned(); - true - } - MultigroupEmStatus::Converged { delta, tolerance } => { - trace.push(current); - *final_delta = delta; - *stopping_tolerance = tolerance; - *converged = true; - *termination_reason = "tolerance".to_owned(); - true - } - MultigroupEmStatus::Continue { delta, tolerance } => { - trace.push(current); - *final_delta = delta; - *stopping_tolerance = tolerance; - false - } - MultigroupEmStatus::First => { - trace.push(current); - false - } - } -} - -/// Multi-group polytomous marginal MLE (Bock-Zimowski population), the estimator -/// behind the likelihood-ratio DIF test. Persons carry a `group_id` (group 0 is -/// the reference, pinned to `N(0,1)`); each other group's latent distribution -/// `N(mu_g, sigma_g^2)` is estimated. Items are common across groups (the -/// anchor) except `studied_item`, whose parameters are freed per group in the -/// augmented model (`studied_item = Some(j)`); with `studied_item = None` all -/// items are common (the compact model). The node-shift reparameterization -/// `theta_{g,t} = mu_g + sigma_g x_t` keeps the shared Gauss-Hermite weights, and -/// the per-item M-step reuses [`m_step_item`] by stacking each group's nodes and -/// expected counts (the concatenation is exactly the Bock-Zimowski pooling). -/// -/// # References (APA 7th ed.) -/// -/// Bock, R. D., & Zimowski, M. F. (1997). Multiple group IRT. In W. J. van der -/// Linden & R. K. Hambleton (Eds.), *Handbook of modern item response theory* -/// (pp. 433–448). Springer. https://doi.org/10.1007/978-1-4757-2691-6_25 -#[allow(clippy::too_many_arguments)] -pub fn fit_poly_multigroup( - y: &[usize], - observed: Option<&[bool]>, - group_id: &[usize], - n_groups: usize, - n_persons: usize, - n_items: usize, - n_cat: usize, - model: PolyModel, - studied_item: Option, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(2..=POLY_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={POLY_MAX_CAT}")); - } - if !(1..=POLY_MAX_ITER).contains(&max_iter) { - return Err(format!("max_iter must be in 1..={POLY_MAX_ITER}")); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and > 0".into()); - } - if n_groups < 2 { - return Err("n_groups must be >= 2".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if group_id.len() != n_persons { - return Err("group_id must have length n_persons".into()); - } - if group_id.iter().any(|&g| g >= n_groups) { - return Err("group_id labels must be < n_groups".into()); - } - // Every declared group must be populated, otherwise the `df = (n_groups-1)* - // n_cat` used by the LR test would count parameters no data can identify - // (an empty group's item params stay at init and contribute nothing to the - // likelihood), making the test miscalibrated. Callers with sparse labels - // should compact them first (the Python wrapper does). - let mut group_n = vec![0usize; n_groups]; - for &g in group_id { - group_n[g] += 1; - } - if group_n.iter().any(|&c| c == 0) { - return Err("every group 0..n_groups-1 must contain at least one person".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - validate_observed_categories(y, observed, n_cat)?; - if let Some(j) = studied_item { - if j >= n_items { - return Err("studied_item out of range".into()); - } - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - let (nodes, weights) = crate::quadrature::require_gh_rule(q_theta, "q_theta")?; - let log_w: Vec = weights.iter().map(|w| w.ln()).collect(); - let qn = nodes.len(); - - // pooled init from all groups (same scheme as fit_poly_unidim) - let mut params = vec![vec![0.0_f64; n_cat]; n_items]; - for i in 0..n_items { - let mut freq = vec![1e-3_f64; n_cat]; - for p in 0..n_persons { - if is_obs(p, i) { - freq[y[p * n_items + i]] += 1.0; - } - } - let tot: f64 = freq.iter().sum(); - for f in freq.iter_mut() { - *f /= tot; - } - match model { - PolyModel::Gpcm => { - for k in 1..n_cat { - params[i][k] = (freq[k] / freq[0]).ln(); - } - } - PolyModel::Grm => { - let mut cum = 0.0_f64; - for k in (1..n_cat).rev() { - cum += freq[k]; - let c = cum.clamp(1e-4, 1.0 - 1e-4); - params[i][k] = (c / (1.0 - c)).ln(); - } - } - } - } - let mut studied_params: Vec> = match studied_item { - Some(j) => vec![params[j].clone(); n_groups], - None => Vec::new(), - }; - let mut mu = vec![0.0_f64; n_groups]; - let mut sigma = vec![1.0_f64; n_groups]; - - let mut ll: f64; - let mut it = 0; - let mut converged = false; - let mut termination_reason = "max_iter".to_owned(); - let mut final_delta = f64::INFINITY; - let mut stopping_tolerance = f64::INFINITY; - let mut loglik_trace = Vec::with_capacity(max_iter + 1); - loop { - // group-specific trait locations for the shared standard nodes - let theta: Vec> = (0..n_groups) - .map(|g| nodes.iter().map(|&x| mu[g] + sigma[g] * x).collect()) - .collect(); - // per-group cell log-probs - let mut item_lp = vec![vec![vec![0.0_f64; qn * n_cat]; n_items]; n_groups]; - for g in 0..n_groups { - for i in 0..n_items { - let p_i = if Some(i) == studied_item { - &studied_params[g] - } else { - ¶ms[i] - }; - let a = p_i[0].exp(); - for (t, &th) in theta[g].iter().enumerate() { - let base = a * th; - let lp = match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; n_cat]; - ic[1..].copy_from_slice(&p_i[1..]); - gpcm_logprobs(base, &scores, &ic) - } - PolyModel::Grm => grm_logprobs(base, &p_i[1..]), - }; - item_lp[g][i][t * n_cat..(t + 1) * n_cat].copy_from_slice(&lp); - } - } - } - // E-step: per-group posteriors, expected counts, and trait moments - let mut counts = vec![vec![vec![vec![0.0_f64; n_cat]; qn]; n_items]; n_groups]; - let mut w_acc = vec![0.0_f64; n_groups]; - let mut s1 = vec![0.0_f64; n_groups]; - let mut s2 = vec![0.0_f64; n_groups]; - ll = 0.0; - let mut log_node = vec![0.0_f64; qn]; - for p in 0..n_persons { - let g = group_id[p]; - for t in 0..qn { - log_node[t] = log_w[t]; - } - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for t in 0..qn { - log_node[t] += item_lp[g][i][t * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0_f64; - for t in 0..qn { - denom += (log_node[t] - mx).exp(); - } - ll += mx + denom.ln(); - for t in 0..qn { - let post = (log_node[t] - mx).exp() / denom; - w_acc[g] += post; - s1[g] += post * theta[g][t]; - s2[g] += post * theta[g][t] * theta[g][t]; - for i in 0..n_items { - if is_obs(p, i) { - counts[g][i][t][y[p * n_items + i]] += post; - } - } - } - } - let status = multigroup_em_status(ll, loglik_trace.last().copied(), tol); - if record_multigroup_em_status( - status, - ll, - &mut loglik_trace, - &mut converged, - &mut termination_reason, - &mut final_delta, - &mut stopping_tolerance, - ) { - break; - } - if it == max_iter { - break; - } - // M-step, item parameters - for i in 0..n_items { - if Some(i) == studied_item { - for g in 0..n_groups { - studied_params[g] = m_step_item( - studied_params[g].clone(), - &theta[g], - &counts[g][i], - model, - 10, - ); - } - } else { - let mut stacked_nodes = Vec::with_capacity(n_groups * qn); - let mut stacked_counts = Vec::with_capacity(n_groups * qn); - for g in 0..n_groups { - stacked_nodes.extend_from_slice(&theta[g]); - for t in 0..qn { - stacked_counts.push(counts[g][i][t].clone()); - } - } - params[i] = m_step_item( - params[i].clone(), - &stacked_nodes, - &stacked_counts, - model, - 10, - ); - } - } - // M-step, focal group latent distributions (reference g=0 pinned) - for g in 1..n_groups { - if w_acc[g] > 0.0 { - let mean = s1[g] / w_acc[g]; - let var = (s2[g] / w_acc[g] - mean * mean).max(0.01); - mu[g] = mean; - sigma[g] = var.sqrt().clamp(0.1, 10.0); - } - } - it += 1; - } - - let slope: Vec = (0..n_items).map(|i| params[i][0].exp()).collect(); - let cat_params: Vec> = params.iter().map(|p| p[1..].to_vec()).collect(); - let (studied_slope, studied_cat) = if studied_item.is_some() { - ( - studied_params.iter().map(|p| p[0].exp()).collect(), - studied_params.iter().map(|p| p[1..].to_vec()).collect(), - ) - } else { - (Vec::new(), Vec::new()) - }; - Ok(TwoGroupPolyFit { - slope, - cat_params, - studied_slope, - studied_cat, - mu, - sigma, - loglik: ll, - n_iter: it, - converged, - termination_reason, - loglik_trace, - final_delta, - stopping_tolerance, - }) -} - -/// One studied item's likelihood-ratio DIF result. -pub struct PolyDifRow { - pub item: usize, - pub lr: f64, - pub df: usize, - pub p_value: f64, - pub flagged_bh: bool, - /// Unsigned across-group range (>= 0) of the item's mean category-location: a - /// DIF magnitude, monotone in uniform DIF — a size, not a direction. `NaN` if - /// the augmented fit for this item did not converge to finite parameters. - pub effect_size: f64, -} - -fn validate_poly_dif_compact(fit: &TwoGroupPolyFit, max_iter: usize) -> Result<(), String> { - if !fit.loglik.is_finite() { - return Err( - "compact multi-group fit did not reach a finite log-likelihood \ - (a group may have a rarely-used category; try model=\"gpcm\")" - .into(), - ); - } - if !fit.converged { - return Err(format!( - "compact multi-group fit did not converge: reason={}, iteration={}/{}, \ - final_delta={:.6e}, tolerance={:.6e}", - fit.termination_reason, fit.n_iter, max_iter, fit.final_delta, fit.stopping_tolerance - )); - } - Ok(()) -} - -fn poly_dif_metrics( - augmented: &TwoGroupPolyFit, - compact_loglik: f64, - df: usize, -) -> (f64, f64, f64) { - if augmented.converged && augmented.loglik.is_finite() { - let lr = (2.0 * (augmented.loglik - compact_loglik)).max(0.0); - let group_locations: Vec = augmented - .studied_cat - .iter() - .map(|categories| categories.iter().sum::() / categories.len().max(1) as f64) - .collect(); - let high = group_locations - .iter() - .cloned() - .fold(f64::NEG_INFINITY, f64::max); - let low = group_locations - .iter() - .cloned() - .fold(f64::INFINITY, f64::min); - (lr, crate::fitstats::chi2_sf(lr, df as f64), high - low) - } else { - (f64::NAN, f64::NAN, f64::NAN) - } -} - -/// Likelihood-ratio DIF sweep for polytomous items (Thissen, Steinberg & Wainer, -/// 1993, framework; Woehr & Meriac, 2010, for GRM/GPCM). Fits the compact model -/// (all items common across groups) once, then, per studied item, the augmented -/// model (that item freed per group); `LR = 2(ll_aug - ll_compact)` is compared -/// to `chi²((n_groups-1) * n_cat)`. Because the focal latent distribution is -/// estimated in both models, genuine group ability differences (impact) are -/// absorbed and not misread as DIF. `studied_items = None` sweeps every item -/// against the all-others anchor; Benjamini-Hochberg controls the FDR at -/// `fdr_q`. -/// -/// # References (APA 7th ed.) -/// -/// Thissen, D., Steinberg, L., & Wainer, H. (1993). Detection of differential -/// item functioning using the parameters of item response models. In P. W. -/// Holland & H. Wainer (Eds.), *Differential item functioning* (pp. 67–113). -/// Erlbaum. -/// -/// Woehr, D. J., & Meriac, J. P. (2010). Using polytomous item response theory -/// to examine differential item and test functioning: The case of work ethic. -/// In J. A. Harkness, M. Braun, B. Edwards, T. P. Johnson, L. E. Lyberg, -/// P. P. Mohler, B.-E. Pennell, & T. W. Smith (Eds.), *Survey methods in -/// multinational, multiregional, and multicultural contexts* (pp. 419–433). -/// Wiley. -/// https://doi.org/10.1002/9780470609927.ch22 -#[allow(clippy::too_many_arguments)] -pub fn poly_dif_sweep( - y: &[usize], - observed: Option<&[bool]>, - group_id: &[usize], - n_groups: usize, - n_persons: usize, - n_items: usize, - n_cat: usize, - model: PolyModel, - studied_items: Option<&[usize]>, - q_theta: usize, - max_iter: usize, - tol: f64, - fdr_q: f64, -) -> Result, String> { - let con = fit_poly_multigroup( - y, observed, group_id, n_groups, n_persons, n_items, n_cat, model, None, q_theta, max_iter, - tol, - )?; - validate_poly_dif_compact(&con, max_iter)?; - let items: Vec = match studied_items { - Some(s) => s.to_vec(), - None => (0..n_items).collect(), - }; - let df = (n_groups - 1) * n_cat; - let mut rows: Vec = Vec::with_capacity(items.len()); - for &j in &items { - if j >= n_items { - return Err("studied item out of range".into()); - } - let aug = fit_poly_multigroup( - y, - observed, - group_id, - n_groups, - n_persons, - n_items, - n_cat, - model, - Some(j), - q_theta, - max_iter, - tol, - ) - .expect("the compact fit validated the shared inputs and the item index is in range"); - // If this item's augmented fit diverged, surface it as NaN rather than let - // `.max(0.0)` mask a failed fit as LR=0 (a silent "no DIF" false negative). - let (lr, p_value, effect_size) = poly_dif_metrics(&aug, con.loglik, df); - rows.push(PolyDifRow { - item: j, - lr, - df, - p_value, - flagged_bh: false, - effect_size, - }); - } - let pvals: Vec = rows.iter().map(|r| r.p_value).collect(); - let bh = crate::fitstats::benjamini_hochberg(&pvals, fdr_q); - for (r, &f) in rows.iter_mut().zip(&bh) { - r.flagged_bh = f; - } - Ok(rows) -} - -/// Per-person nonparametric polytomous person-fit result ([`u3_poly_person_fit`]). -pub struct U3PolyResult { - /// Raw U3poly in `[0, 1]` (0 = popularity-consistent, 1 = maximally aberrant); - /// `NaN` where undefined (an interior total score whose attainable weighted - /// range collapses to zero). - pub u3poly: Vec, - /// `NC_p`, the person's summed ordinal score over observed items (the - /// conditioning group the statistic is normalized within). - pub total_score: Vec, - /// `u3poly >= cutoff` (all `false` when `cutoff` is `None` or `u3poly` is - /// `NaN`). - pub flagged: Vec, -} - -/// Min-plus and max-plus convolution of a set of per-item cumulative-weight -/// vectors `cw[i][0..=m]` (`cw[i][x] = sum_{s<=x} w_{i,s}`, `cw[i][0]=0`): returns -/// `(max_w, min_w)` over total step counts `0..=n_items*m`, where `max_w[t]` / -/// `min_w[t]` are the largest / smallest attainable weighted score for a response -/// pattern whose ordinal scores sum to `t`. Both are exact DPs; the max side is -/// **not** the flat "sum of the t largest step weights" shortcut, which -/// over-counts when a clamped (unused) category breaks within-item monotonicity. -fn u3_min_max_conv(cw: &[&[f64]], m: usize) -> (Vec, Vec) { - let total = cw.len() * m; - let mut max_dp = vec![f64::NEG_INFINITY; total + 1]; - let mut min_dp = vec![f64::INFINITY; total + 1]; - max_dp[0] = 0.0; - min_dp[0] = 0.0; - let mut reach = 0usize; - for ci in cw { - let mut nmax = vec![f64::NEG_INFINITY; total + 1]; - let mut nmin = vec![f64::INFINITY; total + 1]; - for t in 0..=reach { - let (mv, nv) = (max_dp[t], min_dp[t]); - for x in 0..=m { - let nt = t + x; - let a = mv + ci[x]; - if a > nmax[nt] { - nmax[nt] = a; - } - let b = nv + ci[x]; - if b < nmin[nt] { - nmin[nt] = b; - } - } - } - max_dp = nmax; - min_dp = nmin; - reach += m; - } - (max_dp, min_dp) -} - -/// van der Flier's (1980, 1982) `U3` person-fit statistic generalized to ordered -/// polytomous items (Emons, 2008): a *nonparametric* index that needs no fitted -/// IRT model. Each item-step response function `P(Y_i >= m)` is estimated by its -/// sample proportion, turned into a logit weight `w_{i,m} = ln(pi/(1-pi))` -/// (a degenerate step with `pi in {0,1}` contributes 0), and a person's observed -/// weighted score `W_p = sum_i sum_{m<=y_i} w_{i,m}` is compared to the largest -/// and smallest weighted scores attainable at that person's total score `NC_p` -/// (the conditioning group): `U3 = (maxW(NC_p) - W_p) / (maxW(NC_p) - minW(NC_p))`, -/// in `[0, 1]` with 1 = maximally popularity-inconsistent (misfit). The min/max -/// bounds are computed by exact DP ([`u3_min_max_conv`]). Perfect patterns -/// (`NC_p in {0, n_items*(n_cat-1)}`) take the reference `den = 1` (statistic 0), -/// matching the `PerFit` reference implementation; a `NaN` is returned only when -/// an *interior* score's attainable range collapses. Items must be keyed so a -/// higher category means more of the trait (recode reverse-keyed items first). -/// `cutoff` (see [`u3_poly_bootstrap_cutoff`]) flags `u3poly >= cutoff`; the raw -/// statistic has no reliable analytic reference distribution, so flagging uses a -/// simulated critical value rather than a normal approximation. -/// -/// # References (APA 7th ed.) -/// -/// Emons, W. H. M. (2008). Nonparametric person-fit analysis of polytomous item -/// scores. *Applied Psychological Measurement, 32*(3), 224–247. -/// https://doi.org/10.1177/0146621607302479 -/// -/// van der Flier, H. (1982). Deviant response patterns and comparability of test -/// scores. *Journal of Cross-Cultural Psychology, 13*(3), 267–298. -/// https://doi.org/10.1177/0022002182013003001 -pub fn u3_poly_person_fit( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - cutoff: Option, -) -> Result { - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - validate_observed_categories(y, observed, n_cat)?; - if let Some(c) = cutoff { - if !c.is_finite() { - return Err("cutoff must be finite".into()); - } - } - let m = n_cat - 1; - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - - // per-item cumulative logit weights cw[i][0..=m] from sample ISRF proportions - let mut cw = vec![vec![0.0_f64; n_cat]; n_items]; - for i in 0..n_items { - let mut freq = vec![0usize; n_cat]; - let mut nobs = 0usize; - for p in 0..n_persons { - if is_obs(p, i) { - freq[y[p * n_items + i]] += 1; - nobs += 1; - } - } - // suffix counts: ge[s] = #(x >= s) - let mut ge = vec![0usize; n_cat + 1]; - for c in (0..n_cat).rev() { - ge[c] = ge[c + 1] + freq[c]; - } - let mut cum = 0.0_f64; - for step in 1..=m { - let pi = if nobs > 0 { - ge[step] as f64 / nobs as f64 - } else { - 0.0 - }; - let w = if pi <= 0.0 || pi >= 1.0 { - 0.0 - } else { - (pi / (1.0 - pi)).ln() - }; - cum += w; - cw[i][step] = cum; - } - } - - // shared bounds for the complete-data path (computed once) - let all_cw: Vec<&[f64]> = cw.iter().map(|v| v.as_slice()).collect(); - let (gmax, gmin) = u3_min_max_conv(&all_cw, m); - let full_complete = observed.is_none(); - - let mut u3poly = vec![0.0_f64; n_persons]; - let mut total_score = vec![0usize; n_persons]; - for p in 0..n_persons { - let complete = full_complete || (0..n_items).all(|i| is_obs(p, i)); - let (mx, mn, total_steps, wsum, nc) = if complete { - let mut wsum = 0.0_f64; - let mut nc = 0usize; - for i in 0..n_items { - let x = y[p * n_items + i]; - wsum += cw[i][x]; - nc += x; - } - (gmax[nc], gmin[nc], n_items * m, wsum, nc) - } else { - // ponytail: per-person DP only on the missing path; the person's - // attainable range spans only their observed item-steps. - let obs_cw: Vec<&[f64]> = (0..n_items) - .filter(|&i| is_obs(p, i)) - .map(|i| cw[i].as_slice()) - .collect(); - let (pmax, pmin) = u3_min_max_conv(&obs_cw, m); - let mut wsum = 0.0_f64; - let mut nc = 0usize; - for &i in (0..n_items) - .filter(|&i| is_obs(p, i)) - .collect::>() - .iter() - { - let x = y[p * n_items + i]; - wsum += cw[i][x]; - nc += x; - } - let ts = obs_cw.len() * m; - (pmax[nc], pmin[nc], ts, wsum, nc) - }; - total_score[p] = nc; - u3poly[p] = if total_steps == 0 { - // no observed item-steps => no conditioning group, statistic undefined - // (distinct from a complete all-min/all-max pattern, which is a real 0) - f64::NAN - } else { - // PerFit boundary: perfect patterns get reference den = 1 (statistic 0); - // an interior score whose range collapses is genuinely undefined. - let den = if nc == 0 || nc == total_steps { - 1.0 - } else { - mx - mn - }; - if den > 1e-9 { - (mx - wsum) / den - } else { - f64::NAN - } - }; - } - - let flagged: Vec = match cutoff { - Some(c) => u3poly.iter().map(|&v| v.is_finite() && v >= c).collect(), - None => vec![false; n_persons], - }; - Ok(U3PolyResult { - u3poly, - total_score, - flagged, - }) -} - -/// Simulated critical value for [`u3_poly_person_fit`]: the empirical -/// `1 - alpha` quantile of the raw U3poly statistic under `n_rep` complete -/// datasets drawn from a fitted GRM/GPCM at `theta ~ N(0, 1)` (a parametric -/// bootstrap, following Emons, 2008, who used simulated critical values because -/// U3poly has no usable analytic null). `slope`/`cat_params` are the item bank -/// (`cat_params` flattened `n_items * (n_cat-1)`). Because the null distribution -/// depends on the latent distribution, a cutoff from this `N(0,1)` bootstrap is -/// only appropriate when that population assumption is reasonable. The -/// replications are complete (`n_items`-long) patterns, so the cutoff is -/// calibrated for complete responders; a person with substantial missing data has -/// a shorter, coarser null and should not be flagged against this cutoff. -#[allow(clippy::too_many_arguments)] -pub fn u3_poly_bootstrap_cutoff( - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: &[f64], - cat_params: &[f64], - model: PolyModel, - alpha: f64, - n_rep: usize, - seed: u64, -) -> Result { - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - validate_poly_item_parameters(slope, cat_params, n_items, n_cat, model)?; - if n_persons < 1 || n_items < 1 { - return Err("need at least one person and item".into()); - } - if !(alpha > 0.0 && alpha < 1.0) { - return Err("alpha must be in (0, 1)".into()); - } - if n_rep < 1 { - return Err("n_rep must be >= 1".into()); - } - let z = n_cat - 1; - let mut st = seed.max(1); - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let cell = |i: usize, theta: f64| -> Vec { - let base = slope[i] * theta; - let cp = &cat_params[i * z..(i + 1) * z]; - match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; n_cat]; - ic[1..].copy_from_slice(cp); - gpcm_logprobs(base, &scores, &ic) - } - PolyModel::Grm => grm_logprobs(base, cp), - } - }; - let pool_capacity = crate::checked_mul_usize( - n_rep, - n_persons, - "n_rep * n_persons exceeds the bootstrap buffer size", - )?; - let response_len = crate::checked_mul_usize( - n_persons, - n_items, - "n_persons * n_items exceeds the response buffer size", - )?; - let mut pool: Vec = Vec::with_capacity(pool_capacity); - let mut y = vec![0usize; response_len]; - for _rep in 0..n_rep { - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let lp = cell(i, theta); - let d = u(); - let (mut acc, mut cat) = (0.0_f64, n_cat - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if d <= acc { - cat = c; - break; - } - } - y[p * n_items + i] = cat; - } - } - let res = u3_poly_person_fit(&y, None, n_persons, n_items, n_cat, None)?; - pool.extend(res.u3poly.into_iter().filter(|v| v.is_finite())); - } - if pool.is_empty() { - return Err("bootstrap produced no finite U3 values".into()); - } - pool.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let np = pool.len(); - let idx = (np as f64 - 1.0) * (1.0 - alpha); - let lo = idx.floor() as usize; - let hi = idx.ceil() as usize; - let q = if lo == hi { - pool[lo] - } else { - pool[lo] + (idx - lo as f64) * (pool[hi] - pool[lo]) - }; - Ok(q) -} - -/// Fisher item information `I(theta) = sum_k (dP_k/dtheta)^2 / P_k` for one -/// polytomous item at trait value `theta`. GPCM reduces to `a^2 * Var_P(scores)`; -/// GRM to `a^2 * sum_k (v_k - v_{k+1})^2 / P_k` with `v_j = s_j(1-s_j)`, -/// `s_j = P(Y>=j)`. `cat_params` is this item's `K-1` category parameters. -/// -/// # References -/// -/// Muraki, E. (1993). Information functions of the generalized partial credit -/// model. *Applied Psychological Measurement, 17*(4), 351–363. -/// -/// -/// Samejima, F. (1969). Estimation of latent ability using a response pattern -/// of graded scores. *Psychometrika, 34*(S1), 1–97. -/// -pub fn poly_item_information(theta: f64, slope: f64, cat_params: &[f64], model: PolyModel) -> f64 { - let a = slope; - let base = a * theta; - match model { - PolyModel::Gpcm => { - let k = cat_params.len() + 1; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0_f64; k]; - intercepts[1..].copy_from_slice(cat_params); - let p: Vec = gpcm_logprobs(base, &scores, &intercepts) - .iter() - .map(|l| l.exp()) - .collect(); - let ebar: f64 = scores.iter().zip(&p).map(|(s, pp)| s * pp).sum(); - let var: f64 = scores - .iter() - .zip(&p) - .map(|(s, pp)| pp * (s - ebar).powi(2)) - .sum(); - a * a * var - } - PolyModel::Grm => { - let kk = cat_params.len() + 1; // K - let p: Vec = grm_logprobs(base, cat_params) - .iter() - .map(|l| l.exp()) - .collect(); - let mut v = vec![0.0_f64; kk + 1]; // v[0]=v[K]=0 - for (j, item) in v.iter_mut().enumerate().take(kk).skip(1) { - let s = 1.0 / (1.0 + (-(base + cat_params[j - 1])).exp()); - *item = s * (1.0 - s); - } - let mut info = 0.0_f64; - for k in 0..kk { - let d = v[k] - v[k + 1]; - info += d * d / p[k].max(1e-300); - } - a * a * info - } - } -} - -/// Item information curves over a trait grid: returns a flattened -/// `n_theta * n_items` vector of `I_i(theta)` (row-major by theta). Test -/// information is the per-theta row sum. -#[allow(clippy::too_many_arguments)] -pub fn poly_information_curves( - theta: &[f64], - slope: &[f64], - cat_params: &[f64], - n_items: usize, - n_cat: usize, - model: PolyModel, -) -> Result, String> { - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - if n_items < 1 { - return Err("need at least one item".into()); - } - if theta.is_empty() { - return Err("theta must be non-empty".into()); - } - validate_poly_item_parameters(slope, cat_params, n_items, n_cat, model)?; - if theta.iter().any(|value| !value.is_finite()) { - return Err("theta must be finite".into()); - } - let output_len = crate::checked_mul_usize(theta.len(), n_items, "output size overflows")?; - let mut out = vec![0.0_f64; output_len]; - for (t, &th) in theta.iter().enumerate() { - for i in 0..n_items { - let cp = &cat_params[i * (n_cat - 1)..(i + 1) * (n_cat - 1)]; - out[t * n_items + i] = poly_item_information(th, slope[i], cp, model); - } - } - Ok(out) -} - -/// EAP trait scores from polytomous responses given fitted item parameters -/// (the Rust scoring companion to [`fit_poly_unidim`]). `slope[i]` is `a_i`; -/// `cat_params` is flattened `n_items * (n_cat-1)` (GPCM intercepts or GRM -/// thresholds). Returns `(theta_eap, theta_sd)` per person over a `theta~N(0,1)` -/// Gauss-Hermite grid. -/// -/// # References -/// -/// Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability in -/// a microcomputer environment. *Applied Psychological Measurement, 6*(4), -/// 431–444. https://doi.org/10.1177/014662168200600405 -#[allow(clippy::too_many_arguments)] -pub fn score_poly_eap( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: &[f64], - cat_params: &[f64], - model: PolyModel, - q_theta: usize, -) -> Result<(Vec, Vec), String> { - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - validate_poly_item_parameters(slope, cat_params, n_items, n_cat, model)?; - for (idx, &yc) in y.iter().enumerate() { - if observed.map_or(true, |o| o[idx]) && yc >= n_cat { - return Err(format!( - "observed responses must be categories in 0..{}; y[{idx}]={yc}", - n_cat - 1 - )); - } - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - let (nodes, weights) = crate::quadrature::require_gh_rule(q_theta, "q_theta")?; - let log_w: Vec = weights.iter().map(|w| w.ln()).collect(); - let qn = nodes.len(); - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - - // per-item cell log-probs at each node: item_lp[i][node*n_cat + k] - let mut item_lp = vec![vec![0.0_f64; qn * n_cat]; n_items]; - for i in 0..n_items { - let a = slope[i]; - let cp = &cat_params[i * (n_cat - 1)..(i + 1) * (n_cat - 1)]; - for (nd, &theta) in nodes.iter().enumerate() { - let base = a * theta; - let lp = match model { - PolyModel::Gpcm => { - let mut intercepts = vec![0.0_f64; n_cat]; - intercepts[1..].copy_from_slice(cp); - gpcm_logprobs(base, &scores, &intercepts) - } - PolyModel::Grm => grm_logprobs(base, cp), - }; - item_lp[i][nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - } - - let mut theta_eap = vec![0.0_f64; n_persons]; - let mut theta_sd = vec![0.0_f64; n_persons]; - let mut log_node = vec![0.0_f64; qn]; - for p in 0..n_persons { - for nd in 0..qn { - log_node[nd] = log_w[nd]; - } - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - log_node[nd] += item_lp[i][nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0_f64; - for nd in 0..qn { - denom += (log_node[nd] - mx).exp(); - } - let (mut m1, mut m2) = (0.0_f64, 0.0_f64); - for nd in 0..qn { - let post = (log_node[nd] - mx).exp() / denom; - m1 += post * nodes[nd]; - m2 += post * nodes[nd] * nodes[nd]; - } - theta_eap[p] = m1; - theta_sd[p] = (m2 - m1 * m1).max(0.0).sqrt(); - } - Ok((theta_eap, theta_sd)) -} - -/// Per-item generalized S-X² polytomous item-fit result. -pub struct PolySX2Result { - pub statistic: Vec, - pub df: Vec, - pub p_value: Vec, - /// Retained independent cells `Σ_g (#supercats_g − 1)` before the `−m` - /// parameter adjustment — the reference df when the statistic is evaluated - /// at KNOWN (not estimated) item parameters. - pub n_cells: Vec, -} - -/// Generalized S-X² item fit for ordered polytomous IRT — Kang & Chen (2008) -/// for the GPCM, Kang & Chen (2011) for the GRM — the category extension of the -/// binary Orlando-Thissen statistic in [`crate::fitstats::s_x2`]. Persons are -/// grouped by the summed score `k = 0..F` (`F = n_items * (n_cat-1)`); the -/// model-expected category proportions -/// `E_ikz = ∫ P_i(z|θ) f*ᵢ(k-z|θ) φ(θ) dθ / ∫ f(k|θ) φ(θ) dθ` -/// use the generalized Lord-Wingersky summed-score recursion (Thissen, -/// Pommerich, Billeaud & Williams 1995), with `f*ᵢ` the leave-one-out -/// distribution. Groups `k < Z` collapse into the boundary group `Z`, groups -/// `k > F−Z` into `F−Z`, and `k = 0`, `k = F` are excluded (their off-boundary -/// cells are structurally zero); within a retained group adjacent *categories* -/// are collapsed left-to-right to hold every expected cell frequency at or above -/// `min_expected` (Kang & Chen's category-collapsing, feasible where score-group -/// collapsing would erase the table). `df = Σ_g (#cells_g − 1) − m`, with -/// `m = n_cat` estimated item parameters (slope + `K−1` category parameters). -/// -/// At `n_cat = 2` this reduces exactly to [`crate::fitstats::s_x2`] on the same -/// grid; all items are assumed to share `n_cat` categories (the fitter's -/// setting). Only persons observed on every item enter the summed-score table. -/// -/// # References (APA 7th ed.) -/// -/// Kang, T., & Chen, T. T. (2008). Performance of the generalized S-X² item fit -/// index for polytomous IRT models. *Journal of Educational Measurement, -/// 45*(4), 391–406. https://doi.org/10.1111/j.1745-3984.2008.00070.x -/// -/// Kang, T., & Chen, T. T. (2011). Performance of the generalized S-X² item fit -/// index for the graded response model. *Asia Pacific Education Review, -/// 12*(1), 89–96. https://doi.org/10.1007/s12564-010-9082-4 -/// -/// Orlando, M., & Thissen, D. (2000). Likelihood-based item-fit indices for -/// dichotomous item response theory models. *Applied Psychological -/// Measurement, 24*(1), 50–64. https://doi.org/10.1177/01466216000241003 -/// -/// Thissen, D., Pommerich, M., Billeaud, K., & Williams, V. A. (1995). Item -/// response theory for scores on tests including polytomous items with ordered -/// responses. *Applied Psychological Measurement, 19*(1), 39–49. -/// https://doi.org/10.1177/014662169501900105 -/// -/// Lord, F. M., & Wingersky, M. S. (1984). Comparison of IRT true-score and -/// equipercentile observed-score "equatings." *Applied Psychological -/// Measurement, 8*(4), 453–461. https://doi.org/10.1177/014662168400800409 -#[allow(clippy::too_many_arguments)] -pub fn poly_s_x2( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: &[f64], - cat_params: &[f64], - model: PolyModel, - q_theta: usize, - min_expected: f64, -) -> Result { - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - if n_items < 2 { - return Err("n_items must be >= 2".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - validate_poly_item_parameters(slope, cat_params, n_items, n_cat, model)?; - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - validate_observed_categories(y, observed, n_cat)?; - - let z = n_cat - 1; // highest category score Z - let f_max = n_items * z; // perfect summed score F - let (nodes, weights) = crate::quadrature::require_gh_rule(q_theta, "q_theta")?; - let qn = nodes.len(); - - // per-item category probabilities at each node: probs[(i*qn + t)*n_cat + zc] - let mut probs = vec![0.0_f64; n_items * qn * n_cat]; - for i in 0..n_items { - let a = slope[i]; - let cp = &cat_params[i * z..(i + 1) * z]; - for (t, &theta) in nodes.iter().enumerate() { - let base = a * theta; - let lp = match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0_f64; n_cat]; - intercepts[1..].copy_from_slice(cp); - gpcm_logprobs(base, &scores, &intercepts) - } - PolyModel::Grm => grm_logprobs(base, cp), - }; - let off = (i * qn + t) * n_cat; - for zc in 0..n_cat { - probs[off + zc] = lp[zc].exp(); - } - } - } - - // generalized Lord-Wingersky over an item subset: f[k*qn + t], k = 0..items*z - let poly_lw = |items: &[usize]| -> Vec { - let max_s = items.len() * z; - let mut dist = vec![0.0_f64; (max_s + 1) * qn]; - for t in 0..qn { - dist[t] = 1.0; // score 0 has probability 1 before adding any item - } - let mut cur = 0usize; - for &i in items { - let mut next = vec![0.0_f64; (max_s + 1) * qn]; - for s in 0..=cur { - for zc in 0..n_cat { - let off = (i * qn) * n_cat + zc; - let (drow, srow) = ((s + zc) * qn, s * qn); - for t in 0..qn { - next[drow + t] += dist[srow + t] * probs[off + t * n_cat]; - } - } - } - cur += z; - dist = next; - } - dist - }; - - let all: Vec = (0..n_items).collect(); - let f_all = poly_lw(&all); - let denom: Vec = (0..=f_max) - .map(|k| (0..qn).map(|t| f_all[k * qn + t] * weights[t]).sum()) - .collect(); - - // observed counts by total score: nk[k] and obs[(i*(F+1)+k)*n_cat + zc] - let complete = |p: usize| observed.map_or(true, |o| (0..n_items).all(|i| o[p * n_items + i])); - let mut nk = vec![0.0_f64; f_max + 1]; - let mut obs = vec![0.0_f64; n_items * (f_max + 1) * n_cat]; - for p in 0..n_persons { - if !complete(p) { - continue; - } - let total: usize = (0..n_items).map(|i| y[p * n_items + i]).sum(); - nk[total] += 1.0; - for i in 0..n_items { - obs[(i * (f_max + 1) + total) * n_cat + y[p * n_items + i]] += 1.0; - } - } - - let m = n_cat as f64; // slope + (K-1) category parameters - let n_buckets = f_max - 2 * z + 1; // groups k in [Z, F-Z] after boundary merge - let mut out = PolySX2Result { - statistic: vec![f64::NAN; n_items], - df: vec![f64::NAN; n_items], - p_value: vec![f64::NAN; n_items], - n_cells: vec![0; n_items], - }; - for i in 0..n_items { - let rest: Vec = (0..n_items).filter(|&j| j != i).collect(); - let f_rest = poly_lw(&rest); // f*ᵢ, scores 0..(F-z) - let rest_max = f_max - z; - // observed/expected counts per (bucket, category); k in [1, F-1] - let mut bo = vec![0.0_f64; n_buckets * n_cat]; - let mut be = vec![0.0_f64; n_buckets * n_cat]; - let mut bn = vec![0.0_f64; n_buckets]; - for k in 1..f_max { - debug_assert!( - denom[k] > 0.0, - "quadrature weights and item probabilities are positive" - ); - let bucket = k.clamp(z, f_max - z) - z; - bn[bucket] += nk[k]; - for zc in 0..n_cat { - bo[bucket * n_cat + zc] += obs[(i * (f_max + 1) + k) * n_cat + zc]; - if k >= zc && k - zc <= rest_max { - let kr = k - zc; - let num: f64 = (0..qn) - .map(|t| { - probs[(i * qn + t) * n_cat + zc] * f_rest[kr * qn + t] * weights[t] - }) - .sum(); - be[bucket * n_cat + zc] += nk[k] * num / denom[k]; - } - } - } - // per bucket: collapse adjacent categories to min_expected, accumulate chi-square - let mut x2 = 0.0_f64; - let mut cells = 0usize; - for g in 0..n_buckets { - if bn[g] <= 0.0 { - continue; - } - let mut supers: Vec<(f64, f64)> = Vec::new(); - let (mut ao, mut ae) = (0.0_f64, 0.0_f64); - for zc in 0..n_cat { - ao += bo[g * n_cat + zc]; - ae += be[g * n_cat + zc]; - if ae >= min_expected { - supers.push((ao, ae)); - ao = 0.0; - ae = 0.0; - } - } - if ao > 0.0 || ae > 0.0 { - if let Some(last) = supers.last_mut() { - last.0 += ao; - last.1 += ae; - } else { - supers.push((ao, ae)); - } - } - for &(o, e) in &supers { - if e > 0.0 { - x2 += (o - e) * (o - e) / e; - } - } - cells += supers.len().saturating_sub(1); - } - out.statistic[i] = x2; - out.n_cells[i] = cells; - let df = cells as f64 - m; - if df >= 1.0 { - out.df[i] = df; - out.p_value[i] = crate::fitstats::chi2_sf(x2, df); - } - } - Ok(out) -} - -#[cfg(test)] -#[path = "../../../tests/unit/poly_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/poly_marginal.rs b/crates/mlsirm-core/src/poly_marginal.rs deleted file mode 100644 index fa88e091a..000000000 --- a/crates/mlsirm-core/src/poly_marginal.rs +++ /dev/null @@ -1,467 +0,0 @@ -//! Latent-space polytomous item response model (polytomous LSIRM) by marginal -//! EM — the Rust compute path for the GRM/GPCM cell embedded in an interaction -//! map. Unidimensional trait `theta` with a `latent_dim`-dimensional latent -//! space; the person latent position `xi` is integrated over a tensor -//! Gauss-Hermite grid and the item position `zeta_i` is estimated. The distance -//! weight is fixed at 1 as this crate's scale-identification choice; Go et al.'s -//! lsirm12pl keeps a `gamma` term for continuous responses and leaves ordinal -//! LSIRM as future work. -//! -//! Fully additive: reuses the [`crate::poly`] cells/gradients and the exact -//! `d eta / d zeta` distance derivative from the binary M-step -//! (`marginal.rs`), but touches neither the binary estimator nor the GPU. -//! -//! `base_i(theta, xi) = a_i * theta - ||xi - zeta_i||` (distance interaction, -//! gamma = 1); the polytomous cell turns `base` into category probabilities. -//! The item M-step reuses the binary chain rule: the per-node `g_base` (the -//! category-weighted residual from `poly::*_node_gradient`) multiplies -//! `d base / d a = a*theta` and `d base / d zeta_k = (xi_k - zeta_k)/dist`. - -use crate::poly::{ - gpcm_logprobs, gpcm_node_gradient, grm_logprobs, grm_node_gradient, solve_small, PolyModel, - POLY_MAX_CAT, POLY_MAX_ITER, -}; - -/// Result of [`fit_poly_lsirm`]. `zeta` is `n_items * latent_dim` item positions -/// (identified up to rotation/reflection/translation — compare via distances). -pub struct PolyLsirmFit { - pub slope: Vec, - pub cat_params: Vec>, - pub zeta: Vec, - /// Per-person EAP trait score and posterior SD. - pub theta_eap: Vec, - pub theta_sd: Vec, - /// Per-person EAP latent position (`n_persons * latent_dim`). - pub xi_eap: Vec, - pub loglik: f64, - pub n_iter: usize, -} - -/// Tensor Gauss-Hermite grid for a `latent_dim`-dimensional standard normal: -/// returns `(grid [n_xi * latent_dim], log_weights [n_xi])`. -fn xi_tensor_grid(q_xi: usize, latent_dim: usize) -> Result<(Vec, Vec), String> { - let (nodes, weights) = crate::quadrature::require_gh_rule(q_xi, "q_xi")?; - let q = nodes.len(); - let n_xi = q - .checked_pow(latent_dim as u32) - .ok_or("xi grid too large")?; - if n_xi > 200_000 { - return Err("q_xi ** latent_dim exceeds the tensor-grid limit".into()); - } - let mut grid = vec![0.0_f64; n_xi * latent_dim]; - let mut logw = vec![0.0_f64; n_xi]; - for (idx, lw) in logw.iter_mut().enumerate() { - let mut rem = idx; - let mut acc = 0.0_f64; - for k in 0..latent_dim { - let j = rem % q; - rem /= q; - grid[idx * latent_dim + k] = nodes[j]; - acc += weights[j].ln(); - } - *lw = acc; - } - Ok((grid, logw)) -} - -fn poly_cell(base: f64, model: PolyModel, cat: &[f64], n_cat: usize) -> Vec { - match model { - PolyModel::Grm => grm_logprobs(base, cat), - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; n_cat]; - ic[1..].copy_from_slice(cat); - gpcm_logprobs(base, &scores, &ic) - } - } -} - -/// Per-node `(category-parameter gradient, g_base)` for the chosen cell. -fn poly_cat_grad(base: f64, model: PolyModel, cat: &[f64], counts: &[f64]) -> (Vec, f64) { - match model { - PolyModel::Grm => { - let (gb, gt) = grm_node_gradient(base, cat, counts); - (gt, gb) - } - PolyModel::Gpcm => { - let k = counts.len(); - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; k]; - ic[1..].copy_from_slice(cat); - let (gi, gb, _gs) = gpcm_node_gradient(base, &scores, &ic, counts); - (gi, gb) - } - } -} - -#[allow(clippy::too_many_arguments)] -struct ItemCtx<'a> { - model: PolyModel, - n_cat: usize, - latent_dim: usize, - eps: f64, - theta: &'a [f64], - xi_grid: &'a [f64], - n_xi: usize, - rbar_i: &'a [f64], // [q_theta * n_xi * n_cat] expected counts - lambda_alpha: f64, - mu_alpha: f64, - lambda_zeta: f64, -} - -/// Negative penalized expected complete-data objective and its gradient for one -/// item. `params = [log_a, cat_1..cat_{K-1}, zeta_1..zeta_L]`. -fn item_neg_ll_grad(params: &[f64], c: &ItemCtx) -> (f64, Vec) { - let a = params[0].exp(); - let cat = ¶ms[1..c.n_cat]; - let zeta = ¶ms[c.n_cat..c.n_cat + c.latent_dim]; - let mut ll = 0.0_f64; - let mut g = vec![0.0_f64; params.len()]; - for (t, &theta_t) in c.theta.iter().enumerate() { - for x in 0..c.n_xi { - let node = t * c.n_xi + x; - let counts = &c.rbar_i[node * c.n_cat..(node + 1) * c.n_cat]; - let ncount: f64 = counts.iter().sum(); - if ncount <= 0.0 { - continue; - } - let xi = &c.xi_grid[x * c.latent_dim..(x + 1) * c.latent_dim]; - let mut dist2 = c.eps; - for k in 0..c.latent_dim { - let dd = xi[k] - zeta[k]; - dist2 += dd * dd; - } - let dist = dist2.sqrt(); - let base = a * theta_t - dist; // gamma = 1 - let lp = poly_cell(base, c.model, cat, c.n_cat); - ll += counts.iter().zip(&lp).map(|(cc, l)| cc * l).sum::(); - let (g_cat, g_base) = poly_cat_grad(base, c.model, cat, counts); - g[0] += g_base * (a * theta_t); // d base / d log_a - for (m, gm) in g_cat.iter().enumerate() { - g[1 + m] += gm; - } - for k in 0..c.latent_dim { - let deta = (xi[k] - zeta[k]) / dist; // d base / d zeta_k - g[1 + (c.n_cat - 1) + k] += g_base * deta; - } - } - } - // MAP penalties (Gaussian priors on alpha and the item positions) - ll -= 0.5 * c.lambda_alpha * (params[0] - c.mu_alpha).powi(2); - g[0] -= c.lambda_alpha * (params[0] - c.mu_alpha); - for k in 0..c.latent_dim { - let z = zeta[k]; - ll -= 0.5 * c.lambda_zeta * z * z; - g[1 + (c.n_cat - 1) + k] -= c.lambda_zeta * z; - } - (-ll, g.iter().map(|v| -v).collect()) -} - -/// Backtracked numerical-Hessian Newton M-step for one item. -fn m_step_item(mut params: Vec, c: &ItemCtx, n_newton: usize) -> Vec { - let np = params.len(); - for _ in 0..n_newton { - let (f0, g) = item_neg_ll_grad(¶ms, c); - let h = 1e-5; - let mut hess = vec![vec![0.0_f64; np]; np]; - for j in 0..np { - let mut pj = params.clone(); - pj[j] += h; - let (_f, gj) = item_neg_ll_grad(&pj, c); - for r in 0..np { - hess[r][j] = (gj[r] - g[r]) / h; - } - } - for r in 0..np { - for col in 0..np { - hess[r][col] = 0.5 * (hess[r][col] + hess[col][r]); - } - hess[r][r] += 1e-6; - } - let step = solve_small(hess, g.clone()); - // backtracking: accept a decrease in the negative objective - let mut alpha = 1.0_f64; - let mut accepted = false; - for _ in 0..25 { - let cand: Vec = (0..np).map(|j| params[j] - alpha * step[j]).collect(); - let (fc, _) = item_neg_ll_grad(&cand, c); - if fc < f0 - 1e-10 { - params = cand; - accepted = true; - break; - } - alpha *= 0.5; - } - if !accepted { - break; - } - } - params -} - -/// Fit a unidimensional-trait polytomous LSIRM by marginal EM (fixed gamma = 1, -/// distance interaction). `y` is `n_persons * n_items` row-major categories -/// `0..n_cat-1`; `observed` marks non-missing cells (None = all observed). -#[allow(clippy::too_many_arguments)] -pub fn fit_poly_lsirm( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - latent_dim: usize, - model: PolyModel, - q_theta: usize, - q_xi: usize, - max_iter: usize, - tol: f64, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be positive".into()); - } - if !(2..=POLY_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={POLY_MAX_CAT}")); - } - if !(1..=POLY_MAX_ITER).contains(&max_iter) { - return Err(format!("max_iter must be in 1..={POLY_MAX_ITER}")); - } - if latent_dim < 1 || latent_dim > 3 { - return Err("latent_dim must be 1..3 for the tensor grid".into()); - } - let n_cells = crate::checked_mul_usize( - n_persons, - n_items, - "n_persons * n_items exceeds the response buffer size", - )?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - for p in 0..n_persons { - for i in 0..n_items { - if is_obs(p, i) && y[p * n_items + i] >= n_cat { - return Err("observed response categories must be < n_cat".into()); - } - } - } - let (theta, t_w) = crate::quadrature::require_gh_rule(q_theta, "q_theta")?; - let t_logw: Vec = t_w.iter().map(|w| w.ln()).collect(); - let (xi_grid, x_logw) = xi_tensor_grid(q_xi, latent_dim)?; - let n_xi = x_logw.len(); - let q_t = theta.len(); - let cell = q_t * n_xi; - let eps = 1e-8_f64; - let (lambda_alpha, mu_alpha, lambda_zeta) = (1.0_f64, 0.0_f64, 1.0_f64); - - // init: log_a = 0; category params from base rates; positions on a small ring - let kp = n_cat - 1; - let np = 1 + kp + latent_dim; - let mut params = vec![vec![0.0_f64; np]; n_items]; - for i in 0..n_items { - let mut freq = vec![1e-3_f64; n_cat]; - for p in 0..n_persons { - if is_obs(p, i) { - freq[y[p * n_items + i]] += 1.0; - } - } - let tot: f64 = freq.iter().sum(); - for f in freq.iter_mut() { - *f /= tot; - } - match model { - PolyModel::Gpcm => { - for k in 1..n_cat { - params[i][k] = (freq[k] / freq[0]).ln(); - } - } - PolyModel::Grm => { - let mut cum = 0.0_f64; - for k in (1..n_cat).rev() { - cum += freq[k]; - let cc = cum.clamp(1e-4, 1.0 - 1e-4); - params[i][k] = (cc / (1.0 - cc)).ln(); - } - } - } - // positive-manifold ring init for the positions - let ang = 2.0 * std::f64::consts::PI * (i as f64) / (n_items as f64); - params[i][1 + kp] = 0.5 * ang.cos(); - if latent_dim >= 2 { - params[i][1 + kp + 1] = 0.5 * ang.sin(); - } - if latent_dim >= 3 { - params[i][1 + kp + 2] = 0.25 * (2.0 * ang).cos(); - } - } - - let mut prev_ll = f64::NEG_INFINITY; - let mut ll = f64::NEG_INFINITY; - let mut it = 0; - while it < max_iter { - // per-item cell log-probs at each (theta, xi) node - let mut item_lp = vec![vec![0.0_f64; cell * n_cat]; n_items]; - for i in 0..n_items { - let a = params[i][0].exp(); - let cat = ¶ms[i][1..n_cat]; - let zeta = ¶ms[i][n_cat..n_cat + latent_dim]; - for (t, &theta_t) in theta.iter().enumerate() { - for x in 0..n_xi { - let xi = &xi_grid[x * latent_dim..(x + 1) * latent_dim]; - let mut dist2 = eps; - for k in 0..latent_dim { - let dd = xi[k] - zeta[k]; - dist2 += dd * dd; - } - let base = a * theta_t - dist2.sqrt(); - let lp = poly_cell(base, model, cat, n_cat); - let node = t * n_xi + x; - item_lp[i][node * n_cat..(node + 1) * n_cat].copy_from_slice(&lp); - } - } - } - // E-step: person posteriors -> expected category counts rbar[i][node][k] - let mut rbar = vec![vec![0.0_f64; cell * n_cat]; n_items]; - ll = 0.0; - let mut log_node = vec![0.0_f64; cell]; - for p in 0..n_persons { - for t in 0..q_t { - for x in 0..n_xi { - log_node[t * n_xi + x] = t_logw[t] + x_logw[x]; - } - } - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for node in 0..cell { - log_node[node] += item_lp[i][node * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0_f64; - for node in 0..cell { - denom += (log_node[node] - mx).exp(); - } - ll += mx + denom.ln(); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for node in 0..cell { - rbar[i][node * n_cat + yc] += (log_node[node] - mx).exp() / denom; - } - } - } - // M-step: per-item Newton over [log_a, cat, zeta] - for i in 0..n_items { - let ctx = ItemCtx { - model, - n_cat, - latent_dim, - eps, - theta, - xi_grid: &xi_grid, - n_xi, - rbar_i: &rbar[i], - lambda_alpha, - mu_alpha, - lambda_zeta, - }; - params[i] = m_step_item(params[i].clone(), &ctx, 6); - } - it += 1; - if (ll - prev_ll).abs() < tol * (1.0 + prev_ll.abs()) { - break; - } - prev_ll = ll; - } - - let slope: Vec = (0..n_items).map(|i| params[i][0].exp()).collect(); - let cat_params = (0..n_items).map(|i| params[i][1..n_cat].to_vec()).collect(); - let mut zeta = vec![0.0_f64; n_items * latent_dim]; - for i in 0..n_items { - zeta[i * latent_dim..(i + 1) * latent_dim] - .copy_from_slice(¶ms[i][n_cat..n_cat + latent_dim]); - } - - // Person scores: one posterior pass at the final parameters. EAP moments of - // theta and xi over the (theta, xi) grid. - let mut item_lp = vec![vec![0.0_f64; cell * n_cat]; n_items]; - for i in 0..n_items { - let a = slope[i]; - let cat = ¶ms[i][1..n_cat]; - for (t, &theta_t) in theta.iter().enumerate() { - for x in 0..n_xi { - let xi = &xi_grid[x * latent_dim..(x + 1) * latent_dim]; - let mut dist2 = eps; - for k in 0..latent_dim { - let dd = xi[k] - zeta[i * latent_dim + k]; - dist2 += dd * dd; - } - let lp = poly_cell(a * theta_t - dist2.sqrt(), model, cat, n_cat); - let node = t * n_xi + x; - item_lp[i][node * n_cat..(node + 1) * n_cat].copy_from_slice(&lp); - } - } - } - let mut theta_eap = vec![0.0_f64; n_persons]; - let mut theta_sd = vec![0.0_f64; n_persons]; - let mut xi_eap = vec![0.0_f64; n_persons * latent_dim]; - let mut log_node = vec![0.0_f64; cell]; - for p in 0..n_persons { - for t in 0..q_t { - for x in 0..n_xi { - log_node[t * n_xi + x] = t_logw[t] + x_logw[x]; - } - } - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for node in 0..cell { - log_node[node] += item_lp[i][node * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0_f64; - for node in 0..cell { - denom += (log_node[node] - mx).exp(); - } - let (mut m1, mut m2) = (0.0_f64, 0.0_f64); - for t in 0..q_t { - for x in 0..n_xi { - let post = (log_node[t * n_xi + x] - mx).exp() / denom; - m1 += post * theta[t]; - m2 += post * theta[t] * theta[t]; - for k in 0..latent_dim { - xi_eap[p * latent_dim + k] += post * xi_grid[x * latent_dim + k]; - } - } - } - theta_eap[p] = m1; - theta_sd[p] = (m2 - m1 * m1).max(0.0).sqrt(); - } - - Ok(PolyLsirmFit { - slope, - cat_params, - zeta, - theta_eap, - theta_sd, - xi_eap, - loglik: ll, - n_iter: it, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/poly_marginal_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/quadrature.rs b/crates/mlsirm-core/src/quadrature.rs deleted file mode 100644 index f3260c459..000000000 --- a/crates/mlsirm-core/src/quadrature.rs +++ /dev/null @@ -1,309 +0,0 @@ -// Auto-generated probabilists' Gauss-Hermite tables (weights normalized to sum 1). -// Source: numpy.polynomial.hermite_e.hermegauss(Q); shortest-roundtrip f64 repr — -// bit-identical to the NumPy reference (parity contract, same convention as mmle.rs). -pub(crate) const GH_NODES_7: [f64; 7] = [ - -3.7504397177257425, - -2.366759410734541, - -1.1544053947399682, - 0.0, - 1.1544053947399682, - 2.366759410734541, - 3.7504397177257425, -]; -pub(crate) const GH_WEIGHTS_7: [f64; 7] = [ - 0.000548268855972217, - 0.03075712396758652, - 0.2401231786050127, - 0.45714285714285724, - 0.2401231786050127, - 0.03075712396758652, - 0.000548268855972217, -]; -pub(crate) const GH_NODES_11: [f64; 11] = [ - -5.1880012243748705, - -3.936166607129977, - -2.865123160643645, - -1.876035020154846, - -0.928868997381064, - 0.0, - 0.928868997381064, - 1.876035020154846, - 2.865123160643645, - 3.936166607129977, - 5.1880012243748705, -]; -pub(crate) const GH_WEIGHTS_11: [f64; 11] = [ - 8.121849790214909e-07, - 0.0001956719302712236, - 0.0067202852355372706, - 0.06613874607105778, - 0.24224029987396997, - 0.3694083694083693, - 0.24224029987396997, - 0.06613874607105778, - 0.0067202852355372706, - 0.0001956719302712236, - 8.121849790214909e-07, -]; -pub(crate) const GH_NODES_15: [f64; 15] = [ - -6.363947888829839, - -5.190093591304781, - -4.1962077112690155, - -3.2890824243987664, - -2.432436827009758, - -1.6067100690287297, - -0.799129068324548, - 0.0, - 0.799129068324548, - 1.6067100690287297, - 2.432436827009758, - 3.2890824243987664, - 4.1962077112690155, - 5.190093591304781, - 6.363947888829839, -]; -pub(crate) const GH_WEIGHTS_15: [f64; 15] = [ - 8.589649899633252e-10, - 5.975419597920599e-07, - 5.642146405189029e-05, - 0.001567357503549956, - 0.017365774492137616, - 0.08941779539984437, - 0.23246229360973225, - 0.31825951825951815, - 0.23246229360973225, - 0.08941779539984437, - 0.017365774492137616, - 0.001567357503549956, - 5.642146405189029e-05, - 5.975419597920599e-07, - 8.589649899633252e-10, -]; -pub(crate) const GH_NODES_21: [f64; 21] = [ - -7.8493828951138225, - -6.751444718717461, - -5.8293820073044715, - -4.994963944782025, - -4.214343981688422, - -3.4698466904753764, - -2.7505929810523733, - -2.049102468257163, - -1.3597658232112302, - -0.678045692440644, - 0.0, - 0.678045692440644, - 1.3597658232112302, - 2.049102468257163, - 2.7505929810523733, - 3.4698466904753764, - 4.214343981688422, - 4.994963944782025, - 5.8293820073044715, - 6.751444718717461, - 7.8493828951138225, -]; -pub(crate) const GH_WEIGHTS_21: [f64; 21] = [ - 2.0989912195656712e-14, - 4.9753686041217186e-11, - 1.4506612844930857e-08, - 1.2253548361482526e-06, - 4.219234742551663e-05, - 0.0007080477954815369, - 0.006439697051408777, - 0.033952729786542866, - 0.10839228562641949, - 0.2153337156950597, - 0.27026018357287707, - 0.2153337156950597, - 0.10839228562641949, - 0.033952729786542866, - 0.006439697051408777, - 0.0007080477954815369, - 4.219234742551663e-05, - 1.2253548361482526e-06, - 1.4506612844930857e-08, - 4.9753686041217186e-11, - 2.0989912195656712e-14, -]; -pub(crate) const GH_NODES_31: [f64; 31] = [ - -9.893385708986651, - -8.87430140948879, - -8.024193227361646, - -7.2600004888908645, - -6.550014268765683, - -5.877855885986258, - -5.233641511712708, - -4.61078979732399, - -4.004600901491223, - -3.4115324158431557, - -2.828792768157508, - -2.2540950007544107, - -1.685497905069052, - -1.1212973740470091, - -0.5599475878410028, - 0.0, - 0.5599475878410028, - 1.1212973740470091, - 1.685497905069052, - 2.2540950007544107, - 2.828792768157508, - 3.4115324158431557, - 4.004600901491223, - 4.61078979732399, - 5.233641511712708, - 5.877855885986258, - 6.550014268765683, - 7.2600004888908645, - 8.024193227361646, - 8.87430140948879, - 9.893385708986651, -]; -pub(crate) const GH_WEIGHTS_31: [f64; 31] = [ - 2.6059738548929907e-22, - 2.883352367857825e-18, - 3.3284683241484216e-15, - 1.049603362311366e-12, - 1.3272514835897158e-10, - 8.243931619119728e-09, - 2.8456100881628386e-07, - 5.9232023176863475e-06, - 7.871624069602279e-05, - 0.0006960312713792916, - 0.004221717767270714, - 0.017967875843441658, - 0.054567258894475015, - 0.1196831096958546, - 0.19113200477464318, - 0.2232941387424068, - 0.19113200477464318, - 0.1196831096958546, - 0.054567258894475015, - 0.017967875843441658, - 0.004221717767270714, - 0.0006960312713792916, - 7.871624069602279e-05, - 5.9232023176863475e-06, - 2.8456100881628386e-07, - 8.243931619119728e-09, - 1.3272514835897158e-10, - 1.049603362311366e-12, - 3.3284683241484216e-15, - 2.883352367857825e-18, - 2.6059738548929907e-22, -]; -pub(crate) const GH_NODES_41: [f64; 41] = [ - -11.614937254337464, - -10.647536786319334, - -9.843433249157995, - -9.123069907984473, - -8.45609908326939, - -7.82688200405387, - -7.226022663732788, - -6.647308470747189, - -6.0863491648784755, - -5.539884440458124, - -5.0053966834041255, - -4.480878331594007, - -3.9646840280332665, - -3.4554322177809933, - -2.9519370163811907, - -2.453159345907048, - -1.9581707119772913, - -1.4661254572959665, - -0.9762387671800493, - -0.4877685693194346, - 0.0, - 0.4877685693194346, - 0.9762387671800493, - 1.4661254572959665, - 1.9581707119772913, - 2.453159345907048, - 2.9519370163811907, - 3.4554322177809933, - 3.9646840280332665, - 4.480878331594007, - 5.0053966834041255, - 5.539884440458124, - 6.0863491648784755, - 6.647308470747189, - 7.226022663732788, - 7.82688200405387, - 8.45609908326939, - 9.123069907984473, - 9.843433249157995, - 10.647536786319334, - 11.614937254337464, -]; -pub(crate) const GH_WEIGHTS_41: [f64; 41] = [ - 2.2578639565831077e-30, - 8.308558938782659e-26, - 2.7468912285223205e-22, - 2.3263841455871947e-19, - 7.655982291966907e-17, - 1.2203348742027809e-14, - 1.0778183949358929e-12, - 5.7698534280921236e-11, - 1.994794756757345e-09, - 4.66734770810732e-08, - 7.658186077982326e-07, - 9.058608622432971e-06, - 7.89471931950462e-05, - 0.000515801444343186, - 0.002561642428649783, - 0.009777902738208262, - 0.028937211747934403, - 0.06684765935446638, - 0.12114891701151059, - 0.17284953105060138, - 0.19454502775360044, - 0.17284953105060138, - 0.12114891701151059, - 0.06684765935446638, - 0.028937211747934403, - 0.009777902738208262, - 0.002561642428649783, - 0.000515801444343186, - 7.89471931950462e-05, - 9.058608622432971e-06, - 7.658186077982326e-07, - 4.66734770810732e-08, - 1.994794756757345e-09, - 5.7698534280921236e-11, - 1.0778183949358929e-12, - 1.2203348742027809e-14, - 7.655982291966907e-17, - 2.3263841455871947e-19, - 2.7468912285223205e-22, - 8.308558938782659e-26, - 2.2578639565831077e-30, -]; - -/// Look up the embedded rule for `q` nodes; `None` when unsupported. -pub(crate) fn gh_rule(q: usize) -> Option<(&'static [f64], &'static [f64])> { - match q { - 7 => Some((&GH_NODES_7, &GH_WEIGHTS_7)), - 11 => Some((&GH_NODES_11, &GH_WEIGHTS_11)), - 15 => Some((&GH_NODES_15, &GH_WEIGHTS_15)), - 21 => Some((&GH_NODES_21, &GH_WEIGHTS_21)), - 31 => Some((&GH_NODES_31, &GH_WEIGHTS_31)), - 41 => Some((&GH_NODES_41, &GH_WEIGHTS_41)), - _ => None, - } -} - -/// Resolve an embedded rule with a consistent public-validation error. -pub(crate) fn require_gh_rule( - q: usize, - name: &str, -) -> Result<(&'static [f64], &'static [f64]), String> { - match gh_rule(q) { - Some(rule) => Ok(rule), - None => Err(format!("unsupported {name} {q}")), - } -} - -pub(crate) const SUPPORTED_Q: [usize; 6] = [7, 11, 15, 21, 31, 41]; - -#[cfg(test)] -#[path = "../../../tests/unit/quadrature_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/rasch_cml.rs b/crates/mlsirm-core/src/rasch_cml.rs deleted file mode 100644 index f40451175..000000000 --- a/crates/mlsirm-core/src/rasch_cml.rs +++ /dev/null @@ -1,429 +0,0 @@ -//! Conditional maximum likelihood (CML) estimation of the Rasch model, with Andersen's LR test. -//! -//! The dichotomous Rasch model `P(X_vi = 1) = exp(theta_v - beta_i) / (1 + exp(theta_v - beta_i))` has -//! the raw score `r_v = sum_i x_vi` as the sufficient statistic for the person parameter `theta_v`. -//! Conditioning each response pattern on its raw score ELIMINATES the person parameters entirely, so the -//! item difficulties `beta` are estimated without any assumption on the ability distribution (Rasch's -//! specific objectivity) and consistently at fixed test length as `N -> infinity` — unlike joint ML -//! (inconsistent) or marginal ML (which must posit a `theta` distribution). -//! -//! With `eps_i = exp(-beta_i)`, `s_i = sum_v x_vi` (item total-correct over the retained persons), -//! `n_r` = number of persons with raw score `r`, and `gamma_r(eps)` the ELEMENTARY SYMMETRIC FUNCTION -//! of order `r` of `{eps_1, .., eps_k}` (the sum over all size-`r` subsets of products of `eps`), the -//! conditional log-likelihood is -//! -//! ```text -//! ln L_c(beta) = -sum_i s_i beta_i - sum_{r=1}^{k-1} n_r ln gamma_r(eps). -//! ``` -//! -//! Persons with raw score `0` or `k` carry no conditional information (their total contribution to -//! `ln L_c` is identically `0`, and their conditional expected item score is `0`/`1` and cancels in the -//! score equation), so they are dropped. The score equation is `observed s_i = expected`, with -//! `E[s_i | r] = eps_i gamma_{r-1}^{(i)} / gamma_r` (`gamma^{(i)}` = ESF over the items excluding `i`). -//! `beta` is identified up to an additive constant, reported centered to `sum_i beta_i = 0`. -//! -//! The ESF and its per-item / per-pair derivatives use the SUMMATION algorithm (a fresh forward -//! recursion `gamma_r += eps_j gamma_{r-1}` over the relevant item subset), which is numerically stable; -//! the subtractive "difference" recursion `gamma_r^{(i)} = gamma_r - eps_i gamma_{r-1}^{(i)}` is avoided -//! because it cancels catastrophically for very easy items (large `eps_i`) (Verhelst, Glas & van der -//! Sluis, 1984; Fischer & Molenaar, 1995). -//! -//! Andersen's (1973) conditional likelihood-ratio test of Rasch fit partitions the persons into `G` -//! subgroups, estimates `beta` within each and over the pooled sample, and refers -//! `LR = 2 [sum_g ln L_c^{(g)}(beta_hat_g) - ln L_c(beta_hat)]` to `chi^2((G - 1)(k - 1))`; a large `LR` -//! rejects the invariance of the item difficulties across the split. -//! -//! # References (APA 7th ed.) -//! -//! Andersen, E. B. (1970). Asymptotic properties of conditional maximum-likelihood estimators. *Journal -//! of the Royal Statistical Society: Series B, 32*(2), 283-301. -//! https://doi.org/10.1111/j.2517-6161.1970.tb00842.x -//! Andersen, E. B. (1972). The numerical solution of a set of conditional estimation equations. -//! *Journal of the Royal Statistical Society: Series B, 34*(1), 42-54. -//! https://doi.org/10.1111/j.2517-6161.1972.tb00887.x -//! Andersen, E. B. (1973). A goodness of fit test for the Rasch model. *Psychometrika, 38*(1), 123-140. -//! https://doi.org/10.1007/BF02291180 -//! Rasch, G. (1960). *Probabilistic models for some intelligence and attainment tests*. Danish -//! Institute for Educational Research. -//! Verhelst, N. D., Glas, C. A. W., & van der Sluis, A. (1984). Estimation problems in the Rasch model: -//! The basic symmetric functions. *Computational Statistics Quarterly, 1*(3), 245-262. - -use crate::fitstats::chi2_sf; -use crate::poly::solve_small; -use crate::twopl::sym_inv_logdet; - -/// Maximum number of items (bounds the `O(k^4)` per-iteration Hessian and keeps the plain-value ESF in -/// range; a log-domain ESF would be needed above this). -pub const CML_MAX_ITEMS: usize = 100; - -/// Elementary symmetric functions `gamma_0..gamma_k` of `eps` by the summation algorithm. -fn esf(eps: &[f64]) -> Vec { - let k = eps.len(); - let mut g = vec![0.0f64; k + 1]; - g[0] = 1.0; - for (j, &e) in eps.iter().enumerate() { - for r in (1..=(j + 1).min(k)).rev() { - g[r] += e * g[r - 1]; - } - } - g -} - -/// ESF `gamma_0..gamma_{k-1}` of the items EXCLUDING `omit`, by a fresh summation pass (stable; no -/// subtractive cancellation). -fn esf_omit(eps: &[f64], omit: usize) -> Vec { - let k = eps.len(); - let mut g = vec![0.0f64; k]; // orders 0..=k-1 - g[0] = 1.0; - let mut cnt = 0usize; - for (j, &e) in eps.iter().enumerate() { - if j == omit { - continue; - } - cnt += 1; - for r in (1..=cnt.min(k - 1)).rev() { - g[r] += e * g[r - 1]; - } - } - g -} - -/// ESF `gamma_0..gamma_{k-2}` of the items EXCLUDING both `a` and `b` (`a != b`), by a fresh pass. -fn esf_omit2(eps: &[f64], a: usize, b: usize) -> Vec { - let k = eps.len(); - let mut g = vec![0.0f64; k - 1]; // orders 0..=k-2 - g[0] = 1.0; - let mut cnt = 0usize; - for (j, &e) in eps.iter().enumerate() { - if j == a || j == b { - continue; - } - cnt += 1; - for r in (1..=cnt.min(k - 2)).rev() { - g[r] += e * g[r - 1]; - } - } - g -} - -/// Conditional log-likelihood, gradient, and Hessian at `beta` given item totals `s` and score -/// frequencies `nr` (`nr[r]` = retained persons with raw score `r`; `r = 0` and `r = k` are ignored). -fn cml_eval(beta: &[f64], s: &[f64], nr: &[f64]) -> (f64, Vec, Vec) { - let k = beta.len(); - let eps: Vec = beta.iter().map(|b| (-b).exp()).collect(); - let g = esf(&eps); - let gi: Vec> = (0..k).map(|i| esf_omit(&eps, i)).collect(); - - let mut ll = 0.0; - for i in 0..k { - ll -= s[i] * beta[i]; - } - for r in 1..k { - if nr[r] != 0.0 { - ll -= nr[r] * g[r].ln(); - } - } - - let mut grad = vec![0.0f64; k]; - let mut hess = vec![0.0f64; k * k]; - // conditional expected item score E[s_i] and the diagonal (variance) term. - for i in 0..k { - grad[i] = -s[i]; - for r in 1..k { - if nr[r] == 0.0 { - continue; - } - let eir = eps[i] * gi[i][r - 1] / g[r]; - grad[i] += nr[r] * eir; - hess[i * k + i] += nr[r] * (eir * eir - eir); // = -nr E_ir(1 - E_ir) - } - } - // off-diagonal: H_ij = sum_r n_r [E_ir E_jr - eps_i eps_j gamma_{r-2}^{(ij)} / gamma_r] - for i in 0..k { - for j in (i + 1)..k { - let gij = esf_omit2(&eps, i, j); - let mut hij = 0.0; - for r in 1..k { - if nr[r] == 0.0 { - continue; - } - let eir = eps[i] * gi[i][r - 1] / g[r]; - let ejr = eps[j] * gi[j][r - 1] / g[r]; - let joint = if r >= 2 { - eps[i] * eps[j] * gij[r - 2] / g[r] - } else { - 0.0 - }; - hij += nr[r] * (eir * ejr - joint); - } - hess[i * k + j] = hij; - hess[j * k + i] = hij; - } - } - (ll, grad, hess) -} - -/// A fitted Rasch CML result. -pub struct CmlFit { - /// Item difficulties, centered to `sum_i beta_i = 0`. - pub beta: Vec, - /// Standard errors (sum-zero metric; `NaN` if the information is non-PD). - pub se: Vec, - /// Conditional log-likelihood at `beta`. - pub loglik: f64, - pub n_iter: usize, - pub converged: bool, - /// Persons retained (raw score in `1..k`). - pub n_used: usize, -} - -/// Reduce a complete `0/1` matrix to item totals `s` and score frequencies `nr` over the persons with -/// raw score in `1..k` (dropping the uninformative `0` and `k` patterns). -fn reduce(y: &[u8], n_persons: usize, n_items: usize) -> (Vec, Vec, usize) { - let mut s = vec![0.0f64; n_items]; - let mut nr = vec![0.0f64; n_items + 1]; - let mut used = 0usize; - for p in 0..n_persons { - let row = &y[p * n_items..(p + 1) * n_items]; - let r: usize = row.iter().map(|&v| v as usize).sum(); - if r == 0 || r == n_items { - continue; - } - used += 1; - nr[r] += 1.0; - for i in 0..n_items { - s[i] += row[i] as f64; - } - } - (s, nr, used) -} - -fn center(beta: &mut [f64]) { - let m = beta.iter().sum::() / beta.len() as f64; - for b in beta.iter_mut() { - *b -= m; - } -} - -/// Newton CML fit from precomputed sufficient statistics. -fn fit_from_stats( - s: &[f64], - nr: &[f64], - n_used: usize, - max_iter: usize, - tol: f64, -) -> Result { - let k = s.len(); - let mut beta = vec![0.0f64; k]; - let (mut ll, mut grad, mut hess) = cml_eval(&beta, s, nr); - let mut converged = false; - let mut iter = 0; - while iter < max_iter { - iter += 1; - // reduced Newton system: drop the last coordinate (pin its update to 0), re-center after. - let m = k - 1; - let mut hr: Vec> = (0..m) - .map(|a| (0..m).map(|b| hess[a * k + b]).collect()) - .collect(); - // tiny ridge for a well-posed solve near the optimum - for a in 0..m { - hr[a][a] -= 1e-10; - } - let gr: Vec = grad[..m].to_vec(); - // Newton maximization step: beta -= H^{-1} grad. - let step = solve_small(hr, gr); - // backtracking to guarantee ascent of the concave conditional likelihood. - let mut scale = 1.0f64; - let mut accepted = false; - for _ in 0..20 { - let mut cand = beta.clone(); - for a in 0..m { - cand[a] -= scale * step[a]; - } - center(&mut cand); - let (ll_c, g_c, h_c) = cml_eval(&cand, s, nr); - if ll_c.is_finite() && ll_c >= ll - 1e-12 { - beta = cand; - ll = ll_c; - grad = g_c; - hess = h_c; - accepted = true; - break; - } - scale *= 0.5; - } - if !accepted { - break; - } - if grad.iter().fold(0.0f64, |m, &v| m.max(v.abs())) < tol { - converged = true; - break; - } - } - // SE from the pseudoinverse of the conditional information I_c = -H (rank k-1, null space = ones): - // I_c^+ = (I_c + (1/k) J)^{-1} - (1/k) J, with J the all-ones matrix; SE_i = sqrt(I_c^+_{ii}). - let mut se = vec![f64::NAN; k]; - let mut m = vec![0.0f64; k * k]; - let inv_k = 1.0 / k as f64; - for a in 0..k { - for b in 0..k { - m[a * k + b] = -hess[a * k + b] + inv_k; - } - } - if let Some((minv, _)) = sym_inv_logdet(&m, k) { - for i in 0..k { - let v = minv[i * k + i] - inv_k; - se[i] = if v > 0.0 { v.sqrt() } else { f64::NAN }; - } - } - Ok(CmlFit { - beta, - se, - loglik: ll, - n_iter: iter, - converged, - n_used, - }) -} - -/// Fit the dichotomous Rasch model by conditional maximum likelihood (Andersen, 1970, 1972). -/// -/// `y` is a row-major `n_persons * n_items` complete `0/1` array (CML requires complete data — a person -/// with missing items has a different conditioning score set; that extension is out of scope). Persons -/// scoring `0` or `k` are dropped. Returns the sum-zero item difficulties and their standard errors. -pub fn fit_rasch_cml( - y: &[u8], - n_persons: usize, - n_items: usize, - max_iter: usize, - tol: f64, -) -> Result { - validate(y, n_persons, n_items, max_iter, tol)?; - let (s, nr, used) = reduce(y, n_persons, n_items); - if used == 0 { - return Err("no persons with an informative raw score (all scored 0 or k)".into()); - } - fit_from_stats(&s, &nr, used, max_iter, tol) -} - -/// One item's group-split difficulties and the Andersen LR test. -pub struct AndersenLr { - /// Conditional LR statistic `2[sum_g llc_g(beta_g) - llc(beta_pooled)]`. - pub lr: f64, - /// Degrees of freedom `(G - 1)(k - 1)`. - pub df: usize, - /// Upper-tail `p`-value of `lr` under `chi^2(df)`. - pub p_value: f64, - /// Per-group retained-person counts. - pub n_used: Vec, - /// `true` only if the pooled AND every per-group CML fit converged. When `false` the `lr`/`p_value` - /// are untrustworthy: a stalled group fit can drive the pre-clamp statistic negative (it is clamped - /// to `0`), so do not interpret a non-converged result as a clean non-rejection. - pub converged: bool, -} - -/// Andersen's (1973) conditional likelihood-ratio test of Rasch fit across a person partition. -/// -/// `group` is length `n_persons` with labels `0..n_groups`. Fits CML within each group and over the -/// pooled sample; `LR = 2[sum_g llc_g(beta_hat_g) - llc(beta_hat_pooled)]` is referred to -/// `chi^2((n_groups - 1)(n_items - 1))`. A significant `LR` rejects invariance of the item difficulties -/// across the split (Rasch misfit). -pub fn andersen_lr_test( - y: &[u8], - group: &[u8], - n_groups: usize, - n_persons: usize, - n_items: usize, - max_iter: usize, - tol: f64, -) -> Result { - validate(y, n_persons, n_items, max_iter, tol)?; - if group.len() != n_persons { - return Err(format!( - "group has {} entries; expected {n_persons}", - group.len() - )); - } - if n_groups < 2 { - return Err("the Andersen LR test needs at least 2 groups".into()); - } - if group.iter().any(|&g| g as usize >= n_groups) { - return Err("group labels must be in 0..n_groups".into()); - } - // pooled fit - let pooled = fit_rasch_cml(y, n_persons, n_items, max_iter, tol)?; - let mut all_converged = pooled.converged; - // per-group fits + their conditional loglik at the pooled beta - let mut ll_groups = 0.0f64; - let mut ll_pooled_on_groups = 0.0f64; - let mut n_used = vec![0usize; n_groups]; - for gg in 0..n_groups { - let rows: Vec = (0..n_persons) - .filter(|&p| group[p] as usize == gg) - .flat_map(|p| y[p * n_items..(p + 1) * n_items].iter().copied()) - .collect(); - let ng = rows.len() / n_items; - if ng == 0 { - return Err(format!("group {gg} has no persons")); - } - let (sg, nrg, usedg) = reduce(&rows, ng, n_items); - if usedg == 0 { - return Err(format!( - "group {gg} has no informative persons (all scored 0 or {n_items})" - )); - } - n_used[gg] = usedg; - let fit_g = fit_from_stats(&sg, &nrg, usedg, max_iter, tol)?; - all_converged &= fit_g.converged; - ll_groups += fit_g.loglik; - // pooled beta evaluated on group g's sufficient statistics - ll_pooled_on_groups += cml_eval(&pooled.beta, &sg, &nrg).0; - } - // Each group term llc_g(beta_g) - llc_g(beta_pooled) is >= 0 only when beta_g maximizes llc_g; a - // stalled fit can make it negative, so clamp rounding noise but flag non-convergence rather than - // silently reporting a clamped lr = 0 as a clean non-rejection. - let lr = (2.0 * (ll_groups - ll_pooled_on_groups)).max(0.0); - let df = (n_groups - 1) * (n_items - 1); - Ok(AndersenLr { - lr, - df, - p_value: chi2_sf(lr, df as f64), - n_used, - converged: all_converged, - }) -} - -fn validate( - y: &[u8], - n_persons: usize, - n_items: usize, - max_iter: usize, - tol: f64, -) -> Result<(), String> { - if n_persons < 1 || n_items < 2 { - return Err("need n_persons >= 1 and n_items >= 2".into()); - } - if n_items > CML_MAX_ITEMS { - return Err(format!("n_items {n_items} exceeds the cap {CML_MAX_ITEMS}")); - } - let cells = n_persons - .checked_mul(n_items) - .ok_or("n_persons * n_items overflow")?; - if y.len() != cells { - return Err(format!("y has {} entries; expected {cells}", y.len())); - } - if y.iter().any(|&v| v > 1) { - return Err("responses must be 0 or 1".into()); - } - if max_iter == 0 { - return Err("max_iter must be >= 1".into()); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - Ok(()) -} - -#[cfg(test)] -#[path = "../../../tests/unit/rasch_cml_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/reliability.rs b/crates/mlsirm-core/src/reliability.rs deleted file mode 100644 index 863fd768b..000000000 --- a/crates/mlsirm-core/src/reliability.rs +++ /dev/null @@ -1,736 +0,0 @@ -//! Guttman (1945) lower-bound reliability coefficients (lambda 1-6) with the -//! split-half machinery of Revelle's `psych` package. -//! -//! # Verified sources -//! -//! - **READ IN FULL** (the oracle): psych 2.6.5 (Revelle, 2025) R sources -//! `R/guttman.R`, `R/splitHalf.R`, `R/smc.R`, fetched from CRAN. Every -//! formula below is traced to a line of that code. -//! - **NOT read**: Guttman (1945) itself (paywalled at verification time). -//! Attribution is therefore "Guttman (1945), as implemented in psych -//! 2.6.5"; no claim is made about the primary text beyond what psych -//! implements. -//! -//! # Formulas (on the item Pearson correlation matrix `R`, `p` items) -//! -//! With `Vt = sum(R)` (all cells), `sum_off = Vt - p` (`tr(R) = p`), -//! `sumsq_off = sum of squared off-diagonals`: -//! -//! - `lambda1 = 1 - p / Vt` (guttman.R line 78) -//! - `lambda2 = (sum_off + sqrt(sumsq_off * p/(p-1))) / Vt` (line 84) -//! - `lambda3 = p/(p-1) * lambda1` ( = coefficient alpha) (line 85) -//! - `lambda5 = lambda1 + 2*sqrt(max_j C_j)/Vt` with -//! `C_j = sum_{i != j} R_ij^2` (`colSums(r^2) - diag(r^2)`, lines 89-91) -//! - `lambda6 = (sum_off + sum_j smc_j) / Vt` with -//! `smc_j = 1 - 1/[R^{-1}]_jj` clamped to `[0, 1]` (smc.R lines 57, 68-71; -//! guttman.R line 87 — `sum.r - tr(R) = sum_off`, same expression) -//! - **Split halves** (splitHalf.R): items are split into subset A of size -//! `m = floor(p/2)` and complement B; for each split -//! `rb = |4 * S_AB / Vt|` where `S_AB = sum_{i in A, j in B} R_ij` -//! (line 17: `rab = 4*R[1,2]/sum(R)`; the 2x2 collapsed matrix satisfies -//! `R11 + R22 + 2 R12 = Vt` because A and B partition the items). -//! `lambda4 = max rb`, `beta = max(min rb, 0)` (guttman.R lines 121-122), -//! `mean_split = mean rb`. All `C(p, m)` subsets are enumerated when that -//! count fits the `n_sample_splits` budget (psych brute-forces at -//! `<= 15000`, splitHalf.R lines 77-78); otherwise `n_sample_splits` -//! random subsets are drawn. -//! -//! # Deliberate divergences from psych (all verified against the R source) -//! -//! 1. No `check.keys` auto-reversal of negatively keyed items (splitHalf.R -//! lines 34-38 call `principal()`; factor analysis is out of scope). -//! Supply keyed data; for negatively keyed inputs `lambda4`/`beta` are -//! NOT psych-parity because psych may auto-reverse. -//! 2. `lambda5p`, `alpha.pc`, `r.pc`, `beta.pc`, `glb`, `tenberge` are not -//! computed (they need `fa`/`glb.fa`, out of scope). -//! 3. The sampled branch draws subsets from the crate LCG stream, not R's -//! `sample()` — not bit-identical to psych. The exhaustive branch is -//! deterministic and directly comparable. -//! 4. SMC uses a plain Gauss-Jordan inverse and returns an error on a -//! singular correlation matrix; psych uses a pseudo-inverse (`Pinv`) and -//! silently degrades. An exactly singular `R` (e.g. duplicate items) is -//! an input problem this crate refuses rather than papers over. -//! 5. `|rb|` is taken in BOTH branches. psych's exhaustive branch takes -//! `abs()` (splitHalf.R lines 85, 107) but its sampled branch does not -//! (lines 118-128); we prefer internal consistency over replicating that -//! asymmetry. -//! 6. Sampled subsets may repeat (psych's `sample()` loop has the same -//! property). -//! -//! # References -//! -//! Guttman, L. (1945). A basis for analyzing test-retest reliability. -//! *Psychometrika, 10*(4), 255-282. (As implemented in psych 2.6.5; primary -//! text not consulted.) -//! -//! Revelle, W. (2025). *psych: Procedures for psychological, psychometric, -//! and personality research* (Version 2.6.5) [R package]. CRAN. -use crate::parallel::{correlation_matrix, lcg_uniform}; - -/// Guttman lambda coefficients and split-half summaries. -#[derive(Debug, Clone)] -pub struct GuttmanResult { - pub lambda1: f64, - pub lambda2: f64, - pub lambda3: f64, - /// Maximum absolute split-half reliability over the evaluated splits. - pub lambda4: f64, - pub lambda5: f64, - pub lambda6: f64, - /// Worst (minimum) split-half, floored at 0 (guttman.R line 122). - pub beta: f64, - /// Mean absolute split-half over the evaluated splits. - pub mean_split: f64, - /// Number of splits evaluated. - pub n_splits: usize, - /// `true` when all `C(p, floor(p/2))` subsets were enumerated. - pub exhaustive: bool, -} - -/// Guttman lambda 1-6 reliability bounds for a row-major `n_persons x -/// n_items` data matrix (complete, finite; see the module docs for the -/// formula provenance and divergences from psych). -/// -/// `n_sample_splits` is the split-evaluation budget: when -/// `C(p, floor(p/2))` exceeds it, that many random splits are sampled from -/// the crate's deterministic LCG stream seeded with `seed.max(1)`. -pub fn guttman_lambdas( - data: &[f64], - n_persons: usize, - n_items: usize, - n_sample_splits: usize, - seed: u64, -) -> Result { - if n_persons < 3 { - return Err("guttman_lambdas needs n_persons >= 3".into()); - } - if n_items < 3 { - // psych's guttman() stops below 3 items (guttman.R lines 29-30). - return Err("guttman_lambdas needs n_items >= 3".into()); - } - if n_sample_splits == 0 { - return Err("n_sample_splits must be >= 1".into()); - } - let cells = n_persons - .checked_mul(n_items) - .ok_or("data dimensions overflow usize")?; - if data.len() != cells { - return Err(format!( - "data length {} does not match n_persons * n_items = {cells}", - data.len() - )); - } - if data.iter().any(|v| !v.is_finite()) { - return Err("data must be finite (no NaN/inf; complete data required)".into()); - } - - let p = n_items; - let r = correlation_matrix(data, n_persons, p)?; - let vt: f64 = r.iter().sum(); - if !vt.is_finite() || vt <= 0.0 { - return Err(format!( - "sum of the correlation matrix is {vt}; total-score variance must be positive" - )); - } - let sum_off = vt - p as f64; // tr(R) = p exactly - let sumsq_off: f64 = (0..p) - .flat_map(|i| (0..p).map(move |j| (i, j))) - .filter(|(i, j)| i != j) - .map(|(i, j)| r[i * p + j] * r[i * p + j]) - .sum(); - let pm1 = (p - 1) as f64; - - let lambda1 = 1.0 - p as f64 / vt; - let lambda2 = (sum_off + (sumsq_off * p as f64 / pm1).sqrt()) / vt; - let lambda3 = p as f64 / pm1 * lambda1; - - // lambda5: column sums of squared off-diagonals (guttman.R lines 89-91). - let mut c_max = f64::NEG_INFINITY; - for j in 0..p { - let cj: f64 = (0..p) - .filter(|&i| i != j) - .map(|i| r[i * p + j] * r[i * p + j]) - .sum(); - c_max = c_max.max(cj); - } - let lambda5 = lambda1 + 2.0 * c_max.sqrt() / vt; - - // lambda6: squared multiple correlations from the inverse diagonal, - // clamped to [0, 1] as psych's smc() does (smc.R lines 57, 68-71). - let rinv = invert_symmetric(&r, p) - .map_err(|e| format!("{e}; lambda6 (SMC) requires an invertible correlation matrix"))?; - let sum_smc: f64 = (0..p) - .map(|j| (1.0 - 1.0 / rinv[j * p + j]).clamp(0.0, 1.0)) - .sum(); - let lambda6 = (sum_off + sum_smc) / vt; - - // Split halves. - let m = p / 2; - let count = binomial(p, m); - let mut max_rb = f64::NEG_INFINITY; - let mut min_rb = f64::INFINITY; - let mut sum_rb = 0.0_f64; - let mut n_splits = 0_usize; - let exhaustive = count <= n_sample_splits as u128; - if exhaustive { - // Lexicographic enumeration of all m-subsets of 0..p. - let mut idx: Vec = (0..m).collect(); - loop { - let rb = split_rb(&r, p, vt, &idx); - max_rb = max_rb.max(rb); - min_rb = min_rb.min(rb); - sum_rb += rb; - n_splits += 1; - // next combination - let mut i = m; - loop { - if i == 0 { - break; - } - i -= 1; - if idx[i] != i + p - m { - idx[i] += 1; - for k in (i + 1)..m { - idx[k] = idx[k - 1] + 1; - } - break; - } - } - if idx[0] == p - m && (1..m).all(|k| idx[k] == p - m + k) && n_splits as u128 == count { - break; - } - if n_splits as u128 > count { - return Err("split enumeration overran the binomial count (internal bug)".into()); - } - } - } else { - let mut state = seed.max(1); - let mut idx: Vec = (0..p).collect(); - for _ in 0..n_sample_splits { - // Partial Fisher-Yates: first m entries become subset A. - for (i, item) in idx.iter_mut().enumerate() { - *item = i; - } - for i in 0..m { - let u = lcg_uniform(&mut state); - let j = (i + (u * (p - i) as f64) as usize).min(p - 1); - idx.swap(i, j); - } - let mut a: Vec = idx[..m].to_vec(); - a.sort_unstable(); - let rb = split_rb(&r, p, vt, &a); - max_rb = max_rb.max(rb); - min_rb = min_rb.min(rb); - sum_rb += rb; - n_splits += 1; - } - } - - Ok(GuttmanResult { - lambda1, - lambda2, - lambda3, - lambda4: max_rb, - lambda5, - lambda6, - beta: min_rb.max(0.0), - mean_split: sum_rb / n_splits as f64, - n_splits, - exhaustive, - }) -} - -/// ten Berge & Zegers mu coefficient series (mu0..mu3). -#[derive(Debug, Clone)] -pub struct TenBergeResult { - /// mu0 = coefficient alpha (equals Guttman lambda3 exactly). - pub mu0: f64, - /// mu1 (equals Guttman lambda2 exactly). - pub mu1: f64, - pub mu2: f64, - pub mu3: f64, -} - -/// ten Berge & Zegers (1978) mu0-mu3 reliability lower bounds for a -/// row-major `n_persons x n_items` data matrix (complete, finite). -/// -/// Transcribed from psych 2.6.5 `tenberge.R` lines 4-12, read line by line -/// (Revelle, 2025); ten Berge & Zegers (1978), *Psychometrika, 43*(4), -/// 575-579, https://doi.org/10.1007/BF02293811, was NOT read — attribution -/// is "as cited in / as implemented by Revelle (2025)". With `Vt = sum(R)`, -/// `S_k = sum_{i != j} R_ij^k`, and `c = p/(p-1)` applied to the INNERMOST -/// radical only: -/// -/// ```text -/// mu0 = c * S_1 / Vt (tenberge.R line 9) -/// mu1 = (S_1 + sqrt(c * S_2)) / Vt (line 10) -/// mu2 = (S_1 + sqrt(S_2 + sqrt(c * S_4))) / Vt (line 11) -/// mu3 = (S_1 + sqrt(S_2 + sqrt(S_4 + sqrt(c * S_8)))) / Vt (line 12) -/// ``` -/// -/// Verified identities (disclosed, pinned independently in tests): -/// `mu0 == guttman lambda3` (alpha; since `S_1 = Vt - p`) and -/// `mu1 == guttman lambda2` (character-identical formula in guttman.R). -/// `mu0 <= mu1 <= mu2 <= mu3` holds for every valid correlation matrix by -/// Cauchy-Schwarz over the `p*(p-1)` off-diagonal cells -/// (`sqrt(c * S_{2k}) >= S_k / (p-1)` step-wise) given `Vt > 0`. -/// -/// Divergences from psych (deliberate): raw-data input only (no -/// correlation-matrix passthrough via the fragile `dim[1] > n` heuristic, -/// no `use = "pairwise"`); hard errors on degenerate input instead of NA -/// propagation. `S_1` is summed directly over off-diagonal cells rather -/// than as `Vt - p` to avoid cancellation. -pub fn tenberge_mu( - data: &[f64], - n_persons: usize, - n_items: usize, -) -> Result { - if n_persons < 3 { - return Err("tenberge_mu needs n_persons >= 3".into()); - } - if n_items < 3 { - return Err("tenberge_mu needs n_items >= 3".into()); - } - let cells = n_persons - .checked_mul(n_items) - .ok_or("data dimensions overflow usize")?; - if data.len() != cells { - return Err(format!( - "data length {} does not match n_persons * n_items = {cells}", - data.len() - )); - } - if data.iter().any(|v| !v.is_finite()) { - return Err("data must be finite (no NaN/inf; complete data required)".into()); - } - let p = n_items; - let r = correlation_matrix(data, n_persons, p)?; - let vt: f64 = r.iter().sum(); - if !vt.is_finite() || vt <= 0.0 { - return Err(format!( - "sum of the correlation matrix is {vt}; total-score variance must be positive" - )); - } - let (mut s1, mut s2, mut s4, mut s8) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); - for i in 0..p { - for j in 0..p { - if i == j { - continue; - } - let x = r[i * p + j]; - let x2 = x * x; - let x4 = x2 * x2; - s1 += x; - s2 += x2; - s4 += x4; - s8 += x4 * x4; - } - } - let c = p as f64 / (p as f64 - 1.0); - Ok(TenBergeResult { - mu0: c * s1 / vt, - mu1: (s1 + (c * s2).sqrt()) / vt, - mu2: (s1 + (s2 + (c * s4).sqrt()).sqrt()) / vt, - mu3: (s1 + (s2 + (s4 + (c * s8).sqrt()).sqrt()).sqrt()) / vt, - }) -} - -/// `|4 * S_AB / Vt|` for the split with subset A = `a_idx` (sorted item -/// indices) — splitHalf.R line 17 with abs per divergence 5. -fn split_rb(r: &[f64], p: usize, vt: f64, a_idx: &[usize]) -> f64 { - let mut in_a = vec![false; p]; - for &i in a_idx { - in_a[i] = true; - } - let mut s_ab = 0.0_f64; - for i in 0..p { - if !in_a[i] { - continue; - } - for j in 0..p { - if !in_a[j] { - s_ab += r[i * p + j]; - } - } - } - (4.0 * s_ab / vt).abs() -} - -/// `C(n, k)` in saturating u128 (only compared against a budget). -fn binomial(n: usize, k: usize) -> u128 { - let k = k.min(n - k); - let mut acc: u128 = 1; - for i in 0..k { - acc = acc.saturating_mul((n - i) as u128) / (i + 1) as u128; - } - acc -} - -/// Gauss-Jordan inverse with partial pivoting. Errors when a pivot falls -/// below `1e-12` (singular / numerically singular input). -pub(crate) fn invert_symmetric(matrix: &[f64], p: usize) -> Result, String> { - let mut a = matrix.to_vec(); - let mut inv = vec![0.0_f64; p * p]; - for i in 0..p { - inv[i * p + i] = 1.0; - } - for col in 0..p { - let (pivot_row, pivot_abs) = (col..p) - .map(|row| (row, a[row * p + col].abs())) - .max_by(|x, y| x.1.partial_cmp(&y.1).expect("finite pivots")) - .expect("non-empty column"); - if pivot_abs < 1e-12 { - return Err("correlation matrix is singular".into()); - } - if pivot_row != col { - for k in 0..p { - a.swap(col * p + k, pivot_row * p + k); - inv.swap(col * p + k, pivot_row * p + k); - } - } - let pivot = a[col * p + col]; - for k in 0..p { - a[col * p + k] /= pivot; - inv[col * p + k] /= pivot; - } - for row in 0..p { - if row == col { - continue; - } - let factor = a[row * p + col]; - if factor == 0.0 { - continue; - } - for k in 0..p { - a[row * p + k] -= factor * a[col * p + k]; - inv[row * p + k] -= factor * inv[col * p + k]; - } - } - } - Ok(inv) -} - -/// Result of a Feldt (1965) confidence interval for coefficient alpha. -/// -/// Feldt, L. S. (1965). The approximate sampling distribution of -/// Kuder-Richardson reliability coefficient twenty. Psychometrika, 30(3), -/// 357-370. https://doi.org/10.1007/BF02289499 (paper unobtainable; formula -/// as cited in and verified against Revelle, W. (2025), psych R package -/// v2.6.5, `alpha.ci` in R/alpha.R, read in full). -#[derive(Debug, Clone, Copy)] -pub struct AlphaCiResult { - /// The point estimate passed in. - pub alpha: f64, - /// Lower confidence bound (may be negative; not clamped, matching psych). - pub lower: f64, - /// Upper confidence bound. - pub upper: f64, - /// Average inter-item correlation implied by alpha: - /// `r_bar = alpha / (p - alpha*(p-1))` (Spearman-Brown inversion). - pub r_bar: f64, - /// Numerator degrees of freedom, `n - 1`. - pub df1: f64, - /// Denominator degrees of freedom, `(n - 1) * (p - 1)`. - pub df2: f64, -} - -/// Cronbach's coefficient alpha from raw data (covariance form). -/// -/// `alpha = p/(p-1) * (1 - tr(C)/sum(C))` on the sample covariance matrix C -/// (denominator n-1; the n vs n-1 choice cancels in the ratio). -/// -/// Cronbach, L. J. (1951). Coefficient alpha and the internal structure of -/// tests. Psychometrika, 16(3), 297-334. https://doi.org/10.1007/BF02310555 -/// (covariance form verified against Revelle (2025) psych v2.6.5 R/alpha.R -/// raw-alpha computation; the 1951 paper itself was not re-read for this -/// implementation). -/// -/// `data` is row-major n x p. Divergences from psych::alpha: raw-data input -/// only (no reverse-keying/check.keys), zero-variance items are rejected -/// rather than dropped with a warning, hard errors instead of NA. -pub fn cronbach_alpha(data: &[f64], n: usize, p: usize) -> Result { - if n < 3 { - return Err("cronbach_alpha needs at least 3 persons".into()); - } - if p < 2 { - return Err("cronbach_alpha needs at least 2 items".into()); - } - if data.len() != n * p { - return Err(format!("data length {} != n*p = {}", data.len(), n * p)); - } - if data.iter().any(|v| !v.is_finite()) { - return Err("data contains non-finite values".into()); - } - let nf = n as f64; - let mut means = vec![0.0; p]; - for row in data.chunks_exact(p) { - for (m, v) in means.iter_mut().zip(row) { - *m += v; - } - } - for m in &mut means { - *m /= nf; - } - // Covariance matrix accumulators: trace and full sum are all we need. - let mut trace = 0.0; - let mut total = 0.0; - for j in 0..p { - for k in j..p { - let mut acc = 0.0; - for row in data.chunks_exact(p) { - acc += (row[j] - means[j]) * (row[k] - means[k]); - } - let cov = acc / (nf - 1.0); - if j == k { - if cov <= 0.0 { - return Err(format!("item {j} has non-positive variance")); - } - trace += cov; - total += cov; - } else { - total += 2.0 * cov; - } - } - } - if total <= 0.0 { - return Err("total-score variance (sum of covariance matrix) is not positive".into()); - } - let pf = p as f64; - Ok(pf / (pf - 1.0) * (1.0 - trace / total)) -} - -/// Regularized incomplete beta I_x(a, b) via Lentz continued fraction -/// (Numerical Recipes 3rd ed., sec. 6.4 `betacf` form; transcribed and -/// verified against scipy.stats fixtures in the test suite). -pub(crate) fn inc_beta(a: f64, b: f64, x: f64) -> f64 { - if x <= 0.0 { - return 0.0; - } - if x >= 1.0 { - return 1.0; - } - let ln_front = crate::fitstats::ln_gamma(a + b) - - crate::fitstats::ln_gamma(a) - - crate::fitstats::ln_gamma(b) - + a * x.ln() - + b * (1.0 - x).ln(); - // Symmetry: use the tail where the continued fraction converges fast. - if x < (a + 1.0) / (a + b + 2.0) { - ln_front.exp() * beta_cf(a, b, x) / a - } else { - 1.0 - ln_front.exp() * beta_cf(b, a, 1.0 - x) / b - } -} - -fn beta_cf(a: f64, b: f64, x: f64) -> f64 { - const TINY: f64 = 1e-300; - const EPS: f64 = 1e-15; - let qab = a + b; - let qap = a + 1.0; - let qam = a - 1.0; - let mut c = 1.0; - let mut d = 1.0 - qab * x / qap; - if d.abs() < TINY { - d = TINY; - } - d = 1.0 / d; - let mut h = d; - for m in 1..=300 { - let mf = m as f64; - let m2 = 2.0 * mf; - // even step - let aa = mf * (b - mf) * x / ((qam + m2) * (a + m2)); - d = 1.0 + aa * d; - if d.abs() < TINY { - d = TINY; - } - c = 1.0 + aa / c; - if c.abs() < TINY { - c = TINY; - } - d = 1.0 / d; - h *= d * c; - // odd step - let aa = -(a + mf) * (qab + mf) * x / ((a + m2) * (qap + m2)); - d = 1.0 + aa * d; - if d.abs() < TINY { - d = TINY; - } - c = 1.0 + aa / c; - if c.abs() < TINY { - c = TINY; - } - d = 1.0 / d; - let del = d * c; - h *= del; - if (del - 1.0).abs() < EPS { - break; - } - } - h -} - -/// F-distribution CDF: P(F <= x; d1, d2) = I_z(d1/2, d2/2) with -/// z = d1*x / (d1*x + d2). -fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 { - if x <= 0.0 { - return 0.0; - } - let z = d1 * x / (d1 * x + d2); - inc_beta(d1 / 2.0, d2 / 2.0, z) -} - -/// F-distribution quantile by bisection on z in (0, 1), then -/// x = d2*z / (d1*(1 - z)). Endpoints: prob <= 0 -> 0, prob >= 1 -> +inf -/// (matching qf/scipy; bisection alone would return a finite cap). -fn f_quantile(prob: f64, d1: f64, d2: f64) -> f64 { - if prob <= 0.0 { - return 0.0; - } - if prob >= 1.0 { - return f64::INFINITY; - } - let (mut lo, mut hi) = (0.0_f64, 1.0_f64); - for _ in 0..200 { - let mid = 0.5 * (lo + hi); - let x = d2 * mid / (d1 * (1.0 - mid)); - if f_cdf(x, d1, d2) < prob { - lo = mid; - } else { - hi = mid; - } - } - let z = 0.5 * (lo + hi); - d2 * z / (d1 * (1.0 - z)) -} - -/// Feldt (1965) exact-F confidence interval for coefficient alpha. -/// -/// The pivot (1-alpha)/(1-alpha_hat) is approximately F(n-1, (n-1)(p-1)), so -/// for a two-sided interval at confidence `level` (delta = 1 - level): -/// -/// `lower = 1 - (1-alpha_hat) * F^-1(1-delta/2; df1, df2)` -/// `upper = 1 - (1-alpha_hat) * F^-1(delta/2; df1, df2)` -/// -/// Feldt, L. S. (1965). The approximate sampling distribution of -/// Kuder-Richardson reliability coefficient twenty. Psychometrika, 30(3), -/// 357-370. https://doi.org/10.1007/BF02289499 (paper unobtainable; bound -/// mapping verified line-by-line against Revelle (2025) psych v2.6.5 -/// `alpha.ci`, and numerically against scipy.stats.f.ppf fixtures). -/// -/// Negative `alpha` is allowed (alpha can be negative); bounds are not -/// clamped, matching psych. Divergence from psych: takes the confidence -/// `level` (e.g. 0.95) rather than p.val, and errors instead of NA. -pub fn feldt_alpha_ci(alpha: f64, n: usize, p: usize, level: f64) -> Result { - if !alpha.is_finite() { - return Err("alpha must be finite".into()); - } - if alpha >= 1.0 { - return Err("alpha must be < 1 (pivot degenerate at alpha = 1)".into()); - } - if n < 3 { - return Err("feldt_alpha_ci needs at least 3 persons".into()); - } - if p < 2 { - return Err("feldt_alpha_ci needs at least 2 items".into()); - } - if !(level > 0.0 && level < 1.0) { - return Err("level must be in (0, 1)".into()); - } - let df1 = (n - 1) as f64; - let df2 = ((n - 1) * (p - 1)) as f64; - let delta = 1.0 - level; - let one_minus = 1.0 - alpha; - let lower = 1.0 - one_minus * f_quantile(1.0 - delta / 2.0, df1, df2); - let upper = 1.0 - one_minus * f_quantile(delta / 2.0, df1, df2); - let pf = p as f64; - let r_bar = alpha / (pf - alpha * (pf - 1.0)); - Ok(AlphaCiResult { - alpha, - lower, - upper, - r_bar, - df1, - df2, - }) -} - -/// Person separation reliability (eRm `SepRel`; Wright & Stone, 1999, as -/// cited in Mair et al.'s eRm documentation — neither primary source read). -#[derive(Debug, Clone, PartialEq)] -pub struct SeparationReliabilityResult { - /// `(ssd - mse) / ssd`; unclamped (negative when `mse > ssd`), NaN when - /// `ssd <= 1e-12`. - pub sep_rel: f64, - /// Sample variance of the measures (n-1 denominator, matching R `var`). - pub ssd: f64, - /// Mean squared standard error, `mean(se_i^2)`. - pub mse: f64, - /// Separation index `G = sqrt((ssd - mse) / mse)`. HAND-DERIVED, not in - /// the read source: adjusted true SD over RMSE of measurement, so - /// `G^2 = R / (1 - R)`. NaN when `mse <= 1e-12` or `ssd < mse`. - pub sep_index: f64, -} - -/// Person separation reliability `(SSD - MSE) / SSD` (transcribed from CRAN -/// eRm `R/SepRel.R`, read in full; the docs cite Wright & Stone, 1999, not -/// read). `measures` are point estimates (any estimation method — eRm's -/// docs stress values differ across methods) and `se` their standard -/// errors. -/// -/// Caller responsibility (eRm plumbing NOT reproduced here): eRm drops -/// persons with extreme raw scores (interpolated thetas) and missing -/// estimates before applying the formula — pass already-cleaned vectors. -/// The eRm-backed claim covers person measures; applying this to item -/// measures is a generic extension of the same algebra, not sourced. -pub fn separation_reliability( - measures: &[f64], - se: &[f64], -) -> Result { - let n = measures.len(); - if n < 2 { - return Err("separation_reliability: need at least 2 measures".into()); - } - if se.len() != n { - return Err(format!( - "separation_reliability: se length {} does not match measures length {n}", - se.len() - )); - } - if measures.iter().any(|v| !v.is_finite()) { - return Err("separation_reliability: measures must be finite".into()); - } - if se.iter().any(|v| !v.is_finite() || *v < 0.0) { - return Err("separation_reliability: standard errors must be finite and >= 0".into()); - } - let nf = n as f64; - let mean = measures.iter().sum::() / nf; - let ssd = measures.iter().map(|x| (x - mean).powi(2)).sum::() / (nf - 1.0); - let mse = se.iter().map(|s| s * s).sum::() / nf; - if !ssd.is_finite() || !mse.is_finite() { - return Err( - "separation_reliability: variance/MSE overflowed to non-finite (inputs too large)" - .into(), - ); - } - let sep_rel = if ssd <= 1e-12 { - f64::NAN - } else { - (ssd - mse) / ssd - }; - let sep_index = if mse <= 1e-12 || ssd < mse { - f64::NAN - } else { - ((ssd - mse) / mse).sqrt() - }; - Ok(SeparationReliabilityResult { - sep_rel, - ssd, - mse, - sep_index, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/reliability_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/rsm.rs b/crates/mlsirm-core/src/rsm.rs deleted file mode 100644 index b7395ee11..000000000 --- a/crates/mlsirm-core/src/rsm.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! Rating Scale Model (Andrich, 1978) by marginal-ML EM. -//! -//! The RSM is the Rasch-family polytomous model for items sharing a common rating -//! scale (e.g. Likert): every item has its own location `delta_i`, but the category -//! *threshold* structure `tau_1..tau_{K-1}` is **shared across all items**. The -//! adjacent-category log-odds are -//! -//! ```text -//! ln[ P(X_ij = k | theta) / P(X_ij = k-1 | theta) ] = theta_j - delta_i - tau_k, -//! ``` -//! -//! for `k = 1..K-1`, so the cumulative predictor is -//! `psi_k(theta) = k*theta - k*delta_i - T_k` with `T_k = sum_{m<=k} tau_m` and -//! `psi_0 = 0`; `P(X=k|theta) = softmax_k(psi)`. This is exactly the GPCM cell -//! ([`crate::poly::gpcm_logprobs`]) with slope 1, scores `0..K-1`, and the structured -//! intercept `intercepts[k] = -k*delta_i - T_k`, which this module reuses. -//! -//! Distinct from the per-item PCM/GPCM (`poly.rs`, `mixed.rs`), whose category -//! thresholds are free per item; the RSM ties them to one common set. The trait -//! `theta ~ N(0,1)` fixes the scale; the remaining shift redundancy — the model is -//! invariant under `tau_m -> tau_m - c`, `delta_i -> delta_i + c` — is removed by -//! centering `sum_m tau_m = 0`. Fit by ECM: a per-item Newton for `delta`, then a -//! joint Newton for the common `tau` aggregated over items, then re-centering. -//! -//! # References (APA 7th ed.) -//! Andrich, D. (1978). A rating formulation for ordered response categories. -//! *Psychometrika, 43*(4), 561-573. https://doi.org/10.1007/BF02293814 - -use crate::poly::{gpcm_logprobs, solve_small}; - -const RSM_MAX_CAT: usize = 64; -const RSM_MAX_ITER: usize = 100_000; -const RSM_MAX_COUNT_CELLS: usize = 60_000_000; - -/// Fitted rating scale model (Andrich, 1978). `item_location` is the per-item -/// `delta_i`; `thresholds` the `K-1` common category thresholds `tau_k` (centered, -/// `sum = 0`); `theta` the per-person EAP trait. -#[derive(Clone, Debug)] -pub struct RsmResult { - pub item_location: Vec, - pub thresholds: Vec, - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// `n_items + (n_cat - 1) - 1 = n_items + n_cat - 2`. - pub n_parameters: usize, -} - -/// RSM category log-probabilities at one node: `log P(X = k | theta)` for -/// `k = 0..K-1`, given item location `delta` and the `K-1` common thresholds `tau`. -/// Equals the GPCM cell with slope 1 and `intercepts[k] = -k*delta - T_k`. -pub fn rsm_logprobs(theta: f64, delta: f64, tau: &[f64]) -> Vec { - let kb = tau.len(); // K-1 - let scores: Vec = (0..=kb).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0f64; kb + 1]; - let mut t = 0.0f64; - for k in 1..=kb { - t += tau[k - 1]; // T_k - intercepts[k] = -(k as f64) * delta - t; - } - gpcm_logprobs(theta, &scores, &intercepts) -} - -/// Fit the rating scale model (Andrich, 1978) by marginal-ML EM. `y` is -/// `n_persons * n_items` row-major categories `0..n_cat-1`; `observed` marks -/// non-missing cells (dropped under MAR; `None` = all observed). Ability -/// `theta ~ N(0,1)` on the `q_theta`-node Gauss-Hermite grid. -#[allow(clippy::too_many_arguments)] -pub fn fit_rsm( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - q_theta: usize, - max_iter: usize, - tol: f64, -) -> Result { - if !(2..=RSM_MAX_CAT).contains(&n_cat) { - return Err(format!("n_cat must be in 2..={RSM_MAX_CAT}")); - } - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(1..=RSM_MAX_ITER).contains(&max_iter) { - return Err(format!("max_iter must be in 1..={RSM_MAX_ITER}")); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and > 0".into()); - } - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - for (idx, &c) in y.iter().enumerate() { - if observed.map_or(true, |o| o[idx]) && c >= n_cat { - return Err("response category out of range 0..n_cat-1".into()); - } - } - for i in 0..n_items { - if !(0..n_persons).any(|p| observed.map_or(true, |o| o[p * n_items + i])) { - return Err(format!("item {i} has no observed responses")); - } - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - let (nodes, weights) = crate::quadrature::gh_rule(q_theta) - .ok_or_else(|| format!("unsupported q_theta {q_theta}"))?; - let count_cells = nodes - .len() - .checked_mul(n_items) - .and_then(|cells| cells.checked_mul(n_cat)) - .ok_or_else(|| "node * item * category count-table size overflows usize".to_string())?; - if count_cells > RSM_MAX_COUNT_CELLS { - return Err(format!( - "count table {count_cells} cells exceeds the cap {RSM_MAX_COUNT_CELLS}" - )); - } - let log_w: Vec = weights.iter().map(|w| w.ln()).collect(); - let qn = nodes.len(); - let kb = n_cat - 1; // number of thresholds - - // Init: item locations from the item mean category, thresholds at 0. - let mut delta = vec![0.0f64; n_items]; - let mut tau = vec![0.0f64; kb]; - for i in 0..n_items { - let (mut s, mut c) = (0.0f64, 0.0f64); - for p in 0..n_persons { - if is_obs(p, i) { - s += y[p * n_items + i] as f64; - c += 1.0; - } - } - if c > 0.0 { - // higher mean category -> easier item -> lower delta - let mean = s / c / kb as f64; // in [0,1] - delta[i] = ((1.0 - mean).clamp(0.02, 0.98) / mean.clamp(0.02, 0.98)).ln(); - } - } - - let mut ll; - let mut it = 0usize; - let mut converged = false; - let mut loglik_trace: Vec = Vec::new(); - // Expected category counts r[i][node][k]. - let cell = qn; - - while it < max_iter { - // Per-item cell log-probs at each node. - let mut item_lp = vec![vec![0.0f64; cell * n_cat]; n_items]; - for i in 0..n_items { - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, delta[i], &tau); - item_lp[i][nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - } - // E-step: posteriors -> expected counts. - let mut r = vec![vec![0.0f64; cell * n_cat]; n_items]; - ll = 0.0; - let mut log_node = vec![0.0f64; qn]; - for p in 0..n_persons { - log_node[..qn].copy_from_slice(&log_w[..qn]); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - log_node[nd] += item_lp[i][nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for nd in 0..qn { - denom += (log_node[nd] - mx).exp(); - } - ll += mx + denom.ln(); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - r[i][nd * n_cat + yc] += (log_node[nd] - mx).exp() / denom; - } - } - } - - loglik_trace.push(ll); - // Converge check before the M-step so the returned params match the trace endpoint. - it += 1; - if loglik_trace.len() > 1 { - let nn = loglik_trace.len(); - if (loglik_trace[nn - 1] - loglik_trace[nn - 2]).abs() - < tol * (1.0 + loglik_trace[nn - 2].abs()) - { - converged = true; - break; - } - } - - // CM-1: per-item Newton on delta_i (tau fixed), with a backtracking line search - // on the item objective so the step never lowers it (keeps ECM monotone). - // g = -sum_nd sum_k k*(r - n*P); h = -sum_nd n*Var_nd(score) < 0. - for i in 0..n_items { - for _ in 0..25 { - let (mut g, mut h) = (0.0f64, 0.0f64); - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, delta[i], &tau); - let mut n = 0.0f64; - for k in 0..n_cat { - n += r[i][nd * n_cat + k]; - } - // Validation guarantees an observed response for every item; - // posterior node weights are strictly positive, hence n > 0. - let (mut e1, mut e2) = (0.0f64, 0.0f64); - for k in 0..n_cat { - let pk = lp[k].exp(); - let kf = k as f64; - e1 += kf * pk; - e2 += kf * kf * pk; - g += -(kf) * (r[i][nd * n_cat + k] - n * pk); - } - h += -n * (e2 - e1 * e1); // -Var(score) - } - // With at least two categories and positive expected count, the - // score variance is positive and the Hessian is strictly negative. - let step = g / h; - let cur = item_ell(delta[i], &tau, &r[i], &nodes, n_cat); - let mut al = 1.0f64; - let mut accepted = false; - for _ in 0..24 { - let cand = delta[i] - al * step; - if item_ell(cand, &tau, &r[i], &nodes, n_cat) >= cur - 1e-12 { - delta[i] = cand; - accepted = true; - break; - } - al *= 0.5; - } - if !accepted || (al * step).abs() < 1e-9 { - break; - } - } - } - - // CM-2: joint Newton on the common tau (delta fixed), aggregated over items. - // g_m = -sum_i sum_nd sum_{k>=m} (r - n*P); Hessian by finite differences of g. - for _ in 0..25 { - let g = tau_gradient(&tau, &delta, &r, &nodes, n_items, n_cat); - let mut hess = vec![vec![0.0f64; kb]; kb]; - let eps = 1e-5; - for j in 0..kb { - let mut tp = tau.clone(); - tp[j] += eps; - let gj = tau_gradient(&tp, &delta, &r, &nodes, n_items, n_cat); - for a in 0..kb { - hess[a][j] = (gj[a] - g[a]) / eps; - } - } - for a in 0..kb { - for b in 0..kb { - hess[a][b] = 0.5 * (hess[a][b] + hess[b][a]); - } - hess[a][a] -= 1e-8; // keep the maximizer's Hessian negative definite - } - let step = solve_small(hess, g.clone()); - // Backtracking on the aggregate objective so the shared-tau step is monotone. - let cur = total_ell(&delta, &tau, &r, &nodes, n_items, n_cat); - let mut al = 1.0f64; - let mut accepted = false; - let mut max_step = 0.0f64; - for _ in 0..24 { - let cand: Vec = (0..kb).map(|j| tau[j] - al * step[j]).collect(); - if total_ell(&delta, &cand, &r, &nodes, n_items, n_cat) >= cur - 1e-12 { - max_step = (0..kb).map(|j| (al * step[j]).abs()).fold(0.0, f64::max); - tau = cand; - accepted = true; - break; - } - al *= 0.5; - } - if !accepted || max_step < 1e-9 { - break; - } - } - - // Re-center tau (sum = 0), shifting the level into the item locations. The - // model P(X=k|theta) is invariant under tau_m -> tau_m - c, delta_i -> delta_i + c - // (then psi_k = k*theta - k*(delta+c) - (T_k - k*c) = k*theta - k*delta - T_k). - let c = tau.iter().sum::() / kb as f64; - for tm in tau.iter_mut() { - *tm -= c; - } - for di in delta.iter_mut() { - *di += c; - } - } - - // Final person EAP pass at the returned parameters; recompute the cell tables. - let mut item_lp = vec![vec![0.0f64; cell * n_cat]; n_items]; - for i in 0..n_items { - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, delta[i], &tau); - item_lp[i][nd * n_cat..(nd + 1) * n_cat].copy_from_slice(&lp); - } - } - let mut theta = vec![0.0f64; n_persons]; - let mut final_ll = 0.0f64; - let mut log_node = vec![0.0f64; qn]; - for p in 0..n_persons { - log_node[..qn].copy_from_slice(&log_w[..qn]); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let yc = y[p * n_items + i]; - for nd in 0..qn { - log_node[nd] += item_lp[i][nd * n_cat + yc]; - } - } - let mx = log_node.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for nd in 0..qn { - denom += (log_node[nd] - mx).exp(); - } - final_ll += mx + denom.ln(); - let mut m = 0.0f64; - for (nd, &node) in nodes.iter().enumerate() { - m += (log_node[nd] - mx).exp() / denom * node; - } - theta[p] = m; - } - // On convergence the trace endpoint already holds the loglik at the returned - // params (checked before the M-step); on a max-iter exit the last M-step moved - // them, so record the loglik of the parameters actually returned. - if !converged { - loglik_trace.push(final_ll); - } - - Ok(RsmResult { - item_location: delta, - thresholds: tau, - theta, - loglik_trace, - n_iter: it, - converged, - n_parameters: n_items + n_cat - 2, - }) -} - -/// Expected complete-data log-likelihood of one item over the nodes, -/// `sum_nd sum_k r[i][nd][k] * log P(k | theta_nd; delta, tau)` — the objective the -/// per-item `delta` and the common `tau` conditional-maximization steps ascend -/// (used by their backtracking line searches to guarantee EM monotonicity). -fn item_ell(delta: f64, tau: &[f64], r_i: &[f64], nodes: &[f64], n_cat: usize) -> f64 { - let mut acc = 0.0f64; - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, delta, tau); - for k in 0..n_cat { - let rc = r_i[nd * n_cat + k]; - if rc != 0.0 { - acc += rc * lp[k]; - } - } - } - acc -} - -/// Total expected complete-data item log-likelihood over all items (for the shared -/// `tau` line search). -fn total_ell( - delta: &[f64], - tau: &[f64], - r: &[Vec], - nodes: &[f64], - n_items: usize, - n_cat: usize, -) -> f64 { - (0..n_items) - .map(|i| item_ell(delta[i], tau, &r[i], nodes, n_cat)) - .sum() -} - -/// Gradient of the expected complete-data objective w.r.t. the common thresholds: -/// `g_m = -sum_i sum_nd sum_{k>=m+1} (r - n*P)` (0-indexed `m` for `tau_{m+1}`). -fn tau_gradient( - tau: &[f64], - delta: &[f64], - r: &[Vec], - nodes: &[f64], - n_items: usize, - n_cat: usize, -) -> Vec { - let kb = tau.len(); - let mut g = vec![0.0f64; kb]; - for i in 0..n_items { - for (nd, &theta) in nodes.iter().enumerate() { - let lp = rsm_logprobs(theta, delta[i], tau); - let mut n = 0.0f64; - for k in 0..n_cat { - n += r[i][nd * n_cat + k]; - } - if n <= 0.0 { - continue; - } - // resid[k] = r[k] - n*P[k]; g_{tau_{m}} = -sum_{k>=m} resid[k], m = 1..K-1. - // Accumulate the suffix sum of residuals. - let mut suffix = 0.0f64; - for k in (1..n_cat).rev() { - suffix += r[i][nd * n_cat + k] - n * lp[k].exp(); - g[k - 1] += -suffix; // tau_k corresponds to g index k-1 - } - } - } - g -} - -#[cfg(test)] -#[path = "../../../tests/unit/rsm_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/rt.rs b/crates/mlsirm-core/src/rt.rs deleted file mode 100644 index c06682137..000000000 --- a/crates/mlsirm-core/src/rt.rs +++ /dev/null @@ -1,487 +0,0 @@ -//! Lognormal response-time (RT) measurement model (van der Linden, 2007): the -//! speed-side analogue of the 2PL for item response *times*. For person `j` with -//! latent speed `tau_j` and item `i` with time intensity `beta_i` and time -//! discrimination `alpha_i > 0`, -//! -//! ```text -//! ln(T_ij) | tau_j ~ Normal( beta_i - tau_j, 1 / alpha_i^2 ) -//! ``` -//! -//! i.e. the log response time is normal with mean `beta_i - tau_j` and standard -//! deviation `1/alpha_i` (higher speed => shorter time; higher `alpha` => sharper -//! timing). Item parameters and the speed distribution are estimated by marginal -//! maximum likelihood with `tau_j ~ Normal(mu_tau, sigma_tau^2)` marginalized out, -//! and speed is scored by EAP. -//! -//! Because the model is *conditionally Gaussian with a unit loading on `tau`*, the -//! speed posterior, the marginal likelihood, and the EAP are all available in -//! exact closed form (matrix-determinant / Sherman-Morrison), so the estimator -//! needs neither quadrature nor a line search — the EM is exact coordinate ascent. -//! -//! Identification: the log-time metric fixes the speed *scale* (`alpha_i` -//! multiplies the residual, not `beta_i - tau_j`, so there is no `alpha`↔`sigma_tau` -//! trade-off), leaving only the speed *location* free. The estimator pins the -//! population `mu_tau = 0` and estimates `sigma_tau` directly from the -//! between-person, same-person cross-item log-time covariance. -//! -//! # References (APA 7th ed.) -//! -//! van der Linden, W. J. (2007). A hierarchical framework for modeling speed and -//! accuracy on test items. *Psychometrika, 72*(3), 287–308. -//! https://doi.org/10.1007/s11336-006-1478-z - -/// Estimation controls for [`fit_rt_lognormal`]. -#[derive(Clone, Copy, Debug)] -pub struct RtConfig { - pub max_iter: usize, - pub tol: f64, - /// Minimum residual variance `1/alpha_i^2` (bounds `alpha_i` away from `inf`). - pub var_floor: f64, - /// Minimum `sigma_tau^2`. - pub sigma_floor: f64, - /// `None` estimates `sigma_tau` (default, faithful identification with - /// `mu_tau = 0`); `Some(s)` holds `sigma_tau = s` fixed (a genuine restriction — - /// it forces every same-person inter-item log-time covariance to `s^2` — not a - /// harmless normalization; use only for a deliberately standardized metric). - pub fix_sigma_tau: Option, -} - -impl Default for RtConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - var_floor: 1e-4, - sigma_floor: 1e-4, - fix_sigma_tau: None, - } - } -} - -/// Fitted lognormal RT model. -#[derive(Clone, Debug)] -pub struct RtFit { - /// Time discriminations `alpha_i > 0` (length `n_items`). - pub alpha: Vec, - /// Time intensities `beta_i` (length `n_items`). - pub beta: Vec, - /// Pinned to 0 (the identification constraint). - pub mu_tau: f64, - /// Estimated speed SD. - pub sigma_tau: f64, - /// EAP speed `tau_hat_j` (length `n_persons`). - pub tau_eap: Vec, - /// Posterior SD of the speed EAP. - pub tau_sd: Vec, - pub loglik: f64, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub final_loglik_change: f64, -} - -/// Fit the lognormal RT measurement model by marginal-ML EM (van der Linden, -/// 2007). `times` is `n_persons * n_items` row-major raw response times (`> 0` -/// where observed); `observed` is an optional missingness mask of the same length -/// (`None` = fully observed). Returns item `alpha`/`beta`, the estimated -/// `sigma_tau`, and per-person EAP speed. -pub fn fit_rt_lognormal( - times: &[f64], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - config: RtConfig, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be positive".into()); - } - if times.len() != n_persons * n_items { - return Err("times must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_persons * n_items { - return Err("observed must have length n_persons * n_items".into()); - } - } - if config.max_iter == 0 { - return Err("max_iter must be positive".into()); - } - if !(config.tol.is_finite() && config.tol > 0.0) { - return Err("tol must be positive and finite".into()); - } - if !(config.var_floor.is_finite() && config.var_floor > 0.0) { - return Err("var_floor must be positive and finite".into()); - } - if !(config.sigma_floor.is_finite() && config.sigma_floor > 0.0) { - return Err("sigma_floor must be positive and finite".into()); - } - if let Some(s) = config.fix_sigma_tau { - if !(s.is_finite() && s > 0.0) { - return Err("fix_sigma_tau must be positive and finite".into()); - } - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - - // log-times where observed - let mut y = vec![0.0_f64; n_persons * n_items]; - for p in 0..n_persons { - for i in 0..n_items { - if is_obs(p, i) { - let t = times[p * n_items + i]; - if !t.is_finite() || t <= 0.0 { - return Err("response times must be finite and positive where observed".into()); - } - y[p * n_items + i] = t.ln(); - } - } - } - let mut n_i = vec![0usize; n_items]; - for p in 0..n_persons { - for i in 0..n_items { - if is_obs(p, i) { - n_i[i] += 1; - } - } - } - if n_i.iter().any(|&c| c == 0) { - return Err("every item must be observed by at least one person".into()); - } - - // init: method-of-moments beta (E[y]=beta since mu_tau=0), flat alpha/sigma_tau - let mut beta = vec![0.0_f64; n_items]; - for i in 0..n_items { - let mut s = 0.0; - for p in 0..n_persons { - if is_obs(p, i) { - s += y[p * n_items + i]; - } - } - beta[i] = s / n_i[i] as f64; - } - let mut alpha = vec![1.0_f64; n_items]; - let mut sigma_tau2 = match config.fix_sigma_tau { - Some(s) => s * s, - None => 0.09, // 0.3^2, a benign start - }; - let mut tau_eap = vec![0.0_f64; n_persons]; - let mut tau_sd = vec![0.0_f64; n_persons]; - let mut v_all = vec![0.0_f64; n_persons]; - let mut s_all = vec![0.0_f64; n_persons]; - let mut trace: Vec = Vec::new(); - let ln2pi = (2.0 * std::f64::consts::PI).ln(); - let mut converged = false; - let mut n_iter = 0usize; - - for it in 0..config.max_iter { - n_iter = it + 1; - let a: Vec = alpha.iter().map(|&al| al * al).collect(); - let lna: Vec = a.iter().map(|&ai| ai.ln()).collect(); - // E-step (exact Gaussian posterior) + marginal log-likelihood - let mut loglik = 0.0_f64; - for p in 0..n_persons { - let (mut a_sum, mut num, mut ar2, mut ld, mut nj) = (0.0, 0.0, 0.0, 0.0, 0usize); - for i in 0..n_items { - if is_obs(p, i) { - let r = y[p * n_items + i] - beta[i]; - a_sum += a[i]; - num += a[i] * (-r); - ar2 += a[i] * r * r; - ld += lna[i]; - nj += 1; - } - } - let pj = 1.0 / sigma_tau2 + a_sum; - let te = num / pj; - let vj = 1.0 / pj; - tau_eap[p] = te; - v_all[p] = vj; - s_all[p] = te * te + vj; - loglik += - -0.5 * (nj as f64 * ln2pi - ld + sigma_tau2.ln() + pj.ln() + ar2 - pj * te * te); - } - // Validation bounds every observed log-time to the finite f64 log - // domain; the variance floors keep this Gaussian likelihood finite. - trace.push(loglik); - - // Stop at the likelihood state that is actually returned. Checking after - // the M-step would return parameters one update beyond the state whose - // likelihood change met `tol`. - if it > 0 && (trace[it] - trace[it - 1]).abs() < config.tol { - converged = true; - break; - } - - // M-step (closed form): beta, then alpha with fresh beta, then sigma_tau - for i in 0..n_items { - let mut s = 0.0; - for p in 0..n_persons { - if is_obs(p, i) { - s += y[p * n_items + i] + tau_eap[p]; - } - } - beta[i] = s / n_i[i] as f64; - } - for i in 0..n_items { - let mut ss = 0.0; - for p in 0..n_persons { - if is_obs(p, i) { - let e = y[p * n_items + i] - beta[i] + tau_eap[p]; - // + v_all[p] is the EM posterior-variance correction; dropping - // it biases alpha high - ss += e * e + v_all[p]; - } - } - let resvar = (ss / n_i[i] as f64).max(config.var_floor); - alpha[i] = 1.0 / resvar.sqrt(); - } - if config.fix_sigma_tau.is_none() { - let mean_s: f64 = s_all.iter().sum::() / n_persons as f64; - sigma_tau2 = mean_s.max(config.sigma_floor); - } - } - - // final EAP + log-likelihood at the converged parameters - let a: Vec = alpha.iter().map(|&al| al * al).collect(); - let lna: Vec = a.iter().map(|&ai| ai.ln()).collect(); - let mut final_ll = 0.0_f64; - for p in 0..n_persons { - let (mut a_sum, mut num, mut ar2, mut ld, mut nj) = (0.0, 0.0, 0.0, 0.0, 0usize); - for i in 0..n_items { - if is_obs(p, i) { - let r = y[p * n_items + i] - beta[i]; - a_sum += a[i]; - num += a[i] * (-r); - ar2 += a[i] * r * r; - ld += lna[i]; - nj += 1; - } - } - let pj = 1.0 / sigma_tau2 + a_sum; - let te = num / pj; - tau_eap[p] = te; - tau_sd[p] = (1.0 / pj).sqrt(); - final_ll += - -0.5 * (nj as f64 * ln2pi - ld + sigma_tau2.ln() + pj.ln() + ar2 - pj * te * te); - } - if converged { - // The loop broke before the M-step, so this recomputation is the same - // parameter state. Replace the endpoint instead of duplicating it. - *trace.last_mut().expect("a converged fit has a likelihood") = final_ll; - } else { - // At max_iter the final M-step has not yet been evaluated in the trace. - trace.push(final_ll); - } - let final_loglik_change = trace - .windows(2) - .last() - .map_or(f64::INFINITY, |w| (w[1] - w[0]).abs()); - let termination_reason = if converged { - "converged" - } else { - "max_iter_reached" - }; - - Ok(RtFit { - alpha, - beta, - mu_tau: 0.0, - sigma_tau: sigma_tau2.sqrt(), - tau_eap, - tau_sd, - loglik: final_ll, - loglik_trace: trace, - n_iter, - converged, - termination_reason: termination_reason.to_string(), - final_loglik_change, - }) -} - -/// Per-person response-time person-fit result ([`rt_person_fit`]). -pub struct RtPersonFit { - /// `W_j = sum_i z_hat_ij^2`, distributed `chi2(n_j - 1)` under the model - /// (`NaN` for a person with fewer than 2 observed times). - pub w: Vec, - /// Degrees of freedom `n_j - 1`. - pub df: Vec, - /// Wilson-Hilferty standardization of `W` (`~ N(0,1)`; positive = aberrant). - /// The field name is retained for API compatibility; it is not a separate - /// literature statistic named `l_t`. - pub l_t: Vec, - /// Upper-tail p-value `P(chi2_{df} >= W)`. - pub p_value: Vec, - /// `p_value < alpha_level`. - pub flagged: Vec, - /// The per-person ML-profiled speed used (differs from an EAP speed). - pub tau_ml: Vec, - /// `n_persons * n_items` studentized log-time residuals (`~ N(0,1)` marginally; - /// `NaN` where unobserved). A strongly negative value is a too-fast response. - pub z_resid: Vec, - /// `n_persons * n_items` one-sided too-fast flags (`z_resid < -z_fast`). - pub item_flag: Vec, -} - -/// Sinharay's (2018) frequentist response-time person-fit statistic under a fitted -/// lognormal RT model. For each person the speed is profiled by per-person ML, so -/// the sum of squared standardized log-time -/// residuals `W_j = sum_i [alpha_i (ln T_ij - (beta_i - tau_hat_j))]^2` is -/// *exactly* `chi2(n_j - 1)` under the model — an orthogonal-projection identity, -/// not an asymptotic approximation, so the estimated-speed correction is a clean -/// loss of one degree of freedom (the RT analogue of `l_z*`). Detects speed -/// *inconsistency across items* — rapid guessing (a cluster of implausibly fast -/// responses) or item preknowledge (fast responses concentrated on hard items) — -/// which appear as strongly negative residuals; a uniformly fast-but-consistent -/// responder is correctly *not* flagged because the profile absorbs the speed -/// level. `alpha`/`beta` come from a fitted [`RtFit`]; `alpha_level` flags the -/// aggregate `W`, `z_fast` the per-item one-sided too-fast residual. -/// -/// The per-item studentized ML residuals are a fixed-bank diagnostic provided by -/// this crate. Van der Linden and Guo (2008) motivate the interpretation of -/// unusually fast item responses, but their Bayesian leave-one-out procedure is -/// not the statistic implemented here. -/// Inputs whose squared time discriminations or profiled residual arithmetic are -/// non-finite are rejected rather than returned as undefined diagnostics. -/// -/// # References (APA 7th ed.) -/// -/// van der Linden, W. J., & Guo, F. (2008). Bayesian procedures for identifying -/// aberrant response-time patterns in adaptive testing. *Psychometrika, 73*(3), -/// 365–384. https://doi.org/10.1007/s11336-007-9046-8 -/// -/// Sinharay, S. (2018). A new person-fit statistic for the lognormal model for -/// response times. *Journal of Educational Measurement, 55*(4), 457–476. -/// https://doi.org/10.1111/jedm.12188 -#[allow(clippy::too_many_arguments)] -pub fn rt_person_fit( - times: &[f64], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - alpha: &[f64], - beta: &[f64], - alpha_level: f64, - z_fast: f64, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be positive".into()); - } - let n_cells = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows usize".to_string())?; - if times.len() != n_cells { - return Err("times must have length n_persons * n_items".into()); - } - if alpha.len() != n_items || beta.len() != n_items { - return Err("alpha and beta must have length n_items".into()); - } - if let Some(o) = observed { - if o.len() != n_cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - if alpha.iter().any(|a| { - let a2 = *a * *a; - !a.is_finite() || *a <= 0.0 || !a2.is_finite() || a2 <= 0.0 - }) { - return Err("alpha values must have finite positive squares".into()); - } - if beta.iter().any(|b| !b.is_finite()) { - return Err("beta values must be finite".into()); - } - if !(0.0 < alpha_level && alpha_level < 1.0) { - return Err("alpha_level must be in (0, 1)".into()); - } - if !z_fast.is_finite() || z_fast < 0.0 { - return Err("z_fast must be finite and non-negative".into()); - } - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - - let mut w = vec![f64::NAN; n_persons]; - let mut df = vec![0usize; n_persons]; - let mut l_t = vec![f64::NAN; n_persons]; - let mut p_value = vec![f64::NAN; n_persons]; - let mut flagged = vec![false; n_persons]; - let mut tau_ml = vec![f64::NAN; n_persons]; - let mut z_resid = vec![f64::NAN; n_persons * n_items]; - let mut item_flag = vec![false; n_persons * n_items]; - - for p in 0..n_persons { - // pass 1: profiled speed tau_hat = sum a_i(beta_i - y_i) / sum a_i - let (mut num, mut s, mut nj) = (0.0_f64, 0.0_f64, 0usize); - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let t = times[p * n_items + i]; - if !t.is_finite() || t <= 0.0 { - return Err("response times must be finite and positive where observed".into()); - } - let a2 = alpha[i] * alpha[i]; - let contribution = a2 * (beta[i] - t.ln()); - if !contribution.is_finite() { - return Err("non-finite response-time profile contribution".into()); - } - num += contribution; - s += a2; - if !num.is_finite() || !s.is_finite() { - return Err("non-finite response-time profile accumulation".into()); - } - nj += 1; - } - if nj < 2 || s <= 0.0 { - continue; // undefined; leave NaN/unflagged - } - let tau_hat = num / s; - // `num` and the strictly positive `s` were checked finite above, so - // their weighted-average ratio is finite as well. - tau_ml[p] = tau_hat; - // pass 2: residuals + statistics - let mut wj = 0.0_f64; - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - let y = times[p * n_items + i].ln(); - let zhat = alpha[i] * (y - beta[i] + tau_hat); - let z2 = zhat * zhat; - if !zhat.is_finite() || !z2.is_finite() { - return Err("non-finite response-time residual".into()); - } - wj += z2; - if !wj.is_finite() { - return Err("non-finite response-time person-fit statistic".into()); - } - let h = alpha[i] * alpha[i] / s; // leverage - let iz = zhat / (1.0 - h).max(1e-12).sqrt(); - // `h` is a finite squared-loading share in [0, 1], while finite - // `z2` above bounds `zhat`; the floored denominator keeps `iz` - // finite. - z_resid[p * n_items + i] = iz; - item_flag[p * n_items + i] = iz < -z_fast; - } - let dj = nj - 1; - w[p] = wj; - df[p] = dj; - p_value[p] = crate::fitstats::chi2_sf(wj, dj as f64); - flagged[p] = p_value[p] < alpha_level; - // Wilson-Hilferty - let d = 2.0 / (9.0 * dj as f64); - l_t[p] = ((wj / dj as f64).cbrt() - (1.0 - d)) / d.sqrt(); - } - - Ok(RtPersonFit { - w, - df, - l_t, - p_value, - flagged, - tau_ml, - z_resid, - item_flag, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/rt_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/rt_joint.rs b/crates/mlsirm-core/src/rt_joint.rs deleted file mode 100644 index 5e16780af..000000000 --- a/crates/mlsirm-core/src/rt_joint.rs +++ /dev/null @@ -1,470 +0,0 @@ -//! Joint speed-accuracy hierarchical model using the person-level covariance -//! structure of van der Linden (2007): a bivariate-normal distribution that ties -//! ability `theta` (from an accuracy 2PL model) to speed `tau` (from the lognormal -//! response-time model), -//! -//! ```text -//! (theta_j, tau_j) ~ Normal2( 0, [[1, rho*sigma_tau], [rho*sigma_tau, sigma_tau^2]] ) -//! ``` -//! -//! with the accuracy responses and the log response times conditionally -//! independent given `(theta, tau)`. The headline quantity is `rho`, the -//! ability-speed correlation. -//! -//! The original article illustrates the framework with a normal-ogive response -//! model and Bayesian MCMC. This crate instead provides a repository-specific -//! *two-stage* (limited-information) marginal-ML adaptation: the item parameters -//! of both measurement models are held fixed after separate calibration, the -//! accuracy side is logistic, and only `(rho, sigma_tau)` is estimated over a 2-D -//! Gauss-Hermite grid. This estimator and its closed-form covariance M-step must -//! therefore not be attributed to the original article. -//! -//! Note the `rho` estimated here is *not* the attenuated correlation of the two -//! separately-scored EAPs — those are biased toward zero by EAP shrinkage — but -//! the consistent marginal-ML person-covariance. -//! -//! # References (APA 7th ed.) -//! -//! van der Linden, W. J. (2007). A hierarchical framework for modeling speed and -//! accuracy on test items. *Psychometrika, 72*(3), 287–308. -//! https://doi.org/10.1007/s11336-006-1478-z - -use crate::quadrature::gh_rule; - -/// Upper bound on caller-controlled EM iterations, shared with the other fit APIs. -const RT_JOINT_MAX_ITER: usize = 100_000; - -#[inline] -fn log_sigmoid(x: f64) -> f64 { - if x >= 0.0 { - -(-x).exp().ln_1p() - } else { - x - x.exp().ln_1p() - } -} - -#[inline] -fn covariance_q(c: f64, s11: f64, s12: f64, s22: f64, sigma_tau2: f64) -> f64 { - let det = sigma_tau2 - c * c; - if !(det.is_finite() && det > 0.0) { - return f64::NEG_INFINITY; - } - -0.5 * (det.ln() + (sigma_tau2 * s11 - 2.0 * c * s12 + s22) / det) -} - -fn cubic_real_roots(qa: f64, qb: f64, qc: f64) -> Vec { - let p = qb - qa * qa / 3.0; - let q = 2.0 * qa * qa * qa / 27.0 - qa * qb / 3.0 + qc; - let discriminant = (q * 0.5).powi(2) + (p / 3.0).powi(3); - let shift = -qa / 3.0; - let scale = (q * q).abs() + (p * p * p).abs() + 1.0; - let disc_tol = 64.0 * f64::EPSILON * scale; - - if discriminant > disc_tol { - vec![ - (-0.5 * q + discriminant.sqrt()).cbrt() - + (-0.5 * q - discriminant.sqrt()).cbrt() - + shift, - ] - } else if discriminant >= -disc_tol { - let u = (-0.5 * q).cbrt(); - vec![2.0 * u + shift, -u + shift] - } else { - let radius = 2.0 * (-p / 3.0).sqrt(); - let cos_arg = (-0.5 * q / (-(p / 3.0).powi(3)).sqrt()).clamp(-1.0, 1.0); - let phi = cos_arg.acos(); - (0..3) - .map(|k| radius * ((phi + 2.0 * std::f64::consts::PI * k as f64) / 3.0).cos() + shift) - .collect() - } -} - -/// Maximize the covariance part of the expected complete-data log-likelihood -/// when `Var(theta) = 1` and `Var(tau) = sigma_tau2` are fixed. The score -/// equation is cubic in `c = Cov(theta, tau)`: -/// -/// `c^3 - s12*c^2 + (sigma_tau2*(s11 - 1) + s22)*c - s12*sigma_tau2 = 0`. -/// -/// Evaluate every real stationary point plus the positive-definiteness bounds -/// so the fixed-variance branch remains a genuine EM M-step. -fn maximize_fixed_variance_covariance( - s11: f64, - s12: f64, - s22: f64, - sigma_tau2: f64, - rho_limit: f64, -) -> f64 { - let bound = rho_limit * sigma_tau2.sqrt(); - let qa = -s12; - let qb = sigma_tau2 * (s11 - 1.0) + s22; - let qc = -s12 * sigma_tau2; - let mut candidates = vec![-bound, bound, 0.0]; - candidates.extend(cubic_real_roots(qa, qb, qc)); - - let mut best_c = 0.0; - let mut best_q = covariance_q(best_c, s11, s12, s22, sigma_tau2); - for candidate in candidates { - if candidate.is_finite() && candidate >= -bound && candidate <= bound { - let value = covariance_q(candidate, s11, s12, s22, sigma_tau2); - if value > best_q { - best_q = value; - best_c = candidate; - } - } - } - best_c -} - -fn joint_summary_is_finite(final_ll: f64, acc11: f64, theta_eap: &[f64], tau_eap: &[f64]) -> bool { - final_ll.is_finite() - && acc11.is_finite() - && theta_eap - .iter() - .chain(tau_eap) - .all(|value| value.is_finite()) -} - -fn ensure_joint_summary_is_finite( - final_ll: f64, - acc11: f64, - theta_eap: &[f64], - tau_eap: &[f64], -) -> Result<(), String> { - if joint_summary_is_finite(final_ll, acc11, theta_eap, tau_eap) { - Ok(()) - } else { - Err("joint speed-accuracy final likelihood or EAPs became non-finite".into()) - } -} - -/// Controls for [`fit_speed_accuracy_covariance`]. -#[derive(Clone, Copy, Debug)] -pub struct SpeedAccuracyConfig { - /// Gauss-Hermite nodes per dimension (in `{7, 11, 15, 21, 31, 41}`). - pub q: usize, - pub max_iter: usize, - pub tol: f64, - /// `|rho|` clamp (positive-definiteness guard on `Sigma_P`). - pub rho_floor: f64, - pub sigma_floor: f64, - /// `Some(s)` holds `sigma_tau = s` fixed (e.g. at the stage-1 value), leaving - /// only `rho` free. - pub fix_sigma_tau: Option, -} - -impl Default for SpeedAccuracyConfig { - fn default() -> Self { - Self { - q: 21, - max_iter: 500, - tol: 1e-6, - rho_floor: 0.999, - sigma_floor: 1e-4, - fix_sigma_tau: None, - } - } -} - -/// Result of [`fit_speed_accuracy_covariance`]. -#[derive(Clone, Debug)] -pub struct SpeedAccuracyFit { - /// Ability-speed correlation (the headline output). - pub rho: f64, - pub sigma_tau: f64, - /// Posterior second moment of `theta` (`S11`); a diagnostic — `~1` when the - /// accuracy and RT calibrations share a metric. Reported, never re-estimated. - pub s_theta2: f64, - pub loglik: f64, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - pub termination_reason: String, - pub final_loglik_change: f64, - /// Joint-posterior EAP ability / speed (borrow strength through `rho`). - pub theta_eap: Vec, - pub tau_eap: Vec, -} - -/// Estimate a two-stage marginal-ML adaptation of the van der Linden (2007) -/// person covariance -/// `Sigma_P = [[1, rho*sigma_tau], [rho*sigma_tau, sigma_tau^2]]` by two-stage -/// marginal ML, holding the item parameters fixed. `responses` (0/1) and `times` -/// (`> 0` where observed) are `n_persons * n_items` row-major; `observed` masks -/// both (`None` = fully observed). `a`/`b` are the accuracy 2PL raw slope / -/// intercept (`eta = a_i*theta + b_i`); `alpha`/`beta` are the lognormal time -/// discrimination / intensity. At least one paired observation and one observed -/// item with non-zero accuracy discrimination are required to identify `rho`. -#[allow(clippy::too_many_arguments)] -pub fn fit_speed_accuracy_covariance( - responses: &[f64], - times: &[f64], - observed: Option<&[bool]>, - a: &[f64], - b: &[f64], - alpha: &[f64], - beta: &[f64], - n_persons: usize, - n_items: usize, - config: SpeedAccuracyConfig, -) -> Result { - if n_persons == 0 || n_items == 0 { - return Err("n_persons and n_items must be positive".into()); - } - let expected = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows".to_string())?; - if responses.len() != expected || times.len() != expected { - return Err("responses and times must have length n_persons * n_items".into()); - } - if a.len() != n_items || b.len() != n_items || alpha.len() != n_items || beta.len() != n_items { - return Err("item-parameter vectors must have length n_items".into()); - } - if let Some(o) = observed { - if o.len() != expected { - return Err("observed must have length n_persons * n_items".into()); - } - } - if !(1..=RT_JOINT_MAX_ITER).contains(&config.max_iter) { - return Err(format!("max_iter must be in 1..={RT_JOINT_MAX_ITER}")); - } - if !(config.tol.is_finite() && config.tol > 0.0) { - return Err("tol must be positive and finite".into()); - } - if !(config.rho_floor.is_finite() && config.rho_floor > 0.0 && config.rho_floor < 1.0) { - return Err("rho_floor must be finite and strictly between 0 and 1".into()); - } - if !(config.sigma_floor.is_finite() && config.sigma_floor > 0.0) { - return Err("sigma_floor must be positive and finite".into()); - } - if let Some(s) = config.fix_sigma_tau { - if !(s.is_finite() && s > 0.0 && (s * s).is_finite()) { - return Err("fix_sigma_tau must be positive and finite".into()); - } - } - if a.iter().chain(b).chain(beta).any(|x| !x.is_finite()) { - return Err("a, b, and beta must contain only finite values".into()); - } - if alpha - .iter() - .any(|x| !x.is_finite() || *x <= 0.0 || !(*x * *x).is_finite()) - { - return Err( - "alpha must be positive with finite squares; otherwise the joint likelihood is non-finite".into(), - ); - } - let (nodes, weights) = - gh_rule(config.q).ok_or_else(|| format!("unsupported q {}", config.q))?; - let q = nodes.len(); - let lnw: Vec = weights.iter().map(|w| w.ln()).collect(); - let is_obs = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - let ln2pi = (2.0 * std::f64::consts::PI).ln(); - - // precompute per-person accuracy log-lik at each theta node (theta = z_a is - // independent of Sigma_P, so this is one-time) and the RT sufficient stats. - let mut la = vec![0.0_f64; n_persons * q]; - let mut aj = vec![0.0_f64; n_persons]; - let mut bj = vec![0.0_f64; n_persons]; - let mut cj = vec![0.0_f64; n_persons]; - let mut kj = vec![0.0_f64; n_persons]; - let mut n_observed = 0usize; - let mut n_accuracy_informative = 0usize; - for p in 0..n_persons { - for i in 0..n_items { - if !is_obs(p, i) { - continue; - } - n_observed += 1; - if a[i] != 0.0 { - n_accuracy_informative += 1; - } - let u = responses[p * n_items + i]; - if u != 0.0 && u != 1.0 { - return Err("responses must be 0 or 1 where observed".into()); - } - for (ai, &z) in nodes.iter().enumerate() { - let eta = a[i] * z + b[i]; - la[p * q + ai] += if u > 0.5 { - log_sigmoid(eta) - } else { - log_sigmoid(-eta) - }; - } - let t = times[p * n_items + i]; - if !t.is_finite() || t <= 0.0 { - return Err("response times must be finite and positive where observed".into()); - } - let y = t.ln(); - let a2 = alpha[i] * alpha[i]; - let d = y - beta[i]; - aj[p] += a2; - bj[p] += a2 * d; - cj[p] += a2 * d * d; - kj[p] += alpha[i].ln() - 0.5 * ln2pi; - } - } - if n_observed == 0 { - return Err("at least one response-time pair must be observed".into()); - } - if n_accuracy_informative == 0 { - return Err( - "at least one observed response must have non-zero accuracy discrimination".into(), - ); - } - - let mut sigma_tau2 = match config.fix_sigma_tau { - Some(s) => s * s, - None => 0.09, // 0.3^2 warm start - }; - let mut c = 0.0_f64; // covariance rho*sigma_tau; warm start rho = 0 - let pd_eps = 1e-12_f64; - let mut trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - let mut lj = vec![0.0_f64; q * q]; - - for it in 0..config.max_iter { - n_iter = it + 1; - let l22 = (sigma_tau2 - c * c).max(pd_eps).sqrt(); - let (mut acc11, mut acc12, mut acc22) = (0.0_f64, 0.0, 0.0); - let mut loglik = 0.0_f64; - for p in 0..n_persons { - // grid log-joint, tracking the max for a stable logsumexp - let mut mx = f64::NEG_INFINITY; - for ai in 0..q { - let base = lnw[ai] + la[p * q + ai]; - let za = nodes[ai]; - for bi in 0..q { - let tau = c * za + l22 * nodes[bi]; - let lt = kj[p] - 0.5 * (aj[p] * tau * tau + 2.0 * bj[p] * tau + cj[p]); - let val = base + lnw[bi] + lt; - lj[ai * q + bi] = val; - if val > mx { - mx = val; - } - } - } - let mut denom = 0.0_f64; - for &val in lj.iter() { - denom += (val - mx).exp(); - } - let logl_j = mx + denom.ln(); - loglik += logl_j; - for ai in 0..q { - let za = nodes[ai]; - for bi in 0..q { - let tau = c * za + l22 * nodes[bi]; - let w = (lj[ai * q + bi] - logl_j).exp(); - acc11 += w * za * za; - acc12 += w * za * tau; - acc22 += w * tau * tau; - } - } - } - if !loglik.is_finite() || !acc11.is_finite() || !acc12.is_finite() || !acc22.is_finite() { - return Err( - "joint speed-accuracy likelihood or posterior moments became non-finite".into(), - ); - } - trace.push(loglik); - if it > 0 && (trace[it] - trace[it - 1]).abs() < config.tol { - converged = true; - break; - } - // M-step (exact constrained maximizer, sigma_theta^2 == 1) - let s11 = acc11 / n_persons as f64; - let s12 = acc12 / n_persons as f64; - let s22 = acc22 / n_persons as f64; - if let Some(s) = config.fix_sigma_tau { - sigma_tau2 = s * s; - c = maximize_fixed_variance_covariance(s11, s12, s22, sigma_tau2, config.rho_floor); - } else { - let c_new = s12 / s11; - let v_new = (s22 - s12 * s12 * (s11 - 1.0) / (s11 * s11)).max(config.sigma_floor); - sigma_tau2 = v_new; - let sig = v_new.sqrt(); - let rho = (c_new / sig).clamp(-config.rho_floor, config.rho_floor); - c = rho * sig; - } - // re-clamp covariance for positive definiteness under fixed sigma_tau too - let sig = sigma_tau2.sqrt(); - let rho = (c / sig).clamp(-config.rho_floor, config.rho_floor); - c = rho * sig; - } - - // final pass: EAPs + loglik at converged Sigma_P - let l22 = (sigma_tau2 - c * c).max(pd_eps).sqrt(); - let mut theta_eap = vec![0.0_f64; n_persons]; - let mut tau_eap = vec![0.0_f64; n_persons]; - let mut final_ll = 0.0_f64; - let mut acc11 = 0.0_f64; - for p in 0..n_persons { - let mut mx = f64::NEG_INFINITY; - for ai in 0..q { - let base = lnw[ai] + la[p * q + ai]; - let za = nodes[ai]; - for bi in 0..q { - let tau = c * za + l22 * nodes[bi]; - let lt = kj[p] - 0.5 * (aj[p] * tau * tau + 2.0 * bj[p] * tau + cj[p]); - let val = base + lnw[bi] + lt; - lj[ai * q + bi] = val; - if val > mx { - mx = val; - } - } - } - let mut denom = 0.0_f64; - for &val in lj.iter() { - denom += (val - mx).exp(); - } - let logl_j = mx + denom.ln(); - final_ll += logl_j; - let (mut te, mut ts) = (0.0_f64, 0.0_f64); - for ai in 0..q { - let za = nodes[ai]; - for bi in 0..q { - let tau = c * za + l22 * nodes[bi]; - let w = (lj[ai * q + bi] - logl_j).exp(); - te += w * za; - ts += w * tau; - acc11 += w * za * za; - } - } - theta_eap[p] = te; - tau_eap[p] = ts; - } - ensure_joint_summary_is_finite(final_ll, acc11, &theta_eap, &tau_eap)?; - if trace - .last() - .is_none_or(|last| last.to_bits() != final_ll.to_bits()) - { - trace.push(final_ll); - } - let final_loglik_change = trace - .windows(2) - .last() - .map_or(f64::INFINITY, |pair| (pair[1] - pair[0]).abs()); - let termination_reason = if converged { - "converged" - } else { - "max_iter_reached" - }; - let sigma_tau = sigma_tau2.sqrt(); - let rho = c / sigma_tau; - Ok(SpeedAccuracyFit { - rho, - sigma_tau, - s_theta2: acc11 / n_persons as f64, - loglik: final_ll, - loglik_trace: trace, - n_iter, - converged, - termination_reason: termination_reason.to_string(), - final_loglik_change, - theta_eap, - tau_eap, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/rt_joint_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/scoring.rs b/crates/mlsirm-core/src/scoring.rs deleted file mode 100644 index 7fec065cd..000000000 --- a/crates/mlsirm-core/src/scoring.rs +++ /dev/null @@ -1,2659 +0,0 @@ -//! Respondent scoring with frozen item parameters: EAP, MAP, and summed-score -//! EAP (EAPsum) tables via the Lord-Wingersky recursion. -//! -//! Sources (see docs/papers/mmle-lsirm-formula-compilation.md): Bock & Mislevy -//! (1982) EAP; standard MAP scoring with the posterior Newton step; Thissen, -//! Pommerich, Billeaud & Williams (1995) summed-score EAP with the -//! Lord & Wingersky (1984) recursion. -//! -//! Population priors are per-dimension `N(mean_d, sd_d^2)`, which covers all -//! three population structures of the marginal estimator: -//! - single: `mean = 0, sd = 1`; -//! - multigroup: the group's `(mu_gd, sigma_gd)`; -//! - multilevel: `N(sigma_u * u_hat_c, 1)` conditional on a known cluster, or -//! the marginal `N(0, sqrt(1 + sigma_u^2))` for an unknown cluster. - -use crate::marginal::{build_tables, index_responses, person_pass, Contexts, Grids}; -use crate::nodes::{build_xi_nodes, XiRule}; -use crate::poly::{gpcm_logprobs, grm_logprobs, PolyModel}; -use crate::quadrature::gh_rule; -use crate::{model_exec_flags, ModelConfig, ModelType}; - -/// Frozen item parameters plus the model contract they were calibrated under. -pub struct ItemBank<'a> { - pub alpha: &'a [f64], - pub b: &'a [f64], - /// Row-major `n_items x latent_dim`. - pub zeta: &'a [f64], - pub tau: f64, - pub factor_id: &'a [usize], - pub model_type: ModelType, - pub n_dims: usize, - pub latent_dim: usize, - pub eps_distance: f64, -} - -/// Per-dimension trait prior `N(mean_d, sd_d^2)`. -#[derive(Clone, Debug)] -pub struct PriorSpec { - pub mean: Vec, - pub sd: Vec, -} - -impl PriorSpec { - pub fn standard(n_dims: usize) -> Self { - Self { - mean: vec![0.0; n_dims], - sd: vec![1.0; n_dims], - } - } -} - -pub struct EapScores { - pub theta_eap: Vec, - pub theta_sd: Vec, - pub xi_eap: Vec, - pub loglik: Vec, -} - -pub struct MapScores { - pub theta_map: Vec, - pub theta_se: Vec, - pub xi_map: Vec, - pub log_posterior: Vec, - pub converged: Vec, -} - -/// Summed-score EAP conversion table for one trait dimension. -pub struct EapSumTable { - pub dim: usize, - /// Item count of the dimension; scores run 0..=n_items_dim. - pub n_items_dim: usize, - /// `P(score = s)` under the prior (model-implied score distribution). - pub score_prob: Vec, - /// `E[theta_d | score = s]`. - pub eap: Vec, - /// `SD[theta_d | score = s]`. - pub sd: Vec, -} - -/// Summed-score lookup results for complete dichotomous response vectors. -#[derive(Debug)] -pub struct EapSumScores { - pub theta_eap: Vec, - pub theta_sd: Vec, - pub n_observed: Vec, -} - -pub(crate) fn validate_bank(bank: &ItemBank<'_>) -> Result { - let n_items = bank.b.len(); - let expected_zeta = n_items - .checked_mul(bank.latent_dim) - .ok_or_else(|| "n_items * latent_dim overflows usize".to_string())?; - if bank.alpha.len() != n_items - || bank.factor_id.len() != n_items - || bank.zeta.len() != expected_zeta - { - return Err("item bank arrays have inconsistent lengths".into()); - } - if bank.factor_id.iter().any(|&d| d >= bank.n_dims) { - return Err("factor_id values must be in 0..n_dims-1".into()); - } - if bank.n_dims == 0 || bank.latent_dim == 0 { - return Err("parameter dimensions must be positive".into()); - } - if bank - .alpha - .iter() - .chain(bank.b) - .chain(bank.zeta) - .any(|v| !v.is_finite()) - || !bank.tau.is_finite() - { - return Err("item bank parameters must be finite".into()); - } - if !bank.eps_distance.is_finite() || bank.eps_distance <= 0.0 { - return Err("eps_distance must be positive and finite".into()); - } - Ok(n_items) -} - -pub(crate) fn validate_dichotomous_responses( - y: &[f64], - observed: &[bool], - n_persons: usize, - n_items: usize, -) -> Result<(), String> { - let n_cells = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows usize".to_string())?; - if y.len() != n_cells || observed.len() != n_cells { - return Err("y and observed must both have length n_persons * n_items".into()); - } - if y.iter() - .zip(observed) - .any(|(&value, &is_observed)| is_observed && value != 0.0 && value != 1.0) - { - return Err("observed responses must be 0 or 1".into()); - } - Ok(()) -} - -pub(crate) fn validate_prior(prior: &PriorSpec, n_dims: usize) -> Result<(), String> { - if prior.mean.len() != n_dims || prior.sd.len() != n_dims { - return Err("prior mean/sd must have one entry per trait dimension".into()); - } - if prior.sd.iter().any(|&s| !s.is_finite() || s <= 0.0) { - return Err("prior sds must be positive and finite".into()); - } - if prior.mean.iter().any(|&m| !m.is_finite()) { - return Err("prior means must be finite".into()); - } - Ok(()) -} - -fn scoring_grids(bank: &ItemBank<'_>, q_theta: usize, xi_rule: XiRule) -> Result { - let (_, uses_space) = model_exec_flags(bank.model_type); - let (t_nodes, t_weights) = - gh_rule(q_theta).ok_or_else(|| format!("unsupported quadrature size {q_theta}"))?; - let (x_grid, x_logw) = if uses_space { - let nodes = build_xi_nodes(xi_rule, bank.latent_dim)?; - (nodes.grid, nodes.logw) - } else { - (vec![0.0; bank.latent_dim], vec![0.0]) - }; - Ok(Grids { - t_nodes: t_nodes.to_vec(), - t_logw: t_weights.iter().map(|w| w.ln()).collect(), - n_x: x_logw.len(), - x_grid, - x_logw, - q_t: q_theta, - }) -} - -fn prior_contexts(prior: &PriorSpec) -> Contexts { - Contexts { - n_ctx: 1, - shift: prior.mean.clone(), - scale: prior.sd.clone(), - u_nodes: Vec::new(), - u_logw: Vec::new(), - } -} - -fn bank_model_config(bank: &ItemBank<'_>, n_persons: usize, n_items: usize) -> ModelConfig { - ModelConfig { - n_persons, - n_items, - n_dims: bank.n_dims, - latent_dim: bank.latent_dim, - model_type: bank.model_type, - eps_distance: bank.eps_distance, - } -} - -/// EAP scoring (Bock & Mislevy 1982) of `n_persons` response vectors against -/// the frozen bank, under a shared per-dimension prior. -/// -/// The default execution policy is [`crate::Device::Auto`]: prefer the wgpu -/// kernel when the crate is built with GPU support and an adapter is usable, -/// otherwise fall back to the f64 CPU reduction. Use [`score_eap_device`] with -/// [`crate::Device::Cpu`] when a hardware-independent f64 reference is needed. -pub fn score_eap( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, -) -> Result { - score_eap_device( - bank, - y, - observed, - n_persons, - prior, - q_theta, - xi_rule, - crate::Device::Auto, - ) -} - -/// EAP scoring with an explicit compute device. `Device::Cpu` keeps the exact -/// f64 reduction; `Device::Gpu`/`Auto` offloads to the wgpu `score_pass` kernel -/// (f32, ~1e-4) when an adapter is present, otherwise CPU. An explicit -/// `Device::Gpu` request emits a warning when it falls back. -#[allow(clippy::too_many_arguments)] -pub fn score_eap_device( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, - device: crate::Device, -) -> Result { - let n_items = validate_bank(bank)?; - validate_prior(prior, bank.n_dims)?; - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - let grids = scoring_grids(bank, q_theta, xi_rule)?; - let ctx = prior_contexts(prior); - let config = bank_model_config(bank, n_persons, n_items); - let tables = build_tables( - bank.alpha, - bank.b, - bank.zeta, - bank.tau, - &config, - bank.factor_id, - &ctx, - &grids, - ); - let resp = index_responses(y, observed, n_persons, n_items); - Ok(dispatch_eap_device( - bank, prior, &grids, &tables, &resp, n_persons, n_items, device, - )) -} - -/// The scalar f64 CPU EAP reduction (the parity reference for `score_eap_gpu`). -#[allow(clippy::too_many_arguments)] -fn score_eap_cpu_reduce( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_persons: usize, - n_items: usize, -) -> EapScores { - if n_persons == 0 { - return EapScores { - theta_eap: Vec::new(), - theta_sd: Vec::new(), - xi_eap: Vec::new(), - loglik: Vec::new(), - }; - } - let worker_count = std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(1) - .min(n_persons.max(1)); - let chunk_size = n_persons.div_ceil(worker_count); - let cell = grids.q_t * grids.n_x; - let dim_count = bank.n_dims; - let latent_dim = bank.latent_dim; - let chunks = std::thread::scope(|scope| { - let mut handles = Vec::with_capacity(worker_count); - for start in (0..n_persons).step_by(chunk_size) { - let end = (start + chunk_size).min(n_persons); - handles.push(scope.spawn(move || { - let mut l_buf = vec![0.0_f64; dim_count * cell]; - let mut log_zdx = vec![0.0_f64; dim_count * grids.n_x]; - let local_len = end - start; - let mut theta_eap = vec![0.0_f64; local_len * dim_count]; - let mut theta_sd = vec![0.0_f64; local_len * dim_count]; - let mut xi_eap = vec![0.0_f64; local_len * latent_dim]; - let mut loglik = vec![0.0_f64; local_len]; - for (local_p, p) in (start..end).enumerate() { - let lp = person_pass( - p, - 0, - tables, - resp, - bank.factor_id, - dim_count, - n_items, - grids, - &mut l_buf, - &mut log_zdx, - ); - loglik[local_p] = lp; - let mut theta_m2 = vec![0.0_f64; dim_count]; - for x in 0..grids.n_x { - let mut lx = grids.x_logw[x] - lp; - for d in 0..dim_count { - lx += log_zdx[d * grids.n_x + x]; - } - let px = lx.exp(); - for k in 0..latent_dim { - xi_eap[local_p * latent_dim + k] += - px * grids.x_grid[x * latent_dim + k]; - } - for d in 0..dim_count { - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = prior.mean[d] + prior.sd[d] * node_t; - let pt = (grids.t_logw[t] + l_buf[d * cell + t * grids.n_x + x] - - log_zdx[d * grids.n_x + x]) - .exp(); - theta_eap[local_p * dim_count + d] += px * pt * theta; - theta_m2[d] += px * pt * theta * theta; - } - } - } - for d in 0..dim_count { - let m = theta_eap[local_p * dim_count + d]; - theta_sd[local_p * dim_count + d] = (theta_m2[d] - m * m).max(0.0).sqrt(); - } - } - (start, end, theta_eap, theta_sd, xi_eap, loglik) - })); - } - handles - .into_iter() - .map(|handle| handle.join().expect("score_eap CPU worker panicked")) - .collect::>() - }); - let mut out = EapScores { - theta_eap: vec![0.0; n_persons * dim_count], - theta_sd: vec![0.0; n_persons * dim_count], - xi_eap: vec![0.0; n_persons * latent_dim], - loglik: vec![0.0; n_persons], - }; - for (start, end, theta_eap, theta_sd, xi_eap, loglik) in chunks { - let local_len = end - start; - out.theta_eap[start * dim_count..end * dim_count].copy_from_slice(&theta_eap); - out.theta_sd[start * dim_count..end * dim_count].copy_from_slice(&theta_sd); - out.xi_eap[start * latent_dim..end * latent_dim].copy_from_slice(&xi_eap); - out.loglik[start..end].copy_from_slice(&loglik[..local_len]); - } - out -} - -/// GPU EAP path (Bock-Mislevy on wgpu, f32) when a device is requested; falls back to the exact CPU -/// reduction when Cpu, no adapter, or the model exceeds the kernel bounds. -#[cfg(all(feature = "gpu", not(coverage)))] -#[allow(clippy::too_many_arguments)] -fn dispatch_eap_device( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_persons: usize, - n_items: usize, - device: crate::Device, -) -> EapScores { - if device != crate::Device::Cpu { - if let Some(gpu_out) = - try_score_eap_gpu(bank, prior, grids, tables, resp, n_persons, n_items) - { - return gpu_out; - } - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU scoring requested but no usable GPU adapter was found or the model exceeds GPU kernel bounds; falling back to the CPU implementation." - ); - } - } - score_eap_cpu_reduce(bank, prior, grids, tables, resp, n_persons, n_items) -} - -#[cfg(any(not(feature = "gpu"), coverage))] -#[allow(clippy::too_many_arguments)] -fn dispatch_eap_device( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_persons: usize, - n_items: usize, - device: crate::Device, -) -> EapScores { - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU scoring requested but this build has no GPU support; falling back to the CPU implementation." - ); - } - score_eap_cpu_reduce(bank, prior, grids, tables, resp, n_persons, n_items) -} - -/// Build the GPU score inputs (CSR-flattened responses) and dispatch the -/// `score_pass` kernel; `None` on no-adapter or out-of-bounds models. -#[cfg(all(feature = "gpu", not(coverage)))] -#[allow(clippy::too_many_arguments)] -fn try_score_eap_gpu( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_persons: usize, - n_items: usize, -) -> Option { - let mut pos_off: Vec = Vec::with_capacity(n_persons + 1); - let mut pos_items: Vec = Vec::new(); - pos_off.push(0); - for p in 0..n_persons { - for &i in &resp.pos[p] { - pos_items.push(i as u32); - } - pos_off.push(pos_items.len() as u32); - } - let mut miss_off: Vec = Vec::with_capacity(n_persons + 1); - let mut miss_items: Vec = Vec::new(); - miss_off.push(0); - for p in 0..n_persons { - for &i in &resp.miss[p] { - miss_items.push(i as u32); - } - miss_off.push(miss_items.len() as u32); - } - let inputs = crate::gpu_marginal::GpuScoreInputs { - n_persons, - n_items, - n_dims: bank.n_dims, - latent_dim: bank.latent_dim, - q_t: grids.q_t, - n_x: grids.n_x, - logp0: &tables.logp0, - logp1: &tables.logp1, - c0: &tables.c0, - t_logw: &grids.t_logw, - x_logw: &grids.x_logw, - t_nodes: &grids.t_nodes, - x_grid: &grids.x_grid, - prior_mean: &prior.mean, - prior_sd: &prior.sd, - factor_id: bank.factor_id, - pos_off: &pos_off, - pos_items: &pos_items, - miss_off: &miss_off, - miss_items: &miss_items, - }; - let out = crate::gpu_marginal::score_eap_gpu(&inputs)?; - Some(EapScores { - theta_eap: out.theta_eap, - theta_sd: out.theta_sd, - xi_eap: out.xi_eap, - loglik: out.loglik, - }) -} - -#[inline] -fn sigmoid(x: f64) -> f64 { - if x >= 0.0 { - 1.0 / (1.0 + (-x).exp()) - } else { - let ex = x.exp(); - ex / (1.0 + ex) - } -} - -#[inline] -fn log_sigmoid(x: f64) -> f64 { - if x >= 0.0 { - -(-x).exp().ln_1p() - } else { - x - x.exp().ln_1p() - } -} - -/// Solve the symmetric linear system `H x = g` in place (Gauss-Jordan with -/// partial pivoting); `H` is `n x n` row-major. Returns None when singular. -fn solve_sym(mut h: Vec, mut g: Vec, n: usize) -> Option> { - for col in 0..n { - let mut piv = col; - for r in (col + 1)..n { - if h[r * n + col].abs() > h[piv * n + col].abs() { - piv = r; - } - } - if h[piv * n + col].abs() < 1e-12 { - return None; - } - if piv != col { - for c in 0..n { - h.swap(col * n + c, piv * n + c); - } - g.swap(col, piv); - } - let d = h[col * n + col]; - for c in 0..n { - h[col * n + c] /= d; - } - g[col] /= d; - for r in 0..n { - if r != col { - let f = h[r * n + col]; - if f != 0.0 { - for c in 0..n { - h[r * n + c] -= f * h[col * n + c]; - } - g[r] -= f * g[col]; - } - } - } - } - Some(g) -} - -/// MAP scoring: damped Fisher scoring of the log posterior over -/// `(theta in R^D, xi in R^K)` per person, with standard errors from the -/// diagonal of the inverse observed information at the mode. -pub fn score_map( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - max_iter: usize, - tol: f64, -) -> Result { - let n_items = validate_bank(bank)?; - validate_prior(prior, bank.n_dims)?; - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - if max_iter == 0 { - return Err("max_iter must be positive".into()); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be positive and finite".into()); - } - let (free_alpha, uses_space) = model_exec_flags(bank.model_type); - let kind = crate::interaction_kind(bank.model_type); - let (n_dims, latent_dim) = (bank.n_dims, bank.latent_dim); - let n_par = n_dims + if uses_space { latent_dim } else { 0 }; - let gamma = if kind == crate::InteractionKind::Distance { - bank.tau.exp() - } else { - 0.0 - }; - - let mut out = MapScores { - theta_map: vec![0.0; n_persons * n_dims], - theta_se: vec![0.0; n_persons * n_dims], - xi_map: vec![0.0; n_persons * latent_dim], - log_posterior: vec![0.0; n_persons], - converged: vec![false; n_persons], - }; - - // log posterior and its gradient / observed information at (theta, xi) - let eval = |p: usize, - par: &[f64], - grad: Option<&mut Vec>, - info: Option<&mut Vec>, - observed_curvature: bool| - -> f64 { - let theta = &par[..n_dims]; - let xi = &par[n_dims..]; - let mut lp = 0.0; - let mut g = vec![0.0_f64; n_par]; - let mut h = vec![0.0_f64; n_par * n_par]; - for i in 0..n_items { - let idx = p * n_items + i; - if !observed[idx] { - continue; - } - let d = bank.factor_id[i]; - let a = if free_alpha { bank.alpha[i].exp() } else { 1.0 }; - let mut eta = a * theta[d] + bank.b[i]; - let mut dist = 1.0; - match kind { - crate::InteractionKind::None => {} - crate::InteractionKind::Distance => { - let mut dist2 = bank.eps_distance; - for k in 0..latent_dim { - let diff = xi[k] - bank.zeta[i * latent_dim + k]; - dist2 += diff * diff; - } - dist = dist2.sqrt(); - eta -= gamma * dist; - } - crate::InteractionKind::Inner => { - for k in 0..latent_dim { - eta += bank.zeta[i * latent_dim + k] * xi[k]; - } - } - } - let yy = y[idx]; - lp += yy * log_sigmoid(eta) + (1.0 - yy) * log_sigmoid(-eta); - let prob = sigmoid(eta); - let resid = yy - prob; - let w = prob * (1.0 - prob); - // d eta / d theta_d = a ; d eta / d xi_k = -gamma (xi_k - zeta_ik)/dist - g[d] += resid * a; - h[d * n_par + d] += w * a * a; - if uses_space { - for k in 0..latent_dim { - // model_exec_flags guarantees that a spatial model is either distance or - // inner-product; InteractionKind::None always has uses_space=false. - let u_k = if kind == crate::InteractionKind::Distance { - -gamma * (xi[k] - bank.zeta[i * latent_dim + k]) / dist - } else { - bank.zeta[i * latent_dim + k] - }; - g[n_dims + k] += resid * u_k; - h[d * n_par + n_dims + k] += w * a * u_k; - h[(n_dims + k) * n_par + d] += w * a * u_k; - for k2 in 0..latent_dim { - let u_k2 = if kind == crate::InteractionKind::Distance { - -gamma * (xi[k2] - bank.zeta[i * latent_dim + k2]) / dist - } else { - bank.zeta[i * latent_dim + k2] - }; - let entry = (n_dims + k) * n_par + n_dims + k2; - h[entry] += w * u_k * u_k2; - if observed_curvature && kind == crate::InteractionKind::Distance { - let diff_k = xi[k] - bank.zeta[i * latent_dim + k]; - let diff_k2 = xi[k2] - bank.zeta[i * latent_dim + k2]; - let diagonal = if k == k2 { 1.0 } else { 0.0 }; - let eta_second = -gamma - * (diagonal / dist - diff_k * diff_k2 / (dist * dist * dist)); - // -d2 log p(y|eta) = w eta'eta' - (y-p) eta''. - h[entry] -= resid * eta_second; - } - } - } - } - } - for d in 0..n_dims { - let z = (theta[d] - prior.mean[d]) / prior.sd[d]; - lp -= 0.5 * z * z; - g[d] -= z / prior.sd[d]; - h[d * n_par + d] += 1.0 / (prior.sd[d] * prior.sd[d]); - } - if uses_space { - for k in 0..latent_dim { - lp -= 0.5 * xi[k] * xi[k]; - g[n_dims + k] -= xi[k]; - h[(n_dims + k) * n_par + n_dims + k] += 1.0; - } - } - if let Some(gr) = grad { - *gr = g; - } - if let Some(inf) = info { - *inf = h; - } - lp - }; - - for p in 0..n_persons { - let mut par = vec![0.0_f64; n_par]; - let mut lp = eval(p, &par, None, None, false); - let mut converged = false; - for _ in 0..max_iter { - let mut g = Vec::new(); - let mut h = Vec::new(); - eval(p, &par, Some(&mut g), Some(&mut h), false); - let g_norm: f64 = g.iter().map(|v| v * v).sum::().sqrt(); - if g_norm < tol { - converged = true; - break; - } - // Fisher information is positive semidefinite and the proper Gaussian priors add a - // strictly positive diagonal. Fail closed if finite-precision elimination nevertheless - // cannot solve the system rather than unwinding a public scoring call. - let Some(step_dir) = solve_sym(h, g, n_par) else { - break; - }; - let mut step = 1.0_f64; - let mut accepted = false; - for _ in 0..25 { - let cand: Vec = par - .iter() - .zip(&step_dir) - .map(|(v, s)| v + step * s) - .collect(); - let cand_lp = eval(p, &cand, None, None, false); - if cand_lp > lp { - par = cand; - lp = cand_lp; - accepted = true; - break; - } - step *= 0.5; - } - if !accepted { - break; - } - } - // SEs from the observed information at the mode. - let mut h = Vec::new(); - eval(p, &par, None, Some(&mut h), true); - for d in 0..n_dims { - let mut e = vec![0.0_f64; n_par]; - e[d] = 1.0; - let se = solve_sym(h.clone(), e, n_par) - .map(|col| col[d].max(0.0).sqrt()) - .unwrap_or(f64::NAN); - out.theta_se[p * n_dims + d] = se; - } - out.theta_map[p * n_dims..(p + 1) * n_dims].copy_from_slice(&par[..n_dims]); - if uses_space { - out.xi_map[p * latent_dim..(p + 1) * latent_dim].copy_from_slice(&par[n_dims..]); - } - out.log_posterior[p] = lp; - out.converged[p] = converged; - } - Ok(out) -} - -/// Lord-Wingersky (1984) recursion: `probs` is `n_items x n_nodes` row-major -/// success probabilities; returns the `(n_items + 1) x n_nodes` summed-score -/// distribution. -pub fn lord_wingersky(probs: &[f64], n_items: usize, n_nodes: usize) -> Vec { - assert_eq!(probs.len(), n_items * n_nodes); - let mut f = vec![0.0_f64; (n_items + 1) * n_nodes]; - if n_items == 0 { - for x in 0..n_nodes { - f[x] = 1.0; - } - return f; - } - for x in 0..n_nodes { - f[x] = 1.0 - probs[x]; - f[n_nodes + x] = probs[x]; - } - let mut prev = vec![0.0_f64; (n_items + 1) * n_nodes]; - for n in 1..n_items { - prev[..(n + 1) * n_nodes].copy_from_slice(&f[..(n + 1) * n_nodes]); - for r in 0..=(n + 1) { - for x in 0..n_nodes { - let p = probs[n * n_nodes + x]; - let stay = if r <= n { - prev[r * n_nodes + x] * (1.0 - p) - } else { - 0.0 - }; - let up = if r >= 1 { - prev[(r - 1) * n_nodes + x] * p - } else { - 0.0 - }; - f[r * n_nodes + x] = stay + up; - } - } - } - f -} - -/// Summed-score EAP tables (Thissen et al. 1995), one per trait dimension: -/// `E[theta_d | summed score over the dimension's items]`, with the item -/// success probabilities marginalized over the latent-space nodes. -/// -/// # References -/// -/// Thissen, D., Pommerich, M., Billeaud, K., & Williams, V. S. L. (1995). -/// Item response theory for scores on tests including polytomous items with -/// ordered responses. *Applied Psychological Measurement, 19*(1), 39–49. -/// https://doi.org/10.1177/014662169501900105 -pub fn eapsum_tables( - bank: &ItemBank<'_>, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, -) -> Result, String> { - eapsum_tables_device(bank, prior, q_theta, xi_rule, crate::Device::Auto) -} - -/// Build summed-score conversion tables with an explicit execution device. -/// `Auto`/`Gpu` offloads the Lord-Wingersky recursion and posterior moment -/// reduction to wgpu when possible; the bounded Rust CPU path is the f64 -/// reference and fallback. -pub fn eapsum_tables_device( - bank: &ItemBank<'_>, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, - device: crate::Device, -) -> Result, String> { - let n_items = validate_bank(bank)?; - validate_prior(prior, bank.n_dims)?; - let grids = scoring_grids(bank, q_theta, xi_rule)?; - let ctx = prior_contexts(prior); - let config = bank_model_config(bank, 1, n_items); - let tables = build_tables( - bank.alpha, - bank.b, - bank.zeta, - bank.tau, - &config, - bank.factor_id, - &ctx, - &grids, - ); - Ok(dispatch_eapsum_tables_device( - bank, prior, &grids, &tables, n_items, device, - )) -} - -fn eapsum_table_for_dim( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - n_items: usize, - d: usize, -) -> EapSumTable { - let cell = grids.q_t * grids.n_x; - let items: Vec = (0..n_items).filter(|&i| bank.factor_id[i] == d).collect(); - let n_d = items.len(); - if n_d == 0 { - return EapSumTable { - dim: d, - n_items_dim: 0, - score_prob: vec![1.0], - eap: vec![prior.mean[d]], - sd: vec![prior.sd[d]], - }; - } - let mut probs = vec![0.0_f64; n_d * cell]; - for (row, &i) in items.iter().enumerate() { - for c in 0..cell { - probs[row * cell + c] = tables.logp1[i * cell + c].exp(); - } - } - let score_dist = lord_wingersky(&probs, n_d, cell); - let mut w = vec![0.0_f64; cell]; - let mut theta_val = vec![0.0_f64; cell]; - for (t, &node_t) in grids.t_nodes.iter().enumerate() { - let theta = prior.mean[d] + prior.sd[d] * node_t; - for x in 0..grids.n_x { - let c = t * grids.n_x + x; - w[c] = (grids.t_logw[t] + grids.x_logw[x]).exp(); - theta_val[c] = theta; - } - } - let mut score_prob = vec![0.0_f64; n_d + 1]; - let mut eap = vec![0.0_f64; n_d + 1]; - let mut sd = vec![0.0_f64; n_d + 1]; - for s in 0..=n_d { - let (mut p0, mut m1, mut m2) = (0.0_f64, 0.0_f64, 0.0_f64); - for c in 0..cell { - let v = w[c] * score_dist[s * cell + c]; - p0 += v; - m1 += v * theta_val[c]; - m2 += v * theta_val[c] * theta_val[c]; - } - score_prob[s] = p0; - if p0 > 0.0 { - eap[s] = m1 / p0; - sd[s] = (m2 / p0 - eap[s] * eap[s]).max(0.0).sqrt(); - } else { - eap[s] = prior.mean[d]; - sd[s] = prior.sd[d]; - } - } - EapSumTable { - dim: d, - n_items_dim: n_d, - score_prob, - eap, - sd, - } -} - -fn eapsum_tables_cpu_reduce( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - n_items: usize, -) -> Vec { - let worker_count = std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(1) - .min(bank.n_dims); - if worker_count <= 1 { - return (0..bank.n_dims) - .map(|d| eapsum_table_for_dim(bank, prior, grids, tables, n_items, d)) - .collect(); - } - let dims_per_worker = bank.n_dims.div_ceil(worker_count); - std::thread::scope(|scope| { - let mut handles = Vec::with_capacity(worker_count); - for start in (0..bank.n_dims).step_by(dims_per_worker) { - let end = (start + dims_per_worker).min(bank.n_dims); - handles.push(scope.spawn(move || { - (start..end) - .map(|d| eapsum_table_for_dim(bank, prior, grids, tables, n_items, d)) - .collect::>() - })); - } - handles - .into_iter() - .flat_map(|handle| handle.join().expect("EAPsum CPU worker panicked")) - .collect() - }) -} - -#[cfg(all(feature = "gpu", not(coverage)))] -fn dispatch_eapsum_tables_device( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - n_items: usize, - device: crate::Device, -) -> Vec { - if device != crate::Device::Cpu { - let inputs = crate::gpu_eapsum::GpuEapSumTableInputs { - n_items, - n_dims: bank.n_dims, - q_t: grids.q_t, - n_x: grids.n_x, - factor_id: bank.factor_id, - logp1: &tables.logp1, - t_logw: &grids.t_logw, - x_logw: &grids.x_logw, - t_nodes: &grids.t_nodes, - prior_mean: &prior.mean, - prior_sd: &prior.sd, - }; - if let Some(gpu) = crate::gpu_eapsum::eapsum_tables_gpu(&inputs) { - return gpu; - } - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU EAPsum tables requested but no usable GPU adapter was found or the table exceeds GPU bounds; falling back to the CPU implementation." - ); - } - } - eapsum_tables_cpu_reduce(bank, prior, grids, tables, n_items) -} - -#[cfg(any(not(feature = "gpu"), coverage))] -fn dispatch_eapsum_tables_device( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - n_items: usize, - device: crate::Device, -) -> Vec { - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU EAPsum tables requested but this build has no GPU support; falling back to the CPU implementation." - ); - } - eapsum_tables_cpu_reduce(bank, prior, grids, tables, n_items) -} - -fn validate_eapsum_lookup( - y: &[f64], - observed: &[bool], - n_persons: usize, - factor_id: &[usize], - n_dims: usize, - tables: &[EapSumTable], -) -> Result { - if n_dims == 0 { - return Err("n_dims must be positive".into()); - } - let n_items = factor_id.len(); - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - if factor_id.iter().any(|&d| d >= n_dims) { - return Err("factor_id values must be in 0..n_dims-1".into()); - } - if observed.iter().any(|&value| !value) { - return Err("eapsum scoring requires complete responses within each dimension".into()); - } - if tables.len() != n_dims { - return Err("eapsum tables must contain exactly one table per dimension".into()); - } - let mut seen = vec![false; n_dims]; - for table in tables { - if table.dim >= n_dims || seen[table.dim] { - return Err("eapsum table dimensions must be unique values in 0..n_dims-1".into()); - } - seen[table.dim] = true; - let expected_items = factor_id.iter().filter(|&&d| d == table.dim).count(); - if table.n_items_dim != expected_items - || table.eap.len() != expected_items + 1 - || table.sd.len() != expected_items + 1 - { - return Err("eapsum table lengths do not match factor_id".into()); - } - if table.eap.iter().any(|value| !value.is_finite()) - || table - .sd - .iter() - .any(|value| !value.is_finite() || *value < 0.0) - { - return Err("eapsum table values must be finite and SDs non-negative".into()); - } - } - Ok(n_items) -} - -fn score_eapsum_cpu_chunk( - y: &[f64], - n_items: usize, - factor_id: &[usize], - tables_by_dim: &[&EapSumTable], - start_person: usize, - theta: &mut [f64], - theta_sd: &mut [f64], -) { - let n_dims = tables_by_dim.len(); - for local_person in 0..theta.len() / n_dims { - let person = start_person + local_person; - for d in 0..n_dims { - let score = (0..n_items) - .filter(|&item| factor_id[item] == d) - .map(|item| y[person * n_items + item] as usize) - .sum::(); - theta[local_person * n_dims + d] = tables_by_dim[d].eap[score]; - theta_sd[local_person * n_dims + d] = tables_by_dim[d].sd[score]; - } - } -} - -fn score_eapsum_cpu_reduce( - y: &[f64], - n_persons: usize, - n_items: usize, - factor_id: &[usize], - tables: &[EapSumTable], -) -> EapSumScores { - let n_dims = tables.len(); - let mut tables_by_dim = vec![&tables[0]; n_dims]; - for table in tables { - tables_by_dim[table.dim] = table; - } - let mut theta_eap = vec![0.0; n_persons * n_dims]; - let mut theta_sd = vec![0.0; n_persons * n_dims]; - if n_persons > 0 { - let worker_count = std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(1) - .min(n_persons); - let persons_per_worker = n_persons.div_ceil(worker_count); - let chunk_len = persons_per_worker * n_dims; - std::thread::scope(|scope| { - for (worker, (theta, sd)) in theta_eap - .chunks_mut(chunk_len) - .zip(theta_sd.chunks_mut(chunk_len)) - .enumerate() - { - let start_person = worker * persons_per_worker; - let tables_by_dim = &tables_by_dim; - scope.spawn(move || { - score_eapsum_cpu_chunk( - y, - n_items, - factor_id, - tables_by_dim, - start_person, - theta, - sd, - ); - }); - } - }); - } - EapSumScores { - theta_eap, - theta_sd, - n_observed: vec![n_items; n_persons], - } -} - -/// Apply summed-score conversion tables to complete response vectors. The -/// default follows the Rust GPU-first policy and retains the parallel f64 CPU -/// lookup as a hardware-independent fallback. -pub fn score_eapsum( - y: &[f64], - observed: &[bool], - n_persons: usize, - factor_id: &[usize], - n_dims: usize, - tables: &[EapSumTable], -) -> Result { - score_eapsum_device( - y, - observed, - n_persons, - factor_id, - n_dims, - tables, - crate::Device::Auto, - ) -} - -pub fn score_eapsum_device( - y: &[f64], - observed: &[bool], - n_persons: usize, - factor_id: &[usize], - n_dims: usize, - tables: &[EapSumTable], - device: crate::Device, -) -> Result { - let n_items = validate_eapsum_lookup(y, observed, n_persons, factor_id, n_dims, tables)?; - Ok(dispatch_score_eapsum_device( - y, n_persons, n_items, factor_id, tables, device, - )) -} - -#[cfg(all(feature = "gpu", not(coverage)))] -fn dispatch_score_eapsum_device( - y: &[f64], - n_persons: usize, - n_items: usize, - factor_id: &[usize], - tables: &[EapSumTable], - device: crate::Device, -) -> EapSumScores { - if device != crate::Device::Cpu { - let inputs = crate::gpu_eapsum::GpuEapSumLookupInputs { - y, - n_persons, - n_items, - n_dims: tables.len(), - factor_id, - tables, - }; - if let Some((theta_eap, theta_sd)) = crate::gpu_eapsum::score_eapsum_gpu(&inputs) { - return EapSumScores { - theta_eap, - theta_sd, - n_observed: vec![n_items; n_persons], - }; - } - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU EAPsum scoring requested but no usable GPU adapter was found or the request exceeds GPU bounds; falling back to the CPU implementation." - ); - } - } - score_eapsum_cpu_reduce(y, n_persons, n_items, factor_id, tables) -} - -#[cfg(any(not(feature = "gpu"), coverage))] -fn dispatch_score_eapsum_device( - y: &[f64], - n_persons: usize, - n_items: usize, - factor_id: &[usize], - tables: &[EapSumTable], - device: crate::Device, -) -> EapSumScores { - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU EAPsum scoring requested but this build has no GPU support; falling back to the CPU implementation." - ); - } - score_eapsum_cpu_reduce(y, n_persons, n_items, factor_id, tables) -} - -#[cfg(test)] -#[path = "../../../tests/unit/scoring_tests.rs"] -mod tests; - -/// Item information of the four-parameter logistic model (Magis 2013, APM, -/// "A note on the item information function of the four-parameter logistic -/// model"): with `P = c + (d - c) sigmoid(eta)` and slope `a`, -/// `I(theta) = a^2 (P - c)^2 (d - P)^2 / ((d - c)^2 P (1 - P))`. -/// `c = 0, d = 1` reduces to the 2PL `a^2 P (1 - P)`. For the latent-space -/// models the information is with respect to the trait direction at a fixed -/// latent-space position. -pub fn item_information_4pl(a: f64, p: f64, c: f64, d: f64) -> f64 { - if p <= 0.0 || p >= 1.0 || d <= c { - return 0.0; - } - let num = a * a * (p - c) * (p - c) * (d - p) * (d - p); - num / ((d - c) * (d - c) * p * (1.0 - p)) -} - -/// Per-item information at arbitrary `(theta_d, xi)` points for a frozen -/// bank; also the per-dimension test information (sum over the dimension's -/// items). `theta` is `n_points x n_dims`, `xi` is `n_points x latent_dim`. -pub fn bank_information( - bank: &ItemBank<'_>, - theta: &[f64], - xi: &[f64], - n_points: usize, -) -> Result<(Vec, Vec), String> { - bank_information_device(bank, theta, xi, n_points, crate::Device::Auto) -} - -/// Fixed-bank information with an explicit compute device. `Device::Auto` -/// prefers the wgpu f32 kernel and falls back to the scalar Rust f64 -/// implementation. `Device::Gpu` warns when no usable adapter is available. -pub fn bank_information_device( - bank: &ItemBank<'_>, - theta: &[f64], - xi: &[f64], - n_points: usize, - device: crate::Device, -) -> Result<(Vec, Vec), String> { - let n_items = validate_bank(bank)?; - let theta_len = crate::checked_mul_usize(n_points, bank.n_dims, "n_points * n_dims overflows")?; - let xi_len = - crate::checked_mul_usize(n_points, bank.latent_dim, "n_points * latent_dim overflows")?; - if theta.len() != theta_len || xi.len() != xi_len { - return Err("theta/xi shapes must match n_points".into()); - } - if theta.iter().chain(xi).any(|value| !value.is_finite()) { - return Err("theta/xi values must be finite".into()); - } - Ok(dispatch_information_device( - bank, theta, xi, n_points, n_items, device, - )) -} - -fn bank_information_cpu_reduce( - bank: &ItemBank<'_>, - theta: &[f64], - xi: &[f64], - n_points: usize, - n_items: usize, -) -> (Vec, Vec) { - if n_points == 0 { - return (Vec::new(), Vec::new()); - } - let (free_alpha, _uses_space) = model_exec_flags(bank.model_type); - let kind = crate::interaction_kind(bank.model_type); - let gamma = if kind == crate::InteractionKind::Distance { - bank.tau.exp() - } else { - 0.0 - }; - let worker_count = std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(1) - .min(n_points.max(1)); - let chunk_size = n_points.div_ceil(worker_count); - let dim_count = bank.n_dims; - let latent_dim = bank.latent_dim; - let chunks = std::thread::scope(|scope| { - let mut handles = Vec::with_capacity(worker_count); - for start in (0..n_points).step_by(chunk_size) { - let end = (start + chunk_size).min(n_points); - handles.push(scope.spawn(move || { - let local_len = end - start; - let mut item_info = vec![0.0_f64; local_len * n_items]; - let mut test_info = vec![0.0_f64; local_len * dim_count]; - for (local_p, p) in (start..end).enumerate() { - for i in 0..n_items { - let d = bank.factor_id[i]; - let a = if free_alpha { bank.alpha[i].exp() } else { 1.0 }; - let mut eta = a * theta[p * dim_count + d] + bank.b[i]; - match kind { - crate::InteractionKind::None => {} - crate::InteractionKind::Distance => { - let mut dist2 = bank.eps_distance; - for k in 0..latent_dim { - let diff = - xi[p * latent_dim + k] - bank.zeta[i * latent_dim + k]; - dist2 += diff * diff; - } - eta -= gamma * dist2.sqrt(); - } - crate::InteractionKind::Inner => { - for k in 0..latent_dim { - eta += bank.zeta[i * latent_dim + k] * xi[p * latent_dim + k]; - } - } - } - let prob = sigmoid(eta); - let info = item_information_4pl(a, prob, 0.0, 1.0); - item_info[local_p * n_items + i] = info; - test_info[local_p * dim_count + d] += info; - } - } - (start, end, item_info, test_info) - })); - } - handles - .into_iter() - .map(|handle| handle.join().expect("bank_information CPU worker panicked")) - .collect::>() - }); - let mut item_info = vec![0.0_f64; n_points * n_items]; - let mut test_info = vec![0.0_f64; n_points * dim_count]; - for (start, end, local_item, local_test) in chunks { - item_info[start * n_items..end * n_items].copy_from_slice(&local_item); - test_info[start * dim_count..end * dim_count].copy_from_slice(&local_test); - } - (item_info, test_info) -} - -#[cfg(all(feature = "gpu", not(coverage)))] -fn try_bank_information_gpu( - bank: &ItemBank<'_>, - theta: &[f64], - xi: &[f64], - n_points: usize, - n_items: usize, -) -> Option<(Vec, Vec)> { - let (free_alpha, _uses_space) = model_exec_flags(bank.model_type); - let kind = crate::interaction_kind(bank.model_type); - let interaction_kind = match kind { - crate::InteractionKind::None => 0, - crate::InteractionKind::Distance => 1, - crate::InteractionKind::Inner => 2, - }; - let gamma = if kind == crate::InteractionKind::Distance { - bank.tau.exp() - } else { - 0.0 - }; - let output = - crate::gpu_scoring::bank_information_gpu(&crate::gpu_scoring::GpuInformationInputs { - n_points, - n_items, - n_dims: bank.n_dims, - latent_dim: bank.latent_dim, - free_alpha, - interaction_kind, - gamma, - eps_distance: bank.eps_distance, - alpha: bank.alpha, - b: bank.b, - zeta: bank.zeta, - factor_id: bank.factor_id, - theta, - xi, - })?; - Some((output.item_info, output.test_info)) -} - -#[cfg(all(feature = "gpu", not(coverage)))] -fn dispatch_information_device( - bank: &ItemBank<'_>, - theta: &[f64], - xi: &[f64], - n_points: usize, - n_items: usize, - device: crate::Device, -) -> (Vec, Vec) { - if device != crate::Device::Cpu { - if let Some(output) = try_bank_information_gpu(bank, theta, xi, n_points, n_items) { - return output; - } - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU bank information requested but no usable GPU adapter was found, the output exceeds GPU indexing bounds, or f32 arithmetic produced invalid information; falling back to the CPU implementation." - ); - } - } - bank_information_cpu_reduce(bank, theta, xi, n_points, n_items) -} - -#[cfg(any(not(feature = "gpu"), coverage))] -fn dispatch_information_device( - bank: &ItemBank<'_>, - theta: &[f64], - xi: &[f64], - n_points: usize, - n_items: usize, - device: crate::Device, -) -> (Vec, Vec) { - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU bank information requested but this build has no GPU support; falling back to the CPU implementation." - ); - } - bank_information_cpu_reduce(bank, theta, xi, n_points, n_items) -} - -/// Warm's (1989) weighted-likelihood ability estimates for a UNIDIMENSIONAL dichotomous test. -/// -/// The maximum-likelihood ability estimate carries an `O(1/n)` bias; Warm removes its leading term by -/// weighting the likelihood by a function `w(theta)` with `w'/w = J(theta) / (2 I(theta))`, giving the -/// estimating equation -/// -/// ```text -/// dlnL/dtheta + J(theta) / (2 I(theta)) = 0, -/// ``` -/// -/// where, for the 4-parameter logistic `P_i = c_i + (d_i - c_i) sigmoid(a_i (theta - b_i))` (the 3PL is -/// `d_i = 1`, the 2PL is `c_i = 0, d_i = 1`), with `P_i' = a_i (d_i - c_i) s_i (1 - s_i)` and -/// `P_i'' = a_i^2 (d_i - c_i) s_i (1 - s_i)(1 - 2 s_i)` (`s_i = sigmoid(a_i(theta - b_i))`): -/// -/// - `dlnL/dtheta = sum_i (y_i - P_i) P_i' / (P_i Q_i)` (score); -/// - `I(theta) = sum_i P_i'^2 / (P_i Q_i)` (test information, [`item_information_4pl`] per item); -/// - `J(theta) = sum_i P_i' P_i'' / (P_i Q_i)` (the Warm correction; computed DIRECTLY from `P' P''`). -/// -/// `J` is **not** `I'(theta)/2`. For the 2PL/Rasch (`c = 0, d = 1`) `J = I'(theta)` EXACTLY, which is -/// why the Warm weight is `sqrt(I)` (the Jeffreys prior) there. In general, differentiating -/// `I = sum_i P_i'^2 / (P_i Q_i)` and using `(P Q)' = P'(Q - P)` gives `I' = 2J - T` with -/// -/// ```text -/// T = sum_i P_i'^3 (1 - 2 P_i) / (P_i Q_i)^2, -/// ``` -/// -/// and `T = J` only when `c = 0, d = 1`; any `c > 0` or `d < 1` gives `J != I'`, so a `sqrt(I)`-weighted -/// estimator applies the wrong 3PL/4PL correction. `I' = 2J - T` is pinned by -/// `wle_information_derivative_identity`, because this comment has been wrong twice: it first claimed -/// the coincidence was with `I'/2`, and the correction of that claim dropped the `(1 - 2 P)` factor. Two properties Warm establishes: the estimator removes the leading MLE bias, and — unlike -/// the MLE, which is `+/-infinity` for the all-correct / all-incorrect pattern — it yields a FINITE -/// estimate there. The estimate is the GLOBAL maximizer of the weighted log-likelihood `Phi` -/// (`Phi' = g`), located by a grid scan of `g` (its trapezoidal cumulative integral recovers `Phi`) plus -/// a local root refinement; this is robust to the 3PL/4PL case where the weighted likelihood can be -/// multimodal (Samejima, 1973; Yen, Burket & Sykes, 1991), which a single bracketed root can get wrong. -/// The grid resolution scales with the steepest item discrimination; combinations that would require -/// more than 65,536 intervals are rejected rather than returned with an unresolved global mode. This -/// bounded adaptive search is a repository implementation choice, not a procedure specified by Warm. -/// The reported standard error is `1 / sqrt(I(theta_wle))` (asymptotic). -/// -/// `a`/`b`/`c`/`d` are per-item NATURAL-scale parameters (length `n_items`; `a` is the slope, NOT -/// log-alpha) with `0 <= c_i < d_i <= 1`; `y`/`observed` are row-major `n_persons * n_items` (missing -/// items dropped per person). `theta_bound` bounds the search grid; when the finite Warm root lies -/// beyond it (very easy/hard items relative to the pattern) the estimate is clamped to the boundary and -/// `boundary` is set. A person with no observed items gets `NaN` theta/se with `boundary` set (ability -/// undefined). -/// -/// # References (APA 7th ed.) -/// -/// Warm, T. A. (1989). Weighted likelihood estimation of ability in item response theory. -/// *Psychometrika, 54*(3), 427-450. -pub struct WleScores { - /// Weighted-likelihood ability estimate per person. - pub theta: Vec, - /// Asymptotic standard error `1 / sqrt(I(theta_wle))` (`NaN` if the test information is ~0). - pub se: Vec, - /// `true` when the finite root fell outside `[-theta_bound, theta_bound]` and `theta` was clamped. - pub boundary: Vec, -} - -fn finite_wle_value(value: f64, message: String) -> Result { - if value.is_finite() { - Ok(value) - } else { - Err(message) - } -} - -fn refine_wle_root( - mut lower: f64, - mut upper: f64, - tol: f64, - evaluate: &mut dyn FnMut(f64) -> f64, -) -> Result { - let mut lower_value = evaluate(lower); - if lower_value * evaluate(upper) > 0.0 { - return Err("failed to bracket the global WLE mode"); - } - for _ in 0..200 { - if upper - lower < tol { - return Ok(lower + 0.5 * (upper - lower)); - } - let mid = lower + 0.5 * (upper - lower); - let mid_value = evaluate(mid); - if mid_value == 0.0 { - return Ok(mid); - } - if (mid_value > 0.0) == (lower_value > 0.0) { - lower = mid; - lower_value = mid_value; - } else { - upper = mid; - } - } - Err("WLE root refinement did not converge") -} - -#[allow(clippy::too_many_arguments)] -pub fn score_wle( - a: &[f64], - b: &[f64], - c: &[f64], - d: &[f64], - y: &[f64], - observed: &[bool], - n_persons: usize, - theta_bound: f64, - tol: f64, -) -> Result { - let n_items = a.len(); - if n_items == 0 { - return Err("need at least one item".into()); - } - if b.len() != n_items || c.len() != n_items || d.len() != n_items { - return Err("a, b, c, d must have equal length".into()); - } - for i in 0..n_items { - if !a[i].is_finite() || !b[i].is_finite() || !c[i].is_finite() || !d[i].is_finite() { - return Err("item parameters must be finite".into()); - } - if !(0.0..1.0).contains(&c[i]) || c[i] >= d[i] || d[i] > 1.0 { - return Err("require 0 <= c_i < d_i <= 1".into()); - } - } - if !theta_bound.is_finite() || theta_bound <= 0.0 { - return Err("theta_bound must be finite and positive".into()); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - - // (g, I) at theta for person p, where g = score + J/(2I) is the Warm estimating function. The - // clamp on s keeps P Q away from 0 (item_information_4pl-style saturation), and the I floor guards - // the J/(2I) division when every observed item is saturated. - let eval = |p: usize, theta: f64| -> (f64, f64) { - let (mut score, mut info, mut jterm) = (0.0_f64, 0.0_f64, 0.0_f64); - for i in 0..n_items { - let idx = p * n_items + i; - if !observed[idx] { - continue; - } - let s = sigmoid(a[i] * (theta - b[i])).clamp(1e-12, 1.0 - 1e-12); - let dc = d[i] - c[i]; - let pp = c[i] + dc * s; - let pq = (pp * (1.0 - pp)).max(1e-300); - let p1 = a[i] * dc * s * (1.0 - s); // P' - let p2 = a[i] * a[i] * dc * s * (1.0 - s) * (1.0 - 2.0 * s); // P'' - score += (y[idx] - pp) * p1 / pq; - info += p1 * p1 / pq; - jterm += p1 * p2 / pq; - } - (score + jterm / (2.0 * info.max(1e-12)), info) - }; - - // The WLE is the GLOBAL maximizer of the weighted log-likelihood `Phi` with `Phi'(theta) = g`; for - // the 2PL/Rasch `Phi` is unimodal, but for the 3PL/4PL the weighted likelihood can have SEVERAL - // stationary points (Samejima, 1973; Yen, Burket & Sykes, 1991), so a single bracketed bisection can - // converge to a non-dominant root. Recover `Phi` (up to a constant) as the trapezoidal cumulative - // integral of `g` over a grid, take the global-max node, and refine the root of `g` around it. - // - // A fixed theta grid is not sufficient here: a logistic transition has width O(1 / |a_i|), and a - // high-discrimination 3PL/4PL item can therefore create a dominant mode entirely between two fixed - // nodes. Keep the historical 512-node floor for ordinary tests, but guarantee four intervals per - // unit of the steepest item's logit scale. Refuse pathological controls that would exceed the - // explicit work bound instead of silently returning the wrong mode. - const MIN_GRID: usize = 512; - const MAX_GRID: usize = 65_536; - const INTERVALS_PER_LOGIT: f64 = 4.0; - let max_abs_a = a.iter().fold(0.0_f64, |acc, &value| acc.max(value.abs())); - if max_abs_a == 0.0 { - return Err("at least one item must have nonzero discrimination".into()); - } - let required_grid = (2.0 * theta_bound * max_abs_a * INTERVALS_PER_LOGIT).ceil(); - if !required_grid.is_finite() || required_grid > MAX_GRID as f64 { - return Err(format!( - "theta_bound and item discrimination require more than {MAX_GRID} WLE grid intervals" - )); - } - let grid = (required_grid as usize).max(MIN_GRID); - let h = 2.0 * (theta_bound / grid as f64); - let grid_theta = |k: usize| theta_bound * (2.0 * k as f64 / grid as f64 - 1.0); - let mut gvals = vec![0.0f64; grid + 1]; - let mut out = WleScores { - theta: vec![0.0; n_persons], - se: vec![0.0; n_persons], - boundary: vec![false; n_persons], - }; - for p in 0..n_persons { - // No observed items -> ability is undefined; do not report a spurious theta = 0. - if (0..n_items).all(|i| !observed[p * n_items + i]) { - out.theta[p] = f64::NAN; - out.se[p] = f64::NAN; - out.boundary[p] = true; - continue; - } - for (k, gval) in gvals.iter_mut().enumerate() { - *gval = finite_wle_value( - eval(p, grid_theta(k)).0, - format!("non-finite WLE estimating function for person {p}"), - )?; - } - // Phi_0 = 0 (reference); track the global argmax over the grid nodes. - let (mut phi, mut best_phi, mut best_k) = (0.0f64, 0.0f64, 0usize); - for k in 1..=grid { - // The adaptive grid constrains `max(|a|) * theta_bound`, while the finite estimating - // function scales with `a`; hence each trapezoid increment is finite after the check above. - phi += 0.5 * (gvals[k - 1] + gvals[k]) * h; - if phi > best_phi { - best_phi = phi; - best_k = k; - } - } - let theta_hat = if best_k == 0 || best_k == grid { - // Global max at a boundary node: the finite Warm root lies at/beyond the hard bound. - out.boundary[p] = true; - grid_theta(best_k) - } else { - // Interior max: Phi' = g crosses + -> - in [node-1, node+1]; refine by bisection. - let mut evaluate = |theta| eval(p, theta).0; - refine_wle_root( - grid_theta(best_k - 1), - grid_theta(best_k + 1), - tol, - &mut evaluate, - ) - .map_err(|reason| format!("{reason} for person {p}"))? - }; - out.theta[p] = theta_hat; - let info = eval(p, theta_hat).1; - out.se[p] = if info > 1e-12 { - (1.0 / info).sqrt() - } else { - f64::NAN - }; - } - Ok(out) -} - -/// Per-category Warm quantities for ONE polytomous item at `theta`, returned as -/// `(P_k, P'_k / P_k, P''_k / P_k)`. -/// -/// The ratios are formed DIVISION-FREE from the sigmoids rather than by dividing derivatives by `P`, -/// so an underflowing extreme-tail category still contributes a finite score: `P'_k / P_k` and -/// `P''_k / P_k` are bounded even where `P_k` is not representable. Nothing here needs a probability -/// floor. -fn poly_wle_ratios( - theta: f64, - slope: f64, - cat_params: &[f64], - model: PolyModel, -) -> (Vec, Vec, Vec) { - let a = slope; - let base = a * theta; - let k_cat = cat_params.len() + 1; - match model { - PolyModel::Gpcm => { - // psi_k = k * base + c_k with c_0 = 0, matching `gpcm_logprobs`. - let scores: Vec = (0..k_cat).map(|c| c as f64).collect(); - let mut intercepts = vec![0.0_f64; k_cat]; - intercepts[1..].copy_from_slice(cat_params); - let p: Vec = gpcm_logprobs(base, &scores, &intercepts) - .iter() - .map(|l| l.exp()) - .collect(); - let e: f64 = scores.iter().zip(&p).map(|(s, pp)| s * pp).sum(); - let v: f64 = scores - .iter() - .zip(&p) - .map(|(s, pp)| pp * (s - e) * (s - e)) - .sum(); - // P'_k/P_k = a (k - E); P''_k/P_k = (P'_k/P_k)^2 - a^2 Var(k). - let r1: Vec = scores.iter().map(|s| a * (s - e)).collect(); - let r2: Vec = r1.iter().map(|r| r * r - a * a * v).collect(); - (p, r1, r2) - } - PolyModel::Grm => { - // s_0 = 1, s_j = sigmoid(base + beta_{j-1}) for j = 1..K-1, s_K = 0, matching - // `grm_logprobs`. Then v_0 = v_K = 0 automatically. - let p: Vec = grm_logprobs(base, cat_params) - .iter() - .map(|l| l.exp()) - .collect(); - let mut s = vec![0.0_f64; k_cat + 1]; - s[0] = 1.0; - for j in 1..k_cat { - s[j] = sigmoid(base + cat_params[j - 1]); - } - let v: Vec = s.iter().map(|sj| sj * (1.0 - sj)).collect(); - // P'_k/P_k = a (1 - s_k - s_{k+1}); P''_k/P_k = (P'_k/P_k)^2 - a^2 (v_k + v_{k+1}). - let r1: Vec = (0..k_cat).map(|k| a * (1.0 - s[k] - s[k + 1])).collect(); - let r2: Vec = (0..k_cat) - .map(|k| r1[k] * r1[k] - a * a * (v[k] + v[k + 1])) - .collect(); - (p, r1, r2) - } - } -} - -/// Test information and Warm correction numerator for ONE polytomous item at `theta`: -/// `(I, J) = (sum_k P'_k^2 / P_k, sum_k P'_k P''_k / P_k)`, accumulated from -/// [`poly_wle_ratios`] as `sum_k P_k r1_k^2` and `sum_k P_k r1_k r2_k`. -/// -/// Exposed so the tests can pin `J` against an independently obtained `I'(theta)` — see the -/// verification note on [`score_wle_poly`]. `J` is what the estimator actually uses; the identity -/// `J = I'` is a test oracle only and is never substituted for this computation. -/// -/// Test-only: [`score_wle_poly`]'s hot loop inlines the same accumulation alongside the score term -/// rather than calling this, so gating it keeps `cargo build` warning-free. -#[cfg(test)] -pub(crate) fn poly_wle_terms( - theta: f64, - slope: f64, - cat_params: &[f64], - model: PolyModel, -) -> (f64, f64) { - let (p, r1, r2) = poly_wle_ratios(theta, slope, cat_params, model); - let mut info = 0.0_f64; - let mut jterm = 0.0_f64; - for k in 0..p.len() { - info += p[k] * r1[k] * r1[k]; - jterm += p[k] * r1[k] * r2[k]; - } - (info, jterm) -} - -/// Warm's (1989) weighted-likelihood ability estimates for a UNIDIMENSIONAL POLYTOMOUS test -/// (GRM or GPCM). The maximum-likelihood estimate carries an `O(1/n)` bias; Warm removes its leading -/// term by weighting the likelihood by `w` with `w'/w = J/(2 I)`, giving -/// -/// ```text -/// dlnL/dtheta + J(theta) / (2 I(theta)) = 0, -/// ``` -/// -/// accumulated over the person's observed items with, per item and category `k = 0..K-1`, -/// -/// ```text -/// score = P'_y / P_y, I = sum_k P'_k^2 / P_k, J = sum_k P'_k P''_k / P_k. -/// ``` -/// -/// This is the exact generalization of the dichotomous `sum_i P_i' P_i'' / (P_i Q_i)` in -/// [`score_wle`], which is the two-category case of the same sum. `J` is computed DIRECTLY from `P'` -/// and `P''`; it is never written as `I'(theta) / (2 I)`. -/// -/// # Verification status -/// -/// CONFIRMED FROM AN INDEPENDENT IMPLEMENTATION'S SOURCE, not from a primary paper: that the -/// polytomous Warm correction is `J/(2 I)` with `J = sum_k P'_k P''_k / P_k`. The `catR` package -/// computes exactly this (`R/Ji.R`, polytomous branch `dP*d2P/P` row-summed; `R/thetaEst.R`, method -/// `"WL"`, `sum(Ji)/(2*sum(Ii))`), and its Jeffreys-prior branch uses a DIFFERENT expression, -/// `sum(dIi)/(2*sum(Ii))` — the two estimators are distinct there and are kept distinct here -/// (Magis & Raiche, 2012). The primary-source polytomous derivation was NOT read for this work. -/// -/// PROVED AND VERIFIED IN THIS REPOSITORY, not taken from a source: `J(theta) = I'(theta)` for both -/// families shipped here. From `I' = 2J - T` with `T = sum_k P'^3_k / P_k^2` one gets -/// `J - I' = -E[l' l'']`, and -/// * GPCM: `l''_k = -a^2 Var_P(k)` does not depend on `k`, so `E[l' l''] = -Var * E[l'] = 0`; -/// * GRM: with `s_j = sigmoid(a theta + beta_{j-1})`, `s_0 = 1`, `s_K = 0`, `v_j = s_j (1 - s_j)`, -/// `E[l' l''] = -a^3 sum_k (v_k - v_{k+1})(v_k + v_{k+1}) = -a^3 (v_0^2 - v_K^2) = 0` by -/// telescoping. -/// Checked numerically at 80-digit precision against fully numeric derivatives of `P` (K = 4 and -/// K = 5, asymmetric non-centred parameters, off-centre theta): relative `|J - I'| <= 1.1e-30`. The -/// WLE therefore coincides with the Jeffreys modal estimate here. The identity is used ONLY as a test -/// oracle, never as an implementation shortcut: it is a property of a single slope per item with the -/// logistic link and no lower asymptote, and it FAILS — verified at the same precision — for a graded -/// model with per-boundary slopes (relative `|J - I'|` of 0.92 and 1.17 at the two thetas the test -/// uses) and for the 3PL (0.47 at `c = 0.25`, the case [`score_wle`] already handles). Both figures are -/// the values the shipped fixtures actually assert. -/// -/// A CONSEQUENCE WORTH STATING: since the identity is exact here, an implementation that replaced `J` -/// with a numerical derivative of `I` would be behaviour-preserving for GRM and GPCM, and no -/// polytomous test can detect the substitution. The anchors that do are in the dichotomous suite, -/// where a lower asymptote breaks the identity. A family added later must re-derive `J`. -/// -/// NOT VERIFIED, AND DELIBERATELY NOT CITED: Penfield and Bergeron (2005) treat the GPCM but their -/// equations were not obtainable and are not the source of anything here. -/// -/// # Numerics -/// -/// Per-category quantities are formed division-free from the sigmoids, so no category probability ever -/// appears in a denominator. GPCM: `P'_k/P_k = a(k - E)`, `P''_k/P_k = (P'_k/P_k)^2 - a^2 Var(k)`. -/// GRM: `P'_k/P_k = a(1 - s_k - s_{k+1})`, `P''_k/P_k = (P'_k/P_k)^2 - a^2 (v_k + v_{k+1})`; the -/// resulting `I = a^2 sum_k P_k (1 - s_k - s_{k+1})^2` is algebraically identical to -/// [`crate::poly::poly_item_information`]'s `a^2 sum_k (v_k - v_{k+1})^2 / P_k`, since -/// `v_k - v_{k+1} = P_k (1 - s_k - s_{k+1})`. -/// -/// The estimate is the GLOBAL maximizer of the weighted log-likelihood `Phi` (`Phi' = g`). Both -/// polytomous log-likelihoods are log-concave, but `ln w` is not, and `Phi` is genuinely multimodal: a -/// 3-item GPCM bank in the test suite has stationary points at `+0.0988`, `+0.3774` and `+1.3314` with -/// `Phi = -4.7208 / -4.7694 / -3.8787` while `max lnL'' = -5.6e-5 < 0`, so a solver that brackets the -/// first sign change from the left errs by 1.23 logits (2.36 for the GRM fixture). The grid scan plus -/// local refinement of [`score_wle`] is therefore reused unchanged, including its refusal to return an -/// unresolved mode beyond 65,536 intervals. That bounded adaptive search is a repository implementation -/// choice, not a procedure specified by Warm. -/// -/// PCM is this GPCM path with `a = 1` (a reparameterization, not a separate code path). RSM is NOT -/// supported: it is a constrained GPCM in theory, but the fitted `(delta_i, shared tau)` -/// parameterization is not convertible through any exposed API ([`crate::rsm::rsm_logprobs`] builds -/// the equivalent intercepts internally and does not return them). -/// -/// `y[p * n_items + i]` is the observed category in `0..n_cat`; `observed = None` means complete data. -/// A person with no observed items gets `NaN` theta/se with `boundary` set. -/// -/// # References (APA 7th ed.) -/// -/// Magis, D., & Raiche, G. (2012). Random generation of response patterns under computerized adaptive -/// testing with the R package catR. *Journal of Statistical Software, 48*(8), 1-31. -/// -/// -/// Muraki, E. (1992). A generalized partial credit model: Application of an EM algorithm. -/// *Applied Psychological Measurement, 16*(2), 159-176. -/// -/// Samejima, F. (1969). Estimation of latent ability using a response pattern of graded scores. -/// *Psychometrika, 34*(S1), 1-97. -/// -/// Warm, T. A. (1989). Weighted likelihood estimation of ability in item response theory. -/// *Psychometrika, 54*(3), 427-450. -#[allow(clippy::too_many_arguments)] -pub fn score_wle_poly( - y: &[usize], - observed: Option<&[bool]>, - n_persons: usize, - n_items: usize, - n_cat: usize, - slope: &[f64], - cat_params: &[f64], - model: PolyModel, - theta_bound: f64, - tol: f64, -) -> Result { - if n_cat < 2 { - return Err("n_cat must be >= 2".into()); - } - if n_items == 0 { - return Err("need at least one item".into()); - } - let cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != cells { - return Err("y must have length n_persons * n_items".into()); - } - if let Some(o) = observed { - if o.len() != cells { - return Err("observed must have length n_persons * n_items".into()); - } - } - let n_par = - crate::checked_mul_usize(n_items, n_cat - 1, "n_items * (n_cat - 1) overflows usize")?; - if slope.len() != n_items || cat_params.len() != n_par { - return Err("slope/cat_params sizes inconsistent with n_items/n_cat".into()); - } - if slope - .iter() - .chain(cat_params.iter()) - .any(|v| !v.is_finite()) - { - return Err("slope and cat_params must be finite".into()); - } - for (idx, &cat) in y.iter().enumerate() { - if observed.map_or(true, |o| o[idx]) && cat >= n_cat { - return Err("observed responses must be in 0..n_cat".into()); - } - } - if !theta_bound.is_finite() || theta_bound <= 0.0 { - return Err("theta_bound must be finite and positive".into()); - } - if !tol.is_finite() || tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - - let seen = |p: usize, i: usize| observed.map_or(true, |o| o[p * n_items + i]); - // (g, I) at theta for person p, where g = score + J/(2 I) is the Warm estimating function. - let eval = |p: usize, theta: f64| -> (f64, f64) { - let (mut score, mut info, mut jterm) = (0.0_f64, 0.0_f64, 0.0_f64); - for i in 0..n_items { - if !seen(p, i) { - continue; - } - let pars = &cat_params[i * (n_cat - 1)..(i + 1) * (n_cat - 1)]; - let (p_k, r1, r2) = poly_wle_ratios(theta, slope[i], pars, model); - score += r1[y[p * n_items + i]]; - for k in 0..p_k.len() { - info += p_k[k] * r1[k] * r1[k]; - jterm += p_k[k] * r1[k] * r2[k]; - } - } - (score + jterm / (2.0 * info.max(1e-12)), info) - }; - - const MIN_GRID: usize = 512; - const MAX_GRID: usize = 65_536; - const INTERVALS_PER_LOGIT: f64 = 4.0; - let max_abs_a = slope.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs())); - if max_abs_a == 0.0 { - return Err("at least one item must have nonzero discrimination".into()); - } - // The theta-scale of the narrowest feature of Phi is set by the total information: - // I = a^2 Var_P(k) and sd(k) <= (K-1)/2, so a mode can be as narrow as O(2/(a(K-1))) - // rather than O(1/a). Scaling the grid by (n_cat - 1) is a derived worst-case margin - // whose only cost is nodes; NO configuration in which the unscaled max|a| rule actually - // returns the wrong global mode was reproduced when this was reviewed (0 misses in 400 - // steep GPCM draws, K = 5, a ~ U(4,12), theta_bound = 8, against a 262,144-node reference). - let required_grid = - (2.0 * theta_bound * max_abs_a * INTERVALS_PER_LOGIT * (n_cat - 1) as f64).ceil(); - if !required_grid.is_finite() || required_grid > MAX_GRID as f64 { - return Err(format!( - "theta_bound and item discrimination require more than {MAX_GRID} WLE grid intervals" - )); - } - let grid = (required_grid as usize).max(MIN_GRID); - let h = 2.0 * (theta_bound / grid as f64); - let grid_theta = |k: usize| theta_bound * (2.0 * k as f64 / grid as f64 - 1.0); - let mut gvals = vec![0.0f64; grid + 1]; - let mut out = WleScores { - theta: vec![0.0; n_persons], - se: vec![0.0; n_persons], - boundary: vec![false; n_persons], - }; - for p in 0..n_persons { - if (0..n_items).all(|i| !seen(p, i)) { - out.theta[p] = f64::NAN; - out.se[p] = f64::NAN; - out.boundary[p] = true; - continue; - } - for (k, gval) in gvals.iter_mut().enumerate() { - *gval = finite_wle_value( - eval(p, grid_theta(k)).0, - format!("non-finite WLE estimating function for person {p}"), - )?; - } - let (mut phi, mut best_phi, mut best_k) = (0.0f64, 0.0f64, 0usize); - for k in 1..=grid { - phi += 0.5 * (gvals[k - 1] + gvals[k]) * h; - if phi > best_phi { - best_phi = phi; - best_k = k; - } - } - let theta_hat = if best_k == 0 || best_k == grid { - out.boundary[p] = true; - grid_theta(best_k) - } else { - let mut evaluate = |theta| eval(p, theta).0; - refine_wle_root( - grid_theta(best_k - 1), - grid_theta(best_k + 1), - tol, - &mut evaluate, - ) - .map_err(|reason| format!("{reason} for person {p}"))? - }; - out.theta[p] = theta_hat; - let info = eval(p, theta_hat).1; - out.se[p] = if info > 1e-12 { - (1.0 / info).sqrt() - } else { - f64::NAN - }; - } - Ok(out) -} - -/// One step of adaptive EAP testing: score the responses so far by EAP, pick -/// the trait dimension with the largest posterior SD, and return the -/// unadministered items of that dimension ranked by information at the current -/// EAP point. Bock and Mislevy (1982) support the noniterative EAP scoring, and -/// Wang et al. (2010) describe multidimensional CAT with information-based item -/// selection. Choosing the largest-posterior-SD dimension is a repository -/// policy, not a procedure prescribed by either source. -/// -/// # References -/// -/// Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability in -/// a microcomputer environment. *Applied Psychological Measurement, 6*(4), -/// 431–444. -/// -/// Wang, H.-P., Kuo, B.-C., & Chao, R.-C. (2010). A multidimensional -/// computerized adaptive testing system for enhancing the Chinese as second -/// language proficiency test. In H. Fujita & J. Sasaki (Eds.), *Selected topics -/// in education and educational technology* (pp. 245–252). WSEAS Press. -pub struct CatStep { - pub theta_eap: Vec, - pub theta_sd: Vec, - pub xi_eap: Vec, - pub target_dim: usize, - /// Unadministered item indices, best first. - pub ranked_items: Vec, - /// Information of each ranked item at the current EAP point. - pub ranked_info: Vec, -} - -#[allow(clippy::too_many_arguments)] -pub fn cat_next_item( - bank: &ItemBank<'_>, - y: &[f64], - administered: &[bool], - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, -) -> Result { - cat_next_item_device( - bank, - y, - administered, - prior, - q_theta, - xi_rule, - crate::Device::Auto, - ) -} - -/// One CAT step with an explicit device for both EAP scoring and item -/// information. `Auto` is GPU-first; CPU remains the f64 fallback. -#[allow(clippy::too_many_arguments)] -pub fn cat_next_item_device( - bank: &ItemBank<'_>, - y: &[f64], - administered: &[bool], - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, - device: crate::Device, -) -> Result { - let n_items = validate_bank(bank)?; - if y.len() != n_items || administered.len() != n_items { - return Err("y and administered must have length n_items".into()); - } - if y.iter() - .zip(administered) - .any(|(&value, &is_administered)| { - is_administered && (!value.is_finite() || (value != 0.0 && value != 1.0)) - }) - { - return Err("administered responses must be 0 or 1".into()); - } - let scores = score_eap_device(bank, y, administered, 1, prior, q_theta, xi_rule, device)?; - let target_dim = (0..bank.n_dims) - .max_by(|&a, &b| { - scores.theta_sd[a] - .partial_cmp(&scores.theta_sd[b]) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .unwrap_or(0); - let (item_info, _) = - bank_information_device(bank, &scores.theta_eap, &scores.xi_eap, 1, device)?; - let mut candidates: Vec = (0..n_items) - .filter(|&i| !administered[i] && bank.factor_id[i] == target_dim) - .collect(); - if candidates.is_empty() { - candidates = (0..n_items).filter(|&i| !administered[i]).collect(); - } - candidates.sort_by(|&a, &b| { - item_info[b] - .partial_cmp(&item_info[a]) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let ranked_info: Vec = candidates.iter().map(|&i| item_info[i]).collect(); - Ok(CatStep { - theta_eap: scores.theta_eap, - theta_sd: scores.theta_sd, - xi_eap: scores.xi_eap, - target_dim, - ranked_items: candidates, - ranked_info, - }) -} - -/// Plausible values (Marsman et al., 2016): seeded categorical draws of `theta` -/// from each person posterior over the scoring grid, for secondary analyses -/// that need the ability distribution rather than point EAPs. The fixed item -/// bank and discrete quadrature-grid sampler are repository implementation -/// choices; this routine does not propagate item-parameter uncertainty. -/// Returns row-major `n_persons x n_draws x n_dims`. -/// `Device::Auto` prefers the Rust wgpu posterior-reduction and sampling -/// kernels. The f64 CPU fallback uses a fixed number of contiguous person -/// shards, bounded by available hardware parallelism, to avoid task-pool -/// oversubscription and unnecessary context switching. -/// -/// # References -/// -/// Marsman, M., Maris, G., Bechger, T., & Glas, C. (2016). What can we learn -/// from plausible values? *Psychometrika, 81*(2), 274–289. -/// -#[allow(clippy::too_many_arguments)] -pub fn plausible_values( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, - n_draws: usize, - seed: u64, -) -> Result, String> { - plausible_values_device( - bank, - y, - observed, - n_persons, - prior, - q_theta, - xi_rule, - n_draws, - seed, - crate::Device::Auto, - ) -} - -/// Plausible values with explicit Rust GPU/CPU dispatch. The same seeded -/// uniform stream is supplied to both implementations; `Device::Gpu` warns and -/// uses the parallel f64 CPU reference if GPU execution is unavailable or its -/// f32 result fails validation. -#[allow(clippy::too_many_arguments)] -pub fn plausible_values_device( - bank: &ItemBank<'_>, - y: &[f64], - observed: &[bool], - n_persons: usize, - prior: &PriorSpec, - q_theta: usize, - xi_rule: XiRule, - n_draws: usize, - seed: u64, - device: crate::Device, -) -> Result, String> { - let n_items = validate_bank(bank)?; - validate_prior(prior, bank.n_dims)?; - validate_dichotomous_responses(y, observed, n_persons, n_items)?; - if n_draws == 0 { - return Err("n_draws must be >= 1".into()); - } - let person_draws = n_persons - .checked_mul(n_draws) - .ok_or_else(|| "n_persons * n_draws overflows usize".to_string())?; - person_draws - .checked_mul(bank.n_dims) - .ok_or_else(|| "plausible-values output length overflows usize".to_string())?; - let random_width = bank - .n_dims - .checked_add(1) - .ok_or_else(|| "n_dims + 1 overflows usize".to_string())?; - let random_count = person_draws - .checked_mul(random_width) - .ok_or_else(|| "plausible-values random stream length overflows usize".to_string())?; - let grids = scoring_grids(bank, q_theta, xi_rule)?; - let ctx = prior_contexts(prior); - let config = bank_model_config(bank, n_persons, n_items); - let tables = build_tables( - bank.alpha, - bank.b, - bank.zeta, - bank.tau, - &config, - bank.factor_id, - &ctx, - &grids, - ); - let resp = index_responses(y, observed, n_persons, n_items); - let random_uniforms = plausible_uniforms(seed, random_count); - Ok(dispatch_plausible_values_device( - bank, - prior, - &grids, - &tables, - &resp, - n_persons, - n_items, - n_draws, - &random_uniforms, - device, - )) -} - -fn plausible_uniforms(seed: u64, count: usize) -> Vec { - let mut state = seed.max(1); - (0..count) - .map(|_| { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }) - .collect() -} - -#[allow(clippy::too_many_arguments)] -fn plausible_values_cpu_chunk( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_items: usize, - n_draws: usize, - random_uniforms: &[f64], - start_person: usize, - out: &mut [f64], -) { - let cell = grids.q_t * grids.n_x; - let mut l_buf = vec![0.0_f64; bank.n_dims * cell]; - let mut log_zdx = vec![0.0_f64; bank.n_dims * grids.n_x]; - let out_per_person = n_draws * bank.n_dims; - let random_per_person = n_draws * (bank.n_dims + 1); - for local_person in 0..out.len() / out_per_person { - let p = start_person + local_person; - let lp = person_pass( - p, - 0, - tables, - resp, - bank.factor_id, - bank.n_dims, - n_items, - grids, - &mut l_buf, - &mut log_zdx, - ); - let mut px = vec![0.0_f64; grids.n_x]; - for x in 0..grids.n_x { - let mut lx = grids.x_logw[x] - lp; - for d in 0..bank.n_dims { - lx += log_zdx[d * grids.n_x + x]; - } - px[x] = lx.exp(); - } - for draw in 0..n_draws { - let random_base = p * random_per_person + draw * (bank.n_dims + 1); - let ux = random_uniforms[random_base]; - let mut acc = 0.0; - let mut x_sel = grids.n_x - 1; - for (x, &w) in px.iter().enumerate() { - acc += w; - if ux <= acc { - x_sel = x; - break; - } - } - for d in 0..bank.n_dims { - let ut = random_uniforms[random_base + 1 + d]; - let mut acc_t = 0.0; - let mut t_sel = grids.q_t - 1; - for t in 0..grids.q_t { - let pt = (grids.t_logw[t] + l_buf[d * cell + t * grids.n_x + x_sel] - - log_zdx[d * grids.n_x + x_sel]) - .exp(); - acc_t += pt; - if ut <= acc_t { - t_sel = t; - break; - } - } - out[(local_person * n_draws + draw) * bank.n_dims + d] = - prior.mean[d] + prior.sd[d] * grids.t_nodes[t_sel]; - } - } - } -} - -#[allow(clippy::too_many_arguments)] -fn plausible_values_cpu_reduce( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_persons: usize, - n_items: usize, - n_draws: usize, - random_uniforms: &[f64], -) -> Vec { - let out_per_person = n_draws * bank.n_dims; - let mut out = vec![0.0_f64; n_persons * out_per_person]; - if n_persons == 0 { - return out; - } - let worker_count = std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(1) - .min(n_persons); - if worker_count == 1 { - plausible_values_cpu_chunk( - bank, - prior, - grids, - tables, - resp, - n_items, - n_draws, - random_uniforms, - 0, - &mut out, - ); - return out; - } - let persons_per_worker = n_persons.div_ceil(worker_count); - let chunk_len = persons_per_worker * out_per_person; - std::thread::scope(|scope| { - for (worker, chunk) in out.chunks_mut(chunk_len).enumerate() { - let start_person = worker * persons_per_worker; - scope.spawn(move || { - plausible_values_cpu_chunk( - bank, - prior, - grids, - tables, - resp, - n_items, - n_draws, - random_uniforms, - start_person, - chunk, - ); - }); - } - }); - out -} - -#[cfg(all(feature = "gpu", not(coverage)))] -#[allow(clippy::too_many_arguments)] -fn dispatch_plausible_values_device( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_persons: usize, - n_items: usize, - n_draws: usize, - random_uniforms: &[f64], - device: crate::Device, -) -> Vec { - if device != crate::Device::Cpu { - if let Some(values) = try_plausible_values_gpu( - bank, - prior, - grids, - tables, - resp, - n_persons, - n_items, - n_draws, - random_uniforms, - ) { - return values; - } - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU plausible values requested but no usable GPU adapter was found, the problem exceeds GPU bounds, or f32 validation failed; falling back to the parallel CPU implementation." - ); - } - } - plausible_values_cpu_reduce( - bank, - prior, - grids, - tables, - resp, - n_persons, - n_items, - n_draws, - random_uniforms, - ) -} - -#[cfg(any(not(feature = "gpu"), coverage))] -#[allow(clippy::too_many_arguments)] -fn dispatch_plausible_values_device( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_persons: usize, - n_items: usize, - n_draws: usize, - random_uniforms: &[f64], - device: crate::Device, -) -> Vec { - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU plausible values requested but this build has no GPU support; falling back to the parallel CPU implementation." - ); - } - plausible_values_cpu_reduce( - bank, - prior, - grids, - tables, - resp, - n_persons, - n_items, - n_draws, - random_uniforms, - ) -} - -#[cfg(all(feature = "gpu", not(coverage)))] -#[allow(clippy::too_many_arguments)] -fn try_plausible_values_gpu( - bank: &ItemBank<'_>, - prior: &PriorSpec, - grids: &crate::marginal::Grids, - tables: &crate::marginal::Tables, - resp: &crate::marginal::ResponseIndex, - n_persons: usize, - n_items: usize, - n_draws: usize, - random_uniforms: &[f64], -) -> Option> { - if n_items > u32::MAX as usize { - return None; - } - let mut pos_off = Vec::with_capacity(n_persons + 1); - let mut pos_items = Vec::new(); - pos_off.push(0); - for items in &resp.pos { - pos_items.extend( - items - .iter() - .map(|&item| u32::try_from(item).ok()) - .collect::>>()?, - ); - pos_off.push(u32::try_from(pos_items.len()).ok()?); - } - let mut miss_off = Vec::with_capacity(n_persons + 1); - let mut miss_items = Vec::new(); - miss_off.push(0); - for items in &resp.miss { - miss_items.extend( - items - .iter() - .map(|&item| u32::try_from(item).ok()) - .collect::>>()?, - ); - miss_off.push(u32::try_from(miss_items.len()).ok()?); - } - crate::gpu_plausible::plausible_values_gpu(&crate::gpu_plausible::GpuPlausibleInputs { - n_persons, - n_items, - n_dims: bank.n_dims, - q_t: grids.q_t, - n_x: grids.n_x, - n_draws, - logp0: &tables.logp0, - logp1: &tables.logp1, - c0: &tables.c0, - t_logw: &grids.t_logw, - x_logw: &grids.x_logw, - t_nodes: &grids.t_nodes, - prior_mean: &prior.mean, - prior_sd: &prior.sd, - factor_id: bank.factor_id, - pos_off: &pos_off, - pos_items: &pos_items, - miss_off: &miss_off, - miss_items: &miss_items, - random_uniforms, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/scoring_cat_pv_tests.rs"] -mod cat_pv_tests; - -/// Empirical (marginal) reliability of the EAP scale scores per trait -/// dimension: `rho_d = Var(theta_hat_d) / (Var(theta_hat_d) + mean(SE_d^2))`. -/// This follows the posterior variance decomposition in Bechger et al. (2003). -/// Because reliability does not establish model fit (Stanley & Edwards, 2016), -/// report it alongside the fit statistics, never instead of them. -/// -/// # References -/// -/// Bechger, T. M., Maris, G., Verstralen, H. H. F. M., & Béguin, A. A. (2003). -/// Using classical test theory in combination with item response theory. -/// *Applied Psychological Measurement, 27*(5), 319–334. -/// -/// -/// Stanley, L. M., & Edwards, M. C. (2016). Reliability and model fit. -/// *Educational and Psychological Measurement, 76*(6), 976–985. -/// -pub fn empirical_reliability( - theta_eap: &[f64], - theta_sd: &[f64], - n_persons: usize, - n_dims: usize, -) -> Result, String> { - empirical_reliability_device(theta_eap, theta_sd, n_persons, n_dims, crate::Device::Auto) -} - -/// Empirical reliability with an explicit execution device. `Auto`/`Gpu` -/// prefers the Rust wgpu f32 reduction and falls back to the fixed-shard, -/// parallel Rust f64 reduction. `Gpu` warns when a usable accelerator is not -/// available; `Cpu` is the hardware-independent numerical reference. -pub fn empirical_reliability_device( - theta_eap: &[f64], - theta_sd: &[f64], - n_persons: usize, - n_dims: usize, - device: crate::Device, -) -> Result, String> { - let expected_len = crate::checked_mul_usize( - n_persons, - n_dims, - "n_persons * n_dims overflows for empirical reliability", - )?; - if theta_eap.len() != expected_len || theta_sd.len() != theta_eap.len() { - return Err("theta_eap/theta_sd must be n_persons x n_dims".into()); - } - if n_persons < 2 { - return Err("empirical reliability needs n_persons >= 2".into()); - } - if n_dims == 0 { - return Err("empirical reliability needs n_dims >= 1".into()); - } - if theta_eap.iter().any(|value| !value.is_finite()) { - return Err("theta_eap values must be finite".into()); - } - if theta_sd - .iter() - .any(|&value| !value.is_finite() || value < 0.0) - { - return Err("theta_sd values must be finite and non-negative".into()); - } - Ok(dispatch_empirical_reliability_device( - theta_eap, theta_sd, n_persons, n_dims, device, - )) -} - -fn empirical_reliability_for_dimension( - theta_eap: &[f64], - theta_sd: &[f64], - n_persons: usize, - n_dims: usize, - dimension: usize, -) -> f64 { - let n = n_persons as f64; - let mean = (0..n_persons) - .map(|person| theta_eap[person * n_dims + dimension]) - .sum::() - / n; - let variance = (0..n_persons) - .map(|person| { - let centered = theta_eap[person * n_dims + dimension] - mean; - centered * centered - }) - .sum::() - / n; - let mse = (0..n_persons) - .map(|person| { - let sd = theta_sd[person * n_dims + dimension]; - sd * sd - }) - .sum::() - / n; - let denominator = variance + mse; - if denominator > 0.0 { - variance / denominator - } else { - f64::NAN - } -} - -fn empirical_reliability_cpu_reduce( - theta_eap: &[f64], - theta_sd: &[f64], - n_persons: usize, - n_dims: usize, -) -> Vec { - let worker_count = std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(1) - .min(n_dims); - if worker_count <= 1 { - return (0..n_dims) - .map(|dimension| { - empirical_reliability_for_dimension( - theta_eap, theta_sd, n_persons, n_dims, dimension, - ) - }) - .collect(); - } - let dims_per_worker = n_dims.div_ceil(worker_count); - std::thread::scope(|scope| { - let mut handles = Vec::with_capacity(worker_count); - for start in (0..n_dims).step_by(dims_per_worker) { - let end = (start + dims_per_worker).min(n_dims); - handles.push(scope.spawn(move || { - (start..end) - .map(|dimension| { - empirical_reliability_for_dimension( - theta_eap, theta_sd, n_persons, n_dims, dimension, - ) - }) - .collect::>() - })); - } - handles - .into_iter() - .flat_map(|handle| handle.join().expect("reliability CPU worker panicked")) - .collect() - }) -} - -#[cfg(all(feature = "gpu", not(coverage)))] -fn dispatch_empirical_reliability_device( - theta_eap: &[f64], - theta_sd: &[f64], - n_persons: usize, - n_dims: usize, - device: crate::Device, -) -> Vec { - if device != crate::Device::Cpu { - if let Some(output) = - crate::gpu_scoring::empirical_reliability_gpu(theta_eap, theta_sd, n_persons, n_dims) - { - return output; - } - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU empirical reliability requested but no usable GPU adapter was found, the request exceeds GPU bounds, or f32 arithmetic produced an invalid result; falling back to the CPU implementation." - ); - } - } - empirical_reliability_cpu_reduce(theta_eap, theta_sd, n_persons, n_dims) -} - -#[cfg(any(not(feature = "gpu"), coverage))] -fn dispatch_empirical_reliability_device( - theta_eap: &[f64], - theta_sd: &[f64], - n_persons: usize, - n_dims: usize, - device: crate::Device, -) -> Vec { - if device == crate::Device::Gpu { - eprintln!( - "fast-mlsirm: GPU empirical reliability requested but this build has no GPU support; falling back to the CPU implementation." - ); - } - empirical_reliability_cpu_reduce(theta_eap, theta_sd, n_persons, n_dims) -} - -#[cfg(test)] -#[path = "../../../tests/unit/scoring_reliability_tests.rs"] -mod reliability_tests; - -#[cfg(test)] -#[path = "../../../tests/unit/scoring_validate_branch_tests.rs"] -mod validate_branch_tests; - -#[cfg(all(test, feature = "gpu", not(coverage)))] -#[path = "../../../tests/unit/scoring_gpu_score_tests.rs"] -mod gpu_score_tests; - -#[cfg(test)] -#[path = "../../../tests/unit/scoring_wle_tests.rs"] -mod wle_tests; - -#[cfg(test)] -#[path = "../../../tests/unit/scoring_wle_poly_tests.rs"] -mod wle_poly_tests; diff --git a/crates/mlsirm-core/src/subscores.rs b/crates/mlsirm-core/src/subscores.rs deleted file mode 100644 index 7ae13bd5a..000000000 --- a/crates/mlsirm-core/src/subscores.rs +++ /dev/null @@ -1,379 +0,0 @@ -//! Haberman (2008) subscore added-value analysis via the proportional -//! reduction in mean squared error (PRMSE). -//! -//! Given a persons x items matrix of scored responses partitioned into `K` -//! disjoint, exhaustive subscales, this module computes for each subscale the -//! three Haberman estimators of the true subscore `s_t` and their PRMSEs: -//! -//! ```text -//! s_hat_s = E(s) + rho_s (s - E(s)) PRMSE_s = rho_s -//! s_hat_x = E(s) + sqrt(PRMSE_x) (sigma_t / sigma_x)(x - E(x)) -//! PRMSE_x = rho^2(s_t, x_t) rho_x -//! s_hat_sx = E(s) + beta (s - E(s)) + gamma (x - E(x)) PRMSE_sx = rho_s + tau^2 (1 - r^2) -//! ``` -//! -//! with `rho_s`/`rho_x` the Cronbach-alpha reliabilities of the subscale and -//! the total test, `r = corr(s, x)`, -//! `rho^2(s_t, x_t) = cov_k^2 / (V(s_t) V(x_t))` where `cov_k` is the row sum -//! of the true-subscore covariance matrix over the `K` subscore columns -//! (observed covariances off the diagonal, `alpha * observed variance` on the -//! diagonal; the total-score column is EXCLUDED), and -//! -//! ```text -//! tau = (sqrt(rho_x) sqrt(rho^2(s_t, x_t)) - r sqrt(rho_s)) / (1 - r^2) -//! beta = sqrt(rho_s) (sqrt(rho_s) - r tau) -//! gamma = sqrt(rho_s) tau (sigma_s / sigma_x) -//! ``` -//! -//! Added-value decisions: a subscore has added value iff -//! `PRMSE_s > PRMSE_x` (Haberman's rule); an augmented subscore has added -//! value iff `PRMSE_sx > max(PRMSE_s, PRMSE_x) + 0.01`, which is the -//! operational convention of Sinharay (2010) — the CRAN `subscore` package's -//! `CTTsub` uses a different relative rule (`0.1 * (1 - max)`) that is NOT -//! implemented here. -//! -//! # Verified sources -//! -//! Formulas were verified against (a) the Appendix of Sinharay (2010, ETS -//! RR-10-16), which reproduces the Haberman (2008) methodology, and (b) the -//! CRAN `subscore` package R source (`subscore.s.r`, `subscore.x.R`, -//! `subscore.sx.R`, `data.prep.R`) read line by line; the R code was used to -//! disambiguate Greek symbols lost in PDF extraction. Haberman (2008) itself -//! and Wainer et al. (2001) were NOT read (paywalled) and are cited only as -//! cited in Sinharay (2010). -//! -//! # Divergences from the R package (deliberate) -//! -//! - The partition is validated (every item in exactly one subscale, each -//! subscale with >= 2 items); CRAN `data.prep()` silently allows totals -//! that are not the union of the subscales. -//! - Degenerate inputs are rejected instead of propagating NaN or emitting a -//! warning: any Cronbach alpha outside `(0, 1]`, any non-positive observed -//! variance, `|corr(s_k, x)| >= 1 - 1e-12`, non-finite moments, or a -//! computed PRMSE outside `[0, 1 + 1e-9]`. -//! - `s_hat_x` uses the nonnegative root `sqrt(PRMSE_x)` exactly as the R -//! code does, even when the signed correlation form of the Sinharay -//! appendix would be negative (i.e. when `cov(s_t, x_t) < 0`, which the -//! guards do NOT rule out); this follows CRAN's convention. -//! - Missing data are not supported (the R code uses `na.rm`); v1 requires -//! complete data. -//! -//! All moments are unbiased (`n - 1`), matching R's `var`/`cov`/`cor`. -//! -//! In LLM-as-a-Judge item-quality management this decides whether per-domain -//! judge subscores carry diagnostic information beyond the overall score, or -//! whether reporting them would be statistically misleading. -//! -//! # References (APA 7th ed.) -//! -//! Haberman, S. J. (2008). When can subscores have value? *Journal of -//! Educational and Behavioral Statistics, 33*(2), 204-229. -//! https://doi.org/10.3102/1076998607302636 (as cited in Sinharay, 2010) -//! -//! Sinharay, S. (2010). *When can subscores be expected to have added value? -//! Results from operational and simulated data* (ETS Research Rep. No. -//! RR-10-16). Educational Testing Service. -//! -//! Wainer, H., Vevea, J. L., Camacho, F., Reeve, B. B., Rosa, K., & Nelson, -//! L. (2001). Augmented scores — "borrowing strength" to compute scores based -//! on small numbers of items. In D. Thissen & H. Wainer (Eds.), *Test -//! scoring* (pp. 343-387). Lawrence Erlbaum. (as cited in Sinharay, 2010) - -/// Result of the Haberman subscore added-value analysis. All vectors are -/// indexed by subscale `k = 0..K`; person-level estimator matrices are -/// `n_persons x K` in row-major nested `Vec`s. -#[derive(Debug, Clone)] -pub struct SubscoreResult { - /// Cronbach alpha of each subscale ( = PRMSE_s). - pub alpha: Vec, - /// Cronbach alpha of the total test. - pub alpha_total: f64, - /// `(K+1) x (K+1)` correlation matrix of `(s_1..s_K, x)`, total last. - pub corr: Vec>, - /// `K x K` disattenuated subscore correlations - /// `corr_kl / sqrt(alpha_k alpha_l)`; diagonal is NaN. - pub disattenuated_corr: Vec>, - /// PRMSE of the observed-subscore estimator ( = subscale reliability). - pub prmse_s: Vec, - /// PRMSE of the total-score estimator `rho^2(s_t, x_t) rho_x`. - pub prmse_x: Vec, - /// PRMSE of the augmented estimator `rho_s + tau^2 (1 - r^2)`. - pub prmse_sx: Vec, - /// Regression helpers for the augmented estimator. - pub tau: Vec, - pub beta: Vec, - pub gamma: Vec, - /// `PRMSE_s > PRMSE_x` (Haberman's added-value rule). - pub added_value_s: Vec, - /// `PRMSE_sx > max(PRMSE_s, PRMSE_x) + 0.01` (Sinharay 2010 convention). - pub added_value_sx: Vec, - /// Observed subscores `s_k` per person (`n x K`). - pub observed: Vec>, - /// Observed total score per person. - pub total: Vec, - /// Estimated true subscores from the observed subscore (`n x K`). - pub subscore_s: Vec>, - /// Estimated true subscores from the observed total (`n x K`). - pub subscore_x: Vec>, - /// Estimated true subscores from both (`n x K`). - pub subscore_sx: Vec>, -} - -/// Unbiased sample variance (`n - 1`); caller guarantees `v.len() >= 2`. -fn var(v: &[f64]) -> f64 { - let n = v.len() as f64; - let m = v.iter().sum::() / n; - v.iter().map(|&a| (a - m) * (a - m)).sum::() / (n - 1.0) -} - -/// Unbiased sample covariance (`n - 1`). -fn cov(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - let (ma, mb) = ( - a.iter().sum::() / n, - b.iter().sum::() / n, - ); - a.iter() - .zip(b) - .map(|(&x, &y)| (x - ma) * (y - mb)) - .sum::() - / (n - 1.0) -} - -/// Cronbach alpha `m/(m-1) (1 - sum_j var(y_j) / var(sum_j y_j))` over the -/// given item columns. Same unbiased convention in numerator and denominator. -fn cronbach_alpha(x: &[Vec], items: &[usize]) -> f64 { - let m = items.len() as f64; - let totals: Vec = x - .iter() - .map(|row| items.iter().map(|&j| row[j]).sum()) - .collect(); - let item_var_sum: f64 = items - .iter() - .map(|&j| { - let col: Vec = x.iter().map(|row| row[j]).collect(); - var(&col) - }) - .sum(); - let tv = var(&totals); - m / (m - 1.0) * (1.0 - item_var_sum / tv) -} - -/// Haberman (2008) subscore added-value analysis (see module docs). -/// -/// `x` is a complete `n_persons x n_items` matrix of finite scored responses; -/// `groups[j]` in `0..K` assigns item `j` to a subscale. The partition must be -/// exhaustive with at least 2 items per subscale, `n_persons >= 3`, and -/// `K >= 2`. Returns an error on any validation or degeneracy failure (see -/// the module-level guard list). -pub fn subscores(x: &[Vec], groups: &[usize]) -> Result { - let n = x.len(); - if n < 3 { - return Err("subscores requires at least 3 persons".into()); - } - let n_items = x[0].len(); - if groups.len() != n_items { - return Err("groups must assign every item to a subscale".into()); - } - for row in x { - if row.len() != n_items { - return Err("ragged response matrix".into()); - } - if row.iter().any(|v| !v.is_finite()) { - return Err("responses must be complete and finite".into()); - } - } - let k_count = match groups.iter().max() { - Some(&g) => g + 1, - None => return Err("groups must assign every item to a subscale".into()), - }; - if k_count < 2 { - return Err("at least 2 subscales are required".into()); - } - // Bound BEFORE allocating items_of: a hostile sparse index (e.g. 10^9) - // would otherwise drive a huge allocation. K <= n_items/2 given >= 2 - // items per subscale. - if k_count > n_items / 2 { - return Err("subscale indices must be dense: 0..K with K <= n_items / 2".into()); - } - let mut items_of: Vec> = vec![Vec::new(); k_count]; - for (j, &g) in groups.iter().enumerate() { - items_of[g].push(j); - } - if items_of.iter().any(|v| v.len() < 2) { - return Err("every subscale needs at least 2 items".into()); - } - - // Observed subscores and total (the partition makes x = sum_k s_k). - let observed: Vec> = x - .iter() - .map(|row| { - items_of - .iter() - .map(|items| items.iter().map(|&j| row[j]).sum()) - .collect() - }) - .collect(); - let total: Vec = observed.iter().map(|s| s.iter().sum()).collect(); - - // Columns of the (K+1)-variate score matrix, total last. - let mut cols: Vec> = (0..k_count) - .map(|k| observed.iter().map(|s| s[k]).collect()) - .collect(); - cols.push(total.clone()); - let kk = k_count + 1; - - let all_items: Vec = (0..n_items).collect(); - let mut alpha: Vec = items_of - .iter() - .map(|items| cronbach_alpha(x, items)) - .collect(); - let alpha_total = cronbach_alpha(x, &all_items); - alpha.push(alpha_total); - for (i, &a) in alpha.iter().enumerate() { - if !a.is_finite() || a <= 0.0 || a > 1.0 { - return Err(format!( - "Cronbach alpha of {} is {a:.6}, outside (0, 1]; the Haberman \ - analysis is undefined (zero variance or negatively \ - correlated items)", - if i < k_count { "a subscale" } else { "the total test" } - )); - } - } - - let var_obs: Vec = cols.iter().map(|c| var(c)).collect(); - if var_obs.iter().any(|&v| !(v.is_finite() && v > 0.0)) { - return Err("zero-variance subscore or total score".into()); - } - let mut c_obs = vec![vec![0.0f64; kk]; kk]; - for a in 0..kk { - for b in a..kk { - let v = cov(&cols[a], &cols[b]); - c_obs[a][b] = v; - c_obs[b][a] = v; - } - } - let corr: Vec> = (0..kk) - .map(|a| { - (0..kk) - .map(|b| c_obs[a][b] / (var_obs[a] * var_obs[b]).sqrt()) - .collect() - }) - .collect(); - for k in 0..k_count { - if corr[k][k_count].abs() >= 1.0 - 1e-12 { - return Err( - "a subscore is (numerically) collinear with the total score; \ - the augmented regression is undefined" - .into(), - ); - } - } - - // True-score covariance matrix C_T: observed off-diagonals, diagonal - // alpha_k * V(s_k). cov_k sums row k over the K subscore columns ONLY - // (the total column is excluded). - let var_true: Vec = (0..kk).map(|i| var_obs[i] * alpha[i]).collect(); - let cov_rowsum: Vec = (0..k_count) - .map(|k| { - (0..k_count) - .map(|l| if l == k { var_true[k] } else { c_obs[k][l] }) - .sum() - }) - .collect(); - - let mut prmse_s = Vec::with_capacity(k_count); - let mut prmse_x = Vec::with_capacity(k_count); - let mut prmse_sx = Vec::with_capacity(k_count); - let (mut tau, mut beta, mut gamma) = ( - Vec::with_capacity(k_count), - Vec::with_capacity(k_count), - Vec::with_capacity(k_count), - ); - for k in 0..k_count { - let r_stxt = cov_rowsum[k] * cov_rowsum[k] / (var_true[k] * var_true[k_count]); - let r = corr[k][k_count]; - let t = (alpha_total.sqrt() * r_stxt.sqrt() - r * alpha[k].sqrt()) / (1.0 - r * r); - let ps = alpha[k]; - let px = r_stxt * alpha_total; - let psx = alpha[k] + t * t * (1.0 - r * r); - for (name, v) in [("PRMSE_s", ps), ("PRMSE_x", px), ("PRMSE_sx", psx)] { - if !v.is_finite() || v < 0.0 || v > 1.0 + 1e-9 { - return Err(format!( - "computed {name} = {v:.6} outside [0, 1]; the sample \ - moments are inconsistent with the CTT assumptions" - )); - } - } - prmse_s.push(ps); - prmse_x.push(px); - prmse_sx.push(psx); - tau.push(t); - beta.push(alpha[k].sqrt() * (alpha[k].sqrt() - r * t)); - gamma.push(alpha[k].sqrt() * t * (var_obs[k].sqrt() / var_obs[k_count].sqrt())); - } - - let disattenuated_corr: Vec> = (0..k_count) - .map(|a| { - (0..k_count) - .map(|b| { - if a == b { - f64::NAN - } else { - corr[a][b] / (alpha[a] * alpha[b]).sqrt() - } - }) - .collect() - }) - .collect(); - - let mean_s: Vec = (0..k_count) - .map(|k| cols[k].iter().sum::() / n as f64) - .collect(); - let mean_x = total.iter().sum::() / n as f64; - let sd_x = var_obs[k_count].sqrt(); - - let mut subscore_s = vec![vec![0.0f64; k_count]; n]; - let mut subscore_x = vec![vec![0.0f64; k_count]; n]; - let mut subscore_sx = vec![vec![0.0f64; k_count]; n]; - for p in 0..n { - for k in 0..k_count { - let ds = observed[p][k] - mean_s[k]; - let dx = total[p] - mean_x; - subscore_s[p][k] = mean_s[k] + alpha[k] * ds; - subscore_x[p][k] = - mean_s[k] + prmse_x[k].sqrt() * (var_true[k].sqrt() / sd_x) * dx; - subscore_sx[p][k] = mean_s[k] + beta[k] * ds + gamma[k] * dx; - } - } - - let added_value_s: Vec = (0..k_count).map(|k| prmse_s[k] > prmse_x[k]).collect(); - let added_value_sx: Vec = (0..k_count) - .map(|k| prmse_sx[k] > prmse_s[k].max(prmse_x[k]) + 0.01) - .collect(); - - alpha.pop(); - Ok(SubscoreResult { - alpha, - alpha_total, - corr, - disattenuated_corr, - prmse_s, - prmse_x, - prmse_sx, - tau, - beta, - gamma, - added_value_s, - added_value_sx, - observed, - total, - subscore_s, - subscore_x, - subscore_sx, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/subscores_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/testlet.rs b/crates/mlsirm-core/src/testlet.rs deleted file mode 100644 index 405d6b5c9..000000000 --- a/crates/mlsirm-core/src/testlet.rs +++ /dev/null @@ -1,716 +0,0 @@ -//! Testlet response model (Bradlow, Wainer, & Wang, 1999; Wang, Bradlow, & Wainer, -//! 2002): a marginal-ML estimator for the local dependence induced when items share a -//! common stimulus (a reading passage, a scenario). Items are partitioned into disjoint -//! *testlets*; each item `i` in testlet `d(i)` carries a person-specific random effect -//! `gamma_{j,d(i)} ~ N(0, sigma^2_d)`, independent across testlets and of `theta_j`: -//! -//! ```text -//! P(X_ij = 1 | theta_j, gamma) = sigmoid(a_i * (theta_j - b_i - gamma_{j,d(i)})) -//! = sigmoid(a_i*theta_j + beta_i - a_i*gamma_{j,d(i)}) -//! ``` -//! -//! with `beta_i = -a_i*b_i` (Rasch fixes `a_i = 1`). The **testlet variance `sigma^2_d` -//! is the estimand of interest**: a large value flags strong within-testlet local -//! dependence (the bundle measures a passage-specific nuisance beyond `theta`); all -//! `sigma^2_d = 0` is exactly the conditional-independence 2PL/Rasch model. -//! -//! Estimation is marginal ML, integrating out `theta` AND the `D` testlet effects. -//! Because each item depends on `theta` and exactly ONE `gamma` and testlets are -//! disjoint, the `D`-dimensional `gamma` integral FACTORS per testlet given `theta`: -//! the marginal likelihood is a `theta`-outer / per-testlet-`gamma`-inner nested -//! Gauss-Hermite quadrature at per-person cost `Q_theta * Q_gamma * n_items` -//! (independent of `D`) — NOT a `(D+1)`-dimensional tensor grid. This is why a -//! dedicated estimator, rather than the general free-loading bifactor -//! ([`crate::ModelType::Bifac2plm`]), is used: the bifactor cannot report the per- -//! testlet variance and its `D`-dimensional secondary-factor grid is exponential. -//! -//! Identification: `theta ~ N(0,1)` pins the trait metric (location -> `beta_i`, scale -//! -> `a_i`); `gamma` is centered (mean absorbed into `beta_i`); only the magnitude -//! `sigma^2_d` is identified, and only for testlets with >= 2 items (a singleton -//! testlet has no within-bundle pair to reveal excess correlation, so its variance is -//! pinned to 0 rather than left to report spurious dependence). -//! -//! Deferred (non-goals): polytomous testlets, 3PL guessing, covariate/second-order -//! structure (the free-loading bifactor already covers that), per-person `gamma` EAP -//! output, GPU offload, and the original paper's fully-Bayesian probit + Gibbs -//! estimator (this is the standard logit + marginal-ML reduction; Wainer, Bradlow, & -//! Wang, 2007). -//! -//! References (APA 7th ed.): -//! - Bradlow, E. T., Wainer, H., & Wang, X. (1999). A Bayesian random effects model -//! for testlets. *Psychometrika, 64*(2), 153-168. -//! -//! - Wang, X., Bradlow, E. T., & Wainer, H. (2002). A general Bayesian model for -//! testlets: Theory and applications. *Applied Psychological Measurement, 26*(1), -//! 109-128. -//! - Bock, R. D., & Aitkin, M. (1981). Marginal maximum likelihood estimation of item -//! parameters. *Psychometrika, 46*(4), 443-459. - -use crate::mmle::{log_sigmoid, sigmoid_stable, GH_NODES, GH_WEIGHTS}; -use crate::quadrature::{gh_rule, SUPPORTED_Q}; - -/// Upper bound on caller-controlled EM iterations, shared with the Python API. -const TESTLET_MAX_ITER: usize = 100_000; - -/// Within-testlet response model: `Rasch` fixes `a_i = 1`; `TwoPl` frees `a_i`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TestletModel { - Rasch, - TwoPl, -} - -/// EM configuration for the testlet estimator. -#[derive(Clone, Copy, Debug)] -pub struct TestletConfig { - pub max_iter: usize, - /// Convergence tolerance on `|delta loglik|`; `0.0` is permitted (runs the full - /// `max_iter`) — needed for the exact `sigma -> 0` reduction anchor. - pub tol: f64, - /// Inner `gamma` Gauss-Hermite nodes; must be one of `SUPPORTED_Q` (7/11/15/21/31/41). - pub q_gamma: usize, - pub ridge_a: f64, - pub ridge_b: f64, - pub newton_iter: usize, - /// Estimate the testlet variances; `false` pins them at `init_sigma2` - /// (`init_sigma2 = 0` then gives the exact 2PL/Rasch reduction). - pub estimate_sigma: bool, - /// Initial (and, if `!estimate_sigma`, fixed) testlet variance for multi-item testlets. - pub init_sigma2: f64, -} - -impl Default for TestletConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - q_gamma: 21, - ridge_a: 1e-3, - ridge_b: 1e-3, - newton_iter: 25, - estimate_sigma: true, - init_sigma2: 0.5, - } - } -} - -/// Fitted testlet model. -#[derive(Clone, Debug)] -pub struct TestletResult { - pub model: TestletModel, - /// Per-item discrimination (Rasch: all 1.0), length `J`. - pub a: Vec, - /// Per-item IRT difficulty `b_i = -beta_i / a_i`, length `J`. - pub b: Vec, - /// Per-item intercept `beta_i` (2PL-parity metric; equals `fit_mmle_2pl.b` at - /// `sigma = 0`), length `J`. - pub beta: Vec, - /// Per-testlet variance `sigma^2_d` — the local-dependence estimand, length `D`. - pub sigma2: Vec, - /// Per-person EAP ability, length `N`. - pub theta: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// Machine-readable termination status: `converged` or `max_iter_reached`. - pub termination_reason: String, - /// Absolute change between the final two evaluated marginal log-likelihoods. - pub final_loglik_change: f64, - /// `(TwoPl? 2J : J) + D`. - pub n_parameters: usize, -} - -#[allow(clippy::too_many_arguments)] -fn validate( - y: &[f64], - observed: &[bool], - testlet_id: &[usize], - n_persons: usize, - n_items: usize, - n_testlets: usize, - cfg: &TestletConfig, -) -> Result<(), String> { - if n_persons < 1 || n_items < 1 || n_testlets < 1 { - return Err("n_persons, n_items and n_testlets must be >= 1".into()); - } - if n_testlets > n_items { - return Err("n_testlets must not exceed n_items".into()); - } - if !(1..=TESTLET_MAX_ITER).contains(&cfg.max_iter) { - return Err(format!("max_iter must be in 1..={TESTLET_MAX_ITER}")); - } - if cfg.newton_iter == 0 { - return Err("newton_iter must be positive".into()); - } - if !cfg.tol.is_finite() || cfg.tol < 0.0 { - return Err("tol must be finite and non-negative".into()); - } - if !cfg.ridge_a.is_finite() - || cfg.ridge_a < 0.0 - || !cfg.ridge_b.is_finite() - || cfg.ridge_b < 0.0 - { - return Err("ridge_a and ridge_b must be finite and non-negative".into()); - } - if !cfg.init_sigma2.is_finite() || cfg.init_sigma2 < 0.0 { - return Err("init_sigma2 must be finite and non-negative".into()); - } - if !SUPPORTED_Q.contains(&cfg.q_gamma) { - return Err(format!( - "q_gamma must be one of {SUPPORTED_Q:?}; got {}", - cfg.q_gamma - )); - } - let n_cells = n_persons - .checked_mul(n_items) - .ok_or_else(|| "n_persons * n_items overflows usize".to_string())?; - if y.len() != n_cells || observed.len() != n_cells { - return Err("y and observed must have length n_persons * n_items".into()); - } - if testlet_id.len() != n_items { - return Err("testlet_id must have length n_items".into()); - } - for (idx, &v) in y.iter().enumerate() { - if observed[idx] && v != 0.0 && v != 1.0 { - return Err(format!("y[{idx}] must be 0 or 1 where observed; got {v}")); - } - } - let mut size = vec![0usize; n_testlets]; - for (i, &d) in testlet_id.iter().enumerate() { - if d >= n_testlets { - return Err(format!( - "testlet_id[{i}] = {d} out of range 0..{n_testlets}" - )); - } - size[d] += 1; - } - for (d, &s) in size.iter().enumerate() { - if s == 0 { - return Err(format!("testlet {d} has no items")); - } - } - for i in 0..n_items { - if !(0..n_persons).any(|p| observed[p * n_items + i]) { - return Err(format!("item {i} has no observed responses")); - } - } - Ok(()) -} - -/// Rasch/2PL easiness init identical to `fit_mmle_2pl`: `beta_i = logit(clamp(prop))`. -fn init_beta(y: &[f64], observed: &[bool], n_persons: usize, n_items: usize) -> Vec { - (0..n_items) - .map(|i| { - let (mut num, mut den) = (0.0, 0.0); - for p in 0..n_persons { - let idx = p * n_items + i; - if observed[idx] { - num += y[idx]; - den += 1.0; - } - } - // validate() guarantees every item has at least one observed response. - let prop = (num / den).clamp(0.02, 0.98); - (prop / (1.0 - prop)).ln() - }) - .collect() -} - -/// Immutable context shared across E-steps. -struct Ctx<'a> { - y: &'a [f64], - observed: &'a [bool], - testlet_id: &'a [usize], - items_of: &'a [Vec], - n: usize, - j: usize, - d_n: usize, - qt: usize, - qg: usize, - u_nodes: &'a [f64], - log_wt: &'a [f64], - log_vu: &'a [f64], -} - -/// One full E-step: the marginal loglik at `(a, beta, sigma2)`, the expected counts -/// `n_i`/`r_i`, the per-testlet `sum_j E[u_d^2 | y_j]`, and the person theta EAPs. The -/// `sigma == 0` fast path adds each item's term directly into the theta log-numerator, -/// reproducing `fit_mmle_2pl`'s sequential accumulation bit-for-bit (contiguous testlets). -fn full_estep( - ctx: &Ctx, - a: &[f64], - beta: &[f64], - sigma2: &[f64], -) -> (f64, Vec, Vec, Vec, Vec) { - let (n, j, d_n, qt, qg) = (ctx.n, ctx.j, ctx.d_n, ctx.qt, ctx.qg); - let idx3 = |i: usize, g: usize, h: usize| (i * qt + g) * qg + h; - let mut logp1 = vec![0.0f64; j * qt * qg]; - let mut logp0 = vec![0.0f64; j * qt * qg]; - for i in 0..j { - let sd = sigma2[ctx.testlet_id[i]].sqrt(); - for g in 0..qt { - if sd == 0.0 { - let eta = a[i] * GH_NODES[g] + beta[i]; - logp1[idx3(i, g, 0)] = log_sigmoid(eta); - logp0[idx3(i, g, 0)] = log_sigmoid(-eta); - } else { - for h in 0..qg { - let eta = a[i] * GH_NODES[g] + beta[i] - a[i] * sd * ctx.u_nodes[h]; - logp1[idx3(i, g, h)] = log_sigmoid(eta); - logp0[idx3(i, g, h)] = log_sigmoid(-eta); - } - } - } - } - let mut n_i = vec![0.0f64; j * qt * qg]; - let mut r_i = vec![0.0f64; j * qt * qg]; - let mut sum_u2 = vec![0.0f64; d_n]; - let mut theta = vec![0.0f64; n]; - let mut total_ll = 0.0; - let mut log_a = vec![0.0f64; qt]; - let mut log_g = vec![0.0f64; d_n * qt]; - let mut s_arr = vec![0.0f64; d_n * qt * qg]; - for p in 0..n { - log_a.copy_from_slice(ctx.log_wt); - for d in 0..d_n { - let sd = sigma2[d].sqrt(); - if sd == 0.0 { - for &i in &ctx.items_of[d] { - let idx = p * j + i; - if ctx.observed[idx] { - let yy = ctx.y[idx]; - for g in 0..qt { - log_a[g] += - yy * logp1[idx3(i, g, 0)] + (1.0 - yy) * logp0[idx3(i, g, 0)]; - } - } - } - } else { - for g in 0..qt { - for h in 0..qg { - let mut s = 0.0; - for &i in &ctx.items_of[d] { - let idx = p * j + i; - if ctx.observed[idx] { - let yy = ctx.y[idx]; - s += yy * logp1[idx3(i, g, h)] + (1.0 - yy) * logp0[idx3(i, g, h)]; - } - } - s_arr[(d * qt + g) * qg + h] = s; - } - let mut m = f64::NEG_INFINITY; - for h in 0..qg { - let v = ctx.log_vu[h] + s_arr[(d * qt + g) * qg + h]; - if v > m { - m = v; - } - } - let mut denom = 0.0; - for h in 0..qg { - denom += (ctx.log_vu[h] + s_arr[(d * qt + g) * qg + h] - m).exp(); - } - let lg = m + denom.ln(); - log_g[d * qt + g] = lg; - log_a[g] += lg; - } - } - } - let mut mg = f64::NEG_INFINITY; - for &v in log_a.iter() { - if v > mg { - mg = v; - } - } - let mut denomg = 0.0; - for &v in log_a.iter() { - denomg += (v - mg).exp(); - } - total_ll += mg + denomg.ln(); - for g in 0..qt { - log_a[g] = (log_a[g] - mg).exp() / denomg; - } - let mut th = 0.0; - for g in 0..qt { - th += log_a[g] * GH_NODES[g]; - } - theta[p] = th; - for d in 0..d_n { - let sd = sigma2[d].sqrt(); - if sd == 0.0 { - for &i in &ctx.items_of[d] { - let idx = p * j + i; - if ctx.observed[idx] { - let yy = ctx.y[idx]; - for g in 0..qt { - let pv = log_a[g]; - n_i[idx3(i, g, 0)] += pv; - r_i[idx3(i, g, 0)] += yy * pv; - } - } - } - } else { - for g in 0..qt { - let lg = log_g[d * qt + g]; - let pg = log_a[g]; - for &i in &ctx.items_of[d] { - let idx = p * j + i; - if ctx.observed[idx] { - let yy = ctx.y[idx]; - for h in 0..qg { - let c = (ctx.log_vu[h] + s_arr[(d * qt + g) * qg + h] - lg).exp(); - let resp = pg * c; - n_i[idx3(i, g, h)] += resp; - r_i[idx3(i, g, h)] += yy * resp; - } - } - } - for h in 0..qg { - let c = (ctx.log_vu[h] + s_arr[(d * qt + g) * qg + h] - lg).exp(); - sum_u2[d] += pg * c * ctx.u_nodes[h] * ctx.u_nodes[h]; - } - } - } - } - } - (total_ll, n_i, r_i, sum_u2, theta) -} - -/// One M-step from the expected counts: per-item 2-D Newton on the effective node -/// `z = t_g - sigma_d*u_h` (verbatim `fit_mmle_2pl` arithmetic; `fix_slope` holds -/// `a = 1`) and the closed-form testlet-variance update -/// `sigma^2_d <- sigma^2_d * mean_j E[u_d^2 | y_j]`. Returns the new `(a, beta, sigma2)`. -#[allow(clippy::too_many_arguments)] -fn m_step( - ctx: &Ctx, - a: &[f64], - beta: &[f64], - sigma2: &[f64], - n_i: &[f64], - r_i: &[f64], - sum_u2: &[f64], - multi: &[bool], - fix_slope: bool, - cfg: &TestletConfig, -) -> (Vec, Vec, Vec) { - let (j, d_n, qt, qg) = (ctx.j, ctx.d_n, ctx.qt, ctx.qg); - let idx3 = |i: usize, g: usize, h: usize| (i * qt + g) * qg + h; - let mut a = a.to_vec(); - let mut beta = beta.to_vec(); - let mut sigma2 = sigma2.to_vec(); - for i in 0..j { - let sd = sigma2[ctx.testlet_id[i]].sqrt(); - let (mut ai, mut bi) = (a[i], beta[i]); - for _ in 0..cfg.newton_iter { - let (mut g_a, mut g_b, mut h_aa, mut h_bb, mut h_ab) = (0.0, 0.0, 0.0, 0.0, 0.0); - if sd == 0.0 { - for g in 0..qt { - let z = GH_NODES[g]; - let pc = sigmoid_stable(ai * z + bi); - let nn = n_i[idx3(i, g, 0)]; - let w = nn * pc * (1.0 - pc); - let resid = r_i[idx3(i, g, 0)] - nn * pc; - g_a += resid * z; - g_b += resid; - h_aa -= w * z * z; - h_bb -= w; - h_ab -= w * z; - } - } else { - for g in 0..qt { - for h in 0..qg { - let z = GH_NODES[g] - sd * ctx.u_nodes[h]; - let pc = sigmoid_stable(ai * z + bi); - let nn = n_i[idx3(i, g, h)]; - let w = nn * pc * (1.0 - pc); - let resid = r_i[idx3(i, g, h)] - nn * pc; - g_a += resid * z; - g_b += resid; - h_aa -= w * z * z; - h_bb -= w; - h_ab -= w * z; - } - } - } - if fix_slope { - g_b -= cfg.ridge_b * bi; - h_bb -= cfg.ridge_b; - // Every valid item has positive posterior mass and finite logistic probabilities, - // hence h_bb is strictly negative (with optional extra negative ridge). - let db = g_b / h_bb; - bi -= db; - if db.abs() < 1e-8 { - break; - } - } else { - g_a -= cfg.ridge_a * ai; - g_b -= cfg.ridge_b * bi; - h_aa -= cfg.ridge_a; - h_bb -= cfg.ridge_b; - let det = h_aa * h_bb - h_ab * h_ab; - // Positive Gauss-Hermite mass at distinct theta values makes this information - // matrix nonsingular for every valid item. - let da = (h_bb * g_a - h_ab * g_b) / det; - let db = (h_aa * g_b - h_ab * g_a) / det; - ai = (ai - da).clamp(1e-3, 10.0); - bi -= db; - if da.abs() + db.abs() < 1e-8 { - break; - } - } - } - a[i] = ai; - beta[i] = bi; - } - if cfg.estimate_sigma { - for d in 0..d_n { - if multi[d] { - sigma2[d] = (sigma2[d] * sum_u2[d] / ctx.n as f64).clamp(0.0, 100.0); - } - } - } - (a, beta, sigma2) -} - -fn choose_squarem_parameters(extrapolated: Option>, two_step_em: Vec) -> Vec { - extrapolated.unwrap_or(two_step_em) -} - -fn squarem_alpha(sr: f64, sv: f64) -> f64 { - if sv > 1e-300 { - (-(sr / sv).sqrt()).min(-1.0) - } else { - -1.0 - } -} - -/// Fit the testlet response model (Bradlow, Wainer, & Wang, 1999) by marginal EM. -/// `y`/`observed` are row-major `N*J` (`y` in {0,1}); `testlet_id[i]` is item `i`'s -/// testlet in `0..n_testlets`. Missing cells are dropped (MAR). Singleton testlets have -/// `sigma^2_d` pinned to 0 (non-identified). `TestletConfig { estimate_sigma: false, -/// init_sigma2: 0.0 }` reduces exactly to a 2PL/Rasch marginal fit. -/// -/// The variance-component EM converges only linearly, so when `estimate_sigma` is on the -/// fit is accelerated with SQUAREM (Varadhan & Roland, 2008; monotone, with a plain-EM -/// fallback). Precise `sigma^2_d` may still want a generous `max_iter`. -#[allow(clippy::too_many_arguments)] -pub fn fit_testlet( - y: &[f64], - observed: &[bool], - testlet_id: &[usize], - n_persons: usize, - n_items: usize, - n_testlets: usize, - model: TestletModel, - cfg: &TestletConfig, -) -> Result { - validate(y, observed, testlet_id, n_persons, n_items, n_testlets, cfg)?; - let (n, j, d_n) = (n_persons, n_items, n_testlets); - let qt = GH_NODES.len(); - let (u_nodes, u_weights) = gh_rule(cfg.q_gamma).expect("q_gamma validated in SUPPORTED_Q"); - let qg = u_nodes.len(); - let log_wt: Vec = GH_WEIGHTS.iter().map(|w| w.ln()).collect(); - let log_vu: Vec = u_weights.iter().map(|w| w.ln()).collect(); - - // Testlet -> item indices, and per-testlet size (singletons pin sigma^2 = 0). - let mut items_of: Vec> = vec![Vec::new(); d_n]; - for (i, &d) in testlet_id.iter().enumerate() { - items_of[d].push(i); - } - let multi: Vec = items_of.iter().map(|v| v.len() >= 2).collect(); - - let fix_slope = model == TestletModel::Rasch; - let ctx = Ctx { - y, - observed, - testlet_id, - items_of: &items_of, - n, - j, - d_n, - qt, - qg, - u_nodes, - log_wt: &log_wt, - log_vu: &log_vu, - }; - - let mut a = vec![1.0f64; j]; - let mut beta = init_beta(y, observed, n, j); - let mut sigma2: Vec = (0..d_n) - .map(|d| if multi[d] { cfg.init_sigma2 } else { 0.0 }) - .collect(); - - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - - // SQUAREM (Varadhan & Roland, 2008) accelerates the slow variance-component EM; - // used only when sigma^2 is estimated (plain EM otherwise keeps the sigma->0 - // reduction bit-exact with fit_mmle_2pl). - let use_squarem = cfg.estimate_sigma && multi.iter().any(|&m| m); - - if use_squarem { - let len = 2 * j + d_n; - let pack = |a: &[f64], b: &[f64], s: &[f64]| -> Vec { - a.iter().chain(b.iter()).chain(s.iter()).copied().collect() - }; - let unpack = |p: &[f64]| -> (Vec, Vec, Vec) { - ( - p[0..j].to_vec(), - p[j..2 * j].to_vec(), - p[2 * j..2 * j + d_n].to_vec(), - ) - }; - let project = |p: &mut [f64]| { - for ai in p.iter_mut().take(j) { - *ai = ai.clamp(1e-3, 10.0); - } - for d in 0..d_n { - let idx = 2 * j + d; - // Floor multi-testlet sigma^2 above 0: exactly 0 is an absorbing state - // (the sigma==0 fast path stops accumulating sum_u2, so the - // multiplicative update could never revive an overshot testlet). - p[idx] = if multi[d] { - p[idx].clamp(1e-8, 100.0) - } else { - 0.0 - }; - } - }; - let mut params = pack(&a, &beta, &sigma2); - while n_iter < cfg.max_iter { - let (a0, b0, s0) = unpack(¶ms); - let (l0, ni0, ri0, su0, _) = full_estep(&ctx, &a0, &b0, &s0); - loglik_trace.push(l0); - n_iter += 1; - if loglik_trace.len() > 1 { - let k = loglik_trace.len(); - if (l0 - loglik_trace[k - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - if n_iter >= cfg.max_iter { - break; - } - // A SQUAREM cycle consumes two further iteration slots. When only one - // remains, take one plain EM step and evaluate it on the next loop so - // n_iter never exceeds the public max_iter contract. - if cfg.max_iter - n_iter < 2 { - let (a1, b1, s1) = m_step( - &ctx, &a0, &b0, &s0, &ni0, &ri0, &su0, &multi, fix_slope, cfg, - ); - params = pack(&a1, &b1, &s1); - continue; - } - // Two plain EM steps. - let (a1, b1, s1) = m_step( - &ctx, &a0, &b0, &s0, &ni0, &ri0, &su0, &multi, fix_slope, cfg, - ); - let p1 = pack(&a1, &b1, &s1); - let (_l1, ni1, ri1, su1, _) = full_estep(&ctx, &a1, &b1, &s1); - let (a2, b2, s2) = m_step( - &ctx, &a1, &b1, &s1, &ni1, &ri1, &su1, &multi, fix_slope, cfg, - ); - let p2 = pack(&a2, &b2, &s2); - // SqS3 steplength from r = p1 - p0, v = p2 - 2p1 + p0. - let mut r = vec![0.0f64; len]; - let mut v = vec![0.0f64; len]; - for k in 0..len { - r[k] = p1[k] - params[k]; - v[k] = p2[k] - p1[k] - r[k]; - } - let sr: f64 = r.iter().map(|x| x * x).sum(); - let sv: f64 = v.iter().map(|x| x * x).sum(); - let alpha = squarem_alpha(sr, sv); - let mut pn = vec![0.0f64; len]; - for k in 0..len { - pn[k] = params[k] - 2.0 * alpha * r[k] + alpha * alpha * v[k]; - } - project(&mut pn); - let (an, bn, sn) = unpack(&pn); - let (lc, nic, ric, suc, _) = full_estep(&ctx, &an, &bn, &sn); - // Accept only if not worse than the cycle start (=> monotone after one - // stabilizing M-step); else fall back to the two plain EM steps. With a - // degenerate SQUAREM direction (`sv <= 1e-300`) the extrapolated point is - // deliberately rejected and the two plain EM steps are retained. - let extrapolated = (sv > 1e-300 && lc.is_finite() && lc >= l0).then(|| { - let (a3, b3, s3) = m_step( - &ctx, &an, &bn, &sn, &nic, &ric, &suc, &multi, fix_slope, cfg, - ); - pack(&a3, &b3, &s3) - }); - params = choose_squarem_parameters(extrapolated, p2); - n_iter += 2; - } - let (fa, fb, fs) = unpack(¶ms); - a = fa; - beta = fb; - sigma2 = fs; - } else { - while n_iter < cfg.max_iter { - let (l0, ni, ri, su, _) = full_estep(&ctx, &a, &beta, &sigma2); - loglik_trace.push(l0); - n_iter += 1; - if loglik_trace.len() > 1 { - let k = loglik_trace.len(); - if (l0 - loglik_trace[k - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - let (na, nb, ns) = m_step( - &ctx, &a, &beta, &sigma2, &ni, &ri, &su, &multi, fix_slope, cfg, - ); - a = na; - beta = nb; - sigma2 = ns; - } - } - - // Final pass at the returned params: theta EAP + final loglik. - let (final_ll, _, _, _, theta) = full_estep(&ctx, &a, &beta, &sigma2); - if !converged - && loglik_trace - .last() - .is_none_or(|last| last.to_bits() != final_ll.to_bits()) - { - loglik_trace.push(final_ll); - } - let final_loglik_change = loglik_trace - .windows(2) - .last() - .map_or(f64::INFINITY, |pair| (pair[1] - pair[0]).abs()); - let termination_reason = if converged { - "converged" - } else { - "max_iter_reached" - }; - - let b: Vec = (0..j).map(|i| -beta[i] / a[i]).collect(); - let k = if fix_slope { 1 } else { 2 }; - // Only FREELY-estimated testlet variances count: singletons are pinned to 0 - // (non-identified) and estimate_sigma=false fixes every variance. - let n_free_sigma = if cfg.estimate_sigma { - multi.iter().filter(|&&m| m).count() - } else { - 0 - }; - Ok(TestletResult { - model, - a, - b, - beta, - sigma2, - theta, - loglik_trace, - n_iter, - converged, - termination_reason: termination_reason.to_string(), - final_loglik_change, - n_parameters: k * j + n_free_sigma, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/testlet_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/twopl.rs b/crates/mlsirm-core/src/twopl.rs deleted file mode 100644 index 096fdcec4..000000000 --- a/crates/mlsirm-core/src/twopl.rs +++ /dev/null @@ -1,1138 +0,0 @@ -//! Two-parameter logistic item response model — confirmatory multidimensional form, orthogonal or correlated (Reckase, -//! 2009; Bock, Gibbons, & Muraki, 1988). -//! -//! `fit_2pl` fits a **general compensatory** multidimensional 2PL in which an -//! item may load FREELY on several latent dimensions, which trade off ADDITIVELY inside a -//! single logit: -//! -//! ```text -//! P(X_ij = 1 | theta_j) = sigmoid( sum_{d in S_i} a_id * theta_jd + b_i ), theta_j ~ MVN(0, I_D) -//! ``` -//! -//! `S_i = { d : L_id = 1 }` is item `i`'s loading set from a 0/1 confirmatory pattern `L` -//! (J x D); `a_id` is a free loading for `d in S_i` (zero otherwise); `b_i` is the intercept. -//! This is Reckase's compensatory M2PL / the full-information item factor model of Bock, -//! Gibbons & Muraki (1988), estimated by marginal-ML EM over a product Gauss-Hermite grid. -//! -//! It is genuinely COMPENSATORY (a low standing on one trait can be offset by a high standing -//! on another, because the traits sum in the logit), and it is distinct from the existing -//! estimators: the LSIRM/`marginal.rs` family is simple-structure (one trait dimension per -//! item, which factorizes the quadrature), and the orthogonal bifactor is the special case -//! "one primary + one general per item". Allowing arbitrary within-item cross-loadings breaks -//! that factorization and requires the full `Q^D` product quadrature, so this is a dedicated -//! estimator rather than a mode of `marginal.rs`. -//! -//! **Latent traits.** `theta ~ MVN(0, Sigma)`, `Sigma` a CORRELATION matrix (unit diagonal). -//! With `estimate_corr = false` (default) the factors are ORTHOGONAL (`Sigma = I`); with -//! `estimate_corr = true` the inter-factor correlations are estimated by an ECM step. The -//! correlated case maps the standard-normal node set through `theta_g = L(Sigma) z_g`; the item -//! M-step is reused verbatim on those mapped nodes, while the `Sigma` M-step ascends the -//! Gaussian-prior objective -//! `-0.5[log|Sigma| + tr(Sigma^{-1} C)]` over the free correlations (`C` the posterior second -//! moment). Because remapping a finite QMC set changes its approximated objective, every proposed -//! `Sigma` update is additionally backtracked against the actual finite-node marginal likelihood; -//! this positive-definite observed-objective guard preserves EM ascent and convergence. -//! -//! **Integration node rule (`xi_rule`).** The product Gauss-Hermite grid is exact for -//! near-polynomial integrands but its `Q^D` node count is exponential in `D`, so it is capped at -//! `D <= 3`. For `D = 4, 5, 6` the E-step integral is instead evaluated by **quasi-Monte-Carlo** -//! (`Halton`, the low-discrepancy default for the QMC path) or plain **Monte-Carlo** (`MonteCarlo`) -//! quadrature: `xi_points` points drawn from the standard-normal prior (Halton radical inverse -> -//! `inv_normal_cdf`, or seeded Gaussian draws), equal weights `1/xi_points`. This is Jank's (2005) -//! QMC-EM — only the E-step nodes/weights change; the per-item Newton M-step and the `Sigma` -//! M-step are byte-for-byte the same code on the swapped node set. Because the standard node set -//! is FIXED for the whole EM run, the orthogonal fit optimizes one QMC approximation directly. In -//! the correlated fit, the repository-specific observed-objective backtracking described above -//! compensates for the changing Cholesky-mapped finite node cloud. QMC still carries an -//! `O(N^{-1} (log N)^D)` finite-node bias that grows with `D`, so `D = 5, 6` and the correlated -//! `Sigma` off-diagonals need materially larger `xi_points`; a Cranley-Patterson random shift -//! (`xi_seed`, nonzero by default) de-correlates the higher-prime Halton axes. -//! -//! Identification: unit trait variances fix the per-dimension loading scale (independently of -//! the free correlations); `E[theta] = 0` fixes the intercepts; the confirmatory pattern -//! labels the dimensions and — with at least one PURE single-loading anchor item per dimension -//! (`validate` enforces this, rejecting rotationally-degenerate patterns such as all-ones) — -//! fixes rotation even with correlated factors (one pure indicator per factor forces the -//! observational-equivalence transform to be diagonal, and the unit diagonal then forces it to -//! `+-I`); the residual per-dimension sign is fixed by a reflection anchor (each dimension is -//! flipped so its largest-magnitude pure anchor loads positively, which also negates that -//! dimension's correlation off-diagonals). -//! -//! # References (APA 7th ed.) -//! -//! Reckase, M. D. (2009). *Multidimensional item response theory*. Springer. -//! https://doi.org/10.1007/978-0-387-89976-3 -//! -//! Bock, R. D., Gibbons, R., & Muraki, E. (1988). Full-information item factor analysis. -//! *Applied Psychological Measurement, 12*(3), 261-280. -//! https://doi.org/10.1177/014662168801200305 -//! -//! Jank, W. (2005). Quasi-Monte Carlo sampling to improve the efficiency of Monte Carlo EM. -//! *Computational Statistics & Data Analysis, 48*(4), 685-701. -//! https://doi.org/10.1016/j.csda.2004.03.019 - -use crate::marginal::XiRuleKind; -use crate::mmle::{log_sigmoid, sigmoid_stable}; -use crate::nodes::{build_xi_nodes, XiRule}; -use crate::poly::solve_small; -use crate::quadrature::{gh_rule, SUPPORTED_Q}; - -/// Maximum integration node count (bounds the per-iteration `nodes x J` tables) for BOTH the -/// `Q^D` Gauss-Hermite grid and the `xi_points` QMC/MC point set. -const MIRT_MAX_NODES: usize = 200_000; -/// Maximum node-by-item cells in each E-step table. Four dense f64 tables use this shape -/// (log P1, log P0, expected trials, expected successes), so this cap bounds aggregate table -/// memory and must be checked before any of those allocations. -const MIRT_MAX_NODE_ITEM_CELLS: usize = 60_000_000; -/// Upper bound on caller-controlled EM iterations. -const MIRT_MAX_ITER: usize = 100_000; - -fn checked_grid_nodes(current: usize, q: usize) -> Result { - current - .checked_mul(q) - .filter(|&n| n <= MIRT_MAX_NODES) - .ok_or_else(|| format!("q^n_dims exceeds the node cap {MIRT_MAX_NODES}")) -} - -fn should_stop_item_newton(accepted: bool, moved: f64) -> bool { - !accepted || moved < 1e-9 -} -/// Maximum latent dimensions for the Gauss-Hermite product grid (`41^3 = 68_921 <= cap`). `D > 3` -/// is served by the quasi-Monte-Carlo (Halton) / Monte-Carlo node rules instead. -const MIRT_MAX_DIMS: usize = 3; -/// Maximum latent dimensions for the Halton/MonteCarlo rules (= `HALTON_PRIMES.len()` in `nodes`, -/// the Halton axis cap; also the sole guard for the MonteCarlo builder, which has no internal cap). -const MIRT_MAX_DIMS_QMC: usize = 6; -/// Symmetric loading bound. Loadings are NOT floored positive: confirmatory MIRT routinely -/// has opposite-sign loadings on a shared dimension (reverse-keyed items, suppressor -/// cross-loadings). The per-dimension reflection anchor fixes only the global sign. -const MIRT_A_BOUND: f64 = 10.0; - -/// Configuration for [`fit_2pl`]. -#[derive(Clone, Copy, Debug)] -pub struct TwoPlConfig { - /// Maximum EM iterations. - pub max_iter: usize, - /// Convergence tolerance on `|delta loglik|`. - pub tol: f64, - /// Gauss-Hermite nodes per dimension (must be in `{7, 11, 15, 21, 31, 41}`). - pub q: usize, - /// Ridge on the loading Hessian block (Gaussian prior, mirrors `MmleConfig`). - pub ridge_a: f64, - /// Ridge on the intercept Hessian entry. - pub ridge_b: f64, - /// Inner Newton iterations per item M-step. - pub newton_iter: usize, - /// Estimate a free latent CORRELATION matrix `Sigma` (`theta ~ MVN(0, Sigma)`, unit - /// diagonal). When `false`, `Sigma = I` (orthogonal factors) exactly — the item model is - /// evaluated on the raw Gauss-Hermite grid, bit-for-bit as the orthogonal fit. - pub estimate_corr: bool, - /// Latent-integral node rule. `GaussHermite` (default) uses the `q^D` product grid and caps - /// `D <= 3`; `Halton` (quasi-Monte-Carlo, Jank 2005) and `MonteCarlo` use `xi_points` nodes - /// mapped from the prior and unlock `D` up to 6 (the Halton prime axes). The item and `Sigma` - /// M-steps are identical for every rule — only the E-step quadrature nodes/weights change. - pub xi_rule: XiRuleKind, - /// Number of QMC/MC integration points (used only for `Halton`/`MonteCarlo`; `q` is ignored - /// for those rules). `D = 5, 6` need materially larger `xi_points` to keep the QMC error small. - pub xi_points: usize, - /// Halton Cranley-Patterson random-shift seed / Monte-Carlo seed. Nonzero by default so QMC - /// runs are randomized (helps the high-prime axes at `D >= 5`); deterministic given the seed. - pub xi_seed: u64, -} - -impl Default for TwoPlConfig { - fn default() -> Self { - Self { - max_iter: 500, - tol: 1e-6, - q: 21, - ridge_a: 1e-3, - ridge_b: 1e-3, - newton_iter: 25, - estimate_corr: false, - xi_rule: XiRuleKind::GaussHermite, - xi_points: 4000, - xi_seed: 0x9E37_79B9_7F4A_7C15, - } - } -} - -/// Result of [`fit_2pl`] (confirmatory compensatory MIRT, orthogonal or -/// correlated latent factors). -#[derive(Clone, Debug)] -pub struct TwoPlResult { - /// Free loadings `a_id`, row-major `J x D` (exactly `0.0` where `L_id = 0`). - pub loading: Vec, - /// Item intercepts `b_i`, length `J`. - pub intercept: Vec, - /// Per-person trait EAP `E[theta_jd | X_j]`, row-major `N x D`. - pub theta: Vec, - /// Number of latent dimensions `D`. - pub n_dims: usize, - /// Latent correlation matrix `Sigma`, row-major `D x D` (identity when `estimate_corr` - /// is `false`; unit diagonal, estimated off-diagonals otherwise). - pub corr: Vec, - pub loglik_trace: Vec, - pub n_iter: usize, - pub converged: bool, - /// Machine-readable termination status: `converged` or `max_iter_reached`. - pub termination_reason: String, - /// Absolute change between the final two evaluated marginal log-likelihoods. - pub final_loglik_change: f64, - /// `#{L_id = 1}` loadings `+ J` intercepts `+ D(D-1)/2` correlations (when estimated). - pub n_parameters: usize, -} - -#[allow(clippy::too_many_arguments)] -fn validate( - y: &[f64], - observed: &[bool], - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - cfg: &TwoPlConfig, -) -> Result<(), String> { - if n_persons < 1 || n_items < 1 { - return Err("n_persons and n_items must be >= 1".into()); - } - if !(1..=MIRT_MAX_ITER).contains(&cfg.max_iter) { - return Err(format!("max_iter must be in 1..={MIRT_MAX_ITER}")); - } - if !cfg.tol.is_finite() || cfg.tol <= 0.0 { - return Err("tol must be finite and positive".into()); - } - for (name, v) in [("ridge_a", cfg.ridge_a), ("ridge_b", cfg.ridge_b)] { - // Strictly positive: the ridge is what makes A = -Hessian strictly positive-definite, - // so the Newton solve is always an exact ascent step (never the singular fallback). - if !v.is_finite() || v <= 0.0 { - return Err(format!("{name} must be finite and positive")); - } - } - // Rule-dependent dimension bound + node-count cap. The Gauss-Hermite product grid caps at - // MIRT_MAX_DIMS (Q^D blows up); the QMC/MC rules cap at MIRT_MAX_DIMS_QMC (the Halton primes) - // and bound the user-supplied point count instead. `q` is validated/used only for GH. - let n_nodes = match cfg.xi_rule { - XiRuleKind::GaussHermite => { - if !(1..=MIRT_MAX_DIMS).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={MIRT_MAX_DIMS} for the Gauss-Hermite grid; use \ - xi_rule Halton/MonteCarlo for D up to {MIRT_MAX_DIMS_QMC}" - )); - } - if !SUPPORTED_Q.contains(&cfg.q) { - return Err(format!( - "q must be one of {SUPPORTED_Q:?} (Gauss-Hermite rules); got {}", - cfg.q - )); - } - // Q^D via an accumulating checked multiply in a fixed order (never wraps). - let mut n_nodes = 1usize; - for _ in 0..n_dims { - n_nodes = checked_grid_nodes(n_nodes, cfg.q)?; - } - n_nodes - } - XiRuleKind::Halton | XiRuleKind::MonteCarlo => { - // The MonteCarlo node builder has no internal dimension cap, so this bound is the sole - // guard for MC at D > MIRT_MAX_DIMS_QMC (Halton's own builder errors past its primes). - if !(1..=MIRT_MAX_DIMS_QMC).contains(&n_dims) { - return Err(format!( - "n_dims must be in 1..={MIRT_MAX_DIMS_QMC} for the Halton/MonteCarlo rules" - )); - } - if !(1..=MIRT_MAX_NODES).contains(&cfg.xi_points) { - return Err(format!( - "xi_points must be in 1..={MIRT_MAX_NODES} for the Halton/MonteCarlo rules; \ - got {}", - cfg.xi_points - )); - } - cfg.xi_points - } - }; - let table_cells = - crate::checked_mul_usize(n_nodes, n_items, "node * item table size overflows usize")?; - if table_cells > MIRT_MAX_NODE_ITEM_CELLS { - return Err(format!( - "node * item table has {table_cells} cells, exceeding the cap \ - {MIRT_MAX_NODE_ITEM_CELLS}; reduce nodes or items" - )); - } - crate::checked_mul_usize(n_nodes, n_dims, "node-dimension size overflows")?; - let n_cells = - crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")?; - if y.len() != n_cells || observed.len() != n_cells { - return Err("y and observed must have length n_persons * n_items".into()); - } - let n_l = crate::checked_mul_usize(n_items, n_dims, "n_items * n_dims overflows usize")?; - if loading_pattern.len() != n_l { - return Err("loading_pattern must have length n_items * n_dims".into()); - } - for (idx, &v) in y.iter().enumerate() { - if observed[idx] && v != 0.0 && v != 1.0 { - return Err(format!("y[{idx}] must be 0 or 1 where observed; got {v}")); - } - } - for (idx, &v) in loading_pattern.iter().enumerate() { - if v != 0 && v != 1 { - return Err(format!("loading_pattern[{idx}] must be 0 or 1; got {v}")); - } - } - // Every item loads >= 1 dimension; every item has >= 1 observed response. - for i in 0..n_items { - if !(0..n_dims).any(|d| loading_pattern[i * n_dims + d] != 0) { - return Err(format!( - "item {i} loads no dimension (all-zero loading_pattern row)" - )); - } - if !(0..n_persons).any(|p| observed[p * n_items + i]) { - return Err(format!("item {i} has no observed responses")); - } - } - // Identification: every dimension needs a PURE single-loading anchor item (an item that - // loads ONLY that dimension). This is the sufficient structural condition that fixes the - // orthogonal rotation (and gives the sign anchor a target); it rejects the all-ones and - // other rotationally-degenerate patterns that leave the item Hessian block singular. - for d in 0..n_dims { - let has_pure_anchor = (0..n_items).any(|i| { - loading_pattern[i * n_dims + d] != 0 - && (0..n_dims) - .filter(|&d2| loading_pattern[i * n_dims + d2] != 0) - .count() - == 1 - }); - if !has_pure_anchor { - return Err(format!( - "dimension {d} has no pure single-loading anchor item (needed for identification; \ - rotationally-degenerate pattern)" - )); - } - } - Ok(()) -} - -/// Build the `D`-fold Cartesian product Gauss-Hermite grid over orthogonal `N(0,1)` axes. -/// Returns row-major `nodes[g*D + d]` and `logw[g] = sum_d ln(w_axis[digit_d])`. -fn build_grid(n_dims: usize, q: usize) -> (Vec, Vec) { - let (axis_nodes, axis_weights) = gh_rule(q).expect("q validated in supported set"); - let log_aw: Vec = axis_weights.iter().map(|w| w.ln()).collect(); - let n_nodes = q.pow(n_dims as u32); - let mut nodes = vec![0.0f64; n_nodes * n_dims]; - let mut logw = vec![0.0f64; n_nodes]; - for g in 0..n_nodes { - let mut rem = g; - let mut lw = 0.0f64; - for d in 0..n_dims { - let digit = rem % q; // mixed-radix base q; digit_d = (g / q^d) % q - rem /= q; - nodes[g * n_dims + d] = axis_nodes[digit]; - lw += log_aw[digit]; - } - logw[g] = lw; - } - (nodes, logw) -} - -/// Penalized per-item complete-data objective `Q_i` (the M-step ascends this): the expected -/// Bernoulli log-likelihood over the grid minus the ridge Gaussian penalty. Used for the -/// backtracking line search so every M-step step is non-decreasing (keeps EM monotone). -#[allow(clippy::too_many_arguments)] -fn item_obj( - dims: &[usize], - a: &[f64], - b: f64, - n_ig: &[f64], - r_ig: &[f64], - nodes: &[f64], - n_dims: usize, - n_nodes: usize, - ridge_a: f64, - ridge_b: f64, -) -> f64 { - let mut acc = 0.0f64; - for g in 0..n_nodes { - let mut eta = b; - for (k, &d) in dims.iter().enumerate() { - eta += a[k] * nodes[g * n_dims + d]; - } - acc += r_ig[g] * log_sigmoid(eta) + (n_ig[g] - r_ig[g]) * log_sigmoid(-eta); - } - let pen: f64 = a.iter().map(|&ak| ak * ak).sum::() * ridge_a + b * b * ridge_b; - acc - 0.5 * pen -} - -/// Ascent gradient `g` and the positive-definite `A = -Hessian` of the penalized item -/// objective [`item_obj`] at the current `(a, b)`, over the loaded dimensions `dims`. The -/// diagonal ridge makes `A` strictly positive-definite, so the Newton solve is a well-posed -/// ascent step that never triggers `solve_small`'s singular fallback. `g[k] = sum_g -/// (r_ig - n_ig p_g) z_gk - ridge_k a_k`, `A[k][j] = sum_g n_ig p_g(1-p_g) z_gk z_gj + -/// ridge_k [k=j]`, with `z_gk = theta_{g,dims[k]}` for loadings and `1` for the intercept -/// (last index). This is the Bock-Gibbons-Muraki (1988) full-information item update; at -/// `D = 1` it is `mmle`'s 2x2 block. -#[allow(clippy::too_many_arguments)] -fn item_grad_hess( - dims: &[usize], - a: &[f64], - b: f64, - n_ig: &[f64], - r_ig: &[f64], - nodes: &[f64], - n_dims: usize, - n_nodes: usize, - ridge_a: f64, - ridge_b: f64, -) -> (Vec, Vec>) { - let ni = dims.len(); - let np = ni + 1; - let mut grad = vec![0.0f64; np]; - let mut amat = vec![vec![0.0f64; np]; np]; - for g in 0..n_nodes { - let n = n_ig[g]; - if n == 0.0 { - continue; - } - let mut eta = b; - for (k, &d) in dims.iter().enumerate() { - eta += a[k] * nodes[g * n_dims + d]; - } - let pg = sigmoid_stable(eta); - let w = n * pg * (1.0 - pg); - let resid = r_ig[g] - n * pg; - for k in 0..np { - let zk = if k < ni { - nodes[g * n_dims + dims[k]] - } else { - 1.0 - }; - grad[k] += resid * zk; - for j in 0..np { - let zj = if j < ni { - nodes[g * n_dims + dims[j]] - } else { - 1.0 - }; - amat[k][j] += w * zk * zj; - } - } - } - for k in 0..np { - let (rk, pk) = if k < ni { - (ridge_a, a[k]) - } else { - (ridge_b, b) - }; - grad[k] -= rk * pk; - amat[k][k] += rk; - } - (grad, amat) -} - -/// Lower Cholesky factor of a `D x D` symmetric matrix (row-major), or `None` if it is not -/// (numerically) positive-definite — the PD gate for the correlation M-step and the node map. -pub(crate) fn chol_lower(sigma: &[f64], d: usize) -> Option> { - let mut l = vec![0.0f64; d * d]; - for i in 0..d { - for j in 0..=i { - let mut s = sigma[i * d + j]; - for k in 0..j { - s -= l[i * d + k] * l[j * d + k]; - } - if i == j { - if s <= 1e-12 { - return None; - } - l[i * d + i] = s.sqrt(); - } else { - l[i * d + j] = s / l[j * d + j]; - } - } - } - Some(l) -} - -/// Inverse (row-major) and log-determinant of a symmetric PD `D x D` matrix via its Cholesky -/// factor; `None` if not PD. -pub(crate) fn sym_inv_logdet(sigma: &[f64], d: usize) -> Option<(Vec, f64)> { - let l = chol_lower(sigma, d)?; - let logdet = (0..d).map(|i| 2.0 * l[i * d + i].ln()).sum::(); - let mut inv = vec![0.0f64; d * d]; - for col in 0..d { - let mut y = vec![0.0f64; d]; // forward solve L y = e_col - for i in 0..d { - let mut s = if i == col { 1.0 } else { 0.0 }; - for k in 0..i { - s -= l[i * d + k] * y[k]; - } - y[i] = s / l[i * d + i]; - } - for i in (0..d).rev() { - // back solve L^T x = y - let mut s = y[i]; - for k in i + 1..d { - s -= l[k * d + i] * inv[k * d + col]; - } - inv[i * d + col] = s / l[i * d + i]; - } - } - Some((inv, logdet)) -} - -/// Gaussian-prior objective the correlation M-step ascends: -/// `Q_prior(Sigma) = -0.5 [ log|Sigma| + tr(Sigma^{-1} C) ]`, `C` the posterior second moment. -/// `None` if `Sigma` is not PD. -fn sigma_qprior(sigma: &[f64], c: &[f64], d: usize) -> Option { - let (inv, logdet) = sym_inv_logdet(sigma, d)?; - let mut tr = 0.0f64; - for i in 0..d { - for k in 0..d { - tr += inv[i * d + k] * c[k * d + i]; - } - } - Some(-0.5 * (logdet + tr)) -} - -/// Off-diagonal gradient of `sigma_qprior` w.r.t. the free correlations (pairs `(i,j)`, `i Option> { - let (inv, _) = sym_inv_logdet(sigma, d)?; - let mut ic = vec![0.0f64; d * d]; // inv * C - for i in 0..d { - for j in 0..d { - let mut s = 0.0; - for k in 0..d { - s += inv[i * d + k] * c[k * d + j]; - } - ic[i * d + j] = s; - } - } - let mut g = Vec::with_capacity(d * (d - 1) / 2); - for i in 0..d { - for j in i + 1..d { - let mut ici = 0.0; // (inv * C * inv)_{ij} - for k in 0..d { - ici += ic[i * d + k] * inv[k * d + j]; - } - g.push(ici - inv[i * d + j]); - } - } - Some(g) -} - -/// Build a `D x D` correlation matrix (row-major, unit diagonal) from the free off-diagonal -/// correlations (pairs `(i,j)`, `i Vec { - let mut s = vec![0.0f64; d * d]; - for i in 0..d { - s[i * d + i] = 1.0; - } - let mut m = 0; - for i in 0..d { - for j in i + 1..d { - let r = offdiag[m].clamp(-0.999, 0.999); - m += 1; - s[i * d + j] = r; - s[j * d + i] = r; - } - } - s -} - -/// Negate the free correlations (off-diagonal `(i,j)` pairs, `i -theta_flip` stays consistent with the -/// reported correlation matrix (`corr(theta_flip, theta_k) -> -corr`). Correlations not -/// involving `flip` are untouched; the diagonal is implicitly unchanged (it is not stored). -pub(crate) fn flip_corr_dim(offdiag: &mut [f64], d: usize, flip: usize) { - let mut m = 0; - for i in 0..d { - for j in i + 1..d { - if i == flip || j == flip { - offdiag[m] = -offdiag[m]; - } - m += 1; - } - } -} - -fn corr_line_search( - r_off: &[f64], - grad: &[f64], - q0: f64, - cmat: &[f64], - d: usize, -) -> Option> { - let mut alpha = 1.0f64; - for _ in 0..40 { - let candidate_offdiag: Vec = (0..r_off.len()) - .map(|m| (r_off[m] + alpha * grad[m]).clamp(-0.999, 0.999)) - .collect(); - let candidate = build_corr(&candidate_offdiag, d); - if sigma_qprior(&candidate, cmat, d).is_some_and(|q1| q1 >= q0 - 1e-12) { - return Some(candidate_offdiag); - } - alpha *= 0.5; - } - None -} - -fn map_corr_nodes(r_off: &[f64], base_nodes: &[f64], d: usize, mapped: &mut [f64]) -> bool { - let Some(lchol) = chol_lower(&build_corr(r_off, d), d) else { - return false; - }; - for (source, target) in base_nodes.chunks_exact(d).zip(mapped.chunks_exact_mut(d)) { - for k in 0..d { - target[k] = (0..=k).map(|j| lchol[k * d + j] * source[j]).sum(); - } - } - true -} - -#[allow(clippy::too_many_arguments)] -fn marginal_loglik_on_nodes( - y: &[f64], - observed: &[bool], - loading: &[f64], - intercept: &[f64], - dims_of: &[Vec], - n_persons: usize, - n_items: usize, - n_dims: usize, - nodes: &[f64], - logw: &[f64], -) -> f64 { - let n_nodes = logw.len(); - let mut log_p1 = vec![0.0; n_nodes * n_items]; - let mut log_p0 = vec![0.0; n_nodes * n_items]; - for g in 0..n_nodes { - for i in 0..n_items { - let eta = dims_of[i].iter().fold(intercept[i], |value, &dimension| { - value + loading[i * n_dims + dimension] * nodes[g * n_dims + dimension] - }); - log_p1[g * n_items + i] = log_sigmoid(eta); - log_p0[g * n_items + i] = log_sigmoid(-eta); - } - } - let mut post = vec![0.0; n_nodes]; - let mut total = 0.0; - for p in 0..n_persons { - for g in 0..n_nodes { - post[g] = (0..n_items).fold(logw[g], |value, i| { - let idx = p * n_items + i; - if observed[idx] { - let response = y[idx]; - value - + response * log_p1[g * n_items + i] - + (1.0 - response) * log_p0[g * n_items + i] - } else { - value - } - }); - } - let maximum = post.iter().copied().fold(f64::NEG_INFINITY, f64::max); - total += maximum - + post - .iter() - .map(|value| (value - maximum).exp()) - .sum::() - .ln(); - } - total -} - -#[allow(clippy::too_many_arguments)] -fn corr_marginal_line_search( - current: &[f64], - target: &[f64], - baseline: f64, - y: &[f64], - observed: &[bool], - loading: &[f64], - intercept: &[f64], - dims_of: &[Vec], - n_persons: usize, - n_items: usize, - n_dims: usize, - base_nodes: &[f64], - logw: &[f64], -) -> Option<(Vec, Vec)> { - let mut alpha = 1.0; - let mut mapped = vec![0.0; base_nodes.len()]; - for _ in 0..20 { - let candidate: Vec = current - .iter() - .zip(target) - .map(|(&old, &new)| old + alpha * (new - old)) - .collect(); - // Both endpoints are positive-definite correlation matrices and the PD cone is convex, so - // every interpolation candidate must map successfully. - assert!(map_corr_nodes(&candidate, base_nodes, n_dims, &mut mapped)); - let candidate_ll = marginal_loglik_on_nodes( - y, observed, loading, intercept, dims_of, n_persons, n_items, n_dims, &mapped, logw, - ); - if candidate_ll >= baseline - 1e-10 { - return Some((candidate, mapped)); - } - alpha *= 0.5; - } - None -} - -#[allow(clippy::too_many_arguments)] -fn reflect_mirt_dimensions( - loading: &mut [f64], - theta: &mut [f64], - r_off: &mut [f64], - dims_of: &[Vec], - n_persons: usize, - n_items: usize, - n_dims: usize, -) { - for d in 0..n_dims { - let mut anchor: Option = None; - let mut best = 0.0f64; - for i in 0..n_items { - let is_pure = dims_of[i].len() == 1 && dims_of[i][0] == d; - if is_pure && loading[i * n_dims + d].abs() > best { - best = loading[i * n_dims + d].abs(); - anchor = Some(i); - } - } - if let Some(item) = anchor { - if loading[item * n_dims + d] < 0.0 { - for i in 0..n_items { - loading[i * n_dims + d] = -loading[i * n_dims + d]; - } - for p in 0..n_persons { - theta[p * n_dims + d] = -theta[p * n_dims + d]; - } - flip_corr_dim(r_off, n_dims, d); - } - } - } -} - -/// Fit the orthogonal OR correlated confirmatory compensatory MIRT by marginal-ML (EC)M. -/// -/// `y`/`observed` are row-major `N*J` (`y` in `{0,1}` where observed; missing cells dropped -/// under MAR); `loading_pattern` is row-major `J*D` in `{0,1}`. Returns `Err` on malformed or -/// rotationally-underidentified input. -#[allow(clippy::too_many_arguments)] -pub fn fit_2pl( - y: &[f64], - observed: &[bool], - loading_pattern: &[u8], - n_persons: usize, - n_items: usize, - n_dims: usize, - cfg: &TwoPlConfig, -) -> Result { - validate( - y, - observed, - loading_pattern, - n_persons, - n_items, - n_dims, - cfg, - )?; - // Build the latent-integral node set once, before the EM loop: a FIXED quadrature keeps EM - // monotone in the (QMC-)approximated marginal likelihood (Jank, 2005). The Gauss-Hermite path - // keeps `build_grid` verbatim (bit-for-bit the orthogonal fit); Halton/MonteCarlo delegate to - // the shared, parity-tested `build_xi_nodes` (prior-sampled points, equal `logw = -ln(n)`). - let (nodes, logw) = match cfg.xi_rule { - XiRuleKind::GaussHermite => build_grid(n_dims, cfg.q), - XiRuleKind::Halton => { - let xn = build_xi_nodes( - XiRule::Halton { - n: cfg.xi_points, - shift_seed: cfg.xi_seed, - }, - n_dims, - ) - .expect("validated Halton dimensions and point count must build"); - (xn.grid, xn.logw) - } - XiRuleKind::MonteCarlo => { - let xn = build_xi_nodes( - XiRule::MonteCarlo { - n: cfg.xi_points, - seed: cfg.xi_seed.max(1), - }, - n_dims, - ) - .expect("validated Monte Carlo dimensions and point count must build"); - (xn.grid, xn.logw) - } - }; - let n_nodes = logw.len(); - - // Per-item loaded-dimension lists S_i (the free-loading dims). - let dims_of: Vec> = (0..n_items) - .map(|i| { - (0..n_dims) - .filter(|&d| loading_pattern[i * n_dims + d] != 0) - .collect() - }) - .collect(); - - // Init: loadings 1.0 on the pattern; intercept = logit of the item's observed proportion. - let mut loading = vec![0.0f64; n_items * n_dims]; - let mut intercept = vec![0.0f64; n_items]; - for i in 0..n_items { - for &d in &dims_of[i] { - loading[i * n_dims + d] = 1.0; - } - let (mut num, mut den) = (0.0f64, 0.0f64); - for p in 0..n_persons { - let idx = p * n_items + i; - if observed[idx] { - num += y[idx]; - den += 1.0; - } - } - debug_assert!( - den > 0.0, - "every item has an observed response by validation" - ); - let prop = (num / den).clamp(0.02, 0.98); - intercept[i] = (prop / (1.0 - prop)).ln(); - } - - let mut loglik_trace: Vec = Vec::new(); - let mut converged = false; - let mut n_iter = 0usize; - let mut theta = vec![0.0f64; n_persons * n_dims]; - - let mut post = vec![0.0f64; n_nodes]; // reused per-person buffer (no N x Q^D storage) - let mut log_p1 = vec![0.0f64; n_nodes * n_items]; - let mut log_p0 = vec![0.0f64; n_nodes * n_items]; - - // Correlated traits (estimate_corr): free correlations `r_off` (pairs i() - 1.0).abs() < 1e-9, - "posterior sums to 1" - ); - if cfg.estimate_corr { - for (mg, &pg) in m_g.iter_mut().zip(post.iter()) { - *mg += pg; - } - } - for i in 0..n_items { - let idx = p * n_items + i; - if observed[idx] { - let yy = y[idx]; - let base = i * n_nodes; - for g in 0..n_nodes { - n_ig[base + g] += post[g]; - r_ig[base + g] += yy * post[g]; - } - } - } - } - loglik_trace.push(total_ll); - - // Converged-check BEFORE the M-step so returned params match the trace endpoint. - if loglik_trace.len() > 1 { - let l = loglik_trace.len(); - if (loglik_trace[l - 1] - loglik_trace[l - 2]).abs() < cfg.tol { - converged = true; - break; - } - } - - // M-step: per-item (n_i+1)-dim Newton with ridge + backtracking line search. - for i in 0..n_items { - let dims = &dims_of[i]; - let ni = dims.len(); - let ni_off = i * n_nodes; - let mut a: Vec = dims.iter().map(|&d| loading[i * n_dims + d]).collect(); - let mut b = intercept[i]; - let ns = &n_ig[ni_off..ni_off + n_nodes]; - let rs = &r_ig[ni_off..ni_off + n_nodes]; - for _ in 0..cfg.newton_iter { - let (grad, amat) = item_grad_hess( - dims, - &a, - b, - ns, - rs, - cur_nodes, - n_dims, - n_nodes, - cfg.ridge_a, - cfg.ridge_b, - ); - let delta = solve_small(amat, grad); // A positive-definite => exact ascent step - let q0 = item_obj( - dims, - &a, - b, - ns, - rs, - cur_nodes, - n_dims, - n_nodes, - cfg.ridge_a, - cfg.ridge_b, - ); - // Backtracking: halve until the penalized item objective does not decrease. - let mut step = 1.0f64; - let mut accepted = false; - let (mut a_new, mut b_new) = (a.clone(), b); - for _ in 0..20 { - for k in 0..ni { - a_new[k] = (a[k] + step * delta[k]).clamp(-MIRT_A_BOUND, MIRT_A_BOUND); - } - b_new = b + step * delta[ni]; - let q1 = item_obj( - dims, - &a_new, - b_new, - ns, - rs, - cur_nodes, - n_dims, - n_nodes, - cfg.ridge_a, - cfg.ridge_b, - ); - if q1 >= q0 - 1e-12 { - accepted = true; - break; - } - step *= 0.5; - } - // A rejected backtracking step keeps the previous parameters and stops. Encoding - // that decision separately makes the rare near-maximum path directly testable. - let moved = accepted - .then(|| { - (0..ni).map(|k| (a_new[k] - a[k]).abs()).sum::() + (b_new - b).abs() - }) - .unwrap_or(f64::INFINITY); - if accepted { - a = a_new; - b = b_new; - } - if should_stop_item_newton(accepted, moved) { - break; - } - } - for (k, &d) in dims.iter().enumerate() { - loading[i * n_dims + d] = a[k]; - } - intercept[i] = b; - } - - // Correlation (Sigma) M-step: gradient ascent on Q_prior over the free correlations, - // with backtracking + a full-matrix PD (Cholesky) guard so each step is non-decreasing - // (keeps the ECM marginal loglik monotone). The complete-data Q separates additively - // into item terms + the Gaussian prior, so this block is independent of the item M-step. - if cfg.estimate_corr { - // C = (1/N) sum_g m_g theta_g theta_g^T (posterior second moment; theta_g is - // person-independent, so the marginal node mass m_g factors the N-loop out). - let nf = n_persons as f64; - let mut cmat = vec![0.0f64; d * d]; - for g in 0..n_nodes { - let w = m_g[g] / nf; - for a1 in 0..d { - let ta = theta_nodes[g * d + a1]; - for b1 in 0..d { - cmat[a1 * d + b1] += w * ta * theta_nodes[g * d + b1]; - } - } - } - let current = r_off.clone(); - let baseline = marginal_loglik_on_nodes( - y, - observed, - &loading, - &intercept, - &dims_of, - n_persons, - n_items, - n_dims, - &theta_nodes, - &logw, - ); - let mut target = current.clone(); - for _ in 0..cfg.newton_iter { - let sigma = build_corr(&target, d); - let grad = sigma_grad(&sigma, &cmat, d) - .expect("the accepted correlation matrix remains positive definite"); - let q0 = sigma_qprior(&sigma, &cmat, d) - .expect("the accepted correlation matrix remains positive definite"); - let gnorm = grad.iter().map(|x| x * x).sum::().sqrt(); - if gnorm < 1e-10 { - break; - } - let next = corr_line_search(&target, &grad, q0, &cmat, d); - let Some(candidate) = next else { break }; - target = candidate; - } - if let Some((candidate, candidate_nodes)) = corr_marginal_line_search( - ¤t, &target, baseline, y, observed, &loading, &intercept, &dims_of, - n_persons, n_items, n_dims, &nodes, &logw, - ) { - r_off = candidate; - theta_nodes = candidate_nodes; - } - } - n_iter += 1; - } - - // Final pass under the returned parameters: trait EAP for every person, and the marginal - // loglik of those parameters (pushed when EM exited on max-iter, so the trace endpoint - // matches the returned params — on convergence the last E-step already supplied it). - if cfg.estimate_corr { - assert!(map_corr_nodes(&r_off, &nodes, d, &mut theta_nodes)); - } - let final_nodes: &[f64] = if cfg.estimate_corr { - &theta_nodes - } else { - &nodes - }; - for g in 0..n_nodes { - for i in 0..n_items { - let mut eta = intercept[i]; - for &d in &dims_of[i] { - eta += loading[i * n_dims + d] * final_nodes[g * n_dims + d]; - } - log_p1[g * n_items + i] = log_sigmoid(eta); - log_p0[g * n_items + i] = log_sigmoid(-eta); - } - } - let mut final_ll = 0.0f64; - for p in 0..n_persons { - for (g, slot) in post.iter_mut().enumerate() { - let mut acc = logw[g]; - for i in 0..n_items { - let idx = p * n_items + i; - if observed[idx] { - let yy = y[idx]; - acc += yy * log_p1[g * n_items + i] + (1.0 - yy) * log_p0[g * n_items + i]; - } - } - *slot = acc; - } - let m = post.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let mut denom = 0.0f64; - for v in post.iter() { - denom += (v - m).exp(); - } - final_ll += m + denom.ln(); - for (g, v) in post.iter().enumerate() { - let pg = (v - m).exp() / denom; - for d in 0..n_dims { - theta[p * n_dims + d] += pg * final_nodes[g * n_dims + d]; - } - } - } - if !converged { - loglik_trace.push(final_ll); - let l = loglik_trace.len(); - if (loglik_trace[l - 1] - loglik_trace[l - 2]).abs() < cfg.tol { - converged = true; - } - } - - // Per-dimension reflection anchor: flip dimension d (all loadings on d and all theta_d) so - // its largest-|loading| PURE anchor item loads positively. Flipping theta_d -> -theta_d - // negates corr(theta_d, theta_k), so the correlation off-diagonals of row/col d must flip - // too (likelihood-invariant relabeling). Flips commute across dimensions. - reflect_mirt_dimensions( - &mut loading, - &mut theta, - &mut r_off, - &dims_of, - n_persons, - n_items, - n_dims, - ); - - let n_free_loadings = loading_pattern.iter().filter(|&&v| v == 1).count(); - let l = loglik_trace.len(); - let final_loglik_change = (loglik_trace[l - 1] - loglik_trace[l - 2]).abs(); - let n_parameters = n_free_loadings + n_items + if cfg.estimate_corr { n_off } else { 0 }; - Ok(TwoPlResult { - loading, - intercept, - theta, - n_dims, - corr: build_corr(&r_off, d), - loglik_trace, - n_iter, - converged, - termination_reason: if converged { - "converged" - } else { - "max_iter_reached" - } - .into(), - final_loglik_change, - n_parameters, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/twopl_tests.rs"] -mod tests; diff --git a/crates/mlsirm-core/src/utility.rs b/crates/mlsirm-core/src/utility.rs deleted file mode 100644 index f00afee54..000000000 --- a/crates/mlsirm-core/src/utility.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! Selection utility analysis for personnel decisions. -//! -//! Implements the three classical selection-utility results under the standard -//! bivariate-normal predictor-criterion model: -//! -//! - Taylor-Russell success ratio (Taylor & Russell, 1939), -//! - Naylor-Shine mean standardized criterion of the selected group -//! (Naylor & Shine, 1965), -//! - Brogden-Cronbach-Gleser linear utility gain (Cronbach & Gleser, 1965). -//! -//! # Model -//! -//! Predictor `X` and criterion `Y` are standard bivariate normal with -//! correlation `rxy` (the validity coefficient). Top-down selection retains -//! `X > xc` with selection ratio `sr`, so `xc = Phi^-1(1 - sr)`. For -//! Taylor-Russell, "success" is `Y > yc` with base rate `br`, so -//! `yc = Phi^-1(1 - br)`. -//! -//! Verified formulas (adversarially checked against a scipy oracle and the -//! CRAN iopsych 0.90.1 `R/utility.R` + `R/ai.R` source, which was read in -//! full; see the crate test module for the pinned oracle values): -//! -//! - selection intensity `ux(sr) = phi(xc) / sr` (truncated-normal mean -//! `E[X | X > xc]`; iopsych `ux`, ai.R line 221), -//! - Naylor-Shine `pux = rxy * ux(sr) = E[Y | X > xc]` (from -//! `Y = rxy X + sqrt(1-rxy^2) Z` with `Z` independent), -//! - BCG utility gain `n * period * sdy * pux - cost_total` (iopsych -//! `utilityBcg`; note iopsych labels `cost` "per applicant" but never -//! multiplies by `n`, so the argument here is documented as a TOTAL cost), -//! - Taylor-Russell success ratio `P(Y > yc | X > xc) = Q(xc, yc, rxy) / sr`, -//! where `Q(h, k, rho) = P(X > h, Y > k)`. iopsych's `trModel` computes the -//! equivalent `qa / (qa + qb)`; the identity `qa + qb = sr` was verified -//! both algebraically and numerically during spec review. -//! -//! `Q` is evaluated by the elementary conditional-normal representation -//! `Q(h, k, rho) = int_h^inf phi(x) Phi((rho x - k)/sqrt(1-rho^2)) dx` -//! with composite Gauss-Legendre quadrature. Accuracy vs the scipy oracle -//! (`tests/oracles/oracle_utility.py`): ~1e-15 at moderate `|rho|` -//! (spec-review fixtures, asymmetric points incl. negative rho), and -//! better than 1e-6 across the whole accepted `|rho|` range -//! (regression-tested at `|rho| = 0.999999`; observed error ~1e-11 there). -//! -//! # Scope -//! -//! REDUCED SCOPE relative to the broader utility literature: only the three -//! models above are implemented. The Boudreau (1983) financial extension and -//! Raju-Burke-Normand (1990) model in iopsych are NOT implemented here. -//! `|rxy| >= 1` is rejected (degenerate BVN); use the analytic limits -//! documented in the spec if needed. -//! -//! # References -//! -//! Abramowitz, M., & Stegun, I. A. (Eds.). (1964). *Handbook of mathematical -//! functions with formulas, graphs, and mathematical tables* (Applied -//! Mathematics Series 55). National Bureau of Standards. -//! -//! Cronbach, L. J., & Gleser, G. C. (1965). *Psychological tests and -//! personnel decisions* (2nd ed.). University of Illinois Press. -//! -//! Goebl, A., Jones, J., & Beatty, A. (2016). *iopsych: Methods for industrial/ -//! organizational psychology* (Version 0.90.1) \[R package\]. CRAN. -//! -//! -//! Naylor, J. C., & Shine, L. C. (1965). A table for determining the increase -//! in mean criterion score obtained by using a selection device. *Journal of -//! Industrial Psychology, 3*(2), 33-42. -//! -//! Taylor, H. C., & Russell, J. T. (1939). The relationship of validity -//! coefficients to the practical effectiveness of tests in selection: -//! Discussion and tables. *Journal of Applied Psychology, 23*(5), 565-578. -//! -//! -//! (Citation note: the Naylor-Shine and Taylor-Russell papers' formulas are -//! standard truncated-normal/BVN results that were independently re-derived -//! and verified numerically; the implementation transcribes the iopsych -//! source, which was actually read. The Cronbach-Gleser page-level formula -//! was confirmed against iopsych's `utilityBcg`, not the book itself.) - -use crate::fitstats::erfc; -use crate::nodes::inv_normal_cdf; - -/// Result of a Brogden-Cronbach-Gleser / Naylor-Shine utility analysis. -#[derive(Debug, Clone, Copy)] -pub struct SelectionUtilityResult { - /// Predictor cutoff `xc = Phi^-1(1 - sr)`. - pub xc: f64, - /// Selection intensity `ux = phi(xc) / sr = E[X | X > xc]`. - pub ux: f64, - /// Naylor-Shine mean standardized criterion of the selected group, - /// `pux = rxy * ux`. - pub pux: f64, - /// Brogden-Cronbach-Gleser utility gain - /// `n * period * sdy * pux - cost_total`. - pub utility_gain: f64, -} - -/// Result of a Taylor-Russell (1939) analysis. -#[derive(Debug, Clone, Copy)] -pub struct TaylorRussellResult { - /// Success ratio `P(Y > yc | X > xc)` among those selected. - pub success_ratio: f64, - /// The base rate `br` supplied (echoed for reporting). - pub base_rate: f64, - /// Joint upper-tail probability `Q(xc, yc, rxy) = P(X > xc, Y > yc)`. - pub q_joint: f64, -} - -/// Standard normal pdf. -#[inline] -fn norm_pdf(x: f64) -> f64 { - (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt() -} - -/// Standard normal CDF via the crate's `erfc` approximation. -#[inline] -fn norm_cdf(x: f64) -> f64 { - 0.5 * erfc(-x / std::f64::consts::SQRT_2) -} - -/// Upper-tail bivariate-normal probability `Q(h, k, rho) = P(X > h, Y > k)` -/// for standard margins, via the conditional-normal representation -/// `int_h^inf phi(x) Phi((rho x - k)/sqrt(1 - rho^2)) dx` (elementary result; -/// ~1e-15 vs scipy's BVN CDF at moderate `|rho|` during spec review, and -/// regression-tested to better than 1e-6 at the accepted `|rho|` extremes — -/// see `tests/oracles/oracle_utility.py`). -/// -/// Composite Gauss-Legendre quadrature (16-point panels) over -/// `[h, max(h, 8) + 2]`; beyond that upper limit `phi(x) < 1e-22` and the -/// truncation error is negligible relative to the `erfc` approximation error. -/// The panel width is `min(0.25, sqrt(1 - rho^2) / 2)` so that the -/// conditional-CDF transition (scale `sqrt(1 - rho^2)`) is always resolved; -/// with a fixed 0.25 width, `|rho|` near 1 was inaccurate by ~1e-3 -/// (impl-review finding). Callers must reject `sqrt(1 - rho^2) < 1e-4` -/// (`|rho| > ~0.999999995`) before calling; below that the panel count -/// needed for accuracy is unbounded. -fn bvn_upper(h: f64, k: f64, rho: f64) -> f64 { - // 16-point Gauss-Legendre nodes/weights on [-1, 1] (Abramowitz & Stegun - // Table 25.4, symmetric halves). - const GL_X: [f64; 8] = [ - 0.0950125098376374, - 0.2816035507792589, - 0.4580167776572274, - 0.6178762444026438, - 0.755404408355003, - 0.8656312023878318, - 0.9445750230732326, - 0.9894009349916499, - ]; - const GL_W: [f64; 8] = [ - 0.1894506104550685, - 0.1826034150449236, - 0.1691565193950025, - 0.1495959888165767, - 0.1246289712555339, - 0.0951585116824928, - 0.0622535239386479, - 0.0271524594117541, - ]; - let s = (1.0 - rho * rho).sqrt(); - let upper = h.max(8.0) + 2.0; - let panel_w = 0.25f64.min(0.5 * s); - let n_panels = ((upper - h) / panel_w).ceil() as usize; - let mut q = 0.0f64; - for p in 0..n_panels { - let a = h + p as f64 * panel_w; - let b = (a + panel_w).min(upper); - let c = 0.5 * (a + b); - let half = 0.5 * (b - a); - for j in 0..8 { - for sign in [-1.0f64, 1.0] { - let x = c + sign * half * GL_X[j]; - q += GL_W[j] * half * norm_pdf(x) * norm_cdf((rho * x - k) / s); - } - } - } - q.clamp(0.0, 1.0) -} - -fn check_unit_open(v: f64, name: &str) -> Result<(), String> { - if !v.is_finite() || v <= 0.0 || v >= 1.0 { - return Err(format!("{name} must be strictly inside (0, 1), got {v}")); - } - // Sub-ulp guard: for v below ~half an ulp at 1.0, `1.0 - v` rounds to - // exactly 1.0 and `inv_normal_cdf(1.0)` returns NaN, which previously - // leaked as a NaN/silent-zero Ok result (impl-review HIGH finding). - if 1.0 - v == 1.0 { - return Err(format!( - "{name} = {v} is too close to 0 to resolve in f64 (1.0 - {name} rounds to 1.0)" - )); - } - Ok(()) -} - -fn check_validity(rxy: f64) -> Result<(), String> { - if !rxy.is_finite() || rxy <= -1.0 || rxy >= 1.0 { - return Err(format!( - "validity rxy must be strictly inside (-1, 1), got {rxy}" - )); - } - Ok(()) -} - -/// Brogden-Cronbach-Gleser utility of a selection system with Naylor-Shine -/// selected-group mean. -/// -/// See the module docs for the model and references. Arguments: -/// `n` applicants selected per period (>= 1), `sdy` monetary SD of -/// performance (>= 0), `rxy` validity in (-1, 1), `sr` selection ratio in -/// (0, 1), `cost_total` total selection cost (finite), `period` expected -/// tenure (>= 1). -pub fn selection_utility( - n: f64, - sdy: f64, - rxy: f64, - sr: f64, - cost_total: f64, - period: f64, -) -> Result { - if !n.is_finite() || n < 1.0 { - return Err(format!("n must be finite and >= 1, got {n}")); - } - if !sdy.is_finite() || sdy < 0.0 { - return Err(format!("sdy must be finite and >= 0, got {sdy}")); - } - check_validity(rxy)?; - check_unit_open(sr, "selection ratio sr")?; - if !cost_total.is_finite() { - return Err(format!("cost_total must be finite, got {cost_total}")); - } - if !period.is_finite() || period < 1.0 { - return Err(format!("period must be finite and >= 1, got {period}")); - } - let xc = inv_normal_cdf(1.0 - sr); - let ux = norm_pdf(xc) / sr; - let pux = rxy * ux; - let utility_gain = n * period * sdy * pux - cost_total; - if !xc.is_finite() || !utility_gain.is_finite() { - return Err(format!( - "selection_utility produced non-finite output for sr={sr}" - )); - } - Ok(SelectionUtilityResult { - xc, - ux, - pux, - utility_gain, - }) -} - -/// Taylor-Russell (1939) success ratio for a dichotomous criterion. -/// -/// `rxy` validity in (-1, 1), `sr` selection ratio in (0, 1), `br` base rate -/// in (0, 1). Returns `P(Y > yc | X > xc)` under the standard BVN model. -pub fn taylor_russell(rxy: f64, sr: f64, br: f64) -> Result { - check_validity(rxy)?; - check_unit_open(sr, "selection ratio sr")?; - check_unit_open(br, "base rate br")?; - // The quadrature in `bvn_upper` resolves the conditional-CDF transition - // only while sqrt(1 - rxy^2) >= 1e-4; reject nearer-degenerate rxy - // instead of returning an inaccurate value (impl-review MEDIUM finding). - if (1.0 - rxy * rxy).sqrt() < 1e-4 { - return Err(format!( - "validity rxy = {rxy} is too close to +/-1 for accurate BVN quadrature \ - (requires sqrt(1 - rxy^2) >= 1e-4)" - )); - } - let xc = inv_normal_cdf(1.0 - sr); - let yc = inv_normal_cdf(1.0 - br); - // Probability bound: Q = P(X > xc, Y > yc) <= min(sr, br); quadrature - // round-off could otherwise overshoot slightly (impl-review LOW finding). - let q_joint = bvn_upper(xc, yc, rxy).min(sr.min(br)); - let success_ratio = (q_joint / sr).clamp(0.0, 1.0); - if !success_ratio.is_finite() || !q_joint.is_finite() { - return Err(format!( - "taylor_russell produced non-finite output for rxy={rxy}, sr={sr}, br={br}" - )); - } - Ok(TaylorRussellResult { - success_ratio, - base_rate: br, - q_joint, - }) -} - -#[cfg(test)] -#[path = "../../../tests/unit/utility_tests.rs"] -mod tests; diff --git a/tests/unit/proptest_neg_loglik_tests.rs b/crates/mlsirm-core/tests/proptest_neg_loglik.rs similarity index 96% rename from tests/unit/proptest_neg_loglik_tests.rs rename to crates/mlsirm-core/tests/proptest_neg_loglik.rs index 1e58e2691..44c35d703 100644 --- a/tests/unit/proptest_neg_loglik_tests.rs +++ b/crates/mlsirm-core/tests/proptest_neg_loglik.rs @@ -16,7 +16,7 @@ //! proptest is MIT / Apache-2.0 licensed, so it is safe for this MIT crate. It //! runs under the standard `cargo test`, so CI needs no extra toolchain. -use crate::{ModelConfig, ModelType, Params, PenaltyConfig}; +use mlsirm_core::{ModelConfig, ModelType, Params, PenaltyConfig}; use proptest::prelude::*; const MODEL_TYPES: [ModelType; 5] = [ @@ -107,7 +107,7 @@ proptest! { let mask_ref = mask.as_deref(); let (objective, grad, loglik) = - crate::neg_loglik_and_grad(&y, mask_ref, &factor_id, ¶ms, &config, &penalty); + mlsirm_core::neg_loglik_and_grad(&y, mask_ref, &factor_id, ¶ms, &config, &penalty); // Gradient shapes must exactly match the configured dimensions. prop_assert_eq!(grad.theta.len(), config.n_persons * config.n_dims); diff --git a/docs/mmle_marginal_lsirm_design.md b/docs/mmle_marginal_lsirm_design.md deleted file mode 100644 index cd289b608..000000000 --- a/docs/mmle_marginal_lsirm_design.md +++ /dev/null @@ -1,221 +0,0 @@ -# Model-design PR: Marginal (MMLE) estimation for latent-space models, with multigroup and multilevel extensions - -Status: implemented by this PR. Paper basis is compiled in -[`docs/papers/mmle-lsirm-formula-compilation.md`](papers/mmle-lsirm-formula-compilation.md) -(per-equation verification legend inside); primary sources: Jeon, Jin, Schweinberger & Baugh (2021, -Psychometrika, doi:10.1007/s11336-021-09762-5), Bock & Aitkin (1981, doi:10.1007/BF02293801), -Bock & Zimowski (1997, doi:10.1007/978-1-4757-2691-6_25), Fox & Glas (2001, doi:10.1007/BF02294839), -Orlando & Thissen (2000, doi:10.1177/01466216000241003), Snijders (2001, doi:10.1007/BF02294437), -Cai (2010, doi:10.1007/s11336-009-9136-x). - -## 1. Scope - -This PR generalizes the existing `estimator="mmle"` path (previously unidimensional 2PL only, -`crates/mlsirm-core/src/mmle.rs`) to a **marginal EM estimator for all five model variants** -(`MIRT`, `MLS2PLM`, `MLSRM`, `ULS2PLM`, `ULSRM`) under the repo's simple-structure contract - -```text -eta_pi = a_i * theta_p,d(i) + b_i - gamma * ||xi_p - zeta_i||, a_i = exp(alpha_i), gamma = exp(tau) -``` - -and adds two estimation-level population structures (previously only post-hoc diagnostic strata): - -- **Multigroup** (Bock–Zimowski): group-specific trait distributions - `theta_pd ~ N(mu_gd, sigma_gd^2)` with common (anchored) item parameters and a fixed reference - group `mu_1d = 0, sigma_1d = 1`. -- **Multilevel** (Fox–Glas random intercept): `theta_pd = sigma_u * u_c + e_pd`, - `u_c ~ N(0,1)` shared across the trait dimensions of persons in cluster `c`, - `e_pd ~ N(0,1)`; `sigma_u` estimated (ICC = sigma_u^2 / (1 + sigma_u^2)). - -It also adds likelihood-based fit statistics (S-X², l_z, l_z*) and an item-screening pipeline -(`docs/papers/mmle-lsirm-formula-compilation.md` §7–§9), and a serving-bundle export for scoring -new respondents with frozen item parameters. - -## 2. Marginal likelihood - -Person latents are random effects; item quantities are structural parameters -(formula compilation §3.A): - -- `theta_p ∈ R^D`, independent `N(mu_gd, sigma_gd^2)` per dimension (defaults `N(0,1)`), -- `xi_p ∈ R^K ~ MVN_K(0, I)`, -- structural: `alpha, b, zeta, tau` (+ `mu_g, sigma_g` per non-reference group, or `sigma_u`). - -```text -L = prod_c ∫ phi(u) prod_{p in c} L_p(u) du (multilevel; u vanishes when sigma_u = 0) -L_p(u) = ∫_{R^K} phi_K(x) prod_d [ ∫ phi(t) prod_{i in d, obs} P_pi^y (1-P_pi)^{1-y} dt ] dx -``` - -with `theta_pd = mu_gd + sigma_gd * t + sigma_u * u` inside `eta`. The **key tractability point**: -under simple structure the trait dimensions are conditionally independent given `xi_p`, so the -integral costs `Q_xi^K * (Q_u) * sum_d Q_theta` — NOT `Q^{1+D+K}`. For the supported `K ≤ 2` this -makes deterministic Gauss–Hermite quadrature feasible; the curse-of-dimensionality warning in the -formula compilation (§3.A.2) applies to the unrestricted model, and MH-RM (Cai 2010) remains the -documented alternative if `K ≥ 3` support is ever needed. A deterministic E-step is also what makes -the Rust↔NumPy 1e-6 parity contract testable, which a stochastic MH-RM path would break. - -## 3. EM algorithm - -Quadrature: probabilists' Gauss–Hermite, weights normalized to sum 1 (same convention as the -existing `mmle.rs` table). Defaults: `Q_theta = 21`, `Q_xi = 11` per latent axis (tensor grid, -`11^K` nodes), `Q_u = 15`. - -**E-step.** Item-response tables are person-independent: -`logP1_i(t, x, s) = log sigmoid(eta_i(t, x, s))`, where `s` indexes the population context -(group `g`, or u-node `v`; absent in the single-population case). Per person: - -```text -l_pd(t | x, s) = sum_{i in d, obs} [ y * logP1 + (1-y) * logP0 ] -logL_pd(x, s) = logsumexp_t [ log w_t + l_pd(t|x,s) ] -logL_p(s) = logsumexp_x [ log w_x + sum_d logL_pd(x, s) ] -``` - -Binary sparsity trick: `l_pd(t|x,s) = C_d(t,x,s) + sum_{i in d: y=1} delta_i(t,x,s)` where -`C_d = sum_{i in d} logP0` is shared and `delta_i = logP1 - logP0`, so the person pass scales with -the count of positive responses, with per-cell corrections for missing entries. - -Posteriors (given cluster coupling): `post_c(v) ∝ w_v prod_{p in c} L_p(v)` at cluster level, then -`post_p(t, x | v)` person-level. Expected counts (Bock–Aitkin "artificial data"): - -```text -nbar_d(t,x,s) = sum_p post_p(t,x,s) r_i(t,x,s) = sum_{p: y_pi=1} post_p(t,x,s) -nbar_i = nbar_{d(i)} - (corrections for cells missing on item i) -``` - -**M-step.** -- Per item `i`: Newton/gradient ascent on the expected binomial log-likelihood - `sum_{t,x,s} [ r_i log P_i + (nbar_i - r_i) log(1-P_i) ]` over `(alpha_i, b_i, zeta_i)` - (only `b_i` for `MLSRM/ULSRM`; no `zeta_i` for `MIRT`), with the L2 penalties of - `PenaltyConfig` (MAP-flavored MMLE; keeps sparse items finite — cf. lognormal slope priors in - the 2PL LSIRM package, compilation §2.2). -- Global `tau`: 1-D Newton on the same expected log-likelihood. -- Multigroup: `mu_gd = E_g[theta_pd]`, `sigma_gd^2 = E_g[(theta_pd - mu_gd)^2]` from posterior - moments; reference group pinned. -- Multilevel: `sigma_u^2 <- (1/C) sum_c E[u_c^2 | Y]`. - -Convergence: absolute change of marginal log-likelihood `< tol` (same contract as `mmle.rs`). - -## 4. Identifiability - -- Translation/scale of `theta`, `xi`: fixed by the `N(0,1)` / `MVN(0,I)` population distributions - (compilation §1.4). -- Rotation/reflection of the latent space: `zeta` is identified up to orthogonal maps. For - deterministic, run-to-run comparable output the fitted `zeta` (and EAP `xi`) are PCA-aligned: - rotate so the principal axes of `zeta` coincide with the coordinate axes, sign-fixed so each - axis's largest-|coordinate| item is positive. Procrustes to an external reference remains the - documented option for cross-fit comparisons. -- Multigroup: common item parameters are the anchor; reference group `N(0,1)` pins the scale - (Bock–Zimowski). Multilevel: `E[u]=0`, `Var(e)=1` pin the intercept scale. - -## 5. Fit statistics (new `diagnostics` additions) - -- **S-X² (Orlando & Thissen 2000)** per trait dimension: summed score over the items of dim `d`; - `E_is` via the Lord–Wingersky recursion evaluated on the joint `(t, x)` grid with prior weights - (compilation §7.1); score groups collapsed to expected count ≥ 1; p-values from - `chi2(df = #collapsed groups - m_i)`, `m_i = 2 + K` for `MLS2PLM` (per-model from exec flags). -- **l_z and l_z\* (Drasgow et al. 1985; Snijders 2001)** per person and trait dimension, evaluated - at EAP `theta` with `xi` fixed at its EAP (documented approximation; MAP-case correction - `r_0(theta) = -theta` for the `N(0,1)` prior), formulas exactly as compilation §8. -- Existing infit/outfit MNSQ statistics are reused unchanged. - -## 6. Item screening pipeline (compilation §9) - -`select_items()` iterates: fit → flag → drop → refit, with an audit trail. Flags per round: - -1. pre-screen: fewer than `min_positive` (default 20) positive (or negative) responses; -2. S-X² significant after Benjamini–Hochberg FDR (q = .05); -3. infit/outfit outside `[0.7, 1.3]` (Wright & Linacre 1994 working band); -4. low discrimination `a_i < 0.35` (2PL variants); -5. map isolation: `gamma * mean_p ||xi_p - zeta_i||` a robust-z outlier (> 3) among items. - -An item is removed when it fails ≥ 2 of flags 2–5 (or flag 1 alone); persons with -`l_z* < -1.645` are excluded from the *flagging statistics* (not from the final fit). The loop -stops when nothing is removed or a floor (default 4 items per dimension) is reached. - -## 7. Serving bundle - -`export_serving_bundle(...)` writes a single JSON (plus optional `.npz` mirror) containing: -schema version, model/config, item codes, `alpha/a, b, zeta, tau/gamma`, population parameters -(`mu_g, sigma_g` per group, `sigma_u`), quadrature spec, and the item screening audit. A -`score_respondents(bundle, responses)` function (and `fast-mlsirm score` CLI) computes EAP -`theta`, `xi`, standard errors, and `l_z*` for new response vectors with item parameters frozen — -the same fixed-parameter scoring pattern as the downstream importance-assessment API (mirt -`mod2values`-style freeze, Chalmers 2012). - -## 8. Implementation layout & parity - -- `crates/mlsirm-core/src/marginal.rs` — f64 CPU reference: quadrature tables, E-step, M-step, - multigroup/multilevel contexts (single-threaded, deterministic). -- `crates/mlsirm-core/src/gpu.rs` — new f32 WGSL entry points for the person pass and - expected-count reduction (race-free slot-ownership pattern, same as the JML kernels); `~1e-4` - agreement, CPU fallback when no adapter. -- `crates/fast-mlsirm-py/src/lib.rs` — `fit_mmle_marginal(...)` PyO3 wrapper. -- `python/fast_mlsirm/estimators/marginal.py` — NumPy mirror (the parity reference), same - quadrature tables bit-for-bit. -- `python/fast_mlsirm/fit.py` — `estimator="mmle"` routes latent-space/multidim models (and - `group_id=`/`cluster_id=`) to the new path; the legacy `fit_mmle_2pl` fast path is kept for - plain `ULS2PLM/ULSRM` without grouping. -- Tests: `tests/test_marginal_parity.py` (Rust↔NumPy 1e-6 on marginal loglik + E-step moments + - fitted params on small fixtures), `tests/test_estimator_marginal.py` (simulate→fit recovery for - each variant and each population structure), `tests/test_fitstats.py` (S-X² against hand-computed - small cases; l_z* properties), Rust unit tests in `marginal.rs`. - -## 9. Non-goals - -- Full general-discrimination MLS2PLM (separate model-design PR per AGENTS.md). -- MH-RM engine (documented alternative for `K ≥ 3`). -- Polytomous responses; inner-product (HLSIRM-style) interaction term. - -## 10. Phase 2 additions (QMC/MC-EM, scoring, FIPC — implemented) - -Paper basis: Part II of the formula compilation (Wei & Tanner 1990; Booth & -Hobert 1999; Jank 2005; Meng & Schilling 1996; Bock & Mislevy 1982; Thissen, -Pommerich, Billeaud & Williams 1995; Lord & Wingersky 1984 via Cai 2015; -Kim & Cohen 1998; Hanson & Beguin 2002; Kim 2006; Sinharay & Haberman 2014). - -- **Integration rules** (`nodes.rs`): the latent-space integral accepts - tensor Gauss-Hermite (default, `K <= 3`), Halton QMC with an optional - Cranley-Patterson shift (QMC-EM; `O(N^-1 (log N)^K)` error), or seeded - Monte Carlo (MCEM). All deterministic given their parameters, so the - Rust<->NumPy parity contract extends to them. `FitConfig(xi_rule=..., - xi_points=..., xi_seed=...)`. -- **Scoring** (`scoring.rs`, all-Rust compute): EAP (Bock-Mislevy), MAP - (damped posterior Newton, observed-information SEs), and EAPsum summed- - score conversion tables via the Lord-Wingersky recursion run on the joint - `(t, x)` node set. Priors are per-dimension `N(mean_d, sd_d^2)`: standard, - group `(mu_g, sigma_g)`, cluster-conditional `N(u_hat_c, 1)`, or the - multilevel marginal `N(0, sqrt(1 + sigma_u^2))` for unknown clusters - (`serving_prior`). Serving exposes `method="eap"|"map"|"eapsum"` and the - bundle embeds the conversion tables. -- **Fit statistics** (`fitstats.rs`, all-Rust compute): S-X2 with the - `rms_residual` practical-significance effect size — added after the first - 31k-person run showed BH-significance alone removes 45/57 items (the - chi-square is over-powered at large N); the screening MSQ gate uses infit - only (outfit explodes under <1% pass rates); the `l_z*` screen threshold is - configurable and its MAP `r_0` correction centers on the population prior - mean (team intercepts / group means). -- **FIPC** (`Anchors` + `PopulationSpec::SingleFree`): anchored items (and - optionally `tau`) frozen at supplied values, new items and the freed - population `(mu_d, sigma_d)` estimated each EM cycle — the multiple-cycle - prior-update variant (MWU-MEM-style) Kim (2006) found robust. PCA - re-alignment is skipped so the anchor orientation is inherited. - **Concurrent calibration** is the multigroup path plus structural - missingness (Hanson-Beguin common-item design) — covered by - `concurrent_calibration_two_forms_with_anchor_block`. -- **Compute placement**: every numeric path (estimation, scoring, fit - statistics) executes in `mlsirm-core`; the Python layer is orchestration, - I/O, and the NumPy parity references only. - -## 11. Known numerical notes - -- Multigroup/multilevel EM moves the quadrature nodes when `(mu, sigma)` / - `sigma_u` update, so the quadrature APPROXIMATION of the marginal - log-likelihood can dip by discretization error (~1e-4 on small fixtures) - even though exact EM is monotone; tests allow 1e-3 absolute slack. -- The GPU E-step accumulates in f32 (~1e-4 relative noise): convergence - tolerances below the noise floor never trigger — use a tolerance around - `1e-5 * |loglik|` or an iteration budget for GPU runs. The M-step and the - final EAP pass always run on the CPU in f64. -- 2PL-LSIRM slopes are weakly identified against item positions (the - Bayesian original fixes `alpha_1 = 1`); the lognormal slope prior keeps - them finite, and slope recovery needs materially more data than easiness - recovery. diff --git a/docs/papers/corpus-triage-batch3.md b/docs/papers/corpus-triage-batch3.md deleted file mode 100644 index 8868e2104..000000000 --- a/docs/papers/corpus-triage-batch3.md +++ /dev/null @@ -1,117 +0,0 @@ -# Corpus triage — batch 3 (~100 papers) - -Disposition of the third supplied reading set. "This batch" = implemented in -the current change set; "covered" = the capability already exists (with the -earlier citation basis); "roadmap" = requires a capability the binary -marginal engine does not have yet (reason given); "foundational/context" = -reviews, applications, or textbooks that inform documentation, not code. - -## Implemented in this batch - -| Paper | Feature | -|---|---| -| Magis (2013), item information of the 4PL | closed-form 4PL item information (`item_information_4pl`, reduces to the 2PL at `c=0, d=1`); item/test information surfaces in the scoring module | -| Bock & Mislevy (1982), adaptive EAP estimation | sequential EAP scoring + maximum-information CAT item selection over a frozen bank | -| Wang, Kuo & Chao (2010), MCAT system | the CAT loop generalized to the multidimensional simple-structure bank (per-dimension information targeting) | -| Marsman et al. (2016), plausible values | posterior plausible-value draws from the scoring grid (secondary-analysis exports) | -| Guo, Zheng & Chang (2015), stepwise TCC drift | repository-specific fixed-threshold backward-elimination screen over same-scale TCC area; it does not implement the paper's alternating item-entry/removal steps or locally optimal linking-set search | -| Haberman, Sinharay & Chon (2013), residual item fit | standardized residuals of observed vs estimated ICCs on the score grid | -| Sinharay (2016), resampling person fit | fixed-EAP conditional Monte Carlo approximation for `l_z*` (add-one-smoothed empirical lower-tail frequencies); the complete generalized resampling procedure, including replicate-wise ability re-estimation, is not implemented | -| Tay & Drasgow (2012), adjusted chi2/df | Exploratory repository-specific item-pair ratios only. The paper finds a fixed cutoff insufficient and recommends a parametric bootstrap; that inferential procedure is not implemented. | - -## Already covered (earlier basis) - -- Bock & Lieberman (1970); Bock & Mislevy EAP; Meng & Schilling (1996); - Drasgow, Levine & Williams (1985); Tay et al. (2011); Ames & Penfield - (2015, NCME item-fit module); Sueiro & Abad (2011, nonparametric fit - context); Sinharay (2006, Bayesian item fit → PPMC documented-only); - Sinharay (2015, mixed-format person fit → binary case covered): the - marginal EM, EAP/EAPsum, S-X2, l_z/l_z* stack. -- Jeon & Rijmen (2016, flirt modularity); Chalmers (2015 MH-RM mixed-effects; - 2016 mirtCAT): engine-design references — the modular estimator axes - (population, anchors, covariate, mixture) mirror flirt's design; MH-RM - remains the documented alternative engine. -- Williamson-framework relatives (Bennett 1991/2011; Martinez & Bennett 1992; - Ramineni & Williamson 2013; Higgins et al. 2011; Zechner et al. 2015; Liu - et al. 2014; Advancing Human Assessment 2017): the `validate_judge` gate - set is the operational core; these inform its documentation and thresholds. -- Wilson, De Boeck & Carstensen (2008, explanatory IRT): person-side - explanation = multigroup/multilevel structures; item-side (LLTM) is on the - roadmap below. -- Lord (1974, omitted responses); Kadengye et al. (2014); Bolsinova & Maris - (2016): MAR handling by direct marginal likelihood is the implemented - position; not-reached/omit distinctions documented. -- Finch & Pierson (2011, mixture IRT): the zero-inflated mixture is the first - instance; general C-class mixtures on the roadmap. -- Ferrando (2016, person discrimination/fluctuation): person-fit family - covers the diagnostic use; the person-discrimination parameter itself is a - model extension (roadmap, low priority). - -## Roadmap (capability gaps, with reasons) - -- **Polytomous responses** — Muraki (1990) GPCM; Penfield (2014); Muraki & - Carlson (1995); Dodd et al. (1995 CAT); Kang & Chen-style S-X2 - generalizations; Likert/two-decision (Thissen-Roe & Thissen 2013); ERS - models (Jin & Wang 2014); rating-scale distances (2004): the engine is - binary-only; a categorical-kernel model family is a separate model-design - PR. -- **3PL/4PL response functions** — Barton & Lord (1981): estimating lower/ - upper asymptotes changes the response kernel across the table builder and - every M-step; the information side (Magis) landed first, the estimation - side is the next model-design PR (the table architecture keeps the GPU - kernels untouched). -- **Bifactor family (`BIFAC2PLM`)** — Gibbons & Hedeker (1992), Cai, Yang & - Hansen (2011): the general factor slots into the engine's conditional- - factorization E-step exactly like the latent-space coordinate (the - Gibbons-Hedeker dimension reduction), so this is the highest-leverage next - model variant; then testlet models (Li, Li & Wang - 2010 polytomous testlets; Paek & Fukuhara 2015 testlet DIF), projective - equating (Kim & Cho 2019), vertical scaling with construct shift (Li & - Lissitz 2012), bifactor MCAT design (Seo & Weiss 2015), bifactor - dimensionality suites (Immekus & Imbrie 2008; Reise, Moore & Haviland - 2010): follow-ons once BIFAC2PLM stabilizes. -- **Cognitive diagnosis models** — DINA invariance (de la Torre & Lee 2010), - CDM framework (de la Torre & Minchen 2014), Wald DIF in CDM (Hou et al. - 2014), scaling hybrid (Bradshaw & Templin 2014), Wilson (2008): different - measurement paradigm (attribute mastery), out of the latent-trait engine's - scope. -- **MCMC/Bayesian estimation** — Patz & Junker (1999), Natesan et al. (2016), - Martin-Fernandez & Revuelta (2017), Revuelta & Ximenez (2017), Sinharay - (2006 PPMC), Fox et al. (2014 randomized-response MIRT): AGENTS.md scopes - this package as intentionally not a Bayesian sampler; deterministic - EM/QMC-EM is the estimation contract. -- **Limited-information overall fit (M2/RMSEA2)** — Maydeu-Olivares & Joe - (2014), Cai-style adjustments: valuable; needs bivariate-margin delta - matrices and weighted chi-square tails — planned next after this batch. -- **Equating/linking beyond FIPC** — Ryan & Brockmann (2011 primer), Kim & - Lee (2006 mixed-format linking), Ali & van Rijn (2015 parallel-forms - targets — partially served by `assemble_test_form`), Bolsinova & Maris - (2016): concurrent calibration + FIPC cover the operational need here; - moment-based linking transformations (mean-mean/Stocking-Lord) are a small - future utility. -- **Item-side explanatory structure (LLTM)** — Wilson et al. (2008), Park & - Liu (2019), Embretson & Yang (2013 multicomponent): difficulty design - matrices `b = W delta`; natural extension of the covariate machinery. -- **Specialized response processes** — ideal-point models (Maydeu-Olivares - et al. 2006 GGUM-family; Carter & Dalal 2010; Chernyshenko 2007; - Tay 2011 covered as fit-comparison), forced choice (Hontangas et al. 2015), - response certainty (Ferrando et al. 2013), diffusion-IRT (van der Maas et - al. 2011), response-time effort (Wise & DeMars 2006), fMRI application - (Thomas et al. 2013), non-compensatory calibration (Wang & Nydick 2015): - distinct kernels; the latent-space distance term already gives one - ideal-point-like mechanism (documented relation). -- **Flexible/nonparametric ICCs** — Liang & Browne (2015 quasi-parametric), - Camilli & Fox (2015 aggregate EFA), Zhang (2013 dimensionality across - designs), Ip et al. (2013 functional unidimensionality): exploratory-side - tools; Q3/GDDM + dimensionality_diagnostics are the current instruments. - -## Foundational / context (documentation only) - -Lord (1980, Applications of IRT); Rabe-Hesketh, Skrondal & Pickles (2004 -GLLAMM — the general framework our multilevel structure instantiates); -Parsons & Hulin (1982); Schmitt, Cortina & Whitney (1993); Reise & Flannery -(1996); Edelen & Reeve (2007); Rusch et al. (2017); Christensen et al. -(2016); Terluin et al. (2018); Segall (2001); Ogasawara (2010); Manrique- -Vallier et al. (2014, structural zeros in categorical imputation — cf. the -ZI mixture); Sliter & Zickar (2014); Chernyshenko et al. (2001); remaining -application/review PDFs of the batch. diff --git a/docs/papers/corpus-triage-batch4.md b/docs/papers/corpus-triage-batch4.md deleted file mode 100644 index 3360cc252..000000000 --- a/docs/papers/corpus-triage-batch4.md +++ /dev/null @@ -1,99 +0,0 @@ -# Corpus triage — batch 4 (~75 papers) - -Disposition of the fourth supplied reading set (same legend as batch 3). - -## Implemented in this batch - -| Paper | Feature | -|---|---| -| Chen & Thissen (1997), local-dependence indexes for item pairs | `ld_indices()` — signed standardized pairwise LD X2 and G2 against the model-implied joint probabilities | - -## Already covered (earlier basis) - -- Orlando, Thissen & Thissen (2000): S-X² — implemented (batch 1 of this - branch). Kang & Chen (2008, 2011): its polytomous generalizations — the - binary case is implemented; polytomous with the response-kernel roadmap. -- Thissen, Pommerich, Billeaud & Williams (1995): EAPsum — implemented - (scoring.rs, conversion tables in the serving bundle). Cai (2015) - Lord-Wingersky 2.0: the recursion is implemented on the joint grid; the - hierarchical (bifactor) version lands with `BIFAC2PLM`. -- Cai (2010), Cai & Angeles (2015), Chalmers & Flora (2014 MH-RM): MH-RM - remains the documented alternative engine (AGENTS.md scopes the package to - deterministic EM; QMC-EM covers the high-dimensional-integral need). -- Wang (2010) IRT-ZIP: the structural-zero mixture is implemented for the - binary kernel; the count-response (Poisson) kernel is a response-kernel - roadmap item. -- Yuan, Cheng & Patton (2014): information-matrix SE comparison — the Oakes - observed-information estimator is implemented; the sandwich/XPD variants - are a small follow-on once misspecification-robust SEs are needed. -- Chalmers, Counsell & Flora (2016) DIF effect sizes: `dif_analysis` reports - logit-scale effect sizes; their DRF/sDRF integrals are a natural extension - of the same virtual-item machinery. -- Meade & Craig (2012) careless responding: the person-fit stack (l_z*, - resampling p-values, screening weights) is the operational instrument; - their survey-specific indices (longstring, even-odd) are preprocessing - utilities outside the model core. -- Liu & Maydeu-Olivares (2013, 2014): local-dependence diagnostics land with - `ld_indices`/Q3/GDDM/adjusted chi2; the source-of-misfit decomposition - belongs to the M2 roadmap item. -- Lord (1986 MLE/Bayes estimation), Bock & Mislevy EAP, Reckase (2009), - Carlson (1988), Wirth & Edwards (2007), Cai, Choi & Kuhfeld (2016 IRT - overview): estimation/scoring foundations of the implemented engine. -- Sulis & Toland (2017 multilevel IRT intro): the implemented multilevel - structure; Höhler et al. (2010) within/between multidimensionality: - covered by simple-structure multidim + multilevel intercepts. -- Makransky, Mortensen & Glas (2013 MCAT): `cat_next_item` implements the - multidimensional adaptive loop for the binary bank. - -## Roadmap (consolidated across batches; explicitly requested) - -Priority order for the next model-design PRs on this branch's foundation: - -1. **`BIFAC2PLM` bifactor family** — Gibbons & Hedeker (1992); Cai, Yang & - Hansen (2011); Cai & Hansen (2013); Cai (2015 LW 2.0); Toland et al. - (2017); Li & Rupp (2011 S-X² under bifactor); Liu & Thissen (2012 score - test); Huang et al. (2013 higher-order traits): the general factor enters - the existing conditional-factorization E-step exactly like the - latent-space coordinate (inner-product term `lambda_i * g`), so the - engine structure carries over; higher-order models follow as constrained - bifactor. -2. **Limited-information overall fit (M2, RMSEA2)** — Maydeu-Olivares & Joe - (2005, 2014); Maydeu-Olivares (2013); Cai & Hansen (2013); Hansen et al. - (2016); Liu & Maydeu-Olivares (2014): univariate+bivariate margins, - delta-matrix reduction, weighted chi-square tail. -3. **3PL/4PL estimation** — Barton & Lord (1981): response-kernel change; - the information side (Magis 2013) already landed. -4. **Polytomous response kernels** — Muraki (1990, 1993 GPCM + information); - Muraki & Carlson (1995); Falk & Cai (2016 monotonic-polynomial GPCM); - Thissen et al. (1995 already covers the scoring recursion); Kang & Chen - (2008, 2011); Emons (2008 polytomous person fit); Jiao & Zhang (2015 - polytomous multilevel testlets); response-style models (van Rosmalen et - al. 2010; Huang 2016 mixture random-effect ERS; Kam & Fan 2018; Wang et - al. 2014 wording effects): unlocks the largest cluster of remaining - papers; design decision — categorical kernel with per-category tables. -5. **Linking/equating utilities** — Yao & Boughton (2009 multidimensional - linking); Brossman & Lee (2013 MIRT observed/true-score equating); Chen - et al. (2009 common-scale linking): moment/characteristic-curve - transformations over the existing bank structures. -6. **Response-time integration** — van der Linden, Klein Entink & Fox - (2010 collateral information); Veldkamp (2016 RT in CAT); Partchev & De - Boeck (2013 power/speed): a lognormal RT sidecar likelihood sharing the - person posterior. -7. **Robust/misspecification tooling** — Bolt, Deng & Lee (2014 vertical- - scaling misspecification); Bonifay & Cai (2017 model complexity); Hooker, - Finkelman & Schwartzman (2009 paradoxical MIRT scoring — a documentation - caveat for multidimensional score reporting); Wang (2015 latent-trait - estimation properties). - -## Foundational / context (documentation only) - -Reise & Revicki (Handbook of Item Response Theory Modeling); van der Linden -(Handbook of IRT); Maydeu-Olivares (2013 GOF overview — the M2 roadmap's -frame); Holland (1990 sampling foundations); Wirth & Edwards (2007); -clinical/applied papers (Reise & Waller 2009; Reise & Haviland 2005; Velozo -et al. 2012; Chen et al. 2009 PROMIS-style linking application; Terluin et -al.; Martínez-Plumed et al. 2016 IRT-in-ML; Higgins & Heilman 2014 gaming -susceptibility — operational guidance for the judge-validation gates); -survey-nonresponse context (Shoemaker et al. 2002; Frick & Grabka 2010; -Si-Reiter nonparametric Bayesian imputation) — the MAR-marginalization -position plus the ZI mixture cover the modeling side. diff --git a/docs/papers/corpus-triage-batch5.md b/docs/papers/corpus-triage-batch5.md deleted file mode 100644 index 54183908f..000000000 --- a/docs/papers/corpus-triage-batch5.md +++ /dev/null @@ -1,73 +0,0 @@ -# Corpus triage — batch 5 (~44 papers) - -Disposition of the fifth supplied reading set (same legend as batches 3-4). - -## Implemented in this batch - -| Paper | Feature | -|---|---| -| Stanley & Edwards (2016), reliability and model fit; Milanzi et al. (2015), manifest vs latent correlation functions | `empirical_reliability()` — marginal EAP reliability per trait dimension, documented with the papers' caveat that the coefficient presumes a well-fitting model | - -## Already covered (earlier basis) - -- CAT applications — Makransky & Glas (2013 organizational MCAT); Haley et - al. (2009 item-bank replenishment = FIPC use case); the MCAT fatigue and - heterogeneous-population CAT applications: `cat_next_item` + - FIPC/anchoring implement the operational loop; Sawatzky et al. (2016) - motivates the mixture roadmap item below. -- AES — Loukina & Buzick (2017 spoken-language auto-scoring use): - `validate_judge` gates. -- Person misfit / aberrance — Tendeiro (2016 l_z(p) in unfolding contexts), - Wise (2017 rapid guessing — the RT-based flag is roadmap; the response- - pattern side is covered by l_z*/resampling person fit and the ZI class). -- Multilevel IRT applications — Pastor (2003); Frazier et al. (2015): - implemented multilevel structure. -- Unidimensional interpretations of multidimensional items — Kahraman - (2013); Ip & Chen (2012 projective IRT); Ip (2010 functionally - unidimensional): the marginal engine reports per-dimension EAPs and the - EAPsum tables give the "projected" unidimensional serving scale; the - formal projective-model transformation is noted under the linking roadmap. -- Reise bifactor cluster (2007, 2012), Thomas (2012), Zhang et al. (2014), - Reise, Moore & Maydeu-Olivares (2011 target rotations), Toland et al. - (2017): all reinforce the top roadmap item below. - -## Implemented after triage (same change set) - -- **`BIFAC2PLM` bifactor / inner-product interaction kind** — Gibbons & - Hedeker (1992); Cai, Yang & Hansen (2011); the Reise cluster. - `InteractionKind::{None, Distance, Inner}` now parameterizes every eta - site: `eta += dot(zeta_i, x)` (bilinear/Hoff form; the dichotomous - bifactor at `latent_dim = 1` with `lambda_i = zeta_i`), reusing the - conditional-factorization E-step (the Gibbons-Hedeker dimension - reduction), the tables, and the GPU kernels unchanged. Positive-manifold - loading init; tau inert; marginal estimator only (JML guards). Rust/NumPy - parity at 1e-9; loading-recovery tests in both suites. - -## Roadmap (consolidated; explicitly requested across batches) - -> Superseded by `corpus-triage-batch6.md`. **M2/RMSEA2 is now implemented** -> (`fitstats::m2_rmsea2`); the remaining items below carry forward. - -3. **General C-class mixture IRT** — Sawatzky et al. (2016); Carter et al. - (2011); Zickar et al. (2004 faking classes); Finch & Pierson (2011): the - ZI mixture generalizes (class-weighted E-step already exists); class- - specific item parameters are the added state. -4. **3PL/4PL estimation** (Barton-Lord; Falk & Cai 2016 semiparametric- - with-guessing strengthens the case). -5. **Polytomous kernels** — Thissen, Cai & Bock (2010 nominal model); - Böckenholt et al. (2017 response styles); De Jong et al. (2008 ERS); - Weijters et al. (2013 reversed items); Vispoel & Kim (2014); Wakita et - al. (2012); Woehr & Meriac (2010 polytomous DIF). -6. **Response-time integration** — Wise (2017 rapid-guessing flags); - Kyllonen & Zu (2016). -7. **Linking/equating + projective transformations** — Ip & Chen (2012). - -## Foundational / context (documentation only) - -Reise & Revicki / van der Linden handbooks (batch 4); Christensen, Kreiner & -Mesbah (2012 Rasch in health); Van der Ark et al. (2015 proceedings); Reeve -et al. (2007 PROMIS calibration practice — the operational template our -screening/serving pipeline mirrors); Brown, Inceoglu & Lin (2017 forced -choice); Luo et al. (2013 robust Bayesian longitudinal); Hamano & Sato -(2005 association rules via IRT); Blom et al. (2010 unit nonresponse); -remaining applied/clinical PDFs. diff --git a/docs/papers/corpus-triage-batch6.md b/docs/papers/corpus-triage-batch6.md deleted file mode 100644 index 2c71ab261..000000000 --- a/docs/papers/corpus-triage-batch6.md +++ /dev/null @@ -1,85 +0,0 @@ -# Corpus triage — batch 6 - -The sixth reading set is, by the sender's own note, largely a re-presentation -of batches 3–5 (the same ~190 PDFs) plus a short new head list. Rather than -re-litigate every duplicate, this note records the one **new** implementation -this round and points at the earlier triage docs for everything already -dispositioned. - -## Implemented in this batch - -| Paper(s) | Feature | -|---|---| -| Maydeu-Olivares & Joe (2005, 2006); Cai & Hansen (2013); Maydeu-Olivares (2013); Maydeu-Olivares & Joe (2014) | **M2 / RMSEA2 limited-information goodness-of-fit** — `mlsirm_core::fitstats::m2_rmsea2` (+ `fast_mlsirm.fitstats.m2`). Univariate + bivariate residual margins, df, χ² p-value, RMSEA2 with a 90% noncentral-χ² CI, and the bivariate SRMSR. Model-implied margins and the up-to-4th-order `Xi_2` covariance entries are exact via the local-independence factorization over the `(theta, xi)` node set (Cai-Hansen dimension reduction); `Delta_2` central-differenced; the quadratic form solved through one Cholesky of `Xi_2`. Rust compute path, NumPy parity reference (1e-6), calibration + local-dependence tests in both suites. | -| Kolen & Brennan (2014); Haebara (1980); Stocking & Lord (1983); Kim & Lee (2006); Yao & Boughton (2009); Brossman & Lee (2013) | **IRT scale linking** — `mlsirm_core::linking` (+ `fast_mlsirm.irt_link`). Moment (mean/mean, mean/sigma) and characteristic-curve (Haebara, Stocking-Lord) coefficients for the unidimensional 2PL/1PL common-item case, via a self-contained Nelder-Mead. Rust compute path; four-method recovery tests in both suites. | - -`M2/RMSEA2` and unidimensional common-item `irt_link` move from the roadmap to -done; M2 was the top consolidated roadmap item after `BIFAC2PLM` (batch 5). - -## Approximate-fit cluster (newly surfaced) — covered by the M2 work - -The batch adds a Maydeu-Olivares approximate-fit cluster that the M2/RMSEA2 -commit already implements or directly grounds: - -- **Maydeu-Olivares & Joe (2014), Maydeu-Olivares (2013)** — RMSEA2 and the - SRMSR are exactly the approximate-fit indices `m2_rmsea2` returns. -- **Maydeu-Olivares (2017, *Assessing the Size of Model Misfit in SEM*; - maydeu-olivares2017)** — the population-RMSEA / size-of-misfit framing that - motivates the RMSEA2 point estimate and its noncentral-χ² CI (both returned). -- **Forero & Maydeu-Olivares (2009, limited vs full information GRM); - Forero, Maydeu-Olivares & Gallardo-Pujol (2009, DWLS vs ULS)** — the - limited-information-estimation rationale behind M2 (order-2 residual margins); - full DWLS/ULS *estimation* of ordinal factor models is a separate polytomous - roadmap item, not the fit statistic. -- **Savalei & Rhemtulla (2013, robust test statistics with categorical data)** - — robust/scaled variants of the same limited-information χ²; the scaling - correction is a future refinement of `m2_rmsea2`. -- **Liu & Maydeu-Olivares (2013/2014, local dependence & source of misfit)** — - already covered by the Chen-Thissen LD indices and Q3/GDDM diagnostics. - -## Newly-listed head papers (first 10) — disposition - -- **Brossman & Lee (2013)** MIRT observed/true-score equating; **Yao & - Boughton (2009)** mixed-type MIRT linking; **Kim & Lee (2006)** linking - methods — the linking/equating roadmap item (below). The EAPsum + TCC - machinery already produces the score tables these procedures operate on. -- **Wang (2015)** latent-trait estimation in compensatory MIRT — covered by - the marginal engine's EAP/MAP per-dimension scoring. -- **van den Berg, Glas & Boomsma (2007)** variance decomposition via an IRT - measurement model — the multilevel random-intercept structure (σ_u²/ICC) - already reports the between/within variance split. -- **Woehr & Meriac (2010)** polytomous DIF; **Carter et al. (2011)** mixed-model - / ideal-point survey IRT; **Böckenholt et al. (2017)** response-style - multi-process IRT — the polytomous-kernel and C-class-mixture roadmap items. -- **Kahraman (2013)** unidimensional interpretation of multidimensional items — - covered (batch 5): per-dimension EAP + the EAPsum "projected" serving scale. -- **Pastor (2003)** applied multilevel IRT — implemented multilevel structure. -- **Khodadady & Ghanizadeh (2011)** EFL concept-mapping application; **Milanzi - et al. / "Reliability measures" (2015)** manifest-vs-latent correlation - functions — the latter is the `empirical_reliability` basis (batch 5); the - former is an applied study (context only). - -## Everything else - -Already dispositioned in `corpus-triage-batch3.md`, `-batch4.md`, and -`-batch5.md` (implemented, already-covered, or foundational/context). No new -dispositions are warranted for the duplicated tail. - -## Roadmap (consolidated; explicitly requested across batches) - -1. **General C-class mixture IRT** — Sawatzky et al. (2016); Carter et al. - (2011); Zickar et al. (2004 faking classes); Finch & Pierson (2011): the ZI - mixture generalizes (class-weighted E-step already exists); class-specific - item parameters are the added state. -2. **3PL/4PL estimation** (Barton-Lord 1981; Falk & Cai 2016 semiparametric- - with-guessing): response-kernel change; the table architecture keeps the GPU - path untouched (as for BIFAC2PLM). -3. **Polytomous kernels** — Muraki (1990 GPCM); Thissen, Cai & Bock (2010 - nominal model); Böckenholt et al. (2017); De Jong et al. (2008 ERS); - Weijters et al. (2013 reversed items); Woehr & Meriac (2010 polytomous DIF). -4. **Linking/equating extensions** — the unidimensional common-item case is - now covered by `irt_link` (moment + Haebara + Stocking-Lord); remaining: - multidimensional linking (Yao & Boughton 2009; Brossman & Lee 2013 MIRT - observed/true-score equating), Ip & Chen (2012) projective transforms. -5. **Response-time integration** — van der Linden et al. (2010); Wise (2017 - rapid-guessing flags); Kyllonen & Zu (2016). diff --git a/docs/papers/gpcm-nominal-design-spec.md b/docs/papers/gpcm-nominal-design-spec.md deleted file mode 100644 index f6ac026ea..000000000 --- a/docs/papers/gpcm-nominal-design-spec.md +++ /dev/null @@ -1,238 +0,0 @@ -# GPCM / Nominal Polytomous Kernel — Implementation Spec - -_Synthesized by the `gpcm-design` workflow (study → 3 designs → judge → merge). The base is a unified scoring-function softmax cell nesting binary 2PL, GPCM (Muraki), and nominal (Bock), reusing `eta_at_kind` and preserving binary bit-parity via a `ResponseModel::Bernoulli` branch._ - -## Parametrization - -UNIFIED SCORING-FUNCTION SOFTMAX CELL that nests binary 2PL, GPCM, and nominal in one kernel and REUSES `eta_at_kind` (marginal.rs L309-342) verbatim. - -Base kernel (unchanged): call `eta_at_kind` with a ZERO `b` slice so it returns base_i(theta,x) = a_i*theta + I_i(x), where a_i = exp(alpha[i]) if free_alpha else 1, and I_i is the existing interaction term (Distance = -exp(tau)*||x-zeta_i||, Inner = +dot(zeta_i,x), None = 0). This scoring-function form (from the `correctness` and `gpu-first` designs) is preferred over `minimal-diff`'s per-category-slope-plus-isolated-space form because base bundles slope+space exactly as `eta_at_kind` already returns it, so nominal needs NO decomposition of the space term (removes the one friction in the base design). - -Category logit (baseline category 0 pinned), k = 0..K-1: - psi_{i0} = 0 - psi_{ik} = s_{ik} * base_i(theta,x) + c_{ik} - P_{ik} = softmax_k(psi) = exp(psi_{ik}) / sum_h exp(psi_{ih}) - -Two links via ResponseModel: -- GPCM (Muraki 1992; PCM when free_alpha=false on Mlsrm/Ulsrm): s_{ik} = k FIXED (scores 0..K-1, not estimated). Free = { alpha_i (shared slope), c_{i1..K-1} (additive intercepts), zeta_i, global tau }. Steps NOT order-constrained -> no inequalities. Store ADDITIVE intercepts c_{ik} internally (not cumulative Muraki steps): the intercept gradient becomes the plain residual g_{c,m}=resid_m with no suffix-sum, and GPCM+nominal share one intercept path. Report Muraki step/difficulty via b_{i,k}=c_{i,k-1}-c_{i,k} (pure reporting reparametrization). -- NOMINAL (Bock 1972, scoring-function / operational rank-1 as in mirt & flexMIRT): pin a_i=1 (free_alpha forced false via model_exec_flags), free scoring s_{ik} and intercepts c_{ik} for k=1..K-1, baseline s_{i0}=c_{i0}=0. Fully-free per-category slopes a_{ik} (true multidimensional Bock) is a storage-only future extension; the gradient shape below is unchanged. - -Binary 2PL is exactly K=2, s=[0,1], c=[0,b_i]: psi_1=base+b_i=current eta, and since logsigmoid(eta)-logsigmoid(-eta)=eta, dlp[0]=eta is a FREE parity check. - -IDENTIFICATION: baseline c_{i0}=s_{i0}=0 fixes softmax translation. GPCM: scores fixed, alpha_i identified by N(0,1) theta prior + lambda_alpha, space by tau + PCA alignment (pca_align L1447-1547 UNCHANGED). Nominal: freeing both a_i and s_{ik} is non-identified (both scale base) -> pin a_i=1; label-switching mitigated by baseline-category identification + lambda penalties + ordered init s_k=k. Ragged K_i via global K=max K_i + per-item n_cat_i; slots k>=K_i get psi=-1e30 (prob underflows to 0), fixed K stride. - -CRITICAL MODELING DECISION (needs maintainer sign-off, not a free lunch): because P is divide-by-total, a category-CONSTANT additive term cancels in the softmax and has identically zero gradient (sum_k resid_k=0, the `minimal-diff` insight). Therefore the latent-space interaction I_i(x) MUST enter psi_k scaled by s_k -- category k feels the LSIRM distance s_k-fold ("distance felt k-fold"). Forced by identification, not optional. Consequence: the latent-space axis is category-ORDERED/monotone, so polytomous-LSIRM "nominal" is nominal only on the theta-slopes, not on the space dimension. Novel model, no external oracle; validate internally (NumPy mirror) + recovery sim only. - -## Likelihood integration (E-step cell) - -The category axis is fully consumed INSIDE table-build and the cell decomposition; the theta/xi quadrature (person_pass L468-504, GPU lp_pass/nbar_pass) is category-agnostic and BYTE-UNCHANGED because l_buf[d][t][x] still holds ONE scalar log-lik per (d,t,x) (item conditional-independence within a dimension preserved; each item contributes its observed category's log-prob). - -TABLES REPRESENTATION -- adopt `gpu-first`'s dlp, not `minimal-diff`'s full-K logp table (fewer hot-loop reads + cleaner binary parity check): replace Tables{logp1,logp0} with Tables{ dlp, lp0, c0, n_cat }: - dlp[k-1] = logP_{ik} - logP_{i0} = psi_{ik} (K-1 columns; logP_k-logP_0 = psi_k - psi_0 = psi_k) - lp0 = logP_{i0} (exact role of old logp0) - c0[(s*n_dims+d)*cell+..] = sum_{i in d} lp0[i] ("everyone in category 0" baseline; same shape/role as today's all-fail baseline) -dlp layout: ((s*n_items+i)*(K-1)+(k-1))*cell + t*n_x + x. lp0/c0 unchanged. - -BINARY BIT-PARITY RESOLUTION (resolves the tension both `correctness` and `gpu-first` flagged): build_tables_offset branches on ResponseModel, NOT a byte-identical-softmax-at-K=2 claim. - - ResponseModel::Bernoulli (DEFAULT, dominant binary/ZI hot path): fill via the EXISTING log_sigmoid -- dlp[0]=logsigmoid(eta)-logsigmoid(-eta), lp0=logsigmoid(-eta). person_pass previously computed `logp1[i]-logp0[i]` inline; hoisting that exact subtraction into dlp is bit-identical (same IEEE operands). => CPU binary output stays bit-for-bit; the regression gate is met by NOT touching the arithmetic. - - ResponseModel::Gpcm/Nominal: fill via HOST f64 max-subtract K-way softmax -- psi_k for k=0..K-1 (psi via eta_at_kind with b=0), m=max_c psi_c, logZ=m+ln(sum exp(psi_c-m)), lp0=-logZ, dlp[k-1]=psi_k, c0 += lp0. -So ONE reduction/GPU path serves K=2 and K>2 (no duplicated WGSL / person_pass) while the binary arithmetic path is preserved. GPCM-with-K=2 (softmax path) is validated to reduce to Bernoulli within relative ~1e-12 as a SEPARATE reduction-correctness test -- exercises the softmax path without risking the default. - -PERSON_PASS decomposition (marginal.rs L453-467; only these lines change) -- the c0 + sparse-correction trick and the whole performance argument survive verbatim: - l_buf[d] = c0[s][d] // everyone in category 0 - for i in miss[p]: l_buf[d] -= lp0[i] // remove missing from baseline (UNCHANGED, k=0 table) - for (i,k) in resp[p], k>=1: l_buf[d] += dlp[i][k-1] // swap cat0->cat_k, ONE read (dlp already = logP_k-logP_0) -Observed category-0 responses are never in the response list, cost nothing, stay pooled in c0. K=2: dlp[0]=eta reproduces `+= logp1-logp0` byte-for-byte. The logsumexp reductions over t (L468-484) and x (L485-504), the person log-marginal, and the ZI mixture (zi_mix L510-520) are untouched. GPU cell_l (L83-101) mirrors this line-for-line. - -index_responses (L419-433) + ResponseIndex (L414): pos category-tagged -- keep `pos: Vec>` item ids plus parallel `pos_cat: Vec>`; push (i,cat) only for observed cat>=1; miss unchanged. ZI "structural zero" (L1819) = pos[p].is_empty() STILL holds (every observed response is category 0). - -## M-step gradient - -Expected complete-data multinomial objective and gradient collapse to a category residual + score-weighted residual that drop into the existing m_step_items shell. - -Counts per node: n = nbar - mbar (observed persons, dimension-pooled, UNCHANGED); r_k = rbar_k (expected cat-k count, k=1..K-1); r_0 = n - sum_{k>=1} r_k implicit (as binary r_0=n-r). item_q objective (L1067): - q += sum_{k=0..K-1} r_k * logP_k = n*lp0 + sum_{k>=1} r_k*dlp[k-1] // dlp[k-1]=psi_k - -CATEGORY RESIDUAL VECTOR (generalizes scalar resid=r-n*prob at L1165; sum_k resid_k=0): - resid_k = r_k - n*P_k, k=1..K-1 -For any parameter phi (psi_0=0 => dpsi_0/dphi=0): dq/dphi = sum_{k>=1} resid_k * dpsi_{ik}/dphi - -Per coordinate, psi_k = s_k*base + c_k: -- Intercepts (K-1), dpsi_h/dc_m=[h==m]: g_{c,m} = resid_m. (K=2: g_{c,1}=r-n*prob = current g_b L1167. Diagonal, one residual per intercept -- why additive c_k beats Muraki-step storage.) -- Base coords (alpha, zeta, tau, covariate), dpsi_k/dbase=s_k: SCORE-WEIGHTED RESIDUAL R = sum_{k>=1} s_k*resid_k; then - g_alpha = R*(a*theta) (SAME deta as L1170) - g_zeta[j] = R*deta_zeta[j] (SAME deta_zeta L1176-1185: Distance gamma*(x_j-zeta_j)/dist, Inner x_j) - g_tau (m_step_tau L1303-1321) uses R in place of scalar resid - GPCM s_k=k => R = sum_k k*resid_k; nominal R = sum_k s_k*resid_k. -- Nominal free scoring s_m, dpsi_h/ds_m=[h==m]*base: g_{s,m} = resid_m*base. - -COVARIATE OFFSET CORRECTNESS FIX (from `correctness`, a real K>2 bug): the ItemCovariate offset currently added flat to eta (build_tables L388, m_step_delta L1388-1425) CANCELS in the softmax if added equally to every psi_k. It MUST fold into `base` (psi_k = s_k*(base + w*delta) + c_k). Then g_delta = R*w, I_delta = V*w^2. This scales the covariate effect by s_k -- the only correct divide-by-total form; a fully category-specific covariate effect is a future extension. - -DIAGONAL FISHER PRECONDITIONER (keep per-coordinate, reuse the whole m_step shell): - V = n*Var_P(s), Var_P(s) = sum_k P_k s_k^2 - (sum_k P_k s_k)^2 // replaces n*prob*(1-prob) L1166 - i_alpha=V*(a*theta)^2 ; i_zeta[j]=V*deta_zeta[j]^2 ; i_tau=V*(gamma*dist)^2 ; i_delta=V*w^2 - i_{c,m}=n*P_m*(1-P_m) // (K=2, s in {0,1}: V=n*P_1(1-P_1)) -Dropped intercept off-diagonal cross-terms (-n*P_m*P_l): generalized-EM ascent + the existing 30-step Armijo backtrack on item_q (L1215-1236) backstop it. Upgrade to a (K-1)x(K-1) Newton block on intercepts ONLY if the M-step measurably stalls at large K. The damped step d=g/(I+lambda) (L1201-1205), slope check, alpha clamp [-6,3] (L1228) UNCHANGED; d_b becomes a length-(K-1) vector. - -GUARD (verified hazard -- do NOT change): keep `if n<=0.0 && r<=0.0 continue` at marginal.rs L1050/1138/1306/1406 and oakes.rs L164; generalize to poly as `if n<=0.0 && r_k.iter().all(|&r| r<=0.0) continue`. `minimal-diff`'s `if n<=0 continue` drops n~0,r>0 nodes and perturbs even the binary FP trajectory -- rejected. - -init (L1756-1767): additive intercepts c_{ik} from empirical log(p_k/p_0) (cumulative-category proportions); nominal scoring s_k init=k. n_free_parameters (marginal.rs L157, NOT lib.rs): per_item +(K-2) GPCM; nominal +(K-1) scoring +(K-1) intercept minus the freed alpha. - -## GPU / wgpu plan - -GPU-FIRST DISCIPLINE (strongest parity idea, from `gpu-first`): the K-way softmax runs on the HOST in f64 inside build_tables_offset (mirroring today's log_sigmoid); the GPU consumes only precomputed dlp offsets and runs the IDENTICAL logsumexp reductions. => NO new f32 softmax surface, NO new parity risk class. dlp magnitudes O(1-10) like today's logp1-logp0; low-prob categories give large-negative dlp (~-40) that exp-underflow to 0 identically in f32/f64. Parity stays ~1e-4 relative. M-step (build_tables f64, all gradients f64) and final EAP stay CPU-f64. - -Reductions lp_pass (L104), nbar_pass (L145), and the online logsumexp over t/x are UNCHANGED -- cell_l returns the same scalar; nbar (person count) is category-free so nbar_pass is byte-identical. - -E-step SHADER (18 -> 19 bindings): -- binding 2 (logp1) -> `dlp: array` [ctx][item][(Kmax-1)][t][x]. binding 1 (logp0) -> `lp0`, binding 3 (c0) unchanged shape/role. -- ADD binding 18 `pos_cats: array` parallel to pos_items (binding 9): category k>=1 of each non-baseline response. miss stays item-only. Uniforms gains `n_cat: u32` (reuse the _pad slot). Update the (0..18) layout loop bound (L244), keep the read/write classification (12|13|15 read_write), and the score shader 19->20. -- cell_l (L83-101, ~4 lines): v=c0[...]; pos loop k=pos_cats[j], idx=((s*n_items+i)*(n_cat-1)+(k-1))*cell + t*n_x+x, v += dlp[idx]; miss loop v -= lp0[base0]. Structure identical, K=2 identical numbers. -Note: for binary, dlp is host-computed f64(logp1)-f64(logp0) then cast to f32, vs old device-side f32(logp1)-f32(logp0); shifts GPU BINARY by ~1e-7 (f32 eps) -- well inside the existing ~1e-4 GPU-vs-CPU tolerance and NOT the bit-parity gate (that gate is CPU-only). Call it out in the GPU test. - -rbar_k on GPU -- REUSE item_pass (L176-204) VERBATIM, ZERO new WGSL (all three designs converge, verified): the host bins persons by chosen category into K-1 item-major CSR lists (item_off_k / item_persons_k -- the direct generalization of today's single positive list, which is category-1-only). run_reduce is dispatched K-1 times, each binding the k-th CSR and pointing out_acc at rbar[k]'s slice. Categories are fixed across EM iterations, so binning is one-time. mbar stays ONE item_pass over the category-free miss CSR; nbar_pass unchanged. out_acc sizes [ctx][item][t][x]*(K-1). Total work ~ one sparse pass over non-baseline responses, split by category. - -Host (e_step_gpu L392-589 / GpuEStepInputs L336-358): build dlp instead of logp1; add pos_cats + K-1 per-category item CSRs; loop the rbar dispatch over categories. score_pass SHADER (L631-716 / GpuScoreInputs): same cell_l edit (dlp + pos_cats + n_cat); the EAP-moment loop is category-free. Existing bounds n_dims/latent_dim<=8, q_t<=41 unchanged; add Kmax<=~16 for buffer sizing. - -Ragged K_i: pad to global Kmax (unused slots filled -1e30 on host so exp->0 with no WGSL branch); add a per-item n_cat u32 binding only if memory measurably bites. JML/mmle GPU path (gpu.rs) stays binary-only (hard-error guard). - -## Rust data model - -Gate everything by an ORTHOGONAL response axis so the binary hot path is provably unchanged and ModelType does NOT explode into a K x interaction grid (all three designs agree; keep the `minimal-diff` enum shape + `gpu-first`'s n_cat field): - -- lib.rs: `pub enum ResponseModel { Bernoulli, Gpcm, Nominal }` (Copy/Eq, default Bernoulli). Add to ModelConfig (L167): `response_model: ResponseModel`, `n_cat: usize` (=max K_i, default 2), `cat_counts: Option>` (None => uniform K). ModelType (L19) UNCHANGED -- stays the interaction/slope axis; GPCM x Distance x free_alpha = GPCM-LSIRM, GPCM x Distance x Mlsrm(fixed alpha) = PCM compose for free. model_exec_flags (L84): free_alpha=false when response_model==Nominal (pins a_i=1). Poly permitted only for the marginal estimator. - -- Tables (marginal.rs L294-298): {logp1,logp0} -> { dlp: Vec, lp0: Vec, c0: Vec, n_cat: usize }. dlp = [ctx][item][(n_cat-1)][t][x] holding psi_k; binary => 1 column (dlp=eta). UNIFY (no PolyTables variant): the logsumexp/nbar/item reductions are identical for K=2 and K>2; duplicating ~200 lines of WGSL + person_pass to keep the name logp1 is copy-paste rot. The binary bit-parity test (Bernoulli fills dlp via log_sigmoid) locks the unification. Two scoring.rs readers update: eapsum_tables `tables.logp1[i*cell+c].exp()` -> read dlp[i*(K-1)*cell + 0*cell + c] as psi_1 (P_1/P_0 ratio path). - -- ResponseIndex (L414-417): `pos: Vec>` + parallel `pos_cat: Vec>`; miss unchanged. index_responses (L419-433) pushes (i,cat) only when observed cat>=1. - -- EStep.rbar (L523-540): stride x(K-1), idx ((s*n_items+i)*(K-1)+(k-1))*cell + t*n_x+x. nbar, mbar UNCHANGED (category-free, dimension-pooled). accumulate_person (L545-597) routes post into rbar[k-1] by observed category. - -- Item params (lib.rs Params/Gradients L224-242, MarginalResult L176-209): `b` -> flattened additive intercepts n_items*(n_cat-1), row-major b[i*(K-1)+(v-1)] (K=2 => 1/item, same layout). Add `s: Vec` scoring weights n_items*(n_cat-1), populated only for Nominal (GPCM s implicit=k, stored empty). GPCM alpha stays len n_items; nominal alpha unused. Provide slope_stride(response_model)+intercept_stride(K) helpers so every site derives the stride once (guards the K-1 off-by-one that would corrupt silently across anchors/init/validate). - -- ItemBank (scoring.rs L21-33): `b` reinterpreted 2-D n_items x (K-1); add n_cat, cat_counts, response_model, optional s. EAP/PV follow person_pass automatically; MAP/Lord-Wingersky/information are Phase 4. - -- validate (L1605 / py L394): allow y in 0..n_cat-1. ZI all_zero (L1819): structural zero = every observed response in category 0 (pos[p].is_empty() still holds). n_free_parameters (marginal.rs L157): +(K-2) GPCM; nominal +(K-1)+(K-1)-1. - -- Anchors/FIPC (fit_marginal_anchored): anchors carry K-1 thresholds/item (+K-1 scoring for nominal); route through slope_stride helper. - -- PyO3 bridge (fast-mlsirm-py/src/lib.rs -- OMITTED by base designs, required for Python reach): fit_marginal (L235)->core_fit_marginal_full (L362) must plumb response_model/n_cat/cat_counts into ModelConfig (L105/276/987) and accept/return flat 2-D b + s (L344 area); parse_model_type (L1428) unchanged (response_model is a new arg, not a ModelType string). - -- Python mirror: types.py MLSIRMParams.b -> 2-D thresholds + optional s; marginal.py tracks Rust line-for-line. - -## Parity & tests - -Parity oracle = the NumPy mirror (python/fast_mlsirm/estimators/marginal.py), already pinned line-for-line to Rust by its docstring; extend it to K FIRST, then diff Rust against it (the discipline all three designs share). - -1. BINARY REGRESSION GATE (highest priority, the unification guard): response_model=Bernoulli must make the ENTIRE existing binary suite (crates/mlsirm-core/tests/marginal_recovery.rs + proptest_neg_loglik.rs + scoring/oakes/fitstats tests) pass BIT-IDENTICAL. Achieved by construction: Bernoulli fills dlp via the existing log_sigmoid (hoisted subtraction), so no arithmetic changes. Cross-check dlp[0]==eta. GPU binary rides ~1e-7 inside the existing 1e-4 tolerance (note, not gate). -2. Land the Tables{dlp,lp0,c0} refactor as a VALIDATED NO-OP first (step 2): full binary suite green BEFORE any polytomous math lands. -3. GPCM-K=2 REDUCTION TEST: fit response_model=Gpcm K=2 (softmax path) on the binary fixture; must equal the Bernoulli fit to relative ~1e-12 (resolves the byte-parity tension: softmax path exercised and shown to reduce, without the default hot path riding it). Do NOT claim bit-identical for this path (logsumexp vs log_sigmoid differ at ~1e-13). -4. NumPy golden parity (GPCM then nominal, K=3 and K=4, tiny fixture ~6 persons x 4 items): Rust CPU == Python to ~1e-9 on (a) per-node cell log-lik from build_tables/person_pass, (b) E-counts nbar/rbar_k/mbar, (c) M-step gradient g_c/g_alpha/g_zeta/g_tau/g_delta and Fisher diag. Primary correctness gate. -5. GPCM == Nominal(s frozen=k): fit nominal with scoring locked to integers; must equal the GPCM fit -- validates the scoring generalization shares one code path. -6. RECOVERY (Monte Carlo, extend marginal_recovery.rs with GPCM/nominal simulators): simulate GPCM from known (alpha, additive intercepts, zeta, tau), fixed seed, N~2000; fit; RMSE < ~0.1 per param family, theta-EAP correlation > ~0.95. Repeat nominal (scoring/intercept recovery up to the a_i=1 identification). -7. GPU-vs-CPU: rbar_k, lp within ~1e-4 relative at K=3; verify the K-1 dispatch rbar sums equal CPU rbar_k. -8. HAND CHECK (ponytail self-check, one runnable assert per nontrivial branch): a 3-category softmax with known psi -> assert sum P_k=1, resid sum-to-zero, V>=0, and R by hand; assert K=2 GPCM residual == r-n*prob. -9. ORDERED-vs-UNORDERED gate: assert lord_wingersky / eapsum REJECT response_model=Nominal (summed score not sufficient for unordered categories) -- else summed-score EAP is silently wrong. -10. Edge cases: empty category (r_k=0 -> intercept driven by -n*P_k, clamp like alpha [-6,3], floor P_k, assert no NaN); ragged K_i (-1e30 sentinel path); all-responses-in-one-category item; missing interleaved with polytomous; ZI redefinition; anchored polytomous item; K=2 nominal == binary. -11. External cross-check (loose): mirt R GPCM on a public Likert set; item params within a few %. (No external oracle exists for the k-scaled latent-space term -- recovery sim is the only check there.) -12. SE/fit (Phase 4): oakes.rs SEs finite and sane on the GPCM recovery fit; fitstats item-fit correct for a known-fit GPCM item. - -## Files touched - -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/src/lib.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/src/marginal.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/src/gpu_marginal.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/src/scoring.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/src/oakes.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/src/fitstats.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/src/gpu.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/src/mmle.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/mlsirm-core/tests/marginal_recovery.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/crates/fast-mlsirm-py/src/lib.rs -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/python/fast_mlsirm/estimators/marginal.py -- C:/Users/Seongho Bae/lsirt/fast-mlsirm/python/fast_mlsirm/estimators/types.py - -## Risks - -- MODELING (needs maintainer sign-off before build): the latent-space term is forced to enter psi_k scaled by s_k (a category-constant space term has zero gradient because sum_k resid_k=0), making the LSIRM space axis category-monotone. Polytomous-LSIRM is novel; no external oracle -- correctness is guaranteed only vs the NumPy mirror + recovery sim, and the GPCM/nominal cores match Muraki/Bock. -- PARITY HAZARD (verified, do NOT introduce): keep the existing `if n<=0.0 && r<=0.0 continue` guard (marginal.rs L1050/1138/1306/1406, oakes.rs L164); `minimal-diff`'s `if n<=0 continue` drops n~0,r>0 nodes and perturbs even the binary trajectory. Generalize to `n<=0 && all r_k<=0`. -- COVARIATE bug for K>2: the ItemCovariate/delta offset must move from flat eta (build_tables L388, m_step_delta) into `base`, or it cancels in the softmax and silently nulls the covariate. Fix folds it into base with g_delta=R*w. -- SCOPE: this is a 5-phase PR series, not one PR. The default-Bernoulli flag lets each phase land green independently, but bundling GPU+nominal+scoring+SE+Python into one review is unrealistic. Phase 4 (scoring/SE) is NOT optional for an ordered-rubric deliverable -- Phase 1 fits GPCM but cannot score/report. -- OMITTED-FILE debt (now in files_touched): oakes.rs (SEs: per_item L49-52 no thresholds, resid=r-n*sigmoid L188, y=1.0 cross-term ~L423) and fitstats.rs (item fit, per_item L1299) independently re-implement the binary path and need the K-1 threshold/scoring params, the category/score-weighted residual, and polytomous rbar. SEs are first-class output; neither is auto-derived from person_pass. -- SOFTMAX overflow at grid edges: the host K-way softmax MUST max-subtract or it infs at extreme |theta|*s_k; log_sigmoid was inherently stable. -- NOMINAL identification: freeing both a_i and s_k is non-identified -> pin a_i=1 (free_alpha=false); label-switching mitigated by baseline-category constraint + lambda penalties + ordered init s_k=k. Document. -- rbar memory grows x(K-1) (c0/nbar/mbar unchanged, so total grows sublinearly); fine for Likert K<=7, note for large item banks x multilevel contexts. M-step cost ~Kx per node with backtrack re-eval -- acceptable but hot. -- JML/mmle path stays binary-only: add a HARD error/return (NOT a debug_assert like assert_distance_kind, a no-op in release) in lib.rs neg_loglik_and_grad, mmle.rs, gpu.rs rejecting response_model!=Bernoulli. Polytomous is MMLE/marginal-only. -- Diagonal-only intercept preconditioner drops -n*P_m*P_l cross-terms; may need more inner M-steps at large K. Armijo on true q backstops; upgrade to a (K-1)x(K-1) Newton block only if it stalls. -- Phase-4 scoring is genuinely new math, not a gate: polytomous Lord-Wingersky must convolve a 0..K-1 per-item category distribution (score range 0..sum(K_i-1)), gated to ordered GPCM; eapsum table sizes grow; score_map needs a GPCM Newton gradient/Hessian. -- Variable K-1 stride ripples into anchors/FIPC, init b, validate, n_free_parameters, ItemBank; route every site through slope_stride/intercept_stride helpers or a silent off-by-one corrupts params. - -## Implementation steps - -- PHASE 1 -- CPU estimator core (shippable: fits GPCM on CPU). Step 1: lib.rs add ResponseModel{Bernoulli,Gpcm,Nominal} + ModelConfig fields (response_model,n_cat,cat_counts), default Bernoulli/K=2 so all existing code compiles and the binary suite stays green; model_exec_flags pins free_alpha=false for Nominal; add slope_stride/intercept_stride helpers; add HARD-error guards in neg_loglik_and_grad/mmle.rs/gpu.rs rejecting non-Bernoulli; relax validate L1605 + py L394 to 0..K-1. -- Step 2: refactor Tables{logp1,logp0}->{dlp,lp0,c0,n_cat}; build_tables_offset branches on response_model (Bernoulli fills dlp via log_sigmoid = hoisted subtraction, bit-identical); update the two scoring.rs readers. RUN FULL BINARY SUITE -> must be bit-green (proves the refactor is a no-op). -- Step 3: build_tables_offset Gpcm/Nominal branch = host f64 max-subtract K-way softmax (psi via eta_at_kind with b=0); fold the covariate offset into base. index_responses/ResponseIndex category-tagged pos + pos_cat; person_pass L453-467 `+= dlp[k-1]` decomposition (miss + logsumexp unchanged). GPCM-K=2 reduces to Bernoulli within 1e-12; K=3 vs NumPy to 1e-9. -- Step 4: EStep.rbar -> rbar_k (x(K-1)); accumulate_person routes post by observed category; nbar/mbar untouched. item_q multinomial objective; m_step_items resid_k, score-weighted R, K-1 intercept grads g_{c,m}=resid_m, V=n*Var_P(s) diagonal Fisher, d_b vector, keep guard + Armijo + alpha clamp. m_step_tau/m_step_delta use R,V. init additive intercepts from cumulative category proportions. n_free_parameters bump. GPCM recovery test (extend marginal_recovery.rs with a GPCM simulator). -- Step 5: extend the NumPy mirror (marginal.py, types.py) in lockstep -- softmax/dlp/rbar_k/gradient/init/validation; run the Rust<->Python golden harness (GPCM K=3,4) to 1e-9. Closes Phase 1. -- PHASE 2 -- GPU E-step. Step 6: Uniforms.n_cat; swap logp1->dlp + add pos_cats binding (18->19 layout, score 19->20); edit cell_l in both shaders (~4 lines); build K-1 per-category item CSRs on the host and dispatch item_pass K-1 times into rbar[k] (zero new WGSL); score_pass cell_l edit. GPU-vs-CPU parity K=3 to ~1e-4; note the ~1e-7 binary GPU shift. -- PHASE 3 -- Nominal. Step 7: a_i=1 via model_exec_flags; free scoring s_k with g_{s,m}=resid_m*base and R=sum s_k*resid_k; store s in Params/ItemBank/py; identification via baseline + prior + ordered init. Tests: Nominal(s=k)==GPCM, nominal recovery. -- PHASE 4 -- Scoring + SE (NOT optional for a polytomous scoring deliverable). Step 8: scoring.rs polytomous lord_wingersky (per-item 0..K-1 category convolution, score range 0..sum(K_i-1)), gated to ordered GPCM (reject Nominal); eapsum_tables expanded score range + category probs; score_map GPCM/nominal Newton (score-weighted residual, Hessian -a^2*Var_P(s)); item_information a^2*Var_P(s); plausible_values/score_eap follow person_pass automatically. Step 9: oakes.rs SEs -- extend ParamVec.per_item to K-1 thresholds (+scoring), the category/score-weighted Q-gradient, and polytomous rbar cross-terms; fitstats.rs polytomous item-fit. SE finiteness + fit-stat tests on the recovery fit. -- PHASE 5 -- Python reach. Step 10: PyO3 bridge fast-mlsirm-py/src/lib.rs -- plumb response_model/n_cat/cat_counts through fit_marginal->core_fit_marginal_full->ModelConfig and flat 2-D b + s in/out; estimator API + types.py + docs. Ship GPCM (covers PCM via fixed slope) as the built default; nominal shares the same softmax cell + a ~20-line distinct gradient block. - ---- - -## Literature resolution of the space-scaling design fork (2026-07-14) - -The synthesized spec flagged that under an adjacent-category **softmax (GPCM / -nominal)** cell a category-constant term cancels, so identification *forces* the -latent-space interaction `I(x) = -gamma*d(z,w)` to enter category-scaled -(`s_k * I(x)`), making the latent-space axis category-ordered. A literature -search (alphaXiv + Consensus) resolves this: - -- **Jeon, Jin, Schweinberger & Baugh (2021)**, *Mapping unobserved - item-respondent interactions: A latent space item response model* - (Psychometrika; arXiv:2007.08719) — the base LSIRM adds the interaction as a - **single scalar** on the linear predictor: - `logit P(Y=1) = alpha_j + beta_i - gamma*d(z_k, w_i)`. The paper states the - polytomous extension is "straightforward, by replacing the logit-link ... by a - suitable link function ..., as in generalized linear models" — i.e. the GLM / - link route, NOT a bespoke softmax. -- **Go, Kim, Park, Park, Jeon & Jin (2024/2025)**, *lsirm12pl: An R package for - the latent space item response model* (R Journal; arXiv:2205.06989) — the - authors' own package — extends LSIRM to **continuous** responses with an - identity link: `y = theta + beta - gamma*d(z,w) + eps`; again a single additive - interaction, now on the mean. They explicitly list ordinal LSIRM as - **in-progress future work** ("we are currently engaged in the development of ... - ordinal and longitudinal data"). So no published ordinal/polytomous LSIRM - exists yet: this is novel territory, and the original authors' intended route is - a link function, not a softmax. - -**Resolution.** For the LSIRM family the identification-clean polytomous -extension is the **cumulative-logit Graded Response Model (Samejima 1968)**, not -the adjacent-category GPCM softmax: - - logit P(Y >= k | theta, x) = a_i*theta + beta_{i,k} - gamma*d(z_k, w_i), k = 1..K-1 - P(Y = k) = P(Y >= k) - P(Y >= k+1) - -The single interaction `-gamma*d` enters every cumulative logit as a **shared -shift**. Cumulative-logit is NOT translation-invariant, so nothing cancels and NO -category-scaling is forced; the space axis keeps its original person-item -interaction-map meaning, exactly matching the binary (`-gamma*d` on the logit) and -continuous (`-gamma*d` on the mean) cases. This is the model the original authors -point to. - -- **GPCM** (adjacent-category softmax; Muraki 1992) remains a valid alternative - but carries the forced `s_k * I(x)` category-scaling — keep it as a documented - option for partial-credit scoring where score-weighting is intended, not the - default. -- **Nominal** (Bock 1972; multidimensional: Revuelta 2014; Falk & Cai 2015) - genuinely uses category-specific scoring functions, so category-specific space - entry is consistent with that model's philosophy. - -**Implementation implication.** Target **GRM-LSIRM (cumulative-logit)** as the -default polytomous model. The GRM cell replaces the softmax cell: -`P(Y=k) = Phi_k - Phi_{k+1}` with `Phi_k = sigmoid(a*theta + beta_{i,k} - gamma*d)`; -the softmax `category_logprobs`/`gpcm_node_gradient` oracle is retained for the -GPCM/nominal options only. Which model is primary depends on the target items' response -format: ordinal Likert / rubric levels -> GRM; partial-credit performance levels --> GPCM. diff --git a/docs/papers/group_a_specs.md b/docs/papers/group_a_specs.md deleted file mode 100644 index 25e2b5100..000000000 --- a/docs/papers/group_a_specs.md +++ /dev/null @@ -1,394 +0,0 @@ -# Group A — Implementation-Ready Specs for fast-mlsirm - -Target engine: **fast-mlsirm** (Rust core `mlsirm-core` + Python wrapper `fast_mlsirm`). -Estimation: marginal maximum likelihood (MMLE) by EM with Bock–Aitkin quadrature. -Person latents: trait `theta_p in R^D` (simple structure) + latent-space `xi_p in R^K`. -Item params: `(alpha, b, zeta, tau)`. Populations: single / multigroup / multilevel / FIPC. -Items: **binary only** for now. - -What the engine already has (verified in source), which shapes every feasibility call below: - -- `python/fast_mlsirm/diagnostics.py` already computes **AIC** (`2p − 2·loglik`) and **BIC** (`log(N)·p − 2·loglik`), plus MLE cross-validated held-out log-likelihood. -- `python/fast_mlsirm/inference.py` already exposes `observed_information` (finite-difference Hessian of the penalized negative log-likelihood — this is Pritikin's "central-difference full-parameter Hessian" benchmark), `vcov_from_hessian`, `standard_errors_from_vcov`, `second_order_test`. -- `python/fast_mlsirm/fitstats.py` + `crates/mlsirm-core/src/fitstats.rs` already implement **S-X²** (Orlando & Thissen 2000) with the Lord–Wingersky recursion on the `(theta, xi)` grid, plus `l_z`/`l_z*` and infit/outfit. -- `crates/mlsirm-core/src/marginal.rs` implements the EM map `M(theta)`, per-person posterior over quadrature nodes, and per-item M-step gradients — the exact primitives Oakes/SEM/Vuong need. - -Bottom line up front: - -| Paper | Core contribution | Feasibility for fast-mlsirm | -|---|---|---| -| 2 Pritikin | **Oakes' identity** for the observed-information matrix in EM | **Direct** — highest value; upgrades existing FD-Hessian SEs | -| 1 Schneider et al. | Vuong tests (distinguishability / non-nested / nested) | **Adaptation** — needs casewise scores + eigenvalue machinery | -| 3 Kang–Cohen–Sung | AIC/BIC/DIC/CVLL for model selection; BIC recommended | **Direct (AIC/BIC already done)**; DIC/CVLL document-only | -| 4 Svetina–Levy | Taxonomy/framework for dimensionality assessment | **Mostly document-only**; Q3 / GDDM / parallel analysis adaptable | -| 5 Sinharay–Lu | S-X² has clean Type-I error, no spurious item-param correlation | **Document-only** — validates the existing S-X² choice | - ---- - -## Paper 2 — Pritikin (2017): Parameter covariance estimation in an EM framework - -**Full citation.** Joshua N. Pritikin (2017). *A comparison of parameter covariance estimation methods for item response models in an expectation-maximization framework.* **Cogent Psychology** 4: 1279435. DOI: 10.1080/23311908.2017.1279435. (Open access, CC-BY.) - -**What it is.** A Monte-Carlo bake-off of methods that recover the parameter covariance matrix `V` (hence standard errors `SE = sqrt(diag(V))`) from an EM fit, which does *not* produce `V` natively. Contestants: the completed-data (M-step) Hessian, central-difference + Richardson extrapolation, the Supplemented-EM family (MR-SEM, Tian-SEM, Agile-SEM), and **Oakes' direct method**. Across four IFA models (`m2pl5`, `m3pl15`, `grm20`, `cyh1`) Oakes wins on accuracy (KL divergence, `||RD||_2`) *and* elapsed time, and never fails to converge, whereas MR-SEM/Tian-SEM fail on a large fraction of `grm20`/`cyh1` trials. - -### Exact estimating equations - -Let `L(theta | Y_o)` be the observed-data likelihood, `Y_m` the "made-up" latent data (examinee latent scores), `Y_c = (Y_o, Y_m)` the completed data. - -Complete-data (M-step) Hessian — asymptotically *under*estimates variability: - -$$ -\mathcal{H}_c(\hat\theta; Y_c) \;=\; -\,\frac{\partial^2 \log L(\theta \mid Y_c)}{\partial\theta\,\partial\theta^\top}\bigg|_{\hat\theta} -\tag{1} -$$ - -Observed-data information — the target, usually hard to evaluate directly: - -$$ -\mathcal{H}_o(\hat\theta; Y_o) \;=\; -\,\frac{\partial^2 \log L(\theta \mid Y_o)}{\partial\theta\,\partial\theta^\top}\bigg|_{\hat\theta} -\tag{2} -$$ - -**Finite differences + Richardson extrapolation** (Jamshidian & Jennrich 2000). For a scalar function `f`, central second difference: - -$$ -f''(\theta) \;\approx\; \frac{f(\theta-\delta) - 2f(\theta) + f(\theta+\delta)}{\delta^2}, \qquad \delta > 0 -\tag{3} -$$ - -Richardson shrinks `delta` each iteration and extrapolates the curvature change. Cost `= 1 + r(N^2 + N)` likelihood evaluations (`r` iterations, `N` parameters) — quadratic in `N`, so only practical for small models. (fast-mlsirm's `inference.observed_information` is exactly this family, forward/central FD over the *full* parameter Hessian.) - -**Missing-information principle** (Orchard & Woodbury 1972; Louis 1982). Completed information = observed + missing: - -$$ -\mathcal{I}(\theta; Y_c) \;=\; \mathcal{I}(\theta; Y_o) + \mathcal{I}(\theta; Y_m) -\tag{4a} -$$ -$$ -\big[\, I - \mathcal{I}(\theta; Y_m)\,\mathcal{I}(\theta; Y_c)^{-1} \,\big]\,\mathcal{I}(\theta; Y_c) \;=\; \mathcal{I}(\theta; Y_o) -\tag{5} -$$ - -**Supplemented EM** (Meng & Rubin 1991). One EM cycle is a map `theta_{t+1} = M(theta_t)`. Its Jacobian at the MLE, - -$$ -\mathrm{D}M \;=\; \frac{\partial M(\theta)}{\partial\theta}\bigg|_{\theta=\hat\theta}, -\qquad -\mathrm{D}M \;\approx\; \mathcal{I}(\theta; Y_m)\,\mathcal{I}(\theta; Y_c)^{-1} -\tag{7,8} -$$ - -is the fraction of information the missing data contributes. Combining (5) and (8): - -$$ -V^{-1} \;=\; \mathcal{I}(\theta; Y_o) \;\approx\; \big(I - \mathrm{D}M\big)\,\mathcal{I}(\theta; Y_c). -$$ - -`DM` column `j` by forward-differencing the EM map (run one EM cycle with all params frozen at `theta_hat` except the `j`-th perturbed): - -$$ -r_{ij}(\theta_j) \;=\; \frac{M_i(\hat\theta_1,\dots,\hat\theta_{j-1},\,\theta_j,\,\hat\theta_{j+1},\dots,\hat\theta_d) - M_i(\hat\theta)}{\theta_j - \hat\theta_j} -\tag{9} -$$ - -Column `j` declared converged when `|r_ij(theta_t) − r_ij(theta_{t+1})| < tol` for all `i`, with `tol = sqrt(EM tolerance)` (10). MR-SEM/Tian-SEM/Agile-SEM differ only in *which* trajectory points `theta_t` seed (9) — Tian-SEM uses the near-convergence subset where `t_hat ∈ [.9, .999]`. These are the failure-prone parts. - -**Oakes' direct method** (Oakes 1999) — the recommended method. It gives `I(theta; Y_m)` (equivalently the observed information) directly, without a convergence trajectory. Paper's form: the missing information is the Jacobian of the completed-data gradient w.r.t. the made-up data, - -$$ -\mathcal{I}(\theta; Y_m) \;=\; \frac{\partial}{\partial Y_m}\!\left[\frac{\partial \log L(\theta \mid Y_o, Y_m)}{\partial\theta}\right]. -\tag{11} -$$ - -Implementation-canonical (equivalent) form — the one to code, since it is written in terms of the EM objective `Q` the engine already evaluates. With `Q(theta | theta_tilde) = E_{z | Y_o, theta_tilde}[ log L(theta; Y_o, z) ]` (the E-step expected complete-data log-likelihood), - -$$ -\boxed{\; --\,\frac{\partial^2 \log L(\theta;Y_o)}{\partial\theta\,\partial\theta^\top}\bigg|_{\hat\theta} -=\; --\left( -\underbrace{\frac{\partial^2 Q}{\partial\theta\,\partial\theta^\top}}_{\text{M-step Hessian (1)}} -+\; -\underbrace{\frac{\partial^2 Q}{\partial\theta\,\partial\tilde\theta^\top}}_{\text{cross term}} -\right)\bigg|_{\theta=\tilde\theta=\hat\theta} -\;} -$$ - -The cross term is obtained by forward-differencing the **M-step gradient** `g(theta, theta_tilde) = ∂Q/∂theta` with respect to the *conditioning* parameter `theta_tilde` — i.e. re-run the E-step at each perturbed `theta_tilde`, requiring only `N + 1` gradient evaluations (paper used forward difference, step `1e-5`). This is precisely statement (11). - -### Quality measures used by the paper - -Relative difference of SEs, and its `l2` summary: -$$ -\mathrm{RD} = \frac{\mathrm{SE} - \mathrm{SE}_{\text{true}}}{\mathrm{SE}_{\text{true}}}, \qquad \|\mathrm{RD}\|_2. -$$ -KL divergence between the Monte-Carlo "true" covariance and the estimate (zero-mean MVN, dimension `K`): -$$ -D_{KL}(\Sigma_{\text{true}}, \hat\Sigma) = \tfrac{1}{2}\!\left[\operatorname{Tr}(\hat\Sigma^{-1}\Sigma_{\text{true}}) - K - \log\frac{|\Sigma_{\text{true}}|}{|\hat\Sigma|}\right]. -$$ - -### Recommendation (explicit in the paper) - -**Use Oakes.** It matched or beat every competitor on both accuracy and speed (except the low-accuracy raw M-step benchmark), never failed to converge, and is `N+1` evaluations (linear) vs Richardson's `1 + r(N²+N)`. On `cyh1` Oakes took 0.83 s vs Richardson's 46.4 s at equal accuracy; MR-SEM failed 70% of `cyh1` and 95% of `grm20` trials. The paper's closing argument: because Oakes is implemented optimally from theory, "the deciding factor... may be the parsimony of the theory," and Oakes is the most parsimonious. Caveat it raises: for parameters near a boundary, prefer profile-likelihood CIs over any Wald/`sqrt(diag V)` SE. - -### Implementation plan (mapped to the marginal-EM engine) - -E-step quantities needed (all already produced per EM cycle in `marginal.rs`): -- per-person posterior weights over `(theta, xi)` quadrature nodes at the current params; -- the M-step gradient of the expected complete-data log-likelihood `∂Q/∂(alpha,b,zeta,tau, population moments)` — already computed for the GEM gradient-ascent M-step. - -New computation: -1. At the converged `theta_hat`, assemble the **complete-data Hessian** `∂²Q/∂theta²` (M-step Hessian). For items this is block-diagonal per item; for population moments it is closed-form. Cheap. -2. Compute the **Oakes cross term**: for each free parameter `j`, perturb `theta_tilde_j = theta_hat_j + eps` (eps ≈ `1e-5`), re-run **one E-step** at that `theta_tilde`, recompute the M-step gradient `g(theta_hat, theta_tilde)`, and forward-difference: column `j` of the cross term `= [g(theta_hat, theta_tilde) − g(theta_hat, theta_hat)] / eps`. `N+1` E-step + gradient passes. -3. Observed information `= −(M-step Hessian + cross term)`; symmetrize `(A + Aᵀ)/2`; `V = information⁻¹`; `SE = sqrt(diag V)`. - -Wire it in as a new `method="oakes"` branch alongside the existing FD path in `inference.observed_information` / `vcov_from_hessian`, reusing the Rust E-step. The engine already exposes the FD full-Hessian, so this is an additive, drop-in-comparable estimator. - -Computational cost: `O(N)` E-step passes (one per free parameter) + one Hessian assembly, vs the current full FD Hessian at `O(N²)` likelihood evaluations. For a 73-parameter GRM the paper saw ~100× speedup at equal or better accuracy. - -Minimal correct test: on a small fixed 2PL data set (e.g. `m2pl5`-style, 5 items, `N=1000`), assert Oakes SEs match the existing central-difference `observed_information` SEs to within a few percent `||RD||_2`, and assert the Oakes information matrix is symmetric positive-definite. A stronger optional test: on a simulated data set with a known Monte-Carlo covariance, assert `log D_KL(Oakes) ≤ log D_KL(FD)`. - -### Not implementable / out of scope - -- SEM family (MR/Tian/Agile-SEM): implementable in principle (the EM map `M` exists), but the paper's own evidence is that they are slower and fail to converge far more often — **skip**; Oakes dominates. `ponytail:` don't build the losers. -- Nominal/graded-model covariance results generalize per the paper, but fast-mlsirm is **binary only**, so only the dichotomous parameterization applies today. -- Profile-likelihood CIs (the paper's boundary-case recommendation) are a separate, heavier feature — out of scope for this spec. - ---- - -## Paper 1 — Schneider, Chalmers, Debelak & Merkle (2019): Vuong tests for IRT model selection - -**Full citation.** Lennart Schneider, R. Philip Chalmers, Rudolf Debelak & Edgar C. Merkle (2019). *Model Selection of Nested and Non-Nested Item Response Models Using Vuong Tests.* **Multivariate Behavioral Research.** DOI: 10.1080/00273171.2019.1664280. - -**What it is.** Applies Vuong's (1989) three tests — (i) **distinguishability**, (ii) **non-nested goodness-of-fit**, (iii) **nested** — to marginal-ML IRT models, so both nested and non-nested models get a *formal statistical test* (not just an information criterion). Implemented as an extension of R `nonnest2` driving `mirt` fits. The tests make **no assumption that either model is correctly specified** — their edge over the classical LR test in misspecified / different-dimension comparisons. - -### Exact statistics - -Per-person marginal log-likelihood (Bock–Aitkin), for the M-dimensional model: -$$ -\ell(\Psi; x_i) = \log\!\int \prod_{j=1}^{J} f(x_{ij}\mid \Psi, \theta)\, g(\theta;\Psi)\, d\theta, -\qquad -\ell(\Psi; x_1,\dots,x_N) = \sum_{i=1}^N \ell(\Psi; x_i). -$$ -Per-person score (`P` = number of params): `s(Psi; x_i) = (∂ℓ/∂Psi_1, …, ∂ℓ/∂Psi_P)`, with `Σ_i s(Psi_hat; x_i) = 0` at the MLE. - -**Test of distinguishability.** Population variance of casewise log-likelihood ratios: -$$ -\omega_*^2 = \operatorname{Var}\!\left[\log^2 \frac{f_A(x_i;\Psi_A^*)}{f_B(x_i;\Psi_B^*)}\right], -$$ -estimated by -$$ -\hat\omega^2 = \frac1N\sum_{i=1}^N\!\left[\log\frac{f_A(x_i;\hat\Psi_A)}{f_B(x_i;\hat\Psi_B)}\right]^2 - \left[\frac1N\sum_{i=1}^N\log\frac{f_A(x_i;\hat\Psi_A)}{f_B(x_i;\hat\Psi_B)}\right]^2. -\tag{12} -$$ -Hypotheses `H0: ω²_* = 0` (indistinguishable) vs `H1: ω²_* > 0`. Under H0, `N·ω̂²` follows a **weighted sum of χ²**, weights = squared eigenvalues of a matrix built from both models' scores and information matrices (Merkle et al. 2016 appendix); tail computed via `CompQuadForm`. - -**Non-nested goodness-of-fit.** Compare mean casewise log-likelihoods: `H0: E[ℓ(Ψ_A*;x_i)] = E[ℓ(Ψ_B*;x_i)]`. Statistic -$$ -LR_{AB} = N^{-1/2}\sum_{i=1}^N \log\frac{f_A(x_i;\hat\Psi_A)}{f_B(x_i;\hat\Psi_B)} -\;\xrightarrow{d}\; N(0, \omega_*^2)\ \text{(when distinguishable)}. -\tag{15} -$$ -`nonnest2` rescales to a standard-normal z: -$$ -z = \frac{\sum_i \log\big(f_A(x_i;\hat\Psi_A)/f_B(x_i;\hat\Psi_B)\big)}{\sqrt{N}\,\hat\omega}. -$$ - -**Nested case.** (12) and (15) test the same hypothesis. If one assumes Model A correctly specified, `N·ω̂²` and `2·N^{1/2}·LR_{AB}` converge to ordinary χ²; if not, to weighted sums of χ² with the same eigenvalue weights. - -The paper obtains information matrices via the **Oakes-identity observed information** of Chalmers (2018a) / Pritikin (2017) — i.e. Paper 2 is a prerequisite building block. - -### Implementation plan - -E-step / likelihood quantities needed: -- **Casewise marginal log-likelihood** under each fitted model, `ℓ(Ψ; x_i)` — the engine already forms per-person marginal likelihoods on the quadrature grid (used for the global loglik and for S-X²); expose the per-person vector for both models. -- **Casewise score vectors** `s(Ψ; x_i)` — new: the per-person gradient of the marginal loglik. Derivable by the Fisher/Louis identity as the posterior-weighted complete-data score, `s(Ψ;x_i) = E_{θ|x_i}[∂ log f(x_i,θ;Ψ)/∂Ψ]`, reusing the same posterior weights the M-step already computes (no new integration rule). -- **Observed information** per model — from Paper 2's Oakes estimator. - -New computation: -1. Both models fit on the **same data with the same quadrature**; align/pad score and parameter vectors. -2. `d_i = log f_A(x_i;Ψ̂_A) − log f_B(x_i;Ψ̂_B)`; then `ω̂²` (12), `z` (15) — trivial once `d_i` exists. -3. Distinguishability weights: build the block matrix `W` from `A_m = −(1/N)·(observed information)_m` and `B_m = (1/N)·Σ_i s_m(x_i)s_m(x_i)ᵀ` for `m ∈ {A,B}` plus the cross block `B_{AB} = (1/N)·Σ_i s_A(x_i)s_B(x_i)ᵀ`, take its eigenvalues, and evaluate the weighted-χ² tail (Davies/Imhof; a small self-contained routine, no external dependency needed). - -Computational cost: casewise loglik is already computed; casewise scores add one posterior-weighted gradient pass (`O(N · P_item)`), cheap. The eigenvalue step is `O((P_A+P_B)³)` once — negligible. Weighted-χ² tail is a 1-D numeric integral. - -Minimal correct test: fit a 1PL (Rasch, slopes fixed) and a 2PL to data simulated from the 2PL; assert the **nested** Vuong z favors the 2PL (`z` significant in the 2PL direction) and that the distinguishability test rejects `H0` (`N·ω̂² ` tail p small). Sanity check: `Σ_i s(Ψ̂;x_i) ≈ 0` at each MLE (gradient-zero identity). - -### Not implementable / out of scope - -- **Weighted-χ² tail**: needs a self-contained Davies/Imhof routine (the repo bans SciPy); a modest but real new numeric primitive — flag as the one non-trivial dependency to build. -- Graded-response vs GPCM comparisons in the paper require **polytomous** models — not in fast-mlsirm yet; only 2PL-vs-Rasch / dimension-count comparisons are testable today. -- Requires Paper 2's Oakes information as a prerequisite; do Paper 2 first. - ---- - -## Paper 3 — Kang, Cohen & Sung (2009): Model selection indices for polytomous items - -**Full citation.** Taehoon Kang, Allan S. Cohen & Hyun-Jung Sung (2009). *Model Selection Indices for Polytomous Items.* **Applied Psychological Measurement** 33(7): 499–518. DOI: 10.1177/0146621608327800. - -**What it is.** Compares four indices — AIC, BIC (both MMLE), DIC and cross-validation log-likelihood CVLL (both MCMC/Bayesian) — for choosing among four polytomous IRT models (RSM, PCM, GPCM, GRM). Verdict: **BIC is the most accurate and consistent** (98% correct over 1,600 data sets, 100% for PCM/RSM); AIC nearly ties BIC but over-selects the more complex model as `N` grows; DIC and CVLL need large `N` *and* many categories to reliably pick the GRM over the GPCM. - -### Exact definitions - -Deviance `= −2·log(marginal maximum likelihood)`, `p` = number of estimated parameters, `N` = sample size. - -$$ -\mathrm{AIC} = -2\log L(\hat\theta) + 2p -$$ -$$ -\mathrm{BIC} = -2\log L(\hat\theta) + p\log N -$$ - -DIC (Spiegelhalter et al. 2002), eq (4), with `D(y)` the deviance: -$$ -\mathrm{DIC} = \overline{D(y)} + p_D = D(\bar y) + 2p_D, -\qquad -p_D = \overline{D(y)} - D(\bar y), -$$ -where `\overline{D(y)}` is the posterior mean deviance and `D(\bar y)` the deviance at the posterior-mean parameters. Smallest DIC wins. - -CVLL (Geisser–Eddy / Gelfand–Dey), eq (5): split into calibration `Y_cal` and cross-validation `Y_cv` samples; use the `Y_cal` posterior as the prior: -$$ -P(Y_{cv}\mid \text{Model}) = \int P(Y_{cv}\mid \theta, Y_{cal}, \text{Model})\, f_\theta(\theta \mid Y_{cal}, \text{Model})\, d\theta, -\qquad -\mathrm{CVLL} = \log P(Y_{cv}\mid \text{Model}). -$$ -**Largest** CVLL wins (opposite sign convention to AIC/BIC/DIC). - -### Recommendation (explicit) - -**BIC.** Most accurate and consistent across all 32 conditions; least likely to over-parameterize; works even at `N=500`. AIC ≈ BIC except it tends to pick the more complex model at large `N` (confirmed in their real-data example where AIC alone chose GPCM over the more parsimonious PCM). DIC/CVLL are only competitive at `N=1000` with 5-category items. - -### Implementation plan - -**AIC and BIC are already implemented** in `python/fast_mlsirm/diagnostics.py`: -``` -aic = 2.0 * n_parameters - 2.0 * loglik -bic = np.log(n_observed) * n_parameters - 2.0 * loglik -``` -So the *actionable* deliverable from this paper for an MMLE engine is essentially: **surface BIC as the recommended default index, and confirm `n_parameters` and `n_observed` are counted correctly** for each population structure. E-step quantity needed: the marginal log-likelihood — already the EM convergence criterion. - -`n_parameters` accounting to verify (the only real work): per binary item `alpha, b, zeta(K)` plus global `tau`, plus free population moments — multigroup adds `(mu_gd, sigma_gd)` for non-reference groups; multilevel adds `sigma_u`; FIPC freezes anchored items (do not count them). `N` for BIC should be the number of persons (response vectors), not the number of observed cells — verify against `n_observed` in `diagnostics.py`. - -Computational cost: zero beyond the existing fit. - -Minimal correct test: simulate from a 1PL, fit 1PL and 2PL, assert **BIC(1PL) < BIC(2PL)** (parsimony favored) while the raw loglik of 2PL ≥ 1PL; and assert `AIC = 2p − 2·loglik`, `BIC = log N·p − 2·loglik` exactly for a hand-checked `p`, `N`. - -### Not implementable / out of scope - -- **DIC and CVLL as defined here are Bayesian/MCMC** — they need a posterior sample (`\overline{D(y)}`, posterior-mean parameters, and the `Y_cal`-posterior-as-prior integral). fast-mlsirm is MMLE with no sampler → **out of scope**. Note the engine already has an MLE-based held-out log-likelihood in `diagnostics.py` (`heldout_loglik`), which is the frequentist analogue of CVLL and serves the same "predict a replicate sample" goal without a sampler; document that as the substitute rather than porting Bayesian CVLL. -- All four candidate models here (RSM/PCM/GPCM/GRM) are **polytomous** — the *index formulas* are model-agnostic and apply to binary models unchanged, but the paper's specific model-selection scenarios are not reproducible until polytomous items land. - ---- - -## Paper 4 — Svetina & Levy (2014): A framework for dimensionality assessment for MIRT - -**Full citation.** Dubravka Svetina & Roy Levy (2014). *A Framework for Dimensionality Assessment for Multidimensional Item Response Models.* **Educational Assessment** 19: 35–57. DOI: 10.1080/10627197.2014.869450. - -**What it is.** Not a new method — a **taxonomy/framework** that classifies existing dimensionality-assessment procedures along four axes: exploratory vs confirmatory, parametric vs nonparametric, item-response type (dichotomous / ordered polytomous), and data features (lower asymptote / missing data). It situates ~10 procedures (EFA + parallel analysis, χ² difference test, DETECT/PolyDETECT, DIMTEST/PolyDIMTEST, NOHARM `χ²_{G/D}` & ALR, WRMR, RMSR change, local-dependence indices Q3 / model-based covariance / X²·G², and the GDDM via PPMC) and illustrates each on NAEP Science data. - -### The concrete procedures and the formulas they use - -Local independence (the unifying criterion), eq (3): -$$ -P(X\mid\theta,\omega) = \prod_{j=1}^{J} P(X_j\mid\theta,\omega_j). -$$ -Weak (pairwise) local independence, eq (4): -$$ -E_\theta\!\big[\operatorname{Cov}(X_j, X_{j'}\mid\theta,\omega)\big] -= E_\theta\big[(X_j - E(X_j\mid\theta,\omega_j))(X_{j'} - E(X_{j'}\mid\theta,\omega_{j'}))\big] = 0. -$$ - -Compensatory MIRT (dichotomous), eq (1): `P(X_ij = 1) = c_j + (1 − c_j) F(a_jᵀθ_i + d_j)`. - -Item-pair **local-dependence indices** (the implementable core): -- **Yen's Q3**: residual `d_ij = X_ij − E(X_ij | θ̂_i)`; `Q3_{jj'} = corr(d_{·j}, d_{·j'})`. Flag `|Q3| > 0.20`. -- **Model-based covariance** (Reckase 1997): `Cov_model(X_j, X_{j'})` using model-implied expected values. -- **GDDM** (Levy & Svetina 2011): test-level average of absolute model-based covariance over item pairs, -$$ -\mathrm{GDDM} = \frac{2}{J(J-1)} \sum_{j 2·df` (empirical Type-I inflation). -- **NOHARM `χ²_{G/D}`** and **ALR** — from residual correlations / bivariate LR of a fitted NOHARM model; used in a sequential fit. -- **RMSR change** (Tate 2003): add factors until RMSR reduction < 10%. -- **DETECT / PolyDETECT**: nonparametric; partition items to maximize within-cluster-positive / between-cluster-negative conditional covariance; `D_ref` cutoffs (<.20 unidim; .20–.39 weak; .40–.79 moderate; >.80 strong), plus IDN and ratio R (≥.80 ⇒ simple structure). -- **DIMTEST / PolyDIMTEST**: Stout's `T` statistic aggregating conditional covariance of an assessment subtest (AT) conditional on a partitioning subtest (PT); asymptotically normal under essential unidimensionality. -- **PPMC** (posterior predictive model checking): posterior-predictive p-value = tail area of the reference distribution of a discrepancy (e.g. GDDM) — Bayesian. - -### Implementation plan - -fast-mlsirm supports simple-structure MIRT (`theta in R^D`), so *confirmatory* dimensionality checks on a specified structure are the natural fit. Directly / adaptably implementable: - -- **Q3 and model-based covariance / GDDM** — the highest-value, lowest-cost items. E-step quantities needed: EAP trait scores `θ̂_i` (or full posterior) and model-implied `E(X_ij | θ)`, both already available (the engine computes posterior expectations for S-X² and scoring). New computation: residuals `d_ij`, their `J×J` correlation matrix (Q3), and the pairwise model-based covariance average (GDDM). Cost `O(N·J²)`, one pass. This is a small addition to `diagnostics.py`. -- **χ² difference test** for nested dimensional structures (`D` vs `D+1` traits) — fit both, `Δdeviance ~ χ²(Δdf)`, apply the `χ² > 2·df` guard. Reuses the marginal loglik; near-zero cost. (Overlaps with Paper 1's nested test — Vuong is the more robust version.) -- **Parallel analysis** on the tetrachoric correlation matrix — standalone, doesn't even need a fit; a compact numeric routine (needs a tetrachoric-correlation estimator + eigen-decomposition + random-data resampling). Moderate new code. - -Minimal correct test: simulate a 2-dimensional simple-structure data set with one deliberately cross-loading item; assert Q3 flags that item pair (`|Q3| > 0.2`) and GDDM is larger than for a clean unidimensional fit. For the χ² difference test: simulate 1-D data, fit 1-D and 2-D, assert the difference test does **not** reject with the `2·df` guard. - -### Not implementable / out of scope - -- **DETECT, DIMTEST, NOHARM `χ²_{G/D}`, ALR, WRMR** are **separate software/algorithms** (raw-score conditional-covariance partitioning, Stout's `T`, NOHARM's least-squares residual machinery). Porting any is a project unto itself and mostly duplicates what a confirmatory Q3/GDDM already tells a simple-structure engine → **document-only**, not worth building. -- **PPMC / posterior-predictive p-values are Bayesian** (need an MCMC posterior) → out of scope for MMLE. The frequentist substitute is to reference GDDM/Q3 against a parametric-bootstrap reference distribution instead of a posterior-predictive one. -- **Polytomous / lower-asymptote (3PL guessing)** branches of the framework don't apply — fast-mlsirm is binary, no `c_j`. -- The paper is fundamentally a **review/taxonomy**; its deliverable to fast-mlsirm is the *classification* (use it to justify shipping confirmatory Q3/GDDM/χ²-difference and to document why DETECT/DIMTEST are out of scope), not an algorithm. - ---- - -## Paper 5 — Sinharay & Lu (2008): Correlation between item parameters and item-fit statistics - -**Full citation.** Sandip Sinharay & Ying Lu (2008). *A Further Look at the Correlation Between Item Parameters and Item Fit Statistics.* **Journal of Educational Measurement** 45(1): 1–15. - -**What it is.** Revisits Dodeen's (2004) worrying claim that item-fit statistics correlate with item parameters (so highly-discriminating items falsely look misfitting). Sinharay & Lu show that Dodeen's result is an artifact of a **bad fit statistic** (`χ²_G`, which uses point-θ groupings and has grossly inflated Type-I error). With statistics that have correct Type-I error — especially **S-X²** (Orlando & Thissen 2000) — there is **no** spurious correlation with item parameters. Recommendation: **use S-X² (or S-G²)**. - -### Exact statistics - -`O_j`, `E_j` = observed / expected proportion correct in group `j`; `N_j` = group size; `n` = number of groups. - -`χ²_G` / G²-like (Mislevy–Bock), groups on **proficiency θ**, eq (1) — the *bad* one: -$$ -\chi^2_G = 2\sum_{j=1}^n N_j\!\left[O_j\log\frac{O_j}{E_j} + (1-O_j)\log\frac{1-O_j}{1-E_j}\right]. -$$ -Standardized residual (Hambleton et al.), eq (2): `z_j = (O_j − E_j) / sqrt(E_j(1−E_j)/N_j)`. - -**S-X²** (Orlando & Thissen 2000), groups on **summed/raw score**, eq (3) — the recommended one: -$$ -S\text{-}X^2 = \sum_{j=1}^n \frac{N_j\,(O_j - E_j)^2}{E_j(1-E_j)} \;\sim\; \chi^2_{\,n-4}\ \text{(3PL)}. -$$ -**S-G²**, summed-score groups, eq (4): -$$ -S\text{-}G^2 = 2\sum_{j=1}^n N_j\!\left[O_j\log\frac{O_j}{E_j} + (1-O_j)\log\frac{1-O_j}{1-E_j}\right]. -$$ -`χ²*` / `G²*` (Stone 2000): proficiency-scale groups via posterior "pseudo-counts", rescaled to a χ² reference by a resampling procedure. - -Expected proportions `E_j` in S-X² come from the **Lord–Wingersky recursion** over the summed-score distribution — exactly the machinery already in fast-mlsirm's `fitstats`. - -### Recommendation (explicit) - -**S-X² and S-G².** Type-I error close to nominal across sample sizes and test lengths, respectably high power, and — the paper's point — **no spurious linear relationship** with discrimination/difficulty/guessing. Avoid `χ²_G` / `z_j`: their reference distribution is not χ² (they use point-θ, ignore θ uncertainty; Chernoff–Lehmann shows a plug-in-MLE χ² is stochastically larger than χ²), so they over-flag high-discrimination items. `χ²*`/`G²*` are better than `χ²_G` but showed inflated Type-I at `N=2000` in this study. - -### Implementation plan - -**S-X² is already implemented** in `crates/mlsirm-core/src/fitstats.rs` and `python/fast_mlsirm/fitstats.py`, with the Lord–Wingersky recursion generalized to the `(theta, xi)` grid and a practical-significance effect size (Sinharay & Haberman 2014). So this paper is **confirmatory / document-only** for fast-mlsirm: it is the citation that *justifies the existing choice* of S-X² as the default item-fit statistic and justifies *not* implementing `χ²_G`. - -Actionable follow-through (small): (1) document S-X² as the recommended item-fit index in the API/help, citing Orlando–Thissen 2000 and Sinharay–Lu 2008; (2) ensure S-G² is available too (same `O_j/E_j/N_j` inputs, log form) if not already — it is a two-line addition next to S-X². E-step quantities: the summed-score distribution (Lord–Wingersky) and per-group `E_j` — already computed. - -Minimal correct test: on data simulated from the fitted model, assert S-X² Type-I behaves (across ~100 replications, the rejection rate at α=.05 is ≈ .05, not inflated), and — the paper's specific claim — assert the correlation between generating discrimination `alpha` and average S-X² across items is not significant, whereas the same correlation for a `χ²_G`-style point-θ statistic *is* inflated. A cheaper unit test: assert S-X² on model-consistent data has expected value ≈ its degrees of freedom `(n − k)`. - -### Not implementable / out of scope - -- `χ²_G`, `z_j`, `χ²*`/`G²*`: **deliberately not worth implementing** — the paper's whole message is that the θ-grouped point-estimate statistics have broken Type-I error. Building them would be building known-bad diagnostics. `ponytail:` skip. -- The paper uses the **3PL** (guessing `c`); fast-mlsirm is 2PL/binary with no lower asymptote, so the `n−4` df (which subtracts 3 item params + 1) becomes `n−3` for a 2PL — note the df bookkeeping difference when documenting. - ---- - -### Cross-paper build order (recommendation) - -1. **Paper 2 (Oakes)** — direct, high value, and a prerequisite for Paper 1. Ship first. -2. **Paper 3 (BIC)** — already there; just surface/verify parameter counting. Trivial. -3. **Paper 5 (S-X²)** — already there; add S-G² + docs. Trivial. -4. **Paper 4 (Q3 / GDDM / χ²-difference)** — small confirmatory additions to `diagnostics.py`; parallel analysis optional. -5. **Paper 1 (Vuong)** — adaptation; needs casewise scores + a weighted-χ² tail routine; do after Oakes lands. diff --git a/docs/papers/group_b_specs.md b/docs/papers/group_b_specs.md deleted file mode 100644 index c23bd6c78..000000000 --- a/docs/papers/group_b_specs.md +++ /dev/null @@ -1,540 +0,0 @@ -# Group B: Implementation Specs for `fast-mlsirm` - -**Target engine.** `fast-mlsirm` — Rust marginal-EM (Bock–Aitkin MMLE) engine for latent-space IRT. -Working model for a binary response `Y_pi ∈ {0,1}` of person `p` to item `i`: - -$$ -\operatorname{logit}\big(P(Y_{pi}=1)\big)=a_i\,\theta_{p,d(i)}+b_i-\gamma\,\lVert \xi_p-\zeta_i\rVert , -$$ - -with discrimination `a_i`, easiness `b_i`, simple-structure loading map `d(i)` (item `i` → one latent -dimension), latent respondent/item positions `ξ_p, ζ_i ∈ ℝ^D`, distance weight `γ ≥ 0`. Population: -`θ_{p,d} ~ N(μ_{g(p),d}, σ²_{g(p),d})` (multigroup means/SDs), optional multilevel random intercept -`u_c ~ N(0, σ²_u)` added to the linear predictor. E-step: tensor Gauss–Hermite (`D ≤ 3`) or Halton-QMC / -MC-EM over the trait, Bock–Aitkin expected counts ("artificial data"). M-step: per-item Newton on the -expected binomial log-likelihood; `γ` by 1-D Newton; `μ_{gd}, σ²_{gd}` from posterior moments; `σ²_u` -likewise. Missingness handled "not-presented" (missing `(p,i)` cells dropped from the E-step). Item -anchoring / FIPC: `PopulationSpec::SingleFree` + common (anchored) item block, reference group `N(0,1)`. - -**Notation bridge.** Each paper's original symbols are kept in its equations; the "map to engine" text -translates to `(θ, a_i, b_i, γ, ξ, ζ, μ_{gd}, σ_{gd})`. Sign convention: the engine uses **easiness** -`b_i` (`+b_i` in the linear predictor); papers that use **difficulty** `β_i` map by `b_i = −β_i`. - -**Feasibility legend.** `direct` = expressible with existing knobs or a thin preprocessing layer; -`adaptation` = new E-/M-step term or moment update; `already-covered` = engine already does the core; -`document-only` = record the mapping, no code. - ---- - -## 1. Perumean-Chaney, Morgan, McDowall & Aban (2013) — zero inflation / overdispersion - -**Citation.** Perumean-Chaney, S. E., Morgan, C., McDowall, D., & Aban, I. (2013). Zero-inflated and -overdispersed: what's one to do? *Journal of Statistical Computation and Simulation*, 83(9), 1671–1683. -https://doi.org/10.1080/00949655.2012.668550 - -### 1.1 The paper's models (count data), exact - -Poisson (their Eq. 1): -$$ -\Pr(Y=y)=\frac{e^{-\mu}\mu^{y}}{y!},\qquad y=0,1,2,\dots,\qquad E(Y)=\operatorname{Var}(Y)=\mu . -$$ - -Negative binomial (Eq. 2), mean `μ`, `Var(Y)=μ+μ²/θ`: -$$ -\Pr(Y=y)=\frac{\Gamma(\theta+y)}{\Gamma(\theta)\,\Gamma(y+1)}\, -\frac{\theta^{\theta}\,\mu^{y}}{(\theta+\mu)^{\theta+y}},\qquad y=0,1,2,\dots -$$ - -Two-component mixture with mixing proportion `p` (Eq. 3), the general zero-inflation device: -$$ -P(Y=y)=p\cdot g_1(y)+(1-p)\cdot g_2(y). -$$ - -Zero-inflated Poisson (ZIP, Eq. 4), `g_1` degenerate at 0, mixing proportion `π`: -$$ -P(Y=0)=\pi+(1-\pi)\,e^{-\mu},\qquad -P(Y=y)=(1-\pi)\,\frac{\mu^{y}e^{-\mu}}{y!},\quad y=1,2,\dots -$$ - -Zero-inflated negative binomial (ZINB, Eq. 5): -$$ -P(Y=0)=\pi+(1-\pi)\,\frac{\theta^{\theta}}{(\theta+\mu)^{\theta}},\qquad -P(Y=y)=(1-\pi)\,\frac{\Gamma(\theta+y)}{\Gamma(\theta)\,\Gamma(y+1)}\, -\frac{\theta^{\theta}\mu^{y}}{(\theta+\mu)^{\theta+y}},\quad y=1,2,\dots -$$ - -The zero-inflation `π` is the probability of belonging to a **structural-zero** ("never at risk") class; -the `(1−π)` class is "at risk" and may or may not produce a zero. - -**Practical recommendations (verbatim thrust).** (i) Ignoring zero inflation (fitting Poisson/NB to a -ZI process) **underestimates the mean → misses significant findings (Type II)**. (ii) Ignoring -overdispersion *within* the ZI data (fitting ZIP when the truth is ZINB) **overestimates the mean and -shrinks the SE → false positives (Type I)**. (iii) **When unsure whether ZIP or ZINB, use ZINB** (wider -CIs, robust to unmodeled overdispersion). (iv) Small mean/`N ≤ 50` destabilizes ZINB; prefer `N > 50`. -(v) The two-step LRT–Vuong selector is unreliable at small mean/`N` (correctly IDs ZIP only at moderate -mean μ=5 and N=100, poor for ZINB); mixture LRT asymptotics are not standard, so a naive chi-square test -of `π=0` is untrustworthy. - -### 1.2 Mapping to a zero-inflated IRT mixture - -The count "mean `μ`" has no IRT analog; the transferable structure is the **membership mixture on the -all-zero response pattern**. Let `y_p = (y_{p1},…,y_{pI})` and `A_p = 1[y_p = 0]` (all items zero). Design: - -$$ -\boxed{\; -L_p=\pi\cdot \mathbf 1[y_p=\mathbf 0]+(1-\pi)\,L_{\mathrm{IRT}}(y_p), -\qquad -L_{\mathrm{IRT}}(y_p)=\int \prod_{i\in\Omega_p} P_i(\theta)^{y_{pi}}\big(1-P_i(\theta)\big)^{1-y_{pi}}\,g(\theta)\,d\theta -\;} -$$ - -with `P_i(θ) = logit^{-1}(a_i θ_{d(i)} + b_i − γ‖ξ_p−ζ_i‖)` the engine kernel and `Ω_p` the observed -cells. This is the **exact IRT counterpart of Eq. 4/5**: the degenerate `g_1` (spike at "all-zero") plays -the role of the count spike at `y=0`; the "at-risk" class is the LSIRM. Only respondents with `A_p=1` get -probability mass from the spike — anyone with a single `y_{pi}=1` has spike probability 0. - -**How the findings map to the design.** - -| Paper finding (count) | IRT-mixture consequence | -|---|---| -| Ignoring `π` underestimates `μ`, causes Type II | Forcing `π=0` (plain LSIRM) inflates estimated **easiness `b_i`** (all-zero people look like low-trait people, dragging item easiness / trait mean down); genuine effects can be masked. | -| ZIP-when-ZINB overestimates `μ`, Type I | The IRT analog of "extra within-class dispersion" is an **over-thin at-risk population model**. Forcing a single tight `N(μ_g,σ_g²)` when the at-risk class needs heavier tails / a random intercept is the "ZIP-when-ZINB" error → over-confident item SEs. **Default to the richer at-risk model** (keep multilevel `σ_u`, do not fix `σ_g`), the direct analog of "prefer ZINB." | -| LRT for `π=0` unreliable (mixture boundary) | Test `π=0` with a **boundary-corrected** statistic (50:50 mixture of `χ²_0` and `χ²_1`) or a parametric bootstrap, never a naive `χ²_1`. | -| Small mean/N destabilizes | `π` is identified **only from all-zero patterns**; short tests, high-difficulty items, or few all-zero respondents give weak `π` identification — the exact analog of their small-`μ` instability. Warn/shrink when the all-zero count is small. | - -**`π` estimation inside the EM (structural-zero class).** Add a class indicator `C_p ∈ {S,R}` (structural -zero / at-risk) as a second latent layer above the trait. - -- **E-step (class responsibility).** For every respondent, - $$ - r_p \;=\; P(C_p=S\mid y_p)\;=\; - \frac{\pi\,\mathbf 1[y_p=\mathbf 0]}{\pi\,\mathbf 1[y_p=\mathbf 0]+(1-\pi)\,L_{\mathrm{IRT}}(y_p)} , - $$ - so `r_p = 0` whenever `A_p = 0` (any observed 1). `L_IRT(y_p)` for an all-zero respondent is just the - already-computed marginal `∑_v w_v ∏_i(1−P_i(v))` over quadrature nodes `v`. -- **E-step (trait posterior), reweighted.** Person `p` contributes its Bock–Aitkin expected counts to the - item tables with weight `(1−r_p)` (at-risk responsibility). Non-all-zero respondents are unchanged - (`1−r_p=1`). -- **M-step.** `π ← (1/N) Σ_p r_p`. Item/`γ`/population updates are the existing M-step run on the - `(1−r_p)`-weighted expected counts. Optional covariate model `π_p = logit^{-1}(w_p'η)` replaces the - scalar update with a 1-step IRLS on `η` against targets `r_p` (mirrors Lambert's logit zero model). - -### 1.3 Implementation plan - -- **E-step change:** one extra scalar per respondent (`r_p`); reuses the existing all-zero marginal. No new - integration. -- **M-step change:** one closed-form update for `π` (or a small IRLS for `η`); multiply existing expected - counts by `(1−r_p)`. -- **Parameter count:** `+1` (`π`) or `+q` (covariate zero model). Everything else unchanged. -- **Identification:** `π=0` nests the plain LSIRM. Needs a non-trivial number of all-zero patterns; if the - observed all-zero count is 0, `π` is unidentified → clamp to 0 and warn. Keep the reference group `N(0,1)`. -- **Minimal recovery test:** simulate `N=2000`, `I=20`, spike fraction `π∈{0.2,0.4}`, at-risk from the - LSIRM; fit (a) plain LSIRM and (b) the mixture. Assert: `π̂` within ±0.03 of truth; mixture recovers - `b_i` while plain LSIRM shows systematic easiness bias of the sign predicted above; boundary-corrected - LRT rejects `π=0` when `π=0.4` and holds nominal size when `π=0`. - -### 1.4 Out of scope - -Count/ordinal responses (Poisson/NB IRT), the `μ`/`θ`-overdispersion parameter itself, hurdle models -(which differ from zero-inflation: hurdle has no "at-risk zeros"), and the LRT–Vuong selection study — -we adopt the paper's *conclusion* (prefer the richer model, boundary-correct the test), not its selector. - ---- - -## 2. Jeon, Rijmen & Rabe-Hesketh (2013) — multiple-group bifactor DIF - -**Citation.** Jeon, M., Rijmen, F., & Rabe-Hesketh, S. (2013). Modeling Differential Item Functioning -Using a Generalization of the Multiple-Group Bifactor Model. *Journal of Educational and Behavioral -Statistics*, 38(1), 32–60. https://doi.org/10.3102/1076998611432173 - -### 2.1 The paper's models, exact - -Multiple-group unidimensional 2PL DIF (Eq. 1), person `j` in group `h`, item `i`: -$$ -\operatorname{logit}\big(\Pr(y_{j(h)i}=1\mid \theta_{j(h)})\big)=a_i\big(\theta_{j(h)}s_h+\mu_h\big)+b_i+d_{ih}, -$$ -with `θ_{j(h)} ~ N(0,1)`; the realized ability is `θ*_{j(h)} = θ_{j(h)}s_h + μ_h` (group mean `μ_h`, SD -`s_h`). `d_{ih}` is **uniform DIF** (a group-`h` shift in item easiness). Reference group `h=1`: -`μ_1=0, s_1=1, d_{i1}=0`. Anchor (DIF-free) items: `d_{ih}=0 ∀h`. - -Multiple-group **bifactor** DIF (Eq. 2), item `i` in testlet `k`, general `g` and specific `k` dims: -$$ -\operatorname{logit}\big(\Pr(y_{j(h)i(k)}=1\mid \theta^{*}_{j(h)g},\theta^{*}_{j(h)k})\big) -=a_{ig}\,\theta^{*}_{j(h)g}+a_{ik}\,\theta^{*}_{j(h)k}+b_i+d_{ih}. -$$ - -Independent-dimension parameterization (Eq. 3): `θ*_{j(h)g}=θ_{j(h)g}s_{gh}+μ_{gh}`, -`θ*_{j(h)k}=θ_{j(h)k}s_{kh}+μ_{kh}`. - -Correlation (Cholesky) parameterization (Eq. 4), relaxing orthogonality to *conditional* independence of -the specific dims given the general dim: -$$ -\theta^{*}_{j(h)g}=\theta_{j(h)g}\,c_{ggh}+\mu_{gh},\qquad -\theta^{*}_{j(h)k}=\theta_{j(h)g}\,c_{gkh}+\theta_{j(h)k}\,c_{kkh}+\mu_{kh}, -$$ -with `C_h` lower-triangular (nonzero off-diagonals only in the first column) and `Σ_h = C_h C_h'`. - -**Identification.** Reference group: all means 0, all variances 1, all covariances 0 -(`μ_{g1}=μ_{k1}=0, c_{gg1}=c_{kk1}=1, c_{km1}=0`). Anchor set must contain **≥1 item per testlet**. -**DIF testing:** `H_0: d_{ih}=0` by **Wald or likelihood-ratio test** (asymptotically equivalent; the paper -notes LR/Wald discrepancy grows when the log-likelihood is non-quadratic). Item **purification** is -recommended so the anchor set is itself DIF-free. - -### 2.2 Mapping to the engine - -The engine is **simple-structure**, not bifactor, so the general+specific correlated-dimension machinery -(Eq. 2–6) and its junction-tree E-step are **not** the port. The transferable, engine-shaped piece is the -**multiple-group DIF layer** (Eq. 1) on top of the existing multigroup means/SDs and anchoring: - -$$ -\operatorname{logit}\big(P(Y_{pi}=1)\big)=a_i^{g(p)}\,\theta_{p,d(i)}+b_i^{g(p)}-\gamma\lVert\xi_p-\zeta_i\rVert, -\qquad \theta_{p,d}\sim N(\mu_{g,d},\sigma_{g,d}^2). -$$ - -- **Anchor items** carry group-common `(a_i, b_i)` (the existing FIPC/anchor block). -- **Studied (candidate-DIF) items** get a **group-specific easiness `b_i^g`** (uniform DIF `d_{ih}`), and - optionally a **group-specific slope `a_i^g`** (non-uniform DIF). In the engine, "studied item = non-anchor - with per-group parameters"; "anchor = common parameter." This is already close to how anchoring vs. - free items are represented — DIF adds the *per-group* free parameter on flagged items only. -- Impact is absorbed by the existing `μ_{g,d}, σ_{g,d}` — the paper's central point that impact must be - modeled to avoid spurious DIF is **already satisfied** by the engine's group means/SDs. - -### 2.3 Implementation plan - -- **E-step:** unchanged in structure; item response tables become group-indexed for flagged items (the - engine already integrates per group). No new integration dimension. -- **M-step:** for each flagged item `i`, run the existing per-item Newton **once per group** on that group's - expected counts to update `(a_i^g, b_i^g)`; anchor items keep the pooled update. Reference group pinned - `N(0,1)`. -- **Parameter count:** per flagged item, `+ (H−1)` for uniform DIF (`b_i^g`), `+ (H−1)` more for - non-uniform (`a_i^g`), `H` = #groups. Impact params `μ_{g,d}, σ_{g,d}` already exist. -- **Identification:** reference group `N(0,1)`; anchor set spans and is DIF-free (support optional - purification: iterate — refit, drop anchors whose Wald DIF is significant, refit). With simple structure - the "≥1 anchor per testlet" rule becomes "≥1 anchor per latent dimension `d`." -- **DIF test:** Wald test `d̂_{ih}/SE` from the observed-information SEs the M-step already produces - (cheapest); or LR by refitting with `b_i^g` constrained equal. Provide both; they agree asymptotically. -- **Minimal recovery test:** 2 groups, `I=30`, 6 anchors, impact `μ_2=0.5, σ_2=1.2`, plant uniform DIF - `d=0.5` on 3 items and `0` on the rest. Assert: `d̂` within ±0.05 on planted items; Wald Type-I near 0.05 - on null items; and that omitting impact (`μ_2=0`) inflates DIF estimates (reproduces the paper's key - warning). - -### 2.4 Out of scope - -The bifactor / testlet structure itself (general + conditionally-independent specific dimensions), the -Cholesky `C_h` cross-group covariance/correlation estimation, the graphical-model junction-tree E-step, -differential *testlet* functioning (`μ_{kh}≠0`), and polytomous link functions. Only the multiple-group -**DIF-on-simple-structure** slice is ported. - ---- - -## 3. Debeer & Janssen (2013) — item-position effects - -**Citation.** Debeer, D., & Janssen, R. (2013). Modeling Item-Position Effects Within an IRT Framework. -*Journal of Educational Measurement*, 50(2), 164–185. - -### 3.1 The paper's models, exact - -Base (Eq. 1), person `p`, item `i`, position `k`, difficulty `β_{ik}`: -$$ -\operatorname{logit}[Y_{pik}=1]=\theta_p-\beta_{ik}. -$$ - -DIF-style decomposition of position (Eq. 2), `β_i` = reference-position difficulty, `δ^{β}_{ik}` = position -shift: -$$ -\operatorname{logit}[Y_{pik}=1]=\theta_p-\big(\beta_i+\delta^{\beta}_{ik}\big). -$$ - -2PL with position effects on both parameters (Eq. 3): -$$ -\operatorname{logit}[Y_{pik}=1]=\big(\alpha_i+\delta^{\alpha}_{ik}\big)\big[\theta_p-\big(\beta_i+\delta^{\beta}_{ik}\big)\big]. -$$ - -Position-only (not item-dependent) main effect (Eq. 4): -$$ -\operatorname{logit}[Y_{pik}=1]=\theta_p-\big(\beta_i+\delta^{\beta}_{k}\big). -$$ - -**Linear position effect on difficulty** (Eq. 5), `γ` = shared linear slope, first position = reference: -$$ -\boxed{\;\operatorname{logit}[Y_{pik}=1]=\theta_p-\big[\beta_i+\gamma\,(k-1)\big]\;} -$$ -`γ>0` = fatigue (harder later), `γ<0` = practice/learning. Nonlinear (quadratic/cubic/exponential) forms -allowed by replacing `(k−1)`. - -**Person-specific (random) position effect** (Eq. 6), `γ_p ~ N(·,·)`, correlated with `θ_p`: -$$ -\operatorname{logit}[Y_{pik}=1]=\alpha_i\big[\theta_p-\big(\beta_i+\gamma_p\,(k-1)\big)\big]. -$$ -Model (6) is two-dimensional; `corr(γ_p, θ_p)` is estimable. Empirically `γ ≈ .01–.24` per position/cluster, -`corr(γ_p,θ_p) < 0` (higher-ability persons less affected). - -**Identification.** A reference position with zero effect (first position, since `γ·(k−1)`). Eq. 2/3 need a -per-item reference position; Eq. 4–6 share one reference across items. **Selection:** nested models by LR; -random-slope (6 vs 5) uses a **mixture-of-`χ²`** boundary test; fixed `δ`/`γ` significance by **Wald**. - -### 3.2 Mapping to the engine - -Position enters the linear predictor **additively** (Eq. 4/5), which is exactly a **per-cell covariate -offset**. Write the position covariate `w_{pi} = k(p,i)−1` (position of item `i` on the form person `p` -took, minus 1; or cluster-position for rotated-block designs). In the engine's easiness convention -(`b_i = −β_i`): - -$$ -\boxed{\; -\eta_{pi}=a_i\,\theta_{p,d(i)}+b_i-\gamma\lVert\xi_p-\zeta_i\rVert \;+\; w_{pi}\,\delta, -\qquad \delta=-\gamma_{\text{pos}} -\;} -$$ - -Two useful configurations, both from the paper: - -- **Test-level (shared) slope `δ`** (Eq. 5): a single extra scalar. This is the minimal, recommended form - (the paper's simulation and both applications land on the linear shared-slope model as best fit). -- **Item-level slope `δ_i`** (Eq. 2/4, "position DIF per item"): `w_{pi} δ_i`, one slope per item — the - `δ^{β}_{ik}`/`δ^{β}_k` main-effect family. Choose the position basis in `w`: linear `(k−1)`, or dummy - columns per position for the unstructured Eq. 4 main effect. - -The offset is a **known covariate times an unknown slope** — a GLM offset with a free coefficient, needing -only the linear predictor to gain one additive term and the M-step to gain one (or `I`) coordinate. - -### 3.3 Implementation plan - -- **E-step:** the quadrature kernel `P_i(v)` becomes `P_{pi}(v) = logit^{-1}(a_i v + b_i − γ‖·‖ + w_{pi}δ)`. - Because `w_{pi}` is data (person×item design), the person-independent item-table shortcut is broken **only - when `w_{pi}` varies within an item across persons** (random/rotated orders). For a single fixed form, - `w_{pi}=w_i` is item-constant and the existing tables still apply. Keep both paths. -- **M-step:** add `δ` (or `δ_i`) to the Newton block. Gradient contribution per cell is - `w_{pi}·(expected residual)`; for shared `δ` it is a 1-D Newton summed over all cells, identical in form to - the existing `γ` update. -- **Parameter count:** `+1` (shared linear), `+I` (per-item), or `+(K_pos−1)` (unstructured position dummies). -- **Identification:** reference position `k=1` gives `w=0`, pinning the offset; no extra constraint. `δ` is - identified only if items appear at **≥2 distinct positions** across the data (overlapping/anchor items - across forms, or randomized order) — otherwise position is confounded with item easiness. Enforce/warn. -- **Person-specific `γ_p` (Eq. 6):** *adaptation, heavier.* It is a **random slope** = a second - simple-structure latent dimension whose "loadings" are the fixed known values `w_{pi}=(k−1)`, correlated - with `θ`. Implementable as a 2-D correlated trait (`corr(γ_p,θ_p)` via a `2×2` population covariance) but - requires correlated quadrature — see §5 (same missing capability as Huo's cross-dimension covariance). - Document as the upgrade path; ship the fixed-slope offset first. -- **Minimal recovery test:** `N=1000`, `I=50` drawn per person from a pool of 75 with random order (paper's - design), fixed `γ_pos ∈ {.010,.015,.020}`. Assert: `δ̂` recovers `−γ_pos` within ±.003; plain LSIRM (δ=0) - overestimates difficulty by ≈ (mean position)·γ_pos (their Table 1 finding); person params ~unbiased. - -### 3.4 Out of scope - -Random position slope `γ_p` beyond the documented 2-D adaptation; response-contingent ("dynamic") position -models (Verguts–De Boeck, Verhelst–Glas); pairwise/sequencing effects (item-preceded-by-item); position -effects on response *time*; and speededness/omission mechanisms (the paper explicitly separates these from -position effects — omitted/not-reached items must be handled by the missing-data path, not the offset). - ---- - -## 4. Jeon & De Boeck (2016) — generalized IRTree - -**Citation.** Jeon, M., & De Boeck, P. (2016). A generalized item response tree model for psychological -assessments. *Behavior Research Methods*, 48(3), 1070–1085. https://doi.org/10.3758/s13428-015-0631-y - -### 4.1 The paper's model, exact - -A response with `M` observed categories is decomposed by a tree into `K` internal binary (or polytomous) -**nodes**. Node `k` for person `p`, item `i` uses its own IRT model (Eq. 1/7): -$$ -\Pr\big(Y^{*}_{pik}=T_{mk}\mid\theta_{pk}\big)=g^{-1}\big(\alpha_{ik}\,\theta_{pk}+\beta_{ik}\big), -$$ -with node-specific latent trait `θ_{pk}`, slope `α_{ik}`, intercept `β_{ik}`, and link `g` (logit/probit for -binary nodes; adjacent/cumulative logit for >2-branch nodes). - -**Mapping matrix `T`** is `M×K`; entry `T_{mk} ∈ {0,1,…,L−1}` is the outcome required at node `k` on the -path to observed category `m`, and `NA` when node `k` is **off-path** for `m`. Observed-response likelihood -(Eq. 8): -$$ -\boxed{\; -\Pr(Y_{pi}=m\mid\theta_{p1},\dots,\theta_{pK})=\prod_{k=1}^{K}\Pr\big(Y^{*}_{pik}=T_{mk}\mid\theta_{pk}\big)^{t_{mk}}, -\quad t_{mk}=\begin{cases}T_{mk}, & T_{mk}\in\{0,1\}\\[2pt]0,& T_{mk}=\mathrm{NA}\end{cases} -\;} -$$ - -The two structural assumptions: (i) internal-node outcomes are conditionally independent given the traits; -(ii) exactly one path yields each observed category. The traits `θ_p=(θ_{p1},…,θ_{pK})' ~ N(0,Σ)`. - -**Key equivalence (Eq. 9).** Model (8) **is a simple-structure `K`-dimensional IRT model** fit to the -node-expanded binary responses `Y*_p = (Y*_{p1},…,Y*_{piK})`, with **structural missingness** wherever a -node is off-path (`NA`). No cross-loadings between node-dimensions → identified by the usual simple-structure -constraints (`means 0, diagonal Σ variances 1`). - -Optional refinements: -- **Node-main-effect reduction (Eq. 11–12):** `β_{ik}=β_i+δ_{βk}`, `α_{ik}=α_i+δ_{αk}` — collapses `I×K` - node-specific parameters to `I+K`, and tests node-measurement invariance. -- **Bifactor node structure (Eq. 10):** `g^{-1}(α^{g}_{ik}θ^{g}_p+α_{ik}θ_{pk}+β_{ik})` — a general factor - loading all nodes (within-item multidimensionality). -- **Collapse latent structure (Eq. 13):** if node traits are perfectly correlated, a single `θ_p`. - -### 4.2 Mapping to the engine — pseudo-item expansion - -Because Eq. 9 says the IRTree **is** a simple-structure `K`-dimensional model with structural missingness, -and the engine already does simple-structure multidim + not-presented missingness, the **entire minimal -model is a data-preprocessing layer plus interpretation — no core E-/M-step change.** - -Expansion procedure (the deliverable for a binary-response engine): - -1. Fix a tree and its `M×K` mapping matrix `T` (e.g., three-category "No/Perhaps/Yes" → `K=2`; four-point - Likert → `K=3`; omit-then-respond → `K=2` with node 1 = responded/omitted). -2. For each original response `Y_{pi}=m`, emit `K` **pseudo-items**. Pseudo-item `(i,k)` gets value `T_{mk}` - if `T_{mk}∈{0,1}`, and is **left missing** (dropped from the E-step, exactly the not-presented path) if - `T_{mk}=NA`. Behavioral/omitted responses are just another observed category `m` with its own row of `T` - (this is how the paper models MNAR omission: node 1 = respond/omit). -3. Assign each pseudo-item `(i,k)` to latent dimension `d = k` (simple structure: node = dimension). - Node-specific `(a_{ik}, b_{ik})` are simply free per pseudo-item — the natural default. -4. Run the engine unchanged. Node traits’ correlations are the engine's between-dimension population - correlations (if it estimates them; see §5) — otherwise fit diagonal and report per-node traits. - -This directly delivers: partial-ordering tests of Likert scales, response-style dimensions, and -skip/omission (MNAR) modeling — all as expansions, all binary. - -### 4.3 Implementation plan - -- **E-step / M-step:** unchanged. The expansion produces a binary matrix with missing cells the engine - already integrates over and updates from. -- **Preprocessing module:** `expand_irtree(Y, T) -> (Y*, dim_map)` — takes the original responses and a - mapping matrix, returns the pseudo-item binary matrix, the per-pseudo-item dimension assignment `d=k`, and - the missingness mask. This is the whole feature. -- **Node-main-effect reduction (Eq. 11–12):** *optional adaptation.* Parameter-tying `a_{ik}=a_i+δ_{αk}`, - `b_{ik}=b_i+δ_{βk}` across pseudo-items of the same original item — a linear constraint in the M-step - (shared `a_i,b_i` plus per-node offsets `δ_k`). Ship free per-pseudo-item first; add tying if parameter - economy or invariance testing is wanted. -- **Parameter count:** free version = `K` binary items per original item, each with `(a,b)` → `2·I·K`; - reduced version → `2·I + 2·(K−1)`. -- **Identification:** simple-structure constraints per node-dimension (`mean 0, var 1`). **LR caveat from the - paper:** likelihoods are comparable only between trees of the **same size** (same node vector length); a - tree that changes `K` changes the expanded data, so use AIC/BIC or refit, not a raw LR, across different - trees. -- **Minimal recovery test:** simulate `K=2` tree (3-category), `I=24`, `N=316` (verbal-aggression sizes), - known node-specific `(a,b)` and `corr(θ_1,θ_2)`. Assert: expand → fit recovers node params within Monte - Carlo error and the node-trait correlation; and that treating the 3-category item as a single dichotomized - binary loses the second node's information (sanity check on the value of the expansion). - -### 4.4 Out of scope - -Polytomous (>2-branch) nodes with adjacent/cumulative links (GPCM/GRM at a node) — needs a polytomous -kernel the engine does not have; keep to **binary nodes** (the paper's own primary illustrations are binary). -Bifactor node structure (Eq. 10, within-item general factor) — requires cross-loading, not simple structure. -Multiple-path trees (Böckenholt 2013) — explicitly excluded by the paper's one-path assumption. Node-specific -person **covariates** (Eq. 14) beyond what the engine already supports. - ---- - -## 5. Huo, de la Torre, Mun, Kim, Ray, Jiao & White (2015) — hierarchical multi-unidimensional 2PL for sparse multi-group IDA - -**Citation.** Huo, Y., de la Torre, J., Mun, E.-Y., Kim, S.-Y., Ray, A. E., Jiao, Y., & White, H. R. (2015). -A Hierarchical Multi-Unidimensional IRT Approach for Analyzing Sparse, Multi-Group Data for Integrative Data -Analysis. *Psychometrika*, 80(3), 834–855. https://doi.org/10.1007/s11336-014-9420-2 - -### 5.1 The paper's model, exact - -Between-item (multi-unidimensional) 2PL for respondent `i` in group `g`, item `j` of dimension `d` (Eq. 1): -$$ -P\big(X_{gij(d)}=1\mid\theta_{gi(d)},\alpha_{j(d)},\beta_{j(d)}\big) -=\frac{\exp\!\big[\alpha_{j(d)}\big(\theta_{gi(d)}-\beta_{j(d)}\big)\big]} -{1+\exp\!\big[\alpha_{j(d)}\big(\theta_{gi(d)}-\beta_{j(d)}\big)\big]}, -$$ -each item loads **one** dimension `d=1,…,D` (simple structure). Group-`g` likelihood (Eq. 2): -$$ -L(X_g\mid\theta_g,\mu_g,\Sigma_g,\alpha,\beta)= -\prod_{d=1}^{D}\prod_{i=1}^{I}\prod_{j(d)} -\big[P_{gij(d)}\big]^{X_{gij(d)}}\big[1-P_{gij(d)}\big]^{1-X_{gij(d)}} . -$$ - -**Hierarchical latent structure.** `θ_{gi} ~ N(μ_g, Σ_g)` — each group has its own `D`-vector mean and -`D×D` covariance. **Anchor group** `g=G`: `μ_G = 0`, and `Σ_G` constrained to a **correlation matrix `R`** -(variances 1) — identification is on the *latent distribution*, item parameters left free. Other groups -estimate full `μ_g, Σ_g`. A **second hierarchical level** links the group means (real-data model, Eq. 5): -$$ -\mu_g\sim N(\mu_H,\Sigma_H),\qquad \mu_H\sim N(0,\tau_H^2 I), -$$ -so group means are random effects shrunk toward a grand mean `μ_H` — the mechanism that stabilizes small / -sparse studies. - -**Estimation:** MCMC (Gibbs for `μ_g, Σ_g`; M–H for `θ_{gi}`, for `(α,β)`, and for the correlation matrix -`R` via a determinant-ratio acceptance). Sparse pooled data (≈57% missing) handled by the **"not presented" -(NP)** rule — the sampler skips missing cells and uses only observed responses (MAR assumed, justified by a -design-driven missingness pattern). Two-stage run: calibrate structural params on a reduced sample, then -score everyone with those params fixed. - -### 5.2 Honest coverage assessment against `fast-mlsirm` - -**Already covered by the target engine:** - -- Between-item **simple-structure multidimensional 2PL** — this *is* the engine's `d(i)` loading map. -- **Multiple groups** with group-specific trait means and SDs (`μ_{gd}, σ_{gd}`). -- **Anchor-group identification** on the latent distribution (reference group `N(0,1)`, common/anchored item - block) — the same philosophy Huo emphasizes over constraining item parameters. FIPC covers exactly this. -- **Sparse / MAR missingness via not-presented** — the engine's E-step already drops missing `(p,i)` cells; - Huo's NP rule is the same device. Their second (robustness) simulation just confirms NP is unbiased under - design missingness, which the engine inherits. -- **Two-stage calibrate-then-score** — the engine's scoring path (EAP/EAPsum with item params fixed) is the - EM analog of Huo's calibration/scoring split. - -**Genuinely missing (the real deliverable):** - -1. **Free within-group cross-dimension covariance `Σ_g` (off-diagonals).** The engine carries per-dimension - `σ_{gd}` but (per the MMLE design's tensor-GH note) integrates a **diagonal / separable** trait - population; it does **not** estimate the `D(D−1)/2` correlations *among* the `D` dimensions within a - group. Huo's whole value proposition — "associations across dimensions as auxiliary information improve - trait estimates" (de la Torre & Patz) — depends on those off-diagonals. Adding them needs a **correlated** - E-step (rotate GH nodes by a Cholesky of `Σ_g`, or QMC) and an M-step covariance update - `Σ_{gd d'} = E_g[(θ_d−μ_{gd})(θ_{d'}−μ_{gd'})]` (posterior cross-moments — same shape as the existing - variance update, extended to off-diagonals). -2. **Hierarchical linking / shrinkage of group means (`μ_g ~ N(μ_H, Σ_H)`).** The engine treats each `μ_g` - as a **fixed** effect. Huo's second level makes them **random**, shrinking sparse studies toward `μ_H`. - This is the distinctive IDA feature and is absent. In EM it is an **empirical-Bayes / two-level M-step**: - after the usual `μ_g = E_g[θ]`, apply a James–Stein-style shrinkage `μ_g ← (Σ_H^{-1}+n_g Σ_g^{-1})^{-1} - (Σ_H^{-1}μ_H + n_g Σ_g^{-1} μ̄_g)` and update `μ_H = mean_g μ_g`, `Σ_H` from the between-group spread - (Huo's own `μ_H` prior/update, ported from the Gibbs full conditional to a moment update). -3. **Estimation-method mismatch (MCMC → marginal EM).** Not a model gap but a porting cost: correlation-matrix - `R` sampling, Inverse-Wishart priors, and 4-parameter-Beta item priors are Bayesian conveniences; the EM - port replaces them with the correlated-quadrature E-step (#1) and the moment/EB M-steps (#1, #2). Item - priors, if wanted, become penalties in the M-step Newton. - -**Net:** ~70% already-covered; the port is (1) free within-group `Σ_g` and (2) hierarchical mean shrinkage. - -### 5.3 Implementation plan - -- **E-step:** replace the separable trait weighting with a **group-specific correlated** weighting — GH nodes - transformed by `μ_g + L_g z` where `L_g L_g' = Σ_g` (or QMC when `D ≥ 3`, which the engine already offers - for higher dims). Not-presented handling unchanged. -- **M-step:** (a) item `(a_j,b_j)` via existing per-item Newton on expected counts (unchanged); - (b) `μ_g, Σ_g` from posterior first/second cross-moments (variance update generalized to the full matrix); - (c) **new** hyper-step: `μ_H, Σ_H` and the EB shrinkage of `μ_g` above. Anchor group `μ_G=0, Σ_G=R` - (correlation) pins the metric. -- **Parameter count:** `+ G·D(D−1)/2` for free within-group correlations, `+ D` for `μ_H`, `+ D(D+1)/2` for - `Σ_H`. Anchor group contributes the `D(D−1)/2` correlations of `R` only. -- **Identification:** anchor group `μ_G=0`, `Σ_G=R` a correlation matrix (unit variances). Sparse linkage - requires enough **cross-study common (anchor) items** to bridge groups — the paper collapses near-duplicate - items to raise linkage; the engine's anchor block is the mechanism, but **warn when a group's overlap with - the anchor item set is below a threshold** (their small-study bias came exactly from thin overlap). -- **Minimal recovery test:** `G=3`, `D=5`, `N_g=1000`, off-diagonal correlations 0.4, group scalings - `Σ_2=0.75Σ_1, Σ_3=1.25Σ_1`, means `μ_2=(.3,.4,.5,.6,.7)`, `μ_3=−μ_2` (Huo's own design). Assert: `μ_g` and - `Σ_g` (incl. off-diagonals) recovered with small bias/RMSE; trait scores correlate `≥.98` with truth; then - **induce their real-data 57% design-missingness** and assert item/mean bias stays small (their robustness - result). Add a sparse small-group case to confirm the shrinkage step reduces small-study mean error vs. - fixed-effect means. - -### 5.4 Out of scope - -The MCMC apparatus itself (Gibbs/M–H samplers, Inverse-Wishart / 4-Beta priors, Gelman–Rubin diagnostics, -correlation-matrix determinant-ratio sampling); higher-order IRT (a super-ordinate factor subsuming domains — -the paper explicitly contrasts its "means+covariances" model with the higher-order model); item-collapsing / -harmonization of near-duplicate items across studies (a data-curation step, not an engine feature); and -posterior-predictive model checking as implemented in the paper. - ---- - -## Summary — feasibility per paper - -| # | Paper | Core contribution ported | Feasibility | -|---|---|---|---| -| 1 | Perumean-Chaney 2013 | Structural-zero mixture on the all-zero pattern; `π` via one EM responsibility + reweighted counts; "prefer the richer model, boundary-correct the `π=0` test" | **adaptation** (small: +1 param, no new integration) | -| 2 | Jeon–Rijmen–Rabe-Hesketh 2013 | Multiple-group DIF as per-item group-specific `(a_i^g,b_i^g)` on flagged items + anchors + Wald/LR test; impact via existing group means/SDs | **adaptation** (bifactor itself out of scope; DIF slice mostly already-covered) | -| 3 | Debeer–Janssen 2013 | Linear position effect as an additive per-cell covariate offset `η += w_{pi}·δ` (shared or per-item slope) | **direct** (offset + 1-D Newton); random slope `γ_p` = adaptation | -| 4 | Jeon–De Boeck 2016 | IRTree = simple-structure `K`-dim binary model via mapping-matrix pseudo-item expansion with off-path `NA`; a preprocessing layer, no core change | **direct** (binary nodes); polytomous/bifactor nodes out of scope | -| 5 | Huo et al. 2015 | Hierarchical multi-unidim 2PL for sparse multi-group IDA | **already-covered** for ~70% (simple-structure multidim + multigroup + NP/MAR missing + anchor id); **adaptation** for the 2 genuine gaps: free within-group `Σ_g` off-diagonals, and hierarchical shrinkage of group means `μ_g ~ N(μ_H,Σ_H)` | diff --git a/docs/papers/group_c_specs.md b/docs/papers/group_c_specs.md deleted file mode 100644 index 518fc415a..000000000 --- a/docs/papers/group_c_specs.md +++ /dev/null @@ -1,344 +0,0 @@ -# Group C — Implementation-Ready Specs for fast-mlsirm - -Analyst: psychometrics implementation review of 5 papers. -Target engine: **fast-mlsirm** (Rust marginal-EM latent-space IRT; binary evaluation items; -EAP/MAP/EAPsum scoring; S-X²/l_z*/infit-outfit fit stats; item-screening pipeline; serving bundles). -Use case: calibrating **LLM-as-a-Judge** outputs (each judge verdict = one binary item response). - -Relevant existing modules (verified in tree): -- Rust core `crates/mlsirm-core/src/`: `fitstats.rs`, `scoring.rs`, `marginal.rs`, `mmle.rs`, `nodes.rs`, `quadrature.rs`. -- Python `python/fast_mlsirm/`: `fitstats.py`, `diagnostics.py`, `scoring`(in core), `serving.py`, `report.py`, `linking.py`, `test_design.py`, `inference.py`, `simulation.py`, `io.py`. - -Feasibility legend: **direct** (fits current binary/marginal engine), **adaptation** (needs new -subgroup/keying inputs but implementable), **superseded** (engine already covers it), **document-only** -(needs data the engine does not have — polytomous options, keyed reversals, human-rater panels). - ---- - -## 1. Williamson, Xi & Breyer (2012) — Automated-Scoring Evaluation Framework → **direct** - -**Full citation.** Williamson, D. M., Xi, X., & Breyer, F. J. (2012). A Framework for Evaluation and -Use of Automated Scoring. *Educational Measurement: Issues and Practice, 31*(1), 2–13. -(National Council on Measurement in Education.) - -**Core contribution.** An ETS operational framework (built around e-rater) with *conjunctive* -acceptance criteria for approving an automated scorer to run alongside human scoring. For our purpose the -"automated score" = LLM-judge verdict, the "human score" = gold human label. Any single criterion failing -flags the item/task as a substantive concern. - -### 1.1 Exact statistics and thresholds - -All criteria are **conjunctive** and must be computed on a **held-out set** (not the data used to fit the -judge/calibration model); for task-generalization, no task overlap between fit and eval sets. - -| # | Statistic | Formula | Threshold | Notes | -|---|-----------|---------|-----------|-------| -| A | Quadratic-weighted kappa (QWK), auto vs human | `κ_w = 1 − (Σ w_ij O_ij)/(Σ w_ij E_ij)`, quadratic weights `w_ij = (i−j)²/(K−1)²`, `O` = observed joint prop., `E` = product of marginals (Fleiss & Cohen 1973) | **≥ .70** (auto rounded normally to the human scale), on generally-normal score distributions | "Tipping point where signal outweighs noise"; ~half of human-score variance explained. | -| B | Pearson product–moment r, auto vs human | standard `r` on **unrounded** auto scores | **≥ .70** | Same variance-accounted rationale. Differs from A because A rounds, B does not. | -| C | Exact agreement %, and exact+adjacent (±1) agreement % | proportion equal / within 1 point | **Reported only — NOT an acceptance criterion** | Rejected as a gate due to scale dependence (higher by chance on a 4-pt than 6-pt scale) and base-rate sensitivity. Report for lay readers. | -| D | Degradation from human–human agreement | `Δ = (human–human agreement) − (auto–human agreement)`, in **either** QWK or r | **auto–human may not be > .10 lower** than human–human | Requires a human–human baseline (double-scored subset) as a precursor. Borderline exception noted (e.g. auto–human .69 vs human–human .71 accepted). Auto may legitimately exceed human–human. | -| E | Standardized mean score difference (SMD) | `SMD = (M_auto − M_human) / SD_human` (standardized on the **human** score distribution) | **\|SMD\| ≤ .15** (overall/task level) | Guards against differential scaling / off-center distributions. For a regression-fit scorer, SMD is rarely flagged on the fit set → must use held-out data. | -| F | Subgroup SMD (fairness) | same as E, computed within each subgroup of interest | **\|SMD\| ≤ .10** (stricter than overall) | Applied to every relevant subgroup (Ramineni, Williamson & Weng 2011). | -| G | Discrepancy threshold for human adjudication | \|auto − human\| ≥ τ → route to another human | Program-set: **GRE τ = 0.5** ("exact agreement"); **TOEFL τ = 1.5** | Policy knob, not a pass/fail metric; tuned to legacy human double-scoring policy. | -| H | Human-intervention filters (advisory-input screen) | rule-based flags | flag excessive length/brevity, repetition, "too many problems", off-topic | Route flagged responses to human; keep as a config-driven pre-filter. | - -Supporting (framework, not single-number gates): human scoring process/inter- & intra-rater reliability -review (prerequisite); within-test and external-criterion relationship comparisons (auto vs human); -generalizability G/Phi coefficients across tasks/forms and prediction of human scores on an alternate form; -impact-on-decision-accuracy and subgroup checks on agreement, generalizability, prediction, and decisions. - -### 1.2 Binary-item reductions (LLM-judge case; K = 2) - -The scale is 2 points (fail/pass = 0/1), so: -- **A (QWK) collapses to Cohen's unweighted kappa** — with `K=2`, `w_00=w_11=0`, `w_01=w_10=1`, so - `κ_w = κ = (p_o − p_e)/(1 − p_e)`. Threshold **≥ .70** stands. (Note: kappa is base-rate sensitive - when pass-rate is extreme; report the 2×2 table alongside.) -- **B (Pearson r) collapses to the phi coefficient** on the 2×2 table. Threshold **≥ .70**. -- **C adjacent agreement is degenerate** (with 2 categories, exact+adjacent = 100%); report **exact - agreement = accuracy** only. -- **E SMD** `= (p_judge − p_human)/sqrt(p_human(1−p_human))`. Threshold **|SMD| ≤ .15**. -- **D degradation** needs a human–human κ from a double-labeled subset; **|Δκ| ≤ .10**. -- **G** with binary scores reduces to "disagree → adjudicate" (τ between 0 and 1). - -### 1.3 Implementation plan - -- **Module:** new `python/fast_mlsirm/validation.py` ("machine-scoring validation metrics"), sibling to - `diagnostics.py`. Optionally push the hot kernels (kappa, phi, SMD over large N) to a new - `crates/mlsirm-core/src/validation.rs` mirroring the `fitstats.rs` compute-in-Rust/parity-in-NumPy pattern. -- **Formulas to code:** - 1. `cohen_kappa(judge, human)` and general `quadratic_weighted_kappa(a, b, K)` (weights above). - 2. `pearson_r` / `phi` on paired vectors. - 3. `smd(auto, human)` per §1.1-E and §1.2. - 4. `degradation(auto_human_stat, human_human_stat)` → returns Δ and pass flag at .10. - 5. `subgroup_smd(auto, human, group_id)` → per-group SMD, pass flag at .10. - 6. `agreement_report(...)` bundling exact-agreement %, 2×2 table (reported, non-gating). - 7. A `ValidationVerdict` dataclass with the conjunctive PASS/FLAG rollup + which criteria failed; - surface it in `report.py` and attach to the `serving.py` bundle as a calibration gate. -- **Inputs:** paired `(judge_label, human_label)` arrays + optional `subgroup_id`; a double-labeled subset - for the human–human baseline (criterion D); held-out flag. -- **Minimal test:** hard-code a 2×2 confusion table with a hand-computed kappa/phi/SMD (e.g. from a fixed - contingency matrix) and `assert` the functions reproduce them to 1e-9; one degradation case that must FLAG. - -### 1.4 Out of scope / caveats - -Criterion D **requires human–human double-scored data** (a rater panel); without it, degradation cannot be -computed — degrade gracefully (report A/B/E/F, mark D "N/A: no human–human baseline"). The G/Phi -generalizability and external-criterion analyses need multi-task/multi-form or external-variable data and -are framework guidance, not a single coded metric. Adjacent agreement is not meaningful for binary items. - ---- - -## 2. Ferrando, Lorenzo-Seva & Chico (2009) — FA Procedure for Response Bias → **document-only** (acquiescence analog = adaptation) - -**Full citation.** Ferrando, P. J., Lorenzo-Seva, U., & Chico, E. (2009). A General Factor-Analytic -Procedure for Assessing Response Bias in Questionnaire Measures. *Structural Equation Modeling, 16*(2), -364–381. DOI: 10.1080/10705510902751374. - -**Core contribution.** A semirestricted **tridimensional** factor-analytic model that simultaneously -separates **content (θ₁), acquiescence (θ₂), and social desirability / SD (θ₃)** from questionnaire items, -with a three-stage non-iterative calibration and factor-score estimation. - -### 2.1 Exact model, anchoring, and steps - -Structural model per content item (z-metric, three **uncorrelated** factors), Eq. 2: -``` -X_ij = φ_j1·θ_i1 + φ_j2·θ_i2 + φ_j3·θ_i3 + ε_ij -``` -SD-marker items (part of a lie/control scale) are factorially simple, Eq. 3: `X_ik = φ_k3·θ_i3 + ε_ik`. -Two key structural assumptions: (i) content, acquiescence, SD mutually independent; (ii) acquiescence does -**not** operate on near-pure SD items. Binary case = MIRT 2-parameter normal-ogive on the **tetrachoric** -correlation matrix; graded = polychoric; continuous = product-moment. - -**Three-stage sequential calibration** (one factor per stage; general FA engine = **Minimum Rank Factor -Analysis, MRFA**, which also yields item error variances and %-common-variance per factor): -1. **SD (θ₃) via instrumental-variable estimation** (Hägglund 1982): need **≥ 3 SD markers** (4 recommended). - Take one marker as pivot/proxy for θ₃, the remaining `m−1` markers as instruments; - `φ̂'_j3 = (r_k′ r_k)^{-1} (r_j′ r_k)` (Eq. 17). Reproduce and subtract → first residual matrix. -2. **Acquiescence (θ₂)** from the first residual matrix by the **modified-centroid** formula, using the - **weak balance assumption** (sum of content loadings over a balanced +/− keyed subset ≈ 0). For a - balanced-subset item `j`: `φ̂_j2 = (Σ_g r*_jg − s²_j) / sqrt(Σ_j Σ_g r*_jg − Σ_j s²_j)` (Eq. 18); - analogous Eq. 19 for non-balanced items. Reproduce and subtract → second residual matrix. -3. **Content (θ₁)** = one-common-factor (Spearman) MRFA on the second residual matrix. - -**Anchoring.** SD is anchored by the **marker items** (must be positively identified — large SD loadings, -small content loadings). Acquiescence is anchored by the **partial balance** of positive/negative keyed -items (needs both keying directions). Fit is judged non-inferentially (RMSR of residuals, residual-distribution -shape) — no χ² test. Factor scores: **EAP** (nonlinear/binary/graded) or **Bartlett ML** (continuous), with -posterior SD `PSD = sqrt(E(θ²|x) − θ̂²)` (Eq. 21) and marginal reliability `ρ = 1 − mean(PSD²)` (Eq. 22). - -### 2.2 Why this is document-only for fast-mlsirm - -The method's inputs do not exist in the LLM-judge/binary calibration setting: -- Needs a **partially balanced item set** (positively *and* negatively keyed items). Judge verdicts have no - keying reversal — there is no "disagree" polarity to cancel content loadings, so acquiescence is - unidentified by this route. -- Needs a dedicated **multi-item SD/lie marker scale** administered alongside — absent here. -- Built on **correlation-matrix FA + MRFA + IV estimation**, an entirely different estimation stack from - fast-mlsirm's per-response marginal EM; it is a calibration *replacement*, not an add-on. - -### 2.3 Salvageable adaptation (optional, small) - -The *concept* — that a judge may have a content-independent "yes-tendency" (leniency/acquiescence) or a -"desirability" pull — is worth a lightweight diagnostic, but **not via this FA machinery**. A defensible -analog inside the current engine: after MMLE, report each judge's **base pass-rate residual** (observed -pass-rate minus model-expected pass-rate marginalized over θ) as a leniency index, and, if paired -positive/negative-framed prompt variants exist, a keying-direction contrast. Place in `diagnostics.py`. -Do **not** attempt the tridimensional FA — it needs data we do not collect. `# ponytail: leniency = one -residual, not a 3-factor MRFA; upgrade only if keyed-pair prompts are added.` - -### 2.4 Out of scope - -Full procedure requires: keyed (reversed) items, an SD marker scale, tetrachoric/polychoric matrices with -smoothing (Devlin et al. 1975/1981), and MRFA (FACTOR software). None are in scope for binary judge calibration. - ---- - -## 3. Wolkowitz & Skorupski (2013) — MCM Imputation of MC Response Options → **superseded** - -**Full citation.** Wolkowitz, A. A., & Skorupski, W. P. (2013). A Method for Imputing Response Options for -Missing Data on Multiple-Choice Assessments. *Educational and Psychological Measurement, 73*(6), 1036–1053. -DOI: 10.1177/0013164413497016. - -**Core contribution / exact method.** Uses Thissen & Steinberg's (1984) **Multiple-Choice Model (MCM)** — a -`(3n−1)`-parameter logistic nominal-type model over all `n` options — to **multiply-impute the actual chosen -option** (A/B/C/D/E) for missing responses, so that classical item statistics (p-values, item–total r) -become robust. MCM (Eq. 1): -``` -P(u_ij = k | θ_i) = [ d_jk · exp(a_j0 θ_i + b_j0) + exp(a_jk θ_i + b_jk) ] / Σ_{h=0}^{m_j} exp(a_jh θ_i + b_jh) -``` -constraints `Σ a_jh = 0`, `Σ b_jh = 0`, `Σ_{k≥1} d_jk = 1`; the `d` params + option-0 term form the -"guessing/don't-know" curve, split from the "intentional" curve (Eq. 4). **MI procedure:** (1) calibrate MCM -on the incomplete data (MULTILOG, listwise-ignores missing); (2) compute per-option probabilities for each -missing cell via Eq. 4; (3) Monte-Carlo draw `X~U(0,1)` to assign an option; (4) recompute statistics; -repeat **m = 100** times; bias = mean(estimate) − true, efficiency = SD across imputations. Result: under -**MNAR**, case-deletion overestimated p by ~+.04 (up to .15 for mid-difficulty items); MI-with-MCM shrank -bias to <.01. Under MCAR/MAR both methods were ~unbiased. - -### 3.1 Honest note: does marginal-ML MAR handling supersede it? - -**Yes, for calibration purposes.** The paper itself names ML and MI as the two recommended modern -approaches (Schafer & Graham 2002); fast-mlsirm already uses the **ML branch**: marginal maximum likelihood -integrates over unobserved responses so that **MCAR and MAR missingness is ignorable and needs no -imputation** — item parameters are estimated consistently from observed cells. So the paper's own goal -("more robust item statistics under MCAR/MAR") is met directly by MMLE with no imputation step. - -Two honest boundaries: -- **MNAR** is not solved by either MMLE or MCM in general; MCM only appears to fix MNAR here because the - missingness was simulated *from the MCM intentional-curve itself* (the imputation model equals the - missingness model — a best case). Marginal ML under genuine MNAR is biased too; the remedy is an explicit - missingness/selection model, not option imputation. -- MCM imputes the **specific distractor** (A/B/C/D/E). fast-mlsirm items are **binary** (0/1) with no - distractor structure, so there is nothing to impute at the option level — the (3n−1) machinery has no - target. If a missing binary verdict must be filled for reporting completeness, that is a 1-line posterior - draw `Bernoulli(P(x=1|θ̂))`, not the MCM. - -### 3.2 Implementation plan - -**None recommended for the core.** MMLE already handles the intended MAR/MCAR case. If a "completed-matrix -for reporting" convenience is ever wanted, add a `impute_missing()` helper in `diagnostics.py` that draws -`Bernoulli(predict_proba)` per missing cell over `m` replications and reports across-imputation SD — reusing -existing `predict_proba`. `# ponytail: MMLE integrates out MAR; skip imputation unless a filled matrix is a -hard reporting requirement.` - -### 3.3 Out of scope - -MCM requires **polytomous multiple-choice option data** and MULTILOG-style nominal calibration; incompatible -with binary judge items. Its MNAR success is an artifact of matched simulate/impute models — do not cite it -as an MNAR guarantee. - ---- - -## 4. Makransky & Glas (2013) — DIF via Group-Specific Item Parameters (CAT) → **direct / adaptation** - -**Full citation (previously unknown #4).** Makransky, G., & Glas, C. A. W. (2013). Modeling differential -item functioning with group-specific item parameters: A computerized adaptive testing application. -*Measurement, 46*(9), 3228–3237. Elsevier. DOI: 10.1016/j.measurement.2013.06.020. - -**Core contribution.** A measurement-invariance / DIF workflow in a **2-PL, MML-estimated** IRT model: -detect DIF with the **Lagrange-Multiplier (LM)** and **Wald** statistics, then, instead of deleting DIF -items, split each into **"virtual items"** with **group-specific parameters** so DIF items still contribute -information while subgroups stay on a common scale. Crucially, the LM statistic is coded with an -**observed-response indicator**, so it works for **incomplete / CAT** designs — exactly fast-mlsirm's -marginal + MAR setting. - -### 4.1 Exact statistics and thresholds - -2-PL (Eq. 1): `P_i(θ) = 1/(1 + exp(−a_i(θ − b_i)))`. - -**LM statistic.** Split respondents into subgroups `g = 1…G` (focal/reference, or score-level groups for -model-fit). Per item `i`, subgroup mean observed score (Eq. 2): -``` -S_ig = (1/N_g) Σ_{n in g} b_ni · X_ni -``` -where `X_ni` = observed response (0/1) or dummy if unobserved, and `b_ni = 1` if observed else `0` (this -indicator is what makes it CAT/missing-data safe). Compare `S_ig` to its posterior expectation `E(S_ig)`; -square the differences and weight by their covariance matrix. **LM ~ χ² with G−1 df.** Effect size (Eq. 3): -``` -d_ig = max_g |S_ig − E(S_ig)| -``` -on the observed-score scale `0…m_i` (here `m_i = 1`, binary). **Threshold: `d_ig > 0.10` = more than minor -model violation** (rule of thumb, Glas 1998/2010) — valid specifically for **dichotomous** items. Because LM -power grows with N, **prefer effect size over p-value**. - -**Wald statistic.** Directly contrasts the MML item-parameter estimates (a, b) across subgroups; supports -scatter-plots of subgroup a's and b's for eyeballing misfit. Item flagged if **Wald significant** OR -**LM `d_ig > 0.10`**. - -**Iterative purification (screening pipeline).** Estimate concurrently → flag highest-DIF items via Wald/LM → -assign those **group-specific (virtual-item) parameters** → re-run Wald/LM on the remaining common items → -repeat until no common items show DIF. Then a final concurrent LM check (item-response-curve form + local -independence) confirms all virtual + common items fit one model → subgroup person scores are comparable. - -### 4.2 Implementation plan (strong fit) - -- **Fit-statistics module — `fitstats.rs` / `fitstats.py`:** add `lm_dif(...)` alongside the existing S-X²/ - l_z*. The LM machinery (subgroup observed means vs posterior expectations over the quadrature grid, - covariance-weighted quadratic form) reuses the same `(theta, xi)` node/weight grid already built for S-X²; - the `b_ni` observed-indicator maps directly onto fast-mlsirm's existing response mask. Emit both χ²/p and - `d_ig`, with the **0.10** binary threshold as the default flag. -- **Item-screening pipeline — `test_design.py` / `diagnostics.py`:** implement the iterative purification loop - (flag → assign group-specific params → re-fit → repeat). This is the "judge-DIF" screen: subgroups = - prompt category, evaluated-model family, language, or content demographic slice; flags judges/items whose - difficulty/discrimination differs by slice. -- **Optional model extension (adaptation):** support **group-specific item parameters** in `marginal.rs`/ - `mmle.rs` (virtual items = duplicate an item's `(alpha, b, zeta)` per group) so DIF items are retained - rather than dropped from the calibration/serving bank. -- **Wald:** add `wald_dif(params_g1, params_g2, cov)` in `fitstats.py` from the already-available MML - parameter covariance. -- **Minimal test:** simulate 2 subgroups from `simulation.py`, inject a known b-shift into one item, assert - `lm_dif` flags exactly that item at `d_ig > 0.10` and leaves clean items unflagged; assert `b_ni`-masked - (CAT-style) input reproduces the full-data LM on the observed cells. - -### 4.3 Out of scope / caveats - -The `d_ig > 0.10` cutoff is calibrated for **dichotomous** items (effect sizes are category-weighted); -re-derive if ever extended to polytomous. Needs a **subgroup label** per response (new input column). Virtual -/ group-specific parameters require the model-extension above before DIF items can be *retained* — without -it, the pipeline can still *detect and drop* (the classic approach). Group-specific scaling assumes an -anchor set of DIF-free common items for identification. - ---- - -## 5. Joubert, Inceoglu, Bartram, Dowdeswell & Lin (2015) — Forced-Choice vs Likert Equivalence → **document-only** - -**Full citation (previously unknown #5).** Joubert, T., Inceoglu, I., Bartram, D., Dowdeswell, K., & Lin, Y. -(2015). A Comparison of the Psychometric Properties of the Forced Choice and Likert Scale Versions of a -Personality Instrument. *International Journal of Selection and Assessment, 23*(1), 92–97. (SHL Group / CEB.) - -**Core contribution / method.** An empirical equivalence study (N = 349 SA training delegates) comparing a -**Thurstonian-IRT-scored forced-choice** questionnaire (OPQ32r, 104 triplet blocks) against a **classically -scored single-stimulus 5-point Likert** version (OPQ32n, 230 items), across 32 personality scales. The -method of interest is **Brown & Maydeu-Olivares (2011) Thurstonian IRT** — modeling "most/least like me" -block choices via the Law of Comparative Judgement to recover **normative** trait scores from forced-choice -(ipsative) data, removing ipsative distortion while controlling uniform response biases. - -**Statistics/thresholds reported** (equivalence evidence, not thresholds to code into our engine): -- Reliability: Cronbach's α (OPQ32n, mean .83) vs IRT **empirical reliability** from the test-information - function (OPQ32r, mean .83). -- **Profile similarity** = per-person correlation across the 32 scale scores (median r = .73; 63% ≥ .70; - 86% ≥ .60). -- **Profile distance** = mean of standardized-score differences across scales (96% within 0.5 z ≈ 1 sten). -- Scale intercorrelation patterns compared (both ~70% within ±0.20); same-scale n-vs-r correlations .50–.84 - (median .73). -- **Covariance-structure equivalence via SEM** (EQS): CFI = .967, RMSEA = .039, SRMR = .054 (χ² = 753.9, - df = 496, sample-size-inflated, discounted). - -### 5.1 Why document-only - -- The scoring model is **Thurstonian IRT over forced-choice blocks** (triplets/quads of paired comparisons), - a fundamentally different data structure (rank/comparative choices, multidimensional) from fast-mlsirm's - **independent binary items**. There is no forced-choice block structure in LLM-judge verdicts. -- The paper's outputs are **polytomous/continuous 32-scale personality profiles**; our engine calibrates - binary evaluation items on a (typically) low-dimensional latent space. -- Its equivalence metrics (α vs IRT empirical reliability, SEM covariance-structure invariance, profile - similarity/distance) presuppose **two parallel instruments and multi-scale profiles** — absent here. - -### 5.2 Salvageable ideas (no core work) - -Two concepts transfer as *reporting conventions*, not new estimators: -- **IRT empirical reliability from the information function** (`ρ = 1 − mean(PSD²)` / info-based) — fast-mlsirm - already produces posterior SDs in scoring, so an empirical-reliability line is a trivial `report.py` - addition if not already present. -- **Cross-scorer profile similarity / distance** — if two judges (or judge vs human panel) produce vectors of - per-item θ or pass-rates, a per-unit correlation (similarity) and standardized-difference (distance) mirror - §1 (Williamson) agreement metrics; fold into the `validation.py` from Paper 1 rather than a new module. - -### 5.3 Out of scope - -Thurstonian forced-choice IRT, ipsative-data handling, multidimensional 32-scale profiles, and two-instrument -SEM invariance — all require data structures the binary engine does not model. No implementation. - ---- - -## Cross-paper build priority for fast-mlsirm - -1. **Paper 1 (Williamson)** → new `validation.py` machine-scoring gate (kappa/phi/SMD/degradation/subgroup), - wired into `report.py` + `serving.py`. Highest value, direct fit. Also absorbs Paper 5's profile - similarity/distance and empirical-reliability reporting ideas. -2. **Paper 4 (Makransky-Glas)** → `lm_dif`/`wald_dif` in `fitstats.*` + iterative purification in the - screening pipeline; optional group-specific (virtual) item parameters in `marginal.rs`/`mmle.rs`. Direct - fit; the `b_ni` observed-indicator already matches our MAR handling. -3. **Paper 3 (Wolkowitz)** → no core work; MMLE already covers MAR/MCAR. Optional `Bernoulli` fill helper only. -4. **Paper 2 (Ferrando)** → no core work; optional lightweight leniency-residual diagnostic. Full FA method out - of scope (needs keyed items + SD markers). -5. **Paper 5 (Joubert)** → no core work; reporting-convention ideas folded into #1. diff --git a/docs/papers/implemented-literature-map.md b/docs/papers/implemented-literature-map.md deleted file mode 100644 index ba4a68c57..000000000 --- a/docs/papers/implemented-literature-map.md +++ /dev/null @@ -1,25 +0,0 @@ -# Implemented-literature map - -Where each paper of the supplied reading set landed in the codebase. The -implementation-ready extractions live in this repository at -`docs/papers/group_a_specs.md`, `docs/papers/group_b_specs.md`, and -`docs/papers/group_c_specs.md`; the estimator/scoring foundations are in -`docs/papers/mmle-lsirm-formula-compilation.md`. - -| Paper | Status | Where | -|---|---|---| -| Schneider, Chalmers, Debelak & Merkle (2019), Vuong tests for IRT model selection, MBR | implemented (non-nested z + Schwarz correction; distinguishability pre-test documented-only) | `fitstats.rs::vuong_nonnested`, `fast_mlsirm.vuong_nonnested` | -| Pritikin (2017), EM parameter covariance comparison, Cogent Psychology | implemented (the recommended Oakes-identity estimator) | `oakes.rs`, `fast_mlsirm.oakes_standard_errors` | -| Kang, Cohen & Sung (2009), model-selection indices, APM | implemented (AIC/BIC/AICc/SABIC/CAIC + free-parameter counting; BIC the default comparator; DIC/CVLL documented-only — Bayesian) | `fitstats.rs::information_criteria`, `FitResult.ic` | -| Svetina & Levy (2014), dimensionality-assessment framework, Educational Assessment | implemented (residual procedures: Yen Q3 + GDDM); DETECT/DIMTEST/NOHARM out of scope | `fitstats.rs::dimensionality_residuals`, `fast_mlsirm.dimensionality_residuals` | -| Sinharay & Lu (2008), item parameters vs item-fit correlation, JEM | implemented (S-X² plus S-G² grouped-score item-fit outputs; chi-square-G remains intentionally unimplemented) | `fitstats.rs::s_x2`, `fast_mlsirm.fitstats.s_x2` | -| Perumean-Chaney et al. (2013), zero-inflated/overdispersed count models, JSCS | implemented (structural-zero mixture for the marginal estimator; boundary-aware pi) | `marginal.rs` (`MarginalConfig.zero_inflation`), `FitConfig(zero_inflation=True)` | -| Jeon, Rijmen & Rabe-Hesketh (2013), multiple-group bifactor DIF, — | adapted (the DIF slice: group-specific virtual items + anchors + LR; bifactor machinery out of scope) | `fast_mlsirm.dif_analysis` | -| Debeer & Janssen (2013), item-position effects in IRT | implemented (linear position effect as a context-varying item covariate with estimated delta; person-specific random slope documented as upgrade path) | `marginal.rs::ItemCovariate`, `fit(covariate=...)` | -| Jeon & De Boeck (2016), generalized IRTree, BRM | implemented (mapping-matrix pseudo-item expansion; their Eq. 9 reduces IRTrees to binary IRT on the expanded matrix) | `fast_mlsirm.irtree_expand` | -| Huo et al. (2015), hierarchical multi-unidimensional IRT for sparse multi-group data | largely already-covered (simple-structure multidim + multigroup + MAR missingness + anchoring); free cross-dim covariance and hierarchical shrinkage of group means documented as future adaptations | `marginal.rs` multigroup path | -| Williamson, Xi & Breyer (2012), automated-scoring evaluation framework, EM:IP | implemented (QWK/r/SMD/degradation/subgroup conjunctive gates with the paper's thresholds) | `agreement.rs`, `fast_mlsirm.validate_judge` | -| Makransky & Glas (2013), group-specific item parameters for CAT DIF, Measurement | adapted (LR/Wald-style screen via virtual items; the LM statistic documented-only) | `fast_mlsirm.dif_analysis` | -| Ferrando, Lorenzo-Seva & Chico (2009), factor-analytic response-bias procedure, SEM | adapted (full tridimensional MRFA remains out of scope; added observed-minus-expected leniency residual proxy with explicit non-identification boundaries) | `diagnostics.py::fit_diagnostics` (`personfit.leniency_*`, `model_fit.leniency_*`), group C spec | -| Wolkowitz & Skorupski (2013), MC option imputation, EPM | superseded (marginal-ML integrates over missing cells under MAR; option-level imputation needs polytomous data) | group C spec | -| Joubert et al. (2015), forced-choice vs Likert psychometrics, IJSA | documented-only (Thurstonian forced-choice blocks absent from binary judge data) | group C spec | diff --git a/docs/papers/mmle-lsirm-formula-compilation.md b/docs/papers/mmle-lsirm-formula-compilation.md deleted file mode 100644 index f8320b9fe..000000000 --- a/docs/papers/mmle-lsirm-formula-compilation.md +++ /dev/null @@ -1,1317 +0,0 @@ -# Mathematical Foundations for an MMLE-Estimated Latent Space Item Response Model (LSIRM) and Its Extensions - -**Purpose.** Reference specification for implementing, in software, a marginal-maximum-likelihood -(MMLE) estimated LSIRM together with multilevel, multigroup, and multidimensional extensions, plus -item-fit and person-fit statistics. Every model equation is given in LaTeX with parameter definitions, -identifiability constraints, and concrete estimation update equations. - -**Verification legend** (see also §12): -`[V]` = equation verified verbatim against the cited primary source online in this compilation. -`[S]` = standard textbook result reproduced from memory (source cited, exact page not re-fetched). -`[~]` = partially verified (existence/description confirmed online; exact formula from memory). - ---- - -## 0. Notation - -| Symbol | Meaning | -|---|---| -| `p = 1,…,N` (or `k`) | respondents / persons | -| `i = 1,…,I` (or `j`) | items | -| `Y_{pi} ∈ {0,1}` | binary item response (LSIRM-continuous uses `Y_{pi} ∈ ℝ`) | -| `θ_p` (paper writes `α_j`) | person main effect / latent trait ("ability") | -| `β_i` | item main effect ("easiness"; note sign convention `+β_i`) | -| `z_p ∈ ℝ^D` (paper: `a_j`) | latent position of respondent `p` | -| `w_i ∈ ℝ^D` (paper: `b_i`) | latent position of item `i` | -| `γ ≥ 0` | weight of the distance (interaction) term | -| `d(·,·)` | distance on the latent metric space (default Euclidean `ℓ₂`) | -| `D` | dimension of the latent space (map), `D≥1`, typically `D=2` | -| `g(θ)` | population density of the latent trait (MMLE integrates over this) | - -> **Notation bridge.** The task statement uses `θ_p + β_i − γ·d(z_p,w_i)`. Jeon et al. (2021) write the -> identical model as `α_j + β_i − γ·d(a_j,b_i)`. This document uses `(θ_p, z_p, w_i)` throughout and notes -> the `(α_j, a_j, b_i)` originals where quoting the paper. - ---- - -## 1. Base LSIRM (Jeon, Jin, Schweinberger & Baugh, 2021) `[V]` - -### 1.1 Model - -General interaction form (paper Eq. 2), with `g(·,·)` a real-valued function of the two positions: -$$ -\operatorname{logit}\!\big(P(Y_{pi}=1\mid \theta_p,\beta_i,z_p,w_i)\big)=\theta_p+\beta_i+g(z_p,w_i). -$$ - -Two choices of `g`: - -- **Distance effect (the LSIRM proper, recommended by the authors):** -$$ -g(z_p,w_i)=-\gamma\, d(z_p,w_i),\qquad \gamma\ge 0, -$$ -giving the working model -$$ -\boxed{\;\operatorname{logit}\!\big(P(Y_{pi}=1)\big)=\theta_p+\beta_i-\gamma\,\lVert z_p-w_i\rVert\;} -\tag{LSIRM} -$$ -`γ>0` makes the success probability **decrease** in the respondent–item distance. Distance choices -discussed: `ℓ₁` (city-block), `ℓ₂` (Euclidean, default), `ℓ∞` (maximum). - -- **Multiplicative (bilinear) effect:** `g(z_p,w_i)=z_p^⊤ w_i` (inner product). Related to Hoff's (2005) -bilinear mixed-effects / additive-and-multiplicative-effects network models. Harder to interpret (the -effect is 0 whenever the vectors are orthogonal, regardless of distance), so the paper focuses on the -distance form. - -**Relation to other models** (paper §2.3.2): the 2-parameter IRT model -`logit P = λ_i θ_p + β_i` and the saturated interaction model `logit P = θ_p + β_i + ε_{pi}` are alternatives; -the LSIRM is the special case `ε_{pi} = −γ d(z_p,w_i)`. The distance restriction (reflexivity, symmetry, -triangle inequality) is what makes the interaction **estimable** from a single response per (p,i) pair -and injects transitivity (nearby respondents behave similarly). - -### 1.2 Priors (fully Bayesian original) `[V]` - -$$ -\begin{aligned} -\theta_p\mid\sigma^2 &\overset{ind}{\sim} N(0,\sigma^2), & \sigma^2>0,\\ -\beta_i\mid\tau_\beta^2 &\overset{ind}{\sim} N(0,\tau_\beta^2), & \tau_\beta^2>0,\\ -\log\gamma\mid\mu_\gamma,\tau_\gamma^2 &\sim N(\mu_\gamma,\tau_\gamma^2), & \mu_\gamma\in\mathbb R,\ \tau_\gamma^2>0,\\ -\sigma^2\mid a_\sigma,b_\sigma &\sim \text{Inv-Gamma}(a_\sigma,b_\sigma), & a_\sigma,b_\sigma>0,\\ -z_p &\overset{iid}{\sim}\mathrm{MVN}_D(\mathbf 0, I_D), & p=1,\dots,N,\\ -w_i &\overset{iid}{\sim}\mathrm{MVN}_D(\mathbf 0, I_D), & i=1,\dots,I. -\end{aligned} -$$ -Default hyperparameters used in the paper: `τ_β²=4, a_σ=1, b_σ=1, μ_γ=0.5, τ_γ²=1`. -A prior is placed on **positions** (not distances) because distances must satisfy the triangle inequality, -which is awkward to encode directly. - -Joint posterior (paper Eq. 5): -$$ -f(\theta,\beta,\gamma,Z,W\mid y)\propto -\Big[\textstyle\prod_p f(\theta_p)\Big]\Big[\prod_i f(\beta_i)\Big]f(\gamma) -\Big[\prod_p f(z_p)\Big]\Big[\prod_i f(w_i)\Big] -\prod_{p}\prod_{i}P(Y_{pi}=y_{pi}\mid\theta_p,\beta_i,\gamma,z_p,w_i). -$$ - -### 1.3 Estimation in the original: MCMC (Metropolis-within-Gibbs) `[V]` - -Component-wise updates per iteration `t`; each block accepted with the usual MH ratio -`min{1, f(·*|rest)/f(·^{(t)}|rest)}` using symmetric (multivariate) Gaussian random-walk proposals -centered at the current value with diagonal covariance, tuned to an acceptance rate ≈ 0.3: - -1. `θ_p*` (all `p`); 2. `β_i*` (all `i`); 3. `γ*`; 4. `z_p*` (all `p`); 5. `w_i*` (all `i`); -6. Gibbs draw of `σ²` from its full conditional: -$$ -\sigma^2\sim \text{Inv-Gamma}\!\left(a_\sigma+\tfrac{N}{2},\; b_\sigma+\tfrac{1}{2}\textstyle\sum_{p=1}^N\theta_p^2\right). -$$ -Convergence via trace plots + Gelman–Rubin `R̂`. - -### 1.4 Identifiability of the latent space `[V]` - -The log-odds depends on positions only through **distances**, which are invariant to **translation, -rotation, and reflection** of the whole configuration; hence the likelihood is invariant under these -transformations (the same non-identifiability as in latent space network models, Hoff, Raftery & -Handcock, 2002). Resolution: **post-process the MCMC/optimization output with Procrustes matching** -(Gower, 1975) to a reference configuration; interpret only **relative** distances. Additional practical -pins: the `MVN_D(0, I_D)` prior centers the map at the origin (removes translation); for a point estimate, -also fix scale. The multiplicative/inner-product variant has **only rotational** invariance -(`z^⊤w = (Rz)^⊤(Rw)` for orthogonal `R`). - -> **Implementation note.** For an MMLE/point-estimate pipeline, resolve invariance by: (i) mean-centering -> `Z` and `W` each iteration (translation); (ii) Procrustes-rotating the current `W` to a fixed reference -> `W₀` (rotation+reflection); (iii) fixing `γ>0` scale or standardizing position variance. Anchoring a few -> items' positions is an alternative that also enables cross-group comparability (see §6). - -### 1.5 Model selection (`γ=0` Rasch vs. `γ>0` LSIRM) - -Compare the Rasch/1PL nested model (`γ=0`) against LSIRM. The original uses Bayesian comparison; the R -package uses **BIC** and maximum log-posterior (§2.4). A spike-and-slab mixture prior on `γ` (mass near 0 -vs. spread over positives) yields a built-in test of whether an interaction map is needed. - ---- - -## 2. LSIRM variants (Go, Kim, Park, Park, Jeon & Jin — `lsirm12pl`) `[V]` - -### 2.1 1PL LSIRM (binary) — as §1, package Eq. (2) -$$ -\operatorname{logit}\!\big(P(Y_{pi}=1\mid\theta_p,\beta_i,\gamma,z_p,w_i)\big)=\theta_p+\beta_i-\gamma\,\lVert z_p-w_i\rVert,\qquad \theta_p\sim N(0,\sigma^2). -$$ - -### 2.2 2PL LSIRM (binary) -$$ -\operatorname{logit}\!\big(P(Y_{pi}=1)\big)=\alpha_i\,\theta_p+\beta_i-\gamma\,\lVert z_p-w_i\rVert,\qquad \theta_p\sim N(0,\sigma^2). -$$ -`α_i` = item discrimination. **Identification of slopes:** fix one slope, `α_1=1`. -**Prior:** `log α_i ∼ N(μ_α, τ_α²)` (log-normal, keeps `α_i>0`); package defaults `μ_α=0.5, τ_α=1`. -All other priors as in §1.2. - -### 2.3 Continuous (Gaussian) LSIRM — identity link -$$ -\begin{aligned} -\text{1PL:}\quad Y_{pi}&=\theta_p+\beta_i-\gamma\,\lVert z_p-w_i\rVert+\epsilon_{pi},\\ -\text{2PL:}\quad Y_{pi}&=\alpha_i\theta_p+\beta_i-\gamma\,\lVert z_p-w_i\rVert+\epsilon_{pi}, -\end{aligned} -\qquad \epsilon_{pi}\sim N(0,\sigma_\epsilon^2),\ \theta_p\sim N(0,\sigma^2). -$$ -Likelihood is a product of normals `∏_p ∏_i N(Y_{pi};\,\mu_{pi},\sigma_\epsilon^2)` with mean `μ_{pi}` the -linear predictor. Extra prior: `σ_ε² ∼ Inv-Gamma(a_{σε}, b_{σε})`. Two distinct variance components: -`σ²` = prior variance of `θ_p`; `σ_ε²` = residual variance. - -### 2.4 Estimation & fit in the package -Fully Bayesian **Metropolis-Hastings-within-Gibbs** (Chib & Greenberg, 1995) for all of -`θ,β,γ,Z,W` (plus `α`, `σ_ε²`). MAR missingness handled by data augmentation (Tanner & Wong, 1987). -Identifiability by **Procrustes** post-processing (Gower, 1975). Reported diagnostics: **BIC**, max -log-posterior, posterior-predictive item-mean plots + ROC/AUC (binary), trace/ACF/Gelman–Rubin–Brooks. -No MML/EM/variational in the package — motivating §3–§4 below. - ---- - -## 3. MMLE / EM formulation (the frequentist estimation target) - -LSIRM has latent quantities **per person** (`θ_p`, `z_p ∈ ℝ^D`) and **per item** (`w_i ∈ ℝ^D`), plus -structural parameters `ξ = (β, γ, σ², [α], [σ_ε²])`. Two coherent frequentist framings: - -### 3.A Random-effects / marginal likelihood (persons integrated out) `[S]` - -Treat person latents `(θ_p, z_p)` as random effects with densities `θ_p∼N(0,σ²)`, `z_p∼MVN_D(0,I_D)`; -treat item positions `w_i`, `β_i`, `γ` as **structural parameters** to estimate. The marginal likelihood is -$$ -L(\xi, W;\,y)=\prod_{p=1}^{N}\ \int_{\mathbb R}\!\int_{\mathbb R^{D}} -\ \prod_{i=1}^{I} P_{pi}^{\,y_{pi}}\,(1-P_{pi})^{\,1-y_{pi}}\ \phi(\theta_p;\sigma^2)\,\phi_D(z_p)\ dz_p\,d\theta_p, -$$ -with `P_{pi}=\operatorname{logit}^{-1}(\theta_p+\beta_i-\gamma\lVert z_p-w_i\rVert)`. This is the LSIRM analogue -of Bock & Aitkin (1981). The item positions `w_i` are *not* integrated out here — they are the map we want. -(One may symmetrically put `w_i` as random effects and integrate them out too; then `Z` is estimated, or -one alternates — see §3.C MH-RM, which handles both cleanly.) - -#### 3.A.1 Bock–Aitkin EM for the trait margin (classical IRT baseline) `[~]` -For the **ability-only** margin (fixing `γ`, `Z` momentarily, or for a plain 2PL calibration), the classic -EM with Gauss–Hermite quadrature applies. Approximate `∫ h(θ)g(θ)dθ ≈ Σ_{q=1}^{Q} h(X_q)A_q` at nodes -`X_q` with weights `A_q`. - -**E-step.** Posterior weight of node `q` for person `p`: -$$ -P(X_q\mid y_p)=\frac{L_p(X_q)\,A_q}{\sum_{q'=1}^{Q}L_p(X_{q'})\,A_{q'}},\qquad -L_p(X_q)=\prod_i P_i(X_q)^{y_{pi}}\big(1-P_i(X_q)\big)^{1-y_{pi}}. -$$ -Expected counts (artificial data): -$$ -\bar N_q=\sum_{p=1}^{N}P(X_q\mid y_p),\qquad -\bar r_{iq}=\sum_{p=1}^{N}y_{pi}\,P(X_q\mid y_p). -$$ - -**M-step.** For each item `i`, maximize the expected complete-data log-likelihood -$$ -\sum_{q=1}^{Q}\Big[\bar r_{iq}\log P_i(X_q)+(\bar N_q-\bar r_{iq})\log\big(1-P_i(X_q)\big)\Big] -$$ -i.e. a weighted binomial fit. For the 2PL `P_i(θ)=\operatorname{logit}^{-1}(α_i θ+β_i)` the likelihood -equations are -$$ -\sum_{q}\big(\bar r_{iq}-\bar N_q P_i(X_q)\big)=0,\qquad -\sum_{q}\big(\bar r_{iq}-\bar N_q P_i(X_q)\big)X_q=0, -$$ -solved by Newton–Raphson / Fisher scoring. Iterate E/M to convergence. The population `σ²` (or a free mean) -is updated from the moments of the posterior `P(X_q|y_p)`. - -#### 3.A.2 Why plain quadrature fails for the full LSIRM, and what to do `[S]` -The per-person latent is `(1+D)`-dimensional; a `Q`-point grid needs `Q^{1+D}` nodes (curse of -dimensionality), infeasible for `D≥2`. Practical E-steps: - -- **Monte-Carlo / importance-sampling E-step (MCEM):** draw `m` samples `(θ_p^{(s)}, z_p^{(s)})` from (an - approximation to) the posterior `f(θ_p,z_p\mid y_p,\xi,W)` and replace the integral by the sample average. - Expected complete-data log-likelihood gradient wrt structural params: - `∇_ξ Q ≈ (1/m) Σ_s ∇_ξ log f(y_p,θ_p^{(s)},z_p^{(s)};ξ,W)`. -- **Adaptive Gauss–Hermite** (Laplace-centered nodes per person) — viable for small `D`. -- **Stochastic EM / MH-RM (§3.C)** — the recommended route for LSIRM. - -### 3.B Joint maximum likelihood (JML) — Hoff–Raftery–Handcock lineage `[~]` - -Treat **all** `θ_p, z_p, w_i, β_i, γ` as fixed parameters and maximize the joint log-likelihood -$$ -\ell(\Xi)=\sum_{p=1}^{N}\sum_{i=1}^{I}\Big[y_{pi}\log P_{pi}+(1-y_{pi})\log(1-P_{pi})\Big], -$$ -by block coordinate ascent (gradient steps alternating persons ↔ items). Hoff, Raftery & Handcock (2002) -introduced this exact idea for **latent space network models**: obtain MLE latent positions (they used -distances from a logistic regression + MDS start), then refine — and it transfers directly to LSIRM's -distance model. Gradients (Euclidean distance `d_{pi}=\lVert z_p-w_i\rVert`, unit vector -`u_{pi}=(z_p-w_i)/d_{pi}`): -$$ -\frac{\partial\ell}{\partial\theta_p}=\sum_i (y_{pi}-P_{pi}),\quad -\frac{\partial\ell}{\partial\beta_i}=\sum_p (y_{pi}-P_{pi}),\quad -\frac{\partial\ell}{\partial\gamma}=-\sum_{p,i}(y_{pi}-P_{pi})\,d_{pi}, -$$ -$$ -\frac{\partial\ell}{\partial z_p}=-\gamma\sum_i (y_{pi}-P_{pi})\,u_{pi},\qquad -\frac{\partial\ell}{\partial w_i}=+\gamma\sum_p (y_{pi}-P_{pi})\,u_{pi}. -$$ -**Caveats:** JML latent positions are unidentified up to translation/rotation/reflection (re-Procrustes -each iteration; center `Z,W`); and JML suffers the **incidental-parameters (Neyman–Scott) problem** — with -person and item latents both growing, estimates of structural parameters can be inconsistent. Use JML for -a fast warm start, then hand off to the marginal estimator (§3.A/§3.C) for consistent structural estimates. -A ridge/prior penalty (equivalently the `MVN_D(0,I_D)` prior as an `ℓ₂` penalty on positions) regularizes -the otherwise flat directions. - -### 3.C MH-RM (Cai, 2010) — recommended MML estimator for LSIRM `[V for algorithm, ~ for LSIRM specialization]` - -Metropolis–Hastings Robbins–Monro is stochastic-approximation EM built for exactly this regime (many -latents, high dimension). Let complete data be `(y, φ)` with latent `φ=({θ_p,z_p}_p, [ {w_i}_i ])` and -structural `ξ`. Uses **Fisher's identity** `∇_ξ log L(ξ) = E_φ[ ∇_ξ log f(y,φ;ξ) \mid y,ξ ]`. - -Iteration `t`: -1. **Imputation (MH):** draw `φ^{(t)}` with a few Metropolis–Hastings steps from `f(φ\mid y,\xi^{(t-1)})` - (random-walk proposals on `θ_p, z_p, w_i`). -2. **Approximation:** form the complete-data score (ascent direction) and (optionally) an information - estimate at the imputed data, - $$ - s^{(t)}=\nabla_\xi \log f\big(y,\varphi^{(t)};\xi^{(t-1)}\big),\qquad - H^{(t)}=\text{recursive estimate of }-\nabla^2\ \text{(or empirical info)}. - $$ -3. **Robbins–Monro update:** - $$ - \xi^{(t)}=\xi^{(t-1)}+\varepsilon_t\,\big(H^{(t)}\big)^{-1}s^{(t)},\qquad - \sum_t \varepsilon_t=\infty,\ \ \sum_t \varepsilon_t^2<\infty\ (\text{e.g. }\varepsilon_t=1/t). - $$ -The estimate sequence converges w.p.1 to the MML solution. Standard errors come from the recursive -information accumulation (Louis's identity). Because MH-RM only needs to *sample* `φ` (never integrate), -it sidesteps the `Q^{1+D}` quadrature blow-up — the practical reason to prefer it for LSIRM. (Reference -implementation for multidimensional IRT: `mirt::mirt(..., method = "MHRM")`.) - -### 3.D Variational / importance-sampling MML `[~]` - -Mean-field or Gaussian variational inference maximizes the ELBO -$$ -\log p(y)\ \ge\ \mathcal L(q)=\mathbb E_{q}\big[\log p(y,\varphi,\xi)\big]-\mathbb E_q[\log q(\varphi)], -$$ -with `q(φ)=∏_p N(θ_p;m_p,s_p^2)\,N_D(z_p;μ_p,Σ_p)\,∏_i N_D(w_i;ν_i,Ω_i)`. For the latent-space *network* -model, Gaussian VI (Salter-Townshend & Murphy, 2013) gives fast, scalable position estimates and transfers -to LSIRM's distance likelihood via a local (delta / quadratic) bound on `log σ(·)` or a Pólya–Gamma -augmentation for the logistic term. Variational IRT (Wu et al., 2020; Natesan-style SVI) demonstrates the -same idea for the trait margin. Treat VI as a fast approximate MML; expect variances to be under-estimated. - ---- - -## 4. Multilevel (hierarchical) extension - -### 4.1 HLSIRM (Park, Shin, Jeon, Kim & Jin, 2026) `[V]` - -Students `i` nested in schools `k`, items `j`. **Inner-product** interaction with a stochastic error -(no `γ`; follows Hoff's additive-and-multiplicative-effects models): -$$ -\operatorname{logit}\!\big(P(y_{ij(k)}=1)\big)=\alpha_{i(k)}+\beta_j+z_{i(k)}^{\top}w_j+\varepsilon_{ij(k)}, -\qquad \varepsilon_{ij(k)}\overset{iid}{\sim}N(0,1). \tag{HLSIRM} -$$ -Matrix form for school `k` (`Θ^{(k)}` the `n_k×p` logit matrix): -`logit(Θ^{(k)}) = α_{(k)} 1_p^⊤ + 1_{n_k} β^⊤ + Z^{(k)} W^⊤ + E^{(k)}`. - -**Multilevel structure (random effects at the school level):** -$$ -\alpha_{i(k)}\mid\alpha_{(k)},\sigma^2_{(k)}\sim N(\alpha_{(k)},\sigma^2_{(k)}),\qquad -z_{i(k)}\mid z_{(k)},\Psi_z\sim \mathrm{MVN}(z_{(k)},\Psi_z), -$$ -i.e. the decomposition `θ_{pg}=μ_g+ε_{pg}` is realized here as a student intercept scattered around its -**school-level mean** `α_{(k)}`, and a student position scattered around its **school-level position** -`z_{(k)}`. `σ²_{(k)}` is a **school-specific within-school variance component**; `Ψ_z` a shared -within-school position covariance. - -**Key design choice — one shared map.** Item parameters `β_j, w_j` are **common across schools** -(no hierarchy on items) → measurement invariance → schools are directly comparable inside a **single unified -interaction map**; each school has its own `(α_{(k)}, z_{(k)})` within it. (This differs from fitting -separate per-group models and stitching them together.) - -**Priors / hyperpriors:** -$$ -\begin{aligned} -\alpha_{(k)}\mid\sigma_\alpha^2&\sim N(\alpha_0,\sigma_\alpha^2), & -z_{(k)}\mid\Psi_z&\sim \mathrm{MVN}(z_0,\Psi_z/\kappa_0),\\ -\sigma^2_{(k)}&\sim \text{Inv-Gamma}(a_\sigma,b_\sigma), & -\Psi_z&\sim \text{Inv-Wishart}(S_z,\nu_z),\\ -\beta_j&\sim N(\beta_0,\tau^2), & -w_j\mid\Psi_w&\sim \mathrm{MVN}(w_0,\Psi_w),\quad \Psi_w\sim \text{Inv-Wishart}(S_w,\nu_w). -\end{aligned} -$$ -Values used: `α_0=β_0=0`, `z_0=w_0=0`, `σ_α=τ=2.5` (fixed for identifiability), `ν_z=ν_w=D+1`, -`S_z=S_w=2·I`, `κ_0=1`, `a_σ=b_σ=1`, error precision fixed (`1/φ=1`, confounded with parameter scale). -**Interaction-adjusted summaries** (Kang & Jeon, 2024): -`α̃_{(k)}=α_{(k)}+\tfrac1p Σ_j z_{(k)}^⊤ w_j`, `β̃_j=β_j+\tfrac1K Σ_k z_{(k)}^⊤ w_j`. - -**Estimation:** fully Bayesian MCMC; a **joint per-school** Metropolis–Hastings acceptance for the coupled -block `(α_{(k)}, α_{i(k)}, β_j, z_{(k)}, z_{i(k)}, w_j)`, with conjugate Gibbs draws for the Inv-Gamma / -Inv-Wishart variance/covariance components. **Identifiability:** inner-product form has only rotational -invariance; resolved by **Procrustes** alignment of *all* positions to a reference; cross-school -comparability comes from the shared item parameters (no per-school anchoring needed). **Checking:** -posterior-predictive replication + classification metrics (AUC, F1). - -> **MMLE version of the multilevel model.** To estimate HLSIRM (or a distance-based multilevel LSIRM) by -> marginal likelihood, integrate out **both** student-level latents `(α_{i(k)}, z_{i(k)})` *and* the -> school-level latents `(α_{(k)}, z_{(k)})`, keeping `β_j, w_j` (and variance components -> `σ²_{(k)}, Ψ_z, σ_α², τ²`) as structural parameters. The nested integral factorizes over schools, so an -> MH-RM E-step samples student latents given school latents, then school latents given the rest — a natural -> two-level Gibbs imputation inside the Robbins–Monro update (§3.C). Variance components update from the -> usual random-effects EM moment equations, e.g. `σ_α² ← (1/K)Σ_k E[(α_{(k)}-α_0)²\mid y]`. - -### 4.2 Generic multilevel IRT (Fox & Glas, 2001; Fox, 2010) `[S]` - -A two-level model with a **measurement** level and a **structural** level. Level-1 (e.g. normal-ogive / -2PL): `P(Y_{pjk}=1)=Φ(a_i θ_{pj}-b_i)` for person `p` in group `j`. Level-2 (person abilities as outcomes): -$$ -\theta_{pj}=x_{pj}^\top\beta + u_{0j}+e_{pj},\qquad u_{0j}\sim N(0,\tau^2),\ e_{pj}\sim N(0,\sigma^2), -$$ -so a random-intercept model gives `θ_{pj}=γ_{00}+u_{0j}+e_{pj}`, `Var(θ)=τ^2+σ^2`, with intraclass -correlation `ρ=τ^2/(τ^2+σ^2)`. Estimated by Gibbs sampling (Fox & Glas) or MML with a nested random-effects -integral. This is the template the multilevel LSIRM specializes by adding the latent-position layer. - ---- - -## 5. Multigroup extension - -### 5.1 Bock & Zimowski (1997) multiple-group IRT `[S]` - -Groups `g=1,…,G`; person `p` in group `g` has trait `θ_{pg}`. Item parameters are **common** (anchored) -across groups; group populations differ in mean/variance: -$$ -P(Y_{pi}=1\mid\theta_{pg})=c_i+(1-c_i)\,\operatorname{logit}^{-1}\!\big(a_i(\theta_{pg}-b_i)\big), -\qquad \theta_{pg}\sim N(\mu_g,\sigma_g^2). -$$ -**Identification:** fix one reference group `μ_1=0, σ_1^2=1` (or impose `Σ_g μ_g=0`); estimate `(μ_g,σ_g^2)` -for the others. Marginal likelihood sums the Bock–Aitkin margin (§3.A.1) group-by-group with -group-specific quadrature weights `A_{q}^{(g)}` from `N(μ_g,σ_g^2)`: -$$ -L=\prod_{g}\prod_{p\in g}\ \sum_{q} A_q^{(g)}\prod_i P_i(X_q)^{y_{pi}}(1-P_i(X_q))^{1-y_{pi}}. -$$ -**DIF framing / measurement invariance:** designate **anchor** items with group-invariant parameters; -allow **studied** items' `(a_i,b_i)` to differ across groups. A likelihood-ratio / Wald test on the -group-specific vs. common item parameters is the DIF test; full invariance ⇒ all items anchored. - -### 5.2 Multigroup LSIRM (construction) `[~]` - -No dedicated multigroup-LSIRM paper was located online; the natural specification mirrors §5.1 and the -HLSIRM invariance logic: - -- **Shared map + group trait distributions:** common `β_i, w_i, γ`; group traits `θ_{pg}∼N(μ_g,σ_g²)` - and group positions `z_{pg}∼MVN_D(m_g,Σ_g)` with a fixed reference group `μ_1=0,σ_1^2=1,m_1=0,Σ_1=I_D`. - Enables comparing group **latent-space centroids** on one map (this is exactly what HLSIRM does with - schools as the grouping). -- **Group-specific item positions (interaction DIF):** let `w_i^{(g)}` differ across groups for studied - items while anchor items keep a common `w_i`; a large `γ`-weighted shift `\lVert w_i^{(g)}-w_i^{(g')}\rVert` - flags an item whose respondent–item interaction is group-dependent — the LSIRM analogue of DIF. - -**Cross-group identifiability:** the invariance (translation/rotation/reflection) must be resolved -**jointly** across groups. Either (i) anchor ≥ `D+1` common items to a fixed reference configuration and -Procrustes-map every group to it, or (ii) estimate all groups in one shared space with common item -parameters (HLSIRM route). Anchoring is what makes group centroids comparable. - ---- - -## 6. Multidimensional extension (MIRT) and its relation to LSIRM - -### 6.1 Compensatory MIRT (Reckase, 2009) `[S]` - -For a `d`-dimensional trait `θ_p∈ℝ^d`, item slope vector `a_i∈ℝ^d`, intercept `d_i`: -$$ -P(Y_{pi}=1\mid\theta_p)=c_i+(1-c_i)\,\operatorname{logit}^{-1}\!\big(a_i^\top\theta_p+d_i\big). -$$ -Summary indices: -$$ -\text{MDISC}_i=\lVert a_i\rVert=\sqrt{\textstyle\sum_{m=1}^d a_{im}^2},\qquad -\text{MDIFF}_i=\frac{-d_i}{\lVert a_i\rVert},\qquad -\text{direction cosines }=\frac{a_{im}}{\text{MDISC}_i}. -$$ -"Compensatory" because a low coordinate of `θ_p` can be offset by a high one through the inner product. -Estimated by MML/EM or MH-RM; identifiability fixed by rotation constraints (as in factor analysis). - -### 6.2 How LSIRM relates `[~]` - -- The **multiplicative** LSIRM term `z_p^⊤ w_i` is algebraically a MIRT compensatory term with item - "loadings" `= w_i` and person "traits" `= z_p` (a bilinear/eigenmodel factorization, as HLSIRM uses). - So a `D`-dimensional inner-product LSIRM ≈ a `D`-dimensional compensatory MIRT with the main effects - `θ_p,β_i` as an extra rank-one term. -- The **distance** LSIRM term `−γ\lVert z_p-w_i\rVert` is **non-compensatory / ideal-point-like**: the - probability is maximized when the respondent sits *at* the item's location and falls off symmetrically in - every direction — closer to an unfolding model than to a monotone MIRT surface. Jeon et al.'s - "Multidimensional Latent Space Item Response Models: A Note on the Relativity of Conditional Dependence" - discusses how the recovered map dimension and conditional-dependence structure are only defined **relative** - to a reference, reinforcing that `D` is a modeling choice validated by fit, not an absolute count. -- **Choosing `D`:** fit `D=1,2,3,…` and compare by BIC / WAIC / cross-validated log-likelihood (as the - package does with BIC); interpretability usually caps at `D=2`. - ---- - -## 7. Item-fit statistics - -### 7.1 Orlando & Thissen (2000) S-X² with the Lord–Wingersky recursion `[V formula/df, S recursion]` - -Group examinees by **observed summed score** `s∈{1,…,I-1}` (score-independent of `θ̂`). For item `i`: -$$ -\boxed{\;S\text{-}X^2_i=\sum_{s=1}^{I-1} N_s\,\frac{\big(O_{is}-E_{is}\big)^2}{E_{is}\,(1-E_{is})}\;}, -\qquad df = (I-1)-m_i, -$$ -where `N_s` = number of examinees with summed score `s`, `O_{is}` = observed proportion correct on item `i` -in score group `s`, `E_{is}` = model-expected proportion, and `m_i` = number of estimated parameters for -item `i` (1 for Rasch, 2 for 2PL, 3 for 3PL). Score groups `0` and `I` are excluded (trivial proportions). - -**Expected proportion `E_{is}`** (this is where the recursion enters): -$$ -E_{is}=\frac{\displaystyle\int P_i(\theta)\,S_{s-1}^{(-i)}(\theta)\,g(\theta)\,d\theta} -{\displaystyle\int S_{s}(\theta)\,g(\theta)\,d\theta}, -$$ -i.e. `E_{is}=P(\text{item }i\text{ correct}\mid \text{summed score}=s)`: numerator = P(item `i` correct **and** -total `= s`) — if item `i` is correct the *other* `I-1` items must sum to `s-1`; denominator = P(total `= s`). -Both integrals are evaluated by Gauss–Hermite quadrature over `g(θ)`. - -**Lord–Wingersky (1984) recursion** for the summed-score likelihood at fixed `θ`. Let -`f_r^{(n)}(θ)=P(\text{score}=r\text{ using items }1..n\mid θ)`: -$$ -f_0^{(1)}=1-P_1(\theta),\quad f_1^{(1)}=P_1(\theta);\qquad -f_r^{(n)}(\theta)=f_r^{(n-1)}(\theta)\big(1-P_n(\theta)\big)+f_{r-1}^{(n-1)}(\theta)\,P_n(\theta), -$$ -for `n=2,…,I` and `r=0,…,n` (with `f_r^{(n-1)}≡0` for `r<0` or `r>n-1`). Then -`S_s(θ):=f_s^{(I)}(θ)`, and `S_{s-1}^{(-i)}(θ)` is the same recursion run over the `I-1` items **excluding -item `i`**. (Compute the leave-one-out distributions by removing each item's factor.) - -**Generalization.** Kang & Chen (2008) extend S-X² to polytomous / graded response models (bins on the -total summed score, cell probabilities via a generalized Lord–Wingersky recursion). The likelihood-ratio -analogue is `S-G²_i = 2 Σ_s N_s[ O_{is} ln(O_{is}/E_{is}) + (1-O_{is}) ln((1-O_{is})/(1-E_{is})) ]`. - -### 7.2 Infit / Outfit mean squares (Wright & Masters, 1982) `[S/V heuristics]` - -Standardized residual for the (p,i) cell (`E_{pi}=P_{pi}`, variance `W_{pi}=P_{pi}(1-P_{pi})` for -dichotomous; `W_{pi}=Σ_k (k-E_{pi})^2 P_{pik}` for polytomous): -$$ -z_{pi}=\frac{y_{pi}-E_{pi}}{\sqrt{W_{pi}}}. -$$ -Per-**item** fit: -$$ -\text{Outfit}_i=\frac{1}{N}\sum_{p=1}^{N} z_{pi}^2 -=\frac1N\sum_p \frac{(y_{pi}-E_{pi})^2}{W_{pi}},\qquad -\text{Infit}_i=\frac{\sum_{p}(y_{pi}-E_{pi})^2}{\sum_{p} W_{pi}} -=\frac{\sum_p W_{pi}z_{pi}^2}{\sum_p W_{pi}}. -$$ -Per-**person** fit uses the same expressions summing over items `i` at fixed `p`. Outfit is the unweighted -mean square (sensitive to outliers on items far from a person's ability); Infit is -**information-weighted** (down-weights those extremes). Expected value ≈ 1. Optional -**Wilson–Hilferty** standardization to an approximately `N(0,1)` `t`: -$$ -t=\Big(\text{MS}^{1/3}-1\Big)\frac{3}{q}+\frac{q}{3},\qquad q^2=\widehat{\operatorname{Var}}(\text{MS}). -$$ - -### 7.3 Posterior predictive checks (Bayesian item fit) `[S]` - -Draw `y^{rep}` from the posterior predictive; discrepancy `T(y,ζ)` (e.g. item odds-ratios, item-total -correlations, χ² by score group); posterior predictive `p`-value -`ppp = P\big(T(y^{rep},ζ)\ge T(y,ζ)\mid y\big)`, estimated as the fraction of MCMC draws with -`T(y^{rep(s)},ζ^{(s)})≥T(y,ζ^{(s)})`. Values near 0 or 1 flag misfit (Sinharay, 2005). The `lsirm12pl` -`gof()` (observed vs. replicated item means; ROC/AUC) is a lightweight instance. - ---- - -## 8. Person-fit statistics - -### 8.1 `l_z` (Drasgow, Levine & Williams, 1985) `[V]` - -Standardized log-likelihood of a response pattern at (estimated) ability `θ`: -$$ -l(\theta)=\sum_{i=1}^{n}\Big\{X_i\log\frac{P_i(\theta)}{1-P_i(\theta)}+\log\big(1-P_i(\theta)\big)\Big\}, -$$ -$$ -l_z(\theta)=\frac{l(\theta)-E[l(\theta)]}{\sqrt{\operatorname{Var}[l(\theta)]}} -=\frac{\sum_{i}(X_i-P_i(\theta))\log\frac{P_i(\theta)}{1-P_i(\theta)}}{\sqrt{\operatorname{Var}[l(\theta)]}}, -$$ -with `E[l(θ)]=Σ_i[P_i\log P_i+(1-P_i)\log(1-P_i)]` and -`Var[l(θ)]=Σ_i P_i(1-P_i)\big(\log\frac{P_i}{1-P_i}\big)^2`. Under the model with **known** `θ`, `l_z ≈ N(0,1)`; -low (very negative) values flag aberrance. **Problem:** substituting `θ̂` biases the mean/variance so the -`N(0,1)` reference is wrong — corrected by `l_z^*`. - -### 8.2 Snijders (2001) `l_z^*` — asymptotically correct standardization `[V]` - -Snijders' general class of person-fit statistics: -`W(\theta)=Σ_{i=1}^{n}(X_i-P_i(\theta))\,w_i(\theta)` (for `l_z`, `w_i(θ)=\log\frac{P_i(θ)}{1-P_i(θ)}`). -Define, with `P_i'(θ)=dP_i/dθ` and Fisher information `I(θ)=Σ_i \frac{P_i'(θ)^2}{P_i(θ)(1-P_i(θ))}`: -$$ -r_i(\theta)=\frac{P_i'(\theta)}{P_i(\theta)\{1-P_i(\theta)\}},\qquad -c(\theta)=\frac{\sum_i P_i'(\theta)\,w_i(\theta)}{\sum_i P_i'(\theta)\,r_i(\theta)} -=\frac{1}{I(\theta)}\sum_i P_i'(\theta)\,w_i(\theta), -$$ -$$ -\tilde w_i(\theta)=w_i(\theta)-c(\theta)\,r_i(\theta),\qquad -\tau^2(\theta)=\frac1n\sum_i \tilde w_i^2(\theta)\,P_i(\theta)\{1-P_i(\theta)\}. -$$ -The corrected statistic (asymptotically `N(0,1)` even with estimated `θ̂`): -$$ -\boxed{\;l_z^*=\tilde Z(\hat\theta)=\frac{W(\hat\theta)+c(\hat\theta)\,r_0(\hat\theta)}{\sqrt{n}\,\tau(\hat\theta)}\;} -$$ -where the estimator-dependent term `r_0(θ̂)` is: -$$ -r_0(\hat\theta)=\begin{cases} -0, & \text{MLE},\\[2pt] -\dfrac{d\log f(\hat\theta)}{d\hat\theta}, & \text{MAP (prior } f),\\[6pt] -\dfrac{J(\hat\theta)}{2\,I(\hat\theta)}, & \text{WLE (Warm), with } J(\theta)=\sum_i \dfrac{P_i'P_i''}{P_i(1-P_i)}. -\end{cases} -$$ -Substituting `w_i=\log\frac{P_i}{1-P_i}` gives the corrected `l_z^*`. **Scope:** derived for dichotomous -items; the mean/variance correction absorbs the first-order effect of estimating `θ`. (Multidimensional / -polytomous / mixed-type extensions: Sinharay, 2016; and the "corrected version" note, arXiv:2605.00216, -which is the source of the formulas above.) - ---- - -## 9. Item selection / removal decision procedure (grounded in cited literature) - -A defensible LSIRM item-screening pipeline, combining classical fit rules with LSIRM-specific map -diagnostics. Apply after a converged fit; re-estimate after each removal round (fit indices shift). - -1. **S-X² misfit, multiplicity-controlled.** Compute `S-X²_i` (§7.1) and its `p`-value for every item. - Control the false discovery rate across items with **Benjamini–Hochberg** (1995): sort `p_{(1)}≤…≤p_{(I)}`, - reject where `p_{(i)}≤ (i/I)·q` (`q=.05`). Flag rejected items. (S-X² is preferred over `θ̂`-binned Q₁/G² - because its summed-score bins are model-independent; Orlando & Thissen, 2000.) -2. **Infit/Outfit out of range.** Flag items with mean squares outside a productive-misfit band. - Wright & Linacre (1994) "reasonable" ranges: high-stakes MCQ ≈ `[0.8, 1.2]`; a common working band is - **`[0.7, 1.3]`**; lenient `[0.5, 1.5]` (de Ayala, 2009). Values `>` upper bound (underfit) are the - serious ones — the item is noisier than the model expects (degrades measurement); values `<` lower bound - (overfit) are redundant but rarely harmful. Optionally use the standardized `t` with `|t|>2`, but note - `t` is over-powered at large `N` (Bond & Fox, 2007) — prefer the mean-square band there. -3. **Low discrimination (2PL/MIRT).** Flag items with `α_i` (or `\text{MDISC}_i=\lVert a_i\rVert`) below a - threshold (e.g. `< 0.3–0.4` on the logistic metric); such items carry little information and often - coincide with S-X² misfit. -4. **Person-fit screen before item decisions.** Remove or down-weight aberrant respondents flagged by - `l_z^* < -1.645` (one-sided 5%) *before* finalizing item removals, so item statistics are not distorted - by cheating/careless patterns (Snijders, 2001; §8.2). -5. **LSIRM-specific map diagnostics.** - - **Isolated items.** An item whose position `w_i` is far from the bulk of respondent positions `{z_p}` - (large `γ`-weighted distance `γ·\text{mean}_p \lVert z_p-w_i\rVert`, i.e. a large interaction penalty for - nearly everyone) discriminates poorly in the region where data live — a candidate for removal or - rewording. This is the LSIRM reading of an item that "no one interacts with." - - **Interaction necessity.** If, after refit, an item's removal barely changes `γ` and the map - (or a spike-and-slab prior keeps `γ≈0` for that item), the interaction term is not needed — the item is - adequately described by `θ_p+β_i` alone. - - **Interaction DIF (multigroup).** Flag items whose group-specific positions differ, - `\lVert w_i^{(g)}-w_i^{(g')}\rVert` large (§5.2). -6. **Decision.** Remove an item only when it fails **multiple** criteria (e.g. BH-significant S-X² - **and** Infit/Outfit out of band, or low `α_i` **and** map-isolated), and when removal is substantively - defensible (content coverage preserved). Document each removal and re-run item- and person-fit on the - reduced set. Prefer revision over deletion when content is essential. - ---- - -## 10. Minimal implementation checklist - -- **Likelihood kernel:** `logit⁻¹(θ_p+β_i−γ‖z_p−w_i‖)` (binary) or Gaussian mean (continuous); cache - distances `d_{pi}` and unit vectors `u_{pi}`. -- **Estimator:** MH-RM (§3.C) as the default MML engine (handles the `(1+D)`-dim per-person + `D`-dim - per-item latents without quadrature blow-up); JML (§3.B) for a warm start; Bock–Aitkin quadrature - (§3.A.1) only for the `D`-free trait margin / plain 2PL calibration. -- **Identifiability each iteration:** center `Z, W`; Procrustes-rotate `W` to a fixed reference `W₀`; - fix `γ` scale or standardize position variance. Anchor `≥ D+1` items for multigroup comparability. -- **Variance components:** update `σ², σ_ε²` (and multilevel `σ_α², τ², Ψ_z`) by random-effects EM moment - equations / conjugate draws. -- **Fit module:** S-X² via Lord–Wingersky recursion (§7.1); Infit/Outfit (§7.2); `l_z^*` (§8.2); - posterior-predictive checks if a Bayesian variant is also run. -- **Screening:** the §9 pipeline with BH-FDR on S-X² and a `[0.7,1.3]` mean-square band. - ---- - -## 11. Model-equation quick index - -| Model | Core equation | -|---|---| -| LSIRM (distance) | `logit P = θ_p + β_i − γ‖z_p−w_i‖` | -| LSIRM (multiplicative) | `logit P = θ_p + β_i + z_p^⊤w_i` | -| 2PL LSIRM | `logit P = α_i θ_p + β_i − γ‖z_p−w_i‖`, `α_1=1` | -| Continuous LSIRM | `Y_{pi} = α_i θ_p + β_i − γ‖z_p−w_i‖ + ε_{pi}`, `ε∼N(0,σ_ε²)` | -| HLSIRM (multilevel) | `logit P = α_{i(k)} + β_j + z_{i(k)}^⊤w_j + ε`, `α_{i(k)}∼N(α_{(k)},σ²_{(k)})` | -| Multigroup IRT | `θ_{pg}∼N(μ_g,σ_g²)`, anchored items, `μ_1=0,σ_1²=1` | -| MIRT (compensatory) | `logit P = a_i^⊤θ_p + d_i`, `MDISC=‖a_i‖`, `MDIFF=−d_i/‖a_i‖` | -| MMLE (persons out) | `L=∏_p ∫∫ ∏_i P_{pi}^{y}(1−P_{pi})^{1−y} φ(θ_p)φ_D(z_p)dz_p dθ_p` | -| MH-RM update | `ξ^{(t)}=ξ^{(t−1)}+ε_t H⁻¹ s^{(t)}`, `s=∇_ξ log f(y,φ^{(t)};ξ)` | -| S-X² | `Σ_s N_s (O_{is}−E_{is})²/[E_{is}(1−E_{is})]`, `df=(I−1)−m_i` | -| Lord–Wingersky | `f_r^{(n)}=f_r^{(n−1)}(1−P_n)+f_{r−1}^{(n−1)}P_n` | -| Outfit / Infit | `N⁻¹Σ_p z_{pi}²` / `Σ_p(y−E)²/Σ_p W` | -| `l_z` | `(l−E[l])/√Var[l]` | -| `l_z^*` | `[W(θ̂)+c(θ̂)r_0(θ̂)]/[√n·τ(θ̂)]` | - ---- - -## 12. Citations - -**Verified verbatim online in this compilation `[V]`:** - -- Jeon, M., Jin, I.-H., Schweinberger, M., & Baugh, S. (2021). *Mapping Unobserved Item–Respondent - Interactions: A Latent Space Item Response Model with Interaction Map.* **Psychometrika, 86**(2), 378–403. - DOI: 10.1007/s11336-021-09762-5. arXiv:2007.08719. — model, priors, MCMC, Procrustes verified. -- Go, D., Kim, G., Park, J., Park, J., Jeon, M., & Jin, I. H. (2025). *lsirm12pl: An R package for latent - space item response modeling.* **The R Journal** (contributed). arXiv:2205.06989. — 2PL/continuous - variants, priors, MH-within-Gibbs, BIC verified. Code: https://github.com/jiniuslab/lsirm12pl -- Park, J., Shin, ..., Jeon, M., Kim, ..., & Jin, I. H. (2026). *Hierarchical Latent Space Item Response - Model for Analyzing Mental Health Vulnerability of Elementary School Students in South Korea.* - arXiv:2603.13677 (DOI 10.48550/arXiv.2603.13677). — full multilevel equations verified. -- Snijders, T. A. B. (2001). *Asymptotic null distribution of person fit statistics with estimated person - parameter.* **Psychometrika, 66**(3), 331–342. DOI: 10.1007/BF02294437. — `l_z^*` correction formulas - verified via the corrected re-derivation, arXiv:2605.00216 ("Simplicity Above Elegance…", 2026). -- Orlando, M., & Thissen, D. (2000). *Likelihood-Based Item-Fit Indices for Dichotomous Item Response - Theory Models.* **Applied Psychological Measurement, 24**(1), 50–64. DOI: 10.1177/01466216000241003. — - S-X² formula and `df` verified (NCME Module 40; CRAN `CDM::itemfit.sx2`). -- Cai, L. (2010). *High-Dimensional Exploratory Item Factor Analysis by a Metropolis–Hastings Robbins–Monro - Algorithm.* **Psychometrika, 75**(1), 33–57. DOI: 10.1007/s11336-009-9136-x. (Companion: *MH-RM for - Confirmatory Item Factor Analysis*, **J. Educ. Behav. Stat., 35**(3), 307–335, - DOI: 10.3102/1076998609353115.) — algorithm description verified. - -**Standard results reproduced from memory (source cited, exact page not re-fetched) `[S]`:** - -- Bock, R. D., & Aitkin, M. (1981). *Marginal maximum likelihood estimation of item parameters: - Application of an EM algorithm.* **Psychometrika, 46**(4), 443–459. DOI: 10.1007/BF02293801. (Errata - 47, 369.) — EM/quadrature `E`-step counts `N̄_q, r̄_{iq}` and `M`-step equations are standard; - the primary PDF could not be text-extracted cleanly online (existence/description verified only). -- Lord, F. M., & Wingersky, M. S. (1984). *Comparison of IRT true-score and equipercentile observed-score - equatings.* **Applied Psychological Measurement, 8**(4), 453–461. DOI: 10.1177/014662168400800409. — - the summed-score recursion is referenced by Orlando & Thissen; exact recursion from memory. -- Wright, B. D., & Masters, G. N. (1982). *Rating Scale Analysis.* MESA Press. — infit/outfit mean squares. -- Wright, B. D., & Linacre, J. M. (1994). *Reasonable mean-square fit values.* **Rasch Measurement - Transactions, 8**(3), 370. — the `[0.5,1.5]`/`[0.7,1.3]`/`[0.8,1.2]` bands used in §9. -- Wright, B. D., & Panchapakesan, N. (1969). *A procedure for sample-free item analysis.* **Educational - and Psychological Measurement, 29**, 23–48. — origin of infit/outfit (per NCME Module 40 `[V]`). -- Drasgow, F., Levine, M. V., & Williams, E. A. (1985). *Appropriateness measurement with polychotomous - item response models and standardized indices.* **British J. Math. Stat. Psychology, 38**, 67–86. - DOI: 10.1111/j.2044-8317.1985.tb00817.x. — `l_z` (base `l_z` formula also `[V]` via the Snijders source). -- Hoff, P. D., Raftery, A. E., & Handcock, M. S. (2002). *Latent space approaches to social network - analysis.* **JASA, 97**(460), 1090–1098. DOI: 10.1198/016214502388618906. — latent-space distance model, - JML/MLE-of-positions, and the translation/rotation/reflection identifiability that LSIRM inherits. -- Fox, J.-P., & Glas, C. A. W. (2001). *Bayesian estimation of a multilevel IRT model using Gibbs - sampling.* **Psychometrika, 66**(2), 271–288. DOI: 10.1007/BF02294839. Fox, J.-P. (2010). *Bayesian Item - Response Modeling.* Springer. DOI: 10.1007/978-1-4419-0742-4. — two-level IRT structural equations. -- Bock, R. D., & Zimowski, M. F. (1997). *Multiple group IRT.* In van der Linden & Hambleton (Eds.), - *Handbook of Modern Item Response Theory* (pp. 433–448). Springer. DOI: 10.1007/978-1-4757-2691-6_25. — - multigroup means/variances, anchoring, DIF. -- Reckase, M. D. (2009). *Multidimensional Item Response Theory.* Springer. - DOI: 10.1007/978-0-387-89976-3. — compensatory MIRT, MDISC/MDIFF. -- Gower, J. C. (1975). *Generalized Procrustes analysis.* **Psychometrika, 40**(1), 33–51. - DOI: 10.1007/BF02291478. — identifiability resolution. -- Benjamini, Y., & Hochberg, Y. (1995). *Controlling the false discovery rate.* **JRSS-B, 57**(1), 289–300. - DOI: 10.1111/j.2517-6161.1995.tb02031.x. — multiplicity control in §9. -- de Ayala, R. J. (2009). *The Theory and Practice of Item Response Theory.* Guilford. — fit-flag heuristics. -- Sinharay, S. (2005). *Assessing fit of unidimensional item response theory models using a Bayesian - approach.* **J. Educ. Measurement, 42**(4), 375–394. DOI: 10.1111/j.1745-3984.2005.00021.x. — PPMC. - -**Located but details not fetched (cite for the relevant sub-topic) `[~]`:** - -- Jeon, M., et al. *Multidimensional Latent Space Item Response Models: A Note on the Relativity of - Conditional Dependence.* **Psychometrika** (Cambridge Core). — §6.2 relation of `D` to MIRT. -- Kang, I., & Jeon, M. (2024). Interaction-map summary quantities (`α̃, β̃`), cited by HLSIRM §4.1. -- Salter-Townshend, M., & Murphy, T. B. (2013). *Variational Bayesian inference for the latent position - cluster model.* **Computational Statistics & Data Analysis, 57**, 661–671. - DOI: 10.1016/j.csda.2012.08.004. — VI for latent-space models (§3.D). -- Kang, T., & Chen, T. T. (2008). *Performance of the generalized S-X² for polytomous IRT.* - **J. Educ. Measurement, 45**, 391–406. — polytomous S-X² (§7.1). - ---- - -### Verification summary -Directly verified online: LSIRM base model/priors/MCMC/Procrustes (arXiv:2007.08719); 2PL & continuous -LSIRM + priors + estimation (arXiv:2205.06989); full multilevel HLSIRM (arXiv:2603.13677); Snijders `l_z^*` -exact formulas (arXiv:2605.00216); S-X² formula + df and infit/outfit (NCME Module 40 + CRAN CDM); -MH-RM description (Cai 2010, Springer/SAGE). Reproduced from standard sources (existence confirmed, exact -symbols from memory): Bock–Aitkin EM update equations, Lord–Wingersky recursion, infit/outfit MSQ formulas, -Fox–Glas multilevel IRT, Bock–Zimowski multigroup, Reckase MIRT indices, `l_z` base. A dedicated -**multigroup-LSIRM** paper was **not** found online — §5.2 is a construction by analogy (HLSIRM + Bock– -Zimowski), flagged as such. - ---- ---- - -# Part II — Monte Carlo / Quasi-Monte Carlo EM, IRT Scoring, and Calibration Workflows - -**Scope.** Implementation-ready formulas for (i) Monte Carlo EM and quasi-Monte Carlo EM E-steps -(the practical engines for the `(1+D)`-dimensional LSIRM integral of §3.A.2), (ii) IRT scoring — -EAP, MAP, summed-score EAP with the Lord–Wingersky recursion, and group-specific / multilevel priors, -(iii) concurrent calibration, and (iv) fixed item parameter calibration (FIPC). Verification legend as -in the Part I header (`[V]` / `[S]` / `[~]`). - ---- - -## 13. Notation additions (Part II) - -| Symbol | Meaning | -|---|---| -| `x` (or `φ`) | missing/latent data (LSIRM: per-person `(θ_p, z_p)`; GLMM: random effects `u`) | -| `y` | observed data; `f(y,x;ξ)` complete-data density; `ℓ_c(ξ;y,x)=log f(y,x;ξ)` | -| `f(x\mid y;ξ)` | missing-data (conditional) distribution — the E-step target | -| `M_k` (or `m`) | Monte Carlo sample size at EM iteration `k` | -| `S_c(ξ)=∇_ξ ℓ_c` | complete-data score | -| `X_q, A_q` | quadrature nodes and weights; `L_p(X_q)` person `p` likelihood at node `q` (§3.A.1) | -| `T_i(k\mid θ)` | probability of response category `k` on item `i` (dichotomous: `T_i(1\mid θ)=P_i(θ)`) | -| `L_n(s\mid θ)` | probability of summed score `s` over the first `n` items at fixed `θ` | -| `g(θ)` | scoring prior; default `φ(θ)=N(0,1)`, group version `φ(θ;μ_g,σ_g²)` | -| `φ_b(n)` | radical-inverse function in base `b` (Halton construction) | - ---- - -## 14. Monte Carlo EM (MCEM) - -### 14.1 The MCEM E-step approximation (Wei & Tanner, 1990) `[V]` - -Replace the E-step expectation `Q(ξ\mid ξ^{(k)})=E[ℓ_c(ξ;y,X)\mid y,ξ^{(k)}]` by a (possibly weighted) -Monte Carlo average. With `X_1,…,X_M` sampled (not necessarily iid) from `f(x\mid y;ξ^{(k)})`: -$$ -\boxed{\;\hat Q(\xi\mid\xi^{(k)})=\sum_{s=1}^{M} w_s\,\ell_c(\xi;\,y,X_s)\;} -\qquad\Big(\text{iid case: } w_s=\tfrac1M\Big), -$$ -then M-step `ξ^{(k+1)}=\arg\max_ξ \hat Q(ξ\mid ξ^{(k)})`. (Verbatim as Eq. 19 of the MCEM review, -arXiv:2401.00945, which is the verification source used throughout §14.) The complete-data gradient and -Hessian are the same mixtures, so at convergence the observed information follows from **Louis (1982)**: -generate one final large sample and estimate -`I(ξ̂) = −E[∇²ℓ_c\mid y] − Var[S_c\mid y]` by its Monte Carlo averages. Wei & Tanner's own convergence -recommendation: plot `ξ^{(k)}` across iterations; when the trajectory stabilizes, either stop or increase -`M` and continue until it stabilizes again (this "increase `M` late" heuristic is the primitive form of -every scheduling rule below). - -For LSIRM (§3.A.2): `x = (θ_p, z_p)_{p=1..N}`, the per-person integrals factorize, so the E-step samples -each person's posterior independently — `N` parallel `(1+D)`-dimensional problems, never a `Q^{1+D}` grid. - -### 14.2 Sampling variants for the E-step - -**(a) Posterior sampling by MCMC (McCulloch, 1997) `[V description / S details]`.** -For GLMMs — the model class closest to LSIRM's random-effects margin — draw `u^{(1)},…,u^{(M)}` from -`f(u\mid y;ξ^{(k)})` with a Metropolis–Hastings chain (one-coordinate-at-a-time random walk), then use -equal weights in `\hat Q`. McCulloch (1997, JASA 92, 162–170) compares this MCEM with a Monte Carlo -Newton–Raphson (MCNR) and Monte Carlo maximum likelihood (MCML; Geyer, 1991): MCEM/MCNR beat MCML alone; -MCEM-then-one-MCML-step was best. His schedule (verified): fixed `M`, increased at iterations 20 and 40 — -i.e., a Wei–Tanner-style hand-tuned schedule. - -**(b) Importance sampling from a posterior approximation (Booth & Hobert, 1999) `[V use / S proposal details]`.** -Draw iid `X_s ∼ h(x)` and weight -$$ -w_s=\frac{\tilde w_s}{\sum_{s'}\tilde w_{s'}},\qquad -\tilde w_s=\frac{f(X_s\mid y;\xi^{(k)})}{h(X_s)} -\;\propto\;\frac{f(y\mid X_s;\xi^{(k)})\,g(X_s)}{h(X_s)}, -$$ -(self-normalization removes the unknown normalizing constant `f(y)`). Booth & Hobert's proposal `h` is a -multivariate Student-`t` matched to the Laplace approximation of the posterior (mode + curvature) `[S]`. -They also tried rejection sampling; importance sampling was faster with similar results `[V]`. - -**(c) Importance sampling from the prior (the "cheap" variant) `[S]`.** -Take `h(x)=g(x)` (the latent prior, e.g. `φ(θ_p)φ_D(z_p)` for LSIRM). Then `\tilde w_s = f(y\mid X_s;ξ^{(k)})` -— pure **likelihood weights**, no posterior approximation needed. Trade-off: weights degenerate as the -posterior concentrates away from the prior (long tests, extreme respondents); effective sample size -`ESS = 1/\sum_s w_s^2` should be monitored, and (b) preferred when `ESS/M` is small. This prior-sampling -variant is what plugs most directly into a QMC point set (§15.5), because prior draws are transformations -of uniforms. - -### 14.3 Automated sample-size scheduling and stopping (Booth & Hobert, 1999) `[V]` - -Frame iteration `k` as M-estimation of the **deterministic EM update** `\tilde ξ_k` (what EM would have -produced from `ξ̂_{k-1}` with an exact E-step). As `M_k→∞`, -$$ -\sqrt{M_k}\,(\hat\xi_k-\tilde\xi_k)= --\sqrt{M_k}\Big[\nabla^2 Q(\tilde\xi_k\mid\hat\xi_{k-1})\Big]^{-1} -\Big[\nabla\hat Q(\tilde\xi_k\mid\hat\xi_{k-1})\Big]+o_p(1), -$$ -so `ξ̂_k` is asymptotically normal with (estimable) sandwich covariance -$$ -\widehat{\operatorname{Var}}(\hat\xi_k)\approx\frac{1}{M_k} -\Big[\nabla^2\hat Q(\hat\xi_k\mid\hat\xi_{k-1})\Big]^{-1} -\hat E\big[S_c(\hat\xi_k)S_c(\hat\xi_k)^{\!\top}\mid y\big] -\Big[\nabla^2\hat Q(\hat\xi_k\mid\hat\xi_{k-1})\Big]^{-1} -$$ -(no centering term: `ξ̂_k` maximizes `\hat Q`). Rules: - -- **Sample-size increase.** Build a `100(1-α)%` confidence ellipsoid (or componentwise intervals) for - `\tilde ξ_k`. If it **contains** `ξ̂_{k-1}` — the step is indistinguishable from Monte Carlo noise — set - $$ - \boxed{\;M_{k+1}=M_k+\Big\lfloor \tfrac{M_k}{r}\Big\rfloor = M_k\big(1+\tfrac1r\big),\qquad r\in\{3,4,5\}\;} - $$ - and proceed to the next iteration (Booth–Hobert increase *between* iterations; contrast §14.4). - Start with a **small** `M_1` (tens). -- **Stopping (convergence assessment = MC error vs EM increment).** Terminate when the relative parameter - change is small for **three consecutive iterations**: - $$ - \max_j\left|\frac{\hat\xi_{k,j}-\hat\xi_{k-1,j}}{\hat\xi_{k-1,j}+\delta_1}\right|<\delta_2, - \qquad \delta_1=10^{-3},\ \ \delta_2\in(2\times10^{-3},\,5\times10^{-3}). - $$ - Alternative (variance components near a boundary): replace the denominator by - `SE(ξ̂_{k,j})+δ_1'` with tolerance `δ_2'`. - -### 14.4 Ascent-based MCEM (Caffo, Jank & Jones, 2005) `[V]` - -Quantify MC uncertainty in the **objective increment** rather than the parameter. With -`ΔQ̂ = \hat Q(ξ̂_k\mid ξ̂_{k-1}) − \hat Q(ξ̂_{k-1}\mid ξ̂_{k-1})`, -`\sqrt{M_k}(ΔQ̂−ΔQ) ⇝ N(0,Σ_k)`. Rules: (i) if the **lower** `(1-α)` confidence bound for `ΔQ` is not -positive, augment the sample **at the current iteration** (add `M_k/r` points) and re-test — this -stochastically preserves the EM ascent property; (ii) terminate when the **upper** confidence bound for -`ΔQ` falls below a tolerance `τ` (e.g. `10^{-3}`); (iii) start the next iteration with at least the final -`M_k`. Under importance sampling, `Σ_k` needs a Delta-method estimate (self-normalized weights). -Empirically (verified): slightly worse than Booth–Hobert per unit compute for point estimates, better for -the information matrix, and most of the compute lands in the final iteration — whose sample is then reused -for Louis standard errors. - -### 14.5 MCEM for item factor analysis (Meng & Schilling, 1996) `[~]` - -For the full-information item factor model (multidimensional normal-ogive — the compensatory cousin of -the inner-product LSIRM, §6.2), Meng & Schilling implement the MC E-step with a **Gibbs sampler**: augment -with the underlying continuous responses and factor scores, alternate truncated-normal draws of the -augmented responses and multivariate-normal draws of factor scores, and average complete-data sufficient -statistics over the chain. Two MCEM implementations are given; both recover high-dimensional loadings -where fixed-point Gauss–Hermite quadrature (Bock–Aitkin) degrades — the historical proof-of-concept that -sampling-based E-steps break the quadrature curse for item-level models. Bridge sampling is used to -compute observed-data likelihood ratios for monitoring. (Existence, venue, and method description verified; -sampler equations from memory.) JASA 91(435), 1254–1267. - -### 14.6 Which rule to use (synthesis) `[V — review's comparisons]` - -- Default: **Booth–Hobert** (§14.3) with importance sampling; simplest automated rule, fast convergence. -- If ascent guarantees / information estimates matter: **Caffo et al.** (§14.4). -- If the posterior is only reachable by MCMC (LSIRM with `D≥2`: MH on `(θ_p,z_p)`): McCulloch-style MCEM, - but then the iid-based variance formulas of §14.3–14.4 need batch-means/replicate corrections — or - switch to MH-RM (§3.C), which was designed for exactly this and needs no growing `M_k`. - ---- - -## 15. Quasi-Monte Carlo EM (QMC-EM) - -### 15.1 Error rates: why QMC `[S]` - -Plain MC has probabilistic root-`M` error: `|\hat I_M − I| = O_p(M^{-1/2})`. QMC replaces random draws by a -deterministic **low-discrepancy** point set `{x_1,…,x_M}⊂[0,1)^K` and obeys the **Koksma–Hlawka** bound -$$ -\Big|\frac1M\sum_{s=1}^M f(x_s)-\int_{[0,1)^K}\!f(u)\,du\Big| -\;\le\; V_{HK}(f)\; D_M^{*}, -$$ -with `V_{HK}` the Hardy–Krause variation and `D_M^*` the star discrepancy. Halton and Sobol' sequences -achieve -$$ -D_M^{*}=O\!\big(M^{-1}(\log M)^{K}\big) -\quad\Rightarrow\quad -\text{error } O\!\big(M^{-1}(\log M)^{K}\big)\ \text{vs. MC } O(M^{-1/2}), -$$ -i.e. nearly rate-1 for the small `K` relevant here (`K = 1+D` per person for LSIRM). (Niederreiter, 1992; -Caflisch, 1998.) Owen-scrambled nets attain `O(M^{-3/2}(\log M)^{(K-1)/2})` RMS error for smooth `f` `[S]`. - -### 15.2 Halton construction (radical inverse) `[V]` - -Write `n` in base `b`: `n=\sum_{j\ge0} a_j(n)\,b^{\,j}`, digits `a_j∈{0,…,b-1}`. The **radical inverse** -mirrors the digits about the radix point: -$$ -\boxed{\;\phi_b(n)=\sum_{j\ge0} a_j(n)\,b^{-(j+1)}\;}\in[0,1). -$$ -The `K`-dimensional **Halton point** uses the first `K` primes `b_1=2,b_2=3,b_3=5,…` (pairwise coprime -bases are what guarantee low discrepancy): -$$ -x_n=\big(\phi_{b_1}(n),\,\phi_{b_2}(n),\,\dots,\,\phi_{b_K}(n)\big),\qquad n=1,2,\dots,M. -$$ -Example (verified): `n=6=110_2 → φ_2(6)=0.011_2=3/8`. For `K ≳ 10` use Sobol' or leaped/scrambled Halton -instead — plain Halton's high-base coordinates correlate badly `[S]`. - -### 15.3 Randomized QMC (RQMC): getting an error estimate back `[V rationale / S formulas]` - -Deterministic QMC has no internal error estimate — fatal for the automated rules of §14.3–14.4, which is -precisely the problem Jank (2005) solves `[V]`: randomize the point set, run `R` independent -randomizations, and use the between-replicate variance. - -- **Random shift (Cranley–Patterson) `[S]`:** draw one `U∼\text{Unif}[0,1)^K`, set - $$ - \tilde x_n=(x_n+U)\bmod 1\ \ (\text{componentwise}),\qquad n=1,\dots,M. - $$ - Each `\tilde x_n` is marginally uniform ⇒ the RQMC estimator is **unbiased**; the point set keeps its - low discrepancy. -- **Random-start Halton / digit scrambling (Owen) `[S]`:** randomize the starting index or apply random - permutations to the digits `a_j(n)` per base; scrambling additionally buys the `M^{-3/2}` rate for - smooth integrands. -- **Error estimate `[S]`:** with `R` independent randomizations (`R` small, 5–25) yielding estimates - `\hat I^{(1)},…,\hat I^{(R)}`, - $$ - \hat I_{RQMC}=\frac1R\sum_r \hat I^{(r)},\qquad - \widehat{\operatorname{Var}}(\hat I_{RQMC})=\frac{1}{R(R-1)}\sum_{r}\big(\hat I^{(r)}-\hat I_{RQMC}\big)^2 . - $$ - This variance plugs directly into the Booth–Hobert ellipsoid / Caffo bounds, replacing the iid formulas. - -### 15.4 Uniform → Gaussian: inverse-normal transform - -QMC points must pass through `Φ^{-1}` **coordinatewise** (never Box–Muller, which scrambles the -low-discrepancy structure `[S]`): `z_n=Φ^{-1}(\tilde x_n)`, then map to the sampling density, e.g. prior -draws `θ = μ + σ z` or `(θ_p,z_p) = m_{Lap} + C_{Lap}^{1/2} z` for the Laplace-matched proposal of §14.2(b). - -**Acklam's algorithm for `Φ^{-1}(p)` `[V — all coefficients verified]`.** Max relative error -`1.15×10^{-9}`. Break-points `p_{low}=0.02425`, `p_{high}=1-p_{low}`. - -- Central region `p∈[p_{low},p_{high}]`: with `q=p-\tfrac12`, `r=q^2`, - $$ - \Phi^{-1}(p)\approx\frac{(((((a_1r+a_2)r+a_3)r+a_4)r+a_5)r+a_6)\,q}{((((b_1r+b_2)r+b_3)r+b_4)r+b_5)r+1}. - $$ -- Lower tail `0p_{high}`: same with `q=\sqrt{-2\ln(1-p)}` and overall sign flipped. - -| | 1 | 2 | 3 | 4 | 5 | 6 | -|---|---|---|---|---|---|---| -| `a` | −3.969683028665376e+01 | 2.209460984245205e+02 | −2.759285104469687e+02 | 1.383577518672690e+02 | −3.066479806614716e+01 | 2.506628277459239e+00 | -| `b` | −5.447609879822406e+01 | 1.615858368580409e+02 | −1.556989798598866e+02 | 6.680131188771972e+01 | −1.328068155288572e+01 | — | -| `c` | −7.784894002430293e−03 | −3.223964580411365e−01 | −2.400758277161838e+00 | −2.549732539343734e+00 | 4.374664141464968e+00 | 2.938163982698783e+00 | -| `d` | 7.784695709041462e−03 | 3.224671290700398e−01 | 2.445134137142996e+00 | 3.754408661907416e+00 | — | — | - -Optional full-double-precision polish (one Halley step) `[S]`: -`e=Φ(x)-p`, `u=e\sqrt{2\pi}\,e^{x^2/2}`, `x \leftarrow x-u/(1+xu/2)`. - -**Beasley–Springer–Moro (BSM) `[S]`:** the alternative used throughout computational finance — -Beasley–Springer rational approximation on the center, Moro's Chebyshev-in-`\log(-\log)` tails; the -standard coefficient tables live in Glasserman (2004, *Monte Carlo Methods in Financial Engineering*, -§2.3.2) and Moro (1995, *Risk* 8(2)). Accuracy ≈ `3×10^{-9}` absolute; Acklam is the simpler drop-in. - -### 15.5 The QMC-EM recipe (Jank, 2005) `[V design / S assembled steps]` - -Jank (2005, CSDA 48, 685–701): take the automated MCEM of §14.3 and swap the iid uniforms for RQMC. -Verified findings: RQMC-EM is "much more efficient than ordinary Monte Carlo … with fixed computational -effort, even after dividing this computational budget among multiple independent runs … to facilitate -variance estimation." - -Per EM iteration `k`, for each person `p` (LSIRM: `K=1+D`): - -1. Generate the Halton (or Sobol') points `x_1,…,x_{M_k}∈[0,1)^K` (§15.2) — **reuse the same base set - across iterations**; only the randomization changes. -2. Randomize `R` times: shifts `U^{(1)},…,U^{(R)}` → `\tilde x_n^{(r)}` (§15.3). -3. Transform: `z_n^{(r)}=Φ^{-1}(\tilde x_n^{(r)})` (§15.4), map to draws of `(θ_p,z_p)` from the proposal - `h` (prior, §14.2(c), or Laplace-matched, §14.2(b)). -4. Importance weights `w` as in §14.2; form `\hat Q^{(r)}`, average to `\hat Q`, and estimate the MC error - from the spread of the `R` replicates (§15.3). -5. Apply the Booth–Hobert ellipsoid rule with the RQMC variance: grow `M_k` by `(1+1/r)` when the update - drowns in MC error; stop on the three-consecutive relative-change rule (§14.3). Because the RQMC error - decays ≈`M^{-1}` instead of `M^{-1/2}`, the schedule reaches the same tolerance with far smaller `M`. - -**QMC inside IRT/GLMM likelihoods `[V existence]`:** Pan & Thompson (2007, CSDA 51, 5765–5775) use -randomized QMC point sets to approximate the GLMM marginal likelihood directly (the same integral as an -IRT random-effects margin) and report efficiency gains over GHQ/MC; González et al.'s work on QMC for IRT -connects the same grid idea to latent-trait models. No LSIRM-specific QMC paper was found — §15.5 is the -assembly, flagged as such. - -### 15.6 Cross-reference: MH-RM vs (Q)MC-EM `[S synthesis]` - -MH-RM (§3.C; Cai, 2010) attacks the same integral by **averaging over iterations** (Robbins–Monro gains -`ε_t=1/t`, `Σε_t=∞`, `Σε_t²<∞`) with `M≈1` draw per iteration; (Q)MC-EM attacks it by making each -iteration's integral accurate. Rule of thumb: MH-RM when only MCMC sampling is available and the parameter -count is large (full LSIRM); QMC-EM when iid/importance sampling from a good proposal is possible and -high-precision EM steps (e.g. for FIPC's few free parameters, §18) are wanted. - ---- - -## 16. IRT scoring — exact estimators - -Throughout: response pattern `y=(y_1,…,y_n)`, pattern likelihood -`L(y\mid θ)=∏_i T_i(y_i\mid θ)`, scoring prior `g(θ)` (items fixed at calibrated values). - -### 16.1 EAP (Bock & Mislevy, 1982) `[~ — description verified; formulas standard]` - -Posterior mean and SD by quadrature (`Q` equally-spaced or Gauss–Hermite points; Bock–Mislevy: evaluation -is non-iterative, likelihoods accumulate by summing log terms item by item): -$$ -\hat\theta^{EAP}=\frac{\sum_{q=1}^{Q}X_q\,L(y\mid X_q)\,A_q}{\sum_{q=1}^{Q}L(y\mid X_q)\,A_q}, -\qquad -PSD=\sqrt{\frac{\sum_{q}(X_q-\hat\theta^{EAP})^2\,L(y\mid X_q)\,A_q}{\sum_{q}L(y\mid X_q)\,A_q}} . -$$ -`A_q` = prior weights (`g(X_q)` normalized, or GH weights). PSD is used interchangeably with the SE -(verified claim of the paper). Exists for every pattern (incl. all-0/all-1); shrinks toward the prior mean. - -### 16.2 MAP (Bayes modal) `[S]` - -Maximize `\ell_{post}(θ)=\log L(y\mid θ)+\log g(θ)` by Newton–Raphson: -$$ -\theta^{(t+1)}=\theta^{(t)}-\frac{\ell_{post}'(\theta^{(t)})}{\ell_{post}''(\theta^{(t)})}, -\qquad -SE(\hat\theta^{MAP})=\Big[-\ell_{post}''(\hat\theta^{MAP})\Big]^{-1/2}. -$$ -For the logistic 2PL with `g=N(μ,σ²)`: -`\ell_{post}'(θ)=\sum_i a_i\,(y_i-P_i(θ))-(θ-μ)/σ²` and (exactly, since `∂P_i/∂θ = a_iP_iQ_i`) -`\ell_{post}''(θ)=-\sum_i a_i^2P_i(θ)Q_i(θ)-1/σ²`, so -$$ -SE(\hat\theta^{MAP})=\Big[\textstyle\sum_i a_i^2P_iQ_i+\sigma^{-2}\Big]^{-1/2} -=\big[I(\hat\theta)+\sigma^{-2}\big]^{-1/2}. -$$ -(For 3PL/polytomous the observed Hessian depends on `y`; use the observed one, not `I(θ)`.) Multidimensional: -same Newton step with gradient/Hessian vectors; `SE` from the negative inverse Hessian's diagonal. - -### 16.3 Summed-score EAP — "EAPsum" (Thissen, Pommerich, Billeaud & Williams, 1995; Cai, 2015) `[V]` - -**Lord & Wingersky (1984) recursion — exact statement `[V, verbatim from Cai 2015, Eq. 8]`.** -Let `L_n(s\mid θ)` be the summed-score likelihood over items `1..n`. Initialize -`L_1(0\mid θ)=T_1(0\mid θ)`, `L_1(1\mid θ)=T_1(1\mid θ)`. For `i=2,…,n`: -$$ -\boxed{\; -\begin{aligned} -L_i(0\mid\theta)&=L_{i-1}(0\mid\theta)\,T_i(0\mid\theta),\\ -L_i(s\mid\theta)&=L_{i-1}(s\mid\theta)\,T_i(0\mid\theta)+L_{i-1}(s-1\mid\theta)\,T_i(1\mid\theta), -\quad s=1,\dots,i-1,\\ -L_i(i\mid\theta)&=L_{i-1}(i-1\mid\theta)\,T_i(1\mid\theta). -\end{aligned}\;} -$$ -(Identical to Part I §7.1's `f_r^{(n)}`; now verified verbatim.) **Polytomous generalization** -(Thissen et al., 1995) `[S statement / V existence]`: item `i` with categories `k=0,…,m_i` scored `k`: -$$ -L_i(s\mid\theta)=\sum_{k=0}^{m_i} T_i(k\mid\theta)\,L_{i-1}(s-k\mid\theta), -$$ -zero terms for `s-k` out of range; total cost `O\big(n\cdot S_{max}\cdot\max m_i\big)` per `θ` node. - -**Summed-score posterior and EAP `[V, Cai 2015 Eqs. 4–7]`.** Write `L(s\mid θ)=L_n(s\mid θ)`: -$$ -p(s)=\int L(s\mid\theta)\,g(\theta)\,d\theta,\qquad -p(\theta\mid s)=\frac{L(s\mid\theta)\,g(\theta)}{p(s)}, -$$ -$$ -\boxed{\; -EAP(s)=E(\theta\mid s)=\frac{1}{p(s)}\int\theta\,L(s\mid\theta)\,g(\theta)\,d\theta,\qquad -SD(s)=\sqrt{\frac{1}{p(s)}\int\theta^2L(s\mid\theta)\,g(\theta)\,d\theta-\big[EAP(s)\big]^2}\;} -$$ -all integrals by the same quadrature as §16.1 (`∫ → Σ_q`, `g(θ)dθ → A_q`). - -**Score-conversion-table serving pattern `[V — Cai 2015; mirt::fscores(method="EAPsum")]`.** -Because `EAP(s)` depends only on `s`, precompute once per (form × prior) the table -`{s ↦ (EAP(s), SD(s), p(s))}` for `s=0,…,S_{max}`; scoring is then an `O(1)` lookup — no per-respondent -IRT computation, the standard operational pattern for reported scale scores. `Σ_s p(s)=1` is a free -self-check of the recursion. In `mirt`, `fscores(method="EAPsum", full.scores=FALSE)` returns exactly this -table; custom priors enter via `mean`/`cov` (or `custom_den`) — the hook for §16.4. Missing data: a table -presumes a fixed item set; respondents with omits need pattern-EAP (§16.1) or a table for their sub-form. - -### 16.4 Group-specific and multilevel priors in scoring `[S]` - -**Multiple group (Bock & Zimowski, 1997 margin, §5.1).** Replace `g(θ)` by the examinee's group density -`φ(θ;μ_g,σ_g²)` everywhere in §16.1–16.3: -$$ -\hat\theta^{EAP}_{p}= \frac{\sum_q X_q\,L(y_p\mid X_q)\,\phi(X_q;\mu_g,\sigma_g^2)} -{\sum_q L(y_p\mid X_q)\,\phi(X_q;\mu_g,\sigma_g^2)}, -$$ -and one EAPsum table **per group** (same `L(s\mid θ)`, different prior — recompute only the weights). -This is the correct Bayes score when group membership is known and `(μ_g,σ_g)` were estimated in -calibration; ignoring it biases scores of off-reference groups toward the reference mean. - -**Multilevel (random intercept; §4.2 model).** `θ_{pc}=μ+u_c+e_{pc}`, `u_c∼N(0,σ_u²)`, `e_{pc}∼N(0,σ_e²)`, -cluster `c=c(p)`: -- **Cluster unknown / marginal scoring:** integrate `u_c` out ⇒ prior `θ_{pc}∼N(μ,\ σ_u²+σ_e²)`; - with the usual `σ_e²=1` normalization this is the `N(μ,\,1+σ_u²)` prior — wider, so less shrinkage. -- **Cluster effect known / conditional scoring:** given `û_c` (posterior mean of the cluster effect from - the calibration run), use `θ_{pc}∼N(μ+û_c,\ σ_e²)` — a shifted, **narrower** prior that borrows strength - from clustermates (school-conditioned EAP). The choice is consequential: conditional scoring shrinks a - student toward *their school's* mean, marginal scoring toward the *grand* mean; report which is used. -- LSIRM/HLSIRM analogue: score `(θ_p,z_p)` jointly with prior `N(α_{(k)},σ²_{(k)})×MVN(z_{(k)},Ψ_z)` - (conditional) or with the school-latents integrated out (marginal), via the same MC/QMC machinery of - §14–15 since the posterior is `(1+D)`-dimensional. - ---- - -## 17. Concurrent calibration (multiple forms, common-item design) - -### 17.1 Definition `[S formulation / V descriptions]` - -Groups/forms `g=1,…,G` share **anchor** (common) items; each form also has unique items. Stack all data in -one response matrix with **structural missingness** — item `i` not presented to person `p` contributes -nothing (not-presented ≠ wrong). One MML run estimates all item parameters **and** the group densities -jointly on a single scale: -$$ -L\big(\{a_i,b_i,c_i\}_{i=1}^{I},\{\mu_g,\sigma_g\}_{g=2}^{G}\big) -=\prod_{g=1}^{G}\prod_{p\in g}\ \int \prod_{i\in \mathcal I_p} T_i(y_{pi}\mid\theta)\; -\phi(\theta;\mu_g,\sigma_g^2)\,d\theta, -$$ -`\mathcal I_p` = items actually presented to `p`; identification `μ_1=0, σ_1=1` (reference group), all -other `(μ_g,σ_g)` **freed** — fixing them at `(0,1)` would misestimate anchors when populations differ. -Anchor items appear in `\mathcal I_p` for several groups; that overlap is the only thing tying the scale. -E-step = §3.A.1 with group-specific weights `A_q^{(g)}` from `φ(θ;μ_g,σ_g²)` (Part I §5.1 likelihood); -M-step pools expected counts `\bar r_{iq}, \bar N_q` **across groups** for anchors; group updates by -posterior moments (same equations as §18.3). This is `mirt::multipleGroup` with anchor equality -constraints + freed group means/variances, or BILOG-MG/IRTPRO multigroup runs. - -### 17.2 Evidence: concurrent vs separate + linking `[V]` - -- **Hanson & Béguin (2002, APM 26, 3–24):** simulation, common-item nonequivalent groups, 2PL/3PL. - Concurrent calibration **generally produced lower error** in anchor-parameter recovery than separate - calibration followed by Stocking–Lord/Haebara/moment linking — because anchors are estimated from - **both** groups' responses at once. Caveat retained from their discussion: the advantage assumes the IRT - model fits; separate calibration is more robust to (and more diagnostic of) multidimensionality and - parameter drift, since linking can be checked item by item. -- **Kim & Cohen (1998, APM 22, 131–143):** with **few** common items, separate estimation with - characteristic-curve (Stocking–Lord) linking gave **smaller** RMSD for `a` and `b` than concurrent; - with **larger** anchor sets the methods were similar. -- Working rule: prefer concurrent when the anchor set is healthy (≳15–20 items or ≳20% of the form), - model fit is acceptable, and drift screening (Part I §5.1 DIF logic on anchors) is done first; fall back - to separate + Stocking–Lord with a thin or suspect anchor. - ---- - -## 18. Fixed Item Parameter Calibration (FIPC) - -### 18.1 Setup `[V]` - -New-form data only. Partition items: **fixed** set `F` (anchors, parameters frozen at their old-scale -values — this *is* the linking; no transformation is computed) and **free** set `E` (new items). Estimate -(i) new-item parameters and (ii) the new population density `g_{new}(θ)` — at minimum `(μ_{new},σ_{new})` -— by MML on the fixed items' scale. Kang & Petersen (2012, APER 13, 311–321) `[V]`: FIPC is the third -standard linking route beside concurrent and separate+linking, and its adequacy **hinges on implementation** -— BILOG-MG's default never updates the prior (an NWU method), PARSCALE updates it repeatedly (MWU). - -### 18.2 The five Kim (2006) variants `[V — variant definitions and findings]` - -Two design axes: how often the **prior weights** (latent density estimate) are updated, and how many -**EM cycles** run. - -| Variant | Prior-weight updates | EM cycles | Mechanics | -|---|---|---|---| -| NWU-OEM | never (prior stays `N(0,1)`/initial) | 1 | one E-step **using only the fixed items**, one M-step for new items (Wainer–Mislevy OEM logic) | -| NWU-MEM | never | many | E-steps use **all** items; M-steps update new items only; prior frozen | -| OWU-OEM | once | 1 | first E-step (fixed items) re-estimates the prior weights; second E-step + single M-step for new items | -| OWU-MEM | once | many | as OWU-OEM, then full EM cycles with the once-updated, then-frozen prior | -| MWU-MEM | **every cycle** | many | full EM; prior weights re-estimated from the posterior at each cycle | - -Verified findings: only **MWU-MEM** recovered item parameters and the ability scale properly under all -tested new-population densities (`N(0,1)`, `N(0.5,1.2²)`, `N(1,1.4²)`); the other four under-estimated -(some severely) once the new population departed from `N(0,1)`. NWU-MEM/OWU-MEM were adequate only at -`N(0,1)`. ⇒ **Implement MWU-MEM.** `mirt::fixedCalib(method = "MWU-MEM")` implements all five with an -**empirical-histogram** density update `[V]`; Kim (2020, JEM 57, 10.1111/jedm.12230) extends two variants -to the bifactor model `[V existence]`. An "aFIPC" variant was **not** found online (see verification -summary). - -### 18.3 Exact MWU-MEM recipe in a Bock–Aitkin EM `[S update equations / V architecture]` - -Quadrature nodes `X_q` fixed on the **old scale**. Prior weights `A_q^{(0)}` initialized from `N(0,1)` -(or the old calibration's density). Cycle `t`: - -1. **E-step (all items).** Posterior node weights per person, using fixed values for `i∈F` and current - estimates for `i∈E`: - $$ - P^{(t)}(X_q\mid y_p)=\frac{L_p(X_q)\,A_q^{(t)}}{\sum_{q'}L_p(X_{q'})\,A_{q'}^{(t)}},\qquad - \bar N_q=\sum_p P^{(t)}(X_q\mid y_p),\quad \bar r_{iq}=\sum_p y_{pi}\,P^{(t)}(X_q\mid y_p). - $$ -2. **M-step (free items only).** Solve the §3.A.1 weighted-binomial likelihood equations for `i∈E`; - **skip every `i∈F`** (their gradient contributions are simply never applied). -3. **Prior update (the "WU").** Empirical-histogram update, optionally summarized by moments: - $$ - A_q^{(t+1)}=\frac{\bar N_q}{N},\qquad - \hat\mu^{(t+1)}=\sum_q X_q\,A_q^{(t+1)},\qquad - \hat\sigma^{2\,(t+1)}=\sum_q\big(X_q-\hat\mu^{(t+1)}\big)^2 A_q^{(t+1)}. - $$ - Keep the discrete `A_q` (empirical histogram, Mislevy 1984; what `mirt::fixedCalib` does `[V]`) or refit - `A_q^{(t+1)} ∝ φ(X_q;\hatμ,\hatσ²)` (normal-constrained update). **Do not** restandardize to `N(0,1)` — - the whole point is that `(μ,σ)` drift to the new population while `F` pins the scale. -4. Iterate 1–3 to joint convergence of new-item parameters and `(μ̂,σ̂)` (or `{A_q}`). The other four - variants are obtained by freezing step 3 always (NWU), after one execution (OWU), and/or truncating to - one cycle (OEM). - -Report `(μ̂_{new},σ̂_{new})` — it is the population-drift estimate — and screen `F` for drift beforehand -(misfitting anchors corrupt the scale exactly as in §17.2). - ---- - -## 19. Algorithm quick-reference (Part II) - -| Task | Recipe | -|---|---| -| MCEM E-step | `\hat Q=\sum_s w_s\,\ell_c(\xi;y,X_s)`; `w_s∝f(y\mid X_s)g(X_s)/h(X_s)` self-normalized | -| MC size rule (B–H) | CI for EM update ∋ previous estimate ⇒ `M←M(1+1/r)`, `r∈{3,4,5}` | -| MCEM stop (B–H) | `max_j\|Δξ_j\|/(\|ξ_j\|+10^{-3})<(2\text{–}5)\times10^{-3}` × 3 consecutive | -| Ascent rule (Caffo) | grow `M` until lower CB(`ΔQ`) > 0; stop when upper CB(`ΔQ`) < `τ` | -| Halton point | `x_n=(φ_2(n),φ_3(n),φ_5(n),…)`, `φ_b(n)=Σ a_j b^{-(j+1)}` | -| RQMC | `\tilde x_n=(x_n+U)\bmod 1`; `R` shifts ⇒ between-replicate variance | -| Uniform→normal | Acklam `Φ^{-1}` (coeffs §15.4), never Box–Muller with QMC | -| EAP | `Σ_qX_qL(y\mid X_q)A_q/Σ_qL(y\mid X_q)A_q`; PSD analog | -| MAP | Newton on `\log L+\log g`; `SE=[-\ell_{post}'']^{-1/2}` | -| EAPsum | LW recursion → `L(s\midθ)`; `EAP(s)=∫θL(s\midθ)g/∫L(s\midθ)g`; serve as `s→(EAP,SD)` table | -| Multigroup score | swap `g(θ)→φ(θ;μ_g,σ_g²)`; one conversion table per group | -| Multilevel score | marginal prior `N(μ,1+σ_u²)` vs conditional `N(μ+\hat u_c,σ_e²)` | -| Concurrent cal. | one MML run, structural missingness, anchors shared, `μ_1=0,σ_1=1`, other `(μ_g,σ_g)` free | -| FIPC (MWU-MEM) | anchors frozen in M-step; new items free; `A_q←\bar N_q/N` (⇒ `μ̂,σ̂²`) every cycle | - ---- - -## 20. Part II citations - -**Verified online in this compilation `[V]`:** - -- Wei, G. C. G., & Tanner, M. A. (1990). *A Monte Carlo implementation of the EM algorithm and the poor - man's data augmentation algorithms.* **JASA, 85**(411), 699–704. DOI: 10.1080/01621459.1990.10474930. - — `\hat Q` mixture E-step; convergence-by-plot + increase-`M` heuristic (verified via arXiv:2401.00945). -- Booth, J. G., & Hobert, J. P. (1999). *Maximizing generalized linear mixed model likelihoods with an - automated Monte Carlo EM algorithm.* **JRSS-B, 61**(1), 265–285. DOI: 10.1111/1467-9868.00176. — - M-estimation CI, `M(1+1/r)` rule, both stopping rules with `δ` values (verified via arXiv:2401.00945). -- Caffo, B. S., Jank, W., & Jones, G. L. (2005). *Ascent-based Monte Carlo expectation–maximization.* - **JRSS-B, 67**(2), 235–251. DOI: 10.1111/j.1467-9868.2005.00499.x. — ascent rules (verified via review). -- McCulloch, C. E. (1997). *Maximum likelihood algorithms for generalized linear mixed models.* - **JASA, 92**(437), 162–170. DOI: 10.1080/01621459.1997.10473613. — MCEM/MCNR/MCML comparison. -- Meng, X.-L., & Schilling, S. (1996). *Fitting full-information item factor models and an empirical - investigation of bridge sampling.* **JASA, 91**(435), 1254–1267. DOI: 10.1080/01621459.1996.10476995. - — Gibbs-based MC E-step for item factor analysis (method description verified; sampler details `[S]`). -- Jank, W. (2005). *Quasi-Monte Carlo sampling to improve the efficiency of Monte Carlo EM.* - **Computational Statistics & Data Analysis, 48**(4), 685–701. DOI: 10.1016/j.csda.2004.03.019. - (Online 2004.) — RQMC-in-MCEM design and efficiency finding verified; assembled recipe steps `[S]`. -- Pan, J., & Thompson, R. (2007). *Quasi-Monte Carlo estimation in generalized linear mixed models.* - **Computational Statistics & Data Analysis, 51**(12), 5765–5775. DOI: 10.1016/j.csda.2006.10.003. -- Acklam, P. J. (2002). *An algorithm for computing the inverse normal cumulative distribution function.* - (Web algorithm; coefficients verified via stackedboxes.org mirror.) Max rel. error `1.15×10^{-9}`. -- Halton radical-inverse construction — verified via the standard reference description (Wikipedia, - "Halton sequence"), incl. the `φ_2(6)=3/8` worked example. -- Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability in a microcomputer - environment. *Applied Psychological Measurement, 6*(4), 431–444. - https://doi.org/10.1177/014662168200600405 - — existence/description verified (posterior mean & PSD by quadrature, non-iterative); formulas `[S]` → `[~]`. -- Thissen, D., Pommerich, M., Billeaud, K., & Williams, V. S. L. (1995). *Item response theory for scores - on tests including polytomous items with ordered responses.* **Applied Psychological Measurement, - 19**(1), 39–49. DOI: 10.1177/014662169501900105. — summed-score EAP scope verified; also the reference - cited by `mirt::fscores(method="EAPsum")` `[V]`. -- Cai, L. (2015). *Lord–Wingersky algorithm version 2.0 for hierarchical item factor models with - applications in test scoring, scale alignment, and model fit testing.* **Psychometrika, 80**(2), - 535–559. DOI: 10.1007/s11336-014-9411-3. — LW recursion, `p(s)`, `p(θ|s)`, `E(θ|s)`, `V(θ|s)`, and - conversion-table use verified **verbatim** (PMC4366368). (Version 2.5: Huang & Cai, 2021, - **Psychometrika, 86**, DOI: 10.1007/s11336-021-09785-y.) -- Kim, S., & Cohen, A. S. (1998). *A comparison of linking and concurrent calibration under item response - theory.* **Applied Psychological Measurement, 22**(2), 131–143. DOI: 10.1177/01466216980222003. — - few-anchor result verified. -- Hanson, B. A., & Béguin, A. A. (2002). *Obtaining a common scale for item response theory item - parameters using separate versus concurrent estimation in the common-item equating design.* - **Applied Psychological Measurement, 26**(1), 3–24. DOI: 10.1177/0146621602026001001. — concurrent- - lower-error finding verified. -- Kim, S. (2006). *A comparative study of IRT fixed parameter calibration methods.* **Journal of - Educational Measurement, 43**(4), 355–381. DOI: 10.1111/j.1745-3984.2006.00021.x. — five variants and - MWU-MEM superiority verified; also via `mirt::fixedCalib` docs `[V]`. -- Kang, T., & Petersen, N. S. (2012). *Linking item parameters to a base scale.* **Asia Pacific Education - Review, 13**, 311–321. DOI: 10.1007/s12564-011-9197-2. — FIPC-vs-concurrent-vs-separate framing and the - BILOG-MG(NWU)/PARSCALE(MWU) implementation note verified. -- Kim, S. (2020). *Two IRT fixed parameter calibration methods for the bifactor model.* **Journal of - Educational Measurement, 57**(2). DOI: 10.1111/jedm.12230. — existence verified. -- Ruth, W. (2024). *A review of Monte Carlo-based versions of the EM algorithm.* arXiv:2401.00945. — - the fetched verification source for §14 (its Eq. 19, 21–25 quoted above). - -**Standard results reproduced from memory (source cited) `[S]`:** - -- Louis, T. A. (1982). *Finding the observed information matrix when using the EM algorithm.* - **JRSS-B, 44**(2), 226–233. — information identity in §14.1. -- Chan, K. S., & Ledolter, J. (1995). *Monte Carlo EM estimation for time series models involving counts.* - **JASA, 90**(429), 242–252. — pilot-study scheduling alternative (described in the review `[V]`). -- Niederreiter, H. (1992). *Random Number Generation and Quasi-Monte Carlo Methods.* SIAM. — Koksma–Hlawka, - `O(M^{-1}(\log M)^K)` discrepancy of Halton/Sobol'. -- Caflisch, R. E. (1998). *Monte Carlo and quasi-Monte Carlo methods.* **Acta Numerica, 7**, 1–49. — - rates summary. -- Cranley, R., & Patterson, T. N. L. (1976). *Randomization of number theoretic methods for multiple - integration.* **SIAM J. Numer. Anal., 13**(6), 904–914. — random shift. -- L'Ecuyer, P., & Lemieux, C. (2002). *Recent advances in randomized quasi-Monte Carlo methods.* In - *Modeling Uncertainty* (pp. 419–474). Springer. — RQMC variance estimation (cited for this by the review `[V]`). -- Owen, A. B. (1997). *Scrambled net variance for integrals of smooth functions.* **Ann. Statist., 25**(4), - 1541–1562. — scrambling rate. -- Glasserman, P. (2004). *Monte Carlo Methods in Financial Engineering.* Springer, §2.3.2; and - Moro, B. (1995). *The full Monte.* **Risk, 8**(2), 57–58. — where the Beasley–Springer–Moro inverse-normal - coefficients live (Beasley & Springer, 1977, *Applied Statistics, 26*, 118–121, Algorithm AS 111). -- Mislevy, R. J. (1984). *Estimating latent distributions.* **Psychometrika, 49**(3), 359–381. - DOI: 10.1007/BF02306026. — empirical-histogram / posterior-moment latent density updates (§17–18). -- Samejima, F. (1969). *Estimation of latent ability using a response pattern of graded scores.* - **Psychometrika Monograph 17**. — Bayes modal (MAP) scoring lineage; Newton/SE form is standard. -- Thissen, D., & Wainer, H. (Eds.) (2001). *Test Scoring.* Erlbaum, ch. 4. — EAPsum / conversion-table - serving practice. - ---- - -### Part II verification summary - -Verified verbatim or by direct description online: Wei–Tanner `\hat Q` (review Eq. 19); Booth–Hobert -asymptotic-normality expansion, sandwich variance, `M(1+1/r)` rule with `r∈{3,4,5}`, and both stopping -rules incl. `δ` values (review Eqs. 21–25); Caffo et al. ascent rules; McCulloch (1997) design; Meng– -Schilling method description; Jank (2005) RQMC-EM design + efficiency claim; Halton radical inverse; -Acklam coefficients/break-points/error; LW recursion and all four EAPsum equations (Cai 2015, PMC); -mirt EAPsum/fixedCalib behavior; Kim–Cohen and Hanson–Béguin findings; Kim (2006) five FIPC variants and -MWU-MEM result; Kang–Petersen implementation note; all Part II DOIs (Crossref). From memory `[S]`: -Booth–Hobert *t*-proposal details; prior-sampling IS weights/ESS; Koksma–Hlawka and QMC/scrambling rates; -Cranley–Patterson shift and replicate-variance formulas; BSM coefficient location; EAP/MAP formulas -(Bock–Mislevy description verified, equations standard); multigroup/multilevel scoring priors incl. -`1+σ_u²`; concurrent-calibration likelihood; FIPC §18.3 update equations (architecture verified via mirt). -**Not verifiable online:** an "aFIPC" method (no such variant located); any LSIRM- or IRT-specific QMC-EM -paper (closest: Pan & Thompson 2007 GLMM; §15.5 is an assembly, flagged); exact sampler equations of -Meng–Schilling and exact Jank pseudo-code (paywalled — designs verified via secondary sources). diff --git a/python/fast_mlsirm/__init__.py b/python/fast_mlsirm/__init__.py index 499d0be0f..fdfe22299 100644 --- a/python/fast_mlsirm/__init__.py +++ b/python/fast_mlsirm/__init__.py @@ -3,122 +3,9 @@ from .config import FitConfig as FitConfig, MLS2PLMConfig as MLS2PLMConfig, PenaltyConfig as PenaltyConfig from .diagnostics import align_latent_space as align_latent_space, dimensionality_diagnostics as dimensionality_diagnostics, fit_diagnostics as fit_diagnostics, fixed_item_calibration_diagnostics as fixed_item_calibration_diagnostics, predict_proba as predict_proba, recovery_report as recovery_report, response_process_dimensionality_diagnostics as response_process_dimensionality_diagnostics, response_process_fit_diagnostics as response_process_fit_diagnostics from .fit import fit as fit -from .fitstats import (adjusted_chi2_pairs as adjusted_chi2_pairs, - benjamini_hochberg as benjamini_hochberg, chi2_sf as chi2_sf, - dif_analysis as dif_analysis, - dimensionality_residuals as dimensionality_residuals, - empirical_reliability as empirical_reliability, - infit_outfit as infit_outfit, person_fit as person_fit, - m2 as m2, m2_cmle_rasch as m2_cmle_rasch, - m2_multigroup as m2_multigroup, - m2_multilevel as m2_multilevel, M2Result as M2Result, - person_fit_resampling as person_fit_resampling, - residual_item_fit as residual_item_fit, - s_x2 as s_x2, select_items as select_items, - tcc_drift as tcc_drift, - vuong_nonnested as vuong_nonnested) -from .inference import oakes_standard_errors as oakes_standard_errors, observed_information as observed_information, second_order_test as second_order_test, standard_errors_from_vcov as standard_errors_from_vcov, vcov_from_hessian as vcov_from_hessian +from .inference import observed_information as observed_information, second_order_test as second_order_test, standard_errors_from_vcov as standard_errors_from_vcov, vcov_from_hessian as vcov_from_hessian from .linking import link_fixed_item_parameters as link_fixed_item_parameters -from .linking import irt_link as irt_link, IrtLinkResult as IrtLinkResult -from .equating import equate_observed_scores as equate_observed_scores, equate_neat as equate_neat, EquateResult as EquateResult, equate_observed_scores_kernel as equate_observed_scores_kernel, loglinear_smooth as loglinear_smooth, equate_neat_linear as equate_neat_linear, equating_standard_errors as equating_standard_errors -from .rt import fit_response_times as fit_response_times, RtFit as RtFit, fit_speed_accuracy as fit_speed_accuracy, rt_person_fit as rt_person_fit -from .cdm import fit_cdm as fit_cdm, CdmFit as CdmFit, fit_gdina as fit_gdina, GdinaFit as GdinaFit, validate_q_matrix as validate_q_matrix, QMatrixValidation as QMatrixValidation, gdina_wald_selection as gdina_wald_selection, WaldModelSelection as WaldModelSelection, fit_ho_cdm as fit_ho_cdm, HoCdmFit as HoCdmFit, fit_ho_gdina as fit_ho_gdina, HoGdinaFit as HoGdinaFit, fit_seq_gdina as fit_seq_gdina, SeqGdinaFit as SeqGdinaFit, fit_seq_gdina_qr as fit_seq_gdina_qr, SeqGdinaQrFit as SeqGdinaQrFit -from .mixture import fit_mixture as fit_mixture, MixtureFit as MixtureFit -from .crm import fit_crm as fit_crm, CrmFit as CrmFit -from . import models as models -from .models import ConfirmatoryModel as ConfirmatoryModel, ExploratoryModel as ExploratoryModel, IrtModel as IrtModel -from .twopl import fit_2pl as fit_2pl, TwoPlFit as TwoPlFit -from .mhrm import fit_mhrm as fit_mhrm, MhrmFit as MhrmFit -from .nominal import fit_nominal as fit_nominal, NominalResponseFit as NominalResponseFit -from .grm import fit_grm as fit_grm, GrmFit as GrmFit -from .gpcm import fit_gpcm as fit_gpcm, GpcmFit as GpcmFit -from .facets import fit_facets as fit_facets, FacetsFit as FacetsFit -from .ksirt import ksirt_analysis as ksirt_analysis, KsirtResult as KsirtResult -from .subscores import ( - subscore_analysis as subscore_analysis, - SubscoreResult as SubscoreResult, -) -from .detect import ( - detect_analysis as detect_analysis, - DetectResult as DetectResult, -) -from .classification import ( - rudner_classification as rudner_classification, - lee_classification as lee_classification, - livingston_lewis as livingston_lewis, -) -from .parallel_analysis import ( - parallel_analysis as parallel_analysis, - ParallelAnalysisResult as ParallelAnalysisResult, -) -from .reliability import ( - guttman_lambdas as guttman_lambdas, - GuttmanResult as GuttmanResult, - tenberge_mu as tenberge_mu, - TenBergeResult as TenBergeResult, - cronbach_alpha as cronbach_alpha, - feldt_alpha_ci as feldt_alpha_ci, - AlphaCiResult as AlphaCiResult, - separation_reliability as separation_reliability, - SeparationReliabilityResult as SeparationReliabilityResult, -) -from .factor import ( - minres_fa as minres_fa, - minres_fa_from_data as minres_fa_from_data, - glb_fa as glb_fa, - glb_fa_from_data as glb_fa_from_data, - omega_total_1f as omega_total_1f, - omega_total_1f_from_data as omega_total_1f_from_data, - velicer_map as velicer_map, - velicer_map_from_data as velicer_map_from_data, - VelicerMapResult as VelicerMapResult, -) -from .gtheory import ( - gtheory_pi as gtheory_pi, - gtheory_pio as gtheory_pio, - GTheoryResult as GTheoryResult, - GTheoryDStudyRow as GTheoryDStudyRow, -) -from .mokken import mokken_analysis as mokken_analysis, MokkenResult as MokkenResult -from .utility import ( - selection_utility as selection_utility, - taylor_russell as taylor_russell, - SelectionUtilityResult as SelectionUtilityResult, - TaylorRussellResult as TaylorRussellResult, -) -from .exposure import ( - sympson_hetter as sympson_hetter, - SympsonHetterResult as SympsonHetterResult, - a_stratified as a_stratified, - AStratifiedResult as AStratifiedResult, - kl_information as kl_information, - kl_select as kl_select, - owen_update as owen_update, - owen_cat as owen_cat, - ccat_select as ccat_select, -) -from .rsm import fit_rsm as fit_rsm, RsmFit as RsmFit -from .mixed import fit_mixed_items as fit_mixed_items, MixedFormatFit as MixedFormatFit, MixedItemParameters as MixedItemParameters -from .lltm import fit_lltm as fit_lltm, LltmFit as LltmFit -from .testlet import fit_testlet as fit_testlet, TestletFit as TestletFit from .report import render_diagnostics_report as render_diagnostics_report -from .validation import (ValidationVerdict as ValidationVerdict, - validate_judge as validate_judge) -from .serving import (bank_information as bank_information, - cat_next_item as cat_next_item, - export_serving_bundle as export_serving_bundle, - plausible_values as plausible_values, - load_serving_bundle as load_serving_bundle, - score_respondents as score_respondents) -from .preprocessing import irtree_expand as irtree_expand -from .polytomous import fit_polytomous as fit_polytomous, PolytomousFit as PolytomousFit, score_polytomous as score_polytomous, information_polytomous as information_polytomous, fit_lsirm_polytomous as fit_lsirm_polytomous, PolyLsirmFit as PolyLsirmFit, polytomous_information_criteria as polytomous_information_criteria, item_fit_polytomous as item_fit_polytomous, m2_polytomous as m2_polytomous, local_dependence_polytomous as local_dependence_polytomous, fit_nominal_polytomous as fit_nominal_polytomous, NominalFit as NominalFit, person_fit_polytomous as person_fit_polytomous, cat_simulate_polytomous as cat_simulate_polytomous, dif_polytomous as dif_polytomous, u3_person_fit_polytomous as u3_person_fit_polytomous, u3_cutoff_polytomous as u3_cutoff_polytomous -from .dif import (mantel_haenszel_dif as mantel_haenszel_dif, logistic_dif as logistic_dif, - mantel_haenszel_dif_purified as mantel_haenszel_dif_purified, - logistic_dif_purified as logistic_dif_purified, - sibtest as sibtest, - raju_area as raju_area) -from .wle import score_wle as score_wle, score_wle_poly as score_wle_poly -from .rasch_cml import fit_rasch_cml as fit_rasch_cml, andersen_lr_test as andersen_lr_test from .simulation import simulate as simulate from .test_design import assemble_test_form as assemble_test_form, item_information as item_information, select_cat_item as select_cat_item from .types import DimensionalityDiagnostics as DimensionalityDiagnostics, FitDiagnostics as FitDiagnostics, FitResult as FitResult, MLSIRMParams as MLSIRMParams, RecoveryReport as RecoveryReport, SimulationData as SimulationData @@ -134,7 +21,6 @@ "FitConfig", "FitDiagnostics", "FitResult", - "M2Result", "MLS2PLMConfig", "MLSIRMParams", "PenaltyConfig", @@ -143,169 +29,8 @@ "align_latent_space", "assemble_test_form", "dimensionality_diagnostics", - "ValidationVerdict", - "benjamini_hochberg", - "chi2_sf", - "dif_analysis", - "dimensionality_residuals", - "empirical_reliability", - "irtree_expand", - "oakes_standard_errors", - "validate_judge", - "vuong_nonnested", - "adjusted_chi2_pairs", - "bank_information", - "cat_next_item", - "person_fit_resampling", - "plausible_values", - "residual_item_fit", - "tcc_drift", - "irt_link", - "IrtLinkResult", - "equate_observed_scores", - "equate_neat", - "EquateResult", - "equate_observed_scores_kernel", - "loglinear_smooth", - "equate_neat_linear", - "equating_standard_errors", - "fit_response_times", - "RtFit", - "fit_speed_accuracy", - "rt_person_fit", - "fit_cdm", - "CdmFit", - "fit_gdina", - "GdinaFit", - "validate_q_matrix", - "QMatrixValidation", - "gdina_wald_selection", - "WaldModelSelection", - "fit_ho_cdm", - "HoCdmFit", - "fit_ho_gdina", - "HoGdinaFit", - "fit_seq_gdina", - "SeqGdinaFit", - "fit_seq_gdina_qr", - "SeqGdinaQrFit", - "fit_mixture", - "MixtureFit", - "fit_crm", - "CrmFit", - "models", - "ConfirmatoryModel", - "ExploratoryModel", - "IrtModel", - "fit_2pl", - "TwoPlFit", - "fit_mhrm", - "MhrmFit", - "fit_nominal", - "NominalResponseFit", - "fit_grm", - "GrmFit", - "fit_gpcm", - "GpcmFit", - "fit_rsm", - "RsmFit", - "fit_facets", - "FacetsFit", - "mokken_analysis", - "MokkenResult", - "ksirt_analysis", - "KsirtResult", - "subscore_analysis", - "SubscoreResult", - "detect_analysis", - "rudner_classification", - "lee_classification", - "livingston_lewis", - "parallel_analysis", - "guttman_lambdas", - "GuttmanResult", - "tenberge_mu", - "TenBergeResult", - "cronbach_alpha", - "feldt_alpha_ci", - "AlphaCiResult", - "separation_reliability", - "SeparationReliabilityResult", - "gtheory_pi", - "gtheory_pio", - "minres_fa", - "minres_fa_from_data", - "glb_fa", - "glb_fa_from_data", - "velicer_map", - "velicer_map_from_data", - "VelicerMapResult", - "selection_utility", - "taylor_russell", - "SelectionUtilityResult", - "TaylorRussellResult", - "sympson_hetter", - "SympsonHetterResult", - "a_stratified", - "kl_information", - "kl_select", - "owen_update", - "owen_cat", - "ccat_select", - "AStratifiedResult", - "omega_total_1f", - "omega_total_1f_from_data", - "GTheoryResult", - "GTheoryDStudyRow", - "ClassificationResult", - "DetectResult", - "fit_mixed_items", - "MixedFormatFit", - "MixedItemParameters", - "fit_lltm", - "LltmFit", - "fit_testlet", - "TestletFit", - "export_serving_bundle", "fit", - "fit_polytomous", - "score_polytomous", - "information_polytomous", - "fit_lsirm_polytomous", - "PolyLsirmFit", - "polytomous_information_criteria", - "item_fit_polytomous", - "m2_polytomous", - "local_dependence_polytomous", - "fit_nominal_polytomous", - "NominalFit", - "person_fit_polytomous", - "cat_simulate_polytomous", - "dif_polytomous", - "mantel_haenszel_dif", - "logistic_dif", - "mantel_haenszel_dif_purified", - "logistic_dif_purified", - "sibtest", - "raju_area", - "score_wle", - "score_wle_poly", - "fit_rasch_cml", - "andersen_lr_test", - "u3_person_fit_polytomous", - "u3_cutoff_polytomous", - "PolytomousFit", "fit_diagnostics", - "infit_outfit", - "m2", - "m2_cmle_rasch", - "m2_multigroup", - "m2_multilevel", - "load_serving_bundle", - "person_fit", - "s_x2", - "score_respondents", - "select_items", "fixed_item_calibration_diagnostics", "item_information", "link_fixed_item_parameters", diff --git a/python/fast_mlsirm/cdm.py b/python/fast_mlsirm/cdm.py deleted file mode 100644 index f40d9531c..000000000 --- a/python/fast_mlsirm/cdm.py +++ /dev/null @@ -1,1009 +0,0 @@ -"""Cognitive diagnosis models: DINA (conjunctive / AND gate) and DINO -(disjunctive / OR gate), estimated by marginal-ML EM over the ``2^K`` binary -attribute-mastery profiles in the Rust core.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER - - -_MAX_ATTRIBUTES = 15 - - -def _prepare_binary_responses(y: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Return the flattened values/mask for 0/1 data with NaN-only missingness.""" - if np.isinf(y).any(): - raise ValueError("responses must contain only 0, 1, or NaN (missing)") - observed = ~np.isnan(y) - values = y[observed] - if values.size and not np.all((values == 0.0) | (values == 1.0)): - raise ValueError("responses must contain only 0, 1, or NaN (missing)") - return np.where(observed, y, 0.0).reshape(-1), observed.reshape(-1) - - -def _validate_stopping_controls(max_iter: int, tol: float) -> tuple[int, float]: - if ( - not isinstance(max_iter, (int, np.integer)) - or isinstance(max_iter, (bool, np.bool_)) - or not 1 <= int(max_iter) <= MAX_MAX_ITER - ): - raise ValueError(f"max_iter must be an integer between 1 and {MAX_MAX_ITER}") - if not isinstance(tol, (int, float, np.integer, np.floating)) or isinstance( - tol, (bool, np.bool_) - ): - raise ValueError("tol must be a finite number > 0") - tolerance = float(tol) - if not np.isfinite(tolerance) or tolerance <= 0: - raise ValueError("tol must be a finite number > 0") - return int(max_iter), tolerance - - -def _validate_q_matrix_input( - value: np.ndarray, name: str, n_items: int -) -> tuple[np.ndarray, int]: - """Validate and safely coerce a public Q-matrix before native dispatch.""" - q = np.asarray(value) - if q.ndim != 2: - raise ValueError(f"{name} must be a 2-D items x attributes array") - if q.shape[0] != n_items: - raise ValueError(f"{name} must have one row per item") - n_attributes = q.shape[1] - if not 1 <= n_attributes <= _MAX_ATTRIBUTES: - raise ValueError(f"{name} must have between 1 and {_MAX_ATTRIBUTES} attributes") - if not ( - np.issubdtype(q.dtype, np.number) or np.issubdtype(q.dtype, np.bool_) - ) or np.issubdtype(q.dtype, np.complexfloating): - raise ValueError(f"{name} entries must be numeric 0 or 1") - if not np.all(np.isfinite(q)) or not np.all((q == 0) | (q == 1)): - raise ValueError(f"{name} entries must be finite and exactly 0 or 1") - return q.astype(np.int64, copy=False), n_attributes - - -@dataclass -class CdmFit: - """Fitted DINA/DINO cognitive diagnosis model. - - ``slip``/``guess`` are the per-item ``s_i = P(X=0 | mastered)`` and - ``g_i = P(X=1 | not mastered)``; ``profile_prob`` the population probability of - each of the ``2^K`` attribute profiles (bit-encoded: attribute ``k`` is mastered - in profile ``c`` iff ``(c >> k) & 1``); ``map_profile`` the per-person posterior - mode (bit-encoded); ``attr_prob`` the persons x attributes marginal mastery - probabilities ``P(alpha_jk = 1 | X_j)`` (attribute EAP).""" - - model: str - slip: np.ndarray - guess: np.ndarray - profile_prob: np.ndarray - map_profile: np.ndarray - attr_prob: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - n_parameters: int - - def attribute_mastery(self) -> np.ndarray: - """Hard 0/1 attribute-mastery classification (``attr_prob >= 0.5``).""" - return (self.attr_prob >= 0.5).astype(np.int64) - - def profile_bits(self) -> np.ndarray: - """Decode ``map_profile`` into a persons x attributes 0/1 matrix.""" - k = self.attr_prob.shape[1] - codes = self.map_profile.astype(np.int64) - return ((codes[:, None] >> np.arange(k)) & 1).astype(np.int64) - - -def fit_cdm( - responses: np.ndarray, - q_matrix: np.ndarray, - model: str = "dina", - max_iter: int = 500, - tol: float = 1e-6, -) -> CdmFit: - """Fit a DINA or DINO cognitive diagnosis model (compute in Rust). - - Each respondent has a binary attribute-mastery profile ``alpha in {0,1}^K``; the - Q-matrix specifies which of the ``K`` attributes each item requires. The ideal - (latent) response is ``eta = prod_k alpha_k^{q_k}`` for DINA (mastery of ALL - required attributes) or ``eta = 1 - prod_k (1 - alpha_k)^{q_k}`` for DINO (ANY - required attribute); the observed response adds a per-item slip and guess, - ``P(X=1 | alpha) = (1 - s)^{eta} g^{1 - eta}``. Parameters are estimated by - marginal-ML EM over the ``2^K`` profiles with a free profile distribution; - persons are classified by their posterior-mode profile (``map_profile``) and - marginal attribute probabilities (``attr_prob``). - - ``responses`` is a persons x items array of 0/1 (``NaN`` marks a missing cell, - dropped under a missing-at-random assumption). ``q_matrix`` is an items x - attributes 0/1 array; all-zero rows (an item measuring nothing) and all-zero - columns (an attribute measured by no item) are rejected. These checks do not - establish global model identifiability: DINA identifiability requires stronger - Q-matrix conditions (Gu & Xu, 2019), which callers must assess for their design. - - References (APA 7th ed.): - de la Torre, J. (2009). DINA model and parameter estimation: A didactic. - *Journal of Educational and Behavioral Statistics, 34*(1), 115–130. - https://doi.org/10.3102/1076998607309474 - Gu, Y., & Xu, G. (2019). The sufficient and necessary condition for the - identifiability and estimability of the DINA model. *Psychometrika, - 84*(2), 468–483. https://doi.org/10.1007/s11336-018-9619-8 - Junker, B. W., & Sijtsma, K. (2001). Cognitive assessment models with few - assumptions, and connections with nonparametric item response theory. - *Applied Psychological Measurement, 25*(3), 258–272. - https://doi.org/10.1177/01466210122032064 - Templin, J. L., & Henson, R. A. (2006). Measurement of psychological - disorders using cognitive diagnosis models. *Psychological Methods, - 11*(3), 287–305. https://doi.org/10.1037/1082-989X.11.3.287 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_cdm"): - raise RuntimeError("fit_cdm requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - q, n_attributes = _validate_q_matrix_input(q_matrix, "q_matrix", n_items) - - max_iter, tol = _validate_stopping_controls(max_iter, tol) - yy, observed = _prepare_binary_responses(y) - res = core.fit_cdm( - yy, - observed, - q.reshape(-1), - int(n_persons), - int(n_items), - int(n_attributes), - str(model), - int(max_iter), - float(tol), - ) - return CdmFit( - model=str(res["model"]), - slip=np.asarray(res["slip"], dtype=np.float64), - guess=np.asarray(res["guess"], dtype=np.float64), - profile_prob=np.asarray(res["profile_prob"], dtype=np.float64), - map_profile=np.asarray(res["map_profile"], dtype=np.int64), - attr_prob=np.asarray(res["attr_prob"], dtype=np.float64).reshape(n_persons, n_attributes), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - n_parameters=int(res["n_parameters"]), - ) - - -@dataclass -class GdinaFit: - """Fitted saturated G-DINA model (de la Torre, 2011). - - Item parameters are ragged: item ``i`` has ``2 ** k_required[i]`` reduced - attribute-mastery classes, stored as the CSR slice - ``[item_off[i]:item_off[i+1]]`` of ``item_prob`` and ``item_delta``. - ``item_prob`` holds the free success probabilities ``P(X_i = 1 | reduced - class l)``; ``item_delta`` the identity-link parameters (intercept, main - effects, interactions). ``map_profile``/``attr_prob`` are the per-person MAP - profile and marginal attribute-mastery probabilities.""" - - item_off: np.ndarray - item_prob: np.ndarray - item_delta: np.ndarray - k_required: np.ndarray - profile_prob: np.ndarray - map_profile: np.ndarray - attr_prob: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - n_parameters: int - - def item_prob_row(self, i: int) -> np.ndarray: - """Success probabilities of item ``i``'s ``2 ** K_i`` reduced classes.""" - return self.item_prob[self.item_off[i] : self.item_off[i + 1]] - - def item_delta_row(self, i: int) -> np.ndarray: - """Identity-link parameters of item ``i`` (intercept, mains, interactions).""" - return self.item_delta[self.item_off[i] : self.item_off[i + 1]] - - -def fit_gdina( - responses: np.ndarray, - q_matrix: np.ndarray, - max_iter: int = 500, - tol: float = 1e-6, -) -> GdinaFit: - """Fit the saturated G-DINA model (compute in Rust; de la Torre, 2011). - - G-DINA is the general cognitive-diagnosis framework: for item ``i`` requiring - ``K_i`` attributes, each of the ``2 ** K_i`` reduced attribute-mastery classes - gets a FREE success probability, estimated by marginal-ML EM over the ``2 ** K`` - profiles with the closed-form saturated M-step ``p_il = R_il / I_il`` (expected - correct / expected total in reduced class ``l``). DINA, DINO, A-CDM, LLM and - R-RUM are constrained special cases readable off the fitted identity-link - ``item_delta`` (e.g. DINA leaves only the intercept and the highest-order - interaction nonzero). ``responses`` is a persons x items 0/1 array (``NaN`` = - missing, dropped under MAR); ``q_matrix`` is an items x attributes 0/1 array. - - The saturated fit constrains each success probability only to ``[0, 1]``. It does - not impose subset-lattice order restrictions, so Q-matrix identifiability alone - does not guarantee that mastering more required attributes increases success. - Order-restricted G-DINA estimation is a distinct model choice (Hong et al., 2016) - and is not implemented by this function. - - References (APA 7th ed.): - de la Torre, J. (2011). The generalized DINA model framework. - *Psychometrika, 76*(2), 179-199. - https://doi.org/10.1007/s11336-011-9207-7 - Hong, C.-Y., Chang, Y.-W., & Tsai, R.-C. (2016). Estimation of generalized - DINA model with order restrictions. *Journal of Classification, 33*(3), - 460-484. https://doi.org/10.1007/s00357-016-9215-5 - Ma, W., & de la Torre, J. (2020). GDINA: An R package for cognitive - diagnosis modeling. *Journal of Statistical Software, 93*(14), 1-26. - https://doi.org/10.18637/jss.v093.i14 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_gdina"): - raise RuntimeError("fit_gdina requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - q, n_attributes = _validate_q_matrix_input(q_matrix, "q_matrix", n_items) - - max_iter, tol = _validate_stopping_controls(max_iter, tol) - yy, observed = _prepare_binary_responses(y) - res = core.fit_gdina( - yy, - observed, - q.reshape(-1), - int(n_persons), - int(n_items), - int(n_attributes), - int(max_iter), - float(tol), - ) - return GdinaFit( - item_off=np.asarray(res["item_off"], dtype=np.int64), - item_prob=np.asarray(res["item_prob"], dtype=np.float64), - item_delta=np.asarray(res["item_delta"], dtype=np.float64), - k_required=np.asarray(res["k_required"], dtype=np.int64), - profile_prob=np.asarray(res["profile_prob"], dtype=np.float64), - map_profile=np.asarray(res["map_profile"], dtype=np.int64), - attr_prob=np.asarray(res["attr_prob"], dtype=np.float64).reshape(n_persons, n_attributes), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - n_parameters=int(res["n_parameters"]), - ) - - -@dataclass -class QMatrixValidation: - """Result of empirical Q-matrix validation (de la Torre & Chiu, 2016). - - ``suggested_q`` is the validated items x attributes 0/1 Q-matrix — per item the - fewest-attribute vector whose PVAF (proportion of variance accounted for) - reaches ``epsilon``. ``suggested_pvaf``/``provisional_pvaf`` are the per-item - PVAF of the suggested and the caller's provisional q-vector; ``flagged`` marks - the items whose suggested vector differs from the provisional one. The - ``calibration_*`` fields report the exact stopping evidence for the provisional - G-DINA calibration used to construct the suggestions.""" - - suggested_q: np.ndarray - suggested_pvaf: np.ndarray - provisional_pvaf: np.ndarray - flagged: np.ndarray - epsilon: float - calibration_n_iter: int - calibration_max_iter: int - calibration_termination_reason: str - calibration_final_loglik_change: float - calibration_tol: float - - -def validate_q_matrix( - responses: np.ndarray, - provisional_q: np.ndarray, - epsilon: float = 0.95, - max_iter: int = 500, - tol: float = 1e-6, -) -> QMatrixValidation: - """Validate a Q-matrix by the PVAF method (compute in Rust; de la Torre & Chiu, 2016). - - The G-DINA item response function varies across the ``2^K`` latent attribute - classes. A candidate q-vector groups those classes into masters vs. non-masters - of its required attributes; the proportion of the item's across-class variance - that grouping captures is its ``PVAF``. For each item the method returns the - q-vector with the FEWEST required attributes whose ``PVAF >= epsilon``: an - under-specified provisional vector falls short of the cutoff and is enlarged, an - over-specified one is trimmed because a smaller vector already reaches it. - - The class distribution and identified attribute labels come from a G-DINA fit - with the provisional Q; each item's saturated success probability over all - ``2^K`` classes is then recovered from the fitted posteriors, so a mis-specified - item's true attribute dependence is exposed by the attributes identified from - the other items. The method therefore assumes the provisional Q is mostly - correct (enough items to identify the attributes). - - ``responses`` is a persons x items 0/1 array (``NaN`` = missing, dropped under - MAR); ``provisional_q`` is an items x attributes 0/1 array, each item loading at - least one attribute (``K`` up to 10). ``epsilon`` is the PVAF cutoff. A - nonconverged provisional G-DINA calibration raises ``ValueError`` instead of - producing PVAF suggestions from an unfinished fit. - - References (APA 7th ed.): - de la Torre, J., & Chiu, C.-Y. (2016). A general method of empirical Q-matrix - validation. *Psychometrika, 81*(2), 253–273. - https://doi.org/10.1007/s11336-015-9467-8 - de la Torre, J. (2008). An empirically based method of Q-matrix validation - for the DINA model: Development and applications. *Journal of Educational - Measurement, 45*(4), 343-362. - https://doi.org/10.1111/j.1745-3984.2008.00069.x - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "validate_q_matrix"): - raise RuntimeError("validate_q_matrix requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - q, n_attributes = _validate_q_matrix_input(provisional_q, "provisional_q", n_items) - - max_iter, tol = _validate_stopping_controls(max_iter, tol) - yy, observed = _prepare_binary_responses(y) - res = core.validate_q_matrix( - yy, - observed, - q.reshape(-1), - int(n_persons), - int(n_items), - int(n_attributes), - float(epsilon), - int(max_iter), - float(tol), - ) - return QMatrixValidation( - suggested_q=np.asarray(res["suggested_q"], dtype=np.int64).reshape(n_items, n_attributes), - suggested_pvaf=np.asarray(res["suggested_pvaf"], dtype=np.float64), - provisional_pvaf=np.asarray(res["provisional_pvaf"], dtype=np.float64), - flagged=np.asarray(res["flagged"], dtype=bool), - epsilon=float(res["epsilon"]), - calibration_n_iter=int(res["calibration_n_iter"]), - calibration_max_iter=int(res["calibration_max_iter"]), - calibration_termination_reason=str(res["calibration_termination_reason"]), - calibration_final_loglik_change=float(res["calibration_final_loglik_change"]), - calibration_tol=float(res["calibration_tol"]), - ) - - -@dataclass -class WaldModelSelection: - """Item-level CDM model selection by Wald test (de la Torre & Lee, 2013). - - ``models`` names the candidate reduced models (parsimony order). ``wald_stat``, - ``wald_df`` and ``p_value`` are items x models arrays of the Wald statistic, - degrees of freedom, and upper-tail p-value (``NaN``/0 where a test is undefined, - i.e. an item requiring fewer than two attributes). ``selected`` is per item the - index into ``models`` of the chosen reduced model, or ``-1`` for the saturated - G-DINA.""" - - models: list - wald_stat: np.ndarray - wald_df: np.ndarray - p_value: np.ndarray - selected: np.ndarray - alpha: float - - -def gdina_wald_selection( - responses: np.ndarray, - q_matrix: np.ndarray, - alpha: float = 0.05, - max_iter: int = 500, - tol: float = 1e-6, -) -> WaldModelSelection: - """Select item-level CDMs by Wald test (Rust; de la Torre & Lee, 2013). - - For each item the saturated G-DINA is compared with reduced models that are exact - linear restrictions of the reduced attribute-mastery success probabilities ``P``. - DINA/DINO/A-CDM restrict the identity-link parameters ``delta = M^{-1} P``; LLM and - R-RUM restrict the transformed parameters ``delta^h = M^{-1} h(P)`` on the link on - which each is additive: - - * **DINA** (conjunctive): only the intercept and the top-order interaction free. - * **DINO** (disjunctive): the non-intercept coordinates tied onto one line - ``delta_S = (-1)^{|S|+1} Delta`` (a general, non-coordinate linear restriction). - * **A-CDM** (additive on the identity link): all interaction terms zero - (intercept + main effects). - * **LLM** (linear logistic model; additive on the logit link): interaction terms of - ``delta^{logit} = M^{-1} logit(P)`` zero. - * **R-RUM** (reduced reparameterized unified model; additive on the log link): - interaction terms of ``delta^{log} = M^{-1} log(P)`` zero. - - The Wald statistic ``W = (R delta)' (R Sigma_delta R')^{-1} (R delta) ~ chi^2(df)`` - tests whether the restriction ``R delta = 0`` holds. For the identity link - ``Sigma_delta = M^{-1} Var(P) M^{-T}`` with ``Var(P_l) = P_l(1-P_l)/I_l`` - (complete-data / expected information); for a transformed link the delta method uses - ``Var(h(P_l)) = h'(P_l)^2 Var(P_l)`` (LLM: ``1/(I_l P_l(1-P_l))``; R-RUM: - ``(1-P_l)/(I_l P_l)``). Per item the fewest-parameter model with ``p > alpha`` is - selected (DINA and DINO cost two parameters; A-CDM, LLM and R-RUM each cost - ``1 + K``, so ties are broken by the larger p-value); if all reduced models are - rejected, the saturated G-DINA is kept. - - Note: the complete-data covariance uses expected rather than observed information, - so the test is mildly liberal (Type I slightly above ``alpha``); the gap shrinks - with sample size and item discrimination and with strong attribute identification. - - ``responses`` is a persons x items 0/1 array (``NaN`` = missing, dropped under - MAR); ``q_matrix`` is an items x attributes 0/1 array. A nonconverged saturated - G-DINA calibration raises ``ValueError`` instead of returning Wald statistics and - a model choice from unfinished parameters. - - References (APA 7th ed.): - de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, - 76*(2), 179–199. https://doi.org/10.1007/s11336-011-9207-7 - de la Torre, J., & Lee, Y.-S. (2013). Evaluating the Wald test for item-level - comparison of saturated and reduced models in cognitive diagnosis. - *Journal of Educational Measurement, 50*(4), 355–373. - https://doi.org/10.1111/jedm.12022 - Ma, W., Iaconangelo, C., & de la Torre, J. (2016). Model similarity, model - selection, and attribute classification. *Applied Psychological - Measurement, 40*(3), 200–217. https://doi.org/10.1177/0146621615621717 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "gdina_wald_selection"): - raise RuntimeError("gdina_wald_selection requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - q, n_attributes = _validate_q_matrix_input(q_matrix, "q_matrix", n_items) - - max_iter, tol = _validate_stopping_controls(max_iter, tol) - yy, observed = _prepare_binary_responses(y) - res = core.gdina_wald_selection( - yy, - observed, - q.reshape(-1), - int(n_persons), - int(n_items), - int(n_attributes), - float(alpha), - int(max_iter), - float(tol), - ) - models = list(res["models"]) - n_models = len(models) - return WaldModelSelection( - models=models, - wald_stat=np.asarray(res["wald_stat"], dtype=np.float64).reshape(n_items, n_models), - wald_df=np.asarray(res["wald_df"], dtype=np.int64).reshape(n_items, n_models), - p_value=np.asarray(res["p_value"], dtype=np.float64).reshape(n_items, n_models), - selected=np.asarray(res["selected"], dtype=np.int64), - alpha=float(res["alpha"]), - ) - - -@dataclass -class HoCdmFit: - """Fitted higher-order DINA/DINO model (de la Torre & Douglas, 2004). - - A continuous higher-order trait ``theta ~ N(0,1)`` structures attribute mastery, - ``P(alpha_k=1 | theta) = sigmoid(attr_slope_k * theta + attr_intercept_k)``, with - attributes conditionally independent given ``theta``. ``slip``/``guess`` are the - per-item DINA parameters; ``profile_prob`` the implied ``2^K`` class distribution; - ``theta`` the per-person EAP trait; ``map_profile``/``attr_prob`` the per-person - MAP profile and marginal attribute mastery. The higher-order parameters are a - genuine (identified) restriction only for ``K >= 3``. ``termination_reason``, - ``n_iter``/``max_iter``, ``final_loglik_change``, and ``stopping_tolerance`` - report the outer EM stopping evidence.""" - - model: str - slip: np.ndarray - guess: np.ndarray - attr_slope: np.ndarray - attr_intercept: np.ndarray - profile_prob: np.ndarray - theta: np.ndarray - map_profile: np.ndarray - attr_prob: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - max_iter: int - termination_reason: str - final_loglik_change: float - stopping_tolerance: float - n_parameters: int - - def attribute_mastery(self) -> np.ndarray: - """Hard 0/1 attribute-mastery classification (``attr_prob >= 0.5``).""" - return (self.attr_prob >= 0.5).astype(np.int64) - - -def fit_ho_cdm( - responses: np.ndarray, - q_matrix: np.ndarray, - model: str = "dina", - max_iter: int = 500, - tol: float = 1e-6, -) -> HoCdmFit: - """Fit the higher-order DINA/DINO model (compute in Rust; de la Torre & Douglas, 2004). - - Unlike :func:`fit_cdm` (which estimates a free ``2^K`` class distribution), the - attribute-mastery distribution here is *structured* by a continuous higher-order - trait ``theta ~ N(0,1)``: ``P(alpha_k=1 | theta) = sigmoid(a_k theta + d_k)``, with - attributes conditionally independent given ``theta``. This replaces ``2^K - 1`` free - class probabilities with ``2K`` interpretable attribute parameters. The item part - (slip/guess, DINA or DINO gate) is unchanged. Estimated by marginal-ML EM over the - joint ``(alpha, theta)`` grid; the structural step is ``K`` independent 2PL - calibrations of attribute mastery on the trait. De la Torre and Douglas (2004) - introduced the higher-order model and estimated it by Bayesian MCMC; the - quadrature-EM estimator is this package's implementation choice. - - The observed-data likelihood depends on ``(a_k, d_k)`` only through the implied - class distribution, so the higher-order parameters are identified only for - ``K >= 3``; at ``K <= 2`` only ``profile_prob`` and the attribute classification - are identified. ``attr_slope`` is anchored non-negative. - - ``responses`` is a persons x items 0/1 array (``NaN`` = missing, dropped under MAR); - ``q_matrix`` is an items x attributes 0/1 array; ``model`` is ``"dina"`` or ``"dino"``. - - References (APA 7th ed.): - de la Torre, J., & Douglas, J. A. (2004). Higher-order latent trait models for - cognitive diagnosis. *Psychometrika, 69*(3), 333-353. - https://doi.org/10.1007/BF02295640 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_ho_cdm"): - raise RuntimeError("fit_ho_cdm requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - q, n_attributes = _validate_q_matrix_input(q_matrix, "q_matrix", n_items) - - max_iter, tol = _validate_stopping_controls(max_iter, tol) - yy, observed = _prepare_binary_responses(y) - res = core.fit_ho_cdm( - yy, - observed, - q.reshape(-1), - int(n_persons), - int(n_items), - int(n_attributes), - str(model), - int(max_iter), - float(tol), - ) - return HoCdmFit( - model=str(res["model"]), - slip=np.asarray(res["slip"], dtype=np.float64), - guess=np.asarray(res["guess"], dtype=np.float64), - attr_slope=np.asarray(res["attr_slope"], dtype=np.float64), - attr_intercept=np.asarray(res["attr_intercept"], dtype=np.float64), - profile_prob=np.asarray(res["profile_prob"], dtype=np.float64), - theta=np.asarray(res["theta"], dtype=np.float64), - map_profile=np.asarray(res["map_profile"], dtype=np.int64), - attr_prob=np.asarray(res["attr_prob"], dtype=np.float64).reshape(n_persons, n_attributes), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - max_iter=int(res["max_iter"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - stopping_tolerance=float(res["stopping_tolerance"]), - n_parameters=int(res["n_parameters"]), - ) - - -@dataclass -class HoGdinaFit: - """Fitted higher-order G-DINA model (de la Torre & Douglas, 2004; de la Torre, 2011). - - The saturated G-DINA item model (ragged CSR: item ``i`` has ``2 ** k_required[i]`` - reduced-class success probabilities at ``item_prob[item_off[i]:item_off[i+1]]``, - with the identity-link ``item_delta``) under a higher-order structural attribute - prior ``P(alpha_k=1 | theta) = sigmoid(attr_slope_k*theta + attr_intercept_k)``, - ``theta ~ N(0,1)``. ``profile_prob`` is the implied ``2^K`` class distribution; - ``theta``/``map_profile``/``attr_prob`` the per-person trait EAP, MAP profile, and - marginal attribute mastery.""" - - item_off: np.ndarray - item_prob: np.ndarray - item_delta: np.ndarray - k_required: np.ndarray - attr_slope: np.ndarray - attr_intercept: np.ndarray - profile_prob: np.ndarray - theta: np.ndarray - map_profile: np.ndarray - attr_prob: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - termination_reason: str - final_loglik_change: float - final_relative_loglik_change: float - stopping_tolerance: float - n_parameters: int - - def item_prob_row(self, i: int) -> np.ndarray: - """Success probabilities of item ``i``'s ``2 ** K_i`` reduced classes.""" - return self.item_prob[self.item_off[i] : self.item_off[i + 1]] - - -def fit_ho_gdina( - responses: np.ndarray, - q_matrix: np.ndarray, - max_iter: int = 500, - tol: float = 1e-6, -) -> HoGdinaFit: - """Fit the higher-order G-DINA model (compute in Rust; de la Torre & Douglas, 2004; - de la Torre, 2011). - - Combines the saturated G-DINA item model (each item's reduced attribute-mastery - classes get a free success probability, as in :func:`fit_gdina`) with a - higher-order structural attribute prior in which a continuous trait - ``theta ~ N(0,1)`` drives mastery, ``P(alpha_k=1 | theta) = sigmoid(a_k theta + - d_k)``, with attributes conditionally independent given ``theta`` (as in - :func:`fit_ho_cdm`). It generalizes :func:`fit_ho_cdm` (which restricts the item - model to DINA slip/guess) and constrains :func:`fit_gdina`'s free class - distribution to the ``2K``-parameter structured family. Estimated by marginal-ML - EM over the joint ``(alpha, theta)`` grid: the saturated item M-step marginalizes - the trait out, and the structural step is ``K`` independent 2PL calibrations of - attribute mastery on the trait. The higher-order parameters are identified for - ``K >= 3``; fits with fewer attributes are rejected rather than returning - unidentified structural parameters. ``attr_slope`` is anchored non-negative. - - ``responses`` is a persons x items 0/1 array (``NaN`` = missing, dropped under MAR; - positive/negative infinity is invalid); ``q_matrix`` is an items x attributes 0/1 - array. Convergence uses the scale-free observed-data likelihood change - ``abs(delta log L) / (1 + abs(log L_previous)) < tol``; the raw and relative - terminal changes and the stable termination reason are returned explicitly. - - References (APA 7th ed.): - de la Torre, J., & Douglas, J. A. (2004). Higher-order latent trait models for - cognitive diagnosis. *Psychometrika, 69*(3), 333-353. - https://doi.org/10.1007/BF02295640 - de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, - 76*(2), 179-199. https://doi.org/10.1007/s11336-011-9207-7 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_ho_gdina"): - raise RuntimeError("fit_ho_gdina requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - q, n_attributes = _validate_q_matrix_input(q_matrix, "q_matrix", n_items) - - max_iter, tol = _validate_stopping_controls(max_iter, tol) - yy, observed = _prepare_binary_responses(y) - res = core.fit_ho_gdina( - yy, - observed, - q.reshape(-1), - int(n_persons), - int(n_items), - int(n_attributes), - int(max_iter), - float(tol), - ) - return HoGdinaFit( - item_off=np.asarray(res["item_off"], dtype=np.int64), - item_prob=np.asarray(res["item_prob"], dtype=np.float64), - item_delta=np.asarray(res["item_delta"], dtype=np.float64), - k_required=np.asarray(res["k_required"], dtype=np.int64), - attr_slope=np.asarray(res["attr_slope"], dtype=np.float64), - attr_intercept=np.asarray(res["attr_intercept"], dtype=np.float64), - profile_prob=np.asarray(res["profile_prob"], dtype=np.float64), - theta=np.asarray(res["theta"], dtype=np.float64), - map_profile=np.asarray(res["map_profile"], dtype=np.int64), - attr_prob=np.asarray(res["attr_prob"], dtype=np.float64).reshape(n_persons, n_attributes), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - final_relative_loglik_change=float(res["final_relative_loglik_change"]), - stopping_tolerance=float(res["stopping_tolerance"]), - n_parameters=int(res["n_parameters"]), - ) - - -@dataclass -class SeqGdinaFit: - """Fitted shared-Q sequential (continuation-ratio) G-DINA (Ma & de la Torre, 2016). - - Ordered polytomous cognitive diagnosis. Item ``i`` has ``M_i = max_cat[i]`` ordered - steps over ``2 ** k_required[i]`` reduced attribute classes (ragged, CLASS-MAJOR CSR): - ``step_prob[s_off[i] + l*M_i + (k-1)] = s_ik(l) = P(X_i >= k | X_i >= k-1, class l)`` - for step ``k in 1..=M_i`` and reduced class ``l``; the implied category probabilities - are ``cat_prob[cat_off[i] + l*(M_i+1) + x] = P(X_i = x | class l)`` for ``x in 0..=M_i``. - ``profile_prob`` is the free ``2^K`` class distribution; ``map_profile``/``attr_prob`` - the per-person MAP profile and marginal attribute mastery. - - Restriction: every step of an item uses the SAME item Q-vector (shared-Q) — a - restriction of Ma & de la Torre's general per-step ``q_ik`` model. Use - :func:`fit_seq_gdina_qr` for step-distinct attributes; for this result, supply each - item's Q-vector as the union of its steps' required attributes.""" - - s_off: np.ndarray - step_prob: np.ndarray - cat_off: np.ndarray - cat_prob: np.ndarray - max_cat: np.ndarray - k_required: np.ndarray - profile_prob: np.ndarray - map_profile: np.ndarray - attr_prob: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - termination_reason: str - final_loglik_change: float - final_relative_loglik_change: float - stopping_tolerance: float - n_parameters: int - - def item_step_prob(self, i: int) -> np.ndarray: - """Step (continuation) probabilities of item ``i`` as a ``2**K_i x M_i`` array - (row = reduced class ``l``, column = step ``k-1``).""" - m = int(self.max_cat[i]) - return self.step_prob[self.s_off[i] : self.s_off[i + 1]].reshape(-1, m) - - def item_cat_prob(self, i: int) -> np.ndarray: - """Category probabilities of item ``i`` as a ``2**K_i x (M_i+1)`` array - (row = reduced class ``l``, column = category ``x``).""" - m1 = int(self.max_cat[i]) + 1 - return self.cat_prob[self.cat_off[i] : self.cat_off[i + 1]].reshape(-1, m1) - - -def fit_seq_gdina( - responses: np.ndarray, - q_matrix: np.ndarray, - max_iter: int = 500, - tol: float = 1e-6, -) -> SeqGdinaFit: - """Fit the shared-Q sequential G-DINA for ordered polytomous responses (compute in - Rust; Ma & de la Torre, 2016). - - Each ordered category of an item is reached through a sequence of *steps*: the - continuation probability ``s_ik(l) = P(X_i >= k | X_i >= k-1, reduced class l)`` is a - saturated G-DINA over the item's reduced attribute-mastery classes, and the category - probabilities are the sequential decomposition ``P(X_i = k | l) = (prod_{v<=k} - s_iv(l))(1 - s_{i,k+1}(l))`` (stop sentinel ``s_{i,M_i+1} = 0``). The population is a - free profile distribution (as in :func:`fit_gdina`); estimation is marginal-ML EM with - the closed-form saturated step ``s_ik(l) = (expected count reaching >= k) / (expected - count reaching >= k-1)`` in reduced class ``l``. With one step per item (binary data) - it reduces exactly to :func:`fit_gdina`. - - **Restriction (shared item Q-vector).** Every step of item ``i`` is a saturated G-DINA - over the SAME required attributes (row ``i`` of ``q_matrix``). This is a restriction of - Ma & de la Torre's (2016) general per-step ``q_ik`` model, whose headline feature is - *step-distinct* attribute requirements. Use :func:`fit_seq_gdina_qr` for that model; for - this shared-Q entry point, supply each item's Q-vector as the UNION of its steps' - required attributes. - - ``responses`` is a persons x items array of ordered integer categories ``0..M_i`` - (``NaN`` = missing, dropped under MAR); ``M_i`` (the number of steps) is derived as the - maximum observed category of item ``i``, and an item whose observed maximum is 0 (never - leaves the base category) is rejected. ``q_matrix`` is an items x attributes 0/1 array. - Nonzero Q rows/columns are necessary sanity checks, not a certificate of global model - identifiability; supply an identified design and inspect ``converged``. - Convergence uses the absolute observed-data log-likelihood increment and is checked - before another M-step. The stable termination reason, completed M-step count, signed and - relative terminal increments, and requested tolerance are returned explicitly. - - References (APA 7th ed.): - Ma, W., & de la Torre, J. (2016). A sequential cognitive diagnosis model for - polytomous responses. *British Journal of Mathematical and Statistical - Psychology, 69*(3), 253-275. https://doi.org/10.1111/bmsp.12070 - Tutz, G. (1990). Sequential item response models with an ordered response. - *British Journal of Mathematical and Statistical Psychology, 43*(1), 39-55. - https://doi.org/10.1111/j.2044-8317.1990.tb00925.x - de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, - 76*(2), 179-199. https://doi.org/10.1007/s11336-011-9207-7 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_seq_gdina"): - raise RuntimeError("fit_seq_gdina requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - q, n_attributes = _validate_q_matrix_input(q_matrix, "q_matrix", n_items) - if np.isinf(y).any(): - raise ValueError("responses must be finite ordered categories or NaN (missing)") - - max_iter, tol = _validate_stopping_controls(max_iter, tol) - observed = ~np.isnan(y) - yy = np.where(observed, y, 0.0).reshape(-1) - res = core.fit_seq_gdina( - yy, - observed.reshape(-1), - q.reshape(-1), - int(n_persons), - int(n_items), - int(n_attributes), - int(max_iter), - float(tol), - ) - return SeqGdinaFit( - s_off=np.asarray(res["s_off"], dtype=np.int64), - step_prob=np.asarray(res["step_prob"], dtype=np.float64), - cat_off=np.asarray(res["cat_off"], dtype=np.int64), - cat_prob=np.asarray(res["cat_prob"], dtype=np.float64), - max_cat=np.asarray(res["max_cat"], dtype=np.int64), - k_required=np.asarray(res["k_required"], dtype=np.int64), - profile_prob=np.asarray(res["profile_prob"], dtype=np.float64), - map_profile=np.asarray(res["map_profile"], dtype=np.int64), - attr_prob=np.asarray(res["attr_prob"], dtype=np.float64).reshape(n_persons, n_attributes), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - final_relative_loglik_change=float(res["final_relative_loglik_change"]), - stopping_tolerance=float(res["stopping_tolerance"]), - n_parameters=int(res["n_parameters"]), - ) - -@dataclass -class SeqGdinaQrFit: - """Fitted per-step-Q sequential G-DINA (Ma & de la Torre, 2016, restricted-Q). - - Each ordered STEP has its own attribute requirement ``q_ik``. Step probabilities are - STEP-ROW-major: item ``i``'s step ``k`` is step row ``g = step_off[i] + (k-1)`` and owns - ``2 ** step_kq[g]`` reduced classes at ``step_prob[spo[g]:spo[g+1]]`` (``step_kq[g] = - |q_ik|``). Category probabilities are UNION-class-major: item ``i``'s union - ``u_i = OR_k q_ik`` has ``2 ** union_k[i]`` classes and - ``cat_prob[cat_off[i] + uc*(M_i+1) + x] = P(X_i = x | union class uc)``. ``max_cat`` is - ``M_i`` (the number of steps); ``map_profile``/``attr_prob`` the per-person MAP profile and - marginal attribute mastery.""" - - step_off: np.ndarray - spo: np.ndarray - step_prob: np.ndarray - step_kq: np.ndarray - cat_off: np.ndarray - cat_prob: np.ndarray - max_cat: np.ndarray - union_k: np.ndarray - profile_prob: np.ndarray - map_profile: np.ndarray - attr_prob: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - termination_reason: str - final_loglik_change: float - final_relative_loglik_change: float - stopping_tolerance: float - n_parameters: int - - def item_step_prob(self, i: int, k: int) -> np.ndarray: - """Step ``k`` (1-based) of item ``i``: its ``2 ** |q_ik|`` reduced-class continuation - probabilities.""" - g = int(self.step_off[i]) + (k - 1) - return self.step_prob[self.spo[g] : self.spo[g + 1]] - - -def fit_seq_gdina_qr( - responses: np.ndarray, - step_q: np.ndarray, - n_steps, - max_iter: int = 500, - tol: float = 1e-6, -) -> SeqGdinaQrFit: - """Fit the per-step-Q sequential G-DINA (compute in Rust; Ma & de la Torre, 2016). - - The full restricted-Q sequential CDM: each ordered STEP ``k`` of item ``i`` is a saturated - G-DINA over its OWN required attributes ``q_ik`` (step 1 may need attribute A, step 2 need A - and B, etc.). Generalizes :func:`fit_seq_gdina` (which is this with every step of an item - sharing the item's Q-vector). Estimated by marginal-ML EM with the closed-form saturated - step ratio; each step's reduced class is computed directly from the attribute profile, and - the item's union class indexes the category probabilities. - - ``responses`` is a persons x items array of ordered integer categories ``0..M_i`` (``NaN`` = - missing, dropped MAR). ``step_q`` is a ``(sum_i n_steps[i]) x n_attributes`` 0/1 array (row - ``step_off[i] + (k-1)`` is step ``k`` of item ``i``, ``step_off = cumsum(n_steps)``); - ``n_steps[i] = M_i`` is item ``i``'s number of steps, which must equal its maximum observed - category. Every declared step must measure at least one attribute, and every attribute must - be required by at least one step. Those are necessary sanity checks, not a certificate of - global model identifiability; supply an identified design and inspect ``converged``. - - References (APA 7th ed.): - Ma, W., & de la Torre, J. (2016). A sequential cognitive diagnosis model for polytomous - responses. *British Journal of Mathematical and Statistical Psychology, 69*(3), - 253-275. https://doi.org/10.1111/bmsp.12070 - de la Torre, J. (2011). The generalized DINA model framework. *Psychometrika, 76*(2), - 179-199. https://doi.org/10.1007/s11336-011-9207-7 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_seq_gdina_qr"): - raise RuntimeError("fit_seq_gdina_qr requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - raw_steps = np.asarray(n_steps) - if raw_steps.ndim != 1 or raw_steps.shape[0] != n_items: - raise ValueError("n_steps must be a 1-D array of length n_items") - if not np.issubdtype(raw_steps.dtype, np.integer) or np.issubdtype( - raw_steps.dtype, np.bool_ - ): - raise ValueError("n_steps entries must be positive integers") - if np.any(raw_steps < 1): - raise ValueError("n_steps entries must be positive integers") - steps = raw_steps.astype(np.int64, copy=False) - n_step_rows = sum(int(m) for m in steps) - sq = np.asarray(step_q) - if sq.ndim != 2: - raise ValueError("step_q must be a 2-D (sum_i n_steps[i]) x n_attributes array") - if sq.shape[0] != n_step_rows: - raise ValueError("step_q must have sum(n_steps) rows") - sq, n_attributes = _validate_q_matrix_input(step_q, "step_q", n_step_rows) - if np.isinf(y).any(): - raise ValueError("responses must be finite ordered categories or NaN (missing)") - - max_iter, tol = _validate_stopping_controls(max_iter, tol) - observed = ~np.isnan(y) - yy = np.where(observed, y, 0.0).reshape(-1) - res = core.fit_seq_gdina_qr( - yy, - observed.reshape(-1), - sq.reshape(-1), - [int(m) for m in steps], - int(n_persons), - int(n_items), - int(n_attributes), - int(max_iter), - float(tol), - ) - return SeqGdinaQrFit( - step_off=np.asarray(res["step_off"], dtype=np.int64), - spo=np.asarray(res["spo"], dtype=np.int64), - step_prob=np.asarray(res["step_prob"], dtype=np.float64), - step_kq=np.asarray(res["step_kq"], dtype=np.int64), - cat_off=np.asarray(res["cat_off"], dtype=np.int64), - cat_prob=np.asarray(res["cat_prob"], dtype=np.float64), - max_cat=np.asarray(res["max_cat"], dtype=np.int64), - union_k=np.asarray(res["union_k"], dtype=np.int64), - profile_prob=np.asarray(res["profile_prob"], dtype=np.float64), - map_profile=np.asarray(res["map_profile"], dtype=np.int64), - attr_prob=np.asarray(res["attr_prob"], dtype=np.float64).reshape(n_persons, n_attributes), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - final_relative_loglik_change=float(res["final_relative_loglik_change"]), - stopping_tolerance=float(res["stopping_tolerance"]), - n_parameters=int(res["n_parameters"]), - ) diff --git a/python/fast_mlsirm/classification.py b/python/fast_mlsirm/classification.py deleted file mode 100644 index 740720561..000000000 --- a/python/fast_mlsirm/classification.py +++ /dev/null @@ -1,258 +0,0 @@ -"""IRT classification accuracy and consistency for cut-score decisions -(Rudner, 2001, 2005; Lee, 2010, as implemented in CRAN cacIRT). All numeric -work happens in the Rust core; this module only validates and marshals.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Sequence - -import numpy as np - - -@dataclass -class ClassificationResult: - """Classification accuracy/consistency for ``m`` cuts and ``n`` points. - - ``per_cut_*`` treat each cut as its own two-category problem; - ``simultaneous_*`` score the full ``m + 1``-category classification. - ``conditional_*`` are per evaluation point (``m x n`` arrays for the - per-cut versions). Marginals are weighted means over points using the - normalized input weights. Unlike cacIRT, the simultaneous outputs are - always populated; with one cut they equal the per-cut values.""" - - per_cut_accuracy: np.ndarray - per_cut_consistency: np.ndarray - simultaneous_accuracy: float - simultaneous_consistency: float - conditional_accuracy: np.ndarray - conditional_consistency: np.ndarray - conditional_simultaneous_accuracy: np.ndarray - conditional_simultaneous_consistency: np.ndarray - - -@dataclass -class LivingstonLewisResult: - """Livingston-Lewis single-administration classification results. - - ``p_tp``/``p_fp``/``p_tf``/``p_ff`` are the accuracy cells (pass = - observed score at or above the cut; ``t``/``f`` = true pass/fail); - ``p_ii``/``p_ij``/``p_ji``/``p_jj`` are consistency cells over two - hypothetical parallel forms, with ``p_ij == p_ji`` by construction - (single rounded threshold in both blocks — betafunctions' round/floor - mix makes its cells asymmetric; divergence documented in the Rust - core).""" - - effective_test_length: float - etl_rounded: int - lower: float - upper: float - alpha: float - beta: float - used_two_parameter: bool - p_tp: float - p_fp: float - p_tf: float - p_ff: float - accuracy: float - sensitivity: float - specificity: float - p_ii: float - p_ij: float - p_ji: float - p_jj: float - consistency: float - chance_consistency: float - kappa: float - - -_REFERENCES = """References (APA 7th ed.): - Lathrop, Q. N. (2015). *cacIRT: Classification accuracy and - consistency under item response theory* (Version 1.4) - [R package]. https://CRAN.R-project.org/package=cacIRT - Lee, W.-C. (2010). Classification consistency and accuracy for - complex assessments using item response theory. *Journal of - Educational Measurement, 47*(1), 1-17. (as cited in - Lathrop, 2015) - Rudner, L. M. (2001). Computing the expected proportions of - misclassified examinees. *Practical Assessment, Research & - Evaluation, 7*(14). https://doi.org/10.7275/an9m-2035 - Rudner, L. M. (2005). Expected classification accuracy. *Practical - Assessment, Research & Evaluation, 10*(13). - https://doi.org/10.7275/56a5-6b14 - """ - - -def _to_result(res: dict, m: int, n: int) -> ClassificationResult: - def arr(key: str) -> np.ndarray: - return np.asarray(res[key], dtype=np.float64) - - return ClassificationResult( - per_cut_accuracy=arr("per_cut_accuracy"), - per_cut_consistency=arr("per_cut_consistency"), - simultaneous_accuracy=float(res["simultaneous_accuracy"]), - simultaneous_consistency=float(res["simultaneous_consistency"]), - conditional_accuracy=arr("conditional_accuracy").reshape(m, n), - conditional_consistency=arr("conditional_consistency").reshape(m, n), - conditional_simultaneous_accuracy=arr( - "conditional_simultaneous_accuracy" - ), - conditional_simultaneous_consistency=arr( - "conditional_simultaneous_consistency" - ), - ) - - -def _core_or_raise(name: str): - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, name): - raise RuntimeError(f"{name} requires the compiled Rust core") - return core - - -def rudner_classification( - theta: np.ndarray, - sem: np.ndarray, - cutscores: Sequence[float], - weights: np.ndarray | None = None, -) -> ClassificationResult: - """Rudner normal-approximation classification accuracy/consistency - (compute in Rust; Rudner, 2001, 2005, both read in full). - - The observed score of a point with ability ``theta[i]`` is modeled as - normal with mean ``theta[i]`` and standard deviation ``sem[i]`` (Rudner, - 2001, eqs. 1-3; 2005, eq. 1). Accuracy at a cut is the normal mass on - the true side of the cut; consistency is the sum of squared category - masses — a formula that appears in neither Rudner paper and follows the - cacIRT source (Lathrop, 2015), which attributes it to Lee (2010). - Category intervals are left-closed (``theta`` exactly on a cut belongs - to the upper category). ``weights`` defaults to uniform (cacIRT's - person-level ``Rud.P``); quadrature weights give the distribution-level - ``Rud.D`` (normalized internally). In LLM-as-a-Judge quality management - this quantifies how reliably a judge's cut score separates pass from - fail given the calibration's standard errors. - - """ + _REFERENCES - core = _core_or_raise("rudner_classification") - t = np.ascontiguousarray(np.asarray(theta, dtype=np.float64).reshape(-1)) - s = np.ascontiguousarray(np.asarray(sem, dtype=np.float64).reshape(-1)) - w = ( - np.ones_like(t) - if weights is None - else np.ascontiguousarray( - np.asarray(weights, dtype=np.float64).reshape(-1) - ) - ) - cuts = [float(c) for c in cutscores] - res = core.rudner_classification(t, s, w, cuts) - return _to_result(res, len(cuts), t.shape[0]) - - -def lee_classification( - probs: np.ndarray, - cutscores: Sequence[float], - weights: np.ndarray | None = None, -) -> ClassificationResult: - """Lee summed-score classification accuracy/consistency for dichotomous - items (compute in Rust; Lee, 2010, as cited in Lathrop, 2015; mechanics - transcribed from the cacIRT R sources, read line by line). - - ``probs`` is an ``n_points x n_items`` array of correct-response - probabilities strictly inside (0, 1) — model-agnostic: any binary IRF - evaluated at persons or quadrature nodes works. The summed-score - distribution per point comes from the Lord-Wingersky (1984) recursion; - raw cut ``c`` splits scores at ``ceil(c)`` and a point's true category - is the raw-score interval containing its expected true score - (left-closed; cacIRT's ``Lee.D`` alone is right-closed — divergence - documented in the Rust core). ``weights`` defaults to uniform. - - """ + _REFERENCES - core = _core_or_raise("lee_classification") - p = np.ascontiguousarray(np.asarray(probs, dtype=np.float64)) - if p.ndim != 2: - raise ValueError("probs must be a 2-D points x items array") - n_points, n_items = p.shape - w = ( - np.ones(n_points) - if weights is None - else np.ascontiguousarray( - np.asarray(weights, dtype=np.float64).reshape(-1) - ) - ) - cuts = [float(c) for c in cutscores] - res = core.lee_classification( - p.reshape(-1), int(n_points), int(n_items), w, cuts - ) - return _to_result(res, len(cuts), n_points) - - -def livingston_lewis( - scores: np.ndarray, - reliability: float, - min_score: float, - max_score: float, - cut: float, -) -> LivingstonLewisResult: - """Livingston-Lewis classification accuracy/consistency from a single - test administration (compute in Rust; Livingston & Lewis, 1995, as - implemented in CRAN betafunctions 1.9.0 ``LL.CA``, read line by line — - the original article was not consulted directly). - - Proportional true scores are modeled as a four-parameter beta fitted by - the method of moments (Hanson, 1991, as cited in Haakstad, 2022), with - a two-parameter [0, 1] fail-safe when the four-parameter fit is out of - bounds or numerically invalid; the observed-score model is binomial - with ``N = round(effective test length)``. Pass = observed score >= - ``cut``; sensitivity/specificity follow this pass-positive orientation - (betafunctions labels *fail* as positive, so its sensitivity is this - function's specificity). ``reliability`` is any single-administration - estimate (e.g. alpha). In LLM-as-a-Judge quality management this - estimates how accurately and repeatably a judge's cut score classifies - outputs given the score reliability. Sensitivity, specificity, and - kappa are ``NaN`` when their margin or chance denominator vanishes - (e.g. a cut outside the fitted beta support). - - """ + _REFERENCES + """ - Haakstad, H. (2022). *betafunctions: Functions for working with - two- and four-parameter beta probability distributions and - psychometric analysis of classifications* (Version 1.9.0) - [R package]. https://CRAN.R-project.org/package=betafunctions - Hanson, B. A. (1991). *Method of moments estimates for the - four-parameter beta compound binomial model and the calculation - of classification consistency indexes* (ACT Research Report - 91-5). (as cited in Haakstad, 2022) - Livingston, S. A., & Lewis, C. (1995). Estimating the consistency - and accuracy of classifications based on test scores. *Journal - of Educational Measurement, 32*(2), 179-197. (as cited in - Haakstad, 2022) - """ - core = _core_or_raise("livingston_lewis") - x = np.ascontiguousarray(np.asarray(scores, dtype=np.float64).reshape(-1)) - res = core.livingston_lewis( - x, float(reliability), float(min_score), float(max_score), float(cut) - ) - return LivingstonLewisResult( - effective_test_length=float(res["effective_test_length"]), - etl_rounded=int(res["etl_rounded"]), - lower=float(res["lower"]), - upper=float(res["upper"]), - alpha=float(res["alpha"]), - beta=float(res["beta"]), - used_two_parameter=bool(res["used_two_parameter"]), - p_tp=float(res["p_tp"]), - p_fp=float(res["p_fp"]), - p_tf=float(res["p_tf"]), - p_ff=float(res["p_ff"]), - accuracy=float(res["accuracy"]), - sensitivity=float(res["sensitivity"]), - specificity=float(res["specificity"]), - p_ii=float(res["p_ii"]), - p_ij=float(res["p_ij"]), - p_ji=float(res["p_ji"]), - p_jj=float(res["p_jj"]), - consistency=float(res["consistency"]), - chance_consistency=float(res["chance_consistency"]), - kappa=float(res["kappa"]), - ) diff --git a/python/fast_mlsirm/cli.py b/python/fast_mlsirm/cli.py index c57047ab8..ef2c9e8c8 100644 --- a/python/fast_mlsirm/cli.py +++ b/python/fast_mlsirm/cli.py @@ -17,26 +17,11 @@ response_process_fit_diagnostics, ) from .fit import fit -from .io import ( - _atomic_write_text, - _load_json_bounded, - _load_numpy_bounded, - load_factor_csv, - load_params, - save_dimensionality_diagnostics, - save_fit_diagnostics, - save_fit_result, - save_simulation, -) +from .io import load_factor_csv, load_params, save_dimensionality_diagnostics, save_fit_diagnostics, save_fit_result, save_simulation from .report import render_diagnostics_report from .simulation import simulate -MAX_CANDIDATE_COUNT = 128 -MAX_CANDIDATE_ELEMENTS = 50_000_000 -MAX_CANDIDATE_BYTES = 512 * 1024 * 1024 - - def _add_json_flag(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--json", @@ -45,34 +30,6 @@ def _add_json_flag(parser: argparse.ArgumentParser) -> None: ) -def _load_fit_context( - params_path: str | Path, -) -> tuple[str | None, dict | None, str | None]: - """Recover estimator, population, and convergence metadata beside params.""" - path = Path(params_path) - summary_path = path.with_name("fit_summary.json") - if not summary_path.exists(): - return None, None, None - summary = _load_json_bounded(summary_path, source="fit summary") - optimizer = str(summary.get("optimizer", "")).lower() - estimator = "mmle" if optimizer.startswith("mmle") else "jmle" if optimizer else None - raw_status = summary.get("convergence_status") - convergence_status = ( - str(raw_status).strip().lower() if raw_status is not None else None - ) - if not convergence_status: - convergence_status = None - population = summary.get("population") - if population is not None: - population = dict(population) - with _load_numpy_bounded(path) as arrays: - if "pop_mu" in arrays: - population["mu"] = np.asarray(arrays["pop_mu"], dtype=float) - if "pop_sigma" in arrays: - population["sigma"] = np.asarray(arrays["pop_sigma"], dtype=float) - return estimator, population, convergence_status - - def _progress(args: argparse.Namespace, message: str) -> None: if not getattr(args, "json", False): print(message) @@ -91,7 +48,7 @@ def _output_file(run_dir: str, filename: str) -> str: def _load_response_and_factors(responses_path: str, factors_path: str) -> tuple[np.ndarray, np.ndarray]: - responses = _load_numpy_bounded(responses_path) + responses = np.load(responses_path, allow_pickle=False) factors = load_factor_csv(factors_path) _validate_response_and_factors(responses, factors) return responses, factors @@ -139,13 +96,6 @@ def _main(argv: list[str] | None = None) -> int: fit_cmd.add_argument("--factors", required=True, help="Path to the item factors CSV file.") fit_cmd.add_argument("--model", default="MLS2PLM", help="Model type to fit (default: MLS2PLM).") fit_cmd.add_argument("--latent-dim", type=int, default=2, help="Latent dimensionality for person traits (default: 2).") - fit_cmd.add_argument("--estimator", choices=["jmle", "mmle"], default="jmle", help="Estimator: penalized joint MLE, or marginal MLE via EM (persons integrated out; default: jmle).") - fit_cmd.add_argument("--group-id", help="Optional .npy person group IDs: estimation-level multigroup calibration (estimator=mmle; group 0 is the N(0,1) reference).") - fit_cmd.add_argument("--cluster-id", help="Optional .npy person cluster IDs: estimation-level multilevel random intercept (estimator=mmle).") - fit_cmd.add_argument("--q-theta", type=int, default=21, help="Marginal estimator: Gauss-Hermite nodes per trait dimension (default: 21).") - fit_cmd.add_argument("--q-xi", type=int, default=11, help="Marginal estimator: Gauss-Hermite nodes per latent-space axis (default: 11).") - fit_cmd.add_argument("--q-u", type=int, default=15, help="Marginal estimator: Gauss-Hermite nodes for the multilevel intercept (default: 15).") - fit_cmd.add_argument("--tolerance", type=float, default=1e-6, help="Convergence tolerance (default: 1e-6).") fit_cmd.add_argument("--optimizer", choices=["adam", "lbfgs", "adam_lbfgs"], default="adam_lbfgs", help="Optimizer to use (default: adam_lbfgs).") fit_cmd.add_argument("--max-iter", type=int, default=100, help="Maximum number of iterations for the optimizer (default: 100).") fit_cmd.add_argument("--n-restarts", type=int, default=1, help="Number of random restarts (default: 1).") @@ -155,16 +105,6 @@ def _main(argv: list[str] | None = None) -> int: fit_cmd.add_argument("--out", required=True, help="Directory path to save the fitted parameters.") _add_json_flag(fit_cmd) - score_cmd = sub.add_parser( - "score", - help="Score new respondents against a frozen serving bundle (EAP).", - description="Score new respondents against a frozen serving bundle (EAP, item parameters fixed).", - ) - score_cmd.add_argument("--bundle", required=True, help="Path to a serving bundle JSON (see fast_mlsirm.serving.export_serving_bundle).") - score_cmd.add_argument("--responses", required=True, help="Responses: a JSON file (dict or list of dicts mapping item code -> 0/1) or a .npy matrix in bundle item order (NaN = missing).") - score_cmd.add_argument("--out", help="Optional path for the scores JSON (default: stdout).") - _add_json_flag(score_cmd) - diagnose = sub.add_parser( "diagnose-fit", help="Compute item, person, and model fit diagnostics for fitted parameters.", @@ -174,21 +114,8 @@ def _main(argv: list[str] | None = None) -> int: diagnose.add_argument("--factors", required=True, help="Path to the item factors CSV file.") diagnose.add_argument("--params", required=True, help="Path to fitted params.npz.") diagnose.add_argument("--model", default="MLS2PLM", help="Model type used for the fitted parameters (default: MLS2PLM).") - diagnose.add_argument( - "--estimator", - choices=["jmle", "cmle", "mmle"], - help="Estimator used for calibration; inferred from fit_summary.json when omitted.", - ) diagnose.add_argument("--group-id", help="Optional .npy person group IDs for multigroup summaries.") diagnose.add_argument("--cluster-id", help="Optional .npy person cluster IDs for multilevel summaries.") - diagnose.add_argument( - "--limited-information", - action="store_true", - help=( - "Also compute M2, RMSEA, SRMR, CFI, and TLI. Multiple-group fits " - "use stacked moments; multilevel fits use cluster-robust covariance." - ), - ) diagnose.add_argument("--out", required=True, help="Directory path to save fit_diagnostics.json.") _add_json_flag(diagnose) @@ -322,60 +249,13 @@ def _main(argv: list[str] | None = None) -> int: }, ) - if args.command == "score": - from .serving import load_serving_bundle, score_respondents - - _progress(args, "⏳ Scoring respondents against the serving bundle...") - try: - bundle = load_serving_bundle(args.bundle) - if args.responses.endswith(".npy"): - payload = _load_numpy_bounded(args.responses) - else: - payload = _load_json_bounded( - args.responses, - source="response JSON", - ) - scores = score_respondents(bundle, payload) - except (ValueError, OSError, json.JSONDecodeError) as e: - if os.environ.get("FAST_MLSIRM_DEBUG"): - raise - print(f"❌ Error: Scoring failed - {str(e)}", file=sys.stderr) - return 1 - if args.out: - _atomic_write_text( - args.out, json.dumps(scores, ensure_ascii=False, indent=2) - ) - return _complete( - args, - json.dumps(scores, ensure_ascii=False, indent=2) - if not args.out - else f"✅ Scores written to {args.out}", - { - "command": "score", - "status": "ok", - "n_scored": len(scores), - "scores": scores, - }, - ) - if args.command == "diagnose-fit": _progress(args, f"⏳ Computing {args.model} fit diagnostics...") try: responses, factors = _load_response_and_factors(args.responses, args.factors) params = load_params(args.params) - saved_estimator, population, convergence_status = _load_fit_context(args.params) group_id = _load_optional_npy(args.group_id) cluster_id = _load_optional_npy(args.cluster_id) - if ( - args.limited_information - and convergence_status is not None - and convergence_status != "converged" - ): - raise ValueError( - "limited-information diagnostics require converged parameters; " - "the fitted model did not converge " - f"(status={convergence_status})" - ) except FileNotFoundError as e: if os.environ.get("FAST_MLSIRM_DEBUG"): raise @@ -399,10 +279,6 @@ def _main(argv: list[str] | None = None) -> int: model=args.model, group_id=group_id, cluster_id=cluster_id, - include_m2=args.limited_information, - estimator=args.estimator or saved_estimator, - population=population, - convergence_status=convergence_status, ) save_fit_diagnostics(diagnostics, args.out) return _complete( @@ -470,8 +346,8 @@ def _main(argv: list[str] | None = None) -> int: if args.command == "diagnose-response-process": _progress(args, f"⏳ Computing {args.item_type} {args.response_process} fit diagnostics...") try: - responses = _load_numpy_bounded(args.responses) - probabilities = _load_numpy_bounded(args.probabilities) + responses = np.load(args.responses, allow_pickle=False) + probabilities = np.load(args.probabilities, allow_pickle=False) group_id = _load_optional_npy(args.group_id) cluster_id = _load_optional_npy(args.cluster_id) except FileNotFoundError as e: @@ -515,7 +391,7 @@ def _main(argv: list[str] | None = None) -> int: if args.command == "diagnose-response-candidates": _progress(args, f"⏳ Comparing {args.item_type} {args.response_process} response candidates...") try: - responses = _load_numpy_bounded(args.responses) + responses = np.load(args.responses, allow_pickle=False) candidate_probabilities = _load_candidate_probabilities(args.candidate) except FileNotFoundError as e: if os.environ.get("FAST_MLSIRM_DEBUG"): @@ -557,7 +433,7 @@ def _main(argv: list[str] | None = None) -> int: if args.command == "diagnose-fixed-item-calibration": _progress(args, "⏳ Scoring fixed-item calibration candidates...") try: - responses = _load_numpy_bounded(args.responses) + responses = np.load(args.responses, allow_pickle=False) candidate_probabilities = _load_candidate_probabilities(args.candidate) fixed_items = _load_optional_npy(args.fixed_items) except FileNotFoundError as e: @@ -658,19 +534,12 @@ def _main(argv: list[str] | None = None) -> int: model=args.model, latent_dim=args.latent_dim, optimizer=args.optimizer, - estimator=args.estimator, max_iter=args.max_iter, n_restarts=args.n_restarts, seed=args.seed, backend=args.backend, rust_device=args.rust_device, - q_theta=args.q_theta, - q_xi=args.q_xi, - q_u=args.q_u, - tolerance=args.tolerance, ), - group_id=_load_optional_npy(args.group_id), - cluster_id=_load_optional_npy(args.cluster_id), ) except ValueError as e: if os.environ.get("FAST_MLSIRM_DEBUG"): @@ -719,35 +588,18 @@ def main(argv: list[str] | None = None) -> int: def _load_optional_npy(path: str | None) -> np.ndarray | None: - return None if path is None else _load_numpy_bounded(path) + return None if path is None else np.load(path, allow_pickle=False) def _load_candidate_probabilities(specs: list[str]) -> dict[str, np.ndarray]: - if len(specs) > MAX_CANDIDATE_COUNT: - raise ValueError( - f"candidate count exceeds the {MAX_CANDIDATE_COUNT}-candidate limit" - ) candidates = {} - total_elements = 0 - total_bytes = 0 for spec in specs: label, path = spec.split("=", 1) if "=" in spec else (Path(spec).stem, spec) if not label: raise ValueError("candidate label must not be empty") if label in candidates: raise ValueError(f"duplicate candidate label: {label}") - candidate = _load_numpy_bounded(path) - if not isinstance(candidate, np.ndarray): - candidate.close() - raise ValueError("candidate probability inputs must be single .npy arrays") - total_elements += int(candidate.size) - total_bytes += int(candidate.nbytes) - if ( - total_elements > MAX_CANDIDATE_ELEMENTS - or total_bytes > MAX_CANDIDATE_BYTES - ): - raise ValueError("candidate probability inputs exceed the aggregate size limit") - candidates[label] = candidate + candidates[label] = np.load(path, allow_pickle=False) return candidates diff --git a/python/fast_mlsirm/config.py b/python/fast_mlsirm/config.py index b2b665db0..cbfdd8d99 100644 --- a/python/fast_mlsirm/config.py +++ b/python/fast_mlsirm/config.py @@ -1,43 +1,17 @@ from __future__ import annotations -import math -import operator from dataclasses import dataclass from .backend import normalize_backend, normalize_device -VALID_MODELS = {"MIRT", "MLS2PLM", "MLSRM", "ULS2PLM", "ULSRM", "BIFAC2PLM"} +VALID_MODELS = {"MIRT", "MLS2PLM", "MLSRM", "ULS2PLM", "ULSRM"} VALID_OPTIMIZERS = {"adam", "lbfgs", "adam_lbfgs"} # Estimation methods. "jmle" (penalized joint MLE) is the legacy default; "mmle" # (marginal MLE via EM) is robust to missing data. "em"/"bayes" are reserved # for future milestones (the driver raises NotImplementedError for now). VALID_ESTIMATORS = {"jmle", "mmle", "em", "bayes"} -# Hard upper bounds on caller-supplied sizes, to reject sparse/oversized -# configurations that would force huge allocations before any real work -# (defense against memory-exhaustion DoS from untrusted fit settings). -# latent_dim: the joint grid is q_xi**latent_dim and Halton QMC supports -# only len(_HALTON_PRIMES) = 6 axes, so 8 is already generous. -MAX_LATENT_DIM = 8 -MAX_XI_POINTS = 1_000_000 -MAX_MAX_ITER = 100_000 -MAX_POLYTOMOUS_CATEGORIES = 64 -MAX_RESTARTS = 1_000 -MAX_M_STEPS = 1_000 -# L-BFGS keeps two full parameter vectors per history entry. Values above 100 -# are outside practical limited-memory use and amplify caller-controlled RAM. -MAX_LBFGS_HISTORY = 100 -# Aggregate optimizer work (max_iter x n_restarts) across a single fit; the -# per-field caps still permit 1e8 iterations together, so bound the product. -MAX_AGGREGATE_ITERS = 10_000_000 -# Simulation config bounds: reject dimensions whose dense response matrix -# (n_persons x n_items float64) would exhaust memory before any real work. -MAX_SIM_PERSONS = 5_000_000 -MAX_SIM_DIMS = 1_000 -MAX_SIM_ITEMS_PER_DIM = 10_000 -MAX_SIM_CELLS = 200_000_000 - @dataclass(frozen=True) class MLS2PLMConfig: @@ -55,47 +29,16 @@ def n_items(self) -> int: return self.n_dims * self.items_per_dim def validate(self) -> None: - for name, value in ( - ("n_persons", self.n_persons), - ("n_dims", self.n_dims), - ("items_per_dim", self.items_per_dim), - ("latent_dim", self.latent_dim), - ): - if isinstance(value, bool): - raise ValueError(f"{name} must be an integer") - try: - operator.index(value) - except TypeError as exc: - raise ValueError(f"{name} must be an integer") from exc if self.n_persons < 1: raise ValueError("n_persons must be >= 1") if self.n_dims < 1: raise ValueError("n_dims must be >= 1") if self.items_per_dim < 1: raise ValueError("items_per_dim must be >= 1") - if self.n_persons > MAX_SIM_PERSONS: - raise ValueError(f"n_persons must be <= {MAX_SIM_PERSONS}") - if self.n_dims > MAX_SIM_DIMS: - raise ValueError(f"n_dims must be <= {MAX_SIM_DIMS}") - if self.items_per_dim > MAX_SIM_ITEMS_PER_DIM: - raise ValueError(f"items_per_dim must be <= {MAX_SIM_ITEMS_PER_DIM}") - if self.n_persons * self.n_items > MAX_SIM_CELLS: - raise ValueError( - f"n_persons x n_items ({self.n_persons * self.n_items}) exceeds the " - f"{MAX_SIM_CELLS}-cell simulation budget" - ) if self.latent_dim < 1: raise ValueError("latent_dim must be >= 1") - if self.latent_dim > MAX_LATENT_DIM: - raise ValueError(f"latent_dim must be <= {MAX_LATENT_DIM}") if not (-1.0 / max(self.n_dims - 1, 1) < self.phi < 1.0): raise ValueError("phi must produce a positive-definite equicorrelation matrix") - try: - gamma_is_finite = math.isfinite(self.gamma) - except TypeError as exc: - raise ValueError("gamma must be finite") from exc - if not gamma_is_finite: - raise ValueError("gamma must be finite") if self.gamma < 0: raise ValueError("gamma must be >= 0") if self.dtype not in {"float32", "float64"}: @@ -140,26 +83,6 @@ class FitConfig: # the identical CPU path. Ignored when backend == "numpy". rust_device: str = "auto" penalty: PenaltyConfig = PenaltyConfig() - # Marginal (MMLE) estimator quadrature: Gauss-Hermite nodes per trait - # dimension, per latent-space axis (tensor grid of q_xi**latent_dim), and - # for the multilevel random intercept. Supported sizes: 7/11/15/21/31/41. - q_theta: int = 21 - q_xi: int = 11 - q_u: int = 15 - # Fisher-preconditioned ascent steps per item per M-step (marginal EM). - m_steps: int = 4 - # Latent-space integration rule for the marginal estimator: "gh" (tensor - # Gauss-Hermite, q_xi per axis), "qmc" (Halton QMC-EM, Jank 2005) or "mc" - # (seeded Monte Carlo EM, Wei & Tanner 1990). - xi_rule: str = "gh" - # Point count for the qmc/mc rules; xi_seed is the Halton random shift / - # Monte Carlo seed (deterministic, mirrored across backends). - xi_points: int = 256 - xi_seed: int = 0 - # Zero-inflated mixture (marginal estimator): a structural-zero latent - # class produces all-zero patterns with probability pi (estimated by EM); - # cf. the ZI count-model guidance of Perumean-Chaney et al. (2013). - zero_inflation: bool = False def normalized_model(self) -> str: return self.model.upper() @@ -168,65 +91,21 @@ def validate(self) -> None: model = self.normalized_model() if model not in VALID_MODELS: raise ValueError(f"model must be one of {sorted(VALID_MODELS)}") - if not (1 <= self.latent_dim <= MAX_LATENT_DIM): - raise ValueError(f"latent_dim must be >= 1 and <= {MAX_LATENT_DIM}") + if self.latent_dim < 1: + raise ValueError("latent_dim must be >= 1") if self.optimizer not in VALID_OPTIMIZERS: raise ValueError(f"optimizer must be one of {sorted(VALID_OPTIMIZERS)}") if self.estimator not in VALID_ESTIMATORS: raise ValueError(f"estimator must be one of {sorted(VALID_ESTIMATORS)}") - if isinstance(self.lbfgs_history, bool): - raise ValueError("lbfgs_history must be an integer") - try: - lbfgs_history = operator.index(self.lbfgs_history) - except TypeError as exc: - raise ValueError("lbfgs_history must be an integer") from exc - if not (1 <= lbfgs_history <= MAX_LBFGS_HISTORY): - raise ValueError( - f"lbfgs_history must be >= 1 and <= {MAX_LBFGS_HISTORY}" - ) - if not (1 <= self.max_iter <= MAX_MAX_ITER): - raise ValueError(f"max_iter must be >= 1 and <= {MAX_MAX_ITER}") - if not (1 <= self.n_restarts <= MAX_RESTARTS): - raise ValueError(f"n_restarts must be >= 1 and <= {MAX_RESTARTS}") - if self.max_iter * self.n_restarts > MAX_AGGREGATE_ITERS: - raise ValueError( - f"max_iter x n_restarts ({self.max_iter * self.n_restarts}) exceeds the " - f"aggregate optimizer-work budget {MAX_AGGREGATE_ITERS}" - ) - # non-finite floats (NaN/Inf) slip past bare `<= 0` comparisons - if not math.isfinite(self.learning_rate) or self.learning_rate <= 0: - raise ValueError("learning_rate must be > 0 and finite") - if not math.isfinite(self.init_gamma) or self.init_gamma <= 0: - raise ValueError("init_gamma must be > 0 and finite") - if not math.isfinite(self.eps_distance) or self.eps_distance <= 0: - raise ValueError("eps_distance must be > 0 and finite") - if not math.isfinite(self.tolerance) or self.tolerance <= 0: - raise ValueError("tolerance must be > 0 and finite") - if self.gradient_clip is not None and ( - not math.isfinite(self.gradient_clip) or self.gradient_clip <= 0 - ): - raise ValueError("gradient_clip must be > 0 and finite, or None") - supported_q = {7, 11, 15, 21, 31, 41} - for name in ("q_theta", "q_xi", "q_u"): - if getattr(self, name) not in supported_q: - raise ValueError(f"{name} must be one of {sorted(supported_q)}") - if not (1 <= self.m_steps <= MAX_M_STEPS): - raise ValueError(f"m_steps must be >= 1 and <= {MAX_M_STEPS}") - if self.xi_rule.lower() not in {"gh", "qmc", "halton", "mc", "montecarlo", "monte-carlo"}: - raise ValueError("xi_rule must be one of ['gh', 'qmc', 'mc']") - for name in ("xi_points", "xi_seed"): - value = getattr(self, name) - if isinstance(value, bool): - raise ValueError(f"{name} must be an integer") - try: - operator.index(value) - except TypeError as exc: - raise ValueError(f"{name} must be an integer") from exc - xi_points = operator.index(self.xi_points) - xi_seed = operator.index(self.xi_seed) - if not (1 <= xi_points <= MAX_XI_POINTS): - raise ValueError(f"xi_points must be >= 1 and <= {MAX_XI_POINTS}") - if not (0 <= xi_seed <= (1 << 64) - 1): - raise ValueError("xi_seed must fit an unsigned 64-bit integer") + if self.max_iter < 1: + raise ValueError("max_iter must be >= 1") + if self.n_restarts < 1: + raise ValueError("n_restarts must be >= 1") + if self.learning_rate <= 0: + raise ValueError("learning_rate must be > 0") + if self.init_gamma <= 0: + raise ValueError("init_gamma must be > 0") + if self.eps_distance <= 0: + raise ValueError("eps_distance must be > 0") normalize_backend(self.backend) normalize_device(self.rust_device) diff --git a/python/fast_mlsirm/crm.py b/python/fast_mlsirm/crm.py deleted file mode 100644 index 3769583b1..000000000 --- a/python/fast_mlsirm/crm.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Continuous Response Model (Samejima, 1973): item response theory for a -continuous bounded response, estimated by marginal-ML EM in the Rust core.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER - - -@dataclass -class CrmFit: - """Fitted continuous response model (Samejima, 1973). - - The logit of the response is conditionally normal and linear in the trait: - ``logit(Z_ij) | theta_j ~ N(slope_i * theta_j + intercept_i, resid_sd_i^2)`` with - ``theta ~ N(0, 1)``. ``slope``/``intercept``/``resid_sd`` are the working item - parameters; ``discrimination = slope / resid_sd`` and - ``difficulty = -intercept / slope`` are the classic Samejima ``(alpha, b)``. - ``theta`` is the per-person EAP trait score.""" - - slope: np.ndarray - intercept: np.ndarray - resid_sd: np.ndarray - discrimination: np.ndarray - difficulty: np.ndarray - theta: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - n_parameters: int - termination_reason: str = "unknown" - final_delta: float = float("nan") - stopping_tolerance: float = float("nan") - - -def fit_crm( - responses: np.ndarray, - q_theta: int = 41, - max_iter: int = 500, - tol: float = 1e-6, -) -> CrmFit: - """Fit the continuous response model (compute in Rust; Samejima, 1973). - - Samejima's CRM is the limit of the graded response model as the number of ordered - categories grows without bound, for an item scored on a *continuous* bounded scale. - Operationally (Wang & Zeng, 1998), the logit of a response ``Z in (0, 1)`` is - conditionally normal and linear in the latent trait: - ``logit(Z_ij) | theta_j ~ N(a_i theta_j + d_i, sigma_i^2)``, ``theta ~ N(0, 1)``. - The item slope ``a_i``, intercept ``d_i``, and residual sd ``sigma_i`` map to the - classic ``(discrimination alpha_i = a_i/sigma_i, difficulty b_i = -d_i/a_i, - scale gamma_i = a_i)``. Estimated by marginal-ML EM with a Gauss-Hermite - quadrature over the trait and a closed-form weighted-least-squares item M-step. - - ``responses`` is a persons x items array of values in the open interval ``(0, 1)`` - (values are clamped to ``[eps, 1-eps]`` before the logit transform; ``NaN`` marks a - missing cell, dropped under a missing-at-random assumption). The trait is - identified up to a global sign, resolved so the mean slope is non-negative. - Convergence requires a finite, non-decreasing observed-data log-likelihood and - a signed final increment no larger than ``tol * (1 + abs(previous_loglik))``; - the returned fit records the termination reason and effective stopping metric - (Dempster et al., 1977; Wu, 1983). - - References (APA 7th ed.): - Samejima, F. (1973). Homogeneous case of the continuous response model. - *Psychometrika, 38*(2), 203-219. https://doi.org/10.1007/BF02291114 - Wang, T., & Zeng, L. (1998). Item parameter estimation for a continuous - response model using an EM algorithm. *Applied Psychological Measurement, - 22*(4), 333-344. https://doi.org/10.1177/014662169802200402 - Dempster, A. P., Laird, N. M., & Rubin, D. B. (1977). Maximum likelihood - from incomplete data via the EM algorithm. *Journal of the Royal - Statistical Society: Series B (Methodological), 39*(1), 1-22. - https://doi.org/10.1111/j.2517-6161.1977.tb01600.x - Wu, C. F. J. (1983). On the convergence properties of the EM algorithm. - *The Annals of Statistics, 11*(1), 95-103. - https://doi.org/10.1214/aos/1176346060 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_crm"): - raise RuntimeError("fit_crm requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_persons == 0 or n_items == 0: - raise ValueError("responses must contain at least one person and one item") - if not 1 <= max_iter <= MAX_MAX_ITER: - raise ValueError(f"max_iter must be in 1..={MAX_MAX_ITER}") - if not np.isfinite(tol) or tol <= 0.0: - raise ValueError("tol must be finite and positive") - - observed = np.isfinite(y) - yy = np.where(observed, y, 0.5).reshape(-1) - res = core.fit_crm( - yy, - observed.reshape(-1), - int(n_persons), - int(n_items), - int(q_theta), - int(max_iter), - float(tol), - ) - return CrmFit( - slope=np.asarray(res["slope"], dtype=np.float64), - intercept=np.asarray(res["intercept"], dtype=np.float64), - resid_sd=np.asarray(res["resid_sd"], dtype=np.float64), - discrimination=np.asarray(res["discrimination"], dtype=np.float64), - difficulty=np.asarray(res["difficulty"], dtype=np.float64), - theta=np.asarray(res["theta"], dtype=np.float64), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - n_parameters=int(res["n_parameters"]), - termination_reason=str(res["termination_reason"]), - final_delta=float(res["final_delta"]), - stopping_tolerance=float(res["stopping_tolerance"]), - ) diff --git a/python/fast_mlsirm/detect.py b/python/fast_mlsirm/detect.py deleted file mode 100644 index a9ab2bccc..000000000 --- a/python/fast_mlsirm/detect.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Confirmatory DETECT dimensionality analysis (Zhang & Stout, 1999, as -implemented by CRAN sirt's sum-score conditioning path). All numeric work -happens in the Rust core; this module only validates and marshals arrays.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass -class DetectResult: - """Confirmatory DETECT indices for a known item partition. - - ``detect``/``madcov100``/``mcov100`` are on the x100 scale of the sirt - package; ``assi`` and ``ratio`` are unscaled in ``[-1, 1]``. ``pair_i``, - ``pair_j``, ``ccov`` give the bias-corrected conditional covariance per - item pair (``i < j``, row-major). Interpretation conventions quoted in - the sirt documentation (as cited there from Jang & Roussos, 2007, and - Zhang, 2007): DETECT < 0.2 suggests essential unidimensionality, >= 1.0 - sizeable multidimensionality relative to the supplied partition.""" - - detect: float - assi: float - ratio: float - madcov100: float - mcov100: float - n_pairs: int - pair_i: np.ndarray - pair_j: np.ndarray - ccov: np.ndarray - - -def detect_analysis( - responses: np.ndarray, - cluster: np.ndarray, -) -> DetectResult: - """Confirmatory DETECT analysis of a binary response matrix (compute in - Rust; Zhang & Stout, 1999, as cited in Robitzsch, 2024). - - Estimates the pairwise conditional covariances of the items given the - raw total score and the pair rest score (bias-corrected average of the - two conditionings), then aggregates them against the supplied item - clustering into the DETECT, ASSI, RATIO, MADCOV100, and MCOV100 indices. - Formulas were transcribed from the CRAN ``sirt`` R sources (read line by - line); the original DETECT papers are paywalled and cited only through - the sirt documentation. This matches sirt's explicit - ``ccov.np(use_sum_score=TRUE, scale_score=FALSE)`` path; the kernel- - smoothed default, missing data (sirt pairwise-deletes), exploratory - cluster search, and polytomous DETECT are not implemented. - - In LLM-as-a-Judge item-quality management this diagnoses whether a - rubric partition of judge items behaves as distinct dimensions (clearly - positive DETECT) or as a single dimension (DETECT near zero). - - ``responses`` is a complete ``persons x items`` array with entries - exactly 0 or 1. ``cluster`` assigns each item an integer label; labels - are opaque (compared for equality only). - - References (APA 7th ed.): - Jang, E. E., & Roussos, L. (2007). An investigation into the - dimensionality of TOEFL using conditional covariance-based - nonparametric approach. *Journal of Educational Measurement, - 44*(1), 1-21. (as cited in Robitzsch, 2024) - Robitzsch, A. (2024). *sirt: Supplementary item response theory - models* (R package). https://CRAN.R-project.org/package=sirt - Zhang, J., & Stout, W. (1999). The theoretical DETECT index of - dimensionality and its application to approximate simple - structure. *Psychometrika, 64*(2), 213-249. (as cited in - Robitzsch, 2024) - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "detect_analysis"): - raise RuntimeError("detect_analysis requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_persons < 2 or n_items < 2: - raise ValueError("responses needs at least 2 persons and 2 items") - if not np.all(np.isfinite(y)): - raise ValueError("responses must be complete (no missing values)") - - c = np.asarray(cluster).reshape(-1) - if c.shape[0] != n_items: - raise ValueError("cluster must assign one label per item") - I64_MAX = np.iinfo(np.int64).max - I64_MIN = np.iinfo(np.int64).min - if not np.issubdtype(c.dtype, np.integer): - cf = np.asarray(cluster, dtype=np.float64).reshape(-1) - if not np.all(np.isfinite(cf)) or np.any(cf != np.round(cf)): - raise ValueError("cluster labels must be integers") - # Reject labels outside i64 before casting: astype(np.int64) on an - # out-of-range float silently wraps/saturates, which would collapse - # distinct labels and change the partition (equality-only contract). - if np.any(cf < -(2.0**63)) or np.any(cf >= 2.0**63): - raise ValueError("cluster labels must fit in a 64-bit integer") - c = cf.astype(np.int64) - else: - # Also enforce i64 bounds for integer dtypes that can exceed i64 - # range. Unsigned types are always >= 0, so only the upper bound can - # be violated; avoid comparing unsigned arrays to negative values to - # prevent NumPy mixed-signedness promotion surprises. Signed types - # narrower than or equal to i64 are always in range. - if np.issubdtype(c.dtype, np.unsignedinteger): - if np.any(c > I64_MAX): - raise ValueError("cluster labels must fit in a 64-bit integer") - elif np.any(c < I64_MIN) or np.any(c > I64_MAX): - raise ValueError("cluster labels must fit in a 64-bit integer") - c = c.astype(np.int64) - - res = core.detect_analysis( - y.reshape(-1), int(n_persons), int(n_items), [int(v) for v in c] - ) - return DetectResult( - detect=float(res["detect"]), - assi=float(res["assi"]), - ratio=float(res["ratio"]), - madcov100=float(res["madcov100"]), - mcov100=float(res["mcov100"]), - n_pairs=int(res["n_pairs"]), - pair_i=np.asarray(res["pair_i"], dtype=np.int64), - pair_j=np.asarray(res["pair_j"], dtype=np.int64), - ccov=np.asarray(res["ccov"], dtype=np.float64), - ) diff --git a/python/fast_mlsirm/diagnostics.py b/python/fast_mlsirm/diagnostics.py index b8f9c3ee8..f719692a2 100644 --- a/python/fast_mlsirm/diagnostics.py +++ b/python/fast_mlsirm/diagnostics.py @@ -2,13 +2,12 @@ from collections.abc import Iterable from dataclasses import replace -from typing import Any import numpy as np from .config import FitConfig from .math import sigmoid, standardize -from .objective import linear_predictor, model_flags, prepare_response, validate_factor_id +from .objective import linear_predictor, model_flags, prepare_response from .types import ( DimensionalityDiagnostics, FitDiagnostics, @@ -17,21 +16,6 @@ ) -MAX_DIM_DIAGNOSTIC_CANDIDATES = 32 -MAX_DIM_DIAGNOSTIC_FOLDS = 100 -MAX_DIM_DIAGNOSTIC_FITS = 1_000 -MAX_DIM_DIAGNOSTIC_MASK_CELLS = 20_000_000 - - -def _core_module(): - try: - from . import _core # type: ignore - - return _core - except Exception: # pragma: no cover - return None - - def predict_proba( params: MLSIRMParams, factor_id: np.ndarray, @@ -39,72 +23,14 @@ def predict_proba( items: np.ndarray | None = None, model: str = "MLS2PLM", ) -> np.ndarray: - factors = validate_factor_id( - factor_id, len(params.b), params.theta.shape[1] - ) sub = _subset_params(params, persons, items) + factors = np.asarray(factor_id, dtype=np.int64) if items is not None: factors = factors[np.asarray(items, dtype=np.int64)] eta, _ = linear_predictor(sub, factors, model=model) return sigmoid(eta) -def _leniency_residuals(y: np.ndarray, observed: np.ndarray, prob: np.ndarray) -> dict[str, np.ndarray | float]: - """Compute an observed-minus-expected pass-rate proxy for response leniency. - - This is an adaptation inspired by the content-independent response-bias - framing in Ferrando, Lorenzo-Seva, and Chico (2009), but it intentionally - does not claim to identify their tridimensional MRFA acquiescence or social - desirability factors. - - References - ---------- - Ferrando, P. J., Lorenzo-Seva, U., & Chico, E. (2009). A general - factor-analytic procedure for assessing response bias in questionnaire - measures. *Structural Equation Modeling: A Multidisciplinary Journal, - 16*(2), 364-381. https://doi.org/10.1080/10705510902751374 - """ - core = _core_module() - if core is not None and hasattr(core, "leniency_residuals_stat"): - result = core.leniency_residuals_stat( - y=np.ascontiguousarray(y.reshape(-1), dtype=np.float64), - observed=np.ascontiguousarray(observed.reshape(-1), dtype=np.bool_), - prob=np.ascontiguousarray(prob.reshape(-1), dtype=np.float64), - n_persons=int(y.shape[0]), - ) - return { - "residual": np.asarray(result["residual"], dtype=float), - "observed_mean": np.asarray(result["observed_mean"], dtype=float), - "expected_mean": np.asarray(result["expected_mean"], dtype=float), - "n_observed": np.asarray(result["n_observed"], dtype=float), - "mean": float(result["mean"]), - "sd": float(result["sd"]), - "abs_p95": float(result["abs_p95"]), - } - - counts = observed.sum(axis=1).astype(float) - observed_sum = np.where(observed, y, 0.0).sum(axis=1) - expected_sum = np.where(observed, prob, 0.0).sum(axis=1) - valid = counts > 0.0 - observed_mean = np.zeros(y.shape[0], dtype=float) - expected_mean = np.zeros(y.shape[0], dtype=float) - residual = np.zeros(y.shape[0], dtype=float) - observed_mean[valid] = observed_sum[valid] / counts[valid] - expected_mean[valid] = expected_sum[valid] / counts[valid] - residual[valid] = observed_mean[valid] - expected_mean[valid] - valid_residual = residual[valid] - abs_values = np.abs(valid_residual) - return { - "residual": residual, - "observed_mean": observed_mean, - "expected_mean": expected_mean, - "n_observed": counts, - "mean": float(np.mean(valid_residual)) if valid_residual.size else 0.0, - "sd": float(np.std(valid_residual)) if valid_residual.size else 0.0, - "abs_p95": float(np.quantile(abs_values, 0.95)) if abs_values.size else 0.0, - } - - def fit_diagnostics( responses: np.ndarray, params: MLSIRMParams, @@ -116,49 +42,7 @@ def fit_diagnostics( *, group_id: np.ndarray | None = None, cluster_id: np.ndarray | None = None, - include_m2: bool = False, - m2_q_theta: int = 21, - m2_q_u: int = 11, - m2_q_xi: int = 11, - estimator: str | None = None, - population: dict[str, Any] | None = None, - convergence_status: str | None = None, ) -> FitDiagnostics: - """Compute item, person, and model diagnostics for binary responses. - - Set ``include_m2=True`` for M2 limited-information global fit, including - M2-based RMSEA, bivariate SRMR/SRMSR, and CFI/TLIRT against a fitted - complete-independence baseline. Pass the actual ``estimator`` when M2 is - requested. Multiple-group MMLE additionally needs ``population['mu']`` and - ``population['sigma']``; multilevel MMLE needs ``population['sigma_u']``. - Clustered data use a between-cluster covariance rather than an iid M2. - When the calibration convergence status is available, pass it so - inferential fit indices cannot be computed from an unfinished fit. - """ - if include_m2 and estimator is None: - raise ValueError("include_m2 requires the actual estimator: jmle, cmle, or mmle") - if include_m2 and convergence_status is not None: - status = str(convergence_status).strip().lower() - if status != "converged": - raise ValueError( - "limited-information diagnostics require converged parameters; " - f"the fitted model did not converge (status={status or 'unknown'})" - ) - if include_m2 and population is not None: - unsupported = [] - if "pi_zero" in population: - unsupported.append("zero inflation") - if "delta" in population: - unsupported.append("item covariates") - if unsupported: - terms = " and ".join(unsupported) - raise ValueError( - "limited-information M2 does not yet support calibrations with " - f"{terms}; the required model moments and free-parameter columns " - "would otherwise be omitted" - ) - if include_m2 and group_id is not None and cluster_id is not None: - raise ValueError("M2 accepts group_id or cluster_id, not both") y, observed = prepare_response(responses, mask) prob = np.clip(predict_proba(params, factor_id, model=model), eps, 1.0 - eps) if prob.shape != y.shape: @@ -178,9 +62,6 @@ def fit_diagnostics( _attach_person_strata( personfit, group_id=group_id, cluster_id=cluster_id, n_persons=y.shape[0] ) - leniency = _leniency_residuals(y, observed, prob) - personfit["leniency_residual"] = leniency["residual"] - personfit["leniency_n_observed"] = leniency["n_observed"] factorfit = _factor_fit( factor_id, y, observed, prob, variance, residual, pearson_sq ) @@ -200,102 +81,7 @@ def fit_diagnostics( "expected_mean": float(prob[observed].mean()), "mean_abs_residual": float(np.abs(residual[observed]).mean()), "pearson_chisq": float(pearson_sq.sum()), - "leniency_mean": float(leniency["mean"]), - "leniency_sd": float(leniency["sd"]), - "leniency_abs_p95": float(leniency["abs_p95"]), } - if include_m2: - from .fitstats import m2, m2_multigroup, m2_multilevel - - estimator_name = str(estimator).lower() - if group_id is not None: - if estimator_name != "mmle": - raise ValueError("multiple-group M2 currently requires estimator='mmle'") - if population is None or "mu" not in population or "sigma" not in population: - raise ValueError("multiple-group M2 requires population mu and sigma") - limited = m2_multigroup( - responses=y, - factor_id=factor_id, - params=params, - model=model, - group_id=group_id, - population_mean=population["mu"], - population_sd=population["sigma"], - mask=observed, - q_theta=m2_q_theta, - q_xi=m2_q_xi, - ) - elif cluster_id is not None: - if estimator_name != "mmle": - raise ValueError("multilevel M2 currently requires estimator='mmle'") - if population is None or "sigma_u" not in population: - raise ValueError("multilevel M2 requires population sigma_u") - limited = m2_multilevel( - responses=y, - factor_id=factor_id, - params=params, - model=model, - cluster_id=cluster_id, - sigma_u=population["sigma_u"], - mask=observed, - q_theta=m2_q_theta, - q_u=m2_q_u, - q_xi=m2_q_xi, - ) - else: - prior_mean = prior_sd = None - if population is not None and "mu" in population and "sigma" in population: - mu = np.asarray(population["mu"], dtype=float) - sigma = np.asarray(population["sigma"], dtype=float) - if mu.shape[0] == 1 and sigma.shape[0] == 1: - prior_mean, prior_sd = mu[0], sigma[0] - limited = m2( - responses=y, - factor_id=factor_id, - params=params, - model=model, - mask=observed, - q_theta=m2_q_theta, - q_xi=m2_q_xi, - estimator=estimator_name, - prior_mean=prior_mean, - prior_sd=prior_sd, - estimate_population=( - population is not None and population.get("kind") == "singlefree" - ), - fixed_items=( - None if population is None else population.get("fixed_items") - ), - tau_fixed=( - False - if population is None - else bool(population.get("tau_fixed", False)) - ), - ) - model_fit.update( - { - "m2": limited.m2, - "m2_df": limited.df, - "m2_p_value": limited.p_value, - "rmsea": limited.rmsea2, - "rmsea2": limited.rmsea2, - "rmsea_ci_lower": limited.rmsea2_ci_lower, - "rmsea_ci_upper": limited.rmsea2_ci_upper, - "srmr": limited.srmsr, - "srmsr": limited.srmsr, - "cfi": limited.cfi, - "tli": limited.tli, - "null_m2": limited.null_m2, - "null_m2_df": limited.null_df, - "m2_n_complete": float(limited.n_complete), - "m2_inference_valid": limited.inference_valid, - "m2_inference_note": limited.inference_note, - "m2_n_groups": float(limited.n_groups), - "m2_n_clusters": ( - float(limited.n_clusters) if limited.n_clusters is not None else float("nan") - ), - } - ) return FitDiagnostics( itemfit=itemfit, personfit=personfit, @@ -330,16 +116,11 @@ def dimensionality_diagnostics( from .fit import fit y, observed = prepare_response(responses, mask) - dims = _validated_latent_dims(latent_dims) folds = _validation_folds(observed, k_folds, seed) - if len(dims) * k_folds > MAX_DIM_DIAGNOSTIC_FITS: - raise ValueError( - "latent dimension candidates x k_folds exceeds the diagnostic fit limit" - ) base = config or FitConfig(model=model) candidates: list[dict[str, float]] = [] - for latent_dim in dims: + for latent_dim in _validated_latent_dims(latent_dims): totals = {"loglik": 0.0, "abs_residual": 0.0, "sq_residual": 0.0, "n": 0.0} for fold_idx, validation_mask in enumerate(folds): train_mask = observed & ~validation_mask @@ -1082,31 +863,19 @@ def _parameter_count(params: MLSIRMParams, model: str) -> int: def _validated_latent_dims(latent_dims: Iterable[int]) -> list[int]: - dims = list(dict.fromkeys(int(value) for value in latent_dims)) + dims = [int(value) for value in latent_dims] if not dims: raise ValueError("latent_dims must not be empty") if any(value < 1 for value in dims): raise ValueError("latent_dims must be >= 1") - if len(dims) > MAX_DIM_DIAGNOSTIC_CANDIDATES: - raise ValueError( - f"latent_dims must contain at most {MAX_DIM_DIAGNOSTIC_CANDIDATES} unique values" - ) return dims def _validation_folds( observed: np.ndarray, k_folds: int, seed: int ) -> list[np.ndarray]: - if ( - not isinstance(k_folds, (int, np.integer)) - or isinstance(k_folds, (bool, np.bool_)) - or not 2 <= int(k_folds) <= MAX_DIM_DIAGNOSTIC_FOLDS - ): - raise ValueError( - f"k_folds must be an integer between 2 and {MAX_DIM_DIAGNOSTIC_FOLDS}" - ) - if observed.size * int(k_folds) > MAX_DIM_DIAGNOSTIC_MASK_CELLS: - raise ValueError("k-fold validation masks exceed the aggregate size limit") + if k_folds < 2: + raise ValueError("k_folds must be >= 2") row_counts = observed.sum(axis=1) col_counts = observed.sum(axis=0) diff --git a/python/fast_mlsirm/dif.py b/python/fast_mlsirm/dif.py deleted file mode 100644 index a596f7951..000000000 --- a/python/fast_mlsirm/dif.py +++ /dev/null @@ -1,591 +0,0 @@ -"""Observed-score differential item functioning (Mantel-Haenszel; Holland & Thayer, 1988). - -The calibration-free complement to the parametric IRT likelihood-ratio DIF -(:func:`fast_mlsirm.dif_polytomous`): examinees are matched on the number-correct total score and a -common odds ratio is estimated per item across the resulting ``2 x 2`` (group by response) tables. No -item response model is fitted. The numerical computation runs in Rust.""" - -from __future__ import annotations - -import numpy as np - - -def mantel_haenszel_dif( - responses: np.ndarray, - group: np.ndarray, - exclude_studied_item: bool = False, - fdr_q: float = 0.05, -) -> dict[str, np.ndarray]: - """Mantel-Haenszel DIF sweep for dichotomous items (compute in Rust; Holland & Thayer, 1988). - - Examinees are stratified by an observed matching score (the number-correct total, including the - studied item by default -- the ETS standard, less biased than the rest score per Donoghue, Holland - & Thayer, 1993; set ``exclude_studied_item=True`` to match on the rest score). For each item, at - every matching level a ``2 x 2`` table of group (reference/focal) by response (correct/incorrect) is - formed, and over the DIF-informative strata (all four marginal totals positive): - - - ``alpha_mh`` is the Mantel-Haenszel common odds ratio; - - ``chi2_mh`` is the continuity-corrected MH chi-square, referred to ``chi2(1)`` for ``p_value``; - - ``mh_d_dif`` is the ETS delta-metric statistic ``-2.35 ln(alpha_mh)`` (negative = harder for the - focal group) with the Robins-Breslow-Greenland (1986) standard error ``se_d_dif``; - - ``ets_class`` is the ETS ``"A"`` (negligible) / ``"B"`` (moderate) / ``"C"`` (large) severity - classification (Zieky, 1993), or ``"U"`` when the statistic is undefined (no DIF-informative - strata or a degenerate odds ratio); - - ``std_p_dif`` is the standardized P-DIF (Dorans & Kulick, 1986), the focal-minus-reference - focal-weighted proportion-correct difference (an effect size whose sign agrees with ``mh_d_dif``); - - ``flagged_bh`` is the Benjamini-Hochberg FDR rejection at ``fdr_q`` on ``p_value``. - - Because MH is an observed-score procedure, its chi-square is over-powered at large N and the studied - item's presence in the matching total mildly contaminates the criterion; the ``ets_class`` A/B/C - rule (which requires ``|mh_d_dif| >= 1.0`` for a non-A flag) is the practical-significance guard - against spuriously flagging clean items. MH detects *uniform* DIF and can miss crossing - (non-uniform) DIF that the parametric IRT-LR test catches. - - ``responses`` is a persons x items ``0/1`` array (no missing data; drop or impute beforehand). - ``group`` is a length-persons array with ``0`` = reference and ``1`` = focal (both must be present). - Returns per-item NumPy arrays keyed as above; NaN statistics / ``"U"`` mark items with no - DIF-informative strata or a degenerate common odds ratio. - - References (APA 7th ed.): - Dorans, N. J., & Kulick, E. (1986). Demonstrating the utility of the standardization approach - to assessing unexpected differential item performance on the Scholastic Aptitude Test. - *Journal of Educational Measurement, 23*(4), 355-368. - https://doi.org/10.1111/j.1745-3984.1986.tb00255.x - Holland, P. W., & Thayer, D. T. (1988). Differential item performance and the Mantel-Haenszel - procedure. In H. Wainer & H. I. Braun (Eds.), *Test validity* (pp. 129-145). Erlbaum. - Robins, J., Breslow, N., & Greenland, S. (1986). Estimators of the Mantel-Haenszel variance - consistent in both sparse data and large-strata limiting models. *Biometrics, 42*(2), - 311-323. https://doi.org/10.2307/2531052 - Zieky, M. (1993). Practical questions in the use of DIF statistics in test development. In P. W. - Holland & H. Wainer (Eds.), *Differential item functioning* (pp. 337-347). Erlbaum. - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "mantel_haenszel_dif"): - raise RuntimeError("mantel_haenszel_dif requires the compiled Rust core") - - y = np.asarray(responses) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_persons == 0 or n_items == 0: - raise ValueError("responses must contain at least one person and one item") - yf = np.asarray(y, dtype=np.float64) - if not np.all(np.isin(yf, (0.0, 1.0))): - raise ValueError("responses must be 0 or 1 (Mantel-Haenszel is for dichotomous items)") - g = np.asarray(group) - if g.ndim != 1 or g.shape[0] != n_persons: - raise ValueError("group must be a length-n_persons 1-D array") - gf = np.asarray(g, dtype=np.float64) - if not np.all(np.isin(gf, (0.0, 1.0))): - raise ValueError("group labels must be 0 (reference) or 1 (focal)") - if not np.isfinite(fdr_q) or not 0 < fdr_q <= 1: - raise ValueError("fdr_q must be finite and in (0, 1]") - - res = core.mantel_haenszel_dif( - yf.astype(np.int64).reshape(-1), - gf.astype(np.int64), - int(n_persons), - int(n_items), - bool(exclude_studied_item), - float(fdr_q), - ) - return { - "item": np.asarray(res["item"], dtype=np.int64), - "alpha_mh": np.asarray(res["alpha_mh"], dtype=np.float64), - "chi2_mh": np.asarray(res["chi2_mh"], dtype=np.float64), - "p_value": np.asarray(res["p_value"], dtype=np.float64), - "mh_d_dif": np.asarray(res["mh_d_dif"], dtype=np.float64), - "se_d_dif": np.asarray(res["se_d_dif"], dtype=np.float64), - "std_p_dif": np.asarray(res["std_p_dif"], dtype=np.float64), - "ets_class": np.asarray(res["ets_class"]), - "flagged_bh": np.asarray(res["flagged_bh"], dtype=bool), - } - - -def _dif_inputs(responses: np.ndarray, group: np.ndarray, fdr_q: float): - """Validation shared by the two PURIFIED entry points. - - The unpurified :func:`mantel_haenszel_dif` and :func:`logistic_dif` predate this helper and still - inline the equivalent checks; the Rust core re-validates everything either way, so this is a - duplicate-message concern, not a hole. - """ - y = np.asarray(responses) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_persons == 0 or n_items == 0: - raise ValueError("responses must contain at least one person and one item") - yf = np.asarray(y, dtype=np.float64) - if not np.all(np.isin(yf, (0.0, 1.0))): - raise ValueError("responses must be 0 or 1 (observed-score DIF is for dichotomous items)") - g = np.asarray(group) - if g.ndim != 1 or g.shape[0] != n_persons: - raise ValueError("group must be a length-n_persons 1-D array") - gf = np.asarray(g, dtype=np.float64) - if not np.all(np.isin(gf, (0.0, 1.0))): - raise ValueError("group labels must be 0 (reference) or 1 (focal)") - if not np.isfinite(fdr_q) or not 0 < fdr_q <= 1: - raise ValueError("fdr_q must be finite and in (0, 1]") - return yf.astype(np.int64).reshape(-1), gf.astype(np.int64), int(n_persons), int(n_items) - - -def mantel_haenszel_dif_purified( - responses: np.ndarray, - group: np.ndarray, - exclude_studied_item: bool = False, - fdr_q: float = 0.05, - max_rounds: int = 3, - min_anchor_items: int = 4, -) -> dict[str, np.ndarray]: - """Mantel-Haenszel DIF with an ITERATIVELY PURIFIED matching criterion (compute in Rust; Candell & - Drasgow, 1988; Clauser, Mazor & Hambleton, 1993). - - :func:`mantel_haenszel_dif` matches on the raw number-correct total, which contains the very items - under test, so items with DIF contaminate the criterion. Purification rebuilds the criterion from the - currently unflagged (anchor) items — an item is scored against ``anchor UNION {itself}`` — and - re-runs the sweep until the flagged set stabilises or ``max_rounds`` is reached. Items are removed - from the anchor on PRACTICAL significance (ETS class B or C), not raw significance, since the MH - chi-square is over-powered at large N. - - Returns everything :func:`mantel_haenszel_dif` returns, plus ``anchor`` (bool per item), ``n_anchor``, - ``rounds`` (purification rounds after the initial full-test sweep; ``0`` means none were applied), - ``purify_converged``, and ``purify_termination_reason`` (``stable_flag_set``, - ``max_rounds_reached``, or ``insufficient_anchor_items``). - - IMPORTANT — the anchor is selected from the SAME data that is then tested against it, so the returned - p-values are conditional on a data-dependent selection: they are not guaranteed super-uniform under - the null and Benjamini-Hochberg does NOT control the FDR at ``fdr_q`` for a purified sweep. Treat - ``flagged_bh`` here as a screening device, not an error-rate guarantee. Purification reduces rather - than removes criterion contamination and can fail outright when DIF is unbalanced in direction - (Wang & Su, 2004). - - Mantel-Haenszel's crossing-DIF blind spot is inherited and purification cannot repair it: an item MH - does not flag stays in the anchor every round and keeps contaminating the criterion. The blindness is - a property of the SIGNED AREA between the two curves over the matched ability distribution, not of - non-uniform DIF as such — a crossing at the centre of that distribution cancels and is invisible, - while the same item with its crossing off centre is detected. Prefer :func:`logistic_dif_purified` - when non-uniform DIF is plausible. - - References (APA 7th ed.): - Candell, G. L., & Drasgow, F. (1988). An iterative procedure for linking metrics and assessing - item bias in item response theory. *Applied Psychological Measurement, 12*(3), 253-260. - https://doi.org/10.1177/014662168801200304 - Clauser, B., Mazor, K., & Hambleton, R. K. (1993). The effects of purification of the matching - criterion on the identification of DIF using the Mantel-Haenszel procedure. *Applied - Measurement in Education, 6*(4), 269-279. https://doi.org/10.1207/s15324818ame0604_2 - Wang, W.-C., & Su, Y.-H. (2004). Effects of average signed area between two item characteristic - curves and test purification procedures on the DIF detection via the Mantel-Haenszel method. - *Applied Measurement in Education, 17*(2), 113-144. - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "mantel_haenszel_dif_purified"): - raise RuntimeError("mantel_haenszel_dif_purified requires the compiled Rust core") - yy, gg, n_persons, n_items = _dif_inputs(responses, group, fdr_q) - res = core.mantel_haenszel_dif_purified( - yy, gg, n_persons, n_items, bool(exclude_studied_item), float(fdr_q), - int(max_rounds), int(min_anchor_items), - ) - return _mh_rows(res) | _purify_meta(res) - - -def logistic_dif_purified( - responses: np.ndarray, - group: np.ndarray, - exclude_studied_item: bool = False, - fdr_q: float = 0.05, - max_iter: int = 50, - max_rounds: int = 3, - min_anchor_items: int = 4, -) -> dict[str, np.ndarray]: - """Zumbo logistic-regression DIF with an ITERATIVELY PURIFIED matching criterion (compute in Rust). - - The same purification loop as :func:`mantel_haenszel_dif_purified`, with the anchor decided by - ``jg_class`` (the Jodoin-Gierl class of the 2-df omnibus test). Unlike the Mantel-Haenszel variant - this detects crossing DIF, so a non-uniform item is removed from the criterion too. - - Returns everything :func:`logistic_dif` returns — including its PER-ITEM ``converged`` array, one - flag per item's IRLS fit — plus ``anchor``, ``n_anchor``, ``rounds``, and the scalar - ``purify_converged`` and ``purify_termination_reason`` for the purification loop itself. The - per-item and loop-level diagnostics are deliberately named differently because they answer - different questions. - - IMPORTANT — the anchor is selected from the SAME data that is then tested against it, so the returned - p-values are conditional on a data-dependent selection: they are not guaranteed super-uniform under - the null and Benjamini-Hochberg does NOT control the FDR at ``fdr_q`` for a purified sweep. Treat - ``flagged_bh`` here as a screening device, not an error-rate guarantee. Purification reduces rather - than removes criterion contamination and can fail outright when DIF is unbalanced in direction - (Wang & Su, 2004). - - Reference (APA 7th ed.): - French, B. F., & Maller, S. J. (2007). Iterative purification and effect size use with logistic - regression for differential item functioning detection. *Educational and Psychological - Measurement, 67*(3), 373-393. https://doi.org/10.1177/0013164406294781 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "logistic_dif_purified"): - raise RuntimeError("logistic_dif_purified requires the compiled Rust core") - yy, gg, n_persons, n_items = _dif_inputs(responses, group, fdr_q) - res = core.logistic_dif_purified( - yy, gg, n_persons, n_items, bool(exclude_studied_item), float(fdr_q), - int(max_iter), int(max_rounds), int(min_anchor_items), - ) - return _logistic_rows(res) | _purify_meta(res) - - -def _purify_meta(res) -> dict[str, np.ndarray]: - # `purify_converged`, not `converged`: the logistic rows already carry a PER-ITEM `converged` array - # and this dict is merged over them, so the loop's scalar flag must not share the name. - return { - "anchor": np.asarray(res["anchor"], dtype=bool), - "n_anchor": int(res["n_anchor"]), - "rounds": int(res["rounds"]), - "purify_converged": bool(res["purify_converged"]), - "purify_termination_reason": str(res["purify_termination_reason"]), - } - - -def _mh_rows(res) -> dict[str, np.ndarray]: - return { - "item": np.asarray(res["item"], dtype=np.int64), - "alpha_mh": np.asarray(res["alpha_mh"], dtype=np.float64), - "chi2_mh": np.asarray(res["chi2_mh"], dtype=np.float64), - "p_value": np.asarray(res["p_value"], dtype=np.float64), - "mh_d_dif": np.asarray(res["mh_d_dif"], dtype=np.float64), - "se_d_dif": np.asarray(res["se_d_dif"], dtype=np.float64), - "std_p_dif": np.asarray(res["std_p_dif"], dtype=np.float64), - "ets_class": np.asarray(res["ets_class"]), - "flagged_bh": np.asarray(res["flagged_bh"], dtype=bool), - } - - -def sibtest( - responses: np.ndarray, - group: np.ndarray, - fdr_q: float = 0.05, - j_min: int = 5, -) -> dict[str, np.ndarray]: - """Uniform SIBTEST for dichotomous items (compute in Rust; Shealy & Stout, 1993). - - The third observed-score DIF procedure in this module, and the only one that corrects the MATCHING - CRITERION itself. :func:`mantel_haenszel_dif` and :func:`logistic_dif` both match on an observed - number-correct score, which is unreliable: under IMPACT (a genuine group difference in ability) two - examinees from different groups with the same OBSERVED score do not have the same expected TRUE - score, because each regresses toward their own group's mean. Matching on the raw score therefore - compares non-equivalent examinees and manufactures DIF for items that have none. Item purification - cannot substitute for this — it changes which items are in the criterion, not the regression of true - score on observed score, so even a perfectly purified criterion stays biased. - - SIBTEST transports each group's conditional mean from that group's own Kelley-regressed true score - to a common target (the unweighted midpoint of the two) before comparing. Each item in turn is the - studied subtest; the valid subtest is every OTHER item, always disjoint — that is a property of the - estimator, not an option. - - Returns per-item arrays: ``beta_uni`` (the regression-corrected weighted mean difference), - ``se_beta``, ``b_uni``, ``p_value`` (``b_uni**2`` referred to ``chi2(1)``), ``alpha_ref`` and - ``alpha_focal`` (the per-group reliabilities the correction divides by — a low or unstable alpha - inflates the correction, so they are reported rather than hidden), ``n_strata_used``, ``flagged_bh``. - - **SIGN WARNING.** ``beta_uni > 0`` means the item is harder for the FOCAL group. This is the - OPPOSITE orientation to ``mh_d_dif`` and ``std_p_dif`` from :func:`mantel_haenszel_dif`, which go - negative in that same situation. The orientation is kept rather than harmonised because published - ``|beta_uni|`` cut-offs assume it; flip one of the two when comparing across procedures. - - **When to prefer it — rarely, on the evidence measured here.** This implementation was compared - against :func:`mantel_haenszel_dif` on identical simulated data, 500 replications per cell, 2PL, - with NO DIF planted so every rejection is a false positive:: - - impact n per group items MH Type I SIBTEST Type I - 0.0 1000 5 .044 .056 - 1.0 1000 5 .046 .086 - - These are the exact cells the shipped Monte-Carlo regression test runs and the rates it prints, so - the table is regenerable from the repository rather than quoted from a study that no longer exists. - - SIBTEST over-rejects in both cells and by roughly DOUBLE under impact — the opposite of the ordering - the motivation above might suggest. The cause is the standard error, below. This is not a - transcription error (the closed-form anchors reproduce the reference implementation exactly); it is - a property of the 1993 estimator, and it is what Jiang and Stout's (1998) paper — "Improved Type I - error control and reduced estimation bias for DIF detection using SIBTEST" — exists to fix. Prefer - :func:`mantel_haenszel_dif` or :func:`logistic_dif` for routine screening; reach for this when you - specifically want the regression-corrected estimand, and read ``beta_uni`` as an effect size rather - than trusting ``p_value`` as a calibrated test. - - **Limitations, none of them silent.** ``se_beta`` treats the regression correction as FIXED, so it - does not propagate the correction's own estimation error; it is optimistic and the test - over-rejects, as measured above. The shipped correction is the single linear one; Jiang and Stout's - (1998) two-segment version is a different, later estimator and is not implemented. No guessing - correction. No effect-size letter class: published cut-offs disagree and none was verified against a - primary source, so the raw effect size ships uncalibrated. - Crossing (non-uniform) DIF is NOT covered — Chalmers (2018) shows the Li and Stout (1996) crossing - test is insufficient and no normal-theory referral is valid, so use :func:`logistic_dif`, whose - ``S x G`` interaction tests crossing directly. - - **Provenance.** The formulas are transcribed from the reference implementation (Chalmers, 2012; - the ``SIBTEST`` routine of the ``mirt`` package), which attributes them to Shealy and Stout (1993). - The primary text was not consulted, and ``mirt`` was not executed — the transcription was made by - reading its source, so the closed-form tests verify this code against the transcribed formulas and - no agreement with ``mirt`` output is claimed. - - ``responses`` is a persons x items ``0/1`` array (no missing data) with at least 3 items; ``group`` - is length-persons with ``0`` = reference and ``1`` = focal. ``j_min`` (default 5) is the number of - examinees per group per matching level that must be STRICTLY exceeded for that level to count. - - References (APA 7th ed.): - Chalmers, R. P. (2012). mirt: A multidimensional item response theory package for the R - environment. *Journal of Statistical Software, 48*(6), 1-29. - https://doi.org/10.18637/jss.v048.i06 - Chalmers, R. P. (2018). Improving the crossing-SIBTEST statistic for detecting non-uniform DIF. - *Psychometrika, 83*(2), 376-386. https://doi.org/10.1007/s11336-017-9583-8 - DeMars, C. E. (2009). Modification of the Mantel-Haenszel and logistic regression DIF procedures - to incorporate the SIBTEST regression correction. *Journal of Educational and Behavioral - Statistics, 34*(2), 149-170. https://doi.org/10.3102/1076998607313923 - Jiang, H., & Stout, W. (1998). Improved Type I error control and reduced estimation bias for DIF - detection using SIBTEST. *Journal of Educational and Behavioral Statistics, 23*(4), 291-322. - https://doi.org/10.3102/10769986023004291 - Li, H.-H., & Stout, W. (1996). A new procedure for detection of crossing DIF. *Psychometrika, - 61*(4), 647-677. https://doi.org/10.1007/BF02294041 - Shealy, R., & Stout, W. (1993). A model-based standardization approach that separates true - bias/DIF from group ability differences and detects test bias/DTF as well as item bias/DIF. - *Psychometrika, 58*(2), 159-194. https://doi.org/10.1007/BF02294572 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "sibtest"): - raise RuntimeError("sibtest requires the compiled Rust core") - yy, gg, n_persons, n_items = _dif_inputs(responses, group, fdr_q) - if not isinstance(j_min, (int, np.integer)) or j_min < 2: - raise ValueError("j_min must be an integer >= 2") - res = core.sibtest(yy, gg, n_persons, n_items, float(fdr_q), int(j_min)) - return { - "item": np.asarray(res["item"], dtype=np.int64), - "beta_uni": np.asarray(res["beta_uni"], dtype=np.float64), - "se_beta": np.asarray(res["se_beta"], dtype=np.float64), - "b_uni": np.asarray(res["b_uni"], dtype=np.float64), - "p_value": np.asarray(res["p_value"], dtype=np.float64), - "alpha_ref": np.asarray(res["alpha_ref"], dtype=np.float64), - "alpha_focal": np.asarray(res["alpha_focal"], dtype=np.float64), - "n_strata_used": np.asarray(res["n_strata_used"], dtype=np.int64), - "flagged_bh": np.asarray(res["flagged_bh"], dtype=bool), - } - - -def raju_area( - a_ref: np.ndarray, - b_ref: np.ndarray, - se_a_ref: np.ndarray, - se_b_ref: np.ndarray, - cov_ab_ref: np.ndarray, - a_foc: np.ndarray, - b_foc: np.ndarray, - se_a_foc: np.ndarray, - se_b_foc: np.ndarray, - cov_ab_foc: np.ndarray, - guess: np.ndarray | None = None, - signed: bool = False, - alpha: float = 0.05, -) -> dict[str, np.ndarray]: - """Raju's ICC-area DIF with signed/unsigned Z tests (compute in Rust; Raju, 1988, 1990). - - Unlike the observed-score procedures in this module (:func:`mantel_haenszel_dif`, - :func:`logistic_dif`, :func:`sibtest`), this is a PARAMETRIC test on separately calibrated 2PL - (or common-guessing 3PL) item parameter estimates: it measures the area between the reference and - focal groups' item characteristic curves. The two calibrations MUST already be on a common scale -- - link them first (e.g. :func:`fast_mlsirm.irt_link` with the mean-sigma method); linking is - deliberately not re-done inside this function. - - Under ``P_G(theta) = 1 / (1 + exp(-a_G (theta - b_G)))``: - - - Signed area (``signed=True``): ``h = b_foc - b_ref`` -- the integral of ``P_ref - P_foc`` over - theta, POSITIVE when the item is harder for the focal group. Exact linear statistic; its Z test - holds the nominal level (Monte-Carlo verified in the crate's test suite). - - Unsigned area (``signed=False``, default): ``h = |H|``, the total area - ``integral |P_foc - P_ref|``, from Raju's closed form with a numerically stable softplus; equal - discriminations fall back to ``|b_foc - b_ref|`` continuously. CAVEAT: under an exact null with - equal true slopes the folded statistic makes the normal-theory Z anti-conservative (over-rejects; - measured ~0.14 at nominal .05 in the crate's Monte-Carlo) -- prefer the signed test when uniform - DIF is the alternative. - - ``se`` is the delta-method standard error using each group's ``se_a``, ``se_b``, and ``cov_ab`` - (the a-b sampling covariance from that group's calibration; pass zeros if unavailable, at the cost - of a misstated variance). Each group's covariance must satisfy the positive-semi-definite bound - ``|cov_ab| <= se_a * se_b`` or the call is rejected. ``z = H / se(H)`` from unscaled quantities; with a common per-item - pseudo-guessing ``guess`` (3PL), ``h`` and ``se`` are reported scaled by ``(1 - c)`` while ``z`` - is unchanged (the convention of the difR reference implementation). ``dif_items`` flags - ``|z| > z_{1-alpha/2}``. - - **Provenance.** The primary papers (Raju, 1988, 1990) were NOT read (paywalled). The formula - oracle is the difR package source (``RajuZ.R``, ``difRaju.R``; Magis et al., 2010 -- code READ in - full, package paper not read). Both areas and all four delta-method partials were additionally - re-derived by hand and verified against numeric quadrature and finite differences during - adversarial spec review; difR's gradient is the uniform negation of the derivative of ``H`` - (variance-equivalent), and its ``exp(Y)`` overflow branch carries a sign this implementation's - softplus formulation never needs. - - References (APA 7th ed.): - Magis, D., Beland, S., Tuerlinckx, F., & De Boeck, P. (2010). A general framework and an R - package for the detection of dichotomous differential item functioning. *Behavior Research - Methods, 42*, 847-862. https://doi.org/10.3758/BRM.42.3.847 - Raju, N. S. (1988). The area between two item characteristic curves. *Psychometrika, 53*(4), - 495-502. https://doi.org/10.1007/BF02294403 - Raju, N. S. (1990). Determining the significance of estimated signed and unsigned areas between - two item response functions. *Applied Psychological Measurement, 14*(2), 197-207. - https://doi.org/10.1177/014662169001400208 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "raju_area"): - raise RuntimeError("raju_area requires the compiled Rust core") - def _vec(v, name: str) -> np.ndarray: - arr = np.asarray(v, dtype=np.float64) - if arr.ndim != 1: - raise ValueError(f"{name} must be a 1-D array, got ndim={arr.ndim}") - return np.ascontiguousarray(arr) - - names = ( - "a_ref", - "b_ref", - "se_a_ref", - "se_b_ref", - "cov_ab_ref", - "a_foc", - "b_foc", - "se_a_foc", - "se_b_foc", - "cov_ab_foc", - ) - vals = (a_ref, b_ref, se_a_ref, se_b_ref, cov_ab_ref, a_foc, b_foc, se_a_foc, se_b_foc, cov_ab_foc) - arrs = [_vec(v, name) for v, name in zip(vals, names)] - g = None - if guess is not None: - g = _vec(guess, "guess") - res = core.raju_area(*arrs, g, bool(signed), float(alpha)) - return { - "h": np.asarray(res["h"], dtype=np.float64), - "se": np.asarray(res["se"], dtype=np.float64), - "z": np.asarray(res["z"], dtype=np.float64), - "p_value": np.asarray(res["p_value"], dtype=np.float64), - "dif_items": np.asarray(res["dif_items"], dtype=np.int64), - "signed": bool(res["signed"]), - } - - -def _logistic_rows(res) -> dict[str, np.ndarray]: - return { - "item": np.asarray(res["item"], dtype=np.int64), - "chi2_uniform": np.asarray(res["chi2_uniform"], dtype=np.float64), - "p_uniform": np.asarray(res["p_uniform"], dtype=np.float64), - "chi2_nonuniform": np.asarray(res["chi2_nonuniform"], dtype=np.float64), - "p_nonuniform": np.asarray(res["p_nonuniform"], dtype=np.float64), - "chi2_total": np.asarray(res["chi2_total"], dtype=np.float64), - "p_total": np.asarray(res["p_total"], dtype=np.float64), - "delta_r2": np.asarray(res["delta_r2"], dtype=np.float64), - "delta_r2_uniform": np.asarray(res["delta_r2_uniform"], dtype=np.float64), - "jg_class": np.asarray(res["jg_class"]), - "flagged_bh": np.asarray(res["flagged_bh"], dtype=bool), - "converged": np.asarray(res["converged"], dtype=bool), - } - - -def logistic_dif( - responses: np.ndarray, - group: np.ndarray, - exclude_studied_item: bool = False, - fdr_q: float = 0.05, - max_iter: int = 50, -) -> dict[str, np.ndarray]: - """Zumbo (1999) logistic-regression DIF for dichotomous items (compute in Rust; Swaminathan & - Rogers, 1990). - - Each item response is regressed on the observed matching score ``S`` (number-correct total, studied - item included by default), the group ``G``, and their interaction, in three NESTED logistic models: - ``M0: b0 + b1 S``; ``M1: + b2 G``; ``M2: + b3 (S x G)``. This separates UNIFORM from NON-UNIFORM - (crossing) DIF — the latter is invisible to :func:`mantel_haenszel_dif`, whose stratified odds-ratio - test can only detect a consistent group advantage. - - - ``chi2_total`` / ``p_total`` (2 df) is the PRIMARY Swaminathan-Rogers/Zumbo omnibus DIF test and is - the value Benjamini-Hochberg adjusts (``flagged_bh``). - - ``chi2_nonuniform`` / ``p_nonuniform`` (1 df) tests the interaction ``b3``. - - ``chi2_uniform`` / ``p_uniform`` (1 df) tests ``b2`` *assuming* ``b3 = 0``; it is a descriptive - follow-up, is NOT the group term of the full model, and is not interpretable when non-uniform DIF - is present. Component p-values are unadjusted. - - ``delta_r2`` is the Nagelkerke pseudo-R² change ``R2(M2) - R2(M0)`` (Zumbo's effect size), and - ``jg_class`` classifies it by Jodoin & Gierl (2001): ``"A"`` negligible (< 0.035), ``"B"`` moderate, - ``"C"`` large (>= 0.070) — forced to ``"A"`` when the omnibus test is not BH-significant, and - ``"U"`` when undefined. ``delta_r2_uniform`` is an uncalibrated descriptive value with no class. - (The older Zumbo & Thomas, 1997 cut-offs of 0.13/0.26 are much more conservative.) - - Items whose fits fail (separation, a rank-deficient design, no convergence) report ``NaN`` - statistics with ``converged=False`` and are never flagged. As with Mantel-Haenszel, the studied item - is included in the matching score and this function does not purify it (see - :func:`logistic_dif_purified`); logistic-regression DIF - additionally assumes the logit is linear in ``S``, so a non-uniform flag is not by itself proof of - crossing item characteristic curves. - - ``responses`` is a persons x items ``0/1`` array (no missing data); ``group`` is length-persons with - ``0`` = reference and ``1`` = focal. Returns per-item NumPy arrays keyed as above. - - References (APA 7th ed.): - Jodoin, M. G., & Gierl, M. J. (2001). Evaluating Type I error and power rates using an effect - size measure with the logistic regression procedure for DIF detection. *Applied Measurement - in Education, 14*(4), 329-349. https://doi.org/10.1207/S15324818AME1404_2 - Swaminathan, H., & Rogers, H. J. (1990). Detecting differential item functioning using logistic - regression procedures. *Journal of Educational Measurement, 27*(4), 361-370. - https://doi.org/10.1111/j.1745-3984.1990.tb00754.x - Zumbo, B. D. (1999). *A handbook on the theory and methods of differential item functioning - (DIF)*. Directorate of Human Resources Research and Evaluation. - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "logistic_dif"): - raise RuntimeError("logistic_dif requires the compiled Rust core") - - y = np.asarray(responses) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_persons == 0 or n_items == 0: - raise ValueError("responses must contain at least one person and one item") - yf = np.asarray(y, dtype=np.float64) - if not np.all(np.isin(yf, (0.0, 1.0))): - raise ValueError("responses must be 0 or 1 (logistic-regression DIF is for dichotomous items)") - g = np.asarray(group) - if g.ndim != 1 or g.shape[0] != n_persons: - raise ValueError("group must be a length-n_persons 1-D array") - gf = np.asarray(g, dtype=np.float64) - if not np.all(np.isin(gf, (0.0, 1.0))): - raise ValueError("group labels must be 0 (reference) or 1 (focal)") - if not np.isfinite(fdr_q) or not 0 < fdr_q <= 1: - raise ValueError("fdr_q must be finite and in (0, 1]") - - res = core.logistic_dif( - yf.astype(np.int64).reshape(-1), - gf.astype(np.int64), - int(n_persons), - int(n_items), - bool(exclude_studied_item), - float(fdr_q), - int(max_iter), - ) - return { - "item": np.asarray(res["item"], dtype=np.int64), - "chi2_uniform": np.asarray(res["chi2_uniform"], dtype=np.float64), - "p_uniform": np.asarray(res["p_uniform"], dtype=np.float64), - "chi2_nonuniform": np.asarray(res["chi2_nonuniform"], dtype=np.float64), - "p_nonuniform": np.asarray(res["p_nonuniform"], dtype=np.float64), - "chi2_total": np.asarray(res["chi2_total"], dtype=np.float64), - "p_total": np.asarray(res["p_total"], dtype=np.float64), - "delta_r2": np.asarray(res["delta_r2"], dtype=np.float64), - "delta_r2_uniform": np.asarray(res["delta_r2_uniform"], dtype=np.float64), - "jg_class": np.asarray(res["jg_class"]), - "flagged_bh": np.asarray(res["flagged_bh"], dtype=bool), - "converged": np.asarray(res["converged"], dtype=bool), - } diff --git a/python/fast_mlsirm/equating.py b/python/fast_mlsirm/equating.py deleted file mode 100644 index 10b255f7e..000000000 --- a/python/fast_mlsirm/equating.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Observed-score equating (Kolen & Brennan, 2014): the raw-score complement to -the IRT scale linking in :mod:`fast_mlsirm.linking`. Equivalent-groups mean / -linear / equipercentile equating and NEAT chained / frequency-estimation -equating, all computed in the Rust core.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass -class EquateResult: - """Observed-score equating result: the conversion table - ``y_equivalents[i] = e_Y(x_scores[i])`` (unrounded), the form moments in - ``moments`` (``mu_x``/``sigma_x``/``mu_y``/``sigma_y``/``mu_eq``/``sigma_eq``), - and (for the moment methods) the linear ``slope``/``intercept`` (``NaN`` for - equipercentile / NEAT). For frequency estimation the ``mu_x``/``sigma_x``/ - ``mu_y``/``sigma_y`` are the *synthetic-population* moments (the densities FE - actually equates), not the raw form marginals, so they are not directly - comparable to a chained or EG result's moments.""" - - x_scores: np.ndarray - y_equivalents: np.ndarray - method: str - design: str # "EG" or "NEAT" - moments: dict[str, float] # mu_x, sigma_x, mu_y, sigma_y, mu_eq, sigma_eq - slope: float - intercept: float - n_x: int - n_y: int - h_x: float = float("nan") # Gaussian-kernel bandwidths (NaN unless kernel) - h_y: float = float("nan") - - -def _infer_k(scores: np.ndarray, k, name: str) -> int: - if k is not None: - return int(k) - arr = np.asarray(scores, dtype=np.float64) - if arr.size == 0: - raise ValueError(f"{name}: score vector must be non-empty") - if not np.all(np.isfinite(arr)): - raise ValueError(f"{name}: scores must be finite") - # Inferring the maximum score from the observed data under-counts the true - # ceiling when the top score was never earned, which shifts the whole - # percentile-rank scale; pass an explicit k for anything but exploratory use. - return int(np.round(arr.max())) - - -def _build(res, method: str, design: str) -> EquateResult: - return EquateResult( - x_scores=np.asarray(res["x_scores"], dtype=np.float64), - y_equivalents=np.asarray(res["y_equivalents"], dtype=np.float64), - method=method, - design=design, - moments={ - "mu_x": float(res["mu_x"]), "sigma_x": float(res["sigma_x"]), - "mu_y": float(res["mu_y"]), "sigma_y": float(res["sigma_y"]), - "mu_eq": float(res["mu_eq"]), "sigma_eq": float(res["sigma_eq"]), - }, - slope=float(res["slope"]), - intercept=float(res["intercept"]), - n_x=int(res["n_x"]), - n_y=int(res["n_y"]), - h_x=float(res.get("h_x", float("nan"))), - h_y=float(res.get("h_y", float("nan"))), - ) - - -def equate_observed_scores( - x_scores: np.ndarray, - y_scores: np.ndarray, - method: str = "equipercentile", - k_x: int | None = None, - k_y: int | None = None, -) -> EquateResult: - """Equivalent-groups (or single-group) observed-score equating of form X onto - form Y (compute in Rust; Kolen & Brennan, 2014). ``x_scores``/``y_scores`` are - raw integer total-score vectors from the two groups. ``method`` is - ``"mean"``, ``"linear"``, or ``"equipercentile"`` (the default; whole- - distribution matching via Kolen-Brennan uniform-kernel continuization). - ``k_x``/``k_y`` are the maximum possible scores (number of items); if omitted - they are inferred from the largest observed score, which is only safe when the - top score was actually earned -- pass them explicitly otherwise. Returns an - :class:`EquateResult` whose ``y_equivalents`` is the unrounded conversion - table for scores ``0..k_x``. - - References (APA 7th ed.): - Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and - linking: Methods and practices* (3rd ed.). Springer. - https://doi.org/10.1007/978-1-4939-0317-7 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "equate_observed_scores"): - raise RuntimeError("equate_observed_scores requires the compiled Rust core") - xs = np.asarray(x_scores, dtype=np.float64).ravel() - ys = np.asarray(y_scores, dtype=np.float64).ravel() - kx = _infer_k(xs, k_x, "k_x") - ky = _infer_k(ys, k_y, "k_y") - res = core.equate_observed_scores(xs, ys, int(kx), int(ky), method=str(method)) - return _build(res, str(method), "EG") - - -def equate_neat( - x_total: np.ndarray, - x_anchor: np.ndarray, - y_total: np.ndarray, - y_anchor: np.ndarray, - method: str = "chained", - k_x: int | None = None, - k_y: int | None = None, - k_v: int | None = None, - w1: float = 0.5, -) -> EquateResult: - """NEAT (common-item non-equivalent groups) observed-score equating (compute - in Rust; Kolen & Brennan, 2014). Population 1 takes form X plus the anchor V - (``x_total``, ``x_anchor``); population 2 takes form Y plus the anchor V - (``y_total``, ``y_anchor``). ``method`` is ``"chained"`` (chained - equipercentile, no population assumption) or ``"frequency_estimation"`` - (post-stratification, assuming population-invariant score-given-anchor - conditionals). ``w1`` is the population-1 synthetic-population weight (used by - frequency estimation only). ``k_x``/``k_y``/``k_v`` are the maximum X/Y/anchor - scores; inferred from the data if omitted (pass them when the ceiling may be - unobserved). - - Frequency estimation assumes the two groups share the anchor's support; where - they do not, the synthetic densities are renormalized, so a poorly overlapping - anchor degrades gracefully toward each group's own marginal rather than - erroring. Chained equating makes no such assumption. - - References (APA 7th ed.): - Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and - linking: Methods and practices* (3rd ed.). Springer. - https://doi.org/10.1007/978-1-4939-0317-7 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "equate_neat"): - raise RuntimeError("equate_neat requires the compiled Rust core") - xt = np.asarray(x_total, dtype=np.float64).ravel() - xa = np.asarray(x_anchor, dtype=np.float64).ravel() - yt = np.asarray(y_total, dtype=np.float64).ravel() - ya = np.asarray(y_anchor, dtype=np.float64).ravel() - kx = _infer_k(xt, k_x, "k_x") - ky = _infer_k(yt, k_y, "k_y") - kv = _infer_k(np.concatenate([xa, ya]), k_v, "k_v") - res = core.equate_neat( - xt, xa, yt, ya, int(kx), int(ky), int(kv), method=str(method), w1=float(w1) - ) - return _build(res, str(method), "NEAT") - - -def equate_neat_linear( - x_total: np.ndarray, - x_anchor: np.ndarray, - y_total: np.ndarray, - y_anchor: np.ndarray, - method: str = "tucker", - anchor_kind: str = "internal", - k_x: int | None = None, - k_y: int | None = None, - w1: float = 0.5, -) -> EquateResult: - """Tucker & Levine linear observed-score NEAT equating (compute in Rust; Kolen - & Brennan, 2014, §4.3-4.4) -- the linear counterpart to :func:`equate_neat`'s - equipercentile methods. Population 1 takes form X plus the anchor V; population - 2 takes form Y plus the anchor V. ``method`` is ``"tucker"`` (equal - total-on-anchor regression across populations) or ``"levine"`` (classical- - congeneric). ``anchor_kind`` is ``"internal"`` (anchor items count toward the - total) or ``"external"`` (separate section) and affects the Levine gamma only - (Tucker is anchor-kind-invariant). ``w1`` is the population-1 synthetic weight. - With equal anchor moments in the two groups every variant collapses to the - equivalent-groups linear equating. Returns an :class:`EquateResult` whose - ``slope``/``intercept`` are the linear conversion and whose moments are the - synthetic-population moments. - - References (APA 7th ed.): - Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and - linking: Methods and practices* (3rd ed.). Springer. - https://doi.org/10.1007/978-1-4939-0317-7 - Brennan, R. L. (2006). *Chained linear equating* (CASMA Technical Report - No. 3). University of Iowa. - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "equate_neat_linear"): - raise RuntimeError("equate_neat_linear requires the compiled Rust core") - xt = np.asarray(x_total, dtype=np.float64).ravel() - xa = np.asarray(x_anchor, dtype=np.float64).ravel() - yt = np.asarray(y_total, dtype=np.float64).ravel() - ya = np.asarray(y_anchor, dtype=np.float64).ravel() - kx = _infer_k(xt, k_x, "k_x") - ky = _infer_k(yt, k_y, "k_y") - res = core.equate_neat_linear( - xt, xa, yt, ya, int(kx), int(ky), - method=str(method), anchor_kind=str(anchor_kind), w1=float(w1), - ) - return _build(res, f"{method}-{anchor_kind}", "NEAT") - - -def loglinear_smooth(counts: np.ndarray, degree: int = 6) -> dict: - """Univariate log-linear presmoothing of a score-frequency distribution - (compute in Rust; Holland & Thayer, 2000; Kolen & Brennan, 2014, ch. 3): fit - ``log m_x = sum_j beta_j q_j(x)`` by Poisson ML so the smoothed density - preserves the first ``degree`` sample moments exactly while damping sampling - noise. ``counts`` are raw frequencies over scores ``0..=k`` (length ``k+1``); - ``degree = k`` reproduces the raw relative frequencies. Returns a dict with - ``probs`` (smoothed density), ``log_lik``, ``aic``, ``bic`` (comparable across - degrees on the same data), ``moments`` (fitted moments on the ``u = x/k`` scale, - orders ``1..=degree``), ``converged``, ``iters``, ``termination_reason``, - ``final_gradient_max``, and ``gradient_tolerance``. - - References (APA 7th ed.): - Holland, P. W., & Thayer, D. T. (2000). Univariate and bivariate loglinear - models for discrete test score distributions. *Journal of Educational - and Behavioral Statistics, 25*(2), 133-183. - https://doi.org/10.3102/10769986025002133 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "loglinear_smooth"): - raise RuntimeError("loglinear_smooth requires the compiled Rust core") - c = np.asarray(counts, dtype=np.float64).ravel() - # the model preserves at most k = len(counts)-1 moments; clamp so the default - # degree works on short forms (k < 6) instead of erroring - deg = max(1, min(int(degree), c.size - 1)) - res = core.loglinear_smooth(c, deg) - return { - "probs": np.asarray(res["probs"], dtype=np.float64), - "log_lik": float(res["log_lik"]), - "aic": float(res["aic"]), - "bic": float(res["bic"]), - "moments": np.asarray(res["moments"], dtype=np.float64), - "converged": bool(res["converged"]), - "iters": int(res["iters"]), - "termination_reason": str(res["termination_reason"]), - "final_gradient_max": float(res["final_gradient_max"]), - "gradient_tolerance": float(res["gradient_tolerance"]), - } - - -def equate_observed_scores_kernel( - x_scores: np.ndarray, - y_scores: np.ndarray, - continuization: str = "gaussian", - k_x: int | None = None, - k_y: int | None = None, - smooth_x: int | None = None, - smooth_y: int | None = None, - bandwidth_x: float | None = None, - bandwidth_y: float | None = None, -) -> EquateResult: - """Equivalent-groups equating with optional log-linear presmoothing and a - choice of continuization kernel (compute in Rust; Kolen & Brennan, 2014; von - Davier, Holland & Thayer, 2004). ``continuization`` is ``"uniform"`` (the - Kolen-Brennan equipercentile, identical to - :func:`equate_observed_scores`) or ``"gaussian"`` (kernel equating). - ``smooth_x``/``smooth_y`` presmooth each form (``None`` = raw frequencies, each - ``>= 1`` when given); ``bandwidth_x``/``bandwidth_y`` fix the Gaussian bandwidth - (``None`` = penalty-selected). The chosen bandwidths are returned on - ``EquateResult.h_x``/``h_y`` (``NaN`` for the uniform kernel). This entry point - defaults to the Gaussian kernel (unlike the plain - :func:`equate_observed_scores`, whose equipercentile is the uniform kernel). - Presmoothing must actually satisfy its stopping criterion. If the Poisson - log-linear fit does not converge, this function raises ``ValueError`` instead of - constructing an equating table from an unfinished density; use - :func:`loglinear_smooth` directly to inspect ``converged`` and ``iters``. - - References (APA 7th ed.): - von Davier, A. A., Holland, P. W., & Thayer, D. T. (2004). *The kernel - method of test equating*. Springer. https://doi.org/10.1007/b97446 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "equate_observed_scores_ext"): - raise RuntimeError("equate_observed_scores_kernel requires the compiled Rust core") - xs = np.asarray(x_scores, dtype=np.float64).ravel() - ys = np.asarray(y_scores, dtype=np.float64).ravel() - for nm, sv in (("smooth_x", smooth_x), ("smooth_y", smooth_y)): - if sv is not None and int(sv) < 1: - raise ValueError(f"{nm} must be >= 1") - kx = _infer_k(xs, k_x, "k_x") - ky = _infer_k(ys, k_y, "k_y") - res = core.equate_observed_scores_ext( - xs, ys, int(kx), int(ky), - continuization=str(continuization), - smooth_degree_x=None if smooth_x is None else int(smooth_x), - smooth_degree_y=None if smooth_y is None else int(smooth_y), - bandwidth_x=None if bandwidth_x is None else float(bandwidth_x), - bandwidth_y=None if bandwidth_y is None else float(bandwidth_y), - ) - return _build(res, f"{continuization}-kernel", "EG") - - -def equating_standard_errors( - x_scores: np.ndarray, - y_scores: np.ndarray, - method: str = "equipercentile", - route: str = "bootstrap", - k_x: int | None = None, - k_y: int | None = None, - n_boot: int = 1000, - ci_level: float = 0.95, - seed: int = 0, -) -> dict: - """Standard errors of equating (SEE) for the equivalent-groups design (compute - in Rust; Kolen & Brennan, 2014, ch. 7): the sampling error of the equated score - at each raw score point. ``route="bootstrap"`` (the default) resamples - examinees per group independently with replacement, re-equates ``n_boot`` times, - and returns the per-score bootstrap SD and a percentile CI -- it works for every - ``method`` (``"mean"``/``"linear"``/``"equipercentile"``). ``route="analytic"`` - returns the closed-form delta-method (normal-theory) SEE for ``"mean"``/ - ``"linear"`` only. Returns a dict with ``x_scores``, ``y_equivalents`` (the - point estimate), ``se``, ``ci_lo``, ``ci_hi`` (all length ``k_x+1``), ``n_boot`` - (0 for the analytic route), and ``ci_level``. - - References (APA 7th ed.): - Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and - linking: Methods and practices* (3rd ed.). Springer. - https://doi.org/10.1007/978-1-4939-0317-7 - Efron, B., & Tibshirani, R. J. (1993). *An introduction to the bootstrap*. - Chapman & Hall. - """ - from .fitstats import _core_module - - core = _core_module() - if core is None: - raise RuntimeError("equating_standard_errors requires the compiled Rust core") - xs = np.asarray(x_scores, dtype=np.float64).ravel() - ys = np.asarray(y_scores, dtype=np.float64).ravel() - kx = _infer_k(xs, k_x, "k_x") - ky = _infer_k(ys, k_y, "k_y") - if route == "bootstrap": - if not hasattr(core, "bootstrap_see"): - raise RuntimeError("bootstrap SEE requires the compiled Rust core") - res = core.bootstrap_see( - xs, ys, int(kx), int(ky), - method=str(method), n_boot=int(n_boot), ci_level=float(ci_level), seed=int(seed), - ) - elif route == "analytic": - if not hasattr(core, "analytic_see"): - raise RuntimeError("analytic SEE requires the compiled Rust core") - res = core.analytic_see(xs, ys, int(kx), int(ky), method=str(method), ci_level=float(ci_level)) - else: - raise ValueError("route must be 'bootstrap' or 'analytic'") - return { - "x_scores": np.asarray(res["x_scores"], dtype=np.float64), - "y_equivalents": np.asarray(res["y_equivalents"], dtype=np.float64), - "se": np.asarray(res["se"], dtype=np.float64), - "ci_lo": np.asarray(res["ci_lo"], dtype=np.float64), - "ci_hi": np.asarray(res["ci_hi"], dtype=np.float64), - "n_boot": int(res["n_boot"]), - "ci_level": float(res["ci_level"]), - } diff --git a/python/fast_mlsirm/estimators/marginal.py b/python/fast_mlsirm/estimators/marginal.py deleted file mode 100644 index 22484c643..000000000 --- a/python/fast_mlsirm/estimators/marginal.py +++ /dev/null @@ -1,1455 +0,0 @@ -"""NumPy reference for the marginal (MMLE-EM) latent-space estimator. - -Mirror of ``crates/mlsirm-core/src/marginal.rs`` — same quadrature tables -(``numpy.polynomial.hermite_e.hermegauss`` with weights normalized to sum 1, -the convention the Rust consts were generated from), same E-step/M-step -algebra, same deterministic initialization and PCA alignment. Kept for parity -testing and as the fallback when the compiled core is unavailable; any change -here must be mirrored in the Rust core (and vice versa). -""" - -from __future__ import annotations - -import numpy as np - -SUPPORTED_Q = (7, 11, 15, 21, 31, 41) -MAX_FACTOR_DIMENSIONS = 64 -MAX_GPCM_CATEGORIES = 256 - -# Priors of Jeon et al. (2021) / lsirm12pl, used as MAP penalties by the -# marginal estimator (mirror of PenaltyConfig::lsirm_prior in Rust): -# beta ~ N(0, 4), log alpha ~ N(0.5, 1), zeta ~ MVN(0, I), log gamma ~ N(0.5, 1). -LSIRM_PRIOR = { - "lambda_b": 0.25, - "lambda_alpha": 1.0, - "mu_alpha": 0.5, - "lambda_zeta": 1.0, - "lambda_tau": 1.0, - "mu_tau": 0.5, -} - - -def _gh(q: int) -> tuple[np.ndarray, np.ndarray]: - if q not in SUPPORTED_Q: - raise ValueError(f"unsupported quadrature size {q}; supported: {SUPPORTED_Q}") - nodes, weights = np.polynomial.hermite_e.hermegauss(q) - return nodes, weights / weights.sum() - - -def _model_flags(model: str) -> tuple[bool, bool]: - model = model.upper() - free_alpha = model not in {"MLSRM", "ULSRM"} - uses_space = model != "MIRT" - return free_alpha, uses_space - - -def _interaction_kind(model: str) -> str: - """Mirror of mlsirm_core::interaction_kind: none | distance | inner.""" - model = model.upper() - if model == "MIRT": - return "none" - if model == "BIFAC2PLM": - return "inner" - return "distance" - - -_HALTON_PRIMES = (2, 3, 5, 7, 11, 13) - -# Acklam's inverse normal CDF (same coefficients as the Rust core; parity). -_ACK_A = (-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, - 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00) -_ACK_B = (-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, - 6.680131188771972e+01, -1.328068155288572e+01) -_ACK_C = (-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, - -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00) -_ACK_D = (7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, - 3.754408661907416e+00) - - -def _inv_normal_cdf(p: float) -> float: - a, b, c, d = _ACK_A, _ACK_B, _ACK_C, _ACK_D - p_low = 0.02425 - if p < p_low: - q = np.sqrt(-2.0 * np.log(p)) - return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ( - (((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0 - ) - if p <= 1.0 - p_low: - q = p - 0.5 - r = q * q - return ( - (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q - ) / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0) - q = np.sqrt(-2.0 * np.log(1.0 - p)) - return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ( - (((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0 - ) - - -def _radical_inverse(i: int, base: int) -> float: - inv, f = 0.0, 1.0 / base - while i > 0: - inv += (i % base) * f - i //= base - f /= base - return inv - - -def _lcg_next(state: int) -> int: - return (state * 6364136223846793005 + 1442695040888963407) % (1 << 64) - - -def _lcg_uniform(state: int) -> tuple[float, int]: - state = _lcg_next(state) - return (state >> 11) / float(1 << 53), state - - -def _normal_draw(state: int) -> tuple[float, int]: - u1, state = _lcg_uniform(state) - u2, state = _lcg_uniform(state) - return float(np.sqrt(-2.0 * np.log(max(u1, 1e-12))) * np.cos(2.0 * np.pi * u2)), state - - -def _xi_nodes( - rule: str, latent_dim: int, q_xi: int, xi_points: int, xi_seed: int -) -> tuple[np.ndarray, np.ndarray]: - """Mirror of ``mlsirm_core::nodes::build_xi_nodes``.""" - rule = rule.lower() - if rule in {"gh", "gauss-hermite", "gausshermite"}: - if latent_dim > 3: - raise ValueError( - "tensor Gauss-Hermite supports latent_dim <= 3; use xi_rule qmc/mc" - ) - return _xi_grid(q_xi, latent_dim) - if rule in {"qmc", "halton"}: - if xi_points < 1: - raise ValueError("xi_points must be >= 1 for the Halton/MonteCarlo rules") - if latent_dim > len(_HALTON_PRIMES): - raise ValueError(f"Halton rule supports latent_dim <= {len(_HALTON_PRIMES)}") - shift = np.zeros(latent_dim) - if xi_seed != 0: - state = xi_seed - for k in range(latent_dim): - shift[k], state = _lcg_uniform(state) - grid = np.empty((xi_points, latent_dim)) - for j in range(xi_points): - for k in range(latent_dim): - u = _radical_inverse(j + 1, _HALTON_PRIMES[k]) + shift[k] - if u >= 1.0: - u -= 1.0 - grid[j, k] = _inv_normal_cdf(min(max(u, 1e-12), 1.0 - 1e-12)) - return grid, np.full(xi_points, -np.log(xi_points)) - if rule in {"mc", "montecarlo", "monte-carlo"}: - if xi_points < 1: - raise ValueError("xi_points must be >= 1 for the Halton/MonteCarlo rules") - state = max(xi_seed, 1) - grid = np.empty((xi_points, latent_dim)) - for j in range(xi_points): - for k in range(latent_dim): - grid[j, k], state = _normal_draw(state) - return grid, np.full(xi_points, -np.log(xi_points)) - raise ValueError("xi_rule must be one of ['gh', 'qmc', 'mc']") - - -def _xi_grid(q_xi: int, latent_dim: int) -> tuple[np.ndarray, np.ndarray]: - nodes, weights = _gh(q_xi) - # Match the Rust ordering: axis k advances every q_xi^k nodes. - n_points = q_xi**latent_dim - if n_points > 1_000_000: - raise ValueError("q_xi ** latent_dim exceeds the tensor-grid limit; use qmc/mc") - idx = np.arange(n_points) - grid = np.empty((len(idx), latent_dim)) - logw = np.zeros(len(idx)) - rem = idx.copy() - for k in range(latent_dim): - sel = rem % q_xi - rem //= q_xi - grid[:, k] = nodes[sel] - logw += np.log(weights[sel]) - return grid, logw - - -def _log_sigmoid(x: np.ndarray) -> np.ndarray: - return np.where(x >= 0.0, -np.log1p(np.exp(-np.abs(x))), x - np.log1p(np.exp(x))) - - -def _build_contexts( - pop: dict, mu: np.ndarray, sigma: np.ndarray, sigma_u: float, n_dims: int, q_u: int -) -> dict: - kind = pop["kind"] - if kind == "single": - return {"n_ctx": 1, "shift": np.zeros((1, n_dims)), "scale": np.ones((1, n_dims))} - if kind in {"multigroup", "singlefree"}: - # singlefree (FIPC) is a one-group multigroup with free (mu, sigma) - return {"n_ctx": mu.shape[0], "shift": mu.copy(), "scale": sigma.copy()} - nodes, weights = _gh(q_u) - return { - "n_ctx": q_u, - "shift": np.repeat((sigma_u * nodes)[:, None], n_dims, axis=1), - "scale": np.ones((q_u, n_dims)), - "u_nodes": nodes, - "u_logw": np.log(weights), - } - - -def _build_tables( - alpha: np.ndarray, - b: np.ndarray, - zeta: np.ndarray, - tau: float, - model: str, - factor_id: np.ndarray, - ctx: dict, - t_nodes: np.ndarray, - x_grid: np.ndarray, - eps_distance: float, - n_dims: int, - offsets: np.ndarray | None = None, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Return (logp1, logp0, c0) with shapes (S, I, Qt, Nx) and (S, D, Qt, Nx).""" - free_alpha, uses_space = _model_flags(model) - a = np.exp(alpha) if free_alpha else np.ones_like(alpha) - # theta value per (ctx, item, t): shift/scale of the item's dimension. - shift = ctx["shift"][:, factor_id] # (S, I) - scale = ctx["scale"][:, factor_id] # (S, I) - theta = shift[:, :, None] + scale[:, :, None] * t_nodes[None, None, :] # (S, I, Qt) - eta = a[None, :, None, None] * theta[:, :, :, None] + b[None, :, None, None] - if offsets is not None: - eta = eta + offsets[:, :, None, None] - kind = _interaction_kind(model) - if kind == "distance": - diff = x_grid[None, :, :] - zeta[:, None, :] # (I, Nx, K) - dist = np.sqrt(eps_distance + np.sum(diff * diff, axis=2)) # (I, Nx) - eta = eta - np.exp(tau) * dist[None, :, None, :] - elif kind == "inner": - eta = eta + (zeta @ x_grid.T)[None, :, None, :] - logp1 = _log_sigmoid(eta) - logp0 = _log_sigmoid(-eta) - n_ctx, n_items = eta.shape[0], eta.shape[1] - c0 = np.zeros((n_ctx, n_dims, eta.shape[2], eta.shape[3])) - for d in range(n_dims): - c0[:, d] = logp0[:, factor_id == d].sum(axis=1) - return logp1, logp0, c0 - - -def _person_logliks( - y: np.ndarray, - observed: np.ndarray, - factor_id: np.ndarray, - logp1: np.ndarray, - logp0: np.ndarray, - c0: np.ndarray, - t_logw: np.ndarray, - x_logw: np.ndarray, - s_of_person: np.ndarray, - n_dims: int, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Vectorized person pass for one context assignment. - - Returns (l, log_zdx, log_lp): l has shape (P, D, Qt, Nx); log_zdx (P, D, Nx); - log_lp (P,). - """ - delta = logp1 - logp0 # (S, I, Qt, Nx) - pos = np.where(observed, y, 0.0) # (P, I) - l = c0[s_of_person] # (P, D, Qt, Nx) — copy via fancy indexing - # positives: add delta_i; missing: subtract logp0_i — per dimension. - for d in range(n_dims): - items = np.flatnonzero(factor_id == d) - # (P, I_d) @ (S,I_d,Qt,Nx) gathered per person context - pos_d = pos[:, items] # (P, I_d) - miss_d = (~observed[:, items]).astype(np.float64) # (P, I_d) - delta_d = delta[:, items] # (S, I_d, Qt, Nx) - logp0_d = logp0[:, items] - # einsum over the item axis with per-person context gather - l[:, d] += np.einsum( - "pi,piqx->pqx", pos_d, delta_d[s_of_person], optimize=True - ) - if miss_d.any(): - l[:, d] -= np.einsum( - "pi,piqx->pqx", miss_d, logp0_d[s_of_person], optimize=True - ) - lw = t_logw[None, None, :, None] + l # (P, D, Qt, Nx) - m = lw.max(axis=2, keepdims=True) - log_zdx = np.squeeze(m, axis=2) + np.log( - np.exp(lw - m).sum(axis=2) - ) # (P, D, Nx) - ax = x_logw[None, :] + log_zdx.sum(axis=1) # (P, Nx) - mx = ax.max(axis=1, keepdims=True) - log_lp = np.squeeze(mx, axis=1) + np.log(np.exp(ax - mx).sum(axis=1)) - return l, log_zdx, log_lp - - -def _posteriors( - l: np.ndarray, - log_zdx: np.ndarray, - log_lp: np.ndarray, - t_logw: np.ndarray, - x_logw: np.ndarray, -) -> np.ndarray: - """Joint per-person posterior over (d, t, x): shape (P, D, Qt, Nx).""" - px = np.exp(x_logw[None, :] + log_zdx.sum(axis=1) - log_lp[:, None]) # (P, Nx) - pt = np.exp(t_logw[None, None, :, None] + l - log_zdx[:, :, None, :]) - return px[:, None, None, :] * pt - - -def _multilevel_context_posteriors( - lp_irt: np.ndarray, - all_zero: np.ndarray, - cluster_id: np.ndarray, - n_clusters: int, - u_logw: np.ndarray, - pi_zero: float | None, -) -> tuple[np.ndarray, np.ndarray]: - """Return cluster and engager-conditional context posteriors. - - ``lp_irt[p, v]`` is the IRT-component log marginal for person ``p`` at - random-intercept node ``v``. The cluster posterior integrates the complete - structural-zero mixture. The second result conditions each person's - context weights on that person belonging to the engager/IRT component, - which is the public scoring contract for zero-inflated calibrations. - """ - if pi_zero is None: - lp_mix = lp_irt - log_irt_adjust = np.zeros_like(lp_irt) - else: - log_pi = np.log(pi_zero) if pi_zero > 0.0 else -np.inf - log_1m = np.log1p(-pi_zero) - a_zero = np.where(all_zero[:, None], log_pi, -np.inf) - b_irt = log_1m + lp_irt - maximum = np.maximum(a_zero, b_irt) - lp_mix = maximum + np.log( - np.exp(a_zero - maximum) + np.exp(b_irt - maximum) - ) - # Replacing this person's mixture contribution with its IRT - # contribution conditions its context posterior on engager membership. - log_irt_adjust = b_irt - lp_mix - - log_cluster = np.zeros((n_clusters, len(u_logw))) + u_logw[None, :] - np.add.at(log_cluster, cluster_id, lp_mix) - maximum = log_cluster.max(axis=1, keepdims=True) - log_norm = maximum + np.log( - np.exp(log_cluster - maximum).sum(axis=1, keepdims=True) - ) - cluster_post = np.exp(log_cluster - log_norm) - - if pi_zero is None: - engager_context_post = cluster_post[cluster_id] - else: - log_engager = log_cluster[cluster_id] + log_irt_adjust - maximum = log_engager.max(axis=1, keepdims=True) - log_norm = maximum + np.log( - np.exp(log_engager - maximum).sum(axis=1, keepdims=True) - ) - engager_context_post = np.exp(log_engager - log_norm) - return cluster_post, engager_context_post - - -def _accumulate( - post: np.ndarray, - w_outer: np.ndarray, - y: np.ndarray, - observed: np.ndarray, - factor_id: np.ndarray, - s_of_person: np.ndarray, - n_ctx: int, - nbar: np.ndarray, - rbar: np.ndarray, - mbar: np.ndarray, -) -> None: - wpost = post * w_outer[:, None, None, None] # (P, D, Qt, Nx) - for s in range(n_ctx): - sel = s_of_person == s - if not sel.any(): - continue - nbar[s] += wpost[sel].sum(axis=0) - pos = np.where(observed[sel], y[sel], 0.0) # (Ps, I) - miss = (~observed[sel]).astype(np.float64) - dsel = wpost[sel][:, factor_id] # (Ps, I, Qt, Nx) - rbar[s] += np.einsum("pi,piqx->iqx", pos, dsel, optimize=True) - if miss.any(): - mbar[s] += np.einsum("pi,piqx->iqx", miss, dsel, optimize=True) - - -def _item_q( - n_i: np.ndarray, - r_i: np.ndarray, - eta: np.ndarray, - alpha_i: float, - b_i: float, - zeta_i: np.ndarray, - free_alpha: bool, - uses_space: bool, - pen: dict, -) -> float: - q = float(np.sum(r_i * _log_sigmoid(eta) + (n_i - r_i) * _log_sigmoid(-eta))) - q -= 0.5 * pen["lambda_b"] * b_i * b_i - if free_alpha: - da = alpha_i - pen["mu_alpha"] - q -= 0.5 * pen["lambda_alpha"] * da * da - if uses_space: - q -= 0.5 * pen["lambda_zeta"] * float(zeta_i @ zeta_i) - return q - - -def fit_marginal_numpy( - y: np.ndarray, - observed: np.ndarray, - factor_id: np.ndarray, - model: str = "MLS2PLM", - n_dims: int | None = None, - latent_dim: int = 2, - pop: dict | None = None, - q_theta: int = 21, - q_xi: int = 11, - q_u: int = 15, - max_iter: int = 200, - tol: float = 1e-5, - m_steps: int = 4, - init_zeta_radius: float = 0.5, - init_sigma_u: float = 0.3, - eps_distance: float = 1e-8, - penalty: dict | None = None, - xi_rule: str = "gh", - xi_points: int = 256, - xi_seed: int = 0, - anchors: dict | None = None, - zero_inflation: bool = False, - covariate: dict | None = None, -) -> dict: - """NumPy mirror of ``mlsirm_core::marginal::fit_marginal``. - - ``pop`` is ``{"kind": "single"}`` (default), ``{"kind": "singlefree"}`` - (FIPC: free population mean/sd, requires ``anchors``), - ``{"kind": "multigroup", "group_id": ..., "n_groups": ...}`` or - ``{"kind": "multilevel", "cluster_id": ..., "n_clusters": ...}``. - ``anchors`` is ``{"fixed": bool[I], "alpha": ..., "b": ..., "zeta": ..., - "tau": float | None}`` — fixed items stay frozen (FIPC, Kim 2006). - - ``zero_inflation=True`` fits a structural-zero response-pattern mixture. - This is a repository-specific Bernoulli-pattern adaptation of the count- - model template in Perumean-Chaney et al. (2013), not an IRT estimator from - that article. Returned latent scores are conditional on the engager/IRT - component; multilevel ``u_eap`` integrates over the complete mixture. - - References - ---------- - Perumean-Chaney, S. E., Morgan, C., McDowall, D., & Aban, I. (2013). - Zero-inflated and overdispersed: What's one to do? *Journal of Statistical - Computation and Simulation, 83*(9), 1671–1683. - https://doi.org/10.1080/00949655.2012.668550 - """ - y = np.asarray(y, dtype=np.float64) - observed = np.asarray(observed, dtype=bool) - factor_id = np.asarray(factor_id, dtype=np.int64) - n_persons, n_items = y.shape - if n_dims is None: - n_dims = int(factor_id.max()) + 1 - # Bound the dominant EM working array (persons x max(items,dims) x q_theta x - # n_xi_nodes) so oversized quadrature/data cannot exhaust memory (DoS). - MAX_MARGINAL_WORKING_SET = 100_000_000 - _rule = str(xi_rule).lower() - _nx = xi_points if _rule in {'qmc', 'halton', 'mc', 'montecarlo', 'monte-carlo'} else min(int(q_xi) ** int(latent_dim), 1_000_001) - if n_persons * max(n_items, n_dims) * int(q_theta) * _nx > MAX_MARGINAL_WORKING_SET: - raise ValueError( - 'marginal working set (persons x max(items,dims) x q_theta x n_xi) ' - f'exceeds the {MAX_MARGINAL_WORKING_SET}-element limit' - ) - model = model.upper() - free_alpha, uses_space = _model_flags(model) - pop = pop or {"kind": "single"} - pen = dict(LSIRM_PRIOR) - if penalty: - pen.update(penalty) - - if model in {"ULS2PLM", "ULSRM"} and n_dims != 1: - raise ValueError("unidimensional models require n_dims == 1") - if factor_id.min() < 0 or factor_id.max() >= n_dims: - raise ValueError("factor_id values must be in 0..n_dims-1") - if latent_dim < 1 or latent_dim > 3: - raise ValueError("marginal estimator supports 1 <= latent_dim <= 3") - obs_vals = y[observed] - if obs_vals.size and not np.all((obs_vals == 0.0) | (obs_vals == 1.0)): - raise ValueError("observed responses must be 0 or 1") - - t_nodes, t_weights = _gh(q_theta) - t_logw = np.log(t_weights) - if uses_space: - x_grid, x_logw = _xi_nodes(xi_rule, latent_dim, q_xi, xi_points, xi_seed) - else: - x_grid, x_logw = np.zeros((1, latent_dim)), np.zeros(1) - n_x = len(x_logw) - - # --- deterministic init (mirror of the Rust code) --- - counts = observed.sum(axis=0) - means = np.where(counts > 0, np.where(observed, y, 0.0).sum(axis=0) / np.maximum(counts, 1), 0.5) - prop = np.clip(means, 0.02, 0.98) - b = np.log(prop / (1.0 - prop)) - alpha = np.zeros(n_items) - zeta = np.zeros((n_items, latent_dim)) - if uses_space and _interaction_kind(model) == "inner": - # positive-manifold start for loadings (mirror of the Rust init) - zeta[:] = init_zeta_radius - elif uses_space: - angle = 2.0 * np.pi * np.arange(n_items) / max(n_items, 1) - zeta[:, 0] = init_zeta_radius * np.cos(angle) - if latent_dim >= 2: - zeta[:, 1] = init_zeta_radius * np.sin(angle) - if latent_dim >= 3: - zeta[:, 2] = init_zeta_radius * np.cos(2.0 * angle) * 0.5 - tau = 0.0 if _interaction_kind(model) == "distance" else -30.0 - - kind = pop["kind"] - if kind == "singlefree" and anchors is None: - raise ValueError("singlefree (FIPC) requires anchors for identification") - if covariate is not None: - if kind == "multilevel": - raise ValueError("item covariates with a multilevel structure are not supported") - n_ctx_expected = pop.get("n_groups", 1) if kind == "multigroup" else 1 - w_cov = np.asarray(covariate["w"], dtype=np.float64).reshape( - n_ctx_expected, n_items - ) - if n_ctx_expected == 1 and anchors is None: - raise ValueError( - "a single-context item covariate is collinear with b; use multigroup " - "contexts (booklets) or anchors" - ) - else: - w_cov = None - n_groups = ( - pop.get("n_groups", 0) if kind == "multigroup" else (1 if kind == "singlefree" else 0) - ) - n_clusters = pop.get("n_clusters", 0) if kind == "multilevel" else 0 - for _cnt, _nm in ((n_groups, "n_groups"), (n_clusters, "n_clusters")): - if _cnt and (int(_cnt) < 1 or int(_cnt) > n_persons): - raise ValueError(f"{_nm} ({_cnt}) must be between 1 and n_persons ({n_persons})") - if kind == "multigroup": - group_id = np.asarray(pop["group_id"], dtype=np.int64) - if group_id.shape != (n_persons,) or group_id.min() < 0 or group_id.max() >= n_groups: - raise ValueError("group_id values must be in 0..n_groups-1") - if kind == "multilevel": - cluster_id = np.asarray(pop["cluster_id"], dtype=np.int64) - if ( - cluster_id.shape != (n_persons,) - or cluster_id.min() < 0 - or cluster_id.max() >= n_clusters - ): - raise ValueError("cluster_id values must be in 0..n_clusters-1") - mu = np.zeros((n_groups, n_dims)) - sigma = np.ones((n_groups, n_dims)) - sigma_u = init_sigma_u if n_clusters else 0.0 - delta = float(covariate.get("init_delta", 0.0)) if covariate is not None else 0.0 - all_zero = ~(np.where(observed, y, 0.0) > 0).any(axis=1) - if zero_inflation: - frac = float(all_zero.mean()) - pi_zero = float(np.clip(0.5 * frac, 1e-4, 0.98)) - else: - pi_zero = 0.0 - zero_resp = np.zeros(n_persons) - fixed_mask = np.zeros(n_items, dtype=bool) - anchor_tau = None - if anchors is not None: - fixed_mask = np.asarray(anchors["fixed"]) - if fixed_mask.dtype.kind != "b": - raise ValueError("anchor fixed must contain only boolean values") - if fixed_mask.shape != (n_items,) or not fixed_mask.any(): - raise ValueError("anchors must fix at least one item and match n_items") - if kind == "singlefree": - fixed_per_dim = np.bincount(factor_id[fixed_mask], minlength=n_dims) - if np.any(fixed_per_dim < 2): - d = int(np.flatnonzero(fixed_per_dim < 2)[0]) - raise ValueError( - "singlefree (FIPC) requires at least two fixed anchor items per " - f"trait dimension; dimension {d} has {int(fixed_per_dim[d])}" - ) - alpha[fixed_mask] = np.asarray(anchors["alpha"], dtype=float)[fixed_mask] - b[fixed_mask] = np.asarray(anchors["b"], dtype=float)[fixed_mask] - zeta[fixed_mask] = np.asarray(anchors["zeta"], dtype=float).reshape( - n_items, latent_dim - )[fixed_mask] - anchor_tau = anchors.get("tau") - if anchor_tau is not None: - tau = float(anchor_tau) - if not np.isfinite(tau): - raise ValueError("anchor tau must be finite") - - loglik_trace: list[float] = [] - converged = False - n_iter = 0 - - def _zi_mix(lp_irt: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - # mixture log-marginal and IRT-class weight, elementwise over persons - log_pi = np.log(pi_zero) if pi_zero > 0 else -np.inf - log_1m = np.log1p(-pi_zero) - a_z = np.where(all_zero_bcast, log_pi, -np.inf) - b_z = log_1m + lp_irt - m = np.maximum(a_z, b_z) - lp_mix = m + np.log(np.exp(a_z - m) + np.exp(b_z - m)) - return lp_mix, np.exp(b_z - lp_mix) - - for _iteration in range(max_iter): - ctx = _build_contexts(pop, mu, sigma, sigma_u, n_dims, q_u) - offsets = delta * w_cov if w_cov is not None else None - logp1, logp0, c0 = _build_tables( - alpha, b, zeta, tau, model, factor_id, ctx, t_nodes, x_grid, eps_distance, - n_dims, offsets, - ) - n_ctx = ctx["n_ctx"] - nbar = np.zeros((n_ctx, n_dims, q_theta, n_x)) - rbar = np.zeros((n_ctx, n_items, q_theta, n_x)) - mbar = np.zeros((n_ctx, n_items, q_theta, n_x)) - - if kind in {"single", "singlefree", "multigroup"}: - s_of_person = ( - group_id if kind == "multigroup" else np.zeros(n_persons, dtype=np.int64) - ) - l, log_zdx, log_lp = _person_logliks( - y, observed, factor_id, logp1, logp0, c0, t_logw, x_logw, s_of_person, n_dims - ) - if zero_inflation: - all_zero_bcast = all_zero - lp_mix, w_irt = _zi_mix(log_lp) - loglik = float(lp_mix.sum()) - zero_resp = 1.0 - w_irt - else: - loglik = float(log_lp.sum()) - w_irt = np.ones(n_persons) - post = _posteriors(l, log_zdx, log_lp, t_logw, x_logw) - _accumulate( - post, w_irt, y, observed, factor_id, s_of_person, n_ctx, - nbar, rbar, mbar, - ) - sum_e_v2 = 0.0 - else: # multilevel - lp_v = np.empty((n_persons, n_ctx)) - for v in range(n_ctx): - s_all = np.full(n_persons, v, dtype=np.int64) - _, _, lp = _person_logliks( - y, observed, factor_id, logp1, logp0, c0, t_logw, x_logw, s_all, n_dims - ) - lp_v[:, v] = lp - if zero_inflation: - all_zero_bcast = all_zero[:, None] - lp_v, w_irt_v = _zi_mix(lp_v) - else: - w_irt_v = np.ones_like(lp_v) - log_cluster = np.zeros((n_clusters, n_ctx)) + ctx["u_logw"][None, :] - np.add.at(log_cluster, cluster_id, lp_v) - mc = log_cluster.max(axis=1, keepdims=True) - lse = np.squeeze(mc, axis=1) + np.log(np.exp(log_cluster - mc).sum(axis=1)) - loglik = float(lse.sum()) - cluster_post = np.exp(log_cluster - lse[:, None]) # (C, V) - sum_e_v2 = float((cluster_post * ctx["u_nodes"][None, :] ** 2).sum()) - if zero_inflation: - zero_resp = (cluster_post[cluster_id] * (1.0 - w_irt_v)).sum(axis=1) - for v in range(n_ctx): - w_outer = cluster_post[cluster_id, v] * w_irt_v[:, v] - keep = w_outer >= 1e-14 - if not keep.any(): - continue - s_all = np.full(n_persons, v, dtype=np.int64) - l, log_zdx, log_lp = _person_logliks( - y, observed, factor_id, logp1, logp0, c0, t_logw, x_logw, s_all, n_dims - ) - post = _posteriors(l, log_zdx, log_lp, t_logw, x_logw) - w_eff = np.where(keep, w_outer, 0.0) - _accumulate( - post, w_eff, y, observed, factor_id, s_all, n_ctx, nbar, rbar, mbar - ) - loglik_trace.append(loglik) - - # The likelihood just evaluated belongs to the current parameters. - # Stop before another M-step so the returned model, trace endpoint, - # information criteria, and zero-inflation responsibilities agree. - if len(loglik_trace) > 1 and abs(loglik_trace[-1] - loglik_trace[-2]) < tol: - converged = True - break - - if zero_inflation: - pi_zero = float(np.clip(zero_resp.mean(), 0.0, 0.999)) - - # --- M-step: items (Fisher-preconditioned ascent with Armijo) --- - gamma = float(np.exp(tau)) - theta_sx = ctx["shift"][:, :, None] + ctx["scale"][:, :, None] * t_nodes[None, None, :] - for i in range(n_items): - if fixed_mask[i]: - continue - d = int(factor_id[i]) - zeta_i = zeta[i].copy() - n_i = nbar[:, d] - mbar[:, i] # (S, Qt, Nx) - r_i = rbar[:, i] - theta_i = theta_sx[:, d] # (S, Qt) - - off_i = ( - offsets[:, i][:, None, None] if offsets is not None else 0.0 - ) - - kind_i = _interaction_kind(model) - - def eta_of(alpha_c: float, b_c: float, zeta_c: np.ndarray) -> np.ndarray: - a_c = np.exp(alpha_c) if free_alpha else 1.0 - e = a_c * theta_i[:, :, None] + b_c + off_i - if kind_i == "distance": - diff = x_grid - zeta_c[None, :] - dist = np.sqrt(eps_distance + np.sum(diff * diff, axis=1)) - e = e - gamma * dist[None, None, :] - elif kind_i == "inner": - e = e + (x_grid @ zeta_c)[None, None, :] - return e - - cur_q = _item_q( - n_i, r_i, eta_of(alpha[i], b[i], zeta_i), alpha[i], b[i], zeta_i, - free_alpha, uses_space, pen, - ) - for _ in range(m_steps): - a_c = np.exp(alpha[i]) if free_alpha else 1.0 - eta = eta_of(alpha[i], b[i], zeta_i) - prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -700, 700))) - resid = r_i - n_i * prob - info = np.maximum(n_i * prob * (1.0 - prob), 0.0) - g_b = float(resid.sum()) - pen["lambda_b"] * b[i] - i_b = float(info.sum()) - if free_alpha: - deta_a = a_c * theta_i[:, :, None] - g_alpha = float((resid * deta_a).sum()) - pen["lambda_alpha"] * ( - alpha[i] - pen["mu_alpha"] - ) - i_alpha = float((info * deta_a * deta_a).sum()) - else: - g_alpha, i_alpha = 0.0, 0.0 - if uses_space: - if kind_i == "inner": - deta_z = x_grid # (Nx, K) - else: - diff = x_grid - zeta_i[None, :] - dist = np.sqrt(eps_distance + np.sum(diff * diff, axis=1)) - deta_z = gamma * diff / dist[:, None] # (Nx, K) - g_zeta = ( - np.einsum("stx,xk->k", resid, deta_z, optimize=True) - - pen["lambda_zeta"] * zeta_i - ) - i_zeta = np.einsum("stx,xk->k", info, deta_z * deta_z, optimize=True) - else: - g_zeta = np.zeros(latent_dim) - i_zeta = np.zeros(latent_dim) - d_b = g_b / (i_b + pen["lambda_b"] + 1e-8) - d_alpha = g_alpha / (i_alpha + pen["lambda_alpha"] + 1e-8) - d_zeta = g_zeta / (i_zeta + pen["lambda_zeta"] + 1e-8) - slope = g_b * d_b + g_alpha * d_alpha + float(g_zeta @ d_zeta) - if slope < 1e-20: - break - step, accepted = 1.0, False - for _ls in range(30): - cand_b = b[i] + step * d_b - cand_alpha = alpha[i] + step * d_alpha if free_alpha else alpha[i] - cand_zeta = zeta_i + step * d_zeta - cand_q = _item_q( - n_i, r_i, eta_of(cand_alpha, cand_b, cand_zeta), cand_alpha, - cand_b, cand_zeta, free_alpha, uses_space, pen, - ) - if cand_q > cur_q + 1e-4 * step * slope: - b[i] = cand_b - if free_alpha: - alpha[i] = float(np.clip(cand_alpha, -6.0, 3.0)) - zeta_i = cand_zeta - cur_q = cand_q - accepted = True - break - step *= 0.5 - if not accepted: - break - zeta[i] = zeta_i - - # --- M-step: tau (distance kind only) --- - if uses_space and anchor_tau is None and _interaction_kind(model) == "distance": - gamma = float(np.exp(tau)) - diff = x_grid[None, :, :] - zeta[:, None, :] - dist = np.sqrt(eps_distance + np.sum(diff * diff, axis=2)) # (I, Nx) - a_all = np.exp(alpha) if free_alpha else np.ones(n_items) - theta_it = theta_sx[:, factor_id] # (S, I, Qt) - n_all = nbar[:, factor_id] - mbar # (S, I, Qt, Nx) - off_all = offsets[:, :, None, None] if offsets is not None else 0.0 - eta = ( - a_all[None, :, None, None] * theta_it[:, :, :, None] - + b[None, :, None, None] - + off_all - - gamma * dist[None, :, None, :] - ) - prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -700, 700))) - resid = rbar - n_all * prob - deta = -gamma * dist[None, :, None, :] - grad = float((resid * deta).sum()) - pen["lambda_tau"] * (tau - pen["mu_tau"]) - info = float((n_all * prob * (1.0 - prob) * deta * deta).sum()) + pen["lambda_tau"] - if info > 0.0: - direction = grad / info - - def total_q(tau_c: float) -> float: - e = ( - a_all[None, :, None, None] * theta_it[:, :, :, None] - + b[None, :, None, None] - + off_all - - np.exp(tau_c) * dist[None, :, None, :] - ) - qv = float( - np.sum(rbar * _log_sigmoid(e) + (n_all - rbar) * _log_sigmoid(-e)) - ) - qv -= 0.5 * pen["lambda_b"] * float(b @ b) - if free_alpha: - da = alpha - pen["mu_alpha"] - qv -= 0.5 * pen["lambda_alpha"] * float(da @ da) - qv -= 0.5 * pen["lambda_zeta"] * float((zeta * zeta).sum()) - dt = tau_c - pen["mu_tau"] - return qv - 0.5 * pen["lambda_tau"] * dt * dt - - cur = total_q(tau) - step = 1.0 - for _ls in range(20): - cand = float(np.clip(tau + step * direction, -10.0, 5.0)) - if total_q(cand) > cur: - tau = cand - break - step *= 0.5 - - # --- M-step: covariate coefficient delta (Debeer-Janssen) --- - if w_cov is not None: - gamma = float(np.exp(tau)) - a_all = np.exp(alpha) if free_alpha else np.ones(n_items) - theta_it = theta_sx[:, factor_id] # (S, I, Qt) - n_all = nbar[:, factor_id] - mbar - kind_i = _interaction_kind(model) - if kind_i == "distance": - diffz = x_grid[None, :, :] - zeta[:, None, :] - distz = np.sqrt(eps_distance + np.sum(diffz * diffz, axis=2)) # (I, Nx) - interaction_term = -gamma * distz[None, :, None, :] - elif kind_i == "inner": - interaction_term = (zeta @ x_grid.T)[None, :, None, :] - else: - interaction_term = 0.0 - - def eta_delta(delta_c: float) -> np.ndarray: - return ( - a_all[None, :, None, None] * theta_it[:, :, :, None] - + b[None, :, None, None] - + (delta_c * w_cov)[:, :, None, None] - + interaction_term - ) - - eta = eta_delta(delta) - prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -700, 700))) - resid = rbar - n_all * prob - w_bcast = w_cov[:, :, None, None] - grad_d = float((resid * w_bcast).sum()) - info_d = float((n_all * prob * (1.0 - prob) * w_bcast * w_bcast).sum()) - if info_d > 0.0: - direction = grad_d / info_d - - def q_of_delta(delta_c: float) -> float: - e = eta_delta(delta_c) - return float( - np.sum(rbar * _log_sigmoid(e) + (n_all - rbar) * _log_sigmoid(-e)) - ) - - cur = q_of_delta(delta) - step = 1.0 - for _ls in range(20): - cand = float(np.clip(delta + step * direction, -10.0, 10.0)) - if q_of_delta(cand) > cur: - delta = cand - break - step *= 0.5 - - # --- M-step: population parameters --- - if kind in {"multigroup", "singlefree"}: - g_start = 0 if kind == "singlefree" else 1 - for g in range(g_start, n_groups): - for d in range(n_dims): - theta_g = mu[g, d] + sigma[g, d] * t_nodes # (Qt,) - w = nbar[g, d] # (Qt, Nx) - w_sum = float(w.sum()) - if w_sum > 1e-10: - m1 = float((w * theta_g[:, None]).sum()) - m2 = float((w * (theta_g**2)[:, None]).sum()) - mean = m1 / w_sum - var = max(m2 / w_sum - mean * mean, 0.01) - mu[g, d] = mean - sigma[g, d] = float(np.clip(np.sqrt(var), 0.1, 10.0)) - elif kind == "multilevel" and n_clusters: - e_v2 = sum_e_v2 / n_clusters - sigma_u = float(np.clip(np.sqrt(sigma_u * sigma_u * e_v2), 0.0, 10.0)) - n_iter += 1 - - # --- final EAP pass --- - ctx = _build_contexts(pop, mu, sigma, sigma_u, n_dims, q_u) - final_offsets = delta * w_cov if w_cov is not None else None - logp1, logp0, c0 = _build_tables( - alpha, b, zeta, tau, model, factor_id, ctx, t_nodes, x_grid, eps_distance, - n_dims, final_offsets, - ) - if not converged: - if kind in {"single", "singlefree", "multigroup"}: - s_of_person = ( - group_id if kind == "multigroup" else np.zeros(n_persons, dtype=np.int64) - ) - _, _, final_log_lp = _person_logliks( - y, - observed, - factor_id, - logp1, - logp0, - c0, - t_logw, - x_logw, - s_of_person, - n_dims, - ) - if zero_inflation: - all_zero_bcast = all_zero - final_lp_mix, final_w_irt = _zi_mix(final_log_lp) - final_loglik = float(final_lp_mix.sum()) - zero_resp = 1.0 - final_w_irt - else: - final_loglik = float(final_log_lp.sum()) - else: - final_lp_v = np.empty((n_persons, ctx["n_ctx"])) - for v in range(ctx["n_ctx"]): - s_all = np.full(n_persons, v, dtype=np.int64) - _, _, final_lp = _person_logliks( - y, - observed, - factor_id, - logp1, - logp0, - c0, - t_logw, - x_logw, - s_all, - n_dims, - ) - final_lp_v[:, v] = final_lp - if zero_inflation: - all_zero_bcast = all_zero[:, None] - final_lp_v, final_w_irt_v = _zi_mix(final_lp_v) - final_log_cluster = ( - np.zeros((n_clusters, ctx["n_ctx"])) + ctx["u_logw"][None, :] - ) - np.add.at(final_log_cluster, cluster_id, final_lp_v) - final_mc = final_log_cluster.max(axis=1, keepdims=True) - final_lse = np.squeeze(final_mc, axis=1) + np.log( - np.exp(final_log_cluster - final_mc).sum(axis=1) - ) - final_loglik = float(final_lse.sum()) - if zero_inflation: - final_cluster_post = np.exp(final_log_cluster - final_lse[:, None]) - zero_resp = ( - final_cluster_post[cluster_id] * (1.0 - final_w_irt_v) - ).sum(axis=1) - loglik_trace.append(final_loglik) - if len(loglik_trace) > 1 and abs(loglik_trace[-1] - loglik_trace[-2]) < tol: - converged = True - - theta_eap = np.zeros((n_persons, n_dims)) - theta_m2 = np.zeros((n_persons, n_dims)) - xi_eap = np.zeros((n_persons, latent_dim)) - u_eap = np.zeros(n_clusters) - - def eap_accumulate(s_all: np.ndarray, w_outer: np.ndarray) -> None: - l, log_zdx, log_lp = _person_logliks( - y, observed, factor_id, logp1, logp0, c0, t_logw, x_logw, s_all, n_dims - ) - post = _posteriors(l, log_zdx, log_lp, t_logw, x_logw) - wpost = post * w_outer[:, None, None, None] - px = wpost.sum(axis=(1, 2)) / n_dims # (P, Nx) — same for every d - xi_eap[:] += px @ x_grid - theta_s = ctx["shift"][s_all][:, :, None] + ctx["scale"][s_all][:, :, None] * t_nodes - theta_eap[:] += np.einsum("pdtx,pdt->pd", wpost, theta_s, optimize=True) - theta_m2[:] += np.einsum("pdtx,pdt->pd", wpost, theta_s**2, optimize=True) - - if kind in {"single", "singlefree"}: - eap_accumulate(np.zeros(n_persons, dtype=np.int64), np.ones(n_persons)) - elif kind == "multigroup": - eap_accumulate(group_id, np.ones(n_persons)) - else: - lp_v = np.empty((n_persons, ctx["n_ctx"])) - for v in range(ctx["n_ctx"]): - s_all = np.full(n_persons, v, dtype=np.int64) - _, _, lp = _person_logliks( - y, observed, factor_id, logp1, logp0, c0, t_logw, x_logw, s_all, n_dims - ) - lp_v[:, v] = lp - cluster_post, engager_context_post = _multilevel_context_posteriors( - lp_v, - all_zero, - cluster_id, - n_clusters, - ctx["u_logw"], - pi_zero if zero_inflation else None, - ) - u_eap[:] = cluster_post @ (sigma_u * ctx["u_nodes"]) - for v in range(ctx["n_ctx"]): - w_outer = engager_context_post[:, v] - w_outer = np.where(w_outer >= 1e-14, w_outer, 0.0) - if not w_outer.any(): - continue - eap_accumulate(np.full(n_persons, v, dtype=np.int64), w_outer) - - theta_sd = np.sqrt(np.maximum(theta_m2 - theta_eap**2, 0.0)) - - # free parameters: items (respecting anchors) + tau + population - per_item = 1 + int(free_alpha) + (latent_dim if uses_space else 0) - n_free_items = int((~fixed_mask).sum()) - tau_free = ( - uses_space and anchor_tau is None and _interaction_kind(model) == "distance" - ) - pop_params = { - "single": 0, - "singlefree": 2 * n_dims, - "multigroup": 2 * n_dims * max(n_groups - 1, 0), - "multilevel": 1, - }[kind] - n_parameters = ( - n_free_items * per_item - + int(tau_free) - + pop_params - + int(zero_inflation) - + int(w_cov is not None) - ) - ll_final = loglik_trace[-1] if loglik_trace else float("nan") - k, nf = float(n_parameters), float(n_persons) - dev = -2.0 * ll_final - aic = dev + 2.0 * k - ic = { - "aic": aic, - "bic": dev + k * np.log(nf), - "aicc": aic + 2.0 * k * (k + 1.0) / (nf - k - 1.0) if nf - k - 1.0 > 0 else float("nan"), - "sabic": dev + k * np.log((nf + 2.0) / 24.0), - "caic": dev + k * (np.log(nf) + 1.0), - "n_parameters": n_parameters, - "n": n_persons, - } - - if uses_space and anchors is None: - # anchored calibrations inherit the anchor orientation - _pca_align(zeta, xi_eap) - - return { - "alpha": alpha, - "b": b, - "zeta": zeta, - "tau": float(tau), - "theta_eap": theta_eap, - "theta_sd": theta_sd, - "xi_eap": xi_eap, - "mu": mu, - "sigma": sigma, - "sigma_u": float(sigma_u), - "u_eap": u_eap, - "loglik_trace": loglik_trace, - "n_iter": n_iter, - "converged": converged, - "status": "converged" if converged else "max_iter_reached", - "ic": ic, - "delta": float(delta), - "pi_zero": float(pi_zero), - "zero_responsibility": zero_resp if zero_inflation else np.zeros(0), - } - - -def _pca_align(zeta: np.ndarray, xi: np.ndarray) -> None: - """In-place rotation/reflection alignment (mirror of the Rust Jacobi code): - principal axes of the uncentered second moment of ``zeta``, columns ordered - by descending eigenvalue, sign fixed by the largest-|coordinate| item.""" - k = zeta.shape[1] - if k == 1: - i = int(np.argmax(np.abs(zeta[:, 0]))) - if zeta[i, 0] < 0.0: - zeta *= -1.0 - xi *= -1.0 - return - m = zeta.T @ zeta - evals, evecs = np.linalg.eigh(m) - order = np.argsort(evals)[::-1] - rot = evecs[:, order] - zeta[:] = zeta @ rot - xi[:] = xi @ rot - for c in range(k): - i = int(np.argmax(np.abs(zeta[:, c]))) - if zeta[i, c] < 0.0: - zeta[:, c] *= -1.0 - xi[:, c] *= -1.0 - - -def score_eap( - y: np.ndarray, - observed: np.ndarray, - factor_id: np.ndarray, - alpha: np.ndarray, - b: np.ndarray, - zeta: np.ndarray, - tau: float, - model: str = "MLS2PLM", - n_dims: int | None = None, - q_theta: int = 21, - q_xi: int = 11, - eps_distance: float = 1e-8, -) -> dict: - """EAP scoring of response vectors with **frozen** item parameters. - - The serving-side counterpart of :func:`fit_marginal_numpy`: one E-step - pass under the standard `N(0, 1)` population prior, no parameter updates. - Returns per-person `theta_eap`, `theta_sd`, `xi_eap`, and the marginal - log-likelihood of each response vector. - """ - y = np.asarray(y, dtype=np.float64) - observed = np.asarray(observed, dtype=bool) - factor_values = np.asarray(factor_id) - if factor_values.ndim != 1 or factor_values.size == 0: - raise ValueError("factor_id must be a non-empty 1-D array") - try: - factor_numeric = np.asarray(factor_values, dtype=np.float64) - except (TypeError, ValueError) as exc: - raise ValueError("factor_id must contain integer values") from exc - if ( - not np.all(np.isfinite(factor_numeric)) - or np.any(factor_numeric != np.floor(factor_numeric)) - or np.any(factor_numeric < 0) - ): - raise ValueError("factor_id must contain finite non-negative integers") - max_factor = int(factor_numeric.max()) - if max_factor >= MAX_FACTOR_DIMENSIONS: - raise ValueError( - f"factor_id values must be below {MAX_FACTOR_DIMENSIONS}" - ) - if n_dims is None: - n_dims = max_factor + 1 - elif ( - not isinstance(n_dims, (int, np.integer)) - or isinstance(n_dims, (bool, np.bool_)) - or not (max_factor < int(n_dims) <= MAX_FACTOR_DIMENSIONS) - ): - raise ValueError( - f"n_dims must be an integer in {max_factor + 1}.." - f"{MAX_FACTOR_DIMENSIONS}" - ) - n_dims = int(n_dims) - factor_id = factor_numeric.astype(np.int64) - model = model.upper() - _, uses_space = _model_flags(model) - alpha = np.asarray(alpha, dtype=np.float64) - b = np.asarray(b, dtype=np.float64) - zeta = np.asarray(zeta, dtype=np.float64) - latent_dim = zeta.shape[1] - n_persons = y.shape[0] - - t_nodes, t_weights = _gh(q_theta) - t_logw = np.log(t_weights) - if uses_space: - x_grid, x_logw = _xi_grid(q_xi, latent_dim) - else: - x_grid, x_logw = np.zeros((1, latent_dim)), np.zeros(1) - - ctx = {"n_ctx": 1, "shift": np.zeros((1, n_dims)), "scale": np.ones((1, n_dims))} - logp1, logp0, c0 = _build_tables( - alpha, b, zeta, float(tau), model, factor_id, ctx, t_nodes, x_grid, - eps_distance, n_dims, - ) - s_all = np.zeros(n_persons, dtype=np.int64) - y_filled = np.where(observed, y, 0.0) - l, log_zdx, log_lp = _person_logliks( - y_filled, observed, factor_id, logp1, logp0, c0, t_logw, x_logw, s_all, n_dims - ) - post = _posteriors(l, log_zdx, log_lp, t_logw, x_logw) - px = post.sum(axis=(1, 2)) / n_dims # (P, Nx) - xi_eap = px @ x_grid - theta_eap = np.einsum("pdtx,t->pd", post, t_nodes, optimize=True) - theta_m2 = np.einsum("pdtx,t->pd", post, t_nodes**2, optimize=True) - theta_sd = np.sqrt(np.maximum(theta_m2 - theta_eap**2, 0.0)) - return { - "theta_eap": theta_eap, - "theta_sd": theta_sd, - "xi_eap": xi_eap, - "loglik": log_lp, - } - - -def category_logprobs(base, scores, intercepts): - """Unified scoring-function log category probabilities — the NumPy parity - reference for the forthcoming Rust GPCM/nominal cell (see - ``docs/papers/gpcm-nominal-design-spec.md``). - - With the baseline category 0 pinned (``scores[0] = intercepts[0] = 0``), - ``psi_k = scores[k] * base + intercepts[k]`` and the returned value is the - numerically-stable ``log softmax_k(psi)``. ``base`` broadcasts over any - leading shape (e.g. a person x node grid); the category axis is last. - - Nests the models: - - binary 2PL / Rasch: ``K=2``, ``scores=[0, 1]``, ``intercepts=[0, b]`` — - then ``logP_1 = log_sigmoid(base + b)`` exactly (the design's free - bit-parity check). - - GPCM (Muraki): ``scores = [0, 1, ..., K-1]`` fixed, ``intercepts`` free. - - nominal (Bock): both ``scores`` and ``intercepts`` free (category 0 pinned). - """ - base = np.asarray(base, dtype=np.float64) - scores = np.asarray(scores, dtype=np.float64) - intercepts = np.asarray(intercepts, dtype=np.float64) - if scores.ndim != 1 or intercepts.shape != scores.shape: - raise ValueError("scores and intercepts must be 1-D arrays of equal length K") - if scores.size < 2: - raise ValueError("need at least K=2 categories") - if scores[0] != 0.0 or intercepts[0] != 0.0: - raise ValueError("baseline category 0 must be pinned: scores[0] = intercepts[0] = 0") - psi = scores * base[..., None] + intercepts # (..., K) - m = psi.max(axis=-1, keepdims=True) - log_z = m[..., 0] + np.log(np.exp(psi - m).sum(axis=-1)) - return psi - log_z[..., None] - - -def gpcm_node_gradient(base, scores, intercepts, counts): - """Analytic gradient of the expected complete-data multinomial log-likelihood - at one quadrature node for the unified GPCM/nominal cell — the parity - reference for the Rust M-step (``docs/papers/gpcm-nominal-design-spec.md``). - - ``counts[k]`` are the expected category counts ``r_k`` accumulated in the - E-step. Returns ``(g_intercepts, g_base, g_scores)``: - - - ``g_intercepts[m-1] = r_m - n * P_m`` (the category residual ``resid_m``) - for the free intercepts ``m = 1..K-1`` (baseline 0 pinned). - - ``g_base = sum_k s_k * resid_k`` — the score-weighted residual ``R`` that - multiplies ``d base / d(alpha, zeta, tau)`` in the chain rule (so the - existing binary ``deta`` terms are reused verbatim). - - ``g_scores[m-1] = resid_m * base`` for the free nominal scores - ``m = 1..K-1`` (zero for GPCM, whose scores are fixed). - - ``sum_k resid_k == 0`` by construction (softmax closure). - """ - scores = np.asarray(scores, dtype=np.float64) - counts = np.asarray(counts, dtype=np.float64) - p = np.exp(category_logprobs(base, scores, intercepts)) - n = counts.sum() - resid = counts - n * p - g_intercepts = resid[1:] - g_base = float(np.dot(scores, resid)) - g_scores = resid[1:] * np.asarray(base, dtype=np.float64) - return g_intercepts, g_base, g_scores - - -def _gpcm_item_negll_grad(params, theta_nodes, r_counts): - """Per-item negative expected complete-data log-lik and its gradient over the - quadrature nodes. ``params = [log_a, c_1..c_{K-1}]``; ``r_counts[node, k]`` - are the E-step expected category counts. GPCM scores ``s_k = k`` are fixed.""" - k_cat = r_counts.shape[1] - scores = np.arange(k_cat, dtype=np.float64) - intercepts = np.concatenate([[0.0], params[1:]]) - a = np.exp(params[0]) - base = a * np.asarray(theta_nodes, dtype=np.float64) - lp = category_logprobs(base, scores, intercepts) # (n_node, K) - ll = float(np.sum(r_counts * lp)) - p = np.exp(lp) - n = r_counts.sum(axis=1) - resid = r_counts - n[:, None] * p - grad = np.zeros_like(params) - grad[1:] = resid[:, 1:].sum(axis=0) - grad[0] = float(np.sum((resid @ scores) * base)) # d base / d log_a = a*theta = base - return -ll, -grad - - -def _gpcm_m_step_item(params0, theta_nodes, r_counts, n_newton=10): - """Newton-Raphson M-step for one item (concave GPCM item likelihood), with a - finite-difference Hessian on the analytic gradient (K is small).""" - p = params0.astype(np.float64).copy() - for _ in range(n_newton): - _, g = _gpcm_item_negll_grad(p, theta_nodes, r_counts) - h = 1e-5 - hess = np.zeros((p.size, p.size)) - for j in range(p.size): - pj = p.copy() - pj[j] += h - _, gj = _gpcm_item_negll_grad(pj, theta_nodes, r_counts) - hess[:, j] = (gj - g) / h - hess = 0.5 * (hess + hess.T) + 1e-8 * np.eye(p.size) - try: - step = np.linalg.solve(hess, g) - except np.linalg.LinAlgError: - step = g - p = p - step - if np.max(np.abs(step)) < 1e-9: - break - return p - - -def fit_gpcm_numpy(y, n_cat, q_theta=21, max_iter=80, tol=1e-6): - """Unidimensional GPCM marginal MLE via Bock-Aitkin EM — the - NumPy parity reference for the polytomous cell of the forthcoming Rust - kernel (``docs/papers/gpcm-nominal-design-spec.md``). Validates the unified - softmax cell (:func:`category_logprobs`) and residual gradient - (:func:`gpcm_node_gradient`) in a full EM loop before the Rust port. - - ``y`` is persons x items with integer categories ``0..n_cat-1`` (complete - data). ``theta ~ N(0, 1)`` on a ``q_theta``-node Gauss-Hermite grid. The - returned likelihood and trace endpoint are evaluated at the returned item - parameters. ``converged`` means the absolute observed-data likelihood change - met the reported scaled tolerance before ``max_iter``. The finite-difference - Hessian Newton item M-step is a repository-specific numerical choice. - - Bock and Aitkin (1981) established marginal item-parameter estimation using - EM, while Muraki (1992) derived the EM calibration of the GPCM. - - References - ---------- - Bock, R. D., & Aitkin, M. (1981). Marginal maximum likelihood estimation of - item parameters: Application of an EM algorithm. *Psychometrika, 46*(4), - 443–459. https://doi.org/10.1007/BF02293801 - - Muraki, E. (1992). A generalized partial credit model: Application of an EM - algorithm. *ETS Research Report Series, 1992*(1), i–30. - https://doi.org/10.1002/j.2333-8504.1992.tb01436.x - """ - try: - yf = np.asarray(y, dtype=np.float64) - except (TypeError, ValueError) as exc: - raise ValueError("responses must be a numeric 2-D array") from exc - if yf.ndim != 2 or 0 in yf.shape: - raise ValueError("responses must be a non-empty persons x items array") - if ( - not isinstance(n_cat, (int, np.integer)) - or isinstance(n_cat, (bool, np.bool_)) - or n_cat < 2 - ): - raise ValueError("n_cat must be an integer >= 2") - if ( - not isinstance(q_theta, (int, np.integer)) - or isinstance(q_theta, (bool, np.bool_)) - or q_theta < 1 - ): - raise ValueError("q_theta must be an integer >= 1") - if ( - not isinstance(max_iter, (int, np.integer)) - or isinstance(max_iter, (bool, np.bool_)) - or max_iter < 1 - ): - raise ValueError("max_iter must be an integer >= 1") - if ( - not isinstance(tol, (int, float, np.integer, np.floating)) - or isinstance(tol, (bool, np.bool_)) - or not np.isfinite(tol) - or tol <= 0 - ): - raise ValueError("tol must be finite and > 0") - - k_cat = int(n_cat) - if k_cat > MAX_GPCM_CATEGORIES: - raise ValueError( - f"n_cat must be at most {MAX_GPCM_CATEGORIES} to bound the " - "per-item Hessian allocation" - ) - if ( - not np.all(np.isfinite(yf)) - or np.any(yf != np.floor(yf)) - or yf.min() < 0 - or yf.max() >= k_cat - ): - raise ValueError(f"responses must be integer categories in 0..{k_cat - 1}") - y = yf.astype(np.int64) - n_persons, n_items = y.shape - nodes, wts = _gh(q_theta) - log_prior = np.log(wts) - scores = np.arange(k_cat, dtype=np.float64) - - params = np.zeros((n_items, k_cat)) # column 0 = log_a, columns 1.. = intercepts - for i in range(n_items): - freq = np.array([(y[:, i] == k).mean() for k in range(k_cat)]) + 1e-3 - params[i, 1:] = np.log(freq[1:] / freq[0]) - - def estep(current_params): - item_lp = [ - category_logprobs( - np.exp(current_params[i, 0]) * nodes, - scores, - np.concatenate([[0.0], current_params[i, 1:]]), - ) - for i in range(n_items) - ] - log_node = np.zeros((n_persons, q_theta)) - for i in range(n_items): - log_node += item_lp[i][:, y[:, i]].T - log_node += log_prior[None, :] - mx = log_node.max(axis=1, keepdims=True) - w = np.exp(log_node - mx) - denom = w.sum(axis=1, keepdims=True) - post = w / denom - ll = float(np.sum(mx[:, 0] + np.log(denom[:, 0]))) - return ll, post - - ll, post = estep(params) - loglik_trace = [ll] - converged = False - final_delta = np.inf - stopping_tolerance = float(tol * (1.0 + abs(ll))) - for it in range(1, max_iter + 1): - for i in range(n_items): - r = np.stack([post[y[:, i] == k].sum(axis=0) for k in range(k_cat)], axis=1) - params[i] = _gpcm_m_step_item(params[i], nodes, r) - next_ll, post = estep(params) - if not np.isfinite(next_ll): - raise RuntimeError("GPCM EM produced a non-finite observed-data likelihood") - final_delta = float(abs(next_ll - ll)) - stopping_tolerance = float(tol * (1.0 + abs(ll))) - ll = next_ll - loglik_trace.append(ll) - if final_delta <= stopping_tolerance: - converged = True - break - - a = np.exp(params[:, 0]) - intercepts = np.concatenate([np.zeros((n_items, 1)), params[:, 1:]], axis=1) - thresholds = intercepts[:, :-1] - intercepts[:, 1:] # Muraki b_{i,k} - return { - "a": a, - "alpha": params[:, 0], - "intercepts": intercepts, - "thresholds": thresholds, - "loglik": ll, - "n_iter": it, - "converged": converged, - "termination_reason": "tolerance" if converged else "max_iter_reached", - "loglik_trace": np.asarray(loglik_trace, dtype=np.float64), - "final_delta": final_delta, - "stopping_tolerance": stopping_tolerance, - } - - -def grm_category_logprobs(base, thresholds): - """NumPy parity reference for the Rust GRM cumulative-logit cell - (``mlsirm_core::poly::grm_logprobs``). ``thresholds`` are the ``K-1`` - cumulative boundary intercepts ``beta_k`` (ordered decreasing); - ``P(Y >= k) = sigmoid(base + beta_k)``. Returns ``log P(Y = k)`` with the - category axis last (``base`` broadcasts over any leading shape). Middle - categories use a factorized cumulative-probability difference so that a - valid narrow category remains finite in either extreme logistic tail. - - Samejima (1969) introduced the graded response formulation used here. - - References - ---------- - Samejima, F. (1969). Estimation of latent ability using a response pattern - of graded scores. *Psychometrika, 34*(S1), 1–97. - https://doi.org/10.1007/BF03372160 - """ - base = np.asarray(base, dtype=np.float64) - thresholds = np.asarray(thresholds, dtype=np.float64) - if thresholds.ndim != 1 or thresholds.size < 1: - raise ValueError("thresholds must be a 1-D array of length K-1 >= 1") - kb = thresholds.shape[0] - eta = base[..., None] + thresholds # (..., K-1) - ls = -np.logaddexp(0.0, -eta) # log sigmoid(eta) = log P(Y>=k) - ls_neg = -np.logaddexp(0.0, eta) # log(1 - P(Y>=k)) - out = np.empty(base.shape + (kb + 1,), dtype=np.float64) - out[..., 0] = ls_neg[..., 0] # P(Y=0) - for k in range(1, kb): # P(Y=k) = e^{ls[k-1]} - e^{ls[k]} - upper = eta[..., k - 1] - lower = eta[..., k] - out[..., k] = ( - -np.logaddexp(0.0, -upper) - - np.logaddexp(0.0, lower) - + np.log(-np.expm1(lower - upper)) - ) - out[..., kb] = ls[..., kb - 1] # P(Y=K-1) - return out diff --git a/python/fast_mlsirm/exposure.py b/python/fast_mlsirm/exposure.py deleted file mode 100644 index 4f403a547..000000000 --- a/python/fast_mlsirm/exposure.py +++ /dev/null @@ -1,544 +0,0 @@ -"""Item-exposure control designs for computerized adaptive testing: -Sympson-Hetter calibration and the a-stratified multistage design. -All numeric work happens in the Rust core (``mlsirm_core::exposure``); this -module only validates and marshals. - -Source status: the algorithm was confirmed from secondary sources that were -READ in full — Georgiadou, Triantafillou, and Economides (2007), which -describes the Sympson-Hetter probabilistic gate and its iterative -calibration, and Barrada, Olea, and Ponsoda (2007), whose Equations 1-3 give -the exact update rule ``k_i <- min(1, r_max / P(S_i))`` implemented here. -The original conference papers (Sympson & Hetter, 1985; Hetter & Sympson, -1997) were NOT read; they are cited as the method's origin as attributed by -the read sources. Convergence is NOT guaranteed (van der Linden, 2003, -abstract); the stopping rule ``max P(A) <= r_max + tol`` is a practical -criterion, not a theorem. - -REDUCED SCOPE (spec decision): dichotomous 3PL max-information CAT with an -interim EAP trait estimate only. No theta-stratified (conditional) variants, -no forced-administration fallback when the pool is exhausted (an error is -raised instead — a repository policy, not a classical prescription), and no -claim of a "classical" iteration count. - -References (APA 7th ed.): - Barrada, J. R., Olea, J., & Ponsoda, V. (2007). Methods for restricting - maximum exposure rate in computerized adaptive testing. - *Methodology, 3*(1), 14-23. https://doi.org/10.1027/1614-2241.3.1.14 - Georgiadou, E., Triantafillou, E., & Economides, A. A. (2007). A review - of item exposure control strategies for computerized adaptive - testing developed from 1983 to 2005. *The Journal of Technology, - Learning, and Assessment, 5*(8). - Hetter, R. D., & Sympson, J. B. (1997). Item exposure control in - CAT-ASVAB. In W. A. Sands, B. K. Waters, & J. R. McBride (Eds.), - *Computerized adaptive testing: From inquiry to operation* - (pp. 141-144). American Psychological Association. (As cited in - Georgiadou et al.; not read.) - Sympson, J. B., & Hetter, R. D. (1985, October). Controlling - item-exposure rates in computerized adaptive testing. *Proceedings - of the 27th Annual Meeting of the Military Testing Association* - (pp. 973-977). Navy Personnel Research and Development Center. - (As cited in Georgiadou et al.; not read.) - van der Linden, W. J. (2003). Some alternatives to Sympson-Hetter - item-exposure control in computerized adaptive testing. - *Journal of Educational and Behavioral Statistics, 28*(3), - 249-265. (Abstract only.) -""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -def _as_int(name: str, value, minimum: int = 0, maximum: int | None = None) -> int: - if isinstance(value, bool) or not isinstance( - value, (int, np.integer, float, np.floating) - ): - raise ValueError(f"{name} must be an integer, got {value!r}") - if isinstance(value, (float, np.floating)) and not np.isfinite(value): - raise ValueError(f"{name} must be an integer, got {value!r}") - iv = int(value) - if iv != value: - raise ValueError(f"{name} must be an integer, got {value!r}") - if iv < minimum or (maximum is not None and iv > maximum): - raise ValueError(f"{name} out of range: {iv}") - return iv - - -@dataclass -class SympsonHetterResult: - """Sympson-Hetter calibration output. - - ``k`` holds the exposure-control parameters ``k_i = P(A_i | S_i)`` in - ``(0, 1]``; ``exposure``/``selection`` the administration and selection - rates ``P(A_i)``/``P(S_i)`` from the final simulation cycle; - ``max_exposure`` their maximum; ``history_max_exposure`` the per-cycle - trace. The returned ``k`` is always the vector that produced the - reported rates (the update is skipped after the final cycle).""" - - k: np.ndarray - exposure: np.ndarray - selection: np.ndarray - max_exposure: float - n_iter: int - converged: bool - history_max_exposure: np.ndarray - - -def sympson_hetter( - a: np.ndarray, - b: np.ndarray, - c: np.ndarray | None = None, - *, - r_max: float = 0.25, - test_length: int = 20, - n_simulees: int = 1000, - max_iter: int = 20, - tol: float = 0.02, - seed: int = 20250724, - q_theta: int = 41, -) -> SympsonHetterResult: - """Calibrate Sympson-Hetter exposure-control parameters by simulation. - - ``a``, ``b``, ``c`` are 3PL item parameters (``c=None`` gives 2PL); - ``r_max`` the target maximum exposure rate in ``(0, 1]`` (must satisfy - ``r_max >= test_length / n_items``, a counting-identity feasibility - bound derived in the Rust core); ``tol`` the Monte-Carlo tolerance on - the stopping rule. ``r_max = 1`` reduces exactly to unconstrained - max-information CAT (no exposure randomization is consumed). - """ - from . import _core - - a = np.ascontiguousarray(a, dtype=np.float64) - b = np.ascontiguousarray(b, dtype=np.float64) - if c is None: - c = np.zeros_like(a) - c = np.ascontiguousarray(c, dtype=np.float64) - - _usize_max = int(np.iinfo(np.uintp).max) - r = _core.py_sympson_hetter( - a, - b, - c, - float(r_max), - _as_int("test_length", test_length, maximum=_usize_max), - _as_int("n_simulees", n_simulees, maximum=_usize_max), - _as_int("max_iter", max_iter, maximum=_usize_max), - float(tol), - _as_int("seed", seed, maximum=2**64 - 1), - _as_int("q_theta", q_theta, maximum=_usize_max), - ) - return SympsonHetterResult( - k=np.asarray(r["k"]), - exposure=np.asarray(r["exposure"]), - selection=np.asarray(r["selection"]), - max_exposure=float(r["max_exposure"]), - n_iter=int(r["n_iter"]), - converged=bool(r["converged"]), - history_max_exposure=np.asarray(r["history_max_exposure"]), - ) - -@dataclass -class AStratifiedResult: - """a-stratified multistage CAT simulation output. - - ``exposure`` holds the administration rates ``P(A_i)``; ``stratum`` the - 0-based stratum index per item (ascending discrimination); - ``stage_lengths`` the number of items administered per stage - (``sum == test_length``); ``theta_rmse``/``theta_bias`` the final-EAP - recovery against the simulated true thetas.""" - - exposure: np.ndarray - max_exposure: float - stratum: np.ndarray - stage_lengths: np.ndarray - theta_rmse: float - theta_bias: float - - -def a_stratified( - a: np.ndarray, - b: np.ndarray, - c: np.ndarray | None = None, - *, - n_strata: int = 4, - test_length: int = 20, - n_simulees: int = 1000, - seed: int = 20250724, - q_theta: int = 41, -) -> AStratifiedResult: - """Simulate the a-stratified multistage CAT design (Chang & Ying, 1999). - - The pool is sorted ascending by discrimination and split into - ``n_strata`` contiguous strata; stage ``k`` administers items only from - stratum ``k``, choosing the item minimizing ``|b_i - theta_hat|`` - (b-matching — the selection rule as restated by Barrada, Mazuela, & - Olea, 2006; the Chang & Ying full text was not read). The near-equal - stratum/stage partitions (first strata one larger), the interim EAP - estimator, and the initial ``theta_hat = 0`` are repository - implementation choices, not claims from the paper. b-blocking (Chang, - Qian, & Ying, 2001) is out of scope. - - References (APA 7th ed.): - Barrada, J. R., Mazuela, P., & Olea, J. (2006). Maximum information - stratification method for controlling item exposure in - computerized adaptive testing. *Psicothema, 18*(1), 156-159. - (Read in full.) - Chang, H.-H., & Ying, Z. (1999). a-Stratified multistage - computerized adaptive testing. *Applied Psychological - Measurement, 23*(3), 211-222. - https://doi.org/10.1177/01466219922031338 (Abstract only.) - Chang, H.-H., Qian, J., & Ying, Z. (2001). a-Stratified multistage - computerized adaptive testing with b blocking. *Applied - Psychological Measurement, 25*(4), 333-341. (Not read; cited - only as the deferred b-blocking extension.) - """ - from . import _core - - a = np.ascontiguousarray(a, dtype=np.float64) - b = np.ascontiguousarray(b, dtype=np.float64) - if c is None: - c = np.zeros_like(a) - c = np.ascontiguousarray(c, dtype=np.float64) - - _usize_max = int(np.iinfo(np.uintp).max) - r = _core.py_a_stratified( - a, - b, - c, - _as_int("n_strata", n_strata, maximum=_usize_max), - _as_int("test_length", test_length, maximum=_usize_max), - _as_int("n_simulees", n_simulees, maximum=_usize_max), - _as_int("seed", seed, maximum=2**64 - 1), - _as_int("q_theta", q_theta, maximum=_usize_max), - ) - return AStratifiedResult( - exposure=np.asarray(r["exposure"]), - max_exposure=float(r["max_exposure"]), - stratum=np.asarray(r["stratum"], dtype=np.intp), - stage_lengths=np.asarray(r["stage_lengths"], dtype=np.intp), - theta_rmse=float(r["theta_rmse"]), - theta_bias=float(r["theta_bias"]), - ) - -def kl_information( - a: np.ndarray, - b: np.ndarray, - c: np.ndarray | None = None, - *, - theta0: float, - delta: float, -) -> np.ndarray: - """Chang-Ying (1996) Kullback-Leibler information index per item. - - Returns the UNNORMALIZED area of the pointwise Bernoulli KL divergence - ``K_i(theta || theta0) = P_i(theta0) ln[P_i(theta0)/P_i(theta)] + - Q_i(theta0) ln[Q_i(theta0)/Q_i(theta)]`` over - ``[theta0 - delta, theta0 + delta]`` for each 3PL item (``c = 0`` gives - 2PL) — Chang and Ying's Equation 17 interval index in area form (for a - common ``delta`` the argmax is identical to the interval average). As - ``delta -> 0`` the area approaches ``I_i(theta0) * delta**3 / 3`` with - ``I_i`` the Fisher information, the paper's connection between global and - local information (verified independently by the adversarial spec - review; anchored by a crate test). All numeric work happens in the Rust - core (``mlsirm_core::exposure::kl_information``); this wrapper only - validates and marshals. Inputs must be 1-D. - - Source status: the pointwise Bernoulli form and expectation-under-theta0 - direction were confirmed against Chang and Ying (1996, Definitions - 2.1-2.2, Eq. 17-18) and the catR implementation (Magis & Raiche, 2012, - ``KL.R``). - - References (APA 7th ed.): - Chang, H.-H., & Ying, Z. (1996). A global information approach to - computerized adaptive testing. *Applied Psychological - Measurement, 20*(3), 213-229. - https://doi.org/10.1177/014662169602000303 - Magis, D., & Raiche, G. (2012). Random generation of response - patterns under computerized adaptive testing with the R package - catR. *Journal of Statistical Software, 48*(8), 1-31. - https://doi.org/10.18637/jss.v048.i08 - """ - from . import _core - - a = np.asarray(a, dtype=np.float64) - b = np.asarray(b, dtype=np.float64) - if a.ndim != 1 or b.ndim != 1: - raise ValueError("a and b must be 1-D arrays") - if c is None: - c = np.zeros_like(a) - c = np.asarray(c, dtype=np.float64) - if c.ndim != 1: - raise ValueError("c must be a 1-D array") - return np.asarray( - _core.py_kl_information( - np.ascontiguousarray(a), - np.ascontiguousarray(b), - np.ascontiguousarray(c), - float(theta0), - float(delta), - ) - ) - - -def kl_select( - a: np.ndarray, - b: np.ndarray, - c: np.ndarray | None = None, - *, - administered: np.ndarray, - theta0: float, - n_administered: int, - r: float = 3.0, -) -> dict: - """Select the next CAT item by the Chang-Ying (1996) KL criterion. - - Computes ``argmax_i KL_i(theta0)`` over items where ``administered`` is - False, with half-width ``delta = r / sqrt(n_administered)`` (the paper's - Equation 18 shrinking-interval rule; their Study 1 uses ``r = 3``). - Requires ``n_administered >= 1`` — the rule is undefined at ``n = 0``; - for the first item call :func:`kl_information` with an explicit - ``delta``. Returns ``{"index", "selected", "delta"}`` where ``index`` is - the full per-item KL vector (administered items keep their value; masking - applies to selection only). All numeric work happens in the Rust core - (``mlsirm_core::exposure::kl_select``). See :func:`kl_information` for - sources and references. - """ - from . import _core - - a = np.asarray(a, dtype=np.float64) - b = np.asarray(b, dtype=np.float64) - if a.ndim != 1 or b.ndim != 1: - raise ValueError("a and b must be 1-D arrays") - if c is None: - c = np.zeros_like(a) - c = np.asarray(c, dtype=np.float64) - mask = np.asarray(administered, dtype=np.bool_) - if c.ndim != 1 or mask.ndim != 1: - raise ValueError("c and administered must be 1-D arrays") - res = _core.py_kl_select( - np.ascontiguousarray(a), - np.ascontiguousarray(b), - np.ascontiguousarray(c), - np.ascontiguousarray(mask), - float(theta0), - _as_int("n_administered", n_administered, minimum=1), - float(r), - ) - return { - "index": np.asarray(res["index"]), - "selected": int(res["selected"]), - "delta": float(res["delta"]), - } - - -def owen_update( - a: float, - b: float, - c: float = 0.0, - *, - correct: bool, - mu: float, - sig2: float, -) -> tuple[float, float]: - """Owen (1975) approximate Bayesian posterior update for one 3PNO item. - - Given a normal prior ``theta ~ N(mu, sig2)`` and a response to a - three-parameter normal-ogive item ``P(X=1|theta) = c + - (1-c) Phi(a (theta - b))``, returns the updated normal-approximation - posterior moments ``(mu', sig2')`` from Owen's closed-form truncated- - normal moment matching. All numeric work happens in the Rust core - (``mlsirm_core::exposure::owen_update``); this wrapper only validates - and marshals. - - Source status: Owen (1975) itself was NOT read (paywalled). The update - formulas are implemented as reproduced by van der Linden (1998, - Appendix Eqs. A.1-A.6) and cross-checked against the R ``irt`` package - ``src/est_ability_owen.cpp``; the adversarial spec review additionally - verified three pinned oracle cases against high-precision numerical - integration of the exact posterior (~1e-13 agreement). - - References (APA 7th ed.): - Owen, R. J. (1975). A Bayesian sequential procedure for quantal - response in the context of adaptive mental testing. *Journal of - the American Statistical Association, 70*(350), 351-356. - https://doi.org/10.1080/01621459.1975.10479871 - van der Linden, W. J. (1998). *Bayesian item selection criteria for - adaptive testing* (Research Report 98-01). University of Twente. - Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of - ability in a microcomputer environment. *Applied Psychological - Measurement, 6*(4), 431-444. - https://doi.org/10.1177/014662168200600405 - """ - from . import _core - - return _core.py_owen_update( - float(a), float(b), float(c), bool(correct), float(mu), float(sig2) - ) - - -def owen_cat( - a: np.ndarray, - b: np.ndarray, - c: np.ndarray | None = None, - *, - responses: np.ndarray, - mu0: float = 0.0, - sig2_0: float = 1.0, - test_length: int, - sig2_stop: float | None = None, -) -> dict: - """Owen (1975) approximate Bayesian sequential CAT. - - Selects items by Owen's b-matching rule ``argmin_i |b_i - mu|`` over the - unadministered pool (ties to the lowest index), applies the - :func:`owen_update` posterior step after each response, and stops when - the posterior variance drops to ``sig2_stop`` (Owen's stopping rule) or - after ``test_length`` items. ``responses`` is a full-pool 0/1 vector - consulted for whichever item is selected — a fixed-person simulation - contract, not item-by-item elicitation. Returns a dict with - ``administered``, ``mu_trace``, ``sig2_trace``, ``mu`` and ``sig2``, all - computed by the Rust core (``mlsirm_core::exposure::owen_cat``). - - Source status: see :func:`owen_update` (Owen 1975 NOT read; formulas per - van der Linden 1998 with R ``irt`` cross-check). The b-matching - selection and variance-threshold stopping rules are Owen's per the same - secondary sources; ``test_length`` is an implementation cap. - - References (APA 7th ed.): - Owen, R. J. (1975). A Bayesian sequential procedure for quantal - response in the context of adaptive mental testing. *Journal of - the American Statistical Association, 70*(350), 351-356. - https://doi.org/10.1080/01621459.1975.10479871 - van der Linden, W. J. (1998). *Bayesian item selection criteria for - adaptive testing* (Research Report 98-01). University of Twente. - """ - from . import _core - - a = np.asarray(a, dtype=np.float64) - b = np.asarray(b, dtype=np.float64) - if a.ndim != 1 or b.ndim != 1: - raise ValueError("a and b must be 1-D arrays") - if c is None: - c = np.zeros_like(a) - c = np.asarray(c, dtype=np.float64) - if c.ndim != 1: - raise ValueError("c must be a 1-D array") - responses = np.asarray(responses) - if responses.ndim != 1: - raise ValueError("responses must be a 1-D array") - # Validate BEFORE the uint8 cast: astype(np.uint8) silently truncates - # (1.2 -> 1, 0.9 -> 0) and wraps negatives (-1 -> 255), and complex - # inputs would silently drop the imaginary part, laundering invalid - # inputs past the Rust-side 0/1 check. - if np.iscomplexobj(responses): - raise ValueError("responses must contain only 0 or 1") - if not np.isin(responses.astype(np.float64), (0.0, 1.0)).all(): - raise ValueError("responses must contain only 0 or 1") - r = _core.py_owen_cat( - np.ascontiguousarray(a), - np.ascontiguousarray(b), - np.ascontiguousarray(c), - np.ascontiguousarray(responses, dtype=np.uint8), - float(mu0), - float(sig2_0), - _as_int("test_length", test_length, minimum=1), - None if sig2_stop is None else float(sig2_stop), - ) - return { - "administered": list(r["administered"]), - "mu_trace": np.asarray(r["mu_trace"]), - "sig2_trace": np.asarray(r["sig2_trace"]), - "mu": float(r["mu"]), - "sig2": float(r["sig2"]), - } - - -def ccat_select( - a: np.ndarray, - b: np.ndarray, - c: np.ndarray | None = None, - *, - groups: np.ndarray, - targets: np.ndarray, - administered: np.ndarray, - theta0: float, -) -> dict: - """Kingsbury & Zara (1989) constrained CAT (CCAT) content balancing. - - Single-step item selection under content-area constraints: any eligible - content group (one with at least one unadministered item) that has zero - administered items has priority; otherwise the eligible group with the - maximal discrepancy ``targets[g] - k_g / k`` (target minus empirical - proportion of administered items) is chosen; within the chosen group the - unadministered item with maximal logistic 3PL Fisher information - ``a^2 (Q/P) ((P - c) / (1 - c))^2`` at ``theta0`` is selected. Ties go - to the lowest index (documented deterministic deviation from catR's - random tie-break). Returns a dict with ``selected``, ``group``, - ``discrepancy`` (per group) and ``info`` (per item; computed for the - whole pool, masking applies to selection only), all computed by the - Rust core (``mlsirm_core::exposure::ccat_select``). - - Source status: Kingsbury & Zara (1989) itself was NOT read (paywalled). - The rule is implemented as reproduced by the R catR package - (``nextItem.R``, ``cbControl`` branch; READ), and the Fisher-information - formula was verified against catR ``Ii.R``/``Pi.R``. - - ``targets`` must be strictly positive and sum to 1; ``groups`` maps each - item to a group index ``0..len(targets)-1``. - - References (APA 7th ed.): - Kingsbury, G. G., & Zara, A. R. (1989). Procedures for selecting - items for computerized adaptive tests. *Applied Measurement in - Education, 2*(4), 359-375. - https://doi.org/10.1207/s15324818ame0204_6 - Magis, D., & Raiche, G. (2012). Random generation of response - patterns under computerized adaptive testing with the R package - catR. *Journal of Statistical Software, 48*(8), 1-31. - https://doi.org/10.18637/jss.v048.i08 - """ - from . import _core - - a = np.asarray(a, dtype=np.float64) - b = np.asarray(b, dtype=np.float64) - if a.ndim != 1 or b.ndim != 1: - raise ValueError("a and b must be 1-D arrays") - if c is None: - c = np.zeros_like(a) - c = np.asarray(c, dtype=np.float64) - if c.ndim != 1: - raise ValueError("c must be a 1-D array") - groups = np.asarray(groups) - if groups.ndim != 1: - raise ValueError("groups must be a 1-D array") - # Validate BEFORE the uintp cast: casting would silently truncate - # non-integers, wrap negatives, and drop imaginary parts. - if np.iscomplexobj(groups): - raise ValueError("groups must contain non-negative integers") - gf = groups.astype(np.float64) - if not np.isfinite(gf).all() or (gf < 0).any() or (gf != np.floor(gf)).any(): - raise ValueError("groups must contain non-negative integers") - targets = np.asarray(targets, dtype=np.float64) - if targets.ndim != 1: - raise ValueError("targets must be a 1-D array") - administered = np.asarray(administered) - if administered.ndim != 1: - raise ValueError("administered must be a 1-D array") - if administered.dtype != np.bool_: - raise ValueError("administered must be a boolean array") - r = _core.py_ccat_select( - np.ascontiguousarray(a), - np.ascontiguousarray(b), - np.ascontiguousarray(c), - np.ascontiguousarray(groups, dtype=np.uintp), - np.ascontiguousarray(targets), - np.ascontiguousarray(administered), - float(theta0), - ) - return { - "selected": int(r["selected"]), - "group": int(r["group"]), - "discrepancy": np.asarray(r["discrepancy"]), - "info": np.asarray(r["info"]), - } diff --git a/python/fast_mlsirm/facets.py b/python/fast_mlsirm/facets.py deleted file mode 100644 index 75776b74c..000000000 --- a/python/fast_mlsirm/facets.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Many-Facet Rasch Model (Linacre, 1989): the rating-scale Rasch model with a -rater-severity facet, estimated by marginal-ML EM in the Rust core. All numeric -work happens in Rust; this module only validates and marshals arrays.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER, MAX_POLYTOMOUS_CATEGORIES - - -@dataclass -class FacetsFit: - """Fitted many-facet Rasch model (Linacre, 1989). - - ``item_difficulty`` is the per-item ``d_i``; ``rater_severity`` the per-rater - ``c_j`` (centered to sum 0; higher = harsher); ``thresholds`` the ``n_cat-1`` - common category thresholds (centered to sum 0); ``theta`` the per-person EAP - trait. The adjacent-category log-odds are - ``ln[P(k)/P(k-1)] = theta - d_i - c_j - f_k``. ``connected`` is False when the - item-rater co-observation design splits into disconnected components — then - severity/difficulty comparisons across components rest solely on the shared - ``theta ~ N(0,1)`` assumption rather than on the rating design (Linacre's - connectedness requirement).""" - - item_difficulty: np.ndarray - rater_severity: np.ndarray - thresholds: np.ndarray - theta: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - connected: bool - n_parameters: int - - -def fit_facets( - responses: np.ndarray, - n_cat: int | None = None, - q_theta: int = 41, - max_iter: int = 500, - tol: float = 1e-6, -) -> FacetsFit: - """Fit the many-facet Rasch model (compute in Rust; Linacre, 1989). - - The MFRM extends the rating scale model (Andrich, 1978) with a rater facet: - the rating of person ``p`` on item ``i`` by rater ``j`` follows the - adjacent-category log-odds - ``ln[P(Y=k)/P(Y=k-1)] = theta_p - d_i - c_j - f_k``, where ``d_i`` is item - difficulty, ``c_j`` rater severity, and ``f_k`` the category thresholds - shared across items and raters. ``theta ~ N(0,1)`` fixes the scale; - severities and thresholds are centered to sum to zero. Estimation is - marginal-ML EM (Bock & Aitkin, 1981) on a Gauss-Hermite trait grid — not - Linacre's JMLE, so estimates match Facets output only up to the JMLE-vs-MMLE - difference. - - In LLM-as-a-Judge calibration, raters are judges: ``rater_severity`` - estimates each judge's harshness on a common logit scale, adjusted for item - difficulty and respondent ability. - - ``responses`` is a ``persons x items x raters`` array of integer category - indices ``0..n_cat-1``; ``NaN`` marks unscored cells (sparse judging plans), - dropped under a missing-at-random assumption. ``n_cat`` defaults to - ``max(responses) + 1``. Every item and every rater needs at least one - observed rating. - - References (APA 7th ed.): - Linacre, J. M. (1989). *Many-facet Rasch measurement*. MESA Press. - Eckes, T. (2015). *Introduction to many-facet Rasch measurement* - (2nd ed.). Peter Lang. https://doi.org/10.3726/978-3-653-04844-5 - Bock, R. D., & Aitkin, M. (1981). Marginal maximum likelihood estimation - of item parameters: Application of an EM algorithm. *Psychometrika, - 46*(4), 443-459. https://doi.org/10.1007/BF02293801 - Andrich, D. (1978). A rating formulation for ordered response - categories. *Psychometrika, 43*(4), 561-573. - https://doi.org/10.1007/BF02293814 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_facets"): - raise RuntimeError("fit_facets requires the compiled Rust core") - - if not isinstance(n_cat, (int, type(None))) or isinstance(n_cat, bool): - raise ValueError("n_cat must be an integer >= 2") - if n_cat is not None and not (2 <= n_cat <= MAX_POLYTOMOUS_CATEGORIES): - raise ValueError(f"n_cat must be an integer in 2..{MAX_POLYTOMOUS_CATEGORIES}") - if q_theta not in {7, 11, 15, 21, 31, 41}: - raise ValueError("q_theta must be one of 7, 11, 15, 21, 31, 41") - if ( - not isinstance(max_iter, int) - or isinstance(max_iter, bool) - or not (1 <= max_iter <= MAX_MAX_ITER) - ): - raise ValueError(f"max_iter must be an integer in 1..{MAX_MAX_ITER}") - if not np.isfinite(tol) or tol <= 0: - raise ValueError("tol must be finite and > 0") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 3: - raise ValueError("responses must be a 3-D persons x items x raters array") - n_persons, n_items, n_raters = y.shape - if n_persons < 1 or n_items < 1 or n_raters < 1: - raise ValueError( - "responses must contain at least one person, one item and one rater" - ) - missing = np.isnan(y) - if np.any(~missing & ~np.isfinite(y)): - raise ValueError("observed responses must be finite integer categories") - observed = ~missing - obs_values = y[observed] - if obs_values.size and ( - np.any(obs_values != np.floor(obs_values)) or np.any(obs_values < 0) - ): - raise ValueError("observed responses must be non-negative integer categories") - if n_cat is None: - if obs_values.size == 0: - raise ValueError("responses has no observed values") - n_cat = int(obs_values.max()) + 1 - if n_cat < 2: - raise ValueError("responses must contain at least two categories") - if n_cat > MAX_POLYTOMOUS_CATEGORIES: - raise ValueError( - f"responses imply more than {MAX_POLYTOMOUS_CATEGORIES} categories" - ) - if obs_values.size and np.any(obs_values >= n_cat): - raise ValueError( - f"observed responses must be integer categories in 0..{n_cat - 1}" - ) - missing_items = np.flatnonzero(~observed.any(axis=(0, 2))) - if missing_items.size: - raise ValueError(f"item {int(missing_items[0])} has no observed responses") - missing_raters = np.flatnonzero(~observed.any(axis=(0, 1))) - if missing_raters.size: - raise ValueError(f"rater {int(missing_raters[0])} has no observed responses") - yy = np.where(observed, y, 0.0).astype(np.int64).reshape(-1) - res = core.fit_facets( - yy, - observed.reshape(-1), - int(n_persons), - int(n_items), - int(n_raters), - int(n_cat), - int(q_theta), - int(max_iter), - float(tol), - ) - return FacetsFit( - item_difficulty=np.asarray(res["item_difficulty"], dtype=np.float64), - rater_severity=np.asarray(res["rater_severity"], dtype=np.float64), - thresholds=np.asarray(res["thresholds"], dtype=np.float64), - theta=np.asarray(res["theta"], dtype=np.float64), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - connected=bool(res["connected"]), - n_parameters=int(res["n_parameters"]), - ) diff --git a/python/fast_mlsirm/factor.py b/python/fast_mlsirm/factor.py deleted file mode 100644 index 4bd38b740..000000000 --- a/python/fast_mlsirm/factor.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Minres (ULS) exploratory factor analysis and McDonald's omega_total for -the unidimensional case. All numeric work happens in the Rust core -(``mlsirm_core::factor``); this module only validates and marshals. - -Source status: the minres algorithm is a line-by-line transcription of the -CRAN psych package's ``fa.R`` (Revelle, 2025 — READ: ``fit.residuals``, -``fit``, ``FAgr.minres``, ``FAout.wls``, smc start values). McDonald (1999) -was NOT read; the omega_total formula is hand-derived from the standardized -1-factor model (derivation in the Rust module docs) and matches what -secondary sources attribute to McDonald. Tests pin parity against an -independent scipy L-BFGS-B transcription oracle (same optimizer family as -R's ``optim``; not claimed bit-identical to any R run). - -REDUCED SCOPE (spec decision): no rotation (loadings are unrotated), no -Schmid-Leiman, no omega_hierarchical, no ML/WLS/GLS methods, no factor -scores. The public omega name is ``omega_total_1f`` to make the 1-factor -restriction explicit. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -_REFERENCES = """References (APA 7th ed.): - Revelle, W. (2025). *psych: Procedures for psychological, - psychometric, and personality research* (Version 2.6.5) - [R package]. https://CRAN.R-project.org/package=psych - McDonald, R. P. (1999). *Test theory: A unified treatment*. - Erlbaum. (As cited in Revelle, 2025; not read.) - """ - - -@dataclass -class MinresFaResult: - """Minres factor-analysis output. - - ``loadings`` is a ``(p, n_factors)`` array (unrotated, columns in - descending-eigenvalue order, column sums >= 0). ``kkt_violation`` is - the maximum finite-difference box-KKT violation of the minres - objective at the solution; ``converged`` means it is below the crate - tolerance (1e-6).""" - - loadings: np.ndarray - uniquenesses: np.ndarray - communalities: np.ndarray - objective: float - kkt_violation: float - n_iter: int - converged: bool - - -@dataclass -class OmegaResult: - """McDonald's omega_total from a 1-factor minres fit: - ``(sum lambda)^2 / ((sum lambda)^2 + sum psi)``.""" - - omega_total: float - fa: MinresFaResult - - -def _fa_from_dict(d: dict, p: int, n_factors: int) -> MinresFaResult: - return MinresFaResult( - loadings=np.asarray(d["loadings"], dtype=np.float64).reshape(p, n_factors), - uniquenesses=np.asarray(d["uniquenesses"], dtype=np.float64), - communalities=np.asarray(d["communalities"], dtype=np.float64), - objective=float(d["objective"]), - kkt_violation=float(d["kkt_violation"]), - n_iter=int(d["n_iter"]), - converged=bool(d["converged"]), - ) - - -def minres_fa(corr: np.ndarray, n_factors: int) -> MinresFaResult: - """Minres factor analysis of a ``(p, p)`` correlation matrix - (psych fa.R transcription; Revelle, 2025). - - """ - from . import _core - - r = np.ascontiguousarray(np.asarray(corr, dtype=np.float64)) - if r.ndim != 2 or r.shape[0] != r.shape[1]: - raise ValueError("corr must be a square (p, p) matrix") - p = int(r.shape[0]) - out = _core.minres_fa(r.reshape(-1), p, int(n_factors)) - return _fa_from_dict(out, p, int(n_factors)) - - -minres_fa.__doc__ += _REFERENCES - - -def minres_fa_from_data(data: np.ndarray, n_factors: int) -> MinresFaResult: - """:func:`minres_fa` from a complete ``(n, p)`` data matrix (Pearson - correlations computed in the Rust core). - - """ - from . import _core - - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D (n, p) matrix") - n, p = map(int, x.shape) - out = _core.minres_fa_from_data(x.reshape(-1), n, p, int(n_factors)) - return _fa_from_dict(out, p, int(n_factors)) - - -minres_fa_from_data.__doc__ += _REFERENCES - - -def omega_total_1f(corr: np.ndarray) -> OmegaResult: - """McDonald's omega_total for the unidimensional case from a ``(p, p)`` - correlation matrix (1-factor minres fit; McDonald, 1999, as cited in - Revelle, 2025 — formula hand-derived, see Rust module docs). - - """ - from . import _core - - r = np.ascontiguousarray(np.asarray(corr, dtype=np.float64)) - if r.ndim != 2 or r.shape[0] != r.shape[1]: - raise ValueError("corr must be a square (p, p) matrix") - p = int(r.shape[0]) - out = _core.omega_total_1f(r.reshape(-1), p) - return OmegaResult( - omega_total=float(out["omega_total"]), fa=_fa_from_dict(out["fa"], p, 1) - ) - - -omega_total_1f.__doc__ += _REFERENCES - - -def omega_total_1f_from_data(data: np.ndarray) -> OmegaResult: - """:func:`omega_total_1f` from a complete ``(n, p)`` data matrix. - - """ - from . import _core - - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D (n, p) matrix") - n, p = map(int, x.shape) - out = _core.omega_total_1f_from_data(x.reshape(-1), n, p) - return OmegaResult( - omega_total=float(out["omega_total"]), fa=_fa_from_dict(out["fa"], p, 1) - ) - - -omega_total_1f_from_data.__doc__ += _REFERENCES - - -@dataclass -class GlbFaResult: - """Factor-analytic greatest lower bound to reliability (psych glb.fa - transcription). ``nf`` is the fitted factor count after psych's df - adjustment; ``communalities`` come from that nf-factor minres fit. - - REDUCED SCOPE: this is NOT the algebraic glb of Jackson & Agunwamba - (as computed by psych ``glb.algebraic``, which needs an SDP solver); - it is the factor-analytic approximation from psych ``glbs.R`` - (Revelle, 2025 — READ). Sijtsma (2009) was NOT read; cite the psych - transcription only. Correlation-matrix or complete-data input only - (no cov2cor / pairwise-deletion branches).""" - - glb: float - communalities: np.ndarray - nf: int - - -def _glbfa_from_dict(d: dict) -> GlbFaResult: - return GlbFaResult( - glb=float(d["glb"]), - communalities=np.asarray(d["communalities"], dtype=np.float64), - nf=int(d["nf"]), - ) - - -def glb_fa(corr: np.ndarray) -> GlbFaResult: - """Factor-analytic glb from a ``(p, p)`` correlation matrix (psych - ``glb.fa`` transcription; Revelle, 2025). See :class:`GlbFaResult` - for the scope reduction (not the algebraic glb). - - %s""" % _REFERENCES - from . import _core - - r = np.ascontiguousarray(np.asarray(corr, dtype=np.float64)) - if r.ndim != 2 or r.shape[0] != r.shape[1]: - raise ValueError("corr must be a square (p, p) matrix") - return _glbfa_from_dict(_core.glb_fa(r.reshape(-1), int(r.shape[0]))) - - -def glb_fa_from_data(data: np.ndarray) -> GlbFaResult: - """:func:`glb_fa` from a complete ``(n, p)`` data matrix. - - %s""" % _REFERENCES - from . import _core - - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D (n, p) matrix") - n, p = map(int, x.shape) - return _glbfa_from_dict(_core.glb_fa_from_data(x.reshape(-1), n, p)) - - -@dataclass -class VelicerMapResult: - """Velicer's minimum average partial (MAP) test. - - ``f2[m]`` / ``f4[m]`` for ``m = 0..max_m``: average squared (resp. - elementwise fourth-power) off-diagonal partial correlation after - partialing out the first ``m`` principal components; ``m = 0`` is the - unpartialed baseline. Invalid rows (singular partial-covariance - normalization, e.g. an identity matrix for ``m >= 1``) are NaN and - excluded from the retained-count argmin. - - ``retained_f2`` follows O'Connor's canonical MAP programs (READ): - the ``m`` attaining the minimum. Note ``fungible::faMAP`` prints a - 1-based row position instead — off by one; that convention is a bug - and is not reproduced. The fourth-power criterion is elementwise per - O'Connor's code; ``EFA.dimensions`` now uses matrix powers, an - unresolved conflict pending the Velicer, Eaton & Fava (2000) chapter - (NOT read).""" - - f2: np.ndarray - f4: np.ndarray - retained_f2: int - retained_f4: int - - -_MAP_REFERENCES = """References (APA 7th ed.): - Velicer, W. F. (1976). Determining the number of components from - the matrix of partial correlations. *Psychometrika, 41*(3), - 321-327. https://doi.org/10.1007/BF02293557 (NOT read; formula - support is the read implementations below.) - O'Connor, B. P. (2000). SPSS and SAS programs for determining the - number of components using parallel analysis and Velicer's MAP - test. *Behavior Research Methods, Instruments, & Computers, - 32*(3), 396-402. https://doi.org/10.3758/BF03200807 (Paper NOT - read; the map.m/map.sps programs it distributes were READ in - full and are the algorithm oracle.) - Velicer, W. F., Eaton, C. A., & Fava, J. L. (2000). Construct - explication through factor or component analysis. In R. D. - Goffin & E. Helmes (Eds.), *Problems and solutions in human - assessment* (pp. 41-71). Kluwer. (NOT read; the fourth-power - criterion is attributed to it per O'Connor's code comments.) - Revelle, W. (2025). *psych: Procedures for psychological, - psychometric, and personality research* (Version 2.6.5) - [R package]. https://CRAN.R-project.org/package=psych - (VSS.R map() READ.) - """ - - -def _map_from_dict(d: dict) -> VelicerMapResult: - return VelicerMapResult( - f2=np.asarray(d["f2"], dtype=np.float64), - f4=np.asarray(d["f4"], dtype=np.float64), - retained_f2=int(d["retained_f2"]), - retained_f4=int(d["retained_f4"]), - ) - - -def velicer_map(corr: np.ndarray, max_m: int | None = None) -> VelicerMapResult: - """Velicer's MAP test from a ``(p, p)`` correlation matrix. - - ``max_m`` defaults to ``p - 1`` (the canonical upper bound). - - %s""" % _MAP_REFERENCES - from . import _core - - r = np.ascontiguousarray(np.asarray(corr, dtype=np.float64)) - if r.ndim != 2 or r.shape[0] != r.shape[1]: - raise ValueError("corr must be a square (p, p) matrix") - p = int(r.shape[0]) - m = p - 1 if max_m is None else int(max_m) - return _map_from_dict(_core.velicer_map(r.reshape(-1), p, m)) - - -def velicer_map_from_data(data: np.ndarray, max_m: int | None = None) -> VelicerMapResult: - """:func:`velicer_map` from a complete ``(n, p)`` data matrix. - - %s""" % _MAP_REFERENCES - from . import _core - - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D (n, p) matrix") - n, p = map(int, x.shape) - m = p - 1 if max_m is None else int(max_m) - return _map_from_dict(_core.velicer_map_from_data(x.reshape(-1), n, p, m)) diff --git a/python/fast_mlsirm/fit.py b/python/fast_mlsirm/fit.py index 8f4d1c0e5..e185c667e 100644 --- a/python/fast_mlsirm/fit.py +++ b/python/fast_mlsirm/fit.py @@ -5,98 +5,19 @@ import numpy as np from .backend import normalize_device, resolve_backend -from .config import FitConfig, PenaltyConfig +from .config import FitConfig from .math import logit, normalize_latent_positions, standardize from .objective import (model_flags, neg_loglik_and_grad, prepare_response, validate_factor_id) from .types import FitResult, MLSIRMParams -def _compact_population_labels(raw, n_persons: int, name: str): - """Validate and compact caller-supplied population labels to contiguous - ``0..k-1`` ids. Rejects non-1-D, wrong-length, non-finite, non-integer, or - negative labels, and remaps the observed labels so the derived group/cluster - count is the number of *distinct* labels (<= n_persons) rather than - ``max(label) + 1`` -- which otherwise lets sparse ids such as ``[0, 1e9]`` - force unbounded population allocations (memory-exhaustion DoS).""" - import numpy as _np - - arr = _np.asarray(raw) - if arr.ndim != 1 or arr.shape[0] != n_persons: - raise ValueError(f"{name} must be a 1-D array of length n_persons ({n_persons})") - fl = arr.astype(_np.float64) - if not _np.all(_np.isfinite(fl)): - raise ValueError(f"{name} must be finite") - if _np.any(fl < 0) or _np.any(fl != _np.floor(fl)): - raise ValueError(f"{name} must be non-negative integers") - uniq, remapped = _np.unique(arr.astype(_np.int64), return_inverse=True) - return remapped.astype(_np.int64), int(uniq.size) - - def fit( responses: np.ndarray, factor_id: np.ndarray, config: FitConfig | None = None, mask: np.ndarray | None = None, - group_id: np.ndarray | None = None, - cluster_id: np.ndarray | None = None, - anchors: dict | None = None, - covariate: dict | None = None, ) -> FitResult: - """Fit a latent-space model. - - ``covariate`` = ``{"w": (n_groups x n_items) array, "init_delta": float}`` - switches on a context-varying item covariate with one estimated - coefficient (Debeer & Janssen 2013 linear item-position effect): - ``eta += delta * w[group(p), i]``. Requires multigroup contexts (booklets) - or anchors for identification. - - ``group_id``/``cluster_id`` (mutually exclusive, ``estimator="mmle"`` only) - switch on estimation-level population structures: multigroup calibration - (Bock & Zimowski 1997 — group-specific trait means/SDs, common items, - group 0 as the N(0,1) reference) and multilevel random intercepts - (Fox & Glas 2001 — cluster intercept SD ``sigma_u`` estimated). - - ``anchors`` enables Fixed Item Parameter Calibration (Kim 2006, the - MWU-MEM-style variant): ``{"fixed": bool[I], "alpha", "b", "zeta", - "tau" (optional)}``. Anchored items stay frozen; without a - ``group_id``/``cluster_id`` the population mean/SD is freed - (concurrent-calibration-ready ``singlefree`` population). This - implementation requires at least two fixed items per simple-structure - trait dimension, a necessary guard for estimating both its mean and SD. - - For marginal latent-space integration, ``config.xi_rule="qmc"`` uses - randomized quasi-Monte Carlo nodes (Jank, 2005), while ``"mc"`` uses - seeded Monte Carlo nodes (Wei & Tanner, 1990). Reusing one fixed node set - across EM iterations is a repository-specific deterministic approximation, - not the adaptive sample-size procedure studied in those papers. - - ``config.zero_inflation`` enables a structural-zero response-pattern - mixture. This is a repository-specific Bernoulli-pattern adaptation of the - count-model template in Perumean-Chaney et al. (2013), not an IRT estimator - from that article. Returned latent scores are conditional on the engager/ - IRT component; multilevel ``u_eap`` integrates over the complete mixture. - - References - ---------- - Jank, W. (2005). Quasi-Monte Carlo sampling to improve the efficiency of - Monte Carlo EM. *Computational Statistics & Data Analysis, 48*(4), - 685–701. https://doi.org/10.1016/j.csda.2004.03.019 - - Kim, S. (2006). A comparative study of IRT fixed parameter calibration - methods. *Journal of Educational Measurement, 43*(4), 355–381. - https://doi.org/10.1111/j.1745-3984.2006.00021.x - - Perumean-Chaney, S. E., Morgan, C., McDowall, D., & Aban, I. (2013). - Zero-inflated and overdispersed: What's one to do? *Journal of Statistical - Computation and Simulation, 83*(9), 1671–1683. - https://doi.org/10.1080/00949655.2012.668550 - - Wei, G. C. G., & Tanner, M. A. (1990). A Monte Carlo implementation of - the EM algorithm and the poor man's data augmentation algorithms. - *Journal of the American Statistical Association, 85*(411), 699–704. - https://doi.org/10.1080/01621459.1990.10474930 - """ config = config or FitConfig() config.validate() backend = resolve_backend(config.backend) @@ -106,11 +27,7 @@ def fit( y, observed = prepare_response(responses, mask) _, n_items = y.shape - factors = np.asarray(factor_id) - if factors.shape != (n_items,): - raise ValueError("factor_id length must match number of items") - if factors.dtype.kind not in {"i", "u"}: - raise ValueError("factor_id must contain integer values") + factors = np.asarray(factor_id, dtype=np.int64) n_dims = 1 if model in {"ULS2PLM", "ULSRM"} else int(factors.max()) + 1 if n_dims > n_items: raise ValueError("factor_id implies more dimensions than items") @@ -119,45 +36,13 @@ def fit( factors = np.zeros_like(factors) # pragma: no cover factors = validate_factor_id(factors, n_items, n_dims) - if group_id is not None and cluster_id is not None: - raise ValueError("group_id and cluster_id are mutually exclusive") - if (group_id is not None or cluster_id is not None) and config.estimator != "mmle": - raise ValueError( - "estimation-level multigroup/multilevel structures require estimator='mmle'" - ) - if anchors is not None and config.estimator != "mmle": - raise ValueError("anchors (FIPC) require estimator='mmle'") - if anchors is not None and cluster_id is not None: - raise ValueError("anchors with a multilevel structure are not supported yet") - if covariate is not None and config.estimator != "mmle": - raise ValueError("item covariates require estimator='mmle'") - if covariate is not None and cluster_id is not None: - raise ValueError("item covariates with a multilevel structure are not supported") - - if model == "BIFAC2PLM" and config.estimator != "mmle": - raise NotImplementedError( - "BIFAC2PLM (bifactor) is supported by the marginal estimator only; " - "use estimator='mmle'." - ) if config.estimator == "mmle": - if ( - model in {"ULS2PLM", "ULSRM"} - and group_id is None - and cluster_id is None - and anchors is None - and covariate is None - and not config.zero_inflation - ): - # Legacy fast path: plain unidimensional 2PL margin (the latent - # space is not estimated — unchanged public behavior). Use the - # spatial models or a population structure for the full marginal - # latent-space fit. - return _fit_mmle(y, observed, model, config) - return _fit_mmle_marginal( - y, observed, factors, n_dims, model, config, backend, device, - group_id=group_id, cluster_id=cluster_id, anchors=anchors, - covariate=covariate, - ) + if model not in {"ULS2PLM", "ULSRM"}: + raise NotImplementedError( + "estimator 'mmle' currently supports only unidimensional 2PL models " + "(ULS2PLM/ULSRM); use 'jmle' for spatial or multidimensional models." + ) + return _fit_mmle(y, observed, model, config) if config.estimator in {"em", "bayes"}: raise NotImplementedError( f"estimator '{config.estimator}' is reserved for a future milestone; " @@ -251,263 +136,6 @@ def _fit_mmle( ) -def _fit_mmle_marginal( - y: np.ndarray, - observed: np.ndarray, - factors: np.ndarray, - n_dims: int, - model: str, - config: FitConfig, - backend: str, - device: str, - group_id: np.ndarray | None = None, - cluster_id: np.ndarray | None = None, - anchors: dict | None = None, - covariate: dict | None = None, -) -> FitResult: - """Marginal EM for the latent-space family (Rust core, NumPy fallback). - - Person latents are integrated out by Gauss-Hermite quadrature; item-side - parameters carry the LSIRM priors of Jeon et al. (2021) as MAP penalties - (see ``estimators/marginal.py`` / ``mlsirm-core/src/marginal.rs``). - """ - from .estimators.marginal import LSIRM_PRIOR, fit_marginal_numpy - - n_persons, n_items = y.shape - if group_id is not None: - ids, n_pop = _compact_population_labels(group_id, n_persons, "group_id") - pop_kind = "multigroup" - elif cluster_id is not None: - ids, n_pop = _compact_population_labels(cluster_id, n_persons, "cluster_id") - pop_kind = "multilevel" - elif anchors is not None: - # FIPC: anchored items identify a free single population. - ids, pop_kind, n_pop = None, "singlefree", 1 - else: - ids, pop_kind, n_pop = None, "single", 0 - covariate_kwargs: dict = {} - if covariate is not None: - covariate_w = np.asarray(covariate["w"], dtype=np.float64).ravel() - n_ctx = int(n_pop) if pop_kind == "multigroup" else 1 - if covariate_w.size != n_ctx * n_items: - raise ValueError( - f"covariate w must have {n_ctx} x {n_items} entries (n_contexts x n_items)" - ) - if not np.all(np.isfinite(covariate_w)): - raise ValueError("covariate w must be finite") - covariate_kwargs = dict( - covariate_w=covariate_w, - covariate_init_delta=float(covariate.get("init_delta", 0.0)), - ) - anchor_kwargs: dict = {} - if anchors is not None: - fixed = np.asarray(anchors["fixed"]) - if fixed.dtype.kind != "b": - raise ValueError("anchor fixed must contain only boolean values") - a_alpha = np.asarray(anchors["alpha"], dtype=np.float64) - a_b = np.asarray(anchors["b"], dtype=np.float64) - a_zeta = np.asarray(anchors["zeta"], dtype=np.float64).ravel() - if fixed.shape != (n_items,): - raise ValueError(f"anchor_fixed must have shape ({n_items},)") - if a_alpha.shape != (n_items,) or a_b.shape != (n_items,): - raise ValueError(f"anchor alpha/b must have shape ({n_items},)") - if a_zeta.size != n_items * int(config.latent_dim): - raise ValueError("anchor zeta must have n_items x latent_dim entries") - if not (np.all(np.isfinite(a_alpha)) and np.all(np.isfinite(a_b)) and np.all(np.isfinite(a_zeta))): - raise ValueError("anchor alpha/b/zeta must be finite") - fixed_per_dim = np.bincount(factors[fixed], minlength=n_dims) - if pop_kind == "singlefree" and np.any(fixed_per_dim < 2): - d = int(np.flatnonzero(fixed_per_dim < 2)[0]) - raise ValueError( - "singlefree (FIPC) requires at least two fixed anchor items per " - f"trait dimension; dimension {d} has {int(fixed_per_dim[d])}" - ) - anchor_tau = None if anchors.get("tau") is None else float(anchors["tau"]) - if anchor_tau is not None and not np.isfinite(anchor_tau): - raise ValueError("anchor tau must be finite") - anchor_kwargs = dict( - anchor_fixed=fixed, - anchor_alpha=a_alpha, - anchor_b=a_b, - anchor_zeta=a_zeta, - anchor_tau=anchor_tau, - ) - if ids is not None: - if ids.shape != (n_persons,): - raise ValueError(f"{pop_kind} ids must have shape (n_persons,)") - if ids.size and ids.min() < 0: - raise ValueError(f"{pop_kind} ids must be >= 0") - - # MAP penalties: the paper priors, unless the caller customized the - # penalty config away from its (JML-oriented) defaults. - pen = dict(LSIRM_PRIOR) - if config.penalty != PenaltyConfig(): - pen = { - "lambda_b": config.penalty.lambda_b, - "lambda_alpha": config.penalty.lambda_alpha, - "mu_alpha": config.penalty.mu_alpha, - "lambda_zeta": config.penalty.lambda_zeta, - "lambda_tau": config.penalty.lambda_tau, - "mu_tau": config.penalty.mu_tau, - } - - rust = None - if backend == "rust": - try: # pragma: no cover - depends on the compiled extension - from . import _core # type: ignore - - rust = getattr(_core, "fit_marginal", None) - except Exception: # pragma: no cover - rust = None - - y_filled = np.where(observed, y, 0.0).astype(np.float64) - if rust is not None: # pragma: no cover - exercised only with the extension - try: - res = rust( - y_filled.ravel(), - observed.astype(bool).ravel(), - factors.astype(np.int64), - int(n_persons), - int(n_items), - int(n_dims), - int(config.latent_dim), - model, - float(config.eps_distance), - pop_kind=pop_kind, - pop_id=None if ids is None else ids, - n_pop=int(n_pop), - q_theta=int(config.q_theta), - q_xi=int(config.q_xi), - q_u=int(config.q_u), - max_iter=int(config.max_iter), - tol=float(config.tolerance), - m_steps=int(config.m_steps), - lambda_b=pen["lambda_b"], - lambda_alpha=pen["lambda_alpha"], - mu_alpha=pen["mu_alpha"], - lambda_zeta=pen["lambda_zeta"], - lambda_tau=pen["lambda_tau"], - mu_tau=pen["mu_tau"], - device=device, - xi_rule=config.xi_rule, - xi_points=int(config.xi_points), - xi_seed=int(config.xi_seed), - zero_inflation=bool(config.zero_inflation), - **anchor_kwargs, - **covariate_kwargs, - ) - except ValueError as exc: - raise ValueError(str(exc)) from exc - alpha = np.asarray(res["alpha"], dtype=np.float64) - b = np.asarray(res["b"], dtype=np.float64) - zeta = np.asarray(res["zeta"], dtype=np.float64).reshape( - n_items, config.latent_dim - ) - tau = float(res["tau"]) - theta_eap = np.asarray(res["theta_eap"], dtype=np.float64).reshape( - n_persons, n_dims - ) - theta_sd = np.asarray(res["theta_sd"], dtype=np.float64).reshape( - n_persons, n_dims - ) - xi_eap = np.asarray(res["xi_eap"], dtype=np.float64).reshape( - n_persons, config.latent_dim - ) - mu = np.asarray(res["mu"], dtype=np.float64).reshape(-1, n_dims) - sigma = np.asarray(res["sigma"], dtype=np.float64).reshape(-1, n_dims) - sigma_u = float(res["sigma_u"]) - u_eap = np.asarray(res["u_eap"], dtype=np.float64) - loglik_trace = [float(v) for v in res["loglik_trace"]] - n_iter = int(res["n_iter"]) - converged = bool(res["converged"]) - ic = dict(res["ic"]) if "ic" in res else None - delta = float(res.get("delta", 0.0)) - pi_zero = float(res.get("pi_zero", 0.0)) - zero_resp = np.asarray(res.get("zero_responsibility", []), dtype=np.float64) - optimizer = "mmle_marginal_em/rust" - else: - pop: dict = {"kind": pop_kind} - if pop_kind == "multigroup": - pop = {"kind": "multigroup", "group_id": ids, "n_groups": n_pop} - elif pop_kind == "multilevel": - pop = {"kind": "multilevel", "cluster_id": ids, "n_clusters": n_pop} - res = fit_marginal_numpy( - y_filled, - observed.astype(bool), - factors, - model=model, - n_dims=n_dims, - latent_dim=config.latent_dim, - pop=pop, - q_theta=config.q_theta, - q_xi=config.q_xi, - q_u=config.q_u, - max_iter=config.max_iter, - tol=config.tolerance, - m_steps=config.m_steps, - eps_distance=config.eps_distance, - penalty=pen, - xi_rule=config.xi_rule, - xi_points=int(config.xi_points), - xi_seed=int(config.xi_seed), - anchors=anchors, - zero_inflation=bool(config.zero_inflation), - covariate=covariate, - ) - alpha, b, zeta, tau = res["alpha"], res["b"], res["zeta"], res["tau"] - theta_eap, theta_sd = res["theta_eap"], res["theta_sd"] - xi_eap = res["xi_eap"] - mu, sigma = res["mu"], res["sigma"] - sigma_u, u_eap = res["sigma_u"], res["u_eap"] - loglik_trace = [float(v) for v in res["loglik_trace"]] - n_iter = int(res["n_iter"]) - converged = bool(res["converged"]) - ic = res.get("ic") - delta = float(res.get("delta", 0.0)) - pi_zero = float(res.get("pi_zero", 0.0)) - zero_resp = np.asarray(res.get("zero_responsibility", []), dtype=np.float64) - optimizer = "mmle_marginal_em/numpy" - - population: dict = {"kind": pop_kind, "theta_sd": theta_sd} - if config.zero_inflation: - population.update(pi_zero=pi_zero, zero_responsibility=zero_resp) - if covariate is not None: - population.update(delta=delta) - if pop_kind in {"multigroup", "singlefree"}: - population.update(mu=mu, sigma=sigma) - elif pop_kind == "multilevel": - icc = sigma_u**2 / (sigma_u**2 + 1.0) - population.update(sigma_u=sigma_u, u_eap=u_eap, icc=icc) - if anchors is not None: - population.update( - fixed_items=np.asarray(anchors["fixed"], dtype=bool).copy(), - tau_fixed=anchors.get("tau") is not None, - ) - - params = MLSIRMParams( - theta=theta_eap, - alpha=alpha, - b=b, - xi=xi_eap, - zeta=zeta, - tau=tau, - ) - return FitResult( - params=params, - model=model, - optimizer=optimizer, - backend=backend, - rust_device=device, - objective=float(-loglik_trace[-1]) if loglik_trace else float("nan"), - loglik_trace=loglik_trace, - objective_trace=[float(-v) for v in loglik_trace], - convergence_status="converged" if converged else "max_iter_reached", - n_iter=n_iter, - population=population, - ic=ic, - ) - - def _run_single_fit( y: np.ndarray, observed: np.ndarray, diff --git a/python/fast_mlsirm/fitstats.py b/python/fast_mlsirm/fitstats.py deleted file mode 100644 index 0511105b0..000000000 --- a/python/fast_mlsirm/fitstats.py +++ /dev/null @@ -1,2930 +0,0 @@ -"""Likelihood-based item- and person-fit statistics for marginal (MMLE) fits. - -Implements, for the latent-space model family (see -``docs/papers/mmle-lsirm-formula-compilation.md`` §7-§8 for the sourced -formulas): - -- **S-X²** (Orlando & Thissen, 2000): summed-score item fit with the - Lord-Wingersky (1984) recursion, generalized to the joint (theta, xi) - quadrature grid — the recursion runs at every grid node and the expected - proportions marginalize over the node weights. Scores are computed within - each trait dimension (simple structure), score groups are collapsed to a - minimum expected count, and p-values use the chi-square upper tail. -- **l_z** (Drasgow, Levine & Williams, 1985) and **l_z*** (Snijders, 2001): - standardized person-fit log-likelihood statistics evaluated at the EAP - trait score with the person's latent-space position fixed at its EAP (a - documented approximation for the interaction term), using the MAP-case - correction ``r_0(theta) = -(theta - prior_mean)`` for the N(prior_mean, 1) - trait prior. - -No SciPy (repo constraint): the chi-square survival function is computed via -the regularized upper incomplete gamma function. -""" - -from __future__ import annotations - -import math -from dataclasses import dataclass, field - -import numpy as np - -from .estimators.marginal import _gh, _xi_grid -from .backend import normalize_device -from .math import sigmoid -from .objective import linear_predictor, prepare_response, validate_factor_id - -MAX_PERSON_FIT_REPLICATES = 10_000 -MAX_PERSON_FIT_WORK_CELLS = 200_000_000 -_SUPPORTED_QUADRATURE = (7, 11, 15, 21, 31, 41) - - -def _validate_sx2_controls( - q_theta, q_xi, min_expected, fdr_q, min_effect -) -> tuple[int, int, float, float, float]: - quadrature = [] - for name, value in (("q_theta", q_theta), ("q_xi", q_xi)): - if ( - isinstance(value, (bool, np.bool_)) - or not isinstance(value, (int, np.integer)) - or int(value) not in _SUPPORTED_QUADRATURE - ): - raise ValueError(f"{name} must be one of {_SUPPORTED_QUADRATURE}") - quadrature.append(int(value)) - - numeric = [] - for name, value in ( - ("min_expected", min_expected), - ("fdr_q", fdr_q), - ("min_effect", min_effect), - ): - if isinstance(value, (bool, np.bool_)) or not isinstance( - value, (int, float, np.integer, np.floating) - ): - raise ValueError(f"{name} must be a finite number") - converted = float(value) - if not np.isfinite(converted): - raise ValueError(f"{name} must be a finite number") - numeric.append(converted) - min_expected_value, fdr_q_value, min_effect_value = numeric - if min_expected_value <= 0.0: - raise ValueError("min_expected must be positive") - if not 0.0 < fdr_q_value <= 1.0: - raise ValueError("fdr_q must be in (0, 1]") - if min_effect_value < 0.0: - raise ValueError("min_effect must be non-negative") - return ( - quadrature[0], - quadrature[1], - min_expected_value, - fdr_q_value, - min_effect_value, - ) - - -def _xlogx_over_y(x: float, y: float) -> float: - """Stable `x * log(x / y)` with the `x -> 0` limit fixed to 0.""" - return 0.0 if x == 0.0 else x * math.log(x / y) - - -def _core_module(): - """The compiled Rust core, when built — the compute path for every - statistic here (the NumPy bodies below are the parity reference and - fallback).""" - try: - from . import _core # type: ignore - - return _core - except Exception: # pragma: no cover - return None - - -def _validate_factor_id(factor_id): - """Validate an untrusted factor_id vector and return (int64 array, n_dims). - Bounds n_dims by the item count (len(factor_id)) so a huge dimension label - cannot force n_dims-sized allocations in the fit-statistics cores.""" - fid = np.asarray(factor_id) - if fid.ndim != 1: - raise ValueError("factor_id must be a 1-D array") - if fid.dtype.kind not in {"i", "u"}: - raise ValueError("factor_id must be finite non-negative integers") - if fid.size and (np.any(fid < 0) or int(fid.max()) >= fid.size): - raise ValueError("factor_id values must be in 0..n_items-1") - d = fid.astype(np.int64, copy=False) - n_dims = int(d.max()) + 1 if d.size else 0 - return d, n_dims - - -def _prepare_dichotomous_diagnostic_inputs(responses, factor_id, mask): - """Validate shared response inputs for the dichotomous fit diagnostics.""" - y = np.asarray(responses, dtype=float) - if y.ndim != 2: - raise ValueError("responses must be a 2-D array") - if y.shape[1] == 0: - raise ValueError("responses must contain at least one item") - d_of_i, _n_dims = _validate_factor_id(factor_id) - if d_of_i.size != y.shape[1]: - raise ValueError("factor_id length must match the number of response items") - if mask is None: - observed = ~np.isnan(y) - else: - observed = np.asarray(mask) - if observed.dtype.kind != "b": - raise ValueError("mask must be a boolean array") - if observed.shape != y.shape: - raise ValueError("mask shape must match responses") - if np.any(observed & (y != 0.0) & (y != 1.0)): - raise ValueError("observed responses must be 0 or 1") - return np.where(observed, y, 0.0), observed, d_of_i - - -def _bank_args(params, factor_id, model, n_dims, eps_distance): - zeta = np.asarray(params.zeta, dtype=np.float64) - return dict( - alpha=np.asarray(params.alpha, dtype=np.float64), - b=np.asarray(params.b, dtype=np.float64), - zeta=zeta.ravel(), - tau=float(params.tau), - factor_id=np.asarray(factor_id, dtype=np.int64), - model=model, - n_dims=int(n_dims), - latent_dim=int(zeta.shape[1]), - eps_distance=float(eps_distance), - ) - - -# -------------------------------------------------------------------------- -# chi-square survival function (regularized upper incomplete gamma), no SciPy -# -------------------------------------------------------------------------- - - -def _gammainc_upper_reg(a: float, x: float) -> float: - """Regularized upper incomplete gamma Q(a, x) (Numerical Recipes 6.2).""" - if x < 0.0 or a <= 0.0: - raise ValueError("invalid arguments to Q(a, x)") - if x == 0.0: - return 1.0 - if x < a + 1.0: - # series for P(a,x), return 1 - P - ap = a - total = 1.0 / a - delta = total - for _ in range(500): - ap += 1.0 - delta *= x / ap - total += delta - if abs(delta) < abs(total) * 1e-15: - break - p = total * math.exp(-x + a * math.log(x) - math.lgamma(a)) - return max(0.0, min(1.0, 1.0 - p)) - # continued fraction for Q(a,x) (modified Lentz) - tiny = 1e-300 - b = x + 1.0 - a - c = 1.0 / tiny - d = 1.0 / b - h = d - for i in range(1, 500): - an = -i * (i - a) - b += 2.0 - d = an * d + b - if abs(d) < tiny: - d = tiny - c = b + an / c - if abs(c) < tiny: - c = tiny - d = 1.0 / d - delta = d * c - h *= delta - if abs(delta - 1.0) < 1e-15: - break - return max(0.0, min(1.0, h * math.exp(-x + a * math.log(x) - math.lgamma(a)))) - - -def chi2_sf(x: float, df: float) -> float: - """P(Chi2_df >= x).""" - if df <= 0: - return float("nan") - return _gammainc_upper_reg(df / 2.0, max(x, 0.0) / 2.0) - - -def benjamini_hochberg(p_values: np.ndarray, q: float = 0.05) -> np.ndarray: - """Boolean rejection mask controlling FDR at level q (BH 1995).""" - p = np.asarray(p_values, dtype=float) - valid = np.isfinite(p) - m = int(valid.sum()) - reject = np.zeros(p.shape, dtype=bool) - if m == 0: - return reject - order = np.argsort(np.where(valid, p, np.inf)) - ranked = p[order][:m] - thresh = q * (np.arange(1, m + 1) / m) - below = ranked <= thresh - if below.any(): - k = int(np.max(np.nonzero(below)[0])) - reject[order[: k + 1]] = True - return reject - - -# -------------------------------------------------------------------------- -# marginal item response tables on the quadrature grid -# -------------------------------------------------------------------------- - - -def _icc_grid( - params, - factor_id: np.ndarray, - model: str, - q_theta: int = 21, - q_xi: int = 11, - eps_distance: float = 1e-8, - prior_mean: np.ndarray | None = None, - prior_sd: np.ndarray | None = None, -): - """Item ICCs on the joint (t, x) grid. - - Returns (probs (I, Qt, Nx), node weights (Qt,), (Nx,), theta nodes (Qt,)). - ``prior_mean`` and ``prior_sd`` optionally transform the trait prior per - dimension (D,) — used for multigroup/multilevel populations where - theta_d ~ N(mean_d, sd_d^2). - """ - model = model.upper() - free_alpha = model not in {"MLSRM", "ULSRM"} - uses_space = model != "MIRT" - t_nodes, t_w = _gh(q_theta) - if uses_space: - x_grid, x_logw = _xi_grid(q_xi, params.zeta.shape[1]) - x_w = np.exp(x_logw) - else: - x_grid = np.zeros((1, params.zeta.shape[1])) - x_w = np.ones(1) - a = np.exp(params.alpha) if free_alpha else np.ones_like(params.alpha) - d_of_i, _fid_ndims = _validate_factor_id(factor_id) - shift = np.zeros(int(d_of_i.max()) + 1) if prior_mean is None else np.asarray(prior_mean) - scale = np.ones(int(d_of_i.max()) + 1) if prior_sd is None else np.asarray(prior_sd) - if shift.shape != scale.shape or np.any(~np.isfinite(shift)): - raise ValueError("prior_mean/prior_sd must be finite vectors with matching dimensions") - if np.any(~np.isfinite(scale)) or np.any(scale <= 0.0): - raise ValueError("prior_sd must contain finite positive values") - theta = shift[d_of_i][:, None] + scale[d_of_i][:, None] * t_nodes[None, :] # (I, Qt) - eta = a[:, None, None] * theta[:, :, None] + params.b[:, None, None] - if uses_space: - diff = x_grid[None, :, :] - params.zeta[:, None, :] - dist = np.sqrt(eps_distance + np.sum(diff * diff, axis=2)) # (I, Nx) - eta = eta - math.exp(params.tau) * dist[:, None, :] - probs = 1.0 / (1.0 + np.exp(-np.clip(eta, -700, 700))) - return probs, t_w, x_w, t_nodes - - -def _factorized_trait_moments( - probs: np.ndarray, - trait_weights: np.ndarray, - space_weights: np.ndarray, - factor_id: np.ndarray, - item_sets: list[list[int]], -) -> np.ndarray: - """Integrate simple-structure margins over independent trait dimensions. - - ``probs`` has shape ``(items, trait_nodes, space_nodes)``. Items on the - same factor share a trait node; distinct factors are integrated - independently conditional on the common latent-space node. - """ - probs = np.asarray(probs, dtype=float) - trait_weights = np.asarray(trait_weights, dtype=float) - space_weights = np.asarray(space_weights, dtype=float) - d_of_i = np.asarray(factor_id, dtype=np.int64) - if probs.ndim != 3 or probs.shape[1:] != ( - trait_weights.size, - space_weights.size, - ): - raise ValueError("probability grid does not match quadrature weights") - out = np.empty(len(item_sets), dtype=float) - for row, item_set in enumerate(item_sets): - item_set = np.asarray(item_set, dtype=np.int64) - conditional = np.ones(space_weights.size, dtype=float) - for dimension in np.unique(d_of_i[item_set]): - items = item_set[d_of_i[item_set] == dimension] - conditional *= trait_weights @ np.prod(probs[items], axis=0) - out[row] = float(space_weights @ conditional) - return out - - -def _icc_multilevel_grid( - params, - factor_id: np.ndarray, - model: str, - sigma_u: float, - q_u: int, - q_theta: int, - q_xi: int, - eps_distance: float, -): - """ICC grids conditional on one shared cluster-intercept quadrature.""" - d_of_i, n_dims = _validate_factor_id(factor_id) - u_nodes, u_weights = _gh(q_u) - grids = [] - trait_weights = space_weights = None - for node in u_nodes: - probs, trait_weights, space_weights, _ = _icc_grid( - params, - d_of_i, - model, - q_theta, - q_xi, - eps_distance, - np.full(n_dims, sigma_u * node), - np.ones(n_dims), - ) - grids.append(probs) - return np.stack(grids), u_weights, trait_weights, space_weights - - -def _factorized_multilevel_moments( - probs: np.ndarray, - cluster_weights: np.ndarray, - trait_weights: np.ndarray, - space_weights: np.ndarray, - factor_id: np.ndarray, - item_sets: list[list[int]], -) -> np.ndarray: - """Integrate margins over shared cluster and independent residual traits.""" - conditional = np.stack( - [ - _factorized_trait_moments( - grid, trait_weights, space_weights, factor_id, item_sets - ) - for grid in probs - ] - ) - return np.asarray(cluster_weights, dtype=float) @ conditional - - -def _lord_wingersky(probs: np.ndarray) -> np.ndarray: - """Summed-score distribution at each grid node. - - ``probs`` is (I, Q) — item success probabilities at Q nodes. Returns - (I+1, Q): P(score = r | node q) for the item set. - """ - n_items, n_nodes = probs.shape - f = np.zeros((n_items + 1, n_nodes)) - f[0] = 1.0 - probs[0] - f[1] = probs[0] - for n in range(1, n_items): - p = probs[n] - prev = f[: n + 1].copy() - f[: n + 2] = 0.0 - f[: n + 1] += prev * (1.0 - p)[None, :] - f[1 : n + 2] += prev * p[None, :] - return f - - -@dataclass -class SX2Result: - statistic: np.ndarray - g2_statistic: np.ndarray - df: np.ndarray - p_value: np.ndarray - g2_p_value: np.ndarray - flagged_bh: np.ndarray - n_score_groups: np.ndarray - # N_s-weighted RMS of (O - E): the practical-significance effect size that - # keeps the over-powered chi-square honest at large N (Sinharay & Haberman - # 2014). flagged_bh requires BH significance AND rms >= min_effect. - rms_residual: np.ndarray | None = None - - -def s_x2( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - mask: np.ndarray | None = None, - q_theta: int = 21, - q_xi: int = 11, - eps_distance: float = 1e-8, - prior_mean: np.ndarray | None = None, - min_expected: float = 1.0, - fdr_q: float = 0.05, - person_weight: np.ndarray | None = None, - min_effect: float = 0.0, -) -> SX2Result: - """Orlando-Thissen S-X² per item, summed scores within each trait dim. - - Persons with any missing response inside a dimension are excluded from - that dimension's observed table (the summed score would not be - comparable). ``person_weight`` (0/1) can down-weight aberrant respondents - flagged by person fit before item decisions (design doc §6). - ``min_effect`` guards the BH flag with the RMS observed-minus-expected - effect size (practical significance at large N). - - References - ---------- - Orlando, M., & Thissen, D. (2000). Likelihood-based item-fit indices for - dichotomous item response theory models. *Applied Psychological - Measurement, 24*(1), 50–64. https://doi.org/10.1177/01466216000241003 - - Sinharay, S., & Lu, Y. (2008). A further look at the correlation between - item parameters and item fit statistics. *Journal of Educational - Measurement, 45*(1), 1-15. - """ - ( - q_theta, - q_xi, - min_expected, - fdr_q, - min_effect, - ) = _validate_sx2_controls(q_theta, q_xi, min_expected, fdr_q, min_effect) - try: - y0 = np.asarray(responses, dtype=float) - except (TypeError, ValueError) as exc: - raise ValueError("responses must be a 2-D numeric array") from exc - if y0.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y0.shape - d_of_i, _fid_ndims = _validate_factor_id(factor_id) - if d_of_i.shape != (n_items,): - raise ValueError("factor_id length must match the number of response items") - observed0 = ~np.isnan(y0) if mask is None else np.asarray(mask, dtype=bool) - if observed0.shape != y0.shape: - raise ValueError("mask shape must match responses") - if np.any(observed0 & (~np.isfinite(y0) | ((y0 != 0.0) & (y0 != 1.0)))): - raise ValueError("observed responses must be dichotomous (0/1)") - if person_weight is None: - weight = np.ones(n_persons) - else: - weight = np.asarray(person_weight, dtype=float) - if weight.shape != (n_persons,): - raise ValueError("person_weight must have length n_persons") - if np.any(~np.isfinite(weight)) or np.any((weight != 0.0) & (weight != 1.0)): - raise ValueError("person_weight must contain only finite 0/1 values") - - core = _core_module() - if core is not None and prior_mean is None: - n_dims = int(d_of_i.max()) + 1 - bank = _bank_args(params, d_of_i, model, n_dims, eps_distance) - res = core.s_x2_stat( - np.where(observed0, y0, 0.0).ravel(), - observed0.ravel(), - int(y0.shape[0]), - bank["alpha"], - bank["b"], - bank["zeta"], - bank["tau"], - bank["factor_id"], - bank["model"], - bank["n_dims"], - bank["latent_dim"], - bank["eps_distance"], - np.zeros(n_dims), - np.ones(n_dims), - q_theta=int(q_theta), - xi_rule="gh", - q_xi=int(q_xi), - min_expected=float(min_expected), - fdr_q=float(fdr_q), - min_effect=float(min_effect), - person_weight=None if person_weight is None else weight, - ) - return SX2Result( - statistic=np.asarray(res["statistic"]), - g2_statistic=np.asarray( - res.get("g2_statistic", np.full_like(res["statistic"], np.nan)) - ), - df=np.asarray(res["df"]), - p_value=np.asarray(res["p_value"]), - g2_p_value=np.asarray( - res.get("g2_p_value", np.full_like(res["p_value"], np.nan)) - ), - flagged_bh=np.asarray(res["flagged_bh"], dtype=bool), - n_score_groups=np.asarray(res["n_score_groups"], dtype=int), - rms_residual=np.asarray(res["rms_residual"]), - ) - observed = observed0 - y = np.where(observed, y0, 0.0) - n_dims = int(d_of_i.max()) + 1 - - probs, t_w, x_w, _ = _icc_grid( - params, d_of_i, model, q_theta, q_xi, eps_distance, prior_mean - ) - n_free = {"MLSRM": 1, "ULSRM": 1}.get(model.upper(), 2) - if model.upper() != "MIRT": - n_free += params.zeta.shape[1] - - stat = np.full(n_items, np.nan) - g2_stat = np.full(n_items, np.nan) - dof = np.full(n_items, np.nan) - pval = np.full(n_items, np.nan) - g2_pval = np.full(n_items, np.nan) - rms = np.full(n_items, np.nan) - n_groups_out = np.zeros(n_items, dtype=int) - - for d in range(n_dims): - items = np.flatnonzero(d_of_i == d) - if len(items) < 2: - continue - complete = observed[:, items].all(axis=1) & (weight > 0) - yd = y[np.ix_(complete, items)] - n_d = len(items) - if yd.shape[0] == 0: - continue - scores = yd.sum(axis=1).astype(int) - # grid: flatten (t, x) nodes with product weights - p_flat = probs[items].reshape(n_d, -1) # (I_d, Qt*Nx) - w_flat = (t_w[:, None] * x_w[None, :]).reshape(-1) - s_all = _lord_wingersky(p_flat) # (I_d+1, Q) - denom = s_all @ w_flat # (I_d+1,) - for local_i, i in enumerate(items): - rest = np.delete(np.arange(n_d), local_i) - s_rest = _lord_wingersky(p_flat[rest]) if len(rest) else None - # E_is for s = 1..I_d-1 - e = np.full(n_d + 1, np.nan) - for s_score in range(1, n_d): - num = float((p_flat[local_i] * s_rest[s_score - 1]) @ w_flat) - den = float(denom[s_score]) - e[s_score] = num / den if den > 0 else np.nan - obs_n = np.bincount(scores, minlength=n_d + 1).astype(float) - obs_r = np.bincount(scores, weights=yd[:, local_i], minlength=n_d + 1) - # score groups 1..I_d-1; collapse adjacent until expected >= min_expected - groups: list[tuple[float, float, float]] = [] # (N, O_sum, E_sum) - acc_n, acc_r, acc_e = 0.0, 0.0, 0.0 - for s_score in range(1, n_d): - if not np.isfinite(e[s_score]): - continue - acc_n += obs_n[s_score] - acc_r += obs_r[s_score] - acc_e += obs_n[s_score] * e[s_score] - if ( - acc_n > 0 - and acc_e >= min_expected - and (acc_n - acc_e) >= min_expected - ): - groups.append((acc_n, acc_r, acc_e)) - acc_n, acc_r, acc_e = 0.0, 0.0, 0.0 - if acc_n > 0 and groups: - n0, r0, e0 = groups[-1] - groups[-1] = (n0 + acc_n, r0 + acc_r, e0 + acc_e) - elif acc_n > 0: - groups.append((acc_n, acc_r, acc_e)) - x2, g2, n_grp = 0.0, 0.0, 0 - rss, n_tot = 0.0, 0.0 - for gn, gr, ge in groups: - if gn <= 0: - continue - e_prop = ge / gn - if e_prop <= 0.0 or e_prop >= 1.0: - continue - o_prop = gr / gn - x2 += gn * (o_prop - e_prop) ** 2 / (e_prop * (1.0 - e_prop)) - g2 += 2.0 * gn * ( - _xlogx_over_y(o_prop, e_prop) - + _xlogx_over_y(1.0 - o_prop, 1.0 - e_prop) - ) - rss += gn * (o_prop - e_prop) ** 2 - n_tot += gn - n_grp += 1 - df_i = n_grp - n_free - stat[i] = x2 - g2_stat[i] = g2 - n_groups_out[i] = n_grp - rms[i] = np.sqrt(rss / n_tot) if n_tot > 0 else np.nan - if df_i >= 1: - dof[i] = df_i - pval[i] = chi2_sf(x2, df_i) - g2_pval[i] = chi2_sf(g2, df_i) - flagged = benjamini_hochberg(pval, fdr_q) - flagged &= np.where(np.isfinite(rms), rms, -np.inf) >= min_effect - return SX2Result( - statistic=stat, - g2_statistic=g2_stat, - df=dof, - p_value=pval, - g2_p_value=g2_pval, - flagged_bh=flagged, - n_score_groups=n_groups_out, - rms_residual=rms, - ) - - -# -------------------------------------------------------------------------- -# person fit: l_z and Snijders l_z* -# -------------------------------------------------------------------------- - - -@dataclass -class PersonFitResult: - lz: np.ndarray - lz_star: np.ndarray - flagged: np.ndarray - - -def person_fit( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - mask: np.ndarray | None = None, - eps_distance: float = 1e-8, - prior_mean: np.ndarray | None = None, - flag_threshold: float = -1.645, -) -> PersonFitResult: - """l_z and l_z* per person and trait dimension, at EAP estimates. - - Returns arrays of shape (n_persons, n_dims). ``l_z*`` uses Snijders' - (2001) correction with the MAP ``r_0 = -(theta_hat - prior_mean)`` term - for the N(prior_mean, 1) trait prior (EAP ≈ MAP for these posteriors); - the latent-space position is held at its EAP, so the correction covers - the trait estimate only (documented approximation). - - References - ---------- - Drasgow, F., Levine, M. V., & Williams, E. A. (1985). Appropriateness - measurement with polychotomous item response models and standardized - indices. *British Journal of Mathematical and Statistical Psychology, - 38*(1), 67–86. https://doi.org/10.1111/j.2044-8317.1985.tb00817.x - - Snijders, T. A. B. (2001). Asymptotic null distribution of person fit - statistics with estimated person parameter. *Psychometrika, 66*(3), - 331–342. https://doi.org/10.1007/BF02294437 - """ - model = model.upper() - free_alpha = model not in {"MLSRM", "ULSRM"} - uses_space = model != "MIRT" - y, observed, d_of_i = _prepare_dichotomous_diagnostic_inputs( - responses, factor_id, mask - ) - n_persons, n_items = y.shape - n_dims = int(d_of_i.max()) + 1 - core = _core_module() - if core is not None: - bank = _bank_args(params, d_of_i, model, n_dims, eps_distance) - res = core.person_fit_stat( - y.ravel(), - observed.ravel(), - int(n_persons), - bank["alpha"], bank["b"], bank["zeta"], bank["tau"], bank["factor_id"], - bank["model"], bank["n_dims"], bank["latent_dim"], bank["eps_distance"], - np.asarray(params.theta, dtype=np.float64).ravel(), - np.asarray(params.xi, dtype=np.float64).ravel(), - prior_mean=None - if prior_mean is None - else np.broadcast_to( - np.asarray(prior_mean, dtype=np.float64), (n_persons, n_dims) - ).ravel().copy(), - flag_threshold=float(flag_threshold), - ) - return PersonFitResult( - lz=np.asarray(res["lz"]).reshape(n_persons, n_dims), - lz_star=np.asarray(res["lz_star"]).reshape(n_persons, n_dims), - flagged=np.asarray(res["flagged"], dtype=bool), - ) - theta = np.asarray(params.theta, dtype=float) - a = np.exp(params.alpha) if free_alpha else np.ones(n_items) - shift = np.zeros((n_persons, n_dims)) - if prior_mean is not None: - shift += np.asarray(prior_mean) - - # eta_pi at EAP estimates - eta = a[None, :] * theta[:, d_of_i] + params.b[None, :] - if uses_space: - diff = np.asarray(params.xi)[:, None, :] - np.asarray(params.zeta)[None, :, :] - dist = np.sqrt(eps_distance + np.sum(diff * diff, axis=2)) - eta = eta - math.exp(params.tau) * dist - p = 1.0 / (1.0 + np.exp(-np.clip(eta, -700, 700))) - p = np.clip(p, 1e-12, 1.0 - 1e-12) - w = np.log(p / (1.0 - p)) # w_i(theta) for l_z - var_i = p * (1.0 - p) - - lz = np.full((n_persons, n_dims), np.nan) - lz_star = np.full((n_persons, n_dims), np.nan) - for d in range(n_dims): - items = d_of_i == d - o = observed[:, items] - yd, pd, wd, vd = y[:, items], p[:, items], w[:, items], var_i[:, items] - ad = a[items] - n_obs = o.sum(axis=1) - ok = n_obs >= 2 - # l_z - w_stat = ((yd - pd) * wd * o).sum(axis=1) - var_l = (vd * wd**2 * o).sum(axis=1) - with np.errstate(divide="ignore", invalid="ignore"): - lz[:, d] = np.where(ok & (var_l > 0), w_stat / np.sqrt(var_l), np.nan) - # l_z*: r_i = P'/(P(1-P)) = a_i ; c = sum P' w / sum P' r - p_prime = ad[None, :] * vd # (P, I_d) - num_c = (p_prime * wd * o).sum(axis=1) - den_c = (p_prime * ad[None, :] * o).sum(axis=1) - with np.errstate(divide="ignore", invalid="ignore"): - c = np.where(den_c > 0, num_c / den_c, 0.0) - w_tilde = wd - c[:, None] * ad[None, :] - tau2 = (w_tilde**2 * vd * o).sum(axis=1) / np.maximum(n_obs, 1) - r0 = -(theta[:, d] - shift[:, d]) # MAP correction, N(mean, 1) prior - with np.errstate(divide="ignore", invalid="ignore"): - lz_star[:, d] = np.where( - ok & (tau2 > 0), - (w_stat + c * r0) / (np.sqrt(np.maximum(n_obs, 1)) * np.sqrt(tau2)), - np.nan, - ) - flagged = np.nanmin(np.where(np.isnan(lz_star), np.inf, lz_star), axis=1) < flag_threshold - return PersonFitResult(lz=lz, lz_star=lz_star, flagged=flagged) - - -# -------------------------------------------------------------------------- -# infit / outfit at the marginal EAP estimates -# -------------------------------------------------------------------------- - - -def infit_outfit( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - mask: np.ndarray | None = None, - eps_distance: float = 1e-8, -) -> dict[str, np.ndarray]: - """Per-item infit/outfit mean squares at the EAP estimates.""" - model = model.upper() - free_alpha = model not in {"MLSRM", "ULSRM"} - uses_space = model != "MIRT" - y, observed, d_of_i = _prepare_dichotomous_diagnostic_inputs( - responses, factor_id, mask - ) - core = _core_module() - if core is not None: - n_persons = y.shape[0] - n_dims = int(d_of_i.max()) + 1 - bank = _bank_args(params, d_of_i, model, n_dims, eps_distance) - res = core.infit_outfit_stat( - y.ravel(), - observed.ravel(), - int(n_persons), - bank["alpha"], bank["b"], bank["zeta"], bank["tau"], bank["factor_id"], - bank["model"], bank["n_dims"], bank["latent_dim"], bank["eps_distance"], - np.asarray(params.theta, dtype=np.float64).ravel(), - np.asarray(params.xi, dtype=np.float64).ravel(), - ) - return {"infit": np.asarray(res["infit"]), "outfit": np.asarray(res["outfit"])} - a = np.exp(params.alpha) if free_alpha else np.ones(len(params.b)) - eta = a[None, :] * np.asarray(params.theta)[:, d_of_i] + params.b[None, :] - if uses_space: - diff = np.asarray(params.xi)[:, None, :] - np.asarray(params.zeta)[None, :, :] - dist = np.sqrt(eps_distance + np.sum(diff * diff, axis=2)) - eta = eta - math.exp(params.tau) * dist - p = np.clip(1.0 / (1.0 + np.exp(-np.clip(eta, -700, 700))), 1e-12, 1 - 1e-12) - v = p * (1.0 - p) - resid2 = (y - p) ** 2 * observed - n_obs = np.maximum(observed.sum(axis=0), 1) - outfit = (resid2 / v * observed).sum(axis=0) / n_obs - infit = resid2.sum(axis=0) / np.maximum((v * observed).sum(axis=0), 1e-12) - return {"infit": infit, "outfit": outfit} - - -# -------------------------------------------------------------------------- -# item screening pipeline (design doc §6 / formula compilation §9) -# -------------------------------------------------------------------------- - - -@dataclass -class ItemScreeningRound: - round_index: int - kept_items: list[str] - removed_items: list[str] - reasons: dict[str, list[str]] - flags: dict[str, dict[str, bool]] = field(default_factory=dict) - - -@dataclass -class ItemScreeningResult: - kept_items: list[str] - removed_items: dict[str, list[str]] - rounds: list[ItemScreeningRound] - final_result: object - - -def _require_converged_fit(result, config, operation: str, stage: str) -> None: - status = str(result.convergence_status).strip().lower() - if status == "converged": - return - - trace = result.loglik_trace - last_delta = ( - abs(float(trace[-1]) - float(trace[-2])) if len(trace) >= 2 else float("nan") - ) - raise RuntimeError( - f"{operation} requires converged parameters before " - f"{stage}; status={status or 'unknown'}, n_iter={result.n_iter}, " - f"max_iter={config.max_iter}, last_loglik_delta={last_delta:.6g}, " - f"tolerance={config.tolerance:.6g}" - ) - - -def select_items( - responses: np.ndarray, - factor_id: np.ndarray, - item_codes: list[str] | None = None, - config=None, - mask: np.ndarray | None = None, - group_id: np.ndarray | None = None, - cluster_id: np.ndarray | None = None, - min_positive: int = 20, - fdr_q: float = 0.05, - sx2_min_effect: float = 0.02, - msq_band: tuple[float, float] = (0.7, 1.3), - min_discrimination: float = 0.35, - isolation_z: float = 3.0, - min_items_per_dim: int = 4, - max_rounds: int = 5, - min_flags_to_remove: int = 2, - person_flag_threshold: float = -1.645, -) -> ItemScreeningResult: - """Iterative fit -> flag -> remove -> refit item screening. - - Flags per round (literature-grounded; see the formula compilation §9): - - 1. ``sparse``: fewer than ``min_positive`` positive (or negative) - observed responses — removed on this flag alone (the item cannot - support its parameters). - 2. ``sx2``: S-X² significant after Benjamini-Hochberg at ``fdr_q`` AND a - practical effect (`rms_residual >= sx2_min_effect`) — chi-square power - grows without bound in N, so significance alone over-prunes large - calibrations (Sinharay & Haberman 2014). - 3. ``msq``: **infit** outside ``msq_band`` (Wright & Linacre 1994). Outfit - is reported but does not gate: with very low pass rates a handful of - surprising responses explodes the unweighted mean square. - 4. ``low_disc``: discrimination below ``min_discrimination`` (2PL models). - 5. ``isolated``: gamma-weighted mean distance to respondents is a robust - z-score outlier above ``isolation_z`` — the LSIRM reading of an item - nobody interacts with. - - An item is removed when it fails ``min_flags_to_remove`` of flags 2-5 (or - flag 1 alone). Persons flagged by ``l_z* < -1.645`` are excluded from the - flagging statistics (not from the final fit). Dimensions never drop below - ``min_items_per_dim`` items — the worst offenders are retained with a - note. The final refit uses all surviving items. Every screening fit and - the final refit must report convergence; unfinished fits raise with their - iteration and stopping evidence instead of producing inferential flags. - """ - from .config import FitConfig - from .fit import fit - - y = np.asarray(responses, dtype=float) - observed = ~np.isnan(y) if mask is None else np.asarray(mask, dtype=bool) - n_items = y.shape[1] - d_of_i, _fid_ndims = _validate_factor_id(factor_id) - codes = item_codes or [f"item_{i:03d}" for i in range(n_items)] - config = config or FitConfig(model="MLS2PLM", estimator="mmle") - if config.estimator != "mmle": - raise ValueError("select_items requires estimator='mmle'") - if max_rounds < 1: - raise ValueError("max_rounds must be >= 1") - - active = np.ones(n_items, dtype=bool) - rounds: list[ItemScreeningRound] = [] - removed: dict[str, list[str]] = {} - result = None - fitted_active = None - - for round_index in range(max_rounds): - idx = np.flatnonzero(active) - y_r = y[:, idx] - obs_r = observed[:, idx] - fid_r = d_of_i[idx] - # remap dims to a dense 0..D'-1 (dims can empty out only via floor) - result = fit( - np.where(obs_r, y_r, np.nan), - fid_r, - config, - group_id=group_id, - cluster_id=cluster_id, - ) - fitted_active = active.copy() - _require_converged_fit( - result, config, "select_items", "inferential screening" - ) - # person screen — prior means matter for the Snijders MAP correction: - # multilevel EAPs absorb the cluster intercepts, multigroup the group - # means, so r_0 must be centered accordingly. - prior_mean = None - if result.population is not None: - popk = result.population - if popk["kind"] == "multilevel" and cluster_id is not None: - u = np.asarray(popk["u_eap"], dtype=float) - prior_mean = np.repeat( - u[np.asarray(cluster_id, dtype=np.int64)][:, None], - int(fid_r.max()) + 1, - axis=1, - ) - elif popk["kind"] == "multigroup" and group_id is not None: - prior_mean = np.asarray(popk["mu"], dtype=float)[ - np.asarray(group_id, dtype=np.int64) - ] - pf = person_fit( - np.where(obs_r, y_r, np.nan), fid_r, result.params, result.model, - prior_mean=prior_mean, flag_threshold=person_flag_threshold, - ) - weight = (~pf.flagged).astype(float) - # flags - sx2_res = s_x2( - np.where(obs_r, y_r, np.nan), - fid_r, - result.params, - result.model, - q_theta=config.q_theta, - q_xi=config.q_xi, - fdr_q=fdr_q, - person_weight=weight, - min_effect=sx2_min_effect, - ) - msq = infit_outfit(np.where(obs_r, y_r, np.nan), fid_r, result.params, result.model) - a_est = np.exp(result.params.alpha) - pos_count = np.where(obs_r, y_r, 0.0).sum(axis=0) - neg_count = obs_r.sum(axis=0) - pos_count - gamma = float(np.exp(result.params.tau)) - mean_dist = gamma * np.mean( - np.sqrt( - 1e-8 - + np.sum( - (np.asarray(result.params.xi)[:, None, :] - - np.asarray(result.params.zeta)[None, :, :]) ** 2, - axis=2, - ) - ), - axis=0, - ) - med = float(np.median(mean_dist)) - mad = float(np.median(np.abs(mean_dist - med))) * 1.4826 - iso_z = (mean_dist - med) / mad if mad > 0 else np.zeros_like(mean_dist) - - free_alpha = result.model not in {"MLSRM", "ULSRM"} - uses_space = result.model != "MIRT" - flags: dict[str, dict[str, bool]] = {} - to_remove: list[int] = [] - reasons: dict[str, list[str]] = {} - for local_i, gi in enumerate(idx): - code = codes[gi] - f = { - "sparse": bool( - pos_count[local_i] < min_positive or neg_count[local_i] < min_positive - ), - "sx2": bool(sx2_res.flagged_bh[local_i]), - "msq": bool( - msq["infit"][local_i] < msq_band[0] - or msq["infit"][local_i] > msq_band[1] - ), - "outfit_out": bool( - msq["outfit"][local_i] < msq_band[0] - or msq["outfit"][local_i] > msq_band[1] - ), - "low_disc": bool(free_alpha and a_est[local_i] < min_discrimination), - "isolated": bool(uses_space and iso_z[local_i] > isolation_z), - } - flags[code] = f - n_soft = sum(f[k] for k in ("sx2", "msq", "low_disc", "isolated")) - if f["sparse"] or n_soft >= min_flags_to_remove: - to_remove.append(local_i) - reasons[code] = (["sparse"] if f["sparse"] else []) + [ - k for k in ("sx2", "msq", "low_disc", "isolated") if f[k] - ] - - # enforce the per-dimension floor: keep the least-flagged items - kept_after = active.copy() - for local_i in to_remove: - kept_after[idx[local_i]] = False - for d in range(int(d_of_i.max()) + 1): - dim_items = np.flatnonzero((d_of_i == d) & active) - surviving = np.flatnonzero((d_of_i == d) & kept_after) - deficit = min_items_per_dim - len(surviving) - if deficit > 0: - dropped = [g for g in dim_items if not kept_after[g]] - for g in dropped[:deficit]: - kept_after[g] = True - code = codes[g] - reasons.pop(code, None) - - removed_codes = [codes[g] for g in np.flatnonzero(active & ~kept_after)] - rounds.append( - ItemScreeningRound( - round_index=round_index, - kept_items=[codes[g] for g in np.flatnonzero(kept_after)], - removed_items=removed_codes, - reasons=dict(reasons), - flags=flags, - ) - ) - for code in removed_codes: - removed[code] = reasons.get(code, []) - if not removed_codes: - break - active = kept_after - - if fitted_active is None or not np.array_equal(fitted_active, active): - idx = np.flatnonzero(active) - obs_r = observed[:, idx] - result = fit( - np.where(obs_r, y[:, idx], np.nan), - d_of_i[idx], - config, - group_id=group_id, - cluster_id=cluster_id, - ) - _require_converged_fit(result, config, "select_items", "the final refit") - - return ItemScreeningResult( - kept_items=[codes[g] for g in np.flatnonzero(active)], - removed_items=removed, - rounds=rounds, - final_result=result, - ) - - -# -------------------------------------------------------------------------- -# model comparison and dimensionality residuals (Rust-core compute) -# -------------------------------------------------------------------------- - - -def vuong_nonnested( - loglik_a: np.ndarray, - loglik_b: np.ndarray, - k_a: int, - k_b: int, - bic_correction: bool = True, -) -> dict: - """Vuong test for non-nested model comparison from casewise marginal - log-likelihoods. Positive z favors model A; ``bic_correction`` applies the - Schwarz penalty. This function implements the non-nested z test only, not - Vuong's separate distinguishability test (Schneider et al., 2020). - - References (APA 7th ed.): - Schneider, L., Chalmers, R. P., Debelak, R., & Merkle, E. C. (2020). - Model selection of nested and non-nested item response models using - Vuong tests. *Multivariate Behavioral Research, 55*(5), 664–684. - https://doi.org/10.1080/00273171.2019.1664280 - Vuong, Q. H. (1989). Likelihood ratio tests for model selection and - non-nested hypotheses. *Econometrica, 57*(2), 307–333. - https://doi.org/10.2307/1912557 - """ - core = _core_module() - if core is None: - raise RuntimeError("vuong_nonnested requires the compiled Rust core") - - ll_a = np.asarray(loglik_a) - ll_b = np.asarray(loglik_b) - if ll_a.ndim != 1 or ll_b.ndim != 1: - raise ValueError("casewise log-likelihoods must be one-dimensional") - try: - ll_a = ll_a.astype(np.float64, copy=False) - ll_b = ll_b.astype(np.float64, copy=False) - except (TypeError, ValueError) as exc: - raise ValueError("casewise log-likelihoods must be numeric") from exc - if ll_a.size != ll_b.size or ll_a.size < 2: - raise ValueError("casewise log-likelihood vectors must be equal-length with n >= 2") - if not np.all(np.isfinite(ll_a)) or not np.all(np.isfinite(ll_b)): - raise ValueError("casewise log-likelihoods must be finite") - - def parameter_count(value, name: str) -> int: - if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)): - raise ValueError(f"{name} must be a non-negative integer") - result = int(value) - if result < 0: - raise ValueError(f"{name} must be a non-negative integer") - return result - - k_a_int = parameter_count(k_a, "k_a") - k_b_int = parameter_count(k_b, "k_b") - if not isinstance(bic_correction, (bool, np.bool_)): - raise ValueError("bic_correction must be boolean") - return dict( - core.vuong_nonnested( - ll_a, - ll_b, - k_a_int, - k_b_int, - bool(bic_correction), - ) - ) - - -def dimensionality_residuals( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - mask: np.ndarray | None = None, - eps_distance: float = 1e-8, - *, - convergence_status: str | None = None, -) -> dict: - """Compute Yen's Q3 correlations from EAP residuals ``y - P_hat``. - - ``mean_abs_residual_cross_product`` is this package's descriptive average - of ``abs(mean(e_i * e_j))`` over item pairs. The legacy ``gddm`` key is an - alias for that value; it is **not** the published GDDM, which uses - model-based covariance in a posterior-predictive framework (Levy & - Svetina, 2011). When a calibration status is available, pass - ``convergence_status`` so diagnostics reject unfinished estimates. - - References (APA 7th ed.): - Levy, R., & Svetina, D. (2011). A generalized dimensionality - discrepancy measure for dimensionality assessment in - multidimensional item response theory. *British Journal of - Mathematical and Statistical Psychology, 64*(2), 208–232. - https://doi.org/10.1348/000711010X500483 - Yen, W. M. (1984). Effects of local item dependence on the fit and - equating performance of the three-parameter logistic model. - *Applied Psychological Measurement, 8*(2), 125–145. - https://doi.org/10.1177/014662168400800201 - """ - core = _core_module() - if core is None: - raise RuntimeError("dimensionality_residuals requires the compiled Rust core") - if convergence_status is not None: - status = str(convergence_status).strip().lower() - if status != "converged": - raise ValueError( - "dimensionality residuals require converged parameters; " - f"the fitted model did not converge (status={status or 'unknown'})" - ) - if isinstance(eps_distance, (bool, np.bool_)) or not isinstance( - eps_distance, (int, float, np.integer, np.floating) - ): - raise ValueError("eps_distance must be > 0 and finite") - eps_value = float(eps_distance) - if not np.isfinite(eps_value) or eps_value <= 0.0: - raise ValueError("eps_distance must be > 0 and finite") - - y, observed = prepare_response(responses, mask) - theta = np.asarray(params.theta, dtype=np.float64) - if theta.ndim != 2: - raise ValueError("params.theta must be a 2-D array") - d_of_i = validate_factor_id(factor_id, y.shape[1], theta.shape[1]) - eta, _ = linear_predictor(params, d_of_i, model=model, eps_distance=eps_value) - if eta.shape != y.shape: - raise ValueError("parameter dimensions must match responses and factor_id") - if not np.all(np.isfinite(eta)): - raise ValueError("model linear predictors must be finite") - p = sigmoid(eta) - resid = np.where(observed, y - p, np.nan) - out = dict( - core.dimensionality_residuals( - resid.astype(np.float64).ravel(), int(y.shape[0]), int(y.shape[1]) - ) - ) - out["q3"] = np.asarray(out["q3"]) - out["mean_abs_residual_cross_product"] = out["gddm"] - return out - - -# -------------------------------------------------------------------------- -# DIF analysis: group-specific item parameters + likelihood-ratio tests -# -------------------------------------------------------------------------- - - -@dataclass -class DIFResult: - item_codes: list[str] - lr_statistic: np.ndarray - df: np.ndarray - p_value: np.ndarray - flagged_bh: np.ndarray - b_by_group: np.ndarray - a_by_group: np.ndarray - effect_size: np.ndarray - - -def dif_analysis( - responses: np.ndarray, - factor_id: np.ndarray, - group_id: np.ndarray, - config=None, - item_codes: list[str] | None = None, - studied_items: list[int] | None = None, - mask: np.ndarray | None = None, - fdr_q: float = 0.05, -) -> DIFResult: - """Likelihood-ratio DIF screen for the multidimensional 2-PL model. - - Group-specific item parameters are a standard way to represent DIF in - multiple-group item-response models (Jeon et al., 2013; Makransky & Glas, - 2013). This implementation fits a constrained multiple-group MIRT model, - then splits each studied item into group-specific virtual items whose - discrimination and intercept are free while all other items are anchored. - It reports ``LR = 2 (ll_aug - ll_con)`` with - ``df = 2 * (G - 1)``. Applying this itemwise likelihood-ratio screen and - Benjamini-Hochberg correction is a repository-specific implementation - choice, not a reproduction of either cited paper's complete procedure. - - Spatial models are intentionally rejected: their virtual items would also - free latent-space positions, adding nuisance parameters that are not part - of the stated likelihood-ratio degrees of freedom. - - References - ---------- - Benjamini, Y., & Hochberg, Y. (1995). Controlling the false discovery - rate: A practical and powerful approach to multiple testing. *Journal - of the Royal Statistical Society: Series B (Methodological), 57*(1), - 289–300. https://doi.org/10.1111/j.2517-6161.1995.tb02031.x - Jeon, M., Rijmen, F., & Rabe-Hesketh, S. (2013). Modeling differential - item functioning using a generalization of the multiple-group bifactor - model. *Journal of Educational and Behavioral Statistics, 38*(1), - 32–60. https://doi.org/10.3102/1076998611432173 - Makransky, G., & Glas, C. A. W. (2013). Modeling differential item - functioning with group-specific item parameters: A computerized - adaptive testing application. *Measurement, 46*(9), 3228–3237. - https://doi.org/10.1016/j.measurement.2013.06.020 - """ - from .config import FitConfig - from .fit import _compact_population_labels, fit - - y = np.asarray(responses, dtype=float) - if y.ndim != 2 or 0 in y.shape: - raise ValueError("responses must be a non-empty 2D array") - if mask is not None: - mask_array = np.asarray(mask) - if mask_array.dtype.kind != "b": - raise ValueError("mask must be a boolean array") - if mask_array.shape != y.shape: - raise ValueError("mask must have the same shape as responses") - y = np.where(mask_array, y, np.nan) - d_of_i, _fid_ndims = _validate_factor_id(factor_id) - n_persons, n_items = y.shape - gid, n_groups = _compact_population_labels(group_id, n_persons, "group_id") - if n_groups < 2: - raise ValueError("dif_analysis requires at least two groups") - if item_codes is None: - codes = [f"item_{i:03d}" for i in range(n_items)] - else: - codes = list(item_codes) - if len(codes) != n_items: - raise ValueError("item_codes must have one entry per response column") - if studied_items is None: - studied = list(range(n_items)) - else: - studied_array = np.asarray(studied_items) - if studied_array.size == 0: - raise ValueError("studied_items must not be empty") - if studied_array.ndim != 1 or studied_array.dtype.kind not in "iu": - raise ValueError("studied_items must be a one-dimensional integer sequence") - if np.any((studied_array < 0) | (studied_array >= n_items)): - raise ValueError("studied_items contains an out-of-range item index") - if np.unique(studied_array).size != studied_array.size: - raise ValueError("studied_items must not contain duplicate item indices") - studied = studied_array.tolist() - if not np.isfinite(fdr_q) or not 0.0 < fdr_q <= 1.0: - raise ValueError("fdr_q must be finite and in (0, 1]") - config = config or FitConfig(model="MIRT", estimator="mmle") - if config.estimator != "mmle": - raise ValueError("dif_analysis requires estimator='mmle'") - if config.normalized_model() != "MIRT": - raise ValueError( - "dif_analysis currently supports model='MIRT' only; spatial models " - "would free latent-space item positions that are not represented in " - "the likelihood-ratio degrees of freedom" - ) - params_per_item = 2 - - constrained = fit(y, d_of_i, config, group_id=gid) - _require_converged_fit( - constrained, config, "dif_analysis", "the constrained fit" - ) - ll_con = constrained.loglik_trace[-1] - - lr = np.full(n_items, np.nan) - dof = np.full(n_items, np.nan) - pval = np.full(n_items, np.nan) - b_by_group = np.full((n_items, n_groups), np.nan) - a_by_group = np.full((n_items, n_groups), np.nan) - effect = np.full(n_items, np.nan) - - for i in studied: - cols = [np.where(gid == g, y[:, i], np.nan) for g in range(n_groups)] - y_aug = np.concatenate( - [np.delete(y, i, axis=1)] + [c[:, None] for c in cols], axis=1 - ) - fid_aug = np.concatenate( - [np.delete(d_of_i, i), np.full(n_groups, d_of_i[i], dtype=np.int64)] - ) - n_rest = n_items - 1 - fixed = np.zeros(n_rest + n_groups, dtype=bool) - fixed[:n_rest] = True - anchors = dict( - fixed=fixed, - alpha=np.concatenate( - [np.delete(constrained.params.alpha, i), np.zeros(n_groups)] - ), - b=np.concatenate([np.delete(constrained.params.b, i), np.zeros(n_groups)]), - zeta=np.concatenate( - [ - np.delete(constrained.params.zeta, i, axis=0), - np.repeat(constrained.params.zeta[i][None, :], n_groups, axis=0), - ], - axis=0, - ), - tau=float(constrained.params.tau), - ) - augmented = fit(y_aug, fid_aug, config, group_id=gid, anchors=anchors) - _require_converged_fit( - augmented, - config, - "dif_analysis", - f"the augmented fit for item {i}", - ) - ll_aug = augmented.loglik_trace[-1] - stat = max(0.0, 2.0 * (ll_aug - ll_con)) - df_i = (n_groups - 1) * params_per_item - lr[i] = stat - dof[i] = df_i - pval[i] = chi2_sf(stat, df_i) - b_g = augmented.params.b[n_rest:] - a_g = np.exp(augmented.params.alpha[n_rest:]) - b_by_group[i] = b_g - a_by_group[i] = a_g - effect[i] = float(np.nanmax(b_g) - np.nanmin(b_g)) - - return DIFResult( - item_codes=codes, - lr_statistic=lr, - df=dof, - p_value=pval, - flagged_bh=benjamini_hochberg(pval, fdr_q), - b_by_group=b_by_group, - a_by_group=a_by_group, - effect_size=effect, - ) - - -def residual_item_fit( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - mask: np.ndarray | None = None, - n_bins: int = 10, - eps_distance: float = 1e-8, -) -> dict: - """Compute a repository-specific EAP-bin residual item-fit screen. - - Persons are sorted by their EAP score for the item's dimension and split - into bins. Within each bin, the observed proportion is compared with the - mean fitted probability using a plug-in binomial z score. The result is the - maximum absolute z score per item with a Bonferroni-adjusted normal - p-value. - - This diagnostic follows the residual-analysis motivation of Haberman et - al. (2013), but it is not their maximum-likelihood item-response-function - versus alternative-ratio comparison or their covariance-standardized - residual statistic. EAP shrinkage can make this repository-specific - approximation less reliable in short tests; :func:`s_x2` is available as - an alternative. - - References - ---------- - Haberman, S. J., Sinharay, S., & Chon, K. H. (2013). Assessing item fit for - unidimensional item response theory models using residuals from - estimated item response functions. *Psychometrika, 78*(3), 417–440. - https://doi.org/10.1007/s11336-012-9305-1 - """ - core = _core_module() - if core is None: - raise RuntimeError("residual_item_fit requires the compiled Rust core") - y, observed, d_of_i = _prepare_dichotomous_diagnostic_inputs( - responses, factor_id, mask - ) - n_persons = y.shape[0] - if n_persons == 0: - raise ValueError("responses must contain at least one person") - if ( - isinstance(n_bins, (bool, np.bool_)) - or not isinstance(n_bins, (int, np.integer)) - or int(n_bins) < 2 - ): - raise ValueError("n_bins must be an integer >= 2") - n_bins_value = int(n_bins) - if n_bins_value > n_persons // 5: - raise ValueError("n_bins requires at least five persons per bin") - n_dims = int(d_of_i.max()) + 1 - bank = _bank_args(params, d_of_i, model, n_dims, eps_distance) - theta = np.asarray(params.theta, dtype=np.float64) - xi = np.asarray(params.xi, dtype=np.float64) - if theta.shape != (n_persons, n_dims): - raise ValueError("params.theta shape must be (n_persons, n_dims)") - if xi.shape != (n_persons, bank["latent_dim"]): - raise ValueError("params.xi shape must be (n_persons, latent_dim)") - if not np.all(np.isfinite(theta)) or not np.all(np.isfinite(xi)): - raise ValueError("params.theta and params.xi must be finite") - res = dict( - core.residual_item_fit( - y.ravel(), observed.ravel(), int(n_persons), - bank["alpha"], bank["b"], bank["zeta"], bank["tau"], bank["factor_id"], - bank["model"], bank["n_dims"], bank["latent_dim"], bank["eps_distance"], - theta.ravel(), xi.ravel(), n_bins=n_bins_value, - ) - ) - res["max_abs_z"] = np.asarray(res["max_abs_z"]) - res["p_value"] = np.asarray(res["p_value"]) - return res - - -def adjusted_chi2_pairs( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - mask: np.ndarray | None = None, - q_theta: int = 21, - q_xi: int = 11, - eps_distance: float = 1e-8, -) -> dict: - """Compute exploratory pairwise adjusted chi-square/df ratios. - - For each item pair, this repository constructs a model-implied 2x2 table - under a standard-normal trait prior, computes Pearson chi-square with three - degrees of freedom, and rescales it to a reference sample size of 3000. - Fewer than 20 jointly observed responses leave that pair undefined (``NaN``). - - Tay and Drasgow (2012) studied the earlier mean adjusted chi-square/df - tradition. Their simulations found that a fixed cutoff such as 3 was - insufficient across sample sizes and test lengths, and they recommended a - parametric bootstrap. This function is a repository-specific pairwise - simplification: it does not implement that bootstrap, and its outputs are - not source-backed hypothesis tests or universal local-dependence flags. - - References - ---------- - Tay, L., & Drasgow, F. (2012). Adjusting the adjusted chi-square/df ratio - statistic for dichotomous item response theory analyses: Does the model - fit? *Educational and Psychological Measurement, 72*(3), 510–528. - https://doi.org/10.1177/0013164411416976 - """ - core = _core_module() - if core is None: - raise RuntimeError("adjusted_chi2_pairs requires the compiled Rust core") - y, observed, d_of_i = _prepare_dichotomous_diagnostic_inputs( - responses, factor_id, mask - ) - n_persons, n_items = y.shape - if n_persons == 0: - raise ValueError("responses must contain at least one person") - if n_items < 2: - raise ValueError("adjusted pairwise fit requires at least two items") - for name, value in (("q_theta", q_theta), ("q_xi", q_xi)): - if isinstance(value, (bool, np.bool_)) or not isinstance( - value, (int, np.integer) - ): - raise ValueError(f"{name} must be an integer") - n_dims = int(d_of_i.max()) + 1 - bank = _bank_args(params, d_of_i, model, n_dims, eps_distance) - res = dict( - core.adjusted_chi2_pairs( - y.ravel(), observed.ravel(), int(n_persons), - bank["alpha"], bank["b"], bank["zeta"], bank["tau"], bank["factor_id"], - bank["model"], bank["n_dims"], bank["latent_dim"], bank["eps_distance"], - np.zeros(n_dims), np.ones(n_dims), - q_theta=int(q_theta), xi_rule="gh", q_xi=int(q_xi), - ) - ) - res["ratio"] = np.asarray(res["ratio"]) - return res - - -def person_fit_resampling( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - mask: np.ndarray | None = None, - prior_mean: np.ndarray | None = None, - n_replicates: int = 200, - seed: int = 1, - eps_distance: float = 1e-8, -) -> np.ndarray: - """Fixed-estimate Monte Carlo person-fit p-values. - - Replicate responses are sampled conditionally at the supplied EAP estimates, - and :math:`l_z^*` is recomputed at those same estimates. The returned lower- - tail frequency uses add-one smoothing. Persons with too few observed items - receive ``NaN``. - - This repository-specific approximation does not re-estimate EAP scores for - each replicate, so it is not the complete generalized resampling procedure - and does not by itself guarantee nominal Type-I error control (Sinharay, - 2016). - - References - ---------- - Sinharay, S. (2016). Assessment of person fit using resampling-based - approaches. *Journal of Educational Measurement, 53*(1), 63–85. - https://doi.org/10.1111/jedm.12101 - """ - core = _core_module() - if core is None: - raise RuntimeError("person_fit_resampling requires the compiled Rust core") - y, observed, d_of_i = _prepare_dichotomous_diagnostic_inputs( - responses, factor_id, mask - ) - n_persons = y.shape[0] - if n_persons == 0: - raise ValueError("responses must contain at least one person") - if ( - not isinstance(n_replicates, (int, np.integer)) - or isinstance(n_replicates, (bool, np.bool_)) - or not 1 <= int(n_replicates) <= MAX_PERSON_FIT_REPLICATES - ): - raise ValueError( - f"n_replicates must be an integer between 1 and {MAX_PERSON_FIT_REPLICATES}" - ) - if ( - not isinstance(seed, (int, np.integer)) - or isinstance(seed, (bool, np.bool_)) - or not 0 <= int(seed) <= np.iinfo(np.uint64).max - ): - raise ValueError("seed must be an integer between 0 and 2**64 - 1") - if y.size * int(n_replicates) > MAX_PERSON_FIT_WORK_CELLS: - raise ValueError("person-fit resampling exceeds the aggregate work limit") - n_dims = int(d_of_i.max()) + 1 - bank = _bank_args(params, d_of_i, model, n_dims, eps_distance) - theta = np.asarray(params.theta, dtype=np.float64) - if theta.shape != (n_persons, n_dims): - raise ValueError("params.theta shape must be (n_persons, n_dims)") - if not np.all(np.isfinite(theta)): - raise ValueError("params.theta must be finite") - xi = np.asarray(params.xi, dtype=np.float64) - if xi.shape != (n_persons, bank["latent_dim"]): - raise ValueError("params.xi shape must be (n_persons, latent_dim)") - if not np.all(np.isfinite(xi)): - raise ValueError("params.xi must be finite") - pm = None - if prior_mean is not None: - try: - prior = np.broadcast_to( - np.asarray(prior_mean, dtype=np.float64), (n_persons, n_dims) - ) - except ValueError as exc: - raise ValueError( - "prior_mean must broadcast to (n_persons, n_dims)" - ) from exc - if not np.all(np.isfinite(prior)): - raise ValueError("prior_mean must be finite") - pm = prior.ravel().copy() - pv = core.person_fit_resampling( - y.ravel(), observed.ravel(), int(n_persons), - bank["alpha"], bank["b"], bank["zeta"], bank["tau"], bank["factor_id"], - bank["model"], bank["n_dims"], bank["latent_dim"], bank["eps_distance"], - theta.ravel(), xi.ravel(), prior_mean=pm, - n_replicates=int(n_replicates), seed=int(seed), - ) - return np.asarray(pv) - - -def tcc_drift( - params_old, - params_new, - factor_id: np.ndarray, - model: str, - threshold: float = 0.05, - q_theta: int = 21, - q_xi: int = 11, - eps_distance: float = 1e-8, -) -> dict: - """Screen same-scale calibrations with backward TCC-area elimination. - - The repository computes the prior-weighted absolute difference between the - two test characteristic curves and removes the active item with the largest - unsigned ICC-area contribution until the remaining area is at most - ``threshold`` or only two items remain. The result reports the removal - order, area trace, iteration limits, and exact termination reason. - - This is a repository-specific heuristic motivated by the TCC-difference - objective of Guo et al. (2015), not their complete stepwise TCC method. The - published method alternates item-entry and item-removal steps to find a - locally optimal linking set without a predetermined critical value. This - implementation never re-enters excluded items and uses a caller-supplied - fixed threshold; its output must not be interpreted as the paper's - source-backed flagging procedure. - - References - ---------- - Guo, R., Zheng, Y., & Chang, H. H. (2015). A stepwise test characteristic - curve method to detect item parameter drift. *Journal of Educational - Measurement, 52*(3), 280–300. https://doi.org/10.1111/jedm.12077 - """ - core = _core_module() - if core is None: - raise RuntimeError("tcc_drift requires the compiled Rust core") - for name, value in (("q_theta", q_theta), ("q_xi", q_xi)): - if isinstance(value, (bool, np.bool_)) or not isinstance( - value, (int, np.integer) - ): - raise ValueError(f"{name} must be an integer") - if int(value) < 1: - raise ValueError(f"{name} must be at least 1") - if isinstance(threshold, (bool, np.bool_)) or not isinstance( - threshold, (int, float, np.integer, np.floating) - ): - raise ValueError("threshold must be a finite non-negative number") - threshold_value = float(threshold) - if not np.isfinite(threshold_value) or threshold_value < 0.0: - raise ValueError("threshold must be a finite non-negative number") - d_of_i, _fid_ndims = _validate_factor_id(factor_id) - n_dims = int(d_of_i.max()) + 1 - old = _bank_args(params_old, d_of_i, model, n_dims, eps_distance) - new = _bank_args(params_new, d_of_i, model, n_dims, eps_distance) - res = dict( - core.tcc_drift( - old["alpha"], old["b"], old["zeta"], old["tau"], - new["alpha"], new["b"], new["zeta"], new["tau"], - old["factor_id"], old["model"], old["n_dims"], old["latent_dim"], - old["eps_distance"], np.zeros(n_dims), np.ones(n_dims), - q_theta=int(q_theta), xi_rule="gh", q_xi=int(q_xi), - threshold=threshold_value, - ) - ) - return res - - -def empirical_reliability(result, device: str = "auto") -> np.ndarray: - """Empirical (marginal) EAP reliability per trait dimension: - `Var(EAP) / (Var(EAP) + mean(SE^2))`. - - This follows the posterior variance decomposition in Bechger et al. - (2003). Reliability does not establish model fit (Stanley & Edwards, - 2016), so report it alongside the fit statistics. Requires a marginal - (MMLE) fit with posterior SDs. ``device="auto"`` prefers the Rust wgpu - f32 reduction and falls back to a fixed-shard parallel Rust f64 reduction; - use ``device="cpu"`` for the hardware-independent reference. - - References - ---------- - Bechger, T. M., Maris, G., Verstralen, H. H. F. M., & Béguin, A. A. - (2003). Using classical test theory in combination with item response - theory. *Applied Psychological Measurement, 27*(5), 319–334. - https://doi.org/10.1177/0146621603257518 - - Stanley, L. M., & Edwards, M. C. (2016). Reliability and model fit. - *Educational and Psychological Measurement, 76*(6), 976–985. - https://doi.org/10.1177/0013164416638900 - """ - core = _core_module() - if core is None: - raise RuntimeError("empirical_reliability requires the compiled Rust core") - if result.population is None or "theta_sd" not in result.population: - raise ValueError("empirical_reliability needs a marginal fit with theta_sd") - theta = np.asarray(result.params.theta, dtype=np.float64) - sd = np.asarray(result.population["theta_sd"], dtype=np.float64) - device_name = normalize_device(device) - return np.asarray( - core.empirical_reliability( - theta.ravel(), - sd.ravel(), - int(theta.shape[0]), - int(theta.shape[1]), - device=device_name, - ) - ) - - -# -------------------------------------------------------------------------- -# M2 limited-information goodness-of-fit (Maydeu-Olivares & Joe 2005, 2006; -# Cai & Hansen 2013). Rust core is the compute path; the NumPy body below is -# the parity reference and fallback. -# -------------------------------------------------------------------------- - - -@dataclass -class M2Result: - """M2 limited-information goodness-of-fit result. - - Includes RMSEA2 with a 90% CI, bivariate SRMSR, and CFI/TLIRT computed - against a fitted complete-independence (zero-factor) M2 baseline. - """ - - m2: float - df: float - p_value: float - rmsea2: float - rmsea2_ci_lower: float - rmsea2_ci_upper: float - srmsr: float - null_m2: float - null_df: float - cfi: float - tli: float - n_moments: int - n_parameters: int - n_complete: int - estimator: str = "mmle" - inference_valid: bool = True - inference_note: str = "" - n_groups: int = 1 - n_clusters: int | None = None - - @property - def rmsea(self) -> float: - """Conventional label for this M2-based RMSEA2 estimate.""" - return self.rmsea2 - - @property - def srmr(self) -> float: - """Conventional alias for the returned bivariate SRMSR.""" - return self.srmsr - - -class _MutBank: - """Minimal params-like carrier for finite-difference re-evaluation.""" - - __slots__ = ("alpha", "b", "zeta", "tau") - - def __init__(self, alpha, b, zeta, tau): - """Hold mutable item-parameter copies for finite-difference re-evaluation.""" - self.alpha = alpha - self.b = b - self.zeta = zeta - self.tau = tau - - -def m2( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - mask: np.ndarray | None = None, - q_theta: int = 21, - q_xi: int = 11, - eps_distance: float = 1e-8, - *, - estimator: str = "mmle", - prior_mean: np.ndarray | None = None, - prior_sd: np.ndarray | None = None, - estimate_population: bool = False, - fixed_items: np.ndarray | None = None, - tau_fixed: bool = False, -) -> M2Result: - """M2 statistic and approximate/incremental fit indices. - - Returns RMSEA2 with a 90% noncentral-chi-square CI, bivariate SRMSR, and - CFI/TLIRT from a complete-independence M2 baseline. Complete cases only — - M2 presumes a single sample size N (Maydeu-Olivares & Joe, 2006; Cai & - Chung, 2022). ``estimator="cmle"`` selects the conditional Rasch M2 when - ``model="MIRT"`` has fixed unit discriminations. ``estimator="jmle"`` - computes a clearly labelled post-hoc marginal discrepancy using the - supplied (or empirical Gaussian) evaluation distribution; its chi-square - p-value and RMSEA confidence interval are suppressed because ordinary JMLE - is not a fixed-dimensional consistent estimator. - - Set ``estimate_population=True`` when ``prior_mean`` and ``prior_sd`` were - estimated in the calibration (the single-free population used by FIPC). - Those ``2 * n_dims`` nuisance columns then enter both the M2 projection and - its degrees of freedom. ``fixed_items`` marks item rows whose calibration - parameters were anchored rather than estimated; ``tau_fixed`` similarly - excludes an anchored spatial-distance coefficient. These are estimator - bookkeeping choices of this package: the M2 reference requires the - derivative matrix and degrees of freedom to contain the parameters that - were actually estimated (Maydeu-Olivares & Joe, 2006). - - References - ---------- - Cai, L., Chung, S. W., & Lee, T. (2023). Incremental model fit assessment - in the case of categorical data: Tucker–Lewis index for item response - theory modeling. *Prevention Science, 24*(3), 455–466. - https://doi.org/10.1007/s11121-021-01253-4 - - Maydeu-Olivares, A., & Joe, H. (2006). Limited information goodness-of-fit - testing in multidimensional contingency tables. *Psychometrika, 71*(4), - 713–732. https://doi.org/10.1007/s11336-005-1295-9 - - Haberman, S. J. (2004). *Joint and conditional maximum likelihood - estimation for the Rasch model for binary responses* (Research Report No. - RR-04-20). Educational Testing Service. - https://doi.org/10.1002/j.2333-8504.2004.tb01947.x - """ - estimator = str(estimator).lower() - if estimator not in {"mmle", "jmle", "cmle"}: - raise ValueError("estimator must be one of: mmle, jmle, cmle") - if ( - estimate_population or fixed_items is not None or tau_fixed - ) and estimator != "mmle": - raise ValueError( - "structured calibration metadata requires estimator='mmle'" - ) - y0 = np.asarray(responses, dtype=float) - if y0.ndim != 2: - raise ValueError("responses must be a persons-by-items matrix") - observed0 = ~np.isnan(y0) if mask is None else np.asarray(mask, dtype=bool) - if observed0.shape != y0.shape: - raise ValueError("mask must match responses") - values = y0[observed0] - if np.any(~np.isfinite(values)) or np.any((values != 0.0) & (values != 1.0)): - raise ValueError("observed responses must be finite binary values") - d_of_i, _fid_ndims = _validate_factor_id(factor_id) - if d_of_i.shape[0] != y0.shape[1]: - raise ValueError("factor_id length must match the number of items") - n_dims = int(d_of_i.max()) + 1 - if prior_mean is None: - if estimator == "jmle" and hasattr(params, "theta"): - theta = np.asarray(params.theta, dtype=float) - prior_mean = np.mean(theta, axis=0) - else: - prior_mean = np.zeros(n_dims) - if prior_sd is None: - if estimator == "jmle" and hasattr(params, "theta"): - theta = np.asarray(params.theta, dtype=float) - prior_sd = np.std(theta, axis=0, ddof=1) - else: - prior_sd = np.ones(n_dims) - prior_mean = np.asarray(prior_mean, dtype=float) - prior_sd = np.asarray(prior_sd, dtype=float) - if prior_mean.shape != (n_dims,) or prior_sd.shape != (n_dims,): - raise ValueError(f"prior_mean/prior_sd must both have shape ({n_dims},)") - if np.any(~np.isfinite(prior_mean)) or np.any(~np.isfinite(prior_sd)): - raise ValueError("prior_mean/prior_sd must be finite") - if np.any(prior_sd <= 0.0): - raise ValueError("prior_sd must be positive") - - if estimator == "cmle": - if model.upper() != "MIRT" or not np.allclose( - np.asarray(params.alpha, dtype=float), 0.0, atol=1e-10, rtol=0.0 - ): - raise ValueError( - "CMLE M2 is defined here only for the non-spatial Rasch model: " - "model='MIRT' with every alpha fixed at 0 (discrimination 1)" - ) - return m2_cmle_rasch(y0, np.asarray(params.b, dtype=float), observed0) - - if estimate_population or fixed_items is not None or tau_fixed: - return _m2_single_population( - y0, - observed0, - d_of_i, - params, - model, - q_theta, - q_xi, - eps_distance, - prior_mean, - prior_sd, - estimate_population=estimate_population, - fixed_items=fixed_items, - tau_fixed=tau_fixed, - ) - - core = _core_module() - if core is not None: - bank = _bank_args(params, d_of_i, model, n_dims, eps_distance) - res = core.m2_stat( - np.where(observed0, y0, 0.0).ravel(), - observed0.ravel(), - int(y0.shape[0]), - bank["alpha"], bank["b"], bank["zeta"], bank["tau"], bank["factor_id"], - bank["model"], bank["n_dims"], bank["latent_dim"], bank["eps_distance"], - prior_mean, prior_sd, - q_theta=int(q_theta), xi_rule="gh", q_xi=int(q_xi), - ) - result = M2Result( - m2=float(res["m2"]), df=float(res["df"]), p_value=float(res["p_value"]), - rmsea2=float(res["rmsea2"]), - rmsea2_ci_lower=float(res["rmsea2_ci_lower"]), - rmsea2_ci_upper=float(res["rmsea2_ci_upper"]), - srmsr=float(res["srmsr"]), - null_m2=float(res["null_m2"]), null_df=float(res["null_df"]), - cfi=float(res["cfi"]), tli=float(res["tli"]), - n_moments=int(res["n_moments"]), n_parameters=int(res["n_parameters"]), - n_complete=int(res["n_complete"]), - ) - else: - result = _m2_numpy( - y0, observed0, d_of_i, params, model, q_theta, q_xi, - eps_distance, prior_mean, prior_sd, - ) - if estimator == "mmle": - return result - result.estimator = estimator - result.inference_valid = False - result.inference_note = ( - "post-hoc marginal M2 discrepancy only; JMLE does not establish " - "the chi-square reference distribution for this evaluation population" - ) - result.p_value = float("nan") - result.rmsea2_ci_lower = float("nan") - result.rmsea2_ci_upper = float("nan") - return result - - -def _log_elementary_symmetric(log_weights: np.ndarray) -> np.ndarray: - """Log elementary-symmetric polynomials of all orders.""" - out = np.full(log_weights.size + 1, -np.inf, dtype=float) - out[0] = 0.0 - used = 0 - for log_weight in log_weights: - used += 1 - for order in range(used, 0, -1): - out[order] = np.logaddexp(out[order], log_weight + out[order - 1]) - return out - - -def _rasch_conditional_set_probabilities( - item_easiness: np.ndarray, item_sets: list[list[int]] -) -> np.ndarray: - """P(all items in each set are 1 | raw score) under the Rasch model.""" - b = np.asarray(item_easiness, dtype=float) - b = b - b.mean() - n_items = b.size - denominator = _log_elementary_symmetric(b) - out = np.zeros((n_items + 1, len(item_sets)), dtype=float) - all_items = np.arange(n_items) - for col, item_set in enumerate(item_sets): - selected = np.asarray(item_set, dtype=np.int64) - keep = np.ones(n_items, dtype=bool) - keep[selected] = False - numerator = _log_elementary_symmetric(b[all_items[keep]]) - selected_log_weight = float(b[selected].sum()) if selected.size else 0.0 - order = selected.size - for score in range(order, n_items + 1): - remaining_score = score - order - if remaining_score < numerator.size and np.isfinite(denominator[score]): - out[score, col] = math.exp( - selected_log_weight + numerator[remaining_score] - denominator[score] - ) - return out - - -def m2_cmle_rasch( - responses: np.ndarray, - item_easiness: np.ndarray, - mask: np.ndarray | None = None, -) -> M2Result: - """M2 for binary Rasch item parameters estimated by CMLE. - - Conditioning on each person's raw score eliminates ability. The empirical - raw-score distribution supplies the remaining nuisance distribution, and - its ``I`` free probabilities are included in the M2 derivative matrix. - Item easiness is represented by ``I - 1`` contrasts because a common shift - cancels from the conditional likelihood. Haberman (2004) supports the - conditional-estimation and identifiability pieces; combining that nuisance - parameterization with the Maydeu-Olivares--Joe tangent-space M2 projection - is this repository's implementation, not a method attributed to Haberman. - - References - ---------- - Haberman, S. J. (2004). *Joint and conditional maximum likelihood - estimation for the Rasch model for binary responses* (Research Report No. - RR-04-20). Educational Testing Service. - https://doi.org/10.1002/j.2333-8504.2004.tb01947.x - - Maydeu-Olivares, A., & Joe, H. (2006). Limited information goodness-of-fit - testing in multidimensional contingency tables. *Psychometrika, 71*(4), - 713–732. https://doi.org/10.1007/s11336-005-1295-9 - """ - y0 = np.asarray(responses, dtype=float) - if y0.ndim != 2: - raise ValueError("responses must be a persons-by-items matrix") - observed = ~np.isnan(y0) if mask is None else np.asarray(mask, dtype=bool) - if observed.shape != y0.shape: - raise ValueError("mask must match responses") - complete = np.all(observed, axis=1) - y = y0[complete] - if y.shape[0] == 0 or np.any((y != 0.0) & (y != 1.0)): - raise ValueError("CMLE M2 needs complete binary response rows") - b = np.asarray(item_easiness, dtype=float) - n_items = y.shape[1] - if b.shape != (n_items,) or np.any(~np.isfinite(b)): - raise ValueError(f"item_easiness must be a finite vector of length {n_items}") - if n_items < 5: - raise ValueError("CMLE M2 needs at least 5 items for positive degrees of freedom") - - scores = y.sum(axis=1).astype(np.int64) - score_counts = np.bincount(scores, minlength=n_items + 1) - if np.any(score_counts == 0): - missing = np.flatnonzero(score_counts == 0).tolist() - raise ValueError( - "CMLE M2 needs every raw-score category represented; missing scores " - f"{missing}" - ) - n = y.shape[0] - score_prob = score_counts.astype(float) / n - pairs = [(i, j) for i in range(n_items) for j in range(i + 1, n_items)] - moment_items = [[i] for i in range(n_items)] + [[i, j] for i, j in pairs] - s = len(moment_items) - z_rows = np.empty((n, s), dtype=float) - z_rows[:, :n_items] = y - for index, (i, j) in enumerate(pairs): - z_rows[:, n_items + index] = y[:, i] * y[:, j] - p_obs = z_rows.mean(axis=0) - - conditional = _rasch_conditional_set_probabilities(b, moment_items) - model_moments = score_prob @ conditional - p_item = n_items - 1 - p_score = n_items - delta = np.zeros((s, p_item + p_score), dtype=float) - for col in range(p_item): - h = 1e-4 * (1.0 + abs(b[col]) + abs(b[-1])) - plus, minus = b.copy(), b.copy() - plus[col] += h - plus[-1] -= h - minus[col] -= h - minus[-1] += h - delta[:, col] = ( - score_prob @ _rasch_conditional_set_probabilities(plus, moment_items) - - score_prob @ _rasch_conditional_set_probabilities(minus, moment_items) - ) * (0.5 / h) - reference_score = n_items - for score in range(n_items): - delta[:, p_item + score] = conditional[score] - conditional[reference_score] - - cache: dict[tuple[int, ...], float] = {} - - def set_probability(item_set): - key = tuple(sorted(item_set)) - if key not in cache: - values = _rasch_conditional_set_probabilities(b, [list(key)])[:, 0] - cache[key] = float(score_prob @ values) - return cache[key] - - xi = np.empty((s, s), dtype=float) - for a_i in range(s): - for b_i in range(a_i, s): - union = list(dict.fromkeys(moment_items[a_i] + moment_items[b_i])) - cov = set_probability(union) - model_moments[a_i] * model_moments[b_i] - xi[a_i, b_i] = xi[b_i, a_i] = cov - p = delta.shape[1] - if s <= p or n < p + 2: - raise ValueError(f"CMLE M2 needs more moments/cases than parameters ({s}, {n}, {p})") - m2_value = _projected_m2_numpy(p_obs - model_moments, delta, xi, float(n)) - - null_mom, null_delta, null_xi = _m2_null_components(p_obs, moment_items) - null_m2 = _projected_m2_numpy( - p_obs - null_mom, null_delta, null_xi, float(n) - ) - df = float(s - p) - null_df = float(s - n_items) - p_value, rmsea, ci_lower, ci_upper, cfi, tli = _m2_indices( - m2_value, df, null_m2, null_df, n - ) - ss = 0.0 - count = 0 - for index, (i, j) in enumerate(pairs): - pi, pj, pij = p_obs[i], p_obs[j], p_obs[n_items + index] - mi, mj, mij = model_moments[i], model_moments[j], model_moments[n_items + index] - dobs = pi * (1.0 - pi) * pj * (1.0 - pj) - dmod = mi * (1.0 - mi) * mj * (1.0 - mj) - if dobs > 1e-12 and dmod > 1e-12: - ss += ( - (pij - pi * pj) / math.sqrt(dobs) - - (mij - mi * mj) / math.sqrt(dmod) - ) ** 2 - count += 1 - return M2Result( - m2=m2_value, df=df, p_value=p_value, rmsea2=rmsea, - rmsea2_ci_lower=ci_lower, rmsea2_ci_upper=ci_upper, - srmsr=math.sqrt(ss / count) if count else float("nan"), - null_m2=null_m2, null_df=null_df, cfi=cfi, tli=tli, - n_moments=s, n_parameters=p, n_complete=n, - estimator="cmle", - inference_note="conditional Rasch M2 with empirical raw-score nuisance distribution", - ) - - -def _ncchi2_cdf(x: float, df: float, lam: float) -> float: - """Noncentral chi-square CDF from a mode-centered Poisson mixture. - - Centering the recurrence at the Poisson mode avoids underflow of the - ``exp(-lam / 2)`` starting weight for large noncentralities (Benton & - Krishnamoorthy, 2003). - - References - ---------- - Benton, D., & Krishnamoorthy, K. (2003). Computing discrete mixtures of - continuous distributions: Noncentral chi-square, noncentral *t* and the - distribution of the square of the sample multiple correlation coefficient. - *Computational Statistics & Data Analysis, 43*(2), 249–267. - https://doi.org/10.1016/S0167-9473(02)00283-9 - """ - if lam <= 0.0: - return 1.0 - chi2_sf(x, df) - if not (math.isfinite(x) and math.isfinite(df) and math.isfinite(lam)): - return float("nan") - half = 0.5 * lam - mode = int(math.floor(half)) - weighted = 1.0 - chi2_sf(x, df + 2.0 * mode) - normalizer = 1.0 - - weight = 1.0 - j = mode - while j > 0: - weight *= j / half - j -= 1 - normalizer += weight - weighted += weight * (1.0 - chi2_sf(x, df + 2.0 * j)) - if weight <= 1e-15 * normalizer: - break - - weight = 1.0 - j = mode - for _ in range(100_000): - j += 1 - weight *= half / j - normalizer += weight - weighted += weight * (1.0 - chi2_sf(x, df + 2.0 * j)) - if weight <= 1e-15 * normalizer: - break - else: - return float("nan") - return min(1.0, max(0.0, weighted / normalizer)) - - -def _nc_lambda_for(x: float, df: float, target: float) -> float: - """Smallest noncentrality with ncchi2_cdf(x, df, lam) == target (0 if unattainable).""" - if (1.0 - chi2_sf(x, df)) <= target: - return 0.0 - hi = 1.0 - while _ncchi2_cdf(x, df, hi) > target and hi < 1e8: - hi *= 2.0 - if _ncchi2_cdf(x, df, hi) > target: - return float("nan") - lo = 0.0 - for _ in range(200): - mid = 0.5 * (lo + hi) - if _ncchi2_cdf(x, df, mid) > target: - lo = mid - else: - hi = mid - if hi - lo <= 1e-12 * (1.0 + mid): - break - return 0.5 * (lo + hi) - - -def _m2_numpy( - y0, observed0, d_of_i, params, model, q_theta, q_xi, eps_distance, - prior_mean=None, prior_sd=None, -): - """NumPy parity reference for :func:`m2` (Rust core is the compute path).""" - model_u = model.upper() - free_alpha = model_u not in {"MLSRM", "ULSRM"} - uses_space = model_u != "MIRT" - n_persons, n_items = y0.shape - latent_dim = int(np.asarray(params.zeta).shape[1]) - if n_items < 3: - raise ValueError("M2 needs at least 3 items") - - # moment layout: univariate then bivariate pairs (i < j) - pairs = [(i, j) for i in range(n_items) for j in range(i + 1, n_items)] - moment_items = [[i] for i in range(n_items)] + [[i, j] for (i, j) in pairs] - s = len(moment_items) - - # free item parameters (matching the estimator's count) - plist = [] - for i in range(n_items): - plist.append(("b", i, 0)) - if free_alpha: - plist.append(("a", i, 0)) - if uses_space: - for k in range(latent_dim): - plist.append(("z", i, k)) - tau_free = uses_space and model_u in {"MLS2PLM", "ULS2PLM", "MLSRM", "ULSRM"} - if tau_free: - plist.append(("t", 0, 0)) - p = len(plist) - if s <= p: - raise ValueError(f"M2 df non-positive: {s} <= {p}") - - complete = np.all(observed0, axis=1) - idx = np.where(complete)[0] - n_c = int(idx.size) - if n_c < p + 2: - raise ValueError(f"too few complete cases for M2: {n_c}") - yc = y0[idx] - p_obs = np.empty(s) - for i in range(n_items): - p_obs[i] = np.mean(yc[:, i] != 0.0) - for m, (i, j) in enumerate(pairs): - p_obs[n_items + m] = np.mean((yc[:, i] != 0.0) & (yc[:, j] != 0.0)) - - if prior_mean is None: - prior_mean = np.zeros(n_dims_of(d_of_i)) - if prior_sd is None: - prior_sd = np.ones(n_dims_of(d_of_i)) - - def node_probs(pp): - probs, t_w, x_w, _ = _icc_grid( - pp, d_of_i, model, q_theta, q_xi, eps_distance, - prior_mean, prior_sd, - ) - return probs, t_w, x_w - - probs0, trait_weights, space_weights = node_probs(params) - - def pi_set(probs, sset): - return float( - _factorized_trait_moments( - probs, - trait_weights, - space_weights, - d_of_i, - [sset], - )[0] - ) - - def model_moments(probs): - return _factorized_trait_moments( - probs, - trait_weights, - space_weights, - d_of_i, - moment_items, - ) - - mom0 = model_moments(probs0) - e = p_obs - mom0 - - # Delta by central differences of the node moments - alpha0 = np.asarray(params.alpha, dtype=float).copy() - b0 = np.asarray(params.b, dtype=float).copy() - zeta0 = np.asarray(params.zeta, dtype=float).copy() - tau0 = float(params.tau) - delta = np.zeros((s, p)) - for col, (kind, i, k) in enumerate(plist): - base = {"b": b0[i], "a": alpha0[i], "z": zeta0[i, k], "t": tau0}[kind] - h = 1e-4 * (1.0 + abs(base)) - a, b, z, t = alpha0.copy(), b0.copy(), zeta0.copy(), tau0 - if kind == "b": - b[i] = base + h - elif kind == "a": - a[i] = base + h - elif kind == "z": - z[i, k] = base + h - else: - t = base + h - mp, _, _ = node_probs(_MutBank(a, b, z, t)) - mom_plus = model_moments(mp) - a, b, z, t = alpha0.copy(), b0.copy(), zeta0.copy(), tau0 - if kind == "b": - b[i] = base - h - elif kind == "a": - a[i] = base - h - elif kind == "z": - z[i, k] = base - h - else: - t = base - h - mm, _, _ = node_probs(_MutBank(a, b, z, t)) - mom_minus = model_moments(mm) - delta[:, col] = (mom_plus - mom_minus) * (0.5 / h) - - # Xi_2 via the local-independence factorization of union margins - xi = np.zeros((s, s)) - for a_i in range(s): - for b_i in range(a_i, s): - u = list(dict.fromkeys(moment_items[a_i] + moment_items[b_i])) - cov = pi_set(probs0, u) - mom0[a_i] * mom0[b_i] - xi[a_i, b_i] = cov - xi[b_i, a_i] = cov - - n_f = float(n_c) - m2v = _projected_m2_numpy(e, delta, xi, n_f) - df = float(s - p) - p_value = chi2_sf(m2v, df) - denom = df * (n_f - 1.0) - rmsea2 = math.sqrt(max(0.0, m2v - df) / denom) - ci_lo = math.sqrt(_nc_lambda_for(m2v, df, 0.95) / denom) - ci_hi = math.sqrt(_nc_lambda_for(m2v, df, 0.05) / denom) - - ss, cnt = 0.0, 0 - for m, (i, j) in enumerate(pairs): - pi, pj, pij = p_obs[i], p_obs[j], p_obs[n_items + m] - mi, mj, mij = mom0[i], mom0[j], mom0[n_items + m] - dobs = pi * (1 - pi) * pj * (1 - pj) - dmod = mi * (1 - mi) * mj * (1 - mj) - if dobs > 1e-12 and dmod > 1e-12: - robs = (pij - pi * pj) / math.sqrt(dobs) - rmod = (mij - mi * mj) / math.sqrt(dmod) - ss += (robs - rmod) ** 2 - cnt += 1 - srmsr = math.sqrt(ss / cnt) if cnt else float("nan") - - null_mom = np.array( - [np.prod([p_obs[i] for i in sset]) for sset in moment_items], dtype=float - ) - null_e = p_obs - null_mom - null_delta = np.zeros((s, n_items), dtype=float) - for row, sset in enumerate(moment_items): - for col in sset: - null_delta[row, col] = np.prod( - [p_obs[i] for i in sset if i != col], dtype=float - ) - null_xi = np.zeros((s, s), dtype=float) - for a_i in range(s): - for b_i in range(a_i, s): - union = list(dict.fromkeys(moment_items[a_i] + moment_items[b_i])) - union_moment = np.prod([p_obs[i] for i in union], dtype=float) - cov = union_moment - null_mom[a_i] * null_mom[b_i] - null_xi[a_i, b_i] = cov - null_xi[b_i, a_i] = cov - null_m2 = _projected_m2_numpy(null_e, null_delta, null_xi, n_f) - null_df = float(s - n_items) - if null_m2 > m2v and null_m2 > null_df: - cfi = float(np.clip(1.0 - (m2v - df) / (null_m2 - null_df), 0.0, 1.0)) - tli = float( - (null_m2 / null_df - m2v / df) / (null_m2 / null_df - 1.0) - ) - else: - cfi = tli = float("nan") - - return M2Result( - m2=m2v, df=df, p_value=p_value, rmsea2=rmsea2, - rmsea2_ci_lower=ci_lo, rmsea2_ci_upper=ci_hi, srmsr=srmsr, - null_m2=null_m2, null_df=null_df, cfi=cfi, tli=tli, - n_moments=s, n_parameters=p, n_complete=n_c, - ) - - -def _projected_m2_numpy( - residual: np.ndarray, - delta: np.ndarray, - xi: np.ndarray, - n: float, -) -> float: - """Evaluate the projected M2 quadratic form without explicit inverses.""" - xi_residual = np.linalg.solve(xi, residual) - xi_delta = np.linalg.solve(xi, delta) - information = delta.T @ xi_delta - score = xi_delta.T @ residual - adjustment = np.linalg.solve(information, score) - return max( - 0.0, - n * (float(residual @ xi_residual) - float(score @ adjustment)), - ) - - -def _m2_group_components( - y0, - observed0, - d_of_i, - params, - model, - q_theta, - q_xi, - eps_distance, - prior_mean, - prior_sd, - shared_sigma_u=None, - q_u=11, - fixed_items=None, - tau_fixed=False, -): - """Build one population's M2 moments, derivatives, and covariance.""" - model_u = model.upper() - free_alpha = model_u not in {"MLSRM", "ULSRM"} - uses_space = model_u != "MIRT" - n_items = y0.shape[1] - latent_dim = int(np.asarray(params.zeta).shape[1]) - pairs = [(i, j) for i in range(n_items) for j in range(i + 1, n_items)] - moment_items = [[i] for i in range(n_items)] + [[i, j] for i, j in pairs] - s = len(moment_items) - - if fixed_items is None: - fixed = np.zeros(n_items, dtype=bool) - else: - fixed_raw = np.asarray(fixed_items) - if fixed_raw.shape != (n_items,): - raise ValueError(f"fixed_items must have shape ({n_items},)") - if not np.all((fixed_raw == 0) | (fixed_raw == 1)): - raise ValueError("fixed_items must contain only boolean values") - fixed = fixed_raw.astype(bool) - - plist = [] - for i in range(n_items): - if fixed[i]: - continue - plist.append(("b", i, 0)) - if free_alpha: - plist.append(("a", i, 0)) - if uses_space: - plist.extend(("z", i, k) for k in range(latent_dim)) - tau_free = uses_space and model_u in {"MLS2PLM", "ULS2PLM", "MLSRM", "ULSRM"} - if tau_free and not tau_fixed: - plist.append(("t", 0, 0)) - - complete = np.all(observed0, axis=1) - idx = np.flatnonzero(complete) - if idx.size < 2: - raise ValueError("each population needs at least two complete cases for M2") - yc = (np.asarray(y0[idx]) != 0.0).astype(float) - z_rows = np.empty((idx.size, s), dtype=float) - z_rows[:, :n_items] = yc - for m, (i, j) in enumerate(pairs): - z_rows[:, n_items + m] = yc[:, i] * yc[:, j] - p_obs = z_rows.mean(axis=0) - - prior_mean = np.asarray(prior_mean, dtype=float) - prior_sd = np.asarray(prior_sd, dtype=float) - - if shared_sigma_u is None: - - def node_probs(pp, mean=prior_mean, sd=prior_sd, sigma_u=None): - probs, t_w, x_w, _ = _icc_grid( - pp, d_of_i, model, q_theta, q_xi, eps_distance, mean, sd - ) - return probs, None, t_w, x_w - - else: - - def node_probs(pp, mean=None, sd=None, sigma_u=shared_sigma_u): - return _icc_multilevel_grid( - pp, - d_of_i, - model, - float(sigma_u), - int(q_u), - q_theta, - q_xi, - eps_distance, - ) - - probs0, cluster_weights, trait_weights, space_weights = node_probs(params) - - def moments(probs, item_sets=moment_items): - if cluster_weights is None: - return _factorized_trait_moments( - probs, trait_weights, space_weights, d_of_i, item_sets - ) - return _factorized_multilevel_moments( - probs, - cluster_weights, - trait_weights, - space_weights, - d_of_i, - item_sets, - ) - - mom0 = moments(probs0) - alpha0 = np.asarray(params.alpha, dtype=float).copy() - b0 = np.asarray(params.b, dtype=float).copy() - zeta0 = np.asarray(params.zeta, dtype=float).copy() - tau0 = float(params.tau) - delta_item = np.zeros((s, len(plist)), dtype=float) - for col, (kind, i, k) in enumerate(plist): - base = {"b": b0[i], "a": alpha0[i], "z": zeta0[i, k], "t": tau0}[kind] - h = 1e-4 * (1.0 + abs(base)) - a, b, z, t = alpha0.copy(), b0.copy(), zeta0.copy(), tau0 - if kind == "b": - b[i] = base + h - elif kind == "a": - a[i] = base + h - elif kind == "z": - z[i, k] = base + h - else: - t = base + h - plus = moments(node_probs(_MutBank(a, b, z, t))[0]) - a, b, z, t = alpha0.copy(), b0.copy(), zeta0.copy(), tau0 - if kind == "b": - b[i] = base - h - elif kind == "a": - a[i] = base - h - elif kind == "z": - z[i, k] = base - h - else: - t = base - h - minus = moments(node_probs(_MutBank(a, b, z, t))[0]) - delta_item[:, col] = (plus - minus) * (0.5 / h) - - n_dims = prior_mean.size - delta_population = np.zeros((s, 2 * n_dims), dtype=float) - if shared_sigma_u is None: - for d in range(n_dims): - h = 1e-4 * (1.0 + abs(prior_mean[d])) - plus_mean, minus_mean = prior_mean.copy(), prior_mean.copy() - plus_mean[d] += h - minus_mean[d] -= h - delta_population[:, d] = ( - moments(node_probs(params, plus_mean, prior_sd)[0]) - - moments(node_probs(params, minus_mean, prior_sd)[0]) - ) * (0.5 / h) - - h = min(1e-4 * (1.0 + prior_sd[d]), 0.25 * prior_sd[d]) - plus_sd, minus_sd = prior_sd.copy(), prior_sd.copy() - plus_sd[d] += h - minus_sd[d] -= h - delta_population[:, n_dims + d] = ( - moments(node_probs(params, prior_mean, plus_sd)[0]) - - moments(node_probs(params, prior_mean, minus_sd)[0]) - ) * (0.5 / h) - delta_shared = None - else: - h = 1e-4 * (1.0 + abs(float(shared_sigma_u))) - lower = max(0.0, float(shared_sigma_u) - h) - upper = float(shared_sigma_u) + h - delta_shared = ( - moments(node_probs(params, sigma_u=upper)[0]) - - moments(node_probs(params, sigma_u=lower)[0]) - ) / (upper - lower) - - def pi_set(item_set): - return float(moments(probs0, [item_set])[0]) - - xi = np.empty((s, s), dtype=float) - for a_i in range(s): - for b_i in range(a_i, s): - union = list(dict.fromkeys(moment_items[a_i] + moment_items[b_i])) - cov = pi_set(union) - mom0[a_i] * mom0[b_i] - xi[a_i, b_i] = xi[b_i, a_i] = cov - - ss = 0.0 - count = 0 - for m, (i, j) in enumerate(pairs): - pi, pj, pij = p_obs[i], p_obs[j], p_obs[n_items + m] - mi, mj, mij = mom0[i], mom0[j], mom0[n_items + m] - dobs = pi * (1.0 - pi) * pj * (1.0 - pj) - dmod = mi * (1.0 - mi) * mj * (1.0 - mj) - if dobs > 1e-12 and dmod > 1e-12: - robs = (pij - pi * pj) / math.sqrt(dobs) - rmod = (mij - mi * mj) / math.sqrt(dmod) - ss += (robs - rmod) ** 2 - count += 1 - - return { - "idx": idx, - "n": int(idx.size), - "p_obs": p_obs, - "mom": mom0, - "residual": p_obs - mom0, - "delta_item": delta_item, - "delta_population": delta_population, - "delta_shared": delta_shared, - "xi": xi, - "z_rows": z_rows, - "moment_items": moment_items, - "srmsr": math.sqrt(ss / count) if count else float("nan"), - "n_items": n_items, - } - - -def _m2_null_components(p_obs, moment_items): - """Complete-independence moments, derivatives, and model covariance.""" - n_items = len([items for items in moment_items if len(items) == 1]) - s = len(moment_items) - moments = np.array( - [np.prod([p_obs[i] for i in item_set], dtype=float) for item_set in moment_items] - ) - delta = np.zeros((s, n_items), dtype=float) - for row, item_set in enumerate(moment_items): - for col in item_set: - delta[row, col] = np.prod( - [p_obs[i] for i in item_set if i != col], dtype=float - ) - xi = np.empty((s, s), dtype=float) - for a_i in range(s): - for b_i in range(a_i, s): - union = list(dict.fromkeys(moment_items[a_i] + moment_items[b_i])) - union_moment = np.prod([p_obs[i] for i in union], dtype=float) - cov = union_moment - moments[a_i] * moments[b_i] - xi[a_i, b_i] = xi[b_i, a_i] = cov - return moments, delta, xi - - -def _block_diag(matrices): - """Dense block diagonal for the modest one-shot M2 covariance matrices.""" - size = sum(matrix.shape[0] for matrix in matrices) - out = np.zeros((size, size), dtype=float) - offset = 0 - for matrix in matrices: - width = matrix.shape[0] - out[offset : offset + width, offset : offset + width] = matrix - offset += width - return out - - -def _m2_indices(m2_value, df, null_m2, null_df, n): - """Common p-value, RMSEA2, interval, CFI, and TLIRT calculations.""" - p_value = chi2_sf(m2_value, df) - denom = df * (float(n) - 1.0) - rmsea = math.sqrt(max(0.0, m2_value - df) / denom) - ci_lower = math.sqrt(_nc_lambda_for(m2_value, df, 0.95) / denom) - ci_upper = math.sqrt(_nc_lambda_for(m2_value, df, 0.05) / denom) - if null_m2 > m2_value and null_m2 > null_df: - cfi = float(np.clip(1.0 - (m2_value - df) / (null_m2 - null_df), 0.0, 1.0)) - tli = float( - (null_m2 / null_df - m2_value / df) / (null_m2 / null_df - 1.0) - ) - else: - cfi = tli = float("nan") - return p_value, rmsea, ci_lower, ci_upper, cfi, tli - - -def _m2_single_population( - y0, - observed0, - d_of_i, - params, - model, - q_theta, - q_xi, - eps_distance, - prior_mean, - prior_sd, - *, - estimate_population, - fixed_items, - tau_fixed, -): - """Single-population M2 with the calibration's actual free columns.""" - component = _m2_group_components( - y0, - observed0, - d_of_i, - params, - model, - q_theta, - q_xi, - eps_distance, - prior_mean, - prior_sd, - fixed_items=fixed_items, - tau_fixed=tau_fixed, - ) - columns = [component["delta_item"]] - if estimate_population: - columns.append(component["delta_population"]) - delta = np.column_stack(columns) - s, p = delta.shape - if s <= p: - raise ValueError(f"M2 df non-positive: {s} <= {p}") - if component["n"] < p + 2: - raise ValueError(f"too few complete cases for M2: {component['n']}") - - m2_value = _projected_m2_numpy( - component["residual"], delta, component["xi"], float(component["n"]) - ) - null_mom, null_delta, null_xi = _m2_null_components( - component["p_obs"], component["moment_items"] - ) - null_m2 = _projected_m2_numpy( - component["p_obs"] - null_mom, - null_delta, - null_xi, - float(component["n"]), - ) - df = float(s - p) - null_df = float(s - component["n_items"]) - p_value, rmsea, ci_lower, ci_upper, cfi, tli = _m2_indices( - m2_value, df, null_m2, null_df, component["n"] - ) - return M2Result( - m2=m2_value, - df=df, - p_value=p_value, - rmsea2=rmsea, - rmsea2_ci_lower=ci_lower, - rmsea2_ci_upper=ci_upper, - srmsr=component["srmsr"], - null_m2=null_m2, - null_df=null_df, - cfi=cfi, - tli=tli, - n_moments=s, - n_parameters=p, - n_complete=component["n"], - inference_note=( - "single-population MMLE M2 with estimated mean/SD nuisance columns" - if estimate_population - else "single-population MMLE M2 with fixed calibration columns excluded" - ), - ) - - -def m2_multigroup( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - group_id: np.ndarray, - population_mean: np.ndarray, - population_sd: np.ndarray, - mask: np.ndarray | None = None, - q_theta: int = 21, - q_xi: int = 11, - eps_distance: float = 1e-8, -) -> M2Result: - """Multiple-group M2 with common item columns and group population columns. - - Group residuals and covariances are stacked using their own complete-case - sample sizes. Common item parameters occupy one shared derivative block; - non-reference group means and SDs occupy group-specific blocks, matching - the multiple-group construction used by ``mirt::M2``. - - References - ---------- - Chalmers, R. P. (2012). mirt: A multidimensional item response theory - package for the R environment. *Journal of Statistical Software, 48*(6), - 1–29. https://doi.org/10.18637/jss.v048.i06 - - Maydeu-Olivares, A., & Joe, H. (2006). Limited information goodness-of-fit - testing in multidimensional contingency tables. *Psychometrika, 71*(4), - 713–732. https://doi.org/10.1007/s11336-005-1295-9 - """ - y0 = np.asarray(responses, dtype=float) - if y0.ndim != 2: - raise ValueError("responses must be a persons-by-items matrix") - observed0 = ~np.isnan(y0) if mask is None else np.asarray(mask, dtype=bool) - if observed0.shape != y0.shape: - raise ValueError("mask must match responses") - values = y0[observed0] - if np.any(~np.isfinite(values)) or np.any((values != 0.0) & (values != 1.0)): - raise ValueError("observed responses must be finite binary values") - from .fit import _compact_population_labels - - compact, n_groups = _compact_population_labels(group_id, y0.shape[0], "group_id") - d_of_i, _ = _validate_factor_id(factor_id) - if d_of_i.shape[0] != y0.shape[1]: - raise ValueError("factor_id length must match the number of items") - n_dims = n_dims_of(d_of_i) - means = np.asarray(population_mean, dtype=float) - sds = np.asarray(population_sd, dtype=float) - expected = (n_groups, n_dims) - if means.shape != expected or sds.shape != expected: - raise ValueError(f"population_mean/population_sd must have shape {expected}") - if np.any(~np.isfinite(means)) or np.any(~np.isfinite(sds)) or np.any(sds <= 0.0): - raise ValueError("population means must be finite and SDs finite and positive") - - components = [] - for group in range(n_groups): - take = compact == group - components.append( - _m2_group_components( - y0[take], observed0[take], d_of_i, params, model, - q_theta, q_xi, eps_distance, means[group], sds[group], - ) - ) - s = components[0]["residual"].size - p_item = components[0]["delta_item"].shape[1] - p = p_item + 2 * n_dims * (n_groups - 1) - if n_groups * s <= p: - raise ValueError(f"multigroup M2 df non-positive: {n_groups * s} <= {p}") - - residual = np.zeros(n_groups * s, dtype=float) - delta = np.zeros((n_groups * s, p), dtype=float) - xi_blocks = [] - null_delta = np.zeros((n_groups * s, n_groups * y0.shape[1]), dtype=float) - null_xi_blocks = [] - null_residual = np.zeros(n_groups * s, dtype=float) - for group, component in enumerate(components): - rows = slice(group * s, (group + 1) * s) - root_n = math.sqrt(component["n"]) - residual[rows] = root_n * component["residual"] - delta[rows, :p_item] = root_n * component["delta_item"] - if group > 0: - start = p_item + (group - 1) * 2 * n_dims - delta[rows, start : start + 2 * n_dims] = ( - root_n * component["delta_population"] - ) - xi_blocks.append(component["xi"]) - - null_mom, null_d, null_xi = _m2_null_components( - component["p_obs"], component["moment_items"] - ) - null_residual[rows] = root_n * (component["p_obs"] - null_mom) - cols = slice(group * y0.shape[1], (group + 1) * y0.shape[1]) - null_delta[rows, cols] = root_n * null_d - null_xi_blocks.append(null_xi) - - m2_value = _projected_m2_numpy(residual, delta, _block_diag(xi_blocks), 1.0) - null_m2 = _projected_m2_numpy( - null_residual, null_delta, _block_diag(null_xi_blocks), 1.0 - ) - df = float(n_groups * s - p) - null_df = float(n_groups * s - n_groups * y0.shape[1]) - n_complete = sum(component["n"] for component in components) - p_value, rmsea, ci_lower, ci_upper, cfi, tli = _m2_indices( - m2_value, df, null_m2, null_df, n_complete - ) - srmsr = math.sqrt( - sum(component["n"] * component["srmsr"] ** 2 for component in components) - / n_complete - ) - return M2Result( - m2=m2_value, df=df, p_value=p_value, rmsea2=rmsea, - rmsea2_ci_lower=ci_lower, rmsea2_ci_upper=ci_upper, srmsr=srmsr, - null_m2=null_m2, null_df=null_df, cfi=cfi, tli=tli, - n_moments=n_groups * s, n_parameters=p, n_complete=n_complete, - n_groups=n_groups, - ) - - -def _cluster_moment_covariance(z_rows, model_moments, cluster_id): - """Between-cluster covariance estimate of sqrt(N) marginal proportions.""" - labels = np.asarray(cluster_id) - _, compact = np.unique(labels, return_inverse=True) - n_clusters = int(compact.max()) + 1 - s = z_rows.shape[1] - if n_clusters <= s: - raise ValueError( - f"cluster-robust M2 needs more clusters than moments ({n_clusters} <= {s})" - ) - totals = np.zeros((n_clusters, s), dtype=float) - residual_rows = z_rows - np.asarray(model_moments, dtype=float) - np.add.at(totals, compact, residual_rows) - centered = totals - totals.mean(axis=0) - return ( - (n_clusters / (n_clusters - 1.0)) * (centered.T @ centered) / z_rows.shape[0], - n_clusters, - ) - - -def m2_multilevel( - responses: np.ndarray, - factor_id: np.ndarray, - params, - model: str, - cluster_id: np.ndarray, - sigma_u: float, - mask: np.ndarray | None = None, - q_theta: int = 21, - q_u: int = 11, - q_xi: int = 11, - eps_distance: float = 1e-8, -) -> M2Result: - """Cluster-robust M2 for the fitted random-intercept marginal model. - - The fitted scalar random intercept is integrated as one shared quadrature - variable across every trait dimension, preserving the induced - cross-dimension covariance. Residual traits remain independent conditional - on that intercept. The covariance of the observed first- and second-order - proportions is estimated from between-cluster totals. This follows the - complex-sample limited-information covariance construction of Jamil et al. - (2025), rather than treating persons in the same cluster as iid. - Jamil et al. study an aggregated PML setting, not this repository's - disaggregated random-intercept MMLE; the shared-intercept integration and - its combination with their cluster-total covariance are therefore stated - as a repository implementation choice. - - References - ---------- - Jamil, H., Moustaki, I., & Skinner, C. (2025). Pairwise likelihood - estimation and limited-information goodness-of-fit test statistics for - binary factor analysis models under complex survey sampling. *British - Journal of Mathematical and Statistical Psychology, 78*(1), 258–285. - https://doi.org/10.1111/bmsp.12358 - """ - y0 = np.asarray(responses, dtype=float) - if y0.ndim != 2: - raise ValueError("responses must be a persons-by-items matrix") - observed0 = ~np.isnan(y0) if mask is None else np.asarray(mask, dtype=bool) - if observed0.shape != y0.shape: - raise ValueError("mask must match responses") - values = y0[observed0] - if np.any(~np.isfinite(values)) or np.any((values != 0.0) & (values != 1.0)): - raise ValueError("observed responses must be finite binary values") - from .fit import _compact_population_labels - - clusters, _ = _compact_population_labels(cluster_id, y0.shape[0], "cluster_id") - sigma_u = float(sigma_u) - if not np.isfinite(sigma_u) or sigma_u < 0.0: - raise ValueError("sigma_u must be finite and non-negative") - d_of_i, _ = _validate_factor_id(factor_id) - if d_of_i.shape[0] != y0.shape[1]: - raise ValueError("factor_id length must match the number of items") - n_dims = n_dims_of(d_of_i) - component = _m2_group_components( - y0, observed0, d_of_i, params, model, q_theta, q_xi, - eps_distance, np.zeros(n_dims), np.ones(n_dims), - shared_sigma_u=sigma_u, q_u=q_u, - ) - complete_clusters = clusters[component["idx"]] - target_xi, n_clusters = _cluster_moment_covariance( - component["z_rows"], component["mom"], complete_clusters - ) - delta = np.column_stack((component["delta_item"], component["delta_shared"])) - p = delta.shape[1] - s = component["residual"].size - if s <= p: - raise ValueError(f"multilevel M2 df non-positive: {s} <= {p}") - m2_value = _projected_m2_numpy( - component["residual"], delta, target_xi, float(component["n"]) - ) - - null_mom, null_delta, _ = _m2_null_components( - component["p_obs"], component["moment_items"] - ) - null_xi, _ = _cluster_moment_covariance( - component["z_rows"], null_mom, complete_clusters - ) - null_m2 = _projected_m2_numpy( - component["p_obs"] - null_mom, - null_delta, - null_xi, - float(component["n"]), - ) - df = float(s - p) - null_df = float(s - component["n_items"]) - p_value, rmsea, ci_lower, ci_upper, cfi, tli = _m2_indices( - m2_value, df, null_m2, null_df, component["n"] - ) - return M2Result( - m2=m2_value, df=df, p_value=p_value, rmsea2=rmsea, - rmsea2_ci_lower=ci_lower, rmsea2_ci_upper=ci_upper, - srmsr=component["srmsr"], null_m2=null_m2, null_df=null_df, - cfi=cfi, tli=tli, n_moments=s, n_parameters=p, - n_complete=component["n"], n_clusters=n_clusters, - inference_note=( - "cluster-robust limited-information M2; interpret incremental indices " - "against the cluster-robust independence baseline" - ), - ) - - -def n_dims_of(d_of_i): - """Number of trait dimensions implied by a factor-id vector.""" - _d, n_dims = _validate_factor_id(d_of_i) - return n_dims diff --git a/python/fast_mlsirm/gpcm.py b/python/fast_mlsirm/gpcm.py deleted file mode 100644 index 5e9768fdf..000000000 --- a/python/fast_mlsirm/gpcm.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Dimension-agnostic generalized partial credit model (Muraki, 1992). - -Ordered categories share one discrimination vector and use INTEGER category -scores with free (unordered) adjacent-category step intercepts. The public -``model=`` argument selects the one-factor model or a confirmatory -multidimensional loading specification; the numerical estimation runs in Rust.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER, MAX_POLYTOMOUS_CATEGORIES -from .models import ConfirmatoryModel, ExploratoryModel, IrtModel, _resolve_model - -_SUPPORTED_Q = (7, 11, 15, 21, 31, 41) -_MAX_DIMS_GH = 3 -_MAX_DIMS_QMC = 6 -_MAX_NODES = 200_000 - - -@dataclass -class GpcmFit: - """Fitted multidimensional generalized partial credit model (Muraki, 1992). - - ``slope`` is the ``n_items x n_dims`` discrimination matrix ``a_id`` (exactly ``0`` for - dimensions not in the item's loading pattern), per-dimension reflection-canonicalized so each - dimension's largest pure anchor is positive; ``step`` the ``n_items x (n_cat-1)`` category step - intercepts ``step_ik`` (UNORDERED — the GPCM softmax is valid for any values); ``theta`` the - ``n_persons x n_dims`` trait EAP. The model is - ``P(Y_ij = k | theta_j) = softmax_k(k * sum_d a_id theta_jd + step_ik)`` with - ``theta_j ~ MVN(0, I)``. ``termination_reason`` is ``"tolerance_met"`` or ``"max_iter_reached"``; - ``final_loglik_change`` the SIGNED change ``ll_final - ll_prev`` (non-negative up to a tiny - monotone-guard band).""" - - model: IrtModel - slope: np.ndarray - step: np.ndarray - theta: np.ndarray - n_cat: int - loglik_trace: np.ndarray - n_iter: int - converged: bool - termination_reason: str - final_loglik_change: float - n_parameters: int - - @property - def n_dims(self) -> int: - """Latent dimension count derived from :attr:`model`.""" - - return self.model.n_dims - - -def fit_gpcm( - responses: np.ndarray, - n_cat: int, - model: int | ExploratoryModel | ConfirmatoryModel = 1, - q: int = 21, - max_iter: int = 500, - tol: float = 1e-6, - node_rule: str = "gh", - xi_points: int = 4000, - xi_seed: int = 0x9E37_79B9_7F4A_7C15, -) -> GpcmFit: - """Fit the generalized partial credit model (compute in Rust; Muraki, 1992). - - Ordered polytomous categories with a SINGLE multidimensional discrimination vector per item and - INTEGER category scores: for category ``k`` of item ``i``, ``psi_ik = k * sum_{d in S_i} a_id - theta_d + step_ik`` and ``P(Y = k | theta) = softmax_k(psi_ik)``, where ``S_i`` is the item's - loading set from the confirmatory model specification and the ``n_cat-1`` steps ``step_i`` are free - and UNORDERED (the softmax is valid for any values). ``theta ~ MVN(0, I)``. This is the - ``a_ikd = k * a_id`` integer-scoring restriction of the nominal model, in a single-slope - parametrization; it reduces to the unidimensional GPCM at ``n_dims = 1``. - - Identification: unit trait variances + a PURE single-dimension anchor item per dimension fix - rotation; the per-dimension reflection is CANONICALIZED (each dimension flipped so its largest pure - anchor loads positive, leaving steps unchanged). Slopes are UNCONSTRAINED so reverse-keyed / - negative cross-loadings are representable. - - **Integration nodes (``node_rule``).** ``"gh"`` (default) uses the ``q**n_dims`` Gauss-Hermite - product grid and caps ``n_dims <= 3``. For ``n_dims = 4, 5, 6`` use ``"qmc"`` (Halton, Jank 2005) - or ``"mc"`` with ``xi_points`` prior draws. ``q`` applies only to ``"gh"``; ``xi_points``/ - ``xi_seed`` only to ``"qmc"``/``"mc"``. - - ``responses`` is a persons x items integer-category array (``0..n_cat-1``; ``NaN`` or negative = - missing, dropped MAR); For ``model=1``, all item slopes on the single factor are free. A - multidimensional confirmatory structure is supplied with - ``model=models.confirmatory(loading_pattern)``; a numeric exploratory model greater than - one is rejected until unrestricted loading rotation and identification are implemented. - ``n_cat`` is limited to 2..64, ``max_iter`` to 1..100,000, and Monte Carlo/QMC - ``xi_points`` to 1..200,000. Every declared category must be observed for each item, and every - dimension needs a pure anchor item. - - References (APA 7th ed.): - Muraki, E. (1992). A generalized partial credit model: Application of an EM algorithm. - *Applied Psychological Measurement, 16*(2), 159–176. - https://doi.org/10.1177/014662169201600206 - Reckase, M. D. (2009). *Multidimensional item response theory*. Springer. - https://doi.org/10.1007/978-0-387-89976-3 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_gpcm"): - raise RuntimeError("fit_gpcm requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - resolved_model, pat = _resolve_model(model, n_items) - n_dims = pat.shape[1] - _gh = str(node_rule).lower() in ("gh", "gauss-hermite", "gausshermite") - _max_dims = _MAX_DIMS_GH if _gh else _MAX_DIMS_QMC - if not 1 <= n_dims <= _max_dims: - raise ValueError( - f"loading_pattern dimensions must be between 1 and {_max_dims} (node_rule={node_rule!r})" - ) - - def _finite_int(value, name: str) -> int: - scalar = np.asarray(value) - if ( - scalar.ndim != 0 - or not np.issubdtype(scalar.dtype, np.number) - or np.iscomplexobj(scalar) - ): - raise ValueError(f"{name} must be a finite integer") - numeric = float(scalar) - if not np.isfinite(numeric) or numeric != np.floor(numeric): - raise ValueError(f"{name} must be a finite integer") - return int(numeric) - - n_cat_int = _finite_int(n_cat, "n_cat") - if not 2 <= n_cat_int <= MAX_POLYTOMOUS_CATEGORIES: - raise ValueError(f"n_cat must be between 2 and {MAX_POLYTOMOUS_CATEGORIES}") - q_int = _finite_int(q, "q") - if _gh and q_int not in _SUPPORTED_Q: - raise ValueError(f"q must be one of {_SUPPORTED_Q}") - max_iter_int = _finite_int(max_iter, "max_iter") - xi_points_int = _finite_int(xi_points, "xi_points") - if not 1 <= max_iter_int <= MAX_MAX_ITER: - raise ValueError(f"max_iter must be between 1 and {MAX_MAX_ITER}") - if not 1 <= xi_points_int <= _MAX_NODES: - raise ValueError(f"xi_points must be between 1 and {_MAX_NODES}") - if isinstance(xi_seed, bool) or not isinstance(xi_seed, (int, np.integer)): - raise ValueError("xi_seed must be a non-negative integer") - xi_seed_int = int(xi_seed) - if not 0 <= xi_seed_int < 2**64: - raise ValueError("xi_seed must be in [0, 2**64)") - - observed = np.isfinite(y) & (y >= 0) - if np.any(observed): - observed_y = y[observed] - if np.any(observed_y != np.floor(observed_y)) or observed_y.max() >= n_cat_int: - raise ValueError( - "responses must be integer categories in 0..n_cat-1 where observed" - ) - yy = np.where(observed, y, 0.0).astype(np.int64).reshape(-1) - - res = core.fit_gpcm( - yy, - observed.reshape(-1), - pat.astype(np.int64).reshape(-1), - int(n_persons), - int(n_items), - int(n_dims), - n_cat_int, - q_int, - max_iter_int, - float(tol), - str(node_rule), - xi_points_int, - xi_seed_int, - ) - return GpcmFit( - model=resolved_model, - slope=np.asarray(res["slope"], dtype=np.float64).reshape(n_items, n_dims), - step=np.asarray(res["step"], dtype=np.float64).reshape(n_items, n_cat_int - 1), - theta=np.asarray(res["theta"], dtype=np.float64).reshape(n_persons, n_dims), - n_cat=int(res["n_cat"]), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - n_parameters=int(res["n_parameters"]), - ) diff --git a/python/fast_mlsirm/grm.py b/python/fast_mlsirm/grm.py deleted file mode 100644 index ca1767fe9..000000000 --- a/python/fast_mlsirm/grm.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Dimension-agnostic graded response model (Samejima, 1969; Muraki & Carlson, 1995). - -Ordered categories share one discrimination vector and use ordered cumulative -boundaries. The public ``model=`` argument selects the one-factor model or a -confirmatory multidimensional loading specification; the numerical estimation -runs in Rust.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER, MAX_POLYTOMOUS_CATEGORIES -from .models import ConfirmatoryModel, ExploratoryModel, IrtModel, _resolve_model - -_SUPPORTED_Q = (7, 11, 15, 21, 31, 41) -_MAX_DIMS_GH = 3 -_MAX_DIMS_QMC = 6 -_MAX_NODES = 200_000 - - -@dataclass -class GrmFit: - """Fitted multidimensional graded response model (Samejima, 1969; Muraki & Carlson, 1995). - - ``slope`` is the ``n_items x n_dims`` discrimination matrix ``a_id`` (exactly ``0`` for - dimensions not in the item's loading pattern), per-dimension reflection-canonicalized so each - dimension's largest pure anchor is positive; ``threshold`` the ``n_items x (n_cat-1)`` ordered - boundary intercepts ``beta_ik`` (strictly decreasing within each item); ``theta`` the - ``n_persons x n_dims`` trait EAP. The model is - ``P(Y_ij >= k | theta_j) = sigmoid(sum_d a_id theta_jd + beta_i,{k-1})`` with - ``theta_j ~ MVN(0, I)``. ``termination_reason`` is ``"tolerance_met"`` or ``"max_iter_reached"``; - ``final_loglik_change`` the SIGNED change ``ll_final - ll_prev`` (non-negative up to a tiny - monotone-guard band).""" - - model: IrtModel - slope: np.ndarray - threshold: np.ndarray - theta: np.ndarray - n_cat: int - loglik_trace: np.ndarray - n_iter: int - converged: bool - termination_reason: str - final_loglik_change: float - n_parameters: int - - @property - def n_dims(self) -> int: - """Latent dimension count derived from :attr:`model`.""" - - return self.model.n_dims - - -def fit_grm( - responses: np.ndarray, - n_cat: int, - model: int | ExploratoryModel | ConfirmatoryModel = 1, - q: int = 21, - max_iter: int = 500, - tol: float = 1e-6, - node_rule: str = "gh", - xi_points: int = 4000, - xi_seed: int = 0x9E37_79B9_7F4A_7C15, -) -> GrmFit: - """Fit the graded response model (compute in Rust; Samejima, 1969; - Muraki & Carlson, 1995). - - Ordered polytomous categories with a SINGLE multidimensional discrimination vector per item and - ordered boundary intercepts: for category boundary ``k`` of item ``i``, - ``P(Y >= k | theta) = sigmoid(sum_{d in S_i} a_id theta_d + beta_i,{k-1})``, where ``S_i`` is the item's loading set from the confirmatory model specification and the - ``n_cat-1`` - thresholds ``beta_i`` are strictly decreasing (Samejima's graded model). ``theta ~ MVN(0, I)``. - Reduces to the unidimensional GRM at ``n_dims = 1``. - - Identification: unit trait variances + ordered thresholds + a PURE single-dimension anchor item - per dimension fix rotation; the per-dimension reflection is CANONICALIZED (each dimension flipped - so its largest pure anchor loads positive, leaving thresholds unchanged). Slopes are UNCONSTRAINED - so reverse-keyed / negative cross-loadings are representable. - - **Integration nodes (``node_rule``).** ``"gh"`` (default) uses the ``q**n_dims`` Gauss-Hermite - product grid and caps ``n_dims <= 3``. For ``n_dims = 4, 5, 6`` use ``"qmc"`` (Halton, Jank 2005) - or ``"mc"`` with ``xi_points`` prior draws. ``q`` applies only to ``"gh"``; ``xi_points``/ - ``xi_seed`` only to ``"qmc"``/``"mc"``. - - ``responses`` is a persons x items integer-category array (``0..n_cat-1``; ``NaN`` or negative = - missing, dropped MAR); For ``model=1``, all item slopes on the single factor are free. A - multidimensional confirmatory structure is supplied with - ``model=models.confirmatory(loading_pattern)``; a numeric exploratory model greater than - one is rejected until unrestricted loading rotation and identification are implemented. - ``n_cat`` is limited to 2..64, ``max_iter`` to 1..100,000, and Monte Carlo/QMC - ``xi_points`` to 1..200,000. Every declared category must be observed for each item, and every - dimension needs a pure anchor item. - - References (APA 7th ed.): - Samejima, F. (1969). Estimation of latent ability using a response pattern of graded - scores. *Psychometrika, 34*(S1), 1-97. - https://doi.org/10.1007/BF03372160 - Muraki, E., & Carlson, J. E. (1995). Full-information factor analysis for polytomous item - responses. *Applied Psychological Measurement, 19*(1), 73-90. - https://doi.org/10.1177/014662169501900109 - Reckase, M. D. (2009). *Multidimensional item response theory*. Springer. - https://doi.org/10.1007/978-0-387-89976-3 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_grm"): - raise RuntimeError("fit_grm requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - resolved_model, pat = _resolve_model(model, n_items) - n_dims = pat.shape[1] - _gh = str(node_rule).lower() in ("gh", "gauss-hermite", "gausshermite") - _max_dims = _MAX_DIMS_GH if _gh else _MAX_DIMS_QMC - if not 1 <= n_dims <= _max_dims: - raise ValueError( - f"loading_pattern dimensions must be between 1 and {_max_dims} (node_rule={node_rule!r})" - ) - - def _finite_int(value, name: str) -> int: - scalar = np.asarray(value) - if ( - scalar.ndim != 0 - or not np.issubdtype(scalar.dtype, np.number) - or np.iscomplexobj(scalar) - ): - raise ValueError(f"{name} must be a finite integer") - numeric = float(scalar) - if not np.isfinite(numeric) or numeric != np.floor(numeric): - raise ValueError(f"{name} must be a finite integer") - return int(numeric) - - n_cat_int = _finite_int(n_cat, "n_cat") - if not 2 <= n_cat_int <= MAX_POLYTOMOUS_CATEGORIES: - raise ValueError(f"n_cat must be between 2 and {MAX_POLYTOMOUS_CATEGORIES}") - q_int = _finite_int(q, "q") - if _gh and q_int not in _SUPPORTED_Q: - raise ValueError(f"q must be one of {_SUPPORTED_Q}") - max_iter_int = _finite_int(max_iter, "max_iter") - xi_points_int = _finite_int(xi_points, "xi_points") - if not 1 <= max_iter_int <= MAX_MAX_ITER: - raise ValueError(f"max_iter must be between 1 and {MAX_MAX_ITER}") - if not 1 <= xi_points_int <= _MAX_NODES: - raise ValueError(f"xi_points must be between 1 and {_MAX_NODES}") - if isinstance(xi_seed, bool) or not isinstance(xi_seed, (int, np.integer)): - raise ValueError("xi_seed must be a non-negative integer") - xi_seed_int = int(xi_seed) - if not 0 <= xi_seed_int < 2**64: - raise ValueError("xi_seed must be in [0, 2**64)") - - observed = np.isfinite(y) & (y >= 0) - if np.any(observed): - observed_y = y[observed] - if np.any(observed_y != np.floor(observed_y)) or observed_y.max() >= n_cat_int: - raise ValueError( - "responses must be integer categories in 0..n_cat-1 where observed" - ) - yy = np.where(observed, y, 0.0).astype(np.int64).reshape(-1) - - res = core.fit_grm( - yy, - observed.reshape(-1), - pat.astype(np.int64).reshape(-1), - int(n_persons), - int(n_items), - int(n_dims), - n_cat_int, - q_int, - max_iter_int, - float(tol), - str(node_rule), - xi_points_int, - xi_seed_int, - ) - return GrmFit( - model=resolved_model, - slope=np.asarray(res["slope"], dtype=np.float64).reshape(n_items, n_dims), - threshold=np.asarray(res["threshold"], dtype=np.float64).reshape( - n_items, n_cat_int - 1 - ), - theta=np.asarray(res["theta"], dtype=np.float64).reshape(n_persons, n_dims), - n_cat=int(res["n_cat"]), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - n_parameters=int(res["n_parameters"]), - ) diff --git a/python/fast_mlsirm/gtheory.py b/python/fast_mlsirm/gtheory.py deleted file mode 100644 index 407c8da3a..000000000 --- a/python/fast_mlsirm/gtheory.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Generalizability theory G/D-study analyses for crossed designs -(Huebner & Lucht, 2019). All numeric work happens in the Rust core -(``mlsirm_core::gtheory``); this module only validates and marshals. - -Source status: Huebner & Lucht (2019) READ in full, including the worked -p x i and p x i x o examples (Tables 3-6) that the Rust tests reproduce. -Brennan (2001) and Shavelson & Webb (1991) are cited by that paper for the -EMS derivations and were NOT read; the EMS-to-variance-component inversions -are hand-derived and numerically verified against the paper's published -tables (see the Rust module docs). -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Sequence - -import numpy as np - -_REFERENCES = """References (APA 7th ed.): - Huebner, A., & Lucht, M. (2019). Generalizability theory in R. - *Practical Assessment, Research, and Evaluation, 24*, Article 5. - https://doi.org/10.7275/5065-gc10 - Brennan, R. L. (2001). *Generalizability theory*. Springer. - (As cited in Huebner & Lucht, 2019; not read.) - Shavelson, R. J., & Webb, N. M. (1991). *Generalizability theory: - A primer*. Sage. (As cited in Huebner & Lucht, 2019; not read.) - """ - - -@dataclass -class GTheoryDStudyRow: - """One D-study column: proposed facet sizes with the resulting error - variances and coefficients (Huebner & Lucht, 2019, Tables 4 and 6). - ``n_o_prime`` is 1 and unused for the one-facet design.""" - - n_i_prime: int - n_o_prime: int - rel_error_var: float - abs_error_var: float - generalizability: float - dependability: float - - -@dataclass -class GTheoryResult: - """G-study ANOVA table plus D-study rows. - - Component order is ``(p, i, pi)`` for the one-facet design and - ``(p, i, o, pi, po, io, pio)`` for the two-facet design. ``var_raw`` - holds the raw ANOVA estimates (may be negative); ``var`` is the - component-wise ``max(., 0)`` used for all D-study quantities - (clamped-ANOVA policy — an implementation choice, not a - paper-prescribed estimator).""" - - df: list[float] - ss: list[float] - ms: list[float] - var_raw: list[float] - var: list[float] - d_study: list[GTheoryDStudyRow] - - -def _to_result(res: dict) -> GTheoryResult: - return GTheoryResult( - df=[float(v) for v in res["df"]], - ss=[float(v) for v in res["ss"]], - ms=[float(v) for v in res["ms"]], - var_raw=[float(v) for v in res["var_raw"]], - var=[float(v) for v in res["var"]], - d_study=[ - GTheoryDStudyRow( - n_i_prime=int(r["n_i_prime"]), - n_o_prime=int(r["n_o_prime"]), - rel_error_var=float(r["rel_error_var"]), - abs_error_var=float(r["abs_error_var"]), - generalizability=float(r["generalizability"]), - dependability=float(r["dependability"]), - ) - for r in res["d_study"] - ], - ) - - -def _core_or_raise(name: str): - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, name): - raise RuntimeError(f"{name} requires the compiled Rust core") - return core - - -def gtheory_pi( - data: np.ndarray, - n_i_prime: Sequence[int] = (5, 10, 15, 20), -) -> GTheoryResult: - """One-facet crossed ``p x i`` generalizability analysis (compute in - Rust; Huebner & Lucht, 2019, "One-facet crossed design" section and - Tables 3-4). - - ``data`` is a complete, balanced ``n_persons x n_items`` score array. - Variance components are the ANOVA (EMS) estimators; negative raw - estimates are reported in ``var_raw`` and clamped to zero in ``var`` - for the D study. D-study rows give sigma^2(delta), sigma^2(Delta), the - generalizability coefficient E-rho^2 (eq. 6) and the dependability - index Phi (eq. 7) at each proposed ``n_i'``; coefficients are NaN when - their denominator is <= 1e-12. In LLM-as-a-Judge quality management - this asks how many judge items are needed for a dependable rating. - - """ + _REFERENCES - core = _core_or_raise("gtheory_pi") - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D persons x items array") - n_p, n_i = x.shape - primes = [int(v) for v in n_i_prime] - return _to_result(core.gtheory_pi(x.reshape(-1), int(n_p), int(n_i), primes)) - - -def gtheory_pio( - data: np.ndarray, - n_prime: Sequence[tuple[int, int]] = ((5, 2), (10, 2), (15, 2), (20, 2)), -) -> GTheoryResult: - """Two-facet crossed ``p x i x o`` generalizability analysis (compute - in Rust; Huebner & Lucht, 2019, "Two-facet crossed design" section and - Tables 5-6). - - ``data`` is a complete, balanced ``n_persons x n_items x n_occasions`` - score array. Component order everywhere is - ``(p, i, o, pi, po, io, pio)``. ``n_prime`` lists proposed - ``(n_i', n_o')`` D-study pairs; the clamped-ANOVA and NaN-denominator - policies match :func:`gtheory_pi`. - - """ + _REFERENCES - core = _core_or_raise("gtheory_pio") - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 3: - raise ValueError("data must be a 3-D persons x items x occasions array") - n_p, n_i, n_o = x.shape - pairs = [(int(a), int(b)) for a, b in n_prime] - return _to_result( - core.gtheory_pio(x.reshape(-1), int(n_p), int(n_i), int(n_o), pairs) - ) diff --git a/python/fast_mlsirm/inference.py b/python/fast_mlsirm/inference.py index 3583b3d57..b94d3cb6c 100644 --- a/python/fast_mlsirm/inference.py +++ b/python/fast_mlsirm/inference.py @@ -28,8 +28,8 @@ def observed_information( model = config.normalized_model() chosen_backend = config.backend if backend is None else backend x0 = _pack(params, model) - if not np.isfinite(step) or step <= 0: - raise ValueError("step must be > 0 and finite") + if step <= 0: + raise ValueError("step must be > 0") def objective(x: np.ndarray) -> float: value, _, _ = neg_loglik_and_grad( @@ -46,12 +46,6 @@ def objective(x: np.ndarray) -> float: return float(value) n = x0.size - MAX_HESSIAN_DIM = 5_000 - if n > MAX_HESSIAN_DIM: - raise ValueError( - f"observed_information supports at most {MAX_HESSIAN_DIM} parameters (got {n}); " - "the dense finite-difference Hessian is O(n^2) memory and O(n^2) objective calls" - ) hessian = np.zeros((n, n), dtype=np.float64) base = objective(x0) eye = np.eye(n, dtype=np.float64) @@ -104,129 +98,3 @@ def standard_errors_from_vcov(vcov: np.ndarray) -> np.ndarray: if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]: raise ValueError("vcov must be a square matrix") return np.sqrt(np.maximum(np.diag(matrix), 0.0)) - - -def oakes_standard_errors( - result, - responses, - factor_id, - config=None, - mask=None, - group_id=None, - cluster_id=None, - h: float = 1e-5, -) -> dict: - """Item-parameter standard errors for a marginal (MMLE) fit via Oakes' - identity — the estimator Pritikin (2017) recommends in the EM framework - (M-step Hessian at the fixed posterior plus a finite-differenced cross - term, one E-step per parameter). Population parameters are conditioned - on; anchors/zero-inflation/covariates are not supported. Runs on the CPU - in f64 (finite differences would drown in f32 GPU noise). - - The fit must be a converged marginal-MMLE result. Structured likelihoods - whose free-parameter space is not represented by the current Oakes core - (anchors, zero inflation, and item covariates) fail closed instead of - returning curvature for a different model. - - Returns ``{"labels", "se", "information"}`` with labels ``alpha:i``, - ``b:i``, ``zeta:i:k``, ``tau``. - - References - ---------- - Oakes, D. (1999). Direct calculation of the information matrix via the EM - algorithm. *Journal of the Royal Statistical Society Series B: Statistical - Methodology, 61*(2), 479–482. https://doi.org/10.1111/1467-9868.00188 - - Pritikin, J. N. (2017). A comparison of parameter covariance estimation - methods for item response models in an expectation-maximization framework. - *Cogent Psychology, 4*(1), Article 1279435. - https://doi.org/10.1080/23311908.2017.1279435 - """ - import numpy as np - - from . import _core - from .config import FitConfig - from .estimators.marginal import LSIRM_PRIOR - from .objective import prepare_response - - config = config or FitConfig(model=result.model, estimator="mmle") - y, observed = prepare_response(np.asarray(responses, dtype=float), mask) - n_persons, n_items = y.shape - raw_factors = np.asarray(factor_id) - if raw_factors.ndim != 1 or raw_factors.shape != (n_items,): - raise ValueError("factor_id must be a 1-D array with one entry per item") - ff = raw_factors.astype(np.float64) - if not np.all(np.isfinite(ff)) or np.any(ff < 0) or np.any(ff != np.floor(ff)): - raise ValueError("factor_id must be finite non-negative integers") - factors = raw_factors.astype(np.int64) - n_dims = int(factors.max()) + 1 if factors.size else 0 - if n_dims > n_items: - raise ValueError("factor_id implies more dimensions than items") - if not np.isfinite(h) or h <= 0: - raise ValueError("h must be > 0 and finite") - optimizer = getattr(result, "optimizer", None) - if not isinstance(optimizer, str) or not optimizer.startswith("mmle_marginal_em/"): - raise ValueError("Oakes SEs require a marginal MMLE fit") - if getattr(result, "convergence_status", None) != "converged": - raise ValueError("Oakes SEs require a converged marginal MMLE fit") - pop = result.population or {} - if not isinstance(pop, dict): - raise ValueError("result.population must be a dictionary or None") - unsupported: list[str] = [] - if "pi_zero" in pop or "zero_responsibility" in pop: - unsupported.append("zero inflation") - if "delta" in pop or "covariate_delta" in pop: - unsupported.append("item covariates") - if "fixed_items" in pop or "tau_fixed" in pop: - unsupported.append("anchors") - if unsupported: - raise ValueError(f"Oakes SEs do not support {', '.join(unsupported)}") - from .fit import _compact_population_labels - if group_id is not None: - ids, n_pop = _compact_population_labels(group_id, n_persons, "group_id") - pop_kind = "multigroup" - elif cluster_id is not None: - ids, n_pop = _compact_population_labels(cluster_id, n_persons, "cluster_id") - pop_kind = "multilevel" - else: - ids, pop_kind, n_pop = None, "single", 0 - mu = np.asarray(pop.get("mu", np.zeros((0,))), dtype=np.float64).ravel() - sigma = np.asarray(pop.get("sigma", np.ones((0,))), dtype=np.float64).ravel() - sigma_u = float(pop.get("sigma_u", 0.0)) - p = result.params - return dict( - _core.oakes_standard_errors( - np.where(observed, y, 0.0).ravel(), - observed.ravel(), - factors, - int(n_persons), - int(n_items), - int(n_dims), - int(np.asarray(p.zeta).shape[1]), - result.model, - float(config.eps_distance), - np.asarray(p.alpha, dtype=np.float64), - np.asarray(p.b, dtype=np.float64), - np.asarray(p.zeta, dtype=np.float64).ravel(), - float(p.tau), - pop_kind=pop_kind, - pop_id=ids, - n_pop=int(n_pop), - mu=mu if mu.size else None, - sigma=sigma if sigma.size else None, - sigma_u=sigma_u, - q_theta=int(config.q_theta), - q_xi=int(config.q_xi), - q_u=int(config.q_u), - xi_rule=config.xi_rule, - xi_points=int(config.xi_points), - xi_seed=int(config.xi_seed), - lambda_b=LSIRM_PRIOR["lambda_b"], - lambda_alpha=LSIRM_PRIOR["lambda_alpha"], - mu_alpha=LSIRM_PRIOR["mu_alpha"], - lambda_zeta=LSIRM_PRIOR["lambda_zeta"], - lambda_tau=LSIRM_PRIOR["lambda_tau"], - mu_tau=LSIRM_PRIOR["mu_tau"], - h=float(h), - ) - ) diff --git a/python/fast_mlsirm/io.py b/python/fast_mlsirm/io.py index e4100e35d..1e0ce1dd8 100644 --- a/python/fast_mlsirm/io.py +++ b/python/fast_mlsirm/io.py @@ -1,225 +1,32 @@ from __future__ import annotations -import io import json -import os -import tempfile -import zipfile from dataclasses import asdict from datetime import datetime, timezone from pathlib import Path -from typing import BinaryIO, Callable import numpy as np from .types import DimensionalityDiagnostics, FitDiagnostics, FitResult, MLSIRMParams, SimulationData -MAX_NUMPY_ARRAY_ELEMENTS = 50_000_000 -MAX_NUMPY_ARRAY_BYTES = 512 * 1024 * 1024 -MAX_NUMPY_ARCHIVE_BYTES = 512 * 1024 * 1024 -MAX_NUMPY_ARCHIVE_MEMBERS = 256 -MAX_NUMPY_HEADER_BYTES = 64 * 1024 -MAX_JSON_INPUT_BYTES = 32 * 1024 * 1024 -MAX_JSON_NESTING_DEPTH = 128 -MAX_FACTOR_CSV_BYTES = 16 * 1024 * 1024 - - -def _atomic_write(path: str | Path, writer: Callable[[BinaryIO], object]) -> None: - """Write a child artifact without following a pre-existing leaf symlink.""" - destination = Path(path) - fd, temporary_name = tempfile.mkstemp( - dir=destination.parent, - prefix=f".{destination.name}.", - suffix=".tmp", - ) - temporary = Path(temporary_name) - try: - with os.fdopen(fd, "wb") as stream: - writer(stream) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, destination) - except BaseException: - temporary.unlink(missing_ok=True) - raise - - -def _atomic_write_text(path: str | Path, content: str) -> None: - _atomic_write(path, lambda stream: stream.write(content.encode("utf-8"))) - - -def _validate_npy_header(stream: BinaryIO, source: str) -> tuple[int, int]: - """Read only an NPY header and reject unsafe declared allocations.""" - version = np.lib.format.read_magic(stream) - if version == (1, 0): - shape, _, dtype = np.lib.format.read_array_header_1_0( - stream, max_header_size=MAX_NUMPY_HEADER_BYTES - ) - elif version == (2, 0): - shape, _, dtype = np.lib.format.read_array_header_2_0( - stream, max_header_size=MAX_NUMPY_HEADER_BYTES - ) - else: - raise ValueError(f"{source} uses unsupported NPY format version {version}") - if dtype.hasobject: - raise ValueError(f"{source} contains an object dtype") - - elements = 1 - for dim in shape: - if dim < 0: - raise ValueError(f"{source} declares a negative array dimension") - if dim == 0: - elements = 0 - elif elements and elements > MAX_NUMPY_ARRAY_ELEMENTS // dim: - raise ValueError( - f"{source} declares more than {MAX_NUMPY_ARRAY_ELEMENTS} array elements" - ) - else: - elements *= dim - nbytes = elements * int(dtype.itemsize) - if elements > MAX_NUMPY_ARRAY_ELEMENTS or nbytes > MAX_NUMPY_ARRAY_BYTES: - raise ValueError( - f"{source} declares {elements} elements / {nbytes} bytes, above the safe limit" - ) - return nbytes, stream.tell() - - -def _validate_numpy_file(path: Path) -> None: - file_size = path.stat().st_size - if file_size > MAX_NUMPY_ARCHIVE_BYTES: - raise ValueError( - f"NumPy input exceeds the {MAX_NUMPY_ARCHIVE_BYTES}-byte file limit" - ) - if path.suffix.lower() == ".npy": - with path.open("rb") as stream: - nbytes, header_end = _validate_npy_header(stream, path.name) - if file_size - header_end < nbytes: - raise ValueError( - f"{path.name} is truncated relative to its declared array shape" - ) - return - if path.suffix.lower() != ".npz": - raise ValueError("NumPy input must use a .npy or .npz suffix") - - with zipfile.ZipFile(path) as archive: - members = [info for info in archive.infolist() if not info.is_dir()] - if not members or len(members) > MAX_NUMPY_ARCHIVE_MEMBERS: - raise ValueError( - f"NPZ archive must contain 1..{MAX_NUMPY_ARCHIVE_MEMBERS} members" - ) - total_bytes = 0 - for info in members: - if not info.filename.endswith(".npy"): - raise ValueError( - f"NPZ archive member {info.filename!r} is not an NPY array" - ) - if info.file_size > MAX_NUMPY_ARRAY_BYTES + MAX_NUMPY_HEADER_BYTES: - raise ValueError( - f"NPZ member {info.filename!r} exceeds the safe byte limit" - ) - with archive.open(info) as stream: - nbytes, header_end = _validate_npy_header(stream, info.filename) - if info.file_size - header_end < nbytes: - raise ValueError( - f"NPZ member {info.filename!r} is truncated relative to its declared array shape" - ) - total_bytes += nbytes - if total_bytes > MAX_NUMPY_ARCHIVE_BYTES: - raise ValueError( - "NPZ archive declares more array bytes than the safe limit" - ) - - -def _read_text_bounded( - path: str | Path, - *, - source: str, - max_bytes: int, -) -> str: - """Read UTF-8 text without permitting an unbounded in-memory payload.""" - input_path = Path(path) - with input_path.open("rb") as stream: - payload = stream.read(max_bytes + 1) - if len(payload) > max_bytes: - raise ValueError(f"{source} exceeds the {max_bytes}-byte input limit") - try: - return payload.decode("utf-8") - except UnicodeDecodeError as exc: - raise ValueError(f"{source} must be valid UTF-8") from exc - - -def _load_json_bounded( - path: str | Path, - *, - source: str, - parse_constant=None, -): - """Deserialize JSON only after enforcing a byte limit on the input.""" - content = _read_text_bounded( - path, - source=source, - max_bytes=MAX_JSON_INPUT_BYTES, - ) - depth = 0 - in_string = False - escaped = False - for char in content: - if in_string: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == '"': - in_string = False - continue - if char == '"': - in_string = True - elif char in "[{": - depth += 1 - if depth > MAX_JSON_NESTING_DEPTH: - raise ValueError( - f"{source} exceeds the maximum JSON nesting depth " - f"of {MAX_JSON_NESTING_DEPTH}" - ) - elif char in "]}": - depth -= 1 - - kwargs = {} if parse_constant is None else {"parse_constant": parse_constant} - return json.loads(content, **kwargs) - - -def _load_numpy_bounded(path: str | Path): - """Load NPY/NPZ only after validating headers and allocation bounds.""" - source = Path(path) - _validate_numpy_file(source) - return np.load( - source, - allow_pickle=False, - max_header_size=MAX_NUMPY_HEADER_BYTES, - ) - - def save_simulation(data: SimulationData, run_dir: str | Path) -> None: out = Path(run_dir) out.mkdir(parents=True, exist_ok=True) - _atomic_write_text(out / "config.json", json.dumps(asdict(data.config), indent=2)) - _atomic_write(out / "responses.npy", lambda stream: np.save(stream, data.Y)) - _atomic_write( + (out / "config.json").write_text(json.dumps(asdict(data.config), indent=2), encoding="utf-8") + np.save(out / "responses.npy", data.Y) + np.savez( out / "truth.npz", - lambda stream: np.savez( - stream, - theta=data.truth.theta, - alpha=data.truth.alpha, - a=data.truth.a, - b=data.truth.b, - xi=data.truth.xi, - zeta=data.truth.zeta, - tau=np.array(data.truth.tau), - gamma=np.array(data.truth.gamma), - factor_id=data.factor_id, - Phi=data.Phi, - ), + theta=data.truth.theta, + alpha=data.truth.alpha, + a=data.truth.a, + b=data.truth.b, + xi=data.truth.xi, + zeta=data.truth.zeta, + tau=np.array(data.truth.tau), + gamma=np.array(data.truth.gamma), + factor_id=data.factor_id, + Phi=data.Phi, ) _write_factor_csv(out / "item_factor.csv", data.factor_id) manifest = { @@ -236,14 +43,14 @@ def save_simulation(data: SimulationData, run_dir: str | Path) -> None: "seed": int(data.config.seed), "files": {"responses": "responses.npy", "truth": "truth.npz", "factors": "item_factor.csv"}, } - _atomic_write_text(out / "manifest.json", json.dumps(manifest, indent=2)) + (out / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") def save_fit_result(result: FitResult, run_dir: str | Path) -> None: out = Path(run_dir) out.mkdir(parents=True, exist_ok=True) p = result.params - arrays = dict(theta=p.theta, alpha=p.alpha, a=p.a, b=p.b, xi=p.xi, zeta=p.zeta, tau=p.tau, gamma=p.gamma) + np.savez(out / "params.npz", theta=p.theta, alpha=p.alpha, a=p.a, b=p.b, xi=p.xi, zeta=p.zeta, tau=p.tau, gamma=p.gamma) summary = { "model": result.model, "optimizer": result.optimizer, @@ -254,22 +61,7 @@ def save_fit_result(result: FitResult, run_dir: str | Path) -> None: "n_iter": result.n_iter, "final_loglik": result.loglik_trace[-1] if result.loglik_trace else None, } - if result.ic is not None: - summary["information_criteria"] = { - key: (float(v) if isinstance(v, float) else v) - for key, v in result.ic.items() - } - if result.population is not None: - pop = result.population - summary["population"] = {"kind": pop["kind"]} - for key in ("mu", "sigma", "u_eap", "theta_sd"): - if key in pop: - arrays[f"pop_{key}"] = np.asarray(pop[key]) - for key in ("sigma_u", "icc"): - if key in pop: - summary["population"][key] = float(pop[key]) - _atomic_write(out / "params.npz", lambda stream: np.savez(stream, **arrays)) - _atomic_write_text(out / "fit_summary.json", json.dumps(summary, indent=2)) + (out / "fit_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") def save_fit_diagnostics(diagnostics: FitDiagnostics, run_dir: str | Path) -> None: @@ -286,64 +78,44 @@ def save_fit_diagnostics(diagnostics: FitDiagnostics, run_dir: str | Path) -> No "cluster_itemfit": _arrays_to_lists(diagnostics.cluster_itemfit or {}), "model_fit": diagnostics.model_fit, } - _atomic_write_text(out / "fit_diagnostics.json", json.dumps(payload, indent=2)) + (out / "fit_diagnostics.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") def save_dimensionality_diagnostics(diagnostics: DimensionalityDiagnostics, run_dir: str | Path) -> None: out = Path(run_dir) out.mkdir(parents=True, exist_ok=True) payload = {"candidates": diagnostics.candidates, "best": diagnostics.best} - _atomic_write_text(out / "dimension_diagnostics.json", json.dumps(payload, indent=2)) + (out / "dimension_diagnostics.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") def load_params(path: str | Path) -> MLSIRMParams: - with _load_numpy_bounded(path) as data: - return MLSIRMParams(theta=data["theta"], alpha=data["alpha"], b=data["b"], xi=data["xi"], zeta=data["zeta"], tau=float(data["tau"])) + # Security: explicitly disable pickle to prevent arbitrary code execution + data = np.load(path, allow_pickle=False) + return MLSIRMParams(theta=data["theta"], alpha=data["alpha"], b=data["b"], xi=data["xi"], zeta=data["zeta"], tau=float(data["tau"])) def load_factor_csv(path: str | Path) -> np.ndarray: import warnings - - content = _read_text_bounded( - path, - source="factor CSV", - max_bytes=MAX_FACTOR_CSV_BYTES, - ) - if not content or content.isspace(): + content = Path(path).read_text(encoding="utf-8").strip() + if not content: raise ValueError("factor CSV is empty") with warnings.catch_warnings(): warnings.simplefilter("ignore") - return np.loadtxt( - io.StringIO(content), - delimiter=",", - skiprows=1, - usecols=1, - dtype=np.int64, - ndmin=1, - ) + return np.loadtxt(path, delimiter=',', skiprows=1, usecols=1, dtype=np.int64, ndmin=1) def _write_factor_csv(path: Path, factor_id: np.ndarray) -> None: item_ids = np.arange(len(factor_id)) data = np.column_stack((item_ids, factor_id)) - - def write_csv(stream: BinaryIO) -> None: - text_stream = io.TextIOWrapper(stream, encoding="utf-8", newline="") - try: - np.savetxt( - text_stream, - data, - delimiter=",", - header="item_id,factor_id", - comments="", - fmt="%d", - ) - text_stream.flush() - finally: - text_stream.detach() - - _atomic_write(path, write_csv) + np.savetxt( + path, + data, + delimiter=',', + header='item_id,factor_id', + comments='', + fmt='%d' + ) def _arrays_to_lists(values: dict[str, np.ndarray]) -> dict[str, list[float]]: diff --git a/python/fast_mlsirm/ksirt.py b/python/fast_mlsirm/ksirt.py deleted file mode 100644 index 537138b75..000000000 --- a/python/fast_mlsirm/ksirt.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Kernel-smoothing nonparametric IRT: option characteristic curves by -Nadaraya-Watson regression on rank-based ordinal ability estimates (Ramsay, -1991, as cited in Mazza et al., 2014). All numeric work happens in the Rust -core; this module only validates and marshals arrays.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass -class KsirtResult: - """Kernel-smoothed option characteristic curves. - - ``theta`` are the rank-based ordinal ability estimates - ``Phi^-1(rank(total_i)/(n+1))`` in subject order; ``grid`` the - equally-spaced evaluation points; ``bandwidth`` the per-item bandwidths - used. ``options[j]`` lists item ``j``'s distinct observed scores - (ascending), ``occ[j]`` is the matching ``m_j x len(grid)`` option - characteristic curve matrix, ``expected[j]`` the expected item score - curve, and ``expected_total`` their sum over items.""" - - theta: np.ndarray - grid: np.ndarray - bandwidth: np.ndarray - options: list[np.ndarray] - occ: list[np.ndarray] - expected: list[np.ndarray] - expected_total: np.ndarray - - -def ksirt_analysis( - responses: np.ndarray, - kernel: str = "gaussian", - nevalpoints: int = 51, - bandwidth: np.ndarray | None = None, -) -> KsirtResult: - """Kernel smoothing of option characteristic curves (compute in Rust; - Ramsay, 1991, as cited in Mazza et al., 2014). - - Estimates each item's option characteristic curves by Nadaraya-Watson - kernel regression of the option indicators on ordinal ability estimates - ``Phi^-1(rank(total score)/(n+1))`` (ties broken by subject order), - evaluated on an equally-spaced grid from ``Phi^-1(1/(n+1))`` to - ``Phi^-1(n/(n+1))``. The default bandwidth is Silverman's rule - ``1.06 * n^(-1/5)`` on the standard-normal ability metric. Formulas - follow Mazza et al. (2014, Sections 2-2.3) and the KernSmoothIRT R - package source (both read); Ramsay (1991) itself is cited only through - Mazza et al. (2014). Standard errors and cross-validation bandwidths - are deliberately not implemented (see the Rust module docs). - - In LLM-as-a-Judge item-quality management, nonparametric OCCs reveal - non-monotone or poorly discriminating evaluation items without assuming - a parametric response model. - - ``responses`` is a complete ``persons x items`` array of pre-scored - numeric responses; each column's distinct values form that item's - options. ``kernel`` is ``"gaussian"``, ``"quadratic"``, or - ``"uniform"``. ``bandwidth`` optionally gives one positive value per - item. - - References (APA 7th ed.): - Mazza, A., Punzo, A., & McGuire, B. (2014). KernSmoothIRT: An R - package for kernel smoothing in item response theory. *Journal - of Statistical Software, 58*(6), 1-34. - https://doi.org/10.18637/jss.v058.i06 - Ramsay, J. O. (1991). Kernel smoothing approaches to nonparametric - item characteristic curve estimation. *Psychometrika, 56*(4), - 611-630. https://doi.org/10.1007/BF02294494 (as cited in Mazza - et al., 2014) - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "ksirt_occ"): - raise RuntimeError("ksirt_analysis requires the compiled Rust core") - - if kernel not in ("gaussian", "quadratic", "uniform"): - raise ValueError("kernel must be gaussian, quadratic, or uniform") - nevalpoints = int(nevalpoints) - if nevalpoints < 2: - raise ValueError("nevalpoints must be at least 2") - if nevalpoints > 100_000: - # trust boundary: nevalpoints drives Rust-side allocations - raise ValueError("nevalpoints must be at most 100000") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_persons < 2 or n_items < 1: - raise ValueError("responses needs at least 2 persons and 1 item") - if not np.all(np.isfinite(y)): - raise ValueError("responses must be complete (no missing values)") - - bw = None - if bandwidth is not None: - bw_arr = np.asarray(bandwidth, dtype=np.float64).reshape(-1) - if bw_arr.shape[0] != n_items: - raise ValueError("bandwidth must supply one value per item") - if not np.all(np.isfinite(bw_arr)) or np.any(bw_arr <= 0.0): - raise ValueError("bandwidths must be finite and positive") - bw = [float(v) for v in bw_arr] - - res = core.ksirt_occ( - y.reshape(-1), int(n_persons), int(n_items), kernel, nevalpoints, bw - ) - grid = np.asarray(res["grid"], dtype=np.float64) - q = grid.shape[0] - options = [np.asarray(o, dtype=np.float64) for o in res["options"]] - occ = [ - np.asarray(flat, dtype=np.float64).reshape(len(opts), q) - for flat, opts in zip(res["occ"], options) - ] - return KsirtResult( - theta=np.asarray(res["theta"], dtype=np.float64), - grid=grid, - bandwidth=np.asarray(res["bandwidth"], dtype=np.float64), - options=options, - occ=occ, - expected=[np.asarray(e, dtype=np.float64) for e in res["expected"]], - expected_total=np.asarray(res["expected_total"], dtype=np.float64), - ) diff --git a/python/fast_mlsirm/linking.py b/python/fast_mlsirm/linking.py index 1b09c1eab..77fa72301 100644 --- a/python/fast_mlsirm/linking.py +++ b/python/fast_mlsirm/linking.py @@ -12,53 +12,22 @@ def link_fixed_item_parameters( factor_id: np.ndarray | None = None, ) -> tuple[MLSIRMParams, dict[str, np.ndarray]]: """Put source parameters on the target metric using fixed anchor items.""" - anchors_raw = np.asarray(anchor_items) - if anchors_raw.ndim != 1 or anchors_raw.size == 0: + anchors = np.asarray(anchor_items, dtype=np.int64) + if anchors.ndim != 1 or anchors.size == 0: raise ValueError("anchor_items must be a non-empty 1D array") - a_fl = anchors_raw.astype(np.float64) - if not np.all(np.isfinite(a_fl)) or np.any(a_fl < 0) or np.any(a_fl != np.floor(a_fl)): - raise ValueError("anchor_items must be finite non-negative integers") - # Range-check on the float BEFORE narrowing: uint64 max casts to -1 and - # would slip past an upper-bound-only int64 check as a valid last-item index. - if np.any(a_fl >= source.alpha.size): + if np.any(anchors < 0) or np.any(anchors >= source.alpha.size): raise ValueError("anchor_items must reference existing items") - anchors = a_fl.astype(np.int64) - if anchors.size != np.unique(anchors).size: - raise ValueError("anchor_items must be unique") if source.alpha.shape != target.alpha.shape or source.b.shape != target.b.shape: raise ValueError("source and target item parameters must have matching shapes") - if source.theta.ndim != 2 or target.theta.ndim != 2: - raise ValueError("source and target theta must be 2-D (items x dimensions)") if source.theta.shape[1] != target.theta.shape[1]: raise ValueError("source and target theta must have the same dimensionality") - for arr, nm in ( - (source.alpha, "source.alpha"), - (source.b, "source.b"), - (target.alpha, "target.alpha"), - (target.b, "target.b"), - ): - if not np.all(np.isfinite(np.asarray(arr, dtype=float))): - raise ValueError(f"{nm} must be finite") n_items = source.alpha.size n_dims = source.theta.shape[1] - if factor_id is None: - factors = np.zeros(n_items, dtype=np.int64) - else: - f_raw = np.asarray(factor_id) - f_fl = f_raw.astype(np.float64) - if ( - f_raw.ndim != 1 - or not np.all(np.isfinite(f_fl)) - or np.any(f_fl < 0) - or np.any(f_fl != np.floor(f_fl)) - or np.any(f_fl >= n_items) - ): - raise ValueError("factor_id must be a 1-D array of finite non-negative integers") - factors = f_fl.astype(np.int64) + factors = np.zeros(n_items, dtype=np.int64) if factor_id is None else np.asarray(factor_id, dtype=np.int64) if factors.shape != (n_items,): raise ValueError("factor_id length must match number of items") - if np.any(factors >= n_dims): + if np.any(factors < 0) or np.any(factors >= n_dims): raise ValueError("factor_id values must be in 0..n_dims-1") linked = source.copy() @@ -74,8 +43,6 @@ def link_fixed_item_parameters( raise ValueError("target anchor slopes must be positive") scale[dim] = float(np.exp(np.mean(np.log(source.a[dim_anchors] / target_a)))) shift[dim] = float(np.mean((source.b[dim_anchors] - target.b[dim_anchors]) / target_a)) - if not (np.isfinite(scale[dim]) and scale[dim] > 0.0 and np.isfinite(shift[dim])): - raise ValueError("non-finite or non-positive linking coefficients (check anchor parameters)") items = factors == dim linked.theta[:, dim] = scale[dim] * source.theta[:, dim] + shift[dim] @@ -83,108 +50,3 @@ def link_fixed_item_parameters( linked.b[items] = source.b[items] - linked.a[items] * shift[dim] return linked, {"scale": scale, "shift": shift, "anchor_items": anchors.copy()} - - -# -------------------------------------------------------------------------- -# Characteristic-curve / moment IRT scale linking for separately-calibrated -# common-item designs (Kolen & Brennan 2014; Haebara 1980; Stocking & Lord -# 1983). Rust core is the compute path. -# -------------------------------------------------------------------------- - -from dataclasses import dataclass - - -@dataclass -class IrtLinkResult: - """IRT linking coefficients (theta_old = slope*theta_new + intercept) with - the characteristic-curve criterion and explicit termination evidence.""" - - slope: float # theta_old = slope * theta_new + intercept - intercept: float - criterion: float # characteristic-curve loss (0 for moment methods) - n_iter: int - method: str - converged: bool = True - termination_reason: str = "closed_form" - max_iter: int = 0 - final_objective_span: float = 0.0 - objective_tolerance: float = 0.0 - final_parameter_span: float = 0.0 - parameter_tolerance: float = 0.0 - - -def irt_link( - a_old: np.ndarray, - b_old: np.ndarray, - a_new: np.ndarray, - b_new: np.ndarray, - method: str = "stocking_lord", - q_theta: int = 41, -) -> IrtLinkResult: - """Link a separately-calibrated *new* form onto the *old* (reference) scale - from common items, returning ``theta_old = slope * theta_new + intercept``. - - ``a_*`` are slopes (``exp(alpha)`` in the engine's parameterization) and - ``b_*`` the intercepts of the common items in the ``eta = a*theta + b`` - form. ``method`` is one of ``mean_mean``, ``mean_sigma``, ``haebara``, - ``stocking_lord``; the characteristic-curve methods integrate over a - standard-normal Gauss-Hermite grid of ``q_theta`` nodes. Mean/sigma - linking requires non-zero common-item difficulty spread on both scales. - Characteristic-curve results expose both the objective and parameter - simplex stopping criteria; inspect ``converged`` before using a result. - - References - ---------- - Haebara, T. (1980). Equating logistic ability scales by a weighted least - squares method. *Japanese Psychological Research, 22*(3), 144–149. - https://doi.org/10.4992/psycholres1954.22.144 - - Kolen, M. J., & Brennan, R. L. (2014). *Test equating, scaling, and - linking: Methods and practices* (3rd ed.). Springer. - https://doi.org/10.1007/978-1-4939-0317-7 - - Stocking, M. L., & Lord, F. M. (1983). Developing a common metric in item - response theory. *Applied Psychological Measurement, 7*(2), 201–210. - https://doi.org/10.1177/014662168300700208 - """ - from .fitstats import _core_module - from .estimators.marginal import _gh - - core = _core_module() - if core is None: # pragma: no cover - raise RuntimeError("irt_link requires the compiled Rust core") - ao = np.asarray(a_old, dtype=np.float64) - bo = np.asarray(b_old, dtype=np.float64) - an = np.asarray(a_new, dtype=np.float64) - bn = np.asarray(b_new, dtype=np.float64) - for _arr, _nm in ((ao, 'a_old'), (bo, 'b_old'), (an, 'a_new'), (bn, 'b_new')): - if _arr.ndim != 1 or not np.all(np.isfinite(_arr)): - raise ValueError(f'{_nm} must be a 1-D array of finite numbers') - if ao.shape != bo.shape or an.shape != bn.shape or ao.shape != an.shape: - raise ValueError('slope/intercept arrays must have matching lengths') - if np.any(ao <= 0) or np.any(an <= 0): - raise ValueError('slopes (a_old/a_new) must be positive') - if isinstance(q_theta, (bool, np.bool_)) or not isinstance(q_theta, (int, np.integer)): - raise ValueError('q_theta must be an integer quadrature size') - nodes, weights = _gh(int(q_theta)) - res = core.irt_link( - ao, - bo, - an, - bn, - np.asarray(nodes, dtype=np.float64), - np.asarray(weights, dtype=np.float64), - method=str(method), - ) - return IrtLinkResult( - slope=float(res["slope"]), intercept=float(res["intercept"]), - criterion=float(res["criterion"]), n_iter=int(res["n_iter"]), - method=str(method), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - max_iter=int(res["max_iter"]), - final_objective_span=float(res["final_objective_span"]), - objective_tolerance=float(res["objective_tolerance"]), - final_parameter_span=float(res["final_parameter_span"]), - parameter_tolerance=float(res["parameter_tolerance"]), - ) diff --git a/python/fast_mlsirm/lltm.py b/python/fast_mlsirm/lltm.py deleted file mode 100644 index 723b6f3fd..000000000 --- a/python/fast_mlsirm/lltm.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Linear Logistic Test Model (Fischer, 1973): an explanatory Rasch model in which -item difficulties are a linear combination of basic cognitive-operation parameters -through a fixed design matrix, estimated by marginal-ML EM in the Rust core.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass -class LltmFit: - """Fitted LLTM (Fischer, 1973). - - ``eta`` are the basic-operation easiness parameters (Fischer difficulty = - ``-eta``); ``intercept`` the grand-mean easiness ``c`` (``NaN`` if not fit); - ``b`` the induced item easinesses ``c + Q @ eta``; ``theta`` the person EAP - abilities. When the LR test is computed, ``lr_stat``/``lr_df``/``lr_p`` give the - likelihood-ratio test of the LLTM restriction against the saturated Rasch model - (a small ``lr_p`` means the cognitive-operation decomposition does NOT fully - explain the item difficulties).""" - - eta: np.ndarray - intercept: float - b: np.ndarray - theta: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - n_parameters: int - loglik_rasch: float - lr_stat: float - lr_df: int - lr_p: float - - -def fit_lltm( - responses: np.ndarray, - q_design: np.ndarray, - fit_intercept: bool = True, - compute_lr: bool = True, - max_iter: int = 500, - tol: float = 1e-6, -) -> LltmFit: - """Fit the Linear Logistic Test Model (compute in Rust; Fischer, 1973). - - LLTM is an *explanatory* Rasch model: item ``i``'s easiness (the sign convention - returned here) is not free but a linear image - ``b_i = c + sum_k q_ik * eta_k`` of ``K`` basic cognitive-operation parameters - through a fixed weight matrix ``q_design`` (``q_ik`` = how many times operation - ``k`` is engaged by item ``i``). With ``K << J`` parameters it tests - whether a small set of operations explains the item parameters; the returned - likelihood-ratio test against the saturated Rasch model is its classic use. - - ``responses`` is a persons x items 0/1 array (``NaN`` = missing, dropped under - MAR). ``q_design`` is an items x basic-operations real array. The design must have - full column rank (with the intercept column when ``fit_intercept``) for ``eta`` to - be identified — a rank-deficient design (e.g. rows summing to a constant while - fitting an intercept) is rejected. - - Fischer's (1973, 1995) canonical LLTM uses conditional maximum likelihood. This - function instead fixes the ability distribution to ``N(0,1)`` and uses a - Bock-Aitkin-style marginal-ML EM algorithm. This is a repository-specific - estimator choice; finite-sample equality with Fischer's conditional-ML item - estimates is not assumed. - - References (APA 7th ed.): - Fischer, G. H. (1973). The linear logistic test model as an instrument in - educational research. *Acta Psychologica, 37*(6), 359–374. - https://doi.org/10.1016/0001-6918(73)90003-6 - Fischer, G. H. (1995). The linear logistic test model. In G. H. Fischer & I. - W. Molenaar (Eds.), *Rasch models: Foundations, recent developments, and - applications* (pp. 131–155). Springer. - https://doi.org/10.1007/978-1-4612-4230-7_8 - Bock, R. D., & Aitkin, M. (1981). Marginal maximum likelihood estimation of - item parameters: Application of an EM algorithm. *Psychometrika, 46*(4), - 443–459. - https://doi.org/10.1007/BF02293801 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_lltm"): - raise RuntimeError("fit_lltm requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - q = np.asarray(q_design, dtype=np.float64) - if q.ndim != 2: - raise ValueError("q_design must be a 2-D items x basic-operations array") - n_persons, n_items = y.shape - if q.shape[0] != n_items: - raise ValueError("q_design must have one row per item") - n_basic = q.shape[1] - - observed = np.isfinite(y) - yy = np.where(observed, y, 0.0).reshape(-1) - res = core.fit_lltm( - yy, - observed.reshape(-1), - q.reshape(-1), - int(n_persons), - int(n_items), - int(n_basic), - bool(fit_intercept), - bool(compute_lr), - int(max_iter), - float(tol), - ) - return LltmFit( - eta=np.asarray(res["eta"], dtype=np.float64), - intercept=float(res["intercept"]), - b=np.asarray(res["b"], dtype=np.float64), - theta=np.asarray(res["theta"], dtype=np.float64), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - n_parameters=int(res["n_parameters"]), - loglik_rasch=float(res["loglik_rasch"]), - lr_stat=float(res["lr_stat"]), - lr_df=int(res["lr_df"]), - lr_p=float(res["lr_p"]), - ) diff --git a/python/fast_mlsirm/mhrm.py b/python/fast_mlsirm/mhrm.py deleted file mode 100644 index 61d04fa67..000000000 --- a/python/fast_mlsirm/mhrm.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Metropolis-Hastings Robbins-Monro (MH-RM) confirmatory multidimensional IFA (Cai, 2010b). - -A stochastic-approximation EM that scales confirmatory item factor analysis to a latent -dimensionality where the deterministic Gauss-Hermite / quasi-Monte-Carlo E-steps of -:func:`fast_mlsirm.fit_2pl` become infeasible. Each cycle imputes the traits with a short persistent -random-walk Metropolis chain, then takes one Robbins-Monro stochastic-Newton step on the -block-diagonal (per-item) complete-data score and information. Supports the binary 2PL and the ordered -polytomous GPCM (Muraki, 1992) response families, orthogonal or correlated factors. The numerical -estimation runs in Rust.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .models import ConfirmatoryModel, ExploratoryModel, IrtModel, _resolve_model - -_MAX_DIMS = 64 - - -@dataclass -class MhrmFit: - """Fitted MH-RM confirmatory multidimensional 2PL (Cai, 2010b). - - ``loading`` is the ``n_items x n_dims`` matrix of free loadings ``a_id`` (exactly ``0`` where the - confirmatory pattern is ``0``), per-dimension reflection-canonicalized so each dimension's largest - pure anchor loads positive; ``theta`` the ``n_persons x n_dims`` trait EAP (Monte-Carlo mean of the - imputed draws over the convergence stage); ``corr`` the ``n_dims x n_dims`` latent correlation - matrix ``Phi`` (identity when ``estimate_corr=False``, unit diagonal with estimated off-diagonals - otherwise). - - The category parameters depend on :attr:`family`. For the binary 2PL, ``intercept`` holds the - per-item ``b_i`` (and ``step`` is empty); the model is - ``P(X_ij=1 | theta_j) = sigmoid(sum_d a_id theta_jd + b_i)``. For the GPCM (Muraki, 1992), ``step`` - is the ``n_items x (n_cat - 1)`` matrix of UNORDERED step intercepts (and ``intercept`` is empty); - with ``base_ij = sum_d a_id theta_jd`` (no intercept), - ``P(X_ij=k | theta_j) = softmax_k(k*base_ij + step_ik)`` (``step_i0 = 0`` pinned), ``theta_j ~ - MVN(0, Phi)``. ``se_loading`` / ``se_intercept`` / ``se_step`` are the matching Louis (1982) - observed-information standard errors (empty when ``estimate_se=False``; a block falls back to the - complete-data Fisher information where the finite-sample Louis block is not positive-definite). - - ``acceptance_rate`` is the final tuned Metropolis acceptance; ``termination_reason`` is - ``"converged"`` or ``"max_cycles_reached"``; ``final_param_change`` the windowed mean parameter - change at termination.""" - - model: IrtModel - family: str - n_cat: int - loading: np.ndarray - intercept: np.ndarray - step: np.ndarray - theta: np.ndarray - corr: np.ndarray - se_loading: np.ndarray - se_intercept: np.ndarray - se_step: np.ndarray - acceptance_rate: float - n_cycles: int - converged: bool - termination_reason: str - final_param_change: float - n_parameters: int - - @property - def n_dims(self) -> int: - """Latent dimension count derived from :attr:`model`.""" - - return self.model.n_dims - - -def fit_mhrm( - responses: np.ndarray, - model: int | ExploratoryModel | ConfirmatoryModel = 1, - family: str = "2pl", - n_cat: int = 2, - max_cycles: int = 2000, - burn_in: int = 200, - mh_steps: int = 5, - proposal_sd: float = 1.0, - target_accept: float = 0.30, - tol: float = 1e-3, - seed: int = 0x9E37_79B9_7F4A_7C15, - estimate_se: bool = True, - estimate_corr: bool = False, -) -> MhrmFit: - """Fit the confirmatory multidimensional 2PL by Metropolis-Hastings Robbins-Monro (compute in - Rust; Cai, 2010b). - - A stochastic-approximation EM for the general compensatory 2PL, - ``P(X_ij=1 | theta_j) = sigmoid(sum_{d in S_i} a_id theta_jd + b_i)`` with ``theta_j ~ MVN(0, - I_D)``. Unlike :func:`fast_mlsirm.fit_2pl`, the marginal-likelihood integral is not quadratured: - each cycle (1) imputes each person's ``theta`` by a short PERSISTENT (warm-started) random-walk - Metropolis chain from its current posterior, and (2) takes one Robbins-Monro stochastic-Newton - step ``xi <- xi + gain_k Gamma_k^{-1} s_k`` on the block-diagonal per-item complete-data score - ``s_k`` and Robbins-Monro-smoothed information ``Gamma_k``. The gain follows a constant-gain - burn-in then a decreasing ``1/(k - burn_in)`` schedule (``sum gain = inf``, ``sum gain^2 < inf``), - converging almost surely to a marginal-score root. Because the per-item work is closed-form and - ``D``-independent, MH-RM scales to a latent dimensionality (``n_dims`` up to 64) where the - ``q**n_dims`` Gauss-Hermite grid and the QMC E-step are infeasible. - - Identification: unit trait variances fix the loading scale; a PURE single-dimension anchor item - per dimension pins the rotation; the per-dimension sign is CANONICALIZED (largest pure anchor - positive), enforced in-loop each cycle so the stochastic running average stays in one mirror mode. - Loadings are UNCONSTRAINED so reverse-keyed / negative cross-loadings are representable. Standard - errors are the Louis (1982) observed information accumulated over the convergence stage. - - The response family is selected by ``family``: ``"2pl"`` (binary, default) or ``"gpcm"`` (the - ordered polytomous generalized partial credit model, Muraki, 1992, with ``n_cat`` integer - categories ``0..n_cat``). For the GPCM each item keeps a SINGLE discrimination vector ``a_i`` and - gains ``n_cat - 1`` free UNORDERED step intercepts, ``P(X_ij=k) = softmax_k(k*sum_d a_id theta_jd + - step_ik)``, estimated by the same MH-RM machinery with the closed-form multinomial complete-data - Hessian as the Robbins-Monro preconditioner and Louis information. ``family="gpcm"`` requires every - declared category to be observed for every item (else the corresponding step is unidentified). - - ``responses`` is a persons x items array (``NaN`` = missing, dropped under MAR): ``0/1`` for the - 2PL, integer categories ``0..n_cat`` for the GPCM. For ``model=1`` all item loadings on the single - factor are free; a multidimensional confirmatory structure is supplied with - ``model=models.confirmatory(loading_pattern)``; every dimension needs a pure single-loading anchor - item. ``burn_in`` must be less than ``max_cycles``; ``proposal_sd`` is the initial random-walk SD, - auto-tuned toward ``target_accept`` during burn-in. With ``estimate_corr=True`` a free latent - CORRELATION matrix ``Phi`` (``theta ~ MVN(0, Phi)``, unit diagonal) is estimated by a per-cycle - Robbins-Monro gradient step (Cai, 2010b); with ``False`` (default) the factors are orthogonal - (``Phi = I``) and the fit is bit-identical to the flag off. - - References (APA 7th ed.): - Cai, L. (2010a). High-dimensional exploratory item factor analysis by a Metropolis-Hastings - Robbins-Monro algorithm. *Psychometrika, 75*(1), 33–57. - https://doi.org/10.1007/s11336-009-9136-x - Cai, L. (2010b). Metropolis-Hastings Robbins-Monro algorithm for confirmatory item factor - analysis. *Journal of Educational and Behavioral Statistics, 35*(3), 307–335. - https://doi.org/10.3102/1076998609353115 - Louis, T. A. (1982). Finding the observed information matrix when using the EM algorithm. - *Journal of the Royal Statistical Society: Series B (Methodological), 44*(2), 226–233. - https://doi.org/10.1111/j.2517-6161.1982.tb01203.x - Muraki, E. (1992). A generalized partial credit model: Application of an EM algorithm. *Applied - Psychological Measurement, 16*(2), 159–176. https://doi.org/10.1177/014662169201600206 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_mhrm"): - raise RuntimeError("fit_mhrm requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - resolved_model, pat = _resolve_model(model, n_items) - n_dims = pat.shape[1] - if not 1 <= n_dims <= _MAX_DIMS: - raise ValueError(f"loading_pattern dimensions must be between 1 and {_MAX_DIMS}") - - def _finite_int(value, name: str) -> int: - scalar = np.asarray(value) - if ( - scalar.ndim != 0 - or not np.issubdtype(scalar.dtype, np.number) - or np.iscomplexobj(scalar) - ): - raise ValueError(f"{name} must be a finite integer") - numeric = float(scalar) - if not np.isfinite(numeric) or numeric != np.floor(numeric): - raise ValueError(f"{name} must be a finite integer") - return int(numeric) - - max_cycles_int = _finite_int(max_cycles, "max_cycles") - burn_in_int = _finite_int(burn_in, "burn_in") - mh_steps_int = _finite_int(mh_steps, "mh_steps") - if isinstance(seed, bool) or not isinstance(seed, (int, np.integer)): - raise ValueError("seed must be a non-negative integer") - seed_int = int(seed) - if not 0 <= seed_int < 2**64: - raise ValueError("seed must be in [0, 2**64)") - for name, val in (("proposal_sd", proposal_sd), ("target_accept", target_accept), ("tol", tol)): - if not np.isfinite(float(val)): - raise ValueError(f"{name} must be finite") - - fam = str(family).lower() - if fam not in ("2pl", "gpcm"): - raise ValueError("family must be '2pl' or 'gpcm'") - n_cat_int = _finite_int(n_cat, "n_cat") - if fam == "2pl": - n_cat_int = 2 - elif n_cat_int < 2: - raise ValueError("n_cat must be >= 2 for family='gpcm'") - - observed = ~np.isnan(y) - if np.any(observed): - obs_y = y[observed] - if fam == "2pl": - if np.any((obs_y != 0) & (obs_y != 1)): - raise ValueError("responses must be 0, 1, or NaN (missing)") - else: - if np.any(obs_y != np.floor(obs_y)) or np.any(obs_y < 0) or np.any(obs_y >= n_cat_int): - raise ValueError( - f"responses must be integer categories in 0..{n_cat_int}, or NaN (missing)" - ) - yy = np.where(observed, y, 0.0).astype(np.int64).reshape(-1) - - res = core.fit_mhrm( - yy, - observed.reshape(-1), - pat.astype(np.int64).reshape(-1), - int(n_persons), - int(n_items), - int(n_dims), - max_cycles_int, - burn_in_int, - mh_steps_int, - float(proposal_sd), - float(target_accept), - float(tol), - seed_int, - bool(estimate_se), - bool(estimate_corr), - fam, - int(n_cat_int), - ) - se_loading = np.asarray(res["se_loading"], dtype=np.float64) - se_intercept = np.asarray(res["se_intercept"], dtype=np.float64) - se_step = np.asarray(res["se_step"], dtype=np.float64) - n_free_cat = int(res["n_cat"]) - 1 - step = np.asarray(res["step"], dtype=np.float64) - return MhrmFit( - model=resolved_model, - family=fam, - n_cat=int(res["n_cat"]), - loading=np.asarray(res["loading"], dtype=np.float64).reshape(n_items, n_dims), - intercept=np.asarray(res["intercept"], dtype=np.float64), - step=step.reshape(n_items, n_free_cat) if step.size else step, - theta=np.asarray(res["theta"], dtype=np.float64).reshape(n_persons, n_dims), - corr=np.asarray(res["corr"], dtype=np.float64).reshape(n_dims, n_dims), - se_loading=se_loading.reshape(n_items, n_dims) if se_loading.size else se_loading, - se_intercept=se_intercept, - se_step=se_step.reshape(n_items, n_free_cat) if se_step.size else se_step, - acceptance_rate=float(res["acceptance_rate"]), - n_cycles=int(res["n_cycles"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - final_param_change=float(res["final_param_change"]), - n_parameters=int(res["n_parameters"]), - ) diff --git a/python/fast_mlsirm/mixed.py b/python/fast_mlsirm/mixed.py deleted file mode 100644 index 032acaf06..000000000 --- a/python/fast_mlsirm/mixed.py +++ /dev/null @@ -1,344 +0,0 @@ -"""Mixed-format item-bank marginal calibration. - -The public entry point keeps the existing homogeneous fitters unchanged and -adds a per-item response-family specification for one shared latent population. -""" - -from __future__ import annotations - -from dataclasses import dataclass -import warnings - -import numpy as np - - -_ALIASES = { - "rasch": "rasch", - "1pl": "rasch", - "2pl": "2pl", - "binary": "2pl", - "dichotomous": "2pl", - "3pl": "3pl", - "3plu": "3plu", - "upper_3pl": "3plu", - "4pl": "4pl", - "cll": "cll", - "complementary_log_log": "cll", - "grm": "grm", - "graded": "grm", - "pcm": "pcm", - "partial_credit": "pcm", - "gpcm": "gpcm", - "sequential": "sequential", - "tutz": "tutz", - "nominal": "nominal", - "nrm": "nominal", - "ideal": "ideal", - "ideal_point": "ideal", - "ggum": "ggum", - "lsirm": "lsirm", - "lsirm_2pl": "lsirm", - "lsirm_grm": "lsirm_grm", - "lsirm_gpcm": "lsirm_gpcm", -} - - -@dataclass(frozen=True) -class MixedItemParameters: - """Estimated parameters for one item in a mixed-format bank.""" - - model: str - n_categories: int - slope: float | None - intercepts: np.ndarray - thresholds: np.ndarray - scores: np.ndarray - location: float | None - zeta: np.ndarray - lower_asymptote: float | None = None - upper_asymptote: float | None = None - - -@dataclass(frozen=True) -class MixedFormatFit: - """Result of :func:`fit_mixed_items`. - - ``converged`` is true only when the recomputed marginal log-likelihood - satisfies ``abs(delta) <= tol * (1 + abs(loglik))``. ``n_iter`` counts - completed M-steps and ``termination_reason`` distinguishes convergence, - iteration exhaustion, non-finite likelihood, and non-monotone updates. - """ - - items: tuple[MixedItemParameters, ...] - theta_eap: np.ndarray - theta_sd: np.ndarray - xi_eap: np.ndarray - loglik: float - loglik_trace: tuple[float, ...] - n_iter: int - converged: bool - termination_reason: str - n_threads: int - - -def _normalize_models(item_models, n_items: int) -> tuple[str, ...]: - if isinstance(item_models, str): - raw = [item_models] * n_items - else: - raw = list(item_models) - if len(raw) != n_items: - raise ValueError("item_models length must match the number of response columns") - normalized = [] - for item, value in enumerate(raw): - key = str(value).strip().lower() - if key not in _ALIASES: - expected = ", ".join(sorted(set(_ALIASES.values()))) - raise ValueError( - f"item {item}: unsupported response model {value!r}; expected {expected}" - ) - normalized.append(_ALIASES[key]) - return tuple(normalized) - - -def _categories(y: np.ndarray, observed: np.ndarray, n_categories) -> np.ndarray: - n_items = y.shape[1] - if n_categories is None: - out = np.empty(n_items, dtype=np.int64) - for item in range(n_items): - values = y[observed[:, item], item] - if values.size == 0: - raise ValueError( - f"item {item}: at least one observed response is required" - ) - out[item] = int(values.max()) + 1 - else: - raw = np.asarray(n_categories) - if raw.shape != (n_items,): - raise ValueError(f"n_categories must have shape ({n_items},)") - if np.any(~np.isfinite(raw)) or np.any(raw != np.floor(raw)): - raise ValueError("n_categories must contain finite integers") - out = raw.astype(np.int64) - if np.any(out < 2): - raise ValueError("every item must declare at least two categories") - for item, n_cat in enumerate(out): - values = y[observed[:, item], item] - if values.size and np.any(values >= n_cat): - raise ValueError( - f"item {item}: observed response exceeds declared category range 0..{n_cat - 1}" - ) - return out - - -def fit_mixed_items( - responses: np.ndarray, - item_models, - n_categories=None, - mask: np.ndarray | None = None, - *, - latent_dim: int = 2, - q_theta: int = 21, - q_xi: int = 7, - max_iter: int = 100, - tol: float = 1e-5, - n_threads: int = 0, - require_convergence: bool = False, -) -> MixedFormatFit: - """Fit one item bank containing heterogeneous response families by MMLE. - - ``item_models`` is either one model name recycled over all columns or one - name per item. Supported canonical names are ``"rasch"``, ``"2pl"``, - ``"3pl"``, ``"3plu"``, ``"4pl"``, ``"cll"``, ``"grm"``, ``"pcm"``, - ``"gpcm"``, ``"sequential"``, ``"tutz"``, ``"nominal"``, ``"ideal"``, - ``"ggum"``, ``"lsirm"``, ``"lsirm_grm"``, and ``"lsirm_gpcm"``. Items - may have different category counts. ``NaN`` denotes missingness unless an - explicit boolean ``mask`` is supplied. - - Every family retains its own conditional response probability. The shared - trait is fixed to ``N(0, 1)`` for scale identification. Dominance slopes are - positive; nominal baseline category score/intercept are fixed to zero; - ordered GRM/GGUM thresholds use positive gap parameters. ``rasch`` and - ``pcm`` fix the slope to one on the standard-normal trait scale. The 3PL, - upper-3PL, and 4PL asymptotes are transformed so that they remain in the - unit interval (and the 4PL lower bound is strictly below its upper bound). - Sequential cells use continuation-ratio transition logits and report their - transition constants in ``intercepts``; ``tutz`` fixes their common slope - to one. ``cll`` is the one-parameter complementary log-log cell. - Ideal-point items use - ``exp(-0.5 * (a * (theta - b))**2)``. LSIRM items alone use - ``-||xi-zeta||`` with fixed distance weight one; all LSIRM items share the - same standard-normal latent-space coordinate, while non-spatial items are - constant on that integration axis. GGUM observed-category probabilities - pair the two subjective categories ``z`` and ``M-z`` under the symmetric - threshold sequence of Roberts et al. (2000). - - Rust performs the person E-step and independent item M-steps in parallel on - CPU. ``n_threads=0`` selects the available hardware parallelism; larger - explicit values are capped at that hardware limit. The likelihood is - recomputed after every M-step; non-convergence emits a - ``RuntimeWarning`` and is always recorded in the returned result. Set - ``require_convergence=True`` to raise instead. - - Notes - ----- - Adams et al. (1997) and Chalmers (2012) support heterogeneous conditional - item cells under a common latent distribution. Combining the cited ideal, - GGUM, nominal, and LSIRM cells in this exact API and fixing the LSIRM - distance coefficient to one are repository-specific model-design choices, - not claims made by any one cited paper. - - References - ---------- - Adams, R. J., Wilson, M., & Wang, W.-C. (1997). The multidimensional random - coefficients multinomial logit model. *Applied Psychological Measurement, - 21*(1), 1–23. https://doi.org/10.1177/0146621697211001 - - Barton, M. A., & Lord, F. M. (1981). An upper asymptote for the - three-parameter logistic item-response model. *ETS Research Report Series, - 1981*(1), i–8. https://doi.org/10.1002/j.2333-8504.1981.tb01255.x - - Bock, R. D. (1972). Estimating item parameters and latent ability when - responses are scored in two or more nominal categories. *Psychometrika, - 37*(1), 29–51. https://doi.org/10.1007/BF02291411 - - Chalmers, R. P. (2012). mirt: A multidimensional item response theory package - for the R environment. *Journal of Statistical Software, 48*(6), 1–29. - https://doi.org/10.18637/jss.v048.i06 - - Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping - unobserved item-respondent interactions: A latent space item response model - with interaction map. *Psychometrika, 86*(2), 378–403. - https://doi.org/10.1007/s11336-021-09762-5 - - Masters, G. N. (1982). A Rasch model for partial credit scoring. - *Psychometrika, 47*(2), 149–174. https://doi.org/10.1007/BF02296272 - - Maydeu-Olivares, A., Hernández, A., & McDonald, R. P. (2006). A - multidimensional ideal point item response theory model for binary data. - *Multivariate Behavioral Research, 41*(4), 445–472. - https://doi.org/10.1207/s15327906mbr4104_2 - - Roberts, J. S., Donoghue, J. R., & Laughlin, J. E. (1998). The generalized - graded unfolding model: A general parametric item response model for - unfolding graded responses. *ETS Research Report Series, 1998*(2), i–53. - https://doi.org/10.1002/j.2333-8504.1998.tb01781.x - - Roberts, J. S., Donoghue, J. R., & Laughlin, J. E. (2000). A general item - response theory model for unfolding unidimensional polytomous responses. - *Applied Psychological Measurement, 24*(1), 3–32. - https://doi.org/10.1177/01466216000241001 - - Shim, H., Bonifay, W., & Wiedermann, W. (2023). Parsimonious asymmetric - item response theory modeling with the complementary log-log link. - *Behavior Research Methods, 55*(1), 200–219. - https://doi.org/10.3758/s13428-022-01824-5 - - Tutz, G. (1990). Sequential item response models with an ordered response. - *British Journal of Mathematical and Statistical Psychology, 43*(1), - 39–55. https://doi.org/10.1111/j.2044-8317.1990.tb00925.x - - """ - y_float = np.asarray(responses, dtype=np.float64) - if y_float.ndim != 2: - raise ValueError("responses must be a persons-by-items matrix") - n_persons, n_items = y_float.shape - if n_persons == 0 or n_items == 0: - raise ValueError("responses must contain at least one person and one item") - if mask is None: - observed = np.isfinite(y_float) - else: - observed = np.asarray(mask, dtype=bool) - if observed.shape != y_float.shape: - raise ValueError("mask must match responses") - if np.any(observed & ~np.isfinite(y_float)): - raise ValueError("observed responses must be finite") - values = y_float[observed] - if np.any(values < 0.0) or np.any(values != np.floor(values)): - raise ValueError("observed responses must be non-negative integer categories") - y = np.where(observed, y_float, 0.0).astype(np.int64) - models = _normalize_models(item_models, n_items) - categories = _categories(y, observed, n_categories) - if not isinstance(latent_dim, int) or not 1 <= latent_dim <= 3: - raise ValueError("latent_dim must be an integer in 1..=3") - allowed_q = {7, 11, 15, 21, 31, 41} - if q_theta not in allowed_q or q_xi not in allowed_q: - raise ValueError("q_theta and q_xi must be one of 7, 11, 15, 21, 31, 41") - if not isinstance(max_iter, int) or max_iter <= 0: - raise ValueError("max_iter must be a positive integer") - if not np.isfinite(tol) or tol <= 0.0: - raise ValueError("tol must be finite and positive") - if not isinstance(n_threads, int) or n_threads < 0: - raise ValueError("n_threads must be a non-negative integer") - - try: - from . import _core # type: ignore - except Exception as exc: # pragma: no cover - editable/CI builds include Rust - raise RuntimeError("fit_mixed_items requires the compiled Rust core") from exc - if not hasattr(_core, "fit_mixed_items"): - raise RuntimeError( - "the compiled Rust core does not include mixed-format calibration" - ) - result = _core.fit_mixed_items( - y.ravel(), - int(n_persons), - int(n_items), - list(models), - categories, - None if observed.all() else observed.ravel(), - latent_dim=int(latent_dim), - q_theta=int(q_theta), - q_xi=int(q_xi), - max_iter=int(max_iter), - tol=float(tol), - n_threads=int(n_threads), - ) - items = tuple( - MixedItemParameters( - model=str(item["model"]), - n_categories=int(item["n_categories"]), - slope=None if item["slope"] is None else float(item["slope"]), - intercepts=np.asarray(item["intercepts"], dtype=np.float64), - thresholds=np.asarray(item["thresholds"], dtype=np.float64), - scores=np.asarray(item["scores"], dtype=np.float64), - location=None if item["location"] is None else float(item["location"]), - lower_asymptote=( - None - if item["lower_asymptote"] is None - else float(item["lower_asymptote"]) - ), - upper_asymptote=( - None - if item["upper_asymptote"] is None - else float(item["upper_asymptote"]) - ), - zeta=np.asarray(item["zeta"], dtype=np.float64), - ) - for item in result["items"] - ) - used_dim = int(result["latent_dim"]) - fit = MixedFormatFit( - items=items, - theta_eap=np.asarray(result["theta_eap"], dtype=np.float64), - theta_sd=np.asarray(result["theta_sd"], dtype=np.float64), - xi_eap=np.asarray(result["xi_eap"], dtype=np.float64).reshape( - n_persons, used_dim - ), - loglik=float(result["loglik"]), - loglik_trace=tuple(float(value) for value in result["loglik_trace"]), - n_iter=int(result["n_iter"]), - converged=bool(result["converged"]), - termination_reason=str(result["termination_reason"]), - n_threads=int(result["n_threads"]), - ) - if not fit.converged: - message = ( - "mixed-format calibration did not converge: " - f"reason={fit.termination_reason}, iterations={fit.n_iter}/{max_iter}, " - f"final_loglik={fit.loglik:.12g}" - ) - if require_convergence: - raise RuntimeError(message) - warnings.warn(message, RuntimeWarning, stacklevel=2) - return fit - - -__all__ = ["MixedFormatFit", "MixedItemParameters", "fit_mixed_items"] diff --git a/python/fast_mlsirm/mixture.py b/python/fast_mlsirm/mixture.py deleted file mode 100644 index d074c7369..000000000 --- a/python/fast_mlsirm/mixture.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Mixed Rasch / mixture IRT (Rost, 1990): the population is a mixture of latent -classes, each with its own item parameters, fit by marginal-ML EM in the Rust core.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_AGGREGATE_ITERS, MAX_MAX_ITER, MAX_RESTARTS - - -MAX_MIXTURE_CLASSES = 64 -MAX_MIXTURE_BUFFER_CELLS = 60_000_000 - - -@dataclass -class MixtureFit: - """Fitted mixture-IRT model (Rost, 1990). - - ``a``/``b`` are the per-class item discriminations and difficulties, shape - ``(n_classes, n_items)`` (``a`` is all ones for the Rasch model); ``pi`` the - mixing proportions; ``class_posterior`` the ``(n_persons, n_classes)`` class - responsibilities ``P(class | x_j)``; ``map_class`` the per-person modal class; - ``theta`` the mixture-EAP ability. Classes are in canonical order (mixing weight - descending, ties broken by mean difficulty ascending).""" - - model: str - n_classes: int - a: np.ndarray - b: np.ndarray - pi: np.ndarray - class_posterior: np.ndarray - map_class: np.ndarray - theta: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - n_parameters: int - - -def fit_mixture( - responses: np.ndarray, - n_classes: int = 2, - model: str = "rasch", - n_starts: int = 1, - max_iter: int = 500, - tol: float = 1e-6, - seed: int = 0x2545F491, -) -> MixtureFit: - """Fit a mixed Rasch / mixture-IRT model (compute in Rust; Rost, 1990). - - The population is modeled as a mixture of ``n_classes`` latent classes, each with - its own item parameters and a mixing proportion, detecting unobserved - heterogeneity (qualitatively different response strategies). Within a class, - responses follow a Rasch (``model="rasch"``, discrimination fixed at 1) or 2PL - (``model="2pl"``) model with ability ``theta ~ N(0, 1)``, estimated by marginal-ML - EM. Because the mixture likelihood is multimodal, pass ``n_starts > 1`` to run - several restarts and keep the highest-likelihood fit (start 0 is a deterministic - warm start). ``responses`` is a persons x items 0/1 array (``NaN`` = missing, - dropped under MAR). Classes are returned in a canonical order. - - Note: Rost (1990) and ``psychomix`` (Frick et al., 2012) fit Rasch mixtures by - conditional ML within class. This function instead combines Rost's latent-class - structure with a fixed-standard-normal, Bock-Aitkin marginal-ML EM estimator. That - estimator is a repository-specific operationalization; finite-sample equivalence - to the conditional-ML item estimates is not asserted. - - References (APA 7th ed.): - Rost, J. (1990). Rasch models in latent classes: An integration of two - approaches to item analysis. *Applied Psychological Measurement, 14*(3), - 271–282. https://doi.org/10.1177/014662169001400305 - Rost, J., & von Davier, M. (1995). Mixture distribution Rasch models. In G. H. - Fischer & I. W. Molenaar (Eds.), *Rasch models: Foundations, recent - developments, and applications* (pp. 257–268). Springer. - https://doi.org/10.1007/978-1-4612-4230-7_14 - Frick, H., Strobl, C., Leisch, F., & Zeileis, A. (2012). Flexible Rasch - mixture models with package psychomix. *Journal of Statistical Software, - 48*(7), 1–25. https://doi.org/10.18637/jss.v048.i07 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_mixture"): - raise RuntimeError("fit_mixture requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - - def bounded_integer(value, name: str, upper: int) -> int: - if isinstance(value, bool) or not isinstance(value, (int, np.integer)): - raise ValueError(f"{name} must be an integer in 1..{upper}") - result = int(value) - if not (1 <= result <= upper): - raise ValueError(f"{name} must be an integer in 1..{upper}") - return result - - n_classes_int = bounded_integer(n_classes, "n_classes", MAX_MIXTURE_CLASSES) - n_starts_int = bounded_integer(n_starts, "n_starts", MAX_RESTARTS) - max_iter_int = bounded_integer(max_iter, "max_iter", MAX_MAX_ITER) - aggregate_iterations = n_classes_int * n_starts_int * max_iter_int - if aggregate_iterations > MAX_AGGREGATE_ITERS: - raise ValueError( - f"n_classes x n_starts x max_iter ({aggregate_iterations}) exceeds the " - f"{MAX_AGGREGATE_ITERS}-iteration mixture budget" - ) - for label, cells in ( - ("person-class", n_persons * n_classes_int), - ("class-item", n_classes_int * n_items), - ): - if cells > MAX_MIXTURE_BUFFER_CELLS: - raise ValueError( - f"{label} buffer ({cells} cells) exceeds the " - f"{MAX_MIXTURE_BUFFER_CELLS}-cell mixture limit" - ) - observed = np.isfinite(y) - yy = np.where(observed, y, 0.0).reshape(-1) - res = core.fit_mixture( - yy, - observed.reshape(-1), - int(n_persons), - int(n_items), - n_classes_int, - str(model), - n_starts_int, - max_iter_int, - float(tol), - int(seed), - ) - c = int(res["n_classes"]) - return MixtureFit( - model=str(res["model"]), - n_classes=c, - a=np.asarray(res["a"], dtype=np.float64).reshape(c, n_items), - b=np.asarray(res["b"], dtype=np.float64).reshape(c, n_items), - pi=np.asarray(res["pi"], dtype=np.float64), - class_posterior=np.asarray(res["class_posterior"], dtype=np.float64).reshape( - n_persons, c - ), - map_class=np.asarray(res["map_class"], dtype=np.int64), - theta=np.asarray(res["theta"], dtype=np.float64), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - n_parameters=int(res["n_parameters"]), - ) diff --git a/python/fast_mlsirm/models.py b/python/fast_mlsirm/models.py deleted file mode 100644 index afb00b512..000000000 --- a/python/fast_mlsirm/models.py +++ /dev/null @@ -1,129 +0,0 @@ -"""IRT model specifications shared by dimension-agnostic item-family APIs. - -The public fitting functions use one ``model=`` argument, following the R -``mirt`` convention: a number denotes an exploratory factor count, while a -confirmatory specification declares the loading pattern. The current Rust -estimators implement unrestricted exploratory estimation only for one factor; -multidimensional exploratory requests fail explicitly until rotation and -identification are implemented. - -References (APA 7th ed.): - Chalmers, R. P. (2012). mirt: A multidimensional item response theory - package for the R environment. *Journal of Statistical Software, 48*(6), - 1-29. https://doi.org/10.18637/jss.v048.i06 -""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -__all__ = [ - "ConfirmatoryModel", - "ExploratoryModel", - "IrtModel", - "confirmatory", - "exploratory", -] - - -@dataclass(frozen=True) -class ExploratoryModel: - """An exploratory model identified by its number of latent dimensions.""" - - dimensions: int = 1 - - def __post_init__(self) -> None: - if ( - isinstance(self.dimensions, bool) - or not isinstance(self.dimensions, (int, np.integer)) - or int(self.dimensions) < 1 - ): - raise ValueError("exploratory dimensions must be a positive integer") - object.__setattr__(self, "dimensions", int(self.dimensions)) - - @property - def n_dims(self) -> int: - """Derived latent dimension count.""" - - return self.dimensions - - -@dataclass(frozen=True, eq=False) -class ConfirmatoryModel: - """A confirmatory model defined by an items-by-dimensions loading pattern.""" - - loading_pattern: np.ndarray - - def __post_init__(self) -> None: - raw = np.asarray(self.loading_pattern) - if raw.ndim != 2 or raw.shape[0] < 1 or raw.shape[1] < 1: - raise ValueError( - "confirmatory loading_pattern must be a non-empty 2-D items x dimensions array" - ) - if not np.issubdtype(raw.dtype, np.number) and not np.issubdtype( - raw.dtype, np.bool_ - ): - raise ValueError( - "confirmatory loading_pattern entries must be numeric 0 or 1" - ) - if np.iscomplexobj(raw): - raise ValueError("confirmatory loading_pattern entries must be real 0 or 1") - numeric = raw.astype(np.float64) - if not np.all(np.isfinite(numeric)) or not np.all( - (numeric == 0) | (numeric == 1) - ): - raise ValueError( - "confirmatory loading_pattern entries must be finite and exactly 0 or 1" - ) - pattern = numeric.astype(np.int64) - pattern.setflags(write=False) - object.__setattr__(self, "loading_pattern", pattern) - - @property - def n_dims(self) -> int: - """Derived latent dimension count.""" - - return int(self.loading_pattern.shape[1]) - - -IrtModel = ExploratoryModel | ConfirmatoryModel - - -def exploratory(dimensions: int = 1) -> ExploratoryModel: - """Build an exploratory model specification.""" - - return ExploratoryModel(dimensions) - - -def confirmatory(loading_pattern: np.ndarray) -> ConfirmatoryModel: - """Build a confirmatory model specification from a binary loading pattern.""" - - return ConfirmatoryModel(loading_pattern) - - -def _resolve_model( - model: int | IrtModel, - n_items: int, -) -> tuple[IrtModel, np.ndarray]: - """Normalize a public model argument to a specification and core loading pattern.""" - - if isinstance(model, bool): - raise TypeError("model must be a factor count or an IRT model specification") - if isinstance(model, (int, np.integer)): - model = ExploratoryModel(int(model)) - if isinstance(model, ExploratoryModel): - if model.dimensions != 1: - raise NotImplementedError( - "multidimensional exploratory loading estimation is not implemented; " - "use models.confirmatory(...) for an identified loading structure" - ) - return model, np.ones((n_items, 1), dtype=np.int64) - if isinstance(model, ConfirmatoryModel): - if model.loading_pattern.shape[0] != n_items: - raise ValueError( - "confirmatory model must have one loading-pattern row per item" - ) - return model, model.loading_pattern - raise TypeError("model must be a factor count or an IRT model specification") diff --git a/python/fast_mlsirm/mokken.py b/python/fast_mlsirm/mokken.py deleted file mode 100644 index f7b095aa2..000000000 --- a/python/fast_mlsirm/mokken.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Mokken scale analysis: Loevinger scalability coefficients and the automated -item selection procedure (AISP). All numeric work happens in the Rust core; -this module only validates and marshals arrays.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_POLYTOMOUS_CATEGORIES - - -@dataclass -class MokkenResult: - """Mokken scalability coefficients and (optionally) AISP scale labels. - - ``hij`` is the ``items x items`` matrix of pairwise scalability - coefficients (NaN diagonal), ``hi`` the per-item coefficients, ``h`` the - total scale coefficient; ``zij``/``zi``/``z`` are the matching Mokken Z - statistics for the null hypothesis of inter-item independence. - ``scale`` holds per-item AISP labels: 0 = unscalable, 1, 2, ... in - formation order. Sample statistics follow the mokken R package - (van der Ark, 2007).""" - - hij: np.ndarray - hi: np.ndarray - h: float - zij: np.ndarray - zi: np.ndarray - z: float - scale: np.ndarray - - -def mokken_analysis( - responses: np.ndarray, - lower_bound: float = 0.3, - alpha: float = 0.05, -) -> MokkenResult: - """Mokken scale analysis (compute in Rust; Mokken, 1971, as cited in - van der Ark, 2007). - - Computes the Loevinger scalability coefficients ``Hij``, ``Hi``, ``H`` - with their Mokken Z statistics, and partitions the items into Mokken - scales with the automated item selection procedure (AISP), following the - sample statistics and "search normal" algorithm of the mokken R package - (van der Ark, 2007): ``Hij = S_ij / Smax_ij`` where ``S`` is the sample - covariance matrix and ``Smax_ij`` the maximum covariance given the two - items' marginals (sorted-column coupling); ``Hi`` and ``H`` are ratios of - the corresponding pairwise sums. A Mokken scale at lower bound ``c`` - requires nonnegative inter-item covariances and ``Hi >= c`` (rule of - thumb ``c = 0.3``; Straat et al., 2013). - - In LLM-as-a-Judge item-quality management, AISP flags evaluation items - that do not scale with the rest (label 0) and detects multidimensional - item pools before parametric IRT calibration. - - ``responses`` is a complete ``persons x items`` array of integer scores - (dichotomous 0/1 or polytomous); missing values are not supported — - Mokken sample statistics assume complete data (van der Ark, 2007). - - References (APA 7th ed.): - van der Ark, L. A. (2007). Mokken scale analysis in R. *Journal of - Statistical Software, 20*(11), 1-19. - https://doi.org/10.18637/jss.v020.i11 - Straat, J. H., van der Ark, L. A., & Sijtsma, K. (2013). Comparing - optimization algorithms for item selection in Mokken scale - analysis. *Journal of Classification, 30*(1), 75-99. - https://doi.org/10.1007/s00357-013-9122-y - Mokken, R. J. (1971). *A theory and procedure of scale analysis*. - De Gruyter. (as cited in van der Ark, 2007) - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "mokken_coef_h"): - raise RuntimeError("mokken_analysis requires the compiled Rust core") - - if not np.isfinite(lower_bound) or not (0.0 <= lower_bound < 1.0): - raise ValueError("lower_bound must be in [0, 1)") - if not np.isfinite(alpha) or not (0.0 < alpha < 1.0): - raise ValueError("alpha must be in (0, 1)") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if not np.all(np.isfinite(y)): - raise ValueError("responses must be complete (no missing values)") - if np.any(y != np.floor(y)) or np.any(y < 0): - raise ValueError("responses must be non-negative integer scores") - if y.size and int(y.max()) + 1 > MAX_POLYTOMOUS_CATEGORIES: - raise ValueError( - f"responses imply more than {MAX_POLYTOMOUS_CATEGORIES} categories" - ) - x = y.astype(np.int64).reshape(-1) - res = core.mokken_coef_h(x, int(n_persons), int(n_items)) - scale = core.mokken_aisp( - x, int(n_persons), int(n_items), float(lower_bound), float(alpha) - ) - return MokkenResult( - hij=np.asarray(res["hij"], dtype=np.float64).reshape(n_items, n_items), - hi=np.asarray(res["hi"], dtype=np.float64), - h=float(res["h"]), - zij=np.asarray(res["zij"], dtype=np.float64).reshape(n_items, n_items), - zi=np.asarray(res["zi"], dtype=np.float64), - z=float(res["z"]), - scale=np.asarray(scale, dtype=np.int64), - ) diff --git a/python/fast_mlsirm/nominal.py b/python/fast_mlsirm/nominal.py deleted file mode 100644 index 6aa950510..000000000 --- a/python/fast_mlsirm/nominal.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Dimension-agnostic nominal response model (Bock, 1972; Thissen, Cai, & Bock, 2010). - -Each unordered category has its own discrimination vector and intercept. The -public ``model=`` argument selects the one-factor model or a confirmatory -multidimensional loading specification; the numerical estimation runs in Rust.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER, MAX_POLYTOMOUS_CATEGORIES -from .models import ConfirmatoryModel, ExploratoryModel, IrtModel, _resolve_model - -_SUPPORTED_Q = (7, 11, 15, 21, 31, 41) -_MAX_DIMS_GH = 3 -_MAX_DIMS_QMC = 6 -_MAX_NOMINAL_XI_POINTS = 200_000 - - -@dataclass -class NominalResponseFit: - """Fitted multidimensional nominal response model (Bock, 1972). - - ``slope`` is the ``n_items x n_cat x n_dims`` category-slope tensor ``a_ikd`` (exactly ``0`` for - the baseline category ``k = 0`` and for dimensions not in the item's loading pattern); - ``intercept`` the ``n_items x n_cat`` category intercepts ``c_ik`` (baseline ``0``); ``theta`` - the ``n_persons x n_dims`` trait EAP. The model is - ``P(Y_ij = k | theta_j) = softmax_k(sum_d a_ikd theta_jd + c_ik)`` with ``theta_j ~ MVN(0, I)``, - identified up to a per-dimension reflection ``(a_i.d, theta_d) -> (-a_i.d, -theta_d)``. - ``termination_reason`` is ``"tolerance_met"`` or ``"max_iter_reached"``; ``final_loglik_change`` - the SIGNED change ``ll_final - ll_prev`` between the final two evaluated marginal - log-likelihoods (non-negative up to a tiny monotone-guard band).""" - - model: IrtModel - slope: np.ndarray - intercept: np.ndarray - theta: np.ndarray - n_cat: int - loglik_trace: np.ndarray - n_iter: int - converged: bool - termination_reason: str - final_loglik_change: float - n_parameters: int - - @property - def n_dims(self) -> int: - """Latent dimension count derived from :attr:`model`.""" - - return self.model.n_dims - - -def fit_nominal( - responses: np.ndarray, - n_cat: int, - model: int | ExploratoryModel | ConfirmatoryModel = 1, - q: int = 21, - max_iter: int = 500, - tol: float = 1e-6, - node_rule: str = "gh", - xi_points: int = 4000, - xi_seed: int = 0x9E37_79B9_7F4A_7C15, -) -> NominalResponseFit: - """Fit the nominal response model (compute in Rust; Bock, 1972; - Thissen, Cai, & Bock, 2010). - - Unordered polytomous categories with CATEGORY-SPECIFIC multidimensional discrimination: for - category ``k`` of item ``i`` the linear predictor is ``eta_ik = sum_{d in S_i} a_ikd theta_d + - c_ik`` and ``P(Y=k | theta) = softmax_k(eta_ik)``, with the baseline category ``0`` pinned - ``a_i0 = 0, c_i0 = 0``. ``S_i`` is item ``i``'s loading set from the confirmatory model specification; - a slope ``a_ikd`` is free only for ``d in S_i``. ``theta ~ MVN(0, I)``. - At ``n_dims = 1`` this reduces to :func:`fast_mlsirm.fit_nominal` (the same general free-``a_k`` - parametrization). - - Identification: baseline category + unit trait variances + a PURE single-dimension anchor item - per dimension (an item loading exactly one dimension) fix the rotation; parameters are identified - up to a per-dimension reflection (not canonicalized, as in :func:`fit_nominal`). - - **Integration nodes (``node_rule``).** ``"gh"`` (default) uses the ``q**n_dims`` Gauss-Hermite - product grid and caps ``n_dims <= 3``. For ``n_dims = 4, 5, 6`` use ``"qmc"`` (Halton - quasi-Monte-Carlo, Jank 2005) or ``"mc"`` (Monte-Carlo) with ``xi_points`` prior draws. ``q`` - applies only to ``"gh"``; ``xi_points``/``xi_seed`` only to ``"qmc"``/``"mc"``. - - ``responses`` is a persons x items integer-category array (``0..n_cat-1``; ``NaN`` or negative = - missing, dropped MAR); For ``model=1``, all item parameters on the single factor are free. A - multidimensional confirmatory structure is supplied with - ``model=models.confirmatory(loading_pattern)``; a numeric exploratory model greater than - one is rejected until unrestricted loading rotation and identification are implemented. - Every declared category must be observed for each item, and every dimension needs a pure anchor item. - - References (APA 7th ed.): - Bock, R. D. (1972). Estimating item parameters and latent ability when responses are - scored in two or more nominal categories. *Psychometrika, 37*(1), 29-51. - https://doi.org/10.1007/BF02291411 - Thissen, D., Cai, L., & Bock, R. D. (2010). The nominal categories item response model. - In *Handbook of polytomous item response theory models* (pp. 43-75). Routledge. - Reckase, M. D. (2009). *Multidimensional item response theory*. Springer. - https://doi.org/10.1007/978-0-387-89976-3 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_nominal_model"): - raise RuntimeError("fit_nominal requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - resolved_model, pat = _resolve_model(model, n_items) - n_dims = pat.shape[1] - _gh = str(node_rule).lower() in ("gh", "gauss-hermite", "gausshermite") - _max_dims = _MAX_DIMS_GH if _gh else _MAX_DIMS_QMC - if not 1 <= n_dims <= _max_dims: - raise ValueError( - f"loading_pattern dimensions must be between 1 and {_max_dims} (node_rule={node_rule!r})" - ) - - def _finite_int(value, name: str) -> int: - scalar = np.asarray(value) - if ( - scalar.ndim != 0 - or not np.issubdtype(scalar.dtype, np.number) - or np.iscomplexobj(scalar) - ): - raise ValueError(f"{name} must be a finite integer") - numeric = float(scalar) - if not np.isfinite(numeric) or numeric != np.floor(numeric): - raise ValueError(f"{name} must be a finite integer") - return int(numeric) - - n_cat_int = _finite_int(n_cat, "n_cat") - if not (2 <= n_cat_int <= MAX_POLYTOMOUS_CATEGORIES): - raise ValueError(f"n_cat must be in 2..{MAX_POLYTOMOUS_CATEGORIES}") - q_int = _finite_int(q, "q") - if _gh and q_int not in _SUPPORTED_Q: - raise ValueError(f"q must be one of {_SUPPORTED_Q}") - max_iter_int = _finite_int(max_iter, "max_iter") - xi_points_int = _finite_int(xi_points, "xi_points") - if not (1 <= max_iter_int <= MAX_MAX_ITER): - raise ValueError(f"max_iter must be in 1..{MAX_MAX_ITER}") - if not _gh and not (1 <= xi_points_int <= _MAX_NOMINAL_XI_POINTS): - raise ValueError(f"xi_points must be in 1..{_MAX_NOMINAL_XI_POINTS}") - # xi_seed is a full-range u64: validate as an exact integer, no float64 round-trip. - if isinstance(xi_seed, bool) or not isinstance(xi_seed, (int, np.integer)): - raise ValueError("xi_seed must be a non-negative integer") - xi_seed_int = int(xi_seed) - if not 0 <= xi_seed_int < 2**64: - raise ValueError("xi_seed must be in [0, 2**64)") - - # missing = NaN or negative; the core takes a categories array + an observed mask. - observed = np.isfinite(y) & (y >= 0) - if np.any(observed): - observed_y = y[observed] - if np.any(observed_y != np.floor(observed_y)): - raise ValueError( - "responses must be integer categories in 0..n_cat-1 where observed" - ) - maxc = observed_y.max() - if maxc >= n_cat_int: - raise ValueError( - "responses must be integer categories in 0..n_cat-1 where observed" - ) - yy = np.where(observed, y, 0.0).astype(np.int64).reshape(-1) - - res = core.fit_nominal_model( - yy, - observed.reshape(-1), - pat.astype(np.int64).reshape(-1), - int(n_persons), - int(n_items), - int(n_dims), - n_cat_int, - q_int, - max_iter_int, - float(tol), - str(node_rule), - xi_points_int, - xi_seed_int, - ) - return NominalResponseFit( - model=resolved_model, - slope=np.asarray(res["slope"], dtype=np.float64).reshape( - n_items, n_cat_int, n_dims - ), - intercept=np.asarray(res["intercept"], dtype=np.float64).reshape( - n_items, n_cat_int - ), - theta=np.asarray(res["theta"], dtype=np.float64).reshape(n_persons, n_dims), - n_cat=int(res["n_cat"]), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - n_parameters=int(res["n_parameters"]), - ) diff --git a/python/fast_mlsirm/objective.py b/python/fast_mlsirm/objective.py index e43df9809..c0295780e 100644 --- a/python/fast_mlsirm/objective.py +++ b/python/fast_mlsirm/objective.py @@ -3,7 +3,7 @@ import numpy as np from .backend import load_rust_core, normalize_backend, normalize_device, resolve_backend -from .config import VALID_MODELS, FitConfig, PenaltyConfig +from .config import FitConfig, PenaltyConfig from .math import sigmoid, softplus from .types import MLSIRMParams @@ -32,20 +32,16 @@ def prepare_response(responses: np.ndarray, mask: np.ndarray | None = None) -> t def validate_factor_id(factor_id: np.ndarray, n_items: int, n_dims: int) -> np.ndarray: - raw = np.asarray(factor_id) - if raw.shape != (n_items,): + factors = np.asarray(factor_id, dtype=np.int64) + if factors.shape != (n_items,): raise ValueError("factor_id length must match number of items") - if raw.dtype.kind not in {"i", "u"}: - raise ValueError("factor_id must contain integer values") - if raw.size and (np.any(raw < 0) or int(raw.max()) >= n_dims): + if np.any(factors < 0) or np.any(factors >= n_dims): raise ValueError("factor_id values must be in 0..n_dims-1") - return raw.astype(np.int64, copy=False) + return factors def model_flags(model: str) -> tuple[bool, bool]: name = model.upper() - if name not in VALID_MODELS: - raise ValueError(f"model must be one of {sorted(VALID_MODELS)}") free_alpha = name not in {"MLSRM", "ULSRM"} uses_space = name != "MIRT" return free_alpha, uses_space @@ -57,40 +53,24 @@ def linear_predictor( model: str = "MLS2PLM", eps_distance: float = 1e-8, ) -> tuple[np.ndarray, np.ndarray]: - """Return the model linear predictor and any distance matrix. - - ``BIFAC2PLM`` uses a general-factor inner product as in Gibbons and - Hedeker (1992). LSIRM models instead use this repository's negative - distance interaction. - - References (APA 7th ed.): - Gibbons, R. D., & Hedeker, D. R. (1992). Full-information item - bi-factor analysis. *Psychometrika, 57*(3), 423–436. - https://doi.org/10.1007/BF02295430 - """ - name = model.upper() - free_alpha, uses_space = model_flags(name) + free_alpha, uses_space = model_flags(model) a = params.a if free_alpha else np.ones_like(params.alpha) theta_factor = params.theta[:, factor_id] - if name == "BIFAC2PLM": - # The bifactor's general-factor contribution is bilinear, not a - # latent-space distance penalty (Gibbons & Hedeker, 1992). - distance = np.zeros((params.theta.shape[0], len(factor_id)), dtype=np.float64) - interaction = np.dot(params.xi, params.zeta.T) - elif uses_space: + if uses_space: # Optimized distance computation: replace O(N*J*D) 3D broadcast with O(N*J) 2D dot product - xi_sq = np.einsum('ij,ij->i', params.xi, params.xi) - zeta_sq = np.einsum('ij,ij->i', params.zeta, params.zeta) - dist_sq = xi_sq[:, None] + zeta_sq[None, :] - 2 * np.dot(params.xi, params.zeta.T) - dist_sq = np.maximum(dist_sq, 0.0) - distance = np.sqrt(dist_sq + eps_distance) - interaction = -params.gamma * distance + dist_sq = -2.0 * np.dot(params.xi, params.zeta.T) + dist_sq += np.einsum('ij,ij->i', params.xi, params.xi)[:, None] + dist_sq += np.einsum('ij,ij->i', params.zeta, params.zeta)[None, :] + np.maximum(dist_sq, 0.0, out=dist_sq) + dist_sq += eps_distance + distance = np.sqrt(dist_sq, out=dist_sq) + gamma = params.gamma else: distance = np.zeros((params.theta.shape[0], len(factor_id)), dtype=np.float64) - interaction = 0.0 + gamma = 0.0 - eta = a[None, :] * theta_factor + params.b[None, :] + interaction + eta = a[None, :] * theta_factor + params.b[None, :] - gamma * distance return eta, distance @@ -104,16 +84,13 @@ def neg_loglik_and_grad( device: str | None = None, ) -> tuple[float, MLSIRMParams, float]: config = config or FitConfig() - model = config.normalized_model() - if model == "BIFAC2PLM": - raise ValueError("BIFAC2PLM is supported by the marginal estimator only") - requested_backend = normalize_backend(backend) normalized_backend = resolve_backend(requested_backend) if requested_backend == "auto" else requested_backend if normalized_backend == "rust": resolved_device = normalize_device(device if device is not None else config.rust_device) return _neg_loglik_and_grad_rust(responses, factor_id, params, config, mask, resolved_device) + model = config.normalized_model() penalty = config.penalty y, observed = prepare_response(responses, mask) factors = validate_factor_id(factor_id, y.shape[1], params.theta.shape[1]) diff --git a/python/fast_mlsirm/parallel_analysis.py b/python/fast_mlsirm/parallel_analysis.py deleted file mode 100644 index cd951cdb5..000000000 --- a/python/fast_mlsirm/parallel_analysis.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Horn's parallel analysis for principal-component retention (Horn, 1965, -as implemented by CRAN paran; Dinno, 2018). All numeric work happens in the -Rust core; this module only validates and marshals.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass -class ParallelAnalysisResult: - """Parallel-analysis outputs, all vectors in descending observed- - eigenvalue order. ``retained`` counts leading components whose adjusted - eigenvalue stays above 1 up to the first failure (later resurgences do - not count, matching paran's scan).""" - - retained: int - eigenvalues: np.ndarray - random_eigenvalues: np.ndarray - bias: np.ndarray - adjusted_eigenvalues: np.ndarray - - -_REFERENCES = """References (APA 7th ed.): - Dinno, A. (2018). *paran: Horn's test of principal components/ - factors* (Version 1.5.6) [R package]. - https://CRAN.R-project.org/package=paran - Glorfeld, L. W. (1995). An improvement on Horn's parallel analysis - methodology for selecting the correct number of factors to - retain. *Educational and Psychological Measurement, 55*(3), - 377-393. (as cited in Dinno, 2018) - Horn, J. L. (1965). A rationale and a test for the number of factors - in factor analysis. *Psychometrika, 30*(2), 179-185. - https://doi.org/10.1007/BF02289447 (as cited in Dinno, 2018) - """ - - -def parallel_analysis( - data: np.ndarray, - n_iterations: int | None = None, - centile: int = 0, - seed: int = 1, -) -> ParallelAnalysisResult: - """Horn's parallel analysis, PCA path (compute in Rust; algorithm - transcribed from the paran 1.5.6 R source, read line by line; Horn, - 1965, and Glorfeld, 1995, not read — attribution as cited in Dinno, - 2018). - - Eigenvalues of the Pearson correlation matrix of ``data`` (an - ``n_persons x n_items`` array, complete and finite) are adjusted by the - sampling bias estimated from ``n_iterations`` random standard-normal - data sets of the same shape: ``adjusted = observed - (random - 1)``. - Components are retained while ``adjusted > 1``, scanning left to right - and stopping at the first failure. ``centile=0`` benchmarks against the - per-position mean of the random eigenvalues (Horn's method as - implemented by paran); ``centile`` in 1..=99 uses that upper centile - (R type-7 quantile) instead — Glorfeld's conservative variant. - ``n_iterations`` defaults to ``30 * n_items`` (paran's default). The - random stream is this crate's deterministic LCG — results are - paran-inspired but not bit-identical to any R run. In LLM-as-a-Judge - quality management this estimates how many latent dimensions the judge - rubric actually measures. - - """ + _REFERENCES - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "parallel_analysis"): - raise RuntimeError("parallel_analysis requires the compiled Rust core") - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D persons x items array") - n_persons, n_items = x.shape - iters = 30 * n_items if n_iterations is None else int(n_iterations) - if iters < 1: - raise ValueError("n_iterations must be >= 1") - if not 0 <= int(centile) <= 99: - raise ValueError("centile must be 0 (mean) or in 1..=99") - if int(seed) < 0: - raise ValueError("seed must be non-negative") - res = core.parallel_analysis( - x.reshape(-1), int(n_persons), int(n_items), iters, int(centile), int(seed) - ) - return ParallelAnalysisResult( - retained=int(res["retained"]), - eigenvalues=np.asarray(res["eigenvalues"], dtype=np.float64), - random_eigenvalues=np.asarray(res["random_eigenvalues"], dtype=np.float64), - bias=np.asarray(res["bias"], dtype=np.float64), - adjusted_eigenvalues=np.asarray( - res["adjusted_eigenvalues"], dtype=np.float64 - ), - ) diff --git a/python/fast_mlsirm/polytomous.py b/python/fast_mlsirm/polytomous.py deleted file mode 100644 index 525b0e4cf..000000000 --- a/python/fast_mlsirm/polytomous.py +++ /dev/null @@ -1,1168 +0,0 @@ -"""Unidimensional polytomous item-response fitting (GRM / GPCM). - -Thin orchestration over the Rust compute path (``mlsirm_core::poly``): all -numerical work — the Bock-Aitkin marginal-EM loop, the category cells, and the -Newton M-step — runs in Rust. This is the classic (no latent-space) polytomous -model; the latent-space polytomous LSIRM extension slots the same category cell -into the marginal (theta, xi) quadrature and is the next milestone (see -``docs/papers/gpcm-nominal-design-spec.md``). - -``GRM`` (Samejima cumulative logit) is the default; ``GPCM`` (Muraki -adjacent-category) is available for partial-credit scoring. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -import numpy as np - -from .config import ( - MAX_MAX_ITER, - MAX_POLYTOMOUS_CATEGORIES, - MAX_SIM_CELLS, - MAX_SIM_PERSONS, -) - -__all__ = [ - "PolytomousFit", - "fit_polytomous", - "score_polytomous", - "information_polytomous", - "PolyLsirmFit", - "fit_lsirm_polytomous", - "polytomous_information_criteria", -] - -VALID_POLY_MODELS = {"grm", "gpcm"} -MAX_POLY_QUADRATURE_POINTS = 4_096 -MAX_POLY_BOOTSTRAP_REPLICATES = 10_000 -MAX_POLY_CAT_ITEMS = 10_000 - - -def _bounded_integer(value, name: str, lower: int, upper: int) -> int: - if ( - not isinstance(value, (int, np.integer)) - or isinstance(value, (bool, np.bool_)) - or not lower <= int(value) <= upper - ): - raise ValueError(f"{name} must be an integer between {lower} and {upper}") - return int(value) - - -def _quadrature_points(value) -> int: - return _bounded_integer(value, "q_theta", 1, MAX_POLY_QUADRATURE_POINTS) - - -@dataclass -class PolytomousFit: - """Result of :func:`fit_polytomous`. - - ``slope`` is the per-item discrimination ``a_i``. ``cat_params`` is - ``n_items x (n_cat - 1)``: GPCM additive category intercepts, or GRM - cumulative thresholds ``beta_{i,k}`` (ordered decreasing). ``thresholds`` - is the GPCM Muraki step reparametrization ``b_{i,k} = c_{i,k-1} - c_{i,k}`` - (``None`` for GRM, whose ``cat_params`` are already thresholds). - """ - - model: str - slope: np.ndarray - cat_params: np.ndarray - loglik: float - n_iter: int - converged: bool = False - termination_reason: str = "not_fitted" - loglik_trace: np.ndarray = field( - default_factory=lambda: np.empty(0, dtype=np.float64) - ) - final_delta: float = np.nan - stopping_tolerance: float = np.nan - thresholds: np.ndarray | None = None - - -def _core_module(): - try: - from . import _core # type: ignore - - return _core - except Exception: # pragma: no cover - core built in CI - return None - - -def _poly_int_and_mask(responses: np.ndarray, n_cat: int) -> tuple[np.ndarray, np.ndarray]: - """Validate polytomous responses (``NaN`` = missing) and return - ``(int64 categories with missing filled to 0, boolean observed mask)``.""" - if ( - not isinstance(n_cat, (int, np.integer)) - or isinstance(n_cat, (bool, np.bool_)) - or not 2 <= int(n_cat) <= MAX_POLYTOMOUS_CATEGORIES - ): - raise ValueError(f"n_cat must be an integer between 2 and {MAX_POLYTOMOUS_CATEGORIES}") - n_cat = int(n_cat) - yf = np.asarray(responses, dtype=np.float64) - if yf.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - if np.any(np.isinf(yf)): - raise ValueError("responses may only use NaN for missing values") - observed = ~np.isnan(yf) - obs_vals = yf[observed] - if obs_vals.size and ( - np.any(obs_vals != np.floor(obs_vals)) or obs_vals.min() < 0 or obs_vals.max() >= n_cat - ): - raise ValueError(f"observed responses must be integer categories in 0..{n_cat - 1}") - y_int = np.where(observed, yf, 0.0).astype(np.int64) - return y_int, observed - - -def _nonnegative_integer_vector(values, name: str) -> np.ndarray: - """Validate label/index vectors before their irreversible int64 cast.""" - raw = np.asarray(values) - if raw.ndim != 1 or raw.size == 0: - raise ValueError(f"{name} must be a non-empty 1-D array") - if ( - not np.issubdtype(raw.dtype, np.number) - or np.issubdtype(raw.dtype, np.bool_) - or np.issubdtype(raw.dtype, np.complexfloating) - ): - raise ValueError(f"{name} must contain non-negative integers") - numeric = raw.astype(np.float64) - if ( - not np.all(np.isfinite(numeric)) - or np.any(numeric < 0) - or np.any(numeric != np.floor(numeric)) - or np.any(numeric > np.iinfo(np.int64).max) - ): - raise ValueError(f"{name} must contain non-negative integers") - return raw.astype(np.int64) - - -def fit_polytomous( - responses: np.ndarray, - n_cat: int, - model: str = "grm", - q_theta: int = 21, - max_iter: int = 80, - tol: float = 1e-6, -) -> PolytomousFit: - """Fit a unidimensional GRM or GPCM by marginal MLE (compute in Rust). - - ``responses`` is a persons x items array of integer categories - ``0..n_cat-1``; ``NaN`` marks a missing response (marginalized out of the - likelihood). ``model`` is ``"grm"`` (default) or ``"gpcm"``. - ``theta ~ N(0, 1)`` on a ``q_theta``-node Gauss-Hermite grid. The returned - convergence fields describe the observed-data likelihood at the returned - parameter state; reaching ``max_iter`` is reported as nonconvergence. - ``n_cat`` is limited to 2..64 and ``max_iter`` to 1..100,000. - - References - ---------- - Dempster, A. P., Laird, N. M., & Rubin, D. B. (1977). Maximum likelihood - from incomplete data via the EM algorithm. *Journal of the Royal - Statistical Society: Series B (Methodological), 39*(1), 1–22. - https://doi.org/10.1111/j.2517-6161.1977.tb01600.x - - Wu, C. F. J. (1983). On the convergence properties of the EM algorithm. - *The Annals of Statistics, 11*(1), 95–103. - https://doi.org/10.1214/aos/1176346060 - """ - m = str(model).lower() - if m not in VALID_POLY_MODELS: - raise ValueError(f"model must be one of {sorted(VALID_POLY_MODELS)}") - if ( - not isinstance(n_cat, (int, np.integer)) - or isinstance(n_cat, (bool, np.bool_)) - or not 2 <= int(n_cat) <= MAX_POLYTOMOUS_CATEGORIES - ): - raise ValueError(f"n_cat must be an integer between 2 and {MAX_POLYTOMOUS_CATEGORIES}") - if q_theta not in {7, 11, 15, 21, 31, 41}: - raise ValueError("q_theta must be one of 7, 11, 15, 21, 31, 41") - if ( - not isinstance(max_iter, (int, np.integer)) - or isinstance(max_iter, (bool, np.bool_)) - or not 1 <= int(max_iter) <= MAX_MAX_ITER - ): - raise ValueError(f"max_iter must be an integer between 1 and {MAX_MAX_ITER}") - if not np.isfinite(tol) or tol <= 0: - raise ValueError("tol must be finite and > 0") - - y_int, observed = _poly_int_and_mask(responses, n_cat) - - core = _core_module() - if core is None or not hasattr(core, "fit_poly_unidim"): - raise RuntimeError("fit_polytomous requires the compiled Rust core") - - n_persons, n_items = y_int.shape - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.fit_poly_unidim( - y_int.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - obs_arg, - m, - int(q_theta), - int(max_iter), - float(tol), - ) - slope = np.asarray(res["slope"], dtype=np.float64) - cat_params = np.asarray(res["cat_params"], dtype=np.float64) - thresholds = None - if m == "gpcm": - # Muraki step difficulties from additive intercepts (baseline 0 prepended) - c = np.concatenate([np.zeros((n_items, 1)), cat_params], axis=1) - thresholds = c[:, :-1] - c[:, 1:] - return PolytomousFit( - model=m, - slope=slope, - cat_params=cat_params, - loglik=float(res["loglik"]), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - final_delta=float(res["final_delta"]), - stopping_tolerance=float(res["stopping_tolerance"]), - thresholds=thresholds, - ) - - -def score_polytomous( - responses: np.ndarray, - fit: PolytomousFit, - q_theta: int = 21, -) -> dict[str, np.ndarray]: - """EAP trait scores for polytomous responses given a fitted model (compute - in Rust). ``responses`` is persons x items of integer categories; ``fit`` is - a :class:`PolytomousFit` from :func:`fit_polytomous`. ``NaN`` marks a - missing response. The posterior mean and standard deviation are evaluated - on a standard-normal quadrature grid (Bock & Mislevy, 1982). Returns - ``{"theta_eap", "theta_sd"}``. - - References - ---------- - Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability in - a microcomputer environment. *Applied Psychological Measurement, 6*(4), - 431–444. https://doi.org/10.1177/014662168200600405 - """ - if ( - not isinstance(q_theta, int) - or isinstance(q_theta, bool) - or q_theta not in {7, 11, 15, 21, 31, 41} - ): - raise ValueError("q_theta must be one of 7, 11, 15, 21, 31, 41") - - slope = np.asarray(fit.slope, dtype=np.float64) - cat_params = np.asarray(fit.cat_params, dtype=np.float64) - if slope.ndim != 1 or slope.size == 0: - raise ValueError("fit.slope must be a non-empty 1-D array") - if ( - cat_params.ndim != 2 - or cat_params.shape[0] != slope.size - or cat_params.shape[1] < 1 - ): - raise ValueError("fit.cat_params must be n_items x (n_cat - 1)") - if not np.all(np.isfinite(slope)) or not np.all(np.isfinite(cat_params)): - raise ValueError("fit item parameters must be finite") - model = str(fit.model).lower() - if model not in VALID_POLY_MODELS: - raise ValueError(f"fit.model must be one of {sorted(VALID_POLY_MODELS)}") - - n_items = slope.shape[0] - n_cat = cat_params.shape[1] + 1 - y_int, observed = _poly_int_and_mask(responses, n_cat) - if y_int.shape[1] != n_items: - raise ValueError("responses column count must match the fitted item count") - - core = _core_module() - if core is None or not hasattr(core, "score_poly_eap"): - raise RuntimeError("score_polytomous requires the compiled Rust core") - - n_persons = y_int.shape[0] - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.score_poly_eap( - y_int.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - slope, - cat_params.reshape(-1), - obs_arg, - model, - q_theta, - ) - return { - "theta_eap": np.asarray(res["theta_eap"], dtype=np.float64), - "theta_sd": np.asarray(res["theta_sd"], dtype=np.float64), - } - - -def information_polytomous( - fit: PolytomousFit, - theta: np.ndarray, -) -> dict[str, np.ndarray]: - """Item and test information curves for a fitted polytomous model (compute - in Rust). ``theta`` is a 1-D grid of trait values. Returns - ``{"item_info"` (n_theta x n_items), ``"test_info"`` (n_theta)}``. The - model-specific information functions follow Samejima (1969) for the GRM - and Muraki (1993) for the GPCM. - - References - ---------- - Muraki, E. (1993). Information functions of the generalized partial credit - model. *Applied Psychological Measurement, 17*(4), 351–363. - https://doi.org/10.1177/014662169301700403 - - Samejima, F. (1969). Estimation of latent ability using a response pattern - of graded scores. *Psychometrika, 34*(S1), 1–97. - https://doi.org/10.1007/BF03372160 - """ - th = np.asarray(theta, dtype=np.float64) - if th.ndim != 1 or th.size == 0 or not np.all(np.isfinite(th)): - raise ValueError("theta must be a non-empty finite 1-D grid") - slope = np.asarray(fit.slope, dtype=np.float64) - cat_params = np.asarray(fit.cat_params, dtype=np.float64) - if slope.ndim != 1 or slope.size == 0: - raise ValueError("fit.slope must be a non-empty 1-D array") - if ( - cat_params.ndim != 2 - or cat_params.shape[0] != slope.size - or cat_params.shape[1] < 1 - ): - raise ValueError("fit.cat_params must be n_items x (n_cat - 1)") - if not np.all(np.isfinite(slope)) or not np.all(np.isfinite(cat_params)): - raise ValueError("fit item parameters must be finite") - model = str(fit.model).lower() - if model not in VALID_POLY_MODELS: - raise ValueError(f"fit.model must be one of {sorted(VALID_POLY_MODELS)}") - core = _core_module() - if core is None or not hasattr(core, "poly_information_curves"): - raise RuntimeError("information_polytomous requires the compiled Rust core") - - n_items = slope.shape[0] - n_cat = cat_params.shape[1] + 1 - flat = core.poly_information_curves( - th, - slope, - cat_params.reshape(-1), - int(n_items), - int(n_cat), - model, - ) - item_info = np.asarray(flat, dtype=np.float64).reshape(th.size, n_items) - return {"item_info": item_info, "test_info": item_info.sum(axis=1)} - - -@dataclass -class PolyLsirmFit: - """Result of :func:`fit_lsirm_polytomous` — a latent-space polytomous LSIRM. - - ``slope``/``cat_params`` are the item parameters; ``zeta`` is the - ``n_items x latent_dim`` item interaction-map positions (identified up to - rotation/reflection/translation — compare via distances). ``theta_eap`` / - ``theta_sd`` are per-person EAP trait scores and SDs; ``xi_eap`` is the - ``n_persons x latent_dim`` person positions. - """ - - model: str - slope: np.ndarray - cat_params: np.ndarray - zeta: np.ndarray - theta_eap: np.ndarray - theta_sd: np.ndarray - xi_eap: np.ndarray - loglik: float - n_iter: int - - -def fit_lsirm_polytomous( - responses: np.ndarray, - n_cat: int, - latent_dim: int = 2, - model: str = "grm", - q_theta: int = 11, - q_xi: int = 11, - max_iter: int = 60, - tol: float = 1e-5, -) -> PolyLsirmFit: - """Fit a latent-space polytomous LSIRM (GRM/GPCM cell in an interaction map) - by marginal EM — all compute in the Rust core (``poly_marginal``). The - distance weight is fixed to 1 as this crate's scale-identification choice; - positions are identified up to rotation/reflection/translation. ``NaN`` marks - missing. - ``n_cat`` is limited to 2..64 and ``max_iter`` to 1..100,000. - """ - m = str(model).lower() - if m not in VALID_POLY_MODELS: - raise ValueError(f"model must be one of {sorted(VALID_POLY_MODELS)}") - if ( - not isinstance(n_cat, (int, np.integer)) - or isinstance(n_cat, (bool, np.bool_)) - or not 2 <= int(n_cat) <= MAX_POLYTOMOUS_CATEGORIES - ): - raise ValueError(f"n_cat must be an integer between 2 and {MAX_POLYTOMOUS_CATEGORIES}") - if not isinstance(latent_dim, int) or not (1 <= latent_dim <= 3): - raise ValueError("latent_dim must be an integer in 1..3") - if q_theta not in {7, 11, 15, 21, 31, 41} or q_xi not in {7, 11, 15, 21, 31, 41}: - raise ValueError("q_theta/q_xi must be one of 7, 11, 15, 21, 31, 41") - if ( - not isinstance(max_iter, (int, np.integer)) - or isinstance(max_iter, (bool, np.bool_)) - or not 1 <= int(max_iter) <= MAX_MAX_ITER - ): - raise ValueError(f"max_iter must be an integer between 1 and {MAX_MAX_ITER}") - if not np.isfinite(tol) or tol <= 0: - raise ValueError("tol must be finite and > 0") - - y_int, observed = _poly_int_and_mask(responses, n_cat) - core = _core_module() - if core is None or not hasattr(core, "fit_poly_lsirm"): - raise RuntimeError("fit_lsirm_polytomous requires the compiled Rust core") - - n_persons, n_items = y_int.shape - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.fit_poly_lsirm( - y_int.reshape(-1), int(n_persons), int(n_items), int(n_cat), int(latent_dim), - obs_arg, m, int(q_theta), int(q_xi), int(max_iter), float(tol), - ) - return PolyLsirmFit( - model=m, - slope=np.asarray(res["slope"], dtype=np.float64), - cat_params=np.asarray(res["cat_params"], dtype=np.float64), - zeta=np.asarray(res["zeta"], dtype=np.float64).reshape(n_items, latent_dim), - theta_eap=np.asarray(res["theta_eap"], dtype=np.float64), - theta_sd=np.asarray(res["theta_sd"], dtype=np.float64), - xi_eap=np.asarray(res["xi_eap"], dtype=np.float64).reshape(n_persons, latent_dim), - loglik=float(res["loglik"]), - n_iter=int(res["n_iter"]), - ) - - -def polytomous_information_criteria(fit, n_persons: int) -> dict[str, float]: - """Return relative model-selection indices for a polytomous fit. - - Information criteria have been studied for selecting among polytomous IRT - models (Kang et al., 2009). Given a fitted :class:`PolytomousFit` or - :class:`PolyLsirmFit` and the calibration sample size, this repository - applies the conventional ``AIC``, ``BIC``, ``CAIC``, ``AICc``, and - sample-size-adjusted ``SABIC`` formulas to the fitted marginal likelihood. - All five indices use "smaller is better" comparisons. - - The parameter count is read from the fitted arrays: ``slope`` + - ``cat_params`` (+ item positions ``zeta`` for the latent-space model). - ``AICc`` is returned as ``NaN`` when ``n_persons <= n_parameters + 1`` - because its finite-sample correction denominator is then non-positive. - - References (APA 7th ed.): - Akaike, H. (1974). A new look at the statistical model identification. - *IEEE Transactions on Automatic Control, 19*(6), 716-723. - https://doi.org/10.1109/TAC.1974.1100705 - Bozdogan, H. (1987). Model selection and Akaike's information criterion - (AIC): The general theory and its analytical extensions. - *Psychometrika, 52*(3), 345-370. - https://doi.org/10.1007/BF02294361 - Hurvich, C. M., & Tsai, C.-L. (1989). Regression and time series model - selection in small samples. *Biometrika, 76*(2), 297-307. - https://doi.org/10.1093/biomet/76.2.297 - Kang, T., Cohen, A. S., & Sung, H.-J. (2009). Model selection indices for - polytomous items. *Applied Psychological Measurement, 33*(7), 499-518. - https://doi.org/10.1177/0146621608327800 - Schwarz, G. (1978). Estimating the dimension of a model. *The Annals of - Statistics, 6*(2), 461-464. https://doi.org/10.1214/aos/1176344136 - Sclove, S. L. (1987). Application of model-selection criteria to some - problems in multivariate analysis. *Psychometrika, 52*(3), 333-343. - https://doi.org/10.1007/BF02294360 - """ - if not isinstance(n_persons, int) or n_persons < 2: - raise ValueError("n_persons must be an integer >= 2") - k = int(np.asarray(fit.slope).size + np.asarray(fit.cat_params).size) - zeta = getattr(fit, "zeta", None) - if zeta is not None: - k += int(np.asarray(zeta).size) - ll = float(fit.loglik) - n = int(n_persons) - m2ll = -2.0 * ll - aic = m2ll + 2.0 * k - bic = m2ll + k * np.log(n) - caic = m2ll + k * (np.log(n) + 1.0) - aicc = ( - aic + (2.0 * k * (k + 1.0)) / (n - k - 1) - if n > k + 1 - else np.nan - ) - sabic = m2ll + k * np.log((n + 2.0) / 24.0) - return { - "n_parameters": k, - "aic": float(aic), - "bic": float(bic), - "caic": float(caic), - "aicc": float(aicc), - "sabic": float(sabic), - } - - -def item_fit_polytomous( - responses: np.ndarray, - fit: PolytomousFit, - q_theta: int = 21, - min_expected: float = 1.0, -) -> dict[str, np.ndarray]: - """Generalized S-X² item-fit statistic for an ordered polytomous fit - (compute in Rust). Groups persons by summed score, compares observed to - model-expected category proportions formed from the generalized - Lord-Wingersky recursion, and returns per-item ``statistic``, ``df``, - ``p_value``, and ``n_cells`` (the retained cell count, the reference df at - known parameters). ``responses`` is persons x items of integer categories - with ``NaN`` for missing; only persons complete on every item enter the - summed-score table. At ``n_cat = 2`` this equals the binary Orlando-Thissen - S-X². ``min_expected`` is the minimum expected cell frequency below which - adjacent categories are collapsed. - - References (APA 7th ed.): - Kang, T., & Chen, T. T. (2008). Performance of the generalized S-X² - item fit index for polytomous IRT models. *Journal of Educational - Measurement, 45*(4), 391-406. - https://doi.org/10.1111/j.1745-3984.2008.00070.x - Kang, T., & Chen, T. T. (2011). Performance of the generalized S-X² - item fit index for the graded response model. *Asia Pacific - Education Review, 12*(1), 89-96. - https://doi.org/10.1007/s12564-010-9082-4 - Orlando, M., & Thissen, D. (2000). Likelihood-based item-fit indices for - dichotomous item response theory models. *Applied Psychological - Measurement, 24*(1), 50-64. - https://doi.org/10.1177/01466216000241003 - """ - n_items = fit.slope.shape[0] - n_cat = fit.cat_params.shape[1] + 1 - q_theta = _quadrature_points(q_theta) - if not np.isfinite(min_expected) or min_expected <= 0: - raise ValueError("min_expected must be positive") - y_int, observed = _poly_int_and_mask(responses, n_cat) - if y_int.shape[1] != n_items: - raise ValueError("responses column count must match the fitted item count") - - core = _core_module() - if core is None or not hasattr(core, "poly_item_fit_sx2"): - raise RuntimeError("item_fit_polytomous requires the compiled Rust core") - - n_persons = y_int.shape[0] - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.poly_item_fit_sx2( - y_int.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - fit.slope.astype(np.float64), - fit.cat_params.reshape(-1).astype(np.float64), - obs_arg, - fit.model, - int(q_theta), - float(min_expected), - ) - return { - "statistic": np.asarray(res["statistic"], dtype=np.float64), - "df": np.asarray(res["df"], dtype=np.float64), - "p_value": np.asarray(res["p_value"], dtype=np.float64), - "n_cells": np.asarray(res["n_cells"], dtype=np.int64), - } - - -def m2_polytomous( - responses: np.ndarray, - fit: PolytomousFit, - q_theta: int = 21, -) -> dict[str, float]: - """Polytomous M2 limited-information goodness-of-fit for a fitted GRM/GPCM - (compute in Rust). Extends the binary M2 to ordered categories via the - cumulative marginals ``P(Y_i >= c)`` and ``P(Y_i >= c, Y_j >= d)``; equals - the binary M2 at ``n_cat = 2``. ``responses`` is persons x items of integer - categories with ``NaN`` for missing (complete cases only enter the - statistic). Returns ``m2``, ``df``, ``p_value``, ``rmsea2`` and its 90% - interval (``rmsea2_ci_lower``/``rmsea2_ci_upper``), ``srmsr``, and - ``cfi``/``tli`` from a complete-independence M2 baseline (``null_m2`` and - ``null_df``), plus the ``n_moments``/``n_parameters``/``n_complete`` - counts. Requires at least 3 items and ``n_moments > n_parameters``. A fit - carrying a known non-converged status is rejected because the reference - distribution and derived fit indices require a completed calibration. - - References (APA 7th ed.): - Cai, L., Chung, S. W., & Lee, T. (2023). Incremental model fit assessment - in the case of categorical data: Tucker–Lewis index for item response - theory modeling. *Prevention Science, 24*(3), 455–466. - https://doi.org/10.1007/s11121-021-01253-4 - - Maydeu-Olivares, A., & Joe, H. (2014). Assessing approximate fit in - categorical data analysis. *Multivariate Behavioral Research, - 49*(4), 305-328. https://doi.org/10.1080/00273171.2014.911075 - """ - q_theta = _quadrature_points(q_theta) - if hasattr(fit, "converged") and not bool(fit.converged): - reason = getattr(fit, "termination_reason", "unknown") - n_iter = getattr(fit, "n_iter", "unknown") - final_delta = getattr(fit, "final_delta", float("nan")) - stopping_tolerance = getattr(fit, "stopping_tolerance", float("nan")) - raise RuntimeError( - "m2_polytomous requires a converged fit; " - f"termination_reason={reason}, n_iter={n_iter}, " - f"final_delta={final_delta}, stopping_tolerance={stopping_tolerance}" - ) - - n_items = fit.slope.shape[0] - n_cat = fit.cat_params.shape[1] + 1 - y_int, observed = _poly_int_and_mask(responses, n_cat) - if y_int.shape[1] != n_items: - raise ValueError("responses column count must match the fitted item count") - - core = _core_module() - if core is None or not hasattr(core, "poly_m2"): - raise RuntimeError("m2_polytomous requires the compiled Rust core") - - n_persons = y_int.shape[0] - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.poly_m2( - y_int.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - fit.slope.astype(np.float64), - fit.cat_params.reshape(-1).astype(np.float64), - obs_arg, - fit.model, - int(q_theta), - ) - return {k: float(v) if k not in ("n_moments", "n_parameters", "n_complete") - else int(v) for k, v in res.items()} - - -def local_dependence_polytomous( - responses: np.ndarray, - fit: PolytomousFit, - q_theta: int = 21, -) -> dict[str, np.ndarray]: - """Item-pair local-dependence diagnostics for a fitted GRM/GPCM (compute in - Rust; Chen & Thissen, 1997). For every item pair it compares the observed - ``K x K`` contingency table against the model-implied joint under local - independence and returns per-pair arrays: ``item_i``/``item_j`` (the pair), - ``x2`` (Pearson) and ``g2`` (likelihood-ratio) statistics, ``p_value`` on - ``chi2(df)`` with the shared ``df = (n_cat - 1) ** 2``, ``cramers_v`` effect - size, ``max_abs_std_resid``, and ``n_pair`` (pairwise-complete sample size). - A large ``x2``/``cramers_v`` on a pair flags residual association beyond the - fitted trait (a local-dependence violation). ``responses`` is persons x - items of integer categories with ``NaN`` for missing. The reference is - heuristic and slightly conservative (Liu & Maydeu-Olivares, 2013), so read - it as a diagnostic screen. - - References (APA 7th ed.): - Chen, W.-H., & Thissen, D. (1997). Local dependence indexes for item - pairs using item response theory. *Journal of Educational and - Behavioral Statistics, 22*(3), 265-289. - https://doi.org/10.3102/10769986022003265 - Liu, Y., & Maydeu-Olivares, A. (2013). Local dependence diagnostics in - IRT modeling of binary data. *Educational and Psychological - Measurement, 73*(2), 254-274. - https://doi.org/10.1177/0013164412453841 - """ - n_items = fit.slope.shape[0] - n_cat = fit.cat_params.shape[1] + 1 - q_theta = _quadrature_points(q_theta) - y_int, observed = _poly_int_and_mask(responses, n_cat) - if y_int.shape[1] != n_items: - raise ValueError("responses column count must match the fitted item count") - - core = _core_module() - if core is None or not hasattr(core, "poly_local_dependence"): - raise RuntimeError("local_dependence_polytomous requires the compiled Rust core") - - n_persons = y_int.shape[0] - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.poly_local_dependence( - y_int.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - fit.slope.astype(np.float64), - fit.cat_params.reshape(-1).astype(np.float64), - obs_arg, - fit.model, - int(q_theta), - ) - return { - "item_i": np.asarray(res["item_i"], dtype=np.int64), - "item_j": np.asarray(res["item_j"], dtype=np.int64), - "x2": np.asarray(res["x2"], dtype=np.float64), - "g2": np.asarray(res["g2"], dtype=np.float64), - "df": float(res["df"]), - "p_value": np.asarray(res["p_value"], dtype=np.float64), - "cramers_v": np.asarray(res["cramers_v"], dtype=np.float64), - "max_abs_std_resid": np.asarray(res["max_abs_std_resid"], dtype=np.float64), - "n_pair": np.asarray(res["n_pair"], dtype=np.int64), - } - - -@dataclass -class NominalFit: - """Result of :func:`fit_nominal_polytomous`. ``scores`` and ``intercepts`` - are each ``n_items x (n_cat - 1)``: the free category scoring values - ``a_{i,1}..a_{i,K-1}`` and intercepts ``c_{i,1}..c_{i,K-1}`` of the nominal - model ``P(Y=k|theta) = softmax_k(a_k*theta + c_k)`` (baseline - ``a_0 = c_0 = 0``). Parameters are identified up to the reflection - ``(a_k, theta) -> (-a_k, -theta)``. - """ - - scores: np.ndarray - intercepts: np.ndarray - loglik: float - n_iter: int - converged: bool = False - termination_reason: str = "not_fitted" - loglik_trace: np.ndarray = field( - default_factory=lambda: np.empty(0, dtype=np.float64) - ) - final_delta: float = np.nan - stopping_tolerance: float = np.nan - - -def fit_nominal_polytomous( - responses: np.ndarray, - n_cat: int, - q_theta: int = 21, - max_iter: int = 200, - tol: float = 1e-6, -) -> NominalFit: - """Fit the unidimensional nominal categories model by marginal MLE (compute - in Rust; Bock, 1972; Thissen, Cai & Bock, 2010). Each item has a free scoring - function ``a_k`` and intercept ``c_k`` per category, - ``P(Y=k|theta) = softmax_k(a_k*theta + c_k)``, identified by ``a_0=c_0=0`` - with ``theta ~ N(0,1)``. The generalized partial credit model is the special - case ``a_k = a*k``, so the nominal model nests it. ``responses`` is persons x - items of integer categories ``0..n_cat-1``; ``NaN`` marks a missing response. - As a repository-level convergence contract, the returned trace evaluates the - observed-data log-likelihood at every returned parameter state; - ``converged=False`` with ``termination_reason="max_iter"`` distinguishes an - exhausted iteration budget from tolerance-based convergence. - - References (APA 7th ed.): - Bock, R. D. (1972). Estimating item parameters and latent ability when - responses are scored in two or more nominal categories. - *Psychometrika, 37*(1), 29–51. https://doi.org/10.1007/BF02291411 - Thissen, D., Cai, L., & Bock, R. D. (2010). The nominal categories item - response model. In *Handbook of polytomous item response theory - models* (pp. 43-75). Routledge. - """ - if ( - not isinstance(n_cat, (int, np.integer)) - or isinstance(n_cat, (bool, np.bool_)) - or not 2 <= int(n_cat) <= MAX_POLYTOMOUS_CATEGORIES - ): - raise ValueError(f"n_cat must be an integer between 2 and {MAX_POLYTOMOUS_CATEGORIES}") - if q_theta not in {7, 11, 15, 21, 31, 41}: - raise ValueError("q_theta must be one of 7, 11, 15, 21, 31, 41") - if ( - isinstance(max_iter, bool) - or not isinstance(max_iter, (int, np.integer)) - or not 1 <= int(max_iter) <= MAX_MAX_ITER - ): - raise ValueError(f"max_iter must be an integer between 1 and {MAX_MAX_ITER}") - if not np.isfinite(tol) or tol <= 0: - raise ValueError("tol must be finite and > 0") - - y_int, observed = _poly_int_and_mask(responses, n_cat) - if y_int.shape[0] == 0 or y_int.shape[1] == 0: - raise ValueError("responses must contain at least one person and one item") - missing_items = np.flatnonzero(~observed.any(axis=0)) - if missing_items.size: - raise ValueError(f"items with no observed responses: {missing_items.tolist()}") - core = _core_module() - if core is None or not hasattr(core, "fit_nominal"): - raise RuntimeError("fit_nominal_polytomous requires the compiled Rust core") - - n_persons, n_items = y_int.shape - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.fit_nominal( - y_int.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - obs_arg, - int(q_theta), - int(max_iter), - float(tol), - ) - return NominalFit( - scores=np.asarray(res["scores"], dtype=np.float64), - intercepts=np.asarray(res["intercepts"], dtype=np.float64), - loglik=float(res["loglik"]), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - final_delta=float(res["final_delta"]), - stopping_tolerance=float(res["stopping_tolerance"]), - ) - - -def person_fit_polytomous( - responses: np.ndarray, - fit: PolytomousFit, - q_theta: int = 21, - prior_mean: float = 0.0, - prior_sd: float = 1.0, - flag_threshold: float = -1.645, -) -> dict[str, np.ndarray]: - """Person-fit statistics for polytomous responses under a fitted GRM/GPCM - (compute in Rust). Returns the standardized log-likelihood ``lz`` (Drasgow, - Levine & Williams, 1985) and its estimated-trait correction ``lz_star`` - (Snijders, 2001) at the EAP trait, plus ``theta_eap`` and a boolean - ``flagged`` (``lz_star < flag_threshold``, i.e. an aberrant / misfitting - response pattern). ``responses`` is persons x items of integer categories - with ``NaN`` for missing; ``prior_mean``/``prior_sd`` set the MAP prior used - in the Snijders correction. Reduces to the binary l_z at ``n_cat = 2``. Low - (negative) values indicate poor person fit. - - References (APA 7th ed.): - Drasgow, F., Levine, M. V., & Williams, E. A. (1985). Appropriateness - measurement with polychotomous item response models and standardized - indices. *British Journal of Mathematical and Statistical - Psychology, 38*(1), 67-86. - https://doi.org/10.1111/j.2044-8317.1985.tb00817.x - Snijders, T. A. B. (2001). Asymptotic null distribution of person fit - statistics with estimated person parameter. *Psychometrika, 66*(3), - 331-342. https://doi.org/10.1007/BF02294437 - """ - n_items = fit.slope.shape[0] - n_cat = fit.cat_params.shape[1] + 1 - q_theta = _quadrature_points(q_theta) - if not np.isfinite(prior_mean): - raise ValueError("prior_mean must be finite") - if not np.isfinite(prior_sd) or prior_sd <= 0: - raise ValueError("prior_sd must be finite and > 0") - if not np.isfinite(flag_threshold): - raise ValueError("flag_threshold must be finite") - y_int, observed = _poly_int_and_mask(responses, n_cat) - if y_int.shape[1] != n_items: - raise ValueError("responses column count must match the fitted item count") - - core = _core_module() - if core is None or not hasattr(core, "poly_person_fit"): - raise RuntimeError("person_fit_polytomous requires the compiled Rust core") - - n_persons = y_int.shape[0] - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.poly_person_fit( - y_int.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - fit.slope.astype(np.float64), - fit.cat_params.reshape(-1).astype(np.float64), - obs_arg, - fit.model, - int(q_theta), - float(prior_mean), - float(prior_sd), - float(flag_threshold), - ) - return { - "lz": np.asarray(res["lz"], dtype=np.float64), - "lz_star": np.asarray(res["lz_star"], dtype=np.float64), - "theta_eap": np.asarray(res["theta_eap"], dtype=np.float64), - "flagged": np.asarray(res["flagged"], dtype=bool), - } - - -def cat_simulate_polytomous( - true_theta: np.ndarray, - fit: PolytomousFit, - q_theta: int = 21, - se_threshold: float = 0.3, - min_items: int = 5, - max_items: int = 30, - adaptive: bool = True, - seed: int = 0, -) -> dict[str, np.ndarray]: - """Simulate a polytomous computerized adaptive test over a fitted GRM/GPCM - item bank (compute in Rust; Dodd, De Ayala & Koch, 1995). For each true trait - in ``true_theta`` it selects items by maximum Fisher information at the - running EAP estimate (or at random when ``adaptive=False``), generates the - response at the true trait, and re-estimates the trait after each item, - stopping once at least ``min_items`` are given and the posterior SD is below - ``se_threshold`` (or at ``max_items``; set ``se_threshold=0`` with - ``min_items == max_items`` for a fixed-length CAT). Returns per-simulee - ``theta_eap``, ``theta_sd`` (the final CAT standard error), and ``n_used``. - - References (APA 7th ed.): - Dodd, B. G., De Ayala, R. J., & Koch, W. R. (1995). Computerized - adaptive testing with polytomous items. *Applied Psychological - Measurement, 19*(1), 5-22. - https://doi.org/10.1177/014662169501900103 - """ - n_items = fit.slope.shape[0] - n_cat = fit.cat_params.shape[1] + 1 - tt = np.asarray(true_theta, dtype=np.float64).ravel() - if tt.size == 0 or not np.all(np.isfinite(tt)): - raise ValueError("true_theta must be a non-empty finite 1-D array") - q_theta = _quadrature_points(q_theta) - min_items = _bounded_integer(min_items, "min_items", 1, MAX_POLY_CAT_ITEMS) - max_items = _bounded_integer(max_items, "max_items", min_items, MAX_POLY_CAT_ITEMS) - effective_max_items = min(max_items, n_items) - if min_items > effective_max_items: - raise ValueError("min_items must not exceed the fitted item-bank size") - if not np.isfinite(se_threshold) or se_threshold < 0: - raise ValueError("se_threshold must be finite and >= 0") - if tt.size > MAX_SIM_PERSONS or tt.size * effective_max_items > MAX_SIM_CELLS: - raise ValueError("polytomous CAT simulation exceeds the aggregate work limit") - - core = _core_module() - if core is None or not hasattr(core, "poly_cat_simulate"): - raise RuntimeError("cat_simulate_polytomous requires the compiled Rust core") - - res = core.poly_cat_simulate( - tt, - fit.slope.astype(np.float64), - fit.cat_params.reshape(-1).astype(np.float64), - int(n_items), - int(n_cat), - fit.model, - int(q_theta), - float(se_threshold), - int(min_items), - int(max_items), - bool(adaptive), - int(seed), - ) - return { - "theta_eap": np.asarray(res["theta_eap"], dtype=np.float64), - "theta_sd": np.asarray(res["theta_sd"], dtype=np.float64), - "n_used": np.asarray(res["n_used"], dtype=np.int64), - } - - -def dif_polytomous( - responses: np.ndarray, - group_id: np.ndarray, - n_cat: int, - model: str = "gpcm", - studied_items: np.ndarray | None = None, - q_theta: int = 21, - max_iter: int = 200, - tol: float = 1e-5, - fdr_q: float = 0.05, -) -> dict[str, np.ndarray]: - """Likelihood-ratio DIF sweep for polytomous items via a two-group marginal-EM - fit (compute in Rust; Thissen, Steinberg & Wainer, 1993). Group 0 is the - reference (latent ``N(0, 1)``); each other group's latent ``N(mu_g, - sigma_g^2)`` is estimated, so genuine ability differences between groups - (impact) are absorbed rather than mistaken for DIF. It fits the *compact* - model (all items group-invariant) once, then per studied item the *augmented* - model (that item's parameters freed per group) with every other item as the - anchor; ``LR = 2 * (loglik_aug - loglik_compact)`` is referred to - ``chi2((n_groups - 1) * n_cat)``. Returns per-item arrays: ``item`` (index), - ``lr``, ``df``, ``p_value``, ``flagged_bh`` (Benjamini-Hochberg FDR at - ``fdr_q``), and ``effect_size`` (the unsigned across-group range of the item's - mean category location -- a DIF magnitude >= 0, monotone in uniform DIF, not a - direction). If an item's augmented fit fails to converge (e.g. GRM thresholds - disorder on a sparse focal category) its ``lr``/``p_value``/``effect_size`` are - ``NaN`` and it is left unflagged rather than silently reported as clean. - - ``responses`` is persons x items of integer categories (``NaN`` = missing); - ``group_id`` is a length-persons integer array of group labels (any - non-negative integers; densified internally, so non-contiguous or 1-based - codes are fine). - ``studied_items`` limits the sweep to those column indices (default: all - items). ``model`` is ``"grm"`` or ``"gpcm"``; GPCM is recommended when focal - groups have sparse extreme categories (GRM thresholds can become disordered - on a rarely used category). This is the parametric IRT-LR approach; for an - observed-score alternative that needs no multi-group calibration see the - ordinal-logistic DIF of Zumbo (1999). - - References (APA 7th ed.): - Thissen, D., Steinberg, L., & Wainer, H. (1993). Detection of - differential item functioning using the parameters of item response - models. In P. W. Holland & H. Wainer (Eds.), *Differential item - functioning* (pp. 67-113). Erlbaum. - Woehr, D. J., & Meriac, J. P. (2010). Using polytomous item response - theory to examine differential item and test functioning: The case - of work ethic. In J. A. Harkness, M. Braun, B. Edwards, T. P. - Johnson, L. E. Lyberg, P. P. Mohler, B.-E. Pennell, & T. W. Smith - (Eds.), *Survey methods in multinational, multiregional, and - multicultural contexts* (pp. 419-433). Wiley. - https://doi.org/10.1002/9780470609927.ch22 - """ - if ( - not isinstance(n_cat, (int, np.integer)) - or isinstance(n_cat, (bool, np.bool_)) - or not 2 <= int(n_cat) <= MAX_POLYTOMOUS_CATEGORIES - ): - raise ValueError(f"n_cat must be an integer between 2 and {MAX_POLYTOMOUS_CATEGORIES}") - m = str(model).lower() - if m not in VALID_POLY_MODELS: - raise ValueError(f"model must be one of {sorted(VALID_POLY_MODELS)}") - if ( - not isinstance(q_theta, (int, np.integer)) - or isinstance(q_theta, (bool, np.bool_)) - or q_theta not in {7, 11, 15, 21, 31, 41} - ): - raise ValueError("q_theta must be one of 7, 11, 15, 21, 31, 41") - if ( - not isinstance(max_iter, (int, np.integer)) - or isinstance(max_iter, (bool, np.bool_)) - or not 1 <= int(max_iter) <= MAX_MAX_ITER - ): - raise ValueError(f"max_iter must be an integer between 1 and {MAX_MAX_ITER}") - if not np.isfinite(tol) or tol <= 0: - raise ValueError("tol must be finite and > 0") - if not np.isfinite(fdr_q) or not 0 < fdr_q <= 1: - raise ValueError("fdr_q must be finite and in (0, 1]") - - y_int, observed = _poly_int_and_mask(responses, int(n_cat)) - n_persons, n_items = y_int.shape - if n_persons == 0 or n_items == 0: - raise ValueError("responses must contain at least one person and one item") - gid_raw = _nonnegative_integer_vector(group_id, "group_id") - if gid_raw.shape[0] != n_persons: - raise ValueError("group_id length must match the number of persons") - # Densify labels so n_groups equals the number of *populated* groups and the - # LR test's df = (n_groups - 1) * n_cat counts only groups backed by data. - # Without this, sparse/non-contiguous labels (e.g. {0, 2} after filtering, or - # 1-based codes) would leave phantom empty groups that inflate df and make the - # test conservative. np.unique sorts, so the smallest label stays group 0 - # (the pinned N(0,1) reference). - uniq, gid = np.unique(gid_raw, return_inverse=True) - gid = gid.astype(np.int64) - n_groups = uniq.size - if n_groups < 2: - raise ValueError("DIF requires at least two groups") - - core = _core_module() - if core is None or not hasattr(core, "poly_dif"): - raise RuntimeError("dif_polytomous requires the compiled Rust core") - - studied_arg = None - if studied_items is not None: - studied_arg = _nonnegative_integer_vector(studied_items, "studied_items") - if np.any(studied_arg >= n_items): - raise ValueError("studied_items entries must be valid item indices") - if np.unique(studied_arg).size != studied_arg.size: - raise ValueError("studied_items must not contain duplicates") - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.poly_dif( - y_int.reshape(-1), - gid, - int(n_groups), - int(n_persons), - int(n_items), - int(n_cat), - obs_arg, - m, - studied_arg, - int(q_theta), - int(max_iter), - float(tol), - float(fdr_q), - ) - return { - "item": np.asarray(res["item"], dtype=np.int64), - "lr": np.asarray(res["lr"], dtype=np.float64), - "df": np.asarray(res["df"], dtype=np.int64), - "p_value": np.asarray(res["p_value"], dtype=np.float64), - "flagged_bh": np.asarray(res["flagged_bh"], dtype=bool), - "effect_size": np.asarray(res["effect_size"], dtype=np.float64), - } - - -def u3_person_fit_polytomous( - responses: np.ndarray, - n_cat: int, - cutoff: float | None = None, -) -> dict[str, np.ndarray]: - """Nonparametric polytomous person-fit U3poly (compute in Rust; Emons, 2008), - van der Flier's (1982) dichotomous U3 generalized to ordered polytomous items. - It needs NO fitted IRT model: each item-step response function ``P(Y_i >= m)`` - is estimated by its sample proportion and turned into a logit weight, and a - person's observed weighted score is compared to the largest and smallest - weighted scores attainable at that person's total score (the conditioning - group). Returns per-person ``u3poly`` in ``[0, 1]`` (0 = perfectly - popularity-consistent, 1 = maximally aberrant; ``NaN`` where undefined), - ``total_score`` (the summed ordinal score over observed items), and - ``flagged`` (``u3poly >= cutoff``; all ``False`` when ``cutoff is None``). - - ``responses`` is persons x items of integer categories with ``NaN`` for - missing (marginalized per person). Items must be keyed so a higher category - means more of the trait -- recode reverse-keyed items first. U3poly has no - reliable analytic null, so a critical value should come from - :func:`u3_cutoff_polytomous` (a simulated reference), not a normal - approximation; and because a single pooled cutoff cannot fully condition on - the total score, treat flags near the score extremes cautiously. - - References (APA 7th ed.): - Emons, W. H. M. (2008). Nonparametric person-fit analysis of polytomous - item scores. *Applied Psychological Measurement, 32*(3), 224-247. - https://doi.org/10.1177/0146621607302479 - van der Flier, H. (1982). Deviant response patterns and comparability of - test scores. *Journal of Cross-Cultural Psychology, 13*(3), 267-298. - https://doi.org/10.1177/0022002182013003001 - """ - y_int, observed = _poly_int_and_mask(responses, n_cat) - n_persons, n_items = y_int.shape - core = _core_module() - if core is None or not hasattr(core, "u3_person_fit"): - raise RuntimeError("u3_person_fit_polytomous requires the compiled Rust core") - - obs_arg = None if observed.all() else observed.reshape(-1) - res = core.u3_person_fit( - y_int.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - obs_arg, - None if cutoff is None else float(cutoff), - ) - return { - "u3poly": np.asarray(res["u3poly"], dtype=np.float64), - "total_score": np.asarray(res["total_score"], dtype=np.int64), - "flagged": np.asarray(res["flagged"], dtype=bool), - } - - -def u3_cutoff_polytomous( - fit: PolytomousFit, - n_persons: int, - alpha: float = 0.05, - n_rep: int = 200, - seed: int = 0, -) -> float: - """Simulated ``1 - alpha`` critical value for :func:`u3_person_fit_polytomous` - (compute in Rust; Emons, 2008, used simulated critical values). A parametric - bootstrap: ``n_rep`` complete datasets of ``n_persons`` x (fitted item count) - are generated from the fitted GRM/GPCM ``fit`` at ``theta ~ N(0, 1)``, and the - empirical ``1 - alpha`` quantile of the pooled U3poly is returned. Because the - null distribution depends on the latent distribution, this ``N(0, 1)`` cutoff - is appropriate only when that population assumption is reasonable; for a skewed - population, calibrate against a matching simulation. The replications are - complete (full-length) patterns, so the cutoff is calibrated for complete - responders only -- do not flag persons with substantial missing data against - it (their U3poly comes from a shorter, coarser null). - """ - n_items = fit.slope.shape[0] - n_cat = fit.cat_params.shape[1] + 1 - n_persons = _bounded_integer(n_persons, "n_persons", 1, MAX_SIM_PERSONS) - n_rep = _bounded_integer(n_rep, "n_rep", 1, MAX_POLY_BOOTSTRAP_REPLICATES) - if not np.isfinite(alpha) or not 0 < float(alpha) < 1: - raise ValueError("alpha must be finite and in (0, 1)") - if n_persons * n_items * n_rep > MAX_SIM_CELLS: - raise ValueError("U3 bootstrap exceeds the aggregate work limit") - core = _core_module() - if core is None or not hasattr(core, "u3_bootstrap_cutoff"): - raise RuntimeError("u3_cutoff_polytomous requires the compiled Rust core") - return float( - core.u3_bootstrap_cutoff( - int(n_persons), - int(n_items), - int(n_cat), - fit.slope.astype(np.float64), - fit.cat_params.reshape(-1).astype(np.float64), - fit.model, - float(alpha), - int(n_rep), - int(seed), - ) - ) diff --git a/python/fast_mlsirm/preprocessing.py b/python/fast_mlsirm/preprocessing.py deleted file mode 100644 index 1d4928936..000000000 --- a/python/fast_mlsirm/preprocessing.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Response preprocessing utilities. - -`irtree_expand` implements the mapping-matrix pseudo-item expansion described -by Jeon and De Boeck (2016): a categorical response decomposes into conditional -binary internal outcomes, and nodes off the unique path are missing by design. -Their generalized model permits richer node-specific structures than this -repository's estimator; this helper only performs the mapping and does not -claim to implement every model in the article. - -Reference (APA 7th ed.): - Jeon, M., & De Boeck, P. (2016). A generalized item response tree model - for psychological assessments. *Behavior Research Methods, 48*(3), - 1070–1085. https://doi.org/10.3758/s13428-015-0631-y -""" - -from __future__ import annotations - -import numpy as np - - -def irtree_expand( - responses: np.ndarray, - mapping: np.ndarray, - node_dims: np.ndarray | None = None, -) -> tuple[np.ndarray, np.ndarray]: - """Expand categorical responses into binary pseudo-items via a tree map. - - ``responses`` is persons x items with integer categories ``0..C-1`` (NaN = - missing). ``mapping`` is the nodes x categories tree matrix ``T`` with - entries 0/1/NaN: ``T[n, c]`` is the binary pseudo-response of node ``n`` - when category ``c`` was chosen, NaN when the node is off the path. - Returns ``(expanded, factor_id)``: persons x (items * nodes) pseudo-binary - matrix (NaN = off-path or missing) and its trait-dimension mapping — - node ``n`` of every item loads on dimension ``node_dims[n]`` (default: - dimension ``n``, one trait per tree node, the canonical IRTree structure). - """ - y = np.asarray(responses, dtype=float) - if y.ndim != 2: - raise ValueError("responses must be a persons x items matrix") - n_persons, n_items = y.shape - if n_persons == 0 or n_items == 0: - raise ValueError("responses must contain at least one person and one item") - if np.any(np.isinf(y)): - raise ValueError("responses must contain integer categories or NaN") - - t = np.asarray(mapping, dtype=float) - if t.ndim != 2: - raise ValueError("mapping must be nodes x categories") - n_nodes, n_cats = t.shape - if n_nodes == 0 or n_cats == 0: - raise ValueError("mapping must contain at least one node and one category") - if np.any(~(np.isnan(t) | (t == 0.0) | (t == 1.0))): - raise ValueError("mapping entries must be 0, 1, or NaN") - - # Bound the dense expansion so untrusted item/node counts cannot force a - # multi-GB allocation (Jeon-De Boeck expansion is (persons, items*nodes)). - # Byte budget (not a raw element count): the dense float64 output plus - # per-node temporaries dominate memory; 64 MiB covers realistic IRTrees - # (31k x 57 x a few nodes) while blocking allocation-DoS inputs. - MAX_EXPANDED_BYTES = 64 * 1024 * 1024 - if n_persons * n_items * n_nodes * 8 > MAX_EXPANDED_BYTES: - raise ValueError( - f"expanded matrix ({n_persons} x {n_items * n_nodes}) exceeds the " - f"{MAX_EXPANDED_BYTES}-byte limit" - ) - if np.any(np.all(np.isnan(t), axis=0)): - raise ValueError("every response category must have a non-empty tree path") - encoded_paths = np.where(np.isnan(t), -1.0, t).T - if np.unique(encoded_paths, axis=0).shape[0] != n_cats: - raise ValueError("response categories must have distinct tree paths") - for node in t: - node_values = node[np.isfinite(node)] - if not (np.any(node_values == 0.0) and np.any(node_values == 1.0)): - raise ValueError("every tree node must contain both binary branches") - - obs = ~np.isnan(y) - if obs.any(): - vals = y[obs] - if np.any(vals < 0) or np.any(vals >= n_cats) or np.any(vals != np.round(vals)): - raise ValueError(f"responses must be integer categories in 0..{n_cats - 1}") - expanded = np.full((n_persons, n_items * n_nodes), np.nan) - cat_idx = np.where(obs, y, 0).astype(int) - for n in range(n_nodes): - node_vals = t[n, cat_idx] # (P, I): 0/1/NaN by chosen category - node_vals = np.where(obs, node_vals, np.nan) - expanded[:, n * n_items : (n + 1) * n_items] = node_vals - if node_dims is None: - node_dims = np.arange(n_nodes) - node_dims_arr = np.asarray(node_dims) - if node_dims_arr.shape != (n_nodes,): - raise ValueError("node_dims must have one entry per tree node") - try: - nd = node_dims_arr.astype(np.float64) - except (TypeError, ValueError, OverflowError) as exc: - raise ValueError("node_dims must be finite non-negative integers") from exc - if ( - not np.all(np.isfinite(nd)) - or np.any(nd < 0) - or np.any(nd != np.floor(nd)) - or np.any(nd >= n_nodes) - ): - raise ValueError( - "node_dims must be integer dimension indices in 0..number of nodes-1" - ) - node_dims = nd.astype(np.int64) - unique_dims = np.unique(node_dims) - if not np.array_equal(unique_dims, np.arange(unique_dims.size)): - raise ValueError("node_dims must use contiguous dimension indices starting at 0") - factor_id = np.repeat(node_dims, n_items) - return expanded, factor_id diff --git a/python/fast_mlsirm/rasch_cml.py b/python/fast_mlsirm/rasch_cml.py deleted file mode 100644 index 2e3bb228d..000000000 --- a/python/fast_mlsirm/rasch_cml.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Rasch conditional maximum likelihood (CML) estimation and Andersen's (1973) LR test. - -Conditioning each response pattern on its raw score -- the sufficient statistic for ability -- removes -the person parameters, so the Rasch item difficulties are estimated without any assumption on the -ability distribution (specific objectivity) and consistently at fixed test length, unlike joint or -marginal ML. The numerical computation runs in Rust.""" - -from __future__ import annotations - -import numpy as np - - -def _binary_matrix(responses: np.ndarray) -> tuple[np.ndarray, int, int]: - y = np.asarray(responses) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_items < 2: - raise ValueError("need at least 2 items") - yf = np.asarray(y, dtype=np.float64) - if not np.all(np.isin(yf, (0.0, 1.0))): - raise ValueError("responses must be complete 0/1 (Rasch CML has no missing-data path)") - return yf.astype(np.int64).reshape(-1), n_persons, n_items - - -def fit_rasch_cml( - responses: np.ndarray, - max_iter: int = 100, - tol: float = 1e-8, -) -> dict[str, np.ndarray]: - """Fit the dichotomous Rasch model by conditional maximum likelihood (compute in Rust; Andersen, - 1970, 1972). - - ``responses`` is a persons x items complete ``0/1`` array; persons scoring ``0`` or ``n_items`` (no - conditional information) are dropped. Returns ``beta`` (the ``n_items`` item difficulties, centered - to sum zero), ``se`` (standard errors from the pseudoinverse of the conditional information), - ``loglik`` (conditional log-likelihood), ``n_iter``, ``converged``, and ``n_used`` (retained - persons). The estimates are person-distribution-free: they do not depend on the shape of the ability - distribution. - - References (APA 7th ed.): - Andersen, E. B. (1970). Asymptotic properties of conditional maximum-likelihood estimators. - *Journal of the Royal Statistical Society: Series B, 32*(2), 283-301. - Andersen, E. B. (1972). The numerical solution of a set of conditional estimation equations. - *Journal of the Royal Statistical Society: Series B, 34*(1), 42-54. - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_rasch_cml"): - raise RuntimeError("fit_rasch_cml requires the compiled Rust core") - yy, n_persons, n_items = _binary_matrix(responses) - if not np.isfinite(tol) or tol <= 0: - raise ValueError("tol must be finite and positive") - res = core.fit_rasch_cml(yy, int(n_persons), int(n_items), int(max_iter), float(tol)) - return { - "beta": np.asarray(res["beta"], dtype=np.float64), - "se": np.asarray(res["se"], dtype=np.float64), - "loglik": float(res["loglik"]), - "n_iter": int(res["n_iter"]), - "converged": bool(res["converged"]), - "n_used": int(res["n_used"]), - } - - -def andersen_lr_test( - responses: np.ndarray, - group: np.ndarray, - max_iter: int = 100, - tol: float = 1e-8, -) -> dict[str, float]: - """Andersen's (1973) conditional likelihood-ratio test of Rasch fit (compute in Rust). - - Partitions the persons by ``group`` (integer labels ``0..n_groups``), fits CML within each group and - over the pooled sample, and refers ``LR = 2[sum_g llc_g - llc_pooled]`` to - ``chi2((n_groups - 1)(n_items - 1))``. A significant ``LR`` rejects the invariance of the item - difficulties across the split (Rasch misfit); splitting on the raw-score median tests the model's - core sufficiency assumption, and splitting on an external covariate tests for DIF. ``responses`` is a - persons x items complete ``0/1`` array. Returns ``lr``, ``df``, ``p_value``, ``n_used`` (per-group - retained counts), and ``converged`` (``False`` if the pooled or any group fit stalled, in which case - the statistic is untrustworthy — do not read a clamped ``lr = 0`` as a clean non-rejection). - - Reference (APA 7th ed.): - Andersen, E. B. (1973). A goodness of fit test for the Rasch model. *Psychometrika, 38*(1), - 123-140. https://doi.org/10.1007/BF02291180 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "andersen_lr_test"): - raise RuntimeError("andersen_lr_test requires the compiled Rust core") - yy, n_persons, n_items = _binary_matrix(responses) - g = np.asarray(group) - if g.ndim != 1 or g.shape[0] != n_persons: - raise ValueError("group must be a length-n_persons 1-D array") - gf = np.asarray(g, dtype=np.float64) - if not np.all(np.isfinite(gf)) or np.any(gf != np.floor(gf)) or np.any(gf < 0): - raise ValueError("group labels must be finite non-negative integers") - # densify labels so n_groups counts only populated groups - _, gid = np.unique(gf.astype(np.int64), return_inverse=True) - n_groups = int(gid.max()) + 1 - if n_groups < 2: - raise ValueError("the Andersen LR test needs at least 2 groups") - if not np.isfinite(tol) or tol <= 0: - raise ValueError("tol must be finite and positive") - res = core.andersen_lr_test( - yy, gid.astype(np.int64), int(n_groups), int(n_persons), int(n_items), int(max_iter), float(tol) - ) - return { - "lr": float(res["lr"]), - "df": int(res["df"]), - "p_value": float(res["p_value"]), - "n_used": np.asarray(res["n_used"], dtype=np.int64), - "converged": bool(res["converged"]), - } diff --git a/python/fast_mlsirm/reliability.py b/python/fast_mlsirm/reliability.py deleted file mode 100644 index fbab28187..000000000 --- a/python/fast_mlsirm/reliability.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Guttman lambda reliability coefficients (Guttman, 1945, as implemented by -CRAN psych 2.6.5; Revelle, 2025). All numeric work happens in the Rust core; -this module only validates and marshals.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass -class GuttmanResult: - """Guttman lambda coefficients plus split-half summaries. - - ``lambda4`` is the best (maximum) split-half over the enumerated or - sampled splits, ``beta`` the worst (minimum, floored at 0), and - ``mean_split`` the average; ``n_splits`` reports how many splits were - evaluated and ``exhaustive`` whether all C(p, floor(p/2)) subsets were - enumerated.""" - - lambda1: float - lambda2: float - lambda3: float - lambda4: float - lambda5: float - lambda6: float - beta: float - mean_split: float - n_splits: int - exhaustive: bool - - -_REFERENCES = """References (APA 7th ed.): - Guttman, L. (1945). A basis for analyzing test-retest reliability. - *Psychometrika, 10*(4), 255-282. - https://doi.org/10.1007/BF02288892 (as cited in Revelle, 2025) - Revelle, W. (2025). *psych: Procedures for psychological, - psychometric, and personality research* (Version 2.6.5) - [R package]. https://CRAN.R-project.org/package=psych - """ - - -def guttman_lambdas( - data: np.ndarray, - n_sample_splits: int = 15000, - seed: int = 1, -) -> GuttmanResult: - """Guttman's lambda reliability coefficients (compute in Rust; algorithm - transcribed from the psych 2.6.5 R sources ``guttman.R``, ``splitHalf.R`` - and ``smc.R``, read line by line; Guttman, 1945, not read — attribution - as cited in Revelle, 2025). - - Computed on the Pearson correlation matrix of ``data`` (an - ``n_persons x n_items`` array, complete and finite): lambda1-lambda3 - (lambda3 is coefficient alpha), lambda5 (best single covariance column), - lambda6 (squared multiple correlations), and split-half summaries - lambda4 (best split), beta (worst split), and the mean split. All - ``floor(p/2)``-subsets are enumerated when their count fits within - ``n_sample_splits`` (psych's brute-force cutoff is 15000); otherwise - that many splits are sampled with this crate's deterministic LCG, so - sampled results are psych-inspired but not bit-identical to any R run. - Declared divergences from psych (documented in the Rust module): no - ``check.keys`` auto-reversal, absolute split-half correlations in both - branches, plain matrix inverse with an error on singular correlation - matrices instead of a pseudoinverse. In LLM-as-a-Judge quality - management these bound the internal consistency of a judge rubric. - - """ + _REFERENCES - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "guttman_lambdas"): - raise RuntimeError("guttman_lambdas requires the compiled Rust core") - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D persons x items array") - n_persons, n_items = x.shape - if int(n_sample_splits) < 1: - raise ValueError("n_sample_splits must be >= 1") - if int(seed) < 0: - raise ValueError("seed must be non-negative") - res = core.guttman_lambdas( - x.reshape(-1), int(n_persons), int(n_items), int(n_sample_splits), int(seed) - ) - return GuttmanResult( - lambda1=float(res["lambda1"]), - lambda2=float(res["lambda2"]), - lambda3=float(res["lambda3"]), - lambda4=float(res["lambda4"]), - lambda5=float(res["lambda5"]), - lambda6=float(res["lambda6"]), - beta=float(res["beta"]), - mean_split=float(res["mean_split"]), - n_splits=int(res["n_splits"]), - exhaustive=bool(res["exhaustive"]), - ) - - -@dataclass -class TenBergeResult: - """ten Berge & Zegers mu reliability lower bounds. - - ``mu0`` equals coefficient alpha (Guttman lambda3) and ``mu1`` equals - Guttman lambda2 exactly; the series satisfies - ``mu0 <= mu1 <= mu2 <= mu3``.""" - - mu0: float - mu1: float - mu2: float - mu3: float - - -_TENBERGE_REFERENCES = """References (APA 7th ed.): - Revelle, W. (2025). *psych: Procedures for psychological, - psychometric, and personality research* (Version 2.6.5) - [R package]. https://CRAN.R-project.org/package=psych - ten Berge, J. M. F., & Zegers, F. E. (1978). A series of lower - bounds to the reliability of a test. *Psychometrika, 43*(4), - 575-579. https://doi.org/10.1007/BF02293811 (as cited in - Revelle, 2025) - """ - - -def tenberge_mu(data: np.ndarray) -> TenBergeResult: - """ten Berge & Zegers mu0-mu3 reliability lower bounds (compute in - Rust; algorithm transcribed from the psych 2.6.5 R source - ``tenberge.R``, read line by line; ten Berge & Zegers, 1978, not read — - attribution as cited in Revelle, 2025). - - Computed on the Pearson correlation matrix of ``data`` (an - ``n_persons x n_items`` array, complete and finite) with ``Vt = sum(R)``, - off-diagonal power sums ``S_k``, and ``c = p/(p-1)`` on the innermost - radical only: ``mu0 = c*S_1/Vt`` (= alpha), ``mu1 = (S_1 + - sqrt(c*S_2))/Vt``, ``mu2`` and ``mu3`` nest one and two more radicals. - Divergences from psych (documented in the Rust module): raw-data input - only and hard errors on degenerate input. In LLM-as-a-Judge quality - management the series tightens the lower bound on rubric internal - consistency beyond alpha. - - """ + _TENBERGE_REFERENCES - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "tenberge_mu"): - raise RuntimeError("tenberge_mu requires the compiled Rust core") - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D persons x items array") - n_persons, n_items = x.shape - res = core.tenberge_mu(x.reshape(-1), int(n_persons), int(n_items)) - return TenBergeResult( - mu0=float(res["mu0"]), - mu1=float(res["mu1"]), - mu2=float(res["mu2"]), - mu3=float(res["mu3"]), - ) - - -@dataclass -class AlphaCiResult: - """Feldt (1965) exact-F confidence interval for coefficient alpha. - - ``lower``/``upper`` are not clamped and may be negative (alpha itself - can be negative). ``r_bar`` is the average inter-item correlation - implied by alpha via the Spearman-Brown inversion.""" - - alpha: float - lower: float - upper: float - r_bar: float - df1: float - df2: float - - -_FELDT_REFERENCES = """References (APA 7th ed.): - Cronbach, L. J. (1951). Coefficient alpha and the internal - structure of tests. *Psychometrika, 16*(3), 297-334. - https://doi.org/10.1007/BF02310555 (covariance form verified - against Revelle, 2025, not re-read) - Feldt, L. S. (1965). The approximate sampling distribution of - Kuder-Richardson reliability coefficient twenty. - *Psychometrika, 30*(3), 357-370. - https://doi.org/10.1007/BF02289499 (as cited in Revelle, 2025) - Revelle, W. (2025). *psych: Procedures for psychological, - psychometric, and personality research* (Version 2.6.5) - [R package]. https://CRAN.R-project.org/package=psych - """ - - -def cronbach_alpha(data: np.ndarray) -> float: - """Cronbach's coefficient alpha from raw data (compute in Rust). - - Covariance form ``alpha = p/(p-1) * (1 - tr(C)/sum(C))`` on the sample - covariance matrix of ``data`` (an ``n_persons x n_items`` array, - complete and finite). Divergences from psych::alpha (documented in the - Rust module): raw-data input only (no reverse-keying), zero-variance - items rejected, hard errors instead of NA. - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "cronbach_alpha"): - raise RuntimeError("cronbach_alpha requires the compiled Rust core") - x = np.ascontiguousarray(np.asarray(data, dtype=np.float64)) - if x.ndim != 2: - raise ValueError("data must be a 2-D persons x items array") - n_persons, n_items = x.shape - return float(core.cronbach_alpha(x.reshape(-1), int(n_persons), int(n_items))) - - -cronbach_alpha.__doc__ += "\n" + _FELDT_REFERENCES - - -def feldt_alpha_ci( - alpha: float, n_persons: int, n_items: int, level: float = 0.95 -) -> AlphaCiResult: - """Feldt (1965) exact-F confidence interval for alpha (compute in Rust; - bound mapping transcribed from the psych 2.6.5 R source ``alpha.ci``, - read line by line; Feldt, 1965, not read — attribution as cited in - Revelle, 2025). - - The pivot ``(1-alpha)/(1-alpha_hat)`` is approximately - ``F(n-1, (n-1)(p-1))``, giving - ``lower = 1 - (1-alpha_hat) * F^-1(1-delta/2)`` and - ``upper = 1 - (1-alpha_hat) * F^-1(delta/2)`` with ``delta = 1-level``. - In LLM-as-a-Judge quality management this quantifies the sampling - uncertainty of a rubric's internal-consistency estimate. - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "feldt_alpha_ci"): - raise RuntimeError("feldt_alpha_ci requires the compiled Rust core") - if n_persons < 0 or n_items < 0: - raise ValueError("n_persons and n_items must be non-negative") - res = core.feldt_alpha_ci(float(alpha), int(n_persons), int(n_items), float(level)) - return AlphaCiResult( - alpha=float(res["alpha"]), - lower=float(res["lower"]), - upper=float(res["upper"]), - r_bar=float(res["r_bar"]), - df1=float(res["df1"]), - df2=float(res["df2"]), - ) - - -feldt_alpha_ci.__doc__ += "\n" + _FELDT_REFERENCES - -@dataclass -class SeparationReliabilityResult: - """Person separation reliability (eRm ``SepRel``). - - ``sep_rel = (ssd - mse) / ssd`` is unclamped (negative when - ``mse > ssd``, NaN when ``ssd`` is ~0). ``sep_index`` is the - hand-derived separation index ``G = sqrt((ssd - mse) / mse)`` - (adjusted true SD over RMSE; not in the read source), NaN when - ``mse`` is ~0 or ``ssd < mse``.""" - - sep_rel: float - ssd: float - mse: float - sep_index: float - - -def separation_reliability( - measures: np.ndarray, se: np.ndarray -) -> SeparationReliabilityResult: - """Person separation reliability (compute in Rust; formula transcribed - from CRAN eRm ``R/SepRel.R``, read in full: ``SSD = var(measures)`` - with n-1 denominator, ``MSE = mean(se**2)``, - ``R = (SSD - MSE) / SSD``). eRm's docs attribute the statistic to - Wright and Stone (1999), not read. - - Callers must pass already-cleaned vectors: eRm drops persons with - extreme raw scores (interpolated thetas) and missing estimates before - applying the formula; that filtering is not reproduced here. In - LLM-as-a-Judge quality management this measures how reliably judge - severity estimates separate the judged units given their standard - errors. - - References (APA 7th ed.): - Mair, P., Hatzinger, R., & Maier, M. J. (2025). *eRm: Extended - Rasch modeling* [R package]. https://CRAN.R-project.org/package=eRm - Wright, B. D., & Stone, M. H. (1999). *Measurement essentials* - (2nd ed.). Wide Range. (as cited in Mair et al., 2025; not read) - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "separation_reliability"): - raise RuntimeError("separation_reliability requires the compiled Rust core") - x = np.ascontiguousarray(np.asarray(measures, dtype=np.float64)) - s = np.ascontiguousarray(np.asarray(se, dtype=np.float64)) - if x.ndim != 1 or s.ndim != 1: - raise ValueError("measures and se must be 1-D arrays") - res = core.separation_reliability(x, s) - return SeparationReliabilityResult( - sep_rel=float(res["sep_rel"]), - ssd=float(res["ssd"]), - mse=float(res["mse"]), - sep_index=float(res["sep_index"]), - ) diff --git a/python/fast_mlsirm/rsm.py b/python/fast_mlsirm/rsm.py deleted file mode 100644 index c316b7ea0..000000000 --- a/python/fast_mlsirm/rsm.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Rating Scale Model (Andrich, 1978): a Rasch-family polytomous model whose -category thresholds are shared across items, estimated by marginal-ML EM in the -Rust core.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER, MAX_POLYTOMOUS_CATEGORIES - - -@dataclass -class RsmFit: - """Fitted rating scale model (Andrich, 1978). - - ``item_location`` is the per-item location ``delta_i``; ``thresholds`` the - ``n_cat-1`` common category thresholds ``tau_k`` (shared across all items, - centered so they sum to 0); ``theta`` the per-person EAP trait. The - adjacent-category log-odds are ``ln[P(k)/P(k-1)] = theta - delta_i - tau_k``.""" - - item_location: np.ndarray - thresholds: np.ndarray - theta: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - n_parameters: int - - -def fit_rsm( - responses: np.ndarray, - n_cat: int | None = None, - q_theta: int = 41, - max_iter: int = 500, - tol: float = 1e-6, -) -> RsmFit: - """Fit the rating scale model (compute in Rust; Andrich, 1978). - - The RSM is the Rasch-family polytomous model for items on a common rating scale - (e.g. Likert): every item has its own location ``delta_i``, but the ``K-1`` - category thresholds ``tau_k`` are *shared across all items*. The - adjacent-category log-odds are ``ln[P(X=k)/P(X=k-1)] = theta - delta_i - tau_k``, - ``theta ~ N(0,1)``. This is a constrained partial-credit model (the PCM has - item-specific thresholds); at ``K=2`` it reduces to the Rasch model. Estimated by - marginal-ML EM with a Gauss-Hermite trait grid; the item locations and the shared - thresholds are updated by a monotone ECM step and the thresholds are centered to - sum to zero. - - ``responses`` is a persons x items array of integer category indices - ``0..n_cat-1`` (``NaN`` marks a missing cell, dropped under a missing-at-random - assumption). ``n_cat`` defaults to ``max(responses) + 1``. - - References (APA 7th ed.): - Andrich, D. (1978). A rating formulation for ordered response categories. - *Psychometrika, 43*(4), 561-573. https://doi.org/10.1007/BF02293814 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_rsm"): - raise RuntimeError("fit_rsm requires the compiled Rust core") - - if not isinstance(n_cat, (int, type(None))) or isinstance(n_cat, bool): - raise ValueError("n_cat must be an integer >= 2") - if n_cat is not None and not (2 <= n_cat <= MAX_POLYTOMOUS_CATEGORIES): - raise ValueError(f"n_cat must be an integer in 2..{MAX_POLYTOMOUS_CATEGORIES}") - if q_theta not in {7, 11, 15, 21, 31, 41}: - raise ValueError("q_theta must be one of 7, 11, 15, 21, 31, 41") - if ( - not isinstance(max_iter, int) - or isinstance(max_iter, bool) - or not (1 <= max_iter <= MAX_MAX_ITER) - ): - raise ValueError(f"max_iter must be an integer in 1..{MAX_MAX_ITER}") - if not np.isfinite(tol) or tol <= 0: - raise ValueError("tol must be finite and > 0") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_persons < 1 or n_items < 1: - raise ValueError("responses must contain at least one person and one item") - missing = np.isnan(y) - if np.any(~missing & ~np.isfinite(y)): - raise ValueError("observed responses must be finite integer categories") - observed = ~missing - obs_values = y[observed] - if obs_values.size and ( - np.any(obs_values != np.floor(obs_values)) or np.any(obs_values < 0) - ): - raise ValueError("observed responses must be non-negative integer categories") - if n_cat is None: - if obs_values.size == 0: - raise ValueError("responses has no observed values") - n_cat = int(obs_values.max()) + 1 - if n_cat < 2: - raise ValueError("responses must contain at least two categories") - if n_cat > MAX_POLYTOMOUS_CATEGORIES: - raise ValueError( - f"responses imply more than {MAX_POLYTOMOUS_CATEGORIES} categories" - ) - if obs_values.size and np.any(obs_values >= n_cat): - raise ValueError( - f"observed responses must be integer categories in 0..{n_cat - 1}" - ) - missing_items = np.flatnonzero(~observed.any(axis=0)) - if missing_items.size: - raise ValueError(f"item {int(missing_items[0])} has no observed responses") - yy = np.where(observed, y, 0.0).astype(np.int64).reshape(-1) - res = core.fit_rsm( - yy, - observed.reshape(-1), - int(n_persons), - int(n_items), - int(n_cat), - int(q_theta), - int(max_iter), - float(tol), - ) - return RsmFit( - item_location=np.asarray(res["item_location"], dtype=np.float64), - thresholds=np.asarray(res["thresholds"], dtype=np.float64), - theta=np.asarray(res["theta"], dtype=np.float64), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - n_parameters=int(res["n_parameters"]), - ) diff --git a/python/fast_mlsirm/rt.py b/python/fast_mlsirm/rt.py deleted file mode 100644 index d6cd088be..000000000 --- a/python/fast_mlsirm/rt.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Lognormal response-time model (van der Linden, 2007): the speed-side analogue -of the 2PL for item response *times*, estimated by marginal-ML EM in the Rust -core.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import warnings - -import numpy as np - - -@dataclass -class RtFit: - """Fitted lognormal response-time model. ``alpha``/``beta`` are the per-item - time discriminations and time intensities; ``sigma_tau`` the estimated speed - SD (``mu_tau`` is pinned to 0 for identification); ``tau_eap``/``tau_sd`` the - per-person EAP speed and its posterior SD.""" - - alpha: np.ndarray - beta: np.ndarray - mu_tau: float - sigma_tau: float - tau_eap: np.ndarray - tau_sd: np.ndarray - loglik: float - n_iter: int - converged: bool - # Appended defaults preserve the positional constructor used by older callers. - loglik_trace: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.float64)) - termination_reason: str = "unknown" - final_loglik_change: float = float("inf") - - -def fit_response_times( - times: np.ndarray, - max_iter: int = 500, - tol: float = 1e-6, - var_floor: float = 1e-4, - sigma_floor: float = 1e-4, - fix_sigma_tau: float | None = None, - require_convergence: bool = False, -) -> RtFit: - """Fit the lognormal response-time measurement model (compute in Rust; van der - Linden, 2007): ``ln(T_ij) ~ Normal(beta_i - tau_j, 1/alpha_i^2)`` for person - ``j`` (latent speed ``tau_j``) and item ``i`` (time intensity ``beta_i``, time - discrimination ``alpha_i``). Item parameters and the speed SD are estimated by - marginal-ML EM with ``tau ~ Normal(0, sigma_tau^2)``, and speed is scored by - EAP. ``times`` is a persons x items array of raw response times; non-positive - or ``NaN`` entries are treated as missing (marginalized per person). By default - ``sigma_tau`` is estimated (the log-time metric identifies the speed scale); - pass ``fix_sigma_tau`` only to impose a deliberately standardized metric. - The result exposes the likelihood trace, termination reason, and final - likelihood change. Non-convergence emits ``RuntimeWarning``; set - ``require_convergence=True`` to raise instead. - - References (APA 7th ed.): - van der Linden, W. J. (2007). A hierarchical framework for modeling speed - and accuracy on test items. *Psychometrika, 72*(3), 287–308. - https://doi.org/10.1007/s11336-006-1478-z - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_rt_lognormal"): - raise RuntimeError("fit_response_times requires the compiled Rust core") - t = np.asarray(times, dtype=np.float64) - if t.ndim != 2: - raise ValueError("times must be a 2-D persons x items array") - n_persons, n_items = t.shape - observed = np.isfinite(t) & (t > 0) - obs_arg = None if observed.all() else observed.reshape(-1) - tt = np.where(observed, t, 1.0).reshape(-1) # masked entries get a valid placeholder - res = core.fit_rt_lognormal( - tt, obs_arg, int(n_persons), int(n_items), - int(max_iter), float(tol), float(var_floor), float(sigma_floor), - None if fix_sigma_tau is None else float(fix_sigma_tau), - ) - fit = RtFit( - alpha=np.asarray(res["alpha"], dtype=np.float64), - beta=np.asarray(res["beta"], dtype=np.float64), - mu_tau=float(res["mu_tau"]), - sigma_tau=float(res["sigma_tau"]), - tau_eap=np.asarray(res["tau_eap"], dtype=np.float64), - tau_sd=np.asarray(res["tau_sd"], dtype=np.float64), - loglik=float(res["loglik"]), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - ) - if not fit.converged: - message = ( - "response-time calibration did not converge: " - f"reason={fit.termination_reason}, iterations={fit.n_iter}/{max_iter}, " - f"final_loglik_change={fit.final_loglik_change:.12g}, tolerance={tol:.12g}" - ) - if require_convergence: - raise RuntimeError(message) - warnings.warn(message, RuntimeWarning, stacklevel=2) - return fit - - -def fit_speed_accuracy( - responses: np.ndarray, - times: np.ndarray, - a: np.ndarray, - b: np.ndarray, - alpha: np.ndarray, - beta: np.ndarray, - q: int = 21, - max_iter: int = 500, - tol: float = 1e-6, - fix_sigma_tau: float | None = None, - require_convergence: bool = False, -) -> dict: - """Estimate a two-stage marginal-ML adaptation of the joint speed-accuracy - person covariance in van der Linden (2007) (compute in Rust) -- the - ability-speed correlation ``rho`` and speed SD ``sigma_tau`` -- over a 2-D - Gauss-Hermite grid with item parameters held fixed. The original article uses - a normal-ogive response model and Bayesian MCMC; the fixed-bank logistic 2PL - estimator here is a repository-specific adaptation, not an estimator reported - in that article. ``responses`` (0/1) and ``times`` (> 0) are persons x items - arrays sharing a missingness mask (``NaN``/non-positive = missing); ``a``/``b`` - are the accuracy 2PL raw slope/intercept (``eta = a_i*theta + b_i``); - ``alpha``/``beta`` are the lognormal time discrimination/intensity (e.g. from - :func:`fit_response_times`). At least one paired observation and one observed - item with non-zero accuracy discrimination are required to identify ``rho``. - Returns a dict with ``rho``, ``sigma_tau``, - ``s_theta2`` (a theta-metric diagnostic ~1), joint ``theta_eap``/``tau_eap``, - ``loglik``, ``loglik_trace``, ``n_iter``, ``converged``, - ``termination_reason``, and ``final_loglik_change``. Non-convergence emits - ``RuntimeWarning``; set ``require_convergence=True`` to raise instead. - - ``rho`` here is the consistent marginal-ML correlation, NOT the attenuated - correlation of the two separately-scored EAPs (which shrinks toward 0). - - References (APA 7th ed.): - van der Linden, W. J. (2007). A hierarchical framework for modeling speed - and accuracy on test items. *Psychometrika, 72*(3), 287–308. - https://doi.org/10.1007/s11336-006-1478-z - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_speed_accuracy_covariance"): - raise RuntimeError("fit_speed_accuracy requires the compiled Rust core") - u = np.asarray(responses, dtype=np.float64) - t = np.asarray(times, dtype=np.float64) - if u.ndim != 2 or t.shape != u.shape: - raise ValueError("responses and times must be matching 2-D persons x items arrays") - n_persons, n_items = u.shape - observed = np.isfinite(u) & np.isfinite(t) & (t > 0) - obs_arg = None if observed.all() else observed.reshape(-1) - uu = np.where(observed, u, 0.0).reshape(-1) - tt = np.where(observed, t, 1.0).reshape(-1) - res = core.fit_speed_accuracy_covariance( - uu, tt, obs_arg, - np.asarray(a, dtype=np.float64), np.asarray(b, dtype=np.float64), - np.asarray(alpha, dtype=np.float64), np.asarray(beta, dtype=np.float64), - int(n_persons), int(n_items), - int(q), int(max_iter), float(tol), - None if fix_sigma_tau is None else float(fix_sigma_tau), - ) - fit = { - "rho": float(res["rho"]), - "sigma_tau": float(res["sigma_tau"]), - "s_theta2": float(res["s_theta2"]), - "theta_eap": np.asarray(res["theta_eap"], dtype=np.float64), - "tau_eap": np.asarray(res["tau_eap"], dtype=np.float64), - "loglik": float(res["loglik"]), - "loglik_trace": np.asarray(res["loglik_trace"], dtype=np.float64), - "n_iter": int(res["n_iter"]), - "converged": bool(res["converged"]), - "termination_reason": str(res["termination_reason"]), - "final_loglik_change": float(res["final_loglik_change"]), - } - if not fit["converged"]: - message = ( - "joint speed-accuracy calibration did not converge: " - f"reason={fit['termination_reason']}, iterations={fit['n_iter']}/{max_iter}, " - f"final_loglik_change={fit['final_loglik_change']:.12g}, tolerance={tol:.12g}" - ) - if require_convergence: - raise RuntimeError(message) - warnings.warn(message, RuntimeWarning, stacklevel=2) - return fit - - -def rt_person_fit( - times: np.ndarray, - alpha: np.ndarray, - beta: np.ndarray, - alpha_level: float = 0.05, - z_fast: float = 1.645, -) -> dict: - """Sinharay's (2018) frequentist response-time person-fit statistic (computed - in Rust) under a fitted lognormal RT model. It profiles each person's speed by - ML, so the sum of squared standardized log-time residuals ``W = sum_i z_i^2`` is exactly - ``chi2(n_j - 1)`` under the model (a clean one-df correction for the estimated - speed, the RT analogue of ``l_z*``). Detects speed *inconsistency across items* - -- rapid guessing or item preknowledge, which appear as clusters of strongly - negative residuals -- but not a uniform speed level (the profile absorbs it). - ``times`` is a persons x items array of raw response times (``NaN``/non-positive - = missing); ``alpha``/``beta`` come from :func:`fit_response_times`. Returns a - dict with per-person ``w``, ``df``, ``l_t`` (an API-compatible field containing - the Wilson-Hilferty standardization, approximately ``N(0,1)``), ``p_value`` - (upper-tail chi-square), ``flagged`` (``p < alpha_level``), - ``tau_ml`` (profiled speed), and persons x items ``z_resid`` (studentized - residuals; strongly negative = too fast) and ``item_flag`` (one-sided too-fast). - The item residuals are a fixed-bank diagnostic in this package. Van der Linden - and Guo (2008) motivate the aberrant-fast-response interpretation, but their - Bayesian leave-one-out procedure is not implemented here. - Inputs whose squared time discriminations or profiled residual arithmetic are - non-finite are rejected instead of returning undefined diagnostics. - - References (APA 7th ed.): - van der Linden, W. J., & Guo, F. (2008). Bayesian procedures for - identifying aberrant response-time patterns in adaptive testing. - *Psychometrika, 73*(3), 365–384. - https://doi.org/10.1007/s11336-007-9046-8 - Sinharay, S. (2018). A new person-fit statistic for the lognormal model for - response times. *Journal of Educational Measurement, 55*(4), 457–476. - https://doi.org/10.1111/jedm.12188 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "rt_person_fit"): - raise RuntimeError("rt_person_fit requires the compiled Rust core") - t = np.asarray(times, dtype=np.float64) - if t.ndim != 2: - raise ValueError("times must be a 2-D persons x items array") - n_persons, n_items = t.shape - observed = np.isfinite(t) & (t > 0) - obs_arg = None if observed.all() else observed.reshape(-1) - tt = np.where(observed, t, 1.0).reshape(-1) - res = core.rt_person_fit( - tt, obs_arg, int(n_persons), int(n_items), - np.asarray(alpha, dtype=np.float64), np.asarray(beta, dtype=np.float64), - float(alpha_level), float(z_fast), - ) - return { - "w": np.asarray(res["w"], dtype=np.float64), - "df": np.asarray(res["df"], dtype=np.int64), - "l_t": np.asarray(res["l_t"], dtype=np.float64), - "p_value": np.asarray(res["p_value"], dtype=np.float64), - "flagged": np.asarray(res["flagged"], dtype=bool), - "tau_ml": np.asarray(res["tau_ml"], dtype=np.float64), - "z_resid": np.asarray(res["z_resid"], dtype=np.float64).reshape(n_persons, n_items), - "item_flag": np.asarray(res["item_flag"], dtype=bool).reshape(n_persons, n_items), - } diff --git a/python/fast_mlsirm/serving.py b/python/fast_mlsirm/serving.py deleted file mode 100644 index 8bb005da9..000000000 --- a/python/fast_mlsirm/serving.py +++ /dev/null @@ -1,798 +0,0 @@ -"""Serving bundle export and frozen-parameter scoring. - -The downstream deployment pattern (mirroring the mirt-based R plumber API -this feeds): a calibration run freezes the item-side parameters into a single -self-contained JSON bundle; a scoring service loads the bundle and computes -EAP trait scores / latent-space positions for new response vectors, never -re-estimating item parameters. - -Bundle schema (``schema_version`` 1): model/config block, ordered item codes, -item parameters (``alpha``/``a``/``b``/``zeta``), ``tau``/``gamma``, -population block (multigroup ``mu``/``sigma``, multilevel ``sigma_u``/ -``icc``), quadrature spec, and an optional item-screening audit trail. -""" - -from __future__ import annotations - -import json -import math -from pathlib import Path -from typing import Any - -import numpy as np - -from .config import MAX_LATENT_DIM, VALID_MODELS -from .io import _atomic_write_text, _load_json_bounded -from .types import FitResult - -SCHEMA_VERSION = 1 -MAX_DRAWS = 100_000 -MAX_INFORMATION_POINTS = 100_000 -MAX_SCORE_CELLS = 20_000_000 -MAX_SERVING_OUTPUT_CELLS = 20_000_000 -MAX_ABS_LOG_SCALE = 100.0 -MAX_ABS_ITEM_PARAMETER = 1_000_000.0 - - -def _core_module(): - try: - from . import _core # type: ignore - - return _core - except Exception: # pragma: no cover - return None - - -def serving_prior(bundle: dict) -> tuple[np.ndarray, np.ndarray]: - """Default scoring prior implied by the bundle's population block: - N(0, 1) for single/multigroup-reference; the MARGINAL - N(0, sqrt(1 + sigma_u^2)) for multilevel (unknown cluster). Pass an - explicit prior to ``score_respondents`` to condition on a known cluster - (mean = u_eap) or group (mean = mu_g, sd = sigma_g). - """ - n_dims = bundle["n_dims"] - if not isinstance(n_dims, int) or isinstance(n_dims, bool) or not (1 <= n_dims <= 64): - raise ValueError("bundle n_dims must be an integer in 1..64") - mean = np.zeros(n_dims) - sd = np.ones(n_dims) - pop = bundle.get("population") - if pop is None: - pop = {} - elif not isinstance(pop, dict): - raise ValueError("bundle population must be an object or null") - if pop.get("kind") == "multilevel" and "sigma_u" in pop: - su = pop["sigma_u"] - # sigma_u is attacker-controlled in an untrusted bundle: a string - # crashes ** with TypeError, 1e200 overflows, 1e150 poisons sd. - if not _finite_number(su) or su < 0 or su > 1_000.0: - raise ValueError("bundle population sigma_u must be a finite number in 0..1000") - sd[:] = float(np.sqrt(1.0 + float(su) ** 2)) - return mean, sd - - -def export_serving_bundle( - result: FitResult, - item_codes: list[str], - factor_id: np.ndarray, - path: str | Path | None = None, - q_theta: int = 21, - q_xi: int = 11, - eps_distance: float = 1e-8, - screening_audit: dict[str, Any] | None = None, - dim_names: list[str] | None = None, -) -> dict[str, Any]: - """Build (and optionally write) a serving bundle from a converged marginal fit. - - Raises ``RuntimeError`` when calibration did not converge. A frozen bundle - is a deployment artifact, so unfinished parameters must not cross this API - boundary merely because they are finite and serializable. - """ - status = str(result.convergence_status).strip().lower() - if status != "converged": - trace = result.loglik_trace - last_delta = ( - abs(float(trace[-1]) - float(trace[-2])) - if len(trace) >= 2 - else float("nan") - ) - raise RuntimeError( - "export_serving_bundle requires converged calibration parameters; " - f"status={status or 'unknown'}, n_iter={result.n_iter}, " - f"last_loglik_delta={last_delta:.6g}" - ) - p = result.params - n_items = len(p.b) - if len(item_codes) != n_items: - raise ValueError("item_codes length must match the fitted item count") - factor_id = np.asarray(factor_id, dtype=np.int64) - if factor_id.shape != (n_items,): - raise ValueError("factor_id length must match the fitted item count") - bundle: dict[str, Any] = { - "schema_version": SCHEMA_VERSION, - "model": result.model, - "estimator": "mmle", - "optimizer": result.optimizer, - "n_items": n_items, - "n_dims": int(factor_id.max()) + 1, - "latent_dim": int(np.asarray(p.zeta).shape[1]), - "dim_names": dim_names, - "quadrature": {"q_theta": q_theta, "q_xi": q_xi}, - "eps_distance": eps_distance, - "items": [ - { - "code": item_codes[i], - "factor_id": int(factor_id[i]), - "alpha": float(p.alpha[i]), - "a": float(np.exp(p.alpha[i])), - "b": float(p.b[i]), - "zeta": [float(v) for v in np.asarray(p.zeta)[i]], - } - for i in range(n_items) - ], - "tau": float(p.tau), - "gamma": float(np.exp(p.tau)), - "population": None, - "eapsum_tables": None, - "fit": { - "convergence_status": result.convergence_status, - "n_iter": result.n_iter, - "final_loglik": result.loglik_trace[-1] if result.loglik_trace else None, - }, - "screening_audit": screening_audit, - } - if result.population is not None: - pop = dict(result.population) - out_pop: dict[str, Any] = {"kind": pop["kind"]} - if "mu" in pop: - out_pop["mu"] = np.asarray(pop["mu"]).tolist() - out_pop["sigma"] = np.asarray(pop["sigma"]).tolist() - if "sigma_u" in pop: - out_pop["sigma_u"] = float(pop["sigma_u"]) - out_pop["icc"] = float(pop["icc"]) - if "pi_zero" in pop: - # zero-inflated calibration: serving scores are conditional on the - # engager class; pi is reported for downstream base-rate handling - out_pop["pi_zero"] = float(pop["pi_zero"]) - if "delta" in pop: - out_pop["covariate_delta"] = float(pop["delta"]) - bundle["population"] = out_pop - # Summed-score EAP conversion tables (Lord-Wingersky / Thissen et al. - # 1995) under the bundle's serving prior — the lookup-table serving path. - core = _core_module() - if core is not None: - mean, sd = serving_prior(bundle) - zeta = np.asarray(p.zeta, dtype=np.float64) - tables = core.eapsum_tables( - np.asarray(p.alpha, dtype=np.float64), - np.asarray(p.b, dtype=np.float64), - zeta.ravel(), - float(p.tau), - factor_id, - result.model, - int(factor_id.max()) + 1, - int(zeta.shape[1]), - float(eps_distance), - mean, - sd, - q_theta=int(q_theta), - q_xi=int(q_xi), - ) - bundle["eapsum_tables"] = [ - { - "dim": int(t["dim"]), - "dim_name": None if dim_names is None else dim_names[int(t["dim"])], - "n_items_dim": int(t["n_items_dim"]), - "score_prob": [float(v) for v in t["score_prob"]], - "eap": [float(v) for v in t["eap"]], - "sd": [float(v) for v in t["sd"]], - } - for t in tables - ] - if path is not None: - _atomic_write_text(path, json.dumps(bundle, ensure_ascii=False, indent=2)) - return bundle - - -def _reject_nonfinite_json(literal: str) -> float: - raise ValueError(f"serving bundle contains a non-finite JSON constant {literal!r}") - - -def _finite_number(x) -> bool: - return isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(float(x)) - - -def _validate_bundle(bundle: Any) -> None: - """Validate a serving bundle's structure, sizes, and numeric domains - before it is used to score untrusted respondents. Guards against oversized - or inconsistent dimensions (multi-terabyte allocations / index errors) and - unsafe item parameters (NaN/Inf scores) reaching the scoring core.""" - if not isinstance(bundle, dict): - raise ValueError("serving bundle must be a JSON object") - if bundle.get("schema_version") != SCHEMA_VERSION: - raise ValueError( - f"unsupported bundle schema_version {bundle.get('schema_version')!r}" - ) - population = bundle.get("population") - if population is not None and not isinstance(population, dict): - raise ValueError("bundle population must be an object or null") - - def _pos_int(key: str, hi: int) -> int: - v = bundle.get(key) - if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= hi): - raise ValueError(f"bundle {key} must be an integer in 1..{hi}") - return v - - n_items = _pos_int("n_items", 100_000) - n_dims = _pos_int("n_dims", 64) - latent_dim = _pos_int("latent_dim", MAX_LATENT_DIM) - if bundle.get("model") not in VALID_MODELS: - raise ValueError(f"bundle model must be one of {sorted(VALID_MODELS)}") - if ( - not _finite_number(bundle.get("tau")) - or abs(float(bundle["tau"])) > MAX_ABS_LOG_SCALE - ): - raise ValueError( - f"bundle tau must be in the safe numeric range " - f"[-{MAX_ABS_LOG_SCALE}, {MAX_ABS_LOG_SCALE}]" - ) - eps = bundle.get("eps_distance") - if ( - not _finite_number(eps) - or eps <= 0 - or float(eps) > MAX_ABS_ITEM_PARAMETER - ): - raise ValueError( - f"bundle eps_distance must be in the safe numeric range " - f"(0, {MAX_ABS_ITEM_PARAMETER}]" - ) - quad = bundle.get("quadrature") - if not isinstance(quad, dict): - raise ValueError("bundle quadrature must be an object") - for qk in ("q_theta", "q_xi"): - if quad.get(qk) not in {7, 11, 15, 21, 31, 41}: - raise ValueError(f"bundle quadrature {qk} must be one of 7,11,15,21,31,41") - # Latent-space models score on a tensor Gauss-Hermite grid of - # q_xi ** latent_dim points; reject combinations that would allocate an - # astronomically large grid (e.g. 41**8 ~ 8e12). - if bundle["model"] != "MIRT" and int(quad["q_xi"]) ** latent_dim > 1_000_000: - raise ValueError("bundle q_xi ** latent_dim exceeds the serving grid limit") - # The scoring core builds item-response tables of size - # max(n_items, n_dims) * q_theta * n_xi; bound the product (55+ GB otherwise). - n_xi = 1 if bundle["model"] == "MIRT" else int(quad["q_xi"]) ** latent_dim - if max(n_items, n_dims) * int(quad["q_theta"]) * n_xi > 50_000_000: - raise ValueError("bundle scoring-table size (items x q_theta x n_xi) exceeds the serving limit") - items = bundle.get("items") - if not isinstance(items, list) or len(items) != n_items: - raise ValueError("bundle items must be a list of length n_items") - seen: set = set() - for j, it in enumerate(items): - if not isinstance(it, dict): - raise ValueError(f"bundle item {j} must be an object") - code = it.get("code") - if not isinstance(code, str) or code in seen: - raise ValueError(f"bundle item {j} must have a unique string code") - seen.add(code) - fid = it.get("factor_id") - if not isinstance(fid, int) or isinstance(fid, bool) or not (0 <= fid < n_dims): - raise ValueError(f"bundle item {code!r} factor_id must be an int in 0..n_dims-1") - for pk, bound in ( - ("alpha", MAX_ABS_LOG_SCALE), - ("b", MAX_ABS_ITEM_PARAMETER), - ): - if ( - not _finite_number(it.get(pk)) - or abs(float(it[pk])) > bound - ): - raise ValueError( - f"bundle item {code!r} {pk} must be in the safe numeric " - f"range [-{bound}, {bound}]" - ) - zeta = it.get("zeta") - if ( - not isinstance(zeta, list) - or len(zeta) != latent_dim - or not all( - _finite_number(z) and abs(float(z)) <= MAX_ABS_ITEM_PARAMETER - for z in zeta - ) - ): - raise ValueError( - f"bundle item {code!r} zeta must be {latent_dim} numbers in " - f"the safe numeric range [-{MAX_ABS_ITEM_PARAMETER}, " - f"{MAX_ABS_ITEM_PARAMETER}]" - ) - tables = bundle.get("eapsum_tables") - if tables is not None: - if not isinstance(tables, list) or len(tables) != n_dims: - raise ValueError( - "bundle eapsum_tables must be null or a list of length n_dims" - ) - item_counts = [0] * n_dims - for item in items: - item_counts[item["factor_id"]] += 1 - seen_dims: set[int] = set() - for index, table in enumerate(tables): - if not isinstance(table, dict): - raise ValueError(f"bundle eapsum table {index} must be an object") - dim = table.get("dim") - if ( - not isinstance(dim, int) - or isinstance(dim, bool) - or not 0 <= dim < n_dims - or dim in seen_dims - ): - raise ValueError( - "bundle eapsum table dimensions must be unique integers in 0..n_dims-1" - ) - seen_dims.add(dim) - expected = item_counts[dim] + 1 - if table.get("n_items_dim") != item_counts[dim]: - raise ValueError( - "bundle eapsum table n_items_dim does not match bundle items" - ) - for key in ("score_prob", "eap", "sd"): - values = table.get(key) - if ( - not isinstance(values, list) - or len(values) != expected - or not all(_finite_number(value) for value in values) - ): - raise ValueError( - f"bundle eapsum table {key} must contain {expected} finite numbers" - ) - if any(value < 0 for value in table["score_prob"]) or any( - value < 0 for value in table["sd"] - ): - raise ValueError( - "bundle eapsum score_prob and sd values must be non-negative" - ) - - -def load_serving_bundle(path: str | Path) -> dict[str, Any]: - bundle = _load_json_bounded( - path, - source="serving bundle", - parse_constant=_reject_nonfinite_json, - ) - _validate_bundle(bundle) - return bundle - - -def score_respondents( - bundle: dict[str, Any], - responses: dict[str, Any] | list[dict[str, Any]] | np.ndarray, - mask: np.ndarray | None = None, - method: str = "eap", - prior: tuple[np.ndarray, np.ndarray] | None = None, - device: str = "auto", -) -> list[dict[str, Any]]: - """Score new respondents against a frozen bundle. - - ``responses`` is either a dense array (persons x n_items, NaN = missing, - column order = bundle item order) or one/many dicts mapping item code -> - 0/1 (missing items simply absent) — the same shape of payload the - importance-assessment API receives. - - ``method`` is "eap" (posterior mean, default), "map" (posterior mode with - SEs; convergence requires a gradient norm below ``1e-6``), or "eapsum" - (summed-score lookup via the bundle's Lord-Wingersky conversion tables — - requires complete responses within each dimension). - ``prior`` overrides the serving prior (mean, sd per dimension): condition - on a known team with ``mean = u_eap`` or a known group with - ``(mu_g, sigma_g)``. - - All scoring methods require the compiled Rust core. ``device="auto"`` - prefers the Rust wgpu scoring or EAPsum lookup kernel and falls back to the - parallel Rust CPU implementation when no usable GPU is available. Pass - ``device="cpu"`` for the hardware-independent f64 reference reduction. - """ - _validate_bundle(bundle) - items = bundle["items"] - n_items = bundle["n_items"] - code_to_col = {it["code"]: j for j, it in enumerate(items)} - if isinstance(responses, dict): - responses = [responses] - if isinstance(responses, list): - # Bound the dense respondent matrix before allocating: len(responses) - # and n_items are both request/bundle controlled (memory-exhaustion DoS). - if len(responses) * n_items > MAX_SCORE_CELLS: - raise ValueError( - f"response matrix ({len(responses)} x {n_items}) exceeds the " - f"{MAX_SCORE_CELLS}-cell scoring limit" - ) - y = np.full((len(responses), n_items), np.nan) - for r, resp in enumerate(responses): - if not isinstance(resp, dict): - raise ValueError( - "each response must be an object mapping item codes to responses" - ) - for code, value in resp.items(): - j = code_to_col.get(code) - if j is None: - raise ValueError(f"unknown item code {code!r}") - y[r, j] = float(bool(value)) if isinstance(value, bool) else float(value) - else: - y = np.asarray(responses, dtype=float) - if y.ndim == 1: - y = y[None, :] - elif y.ndim != 2: - raise ValueError( - "responses must be a 1-D vector or 2-D persons x items matrix" - ) - if y.shape[1] != n_items: - raise ValueError("responses column count must match the bundle items") - if y.size > MAX_SCORE_CELLS: - raise ValueError( - f"response matrix ({y.shape[0]} x {n_items}) exceeds the " - f"{MAX_SCORE_CELLS}-cell scoring limit" - ) - if mask is None: - observed = ~np.isnan(y) - else: - observed = np.asarray(mask, dtype=bool) - if observed.shape != y.shape: - raise ValueError("mask shape must match responses") - obs_vals = y[observed] - if obs_vals.size and not np.all((obs_vals == 0.0) | (obs_vals == 1.0)): - raise ValueError("observed responses must be 0 or 1") - - alpha = np.array([it["alpha"] for it in items]) - b = np.array([it["b"] for it in items]) - zeta = np.array([it["zeta"] for it in items]) - factor_id = np.array([it["factor_id"] for it in items], dtype=np.int64) - n_dims = bundle["n_dims"] - n_persons = y.shape[0] - output_cells = n_persons * (2 * n_dims + int(bundle["latent_dim"]) + 2) - if output_cells > MAX_SERVING_OUTPUT_CELLS: - raise ValueError( - f"scoring output size ({output_cells} cells) exceeds the " - f"{MAX_SERVING_OUTPUT_CELLS}-cell serving limit" - ) - mean, sd = ( - serving_prior(bundle) - if prior is None - else ( - np.asarray(prior[0], dtype=float), - np.asarray(prior[1], dtype=float), - ) - ) - - if method == "eapsum": - tables = bundle.get("eapsum_tables") - if not tables: - raise ValueError("bundle has no eapsum_tables; re-export the bundle") - core = _core_module() - if core is None: - raise RuntimeError( - "EAPsum scoring requires the compiled Rust core so device selection " - "and CPU fallback remain inside Rust" - ) - sorted_tables = sorted(tables, key=lambda table: table["dim"]) - table_offsets = [0] - table_eap: list[float] = [] - table_sd: list[float] = [] - for table in sorted_tables: - table_eap.extend(table["eap"]) - table_sd.extend(table["sd"]) - table_offsets.append(len(table_eap)) - res = core.score_eapsum( - np.where(observed, y, 0.0).ravel(), - observed.ravel(), - int(n_persons), - factor_id, - int(n_dims), - np.asarray(table_offsets, dtype=np.int64), - np.asarray(table_eap, dtype=float), - np.asarray(table_sd, dtype=float), - device=str(device), - ) - theta = np.asarray(res["theta_eap"]).reshape(n_persons, n_dims) - theta_sd = np.asarray(res["theta_sd"]).reshape(n_persons, n_dims) - n_observed = np.asarray(res["n_observed"], dtype=np.int64) - return [ - { - "theta": [float(value) for value in theta[r]], - "theta_sd": [float(value) for value in theta_sd[r]], - "method": "eapsum", - "n_observed": int(n_observed[r]), - } - for r in range(n_persons) - ] - - core = _core_module() - y_filled = np.where(observed, y, 0.0) - if method == "map": - if core is None: - raise ValueError("MAP scoring requires the compiled Rust core") - res = core.score_bank_map( - y_filled.ravel(), observed.ravel(), int(n_persons), - alpha, b, zeta.ravel(), float(bundle["tau"]), factor_id, - bundle["model"], int(n_dims), int(bundle["latent_dim"]), - float(bundle["eps_distance"]), mean, sd, - ) - theta_map = np.asarray(res["theta_map"]).reshape(n_persons, n_dims) - theta_se = np.asarray(res["theta_se"]).reshape(n_persons, n_dims) - xi_map = np.asarray(res["xi_map"]).reshape(n_persons, bundle["latent_dim"]) - return [ - { - "theta": [float(v) for v in theta_map[r]], - "theta_sd": [float(v) for v in theta_se[r]], - "xi": [float(v) for v in xi_map[r]], - "log_posterior": float(res["log_posterior"][r]), - "converged": bool(res["converged"][r]), - "method": "map", - "n_observed": int(observed[r].sum()), - } - for r in range(n_persons) - ] - if method != "eap": - raise ValueError("method must be one of ['eap', 'map', 'eapsum']") - - if core is None: - raise RuntimeError( - "EAP scoring requires the compiled Rust core so device selection " - "and CPU fallback remain inside Rust" - ) - res = core.score_bank_eap( - y_filled.ravel(), observed.ravel(), int(n_persons), - alpha, b, zeta.ravel(), float(bundle["tau"]), factor_id, - bundle["model"], int(n_dims), int(bundle["latent_dim"]), - float(bundle["eps_distance"]), mean, sd, - q_theta=int(bundle["quadrature"]["q_theta"]), - xi_rule="gh", - q_xi=int(bundle["quadrature"]["q_xi"]), - device=str(device), - ) - out = { - "theta_eap": np.asarray(res["theta_eap"]).reshape(n_persons, n_dims), - "theta_sd": np.asarray(res["theta_sd"]).reshape(n_persons, n_dims), - "xi_eap": np.asarray(res["xi_eap"]).reshape(n_persons, bundle["latent_dim"]), - "loglik": np.asarray(res["loglik"]), - } - results = [] - for r in range(y.shape[0]): - results.append( - { - "theta": [float(v) for v in out["theta_eap"][r]], - "theta_sd": [float(v) for v in out["theta_sd"][r]], - "xi": [float(v) for v in out["xi_eap"][r]], - "loglik": float(out["loglik"][r]), - "method": "eap", - "n_observed": int(observed[r].sum()), - } - ) - return results - - -def _bundle_bank_args(bundle: dict[str, Any]) -> dict[str, Any]: - items = bundle["items"] - return dict( - alpha=np.array([it["alpha"] for it in items], dtype=np.float64), - b=np.array([it["b"] for it in items], dtype=np.float64), - zeta=np.array([it["zeta"] for it in items], dtype=np.float64).ravel(), - tau=float(bundle["tau"]), - factor_id=np.array([it["factor_id"] for it in items], dtype=np.int64), - model=bundle["model"], - n_dims=int(bundle["n_dims"]), - latent_dim=int(bundle["latent_dim"]), - eps_distance=float(bundle["eps_distance"]), - ) - - -def bank_information( - bundle: dict[str, Any], - theta: np.ndarray, - xi: np.ndarray | None = None, - device: str = "auto", -) -> dict[str, np.ndarray]: - """Item/test information at the given trait points (Magis 2013 formula; - Lord's test-information tradition). ``theta`` is points x n_dims; ``xi`` - defaults to the origin of the latent space. ``device="auto"`` prefers the - Rust wgpu kernel and falls back to the Rust f64 CPU implementation.""" - _validate_bundle(bundle) - core = _core_module() - if core is None: - raise RuntimeError("bank_information requires the compiled Rust core") - n_dims = int(bundle["n_dims"]) - latent_dim = int(bundle["latent_dim"]) - theta = np.asarray(theta, dtype=np.float64) - if theta.ndim == 1: - if n_dims == 1: - theta = theta[:, None] - elif theta.shape == (n_dims,): - theta = theta[None, :] - else: - raise ValueError("theta must have shape (points, n_dims)") - if theta.ndim != 2 or theta.shape[1] != n_dims: - raise ValueError("theta must have shape (points, n_dims)") - n_points = theta.shape[0] - if not (1 <= n_points <= MAX_INFORMATION_POINTS): - raise ValueError( - f"theta must contain between 1 and {MAX_INFORMATION_POINTS} points" - ) - output_cells = n_points * (int(bundle["n_items"]) + n_dims) - if output_cells > MAX_SERVING_OUTPUT_CELLS: - raise ValueError( - f"bank-information output size ({output_cells} cells) exceeds the " - f"{MAX_SERVING_OUTPUT_CELLS}-cell serving limit" - ) - if not np.all(np.isfinite(theta)): - raise ValueError("theta must contain only finite values") - if xi is None: - xi_array = np.zeros((n_points, latent_dim)) - else: - xi_array = np.asarray(xi, dtype=np.float64) - if xi_array.ndim == 1: - if latent_dim == 1 and xi_array.shape == (n_points,): - xi_array = xi_array[:, None] - elif n_points == 1 and xi_array.shape == (latent_dim,): - xi_array = xi_array[None, :] - else: - raise ValueError("xi must have shape (points, latent_dim)") - if xi_array.shape != (n_points, latent_dim): - raise ValueError("xi must have shape (points, latent_dim)") - if not np.all(np.isfinite(xi_array)): - raise ValueError("xi must contain only finite values") - res = dict( - core.bank_information( - theta.ravel(), xi_array.ravel(), int(n_points), - device=str(device), - **_bundle_bank_args(bundle), - ) - ) - return { - "item_info": np.asarray(res["item_info"]).reshape(n_points, bundle["n_items"]), - "test_info": np.asarray(res["test_info"]).reshape(n_points, bundle["n_dims"]), - } - - -def cat_next_item( - bundle: dict[str, Any], - responses_so_far: dict[str, Any], - prior: tuple[np.ndarray, np.ndarray] | None = None, - device: str = "auto", -) -> dict[str, Any]: - """Run one adaptive-EAP CAT step over the frozen bank. - - Bock and Mislevy (1982) support the noniterative EAP score, while Wang et - al. (2010) describe multidimensional CAT with information-based item - selection. Selecting the dimension with the largest posterior SD is a - repository policy, not a procedure prescribed by either source. - ``responses_so_far`` maps item code to 0/1. - ``device="auto"`` prefers Rust wgpu for both EAP and information and falls - back to their Rust f64 CPU implementations. - - References - ---------- - Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability - in a microcomputer environment. *Applied Psychological Measurement, - 6*(4), 431–444. https://doi.org/10.1177/014662168200600405 - - Wang, H.-P., Kuo, B.-C., & Chao, R.-C. (2010). A multidimensional - computerized adaptive testing system for enhancing the Chinese as second - language proficiency test. In H. Fujita & J. Sasaki (Eds.), *Selected topics - in education and educational technology* (pp. 245–252). WSEAS Press. - """ - _validate_bundle(bundle) - core = _core_module() - if core is None: - raise RuntimeError("cat_next_item requires the compiled Rust core") - items = bundle["items"] - n_items = bundle["n_items"] - code_to_col = {it["code"]: j for j, it in enumerate(items)} - y = np.zeros(n_items) - administered = np.zeros(n_items, dtype=bool) - if not isinstance(responses_so_far, dict): - raise ValueError( - "responses_so_far must be an object mapping item codes to responses" - ) - for code, value in responses_so_far.items(): - j = code_to_col.get(code) - if j is None: - raise ValueError(f"unknown item code {code!r}") - response = float(bool(value)) if isinstance(value, bool) else float(value) - if not np.isfinite(response) or response not in (0.0, 1.0): - raise ValueError("administered responses must be 0 or 1") - y[j] = response - administered[j] = True - mean, sd = serving_prior(bundle) if prior is None else ( - np.asarray(prior[0], dtype=float), np.asarray(prior[1], dtype=float)) - res = dict( - core.cat_next_item( - y, administered, prior_mean=mean, prior_sd=sd, - q_theta=int(bundle["quadrature"]["q_theta"]), xi_rule="gh", - q_xi=int(bundle["quadrature"]["q_xi"]), - device=str(device), - **_bundle_bank_args(bundle), - ) - ) - res["ranked_codes"] = [items[i]["code"] for i in res["ranked_items"]] - return res - - -def plausible_values( - bundle: dict[str, Any], - responses: dict[str, Any] | list[dict[str, Any]] | np.ndarray, - n_draws: int = 5, - seed: int = 1, - prior: tuple[np.ndarray, np.ndarray] | None = None, - device: str = "auto", -) -> np.ndarray: - """Draw posterior plausible values for secondary analyses. - - The fixed item bank and discrete quadrature-grid sampler are repository - choices; this function does not propagate item-parameter uncertainty. - Returns persons x n_draws x n_dims. - ``device="auto"`` prefers the Rust wgpu posterior-reduction and sampling - kernels and falls back to the bounded parallel Rust f64 CPU path. - - References - ---------- - Marsman, M., Maris, G., Bechger, T., & Glas, C. (2016). What can we learn - from plausible values? *Psychometrika, 81*(2), 274–289. - https://doi.org/10.1007/s11336-016-9497-x - """ - core = _core_module() - if core is None: - raise RuntimeError("plausible_values requires the compiled Rust core") - _validate_bundle(bundle) - if not isinstance(n_draws, (int, np.integer)) or isinstance( - n_draws, (bool, np.bool_) - ): - raise ValueError("n_draws must be an integer") - draw_count = int(n_draws) - if not (1 <= draw_count <= MAX_DRAWS): - raise ValueError(f"n_draws must be between 1 and {MAX_DRAWS}") - items = bundle["items"] - n_items = bundle["n_items"] - code_to_col = {it["code"]: j for j, it in enumerate(items)} - if isinstance(responses, dict): - responses = [responses] - if isinstance(responses, list): - # Bound the dense respondent matrix before allocating: len(responses) - # and n_items are both request/bundle controlled (memory-exhaustion DoS). - if len(responses) * n_items > MAX_SCORE_CELLS: - raise ValueError( - f"response matrix ({len(responses)} x {n_items}) exceeds the " - f"{MAX_SCORE_CELLS}-cell scoring limit" - ) - y = np.full((len(responses), n_items), np.nan) - for r, resp in enumerate(responses): - if not isinstance(resp, dict): - raise ValueError( - "each response must be an object mapping item codes to responses" - ) - for code, value in resp.items(): - j = code_to_col.get(code) - if j is None: - raise ValueError(f"unknown item code {code!r}") - y[r, j] = float(bool(value)) if isinstance(value, bool) else float(value) - else: - y = np.asarray(responses, dtype=float) - if y.ndim != 2 or y.shape[1] != n_items: - raise ValueError("responses must be a 2-D persons x n_items array") - if y.size > MAX_SCORE_CELLS: - raise ValueError( - f"response matrix exceeds the {MAX_SCORE_CELLS}-cell scoring limit" - ) - output_cells = int(y.shape[0]) * draw_count * int(bundle["n_dims"]) - if output_cells > MAX_SERVING_OUTPUT_CELLS: - raise ValueError( - f"plausible-values output size ({output_cells} cells) exceeds the " - f"{MAX_SERVING_OUTPUT_CELLS}-cell serving limit" - ) - observed = ~np.isnan(y) - obs_vals = y[observed] - if obs_vals.size and not np.all((obs_vals == 0.0) | (obs_vals == 1.0)): - raise ValueError("observed responses must be 0 or 1") - mean, sd = serving_prior(bundle) if prior is None else ( - np.asarray(prior[0], dtype=float), np.asarray(prior[1], dtype=float)) - pv = core.plausible_values( - np.where(observed, y, 0.0).ravel(), observed.ravel(), int(y.shape[0]), - prior_mean=mean, prior_sd=sd, - q_theta=int(bundle["quadrature"]["q_theta"]), xi_rule="gh", - q_xi=int(bundle["quadrature"]["q_xi"]), n_draws=draw_count, seed=int(seed), - device=str(device), - **_bundle_bank_args(bundle), - ) - return np.asarray(pv).reshape(y.shape[0], draw_count, bundle["n_dims"]) diff --git a/python/fast_mlsirm/subscores.py b/python/fast_mlsirm/subscores.py deleted file mode 100644 index 23595a137..000000000 --- a/python/fast_mlsirm/subscores.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Haberman subscore added-value analysis via proportional reduction in mean -squared error (PRMSE; Haberman, 2008, as cited in Sinharay, 2010). All numeric -work happens in the Rust core; this module only validates and marshals -arrays.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass -class SubscoreResult: - """Haberman subscore added-value analysis for ``K`` subscales. - - ``alpha`` are the per-subscale Cronbach alphas ( = ``prmse_s``); - ``alpha_total`` the total-test alpha. ``corr`` is the ``(K+1) x (K+1)`` - correlation matrix of the observed subscores with the total score last; - ``disattenuated_corr`` the ``K x K`` disattenuated subscore correlations - (NaN diagonal). ``prmse_s``/``prmse_x``/``prmse_sx`` are the PRMSEs of - predicting the true subscore from the observed subscore, the observed - total, and both; ``tau``/``beta``/``gamma`` the augmented-regression - weights. ``added_value_s`` is Haberman's rule ``PRMSE_s > PRMSE_x``; - ``added_value_sx`` uses Sinharay's (2010) ``+ 0.01`` margin. - ``observed`` (``n x K``), ``total`` (``n``), and the three estimator - matrices ``subscore_s``/``subscore_x``/``subscore_sx`` (each ``n x K``) - give per-person scores.""" - - alpha: np.ndarray - alpha_total: float - corr: np.ndarray - disattenuated_corr: np.ndarray - prmse_s: np.ndarray - prmse_x: np.ndarray - prmse_sx: np.ndarray - tau: np.ndarray - beta: np.ndarray - gamma: np.ndarray - added_value_s: np.ndarray - added_value_sx: np.ndarray - observed: np.ndarray - total: np.ndarray - subscore_s: np.ndarray - subscore_x: np.ndarray - subscore_sx: np.ndarray - - -def subscore_analysis( - responses: np.ndarray, - groups: np.ndarray, -) -> SubscoreResult: - """Haberman subscore added-value analysis (compute in Rust; Haberman, - 2008, as cited in Sinharay, 2010). - - Decides, for each subscale of a test, whether reporting its subscore adds - value over reporting the total score alone, by comparing the PRMSEs of - three classical-test-theory estimators of the true subscore (from the - observed subscore, from the observed total, and from both jointly). - Formulas follow the Appendix of Sinharay (2010) and the CRAN ``subscore`` - package R source (both read); Haberman (2008) and Wainer et al. (2001) - are cited only through Sinharay (2010). Degenerate samples (any Cronbach - alpha outside ``(0, 1]``, zero-variance scores, a subscore collinear with - the total) are rejected with ``ValueError`` rather than propagating NaN. - - In LLM-as-a-Judge item-quality management this decides whether - per-domain judge subscores carry diagnostic information beyond the - overall score, or whether reporting them would be statistically - misleading. - - ``responses`` is a complete ``persons x items`` array of scored - responses (``n >= 3``). ``groups`` assigns each item an integer subscale - index in ``0..K`` (``K >= 2``, every subscale with at least 2 items, - partition exhaustive by construction). - - References (APA 7th ed.): - Haberman, S. J. (2008). When can subscores have value? *Journal of - Educational and Behavioral Statistics, 33*(2), 204-229. - https://doi.org/10.3102/1076998607302636 (as cited in Sinharay, - 2010) - Sinharay, S. (2010). *When can subscores be expected to have added - value? Results from operational and simulated data* (ETS - Research Rep. No. RR-10-16). Educational Testing Service. - Wainer, H., Vevea, J. L., Camacho, F., Reeve, B. B., Rosa, K., & - Nelson, L. (2001). Augmented scores — "borrowing strength" to - compute scores based on small numbers of items. In D. Thissen & - H. Wainer (Eds.), *Test scoring* (pp. 343-387). Lawrence - Erlbaum. (as cited in Sinharay, 2010) - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "subscore_analysis"): - raise RuntimeError("subscore_analysis requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - if n_persons < 3 or n_items < 4: - # K >= 2 subscales with >= 2 items each needs at least 4 items; - # rejecting here keeps degenerate shapes (e.g. huge zero-column - # arrays) from crossing the Rust boundary at all. - raise ValueError("responses needs at least 3 persons and 4 items") - if not np.all(np.isfinite(y)): - raise ValueError("responses must be complete (no missing values)") - - g = np.asarray(groups).reshape(-1) - if g.shape[0] != n_items: - raise ValueError("groups must assign one subscale index per item") - if not np.issubdtype(g.dtype, np.integer): - gf = np.asarray(groups, dtype=np.float64).reshape(-1) - if not np.all(np.isfinite(gf)) or np.any(gf != np.round(gf)): - raise ValueError("groups must be integer subscale indices") - g = gf.astype(np.int64) - if np.any(g < 0): - raise ValueError("groups must be nonnegative subscale indices") - if np.any(g >= n_items): - # trust boundary: the subscale count drives Rust-side allocations - raise ValueError("groups indices must be < n_items") - - res = core.subscore_analysis( - y.reshape(-1), int(n_persons), int(n_items), [int(v) for v in g] - ) - k = len(res["alpha"]) - return SubscoreResult( - alpha=np.asarray(res["alpha"], dtype=np.float64), - alpha_total=float(res["alpha_total"]), - corr=np.asarray(res["corr"], dtype=np.float64).reshape(k + 1, k + 1), - disattenuated_corr=np.asarray( - res["disattenuated_corr"], dtype=np.float64 - ).reshape(k, k), - prmse_s=np.asarray(res["prmse_s"], dtype=np.float64), - prmse_x=np.asarray(res["prmse_x"], dtype=np.float64), - prmse_sx=np.asarray(res["prmse_sx"], dtype=np.float64), - tau=np.asarray(res["tau"], dtype=np.float64), - beta=np.asarray(res["beta"], dtype=np.float64), - gamma=np.asarray(res["gamma"], dtype=np.float64), - added_value_s=np.asarray(res["added_value_s"], dtype=bool), - added_value_sx=np.asarray(res["added_value_sx"], dtype=bool), - observed=np.asarray(res["observed"], dtype=np.float64).reshape( - n_persons, k - ), - total=np.asarray(res["total"], dtype=np.float64), - subscore_s=np.asarray(res["subscore_s"], dtype=np.float64).reshape( - n_persons, k - ), - subscore_x=np.asarray(res["subscore_x"], dtype=np.float64).reshape( - n_persons, k - ), - subscore_sx=np.asarray(res["subscore_sx"], dtype=np.float64).reshape( - n_persons, k - ), - ) diff --git a/python/fast_mlsirm/testlet.py b/python/fast_mlsirm/testlet.py deleted file mode 100644 index a4b4281cf..000000000 --- a/python/fast_mlsirm/testlet.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Testlet response model (Bradlow, Wainer, & Wang, 1999): a random-effects IRT model -for the local dependence induced when items share a common stimulus (a passage), fit -by marginal-ML EM in the Rust core.""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER - - -MAX_TESTLET_RESPONSE_CELLS = 20_000_000 -_SUPPORTED_Q_GAMMA = (7, 11, 15, 21, 31, 41) - - -@dataclass -class TestletFit: - """Fitted testlet model (Bradlow, Wainer, & Wang, 1999). - - ``a``/``b`` are the per-item discriminations and difficulties (``a`` is all ones - for the Rasch model); ``beta = -a*b`` the intercept metric; ``sigma2`` the - per-testlet variances ``sigma^2_d`` — the local-dependence estimand, one per - testlet, where a large value flags strong within-testlet dependence and all zero - is ordinary conditional-independence 2PL/Rasch. ``theta`` is the per-person EAP - ability. Singleton testlets (one item) have ``sigma^2_d`` pinned to 0.""" - - model: str - a: np.ndarray - b: np.ndarray - beta: np.ndarray - sigma2: np.ndarray - theta: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - n_parameters: int - termination_reason: str = "unknown" - final_loglik_change: float = np.nan - - -def fit_testlet( - responses: np.ndarray, - testlet_id: np.ndarray, - model: str = "rasch", - max_iter: int = 500, - tol: float = 1e-6, - q_gamma: int = 21, - estimate_sigma: bool = True, - init_sigma2: float = 0.5, - require_convergence: bool = False, -) -> TestletFit: - """Fit the testlet response model (compute in Rust; Bradlow, Wainer, & Wang, 1999). - - A testlet is a bundle of items sharing a stimulus; each item ``i`` in testlet - ``d(i)`` gets a person-specific random effect ``gamma_{j,d(i)} ~ N(0, sigma^2_d)``, - so ``P(X_ij=1) = sigmoid(a_i*(theta_j - b_i - gamma_{j,d(i)}))`` (Rasch fixes - ``a_i=1``). The per-testlet variance ``sigma^2_d`` measures within-testlet local - dependence; ``sigma^2_d = 0`` for every testlet is the ordinary 2PL/Rasch model, - to which this reduces exactly (``estimate_sigma=False, init_sigma2=0``). Estimated - by marginal-ML EM with a theta-outer / per-testlet-gamma-inner nested Gauss-Hermite - quadrature (cost independent of the number of testlets), accelerated with SQUAREM. - - ``responses`` is a persons x items 0/1 array (``NaN`` = missing, dropped under MAR); - ``testlet_id`` is a length-items integer array assigning each item to a testlet. - Use ``model="rasch"`` for the well-identified case; in the 2PL testlet the - discrimination ``a_i`` and the testlet SD ``sigma_d`` both scale the dependence via - ``a_i*sigma_d`` and separate only weakly. The variance-component EM converges - linearly, so a large ``sigma^2_d`` may want a generous ``max_iter``. - Non-convergence emits ``RuntimeWarning`` and is recorded in - ``termination_reason``; set ``require_convergence=True`` to raise instead. - The repository-specific execution policy limits ``max_iter`` to 100,000 and - the response matrix to 20,000,000 cells; these are resource guards, not - properties of the testlet model. - - References (APA 7th ed.): - Bradlow, E. T., Wainer, H., & Wang, X. (1999). A Bayesian random effects model - for testlets. *Psychometrika, 64*(2), 153-168. - https://doi.org/10.1007/BF02294533 - Wang, X., Bradlow, E. T., & Wainer, H. (2002). A general Bayesian model for - testlets. *Applied Psychological Measurement, 26*(1), 109-128. - https://doi.org/10.1177/0146621602026001007 - """ - raw_y = np.asarray(responses) - if raw_y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - raw_tid = np.asarray(testlet_id) - if raw_tid.ndim != 1: - raise ValueError("testlet_id must be a 1-D array") - n_persons, n_items = raw_y.shape - if n_items < 1: - raise ValueError("responses and testlet_id must describe a non-empty item bank") - if n_persons < 1: - raise ValueError("responses must contain at least one person") - if raw_y.size > MAX_TESTLET_RESPONSE_CELLS: - raise ValueError( - "response matrix exceeds the " - f"{MAX_TESTLET_RESPONSE_CELLS}-cell testlet-calibration limit" - ) - try: - y = np.asarray(raw_y, dtype=np.float64) - except (TypeError, ValueError) as exc: - raise ValueError("responses must contain numeric 0/1 values or NaN") from exc - if not np.all(np.isnan(y) | (np.isfinite(y) & ((y == 0.0) | (y == 1.0)))): - raise ValueError("responses must be 0/1 or NaN (missing)") - if raw_tid.shape[0] != n_items: - raise ValueError("testlet_id must have length n_items") - if not np.issubdtype(raw_tid.dtype, np.integer) or np.issubdtype( - raw_tid.dtype, np.bool_ - ): - raise ValueError("testlet_id entries must be integers") - if not np.all((raw_tid >= 0) & (raw_tid < n_items)): - raise ValueError("testlet_id entries must be between 0 and n_items - 1") - tid = raw_tid.astype(np.int64, copy=False) - n_testlets = int(tid.max()) + 1 - if ( - isinstance(max_iter, (bool, np.bool_)) - or not isinstance(max_iter, (int, np.integer)) - or not 1 <= int(max_iter) <= MAX_MAX_ITER - ): - raise ValueError(f"max_iter must be an integer between 1 and {MAX_MAX_ITER}") - if isinstance(tol, (bool, np.bool_)) or not isinstance( - tol, (int, float, np.integer, np.floating) - ): - raise ValueError("tol must be a finite non-negative number") - tol_value = float(tol) - if not np.isfinite(tol_value) or tol_value < 0.0: - raise ValueError("tol must be a finite non-negative number") - if ( - isinstance(q_gamma, (bool, np.bool_)) - or not isinstance(q_gamma, (int, np.integer)) - or int(q_gamma) not in _SUPPORTED_Q_GAMMA - ): - raise ValueError(f"q_gamma must be one of {_SUPPORTED_Q_GAMMA}") - if isinstance(init_sigma2, (bool, np.bool_)) or not isinstance( - init_sigma2, (int, float, np.integer, np.floating) - ): - raise ValueError("init_sigma2 must be a finite non-negative number") - init_sigma2_value = float(init_sigma2) - if not np.isfinite(init_sigma2_value) or init_sigma2_value < 0.0: - raise ValueError("init_sigma2 must be a finite non-negative number") - - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_testlet"): - raise RuntimeError("fit_testlet requires the compiled Rust core") - - observed = ~np.isnan(y) - yy = np.where(observed, y, 0.0).reshape(-1) - res = core.fit_testlet( - yy, - observed.reshape(-1), - tid, - int(n_persons), - int(n_items), - int(n_testlets), - str(model), - int(max_iter), - tol_value, - int(q_gamma), - bool(estimate_sigma), - init_sigma2_value, - ) - fit = TestletFit( - model=str(res["model"]), - a=np.asarray(res["a"], dtype=np.float64), - b=np.asarray(res["b"], dtype=np.float64), - beta=np.asarray(res["beta"], dtype=np.float64), - sigma2=np.asarray(res["sigma2"], dtype=np.float64), - theta=np.asarray(res["theta"], dtype=np.float64), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - n_parameters=int(res["n_parameters"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - ) - if not fit.converged: - message = ( - "testlet calibration did not converge: " - f"reason={fit.termination_reason}, iterations={fit.n_iter}/{max_iter}, " - "final_loglik_change=" - f"{fit.final_loglik_change:.12g}, tolerance={tol_value:.12g}" - ) - if require_convergence: - raise RuntimeError(message) - warnings.warn(message, RuntimeWarning, stacklevel=2) - return fit diff --git a/python/fast_mlsirm/twopl.py b/python/fast_mlsirm/twopl.py deleted file mode 100644 index 19b3ce26f..000000000 --- a/python/fast_mlsirm/twopl.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Dimension-agnostic compensatory 2PL item response model. - -Reckase (2009) / Bock, Gibbons & Muraki (1988) full-information item factor model, in -which an item may load freely on several latent dimensions that trade off additively in the -logit. Factors are orthogonal by default (``estimate_corr=False``, ``Sigma = I``) or their -correlation matrix is estimated (``estimate_corr=True``). Estimated in the Rust core over a -product Gauss-Hermite grid.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - -from .config import MAX_MAX_ITER, MAX_XI_POINTS -from .models import ConfirmatoryModel, ExploratoryModel, IrtModel, _resolve_model - - -_SUPPORTED_Q = (7, 11, 15, 21, 31, 41) -_MAX_DIMS = 3 - - -@dataclass -class TwoPlFit: - """Fitted compensatory 2PL item response model (Reckase, 2009). - - ``loading`` is the items x dimensions matrix of free loadings ``a_id`` (exactly ``0`` - where the confirmatory model loading pattern is ``0``); ``intercept`` the per-item ``b_i``; ``theta`` - the persons x dimensions trait EAP; ``corr`` the ``n_dims x n_dims`` latent correlation - matrix (identity when ``estimate_corr=False``, estimated off-diagonals otherwise). The - model is ``P(X_ij=1 | theta_j) = sigmoid(sum_d a_id theta_jd + b_i)`` with - ``theta_j ~ MVN(0, Sigma)``, ``Sigma`` a unit-diagonal correlation matrix. - ``termination_reason`` is either ``"converged"`` or ``"max_iter_reached"``; - ``final_loglik_change`` is the absolute difference between the final two evaluated - marginal log-likelihoods.""" - - model: IrtModel - loading: np.ndarray - intercept: np.ndarray - theta: np.ndarray - corr: np.ndarray - loglik_trace: np.ndarray - n_iter: int - converged: bool - n_parameters: int - termination_reason: str = "unknown" - final_loglik_change: float = np.nan - - @property - def n_dims(self) -> int: - """Latent dimension count derived from :attr:`model`.""" - - return self.model.n_dims - - -def fit_2pl( - responses: np.ndarray, - model: int | ExploratoryModel | ConfirmatoryModel = 1, - q: int = 21, - estimate_corr: bool = False, - max_iter: int = 500, - tol: float = 1e-6, - node_rule: str = "gh", - xi_points: int = 4000, - xi_seed: int = 0x9E37_79B9_7F4A_7C15, -) -> TwoPlFit: - """Fit the compensatory 2PL item response model (compute in Rust; Reckase, 2009; - Bock, Gibbons & Muraki, 1988). - - A general COMPENSATORY multidimensional 2PL: an item may load freely on several latent - dimensions, which trade off ADDITIVELY inside a single logit, - ``P(X_ij=1 | theta_j) = sigmoid(sum_{d in S_i} a_id theta_jd + b_i)`` with - ``theta_j ~ MVN(0, I_D)``. ``S_i`` is item ``i``'s loading set from the confirmatory model specification; - ``a_id`` is a free loading for ``d in S_i`` - (zero otherwise). This is distinct from the simple-structure MIRT (one dimension per - item) and the orthogonal bifactor (one primary + one general per item): arbitrary - within-item cross-loadings are allowed, which is why it needs the full ``q**n_dims`` - product quadrature (``n_dims <= 3``). - - Identification: unit trait variances fix the loading scale; the confirmatory pattern - labels the dimensions PROVIDED every dimension has at least one PURE single-loading - anchor item (rotationally-degenerate patterns such as all-ones are rejected); the - per-dimension sign is fixed by a reflection anchor. Loadings are NOT constrained - non-negative — reverse-keyed and suppressor cross-loadings are representable. - - **Latent traits.** With ``estimate_corr=False`` (default) the factors are ORTHOGONAL - (``theta ~ MVN(0, I)``). With ``estimate_corr=True`` the inter-factor CORRELATION matrix - ``Sigma`` (unit diagonal) is estimated by an ECM step (the standard grid is mapped - through ``chol(Sigma)`` and the correlations ascend the Gaussian-prior objective with a - positive-definite, monotone guard). - - **Integration nodes (``node_rule``).** ``"gh"`` (default) uses the exact ``q**n_dims`` - Gauss-Hermite product grid and caps ``n_dims <= 3``. For ``n_dims = 4, 5, 6`` use - ``"qmc"`` (Halton quasi-Monte-Carlo, Jank 2005) or ``"mc"`` (plain Monte-Carlo): the E-step - integral is evaluated at ``xi_points`` points drawn from the prior (equal weights) instead - of the product grid, leaving the item and ``Sigma`` M-steps unchanged. QMC carries an - ``O(N**-1 (log N)**D)`` finite-node bias that grows with the dimension, so ``n_dims = 5, 6`` - need materially larger ``xi_points``; ``xi_seed`` (nonzero by default) applies a - Cranley-Patterson random shift that de-correlates the higher Halton axes. ``q`` is used only - by ``"gh"``; ``xi_points``/``xi_seed`` only by ``"qmc"``/``"mc"``. - - ``responses`` is a persons x items 0/1 array (``NaN`` = missing, dropped under MAR); - For ``model=1``, all item loadings on the single factor are free. A - multidimensional confirmatory structure is supplied with - ``model=models.confirmatory(loading_pattern)``; a numeric exploratory model greater than - one is rejected until unrestricted loading rotation and identification are implemented. - ``q`` is the Gauss-Hermite node count per dimension (one of ``7, 11, 15, 21, 31, 41``). Convergence requires the absolute - change between consecutive evaluated marginal log-likelihoods to be less than ``tol``; - the returned fit exposes that value as ``final_loglik_change`` and the terminal state as - ``termination_reason``. - - References (APA 7th ed.): - Reckase, M. D. (2009). *Multidimensional item response theory*. Springer. - https://doi.org/10.1007/978-0-387-89976-3 - Bock, R. D., Gibbons, R., & Muraki, E. (1988). Full-information item factor - analysis. *Applied Psychological Measurement, 12*(3), 261-280. - https://doi.org/10.1177/014662168801200305 - Jank, W. (2005). Quasi-Monte Carlo sampling to improve the efficiency of Monte - Carlo EM. *Computational Statistics & Data Analysis, 48*(4), 685-701. - https://doi.org/10.1016/j.csda.2004.03.019 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_2pl"): - raise RuntimeError("fit_2pl requires the compiled Rust core") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim != 2: - raise ValueError("responses must be a 2-D persons x items array") - n_persons, n_items = y.shape - resolved_model, pat = _resolve_model(model, n_items) - n_dims = pat.shape[1] - # The Gauss-Hermite product grid caps D <= _MAX_DIMS; the QMC/MC rules reach D <= 6 (the Halton - # prime axes). The core does the authoritative rule-dependent check; this mirrors it up front. - _gh = str(node_rule).lower() in ("gh", "gauss-hermite", "gausshermite") - _max_dims = _MAX_DIMS if _gh else 6 - if not 1 <= n_dims <= _max_dims: - raise ValueError( - f"loading_pattern dimensions must be between 1 and {_max_dims} " - f"(node_rule={node_rule!r})" - ) - if np.isinf(y).any(): - raise ValueError("responses must be 0, 1, or NaN (missing)") - - def _finite_integer(value: int, name: str) -> int: - scalar = np.asarray(value) - if ( - scalar.ndim != 0 - or not np.issubdtype(scalar.dtype, np.number) - or np.iscomplexobj(scalar) - ): - raise ValueError(f"{name} must be a finite integer") - numeric = float(scalar) - if not np.isfinite(numeric) or numeric != np.floor(numeric): - raise ValueError(f"{name} must be a finite integer") - return int(numeric) - - q_int = _finite_integer(q, "q") - max_iter_int = _finite_integer(max_iter, "max_iter") - # q is used only by the Gauss-Hermite rule; the QMC/MC rules ignore it (matching the core). - if _gh and q_int not in _SUPPORTED_Q: - raise ValueError(f"q must be one of {_SUPPORTED_Q}") - xi_points_int = _finite_integer(xi_points, "xi_points") - if not (1 <= max_iter_int <= MAX_MAX_ITER): - raise ValueError(f"max_iter must be in 1..{MAX_MAX_ITER}") - if not _gh and not (1 <= xi_points_int <= MAX_XI_POINTS): - raise ValueError(f"xi_points must be in 1..{MAX_XI_POINTS}") - # xi_seed is a full-range u64 (default 0x9E37_79B9_7F4A_7C15): validate it as an EXACT integer - # WITHOUT a float64 round-trip. _finite_integer casts through float(), which silently rounds any - # value >= 2^53 (the default drifts, breaking Rust<->Python parity) and overflows u64 near the - # top of the range (raising OverflowError in the PyO3 conversion). - if isinstance(xi_seed, bool) or not isinstance(xi_seed, (int, np.integer)): - raise ValueError("xi_seed must be a non-negative integer") - xi_seed_int = int(xi_seed) - if not 0 <= xi_seed_int < 2**64: - raise ValueError("xi_seed must be in [0, 2**64)") - - observed = ~np.isnan(y) - yy = np.where(observed, y, 0.0).reshape(-1) - res = core.fit_2pl( - yy, - observed.reshape(-1), - pat.astype(np.int64).reshape(-1), - int(n_persons), - int(n_items), - int(n_dims), - q_int, - bool(estimate_corr), - max_iter_int, - float(tol), - str(node_rule), - xi_points_int, - xi_seed_int, - ) - return TwoPlFit( - model=resolved_model, - loading=np.asarray(res["loading"], dtype=np.float64).reshape(n_items, n_dims), - intercept=np.asarray(res["intercept"], dtype=np.float64), - theta=np.asarray(res["theta"], dtype=np.float64).reshape(n_persons, n_dims), - corr=np.asarray(res["corr"], dtype=np.float64).reshape(n_dims, n_dims), - loglik_trace=np.asarray(res["loglik_trace"], dtype=np.float64), - n_iter=int(res["n_iter"]), - converged=bool(res["converged"]), - n_parameters=int(res["n_parameters"]), - termination_reason=str(res["termination_reason"]), - final_loglik_change=float(res["final_loglik_change"]), - ) diff --git a/python/fast_mlsirm/types.py b/python/fast_mlsirm/types.py index 9044f991a..954755ebe 100644 --- a/python/fast_mlsirm/types.py +++ b/python/fast_mlsirm/types.py @@ -56,20 +56,13 @@ class FitResult: objective_trace: list[float] convergence_status: str n_iter: int - # Marginal (MMLE) fits: population-structure estimates and posterior SDs. - # Keys (present when applicable): "kind", "mu", "sigma" (multigroup), - # "sigma_u", "u_eap", "icc" (multilevel), "theta_sd". - population: dict[str, Any] | None = None - # Marginal fits: information criteria (Kang, Cohen & Sung 2009) — - # {"aic", "bic", "aicc", "sabic", "caic", "n_parameters", "n"}. - ic: dict[str, Any] | None = None @dataclass class FitDiagnostics: itemfit: dict[str, np.ndarray] personfit: dict[str, np.ndarray] - model_fit: dict[str, Any] + model_fit: dict[str, float] factorfit: dict[str, np.ndarray] | None = None categoryfit: dict[str, np.ndarray] | None = None groupfit: dict[str, np.ndarray] | None = None diff --git a/python/fast_mlsirm/utility.py b/python/fast_mlsirm/utility.py deleted file mode 100644 index c4152203b..000000000 --- a/python/fast_mlsirm/utility.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Classical selection utility analysis (Taylor-Russell, Naylor-Shine, -Brogden-Cronbach-Gleser). All numeric work happens in the Rust core -(``mlsirm_core::utility``); this module only validates and marshals. - -Source status: the formulas are a transcription of the CRAN iopsych 0.90.1 -package (Goebl, Jones, & Beatty — READ: ``R/utility.R`` ``utilityBcg``/``trModel``/ -``.utilitySwitch`` and ``R/ai.R`` ``ux``), independently re-derived from the -standard bivariate-normal selection model and verified against a scipy -oracle (``scipy.stats.norm``, ``scipy.stats.multivariate_normal``). The -original Taylor & Russell (1939), Naylor & Shine (1965), and Cronbach & -Gleser (1965) sources were NOT read; they are cited as the models' -origins as attributed by iopsych, and every implemented equation was -verified numerically rather than taken on faith from the citations. - -REDUCED SCOPE (spec decision): only the three classical models. No -Boudreau (1983) financial extension, no Raju-Burke-Normand (1990), no -multiple-hurdle or banded selection. ``|rxy| >= 1`` is rejected. - -References (APA 7th ed.): - Cronbach, L. J., & Gleser, G. C. (1965). *Psychological tests and - personnel decisions* (2nd ed.). University of Illinois Press. - (As cited in iopsych; not read.) - Goebl, A., Jones, J., & Beatty, A. (2016). *iopsych: Methods for industrial/ - organizational psychology* (Version 0.90.1) [R package]. - https://CRAN.R-project.org/package=iopsych - Naylor, J. C., & Shine, L. C. (1965). A table for determining the - increase in mean criterion score obtained by using a selection - device. *Journal of Industrial Psychology, 3*(2), 33-42. - (As cited in iopsych; not read.) - Taylor, H. C., & Russell, J. T. (1939). The relationship of validity - coefficients to the practical effectiveness of tests in selection: - Discussion and tables. *Journal of Applied Psychology, 23*(5), - 565-578. https://doi.org/10.1037/h0057079 (As cited in iopsych; - not read.) -""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class SelectionUtilityResult: - """Brogden-Cronbach-Gleser / Naylor-Shine utility output. - - ``xc`` is the standard-normal predictor cutoff ``Phi^-1(1 - sr)``; - ``ux`` the selection intensity ``phi(xc)/sr`` (mean standardized - predictor of those selected); ``pux = rxy * ux`` the Naylor-Shine mean - standardized criterion of those selected; ``utility_gain`` the BCG - gain ``n * period * sdy * pux - cost_total``.""" - - xc: float - ux: float - pux: float - utility_gain: float - - -@dataclass -class TaylorRussellResult: - """Taylor-Russell (1939) output: ``success_ratio`` is - ``P(Y > yc | X > xc)`` — the proportion of selected applicants who - succeed — under the standard bivariate-normal model; ``q_joint`` is - the joint tail ``P(X > xc, Y > yc)``.""" - - success_ratio: float - base_rate: float - q_joint: float - - -def selection_utility( - n: float, - sdy: float, - rxy: float, - sr: float, - cost_total: float = 0.0, - period: float = 1.0, -) -> SelectionUtilityResult: - """Brogden-Cronbach-Gleser utility of a top-down selection system. - - Parameters mirror iopsych ``utilityBcg``: ``n`` selectees per period - (>= 1), ``sdy`` monetary SD of job performance (>= 0), ``rxy`` - validity coefficient in (-1, 1), ``sr`` selection ratio in (0, 1), - ``cost_total`` TOTAL selection cost (iopsych labels its ``cost`` "per - applicant" but never multiplies by ``n``; we document the actual - semantics), ``period`` expected tenure (>= 1). - """ - from . import _core - - r = _core.selection_utility( - float(n), float(sdy), float(rxy), float(sr), float(cost_total), float(period) - ) - return SelectionUtilityResult( - xc=r["xc"], ux=r["ux"], pux=r["pux"], utility_gain=r["utility_gain"] - ) - - -def taylor_russell(rxy: float, sr: float, br: float) -> TaylorRussellResult: - """Taylor-Russell (1939) success ratio for a dichotomous criterion. - - ``rxy`` validity in (-1, 1), ``sr`` selection ratio in (0, 1), ``br`` - base rate of success in (0, 1). At ``rxy = 0`` the success ratio - equals ``br`` (no selection information). - """ - from . import _core - - r = _core.taylor_russell(float(rxy), float(sr), float(br)) - return TaylorRussellResult( - success_ratio=r["success_ratio"], - base_rate=r["base_rate"], - q_joint=r["q_joint"], - ) diff --git a/python/fast_mlsirm/validation.py b/python/fast_mlsirm/validation.py deleted file mode 100644 index 61803de66..000000000 --- a/python/fast_mlsirm/validation.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Machine-scoring validation gates for LLM-as-a-Judge calibration. - -Implements the operational criteria of Williamson, Xi & Breyer (2012), -"A Framework for Evaluation and Use of Automated Scoring" (EM:IP 31(1)): -quadratic-weighted kappa >= .70, Pearson r >= .70, degradation from the -human-human baseline <= .10, |SMD| <= .15 overall and <= .10 within every -subgroup; exact/adjacent agreement are reported but are explicitly NOT gates. -All computation runs in the Rust core (`mlsirm_core::agreement`). -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import numpy as np - - -def _validate_labels(a, name: str, *, k: int | None = None, n: int | None = None) -> np.ndarray: - """Validate caller-supplied category labels before the uint32 conversion the - Rust gate expects: reject non-1-D, wrong-length, non-finite, non-integer, - negative, or (when ``k`` given) out-of-range values instead of silently - truncating/wrapping them (which would let malformed labels pass the gate).""" - arr = np.asarray(a) - if arr.ndim != 1: - raise ValueError(f"{name} must be a 1-D array") - if n is not None and arr.shape[0] != n: - raise ValueError(f"{name} length must match the paired labels") - if arr.size == 0: - raise ValueError(f"{name} must be non-empty") - if not np.all(np.isfinite(arr.astype(np.float64))): - raise ValueError(f"{name} must be finite") - fl = arr.astype(np.float64) - if np.any(fl < 0) or np.any(fl != np.floor(fl)): - raise ValueError(f"{name} must be non-negative integers") - if np.any(fl > np.iinfo(np.uint32).max): - raise ValueError(f"{name} values must fit in uint32") - if k is not None and np.any(fl >= k): - raise ValueError(f"{name} values must be in 0..k-1") - return arr.astype(np.uint32) - - -@dataclass -class ValidationVerdict: - gates: list[dict[str, Any]] - exact_agreement: float - adjacent_agreement: float - passed: bool - failed_gates: list[str] = field(default_factory=list) - - -def validate_judge( - judge: np.ndarray, - human: np.ndarray, - k: int = 2, - human_human: tuple[np.ndarray, np.ndarray] | None = None, - subgroup: np.ndarray | None = None, -) -> ValidationVerdict: - """Run the Williamson et al. (2012) conjunctive acceptance gates. - - ``judge``/``human`` are paired labels in ``0..k-1``; ``human_human`` is an - optional double-scored human baseline (pair of label vectors) for the - degradation criterion; ``subgroup`` labels each observation for the - fairness SMD. - """ - from . import _core # computation lives in the Rust core - - MAX_JUDGE_CATEGORIES = 1_000 - if int(k) < 2: - raise ValueError("k (number of categories) must be >= 2") - if int(k) > MAX_JUDGE_CATEGORIES: - # k drives a dense k-by-k confusion matrix in the Rust core. - raise ValueError(f"k (number of categories) must be <= {MAX_JUDGE_CATEGORIES}") - judge_v = _validate_labels(judge, "judge", k=int(k)) - human_v = _validate_labels(human, "human", k=int(k), n=judge_v.shape[0]) - kwargs: dict[str, Any] = {} - if human_human is not None: - kwargs["human_a"] = _validate_labels( - human_human[0], "human_a", k=int(k), n=judge_v.shape[0] - ) - kwargs["human_b"] = _validate_labels( - human_human[1], "human_b", k=int(k), n=kwargs["human_a"].shape[0] - ) - if subgroup is not None: - sg = _validate_labels(subgroup, "subgroup", n=judge_v.shape[0]) - # Compact to contiguous ids: the Rust core loops 0..max(subgroup)+1, - # so a sparse label (e.g. uint32 max) is an O(n_groups) CPU-DoS. - _uniq, sg_compact = np.unique(sg, return_inverse=True) - kwargs["subgroup"] = sg_compact.astype(np.uint32) - res = _core.validate_scoring( - judge_v, - human_v, - int(k), - **kwargs, - ) - gates = [dict(g) for g in res["gates"]] - return ValidationVerdict( - gates=gates, - exact_agreement=float(res["exact_agreement"]), - adjacent_agreement=float(res["adjacent_agreement"]), - passed=bool(res["pass"]), - failed_gates=[g["name"] for g in gates if not g["pass"]], - ) diff --git a/python/fast_mlsirm/wle.py b/python/fast_mlsirm/wle.py deleted file mode 100644 index de557bc12..000000000 --- a/python/fast_mlsirm/wle.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Warm's (1989) weighted likelihood estimation of ability for unidimensional dichotomous IRT. - -The bias-reduced maximum-likelihood ability estimator: it removes the leading ``O(1/n)`` bias of the MLE -and, unlike the MLE (which diverges to ``+/-infinity`` for a perfect or zero score), yields a finite -estimate for every response pattern. The numerical computation runs in Rust.""" - -from __future__ import annotations - -import numpy as np - - -def score_wle( - a: np.ndarray, - b: np.ndarray, - responses: np.ndarray, - observed: np.ndarray | None = None, - c: np.ndarray | None = None, - d: np.ndarray | None = None, - theta_bound: float = 20.0, - tol: float = 1e-8, -) -> dict[str, np.ndarray]: - """Warm's weighted-likelihood ability estimate for a unidimensional dichotomous test (compute in - Rust; Warm, 1989). - - Solves the weighted-likelihood estimating equation ``dlnL/dtheta + J(theta)/(2 I(theta)) = 0`` with - ``J = sum_i P_i' P_i''/(P_i Q_i)`` (the Warm correction, computed directly). ``J = I'(theta)`` for - the 2PL/Rasch only -- with any ``c > 0`` or ``d < 1`` it is neither ``I'`` nor ``I'/2``, so a - ``sqrt(I)``-weighted estimator applies the wrong 3PL/4PL correction. - Here ``P_i = c_i + (d_i - c_i) sigmoid(a_i (theta - b_i))``. The estimate - removes the leading MLE bias and stays FINITE for the all-correct and all-incorrect patterns, and its - standard error is ``1/sqrt(I(theta))``. - - ``a`` and ``b`` are the per-item slope (NATURAL scale, not log-alpha) and difficulty; ``c`` (lower - asymptote, default ``0``) and ``d`` (upper asymptote, default ``1``) give the 3PL/4PL, with - ``0 <= c_i < d_i <= 1`` (the defaults are the 2PL). ``responses`` is a persons x items ``0/1`` array - (or a single length-items vector; ``NaN`` = missing, dropped per person), and ``observed`` an - optional bool mask (defaults to the non-``NaN`` entries). ``theta_bound`` is the hard clamp on the - root search: when the finite Warm root lies beyond it (very easy/hard items for the pattern) the - estimate is clamped to the boundary and flagged. Returns per-person NumPy arrays ``theta``, ``se``, - and ``boundary``. The Rust implementation adapts its bounded search grid to the steepest item and - raises ``ValueError`` when resolving the global mode would exceed its 65,536-interval work limit; - this numerical policy is specific to fast-mlsirm rather than Warm's statistical result. - - Reference (APA 7th ed.): - Warm, T. A. (1989). Weighted likelihood estimation of ability in item response theory. - *Psychometrika, 54*(3), 427-450. https://doi.org/10.1007/BF02294627 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "score_wle"): - raise RuntimeError("score_wle requires the compiled Rust core") - - a = np.asarray(a, dtype=np.float64).reshape(-1) - b = np.asarray(b, dtype=np.float64).reshape(-1) - n_items = a.shape[0] - if n_items == 0: - raise ValueError("need at least one item") - if b.shape[0] != n_items: - raise ValueError("a and b must have the same length") - c = np.zeros(n_items) if c is None else np.asarray(c, dtype=np.float64).reshape(-1) - d = np.ones(n_items) if d is None else np.asarray(d, dtype=np.float64).reshape(-1) - if c.shape[0] != n_items or d.shape[0] != n_items: - raise ValueError("c and d must have the same length as a") - - y = np.asarray(responses, dtype=np.float64) - if y.ndim == 1: - y = y.reshape(1, -1) - if y.ndim != 2 or y.shape[1] != n_items: - raise ValueError("responses must be (n_persons, n_items) matching the item parameters") - n_persons = y.shape[0] - if observed is None: - observed = ~np.isnan(y) - else: - observed = np.asarray(observed, dtype=bool) - if observed.shape != y.shape: - raise ValueError("observed must match responses shape") - yy = np.where(observed, y, 0.0) - if not np.all(np.isin(yy[observed], (0.0, 1.0))): - raise ValueError("responses must be 0 or 1 where observed (NaN = missing)") - - res = core.score_wle( - a, - b, - c, - d, - yy.reshape(-1), - observed.reshape(-1), - int(n_persons), - int(n_items), - float(theta_bound), - float(tol), - ) - return { - "theta": np.asarray(res["theta"], dtype=np.float64), - "se": np.asarray(res["se"], dtype=np.float64), - "boundary": np.asarray(res["boundary"], dtype=bool), - } - - -def score_wle_poly( - responses: np.ndarray, - slope: np.ndarray, - cat_params: np.ndarray, - n_cat: int, - model: str = "grm", - observed: np.ndarray | None = None, - theta_bound: float = 20.0, - tol: float = 1e-8, -) -> dict[str, np.ndarray]: - """Warm's (1989) weighted-likelihood ability estimates for POLYTOMOUS items (compute in Rust). - - The polytomous counterpart of :func:`score_wle`. Solves - ``dlnL/dtheta + J(theta)/(2 I(theta)) = 0`` with, per item and category ``k``, - ``I = sum_k P'_k**2 / P_k`` and ``J = sum_k P'_k P''_k / P_k`` — the exact generalization of the - dichotomous ``sum_i P' P''/(P Q)``, which is its two-category case. ``J`` is computed DIRECTLY, not - as a derivative of ``I``. - - Unlike :func:`fast_mlsirm.score_polytomous` (EAP) this applies NO prior, so the estimate is not - shrunk toward a population mean — the usual requirement when individual scores are reported. It - stays FINITE for the all-lowest and all-highest response patterns, where the maximum-likelihood - estimate diverges. - - ``model`` is ``"grm"`` or ``"gpcm"``. PCM is the GPCM path with ``slope`` all ones. RSM is NOT - supported: its fitted ``(delta, shared tau)`` parameterization is not convertible through any - exposed API. - - **Verification status.** That the polytomous Warm correction is ``J/(2I)`` with - ``J = sum_k P' P''/P`` is confirmed from the ``catR`` package's source, not from a primary paper; - ``catR``'s Jeffreys-prior branch uses a different expression and the two are kept distinct here - (Magis & Raîche, 2012). Separately, and proved in-repository rather than taken from a source, - ``J = I'`` holds exactly for both families shipped here — that identity is used only as a test - oracle, never as an implementation shortcut, and it fails for models with per-boundary slopes or a - lower asymptote. Because it is exact here, replacing ``J`` by a derivative of ``I`` would be - behaviour-preserving for these two families and no polytomous test can detect it; the anchors that - do are in the dichotomous suite. A family added later must re-derive ``J``. Penfield and Bergeron - (2005) treat the GPCM but their equations were not obtainable and are not the source of anything - here. - - ``responses`` is (n_persons, n_items) with categories in ``0..n_cat``; NaN marks missing unless - ``observed`` is given. ``cat_params`` is (n_items, n_cat - 1). Returns ``theta``, ``se`` and - ``boundary``; a person with no observed items gets NaN with ``boundary`` set. - - References (APA 7th ed.): - Magis, D., & Raîche, G. (2012). Random generation of response patterns under computerized - adaptive testing with the R package catR. *Journal of Statistical Software, 48*(8), 1-31. - https://doi.org/10.18637/jss.v048.i08 - Muraki, E. (1992). A generalized partial credit model: Application of an EM algorithm. - *Applied Psychological Measurement, 16*(2), 159-176. - https://doi.org/10.1177/014662169201600206 - Samejima, F. (1969). Estimation of latent ability using a response pattern of graded scores. - *Psychometrika, 34*(S1), 1-97. https://doi.org/10.1007/BF03372160 - Warm, T. A. (1989). Weighted likelihood estimation of ability in item response theory. - *Psychometrika, 54*(3), 427-450. https://doi.org/10.1007/BF02294627 - """ - from .fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "score_wle_poly"): - raise RuntimeError("score_wle_poly requires the compiled Rust core") - - n_cat = int(n_cat) - if n_cat < 2: - raise ValueError("n_cat must be >= 2") - slope = np.asarray(slope, dtype=np.float64).reshape(-1) - n_items = slope.shape[0] - if n_items == 0: - raise ValueError("need at least one item") - cat = np.asarray(cat_params, dtype=np.float64) - if cat.shape != (n_items, n_cat - 1): - raise ValueError("cat_params must be (n_items, n_cat - 1)") - y = np.asarray(responses, dtype=np.float64) - if y.ndim == 1: - y = y.reshape(1, -1) - if y.ndim != 2 or y.shape[1] != n_items: - raise ValueError("responses must be (n_persons, n_items) matching the item parameters") - n_persons = y.shape[0] - if observed is None: - observed = ~np.isnan(y) - else: - observed = np.asarray(observed, dtype=bool) - if observed.shape != y.shape: - raise ValueError("observed must match responses shape") - yy = np.where(observed, y, 0.0) - seen = yy[observed] - if seen.size and (not np.all(np.isfinite(seen)) or not np.all(seen == np.floor(seen)) - or seen.min() < 0 or seen.max() > n_cat - 1): - raise ValueError("responses must be integers in 0..n_cat-1 where observed (NaN = missing)") - - res = core.score_wle_poly( - yy.reshape(-1).astype(np.int64), - int(n_persons), - int(n_items), - n_cat, - slope, - cat.reshape(-1), - observed.reshape(-1), - str(model), - float(theta_bound), - float(tol), - ) - return { - "theta": np.asarray(res["theta"], dtype=np.float64), - "se": np.asarray(res["se"], dtype=np.float64), - "boundary": np.asarray(res["boundary"], dtype=bool), - } diff --git a/tests/oracles/oracle_utility.py b/tests/oracles/oracle_utility.py deleted file mode 100644 index 0efb6ab43..000000000 --- a/tests/oracles/oracle_utility.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Independent scipy oracle for selection utility analysis. - -Regenerates the numeric fixtures pinned in ``tests/unit/utility_tests.rs`` -and ``tests/test_paper_features.py::TestSelectionUtility``. Values are -printed at full ``repr`` precision; the test files pin these values either -verbatim or rounded to fewer digits, always within the tolerance asserted -by the test (the rho=0 fixture ``q = 0.21`` is the analytic ``sr*br``). -Run with any Python that has numpy + scipy (values were pinned with -scipy 1.x):: - - python tests/oracles/oracle_utility.py - -Quantities: - -- ``ux(sr) = phi(xc)/sr``, ``xc = Phi^-1(1-sr)`` (truncated-normal - selection intensity), -- ``pux = rxy * ux(sr)`` (Naylor-Shine), -- BCG utility ``n*period*sdy*pux - cost_total``, -- Taylor-Russell success ratio ``Q(xc, yc, rho)/sr``, ``yc = Phi^-1(1-br)``. - -``Q`` is computed two independent ways: - -1. the multivariate-normal CDF identity - ``Q(h,k,rho) = 1 - Phi(h) - Phi(k) + F(h,k)``, and -2. adaptive quadrature (``scipy.integrate.quad``, epsrel 1e-12) on the same - conditional-normal integral the Rust core uses, - ``int_h^inf phi(x) Phi((rho*x - k)/sqrt(1-rho^2)) dx`` — this is the - oracle for the near-degenerate-|rho| regression fixtures. -""" - -import math - -from scipy.integrate import quad -from scipy.stats import multivariate_normal, norm - - -def q_mvn(h, k, rho): - f = multivariate_normal(mean=[0, 0], cov=[[1, rho], [rho, 1]]).cdf([h, k]) - return 1.0 - norm.cdf(h) - norm.cdf(k) + f - - -def q_quad(h, k, rho): - s = math.sqrt(1.0 - rho * rho) - val, _ = quad( - lambda x: norm.pdf(x) * norm.cdf((rho * x - k) / s), - h, - math.inf, - epsabs=1e-18, - epsrel=1e-12, - limit=1000, - ) - return val - - -def ux(sr): - xc = norm.ppf(1 - sr) - return norm.pdf(xc) / sr - - -def taylor_russell(rxy, sr, br, q=q_mvn): - xc = norm.ppf(1 - sr) - yc = norm.ppf(1 - br) - qv = q(xc, yc, rxy) - return qv / sr, qv - - -def bcg(n, sdy, rxy, sr, cost_total, period): - return n * period * sdy * rxy * ux(sr) - cost_total - - -if __name__ == "__main__": - def r(x): - return repr(float(x)) - - print("=== ux / pux fixtures ===") - for sr in [0.05, 0.3, 0.5, 0.9]: - print(f"sr={sr}: xc={r(norm.ppf(1 - sr))} ux={r(ux(sr))}") - print(f"pux(rxy=.5, sr=.3) = {r(0.5 * ux(0.3))}") - print(f"pux(rxy=-.5, sr=.3) = {r(-0.5 * ux(0.3))}") - - print("\n=== Taylor-Russell fixtures (mvn-CDF oracle) ===") - for (rxy, sr, br) in [ - (0.5, 0.5, 0.6), - (0.5, 0.3, 0.2), - (0.3, 0.05, 0.8), - (-0.6, 0.3, 0.5), - (0.8, 0.3, 0.2), - (0.0, 0.3, 0.7), - ]: - s, q = taylor_russell(rxy, sr, br) - print(f"rxy={rxy} sr={sr} br={br}: success={r(s)} q={r(q)}") - s, _ = taylor_russell(0.7, 0.9999, 0.37, q=q_quad) - print(f"sr->1 limit fixture (rxy=.7, sr=.9999, br=.37): success={r(s)}") - - print("\n=== BCG fixtures ===") - print(f"bcg(1, 10000, .5, .3, 0, 1) = {r(bcg(1, 10000, .5, .3, 0, 1))}") - print(f"bcg(50, 8000, .4, .2, 25000, 3) = {r(bcg(50, 8000, .4, .2, 25000, 3))}") - - print("\n=== near-degenerate-rho regression fixtures (quad oracle) ===") - for (rxy, sr, br) in [(-0.999999, 0.9, 0.9), (0.999999, 1e-12, 1e-12)]: - s, q = taylor_russell(rxy, sr, br, q=q_quad) - print(f"rxy={rxy} sr={sr} br={br}: success={r(s)} q={r(q)}") - - print("\n=== mutation predictions ===") - print(f"M1 (drop rxy) pux at sr=.3: {r(ux(0.3))} vs good {r(0.5 * ux(0.3))}") - xc03, yc08 = norm.ppf(1 - 0.3), norm.ppf(1 - 0.8) - print( - "M4 (sr<->br role swap) at rho=.5, sr=.3, br=.8: " - f"good success={r(q_mvn(xc03, yc08, 0.5) / 0.3)} " - f"mutant={r(q_mvn(yc08, xc03, 0.5) / 0.8)}" - ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 322983811..e79e7f0b5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -173,105 +173,6 @@ def test_cli_fit_rust_device_recorded(tmp_path, capsys): assert summary["rust_device"] == "gpu" -def test_cli_score_json_payload_reports_scores(capsys): - payload = {"item-1": 1} - scores = [{"theta": [0.25], "theta_sd": [0.5], "method": "eap"}] - args = [ - "score", - "--bundle", - "bundle.json", - "--responses", - "responses.json", - "--json", - ] - - with patch( - "fast_mlsirm.serving.load_serving_bundle", return_value={"bundle": True} - ) as load_bundle, patch( - "fast_mlsirm.cli._load_json_bounded", return_value=payload - ) as load_responses, patch( - "fast_mlsirm.serving.score_respondents", return_value=scores - ) as score, patch.object(sys, "argv", ["fast-mlsirm", *args]): - assert main() == 0 - - load_bundle.assert_called_once_with("bundle.json") - load_responses.assert_called_once_with( - "responses.json", source="response JSON" - ) - score.assert_called_once_with({"bundle": True}, payload) - result = json.loads(capsys.readouterr().out) - assert result == { - "command": "score", - "status": "ok", - "n_scored": 1, - "scores": scores, - } - - -def test_cli_score_npy_payload_writes_output(tmp_path): - payload = np.array([[1.0, 0.0]]) - scores = [{"theta": [0.1], "theta_sd": [0.4], "method": "eap"}] - output = tmp_path / "scores.json" - args = [ - "score", - "--bundle", - "bundle.json", - "--responses", - "responses.npy", - "--out", - str(output), - ] - - with patch( - "fast_mlsirm.serving.load_serving_bundle", return_value={"bundle": True} - ), patch( - "fast_mlsirm.cli._load_numpy_bounded", return_value=payload - ) as load_responses, patch( - "fast_mlsirm.serving.score_respondents", return_value=scores - ) as score, patch.object(sys, "argv", ["fast-mlsirm", *args]): - assert main() == 0 - - load_responses.assert_called_once_with("responses.npy") - score.assert_called_once_with({"bundle": True}, payload) - assert json.loads(output.read_text(encoding="utf-8")) == scores - - -def test_cli_score_reports_validation_error(capsys): - args = [ - "score", - "--bundle", - "bad.json", - "--responses", - "responses.json", - ] - - with patch( - "fast_mlsirm.serving.load_serving_bundle", - side_effect=ValueError("invalid bundle"), - ), patch.object(sys, "argv", ["fast-mlsirm", *args]): - assert main() == 1 - - assert "Scoring failed - invalid bundle" in capsys.readouterr().err - - -def test_cli_score_debug_reraises_validation_error(monkeypatch): - monkeypatch.setenv("FAST_MLSIRM_DEBUG", "1") - args = [ - "score", - "--bundle", - "bad.json", - "--responses", - "responses.json", - ] - - with patch( - "fast_mlsirm.serving.load_serving_bundle", - side_effect=ValueError("invalid bundle"), - ), patch.object(sys, "argv", ["fast-mlsirm", *args]), pytest.raises( - ValueError, match="invalid bundle" - ): - main() - def test_cli_diagnose_fit_success(tmp_path): sim_dir = tmp_path / "sim_out" fit_dir = tmp_path / "fit_out" @@ -301,58 +202,6 @@ def test_cli_diagnose_fit_success(tmp_path): assert (diag_dir / "fit_diagnostics.json").exists() - -def test_cli_limited_information_rejects_saved_nonconverged_fit(tmp_path, capsys): - responses = tmp_path / "responses.npy" - factors = tmp_path / "item_factor.csv" - params = tmp_path / "params.npz" - out_dir = tmp_path / "diag_out" - np.save(responses, np.zeros((4, 3))) - factors.write_text("item_id,factor_id\n0,0\n1,0\n2,0\n", encoding="utf-8") - np.savez( - params, - theta=np.zeros((4, 1)), - alpha=np.zeros(3), - b=np.zeros(3), - xi=np.zeros((4, 1)), - zeta=np.zeros((3, 1)), - tau=0.0, - ) - (tmp_path / "fit_summary.json").write_text( - json.dumps( - { - "optimizer": "mmle_em/numpy", - "convergence_status": "max_iter_reached", - "n_iter": 1, - } - ), - encoding="utf-8", - ) - args = [ - "diagnose-fit", - "--responses", - str(responses), - "--factors", - str(factors), - "--params", - str(params), - "--model", - "MIRT", - "--limited-information", - "--out", - str(out_dir), - ] - - with patch("fast_mlsirm.cli.fit_diagnostics") as diagnostics, patch( - "fast_mlsirm.cli.save_fit_diagnostics" - ), patch.object(sys, "argv", ["fast-mlsirm"] + args): - assert main() == 1 - - diagnostics.assert_not_called() - assert "did not converge" in capsys.readouterr().err - assert not out_dir.exists() - - def test_cli_diagnose_dimensions_success(tmp_path): sim_dir = tmp_path / "sim_out" diag_dir = tmp_path / "dim_out" diff --git a/tests/test_config.py b/tests/test_config.py index d5f06d727..6a810ac70 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -94,16 +94,3 @@ def test_fitconfig_invalid_eps_distance(): def test_fitconfig_invalid_backend(): with pytest.raises(ValueError, match="backend must be one of"): FitConfig(backend="cuda").validate() - - -@pytest.mark.parametrize("name", ["xi_points", "xi_seed"]) -@pytest.mark.parametrize("value", [True, 8.75, "9"]) -def test_fitconfig_rejects_noninteger_xi_controls(name, value): - with pytest.raises(ValueError, match=rf"{name} must be an integer"): - FitConfig(**{name: value}).validate() - - -@pytest.mark.parametrize("value", [-1, 1 << 64]) -def test_fitconfig_rejects_xi_seed_outside_u64(value): - with pytest.raises(ValueError, match="xi_seed must fit an unsigned 64-bit integer"): - FitConfig(xi_seed=value).validate() diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 788be227e..9a6767f18 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -22,30 +22,6 @@ def test_predict_proba_matches_simulation(): assert np.allclose(probs, data.probabilities) -def test_predict_proba_bifactor_uses_inner_product(): - params = MLSIRMParams( - theta=np.array([[-0.5], [0.75]]), - alpha=np.log(np.array([1.2, 0.8])), - b=np.array([-0.2, 0.3]), - xi=np.array([[-1.1], [0.6]]), - zeta=np.array([[0.9], [-0.7]]), - # tau is not part of the bifactor predictor; a large value makes a - # mistaken distance penalty visibly disagree with the reference. - tau=np.log(4.0), - ) - factor_id = np.array([0, 0], dtype=np.int64) - eta = ( - np.exp(params.alpha)[None, :] * params.theta[:, factor_id] - + params.b[None, :] - + params.xi @ params.zeta.T - ) - expected = 1.0 / (1.0 + np.exp(-eta)) - - np.testing.assert_allclose( - predict_proba(params, factor_id, model="BIFAC2PLM"), expected - ) - - def test_predict_proba_subset_persons(): data = simulate(MLS2PLMConfig(n_persons=10, n_dims=2, items_per_dim=2, seed=42)) @@ -143,95 +119,6 @@ def test_fit_diagnostics_strata_contract(): assert np.allclose(diagnostics.clusterfit["cluster_id"], [10.0, 20.0]) -def test_fit_diagnostics_leniency_residual_respects_mask_and_sign(): - params = MLSIRMParams( - theta=np.zeros((3, 1)), - alpha=np.zeros(2), - b=np.full(2, np.log(0.2 / 0.8)), - xi=np.zeros((3, 1)), - zeta=np.zeros((2, 1)), - tau=0.0, - ) - responses = np.array([[1.0, 1.0], [0.0, 0.0], [1.0, 0.0]]) - mask = np.array([[True, True], [True, False], [False, False]]) - - diagnostics = fit_diagnostics( - responses, - params, - np.zeros(2, dtype=int), - mask=mask, - model="MIRT", - ) - - # Reads crate-returned person-level residual outputs and fails if the - # implementation mutates to the wrong sign, ignores masking, or leaks an - # empty masked row into finite public outputs / summary statistics. - residual = diagnostics.personfit["leniency_residual"] - n_obs = diagnostics.personfit["leniency_n_observed"] - assert residual[0] > 0.75 - assert residual[1] < -0.15 - assert residual[0] > residual[1] - assert residual[2] == 0.0 - assert np.allclose(n_obs, [2.0, 1.0, 0.0]) - assert diagnostics.model_fit["leniency_mean"] > 0.29 - assert diagnostics.model_fit["leniency_abs_p95"] > abs(residual[1]) - assert diagnostics.model_fit["leniency_abs_p95"] < abs(residual[0]) - - -def test_fit_diagnostics_requires_estimator_and_population_for_structured_m2(): - params = MLSIRMParams( - theta=np.zeros((4, 1)), - alpha=np.zeros(3), - b=np.zeros(3), - xi=np.zeros((4, 1)), - zeta=np.zeros((3, 1)), - tau=0.0, - ) - responses = np.zeros((4, 3)) - - with pytest.raises(ValueError, match="actual estimator"): - fit_diagnostics( - responses, - params, - np.zeros(3, dtype=int), - model="MIRT", - group_id=np.array([0, 0, 1, 1]), - include_m2=True, - ) - with pytest.raises(ValueError, match="population mu and sigma"): - fit_diagnostics( - responses, - params, - np.zeros(3, dtype=int), - model="MIRT", - group_id=np.array([0, 0, 1, 1]), - include_m2=True, - estimator="mmle", - ) - - -def test_fit_diagnostics_rejects_nonconverged_parameters_for_m2(): - params = MLSIRMParams( - theta=np.zeros((4, 1)), - alpha=np.zeros(3), - b=np.zeros(3), - xi=np.zeros((4, 1)), - zeta=np.zeros((3, 1)), - tau=0.0, - ) - - with pytest.raises(ValueError, match="did not converge.*max_iter_reached"): - fit_diagnostics( - np.zeros((4, 3)), - params, - np.zeros(3, dtype=int), - model="MIRT", - include_m2=True, - estimator="mmle", - convergence_status="max_iter_reached", - ) - - def test_dimensionality_diagnostics_returns_best_candidate(): data = simulate(MLS2PLMConfig(n_persons=12, n_dims=2, items_per_dim=3, latent_dim=2, seed=7)) diff --git a/tests/test_estimator_marginal.py b/tests/test_estimator_marginal.py deleted file mode 100644 index 0c27b6c8d..000000000 --- a/tests/test_estimator_marginal.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Public-API recovery tests for the marginal (MMLE-EM) latent-space estimator.""" - -from __future__ import annotations - -import numpy as np -import pytest - -from fast_mlsirm.config import FitConfig -from fast_mlsirm.estimators.marginal import fit_marginal_numpy -from fast_mlsirm.fit import fit - - -def _simulate_lsirm( - seed=0, - n_persons=500, - n_items=14, - n_dims=2, - latent_dim=2, - gamma=1.0, - group_shift=None, - cluster_sd=0.0, - n_clusters=0, -): - rng = np.random.default_rng(seed) - fid = np.array([i % n_dims for i in range(n_items)]) - a = 0.8 + 0.8 * rng.random(n_items) - b = -1.0 + 2.0 * rng.random(n_items) - zeta = rng.standard_normal((n_items, latent_dim)) * 0.8 - group_id = (np.arange(n_persons) % 2) if group_shift is not None else None - cluster_id = (np.arange(n_persons) % n_clusters) if n_clusters else None - u = rng.standard_normal(n_clusters) * cluster_sd if n_clusters else None - theta = rng.standard_normal((n_persons, n_dims)) - if group_shift is not None: - theta += np.asarray(group_shift)[group_id][:, None] - if n_clusters: - theta += u[cluster_id][:, None] - xi = rng.standard_normal((n_persons, latent_dim)) - dist = np.linalg.norm(xi[:, None, :] - zeta[None, :, :], axis=2) - eta = a[None, :] * theta[:, fid] + b[None, :] - gamma * dist - y = (rng.random((n_persons, n_items)) < 1.0 / (1.0 + np.exp(-eta))).astype(float) - return y, fid, a, b, theta, group_id, cluster_id - - -def _cfg(**kwargs): - defaults = dict( - model="MLS2PLM", - estimator="mmle", - max_iter=100, - q_theta=15, - q_xi=7, - q_u=11, - ) - defaults.update(kwargs) - return FitConfig(**defaults) - - -def test_marginal_recovers_spatial_model(): - y, fid, a, b, theta, *_ = _simulate_lsirm(seed=11) - result = fit(y, fid, _cfg()) - trace = np.asarray(result.loglik_trace) - assert np.all(np.diff(trace) >= -1e-6), "marginal loglik must be non-decreasing" - assert np.corrcoef(result.params.theta[:, 0], theta[:, 0])[0, 1] > 0.6 - assert result.params.gamma > 0.3 - assert result.population["theta_sd"].shape == result.params.theta.shape - - -def test_marginal_multigroup_recovers_group_means(): - y, fid, *_ , group_id, _ = _simulate_lsirm( - seed=12, n_dims=1, latent_dim=1, gamma=0.8, group_shift=[0.0, 1.0], - n_items=12, - ) - result = fit(y, fid, _cfg(model="ULS2PLM"), group_id=group_id) - mu = result.population["mu"] - assert abs(mu[0, 0]) < 1e-12, "reference group stays pinned" - assert 0.5 < mu[1, 0] < 1.6, f"group-2 mean should recover ~1.0, got {mu[1, 0]}" - - -def test_marginal_multilevel_recovers_intercept_sd(): - y, fid, *_ , cluster_id = _simulate_lsirm( - seed=13, n_persons=600, n_dims=1, latent_dim=1, gamma=0.8, - cluster_sd=0.8, n_clusters=30, n_items=12, - ) - result = fit(y, fid, _cfg(model="ULSRM"), cluster_id=cluster_id) - pop = result.population - assert 0.35 < pop["sigma_u"] < 1.4, f"sigma_u should recover ~0.8, got {pop['sigma_u']}" - assert 0.0 < pop["icc"] < 1.0 - assert pop["u_eap"].shape == (30,) - - -def test_marginal_handles_missing_data(): - y, fid, *_ = _simulate_lsirm(seed=14, n_persons=200, n_items=10) - rng = np.random.default_rng(0) - y[rng.random(y.shape) < 0.3] = np.nan - result = fit(y, fid, _cfg(max_iter=40)) - assert np.all(np.isfinite(result.params.theta)) - assert result.n_iter > 0 - - -def test_numpy_trace_endpoint_matches_returned_parameters_after_max_iter(): - y = np.array( - [ - [0.0, 0.0, 0.0], - [0.0, 0.0, 1.0], - [0.0, 1.0, 0.0], - [0.0, 1.0, 1.0], - [1.0, 0.0, 0.0], - [1.0, 0.0, 1.0], - [1.0, 1.0, 0.0], - [1.0, 1.0, 1.0], - ] - ) - observed = np.ones_like(y, dtype=bool) - factor_id = np.zeros(y.shape[1], dtype=np.int64) - fit_kwargs = { - "model": "MIRT", - "n_dims": 1, - "latent_dim": 1, - "pop": {"kind": "single"}, - "q_theta": 7, - "q_xi": 7, - "q_u": 7, - "max_iter": 1, - "m_steps": 2, - } - result = fit_marginal_numpy(y, observed, factor_id, **fit_kwargs) - anchors = { - "fixed": np.ones(y.shape[1], dtype=bool), - "alpha": result["alpha"].copy(), - "b": result["b"].copy(), - "zeta": result["zeta"].copy(), - "tau": result["tau"], - } - reevaluated = fit_marginal_numpy( - y, observed, factor_id, anchors=anchors, **fit_kwargs - ) - - assert result["n_iter"] == 1 - assert len(result["loglik_trace"]) == 2 - np.testing.assert_allclose( - result["loglik_trace"][-1], reevaluated["loglik_trace"][0], atol=1e-10 - ) - - -def test_marginal_rejects_invalid_quadrature(): - with pytest.raises(ValueError, match="q_theta must be one of"): - FitConfig(q_theta=12).validate() diff --git a/tests/test_estimator_mmle.py b/tests/test_estimator_mmle.py index 53a9e5fd3..656574891 100644 --- a/tests/test_estimator_mmle.py +++ b/tests/test_estimator_mmle.py @@ -49,37 +49,10 @@ def test_reserved_estimators_raise(estimator): fit(y, factors, FitConfig(model="ULS2PLM", estimator=estimator), mask=mask) -def test_mmle_spatial_models_route_to_marginal_estimator(): - y, factors, mask, *_ = _simulate_2pl(n_persons=60, n_items=6) - result = fit( - y, - factors, - FitConfig(model="MLS2PLM", estimator="mmle", max_iter=5, q_theta=7, q_xi=7), - mask=mask, - ) - assert result.optimizer.startswith("mmle_marginal_em/") - assert result.population is not None and result.population["kind"] == "single" - - -def test_mmle_population_structures_require_mmle_estimator(): +def test_mmle_rejects_spatial_models_until_supported(): y, factors, mask, *_ = _simulate_2pl(n_persons=50, n_items=5) - with pytest.raises(ValueError, match="require estimator='mmle'"): - fit( - y, - factors, - FitConfig(model="ULS2PLM", estimator="jmle"), - mask=mask, - group_id=np.zeros(50, dtype=np.int64), - ) - with pytest.raises(ValueError, match="mutually exclusive"): - fit( - y, - factors, - FitConfig(model="ULS2PLM", estimator="mmle"), - mask=mask, - group_id=np.zeros(50, dtype=np.int64), - cluster_id=np.zeros(50, dtype=np.int64), - ) + with pytest.raises(NotImplementedError, match="only unidimensional 2PL"): + fit(y, factors, FitConfig(model="MLS2PLM", estimator="mmle"), mask=mask) def test_invalid_estimator_rejected_by_validate(): diff --git a/tests/test_fitstats.py b/tests/test_fitstats.py deleted file mode 100644 index 0206296be..000000000 --- a/tests/test_fitstats.py +++ /dev/null @@ -1,512 +0,0 @@ -"""Tests for the S-X², l_z/l_z*, and item-screening additions.""" - -from __future__ import annotations - -from types import SimpleNamespace - -import fast_mlsirm.fitstats as fitstats_module -import numpy as np -import pytest - -from fast_mlsirm.config import FitConfig -from fast_mlsirm.fit import fit -from fast_mlsirm.fitstats import ( - adjusted_chi2_pairs, - benjamini_hochberg, - chi2_sf, - _lord_wingersky, - empirical_reliability, - infit_outfit, - person_fit, - person_fit_resampling, - residual_item_fit, - s_x2, - select_items, - tcc_drift, -) - - -def test_chi2_sf_reference_values(): - # classic critical values - assert chi2_sf(3.841, 1) == pytest.approx(0.05, abs=1e-3) - assert chi2_sf(18.307, 10) == pytest.approx(0.05, abs=1e-3) - assert chi2_sf(0.0, 5) == 1.0 - assert chi2_sf(1e6, 2) < 1e-12 - - -def test_benjamini_hochberg_known_case(): - p = np.array([0.001, 0.008, 0.039, 0.041, 0.042, 0.06, 0.074, 0.205, 0.212, 0.216]) - reject = benjamini_hochberg(p, q=0.05) - # step-up: largest k with p_(k) <= (k/m) q is k=2 (0.008 <= 0.010) - assert reject.sum() == 2 - assert reject[:2].all() - p_with_nan = np.array([0.001, np.nan, 0.9]) - r2 = benjamini_hochberg(p_with_nan, q=0.05) - assert r2[0] and not r2[2] - - -def test_empirical_reliability_python_wrapper(): - theta = np.array([[-1.0, 0.0], [0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]) - theta_sd = np.array([[0.5, 1.0]] * 4) - result = SimpleNamespace( - params=SimpleNamespace(theta=theta), - population={"theta_sd": theta_sd}, - ) - - reliability = empirical_reliability(result, device="cpu") - - np.testing.assert_allclose(reliability, [5.0 / 6.0, 0.0]) - - -def test_empirical_reliability_validates_device_before_native(monkeypatch): - theta = np.zeros((2, 1)) - result = SimpleNamespace( - params=SimpleNamespace(theta=theta), - population={"theta_sd": np.ones_like(theta)}, - ) - - class BombCore: - def empirical_reliability(self, *_args, **_kwargs): - raise AssertionError("invalid device reached native reliability") - - monkeypatch.setattr(fitstats_module, "_core_module", lambda: BombCore()) - with pytest.raises(ValueError, match="rust_device"): - empirical_reliability(result, device="tpu") - - -def test_empirical_reliability_requires_core_and_marginal_sd(monkeypatch): - theta = np.zeros((2, 1)) - result = SimpleNamespace(params=SimpleNamespace(theta=theta), population=None) - - with pytest.raises(ValueError, match="marginal fit with theta_sd"): - empirical_reliability(result) - - result.population = {} - with pytest.raises(ValueError, match="marginal fit with theta_sd"): - empirical_reliability(result) - - result.population = {"theta_sd": np.ones_like(theta)} - monkeypatch.setattr(fitstats_module, "_core_module", lambda: None) - with pytest.raises(RuntimeError, match="compiled Rust core"): - empirical_reliability(result) - - -def test_lord_wingersky_matches_enumeration(): - rng = np.random.default_rng(0) - probs = rng.random((3, 4)) # 3 items, 4 nodes - f = _lord_wingersky(probs) - np.testing.assert_allclose(f.sum(axis=0), 1.0, atol=1e-12) - # brute force over the 2^3 patterns - expected = np.zeros((4, 4)) - for pattern in range(8): - bits = [(pattern >> k) & 1 for k in range(3)] - prob = np.ones(4) - for k, bit in enumerate(bits): - prob *= probs[k] if bit else (1.0 - probs[k]) - expected[sum(bits)] += prob - np.testing.assert_allclose(f, expected, atol=1e-12) - - -def _simulate_2pl(seed=0, n_persons=800, n_items=12, bad_item=None): - rng = np.random.default_rng(seed) - a = 0.8 + 0.8 * rng.random(n_items) - b = -1.0 + 2.0 * rng.random(n_items) - theta = rng.standard_normal(n_persons) - eta = a[None, :] * theta[:, None] + b[None, :] - y = (rng.random((n_persons, n_items)) < 1.0 / (1.0 + np.exp(-eta))).astype(float) - if bad_item is not None: - # a grossly misfitting item: response independent of theta, extreme - # split by an unrelated coin — S-X² should flag it - y[:, bad_item] = (rng.random(n_persons) < 0.5).astype(float) - y[theta > 0, bad_item] = 0.0 # negatively related to ability - fid = np.zeros(n_items, dtype=np.int64) - return y, fid, theta - - -def _fit_mirt(y, fid): - cfg = FitConfig( - model="MIRT", estimator="mmle", max_iter=60, q_theta=15, latent_dim=1 - ) - return fit(y, fid, cfg) - - -def test_sx2_flags_misfitting_item_and_spares_good_ones(): - y, fid, _ = _simulate_2pl(seed=3, bad_item=5) - res = _fit_mirt(y, fid) - out = s_x2(y, fid, res.params, "MIRT", q_theta=15) - assert np.isfinite(out.statistic).sum() >= 10 - assert out.flagged_bh[5], "scrambled item must be BH-flagged" - # most well-specified items should survive - others = np.delete(np.arange(y.shape[1]), 5) - assert out.flagged_bh[others].mean() < 0.5 - - -@pytest.mark.parametrize( - ("kwargs", "match"), - [ - ({"q_theta": 21.5}, "q_theta"), - ({"q_xi": True}, "q_xi"), - ({"min_expected": 0.0}, "min_expected"), - ({"fdr_q": np.nan}, "fdr_q"), - ({"min_effect": -0.1}, "min_effect"), - ({"person_weight": np.array([1.0, 0.5, 1.0])}, "person_weight"), - ], -) -def test_sx2_rejects_unsafe_controls_before_native(monkeypatch, kwargs, match): - class BombCore: - def s_x2_stat(self, *_args, **_kwargs): - raise AssertionError("unsafe S-X2 inputs reached the native core") - - y = np.zeros((3, 4)) - factor_id = np.zeros(4, dtype=np.int64) - params = SimpleNamespace( - alpha=np.zeros(4), - b=np.zeros(4), - zeta=np.zeros((4, 1)), - tau=-30.0, - ) - monkeypatch.setattr(fitstats_module, "_core_module", lambda: BombCore()) - with pytest.raises(ValueError, match=match): - s_x2(y, factor_id, params, "MIRT", **kwargs) - - -def test_sx2_rejects_factor_length_mismatch_before_native(monkeypatch): - class BombCore: - def s_x2_stat(self, *_args, **_kwargs): - raise AssertionError("unsafe S-X2 inputs reached the native core") - - params = SimpleNamespace( - alpha=np.zeros(4), - b=np.zeros(4), - zeta=np.zeros((4, 1)), - tau=-30.0, - ) - monkeypatch.setattr(fitstats_module, "_core_module", lambda: BombCore()) - with pytest.raises(ValueError, match="factor_id length"): - s_x2(np.zeros((3, 4)), np.zeros(3, dtype=np.int64), params, "MIRT") - - -def test_sx2_extreme_probabilities_preserve_native_numpy_parity(monkeypatch): - if fitstats_module._core_module() is None: - pytest.skip("compiled core is unavailable") - rng = np.random.default_rng(29) - y = (rng.random((100, 6)) < 0.5).astype(float) - factor_id = np.zeros(6, dtype=np.int64) - params = SimpleNamespace( - alpha=np.zeros(6), - b=np.full(6, -1000.0), - zeta=np.zeros((6, 1)), - tau=-30.0, - ) - native = s_x2(y, factor_id, params, "MIRT") - monkeypatch.setattr(fitstats_module, "_core_module", lambda: None) - numpy_reference = s_x2(y, factor_id, params, "MIRT") - assert np.all(np.isfinite(native.statistic)) - assert np.all(np.isfinite(native.g2_statistic)) - np.testing.assert_allclose(native.statistic, numpy_reference.statistic) - np.testing.assert_allclose(native.g2_statistic, numpy_reference.g2_statistic) - np.testing.assert_allclose(native.rms_residual, numpy_reference.rms_residual) - np.testing.assert_array_equal(native.n_score_groups, numpy_reference.n_score_groups) - - -def test_sx2_g2_p_values_follow_chi2_mapping(): - y, fid, _ = _simulate_2pl(seed=13, n_persons=1200, n_items=10) - res = _fit_mirt(y, fid) - out = s_x2(y, fid, res.params, "MIRT", q_theta=15) - finite = np.isfinite(out.df) & (out.df >= 1) & np.isfinite(out.g2_statistic) - assert finite.any() - expected = np.array( - [chi2_sf(float(g2), float(df)) for g2, df in zip(out.g2_statistic[finite], out.df[finite])] - ) - np.testing.assert_allclose( - out.g2_p_value[finite], expected - ) - - -def test_person_fit_flags_random_responders(): - y, fid, theta = _simulate_2pl(seed=4) - rng = np.random.default_rng(42) - aberrant = np.arange(25) - y[aberrant] = (rng.random((25, y.shape[1])) < 0.5).astype(float) - res = _fit_mirt(y, fid) - pf = person_fit(y, fid, res.params, "MIRT") - normal = np.arange(100, y.shape[0]) - # aberrant persons score systematically lower on l_z* - assert np.nanmean(pf.lz_star[aberrant, 0]) < np.nanmean(pf.lz_star[normal, 0]) - 0.5 - # calibration: for model-consistent persons l_z* is near standard normal - m = np.nanmean(pf.lz_star[normal, 0]) - s = np.nanstd(pf.lz_star[normal, 0]) - assert abs(m) < 0.35 and 0.6 < s < 1.6 - - -@pytest.mark.parametrize("diagnostic", [person_fit, infit_outfit]) -def test_person_diagnostics_reject_invalid_inputs_before_native(monkeypatch, diagnostic): - class BombCore: - def __getattr__(self, _name): - raise AssertionError("invalid diagnostic inputs reached the native core") - - params = SimpleNamespace( - alpha=np.zeros(4), - b=np.zeros(4), - zeta=np.zeros((4, 1)), - tau=-30.0, - theta=np.zeros((3, 1)), - xi=np.zeros((3, 1)), - ) - monkeypatch.setattr(fitstats_module, "_core_module", lambda: BombCore()) - - with pytest.raises(ValueError, match="factor_id length"): - diagnostic( - np.zeros((3, 4)), np.zeros(3, dtype=np.int64), params, "MIRT" - ) - - nonbinary = np.zeros((3, 4)) - nonbinary[0, 0] = 2.0 - with pytest.raises(ValueError, match="0 or 1"): - diagnostic(nonbinary, np.zeros(4, dtype=np.int64), params, "MIRT") - - -def test_residual_item_fit_rejects_invalid_inputs_before_native(monkeypatch): - class BombCore: - def residual_item_fit(self, *_args, **_kwargs): - raise AssertionError("invalid residual-fit inputs reached the native core") - - params = SimpleNamespace( - alpha=np.zeros(4), - b=np.zeros(4), - zeta=np.zeros((4, 1)), - tau=-30.0, - theta=np.zeros((10, 1)), - xi=np.zeros((10, 1)), - ) - factor_id = np.zeros(4, dtype=np.int64) - monkeypatch.setattr(fitstats_module, "_core_module", lambda: BombCore()) - - nonbinary = np.zeros((10, 4)) - nonbinary[0, 0] = 2.0 - with pytest.raises(ValueError, match="0 or 1"): - residual_item_fit(nonbinary, factor_id, params, "MIRT", n_bins=2) - - params.theta[0, 0] = np.nan - with pytest.raises(ValueError, match="must be finite"): - residual_item_fit(np.zeros((10, 4)), factor_id, params, "MIRT", n_bins=2) - params.theta[0, 0] = 0.0 - - with pytest.raises(ValueError, match="integer >= 2"): - residual_item_fit(np.zeros((10, 4)), factor_id, params, "MIRT", n_bins=True) - - with pytest.raises(ValueError, match="five persons per bin"): - residual_item_fit(np.zeros((10, 4)), factor_id, params, "MIRT", n_bins=3) - - with pytest.raises(ValueError, match="boolean"): - residual_item_fit( - np.zeros((10, 4)), - factor_id, - params, - "MIRT", - mask=np.ones((10, 4)), - n_bins=2, - ) - - -@pytest.mark.skipif( - fitstats_module._core_module() is None, reason="compiled core unavailable" -) -def test_adjusted_chi2_pairs_rejects_invalid_inputs_and_preserves_undefined_max(): - def params(n_persons): - return SimpleNamespace( - alpha=np.zeros(2), - b=np.zeros(2), - zeta=np.zeros((2, 1)), - tau=-30.0, - theta=np.zeros((n_persons, 1)), - xi=np.zeros((n_persons, 1)), - ) - - factor_id = np.zeros(2, dtype=np.int64) - nonbinary = np.zeros((20, 2)) - nonbinary[0, 0] = 2.0 - with pytest.raises(ValueError, match="0 or 1"): - adjusted_chi2_pairs( - nonbinary, factor_id, params(20), "MIRT", q_theta=7, q_xi=3 - ) - - with pytest.raises(ValueError, match="boolean"): - adjusted_chi2_pairs( - np.zeros((20, 2)), - factor_id, - params(20), - "MIRT", - mask=np.ones((20, 2)), - q_theta=7, - q_xi=3, - ) - - sparse = adjusted_chi2_pairs( - np.zeros((19, 2)), factor_id, params(19), "MIRT", q_theta=7, q_xi=3 - ) - assert np.isnan(sparse["ratio"]).all() - assert np.isnan(sparse["mean_ratio"]) - assert np.isnan(sparse["max_ratio"]) - - -def test_person_fit_resampling_rejects_invalid_inputs_before_native(monkeypatch): - class BombCore: - def person_fit_resampling(self, *_args, **_kwargs): - raise AssertionError("invalid resampling inputs reached the native core") - - def params(n_persons=3): - return SimpleNamespace( - alpha=np.zeros(4), - b=np.zeros(4), - zeta=np.zeros((4, 1)), - tau=-30.0, - theta=np.zeros((n_persons, 1)), - xi=np.zeros((n_persons, 1)), - ) - - y = np.zeros((3, 4)) - factor_id = np.zeros(4, dtype=np.int64) - monkeypatch.setattr(fitstats_module, "_core_module", lambda: BombCore()) - - nonbinary = y.copy() - nonbinary[0, 0] = 2.0 - with pytest.raises(ValueError, match="0 or 1"): - person_fit_resampling(nonbinary, factor_id, params(), "MIRT") - - with pytest.raises(ValueError, match="boolean"): - person_fit_resampling(y, factor_id, params(), "MIRT", mask=np.ones_like(y)) - - bad_theta = params() - bad_theta.theta[0, 0] = np.nan - with pytest.raises(ValueError, match="must be finite"): - person_fit_resampling(y, factor_id, bad_theta, "MIRT") - - bad_xi_shape = params() - bad_xi_shape.xi = np.zeros((3, 2)) - with pytest.raises(ValueError, match="params.xi shape"): - person_fit_resampling(y, factor_id, bad_xi_shape, "MIRT") - - with pytest.raises(ValueError, match="prior_mean must be finite"): - person_fit_resampling( - y, factor_id, params(), "MIRT", prior_mean=np.array([np.nan]) - ) - - for bad_seed in (True, 1.5, -1, 2**64): - with pytest.raises(ValueError, match="seed"): - person_fit_resampling(y, factor_id, params(), "MIRT", seed=bad_seed) - - with pytest.raises(ValueError, match="at least one person"): - person_fit_resampling(np.empty((0, 4)), factor_id, params(n_persons=0), "MIRT") - - -def test_tcc_drift_rejects_invalid_controls_before_native(monkeypatch): - class BombCore: - def tcc_drift(self, *_args, **_kwargs): - raise AssertionError("invalid TCC drift controls reached the native core") - - params = SimpleNamespace( - alpha=np.zeros(4), - b=np.zeros(4), - zeta=np.zeros((4, 1)), - tau=-30.0, - ) - factor_id = np.zeros(4, dtype=np.int64) - monkeypatch.setattr(fitstats_module, "_core_module", lambda: BombCore()) - - for bad_threshold in (np.nan, np.inf, -1.0, True, "0.05"): - with pytest.raises(ValueError, match="finite non-negative"): - tcc_drift(params, params, factor_id, "MIRT", threshold=bad_threshold) - - for name, bad_value in (("q_theta", 7.9), ("q_xi", True), ("q_theta", 0)): - with pytest.raises(ValueError, match=name): - tcc_drift( - params, - params, - factor_id, - "MIRT", - **{name: bad_value}, - ) - - -@pytest.mark.skipif( - fitstats_module._core_module() is None, reason="compiled core unavailable" -) -def test_tcc_drift_reports_threshold_termination(): - old = SimpleNamespace( - alpha=np.zeros(6), - b=np.linspace(-1.2, 1.2, 6), - zeta=np.zeros((6, 1)), - tau=-30.0, - ) - new = SimpleNamespace( - alpha=old.alpha.copy(), - b=old.b + np.array([0.0, 0.0, 1.0, 0.0, 0.0, 0.0]), - zeta=old.zeta.copy(), - tau=old.tau, - ) - result = tcc_drift( - old, - new, - np.zeros(6, dtype=np.int64), - "MIRT", - threshold=0.05, - q_theta=7, - q_xi=3, - ) - assert result["drifted"] == [2] - assert result["termination_reason"] == "threshold_met" - assert result["iterations"] == 1 - assert result["max_iterations"] == 4 - assert result["area_trace"][-1] <= 0.05 - - -def test_select_items_removes_sparse_and_scrambled(): - y, fid, _ = _simulate_2pl(seed=5, n_persons=600, n_items=12, bad_item=7) - y[:, 3] = 0.0 - y[:5, 3] = 1.0 # near-zero variance: sparse flag - codes = [f"IT{i:02d}" for i in range(12)] - out = select_items( - y, - fid, - item_codes=codes, - config=FitConfig( - model="MIRT", - estimator="mmle", - max_iter=100, - tolerance=1e-3, - q_theta=15, - latent_dim=1, - ), - max_rounds=2, - min_items_per_dim=4, - ) - assert "IT03" in out.removed_items, "sparse item must be removed" - assert "IT03" in out.removed_items and "sparse" in out.removed_items["IT03"] - assert len(out.kept_items) >= 4 - assert out.final_result is not None - assert out.final_result.convergence_status == "converged" - assert len(out.final_result.params.b) == len(out.kept_items) - assert len(out.rounds) >= 1 - - -def test_select_items_rejects_nonconverged_fit(): - y, fid, _ = _simulate_2pl(seed=19, n_persons=150, n_items=6) - with pytest.raises( - RuntimeError, - match=r"status=max_iter_reached, n_iter=1, max_iter=1, .*tolerance=1e-12", - ): - select_items( - y, - fid, - config=FitConfig( - model="MIRT", - estimator="mmle", - max_iter=1, - tolerance=1e-12, - q_theta=11, - latent_dim=1, - ), - max_rounds=1, - min_items_per_dim=4, - ) diff --git a/tests/test_marginal_parity.py b/tests/test_marginal_parity.py deleted file mode 100644 index 41d91076e..000000000 --- a/tests/test_marginal_parity.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Rust<->NumPy parity gate for the marginal (MMLE-EM) estimator. - -Both backends implement the identical deterministic algorithm (same quadrature -tables, same E/M-step algebra, same init), so agreement is asserted at 1e-9 — -far tighter than the 1e-6 workspace contract — after a full EM run. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -from fast_mlsirm.config import FitConfig -from fast_mlsirm.fit import fit - -pytestmark = pytest.mark.skipif( - pytest.importorskip("fast_mlsirm._core", reason="compiled core required") is None, - reason="compiled core required", -) - - -def _simulate(seed=0, n_persons=250, n_items=12, n_dims=2, latent_dim=2, missing=0.0): - rng = np.random.default_rng(seed) - fid = np.array([i % n_dims for i in range(n_items)]) - theta = rng.standard_normal((n_persons, n_dims)) - xi = rng.standard_normal((n_persons, latent_dim)) - zeta = rng.standard_normal((n_items, latent_dim)) * 0.8 - dist = np.linalg.norm(xi[:, None, :] - zeta[None, :, :], axis=2) - eta = theta[:, fid] + 0.2 - dist - y = (rng.random((n_persons, n_items)) < 1.0 / (1.0 + np.exp(-eta))).astype(float) - if missing > 0.0: - y[rng.random((n_persons, n_items)) < missing] = np.nan - return y, fid - - -def _both(y, fid, model, n_dims, **kwargs): - results = {} - for backend in ("rust", "numpy"): - cfg = FitConfig( - model=model, - estimator="mmle", - max_iter=30, - backend=backend, - rust_device="cpu", - q_theta=15, - q_xi=7, - q_u=11, - ) - results[backend] = fit(y, fid, cfg, **kwargs) - return results["rust"], results["numpy"] - - -def _assert_close(r, n, tol=1e-9): - assert r.optimizer.endswith("/rust") - assert n.optimizer.endswith("/numpy") - np.testing.assert_allclose(r.params.b, n.params.b, atol=tol) - np.testing.assert_allclose(r.params.alpha, n.params.alpha, atol=tol) - np.testing.assert_allclose(r.params.zeta, n.params.zeta, atol=tol) - np.testing.assert_allclose(r.params.tau, n.params.tau, atol=tol) - np.testing.assert_allclose(r.params.theta, n.params.theta, atol=tol) - np.testing.assert_allclose(r.params.xi, n.params.xi, atol=tol) - np.testing.assert_allclose( - r.loglik_trace[-1], n.loglik_trace[-1], rtol=0, atol=tol - ) - - -@pytest.mark.parametrize("model", ["MLS2PLM", "MLSRM", "MIRT"]) -def test_marginal_parity_multidim_models(model): - y, fid = _simulate(seed=1) - r, n = _both(y, fid, model, n_dims=2) - _assert_close(r, n) - - -@pytest.mark.parametrize("model", ["ULS2PLM", "ULSRM"]) -def test_marginal_parity_unidimensional_with_grouping(model): - # Plain ULS* routes to the legacy fast path, so exercise the marginal path - # through the population structures. - y, fid = _simulate(seed=2, n_dims=1) - group_id = np.arange(len(y)) % 3 - r, n = _both(y, fid, model, n_dims=1, group_id=group_id) - _assert_close(r, n) - np.testing.assert_allclose( - r.population["mu"], n.population["mu"], atol=1e-9 - ) - np.testing.assert_allclose( - r.population["sigma"], n.population["sigma"], atol=1e-9 - ) - - -def test_marginal_parity_multilevel(): - y, fid = _simulate(seed=3, n_dims=2) - cluster_id = np.arange(len(y)) % 10 - r, n = _both(y, fid, "MLS2PLM", n_dims=2, cluster_id=cluster_id) - _assert_close(r, n) - np.testing.assert_allclose( - r.population["sigma_u"], n.population["sigma_u"], atol=1e-9 - ) - np.testing.assert_allclose( - r.population["u_eap"], n.population["u_eap"], atol=1e-9 - ) - - -def test_marginal_parity_with_missing_data(): - y, fid = _simulate(seed=4, missing=0.25) - r, n = _both(y, fid, "MLS2PLM", n_dims=2) - _assert_close(r, n) - - -def test_marginal_parity_theta_sd(): - y, fid = _simulate(seed=5) - r, n = _both(y, fid, "MLS2PLM", n_dims=2) - np.testing.assert_allclose( - r.population["theta_sd"], n.population["theta_sd"], atol=1e-9 - ) - - -def test_marginal_gpu_agrees_with_cpu_loosely(capfd): - # Compare an explicit f32 GPU E-step with the f64 CPU reference. An auto - # request can silently fall back to CPU and would not prove GPU parity. - y, fid = _simulate(seed=6) - cluster_id = np.arange(len(y)) % 10 - results = {} - for device in ("cpu", "gpu"): - cfg = FitConfig( - model="MLS2PLM", - estimator="mmle", - max_iter=15, - backend="rust", - rust_device=device, - q_theta=15, - q_xi=7, - q_u=11, - ) - results[device] = fit(y, fid, cfg, cluster_id=cluster_id) - device_stderr = capfd.readouterr().err - if "no usable GPU adapter was found" in device_stderr: - pytest.skip("no usable GPU adapter; explicit GPU request fell back to CPU") - - r, g = results["cpu"], results["gpu"] - np.testing.assert_allclose(g.params.b, r.params.b, atol=1e-3) - np.testing.assert_allclose(g.params.zeta, r.params.zeta, atol=5e-3) - np.testing.assert_allclose(g.params.theta, r.params.theta, atol=1e-3) - np.testing.assert_allclose( - g.population["sigma_u"], r.population["sigma_u"], atol=1e-3 - ) - np.testing.assert_allclose( - g.loglik_trace[-1], r.loglik_trace[-1], rtol=1e-6, atol=2e-4 - ) diff --git a/tests/test_mixed_items.py b/tests/test_mixed_items.py deleted file mode 100644 index e85a3b344..000000000 --- a/tests/test_mixed_items.py +++ /dev/null @@ -1,332 +0,0 @@ -from __future__ import annotations - -import os - -import numpy as np -import pytest - -from fast_mlsirm import MixedItemParameters, fit_mixed_items, fit_polytomous - - -def _draw_rows(rng: np.random.Generator, probabilities: np.ndarray) -> np.ndarray: - return np.asarray( - [rng.choice(probabilities.shape[1], p=row) for row in probabilities], - dtype=float, - ) - - -def _dominance_bank(seed: int = 42, n_persons: int = 400): - rng = np.random.default_rng(seed) - theta = rng.normal(size=n_persons) - y = np.empty((n_persons, 4), dtype=float) - - y[:, 0] = rng.random(n_persons) < 1.0 / (1.0 + np.exp(-(theta - 0.2))) - - boundaries = np.asarray([0.8, -0.6]) - cumulative = 1.0 / (1.0 + np.exp(-(theta[:, None] + boundaries))) - y[:, 1] = _draw_rows( - rng, - np.column_stack( - [ - 1.0 - cumulative[:, 0], - cumulative[:, 0] - cumulative[:, 1], - cumulative[:, 1], - ] - ), - ) - - logits = theta[:, None] * np.arange(3) + np.asarray([0.0, -0.2, -0.8]) - logits -= logits.max(axis=1, keepdims=True) - probabilities = np.exp(logits) - probabilities /= probabilities.sum(axis=1, keepdims=True) - y[:, 2] = _draw_rows(rng, probabilities) - - logits = theta[:, None] * np.asarray([0.0, -0.8, 1.2]) + np.asarray( - [0.0, 0.2, -0.3] - ) - logits -= logits.max(axis=1, keepdims=True) - probabilities = np.exp(logits) - probabilities /= probabilities.sum(axis=1, keepdims=True) - y[:, 3] = _draw_rows(rng, probabilities) - return y, theta - - -def _expanded_family_bank(seed: int = 142, n_persons: int = 800): - rng = np.random.default_rng(seed) - theta = rng.normal(size=n_persons) - y = np.empty((n_persons, 8), dtype=float) - - y[:, 0] = rng.random(n_persons) < 1.0 / (1.0 + np.exp(-(theta - 0.2))) - - logits = theta[:, None] * np.arange(3) + np.asarray([0.0, 0.1, -0.7]) - logits -= logits.max(axis=1, keepdims=True) - probabilities = np.exp(logits) - probabilities /= probabilities.sum(axis=1, keepdims=True) - y[:, 1] = _draw_rows(rng, probabilities) - - core = 1.0 / (1.0 + np.exp(-(1.2 * theta - 0.3))) - y[:, 2] = rng.random(n_persons) < 0.12 + 0.88 * core - y[:, 3] = rng.random(n_persons) < 0.88 * core - y[:, 4] = rng.random(n_persons) < 0.10 + 0.78 * core - - for column, slope in [(5, 1.15), (6, 1.0)]: - q1 = 1.0 / (1.0 + np.exp(-(slope * theta + 0.5))) - q2 = 1.0 / (1.0 + np.exp(-(slope * theta - 0.6))) - y[:, column] = _draw_rows( - rng, - np.column_stack([1.0 - q1, q1 * (1.0 - q2), q1 * q2]), - ) - - y[:, 7] = rng.random(n_persons) < -np.expm1(-np.exp(theta - 0.25)) - return y, theta - - -def _assert_actual_convergence(fit, tol: float, max_iter: int) -> None: - trace = np.asarray(fit.loglik_trace) - assert fit.converged - assert fit.termination_reason == "converged" - assert 0 < fit.n_iter < max_iter - assert trace.shape == (fit.n_iter + 1,) - assert np.all(np.isfinite(trace)) - slack = 1e-8 * (1.0 + np.abs(trace[:-1])) - assert np.all(np.diff(trace) >= -slack) - assert abs(trace[-1] - trace[-2]) <= tol * (1.0 + abs(trace[-1])) - assert fit.loglik == pytest.approx(trace[-1], abs=1e-12) - - -def test_mixed_dominance_bank_converges_and_cpu_threads_are_equivalent(): - y, theta = _dominance_bank() - options = dict( - item_models=["2pl", "grm", "gpcm", "nominal"], - n_categories=[2, 3, 3, 3], - q_theta=11, - max_iter=80, - tol=1e-5, - require_convergence=True, - ) - serial = fit_mixed_items(y, n_threads=1, **options) - requested_threads = min(2, os.cpu_count() or 1) - parallel = fit_mixed_items(y, n_threads=requested_threads, **options) - - _assert_actual_convergence(serial, tol=1e-5, max_iter=80) - _assert_actual_convergence(parallel, tol=1e-5, max_iter=80) - assert serial.n_threads == 1 - assert parallel.n_threads == requested_threads - assert parallel.loglik == pytest.approx(serial.loglik, abs=2e-7) - np.testing.assert_allclose( - parallel.theta_eap, serial.theta_eap, atol=2e-7, rtol=0.0 - ) - assert np.corrcoef(theta, parallel.theta_eap)[0, 1] > 0.65 - - for serial_item, parallel_item in zip(serial.items, parallel.items, strict=True): - assert serial_item.model == parallel_item.model - if serial_item.slope is not None: - assert parallel_item.slope == pytest.approx(serial_item.slope, abs=2e-7) - np.testing.assert_allclose( - parallel_item.intercepts, serial_item.intercepts, atol=2e-7 - ) - np.testing.assert_allclose( - parallel_item.thresholds, serial_item.thresholds, atol=2e-7 - ) - np.testing.assert_allclose(parallel_item.scores, serial_item.scores, atol=2e-7) - - -def test_expanded_response_families_jointly_converge_with_valid_constraints(): - y, theta = _expanded_family_bank() - models = ["rasch", "pcm", "3pl", "3plu", "4pl", "sequential", "tutz", "cll"] - fit = fit_mixed_items( - y, - models, - [2, 3, 2, 2, 2, 3, 3, 2], - q_theta=11, - max_iter=100, - tol=1e-6, - n_threads=2, - require_convergence=True, - ) - - _assert_actual_convergence(fit, tol=1e-6, max_iter=100) - assert [item.model for item in fit.items] == models - assert fit.items[0].slope == 1.0 - assert fit.items[1].slope == 1.0 - assert fit.items[6].slope == 1.0 - assert fit.items[7].slope == 1.0 - assert fit.items[5].intercepts.shape == (2,) - assert fit.items[6].intercepts.shape == (2,) - for index in [2, 3, 4]: - lower = fit.items[index].lower_asymptote - upper = fit.items[index].upper_asymptote - assert lower is not None and upper is not None - assert 0.0 <= lower < upper <= 1.0 - assert np.corrcoef(theta, fit.theta_eap)[0, 1] > 0.65 - - -def test_homogeneous_two_pl_matches_existing_gpcm_binary_cell(): - rng = np.random.default_rng(19) - n_persons = 500 - theta = rng.normal(size=n_persons) - slope = np.asarray([0.8, 1.1, 1.4, 1.0]) - intercept = np.asarray([-1.0, -0.3, 0.4, 1.0]) - probability = 1.0 / (1.0 + np.exp(-(theta[:, None] * slope + intercept))) - y = (rng.random(probability.shape) < probability).astype(float) - - mixed = fit_mixed_items( - y, - "2pl", - [2] * y.shape[1], - q_theta=11, - max_iter=80, - tol=1e-5, - n_threads=2, - require_convergence=True, - ) - homogeneous = fit_polytomous(y, 2, "gpcm", q_theta=11, max_iter=80, tol=1e-5) - - _assert_actual_convergence(mixed, tol=1e-5, max_iter=80) - assert mixed.loglik == pytest.approx(homogeneous.loglik, abs=1e-6) - np.testing.assert_allclose( - [item.slope for item in mixed.items], homogeneous.slope, atol=2e-2, rtol=0.0 - ) - np.testing.assert_allclose( - [item.intercepts[0] for item in mixed.items], - homogeneous.cat_params[:, 0], - atol=5e-3, - rtol=0.0, - ) - - -def test_mixed_lsirm_and_nonspatial_items_share_one_fitted_population(): - rng = np.random.default_rng(71) - n_persons = 300 - theta = rng.normal(size=n_persons) - xi = rng.normal(size=n_persons) - y = np.empty((n_persons, 4), dtype=float) - y[:, 0] = rng.random(n_persons) < 1.0 / (1.0 + np.exp(-(theta - 0.2))) - - boundaries = np.asarray([0.7, -0.7]) - cumulative = 1.0 / (1.0 + np.exp(-(theta[:, None] + boundaries))) - y[:, 1] = _draw_rows( - rng, - np.column_stack( - [ - 1.0 - cumulative[:, 0], - cumulative[:, 0] - cumulative[:, 1], - cumulative[:, 1], - ] - ), - ) - - base = 1.1 * theta - 0.7 - np.sqrt((xi - 0.5) ** 2 + 1e-8) - y[:, 2] = rng.random(n_persons) < 1.0 / (1.0 + np.exp(-base)) - base = 0.9 * theta - np.sqrt((xi + 0.4) ** 2 + 1e-8) - logits = base[:, None] * np.arange(3) + np.asarray([0.0, -0.1, -0.6]) - logits -= logits.max(axis=1, keepdims=True) - probability = np.exp(logits) - probability /= probability.sum(axis=1, keepdims=True) - y[:, 3] = _draw_rows(rng, probability) - - fit = fit_mixed_items( - y, - ["2pl", "grm", "lsirm", "lsirm_gpcm"], - [2, 3, 2, 3], - latent_dim=1, - q_theta=7, - q_xi=7, - max_iter=60, - tol=1e-4, - n_threads=2, - require_convergence=True, - ) - - _assert_actual_convergence(fit, tol=1e-4, max_iter=60) - assert fit.xi_eap.shape == (n_persons, 1) - assert np.all(np.isfinite(fit.xi_eap)) - assert [item.zeta.size for item in fit.items] == [0, 0, 1, 1] - assert np.corrcoef(theta, fit.theta_eap)[0, 1] > 0.55 - - -def test_every_response_family_dispatches_without_hiding_iteration_exhaustion(): - rng = np.random.default_rng(3) - categories = [2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 2, 4, 2, 3, 3] - models = [ - "rasch", - "2pl", - "3pl", - "3plu", - "4pl", - "cll", - "grm", - "pcm", - "gpcm", - "sequential", - "tutz", - "nominal", - "ideal", - "ggum", - "lsirm", - "lsirm_grm", - "lsirm_gpcm", - ] - y = np.column_stack([rng.integers(0, count, 180) for count in categories]).astype( - float - ) - - with pytest.warns(RuntimeWarning, match="max_iter_reached"): - fit = fit_mixed_items( - y, - models, - categories, - latent_dim=1, - q_theta=7, - q_xi=7, - max_iter=1, - tol=1e-14, - n_threads=2, - ) - - assert not fit.converged - assert fit.termination_reason == "max_iter_reached" - assert fit.n_iter == 1 - assert len(fit.loglik_trace) == 2 - assert [item.model for item in fit.items] == models - assert np.all(np.isfinite(fit.theta_eap)) - - -def test_item_parameter_constructor_preserves_pre_asymptote_positional_contract(): - empty = np.empty(0, dtype=float) - item = MixedItemParameters("2pl", 2, 1.0, empty, empty, empty, None, empty) - - assert item.zeta is empty - assert item.lower_asymptote is None - assert item.upper_asymptote is None - - -def test_mixed_input_contract_and_required_convergence(): - y = np.tile([[0.0, 0.0], [1.0, 1.0]], (10, 1)) - with pytest.raises(ValueError, match="item_models length"): - fit_mixed_items(y, ["2pl"], [2, 2]) - with pytest.raises(ValueError, match="requires exactly 2 categories"): - fit_mixed_items(y, ["2pl", "2pl"], [3, 2]) - with pytest.raises(RuntimeError, match="max_iter_reached"): - fit_mixed_items( - y, - ["2pl", "2pl"], - [2, 2], - q_theta=7, - max_iter=1, - tol=1e-14, - require_convergence=True, - ) - - y[0, 0] = np.nan - fit = fit_mixed_items( - y, - ["2pl", "2pl"], - [2, 2], - q_theta=7, - max_iter=20, - tol=1e-3, - n_threads=1, - require_convergence=True, - ) - _assert_actual_convergence(fit, tol=1e-3, max_iter=20) diff --git a/tests/test_objective.py b/tests/test_objective.py index f6c1cbf63..eaae79c65 100644 --- a/tests/test_objective.py +++ b/tests/test_objective.py @@ -421,15 +421,6 @@ def test_objective_model_requires_one_trait(): with pytest.raises(ValueError, match="ULS2PLM requires one trait dimension"): neg_loglik_and_grad(np.zeros((2, 2)), np.zeros(2, dtype=int), params, config=FitConfig(model="ULS2PLM")) - for backend in ("numpy", "rust"): - with pytest.raises(ValueError, match="marginal estimator only"): - neg_loglik_and_grad( - np.zeros((2, 2)), - np.zeros(2, dtype=int), - params, - config=FitConfig(model="BIFAC2PLM"), - backend=backend, - ) def test_objective_add_penalty_uses_space(): diff --git a/tests/test_paper_features.py b/tests/test_paper_features.py deleted file mode 100644 index ff99b33af..000000000 --- a/tests/test_paper_features.py +++ /dev/null @@ -1,6496 +0,0 @@ -"""Tests for the paper-grounded additions: zero inflation, position covariate, -validation gates, IRTree expansion, DIF analysis, Vuong, Q3/GDDM, and ICs.""" - -from __future__ import annotations - -import math - -import numpy as np -import pytest - -from fast_mlsirm import ( - raju_area, - velicer_map, - velicer_map_from_data, - FitConfig, - dif_analysis, - dimensionality_residuals, - fit, - irtree_expand, - validate_judge, - vuong_nonnested, -) - - -def _sim_2pl(seed=0, P=600, I=12, shift_by_group=None, gid=None): - rng = np.random.default_rng(seed) - fid = np.zeros(I, dtype=np.int64) - a = 0.8 + 0.6 * rng.random(I) - b = np.linspace(-1.2, 1.2, I) - theta = rng.standard_normal(P) - if shift_by_group is not None: - theta = theta + np.asarray(shift_by_group)[gid] - eta = a[None, :] * theta[:, None] + b[None, :] - y = (rng.random((P, I)) < 1 / (1 + np.exp(-eta))).astype(float) - return y, fid, a, b - - -def test_zero_inflation_via_public_api(): - y, fid, *_ = _sim_2pl(seed=1) - y[:150] = 0.0 # structural zeros: 25% - cfg = FitConfig( - model="MLSRM", estimator="mmle", max_iter=60, latent_dim=1, - q_theta=15, q_xi=7, zero_inflation=True, rust_device="cpu", - ) - r = fit(y, fid, cfg) - pop = r.population - assert 0.1 < pop["pi_zero"] < 0.45 - assert pop["zero_responsibility"][:150].mean() > 0.6 - # ULSRM: (b + zeta) per item + tau + pi = 12*2 + 1 + 1 - assert r.ic is not None and r.ic["n_parameters"] == 26 - plain = fit(y, fid, FitConfig( - model="MLSRM", estimator="mmle", max_iter=60, latent_dim=1, - q_theta=15, q_xi=7, rust_device="cpu", - )) - assert r.loglik_trace[-1] > plain.loglik_trace[-1] - # BIC prefers the mixture on mixture data (Kang-Cohen-Sung: BIC decides) - assert r.ic["bic"] < plain.ic["bic"] - - -def test_zero_inflation_multilevel_final_scores_use_mixture_posteriors(): - from fast_mlsirm.estimators import marginal as marginal_ref - - rng = np.random.default_rng(20260722) - n_clusters, persons_per_cluster, n_items = 4, 8, 6 - n_persons = n_clusters * persons_per_cluster - cluster_id = np.repeat(np.arange(n_clusters), persons_per_cluster) - u_true = np.array([-1.2, -0.3, 0.6, 1.4]) - theta = rng.standard_normal(n_persons) + u_true[cluster_id] - b_true = np.linspace(-1.0, 1.0, n_items) - eta = theta[:, None] + b_true[None, :] - y = (rng.random((n_persons, n_items)) < 1.0 / (1.0 + np.exp(-eta))).astype(float) - y[np.flatnonzero(cluster_id == n_clusters - 1)[: persons_per_cluster // 2]] = 0.0 - factor_id = np.zeros(n_items, dtype=np.int64) - config = FitConfig( - model="ULS2PLM", - estimator="mmle", - backend="numpy", - latent_dim=1, - q_theta=7, - q_xi=7, - q_u=7, - max_iter=80, - tolerance=3e-2, - zero_inflation=True, - ) - result = fit(y, factor_id, config, cluster_id=cluster_id) - assert result.convergence_status == "converged" - assert result.n_iter < config.max_iter - assert abs(result.loglik_trace[-1] - result.loglik_trace[-2]) < config.tolerance - - population = result.population - t_nodes, t_weights = marginal_ref._gh(config.q_theta) - x_grid, x_logw = marginal_ref._xi_nodes( - config.xi_rule, - config.latent_dim, - config.q_xi, - config.xi_points, - config.xi_seed, - ) - contexts = marginal_ref._build_contexts( - { - "kind": "multilevel", - "cluster_id": cluster_id, - "n_clusters": n_clusters, - }, - np.zeros((0, 1)), - np.zeros((0, 1)), - population["sigma_u"], - 1, - config.q_u, - ) - logp1, logp0, c0 = marginal_ref._build_tables( - result.params.alpha, - result.params.b, - result.params.zeta, - result.params.tau, - config.normalized_model(), - factor_id, - contexts, - t_nodes, - x_grid, - config.eps_distance, - 1, - None, - ) - lp_irt = np.empty((n_persons, config.q_u)) - observed = np.ones_like(y, dtype=bool) - for node in range(config.q_u): - context = np.full(n_persons, node, dtype=np.int64) - _, _, lp_irt[:, node] = marginal_ref._person_logliks( - y, - observed, - factor_id, - logp1, - logp0, - c0, - np.log(t_weights), - x_logw, - context, - 1, - ) - all_zero = ~(y > 0.0).any(axis=1) - cluster_post, _ = marginal_ref._multilevel_context_posteriors( - lp_irt, - all_zero, - cluster_id, - n_clusters, - contexts["u_logw"], - population["pi_zero"], - ) - expected_u = cluster_post @ (population["sigma_u"] * contexts["u_nodes"]) - plain_post, _ = marginal_ref._multilevel_context_posteriors( - lp_irt, - all_zero, - cluster_id, - n_clusters, - contexts["u_logw"], - None, - ) - plain_u = plain_post @ (population["sigma_u"] * contexts["u_nodes"]) - - np.testing.assert_allclose(population["u_eap"], expected_u, atol=1e-12, rtol=0.0) - assert np.max(np.abs(expected_u - plain_u)) > 0.1 - - -def test_position_covariate_via_public_api(): - rng = np.random.default_rng(3) - P, I = 800, 10 - fid = np.zeros(I, dtype=np.int64) - gid = np.arange(P) % 2 - w = np.zeros((2, I)) - w[0] = np.linspace(0, 1, I) - w[1] = np.linspace(1, 0, I) - theta = rng.standard_normal(P) - b = np.linspace(-1, 1, I) - delta_true = -0.9 - eta = theta[:, None] + b[None, :] + delta_true * w[gid] - y = (rng.random((P, I)) < 1 / (1 + np.exp(-eta))).astype(float) - cfg = FitConfig(model="ULSRM", estimator="mmle", max_iter=80, latent_dim=1, - q_theta=15, q_xi=7, rust_device="cpu") - r = fit(y, fid, cfg, group_id=gid, covariate={"w": w, "init_delta": 0.0}) - assert abs(r.population["delta"] - delta_true) < 0.4, r.population["delta"] - with pytest.raises(ValueError, match="multilevel"): - fit(y, fid, cfg, cluster_id=gid, covariate={"w": w}) - - -def test_validation_gates(): - rng = np.random.default_rng(5) - human = (rng.random(500) < 0.5).astype(np.uint32) - good = human.copy() - flip = rng.random(500) < 0.03 - good[flip] = 1 - good[flip] - verdict = validate_judge(good, human, k=2) - assert verdict.passed, verdict.failed_gates - bad = human.copy() - flip = rng.random(500) < 0.4 - bad[flip] = 1 - bad[flip] - verdict_bad = validate_judge(bad, human, k=2) - assert not verdict_bad.passed - assert "qwk" in verdict_bad.failed_gates - - -def test_irtree_expand_linear_tree(): - # 3 categories, 2 nodes (linear tree): node0 = "beyond cat0", - # node1 = "cat2 given beyond cat0" (off-path for cat0) - mapping = np.array([[0.0, 1.0, 1.0], [np.nan, 0.0, 1.0]]) - y = np.array([[0, 2], [1, np.nan]]) - expanded, factor_id = irtree_expand(y, mapping) - assert expanded.shape == (2, 4) - # person 0: item0 cat0 -> node0=0, node1=NaN; item1 cat2 -> node0=1, node1=1 - np.testing.assert_array_equal(expanded[0], [0.0, 1.0, np.nan, 1.0]) - # person 1: item0 cat1 -> node0=1, node1=0; item1 missing -> NaN, NaN - np.testing.assert_array_equal(expanded[1], [1.0, np.nan, 0.0, np.nan]) - np.testing.assert_array_equal(factor_id, [0, 0, 1, 1]) - with pytest.raises(ValueError, match="integer categories"): - irtree_expand(np.array([[5.0]]), mapping) - - -@pytest.mark.parametrize( - ("responses", "mapping", "node_dims", "message"), - [ - (np.array([0.0]), np.array([[0.0, 1.0]]), None, "persons x items"), - (np.empty((0, 1)), np.array([[0.0, 1.0]]), None, "at least one person"), - (np.array([[np.inf]]), np.array([[0.0, 1.0]]), None, "categories or NaN"), - (np.array([[0.0]]), np.array([[0.0, np.inf]]), None, "0, 1, or NaN"), - (np.array([[np.nan]]), np.empty((1, 0)), None, "at least one node"), - (np.array([[0.0]]), np.empty((0, 1)), None, "at least one node"), - ( - np.array([[0.0]]), - np.array([[0.0, 0.0, 1.0], [1.0, 1.0, 0.0]]), - None, - "distinct tree paths", - ), - ( - np.array([[0.0]]), - np.array([[0.0, 1.0], [0.0, 1.0]]), - np.array([0, 2**63]), - "node_dims", - ), - ( - np.array([[0.0]]), - np.array([[0.0, 1.0], [0.0, 1.0]]), - np.array([1, 1]), - "contiguous dimension indices", - ), - ], -) -def test_irtree_expand_rejects_invalid_tree_contracts( - responses, mapping, node_dims, message -): - with pytest.raises(ValueError, match=message): - irtree_expand(responses, mapping, node_dims=node_dims) - - -@pytest.mark.parametrize( - "mapping", - [ - np.array([[np.nan, 0.0, 1.0], [np.nan, 1.0, 0.0]]), - np.array([[0.0, 0.0], [0.0, 1.0]]), - ], -) -def test_irtree_expand_rejects_unidentified_paths_and_nodes(mapping): - with pytest.raises(ValueError): - irtree_expand(np.array([[0.0]]), mapping) - - -def test_dif_analysis_detects_injected_shift(): - rng = np.random.default_rng(11) - P, I = 900, 8 - fid = np.zeros(I, dtype=np.int64) - gid = (np.arange(P) % 2).astype(np.int64) - a = np.ones(I) - b = np.linspace(-1, 1, I) - theta = rng.standard_normal(P) - eta = a[None, :] * theta[:, None] + b[None, :] - eta[:, 3] += np.where(gid == 1, 1.2, 0.0) # uniform DIF on item 3 - y = (rng.random((P, I)) < 1 / (1 + np.exp(-eta))).astype(float) - cfg = FitConfig(model="MIRT", estimator="mmle", max_iter=100, - tolerance=0.02, q_theta=15, rust_device="cpu") - res = dif_analysis(y, fid, gid, config=cfg, studied_items=[2, 3]) - assert res.flagged_bh[3], f"item 3 must flag: p={res.p_value[3]}" - assert res.effect_size[3] > 0.5 - assert not res.flagged_bh[2] or res.p_value[2] > res.p_value[3] - - -def test_dif_analysis_rejects_spatial_models_with_unaccounted_positions(): - y = np.array([[0.0, 1.0], [1.0, 0.0]]) - with pytest.raises(ValueError, match="latent-space item positions"): - dif_analysis( - y, - np.zeros(2, dtype=np.int64), - np.array([0, 1]), - config=FitConfig(model="ULSRM", estimator="mmle"), - studied_items=[0], - ) - - -@pytest.mark.parametrize( - ("group_id", "kwargs", "message"), - [ - (np.array([0, 1]), {"mask": np.ones((2, 2), dtype=int)}, "boolean"), - (np.array([0, 0]), {}, "at least two groups"), - (np.array([0, 1]), {"item_codes": ["item_0"]}, "one entry"), - (np.array([0, 1]), {"studied_items": [-1]}, "out-of-range"), - (np.array([0, 1]), {"studied_items": []}, "must not be empty"), - (np.array([0, 1]), {"studied_items": [0, 0]}, "duplicate"), - (np.array([0, 1]), {"fdr_q": 0.0}, r"in \(0, 1\]"), - (np.array([0, 1]), {"fdr_q": np.nan}, r"in \(0, 1\]"), - ], -) -def test_dif_analysis_rejects_ambiguous_public_inputs(group_id, kwargs, message): - with pytest.raises(ValueError, match=message): - dif_analysis( - np.array([[0.0, 1.0], [1.0, 0.0]]), - np.zeros(2, dtype=np.int64), - group_id, - **kwargs, - ) - - -def test_dif_analysis_compacts_sparse_group_labels(monkeypatch): - """Equivalent group partitions must have identical DIF bookkeeping.""" - import importlib - from types import SimpleNamespace - - fit_module = importlib.import_module("fast_mlsirm.fit") - seen_group_ids = [] - - def fake_fit(y, factor_id, config, group_id=None, anchors=None, **_kwargs): - seen_group_ids.append(np.asarray(group_id).copy()) - n_items = y.shape[1] - return SimpleNamespace( - params=SimpleNamespace( - alpha=np.zeros(n_items), - b=np.arange(n_items, dtype=float), - zeta=np.zeros((n_items, 1)), - tau=0.0, - ), - convergence_status="converged", - n_iter=2, - loglik_trace=[0.0, 1.0] if anchors is not None else [-1.0, 0.0], - ) - - monkeypatch.setattr(fit_module, "fit", fake_fit) - y = np.array([[0.0, 1.0], [1.0, 0.0]]) - result = dif_analysis( - y, - np.zeros(2, dtype=np.int64), - np.array([10, 20]), - studied_items=[0], - ) - - assert result.df[0] == 2.0 - assert result.b_by_group.shape == (2, 2) - assert result.effect_size[0] == 1.0 - assert all(np.array_equal(gid, [0, 1]) for gid in seen_group_ids) - - -def test_dif_analysis_rejects_nonconverged_constrained_fit(monkeypatch): - import importlib - from types import SimpleNamespace - - fit_module = importlib.import_module("fast_mlsirm.fit") - - def fake_fit(*_args, **_kwargs): - return SimpleNamespace( - convergence_status="max_iter_reached", - n_iter=3, - loglik_trace=[-3.0, -2.0, -1.5], - ) - - monkeypatch.setattr(fit_module, "fit", fake_fit) - with pytest.raises( - RuntimeError, - match=r"dif_analysis requires converged parameters.*status=max_iter_reached", - ): - dif_analysis( - np.array([[0.0, 1.0], [1.0, 0.0]]), - np.zeros(2, dtype=np.int64), - np.array([0, 1]), - studied_items=[0], - ) - - -def test_vuong_and_dimensionality_wrappers(): - y, fid, *_ = _sim_2pl(seed=13, P=400, I=10) - cfg = FitConfig(model="ULSRM", estimator="mmle", max_iter=40, latent_dim=1, - q_theta=15, q_xi=7, rust_device="cpu", zero_inflation=False) - r = fit(y, fid, cfg) - # Vuong on synthetic casewise logliks - la = -1.0 + 0.1 * np.random.default_rng(0).random(400) - lb = la - 0.15 - 0.2 * (np.random.default_rng(1).random(400) - 0.5) - v = vuong_nonnested(la, lb, 10, 10, bic_correction=False) - assert v["z"] > 0 and 0 <= v["p_two_sided"] <= 1 - # Residual diagnostics on a converged, well-fitting model: modest Q3 and - # a small repository-specific mean absolute residual cross-product. - d = dimensionality_residuals( - y, - fid, - r.params, - r.model, - convergence_status=r.convergence_status, - ) - assert d["q3"].shape[0] == 10 * 9 // 2 - assert d["q3_max_abs"] < 0.5 - assert d["mean_abs_residual_cross_product"] < 0.05 - assert d["gddm"] == d["mean_abs_residual_cross_product"] - - -def test_dimensionality_residuals_bifactor_uses_inner_product(): - from fast_mlsirm import _core - from fast_mlsirm.types import MLSIRMParams - - params = MLSIRMParams( - theta=np.array([[-1.0], [0.2], [0.8], [-0.4], [1.1], [0.0]]), - alpha=np.log(np.array([1.0, 1.3, 0.8])), - b=np.array([-0.2, 0.3, -0.1]), - xi=np.array([[-1.5], [1.2], [0.7], [-0.8], [1.6], [-0.1]]), - zeta=np.array([[1.1], [-0.9], [0.6]]), - tau=np.log(2.0), - ) - factor_id = np.zeros(3, dtype=np.int64) - eta = ( - np.exp(params.alpha)[None, :] * params.theta[:, factor_id] - + params.b[None, :] - + params.xi @ params.zeta.T - ) - probability = 1.0 / (1.0 + np.exp(-eta)) - responses = (probability >= 0.5).astype(float) - expected = dict( - _core.dimensionality_residuals((responses - probability).ravel(), 6, 3) - ) - - actual = dimensionality_residuals( - responses, - factor_id, - params, - "BIFAC2PLM", - convergence_status="converged", - ) - np.testing.assert_allclose(actual["q3"], expected["q3"]) - assert actual["mean_abs_residual_cross_product"] == pytest.approx( - expected["mean_abs_residual_cross_product"] - ) - - -def test_dimensionality_residuals_rejects_unfinished_fit_and_invalid_inputs(): - from fast_mlsirm.types import MLSIRMParams - - params = MLSIRMParams( - theta=np.zeros((3, 1)), - alpha=np.zeros(2), - b=np.zeros(2), - xi=np.zeros((3, 1)), - zeta=np.zeros((2, 1)), - tau=0.0, - ) - y = np.zeros((3, 2)) - factor_id = np.zeros(2, dtype=np.int64) - with pytest.raises(ValueError, match="did not converge"): - dimensionality_residuals( - y, factor_id, params, "MIRT", convergence_status="max_iter_reached" - ) - with pytest.raises(ValueError, match="model must be one of"): - dimensionality_residuals(y, factor_id, params, "not-a-model") - with pytest.raises(ValueError, match="mask shape"): - dimensionality_residuals(y, factor_id, params, "MIRT", mask=np.ones(3)) - with pytest.raises(ValueError, match="observed responses must be 0 or 1"): - dimensionality_residuals( - np.array([[0.0, 2.0], [0.0, 1.0], [1.0, 0.0]]), - factor_id, - params, - "MIRT", - ) - - -@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf]) -def test_vuong_rejects_nonfinite_casewise_loglikelihoods(bad): - with pytest.raises(ValueError, match="finite"): - vuong_nonnested(np.array([0.0, bad]), np.array([0.0, 1.0]), 1, 1) - - -@pytest.mark.parametrize( - "k_a,k_b,bic_correction,match", - [ - (1.5, 1, True, "k_a"), - (True, 1, True, "k_a"), - (1, -1, True, "k_b"), - (1, 1, 1, "bic_correction"), - ], -) -def test_vuong_rejects_lossy_parameter_controls(k_a, k_b, bic_correction, match): - with pytest.raises(ValueError, match=match): - vuong_nonnested( - np.array([0.0, 1.0]), - np.array([1.0, 0.0]), - k_a, - k_b, - bic_correction=bic_correction, - ) - - -def test_bifactor_parity_and_recovery(): - rng = np.random.default_rng(21) - P, I, D = 500, 10, 2 - fid = np.array([i % D for i in range(I)]) - lam = 0.6 + 0.8 * rng.random(I) - b = np.linspace(-1, 1, I) - g = rng.standard_normal(P) - th = rng.standard_normal((P, D)) - eta = th[:, fid] + b[None, :] + lam[None, :] * g[:, None] - y = (rng.random((P, I)) < 1 / (1 + np.exp(-eta))).astype(float) - max_iter = 300 - tolerance = 1e-4 - results = {} - for backend in ("rust", "numpy"): - cfg = FitConfig( - model="BIFAC2PLM", - estimator="mmle", - max_iter=max_iter, - backend=backend, - rust_device="cpu", - latent_dim=1, - q_theta=15, - q_xi=15, - tolerance=tolerance, - ) - results[backend] = fit(y, fid, cfg) - r, n = results["rust"], results["numpy"] - for result in results.values(): - trace = np.asarray(result.loglik_trace) - assert result.convergence_status == "converged" - assert result.n_iter < max_iter - assert np.all(np.isfinite(trace)) - assert np.all(np.diff(trace) >= -1e-10) - assert abs(trace[-1] - trace[-2]) < tolerance - np.testing.assert_allclose(r.params.b, n.params.b, atol=1e-9) - np.testing.assert_allclose(r.params.zeta, n.params.zeta, atol=1e-9) - np.testing.assert_allclose(r.loglik_trace[-1], n.loglik_trace[-1], atol=1e-9) - # loadings track the truth - c = np.corrcoef(r.params.zeta[:, 0], lam)[0, 1] - assert abs(c) > 0.5, f"lambda recovery: {c}" - # jmle guard - import pytest as _pytest - - with _pytest.raises(NotImplementedError, match="marginal estimator"): - fit(y, fid, FitConfig(model="BIFAC2PLM", estimator="jmle")) - - -def test_bifactor_covariate_parity_uses_inner_product_predictor(): - rng = np.random.default_rng(22) - P, I, D = 400, 8, 2 - fid = np.arange(I) % D - gid = np.arange(P) % 2 - w = np.empty((2, I)) - w[0] = np.linspace(0.0, 1.0, I) - w[1] = w[0, ::-1] - theta = rng.standard_normal((P, D)) - general = rng.standard_normal(P) - loadings = np.linspace(0.7, 1.3, I) - intercepts = np.linspace(-0.8, 0.8, I) - delta_true = -1.0 - eta = ( - theta[:, fid] - + intercepts[None, :] - + loadings[None, :] * general[:, None] - + delta_true * w[gid] - ) - y = (rng.random((P, I)) < 1.0 / (1.0 + np.exp(-eta))).astype(float) - - max_iter = 300 - tolerance = 1e-4 - results = {} - for backend in ("rust", "numpy"): - cfg = FitConfig( - model="BIFAC2PLM", - estimator="mmle", - max_iter=max_iter, - tolerance=tolerance, - backend=backend, - rust_device="cpu", - latent_dim=1, - q_theta=7, - q_xi=7, - ) - results[backend] = fit( - y, - fid, - cfg, - group_id=gid, - covariate={"w": w, "init_delta": 0.0}, - ) - - for result in results.values(): - trace = np.asarray(result.loglik_trace) - assert result.convergence_status == "converged" - assert result.n_iter < max_iter - assert np.all(np.isfinite(trace)) - assert np.all(np.diff(trace) >= -1e-10) - assert abs(trace[-1] - trace[-2]) < tolerance - - rust_delta = results["rust"].population["delta"] - numpy_delta = results["numpy"].population["delta"] - assert rust_delta < -0.2 - np.testing.assert_allclose(rust_delta, numpy_delta, atol=1e-8) - np.testing.assert_allclose( - results["rust"].loglik_trace[-1], - results["numpy"].loglik_trace[-1], - atol=1e-8, - ) - - -def test_m2_rmsea2_parity_and_fit(): - # M2 limited-information GOF (Maydeu-Olivares & Joe): Rust core vs the - # NumPy reference, plus a well-specified-vs-local-dependence contrast. - from fast_mlsirm import fitstats - - y, fid, _a, _b = _sim_2pl(seed=5, P=1800, I=12) - res = fit(y, fid, FitConfig(model="MIRT", estimator="mmle", max_iter=200, - backend="rust", rust_device="cpu")) - - core = fitstats.m2(y, fid, res.params, "MIRT", q_theta=21) - ref = fitstats._m2_numpy(y, ~np.isnan(y), fid, res.params, "MIRT", 21, 11, 1e-8) - - # exact structural agreement - assert core.n_moments == ref.n_moments == 78 - assert core.n_parameters == ref.n_parameters == 24 - assert core.df == ref.df == 54.0 - assert core.n_complete == ref.n_complete == 1800 - # numeric parity (hand Cholesky vs LAPACK solve): tight but not bit-exact - np.testing.assert_allclose(core.m2, ref.m2, rtol=1e-6, atol=1e-6) - np.testing.assert_allclose(core.rmsea2, ref.rmsea2, rtol=1e-6, atol=1e-8) - np.testing.assert_allclose(core.srmsr, ref.srmsr, rtol=1e-6, atol=1e-8) - np.testing.assert_allclose(core.null_m2, ref.null_m2, rtol=1e-6, atol=1e-6) - np.testing.assert_allclose(core.cfi, ref.cfi, rtol=1e-6, atol=1e-8) - np.testing.assert_allclose(core.tli, ref.tli, rtol=1e-6, atol=1e-8) - np.testing.assert_allclose(core.rmsea2_ci_lower, ref.rmsea2_ci_lower, atol=1e-6) - np.testing.assert_allclose(core.rmsea2_ci_upper, ref.rmsea2_ci_upper, atol=1e-6) - assert core.null_df == ref.null_df == 66.0 - assert core.rmsea == core.rmsea2 - assert core.srmr == core.srmsr - expected_cfi = np.clip( - 1.0 - (core.m2 - core.df) / (core.null_m2 - core.null_df), 0.0, 1.0 - ) - expected_tli = ( - (core.null_m2 / core.null_df) - (core.m2 / core.df) - ) / ((core.null_m2 / core.null_df) - 1.0) - np.testing.assert_allclose(core.cfi, expected_cfi, atol=1e-12) - np.testing.assert_allclose(core.tli, expected_tli, atol=1e-12) - - # The main diagnostics path exposes the global indices only when explicitly - # requested, keeping the O(s^3) M2 solve out of ordinary JML diagnostics. - from fast_mlsirm import fit_diagnostics - - diag = fit_diagnostics( - y, res.params, fid, model="MIRT", include_m2=True, estimator="mmle" - ) - for key in ("m2", "rmsea", "srmr", "cfi", "tli", "null_m2"): - assert key in diag.model_fit and np.isfinite(diag.model_fit[key]) - np.testing.assert_allclose(diag.model_fit["rmsea"], core.rmsea2, atol=1e-12) - np.testing.assert_allclose(diag.model_fit["srmr"], core.srmsr, atol=1e-12) - - # well specified: small RMSEA2, CI brackets the point estimate - assert core.rmsea2 < 0.03 - assert core.rmsea2_ci_lower <= core.rmsea2 + 1e-9 <= core.rmsea2_ci_upper + 1e-9 - - # inject local dependence (duplicate item) -> M2 and RMSEA2 inflate - y_ld = y.copy() - y_ld[:, 1] = y_ld[:, 0] - res_ld = fit(y_ld, fid, FitConfig(model="MIRT", estimator="mmle", max_iter=200, - backend="rust", rust_device="cpu")) - ld = fitstats.m2(y_ld, fid, res_ld.params, "MIRT", q_theta=21) - assert ld.m2 > core.m2 - assert ld.rmsea2 > 0.08 - assert ld.srmsr > core.srmsr - assert ld.cfi < core.cfi - assert ld.tli < core.tli - - # JMLE/CMLE estimates may still be inspected against an explicit marginal - # evaluation population, but ordinary chi-square inference is not claimed. - descriptive = fitstats.m2( - y, fid, res.params, "MIRT", q_theta=21, estimator="jmle" - ) - assert np.isfinite(descriptive.m2) - assert not descriptive.inference_valid - assert np.isnan(descriptive.p_value) - assert np.isnan(descriptive.rmsea2_ci_lower) - - -def test_m2_noncentral_ci_large_statistic_reference_values(): - """Large noncentralities must not collapse when exp(-lambda/2) underflows.""" - from fast_mlsirm.fitstats import _nc_lambda_for, _ncchi2_cdf - - # Independently evaluated with scipy.stats.ncx2 and scipy.optimize.brentq. - cases = [ - (2_000.0, 50.0, 0.05, 2_099.9287582915094), - (10_000.0, 50.0, 0.05, 10_282.274417418035), - (10_000.0, 50.0, 0.95, 9_625.139462181574), - ] - for statistic, df, target, expected in cases: - got = _nc_lambda_for(statistic, df, target) - np.testing.assert_allclose(got, expected, rtol=1e-10) - np.testing.assert_allclose( - _ncchi2_cdf(statistic, df, got), target, atol=1e-10 - ) - - -def test_m2_singlefree_uses_only_estimated_calibration_columns(): - """FIPC M2 counts free-population columns and excludes anchored items.""" - from fast_mlsirm import fit_diagnostics - from fast_mlsirm import fitstats - from fast_mlsirm.types import MLSIRMParams - - rng = np.random.default_rng(2718) - n_persons, n_items = 1600, 8 - factor_id = np.zeros(n_items, dtype=np.int64) - alpha = np.log(np.linspace(0.8, 1.4, n_items)) - b = np.linspace(-1.0, 1.0, n_items) - population_mean = np.array([0.4]) - population_sd = np.array([1.2]) - theta = population_mean[0] + population_sd[0] * rng.standard_normal(n_persons) - probability = 1.0 / ( - 1.0 + np.exp(-(theta[:, None] * np.exp(alpha)[None, :] + b[None, :])) - ) - responses = (rng.random(probability.shape) < probability).astype(float) - params = MLSIRMParams( - theta=theta[:, None], - alpha=alpha, - b=b, - xi=np.zeros((n_persons, 1)), - zeta=np.zeros((n_items, 1)), - tau=0.0, - ) - fixed_items = np.arange(n_items) < 3 - - ordinary = fitstats.m2( - responses, - factor_id, - params, - "MIRT", - prior_mean=population_mean, - prior_sd=population_sd, - ) - singlefree = fitstats.m2( - responses, - factor_id, - params, - "MIRT", - prior_mean=population_mean, - prior_sd=population_sd, - estimate_population=True, - fixed_items=fixed_items, - ) - - # Ordinary 2PL estimates 2I item columns. FIPC fixes three item rows and - # instead estimates the population mean and SD: 2*(8-3) + 2 = 12. - assert ordinary.n_parameters == 16 - assert singlefree.n_parameters == 12 - assert singlefree.n_moments == 36 - assert singlefree.df == 24.0 - assert np.isfinite(singlefree.m2) - assert np.isfinite(singlefree.p_value) - assert "estimated mean/SD" in singlefree.inference_note - - diagnostics = fit_diagnostics( - responses, - params, - factor_id, - model="MIRT", - include_m2=True, - estimator="mmle", - convergence_status="converged", - population={ - "kind": "singlefree", - "mu": population_mean[None, :], - "sigma": population_sd[None, :], - "fixed_items": fixed_items, - "tau_fixed": False, - }, - ) - assert diagnostics.model_fit["m2_df"] == 24.0 - assert diagnostics.model_fit["m2"] == singlefree.m2 - - with pytest.raises(ValueError, match="fixed_items must have shape"): - fitstats.m2( - responses, - factor_id, - params, - "MIRT", - prior_mean=population_mean, - prior_sd=population_sd, - estimate_population=True, - fixed_items=np.zeros(n_items - 1, dtype=bool), - ) - with pytest.raises(ValueError, match="requires estimator='mmle'"): - fitstats.m2( - responses, - factor_id, - params, - "MIRT", - estimator="jmle", - fixed_items=fixed_items, - ) - - -def test_m2_multigroup_and_multilevel_structures(): - """Population structure changes M2 moments/covariance, not just labels.""" - from fast_mlsirm import fit_diagnostics, m2_multigroup, m2_multilevel - from fast_mlsirm.types import MLSIRMParams - - rng = np.random.default_rng(123) - n_items = 10 - n_per_group = 700 - factor_id = np.zeros(n_items, dtype=np.int64) - alpha = np.log(np.linspace(0.8, 1.4, n_items)) - b = np.linspace(-1.0, 1.0, n_items) - group_id = np.repeat(np.arange(2), n_per_group) - means = np.array([[0.0], [0.6]]) - sds = np.array([[1.0], [1.2]]) - theta = means[group_id, 0] + sds[group_id, 0] * rng.standard_normal(group_id.size) - prob = 1.0 / (1.0 + np.exp(-(theta[:, None] * np.exp(alpha) + b))) - responses = (rng.random(prob.shape) < prob).astype(float) - params = MLSIRMParams( - theta=np.zeros((responses.shape[0], 1)), - alpha=alpha, - b=b, - xi=np.zeros((responses.shape[0], 1)), - zeta=np.zeros((n_items, 1)), - tau=0.0, - ) - group_fit = m2_multigroup( - responses, factor_id, params, "MIRT", group_id, means, sds - ) - assert group_fit.n_groups == 2 - assert group_fit.n_parameters == 2 * n_items + 2 - assert group_fit.df == 88.0 - assert group_fit.null_df == 90.0 - assert np.isfinite(group_fit.m2) - assert np.isfinite(group_fit.cfi) - with pytest.raises(ValueError, match="non-negative integers"): - m2_multigroup( - responses, - factor_id, - params, - "MIRT", - group_id.astype(float) + 0.25, - means, - sds, - ) - with pytest.raises(ValueError, match="mask must match"): - m2_multigroup( - responses, - factor_id, - params, - "MIRT", - group_id, - means, - sds, - mask=np.ones((1, 1), dtype=bool), - ) - group_diagnostics = fit_diagnostics( - responses, - params, - factor_id, - model="MIRT", - group_id=group_id, - include_m2=True, - estimator="mmle", - population={"kind": "multigroup", "mu": means, "sigma": sds}, - ) - np.testing.assert_allclose(group_diagnostics.model_fit["m2"], group_fit.m2) - assert group_diagnostics.model_fit["m2_n_groups"] == 2.0 - - locally_dependent = responses.copy() - locally_dependent[:, 1] = locally_dependent[:, 0] - group_ld = m2_multigroup( - locally_dependent, factor_id, params, "MIRT", group_id, means, sds - ) - assert group_ld.m2 > group_fit.m2 - assert group_ld.cfi < group_fit.cfi - assert group_ld.tli < group_fit.tli - - # Random-intercept data: the effective covariance comes from independent - # cluster totals. There must be more clusters than retained M2 moments. - n_items = 8 - n_clusters = 70 - cluster_size = 12 - sigma_u = 0.7 - cluster_id = np.repeat(np.arange(n_clusters), cluster_size) - alpha = np.log(np.linspace(0.9, 1.3, n_items)) - b = np.linspace(-0.8, 0.8, n_items) - u = np.repeat(sigma_u * rng.standard_normal(n_clusters), cluster_size) - theta = rng.standard_normal(cluster_id.size) + u - prob = 1.0 / (1.0 + np.exp(-(theta[:, None] * np.exp(alpha) + b))) - responses = (rng.random(prob.shape) < prob).astype(float) - params = MLSIRMParams( - theta=np.zeros((responses.shape[0], 1)), - alpha=alpha, - b=b, - xi=np.zeros((responses.shape[0], 1)), - zeta=np.zeros((n_items, 1)), - tau=0.0, - ) - cluster_fit = m2_multilevel( - responses, np.zeros(n_items, dtype=np.int64), params, "MIRT", - cluster_id, sigma_u, - ) - assert cluster_fit.n_clusters == n_clusters - assert cluster_fit.n_parameters == 2 * n_items + 1 - assert cluster_fit.df == 19.0 - assert np.isfinite(cluster_fit.m2) - assert np.isfinite(cluster_fit.p_value) - assert "cluster-robust" in cluster_fit.inference_note - cluster_diagnostics = fit_diagnostics( - responses, - params, - np.zeros(n_items, dtype=np.int64), - model="MIRT", - cluster_id=cluster_id, - include_m2=True, - estimator="mmle", - population={"kind": "multilevel", "sigma_u": sigma_u}, - ) - np.testing.assert_allclose(cluster_diagnostics.model_fit["m2"], cluster_fit.m2) - assert cluster_diagnostics.model_fit["m2_n_clusters"] == float(n_clusters) - - -def test_m2_multilevel_integrates_one_shared_intercept_across_dimensions(): - """The scalar cluster effect induces cross-factor covariance.""" - from fast_mlsirm.fitstats import _m2_group_components - from fast_mlsirm.types import MLSIRMParams - - factor_id = np.array([0, 0, 1, 1], dtype=np.int64) - responses = np.zeros((20, 4), dtype=float) - params = MLSIRMParams( - theta=np.zeros((20, 2)), - alpha=np.zeros(4), - b=np.zeros(4), - xi=np.zeros((20, 1)), - zeta=np.zeros((4, 1)), - tau=0.0, - ) - common = dict( - y0=responses, - observed0=np.ones_like(responses, dtype=bool), - d_of_i=factor_id, - params=params, - model="MIRT", - q_theta=15, - q_xi=7, - eps_distance=1e-8, - prior_mean=np.zeros(2), - prior_sd=np.ones(2), - ) - independent = _m2_group_components(**common) - shared = _m2_group_components(**common, shared_sigma_u=0.8, q_u=15) - - # Pair order after four univariate moments is (0,1), (0,2), ... . - cross_factor_pair = 5 - assert abs(independent["mom"][cross_factor_pair] - 0.25) < 1e-12 - assert shared["mom"][cross_factor_pair] > independent["mom"][cross_factor_pair] - assert np.any(np.abs(shared["delta_shared"]) > 1e-8) - - -def test_m2_cmle_rasch_conditions_out_person_ability(): - """CMLE M2 uses raw-score conditional moments, not a Gaussian prior.""" - from fast_mlsirm import m2 - from fast_mlsirm.types import MLSIRMParams - - rng = np.random.default_rng(9) - n_persons, n_items = 3000, 8 - b = np.linspace(-1.3, 1.3, n_items) - theta = rng.standard_normal(n_persons) - prob = 1.0 / (1.0 + np.exp(-(theta[:, None] + b))) - responses = (rng.random(prob.shape) < prob).astype(float) - assert np.all(np.bincount(responses.sum(axis=1).astype(int), minlength=n_items + 1) > 0) - params = MLSIRMParams( - theta=theta[:, None], - alpha=np.zeros(n_items), - b=b, - xi=np.zeros((n_persons, 1)), - zeta=np.zeros((n_items, 1)), - tau=0.0, - ) - result = m2( - responses, - np.zeros(n_items, dtype=np.int64), - params, - "MIRT", - estimator="cmle", - ) - assert result.estimator == "cmle" - assert result.inference_valid - assert result.n_parameters == (n_items - 1) + n_items - assert result.df == 21.0 - assert result.p_value > 0.05 - assert result.rmsea2 < 0.02 - - non_rasch = MLSIRMParams( - theta=params.theta, - alpha=np.full(n_items, 0.1), - b=b, - xi=params.xi, - zeta=params.zeta, - tau=0.0, - ) - with pytest.raises(ValueError, match="Rasch"): - m2( - responses, - np.zeros(n_items, dtype=np.int64), - non_rasch, - "MIRT", - estimator="cmle", - ) - - -def test_irt_link_recovers_known_transform(): - # separately-calibrated common-item linking (Kolen & Brennan; Haebara; - # Stocking-Lord): recover a known theta_old = A*theta_new + B. - from fast_mlsirm import irt_link - - a_old = np.array([1.2, 0.8, 1.5, 1.0, 0.9, 1.3, 1.1, 0.7]) - b_old = np.array([-0.5, 0.3, 1.0, -1.2, 0.0, 0.6, -0.8, 0.4]) - A0, B0 = 1.3, 0.4 - a_new = A0 * a_old - b_new = b_old + a_old * B0 - for method in ("mean_mean", "mean_sigma", "haebara", "stocking_lord"): - r = irt_link(a_old, b_old, a_new, b_new, method=method) - assert abs(r.slope - A0) < 1e-3, f"{method}: slope {r.slope}" - assert abs(r.intercept - B0) < 1e-3, f"{method}: intercept {r.intercept}" - assert r.converged - if method in {"haebara", "stocking_lord"}: - assert r.termination_reason == "tolerance_met" - assert r.n_iter < r.max_iter - assert r.final_objective_span <= r.objective_tolerance - assert r.final_parameter_span <= r.parameter_tolerance - else: - assert r.termination_reason == "closed_form" - assert r.n_iter == r.max_iter == 0 - import pytest as _pytest - - with _pytest.raises(Exception): - irt_link(a_old, b_old, a_new, b_new, method="not_a_method") - with _pytest.raises(ValueError, match="non-zero difficulty spread"): - irt_link( - np.ones(3), - np.array([-0.5, 0.0, 0.5]), - np.ones(3), - np.zeros(3), - method="mean_sigma", - ) - with _pytest.raises(ValueError, match="integer quadrature size"): - irt_link(a_old, b_old, a_new, b_new, q_theta=21.5) - - -def test_category_logprobs_binary_parity_and_gpcm_monotone(): - """The unified GPCM/nominal cell nests binary 2PL (bit-parity check) and is - a proper log-softmax; GPCM scores make higher `base` favor higher categories. - Parity reference for the Rust polytomous kernel (design spec).""" - import numpy as np - from fast_mlsirm.estimators.marginal import category_logprobs - - rng = np.random.default_rng(0) - base = rng.normal(size=32) - b = 0.3 - - # binary 2PL: logP_1 == log_sigmoid(base + b), logP_0 == log_sigmoid(-(base+b)) - lp = category_logprobs(base, [0.0, 1.0], [0.0, b]) - assert np.allclose(np.exp(lp).sum(axis=-1), 1.0, atol=1e-12) - eta = base + b - assert np.allclose(lp[:, 1], -np.logaddexp(0.0, -eta), atol=1e-12) - assert np.allclose(lp[:, 0], -np.logaddexp(0.0, eta), atol=1e-12) - - # GPCM (scores 0,1,2): larger base shifts mass to the top category - lp3 = category_logprobs(np.array([-2.0, 2.0]), [0.0, 1.0, 2.0], [0.0, 0.0, 0.0]) - assert np.allclose(np.exp(lp3).sum(axis=-1), 1.0, atol=1e-12) - p_top = np.exp(lp3[:, 2]) - assert p_top[1] > p_top[0] - - # baseline must be pinned - import pytest - with pytest.raises(ValueError): - category_logprobs(base, [0.5, 1.0], [0.0, b]) - - -def test_gpcm_node_gradient_matches_finite_difference(): - """The analytic M-step gradient of the GPCM/nominal cell (category residual, - score-weighted base residual, and nominal-score gradient) matches central - finite differences — de-risks the Rust M-step before it is written.""" - import numpy as np - from fast_mlsirm.estimators.marginal import category_logprobs, gpcm_node_gradient - - scores = np.array([0.0, 1.0, 2.0, 3.0]) - intercepts = np.array([0.0, 0.2, -0.1, 0.3]) - counts = np.array([3.0, 5.0, 2.0, 4.0]) - base = 0.4 - - def q(b, ic, sc): - return float(np.dot(counts, category_logprobs(b, sc, ic))) - - g_ic, g_base, g_sc = gpcm_node_gradient(base, scores, intercepts, counts) - h = 1e-6 - for m in range(1, 4): - ic_p, ic_m = intercepts.copy(), intercepts.copy() - ic_p[m] += h - ic_m[m] -= h - assert abs((q(base, ic_p, scores) - q(base, ic_m, scores)) / (2 * h) - g_ic[m - 1]) < 1e-5 - sc_p, sc_m = scores.copy(), scores.copy() - sc_p[m] += h - sc_m[m] -= h - assert abs((q(base, intercepts, sc_p) - q(base, intercepts, sc_m)) / (2 * h) - g_sc[m - 1]) < 1e-5 - assert abs((q(base + h, intercepts, scores) - q(base - h, intercepts, scores)) / (2 * h) - g_base) < 1e-5 - - # residual closure - p = np.exp(category_logprobs(base, scores, intercepts)) - assert abs((counts - counts.sum() * p).sum()) < 1e-12 - - -def test_fit_gpcm_numpy_recovers_known_parameters(): - """Unidimensional GPCM MMLE-EM (the polytomous parity reference) recovers - known slopes and category intercepts from simulated data.""" - import numpy as np - from fast_mlsirm.estimators.marginal import category_logprobs, fit_gpcm_numpy - - rng = np.random.default_rng(7) - n_persons, n_items, k_cat = 4000, 6, 3 - a_true = rng.uniform(0.8, 1.8, n_items) - c_true = np.zeros((n_items, k_cat)) - c_true[:, 1:] = rng.normal(0.0, 1.0, (n_items, k_cat - 1)) - theta = rng.normal(0.0, 1.0, n_persons) - scores = np.arange(k_cat, dtype=float) - y = np.zeros((n_persons, n_items), dtype=int) - for i in range(n_items): - p = np.exp(category_logprobs(a_true[i] * theta, scores, c_true[i])) - for pp in range(n_persons): - y[pp, i] = rng.choice(k_cat, p=p[pp]) - - res = fit_gpcm_numpy(y, k_cat, max_iter=80) - assert np.isfinite(res["loglik"]) - assert res["converged"] - assert res["termination_reason"] == "tolerance" - assert res["n_iter"] < 80 - assert res["loglik_trace"].shape == (res["n_iter"] + 1,) - assert np.all(np.isfinite(res["loglik_trace"])) - assert np.all(np.diff(res["loglik_trace"]) >= -1e-10) - assert res["final_delta"] <= res["stopping_tolerance"] - assert res["loglik"] == res["loglik_trace"][-1] - assert np.corrcoef(a_true, res["a"])[0, 1] > 0.9 - assert np.max(np.abs(a_true - res["a"])) < 0.35 - assert np.mean(np.abs(c_true[:, 1:] - res["intercepts"][:, 1:])) < 0.2 - - -def test_fit_gpcm_numpy_reports_likelihood_at_returned_parameters(): - """The reference EM result and trace end at the returned parameter state.""" - import numpy as np - - from fast_mlsirm.estimators.marginal import _gh, category_logprobs, fit_gpcm_numpy - - y = np.array( - [ - [0, 0, 0], - [0, 1, 0], - [1, 1, 1], - [1, 2, 1], - [2, 2, 2], - [2, 1, 2], - [1, 0, 1], - [2, 2, 1], - ], - dtype=np.int64, - ) - res = fit_gpcm_numpy(y, 3, q_theta=7, max_iter=1, tol=1e-6) - - nodes, weights = _gh(7) - scores = np.arange(3, dtype=np.float64) - log_node = np.zeros((y.shape[0], nodes.size), dtype=np.float64) - for item in range(y.shape[1]): - item_lp = category_logprobs( - res["a"][item] * nodes, scores, res["intercepts"][item] - ) - log_node += item_lp[:, y[:, item]].T - log_node += np.log(weights)[None, :] - maximum = log_node.max(axis=1, keepdims=True) - reevaluated = float( - np.sum(maximum[:, 0] + np.log(np.exp(log_node - maximum).sum(axis=1))) - ) - - assert res["n_iter"] == 1 - assert not res["converged"] - assert res["termination_reason"] == "max_iter_reached" - assert res["loglik_trace"].shape == (2,) - assert np.all(np.isfinite(res["loglik_trace"])) - assert res["final_delta"] > res["stopping_tolerance"] - assert np.allclose(res["loglik"], res["loglik_trace"][-1], atol=1e-12) - assert np.allclose(res["loglik"], reevaluated, atol=1e-12) - - -def test_fit_gpcm_numpy_rejects_malformed_controls_and_responses(): - import numpy as np - import pytest - - from fast_mlsirm.estimators.marginal import fit_gpcm_numpy - - valid = np.array([[0, 1], [1, 2]], dtype=np.int64) - for bad in (np.array([0, 1, 2]), np.empty((0, 2)), np.array([[0.5, 1.0]])): - with pytest.raises(ValueError): - fit_gpcm_numpy(bad, 3, q_theta=7, max_iter=1) - for kwargs in ( - {"n_cat": 3.5}, - {"n_cat": 3, "max_iter": 0}, - {"n_cat": 3, "tol": 0.0}, - ): - with pytest.raises(ValueError): - fit_gpcm_numpy(valid, **kwargs) - - -def test_poly_cell_and_fitter_rust_numpy_parity(): - """The Rust polytomous cell matches the NumPy reference bit-for-bit, and the - Rust unidimensional GPCM fitter agrees with the NumPy mirror on recovery.""" - import numpy as np - import pytest - try: - from fast_mlsirm import _core - except Exception: # pragma: no cover - pytest.skip("compiled core not available") - if not hasattr(_core, "fit_poly_unidim"): # pragma: no cover - pytest.skip("core built without polytomous functions") - from fast_mlsirm.estimators.marginal import category_logprobs, fit_gpcm_numpy - - # cell parity: same softmax formula in both languages - scores = np.array([0.0, 1.0, 2.0]) - intercepts = np.array([0.0, 0.3, -0.2]) - for base in (-1.3, 0.0, 0.75): - rust = np.array(_core.gpcm_cell_logprobs(float(base), scores, intercepts)) - npy = category_logprobs(np.array([base]), scores, intercepts)[0] - assert np.allclose(rust, npy, atol=1e-12), f"cell parity at base={base}" - - # fitter agreement: same EM/Newton, same GH grid -> same MLE - rng = np.random.default_rng(11) - n_persons, n_items, k = 2500, 5, 3 - a_true = rng.uniform(0.8, 1.6, n_items) - c_true = np.zeros((n_items, k)) - c_true[:, 1:] = rng.normal(0.0, 0.8, (n_items, k - 1)) - theta = rng.normal(0.0, 1.0, n_persons) - y = np.zeros((n_persons, n_items), dtype=np.int64) - for i in range(n_items): - p = np.exp(category_logprobs(a_true[i] * theta, scores, c_true[i])) - for pp in range(n_persons): - y[pp, i] = rng.choice(k, p=p[pp]) - - rust_fit = _core.fit_poly_unidim(y.ravel(), n_persons, n_items, k, None, "gpcm", 21, 80, 1e-6) - npy_fit = fit_gpcm_numpy(y, k) - assert np.allclose(np.array(rust_fit["slope"]), npy_fit["a"], atol=0.05) - assert np.isfinite(rust_fit["loglik"]) - - -def test_fit_polytomous_api_recovers_and_validates(): - """The public fit_polytomous wrapper (Rust compute) recovers GRM parameters - and rejects malformed input.""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous - from fast_mlsirm.estimators.marginal import _gh - - try: - from fast_mlsirm import _core # noqa: F401 - if not hasattr(__import__("fast_mlsirm")._core, "fit_poly_unidim"): - pytest.skip("core built without polytomous functions") - except Exception: # pragma: no cover - pytest.skip("compiled core not available") - - from fast_mlsirm.polytomous import _core_module - if _core_module() is None: # pragma: no cover - pytest.skip("compiled core not available") - - # GRM recovery via the public API - rng = np.random.default_rng(3) - n_persons, n_items, k = 3000, 5, 4 - a_true = rng.uniform(0.9, 1.6, n_items) - thr_true = np.array([1.3, 0.0, -1.3]) - nodes, _ = _gh(21) - theta = rng.normal(0.0, 1.0, n_persons) - y = np.zeros((n_persons, n_items), dtype=int) - for i in range(n_items): - for pp in range(n_persons): - eta = a_true[i] * theta[pp] + thr_true # cumulative logits P(Y>=k) - cum = 1.0 / (1.0 + np.exp(-eta)) - p = np.empty(k) - p[0] = 1 - cum[0] - p[1:k - 1] = cum[:k - 2] - cum[1:k - 1] - p[k - 1] = cum[k - 2] - y[pp, i] = rng.choice(k, p=p / p.sum()) - fit = fit_polytomous(y, k, model="grm") - assert fit.model == "grm" and np.isfinite(fit.loglik) - assert fit.converged - assert fit.termination_reason == "tolerance" - assert fit.n_iter < 80 - assert fit.loglik_trace.shape == (fit.n_iter + 1,) - assert fit.loglik == fit.loglik_trace[-1] - assert fit.final_delta >= -32 * np.finfo(float).eps * ( - 1 + abs(fit.loglik_trace[-2]) - ) - assert fit.final_delta <= fit.stopping_tolerance - assert np.corrcoef(a_true, fit.slope)[0, 1] > 0.9 - - # validation - with pytest.raises(ValueError): - fit_polytomous(y, k, model="nominal") # unsupported model - with pytest.raises(ValueError): - fit_polytomous(y.astype(float) + 0.5, k) # non-integer categories - with pytest.raises(ValueError): - fit_polytomous(y, 2) # category out of range - with pytest.raises(ValueError): - fit_polytomous(y, k, max_iter=0) - with pytest.raises(ValueError): - fit_polytomous(y, k, tol=np.nan) - - -def test_score_polytomous_recovers_theta(): - """fit_polytomous -> score_polytomous round-trip: EAP trait scores correlate - with true theta (Rust compute end to end).""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, score_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "score_poly_eap"): - pytest.skip("compiled core without polytomous scoring") - - rng = np.random.default_rng(5) - n_persons, n_items, k = 3000, 8, 3 - a_true = rng.uniform(0.9, 1.6, n_items) - c_true = np.zeros((n_items, k)) - c_true[:, 1:] = rng.normal(0.0, 0.6, (n_items, k - 1)) - theta_true = rng.normal(0.0, 1.0, n_persons) - scores = np.arange(k, dtype=float) - y = np.zeros((n_persons, n_items), dtype=int) - for i in range(n_items): - p = np.exp(category_logprobs(a_true[i] * theta_true, scores, c_true[i])) - for pp in range(n_persons): - y[pp, i] = rng.choice(k, p=p[pp]) - - fit = fit_polytomous(y, k, model="gpcm") - sc = score_polytomous(y, fit) - assert sc["theta_eap"].shape == (n_persons,) - assert np.all(sc["theta_sd"] > 0) - assert np.corrcoef(theta_true, sc["theta_eap"])[0, 1] > 0.8 - - -def test_score_polytomous_rejects_malformed_scoring_contract(): - """Scoring must not truncate quadrature controls or emit non-finite scores.""" - import numpy as np - import pytest - - from fast_mlsirm import score_polytomous - from fast_mlsirm.polytomous import PolytomousFit, _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "score_poly_eap"): - pytest.skip("compiled core without polytomous scoring") - - fit = PolytomousFit( - model="gpcm", - slope=np.array([1.0]), - cat_params=np.array([[0.2, -0.3]]), - loglik=0.0, - n_iter=0, - ) - with pytest.raises(ValueError, match="q_theta must be one of"): - score_polytomous(np.array([[0.0]]), fit, q_theta=21.5) - - fit.slope[0] = np.nan - with pytest.raises(ValueError, match="finite"): - score_polytomous(np.array([[1.0]]), fit) - - -def test_grm_cell_rust_numpy_parity(): - """The Rust GRM cumulative-logit cell matches the NumPy reference to 1e-12, - and the NumPy GRM cell is a proper (normalized) log-distribution.""" - import numpy as np - import pytest - from fast_mlsirm.estimators.marginal import grm_category_logprobs - - # NumPy self-consistency: normalization + binary reduction - for base in (-1.0, 0.3, 1.7): - lp = grm_category_logprobs(np.array([base]), np.array([1.0, -1.0]))[0] - assert abs(np.log(np.exp(lp).sum())) < 1e-12 - # binary GRM (K=2): P(Y=1) = sigmoid(base + beta) - lp2 = grm_category_logprobs(np.array([0.4]), np.array([0.2]))[0] - assert abs(lp2[1] - (-np.logaddexp(0.0, -(0.4 + 0.2)))) < 1e-12 - - try: - from fast_mlsirm import _core - except Exception: # pragma: no cover - pytest.skip("compiled core not available") - if not hasattr(_core, "grm_cell_logprobs"): # pragma: no cover - pytest.skip("core built without grm cell") - thr = np.array([1.3, 0.1, -1.2]) - for base in (-1.4, 0.0, 0.9): - rust = np.array(_core.grm_cell_logprobs(float(base), thr)) - npy = grm_category_logprobs(np.array([base]), thr)[0] - assert np.allclose(rust, npy, atol=1e-12), f"grm parity at base={base}" - - -def test_grm_cell_extreme_predictor_stays_finite(): - from fast_mlsirm.estimators.marginal import grm_category_logprobs - - thresholds = np.array([1.0, 0.0]) - expected_middle = -1000.0 + np.log1p(-np.exp(-1.0)) - npy = grm_category_logprobs(np.array([1000.0]), thresholds)[0] - assert np.all(np.isfinite(npy)), npy - np.testing.assert_allclose(npy[1], expected_middle, atol=1e-12) - - try: - from fast_mlsirm import _core - except Exception: # pragma: no cover - pytest.skip("compiled core not available") - rust = np.asarray(_core.grm_cell_logprobs(1000.0, thresholds)) - assert np.all(np.isfinite(rust)), rust - np.testing.assert_allclose(rust, npy, atol=1e-12) - - -def test_information_polytomous_api(): - """information_polytomous returns positive item/test information curves whose - test info equals the item-info row sum (Rust compute).""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, information_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "poly_information_curves"): - pytest.skip("compiled core without polytomous information") - - rng = np.random.default_rng(8) - n_persons, n_items, k = 1500, 5, 3 - a_true = rng.uniform(1.0, 1.5, n_items) - c_true = np.zeros((n_items, k)) - c_true[:, 1:] = rng.normal(0.0, 0.4, (n_items, k - 1)) - theta_p = rng.normal(0.0, 1.0, n_persons) - scores = np.arange(k, dtype=float) - y = np.zeros((n_persons, n_items), dtype=int) - for i in range(n_items): - p = np.exp(category_logprobs(a_true[i] * theta_p, scores, c_true[i])) - for pp in range(n_persons): - y[pp, i] = rng.choice(k, p=p[pp]) - - fit = fit_polytomous(y, k, model="gpcm") - grid = np.linspace(-3, 3, 25) - info = information_polytomous(fit, grid) - assert info["item_info"].shape == (25, n_items) - assert np.all(info["item_info"] >= 0) and np.all(info["test_info"] > 0) - assert np.allclose(info["test_info"], info["item_info"].sum(axis=1)) - # information is highest in the interior for well-centered items - assert info["test_info"].argmax() not in (0, 24) - - -def test_fit_polytomous_handles_missing_data(): - """fit_polytomous marginalizes NaN (missing) responses and still recovers - slopes; score_polytomous accepts partially-missing rows.""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, score_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "fit_poly_unidim"): - pytest.skip("compiled core not available") - - rng = np.random.default_rng(21) - n_persons, n_items, k = 5000, 6, 3 - a_true = rng.uniform(0.9, 1.6, n_items) - c_true = np.zeros((n_items, k)) - c_true[:, 1:] = rng.normal(0.0, 0.6, (n_items, k - 1)) - theta = rng.normal(0.0, 1.0, n_persons) - scores = np.arange(k, dtype=float) - y = np.full((n_persons, n_items), np.nan) - for i in range(n_items): - p = np.exp(category_logprobs(a_true[i] * theta, scores, c_true[i])) - for pp in range(n_persons): - if rng.random() < 0.25: # ~25% MCAR missing -> stays NaN - continue - y[pp, i] = rng.choice(k, p=p[pp]) - - fit = fit_polytomous(y, k, model="gpcm") - assert np.isfinite(fit.loglik) - assert np.corrcoef(a_true, fit.slope)[0, 1] > 0.9 - sc = score_polytomous(y, fit) - assert sc["theta_eap"].shape == (n_persons,) and np.all(np.isfinite(sc["theta_eap"])) - - -def test_fit_lsirm_polytomous_recovers_positions(): - """The latent-space polytomous LSIRM (Rust compute) recovers item positions - (distance-matrix RMSE) and slopes (RMSE), and returns person scores.""" - import numpy as np - import pytest - from fast_mlsirm import fit_lsirm_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "fit_poly_lsirm"): - pytest.skip("compiled core without polytomous LSIRM") - - rng = np.random.default_rng(4) - n_persons, n_items, k, ld = 1000, 6, 3, 2 - # two separated item clusters - zeta_true = np.zeros((n_items, ld)) - for i in range(n_items): - zeta_true[i, 0] = (-1.2 if i < n_items // 2 else 1.2) + 0.3 * rng.standard_normal() - zeta_true[i, 1] = 0.3 * rng.standard_normal() - a_true = 1.0 + 0.1 * np.arange(n_items) - c_true = np.zeros((n_items, k)) - c_true[:, 1:] = np.array([0.2, -0.2]) - scores = np.arange(k, dtype=float) - y = np.zeros((n_persons, n_items), dtype=int) - for p in range(n_persons): - theta = rng.standard_normal() - xi = rng.standard_normal(ld) - for i in range(n_items): - base = a_true[i] * theta - np.sqrt(1e-8 + np.sum((xi - zeta_true[i]) ** 2)) - pr = np.exp(category_logprobs(np.array([base]), scores, c_true[i])[0]) - y[p, i] = rng.choice(k, p=pr / pr.sum()) - - fit = fit_lsirm_polytomous(y, k, latent_dim=ld, model="gpcm", q_theta=7, q_xi=7, max_iter=30) - assert fit.zeta.shape == (n_items, ld) - assert fit.theta_eap.shape == (n_persons,) and fit.xi_eap.shape == (n_persons, ld) - assert np.all(fit.theta_sd > 0) and np.isfinite(fit.loglik) - - def dmat(z): - return np.array([np.linalg.norm(z[i] - z[j]) for i in range(n_items) for j in range(i + 1, n_items)]) - - def rmse(u, v): - return float(np.sqrt(np.mean((u - v) ** 2))) - - assert rmse(a_true, fit.slope) < 0.3, "slope RMSE" - assert rmse(dmat(zeta_true), dmat(fit.zeta)) < 0.7, "position distance-matrix RMSE" - - -def test_polytomous_information_criteria(): - """Kang-Cohen-Sung (2009) model-selection indices for a polytomous fit: - correct free-parameter count and finite, ordered indices.""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, polytomous_information_criteria - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "fit_poly_unidim"): - pytest.skip("compiled core not available") - - rng = np.random.default_rng(6) - n_persons, n_items, k = 1500, 5, 3 - a = rng.uniform(0.9, 1.5, n_items) - c = np.zeros((n_items, k)) - c[:, 1:] = rng.normal(0.0, 0.5, (n_items, k - 1)) - theta = rng.standard_normal(n_persons) - scores = np.arange(k, dtype=float) - y = np.zeros((n_persons, n_items), dtype=int) - for i in range(n_items): - p = np.exp(category_logprobs(a[i] * theta, scores, c[i])) - for pp in range(n_persons): - y[pp, i] = rng.choice(k, p=p[pp]) - - fit = fit_polytomous(y, k, model="gpcm") - ic = polytomous_information_criteria(fit, n_persons) - # slope (n_items) + intercepts (n_items*(K-1)) = n_items*K - assert ic["n_parameters"] == n_items * k - for key in ("aic", "bic", "caic", "aicc", "sabic"): - assert np.isfinite(ic[key]) - # BIC/CAIC penalize free parameters more heavily than AIC at N=1500 - assert ic["aic"] < ic["bic"] < ic["caic"] - with pytest.raises(ValueError): - polytomous_information_criteria(fit, 1) - - -def test_polytomous_information_criteria_marks_undefined_aicc(): - """AICc must not report a finite correction when N - K - 1 <= 0.""" - from types import SimpleNamespace - - import numpy as np - - from fast_mlsirm import polytomous_information_criteria - - fit = SimpleNamespace( - slope=np.ones(2), - cat_params=np.zeros((2, 2)), - loglik=-10.0, - ) - - undefined = polytomous_information_criteria(fit, n_persons=5) - assert undefined["n_parameters"] == 6 - assert np.isnan(undefined["aicc"]) - for key in ("aic", "bic", "caic", "sabic"): - assert np.isfinite(undefined[key]) - - defined = polytomous_information_criteria(fit, n_persons=10) - expected = defined["aic"] + 2.0 * 6 * 7 / (10 - 6 - 1) - assert defined["aicc"] == expected - - -def test_item_fit_polytomous_sx2(): - """Generalized S-X² polytomous item fit (Kang & Chen, 2008, 2011) through the - public API: well-formed per-item output, calibration at the fitted model - (statistic tracks df, few false flags), and input validation.""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, item_fit_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr( - __import__("fast_mlsirm")._core, "poly_item_fit_sx2" - ): - pytest.skip("compiled core built without poly_item_fit_sx2") - - rng = np.random.default_rng(11) - n_persons, n_items, k = 1500, 8, 4 - a = rng.uniform(0.9, 1.5, n_items) - c = np.zeros((n_items, k)) - c[:, 1:] = rng.normal(0.0, 0.6, (n_items, k - 1)) - theta = rng.standard_normal(n_persons) - scores = np.arange(k, dtype=float) - y = np.zeros((n_persons, n_items), dtype=int) - for i in range(n_items): - p = np.exp(category_logprobs(a[i] * theta, scores, c[i])) - for pp in range(n_persons): - y[pp, i] = rng.choice(k, p=p[pp]) - - fit = fit_polytomous(y, k, model="gpcm") - res = item_fit_polytomous(y, fit, q_theta=21) - for key in ("statistic", "df", "p_value", "n_cells"): - assert res[key].shape == (n_items,) - assert np.all(np.isfinite(res["statistic"])) - # df is the retained cell count minus m = n_cat item parameters - assert np.array_equal(res["df"].astype(int), res["n_cells"] - k) - finite_p = res["p_value"][np.isfinite(res["p_value"])] - assert np.all((finite_p >= 0.0) & (finite_p <= 1.0)) - # a correctly fitted model is rarely flagged (contrast: G2 flags >30%) - assert np.mean(finite_p < 0.05) < 0.30 - # statistic ~ df at the fitted parameters - ratio = res["statistic"].sum() / res["df"].sum() - assert 0.6 < ratio < 1.6, f"S-X2/df ratio off: {ratio}" - - # missing data (NaN) is marginalized: complete-case summed-score table - y_miss = y.astype(float) - y_miss[rng.random(y_miss.shape) < 0.05] = np.nan - res_miss = item_fit_polytomous(y_miss, fit) - assert np.all(np.isfinite(res_miss["statistic"])) - - # validation - with pytest.raises(ValueError): - item_fit_polytomous(y[:, :-1], fit) # wrong item count - with pytest.raises(ValueError): - item_fit_polytomous(y, fit, min_expected=0.0) # non-positive floor - - -def test_m2_polytomous(): - """Polytomous M2 (Maydeu-Olivares & Joe, 2014) through the public API: - correct moment/df bookkeeping, a good fit for correctly-specified data, and - detection of a strongly misspecified (skewed-population) fit.""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, m2_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "poly_m2"): - pytest.skip("compiled core built without poly_m2") - - def sim(theta, a, c, k): - n, j = theta.size, a.size - y = np.zeros((n, j), dtype=int) - scores = np.arange(k, dtype=float) - for i in range(j): - p = np.exp(category_logprobs(a[i] * theta, scores, c[i])) - for pp in range(n): - y[pp, i] = np.random.default_rng(1000 + i * n + pp).choice(k, p=p[pp]) - return y - - rng = np.random.default_rng(7) - n, j, k = 2000, 6, 3 - a = rng.uniform(0.9, 1.5, j) - c = np.zeros((j, k)) - c[:, 1:] = rng.normal(0.0, 0.5, (j, k - 1)) - - # correctly specified (normal ability) -> good fit - theta = rng.standard_normal(n) - y = sim(theta, a, c, k) - fit = fit_polytomous(y, k, model="gpcm") - res = m2_polytomous(y, fit) - # Q = j*(k-1) + C(j,2)*(k-1)^2 ; P = j*k ; df = Q - P - q = j * (k - 1) + (j * (j - 1) // 2) * (k - 1) ** 2 - assert res["n_moments"] == q - assert res["n_parameters"] == j * k - assert res["df"] == q - j * k - assert res["null_df"] == q - j * (k - 1) - assert np.isfinite(res["m2"]) and res["m2"] >= 0.0 - assert 0.0 <= res["p_value"] <= 1.0 - assert res["rmsea2_ci_lower"] <= res["rmsea2_ci_upper"] + 1e-9 - assert res["rmsea2"] < 0.05 # well-fitting - assert res["null_m2"] > res["m2"] - expected_cfi = np.clip( - 1.0 - (res["m2"] - res["df"]) / (res["null_m2"] - res["null_df"]), - 0.0, - 1.0, - ) - expected_tli = ( - res["null_m2"] / res["null_df"] - res["m2"] / res["df"] - ) / (res["null_m2"] / res["null_df"] - 1.0) - np.testing.assert_allclose(res["cfi"], expected_cfi, atol=1e-12) - np.testing.assert_allclose(res["tli"], expected_tli, atol=1e-12) - - # strongly misspecified: fit the wrong item parameters -> M2 must reject - bad = fit - bad.slope = a * 3.0 # inflate discriminations far from the truth - res_bad = m2_polytomous(y, bad) - assert res_bad["m2"] > res["m2"] - assert res_bad["p_value"] < 0.05 - assert res_bad["cfi"] < res["cfi"] - assert res_bad["tli"] < res["tli"] - - # validation: fewer than 3 items has non-positive df - with pytest.raises((ValueError, RuntimeError)): - fit2 = fit_polytomous(y[:, :2], k, model="gpcm") - m2_polytomous(y[:, :2], fit2) - - -def test_m2_polytomous_rejects_nonconverged_calibration(): - """Finite parameters are not sufficient evidence for M2 inference.""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, m2_polytomous - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr( - __import__("fast_mlsirm")._core, "poly_m2" - ): - pytest.skip("compiled core built without poly_m2") - - rng = np.random.default_rng(20260716) - y = rng.integers(0, 3, size=(240, 6)) - fit = fit_polytomous( - y, - 3, - model="gpcm", - q_theta=11, - max_iter=1, - tol=1e-12, - ) - - assert fit.converged is False - assert fit.termination_reason == "max_iter" - assert fit.n_iter == 1 - assert fit.final_delta > fit.stopping_tolerance - assert np.all(np.isfinite(fit.slope)) - assert np.all(np.isfinite(fit.cat_params)) - with pytest.raises(RuntimeError, match="requires a converged fit"): - m2_polytomous(y, fit, q_theta=11) - - -@pytest.mark.parametrize( - ("population", "message"), - [ - ({"kind": "single", "pi_zero": 0.2}, "zero inflation"), - ({"kind": "single", "delta": 0.4}, "item covariates"), - ( - {"kind": "single", "pi_zero": 0.2, "delta": 0.4}, - "zero inflation and item covariates", - ), - ], -) -def test_m2_rejects_unsupported_calibration_terms(population, message): - """M2 must not silently use base-model moments for structured fits.""" - from fast_mlsirm import fit_diagnostics - from fast_mlsirm.types import MLSIRMParams - - n_persons, n_items = 40, 8 - responses = np.tile([0.0, 1.0], (n_persons, n_items // 2)) - params = MLSIRMParams( - theta=np.zeros((n_persons, 1)), - alpha=np.zeros(n_items), - b=np.zeros(n_items), - xi=np.zeros((n_persons, 1)), - zeta=np.zeros((n_items, 1)), - tau=0.0, - ) - - with pytest.raises(ValueError, match=message): - fit_diagnostics( - responses, - params, - np.zeros(n_items, dtype=np.int64), - model="MIRT", - include_m2=True, - estimator="mmle", - convergence_status="converged", - population=population, - ) - - -def test_local_dependence_polytomous(): - """Item-pair local dependence (Chen & Thissen, 1997) through the public API: - correct per-pair bookkeeping, calibrated (few flags) for a locally - independent fit, and detection of an injected testlet pair.""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, local_dependence_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr( - __import__("fast_mlsirm")._core, "poly_local_dependence" - ): - pytest.skip("compiled core built without poly_local_dependence") - - def sim(theta, a, c, k, testlet=None): - n, j = theta.size, a.size - y = np.zeros((n, j), dtype=int) - scores = np.arange(k, dtype=float) - for i in range(j): - base = a[i] * theta + (testlet if testlet is not None and i in (0, 1) else 0.0) - p = np.exp(category_logprobs(base, scores, c[i])) - for pp in range(n): - y[pp, i] = np.random.default_rng(300 + i * n + pp).choice(k, p=p[pp]) - return y - - rng = np.random.default_rng(4) - n, j, k = 1500, 5, 3 - a = rng.uniform(0.9, 1.4, j) - c = np.zeros((j, k)) - c[:, 1:] = rng.normal(0.0, 0.5, (j, k - 1)) - - # locally independent -> calibrated (the reference is conservative) - theta = rng.standard_normal(n) - y = sim(theta, a, c, k) - fit = fit_polytomous(y, k, model="gpcm") - ld = local_dependence_polytomous(y, fit) - n_pairs = j * (j - 1) // 2 - for key in ("item_i", "item_j", "x2", "g2", "p_value", "cramers_v", "max_abs_std_resid"): - assert ld[key].shape == (n_pairs,) - assert ld["df"] == (k - 1) ** 2 - assert np.all(ld["item_i"] < ld["item_j"]) - assert np.all(np.isfinite(ld["x2"])) and np.all(ld["x2"] >= 0.0) - finite_p = ld["p_value"][np.isfinite(ld["p_value"])] - assert np.all((finite_p >= 0.0) & (finite_p <= 1.0)) - assert np.mean(finite_p < 0.05) < 0.35 # few flags under local independence - - # a strong shared testlet on items 0,1 -> that pair is strongly dependent - dep = sim(theta, a, c, k, testlet=1.5 * rng.standard_normal(n)) - fit_d = fit_polytomous(dep, k, model="gpcm") - ld_d = local_dependence_polytomous(dep, fit_d) - pair01 = next(idx for idx in range(n_pairs) - if (ld_d["item_i"][idx], ld_d["item_j"][idx]) == (0, 1)) - assert ld_d["x2"][pair01] > np.median(ld_d["x2"]) - assert ld_d["p_value"][pair01] < 0.05 - - with pytest.raises(ValueError): - local_dependence_polytomous(y[:, :-1], fit) - - -def test_fit_nominal_polytomous(): - """Nominal categories model (Thissen, Cai & Bock, 2010) through the public - API: correct shapes, GPCM nesting (loglik >= GPCM, linear recovered scores), - and input validation.""" - import numpy as np - import pytest - from fast_mlsirm import fit_nominal_polytomous, fit_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "fit_nominal"): - pytest.skip("compiled core built without fit_nominal") - - rng = np.random.default_rng(9) - n, j, k = 2500, 5, 3 - a = rng.uniform(0.9, 1.5, j) - c = np.zeros((j, k)) - c[:, 1:] = rng.normal(0.0, 0.4, (j, k - 1)) - theta = rng.standard_normal(n) - scores = np.arange(k, dtype=float) - y = np.zeros((n, j), dtype=int) - for i in range(j): - p = np.exp(category_logprobs(a[i] * theta, scores, c[i])) - for pp in range(n): - y[pp, i] = rng.choice(k, p=p[pp]) - - nom = fit_nominal_polytomous(y, k) - assert nom.scores.shape == (j, k - 1) - assert nom.intercepts.shape == (j, k - 1) - assert np.isfinite(nom.loglik) - assert nom.converged - assert nom.termination_reason == "tolerance" - assert nom.loglik_trace.shape == (nom.n_iter + 1,) - assert nom.loglik == nom.loglik_trace[-1] - assert nom.final_delta <= nom.stopping_tolerance - assert np.all(np.diff(nom.loglik_trace) >= -1e-10) - - # nests the GPCM: at least as high a loglik, and linear recovered scores - gp = fit_polytomous(y, k, model="gpcm") - assert nom.loglik >= gp.loglik - 0.5 - ratio = nom.scores[:, 1] / nom.scores[:, 0] - assert np.all(np.abs(ratio - 2.0) < 0.4) # a_k ~ a*k - - with pytest.raises(ValueError): - fit_nominal_polytomous(y, 1) - with pytest.raises(ValueError): - fit_nominal_polytomous(y.astype(float) + 0.5, k) # non-integer categories - - unfinished = fit_nominal_polytomous(y[:50], k, max_iter=1, tol=1e-12) - assert not unfinished.converged - assert unfinished.termination_reason == "max_iter" - assert unfinished.n_iter == 1 - assert unfinished.loglik_trace.shape == (2,) - assert unfinished.loglik == unfinished.loglik_trace[-1] - assert np.isfinite(unfinished.final_delta) - assert unfinished.final_delta > unfinished.stopping_tolerance - - malformed = ( - np.empty((0, j)), - np.empty((n, 0)), - np.column_stack((y[:, 0], np.full(n, np.nan))), - np.where(np.arange(y.size).reshape(y.shape) == 0, np.inf, y), - ) - for bad in malformed: - with pytest.raises(ValueError): - fit_nominal_polytomous(bad, k) - for kwargs in ({"max_iter": 0}, {"tol": np.inf}, {"tol": -1.0}): - with pytest.raises(ValueError): - fit_nominal_polytomous(y, k, **kwargs) - - -def test_person_fit_polytomous(): - """Polytomous person fit l_z / l_z* (Drasgow-Levine-Williams, 1985; Snijders, - 2001) through the public API: calibrated on a clean fit and flagging - inconsistent responders evaluated against that fit.""" - import numpy as np - import pytest - from fast_mlsirm import fit_polytomous, person_fit_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "poly_person_fit"): - pytest.skip("compiled core built without poly_person_fit") - - rng = np.random.default_rng(5) - n, j, k = 1500, 20, 3 - a = 1.0 + 0.03 * np.arange(j) - c = np.zeros((j, k)) - c[:, 1] = 0.6 - c[:, 2] = -0.6 - scores = np.arange(k, dtype=float) - - def sim_person(th): - return [ - rng.choice(k, p=np.exp(category_logprobs(np.array([a[i] * th]), scores, c[i])[0])) - for i in range(j) - ] - - # clean sample -> fit -> calibrated person fit - theta = rng.standard_normal(n) - y = np.array([sim_person(theta[p]) for p in range(n)]) - fit = fit_polytomous(y, k, model="gpcm") - pf = person_fit_polytomous(y, fit) - for key in ("lz", "lz_star", "theta_eap", "flagged"): - assert len(pf[key]) == n - finite = np.isfinite(pf["lz_star"]) - assert np.mean(pf["flagged"]) < 0.15 # Type I near nominal - assert abs(np.mean(pf["lz_star"][finite])) < 0.4 - assert 0.8 < np.std(pf["lz_star"][finite]) < 1.25 - - # inconsistent responders (implied trait alternates +-1.6 across items) — - # evaluated at the SAME clean fit — are flagged - m = 40 - ab = y.copy() - for p in range(m): - for i in range(j): - ti = 1.6 if i % 2 == 0 else -1.6 - pr = np.exp(category_logprobs(np.array([a[i] * ti]), scores, c[i])[0]) - ab[p, i] = rng.choice(k, p=pr) - pf_ab = person_fit_polytomous(ab, fit) - assert np.mean(pf_ab["flagged"][:m]) > 0.6 - - with pytest.raises(ValueError): - person_fit_polytomous(y[:, :-1], fit) - - -def test_cat_simulate_polytomous(): - """Polytomous CAT (Dodd, De Ayala & Koch, 1995) through the public API: - recovers the trait efficiently and max-information beats random selection.""" - import numpy as np - import pytest - from fast_mlsirm import cat_simulate_polytomous, PolytomousFit - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "poly_cat_simulate"): - pytest.skip("compiled core built without poly_cat_simulate") - - j, k, z = 40, 4, 3 - slope = np.array([1.0 + 0.25 * (i % 3) for i in range(j)]) - cat = np.zeros((j, z)) - for i in range(j): - b = -2.2 + 4.4 * i / (j - 1) - cum = 0.0 - for m in range(z): - cum += b + (m - (z - 1) / 2) * 0.9 - cat[i, m] = -slope[i] * cum - fit = PolytomousFit(model="gpcm", slope=slope, cat_params=cat, loglik=0.0, n_iter=0) - - tt = np.random.default_rng(0).standard_normal(400) - var = cat_simulate_polytomous(tt, fit, se_threshold=0.30, min_items=5, max_items=30, seed=1) - for key in ("theta_eap", "theta_sd", "n_used"): - assert var[key].shape == (tt.size,) - rmse = np.sqrt(np.mean((var["theta_eap"] - tt) ** 2)) - assert rmse < 0.40 - assert var["n_used"].mean() < 0.75 * j # far fewer than the bank - assert np.all(var["n_used"] <= 30) - - adap = cat_simulate_polytomous(tt, fit, se_threshold=0.0, min_items=12, max_items=12, adaptive=True, seed=2) - rand = cat_simulate_polytomous(tt, fit, se_threshold=0.0, min_items=12, max_items=12, adaptive=False, seed=3) - r_a = np.sqrt(np.mean((adap["theta_eap"] - tt) ** 2)) - r_r = np.sqrt(np.mean((rand["theta_eap"] - tt) ** 2)) - assert r_a < r_r # max-information more efficient than random - - with pytest.raises(ValueError): - cat_simulate_polytomous(tt, fit, min_items=10, max_items=5) - - -def test_dif_polytomous(): - """Two-group IRT-LR DIF for polytomous items (Thissen, Steinberg & Wainer, - 1993) via the public API: correct bookkeeping, impact does not trigger DIF, - and an injected uniform difficulty shift on one item is flagged while the - anchor items stay clean.""" - import numpy as np - import pytest - from fast_mlsirm import dif_polytomous - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr( - __import__("fast_mlsirm")._core, "poly_dif" - ): - pytest.skip("compiled core built without poly_dif") - - k, j = 3, 8 - n_per = 900 - rng = np.random.default_rng(11) - a = rng.uniform(0.9, 1.4, j) - c = np.zeros((j, k)) - c[:, 1:] = rng.normal(0.0, 0.5, (j, k - 1)) - scores = np.arange(k, dtype=float) - - def gen(dif_on_item0): - # group 0: theta ~ N(0,1); group 1 (focal): theta ~ N(0.5, 1.2^2) (impact) - th0 = rng.standard_normal(n_per) - th1 = 0.5 + 1.2 * rng.standard_normal(n_per) - theta = np.concatenate([th0, th1]) - gid = np.concatenate([np.zeros(n_per, int), np.ones(n_per, int)]) - y = np.zeros((2 * n_per, j), dtype=float) - for p in range(2 * n_per): - focal = gid[p] == 1 - for i in range(j): - ci = c[i].copy() - if i == 0 and focal and dif_on_item0: - # uniform DIF: shift difficulty => intercept_m += m * a * delta - d = 0.7 - ci = ci + a[i] * d * scores - base = a[i] * theta[p] - pr = np.exp(category_logprobs(base, scores, ci)) - y[p, i] = rng.choice(k, p=pr) - return y, gid - - # impact but NO DIF: anchor items should be (mostly) clean, df = n_cat - y0, gid0 = gen(dif_on_item0=False) - res0 = dif_polytomous(y0, gid0, k, model="gpcm") - assert res0["item"].shape == (j,) - for key in ("lr", "df", "p_value", "flagged_bh", "effect_size"): - assert res0[key].shape == (j,) - assert np.all(res0["df"] == k) # (G-1)*K = K - assert np.all(res0["lr"] >= 0.0) - p0 = res0["p_value"] - assert np.all((p0 >= 0.0) & (p0 <= 1.0)) - assert res0["flagged_bh"].sum() <= 1 # impact alone must not manufacture DIF - - # inject uniform DIF on item 0: it should be flagged with the largest effect - y1, gid1 = gen(dif_on_item0=True) - res1 = dif_polytomous(y1, gid1, k, model="gpcm") - assert res1["flagged_bh"][0] - assert res1["p_value"][0] < 0.01 - assert res1["effect_size"][0] == res1["effect_size"].max() - assert res1["flagged_bh"][1:].sum() <= 1 # anchors stay clean - - # studied_items subset restricts the sweep - sub = dif_polytomous(y1, gid1, k, model="gpcm", studied_items=np.array([0, 3])) - assert list(sub["item"]) == [0, 3] - - # non-contiguous labels {0,2} must densify to 2 groups (df == n_cat), giving - # the SAME result as contiguous {0,1} -- not an inflated, conservative df. - gid_gap = np.where(gid1 == 1, 2, 0) - res_gap = dif_polytomous(y1, gid_gap, k, model="gpcm") - assert np.all(res_gap["df"] == k) # not (3-1)*k = 2k - assert res_gap["flagged_bh"][0] # strong DIF still detected despite label gap - np.testing.assert_allclose(res_gap["lr"], res1["lr"], rtol=1e-6) - - with pytest.raises(ValueError): - dif_polytomous(y1, gid1[:-5], k) # group_id length mismatch - - -def test_mantel_haenszel_dif(): - """Observed-score Mantel-Haenszel DIF (Holland & Thayer, 1988) via the public API: a uniform - (b-shift) DIF planted on one dichotomous item, no group impact. MH flags the planted item as - practically large (ETS class B/C, BH-significant) with the delta sign matching the shift (harder for - the focal group -> negative MH_D-DIF and STD-P-DIF), classifies the clean items as A, and returns the - documented per-item arrays. Validation guards reject non-dichotomous responses and a single group.""" - import numpy as np - import pytest - from fast_mlsirm import mantel_haenszel_dif - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "mantel_haenszel_dif"): - pytest.skip("compiled core built without mantel_haenszel_dif") - - n, n_items = 3000, 12 - dif_item = 6 - rng = np.random.default_rng(1988) - a = np.full(n_items, 1.2) - b = -0.8 + 0.14 * np.arange(n_items) - group = np.tile([0, 1], n // 2).astype(np.int64) - theta = rng.standard_normal(n) # equal ability distribution -> no impact - bmat = np.tile(b, (n, 1)) - bmat[group == 1, dif_item] += 0.7 # item harder for the focal group (uniform DIF) - p = 1.0 / (1.0 + np.exp(-(a * (theta[:, None] - bmat)))) - y = (rng.random((n, n_items)) < p).astype(float) - - res = mantel_haenszel_dif(y, group) - for key in ("item", "alpha_mh", "chi2_mh", "p_value", "mh_d_dif", "se_d_dif", "std_p_dif", - "ets_class", "flagged_bh"): - assert res[key].shape == (n_items,), key - assert list(res["item"]) == list(range(n_items)) - # planted item: BH-flagged, large negative delta, negative std-p-dif, class B or C - assert res["flagged_bh"][dif_item] - assert res["mh_d_dif"][dif_item] < -0.8 - assert res["std_p_dif"][dif_item] < 0.0 - assert res["ets_class"][dif_item] in ("B", "C") - # clean items: negligible (class A) by the practical-significance classification - clean = [i for i in range(n_items) if i != dif_item] - assert all(res["ets_class"][i] == "A" for i in clean) - assert np.all(np.abs(res["mh_d_dif"][clean]) < 1.0) - - # validation: non-dichotomous responses and a single-group sample are rejected - ybad = y.copy() - ybad[0, 0] = 2 - with pytest.raises(ValueError): - mantel_haenszel_dif(ybad, group) - with pytest.raises(ValueError): - mantel_haenszel_dif(y, np.zeros(n, dtype=np.int64)) - - -def test_logistic_dif_zumbo(): - """Zumbo (1999) logistic-regression DIF via the public API. Its reason to exist is NON-UNIFORM DIF: - a crossing item whose ICCs intersect at the common group ability mean is flagged through the - score x group interaction, while Mantel-Haenszel — a stratified odds-ratio test — calls the very - same item negligible. A plain b-shift item shows the reverse pattern (uniform component fires, the - interaction does not), and the exact LR decomposition holds for every item.""" - import numpy as np - import pytest - from fast_mlsirm import logistic_dif, mantel_haenszel_dif - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "logistic_dif"): - pytest.skip("compiled core built without logistic_dif") - - rng = np.random.default_rng(1999) - n, n_items = 4000, 10 - cross_item, unif_item = 4, 7 - b = -0.9 + 0.2 * np.arange(n_items) - group = (np.arange(n) % 2).astype(np.int64) - theta = rng.standard_normal(n) # identical ability distribution in both groups - - a_mat = np.ones((n, n_items)) - b_mat = np.tile(b, (n, 1)) - focal = group == 1 - # crossing DIF centered at the common ability mean: same difficulty, different slope - a_mat[:, cross_item] = np.where(focal, 0.4, 1.7) - b_mat[:, cross_item] = 0.0 - # pure uniform DIF - b_mat[focal, unif_item] += 0.8 - p = 1.0 / (1.0 + np.exp(-(a_mat * (theta[:, None] - b_mat)))) - y = (rng.random((n, n_items)) < p).astype(float) - - lr = logistic_dif(y, group) - mh = mantel_haenszel_dif(y, group) - for key in ("item", "chi2_uniform", "p_uniform", "chi2_nonuniform", "p_nonuniform", - "chi2_total", "p_total", "delta_r2", "delta_r2_uniform", "jg_class", - "flagged_bh", "converged"): - assert lr[key].shape == (n_items,), key - - # crossing item: the interaction fires, the group main effect does not, MH says negligible - assert lr["converged"][cross_item] - assert lr["p_nonuniform"][cross_item] < 0.01 - assert lr["p_uniform"][cross_item] > 0.05 - assert lr["flagged_bh"][cross_item] - assert mh["ets_class"][cross_item] == "A", "MH unexpectedly flagged the crossing item" - - # uniform item: the reverse pattern, and MH does see it - assert lr["p_uniform"][unif_item] < 0.01 - assert lr["p_nonuniform"][unif_item] > 0.05 - assert mh["ets_class"][unif_item] != "A" - - # exact nesting decomposition everywhere; clean items negligible - np.testing.assert_allclose( - lr["chi2_uniform"] + lr["chi2_nonuniform"], lr["chi2_total"], atol=1e-6 - ) - clean = [i for i in range(n_items) if i not in (cross_item, unif_item)] - assert all(lr["jg_class"][i] == "A" for i in clean) - - # validation - with pytest.raises(ValueError): - bad = y.copy() - bad[0, 0] = 2 - logistic_dif(bad, group) - with pytest.raises(ValueError): - logistic_dif(y, np.zeros(n, dtype=np.int64) + 3) - - -def test_score_wle_poly(): - """Warm (1989) WLE for polytomous items via the public API. - Pins the two properties that distinguish it from the EAP already shipped: it is NOT shrunk toward - a prior mean, and it stays finite for the all-lowest/all-highest patterns where ML diverges. - Also pins the K=2 reduction to the dichotomous score_wle, which is the plumbing anchor.""" - import numpy as np - import pytest - from fast_mlsirm import score_wle, score_wle_poly - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "score_wle_poly"): - pytest.skip("compiled core built without score_wle_poly") - - slope = np.array([1.7, 0.9, 1.3, 1.1, 0.8]) - cat = np.array([[1.6, 0.1, -1.2], [2.4, -0.5, -1.9], [0.8, -0.2, -2.6], - [1.2, 0.3, -1.5], [2.0, -0.7, -2.1]]) - y = np.array([[1, 3, 2, 0, 2], [0, 0, 0, 0, 0], [3, 3, 3, 3, 3]], float) - - for model in ("grm", "gpcm"): - r = score_wle_poly(y, slope, cat, 4, model=model) - for key in ("theta", "se", "boundary"): - assert key in r - assert r["theta"].shape == (3,) - assert np.all(np.isfinite(r["theta"])), f"{model}: WLE must be finite for every pattern" - assert not r["boundary"].any(), f"{model}: no pattern should hit the bound here" - # the extreme patterns are where ML diverges; WLE must be finite AND ordered - assert r["theta"][1] < r["theta"][0] < r["theta"][2] - assert np.all(r["se"] > 0) - - # K=2 must reproduce the dichotomous estimator exactly (both families ARE the 2PL there) - a = np.array([1.4, 0.8, 2.1, 1.1, 0.6]) - b = np.array([-0.9, 0.3, 1.4, -1.7, 0.8]) - ybin = np.array([[1, 1, 0, 1, 0], [0, 1, 0, 0, 0], [1, 1, 1, 1, 0]], float) - dich = score_wle(a, b, ybin, theta_bound=6.0, tol=1e-10) - cat2 = (-a * b).reshape(-1, 1) - for model in ("gpcm", "grm"): - poly = score_wle_poly(ybin, a, cat2, 2, model=model, theta_bound=6.0, tol=1e-10) - np.testing.assert_allclose(poly["theta"], dich["theta"], atol=1e-6) - np.testing.assert_allclose(poly["se"], dich["se"], atol=1e-6) - - # missing data: a person with nothing observed is undefined, not average - ymiss = y.copy() - ymiss[1, :] = np.nan - r = score_wle_poly(ymiss, slope, cat, 4, model="grm") - assert np.isnan(r["theta"][1]) and r["boundary"][1] - assert np.isfinite(r["theta"][0]) - - # validation - with pytest.raises(ValueError): - score_wle_poly(y, slope, cat, 1, model="grm") - with pytest.raises(ValueError): - score_wle_poly(np.full_like(y, 9.0), slope, cat, 4, model="grm") - with pytest.raises(ValueError): - score_wle_poly(y, slope, cat[:, :2], 4, model="grm") - - -def test_sibtest_uniform(): - """Uniform SIBTEST (Shealy & Stout, 1993) through the public API. - Pins the parts that are actually true: the sign is OPPOSITE to Mantel-Haenszel's, the per-group - reliabilities are exposed, planted DIF is detected, and a degenerate item yields NaN rather than a - plausible number. Deliberately does NOT assert that SIBTEST beats MH under impact -- measurement - says the reverse (it over-rejects), and that is documented in the docstring.""" - import numpy as np - import pytest - from fast_mlsirm import mantel_haenszel_dif, sibtest - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "sibtest"): - pytest.skip("compiled core built without sibtest") - - rng = np.random.default_rng(23) - n, J = 3000, 8 - group = (np.arange(n) % 2).astype(np.int64) - theta = rng.standard_normal(n) - b = np.tile(-1.1 + 0.23 * np.arange(J), (n, 1)) - b[group == 1, 4] += 1.1 # item 4 harder for the focal group - p = 1.0 / (1.0 + np.exp(-(1.3 * (theta[:, None] - b)))) - y = (rng.random((n, J)) < p).astype(float) - - sib = sibtest(y, group) - mh = mantel_haenszel_dif(y, group) - for key in ("beta_uni", "se_beta", "b_uni", "p_value", "alpha_ref", "alpha_focal", - "n_strata_used", "flagged_bh"): - assert key in sib, key - assert sib["beta_uni"].shape == (J,) - - # planted item detected, and the sign conventions are OPPOSITE across the two procedures - assert sib["p_value"][4] < 0.01 - assert sib["beta_uni"][4] > 0.0, "positive beta_uni = harder for the focal group" - assert mh["mh_d_dif"][4] < 0.0, "mh_d_dif is focal-oriented and goes the other way" - assert sib["beta_uni"][4] * mh["std_p_dif"][4] < 0.0 - - # swapping the group labels flips beta_uni and preserves its magnitude - sib_sw = sibtest(y, 1 - group) - assert sib_sw["beta_uni"][4] < 0.0 - assert abs(sib_sw["beta_uni"][4] + sib["beta_uni"][4]) < 1e-9 - - # reliabilities are real, per-group, and in range - assert np.all((sib["alpha_ref"] > 0.0) & (sib["alpha_ref"] < 1.0)) - assert np.all((sib["alpha_focal"] > 0.0) & (sib["alpha_focal"] < 1.0)) - assert not np.array_equal(sib["alpha_ref"], sib["alpha_focal"]), "alphas must be per-group" - assert np.all(sib["n_strata_used"][np.isfinite(sib["beta_uni"])] > 0) - - # the binding's field mapping is pinned by internal identities, so a swapped pair of dict keys - # (se_beta/b_uni, or alpha_ref/alpha_focal) cannot slip through the boundary unnoticed - ok = np.isfinite(sib["b_uni"]) - assert np.array_equal(sib["item"], np.arange(J)) - assert np.allclose(sib["b_uni"][ok], sib["beta_uni"][ok] / sib["se_beta"][ok], rtol=1e-12) - from fast_mlsirm import chi2_sf - expected_p = np.array([chi2_sf(v * v, 1.0) for v in sib["b_uni"][ok]]) - assert np.allclose(sib["p_value"][ok], expected_p, rtol=1e-10) - - # j_min is plumbed through: a stricter floor can only retain fewer strata, and at a floor high - # enough to bite on this fixture it must retain strictly fewer (60 is too low to constrain a - # 3000-person bank, so asserting on it would prove nothing) - strict = sibtest(y, group, j_min=250) - assert np.all(strict["n_strata_used"] <= sib["n_strata_used"]) - assert strict["n_strata_used"].sum() < sib["n_strata_used"].sum() - - # a constant item is undefined, not "no DIF" - y2 = y.copy() - y2[:, 7] = 1.0 - deg = sibtest(y2, group) - assert np.isnan(deg["beta_uni"][7]) and np.isnan(deg["p_value"][7]) - assert not deg["flagged_bh"][7] - - # validation - with pytest.raises(ValueError): - sibtest(y[:, :2], group) - with pytest.raises(ValueError): - sibtest(y, group, j_min=1) - - -def test_dif_purification(): - """Iterative item purification (Candell & Drasgow, 1988; Clauser et al., 1993) via the public API. - Seeded regression fixture: several items are shifted UNIDIRECTIONALLY against the focal group, which - depresses that group's number-correct total, so CLEAN items pick up spurious DIF. Rebuilding the - criterion from the unflagged anchor reduces those false flags while the planted items stay flagged. - The precondition is asserted first so the test cannot pass on a fixture with no contamination.""" - import numpy as np - import pytest - from fast_mlsirm import ( - velicer_map, - velicer_map_from_data,logistic_dif_purified, mantel_haenszel_dif, - mantel_haenszel_dif_purified) - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "mantel_haenszel_dif_purified"): - pytest.skip("compiled core built without mantel_haenszel_dif_purified") - - def bank(dif_items, shift, seed, n=3000, n_items=12): - rng = np.random.default_rng(seed) - b = -0.9 + 0.16 * np.arange(n_items) - group = (np.arange(n) % 2).astype(np.int64) - theta = rng.standard_normal(n) - bmat = np.tile(b, (n, 1)) - for i in dif_items: - bmat[group == 1, i] += shift # all against the SAME group - p = 1.0 / (1.0 + np.exp(-(1.2 * (theta[:, None] - bmat)))) - return (rng.random((n, n_items)) < p).astype(float), group - - dif_items = [2, 5, 8] - y, group = bank(dif_items, 1.2, 41) - plain = mantel_haenszel_dif(y, group) - pur = mantel_haenszel_dif_purified(y, group) - for key in ( - "anchor", - "n_anchor", - "rounds", - "purify_converged", - "purify_termination_reason", - ): - assert key in pur - assert pur["anchor"].shape == (12,) - - clean = [i for i in range(12) if i not in dif_items] - before = [j for j in clean if plain["ets_class"][j] != "A"] - assert before, "fixture precondition: the unpurified sweep false-flagged no clean item" - after = [j for j in clean if pur["ets_class"][j] != "A"] - assert len(after) < len(before), f"purification did not reduce false flags: {before} -> {after}" - # true positives retained, and the planted items are what left the anchor - for d in dif_items: - assert pur["ets_class"][d] in ("B", "C") - assert not pur["anchor"][d] - # the criterion genuinely changed - assert any(abs(pur["chi2_mh"][j] - plain["chi2_mh"][j]) > 1e-6 for j in clean) - - # a clean bank needs no purification and reproduces the plain sweep exactly - y0, g0 = bank([], 0.0, 7) - p0 = mantel_haenszel_dif(y0, g0) - q0 = mantel_haenszel_dif_purified(y0, g0) - assert q0["rounds"] == 0 and q0["purify_converged"] and q0["n_anchor"] == 12 - assert q0["purify_termination_reason"] == "stable_flag_set" - np.testing.assert_array_equal(q0["chi2_mh"], p0["chi2_mh"]) - - # the logistic variant runs and also drops the planted items from its anchor - lp = logistic_dif_purified(y, group) - assert all(not lp["anchor"][d] for d in dif_items) - # the loop's scalar flag must NOT collide with logistic_dif's PER-ITEM convergence array: the two - # answer different questions, and a same-named scalar silently destroyed the array at the boundary - assert np.asarray(lp["converged"], dtype=bool).shape == (12,) - assert isinstance(lp["purify_converged"], bool) - - # the interpretation limits must survive to __doc__ -- a docstring written as `"""..""" + NOTE` - # is a BinOp, not a constant, so the compiler discards it and help() shows nothing - for fn in (mantel_haenszel_dif_purified, logistic_dif_purified): - assert fn.__doc__ and "FDR" in fn.__doc__, f"{fn.__name__} lost its caveat" - - -def test_score_wle_warm(): - """Warm's WLE (1989) via the public API: FINITE estimates for the perfect/zero patterns where the - MLE diverges (correct > incorrect), monotone in the raw score, SE = 1/sqrt(I), 3PL support, and - input validation.""" - import numpy as np - import pytest - from fast_mlsirm import score_wle - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "score_wle"): - pytest.skip("compiled core built without score_wle") - - j = 8 - a = 1.0 + 0.1 * np.arange(j) - b = np.linspace(-2.0, 2.0, j) - # perfect and zero patterns -> finite, interior, correct > incorrect (MLE would be +/-inf) - rc = score_wle(a, b, np.ones((1, j))) - rw = score_wle(a, b, np.zeros((1, j))) - assert np.isfinite(rc["theta"][0]) and not rc["boundary"][0] - assert np.isfinite(rw["theta"][0]) and not rw["boundary"][0] - assert rc["theta"][0] > rw["theta"][0] - # monotone in the number-correct score (Rasch, a=1) - a1 = np.ones(j) - thetas = [ - score_wle(a1, b, np.array([[1.0 if i < k else 0.0 for i in range(j)]]))["theta"][0] - for k in range(j + 1) - ] - assert all(thetas[k] <= thetas[k + 1] + 1e-9 for k in range(j)) - # SE = 1/sqrt(I) at the estimate (2PL information) - y = np.array([[1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0]]) - res = score_wle(a, b, y) - assert res["theta"].shape == (1,) and res["se"].shape == (1,) and res["boundary"].shape == (1,) - theta = res["theta"][0] - p = 1.0 / (1.0 + np.exp(-a * (theta - b))) - info = np.sum(a**2 * p * (1.0 - p)) - assert abs(res["se"][0] - 1.0 / np.sqrt(info)) < 1e-6 - # 3PL support (lower asymptote c > 0) - r3 = score_wle(a, b, y, c=np.full(j, 0.2)) - assert np.isfinite(r3["theta"][0]) - # validation: a/b length mismatch and a non-0/1 response - with pytest.raises(ValueError): - score_wle(a, b[:-1], y) - with pytest.raises(ValueError): - score_wle(a, b, np.array([[2.0] + [0.0] * (j - 1)])) - - -def test_score_wle_resolves_narrow_global_mode(): - """The Rust WLE search resolves a narrow high-discrimination 4PL global mode that falls between - the historical fixed-grid nodes.""" - import numpy as np - - from fast_mlsirm import score_wle - - a = np.array( - [ - 3.329447657883643, - 0.27232757528116147, - 84.38646237902715, - 4.507142332708399, - 0.216076032654272, - 1.152868526694496, - 0.5026701543207452, - 3.594020470848568, - ] - ) - b = np.array( - [ - -2.2559085720992726, - 4.784793518100594, - -2.7313173853279284, - 3.16639784715872, - 2.45483432935667, - 3.577399138394002, - -0.541499889021253, - -3.1606254220709538, - ] - ) - c = np.array( - [ - 0.4293154638946107, - 0.03968316086976924, - 0.2117187277379179, - 0.4041453105751009, - 0.14842532496042327, - 0.2781240730868334, - 0.07100800469041686, - 0.16882942315223948, - ] - ) - d = np.array( - [ - 0.9271440266982822, - 0.8326920519773708, - 0.7052699247299387, - 0.7321429393598535, - 0.7250331916969143, - 0.8800003001396377, - 0.7964931220169523, - 0.8078636510671307, - ] - ) - y = np.array([[1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0]]) - - result = score_wle(a, b, y, c=c, d=d, tol=1e-10) - assert not result["boundary"][0] - assert abs(result["theta"][0] + 2.74) < 0.02 - - -def test_rasch_cml_and_andersen_lr(): - """Rasch CML (Andersen, 1970/1972) and Andersen's (1973) LR test via the public API: the item - difficulties are recovered PERSON-DISTRIBUTION-FREE (the same beta under N(0,1) vs skewed ability, - the CML signature), and the LR test does not over-reject true Rasch data but flags a planted - group-specific difficulty shift.""" - import numpy as np - import pytest - from fast_mlsirm import andersen_lr_test, fit_rasch_cml - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_rasch_cml"): - pytest.skip("compiled core built without fit_rasch_cml") - - rng = np.random.default_rng(1972) - k, n = 8, 4000 - beta = np.array([-1.6, -0.9, -0.3, 0.2, 0.7, 1.2, 1.7, 0.0]) - beta -= beta.mean() - - def sim(theta): - p = 1.0 / (1.0 + np.exp(-(theta[:, None] - beta))) - return (rng.random((n, k)) < p).astype(float) - - # (a) N(0,1) ability and (b) a strongly right-skewed ability - ya = sim(rng.standard_normal(n)) - yb = sim(1.5 * rng.exponential(size=n) - 1.5) - fa = fit_rasch_cml(ya) - fb = fit_rasch_cml(yb) - assert fa["converged"] and fb["converged"] - assert fa["beta"].shape == (k,) and fa["se"].shape == (k,) - assert abs(fa["beta"].sum()) < 1e-8 # sum-zero identification - assert np.sqrt(np.mean((fa["beta"] - beta) ** 2)) < 0.15 - # the defining property: the two ability distributions give the SAME estimates - assert np.sqrt(np.mean((fa["beta"] - fb["beta"]) ** 2)) < 0.15 - assert np.all(np.isfinite(fa["se"])) and np.all(fa["se"] > 0) - - # Andersen LR: arbitrary split of true Rasch data -> not rejected; planted DIF -> rejected - theta = rng.standard_normal(n) - group = (np.arange(n) % 2) - y_rasch = sim(theta) - t1 = andersen_lr_test(y_rasch, group) - assert t1["df"] == (2 - 1) * (k - 1) - assert t1["p_value"] > 0.01 - y_dif = y_rasch.copy() - # regenerate group-1 responses on item 0 under a shifted difficulty - b0 = beta.copy() - b0[0] += 1.5 - mask = group == 1 - p_dif = 1.0 / (1.0 + np.exp(-(theta[mask][:, None] - b0))) - y_dif[mask] = (rng.random((mask.sum(), k)) < p_dif).astype(float) - t2 = andersen_lr_test(y_dif, group) - assert t2["lr"] > t1["lr"] + 15.0 and t2["p_value"] < 0.01 - - # validation: non-0/1 responses rejected - with pytest.raises(ValueError): - bad = ya.copy() - bad[0, 0] = 2 - fit_rasch_cml(bad) - - -def test_andersen_group_labels_must_be_finite(monkeypatch): - """Non-finite labels must fail before NumPy's float-to-int cast can densify them.""" - import numpy as np - import pytest - - from fast_mlsirm import andersen_lr_test - - class UnexpectedCore: - def andersen_lr_test(self, *_args): # pragma: no cover - must not be reached - pytest.fail("invalid group labels reached the Rust core") - - monkeypatch.setattr("fast_mlsirm.fitstats._core_module", lambda: UnexpectedCore()) - responses = np.array([[1.0, 0.0], [0.0, 1.0]]) - for invalid in (np.nan, np.inf, -np.inf): - with pytest.raises(ValueError, match="finite non-negative integers"): - andersen_lr_test(responses, np.array([0.0, invalid])) - - -def test_dif_polytomous_grm_no_silent_false_negative(): - """A GRM studied item whose focal group never uses a middle category can - disorder thresholds -> NaN loglik. The finiteness guard must surface that as - NaN (unflagged) rather than let the `.max(0.0)` clamp report a strongly-DIF - item as clean (lr=0, p=1).""" - import numpy as np - import pytest - from fast_mlsirm import dif_polytomous - from fast_mlsirm.estimators.marginal import grm_category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "poly_dif"): - pytest.skip("compiled core built without poly_dif") - - k, j, n_per = 4, 6, 500 - rng = np.random.default_rng(7) - a = rng.uniform(1.0, 1.4, j) - # decreasing GRM thresholds (ordered) - b = np.tile(np.array([1.2, 0.0, -1.2]), (j, 1)) - - def draw(theta, ai, bi): - p = np.exp(grm_category_logprobs(ai * theta, bi)) - return rng.choice(k, p=p) - - y = np.zeros((2 * n_per, j)) - gid = np.concatenate([np.zeros(n_per, int), np.ones(n_per, int)]) - theta = np.concatenate([rng.standard_normal(n_per), 0.6 + rng.standard_normal(n_per)]) - for p in range(2 * n_per): - focal = gid[p] == 1 - for i in range(j): - if i == 0 and focal: - # strong DIF + squeeze a middle category out for the focal group - # (category 1 ~ 0 counts) so the GRM per-group M-step disorders - # thresholds and the fit goes non-finite -- the guard's trigger - y[p, i] = draw(theta[p], a[i], np.array([4.2, 4.1, -4.2])) - else: - y[p, i] = draw(theta[p], a[i], b[i]) - - res = dif_polytomous(y, gid, k, model="grm") - # item 0 must NOT be a silent clean report: either surfaced NaN, or (if the - # fit stayed finite) correctly flagged. A finite p>0.5 unflagged would be the - # masked false-negative the guard exists to prevent. - p0, flg0 = res["p_value"][0], res["flagged_bh"][0] - assert np.isnan(p0) or flg0, f"item 0 silently reported clean: p={p0}, flagged={flg0}" - # a NaN p-value must be reported unflagged (never counted as significant) - assert not (np.isnan(p0) and flg0) - - -def test_u3_person_fit_polytomous(): - """Nonparametric polytomous U3poly (Emons, 2008) through the public API: - correct bookkeeping, calibrated flag rate under a simulated cutoff, and - detection of careless responders.""" - import numpy as np - import pytest - from fast_mlsirm import ( - velicer_map, - velicer_map_from_data, - fit_polytomous, - u3_cutoff_polytomous, - u3_person_fit_polytomous, - ) - from fast_mlsirm.estimators.marginal import category_logprobs - from fast_mlsirm.polytomous import _core_module - - if _core_module() is None or not hasattr(__import__("fast_mlsirm")._core, "u3_person_fit"): - pytest.skip("compiled core built without u3_person_fit") - - k, j, n = 5, 20, 800 - rng = np.random.default_rng(3) - a = rng.uniform(0.9, 1.5, j) - # spread item difficulty across the bank so items differ in popularity -- the - # regime where a popularity-based statistic like U3 has power (Emons, 2008) - bdiff = np.linspace(-1.6, 1.6, j) - c = np.zeros((j, k)) - for i in range(j): - cum = 0.0 - for m in range(1, k): - cum += bdiff[i] + (m - 1 - (k - 2) / 2) * 0.8 - c[i, m] = -a[i] * cum - scores = np.arange(k, dtype=float) - - def gen(n_care): - theta = rng.standard_normal(n) - y = np.zeros((n, j)) - for p in range(n): - for i in range(j): - if p < n_care: # careless: uniform-random category - y[p, i] = rng.integers(k) - else: - pr = np.exp(category_logprobs(a[i] * theta[p], scores, c[i])) - y[p, i] = rng.choice(k, p=pr) - return y - - # clean data -> fit a bank -> simulated cutoff -> near-nominal flag rate - y0 = gen(0) - res0 = u3_person_fit_polytomous(y0, k) - for key in ("u3poly", "total_score", "flagged"): - assert res0[key].shape == (n,) - finite = res0["u3poly"][np.isfinite(res0["u3poly"])] - assert np.all((finite >= 0.0) & (finite <= 1.0)) - assert not res0["flagged"].any() # no cutoff -> nothing flagged - - fit = fit_polytomous(y0, k, model="gpcm") - cutoff = u3_cutoff_polytomous(fit, n_persons=n, alpha=0.05, n_rep=60, seed=7) - assert 0.0 < cutoff < 1.0 - flagged0 = u3_person_fit_polytomous(y0, k, cutoff=cutoff)["flagged"] - assert flagged0.mean() < 0.15 # calibrated-ish on clean data - - # inject careless responders -> they are flagged far more than clean persons - n_care = 120 - y1 = gen(n_care) - res1 = u3_person_fit_polytomous(y1, k, cutoff=cutoff) - care_rate = res1["flagged"][:n_care].mean() - clean_rate = res1["flagged"][n_care:].mean() - assert care_rate > 0.6, f"careless detection too weak: {care_rate}" - assert care_rate > 3 * clean_rate - - # K=2 stays in range and total_score bookkeeping is correct - yb = (rng.random((200, 10)) < 0.5).astype(float) - rb = u3_person_fit_polytomous(yb, 2) - assert np.array_equal(rb["total_score"], yb.sum(axis=1).astype(np.int64)) - - # an all-missing respondent has no conditioning group -> undefined (NaN), - # never a silent perfect fit that can't be flagged - ymiss = y0.copy() - ymiss[0, :] = np.nan - rm = u3_person_fit_polytomous(ymiss, k, cutoff=cutoff) - assert np.isnan(rm["u3poly"][0]) - assert not rm["flagged"][0] - - with pytest.raises(ValueError): - u3_person_fit_polytomous(y0, k, cutoff=float("nan")) - - -def test_equate_observed_scores_and_neat(): - """Observed-score equating (Kolen & Brennan, 2014) through the public API: - equipercentile self-equate is the identity, mean/linear recover a known - transform, and NEAT chained/FE collapse to EG equipercentile under equal - anchor distributions.""" - import numpy as np - import pytest - from fast_mlsirm import equate_neat, equate_observed_scores - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "equate_observed_scores"): - pytest.skip("compiled core built without equating") - - rng = np.random.default_rng(5) - k = 40 - x = np.clip(np.round(20 + 8 * rng.standard_normal(4000)), 0, k) - - # self-equate is the identity on populated scores - r = equate_observed_scores(x, x, method="equipercentile", k_x=k, k_y=k) - g = np.bincount(x.astype(int), minlength=k + 1) - dev = np.abs(r.y_equivalents - r.x_scores)[g > 0] - assert dev.max() < 1e-9 - assert r.design == "EG" - - # mean equating recovers Y = X + 5 - y = x + 5 - rm = equate_observed_scores(x, y, method="mean", k_x=k, k_y=k + 5) - assert abs(rm.intercept - 5.0) < 1e-9 and abs(rm.slope - 1.0) < 1e-12 - - # NEAT collapse under equal anchor distributions (identical anchor vectors) - n = 6000 - kv, kx, ky = 15, 30, 40 - v = np.clip(np.round(7 + 3 * rng.standard_normal(n)), 0, kv) - xt = np.clip(np.round(1.4 * v + 4 + 4 * rng.standard_normal(n)), 0, kx) - yt = np.clip(np.round(2.0 * v + 6 + 5 * rng.standard_normal(n)), 0, ky) - eg = equate_observed_scores(xt, yt, method="equipercentile", k_x=kx, k_y=ky) - ch = equate_neat(xt, v, yt, v, method="chained", k_x=kx, k_y=ky, k_v=kv) - fe = equate_neat(xt, v, yt, v, method="frequency_estimation", k_x=kx, k_y=ky, k_v=kv, w1=0.5) - assert np.max(np.abs(ch.y_equivalents - eg.y_equivalents)) < 1e-9 - assert np.max(np.abs(fe.y_equivalents - eg.y_equivalents)) < 1e-9 - assert ch.design == "NEAT" - - with pytest.raises(ValueError): - equate_observed_scores(x, y, method="bogus", k_x=k, k_y=k + 5) - - -def test_kernel_equating_and_presmoothing(): - """Kernel equating + log-linear presmoothing (von Davier et al., 2004; - Holland & Thayer, 2000) through the public API: presmoothing preserves - moments, uniform-kernel ext matches equipercentile, and a large bandwidth - drives kernel equating to linear.""" - import numpy as np - import pytest - from fast_mlsirm import ( - velicer_map, - velicer_map_from_data, - equate_observed_scores, - equate_observed_scores_kernel, - loglinear_smooth, - ) - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "equate_observed_scores_ext"): - pytest.skip("compiled core built without kernel equating") - - rng = np.random.default_rng(7) - k = 40 - x = np.clip(np.round(20 + 7 * rng.standard_normal(5000)), 0, k) - counts = np.bincount(x.astype(int), minlength=k + 1).astype(float) - - # presmoothing preserves the first `degree` moments (on the u=x/k scale) - fit = loglinear_smooth(counts, degree=4) - assert fit["converged"] and abs(fit["probs"].sum() - 1.0) < 1e-12 - g = counts / counts.sum() - for j, fm in enumerate(fit["moments"], start=1): - sm = float(((np.arange(k + 1) / k) ** j * g).sum()) - assert abs(fm - sm) < 1e-8, f"moment {j}: {fm} vs {sm}" - - # uniform-kernel ext == equipercentile - y = np.clip(np.round(22 + 8 * rng.standard_normal(5000)), 0, k) - base = equate_observed_scores(x, y, method="equipercentile", k_x=k, k_y=k) - uni = equate_observed_scores_kernel(x, y, continuization="uniform", k_x=k, k_y=k) - assert np.max(np.abs(base.y_equivalents - uni.y_equivalents)) < 1e-12 - assert np.isnan(uni.h_x) - - # large-bandwidth Gaussian kernel -> linear equating - lin = equate_observed_scores(x, y, method="linear", k_x=k, k_y=k) - ker = equate_observed_scores_kernel( - x, y, continuization="gaussian", k_x=k, k_y=k, bandwidth_x=1e6, bandwidth_y=1e6 - ) - assert np.max(np.abs(lin.y_equivalents - ker.y_equivalents)) < 1e-3 - assert ker.h_x == 1e6 - - # penalty-selected bandwidth is finite and positive - auto = equate_observed_scores_kernel(x, y, continuization="gaussian", k_x=k, k_y=k) - assert np.isfinite(auto.h_x) and auto.h_x > 0 - - # default degree clamps to k, so short forms (k < 6) do not error - short = loglinear_smooth(np.array([10.0, 20.0, 30.0, 15.0, 8.0, 4.0])) # k = 5 - assert short["converged"] and short["probs"].shape == (6,) - - # A successful function return is not a successful optimization. This sparse - # fixture stops before satisfying the log-linear score tolerance, so the - # high-level equating path must not consume its unfinished density. - sparse_counts = np.array([0, 1564, 426, 0, 1008, 0, 0]) - unfinished = loglinear_smooth(sparse_counts, degree=5) - assert not unfinished["converged"] and unfinished["iters"] < 50 - assert unfinished["termination_reason"] == "line_search_stalled" - assert unfinished["final_gradient_max"] > unfinished["gradient_tolerance"] - sparse_scores = np.repeat(np.arange(7), sparse_counts).astype(float) - with pytest.raises(ValueError, match="presmoothing did not converge"): - equate_observed_scores_kernel( - sparse_scores, - sparse_scores, - continuization="uniform", - k_x=6, - k_y=6, - smooth_x=5, - smooth_y=5, - ) - - with pytest.raises(ValueError): - equate_observed_scores_kernel(x, y, continuization="bogus", k_x=k, k_y=k) - with pytest.raises(ValueError): - equate_observed_scores_kernel(x, y, continuization="gaussian", k_x=k, k_y=k, smooth_x=-1) - - -def test_equate_neat_linear_tucker_levine(): - """Tucker & Levine linear NEAT equating (Kolen & Brennan, 2014) through the - public API: with equal anchor moments every variant collapses to EG linear, - and the internal/external Levine gamma give distinct conversions.""" - import numpy as np - import pytest - from fast_mlsirm import equate_neat_linear, equate_observed_scores - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "equate_neat_linear"): - pytest.skip("compiled core built without equate_neat_linear") - - rng = np.random.default_rng(5) - n, kx, ky = 4000, 30, 40 - anchor = np.clip(np.round(7 + 3 * rng.standard_normal(n)), 0, 15) - xt = np.clip(np.round(1.5 * anchor + 4 + 3 * rng.standard_normal(n)), 0, kx) - yt = np.clip(np.round(1.8 * anchor + 6 + 4 * rng.standard_normal(n)), 0, ky) - - # collapse: shared anchor -> equal anchor moments -> EG linear - eg = equate_observed_scores(xt, yt, method="linear", k_x=kx, k_y=ky) - for method in ("tucker", "levine"): - for ak in ("internal", "external"): - r = equate_neat_linear(xt, anchor, yt, anchor, method=method, anchor_kind=ak, k_x=kx, k_y=ky) - assert abs(r.slope - eg.slope) < 1e-9 and abs(r.intercept - eg.intercept) < 1e-9 - assert r.design == "NEAT" - - # internal vs external Levine differ on a genuinely non-equivalent anchor - ancx = np.clip(np.round(6 + 2.5 * rng.standard_normal(n)), 0, 15) - ancy = np.clip(np.round(9 + 2.5 * rng.standard_normal(n)), 0, 15) # shifted - xt2 = np.clip(np.round(1.2 * ancx + 5 + 3 * rng.standard_normal(n)), 0, kx) - yt2 = np.clip(np.round(1.2 * ancy + 8 + 3 * rng.standard_normal(n)), 0, ky) - li = equate_neat_linear(xt2, ancx, yt2, ancy, method="levine", anchor_kind="internal", k_x=kx, k_y=ky) - le = equate_neat_linear(xt2, ancx, yt2, ancy, method="levine", anchor_kind="external", k_x=kx, k_y=ky) - tk = equate_neat_linear(xt2, ancx, yt2, ancy, method="tucker", k_x=kx, k_y=ky) - assert abs(li.slope - le.slope) > 1e-6 or abs(li.intercept - le.intercept) > 1e-6 - assert abs(tk.slope - li.slope) > 1e-6 or abs(tk.intercept - li.intercept) > 1e-6 - - with pytest.raises(ValueError): - equate_neat_linear(xt, anchor, yt, anchor, method="bogus", k_x=kx, k_y=ky) - - -def test_equating_standard_errors(): - """Standard errors of equating (Kolen & Brennan, 2014, ch. 7) through the - public API: analytic and bootstrap linear SEE agree, Mean SEE is constant, - the CI brackets the point estimate, and equipercentile SEE is bootstrap-only.""" - import numpy as np - import pytest - from fast_mlsirm import equating_standard_errors - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "bootstrap_see"): - pytest.skip("compiled core built without SEE") - - rng = np.random.default_rng(7) - k, n = 30, 3000 - x = np.clip(np.round(15 + 5 * rng.standard_normal(n)), 0, k) - y = np.clip(np.round(16 + 5 * rng.standard_normal(n)), 0, k) - - a = equating_standard_errors(x, y, method="linear", route="analytic", k_x=k, k_y=k) - b = equating_standard_errors(x, y, method="linear", route="bootstrap", k_x=k, k_y=k, n_boot=2000, seed=1) - lo, hi = int(np.ceil(k * 0.1)), k - int(np.ceil(k * 0.1)) - rel = np.abs(b["se"][lo:hi] - a["se"][lo:hi]) / a["se"][lo:hi] - assert rel.max() < 0.15, f"analytic vs bootstrap linear SEE: {rel.max()}" - # CI brackets the point estimate - assert np.all(b["ci_lo"][lo:hi] <= b["y_equivalents"][lo:hi] + 1e-9) - assert np.all(b["y_equivalents"][lo:hi] <= b["ci_hi"][lo:hi] + 1e-9) - assert b["n_boot"] == 2000 and abs(b["ci_level"] - 0.95) < 1e-12 - - # Mean SEE constant in x - m = equating_standard_errors(x, y, method="mean", route="analytic", k_x=k, k_y=k) - assert np.allclose(m["se"], m["se"][0]) - - # equipercentile has no analytic SEE; bootstrap works - ep = equating_standard_errors(x, y, method="equipercentile", route="bootstrap", k_x=k, k_y=k, n_boot=300, seed=3) - assert np.all(ep["se"][lo:hi] > 0) and ep["n_boot"] == 300 - with pytest.raises(ValueError): - equating_standard_errors(x, y, method="equipercentile", route="analytic", k_x=k, k_y=k) - with pytest.raises(ValueError): - equating_standard_errors(x, y, method="linear", route="bogus", k_x=k, k_y=k) - with pytest.raises(ValueError, match="bootstrap buffer size"): - equating_standard_errors( - x, - y, - method="mean", - route="bootstrap", - k_x=k, - k_y=k, - n_boot=np.iinfo(np.uintp).max, - ) - - -def test_fit_response_times(): - """Lognormal response-time model (van der Linden, 2007) through the public - API: recovers the item time parameters, the speed SD, and the speed EAP, and - handles missing (NaN) response times.""" - import numpy as np - import pytest - from fast_mlsirm import fit_response_times - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_rt_lognormal"): - pytest.skip("compiled core built without fit_rt_lognormal") - - rng = np.random.default_rng(4) - n, m = 800, 20 - beta = np.linspace(3.5, 4.5, m) - alpha = np.linspace(1.0, 3.0, m) - sigma = 0.3 - tau = sigma * rng.standard_normal(n) - y = beta[None, :] - tau[:, None] + rng.standard_normal((n, m)) / alpha[None, :] - times = np.exp(y) - # inject ~20% missing as NaN - times[rng.random((n, m)) < 0.2] = np.nan - - fit = fit_response_times(times) - assert fit.converged - assert fit.termination_reason == "converged" - assert fit.n_iter < 500 - assert fit.final_loglik_change < 1e-6 - assert np.isfinite(fit.loglik_trace).all() - assert np.all( - np.diff(fit.loglik_trace) - >= -1e-6 * np.maximum(np.abs(fit.loglik_trace[:-1]), 1) - ) - assert fit.loglik == fit.loglik_trace[-1] - assert fit.alpha.shape == (m,) and fit.tau_eap.shape == (n,) - assert np.corrcoef(fit.beta, beta)[0, 1] > 0.95 - assert np.corrcoef(fit.alpha, alpha)[0, 1] > 0.85 - assert np.corrcoef(fit.tau_eap, tau)[0, 1] > 0.8 - assert abs(fit.sigma_tau - sigma) < 0.1 - assert fit.mu_tau == 0.0 - # loglik trace is non-decreasing (monotone EM) - # (exposed via n_iter/converged; recompute a small fit to confirm determinism) - fit2 = fit_response_times(times) - assert np.allclose(fit.beta, fit2.beta) - - with pytest.warns(RuntimeWarning, match="max_iter_reached"): - fit_nc = fit_response_times(times, max_iter=1) - assert not fit_nc.converged - assert fit_nc.termination_reason == "max_iter_reached" - assert fit_nc.n_iter == 1 - assert fit_nc.final_loglik_change >= 1e-6 - - with pytest.raises(RuntimeError, match="max_iter_reached"): - fit_response_times(times, max_iter=1, require_convergence=True) - - with pytest.raises(ValueError, match="max_iter"): - fit_response_times(times, max_iter=0) - - with pytest.raises(ValueError): - fit_response_times(times.ravel()) # not 2-D - - -def test_fit_speed_accuracy(): - """Joint speed-accuracy model (van der Linden, 2007, Level 2) through the - public API: recovers a positive ability-speed correlation with item banks - fixed, and returns ~0 under true independence.""" - import numpy as np - import pytest - from fast_mlsirm import fit_response_times, fit_speed_accuracy - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_speed_accuracy_covariance"): - pytest.skip("compiled core built without fit_speed_accuracy_covariance") - - rng = np.random.default_rng(7) - n, m = 1000, 20 - a = 0.9 + 0.6 * (np.arange(m) % 3) / 2.0 - b = np.linspace(-1.5, 1.5, m) - alpha = np.linspace(1.0, 3.0, m) - beta = np.linspace(3.5, 4.5, m) - - def sim(rho, sig=0.3): - za = rng.standard_normal(n) - zb = rng.standard_normal(n) - theta = za - tau = rho * sig * za + sig * np.sqrt(1 - rho * rho) * zb - pr = 1.0 / (1.0 + np.exp(-(a[None, :] * theta[:, None] + b[None, :]))) - resp = (rng.random((n, m)) < pr).astype(float) - y = beta[None, :] - tau[:, None] + rng.standard_normal((n, m)) / alpha[None, :] - return resp, np.exp(y) - - resp, times = sim(0.5) - res = fit_speed_accuracy(resp, times, a, b, alpha, beta) - assert res["converged"] - assert res["termination_reason"] == "converged" - assert res["n_iter"] < 500 - assert res["final_loglik_change"] < 1e-6 - assert np.isfinite(res["loglik_trace"]).all() - assert np.all(np.diff(res["loglik_trace"]) >= -1e-6 * np.maximum(np.abs(res["loglik_trace"][:-1]), 1)) - assert res["loglik"] == res["loglik_trace"][-1] - assert abs(res["rho"] - 0.5) < 0.1, res["rho"] - assert abs(res["sigma_tau"] - 0.3) < 0.05 - assert res["theta_eap"].shape == (n,) and res["tau_eap"].shape == (n,) - - # true independence -> rho ~ 0 - r0, t0 = sim(0.0) - res0 = fit_speed_accuracy(r0, t0, a, b, alpha, beta) - assert res0["converged"] - assert res0["termination_reason"] == "converged" - assert res0["final_loglik_change"] < 1e-6 - assert abs(res0["rho"]) < 0.08, res0["rho"] - - # works with a fitted RT model's alpha/beta - rt = fit_response_times(times) - res_rt = fit_speed_accuracy(resp, times, a, b, rt.alpha, rt.beta) - assert res_rt["converged"] - assert res_rt["termination_reason"] == "converged" - assert res_rt["final_loglik_change"] < 1e-6 - assert abs(res_rt["rho"] - 0.5) < 0.15 - - with pytest.warns(RuntimeWarning, match="max_iter_reached"): - res_nc = fit_speed_accuracy( - resp, - times, - a, - b, - alpha, - beta, - q=7, - max_iter=1, - ) - assert not res_nc["converged"] - assert res_nc["termination_reason"] == "max_iter_reached" - assert res_nc["n_iter"] == 1 - assert res_nc["final_loglik_change"] >= 1e-6 - - with pytest.raises(RuntimeError, match="max_iter_reached"): - fit_speed_accuracy( - resp, - times, - a, - b, - alpha, - beta, - q=7, - max_iter=1, - require_convergence=True, - ) - - with pytest.raises(ValueError): - fit_speed_accuracy(resp.ravel(), times, a, b, alpha, beta) # not 2-D - - with pytest.raises(ValueError, match="observed"): - fit_speed_accuracy( - np.full((1, 1), np.nan), - np.full((1, 1), np.nan), - np.ones(1), - np.zeros(1), - np.ones(1), - np.ones(1), - ) - - with pytest.raises(ValueError, match="discrimination"): - fit_speed_accuracy( - np.array([[0.0], [1.0]]), - np.array([[2.0], [1.8]]), - np.zeros(1), - np.zeros(1), - np.ones(1), - np.ones(1), - ) - - with pytest.raises(ValueError, match="non-finite"): - fit_speed_accuracy( - np.array([[0.0], [1.0]]), - np.array([[2.0], [1.8]]), - np.ones(1), - np.zeros(1), - np.array([1e308]), - np.ones(1), - ) - - -def test_rt_person_fit(): - """RT person fit (van der Linden & Guo, 2008): W ~ chi2(n-1) with l_t ~ N(0,1) - on model-consistent data, and rapid-guessing responders flagged.""" - import numpy as np - import pytest - from fast_mlsirm import fit_response_times, rt_person_fit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "rt_person_fit"): - pytest.skip("compiled core built without rt_person_fit") - - rng = np.random.default_rng(3) - n, m = 2000, 20 - beta = np.linspace(3.5, 4.5, m) - alpha = np.linspace(1.0, 3.0, m) - tau = 0.3 * rng.standard_normal(n) - y = beta[None, :] - tau[:, None] + rng.standard_normal((n, m)) / alpha[None, :] - # first 10% rapid-guess on the last 7 items - n_ab = n // 10 - for p in range(n_ab): - y[p, -7:] = (beta[-7:] - tau[p]) - 2.5 + 0.3 * rng.standard_normal(7) - times = np.exp(y) - - # exact calibration with the (uncontaminated) item parameters: W ~ chi2(n-1), - # l_t ~ N(0,1) on the clean responders, ~.05 Type I, high power - pf = rt_person_fit(times, alpha, beta) - assert pf["w"].shape == (n,) and pf["z_resid"].shape == (n, m) - clean = pf["l_t"][n_ab:] - assert abs(clean.mean()) < 0.15 and 0.8 < clean.std() < 1.25 - assert pf["flagged"][n_ab:].mean() < 0.12 - assert pf["flagged"][:n_ab].mean() > 0.7 - # the tampered items are flagged too-fast (strongly negative residual) - assert pf["item_flag"][:n_ab, -7:].mean() > 0.7 - assert np.all(pf["z_resid"][:n_ab, -7:] < 0) # too-fast = negative - - # with a fitted RT model (production path) the aberrant are still detected; - # fitting on the contaminated sample makes the clean responders conservative - fit = fit_response_times(times) - pf2 = rt_person_fit(times, fit.alpha, fit.beta) - assert pf2["flagged"][:n_ab].mean() > 0.6 - - with pytest.raises(ValueError): - rt_person_fit(times.ravel(), alpha, beta) # not 2-D - with pytest.raises(ValueError, match="finite positive squares"): - rt_person_fit(np.array([[1.0, 2.0]]), np.array([1e308, 1.0]), np.zeros(2)) - with pytest.raises(ValueError, match="finite positive squares"): - rt_person_fit(np.array([[1.0, 2.0]]), np.full(2, 1e-308), np.zeros(2)) - with pytest.raises(ValueError, match="non-finite"): - rt_person_fit(np.array([[1.0, 2.0]]), np.ones(2), np.full(2, 1e308)) - - -def _sim_cdm(rng, q, s, g, profiles, model="dina"): - """Simulate DINA/DINO responses for the given bit-encoded true profiles.""" - n, (n_items, k) = len(profiles), q.shape - y = np.empty((n, n_items)) - for j in range(n): - for i in range(n_items): - mask = int(np.dot(q[i], 1 << np.arange(k))) - c = int(profiles[j]) - eta = (c & mask) == mask if model == "dina" else (c & mask) != 0 - p = 1.0 - s[i] if eta else g[i] - y[j, i] = 1.0 if rng.random() < p else 0.0 - return y - - -def test_fit_cdm_dina_recovers_and_classifies(): - """DINA cognitive diagnosis (de la Torre, 2009): recover slip/guess and classify - attribute mastery under a known Q-matrix; DINO reduces to DINA on single-attribute - items.""" - import numpy as np - import pytest - from fast_mlsirm import fit_cdm, CdmFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_cdm"): - pytest.skip("compiled core built without fit_cdm") - - rng = np.random.default_rng(11) - k, n_items, n = 3, 15, 1500 - # 6 single-attribute items (2 per attribute) + pairs + one triple. - rows = [] - for a in range(k): - rows += [[1 if t == a else 0 for t in range(k)]] * 2 - rows += [[1, 1, 0], [0, 1, 1], [1, 0, 1], [1, 1, 0], [0, 1, 1], [1, 0, 1], [1, 1, 1], [1, 1, 1], [1, 0, 1]] - q = np.array(rows[:n_items], dtype=np.int64) - s = np.full(n_items, 0.15) - g = np.full(n_items, 0.15) - profiles = rng.integers(0, 1 << k, size=n) - y = _sim_cdm(rng, q, s, g, profiles) - - res = fit_cdm(y, q, model="dina") - assert isinstance(res, CdmFit) and res.converged - assert np.all(np.diff(res.loglik_trace) >= -1e-6) # monotone ascent - assert np.all(1.0 - res.slip > res.guess) # identification - assert np.sqrt(np.mean((res.slip - s) ** 2)) < 0.05 - assert np.sqrt(np.mean((res.guess - g) ** 2)) < 0.05 - # attribute classification agreement (marginal mastery vs truth) - true_bits = ((profiles[:, None] >> np.arange(k)) & 1) - attr_ok = (res.attribute_mastery() == true_bits).mean() - assert attr_ok > 0.85, attr_ok - # pattern-wise agreement (exact 3-bit profile) - assert (res.map_profile == profiles).mean() > 0.75 - assert res.n_parameters == 2 * n_items + ((1 << k) - 1) - assert res.profile_bits().shape == (n, k) - - # single-attribute Q => DINA and DINO share identical eta and thus identical fits. - q1 = np.array([[1, 0], [1, 0], [0, 1], [0, 1]], dtype=np.int64) - prof1 = rng.integers(0, 4, size=800) - y1 = _sim_cdm(rng, q1, np.full(4, 0.2), np.full(4, 0.2), prof1) - a = fit_cdm(y1, q1, model="dina") - b = fit_cdm(y1, q1, model="dino") - assert np.allclose(a.slip, b.slip, atol=1e-9) - assert np.allclose(a.guess, b.guess, atol=1e-9) - - # missing-at-random cells are dropped, not imputed. - ym = y.copy() - ym[rng.random(ym.shape) < 0.15] = np.nan - resm = fit_cdm(ym, q, model="dina") - assert resm.converged and np.all(1.0 - resm.slip > resm.guess) - - with pytest.raises(ValueError): - fit_cdm(y.ravel(), q) # responses not 2-D - with pytest.raises(ValueError): - fit_cdm(y, q, model="rasch") # unknown gate - with pytest.raises(ValueError): - fit_cdm(y, np.zeros((n_items, k), dtype=np.int64)) # all-zero Q rows/cols - - -def test_fit_gdina_recovers_saturated_and_reduces_to_dina(): - """Saturated G-DINA (de la Torre, 2011): recover free reduced-class success - probabilities, and confirm DINA-generated data yields the DINA identity-link - pattern (only intercept + highest-order interaction nonzero).""" - import numpy as np - import pytest - from fast_mlsirm import fit_gdina, GdinaFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_gdina"): - pytest.skip("compiled core built without fit_gdina") - - def reduce_class(c, qmask, k): - l, m = 0, 0 - for bit in range(k): - if (qmask >> bit) & 1: - l |= ((c >> bit) & 1) << m - m += 1 - return l - - rng = np.random.default_rng(2011) - k, n_items, n = 2, 12, 2500 - q = np.zeros((n_items, k), dtype=np.int64) - for i in range(n_items): - if i < 4: - q[i, 0] = 1 - elif i < 8: - q[i, 1] = 1 - else: - q[i] = [1, 1] - qmask = [int(np.dot(q[i], 1 << np.arange(k))) for i in range(n_items)] - item_off = np.concatenate([[0], np.cumsum([1 << int(q[i].sum()) for i in range(n_items)])]) - - # DINA truth in CSR layout: top reduced class = 1 - s, all others = g. - s, g = 0.15, 0.2 - truth = np.empty(item_off[-1]) - for i in range(n_items): - truth[item_off[i] : item_off[i + 1]] = g - truth[item_off[i + 1] - 1] = 1.0 - s - profiles = rng.integers(0, 1 << k, size=n) - y = np.empty((n, n_items)) - for j in range(n): - for i in range(n_items): - p = truth[item_off[i] + reduce_class(int(profiles[j]), qmask[i], k)] - y[j, i] = 1.0 if rng.random() < p else 0.0 - - res = fit_gdina(y, q) - assert isinstance(res, GdinaFit) and res.converged - assert np.all(np.diff(res.loglik_trace) >= -1e-6) - assert np.sqrt(np.mean((res.item_prob - truth) ** 2)) < 0.03 - assert res.n_parameters == int(item_off[-1]) + ((1 << k) - 1) - # DINA identity-link pattern per item: delta_0 ~ g, delta_full ~ (1-s)-g, mids ~ 0. - for i in range(n_items): - d = res.item_delta_row(i) - assert abs(d[0] - g) < 0.05 - assert abs(d[-1] - ((1.0 - s) - g)) < 0.05 - if len(d) > 2: - assert np.all(np.abs(d[1:-1]) < 0.05) - # For this DINA-generated monotone truth, the all-mastered class is highest; - # unconstrained saturated G-DINA does not guarantee this for arbitrary data. - for i in range(n_items): - row = res.item_prob_row(i) - assert row[-1] >= row.max() - 1e-9 - - # missing-at-random cells are dropped. - ym = y.copy() - ym[rng.random(ym.shape) < 0.15] = np.nan - assert fit_gdina(ym, q).converged - - with pytest.raises(ValueError): - fit_gdina(y.ravel(), q) # responses not 2-D - with pytest.raises(ValueError): - fit_gdina(y, np.zeros((n_items, k), dtype=np.int64)) # all-zero Q rows/cols - - -def test_fit_gdina_does_not_claim_or_project_order_restrictions(): - """The saturated estimator leaves G-DINA order restrictions unconstrained.""" - import numpy as np - import pytest - from fast_mlsirm import fit_gdina - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_gdina"): - pytest.skip("compiled core built without fit_gdina") - - rng = np.random.default_rng(20260720) - k, n_items, n = 2, 12, 1500 - q = np.zeros((n_items, k), dtype=np.int64) - q[:4, 0] = 1 - q[4:8, 1] = 1 - q[8:, :] = 1 - profiles = rng.integers(0, 1 << k, size=n) - y = np.empty((n, n_items), dtype=float) - joint_prob = np.array([0.1, 0.8, 0.7, 0.2]) - for person, profile in enumerate(profiles): - for item in range(n_items): - if item < 4: - prob = (0.2, 0.8)[profile & 1] - elif item < 8: - prob = (0.2, 0.8)[(profile >> 1) & 1] - else: - prob = joint_prob[profile] - y[person, item] = rng.random() < prob - - result = fit_gdina(y, q, max_iter=500, tol=1e-6) - delta = np.diff(result.loglik_trace) - assert result.converged and result.n_iter < 500 - assert np.isfinite(result.loglik_trace).all() - assert np.all(delta >= -1e-6) - assert abs(delta[-1]) < 1e-6 - for item in range(8, n_items): - row = result.item_prob_row(item) - assert row[-1] < row[1:-1].max() - 0.3 - - -def test_validate_q_matrix_corrects_misspecification(): - """PVAF Q-matrix validation (de la Torre & Chiu, 2016): the true Q validates to - itself, and a Q with an over-specified and an under-specified item is corrected - back to the truth while flagging exactly those items.""" - import numpy as np - import pytest - from fast_mlsirm import validate_q_matrix, QMatrixValidation - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "validate_q_matrix"): - pytest.skip("compiled core built without validate_q_matrix") - - rng = np.random.default_rng(715) - k, n_items, n = 3, 15, 3000 - rows = [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 0], [0, 1, 0], [0, 0, 1], - [1, 1, 0], [1, 0, 1], [0, 1, 1], [1, 1, 0], [1, 0, 1], [0, 1, 1], - [1, 1, 1], [1, 1, 1], [1, 1, 1]] - truth = np.array(rows, dtype=np.int64) - s = np.full(n_items, 0.1) - g = np.full(n_items, 0.1) - profiles = rng.integers(0, 1 << k, size=n) - y = _sim_cdm(rng, truth, s, g, profiles) - - # Anchor: the true Q validates to itself, nothing flagged. - res = validate_q_matrix(y, truth, epsilon=0.95) - assert isinstance(res, QMatrixValidation) - assert np.array_equal(res.suggested_q, truth) - assert not res.flagged.any() - assert np.all(res.provisional_pvaf > 0.9) - assert res.calibration_termination_reason == "tolerance_met" - assert 0 < res.calibration_n_iter < res.calibration_max_iter - assert 0 <= res.calibration_final_loglik_change < res.calibration_tol - - # Over-specify item 0 ({0} -> {0,1}) and under-specify item 6 ({0,1} -> {0}). - prov = truth.copy() - prov[0, 1] = 1 - prov[6, 1] = 0 - res2 = validate_q_matrix(y, prov, epsilon=0.95) - assert np.array_equal(res2.suggested_q[0], truth[0]) # trimmed back - assert np.array_equal(res2.suggested_q[6], truth[6]) # enlarged back - assert res2.flagged[0] and res2.flagged[6] - # the under-specified item's provisional q falls short of the cutoff - assert res2.provisional_pvaf[6] < 0.95 - assert res2.calibration_termination_reason == "tolerance_met" - assert 0 < res2.calibration_n_iter < res2.calibration_max_iter - assert 0 <= res2.calibration_final_loglik_change < res2.calibration_tol - - with pytest.raises(ValueError): - validate_q_matrix(y.ravel(), truth) # responses not 2-D - with pytest.raises(ValueError): - validate_q_matrix(y, truth, epsilon=1.5) # epsilon out of range - with pytest.raises( - ValueError, - match=r"G-DINA calibration did not converge after 1 of 1 M-steps", - ): - validate_q_matrix(y, truth, max_iter=1, tol=1e-12) - - -def test_gdina_wald_selection_classifies_items(): - """Item-level Wald model selection (de la Torre, 2011; de la Torre & Lee, 2013): - a conjunctive (DINA), disjunctive (DINO), identity-additive (A-CDM), logit-additive - (LLM), and log-additive (R-RUM) item are each classified as their reduced model, - and an item with both main effects and an interaction keeps the saturated G-DINA. - The LLM and R-RUM truths are additive ONLY on their own link (identity- and - cross-link-nonadditive), so classifying them correctly exercises the link-transformed - delta and its delta-method covariance, not just the identity link.""" - import numpy as np - import pytest - from fast_mlsirm import gdina_wald_selection, WaldModelSelection - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "gdina_wald_selection"): - pytest.skip("compiled core built without gdina_wald_selection") - - def sig(x): - return 1.0 / (1.0 + np.exp(-x)) - - rng = np.random.default_rng(2011) - k, n = 2, 8000 - # 5 single-attribute items per attribute (identification) + 6 pair items: - # DINA, DINO, A-CDM, LLM, R-RUM, saturated. - rows = [[1, 0]] * 5 + [[0, 1]] * 5 + [[1, 1]] * 6 - q = np.array(rows, dtype=np.int64) - n_items = q.shape[0] - # per reduced-class truth [none, a0, a1, both] - truth_pair = {10: [0.15, 0.15, 0.15, 0.85], # DINA (conjunctive) - 11: [0.15, 0.85, 0.85, 0.85], # DINO (disjunctive) - 12: [0.10, 0.45, 0.45, 0.80], # A-CDM (identity-additive) - # LLM: logit(P) = -3 + 2 a0 + 2 a1 (logit-additive, identity- & - # log-nonadditive). - 13: [sig(-3.0), sig(-1.0), sig(-1.0), sig(1.0)], - # R-RUM: P = 0.92 * 0.3^(1-a0) * 0.4^(1-a1) (log-additive, identity- & - # logit-nonadditive). - 14: [0.92 * 0.3 * 0.4, 0.92 * 0.4, 0.92 * 0.3, 0.92], - 15: [0.10, 0.35, 0.35, 0.90]} # saturated - profiles = rng.integers(0, 1 << k, size=n) - y = np.empty((n, n_items)) - for j in range(n): - c = int(profiles[j]) - for i in range(n_items): - if i < 10: - a = i // 5 # attribute of this single item - p = 0.85 if (c >> a) & 1 else 0.15 - else: - l = (c & 1) + 2 * ((c >> 1) & 1) # reduced class for a {0,1} item - p = truth_pair[i][l] - y[j, i] = 1.0 if rng.random() < p else 0.0 - - res = gdina_wald_selection(y, q, alpha=0.05) - assert isinstance(res, WaldModelSelection) - assert res.models == ["dina", "dino", "acdm", "llm", "rrum"] - assert res.selected[10] == 0 # DINA - assert res.selected[11] == 1 # DINO - assert res.selected[12] == 2 # A-CDM - assert res.selected[13] == 3 # LLM (logit link) - assert res.selected[14] == 4 # R-RUM (log link) - assert res.selected[15] == -1 # saturated G-DINA - # single-attribute items carry no test (df 0), keep saturated - assert np.all(res.selected[:10] == -1) - assert np.all(res.wald_df[:10] == 0) - # the tested pair items have the right degrees of freedom (K=2): DINA & DINO - # df = 2^K-2 = 2; A-CDM, LLM, R-RUM df = 2^K-1-K = 1. - assert res.wald_df[10, 0] == 2 and res.wald_df[10, 1] == 2 - assert res.wald_df[10, 2] == 1 and res.wald_df[10, 3] == 1 and res.wald_df[10, 4] == 1 - - with pytest.raises(ValueError): - gdina_wald_selection(y.ravel(), q) # responses not 2-D - with pytest.raises(ValueError): - gdina_wald_selection(y, q, alpha=0.0) # alpha out of range - with pytest.raises( - ValueError, - match=r"G-DINA calibration did not converge after 1 of 1 M-steps", - ): - gdina_wald_selection(y, q, max_iter=1, tol=1e-12) - - -def test_fit_ho_cdm_recovers_higher_order_structure(): - """Higher-order DINA (de la Torre & Douglas, 2004): a continuous trait structures - attribute mastery; recover the attribute slopes/intercepts, slip/guess, and - classification, and confirm the slope-zero reduction to independent attributes.""" - import numpy as np - import pytest - from fast_mlsirm import fit_ho_cdm, HoCdmFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_ho_cdm"): - pytest.skip("compiled core built without fit_ho_cdm") - - rng = np.random.default_rng(2004) - k, n = 3, 4000 - # 4 single-attribute items per attribute + 3 pair items (all attributes identified) - rows = [] - for a in range(k): - for _ in range(4): - rows.append([1 if t == a else 0 for t in range(k)]) - rows += [[1, 1, 0], [0, 1, 1], [1, 0, 1]] - q = np.array(rows, dtype=np.int64) - n_items = q.shape[0] - a_true = np.array([1.2, 1.5, 0.9]) - d_true = np.array([0.3, -0.5, 0.6]) - s, g = np.full(n_items, 0.12), np.full(n_items, 0.12) - - theta = rng.standard_normal(n) - alpha = (rng.random((n, k)) < 1.0 / (1.0 + np.exp(-(theta[:, None] * a_true + d_true)))).astype(int) - codes = (alpha * (1 << np.arange(k))).sum(1) - y = np.empty((n, n_items)) - for j in range(n): - c = int(codes[j]) - for i in range(n_items): - mask = int(np.dot(q[i], 1 << np.arange(k))) - eta = (c & mask) == mask - p = 1.0 - s[i] if eta else g[i] - y[j, i] = 1.0 if rng.random() < p else 0.0 - - res = fit_ho_cdm(y, q, model="dina") - assert isinstance(res, HoCdmFit) and res.converged - assert res.termination_reason == "tolerance_met" - assert 0 < res.n_iter < res.max_iter == 500 - assert 0 <= res.final_loglik_change < res.stopping_tolerance == 1e-6 - assert np.all(np.diff(res.loglik_trace) >= -1e-6) # monotone ascent - assert res.n_parameters == 2 * n_items + 2 * k - assert abs(res.profile_prob.sum() - 1.0) < 1e-9 - assert np.all(res.attr_slope > 0) # anchored non-negative - assert np.sqrt(np.mean((res.slip - s) ** 2)) < 0.05 - assert np.sqrt(np.mean((res.attr_slope - a_true) ** 2)) < 0.4 # identified at K=3 - assert np.sqrt(np.mean((res.attr_intercept - d_true) ** 2)) < 0.3 - # attribute classification agreement - est = res.attribute_mastery() - assert (est == alpha).mean() > 0.85 - - limited = fit_ho_cdm(y, q, model="dina", max_iter=1, tol=1e-12) - assert not limited.converged - assert limited.termination_reason == "max_iter_reached" - assert limited.n_iter == limited.max_iter == 1 - assert np.isfinite(limited.final_loglik_change) - assert limited.final_loglik_change >= limited.stopping_tolerance == 1e-12 - - with pytest.raises(ValueError): - fit_ho_cdm(y.ravel(), q) # responses not 2-D - with pytest.raises(ValueError): - fit_ho_cdm(y, q, model="rasch") # unknown gate - - -def test_fit_ho_gdina_recovers_saturated_and_structure(): - """Higher-order G-DINA (de la Torre & Douglas, 2004; de la Torre, 2011): a free - saturated item fit of DINA-patterned data recovers the DINA identity-link delta - and the higher-order attribute parameters.""" - import numpy as np - import pytest - from fast_mlsirm import fit_ho_gdina, HoGdinaFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_ho_gdina"): - pytest.skip("compiled core built without fit_ho_gdina") - - rng = np.random.default_rng(2011) - k, n = 3, 3000 - rows = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] * 3 # 9 singles - rows += [[1, 1, 0], [0, 1, 1], [1, 0, 1], [1, 1, 0], [0, 1, 1]] # 5 pairs - rows += [[1, 1, 1]] # 1 triple - q = np.array(rows, dtype=np.int64) - n_items = q.shape[0] - s, g = 0.15, 0.2 - a_true = np.array([1.2, 1.5, 0.9]) - d_true = np.array([0.3, -0.5, 0.6]) - - theta = rng.standard_normal(n) - alpha = (rng.random((n, k)) < 1.0 / (1.0 + np.exp(-(theta[:, None] * a_true + d_true)))).astype(int) - codes = (alpha * (1 << np.arange(k))).sum(1) - y = np.empty((n, n_items)) - for j in range(n): - c = int(codes[j]) - for i in range(n_items): - mask = int(np.dot(q[i], 1 << np.arange(k))) - eta = (c & mask) == mask # DINA gate - p = (1.0 - s) if eta else g - y[j, i] = 1.0 if rng.random() < p else 0.0 - - res = fit_ho_gdina(y, q) - assert isinstance(res, HoGdinaFit) and res.converged - assert res.termination_reason == "tolerance_met" - assert res.final_relative_loglik_change < res.stopping_tolerance - assert np.all(np.diff(res.loglik_trace) >= -1e-6) - assert np.all(res.attr_slope > 0) # anchored non-negative - assert abs(res.profile_prob.sum() - 1.0) < 1e-9 - # the triple item's identity-link delta shows the DINA pattern (intercept + top) - triple = n_items - 1 - dl = res.item_delta[res.item_off[triple] : res.item_off[triple + 1]] - assert abs(dl[0] - g) < 0.06 and abs(dl[-1] - ((1.0 - s) - g)) < 0.06 - assert np.all(np.abs(dl[1:-1]) < 0.06) - # higher-order parameter recovery (identified at K=3) - assert np.sqrt(np.mean((res.attr_slope - a_true) ** 2)) < 0.45 - # attribute classification - est = (res.attr_prob >= 0.5).astype(int) - assert (est == alpha).mean() > 0.9 - - with pytest.raises(ValueError): - fit_ho_gdina(y.ravel(), q) # not 2-D - with pytest.raises(ValueError, match="at least 3 attributes"): - fit_ho_gdina(y[:, :2], np.eye(2, dtype=np.int64)) - for bad in (np.inf, -np.inf): - malformed = y.copy() - malformed[0, 0] = bad - with pytest.raises(ValueError, match="only 0, 1, or NaN"): - fit_ho_gdina(malformed, q) - - limited = y[:100].copy() - limited[0, 0] = np.nan - unfinished = fit_ho_gdina(limited, q, max_iter=1, tol=1e-12) - assert not unfinished.converged - assert unfinished.n_iter == 1 - assert unfinished.termination_reason == "max_iter_reached" - assert np.isfinite(unfinished.final_loglik_change) - assert np.isfinite(unfinished.final_relative_loglik_change) - - -def test_fit_seq_gdina_recovers_polytomous_and_reduces_to_gdina(): - """Shared-Q sequential G-DINA (Ma & de la Torre, 2016): recover the ordered-category - step and category probabilities of a polytomous item, and confirm that binary data - (one step per item) reduces exactly to :func:`fit_gdina`. The M=2 truth is additive - on neither trivial link — its two step tables are distinct and asymmetric, so the - ordered structure (not a degenerate collapse) is what is recovered.""" - import numpy as np - import pytest - from fast_mlsirm import fit_seq_gdina, SeqGdinaFit, fit_gdina - from fast_mlsirm.fitstats import _core_module - - for public_doc in (SeqGdinaFit.__doc__, fit_seq_gdina.__doc__): - assert "fit_seq_gdina_qr" in (public_doc or "") - assert "deferred non-goal" not in (public_doc or "") - - core = _core_module() - if core is None or not hasattr(core, "fit_seq_gdina"): - pytest.skip("compiled core built without fit_seq_gdina") - - rng = np.random.default_rng(2016) - k, n = 2, 6000 - # 4 single-attribute items per attribute (M=1 identification) + 4 pair M=2 items. - rows = [[1, 0]] * 4 + [[0, 1]] * 4 + [[1, 1]] * 4 - q = np.array(rows, dtype=np.int64) - n_items = q.shape[0] - # per reduced-class step tables (class-major [00,10,01,11]); asymmetric, increasing. - pair_s1 = {0: 0.25, 1: 0.55, 2: 0.50, 3: 0.85} - pair_s2 = {0: 0.15, 1: 0.30, 2: 0.25, 3: 0.70} - profiles = rng.integers(0, 1 << k, size=n) - y = np.zeros((n, n_items)) - for j in range(n): - c = int(profiles[j]) - for i in range(n_items): - if i < 8: - a = i // 4 - p = 0.85 if (c >> a) & 1 else 0.15 - y[j, i] = 1.0 if rng.random() < p else 0.0 - else: - l = (c & 1) + 2 * ((c >> 1) & 1) - cat = 0 - if rng.random() < pair_s1[l]: - cat = 1 - if rng.random() < pair_s2[l]: - cat = 2 - y[j, i] = float(cat) - - res = fit_seq_gdina(y, q) - assert isinstance(res, SeqGdinaFit) and res.converged - assert res.termination_reason == "tolerance_met" - assert abs(res.final_loglik_change) < res.stopping_tolerance - assert np.isfinite(res.final_relative_loglik_change) - assert res.max_cat.tolist() == [1] * 8 + [2] * 4 # M_i derived from data - assert abs(res.profile_prob.sum() - 1.0) < 1e-9 - # category probabilities sum to 1 per (item, reduced class) - for i in range(n_items): - cp = res.item_cat_prob(i) - assert cp.shape == (1 << int(res.k_required[i]), int(res.max_cat[i]) + 1) - assert np.allclose(cp.sum(axis=1), 1.0, atol=1e-9) - # recover the pair items' category probabilities (stable, model-predicted quantity) - for i in range(8, n_items): - cp = res.item_cat_prob(i) # 4 classes x 3 categories - for l in range(4): - s1, s2 = pair_s1[l], pair_s2[l] - truth = np.array([1 - s1, s1 * (1 - s2), s1 * s2]) - assert np.max(np.abs(cp[l] - truth)) < 0.04, f"item{i} class{l}: {cp[l]} vs {truth}" - # attribute classification - est = (res.attr_prob >= 0.5).astype(int) - alpha = ((profiles[:, None] >> np.arange(k)) & 1) - assert (est == alpha).mean() > 0.9 - - # Binary data (M_i = 1 for all items) reduces to fit_gdina bit-for-bit. - ybin = (y[:, :8] > 0).astype(float) - qbin = q[:8] - sq1 = fit_seq_gdina(ybin, qbin) - g1 = fit_gdina(ybin, qbin) - assert sq1.max_cat.tolist() == [1] * 8 - assert np.allclose(sq1.step_prob, g1.item_prob, atol=1e-12) - assert len(sq1.loglik_trace) == len(g1.loglik_trace) - assert np.allclose(sq1.loglik_trace, g1.loglik_trace, atol=1e-12) - - # Validation: an item stuck at category 0 (measures nothing) is rejected; a - # non-integer category is rejected; missing (NaN) is allowed. - with pytest.raises(ValueError): - fit_seq_gdina(y.ravel(), q) # not 2-D - yzero = y.copy() - yzero[:, 8] = 0.0 - with pytest.raises(ValueError, match="never leaves category 0"): - fit_seq_gdina(yzero, q) - ybad = y.copy() - ybad[0, 8] = 1.5 - with pytest.raises(ValueError, match="non-negative integer category"): - fit_seq_gdina(ybad, q) - ymiss = y.copy() - ymiss[0, 0] = np.nan - assert fit_seq_gdina(ymiss, q).converged - - unfinished = fit_seq_gdina(y[:100], q, max_iter=1, tol=1e-12) - assert not unfinished.converged - assert unfinished.n_iter == 1 - assert unfinished.termination_reason == "max_iter_reached" - assert np.isfinite(unfinished.final_loglik_change) - assert np.isfinite(unfinished.final_relative_loglik_change) - assert unfinished.stopping_tolerance == 1e-12 - - -def test_fit_seq_gdina_qr_per_step_q_reduces_and_recovers_structure(): - """Per-step-Q sequential G-DINA (Ma & de la Torre, 2016, restricted-Q full model): - each ordered step of an item may require its OWN attributes. Three guards: - - (1) SHARED-Q REDUCTION -- expanding every step of an item to the item's Q reproduces - :func:`fit_seq_gdina` BIT-EXACTLY (layout-aware step_prob compare: item-major - ``s_off[i]+l*M_i+(k-1)`` vs step-row-major ``spo[step_off[i]+(k-1)]+l``; cat_prob - and loglik_trace are class-major and compared directly). A dimension-map, layout, - or union-collapse bug fails this exact-zero guard. - (2) STRUCTURE -- four items have step1 q={A} (block width 2^1=2), step2 q={A,B} - (width 2^2=4), with four single-step anchors per attribute for identification. The - per-step widths and n_parameters must reflect the distinct step Qs, NOT a single - union block. A large B-contrast in step 2 (s2(A1,B0)=0.20 vs s2(A1,B1)=0.80, - gap 0.60) is recovered (gap >= 0.4) while the union stays lossless. Value recovery - alone can't catch an over-collapse to the union; the width assertions can. - (3) VALIDATION -- all-zero step row (a step measuring nothing), an attribute used by no - step (all-zero union column), and n_steps != observed max are all rejected.""" - import numpy as np - import pytest - from fast_mlsirm import fit_seq_gdina, fit_seq_gdina_qr, SeqGdinaQrFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_seq_gdina_qr"): - pytest.skip("compiled core built without fit_seq_gdina_qr") - - rng = np.random.default_rng(2016_3) - k, n = 2, 6000 - # 4 single-attribute M=1 items per attribute (identification) + 4 shared-Q M=2 pairs. - rows = [[1, 0]] * 4 + [[0, 1]] * 4 + [[1, 1]] * 4 - q = np.array(rows, dtype=np.int64) - n_items = q.shape[0] - pair_s1 = {0: 0.25, 1: 0.55, 2: 0.50, 3: 0.85} - pair_s2 = {0: 0.15, 1: 0.30, 2: 0.25, 3: 0.70} - profiles = rng.integers(0, 1 << k, size=n) - y = np.zeros((n, n_items)) - for j in range(n): - c = int(profiles[j]) - for i in range(n_items): - if i < 8: - a = i // 4 - p = 0.85 if (c >> a) & 1 else 0.15 - y[j, i] = 1.0 if rng.random() < p else 0.0 - else: - l = (c & 1) + 2 * ((c >> 1) & 1) - cat = 0 - if rng.random() < pair_s1[l]: - cat = 1 - if rng.random() < pair_s2[l]: - cat = 2 - y[j, i] = float(cat) - - # (1) Shared-Q reduction: expand item Q into per-step rows sharing the item Q. - n_steps = np.array([1] * 8 + [2] * 4, dtype=np.int64) - step_rows = [] - for i in range(n_items): - for _ in range(int(n_steps[i])): - step_rows.append(q[i]) - step_q = np.vstack(step_rows).astype(np.int64) - - sh = fit_seq_gdina(y, q, tol=1e-8) - qr = fit_seq_gdina_qr(y, step_q, n_steps, tol=1e-8) - assert isinstance(qr, SeqGdinaQrFit) and qr.converged - assert qr.termination_reason == "tolerance_met" - assert qr.max_cat.tolist() == [1] * 8 + [2] * 4 - # cat_prob and loglik_trace are class-major: direct bit-exact compare. - assert sh.cat_prob.shape == qr.cat_prob.shape - assert np.array_equal(sh.cat_prob, qr.cat_prob) - assert len(sh.loglik_trace) == len(qr.loglik_trace) - assert np.array_equal(sh.loglik_trace, qr.loglik_trace) - # step_prob layouts are transposed: cell-by-cell exact-zero difference. - for i in range(n_items): - Mi = int(n_steps[i]) - width = 1 << int(q[i].sum()) - for l in range(width): - for kk in range(1, Mi + 1): - sh_val = sh.step_prob[int(sh.s_off[i]) + l * Mi + (kk - 1)] - qr_val = qr.item_step_prob(i, kk)[l] - assert sh_val == qr_val, f"item{i} l{l} k{kk}: {sh_val} vs {qr_val}" - - # (2) Structure: distinct per-step Qs the shared-Q model cannot represent. Four - # polytomous items have step1={A}, step2={A,B}; four M=1 anchors per attribute - # identify both dimensions and avoid treating a likelihood ridge as recovery. - n_pair2, n_anchor2 = 4, 4 - step_q2 = np.array( - [[1, 0], [1, 1]] * n_pair2 - + [[1, 0]] * n_anchor2 - + [[0, 1]] * n_anchor2, - dtype=np.int64, - ) - n_steps2 = np.array([2] * n_pair2 + [1] * (2 * n_anchor2), dtype=np.int64) - s2_by_class = {0: 0.15, 1: 0.20, 2: 0.30, 3: 0.80} # big B-contrast at A=1 - n2 = 4000 - al2 = rng.integers(0, 2, size=(n2, k)) - Y2 = np.zeros((n2, n_pair2 + 2 * n_anchor2)) - for j in range(n2): - a0, a1 = int(al2[j, 0]), int(al2[j, 1]) - for i in range(n_pair2): - if rng.random() < (0.25 + 0.5 * a0): - Y2[j, i] = 1 - rcAB = a0 + 2 * a1 - if rng.random() < s2_by_class[rcAB]: - Y2[j, i] = 2 - for i in range(n_pair2, n_pair2 + n_anchor2): - Y2[j, i] = 1.0 if rng.random() < (0.2 + 0.6 * a0) else 0.0 - for i in range(n_pair2 + n_anchor2, Y2.shape[1]): - Y2[j, i] = 1.0 if rng.random() < (0.2 + 0.6 * a1) else 0.0 - qr2 = fit_seq_gdina_qr(Y2, step_q2, n_steps2, max_iter=1000, tol=1e-6) - assert qr2.converged and qr2.termination_reason == "tolerance_met" - assert qr2.n_iter < 1000 - assert abs(qr2.final_loglik_change) < qr2.stopping_tolerance - assert np.isfinite(qr2.loglik_trace).all() - assert np.min(np.diff(qr2.loglik_trace)) >= -1e-8 - # per-step block widths reflect the distinct Qs (2 and 4), not a single union block. - assert len(qr2.item_step_prob(0, 1)) == 2 - assert len(qr2.item_step_prob(0, 2)) == 4 - assert qr2.step_kq.tolist() == [1, 2] * n_pair2 + [1] * (2 * n_anchor2) - # n_parameters = total step cells + (2^K - 1) free profile weights. - assert qr2.n_parameters == n_pair2 * (2 + 4) + 2 * n_anchor2 * 2 + ((1 << k) - 1) - # large B-contrast recovered in step 2 (class A1B1 minus A1B0). - s2 = qr2.item_step_prob(0, 2) - assert s2[3] - s2[1] >= 0.4, f"B-gap too small: {s2}" - # category space: P(X=2 | A1B1) >> P(X=2 | A1B0), P(X>=1) roughly equal. - cp = qr2.cat_prob[int(qr2.cat_off[0]):int(qr2.cat_off[0]) + 4 * 3].reshape(4, 3) - assert cp[3, 2] - cp[1, 2] >= 0.3 - assert abs((1 - cp[3, 0]) - (1 - cp[1, 0])) < 0.15 # P(X>=1) close across B - # Four M=1 anchors per attribute identify both dimensions and support stable recovery. - est = (qr2.attr_prob >= 0.5).astype(int) - assert (est == al2).mean() > 0.75 - - # (3) Validation. - with pytest.raises(ValueError): - fit_seq_gdina_qr(Y2.ravel(), step_q2, n_steps2) # not 2-D - zero_row = step_q2.copy() - zero_row[1] = [0, 0] # a step measuring nothing - with pytest.raises(ValueError): - fit_seq_gdina_qr(Y2, zero_row, n_steps2) - dead_col = step_q2.copy() - dead_col[:, 1] = 0 # attr B unused - with pytest.raises(ValueError): - fit_seq_gdina_qr(Y2, dead_col, n_steps2) - with pytest.raises(ValueError, match="sum"): - too_many_steps = n_steps2.copy() - too_many_steps[0] = 3 - fit_seq_gdina_qr(Y2, step_q2, too_many_steps) # wrapper: rows != sum(n_steps) - # max observed category != declared n_steps -- reaches the Rust guard, NOT the wrapper's - # row-count guard: keep sum(n_steps)=16 (matching step_q2's 16 rows) but let item0 declare - # 2 steps while its data never reaches category 2. - y_low = Y2.copy() - y_low[y_low[:, 0] == 2, 0] = 1 - with pytest.raises(ValueError, match="max observed category"): - fit_seq_gdina_qr(y_low, step_q2, n_steps2) - ymiss = Y2.copy() - ymiss[0, 0] = np.nan - fm = fit_seq_gdina_qr(ymiss, step_q2, n_steps2, max_iter=1000) # MAR: dropped, no crash - assert np.isfinite(fm.loglik_trace[-1]) and abs(fm.profile_prob.sum() - 1.0) < 1e-9 - - -def test_fit_crm_recovers_continuous_responses(): - """Continuous Response Model (Samejima, 1973): recover the item slope/intercept/ - residual-sd and the Samejima discrimination/difficulty from continuous bounded - responses, plus the trait (continuous responses are information-rich).""" - import numpy as np - import pytest - from fast_mlsirm import fit_crm, CrmFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_crm"): - pytest.skip("compiled core built without fit_crm") - - rng = np.random.default_rng(1973) - n_items, n = 15, 1500 - a_true = 0.8 + 0.05 * np.arange(n_items) - d_true = -0.6 + 0.08 * np.arange(n_items) - sigma_true = 0.6 + 0.02 * (np.arange(n_items) % 5) - theta = rng.standard_normal(n) - x = a_true * theta[:, None] + d_true + sigma_true * rng.standard_normal((n, n_items)) - z = 1.0 / (1.0 + np.exp(-x)) # in (0,1) - - res = fit_crm(z) - assert isinstance(res, CrmFit) and res.converged - assert res.termination_reason == "tolerance" - assert res.final_delta <= res.stopping_tolerance - assert res.n_iter + 1 == len(res.loglik_trace) - assert np.all(np.diff(res.loglik_trace) >= -1e-6) # monotone ascent - assert res.n_parameters == 3 * n_items - assert np.all(res.slope > 0) # reflection convention - assert np.sqrt(np.mean((res.slope - a_true) ** 2)) < 0.15 - assert np.sqrt(np.mean((res.intercept - d_true) ** 2)) < 0.1 - assert np.sqrt(np.mean((res.resid_sd - sigma_true) ** 2)) < 0.1 - # Samejima re-parameterization - assert np.sqrt(np.mean((res.discrimination - a_true / sigma_true) ** 2)) < 0.3 - assert np.sqrt(np.mean((res.difficulty - (-d_true / a_true)) ** 2)) < 0.2 - # trait recovery - assert np.corrcoef(res.theta, theta)[0, 1] > 0.9 - - limited = fit_crm(z, max_iter=1, tol=1e-12) - assert not limited.converged - assert limited.termination_reason == "max_iter" - assert limited.n_iter == 1 - assert limited.final_delta > limited.stopping_tolerance - - # missing-at-random handling - zm = z.copy() - zm[rng.random(zm.shape) < 0.15] = np.nan - assert fit_crm(zm).converged - - with pytest.raises(ValueError): - fit_crm(z.ravel()) # responses not 2-D - with pytest.raises(ValueError): - fit_crm(np.full((4, 3), 1.5)) # outside (0,1) - with pytest.raises(ValueError, match="at least one person"): - fit_crm(np.empty((0, 3))) - with pytest.raises(ValueError, match="at least one person"): - fit_crm(np.empty((3, 0))) - with pytest.raises(ValueError, match="max_iter"): - fit_crm(z, max_iter=0) - with pytest.raises(ValueError, match="1..=100000"): - fit_crm(z, max_iter=100_001) - with pytest.raises(ValueError, match="tol"): - fit_crm(z, tol=np.nan) - with pytest.raises(ValueError, match="no observed responses"): - missing_item = z.copy() - missing_item[:, 0] = np.nan - fit_crm(missing_item) - - -def test_fit_rsm_recovers_shared_thresholds(): - """Rating Scale Model (Andrich, 1978): recover item locations and the shared - category thresholds (centered) plus the trait; K=2 reduces to Rasch.""" - import numpy as np - import pytest - from fast_mlsirm import fit_rsm, RsmFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_rsm"): - pytest.skip("compiled core built without fit_rsm") - - rng = np.random.default_rng(1978) - n_items, n_cat, n = 12, 5, 2500 - delta_true = -1.2 + 0.2 * np.arange(n_items) - tau_true = np.array([0.9, 0.2, -0.3, -0.8]) # sums to 0 - theta = rng.standard_normal(n) - - def draw(th, d): - # cumulative psi_k = k*th - k*d - sum_{m<=k} tau - tk = np.concatenate([[0.0], np.cumsum(tau_true)]) - psi = np.arange(n_cat) * th - np.arange(n_cat) * d - tk - p = np.exp(psi - psi.max()) - p /= p.sum() - return rng.choice(n_cat, p=p) - - y = np.array([[draw(theta[j], delta_true[i]) for i in range(n_items)] for j in range(n)], - dtype=float) - - res = fit_rsm(y) - assert isinstance(res, RsmFit) and res.converged - assert np.all(np.diff(res.loglik_trace) >= -1e-6) # monotone ascent - assert res.n_parameters == n_items + n_cat - 2 - assert abs(res.thresholds.sum()) < 1e-6 # centered - assert np.sqrt(np.mean((res.item_location - delta_true) ** 2)) < 0.15 - assert np.sqrt(np.mean((res.thresholds - tau_true) ** 2)) < 0.12 - assert np.corrcoef(res.theta, theta)[0, 1] > 0.85 - - # missing-at-random - ym = y.copy() - ym[rng.random(ym.shape) < 0.15] = np.nan - assert fit_rsm(ym).converged - - with pytest.raises(ValueError): - fit_rsm(y.ravel()) # not 2-D - - -def test_fit_rsm_rejects_unidentified_or_malformed_inputs(): - """RSM must not report convergence for data that cannot identify a fit.""" - import numpy as np - import pytest - from fast_mlsirm import fit_rsm - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_rsm"): - pytest.skip("compiled core built without fit_rsm") - - valid = np.array([[0.0, 1.0], [1.0, 0.0]]) - with pytest.raises(ValueError, match="at least one person"): - fit_rsm(np.empty((0, 2)), n_cat=2) - with pytest.raises(ValueError, match="at least one person"): - fit_rsm(np.empty((2, 0)), n_cat=2) - with pytest.raises(ValueError, match="integer categories"): - fit_rsm(np.array([[0.2, 1.0], [1.0, 0.0]]), n_cat=2) - with pytest.raises(ValueError, match="finite integer categories"): - fit_rsm(np.array([[0.0, np.inf], [1.0, 0.0]]), n_cat=2) - with pytest.raises(ValueError, match="item 1 has no observed responses"): - fit_rsm(np.array([[0.0, np.nan], [1.0, np.nan]]), n_cat=2) - with pytest.raises(ValueError, match="max_iter"): - fit_rsm(valid, n_cat=2, max_iter=0) - with pytest.raises(ValueError, match="tol"): - fit_rsm(valid, n_cat=2, tol=np.inf) - - unfinished = fit_rsm(valid, n_cat=2, max_iter=1) - assert not unfinished.converged - assert unfinished.n_iter == 1 - assert len(unfinished.loglik_trace) == 2 - assert np.all(np.isfinite(unfinished.loglik_trace)) - - -def test_fit_2pl_recovers_confirmatory_loadings(): - """Compensatory MIRT (Reckase, 2009): recover a confirmatory 2-dimensional loading - pattern (dim0-only, dim1-only, and BOTH-loading items) including a genuinely NEGATIVE - loading, plus the per-dimension trait EAP; and reject a rotationally-degenerate - (all-ones) pattern.""" - import numpy as np - import pytest - from fast_mlsirm import TwoPlFit, fit_2pl, models - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_2pl"): - pytest.skip("compiled core built without fit_2pl") - - rng = np.random.default_rng(2009) - n, n_dims = 4000, 2 - pattern = np.array([[1, 0]] * 4 + [[0, 1]] * 4 + [[1, 1]] * 3, dtype=np.int64) - n_items = pattern.shape[0] - loading = np.zeros((n_items, n_dims)) - loading[:4, 0] = [1.2, 0.8, 1.5, -0.9] # dim0-only, incl. a negative loading - loading[4:8, 1] = [1.0, 1.3, 0.7, 1.1] # dim1-only - loading[8:] = [[0.9, 1.1], [1.2, -0.7], [0.8, 0.9]] # both, incl. a negative cross-loading - intercept = np.linspace(-0.8, 0.8, n_items) - theta = rng.standard_normal((n, n_dims)) - p = 1.0 / (1.0 + np.exp(-(theta @ loading.T + intercept))) - y = (rng.random((n, n_items)) < p).astype(float) - - res = fit_2pl(y, model=models.confirmatory(pattern), q=21) - assert isinstance(res, TwoPlFit) and res.converged - assert res.loading.shape == (n_items, n_dims) and res.n_dims == 2 - # off-pattern entries are exactly zero - assert np.all(res.loading[pattern == 0] == 0.0) - assert np.sqrt(np.mean((res.loading - loading) ** 2)) < 0.13 - # negative loadings recovered with the correct sign (needs the symmetric clamp) - assert res.loading[3, 0] < -0.5 - assert res.loading[9, 1] < -0.3 - # per-dimension trait recovery (positive correlation; a sign/swap bug -> near 0 or negative) - for d in range(n_dims): - c = np.corrcoef(res.theta[:, d], theta[:, d])[0, 1] - assert c > 0.7, f"dim {d} theta corr {c}" - assert np.all(np.diff(res.loglik_trace) >= -1e-6) # EM monotone - assert res.termination_reason == "converged" - assert res.n_iter < 500 - assert np.isfinite(res.final_loglik_change) - assert res.final_loglik_change < 1e-6 - - # a rotationally-degenerate all-ones pattern is rejected (no pure anchor per dimension) - with pytest.raises(ValueError): - fit_2pl(y, model=models.confirmatory(np.ones((n_items, n_dims), dtype=np.int64))) - fractional = pattern.astype(float) - fractional[0, 1] = 0.5 - with pytest.raises(ValueError, match="exactly 0 or 1"): - fit_2pl(y, model=models.confirmatory(fractional)) - with pytest.raises(ValueError, match="q must be a finite integer"): - fit_2pl(y, model=models.confirmatory(pattern), q=15.5) - with pytest.raises(ValueError, match="max_iter must be a finite integer"): - fit_2pl(y, model=models.confirmatory(pattern), max_iter=1.5) - # missing (MAR) handled - ymiss = y.copy() - ymiss[0, 0] = np.nan - assert fit_2pl(ymiss, model=models.confirmatory(pattern), q=15).converged - - # A one-step run that has not met the documented tolerance is explicitly unfinished. - unfinished = fit_2pl(y, model=models.confirmatory(pattern), q=7, max_iter=1, tol=1e-12) - assert not unfinished.converged - assert unfinished.termination_reason == "max_iter_reached" - assert unfinished.n_iter == 1 - assert len(unfinished.loglik_trace) == 2 - assert unfinished.final_loglik_change >= 1e-12 - - # estimate_corr=False reports Sigma = I; estimate_corr=True recovers a known correlation. - ortho = fit_2pl(y, model=models.confirmatory(pattern), q=15, estimate_corr=False) - assert np.allclose(ortho.corr, np.eye(n_dims)) - ncorr = np.linalg.cholesky(np.array([[1.0, 0.5], [0.5, 1.0]])) - thc = (ncorr @ rng.standard_normal((n_dims, n))).T - pc = 1.0 / (1.0 + np.exp(-(thc @ loading.T + intercept))) - yc = (rng.random((n, n_items)) < pc).astype(float) - rc = fit_2pl(yc, model=models.confirmatory(pattern), q=15, estimate_corr=True) - assert rc.corr.shape == (n_dims, n_dims) - assert np.allclose(np.diag(rc.corr), 1.0) and np.allclose(rc.corr, rc.corr.T) - realized = np.corrcoef(thc.T)[0, 1] - assert abs(rc.corr[0, 1] - realized) < 0.06, f"corr {rc.corr[0, 1]} vs realized {realized}" - assert np.all(np.linalg.eigvalsh(rc.corr) > 0) # positive-definite - - -def test_fit_2pl_qmc_high_dim(): - """QMC compensatory MIRT (Jank, 2005): the D>3 quasi-Monte-Carlo path the Gauss-Hermite - product grid cannot reach. Recovers a D=4 confirmatory loading pattern (2 pure anchors per - dimension + cross-loaders including a genuine NEGATIVE one) on Halton nodes; confirms the GH - path still caps D<=3 while QMC/MC reach D<=6; and checks the wrapper plumbing is two-sided - (a D<=3 QMC fit agrees with GH within QMC error but is NOT a silent bit-identical GH fallback).""" - import numpy as np - import pytest - from fast_mlsirm import TwoPlFit, fit_2pl, models - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_2pl"): - pytest.skip("compiled core built without fit_2pl") - - rng = np.random.default_rng(2005) - n, n_dims = 2500, 4 - # 2 pure anchors per dim + 3 cross-loaders (one with a negative dim-1 loading). - rows = [] - for d in range(n_dims): - rows += [[1 if k == d else 0 for k in range(n_dims)]] * 2 - rows += [[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]] - pattern = np.array(rows, dtype=np.int64) - n_items = pattern.shape[0] - loading = np.zeros((n_items, n_dims)) - for d in range(n_dims): - loading[2 * d, d] = 1.2 + 0.1 * d - loading[2 * d + 1, d] = 0.9 - cross = 2 * n_dims - loading[cross] = [1.0, -0.8, 0.0, 0.0] # the negative cross-loader - loading[cross + 1] = [0.0, 1.1, 0.7, 0.0] - loading[cross + 2] = [0.0, 0.0, 0.8, 1.0] - intercept = np.linspace(-0.5, 0.7, n_items) - theta = rng.standard_normal((n, n_dims)) - p = 1.0 / (1.0 + np.exp(-(theta @ loading.T + intercept))) - y = (rng.random((n, n_items)) < p).astype(float) - - # GH cannot reach D=4; QMC (Halton) can. - with pytest.raises(ValueError): - fit_2pl(y, model=models.confirmatory(pattern), node_rule="gh") - res = fit_2pl(y, model=models.confirmatory(pattern), node_rule="qmc", xi_points=4000, xi_seed=12345) - assert isinstance(res, TwoPlFit) and res.n_dims == 4 - assert np.all(res.loading[pattern == 0] == 0.0) - assert np.sqrt(np.mean((res.loading - loading) ** 2)) < 0.18 - assert res.loading[cross, 1] < -0.3 # negative cross-loader recovered with sign - for d in range(n_dims): - c = np.corrcoef(res.theta[:, d], theta[:, d])[0, 1] - assert c > 0.55, f"dim {d} theta corr {c}" - assert np.all(np.diff(res.loglik_trace) >= -1e-6) # EM monotone - - # node_rule validation and D<=6 bounds. - with pytest.raises(ValueError, match="node_rule"): - fit_2pl(y, model=models.confirmatory(pattern), node_rule="nope") - pat7 = np.eye(7, dtype=np.int64) - y7 = (rng.random((200, 7)) < 0.5).astype(float) - with pytest.raises(ValueError): - fit_2pl(y7, model=models.confirmatory(pat7), node_rule="qmc", xi_points=200) # D=7 > 6 - - # Two-sided wrapper plumbing at D=2: GH and QMC agree within QMC error yet differ bit-wise - # (a silent GH fallback on the QMC arm would make them identical). - pat2 = np.array([[1, 0]] * 3 + [[0, 1]] * 3 + [[1, 1]], dtype=np.int64) - ld2 = np.zeros((7, 2)) - for i in range(3): - ld2[i, 0] = 1.0 + 0.1 * i - ld2[3 + i, 1] = 1.0 - ld2[6] = [0.9, 0.8] - ic2 = np.linspace(-0.4, 0.5, 7) - th2 = rng.standard_normal((2000, 2)) - p2 = 1.0 / (1.0 + np.exp(-(th2 @ ld2.T + ic2))) - y2 = (rng.random((2000, 7)) < p2).astype(float) - gh2 = fit_2pl(y2, model=models.confirmatory(pat2), q=21, node_rule="gh") - qmc2 = fit_2pl(y2, model=models.confirmatory(pat2), node_rule="qmc", xi_points=6000, xi_seed=0) - max_abs = max( - np.max(np.abs(gh2.loading - qmc2.loading)), - np.max(np.abs(gh2.intercept - qmc2.intercept)), - ) - assert max_abs < 0.10, f"QMC vs GH beyond QMC error: {max_abs}" - assert max_abs > 1e-10, "QMC fit bit-identical to GH (silent fallback?)" - - -def test_fit_mhrm_recovers_high_dimensional_2pl(): - """MH-RM (Cai, 2010): high-dimensional confirmatory 2PL by Metropolis-Hastings Robbins-Monro - stochastic approximation. Recovers a D=6 confirmatory loading pattern — the q**D Gauss-Hermite - grid (21**6 ~ 8.6e7) and even the QMC E-step are infeasible at this dimensionality, which is the - module's reason to exist — including a genuine NEGATIVE cross-loader, with reflection- - canonicalized signs, per-dimension trait recovery, finite Louis observed-information SEs, and a - tuned acceptance rate; and rejects rotationally-degenerate patterns and non-binary responses.""" - import numpy as np - import pytest - from fast_mlsirm import MhrmFit, fit_mhrm, models - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_mhrm"): - pytest.skip("compiled core built without fit_mhrm") - - rng = np.random.default_rng(2010) - n, n_dims = 3000, 6 - rows = [] - for d in range(n_dims): - rows += [[1 if k == d else 0 for k in range(n_dims)]] * 3 # 3 pure anchors per dim - cross = [0] * n_dims - cross[0] = 1 - cross[3] = 1 - rows.append(cross) # one cross-loader on dims 0 and 3 - pattern = np.array(rows, dtype=np.int64) - n_items = pattern.shape[0] - loading = np.zeros((n_items, n_dims)) - for d in range(n_dims): - for a in range(3): - loading[3 * d + a, d] = 0.9 + 0.1 * a - xi = n_items - 1 - loading[xi, 0] = 1.0 - loading[xi, 3] = -0.7 # negative cross-loader - intercept = np.linspace(-0.5, 0.6, n_items) - theta = rng.standard_normal((n, n_dims)) - p = 1.0 / (1.0 + np.exp(-(theta @ loading.T + intercept))) - y = (rng.random((n, n_items)) < p).astype(float) - - res = fit_mhrm(y, model=models.confirmatory(pattern), max_cycles=1400, burn_in=280, mh_steps=8, seed=7) - assert isinstance(res, MhrmFit) and res.n_dims == 6 - assert res.loading.shape == (n_items, n_dims) - assert np.all(res.loading[pattern == 0] == 0.0) - assert res.n_parameters == int(pattern.sum()) + n_items - onpat = pattern == 1 - assert np.sqrt(np.mean((res.loading[onpat] - loading[onpat]) ** 2)) < 0.22 - assert res.loading[xi, 3] < -0.3 # negative cross-loader recovered with sign - for d in range(n_dims): - c = np.corrcoef(res.theta[:, d], theta[:, d])[0, 1] - assert c > 0.5, f"dim {d} theta corr {c}" - # Louis SEs: right shape, finite on-pattern - assert res.se_loading.shape == (n_items, n_dims) - assert np.all(np.isfinite(res.se_loading[onpat])) - assert res.se_intercept.shape == (n_items,) - # acceptance auto-tuned into a sane band - assert 0.1 < res.acceptance_rate < 0.7, res.acceptance_rate - - # rotationally-degenerate pattern (every item loads all dims -> no pure anchor) rejected - with pytest.raises(ValueError): - fit_mhrm(y, model=models.confirmatory(np.ones((n_items, n_dims), dtype=np.int64))) - # non-binary response rejected - with pytest.raises(ValueError): - ybad = y.copy() - ybad[0, 0] = 2 - fit_mhrm(ybad, model=models.confirmatory(pattern)) - - -def test_fit_mhrm_reports_the_windowed_stopping_metric(): - """The reported metric must be the same window mean used to declare convergence.""" - import numpy as np - import pytest - from fast_mlsirm import fit_mhrm - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_mhrm"): - pytest.skip("compiled core built without fit_mhrm") - - rng = np.random.default_rng(20260720) - n, n_items = 100, 6 - theta = rng.normal(size=n) - loading = np.array([0.7, 0.9, 1.1, 1.3, 0.8, 1.0]) - intercept = np.array([-0.8, -0.4, 0.0, 0.3, 0.6, -0.2]) - prob = 1.0 / (1.0 + np.exp(-(theta[:, None] * loading + intercept))) - responses = (rng.random((n, n_items)) < prob).astype(float) - tol = 0.005 - - result = fit_mhrm( - responses, - model=1, - max_cycles=150, - burn_in=5, - mh_steps=1, - tol=tol, - seed=52, - estimate_se=False, - ) - - assert result.converged - assert result.termination_reason == "converged" - assert result.n_cycles == 110 - assert result.final_param_change < tol - - -def test_fit_mhrm_estimate_corr_recovers_factor_correlation(): - """MH-RM with estimate_corr (Cai, 2010b): recover a free latent factor CORRELATION at D=2 from - theta ~ MVN(0, Phi), and confirm estimate_corr=False yields exactly the identity.""" - import numpy as np - import pytest - from fast_mlsirm import MhrmFit, fit_mhrm, models - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_mhrm"): - pytest.skip("compiled core built without fit_mhrm") - - rng = np.random.default_rng(2010) - n, n_dims, rho = 3000, 2, 0.5 - per = 4 - n_items = per * n_dims - pattern = np.zeros((n_items, n_dims), dtype=np.int64) - loading = np.zeros((n_items, n_dims)) - for d in range(n_dims): - for a in range(per): - pattern[d * per + a, d] = 1 - loading[d * per + a, d] = 1.0 + 0.1 * a - intercept = np.linspace(-0.4, 0.5, n_items) - phi = np.array([[1.0, rho], [rho, 1.0]]) - theta = rng.multivariate_normal(np.zeros(n_dims), phi, size=n) - p = 1.0 / (1.0 + np.exp(-(theta @ loading.T + intercept))) - y = (rng.random((n, n_items)) < p).astype(float) - - res = fit_mhrm(y, model=models.confirmatory(pattern), max_cycles=1500, burn_in=320, - mh_steps=8, estimate_corr=True, seed=3) - assert isinstance(res, MhrmFit) - assert res.corr.shape == (n_dims, n_dims) - assert np.allclose(np.diag(res.corr), 1.0) - assert abs(res.corr[0, 1] - rho) < 0.12, res.corr[0, 1] - assert res.n_parameters == n_items + n_items + n_dims * (n_dims - 1) // 2 - - # estimate_corr=False -> exactly the identity, and fewer parameters - res0 = fit_mhrm(y, model=models.confirmatory(pattern), max_cycles=400, burn_in=100, - estimate_corr=False, seed=3) - assert np.array_equal(res0.corr, np.eye(n_dims)) - assert res0.n_parameters == n_items + n_items - - -def test_fit_mhrm_gpcm_recovers_high_dimensional_polytomous(): - """MH-RM GPCM (Muraki, 1992; Cai, 2010): high-dimensional confirmatory GENERALIZED PARTIAL CREDIT - model by Metropolis-Hastings Robbins-Monro. Recovers a D=3 confirmatory pattern of loadings and - UNORDERED step intercepts — the q**D Gauss-Hermite grid and the QMC E-step are infeasible for a - polytomous item factor model at this dimensionality — including a genuinely NEGATIVE cross-loader, - with reflection-canonicalized signs; exposes the family/n_cat/step result shape (intercept empty); - and rejects out-of-range responses and a never-observed category (an unidentified step).""" - import numpy as np - import pytest - from fast_mlsirm import MhrmFit, fit_mhrm, models - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_mhrm"): - pytest.skip("compiled core built without fit_mhrm") - - rng = np.random.default_rng(1992) - n, n_dims, n_cat = 3000, 3, 3 - rows = [] - for d in range(n_dims): - rows += [[1 if k == d else 0 for k in range(n_dims)]] * 3 # 3 pure anchors per dim - cross = [0] * n_dims - cross[0] = 1 - cross[2] = 1 - rows.append(cross) # one cross-loader on dims 0 and 2 - pattern = np.array(rows, dtype=np.int64) - n_items = pattern.shape[0] - loading = np.zeros((n_items, n_dims)) - for d in range(n_dims): - for a in range(3): - loading[3 * d + a, d] = 0.9 + 0.1 * a - xi = n_items - 1 - loading[xi, 0] = 1.0 - loading[xi, 2] = -0.7 # negative cross-loader - # non-monotone (unordered) steps per item - step = np.column_stack([ - 0.7 - 0.12 * (np.arange(n_items) % 3), - -0.4 + 0.1 * (np.arange(n_items) % 4), - ]) - theta = rng.standard_normal((n, n_dims)) - base = theta @ loading.T # (n, n_items), no intercept - # psi_k = k*base + step_k (step_0 = 0); sample categories from the softmax - ks = np.arange(n_cat) - full_step = np.hstack([np.zeros((n_items, 1)), step]) # (n_items, n_cat) - psi = base[:, :, None] * ks[None, None, :] + full_step[None, :, :] # (n, n_items, n_cat) - psi -= psi.max(axis=2, keepdims=True) - prob = np.exp(psi) - prob /= prob.sum(axis=2, keepdims=True) - u = rng.random((n, n_items)) - y = (u[:, :, None] > np.cumsum(prob, axis=2)).sum(axis=2).astype(float) # inverse-CDF draw - - res = fit_mhrm(y, model=models.confirmatory(pattern), family="gpcm", n_cat=n_cat, - max_cycles=1200, burn_in=250, mh_steps=6, seed=11) - assert isinstance(res, MhrmFit) and res.n_dims == n_dims - assert res.family == "gpcm" and res.n_cat == n_cat - assert res.loading.shape == (n_items, n_dims) - assert res.step.shape == (n_items, n_cat - 1) - assert res.intercept.size == 0 # 2PL intercept empty for GPCM - assert res.se_step.shape == (n_items, n_cat - 1) - assert np.all(res.loading[pattern == 0] == 0.0) - assert res.n_parameters == int(pattern.sum()) + n_items * (n_cat - 1) - onpat = pattern == 1 - assert np.sqrt(np.mean((res.loading[onpat] - loading[onpat]) ** 2)) < 0.25 - assert res.loading[xi, 2] < -0.3 # negative cross-loader recovered with sign - assert np.sqrt(np.mean((res.step - step) ** 2)) < 0.25 - for d in range(n_dims): - c = np.corrcoef(res.theta[:, d], theta[:, d])[0, 1] - assert c > 0.5, f"dim {d} theta corr {c}" - - # out-of-range response (== n_cat) rejected - with pytest.raises(ValueError): - ybad = y.copy() - ybad[0, 0] = n_cat - fit_mhrm(ybad, model=models.confirmatory(pattern), family="gpcm", n_cat=n_cat) - # a declared category never observed for an item (unidentified step) rejected - with pytest.raises(ValueError): - ycov = y.copy() - ycov[ycov[:, 0] == 1, 0] = 0 # item 0 never shows category 1 - fit_mhrm(ycov, model=models.confirmatory(pattern), family="gpcm", n_cat=n_cat) - - -def test_fit_nominal_recovers_confirmatory_multidimensional_categories(): - """Confirmatory MULTIDIMENSIONAL nominal response model (Bock, 1972; Thissen-Cai-Bock, 2010): - recover a D=2 confirmatory pattern of CATEGORY-SPECIFIC multidimensional slopes (unordered - categories) including a genuinely NEGATIVE cross-loader slope with an OPPOSITE-sign sibling - category on the same dimension (the signature a per-item RMSE would average away), assessed up - to per-dimension reflection; confirm the baseline/off-pattern slopes are exactly zero; and - reject rotationally-degenerate patterns, out-of-range and unobserved categories, and GH D>3.""" - import numpy as np - import pytest - from fast_mlsirm import NominalResponseFit, fit_nominal, models - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_nominal_model"): - pytest.skip("compiled core built without fit_nominal_model") - - rng = np.random.default_rng(1972) - n_dims, n_cat, n = 2, 3, 6000 - # items 0,1 pure dim0; items 2,3 pure dim1; item 4 cross-loader {0,1}. - pattern = np.array([[1, 0], [1, 0], [0, 1], [0, 1], [1, 1]], dtype=np.int64) - n_items = pattern.shape[0] - anchor = [0, 2] # pure anchor item per dim - slope = np.zeros((n_items, n_cat, n_dims)) - slope[0, 1, 0], slope[0, 2, 0] = 1.4, 0.8 - slope[1, 1, 0], slope[1, 2, 0] = 1.0, 1.3 - slope[2, 1, 1], slope[2, 2, 1] = 1.2, 0.9 - slope[3, 1, 1], slope[3, 2, 1] = 1.1, 1.4 - slope[4, 1, 0], slope[4, 2, 0] = -1.1, 1.0 # negative + positive sibling on dim0 - slope[4, 1, 1], slope[4, 2, 1] = 0.9, 0.7 - intercept = np.zeros((n_items, n_cat)) - for i in range(n_items): - for k in range(1, n_cat): - intercept[i, k] = -0.2 + 0.15 * k - 0.05 * i - theta = rng.standard_normal((n, n_dims)) - eta = np.zeros((n, n_items, n_cat)) - for k in range(1, n_cat): - eta[:, :, k] = theta @ slope[:, k, :].T + intercept[:, k] - ex = np.exp(eta - eta.max(axis=2, keepdims=True)) - probs = ex / ex.sum(axis=2, keepdims=True) - u = rng.random((n, n_items)) - y = (probs.cumsum(axis=2) < u[:, :, None]).sum(axis=2) - - res = fit_nominal(y, n_cat, model=models.confirmatory(pattern), q=21) - assert isinstance(res, NominalResponseFit) and res.converged - assert res.slope.shape == (n_items, n_cat, n_dims) and res.n_dims == 2 and res.n_cat == 3 - # baseline category and off-pattern entries are EXACTLY zero - assert np.all(res.slope[:, 0, :] == 0.0) - for i in range(n_items): - for d in range(n_dims): - if pattern[i, d] == 0: - assert np.all(res.slope[i, :, d] == 0.0) - assert np.all(res.intercept[:, 0] == 0.0) - # free-parameter count = sum_i (n_cat-1)*(|S_i|+1) - assert res.n_parameters == 2 * 2 + 2 * 2 + 2 * 2 + 2 * 2 + 2 * 3 - - # per-dimension reflection alignment to truth (same rule applied to est), then compare - est = res.slope.copy() - for d in range(n_dims): - if est[anchor[d], 1, d] * slope[anchor[d], 1, d] < 0: - est[:, :, d] = -est[:, :, d] - assert np.sqrt(np.mean((est - slope) ** 2)) < 0.16 - # the negative cross-loader slope and its opposite-sign sibling recovered with the right signs - assert est[4, 1, 0] < -0.4 - assert est[4, 2, 0] > 0.4 - # per-dim trait EAP correlation (sign-aligned) - for d in range(n_dims): - th = res.theta[:, d].copy() - if res.slope[anchor[d], 1, d] * slope[anchor[d], 1, d] < 0: - th = -th - assert np.corrcoef(th, theta[:, d])[0, 1] > 0.6 - assert np.all(np.diff(res.loglik_trace) >= -1e-9) # EM monotone - - # validation - with pytest.raises(ValueError): # GH cannot reach D=4 - pat4 = np.eye(4, dtype=np.int64) - fit_nominal(np.zeros((50, 4), dtype=np.int64), n_cat, model=models.confirmatory(pat4), node_rule="gh") - with pytest.raises(ValueError): # no pure anchor for either dim - fit_nominal(y, n_cat, model=models.confirmatory(np.ones((n_items, n_dims), dtype=np.int64))) - with pytest.raises(ValueError): # category out of range - ybad = y.copy() - ybad[0, 0] = n_cat - fit_nominal(ybad, n_cat, model=models.confirmatory(pattern)) - with pytest.raises(ValueError): # an unobserved category for an item - ygap = y.copy() - ygap[ygap[:, 0] == 2, 0] = 1 - fit_nominal(ygap, n_cat, model=models.confirmatory(pattern)) - - -def test_fit_grm_recovers_confirmatory_multidimensional_ordered_categories(): - """Confirmatory MULTIDIMENSIONAL graded response model (Samejima, 1969; Muraki & Carlson, 1995): - recover a D=2 confirmatory pattern of item discrimination vectors (ORDERED categories) including a - genuinely NEGATIVE cross-loader on a positively-anchored dimension; confirm the recovered - thresholds are strictly ordered and the baseline reflection is canonicalized (pure anchors - positive); and reject rotationally-degenerate patterns, out-of-range and unobserved categories, - and GH D>3.""" - import numpy as np - import pytest - from fast_mlsirm import GrmFit, fit_grm, models - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_grm"): - pytest.skip("compiled core built without fit_grm") - - rng = np.random.default_rng(1969) - n_dims, n_cat, n = 2, 3, 6000 - pattern = np.array([[1, 0], [1, 0], [0, 1], [0, 1], [1, 1]], dtype=np.int64) - n_items = pattern.shape[0] - slope = np.zeros((n_items, n_dims)) - slope[0, 0], slope[1, 0] = 1.4, 1.0 - slope[2, 1], slope[3, 1] = 1.2, 1.1 - slope[4, 0], slope[4, 1] = -1.0, 0.9 # negative cross-loader on dim0 (anchor item 0 positive) - threshold = np.zeros((n_items, n_cat - 1)) - for i in range(n_items): - threshold[i] = [1.1 + 0.05 * i, 0.05 * i - 0.9] # strictly decreasing - theta = rng.standard_normal((n, n_dims)) - # simulate via cumulative logits P(Y>=k)=sigmoid(base+beta_{k-1}) - y = np.zeros((n, n_items), dtype=np.int64) - for i in range(n_items): - base = theta @ slope[i] - ge = 1.0 / (1.0 + np.exp(-(base[:, None] + threshold[i][None, :]))) # (n, n_cat-1) - pk = np.zeros((n, n_cat)) - pk[:, 0] = 1.0 - ge[:, 0] - for k in range(1, n_cat - 1): - pk[:, k] = ge[:, k - 1] - ge[:, k] - pk[:, n_cat - 1] = ge[:, n_cat - 2] - pk = np.clip(pk, 1e-12, None) - pk /= pk.sum(axis=1, keepdims=True) - u = rng.random(n) - y[:, i] = (pk.cumsum(axis=1) < u[:, None]).sum(axis=1) - - res = fit_grm(y, n_cat, model=models.confirmatory(pattern), q=21) - assert isinstance(res, GrmFit) and res.converged - assert res.slope.shape == (n_items, n_dims) and res.threshold.shape == (n_items, n_cat - 1) - assert res.n_dims == 2 and res.n_cat == 3 - # off-pattern slopes exactly zero - for i in range(n_items): - for d in range(n_dims): - if pattern[i, d] == 0: - assert res.slope[i, d] == 0.0 - # free-parameter count = sum_i (|S_i| + (n_cat-1)) - assert res.n_parameters == 4 * (1 + 2) + (2 + 2) - # recovered thresholds strictly decreasing on every item - assert np.all(res.threshold[:, 0] > res.threshold[:, 1]) - # canonical: pure anchors positive; negative cross-loader recovered negative - assert res.slope[0, 0] > 0.5 and res.slope[2, 1] > 0.5 - assert res.slope[4, 0] < -0.4, f"neg cross-loader {res.slope[4, 0]}" - assert np.sqrt(np.mean((res.slope - slope) ** 2)) < 0.16 - for d in range(n_dims): - assert np.corrcoef(res.theta[:, d], theta[:, d])[0, 1] > 0.6 - assert np.all(np.diff(res.loglik_trace) >= -1e-9) # EM monotone - - # validation - with pytest.raises(ValueError): # GH D=4 - fit_grm((np.arange(200).reshape(50, 4) % n_cat).astype(np.int64), n_cat, - model=models.confirmatory(np.eye(4, dtype=np.int64)), node_rule="gh") - with pytest.raises(ValueError): # no pure anchor - fit_grm(y, n_cat, model=models.confirmatory(np.ones((n_items, n_dims), dtype=np.int64))) - with pytest.raises(ValueError): # category out of range - ybad = y.copy(); ybad[0, 0] = n_cat - fit_grm(ybad, n_cat, model=models.confirmatory(pattern)) - with pytest.raises(ValueError): # unobserved category - ygap = y.copy(); ygap[ygap[:, 0] == 1, 0] = 0 - fit_grm(ygap, n_cat, model=models.confirmatory(pattern)) - - -def test_fit_gpcm_recovers_confirmatory_multidimensional_adjacent_category(): - """Confirmatory MULTIDIMENSIONAL generalized partial credit model (Muraki, 1992): recover a D=2 - confirmatory pattern of item discrimination vectors (INTEGER-scored adjacent-category logits) - including a genuinely NEGATIVE cross-loader on a positively-anchored dimension; recover the - UNORDERED category step intercepts numerically (GPCM steps carry no ordering constraint, so a - monotone canary would be vacuous — RMSE is the only guard); confirm the baseline reflection is - canonicalized (pure anchors positive) while steps are left unflipped; and reject - rotationally-degenerate patterns, out-of-range and unobserved categories, and GH D>3.""" - import numpy as np - import pytest - from fast_mlsirm import GpcmFit, fit_gpcm, models - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_gpcm"): - pytest.skip("compiled core built without fit_gpcm") - - rng = np.random.default_rng(1992) - n_dims, n_cat, n = 2, 4, 6000 - pattern = np.array([[1, 0], [1, 0], [0, 1], [0, 1], [1, 1]], dtype=np.int64) - n_items = pattern.shape[0] - slope = np.zeros((n_items, n_dims)) - slope[0, 0], slope[1, 0] = 1.4, 1.0 - slope[2, 1], slope[3, 1] = 1.2, 1.1 - slope[4, 0], slope[4, 1] = -1.0, 0.9 # negative cross-loader on dim0 (anchor item 0 positive) - # UNORDERED step intercepts gamma_k (psi_k = k*base + gamma_k, gamma_0 = 0); deliberately - # non-monotone across k to exercise the free-step estimator. - step = np.array([ - [0.7, -0.4, 0.9], - [-0.3, 0.6, 0.1], - [0.5, 0.2, -0.6], - [-0.2, 0.8, -0.3], - [0.4, -0.5, 0.7], - ]) - theta = rng.standard_normal((n, n_dims)) - # simulate via adjacent-category softmax P(Y=k) = softmax_k(k*base + gamma_k) - y = np.zeros((n, n_items), dtype=np.int64) - for i in range(n_items): - base = theta @ slope[i] - psi = np.zeros((n, n_cat)) - for k in range(1, n_cat): - psi[:, k] = k * base + step[i, k - 1] - psi -= psi.max(axis=1, keepdims=True) - pk = np.exp(psi) - pk /= pk.sum(axis=1, keepdims=True) - u = rng.random(n) - y[:, i] = (pk.cumsum(axis=1) < u[:, None]).sum(axis=1) - - res = fit_gpcm(y, n_cat, model=models.confirmatory(pattern), q=21) - assert isinstance(res, GpcmFit) and res.converged - assert res.slope.shape == (n_items, n_dims) and res.step.shape == (n_items, n_cat - 1) - assert res.n_dims == 2 and res.n_cat == 4 - # off-pattern slopes exactly zero - for i in range(n_items): - for d in range(n_dims): - if pattern[i, d] == 0: - assert res.slope[i, d] == 0.0 - # free-parameter count = sum_i (|S_i| + (n_cat-1)) - assert res.n_parameters == 4 * (1 + 3) + (2 + 3) - # canonical: pure anchors positive; negative cross-loader recovered negative - assert res.slope[0, 0] > 0.5 and res.slope[2, 1] > 0.5 - assert res.slope[4, 0] < -0.4, f"neg cross-loader {res.slope[4, 0]}" - assert np.sqrt(np.mean((res.slope - slope) ** 2)) < 0.16 - # UNORDERED steps recovered numerically (no ordering canary possible for GPCM) - assert np.sqrt(np.mean((res.step - step) ** 2)) < 0.16, f"step RMSE {res.step}" - for d in range(n_dims): - assert np.corrcoef(res.theta[:, d], theta[:, d])[0, 1] > 0.6 - assert np.all(np.diff(res.loglik_trace) >= -1e-9) # EM monotone - - # validation - with pytest.raises(ValueError): # GH D=4 - fit_gpcm((np.arange(200).reshape(50, 4) % n_cat).astype(np.int64), n_cat, - model=models.confirmatory(np.eye(4, dtype=np.int64)), node_rule="gh") - with pytest.raises(ValueError): # no pure anchor - fit_gpcm(y, n_cat, model=models.confirmatory(np.ones((n_items, n_dims), dtype=np.int64))) - with pytest.raises(ValueError): # category out of range - ybad = y.copy(); ybad[0, 0] = n_cat - fit_gpcm(ybad, n_cat, model=models.confirmatory(pattern)) - with pytest.raises(ValueError): # unobserved category - ygap = y.copy(); ygap[ygap[:, 0] == 1, 0] = 0 - fit_gpcm(ygap, n_cat, model=models.confirmatory(pattern)) - - -def test_fit_mixture_recovers_two_class_rasch(): - """Mixed Rasch / mixture IRT (Rost, 1990): recover two latent classes with a - difficulty reversal (a single-class model cannot fit both orderings).""" - import numpy as np - import pytest - from fast_mlsirm import fit_mixture, MixtureFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_mixture"): - pytest.skip("compiled core built without fit_mixture") - - rng = np.random.default_rng(1990) - n, j, pi_true = 1500, 15, 0.6 - b0 = np.linspace(-2.0, 2.0, j) - # class 0: b0; class 1: -b0 (reversal). theta ~ N(0,1). - cls = (rng.random(n) >= pi_true).astype(int) # 0 w.p. pi_true - theta = rng.standard_normal(n) - y = np.empty((n, j)) - for p in range(n): - b = b0 if cls[p] == 0 else -b0 - y[p] = (rng.random(j) < 1 / (1 + np.exp(-(theta[p] + b)))).astype(float) - - res = fit_mixture(y, n_classes=2, model="rasch", n_starts=8, seed=123) - assert isinstance(res, MixtureFit) and res.converged - assert np.all(np.diff(res.loglik_trace) >= -1e-6) - assert res.a.shape == (2, j) and np.allclose(res.a, 1.0) # Rasch: a == 1 - assert res.n_parameters == 2 * j + 1 # 2 classes * J difficulties + (C-1) - - # permutation-match the two fitted classes to (b0, -b0) by difficulty SSE - b_true = np.stack([b0, -b0]) - sse = lambda perm: float(np.sum((res.b[list(perm)] - b_true) ** 2)) - perm = (0, 1) if sse((0, 1)) <= sse((1, 0)) else (1, 0) - brmse = np.sqrt(np.mean((res.b[list(perm)] - b_true) ** 2)) - assert brmse < 0.25, f"matched b RMSE {brmse}" - assert abs(res.pi[perm[0]] - pi_true) < 0.06, f"pi {res.pi[perm[0]]}" - - # Adjusted Rand Index (label-invariant) between MAP class and truth - def ari(a, b): - from itertools import product - ka, kb = a.max() + 1, b.max() + 1 - tab = np.zeros((ka, kb)) - for x, yv in zip(a, b): - tab[x, yv] += 1 - c2 = lambda m: m * (m - 1) / 2 - idx = sum(c2(tab[i, k]) for i, k in product(range(ka), range(kb))) - sa = sum(c2(tab[i].sum()) for i in range(ka)) - sb = sum(c2(tab[:, k].sum()) for k in range(kb)) - exp = sa * sb / c2(len(a)) - return (idx - exp) / (0.5 * (sa + sb) - exp) - - assert ari(res.map_class, cls) > 0.35, "class recovery (ARI) too low" - - with pytest.raises(ValueError): - fit_mixture(y.ravel(), n_classes=2) # responses not 2-D - with pytest.raises(ValueError): - fit_mixture(y, n_classes=2, model="graded") # unknown within-class model - - -def test_fit_lltm_recovers_basic_parameters(): - """LLTM (Fischer, 1973): recover the basic cognitive-operation parameters from the - design matrix, and the LR test does not reject when the restriction holds.""" - import numpy as np - import pytest - from fast_mlsirm import fit_lltm, LltmFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_lltm"): - pytest.skip("compiled core built without fit_lltm") - - rng = np.random.default_rng(1973) - n, j, k = 2500, 20, 5 - # full-rank design with varying row sums (column k has period k+2) - q = np.array([[(i + kk) % (kk + 2) for kk in range(k)] for i in range(j)], dtype=float) - eta_true = np.array([0.6, -0.4, 0.9, -0.5, 0.3]) - c_true = -0.2 - b_true = c_true + q @ eta_true - theta = rng.standard_normal(n) - y = (rng.random((n, j)) < 1 / (1 + np.exp(-(theta[:, None] + b_true[None, :])))).astype(float) - - res = fit_lltm(y, q) - assert isinstance(res, LltmFit) and res.converged - assert np.all(np.diff(res.loglik_trace) >= -1e-6) - assert res.eta.shape == (k,) and res.b.shape == (j,) - assert np.corrcoef(res.eta, eta_true)[0, 1] > 0.95 - assert np.sqrt(np.mean((res.b - b_true) ** 2)) < 0.15 - assert res.n_parameters == k + 1 # K basic + intercept - # LR test: LLTM restriction HOLDS, so it should not reject - assert res.lr_df == j - k - 1 - assert res.lr_p > 0.01, f"LR falsely rejected true LLTM: p={res.lr_p}" - - with pytest.raises(ValueError): - fit_lltm(y.ravel(), q) # responses not 2-D - with pytest.raises(ValueError): - # rows sum to a constant + intercept => rank-deficient design, rejected - fit_lltm(y, np.ones((j, 1))) - - -def test_fit_testlet_recovers_local_dependence(): - """Testlet model (Bradlow, Wainer, & Wang, 1999): recover the per-testlet variance - (local dependence), and confirm sigma^2=0 reduces to the ordinary Rasch/2PL fit.""" - import numpy as np - import pytest - from fast_mlsirm import fit_testlet, TestletFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_testlet"): - pytest.skip("compiled core built without fit_testlet") - - rng = np.random.default_rng(1999) - n, per, d = 800, 8, 2 - j = per * d - tid = np.repeat(np.arange(d), per) # contiguous testlets - sig2 = np.array([0.6, 0.3]) - beta = np.tile(np.linspace(-1.5, 1.5, per), d) # Rasch: b = -beta (a=1) - theta = rng.standard_normal(n) - gamma = rng.standard_normal((n, d)) * np.sqrt(sig2)[None, :] - y = np.empty((n, j)) - for p in range(n): - eta = theta[p] + beta - gamma[p, tid] - y[p] = (rng.random(j) < 1 / (1 + np.exp(-eta))).astype(float) - - res = fit_testlet(y, tid, model="rasch") - assert isinstance(res, TestletFit) and res.converged - assert res.termination_reason == "converged" - assert res.final_loglik_change < 1e-6 - assert np.all(np.diff(res.loglik_trace) >= -1e-6) - assert np.all(res.a == 1.0) # Rasch - assert res.sigma2.shape == (d,) - # the strong-LD testlet is recovered as clearly larger than the weak one - assert res.sigma2[0] > 0.35 and res.sigma2[0] > res.sigma2[1] - assert np.sqrt(np.mean((res.sigma2 - sig2) ** 2)) < 0.2 - - # sigma^2 pinned to 0 => ordinary Rasch (no local dependence modeled) - res0 = fit_testlet(y, tid, model="rasch", estimate_sigma=False, init_sigma2=0.0) - assert np.all(res0.sigma2 == 0.0) - assert res0.n_parameters == j # fixed variances are not free parameters - - with pytest.raises(ValueError): - fit_testlet(y.ravel(), tid) # responses not 2-D - with pytest.raises(ValueError): - fit_testlet(y, tid, model="graded") # unknown model - - with pytest.raises(RuntimeError, match="max_iter_reached"): - fit_testlet(y[:40], tid, model="rasch", max_iter=1, require_convergence=True) - - -def test_fit_facets_recovers_rater_severity(): - """Many-facet Rasch model (Linacre, 1989): recover asymmetric rater - severities, item difficulties, and shared thresholds from a sparse judging - plan; single-rater case must agree with fit_rsm (RSM reduction). - - Asserts read crate outputs (res.rater_severity / item_difficulty / - thresholds / theta / loglik_trace / n_parameters / connected). A severity - sign-flip or a d/c dimension-map swap in the Rust core fails the recovery - and reduction checks.""" - import numpy as np - import pytest - from fast_mlsirm import fit_facets, fit_rsm, FacetsFit - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_facets"): - pytest.skip("compiled core built without fit_facets") - - rng = np.random.default_rng(1989) - n, n_items, n_raters, n_cat = 1500, 8, 4, 4 - d_true = -0.9 + 0.25 * np.arange(n_items) - c_true = np.array([1.1, -0.2, -0.4, -0.5]) # asymmetric, sums to 0 - f_true = np.array([0.7, 0.1, -0.8]) # sums to 0 - theta = rng.standard_normal(n) - tk = np.concatenate([[0.0], np.cumsum(f_true)]) - ks = np.arange(n_cat) - - y = np.full((n, n_items, n_raters), np.nan) - for p in range(n): - for i in range(n_items): - for j in range(n_raters): - if rng.random() < 0.4: # sparse plan - continue - psi = ks * theta[p] - ks * (d_true[i] + c_true[j]) - tk - pr = np.exp(psi - psi.max()) - y[p, i, j] = rng.choice(n_cat, p=pr / pr.sum()) - - res = fit_facets(y, n_cat=n_cat) - assert isinstance(res, FacetsFit) and res.converged and res.connected - assert np.all(np.diff(res.loglik_trace) >= -1e-6) - assert res.n_parameters == n_items + (n_raters - 1) + (n_cat - 2) - assert abs(res.rater_severity.sum()) < 1e-6 - assert abs(res.thresholds.sum()) < 1e-6 - assert np.sqrt(np.mean((res.rater_severity - c_true) ** 2)) < 0.12 - assert np.sqrt(np.mean((res.item_difficulty - d_true) ** 2)) < 0.15 - assert np.sqrt(np.mean((res.thresholds - f_true) ** 2)) < 0.12 - assert np.corrcoef(res.theta, theta)[0, 1] > 0.85 - - # single-rater reduction: MFRM with J=1 must match RSM (severity absorbed) - y1 = y[:400, :, :1] - keep = ~np.isnan(y1).all(axis=(1, 2)) - y1 = y1[keep] - r_f = fit_facets(y1, n_cat=n_cat) - r_r = fit_rsm(y1[:, :, 0], n_cat=n_cat) - assert np.allclose(r_f.rater_severity, [0.0]) - assert np.allclose(r_f.item_difficulty, r_r.item_location, atol=5e-3) - assert np.allclose(r_f.thresholds, r_r.thresholds, atol=5e-3) - - -def test_fit_facets_rejects_malformed_and_flags_disconnected(): - """MFRM input validation plus Linacre's connectedness diagnostic: a judging - plan whose item-rater graph splits into components must set connected=False - (asserts read res.connected from the crate). The False assert kills a - deleted union-find pass; the True assert on the bridged plan kills the - join-only-item-item-edges mutant, which would leave rater nodes isolated - and report every design as disconnected.""" - import numpy as np - import pytest - from fast_mlsirm import fit_facets - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "fit_facets"): - pytest.skip("compiled core built without fit_facets") - - valid = np.zeros((4, 2, 2)) - valid[::2] = 1.0 - with pytest.raises(ValueError, match="3-D"): - fit_facets(np.zeros((4, 2)), n_cat=2) - with pytest.raises(ValueError, match="at least one person"): - fit_facets(np.empty((0, 2, 2)), n_cat=2) - with pytest.raises(ValueError, match="integer categories"): - fit_facets(valid + 0.5, n_cat=2) - with pytest.raises(ValueError, match="in 0..1"): - fit_facets(valid * 3, n_cat=2) - with pytest.raises(ValueError, match="rater 1 has no observed"): - bad = valid.copy() - bad[:, :, 1] = np.nan - fit_facets(bad, n_cat=2) - with pytest.raises(ValueError, match="q_theta"): - fit_facets(valid, n_cat=2, q_theta=10) - with pytest.raises(ValueError, match="max_iter"): - fit_facets(valid, n_cat=2, max_iter=0) - with pytest.raises(ValueError, match="tol"): - fit_facets(valid, n_cat=2, tol=0.0) - - # disconnected plan: persons 0-19 see (item0, rater0), persons 20-39 see - # (item1, rater1) -- no shared element links the two components - rng = np.random.default_rng(7) - y = np.full((40, 2, 2), np.nan) - y[:20, 0, 0] = rng.integers(0, 2, 20) - y[20:, 1, 1] = rng.integers(0, 2, 20) - y[0, 0, 0], y[1, 0, 0] = 0.0, 1.0 - y[20, 1, 1], y[21, 1, 1] = 0.0, 1.0 - res = fit_facets(y, n_cat=2, max_iter=50) - assert res.connected is False - - # bridged plan: person 5 also sees (item1, rater1), joining the components - yb = y.copy() - yb[5, 1, 1] = 1.0 - yb[6, 1, 1] = 0.0 - resb = fit_facets(yb, n_cat=2, max_iter=50) - assert resb.connected is True - - -def test_mokken_analysis_coefficients_and_cluster_recovery(): - """Mokken scale analysis (van der Ark, 2007): every assert reads crate - outputs (hij/hi/h/zij/scale from mokken_coef_h / mokken_aisp via the - wrapper). A perfect Guttman scalogram must give H = 1 exactly (kills - covmax mutants), and a two-cluster simulation must be partitioned into - exactly two AISP scales (kills selection-logic mutants).""" - import numpy as np - import pytest - from fast_mlsirm import MokkenResult, mokken_analysis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "mokken_coef_h"): - pytest.skip("compiled core built without mokken_coef_h") - - # perfect Guttman scalogram -> H exactly 1 - guttman = np.array( - [[0, 0, 0], [1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 1, 1]], dtype=float - ) - g = mokken_analysis(guttman) - assert isinstance(g, MokkenResult) - assert abs(g.h - 1.0) < 1e-12 - off = ~np.eye(3, dtype=bool) - assert np.allclose(g.hij[off], 1.0) - assert np.all(np.isnan(np.diag(g.hij))) - - # two independent Rasch clusters -> two scales - rng = np.random.default_rng(2013) - n, per = 1500, 4 - bs = np.array([-0.8, -0.3, 0.3, 0.8]) - t1 = rng.normal(size=(n, 1)) * 1.6 - t2 = rng.normal(size=(n, 1)) * 1.6 - xa = (rng.random((n, per)) < 1.0 / (1.0 + np.exp(-(t1 - bs)))).astype(int) - xb = (rng.random((n, per)) < 1.0 / (1.0 + np.exp(-(t2 - bs)))).astype(int) - res = mokken_analysis(np.hstack([xa, xb]), lower_bound=0.3) - a, b = res.scale[0], res.scale[4] - assert a > 0 and b > 0 and a != b, res.scale - assert np.all(res.scale[:4] == a) and np.all(res.scale[4:] == b), res.scale - # crate zij symmetry read-back on real data - assert np.allclose(res.zij[off_idx := ~np.eye(8, dtype=bool)], - res.zij.T[off_idx]) - - -def test_mokken_analysis_rejects_malformed_input(): - """Wrapper validation: incomplete, non-integer, negative, non-2D data and - out-of-range c/alpha raise ValueError (each assert exercises the wrapper - guard in front of the crate; kills guard-deletion mutants).""" - import numpy as np - import pytest - from fast_mlsirm import mokken_analysis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "mokken_coef_h"): - pytest.skip("compiled core built without mokken_coef_h") - - ok = np.array([[0, 1], [1, 0], [1, 1], [0, 0], [1, 0], [0, 1]], dtype=float) - with pytest.raises(ValueError, match="complete"): - bad = ok.copy() - bad[0, 0] = np.nan - mokken_analysis(bad) - with pytest.raises(ValueError, match="integer"): - mokken_analysis(ok + 0.5) - with pytest.raises(ValueError, match="non-negative"): - mokken_analysis(ok - 1.0) - with pytest.raises(ValueError, match="2-D"): - mokken_analysis(ok.reshape(-1)) - with pytest.raises(ValueError, match="lower_bound"): - mokken_analysis(ok, lower_bound=1.0) - with pytest.raises(ValueError, match="alpha"): - mokken_analysis(ok, alpha=0.0) - # crate-side guard surfaces as ValueError too (zero-variance item) - cst = ok.copy() - cst[:, 0] = 1.0 - with pytest.raises(ValueError, match="zero variance"): - mokken_analysis(cst) - -def test_ksirt_analysis_fixture_exact_values(): - """Kernel-smoothing IRT (Ramsay, 1991, as cited in Mazza et al., 2014): - every assert reads crate outputs (theta/grid/bandwidth/occ/expected/ - expected_total from ksirt_occ via the wrapper) against independently - derived constants. theta must equal qnorm literals (kills rank-map - mutants), grid endpoints qnorm(1/5)/qnorm(4/5) (kills grid mutants), - bandwidth the Silverman literal 1.06*4^(-1/5) (kills bandwidth mutants), - and a huge bandwidth must flatten every OCC to the marginal proportion - 0.5 (kills NW-normalization mutants).""" - import numpy as np - import pytest - from fast_mlsirm import KsirtResult, ksirt_analysis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "ksirt_occ"): - pytest.skip("compiled core built without ksirt_occ") - - x = np.array([[0.0], [1.0], [0.0], [1.0]]) - q02, q04 = -0.8416212335729143, -0.2533471031357997 # qnorm(.2), qnorm(.4) - r = ksirt_analysis(x, nevalpoints=5) - assert isinstance(r, KsirtResult) - # ties.method="first": totals [0,1,0,1] -> ranks [1,3,2,4] - assert np.allclose(r.theta, [q02, -q04, q04, -q02], atol=1e-8) - assert abs(r.grid[0] - q02) < 1e-8 and abs(r.grid[-1] + q02) < 1e-8 - assert np.allclose(np.diff(r.grid), np.diff(r.grid)[0], atol=1e-12) - assert abs(r.bandwidth[0] - 1.06 * 4.0 ** (-0.2)) < 1e-12 - assert len(r.options) == 1 and np.array_equal(r.options[0], [0.0, 1.0]) - assert r.occ[0].shape == (2, 5) - # huge bandwidth -> equal NW weights -> OCC = marginal proportions - flat = ksirt_analysis(x, nevalpoints=5, bandwidth=np.array([1e9])) - assert np.allclose(flat.occ[0], 0.5, atol=1e-12) - assert np.allclose(flat.expected[0], 0.5, atol=1e-12) - assert np.allclose(flat.expected_total, 0.5, atol=1e-12) - - -def test_ksirt_analysis_recovery_and_rejects_malformed_input(): - """Monotone-recovery smoke on a simulated 2PL item (crate expected_total - must increase over the grid and crate theta must correlate with true - ability; kills over-collapse/ordering mutants) plus wrapper guard - checks (each raise exercises a validation branch in front of the - crate).""" - import numpy as np - import pytest - from fast_mlsirm import ksirt_analysis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "ksirt_occ"): - pytest.skip("compiled core built without ksirt_occ") - - rng = np.random.default_rng(1991) - n = 800 - t = rng.standard_normal(n) - bs = np.array([-1.0, -0.3, 0.4, 1.1]) - x = (rng.random((n, 4)) < 1.0 / (1.0 + np.exp(-1.5 * (t[:, None] - bs)))).astype(float) - r = ksirt_analysis(x, kernel="gaussian") - d = np.diff(r.expected_total) - assert np.all(d > -1e-9) # non-decreasing everywhere - assert np.all(d[10:-10] > 0.0) # strictly increasing in the interior - assert r.expected_total[-1] - r.expected_total[0] > 2.0 - assert np.corrcoef(r.theta, t)[0, 1] > 0.7 - - ok = x[:8] - with pytest.raises(ValueError, match="2-D"): - ksirt_analysis(ok.reshape(-1)) - with pytest.raises(ValueError, match="complete"): - bad = ok.copy() - bad[0, 0] = np.nan - ksirt_analysis(bad) - with pytest.raises(ValueError, match="kernel"): - ksirt_analysis(ok, kernel="triangular") - with pytest.raises(ValueError, match="nevalpoints"): - ksirt_analysis(ok, nevalpoints=1) - with pytest.raises(ValueError, match="nevalpoints"): - ksirt_analysis(ok, nevalpoints=10**11) # allocation bound - with pytest.raises(ValueError, match="one value per item"): - ksirt_analysis(ok, bandwidth=np.array([0.5])) - with pytest.raises(ValueError, match="positive"): - ksirt_analysis(ok, bandwidth=np.array([0.5, -0.1, 0.5, 0.5])) - - -def test_subscore_analysis_matches_independent_reference(): - """Haberman (2008, as cited in Sinharay, 2010) subscore added-value - analysis: every assert reads the SubscoreResult returned by the crate via - the PyO3 binding, pinned against literals from an independent NumPy - transcription of the CRAN subscore R semantics (ddof=1). The fixture is - asymmetric across subscales (added_value_sx = [False, True]), so - decision-rule and PRMSE mutants (rowsum including the total column, - missing true-variance diagonal, dropped +0.01 margin) all fail here.""" - import numpy as np - import pytest - from fast_mlsirm import SubscoreResult, subscore_analysis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "subscore_analysis"): - pytest.skip("compiled core built without subscore_analysis") - - x = np.array( - [ - [0, 0, 0, 1], [0, 1, 0, 0], [1, 1, 0, 1], [0, 0, 1, 0], - [1, 1, 1, 1], [1, 0, 1, 1], [0, 0, 0, 0], [1, 1, 1, 0], - [0, 1, 1, 1], [1, 1, 0, 0], - ], - dtype=float, - ) - res = subscore_analysis(x, [0, 0, 1, 1]) - assert isinstance(res, SubscoreResult) - np.testing.assert_allclose( - res.alpha, [0.5797101449275359, 0.33333333333333326], atol=1e-10 - ) - assert abs(res.alpha_total - 0.44742729306487705) < 1e-12 - np.testing.assert_allclose( - res.prmse_x, [0.41946308724832204, 0.3020134228187919], atol=1e-10 - ) - np.testing.assert_allclose( - res.prmse_sx, [0.5872524752475244, 0.3663366336633662], atol=1e-10 - ) - np.testing.assert_allclose( - res.tau, [0.1385414635898375, 0.27024443691002326], atol=1e-10 - ) - np.testing.assert_allclose( - res.beta, [0.49752475247524747, 0.21782178217821788], atol=1e-10 - ) - np.testing.assert_allclose( - res.gamma, [0.07178217821782161, 0.09900990099009896], atol=1e-10 - ) - assert res.added_value_s.tolist() == [True, True] - assert res.added_value_sx.tolist() == [False, True] - assert res.corr.shape == (3, 3) - assert abs(res.corr[0, 2] - 0.7791290756515445) < 1e-10 - assert np.isnan(res.disattenuated_corr[0, 0]) - assert abs(res.disattenuated_corr[0, 1] - 0.35355339059327384) < 1e-10 - # person-0 estimates from all three estimators - np.testing.assert_allclose( - res.subscore_s[0], [0.4623188405797105, 1.0], atol=1e-10 - ) - np.testing.assert_allclose( - res.subscore_x[0], [0.7308724832214768, 0.778523489932886], atol=1e-10 - ) - np.testing.assert_allclose( - res.subscore_sx[0], [0.47376237623762407, 0.8910891089108911], atol=1e-10 - ) - assert res.observed.shape == (10, 2) and res.total.shape == (10,) - - -def test_subscore_analysis_rejects_degenerate_inputs(): - """Guard behavior (spec-review mandated): duplicated subscales make a - subscore collinear with the total (tau divides by 1 - r^2 = 0) and must - raise, as must negative within-subscale alpha, bad partitions, and - incomplete data. Every assert reads the ValueError raised by the crate - through the binding; removing any Rust-side guard turns these into NaN - results or panics and fails the test.""" - import numpy as np - import pytest - from fast_mlsirm import subscore_analysis - - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "subscore_analysis"): - pytest.skip("compiled core built without subscore_analysis") - - base = np.array( - [ - [0, 0, 0, 1], [0, 1, 0, 0], [1, 1, 0, 1], [0, 0, 1, 0], - [1, 1, 1, 1], [1, 0, 1, 1], [0, 0, 0, 0], [1, 1, 1, 0], - [0, 1, 1, 1], [1, 1, 0, 0], - ], - dtype=float, - ) - dup = base[:, [0, 1, 0, 1]] - with pytest.raises(ValueError, match="collinear"): - subscore_analysis(dup, [0, 0, 1, 1]) - rev = base.copy() - rev[:, 1] = 1.0 - rev[:, 0] # reversed item -> negative alpha - with pytest.raises(ValueError, match="outside"): - subscore_analysis(rev, [0, 0, 1, 1]) - with pytest.raises(ValueError): - subscore_analysis(base, [0, 0, 1]) # groups length mismatch - with pytest.raises(ValueError): - subscore_analysis(base, [0, 0, 0, 0]) # single subscale - with pytest.raises(ValueError): - subscore_analysis(base, [0, 1, 1, 1]) # singleton subscale - nanx = base.copy() - nanx[0, 0] = np.nan - with pytest.raises(ValueError, match="complete"): - subscore_analysis(nanx, [0, 0, 1, 1]) - - -def test_detect_analysis_matches_independent_reference(): - """Confirmatory DETECT (Zhang & Stout, 1999, via sirt's sum-score path). - - All asserts read crate outputs (DetectResult fields returned through the - PyO3 binding). Literals come from an independent NumPy transcription of - the R semantics that never imports this package. Killed by: delta sign - swap, dropped bias correction, n-1 covariance divisor, index-scaling - (x100) errors, label-as-index bugs.""" - from fast_mlsirm import detect_analysis - - x = np.array( - [ - [1, 0, 0, 1, 1], - [0, 0, 0, 0, 1], - [0, 0, 1, 1, 1], - [1, 0, 1, 0, 1], - [0, 0, 0, 1, 1], - [0, 1, 0, 1, 1], - [0, 0, 1, 1, 1], - [0, 1, 0, 1, 1], - [1, 0, 1, 0, 1], - ], - dtype=float, - ) - res = detect_analysis(x, [0, 0, 1, 1, 0]) - assert res.detect == pytest.approx(-0.2830687830687831, abs=1e-12) - assert res.assi == pytest.approx(-0.2, abs=1e-12) - assert res.ratio == pytest.approx(-0.05439755973563803, abs=1e-12) - assert res.madcov100 == pytest.approx(5.203703703703703, abs=1e-12) - assert res.mcov100 == pytest.approx(-3.5105820105820107, abs=1e-12) - assert res.n_pairs == 10 - assert res.ccov[0] == pytest.approx(-0.10317460317460317, abs=1e-12) - assert res.ccov[3] == 0.0 # constant item 4: exact zero ccov - # hostile labels behave identically (equality-only comparison) - big = detect_analysis( - x, np.array([-(2**63), -(2**63), 2**63 - 1, 2**63 - 1, -(2**63)]) - ) - assert big.detect == pytest.approx(res.detect, abs=1e-12) - - -def test_detect_analysis_rejects_degenerate_inputs(): - """Trust-boundary rejections; asserts read ValueError raised by the - wrapper/crate before or at the Rust boundary.""" - from fast_mlsirm import detect_analysis - - base = np.array( - [[1, 0, 1, 0], [0, 1, 1, 0], [1, 1, 0, 1], [0, 0, 1, 1]], dtype=float - ) - with pytest.raises(ValueError): - detect_analysis(base, [0, 0, 1]) # cluster length mismatch - with pytest.raises(ValueError, match="complete"): - nanx = base.copy() - nanx[0, 0] = np.nan - detect_analysis(nanx, [0, 0, 1, 1]) - with pytest.raises(ValueError): - half = base.copy() - half[1, 2] = 0.5 # non-binary - detect_analysis(half, [0, 0, 1, 1]) - with pytest.raises(ValueError): - detect_analysis(base[:1], [0, 0, 1, 1]) # 1 person - with pytest.raises(ValueError): - detect_analysis(np.ones((4, 2)), [0, 1]) # all-zero ccov: RATIO 0/0 - # float labels beyond i64 must be rejected, not silently wrapped by - # astype(int64) (which would collapse distinct labels into one cluster) - with pytest.raises(ValueError, match="64-bit"): - detect_analysis(base, [2.0**63, 2.0**63, 2.0**63 + 2048, 0.0]) - - -def test_classification_accuracy_matches_independent_reference(): - """Rudner and Lee classification accuracy/consistency (Rudner, 2001, - 2005; Lee, 2010, as cited in Lathrop's cacIRT sources): every assert - reads crate outputs returned through the wrapper against literals from - an independent NumPy transcription (exact math.erf / own recursion, - never this crate). Rudner tolerance 1e-6 covers the crate erfc - approximation (|err| < 1.2e-7); Lee values are exact-recursion, pinned - at 1e-12. Weights are deliberately unnormalized (kills a forgot-to- - normalize mutation); Lee cut 2.4 is non-integer (kills floor-vs-ceil); - theta[2] == cut -0.4 anchors left-closed categorization.""" - from fast_mlsirm import lee_classification, rudner_classification - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "rudner_classification"): - pytest.skip("compiled core built without classification") - - r = rudner_classification( - theta=[-1.7, -0.63, -0.4, 0.11, 0.52, 1.28, 1.95], - sem=[0.85, 0.42, 0.31, 0.27, 0.33, 0.48, 0.66], - cutscores=(-0.4, 0.85), - weights=[0.4, 1.1, 1.7, 2.3, 1.9, 0.8, 0.3], - ) - tol = 1e-6 - assert abs(r.per_cut_accuracy[0] - 0.8506551287597999) < tol - assert abs(r.per_cut_consistency[1] - 0.9068646398531202) < tol - assert abs(r.simultaneous_accuracy - 0.7952699812339523) < tol - assert abs(r.simultaneous_consistency - 0.7315355182946305) < tol - assert r.conditional_accuracy.shape == (2, 7) - assert abs(r.conditional_accuracy[1, 4] - 0.8413447460685428) < tol - # theta exactly on the cut -> upper category (left-closed) - assert abs(r.conditional_simultaneous_accuracy[2] - 0.4999723782624206) < tol - - p = np.array( - [ - [0.08, 0.15, 0.22, 0.31, 0.12, 0.19], - [0.23, 0.34, 0.41, 0.52, 0.28, 0.37], - [0.47, 0.55, 0.61, 0.68, 0.51, 0.58], - [0.66, 0.72, 0.79, 0.83, 0.69, 0.76], - [0.81, 0.87, 0.90, 0.93, 0.84, 0.88], - ] - ) - le = lee_classification( - p, cutscores=(2.4, 4.0), weights=[0.6, 1.4, 2.0, 1.4, 0.6] - ) - tol = 1e-12 - assert abs(le.per_cut_accuracy[0] - 0.8221250493919999) < tol - assert abs(le.per_cut_consistency[1] - 0.7071425914148844) < tol - assert abs(le.simultaneous_accuracy - 0.6284128106933332) < tol - assert abs(le.simultaneous_consistency - 0.5754886794891432) < tol - assert abs(le.conditional_consistency[1, 2] - 0.5012741633381826) < tol - assert abs(le.conditional_simultaneous_accuracy[2] - 0.2992865452) < tol - - # true score exactly on an integer cut -> upper category (left-closed); - # dyadic probs make the sum exact in binary64 - edge = lee_classification([[0.5, 0.5, 0.5, 0.5, 0.25, 0.75]], cutscores=(3.0,)) - assert abs(edge.per_cut_accuracy[0] - 0.6640625) < tol - assert abs(edge.per_cut_consistency[0] - 0.5538330078125) < tol - - -def test_classification_rejects_degenerate_inputs(): - """Trust-boundary rejections; asserts read the ValueError/RuntimeError - raised by the wrapper/crate at the Rust boundary.""" - from fast_mlsirm import lee_classification, rudner_classification - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "rudner_classification"): - pytest.skip("compiled core built without classification") - - with pytest.raises(ValueError): - rudner_classification([0.0, 1.0], [0.5], (0.0,)) # length mismatch - with pytest.raises(ValueError): - rudner_classification([0.0, 1.0], [0.5, 0.0], (0.0,)) # sem <= 0 - with pytest.raises(ValueError): - rudner_classification([0.0, 1.0], [0.5, 0.5], (0.3, 0.3)) # cuts not increasing - with pytest.raises(ValueError): - rudner_classification([0.0, 1.0], [0.5, 0.5], (0.0,), weights=[0.0, 0.0]) - - good = [[0.2, 0.4], [0.6, 0.8]] - with pytest.raises(ValueError): - lee_classification([[1.0, 0.4], [0.6, 0.8]], (1.5,)) # P == 1 - with pytest.raises(ValueError): - lee_classification(good, (2.5,)) # raw cut > n_items - with pytest.raises(ValueError): - lee_classification(good, (0.2, 0.9)) # ceil-collision at 1 - with pytest.raises(ValueError): - lee_classification(np.array([0.2, 0.4]), (1.5,)) # 1-D probs - - - -def test_parallel_analysis_matches_numpy_fixture(): - """Horn parallel analysis (paran PCA path): fixture literals computed by - an independent NumPy replication that mirrors the crate LCG stream with - np.uint64 arithmetic and uses np.corrcoef + np.linalg.eigvalsh. Every - assert reads crate outputs returned through the wrapper. Duplicate - columns give analytic observed eigenvalues {2, 0}; the random benchmark, - bias, adjustment, and retention are pinned deterministically - (n=12, p=2, iterations=20, centile=0, seed=7).""" - from fast_mlsirm import parallel_analysis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "parallel_analysis"): - pytest.skip("compiled core built without parallel_analysis") - - x = np.arange(1.0, 13.0) - res = parallel_analysis( - np.column_stack([x, x]), n_iterations=20, centile=0, seed=7 - ) - tol = 1e-9 - assert abs(res.eigenvalues[0] - 2.0) < tol - assert abs(res.eigenvalues[1]) < tol - assert abs(res.random_eigenvalues[0] - 1.265490626983912) < tol - assert abs(res.random_eigenvalues[1] - 0.734509373016088) < tol - assert abs(res.bias[0] - 0.265490626983912) < tol - assert abs(res.adjusted_eigenvalues[0] - 1.734509373016088) < tol - assert abs(res.adjusted_eigenvalues[1] - 0.265490626983912) < tol - assert res.retained == 1 - - -def test_parallel_analysis_rejects_degenerate_inputs(): - """Trust-boundary rejections raised at the Rust boundary.""" - from fast_mlsirm import parallel_analysis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "parallel_analysis"): - pytest.skip("compiled core built without parallel_analysis") - - good = np.array([[1.0, 2.0], [2.0, 1.0], [3.0, 5.0], [4.0, 4.5]]) - with pytest.raises(ValueError): - parallel_analysis(good[:2], n_iterations=5) # n_persons < 3 - with pytest.raises(ValueError): - parallel_analysis(good[:, :1], n_iterations=5) # n_items < 2 - with pytest.raises(ValueError): - parallel_analysis(good, n_iterations=0) - with pytest.raises(ValueError): - parallel_analysis(good, n_iterations=5, centile=100) - bad = good.copy() - bad[1, 1] = np.nan - with pytest.raises(ValueError): - parallel_analysis(bad, n_iterations=5) - const = good.copy() - const[:, 0] = 1.0 - with pytest.raises(ValueError): - parallel_analysis(const, n_iterations=5) # zero variance - with pytest.raises(ValueError): - parallel_analysis(np.array([1.0, 2.0, 3.0]), n_iterations=5) # 1-D - - -def test_parallel_analysis_rejects_negative_scalars_and_huge_data(): - """Impl-review regressions: negative scalars raise ValueError (not - PyO3 OverflowError) via wrapper validation; finite-but-huge data that - overflows the correlation computation is rejected by the crate.""" - from fast_mlsirm import parallel_analysis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "parallel_analysis"): - pytest.skip("compiled core built without parallel_analysis") - - good = np.array([[1.0, 2.0], [2.0, 1.0], [3.0, 5.0], [4.0, 4.5]]) - with pytest.raises(ValueError): - parallel_analysis(good, n_iterations=-1) - with pytest.raises(ValueError): - parallel_analysis(good, n_iterations=5, centile=-3) - with pytest.raises(ValueError): - parallel_analysis(good, n_iterations=5, seed=-1) - big = 1e308 - huge = np.array([[big, big], [big, -big], [-big, big], [-big, -big]]) - with pytest.raises(ValueError): - parallel_analysis(huge, n_iterations=5) - - -def _guttman_gen_data(n, p, seed, loadings, means, scales): - """NumPy mirror of the crate LCG used to build the Guttman fixture data - (test-input construction only; all asserted values come from the crate).""" - state = np.uint64(max(seed, 1)) - mul = np.uint64(6364136223846793005) - add = np.uint64(1442695040888963407) - - def uniform(): - nonlocal state - with np.errstate(over="ignore"): - state = state * mul + add - return float(state >> np.uint64(11)) / float(1 << 53) - - def normal(): - u1 = max(uniform(), 1e-12) - u2 = uniform() - return math.sqrt(-2.0 * math.log(u1)) * math.cos(2.0 * math.pi * u2) - - out = np.empty((n, p)) - for i in range(n): - f = normal() - for j in range(p): - e = normal() - out[i, j] = means[j] + scales[j] * (loadings[j] * f + e) - return out - - -def test_guttman_lambdas_matches_numpy_fixture(): - """Guttman lambdas (psych 2.6.5 contract): fixture A literals computed by - an independent NumPy replication (np.corrcoef, np.linalg.inv, - itertools.combinations). Every assert reads GuttmanResult fields returned - by the crate through the wrapper (n=30, p=6, exhaustive C(6,3)=20).""" - from fast_mlsirm import guttman_lambdas - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "guttman_lambdas"): - pytest.skip("compiled core built without guttman_lambdas") - - data = _guttman_gen_data( - 30, - 6, - 42, - [0.9, 0.8, 0.7, 0.6, 0.5, 0.2], - [10.0, -3.0, 5.0, 0.5, 100.0, 7.0], - [1.0, 2.0, 0.5, 3.0, 10.0, 1.5], - ) - g = guttman_lambdas(data) - tol = 1e-9 - assert abs(g.lambda1 - 0.41068157604336863) < tol - assert abs(g.lambda2 - 0.5561900258418755) < tol - assert abs(g.lambda3 - 0.49281789125204234) < tol - assert abs(g.lambda4 - 0.6842463861953535) < tol - assert abs(g.lambda5 - 0.5602004712206824) < tol - assert abs(g.lambda6 - 0.59150154331973) < tol - assert abs(g.beta - 0.1784553694910821) < tol - assert abs(g.mean_split - 0.4928178912520423) < tol - assert g.n_splits == 20 - assert g.exhaustive - - -def test_guttman_lambdas_rejects_degenerate_inputs(): - """Trust-boundary rejections: wrapper scalars raise ValueError (not - PyO3 OverflowError); shape/finite/variance/singularity checks raise at - the Rust boundary.""" - from fast_mlsirm import guttman_lambdas - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "guttman_lambdas"): - pytest.skip("compiled core built without guttman_lambdas") - - rng = np.random.default_rng(3) - good = rng.normal(size=(10, 4)) - with pytest.raises(ValueError): - guttman_lambdas(good[:2]) # n_persons < 3 - with pytest.raises(ValueError): - guttman_lambdas(good[:, :2]) # n_items < 3 - with pytest.raises(ValueError): - guttman_lambdas(good, n_sample_splits=0) - with pytest.raises(ValueError): - guttman_lambdas(good, seed=-1) - with pytest.raises(ValueError): - guttman_lambdas(np.array([1.0, 2.0, 3.0])) # 1-D - bad = good.copy() - bad[1, 1] = np.nan - with pytest.raises(ValueError): - guttman_lambdas(bad) - const = good.copy() - const[:, 0] = 1.0 - with pytest.raises(ValueError): - guttman_lambdas(const) # zero variance - dup = good.copy() - dup[:, 1] = dup[:, 0] # singular correlation matrix -> lambda6 inverse - with pytest.raises(ValueError): - guttman_lambdas(dup) - - -def test_tenberge_mu_matches_numpy_fixture(): - """ten Berge & Zegers mu series (psych 2.6.5 tenberge.R contract): - fixture A literals from an independent NumPy replication (np.corrcoef + - explicit off-diagonal power sums). Every assert reads TenBergeResult - fields returned by the crate through the wrapper.""" - from fast_mlsirm import tenberge_mu - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "tenberge_mu"): - pytest.skip("compiled core built without tenberge_mu") - - data = _guttman_gen_data( - 30, - 6, - 42, - [0.9, 0.8, 0.7, 0.6, 0.5, 0.2], - [10.0, -3.0, 5.0, 0.5, 100.0, 7.0], - [1.0, 2.0, 0.5, 3.0, 10.0, 1.5], - ) - t = tenberge_mu(data) - tol = 1e-9 - assert abs(t.mu0 - 0.49281789125204223) < tol - assert abs(t.mu1 - 0.5561900258418755) < tol - assert abs(t.mu2 - 0.566334883191414) < tol - assert abs(t.mu3 - 0.5694865282091219) < tol - assert t.mu0 <= t.mu1 <= t.mu2 <= t.mu3 - - -def test_tenberge_mu_rejects_degenerate_inputs(): - """Trust-boundary rejections raised at the Rust boundary.""" - from fast_mlsirm import tenberge_mu - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "tenberge_mu"): - pytest.skip("compiled core built without tenberge_mu") - - rng = np.random.default_rng(9) - good = rng.normal(size=(10, 4)) - with pytest.raises(ValueError): - tenberge_mu(good[:2]) # n_persons < 3 - with pytest.raises(ValueError): - tenberge_mu(good[:, :2]) # n_items < 3 - with pytest.raises(ValueError): - tenberge_mu(np.array([1.0, 2.0, 3.0])) # 1-D - bad = good.copy() - bad[0, 0] = np.inf - with pytest.raises(ValueError): - tenberge_mu(bad) - const = good.copy() - const[:, 3] = 0.25 - with pytest.raises(ValueError): - tenberge_mu(const) # zero variance - - -def test_cronbach_alpha_and_feldt_ci_fixture(): - """Pinned against an independent NumPy+scipy replication (scipy.stats.f.ppf). - - Reads crate outputs via fast_mlsirm.cronbach_alpha / feldt_alpha_ci. - """ - from fast_mlsirm import cronbach_alpha, feldt_alpha_ci - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "feldt_alpha_ci"): - pytest.skip("compiled core built without feldt_alpha_ci") - - data = _guttman_gen_data( - 30, 6, 42, - [0.9, 0.8, 0.7, 0.6, 0.5, 0.2], - [10.0, -3.0, 5.0, 0.5, 100.0, 7.0], - [1.0, 2.0, 0.5, 3.0, 10.0, 1.5], - ) - tol = 1e-9 - a = cronbach_alpha(data) - assert abs(a - 0.26300193786625514) < tol - ci = feldt_alpha_ci(a, 30, 6, level=0.95) - assert abs(ci.lower - -0.23710034474579156) < tol - assert abs(ci.upper - 0.6065060295943752) < tol - assert abs(ci.r_bar - 0.05613713592263862) < tol - assert ci.df1 == 29.0 and ci.df2 == 145.0 - assert ci.lower < a < ci.upper - - -def test_feldt_ci_rejects_degenerate_inputs(): - """Trust-boundary rejections raised at the Rust boundary.""" - from fast_mlsirm import cronbach_alpha, feldt_alpha_ci - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "feldt_alpha_ci"): - pytest.skip("compiled core built without feldt_alpha_ci") - - rng = np.random.default_rng(11) - good = rng.normal(size=(10, 4)) - with pytest.raises(ValueError): - cronbach_alpha(good[:2]) # n_persons < 3 - with pytest.raises(ValueError): - cronbach_alpha(np.array([1.0, 2.0])) # 1-D - flat = good.copy() - flat[:, 0] = 3.0 - with pytest.raises(ValueError): - cronbach_alpha(flat) # zero-variance item - with pytest.raises(ValueError): - feldt_alpha_ci(1.0, 30, 6) # alpha >= 1 - with pytest.raises(ValueError): - feldt_alpha_ci(0.8, 30, 6, level=1.0) - with pytest.raises(ValueError): - feldt_alpha_ci(0.8, 2, 6) - - -def test_livingston_lewis_matches_independent_reference(): - """Livingston-Lewis single-administration classification (Livingston & - Lewis, 1995, as implemented in CRAN betafunctions 1.9.0 LL.CA, read in - full): every assert reads crate outputs returned through the wrapper - against literals from an independent scipy.integrate.quad replication - of the pipeline (never this crate). The fixture takes the genuine - four-parameter path (kills a dropped-failsafe-branch confusion) and has - round(48 * 0.56) = 27 != floor = 26 (anchors round-ties-even in k). - Integral tolerance 1e-7 covers the crate Gauss-Legendre quadrature.""" - from fast_mlsirm import livingston_lewis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "livingston_lewis"): - pytest.skip("compiled core built without livingston_lewis") - - scores = [11, 14, 16, 17, 19, 20, 21, 22, 23, 23, 24, 25, 25, 26, 27, - 27, 28, 29, 30, 31, 31, 32, 33, 34, 35, 36, 38, 39, 41, 44] - r = livingston_lewis(scores, 0.82, 0.0, 50.0, 28.0) - assert abs(r.effective_test_length - 47.50171019930535) < 1e-9 - assert r.etl_rounded == 48 - assert not r.used_two_parameter - assert abs(r.lower - 0.19555999873721175) < 1e-9 - assert abs(r.upper - 0.9652932601665462) < 1e-9 - assert abs(r.alpha - 2.7763142806015297) < 1e-9 - assert abs(r.beta - 3.2986812798601846) < 1e-9 - tol = 1e-7 - assert abs(r.p_tp - 0.40501323923680005) < tol - assert abs(r.p_fp - 0.07917200341607164) < tol - assert abs(r.p_tf - 0.46137445417258505) < tol - assert abs(r.p_ff - 0.05444030317454212) < tol - assert abs(r.accuracy - 0.866387693409385) < tol - assert abs(r.sensitivity - 0.8815107553881857) < tol - assert abs(r.specificity - 0.8535333969826517) < tol - assert abs(r.p_ii - 0.4232336892048015) < tol - assert abs(r.p_ij - 0.0925810681423261) < tol - assert r.p_ji == r.p_ij # symmetric by construction (single threshold) - assert abs(r.p_jj - 0.39160417451054624) < tol - assert abs(r.consistency - 0.8148378637153477) < tol - assert abs(r.chance_consistency - 0.5005002130998969) < tol - assert abs(r.kappa - 0.6293048743148242) < tol - - -def test_livingston_lewis_rejects_degenerate_inputs(): - """Trust-boundary rejections; asserts read the ValueError raised by the - wrapper/crate at the Rust boundary.""" - from fast_mlsirm import livingston_lewis - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "livingston_lewis"): - pytest.skip("compiled core built without livingston_lewis") - - good = [11.0, 14.0, 20.0, 25.0, 31.0, 36.0, 41.0, 44.0] - with pytest.raises(ValueError): - livingston_lewis(good, 1.0, 0.0, 50.0, 28.0) # reliability >= 1 - with pytest.raises(ValueError): - livingston_lewis(good, 0.82, 0.0, 50.0, 50.0) # cut not inside - with pytest.raises(ValueError): - livingston_lewis(good, 0.82, 12.0, 50.0, 28.0) # score below min - with pytest.raises(ValueError): - livingston_lewis([20.0, 20.0, 20.0], 0.82, 0.0, 50.0, 10.0) # var 0 - -def test_gtheory_pi_matches_paper_tables(): - """Huebner & Lucht (2019) appendix pi_dat; asserts read the crate's - var/var_raw/d_study outputs (Tables 3-4 reproduced by the Rust tests - to full precision; pinned literals from the same verified pipeline).""" - from fast_mlsirm import gtheory_pi - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "gtheory_pi"): - pytest.skip("compiled core built without gtheory_pi") - - x = np.array( - [ - [9.0, 9.0, 7.0, 4.0], - [9.0, 8.0, 4.0, 6.0], - [8.0, 8.0, 6.0, 2.0], - [9.0, 8.0, 6.0, 3.0], - [10.0, 9.0, 8.0, 7.0], - [6.0, 4.0, 5.0, 1.0], - ] - ) - r = gtheory_pi(x, n_i_prime=[4, 8]) - tol = 1e-9 - assert abs(r.var[0] - 1.905555555556) < tol - assert abs(r.var[1] - 4.027777777778) < tol - assert abs(r.var[2] - 1.277777777778) < tol - assert r.var == r.var_raw # all raw components positive here - d4 = r.d_study[0] - assert d4.n_i_prime == 4 - assert abs(d4.rel_error_var - 1.277777777778 / 4.0) < tol - assert abs(d4.generalizability - 0.856429463171) < tol - assert abs(d4.dependability - 0.589600343790) < tol - d8 = r.d_study[1] - # sigma^2(p) invariant across n' (weak identity); delta halves. - assert abs(d8.rel_error_var - d4.rel_error_var / 2.0) < tol - assert d8.generalizability > d4.generalizability - - -def test_gtheory_pio_matches_paper_tables_and_rejects_bad_input(): - """Huebner & Lucht (2019) appendix pio_cross_dat (6 x 4 x 2); asserts - read the crate's 7-component var vector (order p,i,o,pi,po,io,pio) and - D-study coefficients pinned from the verified pipeline (Tables 5-6).""" - from fast_mlsirm import gtheory_pi, gtheory_pio - from fast_mlsirm.fitstats import _core_module - - core = _core_module() - if core is None or not hasattr(core, "gtheory_pio"): - pytest.skip("compiled core built without gtheory_pio") - - # pio_cross_dat in crate layout x[p*n_i*n_o + i*n_o + o], i.e. a - # (6, 4, 2) persons x items x occasions array. - scores = np.array( - [ - 9.0, 9.0, 9.0, 8.0, 7.0, 5.0, 4.0, 5.0, - 9.0, 6.0, 8.0, 5.0, 4.0, 3.0, 6.0, 3.0, - 8.0, 8.0, 8.0, 7.0, 6.0, 3.0, 2.0, 2.0, - 9.0, 9.0, 8.0, 6.0, 6.0, 6.0, 3.0, 2.0, - 10.0, 8.0, 9.0, 8.0, 8.0, 9.0, 7.0, 7.0, - 6.0, 3.0, 4.0, 2.0, 5.0, 3.0, 1.0, 2.0, - ] - ).reshape(6, 4, 2) - r = gtheory_pio(scores, n_prime=[(4, 2)]) - tol = 1e-9 - want = [ - 2.483333333333, 3.066666666667, 0.577777777778, 0.766666666667, - 0.088888888889, 0.022222222222, 0.831944444444, - ] - for got, w in zip(r.var, want): - assert abs(got - w) < tol - d = r.d_study[0] - assert d.n_i_prime == 4 and d.n_o_prime == 2 - # delta = pi/4 + po/2 + pio/8 (kills a dropped-term mutation). - assert abs(d.rel_error_var - (want[3] / 4.0 + want[4] / 2.0 + want[6] / 8.0)) < tol - assert 0.0 < d.dependability < d.generalizability < 1.0 - - with pytest.raises(ValueError): - gtheory_pio(scores[:1]) # n_p < 2 - with pytest.raises(ValueError): - gtheory_pi(np.array([[1.0, np.nan], [2.0, 3.0]])) # non-finite - - -def test_minres_fa_oracle_parity_9x2(): - """Rust minres FA matches the pinned scipy L-BFGS-B oracle (session - oracle_minres.py) on the 9-var / 2-factor exact-structure matrix. - Asserts read crate outputs via the binding (loadings/uniquenesses/ - objective/converged).""" - import numpy as np - - from fast_mlsirm import minres_fa - - lam = np.zeros((9, 2)) - lam[:5, 0] = [0.8, 0.7, 0.6, 0.5, 0.4] - lam[4:, 1] = [0.3, 0.7, 0.6, 0.5, 0.45] - s = lam @ lam.T - np.fill_diagonal(s, 1.0) - r = minres_fa(s, 2) - assert r.converged - assert r.objective < 1e-9 - assert r.loadings.shape == (9, 2) - want_row0 = (0.780565885133, -0.175262374782) - assert abs(r.loadings[0, 0] - want_row0[0]) < 5e-5 - assert abs(r.loadings[0, 1] - want_row0[1]) < 5e-5 - want_psi = [0.36, 0.51, 0.64, 0.75, 0.75, 0.51, 0.64, 0.75, 0.7975] - assert np.allclose(r.uniquenesses, want_psi, atol=5e-5) - assert np.allclose(r.communalities, (r.loadings**2).sum(axis=1), atol=1e-12) - - -def test_omega_total_1f_oracle_parity(): - """omega_total_1f on the pinned 6-var sampled-data correlation matrix - reproduces the oracle omega (0.825058734426). Asserts read the crate - omega and embedded fit via the binding.""" - import numpy as np - - from fast_mlsirm import omega_total_1f - - r6 = np.array( - [ - [1.0, 0.498568868921, 0.625966200012, 0.370348287807, 0.540999349418, 0.424034177377], - [0.498568868921, 1.0, 0.511016876110, 0.334681533406, 0.380581491599, 0.347762451610], - [0.625966200012, 0.511016876110, 1.0, 0.345954222399, 0.593952865689, 0.454414314062], - [0.370348287807, 0.334681533406, 0.345954222399, 1.0, 0.327886204696, 0.311070632680], - [0.540999349418, 0.380581491599, 0.593952865689, 0.327886204696, 1.0, 0.415636442046], - [0.424034177377, 0.347762451610, 0.454414314062, 0.311070632680, 0.415636442046, 1.0], - ] - ) - o = omega_total_1f(r6) - assert o.fa.converged - assert abs(o.omega_total - 0.825058734426) < 1e-5 - assert abs(o.fa.loadings[0, 0] - 0.774100663868) < 5e-5 - assert abs(o.fa.uniquenesses[2] - 0.345079760758) < 5e-5 - - -def test_separation_reliability_matches_numpy_oracle(): - # Pinned numpy oracle (ddof=1 var; mean se^2); asserts read wrapper - # outputs which read the Rust crate values. - from fast_mlsirm import separation_reliability - - x = np.array([-1.8, -0.6, 0.15, 0.9, 2.1, -0.35, 1.4]) - se = np.array([0.45, 0.38, 0.35, 0.37, 0.52, 0.36, 0.41]) - r = separation_reliability(x, se) - assert abs(r.ssd - 1.743690476190) < 1e-10 - assert abs(r.mse - 0.167771428571) < 1e-10 - assert abs(r.sep_rel - 0.903783709975) < 1e-10 - assert abs(r.sep_index - 3.064841016127) < 1e-10 - - -def test_separation_reliability_negative_unclamped(): - from fast_mlsirm import separation_reliability - - r = separation_reliability( - np.array([0.0, 0.1, -0.1, 0.05]), np.array([1.0, 1.0, 1.0, 1.0]) - ) - assert r.sep_rel < 0.0 - assert np.isnan(r.sep_index) -def test_glb_fa_oracle_s9(): - """S9 oracle fixture from oracle_glbfa.py (asserts read wrapper->crate - outputs; mutation kills executed at the Rust level).""" - import numpy as np - from fast_mlsirm import glb_fa - - L = np.zeros((9, 2)) - L[:5, 0] = [0.8, 0.7, 0.6, 0.5, 0.4] - L[4:, 1] = [0.3, 0.7, 0.6, 0.5, 0.45] - R = L @ L.T - np.fill_diagonal(R, 1.0) - res = glb_fa(R) - assert res.nf == 2 - assert abs(res.glb - 0.730905233399) < 1e-5 - assert res.communalities.shape == (9,) - - -def test_glb_fa_from_data_and_errors(): - import numpy as np - import pytest - from fast_mlsirm import glb_fa, glb_fa_from_data - - rng = np.random.default_rng(7) - f = rng.standard_normal(500) - lam = np.array([0.7, 0.6, 0.8, 0.5, 0.65, 0.75]) - x = np.outer(f, lam) + rng.standard_normal((500, 6)) * np.sqrt(1 - lam**2) - res = glb_fa_from_data(x) - assert 0.5 < res.glb <= 1.0 - assert 1 <= res.nf < 6 - with pytest.raises(ValueError): - glb_fa(np.ones((2, 3))) - with pytest.raises(ValueError): - glb_fa_from_data(np.full((5, 4), np.nan)) - - -class TestSelectionUtility: - """Selection utility (Taylor-Russell / Naylor-Shine / BCG) wrappers. - - Oracle values pinned from scipy (tests/oracles/oracle_utility.py); asserts read - the crate outputs returned through the wrapper. Tolerance 1e-7 reflects - the crate's Acklam inverse-normal precision, not slack. - """ - - def test_selection_utility_oracle(self): - import fast_mlsirm as fm - - r = fm.selection_utility(n=1, sdy=10000, rxy=0.5, sr=0.3) - assert abs(r.pux - 0.579487690333456) < 1e-7 - assert abs(r.utility_gain - 5794.8769033346) < 1e-3 - r2 = fm.selection_utility(n=50, sdy=8000, rxy=0.4, sr=0.2, - cost_total=25000, period=3) - assert abs(r2.utility_gain - 646908.6089787399) < 1e-2 - - def test_taylor_russell_oracle_and_errors(self): - import pytest - - import fast_mlsirm as fm - - r = fm.taylor_russell(rxy=0.5, sr=0.3, br=0.2) - assert abs(r.success_ratio - 0.384157434057053) < 1e-7 - assert abs(r.q_joint - 0.115247230217116) < 1e-7 - # rho=0 analytic anchor: success == br - r0 = fm.taylor_russell(rxy=0.0, sr=0.3, br=0.7) - assert abs(r0.success_ratio - 0.7) < 1e-6 - with pytest.raises(ValueError): - fm.taylor_russell(rxy=1.0, sr=0.3, br=0.5) - with pytest.raises(ValueError): - fm.selection_utility(n=0, sdy=1, rxy=0.5, sr=0.3) - - -class TestSympsonHetter: - """Sympson-Hetter exposure control wrapper (mlsirm_core::exposure). - - Every assert reads crate outputs returned through the wrapper; the sum - identity sum(exposure) == test_length is an exact counting identity of - the returned rates, not a local recomputation of the algorithm. - """ - - @staticmethod - def _pool(): - import numpy as np - - n = 30 - x = np.arange(n, dtype=float) - a = 0.6 + 1.4 * np.abs(np.sin(x * 0.37)) - b = -2.4 + 4.8 * x / (n - 1) + 0.3 * np.sin(x * 0.71) - return a, b - - def test_controls_max_exposure_and_identities(self): - import fast_mlsirm as fm - - a, b = self._pool() - r = fm.sympson_hetter( - a, b, r_max=0.25, test_length=5, n_simulees=1500, - max_iter=12, tol=0.02, seed=42, q_theta=31, - ) - assert r.converged - assert r.max_exposure <= 0.25 + 0.02 + 1e-12 - assert abs(float(r.exposure.sum()) - 5.0) < 1e-9 - assert (r.exposure <= r.selection + 1e-12).all() - assert ((r.k > 0.0) & (r.k <= 1.0)).all() - assert r.n_iter == len(r.history_max_exposure) - assert abs(r.max_exposure - float(r.history_max_exposure[-1])) < 1e-15 - - def test_rmax_one_unconstrained_and_errors(self): - import numpy as np - import pytest - - import fast_mlsirm as fm - - a, b = self._pool() - r = fm.sympson_hetter( - a, b, r_max=1.0, test_length=5, n_simulees=400, - max_iter=6, seed=7, q_theta=21, - ) - assert r.n_iter == 1 - assert (r.k == 1.0).all() - assert np.array_equal(r.exposure, r.selection) - with pytest.raises(ValueError): - fm.sympson_hetter(a, b, r_max=0.05, test_length=5) # infeasible - with pytest.raises(ValueError): - fm.sympson_hetter(a, b, r_max=0.25, test_length=0) - # Boundary coercion discipline: non-integral floats, bools, and - # negative seeds are ValueError, not silent truncation/OverflowError. - with pytest.raises(ValueError): - fm.sympson_hetter(a, b, r_max=0.25, test_length=5.9) - with pytest.raises(ValueError): - fm.sympson_hetter(a, b, r_max=0.25, test_length=5, n_simulees=True) - with pytest.raises(ValueError): - fm.sympson_hetter(a, b, r_max=0.25, test_length=5, seed=-1) - with pytest.raises(ValueError): - fm.sympson_hetter(a, b, r_max=0.25, test_length=float("inf")) - with pytest.raises(ValueError): - fm.sympson_hetter(a, b, r_max=0.25, test_length=np.float64("nan")) - - -class TestAStratified: - """a-stratified multistage CAT wrapper (mlsirm_core::exposure). - - Every assert reads crate outputs returned through the wrapper; the - per-stratum sum identity sum(exposure[stratum == k]) == stage_lengths[k] - is an exact counting identity of returned values, not a local - recomputation of the algorithm. - """ - - @staticmethod - def _pool(): - n = 30 - x = np.arange(n, dtype=float) - a = 0.6 + 1.4 * np.abs(np.sin(x * 0.37)) - b = -2.4 + 4.8 * x / (n - 1) + 0.3 * np.sin(x * 0.71) - return a, b - - def test_per_stratum_identity_and_strata(self): - import fast_mlsirm as fm - - a, b = self._pool() - r = fm.a_stratified( - a, b, n_strata=4, test_length=10, n_simulees=200, - seed=42, q_theta=31, - ) - assert list(r.stage_lengths) == [3, 3, 2, 2] - for k in range(4): - got = float(r.exposure[r.stratum == k].sum()) - assert abs(got - r.stage_lengths[k]) < 1e-9 - assert abs(float(r.exposure.sum()) - 10.0) < 1e-9 - assert r.max_exposure == float(r.exposure.max()) - assert np.isfinite(r.theta_rmse) and r.theta_rmse >= 0.0 - assert np.isfinite(r.theta_bias) - # Strata are ascending in a: mean a must not decrease across strata - # (reads the crate stratum assignment). - means = [float(a[r.stratum == k].mean()) for k in range(4)] - assert all(m2 >= m1 for m1, m2 in zip(means, means[1:])) - - def test_validation_and_boundary_coercion(self): - import fast_mlsirm as fm - - a, b = self._pool() - with pytest.raises(ValueError): - fm.a_stratified(a, b, n_strata=0, test_length=10) - with pytest.raises(ValueError): - fm.a_stratified(a, b, n_strata=11, test_length=10) - with pytest.raises(ValueError): - fm.a_stratified(a, b, n_strata=4, test_length=31) - with pytest.raises(ValueError): - fm.a_stratified(a, b, n_strata=4, test_length=10.5) - with pytest.raises(ValueError): - fm.a_stratified(a, b, n_strata=True, test_length=10) - with pytest.raises(ValueError): - fm.a_stratified(a, b, n_strata=4, test_length=10, seed=-1) - with pytest.raises(ValueError): - fm.a_stratified(a, b, n_strata=4, test_length=float("nan")) - - -class TestVelicerMap: - """Velicer (1976) minimum average partial test (O'Connor programs READ; - core oracle: independent NumPy transcription on Harman-8). Every assert - reads crate outputs through the binding.""" - - HARMAN8 = np.array( - [ - [1.000, 0.846, 0.805, 0.859, 0.473, 0.398, 0.301, 0.382], - [0.846, 1.000, 0.881, 0.826, 0.376, 0.326, 0.277, 0.415], - [0.805, 0.881, 1.000, 0.801, 0.380, 0.319, 0.237, 0.345], - [0.859, 0.826, 0.801, 1.000, 0.436, 0.329, 0.327, 0.365], - [0.473, 0.376, 0.380, 0.436, 1.000, 0.762, 0.730, 0.629], - [0.398, 0.326, 0.319, 0.329, 0.762, 1.000, 0.583, 0.577], - [0.301, 0.277, 0.237, 0.327, 0.730, 0.583, 1.000, 0.539], - [0.382, 0.415, 0.345, 0.365, 0.629, 0.577, 0.539, 1.000], - ] - ) - - def test_harman8_oracle(self): - # Reads crate f2/f4/retained via the binding; kills any wrapper - # reshaping or dict-key regression on top of the Rust-side kills. - res = velicer_map(self.HARMAN8) - np.testing.assert_allclose( - res.f2, - [0.312474786, 0.245120888, 0.066444959, 0.127594380, - 0.204202701, 0.271829458, 0.434591269, 1.0], - atol=1e-7, - ) - assert res.retained_f2 == 2 - assert res.retained_f4 == 2 - assert res.f4[2] == pytest.approx(0.011930167, abs=1e-7) - - def test_identity_invalid_rows(self): - res = velicer_map(np.eye(5)) - assert res.f2[0] == 0.0 - assert res.retained_f2 == 0 - assert np.isnan(res.f2[1:]).all() - - def test_data_path_and_default_max_m(self): - rng = np.random.default_rng(7) - f = rng.standard_normal((300, 2)) - lam = np.zeros((6, 2)) - lam[:3, 0] = 0.8 - lam[3:, 1] = 0.8 - data = f @ lam.T + np.sqrt(1 - 0.64) * rng.standard_normal((300, 6)) - res = velicer_map_from_data(data) - assert len(res.f2) == 6 # max_m defaults to p - 1 -> rows 0..5 - assert res.retained_f2 == 2 - - def test_validation(self): - with pytest.raises(ValueError): - velicer_map(np.ones((3, 2))) - with pytest.raises(ValueError): - velicer_map(self.HARMAN8, max_m=8) # > p - 1 - - -def test_raju_area_unsigned_oracle(): - """Wrapper returns the crate's unsigned areas; pinned to independently - computed constants (asserts read the crate h/z/p returned via the pyd).""" - a_f = np.array([1.2, 0.8, 1.5, 0.6]) - b_f = np.array([0.5, 0.0, -1.0, 0.8]) - a_r = np.array([0.8, 1.7, 0.7, 1.4]) - b_r = np.array([-0.3, 0.9, 1.1, -0.4]) - se = np.full(4, 0.1) - cov = np.zeros(4) - res = raju_area(a_r, b_r, se, se, cov, a_f, b_f, se, se, cov) - expect = [0.9140059278766983, 1.2023709167732664, 2.193856226242703, 1.6756394651297617] - assert np.allclose(res["h"], expect, atol=1e-9) - assert res["signed"] is False - assert np.all(res["z"] > 0) and np.all(res["p_value"] < 1) - # Crossing item: unsigned area strictly exceeds |signed area| = 0.8. - assert res["h"][0] > 0.8 + 1e-3 - - -def test_raju_area_signed_orientation_and_3pl(): - """Signed h = b_foc - b_ref (positive = harder for focal); common-c 3PL - scales h/se by (1 - c) and leaves z untouched (crate values throughout).""" - a_r, b_r = np.array([0.8]), np.array([-0.3]) - a_f, b_f = np.array([1.2]), np.array([0.5]) - se, cov = np.array([0.1]), np.array([0.0]) - s = raju_area(a_r, b_r, se, se, cov, a_f, b_f, se, se, cov, signed=True) - assert abs(s["h"][0] - 0.8) < 1e-12 - two = raju_area(a_r, b_r, se, se, cov, a_f, b_f, se, se, cov) - three = raju_area(a_r, b_r, se, se, cov, a_f, b_f, se, se, cov, guess=np.array([0.35])) - assert abs(three["h"][0] - 0.65 * two["h"][0]) < 1e-12 - assert abs(three["se"][0] - 0.65 * two["se"][0]) < 1e-12 - assert abs(three["z"][0] - two["z"][0]) < 1e-12 - # Detection flag reads the crate dif_items: strong DIF must be flagged. - tiny = np.array([0.01]) - strong = raju_area(a_r, b_r, tiny, tiny, cov, a_f, b_f, tiny, tiny, cov, signed=True) - assert list(strong["dif_items"]) == [0] - - -def test_raju_area_error_paths(): - ok, se, cov = np.array([1.0]), np.array([0.1]), np.array([0.0]) - b = np.array([0.0]) - with pytest.raises(ValueError, match="discriminations"): - raju_area(np.array([-1.0]), b, se, se, cov, ok, b, se, se, cov) - with pytest.raises(ValueError, match="alpha"): - raju_area(ok, b, se, se, cov, ok, b, se, se, cov, alpha=0.0) - with pytest.raises(ValueError, match="guessing"): - raju_area(ok, b, se, se, cov, ok, b, se, se, cov, guess=np.array([1.0])) - with pytest.raises(ValueError, match="length"): - raju_area(ok, b, se, se, cov, np.array([1.0, 1.0]), b, se, se, cov) - with pytest.raises(ValueError, match="semi-definite"): - raju_area(ok, b, se, se, np.array([-0.011]), ok, b, se, se, cov) - with pytest.raises(ValueError, match="1-D"): - raju_area(np.ones((1, 1)), b, se, se, cov, ok, b, se, se, cov) - - -class TestKlInformation: - """Chang-Ying (1996) KL global-information CAT selection (paper READ via - Digital Conservancy; cross-checked against catR KL.R). Pinned oracles were - computed twice independently (Decimal 60-digit Simpson). Every assert - reads crate outputs returned through the binding.""" - - def test_pinned_oracle_and_select(self): - import fast_mlsirm as fm - - # 2PL pinned area oracle (kills sign/Q-term/quadrature mutations). - v = fm.kl_information( - np.array([1.2]), np.array([0.3]), theta0=0.5, delta=1.0 - ) - assert abs(float(v[0]) - 0.114454883565329) < 1e-12 - - # Selection-pool oracle: full index vector + argmax + delta = r/sqrt(n). - a = np.array([0.8, 1.5, 1.0, 2.0, 0.6]) - b = np.array([-1.0, 0.4, 0.0, 1.2, 0.3]) - c = np.array([0.0, 0.0, 0.15, 0.1, 0.0]) - adm = np.zeros(5, dtype=bool) - r = fm.kl_select( - a, b, c, administered=adm, theta0=0.25, n_administered=4, r=3.0 - ) - assert r["selected"] == 1 - assert abs(r["delta"] - 1.5) < 1e-15 - expected = np.array([ - 0.139656556289429, 0.562390595050374, 0.194256509461839, - 0.349021047637362, 0.0992541089721506, - ]) - assert np.max(np.abs(r["index"] - expected)) < 1e-12 - # Masking applies to selection only: administered item keeps its index. - adm2 = adm.copy(); adm2[1] = True - r2 = fm.kl_select( - a, b, c, administered=adm2, theta0=0.25, n_administered=4, r=3.0 - ) - assert r2["selected"] == 3 - assert abs(r2["index"][1] - expected[1]) < 1e-12 - - def test_validation(self): - import fast_mlsirm as fm - - a = np.array([1.0, 1.2]); b = np.array([0.0, 0.5]) - adm = np.zeros(2, dtype=bool) - with pytest.raises(ValueError): - fm.kl_information(a.reshape(1, 2), b, theta0=0.0, delta=1.0) - with pytest.raises(ValueError): - fm.kl_select(a, b, administered=adm, theta0=0.0, n_administered=0) - with pytest.raises(ValueError): - fm.kl_select( - a, b, administered=np.ones(2, dtype=bool), - theta0=0.0, n_administered=3, - ) - - -class TestOwenCat: - """Owen (1975) approximate Bayesian sequential CAT (Rust core). - - Every assert reads crate outputs returned through the binding; oracles - are the adversarial-spec-review pinned values (exact-posterior numerical - integration). Killing mutations: update sign flips / wrong denominators - change the pinned moments; argmin->argmax changes the administered order. - """ - - def test_pinned_update_oracles(self): - import fast_mlsirm as fm - - mu, s2 = fm.owen_update(1.5, 0.3, 0.0, correct=True, mu=0.2, sig2=1.2) - assert abs(mu - 0.993708628794091) < 5e-7 - assert abs(s2 - 0.627945890895211) < 5e-7 - mu, s2 = fm.owen_update(1.5, 0.3, 0.0, correct=False, mu=0.2, sig2=1.2) - assert abs(mu - -0.500813523713129) < 5e-7 - assert abs(s2 - 0.657719958655775) < 5e-7 - mu, s2 = fm.owen_update(1.5, 0.3, 0.2, correct=True, mu=0.2, sig2=1.2) - assert abs(mu - 0.717701908086462) < 5e-7 - assert abs(s2 - 0.969762981710486) < 5e-7 - - def test_cat_trajectory_oracle(self): - import fast_mlsirm as fm - - a = np.array([0.9, 1.4, 1.1, 2.0, 0.7, 1.6]) - b = np.array([-1.2, 0.5, 0.0, 1.0, -0.4, 0.2]) - c = np.array([0.0, 0.1, 0.0, 0.15, 0.0, 0.0]) - resp = np.array([1, 0, 1, 0, 1, 1]) - r = fm.owen_cat(a, b, c, responses=resp, test_length=4) - assert r["administered"] == [2, 1, 5, 3] - assert abs(r["mu"] - 0.33188033525266003) < 1e-5 - assert abs(r["sig2"] - 0.20724352639310067) < 1e-5 - assert abs(r["mu_trace"][0] - 0.5903867604819626) < 1e-5 - assert abs(r["sig2_trace"][1] - 0.4123387131860715) < 1e-5 - # Variance-threshold stopping (Owen's rule). - r = fm.owen_cat(a, b, c, responses=resp, test_length=4, sig2_stop=0.7) - assert len(r["administered"]) == 1 - assert r["sig2"] <= 0.7 - - def test_validation(self): - import fast_mlsirm as fm - - a = np.array([1.0, 1.2]) - b = np.array([0.0, 0.5]) - resp = np.array([1, 0]) - with pytest.raises(ValueError): - fm.owen_update(1.0, 0.0, 1.0, correct=True, mu=0.0, sig2=1.0) - with pytest.raises(ValueError): - fm.owen_cat(a, b, responses=resp, test_length=3) - with pytest.raises(ValueError): - fm.owen_cat(a, b, responses=np.array([2, 0]), test_length=1) - with pytest.raises(ValueError): - fm.owen_cat(a, b, responses=resp, test_length=1, sig2_stop=0.0) - - def test_response_validation_before_cast(self): - # Impl-review finding: the uint8 cast used to launder 1.2 -> 1, - # 0.9/-0.2/nan -> 0, and -1 -> 255 past validation. - import fast_mlsirm as fm - - a = np.array([1.0, 1.2]) - b = np.array([0.0, 0.5]) - for bad in ([1.2, 0], [0.9, 1], [-0.2, 1], [np.nan, 1], [-1, 0]): - with pytest.raises(ValueError): - fm.owen_cat(a, b, responses=np.array(bad), test_length=1) - - def test_complex_responses_rejected(self): - # Round-2 impl-review finding: astype(float64) silently dropped the - # imaginary part, laundering complex inputs past validation. - import fast_mlsirm as fm - - with pytest.raises(ValueError): - fm.owen_cat( - np.array([1.0, 1.2]), - np.array([0.0, 0.5]), - responses=np.array([1 + 2j, 0 + 0j]), - test_length=1, - ) - - -class TestCcatSelect: - """Kingsbury & Zara (1989) CCAT content balancing (Rust core parity).""" - - def _pool(self): - import numpy as np - - return dict( - a=np.array([1.0, 1.5, 0.8, 2.0, 1.2, 0.9]), - b=np.array([-0.5, 0.2, 0.0, 0.8, -0.2, 0.4]), - c=np.array([0.0, 0.1, 0.0, 0.2, 0.0, 0.0]), - groups=np.array([0, 0, 1, 1, 0, 1]), - targets=np.array([0.6, 0.4]), - ) - - def test_pinned_oracle(self): - # Every assert reads the dict returned by the crate binding. - import numpy as np - - from fast_mlsirm import ccat_select - - p = self._pool() - r = ccat_select( - p["a"], - p["b"], - p["c"], - groups=p["groups"], - targets=p["targets"], - administered=np.array([True, False, False, True, False, False]), - theta0=0.1, - ) - assert r["group"] == 0 - assert r["selected"] == 1 - assert abs(r["discrepancy"][0] - 0.1) < 1e-12 - assert abs(r["info"][1] - 0.451012779418390198) < 1e-12 - assert abs(r["info"][4] - 0.348583393587808097) < 1e-12 - - def test_balancing_overrides_global_max_info(self): - import numpy as np - - from fast_mlsirm import ccat_select - - p = self._pool() - r = ccat_select( - p["a"], - p["b"], - p["c"], - groups=p["groups"], - targets=p["targets"], - administered=np.array([True, False, False, False, False, False]), - theta0=0.1, - ) - assert r["group"] == 1 - assert r["selected"] == 3 - # unconstrained max-info would have picked item 1 - assert int(np.argmax(np.where([True] + [False] * 5, -np.inf, r["info"]))) == 1 - - def test_group_validation_before_cast(self): - import numpy as np - import pytest - - from fast_mlsirm import ccat_select - - p = self._pool() - adm = np.array([False] * 6) - for bad in ( - np.array([0, 0, 1, 1, 0, -1]), - np.array([0.0, 0.5, 1.0, 1.0, 0.0, 1.0]), - np.array([0, 0, 1, 1, 0, 1], dtype=complex), - ): - with pytest.raises(ValueError): - ccat_select( - p["a"], - p["b"], - p["c"], - groups=bad, - targets=p["targets"], - administered=adm, - theta0=0.0, - ) - - def test_error_paths(self): - import numpy as np - import pytest - - from fast_mlsirm import ccat_select - - p = self._pool() - with pytest.raises(ValueError): - ccat_select( - p["a"], - p["b"], - p["c"], - groups=p["groups"], - targets=np.array([0.5, 0.6]), - administered=np.array([False] * 6), - theta0=0.0, - ) - with pytest.raises(ValueError): - ccat_select( - p["a"], - p["b"], - p["c"], - groups=p["groups"], - targets=p["targets"], - administered=np.array([True] * 6), - theta0=0.0, - ) diff --git a/tests/test_scoring_methods.py b/tests/test_scoring_methods.py deleted file mode 100644 index ea285fd97..000000000 --- a/tests/test_scoring_methods.py +++ /dev/null @@ -1,266 +0,0 @@ -"""EAP/MAP/EAPsum scoring, QMC/MC estimator rules, and FIPC via the public API.""" - -from __future__ import annotations - -import numpy as np -import pytest - -import fast_mlsirm.serving as serving -from fast_mlsirm.config import FitConfig -from fast_mlsirm.estimators.marginal import fit_marginal_numpy -from fast_mlsirm.fit import fit -from fast_mlsirm.serving import export_serving_bundle, score_respondents, serving_prior - - -def _simulate(seed=0, P=400, I=12, D=2, gamma=1.0): - rng = np.random.default_rng(seed) - fid = np.array([i % D for i in range(I)]) - theta = rng.standard_normal((P, D)) - xi = rng.standard_normal((P, 2)) - zeta = rng.standard_normal((I, 2)) * 0.8 - eta = theta[:, fid] + 0.3 - gamma * np.linalg.norm(xi[:, None] - zeta[None], axis=2) - y = (rng.random((P, I)) < 1 / (1 + np.exp(-eta))).astype(float) - return y, fid - - -def _bundle(seed=0, **fit_kwargs): - y, fid = _simulate(seed=seed) - cfg = FitConfig( - model="MLS2PLM", - estimator="mmle", - max_iter=240, - tolerance=1e-2, - q_theta=15, - q_xi=7, - **fit_kwargs, - ) - result = fit(y, fid, cfg) - codes = [f"I{i}" for i in range(y.shape[1])] - return y, fid, export_serving_bundle(result, codes, fid, q_theta=15, q_xi=7), codes - - -def test_map_scoring_matches_eap_loosely_and_reports_se(): - y, fid, bundle, codes = _bundle(seed=1) - payload = {codes[0]: 1, codes[2]: 1, codes[4]: 0, codes[6]: 0} - eap = score_respondents(bundle, payload, method="eap")[0] - mp = score_respondents(bundle, payload, method="map")[0] - assert mp["converged"] - for d in range(bundle["n_dims"]): - assert abs(eap["theta"][d] - mp["theta"][d]) < 0.7 - assert mp["theta_sd"][d] > 0.0 - # MAP shrinks toward the mode of a unimodal posterior — same sign as EAP - assert np.sign(eap["theta"][0]) == np.sign(mp["theta"][0]) or abs(eap["theta"][0]) < 0.15 - - -def test_eapsum_tables_in_bundle_and_lookup_scoring(): - y, fid, bundle, codes = _bundle(seed=2) - tables = bundle["eapsum_tables"] - assert tables is not None and len(tables) == bundle["n_dims"] - for t in tables: - assert len(t["eap"]) == t["n_items_dim"] + 1 - assert abs(sum(t["score_prob"]) - 1.0) < 1e-8 - assert all(b >= a - 1e-9 for a, b in zip(t["eap"], t["eap"][1:])) - # complete response vector -> lookup scoring works and tracks EAP scoring - full = {c: int(v) for c, v in zip(codes, y[0])} - via_table = score_respondents(bundle, full, method="eapsum", device="auto")[0] - via_table_cpu = score_respondents(bundle, full, method="eapsum", device="cpu")[0] - np.testing.assert_allclose(via_table["theta"], via_table_cpu["theta"], atol=2e-4) - np.testing.assert_allclose(via_table["theta_sd"], via_table_cpu["theta_sd"], atol=2e-4) - via_eap = score_respondents(bundle, full, method="eap")[0] - for d in range(bundle["n_dims"]): - # summed-score EAP loses the latent-space detail; loose agreement only - assert abs(via_table["theta"][d] - via_eap["theta"][d]) < 0.8 - # incomplete pattern must be rejected for the lookup path - with pytest.raises(ValueError, match="complete responses"): - score_respondents(bundle, {codes[0]: 1}, method="eapsum") - - -def test_eapsum_scoring_fails_closed_without_rust(monkeypatch): - _, _, bundle, codes = _bundle(seed=12) - monkeypatch.setattr(serving, "_core_module", lambda: None) - with pytest.raises(RuntimeError, match="compiled Rust core"): - score_respondents(bundle, {code: 1 for code in codes}, method="eapsum") - - -def test_prior_override_conditions_scores(): - _, _, bundle, codes = _bundle(seed=3) - payload = {codes[0]: 1, codes[1]: 0} - n_dims = bundle["n_dims"] - base = score_respondents(bundle, payload)[0] - shifted = score_respondents( - bundle, payload, prior=(np.full(n_dims, 1.0), np.ones(n_dims)) - )[0] - assert all(s > b for s, b in zip(shifted["theta"], base["theta"])) - - -def test_serving_prior_widens_for_multilevel_bundles(): - y, fid = _simulate(seed=4, P=300) - cid = np.arange(len(y)) % 10 - cfg = FitConfig( - model="MLS2PLM", - estimator="mmle", - max_iter=160, - tolerance=1e-2, - q_theta=15, - q_xi=7, - ) - result = fit(y, fid, cfg, cluster_id=cid) - bundle = export_serving_bundle( - result, [f"I{i}" for i in range(y.shape[1])], fid, q_theta=15, q_xi=7 - ) - mean, sd = serving_prior(bundle) - sigma_u = bundle["population"]["sigma_u"] - assert np.allclose(mean, 0.0) - assert np.allclose(sd, np.sqrt(1.0 + sigma_u**2)) - - -@pytest.mark.parametrize("rule", ["qmc", "mc"]) -def test_qmc_mc_rules_parity_between_backends(rule): - y, fid = _simulate(seed=5, P=100, I=8) - results = {} - for backend in ("rust", "numpy"): - cfg = FitConfig( - model="MLS2PLM", - estimator="mmle", - max_iter=150, - tolerance=1e-2, - backend=backend, - rust_device="cpu", - q_theta=11, - xi_rule=rule, - xi_points=32, - xi_seed=9, - ) - results[backend] = fit(y, fid, cfg) - trace = np.asarray(results[backend].loglik_trace) - final_change = float(trace[-1] - trace[-2]) - assert results[backend].convergence_status == "converged" - assert results[backend].n_iter < cfg.max_iter - assert np.all(np.isfinite(trace)) - assert np.all(np.diff(trace) >= -1e-9) - assert 0.0 <= final_change < cfg.tolerance - np.testing.assert_allclose( - results["rust"].params.b, results["numpy"].params.b, atol=1e-9 - ) - np.testing.assert_allclose( - results["rust"].loglik_trace[-1], results["numpy"].loglik_trace[-1], atol=1e-9 - ) - - -def test_fipc_public_api_freezes_anchors_and_frees_population(): - rng = np.random.default_rng(11) - P, I = 600, 12 - fid = np.zeros(I, dtype=np.int64) - a_true = 0.8 + 0.6 * rng.random(I) - b_true = -1.0 + 2.0 * rng.random(I) - theta = 0.8 + rng.standard_normal(P) # shifted population - eta = a_true[None, :] * theta[:, None] + b_true[None, :] - y = (rng.random((P, I)) < 1 / (1 + np.exp(-eta))).astype(float) - anchors = dict( - fixed=np.arange(I) < 6, - alpha=np.log(a_true), - b=b_true, - zeta=np.zeros((I, 1)), - tau=-30.0, - ) - cfg = FitConfig( - model="ULS2PLM", estimator="mmle", max_iter=80, q_theta=15, latent_dim=1 - ) - result = fit(y, fid, cfg, anchors=anchors) - np.testing.assert_allclose(result.params.b[:6], b_true[:6]) - np.testing.assert_allclose(np.exp(result.params.alpha[:6]), a_true[:6]) - pop = result.population - assert pop["kind"] == "singlefree" - np.testing.assert_array_equal(pop["fixed_items"], anchors["fixed"]) - assert pop["tau_fixed"] - assert 0.4 < pop["mu"][0, 0] < 1.3, f"FIPC mean should recover ~0.8: {pop['mu']}" - - -def test_fipc_guards(): - y, fid = _simulate(seed=6, P=60, I=6) - anchors = dict( - fixed=np.zeros(6, dtype=bool), - alpha=np.zeros(6), - b=np.zeros(6), - zeta=np.zeros((6, 2)), - ) - with pytest.raises(ValueError): - fit(y, fid, FitConfig(model="MLS2PLM", estimator="mmle", max_iter=3), anchors=anchors) - with pytest.raises(ValueError, match="require estimator"): - fit(y, fid, FitConfig(model="MLS2PLM", estimator="jmle"), anchors=anchors) - - -@pytest.mark.parametrize("backend", ["rust", "numpy"]) -def test_fipc_rejects_unidentified_and_nonfinite_anchor_contracts(backend): - y, fid = _simulate(seed=17, P=80, I=6) - base = dict( - fixed=np.array([True, True, False, False, False, False]), - alpha=np.zeros(6), - b=np.zeros(6), - zeta=np.zeros((6, 2)), - ) - cfg = FitConfig( - model="MLS2PLM", - estimator="mmle", - backend=backend, - rust_device="cpu", - max_iter=3, - ) - with pytest.raises(ValueError, match="at least two fixed anchor items"): - fit(y, fid, cfg, anchors=base) - - finite = dict(base) - finite["fixed"] = np.ones(6, dtype=bool) - finite["tau"] = np.nan - with pytest.raises(ValueError, match="anchor tau must be finite"): - fit(y, fid, cfg, anchors=finite) - - -@pytest.mark.parametrize("backend", ["rust", "numpy"]) -@pytest.mark.parametrize( - "fixed", - [ - np.array([np.nan, np.nan, np.nan, np.nan, 0.0, 0.0]), - np.array(["false", "false", "false", "false", "", ""]), - ], - ids=["nan-numeric", "false-string"], -) -def test_fipc_rejects_nonboolean_anchor_masks(backend, fixed): - y, fid = _simulate(seed=18, P=40, I=6) - anchors = dict( - fixed=fixed, - alpha=np.zeros(6), - b=np.zeros(6), - zeta=np.zeros((6, 2)), - ) - cfg = FitConfig( - model="MLS2PLM", - estimator="mmle", - backend=backend, - rust_device="cpu", - max_iter=1, - ) - with pytest.raises(ValueError, match="anchor fixed must contain only boolean"): - fit(y, fid, cfg, anchors=anchors) - - -def test_numpy_marginal_rejects_nonboolean_anchor_mask_directly(): - y, fid = _simulate(seed=19, P=20, I=6) - anchors = dict( - fixed=np.array([np.nan, np.nan, np.nan, np.nan, 0.0, 0.0]), - alpha=np.zeros(6), - b=np.zeros(6), - zeta=np.zeros((6, 2)), - ) - with pytest.raises(ValueError, match="anchor fixed must contain only boolean"): - fit_marginal_numpy( - y, - np.ones_like(y, dtype=bool), - fid, - model="MLS2PLM", - n_dims=2, - latent_dim=2, - pop={"kind": "singlefree"}, - max_iter=1, - anchors=anchors, - ) diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py deleted file mode 100644 index 5d78a5f29..000000000 --- a/tests/test_security_hardening.py +++ /dev/null @@ -1,1589 +0,0 @@ -"""Regression tests for the Strix VULN-0001..0007 input-validation hardening: -untrusted population labels, judge labels, fit-config sizes, serving-bundle -structure/finiteness, and plausible-values response domain.""" - -from __future__ import annotations - -import io -import json -import warnings -import zipfile -from unittest.mock import patch - -import numpy as np -import pytest - -from fast_mlsirm import serving -from fast_mlsirm.cli import _load_optional_npy -from fast_mlsirm.config import ( - MAX_LATENT_DIM, - MAX_LBFGS_HISTORY, - MAX_MAX_ITER, - MAX_POLYTOMOUS_CATEGORIES, - MAX_XI_POINTS, - FitConfig, -) -from fast_mlsirm.fit import _compact_population_labels -from fast_mlsirm.estimators.marginal import fit_gpcm_numpy, score_eap -from fast_mlsirm.io import load_factor_csv, load_params, save_fit_diagnostics -from fast_mlsirm.types import FitDiagnostics -from fast_mlsirm.validation import validate_judge - - -# ---- VULN-0001: sparse/invalid population labels --------------------------- -def test_population_labels_compacted_not_unbounded(): - ids, n = _compact_population_labels(np.array([0, 999_999_999]), 2, "group_id") - assert n == 2 and ids.tolist() == [0, 1] # bounded by distinct count, not max+1 - # already-contiguous labels are unchanged - ids2, n2 = _compact_population_labels(np.array([0, 1, 1, 2, 0]), 5, "cluster_id") - assert n2 == 3 and ids2.tolist() == [0, 1, 1, 2, 0] - - -@pytest.mark.parametrize( - "bad", - [np.array([-1, 0]), np.array([0.5, 1.5]), np.array([np.nan, 1.0]), np.array([[0], [1]])], -) -def test_population_labels_reject_invalid(bad): - with pytest.raises(ValueError): - _compact_population_labels(bad, bad.shape[0] if bad.ndim == 1 else 2, "group_id") - - -# ---- VULN-0004 / VULN-0005: unbounded config sizes ------------------------- -def test_config_rejects_extreme_latent_dim(): - with pytest.raises(ValueError): - FitConfig(model="MLS2PLM", latent_dim=1_000_000_000).validate() - FitConfig(model="MLS2PLM", latent_dim=MAX_LATENT_DIM).validate() # boundary ok - - -def test_config_rejects_extreme_xi_points(): - with pytest.raises(ValueError): - FitConfig(model="MLS2PLM", xi_rule="qmc", xi_points=100_000_000).validate() - FitConfig(model="MLS2PLM", xi_rule="qmc", xi_points=MAX_XI_POINTS).validate() - - -# ---- VULN-0003: judge label coercion --------------------------------------- -@pytest.mark.parametrize( - "labels", - [np.array([0.9, 1.9]), np.array([-1.0, 0.0]), np.array([np.nan, 1.0]), - np.array([np.inf, 1.0]), np.array([0, 5]), np.array([[0], [1]])], -) -def test_validate_judge_rejects_bad_labels(labels): - with pytest.raises(ValueError): - validate_judge(labels, np.array([0, 1]), k=2) - - -# ---- serving-bundle helpers ------------------------------------------------ -def _bundle(n_items=1, n_dims=1, latent_dim=1): - return { - "schema_version": serving.SCHEMA_VERSION, - "model": "MIRT", - "n_items": n_items, - "n_dims": n_dims, - "latent_dim": latent_dim, - "quadrature": {"q_theta": 7, "q_xi": 7}, - "eps_distance": 1e-8, - "tau": -30.0, - "population": None, - "items": [ - {"code": f"q{j}", "factor_id": 0, "alpha": 0.0, "b": 0.0, "zeta": [0.0] * latent_dim} - for j in range(n_items) - ], - } - - -def _oversized_npy_header() -> bytes: - payload = io.BytesIO() - np.lib.format.write_array_header_1_0( - payload, - { - "descr": np.dtype(" ValueError, not KeyError ------ -def test_score_respondents_rejects_bundle_missing_items(): - with pytest.raises(ValueError): - serving.score_respondents({"schema_version": serving.SCHEMA_VERSION}, [{}]) - - -# =========================================================================== -# Strix 2nd-batch VULN-0001..0011: preprocessing / inference / linking / -# validation / serving grid — input-validation & allocation-bound hardening. -# =========================================================================== -import types # noqa: E402 - -from fast_mlsirm.inference import observed_information, oakes_standard_errors # noqa: E402 -from fast_mlsirm.linking import link_fixed_item_parameters # noqa: E402 -from fast_mlsirm.preprocessing import irtree_expand # noqa: E402 -from fast_mlsirm.types import MLSIRMParams # noqa: E402 -from fast_mlsirm.validation import _validate_labels # noqa: E402 - - -# ---- VULN-0001 (2nd): irtree_expand dense-allocation bound ----------------- -def test_irtree_expand_rejects_oversized_expansion(): - y = np.zeros((1, 60_000)) # persons*items*nodes = 1*60000*900 = 5.4e7 - mapping = np.zeros((900, 2)) - with pytest.raises(ValueError, match="exceeds"): - irtree_expand(y, mapping) - - -def test_irtree_expand_accepts_normal_shapes(): - y = np.array([[0.0, 1.0], [1.0, 0.0]]) - mapping = np.array([[0.0, 1.0], [1.0, 0.0]]) # 2 nodes x 2 cats - expanded, factor_id = irtree_expand(y, mapping) - assert expanded.shape == (2, 4) and factor_id.shape == (4,) - - -# ---- VULN-0002 (2nd): irtree node_dims must be finite non-negative ints ---- -@pytest.mark.parametrize("bad", [np.array([0.5, 1.0]), np.array([-1.0, 0.0]), - np.array([np.nan, 0.0]), np.array([np.inf, 0.0])]) -def test_irtree_expand_rejects_bad_node_dims(bad): - y = np.zeros((3, 2)) - mapping = np.zeros((2, 3)) - with pytest.raises(ValueError): - irtree_expand(y, mapping, node_dims=bad) - - -# ---- VULN-0003 (2nd): label values above uint32 max ------------------------ -def test_validate_labels_rejects_uint32_overflow(): - with pytest.raises(ValueError, match="uint32"): - _validate_labels(np.array([0.0, 5_000_000_000.0]), "judge") - - -# ---- VULN-0011: human_human baseline length must match paired labels ------- -def test_validate_judge_rejects_mismatched_human_a_length(): - judge = np.array([0, 1, 0, 1]) - human = np.array([0, 1, 1, 0]) - with pytest.raises(ValueError, match="length"): - validate_judge(judge, human, k=2, human_human=(np.array([0, 1]),)) - - -# ---- VULN-0004 (2nd): oakes factor_id validated before use ----------------- -@pytest.mark.parametrize("bad", [np.array([0.0, np.nan, 1.0]), - np.array([0.0, -1.0, 1.0]), - np.array([0.5, 1.0, 2.0]), - np.zeros((2, 3))]) -def test_oakes_rejects_bad_factor_id(bad): - result = types.SimpleNamespace(model="MLSRM", population={}, params=None) - y = np.zeros((5, 3)) - with pytest.raises(ValueError): - oakes_standard_errors(result, y, bad) - - -# ---- VULN-0005 (2nd): observed_information bounds the dense Hessian --------- -def test_observed_information_rejects_huge_parameter_vector(): - p = MLSIRMParams(theta=np.zeros((6000, 1)), alpha=np.zeros(1), b=np.zeros(1), - xi=np.zeros((1, 1)), zeta=np.zeros((1, 1)), tau=0.0) - with pytest.raises(ValueError, match="at most"): - observed_information(np.zeros((3, 1)), np.array([0]), p) - - -# ---- VULN-0006 (2nd): serving tensor-grid explosion ------------------------ -def test_validate_bundle_rejects_grid_explosion(): - bundle = _bundle(latent_dim=4) - bundle["model"] = "MLS2PLM" - bundle["quadrature"] = {"q_theta": 21, "q_xi": 41} # 41**4 = 2.8e6 > 1e6 - with pytest.raises(ValueError, match="grid limit"): - serving._validate_bundle(bundle) - - -# ---- VULN-0007..0010: link_fixed_item_parameters anchor/param hardening ----- -def _link_ns(alpha, b, theta): - alpha = np.asarray(alpha, float) - return types.SimpleNamespace( - alpha=alpha, a=np.exp(alpha).reshape(-1, 1), - b=np.asarray(b, float), theta=np.asarray(theta, float), - ) - - -def test_link_rejects_duplicate_anchors(): # VULN-0007 - s = _link_ns([0.0, 0.0], [0.0, 0.1], np.zeros((2, 1))) - t = _link_ns([0.0, 0.0], [0.0, 0.1], np.zeros((2, 1))) - with pytest.raises(ValueError, match="unique"): - link_fixed_item_parameters(s, t, anchor_items=np.array([0, 0])) - - -def test_link_rejects_non_2d_theta(): # VULN-0008 - s = _link_ns([0.0, 0.0], [0.0, 0.1], np.zeros(2)) - t = _link_ns([0.0, 0.0], [0.0, 0.1], np.zeros(2)) - with pytest.raises(ValueError, match="2-D"): - link_fixed_item_parameters(s, t, anchor_items=np.array([0, 1])) - - -@pytest.mark.parametrize("alpha", [[0.0, np.inf], [0.0, np.nan]]) -def test_link_rejects_non_finite_params(alpha): # VULN-0009 - s = _link_ns(alpha, [0.0, 0.1], np.zeros((2, 1))) - t = _link_ns([0.0, 0.0], [0.0, 0.1], np.zeros((2, 1))) - with pytest.raises(ValueError, match="finite"): - link_fixed_item_parameters(s, t, anchor_items=np.array([0, 1])) - - -@pytest.mark.parametrize("anchors", [np.array([0.5, 1.0]), np.array([-1.0, 0.0]), - np.array([np.nan, 0.0])]) -def test_link_rejects_bad_anchor_indices(anchors): # VULN-0010 - s = _link_ns([0.0, 0.0], [0.0, 0.1], np.zeros((2, 1))) - t = _link_ns([0.0, 0.0], [0.0, 0.1], np.zeros((2, 1))) - with pytest.raises(ValueError): - link_fixed_item_parameters(s, t, anchor_items=anchors) - - -# =========================================================================== -# Strix 3rd batch (re-scan of b5d9d90): VULN-0002..0012. 0001 was a scanner -# false positive (PR-scope-only checkout; all modules exist and import cleanly). -# =========================================================================== -from fast_mlsirm.config import MLS2PLMConfig # noqa: E402 -from fast_mlsirm.estimators.marginal import fit_marginal_numpy # noqa: E402 -import fast_mlsirm.fitstats as fitstats # noqa: E402 - - -# ---- VULN-0002: unbounded respondent matrix in score_respondents ----------- -def test_score_respondents_rejects_oversized_response_rows(): - bundle = _bundle(n_items=1000) - with pytest.raises(ValueError, match="scoring limit"): - serving.score_respondents(bundle, [{} for _ in range(21_000)]) - - -# ---- VULN-0003: uint64 anchor index wraps to -1 in linking ----------------- -def test_link_rejects_uint64_wraparound_anchor(): - s = _link_ns([0.1, 0.2], [0.0, 1.0], np.zeros((2, 1))) - t = _link_ns([0.1, 0.2], [0.0, 1.0], np.zeros((2, 1))) - with pytest.raises(ValueError, match="reference existing items"): - link_fixed_item_parameters(s, t, anchor_items=np.array([2**64 - 1], dtype=np.uint64)) - - -# ---- VULN-0004: unbounded category count k in validate_judge --------------- -def test_validate_judge_rejects_huge_k(): - with pytest.raises(ValueError, match="must be <="): - validate_judge(np.array([0, 1]), np.array([0, 1]), k=1_000_000) - - -# ---- VULN-0005: irtree byte budget rejects the old 50M-element boundary ----- -def test_irtree_expand_byte_budget_rejects_400mb(): - with pytest.raises(ValueError, match="byte limit"): - irtree_expand(np.zeros((1, 50_000)), np.zeros((1_000, 1))) - - -# ---- VULN-0006: unbounded simulation dimensions ---------------------------- -@pytest.mark.parametrize("kw", [ - {"n_persons": 100_000, "n_dims": 100, "items_per_dim": 100}, - {"n_persons": 10_000_000, "n_dims": 2, "items_per_dim": 8}, -]) -def test_mls2plmconfig_rejects_oversized_dims(kw): - with pytest.raises(ValueError): - MLS2PLMConfig(**kw).validate() - - -@pytest.mark.parametrize("latent_dim", [2.5, np.nan, np.inf, MAX_LATENT_DIM + 1]) -def test_mls2plmconfig_rejects_invalid_latent_dim(latent_dim): - with pytest.raises(ValueError, match="latent_dim"): - MLS2PLMConfig(latent_dim=latent_dim).validate() - - -@pytest.mark.parametrize("gamma", [np.nan, np.inf, -np.inf]) -def test_mls2plmconfig_rejects_nonfinite_gamma(gamma): - with pytest.raises(ValueError, match="gamma"): - MLS2PLMConfig(gamma=gamma).validate() - - -# ---- VULN-0007: oversized population counts in fit_marginal_numpy ----------- -def test_fit_marginal_numpy_rejects_oversized_population(): - with pytest.raises(ValueError, match="n_groups"): - fit_marginal_numpy( - np.array([[0.0]]), np.array([[True]]), np.array([0], dtype=np.int64), - model="ULS2PLM", n_dims=1, latent_dim=1, - pop={"kind": "multigroup", "group_id": np.array([0], dtype=np.int64), - "n_groups": 1_000_000_000}, - q_theta=7, q_xi=7, q_u=7, max_iter=1, - ) - - -# ---- VULN-0008: unbounded aggregate optimizer work ------------------------- -def test_fitconfig_rejects_aggregate_optimizer_work(): - with pytest.raises(ValueError, match="aggregate optimizer-work"): - FitConfig(max_iter=100_000, n_restarts=1_000).validate() - - -# ---- VULN-0009: non-finite finite-difference step -------------------------- -@pytest.mark.parametrize("bad", [float("nan"), float("inf")]) -def test_observed_information_rejects_nonfinite_step(bad): - from fast_mlsirm.inference import observed_information - from fast_mlsirm.types import MLSIRMParams - p = MLSIRMParams(theta=np.zeros((2, 1)), alpha=np.zeros(1), b=np.zeros(1), - xi=np.zeros((1, 1)), zeta=np.zeros((1, 1)), tau=0.0) - with pytest.raises(ValueError, match="finite"): - observed_information(np.zeros((2, 1)), np.array([0]), p, step=bad) - - -# ---- VULN-0010: unbounded n_dims from factor_id in fit statistics ---------- -@pytest.mark.parametrize("fn", [fitstats._validate_factor_id, fitstats.n_dims_of]) -def test_fitstats_factor_id_bounds_n_dims(fn): - with pytest.raises(ValueError, match="0..n_items-1"): - fn(np.array([1_000_000_000])) - - -def test_fitstats_validate_factor_id_accepts_normal(): - d, n_dims = fitstats._validate_factor_id(np.array([0, 0, 1, 1, 2])) - assert n_dims == 3 and d.tolist() == [0, 0, 1, 1, 2] - - -# ---- VULN-0011: factor_id conversion must not alias untrusted labels ------- -def test_fitstats_rejects_uint64_factor_id_wraparound(): - bad = np.array([np.iinfo(np.uint64).max], dtype=np.uint64) - with pytest.raises(ValueError, match="factor_id"): - fitstats._validate_factor_id(bad) - - -@pytest.mark.parametrize( - "bad", - [ - np.array([np.iinfo(np.uint64).max], dtype=np.uint64), - np.array([0.5], dtype=np.float64), - ], -) -def test_predict_proba_rejects_factor_id_before_integer_cast(bad): - from fast_mlsirm.diagnostics import predict_proba - - params = MLSIRMParams( - theta=np.array([[0.0, 4.0]]), - alpha=np.array([0.0]), - b=np.array([0.0]), - xi=np.zeros((1, 1)), - zeta=np.zeros((1, 1)), - tau=0.0, - ) - with pytest.raises(ValueError, match="factor_id"): - predict_proba(params, bad, model="MIRT") - - -# ---- VULN-0012: unbounded QMC quadrature working set ----------------------- -def test_fit_marginal_numpy_rejects_qmc_working_set(): - with pytest.raises(ValueError, match="working set"): - fit_marginal_numpy( - np.zeros((16, 4)), np.ones((16, 4), bool), np.array([0, 0, 0, 0], dtype=np.int64), - model="MLS2PLM", n_dims=1, latent_dim=2, - q_theta=7, q_xi=7, q_u=7, xi_rule="qmc", xi_points=1_000_000, m_steps=1, max_iter=1, - ) - - -# =========================================================================== -# Proactive boundary audit (workflow sec-audit): findings Strix had not yet -# surfaced — bundle table-product OOM, serving_prior sigma_u crash, irt_link -# NaN panic, oakes n_dims, subgroup O(max+1) CPU-DoS. -# =========================================================================== -def _bundle_q(n_items, latent_dim, model, q_theta, q_xi, population=None): - b = _bundle(n_items=n_items, n_dims=1, latent_dim=latent_dim) - b["model"] = model - b["quadrature"] = {"q_theta": q_theta, "q_xi": q_xi} - b["population"] = population - return b - - -@pytest.mark.parametrize("sigma_u", [1e200, 1e150, "x", float("nan")]) -def test_serving_prior_rejects_bad_sigma_u(sigma_u): - b = _bundle(n_items=1) - b["population"] = {"kind": "multilevel", "sigma_u": sigma_u} - with pytest.raises(ValueError, match="sigma_u"): - serving.serving_prior(b) - - -@pytest.mark.parametrize("population", ["attacker-string", [], 1]) -def test_serving_bundle_rejects_non_object_population(population): - b = _bundle(n_items=1) - b["population"] = population - with pytest.raises(ValueError, match="population must be an object or null"): - serving._validate_bundle(b) - with pytest.raises(ValueError, match="population must be an object or null"): - serving.serving_prior(b) - - -def test_validate_bundle_rejects_oversized_scoring_tables(): - # 20 items x q_theta 41 x q_xi^3 (41^3=68921) ~ 5.6e7 > 5e7 table cells - b = _bundle_q(n_items=20, latent_dim=3, model="MLS2PLM", q_theta=41, q_xi=41) - with pytest.raises(ValueError, match="scoring-table size"): - serving._validate_bundle(b) - - -def test_oakes_rejects_n_dims_exceeding_items(): - result = types.SimpleNamespace(model="MLSRM", population={}, params=None) - with pytest.raises(ValueError, match="more dimensions than items"): - oakes_standard_errors(result, np.zeros((5, 1)), np.array([7])) - - -def _oakes_result(*, population=None, status="converged", optimizer="mmle_marginal_em/rust"): - return types.SimpleNamespace( - model="MLS2PLM", - population={} if population is None else population, - params=MLSIRMParams( - theta=np.zeros((4, 1)), - alpha=np.zeros(2), - b=np.zeros(2), - xi=np.zeros((4, 1)), - zeta=np.zeros((2, 1)), - tau=-2.0, - ), - optimizer=optimizer, - convergence_status=status, - ) - - -@pytest.mark.parametrize( - ("population", "match"), - [ - ({"kind": "single", "pi_zero": 0.25}, "zero inflation"), - ({"kind": "single", "delta": 0.4}, "item covariates"), - ({"kind": "single", "fixed_items": np.array([True, False])}, "anchors"), - ], -) -def test_oakes_rejects_unsupported_fitted_likelihoods(population, match): - with pytest.raises(ValueError, match=match): - oakes_standard_errors( - _oakes_result(population=population), - np.zeros((4, 2)), - np.array([0, 0]), - ) - - -def test_oakes_rejects_nonconverged_or_non_mmle_fit(): - y = np.zeros((4, 2)) - factors = np.array([0, 0]) - with pytest.raises(ValueError, match="converged"): - oakes_standard_errors(_oakes_result(status="max_iter_reached"), y, factors) - with pytest.raises(ValueError, match="marginal MMLE"): - oakes_standard_errors(_oakes_result(optimizer="scipy/L-BFGS-B"), y, factors) - - -@pytest.mark.parametrize("h", [0.0, -1e-5, np.nan, np.inf]) -def test_oakes_rejects_invalid_finite_difference_step(h): - with pytest.raises(ValueError, match="h must be"): - oakes_standard_errors( - _oakes_result(), np.zeros((4, 2)), np.array([0, 0]), h=h - ) - - -def test_oakes_allows_supported_converged_mmle_fit(monkeypatch): - from fast_mlsirm import _core - - monkeypatch.setattr( - _core, - "oakes_standard_errors", - lambda *args, **kwargs: { - "labels": ["b:0"], - "se": [0.2], - "information": [25.0], - }, - ) - result = oakes_standard_errors( - _oakes_result(), np.zeros((4, 2)), np.array([0, 0]) - ) - assert result == {"labels": ["b:0"], "se": [0.2], "information": [25.0]} - - -@pytest.mark.parametrize("args", [ - (np.array([1.0, np.nan]), np.array([0.0, 0.0]), np.array([1.0, 1.0]), np.array([0.0, 0.0])), - (np.array([-1.0]), np.array([0.0]), np.array([1.0]), np.array([0.0])), -]) -def test_irt_link_rejects_nonfinite_or_nonpositive(args): - from fast_mlsirm import linking as _lk - if fitstats._core_module() is None: # pragma: no cover - pytest.skip("irt_link requires the compiled Rust core") - with pytest.raises(ValueError): - _lk.irt_link(*args) - - -def test_validate_judge_compacts_sparse_subgroup(): - # Sparse subgroup label (uint32 max) must NOT drive an O(max+1) core loop. - if serving._core_module() is None: # pragma: no cover - pytest.skip("validate_judge requires the compiled Rust core") - v = validate_judge( - np.array([0, 1, 0, 1]), np.array([0, 1, 1, 0]), k=2, - subgroup=np.array([0, 4294967295, 0, 4294967295], dtype=np.uint32), - ) - assert v is not None # returns promptly; compaction -> 2 groups - - -# ---- Proactive audit: polytomous information public API ------------------- -@pytest.mark.parametrize( - ("theta", "slope", "cat_params", "match"), - [ - (np.array([[0.0, 1.0]]), np.array([1.0]), np.array([[0.0, 0.0]]), "1-D"), - (np.array([0.0]), np.array([np.nan]), np.array([[0.0, 0.0]]), "finite"), - (np.array([0.0]), np.array([1.0]), np.array([0.0, 0.0]), "n_items"), - (np.array([0.0]), np.array([]), np.empty((0, 2)), "non-empty"), - ], -) -def test_information_polytomous_rejects_malformed_inputs( - theta, slope, cat_params, match -): - from fast_mlsirm.polytomous import PolytomousFit, information_polytomous - - fit = PolytomousFit( - model="gpcm", slope=slope, cat_params=cat_params, loglik=0.0, n_iter=1 - ) - with pytest.raises(ValueError, match=match): - information_polytomous(fit, theta) - - -def test_irt_model_contract_unifies_dimension_and_confirmatory_structure(): - from fast_mlsirm import models - from fast_mlsirm.models import _resolve_model - - one_factor, pattern = _resolve_model(1, 3) - assert isinstance(one_factor, models.ExploratoryModel) - assert one_factor.dimensions == 1 - assert pattern.shape == (3, 1) - assert np.all(pattern == 1) - - confirmatory = models.confirmatory([[1, 0], [0, 1], [1, 1]]) - assert confirmatory.n_dims == 2 - resolved, resolved_pattern = _resolve_model(confirmatory, 3) - assert resolved is confirmatory - assert np.array_equal(resolved_pattern, confirmatory.loading_pattern) - - with pytest.raises(NotImplementedError, match="exploratory loading estimation"): - _resolve_model(2, 3) - with pytest.raises(ValueError, match="exactly 0 or 1"): - models.confirmatory([[1, 0.5]]) - with pytest.raises(TypeError, match="model"): - _resolve_model(np.ones((3, 2), dtype=np.int64), 3) - - -# ---- Current-head Strix: native allocation controls ---------------------- -class _RejectResourceCore: - _cdm_methods = { - "fit_cdm", - "fit_gdina", - "validate_q_matrix", - "gdina_wald_selection", - "fit_ho_cdm", - "fit_ho_gdina", - "fit_seq_gdina", - "fit_seq_gdina_qr", - } - - def fit_testlet(self, *_args): - raise AssertionError("invalid testlet IDs reached the native core") - - def fit_2pl(self, *_args): - raise AssertionError("invalid MIRT dimensions reached the native core") - - def __getattr__(self, name): - if name in self._cdm_methods: - return lambda *_args: (_ for _ in ()).throw( - AssertionError("invalid Q-matrix reached the native core") - ) - raise AttributeError(name) - - -@pytest.mark.parametrize( - "testlet_id", - [ - np.array([-1]), - np.array([1]), - np.array([1_000_000_000]), - np.array([np.nan]), - np.array([0.5]), - ], -) -def test_fit_testlet_rejects_unsafe_ids_before_native(monkeypatch, testlet_id): - from fast_mlsirm.testlet import fit_testlet - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - with pytest.raises(ValueError, match="testlet_id"): - fit_testlet(np.array([[1.0]]), testlet_id) - - -def test_fit_testlet_rejects_empty_bank_before_native(monkeypatch): - from fast_mlsirm.testlet import fit_testlet - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - with pytest.raises(ValueError, match="non-empty"): - fit_testlet(np.empty((1, 0)), np.array([], dtype=np.int64)) - - -@pytest.mark.parametrize( - ("kwargs", "match"), - [ - ({"max_iter": True}, "max_iter"), - ({"max_iter": 1.5}, "max_iter"), - ({"max_iter": 0}, "max_iter"), - ({"max_iter": 100_001}, "max_iter"), - ({"tol": np.nan}, "tol"), - ({"tol": np.inf}, "tol"), - ({"tol": -1.0}, "tol"), - ({"q_gamma": True}, "q_gamma"), - ({"q_gamma": 7.5}, "q_gamma"), - ({"q_gamma": 8}, "q_gamma"), - ({"init_sigma2": np.inf}, "init_sigma2"), - ({"init_sigma2": -1.0}, "init_sigma2"), - ], -) -def test_fit_testlet_rejects_unsafe_controls_before_native(monkeypatch, kwargs, match): - from fast_mlsirm.testlet import fit_testlet - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - with pytest.raises(ValueError, match=match): - fit_testlet(np.array([[1.0, 0.0]]), np.array([0, 0]), **kwargs) - - -@pytest.mark.parametrize( - "responses", - [ - np.array([[0.0, 2.0]]), - np.array([[0.0, np.inf]]), - np.empty((0, 2)), - ], -) -def test_fit_testlet_rejects_unsafe_responses_before_native(monkeypatch, responses): - from fast_mlsirm.testlet import fit_testlet - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - with pytest.raises(ValueError, match="responses"): - fit_testlet(responses, np.array([0, 0])) - - -def test_fit_testlet_rejects_oversized_response_matrix_before_native(monkeypatch): - from fast_mlsirm import testlet - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - monkeypatch.setattr(testlet, "MAX_TESTLET_RESPONSE_CELLS", 3, raising=False) - with pytest.raises(ValueError, match="response.*limit"): - testlet.fit_testlet(np.zeros((2, 2)), np.array([0, 0])) - - -@pytest.mark.parametrize("q", [0, 8, 1_000_000_000]) -def test_2pl_rejects_unsupported_quadrature_before_native(monkeypatch, q): - from fast_mlsirm import models - from fast_mlsirm.twopl import fit_2pl - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - with pytest.raises(ValueError, match="q must be one of"): - fit_2pl( - np.array([[1.0, 0.0]]), model=models.confirmatory(np.eye(2, dtype=np.int64)), q=q - ) - - -def test_2pl_rejects_more_than_three_dimensions_before_native(monkeypatch): - from fast_mlsirm import models - from fast_mlsirm.twopl import fit_2pl - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - with pytest.raises(ValueError, match="between 1 and 3"): - fit_2pl( - np.array([[1.0, 0.0, 1.0, 0.0]]), model=models.confirmatory(np.eye(4, dtype=np.int64)) - ) - - -@pytest.mark.parametrize( - "wrapper_name", - [ - "fit_cdm", - "fit_gdina", - "validate_q_matrix", - "gdina_wald_selection", - "fit_ho_cdm", - "fit_ho_gdina", - "fit_seq_gdina", - "fit_seq_gdina_qr", - ], -) -@pytest.mark.parametrize( - "q_matrix", - [ - np.array([[np.nan]]), - np.array([[2.5]]), - np.zeros((1, 16), dtype=np.int64), - ], -) -def test_cdm_wrappers_reject_unsafe_q_before_native( - monkeypatch, wrapper_name, q_matrix -): - from fast_mlsirm import cdm - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - with pytest.raises(ValueError, match="q_matrix|provisional_q|step_q"): - wrapper = getattr(cdm, wrapper_name) - if wrapper_name == "fit_seq_gdina_qr": - wrapper(np.array([[1.0]]), q_matrix, np.array([1])) - else: - wrapper(np.array([[1.0]]), q_matrix) - - -@pytest.mark.parametrize( - "n_steps", - [np.array([1.5]), np.array([np.nan]), np.array([-1]), np.array([0])], -) -def test_seq_gdina_qr_rejects_unsafe_step_counts_before_native( - monkeypatch, n_steps -): - from fast_mlsirm.cdm import fit_seq_gdina_qr - - monkeypatch.setattr(fitstats, "_core_module", lambda: _RejectResourceCore()) - with pytest.raises(ValueError, match="n_steps"): - fit_seq_gdina_qr(np.array([[1.0]]), np.array([[1]]), n_steps) - -class _RejectPolyDifCore: - def poly_dif(self, *_args): - raise AssertionError("invalid DIF input reached the native core") - - -@pytest.mark.parametrize( - "group_id", - [np.array([0.0, 1.5]), np.array([0.0, np.nan]), np.array([0, -1])], -) -def test_polytomous_dif_rejects_unsafe_group_labels_before_native( - monkeypatch, group_id -): - from fast_mlsirm import polytomous - - monkeypatch.setattr(polytomous, "_core_module", lambda: _RejectPolyDifCore()) - with pytest.raises(ValueError, match="group_id"): - polytomous.dif_polytomous(np.array([[0.0], [1.0]]), group_id, 2) - - -@pytest.mark.parametrize( - "studied_items", - [ - np.array([0.5]), - np.array([np.nan]), - np.array([-1]), - np.array([1]), - np.array([0, 0]), - ], -) -def test_polytomous_dif_rejects_unsafe_studied_items_before_native( - monkeypatch, studied_items -): - from fast_mlsirm import polytomous - - monkeypatch.setattr(polytomous, "_core_module", lambda: _RejectPolyDifCore()) - with pytest.raises(ValueError, match="studied_items"): - polytomous.dif_polytomous( - np.array([[0.0], [1.0]]), - np.array([0, 1]), - 2, - studied_items=studied_items, - ) - - -@pytest.mark.parametrize( - ("kwargs", "match"), - [ - ({"n_cat": 2.5}, "n_cat"), - ({"model": "bad"}, "model"), - ({"q_theta": 8}, "q_theta"), - ({"max_iter": 1.5}, "max_iter"), - ({"tol": np.nan}, "tol"), - ({"fdr_q": 1.5}, "fdr_q"), - ], -) -def test_polytomous_dif_rejects_unsafe_controls_before_native( - monkeypatch, kwargs, match -): - from fast_mlsirm import polytomous - - monkeypatch.setattr(polytomous, "_core_module", lambda: _RejectPolyDifCore()) - n_cat = kwargs.pop("n_cat", 2) - with pytest.raises(ValueError, match=match): - polytomous.dif_polytomous( - np.array([[0.0], [1.0]]), np.array([0, 1]), n_cat, **kwargs - ) - -def test_nominal_rejects_fractional_categories_before_native(monkeypatch): - from fast_mlsirm import models - from fast_mlsirm.nominal import fit_nominal - - class BombCore: - def fit_nominal_model(self, *_args): - raise AssertionError("fractional responses reached the native core") - - monkeypatch.setattr(fitstats, "_core_module", lambda: BombCore()) - with pytest.raises(ValueError, match="integer categories"): - fit_nominal( - np.array([[0.9], [1.9]]), - n_cat=2, - model=models.confirmatory(np.ones((1, 1), dtype=np.int64)), - ) - -@pytest.mark.parametrize("factor_id", [np.array([0.5]), np.array([np.nan])]) -def test_fit_rejects_fractional_factor_id_before_integer_cast(factor_id): - from fast_mlsirm.fit import fit as fit_model - - with pytest.raises(ValueError, match="integer values"): - fit_model( - np.array([[0.0], [1.0]]), - factor_id, - FitConfig(model="MIRT", estimator="mmle", backend="numpy", max_iter=1), - ) - - -# =========================================================================== -# Strix current-head resource-exhaustion regressions. -# =========================================================================== -def test_factor_csv_rejects_oversized_text_before_numpy_parse( - tmp_path, monkeypatch -): - path = tmp_path / "factors.csv" - path.write_text("item_id,factor_id\n0,0\n", encoding="utf-8") - monkeypatch.setattr("fast_mlsirm.io.MAX_FACTOR_CSV_BYTES", 8) - with patch( - "fast_mlsirm.io.np.loadtxt", - side_effect=AssertionError("oversized CSV reached NumPy parsing"), - ): - with pytest.raises(ValueError, match="input limit"): - load_factor_csv(path) - - -def test_serving_bundle_rejects_oversized_json_before_deserialization( - tmp_path, monkeypatch -): - path = tmp_path / "bundle.json" - path.write_text(json.dumps(_bundle()), encoding="utf-8") - monkeypatch.setattr("fast_mlsirm.io.MAX_JSON_INPUT_BYTES", 8) - with pytest.raises(ValueError, match="input limit"): - serving.load_serving_bundle(path) - - -def test_fit_gpcm_rejects_unbounded_category_count_before_quadrature(): - with patch( - "fast_mlsirm.estimators.marginal._gh", - side_effect=AssertionError("oversized category count reached quadrature"), - ): - with pytest.raises(ValueError, match="at most"): - fit_gpcm_numpy(np.array([[0.0]]), n_cat=100_000) - - -class _RejectPolytomousCore: - def fit_gpcm(self, *_args): - raise AssertionError("unsafe input reached native fit_gpcm") - - def fit_grm(self, *_args): - raise AssertionError("unsafe input reached native fit_grm") - - def fit_poly_unidim(self, *_args): - raise AssertionError("unsafe input reached native fit_poly_unidim") - - def fit_poly_lsirm(self, *_args): - raise AssertionError("unsafe input reached native fit_poly_lsirm") - - -@pytest.mark.parametrize("family", ["gpcm", "grm"]) -def test_multidimensional_polytomous_rejects_unsafe_int64_cast_before_native( - family, -): - module = __import__(f"fast_mlsirm.{family}", fromlist=[f"fit_{family}"]) - function = getattr(module, f"fit_{family}") - with ( - patch("fast_mlsirm.fitstats._core_module", return_value=_RejectPolytomousCore()), - warnings.catch_warnings(), - ): - warnings.simplefilter("error", RuntimeWarning) - with pytest.raises(ValueError, match="n_cat"): - function( - np.array([[1e19]]), - n_cat=np.uint64(2**64 - 1), - q=7, - max_iter=1, - ) - - -def test_unidimensional_polytomous_rejects_unsafe_int64_cast_before_native( - monkeypatch, -): - from fast_mlsirm import polytomous - - monkeypatch.setattr(polytomous, "_core_module", lambda: _RejectPolytomousCore()) - with warnings.catch_warnings(): - warnings.simplefilter("error", RuntimeWarning) - with pytest.raises(ValueError, match="n_cat"): - polytomous.fit_polytomous( - np.array([[1e30]]), n_cat=10**40, q_theta=7, max_iter=1 - ) - - -@pytest.mark.parametrize("family", ["gpcm", "grm"]) -@pytest.mark.parametrize( - "kwargs", - [ - {"max_iter": 0}, - {"max_iter": MAX_MAX_ITER + 1}, - {"node_rule": "qmc", "xi_points": 0}, - {"node_rule": "qmc", "xi_points": 200_001}, - ], -) -def test_multidimensional_polytomous_rejects_unsafe_budgets_before_native( - family, kwargs -): - module = __import__(f"fast_mlsirm.{family}", fromlist=[f"fit_{family}"]) - function = getattr(module, f"fit_{family}") - with patch("fast_mlsirm.fitstats._core_module", return_value=_RejectPolytomousCore()): - with pytest.raises(ValueError, match="max_iter|xi_points"): - function(np.array([[0.0]]), n_cat=2, q=7, **kwargs) - - -@pytest.mark.parametrize("function_name", ["fit_polytomous", "fit_lsirm_polytomous"]) -@pytest.mark.parametrize( - "kwargs", - [ - {"n_cat": MAX_POLYTOMOUS_CATEGORIES + 1, "max_iter": 1}, - {"n_cat": 2, "max_iter": 0}, - {"n_cat": 2, "max_iter": MAX_MAX_ITER + 1}, - ], -) -def test_polytomous_fitters_reject_unsafe_budgets_before_native( - monkeypatch, function_name, kwargs -): - from fast_mlsirm import polytomous - - monkeypatch.setattr(polytomous, "_core_module", lambda: _RejectPolytomousCore()) - function = getattr(polytomous, function_name) - with pytest.raises(ValueError, match="n_cat|max_iter"): - function(np.array([[0.0]]), q_theta=7, **kwargs) - - -@pytest.mark.parametrize( - "factor_id", - [ - np.array([100_000_000]), - np.array([2**63], dtype=np.uint64), - ], -) -def test_score_eap_rejects_unbounded_factor_id_before_quadrature(factor_id): - with patch( - "fast_mlsirm.estimators.marginal._gh", - side_effect=AssertionError("unsafe factor_id reached quadrature"), - ): - with pytest.raises(ValueError, match="factor_id"): - score_eap( - np.zeros((1, 1)), - np.ones((1, 1), dtype=bool), - factor_id, - np.zeros(1), - np.zeros(1), - np.zeros((1, 1)), - 0.0, - model="MIRT", - ) - - -def test_score_eap_rejects_unbounded_explicit_dimensions_before_quadrature(): - with patch( - "fast_mlsirm.estimators.marginal._gh", - side_effect=AssertionError("unsafe n_dims reached quadrature"), - ): - with pytest.raises(ValueError, match="n_dims"): - score_eap( - np.zeros((1, 1)), - np.ones((1, 1), dtype=bool), - np.array([0]), - np.zeros(1), - np.zeros(1), - np.zeros((1, 1)), - 0.0, - model="MIRT", - n_dims=100_000_000, - ) - - -# ---- Current-head Strix: bounded fit controls, outputs, and safe writes ---- -class _CurrentHeadBombCore: - def fit_rsm(self, *_args): - raise AssertionError("unsafe RSM controls reached the native core") - - def fit_mixture(self, *_args): - raise AssertionError("unsafe mixture controls reached the native core") - - def fit_nominal_model(self, *_args): - raise AssertionError("unsafe nominal controls reached the native core") - - def fit_2pl(self, *_args): - raise AssertionError("unsafe 2PL controls reached the native core") - - -@pytest.mark.parametrize( - "kwargs", - [ - {"n_cat": MAX_POLYTOMOUS_CATEGORIES + 1}, - {"max_iter": MAX_MAX_ITER + 1}, - ], -) -def test_rsm_rejects_unbounded_controls_before_native(monkeypatch, kwargs): - from fast_mlsirm.rsm import fit_rsm - - monkeypatch.setattr(fitstats, "_core_module", lambda: _CurrentHeadBombCore()) - with pytest.raises(ValueError, match="n_cat|max_iter"): - fit_rsm(np.array([[0.0], [1.0]]), **kwargs) - - -@pytest.mark.parametrize( - "kwargs", - [ - {"n_classes": 65}, - {"n_starts": 1_001}, - {"max_iter": MAX_MAX_ITER + 1}, - {"n_classes": 2, "n_starts": 1_000, "max_iter": 10_000}, - ], -) -def test_mixture_rejects_unbounded_controls_before_native(monkeypatch, kwargs): - from fast_mlsirm.mixture import fit_mixture - - monkeypatch.setattr(fitstats, "_core_module", lambda: _CurrentHeadBombCore()) - with pytest.raises(ValueError, match="n_classes|n_starts|max_iter|mixture budget"): - fit_mixture(np.array([[0.0], [1.0]]), **kwargs) - - -def test_mixture_rejects_aggregate_buffer_before_native(monkeypatch): - from fast_mlsirm import mixture - - monkeypatch.setattr(fitstats, "_core_module", lambda: _CurrentHeadBombCore()) - monkeypatch.setattr(mixture, "MAX_MIXTURE_BUFFER_CELLS", 1) - with pytest.raises(ValueError, match="buffer"): - mixture.fit_mixture(np.array([[0.0], [1.0]]), n_classes=2) - - -@pytest.mark.parametrize( - "kwargs", - [ - {"n_cat": MAX_POLYTOMOUS_CATEGORIES + 1}, - {"max_iter": MAX_MAX_ITER + 1}, - {"node_rule": "qmc", "xi_points": 200_001}, - ], -) -def test_nominal_rejects_unbounded_controls_before_native(monkeypatch, kwargs): - from fast_mlsirm.nominal import fit_nominal - - monkeypatch.setattr(fitstats, "_core_module", lambda: _CurrentHeadBombCore()) - controls = {"n_cat": 2, **kwargs} - with pytest.raises(ValueError, match="n_cat|max_iter|xi_points"): - fit_nominal(np.array([[0.0], [1.0]]), **controls) - - -@pytest.mark.parametrize( - "kwargs", - [ - {"max_iter": MAX_MAX_ITER + 1}, - {"node_rule": "qmc", "xi_points": MAX_XI_POINTS + 1}, - ], -) -def test_2pl_rejects_unbounded_controls_before_native(monkeypatch, kwargs): - from fast_mlsirm.twopl import fit_2pl - - monkeypatch.setattr(fitstats, "_core_module", lambda: _CurrentHeadBombCore()) - with pytest.raises(ValueError, match="max_iter|xi_points"): - fit_2pl(np.array([[0.0], [1.0]]), **kwargs) - - -@pytest.mark.parametrize("method", ["eap", "map", "eapsum"]) -def test_score_respondents_rejects_unbounded_output_before_core(monkeypatch, method): - class BombCore: - def __getattr__(self, name): - raise AssertionError(f"oversized output reached native core: {name}") - - monkeypatch.setattr(serving, "_core_module", lambda: BombCore()) - monkeypatch.setattr(serving, "MAX_SERVING_OUTPUT_CELLS", 4) - with pytest.raises(ValueError, match="output size"): - serving.score_respondents(_bundle(), np.array([[1.0]]), method=method) - - -def test_diagnostics_save_replaces_leaf_symlink_without_overwriting_target(tmp_path): - external = tmp_path / "external.txt" - external.write_text("do not overwrite", encoding="utf-8") - output = tmp_path / "fit_diagnostics.json" - output.symlink_to(external) - diagnostics = FitDiagnostics(itemfit={}, personfit={}, model_fit={}) - - save_fit_diagnostics(diagnostics, tmp_path) - - assert external.read_text(encoding="utf-8") == "do not overwrite" - assert output.is_file() and not output.is_symlink() - assert json.loads(output.read_text(encoding="utf-8"))["model_fit"] == {} - - -# ---- Same-head Strix follow-up: remaining public trust boundaries --------- -def test_cli_score_replaces_leaf_symlink_without_overwriting_target( - tmp_path, monkeypatch -): - import fast_mlsirm.cli as cli - - external = tmp_path / "external.txt" - external.write_text("do not overwrite", encoding="utf-8") - output = tmp_path / "scores.json" - output.symlink_to(external) - monkeypatch.setattr(serving, "load_serving_bundle", lambda _path: _bundle()) - monkeypatch.setattr( - serving, "score_respondents", lambda _bundle, _payload: [{"theta": [0.0]}] - ) - monkeypatch.setattr( - cli, "_load_json_bounded", lambda *_args, **_kwargs: [{"q0": 1}] - ) - - assert ( - cli.main( - [ - "score", - "--bundle", - "bundle.json", - "--responses", - "responses.json", - "--out", - str(output), - ] - ) - == 0 - ) - assert external.read_text(encoding="utf-8") == "do not overwrite" - assert output.is_file() and not output.is_symlink() - - -def test_candidate_loader_rejects_count_and_aggregate_budgets(monkeypatch): - import fast_mlsirm.cli as cli - - monkeypatch.setattr(cli, "_load_numpy_bounded", lambda _path: np.zeros(2)) - with pytest.raises(ValueError, match="candidate count"): - cli._load_candidate_probabilities( - [f"c{i}=candidate-{i}.npy" for i in range(cli.MAX_CANDIDATE_COUNT + 1)] - ) - monkeypatch.setattr(cli, "MAX_CANDIDATE_ELEMENTS", 1) - with pytest.raises(ValueError, match="aggregate"): - cli._load_candidate_probabilities(["one=candidate.npy"]) - - -def test_dimensionality_controls_deduplicate_and_bound_work(monkeypatch): - import fast_mlsirm.diagnostics as diagnostics - - assert diagnostics._validated_latent_dims([2, 2, 1, 2]) == [2, 1] - with pytest.raises(ValueError, match="k_folds"): - diagnostics._validation_folds( - np.ones((20, 20), dtype=bool), - diagnostics.MAX_DIM_DIAGNOSTIC_FOLDS + 1, - 1, - ) - monkeypatch.setattr(diagnostics, "MAX_DIM_DIAGNOSTIC_MASK_CELLS", 100) - with pytest.raises(ValueError, match="aggregate"): - diagnostics._validation_folds(np.ones((10, 10), dtype=bool), 2, 1) - - -def test_cdm_rejects_nonbinary_responses(): - from fast_mlsirm.cdm import _prepare_binary_responses - - with pytest.raises(ValueError, match="0, 1"): - _prepare_binary_responses(np.array([[0.0, 0.5, 1.0]])) - - -@pytest.mark.parametrize( - "name,args", - [ - ("fit_cdm", (np.array([[0.0, 1.0]]), np.array([[1], [1]]))), - ("fit_gdina", (np.array([[0.0, 1.0]]), np.array([[1], [1]]))), - ("validate_q_matrix", (np.array([[0.0, 1.0]]), np.array([[1], [1]]))), - ("gdina_wald_selection", (np.array([[0.0, 1.0]]), np.array([[1], [1]]))), - ("fit_ho_cdm", (np.array([[0.0, 1.0]]), np.array([[1], [1]]))), - ("fit_ho_gdina", (np.array([[0.0, 1.0]]), np.array([[1], [1]]))), - ("fit_seq_gdina", (np.array([[0.0, 1.0]]), np.array([[1], [1]]))), - ( - "fit_seq_gdina_qr", - (np.array([[0.0, 1.0]]), np.array([[1], [1]]), [1, 1]), - ), - ], -) -def test_all_cdm_wrappers_reject_invalid_stopping_controls_before_native( - monkeypatch, name, args -): - import fast_mlsirm.cdm as cdm - - class BombCore: - def __getattr__(self, method): - def call(*_args, **_kwargs): - raise AssertionError(f"unsafe controls reached native {method}") - - return call - - monkeypatch.setattr(fitstats, "_core_module", lambda: BombCore()) - with pytest.raises(ValueError, match="max_iter"): - getattr(cdm, name)(*args, max_iter=0, tol=float("nan")) - - -def test_serving_bundle_rejects_malformed_eapsum_tables(): - bundle = _bundle() - bundle["eapsum_tables"] = [ - { - "dim": 0, - "n_items_dim": 1, - "score_prob": [1.0], - "eap": [], - "sd": [], - } - ] - with pytest.raises(ValueError, match="eapsum"): - serving._validate_bundle(bundle) - - -def test_json_loader_rejects_excessive_nesting(tmp_path): - from fast_mlsirm.io import MAX_JSON_NESTING_DEPTH, _load_json_bounded - - path = tmp_path / "deep.json" - depth = MAX_JSON_NESTING_DEPTH + 1 - path.write_text("[" * depth + "0" + "]" * depth, encoding="utf-8") - with pytest.raises(ValueError, match="nesting"): - _load_json_bounded(path, source="test JSON") - - -def test_person_fit_resampling_rejects_unbounded_replicates_before_native(monkeypatch): - from types import SimpleNamespace - - class BombCore: - def person_fit_resampling(self, *_args, **_kwargs): - raise AssertionError("unsafe replication count reached native core") - - monkeypatch.setattr(fitstats, "_core_module", lambda: BombCore()) - params = SimpleNamespace(theta=np.zeros((1, 1)), xi=np.zeros((1, 1))) - with pytest.raises(ValueError, match="n_replicates"): - fitstats.person_fit_resampling( - np.zeros((1, 1)), - np.array([0]), - params, - "MIRT", - n_replicates=fitstats.MAX_PERSON_FIT_REPLICATES + 1, - ) - - -def test_polytomous_diagnostics_reject_unbounded_quadrature_before_native(monkeypatch): - import fast_mlsirm.polytomous as poly - - class BombCore: - def __getattr__(self, name): - raise AssertionError(f"unsafe quadrature reached native core: {name}") - - fit = poly.PolytomousFit( - model="grm", - slope=np.array([1.0]), - cat_params=np.array([[0.0]]), - loglik=0.0, - n_iter=1, - converged=True, - ) - monkeypatch.setattr(poly, "_core_module", lambda: BombCore()) - for function in ( - poly.item_fit_polytomous, - poly.m2_polytomous, - poly.local_dependence_polytomous, - poly.person_fit_polytomous, - ): - with pytest.raises(ValueError, match="q_theta"): - function( - np.array([[0.0]]), - fit, - q_theta=poly.MAX_POLY_QUADRATURE_POINTS + 1, - ) - - -def test_polytomous_simulation_rejects_unsafe_controls_before_native(monkeypatch): - import fast_mlsirm.polytomous as poly - - fit = poly.PolytomousFit( - model="grm", - slope=np.array([1.0]), - cat_params=np.array([[0.0]]), - loglik=0.0, - n_iter=1, - converged=True, - ) - monkeypatch.setattr( - poly, - "_core_module", - lambda: pytest.fail("unsafe controls reached native core"), - ) - with pytest.raises(ValueError, match="q_theta"): - poly.cat_simulate_polytomous( - np.array([0.0]), fit, q_theta=poly.MAX_POLY_QUADRATURE_POINTS + 1 - ) - with pytest.raises(ValueError, match="max_items"): - poly.cat_simulate_polytomous( - np.array([0.0]), - fit, - min_items=1, - max_items=poly.MAX_POLY_CAT_ITEMS + 1, - ) - with pytest.raises(ValueError, match="alpha"): - poly.u3_cutoff_polytomous(fit, n_persons=1, alpha=float("nan")) - with pytest.raises(ValueError, match="n_rep"): - poly.u3_cutoff_polytomous( - fit, - n_persons=1, - n_rep=poly.MAX_POLY_BOOTSTRAP_REPLICATES + 1, - ) - - -@pytest.mark.parametrize( - "function,payload", - [ - (serving.score_respondents, [[]]), - (serving.plausible_values, [[]]), - (serving.cat_next_item, []), - ], -) -def test_serving_rejects_non_mapping_response_payloads(monkeypatch, function, payload): - class BombCore: - def __getattr__(self, name): - raise AssertionError(f"malformed payload reached native core: {name}") - - monkeypatch.setattr(serving, "_core_module", lambda: BombCore()) - with pytest.raises(ValueError, match="object mapping"): - function(_bundle(), payload) diff --git a/tests/test_serving.py b/tests/test_serving.py deleted file mode 100644 index 86fa7176d..000000000 --- a/tests/test_serving.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Serving-bundle export/scoring round-trip tests.""" - -from __future__ import annotations - -import inspect - -import numpy as np -import pytest - -from fast_mlsirm.config import FitConfig -from fast_mlsirm.fit import fit -from fast_mlsirm.serving import ( - bank_information, - cat_next_item, - export_serving_bundle, - load_serving_bundle, - score_respondents, -) -from fast_mlsirm.types import FitResult, MLSIRMParams - - -def test_scoring_prefers_gpu_automatically_by_default(): - assert inspect.signature(score_respondents).parameters["device"].default == "auto" - assert inspect.signature(bank_information).parameters["device"].default == "auto" - assert inspect.signature(cat_next_item).parameters["device"].default == "auto" - - -def test_information_and_cat_device_contract(capfd): - parameters = [ - (0.1, -0.7, -0.3), - (0.3, -0.1, 0.2), - (-0.2, 0.4, 0.5), - (0.0, 0.9, -0.4), - ] - items = [ - { - "code": f"i{index}", - "factor_id": 0, - "alpha": alpha, - "b": intercept, - "zeta": [zeta], - } - for index, (alpha, intercept, zeta) in enumerate(parameters) - ] - bundle = { - "schema_version": 1, - "model": "MLS2PLM", - "n_items": len(items), - "n_dims": 1, - "latent_dim": 1, - "quadrature": {"q_theta": 7, "q_xi": 7}, - "eps_distance": 1e-8, - "tau": -0.2, - "population": None, - "eapsum_tables": None, - "items": items, - } - theta = np.array([-0.8, 0.0, 0.9]) - cpu_info = bank_information(bundle, theta, device="cpu") - gpu_info = bank_information(bundle, theta, device="gpu") - assert np.allclose( - gpu_info["item_info"], cpu_info["item_info"], atol=2e-4, rtol=0.0 - ) - assert np.allclose( - gpu_info["test_info"], cpu_info["test_info"], atol=5e-4, rtol=0.0 - ) - - extreme_bundle = dict(bundle) - extreme_bundle["items"] = [dict(item) for item in items] - extreme_bundle["items"][0]["alpha"] = 100.0 - extreme_cpu = bank_information(extreme_bundle, theta, device="cpu") - extreme_gpu = bank_information(extreme_bundle, theta, device="gpu") - assert np.all(np.isfinite(extreme_gpu["item_info"])) - assert np.all(np.isfinite(extreme_gpu["test_info"])) - assert np.array_equal(extreme_gpu["item_info"], extreme_cpu["item_info"]) - assert np.array_equal(extreme_gpu["test_info"], extreme_cpu["test_info"]) - assert "falling back to the CPU implementation" in capfd.readouterr().err - - cpu_cat = cat_next_item(bundle, {"i0": 1}, device="cpu") - gpu_cat = cat_next_item(bundle, {"i0": 1}, device="gpu") - assert gpu_cat["ranked_codes"] == cpu_cat["ranked_codes"] - assert gpu_cat["target_dim"] == cpu_cat["target_dim"] - assert np.allclose( - gpu_cat["theta_eap"], cpu_cat["theta_eap"], atol=2e-3, rtol=0.0 - ) - assert np.allclose( - gpu_cat["ranked_info"], cpu_cat["ranked_info"], atol=5e-4, rtol=0.0 - ) - - with pytest.raises(ValueError, match="device must be"): - bank_information(bundle, theta, device="tpu") - - -def test_eap_scoring_never_falls_back_to_python(monkeypatch): - import fast_mlsirm.serving as serving - - bundle = { - "schema_version": 1, - "model": "MIRT", - "n_items": 2, - "n_dims": 1, - "latent_dim": 1, - "quadrature": {"q_theta": 7, "q_xi": 7}, - "eps_distance": 1e-8, - "tau": 0.0, - "population": None, - "eapsum_tables": None, - "items": [ - { - "code": "i1", - "factor_id": 0, - "alpha": 0.0, - "b": 0.0, - "zeta": [0.0], - }, - { - "code": "i2", - "factor_id": 0, - "alpha": 0.0, - "b": 0.0, - "zeta": [0.0], - }, - ], - } - monkeypatch.setattr(serving, "_core_module", lambda: None) - - with pytest.raises(RuntimeError, match="compiled Rust core"): - score_respondents(bundle, {"i1": 1, "i2": 0}, device="gpu") - - -def _fit_small(seed=0): - rng = np.random.default_rng(seed) - P, I, D = 300, 10, 2 - fid = np.array([i % D for i in range(I)]) - theta = rng.standard_normal((P, D)) - xi = rng.standard_normal((P, 2)) - zeta = rng.standard_normal((I, 2)) * 0.8 - eta = theta[:, fid] + 0.3 - np.linalg.norm(xi[:, None] - zeta[None], axis=2) - y = (rng.random((P, I)) < 1 / (1 + np.exp(-eta))).astype(float) - cfg = FitConfig( - model="MLS2PLM", - estimator="mmle", - max_iter=160, - tolerance=1e-2, - q_theta=15, - q_xi=7, - ) - return y, fid, fit(y, fid, cfg) - - -@pytest.mark.parametrize( - ("trace", "expected_delta"), - [([-12.0, -10.0], "2"), ([-10.0], "nan")], -) -def test_export_rejects_nonconverged_calibration(trace, expected_delta): - params = MLSIRMParams( - theta=np.zeros((1, 1)), - alpha=np.zeros(2), - b=np.zeros(2), - xi=np.zeros((1, 1)), - zeta=np.zeros((2, 1)), - tau=0.0, - ) - result = FitResult( - params=params, - model="MLS2PLM", - optimizer="em", - backend="rust", - rust_device="cpu", - objective=10.0, - loglik_trace=trace, - objective_trace=[], - convergence_status="max_iter_reached", - n_iter=1, - ) - - with pytest.raises( - RuntimeError, - match=rf"status=max_iter_reached, n_iter=1, last_loglik_delta={expected_delta}", - ): - export_serving_bundle(result, ["I0", "I1"], np.array([0, 0])) - - -def test_bundle_roundtrip_and_scoring(tmp_path): - y, fid, result = _fit_small() - codes = [f"IMP{i:03d}" for i in range(y.shape[1])] - path = tmp_path / "bundle.json" - bundle = export_serving_bundle( - result, codes, fid, path=path, q_theta=15, q_xi=7, - dim_names=["IMP", "OTH"], - ) - loaded = load_serving_bundle(path) - assert loaded["schema_version"] == 1 - assert loaded["n_items"] == y.shape[1] - assert loaded["items"][0]["code"] == "IMP000" - - # dict payload, partial responses (like the downstream API) - scores = score_respondents(loaded, {"IMP000": 1, "IMP001": 0, "IMP005": True}) - assert len(scores) == 1 - s = scores[0] - assert len(s["theta"]) == loaded["n_dims"] - assert len(s["xi"]) == loaded["latent_dim"] - assert s["n_observed"] == 3 - assert np.isfinite(s["loglik"]) - - # dense payload: scoring the training persons reproduces their EAPs - scores_all = score_respondents(loaded, y) - theta_served = np.array([r["theta"] for r in scores_all]) - corr = np.corrcoef(theta_served[:, 0], result.params.theta[:, 0])[0, 1] - assert corr > 0.99 - - -def test_scoring_monotone_in_responses(): - y, fid, result = _fit_small(seed=2) - codes = [f"I{i}" for i in range(y.shape[1])] - bundle = export_serving_bundle(result, codes, fid, q_theta=15, q_xi=7) - dim0_items = {codes[i]: 1 for i in range(y.shape[1]) if fid[i] == 0} - all_pass = score_respondents(bundle, dim0_items)[0] - all_fail = score_respondents(bundle, {c: 0 for c in dim0_items})[0] - assert all_pass["theta"][0] > all_fail["theta"][0] - - -def test_scoring_rejects_bad_payloads(): - y, fid, result = _fit_small(seed=3) - codes = [f"I{i}" for i in range(y.shape[1])] - bundle = export_serving_bundle(result, codes, fid, q_theta=15, q_xi=7) - import pytest - - with pytest.raises(ValueError, match="unknown item code"): - score_respondents(bundle, {"NOPE": 1}) - with pytest.raises(ValueError, match="must be 0 or 1"): - score_respondents(bundle, {"I0": 2}) - with pytest.raises(ValueError, match="column count"): - score_respondents(bundle, np.zeros((1, 3))) diff --git a/tests/unit/agreement_tests.rs b/tests/unit/agreement_tests.rs deleted file mode 100644 index 0b3ae850f..000000000 --- a/tests/unit/agreement_tests.rs +++ /dev/null @@ -1,136 +0,0 @@ -use super::*; - -#[test] -fn kappa_hand_computed_2x2() { - // table: a\b -> [[20, 5], [10, 65]], n = 100 - let mut a = Vec::new(); - let mut b = Vec::new(); - for (x, y, count) in [(0, 0, 20), (0, 1, 5), (1, 0, 10), (1, 1, 65)] { - for _ in 0..count { - a.push(x); - b.push(y); - } - } - // po = .85; pe = .25*.30 + .75*.70 = .60; kappa = .25/.40 = .625 - let k = cohen_kappa(&a, &b, 2).unwrap(); - assert!((k - 0.625).abs() < 1e-9, "kappa {k}"); - // binary QWK equals unweighted kappa - let qwk = quadratic_weighted_kappa(&a, &b, 2).unwrap(); - assert!((qwk - k).abs() < 1e-9); - let (exact, adjacent) = agreement_rates(&a, &b).unwrap(); - assert!((exact - 0.85).abs() < 1e-9); - assert!( - (adjacent - 1.0).abs() < 1e-9, - "binary adjacent is degenerate at 1" - ); -} - -#[test] -fn smd_and_r_hand_computed() { - let human = [1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0]; - let auto = [1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0]; - // p_h = .625, sd_h = sqrt(.625*.375); p_a = .75 - let expect = (0.75 - 0.625) / (0.625_f64 * 0.375).sqrt(); - assert!((smd(&auto, &human).unwrap() - expect).abs() < 1e-9); - let r = pearson_r(&auto, &human).unwrap(); - assert!(r > 0.6 && r < 1.0); -} - -#[test] -fn verdict_gates_flag_degradation() { - // auto-human agreement clearly worse than human-human - let human: Vec = (0..200).map(|i| (i % 2) as u32).collect(); - let auto: Vec = (0..200) - .map(|i| { - if i % 5 == 0 { - 1 - (i % 2) as u32 - } else { - (i % 2) as u32 - } - }) - .collect(); - let h2: Vec = human.clone(); // perfect human-human baseline - let verdict = validate_scoring(&auto, &human, 2, Some((&human, &h2)), None).unwrap(); - let degr = verdict - .gates - .iter() - .find(|g| g.name == "degradation") - .unwrap(); - assert!( - !degr.pass, - "20% flips vs perfect baseline must flag degradation" - ); - assert!(verdict.exact_agreement < 1.0); -} - -#[test] -fn subgroup_smd_catches_biased_slice() { - // group 1 systematically over-scored by the auto rater - let mut auto = Vec::new(); - let mut human = Vec::new(); - let mut grp = Vec::new(); - let mut state = 9u64; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - for i in 0..400 { - let g = (i % 2) as u32; - let h = if unif() < 0.5 { 1u32 } else { 0 }; - let a = if g == 1 && h == 0 && unif() < 0.5 { - 1 - } else { - h - }; - auto.push(a); - human.push(h); - grp.push(g); - } - let verdict = validate_scoring(&auto, &human, 2, None, Some(&grp)).unwrap(); - let sg = verdict - .gates - .iter() - .find(|g| g.name == "subgroup_smd") - .unwrap(); - assert!( - !sg.pass, - "inflated group-1 scores must flag the subgroup SMD gate" - ); -} - -#[test] -fn rejects_degenerate_inputs() { - assert!(cohen_kappa(&[0, 1], &[0], 2).is_err()); - assert!(quadratic_weighted_kappa(&[0, 0], &[0, 0], 2).is_err()); - assert!(pearson_r(&[1.0, 1.0], &[0.0, 1.0]).is_err()); - assert!(smd(&[1.0, 1.0], &[1.0, 1.0]).is_err()); - assert!(quadratic_weighted_kappa(&[0, 3], &[0, 1], 2).is_err()); - assert!(quadratic_weighted_kappa(&[0, 1], &[0, 1], 1).is_err()); - assert!(cohen_kappa(&[0, 0], &[0, 0], 2).is_err()); - assert!(pearson_r(&[1.0], &[1.0]).is_err()); - assert!(smd(&[1.0], &[1.0]).is_err()); - assert!(agreement_rates(&[], &[]).is_err()); - - let auto = [0, 1, 0, 1]; - let human = [0, 1, 1, 0]; - assert!(validate_scoring(&auto, &human, 2, None, Some(&[0, 1])).is_err()); - let singleton = validate_scoring(&auto, &human, 2, None, Some(&[0, 1, 1, 1])).unwrap(); - assert!(singleton - .gates - .iter() - .any(|gate| gate.name == "subgroup_smd")); - let zero_variance_group = - validate_scoring(&auto, &human, 2, None, Some(&[0, 0, 1, 1])).unwrap(); - assert!(zero_variance_group - .gates - .iter() - .any(|gate| gate.name == "subgroup_smd")); - let subgroup_human_zero_variance = - validate_scoring(&auto, &[0, 0, 0, 1], 2, None, Some(&[0, 0, 1, 1])).unwrap(); - assert!(subgroup_human_zero_variance - .gates - .iter() - .any(|gate| gate.name == "subgroup_smd")); -} diff --git a/tests/unit/cdm_tests.rs b/tests/unit/cdm_tests.rs deleted file mode 100644 index 7d4a71c5d..000000000 --- a/tests/unit/cdm_tests.rs +++ /dev/null @@ -1,4165 +0,0 @@ -use super::*; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn bern(&mut self, p: f64) -> f64 { - if self.next_f64() < p { - 1.0 - } else { - 0.0 - } - } - fn profile(&mut self, l: usize) -> usize { - ((self.next_f64() * l as f64) as usize).min(l - 1) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -#[test] -fn sequential_cdm_validation_boundaries_are_explicit() { - let base = CdmConfig::default(); - let y = [0.0, 0.0, 1.0, 1.0]; - let observed = [true; 4]; - let q = [1u8, 1]; - - assert!(validate( - &y, - &observed, - &q, - 2, - 2, - 1, - &CdmConfig { - init_guess: f64::NAN, - ..base - } - ) - .is_err()); - assert!(validate_seq_gdina(&y, &observed, &q, 0, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&y, &observed, &q, 2, 2, 0, &base).is_err()); - assert!(validate_seq_gdina(&y, &observed, &q, 2, 2, 16, &base).is_err()); - assert!(validate_seq_gdina( - &y, - &observed, - &q, - 2, - 2, - 1, - &CdmConfig { - max_iter: 0, - ..base - } - ) - .is_err()); - assert!( - validate_seq_gdina(&y, &observed, &q, 2, 2, 1, &CdmConfig { tol: 0.0, ..base }).is_err() - ); - assert!( - validate_seq_gdina(&y, &observed, &q, 2, 2, 1, &CdmConfig { eps: 0.0, ..base }).is_err() - ); - assert!(validate_seq_gdina( - &y, - &observed, - &q, - 2, - 2, - 1, - &CdmConfig { - init_slip: f64::NAN, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina( - &y, - &observed, - &q, - 2, - 2, - 1, - &CdmConfig { - init_guess: f64::NAN, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina( - &y, - &observed, - &q, - 2, - 2, - 1, - &CdmConfig { - init_slip: 0.6, - init_guess: 0.6, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina( - &y, - &observed, - &q, - 2, - 2, - 1, - &CdmConfig { - count_floor: -1.0, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina(&y[..3], &observed, &q, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&y, &observed, &[1], 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&[f64::NAN, 0.0, 1.0, 1.0], &observed, &q, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&y, &observed, &[2, 1], 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&y, &[false, true, false, true], &q, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&[0.0; 4], &observed, &q, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&[65.0, 0.0, 1.0, 1.0], &observed, &q, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&y, &observed, &[0, 1], 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina(&y, &observed, &[1, 0, 1, 0], 2, 2, 2, &base).is_err()); - - let steps = [1usize, 1]; - let step_q = [1u8, 1]; - assert!(validate_seq_gdina_qr(&y, &observed, &step_q, &steps, 0, 2, 1, &base).is_err()); - assert!(validate_seq_gdina_qr(&y, &observed, &step_q, &steps, 2, 2, 0, &base).is_err()); - assert!(validate_seq_gdina_qr( - &y, - &observed, - &step_q, - &steps, - 2, - 2, - 1, - &CdmConfig { - max_iter: 0, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina_qr( - &y, - &observed, - &step_q, - &steps, - 2, - 2, - 1, - &CdmConfig { tol: 0.0, ..base }, - ) - .is_err()); - assert!(validate_seq_gdina_qr( - &y, - &observed, - &step_q, - &steps, - 2, - 2, - 1, - &CdmConfig { eps: 0.0, ..base }, - ) - .is_err()); - assert!(validate_seq_gdina_qr( - &y, - &observed, - &step_q, - &steps, - 2, - 2, - 1, - &CdmConfig { - init_slip: f64::NAN, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina_qr( - &y, - &observed, - &step_q, - &steps, - 2, - 2, - 1, - &CdmConfig { - init_guess: f64::NAN, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina_qr( - &y, - &observed, - &step_q, - &steps, - 2, - 2, - 1, - &CdmConfig { - init_slip: 0.6, - init_guess: 0.6, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina_qr( - &y, - &observed, - &step_q, - &steps, - 2, - 2, - 1, - &CdmConfig { - count_floor: -1.0, - ..base - }, - ) - .is_err()); - assert!(validate_seq_gdina_qr(&y, &observed, &step_q, &[1], 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina_qr(&y, &observed, &step_q, &[0, 1], 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina_qr(&y, &observed, &step_q, &[65, 1], 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina_qr(&y, &observed, &[1], &steps, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina_qr(&y[..3], &observed, &step_q, &steps, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina_qr( - &[f64::NAN, 0.0, 1.0, 1.0], - &observed, - &step_q, - &steps, - 2, - 2, - 1, - &base, - ) - .is_err()); - assert!(validate_seq_gdina_qr(&y, &observed, &[2, 1], &steps, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina_qr(&y, &observed, &[0, 1], &steps, 2, 2, 1, &base).is_err()); - assert!(validate_seq_gdina_qr(&y, &observed, &[1, 0, 1, 0], &steps, 2, 2, 2, &base,).is_err()); - assert!(validate_seq_gdina_qr( - &y, - &[false, true, false, true], - &step_q, - &steps, - 2, - 2, - 1, - &base, - ) - .is_err()); - assert!(validate_seq_gdina_qr(&[0.0; 4], &observed, &step_q, &steps, 2, 2, 1, &base).is_err()); -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - (a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::() / n).sqrt() -} -fn bias(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - a.iter().zip(b).map(|(x, y)| x - y).sum::() / n -} - -fn qmask_of(q: &[u8], i: usize, k: usize) -> usize { - let mut m = 0usize; - for a in 0..k { - if q[i * k + a] != 0 { - m |= 1 << a; - } - } - m -} -fn eta_of(model: CdmModel, c: usize, mask: usize) -> u8 { - match model { - CdmModel::Dina => ((c & mask) == mask) as u8, - CdmModel::Dino => ((c & mask) != 0) as u8, - } -} - -/// Draw responses for the given true profiles using the same bit encoding as the estimator. -fn simulate( - model: CdmModel, - q: &[u8], - s: &[f64], - g: &[f64], - profiles: &[usize], - n_items: usize, - n_attr: usize, - rng: &mut Lcg, -) -> Vec { - let n = profiles.len(); - let mut y = vec![0.0f64; n * n_items]; - for j in 0..n { - for i in 0..n_items { - let mask = qmask_of(q, i, n_attr); - let eta = eta_of(model, profiles[j], mask); - let p = if eta == 1 { 1.0 - s[i] } else { g[i] }; - y[j * n_items + i] = rng.bern(p); - } - } - y -} - -fn pattern_agreement(map: &[u32], truth: &[usize]) -> f64 { - let ok = map - .iter() - .zip(truth) - .filter(|(m, t)| **m as usize == **t) - .count(); - ok as f64 / map.len() as f64 -} -fn attribute_agreement(attr_prob: &[f64], truth: &[usize], n: usize, k: usize) -> f64 { - let mut ok = 0usize; - for j in 0..n { - for a in 0..k { - let est = (attr_prob[j * k + a] >= 0.5) as usize; - let tru = (truth[j] >> a) & 1; - if est == tru { - ok += 1; - } - } - } - ok as f64 / (n * k) as f64 -} -fn nondecreasing(trace: &[f64]) -> bool { - trace.windows(2).all(|w| w[1] >= w[0] - 1e-6) -} -fn monotone_items(res: &CdmResult) -> bool { - // 1 - s_i > g_i, with slack for the extreme clamp corner (1-s = g = eps). - res.slip - .iter() - .zip(&res.guess) - .all(|(s, g)| 1.0 - s > g - 1e-9) -} - -/// Anchor 1: the eta bitmask + likelihood algebra, with zero estimation. `P(X_j)` -/// from the module's log-space path must equal a naive enumeration that expands -/// `eta = prod_k alpha^{q}` in plain arithmetic. -#[test] -fn anchor_brute_force_likelihood() { - let (n_attr, n_items, l) = (2usize, 2usize, 4usize); - let q: Vec = vec![1, 0, /* */ 1, 1]; - let s = [0.1f64, 0.2]; - let g = [0.15f64, 0.2]; - let pi = [0.4f64, 0.2, 0.1, 0.3]; - let x = [1.0f64, 0.0]; - let model = CdmModel::Dina; - - let mut eta = vec![0u8; n_items * l]; - let mut lp1 = vec![0.0f64; n_items * 2]; - let mut lp0 = vec![0.0f64; n_items * 2]; - for i in 0..n_items { - let mask = qmask_of(&q, i, n_attr); - for c in 0..l { - eta[i * l + c] = eta_of(model, c, mask); - } - lp1[i * 2 + 1] = (1.0 - s[i]).ln(); - lp0[i * 2 + 1] = s[i].ln(); - lp1[i * 2] = g[i].ln(); - lp0[i * 2] = (1.0 - g[i]).ln(); - } - let log_pi: Vec = pi.iter().map(|p| p.ln()).collect(); - let observed = vec![true; n_items]; - let mut post = vec![0.0f64; l]; - let log_px = posterior_row( - 0, &x, &observed, n_items, l, &eta, &lp1, &lp0, &log_pi, &mut post, - ); - - let mut px = 0.0; - for c in 0..l { - let mut lik = pi[c]; - for i in 0..n_items { - let mut e = 1u8; - for k in 0..n_attr { - if q[i * n_attr + k] == 1 { - e *= ((c >> k) & 1) as u8; // AND gate as a product - } - } - let pc = if e == 1 { 1.0 - s[i] } else { g[i] }; - let xi = x[i]; - lik *= pc.powf(xi) * (1.0 - pc).powf(1.0 - xi); - } - px += lik; - } - assert!( - (log_px.exp() - px).abs() < 1e-12, - "module {} vs naive {}", - log_px.exp(), - px - ); - assert!((post.iter().sum::() - 1.0).abs() < 1e-12); -} - -/// Anchor 2: deterministic limit s=g=0 => X = eta exactly. Recovery of the ideal -/// pattern must be perfect and recovered slip/guess near zero. -#[test] -fn anchor_deterministic_limit() { - let (n_attr, n_items) = (2usize, 3usize); - let q: Vec = vec![1, 0, /* */ 0, 1, /* */ 1, 1]; - let s = vec![0.0f64; n_items]; - let g = vec![0.0f64; n_items]; - let n = 400usize; - let profiles: Vec = (0..n).map(|j| j % 4).collect(); - let mut rng = Lcg(12345); - let y = simulate( - CdmModel::Dina, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - assert!(res.converged); - assert!(nondecreasing(&res.loglik_trace)); - assert!(monotone_items(&res)); - assert!(pattern_agreement(&res.map_profile, &profiles) > 0.99); - assert!(res.slip.iter().all(|&s| s < 1e-2), "slip {:?}", res.slip); - assert!(res.guess.iter().all(|&g| g < 1e-2), "guess {:?}", res.guess); -} - -/// Anchor 3: with a single-attribute-per-item Q, `(c & mask) == mask` and -/// `(c & mask) != 0` coincide, so DINA and DINO share bit-identical eta and, from -/// the deterministic init, must produce identical fits. Pure algebraic identity. -#[test] -fn anchor_dina_dino_gate_identity() { - let (n_attr, n_items) = (2usize, 4usize); - let q: Vec = vec![1, 0, /* */ 1, 0, /* */ 0, 1, /* */ 0, 1]; - let s = vec![0.15f64; n_items]; - let g = vec![0.2f64; n_items]; - let n = 500usize; - let mut rng = Lcg(999); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << n_attr)).collect(); - let y = simulate( - CdmModel::Dina, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let cfg = CdmConfig::default(); - let a = fit_cdm(&y, &observed, &q, n, n_items, n_attr, CdmModel::Dina, &cfg).unwrap(); - let b = fit_cdm(&y, &observed, &q, n, n_items, n_attr, CdmModel::Dino, &cfg).unwrap(); - assert!(rmse(&a.slip, &b.slip) < 1e-9); - assert!(rmse(&a.guess, &b.guess) < 1e-9); - assert!(rmse(&a.profile_prob, &b.profile_prob) < 1e-9); -} - -/// Anchor 4: K=1, Q all-ones reduces to a 2-class latent-class model. Recover the -/// master proportion, slip and guess. -#[test] -fn anchor_k1_two_class_reduction() { - let (n_attr, n_items) = (1usize, 10usize); - let q: Vec = vec![1u8; n_items]; - let (s_true, g_true, pi1) = (0.15f64, 0.2f64, 0.6f64); - let s = vec![s_true; n_items]; - let g = vec![g_true; n_items]; - let n = 2000usize; - let mut rng = Lcg(7); - let profiles: Vec = (0..n) - .map(|_| if rng.next_f64() < pi1 { 1 } else { 0 }) - .collect(); - let y = simulate( - CdmModel::Dina, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - assert!(res.converged && monotone_items(&res)); - let mean_s = res.slip.iter().sum::() / n_items as f64; - let mean_g = res.guess.iter().sum::() / n_items as f64; - assert!((mean_s - s_true).abs() < 0.05, "mean slip {mean_s}"); - assert!((mean_g - g_true).abs() < 0.05, "mean guess {mean_g}"); - assert!( - (res.profile_prob[1] - pi1).abs() < 0.05, - "pi1 {}", - res.profile_prob[1] - ); -} - -/// Tier-1 fast recovery guard: K=2, J=15, N=1000, s=g=0.2, identifiable Q. -#[test] -fn recovery_guard() { - let (n_attr, n_items, n) = (2usize, 15usize, 1000usize); - // 5 items {a0}, 5 items {a1}, 5 items {a0,a1}. - let mut q = vec![0u8; n_items * n_attr]; - for i in 0..15 { - if i < 5 { - q[i * 2] = 1; - } else if i < 10 { - q[i * 2 + 1] = 1; - } else { - q[i * 2] = 1; - q[i * 2 + 1] = 1; - } - } - let s = vec![0.2f64; n_items]; - let g = vec![0.2f64; n_items]; - let mut rng = Lcg(2024); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << n_attr)).collect(); - let y = simulate( - CdmModel::Dina, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - assert!(res.converged); - assert!(nondecreasing(&res.loglik_trace)); - assert!(monotone_items(&res)); - assert!( - rmse(&res.slip, &s) < 0.05, - "rmse slip {}", - rmse(&res.slip, &s) - ); - assert!( - rmse(&res.guess, &g) < 0.05, - "rmse guess {}", - rmse(&res.guess, &g) - ); - assert!(pattern_agreement(&res.map_profile, &profiles) > 0.80); - assert!(attribute_agreement(&res.attr_prob, &profiles, n, n_attr) > 0.85); - assert_eq!(res.n_parameters, 2 * n_items + ((1 << n_attr) - 1)); -} - -/// Missing-data (MAR) path: masked cells are dropped from likelihood and counts. -#[test] -fn handles_missing_data() { - let (n_attr, n_items, n) = (2usize, 8usize, 400usize); - let q: Vec = vec![ - 1, 0, /* */ 0, 1, /* */ 1, 1, /* */ 1, 0, /* */ 0, 1, /* */ 1, 1, - /* */ 1, 0, /* */ 0, 1, - ]; - let s = vec![0.15f64; n_items]; - let g = vec![0.2f64; n_items]; - let mut rng = Lcg(555); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << n_attr)).collect(); - let y = simulate( - CdmModel::Dina, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let mut observed = vec![true; n * n_items]; - for (idx, o) in observed.iter_mut().enumerate() { - if rng.next_f64() < 0.2 { - *o = false; // ~20% MCAR missing - } - let _ = idx; - } - let res = fit_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - assert!(res.converged && monotone_items(&res)); - assert!(nondecreasing(&res.loglik_trace)); -} - -/// Directly exercise every M-step branch (normal, both count guards, projection). -#[test] -fn update_item_branches() { - let cfg = CdmConfig::default(); - let mut s = vec![0.2, 0.2, 0.2, 0.2]; - let mut g = vec![0.2, 0.2, 0.2, 0.2]; - // 0: normal — masters mostly right, non-masters mostly wrong. - // 1: I1 below floor -> keep previous slip. - // 2: I0 below floor -> keep previous guess. - // 3: monotonicity violation (masters worse than non-masters) -> projection. - let i1 = vec![100.0, 1e-12, 100.0, 100.0]; - let r1 = vec![80.0, 0.0, 80.0, 20.0]; - let i0 = vec![100.0, 100.0, 1e-12, 100.0]; - let r0 = vec![20.0, 20.0, 0.0, 80.0]; - for i in 0..4 { - update_item(i, &i1, &r1, &i0, &r0, &mut s, &mut g, &cfg); - } - assert!((s[0] - 0.2).abs() < 1e-9 && (g[0] - 0.2).abs() < 1e-9); - assert!((s[1] - 0.2).abs() < 1e-9, "kept prev slip {}", s[1]); // guard held slip - assert!((g[2] - 0.2).abs() < 1e-9, "kept prev guess {}", g[2]); // guard held guess - assert!( - 1.0 - s[3] > g[3], - "projection kept monotonicity: 1-s={} g={}", - 1.0 - s[3], - g[3] - ); -} - -/// The non-converged exit path (max_iter reached without meeting tol). -#[test] -fn stops_at_max_iter() { - let (n_attr, n_items, n) = (1usize, 4usize, 50usize); - let q = vec![1u8; n_items]; - let s = vec![0.1f64; n_items]; - let g = vec![0.2f64; n_items]; - let mut rng = Lcg(3); - let profiles: Vec = (0..n).map(|_| rng.profile(2)).collect(); - let y = simulate( - CdmModel::Dina, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let cfg = CdmConfig { - max_iter: 1, - ..CdmConfig::default() - }; - let res = fit_cdm(&y, &observed, &q, n, n_items, n_attr, CdmModel::Dina, &cfg).unwrap(); - assert!(!res.converged); - assert_eq!(res.n_iter, 1); - assert_eq!(res.loglik_trace.len(), 2); - assert!(nondecreasing(&res.loglik_trace)); -} - -/// Malformed inputs are rejected with `Err` (covers each validate branch). -#[test] -fn validate_rejects_malformed() { - let q_ok = vec![1u8, 0, 0, 1]; - let y = vec![0.0f64; 2 * 2]; - let obs = vec![true; 4]; - let cfg = CdmConfig::default(); - let bad = |q: &[u8], y: &[f64], obs: &[bool], n: usize, j: usize, k: usize| { - fit_cdm(y, obs, q, n, j, k, CdmModel::Dina, &cfg).is_err() - }; - assert!(bad(&q_ok, &y, &obs, 0, 2, 2)); // n_persons < 1 - assert!(bad(&q_ok, &y, &obs, 2, 2, 0)); // K < 1 - assert!(bad( - &vec![1u8; 2 * 16], - &vec![0.0; 2 * 2], - &vec![true; 4], - 2, - 2, - 16 - )); // K > 15 - assert!(bad(&q_ok, &vec![0.0; 3], &obs, 2, 2, 2)); // y length - assert!(bad(&q_ok, &y, &vec![true; 3], 2, 2, 2)); // observed length - assert!(bad(&vec![1u8; 3], &y, &obs, 2, 2, 2)); // q length - assert!(bad(&q_ok, &vec![2.0, 0.0, 0.0, 0.0], &obs, 2, 2, 2)); // y not in {0,1} - assert!(bad(&vec![2u8, 0, 0, 1], &y, &obs, 2, 2, 2)); // q not in {0,1} - assert!(bad(&vec![0u8, 0, 1, 1], &y, &obs, 2, 2, 2)); // all-zero Q row 0 - assert!(bad(&vec![1u8, 0, 1, 0], &y, &obs, 2, 2, 2)); // all-zero Q column 1 - // Item 1 is entirely missing, so its slip/guess cannot be estimated. - assert!(bad(&q_ok, &y, &[true, false, true, false], 2, 2, 2)); - // A well-formed call still succeeds. - assert!(fit_cdm(&y, &obs, &q_ok, 2, 2, 2, CdmModel::Dina, &cfg).is_ok()); -} - -#[test] -fn validate_rejects_invalid_config() { - let q = vec![1u8, 0, 0, 1]; - let y = vec![0.0f64; 4]; - let observed = vec![true; 4]; - let rejected = - |cfg: CdmConfig| fit_cdm(&y, &observed, &q, 2, 2, 2, CdmModel::Dina, &cfg).is_err(); - assert!(rejected(CdmConfig { - max_iter: 0, - ..CdmConfig::default() - })); - assert!(rejected(CdmConfig { - tol: f64::NAN, - ..CdmConfig::default() - })); - assert!(rejected(CdmConfig { - eps: 0.5, - ..CdmConfig::default() - })); - assert!(rejected(CdmConfig { - eps: 1e-3, - mono_backoff: 2e-3, - ..CdmConfig::default() - })); - assert!(rejected(CdmConfig { - init_slip: f64::INFINITY, - ..CdmConfig::default() - })); - assert!(rejected(CdmConfig { - init_slip: 0.6, - init_guess: 0.4, - ..CdmConfig::default() - })); - assert!(rejected(CdmConfig { - count_floor: -1.0, - ..CdmConfig::default() - })); -} - -/// Literature-grade Monte-Carlo (>=500 reps): de la Torre (2009)-style design, -/// recovering slip/guess (RMSE/bias) and attribute/pattern classification accuracy. -/// Q is held to moderate complexity (1-2 attribute items) so the aggregate RMSE -/// bound holds (a 3-attribute item shrinks the eta=1 group to ~N/8 and inflates SE). -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_cdm_recovery() { - let (n_attr, n_items, n, reps) = (5usize, 30usize, 1000usize, 500usize); - let l = 1usize << n_attr; - // 20 single-attribute items (4 per attribute) + 10 two-attribute items (pairs). - let mut q = vec![0u8; n_items * n_attr]; - for a in 0..5 { - for r in 0..4 { - q[(a * 4 + r) * n_attr + a] = 1; - } - } - let pairs = [ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - (0, 2), - (1, 3), - (2, 4), - (0, 3), - (1, 4), - (0, 4), - ]; - for (t, &(a, b)) in pairs.iter().enumerate() { - q[(20 + t) * n_attr + a] = 1; - q[(20 + t) * n_attr + b] = 1; - } - - for (cond, &sg) in [0.1f64, 0.2].iter().enumerate() { - let s_true = vec![sg; n_items]; - let g_true = vec![sg; n_items]; - let (mut sum_rs, mut sum_rg, mut sum_bs, mut sum_bg) = (0.0, 0.0, 0.0, 0.0); - let (mut ss_rs, mut ss_rg) = (0.0, 0.0); - let (mut sum_pat, mut sum_attr) = (0.0, 0.0); - for rep in 0..reps { - let seed = 0xD1B54A32D192ED03u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((cond as u64 + 1) * 0x9E3779B97F4A7C15); - let mut rng = Lcg(seed); - let profiles: Vec = (0..n).map(|_| rng.profile(l)).collect(); - let y = simulate( - CdmModel::Dina, - &q, - &s_true, - &g_true, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - let (rs, rg) = (rmse(&res.slip, &s_true), rmse(&res.guess, &g_true)); - sum_rs += rs; - sum_rg += rg; - ss_rs += rs * rs; - ss_rg += rg * rg; - sum_bs += bias(&res.slip, &s_true); - sum_bg += bias(&res.guess, &g_true); - sum_pat += pattern_agreement(&res.map_profile, &profiles); - sum_attr += attribute_agreement(&res.attr_prob, &profiles, n, n_attr); - } - let r = reps as f64; - let (m_rs, m_rg) = (sum_rs / r, sum_rg / r); - let sd_rs = (ss_rs / r - m_rs * m_rs).max(0.0).sqrt(); - let sd_rg = (ss_rg / r - m_rg * m_rg).max(0.0).sqrt(); - println!( - "s=g={:.1}: RMSE(s)={:.4}(SD {:.4}) RMSE(g)={:.4}(SD {:.4}) bias(s)={:.4} bias(g)={:.4} pattern={:.3} attribute={:.3}", - sg, m_rs, sd_rs, m_rg, sd_rg, sum_bs / r, sum_bg / r, sum_pat / r, sum_attr / r - ); - assert!(m_rs < 0.03, "mean RMSE(s) {m_rs} at s=g={sg}"); - assert!(m_rg < 0.03, "mean RMSE(g) {m_rg} at s=g={sg}"); - if sg == 0.1 { - assert!( - sum_attr / r > 0.90, - "mean attribute agreement {} at s=g=0.1", - sum_attr / r - ); - } - } -} - -// ----- G-DINA (saturated) tests ----- - -/// Build the ragged CSR layout (item_off, qmask, k_required) from a Q-matrix, -/// matching fit_gdina exactly. -fn gdina_layout(q: &[u8], n_items: usize, n_attr: usize) -> (Vec, Vec, Vec) { - let mut qmask = vec![0usize; n_items]; - let mut kreq = vec![0u32; n_items]; - for i in 0..n_items { - let m = qmask_of(q, i, n_attr); - qmask[i] = m; - kreq[i] = m.count_ones(); - } - let mut off = vec![0usize; n_items + 1]; - for i in 0..n_items { - off[i + 1] = off[i] + (1usize << kreq[i]); - } - (off, qmask, kreq) -} - -/// Draw responses from a CSR-flat truth table, using the SAME reduce_class + item_off -/// convention as the estimator so RMSE compares matched classes (spec fix 3). -fn simulate_gdina( - qmask: &[usize], - item_off: &[usize], - truth_p: &[f64], - profiles: &[usize], - n_items: usize, - rng: &mut Lcg, -) -> Vec { - let n = profiles.len(); - let mut y = vec![0.0f64; n * n_items]; - for j in 0..n { - for i in 0..n_items { - let l = reduce_class(profiles[j], qmask[i]); - y[j * n_items + i] = rng.bern(truth_p[item_off[i] + l]); - } - } - y -} - -/// Check the all-mastered class for monotone-truth fixtures only; this is not an -/// invariant of the unconstrained saturated G-DINA estimator. -fn top_class_is_max(res: &GdinaResult) -> bool { - (0..res.k_required.len()).all(|i| { - let (a, b) = (res.item_off[i], res.item_off[i + 1]); - let top = res.item_prob[b - 1]; - res.item_prob[a..b].iter().all(|&p| p <= top + 1e-9) - }) -} - -/// reduce_class packs the required-attribute mastery bits LSB-ascending, and -/// equals L_i-1 iff all required attributes are mastered (the DINA eta identity). -#[test] -fn gdina_reduce_class_matches_bruteforce() { - for k in 1..=4usize { - for qmask in 1..(1usize << k) { - let li = 1usize << (qmask.count_ones()); - for c in 0..(1usize << k) { - let (mut expect, mut m) = (0usize, 0u32); - for bit in 0..k { - if (qmask >> bit) & 1 == 1 { - expect |= ((c >> bit) & 1) << m; - m += 1; - } - } - assert_eq!(reduce_class(c, qmask), expect); - assert_eq!(reduce_class(c, qmask) == li - 1, (c & qmask) == qmask); - } - } - } -} - -/// mobius_inverse_inplace is the exact inverse of the zeta subset-sum, and matches -/// the explicit K=2 identity-link formulas. -#[test] -fn gdina_mobius_roundtrip() { - let mut rng = Lcg(42); - for ki in 1..=3u32 { - let li = 1usize << ki; - let p: Vec = (0..li).map(|_| 0.05 + 0.9 * rng.next_f64()).collect(); - let mut delta = p.clone(); - mobius_inverse_inplace(&mut delta, ki); - for l in 0..li { - // reconstruct p_l = sum_{S subset of l} delta_S - let recon: f64 = (0..li).filter(|&s| (l & s) == s).map(|s| delta[s]).sum(); - assert!((recon - p[l]).abs() < 1e-12, "roundtrip K={ki} l={l}"); - } - } - let mut d = vec![0.2, 0.5, 0.6, 0.9]; // p00, p10, p01, p11 - mobius_inverse_inplace(&mut d, 2); - assert!((d[0] - 0.2).abs() < 1e-12); - assert!((d[1] - (0.5 - 0.2)).abs() < 1e-12); - assert!((d[2] - (0.6 - 0.2)).abs() < 1e-12); - assert!((d[3] - (0.9 - 0.5 - 0.6 + 0.2)).abs() < 1e-12); -} - -/// Brute-force likelihood: the CSR log-space path equals a naive enumeration. -#[test] -fn gdina_brute_force_likelihood() { - let (n_attr, n_items) = (2usize, 2usize); - let l_full = 1usize << n_attr; - let q: Vec = vec![1, 0, /* */ 1, 1]; // item 0: K=1, item 1: K=2 - let (item_off, qmask, _k) = gdina_layout(&q, n_items, n_attr); - let total = item_off[n_items]; - let p = vec![0.15f64, 0.8, /* */ 0.1, 0.3, 0.4, 0.85]; - assert_eq!(p.len(), total); - let mut red = vec![0u16; n_items * l_full]; - for i in 0..n_items { - for c in 0..l_full { - red[i * l_full + c] = reduce_class(c, qmask[i]) as u16; - } - } - let (mut log_p1, mut log_p0) = (vec![0.0f64; total], vec![0.0f64; total]); - for x in 0..total { - log_p1[x] = p[x].ln(); - log_p0[x] = (1.0 - p[x]).ln(); - } - let pi = [0.4f64, 0.2, 0.1, 0.3]; - let log_pi: Vec = pi.iter().map(|v| v.ln()).collect(); - let x = [1.0f64, 0.0]; - let observed = vec![true; n_items]; - let mut post = vec![0.0f64; l_full]; - let log_px = posterior_row_gdina( - 0, &x, &observed, n_items, l_full, &red, &log_p1, &log_p0, &item_off, &log_pi, &mut post, - ); - let mut px = 0.0; - for c in 0..l_full { - let mut lik = pi[c]; - for i in 0..n_items { - let pc = p[item_off[i] + reduce_class(c, qmask[i])]; - let xi = x[i]; - lik *= pc.powf(xi) * (1.0 - pc).powf(1.0 - xi); - } - px += lik; - } - assert!( - (log_px.exp() - px).abs() < 1e-12, - "module {} vs naive {}", - log_px.exp(), - px - ); - assert!((post.iter().sum::() - 1.0).abs() < 1e-12); -} - -/// THE CRUX ANCHOR: DINA-generated data => the saturated fit recovers p = g for -/// every non-top reduced class and 1-s at the top, so delta has only the intercept -/// and the highest-order interaction nonzero (the exact DINA identity-link constraint). -#[test] -fn gdina_recovers_dina() { - let (n_attr, n_items, n) = (2usize, 12usize, 2500usize); - let mut q = vec![0u8; n_items * n_attr]; - for i in 0..n_items { - if i < 4 { - q[i * 2] = 1; - } else if i < 8 { - q[i * 2 + 1] = 1; - } else { - q[i * 2] = 1; - q[i * 2 + 1] = 1; - } - } - let s = vec![0.15f64; n_items]; - let g = vec![0.2f64; n_items]; - let mut rng = Lcg(2011); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << n_attr)).collect(); - let y = simulate( - CdmModel::Dina, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace) && top_class_is_max(&res)); - let (item_off, _qm, _k) = gdina_layout(&q, n_items, n_attr); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let (a, b) = (item_off[i], item_off[i + 1]); - for l in a..b { - truth[l] = g[i]; - } - truth[b - 1] = 1.0 - s[i]; - } - assert!( - rmse(&res.item_prob, &truth) < 0.03, - "DINA p RMSE {}", - rmse(&res.item_prob, &truth) - ); - for i in 0..n_items { - let (a, b) = (item_off[i], item_off[i + 1]); - let d = &res.item_delta[a..b]; - assert!((d[0] - g[i]).abs() < 0.05, "delta0 {} vs g {}", d[0], g[i]); - assert!( - (d[b - a - 1] - ((1.0 - s[i]) - g[i])).abs() < 0.05, - "delta_full item {i}" - ); - for l in 1..(b - a - 1) { - assert!( - d[l].abs() < 0.05, - "interior delta item {i} idx {l} = {}", - d[l] - ); - } - } -} - -/// DINO-generated data: p = g at the empty reduced class, 1-s elsewhere. Uses a -/// mixed Q (single-attribute items identify the attributes; an all-two-attribute Q -/// would leave profiles 10/01/11 response-equivalent under the OR gate). -#[test] -fn gdina_recovers_dino() { - let (n_attr, n_items, n) = (2usize, 12usize, 2500usize); - let mut q = vec![0u8; n_items * n_attr]; - for i in 0..n_items { - if i < 4 { - q[i * 2] = 1; - } else if i < 8 { - q[i * 2 + 1] = 1; - } else { - q[i * 2] = 1; - q[i * 2 + 1] = 1; - } - } - let s = vec![0.15f64; n_items]; - let g = vec![0.2f64; n_items]; - let mut rng = Lcg(77); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << n_attr)).collect(); - let y = simulate( - CdmModel::Dino, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - let (item_off, _qm, _k) = gdina_layout(&q, n_items, n_attr); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let (a, b) = (item_off[i], item_off[i + 1]); - for l in a..b { - truth[l] = 1.0 - s[i]; - } - truth[a] = g[i]; - } - assert!( - rmse(&res.item_prob, &truth) < 0.03, - "DINO p RMSE {}", - rmse(&res.item_prob, &truth) - ); -} - -/// A-CDM (additive) data: recover p and confirm the interaction delta is ~0. -#[test] -fn gdina_recovers_acdm() { - let (n_attr, n_items, n) = (2usize, 10usize, 4000usize); - let q = vec![1u8; n_items * n_attr]; - let base = [0.1f64, 0.35, 0.4, 0.65]; // additive: p11 = 0.1 + 0.25 + 0.3, no interaction - let (item_off, qmask, _k) = gdina_layout(&q, n_items, n_attr); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - for l in 0..4 { - truth[item_off[i] + l] = base[l]; - } - } - let mut rng = Lcg(303); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << n_attr)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = fit_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - assert!( - rmse(&res.item_prob, &truth) < 0.05, - "A-CDM p RMSE {}", - rmse(&res.item_prob, &truth) - ); - // Additive truth => interaction terms are negligible RELATIVE to the main - // effects (an interaction is a 4-probability contrast, so its absolute noise - // (~0.05) makes a fixed bound flaky; the additivity claim is a small ratio). - let (mut sum_int, mut sum_main) = (0.0, 0.0); - for i in 0..n_items { - let base = item_off[i]; - sum_int += res.item_delta[base + 3].abs(); // both-attribute interaction - sum_main += (res.item_delta[base + 1].abs() + res.item_delta[base + 2].abs()) / 2.0; - } - assert!( - sum_int / sum_main < 0.35, - "A-CDM interaction/main ratio {}", - sum_int / sum_main - ); - assert!(top_class_is_max(&res)); -} - -/// Deterministic s=g=0 limit: ideal responses => exact pattern recovery. -#[test] -fn gdina_deterministic_limit() { - let (n_attr, n_items, n) = (2usize, 3usize, 400usize); - let q: Vec = vec![1, 0, /* */ 0, 1, /* */ 1, 1]; - let s = vec![0.0f64; n_items]; - let g = vec![0.0f64; n_items]; - let profiles: Vec = (0..n).map(|j| j % 4).collect(); - let mut rng = Lcg(9); - let y = simulate( - CdmModel::Dina, - &q, - &s, - &g, - &profiles, - n_items, - n_attr, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - assert!(res.converged && top_class_is_max(&res)); - assert!(pattern_agreement(&res.map_profile, &profiles) > 0.99); -} - -/// Tier-1 fast recovery guard: K=2, J=15, N=1000, monotone saturated truth. -#[test] -fn gdina_recovery_guard() { - let (n_attr, n_items, n) = (2usize, 15usize, 1000usize); - let mut q = vec![0u8; n_items * n_attr]; - for i in 0..15 { - if i < 5 { - q[i * 2] = 1; - } else if i < 10 { - q[i * 2 + 1] = 1; - } else { - q[i * 2] = 1; - q[i * 2 + 1] = 1; - } - } - let (item_off, qmask, kreq) = gdina_layout(&q, n_items, n_attr); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let a = item_off[i]; - if kreq[i] == 1 { - truth[a] = 0.2; - truth[a + 1] = 0.8; - } else { - truth[a] = 0.2; - truth[a + 1] = 0.5; - truth[a + 2] = 0.55; - truth[a + 3] = 0.85; - } - } - let mut rng = Lcg(2024); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << n_attr)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = fit_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); - assert!( - rmse(&res.item_prob, &truth) < 0.05, - "guard p RMSE {}", - rmse(&res.item_prob, &truth) - ); - assert!(top_class_is_max(&res)); - assert!(pattern_agreement(&res.map_profile, &profiles) > 0.80); - assert!(attribute_agreement(&res.attr_prob, &profiles, n, n_attr) > 0.85); - let total: usize = (0..n_items).map(|i| 1usize << kreq[i]).sum(); - assert_eq!(res.n_parameters, total + ((1 << n_attr) - 1)); -} - -/// Missing-at-random cells are dropped from both likelihood and reduced-class counts. -#[test] -fn gdina_handles_missing_data() { - let (n_attr, n_items, n) = (2usize, 9usize, 500usize); - let q: Vec = vec![ - 1, 0, /* */ 0, 1, /* */ 1, 1, /* */ 1, 0, /* */ 0, 1, /* */ 1, 1, - /* */ 1, 0, /* */ 0, 1, /* */ 1, 1, - ]; - let (item_off, qmask, kreq) = gdina_layout(&q, n_items, n_attr); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let a = item_off[i]; - if kreq[i] == 1 { - truth[a] = 0.2; - truth[a + 1] = 0.8; - } else { - truth[a] = 0.15; - truth[a + 1] = 0.5; - truth[a + 2] = 0.55; - truth[a + 3] = 0.85; - } - } - let mut rng = Lcg(555); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << n_attr)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let mut observed = vec![true; n * n_items]; - for o in observed.iter_mut() { - if rng.next_f64() < 0.2 { - *o = false; - } - } - let res = fit_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - assert!(res.converged && top_class_is_max(&res)); - assert!(nondecreasing(&res.loglik_trace)); -} - -/// Literature-grade Monte-Carlo (>=500 reps): de la Torre (2011)-style design. -/// Attributes are drawn from a STOCHASTIC higher-order logistic model (de la Torre -/// & Douglas, 2004) so every reduced class gets positive, correlated mass; RMSE(p) -/// is mass-weighted so near-empty classes don't dominate (spec fixes 1 & 2). Q is -/// held to 1-2 required attributes per item to keep the reduced classes populated. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_gdina_recovery() { - let (n_attr, n_items, n, reps) = (5usize, 30usize, 1000usize, 500usize); - let mut q = vec![0u8; n_items * n_attr]; - for a in 0..5 { - for r in 0..4 { - q[(a * 4 + r) * n_attr + a] = 1; - } - } - let pairs = [ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - (0, 2), - (1, 3), - (2, 4), - (0, 3), - (1, 4), - (0, 4), - ]; - for (t, &(a, b)) in pairs.iter().enumerate() { - q[(20 + t) * n_attr + a] = 1; - q[(20 + t) * n_attr + b] = 1; - } - let (item_off, qmask, kreq) = gdina_layout(&q, n_items, n_attr); - let total = item_off[n_items]; - let bk = [-1.0f64, -0.5, 0.0, 0.5, 1.0]; - let lambda = 1.5f64; - - for &skew in [false, true].iter() { - for &sg in [0.1f64, 0.2].iter() { - // Additive monotone truth: p_il = sg + (1-2sg)*popcount(l)/K_i. - let mut truth = vec![0.0f64; total]; - for i in 0..n_items { - let ki = kreq[i] as f64; - for l in 0..(item_off[i + 1] - item_off[i]) { - truth[item_off[i] + l] = sg + (1.0 - 2.0 * sg) * (l.count_ones() as f64) / ki; - } - } - let mut dtruth = truth.clone(); - for i in 0..n_items { - mobius_inverse_inplace(&mut dtruth[item_off[i]..item_off[i + 1]], kreq[i]); - } - let (mut sum_wp, mut sum_bp, mut sum_dp, mut sum_pat, mut sum_attr) = - (0.0, 0.0, 0.0, 0.0, 0.0); - for rep in 0..reps { - let seed = 0xD1B54A32D192ED03u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 * 2 + (sg == 0.1) as u64 + 1) * 0x9E3779B97F4A7C15); - let mut rng = Lcg(seed); - let profiles: Vec = (0..n) - .map(|_| { - let theta = if skew { - -(rng.next_f64().max(1e-12)).ln() - 1.0 - } else { - rng.normal() - }; - let mut c = 0usize; - for k in 0..n_attr { - let pk = 1.0 / (1.0 + (-lambda * (theta - bk[k])).exp()); - if rng.next_f64() < pk { - c |= 1 << k; - } - } - c - }) - .collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = fit_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()) - .unwrap(); - // mass-weighted RMSE(p): weight each class by realized frequency. - let mut mass = vec![0.0f64; total]; - for &c in &profiles { - for i in 0..n_items { - mass[item_off[i] + reduce_class(c, qmask[i])] += 1.0; - } - } - let (mut num, mut den) = (0.0, 0.0); - for x in 0..total { - let e = res.item_prob[x] - truth[x]; - num += mass[x] * e * e; - den += mass[x]; - } - sum_wp += (num / den).sqrt(); - sum_bp += bias(&res.item_prob, &truth); - sum_dp += rmse(&res.item_delta, &dtruth); - sum_pat += pattern_agreement(&res.map_profile, &profiles); - sum_attr += attribute_agreement(&res.attr_prob, &profiles, n, n_attr); - } - let r = reps as f64; - println!( - "skew={} s=g={:.1}: wRMSE(p)={:.4} bias(p)={:.4} RMSE(delta)={:.4} pattern={:.3} attribute={:.3}", - skew, sg, sum_wp / r, sum_bp / r, sum_dp / r, sum_pat / r, sum_attr / r - ); - assert!( - sum_wp / r < 0.03, - "mass-weighted RMSE(p) {} skew={skew} sg={sg}", - sum_wp / r - ); - if sg == 0.1 { - assert!( - sum_attr / r > 0.90, - "attribute agreement {} skew={skew}", - sum_attr / r - ); - } - } - } -} - -// ----- Q-matrix validation (de la Torre & Chiu, 2016) tests ----- - -/// A canonical K=3, 15-item Q-matrix: six single-attribute items (two per -/// attribute), six two-attribute items (two per pair), three full-triple items. -fn canonical_q3() -> Vec { - let k = 3usize; - let mut q = vec![0u8; 15 * k]; - let set = |q: &mut [u8], i: usize, attrs: &[usize]| { - for &a in attrs { - q[i * k + a] = 1; - } - }; - let rows: [&[usize]; 15] = [ - &[0], - &[1], - &[2], - &[0], - &[1], - &[2], // singles - &[0, 1], - &[0, 2], - &[1, 2], - &[0, 1], - &[0, 2], - &[1, 2], // pairs - &[0, 1, 2], - &[0, 1, 2], - &[0, 1, 2], // triples - ]; - for (i, r) in rows.iter().enumerate() { - set(&mut q, i, r); - } - q -} - -fn q_rows_equal(a: &[u8], b: &[u8], i: usize, k: usize) -> bool { - (0..k).all(|c| (a[i * k + c] != 0) == (b[i * k + c] != 0)) -} - -/// ANCHOR: DINA-generated data whose provisional Q is the TRUE Q must validate -/// to itself — every item's true q-vector is the fewest-attribute vector whose -/// PVAF clears the cutoff, so nothing is flagged. -#[test] -fn qval_true_q_validates_to_itself() { - let (k, n_items, n) = (3usize, 15usize, 3000usize); - let q = canonical_q3(); - let (s, g) = (vec![0.1f64; n_items], vec![0.1f64; n_items]); - let mut rng = Lcg(20240715); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << k)).collect(); - let y = simulate(CdmModel::Dina, &q, &s, &g, &profiles, n_items, k, &mut rng); - let observed = vec![true; n * n_items]; - let res = validate_q_matrix( - &y, - &observed, - &q, - n, - n_items, - k, - 0.95, - &CdmConfig::default(), - ) - .unwrap(); - let correct = (0..n_items) - .filter(|&i| q_rows_equal(&res.suggested_q, &q, i, k)) - .count(); - assert!( - correct >= n_items - 1, - "recovered {correct}/{n_items} true q-vectors" - ); - // The true q-vector explains ~all the item variance. - assert!( - res.provisional_pvaf.iter().all(|&p| p > 0.9), - "min provisional PVAF {}", - res.provisional_pvaf - .iter() - .cloned() - .fold(f64::INFINITY, f64::min) - ); -} - -/// A provisional Q with BOTH under-specified pairs (one attribute dropped) and -/// over-specified singles (one spurious attribute added) is corrected back to -/// the truth, and exactly the mis-specified items are flagged. -#[test] -fn qval_corrects_over_and_under_specification() { - let (k, n_items, n) = (3usize, 15usize, 4000usize); - let truth = canonical_q3(); - let (s, g) = (vec![0.1f64; n_items], vec![0.1f64; n_items]); - let mut rng = Lcg(13579); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << k)).collect(); - let y = simulate( - CdmModel::Dina, - &truth, - &s, - &g, - &profiles, - n_items, - k, - &mut rng, - ); - let observed = vec![true; n * n_items]; - - // Mis-specify a FEW items only (the method needs the rest of the Q to keep - // the attributes identified): over-specify singles 0 & 3, under-specify - // pairs 6 & 9. - let mut prov = truth.clone(); - prov[0 * k + 1] = 1; // item 0 {0} -> {0,1} - prov[3 * k + 2] = 1; // item 3 {0} -> {0,2} - prov[6 * k + 1] = 0; // item 6 {0,1} -> {0} - prov[9 * k + 0] = 0; // item 9 {0,1} -> {1} - let perturbed = [0usize, 3, 6, 9]; - - let res = validate_q_matrix( - &y, - &observed, - &prov, - n, - n_items, - k, - 0.95, - &CdmConfig::default(), - ) - .unwrap(); - let correct = (0..n_items) - .filter(|&i| q_rows_equal(&res.suggested_q, &truth, i, k)) - .count(); - assert!( - correct >= n_items - 1, - "corrected {correct}/{n_items} to truth" - ); - for &i in &perturbed { - assert!(res.flagged[i], "item {i} was mis-specified but not flagged"); - assert!( - q_rows_equal(&res.suggested_q, &truth, i, k), - "item {i} not corrected back to truth" - ); - } -} - -#[test] -fn qval_rejects_malformed() { - let n = 4usize; - let y = vec![0.0f64; n * 3]; - let obs = vec![true; n * 3]; - let q = vec![1u8; 3 * 2]; - // bad epsilon - assert!(validate_q_matrix(&y, &obs, &q, n, 3, 2, 0.0, &CdmConfig::default()).is_err()); - assert!(validate_q_matrix(&y, &obs, &q, n, 3, 2, 1.5, &CdmConfig::default()).is_err()); - // n_attributes out of range - assert!(validate_q_matrix(&y, &obs, &q, n, 3, 0, 0.95, &CdmConfig::default()).is_err()); - assert!(validate_q_matrix( - &y, - &obs, - &[1u8; 3 * 11], - n, - 3, - 11, - 0.95, - &CdmConfig::default() - ) - .is_err()); - // wrong provisional_q length - assert!(validate_q_matrix(&y, &obs, &[1u8; 5], n, 3, 2, 0.95, &CdmConfig::default()).is_err()); - // non-binary provisional entry - assert!(validate_q_matrix( - &y, - &obs, - &[2, 0, 1, 1, 0, 1], - n, - 3, - 2, - 0.95, - &CdmConfig::default() - ) - .is_err()); - assert!(validate_q_matrix( - &y, - &obs, - &[0, 0, 1, 1, 0, 1], - n, - 3, - 2, - 0.95, - &CdmConfig::default() - ) - .is_err()); - assert!(validate_q_matrix( - &y[..y.len() - 1], - &obs, - &q, - n, - 3, - 2, - 0.95, - &CdmConfig::default() - ) - .is_err()); -} - -#[test] -fn qval_constant_items_and_missing_cells_take_the_defined_fallback() { - let n = 12usize; - let y = vec![0.0; n * 3]; - let mut observed = vec![true; y.len()]; - observed[0] = false; - let q = [1, 0, 0, 1, 1, 1]; - let cfg = CdmConfig { - max_iter: 3, - tol: 1e9, - count_floor: 1e9, - ..CdmConfig::default() - }; - let result = validate_q_matrix(&y, &observed, &q, n, 3, 2, 0.95, &cfg).unwrap(); - assert_eq!(result.suggested_q, q); - assert_eq!(result.suggested_pvaf, vec![0.0; 3]); - assert_eq!(result.flagged, vec![false; 3]); - - let wald = gdina_wald_selection(&y, &observed, &q, n, 3, 2, 0.05, &cfg).unwrap(); - assert_eq!(wald.models, ["dina", "dino", "acdm", "llm", "rrum"]); - assert_eq!(wald.selected.len(), 3); -} - -#[test] -fn wald_selection_helper_covers_undefined_ties_and_parsimony() { - assert_eq!(select_wald_model(&[0, 0], &[0.9, 0.9], 0.05, 3), -1); - assert_eq!(select_wald_model(&[1, 1], &[f64::NAN, 0.01], 0.05, 3), -1); - assert_eq!(select_wald_model(&[1, 1, 1], &[0.2, 0.4, 0.9], 0.05, 3), 1); - assert_eq!(select_wald_model(&[1, 1, 1], &[0.2, 0.1, 0.9], 0.05, 1), 2); -} - -#[test] -fn qval_rejects_nonconverged_calibration() { - let n = 8usize; - let y = vec![ - 0.0, 0.0, 0.0, // 00 - 0.0, 1.0, 0.0, // 01 - 1.0, 0.0, 0.0, // 10 - 1.0, 1.0, 1.0, // 11 - 0.0, 0.0, 0.0, // repeated response patterns keep every item observed - 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, - ]; - let observed = vec![true; y.len()]; - let q = vec![1, 0, 0, 1, 1, 1]; - let cfg = CdmConfig { - max_iter: 1, - tol: 1e-12, - ..CdmConfig::default() - }; - - let err = validate_q_matrix(&y, &observed, &q, n, 3, 2, 0.95, &cfg).unwrap_err(); - assert!(err.contains("did not converge"), "unexpected error: {err}"); - assert!(err.contains("1 of 1 M-steps"), "unexpected error: {err}"); - assert!( - err.contains("tol = 1.000000e-12"), - "unexpected error: {err}" - ); -} - -/// Literature-grade Monte-Carlo (>=500 reps): recovery of the true Q-matrix by -/// PVAF validation starting from a mis-specified provisional Q, under a uniform -/// (independent) and a correlated/skew (higher-order) attribute distribution. -/// Reported as a *procedure* recovery: per-item exact q-vector rate plus -/// attribute-level true-positive / false-positive rates. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_qval_recovery_500() { - let (k, n_items, n, reps) = (3usize, 15usize, 1000usize, 500usize); - let truth = canonical_q3(); - let (s, g) = (vec![0.1f64; n_items], vec![0.1f64; n_items]); - let bk = [-0.6f64, 0.0, 0.6]; - let lambda = 1.5f64; - // This simulation has N*J = 15,000 observed cells, so an absolute - // log-likelihood increment of 2e-4 is at most 1.4e-8 per cell. Keep the - // production iteration cap, but make the literature-grade stopping - // contract explicit instead of accepting unfinished default-tolerance fits. - let cfg = CdmConfig { - tol: 2e-4, - ..CdmConfig::default() - }; - - for &skew in [false, true].iter() { - let (mut sum_qrec, mut sum_tpr, mut sum_fpr) = (0.0f64, 0.0f64, 0.0f64); - let (mut max_n_iter, mut max_final_delta) = (0usize, 0.0f64); - for rep in 0..reps { - let seed = 0x2545F4914F6CDD1Du64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0x9E3779B97F4A7C15); - let mut rng = Lcg(seed); - // attribute profiles - let profiles: Vec = (0..n) - .map(|_| { - if skew { - // correlated higher-order logistic (de la Torre & Douglas, 2004) - let theta = -(rng.next_f64().max(1e-12)).ln() - 1.0; - let mut c = 0usize; - for a in 0..k { - let pk = 1.0 / (1.0 + (-lambda * (theta - bk[a])).exp()); - if rng.next_f64() < pk { - c |= 1 << a; - } - } - c - } else { - rng.profile(1 << k) // independent uniform over classes - } - }) - .collect(); - let y = simulate( - CdmModel::Dina, - &truth, - &s, - &g, - &profiles, - n_items, - k, - &mut rng, - ); - let observed = vec![true; n * n_items]; - - // mis-specify ~1/6 of items (flip one attribute bit); the rest keep - // the attributes identified, as the method requires. - let mut prov = truth.clone(); - for i in 0..n_items { - if rng.next_f64() < 0.17 { - let a = (rng.next_f64() * k as f64) as usize % k; - prov[i * k + a] ^= 1; - } - // guard against an all-zero provisional row (validation needs >=1) - if (0..k).all(|a| prov[i * k + a] == 0) { - prov[i * k] = 1; - } - } - let res = validate_q_matrix(&y, &observed, &prov, n, n_items, k, 0.95, &cfg) - .unwrap_or_else(|err| { - panic!("Q validation failed for skew={skew} rep={rep} seed={seed}: {err}") - }); - assert_eq!(res.calibration_termination_reason, "tolerance_met"); - assert_eq!(res.calibration_max_iter, cfg.max_iter); - assert_eq!(res.calibration_tol, cfg.tol); - assert!( - 0 < res.calibration_n_iter && res.calibration_n_iter < cfg.max_iter, - "invalid iteration evidence for skew={skew} rep={rep} seed={seed}: {}/{}", - res.calibration_n_iter, - cfg.max_iter - ); - assert!( - res.calibration_final_loglik_change.is_finite() - && res.calibration_final_loglik_change < cfg.tol, - "invalid stopping evidence for skew={skew} rep={rep} seed={seed}: {} >= {}", - res.calibration_final_loglik_change, - cfg.tol - ); - max_n_iter = max_n_iter.max(res.calibration_n_iter); - max_final_delta = max_final_delta.max(res.calibration_final_loglik_change); - - let mut qrec = 0usize; - let (mut tp, mut fp, mut pos, mut neg) = (0usize, 0usize, 0usize, 0usize); - for i in 0..n_items { - if q_rows_equal(&res.suggested_q, &truth, i, k) { - qrec += 1; - } - for a in 0..k { - let t = truth[i * k + a] != 0; - let hcap = res.suggested_q[i * k + a] != 0; - if t { - pos += 1; - if hcap { - tp += 1; - } - } else { - neg += 1; - if hcap { - fp += 1; - } - } - } - } - sum_qrec += qrec as f64 / n_items as f64; - sum_tpr += tp as f64 / pos as f64; - sum_fpr += fp as f64 / neg as f64; - } - let r = reps as f64; - println!( - concat!( - "[qval MC skew={}] reps={} converged={}/{} ", - "termination=tolerance_met iterations_max={}/{} ", - "final_delta_max={:.6e} tol={:.1e} ", - "q-recovery={:.3} attr-TPR={:.3} attr-FPR={:.3}" - ), - skew, - reps, - reps, - reps, - max_n_iter, - cfg.max_iter, - max_final_delta, - cfg.tol, - sum_qrec / r, - sum_tpr / r, - sum_fpr / r - ); - assert!( - sum_qrec / r > 0.80, - "q-vector recovery {} skew={skew}", - sum_qrec / r - ); - assert!( - sum_tpr / r > 0.90, - "attribute TPR {} skew={skew}", - sum_tpr / r - ); - assert!( - sum_fpr / r < 0.10, - "attribute FPR {} skew={skew}", - sum_fpr / r - ); - } -} - -// ----- CDM item-level Wald model selection (de la Torre, 2011) tests ----- - -/// K=2 Q with `n_single` single-attribute items per attribute (strong attribute -/// identification keeps the complete-data Wald covariance accurate) plus -/// `n_pair` two-attribute items (the ones the Wald test evaluates). The first -/// `2*n_single` items are singletons; the pair items follow. -fn wald_q2(n_single: usize, n_pair: usize) -> (Vec, usize) { - let k = 2usize; - let mut rows: Vec<[u8; 2]> = Vec::new(); - for _ in 0..n_single { - rows.push([1, 0]); - } - for _ in 0..n_single { - rows.push([0, 1]); - } - for _ in 0..n_pair { - rows.push([1, 1]); - } - let n_items = rows.len(); - let mut q = vec![0u8; n_items * k]; - for (i, r) in rows.iter().enumerate() { - q[i * k] = r[0]; - q[i * k + 1] = r[1]; - } - (q, n_items) -} - -/// CSR truth table for the K=2 scenario. Single items are 2PL-like (low/high); -/// pair items follow `kind`: DINA (conjunctive), DINO (disjunctive), A-CDM -/// (additive), or "sat" (main effects AND interaction, so no reduced model fits). -fn wald_truth(q: &[u8], n_items: usize, kind: &str) -> (Vec, Vec, Vec) { - let (item_off, qmask, kreq) = gdina_layout(q, n_items, 2); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let a = item_off[i]; - if kreq[i] == 1 { - truth[a] = 0.15; - truth[a + 1] = 0.85; - } else { - // reduce_class layout: [none, a0, a1, both] - let sig = |x: f64| 1.0 / (1.0 + (-x).exp()); - let (p00, p10, p01, p11) = match kind { - "dina" => (0.15, 0.15, 0.15, 0.85), // conjunctive - "dino" => (0.15, 0.85, 0.85, 0.85), // disjunctive (any mastered -> 1-s) - "acdm" => (0.10, 0.45, 0.45, 0.80), // additive 0.1 + .35a0 + .35a1 - // LLM: additive on the logit, logit(P) = -3 + 2 a0 + 2 a1. Chosen - // asymmetric (2*(-3)+2+2 = -2 != 0) so the four points are NOT - // reflection-symmetric about 0 -> genuinely identity-NONadditive - // (A-CDM must reject) yet exactly logit-additive (LLM must not). Also - // log-nonadditive (P10/P00 != P11/P01), so R-RUM rejects too. - "llm" => (sig(-3.0), sig(-1.0), sig(-1.0), sig(1.0)), - // R-RUM: additive on the log, P = pi* r0^(1-a0) r1^(1-a1) with - // pi*=0.92, r0=0.3, r1=0.4. Log-additive (P10/P00 = P11/P01 = 1/r0) - // but strongly identity- AND logit-NONadditive (the high pi* makes - // logit(P) depart from log(P) sharply), so only R-RUM survives. - "rrum" => (0.92 * 0.3 * 0.4, 0.92 * 0.4, 0.92 * 0.3, 0.92), - _ => (0.10, 0.35, 0.35, 0.90), // main effects + interaction (saturated) - }; - truth[a] = p00; - truth[a + 1] = p10; - truth[a + 2] = p01; - truth[a + 3] = p11; - } - } - (item_off, qmask, truth) -} - -/// DINA-generated pair items are classified as DINA (the conjunctive reduced -/// model is not rejected while the additive one is). -#[test] -fn wald_dina_data_selects_dina() { - let (q, n_items) = wald_q2(5, 8); - let n = 5000usize; - let first_pair = 10usize; - let (item_off, qmask, truth) = wald_truth(&q, n_items, "dina"); - let mut rng = Lcg(4011); - let profiles: Vec = (0..n).map(|_| rng.profile(4)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = gdina_wald_selection( - &y, - &observed, - &q, - n, - n_items, - 2, - 0.05, - &CdmConfig::default(), - ) - .unwrap(); - assert_eq!( - res.models, - vec![ - "dina".to_string(), - "dino".to_string(), - "acdm".to_string(), - "llm".to_string(), - "rrum".to_string(), - ] - ); - let nm = res.models.len(); - let pair_dina = (first_pair..n_items) - .filter(|&i| res.selected[i] == 0) - .count(); - assert!(pair_dina >= 7, "DINA selected for {pair_dina}/8 pair items"); - // single-attribute items are trivial (df=0) -> saturated, NaN stats - for i in 0..first_pair { - assert_eq!(res.selected[i], -1); - assert!(res.wald_stat[i * nm].is_nan()); - } -} - -/// DINO-generated pair items are classified as DINO (the disjunctive reduced -/// model is not rejected while DINA and A-CDM are). Exercises the general -/// (non-coordinate) linear restriction and the DINA/DINO parameter-count tie. -#[test] -fn wald_dino_data_selects_dino() { - let (q, n_items) = wald_q2(5, 8); - let n = 8000usize; - let first_pair = 10usize; - let (item_off, qmask, truth) = wald_truth(&q, n_items, "dino"); - let mut rng = Lcg(6060); - let profiles: Vec = (0..n).map(|_| rng.profile(4)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = gdina_wald_selection( - &y, - &observed, - &q, - n, - n_items, - 2, - 0.05, - &CdmConfig::default(), - ) - .unwrap(); - let nm = res.models.len(); - let pair_dino = (first_pair..n_items) - .filter(|&i| res.selected[i] == 1) - .count(); - assert!(pair_dino >= 7, "DINO selected for {pair_dino}/8 pair items"); - // DINO and DINA both have df = 2^K - 2 = 2 at K=2 - assert_eq!(res.wald_df[first_pair * nm], 2); // DINA - assert_eq!(res.wald_df[first_pair * nm + 1], 2); // DINO -} - -/// Additive-generated pair items are classified as A-CDM (additive not rejected, -/// conjunctive DINA and disjunctive DINO rejected). A-CDM is candidate index 2. -#[test] -fn wald_acdm_data_selects_acdm() { - let (q, n_items) = wald_q2(5, 8); - let n = 5000usize; - let first_pair = 10usize; - let (item_off, qmask, truth) = wald_truth(&q, n_items, "acdm"); - let mut rng = Lcg(2027); - let profiles: Vec = (0..n).map(|_| rng.profile(4)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = gdina_wald_selection( - &y, - &observed, - &q, - n, - n_items, - 2, - 0.05, - &CdmConfig::default(), - ) - .unwrap(); - let pair_acdm = (first_pair..n_items) - .filter(|&i| res.selected[i] == 2) - .count(); - assert!( - pair_acdm >= 7, - "A-CDM selected for {pair_acdm}/8 pair items" - ); -} - -/// Faithfulness anchor for the link-transformed reduced models. The LLM and R-RUM -/// truths are constructed to be additive ONLY on their own link (logit / log) and -/// genuinely NON-additive on the identity link, so a correct implementation must -/// (a) select LLM (index 3) / R-RUM (index 4) and (b) *reject* the identity-link -/// A-CDM (index 2) — a sign/identity bug in the Jacobian covariance or the -/// transformed delta would collapse this distinction. This is deliberately a -/// non-centered, non-trivial truth: A-CDM, LLM and R-RUM all cost 1+K parameters, -/// so only the transform can break the tie. -#[test] -fn wald_llm_and_rrum_data_select_their_link() { - let (q, n_items) = wald_q2(5, 8); - let n = 8000usize; - let first_pair = 10usize; - - // LLM truth (logit-additive; identity- and log-NONadditive) -> LLM selected. - let (item_off, qmask, truth) = wald_truth(&q, n_items, "llm"); - let mut rng = Lcg(770011); - let profiles: Vec = (0..n).map(|_| rng.profile(4)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = gdina_wald_selection( - &y, - &observed, - &q, - n, - n_items, - 2, - 0.05, - &CdmConfig::default(), - ) - .unwrap(); - let nm = res.models.len(); - let pair_llm = (first_pair..n_items) - .filter(|&i| res.selected[i] == 3) - .count(); - assert!(pair_llm >= 7, "LLM selected for {pair_llm}/8 pair items"); - // The identity-link A-CDM must be rejected on these identity-nonadditive items. - let acdm_rej = (first_pair..n_items) - .filter(|&i| res.p_value[i * nm + 2] < 0.05) - .count(); - assert!( - acdm_rej >= 7, - "A-CDM rejected on {acdm_rej}/8 LLM items (identity-nonadditive)" - ); - - // R-RUM truth (log-additive; identity- and logit-NONadditive) -> R-RUM selected. - let (item_off, qmask, truth) = wald_truth(&q, n_items, "rrum"); - let mut rng = Lcg(880022); - let profiles: Vec = (0..n).map(|_| rng.profile(4)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let res = gdina_wald_selection( - &y, - &observed, - &q, - n, - n_items, - 2, - 0.05, - &CdmConfig::default(), - ) - .unwrap(); - let pair_rrum = (first_pair..n_items) - .filter(|&i| res.selected[i] == 4) - .count(); - assert!( - pair_rrum >= 7, - "R-RUM selected for {pair_rrum}/8 pair items" - ); - // The logit-link LLM must be rejected on these logit-nonadditive items. - let llm_rej = (first_pair..n_items) - .filter(|&i| res.p_value[i * nm + 3] < 0.05) - .count(); - assert!( - llm_rej >= 7, - "LLM rejected on {llm_rej}/8 R-RUM items (logit-nonadditive)" - ); -} - -/// Items with both main effects and an interaction reject every reduced model, -/// so the saturated G-DINA is kept. -#[test] -fn wald_saturated_data_selects_saturated() { - let (q, n_items) = wald_q2(5, 8); - let n = 5000usize; - let first_pair = 10usize; - let (item_off, qmask, truth) = wald_truth(&q, n_items, "sat"); - let mut rng = Lcg(9091); - let profiles: Vec = (0..n).map(|_| rng.profile(4)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = gdina_wald_selection( - &y, - &observed, - &q, - n, - n_items, - 2, - 0.05, - &CdmConfig::default(), - ) - .unwrap(); - let nm = res.models.len(); - let pair_sat = (first_pair..n_items) - .filter(|&i| res.selected[i] == -1) - .count(); - assert!(pair_sat >= 7, "saturated kept for {pair_sat}/8 pair items"); - // every reduced model (DINA/DINO/A-CDM/LLM/R-RUM) carries a positive, finite stat - for i in first_pair..n_items { - for m in 0..nm { - assert!(res.wald_stat[i * nm + m].is_finite() && res.wald_stat[i * nm + m] >= 0.0); - assert!(res.p_value[i * nm + m].is_finite()); - } - } -} - -/// Degrees of freedom are exactly the restriction sizes: DINA & DINO df = 2^K-2, -/// A-CDM df = 2^K-1-K, for K=3 items. -#[test] -fn wald_degrees_of_freedom() { - // K=3 Q: single items (identification) + one triple item to read df off. - let k = 3usize; - let mut rows: Vec<[u8; 3]> = Vec::new(); - for a in 0..3 { - for _ in 0..3 { - let mut r = [0u8; 3]; - r[a] = 1; - rows.push(r); - } - } - rows.push([1, 1, 1]); // one K=3 item - let n_items = rows.len(); - let mut q = vec![0u8; n_items * k]; - for (i, r) in rows.iter().enumerate() { - q[i * k..i * k + k].copy_from_slice(r); - } - let n = 3000usize; - let (item_off, qmask, _kr) = gdina_layout(&q, n_items, k); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let a = item_off[i]; - let w = item_off[i + 1] - a; - for l in 0..w { - truth[a + l] = 0.15 + 0.7 * (l.count_ones() as f64) / (w.trailing_zeros() as f64); - } - } - let mut rng = Lcg(31337); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << k)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let res = gdina_wald_selection( - &y, - &observed, - &q, - n, - n_items, - k, - 0.05, - &CdmConfig::default(), - ) - .unwrap(); - let nm = res.models.len(); - let triple = n_items - 1; - assert_eq!(res.wald_df[triple * nm], (1 << k) - 2, "DINA df"); // 6 - assert_eq!(res.wald_df[triple * nm + 1], (1 << k) - 2, "DINO df"); // 6 - assert_eq!(res.wald_df[triple * nm + 2], (1 << k) - 1 - k, "A-CDM df"); // 4 - assert_eq!(res.wald_df[triple * nm + 3], (1 << k) - 1 - k, "LLM df"); // 4 - assert_eq!(res.wald_df[triple * nm + 4], (1 << k) - 1 - k, "R-RUM df"); // 4 - // single-attribute items: no test (df=0), saturated - assert_eq!(res.wald_df[0], 0); - assert_eq!(res.selected[0], -1); -} - -#[test] -fn wald_rejects_malformed() { - let (q, n_items) = wald_q2(2, 2); - let n = 10usize; - let y = vec![0.0f64; n * n_items]; - let obs = vec![true; n * n_items]; - // alpha out of (0,1) - assert!(gdina_wald_selection(&y, &obs, &q, n, n_items, 2, 0.0, &CdmConfig::default()).is_err()); - assert!(gdina_wald_selection(&y, &obs, &q, n, n_items, 2, 1.0, &CdmConfig::default()).is_err()); - // shape errors are delegated to fit_gdina's validate - assert!(gdina_wald_selection( - &y[..5], - &obs, - &q, - n, - n_items, - 2, - 0.05, - &CdmConfig::default() - ) - .is_err()); -} - -#[test] -fn wald_rejects_nonconverged_gdina_calibration() { - let (q, n_items) = wald_q2(2, 2); - let n = 80usize; - let mut rng = Lcg(20260715); - let profiles: Vec = (0..n).map(|_| rng.profile(4)).collect(); - let (item_off, qmask, truth) = wald_truth(&q, n_items, "dina"); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = CdmConfig { - max_iter: 1, - tol: 1e-12, - ..CdmConfig::default() - }; - - let err = gdina_wald_selection(&y, &observed, &q, n, n_items, 2, 0.05, &cfg) - .expect_err("Wald selection must not use unfinished G-DINA parameters"); - assert!(err.contains("G-DINA calibration did not converge after 1 of 1 M-steps")); - assert!(err.contains("final |delta loglik| =")); - assert!(err.contains("tol = 1.000000e-12")); -} - -/// Literature-grade Monte-Carlo (>=500 reps): Type I error (reject the TRUE -/// reduced model ~ alpha) and power (reject a false, over-restrictive model), -/// under uniform and correlated/skew attribute distributions. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_wald_type1_power_500() { - let reps = 500usize; - let (q, n_items) = wald_q2(5, 8); - let n = 3000usize; - let first_pair = 10usize; - let k = 2usize; - let bk = [-0.4f64, 0.4]; - let lambda = 1.5f64; - let draw_profiles = |rng: &mut Lcg, skew: bool| -> Vec { - (0..n) - .map(|_| { - if skew { - let theta = -(rng.next_f64().max(1e-12)).ln() - 1.0; - let mut c = 0usize; - for a in 0..k { - let pk = 1.0 / (1.0 + (-lambda * (theta - bk[a])).exp()); - if rng.next_f64() < pk { - c |= 1 << a; - } - } - c - } else { - rng.profile(1 << k) - } - }) - .collect() - }; - - // Candidate columns: DINA=0, DINO=1, A-CDM=2, LLM=3, R-RUM=4. - for &skew in [false, true].iter() { - let (mut t1_acdm, mut t1_dina, mut t1_dino, mut t1_llm, mut t1_rrum) = - (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64); - // Power of over-restrictive models against each additive-family truth: the - // identity-link A-CDM and cross-link LLM/R-RUM must reject the wrong link. - let (mut pow_dina, mut pow_dino, mut pow_acdm_llm, mut pow_rrum_llm, mut pow_llm_rrum) = - (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64); - let mut den = 0.0f64; - for rep in 0..reps { - let mut rng = Lcg(0x9E3779B97F4A7C15u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0xD1B54A32D192ED03)); - let obs = vec![true; n * n_items]; - let run = |kind: &str, rng: &mut Lcg| { - let (io, qm, tr) = wald_truth(&q, n_items, kind); - let prof = draw_profiles(rng, skew); - let y = simulate_gdina(&qm, &io, &tr, &prof, n_items, rng); - gdina_wald_selection(&y, &obs, &q, n, n_items, k, 0.05, &CdmConfig::default()) - .unwrap() - }; - // A-CDM truth: Type I of A-CDM (col 2) + power of the false DINA (col 0). - let ra = run("acdm", &mut rng); - // DINA truth: Type I of DINA (col 0) + power of the false DINO (col 1). - let rd = run("dina", &mut rng); - // DINO truth: Type I of DINO (col 1). - let rn = run("dino", &mut rng); - // LLM truth: Type I of LLM (col 3) + power of the false identity A-CDM - // (col 2) and false log-link R-RUM (col 4). - let rl = run("llm", &mut rng); - // R-RUM truth: Type I of R-RUM (col 4) + power of the false logit LLM (col 3). - let rr = run("rrum", &mut rng); - let nm = ra.models.len(); - for i in first_pair..n_items { - if ra.p_value[i * nm + 2] < 0.05 { - t1_acdm += 1.0; - } - if ra.p_value[i * nm] < 0.05 { - pow_dina += 1.0; // DINA false under A-CDM truth - } - if rd.p_value[i * nm] < 0.05 { - t1_dina += 1.0; - } - if rd.p_value[i * nm + 1] < 0.05 { - pow_dino += 1.0; // DINO false under DINA truth - } - if rn.p_value[i * nm + 1] < 0.05 { - t1_dino += 1.0; - } - if rl.p_value[i * nm + 3] < 0.05 { - t1_llm += 1.0; - } - if rl.p_value[i * nm + 2] < 0.05 { - pow_acdm_llm += 1.0; // A-CDM false under LLM truth - } - if rl.p_value[i * nm + 4] < 0.05 { - pow_rrum_llm += 1.0; // R-RUM false under LLM truth - } - if rr.p_value[i * nm + 4] < 0.05 { - t1_rrum += 1.0; - } - if rr.p_value[i * nm + 3] < 0.05 { - pow_llm_rrum += 1.0; // LLM false under R-RUM truth - } - den += 1.0; - } - } - println!( - "[wald MC skew={skew}] reps={reps} TypeI(dina)={:.3} TypeI(dino)={:.3} \ - TypeI(acdm)={:.3} TypeI(llm)={:.3} TypeI(rrum)={:.3} power(dina|acdm)={:.3} \ - power(dino|dina)={:.3} power(acdm|llm)={:.3} power(rrum|llm)={:.3} \ - power(llm|rrum)={:.3}", - t1_dina / den, - t1_dino / den, - t1_acdm / den, - t1_llm / den, - t1_rrum / den, - pow_dina / den, - pow_dino / den, - pow_acdm_llm / den, - pow_rrum_llm / den, - pow_llm_rrum / den - ); - // Complete-data covariance is mildly liberal; allow up to ~2.5x nominal. - assert!(t1_acdm / den < 0.13, "A-CDM Type I {}", t1_acdm / den); - assert!(t1_dina / den < 0.13, "DINA Type I {}", t1_dina / den); - assert!(t1_dino / den < 0.13, "DINO Type I {}", t1_dino / den); - assert!(t1_llm / den < 0.13, "LLM Type I {}", t1_llm / den); - assert!(t1_rrum / den < 0.13, "R-RUM Type I {}", t1_rrum / den); - assert!(pow_dina / den > 0.95, "DINA power {}", pow_dina / den); - assert!(pow_dino / den > 0.95, "DINO power {}", pow_dino / den); - assert!( - pow_acdm_llm / den > 0.95, - "A-CDM|LLM power {}", - pow_acdm_llm / den - ); - assert!( - pow_rrum_llm / den > 0.90, - "R-RUM|LLM power {}", - pow_rrum_llm / den - ); - assert!( - pow_llm_rrum / den > 0.90, - "LLM|R-RUM power {}", - pow_llm_rrum / den - ); - } -} - -// ----- Higher-order structured attribute prior (de la Torre & Douglas, 2004) ----- - -/// Simulate higher-order DINA data: theta -> attribute mastery via -/// sigmoid(a_k theta + d_k), then the DINA gate with slip/guess. -#[allow(clippy::too_many_arguments)] -fn simulate_ho_dina( - a: &[f64], - d: &[f64], - s: &[f64], - g: &[f64], - q: &[u8], - n: usize, - n_items: usize, - n_attr: usize, - skew: bool, - rng: &mut Lcg, -) -> (Vec, Vec, Vec) { - let mut y = vec![0.0f64; n * n_items]; - let mut profiles = vec![0usize; n]; - let mut thetas = vec![0.0f64; n]; - for j in 0..n { - let theta = if skew { - // standardized shifted chi-square(3): mean 0, var 1, right-skewed - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / (6.0_f64).sqrt() - } else { - rng.normal() - }; - thetas[j] = theta; - let mut c = 0usize; - for k in 0..n_attr { - let p = 1.0 / (1.0 + (-(a[k] * theta + d[k])).exp()); - if rng.next_f64() < p { - c |= 1 << k; - } - } - profiles[j] = c; - for i in 0..n_items { - let mask = qmask_of(q, i, n_attr); - let eta = (c & mask) == mask; - let p = if eta { 1.0 - s[i] } else { g[i] }; - y[j * n_items + i] = rng.bern(p); - } - } - (y, profiles, thetas) -} - -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let mx = x.iter().sum::() / n; - let my = y.iter().sum::() / n; - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for i in 0..x.len() { - sxy += (x[i] - mx) * (y[i] - my); - sxx += (x[i] - mx).powi(2); - syy += (y[i] - my).powi(2); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -/// ANCHOR: with every attribute slope zero, the implied class prior is exactly the -/// independent-attribute Bernoulli product (theta drops out), bit-for-bit. -#[test] -fn ho_pi_independent_when_slope_zero() { - let k = 3usize; - let a = vec![0.0f64; k]; - let d = vec![0.7f64, -0.4, 0.2]; - let pi = ho_pi_from_params(&a, &d, k); - let pk: Vec = d.iter().map(|&dk| 1.0 / (1.0 + (-dk).exp())).collect(); - for c in 0..(1 << k) { - let mut prod = 1.0f64; - for (bit, &p) in pk.iter().enumerate() { - prod *= if (c >> bit) & 1 == 1 { p } else { 1.0 - p }; - } - assert!( - (pi[c] - prod).abs() < 1e-12, - "class {c}: {} vs {}", - pi[c], - prod - ); - } - assert!((pi.iter().sum::() - 1.0).abs() < 1e-12); -} - -/// Higher-order DINA recovery: attribute slopes/intercepts, slip/guess, the trait, -/// and attribute classification under a known higher-order structure. -#[test] -fn ho_recovers_params() { - let (n_attr, n_items, n) = (3usize, 15usize, 4000usize); - let mut q = vec![0u8; n_items * n_attr]; - for i in 0..n_items { - // 4 single-attribute items per attribute + 3 pair items - if i < 12 { - q[i * n_attr + (i / 4)] = 1; - } else { - q[i * n_attr + (i - 12)] = 1; - q[i * n_attr + ((i - 12) + 1) % n_attr] = 1; - } - } - let a_true = vec![1.2f64, 1.5, 0.9]; - let d_true = vec![0.3f64, -0.5, 0.6]; - let s = vec![0.12f64; n_items]; - let g = vec![0.12f64; n_items]; - let mut rng = Lcg(70424); - let (y, profiles, thetas) = simulate_ho_dina( - &a_true, &d_true, &s, &g, &q, n, n_items, n_attr, false, &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_ho_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); - assert!(res.n_parameters == 2 * n_items + 2 * n_attr); - assert!((res.profile_prob.iter().sum::() - 1.0).abs() < 1e-9); - // slip/guess - assert!( - rmse(&res.slip, &s) < 0.05, - "slip RMSE {}", - rmse(&res.slip, &s) - ); - assert!( - rmse(&res.guess, &g) < 0.05, - "guess RMSE {}", - rmse(&res.guess, &g) - ); - // higher-order parameters (identified up to the N(0,1) trait scale) - assert!( - rmse(&res.attr_slope, &a_true) < 0.4, - "a RMSE {}", - rmse(&res.attr_slope, &a_true) - ); - assert!( - rmse(&res.attr_intercept, &d_true) < 0.3, - "d RMSE {}", - rmse(&res.attr_intercept, &d_true) - ); - assert!(res.attr_slope.iter().all(|&x| x > 0.0)); - // trait recovery (EAP is shrunk, so correlation is the right metric) - assert!( - corr(&res.theta, &thetas) > 0.6, - "theta corr {}", - corr(&res.theta, &thetas) - ); - // attribute classification - assert!( - attribute_agreement(&res.attr_prob, &profiles, n, n_attr) > 0.85, - "attribute agreement {}", - attribute_agreement(&res.attr_prob, &profiles, n, n_attr) - ); -} - -/// Data from independent attributes (all true slopes 0) -> the *implied class -/// distribution* `pi_c` recovers the independent-attribute product. (The -/// individual slopes are not the right target: independence is also consistent -/// with a single nonzero slope, since one attribute loading on theta induces no -/// cross-attribute correlation. The likelihood identifies only `pi_c`.) -#[test] -fn ho_independent_data_recovers_pi() { - let (n_attr, n_items, n) = (3usize, 15usize, 4000usize); - let mut q = vec![0u8; n_items * n_attr]; - for i in 0..n_items { - if i < 12 { - q[i * n_attr + (i / 4)] = 1; - } else { - q[i * n_attr + (i - 12)] = 1; - q[i * n_attr + ((i - 12) + 1) % n_attr] = 1; - } - } - let a_true = vec![0.0f64; n_attr]; - let d_true = vec![0.4f64, -0.3, 0.2]; - let s = vec![0.1f64; n_items]; - let g = vec![0.1f64; n_items]; - let mut rng = Lcg(9021); - let (y, _p, _t) = simulate_ho_dina( - &a_true, &d_true, &s, &g, &q, n, n_items, n_attr, false, &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_ho_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - let pi_true = ho_pi_from_params(&a_true, &d_true, n_attr); - assert!( - rmse(&res.profile_prob, &pi_true) < 0.03, - "implied pi RMSE {}", - rmse(&res.profile_prob, &pi_true) - ); -} - -/// Single-attribute Q: DINA and DINO share the ideal-response gate, so the -/// higher-order fits coincide. Also exercises missing-at-random data. -#[test] -fn ho_reduces_dino_and_handles_missing() { - let (n_attr, n_items, n) = (2usize, 8usize, 1000usize); - let q: Vec = (0..n_items) - .flat_map(|i| if i % 2 == 0 { [1u8, 0] } else { [0u8, 1] }) - .collect(); - let a_true = vec![1.0f64, 1.0]; - let d_true = vec![0.0f64, 0.0]; - let s = vec![0.15f64; n_items]; - let g = vec![0.15f64; n_items]; - let mut rng = Lcg(4242); - let (mut y, _p, _t) = simulate_ho_dina( - &a_true, &d_true, &s, &g, &q, n, n_items, n_attr, false, &mut rng, - ); - let mut observed = vec![true; n * n_items]; - // DINA == DINO on single-attribute items - let da = fit_ho_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - let di = fit_ho_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dino, - &CdmConfig::default(), - ) - .unwrap(); - assert!(rmse(&da.slip, &di.slip) < 1e-9 && rmse(&da.guess, &di.guess) < 1e-9); - // missing-at-random cells dropped, still converges - for o in observed.iter_mut() { - if rng.next_f64() < 0.15 { - *o = false; - } - } - for (idx, o) in observed.iter().enumerate() { - if !o { - y[idx] = 0.0; - } - } - let rm = fit_ho_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - assert!(rm.loglik_trace.iter().all(|v| v.is_finite())); -} - -/// Full structural Newton steps used to make the observed log-likelihood fall -/// (seed 12) and could then satisfy `abs(delta) < tol` on a negative change, -/// falsely reporting convergence (seed 6). -#[test] -fn ho_structural_newton_preserves_em_ascent() { - let (n_attr, n_items, n) = (3usize, 9usize, 40usize); - let q = vec![ - 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 1, - ]; - let item_prob = [0.1f64, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]; - for (seed, max_iter) in [(12u64, 100usize), (6, 500)] { - let mut rng = Lcg(seed); - let mut y = vec![0.0; n * n_items]; - for j in 0..n { - for i in 0..n_items { - y[j * n_items + i] = rng.bern(item_prob[i]); - } - } - let observed = vec![true; y.len()]; - let cfg = CdmConfig { - max_iter, - ..CdmConfig::default() - }; - let res = fit_ho_cdm(&y, &observed, &q, n, n_items, n_attr, CdmModel::Dina, &cfg).unwrap(); - let final_delta = res.loglik_trace[res.loglik_trace.len() - 1] - - res.loglik_trace[res.loglik_trace.len() - 2]; - assert_eq!(res.max_iter, cfg.max_iter); - assert_eq!(res.stopping_tolerance, cfg.tol); - assert_eq!(res.final_loglik_change, final_delta); - assert!( - nondecreasing(&res.loglik_trace), - "higher-order GEM lowered log-likelihood for seed {seed}: {:?}", - res.loglik_trace - ); - if seed == 6 { - assert!(res.converged, "safeguarded seed-6 fit did not converge"); - assert_eq!(res.termination_reason, "tolerance_met"); - assert!(res.n_iter < res.max_iter); - assert!( - (0.0..cfg.tol).contains(&res.final_loglik_change), - "convergence must be a non-negative improvement below tol; delta={:e}", - res.final_loglik_change - ); - } else { - assert!(!res.converged); - assert_eq!(res.termination_reason, "max_iter_reached"); - assert_eq!(res.n_iter, res.max_iter); - assert!(res.final_loglik_change.is_finite()); - assert!(res.final_loglik_change >= res.stopping_tolerance); - } - } -} - -#[test] -fn ho_validate_rejects_malformed() { - let cfg = CdmConfig::default(); - // y length mismatch (expects n_persons * n_items = 2) - assert!(fit_ho_cdm(&[0.0], &[true], &[1, 1], 1, 2, 1, CdmModel::Dina, &cfg).is_err()); - // all-zero Q column: attribute 1 measured by no item - assert!(fit_ho_cdm( - &[0.0, 1.0], - &[true, true], - &[1, 0, 1, 0], - 1, - 2, - 2, - CdmModel::Dina, - &cfg - ) - .is_err()); -} - -/// Literature-grade Monte-Carlo (>=500 reps): higher-order DINA parameter recovery -/// under normal and skew (mis-specified prior) trait distributions. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_ho_recovery_500() { - let (n_attr, n_items, n, reps) = (3usize, 15usize, 1000usize, 500usize); - let mut q = vec![0u8; n_items * n_attr]; - for i in 0..n_items { - if i < 12 { - q[i * n_attr + (i / 4)] = 1; - } else { - q[i * n_attr + (i - 12)] = 1; - q[i * n_attr + ((i - 12) + 1) % n_attr] = 1; - } - } - let a_true = vec![1.2f64, 1.5, 0.9]; - let d_true = vec![0.3f64, -0.5, 0.6]; - let s = vec![0.12f64; n_items]; - let g = vec![0.12f64; n_items]; - for &skew in [false, true].iter() { - let (mut ra, mut rd, mut ba, mut bd, mut attr, mut nconv) = - (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64, 0usize); - for rep in 0..reps { - let mut rng = Lcg(0xA24BAED4963EE407u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0x9E3779B97F4A7C15)); - let (y, profiles, _t) = simulate_ho_dina( - &a_true, &d_true, &s, &g, &q, n, n_items, n_attr, skew, &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_ho_cdm( - &y, - &observed, - &q, - n, - n_items, - n_attr, - CdmModel::Dina, - &CdmConfig::default(), - ) - .unwrap(); - if res.converged { - nconv += 1; - ra += rmse(&res.attr_slope, &a_true); - rd += rmse(&res.attr_intercept, &d_true); - ba += bias(&res.attr_slope, &a_true); - bd += bias(&res.attr_intercept, &d_true); - attr += attribute_agreement(&res.attr_prob, &profiles, n, n_attr); - } - } - let conv_rate = nconv as f64 / reps as f64; - assert!( - conv_rate >= 0.95, - "higher-order MC convergence rate {conv_rate:.3} below 0.95 for skew={skew}" - ); - let den = nconv as f64; - ra /= den; - rd /= den; - ba /= den; - bd /= den; - attr /= den; - println!( - "[HO-DINA MC skew={skew}] reps={reps} converged={nconv} ({conv_rate:.3}) \ - RMSE(a)={ra:.3} RMSE(d)={rd:.3} bias(a)={ba:.3} bias(d)={bd:.3} \ - attr-agree={attr:.3}" - ); - // The trait prior is fixed N(0,1); under a skewed true trait the - // structural slope/intercept degrade (prior mis-specification, as in 2PL - // MMLE), while the attribute classification stays robust. Observed: - // normal RMSE(a)~0.28 / RMSE(d)~0.09; skew RMSE(a)~0.37 / RMSE(d)~0.18; - // attribute agreement ~0.98 in both. Bounds are condition-specific. - let (a_bound, d_bound) = if skew { (0.45, 0.25) } else { (0.32, 0.15) }; - assert!(ra < a_bound, "RMSE(a) {ra} skew={skew}"); - assert!(rd < d_bound, "RMSE(d) {rd} skew={skew}"); - assert!(attr > 0.90, "attribute agreement {attr} skew={skew}"); - } -} - -// ----- Higher-order G-DINA (de la Torre & Douglas, 2004 x de la Torre, 2011) ----- - -/// Simulate higher-order G-DINA data: theta -> attribute mastery via -/// sigmoid(a_k theta + d_k), then draw responses from the SATURATED per-reduced- -/// class truth table (CSR, indexed by reduce_class), returning (y, profiles, thetas). -#[allow(clippy::too_many_arguments)] -fn simulate_ho_gdina( - a: &[f64], - d: &[f64], - qmask: &[usize], - item_off: &[usize], - truth_p: &[f64], - n: usize, - n_items: usize, - n_attr: usize, - skew: bool, - rng: &mut Lcg, -) -> (Vec, Vec, Vec) { - let mut y = vec![0.0f64; n * n_items]; - let mut profiles = vec![0usize; n]; - let mut thetas = vec![0.0f64; n]; - for j in 0..n { - let theta = if skew { - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / (6.0_f64).sqrt() - } else { - rng.normal() - }; - thetas[j] = theta; - let mut c = 0usize; - for k in 0..n_attr { - let pk = 1.0 / (1.0 + (-(a[k] * theta + d[k])).exp()); - if rng.next_f64() < pk { - c |= 1 << k; - } - } - profiles[j] = c; - for i in 0..n_items { - let l = reduce_class(c, qmask[i]); - y[j * n_items + i] = rng.bern(truth_p[item_off[i] + l]); - } - } - (y, profiles, thetas) -} - -/// A canonical K=3 Q: single-attribute items (identification) + pair + triple. -fn hogdina_q3() -> Vec { - let k = 3usize; - let mut q = vec![0u8; 15 * k]; - let rows: [&[usize]; 15] = [ - &[0], - &[1], - &[2], - &[0], - &[1], - &[2], - &[0], - &[1], - &[2], // 9 singles - &[0, 1], - &[1, 2], - &[0, 2], - &[0, 1], - &[1, 2], // 5 pairs - &[0, 1, 2], // 1 triple - ]; - for (i, r) in rows.iter().enumerate() { - for &at in *r { - q[i * k + at] = 1; - } - } - q -} - -/// NON-TRIVIAL anchor: HO structure with SATURATED item probs set to the DINA -/// pattern (g off-top, 1-s at top). The free saturated fit recovers those probs -/// (so the item-level identity-link delta shows the DINA pattern) and the -/// higher-order (a, d). -#[test] -fn ho_gdina_recovers_dina_pattern() { - let (n_attr, n_items, n) = (3usize, 15usize, 3000usize); - let q = hogdina_q3(); - let (item_off, qmask, _kreq) = gdina_layout(&q, n_items, n_attr); - let (s, g) = (0.15f64, 0.2f64); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let (a0, b0) = (item_off[i], item_off[i + 1]); - for l in a0..b0 { - truth[l] = g; - } - truth[b0 - 1] = 1.0 - s; // DINA: only the all-mastered reduced class is high - } - let a_true = vec![1.2f64, 1.5, 0.9]; - let d_true = vec![0.3f64, -0.5, 0.6]; - let mut rng = Lcg(20242011); - let (y, profiles, thetas) = simulate_ho_gdina( - &a_true, &d_true, &qmask, &item_off, &truth, n, n_items, n_attr, false, &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_ho_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); - assert!(res.n_parameters == item_off[n_items] + 2 * n_attr); - // saturated item probs recover the DINA pattern - assert!( - rmse(&res.item_prob, &truth) < 0.04, - "item p RMSE {}", - rmse(&res.item_prob, &truth) - ); - // identity-link delta: intercept ~ g, top interaction ~ (1-s)-g, interior ~ 0 - for i in 0..n_items { - let (a0, b0) = (item_off[i], item_off[i + 1]); - let dl = &res.item_delta[a0..b0]; - assert!((dl[0] - g).abs() < 0.06, "delta0 item {i}"); - assert!( - (dl[b0 - a0 - 1] - ((1.0 - s) - g)).abs() < 0.06, - "delta_full item {i}" - ); - for l in 1..(b0 - a0 - 1) { - assert!(dl[l].abs() < 0.06, "interior delta item {i} idx {l}"); - } - } - // higher-order recovery (identified at K=3) + trait + classification - assert!( - rmse(&res.attr_slope, &a_true) < 0.45, - "a RMSE {}", - rmse(&res.attr_slope, &a_true) - ); - assert!(res.attr_slope.iter().all(|&x| x > 0.0)); - assert!(attribute_agreement(&res.attr_prob, &profiles, n, n_attr) > 0.9); - let tc = { - let corr = |x: &[f64], y: &[f64]| { - let nn = x.len() as f64; - let (mx, my) = (x.iter().sum::() / nn, y.iter().sum::() / nn); - let (mut sxy, mut sx, mut sy) = (0.0, 0.0, 0.0); - for i in 0..x.len() { - sxy += (x[i] - mx) * (y[i] - my); - sx += (x[i] - mx).powi(2); - sy += (y[i] - my).powi(2); - } - sxy / (sx.sqrt() * sy.sqrt()) - }; - corr(&res.theta, &thetas) - }; - assert!(tc > 0.55, "theta corr {tc}"); -} - -/// Independent-attribute data (all slopes 0) -> the implied class distribution -/// recovers the independent-attribute product (K=3; the identified quantity). -#[test] -fn ho_gdina_independent_recovers_pi() { - let (n_attr, n_items, n) = (3usize, 15usize, 3000usize); - let q = hogdina_q3(); - let (item_off, qmask, _kr) = gdina_layout(&q, n_items, n_attr); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let (a0, b0) = (item_off[i], item_off[i + 1]); - for (li, l) in (a0..b0).enumerate() { - truth[l] = 0.15 + 0.7 * (li.count_ones() as f64) / (b0 - a0).trailing_zeros() as f64; - } - } - let a_true = vec![0.0f64; n_attr]; - let d_true = vec![0.4f64, -0.3, 0.2]; - let mut rng = Lcg(7777); - let (y, _p, _t) = simulate_ho_gdina( - &a_true, &d_true, &qmask, &item_off, &truth, n, n_items, n_attr, false, &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_ho_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - let pi_true = ho_pi_from_params(&a_true, &d_true, n_attr); - assert!( - res.converged, - "termination={} n_iter={} relative_change={} tolerance={} attr_slope={:?}", - res.termination_reason, - res.n_iter, - res.final_relative_loglik_change, - res.stopping_tolerance, - res.attr_slope - ); - assert_eq!(res.termination_reason, "tolerance_met"); - assert!(res.final_relative_loglik_change < res.stopping_tolerance); - assert!(nondecreasing(&res.loglik_trace)); - println!( - "[HO-GDINA independent] n_iter={} delta_loglik={:.3e} relative_delta={:.3e} tol={:.1e}", - res.n_iter, - res.final_loglik_change, - res.final_relative_loglik_change, - res.stopping_tolerance - ); - assert!( - rmse(&res.profile_prob, &pi_true) < 0.03, - "pi RMSE {}", - rmse(&res.profile_prob, &pi_true) - ); -} - -#[test] -fn ho_gdina_handles_missing_and_validates() { - let (n_attr, n_items, n) = (3usize, 15usize, 1000usize); - let q = hogdina_q3(); - let (item_off, qmask, _kr) = gdina_layout(&q, n_items, n_attr); - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let (a0, b0) = (item_off[i], item_off[i + 1]); - for l in a0..b0 { - truth[l] = 0.2; - } - truth[b0 - 1] = 0.85; - } - let mut rng = Lcg(99); - let (mut y, _p, _t) = simulate_ho_gdina( - &[1.0, 1.0, 1.0], - &[0.0, 0.0, 0.0], - &qmask, - &item_off, - &truth, - n, - n_items, - n_attr, - false, - &mut rng, - ); - let mut observed = vec![true; n * n_items]; - for o in observed.iter_mut() { - if rng.next_f64() < 0.15 { - *o = false; - } - } - for (idx, o) in observed.iter().enumerate() { - if !o { - y[idx] = 0.0; - } - } - let res = fit_ho_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - assert!(res.loglik_trace.iter().all(|v| v.is_finite())); - // malformed - let cfg = CdmConfig::default(); - assert!(fit_ho_gdina(&[0.0], &[true], &[1, 1], 1, 2, 1, &cfg).is_err()); // y length mismatch - assert!(fit_ho_gdina(&[0.0, 1.0], &[true, true], &[0, 0, 0, 0], 1, 2, 2, &cfg).is_err()); // all-zero Q row - let err = fit_ho_gdina( - &[0.0, 1.0, 1.0, 0.0], - &[true; 4], - &[1, 0, 0, 1], - 2, - 2, - 2, - &cfg, - ) - .unwrap_err(); - assert!(err.contains("at least 3 attributes"), "{err}"); - - let one_step = fit_ho_gdina( - &y, - &observed, - &q, - n, - n_items, - n_attr, - &CdmConfig { - max_iter: 1, - tol: 1e-12, - ..CdmConfig::default() - }, - ) - .unwrap(); - assert!(!one_step.converged); - assert_eq!(one_step.n_iter, 1); - assert_eq!(one_step.termination_reason, "max_iter_reached"); - assert!(one_step.final_loglik_change.is_finite()); - assert!(one_step.final_relative_loglik_change.is_finite()); -} - -/// Literature-grade Monte-Carlo (>=500 reps): higher-order G-DINA recovery of the -/// saturated item probabilities and the higher-order parameters under a normal and -/// a skewed (mis-specified prior) trait distribution. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_ho_gdina_recovery_500() { - let (n_attr, n_items, n, reps) = (3usize, 15usize, 1500usize, 500usize); - let q = hogdina_q3(); - let (item_off, qmask, kreq) = gdina_layout(&q, n_items, n_attr); - // additive saturated truth: p_il = 0.15 + 0.7 * popcount(l)/K_i - let mut truth = vec![0.0f64; item_off[n_items]]; - for i in 0..n_items { - let (a0, b0) = (item_off[i], item_off[i + 1]); - for (li, l) in (a0..b0).enumerate() { - truth[l] = 0.15 + 0.7 * (li.count_ones() as f64) / kreq[i] as f64; - } - } - let a_true = vec![1.2f64, 1.5, 0.9]; - let d_true = vec![0.3f64, -0.5, 0.6]; - for &skew in [false, true].iter() { - let (mut wp, mut ra, mut attr, mut nconv) = (0.0f64, 0.0f64, 0.0f64, 0usize); - for rep in 0..reps { - let mut rng = Lcg(0x27BB2EE687B0B0FDu64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0x9E3779B97F4A7C15)); - let (y, profiles, _t) = simulate_ho_gdina( - &a_true, &d_true, &qmask, &item_off, &truth, n, n_items, n_attr, skew, &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = - fit_ho_gdina(&y, &observed, &q, n, n_items, n_attr, &CdmConfig::default()).unwrap(); - if res.converged { - nconv += 1; - } - // mass-weighted RMSE(p) so near-empty classes don't dominate - let mut mass = vec![0.0f64; item_off[n_items]]; - for &c in &profiles { - for i in 0..n_items { - mass[item_off[i] + reduce_class(c, qmask[i])] += 1.0; - } - } - let (mut num, mut den) = (0.0f64, 0.0f64); - for x in 0..item_off[n_items] { - let e = res.item_prob[x] - truth[x]; - num += mass[x] * e * e; - den += mass[x]; - } - wp += (num / den).sqrt() / reps as f64; - ra += rmse(&res.attr_slope, &a_true) / reps as f64; - attr += attribute_agreement(&res.attr_prob, &profiles, n, n_attr) / reps as f64; - } - println!( - "[HO-GDINA MC skew={skew}] reps={reps} conv={:.2} wRMSE(p)={:.4} RMSE(a)={:.3} attr-agree={:.3}", - nconv as f64 / reps as f64, - wp, - ra, - attr - ); - assert_eq!( - nconv, - reps, - "nonconverged replications: {} of {reps} (skew={skew})", - reps - nconv - ); - assert!(wp < 0.04, "wRMSE(p) {wp} skew={skew}"); - assert!(attr > 0.90, "attribute agreement {attr} skew={skew}"); - } -} - -// ----- Sequential G-DINA polytomous CDM (Ma & de la Torre, 2016) ----- - -/// Deterministic anchor A (category-probability identity): step probs [a, b] give -/// P(0)=1-a, P(1)=a(1-b), P(2)=a*b, summing to 1 — catches a product-direction or -/// trailing-factor (sentinel) off-by-one with no Monte-Carlo noise. -#[test] -fn seq_category_probs_matches_identity() { - let (a, b) = (0.7, 0.3); // a != b, both != 0.5 (non-centered) - let p = seq_category_probs(&[a, b]); - assert!((p[0] - (1.0 - a)).abs() < 1e-12, "P(0)"); - assert!((p[1] - a * (1.0 - b)).abs() < 1e-12, "P(1)"); - assert!( - (p[2] - a * b).abs() < 1e-12, - "P(2) top has no trailing factor" - ); - assert!((p.iter().sum::() - 1.0).abs() < 1e-12, "sum to 1"); - // M=1 collapses to Bernoulli. - let p1 = seq_category_probs(&[0.8]); - assert!((p1[0] - 0.2).abs() < 1e-12 && (p1[1] - 0.8).abs() < 1e-12); - // M=3 telescopes to 1 for an asymmetric table. - let p3 = seq_category_probs(&[0.6, 0.4, 0.3]); - assert!((p3.iter().sum::() - 1.0).abs() < 1e-12); - assert!((p3[3] - 0.6 * 0.4 * 0.3).abs() < 1e-12); - // The PRODUCTION log transform (used by the estimator's E-step refresh) exp-matches - // the literal-anchored reference for interior steps — so the two implementations - // cannot harbour a shared, mutually-hidden bug. - for steps in [vec![0.7, 0.3], vec![0.6, 0.4, 0.3], vec![0.9]] { - let mut lp = vec![0.0f64; steps.len() + 1]; - seq_category_logprobs_into(&steps, 1e-9, &mut lp); - let pr = seq_category_probs(&steps); - for (a, b) in lp.iter().zip(&pr) { - assert!((a.exp() - b).abs() < 1e-12, "log transform {a} vs {b}"); - } - } -} - -/// Deterministic anchor B (at-risk / advanced counts): responses {0,1,1,2} in one -/// reduced class give I=[4,3], R=[3,1], so s_1=3/4, s_2=1/3 — nails the {>=k}/{>=k-1} -/// denominator subsetting that a fit/RMSE test cannot reliably expose. -#[test] -fn seq_scatter_counts_at_risk_denominator() { - let mut ii = vec![0.0f64; 2]; - let mut rr = vec![0.0f64; 2]; - for &x in &[0usize, 1, 1, 2] { - seq_scatter_counts(x, 1.0, 2, &mut ii, &mut rr); - } - assert_eq!(ii, vec![4.0, 3.0]); // at risk: step1 (x>=0)=4, step2 (x>=1)=3 - assert_eq!(rr, vec![3.0, 1.0]); // advanced: step1 (x>=1)=3, step2 (x>=2)=1 - assert!((rr[0] / ii[0] - 0.75).abs() < 1e-12); // s_1 = 3/4 - assert!((rr[1] / ii[1] - 1.0 / 3.0).abs() < 1e-12); // s_2 = 1/3 -} - -/// Binary data (M_i = 1 for every item) reduces the sequential G-DINA to fit_gdina -/// BIT-FOR-BIT: identical monotone init, identical E-step logprobs (ln s / ln(1-s)), -/// identical closed-form ratio, so the whole loglik trace and the step/success probs -/// agree to machine precision. -#[test] -fn seq_gdina_reduces_to_gdina_at_m1() { - let (q, n_items) = wald_q2(3, 3); - let n = 800usize; - let (item_off, qmask, truth) = wald_truth(&q, n_items, "acdm"); - let mut rng = Lcg(424242); - let profiles: Vec = (0..n).map(|_| rng.profile(4)).collect(); - let y = simulate_gdina(&qmask, &item_off, &truth, &profiles, n_items, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = CdmConfig::default(); - let g = fit_gdina(&y, &observed, &q, n, n_items, 2, &cfg).unwrap(); - let sq = fit_seq_gdina(&y, &observed, &q, n, n_items, 2, &cfg).unwrap(); - assert_eq!( - sq.max_cat, - vec![1u32; n_items], - "all items binary -> M_i = 1" - ); - assert_eq!(sq.step_prob.len(), g.item_prob.len()); - assert_eq!( - sq.loglik_trace.len(), - g.loglik_trace.len(), - "same iteration count" - ); - assert_eq!(sq.n_iter, g.n_iter); - assert_eq!(sq.converged, g.converged); - for (a, b) in sq.loglik_trace.iter().zip(&g.loglik_trace) { - assert!((a - b).abs() < 1e-12, "loglik trace {a} vs {b}"); - } - for (a, b) in sq.step_prob.iter().zip(&g.item_prob) { - assert!((a - b).abs() < 1e-12, "step prob {a} vs {b}"); - } - // P(X=1|l) == fit_gdina p_il, P(X=0|l) == 1 - p_il. - for i in 0..n_items { - let rw = 1usize << sq.k_required[i]; - for l in 0..rw { - let p1 = sq.cat_prob[sq.cat_off[i] + l * 2 + 1]; - let p0 = sq.cat_prob[sq.cat_off[i] + l * 2]; - let pg = g.item_prob[g.item_off[i] + l]; - assert!((p1 - pg).abs() < 1e-12 && (p0 - (1.0 - pg)).abs() < 1e-12); - } - } -} - -/// Draw ordered polytomous responses from per-item, per-class step tables, using the -/// SAME class-major reduce_class layout the estimator recovers (spec-fix: matched -/// classes). Sequential draw: advance while Bernoulli(s_k) succeeds, stop at first fail. -fn simulate_seq_gdina( - qmask: &[usize], - s_off: &[usize], - max_cat: &[u32], - truth_steps: &[f64], - profiles: &[usize], - n_items: usize, - rng: &mut Lcg, -) -> Vec { - let n = profiles.len(); - let mut y = vec![0.0f64; n * n_items]; - for j in 0..n { - for i in 0..n_items { - let m = max_cat[i] as usize; - let l = reduce_class(profiles[j], qmask[i]); - let base = s_off[i] + l * m; - let mut cat = 0usize; - for k in 1..=m { - if rng.next_f64() < truth_steps[base + (k - 1)] { - cat = k; - } else { - break; - } - } - y[j * n_items + i] = cat as f64; - } - } - y -} - -/// K=2 design: `n_single` single-attribute M=1 items per attribute (identification) + -/// `n_pair` two-attribute M=2 polytomous items with an ASYMMETRIC, mastery-increasing -/// step table. Returns (q, qmask, s_off, max_cat, truth_steps). -#[allow(clippy::type_complexity)] -fn seq_design( - n_single: usize, - n_pair: usize, -) -> (Vec, Vec, Vec, Vec, Vec) { - let k = 2usize; - let mut q: Vec = Vec::new(); - for _ in 0..n_single { - q.extend_from_slice(&[1, 0]); - } - for _ in 0..n_single { - q.extend_from_slice(&[0, 1]); - } - for _ in 0..n_pair { - q.extend_from_slice(&[1, 1]); - } - let n_items = 2 * n_single + n_pair; - let mut qmask = vec![0usize; n_items]; - let mut kreq = vec![0u32; n_items]; - for i in 0..n_items { - qmask[i] = qmask_of(&q, i, k); - kreq[i] = qmask[i].count_ones(); - } - let mut max_cat = vec![1u32; n_items]; - for m in max_cat.iter_mut().skip(2 * n_single) { - *m = 2; - } - let mut s_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - s_off[i + 1] = s_off[i] + (max_cat[i] as usize) * (1usize << kreq[i]); - } - let mut truth = vec![0.0f64; s_off[n_items]]; - for i in 0..(2 * n_single) { - // M=1, K=1: [non-master, master] - truth[s_off[i]] = 0.20; - truth[s_off[i] + 1] = 0.85; - } - // M=2, K=2, class-major [l*2 + (k-1)]; asymmetric (s1 != s2), mastery-increasing. - let pair = [[0.25, 0.15], [0.55, 0.30], [0.50, 0.25], [0.85, 0.70]]; - for i in (2 * n_single)..n_items { - let base = s_off[i]; - for (l, row) in pair.iter().enumerate() { - truth[base + l * 2] = row[0]; - truth[base + l * 2 + 1] = row[1]; - } - } - (q, qmask, s_off, max_cat, truth) -} - -/// Non-trivial ordered recovery: fit the shared-Q sequential G-DINA on M=2 polytomous -/// data with distinct, asymmetric per-class step tables and recover the step and -/// category probabilities plus attribute classification. -#[test] -fn seq_gdina_recovers_polytomous_steps() { - let k = 2usize; - let (n_single, n_pair) = (5usize, 5usize); - let (q, qmask, s_off, max_cat, truth) = seq_design(n_single, n_pair); - let n_items = 2 * n_single + n_pair; - let n = 5000usize; - let mut rng = Lcg(20160716); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << k)).collect(); - let y = simulate_seq_gdina( - &qmask, &s_off, &max_cat, &truth, &profiles, n_items, &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_seq_gdina(&y, &observed, &q, n, n_items, k, &CdmConfig::default()).unwrap(); - assert_eq!(res.max_cat, max_cat, "derived max categories"); - assert_eq!(res.s_off, s_off, "step layout"); - let rm = rmse(&res.step_prob, &truth); - assert!(rm < 0.05, "step-prob RMSE {rm}"); - // Category-prob recovery for the pair items (the stable, PRIMARY quantity). - let pair = [[0.25, 0.15], [0.55, 0.30], [0.50, 0.25], [0.85, 0.70]]; - for i in (2 * n_single)..n_items { - let m1 = max_cat[i] as usize + 1; - for (l, row) in pair.iter().enumerate() { - let tc = seq_category_probs(row); - for (x, &tcx) in tc.iter().enumerate().take(m1) { - let est = res.cat_prob[res.cat_off[i] + l * m1 + x]; - assert!( - (est - tcx).abs() < 0.04, - "cat i{i} l{l} x{x}: {est} vs {tcx}" - ); - } - } - } - // Attribute classification agreement. - let mut correct = 0usize; - for j in 0..n { - for kk in 0..k { - let est = (res.attr_prob[j * k + kk] >= 0.5) as usize; - if est == ((profiles[j] >> kk) & 1) { - correct += 1; - } - } - } - let acc = correct as f64 / (n * k) as f64; - assert!(acc > 0.85, "attribute accuracy {acc}"); -} - -/// Missing (MAR) is dropped; malformed input is rejected — including the sequential -/// pitfall of an item stuck at category 0 (measures nothing), while a zero-frequency -/// INTERIOR category is accepted (legitimate under a continuation-ratio model). -#[test] -fn seq_gdina_handles_missing_and_validates() { - let k = 2usize; - let (n_single, n_pair) = (3usize, 2usize); - let (q, qmask, s_off, max_cat, truth) = seq_design(n_single, n_pair); - let n_items = 2 * n_single + n_pair; - let n = 400usize; - let mut rng = Lcg(77); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << k)).collect(); - let y = simulate_seq_gdina( - &qmask, &s_off, &max_cat, &truth, &profiles, n_items, &mut rng, - ); - let cfg = CdmConfig::default(); - // Valid fit with a few missing cells. - let mut observed = vec![true; n * n_items]; - observed[0] = false; - observed[n_items + 1] = false; - let res = fit_seq_gdina(&y, &observed, &q, n, n_items, k, &cfg).unwrap(); - assert!(!res.loglik_trace.is_empty()); - assert!( - res.converged, - "termination={} n_iter={} delta={} tolerance={}", - res.termination_reason, res.n_iter, res.final_loglik_change, res.stopping_tolerance - ); - assert_eq!(res.termination_reason, "tolerance_met"); - assert!(res.final_loglik_change.abs() < res.stopping_tolerance); - assert!(res.final_relative_loglik_change.is_finite()); - let all_obs = vec![true; n * n_items]; - // Non-integer category. - let mut ybad = y.clone(); - ybad[10] = 1.5; - assert!(fit_seq_gdina(&ybad, &all_obs, &q, n, n_items, k, &cfg).is_err()); - // Negative category. - let mut yneg = y.clone(); - yneg[10] = -1.0; - assert!(fit_seq_gdina(&yneg, &all_obs, &q, n, n_items, k, &cfg).is_err()); - // A pair item stuck at category 0 (never leaves 0) -> rejected. - let mut yzero = y.clone(); - for j in 0..n { - yzero[j * n_items + 2 * n_single] = 0.0; - } - assert!(fit_seq_gdina(&yzero, &all_obs, &q, n, n_items, k, &cfg).is_err()); - // Shape mismatch. - assert!(fit_seq_gdina(&y[..y.len() - 1], &all_obs, &q, n, n_items, k, &cfg).is_err()); - // A zero-frequency INTERIOR category must NOT be rejected: force item (2*n_single) - // to skip category 1 (only 0 and 2 observed) — still a valid sequential item. - let mut yskip = y.clone(); - let it = 2 * n_single; - for j in 0..n { - let v = yskip[j * n_items + it]; - yskip[j * n_items + it] = if v >= 1.0 { 2.0 } else { 0.0 }; - } - // max observed category is 2 (some persons reach 2), interior cat 1 has 0 freq. - assert!(fit_seq_gdina(&yskip, &all_obs, &q, n, n_items, k, &cfg).is_ok()); - - // Iteration-limited fits expose exact nonconvergence evidence instead of requiring - // callers to infer the reason and stopping metric from the likelihood trace. - let one_cfg = CdmConfig { - max_iter: 1, - tol: 1e-12, - ..CdmConfig::default() - }; - let one = fit_seq_gdina(&y, &all_obs, &q, n, n_items, k, &one_cfg).unwrap(); - assert!(!one.converged); - assert_eq!(one.n_iter, 1); - assert_eq!(one.termination_reason, "max_iter_reached"); - assert!(one.final_loglik_change.is_finite()); - assert!(one.final_relative_loglik_change.is_finite()); - assert_eq!(one.stopping_tolerance, one_cfg.tol); -} - -/// Literature-grade Monte-Carlo (>=500 reps): recover the sequential G-DINA step and -/// category probabilities under BOTH a normal and a right-skew higher-order attribute -/// distribution (fitting a free pi_c). Primary hard assertion is the category-prob -/// RMSE (the stable, model-predicted quantity); the step RMSE is weighted by realized -/// AT-RISK mass (top steps are inherently noisier) and reported as secondary. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_seq_gdina_recovery_500() { - let reps = 500usize; - let k = 3usize; - let n = 2500usize; - // 3 single M=1 items per attribute (identification) + M=2 and M=3 polytomous items. - let mut q: Vec = Vec::new(); - for a in 0..k { - for _ in 0..3 { - let mut r = vec![0u8; k]; - r[a] = 1; - q.extend_from_slice(&r); - } - } - // polytomous items on attribute pairs/triples. - let poly_q: [&[usize]; 4] = [&[0, 1], &[0, 2], &[1, 2], &[0, 1, 2]]; - let poly_m: [u32; 4] = [2, 2, 3, 3]; // include M=3 (>=2 interior steps) - for pq in poly_q.iter() { - let mut r = vec![0u8; k]; - for &a in pq.iter() { - r[a] = 1; - } - q.extend_from_slice(&r); - } - let n_items = 3 * k + poly_q.len(); - let mut qmask = vec![0usize; n_items]; - let mut kreq = vec![0u32; n_items]; - for i in 0..n_items { - qmask[i] = qmask_of(&q, i, k); - kreq[i] = qmask[i].count_ones(); - } - let mut max_cat = vec![1u32; n_items]; - for (j, &m) in poly_m.iter().enumerate() { - max_cat[3 * k + j] = m; - } - let mut s_off = vec![0usize; n_items + 1]; - let mut cat_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - s_off[i + 1] = s_off[i] + (max_cat[i] as usize) * (1usize << kreq[i]); - cat_off[i + 1] = cat_off[i] + (max_cat[i] as usize + 1) * (1usize << kreq[i]); - } - // Truth step tables: mastery-increasing (more mastered required attrs -> higher - // continuation at every step), step decreasing in k (higher categories harder). - let mut truth = vec![0.0f64; s_off[n_items]]; - for i in 0..n_items { - let m = max_cat[i] as usize; - let rw = 1usize << kreq[i]; - let ki = kreq[i] as f64; - for l in 0..rw { - let frac = l.count_ones() as f64 / ki; // fraction of required attrs mastered - for kk in 0..m { - // step 1 base ~0.30..0.90; each higher step -0.12; +mastery. - let base = 0.30 + 0.55 * frac - 0.12 * kk as f64; - truth[s_off[i] + l * m + kk] = base.clamp(0.08, 0.92); - } - } - } - // Strong single-attribute M=1 identification items (guess 0.12, mastery 0.90) so - // the profile posterior is sharp; the polytomous items carry the recovery target. - for i in 0..(3 * k) { - truth[s_off[i]] = 0.12; - truth[s_off[i] + 1] = 0.90; - } - // Higher-order attribute parameters (2PL): theta -> mastery. - let a_ho = vec![1.2f64; k]; - let d_ho: Vec = (0..k).map(|kk| 0.4 - 0.4 * kk as f64).collect(); - - for &skew in [false, true].iter() { - let (mut wnum, mut wden) = (0.0f64, 0.0f64); - let (mut cat_se, mut cat_cells) = (0.0f64, 0.0f64); - let (mut attr_ok, mut attr_tot) = (0.0f64, 0.0f64); - let mut nconv = 0usize; - let mut min_atrisk = f64::INFINITY; - for rep in 0..reps { - let mut rng = Lcg(0x9E3779B97F4A7C15u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0xD1B54A32D192ED03)); - let profiles: Vec = (0..n) - .map(|_| { - let theta = if skew { - // standardized shifted chi-square(3): mean 0, var 1, right-skew. - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / 6.0_f64.sqrt() - } else { - rng.normal() - }; - let mut c = 0usize; - for kk in 0..k { - let p = 1.0 / (1.0 + (-(a_ho[kk] * theta + d_ho[kk])).exp()); - if rng.next_f64() < p { - c |= 1 << kk; - } - } - c - }) - .collect(); - let y = simulate_seq_gdina( - &qmask, &s_off, &max_cat, &truth, &profiles, n_items, &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = - fit_seq_gdina(&y, &observed, &q, n, n_items, k, &CdmConfig::default()).unwrap(); - assert!( - res.converged, - "rep {rep} skew={skew}: termination={} n_iter={} delta={} relative_delta={} tolerance={}", - res.termination_reason, - res.n_iter, - res.final_loglik_change, - res.final_relative_loglik_change, - res.stopping_tolerance - ); - assert_eq!(res.termination_reason, "tolerance_met"); - assert!(res.final_loglik_change.abs() < res.stopping_tolerance); - nconv += 1; - assert_eq!( - res.max_cat, max_cat, - "derived M_i matches design (rep {rep})" - ); - // Invariants: every step/category prob finite in (0,1); category probs sum to 1. - for &sp in &res.step_prob { - assert!(sp.is_finite() && sp > 0.0 && sp < 1.0, "step prob {sp}"); - } - for i in 0..n_items { - let m1 = max_cat[i] as usize + 1; - let rw = 1usize << kreq[i]; - for l in 0..rw { - let mut s = 0.0; - for x in 0..m1 { - let p = res.cat_prob[res.cat_off[i] + l * m1 + x]; - assert!(p.is_finite() && p >= 0.0, "cat prob {p}"); - s += p; - } - assert!((s - 1.0).abs() < 1e-9, "category simplex {s}"); - } - } - // Realized at-risk mass I_ik(l) from true profiles, for step weighting. - let mut atrisk = vec![0.0f64; s_off[n_items]]; - let mut advanced = vec![0.0f64; s_off[n_items]]; - for j in 0..n { - for i in 0..n_items { - let m = max_cat[i] as usize; - let l = reduce_class(profiles[j], qmask[i]); - let base = s_off[i] + l * m; - let x = y[j * n_items + i] as usize; - seq_scatter_counts( - x, - 1.0, - m, - &mut atrisk[base..base + m], - &mut advanced[base..base + m], - ); - } - } - for cell in 0..s_off[n_items] { - let w = atrisk[cell]; - if w > 0.0 { - min_atrisk = min_atrisk.min(w); - let e = res.step_prob[cell] - truth[cell]; - wnum += w * e * e; - wden += w; - } - } - // Category-prob RMSE vs the model-implied truth (primary, stable). - for i in 0..n_items { - let m = max_cat[i] as usize; - let m1 = m + 1; - let rw = 1usize << kreq[i]; - for l in 0..rw { - let tsteps = &truth[s_off[i] + l * m..s_off[i] + l * m + m]; - let tc = seq_category_probs(tsteps); - for x in 0..m1 { - let e = res.cat_prob[res.cat_off[i] + l * m1 + x] - tc[x]; - cat_se += e * e; - cat_cells += 1.0; - } - } - } - for j in 0..n { - for kk in 0..k { - let est = (res.attr_prob[j * k + kk] >= 0.5) as usize; - if est == ((profiles[j] >> kk) & 1) { - attr_ok += 1.0; - } - attr_tot += 1.0; - } - } - } - let wrmse_step = (wnum / wden).sqrt(); - let rmse_cat = (cat_se / cat_cells).sqrt(); - let attr = attr_ok / attr_tot; - let conv = nconv as f64 / reps as f64; - println!( - "[seq-gdina MC skew={skew}] reps={reps} conv={conv:.3} \ - wRMSE(step|at-risk)={wrmse_step:.4} RMSE(cat)={rmse_cat:.4} \ - attr={attr:.3} min_at_risk_mass={min_atrisk:.1}" - ); - // Category probs are the stable primary target; step probs (esp. top steps - // starved under skew, min at-risk mass reported above) are looser and - // at-risk-weighted. Thresholds calibrated to this K=3, M in {2,3} design. - assert!(rmse_cat < 0.03, "category-prob RMSE {rmse_cat} skew={skew}"); - assert!( - wrmse_step < 0.05, - "at-risk-weighted step RMSE {wrmse_step} skew={skew}" - ); - assert!(attr > 0.92, "attribute agreement {attr} skew={skew}"); - assert_eq!(nconv, reps, "every calibration must converge skew={skew}"); - } -} - -// ----- Per-step-Q sequential G-DINA (Ma & de la Torre, 2016, restricted-Q) ----- - -/// Simulate per-step-Q sequential responses: step v of item i succeeds with probability -/// `step_truth[step_off[i]+v-1][reduce_class(profile, step_qmask[g])]`. -fn simulate_seq_gdina_qr( - step_off: &[usize], - step_qmask: &[usize], - spo_kq: &[u32], // |q_ik| per step row (for the truth table width) - step_truth: &[f64], // step-row-major, spo-indexed - spo: &[usize], - n_steps: &[usize], - profiles: &[usize], - n_items: usize, - rng: &mut Lcg, -) -> Vec { - let _ = spo_kq; - let n = profiles.len(); - let mut y = vec![0.0f64; n * n_items]; - for j in 0..n { - for i in 0..n_items { - let m = n_steps[i]; - let mut cat = 0usize; - for v in 1..=m { - let g = step_off[i] + (v - 1); - let l = reduce_class(profiles[j], step_qmask[g]); - if rng.next_f64() < step_truth[spo[g] + l] { - cat = v; - } else { - break; - } - } - y[j * n_items + i] = cat as f64; - } - } - y -} - -/// Shared-Q reduction: with every step of an item sharing the item's Q, fit_seq_gdina_qr -/// matches the shipped shared-Q fit_seq_gdina. loglik and cat_prob zip bit-exactly; step_prob -/// is compared CELL-BY-CELL through the transposed layout map (class-major vs step-row-major). -#[test] -fn seq_gdina_qr_reduces_to_shared_q() { - let (q, qmask, s_off_t, max_cat_t, truth) = seq_design(4, 4); - let n_items = 2 * 4 + 4; - let n = 3000usize; - let mut rng = Lcg(20240101); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << 2)).collect(); - let y = simulate_seq_gdina( - &qmask, &s_off_t, &max_cat_t, &truth, &profiles, n_items, &mut rng, - ); - let observed = vec![true; n * n_items]; - let cfg = CdmConfig::default(); - let shared = fit_seq_gdina(&y, &observed, &q, n, n_items, 2, &cfg).unwrap(); - let n_steps: Vec = shared.max_cat.iter().map(|&m| m as usize).collect(); - let mut step_q: Vec = Vec::new(); - for i in 0..n_items { - for _ in 0..n_steps[i] { - step_q.extend_from_slice(&q[i * 2..i * 2 + 2]); - } - } - let qr = fit_seq_gdina_qr(&y, &observed, &step_q, &n_steps, n, n_items, 2, &cfg).unwrap(); - assert_eq!(qr.loglik_trace.len(), shared.loglik_trace.len()); - for (a, b) in qr.loglik_trace.iter().zip(&shared.loglik_trace) { - assert!((a - b).abs() < 1e-12, "loglik {a} vs {b}"); - } - for (a, b) in qr.cat_prob.iter().zip(&shared.cat_prob) { - assert!((a - b).abs() < 1e-12, "cat_prob {a} vs {b}"); - } - assert_eq!(qr.n_parameters, shared.n_parameters); - // step_prob: shared s_off[i]+l*M+(k-1) (class-major) vs qr spo[step_off[i]+(k-1)]+l. - for i in 0..n_items { - let m = shared.max_cat[i] as usize; - let rw = 1usize << shared.k_required[i]; - for l in 0..rw { - for k in 1..=m { - let sh = shared.step_prob[shared.s_off[i] + l * m + (k - 1)]; - let g = qr.step_off[i] + (k - 1); - let qv = qr.step_prob[qr.spo[g] + l]; - assert!((sh - qv).abs() < 1e-12, "step i{i} l{l} k{k}: {sh} vs {qv}"); - } - } - } -} - -/// Non-trivial STEP-DISTINCT recovery with a NON-CONTIGUOUS union: an item whose step 1 -/// requires attribute 0 only and step 2 requires attributes {0, 2} (union {0,2} is -/// non-contiguous — a naive union-mask-AND derivation would misread the step class). Asserts -/// (a) per-step block WIDTHS (2 and 4 — the only thing that catches over-collapse), (b) a -/// large B-contrast in step 2 is recovered (gap >= 0.4), and (c) step 1 is flat in attr 2. -#[test] -fn seq_gdina_qr_recovers_step_distinct() { - let k = 3usize; // attrs 0,1,2 - // items: 3 single-attr M=1 identification items per attribute (pins each dim) + 1 - // step-distinct M=2 item (step1 q={0}, step2 q={0,2}). - let mut step_q: Vec = Vec::new(); - let mut n_steps: Vec = Vec::new(); - for a in 0..k { - for _ in 0..3 { - let mut r = vec![0u8; k]; - r[a] = 1; - step_q.extend_from_slice(&r); // one step row - n_steps.push(1); - } - } - // the step-distinct item: step1 {0}, step2 {0,2} - step_q.extend_from_slice(&[1, 0, 0]); // step 1 q = {0} - step_q.extend_from_slice(&[1, 0, 1]); // step 2 q = {0,2} - n_steps.push(2); - let n_items = 3 * k + 1; - let sd = n_items - 1; // the step-distinct item index - - // truth: singles guess 0.15 / master 0.90; step-distinct item step1 (q={0}: classes - // [a0=0,a0=1]) = [0.30, 0.80]; step2 (q={0,2}: classes [00,10,01,11] over (a0,a2)) with a - // LARGE a2-contrast: s2(a0=1,a2=0)=0.20 vs s2(a0=1,a2=1)=0.80. - // Build step_off/spo/step_qmask to drive the simulator. - let mut step_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - step_off[i + 1] = step_off[i] + n_steps[i]; - } - let n_rows = step_off[n_items]; - let mut step_qmask = vec![0usize; n_rows]; - let mut spo = vec![0usize; n_rows + 1]; - for g in 0..n_rows { - let mut m = 0usize; - for a in 0..k { - if step_q[g * k + a] != 0 { - m |= 1 << a; - } - } - step_qmask[g] = m; - spo[g + 1] = spo[g] + (1usize << m.count_ones()); - } - let mut truth = vec![0.0f64; spo[n_rows]]; - for i in 0..(3 * k) { - // single M=1 identification items (K=1: classes [non,master]) - truth[spo[step_off[i]]] = 0.15; - truth[spo[step_off[i]] + 1] = 0.90; - } - // step-distinct item - let g1 = step_off[sd]; // step 1, q={0}: classes [a0=0, a0=1] - truth[spo[g1]] = 0.30; - truth[spo[g1] + 1] = 0.80; - let g2 = step_off[sd] + 1; // step 2, q={0,2}: reduce_class over {0,2} = a0 + 2*a2 - truth[spo[g2]] = 0.15; // (a0=0,a2=0) - truth[spo[g2] + 1] = 0.20; // (a0=1,a2=0) - truth[spo[g2] + 2] = 0.20; // (a0=0,a2=1) - truth[spo[g2] + 3] = 0.80; // (a0=1,a2=1) <- large a2 contrast at a0=1 - - let n = 6000usize; - let mut rng = Lcg(916); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << k)).collect(); - let y = simulate_seq_gdina_qr( - &step_off, - &step_qmask, - &[], - &truth, - &spo, - &n_steps, - &profiles, - n_items, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = fit_seq_gdina_qr( - &y, - &observed, - &step_q, - &n_steps, - n, - n_items, - k, - &CdmConfig::default(), - ) - .unwrap(); - assert!(res.converged); - // (a) STRUCTURE: the step-distinct item's step blocks have widths 2 and 4. - let g1r = res.step_off[sd]; - let g2r = res.step_off[sd] + 1; - assert_eq!( - res.spo[g1r + 1] - res.spo[g1r], - 2, - "step 1 width = 2^{{|q1|}}" - ); - assert_eq!( - res.spo[g2r + 1] - res.spo[g2r], - 4, - "step 2 width = 2^{{|q2|}}" - ); - assert_eq!(res.step_kq[g1r], 1); - assert_eq!(res.step_kq[g2r], 2); - // n_parameters reflects the per-step widths (2 + 4 for the step-distinct item). - let total_step_params: usize = (0..n_rows).map(|g| res.spo[g + 1] - res.spo[g]).sum(); - assert_eq!(res.n_parameters, total_step_params + ((1 << k) - 1)); - // (b) large a2-contrast in step 2 recovered (gap >= 0.4). - let s2_a1_b0 = res.step_prob[res.spo[g2r] + 1]; // (a0=1,a2=0) - let s2_a1_b1 = res.step_prob[res.spo[g2r] + 3]; // (a0=1,a2=1) - assert!( - s2_a1_b1 - s2_a1_b0 > 0.4, - "step-2 a2 contrast {s2_a1_b0} -> {s2_a1_b1}" - ); - // (c) step 1 is (near) flat in attr 2 (it only depends on a0): both a0=1 draws equal. - // step 1 has only 2 classes (a0), so it is structurally flat in a2 by construction; assert - // the recovered step-1 master prob is near 0.80 and non-master near 0.30. - assert!( - (res.step_prob[res.spo[g1r]] - 0.30).abs() < 0.06, - "step1 non-master" - ); - assert!( - (res.step_prob[res.spo[g1r] + 1] - 0.80).abs() < 0.06, - "step1 master" - ); - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "EM monotone"); - } -} - -#[test] -fn seq_gdina_qr_validates() { - let k = 2usize; - // valid: 2 single items + 1 M=2 step-distinct-ish item (step1 {0}, step2 {0,1}) - let mut step_q: Vec = vec![1, 0, /*item0 step1*/ 0, 1 /*item1 step1*/]; - let mut n_steps = vec![1usize, 1]; - step_q.extend_from_slice(&[1, 0]); // item2 step1 {0} - step_q.extend_from_slice(&[1, 1]); // item2 step2 {0,1} - n_steps.push(2); - let n_items = 3usize; - let n = 300usize; - // build a simple valid y via the simulator - let mut step_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - step_off[i + 1] = step_off[i] + n_steps[i]; - } - let n_rows = step_off[n_items]; - let mut step_qmask = vec![0usize; n_rows]; - let mut spo = vec![0usize; n_rows + 1]; - for g in 0..n_rows { - let mut m = 0usize; - for a in 0..k { - if step_q[g * k + a] != 0 { - m |= 1 << a; - } - } - step_qmask[g] = m; - spo[g + 1] = spo[g] + (1usize << m.count_ones()); - } - let mut truth = vec![0.5f64; spo[n_rows]]; - truth[spo[step_off[0]]] = 0.2; - truth[spo[step_off[0]] + 1] = 0.85; - truth[spo[step_off[1]]] = 0.2; - truth[spo[step_off[1]] + 1] = 0.85; - let mut rng = Lcg(3); - let profiles: Vec = (0..n).map(|_| rng.profile(1 << k)).collect(); - let y = simulate_seq_gdina_qr( - &step_off, - &step_qmask, - &[], - &truth, - &spo, - &n_steps, - &profiles, - n_items, - &mut rng, - ); - let cfg = CdmConfig::default(); - let obs = vec![true; n * n_items]; - // valid fit (if item2 reaches category 2 for someone; make sure the design does) - let ok = fit_seq_gdina_qr(&y, &obs, &step_q, &n_steps, n, n_items, k, &cfg); - assert!(ok.is_ok(), "valid: {:?}", ok.err()); - let mut missing = obs.clone(); - missing[0] = false; - assert!(fit_seq_gdina_qr(&y, &missing, &step_q, &n_steps, n, n_items, k, &cfg).is_ok()); - // n_steps length mismatch - assert!(fit_seq_gdina_qr(&y, &obs, &step_q, &n_steps[..2], n, n_items, k, &cfg).is_err()); - // all-zero step-q row (a step measuring nothing) - let mut zq = step_q.clone(); - zq[0] = 0; // item0 step1 was {0} -> now all-zero - assert!(fit_seq_gdina_qr(&y, &obs, &zq, &n_steps, n, n_items, k, &cfg).is_err()); - // all-zero COLUMN: an attribute required by no step. ISOLATE this guard from the - // all-zero-ROW guard that precedes it by keeping every row non-empty -- two items whose - // only step is {0}, so attr1 appears in no column while no row is all-zero (a naive - // fixture that empties attr1's only single-attr step trips the row guard first and would - // let a deletion of the column guard survive). - let col_q: Vec = vec![1, 0, 1, 0]; - let col_ns = vec![1usize, 1]; - let col_y = vec![0.0f64; n * 2]; - let col_obs = vec![true; n * 2]; - let col_err = fit_seq_gdina_qr(&col_y, &col_obs, &col_q, &col_ns, n, 2, k, &cfg).unwrap_err(); - assert!( - col_err.contains("required by no step"), - "expected column guard, got: {col_err}" - ); - // max observed category != declared n_steps: clamp item2 (declared M=2) so its data never - // reaches category 2. sum(n_steps)=4 still matches the 4 step_q rows, so the length guard - // passes and the max-observed guard is what must reject it (else x = y as usize could - // exceed M_i and index clp past the item's (M_i+1)-wide block). - let mut y_low = y.clone(); - for p in 0..n { - let idx = p * n_items + 2; - if y_low[idx] > 1.0 { - y_low[idx] = 1.0; - } - } - let low_err = - fit_seq_gdina_qr(&y_low, &obs, &step_q, &n_steps, n, n_items, k, &cfg).unwrap_err(); - assert!( - low_err.contains("max observed category"), - "expected max-observed guard, got: {low_err}" - ); - // non-integer response - let mut yb = y.clone(); - yb[5] = 1.5; - assert!(fit_seq_gdina_qr(&yb, &obs, &step_q, &n_steps, n, n_items, k, &cfg).is_err()); -} - -/// Literature-grade Monte-Carlo (>=500 reps): recover the per-step-Q sequential G-DINA under -/// normal and skew higher-order attribute distributions. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_seq_gdina_qr_recovery_500() { - let reps = 500usize; - let k = 3usize; - let n = 2000usize; - // 3 single M=1 items per attribute (identification) + step-distinct polytomous items. - let mut step_q: Vec = Vec::new(); - let mut n_steps: Vec = Vec::new(); - for a in 0..k { - for _ in 0..3 { - let mut r = vec![0u8; k]; - r[a] = 1; - step_q.extend_from_slice(&r); - n_steps.push(1); - } - } - // step-distinct items: (step1 {0}, step2 {0,1}); (step1 {1}, step2 {1,2}); (step1 {2}, - // step2 {0,2}, step3 {0,1,2}). - let poly: [&[&[usize]]; 3] = [ - &[&[0], &[0, 1]], - &[&[1], &[1, 2]], - &[&[2], &[0, 2], &[0, 1, 2]], - ]; - for steps in poly.iter() { - for stp in steps.iter() { - let mut r = vec![0u8; k]; - for &a in stp.iter() { - r[a] = 1; - } - step_q.extend_from_slice(&r); - } - n_steps.push(steps.len()); - } - let n_items = 3 * k + poly.len(); - let mut step_off = vec![0usize; n_items + 1]; - for i in 0..n_items { - step_off[i + 1] = step_off[i] + n_steps[i]; - } - let n_rows = step_off[n_items]; - let mut step_qmask = vec![0usize; n_rows]; - let mut spo = vec![0usize; n_rows + 1]; - for g in 0..n_rows { - let mut m = 0usize; - for a in 0..k { - if step_q[g * k + a] != 0 { - m |= 1 << a; - } - } - step_qmask[g] = m; - spo[g + 1] = spo[g] + (1usize << m.count_ones()); - } - // truth step tables: mastery-increasing per step (more mastered required attrs -> higher). - let mut truth = vec![0.0f64; spo[n_rows]]; - for g in 0..n_rows { - let rw = 1usize << step_qmask[g].count_ones(); - let kq = step_qmask[g].count_ones() as f64; - for l in 0..rw { - let frac = l.count_ones() as f64 / kq; - truth[spo[g] + l] = (0.20 + 0.65 * frac).clamp(0.08, 0.92); - } - } - // strong single identification items - for i in 0..(3 * k) { - truth[spo[step_off[i]]] = 0.12; - truth[spo[step_off[i]] + 1] = 0.90; - } - let a_ho = vec![1.2f64; k]; - let d_ho: Vec = (0..k).map(|kk| 0.4 - 0.4 * kk as f64).collect(); - - for &skew in [false, true].iter() { - let (mut wnum, mut wden) = (0.0f64, 0.0f64); - let (mut cat_se, mut cat_cnt) = (0.0f64, 0.0f64); - let (mut attr_ok, mut attr_tot) = (0.0f64, 0.0f64); - let mut nconv = 0usize; - for rep in 0..reps { - let mut rng = Lcg(0x9E3779B97F4A7C15u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0xD1B54A32D192ED03)); - let profiles: Vec = (0..n) - .map(|_| { - let theta = if skew { - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / 6.0_f64.sqrt() - } else { - rng.normal() - }; - let mut c = 0usize; - for kk in 0..k { - let p = 1.0 / (1.0 + (-(a_ho[kk] * theta + d_ho[kk])).exp()); - if rng.next_f64() < p { - c |= 1 << kk; - } - } - c - }) - .collect(); - let y = simulate_seq_gdina_qr( - &step_off, - &step_qmask, - &[], - &truth, - &spo, - &n_steps, - &profiles, - n_items, - &mut rng, - ); - let observed = vec![true; n * n_items]; - let res = match fit_seq_gdina_qr( - &y, - &observed, - &step_q, - &n_steps, - n, - n_items, - k, - &CdmConfig::default(), - ) { - Ok(r) => r, - Err(_) => continue, // a rep where a poly item did not reach its top category - }; - if res.converged { - nconv += 1; - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "EM monotone (rep {rep})"); - } - for &sp in &res.step_prob { - assert!(sp.is_finite() && sp > 0.0 && sp < 1.0, "step prob {sp}"); - } - // realized at-risk mass per step cell for weighting. - let mut atrisk = vec![0.0f64; spo[n_rows]]; - let mut advanced = vec![0.0f64; spo[n_rows]]; - for j in 0..n { - for i in 0..n_items { - let m = n_steps[i]; - let x = y[j * n_items + i] as usize; - for v in 1..=m { - let g = step_off[i] + (v - 1); - let l = reduce_class(profiles[j], step_qmask[g]); - if x >= v - 1 { - atrisk[spo[g] + l] += 1.0; - if x >= v { - advanced[spo[g] + l] += 1.0; - } - } - } - } - } - for cell in 0..spo[n_rows] { - if atrisk[cell] > 0.0 { - let e = res.step_prob[cell] - truth[cell]; - wnum += atrisk[cell] * e * e; - wden += atrisk[cell]; - } - } - // category-prob RMSE vs model truth for the poly items. - for i in (3 * k)..n_items { - let m = n_steps[i]; - let m1 = m + 1; - // union class truth: gather step probs per union class via full profiles. - // compare recovered cat_prob against seq_category_probs of the truth steps - // at each union class (representative full profile). - let mut u = 0usize; - for g in step_off[i]..step_off[i + 1] { - u |= step_qmask[g]; - } - let rwu = 1usize << u.count_ones(); - for c in 0..(1 << k) { - let uc = reduce_class(c, u); - if uc >= rwu { - continue; - } - let mut steps_t = vec![0.0f64; m]; - for v in 0..m { - let g = step_off[i] + v; - steps_t[v] = truth[spo[g] + reduce_class(c, step_qmask[g])]; - } - let tc = seq_category_probs(&steps_t); - for x in 0..m1 { - let est = res.cat_prob[res.cat_off[i] + uc * m1 + x]; - let e = est - tc[x]; - cat_se += e * e; - cat_cnt += 1.0; - } - } - } - for j in 0..n { - for kk in 0..k { - let est = (res.attr_prob[j * k + kk] >= 0.5) as usize; - if est == ((profiles[j] >> kk) & 1) { - attr_ok += 1.0; - } - attr_tot += 1.0; - } - } - } - let wrmse = (wnum / wden).sqrt(); - let crmse = (cat_se / cat_cnt).sqrt(); - let attr = attr_ok / attr_tot; - let conv = nconv as f64 / reps as f64; - println!( - "[seq-qr MC skew={skew}] reps={reps} conv={conv:.3} wRMSE(step)={wrmse:.4} \ - RMSE(cat)={crmse:.4} attr={attr:.3}" - ); - assert!(conv > 0.9, "convergence {conv} skew={skew}"); - assert!(crmse < 0.03, "category-prob RMSE {crmse} skew={skew}"); - assert!( - wrmse < 0.05, - "at-risk-weighted step RMSE {wrmse} skew={skew}" - ); - assert!(attr > 0.90, "attribute agreement {attr} skew={skew}"); - } -} diff --git a/tests/unit/classification_tests.rs b/tests/unit/classification_tests.rs deleted file mode 100644 index 3d7eb1b6d..000000000 --- a/tests/unit/classification_tests.rs +++ /dev/null @@ -1,518 +0,0 @@ -//! Tests for IRT classification accuracy/consistency (Rudner and Lee). -//! -//! Every assert reads values returned by the crate -//! (`rudner_classification` / `lee_classification` outputs); no assert is -//! computed from test-local re-derivations. Fixture literals come from an -//! independent NumPy transcription of the verified sources (session artifact -//! `cac_fixture.py`) that never imports this crate. -//! -//! Disclosed limitations: -//! - For a single cut, simultaneous == per-cut outputs by construction -//! (deliberate API divergence from cacIRT); the m = 2 fixtures anchor the -//! case where they differ. -//! - Rudner values inherit the crate `erfc` accuracy (|err| < 1.2e-7), so -//! Rudner asserts use 1e-6 tolerances; the targeted mutations shift values -//! by >= 1e-3. Lee asserts are exact-recursion values pinned at 1e-12. -//! - Uniform-weight == unweighted-mean is an identity; the non-uniform, -//! non-normalized weight fixtures anchor weight handling. - -use super::{lee_classification, rudner_classification}; - -const RUD_THETA: [f64; 7] = [-1.7, -0.63, -0.4, 0.11, 0.52, 1.28, 1.95]; -const RUD_SEM: [f64; 7] = [0.85, 0.42, 0.31, 0.27, 0.33, 0.48, 0.66]; -// deliberately unnormalized (sum 8.5): kills a "forgot to normalize" mutation -const RUD_W: [f64; 7] = [0.4, 1.1, 1.7, 2.3, 1.9, 0.8, 0.3]; -const RUD_CUTS: [f64; 2] = [-0.4, 0.85]; - -#[test] -fn rudner_matches_independent_reference() { - // Kills: swapped tail direction, dropped squared term in consistency, - // per-cut/simultaneous mixups, unnormalized-weight aggregation. - let r = rudner_classification(&RUD_THETA, &RUD_SEM, &RUD_W, &RUD_CUTS).unwrap(); - let tol = 1e-6; - assert!((r.per_cut_accuracy[0] - 0.8506551287597999).abs() < tol); - assert!((r.per_cut_accuracy[1] - 0.9444953787199609).abs() < tol); - assert!((r.per_cut_consistency[0] - 0.8242222191464211).abs() < tol); - assert!((r.per_cut_consistency[1] - 0.9068646398531202).abs() < tol); - assert!((r.simultaneous_accuracy - 0.7952699812339523).abs() < tol); - assert!((r.simultaneous_consistency - 0.7315355182946305).abs() < tol); - // conditional rows (m x n row-major) - assert!((r.conditional_accuracy[0] - 0.936918801128759).abs() < tol); - assert!((r.conditional_accuracy[7 + 4] - 0.8413447460685428).abs() < tol); - assert!((r.conditional_consistency[1] - 0.5865473418589322).abs() < tol); - assert!((r.conditional_simultaneous_accuracy[4] - 0.8386920265482238).abs() < tol); - assert!((r.conditional_simultaneous_consistency[6] - 0.9089695170230561).abs() < tol); -} - -#[test] -fn rudner_theta_on_cut_classifies_into_upper_category() { - // theta[2] == -0.4 sits EXACTLY on cut 1. Left-closed categorization - // puts it in the upper category, so its conditional accuracy is the - // upper-tail mass 0.5 (theta centered on the cut). A right-closed - // mutation flips it to the lower mass — also 0.5 here, so the - // discriminating read is the SIMULTANEOUS accuracy, whose upper-category - // mass [−0.4, 0.85) = 0.4999723782624206 differs from the lower-category - // mass under a right-closed mutation (Phi((-0.4+0.4)/0.31) - 0 = 0.5). - let r = rudner_classification(&RUD_THETA, &RUD_SEM, &RUD_W, &RUD_CUTS).unwrap(); - assert!((r.conditional_accuracy[2] - 0.5).abs() < 1e-6); - assert!((r.conditional_simultaneous_accuracy[2] - 0.4999723782624206).abs() < 1e-6); - assert!((r.conditional_simultaneous_consistency[2] - 0.49997237978834136).abs() < 1e-6); -} - -#[test] -fn rudner_rejects_degenerate_inputs() { - let ok_t = [0.0, 1.0]; - let ok_s = [0.5, 0.5]; - let ok_w = [1.0, 1.0]; - assert!(rudner_classification(&[], &[], &[], &[0.0]).is_err()); - assert!(rudner_classification(&ok_t, &[0.5], &ok_w, &[0.0]).is_err()); - assert!(rudner_classification(&ok_t, &[0.5, 0.0], &ok_w, &[0.0]).is_err()); - assert!(rudner_classification(&ok_t, &[0.5, f64::NAN], &ok_w, &[0.0]).is_err()); - assert!(rudner_classification(&[0.0, f64::INFINITY], &ok_s, &ok_w, &[0.0]).is_err()); - assert!(rudner_classification(&ok_t, &ok_s, &[1.0, -1.0], &[0.0]).is_err()); - assert!(rudner_classification(&ok_t, &ok_s, &[0.0, 0.0], &[0.0]).is_err()); - // finite weights whose SUM overflows to inf would otherwise normalize - // every weight to 0.0 and silently zero all marginals - assert!(rudner_classification(&ok_t, &ok_s, &[1e308, 1e308], &[0.0]).is_err()); - assert!(rudner_classification(&ok_t, &ok_s, &ok_w, &[]).is_err()); - assert!(rudner_classification(&ok_t, &ok_s, &ok_w, &[0.3, 0.3]).is_err()); - assert!(rudner_classification(&ok_t, &ok_s, &ok_w, &[0.3, f64::NAN]).is_err()); -} - -const LEE_P: [f64; 30] = [ - 0.08, 0.15, 0.22, 0.31, 0.12, 0.19, // - 0.23, 0.34, 0.41, 0.52, 0.28, 0.37, // - 0.47, 0.55, 0.61, 0.68, 0.51, 0.58, // - 0.66, 0.72, 0.79, 0.83, 0.69, 0.76, // - 0.81, 0.87, 0.90, 0.93, 0.84, 0.88, -]; -const LEE_W: [f64; 5] = [0.6, 1.4, 2.0, 1.4, 0.6]; -const LEE_CUTS: [f64; 2] = [2.4, 4.0]; // non-integer cut kills floor-vs-ceil - -#[test] -fn lee_matches_independent_reference() { - // Kills: floor(c) boundary mutation (cut 2.4), dropped square in - // consistency, per-cut/simultaneous mixups, unnormalized weights, - // Lord-Wingersky misuse (wrong table orientation collapses everything). - let r = lee_classification(&LEE_P, 5, 6, &LEE_W, &LEE_CUTS).unwrap(); - let tol = 1e-12; - assert!((r.per_cut_accuracy[0] - 0.8221250493919999).abs() < tol); - assert!((r.per_cut_accuracy[1] - 0.7672704000213333).abs() < tol); - assert!((r.per_cut_consistency[0] - 0.7432160432137724).abs() < tol); - assert!((r.per_cut_consistency[1] - 0.7071425914148844).abs() < tol); - assert!((r.simultaneous_accuracy - 0.6284128106933332).abs() < tol); - assert!((r.simultaneous_consistency - 0.5754886794891432).abs() < tol); - assert!((r.conditional_accuracy[1] - 0.6320063246400001).abs() < tol); - assert!((r.conditional_consistency[5 + 2] - 0.5012741633381826).abs() < tol); - assert!((r.conditional_simultaneous_accuracy[2] - 0.2992865452).abs() < tol); - assert!((r.conditional_simultaneous_consistency[3] - 0.6924377380497955).abs() < tol); -} - -#[test] -fn lee_true_score_on_cut_classifies_into_upper_category() { - // Dyadic P row sums to EXACTLY 3.0 (binary64); cut at 3.0. Left-closed - // categorization -> upper category -> accuracy = P(X >= 3) = 0.6640625. - // A right-closed mutation reads the lower mass 1 - 0.6640625 = 0.3359375. - let p = [0.5, 0.5, 0.5, 0.5, 0.25, 0.75]; - let r = lee_classification(&p, 1, 6, &[1.0], &[3.0]).unwrap(); - assert!((r.per_cut_accuracy[0] - 0.6640625).abs() < 1e-12); - assert!((r.per_cut_consistency[0] - 0.5538330078125).abs() < 1e-12); -} - -#[test] -fn lee_rejects_degenerate_inputs() { - let p = [0.2, 0.4, 0.6, 0.8]; - assert!(lee_classification(&p, 2, 2, &[1.0, 1.0], &[1.5]).is_ok()); - // probs on/outside the open interval - assert!(lee_classification(&[0.0, 0.4, 0.6, 0.8], 2, 2, &[1.0, 1.0], &[1.5]).is_err()); - assert!(lee_classification(&[1.0, 0.4, 0.6, 0.8], 2, 2, &[1.0, 1.0], &[1.5]).is_err()); - assert!(lee_classification(&[f64::NAN, 0.4, 0.6, 0.8], 2, 2, &[1.0, 1.0], &[1.5]).is_err()); - // shape mismatch - assert!(lee_classification(&p, 2, 3, &[1.0, 1.0], &[1.5]).is_err()); - assert!(lee_classification(&p, 0, 2, &[], &[1.5]).is_err()); - // raw cuts outside (0, n_items] - assert!(lee_classification(&p, 2, 2, &[1.0, 1.0], &[0.0]).is_err()); - assert!(lee_classification(&p, 2, 2, &[1.0, 1.0], &[2.5]).is_err()); - // ceil-collision: 0.2 and 0.9 both ceil to 1 - assert!(lee_classification(&p, 2, 2, &[1.0, 1.0], &[0.2, 0.9]).is_err()); - // weights - assert!(lee_classification(&p, 2, 2, &[0.0, 0.0], &[1.5]).is_err()); - assert!(lee_classification(&p, 2, 2, &[1e308, 1e308], &[1.5]).is_err()); - assert!(lee_classification(&p, 2, 2, &[1.0], &[1.5]).is_err()); -} - -/// 500-replication Monte Carlo: on 2PL-simulated tests, longer/more -/// informative tests must yield strictly higher marginal simultaneous -/// accuracy and consistency than short noisy tests, and both statistics must -/// stay in (0, 1]. Reads only crate outputs. Run with `--ignored`. -#[test] -#[ignore] -fn monte_carlo_accuracy_orders_test_quality() { - // Minimal LCG (Numerical Recipes constants) — no external dependencies. - let mut state: u64 = 0x9E3779B97F4A7C15; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let n_nodes = 21; - let nodes: Vec = (0..n_nodes).map(|i| -3.0 + 6.0 * i as f64 / 20.0).collect(); - // standard normal quadrature-ish weights (unnormalized; crate normalizes) - let wts: Vec = nodes.iter().map(|t| (-0.5 * t * t).exp()).collect(); - let mut wins_acc = 0usize; - let mut wins_con = 0usize; - let reps = 500; - for _ in 0..reps { - let mut probs = |n_items: usize, a_lo: f64, a_hi: f64| -> Vec { - let items: Vec<(f64, f64)> = (0..n_items) - .map(|_| (a_lo + (a_hi - a_lo) * unif(), -1.5 + 3.0 * unif())) - .collect(); - nodes - .iter() - .flat_map(|&t| { - items - .iter() - .map(move |&(a, b)| 1.0 / (1.0 + (-a * (t - b)).exp())) - }) - .collect() - }; - let short = probs(8, 0.4, 0.9); - let long = probs(40, 1.2, 2.2); - let rs = lee_classification(&short, n_nodes, 8, &wts, &[4.0]).unwrap(); - let rl = lee_classification(&long, n_nodes, 40, &wts, &[20.0]).unwrap(); - for r in [&rs, &rl] { - assert!(r.simultaneous_accuracy > 0.0 && r.simultaneous_accuracy <= 1.0); - assert!(r.simultaneous_consistency > 0.0 && r.simultaneous_consistency <= 1.0); - } - if rl.simultaneous_accuracy > rs.simultaneous_accuracy { - wins_acc += 1; - } - if rl.simultaneous_consistency > rs.simultaneous_consistency { - wins_con += 1; - } - } - // The informative long test should dominate in the vast majority of reps. - assert!( - wins_acc as f64 / reps as f64 > 0.95, - "acc wins {wins_acc}/{reps}" - ); - assert!( - wins_con as f64 / reps as f64 > 0.95, - "con wins {wins_con}/{reps}" - ); -} - -// ===================== Livingston & Lewis (1995) ===================== -// -// Every assert below reads fields of the `LivingstonLewisResult` returned by -// `livingston_lewis` (crate outputs). Fixture literals come from an -// independent Python replication of spec ll_spec.md rev 2 (session artifact -// ll_fixture.py: scipy adaptive quadrature vs the crate substituted -// Gauss-Legendre — independent integration methods), tolerance 1e-7 for -// integral-derived fields and 1e-9 for pre-integration arithmetic. -// -// Mutation-kill map (spot-checked by actually applying each mutation): -// - M1 swap upper/lower binomial tail in accuracy integrands -> -// p_tp/p_ff literals FAIL. -// - M2 use unrounded ETL in the passing threshold -> k shifts, literals FAIL -// (fixture A has ETL = 91.123, materially non-integer). -// - M4 threshold off-by-one (k-1 -> k) -> literals FAIL (fixture A has -// round(N c) = 55 != floor = 54, locking round-ties-even). -// - M5 sample variance ddof n-1 -> n -> ETL literal FAILs. -// - Failsafe drop (keep invalid 4P) -> fixture A/B (2P path) FAIL. -// -// Disclosed limitation: p_ij == p_ji is BY CONSTRUCTION under the -// single-threshold contract, so no mutation distinguishable through the -// p_ji field alone exists; the discriminating anchor for threshold handling -// is the fixture-A k = 55 vs floor 54 literal set. - -use super::{livingston_lewis, LivingstonLewisResult}; - -/// LCG mirrored bit-for-bit in ll_fixture.py. -struct Lcg { - s: u64, -} - -impl Lcg { - fn new(seed: u64) -> Self { - Self { - s: seed - .wrapping_mul(2862933555777941757) - .wrapping_add(3037000493), - } - } - fn unif(&mut self) -> f64 { - self.s = self - .s - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (self.s >> 11) as f64 / (1u64 << 53) as f64 - } -} - -fn gen_scores(n: usize, n_items: usize, seed: u64, lo: f64, hi: f64, bates_k: usize) -> Vec { - let mut r = Lcg::new(seed); - (0..n) - .map(|_| { - let mut acc = 0.0; - for _ in 0..bates_k { - acc += r.unif(); - } - let p = lo + (hi - lo) * acc / bates_k as f64; - let mut x = 0u32; - for _ in 0..n_items { - if r.unif() < p { - x += 1; - } - } - x as f64 - }) - .collect() -} - -fn gen_scores_beta( - n: usize, - n_items: usize, - seed: u64, - a: f64, - b: f64, - lo: f64, - hi: f64, -) -> Vec { - let mut r = Lcg::new(seed); - (0..n) - .map(|_| { - let p = loop { - let x = r.unif().powf(1.0 / a); - let y = r.unif().powf(1.0 / b); - if x + y <= 1.0 { - break lo + (hi - lo) * (x / (x + y)); - } - }; - let mut s = 0u32; - for _ in 0..n_items { - if r.unif() < p { - s += 1; - } - } - s as f64 - }) - .collect() -} - -fn fixture_a() -> LivingstonLewisResult { - let scores = gen_scores(250, 60, 42, 0.15, 0.95, 4); - livingston_lewis(&scores, 0.85, 0.0, 60.0, 36.0).unwrap() -} - -#[test] -fn ll_matches_independent_reference_two_parameter() { - // Kills M1/M2/M4/M5 and the failsafe drop (see map above); fixture A has - // round(91 * 0.6) = 55 while floor = 54, anchoring round-ties-even. - let r = fixture_a(); - assert!((r.effective_test_length - 91.12301435951845).abs() < 1e-9); - assert_eq!(r.etl_rounded, 91); - assert!(r.used_two_parameter); - assert_eq!(r.lower, 0.0); - assert_eq!(r.upper, 1.0); - assert!((r.alpha - 8.526094673559584).abs() < 1e-9); - assert!((r.beta - 7.63609667022833).abs() < 1e-9); - let tol = 1e-7; - assert!((r.p_tp - 0.23854946889141398).abs() < tol); - assert!((r.p_fp - 0.06401868217743813).abs() < tol); - assert!((r.p_tf - 0.6521887072379015).abs() < tol); - assert!((r.p_ff - 0.04524314169324793).abs() < tol); - assert!((r.accuracy - 0.8907381761293154).abs() < tol); - assert!((r.sensitivity - 0.8405767451096093).abs() < tol); - assert!((r.specificity - 0.9106143232762532).abs() < tol); - assert!((r.p_ii - 0.6209475077858381).abs() < tol); - assert!((r.p_ij - 0.07648434114531032).abs() < tol); - assert!((r.p_jj - 0.22608380992354124).abs() < tol); - assert!((r.consistency - 0.8470313177093793).abs() < tol); - assert!((r.chance_consistency - 0.5779586699447437).abs() < tol); - assert!((r.kappa - 0.6375504686458243).abs() < tol); -} - -#[test] -fn ll_extreme_cut_distinguishes_rounded_etl_in_k() { - // Same data as fixture A but cut = 59: round(N*c) = round(91*59/60) = 89 - // while round(etl*c) = round(89.604) = 90, so this fixture kills the - // mutation that computes k from the unrounded ETL (which fixtures A/B/P - // cannot distinguish). Reference literals from ll_fixture.py (scipy quad). - let scores = gen_scores(250, 60, 42, 0.15, 0.95, 4); - let r = livingston_lewis(&scores, 0.85, 0.0, 60.0, 59.0).unwrap(); - assert_eq!(r.etl_rounded, 91); - let tol = 1e-8; - assert!((r.p_fp - 2.152128586520244e-06).abs() < tol); - assert!((r.p_tf - 0.9999978476968782).abs() < 1e-7); - assert!((r.kappa - 0.04370046162326584).abs() < 1e-7); -} - -#[test] -fn ll_failsafe_engages_on_skewed_data() { - // Ceiling-skewed data: the 4P moment fit lands out of bounds and the 2P - // failsafe must engage. Kills a dropped-failsafe mutation. - let scores = gen_scores(150, 40, 7, 0.70, 1.0, 2); - let r = livingston_lewis(&scores, 0.80, 0.0, 40.0, 30.0).unwrap(); - assert!(r.used_two_parameter); - assert!((r.effective_test_length - 90.29478586833179).abs() < 1e-9); - assert!((r.alpha - 19.251648456163373).abs() < 1e-9); - assert!((r.beta - 3.522383629203395).abs() < 1e-9); - let tol = 1e-7; - assert!((r.p_tp - 0.8432448010046247).abs() < tol); - assert!((r.p_fp - 0.025111134448713338).abs() < tol); - assert!((r.p_tf - 0.0853397836347724).abs() < tol); - assert!((r.p_ff - 0.046304280911892266).abs() < tol); - assert!((r.accuracy - 0.9285845846393972).abs() < tol); - assert!((r.consistency - 0.89849902437627).abs() < tol); - assert!((r.kappa - 0.5560427413146216).abs() < tol); -} - -#[test] -fn ll_four_parameter_path() { - // Beta(2, 1.5) true scores on [0.2, 0.95]: valid 4P solution with - // non-trivial location parameters. Kills mutations in the 4P branch - // (l/u formulas, g3-sign branch, spread term) that the 2P fixtures - // cannot see. - let scores = gen_scores_beta(300, 50, 1, 2.0, 1.5, 0.2, 0.95); - let r = livingston_lewis(&scores, 0.9, 0.0, 50.0, 30.0).unwrap(); - assert!(!r.used_two_parameter); - assert!((r.effective_test_length - 61.449520085331116).abs() < 1e-9); - assert_eq!(r.etl_rounded, 61); - assert!((r.lower - 0.24143017903895592).abs() < 1e-9); - assert!((r.upper - 0.9393717155534436).abs() < 1e-9); - assert!((r.alpha - 1.7565737858531474).abs() < 1e-9); - assert!((r.beta - 1.3137395685852833).abs() < 1e-9); - let tol = 1e-7; - assert!((r.p_tp - 0.5543443184884189).abs() < tol); - assert!((r.p_fp - 0.044475496859695596).abs() < tol); - assert!((r.p_tf - 0.3583220858730486).abs() < tol); - assert!((r.p_ff - 0.04285809877883742).abs() < tol); - assert!((r.accuracy - 0.9126664043614675).abs() < tol); - assert!((r.sensitivity - 0.928235222196601).abs() < tol); - assert!((r.specificity - 0.8895835060430215).abs() < tol); - assert!((r.p_ii - 0.33976752439044877).abs() < tol); - assert!((r.p_ij - 0.06141266026143706).abs() < tol); - assert!((r.p_jj - 0.537407155086677).abs() < tol); - assert!((r.consistency - 0.8771746794771258).abs() < tol); - assert!((r.chance_consistency - 0.5195307118108707).abs() < tol); - assert!((r.kappa - 0.7443638468843696).abs() < tol); -} - -#[test] -fn ll_structural_invariants_read_crate_fields() { - // All operands are crate outputs; these anchors tie the derived fields - // (accuracy, consistency, kappa) to the cell fields so a mutation that - // desynchronizes them (e.g. computing kappa from raw unnormalized cells) - // fails here even if it preserved the individual pinned literals. - let r = fixture_a(); - assert!((r.p_tp + r.p_fp + r.p_tf + r.p_ff - 1.0).abs() < 1e-9); - assert!((r.p_ii + r.p_ij + r.p_ji + r.p_jj - 1.0).abs() < 1e-12); - assert_eq!(r.p_ij, r.p_ji); // by construction (disclosed above) - assert!((r.accuracy - (r.p_tp + r.p_tf)).abs() < 1e-15); - assert!((r.consistency - (r.p_ii + r.p_jj)).abs() < 1e-15); - let pc = (r.p_ii + r.p_ij) * (r.p_ii + r.p_ji) + (r.p_ij + r.p_jj) * (r.p_ji + r.p_jj); - assert!((r.chance_consistency - pc).abs() < 1e-15); - assert!((r.kappa - (r.consistency - pc) / (1.0 - pc)).abs() < 1e-12); -} - -#[test] -fn ll_rejects_malformed_input() { - let ok = gen_scores(30, 20, 3, 0.2, 0.9, 3); - assert!(livingston_lewis(&ok[..5], 0.8, 0.0, 20.0, 10.0).is_err()); - assert!(livingston_lewis(&ok, 0.8, 20.0, 0.0, 10.0).is_err()); - assert!(livingston_lewis(&ok, 0.8, 0.0, 20.0, 0.0).is_err()); - assert!(livingston_lewis(&ok, 0.8, 0.0, 20.0, 20.0).is_err()); - assert!(livingston_lewis(&ok, 0.0, 0.0, 20.0, 10.0).is_err()); - assert!(livingston_lewis(&ok, 1.0, 0.0, 20.0, 10.0).is_err()); - assert!(livingston_lewis(&ok, f64::NAN, 0.0, 20.0, 10.0).is_err()); - let mut with_nan = ok.clone(); - with_nan[0] = f64::NAN; - assert!(livingston_lewis(&with_nan, 0.8, 0.0, 20.0, 10.0).is_err()); - let mut out_of_range = ok.clone(); - out_of_range[0] = 25.0; - assert!(livingston_lewis(&out_of_range, 0.8, 0.0, 20.0, 10.0).is_err()); - let constant = vec![7.0; 30]; - assert!(livingston_lewis(&constant, 0.8, 0.0, 20.0, 10.0).is_err()); -} - -#[test] -#[ignore = "500-rep Monte Carlo; run with --ignored"] -fn ll_mc_consistency_recovers_empirical_agreement() { - // Value recovery: crate `consistency` (model-based, from ONE - // administration + reliability) vs the empirical agreement rate of two - // independent simulated administrations. Both operands per rep: one is - // the crate output, the other simulation truth. - let n_items = 60usize; - let n = 400usize; - let reps = 500u64; - let mut diff_sum = 0.0; - for rep in 0..reps { - let mut r = Lcg::new(9000 + rep); - let mut x1 = Vec::with_capacity(n); - let mut agree = 0usize; - let cut = 33.0; - let mut s1 = Vec::with_capacity(n); - let mut s2 = Vec::with_capacity(n); - for _ in 0..n { - let mut acc = 0.0; - for _ in 0..4 { - acc += r.unif(); - } - let p = 0.2 + 0.7 * acc / 4.0; - let mut a = 0u32; - let mut b = 0u32; - for _ in 0..n_items { - if r.unif() < p { - a += 1; - } - if r.unif() < p { - b += 1; - } - } - if (a as f64 >= cut) == (b as f64 >= cut) { - agree += 1; - } - x1.push(a as f64); - s1.push(a as f64); - s2.push(b as f64); - } - // Parallel-forms reliability = corr(X1, X2) for this rep. - let m1 = s1.iter().sum::() / n as f64; - let m2 = s2.iter().sum::() / n as f64; - let (mut c12, mut v1, mut v2) = (0.0, 0.0, 0.0); - for i in 0..n { - c12 += (s1[i] - m1) * (s2[i] - m2); - v1 += (s1[i] - m1) * (s1[i] - m1); - v2 += (s2[i] - m2) * (s2[i] - m2); - } - let rel = c12 / (v1.sqrt() * v2.sqrt()); - let est = livingston_lewis(&x1, rel, 0.0, n_items as f64, cut).unwrap(); - diff_sum += est.consistency - agree as f64 / n as f64; - } - let bias = diff_sum / reps as f64; - assert!(bias.abs() < 0.02, "mean consistency bias {bias}"); -} - -#[test] -fn ll_conditional_ratios_nan_when_margin_vanishes() { - // Cut far below the fitted 4P support: the true-fail margin is zero, so - // specificity must be NaN while the aggregate cells stay finite and sum - // to 1. With cut = 0.1 on fixture A data, k = round(91 * c) = 0 forces - // fail_prob == 0, giving consistency == chance == 1 and kappa NaN. - // Limitation: at exactly-zero denominators IEEE 0/0 is already NaN, so - // a dropped `ratio` guard is NOT killed here; the guard's value is for - // tiny nonzero denominators (documented, no discriminating fixture). - let scores = gen_scores_beta(300, 50, 1, 2.0, 1.5, 0.2, 0.95); - let r = livingston_lewis(&scores, 0.9, 0.0, 50.0, 5.0).unwrap(); - assert!(r.specificity.is_nan()); - assert!(r.sensitivity.is_finite()); - assert!((r.p_tp + r.p_fp + r.p_tf + r.p_ff - 1.0).abs() < 1e-9); - - let scores_a = gen_scores(250, 60, 42, 0.15, 0.95, 4); - let r = livingston_lewis(&scores_a, 0.85, 0.0, 60.0, 0.1).unwrap(); - assert!(r.kappa.is_nan()); - assert!((r.consistency - 1.0).abs() < 1e-9); - assert!((r.accuracy - 1.0).abs() < 1e-9); -} diff --git a/tests/unit/crm_tests.rs b/tests/unit/crm_tests.rs deleted file mode 100644 index 6b3a296ec..000000000 --- a/tests/unit/crm_tests.rs +++ /dev/null @@ -1,308 +0,0 @@ -use super::*; - -struct Lcg(u64); -impl Lcg { - fn f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.f64().max(1e-12); - let u2 = self.f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64).sqrt() -} -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let mx = x.iter().sum::() / n; - let my = y.iter().sum::() / n; - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for i in 0..x.len() { - sxy += (x[i] - mx) * (y[i] - my); - sxx += (x[i] - mx).powi(2); - syy += (y[i] - my).powi(2); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -/// Simulate CRM data: X = a*theta + d + sigma*eps, Z = logistic(X). -#[allow(clippy::too_many_arguments)] -fn simulate_crm( - a: &[f64], - d: &[f64], - sigma: &[f64], - n: usize, - n_items: usize, - skew: bool, - rng: &mut Lcg, -) -> (Vec, Vec) { - let mut z = vec![0.0f64; n * n_items]; - let mut thetas = vec![0.0f64; n]; - for j in 0..n { - let theta = if skew { - let mut c = 0.0; - for _ in 0..3 { - let g = rng.normal(); - c += g * g; - } - (c - 3.0) / (6.0_f64).sqrt() - } else { - rng.normal() - }; - thetas[j] = theta; - for i in 0..n_items { - let xij = a[i] * theta + d[i] + sigma[i] * rng.normal(); - z[j * n_items + i] = 1.0 / (1.0 + (-xij).exp()); - } - } - (z, thetas) -} - -/// Unit test of the closed-form WLS + residual formula against a hand solve. -#[test] -fn crm_wls_matches_direct_solve() { - // Three (theta, X) points with unit posterior weight -> ordinary least squares. - let th = [-1.0f64, 0.0, 1.0]; - let xv = [0.2f64, 0.5, 1.4]; - let (mut s1, mut sth, mut sthth, mut sx, mut sxth, mut sxx) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); - for k in 0..3 { - s1 += 1.0; - sth += th[k]; - sthth += th[k] * th[k]; - sx += xv[k]; - sxth += xv[k] * th[k]; - sxx += xv[k] * xv[k]; - } - let det = sthth * s1 - sth * sth; - let a = (sxth * s1 - sth * sx) / det; - let dd = (sthth * sx - sth * sxth) / det; - // OLS slope = cov(theta,X)/var(theta); with theta mean 0: a = sxth/sthth - assert!((a - sxth / sthth).abs() < 1e-12); - // intercept = mean(X) - a*mean(theta) = mean(X) (theta mean 0) - assert!((dd - sx / s1).abs() < 1e-12); - let resid = (sxx - a * sxth - dd * sx) / s1; - // residual = mean((X - a*theta - d)^2) - let direct: f64 = (0..3) - .map(|k| (xv[k] - a * th[k] - dd).powi(2)) - .sum::() - / 3.0; - assert!((resid - direct).abs() < 1e-12, "{resid} vs {direct}"); -} - -#[test] -fn crm_private_numeric_contracts_cover_all_defensive_outcomes() { - assert_eq!(contextualize_crm_update(Ok(None), 3).unwrap(), None); - assert_eq!( - contextualize_crm_update(Err("singular update".to_owned()), 3).unwrap_err(), - "singular update for item 3" - ); - assert!(checked_crm_delta(f64::NAN, None, 1e-6).is_err()); - assert_eq!(checked_crm_delta(-10.0, None, 1e-6).unwrap(), None); - assert!(checked_crm_delta(-11.0, Some(-10.0), 1e-6).is_err()); - let (_, tolerance, converged) = checked_crm_delta(-9.999_999, Some(-10.0), 1e-6) - .unwrap() - .unwrap(); - assert_eq!(tolerance, 11e-6); - assert!(converged); - assert!( - !checked_crm_delta(-9.0, Some(-10.0), 1e-6) - .unwrap() - .unwrap() - .2 - ); - - let degenerate = CrmWlsStats { - s1: 1.0, - sth: 1.0, - sthth: 1.0, - sx: 1.0, - sxth: 1.0, - sxx: 1.0, - }; - assert!(crm_wls_update(degenerate, 1e-6).unwrap().is_none()); - let nonfinite = CrmWlsStats { - sthth: 2.0, - sxx: f64::INFINITY, - ..degenerate - }; - assert!(crm_wls_update(nonfinite, 1e-6).is_err()); - - let mut loadings = [-1.0, -2.0]; - reflect_crm_loadings(&mut loadings); - assert_eq!(loadings, [1.0, 2.0]); - reflect_crm_loadings(&mut loadings); - assert_eq!(loadings, [1.0, 2.0]); - assert!(crm_difficulty(0.0, 2.0).is_nan()); - assert_eq!(crm_difficulty(2.0, -4.0), 2.0); -} - -/// Continuous responses are highly informative, so the model recovers the item -/// parameters, the Samejima re-parameterization, and the trait well. -#[test] -fn crm_recovers_params() { - let (n_items, n) = (15usize, 1500usize); - let a_true: Vec = (0..n_items).map(|i| 0.8 + 0.05 * i as f64).collect(); - let d_true: Vec = (0..n_items).map(|i| -0.6 + 0.08 * i as f64).collect(); - let sigma_true: Vec = (0..n_items).map(|i| 0.6 + 0.02 * (i % 5) as f64).collect(); - let mut rng = Lcg(73); - let (z, thetas) = simulate_crm(&a_true, &d_true, &sigma_true, n, n_items, false, &mut rng); - let observed = vec![true; n * n_items]; - let res = fit_crm(&z, &observed, n, n_items, 41, 500, 1e-7).unwrap(); - println!( - "CRM convergence: reason={}, iterations={}/500, final_delta={}, tolerance={}, loglik={} -> {}", - res.termination_reason, - res.n_iter, - res.final_delta, - res.stopping_tolerance, - res.loglik_trace.first().unwrap(), - res.loglik_trace.last().unwrap() - ); - assert!(res.converged); - assert_eq!(res.termination_reason, "tolerance"); - assert!(res.final_delta <= res.stopping_tolerance); - assert_eq!(res.n_iter + 1, res.loglik_trace.len()); - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "loglik decreased {} -> {}", w[0], w[1]); - } - assert_eq!(res.n_parameters, 3 * n_items); - assert!( - rmse(&res.slope, &a_true) < 0.15, - "a RMSE {}", - rmse(&res.slope, &a_true) - ); - assert!( - rmse(&res.intercept, &d_true) < 0.1, - "d RMSE {}", - rmse(&res.intercept, &d_true) - ); - assert!( - rmse(&res.resid_sd, &sigma_true) < 0.1, - "sigma RMSE {}", - rmse(&res.resid_sd, &sigma_true) - ); - assert!(res.slope.iter().all(|&x| x > 0.0)); // reflection convention - // Samejima re-parameterization recovers the generating discrimination/difficulty. - let alpha_true: Vec = (0..n_items).map(|i| a_true[i] / sigma_true[i]).collect(); - let b_true: Vec = (0..n_items).map(|i| -d_true[i] / a_true[i]).collect(); - assert!(rmse(&res.discrimination, &alpha_true) < 0.3, "alpha RMSE"); - assert!(rmse(&res.difficulty, &b_true) < 0.2, "b RMSE"); - // trait recovery (continuous responses are information-rich) - assert!( - corr(&res.theta, &thetas) > 0.9, - "theta corr {}", - corr(&res.theta, &thetas) - ); -} - -#[test] -fn crm_handles_missing_data() { - let (n_items, n) = (8usize, 600usize); - let a_true = vec![1.0f64; n_items]; - let d_true = vec![0.0f64; n_items]; - let sigma_true = vec![0.7f64; n_items]; - let mut rng = Lcg(9); - let (z, _t) = simulate_crm(&a_true, &d_true, &sigma_true, n, n_items, false, &mut rng); - let mut observed = vec![true; n * n_items]; - for o in observed.iter_mut() { - if rng.f64() < 0.2 { - *o = false; - } - } - let res = fit_crm(&z, &observed, n, n_items, 21, 400, 1e-6).unwrap(); - assert!( - res.converged, - "{} after {} iterations", - res.termination_reason, res.n_iter - ); - assert!(res.loglik_trace.iter().all(|v| v.is_finite())); - assert!(res.resid_sd.iter().all(|&s| s > 0.0)); -} - -#[test] -fn crm_validate_rejects_malformed() { - assert!(fit_crm(&[0.5, 0.5], &[true, true], 1, 3, 21, 10, 1e-6).is_err()); // wrong len - assert!(fit_crm(&[0.5, 1.5], &[true, true], 1, 2, 21, 10, 1e-6).is_err()); // out of (0,1) - assert!(fit_crm(&[0.5, 0.5], &[true, true], 1, 2, 99, 10, 1e-6).is_err()); // bad q - assert!(fit_crm(&[], &[], 0, 2, 21, 10, 1e-6).is_err()); // no persons - assert!(fit_crm(&[], &[], 2, 0, 21, 10, 1e-6).is_err()); // no items - assert!(fit_crm(&[0.5, 0.5], &[true, true], 1, 2, 21, 0, 1e-6).is_err()); // no iterations - assert!(fit_crm(&[0.5, 0.5], &[true, true], 1, 2, 21, 100_001, 1e-6).is_err()); - assert!(fit_crm(&[0.5, 0.5], &[true, true], 1, 2, 21, 10, f64::NAN).is_err()); - assert!(fit_crm(&[0.5, 0.5], &[true, true], 1, 2, 21, 10, 0.0).is_err()); - assert!(fit_crm(&[0.5, 0.5], &[true, false], 1, 2, 21, 10, 1e-6).is_err()); - assert!(fit_crm(&[], &[], usize::MAX, 2, 21, 10, 1e-6).is_err()); - assert!(fit_crm(&[0.5, 0.5], &[true], 1, 2, 21, 10, 1e-6).is_err()); - assert!(fit_crm(&[], &[], 1, usize::MAX, 21, 10, 1e-6).is_err()); -} - -#[test] -fn crm_reports_iteration_limit_without_false_success() { - let z = [0.2, 0.7, 0.4, 0.8, 0.6, 0.3, 0.9, 0.5]; - let observed = [true; 8]; - let res = fit_crm(&z, &observed, 4, 2, 21, 1, 1e-12).unwrap(); - assert!(!res.converged); - assert_eq!(res.termination_reason, "max_iter"); - assert_eq!(res.n_iter, 1); - assert_eq!(res.loglik_trace.len(), 2); - assert!(res.final_delta > res.stopping_tolerance); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_crm_recovery_500() { - let (n_items, n, reps) = (15usize, 500usize, 500usize); - let a_true: Vec = (0..n_items).map(|i| 0.8 + 0.05 * i as f64).collect(); - let d_true: Vec = (0..n_items).map(|i| -0.6 + 0.08 * i as f64).collect(); - let sigma_true: Vec = (0..n_items).map(|i| 0.6 + 0.02 * (i % 5) as f64).collect(); - for &skew in [false, true].iter() { - let (mut ra, mut rd, mut rs, mut ba, mut nconv, mut tcorr) = - (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0usize, 0.0f64); - for rep in 0..reps { - let mut rng = Lcg(0x5DEECE66Du64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0x9E3779B97F4A7C15)); - let (z, thetas) = - simulate_crm(&a_true, &d_true, &sigma_true, n, n_items, skew, &mut rng); - let observed = vec![true; n * n_items]; - let res = fit_crm(&z, &observed, n, n_items, 41, 500, 1e-6).unwrap(); - assert!( - res.converged, - "CRM did not converge: skew={skew} rep={rep} reason={} n_iter={} final_delta={} tol={}", - res.termination_reason, - res.n_iter, - res.final_delta, - res.stopping_tolerance - ); - if res.converged { - nconv += 1; - } - ra += rmse(&res.slope, &a_true) / reps as f64; - rd += rmse(&res.intercept, &d_true) / reps as f64; - rs += rmse(&res.resid_sd, &sigma_true) / reps as f64; - ba += (res.slope.iter().sum::() - a_true.iter().sum::()) - / n_items as f64 - / reps as f64; - tcorr += corr(&res.theta, &thetas) / reps as f64; - } - println!( - "[CRM MC skew={skew}] reps={reps} conv={:.2} RMSE(a)={:.3} RMSE(d)={:.3} \ - RMSE(sigma)={:.3} bias(a)={:.3} theta-corr={:.3}", - nconv as f64 / reps as f64, - ra, - rd, - rs, - ba, - tcorr - ); - assert!(ra < 0.15, "RMSE(a) {ra} skew={skew}"); - assert!(rd < 0.12, "RMSE(d) {rd} skew={skew}"); - assert!(rs < 0.1, "RMSE(sigma) {rs} skew={skew}"); - assert!(tcorr > 0.9, "theta corr {tcorr} skew={skew}"); - } -} diff --git a/tests/unit/detect_tests.rs b/tests/unit/detect_tests.rs deleted file mode 100644 index 618f97234..000000000 --- a/tests/unit/detect_tests.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Tests for confirmatory DETECT (Zhang & Stout, 1999; transcribed from CRAN -//! sirt's `scale_score=FALSE` sum-score path). -//! -//! Fixture literals were computed by an INDEPENDENT NumPy transcription of -//! the R semantics (ML covariance dividing by n, group-frequency weights, -//! bias-corrected average of total-score and rest-score conditioning); the -//! generating script never imports this crate. -//! -//! Disclosed limitations of this suite: -//! - Grouping by unique score values is invariant to strictly monotone score -//! transforms, so NO fixture here can discriminate `scale_score=FALSE` -//! (implemented) from a z-standardizing mutant whose rounding merges no -//! groups; the module doc's scope statement pins that contract instead. -//! - Under a single cluster, DETECT == MCOV100 is an identity (delta = +1 -//! everywhere); the single-cluster test therefore anchors DETECT to an -//! external literal and does not use that equality as a signal. -//! - |RATIO| <= 1 is the triangle inequality (tautological); never asserted -//! as a correctness signal. - -use super::*; - -const TOL: f64 = 1e-12; - -/// 9 persons x 5 items; item 4 is constant (=1), forcing exact ccov = 0 for -/// its four pairs (exercises R's sign(0) = 0 in ASSI). -fn fixture_x() -> Vec { - [ - [1, 0, 0, 1, 1], - [0, 0, 0, 0, 1], - [0, 0, 1, 1, 1], - [1, 0, 1, 0, 1], - [0, 0, 0, 1, 1], - [0, 1, 0, 1, 1], - [0, 0, 1, 1, 1], - [0, 1, 0, 1, 1], - [1, 0, 1, 0, 1], - ] - .iter() - .flatten() - .map(|&v| v as f64) - .collect() -} - -/// Asserts read: DetectResult.{detect, assi, ratio, madcov100, mcov100} -/// returned by the crate. Killed by: delta sign swap, dropped bias -/// correction, n-1 covariance divisor, missing/extra x100 scaling on any -/// index, sign(0) = +1, wrong weight normalization. -#[test] -fn detect_fixture_values() { - let x = fixture_x(); - let r = detect_analysis(&x, 9, 5, &[0, 0, 1, 1, 0]).unwrap(); - assert!((r.detect - (-0.2830687830687831)).abs() < TOL, "{}", r.detect); - assert!((r.assi - (-0.2)).abs() < TOL, "{}", r.assi); - assert!((r.ratio - (-0.05439755973563803)).abs() < TOL, "{}", r.ratio); - assert!((r.madcov100 - 5.203703703703703).abs() < TOL, "{}", r.madcov100); - assert!((r.mcov100 - (-3.5105820105820107)).abs() < TOL, "{}", r.mcov100); - assert_eq!(r.n_pairs, 10); -} - -/// Asserts read: DetectResult.{pair_i, pair_j, ccov} per pair. Killed by: -/// rest score computed as S - X_i only (forgot X_j), aggregation weight -/// 1/n_groups instead of group frequency, singleton-group mishandling, -/// reusing total-score weights for the rest-score pass, pair-order/layout -/// bugs (all six non-constant ccovs are distinct). -#[test] -fn ccov_per_pair_fixture() { - let x = fixture_x(); - let r = detect_analysis(&x, 9, 5, &[0, 0, 1, 1, 0]).unwrap(); - let expected: [(usize, usize, f64); 10] = [ - (0, 1, -0.10317460317460317), - (0, 2, 0.025132275132275124), - (0, 3, -0.11904761904761904), - (0, 4, 0.0), - (1, 2, -0.12698412698412698), - (1, 3, 0.05952380952380952), - (1, 4, 0.0), - (2, 3, -0.08650793650793652), - (2, 4, 0.0), - (3, 4, 0.0), - ]; - assert_eq!(r.ccov.len(), 10); - for (k, &(i, j, c)) in expected.iter().enumerate() { - assert_eq!(r.pair_i[k], i); - assert_eq!(r.pair_j[k], j); - assert!((r.ccov[k] - c).abs() < TOL, "pair ({i},{j}): {}", r.ccov[k]); - } -} - -/// Interleaved (non-contiguous) clusters over the SAME responses. Asserts -/// read: crate indices under cluster [0,1,0,1,0]. Killed by: contiguous- -/// block cluster assumptions, label-position bugs, delta mapping errors -/// (DETECT here is +5.2037..., not the negation of the primary fixture). -#[test] -fn delta_structure_interleaved() { - let x = fixture_x(); - let r = detect_analysis(&x, 9, 5, &[0, 1, 0, 1, 0]).unwrap(); - assert!((r.detect - 5.203703703703703).abs() < TOL, "{}", r.detect); - assert!((r.assi - 0.6).abs() < TOL, "{}", r.assi); - assert!((r.ratio - 1.0).abs() < TOL, "{}", r.ratio); - assert!((r.madcov100 - 5.203703703703703).abs() < TOL, "{}", r.madcov100); - assert!((r.mcov100 - (-3.5105820105820107)).abs() < TOL, "{}", r.mcov100); -} - -/// All items in one cluster: DETECT anchored to an external literal. -/// (crate-DETECT == crate-MCOV100 here is an identity since delta = +1 -/// everywhere — deliberately NOT used as a signal.) Asserts read: crate -/// detect/assi under a shared nonzero label. -#[test] -fn single_cluster_anchor() { - let x = fixture_x(); - let r = detect_analysis(&x, 9, 5, &[7, 7, 7, 7, 7]).unwrap(); - assert!((r.detect - (-3.5105820105820107)).abs() < TOL, "{}", r.detect); - assert!((r.assi - (-0.2)).abs() < TOL, "{}", r.assi); - assert!((r.ratio - (-0.6746314184036604)).abs() < TOL, "{}", r.ratio); -} - -/// Hostile labels: extreme i64 values must behave exactly like [0,0,1,1,0] -/// (labels compared for equality, never used as indices). Asserts read: -/// crate indices under i64::MIN/i64::MAX labels. Killed by: any use of the -/// label value as an array index or dense-relabeling arithmetic. -#[test] -fn hostile_cluster_labels() { - let x = fixture_x(); - let cl = [i64::MIN, i64::MIN, i64::MAX, i64::MAX, i64::MIN]; - let r = detect_analysis(&x, 9, 5, &cl).unwrap(); - assert!((r.detect - (-0.2830687830687831)).abs() < TOL, "{}", r.detect); - assert!((r.assi - (-0.2)).abs() < TOL, "{}", r.assi); -} - -/// Guard rejections. Asserts read: Err values returned by the crate. -#[test] -fn guard_rejections() { - let x = fixture_x(); - // cluster length mismatch - assert!(detect_analysis(&x, 9, 5, &[0, 0, 1, 1]).is_err()); - // non-binary value - let mut bad = x.clone(); - bad[3] = 0.5; - assert!(detect_analysis(&bad, 9, 5, &[0, 0, 1, 1, 0]).is_err()); - // NaN (missing data not supported) - let mut nan = x.clone(); - nan[7] = f64::NAN; - assert!(detect_analysis(&nan, 9, 5, &[0, 0, 1, 1, 0]).is_err()); - // too few items / persons - assert!(detect_analysis(&[0.0, 1.0], 2, 1, &[0]).is_err()); - assert!(detect_analysis(&[0.0, 1.0], 1, 2, &[0, 1]).is_err()); - // length mismatch - assert!(detect_analysis(&x[..40], 9, 5, &[0, 0, 1, 1, 0]).is_err()); - // all-zero conditional covariances (two constant items): RATIO is 0/0 in - // R; the crate must return Err, not NaN. - let ones = vec![1.0; 8]; - assert!(detect_analysis(&ones, 4, 2, &[0, 1]).is_err()); -} - -/// Monte Carlo behavioral check (>= 500 replications; run with --ignored). -/// 2D simple structure (theta correlation 0.3): DETECT with the correct -/// partition should be clearly positive on average; unidimensional data with -/// the same partition should yield DETECT near zero. Asserts read: mean of -/// crate detect values across replications. -#[test] -#[ignore] -fn monte_carlo_detect_recovery() { - // Minimal deterministic LCG (no rand dependency in dev-deps for core). - struct Lcg(u64); - impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - /// Box-Muller standard normal. - fn next_norm(&mut self) -> f64 { - let (u1, u2) = (self.next_f64().max(1e-12), self.next_f64()); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } - } - let reps = 500; - let (n, items_per_dim) = (400, 6); - let n_items = 2 * items_per_dim; - let cluster: Vec = (0..n_items).map(|i| (i / items_per_dim) as i64).collect(); - let mut rng = Lcg(20240601); - let (mut sum_multi, mut sum_uni) = (0.0, 0.0); - for _ in 0..reps { - // Multidimensional: theta1, theta2 with corr 0.3. - let mut x_multi = vec![0.0; n * n_items]; - let mut x_uni = vec![0.0; n * n_items]; - for p in 0..n { - let z1 = rng.next_norm(); - let z2 = 0.3 * z1 + (1.0f64 - 0.09).sqrt() * rng.next_norm(); - let t_uni = rng.next_norm(); - for i in 0..n_items { - let b = -1.0 + 2.0 * (i % items_per_dim) as f64 / (items_per_dim - 1) as f64; - let th = if i < items_per_dim { z1 } else { z2 }; - let pr_m = 1.0 / (1.0 + (-(1.2 * (th - b))).exp()); - let pr_u = 1.0 / (1.0 + (-(1.2 * (t_uni - b))).exp()); - let u = rng.next_f64(); - x_multi[p * n_items + i] = if u < pr_m { 1.0 } else { 0.0 }; - // reuse the same uniform for the unidimensional draw is NOT - // independent; draw a fresh one. - let u2 = rng.next_f64(); - x_uni[p * n_items + i] = if u2 < pr_u { 1.0 } else { 0.0 }; - } - } - sum_multi += detect_analysis(&x_multi, n, n_items, &cluster).unwrap().detect; - sum_uni += detect_analysis(&x_uni, n, n_items, &cluster).unwrap().detect; - } - let (mean_multi, mean_uni) = (sum_multi / reps as f64, sum_uni / reps as f64); - assert!( - mean_multi > 0.5, - "2D simple structure should give clearly positive DETECT, got {mean_multi}" - ); - assert!( - mean_uni.abs() < 0.2, - "unidimensional data should give DETECT near zero, got {mean_uni}" - ); - assert!( - mean_multi > mean_uni + 0.3, - "multidimensional DETECT should exceed unidimensional: {mean_multi} vs {mean_uni}" - ); -} diff --git a/tests/unit/dif_tests.rs b/tests/unit/dif_tests.rs deleted file mode 100644 index ab2d43aaf..000000000 --- a/tests/unit/dif_tests.rs +++ /dev/null @@ -1,2015 +0,0 @@ -use super::*; - -/// Minimal LCG + Box-Muller normal (crate PRNG idiom) for the simulation anchors. -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -/// Build a stratified `(resp, group, matching)` sample from explicit per-stratum `(A,B,C,D)` cells -/// (ref-correct, ref-incorrect, focal-correct, focal-incorrect), with `matching[p]` = the stratum -/// index. Lets the deterministic anchor pin the arithmetic without engineering total scores. -fn build(cells: &[(usize, u64, u64, u64, u64)], n_levels: usize) -> (Vec, Vec, Vec) { - let (mut resp, mut group, mut matching) = (Vec::new(), Vec::new(), Vec::new()); - let mut push = |g: u8, r: u8, m: usize, n: u64| { - for _ in 0..n { - resp.push(r); - group.push(g); - matching.push(m); - } - }; - for &(m, a, b, c, d) in cells { - push(0, 1, m, a); - push(0, 0, m, b); - push(1, 1, m, c); - push(1, 0, m, d); - } - assert!(n_levels > cells.iter().map(|c| c.0).max().unwrap()); - (resp, group, matching) -} - -/// Deterministic anchor: two strata hand-computed off Holland & Thayer (1988), the RBG (1986) -/// variance, and the ETS delta/classification. Pins alpha_MH, the CONTINUITY-CORRECTED chi-square, -/// MH D-DIF, SE, STD-P-DIF (focal minus reference), and the C label. A dropped `-0.5`, a wrong -/// variance denominator, a sign flip, or a reference-minus-focal STD-P-DIF all fail here. -#[test] -fn mh_two_stratum_hand_anchor() { - // Stratum 1: A=80 B=20 C=40 D=60; Stratum 2: A=60 B=40 C=30 D=70. - let (resp, group, matching) = build(&[(1, 80, 20, 40, 60), (2, 60, 40, 30, 70)], 3); - let st = mh_item_stats(&resp, &group, &matching, 3); - // alpha = (80*60/200 + 60*70/200) / (20*40/200 + 40*30/200) = 45 / 10 = 4.5 - assert!((st.alpha_mh - 4.5).abs() < 1e-12, "alpha {}", st.alpha_mh); - // D-DIF = -2.35 ln(4.5) - assert!( - (st.mh_d_dif - (-2.35 * 4.5_f64.ln())).abs() < 1e-10, - "d_dif {}", - st.mh_d_dif - ); - // chi2 = (|140 - 105| - 0.5)^2 / 24.497487... = 34.5^2 / 24.497487 = 48.5865... - assert!((st.chi2_mh - 48.58647).abs() < 1e-3, "chi2 {}", st.chi2_mh); - // SE = 2.35 * sqrt(0.04762963) = 0.512869... - assert!((st.se_d_dif - 0.512869).abs() < 1e-5, "se {}", st.se_d_dif); - // STD-P-DIF = (100*(0.4-0.8) + 100*(0.3-0.6)) / 200 = -0.35 (focal - reference, negative) - assert!( - (st.std_p_dif - (-0.35)).abs() < 1e-12, - "std_p {}", - st.std_p_dif - ); - assert!(st.p_value < 1e-6, "p {}", st.p_value); - // |D-DIF|=3.53 >= 1.5 and 3.53 - 1.645*0.5129 = 2.69 > 1.0 and significant -> C - assert_eq!(st.ets_class, EtsClass::C); - // sign agreement: both effect sizes negative (against the focal group) - assert!(st.mh_d_dif < 0.0 && st.std_p_dif < 0.0); -} - -/// No-DIF symmetry: identical reference/focal conditional response rates within every stratum give -/// alpha_MH = 1, MH D-DIF = 0, STD-P-DIF = 0, and class A. -#[test] -fn mh_no_dif_symmetry() { - // Each stratum: A/n_R == C/n_F exactly, so every 2x2 has odds ratio 1. - let (resp, group, matching) = build(&[(1, 60, 40, 60, 40), (2, 30, 70, 30, 70)], 3); - let st = mh_item_stats(&resp, &group, &matching, 3); - assert!((st.alpha_mh - 1.0).abs() < 1e-12, "alpha {}", st.alpha_mh); - assert!(st.mh_d_dif.abs() < 1e-10, "d_dif {}", st.mh_d_dif); - assert!(st.std_p_dif.abs() < 1e-12, "std_p {}", st.std_p_dif); - assert!(st.chi2_mh < 1e-9, "chi2 {}", st.chi2_mh); - assert_eq!(st.ets_class, EtsClass::A); -} - -/// Degenerate guard: a single-group stratum (focal absent) contributes nothing, and a perfectly -/// separated table (no informative stratum) yields NaN statistics and an Undefined class — NOT A. -#[test] -fn mh_degenerate_is_undefined_not_a() { - // Only a reference group present at level 1 (no focal anywhere) -> no informative strata. - let (resp, group, matching) = build(&[(1, 30, 20, 0, 0)], 2); - let st = mh_item_stats(&resp, &group, &matching, 2); - assert!(st.alpha_mh.is_nan(), "alpha {}", st.alpha_mh); - assert!(st.mh_d_dif.is_nan(), "d_dif {}", st.mh_d_dif); - assert!(st.se_d_dif.is_nan(), "se {}", st.se_d_dif); - assert!(st.chi2_mh.is_nan() && st.p_value.is_nan()); - assert_eq!(st.ets_class, EtsClass::Undefined); - - // Perfect separation: reference always correct, focal always incorrect (sum B_m C_m = 0 -> - // alpha_MH = +inf). Both groups present and both responses present across strata, so chi2 is - // defined, but the delta metric is undefined. - let (resp2, group2, matching2) = build(&[(1, 50, 0, 0, 50)], 2); - let st2 = mh_item_stats(&resp2, &group2, &matching2, 2); - assert!(st2.mh_d_dif.is_nan(), "sep d_dif {}", st2.mh_d_dif); - assert_eq!(st2.ets_class, EtsClass::Undefined); -} - -#[test] -fn dif_serialization_and_shared_validation_cover_every_boundary() { - assert_eq!(EtsClass::A.as_str(), "A"); - assert_eq!(EtsClass::B.as_str(), "B"); - assert_eq!(EtsClass::C.as_str(), "C"); - assert_eq!(EtsClass::Undefined.as_str(), "U"); - - let cfg = MhDifConfig::default(); - assert!(validate_dif_inputs(&[], &[], 0, 1, &cfg).is_err()); - assert!(validate_dif_inputs(&[], &[], MAX_CELLS + 1, 1, &cfg).is_err()); - assert!(validate_dif_inputs(&[0, 1], &[0, 1], 2, 2, &cfg).is_err()); - assert!(validate_dif_inputs(&[0, 1], &[0], 2, 1, &cfg).is_err()); -} - -/// Simulation anchor: a 2PL DGP with a uniform (b-shift) DIF planted on one item, no group impact. -/// MH flags the planted item as large (class B/C, BH-significant) with the delta sign matching the -/// shift (item harder for the focal group -> negative D-DIF, negative STD-P-DIF), and classifies the -/// clean items as A (negligible). The clean items are asserted by the ETS practical-significance -/// CLASS, not by the raw BH flag: MH chi-square is over-powered at large N and the DIF item's -/// presence in the number-correct total mildly contaminates the matching criterion, so a clean -/// item's chi-square can be BH-significant while its effect size stays negligible (the A/B/C -/// classification is exactly the guard against this; item purification is the standard remedy and is -/// out of scope here). The parametric IRT-LR DIF, which does not match on the observed total, is -/// checked on the planted item plus one clean item for cross-method agreement. -#[test] -fn mh_flags_planted_uniform_dif_and_agrees_with_irt_lr() { - use crate::poly::{poly_dif_sweep, PolyModel}; - let (n, n_items) = (3000usize, 12usize); - let a = vec![1.2f64; n_items]; - let mut b = vec![0.0f64; n_items]; - for (i, bi) in b.iter_mut().enumerate() { - *bi = -0.8 + 0.14 * i as f64; - } - let dif_item = 6usize; - let clean_item = 0usize; - let b_focal_shift = 0.7; // item dif_item is HARDER for the focal group (uniform DIF) - let mut rng = Lcg(0xD1F); - let mut y = vec![0u8; n * n_items]; - let mut group = vec![0u8; n]; - for p in 0..n { - let g = if p % 2 == 0 { 0u8 } else { 1u8 }; - group[p] = g; - // equal ability distribution across groups (no impact) so DIF is isolated - let theta = rng.normal(); - for i in 0..n_items { - let mut bi = b[i]; - if i == dif_item && g == 1 { - bi += b_focal_shift; - } - let pr = 1.0 / (1.0 + (-(a[i] * (theta - bi))).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let rows = mantel_haenszel_dif(&y, &group, n, n_items, &MhDifConfig::default()).unwrap(); - // the planted item is flagged and large, harder-for-focal (negative delta + std_p) - let dr = &rows[dif_item]; - assert!( - dr.flagged_bh, - "planted item not BH-flagged (p={})", - dr.p_value - ); - assert!( - dr.mh_d_dif < -0.8, - "planted delta not large-negative: {}", - dr.mh_d_dif - ); - assert!(dr.std_p_dif < 0.0, "planted std_p sign: {}", dr.std_p_dif); - assert!( - matches!(dr.ets_class, EtsClass::B | EtsClass::C), - "planted class {:?}", - dr.ets_class - ); - // clean items are class A (negligible) by the practical-significance classification - for (i, r) in rows.iter().enumerate() { - if i != dif_item { - assert_eq!( - r.ets_class, - EtsClass::A, - "clean item {i} class {:?}", - r.ets_class - ); - assert!( - r.mh_d_dif.abs() < 1.0, - "clean item {i} |delta| {}", - r.mh_d_dif - ); - } - } - // agreement with the parametric IRT-LR DIF (uniform DIF, which MH is designed to catch): both - // flag the planted item and leave a clean item unflagged. Scoped to two studied items to keep - // the (per-item multigroup EM) cost bounded. - let yl: Vec = y.iter().map(|&v| v as usize).collect(); - let gl: Vec = group.iter().map(|&v| v as usize).collect(); - let studied = [dif_item, clean_item]; - let lr = poly_dif_sweep( - &yl, - None, - &gl, - 2, - n, - n_items, - 2, - PolyModel::Gpcm, - Some(&studied), - 21, - 200, - 1e-5, - 0.05, - ) - .unwrap(); - let lr_dif = lr.iter().find(|r| r.item == dif_item).unwrap(); - let lr_clean = lr.iter().find(|r| r.item == clean_item).unwrap(); - assert!( - lr_dif.flagged_bh, - "IRT-LR missed the planted item (p={})", - lr_dif.p_value - ); - assert!( - !lr_clean.flagged_bh, - "IRT-LR spuriously flagged the clean item" - ); -} - -/// Validation guards trip non-vacuously. -#[test] -fn mh_validates() { - let n = 20usize; - let n_items = 4usize; - let y = vec![1u8; n * n_items]; - let mut group = vec![0u8; n]; - for p in 0..n { - group[p] = (p % 2) as u8; - } - let cfg = MhDifConfig::default(); - // ok baseline (degenerate everywhere but valid input -> Undefined rows, not an error) - assert!(mantel_haenszel_dif(&y, &group, n, n_items, &cfg).is_ok()); - // response > 1 - let mut ybad = y.clone(); - ybad[0] = 2; - assert!(mantel_haenszel_dif(&ybad, &group, n, n_items, &cfg).is_err()); - // group label > 1 - let mut gbad = group.clone(); - gbad[0] = 2; - assert!(mantel_haenszel_dif(&y, &gbad, n, n_items, &cfg).is_err()); - // only one group present - let gone = vec![0u8; n]; - assert!(mantel_haenszel_dif(&y, &gone, n, n_items, &cfg).is_err()); - // y length mismatch - assert!(mantel_haenszel_dif(&y[..n * n_items - 1], &group, n, n_items, &cfg).is_err()); - // fdr_q out of range - let badq = MhDifConfig { fdr_q: 0.0, ..cfg }; - assert!(mantel_haenszel_dif(&y, &group, n, n_items, &badq).is_err()); -} - -/// Rest-score matching (`exclude_studied_item=true`) puts persons in different strata than the -/// item-included total, so the studied item's MH statistics differ between the two modes and the -/// rest-score path runs without an out-of-bounds level. A mutation dropping the `- y_i` (leaving the -/// rest score equal to the total) would make the two modes identical. -#[test] -fn mh_rest_score_matching_differs_from_item_included() { - let (n, n_items) = (1200usize, 6usize); - let a = 1.2f64; - let b = [-0.6, -0.3, 0.0, 0.3, 0.6, 0.9]; - let dif_item = 2usize; - let mut rng = Lcg(0x5E5); - let mut y = vec![0u8; n * n_items]; - let mut group = vec![0u8; n]; - for p in 0..n { - let g = (p % 2) as u8; - group[p] = g; - let theta = rng.normal(); - for i in 0..n_items { - let mut bi = b[i]; - if i == dif_item && g == 1 { - bi += 1.0; - } - let pr = 1.0 / (1.0 + (-(a * (theta - bi))).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let incl = mantel_haenszel_dif( - &y, - &group, - n, - n_items, - &MhDifConfig { - exclude_studied_item: false, - fdr_q: 0.05, - }, - ) - .unwrap(); - let excl = mantel_haenszel_dif( - &y, - &group, - n, - n_items, - &MhDifConfig { - exclude_studied_item: true, - fdr_q: 0.05, - }, - ) - .unwrap(); - // rest-score path completes (n_levels correct) and still flags the planted item - assert!(incl[dif_item].flagged_bh && excl[dif_item].flagged_bh); - // the studied item's strata genuinely change between the two matching schemes - assert!( - (incl[dif_item].chi2_mh - excl[dif_item].chi2_mh).abs() > 1e-6, - "rest-score identical to item-included: {} vs {}", - incl[dif_item].chi2_mh, - excl[dif_item].chi2_mh - ); -} - -/// ETS A/B/C/Undefined boundaries pinned directly, including the ONE-SIDED 1.645 critical value for -/// the C rule: at `|D|=1.5, SE=0.28` the `|D| - 1.645 SE = 1.039 > 1.0` test passes (C) but the -/// `1.96` mutant (`0.951`) would fail (B). -#[test] -fn mh_classify_boundaries() { - assert_eq!(classify(f64::NAN, 0.3, 0.001), EtsClass::Undefined); // undefined delta - assert_eq!(classify(-3.0, 0.4, 0.20), EtsClass::A); // not significant -> A - assert_eq!(classify(-0.8, 0.2, 0.001), EtsClass::A); // |D| < 1.0 -> A - assert_eq!(classify(-1.3, 0.2, 0.001), EtsClass::B); // 1.0 <= |D| < 1.5 -> B - assert_eq!(classify(-1.5, 0.28, 0.001), EtsClass::C); // C via the 1.645 test (1.96 -> B) - assert_eq!(classify(-1.6, 1.0, 0.001), EtsClass::B); // |D|>=1.5 but not sig. above 1.0 -> B -} - -/// STD-P-DIF uses the WIDER "both groups present" stratum gate, not the MH 4-marginal gate: an -/// all-correct stratum (`m0 = 0`, not MH-informative) still contributes focal weight to the -/// Dorans-Kulick standardization denominator. Under the stricter gate |STD-P-DIF| would inflate from -/// `40/150` to `40/100`. -#[test] -fn mh_std_p_dif_includes_all_correct_stratum_weight() { - // Stratum 1 informative (DIF); stratum 2 both-groups all-correct (m0 = 0). - let (resp, group, matching) = build(&[(1, 80, 20, 40, 60), (2, 50, 0, 50, 0)], 3); - let st = mh_item_stats(&resp, &group, &matching, 3); - // STD-P-DIF = (100*(0.4-0.8) + 50*(1.0-1.0)) / (100 + 50) = -40/150 - assert!( - (st.std_p_dif - (-40.0 / 150.0)).abs() < 1e-12, - "std_p {}", - st.std_p_dif - ); - // MH uses only the informative stratum 1: alpha = (80*60/200)/(20*40/200) = 6 - assert!((st.alpha_mh - 6.0).abs() < 1e-12, "alpha {}", st.alpha_mh); -} - -// ---------------- Zumbo (1999) logistic regression DIF ---------------- - -/// Log-likelihood of `n` Bernoulli trials with `k` successes evaluated at the MLE `p = k/n`. -fn bin_ll(k: f64, n: f64) -> f64 { - if n <= 0.0 { - return 0.0; - } - let p = k / n; - let a = if k > 0.0 { k * p.ln() } else { 0.0 }; - let b = if n - k > 0.0 { - (n - k) * (1.0 - p).ln() - } else { - 0.0 - }; - a + b -} - -/// Expand per-cell `(score, group, n, k)` counts into person-level response/score/group vectors. -fn expand(cells: &[(f64, f64, usize, usize)]) -> (Vec, Vec, Vec) { - let (mut resp, mut score, mut group) = (Vec::new(), Vec::new(), Vec::new()); - for &(s, g, n, k) in cells { - for j in 0..n { - resp.push(if j < k { 1.0 } else { 0.0 }); - score.push(s); - group.push(g); - } - } - (resp, score, group) -} - -/// SATURATED-DESIGN closed-form anchor. With a two-level matching score and a binary group, -/// `{1, S, G, S x G}` is saturated, so the M2 MLE fitted probabilities are exactly the four observed -/// cell proportions and `ll(M2)`, `ll(M0)` (pooled over group within score level) and the -/// intercept-only `ll_null` are all closed-form binomial log-likelihoods. This pins the IRLS, the -/// log-likelihood, the omnibus chi-square and the Nagelkerke effect size against independent -/// arithmetic — far stronger than a self-consistent finite-difference check. It also pins the exact -/// LR decomposition `chi2_uniform + chi2_nonuniform == chi2_total`, which fails if any nested fit -/// lands off its maximum (the `.max(0.0)` clamps would otherwise hide it). -#[test] -fn logistic_dif_saturated_design_closed_form() { - // (S, G, n, k): a crossing pattern - focal below reference at S=0, above it at S=1. - let cells = [ - (0.0, 0.0, 100usize, 30usize), - (1.0, 0.0, 100, 70), - (0.0, 1.0, 100, 20), - (1.0, 1.0, 100, 80), - ]; - let (resp, score, group) = expand(&cells); - let n = resp.len(); - let st = logistic_item_stats(&resp, &score, &group, n, 100); - assert!(st.converged, "saturated fit did not converge"); - - // closed forms - let ll2: f64 = cells - .iter() - .map(|&(_, _, nn, kk)| bin_ll(kk as f64, nn as f64)) - .sum(); - let ll0 = bin_ll(30.0 + 20.0, 200.0) + bin_ll(70.0 + 80.0, 200.0); // pooled within score level - let ll_null = bin_ll(200.0, 400.0); - let chi2_total = 2.0 * (ll2 - ll0); - assert!( - (st.chi2_total - chi2_total).abs() < 1e-6, - "chi2_total {} vs closed form {chi2_total}", - st.chi2_total - ); - // Nagelkerke delta R^2 from the same closed forms - let nn = n as f64; - let denom = 1.0 - (2.0 * ll_null / nn).exp(); - let r2n = |ll: f64| (1.0 - (2.0 * (ll_null - ll) / nn).exp()) / denom; - let d_r2 = r2n(ll2) - r2n(ll0); - assert!( - (st.delta_r2 - d_r2).abs() < 1e-6, - "delta_r2 {} vs closed form {d_r2}", - st.delta_r2 - ); - assert!(st.delta_r2 > 0.0 && st.delta_r2 <= 1.0); - // exact nesting decomposition (also the monotonicity check at converged MLEs) - assert!( - (st.chi2_uniform + st.chi2_nonuniform - st.chi2_total).abs() < 1e-6, - "decomposition {} + {} != {}", - st.chi2_uniform, - st.chi2_nonuniform, - st.chi2_total - ); -} - -/// THE DISCRIMINATING ANCHOR versus Mantel-Haenszel. A crossing (slope-difference) DIF item whose -/// ICCs intersect at the COMMON group ability mean produces essentially no net uniform effect, so -/// the MH common odds ratio is ~1 and MH classifies it NEGLIGIBLE (class A) — the known blind spot -/// of a stratified odds-ratio test. The logistic-regression procedure detects it through the -/// `S x G` interaction: `chi2_nonuniform` is significant while `chi2_uniform` is not. Also checks -/// that a plain uniform (b-shift) item is picked up by the uniform component and not the -/// interaction, and that clean items stay class A. Fixed seed, equal ability distributions. -#[test] -fn logistic_dif_detects_crossing_dif_that_mantel_haenszel_misses() { - let (n, n_items) = (4000usize, 10usize); - let cross_item = 4usize; - let unif_item = 7usize; - // A pronounced slope difference: strong enough that the TOTAL Nagelkerke effect clears the - // Jodoin-Gierl moderate cut-off while the uniform-only component stays negligible, which is - // what separates "classified from delta_r2" from "classified from delta_r2_uniform". - let a_ref = 2.6f64; - let a_foc = 0.15f64; // same difficulty, different slope -> ICCs cross at theta = 0 - let mut rng = Lcg(0x2117B0); - let b: Vec = (0..n_items).map(|i| -0.9 + 0.2 * i as f64).collect(); - let mut y = vec![0u8; n * n_items]; - let mut group = vec![0u8; n]; - for p in 0..n { - let g = (p % 2) as u8; - group[p] = g; - let theta = rng.normal(); // identical ability distribution in both groups - for i in 0..n_items { - let (mut ai, mut bi) = (1.0f64, b[i]); - if i == cross_item { - // crossing centered at the common ability mean (b = 0) - ai = if g == 0 { a_ref } else { a_foc }; - bi = 0.0; - } else if i == unif_item && g == 1 { - bi += 0.8; // pure uniform DIF - } - let pr = 1.0 / (1.0 + (-(ai * (theta - bi))).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let lr = logistic_dif(&y, &group, n, n_items, &LogisticDifConfig::default()).unwrap(); - let mh = mantel_haenszel_dif(&y, &group, n, n_items, &MhDifConfig::default()).unwrap(); - - // (1) crossing item: logistic flags the INTERACTION, not the group main effect - let c = &lr[cross_item]; - assert!(c.converged); - assert!( - c.p_nonuniform < 0.01, - "crossing p_nonuniform {}", - c.p_nonuniform - ); - assert!( - c.p_uniform > 0.05, - "crossing p_uniform should be n.s.: {}", - c.p_uniform - ); - assert!( - c.flagged_bh, - "crossing item not flagged by the omnibus test" - ); - // the class must come from the TOTAL delta_r2, not the uniform-only one: a crossing item has a - // substantial total effect but a near-zero uniform component, so classifying the latter would - // wrongly report A here. - assert!( - c.delta_r2 > c.delta_r2_uniform, - "total effect {} should exceed the uniform-only {}", - c.delta_r2, - c.delta_r2_uniform - ); - assert_ne!( - c.jg_class, - EtsClass::A, - "crossing item classified from the wrong delta_r2 (total {} vs uniform-only {})", - c.delta_r2, - c.delta_r2_uniform - ); - assert!( - c.delta_r2_uniform < JG_MODERATE, - "uniform-only component should stay negligible: {}", - c.delta_r2_uniform - ); - // ... and Mantel-Haenszel calls the very same item negligible (its blind spot) - assert_eq!( - mh[cross_item].ets_class, - EtsClass::A, - "MH unexpectedly flagged the crossing item (delta {})", - mh[cross_item].mh_d_dif - ); - - // (2) uniform item: the group main effect fires, the interaction does not - let u = &lr[unif_item]; - assert!(u.p_uniform < 0.01, "uniform p_uniform {}", u.p_uniform); - assert!( - u.p_nonuniform > 0.05, - "uniform p_nonuniform should be n.s.: {}", - u.p_nonuniform - ); - assert!(u.flagged_bh); - // MH does see the uniform item (it is not blind to this kind) - assert_ne!(mh[unif_item].ets_class, EtsClass::A); - - // (3) clean items: negligible class, and the exact LR decomposition holds everywhere - for (i, r) in lr.iter().enumerate() { - assert!( - (r.chi2_uniform + r.chi2_nonuniform - r.chi2_total).abs() < 1e-6, - "item {i} decomposition" - ); - if i != cross_item && i != unif_item { - assert_eq!( - r.jg_class, - EtsClass::A, - "clean item {i} class {:?}", - r.jg_class - ); - } - } -} - -/// Jodoin & Gierl (2001) classification pinned directly at its boundaries. Without this, three -/// distinct mutations survive the simulation tests (whose clean items have `delta_r2 ~ 0` either -/// way): dropping the "not significant => A" rule, swapping the LARGE/MODERATE comparisons, and -/// classifying `delta_r2_uniform` instead of `delta_r2`. -#[test] -fn jg_classify_boundaries() { - // undefined statistic -> Undefined, never a letter - assert_eq!(jg_classify(f64::NAN, true), EtsClass::Undefined); - assert_eq!(jg_classify(f64::NAN, false), EtsClass::Undefined); - // NOT significant -> A regardless of magnitude (conditional classification) - assert_eq!(jg_classify(0.50, false), EtsClass::A); - assert_eq!(jg_classify(JG_LARGE + 0.1, false), EtsClass::A); - // significant: the two boundaries, inclusive at the cut-points - assert_eq!(jg_classify(JG_MODERATE - 1e-9, true), EtsClass::A); - assert_eq!(jg_classify(JG_MODERATE, true), EtsClass::B); - assert_eq!(jg_classify(JG_LARGE - 1e-9, true), EtsClass::B); - assert_eq!(jg_classify(JG_LARGE, true), EtsClass::C); - assert_eq!(jg_classify(0.5, true), EtsClass::C); - // the ordering itself (a swapped comparison would break this) - assert_ne!(jg_classify(0.04, true), jg_classify(0.20, true)); -} - -/// Degenerate items are reported as UNDEFINED, never as a clean non-DIF result: an item everyone -/// answers identically has `ll_null = 0`, which makes the Nagelkerke normalizer zero (a 0/0), and a -/// rank-deficient design cannot be fitted at all. -#[test] -fn logistic_dif_undefined_on_degenerate_item() { - let (n, n_items) = (200usize, 4usize); - let mut y = vec![0u8; n * n_items]; - let mut group = vec![0u8; n]; - for p in 0..n { - group[p] = (p % 2) as u8; - for i in 0..n_items { - // item 0 is answered correctly by everyone; the rest vary - y[p * n_items + i] = if i == 0 { 1 } else { ((p / (i + 1)) % 2) as u8 }; - } - } - let rows = logistic_dif(&y, &group, n, n_items, &LogisticDifConfig::default()).unwrap(); - let r0 = &rows[0]; - assert!( - !r0.converged, - "constant item should not report a converged fit" - ); - assert!(r0.chi2_total.is_nan() && r0.delta_r2.is_nan()); - // The p-values must be NaN too, NOT 1.0: chi2_sf maps a NaN statistic to 1.0 (f64::max ignores - // NaN), which would read as "definitively no DIF" and, being finite, would make - // Benjamini-Hochberg count this unfittable item in `m` and dilute every other item's threshold. - assert!( - r0.p_total.is_nan() && r0.p_uniform.is_nan() && r0.p_nonuniform.is_nan(), - "failed fit reported p_total {} (expected NaN)", - r0.p_total - ); - assert_eq!(r0.jg_class, EtsClass::Undefined); - assert!(!r0.flagged_bh, "an undefined item must never be BH-flagged"); - // validation is shared with the MH path - let cfg_bad = LogisticDifConfig { - fdr_q: 0.0, - ..LogisticDifConfig::default() - }; - assert!(logistic_dif(&y, &group, n, n_items, &cfg_bad).is_err()); - let cfg_it = LogisticDifConfig { - max_iter: 0, - ..LogisticDifConfig::default() - }; - assert!(logistic_dif(&y, &group, n, n_items, &cfg_it).is_err()); - assert!(logistic_dif(&y, &vec![0u8; n], n, n_items, &LogisticDifConfig::default()).is_err()); -} - -#[test] -fn logistic_private_failures_and_rest_score_path_are_explicit() { - assert!(logit_fit(&[f64::NAN], &[1.0], 1, 1, &[1.0], 1).is_none()); - assert!(logit_fit(&[1.0, 1.0], &[0.0, 1.0], 2, 1, &[0.0], 0).is_none()); - - let x = vec![1.0; 20]; - let y: Vec = (0..20).map(|index| (index % 2) as f64).collect(); - let bounded = logit_fit(&x, &y, 20, 1, &[29.9], 1); - assert!(bounded.is_none() || bounded.unwrap().0[0].abs() <= LOGIT_COEF_BOUND); - assert!(logit_fit(&[0.1; 20], &[1.0; 20], 20, 1, &[31.0], 1).is_none()); - - assert!(!logistic_item_stats(&[0.0; 19], &[0.0; 19], &[0.0; 19], 19, 50).converged); - let response: Vec = (0..20).map(|index| (index % 2) as f64).collect(); - assert!(!logistic_item_stats(&response, &[0.0; 20], &[0.0; 20], 20, 50).converged); - let score = response.clone(); - let group: Vec = (0..20).map(|index| ((index / 2) % 2) as f64).collect(); - assert!(!logistic_item_stats(&response, &score, &group, 20, 50).converged); - assert!( - !logistic_item_stats( - &response, - &(0..20).map(|v| v as f64).collect::>(), - &group, - 20, - 0 - ) - .converged - ); - - let n = 40; - let n_items = 2; - let responses: Vec = (0..n * n_items) - .map(|index| ((index / n_items + index % n_items) % 2) as u8) - .collect(); - let groups: Vec = (0..n).map(|index| (index % 2) as u8).collect(); - let rows = logistic_dif( - &responses, - &groups, - n, - n_items, - &LogisticDifConfig { - exclude_studied_item: true, - ..LogisticDifConfig::default() - }, - ) - .unwrap(); - assert_eq!(rows.len(), n_items); - - let score: Vec = (0..40).map(|index| (index % 2) as f64).collect(); - let group: Vec = (0..40).map(|index| ((index / 2) % 2) as f64).collect(); - let group_separated = group.clone(); - assert!(!logistic_item_stats(&group_separated, &score, &group, 40, 50).converged); - - let interaction_separated: Vec = score - .iter() - .zip(&group) - .map(|(score, group)| if score == group { 1.0 } else { 0.0 }) - .collect(); - assert!(!logistic_item_stats(&interaction_separated, &score, &group, 40, 50).converged); -} - -// ---------------- iterative item purification ---------------- - -/// Build a seeded bank whose `dif_items` are shifted UNIDIRECTIONALLY against the focal group. -/// The direction matters: bidirectional shifts cancel in the number-correct total and produce no -/// criterion contamination at all, which would make the whole fixture vacuous. -fn purification_bank( - n: usize, - n_items: usize, - dif_items: &[usize], - shift: f64, - seed: u64, -) -> (Vec, Vec) { - let mut rng = Lcg(seed); - let b: Vec = (0..n_items).map(|i| -0.9 + 0.16 * i as f64).collect(); - let mut y = vec![0u8; n * n_items]; - let mut group = vec![0u8; n]; - for p in 0..n { - let g = (p % 2) as u8; - group[p] = g; - let theta = rng.normal(); // identical ability distributions in both groups - for i in 0..n_items { - let mut bi = b[i]; - if g == 1 && dif_items.contains(&i) { - bi += shift; // every planted item is harder for the SAME group - } - let pr = 1.0 / (1.0 + (-(1.2 * (theta - bi))).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - (y, group) -} - -/// SEEDED REGRESSION FIXTURE (not a general property of purification): with several items shifted -/// against the focal group, the unpurified number-correct criterion is depressed for that group, so -/// CLEAN items pick up spurious DIF. Rebuilding the criterion from the unflagged anchor reduces -/// those false flags while the planted items stay flagged. The precondition is asserted first — -/// without it the test would pass trivially on a simulation that produced no contamination. -#[test] -fn purification_reduces_criterion_contamination_false_flags() { - let (n, n_items) = (3000usize, 12usize); - let dif_items = [2usize, 5, 8]; - let (y, group) = purification_bank(n, n_items, &dif_items, 1.2, 0x9F1E2); - let cfg = MhDifConfig::default(); - let plain = mantel_haenszel_dif(&y, &group, n, n_items, &cfg).unwrap(); - let pur = mantel_haenszel_dif_purified(&y, &group, n, n_items, &cfg, &PurifyConfig::default()) - .unwrap(); - - let clean: Vec = (0..n_items).filter(|i| !dif_items.contains(i)).collect(); - let false_before: Vec = clean - .iter() - .copied() - .filter(|&j| plain[j].ets_class != EtsClass::A) - .collect(); - // PRECONDITION: the fixture must actually exhibit contamination, else nothing is being tested. - assert!( - !false_before.is_empty(), - "fixture precondition failed: no clean item false-flagged. classes={:?} deltas={:?}", - plain.iter().map(|r| r.ets_class).collect::>(), - plain - .iter() - .map(|r| (r.mh_d_dif * 100.0).round() / 100.0) - .collect::>() - ); - let false_after: Vec = clean - .iter() - .copied() - .filter(|&j| pur.rows[j].ets_class != EtsClass::A) - .collect(); - assert!( - false_after.len() < false_before.len(), - "purification did not reduce false flags: {false_before:?} -> {false_after:?}; \ - rounds={} n_anchor={} classes={:?} deltas={:?}", - pur.rounds, - pur.n_anchor, - plain.iter().map(|r| r.ets_class).collect::>(), - plain - .iter() - .map(|r| (r.mh_d_dif * 100.0).round() / 100.0) - .collect::>() - ); - // TRUE POSITIVES retained - otherwise "removes false flags" is satisfiable by flagging nothing. - for &d in &dif_items { - assert!( - matches!(pur.rows[d].ets_class, EtsClass::B | EtsClass::C), - "planted item {d} lost after purification: {:?}", - pur.rows[d].ets_class - ); - assert!(!pur.anchor[d], "planted item {d} left in the anchor"); - } - // The criterion genuinely changed: at least one clean item's statistic moved. Without this an - // implementation that simply returned the unpurified rows would pass everything above. - assert!( - clean - .iter() - .any(|&j| (pur.rows[j].chi2_mh - plain[j].chi2_mh).abs() > 1e-6), - "purified statistics identical to the unpurified sweep" - ); - assert!(pur.rounds >= 1 && pur.n_anchor == n_items - dif_items.len()); -} - -/// A clean bank needs no purification: nothing is flagged, so the anchor stays the whole test, no -/// rounds run, and round 0 reproduces the shipped unpurified sweep EXACTLY (pinning the refactor's -/// no-op path - `anchor = None` and an all-true anchor must agree bit for bit). -#[test] -fn purification_is_a_no_op_on_a_clean_bank() { - let (n, n_items) = (2000usize, 10usize); - let (y, group) = purification_bank(n, n_items, &[], 0.0, 0x5AFE); - let cfg = MhDifConfig::default(); - let plain = mantel_haenszel_dif(&y, &group, n, n_items, &cfg).unwrap(); - let pur = mantel_haenszel_dif_purified(&y, &group, n, n_items, &cfg, &PurifyConfig::default()) - .unwrap(); - assert!(pur.converged && pur.rounds == 0); - assert!(pur.anchor.iter().all(|&a| a) && pur.n_anchor == n_items); - for i in 0..n_items { - assert_eq!( - pur.rows[i].chi2_mh, plain[i].chi2_mh, - "round 0 must equal the shipped sweep exactly at item {i}" - ); - assert_eq!(pur.rows[i].alpha_mh, plain[i].alpha_mh); - assert_eq!(pur.rows[i].ets_class, plain[i].ets_class); - } -} - -/// The round cap is observable: with `max_rounds = 1` on a bank that is still changing, the loop -/// stops after one purification round and reports `converged = false` with the round-1 rows. -#[test] -fn purification_round_cap_reports_non_convergence() { - let (n, n_items) = (3000usize, 12usize); - let dif_items = [2usize, 5, 8]; - let (y, group) = purification_bank(n, n_items, &dif_items, 1.2, 0x9F1E2); - let cfg = MhDifConfig::default(); - let capped = mantel_haenszel_dif_purified( - &y, - &group, - n, - n_items, - &cfg, - &PurifyConfig { - max_rounds: 1, - ..PurifyConfig::default() - }, - ) - .unwrap(); - assert_eq!( - capped.rounds, 1, - "rounds={} converged={} n_anchor={} anchor={:?}", - capped.rounds, capped.converged, capped.n_anchor, capped.anchor - ); - assert!( - !capped.converged, - "hitting the round cap must report converged = false" - ); - assert_eq!(capped.termination_reason, "max_rounds_reached"); - // max_rounds = 0 is rejected rather than silently meaning "no purification" - assert!(mantel_haenszel_dif_purified( - &y, - &group, - n, - n_items, - &cfg, - &PurifyConfig { - max_rounds: 0, - ..PurifyConfig::default() - } - ) - .is_err()); -} - -/// The anchor guard fires BEFORE sweeping on a uselessly short criterion: with `min_anchor_items` -/// set above what the flagged set leaves, purification stops and returns the last usable rows with -/// `converged = false` rather than matching on a near-empty anchor. Also exercises the logistic -/// variant of the purified entry point. -#[test] -fn purification_stops_on_a_too_short_anchor() { - let (n, n_items) = (3000usize, 12usize); - let dif_items = [2usize, 5, 8]; - let (y, group) = purification_bank(n, n_items, &dif_items, 1.2, 0x9F1E2); - let strict = PurifyConfig { - max_rounds: 3, - min_anchor_items: n_items, - }; - let pur = - mantel_haenszel_dif_purified(&y, &group, n, n_items, &MhDifConfig::default(), &strict) - .unwrap(); - // the guard tripped immediately: no round ran, the anchor is still the full test - assert_eq!(pur.rounds, 0); - assert!(!pur.converged && pur.n_anchor == n_items); - assert_eq!(pur.termination_reason, "insufficient_anchor_items"); - // the logistic purified entry point runs and removes the planted items from its anchor - let lp = logistic_dif_purified( - &y, - &group, - n, - n_items, - &LogisticDifConfig::default(), - &PurifyConfig::default(), - ) - .unwrap(); - assert!(lp.n_anchor <= n_items); - for &d in &dif_items { - assert!( - !lp.anchor[d], - "logistic purification left planted item {d} in the anchor" - ); - } -} - -/// STRUCTURAL ANCHOR for the returned rows: `rows` must be the sweep against the REPORTED `anchor`, -/// on every exit path. Returning an earlier round's rows while reporting the final anchor is the -/// highest-severity failure mode of a purification loop and is invisible to a "did it flag the right -/// items" test, because the intermediate rounds usually flag the same items. Swept over round caps, -/// anchor floors and BOTH matching conventions so the `exclude_studied_item = true` branch of -/// [`matching_for_item`] — untested by the fixtures above — is covered here. -#[test] -fn purified_rows_are_the_sweep_against_the_reported_anchor() { - let (n, n_items) = (1200usize, 12usize); - let (y, group) = purification_bank(n, n_items, &[2, 5, 8], 1.2, 0x51A7); - let mut seen_purified_round = false; - for exclude_studied_item in [false, true] { - let cfg = MhDifConfig { - exclude_studied_item, - ..MhDifConfig::default() - }; - for max_rounds in [1usize, 2, 5] { - for min_anchor_items in [1usize, 4, 9] { - let purify = PurifyConfig { - max_rounds, - min_anchor_items, - }; - let res = - mantel_haenszel_dif_purified(&y, &group, n, n_items, &cfg, &purify).unwrap(); - seen_purified_round |= res.rounds > 0; - // A fresh sweep against the reported anchor must reproduce the reported rows. - let refr = mh_sweep(&y, &group, n, n_items, &cfg, Some(&res.anchor)).unwrap(); - for i in 0..n_items { - assert_eq!( - res.rows[i].chi2_mh, refr[i].chi2_mh, - "item {i}: rows do not match the reported anchor \ - (exclude={exclude_studied_item} max_rounds={max_rounds} \ - min_anchor={min_anchor_items} rounds={} n_anchor={})", - res.rounds, res.n_anchor - ); - assert_eq!(res.rows[i].mh_d_dif, refr[i].mh_d_dif, "item {i} d-DIF"); - } - assert_eq!(res.n_anchor, res.anchor.iter().filter(|&&a| a).count()); - } - } - } - // Guard the guard: if no configuration ever purified, the assertions above are vacuous. - assert!( - seen_purified_round, - "no configuration performed a purification round" - ); -} - -/// VALUE ANCHOR for the criterion itself, against an independent reference rather than against the -/// implementation's own arithmetic. Purification matches every item on `anchor UNION {studied}`, so -/// the purified row for item `i` must equal the ORDINARY unpurified sweep run on a test consisting of -/// exactly those columns. Checked for both a non-anchor item (the add-back branch) and an anchor item -/// (no add-back), with a deliberately NON-CONTIGUOUS anchor so an index-map or layout error cannot -/// hide behind a prefix. This is what fails if the add-back is dropped, doubled, or applied to the -/// wrong branch — none of which the flag-counting fixtures can see. -#[test] -fn purified_item_is_matched_on_the_anchor_union_itself() { - let (n, n_items) = (1500usize, 10usize); - let (y, group) = purification_bank(n, n_items, &[3], 1.0, 0x2C4B); - // scattered anchor: items 1, 4, 6, 9 are OUT - let anchor: Vec = (0..n_items).map(|i| !matches!(i, 1 | 4 | 6 | 9)).collect(); - for exclude_studied_item in [false, true] { - let cfg = MhDifConfig { - exclude_studied_item, - ..MhDifConfig::default() - }; - let swept = mh_sweep(&y, &group, n, n_items, &cfg, Some(&anchor)).unwrap(); - for studied in [4usize, 5] { - // columns of the reference test: anchor UNION {studied}, original order preserved - let cols: Vec = (0..n_items) - .filter(|&j| anchor[j] || j == studied) - .collect(); - let pos = cols.iter().position(|&j| j == studied).unwrap(); - let mut reduced = vec![0u8; n * cols.len()]; - for p in 0..n { - for (c, &j) in cols.iter().enumerate() { - reduced[p * cols.len() + c] = y[p * n_items + j]; - } - } - let refr = mantel_haenszel_dif(&reduced, &group, n, cols.len(), &cfg).unwrap(); - let (a, b) = (&swept[studied], &refr[pos]); - assert_eq!( - a.chi2_mh, b.chi2_mh, - "item {studied} (in_anchor={}, exclude={exclude_studied_item}) is not matched on \ - anchor UNION itself", - anchor[studied] - ); - assert_eq!(a.alpha_mh, b.alpha_mh, "item {studied} alpha_MH"); - assert_eq!(a.mh_d_dif, b.mh_d_dif, "item {studied} ETS delta"); - assert_eq!(a.std_p_dif, b.std_p_dif, "item {studied} STD P-DIF"); - assert_eq!(a.ets_class, b.ets_class, "item {studied} ETS class"); - } - } -} - -/// The anchor rule is PRACTICAL significance, not `class != A`. `Undefined` is also `!= A`, so the -/// lazier predicate would purge unfittable items — which carry no evidence of DIF — and shrink the -/// anchor for free. No simulated bank distinguishes the two (a clean 2PL never produces `Undefined`), -/// so the predicate is pinned directly. -#[test] -fn purify_flagged_is_practical_significance_not_just_non_a() { - assert!(!purify_flagged(EtsClass::A)); - assert!(purify_flagged(EtsClass::B)); - assert!(purify_flagged(EtsClass::C)); - assert!( - !purify_flagged(EtsClass::Undefined), - "an unfittable item carries no evidence of DIF and must stay in the anchor" - ); -} - -// ---------------- SIBTEST (uniform) ---------------- - -/// Build one item's per-level cells from `(level, J_R, sum_y_R, J_F, sum_y_F)`. Responses are 0/1, so -/// the raw second moment equals the first -- the core still computes its own variance from these raw -/// moments, which keeps the biased-vs-unbiased mutation live. -fn sib_cells(rows: &[(usize, u64, u64, u64, u64)]) -> Vec { - rows.iter() - .map(|&(level, j_r, sy_r, j_f, sy_f)| SibCell { - level, - j_r, - sum_y_r: sy_r as f64, - sum_y2_r: sy_r as f64, - j_f, - sum_y_f: sy_f as f64, - sum_y2_f: sy_f as f64, - }) - .collect() -} - -/// CLOSED-FORM ACCEPTANCE ANCHOR. Every constant was derived from the estimator in exact rational -/// arithmetic and re-derived independently before this test was written; nothing here is a -/// record-what-it-printed baseline. With `Xbar_R = 2.4` and `Xbar_F = 1.8`, the level-2 slopes come out -/// `M_R = 2.0` and `M_F = 8.0` -- the focal group's smaller alpha compresses its true-score scale, so -/// the same rise in `Ybar` across the same observed span implies a four times steeper slope -- and the -/// corrected means are `0.44` and `0.54`, giving `beta = -1/10` and `sigma^2 = 23/1950`. -/// -/// kills: correction deleted [+0.20]; correction sign flipped [+0.50]; subtracting the OBSERVED mean -/// instead of `V*_Gk` [+0.26]; the midpoint replaced by each group's own `V*` [+0.20]; Kelley written -/// as `(1 - alpha)` [-0.25]; a pooled alpha in the `M` denominator [+0.008]; biased cell variance -/// [se = 0.107238053]; the slope taken over retained levels only [NaN]. -#[test] -fn sibtest_closed_form_single_stratum_anchor() { - let cells = sib_cells(&[(1, 10, 1, 40, 2), (2, 40, 20, 40, 12), (3, 50, 45, 20, 17)]); - let st = sibtest_stats(&cells, 0.8, 0.2, 4, 5); - assert_eq!(st.n_strata_used, 1, "only level 2 is interior and well-populated"); - assert!((st.beta_uni - (-0.10)).abs() < 1e-12, "beta_uni = {}", st.beta_uni); - assert!((st.se_beta - 0.1086041978694737).abs() < 1e-12, "se = {}", st.se_beta); - assert!((st.b_uni - (-0.920774721067277)).abs() < 1e-12, "b_uni = {}", st.b_uni); - assert!((st.b_uni * st.b_uni - 39.0 / 46.0).abs() < 1e-12, "X2 must be 39/46"); - assert!((st.p_value - 0.35716805550697844).abs() < 1e-12, "p = {}", st.p_value); -} - -/// MULTI-LEVEL WEIGHTING ANCHOR. The single-stratum anchor above is structurally blind to every -/// weighting question, because one retained level always carries weight 1. Here level 0 fails `j_min` -/// and level 4 is the maximum, so the retained weights are `(1/4, 2/5, 7/20)` and must sum to exactly 1 -/// after renormalization. -/// -/// The UNCORRECTED beta is `+0.11` under BOTH weighting schemes, so the same assertion made on an -/// uncorrected statistic would prove nothing; it is asserted on the corrected value. -/// -/// kills: focal-group weights instead of combined-sample [-0.039932]; weights left unrenormalized; the -/// `j_min` gate dropped; the min/max exclusion dropped; renormalizing `beta` but not `se` (this pins -/// `X^2`, which is invariant to renormalization only when BOTH are scaled together). -#[test] -fn sibtest_multi_level_weighting_anchor() { - let cells = sib_cells(&[ - (0, 4, 0, 3, 0), - (1, 10, 1, 40, 2), - (2, 40, 20, 40, 12), - (3, 50, 45, 20, 17), - (4, 6, 6, 7, 7), - ]); - let st = sibtest_stats(&cells, 0.8, 0.2, 4, 5); - assert_eq!(st.n_strata_used, 3); - assert!( - (st.beta_uni - (-16993.0 / 88000.0)).abs() < 1e-12, - "beta_uni = {} (expected -16993/88000)", - st.beta_uni - ); - assert!((st.se_beta - 0.06029378704091735).abs() < 1e-12, "se = {}", st.se_beta); - assert!( - (st.b_uni * st.b_uni - 10.257219401952296).abs() < 1e-9, - "X2 = {}", - st.b_uni * st.b_uni - ); -} - -/// NON-CONTIGUOUS LEVELS. The central difference spans the adjacent OBSERVED positions, whose levels -/// here differ by 4 rather than 2 because levels 2 and 3 have no examinees at all. A slope built from a -/// hardcoded `2 * alpha / n_valid` denominator is exactly twice as steep, which is asserted directly. -/// -/// kills: a hardcoded `2 * alpha / n_valid` denominator; arithmetic `k +/- 1` indexing instead of -/// positional; a dense `0..=n_valid` level vector, which would fabricate the empty interior levels. -#[test] -fn sibtest_uses_observed_level_spacing_not_arithmetic_neighbours() { - let cells = sib_cells(&[ - (0, 20, 2, 20, 3), - (1, 30, 9, 30, 12), - (4, 30, 24, 30, 21), - (5, 20, 18, 20, 17), - ]); - let st = sibtest_stats(&cells, 0.75, 0.5, 6, 5); - assert_eq!(st.n_strata_used, 2, "levels 1 and 4 are the interior positions"); - // Pinned to the IMPLEMENTATION's output, in exact rational arithmetic: beta = 1/128. Both mutants - // named above return 1/64 -- exactly double, because they divide the slope by a span of 2 where - // the observed span is 4. An assertion computed from test-local arithmetic instead of from `st` - // would be an identity about `f64` and would pass for ANY implementation; this one cannot. - assert!( - (st.beta_uni - 1.0 / 128.0).abs() < 1e-12, - "beta_uni = {} (expected 1/128; the contiguous-span mutants give 1/64)", - st.beta_uni - ); -} - -/// STRICT-INEQUALITY BOUNDARY on `j_min`, tested on BOTH sides of the conjunction: level 1 sits at -/// exactly `j_min` in the REFERENCE group, level 2 at exactly `j_min` in the FOCAL group, and level 3 -/// at `j_min + 1` in both. Only level 3 may survive. A one-sided fixture would let the untested half of -/// the gate be weakened or deleted outright. -/// -/// kills: `>=` written where `>` is meant, in EITHER group's count gate; the focal-group conjunct -/// dropped entirely. -#[test] -fn sibtest_j_min_is_strictly_exceeded_in_both_groups() { - let cells = sib_cells(&[ - (0, 20, 2, 20, 3), - (1, 5, 2, 20, 6), - (2, 20, 8, 5, 2), - (3, 6, 4, 6, 3), - (4, 20, 18, 20, 17), - ]); - let st = sibtest_stats(&cells, 0.8, 0.8, 5, 5); - assert_eq!( - st.n_strata_used, 1, - "levels 1 (J_R == j_min) and 2 (J_F == j_min) must be excluded; only level 3 (j_min + 1 in \ - both) may survive" - ); -} - -/// ASYMMETRIC NEIGHBOUR GUARD, and a documented DIVERGENCE from the reference implementation: it -/// imputes an absent group-by-level cell's mean to 0.0 and feeds that fabricated zero into the -/// neighbouring central difference, producing a finite but meaningless slope. This implementation drops -/// the level. The divergence is asserted so it cannot silently drift back. -/// -/// kills: inheriting the NaN-to-zero imputation, which would retain the level and report a number. -#[test] -fn sibtest_drops_levels_whose_neighbour_lacks_a_group() { - let full = sib_cells(&[ - (0, 20, 2, 20, 3), - (1, 30, 9, 30, 12), - (2, 30, 24, 30, 21), - (3, 20, 18, 20, 17), - ]); - assert_eq!(sibtest_stats(&full, 0.8, 0.8, 4, 5).n_strata_used, 2); - - // The guard is a four-way conjunction (lower/upper x reference/focal). Testing one corner would - // leave the other three deletable, so every corner is holed in turn. - let base = [(0usize, 20u64, 2u64, 20u64, 3u64), (1, 30, 9, 30, 12), (2, 30, 24, 30, 21), (3, 20, 18, 20, 17)]; - for (pos, which, dropped) in [ - (0usize, "reference", 1usize), - (0, "focal", 1), - (3, "reference", 2), - (3, "focal", 2), - ] { - let mut rows = base; - if which == "reference" { - rows[pos].1 = 0; - rows[pos].2 = 0; - } else { - rows[pos].3 = 0; - rows[pos].4 = 0; - } - let st = sibtest_stats(&sib_cells(&rows), 0.8, 0.8, 4, 5); - assert_eq!( - st.n_strata_used, 1, - "emptying the {which} group at position {pos} must drop interior level {dropped}" - ); - } -} - -/// ALPHA GATE. The correction divides by the reliability through the local slope, so a non-positive or -/// non-finite alpha makes the statistic meaningless and must yield a NaN row rather than a clamp. -/// -/// kills: a gate written only against NaN, which misses `alpha <= 0`; `alpha != 0` in place of -/// `alpha > 0`. -#[test] -fn sibtest_rejects_degenerate_reliability() { - let cells = sib_cells(&[ - (0, 20, 2, 20, 3), - (1, 30, 9, 30, 12), - (2, 30, 24, 30, 21), - (3, 20, 18, 20, 17), - ]); - for (ar, af, why) in [ - (-0.3, 0.8, "negative reference alpha"), - (0.8, -0.3, "negative focal alpha"), - (0.0, 0.8, "zero alpha is degenerate, not merely small"), - (f64::NAN, 0.8, "non-finite alpha"), - ] { - let st = sibtest_stats(&cells, ar, af, 4, 5); - assert!(st.beta_uni.is_nan(), "{why}: beta must be NaN"); - assert!(st.p_value.is_nan(), "{why}: p must be NaN, never 1.0"); - assert_eq!(st.n_strata_used, 0, "{why}"); - } -} - -/// The undefined contract is NaN, never a zero-initialized accumulator and never `p = 1.0`. A `0.0` -/// beta reads as an affirmative "no DIF" claim, and a FINITE `p = 1.0` would be counted by -/// Benjamini-Hochberg, shrinking every other item's threshold. -/// -/// kills: `return 0.0` on the degenerate path -- an `.abs() < eps` assertion would pass that mutant, so -/// `is_nan` is asserted explicitly; a dropped `is_finite` guard before squaring `b_uni`. -#[test] -fn sibtest_undefined_is_nan_not_zero_or_one() { - let st = sibtest_stats(&sib_cells(&[(0, 30, 3, 30, 4), (1, 30, 20, 30, 18)]), 0.8, 0.8, 4, 5); - assert!(st.beta_uni.is_nan() && st.se_beta.is_nan() && st.b_uni.is_nan()); - assert!(st.p_value.is_nan(), "must not collapse to 1.0"); - assert_eq!(st.n_strata_used, 0); -} - -/// Seeded 2PL bank with UNEQUAL group sizes and non-mirrored difficulties. Deliberately not 50/50 and -/// deliberately not symmetric: a balanced, mirrored fixture cancels sign errors. -fn sibtest_bank( - n_ref: usize, - n_focal: usize, - n_items: usize, - dif_items: &[usize], - shift: f64, - impact: f64, - seed: u64, -) -> (Vec, Vec) { - let mut rng = Lcg(seed); - let n = n_ref + n_focal; - let b: Vec = (0..n_items).map(|i| -1.1 + 0.23 * i as f64).collect(); - let mut y = vec![0u8; n * n_items]; - let mut group = vec![0u8; n]; - for p in 0..n { - let g = if p < n_ref { 0u8 } else { 1u8 }; - group[p] = g; - // `impact` is a genuine ability difference, not DIF: it shifts the focal ability distribution. - let theta = rng.normal() - if g == 1 { impact } else { 0.0 }; - for i in 0..n_items { - let mut bi = b[i]; - if g == 1 && dif_items.contains(&i) { - bi += shift; // harder for the focal group - } - let pr = 1.0 / (1.0 + (-(1.3 * (theta - bi))).exp()); - y[p * n_items + i] = u8::from(rng.next_f64() < pr); - } - } - (y, group) -} - -/// STUDIED-RESPONSE LEAK. SIBTEST's valid subtest and studied subtest are DISJOINT by construction, so -/// item `i`'s own response must not enter item `i`'s matching criterion at all. -/// -/// The exact invariant is NOT that item `i`'s row is unchanged -- flipping `y_i` to `1 - y_i` sends -/// every conditional mean `Ybar_Gk` to `1 - Ybar_Gk` and every slope `M_G` to `-M_G`, so the -/// transported mean becomes `1 - Ybar*_Gk` and the DIFFERENCE, hence `beta_uni`, is exactly NEGATED -/// while `se_beta` is invariant (a Bernoulli variance is symmetric under complement). That is what is -/// asserted, and it is strictly stronger than an integer stratum-count comparison: it pins the whole -/// real-valued statistic rather than a coarse proxy. If the studied item leaked into its own criterion -/// the strata themselves would move and neither identity would hold. -/// -/// The second block -- some OTHER item must react -- is what stops a constant or saturated criterion -/// from passing vacuously, since a criterion that ignores the data is also flip-invariant. -/// -/// kills: the item-included Mantel-Haenszel convention reused by mistake; the studied item added back -/// into its own valid subtest; a criterion that ignores the responses entirely. -#[test] -fn sibtest_criterion_excludes_the_studied_item() { - let (y, group) = sibtest_bank(400, 250, 7, &[3], 0.9, 0.0, 0x5B1); - let cfg = SibtestConfig::default(); - let base_rows = sibtest(&y, &group, 650, 7, &cfg).unwrap(); - for i in 0..7 { - let mut flipped = y.clone(); - for p in 0..650 { - flipped[p * 7 + i] = 1 - flipped[p * 7 + i]; - } - let rows = sibtest(&flipped, &group, 650, 7, &cfg).unwrap(); - assert_eq!( - rows[i].n_strata_used, base_rows[i].n_strata_used, - "item {i}: flipping its own column changed its own stratification" - ); - assert!( - (rows[i].beta_uni + base_rows[i].beta_uni).abs() < 1e-12, - "item {i}: complementing the studied response must NEGATE beta_uni exactly ({} vs {})", - base_rows[i].beta_uni, - rows[i].beta_uni - ); - assert!( - (rows[i].se_beta - base_rows[i].se_beta).abs() < 1e-12, - "item {i}: se_beta must be invariant under complementing the studied response" - ); - // ...but at least one OTHER item must react, or the criterion is not reading the data at all - assert!( - (0..7).any(|j| j != i && rows[j].beta_uni != base_rows[j].beta_uni), - "item {i}: flipping it changed no other item, so the criterion is degenerate" - ); - } -} - -/// CROSS-MODULE SIGN ANCHOR, in both directions. `beta_uni` is reference-minus-focal while `mh_d_dif` -/// and `std_p_dif` are focal-oriented, so on an item that is harder for the focal group SIBTEST goes -/// POSITIVE where both Mantel-Haenszel statistics go NEGATIVE. Swapping the group labels must flip all -/// of them, and the product assertion is what would catch a future "harmonisation" that flips both -/// modules at once and so preserves every single-module assertion. -/// -/// kills: a sign flip in either module; an `abs()` collapse (caught by the mirror block); a -/// simultaneous flip of both conventions (caught by the product). -#[test] -fn sibtest_sign_is_opposite_to_mantel_haenszel() { - let (y, group) = sibtest_bank(420, 300, 8, &[4], 1.1, 0.0, 0x7C3); - let sib = sibtest(&y, &group, 720, 8, &SibtestConfig::default()).unwrap(); - let mh = mantel_haenszel_dif(&y, &group, 720, 8, &MhDifConfig::default()).unwrap(); - assert!(sib[4].beta_uni > 0.0, "beta_uni = {} (must be > 0)", sib[4].beta_uni); - assert!(mh[4].mh_d_dif < 0.0, "mh_d_dif = {}", mh[4].mh_d_dif); - assert!(mh[4].std_p_dif < 0.0, "std_p_dif = {}", mh[4].std_p_dif); - assert!( - sib[4].beta_uni * mh[4].std_p_dif < 0.0, - "the two modules must keep OPPOSITE orientations" - ); - - // Mirror: swapping the labels flips the sign and (to numerical noise) the magnitude is preserved. - let swapped: Vec = group.iter().map(|&g| 1 - g).collect(); - let sib_sw = sibtest(&y, &swapped, 720, 8, &SibtestConfig::default()).unwrap(); - assert!(sib_sw[4].beta_uni < 0.0, "swapping labels must flip beta_uni"); - assert!( - (sib_sw[4].beta_uni + sib[4].beta_uni).abs() < 1e-9, - "beta_uni must be antisymmetric in the group labels: {} vs {}", - sib[4].beta_uni, - sib_sw[4].beta_uni - ); -} - -/// COEFFICIENT ALPHA is computed PER GROUP and on the VALID subtest only (which is a different item set -/// for every studied item). Pinned against the direct KR-20 definition recomputed here from the raw -/// matrix, on a fixture whose groups have deliberately different reliabilities. -/// -/// kills: a single POOLED alpha, which survives every fixture whose groups happen to be equally -/// reliable; `n_items` substituted for `n_valid` in the `k/(k-1)` factor; alpha computed over all items -/// including the studied one. -#[test] -fn sibtest_alpha_is_per_group_on_the_valid_subtest() { - // the focal group is given a much larger ability spread, so its alpha differs materially - let (y, group) = sibtest_bank(500, 400, 6, &[], 0.0, 1.4, 0x2E9); - let (n, n_items) = (900usize, 6usize); - let rows = sibtest(&y, &group, n, n_items, &SibtestConfig::default()).unwrap(); - - for studied in [0usize, 3, 5] { - for (g, reported) in [(0u8, rows[studied].alpha_ref), (1u8, rows[studied].alpha_focal)] { - let idx: Vec = (0..n).filter(|&p| group[p] == g).collect(); - let ng = idx.len() as f64; - let valid: Vec = (0..n_items).filter(|&j| j != studied).collect(); - let mut item_var_sum = 0.0; - for &j in &valid { - let m = idx.iter().map(|&p| y[p * n_items + j] as f64).sum::() / ng; - item_var_sum += m - m * m; // 0/1 item: E[y^2] = E[y] - } - let totals: Vec = idx - .iter() - .map(|&p| valid.iter().map(|&j| y[p * n_items + j] as f64).sum::()) - .collect(); - let tm = totals.iter().sum::() / ng; - let tv = totals.iter().map(|t| (t - tm) * (t - tm)).sum::() / ng; - let k = valid.len() as f64; - let expected = (k / (k - 1.0)) * (1.0 - item_var_sum / tv); - assert!( - (reported - expected).abs() < 1e-12, - "item {studied} group {g}: alpha {reported} != direct KR-20 {expected}" - ); - } - } - assert!( - (rows[0].alpha_ref - rows[0].alpha_focal).abs() > 0.05, - "fixture precondition: the two groups must differ in reliability, else a pooled alpha would \ - pass this test ({} vs {})", - rows[0].alpha_ref, - rows[0].alpha_focal - ); -} - -/// A degenerate item must produce a NaN row that is NOT Benjamini-Hochberg flagged and, critically, is -/// NOT counted in BH's `m`: a finite `p = 1.0` would silently shrink every other item's threshold. -/// -/// kills: zero-initialized accumulators reaching the row; an undefined row entering the flag path; a -/// NaN p-value collapsing to 1.0 through `chi2_sf`. -#[test] -fn sibtest_degenerate_item_is_nan_and_not_flagged() { - let (mut y, group) = sibtest_bank(380, 260, 5, &[2], 1.2, 0.0, 0x11D); - let n = 640usize; - for p in 0..n { - y[p * 5 + 4] = 1; // constant item: no within-level variance anywhere - } - let rows = sibtest(&y, &group, n, 5, &SibtestConfig::default()).unwrap(); - assert!(rows[4].beta_uni.is_nan(), "constant item must be NaN, not 0.0"); - assert!(rows[4].p_value.is_nan(), "constant item p must be NaN, not 1.0"); - assert!(!rows[4].flagged_bh, "an undefined row must never be flagged"); - assert_eq!(rows[4].n_strata_used, 0); - // the planted DIF item is still detected alongside it, and IS flagged -- the positive twin of the - // assertion above, without which the whole Benjamini-Hochberg block could be deleted and every - // test would still pass - assert!(rows[2].p_value.is_finite() && rows[2].p_value < 0.05, "p = {}", rows[2].p_value); - assert!(rows[2].flagged_bh, "the planted DIF item must be BH-flagged"); - // `fdr_q` is actually plumbed through rather than ignored. Asserted as NESTING plus a strict - // reduction, not as "a tiny q flags nothing": the planted item's p-value is around 1e-16, so it - // legitimately survives an arbitrarily small level, and a test demanding otherwise would be - // asserting a bug. - let strict = sibtest(&y, &group, n, 5, &SibtestConfig { fdr_q: 1e-9, ..SibtestConfig::default() }) - .unwrap(); - let (lax_n, strict_n) = ( - rows.iter().filter(|r| r.flagged_bh).count(), - strict.iter().filter(|r| r.flagged_bh).count(), - ); - assert!( - strict_n < lax_n, - "tightening fdr_q must flag strictly fewer items ({strict_n} vs {lax_n}); the configured \ - level is being ignored" - ); - assert!( - strict.iter().zip(&rows).all(|(s, r)| !s.flagged_bh || r.flagged_bh), - "the strict flag set must be nested inside the lax one" - ); - assert!(strict[2].flagged_bh, "p ~ 1e-16 survives any usable level"); -} - -/// MONTE-CARLO TYPE I, 500 replications per cell, no DIF planted so every rejection is a false -/// positive. This exists because the module note now makes a quantitative Type I CLAIM, and a claim in -/// the docs that nothing checks is how documentation rots. -/// -/// The finding is deliberately unflattering and is asserted as such: SIBTEST over-rejects, and by more -/// than Mantel-Haenszel under impact, because `se_beta` treats the estimated regression correction as -/// fixed. Asserted as loose bounds rather than point values so the test pins the DIRECTION of the -/// finding without becoming a seed-dependent tripwire. -#[test] -#[ignore = "500-replication Monte-Carlo; run explicitly"] -fn sibtest_type_i_error_exceeds_mantel_haenszel_under_impact() { - const REPS: usize = 500; - for (impact, n_ref, n_focal, n_items) in [(0.0f64, 1000usize, 1000usize, 5usize), (1.0, 1000, 1000, 5)] { - let (mut mh_fp, mut mh_tot, mut sib_fp, mut sib_tot) = (0usize, 0usize, 0usize, 0usize); - for rep in 0..REPS { - let (y, group) = - sibtest_bank(n_ref, n_focal, n_items, &[], 0.0, impact, 0xA000 + rep as u64); - let n = n_ref + n_focal; - let mh = mantel_haenszel_dif(&y, &group, n, n_items, &MhDifConfig::default()).unwrap(); - let sib = sibtest(&y, &group, n, n_items, &SibtestConfig::default()).unwrap(); - for r in &mh { - if r.p_value.is_finite() { - mh_tot += 1; - mh_fp += usize::from(r.p_value < 0.05); - } - } - for r in &sib { - if r.p_value.is_finite() { - sib_tot += 1; - sib_fp += usize::from(r.p_value < 0.05); - } - } - } - let (mh_rate, sib_rate) = (mh_fp as f64 / mh_tot as f64, sib_fp as f64 / sib_tot as f64); - println!("impact={impact}: MH type-I={mh_rate} SIBTEST type-I={sib_rate}"); - assert!( - (0.02..0.09).contains(&mh_rate), - "Mantel-Haenszel Type I drifted out of its documented band: {mh_rate}" - ); - assert!( - sib_rate > 0.05, - "SIBTEST is documented as over-rejecting; if this now holds nominal the docs are stale: \ - {sib_rate}" - ); - if impact > 0.0 { - assert!( - sib_rate > mh_rate, - "under impact SIBTEST is documented as over-rejecting MORE than Mantel-Haenszel: \ - {sib_rate} vs {mh_rate}" - ); - } - } -} - -/// Validation is non-vacuous: each guard trips on its own. -#[test] -fn sibtest_validates() { - let (y, group) = sibtest_bank(60, 60, 4, &[], 0.0, 0.0, 0x99); - let ok = SibtestConfig::default(); - assert!(sibtest(&y, &group, 120, 4, &ok).is_ok()); - // the valid subtest needs >= 2 items, so a 2-item test cannot be swept - let (y2, g2) = sibtest_bank(60, 60, 2, &[], 0.0, 0.0, 0x99); - assert!(sibtest(&y2, &g2, 120, 2, &ok).is_err()); - // a within-level variance needs two examinees - assert!(sibtest(&y, &group, 120, 4, &SibtestConfig { j_min: 1, ..ok }).is_err()); - // shared boundary checks still apply - assert!(sibtest(&y, &group, 121, 4, &ok).is_err()); - assert!(sibtest(&y, &group, 120, 4, &SibtestConfig { fdr_q: 0.0, ..ok }).is_err()); -} - -// ===================== Raju (1988/1990) ICC area DIF ========================== -// -// MUTATION AUDIT (all asserts below read crate outputs from `raju_area`): -// - RAJU-M1 EXECUTED: drop the (1 - c) report scaling (`let scale = ... 1.0 - c[i]` -// -> `1.0`) => raju_3pl_common_c_scaling FAILs (h/se no longer 0.65x). -// - RAJU-M2 EXECUTED: flip a covariance cross-term sign -// (`+ 2.0 * g_ar * g_br * cov_ab_ref[i]` -> `- ...`) => raju_fd_variance_anchor -// FAILs (assembled se differs from the FD delta method). -// - RAJU-M3 EXECUTED: softplus -> identity (`2.0 * d / (af * ar) * l` -> `* y`) -// => raju_small_y_softplus_anchor FAILs (1.3203... vs 0.01). -// - RAJU-M4 EXECUTED: detection quantile alpha/2 -> alpha -// (`normal_upper_quantile(alpha / 2.0)` -> `(alpha)`) => raju_flag_threshold -// FAILs (z = 1.8 gets flagged at z_crit ~ 1.645). -// Disclosed identity limitation: the R/F swap test on the unsigned h is an -// invariance (|H| symmetric), so it cannot kill orientation mutations by -// itself; the discriminating anchors are the signed quadrature test (pins -// h = b_F - b_R against a numeric integral of P_R - P_F) and the pinned -// unsigned oracle vector. - -/// Signed orientation: crate h must equal both b_F - b_R and the numeric -/// quadrature of integral (P_R - P_F) dtheta under the documented ICC. -/// Asserts read: crate `h`, `z`, `p_value` (signed path). -#[test] -fn raju_signed_orientation_quadrature() { - let (af, bf, ar, br) = (1.2, 0.5, 0.8, -0.3); - let r = raju_area( - &[ar], - &[br], - &[0.1], - &[0.09], - &[0.0], - &[af], - &[bf], - &[0.11], - &[0.07], - &[0.0], - None, - true, - 0.05, - ) - .unwrap(); - assert!((r.h[0] - (bf - br)).abs() < 1e-12, "h = {}", r.h[0]); - // Trapezoid quadrature of (P_R - P_F) over [-40, 40]. - let (mut acc, step) = (0.0, 5e-4); - let m = (80.0 / step) as usize; - for k in 0..=m { - let th = -40.0 + k as f64 * step; - let pf = 1.0 / (1.0 + (-af * (th - bf)).exp()); - let pr = 1.0 / (1.0 + (-ar * (th - br)).exp()); - let w = if k == 0 || k == m { 0.5 } else { 1.0 }; - acc += w * (pr - pf) * step; - } - assert!( - (r.h[0] - acc).abs() < 1e-6, - "quadrature {acc} vs h {}", - r.h[0] - ); - let sd = (0.09f64 * 0.09 + 0.07 * 0.07).sqrt(); - assert!((r.z[0] - r.h[0] / sd).abs() < 1e-12); - assert!(r.p_value[0] > 0.0 && r.p_value[0] < 1.0); -} - -/// Unsigned closed form pinned to independently computed constants (NumPy, -/// matching the spec reviewer's quadrature); includes a crossing case where -/// UA > |SA| (kills an `abs(signed)` implementation) and quadrature agreement. -/// Asserts read: crate `h` (unsigned path). -#[test] -fn raju_unsigned_oracle() { - let a_f = [1.2, 0.8, 1.5, 0.6]; - let b_f = [0.5, 0.0, -1.0, 0.8]; - let a_r = [0.8, 1.7, 0.7, 1.4]; - let b_r = [-0.3, 0.9, 1.1, -0.4]; - let se = [0.1; 4]; - let cov = [0.0; 4]; - let r = raju_area( - &a_r, &b_r, &se, &se, &cov, &a_f, &b_f, &se, &se, &cov, None, false, 0.05, - ) - .unwrap(); - let expect = [ - 0.9140059278766983, - 1.2023709167732664, - 2.193856226242703, - 1.6756394651297617, - ]; - for i in 0..4 { - assert!( - (r.h[i] - expect[i]).abs() < 1e-9, - "item {i}: {} vs {}", - r.h[i], - expect[i] - ); - assert!(r.z[i] > 0.0 && r.p_value[i] < 1.0); - } - // Crossing case: unsigned area strictly exceeds |signed| = 0.8. - assert!(r.h[0] > 0.8 + 1e-3); - // Quadrature of |P_F - P_R| for item 0. - let (mut acc, step) = (0.0, 5e-4); - let m = (80.0 / step) as usize; - for k in 0..=m { - let th = -40.0 + k as f64 * step; - let pf = 1.0 / (1.0 + (-a_f[0] * (th - b_f[0])).exp()); - let pr = 1.0 / (1.0 + (-a_r[0] * (th - b_r[0])).exp()); - let w = if k == 0 || k == m { 0.5 } else { 1.0 }; - acc += w * (pf - pr).abs() * step; - } - assert!( - (r.h[0] - acc).abs() < 1e-6, - "quadrature {acc} vs {}", - r.h[0] - ); -} - -/// FD delta-method anchor: assembled Var must match finite differences of the -/// crate's own H surface (extracted via near-zero SEs) combined with the same -/// covariances — kills any wrong analytic partial (spec FLAG-2 class). -/// Asserts read: crate `se` and crate `h` at perturbed parameters. -#[test] -fn raju_fd_variance_anchor() { - let (af, bf, ar, br) = (1.2f64, 0.5, 0.8, -0.3); - let (se_af, se_bf, se_ar, se_br) = (0.11f64, 0.09, 0.13, 0.07); - let (cov_f, cov_r) = (0.004f64, -0.003); - let r = raju_area( - &[ar], - &[br], - &[se_ar], - &[se_br], - &[cov_r], - &[af], - &[bf], - &[se_af], - &[se_bf], - &[cov_f], - None, - false, - 0.05, - ) - .unwrap(); - // Independently assembled reference (NumPy, spec review): se = 0.1804448217. - assert!( - (r.se[0] - 0.1804448217314667).abs() < 1e-9, - "se {}", - r.se[0] - ); - // FD partials of the crate's |H| surface (H > 0 in this neighborhood). - let tiny = 1e-30; - let h_at = |af: f64, bf: f64, ar: f64, br: f64| -> f64 { - raju_area( - &[ar], - &[br], - &[tiny], - &[tiny], - &[0.0], - &[af], - &[bf], - &[tiny], - &[tiny], - &[0.0], - None, - false, - 0.05, - ) - .unwrap() - .h[0] - }; - let e = 1e-6; - let g_af = (h_at(af + e, bf, ar, br) - h_at(af - e, bf, ar, br)) / (2.0 * e); - let g_bf = (h_at(af, bf + e, ar, br) - h_at(af, bf - e, ar, br)) / (2.0 * e); - let g_ar = (h_at(af, bf, ar + e, br) - h_at(af, bf, ar - e, br)) / (2.0 * e); - let g_br = (h_at(af, bf, ar, br + e) - h_at(af, bf, ar, br - e)) / (2.0 * e); - let var_fd = g_af * g_af * se_af * se_af - + g_bf * g_bf * se_bf * se_bf - + 2.0 * g_af * g_bf * cov_f - + g_ar * g_ar * se_ar * se_ar - + g_br * g_br * se_br * se_br - + 2.0 * g_ar * g_br * cov_r; - assert!( - (r.se[0] - var_fd.sqrt()).abs() < 1e-6, - "crate se {} vs FD {}", - r.se[0], - var_fd.sqrt() - ); -} - -/// Equal-slope behavior from BOTH sides (Y -> +inf and Y -> -inf) plus the -/// exact fallback: |H| must be continuous at |delta| for every sign combo of -/// delta and (a_F - a_R) — kills sign(Y)-assumption and branch-sign mutations -/// (spec FLAG-1). Asserts read: crate `h`, `se` for each call. -#[test] -fn raju_equal_slope_both_sides() { - let se = [0.08f64]; - let cov = [0.0f64]; - for &delta in &[0.6f64, -0.6] { - for &eps in &[1e-9f64, -1e-9] { - let r = raju_area( - &[1.0], - &[0.0], - &se, - &se, - &cov, - &[1.0 + eps], - &[delta], - &se, - &se, - &cov, - None, - false, - 0.05, - ) - .unwrap(); - assert!( - (r.h[0] - delta.abs()).abs() < 1e-6, - "delta {delta} eps {eps}: h {}", - r.h[0] - ); - } - // Exact equality fallback: h = |delta| exactly, b-only variance. - let r = raju_area( - &[1.0], - &[0.0], - &se, - &se, - &cov, - &[1.0], - &[delta], - &se, - &se, - &cov, - None, - false, - 0.05, - ) - .unwrap(); - assert_eq!(r.h[0], delta.abs()); - assert!((r.se[0] - (2.0f64 * 0.08 * 0.08).sqrt()).abs() < 1e-15); - } -} - -/// Swapping reference and focal groups negates the signed h and z exactly and -/// leaves the unsigned h invariant. Asserts read: crate outputs of both calls. -#[test] -fn raju_swap_asymmetry() { - let (a1, b1, a2, b2) = ([1.2], [0.5], [0.8], [-0.3]); - let se = [0.1]; - let cov = [0.002]; - let fwd = raju_area( - &a2, &b2, &se, &se, &cov, &a1, &b1, &se, &se, &cov, None, true, 0.05, - ) - .unwrap(); - let rev = raju_area( - &a1, &b1, &se, &se, &cov, &a2, &b2, &se, &se, &cov, None, true, 0.05, - ) - .unwrap(); - assert_eq!(fwd.h[0], -rev.h[0]); - assert_eq!(fwd.z[0], -rev.z[0]); - let fwd_u = raju_area( - &a2, &b2, &se, &se, &cov, &a1, &b1, &se, &se, &cov, None, false, 0.05, - ) - .unwrap(); - let rev_u = raju_area( - &a1, &b1, &se, &se, &cov, &a2, &b2, &se, &se, &cov, None, false, 0.05, - ) - .unwrap(); - assert!((fwd_u.h[0] - rev_u.h[0]).abs() < 1e-12); -} - -/// 3PL common-c convention: h and se scale by (1 - c); z and p are invariant -/// to c (difR convention: Z from unscaled H / sH). Asserts read: crate `h`, -/// `se`, `z`, `p_value` of the 2PL and 3PL calls. -#[test] -fn raju_3pl_common_c_scaling() { - let (a_r, b_r, a_f, b_f) = ([0.8], [-0.3], [1.2], [0.5]); - let se = [0.1]; - let cov = [0.0]; - for &signed in &[true, false] { - let two = raju_area( - &a_r, &b_r, &se, &se, &cov, &a_f, &b_f, &se, &se, &cov, None, signed, 0.05, - ) - .unwrap(); - let three = raju_area( - &a_r, - &b_r, - &se, - &se, - &cov, - &a_f, - &b_f, - &se, - &se, - &cov, - Some(&[0.35]), - signed, - 0.05, - ) - .unwrap(); - assert!( - (three.h[0] - 0.65 * two.h[0]).abs() < 1e-12, - "signed {signed}" - ); - assert!((three.se[0] - 0.65 * two.se[0]).abs() < 1e-12); - assert!((three.z[0] - two.z[0]).abs() < 1e-12); - assert!((three.p_value[0] - two.p_value[0]).abs() < 1e-12); - } -} - -/// Small-Y anchor: delta near zero with unequal slopes still has a LARGE -/// unsigned area (softplus(Y) ~ ln 2, not ~ Y). Pinned to an independently -/// computed constant; kills replacing softplus with the identity. -/// Asserts read: crate `h`. -#[test] -fn raju_small_y_softplus_anchor() { - let r = raju_area( - &[0.6], - &[0.0], - &[0.1], - &[0.1], - &[0.0], - &[1.4], - &[0.01], - &[0.1], - &[0.1], - &[0.0], - None, - false, - 0.05, - ) - .unwrap(); - assert!((r.h[0] - 1.32030659380312).abs() < 1e-9, "h {}", r.h[0]); -} - -/// Detection threshold uses z_{1 - alpha/2}: z = 1.8 must NOT be flagged at -/// alpha = .05 (z_crit ~ 1.960) while z = 2.2 must be; both z values are read -/// back from the crate. Kills the alpha/2 -> alpha quantile mutation -/// (z_crit ~ 1.645 would flag 1.8). -#[test] -fn raju_flag_threshold() { - // Signed path: h = delta, se = sqrt(2) * se_b. Tune se_b for exact z. - let se_for = |target_z: f64, delta: f64| (delta / target_z) / std::f64::consts::SQRT_2; - let s1 = se_for(1.8, 0.36); - let s2 = se_for(2.2, 0.44); - let r = raju_area( - &[1.0, 1.0], - &[0.0, 0.0], - &[0.0, 0.0], - &[s1, s2], - &[0.0, 0.0], - &[1.0, 1.0], - &[0.36, 0.44], - &[0.0, 0.0], - &[s1, s2], - &[0.0, 0.0], - None, - true, - 0.05, - ) - .unwrap(); - assert!((r.z[0] - 1.8).abs() < 1e-9 && (r.z[1] - 2.2).abs() < 1e-9); - assert_eq!(r.dif_items, vec![1]); -} - -/// Error paths: every rejection is a crate Err (asserts read crate results). -#[test] -fn raju_error_paths() { - let ok = [1.0]; - let se = [0.1]; - let cov = [0.0]; - let call = |a_r: &[f64], alpha: f64, guess: Option<&[f64]>, cov_r: &[f64]| { - raju_area( - a_r, - &[0.0], - &se, - &se, - cov_r, - &ok, - &[0.5], - &se, - &se, - &cov, - guess, - false, - alpha, - ) - }; - assert!(call(&[], 0.05, None, &cov).is_err()); // length mismatch / empty - assert!(call(&[-1.0], 0.05, None, &cov).is_err()); // a <= 0 - assert!(call(&[f64::NAN], 0.05, None, &cov).is_err()); // non-finite - assert!(call(&ok, 0.0, None, &cov).is_err()); // alpha out of range - assert!(call(&ok, 1.0, None, &cov).is_err()); - assert!(call(&ok, 0.05, Some(&[1.0]), &cov).is_err()); // c >= 1 - assert!(call(&ok, 0.05, Some(&[-0.1]), &cov).is_err()); // c < 0 - assert!(call(&ok, 0.05, Some(&[0.2, 0.2]), &cov).is_err()); // guess len - // PSD guard: |cov| > se_a * se_b must be rejected with the - // positive-semi-definite message even though the assembled variance - // would still be positive (se = .1 gives bound .01; -.011 violates it). - let err = call(&[0.8], 0.05, None, &[-0.011]).unwrap_err(); - assert!(err.contains("positive semi-definite"), "err = {err}"); - // Grossly invalid covariance is caught by the same guard. - assert!(call(&[0.8], 0.05, None, &[-9.0]).is_err()); - // Negative SE. - assert!(raju_area( - &ok, - &[0.0], - &[-0.1], - &se, - &cov, - &ok, - &[0.5], - &se, - &se, - &cov, - None, - false, - 0.05 - ) - .is_err()); -} - -/// MC-500 (#[ignore]): parametric asymptotic Monte Carlo. Per group, draw -/// (a_hat, b_hat) from N(truth, Sigma) with Sigma the inverse 2PL Fisher -/// information at N = 1500 (crate 41-node Gauss-Hermite, theta ~ N(0, 1)); -/// one null item (delta_b = 0) and one DIF item (delta_b = 0.6), alpha .05. -/// Signed test: Type I <= .08 (exact linear statistic) and power >= .85. -/// Unsigned test: power >= .85, plus a DOCUMENTED loose null band (<= .25) — -/// see the in-test comment for why |H| is anti-conservative at an exact -/// equal-slope null. Asserts read: crate `dif_items` per replication. -/// Evidence run, not a CI gate. -#[test] -#[ignore] -fn raju_mc_500() { - // 2PL Fisher information per person at (a, b), theta ~ N(0,1), via the - // crate's 41-node probabilists' Gauss-Hermite rule (weights sum to 1, so - // E[f(theta)] = sum w_q f(node_q) directly). - let info = |a: f64, b: f64| -> [f64; 3] { - let mut m = [0.0f64; 3]; // [I_aa, I_ab, I_bb] - for (&th, &wt) in crate::mmle::GH_NODES - .iter() - .zip(crate::mmle::GH_WEIGHTS.iter()) - { - let p = 1.0 / (1.0 + (-a * (th - b)).exp()); - let pq = p * (1.0 - p); - let (ga, gb) = (th - b, -a); - m[0] += wt * pq * ga * ga; - m[1] += wt * pq * ga * gb; - m[2] += wt * pq * gb * gb; - } - m - }; - let n_per_group = 1500.0; - let draw = |lcg: &mut Lcg, a: f64, b: f64| -> (f64, f64, f64, f64, f64) { - let m = info(a, b); - let (iaa, iab, ibb) = (m[0] * n_per_group, m[1] * n_per_group, m[2] * n_per_group); - let det = iaa * ibb - iab * iab; - let (vaa, vab, vbb) = (ibb / det, -iab / det, iaa / det); - // Cholesky of the 2x2 covariance. - let l11 = vaa.sqrt(); - let l21 = vab / l11; - let l22 = (vbb - l21 * l21).sqrt(); - let (z1, z2) = (lcg.normal(), lcg.normal()); - ( - a + l11 * z1, - b + l21 * z1 + l22 * z2, - vaa.sqrt(), - vbb.sqrt(), - vab, - ) - }; - let mut lcg = Lcg(0xD1FA_4EA5_EED0); - let (mut s_null_rej, mut s_dif_rej) = (0u32, 0u32); - let (mut u_null_rej, mut u_dif_rej) = (0u32, 0u32); - let reps = 500; - for _ in 0..reps { - // Items: 0 null (b = 0.2 both groups), 1 DIF (b_F = b_R + 0.6). - let truth = [(1.1, 0.2, 1.1, 0.2), (0.9, -0.1, 0.9, 0.5)]; - let (mut ar, mut br, mut sar, mut sbr, mut cvr) = (vec![], vec![], vec![], vec![], vec![]); - let (mut af, mut bf, mut saf, mut sbf, mut cvf) = (vec![], vec![], vec![], vec![], vec![]); - for &(a_r, b_r, a_f, b_f) in &truth { - let (a, b, sa, sb, cv) = draw(&mut lcg, a_r, b_r); - ar.push(a); - br.push(b); - sar.push(sa); - sbr.push(sb); - cvr.push(cv); - let (a, b, sa, sb, cv) = draw(&mut lcg, a_f, b_f); - af.push(a); - bf.push(b); - saf.push(sa); - sbf.push(sb); - cvf.push(cv); - } - let s = raju_area( - &ar, &br, &sar, &sbr, &cvr, &af, &bf, &saf, &sbf, &cvf, None, true, 0.05, - ) - .unwrap(); - if s.dif_items.contains(&0) { - s_null_rej += 1; - } - if s.dif_items.contains(&1) { - s_dif_rej += 1; - } - let u = raju_area( - &ar, &br, &sar, &sbr, &cvr, &af, &bf, &saf, &sbf, &cvf, None, false, 0.05, - ) - .unwrap(); - if u.dif_items.contains(&0) { - u_null_rej += 1; - } - if u.dif_items.contains(&1) { - u_dif_rej += 1; - } - } - let s_type_i = f64::from(s_null_rej) / f64::from(reps); - let s_power = f64::from(s_dif_rej) / f64::from(reps); - let u_type_i = f64::from(u_null_rej) / f64::from(reps); - let u_power = f64::from(u_dif_rej) / f64::from(reps); - // Signed statistic: H = b_F - b_R is exactly linear in the estimates, so - // the delta method is exact and the nominal alpha = .05 level holds. - assert!(s_type_i <= 0.08, "signed null Type I = {s_type_i}"); - assert!(s_power >= 0.85, "signed DIF power = {s_power}"); - // Unsigned statistic under an EXACT null with equal true slopes: |H| has a - // folded (non-normal) sampling distribution — the leading term of H is - // O(db^2 / da), the same order as the linear term — so the normal-theory - // Z is anti-conservative at the null (observed ~0.14 here). This is a - // documented property of Raju's UA Z test in this worst-case null - // configuration, not an implementation error; the strict level gate is - // carried by the signed test above. Loose sanity band + power only. - assert!(u_type_i <= 0.25, "unsigned null Type I = {u_type_i}"); - assert!(u_power >= 0.85, "unsigned DIF power = {u_power}"); -} diff --git a/tests/unit/equating_tests.rs b/tests/unit/equating_tests.rs deleted file mode 100644 index 7e1ac27c7..000000000 --- a/tests/unit/equating_tests.rs +++ /dev/null @@ -1,1397 +0,0 @@ -use super::*; - -// Small LCG + Box-Muller for deterministic test data. -fn lcg(seed: u64) -> impl FnMut() -> f64 { - let mut st = seed.max(1); - move || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - } -} -fn normal(u: &mut impl FnMut() -> f64) -> f64 { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() -} - -// R1: equipercentile self-equating is the exact identity at every integer -// score with positive frequency (the tightest correctness anchor). -#[test] -fn equate_self_is_identity() { - let mut u = lcg(11); - let k = 40usize; - // a spread of scores covering the interior, all cells populated - let scores: Vec = (0..4000) - .map(|_| (8.0 + 24.0 * normal(&mut u)).round().clamp(0.0, k as f64)) - .collect(); - let g = rel_freq(&scores, k).unwrap(); - let res = equate_eg(&scores, &scores, k, k, EquateMethod::Equipercentile).unwrap(); - let mut maxdev = 0.0_f64; - for x in 0..=k { - if g[x] > 0.0 { - maxdev = maxdev.max((res.y_equivalents[x] - x as f64).abs()); - } - } - assert!( - maxdev < 1e-9, - "self-equate must be identity, maxdev={maxdev}" - ); - // includes x=0 whenever it has mass (the low-boundary interpolation) - assert!(g[0] == 0.0 || (res.y_equivalents[0]).abs() < 1e-9); -} - -// R2(a): closed-form moment methods recover the exact generating transform. -#[test] -fn equate_mean_linear_recover_transform() { - let mut u = lcg(7); - let k_x = 30usize; - let x_scores: Vec = (0..5000) - .map(|_| (15.0 + 6.0 * normal(&mut u)).round().clamp(0.0, k_x as f64)) - .collect(); - // mean: Y = X + 5 exactly - let c = 5.0; - let y_mean: Vec = x_scores.iter().map(|&x| x + c).collect(); - let rm = equate_eg(&x_scores, &y_mean, k_x, k_x + 5, EquateMethod::Mean).unwrap(); - assert!((rm.intercept - c).abs() < 1e-9 && (rm.slope - 1.0).abs() < 1e-12); - assert!(rm - .y_equivalents - .iter() - .enumerate() - .all(|(x, &y)| (y - (x as f64 + c)).abs() < 1e-9)); - // linear: Y = 2*X + 3 exactly (integer affine, positive slope) - let (a, b) = (2.0_f64, 3.0_f64); - let k_y = (a * k_x as f64 + b) as usize; - let y_lin: Vec = x_scores.iter().map(|&x| a * x + b).collect(); - let rl = equate_eg(&x_scores, &y_lin, k_x, k_y, EquateMethod::Linear).unwrap(); - assert!((rl.slope - a).abs() < 1e-9, "slope {} != {a}", rl.slope); - assert!( - (rl.intercept - b).abs() < 1e-9, - "intercept {} != {b}", - rl.intercept - ); - assert!(rl - .y_equivalents - .iter() - .enumerate() - .all(|(x, &y)| (y - (a * x as f64 + b)).abs() < 1e-9)); -} - -// R3: with EQUAL anchor distributions (h_V1 = h_V2) and genuinely different X -// vs Y forms, both NEAT methods collapse to EG equipercentile of X onto Y. -// (Equal anchor marginals make the anchor cancel in chaining, and make the FE -// synthetic density equal each group's own marginal.) -#[test] -fn neat_collapses_to_eg_under_equal_anchors() { - let mut u = lcg(3); - let n = 6000usize; - let (k_x, k_y, k_v) = (30usize, 40usize, 15usize); - // identical anchor score vector for both populations => h_V1 == h_V2 exactly - let anchor: Vec = (0..n) - .map(|_| (7.0 + 3.0 * normal(&mut u)).round().clamp(0.0, k_v as f64)) - .collect(); - // different X and Y forms, correlated with the anchor but not equal to it - let x_total: Vec = (0..n) - .map(|i| { - (anchor[i] * 1.4 + 4.0 + 4.0 * normal(&mut u)) - .round() - .clamp(0.0, k_x as f64) - }) - .collect(); - let y_total: Vec = (0..n) - .map(|i| { - (anchor[i] * 2.0 + 6.0 + 5.0 * normal(&mut u)) - .round() - .clamp(0.0, k_y as f64) - }) - .collect(); - - let eg = equate_eg(&x_total, &y_total, k_x, k_y, EquateMethod::Equipercentile).unwrap(); - let ch = equate_neat( - &x_total, - &anchor, - &y_total, - &anchor, - k_x, - k_y, - k_v, - 0.5, - NeatMethod::ChainedEquipercentile, - ) - .unwrap(); - let fe = equate_neat( - &x_total, - &anchor, - &y_total, - &anchor, - k_x, - k_y, - k_v, - 0.5, - NeatMethod::FrequencyEstimation, - ) - .unwrap(); - let mut dmax_ch = 0.0_f64; - let mut dmax_fe = 0.0_f64; - for x in 0..=k_x { - dmax_ch = dmax_ch.max((ch.y_equivalents[x] - eg.y_equivalents[x]).abs()); - dmax_fe = dmax_fe.max((fe.y_equivalents[x] - eg.y_equivalents[x]).abs()); - } - assert!( - dmax_ch < 1e-9, - "chained must equal EG under equal anchors: {dmax_ch}" - ); - assert!( - dmax_fe < 1e-9, - "FE must equal EG under equal anchors: {dmax_fe}" - ); - // FE weight is inert here (h1==h2), so w1 in {0,1} agrees too - for w1 in [0.0_f64, 1.0] { - let fw = equate_neat( - &x_total, - &anchor, - &y_total, - &anchor, - k_x, - k_y, - k_v, - w1, - NeatMethod::FrequencyEstimation, - ) - .unwrap(); - let d = (0..=k_x) - .map(|x| (fw.y_equivalents[x] - eg.y_equivalents[x]).abs()) - .fold(0.0, f64::max); - assert!( - d < 1e-9, - "FE(w1={w1}) must match EG under equal anchors: {d}" - ); - } -} - -#[test] -fn method_and_error_paths() { - assert_eq!( - EquateMethod::parse("EquiPercentile"), - Some(EquateMethod::Equipercentile) - ); - assert_eq!(EquateMethod::parse("mean-mean"), None); - assert_eq!( - NeatMethod::parse("FE"), - Some(NeatMethod::FrequencyEstimation) - ); - assert!(equate_eg(&[], &[1.0], 5, 5, EquateMethod::Mean).is_err()); - assert!(equate_eg(&[6.0], &[1.0], 5, 5, EquateMethod::Mean).is_err()); // out of range - assert!(equate_neat( - &[1.0, 2.0], - &[1.0], - &[1.0], - &[1.0], - 5, - 5, - 5, - 0.5, - NeatMethod::FrequencyEstimation - ) - .is_err()); - // out-of-range score (>= k+0.5) is now rejected (the old ±0.4 tolerance - // on the already-rounded index silently binned it to a boundary cell) - assert!(rel_freq(&[30.6], 30).is_err()); - assert!(rel_freq(&[-0.6], 30).is_err()); - // in-range fractional scores bin to the containing category interval: - // 30.4 -> cat 30 ([29.5,30.5)), and -0.5 -> cat 0 ([-0.5,0.5)) - assert_eq!(rel_freq(&[30.4], 30).unwrap()[30], 1.0); - assert_eq!(rel_freq(&[-0.5, 0.0, 1.0], 3).unwrap()[0], 2.0 / 3.0); -} - -#[test] -fn equating_boundary_contracts_and_kernel_helpers() { - assert_eq!( - bandwidth_or_optimal(Some(0.75), &[0.5, 0.5], 0.5, 0.25, 1), - 0.75 - ); - assert!(bandwidth_or_optimal(None, &[0.5, 0.5], 0.5, 0.25, 1).is_finite()); - assert_eq!(NeatMethod::parse("not-a-method"), None); - assert!(rel_freq(&[f64::NAN], 1).is_err()); - - let g = [0.5, 0.5]; - let f = cdf(&g); - assert!(cdf(&[]).is_empty()); - assert_eq!(perc_rank(&g, &f, 1, -1.0), 0.0); - assert_eq!(perc_rank(&g, &f, 1, 2.0), 100.0); - assert_eq!(perc_rank_inv(&f, 1, 0.0), -0.5); - assert_eq!(perc_rank_inv(&f, 1, 100.0), 1.5); - - assert!(bivariate(&[0.0], &[0.0, 1.0], 1, 1).is_err()); - assert!(bivariate(&[], &[], 1, 1).is_err()); - assert!(bivariate(&[f64::NAN], &[0.0], 1, 1).is_err()); - assert!(bivariate(&[2.0], &[0.0], 1, 1).is_err()); - - assert!(equate_eg(&[0.0], &[0.0], 0, 1, EquateMethod::Mean).is_err()); - assert!(equate_eg(&[0.0, 0.0], &[0.0, 1.0], 1, 1, EquateMethod::Linear).is_err()); - assert!(equate_neat( - &[0.0], - &[0.0], - &[0.0], - &[0.0], - 0, - 1, - 1, - 0.5, - NeatMethod::ChainedEquipercentile, - ) - .is_err()); - assert!(equate_neat( - &[0.0, 1.0], - &[0.0, 1.0], - &[0.0, 1.0], - &[0.0, 1.0], - 1, - 1, - 1, - f64::NAN, - NeatMethod::FrequencyEstimation, - ) - .is_err()); - - assert_eq!(NeatLinearMethod::parse("not-a-method"), None); - assert_eq!(AnchorKind::parse("not-an-anchor"), None); - let total = [0.0, 1.0]; - let anchor = [0.0, 1.0]; - assert!(equate_neat_linear( - &total, - &anchor, - &total, - &anchor, - 0, - 1, - 0.5, - NeatLinearMethod::Tucker, - AnchorKind::Internal, - ) - .is_err()); - assert!(equate_neat_linear( - &total[..1], - &anchor, - &total, - &anchor, - 1, - 1, - 0.5, - NeatLinearMethod::Tucker, - AnchorKind::Internal, - ) - .is_err()); - assert!(equate_neat_linear( - &[], - &[], - &total, - &anchor, - 1, - 1, - 0.5, - NeatLinearMethod::Tucker, - AnchorKind::Internal, - ) - .is_err()); - assert!(equate_neat_linear( - &[f64::INFINITY, 1.0], - &anchor, - &total, - &anchor, - 1, - 1, - 0.5, - NeatLinearMethod::Tucker, - AnchorKind::Internal, - ) - .is_err()); - assert!(equate_neat_linear( - &total, - &[1.0, 0.0], - &total, - &anchor, - 1, - 1, - 0.5, - NeatLinearMethod::LevineObserved, - AnchorKind::Internal, - ) - .is_err()); - assert!(equate_neat_linear( - &[1.0, 1.0], - &anchor, - &total, - &anchor, - 1, - 1, - 0.5, - NeatLinearMethod::Tucker, - AnchorKind::Internal, - ) - .is_err()); - - assert_eq!(quantile_type7(&[2.0], 0.3), 2.0); - assert_eq!(quantile_type7(&[1.0, 2.0], 1.0), 2.0); - assert!(analytic_see(&total, &total, 1, 1, EquateMethod::Mean, 1.0).is_err()); - assert!(analytic_see(&[0.0, 0.0], &total, 1, 1, EquateMethod::Mean, 0.95).is_err()); - - assert!(loglinear_smooth(&[1.0], 1).is_err()); - assert!(loglinear_smooth(&[1.0, 1.0], 0).is_err()); - assert!(loglinear_smooth(&[1.0, f64::NAN], 1).is_err()); - assert!(loglinear_smooth(&[0.0, 0.0], 1).is_err()); - let exact = loglinear_smooth(&[1.0, 1.0], 1).unwrap(); - assert!(exact.converged); - assert_eq!(exact.termination_reason, "gradient_tolerance"); - let rank_deficient = ortho_poly_design(0, 1); - assert_eq!(rank_deficient.len(), 1); - let mut zero_mass = [0.0, 0.0]; - renormalize(&mut zero_mass); - assert_eq!(zero_mass, [0.0, 0.0]); - - assert_eq!( - Continuization::parse("uniform"), - Some(Continuization::Uniform) - ); - assert_eq!( - Continuization::parse("kernel"), - Some(Continuization::Gaussian) - ); - assert_eq!(Continuization::parse("not-a-kernel"), None); - assert_eq!(expanded_upper_bandwidth(3.0, 3.0), 6.0); - assert_eq!(expanded_upper_bandwidth(2.0, 3.0), 3.0); - assert!(validate_optional_bandwidth(None, "bandwidth").is_ok()); - assert!(validate_optional_bandwidth(Some(0.5), "bandwidth").is_ok()); - assert!(validate_optional_bandwidth(Some(f64::NAN), "bandwidth").is_err()); - - // The Gaussian has full support, so exact endpoint probabilities force the - // inverse-CDF bracketing guards to expand beyond the nominal score range. - let left = kernel_inv(&g, 0.5, 0.25, 0.5, 0.0, 1); - let right = kernel_inv(&g, 0.5, 0.25, 0.5, 1.0, 1); - assert!(left < -0.5 && right > 1.5); - let derivative_free = kernel_inv(&[0.0, 0.0], 0.5, 0.25, 0.5, 0.5, 1); - assert!(derivative_free.is_finite()); - - // Exercise bandwidth refinement at both sharp and strongly multimodal - // densities, plus a smooth density where the golden-section candidate wins. - for mut density in [ - vec![1.0, 0.0, 0.0, 0.0, 0.0], - vec![0.45, 0.0, 0.05, 0.0, 0.5], - vec![0.05, 0.2, 0.5, 0.2, 0.05], - ] { - renormalize(&mut density); - let (mu, sd) = moments(&density); - let h = optimal_bandwidth(&density, mu, sd * sd, density.len() - 1); - assert!(h.is_finite() && h > 0.0); - } - - assert!(equate_eg_ext( - &total, - &total, - 0, - 1, - ext(Continuization::Uniform, None, None, None, None), - ) - .is_err()); - assert!(equate_eg_ext( - &total, - &total, - 1, - 1, - ext(Continuization::Gaussian, None, None, Some(0.0), Some(0.5)), - ) - .is_err()); - assert!(equate_eg_ext( - &total, - &total, - 1, - 1, - ext( - Continuization::Gaussian, - None, - None, - Some(0.5), - Some(f64::NAN) - ), - ) - .is_err()); - assert!(equate_eg_ext( - &[0.0, 0.0], - &[0.0, 0.0], - 1, - 1, - ext(Continuization::Gaussian, None, None, Some(0.5), Some(0.5)), - ) - .is_err()); -} - -// FE requires the two groups to share anchor support; fully disjoint anchors -// would otherwise silently collapse the synthetic density (finding: garbage -// conversion table returned as Ok). Chained composition has no such -// requirement and still returns a result. -#[test] -fn fe_rejects_disjoint_anchor_support() { - let x_total = vec![1.0, 2.0, 3.0, 2.0, 1.0, 3.0]; - let x_anchor = vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0]; // support {0,1} - let y_total = vec![2.0, 3.0, 1.0, 2.0, 3.0, 1.0]; - let y_anchor = vec![4.0, 5.0, 4.0, 5.0, 4.0, 5.0]; // support {4,5} - assert!(equate_neat( - &x_total, - &x_anchor, - &y_total, - &y_anchor, - 5, - 5, - 5, - 0.5, - NeatMethod::FrequencyEstimation, - ) - .is_err()); - // also at the boundary weight w1=0 (the all-zero-density degenerate case) - assert!(equate_neat( - &x_total, - &x_anchor, - &y_total, - &y_anchor, - 5, - 5, - 5, - 0.0, - NeatMethod::FrequencyEstimation, - ) - .is_err()); - assert!(equate_neat( - &x_total, - &x_anchor, - &y_total, - &y_anchor, - 5, - 5, - 5, - 0.5, - NeatMethod::ChainedEquipercentile, - ) - .is_ok()); -} - -// 2PL population number-correct density on a GH grid, via Lord-Wingersky. -fn pop_density(a: &[f64], b: &[f64], nodes: &[f64], weights: &[f64]) -> Vec { - let n_items = a.len(); - let n_nodes = nodes.len(); - let mut probs = vec![0.0_f64; n_items * n_nodes]; - for i in 0..n_items { - for (t, &th) in nodes.iter().enumerate() { - probs[i * n_nodes + t] = 1.0 / (1.0 + (-(a[i] * th + b[i])).exp()); - } - } - let f = crate::scoring::lord_wingersky(&probs, n_items, n_nodes); - (0..=n_items) - .map(|s| (0..n_nodes).map(|t| weights[t] * f[s * n_nodes + t]).sum()) - .collect() -} - -fn interior_bias_rmse( - a_x: &[f64], - b_x: &[f64], - a_y: &[f64], - b_y: &[f64], - n: usize, - reps: usize, - seed: u64, -) -> (f64, f64) { - let (k_x, k_y) = (a_x.len(), a_y.len()); - let (nodes, weights) = crate::quadrature::gh_rule(41).unwrap(); - // deterministic population reference e_Y*(x) - let gx_pop = pop_density(a_x, b_x, nodes, weights); - let gy_pop = pop_density(a_y, b_y, nodes, weights); - let e_ref = equipercentile(&gx_pop, &gy_pop, k_x, k_y); - let mut u = lcg(seed); - let mut sum = vec![0.0_f64; k_x + 1]; - let mut sum2 = vec![0.0_f64; k_x + 1]; - let sim = |u: &mut dyn FnMut() -> f64, a: &[f64], b: &[f64]| -> Vec { - (0..n) - .map(|_| { - let th = { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - a.iter() - .zip(b) - .filter(|(&ai, &bi)| u() < 1.0 / (1.0 + (-(ai * th + bi)).exp())) - .count() as f64 - }) - .collect() - }; - for _ in 0..reps { - let xs = sim(&mut u, a_x, b_x); - let ys = sim(&mut u, a_y, b_y); - let est = equate_eg(&xs, &ys, k_x, k_y, EquateMethod::Equipercentile).unwrap(); - for x in 0..=k_x { - let d = est.y_equivalents[x] - e_ref[x]; - sum[x] += d; - sum2[x] += d * d; - } - } - // trim the outer ~5% of the score range where zero-cell sampling dominates - let lo = (k_x as f64 * 0.05).ceil() as usize; - let hi = k_x - lo; - let mut max_bias = 0.0_f64; - let mut rmse_acc = 0.0_f64; - let mut cnt = 0usize; - for x in lo..=hi { - max_bias = max_bias.max((sum[x] / reps as f64).abs()); - rmse_acc += sum2[x] / reps as f64; - cnt += 1; - } - (max_bias, (rmse_acc / cnt as f64).sqrt()) -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn equate_monte_carlo_500() { - // distinct 2PL forms X (30 items) and Y (40 items) - let k_x = 30usize; - let k_y = 40usize; - let a_x: Vec = (0..k_x) - .map(|i| 0.8 + 0.5 * ((i % 5) as f64 / 4.0)) - .collect(); - let b_x: Vec = (0..k_x) - .map(|i| 1.5 - 3.0 * i as f64 / (k_x - 1) as f64) - .collect(); - let a_y: Vec = (0..k_y) - .map(|i| 0.9 + 0.4 * ((i % 4) as f64 / 3.0)) - .collect(); - let b_y: Vec = (0..k_y) - .map(|i| 1.8 - 3.6 * i as f64 / (k_y - 1) as f64) - .collect(); - - let reps = 500usize; - let (bias1, rmse1) = interior_bias_rmse(&a_x, &b_x, &a_y, &b_y, 1000, reps, 4001); - let (bias4, rmse4) = interior_bias_rmse(&a_x, &b_x, &a_y, &b_y, 4000, reps, 7001); - let ratio = rmse1 / rmse4; - println!( - "[equate 500] N=1000: max|bias|={bias1:.4} RMSE={rmse1:.4} \ - N=4000: max|bias|={bias4:.4} RMSE={rmse4:.4} RMSE ratio={ratio:.3} (expect ~2)" - ); - // the empirical equipercentile converges to the population equipercentile - // of the same Lord-Wingersky densities (that population transform IS the - // estimand; R1/R2/R3 supply the independent identification): - assert!( - bias1 < 0.15 && bias4 < 0.08, - "bias should be small and shrink: {bias1}, {bias4}" - ); - assert!( - (1.6..=2.4).contains(&ratio), - "RMSE should shrink ~1/sqrt(N): ratio={ratio}" - ); -} - -fn ext( - cont: Continuization, - sx: Option, - sy: Option, - hx: Option, - hy: Option, -) -> EgSmoothOptions { - EgSmoothOptions { - continuization: cont, - smooth_degree_x: sx, - smooth_degree_y: sy, - bandwidth_x: hx, - bandwidth_y: hy, - } -} - -// Anchor 1: uniform-kernel ext == existing equipercentile, bit-exact. -#[test] -fn ext_uniform_matches_equipercentile() { - let mut u = lcg(21); - let (n, kx, ky) = (3000usize, 30usize, 30usize); - let xs: Vec = (0..n) - .map(|_| (15.0 + 6.0 * normal(&mut u)).round().clamp(0.0, kx as f64)) - .collect(); - let ys: Vec = (0..n) - .map(|_| (14.0 + 7.0 * normal(&mut u)).round().clamp(0.0, ky as f64)) - .collect(); - let base = equate_eg(&xs, &ys, kx, ky, EquateMethod::Equipercentile).unwrap(); - let e = equate_eg_ext( - &xs, - &ys, - kx, - ky, - ext(Continuization::Uniform, None, None, None, None), - ) - .unwrap(); - let d = (0..=kx) - .map(|x| (base.y_equivalents[x] - e.y_equivalents[x]).abs()) - .fold(0.0, f64::max); - assert!( - d < 1e-12, - "uniform-kernel ext must equal equipercentile: {d}" - ); -} - -// Anchors 2 & 3: log-linear presmoothing preserves the first T sample moments -// exactly (on the u=x/k scale) and, saturated at T=k, reproduces rel_freq. -#[test] -fn loglinear_preserves_moments_and_saturates() { - let mut u = lcg(5); - let k = 40usize; - let scores: Vec = (0..5000) - .map(|_| (20.0 + 7.0 * normal(&mut u)).round().clamp(0.0, k as f64)) - .collect(); - let g = rel_freq(&scores, k).unwrap(); - let n = scores.len() as f64; - let counts: Vec = g.iter().map(|&p| p * n).collect(); - let fit = loglinear_smooth(&counts, 4).unwrap(); - assert!(fit.converged); - assert!((fit.probs.iter().sum::() - 1.0).abs() < 1e-12); - assert!(fit.probs.iter().all(|&p| p >= 0.0)); - for (j, &fm) in fit.moments.iter().enumerate() { - let order = (j + 1) as i32; - let sm: f64 = (0..=k) - .map(|x| (x as f64 / k as f64).powi(order) * g[x]) - .sum(); - assert!( - (fm - sm).abs() < 1e-8, - "moment {order} not preserved: {fm} vs {sm}" - ); - } - let sat = loglinear_smooth(&counts, k).unwrap(); - let d = (0..=k) - .map(|x| (sat.probs[x] - g[x]).abs()) - .fold(0.0, f64::max); - assert!(d < 1e-9, "saturated loglinear must reproduce rel_freq: {d}"); -} - -#[test] -fn equating_rejects_nonconverged_presmoothing() { - let counts = [0usize, 1564, 426, 0, 1008, 0, 0]; - let scores: Vec = counts - .iter() - .enumerate() - .flat_map(|(score, &count)| std::iter::repeat_n(score as f64, count)) - .collect(); - let fit = loglinear_smooth( - &counts.iter().map(|&count| count as f64).collect::>(), - 5, - ) - .unwrap(); - assert!( - !fit.converged, - "fixture must exercise the non-converged path" - ); - assert_eq!(fit.termination_reason, "line_search_stalled"); - assert!(fit.final_gradient_max > fit.gradient_tolerance); - - let err = equate_eg_ext( - &scores, - &scores, - 6, - 6, - ext(Continuization::Uniform, Some(5), Some(5), None, None), - ) - .unwrap_err(); - assert!(err.contains("did not converge"), "unexpected error: {err}"); -} - -// Anchors 4 & 6: Gaussian-kernel self-equate is the identity (F_h == G_h), and -// the continuized density preserves the discrete mean and variance. -#[test] -fn kernel_self_equate_and_mean_var() { - let mut u = lcg(9); - let k = 30usize; - let xs: Vec = (0..4000) - .map(|_| (15.0 + 6.0 * normal(&mut u)).round().clamp(0.0, k as f64)) - .collect(); - let res = equate_eg_ext( - &xs, - &xs, - k, - k, - ext(Continuization::Gaussian, None, None, Some(0.6), Some(0.6)), - ) - .unwrap(); - let g = rel_freq(&xs, k).unwrap(); - let mut dmax = 0.0_f64; - for x in 0..=k { - if g[x] > 0.0 { - dmax = dmax.max((res.y_equivalents[x] - x as f64).abs()); - } - } - // exact in exact arithmetic (F_h == G_h); the ~1e-8 residual is the - // erfc approximation (|err| < 1.2e-7) through the numeric inverse - assert!(dmax < 1e-6, "kernel self-equate must be identity: {dmax}"); - assert_eq!(res.h_x, 0.6); - let (mu, sd) = moments(&g); - let sig2 = sd * sd; - let h = 0.8; - let (lo, hi, steps) = (-6.0_f64, k as f64 + 6.0, 20000usize); - let dx = (hi - lo) / steps as f64; - let (mut m0, mut m1, mut m2) = (0.0_f64, 0.0, 0.0); - for i in 0..steps { - let x = lo + (i as f64 + 0.5) * dx; - let fh = kernel_pdf(&g, mu, sig2, h, x); - m0 += fh * dx; - m1 += x * fh * dx; - m2 += x * x * fh * dx; - } - let mean = m1 / m0; - let var = m2 / m0 - mean * mean; - assert!((mean - mu).abs() < 1e-3, "kernel mean {mean} != {mu}"); - assert!( - (var - sig2).abs() < 1e-2 * sig2.max(1.0), - "kernel var {var} != {sig2}" - ); -} - -// Anchor 5: a very large bandwidth drives Gaussian-kernel equating to LINEAR. -#[test] -fn kernel_large_bandwidth_is_linear() { - let mut u = lcg(13); - let (kx, ky) = (30usize, 40usize); - let xs: Vec = (0..4000) - .map(|_| (15.0 + 6.0 * normal(&mut u)).round().clamp(0.0, kx as f64)) - .collect(); - let ys: Vec = (0..4000) - .map(|_| (22.0 + 8.0 * normal(&mut u)).round().clamp(0.0, ky as f64)) - .collect(); - let lin = equate_eg(&xs, &ys, kx, ky, EquateMethod::Linear).unwrap(); - let ker = equate_eg_ext( - &xs, - &ys, - kx, - ky, - ext(Continuization::Gaussian, None, None, Some(1e6), Some(1e6)), - ) - .unwrap(); - let d = (0..=kx) - .map(|x| (lin.y_equivalents[x] - ker.y_equivalents[x]).abs()) - .fold(0.0, f64::max); - assert!(d < 1e-4, "large-h kernel must match linear: {d}"); -} - -// Anchor 8: presmoothed self-equate is still the identity. -#[test] -fn presmoothed_self_equate_is_identity() { - let mut u = lcg(17); - let k = 40usize; - let xs: Vec = (0..3000) - .map(|_| (20.0 + 7.0 * normal(&mut u)).round().clamp(0.0, k as f64)) - .collect(); - let res = equate_eg_ext( - &xs, - &xs, - k, - k, - ext(Continuization::Uniform, Some(5), Some(5), None, None), - ) - .unwrap(); - let g = density(&xs, k, Some(5)).unwrap(); - let mut dmax = 0.0_f64; - for x in 0..=k { - if g[x] > 1e-12 { - dmax = dmax.max((res.y_equivalents[x] - x as f64).abs()); - } - } - assert!( - dmax < 1e-8, - "presmoothed self-equate must be identity: {dmax}" - ); -} - -// Fix guard: on a non-unimodal penalty (bimodal density) the golden-section -// refinement can land in a worse cell, so optimal_bandwidth must fall back to -// the grid best rather than ship it. -#[test] -fn optimal_bandwidth_never_worse_than_grid() { - let k = 40usize; - let mut r = vec![0.0_f64; k + 1]; - for j in 0..=k { - let d1 = (j as f64 - 8.0) / 2.0; - let d2 = (j as f64 - 32.0) / 2.0; - r[j] = (-0.5 * d1 * d1).exp() + (-0.5 * d2 * d2).exp(); - } - let s: f64 = r.iter().sum(); - for v in r.iter_mut() { - *v /= s; - } - let (mu, sd) = moments(&r); - let sig2 = sd * sd; - let h = optimal_bandwidth(&r, mu, sig2, k); - assert!(h.is_finite() && h > 0.0); - let pen_h = kernel_penalty(&r, mu, sig2, h, k); - let grid_best = (0..=40) - .map(|i| kernel_penalty(&r, mu, sig2, 0.1 + (3.0 - 0.1) * i as f64 / 40.0, k)) - .fold(f64::INFINITY, f64::min); - assert!( - pen_h <= grid_best + 1e-12, - "optimal_bandwidth worse than grid: {pen_h} vs {grid_best}" - ); -} - -// Gaussian-kernel MC with a FIXED bandwidth shared by the population reference -// and the per-rep estimator, so the assertion measures density-sampling error -// alone (penalty-selected h would inject selection noise). -fn kernel_bias_rmse( - a_x: &[f64], - b_x: &[f64], - a_y: &[f64], - b_y: &[f64], - n: usize, - reps: usize, - seed: u64, - h: f64, -) -> (f64, f64) { - let (k_x, k_y) = (a_x.len(), a_y.len()); - let (nodes, weights) = crate::quadrature::gh_rule(41).unwrap(); - let gx_pop = pop_density(a_x, b_x, nodes, weights); - let gy_pop = pop_density(a_y, b_y, nodes, weights); - let (mux, sdx) = moments(&gx_pop); - let (muy, sdy) = moments(&gy_pop); - let e_ref = kernel_equate( - &gx_pop, - &gy_pop, - mux, - sdx * sdx, - muy, - sdy * sdy, - k_x, - k_y, - h, - h, - ); - let mut u = lcg(seed); - let sim = |u: &mut dyn FnMut() -> f64, a: &[f64], b: &[f64]| -> Vec { - (0..n) - .map(|_| { - let th = { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - a.iter() - .zip(b) - .filter(|(&ai, &bi)| u() < 1.0 / (1.0 + (-(ai * th + bi)).exp())) - .count() as f64 - }) - .collect() - }; - let mut sum = vec![0.0_f64; k_x + 1]; - let mut sum2 = vec![0.0_f64; k_x + 1]; - for _ in 0..reps { - let xs = sim(&mut u, a_x, b_x); - let ys = sim(&mut u, a_y, b_y); - let est = equate_eg_ext( - &xs, - &ys, - k_x, - k_y, - ext(Continuization::Gaussian, None, None, Some(h), Some(h)), - ) - .unwrap(); - for x in 0..=k_x { - let d = est.y_equivalents[x] - e_ref[x]; - sum[x] += d; - sum2[x] += d * d; - } - } - let lo = (k_x as f64 * 0.05).ceil() as usize; - let hi = k_x - lo; - let mut max_bias = 0.0_f64; - let mut rmse_acc = 0.0_f64; - let mut cnt = 0usize; - for x in lo..=hi { - max_bias = max_bias.max((sum[x] / reps as f64).abs()); - rmse_acc += sum2[x] / reps as f64; - cnt += 1; - } - (max_bias, (rmse_acc / cnt as f64).sqrt()) -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn kernel_equate_monte_carlo_500() { - let k_x = 30usize; - let k_y = 40usize; - let a_x: Vec = (0..k_x) - .map(|i| 0.8 + 0.5 * ((i % 5) as f64 / 4.0)) - .collect(); - let b_x: Vec = (0..k_x) - .map(|i| 1.5 - 3.0 * i as f64 / (k_x - 1) as f64) - .collect(); - let a_y: Vec = (0..k_y) - .map(|i| 0.9 + 0.4 * ((i % 4) as f64 / 3.0)) - .collect(); - let b_y: Vec = (0..k_y) - .map(|i| 1.8 - 3.6 * i as f64 / (k_y - 1) as f64) - .collect(); - let reps = 500usize; - let h = 0.6_f64; - let (bias1, rmse1) = kernel_bias_rmse(&a_x, &b_x, &a_y, &b_y, 1000, reps, 5001, h); - let (bias4, rmse4) = kernel_bias_rmse(&a_x, &b_x, &a_y, &b_y, 4000, reps, 8001, h); - let ratio = rmse1 / rmse4; - println!( - "[kernel equate 500] h={h} N=1000: max|bias|={bias1:.4} RMSE={rmse1:.4} \ - N=4000: max|bias|={bias4:.4} RMSE={rmse4:.4} RMSE ratio={ratio:.3} (expect ~2)" - ); - assert!( - bias1 < 0.15 && bias4 < 0.08, - "bias should be small and shrink: {bias1}, {bias4}" - ); - assert!( - (1.6..=2.4).contains(&ratio), - "RMSE should shrink ~1/sqrt(N): {ratio}" - ); -} - -// Primary anchor: with equal anchor moments (a shared anchor vector) every -// Tucker/Levine variant collapses to EG linear equating of X onto Y, for any -// w1 and anchor kind. -#[test] -fn neat_linear_collapses_to_eg_linear() { - let (kx, ky) = (30usize, 40usize); - let mut u = lcg(41); - let n = 4000usize; - // a shared anchor vector (equal anchor moments by construction) that is - // genuinely correlated with both totals (so Levine's covariance is positive) - let anchor: Vec = (0..n) - .map(|_| (7.0 + 3.0 * normal(&mut u)).round().clamp(0.0, 15.0)) - .collect(); - let x_total: Vec = anchor - .iter() - .map(|&v| { - (1.5 * v + 4.0 + 3.0 * normal(&mut u)) - .round() - .clamp(0.0, kx as f64) - }) - .collect(); - let y_total: Vec = anchor - .iter() - .map(|&v| { - (1.8 * v + 6.0 + 4.0 * normal(&mut u)) - .round() - .clamp(0.0, ky as f64) - }) - .collect(); - let eg = equate_eg(&x_total, &y_total, kx, ky, EquateMethod::Linear).unwrap(); - for m in [NeatLinearMethod::Tucker, NeatLinearMethod::LevineObserved] { - for ak in [AnchorKind::Internal, AnchorKind::External] { - for w1 in [0.0_f64, 0.5, 1.0] { - let r = equate_neat_linear(&x_total, &anchor, &y_total, &anchor, kx, ky, w1, m, ak) - .unwrap(); - assert!( - (r.slope - eg.slope).abs() < 1e-9 && (r.intercept - eg.intercept).abs() < 1e-9, - "collapse {m:?}/{ak:?}/w1={w1}: slope {} vs {}, int {} vs {}", - r.slope, - eg.slope, - r.intercept, - eg.intercept - ); - let d = (0..=kx) - .map(|x| (r.y_equivalents[x] - eg.y_equivalents[x]).abs()) - .fold(0.0, f64::max); - assert!(d < 1e-9, "table mismatch: {d}"); - } - } - } -} - -// Pins the internal-vs-external Levine gamma (the crux) against a NumPy oracle -// (N-denominator moments): the three gamma branches give three distinct -// slope/intercept pairs. -#[test] -fn neat_linear_gamma_hand_computed() { - let x1 = [3.0, 5., 7., 9., 4., 6., 8., 2.]; - let v1 = [1.0, 2., 2., 3., 1., 2., 3., 1.]; - let y2 = [2.0, 5., 8., 11., 4., 7., 10., 1.]; - let v2 = [2.0, 4., 4., 6., 3., 5., 6., 2.]; - let (kx, ky, w1) = (11usize, 11usize, 0.5_f64); - let tk = equate_neat_linear( - &x1, - &v1, - &y2, - &v2, - kx, - ky, - w1, - NeatLinearMethod::Tucker, - AnchorKind::Internal, - ) - .unwrap(); - assert!( - (tk.slope - 0.8006819908).abs() < 1e-8 && (tk.intercept + 3.0616870634).abs() < 1e-8, - "tucker {} {}", - tk.slope, - tk.intercept - ); - let li = equate_neat_linear( - &x1, - &v1, - &y2, - &v2, - kx, - ky, - w1, - NeatLinearMethod::LevineObserved, - AnchorKind::Internal, - ) - .unwrap(); - assert!( - (li.slope - 0.7403094687).abs() < 1e-8 && (li.intercept + 3.0252464118).abs() < 1e-8, - "levine-int {} {}", - li.slope, - li.intercept - ); - let le = equate_neat_linear( - &x1, - &v1, - &y2, - &v2, - kx, - ky, - w1, - NeatLinearMethod::LevineObserved, - AnchorKind::External, - ) - .unwrap(); - assert!( - (le.slope - 0.7550256824).abs() < 1e-8 && (le.intercept + 3.017543311).abs() < 1e-8, - "levine-ext {} {}", - le.slope, - le.intercept - ); - // Tucker ignores the anchor kind - let tk2 = equate_neat_linear( - &x1, - &v1, - &y2, - &v2, - kx, - ky, - w1, - NeatLinearMethod::Tucker, - AnchorKind::External, - ) - .unwrap(); - assert_eq!(tk.slope, tk2.slope); - assert_eq!( - NeatLinearMethod::parse("levine"), - Some(NeatLinearMethod::LevineObserved) - ); - assert_eq!(AnchorKind::parse("ext"), Some(AnchorKind::External)); - // error paths: bad w1, constant anchor (zero variance), Levine on a zero-cov anchor - assert!(equate_neat_linear( - &x1, - &v1, - &y2, - &v2, - kx, - ky, - 1.5, - NeatLinearMethod::Tucker, - AnchorKind::Internal - ) - .is_err()); - let const_v = [2.0_f64; 8]; - assert!(equate_neat_linear( - &x1, - &const_v, - &y2, - &v2, - kx, - ky, - w1, - NeatLinearMethod::Tucker, - AnchorKind::Internal - ) - .is_err()); -} - -// Common-regression generative model (satisfies the Tucker assumption); the -// estimator's equated table converges to the large-N reference at ~1/sqrt(N). -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn neat_linear_monte_carlo_500() { - let (kt_x, kt_y, kv) = (40usize, 45usize, 15usize); - let (sdv, beta, tau) = (2.5_f64, 1.2_f64, 3.0_f64); - let gen = |u: &mut dyn FnMut() -> f64, - n: usize, - muv: f64, - alpha: f64, - kt: usize| - -> (Vec, Vec) { - let nd = |u: &mut dyn FnMut() -> f64| { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let mut tot = vec![0.0_f64; n]; - let mut anc = vec![0.0_f64; n]; - for i in 0..n { - let v = muv + sdv * nd(u); - let t = alpha + beta * v + tau * nd(u); - anc[i] = v.round().clamp(0.0, kv as f64); - tot[i] = t.round().clamp(0.0, kt as f64); - } - (tot, anc) - }; - // reference from a large calibration draw through the same sampler+rounding - let mut ur = lcg(9100); - let n_reference = 2_000_000usize; - let (rx, rxa) = gen(&mut ur, n_reference, 6.0, 5.0, kt_x); - let (ry, rya) = gen(&mut ur, n_reference, 9.0, 8.0, kt_y); - let e_ref = equate_neat_linear( - &rx, - &rxa, - &ry, - &rya, - kt_x, - kt_y, - 0.5, - NeatLinearMethod::Tucker, - AnchorKind::Internal, - ) - .unwrap(); - let bias_rmse = |n: usize, seed: u64| -> (f64, f64) { - let mut u = lcg(seed); - let reps = 500usize; - let mut sum = vec![0.0_f64; kt_x + 1]; - let mut sum2 = vec![0.0_f64; kt_x + 1]; - for _ in 0..reps { - let (xt, xa) = gen(&mut u, n, 6.0, 5.0, kt_x); - let (yt, ya) = gen(&mut u, n, 9.0, 8.0, kt_y); - let est = equate_neat_linear( - &xt, - &xa, - &yt, - &ya, - kt_x, - kt_y, - 0.5, - NeatLinearMethod::Tucker, - AnchorKind::Internal, - ) - .unwrap(); - for x in 0..=kt_x { - let d = est.y_equivalents[x] - e_ref.y_equivalents[x]; - sum[x] += d; - sum2[x] += d * d; - } - } - let lo = (kt_x as f64 * 0.05).ceil() as usize; - let hi = kt_x - lo; - let (mut mb, mut ra, mut c) = (0.0_f64, 0.0_f64, 0usize); - for x in lo..=hi { - mb = mb.max((sum[x] / reps as f64).abs()); - ra += sum2[x] / reps as f64; - c += 1; - } - (mb, (ra / c as f64).sqrt()) - }; - let (b1, r1) = bias_rmse(1000, 111); - let (b4, r4) = bias_rmse(4000, 222); - let ratio = r1 / r4; - println!("[neat-linear 500] N=1000: max|bias|={b1:.4} RMSE={r1:.4} N=4000: max|bias|={b4:.4} RMSE={r4:.4} ratio={ratio:.3}"); - assert!( - b1 < 0.20 && b4 < 0.10, - "bias should be small and shrink: {b1}, {b4}" - ); - assert!( - (1.6..=2.4).contains(&ratio), - "RMSE should shrink ~1/sqrt(N): {ratio}" - ); -} - -// helper: two near-normal EG samples of size n -fn see_gen(u: &mut impl FnMut() -> f64, n: usize, k: usize) -> (Vec, Vec) { - let xs = (0..n) - .map(|_| (15.0 + 5.0 * normal(u)).round().clamp(0.0, k as f64)) - .collect(); - let ys = (0..n) - .map(|_| (16.0 + 5.0 * normal(u)).round().clamp(0.0, k as f64)) - .collect(); - (xs, ys) -} - -// A1: delta-method Linear SEE agrees with the bootstrap Linear SEE. -#[test] -fn see_analytic_linear_matches_bootstrap() { - let mut u = lcg(71); - let (k, n) = (30usize, 3000usize); - let (xs, ys) = see_gen(&mut u, n, k); - let a = analytic_see(&xs, &ys, k, k, EquateMethod::Linear, 0.95).unwrap(); - let b = bootstrap_see(&xs, &ys, k, k, EquateMethod::Linear, 2000, 0.95, 12345).unwrap(); - let (lo, hi) = ( - (k as f64 * 0.1).ceil() as usize, - k - (k as f64 * 0.1).ceil() as usize, - ); - let mut maxrel = 0.0_f64; - for x in lo..=hi { - if a.se[x] > 1e-6 { - maxrel = maxrel.max((b.se[x] - a.se[x]).abs() / a.se[x]); - } - } - assert!( - maxrel < 0.15, - "analytic vs bootstrap Linear SEE relative gap too large: {maxrel}" - ); -} - -// A2: Mean SEE is constant in x and equals the closed form. -#[test] -fn see_mean_is_constant() { - let mut u = lcg(72); - let (k, n) = (30usize, 2000usize); - let (xs, ys) = see_gen(&mut u, n, k); - let a = analytic_see(&xs, &ys, k, k, EquateMethod::Mean, 0.95).unwrap(); - let (_, sx) = moments(&rel_freq(&xs, k).unwrap()); - let (_, sy) = moments(&rel_freq(&ys, k).unwrap()); - let expected = (sx * sx / n as f64 + sy * sy / n as f64).sqrt(); - for x in 0..=k { - assert!( - (a.se[x] - expected).abs() < 1e-9 && (a.se[x] - a.se[0]).abs() < 1e-12, - "Mean SEE not constant" - ); - } -} - -// A3/A4: bootstrap sanity (positive SE, CI brackets the estimate, ~1/sqrt(N) -// shrink), determinism, and the input guards. -#[test] -fn see_bootstrap_sanity_and_guards() { - let mut u = lcg(73); - let k = 20usize; - let (x1, y1) = see_gen(&mut u, 1000, k); - let (x4, y4) = see_gen(&mut u, 4000, k); - let b1 = bootstrap_see(&x1, &y1, k, k, EquateMethod::Equipercentile, 500, 0.95, 7).unwrap(); - let b4 = bootstrap_see(&x4, &y4, k, k, EquateMethod::Equipercentile, 500, 0.95, 7).unwrap(); - let (lo, hi) = ( - (k as f64 * 0.1).ceil() as usize, - k - (k as f64 * 0.1).ceil() as usize, - ); - for x in lo..=hi { - assert!(b1.se[x] > 0.0); - assert!( - b1.ci_lo[x] <= b1.y_equivalents[x] + 1e-9 && b1.y_equivalents[x] <= b1.ci_hi[x] + 1e-9 - ); - } - let ratio: f64 = (lo..=hi) - .map(|x| b1.se[x] / b4.se[x].max(1e-9)) - .sum::() - / (hi - lo + 1) as f64; - assert!( - (1.5..=2.6).contains(&ratio), - "SE should ~halve when N x4: {ratio}" - ); - // determinism - let d1 = bootstrap_see(&x1, &y1, k, k, EquateMethod::Linear, 300, 0.95, 99).unwrap(); - let d2 = bootstrap_see(&x1, &y1, k, k, EquateMethod::Linear, 300, 0.95, 99).unwrap(); - assert_eq!(d1.se, d2.se); - // guards - assert!(bootstrap_see(&x1, &y1, k, k, EquateMethod::Mean, 1, 0.95, 1).is_err()); - assert!(bootstrap_see(&x1, &y1, k, k, EquateMethod::Mean, 100, 1.5, 1).is_err()); - let oversized = std::panic::catch_unwind(|| { - bootstrap_see(&x1, &y1, k, k, EquateMethod::Mean, usize::MAX, 0.95, 1) - }); - assert!( - oversized.is_ok(), - "oversized n_boot must return an error instead of panicking" - ); - assert!(oversized.unwrap().is_err()); - assert!(analytic_see(&x1, &y1, k, k, EquateMethod::Equipercentile, 0.95).is_err()); -} - -// The bootstrap SE approximates the TRUE sampling SD of e_Y(x) (from an outer -// Monte-Carlo that redraws fresh 2PL samples) within Monte-Carlo tolerance. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn see_bootstrap_monte_carlo_500() { - let (k_x, k_y, n) = (30usize, 40usize, 2000usize); - let a_x: Vec = (0..k_x) - .map(|i| 0.8 + 0.5 * ((i % 5) as f64 / 4.0)) - .collect(); - let b_x: Vec = (0..k_x) - .map(|i| 1.5 - 3.0 * i as f64 / (k_x - 1) as f64) - .collect(); - let a_y: Vec = (0..k_y) - .map(|i| 0.9 + 0.4 * ((i % 4) as f64 / 3.0)) - .collect(); - let b_y: Vec = (0..k_y) - .map(|i| 1.8 - 3.6 * i as f64 / (k_y - 1) as f64) - .collect(); - let sim = |u: &mut dyn FnMut() -> f64, a: &[f64], b: &[f64]| -> Vec { - (0..n) - .map(|_| { - let th = { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - a.iter() - .zip(b) - .filter(|(&ai, &bi)| u() < 1.0 / (1.0 + (-(ai * th + bi)).exp())) - .count() as f64 - }) - .collect() - }; - let run = |method: EquateMethod, label: &str| { - // outer MC: true SD of e_Y(x) over R fresh samples - let r_out = 500usize; - let mut uo = lcg(3300); - let mut vals = vec![0.0_f64; r_out * (k_x + 1)]; - for r in 0..r_out { - let xs = sim(&mut uo, &a_x, &b_x); - let ys = sim(&mut uo, &a_y, &b_y); - let e = equate_eg(&xs, &ys, k_x, k_y, method).unwrap(); - vals[r * (k_x + 1)..(r + 1) * (k_x + 1)].copy_from_slice(&e.y_equivalents); - } - let true_sd: Vec = (0..=k_x) - .map(|x| { - let col: Vec = (0..r_out).map(|r| vals[r * (k_x + 1) + x]).collect(); - let m = col.iter().sum::() / r_out as f64; - (col.iter().map(|&v| (v - m).powi(2)).sum::() / (r_out as f64 - 1.0)).sqrt() - }) - .collect(); - // mean bootstrap SE over n_samp fresh samples - let n_samp = 40usize; - let mut ub = lcg(9900); - let mut sum_se = vec![0.0_f64; k_x + 1]; - for s_i in 0..n_samp { - let xs = sim(&mut ub, &a_x, &b_x); - let ys = sim(&mut ub, &a_y, &b_y); - let n_boot = 300usize; - let s = bootstrap_see( - &xs, - &ys, - k_x, - k_y, - method, - n_boot, - 0.95, - 41_000 + s_i as u64, - ) - .unwrap(); - for x in 0..=k_x { - sum_se[x] += s.se[x]; - } - } - let (lo, hi) = ( - (k_x as f64 * 0.05).ceil() as usize, - k_x - (k_x as f64 * 0.05).ceil() as usize, - ); - let (mut rmin, mut rmax) = (f64::INFINITY, f64::NEG_INFINITY); - for x in lo..=hi { - let ratio = (sum_se[x] / n_samp as f64) / true_sd[x].max(1e-9); - rmin = rmin.min(ratio); - rmax = rmax.max(ratio); - } - println!("[see 500] {label}: interior boot/true SD ratio in [{rmin:.3}, {rmax:.3}]"); - assert!( - rmin > 0.80 && rmax < 1.20, - "{label} bootstrap SEE off true SD: [{rmin}, {rmax}]" - ); - }; - run(EquateMethod::Linear, "linear"); - run(EquateMethod::Equipercentile, "equipercentile"); -} diff --git a/tests/unit/exposure_tests.rs b/tests/unit/exposure_tests.rs deleted file mode 100644 index f80590822..000000000 --- a/tests/unit/exposure_tests.rs +++ /dev/null @@ -1,1395 +0,0 @@ -//! Sympson-Hetter exposure control tests. -//! -//! Mutation-kill audit (executed kills recorded in the PR evidence): -//! every assert below reads crate outputs (`SympsonHetterResult` fields or -//! the returned `Err`); no assert recomputes the algorithm locally. -//! -//! - M1 gate flip (`u <= k` -> `u >= k` for k < 1): killed by -//! `sh_controls_max_exposure` (a flipped gate rejects with probability k, -//! so calibration cannot pull max exposure to the target). -//! - M2 update denominator (`r/P(S)` -> `r/P(A)`): killed by -//! `sh_controls_max_exposure` (the wrong denominator weakens the filter and -//! the reported max exposure stays above r_max + tol). -//! - M3 rejected item NOT blocked (usable[s] stays true on reject): killed by -//! non-termination (EXECUTED: the mutant diverges — the rejected top item is -//! re-encountered, s_count inflates across cycles, k -> 0, and encounter -//! counts explode; the suite that normally finishes in ~1.4 s did not finish -//! in 7+ minutes and was stopped). The discriminating anchor is the -//! calibration loop itself, not an assert; documented rather than hidden. -//! - M4 swapped S/A bookkeeping: killed by `sh_counting_identities` -//! (P(A) <= P(S) fails, and sum of exposure != test_length). -//! - Denominator mutants (counts divided by n_items or test_length instead -//! of n_simulees): killed by `sh_counting_identities` (sum P(A) == L is an -//! exact counting identity, not an MC approximation). -//! -//! Known limitation (documented, not hidden): with `r_max = 1` the gate is -//! skipped entirely, so gate-only mutants are invisible to -//! `sh_rmax_one_is_unconstrained`; the discriminating anchor for the gate is -//! `sh_controls_max_exposure`. - -use crate::exposure::{ - a_stratified, ccat_select, eap_interim, kl_information, kl_select, owen_cat, owen_update, p3pl, - sympson_hetter, AStratifiedConfig, Lcg, SympsonHetterConfig, -}; - -fn pool30() -> (Vec, Vec, Vec) { - // Deterministic, asymmetric 30-item 2PL pool: a in [0.6, 2.0], - // b spread over [-2.4, 2.4] with an uneven pattern so max-info CAT - // concentrates selections and exposure control has real work to do. - let n = 30; - let mut a = Vec::with_capacity(n); - let mut b = Vec::with_capacity(n); - for i in 0..n { - let x = i as f64; - a.push(0.6 + 1.4 * ((x * 0.37).sin().abs())); - b.push(-2.4 + 4.8 * (x / (n as f64 - 1.0)) + 0.3 * (x * 0.71).sin()); - } - (a, b, vec![0.0; n]) -} - -fn base_cfg() -> SympsonHetterConfig { - SympsonHetterConfig { - r_max: 0.25, - test_length: 5, - n_simulees: 1500, - max_iter: 12, - tol: 0.02, - seed: 42, - q_theta: 31, - } -} - -#[test] -fn sh_controls_max_exposure() { - let (a, b, c) = pool30(); - // Uncontrolled baseline: r_max = 1 leaves k = 1 everywhere. - let free = sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - r_max: 1.0, - max_iter: 1, - ..base_cfg() - }, - ) - .expect("uncontrolled run"); - // The pool is deliberately peaked: without control some item must be - // administered far above the 0.25 target (reads crate exposure output). - assert!( - free.max_exposure > 0.35, - "baseline max exposure {} unexpectedly low; pool no longer discriminates the gate", - free.max_exposure - ); - - let ctl = sympson_hetter(&a, &b, &c, &base_cfg()).expect("controlled run"); - assert!( - ctl.converged, - "calibration did not converge: history {:?}", - ctl.history_max_exposure - ); - assert!( - ctl.max_exposure <= 0.25 + 0.02 + 1e-12, - "max exposure {} above target", - ctl.max_exposure - ); - // Control must actually reduce the crate-reported max exposure. - assert!(ctl.max_exposure < free.max_exposure); - // Some item must have been throttled (k < 1) for the reduction to be - // attributable to the gate rather than to chance. - assert!(ctl.k.iter().any(|&v| v < 1.0)); -} - -#[test] -fn sh_counting_identities() { - let (a, b, c) = pool30(); - let r = sympson_hetter(&a, &b, &c, &base_cfg()).expect("run"); - let n_items = a.len(); - assert_eq!(r.k.len(), n_items); - assert_eq!(r.exposure.len(), n_items); - assert_eq!(r.selection.len(), n_items); - // Exact counting identity: every simulee gets exactly L items, so the - // exposure rates (crate outputs) sum to L up to f64 summation error. - let sum_expo: f64 = r.exposure.iter().sum(); - assert!( - (sum_expo - 5.0).abs() < 1e-9, - "sum of exposure rates {} != test_length", - sum_expo - ); - // Administration requires selection, per item. - for i in 0..n_items { - assert!( - r.exposure[i] <= r.selection[i] + 1e-12, - "item {}: P(A) {} > P(S) {}", - i, - r.exposure[i], - r.selection[i] - ); - } - // k stays in (0, 1]. - for (i, &v) in r.k.iter().enumerate() { - assert!(v > 0.0 && v <= 1.0, "k[{}] = {} out of (0, 1]", i, v); - } - // max_exposure is the max of the reported exposure vector and the last - // history entry (internal consistency of the returned struct). - let max_from_vec = r.exposure.iter().cloned().fold(0.0_f64, f64::max); - assert_eq!(r.max_exposure, max_from_vec); - assert_eq!(r.max_exposure, *r.history_max_exposure.last().unwrap()); - assert_eq!(r.history_max_exposure.len(), r.n_iter); -} - -#[test] -fn sh_rmax_one_is_unconstrained() { - let (a, b, c) = pool30(); - let r = sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - r_max: 1.0, - ..base_cfg() - }, - ) - .expect("run"); - // r_max = 1 is trivially satisfied: one cycle, converged, all k = 1 - // (kills mutants that update k unconditionally), and selection == - // exposure exactly (every selected item is administered; both are crate - // outputs). - assert!(r.converged); - assert_eq!(r.n_iter, 1); - assert!(r.k.iter().all(|&v| v == 1.0)); - assert_eq!(r.exposure, r.selection); -} - -#[test] -fn sh_deterministic_under_seed() { - let (a, b, c) = pool30(); - let r1 = sympson_hetter(&a, &b, &c, &base_cfg()).expect("run 1"); - let r2 = sympson_hetter(&a, &b, &c, &base_cfg()).expect("run 2"); - assert_eq!(r1.k, r2.k); - assert_eq!(r1.exposure, r2.exposure); - assert_eq!(r1.history_max_exposure, r2.history_max_exposure); - // A different seed must actually change the simulation (kills mutants - // that ignore the seed). - let r3 = sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - seed: 43, - ..base_cfg() - }, - ) - .expect("run 3"); - assert_ne!(r1.exposure, r3.exposure); -} - -#[test] -fn sh_input_validation() { - let (a, b, c) = pool30(); - let cfg = base_cfg(); - // Infeasible target: r_max below L / n_items (counting identity). - assert!(sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - r_max: 0.1, - test_length: 5, - ..cfg.clone() - } - ) - .is_err()); - // Mismatched lengths, empty pool, bad domains. - assert!(sympson_hetter(&a[..29], &b, &c, &cfg).is_err()); - assert!(sympson_hetter(&[], &[], &[], &cfg).is_err()); - let mut bad_a = a.clone(); - bad_a[0] = f64::NAN; - assert!(sympson_hetter(&bad_a, &b, &c, &cfg).is_err()); - let mut bad_c = c.clone(); - bad_c[3] = 1.0; - assert!(sympson_hetter(&a, &b, &bad_c, &cfg).is_err()); - for r_max in [0.0, -0.2, 1.5, f64::NAN] { - assert!(sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - r_max, - ..cfg.clone() - } - ) - .is_err()); - } - assert!(sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - test_length: 0, - ..cfg.clone() - } - ) - .is_err()); - assert!(sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - test_length: 31, - ..cfg.clone() - } - ) - .is_err()); - assert!(sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - n_simulees: 0, - ..cfg.clone() - } - ) - .is_err()); - assert!(sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - max_iter: 0, - ..cfg.clone() - } - ) - .is_err()); - assert!(sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - q_theta: 2, - ..cfg.clone() - } - ) - .is_err()); - assert!(sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - tol: f64::NAN, - ..cfg - } - ) - .is_err()); -} - -// Exact-boundary policy pin: `r_max == test_length / n_items` passes the -// necessary feasibility bound, but the no-forced-administration policy means -// the stochastic gate can exhaust the pool mid-test; the crate then returns -// the documented pool-exhausted error rather than forcing an item. This -// regression reads the crate `Err` (round-2 impl-review reproducer) and -// fails if the policy silently changes to forced administration or the -// validation starts rejecting the exact boundary. -#[test] -fn sh_exact_feasibility_boundary_policy() { - let a = vec![1.0, 1.1, 1.2, 1.3]; - let b = vec![-1.0, -0.3, 0.3, 1.0]; - let c = vec![0.0; 4]; - let cfg = SympsonHetterConfig { - r_max: 0.5, // == test_length / n_items exactly - test_length: 2, - n_simulees: 200, - max_iter: 5, - tol: 0.02, - seed: 7, - q_theta: 3, - }; - let err = sympson_hetter(&a, &b, &c, &cfg).unwrap_err(); - assert!( - err.contains("item pool exhausted"), - "expected the documented pool-exhausted policy error, got: {}", - err - ); - // Just below the bound is rejected up front by validation instead. - let below = SympsonHetterConfig { - r_max: 0.5 - 1e-12, - ..cfg - }; - let err2 = sympson_hetter(&a, &b, &c, &below).unwrap_err(); - assert!(err2.contains("infeasible"), "got: {}", err2); -} - -// >= 500-replication Monte Carlo: across seeds the calibrated max exposure -// stays at the target (within MC noise) and the counting identity holds in -// every replication. Run with `cargo test -- --ignored`. -#[test] -#[ignore] -fn sh_monte_carlo_500() { - let (a, b, c) = pool30(); - let mut worst = 0.0_f64; - let mut sum_max = 0.0; - let reps = 500; - for rep in 0..reps { - let r = sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - n_simulees: 1500, - seed: 1000 + rep as u64, - ..base_cfg() - }, - ) - .expect("run"); - let sum_expo: f64 = r.exposure.iter().sum(); - assert!((sum_expo - 5.0).abs() < 1e-9, "rep {}: identity broke", rep); - sum_max += r.max_exposure; - worst = worst.max(r.max_exposure); - } - let mean_max = sum_max / reps as f64; - assert!( - mean_max <= 0.25 + 0.025, - "mean calibrated max exposure {} drifted above target", - mean_max - ); - // Individual replications may sit above target (convergence is not - // guaranteed; van der Linden, 2003) but not wildly so at these sizes. - assert!(worst <= 0.25 + 0.08, "worst-case max exposure {}", worst); -} - -// ===================== a-stratified multistage CAT ===================== -// -// Mutation-kill audit (executed kills recorded in the PR evidence): every -// assert reads crate outputs (`AStratifiedResult` fields or the returned -// `Err`); no assert recomputes the algorithm locally. -// -// - M1 selection rule (b-matching -> max information within the stratum): -// EXECUTED, killed by `as_bmatching_not_maxinfo` (the fixture makes the -// two rules pick different items and reads the crate exposure vector). -// - M2 stratification direction (ascending -> descending a): EXECUTED, -// killed by `as_stratum_assignment_and_remainder` (returned stratum -// vector flips) and `as_bmatching_not_maxinfo`. -// - M3 remainder placement (first strata -> last strata): EXECUTED, killed -// by `as_stratum_assignment_and_remainder` (7 items, 3 strata pins -// [3,2,2]) and `as_per_stratum_counting_identity` (stage lengths shift). -// - M4 stage confinement removed (b-matching over the whole pool): EXECUTED, -// killed by `as_bmatching_not_maxinfo` (stage 1 grabs the perfect-match -// high-a item) and `as_per_stratum_counting_identity`. -// - M5 tie-break direction (`i < j` -> `i > j` on equal b-distance): -// EXECUTED, killed by `as_tiebreak_lowest_index` (exact-tie fixture -// b = [-0.5, 0.5, ...] at theta_hat = 0; the mutant administers item 1 -// instead of item 0 and the returned exposure vector flips). -// -// Known limitation (documented, not hidden): the per-stratum counting -// identity holds under a stage-order reversal that swaps equal-length -// stages; the discriminating anchor for stage ORDER is -// `as_stratum_assignment_and_remainder` plus `as_bmatching_not_maxinfo`, -// whose fixtures make the first administered item stratum-0-only. - -fn as_cfg() -> AStratifiedConfig { - AStratifiedConfig { - n_strata: 4, - test_length: 10, - n_simulees: 300, - seed: 42, - q_theta: 31, - } -} - -#[test] -fn as_per_stratum_counting_identity() { - let (a, b, c) = pool30(); - let r = a_stratified(&a, &b, &c, &as_cfg()).expect("run"); - assert_eq!(r.exposure.len(), 30); - assert_eq!(r.stratum.len(), 30); - // 10 items over 4 stages, first-remainder policy: [3, 3, 2, 2]. - assert_eq!(r.stage_lengths, vec![3, 3, 2, 2]); - // Exact per-stratum counting identity: every simulee takes exactly - // stage_lengths[k] items from stratum k (all values read from the - // crate result). - for k in 0..4 { - let sum_k: f64 = r - .exposure - .iter() - .zip(&r.stratum) - .filter(|(_, &s)| s == k) - .map(|(&e, _)| e) - .sum(); - assert!( - (sum_k - r.stage_lengths[k] as f64).abs() < 1e-9, - "stratum {}: exposure sum {} != stage length {}", - k, - sum_k, - r.stage_lengths[k] - ); - } - let total: f64 = r.exposure.iter().sum(); - assert!((total - 10.0).abs() < 1e-9, "global sum {} != L", total); - let max_from_vec = r.exposure.iter().cloned().fold(0.0_f64, f64::max); - assert_eq!(r.max_exposure, max_from_vec); - // theta stats are finite and rmse is nonnegative by construction of the - // crate computation (reads crate outputs; guards NaN regressions). - assert!(r.theta_rmse.is_finite() && r.theta_rmse >= 0.0); - assert!(r.theta_bias.is_finite()); -} - -#[test] -fn as_stratum_assignment_and_remainder() { - // Scrambled a with one exact tie (indices 1 and 5 share a = 0.9): - // ascending stable order by (a, index) is - // idx 3 (0.5), 1 (0.9), 5 (0.9), 0 (1.0), 6 (1.4), 2 (1.8), 4 (2.2) - // and the 7 = 3+2+2 first-remainder partition gives strata - // {3,1,5} -> 0, {0,6} -> 1, {2,4} -> 2. - let a = vec![1.0, 0.9, 1.8, 0.5, 2.2, 0.9, 1.4]; - let b = vec![0.0; 7]; - let c = vec![0.0; 7]; - let cfg = AStratifiedConfig { - n_strata: 3, - test_length: 3, - n_simulees: 5, - seed: 1, - q_theta: 11, - }; - let r = a_stratified(&a, &b, &c, &cfg).expect("run"); - assert_eq!(r.stratum, vec![1, 0, 2, 0, 2, 0, 1]); - assert_eq!(r.stage_lengths, vec![1, 1, 1]); -} - -#[test] -fn as_bmatching_not_maxinfo() { - // Stage 1 draws only from the low-a stratum {i0, i1}. At the initial - // theta_hat = 0: b-matching picks i0 (|b| = 1 < 2), while maximum - // information picks i1 (info 0.064 > 0.059) — the rules disagree, so a - // max-info mutant flips the crate exposure vector. A no-stratification - // mutant instead picks i2 (b = 0, perfect match) in stage 1. - let a = vec![0.5, 0.6, 2.0, 2.5]; - let b = vec![-1.0, 2.0, 0.0, 5.0]; - let c = vec![0.0; 4]; - let cfg = AStratifiedConfig { - n_strata: 2, - test_length: 2, - n_simulees: 50, - seed: 9, - q_theta: 21, - }; - let r = a_stratified(&a, &b, &c, &cfg).expect("run"); - // Stage 1: i0 for every simulee (deterministic: selection depends only - // on the initial theta_hat). Stage 2: i2 beats i3 at any theta in - // [-4, 4]. All asserts read the crate exposure/stratum outputs. - assert_eq!(r.stratum, vec![0, 0, 1, 1]); - assert_eq!(r.exposure, vec![1.0, 0.0, 1.0, 0.0]); -} - -#[test] -fn as_deterministic_under_seed() { - let (a, b, c) = pool30(); - let r1 = a_stratified(&a, &b, &c, &as_cfg()).expect("run 1"); - let r2 = a_stratified(&a, &b, &c, &as_cfg()).expect("run 2"); - assert_eq!(r1.exposure, r2.exposure); - assert_eq!(r1.theta_rmse, r2.theta_rmse); - let r3 = a_stratified( - &a, - &b, - &c, - &AStratifiedConfig { - seed: 43, - ..as_cfg() - }, - ) - .expect("run 3"); - // A different seed must actually change the simulation. - assert_ne!(r1.exposure, r3.exposure); -} - -#[test] -fn as_input_validation() { - let (a, b, c) = pool30(); - let cfg = as_cfg(); - assert!(a_stratified(&a[..29], &b, &c, &cfg).is_err()); - assert!(a_stratified(&[], &[], &[], &cfg).is_err()); - let mut bad_a = a.clone(); - bad_a[0] = -0.5; - assert!(a_stratified(&bad_a, &b, &c, &cfg).is_err()); - let mut bad_c = c.clone(); - bad_c[3] = 1.0; - assert!(a_stratified(&a, &b, &bad_c, &cfg).is_err()); - for (n_strata, test_length) in [(0, 10), (11, 10), (4, 0), (4, 31)] { - assert!(a_stratified( - &a, - &b, - &c, - &AStratifiedConfig { - n_strata, - test_length, - ..cfg.clone() - } - ) - .is_err()); - } - assert!(a_stratified( - &a, - &b, - &c, - &AStratifiedConfig { - n_simulees: 0, - ..cfg.clone() - } - ) - .is_err()); - assert!(a_stratified(&a, &b, &c, &AStratifiedConfig { q_theta: 2, ..cfg }).is_err()); -} - -#[test] -fn as_tiebreak_lowest_index() { - // Exact b-distance tie at the deterministic initial theta_hat = 0: - // |(-0.5) - 0| == |0.5 - 0| == 0.5 (exact in f64). All a equal, so the - // single stratum lists items in original-index order; the crate must - // administer item 0, not item 1. Reads the returned exposure vector. - // Kills M5 (tie-break `i < j` -> `i > j`), which returns [0, 1, 0, 0]. - let a = [1.0, 1.0, 1.0, 1.0]; - let b = [-0.5, 0.5, 3.0, 4.0]; - let c = [0.0; 4]; - let r = a_stratified( - &a, - &b, - &c, - &AStratifiedConfig { - n_strata: 1, - test_length: 1, - n_simulees: 1, - seed: 7, - q_theta: 21, - }, - ) - .expect("tie fixture"); - assert_eq!(r.exposure, vec![1.0, 0.0, 0.0, 0.0]); -} - -// >= 500-replication Monte Carlo: the design's headline property — exposure -// is more BALANCED than unconstrained max-info CAT — holds on average, the -// counting identity holds in every replication, and theta recovery stays -// reasonable. With the repository's deterministic initial theta_hat = 0 the -// first administered item is the same for every simulee under BOTH designs -// (its exposure is exactly 1), so max exposure cannot discriminate; the -// imbalance metric is the summed squared deviation of the exposure rates -// from the uniform rate L/n (a chi-square-type summary of crate outputs, -// in the spirit of the exposure chi-square used by Barrada et al., 2006). -// Run with `cargo test -- --ignored`. -#[test] -#[ignore] -fn as_monte_carlo_500() { - let (a, b, c) = pool30(); - let uniform = 10.0 / 30.0; - let imbalance = |e: &[f64]| -> f64 { e.iter().map(|&x| (x - uniform) * (x - uniform)).sum() }; - // Unconstrained max-info baseline via sympson_hetter with r_max = 1 - // (documented exact reduction; reads crate output). - let free = sympson_hetter( - &a, - &b, - &c, - &SympsonHetterConfig { - r_max: 1.0, - test_length: 10, - n_simulees: 1500, - max_iter: 1, - tol: 0.02, - seed: 5, - q_theta: 31, - }, - ) - .expect("baseline"); - let free_imbalance = imbalance(&free.exposure); - let reps = 500; - let mut sum_imb = 0.0; - let mut sum_rmse = 0.0; - for rep in 0..reps { - let r = a_stratified( - &a, - &b, - &c, - &AStratifiedConfig { - n_simulees: 300, - seed: 1000 + rep as u64, - ..as_cfg() - }, - ) - .expect("run"); - let total: f64 = r.exposure.iter().sum(); - assert!((total - 10.0).abs() < 1e-9, "rep {}: identity broke", rep); - sum_imb += imbalance(&r.exposure); - sum_rmse += r.theta_rmse; - } - let mean_imb = sum_imb / reps as f64; - let mean_rmse = sum_rmse / reps as f64; - assert!( - mean_imb < free_imbalance, - "a-stratified mean exposure imbalance {} not below max-info baseline {}", - mean_imb, - free_imbalance - ); - assert!( - mean_rmse < 0.7, - "mean theta RMSE {} unexpectedly poor", - mean_rmse - ); -} - -// ===================== Chang-Ying (1996) KL information selection ===================== -// -// Mutation-kill audit (executed kills recorded in the PR evidence). Every -// assert reads crate outputs (`kl_information` vectors, `KlSelectResult` -// fields, or returned `Err`); no assert recomputes the integral locally. -// -// - KL-M1 direction flip (`p0/p` -> `p/p0` inside both logs): killed by -// `kl_pinned_oracles` (reverse-KL areas differ from the pinned constants). -// - KL-M2 drop the Q-term of the pointwise KL: killed by `kl_pinned_oracles` -// and by `kl_fisher_small_delta_anchor` (the Taylor coefficient changes, -// so the ratio gate fails). -// - KL-M3 `delta = r / n` instead of `r / sqrt(n)`: killed by -// `kl_select_full_vector_oracle`, which asserts BOTH the returned `delta` -// AND the full index vector against independent constants at n = 4 -// (an argmax-only test would NOT reliably catch this; that limitation is -// disclosed here per the spec review). -// - KL-M4 argmax -> argmin: killed by `kl_select_full_vector_oracle` (the -// pool has a unique maximum, item 1, and a unique minimum, item 4, and no -// masking removes the minimum). -// -// Oracle provenance: every pinned constant was computed independently TWICE -// before implementation (adversarial spec review: 80-digit Decimal Simpson; -// implementer: 60-digit Decimal Simpson, 4000 panels); the two computations -// agree to 15 significant digits. Values are UNNORMALIZED areas (see the -// contract in the exposure.rs section comment). - -/// Pinned KL-index areas, plus the 3PL mirrored-b asymmetry pair: with c > 0 -/// the index is NOT invariant under b -> 2*theta0 - b, so a mirror-blind -/// implementation cannot pass. All asserts read kl_information outputs. -#[test] -fn kl_pinned_oracles() { - let v = kl_information(&[1.2], &[0.3], &[0.0], 0.5, 1.0).unwrap(); - assert!( - (v[0] - 0.114454883565329).abs() < 1e-9, - "2PL area = {}", - v[0] - ); - let v = kl_information(&[1.0], &[0.0], &[0.2], 0.0, 0.5).unwrap(); - assert!( - (v[0] - 0.00687308819864807).abs() < 1e-9, - "3PL area = {}", - v[0] - ); - // Mirrored-b pair (asymmetry anchor). - let hi = kl_information(&[1.0], &[0.7], &[0.2], 0.0, 0.5).unwrap()[0]; - let lo = kl_information(&[1.0], &[-0.7], &[0.2], 0.0, 0.5).unwrap()[0]; - assert!((hi - 0.00524190785695519).abs() < 1e-9, "b=+0.7 = {hi}"); - assert!((lo - 0.00666896390226957).abs() < 1e-9, "b=-0.7 = {lo}"); - assert!(lo > hi, "3PL c > 0 must break the b-mirror symmetry"); - // Extreme high-discrimination oracle (a = 20, delta = 3): tail - // probabilities reach ~1e-26, so any hard probability clamp (e.g. at - // 1e-12) truncates the log tails and underestimates the area (the - // pre-fix clamped implementation returned 59.7296..., ~30% low). - // Oracle recomputed independently: Decimal 60-digit Simpson converges - // to 85.923363619982739465... at 2048-16384 panels. Kills any - // reintroduced probability clamping or saturating ICC evaluation. - let v = kl_information(&[20.0], &[0.0], &[0.0], 0.0, 3.0).unwrap(); - assert!( - (v[0] - 85.9233636199827395).abs() < 1e-9, - "extreme a=20 area = {}", - v[0] - ); - // Saturated finite inputs must stay finite (0 ln 0 = 0 convention): - // a = 1e308 drives z to inf so ln Q0 = ln Q = -inf; an unguarded - // 0 * (-inf - -inf) product returns NaN (round-2 review finding). - let v = kl_information(&[1e308], &[0.0], &[0.0], 2.0, 1.0).unwrap(); - assert!( - v[0].is_finite() && v[0] >= 0.0, - "saturated area must be finite nonneg, got {}", - v[0] - ); -} - -/// Small-delta Fisher anchor: area / (I(theta0) * delta^3 / 3) -> 1. The -/// numerator reads the crate area; the denominator is the INDEPENDENT -/// closed-form limit (2PL Fisher information a^2 p q), not a recomputation -/// of the integral, so the anchor discriminates. -#[test] -fn kl_fisher_small_delta_anchor() { - let (a, b, t0) = (1.2f64, 0.3f64, 0.5f64); - let p = 1.0 / (1.0 + (-a * (t0 - b)).exp()); - let fisher = a * a * p * (1.0 - p); - let delta = 1e-3; - let area = kl_information(&[a], &[b], &[0.0], t0, delta).unwrap()[0]; - let ratio = area / (fisher * delta * delta * delta / 3.0); - assert!((ratio - 1.0).abs() < 1e-4, "ratio = {ratio}"); -} - -/// Full-vector selection oracle at n = 4 (delta = 3/2): asserts the returned -/// delta, all five index values against independent constants, the argmax -/// (item 1, unique max), and masking (with item 1 administered the argmax -/// moves to item 3). Item 4 is the unique minimum and stays available, so -/// argmax -> argmin (KL-M4) and delta = r/n (KL-M3: delta would be 0.75 and -/// every index value changes) both fail here. Asserts read KlSelectResult. -#[test] -fn kl_select_full_vector_oracle() { - let a = [0.8, 1.5, 1.0, 2.0, 0.6]; - let b = [-1.0, 0.4, 0.0, 1.2, 0.3]; - let c = [0.0, 0.0, 0.15, 0.1, 0.0]; - let expected = [ - 0.139656556289429, - 0.562390595050374, - 0.194256509461839, - 0.349021047637362, - 0.0992541089721506, - ]; - let r = kl_select(&a, &b, &c, &[false; 5], 0.25, 4, 3.0).unwrap(); - assert!((r.delta - 1.5).abs() < 1e-15, "delta = {}", r.delta); - for i in 0..5 { - assert!( - (r.index[i] - expected[i]).abs() < 1e-9, - "index[{i}] = {}", - r.index[i] - ); - } - assert_eq!(r.selected, 1); - // Masking: administered items keep their index but cannot be selected. - let mask = [false, true, false, false, false]; - let r2 = kl_select(&a, &b, &c, &mask, 0.25, 4, 3.0).unwrap(); - assert_eq!(r2.selected, 3); - assert!((r2.index[1] - expected[1]).abs() < 1e-9); -} - -/// K(theta0 || theta0) = 0 and the area is strictly positive for delta > 0 -/// (Gibbs / non-negativity). Reads crate outputs. -#[test] -fn kl_nonnegativity_and_zero_at_center() { - let area = kl_information(&[1.0], &[0.0], &[0.0], 0.0, 2.0).unwrap()[0]; - assert!(area > 0.0); - let tiny = kl_information(&[1.0], &[0.0], &[0.0], 0.0, 1e-9).unwrap()[0]; - // Floating rounding can leave the near-zero area at +/- a few ulps. - assert!(tiny.abs() < 1e-15, "tiny = {tiny}"); -} - -/// Every rejection is a crate Err. -#[test] -fn kl_error_paths() { - assert!(kl_information(&[], &[], &[], 0.0, 1.0).is_err()); // empty - assert!(kl_information(&[1.0], &[0.0, 1.0], &[0.0], 0.0, 1.0).is_err()); // mismatch - assert!(kl_information(&[1.0], &[0.0], &[0.0], f64::NAN, 1.0).is_err()); // theta0 - assert!(kl_information(&[1.0], &[0.0], &[0.0], 0.0, 0.0).is_err()); // delta <= 0 - assert!(kl_information(&[1.0], &[0.0], &[0.0], 0.0, f64::INFINITY).is_err()); - assert!(kl_information(&[-1.0], &[0.0], &[0.0], 0.0, 1.0).is_err()); // a <= 0 - assert!(kl_information(&[1.0], &[f64::NAN], &[0.0], 0.0, 1.0).is_err()); // non-finite - assert!(kl_information(&[1.0], &[0.0], &[1.0], 0.0, 1.0).is_err()); // c >= 1 - assert!(kl_information(&[1.0], &[0.0], &[-0.1], 0.0, 1.0).is_err()); // c < 0 - let (a, b, c) = ([1.0], [0.0], [0.0]); - assert!(kl_select(&a, &b, &c, &[false, false], 0.0, 1, 3.0).is_err()); // mask len - assert!(kl_select(&a, &b, &c, &[false], 0.0, 0, 3.0).is_err()); // n = 0 - assert!(kl_select(&a, &b, &c, &[false], 0.0, 1, 0.0).is_err()); // r <= 0 - assert!(kl_select(&a, &b, &c, &[true], 0.0, 1, 3.0).is_err()); // exhausted -} - -/// MC-500 (#[ignore]): paired-design CAT comparison, KL selection vs random -/// selection. 40-item deterministic 2PL pool, 500 simulees, test length 15, -/// COMMON random numbers: both arms share each simulee's true theta and the -/// same response-uniform stream indexed by administration position, so the -/// arms differ ONLY in the selection rule. First item is fixed (argmin |b|) -/// in both arms because delta = r/sqrt(n) needs n >= 1. Gates (adversarial -/// spec review ruling 5): RMSE_KL < 0.45 sanity ceiling and paired -/// superiority RMSE_KL + 0.01 < RMSE_random under the fixed seed -/// (seed-locked diagnostic, margin piloted before pinning). The KL arm's -/// selections read kl_select crate outputs each step. -#[test] -#[ignore] -fn kl_mc500_paired_cat_recovery() { - let n_items = 40usize; - let mut a = vec![0.0; n_items]; - let mut b = vec![0.0; n_items]; - let c = vec![0.0; n_items]; - for i in 0..n_items { - a[i] = 0.5 + 1.5 * ((i % 8) as f64) / 7.0; - b[i] = -2.0 + 4.0 * (i as f64) / ((n_items - 1) as f64); - } - let first = (0..n_items) - .min_by(|&i, &j| b[i].abs().partial_cmp(&b[j].abs()).unwrap()) - .unwrap(); - let grid: Vec = (0..81).map(|q| -4.0 + 8.0 * (q as f64) / 80.0).collect(); - let log_prior: Vec = grid.iter().map(|t| -0.5 * t * t).collect(); - let test_len = 15usize; - let n_sim = 500usize; - let mut rng = Lcg(0xC4A6_1996); - let mut scratch: Vec = Vec::with_capacity(grid.len()); - let (mut sse_kl, mut sse_rand) = (0.0f64, 0.0f64); - for _ in 0..n_sim { - let theta_true = rng.normal(); - let u: Vec = (0..test_len).map(|_| rng.next_f64()).collect(); - let pick: Vec = (0..test_len).map(|_| rng.next_f64()).collect(); - for arm in 0..2 { - let mut used = vec![false; n_items]; - let mut responses: Vec<(usize, f64)> = Vec::with_capacity(test_len); - let mut theta_hat = 0.0f64; - for k in 0..test_len { - let item = if k == 0 { - first - } else if arm == 0 { - kl_select(&a, &b, &c, &used, theta_hat, k, 3.0) - .unwrap() - .selected - } else { - let avail: Vec = (0..n_items).filter(|&i| !used[i]).collect(); - avail[((pick[k] * avail.len() as f64) as usize).min(avail.len() - 1)] - }; - used[item] = true; - let p = p3pl(theta_true, a[item], b[item], c[item]); - let y = if u[k] < p { 1.0 } else { 0.0 }; - responses.push((item, y)); - theta_hat = eap_interim(&a, &b, &c, &responses, &grid, &log_prior, &mut scratch); - } - let e = theta_hat - theta_true; - if arm == 0 { - sse_kl += e * e; - } else { - sse_rand += e * e; - } - } - } - let rmse_kl = (sse_kl / n_sim as f64).sqrt(); - let rmse_rand = (sse_rand / n_sim as f64).sqrt(); - assert!(rmse_kl < 0.45, "rmse_kl = {rmse_kl}"); - assert!( - rmse_kl + 0.01 < rmse_rand, - "paired superiority failed: kl = {rmse_kl}, random = {rmse_rand}" - ); -} - -// ===================== Owen (1975) approximate Bayesian sequential CAT ===================== -// -// Mutation-kill audit (kills executed and recorded in the PR evidence): -// every assert reads owen_update / owen_cat crate outputs. Pinned oracles -// come from the adversarial spec review's high-precision numerical -// integration of the EXACT posterior (agreement with the closed forms at -// ~1e-13); test tolerance 5e-7 absorbs the crate's erfc approximation error -// (|err| < 1.2e-7) while still killing O(0.01)+ formula mutations. -// -// - OWEN-M1 incorrect-update sign flip (mu + instead of -): killed by -// owen_pinned_update_oracles (incorrect mean oracle) and owen_mirror_symmetry. -// - OWEN-M2 sig2/s -> sig2/s2 in the mean shift: killed by both pinned mean -// oracles. -// - OWEN-M3 drop the D term in the variance update (K*(K+D) -> K*K): killed -// by the pinned variance oracles. -// - OWEN-M4 selection argmin -> argmax: killed by owen_cat_trajectory_oracle -// (administered order changes). - -/// Pinned posterior-moment oracles at (a=1.5, b=0.3, mu=0.2, sig2=1.2), -/// independently verified by numerical integration of the exact posterior -/// (spec review oracle table). All asserts read owen_update outputs. -#[test] -fn owen_pinned_update_oracles() { - let (mu, sig2) = owen_update(1.5, 0.3, 0.0, true, 0.2, 1.2).unwrap(); - assert!( - (mu - 0.993708628794091).abs() < 5e-7, - "c=0 correct mu = {mu}" - ); - assert!( - (sig2 - 0.627945890895211).abs() < 5e-7, - "c=0 correct sig2 = {sig2}" - ); - let (mu, sig2) = owen_update(1.5, 0.3, 0.0, false, 0.2, 1.2).unwrap(); - assert!( - (mu - -0.500813523713129).abs() < 5e-7, - "c=0 incorrect mu = {mu}" - ); - assert!( - (sig2 - 0.657719958655775).abs() < 5e-7, - "c=0 incorrect sig2 = {sig2}" - ); - let (mu, sig2) = owen_update(1.5, 0.3, 0.2, true, 0.2, 1.2).unwrap(); - assert!( - (mu - 0.717701908086462).abs() < 5e-7, - "c=0.2 correct mu = {mu}" - ); - assert!( - (sig2 - 0.969762981710486).abs() < 5e-7, - "c=0.2 correct sig2 = {sig2}" - ); -} - -/// Structural invariants that discriminate directional mutations: with c = 0 -/// and mu = b the correct/incorrect updates are exact mirrors (equal-magnitude -/// opposite mean shifts, identical variances); both variances shrink; and as -/// c -> 1 a correct response carries no information so mu' -> mu. All asserts -/// read owen_update outputs. -#[test] -fn owen_mirror_symmetry_and_shrinkage() { - let (mu_c, s2_c) = owen_update(1.3, 0.4, 0.0, true, 0.4, 0.9).unwrap(); - let (mu_i, s2_i) = owen_update(1.3, 0.4, 0.0, false, 0.4, 0.9).unwrap(); - // Tolerance 1e-6, not 1e-12: the crate's rational erfc approximation - // (|err| < 1.2e-7) perturbs Phi(0) away from exactly 0.5, so the exact - // mirror identity holds only up to approximation error. Still kills a - // sign-flip mutation (shift magnitude ~0.5). - assert!( - ((mu_c - 0.4) + (mu_i - 0.4)).abs() < 1e-6, - "mirror mean shifts: {mu_c} vs {mu_i}" - ); - assert!((s2_c - s2_i).abs() < 1e-6, "mirror variances"); - assert!(s2_c < 0.9 && s2_i < 0.9, "variance must shrink at c = 0"); - // Guessing damps the correct-response information: mean shift and - // variance reduction both smaller than at c = 0. - let (mu_g, s2_g) = owen_update(1.3, 0.4, 0.9, true, 0.4, 0.9).unwrap(); - assert!(mu_g - 0.4 > 0.0 && mu_g - 0.4 < mu_c - 0.4, "c damps shift"); - assert!(s2_g > s2_c, "c damps variance reduction"); -} - -/// Pinned 4-step CAT trajectory on a 6-item 3PNO pool: administration order -/// and both moment traces (tolerance 1e-5: four compounded erfc-approximation -/// steps). Kills selection mutations (argmin -> argmax changes the order) and -/// any update mutation (traces change). All asserts read owen_cat outputs. -#[test] -fn owen_cat_trajectory_oracle() { - let a = [0.9, 1.4, 1.1, 2.0, 0.7, 1.6]; - let b = [-1.2, 0.5, 0.0, 1.0, -0.4, 0.2]; - let c = [0.0, 0.1, 0.0, 0.15, 0.0, 0.0]; - let resp = [1u8, 0, 1, 0, 1, 1]; - let r = owen_cat(&a, &b, &c, &resp, 0.0, 1.0, 4, None).unwrap(); - assert_eq!(r.administered, vec![2, 1, 5, 3]); - let mu_exp = [ - 0.5903867604819626, - 0.07540289320219584, - 0.48423427681776365, - 0.33188033525266003, - ]; - let s2_exp = [ - 0.6514434730476137, - 0.4123387131860715, - 0.27135396476526175, - 0.20724352639310067, - ]; - for k in 0..4 { - assert!( - (r.mu_trace[k] - mu_exp[k]).abs() < 1e-5, - "mu_trace[{k}] = {}", - r.mu_trace[k] - ); - assert!( - (r.sig2_trace[k] - s2_exp[k]).abs() < 1e-5, - "sig2_trace[{k}] = {}", - r.sig2_trace[k] - ); - } - assert!((r.mu - mu_exp[3]).abs() < 1e-5); - assert!((r.sig2 - s2_exp[3]).abs() < 1e-5); - // Variance-threshold stopping (Owen's rule): a loose threshold stops - // after the first item even though test_length allows more. - let r = owen_cat(&a, &b, &c, &resp, 0.0, 1.0, 4, Some(0.7)).unwrap(); - assert_eq!(r.administered.len(), 1); - assert!(r.sig2 <= 0.7, "stop threshold respected, sig2 = {}", r.sig2); -} - -/// Error paths: every case reads the returned Err. -#[test] -fn owen_error_paths() { - assert!(owen_update(0.0, 0.0, 0.0, true, 0.0, 1.0).is_err()); - assert!(owen_update(1.0, 0.0, 1.0, true, 0.0, 1.0).is_err()); - assert!(owen_update(1.0, 0.0, -0.1, true, 0.0, 1.0).is_err()); - assert!(owen_update(1.0, f64::NAN, 0.0, true, 0.0, 1.0).is_err()); - assert!(owen_update(1.0, 0.0, 0.0, true, 0.0, 0.0).is_err()); - assert!(owen_update(1.0, 0.0, 0.0, true, f64::INFINITY, 1.0).is_err()); - let a = [1.0, 1.2]; - let b = [0.0, 0.5]; - let c = [0.0, 0.0]; - let resp = [1u8, 0]; - assert!(owen_cat(&[], &[], &[], &[], 0.0, 1.0, 1, None).is_err()); - assert!(owen_cat(&a, &b[..1], &c, &resp, 0.0, 1.0, 1, None).is_err()); - assert!(owen_cat(&a, &b, &c, &[2u8, 0], 0.0, 1.0, 1, None).is_err()); - assert!(owen_cat(&a, &b, &c, &resp, 0.0, 1.0, 0, None).is_err()); - assert!(owen_cat(&a, &b, &c, &resp, 0.0, 1.0, 3, None).is_err()); - assert!(owen_cat(&a, &b, &c, &resp, 0.0, 0.0, 1, None).is_err()); - assert!(owen_cat(&a, &b, &c, &resp, 0.0, 1.0, 1, Some(0.0)).is_err()); - assert!(owen_cat(&a, &b, &c, &resp, 0.0, 1.0, 1, Some(f64::NAN)).is_err()); -} - -/// MC-500 (#[ignore]): Owen CAT ability recovery on a 40-item 2PL pool with -/// generated 3PNO responses, paired against a random-order arm using COMMON -/// random numbers (shared theta_true and per-item response uniforms), the -/// same design as the KL MC test. Gates read only crate outputs (final mu -/// per replicate). -#[test] -#[ignore] -fn owen_mc500_paired_recovery() { - let n_items = 40usize; - let mut a = Vec::with_capacity(n_items); - let mut b = Vec::with_capacity(n_items); - for i in 0..n_items { - a.push(0.5 + 1.5 * ((i % 8) as f64) / 7.0); - b.push(-2.0 + 4.0 * (i as f64) / ((n_items - 1) as f64)); - } - let c = vec![0.0; n_items]; - let test_len = 15usize; - let n_rep = 500usize; - let mut rng = Lcg(0x0EE1_1975); - let (mut sse_owen, mut sse_rand) = (0.0f64, 0.0f64); - for rep in 0..n_rep { - let theta = rng.normal(); - // Common random numbers: one uniform per item, shared across arms. - let mut resp = vec![0u8; n_items]; - for i in 0..n_items { - let p = c[i] + (1.0 - c[i]) * crate::exposure::norm_cdf(a[i] * (theta - b[i])); - resp[i] = u8::from(rng.next_f64() < p); - } - let r = owen_cat(&a, &b, &c, &resp, 0.0, 1.0, test_len, None).unwrap(); - let e = r.mu - theta; - sse_owen += e * e; - // Random arm: deterministic pseudo-random order seeded by rep, same - // responses, same Owen updates. - let mut order: Vec = (0..n_items).collect(); - let mut sh = Lcg(0x5EED_0000 ^ rep as u64); - for i in (1..n_items).rev() { - let j = (sh.next_f64() * ((i + 1) as f64)) as usize; - order.swap(i, j.min(i)); - } - let (mut mu, mut s2) = (0.0f64, 1.0f64); - for &item in order.iter().take(test_len) { - let (m, v) = owen_update(a[item], b[item], c[item], resp[item] == 1, mu, s2).unwrap(); - mu = m; - s2 = v; - } - let e = mu - theta; - sse_rand += e * e; - } - let rmse_owen = (sse_owen / n_rep as f64).sqrt(); - let rmse_rand = (sse_rand / n_rep as f64).sqrt(); - assert!(rmse_owen < 0.45, "Owen RMSE = {rmse_owen}"); - assert!( - rmse_owen + 0.01 < rmse_rand, - "adaptive must beat random: {rmse_owen} vs {rmse_rand}" - ); -} - -/// Regression for the impl-review tail-cancellation finding: at D = 8 the -/// subtractive form `1 - Phi(D)` cancels catastrophically (it returned -/// sig2' ~ 2.58, an INFLATED variance); the fixed `Phi(-D)` form must match -/// the exact-erfc reference (5.571034034310448, 0.507162441721855) computed -/// with math.erfc. Tolerance 1e-4: the crate erfc's ~1e-7 relative tail -/// error is amplified by L*(L-D). Both asserts read owen_update outputs; -/// the pre-fix mutant fails both by ~0.4 and ~2.07 respectively. -#[test] -fn owen_incorrect_tail_stability() { - let mu0 = 11.313708498984761; // D = mu0 / sqrt(2) = 8 - let (mu, sig2) = owen_update(1.0, 0.0, 0.0, false, mu0, 1.0).unwrap(); - assert!((mu - 5.571034034310448).abs() < 1e-4, "tail mu = {mu}"); - assert!( - (sig2 - 0.507162441721855).abs() < 1e-4, - "tail sig2 = {sig2}" - ); - assert!( - sig2 < 1.0, - "an observed response must not inflate the variance" - ); -} - -/// Doc-claim anchor (round-2 review): a deep-tail incorrect response where -/// Phi(-D) underflows must return the documented degenerate-posterior Err, -/// never a silent garbage update. Reads the crate's Err. -#[test] -fn owen_deep_tail_returns_err() { - assert!(owen_update(1.0, 0.0, 0.0, false, 141.421356237, 1.0).is_err()); -} - -// ===================== Kingsbury-Zara CCAT content balancing ===================== -// -// Mutation-kill audit (kills executed, recorded in the PR evidence): every -// assert reads CcatSelectResult fields or the returned Err. Pinned oracles -// from the adversarial spec review (>= 30 significant digits, exact -// arithmetic); f64 tolerance 1e-12. -// -// - CCAT-M1 gap argmax -> argmin: killed by ccat_pinned_oracle (group flips -// to 1). -// - CCAT-M2 within-group info argmax -> argmin: killed by ccat_pinned_oracle -// (item 1 -> item 4). -// - CCAT-M3 zero-coverage priority dropped (always gap rule): killed by -// ccat_zero_coverage_priority (gap rule would pick group 0, priority -// demands group 2). -// - CCAT-M4 info formula drops the guessing factor ((P-c)/(1-c))^2: killed -// by ccat_pinned_oracle info values. - -fn ccat_pool() -> (Vec, Vec, Vec, Vec, Vec) { - ( - vec![1.0, 1.5, 0.8, 2.0, 1.2, 0.9], - vec![-0.5, 0.2, 0.0, 0.8, -0.2, 0.4], - vec![0.0, 0.1, 0.0, 0.2, 0.0, 0.0], - vec![0, 0, 1, 1, 0, 1], - vec![0.6, 0.4], - ) -} - -/// Pinned oracle (spec review): both groups covered, group 0 has the larger -/// gap (0.1 vs -0.1); items 1 and 4 compete on information and item 1 wins. -/// Info values pinned to the review's exact-arithmetic computation. -#[test] -fn ccat_pinned_oracle() { - let (a, b, c, g, t) = ccat_pool(); - let adm = [true, false, false, true, false, false]; - let r = ccat_select(&a, &b, &c, &g, &t, &adm, 0.1).unwrap(); - assert_eq!(r.group, 0); - assert_eq!(r.selected, 1); - assert!((r.discrepancy[0] - 0.1).abs() < 1e-12); - assert!((r.discrepancy[1] - -0.1).abs() < 1e-12); - assert!((r.info[1] - 0.451012779418390197920232968).abs() < 1e-12); - assert!((r.info[4] - 0.348583393587808097407435360).abs() < 1e-12); - assert!((r.info[3] - 0.280386779886269687918289656).abs() < 1e-12); -} - -/// Discriminating content-balancing oracle (spec review): unconstrained -/// max-info would pick item 1 (group 0, I = 0.4510...), but group 1 has -/// zero coverage after administering item 0, so CCAT must pick group 1 and -/// its most informative item 3 (I = 0.2804 > I_5 = 0.1989 > I_2 = 0.1597). -/// Kills a "global max info" mutant of the whole feature. -#[test] -fn ccat_balancing_overrides_global_max_info() { - let (a, b, c, g, t) = ccat_pool(); - let adm = [true, false, false, false, false, false]; - let r = ccat_select(&a, &b, &c, &g, &t, &adm, 0.1).unwrap(); - assert_eq!(r.group, 1); - assert_eq!(r.selected, 3); - // The globally most informative item is NOT the selection. - let imax = (0..6) - .filter(|&i| !adm[i]) - .max_by(|&x, &y| r.info[x].partial_cmp(&r.info[y]).unwrap()) - .unwrap(); - assert_eq!(imax, 1); - assert_ne!(r.selected, imax); -} - -/// Zero-coverage priority beats the raw gap rule (catR nextItem.R branch: -/// `min(empProp) == 0` short-circuits the gap comparison). 13-item pool, -/// 3 groups, 10 administered (1 from group 0, 9 from group 1, 0 from -/// group 2): gaps are [0.7, -0.8, 0.1], so the plain gap rule would choose -/// group 0, but group 2 is uncovered and must win. -#[test] -fn ccat_zero_coverage_priority() { - let n = 13; - let a = vec![1.0; n]; - let mut b = vec![0.0; n]; - b[11] = 0.3; // group-2 items differ so item choice is non-trivial - b[12] = 0.1; - let c = vec![0.0; n]; - // groups: 0,0,0 | 1 x 9 | 2,2 -> targets [0.8, 0.1, 0.1] - let mut g = vec![0usize; 3]; - g.extend(std::iter::repeat(1usize).take(8)); - g.extend([2usize, 2]); - let t = vec![0.8, 0.1, 0.1]; - let mut adm = vec![false; n]; - adm[0] = true; - adm[2] = true; // two from group 0 - for x in adm.iter_mut().take(11).skip(3) { - *x = true; // eight from group 1 - } - // k = 10, k_g = [2, 8, 0] -> f = [0.2, 0.8, 0.0], gaps [0.6, -0.7, 0.1]: - // the plain gap rule would choose group 0, but group 2 is uncovered. - let r = ccat_select(&a, &b, &c, &g, &t, &adm, 0.0).unwrap(); - assert_eq!(r.group, 2, "uncovered group must have priority"); - // gap rule alone would have chosen group 0: - let gap_argmax = (0..3) - .max_by(|&x, &y| r.discrepancy[x].partial_cmp(&r.discrepancy[y]).unwrap()) - .unwrap(); - assert_eq!(gap_argmax, 0); - // within group 2, item 12 (b = 0.1 closer to theta0 = 0 than b = 0.3) - // has higher information at equal a, c: - assert_eq!(r.selected, 12); - assert!(r.info[12] > r.info[11]); -} - -/// Exhausted groups are skipped: group 1 has the max gap but no -/// unadministered items, so the next-best eligible group is chosen. -#[test] -fn ccat_exhausted_group_skipped() { - let a = vec![1.0, 1.0, 1.0, 1.0]; - let b = vec![0.0, 0.1, 0.2, 0.3]; - let c = vec![0.0; 4]; - let g = vec![0usize, 0, 1, 1]; - let t = vec![0.2, 0.8]; - // Both group-1 items administered: k = 2, f = [0, 1], gaps [0.2, -0.2], - // group 1 exhausted -> eligible only group 0... but k_0 = 0 so the - // zero-priority rule also lands on group 0. Force covered case instead: - let adm = [true, false, true, true]; - // k = 3, k_0 = 1, k_1 = 2 -> f = [1/3, 2/3], gaps [ -0.1333, 0.1333 ]. - // Group 1 has the max gap but is exhausted; group 0 must be chosen. - let r = ccat_select(&a, &b, &c, &g, &t, &adm, 0.0).unwrap(); - assert_eq!(r.group, 0); - assert_eq!(r.selected, 1); - assert!(r.discrepancy[1] > r.discrepancy[0], "gap favored group 1"); -} - -/// Error paths: every case reads the returned Err. -#[test] -fn ccat_error_paths() { - let a = vec![1.0, 1.0]; - let b = vec![0.0, 0.0]; - let c = vec![0.0, 0.0]; - let g = vec![0usize, 1]; - let t = vec![0.5, 0.5]; - let adm = [false, false]; - assert!(ccat_select(&[], &[], &[], &[], &[], &[], 0.0).is_err()); - assert!(ccat_select(&a, &b[..1], &c, &g, &t, &adm, 0.0).is_err()); - assert!(ccat_select(&a, &b, &c, &[0, 2], &t, &adm, 0.0).is_err()); - assert!(ccat_select(&a, &b, &c, &g, &[0.5, 0.6], &adm, 0.0).is_err()); - assert!(ccat_select(&a, &b, &c, &g, &[1.0, 0.0], &adm, 0.0).is_err()); - assert!(ccat_select(&a, &b, &c, &g, &[], &adm, 0.0).is_err()); - assert!(ccat_select(&a, &b, &c, &g, &t, &adm, f64::NAN).is_err()); - assert!(ccat_select(&a, &b, &c, &g, &t, &[true, true], 0.0).is_err()); - assert!(ccat_select(&[1.0, -1.0], &b, &c, &g, &t, &adm, 0.0).is_err()); - assert!(ccat_select(&a, &b, &[0.0, 1.0], &g, &t, &adm, 0.0).is_err()); -} - -/// MC-500 (#[ignore]): 500 random pools/masks; structural invariants of the -/// catR-reproduced rule, all read from CcatSelectResult: the selection is -/// unadministered and inside the chosen group; the chosen group is eligible; -/// if every eligible group is covered, the chosen group attains the maximal -/// eligible discrepancy; if some eligible group is uncovered, the chosen -/// group is the lowest-index uncovered eligible group; the selected item -/// maximizes info within the chosen group (lowest index on ties). -#[test] -#[ignore] -fn ccat_mc500_invariants() { - let mut rng = Lcg(0x0CCA_1989); - for rep in 0..500 { - let n = 5 + (rng.next_f64() * 20.0) as usize; - let n_groups = 2 + (rng.next_f64() * 3.0) as usize; - let mut a = Vec::with_capacity(n); - let mut b = Vec::with_capacity(n); - let mut c = Vec::with_capacity(n); - let mut g = Vec::with_capacity(n); - for i in 0..n { - a.push(0.5 + 1.5 * rng.next_f64()); - b.push(-2.0 + 4.0 * rng.next_f64()); - c.push(0.25 * rng.next_f64()); - g.push(if i < n_groups { - i // guarantee every group is non-empty - } else { - (rng.next_f64() * n_groups as f64) as usize % n_groups - }); - } - let mut t: Vec = (0..n_groups).map(|_| 0.1 + rng.next_f64()).collect(); - let s: f64 = t.iter().sum(); - for x in t.iter_mut() { - *x /= s; - } - let mut adm: Vec = (0..n).map(|_| rng.next_f64() < 0.5).collect(); - if adm.iter().all(|&x| x) { - adm[rep % n] = false; - } - let theta0 = -1.0 + 2.0 * rng.next_f64(); - let r = ccat_select(&a, &b, &c, &g, &t, &adm, theta0).unwrap(); - assert!(!adm[r.selected], "rep {rep}: selected already administered"); - assert_eq!(g[r.selected], r.group, "rep {rep}: item outside group"); - let eligible: Vec = (0..n_groups) - .filter(|&gg| (0..n).any(|i| g[i] == gg && !adm[i])) - .collect(); - assert!(eligible.contains(&r.group), "rep {rep}: ineligible group"); - let k_g: Vec = (0..n_groups) - .map(|gg| (0..n).filter(|&i| g[i] == gg && adm[i]).count()) - .collect(); - let uncovered: Vec = eligible - .iter() - .cloned() - .filter(|&gg| k_g[gg] == 0) - .collect(); - if let Some(&first) = uncovered.first() { - assert_eq!(r.group, first, "rep {rep}: zero-coverage priority"); - } else { - let best = eligible - .iter() - .cloned() - .fold(f64::NEG_INFINITY, |m, gg| m.max(r.discrepancy[gg])); - assert!( - r.discrepancy[r.group] >= best - 1e-15, - "rep {rep}: gap rule violated" - ); - } - for i in 0..n { - if g[i] == r.group && !adm[i] { - assert!( - r.info[r.selected] >= r.info[i] - || (r.info[r.selected] == r.info[i] && r.selected <= i), - "rep {rep}: info rule violated" - ); - } - } - } -} - -/// Regression (impl-review MAJOR): with c = 0 and an extreme finite theta0 -/// the logistic underflows to P = 0 and the naive q/p * r^2 info form is -/// inf * 0 = NaN, which silently corrupted the within-group argmax (NaN -/// item stayed selected because `x > NaN` is false). The limit is 0, so -/// item 1 (P = 0.5, I = 0.25) must win and info[0] must be exactly 0. -#[test] -fn ccat_underflow_info_is_zero_not_nan() { - let a = vec![1.0, 1.0]; - let b = vec![0.0, -1e308]; - let c = vec![0.0, 0.0]; - let g = vec![0usize, 0]; - let t = vec![1.0]; - let adm = [false, false]; - let r = ccat_select(&a, &b, &c, &g, &t, &adm, -1e308).unwrap(); - assert_eq!(r.selected, 1); - assert!(r.info.iter().all(|x| x.is_finite()), "info must be finite"); - assert_eq!(r.info[0], 0.0); - assert!((r.info[1] - 0.25).abs() < 1e-12); -} - -/// Regressions (impl-review round 2): three extreme-but-valid inputs broke -/// the naive q/p * r^2 info form; the log-space form must handle all. -/// Expected values computed with 80-digit Decimal arithmetic from -/// I = a^2 L (1 - L), L = sigmoid(a(theta0 - b)), c = 0. Every assert -/// reads CcatSelectResult fields. -#[test] -fn ccat_info_extreme_inputs_stable() { - // (1) subnormal c > 0 with underflowed L: naive form gave - // inf * 0 = NaN and wrongly selected item 0. - let r = ccat_select( - &[1.0, 1.0], - &[1000.0, 0.0], - &[5e-324, 0.0], - &[0, 0], - &[1.0], - &[false, false], - 0.0, - ) - .unwrap(); - assert_eq!(r.selected, 1); - assert!(r.info.iter().all(|x| x.is_finite()), "info must be finite"); - assert!((r.info[1] - 0.25).abs() < 1e-12); - // (2) a = 1e154, z = -709: naive multiplication order overflowed - // info[0] to +inf (true value 1.2167807506233457) and selected item 0 - // over the genuinely better item 1 (I = 2.25). - let r = ccat_select( - &[1e154, 3.0], - &[709e-154, 0.0], - &[0.0, 0.0], - &[0, 0], - &[1.0], - &[false, false], - 0.0, - ) - .unwrap(); - assert!(r.info[0].is_finite()); - assert!((r.info[0] / 1.2167807506233457 - 1.0).abs() < 1e-9); - assert!((r.info[1] - 2.25).abs() < 1e-12); - assert_eq!(r.selected, 1); - // (3) a = 1e162, z = -745 (L subnormal): the interim p == 0 guard - // masked a genuinely informative item (true info[0] = - // 2.8223507304721003 > 2.25); item 0 must win. - let r = ccat_select( - &[1e162, 3.0], - &[745e-162, 0.0], - &[0.0, 0.0], - &[0, 0], - &[1.0], - &[false, false], - 0.0, - ) - .unwrap(); - assert!(r.info[0].is_finite()); - assert!((r.info[0] / 2.8223507304721003 - 1.0).abs() < 1e-9); - assert_eq!(r.selected, 0); - assert!(r.info[0] > r.info[1]); -} diff --git a/tests/unit/facets_tests.rs b/tests/unit/facets_tests.rs deleted file mode 100644 index e1a81a657..000000000 --- a/tests/unit/facets_tests.rs +++ /dev/null @@ -1,365 +0,0 @@ -use super::*; - -struct Lcg(u64); -impl Lcg { - fn f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.f64().max(1e-12); - let u2 = self.f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64).sqrt() -} -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let mx = x.iter().sum::() / n; - let my = y.iter().sum::() / n; - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for i in 0..x.len() { - sxy += (x[i] - mx) * (y[i] - my); - sxx += (x[i] - mx).powi(2); - syy += (y[i] - my).powi(2); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -/// Draw an MFRM category for ability `theta`, item `d`, rater `c`, thresholds `f`. -fn draw_facets(theta: f64, d: f64, c: f64, f: &[f64], u: f64) -> usize { - let lp = crate::rsm::rsm_logprobs(theta, d + c, f); - let mut cum = 0.0; - for (k, l) in lp.iter().enumerate() { - cum += l.exp(); - if u < cum { - return k; - } - } - lp.len() - 1 -} - -/// Simulate a fully crossed design. Returns row-major `P*I*J` categories. -fn simulate( - seed: u64, - n_persons: usize, - d: &[f64], - c: &[f64], - f: &[f64], -) -> Vec { - let mut rng = Lcg(seed); - let (ni, nj) = (d.len(), c.len()); - let mut y = vec![0usize; n_persons * ni * nj]; - for p in 0..n_persons { - let theta = rng.normal(); - for i in 0..ni { - for j in 0..nj { - y[p * ni * nj + i * nj + j] = draw_facets(theta, d[i], c[j], f, rng.f64()); - } - } - } - y -} - -// --------------------------------------------------------------------------- -// FD anchors on the M-step objective. These asserts read the crate's -// `location_score_terms` / `f_gradient` outputs and compare them against -// central finite differences of the crate's `pair_ell`/`total_ell` at an -// ASYMMETRIC point. Mutations killed: sign flips in the gradients, d<->c index -// transposition (the location derivative would hit the wrong count block), -// suffix-sum off-by-one in `f_gradient` (shifts which residuals feed g_m). -// --------------------------------------------------------------------------- - -#[test] -fn location_gradient_matches_fd() { - // Asymmetric params and asymmetric fake counts (not a fitted state). - let f = [0.9f64, -0.4, -0.5]; - let n_cat = 4usize; - let nodes = [-1.3f64, 0.2, 1.7]; - let mut r = vec![0.0f64; nodes.len() * n_cat]; - let mut rng = Lcg(7); - for v in r.iter_mut() { - *v = 0.05 + rng.f64() * 2.0; - } - let loc0 = 0.37f64; - let (mut g, mut h) = (0.0f64, 0.0f64); - location_score_terms(loc0, &f, &r, &nodes, n_cat, &mut g, &mut h); - let eps = 1e-6; - let fd = (pair_ell(loc0 + eps, &f, &r, &nodes, n_cat) - - pair_ell(loc0 - eps, &f, &r, &nodes, n_cat)) - / (2.0 * eps); - // location_score_terms accumulates -d ell/d location... verify sign - // convention explicitly: Newton uses step = g/h with update loc - step, - // and the code defines g = -sum k (r - nP) = -d ell/d loc? No: the - // derivative d ell/d loc = -sum_k k (r - n P) exactly, so g == fd. - assert!( - (g - fd).abs() < 1e-5, - "analytic {g} vs FD {fd}" - ); - assert!(h < 0.0, "location Hessian must be negative, got {h}"); - // Hessian FD check too (kills Var-of-score sign/formula mutations). - let (mut gp, mut hp) = (0.0f64, 0.0f64); - location_score_terms(loc0 + eps, &f, &r, &nodes, n_cat, &mut gp, &mut hp); - let (mut gm, mut hm) = (0.0f64, 0.0f64); - location_score_terms(loc0 - eps, &f, &r, &nodes, n_cat, &mut gm, &mut hm); - let fd_h = (gp - gm) / (2.0 * eps); - assert!((h - fd_h).abs() < 1e-4, "analytic H {h} vs FD {fd_h}"); -} - -#[test] -fn threshold_gradient_matches_fd() { - let d = [0.3f64, -0.8]; - let c = [0.5f64, -0.1, -0.4]; - let f = [0.7f64, -0.2, -0.5]; - let n_cat = 4usize; - let nodes = [-1.1f64, 0.4, 2.0]; - let n_pairs = d.len() * c.len(); - let mut rng = Lcg(11); - let mut r = vec![vec![0.0f64; nodes.len() * n_cat]; n_pairs]; - for blk in r.iter_mut() { - for v in blk.iter_mut() { - *v = 0.05 + rng.f64(); - } - } - let g = f_gradient(&f, &d, &c, &r, &nodes, d.len(), c.len(), n_cat); - let eps = 1e-6; - for m in 0..f.len() { - let mut fp = f.to_vec(); - fp[m] += eps; - let mut fm = f.to_vec(); - fm[m] -= eps; - let fd = (total_ell(&d, &c, &fp, &r, &nodes, d.len(), c.len(), n_cat) - - total_ell(&d, &c, &fm, &r, &nodes, d.len(), c.len(), n_cat)) - / (2.0 * eps); - assert!( - (g[m] - fd).abs() < 1e-5, - "f[{m}]: analytic {} vs FD {fd}", - g[m] - ); - } -} - -// --------------------------------------------------------------------------- -// J=1 reduction anchor: with one rater the MFRM must reproduce fit_rsm. -// Asserts read fit_facets' item_difficulty/thresholds/loglik and fit_rsm's -// outputs. Mutations killed: wrong aggregation over the rater axis, pair -// indexing bugs (i*n_raters+j vs j*n_items+i), severity leaking into the fit. -// --------------------------------------------------------------------------- -#[test] -fn single_rater_reduces_to_rsm() { - let d_true = [-1.2f64, -0.3, 0.4, 1.1]; - let f_true = [0.8f64, -0.8]; - let y = simulate(42, 400, &d_true, &[0.0], &f_true); - let res = fit_facets(&y, None, 400, 4, 1, 3, 21, 300, 1e-8).unwrap(); - let rsm = crate::rsm::fit_rsm(&y, None, 400, 4, 3, 21, 300, 1e-8).unwrap(); - // sum(c)=0 with one rater forces c_1 = 0 exactly. - assert!(res.rater_severity[0].abs() < 1e-12); - for i in 0..4 { - assert!( - (res.item_difficulty[i] - rsm.item_location[i]).abs() < 1e-4, - "item {i}: facets {} vs rsm {}", - res.item_difficulty[i], - rsm.item_location[i] - ); - } - for m in 0..2 { - assert!((res.thresholds[m] - rsm.thresholds[m]).abs() < 1e-4); - } - let lf = *res.loglik_trace.last().unwrap(); - let lr = *rsm.loglik_trace.last().unwrap(); - assert!((lf - lr).abs() < 1e-4, "loglik facets {lf} vs rsm {lr}"); - assert!(res.connected); - assert_eq!(res.n_parameters, 4 + 0 + 1); -} - -// --------------------------------------------------------------------------- -// Severity recovery with an asymmetric severity vector. Asserts read -// res.rater_severity (crate output). Mutations killed: over-collapse (all -// severities shrink to ~0 -> corr undefined/rmse large), sign flip in the c -// update (corr ~ -1), rater/item dimension-map swap (J=5 != I=6 so shapes -// diverge and recovery fails). -// --------------------------------------------------------------------------- -#[test] -fn recovers_asymmetric_rater_severity() { - let d_true = [-1.5f64, -0.9, -0.2, 0.3, 0.9, 1.6]; - let c_true = [-1.0f64, -0.3, 0.1, 0.4, 0.8]; // deliberately not centered - let f_true = [1.0f64, 0.1, -1.1]; - let y = simulate(2024, 800, &d_true, &c_true, &f_true); - let res = fit_facets(&y, None, 800, 6, 5, 4, 21, 500, 1e-8).unwrap(); - assert!(res.converged); - assert!(res.connected); - // Compare against the centered generating severities (model identifies c - // only up to the sum-zero constraint; the mean shift moves into d). - let mean_c = c_true.iter().sum::() / c_true.len() as f64; - let c_centered: Vec = c_true.iter().map(|v| v - mean_c).collect(); - let r = corr(&res.rater_severity, &c_centered); - let e = rmse(&res.rater_severity, &c_centered); - assert!(r > 0.95, "severity corr {r}"); - assert!(e < 0.15, "severity rmse {e}"); - // Item difficulty absorbs the shift: d_hat ~ d_true + mean_c (+ f-centering - // shift, which is 0 here up to sampling because f_true sums to 0). - let d_shifted: Vec = d_true.iter().map(|v| v + mean_c).collect(); - let rd = corr(&res.item_difficulty, &d_shifted); - assert!(rd > 0.95, "difficulty corr {rd}"); - // Structural invariants of the returned parameters (not test-local math): - // both centerings hold on the crate output. - let sum_c: f64 = res.rater_severity.iter().sum(); - let sum_f: f64 = res.thresholds.iter().sum(); - assert!(sum_c.abs() < 1e-9, "sum(c) = {sum_c}"); - assert!(sum_f.abs() < 1e-9, "sum(f) = {sum_f}"); - assert_eq!(res.n_parameters, 6 + 4 + 2); - // Known limitation: a constant-shift mutation applied jointly to d and -c - // is a model invariance and cannot be detected by any data-based test; - // the discriminating anchors are the centering asserts above. -} - -// --------------------------------------------------------------------------- -// Sparse judging plan: each person is scored by 2 of 5 raters on a rotating -// (non-contiguous) schedule. Asserts read crate outputs. Mutations killed: -// dense-only indexing (missing cells would feed category 0 counts), observed- -// mask offset bugs. -// --------------------------------------------------------------------------- -#[test] -fn sparse_design_recovers_severity_order() { - let d_true = [-0.8f64, 0.0, 0.8]; - let c_true = [-0.9f64, -0.2, 0.0, 0.3, 0.8]; - let f_true = [0.6f64, -0.6]; - let (np, ni, nj) = (1500usize, 3usize, 5usize); - let y = simulate(99, np, &d_true, &c_true, &f_true); - // Rotating pairs (p, p+2 mod 5): non-contiguous rater unions, connected. - let mut obs = vec![false; np * ni * nj]; - for p in 0..np { - let (a, b) = (p % nj, (p + 2) % nj); - for i in 0..ni { - obs[p * ni * nj + i * nj + a] = true; - obs[p * ni * nj + i * nj + b] = true; - } - } - let res = fit_facets(&y, Some(&obs), np, ni, nj, 3, 21, 500, 1e-8).unwrap(); - assert!(res.connected); - let mean_c = c_true.iter().sum::() / nj as f64; - let c_centered: Vec = c_true.iter().map(|v| v - mean_c).collect(); - let r = corr(&res.rater_severity, &c_centered); - assert!(r > 0.9, "sparse severity corr {r}"); - // The recovered severity ORDER must match (kills permutation/off-by-one - // in the rater axis under a sparse mask). - let mut idx: Vec = (0..nj).collect(); - idx.sort_by(|&a, &b| res.rater_severity[a].partial_cmp(&res.rater_severity[b]).unwrap()); - assert_eq!(idx, vec![0, 1, 2, 3, 4]); -} - -// --------------------------------------------------------------------------- -// Connectivity flag. Asserts read res.connected. Mutations killed: joining -// only items to items (not raters), skipping the person-mediated union, or -// hardcoding true. -// --------------------------------------------------------------------------- -#[test] -fn disconnected_design_is_flagged() { - // Two islands: persons 0..P/2 x item 0 x rater 0; persons P/2.. x item 1 x rater 1. - let (np, ni, nj) = (60usize, 2usize, 2usize); - let d = [0.0f64, 0.0]; - let c = [0.5f64, -0.5]; - let f = [0.0f64]; - let y = simulate(5, np, &d, &c, &f); - let mut obs = vec![false; np * ni * nj]; - for p in 0..np { - let island = usize::from(p >= np / 2); - obs[p * ni * nj + island * nj + island] = true; - } - let res = fit_facets(&y, Some(&obs), np, ni, nj, 2, 7, 50, 1e-6).unwrap(); - assert!(!res.connected, "two islands must be flagged disconnected"); - - // Bridging rater: rater 0 also scores item 1 for one person -> connected. - let mut obs2 = obs.clone(); - obs2[0 * ni * nj + 1 * nj + 0] = true; // person 0, item 1, rater 0 - let res2 = fit_facets(&y, Some(&obs2), np, ni, nj, 2, 7, 50, 1e-6).unwrap(); - assert!(res2.connected, "bridge must connect the design"); -} - -// --------------------------------------------------------------------------- -// Validation errors. -// --------------------------------------------------------------------------- -#[test] -fn rejects_bad_inputs() { - let y = vec![0usize; 4]; - assert!(fit_facets(&y, None, 2, 2, 1, 1, 7, 50, 1e-6).is_err()); // n_cat < 2 - assert!(fit_facets(&y, None, 2, 2, 1, 2, 8, 50, 1e-6).is_err()); // bad q - assert!(fit_facets(&y, None, 2, 2, 1, 2, 7, 0, 1e-6).is_err()); // max_iter 0 - assert!(fit_facets(&y, None, 2, 2, 1, 2, 7, 50, f64::NAN).is_err()); - assert!(fit_facets(&y, None, 3, 2, 1, 2, 7, 50, 1e-6).is_err()); // len mismatch - let y2 = vec![0usize, 5, 0, 0]; - assert!(fit_facets(&y2, None, 2, 2, 1, 3, 7, 50, 1e-6).is_err()); // cat >= n_cat - // rater with no observations - let y3 = vec![0usize; 2 * 1 * 2]; - let obs = vec![true, false, true, false]; - assert!(fit_facets(&y3, Some(&obs), 2, 1, 2, 2, 7, 50, 1e-6) - .unwrap_err() - .contains("rater 1")); -} - -#[test] -fn loglik_trace_is_nondecreasing() { - let y = simulate(3, 200, &[-0.5, 0.5], &[-0.4, 0.4], &[0.5, -0.5]); - let res = fit_facets(&y, None, 200, 2, 2, 3, 21, 200, 1e-10).unwrap(); - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-8, "EM must be monotone: {} -> {}", w[0], w[1]); - } -} - -// --------------------------------------------------------------------------- -// Monte-Carlo recovery, 500 replications (heavy; run with --ignored). -// Half the replications generate theta from a skewed distribution (mixture -// shift) to probe prior-misspecification robustness: bias is reported with a -// loose bound rather than asserted tightly. -// --------------------------------------------------------------------------- -#[test] -#[ignore = "500-replication Monte-Carlo; run with --ignored"] -fn monte_carlo_severity_bias_and_rmse() { - let d_true = [-1.0f64, 0.0, 1.0]; - let c_true = [-0.7f64, 0.0, 0.7]; // centered - let f_true = [0.9f64, -0.9]; - let (np, ni, nj) = (300usize, 3usize, 3usize); - let reps = 500usize; - let mut bias = vec![0.0f64; nj]; - let mut mse = vec![0.0f64; nj]; - for rep in 0..reps { - let skewed = rep % 2 == 1; - let mut rng = Lcg(10_000 + rep as u64); - let mut y = vec![0usize; np * ni * nj]; - for p in 0..np { - let theta = if skewed { - // Standardized two-component location mixture (negatively - // skewed), mean 0 / var ~1 by construction below. - let z = rng.normal(); - let comp = if rng.f64() < 0.75 { 0.35 } else { -1.05 }; - (z * 0.8 + comp) / (0.8f64.powi(2) + 0.42f64).sqrt() - } else { - rng.normal() - }; - for i in 0..ni { - for j in 0..nj { - y[p * ni * nj + i * nj + j] = - draw_facets(theta, d_true[i], c_true[j], &f_true, rng.f64()); - } - } - } - let res = fit_facets(&y, None, np, ni, nj, 3, 21, 500, 1e-8).unwrap(); - for j in 0..nj { - let e = res.rater_severity[j] - c_true[j]; - bias[j] += e / reps as f64; - mse[j] += e * e / reps as f64; - } - } - for j in 0..nj { - let rm = mse[j].sqrt(); - // Loose bounds: severity is a fixed effect over 300*3 ratings/rep. - assert!(bias[j].abs() < 0.05, "rater {j} bias {}", bias[j]); - assert!(rm < 0.2, "rater {j} rmse {rm}"); - } -} diff --git a/tests/unit/factor_tests.rs b/tests/unit/factor_tests.rs deleted file mode 100644 index 1384fb943..000000000 --- a/tests/unit/factor_tests.rs +++ /dev/null @@ -1,727 +0,0 @@ -//! Tests for minres factor analysis + omega_total_1f (`factor.rs`). -//! -//! Oracle: independent scipy L-BFGS-B transcription of psych fa.R -//! (session `files/oracle_minres.py`), fixtures pinned at 12 decimals. -//! Same optimizer family as R's `optim(method="L-BFGS-B")`; NOT claimed -//! bit-identical to any R run. -//! -//! Mutation-kill audit (every assert reads crate outputs): -//! - M1 EXECUTED: flip the sign convention in `minres_fa_corr` -//! (`colsum < 0.0` -> `colsum > 0.0`) => oracle-parity loadings FAIL. -//! - M2 EXECUTED: change the objective from strictly-lower-triangle to all -//! off-diagonal residuals (x2 scale, same argmin) => this is UNKILLABLE -//! by any argmin-based assert (disclosed limitation); it is killed ONLY -//! by the absolute-objective anchor in `fa_nf1_absolute_objective_anchor` -//! (0.7076 vs 1.4153). -//! - M3 EXECUTED: sort eigenvalues ascending instead of descending in -//! `symmetric_eigen_desc` => parity + rank-1 recovery FAIL. -//! - M4 EXECUTED: drop `sum psi` from the omega denominator => omega -> 1, -//! `fa_omega_6var_oracle_parity` FAILs. -//! - Documented-only: swapping the FD-fallback pass order (pass 0 vs 1) -//! changes only the search path, not the fixed point asserted on; it is -//! principle-unkillable by value asserts and is instead pinned by the -//! KKT-violation assert (any non-convergent path fails `converged`). - -use super::*; - -const S9: [f64; 81] = [ - 1.0, 0.56, 0.48, 0.40, 0.32, 0.0, 0.0, 0.0, 0.0, // - 0.56, 1.0, 0.42, 0.35, 0.28, 0.0, 0.0, 0.0, 0.0, // - 0.48, 0.42, 1.0, 0.30, 0.24, 0.0, 0.0, 0.0, 0.0, // - 0.40, 0.35, 0.30, 1.0, 0.20, 0.0, 0.0, 0.0, 0.0, // - 0.32, 0.28, 0.24, 0.20, 1.0, 0.21, 0.18, 0.15, 0.135, // - 0.0, 0.0, 0.0, 0.0, 0.21, 1.0, 0.42, 0.35, 0.315, // - 0.0, 0.0, 0.0, 0.0, 0.18, 0.42, 1.0, 0.30, 0.27, // - 0.0, 0.0, 0.0, 0.0, 0.15, 0.35, 0.30, 1.0, 0.225, // - 0.0, 0.0, 0.0, 0.0, 0.135, 0.315, 0.27, 0.225, 1.0, -]; - -/// Oracle parity, 9 variables / 2 factors (exact-structure matrix). -/// Asserts read: crate `loadings`, `uniquenesses`, `objective`, -/// `kkt_violation`, `converged`. Kills M1, M3, optimizer breakage, -/// eigenvalue-clamp removal that yields NaN loadings. -#[test] -fn fa_oracle_parity_9x2() { - let r = minres_fa_corr(&S9, 9, 2).unwrap(); - assert!(r.converged, "kkt_violation = {}", r.kkt_violation); - assert!(r.kkt_violation < 1e-6); - assert!(r.objective < 1e-9, "objective = {}", r.objective); - #[rustfmt::skip] - let want_l = [ - 0.780565885133, -0.175262374782, - 0.682995149541, -0.153354577950, - 0.585424412028, -0.131446780509, - 0.487853678110, -0.109538984208, - 0.456006332771, 0.205081019484, - 0.153354577763, 0.682995148989, - 0.131446781080, 0.585424414300, - 0.109538983915, 0.487853676587, - 0.098585085703, 0.439068310057, - ]; - let want_psi = [ - 0.359999998175, - 0.509999998034, - 0.640000004172, - 0.749999998560, - 0.749999999543, - 0.509999999905, - 0.639999997852, - 0.750000003439, - 0.797499999930, - ]; - for j in 0..18 { - assert!( - (r.loadings[j] - want_l[j]).abs() < 5e-5, - "loading[{j}] = {} vs oracle {}", - r.loadings[j], - want_l[j] - ); - } - for j in 0..9 { - assert!( - (r.uniquenesses[j] - want_psi[j]).abs() < 5e-5, - "psi[{j}] = {} vs oracle {}", - r.uniquenesses[j], - want_psi[j] - ); - } - // Structure invariants read from crate outputs: unrotated columns are - // orthogonal, communalities are the row sums of squared loadings, and - // both sign-convention column sums are nonnegative. - let cross: f64 = (0..9) - .map(|j| r.loadings[j * 2] * r.loadings[j * 2 + 1]) - .sum(); - assert!(cross.abs() < 1e-6, "columns not orthogonal: {cross}"); - for j in 0..9 { - let h2 = r.loadings[j * 2].powi(2) + r.loadings[j * 2 + 1].powi(2); - assert!((r.communalities[j] - h2).abs() < 1e-12); - } - for k in 0..2 { - let cs: f64 = (0..9).map(|j| r.loadings[j * 2 + k]).sum(); - assert!(cs >= 0.0, "column {k} sum negative: {cs}"); - } -} - -/// Absolute-objective anchor: 1-factor fit of the 2-factor matrix has a -/// NONZERO minimum whose value pins the objective's scale and triangle -/// restriction. Oracle (scipy L-BFGS-B): 0.7076498390951. This is the ONLY -/// assert that kills M2 (lower-tri -> all-off-diag doubles it to 1.41530). -/// Asserts read: crate `objective`, `loadings`, `uniquenesses`. -#[test] -fn fa_nf1_absolute_objective_anchor() { - let r = minres_fa_corr(&S9, 9, 1).unwrap(); - assert!(r.converged, "kkt_violation = {}", r.kkt_violation); - assert!( - (r.objective - 0.7076498390951).abs() < 1e-6, - "objective = {}", - r.objective - ); - let want_l = [ - 0.784393051702, - 0.690451800142, - 0.593544036321, - 0.495443115065, - 0.431236098560, - 0.090215272048, - 0.081738564688, - 0.071559166778, - 0.065837065736, - ]; - let want_psi = [ - 0.384727568730, - 0.523276332788, - 0.647705509431, - 0.754536227830, - 0.814035399097, - 0.991860082805, - 0.993318442088, - 0.994880557142, - 0.995668949643, - ]; - for j in 0..9 { - assert!( - (r.loadings[j] - want_l[j]).abs() < 1e-3, - "loading[{j}] = {} vs oracle {}", - r.loadings[j], - want_l[j] - ); - assert!( - (r.uniquenesses[j] - want_psi[j]).abs() < 1e-3, - "psi[{j}] = {} vs oracle {}", - r.uniquenesses[j], - want_psi[j] - ); - } -} - -/// omega_total_1f oracle parity on a sampled-data correlation matrix -/// (numpy default_rng(20260723), n = 400, 6 variables, 1 factor). -/// Asserts read: crate `omega_total`, `fa.loadings`, `fa.uniquenesses`. -/// Kills M4 (omega denominator) and any loadings/psi drift. -#[test] -fn fa_omega_6var_oracle_parity() { - #[rustfmt::skip] - let r6: [f64; 36] = [ - 1.000000000000, 0.498568868921, 0.625966200012, 0.370348287807, 0.540999349418, 0.424034177377, - 0.498568868921, 1.000000000000, 0.511016876110, 0.334681533406, 0.380581491599, 0.347762451610, - 0.625966200012, 0.511016876110, 1.000000000000, 0.345954222399, 0.593952865689, 0.454414314062, - 0.370348287807, 0.334681533406, 0.345954222399, 1.000000000000, 0.327886204696, 0.311070632680, - 0.540999349418, 0.380581491599, 0.593952865689, 0.327886204696, 1.000000000000, 0.415636442046, - 0.424034177377, 0.347762451610, 0.454414314062, 0.311070632680, 0.415636442046, 1.000000000000, - ]; - let o = omega_total_1f_corr(&r6, 6).unwrap(); - assert!(o.fa.converged, "kkt_violation = {}", o.fa.kkt_violation); - assert!( - (o.omega_total - 0.825058734426).abs() < 1e-5, - "omega_total = {}", - o.omega_total - ); - let want_l = [ - 0.774100663868, - 0.619619195261, - 0.809271424864, - 0.481544336034, - 0.696500756198, - 0.573550085378, - ]; - let want_psi = [ - 0.400768161859, - 0.616072053550, - 0.345079760758, - 0.768115052738, - 0.514886696139, - 0.671040299233, - ]; - for j in 0..6 { - assert!( - (o.fa.loadings[j] - want_l[j]).abs() < 5e-5, - "loading[{j}] = {} vs oracle {}", - o.fa.loadings[j], - want_l[j] - ); - assert!( - (o.fa.uniquenesses[j] - want_psi[j]).abs() < 5e-5, - "psi[{j}] = {} vs oracle {}", - o.fa.uniquenesses[j], - want_psi[j] - ); - } -} - -/// Rank-1 exact recovery: S = ll' + diag(1 - l^2) with asymmetric -/// loadings. Asserts read: crate `loadings`, `objective`, `uniquenesses`. -/// Kills eigenvector-scaling and clamp-order mutations (M3 included). -#[test] -fn fa_rank1_exact_recovery() { - let l = [0.9, 0.8, 0.7, 0.6, 0.5]; - let p = 5; - let mut s = vec![0.0; p * p]; - for i in 0..p { - for j in 0..p { - s[i * p + j] = if i == j { 1.0 } else { l[i] * l[j] }; - } - } - let r = minres_fa_corr(&s, p, 1).unwrap(); - assert!(r.converged, "kkt_violation = {}", r.kkt_violation); - assert!(r.objective < 1e-10, "objective = {}", r.objective); - for j in 0..p { - assert!( - (r.loadings[j] - l[j]).abs() < 1e-4, - "loading[{j}] = {} vs true {}", - r.loadings[j], - l[j] - ); - assert!( - (r.uniquenesses[j] - (1.0 - l[j] * l[j])).abs() < 1e-4, - "psi[{j}] = {}", - r.uniquenesses[j] - ); - } -} - -/// Error contract. Asserts read: crate `Err` messages. -#[test] -fn fa_error_paths() { - // p < 3 - assert!(minres_fa_corr(&[1.0, 0.2, 0.2, 1.0], 2, 1).is_err()); - // bad nf - assert!(minres_fa_corr(&S9, 9, 0).is_err()); - assert!(minres_fa_corr(&S9, 9, 9).is_err()); - // asymmetric - let mut bad = S9.to_vec(); - bad[1] += 0.01; - assert!(minres_fa_corr(&bad, 9, 1) - .unwrap_err() - .contains("symmetric")); - // non-unit diagonal - let mut bad = S9.to_vec(); - bad[0] = 2.0; - assert!(minres_fa_corr(&bad, 9, 1).unwrap_err().contains("diagonal")); - // non-finite - let mut bad = S9.to_vec(); - bad[3] = f64::NAN; - bad[27] = f64::NAN; - assert!(minres_fa_corr(&bad, 9, 1).unwrap_err().contains("finite")); - // wrong length - assert!(minres_fa_corr(&S9[..80], 9, 1).is_err()); - // singular matrix -> smc start values error - let p = 4; - let mut sing = vec![0.0; p * p]; - for i in 0..p { - for j in 0..p { - sing[i * p + j] = if i == j { 1.0 } else { 1.0 }; - } - } - assert!(minres_fa_corr(&sing, p, 1).is_err()); - // data entry points - assert!(minres_fa_data(&[0.0; 10], 2, 5, 1).is_err()); // n < 3 - assert!(minres_fa_data(&[0.0; 11], 3, 4, 1).is_err()); // length mismatch - assert!(omega_total_1f_data(&[0.0; 11], 3, 4).is_err()); - let mut d = vec![0.0; 12]; - d[5] = f64::INFINITY; - assert!(minres_fa_data(&d, 4, 3, 1).unwrap_err().contains("finite")); - // impl-review regression: dimension products must not overflow/panic - // (checked_mul guards). Asserts read crate Err values. - assert!(minres_fa_corr(&[], usize::MAX, 1) - .unwrap_err() - .contains("overflow")); - assert!(minres_fa_data(&[], usize::MAX, usize::MAX, 1) - .unwrap_err() - .contains("overflow")); - assert!(omega_total_1f_data(&[], usize::MAX, usize::MAX) - .unwrap_err() - .contains("overflow")); -} - -/// Monte Carlo recovery (>= 500 reps): 1-factor data, n = 300, p = 6; -/// average estimated loadings and omega_total from `minres_fa_data` / -/// `omega_total_1f_data` recover the population values. Asserts read: -/// crate loadings and omega across replications. -#[test] -#[ignore] -fn fa_mc_recovery_500() { - let lam = [0.75, 0.65, 0.8, 0.55, 0.7, 0.6]; - let p = 6; - let n = 300; - let lsum: f64 = lam.iter().sum(); - let psum: f64 = lam.iter().map(|l| 1.0 - l * l).sum(); - let omega_pop = lsum * lsum / (lsum * lsum + psum); - let mut state: u64 = 0x9E3779B97F4A7C15; - let mut next_u = || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut next_normal = || { - let (u1, u2): (f64, f64) = (next_u().max(1e-12), next_u()); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let reps = 500; - let mut lsums = [0.0f64; 6]; - let mut osum = 0.0f64; - for _ in 0..reps { - let mut x = vec![0.0; n * p]; - for r in 0..n { - let f = next_normal(); - for (j, &l) in lam.iter().enumerate() { - x[r * p + j] = l * f + (1.0 - l * l).sqrt() * next_normal(); - } - } - let fit = omega_total_1f_data(&x, n, p).unwrap(); - for j in 0..p { - lsums[j] += fit.fa.loadings[j]; - } - osum += fit.omega_total; - } - for j in 0..p { - let mean = lsums[j] / reps as f64; - assert!( - (mean - lam[j]).abs() < 0.02, - "mean loading[{j}] = {mean} vs true {}", - lam[j] - ); - } - let omean = osum / reps as f64; - assert!( - (omean - omega_pop).abs() < 0.01, - "mean omega = {omean} vs population {omega_pop}" - ); -} - -// --------------------------------------------------------------------------- -// glb_fa (psych glbs.R glb.fa transcription; oracle: oracle_glbfa.py, an -// independent scipy L-BFGS-B reimplementation of the same psych path) -// -// Mutation-kill audit (all asserts read crate outputs): -// - M1 EXECUTED: skip the diagonal substitution in the glb ratio (use -// sum(R)/sum(R)) => S9 glb returns 1.0, fixture FAILs. -// - M2 EXECUTED: use the 1-factor communalities instead of the nf-factor -// refit => S9 (nf = 2) glb becomes ~0.665251972925, fixture FAILs. -// - M3 EXECUTED: drop the df < 0 single decrement => Rm3 fits nf = 4 -// instead of 3, glb and nf asserts FAIL. (S9 has nf = 2, df = 19 and r6 -// has nf = 3, df = 0 — neither triggers the decrement, hence Rm3.) - -/// Pinned oracle: S9 population 2-factor matrix. glb 0.730905233399, -/// detected nf = 2 (df = 19, no decrement). Asserts read crate glb, nf, -/// communalities. -#[test] -fn glbfa_oracle_s9() { - let g = glb_fa_corr(&S9, 9).unwrap(); - assert_eq!(g.nf, 2); - assert!((g.glb - 0.730905233399).abs() < 1e-5, "glb = {}", g.glb); - let want_h2 = [ - 0.640000001825, - 0.490000001966, - 0.359999995828, - 0.250000001440, - 0.250000000457, - 0.490000000095, - 0.360000002148, - 0.249999996561, - 0.202500000070, - ]; - for (j, w) in want_h2.iter().enumerate() { - assert!( - (g.communalities[j] - w).abs() < 1e-4, - "h2[{j}] = {} want {w}", - g.communalities[j] - ); - } -} - -/// Pinned oracle: r6 sampled 1-factor corr. Sampling noise makes the -/// eigenvalue count nf = 3 (df = 0, no decrement); oracle glb -/// 0.876394018918. df = 0 means the 3-factor minres fit is SATURATED, the -/// objective is flat at zero and communalities are not unique, so -/// BB+Armijo (crate) and L-BFGS-B (oracle) legitimately land on nearby -/// but distinct solutions (observed crate glb 0.873934, |delta| 2.5e-3); -/// the band below reflects that indeterminacy, not a defect. Documents -/// that glb_fa on 1-factor DATA is NOT omega_total_1f (nf > 1 is -/// detected), so no identity shortcut exists. Asserts read crate outputs. -#[test] -fn glbfa_oracle_r6() { - #[rustfmt::skip] - let r6: [f64; 36] = [ - 1.000000000000, 0.498568868921, 0.625966200012, 0.370348287807, 0.540999349418, 0.424034177377, - 0.498568868921, 1.000000000000, 0.511016876110, 0.334681533406, 0.380581491599, 0.347762451610, - 0.625966200012, 0.511016876110, 1.000000000000, 0.345954222399, 0.593952865689, 0.454414314062, - 0.370348287807, 0.334681533406, 0.345954222399, 1.000000000000, 0.327886204696, 0.311070632680, - 0.540999349418, 0.380581491599, 0.593952865689, 0.327886204696, 1.000000000000, 0.415636442046, - 0.424034177377, 0.347762451610, 0.454414314062, 0.311070632680, 0.415636442046, 1.000000000000, - ]; - let g = glb_fa_corr(&r6, 6).unwrap(); - assert_eq!(g.nf, 3); - assert!((g.glb - 0.876394018918).abs() < 5e-3, "glb = {}", g.glb); -} - -/// df-adjustment fixture: eigenvalue count detects nf = 4, df = 15 - 24 + -/// 6 = -3 < 0, so psych decrements to nf = 3; oracle glb 0.739790363509. -/// This is the only fixture where the decrement fires: the M3 mutant -/// (decrement dropped) fits nf = 4 and the exact `nf == 3` assert FAILs — -/// that assert is the M3 kill. The nf = 3 fit here is again df = 0 -/// (saturated, flat objective, non-unique communalities with several at -/// the 0.995 bound), so the glb band is wide (observed crate glb -/// 0.752597, |delta| 1.3e-2 vs oracle) and serves as a sanity anchor -/// only. Asserts read crate nf and glb. -#[test] -fn glbfa_df_adjustment_rm3() { - #[rustfmt::skip] - let rm3: [f64; 36] = [ - 1.000000000000, 0.318781496354, -0.085229976062, 0.335304209671, 0.344321815016, 0.328050481937, - 0.318781496354, 1.000000000000, -0.639587262249, -0.268114491438, -0.263043397262, 0.260168422634, - -0.085229976062, -0.639587262249, 1.000000000000, 0.604841164303, -0.314993496385, 0.156083866739, - 0.335304209671, -0.268114491438, 0.604841164303, 1.000000000000, -0.178619109663, -0.278011941380, - 0.344321815016, -0.263043397262, -0.314993496385, -0.178619109663, 1.000000000000, -0.309262040181, - 0.328050481937, 0.260168422634, 0.156083866739, -0.278011941380, -0.309262040181, 1.000000000000, - ]; - let g = glb_fa_corr(&rm3, 6).unwrap(); - assert_eq!(g.nf, 3); - assert!((g.glb - 0.739790363509).abs() < 3e-2, "glb = {}", g.glb); -} - -/// Error contract. Asserts read crate Err values. -#[test] -fn glbfa_error_paths() { - assert!(glb_fa_corr(&[1.0; 4], 2).is_err()); // p < 3 - assert!(glb_fa_corr(&S9, 8).is_err()); // length mismatch - assert!(glb_fa_data(&[0.0; 12], usize::MAX, usize::MAX).is_err()); // overflow - assert!(glb_fa_data(&[f64::NAN; 12], 4, 3).is_err()); - assert!(glb_fa_data(&[0.5; 6], 2, 3).is_err()); // n < 3 - // Regression (impl-review): impossible off-diagonals must Err, not - // panic in eigen. Asserts read crate Err. - let mut bad = [1.0; 9]; - for i in 0..3 { - for j in 0..3 { - if i != j { - bad[i * 3 + j] = 1e308; - } - } - } - assert!(glb_fa_corr(&bad, 3).is_err()); - // Regression (review-thread): glb denominator sum(R) can be zero for - // valid (possibly indefinite) symmetric unit-diagonal inputs; this must - // return an Err, not inf/NaN. - // Here off-diagonals a,b,c are chosen to satisfy a+b+c = -1.5, so for p=3: - // sum(R) = 3 + 2(a+b+c) = 0 exactly. - let a: f64 = -0.15885683833831; - let b: f64 = -0.4821664994140733; - let c: f64 = -0.8589766622476167; - assert!(((a + b + c) + 1.5_f64).abs() < 1e-12); - #[rustfmt::skip] - let zero_sum: [f64; 9] = [ - 1.0, a, b, - a, 1.0, c, - b, c, 1.0, - ]; - let err = glb_fa_corr(&zero_sum, 3).unwrap_err(); - assert!(err.contains("sum(R) too close to zero"), "err = {err}"); -} - -/// 500-rep MC: 1-factor model, loadings [.7,.6,.8,.5,.65,.75], n = 1000 -/// per rep. Population omega_total = 0.830090791180 (spec-verify derived). -/// glb_fa's strict eigenvalue count typically detects nf > 1 on sampled -/// matrices, so glb is biased slightly UPWARD relative to omega; tolerance -/// set from an observed run (see test body comment). Asserts read crate -/// glb across reps. -#[test] -#[ignore] -fn glbfa_mc_recovery_500() { - let mut state: u64 = 0xC0FFEE123456789; - let mut next_u = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut next_normal = move || { - let (u1, u2): (f64, f64) = (next_u().max(1e-12), next_u()); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let lam = [0.7, 0.6, 0.8, 0.5, 0.65, 0.75]; - let (n, p, reps) = (1000usize, 6usize, 500usize); - let mut sum_glb = 0.0; - for _ in 0..reps { - let mut data = vec![0.0; n * p]; - for row in data.chunks_mut(p) { - let f = next_normal(); - for (j, x) in row.iter_mut().enumerate() { - *x = lam[j] * f + (1.0 - lam[j] * lam[j]).sqrt() * next_normal(); - } - } - sum_glb += glb_fa_data(&data, n, p).unwrap().glb; - } - let mean_glb = sum_glb / reps as f64; - let pop_omega = 0.830090791180; - eprintln!("glbfa_mc_recovery_500: mean glb = {mean_glb}"); - // Observed mean over 500 reps: 0.86314 (deterministic LCG seed above), - // i.e. +0.033 above population omega — the expected upward bias from - // nf > 1 detection on sampled matrices; band is asymmetric accordingly. - assert!( - mean_glb >= pop_omega - 0.005 && mean_glb <= pop_omega + 0.045, - "mean glb = {mean_glb} vs population omega {pop_omega}" - ); -} - -// --------------------------------------------------------------------------- -// Velicer MAP (velicer_map_corr / velicer_map_data) -// -// Oracle: independent NumPy transcription of O'Connor's map.m formulas run -// against Harman's 8-physical-variables correlation matrix (session -// files/map_spec.md; values recomputed 2026-07-24, matching the adversarial -// spec reviewer's own O'Connor-based vector to 6 dp). -// -// Mutation-kill audit (every assert reads crate outputs): -// - MAP-M1 EXECUTED: drop eigenvalue weighting in the component removal -// (`eigval[k].max(0.0)` -> `1.0`, i.e. loadings without sqrt(lambda)) -// => Harman f2 vector FAILs. -// - MAP-M2 EXECUTED: skip the D^{-1/2} rescaling (partial covariance used -// directly) => Harman f2 vector FAILs. -// - MAP-M3 EXECUTED: faMAP-style off-by-one retained count -// (`best = m` -> `best = m + 1` equivalent: argmin over 1-based rows) -// => `retained_f2 == 2` FAILs (returns 3). -// - MAP-M4 EXECUTED: squared -> absolute value in the f2 accumulation -// => Harman f2 vector FAILs. -// - MAP-M5 EXECUTED: full-spectrum negative-eigenvalue guard reverted to -// the leading `eigval[..max_m]` slice => map_error_paths non-PSD case -// FAILs (trailing negative eigenvalue slips through for every max_m). -// Principle limitation (disclosed): retained-count asserts alone cannot -// kill M1/M2/M4 on every fixture; the numeric full-vector pins below are -// the discriminating anchors (adversarial spec-review requirement). - -const HARMAN8: [f64; 64] = [ - 1.000, 0.846, 0.805, 0.859, 0.473, 0.398, 0.301, 0.382, // - 0.846, 1.000, 0.881, 0.826, 0.376, 0.326, 0.277, 0.415, // - 0.805, 0.881, 1.000, 0.801, 0.380, 0.319, 0.237, 0.345, // - 0.859, 0.826, 0.801, 1.000, 0.436, 0.329, 0.327, 0.365, // - 0.473, 0.376, 0.380, 0.436, 1.000, 0.762, 0.730, 0.629, // - 0.398, 0.326, 0.319, 0.329, 0.762, 1.000, 0.583, 0.577, // - 0.301, 0.277, 0.237, 0.327, 0.730, 0.583, 1.000, 0.539, // - 0.382, 0.415, 0.345, 0.365, 0.629, 0.577, 0.539, 1.000, -]; - -/// Full-vector oracle parity on Harman-8. Asserts read: crate `f2`, `f4`, -/// `retained_f2`, `retained_f4`. Kills MAP-M1..M4. -#[test] -fn map_harman8_oracle() { - let r = velicer_map_corr(&HARMAN8, 8, 7).unwrap(); - let f2_oracle = [ - 0.312474786, - 0.245120888, - 0.066444959, - 0.127594380, - 0.204202701, - 0.271829458, - 0.434591269, - 1.0, - ]; - let f4_oracle = [ - 0.155060932, - 0.073578687, - 0.011930167, - 0.051913581, - 0.116013162, - 0.152571355, - 0.331202709, - 1.0, - ]; - for m in 0..=7 { - assert!( - (r.f2[m] - f2_oracle[m]).abs() < 1e-7, - "f2[{m}] = {} vs oracle {}", - r.f2[m], - f2_oracle[m] - ); - assert!( - (r.f4[m] - f4_oracle[m]).abs() < 1e-7, - "f4[{m}] = {} vs oracle {}", - r.f4[m], - f4_oracle[m] - ); - } - assert_eq!( - r.retained_f2, 2, - "O'Connor canonical count (faMAP would say 3)" - ); - assert_eq!(r.retained_f4, 2); -} - -/// Identity matrix: baseline f2[0] = 0, retain 0, and all m >= 1 rows are -/// invalid (partial covariance diagonal hits 0) => NaN, not a panic or Inf. -/// Asserts read: crate `f2`, `f4`, `retained_f2`. Guards the -/// singular-normalization contract from the spec review. -#[test] -fn map_identity_guard() { - let p = 5; - let mut eye = vec![0.0; p * p]; - for i in 0..p { - eye[i * p + i] = 1.0; - } - let r = velicer_map_corr(&eye, p, p - 1).unwrap(); - assert_eq!(r.f2[0], 0.0); - assert_eq!(r.f4[0], 0.0); - assert_eq!(r.retained_f2, 0); - for m in 1..p { - assert!(r.f2[m].is_nan(), "f2[{m}] should be invalid on identity R"); - assert!(r.f4[m].is_nan()); - } -} - -/// Data path agrees with the corr path on data whose Pearson matrix is -/// computed internally. Asserts read: crate outputs from both entry points. -#[test] -fn map_data_matches_corr() { - // 2-factor structured data via fixed LCG (same generator as MC tests). - let mut state: u64 = 0xABCDEF987654321; - let mut next_u = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut next_normal = move || { - let (u1, u2): (f64, f64) = (next_u().max(1e-12), next_u()); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let (n, p) = (400usize, 6usize); - let mut data = vec![0.0; n * p]; - for row in data.chunks_mut(p) { - let (f1, f2) = (next_normal(), next_normal()); - for (j, x) in row.iter_mut().enumerate() { - let common = if j < 3 { 0.75 * f1 } else { 0.75 * f2 }; - *x = common + (1.0 - 0.75f64 * 0.75).sqrt() * next_normal(); - } - } - let via_data = velicer_map_data(&data, n, p, 5).unwrap(); - let r = correlation_matrix(&data, n, p).unwrap(); - let via_corr = velicer_map_corr(&r, p, 5).unwrap(); - assert_eq!(via_data, via_corr); - assert_eq!(via_data.retained_f2, 2, "2-factor structure recovered"); -} - -/// Error paths. Asserts read: crate error strings. -#[test] -fn map_error_paths() { - let e = velicer_map_corr(&HARMAN8, 8, 8).unwrap_err(); - assert!(e.contains("max_m"), "{e}"); - let bad = vec![0.5; 9]; // diagonal != 1 - assert!(velicer_map_corr(&bad, 3, 1).is_err()); - assert!(velicer_map_corr(&HARMAN8[..63], 8, 1).is_err()); // wrong length - let mut asym = HARMAN8.to_vec(); - asym[1] = 0.9; // break symmetry - assert!(velicer_map_corr(&asym, 8, 1).is_err()); - assert!(velicer_map_data(&[1.0, f64::NAN, 0.0, 1.0, 2.0, 3.0], 2, 3, 1).is_err()); - // Materially non-PSD "correlation" matrix (det < 0, one eigenvalue - // < -1e-8) must be rejected for every max_m, including the m = 0 - // baseline-only case (trailing-eigenvalue guard; kills a mutation - // restoring the old `eigval[..max_m]` slice). - #[rustfmt::skip] - let npsd = [ - 1.0, 0.9, 0.9, - 0.9, 1.0, -0.9, - 0.9, -0.9, 1.0, - ]; - for m in 0..=2 { - let e = velicer_map_corr(&npsd, 3, m).unwrap_err(); - assert!(e.contains("negative eigenvalue"), "max_m={m}: {e}"); - } -} - -/// MC-500 (#[ignore]): sample-correlation recovery of a 3-factor -/// population; majority of replications must retain exactly 3 by f2. -/// Asserts read: crate `retained_f2` per replication. -#[test] -#[ignore] -fn map_mc_recovery_500() { - let mut state: u64 = 0x5EED5EED5EED5EED; - let mut next_u = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut next_normal = move || { - let (u1, u2): (f64, f64) = (next_u().max(1e-12), next_u()); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let (n, p, reps) = (500usize, 9usize, 500usize); - let mut correct = 0usize; - for _ in 0..reps { - let mut data = vec![0.0; n * p]; - for row in data.chunks_mut(p) { - let f = [next_normal(), next_normal(), next_normal()]; - for (j, x) in row.iter_mut().enumerate() { - let common = 0.8 * f[j / 3]; - *x = common + (1.0 - 0.64f64).sqrt() * next_normal(); - } - } - if velicer_map_data(&data, n, p, p - 1).unwrap().retained_f2 == 3 { - correct += 1; - } - } - eprintln!("map_mc_recovery_500: {correct}/{reps} retained 3"); - assert!( - correct * 10 >= reps * 9, - "only {correct}/{reps} replications retained the true 3 components" - ); -} diff --git a/tests/unit/fitstats_batch3_tests.rs b/tests/unit/fitstats_batch3_tests.rs deleted file mode 100644 index 7ebff94b9..000000000 --- a/tests/unit/fitstats_batch3_tests.rs +++ /dev/null @@ -1,275 +0,0 @@ -use super::*; -use crate::nodes::XiRule; -use crate::scoring::{score_eap, ItemBank, PriorSpec}; -use crate::ModelType; - -fn sim_bank( - n_persons: usize, - n_items: usize, - seed: u64, -) -> ( - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, -) { - let alpha = vec![0.0_f64; n_items]; - let b: Vec = (0..n_items) - .map(|i| -1.2 + 2.4 * i as f64 / n_items as f64) - .collect(); - let zeta = vec![0.0_f64; n_items]; - let fid = vec![0usize; n_items]; - let mut state = seed; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut y = vec![0.0_f64; n_persons * n_items]; - for p in 0..n_persons { - let u1: f64 = unif().max(1e-12); - let u2: f64 = unif(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let eta: f64 = theta + b[i]; - if unif() < 1.0 / (1.0 + (-eta).exp()) { - y[p * n_items + i] = 1.0; - } - } - } - (alpha, b, zeta, fid, y, vec![true; n_persons * n_items]) -} - -fn mk_bank<'a>(alpha: &'a [f64], b: &'a [f64], zeta: &'a [f64], fid: &'a [usize]) -> ItemBank<'a> { - ItemBank { - alpha, - b, - zeta, - tau: -30.0, - factor_id: fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - } -} - -#[test] -fn residual_fit_and_adjusted_chi2_calibrate_on_true_model() { - // Use a long test so the repository's plug-in EAP-bin approximation is - // not dominated by EAP shrinkage. - let (alpha, b, zeta, fid, y, observed) = sim_bank(1500, 40, 99); - let bank = mk_bank(&alpha, &b, &zeta, &fid); - let eap = score_eap( - &bank, - &y, - &observed, - 1500, - &PriorSpec::standard(1), - 15, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - let rf = residual_item_fit(&bank, &y, &observed, 1500, &eap.theta_eap, &eap.xi_eap, 8).unwrap(); - let finite = rf.max_abs_z.iter().filter(|v| v.is_finite()).count(); - assert!(finite >= 35); - let flagged = rf.p_value.iter().filter(|&&p| p < 0.05).count(); - assert!(flagged <= 8, "true model should rarely flag: {flagged}"); - let adj = adjusted_chi2_pairs( - &bank, - &y, - &observed, - 1500, - &PriorSpec::standard(1), - 15, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - assert!( - adj.mean_ratio < 3.0, - "true-model mean adjusted ratio: {}", - adj.mean_ratio - ); -} - -#[test] -fn adjusted_chi2_rejects_nonbinary_data_and_marks_empty_summary_undefined() { - let (alpha, b, zeta, fid, mut y, observed) = sim_bank(20, 2, 17); - let bank = mk_bank(&alpha, &b, &zeta, &fid); - let prior = PriorSpec::standard(1); - y[0] = 2.0; - let err = adjusted_chi2_pairs( - &bank, - &y, - &observed, - 20, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - ) - .err() - .expect("non-binary observed responses must be rejected"); - assert!(err.contains("0 or 1"), "unexpected error: {err}"); - - let (_, _, _, _, sparse_y, sparse_observed) = sim_bank(19, 2, 18); - let sparse = adjusted_chi2_pairs( - &bank, - &sparse_y, - &sparse_observed, - 19, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - ) - .unwrap(); - assert!(sparse.ratio.iter().all(|value| value.is_nan())); - assert!(sparse.mean_ratio.is_nan()); - assert!(sparse.max_ratio.is_nan()); -} - -#[test] -fn residual_fit_rejects_inputs_that_can_hide_misfit() { - let (alpha, b, zeta, fid, mut y, observed) = sim_bank(10, 1, 7); - let bank = mk_bank(&alpha, &b, &zeta, &fid); - let mut theta = vec![0.0; 10]; - let xi = vec![0.0; 10]; - - theta[0] = f64::NAN; - let err = residual_item_fit(&bank, &y, &observed, 10, &theta, &xi, 2) - .err() - .expect("non-finite EAP scores must be rejected"); - assert!(err.contains("finite"), "unexpected error: {err}"); - - theta[0] = 0.0; - y[0] = 2.0; - let err = residual_item_fit(&bank, &y, &observed, 10, &theta, &xi, 2) - .err() - .expect("non-binary observed responses must be rejected"); - assert!(err.contains("0 or 1"), "unexpected error: {err}"); - - y[0] = 0.0; - let err = residual_item_fit(&bank, &y, &observed, 10, &theta, &xi, 3) - .err() - .expect("undersized bins must be rejected"); - assert!(err.contains("five persons"), "unexpected error: {err}"); - - let err = residual_item_fit(&bank, &y, &observed, 10, &theta, &xi, usize::MAX) - .err() - .expect("overflowing bin work must be rejected"); - assert!(err.contains("overflows"), "unexpected error: {err}"); -} - -#[test] -fn resampling_person_fit_flags_reversed_pattern() { - let (alpha, b, zeta, fid, mut y, observed) = sim_bank(60, 20, 5); - // person 0: reversed responses (passes hard, fails easy) — aberrant - for i in 0..20 { - y[i] = if b[i] < 0.0 { 1.0 } else { 0.0 }; - } - let bank = mk_bank(&alpha, &b, &zeta, &fid); - let eap = score_eap( - &bank, - &y, - &observed, - 60, - &PriorSpec::standard(1), - 15, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - let pv = person_fit_resampling( - &bank, - &y, - &observed, - 60, - &eap.theta_eap, - &eap.xi_eap, - &[], - 200, - 11, - ) - .unwrap(); - assert!(pv[0].is_finite()); - let median_rest = { - let mut rest: Vec = (1..60).map(|p| pv[p]).filter(|v| v.is_finite()).collect(); - rest.sort_by(|a, b| a.partial_cmp(b).unwrap()); - rest[rest.len() / 2] - }; - assert!( - pv[0] < median_rest, - "aberrant person must sit low in the bootstrap null: {} vs median {}", - pv[0], - median_rest - ); -} - -#[test] -fn tcc_drift_isolates_the_shifted_item() { - let (alpha, b, zeta, fid, _y, _obs) = sim_bank(10, 10, 1); - let mut b_new = b.clone(); - b_new[4] += 1.0; // drift on item 4 - let bank_old = mk_bank(&alpha, &b, &zeta, &fid); - let bank_new = mk_bank(&alpha, &b_new, &zeta, &fid); - let res = tcc_drift( - &bank_old, - &bank_new, - &PriorSpec::standard(1), - 21, - XiRule::GaussHermite { q_xi: 7 }, - 1e-3, - ) - .unwrap(); - assert!( - res.drifted.contains(&4), - "shifted item must be flagged: {:?}", - res.drifted - ); - assert!(res.area_trace[0] > *res.area_trace.last().unwrap()); - assert_eq!(res.termination_reason, "threshold_met"); - assert_eq!(res.iterations, res.drifted.len()); - assert_eq!(res.max_iterations, 8); -} - -#[test] -fn tcc_drift_rejects_invalid_thresholds_and_reports_the_item_floor() { - let (alpha, b, zeta, fid, _y, _obs) = sim_bank(10, 6, 2); - let b_new: Vec = b.iter().map(|value| value + 0.5).collect(); - let bank_old = mk_bank(&alpha, &b, &zeta, &fid); - let bank_new = mk_bank(&alpha, &b_new, &zeta, &fid); - let prior = PriorSpec::standard(1); - - for threshold in [f64::NAN, -1.0] { - let err = tcc_drift( - &bank_old, - &bank_new, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - threshold, - ) - .err() - .expect("invalid threshold must be rejected"); - assert!( - err.contains("finite and non-negative"), - "unexpected error: {err}" - ); - } - - let res = tcc_drift( - &bank_old, - &bank_new, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - 0.0, - ) - .unwrap(); - assert_eq!(res.termination_reason, "minimum_items_reached"); - assert_eq!(res.iterations, 4); - assert_eq!(res.max_iterations, 4); - assert_eq!(res.area_trace.len(), res.iterations + 1); - assert!(*res.area_trace.last().unwrap() > 0.0); -} diff --git a/tests/unit/fitstats_ic_tests.rs b/tests/unit/fitstats_ic_tests.rs deleted file mode 100644 index 5b0a834fd..000000000 --- a/tests/unit/fitstats_ic_tests.rs +++ /dev/null @@ -1,14 +0,0 @@ -use super::*; - -#[test] -fn information_criteria_reference_values() { - let ic = information_criteria(-500.0, 10, 200); - assert!((ic.aic - 1020.0).abs() < 1e-12); - assert!((ic.bic - (1000.0 + 10.0 * (200.0_f64).ln())).abs() < 1e-12); - assert!((ic.caic - (1000.0 + 10.0 * ((200.0_f64).ln() + 1.0))).abs() < 1e-12); - assert!((ic.aicc - (1020.0 + 220.0 / 189.0)).abs() < 1e-9); - assert!((ic.sabic - (1000.0 + 10.0 * (202.0_f64 / 24.0).ln())).abs() < 1e-9); - // degenerate n does not panic - let tiny = information_criteria(-5.0, 10, 10); - assert!(tiny.aicc.is_nan()); -} diff --git a/tests/unit/fitstats_ld_tests.rs b/tests/unit/fitstats_ld_tests.rs deleted file mode 100644 index 517ee56f4..000000000 --- a/tests/unit/fitstats_ld_tests.rs +++ /dev/null @@ -1,202 +0,0 @@ -use super::*; -use crate::nodes::XiRule; -use crate::scoring::{ItemBank, PriorSpec}; -use crate::ModelType; - -fn two_item_bank<'a>( - alpha: &'a [f64], - b: &'a [f64], - zeta: &'a [f64], - fid: &'a [usize], -) -> ItemBank<'a> { - ItemBank { - alpha, - b, - zeta, - tau: -30.0, - factor_id: fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - } -} - -#[test] -fn ld_indices_reject_non_binary_observed_responses() { - let alpha = vec![0.0; 2]; - let b = vec![0.0; 2]; - let zeta = vec![0.0; 2]; - let fid = vec![0usize; 2]; - let bank = two_item_bank(&alpha, &b, &zeta, &fid); - let observed = vec![true; 40]; - - for invalid in [2.0, f64::NAN] { - let mut y = vec![0.0; 40]; - y[0] = invalid; - assert!( - ld_indices( - &bank, - &y, - &observed, - 20, - &PriorSpec::standard(1), - 7, - XiRule::GaussHermite { q_xi: 7 }, - ) - .is_err(), - "observed response {invalid:?} must be rejected" - ); - } -} - -#[test] -fn ld_indices_returns_error_for_malformed_prior() { - let alpha = vec![0.0; 2]; - let b = vec![0.0; 2]; - let zeta = vec![0.0; 2]; - let fid = vec![0usize; 2]; - let bank = two_item_bank(&alpha, &b, &zeta, &fid); - let y = vec![0.0; 40]; - let observed = vec![true; 40]; - let malformed = PriorSpec { - mean: Vec::new(), - sd: Vec::new(), - }; - - assert!(ld_indices( - &bank, - &y, - &observed, - 20, - &malformed, - 7, - XiRule::GaussHermite { q_xi: 7 }, - ) - .is_err()); -} - -#[test] -fn ld_indices_flag_a_dependent_pair() { - // simulate 1PL data, then force item 1 to copy item 0 (max LD) - let n_items = 6usize; - let n_persons = 800usize; - let alpha = vec![0.0; n_items]; - let b: Vec = (0..n_items).map(|i| -1.0 + 0.4 * i as f64).collect(); - let zeta = vec![0.0; n_items]; - let fid = vec![0usize; n_items]; - let mut state = 21u64; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut y = vec![0.0_f64; n_persons * n_items]; - for p in 0..n_persons { - let u1: f64 = unif().max(1e-12); - let u2: f64 = unif(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let eta: f64 = theta + b[i]; - if unif() < 1.0 / (1.0 + (-eta).exp()) { - y[p * n_items + i] = 1.0; - } - } - y[p * n_items + 1] = y[p * n_items]; // item 1 duplicates item 0 - } - let observed = vec![true; n_persons * n_items]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let res = ld_indices( - &bank, - &y, - &observed, - n_persons, - &PriorSpec::standard(1), - 15, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - // pair (0,1) is the first upper-triangle entry - assert!( - res.x2_signed[0] > 50.0, - "duplicated pair must show large positive LD X2: {}", - res.x2_signed[0] - ); - assert!(res.g2_signed[0] > 50.0); - // an unrelated pair stays modest - let pair_23 = (n_items - 1) + (n_items - 2) + 0; // (2,3) index in triangle - assert!(res.x2_signed[pair_23].abs() < 50.0); -} - -#[test] -fn ld_indices_sign_follows_phi_correlation_not_the_11_cell() { - let alpha = vec![2.0_f64.ln(); 2]; - let b = vec![0.0; 2]; - let zeta = vec![0.0; 2]; - let fid = vec![0usize; 2]; - let bank = two_item_bank(&alpha, &b, &zeta, &fid); - - // The fitted model has equal item marginals and positive phi. This table - // has a larger 11 proportion than the model but phi = 0, so Chen-Thissen's - // direction is negative even though the isolated 11-cell residual is positive. - let mut y = Vec::with_capacity(200); - for (yi, yj, count) in [(1.0, 1.0, 45), (1.0, 0.0, 45), (0.0, 1.0, 5), (0.0, 0.0, 5)] { - for _ in 0..count { - y.extend_from_slice(&[yi, yj]); - } - } - let observed = vec![true; y.len()]; - let res = ld_indices( - &bank, - &y, - &observed, - 100, - &PriorSpec::standard(1), - 41, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - - assert!( - res.x2_signed[0] < 0.0, - "observed phi is below model phi, so LD X2 must be negative: {:?}", - res.x2_signed - ); - assert!(res.g2_signed[0] < 0.0); -} - -#[test] -fn ld_indices_returns_nan_when_phi_is_undefined() { - let alpha = vec![2.0_f64.ln(); 2]; - let b = vec![0.0; 2]; - let zeta = vec![0.0; 2]; - let fid = vec![0usize; 2]; - let bank = two_item_bank(&alpha, &b, &zeta, &fid); - let y = vec![0.0; 40]; - let observed = vec![true; y.len()]; - - let res = ld_indices( - &bank, - &y, - &observed, - 20, - &PriorSpec::standard(1), - 41, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - - assert!(res.x2_signed[0].is_nan()); - assert!(res.g2_signed[0].is_nan()); -} diff --git a/tests/unit/fitstats_m2_branch_tests.rs b/tests/unit/fitstats_m2_branch_tests.rs deleted file mode 100644 index cc9cfa9ed..000000000 --- a/tests/unit/fitstats_m2_branch_tests.rs +++ /dev/null @@ -1,664 +0,0 @@ -use super::*; -use crate::quadrature::gh_rule; -use crate::scoring::{ItemBank, PriorSpec}; - -fn bank<'a>(alpha: &'a [f64], b: &'a [f64], zeta: &'a [f64], fid: &'a [usize]) -> ItemBank<'a> { - ItemBank { - alpha, - b, - zeta, - tau: -30.0, - factor_id: fid, - model_type: crate::ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - } -} - -#[test] -fn m2_factorizes_independent_trait_dimensions() { - let probs = vec![0.2, 0.8, 0.3, 0.7]; - let weights = vec![0.5, 0.5]; - let sets = vec![vec![0, 1]]; - let moments = factorized_trait_moments(&probs, &weights, 2, &[0, 1], 2, &sets); - assert!((moments[0] - 0.25).abs() < 1e-14); - assert!( - (moments[0] - 0.31).abs() > 1e-3, - "must not share one trait node" - ); - let missing_dimension = factorized_trait_moments(&probs, &weights, 2, &[0, 1], 3, &[vec![0]]); - assert!((missing_dimension[0] - 0.5).abs() < 1e-14); -} - -#[test] -fn m2_numeric_helpers_cover_every_parameter_and_metric_branch() { - assert_eq!(finish_ncchi2_mixture(1.0, 2.0, true), 0.5); - assert!(finish_ncchi2_mixture(1.0, 2.0, false).is_nan()); - assert!(solve_decreasing_root(0.0, 1.0, 0.5, &|_| 1.0).is_nan()); - let root = solve_decreasing_root(0.0, 1.0, 0.5, &|value| 1.0 - value); - assert!((root - 0.5).abs() < 1e-10); - - let params = m2_parameters(1, true, true, 2, true); - assert_eq!(params.len(), 5); - let mut alpha = vec![3.0]; - let mut b = vec![2.0]; - let mut zeta = vec![4.0, 5.0]; - let mut tau = 6.0; - for (index, param) in params.iter().copied().enumerate() { - assert_eq!( - m2_param_value(param, &alpha, &b, &zeta, tau, 2), - index as f64 + 2.0 - ); - set_m2_param( - param, - index as f64 + 12.0, - &mut alpha, - &mut b, - &mut zeta, - &mut tau, - 2, - ); - assert_eq!( - m2_param_value(param, &alpha, &b, &zeta, tau, 2), - index as f64 + 12.0 - ); - } - assert_eq!(m2_parameters(1, false, false, 0, false).len(), 1); - - assert_eq!(srmsr_from_sum(4.0, 1), 2.0); - assert!(srmsr_from_sum(0.0, 0).is_nan()); - let finite = comparative_fit_metrics(5.0, 3.0, 20.0, 5.0); - assert!(finite.0.is_finite() && finite.1.is_finite()); - let unavailable = comparative_fit_metrics(5.0, 3.0, 2.0, 5.0); - assert!(unavailable.0.is_nan() && unavailable.1.is_nan()); -} - -#[test] -fn ncchi2_large_noncentrality_matches_reference_values() { - // Independently evaluated with scipy.stats.ncx2 and scipy.optimize.brentq. - let cases = [ - (2_000.0, 50.0, 0.05, 2_099.928_758_291_509_4), - (10_000.0, 50.0, 0.05, 10_282.274_417_418_035), - (10_000.0, 50.0, 0.95, 9_625.139_462_181_574), - ]; - for (statistic, df, target, expected) in cases { - let got = nc_lambda_for(statistic, df, target); - assert!((got - expected).abs() <= 1e-10 * expected); - assert!((ncchi2_cdf(statistic, df, got) - target).abs() <= 1e-10); - } -} - -#[test] -fn m2_rejects_too_few_items() { - let (alpha, b, zeta, fid) = (vec![0.0; 2], vec![0.0; 2], vec![0.0; 2], vec![0usize; 2]); - let bk = bank(&alpha, &b, &zeta, &fid); - let y = vec![0.0; 4]; - let obs = vec![true; 4]; - assert!(m2_rmsea2( - &bk, - &y, - &obs, - 2, - &PriorSpec::standard(1), - 11, - XiRule::GaussHermite { q_xi: 7 } - ) - .is_err()); -} - -#[test] -fn m2_rejects_length_mismatch() { - let (alpha, b, zeta, fid) = (vec![0.0; 4], vec![0.0; 4], vec![0.0; 4], vec![0usize; 4]); - let bk = bank(&alpha, &b, &zeta, &fid); - let y = vec![0.0; 8]; // wrong length for n_persons=3 - let obs = vec![true; 8]; - assert!(m2_rmsea2( - &bk, - &y, - &obs, - 3, - &PriorSpec::standard(1), - 11, - XiRule::GaussHermite { q_xi: 7 } - ) - .is_err()); -} - -#[test] -fn m2_rejects_nonpositive_df() { - // 3 MIRT items: s = 3 + 3 = 6 moments, p = 2*3 = 6 params -> df <= 0 - let (alpha, b, zeta, fid) = (vec![0.0; 3], vec![0.0; 3], vec![0.0; 3], vec![0usize; 3]); - let bk = bank(&alpha, &b, &zeta, &fid); - let n = 50usize; - let y = vec![1.0; n * 3]; - let obs = vec![true; n * 3]; - assert!(m2_rmsea2( - &bk, - &y, - &obs, - n, - &PriorSpec::standard(1), - 11, - XiRule::GaussHermite { q_xi: 7 } - ) - .is_err()); -} - -#[test] -fn m2_rejects_too_few_complete_cases() { - // 8 items, but every row has a missing entry -> no complete cases - let (alpha, b, zeta, fid) = (vec![0.0; 8], vec![0.0; 8], vec![0.0; 8], vec![0usize; 8]); - let bk = bank(&alpha, &b, &zeta, &fid); - let n = 40usize; - let y = vec![0.0; n * 8]; - let mut obs = vec![true; n * 8]; - for p in 0..n { - obs[p * 8] = false; // first item missing for everyone - } - assert!(m2_rmsea2( - &bk, - &y, - &obs, - n, - &PriorSpec::standard(1), - 11, - XiRule::GaussHermite { q_xi: 7 } - ) - .is_err()); -} - -#[test] -fn m2_runs_on_small_hand_built_bank() { - // exercises the full body (Cholesky, Delta, Xi, CI, SRMSR) under the lib - // tests, not only the integration recovery test - let n_items = 8usize; - let n = 400usize; - let alpha = vec![0.0; n_items]; - let b: Vec = (0..n_items).map(|i| -0.8 + 0.2 * i as f64).collect(); - let zeta = vec![0.0; n_items]; - let fid = vec![0usize; n_items]; - let mut state = 4242u64; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut y = vec![0.0; n * n_items]; - for p in 0..n { - let u1 = unif().max(1e-12); - let u2 = unif(); - let th = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let prob = 1.0 / (1.0 + (-(th + b[i])).exp()); - y[p * n_items + i] = if unif() < prob { 1.0 } else { 0.0 }; - } - } - let obs = vec![true; n * n_items]; - let bk = bank(&alpha, &b, &zeta, &fid); - let res = m2_rmsea2( - &bk, - &y, - &obs, - n, - &PriorSpec::standard(1), - 21, - XiRule::GaussHermite { q_xi: 7 }, - ) - .expect("m2 should run"); - assert_eq!(res.n_moments, 36); - assert!(res.m2.is_finite() && res.df == 20.0); - assert!(res.rmsea2_ci_lower <= res.rmsea2_ci_upper + 1e-9); - assert!(res.srmsr.is_finite()); -} - -#[test] -fn poly_m2_reduces_to_binary_m2() { - // At K=2 the polytomous M2 must equal the trusted binary m2_rmsea2 at the - // same parameters (both GRM and GPCM cells reduce to the 2PL). This - // anchors the cumulative-moment machinery, the merge-max Xi, and the - // Delta/Cholesky solve against already-validated code. - use crate::poly::PolyModel; - let (n_persons, n_items) = (1500usize, 6usize); - let mut st = 24680u64; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let a_true: Vec = (0..n_items).map(|i| 0.9 + 0.1 * i as f64).collect(); - let b_true: Vec = (0..n_items).map(|i| -0.5 + 0.2 * i as f64).collect(); - let mut yf = vec![0.0_f64; n_persons * n_items]; - let mut yi = vec![0usize; n_persons * n_items]; - for pp in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let th = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let pr = 1.0 / (1.0 + (-(a_true[i] * th + b_true[i])).exp()); - let v = if u() < pr { 1.0 } else { 0.0 }; - yf[pp * n_items + i] = v; - yi[pp * n_items + i] = v as usize; - } - } - let obs = vec![true; n_persons * n_items]; - let alpha: Vec = a_true.iter().map(|a| a.ln()).collect(); - let zeta = vec![0.0_f64; n_items]; - let fid = vec![0usize; n_items]; - let bk = bank(&alpha, &b_true, &zeta, &fid); - let r_bin = m2_rmsea2( - &bk, - &yf, - &obs, - n_persons, - &PriorSpec::standard(1), - 41, - XiRule::GaussHermite { q_xi: 1 }, - ) - .unwrap(); - for model in [PolyModel::Gpcm, PolyModel::Grm] { - let r_poly = poly_m2( - &yi, - Some(&obs), - n_persons, - n_items, - 2, - &a_true, - &b_true, - model, - 41, - ) - .unwrap(); - assert_eq!(r_poly.n_moments, r_bin.n_moments, "{model:?} n_moments"); - assert_eq!( - r_poly.n_parameters, r_bin.n_parameters, - "{model:?} n_parameters" - ); - assert_eq!(r_poly.df, r_bin.df, "{model:?} df"); - assert!( - (r_poly.m2 - r_bin.m2).abs() < 1e-4, - "{model:?} M2: poly {} vs binary {}", - r_poly.m2, - r_bin.m2 - ); - assert!( - (r_poly.p_value - r_bin.p_value).abs() < 1e-4, - "{model:?} p_value" - ); - assert!( - (r_poly.rmsea2 - r_bin.rmsea2).abs() < 1e-4, - "{model:?} rmsea2" - ); - } -} - -// GPCM Monte-Carlo for M2 calibration: returns (mean M2/df, rejection rate at -// .05, df) over `reps` datasets simulated at fixed true parameters. Under a -// NORMAL theta (matching the N(0,1) quadrature) the model is correctly -// specified, so M2 -> chi^2(df) even at the true parameters (the residual -// projector removes P dimensions); under a right-SKEWED theta the N(0,1) -// quadrature is a population misspecification the statistic should detect. -fn mc_poly_m2(reps: usize, n_persons: usize, skew: bool) -> (f64, f64, f64) { - use crate::poly::{gpcm_logprobs, PolyModel}; - let (n_items, k) = (5usize, 3usize); - let z = k - 1; - let a_true: Vec = (0..n_items).map(|i| 0.9 + 0.12 * i as f64).collect(); - let cat_true: Vec = (0..n_items) - .flat_map(|i| vec![0.8 - 0.1 * i as f64, -0.8 + 0.1 * i as f64]) - .collect(); - let (mut ratio_sum, mut n_reject, mut df_val) = (0.0_f64, 0usize, 0.0_f64); - for rep in 0..reps { - let mut st = 909_090u64 + rep as u64 * 131 + if skew { 5 } else { 0 }; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut yi = vec![0usize; n_persons * n_items]; - for pp in 0..n_persons { - let theta = if skew { - -(u().max(1e-12)).ln() - 1.0 - } else { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - for i in 0..n_items { - let base = a_true[i] * theta; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; k]; - ic[1..].copy_from_slice(&cat_true[i * z..(i + 1) * z]); - let lp = gpcm_logprobs(base, &scores, &ic); - let draw = u(); - let (mut acc, mut cat) = (0.0_f64, k - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - yi[pp * n_items + i] = cat; - } - } - let r = poly_m2( - &yi, - None, - n_persons, - n_items, - k, - &a_true, - &cat_true, - PolyModel::Gpcm, - 21, - ) - .unwrap(); - ratio_sum += r.m2 / r.df; - if r.p_value < 0.05 { - n_reject += 1; - } - df_val = r.df; - } - ( - ratio_sum / reps as f64, - n_reject as f64 / reps as f64, - df_val, - ) -} - -#[test] -fn poly_m2_calibration_null_and_skew_power() { - // Fast CI guard. The authoritative >=500-replication study is - // poly_m2_monte_carlo_500 (ignored). See mc_poly_m2 for the design. - let (reps, n) = (20usize, 1500usize); - let (mn, rej_n, df) = mc_poly_m2(reps, n, false); - let (ms, rej_s, _) = mc_poly_m2(reps, n, true); - println!( - "[poly M2] df={df} normal: mean(M2)/df={mn:.3} reject={rej_n:.3} \ - skew: mean(M2)/df={ms:.3} reject={rej_s:.3}" - ); - // matched N(0,1) prior => calibrated (mean ~ df, few false rejections) - assert!((0.75..=1.35).contains(&mn), "normal M2/df off: {mn}"); - assert!(rej_n < 0.25, "normal rejection too high: {rej_n}"); - // skewed population is a misspecification M2 detects => inflated vs normal - assert!(ms > mn, "skew must inflate M2 vs normal: {ms} vs {mn}"); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn poly_m2_monte_carlo_500() { - let (reps, n) = (500usize, 2000usize); - let (mn, rej_n, df) = mc_poly_m2(reps, n, false); - let (ms, rej_s, _) = mc_poly_m2(reps, n, true); - println!( - "[poly M2 500] df={df} normal: mean(M2)/df={mn:.4} reject={rej_n:.4} \ - skew: mean(M2)/df={ms:.4} reject={rej_s:.4}" - ); - assert!((0.9..=1.1).contains(&mn), "normal M2/df off: {mn}"); - assert!(rej_n < 0.12, "normal Type I too high: {rej_n}"); - assert!( - ms > mn + 0.1 && rej_s > rej_n, - "skew misfit not detected: {ms} vs {mn}" - ); -} - -#[test] -fn poly_ld_matches_direct_2x2_at_k2() { - // Deterministic anchor: at K=2 the polytomous LD X² for each pair must - // equal a from-scratch 2x2 Pearson chi-square of observed counts vs the - // model-implied joint on the same quadrature — validating the table - // assembly, the local-independence marginalization, and the chi-square. - use crate::poly::{gpcm_logprobs, PolyModel}; - let (n_persons, n_items) = (600usize, 3usize); - let mut st = 13131u64; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let a = vec![1.1_f64, 0.9, 1.3]; - let b = vec![0.3_f64, -0.4, 0.1]; // K=2 GPCM intercept per item - let mut yi = vec![0usize; n_persons * n_items]; - for pp in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let th = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let pr = 1.0 / (1.0 + (-(a[i] * th + b[i])).exp()); - yi[pp * n_items + i] = if u() < pr { 1 } else { 0 }; - } - } - let observed = vec![true; yi.len()]; - let r = poly_local_dependence( - &yi, - Some(&observed), - n_persons, - n_items, - 2, - &a, - &b, - PolyModel::Gpcm, - 41, - ) - .unwrap(); - assert_eq!(r.df, 1.0); - let (nodes, weights) = gh_rule(41).unwrap(); - let pcat = |i: usize, t: usize| -> [f64; 2] { - let lp = gpcm_logprobs(a[i] * nodes[t], &[0.0, 1.0], &[0.0, b[i]]); - [lp[0].exp(), lp[1].exp()] - }; - for (idx, &(i, j)) in r.pairs.iter().enumerate() { - let mut pj = [[0.0_f64; 2]; 2]; - for t in 0..nodes.len() { - let (pi, pjj) = (pcat(i, t), pcat(j, t)); - for aa in 0..2 { - for bb in 0..2 { - pj[aa][bb] += weights[t] * pi[aa] * pjj[bb]; - } - } - } - let mut o = [[0.0_f64; 2]; 2]; - for pp in 0..n_persons { - o[yi[pp * n_items + i]][yi[pp * n_items + j]] += 1.0; - } - let nf = n_persons as f64; - let mut x2ref = 0.0_f64; - for aa in 0..2 { - for bb in 0..2 { - let e = nf * pj[aa][bb]; - if e > 1e-12 { - let d = o[aa][bb] - e; - x2ref += d * d / e; - } - } - } - assert!( - (r.x2[idx] - x2ref).abs() < 1e-8, - "pair ({i},{j}): poly {} vs direct 2x2 {}", - r.x2[idx], - x2ref - ); - } -} - -// GPCM Monte-Carlo for the LD X²: returns (mean X²/df over locally-INDEPENDENT -// pairs, their rejection rate, X²/df for the injected/target pair (0,1), its -// rejection rate, df). With `inject_ld` a shared specific factor couples items -// 0 and 1 (a testlet), which the LD X² for that pair should detect while the -// other pairs stay calibrated. A skewed ability is a population -// misspecification that inflates all pairs. -fn mc_poly_ld( - reps: usize, - n_persons: usize, - skew: bool, - inject_ld: bool, -) -> (f64, f64, f64, f64, f64) { - use crate::poly::{fit_poly_unidim, gpcm_logprobs, PolyModel}; - let (n_items, k) = (5usize, 3usize); - let z = k - 1; - let a_true: Vec = (0..n_items).map(|i| 1.0 + 0.1 * i as f64).collect(); - let cat_true: Vec = (0..n_items) - .flat_map(|i| vec![0.7 - 0.08 * i as f64, -0.7 + 0.08 * i as f64]) - .collect(); - let (mut ind_ratio, mut ind_rej, mut ind_cnt) = (0.0_f64, 0usize, 0usize); - let (mut ld_ratio, mut ld_rej) = (0.0_f64, 0usize); - let mut df_val = 0.0_f64; - for rep in 0..reps { - let mut st = 5150u64 + rep as u64 * 131 + (skew as u64) * 7 + (inject_ld as u64) * 101; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut yi = vec![0usize; n_persons * n_items]; - for pp in 0..n_persons { - let theta = if skew { - -(u().max(1e-12)).ln() - 1.0 - } else { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - // shared specific factor coupling items 0 and 1 (testlet LD) - let uij = if inject_ld { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } else { - 0.0 - }; - for i in 0..n_items { - let extra = if inject_ld && (i == 0 || i == 1) { - uij - } else { - 0.0 - }; - let base = a_true[i] * theta + extra; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; k]; - ic[1..].copy_from_slice(&cat_true[i * z..(i + 1) * z]); - let lp = gpcm_logprobs(base, &scores, &ic); - let draw = u(); - let (mut acc, mut cat) = (0.0_f64, k - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - yi[pp * n_items + i] = cat; - } - } - // LD is evaluated at the FITTED parameters (the operational case): the - // marginal MLE absorbs the univariate margins, leaving the (K-1)² - // residual-association dof the statistic references. - let fit = fit_poly_unidim( - &yi, - None, - n_persons, - n_items, - k, - PolyModel::Gpcm, - 21, - 80, - 1e-6, - ) - .unwrap(); - assert!( - fit.converged, - "polytomous LD replicate {rep} did not converge: reason={}, \ - n_iter={}/{}, delta={:.6e}, tolerance={:.6e}", - fit.termination_reason, fit.n_iter, 80, fit.final_delta, fit.stopping_tolerance - ); - let cp_flat: Vec = fit.cat_params.iter().flatten().copied().collect(); - let r = poly_local_dependence( - &yi, - None, - n_persons, - n_items, - k, - &fit.slope, - &cp_flat, - PolyModel::Gpcm, - 21, - ) - .unwrap(); - df_val = r.df; - for (idx, &(i, j)) in r.pairs.iter().enumerate() { - let ratio = r.x2[idx] / r.df; - let rej = r.p_value[idx] < 0.05; - if (i, j) == (0, 1) { - ld_ratio += ratio; - ld_rej += rej as usize; - } else if i >= 2 && j >= 2 { - // pairs among the untouched items 2..; testlet-touching pairs excluded - ind_ratio += ratio; - ind_rej += rej as usize; - ind_cnt += 1; - } - } - } - ( - ind_ratio / ind_cnt as f64, - ind_rej as f64 / ind_cnt as f64, - ld_ratio / reps as f64, - ld_rej as f64 / reps as f64, - df_val, - ) -} - -#[test] -fn poly_ld_calibration_and_power() { - // Fast CI guard (fits each dataset). Authoritative >=500-rep study is - // poly_ld_monte_carlo_500 (ignored). "clean" = pairs among the untouched - // items 2.. ; "pair01" = the item pair carrying the injected testlet. - let (reps, n) = (20usize, 1500usize); - let (c0, r0, t0, _, df) = mc_poly_ld(reps, n, false, false); // null, normal ability - let (cl, rl, tl, tlrej, _) = mc_poly_ld(reps, n, false, true); // testlet on (0,1) - let (cs, rs, _, _, _) = mc_poly_ld(reps, n, true, false); // skewed ability - println!( - "[poly LD] df={df} null: clean X2/df={c0:.3} reject={r0:.3} pair01={t0:.3} \ - LD: clean={cl:.3} reject={rl:.3} pair01 X2/df={tl:.3} reject={tlrej:.3} \ - skew: clean={cs:.3} reject={rs:.3}" - ); - // null: clean pairs calibrated (the Chen-Thissen reference is conservative) - assert!((0.45..=1.35).contains(&c0), "null clean X2/df off: {c0}"); - assert!(r0 < 0.15, "null rejection too high: {r0}"); - // power: the testlet pair (0,1) is flagged; clean pairs stay calibrated - assert!( - tl > 3.0 && tlrej > 0.6, - "LD pair not detected: X2/df={tl}, reject={tlrej}" - ); - assert!( - cl < 1.6 && rl < 0.20, - "clean pairs inflated under LD: {cl}, {rl}" - ); - // a skewed ability that the N(0,1)-quadrature model cannot match inflates - // the pairwise residual association (a detectable distribution misfit) - assert!(cs > 2.0, "skew misspecification should inflate LD: {cs}"); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn poly_ld_monte_carlo_500() { - let (reps, n) = (500usize, 2000usize); - let (c0, r0, _, _, df) = mc_poly_ld(reps, n, false, false); - let (cl, rl, tl, tlrej, _) = mc_poly_ld(reps, n, false, true); - println!( - "[poly LD 500] df={df} null: clean X2/df={c0:.4} reject={r0:.4} \ - LD: clean X2/df={cl:.4} reject={rl:.4} pair01 X2/df={tl:.4} reject={tlrej:.4}" - ); - assert!((0.6..=1.15).contains(&c0), "null clean X2/df off: {c0}"); - assert!(r0 < 0.09, "null Type I not conservative: {r0}"); - // a 2-item testlet biases the whole unidimensional fit, so clean pairs are - // mildly elevated, but the LD pair is localized far above them - assert!(cl < 1.6, "clean pairs too inflated under LD: {cl}"); - assert!( - tl > 6.0 && tlrej > 0.95 && tl > 4.0 * cl, - "LD pair power/separation too low: pair01={tl} clean={cl} reject={tlrej}" - ); -} diff --git a/tests/unit/fitstats_tests.rs b/tests/unit/fitstats_tests.rs deleted file mode 100644 index d9139e290..000000000 --- a/tests/unit/fitstats_tests.rs +++ /dev/null @@ -1,1074 +0,0 @@ -use super::*; -use crate::ModelType; - -#[test] -fn chi2_sf_reference_values() { - assert!((chi2_sf(3.841, 1.0) - 0.05).abs() < 1e-3); - assert!((chi2_sf(18.307, 10.0) - 0.05).abs() < 1e-3); - assert!((chi2_sf(0.0, 5.0) - 1.0).abs() < 1e-12); - assert!(chi2_sf(1e6, 2.0) < 1e-12); -} - -#[test] -fn bh_step_up_known_case() { - let p = [ - 0.001, 0.008, 0.039, 0.041, 0.042, 0.06, 0.074, 0.205, 0.212, 0.216, - ]; - let r = benjamini_hochberg(&p, 0.05); - assert_eq!(r.iter().filter(|&&v| v).count(), 2); - assert!(r[0] && r[1]); -} - -#[test] -fn leniency_residuals_respect_mask_and_sign() { - let y = vec![1.0, 1.0, 0.0, 0.0, 1.0, 0.0]; - let observed = vec![true, true, true, false, false, false]; - let prob = vec![0.2, 0.2, 0.2, 0.2, 0.2, 0.2]; - let result = leniency_residuals(&y, &observed, &prob, 3).unwrap(); - // Reads crate-returned values and kills mutations that flip residual sign - // or ignore observed-mask filtering; the empty third row kills mutations - // that leak NaN/empty rows into public outputs or summary statistics. - assert!(result.residual[0] > 0.75); - assert!(result.residual[1] < -0.15); - assert!(result.residual[0] > result.residual[1]); - assert_eq!(result.residual[2], 0.0); - assert_eq!(result.n_observed, vec![2, 1, 0]); - assert!(result.mean > 0.29); - assert!(result.abs_p95 > result.residual[1].abs()); - assert!(result.abs_p95 < result.residual[0].abs()); -} - -fn toy_bank_data() -> ( - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, -) { - // 1 dim, 20 items, 2000 persons simulated from a plain 1PL (MIRT - // flags); person-fit asymptotics are in the item count, and the S-X2 - // effect size needs enough persons per score group to separate - // sampling noise from systematic misfit. - let n_items = 20usize; - let n_persons = 2000usize; - let alpha = vec![0.0; n_items]; - let b: Vec = (0..n_items).map(|i| -1.2 + 0.12 * i as f64).collect(); - let zeta = vec![0.0; n_items]; - let fid = vec![0usize; n_items]; - let mut state = 777u64; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut theta = vec![0.0_f64; n_persons]; - let mut y = vec![0.0_f64; n_persons * n_items]; - for p in 0..n_persons { - let u1: f64 = unif().max(1e-12); - let u2: f64 = unif(); - theta[p] = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let eta: f64 = theta[p] + b[i]; - let prob = 1.0 / (1.0 + (-eta).exp()); - y[p * n_items + i] = if unif() < prob { 1.0 } else { 0.0 }; - } - } - let observed = vec![true; n_persons * n_items]; - let xi = vec![0.0_f64; n_persons]; - (alpha, b, zeta, fid, y, observed, theta, xi) -} - -#[test] -fn sx2_runs_and_effect_size_is_small_for_true_model() { - let (alpha, b, zeta, fid, y, observed, _, _) = toy_bank_data(); - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let res = s_x2( - &bank, - &y, - &observed, - 2000, - &PriorSpec::standard(1), - &SX2Config { - q_theta: 21, - ..Default::default() - }, - None, - ) - .unwrap(); - let finite = res.statistic.iter().filter(|v| v.is_finite()).count(); - assert!(finite >= 15); - // data simulated from the scoring model: typical effect sizes stay low - // (the residual RMS at this N is dominated by ~sqrt(p(1-p)/N_s) noise) - let mean_effect: f64 = res - .rms_residual - .iter() - .filter(|v| v.is_finite()) - .sum::() - / finite as f64; - assert!( - mean_effect < 0.05, - "effect size too large for a true model: {mean_effect}" - ); -} - -#[test] -fn sx2_rejects_non_dichotomous_responses() { - // A non-0/1 observed value would index the summed-score table out of bounds. - let (alpha, b, zeta, fid, mut y, observed, _, _) = toy_bank_data(); - y[0] = 2.0; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let res = s_x2( - &bank, - &y, - &observed, - 2000, - &PriorSpec::standard(1), - &SX2Config { - q_theta: 21, - ..Default::default() - }, - None, - ); - let err = res.err().expect("expected an error"); - assert!(err.contains("dichotomous"), "got: {err}"); -} - -#[test] -fn sx2_rejects_malformed_bank_controls_and_weights() { - let (alpha, b, zeta, mut fid, y, observed, _, _) = toy_bank_data(); - fid.pop(); - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let err = s_x2( - &bank, - &y, - &observed, - 2000, - &PriorSpec::standard(1), - &SX2Config::default(), - None, - ) - .err() - .expect("expected malformed bank error"); - assert!(err.contains("inconsistent lengths"), "got: {err}"); - - let valid_fid = vec![0usize; b.len()]; - let valid_bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &valid_fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let bad_weight = vec![0.5; 2000]; - let err = s_x2( - &valid_bank, - &y, - &observed, - 2000, - &PriorSpec::standard(1), - &SX2Config::default(), - Some(&bad_weight), - ) - .err() - .expect("expected invalid weight error"); - assert!(err.contains("0/1"), "got: {err}"); - - let err = s_x2( - &valid_bank, - &y, - &observed, - 2000, - &PriorSpec::standard(1), - &SX2Config { - fdr_q: f64::NAN, - ..Default::default() - }, - None, - ) - .err() - .expect("expected invalid fdr error"); - assert!(err.contains("fdr_q"), "got: {err}"); -} - -#[test] -fn sx2_extreme_item_probabilities_remain_finite() { - let (alpha, _b, zeta, fid, mut y, observed, _, _) = toy_bank_data(); - let b = vec![-1000.0; alpha.len()]; - for (index, value) in y.iter_mut().enumerate() { - *value = ((index.wrapping_mul(17).wrapping_add(3)) % 5 < 2) as u8 as f64; - } - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let result = s_x2( - &bank, - &y, - &observed, - 2000, - &PriorSpec::standard(1), - &SX2Config::default(), - None, - ) - .unwrap(); - assert!(result.statistic.iter().all(|value| value.is_finite())); - assert!(result - .rms_residual - .iter() - .zip(&result.n_score_groups) - .all(|(value, &groups)| value.is_finite() || groups == 0)); - assert!(result - .g2_statistic - .iter() - .zip(&result.n_score_groups) - .all(|(value, &groups)| value.is_finite() || groups == 0)); - assert!(result - .g2_p_value - .iter() - .zip(&result.df) - .all(|(value, &df)| value.is_finite() || !df.is_finite() || df < 1.0)); -} - -#[test] -fn sx2_g2_p_values_follow_chi2_sf_mapping() { - let (alpha, b, zeta, fid, y, observed, _, _) = toy_bank_data(); - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let result = s_x2( - &bank, - &y, - &observed, - 2000, - &PriorSpec::standard(1), - &SX2Config::default(), - None, - ) - .unwrap(); - // Reads crate-returned g2_statistic/g2_p_value and fails if the implementation - // mutates to use the wrong p-value mapping. - let mut checked = 0usize; - for i in 0..result.df.len() { - if result.df[i].is_finite() && result.df[i] >= 1.0 && result.g2_statistic[i].is_finite() { - checked += 1; - let expected = chi2_sf(result.g2_statistic[i], result.df[i]); - assert!( - (result.g2_p_value[i] - expected).abs() < 1e-12, - "item {i} mismatch: {} vs {}", - result.g2_p_value[i], - expected - ); - } - } - assert!(checked > 0, "expected at least one finite G2 cell to validate"); -} - -#[test] -fn sx2_g2_stays_finite_when_observed_success_rate_hits_zero() { - let n_items = 20usize; - let n_persons = 2000usize; - let alpha = vec![0.0; n_items]; - let b = vec![0.0; n_items]; - let zeta = vec![0.0; n_items]; - let fid = vec![0usize; n_items]; - let y = vec![0.0_f64; n_persons * n_items]; - let observed = vec![true; n_persons * n_items]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let result = s_x2( - &bank, - &y, - &observed, - n_persons, - &PriorSpec::standard(1), - &SX2Config::default(), - None, - ) - .unwrap(); - // Reads crate-returned g2_statistic and kills the mutation where x*ln(x/y) - // is implemented naively (log(0) * 0 => NaN). - let finite: Vec<(f64, f64)> = result - .g2_statistic - .iter() - .zip(&result.statistic) - .filter_map(|(&g2, &x2)| { - if g2.is_finite() && x2.is_finite() { - Some((g2, x2)) - } else { - None - } - }) - .collect(); - assert!(!finite.is_empty()); - assert!(finite.iter().all(|(g2, _)| *g2 >= 0.0)); - assert!(result - .g2_p_value - .iter() - .zip(&result.df) - .all(|(value, &df)| value.is_finite() || !df.is_finite() || df < 1.0)); -} - -#[test] -fn infit_outfit_rejects_wrong_theta_length() { - let (alpha, b, zeta, fid, y, observed, _, xi) = toy_bank_data(); - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let short_theta = vec![0.0_f64; 3]; // not n_persons * n_dims - let err = infit_outfit(&bank, &y, &observed, 2000, &short_theta, &xi) - .err() - .expect("expected an error"); - assert!(err.contains("theta/xi"), "got: {err}"); -} - -#[test] -fn person_diagnostics_reject_malformed_bank_and_non_dichotomous_responses() { - let (alpha, b, zeta, fid, mut y, observed, theta, xi) = toy_bank_data(); - let malformed_bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid[..fid.len() - 1], - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - for err in [ - person_fit( - &malformed_bank, - &y, - &observed, - 2000, - &theta, - &xi, - &[], - -1.645, - ) - .err() - .expect("expected malformed bank error"), - infit_outfit(&malformed_bank, &y, &observed, 2000, &theta, &xi) - .err() - .expect("expected malformed bank error"), - ] { - assert!(err.contains("inconsistent lengths"), "got: {err}"); - } - - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - y[0] = 2.0; - for err in [ - person_fit(&bank, &y, &observed, 2000, &theta, &xi, &[], -1.645) - .err() - .expect("expected dichotomous response error"), - infit_outfit(&bank, &y, &observed, 2000, &theta, &xi) - .err() - .expect("expected dichotomous response error"), - ] { - assert!(err.contains("0 or 1"), "got: {err}"); - } -} - -#[test] -fn person_fit_and_msq_finite_for_true_model() { - let (alpha, b, zeta, fid, y, observed, _theta_true, _xi_true) = toy_bank_data(); - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - // designed usage: the Snijders correction applies to ESTIMATED scores - let eap = crate::scoring::score_eap( - &bank, - &y, - &observed, - 2000, - &PriorSpec::standard(1), - 21, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - let pf = person_fit( - &bank, - &y, - &observed, - 2000, - &eap.theta_eap, - &eap.xi_eap, - &[], - -1.645, - ) - .unwrap(); - let finite = pf.lz_star.iter().filter(|v| v.is_finite()).count(); - assert!(finite > 1800); - let flag_rate = pf.flagged.iter().filter(|&&f| f).count() as f64 / 2000.0; - assert!( - flag_rate < 0.12, - "flag rate should approach the nominal 5%: {flag_rate}" - ); - let msq = infit_outfit(&bank, &y, &observed, 2000, &eap.theta_eap, &eap.xi_eap).unwrap(); - let mean_infit: f64 = msq.infit.iter().sum::() / 20.0; - assert!( - (mean_infit - 1.0).abs() < 0.25, - "infit should center near 1: {mean_infit}" - ); -} - -#[test] -fn fitstats_public_boundaries_and_interaction_paths() { - assert_eq!(at_least_tiny(0.0, 1e-9), 1e-9); - assert_eq!(at_least_tiny(2.0, 1e-9), 2.0); - assert!(gammainc_upper_reg(1.0, -1.0).is_nan()); - assert!(gammainc_upper_reg(0.0, 1.0).is_nan()); - assert!(ln_gamma(0.25).is_finite()); - assert!(chi2_sf(1.0, 0.0).is_nan()); - assert_eq!(benjamini_hochberg(&[f64::NAN], 0.05), vec![false]); - assert!(erfc(-1.0) > 1.0); - assert!(vuong_nonnested(&[0.0], &[0.0], 1, 1, false).is_err()); - - let alpha = [0.0, 0.1, -0.1]; - let b = [-0.5, 0.0, 0.5]; - let zeta = [0.2, -0.1, 0.3]; - let factor = [0usize, 0, 0]; - let y = [0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0]; - let observed = [true, false, true, true, true, true, false, false, false]; - let theta = [-0.5, 0.0, 0.5]; - let xi = [0.1, -0.2, 0.3]; - let prior = PriorSpec::standard(1); - - for model_type in [ModelType::Mls2plm, ModelType::Bifac2plm] { - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &factor, - model_type, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let pf = person_fit(&bank, &y, &observed, 3, &theta, &xi, &theta, 100.0).unwrap(); - assert_eq!(pf.flagged.len(), 3); - let msq = infit_outfit(&bank, &y, &observed, 3, &theta, &xi).unwrap(); - assert_eq!(msq.infit.len(), 3); - let sx2 = s_x2( - &bank, - &y, - &observed, - 3, - &prior, - &SX2Config { - q_theta: 7, - ..Default::default() - }, - Some(&[0.0, 0.0, 0.0]), - ) - .unwrap(); - assert_eq!(sx2.statistic.len(), 3); - let residual_y: Vec = (0..10) - .flat_map(|person| { - [ - (person % 2) as f64, - ((person + 1) % 2) as f64, - (person % 3 == 0) as u8 as f64, - ] - }) - .collect(); - let residual_observed = vec![true; residual_y.len()]; - let residual_theta: Vec = (0..10).map(|person| person as f64 / 3.0 - 1.5).collect(); - let residual_xi: Vec = (0..10).map(|person| person as f64 / 10.0 - 0.5).collect(); - let residual = residual_item_fit( - &bank, - &residual_y, - &residual_observed, - 10, - &residual_theta, - &residual_xi, - 2, - ) - .unwrap(); - assert_eq!(residual.max_abs_z.len(), 3); - let resampled = - person_fit_resampling(&bank, &y, &observed, 3, &theta, &xi, &theta, 1, 0).unwrap(); - assert_eq!(resampled.len(), 3); - } - - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let rasch_bank = ItemBank { - model_type: ModelType::Mlsrm, - ..bank - }; - assert!(s_x2( - &rasch_bank, - &y, - &observed, - 3, - &prior, - &SX2Config { - q_theta: 7, - min_expected: 100.0, - ..Default::default() - }, - None, - ) - .is_ok()); - let all_zero = [0.0; 9]; - let all_observed = [true; 9]; - let sx2_extreme = s_x2( - &bank, - &all_zero, - &all_observed, - 3, - &prior, - &SX2Config { - q_theta: 7, - ..Default::default() - }, - None, - ) - .unwrap(); - assert!(sx2_extreme.rms_residual.iter().all(|value| value.is_nan())); - - let two_alpha = [0.0, 0.0]; - let two_b = [0.0, 0.0]; - let two_zeta = [0.0, 0.0]; - let split_factor = [0usize, 1]; - let split_bank = ItemBank { - alpha: &two_alpha, - b: &two_b, - zeta: &two_zeta, - tau: 0.0, - factor_id: &split_factor, - model_type: ModelType::Mirt, - n_dims: 2, - latent_dim: 1, - eps_distance: 1e-8, - }; - assert!(s_x2( - &split_bank, - &[0.0, 1.0, 1.0, 0.0], - &[true; 4], - 2, - &PriorSpec::standard(2), - &SX2Config { - q_theta: 7, - ..Default::default() - }, - None, - ) - .is_ok()); - - let mut item_missing = observed; - for p in 0..3 { - item_missing[p * 3 + 2] = false; - } - let empty_item_msq = infit_outfit(&bank, &y, &item_missing, 3, &theta, &xi).unwrap(); - assert!(empty_item_msq.infit[2].is_nan() && empty_item_msq.outfit[2].is_nan()); - - let long_y: Vec = (0..30).map(|index| (index % 2) as f64).collect(); - let long_observed = vec![true; 30]; - let long_theta: Vec = (0..10).map(|p| p as f64 / 5.0 - 1.0).collect(); - let long_xi: Vec = (0..10).map(|p| 0.1 * p as f64).collect(); - for model_type in [ModelType::Mls2plm, ModelType::Bifac2plm] { - let interaction_bank = ItemBank { model_type, ..bank }; - let residual = residual_item_fit( - &interaction_bank, - &long_y, - &long_observed, - 10, - &long_theta, - &long_xi, - 2, - ) - .unwrap(); - assert!(residual.max_abs_z.iter().all(|value| value.is_finite())); - } - assert!(s_x2( - &bank, - &y[..2], - &observed[..2], - 1, - &prior, - &Default::default(), - None - ) - .is_err()); - assert!(s_x2( - &bank, - &y, - &observed, - 3, - &prior, - &Default::default(), - Some(&[1.0]) - ) - .is_err()); - assert!(person_fit(&bank, &y[..2], &observed[..2], 1, &[0.0], &[0.0], &[], -1.0).is_err()); - assert!(person_fit(&bank, &y, &observed, 3, &[0.0], &xi, &[], -1.0).is_err()); - assert!(person_fit(&bank, &y, &observed, 3, &theta, &xi, &[0.0], -1.0).is_err()); - assert!(infit_outfit(&bank, &y[..2], &observed[..2], 1, &[0.0], &[0.0]).is_err()); - assert!(residual_item_fit(&bank, &y[..2], &observed[..2], 1, &[0.0], &[0.0], 2).is_err()); - assert!(residual_item_fit(&bank, &y, &observed, 3, &[0.0], &xi, 2).is_err()); - assert!(residual_item_fit(&bank, &y, &observed, 3, &theta, &xi, 1).is_err()); - assert!(person_fit_resampling(&bank, &y, &observed, 3, &theta, &xi, &[], 0, 1).is_err()); - assert!(person_fit_resampling(&bank, &y, &observed, 3, &theta, &xi, &[], 10_001, 1).is_err()); - assert!( - person_fit_resampling(&bank, &y, &observed, usize::MAX, &theta, &xi, &[], 2, 1).is_err() - ); - - let mut nonfinite_theta = theta.clone(); - nonfinite_theta[0] = f64::NAN; - assert!(person_fit(&bank, &y, &observed, 3, &nonfinite_theta, &xi, &[], -1.0).is_err()); - assert!(person_fit(&bank, &y, &observed, 3, &theta, &xi, &[f64::NAN; 3], -1.0).is_err()); - assert!(person_fit(&bank, &y, &observed, 3, &theta, &xi, &[], f64::NAN).is_err()); - assert!(person_fit_resampling(&bank, &[], &[], 0, &[], &[], &[], 2, 1).is_err()); - assert!(adjusted_chi2_pairs( - &bank, - &y[..2], - &observed[..2], - 1, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 } - ) - .is_err()); - let adjusted = adjusted_chi2_pairs( - &bank, - &y, - &observed, - 3, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - ) - .unwrap(); - assert!(adjusted.ratio.iter().all(|value| value.is_nan())); - - assert!(dimensionality_residuals(&[0.0], 2, 1).is_err()); - assert!(dimensionality_residuals(&[], 0, 1).is_err()); - assert!(dimensionality_residuals(&[], 1, 0).is_err()); - assert!(dimensionality_residuals(&[f64::INFINITY], 1, 1).is_err()); - let sparse = dimensionality_residuals(&[f64::NAN, 0.0, f64::NAN, 0.0], 2, 2).unwrap(); - assert!(sparse.q3[0].is_nan()); - assert!(sparse.q3_max_abs.is_nan()); - assert!(sparse.q3_mean_abs.is_nan()); - let constant = dimensionality_residuals(&[1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 3, 2).unwrap(); - assert!(constant.q3[0].is_nan()); - assert!(constant.q3_max_abs.is_nan()); - assert!(constant.q3_mean_abs.is_nan()); - let no_pairs = dimensionality_residuals(&[0.0, 1.0, 2.0], 3, 1).unwrap(); - assert!(no_pairs.gddm.is_nan()); - assert!(no_pairs.mean_abs_residual_cross_product.is_nan()); - - let one_alpha = [0.0]; - let one_b = [0.0]; - let one_zeta = [0.0]; - let one_factor = [0usize]; - let one_bank = ItemBank { - alpha: &one_alpha, - b: &one_b, - zeta: &one_zeta, - tau: 0.0, - factor_id: &one_factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - assert!(ld_indices( - &one_bank, - &[0.0], - &[true], - 1, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - ) - .is_err()); - assert!(ld_indices( - &bank, - &[0.0], - &[true], - 1, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - ) - .is_err()); - let ld_small = ld_indices( - &bank, - &y, - &observed, - 3, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - ) - .unwrap(); - assert!(ld_small.x2_signed.iter().all(|value| value.is_nan())); - - let mut indefinite = [-1.0, 0.0, 0.0, -1.0]; - assert!(cholesky_lower(&mut indefinite, 2).is_err()); - let mut positive = [4.0, 2.0, 2.0, 3.0]; - cholesky_lower(&mut positive, 2).unwrap(); - let solved = chol_solve(&positive, 2, &[1.0, 2.0]); - assert!(solved.iter().all(|value| value.is_finite())); - assert_eq!(ncchi2_cdf(3.0, 2.0, 0.0), chi2_cdf(3.0, 2.0)); - assert!(ncchi2_cdf(f64::NAN, 2.0, 1.0).is_nan()); - assert_eq!(nc_lambda_for(0.0, 2.0, 0.95), 0.0); - - let other_b = [0.0, 0.1]; - let other_alpha = [0.0, 0.0]; - let other_zeta = [0.0, 0.0]; - let other_factor = [0usize, 0]; - let other_bank = ItemBank { - alpha: &other_alpha, - b: &other_b, - zeta: &other_zeta, - tau: 0.0, - factor_id: &other_factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - assert!(tcc_drift( - &bank, - &other_bank, - &prior, - 7, - XiRule::GaussHermite { q_xi: 3 }, - 0.1 - ) - .is_err()); - let spatial_bank = ItemBank { - model_type: ModelType::Mls2plm, - ..bank - }; - let quadrature_error = match tcc_drift( - &bank, - &spatial_bank, - &prior, - 7, - XiRule::GaussHermite { q_xi: 7 }, - 0.1, - ) { - Err(error) => error, - Ok(_) => panic!("mismatched quadrature unexpectedly accepted"), - }; - assert!( - quadrature_error.contains("quadrature"), - "{quadrature_error}" - ); - - let pm = crate::poly::PolyModel::Gpcm; - assert!(poly_local_dependence(&[], None, 0, 1, 2, &[1.0], &[0.0], pm, 7).is_err()); - assert!(poly_local_dependence(&[], None, 0, 2, 1, &[1.0, 1.0], &[], pm, 7).is_err()); - assert!(poly_local_dependence(&[0], None, 1, 2, 2, &[1.0, 1.0], &[0.0, 0.0], pm, 7).is_err()); - assert!(poly_local_dependence( - &[0, 0], - Some(&[true]), - 1, - 2, - 2, - &[1.0, 1.0], - &[0.0, 0.0], - pm, - 7, - ) - .is_err()); - assert!(poly_local_dependence(&[0, 0], None, 1, 2, 2, &[1.0], &[0.0, 0.0], pm, 7).is_err()); - assert!(poly_local_dependence(&[0, 0], None, 1, 2, 2, &[1.0, 1.0], &[0.0], pm, 7).is_err()); - assert!( - poly_local_dependence(&[0, 0], None, 1, 2, 2, &[f64::NAN, 1.0], &[0.0, 0.0], pm, 7) - .is_err() - ); - assert!( - poly_local_dependence(&[0, 2], None, 1, 2, 2, &[1.0, 1.0], &[0.0, 0.0], pm, 7).is_err() - ); - let overflow_ld = std::panic::catch_unwind(|| { - poly_local_dependence( - &[], - None, - usize::MAX / 2 + 1, - 2, - 2, - &[1.0, 1.0], - &[0.0, 0.0], - pm, - 7, - ) - }); - assert!( - overflow_ld.is_ok(), - "overflowed LD shape must return an error instead of panicking" - ); - // Reads crate Result. Kills the mutation that multiplies n_persons*n_items - // directly before checking response length. - assert!(overflow_ld.unwrap().is_err()); - let masked_ld = poly_local_dependence( - &[0, 99], - Some(&[true, false]), - 1, - 2, - 2, - &[1.0, 1.0], - &[0.0, 0.0], - pm, - 7, - ) - .unwrap(); - // Reads crate output (`x2`). Kills the mutation that validates all y cells - // without checking `observed`. - assert!(masked_ld.x2[0].is_nan()); - assert!(poly_local_dependence( - &[0, 99], - Some(&[true, true]), - 1, - 2, - 2, - &[1.0, 1.0], - &[0.0, 0.0], - pm, - 7, - ) - .is_err()); - let poly_y: Vec = (0..20).flat_map(|p| [p % 3, (p + 1) % 3]).collect(); - for model in [crate::poly::PolyModel::Gpcm, crate::poly::PolyModel::Grm] { - let result = poly_local_dependence( - &poly_y, - None, - 20, - 2, - 3, - &[1.0, 0.8], - &[0.5, -0.5, 0.75, -0.25], - model, - 7, - ) - .unwrap(); - assert_eq!(result.pairs, vec![(0, 1)]); - assert!(result.x2[0].is_finite()); - } - let sparse_pair = - poly_local_dependence(&[0, 1], None, 1, 2, 3, &[1.0, 1.0], &[0.0; 4], pm, 7).unwrap(); - assert!(sparse_pair.x2[0].is_nan()); - - assert!(poly_m2(&[], None, 0, 2, 2, &[1.0; 2], &[0.0; 2], pm, 7).is_err()); - assert!(poly_m2(&[], None, 0, 3, 1, &[1.0; 3], &[], pm, 7).is_err()); - assert!(poly_m2(&[0], None, 1, 3, 2, &[1.0; 3], &[0.0; 3], pm, 7).is_err()); - assert!(poly_m2( - &[0, 0, 0], - Some(&[true]), - 1, - 3, - 2, - &[1.0; 3], - &[0.0; 3], - pm, - 7, - ) - .is_err()); - assert!(poly_m2(&[0, 0, 0], None, 1, 3, 2, &[1.0; 2], &[0.0; 3], pm, 7).is_err()); - assert!(poly_m2(&[0, 0, 0], None, 1, 3, 2, &[1.0; 3], &[0.0; 2], pm, 7).is_err()); - assert!(poly_m2(&[0, 0, 0], None, 1, 3, 2, &[f64::NAN; 3], &[0.0; 3], pm, 7).is_err()); - assert!(poly_m2(&[0, 0, 2], None, 1, 3, 2, &[1.0; 3], &[0.0; 3], pm, 7).is_err()); - let overflow_m2 = std::panic::catch_unwind(|| { - poly_m2( - &[], - None, - usize::MAX / 3 + 1, - 3, - 2, - &[1.0; 3], - &[0.0; 3], - pm, - 7, - ) - }); - assert!( - overflow_m2.is_ok(), - "overflowed M2 shape must return an error instead of panicking" - ); - // Reads crate Result. Kills the mutation that multiplies n_persons*n_items - // directly before checking response length. - assert!(overflow_m2.unwrap().is_err()); - let mut masked_y: Vec = (0..20) - .flat_map(|p| [p % 2, (p / 2) % 2, (p / 3) % 2, (p / 5) % 2]) - .collect(); - let mut masked_obs = vec![true; masked_y.len()]; - masked_y[2] = 99; - masked_obs[2] = false; - let masked_poly_m2 = poly_m2( - &masked_y, - Some(&masked_obs), - 20, - 4, - 2, - &[1.0; 4], - &[0.0; 4], - pm, - 7, - ) - .unwrap(); - // Reads crate output (`n_complete`). Kills the mutation that validates all y - // cells without checking `observed`. - assert_eq!(masked_poly_m2.n_complete, 19); - masked_obs[2] = true; - assert!(poly_m2( - &masked_y, - Some(&masked_obs), - 20, - 4, - 2, - &[1.0; 4], - &[0.0; 4], - pm, - 7 - ) - .is_err()); - assert!(poly_m2(&[], None, 0, 3, 2, &[1.0; 3], &[0.0; 3], pm, 7).is_err()); - assert!(poly_m2(&[], None, 0, 4, 2, &[1.0; 4], &[0.0; 4], pm, 7).is_err()); - let four_y: Vec = (0..12) - .flat_map(|p| [p % 2, (p / 2) % 2, (p / 3) % 2, (p / 5) % 2]) - .collect(); - assert!(poly_m2( - &four_y, - None, - 12, - 4, - 2, - &[1.0; 4], - &[100.0; 4], - crate::poly::PolyModel::Grm, - 7, - ) - .is_err()); -} - -#[test] -fn person_fit_resampling_distinguishes_zero_and_one_seeds() { - let probs = [0.25_f64, 0.5, 0.75]; - let alpha = [0.0; 3]; - let b = probs.map(|p| (p / (1.0 - p)).ln()); - let zeta = [0.0; 3]; - let factor = [0_usize; 3]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let y = [0.0; 3]; - let observed = [true; 3]; - let theta = [0.0]; - let xi = [0.0]; - - let seed_zero = person_fit_resampling(&bank, &y, &observed, 1, &theta, &xi, &[], 1, 0).unwrap(); - let seed_one = person_fit_resampling(&bank, &y, &observed, 1, &theta, &xi, &[], 1, 1).unwrap(); - - assert_eq!(seed_zero, vec![1.0]); - assert_eq!(seed_one, vec![0.5]); -} diff --git a/tests/unit/fitstats_vuong_tests.rs b/tests/unit/fitstats_vuong_tests.rs deleted file mode 100644 index ed09c7396..000000000 --- a/tests/unit/fitstats_vuong_tests.rs +++ /dev/null @@ -1,75 +0,0 @@ -use super::*; - -#[test] -fn vuong_favors_the_better_model() { - // model A consistently better by 0.2 per case, with case noise - let mut state = 5u64; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let n = 400; - let la: Vec = (0..n).map(|_| -1.0 + 0.1 * unif()).collect(); - let lb: Vec = la.iter().map(|&v| v - 0.2 - 0.3 * (unif() - 0.5)).collect(); - let res = vuong_nonnested(&la, &lb, 10, 10, false).unwrap(); - assert!( - res.z > 2.0, - "A must be significantly favored: z = {}", - res.z - ); - assert!(res.p_two_sided < 0.05); - // BIC correction penalizes the bigger model - let res_pen = vuong_nonnested(&la, &lb, 40, 10, true).unwrap(); - assert!(res_pen.z < res.z); - // identical models are rejected as indistinguishable - assert!(vuong_nonnested(&la, &la, 10, 10, false).is_err()); -} - -#[test] -fn vuong_rejects_nonfinite_likelihoods_and_differences() { - assert!(vuong_nonnested(&[0.0, f64::NAN], &[0.0, 0.0], 1, 1, false).is_err()); - assert!(vuong_nonnested(&[0.0, f64::INFINITY], &[0.0, 0.0], 1, 1, false).is_err()); - assert!(vuong_nonnested(&[f64::MAX, 0.0], &[-f64::MAX, 1.0], 1, 1, false).is_err()); -} - -#[test] -fn erfc_reference_values() { - assert!((erfc(0.0) - 1.0).abs() < 1e-7); - assert!((erfc(1.959963984540054 / std::f64::consts::SQRT_2) - 0.05).abs() < 1e-4); -} - -#[test] -fn q3_detects_locally_dependent_pair() { - // residuals: items 0 and 1 share an extra common factor - let mut state = 11u64; - let mut norm = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - let u1 = (((state >> 11) as f64) / ((1u64 << 53) as f64)).max(1e-12); - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - let u2 = ((state >> 11) as f64) / ((1u64 << 53) as f64); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let (n_persons, n_items) = (600, 6); - let mut resid = vec![0.0_f64; n_persons * n_items]; - for p in 0..n_persons { - let shared = norm(); - for i in 0..n_items { - resid[p * n_items + i] = norm() * 0.4 + if i < 2 { 0.6 * shared } else { 0.0 }; - } - } - let out = dimensionality_residuals(&resid, n_persons, n_items).unwrap(); - assert!( - out.q3[0] > 0.5, - "dependent pair must show high Q3: {}", - out.q3[0] - ); - assert!(out.q3_max_abs >= out.q3[0].abs()); - assert!(out.gddm > 0.0); - assert_eq!(out.gddm, out.mean_abs_residual_cross_product); -} diff --git a/tests/unit/gpcm_tests.rs b/tests/unit/gpcm_tests.rs deleted file mode 100644 index 524711cab..000000000 --- a/tests/unit/gpcm_tests.rs +++ /dev/null @@ -1,767 +0,0 @@ -use super::*; -use crate::poly::{fit_poly_unidim, PolyModel}; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::() / a.len() as f64).sqrt() -} -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let (mx, my) = (x.iter().sum::() / n, y.iter().sum::() / n); - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for (a, b) in x.iter().zip(y) { - sxy += (a - mx) * (b - my); - sxx += (a - mx) * (a - mx); - syy += (b - my) * (b - my); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -/// Simulate multidimensional GPCM responses: base = sum_d slope[i,d]*theta_d, then -/// softmax_k(k*base + step_ik). -fn simulate( - slope: &[f64], - step: &[f64], - theta: &[f64], - n: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - rng: &mut Lcg, -) -> Vec { - let m1 = n_cat - 1; - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = 0.0f64; - for d in 0..n_dims { - base += slope[i * n_dims + d] * theta[p * n_dims + d]; - } - let mut intercepts = vec![0.0f64; n_cat]; - intercepts[1..].copy_from_slice(&step[i * m1..(i + 1) * m1]); - let lp = gpcm_logprobs(base, &scores, &intercepts); - let u = rng.next_f64(); - let mut acc = 0.0; - let mut cat = n_cat - 1; - for (k, l) in lp.iter().enumerate() { - acc += l.exp(); - if u < acc { - cat = k; - break; - } - } - y[p * n_items + i] = cat; - } - } - y -} - -/// D = 1 WITHIN-TOL reduction to fit_poly_unidim(GPCM). All-POSITIVE true slopes (fit_poly_unidim -/// forces a>0 via log_a); both reach the same MLE up to optimizer tolerance and the positive -/// reflection. NOT bit-exact. -#[test] -fn gpcm_reduces_to_poly_gpcm_at_d1() { - let (n, n_items, n_cat) = (2000usize, 6usize, 4usize); - let m1 = n_cat - 1; - let mut rng = Lcg(717717); - let mut slope = vec![0.0f64; n_items]; - let mut step = vec![0.0f64; n_items * m1]; - for i in 0..n_items { - slope[i] = 0.8 + 0.2 * i as f64; // POSITIVE - // UNORDERED steps (GPCM has no ordering constraint) - step[i * m1] = 0.6 - 0.1 * i as f64; - step[i * m1 + 1] = -0.4 + 0.05 * i as f64; - step[i * m1 + 2] = 0.3 - 0.08 * i as f64; - } - let theta: Vec = (0..n).map(|_| rng.normal()).collect(); - let y = simulate(&slope, &step, &theta, n, n_items, 1, n_cat, &mut rng); - let pattern = vec![1u8; n_items]; - let cfg = GpcmConfig { - q: 21, - ..GpcmConfig::default() - }; - let mm = fit_gpcm(&y, None, &pattern, n, n_items, 1, n_cat, &cfg).unwrap(); - let pf = fit_poly_unidim(&y, None, n, n_items, n_cat, PolyModel::Gpcm, 21, 500, 1e-6).unwrap(); - for i in 0..n_items { - assert!( - (mm.slope[i] - pf.slope[i]).abs() < 0.05, - "slope[{i}] {} vs {}", - mm.slope[i], - pf.slope[i] - ); - for j in 0..m1 { - let d = (mm.step[i * m1 + j] - pf.cat_params[i][j]).abs(); - assert!(d < 0.06, "step[{i}][{j}] diff {d}"); - } - } - assert!( - (*mm.loglik_trace.last().unwrap() - pf.loglik).abs() < 0.5, - "loglik" - ); - assert_eq!(mm.n_parameters, n_items * (1 + m1)); -} - -/// Deterministic FD GRADIENT anchor at D=2 (GH) AND D=4 (Halton, NON-IDENTITY dims [0,2,3]) with -/// M=4 categories, NON-MONOTONE steps (locks in that the GPCM softmax is finite for any steps — -/// no accidental ordering guard), and distinct random per-category counts (so a slope<->step slot -/// transposition is detected). The M-step uses an FD Hessian, so pin the GRADIENT. -#[test] -fn gpcm_gradient_matches_finite_difference() { - let n_cat = 4usize; - for &(n_dims, ref dims) in [(2usize, vec![0usize, 1]), (4usize, vec![0usize, 2, 3])].iter() { - let l = dims.len(); - let (nodes, n_nodes) = if n_dims == 2 { - let xn = build_xi_nodes(XiRule::GaussHermite { q_xi: 15 }, n_dims).unwrap(); - (xn.grid, xn.logw.len()) - } else { - let xn = build_xi_nodes( - XiRule::Halton { - n: 200, - shift_seed: 0, - }, - n_dims, - ) - .unwrap(); - (xn.grid, xn.logw.len()) - }; - let mut rng = Lcg(1414 + n_dims as u64); - let counts: Vec> = (0..n_nodes) - .map(|_| (0..n_cat).map(|_| 0.1 + rng.next_f64() * 3.0).collect()) - .collect(); - let mut params = vec![0.0f64; l + (n_cat - 1)]; - for t in 0..l { - params[t] = 0.4 + 0.3 * t as f64 - if t == 1 { 0.9 } else { 0.0 }; - } - // NON-MONOTONE steps - let steps = [0.8f64, -0.3, 1.1]; - for j in 0..(n_cat - 1) { - params[l + j] = steps[j]; - } - let (_f0, grad) = gpcm_item_neg_ll_grad(¶ms, dims, &nodes, n_dims, &counts, n_cat); - let eps = 1e-6; - for j in 0..params.len() { - let mut pp = params.clone(); - pp[j] += eps; - let (fp, _) = gpcm_item_neg_ll_grad(&pp, dims, &nodes, n_dims, &counts, n_cat); - let mut pm = params.clone(); - pm[j] -= eps; - let (fm, _) = gpcm_item_neg_ll_grad(&pm, dims, &nodes, n_dims, &counts, n_cat); - let fd = (fp - fm) / (2.0 * eps); - assert!( - (grad[j] - fd).abs() < 1e-4, - "grad[{j}] {} vs fd {fd} (D={n_dims})", - grad[j] - ); - } - } -} - -/// Deterministic OBJECTIVE-VALUE dims-map pin at D=4 (Halton, dims=[0,2,3]). Computes base with the -/// CORRECT dim map and gpcm_logprobs with LITERAL integer scores [0,1,2,3] and a literal 0.0 -/// baseline step, then matches the estimator's internal neg-loglik to < 1e-9. The FD anchor is -/// map-invariant AND scores-invariant; this is the only guard against a wrong-node-column, a -/// wrong-scores (e.g. [1,2,3,4]), or a dropped-baseline-step mutation on the QMC path. -#[test] -fn gpcm_objective_dims_map_pinned_at_d4() { - let n_dims = 4usize; - let dims = vec![0usize, 2, 3]; - let n_cat = 4usize; - let l = dims.len(); - let xn = build_xi_nodes( - XiRule::Halton { - n: 64, - shift_seed: 0, - }, - n_dims, - ) - .unwrap(); - let nodes = xn.grid; - let n_nodes = xn.logw.len(); - let mut rng = Lcg(27182); - let counts: Vec> = (0..n_nodes) - .map(|_| (0..n_cat).map(|_| 0.1 + rng.next_f64() * 2.0).collect()) - .collect(); - let a = [0.9f64, -0.6, 0.7]; - let step = [0.5f64, -0.8, 0.2]; // non-monotone - let mut params = vec![0.0f64; l + (n_cat - 1)]; - params[..l].copy_from_slice(&a); - params[l..].copy_from_slice(&step); - let (neg_ll, _g) = gpcm_item_neg_ll_grad(¶ms, &dims, &nodes, n_dims, &counts, n_cat); - let mut hand = 0.0f64; - for (nd, cnt) in counts.iter().enumerate() { - let base = a[0] * nodes[nd * n_dims + 0] - + a[1] * nodes[nd * n_dims + 2] - + a[2] * nodes[nd * n_dims + 3]; - let lp = gpcm_logprobs( - base, - &[0.0, 1.0, 2.0, 3.0], - &[0.0, step[0], step[1], step[2]], - ); - hand += cnt.iter().zip(&lp).map(|(r, l2)| r * l2).sum::(); - } - assert!( - (neg_ll - (-hand)).abs() < 1e-9, - "objective dims/scores map mismatch: {neg_ll} vs {}", - -hand - ); -} - -#[test] -fn gpcm_validation_sampling_rules_and_missing_paths() { - let base = GpcmConfig { - q: 7, - max_iter: 1, - newton_iter: 1, - ..GpcmConfig::default() - }; - let y = [0usize, 0, 1, 1, 0, 1, 1, 0]; - let observed = [true, false, true, true, true, true, true, true]; - let pattern = [1u8, 1]; - - assert!(validate(&y, None, &pattern, 0, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &pattern, 4, 2, 1, 1, &base).is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &GpcmConfig { - max_iter: 0, - ..base - } - ) - .is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &GpcmConfig { - tol: f64::NAN, - ..base - } - ) - .is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &GpcmConfig { ridge: 0.0, ..base } - ) - .is_err()); - assert!(validate(&y, None, &[], 4, 2, 0, 2, &base).is_err()); - assert!(validate(&y, None, &[1; 8], 4, 2, 4, 2, &base).is_err()); - assert!(validate(&y, None, &pattern, 4, 2, 1, 2, &GpcmConfig { q: 3, ..base }).is_err()); - let halton = GpcmConfig { - xi_rule: XiRuleKind::Halton, - xi_points: 4, - ..base - }; - assert!(validate(&y, None, &[], 4, 2, 0, 2, &halton).is_err()); - assert!(validate(&y, None, &[1; 14], 4, 2, 7, 2, &halton).is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &GpcmConfig { - xi_points: 0, - ..halton - }, - ) - .is_err()); - assert!(validate(&y[..7], None, &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, Some(&[true]), &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[1], 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[2, 1], 4, 2, 1, 2, &base).is_err()); - let bad_y = [2usize, 0, 1, 1, 0, 1, 1, 0]; - assert!(validate(&bad_y, None, &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[0, 1], 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, Some(&[false; 8]), &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&[0; 8], None, &pattern, 4, 2, 1, 2, &base).is_err()); - let cross = [1u8, 1, 1, 1]; - assert!(validate(&y, None, &cross, 4, 2, 2, 2, &base).is_err()); - assert!(validate( - &[], - None, - &[], - 1, - GP_MAX_NODES, - 1, - 2, - &GpcmConfig { - xi_rule: XiRuleKind::Halton, - xi_points: GP_MAX_NODES, - ..base - }, - ) - .unwrap_err() - .contains("count table")); - assert!(validate( - &[], - None, - &[], - 1, - usize::MAX, - 1, - 2, - &GpcmConfig { - xi_rule: XiRuleKind::Halton, - xi_points: GP_MAX_NODES, - ..base - }, - ) - .unwrap_err() - .contains("overflows usize")); - assert!(validate(&[], None, &[], usize::MAX, 2, 1, 2, &base,) - .unwrap_err() - .contains("n_persons * n_items")); - - for xi_rule in [XiRuleKind::Halton, XiRuleKind::MonteCarlo] { - let result = fit_gpcm( - &y, - Some(&observed), - &pattern, - 4, - 2, - 1, - 2, - &GpcmConfig { - xi_rule, - xi_points: 16, - xi_seed: 0, - ..base - }, - ) - .unwrap(); - assert_eq!(result.n_iter, 1); - assert_eq!(result.termination_reason, "max_iter_reached"); - assert!(result.loglik_trace.iter().all(|value| value.is_finite())); - assert!(result.theta.iter().all(|value| value.is_finite())); - } -} - -#[test] -fn gpcm_optimizer_and_em_diagnostics_cover_defensive_paths() { - let dims = [0usize]; - let nodes = [-2.0, 0.0, 2.0]; - let zero_counts = vec![vec![0.0; 3]; 3]; - let initial = vec![1.0, 0.0, 0.0]; - assert_eq!( - gpcm_m_step(initial.clone(), &dims, &nodes, 1, &zero_counts, 3, 0.1, 2), - initial - ); - - let separated_counts = vec![ - vec![1000.0, 0.0, 0.0], - vec![0.0, 1000.0, 0.0], - vec![0.0, 0.0, 1000.0], - ]; - let updated = gpcm_m_step( - vec![0.0, 0.0, 0.0], - &dims, - &nodes, - 1, - &separated_counts, - 3, - -1.0e6, - 2, - ); - assert!(updated.iter().all(|value| value.is_finite())); - - assert_eq!(checked_em_loglik_change(-10.0, None, 0).unwrap(), None); - assert_eq!( - checked_em_loglik_change(-9.5, Some(-10.0), 1).unwrap(), - Some(0.5) - ); - assert!(checked_em_loglik_change(f64::NAN, None, 2) - .unwrap_err() - .contains("non-finite")); - assert!(checked_em_loglik_change(-10.5, Some(-10.0), 3) - .unwrap_err() - .contains("decreased")); -} - -fn design_d2(n_cat: usize) -> (Vec, usize, Vec, Vec) { - let n_dims = 2usize; - let m1 = n_cat - 1; - let pattern: Vec = vec![1, 0, 1, 0, 0, 1, 0, 1, 1, 1]; - let n_items = 5usize; - let mut slope = vec![0.0f64; n_items * n_dims]; - slope[0 * n_dims + 0] = 1.4; - slope[1 * n_dims + 0] = 1.0; - slope[2 * n_dims + 1] = 1.2; - slope[3 * n_dims + 1] = 1.1; - slope[4 * n_dims + 0] = -1.0; // negative cross-loader (dim0 anchor item 0 positive) - slope[4 * n_dims + 1] = 0.9; - let mut step = vec![0.0f64; n_items * m1]; - for i in 0..n_items { - step[i * m1] = 0.5 + 0.05 * i as f64; // non-monotone across k - if m1 > 1 { - step[i * m1 + 1] = -0.4 + 0.03 * i as f64; - } - } - (pattern, n_items, slope, step) -} - -/// D = 2 recovery on GH nodes: pure anchors + a NEGATIVE cross-loader on dim0 (positively -/// anchored). Asserts slope recovery, STEP recovery (numeric — GPCM steps are unordered, no -/// ordering canary), per-dim EAP, finite steps, EM monotone. -#[test] -fn gpcm_recovers_d2_with_negative_cross_loader() { - let (n_dims, n_cat) = (2usize, 3usize); - let (pattern, n_items, slope, step) = design_d2(n_cat); - let n = 6000usize; - let mut rng = Lcg(3535); - let mut theta = vec![0.0f64; n * n_dims]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let y = simulate(&slope, &step, &theta, n, n_items, n_dims, n_cat, &mut rng); - let cfg = GpcmConfig { - q: 21, - ..GpcmConfig::default() - }; - let res = fit_gpcm(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - assert!(res.converged); - for i in 0..n_items { - for d in 0..n_dims { - if pattern[i * n_dims + d] == 0 { - assert_eq!(res.slope[i * n_dims + d], 0.0, "off-pattern zero"); - } - } - } - assert!(res.step.iter().all(|v| v.is_finite()), "finite steps"); - assert!(res.slope[0 * n_dims + 0] > 0.5, "anchor0 positive"); - assert!(res.slope[2 * n_dims + 1] > 0.5, "anchor2 positive"); - assert!( - res.slope[4 * n_dims + 0] < -0.4, - "neg cross-loader: {}", - res.slope[4 * n_dims + 0] - ); - assert!( - rmse(&res.slope, &slope) < 0.16, - "slope RMSE {}", - rmse(&res.slope, &slope) - ); - assert!( - rmse(&res.step, &step) < 0.16, - "step RMSE {}", - rmse(&res.step, &step) - ); - for d in 0..n_dims { - let th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| theta[j * n_dims + d]).collect(); - assert!(corr(&th, &tt) > 0.6, "theta{d} corr {}", corr(&th, &tt)); - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-9, "EM monotone"); - } -} - -/// The reflection canonicalization FIRES — and is WITNESSED by the raw EM mode landing on the -/// wrong side, so dropping the flip flips every assertion below (verified by mutation: disabling -/// the canonicalization block makes this test fail on all three sign checks). -/// -/// The witness depends on which mirror mode raw EM converges to. Init is `+1.0` on each item's -/// first loaded dim (see `fit_gpcm`), so the dim0 axis is oriented by its STRONGEST-|slope| -/// loader. Here that is a positively-keyed CROSS-loader (`item1`, true `+1.7`), NOT the pure -/// anchor: raw EM therefore orients theta_0 to the +item1 axis (its true orientation), and the -/// WEAK reverse-keyed pure anchor (`item0`, true `-0.7`) converges NATIVELY NEGATIVE. Because the -/// pure anchor is the sole pure dim0 item, canonicalization must FLIP dim0 to make it positive — -/// negating item0 to `+0.7`, item1's dim0 slope to `-1.7`, and theta_0 to `-theta_0`. If the flip -/// is removed, item0 stays `-0.7` (anchor check fails), item1 stays `+1.7` (co-loader check -/// fails), and theta_0 stays positively correlated with truth (theta check fails). The STEPS are -/// invariant under the joint (slope, theta) flip (GPCM steps are unordered — no ordering canary — -/// so a reflection bug that also negated the steps could only be caught by this value check). -#[test] -fn gpcm_reflection_fires_on_negative_anchor() { - let (n_dims, n_cat) = (2usize, 3usize); - let m1 = n_cat - 1; - // item0: WEAK reverse-keyed SOLE pure anchor on dim0 -> converges raw-NEGATIVE. - // item1: STRONG positively-keyed cross-loader on dim0 -> dominates the dim0 orientation, so - // raw EM does NOT land the anchor in the canonical (positive) mode on its own. - let pattern: Vec = vec![1, 0, 1, 1, 0, 1, 0, 1]; - let n_items = 4usize; - let mut slope = vec![0.0f64; n_items * n_dims]; - slope[0 * n_dims + 0] = -0.7; // weak reverse-keyed SOLE pure anchor on dim0 - slope[1 * n_dims + 0] = 1.7; // strong cross-loader, positively keyed on dim0 (sets the axis) - slope[1 * n_dims + 1] = 0.6; - slope[2 * n_dims + 1] = 1.2; // pure anchor on dim1 (positively keyed -> dim1 not flipped) - slope[3 * n_dims + 1] = 1.0; - // non-monotone steps (unordered) so a step-negating reflection bug is caught by the RMSE check - let mut step = vec![0.0f64; n_items * m1]; - for i in 0..n_items { - step[i * m1] = 0.6; - step[i * m1 + 1] = -0.5; - } - let n = 6000usize; - let mut rng = Lcg(6262); - let mut theta = vec![0.0f64; n * n_dims]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let y = simulate(&slope, &step, &theta, n, n_items, n_dims, n_cat, &mut rng); - let cfg = GpcmConfig { - q: 21, - ..GpcmConfig::default() - }; - let res = fit_gpcm(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - // canon FIRED: anchor flipped +, strong co-loader flipped -, theta_0 flipped (all three would - // fail with the flip removed, because raw EM lands the anchor negative / co-loader positive). - assert!( - res.slope[0 * n_dims + 0] > 0.3, - "reflected anchor positive: {}", - res.slope[0 * n_dims + 0] - ); - assert!( - res.slope[1 * n_dims + 0] < -0.5, - "co-loader flipped negative: {}", - res.slope[1 * n_dims + 0] - ); - // steps UNCHANGED by the reflection (recovered close to truth) — the unordered-step analogue - // of the GRM's ordering canary: a step-negating reflection bug would blow this up. - assert!( - rmse(&res.step, &step) < 0.15, - "steps preserved: RMSE {}", - rmse(&res.step, &step) - ); - // flipped dim0: EAP theta_0 correlates NEGATIVELY with truth; unflipped dim1 positive. - let th0: Vec = (0..n).map(|j| res.theta[j * n_dims + 0]).collect(); - let tt0: Vec = (0..n).map(|j| theta[j * n_dims + 0]).collect(); - let th1: Vec = (0..n).map(|j| res.theta[j * n_dims + 1]).collect(); - let tt1: Vec = (0..n).map(|j| theta[j * n_dims + 1]).collect(); - assert!( - corr(&th0, &tt0) < -0.5, - "flipped-dim theta corr negative: {}", - corr(&th0, &tt0) - ); - assert!( - corr(&th1, &tt1) > 0.5, - "unflipped-dim theta corr positive: {}", - corr(&th1, &tt1) - ); -} - -/// Structural invariants + validation guards (constructed non-vacuously — the intended guard is -/// the failing branch). -#[test] -fn gpcm_validates_and_structural_invariants() { - let (n_dims, n_cat) = (2usize, 3usize); - let (pattern, n_items, slope, step) = design_d2(n_cat); - let n = 500usize; - let mut rng = Lcg(88); - let mut theta = vec![0.0f64; n * n_dims]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let y = simulate(&slope, &step, &theta, n, n_items, n_dims, n_cat, &mut rng); - let cfg = GpcmConfig { - q: 15, - max_iter: 25, - ..GpcmConfig::default() - }; - let res = fit_gpcm(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - assert_eq!(res.n_parameters, 4 * (1 + 2) + (2 + 2)); - let lp = gpcm_logprobs(0.4, &[0.0, 1.0, 2.0], &[0.0, 0.6, -0.4]); - let s: f64 = lp.iter().map(|l| l.exp()).sum(); - assert!((s - 1.0).abs() < 1e-12); - // GH D=4 rejected (y4 observes every category so the D-bound is the sole reason) - let gh4 = GpcmConfig::default(); - let pat4: Vec = (0..4) - .flat_map(|d| (0..4).map(move |k| (k == d) as u8)) - .collect(); - let y4: Vec = (0..n * 4).map(|idx| idx % n_cat).collect(); - assert!( - fit_gpcm(&y4, None, &pat4, n, 4, 4, n_cat, &gh4).is_err(), - "GH D=4 rejected" - ); - // no pure anchor (3-item all-both pattern with the full 3-item y so the anchor guard fires) - let no_anchor: Vec = vec![1, 1, 1, 1, 1, 1]; - assert!( - fit_gpcm(&y, None, &no_anchor, n, n_items, n_dims, n_cat, &cfg).is_err(), - "no pure anchor rejected" - ); - let mut ybad = y.clone(); - ybad[0] = n_cat; - assert!( - fit_gpcm(&ybad, None, &pattern, n, n_items, n_dims, n_cat, &cfg).is_err(), - "bad category rejected" - ); - let mut ygap = y.clone(); - for p in 0..n { - if ygap[p * n_items + 0] == 1 { - ygap[p * n_items + 0] = 0; - } - } - assert!( - fit_gpcm(&ygap, None, &pattern, n, n_items, n_dims, n_cat, &cfg).is_err(), - "unobserved category rejected" - ); -} - -/// Literature-grade Monte-Carlo (>=500 reps): recover the multidimensional GPCM at D=2 and D=3 -/// under normal AND per-dim-standardized right-skew traits. Per-rep monotone-EM + STEP finiteness -/// canaries (a diverging step is GPCM's characteristic failure mode). -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_gpcm_recovery_500() { - let reps = 500usize; - let n_cat = 3usize; - let m1 = n_cat - 1; - for &(n_dims, q, n) in [(2usize, 15usize, 2500usize), (3usize, 11usize, 2000usize)].iter() { - let mut pattern: Vec = Vec::new(); - for d in 0..n_dims { - for _ in 0..2 { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - pattern.extend_from_slice(&r); - } - } - for d in 0..n_dims { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - r[(d + 1) % n_dims] = 1; - pattern.extend_from_slice(&r); - } - let n_items = 2 * n_dims + n_dims; - let mut slope = vec![0.0f64; n_items * n_dims]; - for d in 0..n_dims { - slope[(2 * d) * n_dims + d] = 1.3; - slope[(2 * d + 1) * n_dims + d] = 1.0; - } - for d in 0..n_dims { - let ci = 2 * n_dims + d; - slope[ci * n_dims + d] = 1.0; - slope[ci * n_dims + (d + 1) % n_dims] = if d % 2 == 0 { 0.7 } else { -0.7 }; - } - let mut step = vec![0.0f64; n_items * m1]; - for i in 0..n_items { - step[i * m1] = 0.6 + 0.03 * i as f64; - step[i * m1 + 1] = -0.5 + 0.02 * i as f64; - } - for &skew in [false, true].iter() { - let (mut lnum, mut lden, mut lbias) = (0.0f64, 0.0f64, 0.0f64); - let (mut snum, mut sden) = (0.0f64, 0.0f64); - let (mut csum, mut ccnt) = (0.0f64, 0.0f64); - let mut nconv = 0usize; - for rep in 0..reps { - let mut rng = Lcg(0x9E3779B97F4A7C15u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0xD1B54A32D192ED03) - .wrapping_add(n_dims as u64 * 0x100000001B3)); - let mut theta = vec![0.0f64; n * n_dims]; - for d in 0..n_dims { - let col: Vec = (0..n) - .map(|_| { - if skew { - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / 6f64.sqrt() - } else { - rng.normal() - } - }) - .collect(); - let m = col.iter().sum::() / n as f64; - let v = col.iter().map(|x| (x - m) * (x - m)).sum::() / n as f64; - let sd = v.sqrt(); - for j in 0..n { - theta[j * n_dims + d] = (col[j] - m) / sd; - } - } - let y = simulate(&slope, &step, &theta, n, n_items, n_dims, n_cat, &mut rng); - let cfg = GpcmConfig { - q, - ..GpcmConfig::default() - }; - let res = fit_gpcm(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - if res.converged { - nconv += 1; - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-9, "monotone (rep {rep})"); - } - assert!( - res.slope.iter().all(|v| v.is_finite()), - "finite slope (rep {rep})" - ); - assert!( - res.step.iter().all(|v| v.is_finite()), - "finite step (rep {rep})" - ); - for i in 0..n_items { - for d in 0..n_dims { - if pattern[i * n_dims + d] != 0 { - let e = res.slope[i * n_dims + d] - slope[i * n_dims + d]; - lnum += e * e; - lden += 1.0; - lbias += e; - } - } - } - for i in 0..n_items { - for j in 0..m1 { - let e = res.step[i * m1 + j] - step[i * m1 + j]; - snum += e * e; - sden += 1.0; - } - } - for d in 0..n_dims { - let th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| theta[j * n_dims + d]).collect(); - csum += corr(&th, &tt); - ccnt += 1.0; - } - } - let lrmse = (lnum / lden).sqrt(); - let srmse = (snum / sden).sqrt(); - let (lb, tc, conv) = (lbias / lden, csum / ccnt, nconv as f64 / reps as f64); - println!( - "[gpcm-mirt MC D={n_dims} q={q} N={n} skew={skew}] reps={reps} conv={conv:.3} \ - loadRMSE={lrmse:.4} loadBias={lb:.4} stepRMSE={srmse:.4} thetaCorr={tc:.3}" - ); - assert!(conv > 0.90, "convergence {conv} (D={n_dims} skew={skew})"); - if skew { - assert!(lrmse < 0.24, "skew load RMSE {lrmse} (D={n_dims})"); - assert!(tc > 0.55, "skew theta corr {tc} (D={n_dims})"); - } else { - assert!(lb.abs() < 0.06, "load bias {lb} (D={n_dims})"); - assert!(lrmse < 0.16, "load RMSE {lrmse} (D={n_dims})"); - assert!(srmse < 0.16, "step RMSE {srmse} (D={n_dims})"); - assert!(tc > 0.6, "theta corr {tc} (D={n_dims})"); - } - } - } -} diff --git a/tests/unit/grm_tests.rs b/tests/unit/grm_tests.rs deleted file mode 100644 index ac0058d92..000000000 --- a/tests/unit/grm_tests.rs +++ /dev/null @@ -1,759 +0,0 @@ -use super::*; -use crate::poly::{fit_poly_unidim, PolyModel}; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::() / a.len() as f64).sqrt() -} -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let (mx, my) = (x.iter().sum::() / n, y.iter().sum::() / n); - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for (a, b) in x.iter().zip(y) { - sxy += (a - mx) * (b - my); - sxx += (a - mx) * (a - mx); - syy += (b - my) * (b - my); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -/// Simulate multidimensional GRM responses from slope (n_items*n_dims), thresholds -/// (n_items*(n_cat-1)), and traits (n_persons*n_dims). -fn simulate( - slope: &[f64], - threshold: &[f64], - theta: &[f64], - n: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - rng: &mut Lcg, -) -> Vec { - let m1 = n_cat - 1; - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = 0.0f64; - for d in 0..n_dims { - base += slope[i * n_dims + d] * theta[p * n_dims + d]; - } - let lp = grm_logprobs(base, &threshold[i * m1..(i + 1) * m1]); - let probs: Vec = lp.iter().map(|l| l.exp()).collect(); - let u = rng.next_f64(); - let mut acc = 0.0; - let mut cat = n_cat - 1; - for (k, &pk) in probs.iter().enumerate() { - acc += pk; - if u < acc { - cat = k; - break; - } - } - y[p * n_items + i] = cat; - } - } - y -} - -/// D = 1 WITHIN-TOL reduction to fit_poly_unidim(GRM). True slopes are all POSITIVE (the domain -/// where fit_poly_unidim's log_a>0 is correctly specified); both fitters reach the same MLE up to -/// optimizer tolerance and the (positive) reflection, so recovered slope & thresholds & loglik -/// agree within a loose bound. NOT bit-exact (log_a vs unconstrained a differ in Newton path). -#[test] -fn grm_reduces_to_poly_grm_at_d1() { - let (n, n_items, n_cat) = (2000usize, 6usize, 4usize); - let m1 = n_cat - 1; - let mut rng = Lcg(51169); - let mut slope = vec![0.0f64; n_items * 1]; - let mut threshold = vec![0.0f64; n_items * m1]; - for i in 0..n_items { - slope[i] = 0.8 + 0.25 * i as f64; // POSITIVE - // strictly decreasing thresholds - for j in 0..m1 { - threshold[i * m1 + j] = 1.2 - 1.0 * j as f64 - 0.05 * i as f64; - } - } - let theta: Vec = (0..n).map(|_| rng.normal()).collect(); - let y = simulate(&slope, &threshold, &theta, n, n_items, 1, n_cat, &mut rng); - let pattern = vec![1u8; n_items]; - let cfg = GrmConfig { - q: 21, - ..GrmConfig::default() - }; - let mm = fit_grm(&y, None, &pattern, n, n_items, 1, n_cat, &cfg).unwrap(); - let pf = fit_poly_unidim(&y, None, n, n_items, n_cat, PolyModel::Grm, 21, 500, 1e-6).unwrap(); - // slopes agree (both positive), thresholds agree, within optimizer tolerance - for i in 0..n_items { - assert!( - (mm.slope[i] - pf.slope[i]).abs() < 0.05, - "slope[{i}] {} vs {}", - mm.slope[i], - pf.slope[i] - ); - for j in 0..m1 { - let d = (mm.threshold[i * m1 + j] - pf.cat_params[i][j]).abs(); - assert!(d < 0.06, "threshold[{i}][{j}] diff {d}"); - } - } - let mm_ll = *mm.loglik_trace.last().unwrap(); - assert!( - (mm_ll - pf.loglik).abs() < 0.5, - "loglik {mm_ll} vs {}", - pf.loglik - ); - assert_eq!(mm.n_parameters, n_items * (1 + m1)); -} - -/// Deterministic FD GRADIENT anchor at D=2 (GH) AND D=4 (Halton, NON-IDENTITY dims [0,2,3]) with -/// M=4 categories. The threshold block is STRICTLY DECREASING with gaps >> the FD eps (GRM NaNs on -/// inverted betas, unlike the finite-everywhere softmax); the slope block is distinct and the -/// per-category counts random+distinct, so a slope<->threshold slot transposition or a sign error -/// is detected. The M-step uses an FD Hessian, so pin the GRADIENT. -#[test] -fn grm_gradient_matches_finite_difference() { - let n_cat = 4usize; - for &(n_dims, ref dims) in [(2usize, vec![0usize, 1]), (4usize, vec![0usize, 2, 3])].iter() { - let l = dims.len(); - let (nodes, n_nodes) = if n_dims == 2 { - let xn = build_xi_nodes(XiRule::GaussHermite { q_xi: 15 }, n_dims).unwrap(); - (xn.grid, xn.logw.len()) - } else { - let xn = build_xi_nodes( - XiRule::Halton { - n: 200, - shift_seed: 0, - }, - n_dims, - ) - .unwrap(); - (xn.grid, xn.logw.len()) - }; - let mut rng = Lcg(2718 + n_dims as u64); - let counts: Vec> = (0..n_nodes) - .map(|_| (0..n_cat).map(|_| 0.1 + rng.next_f64() * 3.0).collect()) - .collect(); - // params: distinct slopes then STRICTLY DECREASING thresholds (gaps 0.7 >> eps). - let mut params = vec![0.0f64; l + (n_cat - 1)]; - for t in 0..l { - params[t] = 0.4 + 0.3 * t as f64 - if t == 1 { 0.9 } else { 0.0 }; - } - for j in 0..(n_cat - 1) { - params[l + j] = 1.0 - 0.7 * j as f64; // 1.0, 0.3, -0.4 (strictly decreasing) - } - let (_f0, grad) = grm_item_neg_ll_grad(¶ms, dims, &nodes, n_dims, &counts, n_cat); - let eps = 1e-6; - for j in 0..params.len() { - let mut pp = params.clone(); - pp[j] += eps; - let (fp, _) = grm_item_neg_ll_grad(&pp, dims, &nodes, n_dims, &counts, n_cat); - let mut pm = params.clone(); - pm[j] -= eps; - let (fm, _) = grm_item_neg_ll_grad(&pm, dims, &nodes, n_dims, &counts, n_cat); - let fd = (fp - fm) / (2.0 * eps); - assert!( - (grad[j] - fd).abs() < 1e-4, - "grad[{j}] {} vs fd {fd} (D={n_dims})", - grad[j] - ); - } - } -} - -/// Deterministic OBJECTIVE-VALUE dims-map pin at D=4 (Halton, dims=[0,2,3]). The FD gradient anchor -/// is map-INVARIANT (a consistent wrong-node-column bug in base+gradient is invisible to a central -/// difference through the same buggy objective); and no D>=4 fit is exercised by the recovery/MC -/// tests. So compute the objective's per-node base and neg-loglik BY HAND with the CORRECT dim map -/// and assert the estimator's internal value equals it to < 1e-9 — pinning nodes[nd*n_dims + dims[t]]. -#[test] -fn grm_objective_dims_map_pinned_at_d4() { - let n_dims = 4usize; - let dims = vec![0usize, 2, 3]; - let n_cat = 4usize; - let l = dims.len(); - let xn = build_xi_nodes( - XiRule::Halton { - n: 64, - shift_seed: 0, - }, - n_dims, - ) - .unwrap(); - let nodes = xn.grid; - let n_nodes = xn.logw.len(); - let mut rng = Lcg(31337); - let counts: Vec> = (0..n_nodes) - .map(|_| (0..n_cat).map(|_| 0.1 + rng.next_f64() * 2.0).collect()) - .collect(); - let a = [0.9f64, -0.6, 0.7]; - let beta = [0.8f64, 0.0, -0.9]; // strictly decreasing - let mut params = vec![0.0f64; l + (n_cat - 1)]; - params[..l].copy_from_slice(&a); - params[l..].copy_from_slice(&beta); - let (neg_ll, _g) = grm_item_neg_ll_grad(¶ms, &dims, &nodes, n_dims, &counts, n_cat); - // hand computation with the CORRECT dim map [0,2,3] - let mut hand = 0.0f64; - for (nd, cnt) in counts.iter().enumerate() { - let base = a[0] * nodes[nd * n_dims + 0] - + a[1] * nodes[nd * n_dims + 2] - + a[2] * nodes[nd * n_dims + 3]; - let lp = grm_logprobs(base, &beta); - hand += cnt.iter().zip(&lp).map(|(r, l2)| r * l2).sum::(); - } - assert!( - (neg_ll - (-hand)).abs() < 1e-9, - "objective dims-map mismatch: {neg_ll} vs {}", - -hand - ); -} - -// build a D=2 confirmatory GRM design (items 0,1 pure dim0; 2,3 pure dim1; item 4 cross-loader). -fn design_d2(n_cat: usize) -> (Vec, usize, Vec, Vec) { - let n_dims = 2usize; - let m1 = n_cat - 1; - let pattern: Vec = vec![1, 0, 1, 0, 0, 1, 0, 1, 1, 1]; - let n_items = 5usize; - let mut slope = vec![0.0f64; n_items * n_dims]; - slope[0 * n_dims + 0] = 1.4; - slope[1 * n_dims + 0] = 1.0; - slope[2 * n_dims + 1] = 1.2; - slope[3 * n_dims + 1] = 1.1; - slope[4 * n_dims + 0] = -1.0; // NEGATIVE cross-loader on dim0 (anchor item 0 is positive) - slope[4 * n_dims + 1] = 0.9; - let mut threshold = vec![0.0f64; n_items * m1]; - for i in 0..n_items { - for j in 0..m1 { - threshold[i * m1 + j] = 1.1 - 1.0 * j as f64 + 0.05 * i as f64; - } - } - (pattern, n_items, slope, threshold) -} - -/// D = 2 recovery on GH nodes: pure anchors + a NEGATIVE cross-loader on dimension 0 (whose pure -/// anchor is positively keyed, so canonicalization preserves the cross-loader's sign). Recovered -/// thresholds must stay STRICTLY ordered on every item. Baseline structural checks + per-dim EAP. -#[test] -fn grm_recovers_d2_with_negative_cross_loader() { - let (n_dims, n_cat) = (2usize, 3usize); - let m1 = n_cat - 1; - let (pattern, n_items, slope, threshold) = design_d2(n_cat); - let n = 6000usize; - let mut rng = Lcg(4747); - let mut theta = vec![0.0f64; n * n_dims]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let y = simulate( - &slope, &threshold, &theta, n, n_items, n_dims, n_cat, &mut rng, - ); - let cfg = GrmConfig { - q: 21, - ..GrmConfig::default() - }; - let res = fit_grm(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - assert!(res.converged); - // off-pattern slopes EXACTLY zero - for i in 0..n_items { - for d in 0..n_dims { - if pattern[i * n_dims + d] == 0 { - assert_eq!(res.slope[i * n_dims + d], 0.0, "off-pattern zero"); - } - } - } - // recovered thresholds strictly ordered-decreasing on EVERY item - for i in 0..n_items { - for j in 0..m1 - 1 { - assert!( - res.threshold[i * m1 + j] > res.threshold[i * m1 + j + 1], - "ordered item {i}" - ); - } - } - // canonical output: pure anchors positive; the negative cross-loader recovered NEGATIVE - assert!(res.slope[0 * n_dims + 0] > 0.5, "anchor0 positive"); - assert!(res.slope[2 * n_dims + 1] > 0.5, "anchor2 positive"); - assert!( - res.slope[4 * n_dims + 0] < -0.4, - "neg cross-loader: {}", - res.slope[4 * n_dims + 0] - ); - assert!( - rmse(&res.slope, &slope) < 0.16, - "slope RMSE {}", - rmse(&res.slope, &slope) - ); - for d in 0..n_dims { - let th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| theta[j * n_dims + d]).collect(); - assert!(corr(&th, &tt) > 0.6, "theta{d} corr {}", corr(&th, &tt)); - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-9, "EM monotone"); - } -} - -/// The baked-in reflection canonicalization actually FIRES: a reverse-keyed LARGEST pure anchor on -/// dimension 0 (true slope strongly NEGATIVE) is flipped so it ends POSITIVE, a positively-keyed -/// co-loader on the same dimension ends NEGATIVE (whole-dimension flip), and the thresholds are -/// UNCHANGED and still ordered (the flip touches only slopes + theta, never betas). -#[test] -fn grm_reflection_fires_on_negative_anchor() { - let (n_dims, n_cat) = (2usize, 3usize); - let m1 = n_cat - 1; - // item0 pure dim0 (largest, NEGATIVE), item1 pure dim0 (positive), items 2,3 pure dim1. - let pattern: Vec = vec![1, 0, 1, 0, 0, 1, 0, 1]; - let n_items = 4usize; - let mut slope = vec![0.0f64; n_items * n_dims]; - slope[0 * n_dims + 0] = -1.8; // reverse-keyed largest anchor on dim0 - slope[1 * n_dims + 0] = 1.0; // positively-keyed co-loader on dim0 - slope[2 * n_dims + 1] = 1.2; - slope[3 * n_dims + 1] = 1.0; - let mut threshold = vec![0.0f64; n_items * m1]; - for i in 0..n_items { - for j in 0..m1 { - threshold[i * m1 + j] = 0.9 - 1.0 * j as f64; - } - } - let n = 4000usize; - let mut rng = Lcg(8181); - let mut theta = vec![0.0f64; n * n_dims]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let y = simulate( - &slope, &threshold, &theta, n, n_items, n_dims, n_cat, &mut rng, - ); - let cfg = GrmConfig { - q: 21, - ..GrmConfig::default() - }; - let res = fit_grm(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - // dim0's largest pure anchor (item 0) ends POSITIVE; co-loader (item 1) ends NEGATIVE. - assert!( - res.slope[0 * n_dims + 0] > 0.8, - "reflected anchor positive: {}", - res.slope[0 * n_dims + 0] - ); - assert!( - res.slope[1 * n_dims + 0] < -0.3, - "co-loader flipped negative: {}", - res.slope[1 * n_dims + 0] - ); - // The reflection flips BOTH the slope column AND theta_d, keeping base = sum a_d theta_d - // invariant. Since dim0 was flipped, the returned EAP theta_0 must correlate NEGATIVELY with - // the true theta_0 (the data was generated with the negative anchor); dim1 (not flipped) stays - // positive. Deleting the theta-negation half of the reflection inverts dim0's sign here. - let th0: Vec = (0..n).map(|j| res.theta[j * n_dims + 0]).collect(); - let tt0: Vec = (0..n).map(|j| theta[j * n_dims + 0]).collect(); - let th1: Vec = (0..n).map(|j| res.theta[j * n_dims + 1]).collect(); - let tt1: Vec = (0..n).map(|j| theta[j * n_dims + 1]).collect(); - assert!( - corr(&th0, &tt0) < -0.5, - "flipped-dim theta corr must be negative: {}", - corr(&th0, &tt0) - ); - assert!( - corr(&th1, &tt1) > 0.5, - "unflipped-dim theta corr positive: {}", - corr(&th1, &tt1) - ); - // thresholds still strictly ordered (untouched by the reflection) - for i in 0..n_items { - for j in 0..m1 - 1 { - assert!( - res.threshold[i * m1 + j] > res.threshold[i * m1 + j + 1], - "ordered item {i}" - ); - } - } -} - -/// Structural invariants + validation guards. -#[test] -fn grm_validates_and_structural_invariants() { - let (n_dims, n_cat) = (2usize, 3usize); - let (pattern, n_items, slope, threshold) = design_d2(n_cat); - let n = 500usize; - let mut rng = Lcg(99); - let mut theta = vec![0.0f64; n * n_dims]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let y = simulate( - &slope, &threshold, &theta, n, n_items, n_dims, n_cat, &mut rng, - ); - let cfg = GrmConfig { - q: 15, - max_iter: 25, - ..GrmConfig::default() - }; - let res = fit_grm(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - // free-parameter count = sum_i (|S_i| + (n_cat-1)): items 0-3 pure (1+2), item 4 cross (2+2). - assert_eq!(res.n_parameters, 4 * (1 + 2) + (2 + 2)); - // grm_logprobs sum to 1 at a sample base - let lp = grm_logprobs(0.4, &[0.8, -0.3]); - let s: f64 = lp.iter().map(|l| l.exp()).sum(); - assert!((s - 1.0).abs() < 1e-12); - // validation: GH D=4 rejected (y observes all categories so the D-bound is the sole reason); - // no pure anchor rejected; category >= n_cat rejected; unobserved category rejected. - let gh4 = GrmConfig::default(); - let pat4: Vec = (0..4) - .flat_map(|d| (0..4).map(move |k| (k == d) as u8)) - .collect(); - let y4: Vec = (0..n * 4).map(|idx| idx % n_cat).collect(); - assert!( - fit_grm(&y4, None, &pat4, n, 4, 4, n_cat, &gh4).is_err(), - "GH D=4 rejected" - ); - let no_anchor: Vec = vec![1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; - assert!( - fit_grm(&y, None, &no_anchor, n, n_items, n_dims, n_cat, &cfg).is_err(), - "no pure anchor rejected" - ); - let mut ybad = y.clone(); - ybad[0] = n_cat; - assert!( - fit_grm(&ybad, None, &pattern, n, n_items, n_dims, n_cat, &cfg).is_err(), - "bad category rejected" - ); - let mut ygap = y.clone(); - for p in 0..n { - if ygap[p * n_items + 0] == 1 { - ygap[p * n_items + 0] = 0; - } - } - assert!( - fit_grm(&ygap, None, &pattern, n, n_items, n_dims, n_cat, &cfg).is_err(), - "unobserved category rejected" - ); -} - -/// Literature-grade Monte-Carlo (>=500 reps): recover the multidimensional GRM at D=2 and D=3 -/// under normal AND per-dim-standardized right-skew traits. The estimator canonicalizes reflection -/// (pure anchors positive), so truth is built positive-anchored and the estimate compares directly. -/// Per-rep monotone-EM + finiteness + threshold-ordering canaries. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_grm_recovery_500() { - let reps = 500usize; - let n_cat = 3usize; - let m1 = n_cat - 1; - for &(n_dims, q, n) in [(2usize, 15usize, 2500usize), (3usize, 11usize, 2000usize)].iter() { - let mut pattern: Vec = Vec::new(); - for d in 0..n_dims { - for _ in 0..2 { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - pattern.extend_from_slice(&r); - } - } - for d in 0..n_dims { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - r[(d + 1) % n_dims] = 1; - pattern.extend_from_slice(&r); - } - let n_items = 2 * n_dims + n_dims; - let mut slope = vec![0.0f64; n_items * n_dims]; - for d in 0..n_dims { - slope[(2 * d) * n_dims + d] = 1.3; // pure anchors POSITIVE - slope[(2 * d + 1) * n_dims + d] = 1.0; - } - for d in 0..n_dims { - let ci = 2 * n_dims + d; - slope[ci * n_dims + d] = 1.0; - slope[ci * n_dims + (d + 1) % n_dims] = if d % 2 == 0 { 0.7 } else { -0.7 }; - } - let mut threshold = vec![0.0f64; n_items * m1]; - for i in 0..n_items { - for j in 0..m1 { - threshold[i * m1 + j] = 1.0 - 1.2 * j as f64 + 0.04 * i as f64; - } - } - for &skew in [false, true].iter() { - let (mut lnum, mut lden, mut lbias) = (0.0f64, 0.0f64, 0.0f64); - let (mut tnum, mut tden) = (0.0f64, 0.0f64); - let (mut csum, mut ccnt) = (0.0f64, 0.0f64); - let mut nconv = 0usize; - for rep in 0..reps { - let mut rng = Lcg(0x9E3779B97F4A7C15u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0xD1B54A32D192ED03) - .wrapping_add(n_dims as u64 * 0x100000001B3)); - let mut theta = vec![0.0f64; n * n_dims]; - for d in 0..n_dims { - let col: Vec = (0..n) - .map(|_| { - if skew { - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / 6f64.sqrt() - } else { - rng.normal() - } - }) - .collect(); - let m = col.iter().sum::() / n as f64; - let v = col.iter().map(|x| (x - m) * (x - m)).sum::() / n as f64; - let sd = v.sqrt(); - for j in 0..n { - theta[j * n_dims + d] = (col[j] - m) / sd; - } - } - let y = simulate( - &slope, &threshold, &theta, n, n_items, n_dims, n_cat, &mut rng, - ); - let cfg = GrmConfig { - q, - ..GrmConfig::default() - }; - let res = fit_grm(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - if res.converged { - nconv += 1; - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-9, "monotone (rep {rep})"); - } - assert!( - res.slope.iter().all(|v| v.is_finite()), - "finite slope (rep {rep})" - ); - for i in 0..n_items { - for j in 0..m1 - 1 { - assert!( - res.threshold[i * m1 + j] > res.threshold[i * m1 + j + 1], - "ordered (rep {rep} item {i})" - ); - } - } - for i in 0..n_items { - for d in 0..n_dims { - if pattern[i * n_dims + d] != 0 { - let e = res.slope[i * n_dims + d] - slope[i * n_dims + d]; - lnum += e * e; - lden += 1.0; - lbias += e; - } - } - } - for i in 0..n_items { - for j in 0..m1 { - let e = res.threshold[i * m1 + j] - threshold[i * m1 + j]; - tnum += e * e; - tden += 1.0; - } - } - for d in 0..n_dims { - let th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| theta[j * n_dims + d]).collect(); - csum += corr(&th, &tt); - ccnt += 1.0; - } - } - let lrmse = (lnum / lden).sqrt(); - let trmse = (tnum / tden).sqrt(); - let (lb, tc, conv) = (lbias / lden, csum / ccnt, nconv as f64 / reps as f64); - println!( - "[grm MC D={n_dims} q={q} N={n} skew={skew}] reps={reps} conv={conv:.3} \ - loadRMSE={lrmse:.4} loadBias={lb:.4} threshRMSE={trmse:.4} thetaCorr={tc:.3}" - ); - assert!(conv > 0.90, "convergence {conv} (D={n_dims} skew={skew})"); - if skew { - assert!(lrmse < 0.24, "skew load RMSE {lrmse} (D={n_dims})"); - assert!(tc > 0.55, "skew theta corr {tc} (D={n_dims})"); - } else { - assert!(lb.abs() < 0.06, "load bias {lb} (D={n_dims})"); - assert!(lrmse < 0.16, "load RMSE {lrmse} (D={n_dims})"); - assert!(trmse < 0.16, "threshold RMSE {trmse} (D={n_dims})"); - assert!(tc > 0.6, "theta corr {tc} (D={n_dims})"); - } - } - } -} -#[test] -fn grm_validation_sampling_rules_and_missing_paths() { - let base = GrmConfig { - q: 7, - max_iter: 1, - newton_iter: 1, - ..GrmConfig::default() - }; - let y = [0usize, 0, 1, 1, 0, 1, 1, 0]; - let observed = [true, false, true, true, true, true, true, true]; - let pattern = [1u8, 1]; - - assert!(validate(&y, None, &pattern, 0, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &pattern, 4, 2, 1, 1, &base).is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &GrmConfig { - max_iter: 0, - ..base - } - ) - .is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &GrmConfig { - tol: f64::NAN, - ..base - } - ) - .is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &GrmConfig { ridge: 0.0, ..base } - ) - .is_err()); - assert!(validate(&y, None, &[], 4, 2, 0, 2, &base).is_err()); - assert!(validate(&y, None, &[1; 8], 4, 2, 4, 2, &base).is_err()); - assert!(validate(&y, None, &pattern, 4, 2, 1, 2, &GrmConfig { q: 3, ..base }).is_err()); - let halton = GrmConfig { - xi_rule: XiRuleKind::Halton, - xi_points: 4, - ..base - }; - assert!(validate(&y, None, &[], 4, 2, 0, 2, &halton).is_err()); - assert!(validate(&y, None, &[1; 14], 4, 2, 7, 2, &halton).is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &GrmConfig { - xi_points: 0, - ..halton - }, - ) - .is_err()); - assert!(validate(&y[..7], None, &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, Some(&[true]), &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[1], 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[2, 1], 4, 2, 1, 2, &base).is_err()); - let bad_y = [2usize, 0, 1, 1, 0, 1, 1, 0]; - assert!(validate(&bad_y, None, &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[0, 1], 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, Some(&[false; 8]), &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&[0; 8], None, &pattern, 4, 2, 1, 2, &base).is_err()); - let cross = [1u8, 1, 1, 1]; - assert!(validate(&y, None, &cross, 4, 2, 2, 2, &base).is_err()); - for (n_items, xi_points, expected) in [ - (GM_MAX_NODES, GM_MAX_NODES, "count table"), - (usize::MAX, GM_MAX_NODES, "overflows usize"), - ] { - assert!(validate( - &[], - None, - &[], - 1, - n_items, - 1, - 2, - &GrmConfig { - xi_rule: XiRuleKind::Halton, - xi_points, - ..base - }, - ) - .unwrap_err() - .contains(expected)); - } - assert!(validate(&[], None, &[], usize::MAX, 2, 1, 2, &base) - .unwrap_err() - .contains("n_persons * n_items")); - - for xi_rule in [XiRuleKind::Halton, XiRuleKind::MonteCarlo] { - let result = fit_grm( - &y, - Some(&observed), - &pattern, - 4, - 2, - 1, - 2, - &GrmConfig { - xi_rule, - xi_points: 16, - xi_seed: 0, - ..base - }, - ) - .unwrap(); - assert_eq!(result.n_iter, 1); - assert_eq!(result.termination_reason, "max_iter_reached"); - assert!(result.loglik_trace.iter().all(|value| value.is_finite())); - assert!(result.theta.iter().all(|value| value.is_finite())); - } -} - -#[test] -fn grm_optimizer_and_em_diagnostics_cover_defensive_paths() { - let dims = [0usize]; - let nodes = [-2.0, 0.0, 2.0]; - let zero_counts = vec![vec![0.0; 3]; 3]; - let initial = vec![1.0, 1.0, -1.0]; - assert_eq!( - grm_m_step(initial.clone(), &dims, &nodes, 1, &zero_counts, 3, 0.1, 2), - initial - ); - let separated_counts = vec![ - vec![1000.0, 0.0, 0.0], - vec![0.0, 1000.0, 0.0], - vec![0.0, 0.0, 1000.0], - ]; - let updated = grm_m_step( - vec![0.0, 1.0, -1.0], - &dims, - &nodes, - 1, - &separated_counts, - 3, - -1.0e6, - 2, - ); - assert!(updated.iter().all(|value| value.is_finite())); - assert_eq!(checked_em_loglik_change(-10.0, None, 0).unwrap(), None); - assert_eq!( - checked_em_loglik_change(-9.5, Some(-10.0), 1).unwrap(), - Some(0.5) - ); - assert!(checked_em_loglik_change(f64::NAN, None, 2).is_err()); - assert!(checked_em_loglik_change(-10.5, Some(-10.0), 3).is_err()); -} diff --git a/tests/unit/gtheory_tests.rs b/tests/unit/gtheory_tests.rs deleted file mode 100644 index 0885ecab5..000000000 --- a/tests/unit/gtheory_tests.rs +++ /dev/null @@ -1,381 +0,0 @@ -//! Tests for the generalizability-theory module (`gtheory.rs`). -//! -//! Every assertion reads values RETURNED BY THE CRATE (`gtheory_pi` / -//! `gtheory_pio` outputs); no assert recomputes the formula locally. -//! -//! Fixture provenance: -//! - `pi_dat` and `pio_cross_dat`: the data appendix of Huebner & Lucht -//! (2019), with expected values pinned from an independent NumPy -//! implementation of the same ANOVA/EMS pipeline, which reproduces the -//! paper's published Tables 3-6 to their printed rounding. -//! - Independent fixtures: generated with numpy `default_rng(20260723)`; -//! expected literals pinned from the same independent NumPy pipeline. -//! -//! Mutation kills. M1, M3, M5, M6 were each ACTUALLY applied to -//! src/gtheory.rs, observed to FAIL the named tests, and reverted -//! (2026-07-23). M2 and M4 are documented expectations only (same code -//! sites as M1/M3, killed by the same fixtures; not separately executed): -//! - M1 (EXECUTED) swap person/item EMS divisors in `gtheory_pi` -//! (`/fi` <-> `/fp`): `gt_pi_paper_fixture`, `gt_pi_independent_fixture`, -//! and `gt_pi_negative_variance_clamped` FAIL. -//! - M2 (documented) drop the `- ms_pi` subtraction in `gtheory_pi` var_p: -//! var_p becomes 2.225 -> `gt_pi_paper_fixture` fails. -//! - M3 (EXECUTED) flip the `+ ms[6]` sign in the `gtheory_pio` var_p -//! inversion: `gt_pio_paper_fixture` and `gt_pio_independent_fixture` FAIL. -//! - M4 (documented) drop the `s_io/(n_i' n_o')` term from the absolute -//! error: Delta(1,1) becomes 5.3319 vs 5.3542 -> `gt_pio_paper_fixture` -//! fails. -//! - M5 (EXECUTED) drop the clamp (`var = var_raw`) in `gtheory_pi`: -//! `gt_pi_negative_variance_clamped` FAILS (var == var_raw identity and -//! sign-sensitive D-study outputs). -//! - M6 (EXECUTED) transpose the pio layout (swap `i`/`o` strides): the -//! 5x4x3 independent fixture has n_i=4 != n_o=3 -> -//! `gt_pio_independent_fixture` and `gt_pio_paper_fixture` FAIL. -//! -//! Weak identity checks (disclosed, NOT counted as kills): `n' = 1` D-study -//! columns (divisor identity point), DF values, and sigma^2(p) invariance -//! across n'. - -use super::{gtheory_pi, gtheory_pio}; - -const TOL: f64 = 1e-9; - -fn assert_close(got: f64, want: f64, tol: f64, what: &str) { - assert!((got - want).abs() <= tol, "{what}: got {got}, want {want}"); -} - -/// Huebner & Lucht (2019) appendix `pi_dat` (6 persons x 4 items). -fn pi_dat() -> Vec { - vec![ - 9., 9., 7., 4., 9., 8., 4., 6., 8., 8., 6., 2., 9., 8., 6., 3., 10., 9., 8., 7., 6., 4., - 5., 1., - ] -} - -/// Huebner & Lucht (2019) appendix `pio_cross_dat` (6 x 4 x 2), re-laid out -/// to the crate layout x[p*n_i*n_o + i*n_o + o]. -fn pio_cross_dat() -> Vec { - vec![ - 9., 9., 9., 8., 7., 5., 4., 5., 9., 6., 8., 5., 4., 3., 6., 3., 8., 8., 8., 7., 6., 3., 2., - 2., 9., 9., 8., 6., 6., 6., 3., 2., 10., 8., 9., 8., 8., 9., 7., 7., 6., 3., 4., 2., 5., - 3., 1., 2., - ] -} - -/// Paper fixture: reproduces Tables 3 and 4 of Huebner & Lucht (2019). -/// Asserts read: ss, ms, var_raw, var, d_study rows from the crate result. -/// Kills M1, M2 (see header). -#[test] -fn gt_pi_paper_fixture() { - let r = gtheory_pi(&pi_dat(), 6, 4, &[4, 10]).unwrap(); - // ANOVA table (paper Table 3, pinned at full precision). - assert_eq!(r.df, [5.0, 3.0, 15.0]); - assert_close(r.ss[0], 44.5, TOL, "ss_p"); - assert_close(r.ss[1], 76.333333333333, 1e-9, "ss_i"); - assert_close(r.ss[2], 19.166666666667, 1e-9, "ss_pi"); - assert_close(r.ms[0], 8.9, TOL, "ms_p"); - assert_close(r.ms[1], 25.444444444444, 1e-9, "ms_i"); - assert_close(r.ms[2], 1.277777777778, 1e-9, "ms_pi"); - // Variance components (paper: 1.91 / 4.03 / 1.28). - assert_close(r.var_raw[0], 1.905555555556, 1e-9, "var_p"); - assert_close(r.var_raw[1], 4.027777777778, 1e-9, "var_i"); - assert_close(r.var_raw[2], 1.277777777778, 1e-9, "var_pi"); - // All components positive here: clamped == raw. - assert_eq!(r.var, r.var_raw); - // D study (paper Table 4 columns n_i' = 4 and 10). - let d4 = &r.d_study[0]; - assert_eq!((d4.n_i_prime, d4.n_o_prime), (4, 1)); - assert_close(d4.rel_error_var, 0.319444444444, 1e-9, "delta(4)"); - assert_close(d4.abs_error_var, 1.326388888889, 1e-9, "Delta(4)"); - assert_close(d4.generalizability, 0.856429463171, 1e-9, "Erho2(4)"); - assert_close(d4.dependability, 0.589600343790, 1e-9, "Phi(4)"); - let d10 = &r.d_study[1]; - assert_close(d10.rel_error_var, 0.127777777778, 1e-9, "delta(10)"); - assert_close(d10.abs_error_var, 0.530555555556, 1e-9, "Delta(10)"); - assert_close(d10.generalizability, 0.937158469945, 1e-9, "Erho2(10)"); - assert_close(d10.dependability, 0.782212086659, 1e-9, "Phi(10)"); -} - -/// Paper fixture: reproduces Tables 5 and 6 of Huebner & Lucht (2019). -/// Asserts read: ss, ms, var_raw, d_study rows from the crate result. -/// Kills M3, M4 (see header). -#[test] -fn gt_pio_paper_fixture() { - let r = gtheory_pio(&pio_cross_dat(), 6, 4, 2, &[(4, 2), (1, 1), (5, 3)]).unwrap(); - assert_eq!(r.df, [5.0, 3.0, 1.0, 15.0, 5.0, 3.0, 15.0]); - let want_ss = [ - 112.9375, - 117.895833333333, - 15.1875, - 35.479166666667, - 5.9375, - 2.895833333333, - 12.479166666667, - ]; - let want_ms = [ - 22.5875, - 39.298611111111, - 15.1875, - 2.365277777778, - 1.1875, - 0.965277777778, - 0.831944444444, - ]; - // Paper Table 5: 2.48 / 3.07 / 0.58 / 0.77 / 0.09 / 0.02 / 0.83. - let want_var = [ - 2.483333333333, - 3.066666666667, - 0.577777777778, - 0.766666666667, - 0.088888888889, - 0.022222222222, - 0.831944444444, - ]; - for k in 0..7 { - assert_close(r.ss[k], want_ss[k], 1e-9, "pio ss"); - assert_close(r.ms[k], want_ms[k], 1e-9, "pio ms"); - assert_close(r.var_raw[k], want_var[k], 1e-9, "pio var"); - } - assert_eq!(r.var, r.var_raw); - // Paper Table 6 columns (n_i', n_o') = (4,2), (1,1), (5,3). - let want_d = [ - // rel, abs, Erho2, Phi - [0.340104166667, 1.3984375, 0.879542519830, 0.639742385617], - [1.6875, 5.354166666667, 0.595404595405, 0.316852737905], - [ - 0.238425925926, - 1.045833333333, - 0.912400068039, - 0.703659976387, - ], - ]; - for (row, want) in r.d_study.iter().zip(want_d.iter()) { - assert_close(row.rel_error_var, want[0], 1e-9, "pio delta"); - assert_close(row.abs_error_var, want[1], 1e-9, "pio Delta"); - assert_close(row.generalizability, want[2], 1e-9, "pio Erho2"); - assert_close(row.dependability, want[3], 1e-9, "pio Phi"); - } - assert_eq!((r.d_study[2].n_i_prime, r.d_study[2].n_o_prime), (5, 3)); -} - -/// Independent 7x5 fixture (numpy default_rng(20260723); literals pinned -/// from an independent NumPy pipeline). n_p=7 != n_i=5 keeps the divisor -/// asymmetry live at a size not used by the paper fixture. -#[test] -fn gt_pi_independent_fixture() { - #[rustfmt::skip] - let x = vec![ - 50.1, 39.0, 41.1, 56.9, 52.3, - 52.4, 52.5, 41.8, 46.3, 37.6, - 59.9, 43.2, 52.7, 47.0, 54.5, - 34.1, 52.4, 45.1, 54.1, 32.9, - 59.7, 46.7, 57.7, 57.9, 45.5, - 54.5, 47.6, 61.2, 55.2, 45.6, - 49.8, 32.8, 69.3, 60.1, 57.7, - ]; - let r = gtheory_pi(&x, 7, 5, &[8]).unwrap(); - assert_close(r.ss[0], 484.0, 1e-8, "ss_p"); - assert_close(r.ss[1], 439.2845714286, 1e-8, "ss_i"); - assert_close(r.ss[2], 1623.3514285714, 1e-8, "ss_pi"); - assert_close(r.ms[0], 80.6666666667, 1e-8, "ms_p"); - assert_close(r.ms[1], 109.8211428571, 1e-8, "ms_i"); - assert_close(r.ms[2], 67.6396428571, 1e-8, "ms_pi"); - assert_close(r.var_raw[0], 2.6054047619, 1e-8, "var_p"); - assert_close(r.var_raw[1], 6.0259285714, 1e-8, "var_i"); - let d = &r.d_study[0]; - assert_close(d.rel_error_var, 8.4549553571, 1e-8, "delta(8)"); - assert_close(d.abs_error_var, 9.2081964286, 1e-8, "Delta(8)"); - assert_close(d.generalizability, 0.2355623808, 1e-8, "Erho2(8)"); - assert_close(d.dependability, 0.2205428065, 1e-8, "Phi(8)"); -} - -/// Negative-variance anchor for the clamped-ANOVA policy (module docs): -/// item means are nearly equal but the interaction is large, so the raw -/// sigma^2_i and sigma^2_p are negative. Asserts read var_raw, var, and the -/// D-study row from the crate result. Kills M5. -#[test] -fn gt_pi_negative_variance_clamped() { - let x = vec![5., 1., 4., 2., 6., 3., 7., 3., 8., 1., 5., 2.]; - let r = gtheory_pi(&x, 4, 3, &[3]).unwrap(); - assert_close(r.var_raw[0], -0.083333333333, 1e-9, "raw var_p < 0"); - assert_close(r.var_raw[1], -1.555555555556, 1e-9, "raw var_i < 0"); - assert!(r.var_raw[0] < 0.0 && r.var_raw[1] < 0.0); - assert_eq!(r.var[0], 0.0); - assert_eq!(r.var[1], 0.0); - assert_close(r.var[2], 6.555555555556, 1e-9, "var_pi"); - let d = &r.d_study[0]; - // Clamped policy: delta = Delta = var_pi/3 (clamped var_i contributes 0). - // Clamped var_p = 0 with a POSITIVE error variance gives Erho2 = Phi = 0 - // (zero universe-score variance), not NaN — the NaN rule only fires when - // the whole denominator is <= 1e-12. - assert_close(d.rel_error_var, 6.555555555556 / 3.0, 1e-9, "delta"); - assert_close(d.abs_error_var, 6.555555555556 / 3.0, 1e-9, "Delta"); - assert_eq!(d.generalizability, 0.0, "Erho2 = 0 at zero var_p"); - assert_eq!(d.dependability, 0.0, "Phi = 0 at zero var_p"); -} - -/// Independent 5x4x3 fixture (numpy default_rng(20260723); literals pinned -/// from an independent NumPy pipeline). All facet sizes distinct -/// (n_p=5, n_i=4, n_o=3) so any axis/stride mix-up shifts every output. -/// Raw sigma^2_pi is negative here, so this doubles as the pio clamp -/// anchor. Kills M5, M6. -#[test] -fn gt_pio_independent_fixture() { - #[rustfmt::skip] - let x = vec![ - 19.3, 17.2, 16.0, 15.9, 18.0, 17.8, 21.4, 22.4, 12.0, 18.1, 17.1, 14.3, - 19.8, 19.6, 18.5, 21.4, 21.4, 17.8, 23.3, 26.0, 17.8, 22.2, 22.2, 21.1, - 15.8, 17.7, 14.5, 15.4, 19.6, 19.7, 21.8, 14.8, 20.3, 18.9, 10.8, 18.6, - 12.0, 16.1, 17.3, 25.4, 15.3, 16.7, 25.5, 25.5, 20.1, 21.3, 21.7, 23.2, - 16.5, 6.9, 16.1, 16.2, 15.7, 17.6, 22.8, 15.3, 16.4, 17.7, 14.4, 14.9, - ]; - let r = gtheory_pio(&x, 5, 4, 3, &[(6, 2)]).unwrap(); - let want_ss = [ - 208.164, - 128.8205, - 45.6333333333, - 82.792, - 95.475, - 56.448, - 213.077, - ]; - let want_ms = [ - 52.041, - 42.9401666667, - 22.8166666667, - 6.8993333333, - 11.934375, - 9.408, - 8.8782083333, - ]; - let want_var = [ - 3.507125, - 2.3674027778, - 0.517625, - -0.659625, - 0.7640416667, - 0.1059583333, - 8.8782083333, - ]; - for k in 0..7 { - assert_close(r.ss[k], want_ss[k], 1e-8, "ind pio ss"); - assert_close(r.ms[k], want_ms[k], 1e-8, "ind pio ms"); - assert_close(r.var_raw[k], want_var[k], 1e-8, "ind pio var_raw"); - } - // pi component is clamped; the rest pass through. - assert!(r.var_raw[3] < 0.0); - assert_eq!(r.var[3], 0.0); - assert_close(r.var[0], 3.507125, 1e-8, "clamped var_p"); - // D study at (6, 2), computed from CLAMPED components. - let d = &r.d_study[0]; - assert_close(d.rel_error_var, 1.1218715278, 1e-8, "ind delta"); - assert_close(d.abs_error_var, 1.7840810185, 1e-8, "ind Delta"); - assert_close(d.generalizability, 0.7576426076, 1e-8, "ind Erho2"); - assert_close(d.dependability, 0.6628214792, 1e-8, "ind Phi"); -} - -#[test] -fn gt_error_paths() { - // Too few levels. - assert!(gtheory_pi(&[1., 2.], 1, 2, &[1]).is_err()); - assert!(gtheory_pi(&[1., 2.], 2, 1, &[1]).is_err()); - assert!(gtheory_pio(&[0.0; 4], 1, 2, 2, &[(1, 1)]).is_err()); - // Length mismatch. - assert!(gtheory_pi(&[1., 2., 3.], 2, 2, &[1]).is_err()); - assert!(gtheory_pio(&[0.0; 7], 2, 2, 2, &[(1, 1)]).is_err()); - // Non-finite input. - assert!(gtheory_pi(&[1., 2., f64::NAN, 4.], 2, 2, &[1]).is_err()); - // Zero proposed size. - assert!(gtheory_pi(&[1., 2., 3., 5.], 2, 2, &[0]).is_err()); - assert!(gtheory_pio(&[0.0; 8], 2, 2, 2, &[(1, 0)]).is_err()); -} - -/// 500-rep Monte Carlo: simulate the fully random p x i model with known -/// components and check that the mean raw ANOVA estimates are close to the -/// truth (the estimators are unbiased for this model). All quantities under -/// test come from `gtheory_pi` outputs. -#[test] -#[ignore] -fn gt_pi_mc_recovery_500() { - // xorshift-style LCG + Box-Muller, matching the crate's test convention - // of dependency-free generators. - let mut state: u64 = 0x9E3779B97F4A7C15; - let mut next_u = || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut next_normal = || { - let (u1, u2): (f64, f64) = (next_u().max(1e-12), next_u()); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let (n_p, n_i) = (60usize, 12usize); - let (sp, si, spi) = (1.5f64, 0.8f64, 0.6f64); // true variances - let reps = 500; - let mut sums = [0.0f64; 3]; - for _ in 0..reps { - let mut x = vec![0.0; n_p * n_i]; - let pe: Vec = (0..n_p).map(|_| next_normal() * sp.sqrt()).collect(); - let ie: Vec = (0..n_i).map(|_| next_normal() * si.sqrt()).collect(); - for p in 0..n_p { - for i in 0..n_i { - x[p * n_i + i] = 10.0 + pe[p] + ie[i] + next_normal() * spi.sqrt(); - } - } - let r = gtheory_pi(&x, n_p, n_i, &[1]).unwrap(); - for k in 0..3 { - sums[k] += r.var_raw[k]; - } - } - let means = sums.map(|s| s / reps as f64); - // Unbiasedness: generous MC tolerances at 500 reps. - assert!( - (means[0] - sp).abs() < 0.10, - "var_p bias: {}", - means[0] - sp - ); - assert!( - (means[1] - si).abs() < 0.12, - "var_i bias: {}", - means[1] - si - ); - assert!( - (means[2] - spi).abs() < 0.02, - "var_pi bias: {}", - means[2] - spi - ); -} - -/// Regression: impl-review found the old `ss_pio = ss_total - ...` -/// subtraction form returns a NEGATIVE residual SS from catastrophic -/// cancellation on large-offset additive data (`x = 1e12 + p + i + o`, -/// true pio interaction = 0; observed ss[6] = -0.037 before the fix). -/// Asserts read the crate ss/var outputs; kills reintroduction of the -/// subtraction form. -#[test] -fn gt_pio_large_offset_no_cancellation() { - let (n_p, n_i, n_o) = (5usize, 4usize, 3usize); - let mut x = vec![0.0; n_p * n_i * n_o]; - for p in 0..n_p { - for i in 0..n_i { - for o in 0..n_o { - x[p * n_i * n_o + i * n_o + o] = 1e12 + p as f64 + i as f64 + o as f64; - } - } - } - let r = gtheory_pio(&x, n_p, n_i, n_o, &[(4, 3)]).unwrap(); - assert!( - r.ss[6] >= 0.0 && r.ss[6] < 1e-3, - "ss_pio must be ~0 and non-negative, got {}", - r.ss[6] - ); - // Interaction components are exactly-zero in truth; clamped var stays 0. - for k in 3..7 { - assert!( - r.var[k].abs() < 1e-3, - "interaction var[{k}] should be ~0, got {}", - r.var[k] - ); - } -} diff --git a/tests/unit/ksirt_tests.rs b/tests/unit/ksirt_tests.rs deleted file mode 100644 index a761ad962..000000000 --- a/tests/unit/ksirt_tests.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Tests for kernel-smoothing nonparametric IRT (`mlsirm_core::ksirt`). -//! -//! Every assert reads values returned by the crate (`KsirtResult` fields). -//! Each test names the crate value it reads and a mutant it kills. -//! -//! Known unkillable mutations (documented per the test discipline): all -//! three supported kernels are symmetric, so flipping the sign of the -//! Nadaraya-Watson argument `(grid - theta)/h` is an identity; likewise a -//! pure multiplicative kernel constant cancels in the NW normalization. -//! No anchor can exist for either — this is a property of the model, not a -//! test gap. - -use super::{ksirt, KsirtKernel}; - -/// qnorm anchors (R `qnorm`, 16 digits) used as independent fixture -/// constants so expected values never route through the crate's quantile. -const QN_1_5: f64 = -0.8416212335729143; // Phi^-1(0.2) -const QN_2_5: f64 = -0.2533471031357997; // Phi^-1(0.4) -const QN_3_5: f64 = 0.2533471031357997; // Phi^-1(0.6) -const QN_4_5: f64 = 0.8416212335729143; // Phi^-1(0.8) - -/// n=4, k=1 binary fixture: subjects 1 and 3 score 1, subjects 0 and 2 -/// score 0. Totals [0,1,0,1] -> first-occurrence ranks [1,3,2,4] -> -/// theta [Phi^-1(.2), Phi^-1(.6), Phi^-1(.4), Phi^-1(.8)]. -fn fixture_theta() -> [f64; 4] { - [QN_1_5, QN_3_5, QN_2_5, QN_4_5] -} - -fn fixture_x() -> Vec> { - vec![vec![0.0], vec![1.0], vec![0.0], vec![1.0]] -} - -/// Reads: `items[0].occ` (both option rows, all grid points). -/// Expected values recomputed here from hard-coded qnorm literals and the -/// Gaussian NW formula (never from crate outputs), so the assert compares -/// the crate curve to an independent hand derivation. -/// Kills: subject/theta index misalignment (swapping two subjects' theta), -/// wrong weight denominator, gaussian<->quadratic kernel swap, and rank -/// mis-ordering (theta enters the expected values). -#[test] -fn hand_fixture_occ_exact() { - let res = ksirt(&fixture_x(), KsirtKernel::Gaussian, 3, Some(&[0.5])).unwrap(); - let theta = fixture_theta(); - let grid = [QN_1_5, 0.0, QN_4_5]; - for (s, &g) in grid.iter().enumerate() { - let kw: Vec = theta - .iter() - .map(|&t| (-0.5 * ((g - t) / 0.5).powi(2)).exp()) - .collect(); - let denom: f64 = kw.iter().sum(); - let exp_p1 = (kw[1] + kw[3]) / denom; // subjects with score 1 - assert!( - (res.items[0].occ[1][s] - exp_p1).abs() < 1e-9, - "occ[1][{s}] = {} expected {exp_p1}", - res.items[0].occ[1][s] - ); - assert!((res.items[0].occ[0][s] - (1.0 - exp_p1)).abs() < 1e-9); - } - // asymmetry anchor: the curve is not flat, low grid point is low - assert!(res.items[0].occ[1][0] < 0.10); - assert!(res.items[0].occ[1][2] > 0.90); -} - -/// Reads: `result.theta`. -/// Kills: `ties.method="first"` violations (tied totals must rank in -/// subject order), and the n+1 -> n denominator mutation (rank 4 of 4 -/// would hit Phi^-1(1.0) = infinity instead of Phi^-1(0.8)). -#[test] -fn theta_rank_ties_first() { - // totals: [2, 5, 2, 7] -> ties between subjects 0 and 2 broken by - // original order: ranks [1, 3, 2, 4] - let x = vec![ - vec![1.0, 1.0, 0.0], - vec![2.0, 2.0, 1.0], - vec![0.0, 1.0, 1.0], - vec![3.0, 3.0, 1.0], - ]; - let res = ksirt(&x, KsirtKernel::Gaussian, 5, None).unwrap(); - let expected = [QN_1_5, QN_3_5, QN_2_5, QN_4_5]; - for i in 0..4 { - assert!( - (res.theta[i] - expected[i]).abs() < 1e-8, - "theta[{i}] = {} expected {}", - res.theta[i], - expected[i] - ); - } -} - -/// Reads: `result.grid`. -/// Kills: wrong endpoints (1/(n+1) vs 1/n), wrong point count, and -/// non-uniform spacing mutations. -#[test] -fn grid_endpoints_and_spacing() { - let x = fixture_x(); - let res = ksirt(&x, KsirtKernel::Gaussian, 5, None).unwrap(); - assert_eq!(res.grid.len(), 5); - assert!((res.grid[0] - QN_1_5).abs() < 1e-8); - assert!((res.grid[4] - QN_4_5).abs() < 1e-8); - let step = (QN_4_5 - QN_1_5) / 4.0; - for s in 1..5 { - // 1e-8: the crate grid endpoints come from the Acklam quantile - // (|error| < 1.15e-9), which propagates into the spacing. - assert!((res.grid[s] - res.grid[s - 1] - step).abs() < 1e-8); - } -} - -/// Reads: `result.bandwidth`. -/// Kills: mutations of the Silverman constant (1.06) or exponent (-1/5), -/// verified against 1.06 * 100^(-0.2) = 0.4219936007867... computed by hand. -#[test] -fn silverman_bandwidth_value() { - let mut x = Vec::new(); - for i in 0..100 { - x.push(vec![(i % 2) as f64, ((i / 2) % 2) as f64]); - } - let res = ksirt(&x, KsirtKernel::Gaussian, 11, None).unwrap(); - let expected = 1.06 * 100f64.powf(-0.2); - assert!((expected - 0.4219936007867).abs() < 1e-9); // pin the hand value - assert_eq!(res.bandwidth.len(), 2); - for &h in &res.bandwidth { - assert!((h - expected).abs() < 1e-12); - } -} - -/// Reads: `items[*].occ` column sums. -/// Weak near-identity on its own (documented); paired with the exact-value -/// tests above. Still kills: per-option denominators (normalizing each -/// option row separately would break cross-option coherence when combined -/// with `hand_fixture_occ_exact`), and dropped options (a missing row makes -/// sums fall short of 1). -#[test] -fn occ_rows_sum_to_one() { - let x = vec![ - vec![0.0, 2.0], - vec![1.0, 0.0], - vec![2.0, 1.0], - vec![1.0, 2.0], - vec![2.0, 2.0], - vec![0.0, 0.0], - ]; - let res = ksirt(&x, KsirtKernel::Gaussian, 7, None).unwrap(); - for item in &res.items { - assert_eq!(item.occ.len(), 3); - for s in 0..7 { - let sum: f64 = item.occ.iter().map(|row| row[s]).sum(); - assert!((sum - 1.0).abs() < 1e-12, "column {s} sums to {sum}"); - } - } -} - -/// Deterministic xorshift for simulation without external deps. -struct Rng(u64); -impl Rng { - fn new(seed: u64) -> Self { - Rng(seed.max(1)) - } - fn next_f64(&mut self) -> f64 { - let mut x = self.0; - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - self.0 = x; - (x >> 11) as f64 / (1u64 << 53) as f64 - } - fn next_normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos() - } -} - -/// Simulate binary 2PL responses: P(X=1) = logistic(a*(theta - b)). -fn simulate_2pl(rng: &mut Rng, n: usize, a: &[f64], b: &[f64], skew: bool) -> Vec> { - let k = a.len(); - let mut x = Vec::with_capacity(n); - for _ in 0..n { - let z = rng.next_normal(); - // shifted lognormal with mean approx 0 for the skewed condition - let th = if skew { (0.5 * z).exp() - 1.1331 } else { z }; - let mut row = Vec::with_capacity(k); - for j in 0..k { - let p = 1.0 / (1.0 + (-a[j] * (th - b[j])).exp()); - row.push(if rng.next_f64() < p { 1.0 } else { 0.0 }); - } - x.push(row); - } - x -} - -/// Reads: `result.expected_total`. -/// Kills: theta/rank inversion (descending ranks would flip the curve) and -/// weight-matrix transposition (grid and subject axes swapped destroys the -/// monotone trend). -#[test] -fn expected_score_monotone_recovery() { - let mut rng = Rng::new(20260213); - let a = [1.2, 0.8, 1.5, 1.0, 0.9, 1.3, 1.1, 0.7, 1.4, 1.0]; - let b = [-1.0, -0.5, 0.0, 0.5, 1.0, -0.8, 0.3, 0.8, -0.2, 0.1]; - let x = simulate_2pl(&mut rng, 300, &a, &b, false); - let res = ksirt(&x, KsirtKernel::Gaussian, 51, None).unwrap(); - let q = res.expected_total.len(); - // top decile of grid clearly above bottom decile - assert!( - res.expected_total[q - 3] > res.expected_total[2] + 2.0, - "expected total not increasing: low {} high {}", - res.expected_total[2], - res.expected_total[q - 3] - ); - // and grid itself is ascending (guards a reversed-grid mutant) - assert!(res.grid[q - 1] > res.grid[0]); -} - -/// Reads: `items[0].occ` under a tiny-bandwidth uniform kernel. -/// n=2: theta = [Phi^-1(1/3), Phi^-1(2/3)], grid (q=3) endpoints coincide -/// with the two thetas. With h=0.01 only the co-located subject is in -/// support at each endpoint, and no subject is in support at the middle. -/// Kills: removal of the zero-denominator fallback (0/0 -> NaN would fail -/// the middle-point zero assertions) and support-window widening. -/// Documented limit: the boundary mutation `|u| <= 1` -> `|u| < 1` is NOT -/// killed here (u=0 at the co-located point passes both); no fixture can -/// place a subject exactly on the support edge with irrational thetas. -#[test] -fn uniform_kernel_support() { - let x = vec![vec![0.0], vec![1.0]]; - let res = ksirt(&x, KsirtKernel::Uniform, 3, Some(&[0.01])).unwrap(); - // endpoint 0: only subject 0 (score 0) in support - assert!((res.items[0].occ[0][0] - 1.0).abs() < 1e-12); - assert!(res.items[0].occ[1][0].abs() < 1e-12); - // middle: nobody in support -> all-zero fallback, finite - assert!(res.items[0].occ[0][1].abs() < 1e-12); - assert!(res.items[0].occ[1][1].abs() < 1e-12); - assert!(res.items[0].occ[0][1].is_finite()); - // endpoint 2: only subject 1 (score 1) - assert!((res.items[0].occ[1][2] - 1.0).abs() < 1e-12); -} - -/// Reads: `items[0].occ` under the quadratic kernel, h=1.5. -/// Expected values recomputed from qnorm literals with (1-u^2) truncation; -/// subject 3 falls outside the support at the low endpoint (|u| > 1), so a -/// gaussian<->quadratic swap or a dropped support check changes the value. -/// Kills: kernel dispatch mutations and support-truncation removal. -#[test] -fn quadratic_kernel_exact() { - let res = ksirt(&fixture_x(), KsirtKernel::Quadratic, 3, Some(&[1.5])).unwrap(); - let theta = fixture_theta(); - let g = QN_1_5; // low endpoint - let kw: Vec = theta - .iter() - .map(|&t| { - let u = (g - t) / 1.5; - if u.abs() <= 1.0 { - 1.0 - u * u - } else { - 0.0 - } - }) - .collect(); - assert!(kw[3] == 0.0, "fixture must exercise the out-of-support branch"); - let denom: f64 = kw.iter().sum(); - let expected = (kw[1] + kw[3]) / denom; - assert!( - (res.items[0].occ[1][0] - expected).abs() < 1e-9, - "occ[1][0] = {} expected {expected}", - res.items[0].occ[1][0] - ); -} - -/// Reads: `items[*].expected` and `result.expected_total`. -/// Kills: option-score/probability pairing mutations (expected uses the -/// sorted option scores against their own occ rows) and per-item summation -/// errors in the total. -#[test] -fn expected_score_matches_occ_combination() { - let x = vec![ - vec![0.0, 2.0], - vec![1.0, 0.0], - vec![2.0, 1.0], - vec![1.0, 2.0], - vec![2.0, 2.0], - vec![0.0, 0.0], - ]; - let res = ksirt(&x, KsirtKernel::Gaussian, 7, None).unwrap(); - for s in 0..7 { - let mut total = 0.0; - for item in &res.items { - let mut e = 0.0; - for (l, &opt) in item.options.iter().enumerate() { - e += opt * item.occ[l][s]; - } - assert!( - (item.expected[s] - e).abs() < 1e-12, - "expected[{s}] mismatch" - ); - total += e; - } - assert!((res.expected_total[s] - total).abs() < 1e-12); - } -} - -/// Reads: the `Result` error branch for each documented rejection. -/// Kills: removal of any input validation guard. -#[test] -fn input_rejection() { - let ok = fixture_x(); - assert!(ksirt(&ok[..1], KsirtKernel::Gaussian, 3, None).is_err()); // n < 2 - assert!(ksirt(&[vec![], vec![]], KsirtKernel::Gaussian, 3, None).is_err()); // k = 0 - assert!( - ksirt(&[vec![1.0], vec![1.0, 2.0]], KsirtKernel::Gaussian, 3, None).is_err(), - "ragged" - ); - assert!( - ksirt(&[vec![f64::NAN], vec![1.0]], KsirtKernel::Gaussian, 3, None).is_err(), - "NaN" - ); - assert!(ksirt(&ok, KsirtKernel::Gaussian, 1, None).is_err()); // q < 2 - assert!(ksirt(&ok, KsirtKernel::Gaussian, 3, Some(&[0.5, 0.5])).is_err()); // len - assert!(ksirt(&ok, KsirtKernel::Gaussian, 3, Some(&[0.0])).is_err()); // h <= 0 - assert!(ksirt(&ok, KsirtKernel::Gaussian, 3, Some(&[f64::NAN])).is_err()); -} - -/// Monte Carlo ICC recovery, 500 replications, normal and skewed abilities. -/// Reads: `items[*].occ` (the score-1 row) against the true 2PL ICC on the -/// central grid, averaged over items and replications. Because theta is -/// rank-based on the normal metric, the skewed condition's oracle is the -/// ICC composed with the monotone generating map t(g) = exp(g/2) - 1.1331 -/// (rank invariance: the estimate depends on abilities only through their -/// ranks, so it recovers P(X=1 | z = g) with z the normal driver). -/// Kills: gross formula errors (wrong theta metric, broken smoothing) that -/// the deterministic fixtures could miss at scale. -#[test] -#[ignore = "500-replication Monte Carlo; run explicitly"] -fn mc_2pl_recovery_500() { - let a: Vec = (0..20).map(|j| 0.7 + 0.05 * j as f64).collect(); - let b: Vec = (0..20).map(|j| -1.5 + 0.15 * j as f64).collect(); - for &skew in &[false, true] { - let mut sum_rmse = 0.0; - let mut reps = 0usize; - for rep in 0..500u64 { - let mut rng = Rng::new(7_000_003 * (rep + 1) + skew as u64); - let x = simulate_2pl(&mut rng, 500, &a, &b, skew); - let res = ksirt(&x, KsirtKernel::Gaussian, 51, None).unwrap(); - let mut se_sum = 0.0; - let mut cnt = 0usize; - for (j, item) in res.items.iter().enumerate() { - // score-1 row (options sorted ascending: [0,1]) - let row = &item.occ[item.options.len() - 1]; - for (s, &g) in res.grid.iter().enumerate() { - if g.abs() <= 1.5 { - // effective ability at normal-metric grid point g - let t = if skew { (0.5 * g).exp() - 1.1331 } else { g }; - let p = 1.0 / (1.0 + (-a[j] * (t - b[j])).exp()); - se_sum += (row[s] - p).powi(2); - cnt += 1; - } - } - } - sum_rmse += (se_sum / cnt as f64).sqrt(); - reps += 1; - } - let avg_rmse = sum_rmse / reps as f64; - assert!( - avg_rmse < 0.06, - "avg ICC RMSE {avg_rmse} (skew={skew}) exceeds 0.06" - ); - } -} diff --git a/tests/unit/lib_additional_tests.rs b/tests/unit/lib_additional_tests.rs deleted file mode 100644 index 4cfbe858a..000000000 --- a/tests/unit/lib_additional_tests.rs +++ /dev/null @@ -1,54 +0,0 @@ -use super::*; - -#[test] -fn test_mask_and_mirt() { - let params = Params { - theta: vec![0.0], - alpha: vec![0.0], - b: vec![0.0], - xi: vec![0.0], - zeta: vec![0.0], - tau: 0.0, - }; - let config = ModelConfig { - n_persons: 1, - n_items: 1, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-12, - model_type: ModelType::Mirt, - }; - let penalty = PenaltyConfig { - lambda_theta: 0.0, - lambda_b: 0.0, - lambda_alpha: 0.0, - lambda_xi: 0.0, - lambda_zeta: 0.0, - lambda_tau: 0.0, - mu_alpha: 0.0, - mu_tau: 0.0, - }; - let y = vec![1.0]; - let mask = vec![false]; - let (obj, _, _) = neg_loglik_and_grad(&y, Some(&mask), &[0], ¶ms, &config, &penalty); - assert_eq!(obj, 0.0); - - let mask_true = vec![true]; - let (obj_mirt, _, _) = - neg_loglik_and_grad(&y, Some(&mask_true), &[0], ¶ms, &config, &penalty); - assert!(obj_mirt > 0.0); -} - -#[test] -fn checked_size_arithmetic_reports_overflow() { - assert_eq!(checked_mul_usize(6, 7, "mul overflow"), Ok(42)); - assert_eq!( - checked_mul_usize(usize::MAX, 2, "mul overflow"), - Err("mul overflow".to_owned()) - ); - assert_eq!(checked_add_usize(40, 2, "add overflow"), Ok(42)); - assert_eq!( - checked_add_usize(usize::MAX, 1, "add overflow"), - Err("add overflow".to_owned()) - ); -} diff --git a/tests/unit/lib_tests.rs b/tests/unit/lib_tests.rs deleted file mode 100644 index be026abfb..000000000 --- a/tests/unit/lib_tests.rs +++ /dev/null @@ -1,233 +0,0 @@ -use super::*; - -fn config() -> ModelConfig { - ModelConfig { - n_persons: 2, - n_items: 2, - n_dims: 1, - latent_dim: 2, - model_type: ModelType::Mls2plm, - eps_distance: 1e-8, - } -} - -fn params() -> Params { - Params { - theta: vec![0.2, -0.4], - alpha: vec![0.1, -0.2], - b: vec![0.3, -0.1], - xi: vec![0.1, 0.2, -0.2, 0.4], - zeta: vec![0.0, -0.1, 0.3, -0.4], - tau: 0.2, - } -} - -#[test] -fn single_item_matches_manual_nll() { - let cfg = ModelConfig { - n_persons: 1, - n_items: 1, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Mls2plm, - eps_distance: 1e-8, - }; - let p = Params { - theta: vec![0.5], - alpha: vec![0.0], - b: vec![0.1], - xi: vec![0.2], - zeta: vec![-0.3], - tau: 0.0, - }; - let penalty = PenaltyConfig { - lambda_theta: 0.0, - lambda_xi: 0.0, - lambda_zeta: 0.0, - lambda_b: 0.0, - lambda_alpha: 0.0, - lambda_tau: 0.0, - mu_alpha: 0.0, - mu_tau: 0.0, - }; - let (got, _, _) = neg_loglik_and_grad(&[1.0], None, &[0], &p, &cfg, &penalty); - let r = ((0.2_f64 - -0.3_f64).powi(2) + 1e-8).sqrt(); - let eta = 0.5 + 0.1 - r; - let expected = softplus(eta) - eta; - assert!((got - expected).abs() < 1e-12); -} - -#[test] -fn gradient_matches_finite_difference_for_tau() { - let cfg = config(); - let p = params(); - let penalty = PenaltyConfig::default(); - let y = vec![1.0, 0.0, 0.0, 1.0]; - let (base, grad, _) = neg_loglik_and_grad(&y, None, &[0, 0], &p, &cfg, &penalty); - - let h = 1e-6; - let mut plus = p.clone(); - plus.tau += h; - let (obj_plus, _, _) = neg_loglik_and_grad(&y, None, &[0, 0], &plus, &cfg, &penalty); - let finite_diff = (obj_plus - base) / h; - assert!((finite_diff - grad.tau).abs() < 1e-5); -} - -#[test] -fn mask_excludes_entries() { - let cfg = config(); - let p = params(); - let penalty = PenaltyConfig::default(); - let y = vec![1.0, 0.0, 0.0, 1.0]; - let mask = vec![true, true, true, false]; - - let (objective, grad, loglik) = - neg_loglik_and_grad(&y, Some(&mask), &[0, 0], &p, &cfg, &penalty); - - assert!(objective.is_finite()); - assert!(loglik.is_finite()); - assert_eq!(grad.theta.len(), cfg.n_persons * cfg.n_dims); -} - -#[test] -fn device_parse_accepts_known_names() { - assert_eq!(Device::parse("cpu"), Some(Device::Cpu)); - assert_eq!(Device::parse("GPU"), Some(Device::Gpu)); - assert_eq!(Device::parse(" Auto "), Some(Device::Auto)); - assert_eq!(Device::parse("cuda"), None); -} - -#[test] -fn device_auto_matches_cpu_on_fallback() { - // On machines/CI without a GPU adapter, Auto/Gpu must fall back to the - // CPU path and reproduce it bit-for-bit. When a GPU is present the f32 - // kernels are exercised instead and this asserts close (not exact) - // agreement, which is the guarantee we make for the GPGPU path. - let cfg = config(); - let p = params(); - let penalty = PenaltyConfig::default(); - let y = vec![1.0, 0.0, 0.0, 1.0]; - let mask = vec![true, true, true, false]; - - let (cpu_obj, cpu_grad, cpu_ll) = - neg_loglik_and_grad_device(Device::Cpu, &y, Some(&mask), &[0, 0], &p, &cfg, &penalty); - for device in [Device::Auto, Device::Gpu] { - let (obj, grad, ll) = - neg_loglik_and_grad_device(device, &y, Some(&mask), &[0, 0], &p, &cfg, &penalty); - assert!( - (obj - cpu_obj).abs() < 1e-4, - "objective mismatch for {device:?}" - ); - assert!((ll - cpu_ll).abs() < 1e-4, "loglik mismatch for {device:?}"); - assert!((grad.tau - cpu_grad.tau).abs() < 1e-4); - for (a, b) in grad.theta.iter().zip(&cpu_grad.theta) { - assert!((a - b).abs() < 1e-4); - } - for (a, b) in grad.xi.iter().zip(&cpu_grad.xi) { - assert!((a - b).abs() < 1e-4); - } - for (a, b) in grad.zeta.iter().zip(&cpu_grad.zeta) { - assert!((a - b).abs() < 1e-4); - } - } -} - -#[test] -fn device_gpu_handles_absent_mask() { - // Exercises the `mask: None` host-side path (dense all-observed matrix) - // through the device entry point. On a GPU-equipped host this drives the - // GPGPU kernels with the `None` mask branch; on a GPU-less host it is the - // CPU fallback. Either way the device result must match the CPU reference - // computed the same way. - let cfg = config(); - let p = params(); - let penalty = PenaltyConfig::default(); - let y = vec![1.0, 0.0, 0.0, 1.0]; - - let (cpu_obj, cpu_grad, cpu_ll) = - neg_loglik_and_grad_device(Device::Cpu, &y, None, &[0, 0], &p, &cfg, &penalty); - for device in [Device::Auto, Device::Gpu] { - let (obj, grad, ll) = - neg_loglik_and_grad_device(device, &y, None, &[0, 0], &p, &cfg, &penalty); - assert!( - (obj - cpu_obj).abs() < 1e-4, - "objective mismatch for {device:?}" - ); - assert!((ll - cpu_ll).abs() < 1e-4, "loglik mismatch for {device:?}"); - assert!((grad.tau - cpu_grad.tau).abs() < 1e-4); - assert_eq!(grad.b.len(), cpu_grad.b.len()); - } -} - -#[cfg(all(feature = "gpu", not(coverage)))] -#[test] -fn finish_device_prefers_gpu_result_when_present() { - // When the GPU adapter produced a result, `finish_device` must return it - // verbatim without invoking the CPU fallback, for both Gpu and Auto. - let cfg = config(); - let p = params(); - let penalty = PenaltyConfig::default(); - let y = vec![1.0, 0.0, 0.0, 1.0]; - let sentinel = Gradients { - theta: vec![1.0, 2.0], - alpha: vec![3.0, 4.0], - b: vec![5.0, 6.0], - xi: vec![7.0, 8.0, 9.0, 10.0], - zeta: vec![11.0, 12.0, 13.0, 14.0], - tau: 42.0, - }; - for device in [Device::Gpu, Device::Auto] { - let (obj, grad, ll) = finish_device( - device, - Some((123.0, sentinel.clone(), -7.0)), - &y, - None, - &[0, 0], - &p, - &cfg, - &penalty, - ); - assert_eq!(obj, 123.0); - assert_eq!(ll, -7.0); - assert_eq!(grad.tau, 42.0); - assert_eq!(grad.b, vec![5.0, 6.0]); - } -} - -#[cfg(all(feature = "gpu", not(coverage)))] -#[test] -fn finish_device_falls_back_to_cpu_when_gpu_absent() { - // When the GPU produced no result, `finish_device` must reproduce the CPU - // reference. `Device::Gpu` additionally emits the fallback warning while - // `Device::Auto` stays silent; both must return the CPU numbers. - let cfg = config(); - let p = params(); - let penalty = PenaltyConfig::default(); - let y = vec![1.0, 0.0, 0.0, 1.0]; - let mask = vec![true, true, true, false]; - let expected = neg_loglik_and_grad(&y, Some(&mask), &[0, 0], &p, &cfg, &penalty); - for device in [Device::Gpu, Device::Auto] { - let (obj, grad, ll) = - finish_device(device, None, &y, Some(&mask), &[0, 0], &p, &cfg, &penalty); - assert_eq!(obj, expected.0); - assert_eq!(ll, expected.2); - assert_eq!(grad.tau, expected.1.tau); - } -} - -#[test] -fn mirt_ignores_latent_space_terms() { - let mut cfg = config(); - cfg.model_type = ModelType::Mirt; - let p = params(); - let penalty = PenaltyConfig::default(); - let y = vec![1.0, 0.0, 0.0, 1.0]; - - let (objective, grad, loglik) = neg_loglik_and_grad(&y, None, &[0, 0], &p, &cfg, &penalty); - - assert!(objective.is_finite()); - assert!(loglik.is_finite()); - assert_eq!(grad.tau, 0.0); - assert!(grad.xi.iter().all(|value| *value == 0.0)); - assert!(grad.zeta.iter().all(|value| *value == 0.0)); -} diff --git a/tests/unit/linking_branch_tests.rs b/tests/unit/linking_branch_tests.rs deleted file mode 100644 index dcd38d20e..000000000 --- a/tests/unit/linking_branch_tests.rs +++ /dev/null @@ -1,112 +0,0 @@ -use super::*; - -#[test] -fn parse_all_methods() { - for (s, m) in [ - ("mean-mean", LinkMethod::MeanMean), - ("mm", LinkMethod::MeanMean), - ("MEAN_SIGMA", LinkMethod::MeanSigma), - ("ms", LinkMethod::MeanSigma), - ("Haebara", LinkMethod::Haebara), - ("hb", LinkMethod::Haebara), - ("stocking-lord", LinkMethod::StockingLord), - ("SL", LinkMethod::StockingLord), - ] { - assert_eq!(LinkMethod::parse(s), Some(m)); - } - assert_eq!(LinkMethod::parse("nope"), None); -} - -#[test] -fn mean_sigma_rejects_zero_spread() { - // sd(d_new) = 0 makes the mean/sigma scale coefficient unidentified. - let a_old = vec![1.0, 1.0, 1.0]; - let b_old = vec![-0.3, 0.1, 0.5]; - let a_new = vec![1.0, 1.0, 1.0]; - let b_new = vec![0.0, 0.0, 0.0]; // all difficulties 0 - let (nodes, w) = (vec![-1.0, 0.0, 1.0], vec![0.25, 0.5, 0.25]); - assert!(irt_link( - &a_old, - &b_old, - &a_new, - &b_new, - &nodes, - &w, - LinkMethod::MeanSigma, - ) - .is_err()); -} - -#[test] -fn cc_objective_penalizes_nonpositive_slope() { - let a = vec![1.0, 1.0]; - let b = vec![0.0, 0.0]; - let th = vec![0.0]; - let w = vec![1.0]; - // slope <= 1e-6 and non-finite intercept both return the 1e18 penalty - assert_eq!(cc_objective(0.0, 0.0, &a, &b, &a, &b, &th, &w, true), 1e18); - assert_eq!( - cc_objective(1.0, f64::NAN, &a, &b, &a, &b, &th, &w, false), - 1e18 - ); -} - -#[test] -fn nelder_mead_minimizes_nonsmooth() { - // a non-smooth V forces contraction/shrink steps, not just reflection - let result = nelder_mead(&|a, b| (a - 2.0).abs() + 3.0 * (b + 1.0).abs(), [8.0, 8.0]); - assert!( - (result.x[0] - 2.0).abs() < 1e-3 && (result.x[1] + 1.0).abs() < 1e-3, - "x = {:?}", - result.x - ); - assert!(result.objective < 1e-3 && result.n_iter > 1); - assert!(result.converged, "{result:?}"); - assert!(result.final_objective_span <= result.objective_tolerance); - assert!(result.final_parameter_span <= result.parameter_tolerance); -} - -#[test] -fn irt_link_rejects_bad_slopes_and_grids() { - let a = vec![1.0, 1.0, 1.0]; - let b = vec![-0.3, 0.1, 0.5]; - let bad = vec![0.0, 1.0, 1.0]; // a slope <= 0 - let (nodes, w) = (vec![-1.0, 0.0, 1.0], vec![0.25, 0.5, 0.25]); - assert!(irt_link(&a, &b, &bad, &b, &nodes, &w, LinkMethod::MeanMean).is_err()); - // empty / mismatched grid for a characteristic-curve method - assert!(irt_link(&a, &b, &a, &b, &[], &[], LinkMethod::Haebara).is_err()); - assert!(irt_link(&a, &b, &a, &b, &nodes, &[0.5], LinkMethod::StockingLord).is_err()); - - let nan_intercept = vec![-0.3, f64::NAN, 0.5]; - assert!(irt_link(&a, &nan_intercept, &a, &b, &nodes, &w, LinkMethod::MeanMean,).is_err()); - assert!(irt_link( - &a, - &b, - &a, - &b, - &nodes, - &[0.25, f64::NAN, 0.25], - LinkMethod::StockingLord, - ) - .is_err()); - assert!(irt_link( - &a, - &b, - &a, - &b, - &nodes, - &[0.25, -0.1, 0.25], - LinkMethod::Haebara, - ) - .is_err()); - assert!(irt_link( - &a, - &b, - &a, - &b, - &nodes, - &[0.0, 0.0, 0.0], - LinkMethod::Haebara, - ) - .is_err()); -} diff --git a/tests/unit/linking_tests.rs b/tests/unit/linking_tests.rs deleted file mode 100644 index 0c2e7c88e..000000000 --- a/tests/unit/linking_tests.rs +++ /dev/null @@ -1,113 +0,0 @@ -use super::*; - -fn gh21() -> (Vec, Vec) { - // coarse standard-normal grid (nodes, weights) sufficient for CC linking - let nodes: Vec = (0..41).map(|i| -4.0 + 0.2 * i as f64).collect(); - let w: Vec = nodes - .iter() - .map(|&t| (-0.5 * t * t).exp() / (2.0 * std::f64::consts::PI).sqrt() * 0.2) - .collect(); - (nodes, w) -} - -fn recover(method: LinkMethod) { - // old-form items (eta form), generate a new form by a known transform - let a_old = vec![1.2, 0.8, 1.5, 1.0, 0.9, 1.3, 1.1, 0.7]; - let b_old = vec![-0.5, 0.3, 1.0, -1.2, 0.0, 0.6, -0.8, 0.4]; - let (a0, b0) = (1.3_f64, 0.4_f64); // true theta_old = 1.3*theta_new + 0.4 - // a_new = A*a_old ; b_new = b_old + a_old*B (inverse of the transform) - let a_new: Vec = a_old.iter().map(|&a| a0 * a).collect(); - let b_new: Vec = a_old - .iter() - .zip(&b_old) - .map(|(&a, &b)| b + a * b0) - .collect(); - let (theta, weight) = gh21(); - let res = irt_link(&a_old, &b_old, &a_new, &b_new, &theta, &weight, method).unwrap(); - assert!( - (res.slope - a0).abs() < 1e-3 && (res.intercept - b0).abs() < 1e-3, - "{method:?}: recovered ({}, {}) vs (1.3, 0.4)", - res.slope, - res.intercept - ); - assert!(res.converged, "{method:?}: {res:?}"); - match method { - LinkMethod::MeanMean | LinkMethod::MeanSigma => { - assert_eq!(res.termination_reason, "closed_form"); - assert_eq!(res.n_iter, 0); - } - LinkMethod::Haebara | LinkMethod::StockingLord => { - assert_eq!(res.termination_reason, "tolerance_met"); - assert!(res.n_iter < res.max_iter); - assert!(res.final_objective_span <= res.objective_tolerance); - assert!(res.final_parameter_span <= res.parameter_tolerance); - } - } -} - -#[test] -fn mean_sigma_recovers_transform() { - recover(LinkMethod::MeanSigma); -} - -#[test] -fn mean_mean_recovers_transform() { - recover(LinkMethod::MeanMean); -} - -#[test] -fn haebara_recovers_transform() { - recover(LinkMethod::Haebara); -} - -#[test] -fn stocking_lord_recovers_transform() { - recover(LinkMethod::StockingLord); -} - -#[test] -fn rejects_bad_input() { - let (theta, weight) = gh21(); - assert!(irt_link( - &[1.0], - &[0.0], - &[1.0], - &[0.0], - &theta, - &weight, - LinkMethod::MeanSigma - ) - .is_err()); - - assert!(irt_link( - &[1.0, 1.2], - &[0.0, 0.5], - &[1.1, 1.3], - &[0.1, 0.6], - &[f64::NAN], - &[1.0], - LinkMethod::Haebara, - ) - .is_err()); - - assert!(moment(&[0.0, 0.0], &[0.0, 1.0], &[1.0, 1.0], &[0.0, 1.0], false).is_err()); - let shrink = nelder_mead(&|_, _| 1.0, [0.0, 0.0]); - assert!(shrink.converged); - assert_eq!(link_termination_reason(false), "max_iter_reached"); -} - -#[test] -fn characteristic_linking_falls_back_when_difficulty_spread_is_zero() { - let result = irt_link( - &[1.0, 2.0], - &[0.0, 0.0], - &[1.2, 2.4], - &[0.0, 0.0], - &[-1.0, 0.0, 1.0], - &[0.25, 0.5, 0.25], - LinkMethod::Haebara, - ) - .unwrap(); - assert!(result.slope.is_finite() && result.slope > 0.0); - assert!(result.intercept.is_finite()); -} diff --git a/tests/unit/lltm_tests.rs b/tests/unit/lltm_tests.rs deleted file mode 100644 index dbda5af64..000000000 --- a/tests/unit/lltm_tests.rs +++ /dev/null @@ -1,433 +0,0 @@ -use super::*; -use crate::mixture::{fit_mixture, MixtureConfig, MixtureModel}; - -struct TestRng(u64); -impl TestRng { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } - fn skew(&mut self) -> f64 { - -(self.next_f64().max(1e-12)).ln() - 1.0 // Exp(1) - 1: mean 0, var 1 - } - fn bern(&mut self, p: f64) -> f64 { - if self.next_f64() < p { - 1.0 - } else { - 0.0 - } - } -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - (a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::() / n).sqrt() -} -fn bias(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - a.iter().zip(b).map(|(x, y)| x - y).sum::() / n -} -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let (mx, my) = (x.iter().sum::() / n, y.iter().sum::() / n); - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for i in 0..x.len() { - sxy += (x[i] - mx) * (y[i] - my); - sxx += (x[i] - mx).powi(2); - syy += (y[i] - my).powi(2); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} -fn nondecreasing(t: &[f64]) -> bool { - t.windows(2).all(|w| w[1] >= w[0] - 1e-6) -} - -/// A full-column-rank integer design whose rows do NOT sum to a constant (so the -/// intercept is identified): column `k` cycles with period `k + 2`, so the columns -/// have distinct fundamental frequencies (independent of each other and of the -/// constant intercept) and the row sums genuinely vary. -fn make_q(n_items: usize, n_basic: usize) -> Vec { - let mut q = vec![0.0f64; n_items * n_basic]; - for i in 0..n_items { - for k in 0..n_basic { - q[i * n_basic + k] = ((i + k) % (k + 2)) as f64; - } - } - q -} - -fn simulate( - design_b: &[f64], // induced true b per item - n_persons: usize, - n_items: usize, - skew: bool, - rng: &mut TestRng, -) -> Vec { - let mut y = vec![0.0f64; n_persons * n_items]; - for p in 0..n_persons { - let theta = if skew { rng.skew() } else { rng.normal() }; - for i in 0..n_items { - y[p * n_items + i] = rng.bern(sigmoid_stable(theta + design_b[i])); - } - } - y -} - -/// Anchor 1: at Q = I, one chain-rule M-step (fixed single Newton step) is -/// BIT-IDENTICAL to J independent per-item Rasch Newton steps. -#[test] -fn lltm_qi_single_mstep_bit_exact() { - let (n_items, q) = (10usize, GH_NODES.len()); - let mut id = vec![0.0f64; n_items * n_items]; - for i in 0..n_items { - id[i * n_items + i] = 1.0; - } - // fabricate deterministic expected counts and an init - let mut rng = TestRng(11); - let (mut n_iq, mut r_iq) = (vec![0.0f64; n_items * q], vec![0.0f64; n_items * q]); - for i in 0..n_items { - for qi in 0..q { - let n = 5.0 + 20.0 * rng.next_f64(); - n_iq[i * q + qi] = n; - r_iq[i * q + qi] = n * rng.next_f64(); - } - } - let params0: Vec = (0..n_items).map(|_| -1.0 + 2.0 * rng.next_f64()).collect(); - let (ridge, nit) = (1e-3, 1usize); - let joint = newton_mstep( - &id, - n_items, - n_items, - q, - &n_iq, - &r_iq, - params0.clone(), - ridge, - nit, - ); - // per-item 1-D Rasch Newton, one step - let mut per_item = params0.clone(); - for i in 0..n_items { - let mut b = params0[i]; - let (mut g_b, mut h_bb) = (0.0, 0.0); - for qi in 0..q { - let p = sigmoid_stable(GH_NODES[qi] + b); - let n = n_iq[i * q + qi]; - let w = n * p * (1.0 - p); - g_b += r_iq[i * q + qi] - n * p; - h_bb -= w; - } - g_b -= ridge * b; - h_bb -= ridge; - b -= g_b / h_bb; - per_item[i] = b; - } - for i in 0..n_items { - assert_eq!( - joint[i], per_item[i], - "item {i}: joint {} vs per-item {}", - joint[i], per_item[i] - ); - } -} - -/// Anchor 2: full LLTM(Q = I, no intercept, tol = 0) equals a single-class Rasch fit. -#[test] -fn lltm_qi_equals_rasch_fit() { - let (n, j) = (700usize, 12usize); - let mut rng = TestRng(7); - let b_true: Vec = (0..j) - .map(|i| -1.2 + 2.4 * i as f64 / (j - 1) as f64) - .collect(); - let y = simulate(&b_true, n, j, false, &mut rng); - let observed = vec![true; n * j]; - let mut id = vec![0.0f64; j * j]; - for i in 0..j { - id[i * j + i] = 1.0; - } - let cfg = LltmConfig { - max_iter: 80, - tol: 0.0, - ridge: 1e-3, - newton_iter: 25, - fit_intercept: false, - compute_lr: false, - }; - let l = fit_lltm(&y, &observed, &id, n, j, j, &cfg).unwrap(); - let mcfg = MixtureConfig { - max_iter: 80, - tol: 0.0, - ridge_b: 1e-3, - ..MixtureConfig::default() - }; - let mix = fit_mixture(&y, &observed, n, j, 1, MixtureModel::Rasch, &mcfg).unwrap(); - assert!(rmse(&l.b, &mix.b) < 1e-10, "b rmse {}", rmse(&l.b, &mix.b)); - assert!(rmse(&l.eta, &l.b) < 1e-12); // eta == b at Q = I - assert_eq!(l.n_parameters, j); - assert_eq!(l.lr_df, 0); -} - -/// EM ascent guard. -#[test] -fn lltm_loglik_nondecreasing() { - let (n, j, k) = (300usize, 8usize, 3usize); - let q = make_q(j, k); - let (design, m) = build_design(&q, j, k, true); - let params: Vec = vec![-0.2, 0.5, -0.3, 0.6]; - let b_true = induced_b(&design, m, j, ¶ms); - let mut rng = TestRng(3); - let y = simulate(&b_true, n, j, false, &mut rng); - let observed = vec![true; n * j]; - let res = fit_lltm(&y, &observed, &q, n, j, k, &LltmConfig::default()).unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); -} - -/// Fast recovery sanity: recover the basic parameters and induced difficulties. -#[test] -fn recovers_lltm() { - let (n, j, k) = (2000usize, 20usize, 5usize); - let q = make_q(j, k); - let (design, m) = build_design(&q, j, k, true); - let eta_true = [0.5f64, -0.3, 0.8, -0.6, 0.4]; - let params: Vec = std::iter::once(-0.2) - .chain(eta_true.iter().copied()) - .collect(); - let b_true = induced_b(&design, m, j, ¶ms); - let mut rng = TestRng(2024); - let y = simulate(&b_true, n, j, false, &mut rng); - let observed = vec![true; n * j]; - let res = fit_lltm(&y, &observed, &q, n, j, k, &LltmConfig::default()).unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); - assert!( - corr(&res.eta, &eta_true) > 0.95, - "eta corr {}", - corr(&res.eta, &eta_true) - ); - assert!( - rmse(&res.b, &b_true) < 0.15, - "b rmse {}", - rmse(&res.b, &b_true) - ); - assert_eq!(res.n_parameters, k + 1); - // LR should NOT reject when the LLTM restriction holds - assert!( - res.lr_p > 0.01, - "LR falsely rejected true LLTM: p={}", - res.lr_p - ); - assert_eq!(res.lr_df, j - k - 1); -} - -/// Malformed inputs are rejected (covers each validate branch, incl. rank deficiency). -#[test] -fn lltm_validate_rejects_malformed() { - assert_eq!(ls_project(&[0.0], 1, 1, &[2.5]), vec![2.5]); - let (n, j, k) = (5usize, 4usize, 2usize); - let q = make_q(j, k); - let y = vec![0.0f64; n * j]; - let obs = vec![true; n * j]; - let d = LltmConfig::default(); - let bad = |y: &[f64], obs: &[bool], q: &[f64], n, j, k, cfg: &LltmConfig| { - fit_lltm(y, obs, q, n, j, k, cfg).is_err() - }; - assert!(bad(&y, &obs, &q, 0, j, k, &d)); // n_persons < 1 - assert!(bad(&y, &obs, &q, n, j, 0, &d)); // n_basic < 1 - assert!(bad(&y, &obs, &q, n, j, k, &LltmConfig { max_iter: 0, ..d })); - assert!(bad( - &y, - &obs, - &q, - n, - j, - k, - &LltmConfig { - newton_iter: 0, - ..d - } - )); - assert!(bad(&y, &obs, &q, n, j, k, &LltmConfig { tol: -1.0, ..d })); - assert!(bad(&y, &obs, &q, n, j, k, &LltmConfig { ridge: -1.0, ..d })); - assert!(bad(&vec![0.0; n * j - 1], &obs, &q, n, j, k, &d)); // y length - assert!(bad(&y, &obs, &vec![0.0; j * k - 1], n, j, k, &d)); // q length - assert!(bad(&vec![2.0; n * j], &obs, &q, n, j, k, &d)); // y not 0/1 - // rank-deficient design: a duplicated column (K=2, both columns identical) - let q_dup = vec![1.0f64, 1.0, 2.0, 2.0, 1.0, 1.0, 3.0, 3.0]; - assert!(bad(&y, &obs, &q_dup, n, j, 2, &d)); - // an all-ones column with intercept on => [1|Q] rank-deficient - let q_const = vec![1.0f64; j * 1]; - assert!(bad( - &y, - &obs, - &q_const, - n, - j, - 1, - &LltmConfig { - fit_intercept: true, - ..d - } - )); - // an item with no observed responses - let mut obs_gap = vec![true; n * j]; - for p in 0..n { - obs_gap[p * j + 1] = false; - } - assert!(bad(&y, &obs_gap, &q, n, j, k, &d)); - let mut q_nonfinite = q.clone(); - q_nonfinite[0] = f64::NAN; - assert!(bad(&y, &obs, &q_nonfinite, n, j, k, &d)); - assert!(bad(&[0.0], &[true], &[1.0, 0.0], 1, 1, 2, &d)); - assert!(bad(&y, &obs, &[0.0, 0.0, 0.0, 0.0], n, j, 1, &d)); - // tol == 0.0 is accepted - assert!(fit_lltm( - &y, - &obs, - &q, - n, - j, - k, - &LltmConfig { - tol: 0.0, - max_iter: 2, - ..d - } - ) - .is_ok()); - - assert!(solve_small_checked(vec![vec![0.0]], vec![1.0]).is_none()); - assert_eq!(init_b(&[0.0], &[false], 1, 1), vec![0.0]); - assert_eq!( - newton_mstep( - &[1.0], - 1, - 1, - GH_NODES.len(), - &vec![0.0; GH_NODES.len()], - &vec![0.0; GH_NODES.len()], - vec![0.0], - 0.0, - 2 - ), - vec![0.0] - ); - - let missing = fit_lltm( - &[0.0, 1.0, 1.0, 0.0], - &[true, false, true, true], - &[1.0, 0.0], - 2, - 2, - 1, - &LltmConfig { max_iter: 2, ..d }, - ) - .unwrap(); - assert!(missing.loglik_trace.iter().all(|value| value.is_finite())); -} - -/// Literature-grade Monte-Carlo (>=500 reps): recover the K basic parameters and -/// induced difficulties under normal and skew ability, and validate the LR test -/// (Type I when the LLTM restriction holds, power when it is violated off-model). -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_lltm_recovery_500() { - let (n, j, k, reps) = (1500usize, 30usize, 5usize, 500usize); - let q = make_q(j, k); - let (design, m) = build_design(&q, j, k, true); - let eta_true = [0.6f64, -0.4, 0.9, -0.5, 0.3]; - let c_true = -0.2; - let params_true: Vec = std::iter::once(c_true) - .chain(eta_true.iter().copied()) - .collect(); - let b_true = induced_b(&design, m, j, ¶ms_true); - // an off-model perturbation orthogonal to colspace([1|Q]) for the power condition - let mut eps = vec![0.0f64; j]; - { - // residual of a vector with a component OUTSIDE the design space after - // projecting onto the design columns. `make_q`'s columns have periods 2..6, - // so a period-7 pattern is guaranteed a nonzero residual (a genuinely - // off-model violation, not one the design can already represent). - let raw: Vec = (0..j).map(|i| (i % 7) as f64 - 3.0).collect(); - let proj = ls_project(&design, m, j, &raw); - let fitted = induced_b(&design, m, j, &proj); - for i in 0..j { - eps[i] = raw[i] - fitted[i]; - } - let nrm = (eps.iter().map(|e| e * e).sum::() / j as f64).sqrt(); - assert!( - nrm > 0.05, - "off-model perturbation is (near) in-design: nrm={nrm}" - ); - for e in eps.iter_mut() { - *e = *e / nrm * 0.6; // scale the off-model violation to RMS 0.6 - } - } - - for &skew in [false, true].iter() { - let (mut sum_re, mut sum_be, mut sum_rb) = (0.0, 0.0, 0.0); - let (mut type1, mut power) = (0.0, 0.0); - for rep in 0..reps { - let seed = 0xC0FFEE1234567u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add(if skew { 0x9E3779B97F4A7C15 } else { 0 }); - let mut rng = TestRng(seed); - // null (LLTM holds) - let y0 = simulate(&b_true, n, j, skew, &mut rng); - let observed = vec![true; n * j]; - let res = fit_lltm(&y0, &observed, &q, n, j, k, &LltmConfig::default()).unwrap(); - sum_re += rmse(&res.eta, &eta_true); - sum_be += bias(&res.eta, &eta_true); - sum_rb += rmse(&res.b, &b_true); - if res.lr_p < 0.05 { - type1 += 1.0; - } - // alternative (off-model): b = b_true + eps - let b_alt: Vec = (0..j).map(|i| b_true[i] + eps[i]).collect(); - let y1 = simulate(&b_alt, n, j, skew, &mut rng); - let res1 = fit_lltm(&y1, &observed, &q, n, j, k, &LltmConfig::default()).unwrap(); - if res1.lr_p < 0.05 { - power += 1.0; - } - } - let r = reps as f64; - println!( - "skew={}: RMSE(eta)={:.4} bias(eta)={:.4} RMSE(b)={:.4} LR-typeI={:.3} LR-power={:.3}", - skew, - sum_re / r, - sum_be / r, - sum_rb / r, - type1 / r, - power / r - ); - assert!( - sum_re / r < 0.08, - "mean RMSE(eta) {} skew={skew}", - sum_re / r - ); - assert!( - (sum_be / r).abs() < 0.03, - "mean bias(eta) {} skew={skew}", - sum_be / r - ); - // The LR Type I is properly calibrated under correct specification - // (normal: ~0.04). A misspecified ability prior (skew = Exp(1)-1 fit with an - // N(0,1) quadrature) inflates it to ~0.13 because the SATURATED Rasch - // reference absorbs skew-induced misfit that the CONSTRAINED LLTM cannot — - // the LR test's known sensitivity to a shared baseline misspecification, not - // an estimator defect (parameter recovery and power stay excellent in both). - let type1_bound = if skew { 0.18 } else { 0.08 }; - assert!( - type1 / r < type1_bound, - "LR Type I {} skew={skew}", - type1 / r - ); - assert!(power / r > 0.90, "LR power {} skew={skew}", power / r); - } -} diff --git a/tests/unit/marginal_covariate_interaction_tests.rs b/tests/unit/marginal_covariate_interaction_tests.rs deleted file mode 100644 index a8320b0c2..000000000 --- a/tests/unit/marginal_covariate_interaction_tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -use super::{m_step_delta, Contexts, EStep, Grids}; -use crate::{ModelConfig, ModelType, PenaltyConfig}; - -#[test] -fn bifactor_delta_step_uses_inner_product_predictor() { - let mut delta = 0.0; - let config = ModelConfig { - n_persons: 100, - n_items: 1, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Bifac2plm, - eps_distance: 1e-8, - }; - let estep = EStep { - nbar: vec![100.0], - rbar: vec![50.0], - mbar: vec![0.0], - loglik: 0.0, - zi_resp: Vec::new(), - sum_e_v2: 0.0, - cluster_post: Vec::new(), - }; - let ctx = Contexts { - n_ctx: 1, - shift: vec![0.0], - scale: vec![1.0], - u_nodes: Vec::new(), - u_logw: Vec::new(), - }; - let grids = Grids { - t_nodes: vec![0.0], - t_logw: vec![0.0], - x_grid: vec![2.0], - x_logw: vec![0.0], - q_t: 1, - n_x: 1, - }; - - m_step_delta( - &[0.0], - &[0.0], - &[2.0], - -30.0, - &mut delta, - &[1.0], - &estep, - &ctx, - &grids, - &config, - &[0], - &PenaltyConfig::default(), - ); - - assert!( - delta < -1.0, - "the inner-product eta is 4 at delta=0, so a 50% success rate must move delta negative; got {delta}" - ); -} diff --git a/tests/unit/marginal_em_endpoint_tests.rs b/tests/unit/marginal_em_endpoint_tests.rs deleted file mode 100644 index f97e54421..000000000 --- a/tests/unit/marginal_em_endpoint_tests.rs +++ /dev/null @@ -1,479 +0,0 @@ -use super::{ - fit_marginal, fit_marginal_anchored, fit_marginal_full, m_step_delta, m_step_items, m_step_tau, - pca_align, validate, Anchors, Contexts, EStep, Grids, ItemCovariate, MarginalConfig, - PopulationSpec, XiRuleKind, -}; -use crate::{Device, ModelConfig, ModelType, PenaltyConfig}; - -#[test] -fn trace_endpoint_matches_returned_parameters_after_max_iter() { - let n_persons = 8; - let n_items = 3; - let y = vec![ - 0.0, 0.0, 0.0, // person 0 - 0.0, 0.0, 1.0, // person 1 - 0.0, 1.0, 0.0, // person 2 - 0.0, 1.0, 1.0, // person 3 - 1.0, 0.0, 0.0, // person 4 - 1.0, 0.0, 1.0, // person 5 - 1.0, 1.0, 0.0, // person 6 - 1.0, 1.0, 1.0, // person 7 - ]; - let observed = vec![true; n_persons * n_items]; - let factor_id = vec![0; n_items]; - let config = ModelConfig { - n_persons, - n_items, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Mirt, - eps_distance: 1e-8, - }; - let mcfg = MarginalConfig { - q_theta: 7, - q_xi: 7, - q_u: 7, - max_iter: 1, - m_steps: 2, - ..MarginalConfig::default() - }; - let result = fit_marginal( - &y, - &observed, - &factor_id, - &config, - &PopulationSpec::Single, - &mcfg, - &PenaltyConfig::default(), - Device::Cpu, - ) - .unwrap(); - let anchors = Anchors { - fixed: vec![true; n_items], - alpha: result.alpha.clone(), - b: result.b.clone(), - zeta: result.zeta.clone(), - tau: Some(result.tau), - }; - let reevaluated = fit_marginal_anchored( - &y, - &observed, - &factor_id, - &config, - &PopulationSpec::Single, - &mcfg, - &PenaltyConfig::default(), - Device::Cpu, - Some(&anchors), - ) - .unwrap(); - - assert_eq!(result.n_iter, 1); - assert!( - (result.loglik_trace.last().unwrap() - reevaluated.loglik_trace[0]).abs() < 1e-10, - "trace endpoint must be the likelihood of the returned parameters: {:?} vs {:?}", - result.loglik_trace, - reevaluated.loglik_trace - ); -} - -fn tiny_config(model_type: ModelType) -> ModelConfig { - ModelConfig { - n_persons: 2, - n_items: 1, - n_dims: 1, - latent_dim: 1, - model_type, - eps_distance: 1e-8, - } -} - -fn tiny_mcfg() -> MarginalConfig { - MarginalConfig { - q_theta: 7, - q_xi: 7, - q_u: 7, - max_iter: 1, - m_steps: 1, - ..MarginalConfig::default() - } -} - -#[test] -fn validation_covers_every_shape_population_and_rule_guard() { - let y = [0.0, 1.0]; - let observed = [true, true]; - let factor = [0]; - let config = tiny_config(ModelType::Mirt); - let mcfg = tiny_mcfg(); - - assert!(validate(&y, &observed, &[], &config, &PopulationSpec::Single, &mcfg).is_err()); - - let mut bad = config.clone(); - bad.model_type = ModelType::Ulsrm; - bad.n_dims = 2; - assert!(validate(&y, &observed, &factor, &bad, &PopulationSpec::Single, &mcfg).is_err()); - - bad = config.clone(); - bad.n_items = 0; - bad.n_dims = 0; - assert!(validate(&[], &[], &[], &bad, &PopulationSpec::Single, &mcfg).is_err()); - - bad = config.clone(); - bad.latent_dim = 7; - assert!(validate(&y, &observed, &factor, &bad, &PopulationSpec::Single, &mcfg).is_err()); - - for eps in [0.0, f64::NAN, f64::INFINITY] { - bad = config.clone(); - bad.eps_distance = eps; - assert!(validate(&y, &observed, &factor, &bad, &PopulationSpec::Single, &mcfg).is_err()); - } - - for rule in [XiRuleKind::Halton, XiRuleKind::MonteCarlo] { - let bad_rule = MarginalConfig { - xi_rule: rule, - xi_points: 0, - ..mcfg - }; - assert!(validate( - &y, - &observed, - &factor, - &config, - &PopulationSpec::Single, - &bad_rule, - ) - .is_err()); - } - - assert!(validate( - &y, - &observed, - &factor, - &config, - &PopulationSpec::Multigroup { - group_id: vec![0], - n_groups: 1, - }, - &mcfg, - ) - .is_err()); - assert!(validate( - &y, - &observed, - &factor, - &config, - &PopulationSpec::Multilevel { - cluster_id: vec![0, 0], - n_clusters: 0, - }, - &mcfg, - ) - .is_err()); -} - -#[test] -fn anchored_and_covariate_contracts_cover_all_early_errors() { - let y = [0.0, 1.0]; - let observed = [true, true]; - let factor = [0]; - let config = tiny_config(ModelType::Mlsrm); - let mcfg = tiny_mcfg(); - let penalty = PenaltyConfig::default(); - let call = |pop: &PopulationSpec, anchors: Option<&Anchors>, cov: Option<&ItemCovariate>| { - fit_marginal_full( - &y, - &observed, - &factor, - &config, - pop, - &mcfg, - &penalty, - Device::Cpu, - anchors, - cov, - ) - }; - - let wrong_shape = Anchors { - fixed: vec![true], - alpha: vec![], - b: vec![0.0], - zeta: vec![0.0], - tau: None, - }; - assert!(call(&PopulationSpec::Single, Some(&wrong_shape), None).is_err()); - - let no_fixed = Anchors { - fixed: vec![false], - alpha: vec![0.0], - b: vec![0.0], - zeta: vec![0.0], - tau: None, - }; - assert!(call(&PopulationSpec::Single, Some(&no_fixed), None).is_err()); - - let nonfinite = Anchors { - fixed: vec![true], - alpha: vec![f64::NAN], - b: vec![0.0], - zeta: vec![0.0], - tau: None, - }; - assert!(call(&PopulationSpec::Single, Some(&nonfinite), None).is_err()); - - let cov = ItemCovariate { - w: vec![0.0], - init_delta: 0.0, - }; - assert!(call( - &PopulationSpec::Multilevel { - cluster_id: vec![0, 0], - n_clusters: 1, - }, - None, - Some(&cov), - ) - .is_err()); - assert!(call( - &PopulationSpec::Multigroup { - group_id: vec![0, 1], - n_groups: 2, - }, - None, - Some(&cov), - ) - .is_err()); -} - -#[test] -fn small_fits_cover_device_zero_inflation_initialization_and_empty_groups() { - let y = [0.0, 1.0]; - let observed = [true, true]; - let factor = [0]; - let penalty = PenaltyConfig::default(); - - let mirt = tiny_config(ModelType::Mirt); - let auto = fit_marginal( - &y, - &observed, - &factor, - &mirt, - &PopulationSpec::Single, - &tiny_mcfg(), - &penalty, - Device::Auto, - ) - .unwrap(); - assert_eq!(auto.n_iter, 1); - - let zi_cfg = MarginalConfig { - zero_inflation: true, - ..tiny_mcfg() - }; - for pop in [ - PopulationSpec::Multigroup { - group_id: vec![0, 1], - n_groups: 2, - }, - PopulationSpec::Multilevel { - cluster_id: vec![0, 0], - n_clusters: 1, - }, - ] { - let fit = fit_marginal( - &y, - &observed, - &factor, - &mirt, - &pop, - &zi_cfg, - &penalty, - Device::Cpu, - ) - .unwrap(); - assert_eq!(fit.zero_responsibility.len(), 2); - } - - let empty_group = fit_marginal( - &y, - &observed, - &factor, - &mirt, - &PopulationSpec::Multigroup { - group_id: vec![0, 0], - n_groups: 2, - }, - &tiny_mcfg(), - &penalty, - Device::Cpu, - ) - .unwrap(); - assert_eq!(empty_group.mu[1], 0.0); - - let all_missing = fit_marginal( - &y, - &[false, false], - &factor, - &mirt, - &PopulationSpec::Single, - &tiny_mcfg(), - &penalty, - Device::Cpu, - ) - .unwrap(); - assert_eq!(all_missing.b[0], 0.0); - - let spatial_d3 = ModelConfig { - latent_dim: 3, - model_type: ModelType::Mlsrm, - ..mirt.clone() - }; - let d3 = fit_marginal( - &y, - &observed, - &factor, - &spatial_d3, - &PopulationSpec::Single, - &tiny_mcfg(), - &penalty, - Device::Cpu, - ) - .unwrap(); - assert_eq!(d3.zeta.len(), 3); - - let anchors = Anchors { - fixed: vec![true], - alpha: vec![0.0], - b: vec![0.0], - zeta: vec![0.25], - tau: Some(0.25), - }; - let anchored = fit_marginal_anchored( - &y, - &observed, - &factor, - &tiny_config(ModelType::Mlsrm), - &PopulationSpec::Single, - &tiny_mcfg(), - &penalty, - Device::Cpu, - Some(&anchors), - ) - .unwrap(); - assert_eq!(anchored.tau, 0.25); -} - -#[test] -fn marginal_numeric_noop_paths_are_stable() { - let config = ModelConfig { - n_persons: 1, - n_items: 1, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Mlsrm, - eps_distance: 1e-8, - }; - let ctx = Contexts { - n_ctx: 1, - shift: vec![0.0], - scale: vec![1.0], - u_nodes: Vec::new(), - u_logw: Vec::new(), - }; - let grids = Grids { - t_nodes: vec![0.0], - t_logw: vec![0.0], - x_grid: vec![0.0], - x_logw: vec![0.0], - q_t: 1, - n_x: 1, - }; - let estep = EStep { - nbar: vec![0.0], - rbar: vec![0.0], - mbar: vec![0.0], - loglik: 0.0, - zi_resp: Vec::new(), - sum_e_v2: 0.0, - cluster_post: Vec::new(), - }; - let mut zero_penalty = PenaltyConfig::default(); - zero_penalty.lambda_b = 0.0; - zero_penalty.lambda_alpha = 0.0; - zero_penalty.lambda_zeta = 0.0; - zero_penalty.lambda_tau = 0.0; - - let (mut alpha, mut b, mut zeta) = (vec![0.0], vec![0.0], vec![0.0]); - m_step_items( - &mut alpha, - &mut b, - &mut zeta, - 0.0, - &estep, - &ctx, - &grids, - &config, - &[0], - &zero_penalty, - 1, - None, - None, - ); - assert_eq!((alpha[0], b[0], zeta[0]), (0.0, 0.0, 0.0)); - - let mut tau = 0.0; - m_step_tau( - &alpha, - &b, - &zeta, - &mut tau, - &estep, - &ctx, - &grids, - &config, - &[0], - &zero_penalty, - None, - ); - assert_eq!(tau, 0.0); - - let mut ridge_penalty = zero_penalty.clone(); - ridge_penalty.lambda_tau = 1.0; - m_step_tau( - &alpha, - &b, - &zeta, - &mut tau, - &estep, - &ctx, - &grids, - &config, - &[0], - &ridge_penalty, - None, - ); - assert_eq!(tau, 0.0); - - let mut delta = 0.0; - m_step_delta( - &alpha, - &b, - &zeta, - tau, - &mut delta, - &[1.0], - &estep, - &ctx, - &grids, - &config, - &[0], - &zero_penalty, - ); - assert_eq!(delta, 0.0); - - let (mut positive, mut xi) = (vec![0.5], vec![0.25]); - pca_align(&mut positive, &mut xi, 1, 1, 1); - pca_align(&mut [], &mut [], 0, 0, 0); - assert_eq!((positive[0], xi[0]), (0.5, 0.25)); -} diff --git a/tests/unit/marginal_recovery_tests.rs b/tests/unit/marginal_recovery_tests.rs deleted file mode 100644 index b12970ee5..000000000 --- a/tests/unit/marginal_recovery_tests.rs +++ /dev/null @@ -1,1230 +0,0 @@ -//! Recovery and contract tests for the marginal (MMLE-EM) estimator. - -use crate::marginal::{ - fit_marginal, multilevel_context_posteriors, MarginalConfig, PopulationSpec, -}; -use crate::{Device, ModelConfig, ModelType, PenaltyConfig}; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let mx = x.iter().sum::() / n; - let my = y.iter().sum::() / n; - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for i in 0..x.len() { - sxy += (x[i] - mx) * (y[i] - my); - sxx += (x[i] - mx).powi(2); - syy += (y[i] - my).powi(2); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -struct Sim { - y: Vec, - observed: Vec, - factor_id: Vec, - b_true: Vec, - a_true: Vec, - theta_true: Vec, - zeta_true: Vec, -} - -/// Interaction-adjusted easiness `b_i - gamma * E_xi[d(xi, zeta_i)]` — the -/// item quantity the distance model identifies (raw `b_i` is confounded with -/// the item's radius; cf. the adjusted summaries in the design-doc sources). -fn adjusted_easiness( - b: &[f64], - zeta: &[f64], - gamma: f64, - latent_dim: usize, - rng: &mut Lcg, -) -> Vec { - let n_items = b.len(); - let draws: Vec = (0..2000 * latent_dim).map(|_| rng.normal()).collect(); - (0..n_items) - .map(|i| { - let mut mean_d = 0.0; - for s in 0..2000 { - let mut d2 = 1e-8; - for k in 0..latent_dim { - let diff = draws[s * latent_dim + k] - zeta[i * latent_dim + k]; - d2 += diff * diff; - } - mean_d += d2.sqrt(); - } - b[i] - gamma * mean_d / 2000.0 - }) - .collect() -} - -#[allow(clippy::too_many_arguments)] -fn simulate( - rng: &mut Lcg, - n_persons: usize, - n_items: usize, - n_dims: usize, - latent_dim: usize, - gamma: f64, - group_shift: &[f64], - group_id: &[usize], - cluster_sd: f64, - cluster_id: &[usize], - n_clusters: usize, -) -> Sim { - let factor_id: Vec = (0..n_items).map(|i| i % n_dims).collect(); - let b_true: Vec = (0..n_items).map(|_| -1.0 + 2.0 * rng.next_f64()).collect(); - let a_true: Vec = (0..n_items).map(|_| 0.8 + 0.8 * rng.next_f64()).collect(); - let zeta_true: Vec = (0..n_items * latent_dim) - .map(|_| rng.normal() * 0.8) - .collect(); - let u_true: Vec = (0..n_clusters).map(|_| rng.normal() * cluster_sd).collect(); - let mut y = vec![0.0_f64; n_persons * n_items]; - let observed = vec![true; n_persons * n_items]; - let mut theta_true = vec![0.0_f64; n_persons * n_dims]; - for p in 0..n_persons { - let shift = if group_shift.is_empty() { - 0.0 - } else { - group_shift[group_id[p]] - }; - let u = if n_clusters > 0 { - u_true[cluster_id[p]] - } else { - 0.0 - }; - let xi_p: Vec = (0..latent_dim).map(|_| rng.normal()).collect(); - for d in 0..n_dims { - theta_true[p * n_dims + d] = shift + u + rng.normal(); - } - for i in 0..n_items { - let d = factor_id[i]; - let mut dist2 = 1e-8; - for k in 0..latent_dim { - let diff = xi_p[k] - zeta_true[i * latent_dim + k]; - dist2 += diff * diff; - } - let eta = a_true[i] * theta_true[p * n_dims + d] + b_true[i] - gamma * dist2.sqrt(); - let prob = 1.0 / (1.0 + (-eta).exp()); - y[p * n_items + i] = if rng.next_f64() < prob { 1.0 } else { 0.0 }; - } - } - Sim { - y, - observed, - factor_id, - b_true, - a_true, - theta_true, - zeta_true, - } -} - -fn small_cfg() -> MarginalConfig { - MarginalConfig { - q_theta: 15, - q_xi: 7, - q_u: 11, - max_iter: 150, - ..Default::default() - } -} - -fn assert_monotone(trace: &[f64]) { - // Exact EM is monotone; with adaptive population nodes (multigroup / - // multilevel updates move the quadrature grid) the quadrature - // APPROXIMATION of the marginal can dip by discretization error, so allow - // a small absolute slack. - for w in trace.windows(2) { - assert!( - w[1] >= w[0] - 1e-3, - "marginal loglik decreased: {} -> {}", - w[0], - w[1] - ); - } -} - -#[test] -fn recovers_mls2plm_single_population() { - let mut rng = Lcg(2024); - let (n_persons, n_items, n_dims, latent_dim) = (800usize, 16usize, 2usize, 2usize); - let sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 1.0, - &[], - &[], - 0.0, - &[], - 0, - ); - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Mls2plm, - eps_distance: 1e-8, - }; - let res = fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Single, - &MarginalConfig { - q_theta: 21, - q_xi: 11, - max_iter: 150, - ..Default::default() - }, - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("fit should succeed"); - assert_monotone(&res.loglik_trace); - let mut mc = Lcg(4242); - let b_adj_true = adjusted_easiness(&sim.b_true, &sim.zeta_true, 1.0, latent_dim, &mut mc); - let mut mc = Lcg(4242); - let b_adj_est = adjusted_easiness(&res.b, &res.zeta, res.tau.exp(), latent_dim, &mut mc); - let cb = corr(&b_adj_est, &b_adj_true); - assert!(cb > 0.85, "adjusted easiness recovery too low: {cb}"); - let a_est: Vec = res.alpha.iter().map(|a| a.exp()).collect(); - let ca = corr(&a_est, &sim.a_true); - assert!(ca > 0.55, "a recovery too low: {ca}"); - let theta_est_d0: Vec = (0..n_persons).map(|p| res.theta_eap[p * n_dims]).collect(); - let theta_true_d0: Vec = (0..n_persons).map(|p| sim.theta_true[p * n_dims]).collect(); - let ct = corr(&theta_est_d0, &theta_true_d0); - assert!(ct > 0.6, "theta recovery too low: {ct}"); - assert!(res.tau.exp() > 0.3, "gamma should stay clearly positive"); - assert!(res.theta_sd.iter().all(|s| s.is_finite() && *s >= 0.0)); -} - -#[test] -fn recovers_multigroup_mean_shift() { - let mut rng = Lcg(7); - let (n_persons, n_items, n_dims, latent_dim) = (500usize, 12usize, 1usize, 1usize); - let group_id: Vec = (0..n_persons).map(|p| p % 2).collect(); - let sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 0.8, - &[0.0, 1.0], - &group_id, - 0.0, - &[], - 0, - ); - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let res = fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Multigroup { - group_id, - n_groups: 2, - }, - &small_cfg(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("fit should succeed"); - assert!( - (res.mu[0] - 0.0).abs() < 1e-12, - "reference group mean must stay pinned" - ); - assert!( - res.mu[1] > 0.5 && res.mu[1] < 1.6, - "group-2 mean should recover ~1.0, got {}", - res.mu[1] - ); - assert_monotone(&res.loglik_trace); -} - -#[test] -fn recovers_multilevel_intercept_sd() { - let mut rng = Lcg(99); - let (n_persons, n_items, n_dims, latent_dim) = (600usize, 12usize, 1usize, 1usize); - let n_clusters = 30usize; - let cluster_id: Vec = (0..n_persons).map(|p| p % n_clusters).collect(); - let sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 0.8, - &[], - &[], - 0.8, - &cluster_id, - n_clusters, - ); - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Ulsrm, - eps_distance: 1e-8, - }; - let res = fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Multilevel { - cluster_id, - n_clusters, - }, - &small_cfg(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("fit should succeed"); - assert!( - res.sigma_u > 0.35 && res.sigma_u < 1.4, - "sigma_u should recover ~0.8, got {}", - res.sigma_u - ); - assert_eq!(res.u_eap.len(), n_clusters); - assert!(res.u_eap.iter().all(|u| u.is_finite())); - assert_monotone(&res.loglik_trace); -} - -#[test] -fn mirt_runs_without_latent_space() { - let mut rng = Lcg(5); - let (n_persons, n_items, n_dims, latent_dim) = (200usize, 8usize, 2usize, 2usize); - let sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 0.0, - &[], - &[], - 0.0, - &[], - 0, - ); - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Mirt, - eps_distance: 1e-8, - }; - let res = fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Single, - &small_cfg(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("fit should succeed"); - assert!( - res.zeta.iter().all(|z| *z == 0.0), - "MIRT must not move item positions" - ); - assert_monotone(&res.loglik_trace); -} - -#[test] -fn tolerates_missing_and_all_missing_rows() { - let mut rng = Lcg(13); - let (n_persons, n_items, n_dims, latent_dim) = (150usize, 10usize, 1usize, 2usize); - let mut sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 1.0, - &[], - &[], - 0.0, - &[], - 0, - ); - for p in 0..n_persons { - for i in 0..n_items { - if rng.next_f64() < 0.25 { - sim.observed[p * n_items + i] = false; - } - } - } - for i in 0..n_items { - sim.observed[i] = false; // person 0: all missing - } - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let res = fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Single, - &small_cfg(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("fit should succeed"); - assert!( - res.theta_eap[0].abs() < 1e-6, - "all-missing person shrinks to prior mean" - ); - assert!(res.theta_eap.iter().all(|t| t.is_finite())); - assert_monotone(&res.loglik_trace); -} - -#[test] -fn rejects_invalid_inputs() { - let config = ModelConfig { - n_persons: 2, - n_items: 2, - n_dims: 1, - latent_dim: 2, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let ok_y = vec![0.0, 1.0, 1.0, 0.0]; - let ok_obs = vec![true; 4]; - let base = MarginalConfig::default(); - let pen = PenaltyConfig::default(); - let single = PopulationSpec::Single; - // wrong y length - assert!(fit_marginal( - &[0.0; 3], - &ok_obs, - &[0, 0], - &config, - &single, - &base, - &pen, - Device::Cpu - ) - .is_err()); - // bad factor id - assert!(fit_marginal( - &ok_y, - &ok_obs, - &[0, 5], - &config, - &single, - &base, - &pen, - Device::Cpu - ) - .is_err()); - // non-binary response - assert!(fit_marginal( - &[0.0, 2.0, 1.0, 0.0], - &ok_obs, - &[0, 0], - &config, - &single, - &base, - &pen, - Device::Cpu - ) - .is_err()); - // unsupported quadrature - let bad_q = MarginalConfig { - q_theta: 12, - ..MarginalConfig::default() - }; - assert!(fit_marginal( - &ok_y, - &ok_obs, - &[0, 0], - &config, - &single, - &bad_q, - &pen, - Device::Cpu - ) - .is_err()); - // bad group id - assert!(fit_marginal( - &ok_y, - &ok_obs, - &[0, 0], - &config, - &PopulationSpec::Multigroup { - group_id: vec![0, 7], - n_groups: 2 - }, - &base, - &pen, - Device::Cpu - ) - .is_err()); - // bad cluster id length - assert!(fit_marginal( - &ok_y, - &ok_obs, - &[0, 0], - &config, - &PopulationSpec::Multilevel { - cluster_id: vec![0], - n_clusters: 1 - }, - &base, - &pen, - Device::Cpu - ) - .is_err()); - // latent_dim too large for grid quadrature - let big_k = ModelConfig { - latent_dim: 4, - ..config.clone() - }; - assert!(fit_marginal( - &ok_y, - &ok_obs, - &[0, 0], - &big_k, - &single, - &base, - &pen, - Device::Cpu - ) - .is_err()); -} - -#[test] -fn qmc_and_mc_rules_recover_like_gauss_hermite() { - use crate::marginal::XiRuleKind; - let mut rng = Lcg(31); - let (n_persons, n_items, n_dims, latent_dim) = (500usize, 14usize, 2usize, 2usize); - let sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 1.0, - &[], - &[], - 0.0, - &[], - 0, - ); - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Mls2plm, - eps_distance: 1e-8, - }; - let fit_with = |rule: XiRuleKind, points: usize| { - fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Single, - &MarginalConfig { - q_theta: 15, - q_xi: 7, - max_iter: 70, - tol: 5e-2, - xi_rule: rule, - xi_points: points, - xi_seed: 7, - ..Default::default() - }, - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("fit should succeed") - }; - let gh = fit_with(XiRuleKind::GaussHermite, 0); - let qmc = fit_with(XiRuleKind::Halton, 128); - let mc = fit_with(XiRuleKind::MonteCarlo, 256); - for (name, fit) in [("GH", &gh), ("QMC", &qmc), ("MC", &mc)] { - let final_change = fit.loglik_trace[fit.loglik_trace.len() - 1] - - fit.loglik_trace[fit.loglik_trace.len() - 2]; - assert!( - fit.converged, - "{name} did not converge in {} M-steps; final likelihood change={final_change}", - fit.n_iter - ); - assert!( - (0.0..5e-2).contains(&final_change), - "{name} final likelihood change {final_change} did not meet tolerance 0.05" - ); - eprintln!( - "[{name}] converged=true reason=tolerance_met n_iter={}/70 final_change={final_change} tolerance=0.05", - fit.n_iter - ); - } - // the integration rule must not change the answer materially - assert!( - corr(&gh.b, &qmc.b) > 0.98, - "QMC b diverges from GH: {}", - corr(&gh.b, &qmc.b) - ); - assert!( - corr(&gh.b, &mc.b) > 0.95, - "MC b diverges from GH: {}", - corr(&gh.b, &mc.b) - ); - assert!( - (gh.tau.exp() - qmc.tau.exp()).abs() < 0.4, - "gamma mismatch GH={} QMC={}", - gh.tau.exp(), - qmc.tau.exp() - ); - assert_monotone(&gh.loglik_trace); - // QMC/MC traces are deterministic too (fixed point sets), so still monotone - assert_monotone(&qmc.loglik_trace); - assert_monotone(&mc.loglik_trace); -} - -#[test] -fn fipc_recovers_shifted_population_with_anchors() { - use crate::marginal::Anchors; - let mut rng = Lcg(55); - let (n_persons, n_items, n_dims, latent_dim) = (700usize, 12usize, 1usize, 1usize); - // simulate a shifted population theta ~ N(0.8, 1) WITHOUT a latent-space - // term: with gamma > 0 the raw b is not the identified anchor quantity - // (it confounds with the item's map radius), so valid anchors require a - // distance-free generating model here. - let sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 0.0, - &[0.8], - &vec![0; n_persons], - 0.0, - &[], - 0, - ); - // "old calibration": treat the first 6 items' TRUE parameters as anchors - let mut fixed = vec![false; n_items]; - let mut anchor_alpha = vec![0.0_f64; n_items]; - let mut anchor_b = vec![0.0_f64; n_items]; - let anchor_zeta = vec![0.0_f64; n_items * latent_dim]; // unknown -> only used where fixed - for i in 0..6 { - fixed[i] = true; - anchor_alpha[i] = sim.a_true[i].ln(); - anchor_b[i] = sim.b_true[i]; - } - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let anchors = Anchors { - fixed: fixed.clone(), - alpha: anchor_alpha.clone(), - b: anchor_b.clone(), - zeta: anchor_zeta, - tau: Some(-30.0), // anchor calibration had no usable space; freeze gamma ~ 0 - }; - let res = crate::marginal::fit_marginal_anchored( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::SingleFree, - &small_cfg(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - Some(&anchors), - ) - .expect("FIPC fit should succeed"); - // anchored items must not move - for i in 0..6 { - assert_eq!(res.alpha[i], anchor_alpha[i], "anchored alpha moved"); - assert_eq!(res.b[i], anchor_b[i], "anchored b moved"); - } - // the free population mean must absorb the shift - assert!( - res.mu[0] > 0.4 && res.mu[0] < 1.3, - "FIPC population mean should recover ~0.8, got {}", - res.mu[0] - ); - assert_monotone(&res.loglik_trace); -} - -#[test] -fn fipc_requires_anchors_for_free_population() { - let config = ModelConfig { - n_persons: 2, - n_items: 2, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let res = fit_marginal( - &[0.0, 1.0, 1.0, 0.0], - &[true; 4], - &[0, 0], - &config, - &PopulationSpec::SingleFree, - &MarginalConfig::default(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ); - assert!(res.is_err(), "SingleFree without anchors must be rejected"); -} - -#[test] -fn fipc_rejects_unidentified_and_nonfinite_anchor_contracts() { - use crate::marginal::{fit_marginal_anchored, Anchors}; - let config = ModelConfig { - n_persons: 2, - n_items: 4, - n_dims: 2, - latent_dim: 1, - model_type: ModelType::Mirt, - eps_distance: 1e-8, - }; - let y = [0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0]; - let factor_id = [0, 0, 1, 1]; - let mut anchors = Anchors { - fixed: vec![true, true, false, false], - alpha: vec![0.0; 4], - b: vec![0.0; 4], - zeta: vec![0.0; 4], - tau: None, - }; - let err = fit_marginal_anchored( - &y, - &[true; 8], - &factor_id, - &config, - &PopulationSpec::SingleFree, - &MarginalConfig::default(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - Some(&anchors), - ) - .unwrap_err(); - assert!(err.contains("at least two fixed anchor items"), "{err}"); - - anchors.fixed.fill(true); - anchors.tau = Some(f64::NAN); - let err = fit_marginal_anchored( - &y, - &[true; 8], - &factor_id, - &config, - &PopulationSpec::SingleFree, - &MarginalConfig::default(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - Some(&anchors), - ) - .unwrap_err(); - assert!(err.contains("anchor tau must be finite"), "{err}"); -} - -#[test] -fn concurrent_calibration_two_forms_with_anchor_block() { - // Hanson-Beguin common-item design: two groups, each sees its own unique - // block plus a shared anchor block; one concurrent multigroup run. - let mut rng = Lcg(77); - let (n_persons, n_items, n_dims, latent_dim) = (800usize, 15usize, 1usize, 1usize); - let group_id: Vec = (0..n_persons).map(|p| p % 2).collect(); - let mut sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 0.8, - &[0.0, 0.7], - &group_id, - 0.0, - &[], - 0, - ); - // items 0..5 unique to form A, 5..10 anchors, 10..15 unique to form B - for p in 0..n_persons { - for i in 0..n_items { - let unique_a = i < 5; - let unique_b = i >= 10; - if (group_id[p] == 1 && unique_a) || (group_id[p] == 0 && unique_b) { - sim.observed[p * n_items + i] = false; - } - } - } - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let res = fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Multigroup { - group_id, - n_groups: 2, - }, - &small_cfg(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("concurrent calibration should succeed"); - assert!((res.mu[0]).abs() < 1e-12, "reference group stays pinned"); - assert!( - res.mu[1] > 0.3 && res.mu[1] < 1.2, - "concurrent run should recover the ~0.7 group shift, got {}", - res.mu[1] - ); - assert_monotone(&res.loglik_trace); - // every item calibrated despite the structural missingness - assert!(res.b.iter().all(|v| v.is_finite())); -} - -#[test] -fn zero_inflation_recovers_mixing_weight() { - let mut rng = Lcg(2027); - let (n_persons, n_items, n_dims, latent_dim) = (800usize, 12usize, 1usize, 1usize); - let mut sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 0.5, - &[], - &[], - 0.0, - &[], - 0, - ); - // structural zeros: 30% of persons produce all-zero patterns regardless - let pi_true = 0.30; - let n_zero = (n_persons as f64 * pi_true) as usize; - for p in 0..n_zero { - for i in 0..n_items { - sim.y[p * n_items + i] = 0.0; - } - } - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let mcfg = MarginalConfig { - zero_inflation: true, - ..small_cfg() - }; - let res = fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Single, - &mcfg, - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("ZI fit should succeed"); - assert!( - res.pi_zero > 0.15 && res.pi_zero < 0.45, - "pi should approach ~0.30 (injected zeros + natural all-zero patterns), got {}", - res.pi_zero - ); - // injected structural zeros carry high responsibility - let mean_resp_zero: f64 = res.zero_responsibility[..n_zero].iter().sum::() / n_zero as f64; - let mean_resp_rest: f64 = - res.zero_responsibility[n_zero..].iter().sum::() / (n_persons - n_zero) as f64; - assert!( - mean_resp_zero > mean_resp_rest + 0.3, - "structural zeros must get higher responsibility: {mean_resp_zero} vs {mean_resp_rest}" - ); - assert_monotone(&res.loglik_trace); - // without the mixture the fit runs unchanged and reports pi = 0 - let plain = fit_marginal( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Single, - &small_cfg(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("plain fit should succeed"); - assert_eq!(plain.pi_zero, 0.0); - assert!( - res.loglik_trace.last().unwrap() > plain.loglik_trace.last().unwrap(), - "the mixture must improve the marginal loglik on ZI data" - ); -} - -#[test] -fn zero_inflated_multilevel_posteriors_condition_on_the_mixture() { - // One all-zero person favors node 0 under the IRT component, while one - // nonzero person favors node 1. The structural-zero class should stop the - // first person from canceling the cluster evidence supplied by the second. - let lp_irt = [-1.0, -10.0, -10.0, -1.0]; - let all_zero = [true, false]; - let cluster_id = [0usize, 0usize]; - let u_logw = [0.5_f64.ln(), 0.5_f64.ln()]; - - let (cluster_post, engager_post) = - multilevel_context_posteriors(&lp_irt, &all_zero, &cluster_id, 1, &u_logw, Some(0.5)); - - assert!( - cluster_post[1] > 0.999, - "mixture cluster posterior must preserve the nonzero person's node evidence: {cluster_post:?}" - ); - assert!( - (engager_post[0] - 0.5).abs() < 1e-12, - "the all-zero person's engager-conditional context posterior should balance: {engager_post:?}" - ); - assert!( - (engager_post[3] - cluster_post[1]).abs() < 1e-12, - "the nonzero person is necessarily in the engager component" - ); -} - -#[test] -fn item_position_covariate_recovers_delta() { - use crate::marginal::{fit_marginal_full, ItemCovariate}; - let mut rng = Lcg(404); - let (n_persons, n_items, n_dims, latent_dim) = (900usize, 12usize, 1usize, 1usize); - let group_id: Vec = (0..n_persons).map(|p| p % 2).collect(); - // two booklets: item i sits at position i in booklet 0, reversed in booklet 1 - let mut w = vec![0.0_f64; 2 * n_items]; - for i in 0..n_items { - w[i] = i as f64 / (n_items - 1) as f64; - w[n_items + i] = (n_items - 1 - i) as f64 / (n_items - 1) as f64; - } - let delta_true = -0.8; // later positions get harder (fatigue effect) - let mut sim = simulate( - &mut rng, - n_persons, - n_items, - n_dims, - latent_dim, - 0.0, - &[0.0, 0.0], - &group_id, - 0.0, - &[], - 0, - ); - // re-simulate responses with the position effect applied - let mut rng2 = Lcg(405); - for p in 0..n_persons { - let booklet = group_id[p]; - for i in 0..n_items { - let eta = sim.a_true[i] * sim.theta_true[p] - + sim.b_true[i] - + delta_true * w[booklet * n_items + i]; - let prob = 1.0 / (1.0 + (-eta).exp()); - sim.y[p * n_items + i] = if rng2.next_f64() < prob { 1.0 } else { 0.0 }; - } - } - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let cov = ItemCovariate { w, init_delta: 0.0 }; - let res = fit_marginal_full( - &sim.y, - &sim.observed, - &sim.factor_id, - &config, - &PopulationSpec::Multigroup { - group_id, - n_groups: 2, - }, - &small_cfg(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - None, - Some(&cov), - ) - .expect("covariate fit should succeed"); - assert!( - (res.delta - delta_true).abs() < 0.35, - "position coefficient should recover ~{delta_true}, got {}", - res.delta - ); - assert_monotone(&res.loglik_trace); -} - -#[test] -fn covariate_guards() { - use crate::marginal::{fit_marginal_full, ItemCovariate}; - let config = ModelConfig { - n_persons: 2, - n_items: 2, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Uls2plm, - eps_distance: 1e-8, - }; - let cov = ItemCovariate { - w: vec![0.0, 1.0], - init_delta: 0.0, - }; - // single-context covariate without anchors: collinear with b -> rejected - let res = fit_marginal_full( - &[0.0, 1.0, 1.0, 0.0], - &[true; 4], - &[0, 0], - &config, - &PopulationSpec::Single, - &MarginalConfig::default(), - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - None, - Some(&cov), - ); - assert!(res.is_err()); -} - -#[test] -fn bifactor_recovers_general_loadings() { - // dichotomous bifactor (Gibbons-Hedeker): specifics via simple structure, - // general factor via the inner-product kind at latent_dim = 1 - let mut rng = Lcg(606); - let (n_persons, n_items, n_dims, latent_dim) = (900usize, 12usize, 2usize, 1usize); - let factor_id: Vec = (0..n_items).map(|i| i % n_dims).collect(); - let b_true: Vec = (0..n_items).map(|_| -1.0 + 2.0 * rng.next_f64()).collect(); - let lambda_true: Vec = (0..n_items).map(|_| 0.6 + 0.9 * rng.next_f64()).collect(); - let mut y = vec![0.0_f64; n_persons * n_items]; - for p in 0..n_persons { - let g = rng.normal(); - let th: Vec = (0..n_dims).map(|_| rng.normal()).collect(); - for i in 0..n_items { - let eta = th[factor_id[i]] + b_true[i] + lambda_true[i] * g; - let prob = 1.0 / (1.0 + (-eta).exp()); - y[p * n_items + i] = if rng.next_f64() < prob { 1.0 } else { 0.0 }; - } - } - let observed = vec![true; n_persons * n_items]; - let config = ModelConfig { - n_persons, - n_items, - n_dims, - latent_dim, - model_type: ModelType::Bifac2plm, - eps_distance: 1e-8, - }; - let marginal_config = MarginalConfig { - q_theta: 15, - q_xi: 15, - max_iter: 300, - tol: 1e-4, - ..Default::default() - }; - let res = fit_marginal( - &y, - &observed, - &factor_id, - &config, - &PopulationSpec::Single, - &marginal_config, - &PenaltyConfig::lsirm_prior(), - Device::Cpu, - ) - .expect("bifactor fit should succeed"); - assert!(res.converged, "bifactor fit reached the iteration limit"); - assert!(res.n_iter < marginal_config.max_iter); - let final_change = (res.loglik_trace[res.loglik_trace.len() - 1] - - res.loglik_trace[res.loglik_trace.len() - 2]) - .abs(); - assert!( - final_change < marginal_config.tol, - "final likelihood change {final_change} exceeds tolerance {}", - marginal_config.tol - ); - assert_monotone(&res.loglik_trace); - // general-factor loadings recovered up to a global sign (fixed by the - // alignment); check correlation with truth - let lam: Vec = res.zeta.clone(); - let c = corr(&lam, &lambda_true); - assert!(c.abs() > 0.6, "lambda recovery too low: {c}"); - // tau is not a free parameter for the inner kind - assert!( - res.tau < -20.0, - "tau must stay inert for BIFAC2PLM: {}", - res.tau - ); -} - -#[test] -fn m2_calibration_and_local_dependence() { - use crate::fitstats::m2_rmsea2; - use crate::nodes::XiRule; - use crate::scoring::{ItemBank, PriorSpec}; - - // unidimensional 2PL (MIRT kind: no latent-space term), fit by MMLE - let mut rng = Lcg(9091); - let (n_persons, n_items) = (2500usize, 12usize); - let factor_id = vec![0usize; n_items]; - let slope: Vec = (0..n_items).map(|_| 0.8 + 0.7 * rng.next_f64()).collect(); - let b_true: Vec = (0..n_items).map(|i| -1.4 + 0.25 * i as f64).collect(); - let mut y = vec![0.0_f64; n_persons * n_items]; - for p in 0..n_persons { - let th = rng.normal(); - for i in 0..n_items { - let eta = slope[i] * th + b_true[i]; - let prob = 1.0 / (1.0 + (-eta).exp()); - y[p * n_items + i] = if rng.next_f64() < prob { 1.0 } else { 0.0 }; - } - } - let observed = vec![true; n_persons * n_items]; - let config = ModelConfig { - n_persons, - n_items, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Mirt, - eps_distance: 1e-8, - }; - let mcfg = MarginalConfig { - q_theta: 21, - max_iter: 300, - ..Default::default() - }; - let fit = |resp: &[f64]| { - fit_marginal( - resp, - &observed, - &factor_id, - &config, - &PopulationSpec::Single, - &mcfg, - &PenaltyConfig::default(), - Device::Cpu, - ) - .expect("fit should succeed") - }; - let run_m2 = |resp: &[f64], res: &crate::marginal::MarginalResult| { - let bank = ItemBank { - alpha: &res.alpha, - b: &res.b, - zeta: &res.zeta, - tau: res.tau, - factor_id: &factor_id, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - m2_rmsea2( - &bank, - resp, - &observed, - n_persons, - &PriorSpec::standard(1), - 21, - XiRule::GaussHermite { q_xi: 7 }, - ) - .expect("m2 should succeed") - }; - - // well-specified: df = (12 + 66) - 24 = 54; RMSEA2 near zero - let res = fit(&y); - let m2 = run_m2(&y, &res); - assert_eq!(m2.n_moments, 78); - assert_eq!(m2.n_parameters, 24); - assert_eq!(m2.df, 54.0); - assert!( - m2.rmsea2 < 0.03, - "well-specified RMSEA2 too high: {}", - m2.rmsea2 - ); - assert!( - m2.rmsea2_ci_lower <= m2.rmsea2 + 1e-9 && m2.rmsea2 <= m2.rmsea2_ci_upper + 1e-9, - "CI must bracket point estimate: [{}, {}] vs {}", - m2.rmsea2_ci_lower, - m2.rmsea2_ci_upper, - m2.rmsea2 - ); - assert!( - m2.srmsr < 0.05, - "well-specified SRMSR too high: {}", - m2.srmsr - ); - - // inject strong local dependence: item 1 becomes an exact copy of item 0 - let mut y_ld = y.clone(); - for p in 0..n_persons { - y_ld[p * n_items + 1] = y_ld[p * n_items + 0]; - } - let res_ld = fit(&y_ld); - let m2_ld = run_m2(&y_ld, &res_ld); - assert!( - m2_ld.rmsea2 > 0.08, - "local dependence should inflate RMSEA2: {}", - m2_ld.rmsea2 - ); - assert!(m2_ld.m2 > m2.m2, "LD M2 must exceed well-specified M2"); - assert!( - m2_ld.srmsr > m2.srmsr, - "LD SRMSR must exceed well-specified SRMSR" - ); -} diff --git a/tests/unit/marginal_xirule_parse_tests.rs b/tests/unit/marginal_xirule_parse_tests.rs deleted file mode 100644 index dbfe22a67..000000000 --- a/tests/unit/marginal_xirule_parse_tests.rs +++ /dev/null @@ -1,18 +0,0 @@ -use super::XiRuleKind; - -#[test] -fn parse_covers_all_arms() { - assert_eq!(XiRuleKind::parse("gh"), Some(XiRuleKind::GaussHermite)); - assert_eq!( - XiRuleKind::parse("gauss-hermite"), - Some(XiRuleKind::GaussHermite) - ); - assert_eq!(XiRuleKind::parse("qmc"), Some(XiRuleKind::Halton)); - assert_eq!(XiRuleKind::parse("halton"), Some(XiRuleKind::Halton)); - assert_eq!(XiRuleKind::parse("mc"), Some(XiRuleKind::MonteCarlo)); - assert_eq!( - XiRuleKind::parse("monte-carlo"), - Some(XiRuleKind::MonteCarlo) - ); - assert_eq!(XiRuleKind::parse("nope"), None); -} diff --git a/tests/unit/mhrm_tests.rs b/tests/unit/mhrm_tests.rs deleted file mode 100644 index 42d1a1f41..000000000 --- a/tests/unit/mhrm_tests.rs +++ /dev/null @@ -1,1505 +0,0 @@ -use super::*; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64).sqrt() -} - -/// Smoke test: unidimensional 2PL recovery. MH-RM at `D = 1` should recover the loadings and -/// intercepts within Monte-Carlo tolerance (a fixed-seed anchor, NOT exact equality). -#[test] -fn mhrm_recovers_unidimensional_2pl() { - let (n, n_items) = (1500usize, 12usize); - let pattern = vec![1u8; n_items]; // D = 1, every item pure - let mut rng = Lcg(20100507); - let true_a: Vec = (0..n_items).map(|i| 0.8 + 0.1 * (i % 5) as f64).collect(); - let true_b: Vec = (0..n_items).map(|i| -0.8 + 0.15 * i as f64).collect(); - let mut theta = vec![0.0f64; n]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let base = true_a[i] * theta[p] + true_b[i]; - let prob = 1.0 / (1.0 + (-base).exp()); - y[p * n_items + i] = if rng.next_f64() < prob { 1 } else { 0 }; - } - } - let cfg = MhrmConfig { - max_cycles: 1200, - burn_in: 150, - mh_steps: 8, - seed: 424242, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, 1, &cfg).unwrap(); - assert_eq!(res.n_dims, 1); - assert_eq!(res.loading.len(), n_items); - assert_eq!(res.n_parameters, n_items + n_items); - // reflection canonical: largest pure anchor positive - assert!(res.loading.iter().cloned().fold(f64::MIN, f64::max) > 0.0); - // acceptance in a sane band after tuning - assert!( - res.acceptance_rate > 0.1 && res.acceptance_rate < 0.7, - "acceptance {}", - res.acceptance_rate - ); - // recover loadings and intercepts within MC tolerance - assert!( - rmse(&res.loading, &true_a) < 0.2, - "loading RMSE {} loadings {:?}", - rmse(&res.loading, &true_a), - res.loading - ); - assert!( - rmse(&res.intercept, &true_b) < 0.2, - "intercept RMSE {}", - rmse(&res.intercept, &true_b) - ); - // trait EAP correlates with the truth - let th: Vec = (0..n).map(|p| res.theta[p]).collect(); - let mt = th.iter().sum::() / n as f64; - let mtt = theta.iter().sum::() / n as f64; - let cov: f64 = (0..n).map(|p| (th[p] - mt) * (theta[p] - mtt)).sum(); - let vt: f64 = th.iter().map(|x| (x - mt).powi(2)).sum(); - let vtt: f64 = theta.iter().map(|x| (x - mtt).powi(2)).sum(); - assert!( - cov / (vt * vtt).sqrt() > 0.8, - "theta corr {}", - cov / (vt * vtt).sqrt() - ); - // Louis SEs finite and positive - assert!(res.se_loading.iter().all(|s| s.is_finite() && *s > 0.0)); -} - -fn corr(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - let ma = a.iter().sum::() / n; - let mb = b.iter().sum::() / n; - let (mut sab, mut saa, mut sbb) = (0.0, 0.0, 0.0); - for i in 0..a.len() { - let (da, db) = (a[i] - ma, b[i] - mb); - sab += da * db; - saa += da * da; - sbb += db * db; - } - sab / (saa * sbb).sqrt() -} - -fn item_loglik( - params: &[f64], - dims: &[usize], - theta: &[f64], - y: &[usize], - np: usize, - nd: usize, -) -> f64 { - let li = dims.len(); - let mut ll = 0.0; - for p in 0..np { - let mut base = params[li]; - for (t, &d) in dims.iter().enumerate() { - base += params[t] * theta[p * nd + d]; - } - let pp = 1.0 / (1.0 + (-base).exp()); - ll += if y[p] == 1 { pp.ln() } else { (1.0 - pp).ln() }; - } - ll -} - -/// Deterministic anchor: the per-item score and information returned by `item_score_info` are -/// pinned against finite differences of the complete-data logistic log-likelihood, on ONE D=2 -/// CROSS-loader item with ASYMMETRIC params (a NEGATIVE loading) at fixed asymmetric traits. A -/// sign flip in the residual, a transposed information layout, or a dropped dims-map entry all -/// fail here — none of which a centered/symmetric value-recovery test would catch. -#[test] -fn mhrm_score_and_info_match_finite_difference() { - let nd = 2usize; - let dims = vec![0usize, 1usize]; - let params = vec![0.8f64, -0.5, 0.3]; // [a0, a1, b] — a1 negative - let theta = vec![0.5, -1.0, -0.7, 0.4, 1.2, 0.9]; // 3 persons x 2 dims (asymmetric) - let y = vec![1usize, 0, 1]; - let np = 3usize; - let pi = 3usize; - let (s, h, hobs) = item_score_info( - MhrmModel::TwoPl, - ¶ms, - &dims, - &theta, - &y, - None, - 0, - np, - 1, - nd, - ); - // score[t] = d loglik / d params[t] - let eps = 1e-6; - for t in 0..pi { - let mut pp = params.clone(); - pp[t] += eps; - let mut pm = params.clone(); - pm[t] -= eps; - let fd = (item_loglik(&pp, &dims, &theta, &y, np, nd) - - item_loglik(&pm, &dims, &theta, &y, np, nd)) - / (2.0 * eps); - assert!((s[t] - fd).abs() < 1e-4, "score[{t}] {} vs FD {}", s[t], fd); - } - // info[a][b] = -d^2 loglik / d params[a] d params[b] = sum_p w_p x_a x_b (symmetric, PD) - let hh = 1e-3; - for a in 0..pi { - for b in 0..pi { - let mut fpp = params.clone(); - fpp[a] += hh; - fpp[b] += hh; - let mut fpm = params.clone(); - fpm[a] += hh; - fpm[b] -= hh; - let mut fmp = params.clone(); - fmp[a] -= hh; - fmp[b] += hh; - let mut fmm = params.clone(); - fmm[a] -= hh; - fmm[b] -= hh; - let d2 = (item_loglik(&fpp, &dims, &theta, &y, np, nd) - - item_loglik(&fpm, &dims, &theta, &y, np, nd) - - item_loglik(&fmp, &dims, &theta, &y, np, nd) - + item_loglik(&fmm, &dims, &theta, &y, np, nd)) - / (4.0 * hh * hh); - assert!( - (h[a * pi + b] - (-d2)).abs() < 1e-2, - "info[{a}][{b}] {} vs -FDhess {}", - h[a * pi + b], - -d2 - ); - assert!( - (h[a * pi + b] - h[b * pi + a]).abs() < 1e-12, - "info symmetric" - ); - } - } - // non-trivial layout: the cross term is genuinely nonzero (asymmetric traits) - assert!(h[1].abs() > 0.05, "off-diag info nonzero: {}", h[1]); - // Louis missing-information term: hobs = sum_p (w_p - r_p^2) X X' = H - sum_p r_p^2 X X'. - // Pin the SIGN of the r^2 subtraction (the mutant `w + r^2` inverts it) by an INDEPENDENT - // re-sum of the per-person score outer product r_p^2 X_p X_p'. - let mut r2_outer = vec![0.0f64; pi * pi]; - for p in 0..np { - let mut base = params[dims.len()]; - for (t, &d) in dims.iter().enumerate() { - base += params[t] * theta[p * nd + d]; - } - let pp = 1.0 / (1.0 + (-base).exp()); - let r2 = (y[p] as f64 - pp).powi(2); - let x = [theta[p * nd], theta[p * nd + 1], 1.0]; - for a in 0..pi { - for b in 0..pi { - r2_outer[a * pi + b] += r2 * x[a] * x[b]; - } - } - } - for idx in 0..pi * pi { - assert!( - (hobs[idx] - (h[idx] - r2_outer[idx])).abs() < 1e-9, - "louis missing-info sign: hobs[{idx}] {} vs H-r2 {}", - hobs[idx], - h[idx] - r2_outer[idx] - ); - } -} - -/// White-box anchor on the Robbins-Monro gain schedule: constant `burn_in_gain` through burn-in, -/// then `1/(k - burn_in)^alpha` (an off-by-one at the boundary is a classic bug the recovery -/// tests would not localize). -#[test] -fn mhrm_gain_schedule() { - let (b, g0) = (10usize, 0.8f64); - assert_eq!(gain_at(1, b, g0, 1.0), g0); - assert_eq!(gain_at(b, b, g0, 1.0), g0); // last burn-in cycle is still constant gain - assert_eq!(gain_at(b + 1, b, g0, 1.0), 1.0); // first convergence-stage cycle: 1/1 - assert_eq!(gain_at(b + 4, b, g0, 1.0), 0.25); // 1/4 - assert!((gain_at(b + 4, b, g0, 0.5) - 0.5).abs() < 1e-12); // 1/4^0.5 = 0.5 -} - -/// Reduction anchor: at `D = 1`, MH-RM agrees with the established deterministic unidimensional -/// MMLE (`mmle::fit_mmle_2pl`) within Monte-Carlo tolerance (NOT bit-exact — MH-RM is stochastic). -#[test] -fn mhrm_reduces_to_mmle_2pl_at_d1() { - use crate::mmle::{fit_mmle_2pl, MmleConfig}; - let (n, n_items) = (1200usize, 10usize); - let pattern = vec![1u8; n_items]; - let mut rng = Lcg(77); - let a_t: Vec = (0..n_items).map(|i| 0.9 + 0.08 * (i % 4) as f64).collect(); - let b_t: Vec = (0..n_items).map(|i| -0.6 + 0.13 * i as f64).collect(); - let mut th = vec![0.0f64; n]; - for v in th.iter_mut() { - *v = rng.normal(); - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let pr = 1.0 / (1.0 + (-(a_t[i] * th[p] + b_t[i])).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let cfg = MhrmConfig { - max_cycles: 1200, - burn_in: 150, - mh_steps: 8, - seed: 9, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, 1, &cfg).unwrap(); - let yf: Vec = y.iter().map(|&v| v as f64).collect(); - let obs = vec![true; n * n_items]; - let m = fit_mmle_2pl(&yf, &obs, n, n_items, &MmleConfig::default()); - assert!( - rmse(&res.loading, &m.a) < 0.12, - "MH-RM vs MMLE loading RMSE {}", - rmse(&res.loading, &m.a) - ); - assert!( - rmse(&res.intercept, &m.b) < 0.12, - "MH-RM vs MMLE intercept RMSE {}", - rmse(&res.intercept, &m.b) - ); -} - -/// Headline capability: `D = 6` confirmatory 2PL. The `q^D` Gauss-Hermite grid (`21^6 ~ 8.6e7`) -/// and even the QMC E-step are infeasible at this dimensionality; MH-RM's stochastic imputation -/// is `D`-agnostic. Simple structure (3 pure anchors per dimension) plus two cross-loaders, one -/// genuinely NEGATIVE — recovered with the correct sign. -#[test] -fn mhrm_recovers_high_dim_d6() { - let (n_dims, n) = (6usize, 2500usize); - let n_items = 20usize; - let mut pattern = vec![0u8; n_items * n_dims]; - for i in 0..18 { - pattern[i * n_dims + i / 3] = 1; // items 0..17: 3 pure anchors per dimension - } - pattern[18 * n_dims] = 1; - pattern[18 * n_dims + 3] = 1; // item18 cross-loads dims 0 and 3 - pattern[19 * n_dims + 1] = 1; - pattern[19 * n_dims + 4] = 1; // item19 cross-loads dims 1 and 4 - let mut a_t = vec![0.0f64; n_items * n_dims]; - for i in 0..18 { - a_t[i * n_dims + i / 3] = 0.9 + 0.1 * (i % 3) as f64; - } - a_t[18 * n_dims] = 1.0; - a_t[18 * n_dims + 3] = -0.7; // NEGATIVE cross-loader - a_t[19 * n_dims + 1] = 0.8; - a_t[19 * n_dims + 4] = 0.6; - let b_t: Vec = (0..n_items).map(|i| -0.5 + 0.1 * (i % 7) as f64).collect(); - let mut rng = Lcg(60606); - let mut th = vec![0.0f64; n * n_dims]; - for v in th.iter_mut() { - *v = rng.normal(); - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = b_t[i]; - for d in 0..n_dims { - base += a_t[i * n_dims + d] * th[p * n_dims + d]; - } - let pr = 1.0 / (1.0 + (-base).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let cfg = MhrmConfig { - max_cycles: 1000, - burn_in: 200, - mh_steps: 6, - seed: 13, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert_eq!(res.n_dims, 6); - for i in 0..n_items { - for d in 0..n_dims { - if pattern[i * n_dims + d] == 0 { - assert_eq!(res.loading[i * n_dims + d], 0.0); - } - } - } - let (mut se2, mut cnt) = (0.0, 0usize); - for idx in 0..n_items * n_dims { - if pattern[idx] == 1 { - se2 += (res.loading[idx] - a_t[idx]).powi(2); - cnt += 1; - } - } - let load_rmse = (se2 / cnt as f64).sqrt(); - assert!(load_rmse < 0.22, "D=6 on-pattern loading RMSE {load_rmse}"); - assert!( - res.loading[18 * n_dims + 3] < -0.3, - "negative cross-loader {}", - res.loading[18 * n_dims + 3] - ); - for d in 0..n_dims { - let est: Vec = (0..n).map(|p| res.theta[p * n_dims + d]).collect(); - let tru: Vec = (0..n).map(|p| th[p * n_dims + d]).collect(); - assert!( - corr(&est, &tru) > 0.5, - "dim {d} theta corr {}", - corr(&est, &tru) - ); - } -} - -/// The reflection canonicalization FIRES and is WITNESSED. dim0 has a WEAK reverse-keyed SOLE -/// pure anchor (item0, true `-0.7`) and a STRONG positively-keyed cross-loader (item1, dim0 -/// `+1.7`) that dominates the axis orientation, so raw MH-RM lands the anchor NEGATIVE and -/// canonicalization must flip dim0: the anchor ends positive, the co-loader negative, and theta_0 -/// correlates NEGATIVELY with the truth. Disabling the flip (in-loop + final) fails all three. -#[test] -fn mhrm_reflection_fires_on_negative_anchor() { - let (n_dims, n) = (2usize, 5000usize); - let n_items = 4usize; - // item0 pure d0 (sole d0 anchor), item1 cross d0/d1, item2/3 pure d1 - let pattern = vec![1u8, 0, 1, 1, 0, 1, 0, 1]; - let mut a_t = vec![0.0f64; n_items * n_dims]; - a_t[0] = -0.7; // weak reverse-keyed pure d0 anchor - a_t[1 * n_dims] = 1.7; // strong positive cross-loader on d0 (sets the axis) - a_t[1 * n_dims + 1] = 0.6; - a_t[2 * n_dims + 1] = 1.2; - a_t[3 * n_dims + 1] = 1.0; - let b_t = vec![0.2f64, -0.1, 0.3, -0.2]; - let mut rng = Lcg(1357); - let mut th = vec![0.0f64; n * n_dims]; - for v in th.iter_mut() { - *v = rng.normal(); - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = b_t[i]; - for d in 0..n_dims { - base += a_t[i * n_dims + d] * th[p * n_dims + d]; - } - let pr = 1.0 / (1.0 + (-base).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let cfg = MhrmConfig { - max_cycles: 1000, - burn_in: 200, - mh_steps: 8, - seed: 24, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert!( - res.loading[0] > 0.3, - "reflected anchor positive: {}", - res.loading[0] - ); - assert!( - res.loading[1 * n_dims] < -0.5, - "co-loader flipped negative: {}", - res.loading[1 * n_dims] - ); - let th0: Vec = (0..n).map(|p| res.theta[p * n_dims]).collect(); - let tt0: Vec = (0..n).map(|p| th[p * n_dims]).collect(); - let th1: Vec = (0..n).map(|p| res.theta[p * n_dims + 1]).collect(); - let tt1: Vec = (0..n).map(|p| th[p * n_dims + 1]).collect(); - assert!( - corr(&th0, &tt0) < -0.4, - "flipped-dim theta corr negative: {}", - corr(&th0, &tt0) - ); - assert!( - corr(&th1, &tt1) > 0.4, - "unflipped-dim theta corr positive: {}", - corr(&th1, &tt1) - ); -} - -/// Correlated-Sigma MH-RM (Cai, 2010b): with `estimate_corr` the free latent correlation matrix -/// `Phi` is recovered from `theta ~ MVN(0, Phi)`. Covers a POSITIVE, a near-PD-boundary (D=3, -/// rho=0.5), and a NEGATIVE correlation (sign correctness); confirms `Phi` stays a valid PD -/// correlation matrix (unit diagonal) and `n_parameters` counts the `D(D-1)/2` correlations. -#[test] -fn mhrm_correlated_recovers_known_phi() { - for &(n_dims, rho, n) in &[ - (2usize, 0.4f64, 3000usize), - (3usize, 0.5f64, 3500usize), - (2usize, -0.5f64, 3000usize), - ] { - // exchangeable Phi - let mut phi = vec![rho; n_dims * n_dims]; - for a in 0..n_dims { - phi[a * n_dims + a] = 1.0; - } - let l = chol_lower(&phi, n_dims).expect("Phi PD"); - let per = 4usize; - let n_items = per * n_dims; - let mut pattern = vec![0u8; n_items * n_dims]; - let mut a_t = vec![0.0f64; n_items * n_dims]; - for d in 0..n_dims { - for a in 0..per { - let i = d * per + a; - pattern[i * n_dims + d] = 1; - a_t[i * n_dims + d] = 1.0 + 0.1 * a as f64; - } - } - let b_t: Vec = (0..n_items).map(|i| -0.4 + 0.1 * (i % 5) as f64).collect(); - let mut rng = Lcg(0x00C0FFEE ^ ((n_dims as u64) << 8) ^ ((rho < 0.0) as u64)); - // theta_p = L z_p ~ MVN(0, Phi) - let mut th = vec![0.0f64; n * n_dims]; - for p in 0..n { - let z: Vec = (0..n_dims).map(|_| rng.normal()).collect(); - for a in 0..n_dims { - let mut v = 0.0; - for b in 0..=a { - v += l[a * n_dims + b] * z[b]; - } - th[p * n_dims + a] = v; - } - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = b_t[i]; - for d in 0..n_dims { - base += a_t[i * n_dims + d] * th[p * n_dims + d]; - } - let pr = 1.0 / (1.0 + (-base).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let cfg = MhrmConfig { - max_cycles: 1600, - burn_in: 350, - mh_steps: 8, - estimate_corr: true, - seed: 42, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert_eq!(res.corr.len(), n_dims * n_dims); - // valid correlation matrix: unit diagonal, symmetric, PD - for a in 0..n_dims { - assert!( - (res.corr[a * n_dims + a] - 1.0).abs() < 1e-9, - "unit diagonal" - ); - for b in 0..n_dims { - assert!((res.corr[a * n_dims + b] - res.corr[b * n_dims + a]).abs() < 1e-12); - } - } - assert!(chol_lower(&res.corr, n_dims).is_some(), "recovered Phi PD"); - // recover the off-diagonals (sign + magnitude) within MC tolerance - for a in 0..n_dims { - for b in a + 1..n_dims { - let est = res.corr[a * n_dims + b]; - assert!( - (est - rho).abs() < 0.12, - "D={n_dims} rho={rho} corr[{a}][{b}]={est}" - ); - } - } - assert_eq!( - res.n_parameters, - n_items + n_items + n_dims * (n_dims - 1) / 2 - ); - } -} - -/// Validation guards constructed non-vacuously (each input trips the INTENDED guard, not an -/// earlier one). -#[test] -fn mhrm_validates_and_structural_invariants() { - let (n, n_items, n_dims) = (60usize, 4usize, 2usize); - let pattern = vec![1u8, 0, 1, 0, 0, 1, 0, 1]; // pure anchors on both dims - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - y[p * n_items + i] = (p + i) % 2; // non-degenerate mixed responses - } - } - let short = MhrmConfig { - max_cycles: 30, - burn_in: 5, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &short).unwrap(); - assert_eq!(res.n_parameters, 4 + 4); // 4 loadings + 4 intercepts (no correlations) - assert_eq!(res.se_loading.len(), n_items * n_dims); - // estimate_corr=false -> Phi is EXACTLY the identity (orthogonal factors) - assert_eq!(res.corr, vec![1.0, 0.0, 0.0, 1.0]); - let mut mask = vec![true; n * n_items]; - mask[0] = false; - assert!(fit_mhrm(&y, Some(&mask), &pattern, n, n_items, n_dims, &short).is_ok()); - let without_se = fit_mhrm( - &y, - None, - &pattern, - n, - n_items, - n_dims, - &MhrmConfig { - estimate_se: false, - ..short - }, - ) - .unwrap(); - assert!(without_se.se_loading.is_empty()); - assert!(without_se.se_intercept.is_empty()); - // no pure anchor on any dimension (every item loads both dims) - let all_both = vec![1u8; n_items * n_dims]; - assert!(fit_mhrm(&y, None, &all_both, n, n_items, n_dims, &short).is_err()); - // non-binary response where observed - let mut ybad = y.clone(); - ybad[0] = 2; - assert!(fit_mhrm(&ybad, None, &pattern, n, n_items, n_dims, &short).is_err()); - // burn_in >= max_cycles - let bad = MhrmConfig { - max_cycles: 10, - burn_in: 10, - ..MhrmConfig::default() - }; - assert!(fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &bad).is_err()); - // gain_exponent out of (0.5, 1] Robbins-Monro band - let badgain = MhrmConfig { - gain_exponent: 0.3, - ..short - }; - assert!(fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &badgain).is_err()); - // n_dims exceeds MHRM_MAX_DIMS (=64) — the n_dims guard is checked before pattern length - let big_pat = vec![1u8; n_items * 65]; - assert!(fit_mhrm(&y, None, &big_pat, n, n_items, 65, &short).is_err()); - // y length mismatch (cells != y.len()) - let y_short = vec![0usize; n * n_items - 1]; - assert!(fit_mhrm(&y_short, None, &pattern, n, n_items, n_dims, &short).is_err()); - // loading_pattern entry other than 0/1 (correct length, so the >1 guard is the sole trip) - let mut pat_bad = pattern.clone(); - pat_bad[0] = 2; - assert!(fit_mhrm(&y, None, &pat_bad, n, n_items, n_dims, &short).is_err()); - - assert!(validate(&[], None, &[], 0, 1, 1, &short).is_err()); - assert!(validate(&[], None, &[1], MHRM_MAX_CELLS + 1, 1, 1, &short,).is_err()); - assert!(validate(&y, Some(&[]), &pattern, n, n_items, n_dims, &short).is_err()); - assert!(validate( - &y, - None, - &pattern[..pattern.len() - 1], - n, - n_items, - n_dims, - &short - ) - .is_err()); - let mut zero_row = pattern.clone(); - zero_row[0] = 0; - assert!(validate(&y, None, &zero_row, n, n_items, n_dims, &short).is_err()); - - for invalid in [ - MhrmConfig { - mh_steps: 0, - ..short - }, - MhrmConfig { - proposal_sd: 0.0, - ..short - }, - MhrmConfig { - target_accept: 2.0, - ..short - }, - MhrmConfig { - burn_in_gain: 0.0, - ..short - }, - MhrmConfig { window: 0, ..short }, - MhrmConfig { tol: 0.0, ..short }, - MhrmConfig { - ridge: 0.0, - ..short - }, - ] { - assert!(validate(&y, None, &pattern, n, n_items, n_dims, &invalid).is_err()); - } - - let gpcm_y = [0usize, 0, 1, 1, 2, 2]; - let gpcm_pattern = [1u8, 1]; - let gpcm_observed = [false, true, false, true, false, true]; - let gpcm_cfg = MhrmConfig { - max_cycles: 30, - burn_in: 5, - model: MhrmModel::Gpcm { n_cat: 3 }, - ..MhrmConfig::default() - }; - assert!(validate( - &gpcm_y, - Some(&gpcm_observed), - &gpcm_pattern, - 3, - 2, - 1, - &gpcm_cfg, - ) - .is_err()); - - for model in [MhrmModel::TwoPl, MhrmModel::Gpcm { n_cat: 3 }] { - let params = if model == MhrmModel::TwoPl { - vec![1.0, 0.0] - } else { - vec![1.0, 0.0, 0.0] - }; - let (score, information, observed_information) = item_score_info( - model, - ¶ms, - &[0], - &[0.0], - &[0], - Some(&[false]), - 0, - 1, - 1, - 1, - ); - assert!(score.iter().all(|&value| value == 0.0)); - assert!(information.iter().all(|&value| value == 0.0)); - assert!(observed_information.iter().all(|&value| value == 0.0)); - } -} - -#[test] -fn mhrm_private_numeric_helpers_cover_reflection_backtracking_and_se_fallbacks() { - let immediate = backtracked_corr_step(&[0.0], 1.0, &[0.1], 2); - assert_eq!(immediate, vec![0.1]); - let stepped = backtracked_corr_step(&[0.0, 0.0, 0.0], 1.0, &[1.0, 1.0, -1.0], 3); - assert_eq!(stepped, vec![0.25, 0.25, -0.25]); - let unchanged = backtracked_corr_step(&[0.0, 0.0, 0.0], 1.0, &[1e9, 1e9, -1e9], 3); - assert_eq!(unchanged, vec![0.0; 3]); - - let dims_of = vec![vec![0], vec![0, 1]]; - let mut loading = vec![-2.0, 0.0, 1.0, 3.0]; - let mut theta = vec![1.0, 2.0, 3.0, 4.0]; - let mut offdiag = vec![0.4]; - canonicalize_final_dimension( - 0, - 2, - 2, - 2, - &dims_of, - &mut loading, - &mut theta, - &mut offdiag, - true, - ); - assert_eq!(loading, vec![2.0, 0.0, -1.0, 3.0]); - assert_eq!(theta, vec![-1.0, 2.0, -3.0, 4.0]); - assert_eq!(offdiag, vec![-0.4]); - canonicalize_final_dimension( - 1, - 2, - 2, - 2, - &dims_of, - &mut loading, - &mut theta, - &mut offdiag, - false, - ); - assert_eq!(standard_error_from_variance(4.0), 2.0); - assert!(standard_error_from_variance(-1.0).is_nan()); -} - -// ================================ GPCM MH-RM (Muraki, 1992) ================================ - -/// GPCM category probabilities at a scalar `base = sum_d a_d theta_d`: `psi_k = k*base + step_k` -/// (`step_0 = 0`), `P_k = softmax_k(psi)`. -fn gpcm_probs(base: f64, steps: &[f64], n_cat: usize) -> Vec { - let mut psi = vec![0.0f64; n_cat]; - let mut m = f64::NEG_INFINITY; - for k in 0..n_cat { - psi[k] = (k as f64) * base + if k == 0 { 0.0 } else { steps[k - 1] }; - if psi[k] > m { - m = psi[k]; - } - } - let mut z = 0.0; - for p in psi.iter_mut() { - *p = (*p - m).exp(); - z += *p; - } - for p in psi.iter_mut() { - *p /= z; - } - psi -} - -/// Inverse-CDF category draw from a probability vector and a uniform `u`. -fn gpcm_sample(probs: &[f64], u: f64) -> usize { - let mut acc = 0.0; - for (k, &p) in probs.iter().enumerate() { - acc += p; - if u < acc { - return k; - } - } - probs.len() - 1 -} - -/// Complete-data GPCM item log-likelihood at fixed traits (the FD target for the score/Hessian -/// anchor). `params = [a_d for d in dims, step_1..step_{K-1}]`. -fn gpcm_item_loglik( - params: &[f64], - dims: &[usize], - theta: &[f64], - y: &[usize], - np: usize, - nd: usize, - n_cat: usize, -) -> f64 { - let li = dims.len(); - let mut ll = 0.0; - for p in 0..np { - let mut base = 0.0; - for (t, &d) in dims.iter().enumerate() { - base += params[t] * theta[p * nd + d]; - } - let mut m = f64::NEG_INFINITY; - let mut psi = vec![0.0f64; n_cat]; - for k in 0..n_cat { - psi[k] = (k as f64) * base + if k == 0 { 0.0 } else { params[li + k - 1] }; - if psi[k] > m { - m = psi[k]; - } - } - let mut z = 0.0; - for k in 0..n_cat { - z += (psi[k] - m).exp(); - } - ll += psi[y[p]] - (m + z.ln()); - } - ll -} - -/// Deterministic anchor for the GPCM (Muraki, 1992) per-item score and the CLOSED-FORM multinomial -/// information, on ONE `D = 2` CROSS-loader item with an ASYMMETRIC NEGATIVE loading and -/// NON-MONOTONE (unordered) steps at fixed asymmetric traits, `K = 3`. The score is pinned against -/// finite differences of the complete-data GPCM log-likelihood, and the information block against -/// the NEGATIVE FD Hessian — which equals the exact multinomial Hessian since it is -/// data-independent given `theta` (the mutant that uses the BHHH score cross-product as the -/// information fails here, and would make the Louis SE degenerate). A sign flip in the residual, a -/// transposed/dropped design-matrix slot, or an over-collapsed step block all fail here — none of -/// which a centered/symmetric value-recovery test would localize. The Louis block is pinned to -/// `H - sum_p s_p s_p'` by an INDEPENDENT per-person score outer-product re-sum (the mutant -/// `H + sum s s'` inverts the sign of the missing-information subtraction). -#[test] -fn gpcm_mhrm_score_and_info_match_finite_difference() { - let nd = 2usize; - let n_cat = 3usize; - let dims = vec![0usize, 1usize]; - // [a0, a1, step_1, step_2]; a1 NEGATIVE, steps non-monotone (0.9 then -0.4 -> not increasing) - let params = vec![0.9f64, -0.6, 0.9, -0.4]; - let pi = dims.len() + (n_cat - 1); // 4 - // 4 persons, asymmetric traits, responses spanning all 3 categories - let theta = vec![0.5, -1.0, -0.7, 0.4, 1.2, 0.9, -0.3, -1.1]; - let y = vec![2usize, 0, 1, 2]; - let np = 4usize; - let (s, h, hobs) = item_score_info( - MhrmModel::Gpcm { n_cat }, - ¶ms, - &dims, - &theta, - &y, - None, - 0, - np, - 1, - nd, - ); - assert_eq!(s.len(), pi); - // score[t] = d loglik / d params[t] - let eps = 1e-6; - for t in 0..pi { - let mut pp = params.clone(); - pp[t] += eps; - let mut pm = params.clone(); - pm[t] -= eps; - let fd = (gpcm_item_loglik(&pp, &dims, &theta, &y, np, nd, n_cat) - - gpcm_item_loglik(&pm, &dims, &theta, &y, np, nd, n_cat)) - / (2.0 * eps); - assert!( - (s[t] - fd).abs() < 1e-4, - "gpcm score[{t}] {} vs FD {}", - s[t], - fd - ); - } - // info[a][b] = -d^2 loglik / d params[a] d params[b] (exact multinomial Hessian; symmetric, PD) - let hh = 1e-3; - for a in 0..pi { - for b in 0..pi { - let mut fpp = params.clone(); - fpp[a] += hh; - fpp[b] += hh; - let mut fpm = params.clone(); - fpm[a] += hh; - fpm[b] -= hh; - let mut fmp = params.clone(); - fmp[a] -= hh; - fmp[b] += hh; - let mut fmm = params.clone(); - fmm[a] -= hh; - fmm[b] -= hh; - let d2 = (gpcm_item_loglik(&fpp, &dims, &theta, &y, np, nd, n_cat) - - gpcm_item_loglik(&fpm, &dims, &theta, &y, np, nd, n_cat) - - gpcm_item_loglik(&fmp, &dims, &theta, &y, np, nd, n_cat) - + gpcm_item_loglik(&fmm, &dims, &theta, &y, np, nd, n_cat)) - / (4.0 * hh * hh); - assert!( - (h[a * pi + b] - (-d2)).abs() < 1e-2, - "gpcm info[{a}][{b}] {} vs -FDhess {}", - h[a * pi + b], - -d2 - ); - assert!( - (h[a * pi + b] - h[b * pi + a]).abs() < 1e-12, - "info symmetric" - ); - } - } - // non-trivial layout: the a0-a1 cross term AND an a0-step1 cross term are genuinely nonzero - assert!(h[1].abs() > 0.05, "a0-a1 cross-info nonzero: {}", h[1]); - assert!(h[2].abs() > 0.02, "a0-step1 cross-info nonzero: {}", h[2]); - // Louis: hobs = H - sum_p s_p s_p'. Re-sum the per-person score outer product INDEPENDENTLY - // (design J[k][t= 1 { - sp[dims.len() + k - 1] += resid; - } - } - for a in 0..pi { - for b in 0..pi { - ss[a * pi + b] += sp[a] * sp[b]; - } - } - } - for idx in 0..pi * pi { - assert!( - (hobs[idx] - (h[idx] - ss[idx])).abs() < 1e-9, - "gpcm louis missing-info sign: hobs[{idx}] {} vs H-ss {}", - hobs[idx], - h[idx] - ss[idx] - ); - } -} - -/// Reduction anchor: at `D = 1`, GPCM MH-RM agrees with the deterministic unidimensional GPCM MMLE -/// (`poly::fit_poly_unidim(PolyModel::Gpcm)`, Bock-Aitkin quadrature) within Monte-Carlo tolerance. -/// NOT bit-exact — MH-RM is stochastic and uses an unconstrained slope (vs `fit_poly_unidim`'s -/// `log_a > 0`), so it is up to reflection (both land positive here on all-positive truth). -#[test] -fn gpcm_mhrm_reduces_to_poly_unidim_at_d1() { - use crate::poly::{fit_poly_unidim, PolyModel}; - let (n, n_items, n_cat) = (1600usize, 8usize, 3usize); - let pattern = vec![1u8; n_items]; - let a_t: Vec = (0..n_items).map(|i| 0.9 + 0.08 * (i % 4) as f64).collect(); - // non-monotone (unordered) steps per item - let step_t: Vec<[f64; 2]> = (0..n_items) - .map(|i| [0.6 - 0.1 * (i % 3) as f64, -0.5 + 0.12 * (i % 4) as f64]) - .collect(); - let mut rng = Lcg(2718281); - let mut th = vec![0.0f64; n]; - for v in th.iter_mut() { - *v = rng.normal(); - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let probs = gpcm_probs(a_t[i] * th[p], &step_t[i], n_cat); - y[p * n_items + i] = gpcm_sample(&probs, rng.next_f64()); - } - } - let cfg = MhrmConfig { - max_cycles: 1200, - burn_in: 180, - mh_steps: 8, - model: MhrmModel::Gpcm { n_cat }, - seed: 31, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, 1, &cfg).unwrap(); - assert_eq!(res.n_cat, n_cat); - assert!(res.intercept.is_empty()); - assert_eq!(res.step.len(), n_items * (n_cat - 1)); - assert_eq!(res.n_parameters, n_items + n_items * (n_cat - 1)); - // slopes land positive after canonicalization - assert!(res.loading.iter().all(|&a| a > 0.0)); - let det = fit_poly_unidim(&y, None, n, n_items, n_cat, PolyModel::Gpcm, 41, 200, 1e-6).unwrap(); - assert!( - rmse(&res.loading, &det.slope) < 0.15, - "GPCM MH-RM vs MMLE slope RMSE {}", - rmse(&res.loading, &det.slope) - ); - let det_steps: Vec = det - .cat_params - .iter() - .flat_map(|c| c.iter().copied()) - .collect(); - assert_eq!(det_steps.len(), res.step.len()); - assert!( - rmse(&res.step, &det_steps) < 0.2, - "GPCM MH-RM vs MMLE step RMSE {}", - rmse(&res.step, &det_steps) - ); -} - -/// Headline GPCM capability: `D = 5` confirmatory GPCM. The `q^D` Gauss-Hermite grid (`21^5`) and -/// the QMC E-step are infeasible; MH-RM's stochastic imputation is `D`-agnostic. Simple structure -/// (3 pure anchors per dimension) plus one genuinely NEGATIVE cross-loader, non-monotone steps, -/// `K = 3` — loadings and steps recovered with the correct sign. -#[test] -fn gpcm_mhrm_recovers_high_dim_d5() { - let (n_dims, n, n_cat) = (5usize, 2200usize, 3usize); - let n_items = 16usize; - let mut pattern = vec![0u8; n_items * n_dims]; - for i in 0..15 { - pattern[i * n_dims + i / 3] = 1; // items 0..14: 3 pure anchors per dimension - } - pattern[15 * n_dims] = 1; - pattern[15 * n_dims + 2] = 1; // item15 cross-loads dims 0 and 2 - let mut a_t = vec![0.0f64; n_items * n_dims]; - for i in 0..15 { - a_t[i * n_dims + i / 3] = 0.9 + 0.1 * (i % 3) as f64; - } - a_t[15 * n_dims] = 1.0; - a_t[15 * n_dims + 2] = -0.7; // NEGATIVE cross-loader - let step_t: Vec<[f64; 2]> = (0..n_items) - .map(|i| [0.7 - 0.12 * (i % 3) as f64, -0.4 + 0.1 * (i % 4) as f64]) - .collect(); - let mut rng = Lcg(50505); - let mut th = vec![0.0f64; n * n_dims]; - for v in th.iter_mut() { - *v = rng.normal(); - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = 0.0; - for d in 0..n_dims { - base += a_t[i * n_dims + d] * th[p * n_dims + d]; - } - let probs = gpcm_probs(base, &step_t[i], n_cat); - y[p * n_items + i] = gpcm_sample(&probs, rng.next_f64()); - } - } - let cfg = MhrmConfig { - max_cycles: 1000, - burn_in: 200, - mh_steps: 6, - model: MhrmModel::Gpcm { n_cat }, - seed: 17, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert_eq!(res.n_dims, 5); - assert_eq!(res.n_cat, n_cat); - for i in 0..n_items { - for d in 0..n_dims { - if pattern[i * n_dims + d] == 0 { - assert_eq!(res.loading[i * n_dims + d], 0.0); - } - } - } - let (mut se2, mut cnt) = (0.0, 0usize); - for idx in 0..n_items * n_dims { - if pattern[idx] == 1 { - se2 += (res.loading[idx] - a_t[idx]).powi(2); - cnt += 1; - } - } - let load_rmse = (se2 / cnt as f64).sqrt(); - assert!( - load_rmse < 0.25, - "D=5 GPCM on-pattern loading RMSE {load_rmse}" - ); - assert!( - res.loading[15 * n_dims + 2] < -0.25, - "negative cross-loader {}", - res.loading[15 * n_dims + 2] - ); - let true_steps: Vec = (0..n_items).flat_map(|i| step_t[i]).collect(); - assert!( - rmse(&res.step, &true_steps) < 0.25, - "GPCM step RMSE {}", - rmse(&res.step, &true_steps) - ); - for d in 0..n_dims { - let est: Vec = (0..n).map(|p| res.theta[p * n_dims + d]).collect(); - let tru: Vec = (0..n).map(|p| th[p * n_dims + d]).collect(); - assert!( - corr(&est, &tru) > 0.5, - "dim {d} theta corr {}", - corr(&est, &tru) - ); - } -} - -/// The reflection canonicalization FIRES for GPCM and is WITNESSED, with the UNORDERED steps left -/// INVARIANT: `base = k*sum a_d theta_d` flips jointly with `(a, theta)`, so canonicalization -/// touches only the slope column and the trait chain — never the step intercepts. dim0 has a WEAK -/// reverse-keyed sole pure anchor (item0, true `-0.7`) and a STRONG positive cross-loader (item1, -/// dim0 `+1.7`) that sets the axis; raw MH-RM lands the anchor NEGATIVE, so canon must flip dim0. -/// A mutant that ALSO negated the flipped dimension's items' steps would push item0's step_1 to the -/// wrong sign — the final assertion catches it. -#[test] -fn gpcm_mhrm_reflection_fires_on_negative_anchor() { - let (n_dims, n, n_cat) = (2usize, 5000usize, 3usize); - let n_items = 4usize; - // item0 pure d0 (sole d0 anchor), item1 cross d0/d1, item2/3 pure d1 - let pattern = vec![1u8, 0, 1, 1, 0, 1, 0, 1]; - let mut a_t = vec![0.0f64; n_items * n_dims]; - a_t[0] = -0.7; // weak reverse-keyed pure d0 anchor - a_t[1 * n_dims] = 1.7; // strong positive cross-loader on d0 (sets the axis) - a_t[1 * n_dims + 1] = 0.6; - a_t[2 * n_dims + 1] = 1.2; - a_t[3 * n_dims + 1] = 1.0; - // item0's steps are positive-then-negative; if reflection wrongly swept them, step_1 -> ~-0.5 - let step_t = [[0.5f64, -0.3], [0.4, -0.5], [0.6, -0.2], [0.3, -0.4]]; - let mut rng = Lcg(97531); - let mut th = vec![0.0f64; n * n_dims]; - for v in th.iter_mut() { - *v = rng.normal(); - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = 0.0; - for d in 0..n_dims { - base += a_t[i * n_dims + d] * th[p * n_dims + d]; - } - let probs = gpcm_probs(base, &step_t[i], n_cat); - y[p * n_items + i] = gpcm_sample(&probs, rng.next_f64()); - } - } - let cfg = MhrmConfig { - max_cycles: 1000, - burn_in: 200, - mh_steps: 8, - model: MhrmModel::Gpcm { n_cat }, - seed: 24, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert!( - res.loading[0] > 0.3, - "reflected anchor positive: {}", - res.loading[0] - ); - assert!( - res.loading[1 * n_dims] < -0.5, - "co-loader flipped negative: {}", - res.loading[1 * n_dims] - ); - let th0: Vec = (0..n).map(|p| res.theta[p * n_dims]).collect(); - let tt0: Vec = (0..n).map(|p| th[p * n_dims]).collect(); - assert!( - corr(&th0, &tt0) < -0.4, - "flipped-dim theta corr negative: {}", - corr(&th0, &tt0) - ); - // steps INVARIANT under reflection: item0's step_1 stays near its (un-flipped) truth +0.5, well - // away from the mutant's -0.5. - assert!( - (res.step[0] - step_t[0][0]).abs() < 0.35, - "GPCM step not swept by reflection: step_1 {} vs truth {}", - res.step[0], - step_t[0][0] - ); -} - -/// GPCM validation guards constructed non-vacuously: the SAME well-formed GPCM dataset fits (and -/// exposes the `step`/`n_cat` result shape), then each defect trips its INTENDED guard — an -/// out-of-range response, and a declared category never observed for an item (an unidentified step, -/// Muraki, 1992). -#[test] -fn gpcm_mhrm_validates_and_structure() { - let (n, n_items, n_dims, n_cat) = (60usize, 4usize, 2usize, 3usize); - let pattern = vec![1u8, 0, 1, 0, 0, 1, 0, 1]; // pure anchors on both dims - // y = (p + i) % 3 -> every item sees all 3 categories across persons - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - y[p * n_items + i] = (p + i) % n_cat; - } - } - let cfg = MhrmConfig { - max_cycles: 30, - burn_in: 5, - model: MhrmModel::Gpcm { n_cat }, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert_eq!(res.n_cat, n_cat); - assert!(res.intercept.is_empty()); - assert_eq!(res.step.len(), n_items * (n_cat - 1)); - assert_eq!(res.se_step.len(), n_items * (n_cat - 1)); - assert!(res.se_intercept.is_empty()); - assert_eq!(res.n_parameters, n_items + n_items * (n_cat - 1)); - // (a) response out of 0..n_cat where observed - let mut ybad = y.clone(); - ybad[0] = n_cat; // == 3, out of range - assert!(fit_mhrm(&ybad, None, &pattern, n, n_items, n_dims, &cfg).is_err()); - // (b) item0's category-1 responses remapped to 0 -> category 1 never observed for item0 - // (still in range), tripping the coverage guard (the binary 2PL does NOT enforce this). - let mut ycov = y.clone(); - for p in 0..n { - if ycov[p * n_items] == 1 { - ycov[p * n_items] = 0; - } - } - assert!(fit_mhrm(&ycov, None, &pattern, n, n_items, n_dims, &cfg).is_err()); - // (c) n_cat above the MHRM_MAX_CAT cap is rejected (the cap guard fires before the - // O(n_cat) coverage allocation) -- makes the MHRM_MAX_CAT constant live. - let cfg_big = MhrmConfig { - model: MhrmModel::Gpcm { - n_cat: MHRM_MAX_CAT + 1, - }, - ..cfg - }; - assert!(fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg_big).is_err()); - // (d) GPCM with n_cat == 2 (also n_free_cat == 1, colliding with the 2PL) routes its single - // step to `step`/`se_step` -- NOT the 2PL `intercept`/`se_intercept` -- honoring the - // family-based contract. y2 = (p + i) % 2 sees both categories per item. - let mut y2 = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - y2[p * n_items + i] = (p + i) % 2; - } - } - let cfg2 = MhrmConfig { - model: MhrmModel::Gpcm { n_cat: 2 }, - ..cfg - }; - let res2 = fit_mhrm(&y2, None, &pattern, n, n_items, n_dims, &cfg2).unwrap(); - assert_eq!(res2.n_cat, 2); - assert!( - res2.intercept.is_empty(), - "GPCM n_cat=2 must not populate 2PL intercept" - ); - assert!(res2.se_intercept.is_empty()); - assert_eq!(res2.step.len(), n_items); // J * (2 - 1) - assert_eq!(res2.se_step.len(), n_items); - assert!(res2.step.iter().all(|s| s.is_finite())); - assert_eq!(res2.n_parameters, n_items + n_items); // 4 free loadings + 4 single steps -} - -/// Literature-grade GPCM Monte-Carlo recovery (>=500 reps), normal + right-skew traits. Run with: -/// `cargo test -p mlsirm-core --release mc_gpcm_mhrm_recovery_500 -- --ignored --nocapture`. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps)"] -fn mc_gpcm_mhrm_recovery_500() { - let reps = 500usize; - let n_cat = 3usize; - // D=5 is the regime GH/QMC cannot reach for a polytomous item factor model. - for &(n_dims, n) in &[(2usize, 2000usize), (5usize, 2500usize)] { - for &skew in &[false, true] { - let n_items = if n_dims == 2 { 8 } else { 15 }; - let mut pattern = vec![0u8; n_items * n_dims]; - let mut a_t = vec![0.0f64; n_items * n_dims]; - let per = n_items / n_dims; - for i in 0..per * n_dims { - let d = i / per; - pattern[i * n_dims + d] = 1; - a_t[i * n_dims + d] = 0.9 + 0.1 * (i % 3) as f64; - } - // last item cross-loads dims 0 and 1 (dim0 negative) - let xi = n_items - 1; - pattern[xi * n_dims] = 1; - pattern[xi * n_dims + 1] = 1; - a_t[xi * n_dims] = -0.8; - a_t[xi * n_dims + 1] = 0.7; - let step_t: Vec<[f64; 2]> = (0..n_items) - .map(|i| [0.7 - 0.12 * (i % 3) as f64, -0.4 + 0.1 * (i % 4) as f64]) - .collect(); - let n_free: usize = pattern.iter().filter(|&&v| v == 1).count(); - - let (mut conv, mut lse2, mut lbias, mut lcnt) = (0usize, 0.0, 0.0, 0usize); - let (mut sse2, mut sbias) = (0.0, 0.0); - let mut corr_sum = 0.0; - for rep in 0..reps { - let mut rng = Lcg(0x6CBC_u64 - .wrapping_mul((rep as u64) + 1) - .wrapping_add(n_dims as u64)); - let mut th = vec![0.0f64; n * n_dims]; - for v in th.iter_mut() { - *v = if skew { - // standardized right-skew (Exp(1) - 1): mean 0, var 1 - -(rng.next_f64().max(1e-12)).ln() - 1.0 - } else { - rng.normal() - }; - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = 0.0; - for d in 0..n_dims { - base += a_t[i * n_dims + d] * th[p * n_dims + d]; - } - let probs = gpcm_probs(base, &step_t[i], n_cat); - y[p * n_items + i] = gpcm_sample(&probs, rng.next_f64()); - } - } - let cfg = MhrmConfig { - max_cycles: 900, - burn_in: 180, - mh_steps: 6, - model: MhrmModel::Gpcm { n_cat }, - seed: 0xC0DE_u64.wrapping_add(rep as u64), - estimate_se: false, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - if res.converged { - conv += 1; - } - for idx in 0..n_items * n_dims { - if pattern[idx] == 1 { - let e = res.loading[idx] - a_t[idx]; - lse2 += e * e; - lbias += e; - lcnt += 1; - } - } - for i in 0..n_items { - for j in 0..n_cat - 1 { - let e = res.step[i * (n_cat - 1) + j] - step_t[i][j]; - sse2 += e * e; - sbias += e; - } - } - let est: Vec = (0..n).map(|p| res.theta[p * n_dims]).collect(); - let tru: Vec = (0..n).map(|p| th[p * n_dims]).collect(); - corr_sum += corr(&est, &tru); - } - let scnt = (reps * n_items * (n_cat - 1)) as f64; - let load_rmse = (lse2 / lcnt as f64).sqrt(); - let step_rmse = (sse2 / scnt).sqrt(); - println!( - "[gpcm MC D={n_dims} N={n} n_free={n_free} K={n_cat} skew={skew}] reps={reps} conv={:.3} loadRMSE={:.4} loadBias={:.4} stepRMSE={:.4} stepBias={:.4} thetaCorr={:.3}", - conv as f64 / reps as f64, - load_rmse, - lbias / lcnt as f64, - step_rmse, - sbias / scnt, - corr_sum / reps as f64 - ); - assert!(conv as f64 / reps as f64 > 0.9, "GPCM convergence rate"); - if !skew { - assert!(load_rmse < 0.22, "GPCM normal loading RMSE {load_rmse}"); - assert!(step_rmse < 0.25, "GPCM normal step RMSE {step_rmse}"); - } - } - } - println!("=== gpcm done ==="); -} - -/// Literature-grade Monte-Carlo recovery (>=500 reps). Run with: -/// `cargo test -p mlsirm-core --release mc_mhrm_recovery_500 -- --ignored --nocapture`. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps)"] -fn mc_mhrm_recovery_500() { - let reps = 500usize; - // (n_dims, N) conditions; D=6 is the regime GH/QMC cannot reach. - for &(n_dims, n) in &[(2usize, 2000usize), (6usize, 2500usize)] { - for &skew in &[false, true] { - let n_items = if n_dims == 2 { 8 } else { 20 }; - // confirmatory pattern: pure anchors per dim + one negative cross-loader - let mut pattern = vec![0u8; n_items * n_dims]; - let mut a_t = vec![0.0f64; n_items * n_dims]; - let per = n_items / n_dims; - for i in 0..per * n_dims { - let d = i / per; - pattern[i * n_dims + d] = 1; - a_t[i * n_dims + d] = 0.9 + 0.1 * (i % 3) as f64; - } - // last item cross-loads dims 0 and 1 (dim0 negative) - let xi = n_items - 1; - pattern[xi * n_dims] = 1; - pattern[xi * n_dims + 1] = 1; - a_t[xi * n_dims] = -0.8; - a_t[xi * n_dims + 1] = 0.7; - let b_t: Vec = (0..n_items).map(|i| -0.4 + 0.12 * (i % 5) as f64).collect(); - let n_free: usize = pattern.iter().filter(|&&v| v == 1).count(); - - let (mut conv, mut se2, mut sbias, mut cnt) = (0usize, 0.0, 0.0, 0usize); - let mut corr_sum = 0.0; - for rep in 0..reps { - let mut rng = Lcg(0x51ED_u64 - .wrapping_mul((rep as u64) + 1) - .wrapping_add(n_dims as u64)); - let mut th = vec![0.0f64; n * n_dims]; - for v in th.iter_mut() { - *v = if skew { - // standardized right-skew (Exp(1) - 1): mean 0, var 1 - -(rng.next_f64().max(1e-12)).ln() - 1.0 - } else { - rng.normal() - }; - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = b_t[i]; - for d in 0..n_dims { - base += a_t[i * n_dims + d] * th[p * n_dims + d]; - } - let pr = 1.0 / (1.0 + (-base).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let cfg = MhrmConfig { - max_cycles: 900, - burn_in: 180, - mh_steps: 6, - seed: 0xABCD_u64.wrapping_add(rep as u64), - estimate_se: false, - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - if res.converged { - conv += 1; - } - for idx in 0..n_items * n_dims { - if pattern[idx] == 1 { - let e = res.loading[idx] - a_t[idx]; - se2 += e * e; - sbias += e; - cnt += 1; - } - } - let est: Vec = (0..n).map(|p| res.theta[p * n_dims]).collect(); - let tru: Vec = (0..n).map(|p| th[p * n_dims]).collect(); - corr_sum += corr(&est, &tru); - } - let load_rmse = (se2 / cnt as f64).sqrt(); - let load_bias = sbias / cnt as f64; - println!( - "[mhrm MC D={n_dims} N={n} n_free={n_free} skew={skew}] reps={reps} conv={:.3} loadRMSE={:.4} loadBias={:.4} thetaCorr={:.3}", - conv as f64 / reps as f64, - load_rmse, - load_bias, - corr_sum / reps as f64 - ); - assert!(conv as f64 / reps as f64 > 0.9, "convergence rate"); - if !skew { - assert!(load_rmse < 0.2, "normal loading RMSE {load_rmse}"); - } - } - } - - // correlated-Sigma condition (Cai 2010b): recover an exchangeable Phi at the near-PD-boundary - // rho = 0.5, D = 3 (so a persistent PD-backtracking stall would surface over 500 reps). - { - let (n_dims, n, rho) = (3usize, 3000usize, 0.5f64); - let per = 4usize; - let n_items = per * n_dims; - let mut pattern = vec![0u8; n_items * n_dims]; - let mut a_t = vec![0.0f64; n_items * n_dims]; - for d in 0..n_dims { - for a in 0..per { - let i = d * per + a; - pattern[i * n_dims + d] = 1; - a_t[i * n_dims + d] = 0.9 + 0.1 * a as f64; - } - } - let b_t: Vec = (0..n_items).map(|i| -0.4 + 0.1 * (i % 5) as f64).collect(); - let mut phi = vec![rho; n_dims * n_dims]; - for a in 0..n_dims { - phi[a * n_dims + a] = 1.0; - } - let l = chol_lower(&phi, n_dims).unwrap(); - let n_off = n_dims * (n_dims - 1) / 2; - let (mut conv, mut se2, mut sbias) = (0usize, 0.0f64, 0.0f64); - for rep in 0..reps { - let mut rng = Lcg(0x5EED_u64.wrapping_mul((rep as u64) + 1)); - let mut th = vec![0.0f64; n * n_dims]; - for p in 0..n { - let z: Vec = (0..n_dims).map(|_| rng.normal()).collect(); - for a in 0..n_dims { - let mut v = 0.0; - for b in 0..=a { - v += l[a * n_dims + b] * z[b]; - } - th[p * n_dims + a] = v; - } - } - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - for i in 0..n_items { - let mut base = b_t[i]; - for d in 0..n_dims { - base += a_t[i * n_dims + d] * th[p * n_dims + d]; - } - let pr = 1.0 / (1.0 + (-base).exp()); - y[p * n_items + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let cfg = MhrmConfig { - max_cycles: 1200, - burn_in: 300, - mh_steps: 6, - estimate_corr: true, - estimate_se: false, - seed: 0xBEEF_u64.wrapping_add(rep as u64), - ..MhrmConfig::default() - }; - let res = fit_mhrm(&y, None, &pattern, n, n_items, n_dims, &cfg).unwrap(); - if res.converged { - conv += 1; - } - for a in 0..n_dims { - for b in a + 1..n_dims { - let e = res.corr[a * n_dims + b] - rho; - se2 += e * e; - sbias += e; - } - } - } - let m = (reps * n_off) as f64; - println!( - "[mhrm MC correlated D={n_dims} N={n} rho={rho}] reps={reps} conv={:.3} corrRMSE={:.4} corrBias={:.4}", - conv as f64 / reps as f64, - (se2 / m).sqrt(), - sbias / m - ); - assert!(conv as f64 / reps as f64 > 0.9, "correlated convergence"); - assert!((se2 / m).sqrt() < 0.1, "correlated corr RMSE"); - } - println!("=== done ==="); -} diff --git a/tests/unit/mixed_tests.rs b/tests/unit/mixed_tests.rs deleted file mode 100644 index b85d919ab..000000000 --- a/tests/unit/mixed_tests.rs +++ /dev/null @@ -1,495 +0,0 @@ -use super::*; - -#[test] -fn ggum_probabilities_match_paired_subjective_category_formula() { - let spec = MixedItemSpec { - kind: MixedItemKind::Ggum, - n_categories: 4, - }; - let a = 1.2_f64; - let delta = -0.3; - let thresholds = [0.8, 0.2, -0.4]; - let mut params = vec![a.ln(), delta]; - params.extend(ordered_raw(&thresholds)); - - let theta = 0.7; - let actual = item_logprobs(&spec, ¶ms, theta, &[], 0); - - // Roberts et al. (2000): P(Z=z) is proportional to - // f(z) + f(M-z), where the subjective-category thresholds are - // symmetric around the zero middle threshold. - let c = spec.n_categories - 1; - let m = 2 * c + 1; - let mut tau = vec![0.0; m + 1]; - tau[1..=c].copy_from_slice(&thresholds); - for z in 1..=c { - tau[m - z + 1] = -thresholds[z - 1]; - } - let mut cumulative_tau = 0.0; - let mut log_f = Vec::with_capacity(m + 1); - for (w, &tau_w) in tau.iter().enumerate() { - cumulative_tau += tau_w; - log_f.push(a * (w as f64 * (theta - delta) - cumulative_tau)); - } - let paired: Vec = (0..=c).map(|z| logaddexp(log_f[z], log_f[m - z])).collect(); - let expected = softmax_log(&paired); - - for (category, (got, want)) in actual.iter().zip(&expected).enumerate() { - assert!( - (got - want).abs() < 1e-12, - "category {category}: got {got}, expected {want}" - ); - } -} - -#[test] -fn every_mixed_cell_normalizes() { - let cases = [ - (MixedItemKind::Rasch, 2), - (MixedItemKind::TwoPl, 2), - (MixedItemKind::ThreePl, 2), - (MixedItemKind::ThreePlUpper, 2), - (MixedItemKind::FourPl, 2), - (MixedItemKind::Cll, 2), - (MixedItemKind::Grm, 4), - (MixedItemKind::Pcm, 4), - (MixedItemKind::Gpcm, 4), - (MixedItemKind::Sequential, 4), - (MixedItemKind::Tutz, 4), - (MixedItemKind::Nominal, 4), - (MixedItemKind::Ideal, 2), - (MixedItemKind::Ggum, 4), - (MixedItemKind::Lsirm, 2), - (MixedItemKind::LsirmGrm, 4), - (MixedItemKind::LsirmGpcm, 4), - ]; - for (kind, n_categories) in cases { - let spec = MixedItemSpec { kind, n_categories }; - let latent_dim = if kind.is_spatial() { 2 } else { 0 }; - let freq = vec![1.0 / n_categories as f64; n_categories]; - let params = initial_params(&spec, &freq, 0, 1, latent_dim); - for theta in [-4.0, 0.0, 4.0] { - let xi = if latent_dim == 0 { - &[][..] - } else { - &[0.3, -0.2][..] - }; - let lp = item_logprobs(&spec, ¶ms, theta, xi, latent_dim); - assert_eq!(lp.len(), n_categories); - assert!(lp.iter().all(|v| v.is_finite()), "{kind:?}: {lp:?}"); - let total: f64 = lp.iter().map(|v| v.exp()).sum(); - assert!((total - 1.0).abs() < 1e-10, "{kind:?}: {total}"); - } - } -} - -#[test] -fn binary_cells_match_their_defining_formulas() { - let theta = 0.4; - let rasch = MixedItemSpec { - kind: MixedItemKind::Rasch, - n_categories: 2, - }; - let lp = item_logprobs(&rasch, &[-0.3], theta, &[], 0); - assert!((lp[1].exp() - logistic(theta + 0.3)).abs() < 1e-12); - - let two = MixedItemSpec { - kind: MixedItemKind::TwoPl, - n_categories: 2, - }; - let lp = item_logprobs(&two, &[1.2_f64.ln(), -0.3], theta, &[], 0); - let expected = 1.0 / (1.0 + (-(1.2 * theta - 0.3)).exp()); - assert!((lp[1].exp() - expected).abs() < 1e-12); - - let three = MixedItemSpec { - kind: MixedItemKind::ThreePl, - n_categories: 2, - }; - let raw_lower = logit(0.2); - let lp = item_logprobs(&three, &[1.2_f64.ln(), -0.3, raw_lower], theta, &[], 0); - let expected = 0.2 + 0.8 * logistic(1.2 * theta - 0.3); - assert!((lp[1].exp() - expected).abs() < 1e-12); - - let upper = MixedItemSpec { - kind: MixedItemKind::ThreePlUpper, - n_categories: 2, - }; - let lp = item_logprobs(&upper, &[1.2_f64.ln(), -0.3, logit(0.85)], theta, &[], 0); - let expected = 0.85 * logistic(1.2 * theta - 0.3); - assert!((lp[1].exp() - expected).abs() < 1e-12); - - let four = MixedItemSpec { - kind: MixedItemKind::FourPl, - n_categories: 2, - }; - let raw_gap = logit((0.85 - 0.2) / (1.0 - 0.2)); - let params = [1.2_f64.ln(), -0.3, raw_lower, raw_gap]; - let lp = item_logprobs(&four, ¶ms, theta, &[], 0); - let expected = 0.2 + 0.65 * logistic(1.2 * theta - 0.3); - assert!((lp[1].exp() - expected).abs() < 1e-12); - let estimate = public_estimate(&four, ¶ms, 0); - assert!((estimate.lower_asymptote.unwrap() - 0.2).abs() < 1e-12); - assert!((estimate.upper_asymptote.unwrap() - 0.85).abs() < 1e-12); - - let cll = MixedItemSpec { - kind: MixedItemKind::Cll, - n_categories: 2, - }; - let lp = item_logprobs(&cll, &[-0.3], theta, &[], 0); - let expected = 1.0 - (-(theta + 0.3).exp()).exp(); - assert!((lp[1].exp() - expected).abs() < 1e-12); - - let ideal = MixedItemSpec { - kind: MixedItemKind::Ideal, - n_categories: 2, - }; - let lp = item_logprobs(&ideal, &[1.5_f64.ln(), -0.2], theta, &[], 0); - let expected = (-0.5 * (1.5 * (theta + 0.2)).powi(2)).exp(); - assert!((lp[1].exp() - expected).abs() < 1e-12); -} - -#[test] -fn partial_credit_and_sequential_cells_match_definitions() { - let theta = 0.35; - let pcm = MixedItemSpec { - kind: MixedItemKind::Pcm, - n_categories: 3, - }; - let pcm_lp = item_logprobs(&pcm, &[0.2, -0.4], theta, &[], 0); - let expected = gpcm_logprobs(theta, &[0.0, 1.0, 2.0], &[0.0, 0.2, -0.4]); - for (got, want) in pcm_lp.iter().zip(expected) { - assert!((*got - want).abs() < 1e-12); - } - - let sequential = MixedItemSpec { - kind: MixedItemKind::Sequential, - n_categories: 3, - }; - let params = [1.4_f64.ln(), 0.2, -0.5]; - let lp = item_logprobs(&sequential, ¶ms, theta, &[], 0); - let q1 = logistic(1.4 * theta + 0.2); - let q2 = logistic(1.4 * theta - 0.5); - let expected = [1.0 - q1, q1 * (1.0 - q2), q1 * q2]; - for (got, want) in lp.iter().map(|v| v.exp()).zip(expected) { - assert!((got - want).abs() < 1e-12); - } - let estimate = public_estimate(&sequential, ¶ms, 0); - assert_eq!(estimate.intercepts, vec![0.2, -0.5]); - - let tutz = MixedItemSpec { - kind: MixedItemKind::Tutz, - n_categories: 3, - }; - let lp = item_logprobs(&tutz, &[0.2, -0.5], theta, &[], 0); - let q1 = logistic(theta + 0.2); - let q2 = logistic(theta - 0.5); - let expected = [1.0 - q1, q1 * (1.0 - q2), q1 * q2]; - for (got, want) in lp.iter().map(|v| v.exp()).zip(expected) { - assert!((got - want).abs() < 1e-12); - } - let estimate = public_estimate(&tutz, &[0.2, -0.5], 0); - assert_eq!(estimate.intercepts, vec![0.2, -0.5]); -} - -#[test] -fn new_family_aliases_and_public_constraints_are_explicit() { - let aliases = [ - ("1pl", MixedItemKind::Rasch, "rasch"), - ("partial_credit", MixedItemKind::Pcm, "pcm"), - ("upper_3pl", MixedItemKind::ThreePlUpper, "3plu"), - ("complementary_log_log", MixedItemKind::Cll, "cll"), - ("sequential", MixedItemKind::Sequential, "sequential"), - ("tutz", MixedItemKind::Tutz, "tutz"), - ]; - for (alias, kind, canonical) in aliases { - assert_eq!(MixedItemKind::parse(alias).unwrap(), kind); - assert_eq!(kind.as_str(), canonical); - } - assert!(MixedItemKind::parse("not-a-family").is_err()); - - let four = MixedItemSpec { - kind: MixedItemKind::FourPl, - n_categories: 2, - }; - let mut extreme = [8.0, 20.0, -20.0, 20.0]; - clamp_params(&four, &mut extreme, 0); - assert_eq!(extreme[0], 4.0); - assert_eq!(extreme[1], 12.0); - let estimate = public_estimate(&four, &extreme, 0); - let lower = estimate.lower_asymptote.unwrap(); - let upper = estimate.upper_asymptote.unwrap(); - assert!(0.0 < lower && lower < upper && upper < 1.0); -} - -#[test] -fn numeric_hessian_is_symmetrized_without_order_bias() { - let mut hessian = vec![vec![2.0, 4.0], vec![8.0, 6.0]]; - symmetrize_and_ridge(&mut hessian, 0.25); - assert_eq!(hessian, vec![vec![2.25, 6.0], vec![6.0, 6.25]]); -} - -#[test] -fn mixed_item_line_search_stops_at_a_clamped_boundary() { - let spec = MixedItemSpec { - kind: MixedItemKind::Rasch, - n_categories: 2, - }; - let grid = build_grid(std::slice::from_ref(&spec), 0, 7, 7).unwrap(); - let mut counts = vec![0.0; grid.cell() * 2]; - for node in 0..grid.cell() { - counts[node * 2] = 1.0; - } - let fitted = m_step_item(&spec, &[12.0], &grid, &counts, 1); - assert_eq!(fitted, vec![12.0]); -} - -#[test] -fn rejects_hidden_nonconvergence_as_success() { - let y = vec![0, 0, 1, 1, 0, 1, 1, 0]; - let specs = vec![ - MixedItemSpec { - kind: MixedItemKind::TwoPl, - n_categories: 2, - }, - MixedItemSpec { - kind: MixedItemKind::TwoPl, - n_categories: 2, - }, - ]; - let fit = fit_mixed_items(&y, None, 4, 2, &specs, 1, 7, 7, 1, 1e-14, 1).unwrap(); - assert!(!fit.converged); - assert_eq!(fit.termination_reason, "max_iter_reached"); - assert_eq!(fit.n_iter, 1); - assert_eq!(fit.loglik_trace.len(), 2); -} - -#[test] -fn mixed_fit_executes_every_response_family() { - let cases = [ - (MixedItemKind::Rasch, 2), - (MixedItemKind::TwoPl, 2), - (MixedItemKind::ThreePl, 2), - (MixedItemKind::ThreePlUpper, 2), - (MixedItemKind::FourPl, 2), - (MixedItemKind::Cll, 2), - (MixedItemKind::Grm, 3), - (MixedItemKind::Pcm, 3), - (MixedItemKind::Gpcm, 3), - (MixedItemKind::Sequential, 3), - (MixedItemKind::Tutz, 3), - (MixedItemKind::Nominal, 3), - (MixedItemKind::Ideal, 2), - (MixedItemKind::Ggum, 3), - (MixedItemKind::Lsirm, 2), - (MixedItemKind::LsirmGrm, 3), - (MixedItemKind::LsirmGpcm, 3), - ]; - let specs: Vec = cases - .iter() - .map(|&(kind, n_categories)| MixedItemSpec { kind, n_categories }) - .collect(); - let n_persons = 4; - let n_items = specs.len(); - let mut y = vec![0usize; n_persons * n_items]; - for person in 0..n_persons { - for (item, spec) in specs.iter().enumerate() { - y[person * n_items + item] = if person % 2 == 0 { - 0 - } else { - spec.n_categories - 1 - }; - } - } - - let fit = fit_mixed_items(&y, None, n_persons, n_items, &specs, 1, 7, 7, 1, 1e-12, 0) - .expect("all documented mixed response families must execute in one calibration"); - assert_eq!(fit.items.len(), n_items); - assert_eq!(fit.theta_eap.len(), n_persons); - assert_eq!(fit.theta_sd.len(), n_persons); - assert_eq!(fit.xi_eap.len(), n_persons); - assert_eq!(fit.latent_dim, 1); - assert_eq!(fit.n_iter, 1); - assert_eq!(fit.termination_reason, "max_iter_reached"); - assert!(fit.loglik.is_finite()); - assert!(fit.loglik_trace.iter().all(|value| value.is_finite())); - assert!(fit.theta_eap.iter().all(|value| value.is_finite())); - assert!(fit.theta_sd.iter().all(|value| value.is_finite())); - assert!(fit.xi_eap.iter().all(|value| value.is_finite())); -} - -#[test] -fn mixed_fit_covers_parallel_masked_and_converged_paths() { - let n_persons = 256; - let n_items = 4; - let specs = vec![ - MixedItemSpec { - kind: MixedItemKind::TwoPl, - n_categories: 2, - }; - n_items - ]; - let y: Vec = (0..n_persons * n_items) - .map(|index| (index / n_items + index % n_items) % 2) - .collect(); - let mut observed = vec![true; y.len()]; - observed[0] = false; - - let fit = fit_mixed_items( - &y, - Some(&observed), - n_persons, - n_items, - &specs, - 1, - 7, - 7, - 2, - 1e12, - 2, - ) - .unwrap(); - assert!(fit.converged); - assert_eq!(fit.termination_reason, "converged"); - assert!(fit.n_threads >= 1); - assert!(fit - .loglik_trace - .windows(2) - .all(|pair| pair[1] + 1e-8 >= pair[0])); -} - -#[test] -fn mixed_fit_validation_and_helper_boundaries() { - assert_eq!(contextualize_mixed_update(Ok(0.25)).unwrap(), 0.25); - assert_eq!( - contextualize_mixed_update(Err("non_monotone_update")).unwrap_err(), - "mixed-format EM update failed: non_monotone_update" - ); - let binary = MixedItemSpec { - kind: MixedItemKind::TwoPl, - n_categories: 2, - }; - let spatial = MixedItemSpec { - kind: MixedItemKind::Lsirm, - n_categories: 2, - }; - let call = |y: &[usize], - observed: Option<&[bool]>, - n_persons, - n_items, - specs: &[MixedItemSpec], - max_iter, - tol| { - fit_mixed_items( - y, observed, n_persons, n_items, specs, 1, 7, 7, max_iter, tol, 1, - ) - }; - - assert!(call(&[], None, 0, 1, &[binary.clone()], 1, 1e-6).is_err()); - assert!(call( - &[], - None, - usize::MAX, - 2, - &[binary.clone(), binary.clone()], - 1, - 1e-6 - ) - .is_err()); - assert!(call(&[0], None, 1, 2, &[binary.clone(), binary.clone()], 1, 1e-6).is_err()); - assert!(call(&[0, 1], None, 2, 1, &[], 1, 1e-6).is_err()); - assert!(call(&[0, 1], Some(&[true]), 2, 1, &[binary.clone()], 1, 1e-6).is_err()); - assert!(call(&[0, 1], None, 2, 1, &[binary.clone()], 0, 1e-6).is_err()); - assert!(call(&[0, 1], None, 2, 1, &[binary.clone()], 1, f64::NAN).is_err()); - assert!(call( - &[0, 1], - None, - 2, - 1, - &[MixedItemSpec { - kind: MixedItemKind::Pcm, - n_categories: 1, - }], - 1, - 1e-6, - ) - .is_err()); - assert!(call( - &[0, 1], - None, - 2, - 1, - &[MixedItemSpec { - kind: MixedItemKind::Rasch, - n_categories: 3, - }], - 1, - 1e-6, - ) - .is_err()); - assert!(call(&[0, 2], None, 2, 1, &[binary.clone()], 1, 1e-6).is_err()); - assert!(call(&[0, 0], None, 2, 1, &[binary.clone()], 1, 1e-6).is_err()); - assert!(build_grid(&[spatial.clone()], 0, 7, 7).is_err()); - assert!(build_grid(&[binary.clone()], 1, 5, 7).is_err()); - assert!(build_grid(&[spatial], 1, 7, 5).is_err()); - assert!(tensor_grid(41, 4).is_err()); - assert!(ordered_values(&[]).is_empty()); - assert!(ordered_raw(&[]).is_empty()); - assert_eq!(asymptotes(MixedItemKind::Rasch, &[0.0]), (0.0, 1.0)); - - let aliases = [ - ("binary", MixedItemKind::TwoPl), - ("3pl", MixedItemKind::ThreePl), - ("4pl", MixedItemKind::FourPl), - ("graded", MixedItemKind::Grm), - ("gpcm", MixedItemKind::Gpcm), - ("nrm", MixedItemKind::Nominal), - ("ideal_point", MixedItemKind::Ideal), - ("ggum", MixedItemKind::Ggum), - ("lsirm", MixedItemKind::Lsirm), - ("lsirm_grm", MixedItemKind::LsirmGrm), - ("lsirm_gpcm", MixedItemKind::LsirmGpcm), - ]; - for (name, kind) in aliases { - assert_eq!(MixedItemKind::parse(name).unwrap(), kind); - assert_eq!(kind.as_str(), MixedItemKind::parse(name).unwrap().as_str()); - } - - let params = initial_params( - &MixedItemSpec { - kind: MixedItemKind::LsirmGpcm, - n_categories: 3, - }, - &[0.3, 0.4, 0.3], - 1, - 3, - 3, - ); - assert_eq!(params.len(), 6); - assert!(params.iter().all(|value| value.is_finite())); - - assert_eq!(assess_loglik_update(-10.0, -9.5), Ok(0.5)); - assert_eq!( - assess_loglik_update(-10.0, f64::NAN), - Err("non_finite_loglik") - ); - assert_eq!( - assess_loglik_update(-10.0, -11.0), - Err("non_monotone_update") - ); - - let grid = build_grid(&[binary.clone()], 1, 7, 7).unwrap(); - let params = vec![initial_params(&binary, &[0.5, 0.5], 0, 1, 0)]; - let counts = vec![vec![f64::NAN; grid.cell() * 2]]; - let fitted = m_step(&[binary], ¶ms, &grid, &counts, 1); - assert_eq!(fitted.len(), 1); - - let binary = MixedItemSpec { - kind: MixedItemKind::TwoPl, - n_categories: 2, - }; - let grid = build_grid(&[binary.clone()], 1, 7, 7).unwrap(); - let initial = initial_params(&binary, &[0.5, 0.5], 0, 1, 0); - let zero_counts = vec![0.0; grid.cell() * 2]; - let stationary = m_step_item(&binary, &initial, &grid, &zero_counts, 6); - assert_eq!(stationary.len(), initial.len()); -} diff --git a/tests/unit/mixture_tests.rs b/tests/unit/mixture_tests.rs deleted file mode 100644 index bd5d51e89..000000000 --- a/tests/unit/mixture_tests.rs +++ /dev/null @@ -1,456 +0,0 @@ -use super::*; - -struct TestRng(u64); -impl TestRng { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } - fn skew(&mut self) -> f64 { - // Exp(1) - 1: mean 0, var 1, right-skewed (skewness 2). - -(self.next_f64().max(1e-12)).ln() - 1.0 - } - fn bern(&mut self, p: f64) -> f64 { - if self.next_f64() < p { - 1.0 - } else { - 0.0 - } - } -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - (a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::() / n).sqrt() -} -fn nondecreasing(trace: &[f64]) -> bool { - trace.windows(2).all(|w| w[1] >= w[0] - 1e-6) -} - -/// Best of the two C=2 label permutations (identity vs swap) minimizing difficulty -/// SSE; returns (permutation as new->old, matched-b RMSE). -fn match_c2(b_fit: &[f64], b_true: &[f64], n_items: usize) -> ([usize; 2], f64) { - let sse = |perm: [usize; 2]| -> f64 { - let mut s = 0.0; - for (c_new, &c_old) in perm.iter().enumerate() { - for i in 0..n_items { - let d = b_fit[c_old * n_items + i] - b_true[c_new * n_items + i]; - s += d * d; - } - } - s - }; - let (id, sw) = ([0usize, 1], [1usize, 0]); - let perm = if sse(id) <= sse(sw) { id } else { sw }; - (perm, (sse(perm) / (2 * n_items) as f64).sqrt()) -} - -/// Adjusted Rand index (Hubert & Arabie, 1985) — label-invariant agreement. -fn ari(a: &[u32], b: &[u32]) -> f64 { - let ka = (*a.iter().max().unwrap() + 1) as usize; - let kb = (*b.iter().max().unwrap() + 1) as usize; - let mut tab = vec![0u64; ka * kb]; - for (&x, &y) in a.iter().zip(b) { - tab[x as usize * kb + y as usize] += 1; - } - let c2 = |n: u64| (n * n.saturating_sub(1) / 2) as f64; - let index: f64 = tab.iter().map(|&n| c2(n)).sum(); - let sum_a: f64 = (0..ka) - .map(|i| c2((0..kb).map(|j| tab[i * kb + j]).sum())) - .sum(); - let sum_b: f64 = (0..kb) - .map(|j| c2((0..ka).map(|i| tab[i * kb + j]).sum())) - .sum(); - let n = a.len() as u64; - let expected = sum_a * sum_b / c2(n); - let max_index = 0.5 * (sum_a + sum_b); - if (max_index - expected).abs() < 1e-12 { - 1.0 - } else { - (index - expected) / (max_index - expected) - } -} - -/// Simulate a two-class mixture with a difficulty REVERSAL (b_1 = -b_0): the -/// canonical Rost two-strategy structure a single class cannot fit. -fn simulate_c2( - n: usize, - n_items: usize, - pi: f64, - b0: &[f64], - a0: &[f64], - skew: bool, - rng: &mut TestRng, -) -> (Vec, Vec) { - let mut y = vec![0.0f64; n * n_items]; - let mut cls = vec![0u32; n]; - for j in 0..n { - let c = if rng.next_f64() < pi { 0usize } else { 1usize }; - cls[j] = c as u32; - let theta = if skew { rng.skew() } else { rng.normal() }; - for i in 0..n_items { - let (ai, bi) = if c == 0 { - (a0[i], b0[i]) - } else { - (a0[i], -b0[i]) - }; - let p = sigmoid_stable(ai * theta + bi); - y[j * n_items + i] = rng.bern(p); - } - } - (y, cls) -} - -/// Anchor 1: C=1 TwoPl reduces bit-exactly to fit_mmle_2pl (tol=0.0 so both run the -/// full max_iter from the identical init). -#[test] -fn mixture_c1_equals_fit_mmle_2pl() { - let (n, j) = (600usize, 12usize); - let mut rng = TestRng(7); - let a_t: Vec = (0..j).map(|_| 0.8 + 0.8 * rng.next_f64()).collect(); - let b_t: Vec = (0..j) - .map(|i| -1.2 + 2.4 * i as f64 / (j - 1) as f64) - .collect(); - let mut y = vec![0.0f64; n * j]; - for p in 0..n { - let theta = rng.normal(); - for i in 0..j { - y[p * j + i] = rng.bern(sigmoid_stable(a_t[i] * theta + b_t[i])); - } - } - let observed = vec![true; n * j]; - let mcfg = MmleConfig { - max_iter: 60, - tol: 0.0, - ridge_a: 1e-3, - ridge_b: 1e-3, - newton_iter: 25, - }; - let mmle = fit_mmle_2pl(&y, &observed, n, j, &mcfg); - let cfg = MixtureConfig { - max_iter: 60, - tol: 0.0, - ridge_a: 1e-3, - ridge_b: 1e-3, - newton_iter: 25, - ..MixtureConfig::default() - }; - let mix = fit_mixture(&y, &observed, n, j, 1, MixtureModel::TwoPl, &cfg).unwrap(); - assert_eq!(mix.pi, vec![1.0]); - assert!( - rmse(&mix.a, &mmle.a) < 1e-12, - "a RMSE {}", - rmse(&mix.a, &mmle.a) - ); - assert!( - rmse(&mix.b, &mmle.b) < 1e-12, - "b RMSE {}", - rmse(&mix.b, &mmle.b) - ); - assert_eq!(mix.n_parameters, 2 * j); -} - -/// Anchor 2: two well-separated classes (difficulty reversal) recovered with -/// permutation matching, multi-start against local optima. -#[test] -fn recovers_mixed_rasch_c2() { - let (n, j) = (1200usize, 15usize); - let pi_true = 0.6; - let b0: Vec = (0..j) - .map(|i| -2.0 + 4.0 * i as f64 / (j - 1) as f64) - .collect(); - let a0 = vec![1.0f64; j]; - let mut rng = TestRng(2024); - let (y, cls) = simulate_c2(n, j, pi_true, &b0, &a0, false, &mut rng); - let observed = vec![true; n * j]; - let cfg = MixtureConfig { - n_starts: 8, - ..MixtureConfig::default() - }; - let res = fit_mixture(&y, &observed, n, j, 2, MixtureModel::Rasch, &cfg).unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); - assert!(res.a.iter().all(|&a| (a - 1.0).abs() < 1e-12)); // Rasch: a == 1 - // truth in canonical layout: class 0 = b0, class 1 = -b0 - let mut b_true = vec![0.0f64; 2 * j]; - b_true[..j].copy_from_slice(&b0); - for i in 0..j { - b_true[j + i] = -b0[i]; - } - let (perm, brmse) = match_c2(&res.b, &b_true, j); - assert!(brmse < 0.25, "matched b RMSE {brmse}"); - // matched mixing proportions (true class 0 has weight pi_true) - let pi_matched0 = res.pi[perm[0]]; - assert!((pi_matched0 - pi_true).abs() < 0.06, "pi {pi_matched0}"); - // classification: relabel map_class by perm, compare to truth; ARI cross-check - let inv = if perm == [0, 1] { [0u32, 1] } else { [1u32, 0] }; - let relabeled: Vec = res.map_class.iter().map(|&m| inv[m as usize]).collect(); - let acc = relabeled.iter().zip(&cls).filter(|(a, b)| a == b).count() as f64 / n as f64; - assert!(acc > 0.80, "MAP class accuracy {acc}"); - assert!( - ari(&res.map_class, &cls) > 0.35, - "ARI {}", - ari(&res.map_class, &cls) - ); -} - -/// Missing-at-random cells are dropped from likelihood and counts. -#[test] -fn mixture_handles_missing_data() { - let (n, j) = (800usize, 12usize); - let b0: Vec = (0..j) - .map(|i| -1.5 + 3.0 * i as f64 / (j - 1) as f64) - .collect(); - let a0 = vec![1.0f64; j]; - let mut rng = TestRng(55); - let (y, _) = simulate_c2(n, j, 0.5, &b0, &a0, false, &mut rng); - let mut observed = vec![true; n * j]; - for o in observed.iter_mut() { - if rng.next_f64() < 0.2 { - *o = false; - } - } - let cfg = MixtureConfig { - n_starts: 6, - ..MixtureConfig::default() - }; - let res = fit_mixture(&y, &observed, n, j, 2, MixtureModel::Rasch, &cfg).unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); -} - -/// The C=1 short-circuit runs a single start regardless of n_starts, and a -/// non-converged fit still returns (max-iter guard). -#[test] -fn mixture_c1_ignores_starts_and_stops_at_max_iter() { - let (n, j) = (200usize, 8usize); - let mut rng = TestRng(3); - let mut y = vec![0.0f64; n * j]; - for p in 0..n { - let theta = rng.normal(); - for i in 0..j { - y[p * j + i] = rng.bern(sigmoid_stable(theta - 0.5 + 0.1 * i as f64)); - } - } - let observed = vec![true; n * j]; - let cfg = MixtureConfig { - max_iter: 1, - n_starts: 9, - ..MixtureConfig::default() - }; - let res = fit_mixture(&y, &observed, n, j, 1, MixtureModel::TwoPl, &cfg).unwrap(); - assert!(!res.converged && res.n_iter == 1 && res.pi == vec![1.0]); -} - -/// Malformed inputs are rejected (covers each validate branch, incl. tol=0 allowed). -#[test] -fn mixture_validate_rejects_malformed() { - let y = vec![0.0f64; 4 * 3]; - let obs = vec![true; 12]; - let d = MixtureConfig::default(); - let bad = |y: &[f64], obs: &[bool], n, j, c, cfg: &MixtureConfig| { - fit_mixture(y, obs, n, j, c, MixtureModel::Rasch, cfg).is_err() - }; - assert!(bad(&y, &obs, 0, 3, 2, &d)); // n_persons < 1 - assert!(bad(&y, &obs, 4, 3, 0, &d)); // n_classes < 1 - assert!(bad(&y, &obs, 4, 3, 2, &MixtureConfig { max_iter: 0, ..d })); // max_iter - assert!(bad(&y, &obs, 4, 3, 2, &MixtureConfig { tol: -1.0, ..d })); // tol < 0 - assert!(bad( - &y, - &obs, - 4, - 3, - 2, - &MixtureConfig { - newton_iter: 0, - ..d - } - )); // newton_iter - assert!(bad(&y, &obs, 4, 3, 2, &MixtureConfig { n_starts: 0, ..d })); // n_starts - assert!(bad(&y, &obs, 4, 3, MIXTURE_MAX_CLASSES + 1, &d)); - assert!(bad( - &y, - &obs, - 4, - 3, - 2, - &MixtureConfig { - max_iter: MIXTURE_MAX_ITER + 1, - ..d - } - )); - assert!(bad( - &y, - &obs, - 4, - 3, - 2, - &MixtureConfig { - n_starts: MIXTURE_MAX_STARTS + 1, - ..d - } - )); - assert!(bad( - &y, - &obs, - 4, - 3, - 2, - &MixtureConfig { - n_starts: MIXTURE_MAX_STARTS, - max_iter: MIXTURE_MAX_AGGREGATE_ITERS, - ..d - } - )); - assert!(bad( - &y, - &obs, - 4, - 3, - 2, - &MixtureConfig { pi_floor: 0.6, ..d } - )); // pi_floor >= 1/C - assert!(bad(&vec![0.0; 5], &obs, 4, 3, 2, &d)); // y length - assert!(bad(&vec![2.0; 12], &obs, 4, 3, 2, &d)); // y not 0/1 - let mut obs_gap = vec![true; 12]; - for p in 0..4 { - obs_gap[p * 3 + 1] = false; // item 1 fully unobserved - } - assert!(bad(&y, &obs_gap, 4, 3, 2, &d)); - // tol == 0.0 is accepted - assert!(fit_mixture( - &y, - &obs, - 4, - 3, - 1, - MixtureModel::Rasch, - &MixtureConfig { - tol: 0.0, - max_iter: 2, - ..d - } - ) - .is_ok()); -} - -#[test] -fn mixture_validate_rejects_nonfinite_optimizer_config() { - let y = vec![0.0f64; 4 * 3]; - let obs = vec![true; 12]; - let d = MixtureConfig::default(); - let bad = - |cfg: &MixtureConfig| fit_mixture(&y, &obs, 4, 3, 2, MixtureModel::Rasch, cfg).is_err(); - - assert!(bad(&MixtureConfig { - ridge_a: f64::NAN, - ..d - })); - assert!(bad(&MixtureConfig { ridge_b: -1.0, ..d })); - assert!(bad(&MixtureConfig { - start_spread: f64::INFINITY, - ..d - })); -} - -#[test] -fn mixture_private_singular_updates_and_empty_initialization_are_safe() { - let zeros = vec![0.0; GH_NODES.len()]; - assert_eq!( - newton_item_2pl(&zeros, &zeros, 1.0, 0.0, true, 3, 0.0, 0.0), - (1.0, 0.0) - ); - assert_eq!( - newton_item_2pl(&zeros, &zeros, 1.0, 0.0, false, 3, 0.0, 0.0), - (1.0, 0.0) - ); - let initialized = init_mmle_like(&[0.0], &[false], 1, 1); - assert_eq!(initialized, vec![0.0]); -} - -#[test] -fn mixture_validate_rejects_dimension_overflow() { - let y = vec![0.0f64; 2]; - let obs = vec![true; 2]; - let cfg = MixtureConfig { - pi_floor: f64::MIN_POSITIVE, - ..MixtureConfig::default() - }; - - assert!(fit_mixture(&y, &obs, 1, 2, usize::MAX, MixtureModel::Rasch, &cfg,).is_err()); -} - -/// Literature-grade Monte-Carlo (>=500 reps): Rost-style two-class reversal recovery -/// under normal and skew ability, permutation-matched, with ARI cross-check. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_mixture_recovery_500() { - let (n, j, reps) = (1500usize, 15usize, 500usize); - let pi_true = 0.6; - let b0: Vec = (0..j) - .map(|i| -2.0 + 4.0 * i as f64 / (j - 1) as f64) - .collect(); - let a0 = vec![1.0f64; j]; - let mut b_true = vec![0.0f64; 2 * j]; - b_true[..j].copy_from_slice(&b0); - for i in 0..j { - b_true[j + i] = -b0[i]; - } - let n_starts = 8; - for &skew in [false, true].iter() { - let (mut sum_brmse, mut sum_bbias, mut sum_pi, mut sum_acc, mut sum_ari) = - (0.0, 0.0, 0.0, 0.0, 0.0); - for rep in 0..reps { - let seed = 0xA1B2C3D4E5F60718u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add(if skew { 0x9E3779B97F4A7C15 } else { 0 }); - let mut rng = TestRng(seed); - let (y, cls) = simulate_c2(n, j, pi_true, &b0, &a0, skew, &mut rng); - let observed = vec![true; n * j]; - let cfg = MixtureConfig { - n_starts, - seed: seed ^ 0xDEAD, - ..MixtureConfig::default() - }; - let res = fit_mixture(&y, &observed, n, j, 2, MixtureModel::Rasch, &cfg).unwrap(); - let (perm, brmse) = match_c2(&res.b, &b_true, j); - sum_brmse += brmse; - let mut bb = 0.0; - for (c_new, &c_old) in perm.iter().enumerate() { - for i in 0..j { - bb += res.b[c_old * j + i] - b_true[c_new * j + i]; - } - } - sum_bbias += bb / (2 * j) as f64; - sum_pi += (res.pi[perm[0]] - pi_true).abs(); - let inv = if perm == [0, 1] { [0u32, 1] } else { [1u32, 0] }; - let relabeled: Vec = res.map_class.iter().map(|&m| inv[m as usize]).collect(); - sum_acc += relabeled.iter().zip(&cls).filter(|(a, b)| a == b).count() as f64 / n as f64; - sum_ari += ari(&res.map_class, &cls); - } - let r = reps as f64; - println!( - "skew={} n_starts={}: RMSE(b)={:.4} bias(b)={:.4} |dpi|={:.4} MAPacc={:.3} ARI={:.3}", - skew, - n_starts, - sum_brmse / r, - sum_bbias / r, - sum_pi / r, - sum_acc / r, - sum_ari / r - ); - assert!( - sum_brmse / r < 0.20, - "mean RMSE(b) {} skew={skew}", - sum_brmse / r - ); - assert!(sum_pi / r < 0.05, "mean |dpi| {} skew={skew}", sum_pi / r); - assert!(sum_ari / r > 0.55, "mean ARI {} skew={skew}", sum_ari / r); - } -} diff --git a/tests/unit/mmle_tests.rs b/tests/unit/mmle_tests.rs deleted file mode 100644 index 8234eadb6..000000000 --- a/tests/unit/mmle_tests.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::*; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let mx = x.iter().sum::() / n; - let my = y.iter().sum::() / n; - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for i in 0..x.len() { - sxy += (x[i] - mx) * (y[i] - my); - sxx += (x[i] - mx).powi(2); - syy += (y[i] - my).powi(2); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -#[test] -fn recovers_2pl_under_30pct_missing() { - let mut rng = Lcg(12345); - let (n_persons, n_items) = (800usize, 20usize); - let a_true: Vec = (0..n_items).map(|_| 0.7 + 1.3 * rng.next_f64()).collect(); - let b_true: Vec = (0..n_items).map(|_| -1.5 + 3.0 * rng.next_f64()).collect(); - let theta_true: Vec = (0..n_persons).map(|_| rng.normal()).collect(); - - let mut y = vec![0.0_f64; n_persons * n_items]; - let mut observed = vec![true; n_persons * n_items]; - for p in 0..n_persons { - for i in 0..n_items { - let idx = p * n_items + i; - let eta = a_true[i] * theta_true[p] + b_true[i]; - let prob = 1.0 / (1.0 + (-eta).exp()); - y[idx] = if rng.next_f64() < prob { 1.0 } else { 0.0 }; - if rng.next_f64() < 0.30 { - observed[idx] = false; - } - } - } - - let res = fit_mmle_2pl(&y, &observed, n_persons, n_items, &MmleConfig::default()); - assert!(res.converged, "EM should converge"); - for w in res.loglik_trace.windows(2) { - assert!( - w[1] >= w[0] - 1e-6, - "loglik decreased: {} -> {}", - w[0], - w[1] - ); - } - assert!(corr(&res.a, &a_true) > 0.85, "a recovery too low"); - assert!(corr(&res.b, &b_true) > 0.9, "b recovery too low"); - assert!( - corr(&res.theta, &theta_true) > 0.8, - "theta recovery too low" - ); -} - -#[test] -fn all_missing_person_row_is_tolerated() { - let (n_persons, n_items) = (3usize, 4usize); - let y = vec![1.0; n_persons * n_items]; - let mut observed = vec![true; n_persons * n_items]; - for i in 0..n_items { - observed[i] = false; - } - let res = fit_mmle_2pl(&y, &observed, n_persons, n_items, &MmleConfig::default()); - assert!(res.theta.iter().all(|t| t.is_finite())); - assert!( - res.theta[0].abs() < 1e-6, - "all-missing person should shrink to prior mean 0" - ); -} - -#[test] -fn newton_tolerates_singular_hessian_without_ridge() { - // An item that nobody observed carries zero Fisher information. With the - // ridge disabled the per-item Newton Hessian is exactly singular, so the - // solver must hit the `det.abs() < 1e-12` guard and break out of the - // Newton loop instead of dividing by (near-)zero. This exercises the - // singular-Hessian branch in fit_mmle_2pl. - let (n_persons, n_items) = (6usize, 3usize); - let mut y = vec![0.0_f64; n_persons * n_items]; - let mut observed = vec![true; n_persons * n_items]; - for p in 0..n_persons { - // Items 0 and 1 carry a varied, informative response pattern. - y[p * n_items] = (p % 2) as f64; - y[p * n_items + 1] = ((p / 2) % 2) as f64; - // Item 2 is never observed -> zero information for its Newton step. - observed[p * n_items + 2] = false; - } - let cfg = MmleConfig { - ridge_a: 0.0, - ridge_b: 0.0, - max_iter: 50, - ..MmleConfig::default() - }; - let res = fit_mmle_2pl(&y, &observed, n_persons, n_items, &cfg); - - assert!( - res.a.iter().all(|v| v.is_finite()), - "item slopes must stay finite" - ); - assert!( - res.b.iter().all(|v| v.is_finite()), - "item intercepts must stay finite" - ); - assert!( - res.theta.iter().all(|t| t.is_finite()), - "abilities must stay finite" - ); - // The zero-information item keeps its initial (a = 1, b = 0) because the - // Newton step breaks on the singular Hessian before any update applies. - assert_eq!( - res.a[2], 1.0, - "unobserved item slope must stay at its initial value" - ); - assert_eq!( - res.b[2], 0.0, - "unobserved item intercept must stay at its initial value" - ); -} diff --git a/tests/unit/mokken_tests.rs b/tests/unit/mokken_tests.rs deleted file mode 100644 index f93aeef35..000000000 --- a/tests/unit/mokken_tests.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! Tests for Mokken scale analysis (`mlsirm_core::mokken`). -//! -//! Every assert reads values returned by the crate (`MokkenH` fields or the -//! `aisp` label vector). Each test names the crate value it reads and a -//! mutant it kills. - -use super::{aisp, coef_h, normal_upper_quantile}; - -/// Reads: `normal_upper_quantile` directly against published anchors -/// Phi^-1(0.95) = 1.6448536..., Phi^-1(0.999) = 3.0902323... . -/// Kills: sign/branch flips in the Acklam approximation (one such flip was -/// caught by `aisp_z_gate_blocks_insignificant_pair` during development). -#[test] -fn normal_quantile_matches_published_anchors() { - assert!((normal_upper_quantile(0.05) - 1.6448536269514722).abs() < 1e-8); - assert!((normal_upper_quantile(0.001) - 3.090232306167813).abs() < 1e-8); - assert!((normal_upper_quantile(0.5)).abs() < 1e-8); -} - -/// Deterministic xorshift for simulation without external deps. -struct Rng(u64); -impl Rng { - fn new(seed: u64) -> Self { - Rng(seed.max(1)) - } - fn next_f64(&mut self) -> f64 { - let mut x = self.0; - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - self.0 = x; - (x >> 11) as f64 / (1u64 << 53) as f64 - } - /// Standard normal via Box-Muller. - fn next_normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos() - } -} - -/// Simulate Rasch data: P(X=1) = logistic(theta - b). -fn simulate_rasch(rng: &mut Rng, n: usize, bs: &[f64], theta_scale: f64) -> Vec { - let j = bs.len(); - let mut x = vec![0i64; n * j]; - for p in 0..n { - let th = rng.next_normal() * theta_scale; - for (i, &b) in bs.iter().enumerate() { - let pr = 1.0 / (1.0 + (-(th - b)).exp()); - x[p * j + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - x -} - -/// Brute-force oracle: two-pass covariance and sorted-column covariance, -/// computed with a DIFFERENT code path (per-pair, f64 accumulation in a -/// different order) than the crate's matrix construction. -fn oracle_pair(x: &[i64], n: usize, j: usize, a: usize, b: usize) -> (f64, f64) { - let col = |it: usize| -> Vec { (0..n).map(|p| x[p * j + it] as f64).collect() }; - let (ca, cb) = (col(a), col(b)); - let (ma, mb) = ( - ca.iter().sum::() / n as f64, - cb.iter().sum::() / n as f64, - ); - let cov = (0..n).map(|p| (ca[p] - ma) * (cb[p] - mb)).sum::() / (n as f64 - 1.0); - let mut sa = ca.clone(); - let mut sb = cb.clone(); - sa.sort_by(|u, v| u.partial_cmp(v).unwrap()); - sb.sort_by(|u, v| u.partial_cmp(v).unwrap()); - let cmx = (0..n).map(|p| (sa[p] - ma) * (sb[p] - mb)).sum::() / (n as f64 - 1.0); - (cov, cmx) -} - -/// Reads: `MokkenH::hij`, `hi`, `h` on random polytomous data, compared to a -/// brute-force oracle computed by an independent path. -/// Kills: any algebra mutant in `pairwise`/`h_subset` (wrong denominator, -/// mean subtraction, sorted-column pairing, aggregation order). -#[test] -fn coefficients_match_brute_force_oracle() { - let mut rng = Rng::new(42); - let (n, j) = (60, 4); - // polytomous 0..=3 with item-varying marginals - let mut x = vec![0i64; n * j]; - for p in 0..n { - let th = rng.next_normal(); - for i in 0..j { - let mut score = 0i64; - for k in 0..3 { - let cut = -1.0 + i as f64 * 0.4 + k as f64 * 0.8; - if th + rng.next_normal() * 0.7 > cut { - score += 1; - } - } - x[p * j + i] = score; - } - } - let res = coef_h(&x, n, j).expect("fit"); - let mut num_tot = 0.0; - let mut den_tot = 0.0; - for a in 0..j { - let mut num_i = 0.0; - let mut den_i = 0.0; - for b in 0..j { - if a == b { - assert!(res.hij[a * j + b].is_nan()); - continue; - } - let (cov, cmx) = oracle_pair(&x, n, j, a, b); - assert!( - (res.hij[a * j + b] - cov / cmx).abs() < 1e-12, - "Hij[{a},{b}] crate {} oracle {}", - res.hij[a * j + b], - cov / cmx - ); - num_i += cov; - den_i += cmx; - if b > a { - num_tot += cov; - den_tot += cmx; - } - } - assert!((res.hi[a] - num_i / den_i).abs() < 1e-12, "Hi[{a}]"); - } - assert!((res.h - num_tot / den_tot).abs() < 1e-12, "H"); -} - -/// Reads: `MokkenH::h` and `hij` on a perfect Guttman scalogram. -/// Kills: covmax mutants — any error in the sorted-column max covariance -/// breaks the exact H = 1 identity (for a nested dichotomous scalogram every -/// observed pair is already comonotone, so S_ij = Smax_ij). -#[test] -fn perfect_guttman_scalogram_has_h_one() { - // 5 persons x 3 items, nested pattern - let x = vec![ - 0, 0, 0, // - 1, 0, 0, // - 1, 1, 0, // - 1, 1, 1, // - 1, 1, 1, // - ]; - let res = coef_h(&x, 5, 3).expect("fit"); - assert!((res.h - 1.0).abs() < 1e-12, "H = {}", res.h); - for a in 0..3 { - for b in 0..3 { - if a != b { - assert!((res.hij[a * 3 + b] - 1.0).abs() < 1e-12); - } - } - } -} - -/// Reads: `MokkenH::zij`, `z` on a hand-computed 2-item fixture -/// (X = [0,0,0,1,1,1], Y = [0,0,1,0,1,1], N = 6); the exact hand derivation -/// is in the test body. -/// Kills: sqrt(N-1) and variance-product mutants in the Z formula. -#[test] -fn z_statistic_matches_hand_computation() { - let x = vec![ - 0, 0, // - 0, 0, // - 0, 1, // - 1, 0, // - 1, 1, // - 1, 1, // - ]; - let res = coef_h(&x, 6, 2).expect("fit"); - // hand: means .5/.5; centered cross products: - // (-.5)(-.5)*2 + (-.5)(.5) + (.5)(-.5) + (.5)(.5)*2 = .5 - .5 + .5 = 0.5 - // S_xy = 0.5/5 = 0.1 ; s_xx = s_yy = (6*.25)/5 = 0.3 - // Smax: sorted-sorted = comonotone = 1.5/5 = 0.3 -> Hij = 1/3 - // Zij = 0.1*sqrt(5)/sqrt(0.09) = 0.1*2.23606.../0.3 = 0.745355... - let expect_z = 0.1 * 5f64.sqrt() / 0.3; - assert!((res.hij[1] - 1.0 / 3.0).abs() < 1e-12, "Hij = {}", res.hij[1]); - assert!((res.zij[1] - expect_z).abs() < 1e-12, "Zij = {}", res.zij[1]); - // total Z for 2 items equals Zij - assert!((res.z - expect_z).abs() < 1e-12); -} - -/// Reads: `aisp` labels on the same fixture: Hij = 1/3 > c = 0.3 but -/// Zij ~ 0.745 < z_crit(0.05) = 1.645, so NO scale may form. -/// Kills: deleting the start-pair Z significance gate (mutant seeds a scale -/// because Hij exceeds c). -#[test] -fn aisp_z_gate_blocks_insignificant_pair() { - let x = vec![ - 0, 0, // - 0, 0, // - 0, 1, // - 1, 0, // - 1, 1, // - 1, 1, // - ]; - let labels = aisp(&x, 6, 2, 0.3, 0.05).expect("aisp"); - assert_eq!(labels, vec![0, 0], "Z-gate must block the scale"); -} - -/// Reads: `aisp` labels plus `MokkenH::hij`/`hi` on a hand-constructed 80x3 -/// contingency design (profile counts: 19x(1,1,1), 11x(1,1,0), 10x(1,0,0), -/// 10x(0,1,1), 11x(0,0,1), 19x(0,0,0); all marginals 0.5). By construction -/// H01 = 0.5 (start pair), H12 = 0.45, H02 = -0.05, and candidate item 2 -/// passes every other gate at c = 0.15: Hi(2) = 0.2 >= c, Zi(2) ~ 2.51 > -/// z_crit, augmented H = 0.3 >= c. Only the negative-Hij (Criterion 1) -/// exclusion keeps it out. -/// Kills: removing the `hij >= 0` candidate filter in the add loop (the -/// mutant then admits item 2, flipping labels to [1,1,1]). -#[test] -fn aisp_excludes_candidate_with_negative_hij() { - let profiles: [([i64; 3], usize); 6] = [ - ([1, 1, 1], 19), - ([1, 1, 0], 11), - ([1, 0, 0], 10), - ([0, 1, 1], 10), - ([0, 0, 1], 11), - ([0, 0, 0], 19), - ]; - let mut x = Vec::with_capacity(80 * 3); - for (row, count) in profiles { - for _ in 0..count { - x.extend_from_slice(&row); - } - } - let res = coef_h(&x, 80, 3).expect("fit"); - // verify the construction via crate values: item 2 negative with item 0 - // yet passes the Hi gate at c = 0.15 - assert!(res.hij[2 * 3] < 0.0, "Hij(2,0) = {}", res.hij[2 * 3]); - assert!((res.hij[2 * 3] - (-0.05)).abs() < 1e-12); - assert!((res.hi[2] - 0.2).abs() < 1e-12, "Hi(2) = {}", res.hi[2]); - assert!((res.hij[1] - 0.5).abs() < 1e-12, "Hij(0,1) = {}", res.hij[1]); - let labels = aisp(&x, 80, 3, 0.15, 0.05).expect("aisp"); - assert_eq!(labels, vec![1, 1, 0], "Criterion 1 must exclude item 2"); -} - -/// Reads: `aisp` labels on a two-cluster simulation (two independent Rasch -/// dimensions). AISP at c = 0.3 must recover the two clusters exactly. -/// Kills: selection-logic mutants (wrong argmax, wrong exclusion of previous -/// scales, missing multi-scale restart). -#[test] -fn aisp_recovers_two_clusters() { - let mut rng = Rng::new(2013); - let n = 1500; - let bs = [-0.8, -0.3, 0.3, 0.8]; - // cluster A: items 0..=3 driven by theta1; cluster B: items 4..=7 by theta2 - let j = 8; - let mut x = vec![0i64; n * j]; - for p in 0..n { - let t1 = rng.next_normal() * 1.6; - let t2 = rng.next_normal() * 1.6; - for (i, &b) in bs.iter().enumerate() { - let pr1 = 1.0 / (1.0 + (-(t1 - b)).exp()); - let pr2 = 1.0 / (1.0 + (-(t2 - b)).exp()); - x[p * j + i] = if rng.next_f64() < pr1 { 1 } else { 0 }; - x[p * j + 4 + i] = if rng.next_f64() < pr2 { 1 } else { 0 }; - } - } - let labels = aisp(&x, n, j, 0.3, 0.05).expect("aisp"); - let first = labels[0]; - let second = labels[4]; - assert!(first > 0 && second > 0 && first != second, "labels = {labels:?}"); - assert!(labels[..4].iter().all(|&l| l == first), "{labels:?}"); - assert!(labels[4..].iter().all(|&l| l == second), "{labels:?}"); -} - -/// Reads: `MokkenH` fields for score-translation invariance: adding a -/// constant to every score of an item must not change any coefficient -/// (covariances are translation-invariant). -/// Kills: accidental use of raw (uncentered) moments. -#[test] -fn coefficients_invariant_to_score_translation() { - let mut rng = Rng::new(99); - let n = 200; - let x = simulate_rasch(&mut rng, n, &[-0.5, 0.0, 0.5], 1.3); - let mut shifted = x.clone(); - for p in 0..n { - shifted[p * 3 + 1] += 3; // item 1 scored 3..4 instead of 0..1 - } - let a = coef_h(&x, n, 3).expect("fit"); - let b = coef_h(&shifted, n, 3).expect("fit"); - assert!((a.h - b.h).abs() < 1e-12); - for i in 0..3 { - assert!((a.hi[i] - b.hi[i]).abs() < 1e-12); - assert!((a.zi[i] - b.zi[i]).abs() < 1e-12); - } -} - -/// Reads: error `Result`s from both entry points. -/// Kills: deletion of the validation guards. -#[test] -fn rejects_bad_inputs() { - let ok = vec![0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 1]; - assert!(coef_h(&ok, 2, 2).is_err(), "n_persons < 3"); - assert!(coef_h(&ok[..4], 4, 2).is_err(), "length mismatch"); - assert!(coef_h(&[0, -1, 1, 0, 1, 1], 3, 2).is_err(), "negative score"); - assert!(coef_h(&[1, 0, 1, 1, 1, 0], 3, 2).is_err(), "zero variance item 0"); - assert!(coef_h(&ok, 6, 1).is_err(), "single item"); - assert!(aisp(&ok, 6, 2, 1.2, 0.05).is_err(), "c out of range"); - assert!(aisp(&ok, 6, 2, 0.3, 0.0).is_err(), "alpha out of range"); -} - -/// Reads: `aisp` labels on an exact-tie design: X0 == X3 and X1 == X2 -/// (identical columns, Hij = 1 for both pairs) with the two blocks exactly -/// uncorrelated (balanced half-split vs alternating pattern gives sample -/// cov = 0). mokken's eps tie-break (search.normal.R: penalty row*1e-10 on -/// the LARGER member index) must pick pair {1,2} first, so labels are -/// [2, 1, 1, 2]. -/// Kills: reverting to first-encountered lexicographic tie-breaking, which -/// would start with pair {0,3} and yield [1, 2, 2, 1]. -#[test] -fn aisp_tie_break_matches_mokken_eps_rule() { - let n = 40; - let j = 4; - let mut x = vec![0i64; n * j]; - for p in 0..n { - let a = if p < 20 { 1 } else { 0 }; // half-split - let b = (p % 2) as i64; // alternating; sample cov(a, b) = 0 exactly - x[p * j] = a; - x[p * j + 1] = b; - x[p * j + 2] = b; - x[p * j + 3] = a; - } - let labels = aisp(&x, n, j, 0.3, 0.05).expect("aisp"); - assert_eq!(labels, vec![2, 1, 1, 2], "eps tie-break must favor pair {{1,2}}"); -} - -/// Reads: `aisp` labels; independent items (no common trait) must all remain -/// unscalable at c = 0.3. Smoke check of overall gating (not attributed to a -/// single mutant; the Z-gate kill lives in `aisp_z_gate_blocks_insignificant_pair`). -#[test] -fn aisp_leaves_independent_items_unscaled() { - let mut rng = Rng::new(5); - let n = 500; - let j = 5; - let mut x = vec![0i64; n * j]; - for v in x.iter_mut() { - *v = if rng.next_f64() < 0.5 { 1 } else { 0 }; - } - let labels = aisp(&x, n, j, 0.3, 0.05).expect("aisp"); - assert_eq!(labels, vec![0; j], "labels = {labels:?}"); -} - -/// Monte Carlo: >= 500 replications of a unidimensional Rasch scale -/// (normal and skew-positive traits). Reads crate `h` and `aisp` labels. -/// Asserts distributional behavior: mean H within a plausible band and -/// one-scale full recovery in >= 95% of replications. -/// Limitations stated: this cannot pin exact constants; the algebra anchors -/// live in `coefficients_match_brute_force_oracle` and -/// `z_statistic_matches_hand_computation`. -#[test] -#[ignore] -fn monte_carlo_unidimensional_recovery() { - let bs = [-1.0, -0.5, 0.0, 0.5, 1.0]; - let n = 500; - for (label, skew) in [("normal", false), ("skew", true)] { - let mut full = 0usize; - let mut h_sum = 0.0; - let reps = 500; - for rep in 0..reps { - let mut rng = Rng::new(1000 + rep as u64); - let j = bs.len(); - let mut x = vec![0i64; n * j]; - for p in 0..n { - let mut th = rng.next_normal(); - if skew { - // half-normal shifted: skewed positive trait - th = th.abs() * 1.2 - 0.9; - } - th *= 1.5; - for (i, &b) in bs.iter().enumerate() { - let pr = 1.0 / (1.0 + (-(th - b)).exp()); - x[p * j + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let res = coef_h(&x, n, bs.len()).expect("fit"); - h_sum += res.h; - let labels = aisp(&x, n, bs.len(), 0.3, 0.05).expect("aisp"); - if labels.iter().all(|&l| l == 1) { - full += 1; - } - } - let mean_h = h_sum / reps as f64; - assert!( - mean_h > 0.35 && mean_h < 0.75, - "{label}: mean H = {mean_h}" - ); - assert!( - full as f64 / reps as f64 >= 0.95, - "{label}: full-recovery rate = {}", - full as f64 / reps as f64 - ); - } -} diff --git a/tests/unit/nodes_coverage_branch_tests.rs b/tests/unit/nodes_coverage_branch_tests.rs deleted file mode 100644 index 43aaeefbf..000000000 --- a/tests/unit/nodes_coverage_branch_tests.rs +++ /dev/null @@ -1,31 +0,0 @@ -use super::*; - -#[test] -fn gh_rule_none_for_unsupported_size() { - // build_xi_nodes surfaces the gh_rule None branch as an error - assert!(build_xi_nodes(XiRule::GaussHermite { q_xi: 999 }, 1).is_err()); - assert!(crate::quadrature::gh_rule(999).is_none()); - assert!(crate::quadrature::gh_rule(21).is_some()); -} - -#[test] -fn halton_rejects_high_latent_dim() { - assert!(build_xi_nodes( - XiRule::Halton { - n: 8, - shift_seed: 0 - }, - 7 - ) - .is_err()); - // a valid Halton grid with a nonzero shift seed exercises the shift path - let nodes = build_xi_nodes( - XiRule::Halton { - n: 16, - shift_seed: 42, - }, - 2, - ) - .unwrap(); - assert_eq!(nodes.grid.len(), 16 * 2); -} diff --git a/tests/unit/nodes_tests.rs b/tests/unit/nodes_tests.rs deleted file mode 100644 index 9f13a9881..000000000 --- a/tests/unit/nodes_tests.rs +++ /dev/null @@ -1,205 +0,0 @@ -use super::*; - -#[test] -fn gh_tensor_matches_marginal_grid_convention() { - let nodes = build_xi_nodes(XiRule::GaussHermite { q_xi: 7 }, 2).unwrap(); - assert_eq!(nodes.grid.len(), 49 * 2); - let total: f64 = nodes.logw.iter().map(|w| w.exp()).sum(); - assert!((total - 1.0).abs() < 1e-12); -} - -#[test] -fn halton_points_have_moments_of_standard_normal() { - let nodes = build_xi_nodes( - XiRule::Halton { - n: 4096, - shift_seed: 0, - }, - 2, - ) - .unwrap(); - for k in 0..2 { - let vals: Vec = (0..4096).map(|j| nodes.grid[j * 2 + k]).collect(); - let mean = vals.iter().sum::() / 4096.0; - let var = vals.iter().map(|v| (v - mean) * (v - mean)).sum::() / 4096.0; - assert!(mean.abs() < 0.02, "halton mean off: {mean}"); - assert!((var - 1.0).abs() < 0.05, "halton var off: {var}"); - } -} - -#[test] -fn mc_points_are_reproducible_and_gaussian() { - let a = build_xi_nodes(XiRule::MonteCarlo { n: 2048, seed: 42 }, 3).unwrap(); - let b = build_xi_nodes(XiRule::MonteCarlo { n: 2048, seed: 42 }, 3).unwrap(); - assert_eq!(a.grid, b.grid); - let mean = a.grid.iter().sum::() / a.grid.len() as f64; - assert!(mean.abs() < 0.05); -} - -#[test] -fn inv_normal_cdf_reference_values() { - assert!((inv_normal_cdf(0.5)).abs() < 1e-12); - assert!((inv_normal_cdf(0.975) - 1.959963984540054).abs() < 1e-8); - assert!((inv_normal_cdf(0.025) + 1.959963984540054).abs() < 1e-8); - assert!((inv_normal_cdf(1e-6) + 4.753424308822899).abs() < 1e-6); -} - -#[test] -fn rqmc_shift_changes_points_but_not_moments() { - let a = build_xi_nodes( - XiRule::Halton { - n: 1024, - shift_seed: 7, - }, - 2, - ) - .unwrap(); - let b = build_xi_nodes( - XiRule::Halton { - n: 1024, - shift_seed: 0, - }, - 2, - ) - .unwrap(); - assert_ne!(a.grid, b.grid); - let mean = a.grid.iter().sum::() / a.grid.len() as f64; - assert!(mean.abs() < 0.05); -} - -#[test] -fn invalid_rules_rejected() { - assert!(build_xi_nodes(XiRule::GaussHermite { q_xi: 12 }, 2).is_err()); - assert!(build_xi_nodes(XiRule::GaussHermite { q_xi: 7 }, 4).is_err()); - assert!(build_xi_nodes( - XiRule::Halton { - n: 0, - shift_seed: 0 - }, - 2 - ) - .is_err()); - assert!(build_xi_nodes(XiRule::MonteCarlo { n: 0, seed: 1 }, 2).is_err()); -} - -#[test] -fn node_rules_reject_overflow_without_panicking() { - assert!(checked_stochastic_grid_len("test", 2, usize::MAX).is_err()); - for rule in [ - XiRule::Halton { - n: usize::MAX, - shift_seed: 0, - }, - XiRule::MonteCarlo { - n: usize::MAX, - seed: 1, - }, - ] { - let result = std::panic::catch_unwind(|| build_xi_nodes(rule, 2)); - assert!( - result.is_ok(), - "node-size overflow must return Err, not panic" - ); - assert!(result.unwrap().is_err()); - } -} - -#[test] -fn node_rules_reject_oversized_point_counts() { - assert!(build_xi_nodes( - XiRule::Halton { - n: MAX_XI_POINTS + 1, - shift_seed: 0, - }, - 1, - ) - .is_err()); - assert!(build_xi_nodes( - XiRule::MonteCarlo { - n: MAX_XI_POINTS + 1, - seed: 1, - }, - 1, - ) - .is_err()); -} - -#[test] -fn node_rules_reject_unsafe_latent_dimensions() { - assert!(build_xi_nodes( - XiRule::Halton { - n: 1, - shift_seed: 0, - }, - 0, - ) - .is_err()); - assert!(build_xi_nodes(XiRule::MonteCarlo { n: 1, seed: 1 }, MAX_XI_LATENT_DIM + 1,).is_err()); -} - -/// Deterministic LAYOUT pin for the Halton grid at D=4. A finite-difference gradient anchor -/// (used downstream in the MIRT QMC tests) reads the SAME grid for both the analytic and the -/// numeric derivative, so a transposed grid, a wrong prime-to-axis assignment, a dropped `+1` -/// index skip, or a mis-ordered row-major write is fed CONSISTENTLY to both and stays -/// invisible to that check. This pins each cell against an INDEPENDENT recomputation of the -/// exact construction, so any of those layout bugs fails here. -#[test] -fn halton_grid_layout_is_prime_per_axis_row_major() { - let (n, d) = (37usize, 4usize); - let nodes = build_xi_nodes(XiRule::Halton { n, shift_seed: 0 }, d).unwrap(); - assert_eq!(nodes.grid.len(), n * d); - for j in 0..n { - for k in 0..d { - // axis k must use the k-th prime; point j must use radical index j+1 (skip 0). - let expect = inv_normal_cdf( - radical_inverse(j as u64 + 1, HALTON_PRIMES[k]).clamp(1e-12, 1.0 - 1e-12), - ); - assert_eq!( - nodes.grid[j * d + k], - expect, - "halton grid[{j}*{d}+{k}] layout mismatch (prime {})", - HALTON_PRIMES[k] - ); - } - } -} - -/// The QMC weights are equal `-ln(n)` (a uniform average over the prior-sampled nodes). Because -/// this constant cancels in the self-normalized posterior and in every posterior moment, a -/// wrong weight (e.g. `0` or a missing `1/n`) is invisible to every fit-level test and surfaces -/// only as a constant shift in the reported marginal loglik — a direct assertion is the ONLY -/// possible guard. -#[test] -fn qmc_weights_are_uniform_log_of_n() { - for (grid, expect) in [ - ( - build_xi_nodes( - XiRule::Halton { - n: 500, - shift_seed: 0, - }, - 3, - ) - .unwrap(), - -(500f64).ln(), - ), - ( - build_xi_nodes(XiRule::MonteCarlo { n: 750, seed: 5 }, 4).unwrap(), - -(750f64).ln(), - ), - ] { - assert!( - grid.logw.iter().all(|&w| w == expect), - "QMC logw not uniform -ln(n)" - ); - let total: f64 = grid.logw.iter().map(|w| w.exp()).sum(); - assert!((total - 1.0).abs() < 1e-12, "sum exp(logw) != 1: {total}"); - } -} - -#[test] -fn inverse_normal_rejects_probabilities_outside_the_unit_interval() { - assert!(inv_normal_cdf(-f64::EPSILON).is_nan()); - assert!(inv_normal_cdf(1.0 + f64::EPSILON).is_nan()); - assert!(inv_normal_cdf(f64::NAN).is_nan()); -} diff --git a/tests/unit/nominal_tests.rs b/tests/unit/nominal_tests.rs deleted file mode 100644 index e64ae6586..000000000 --- a/tests/unit/nominal_tests.rs +++ /dev/null @@ -1,759 +0,0 @@ -use super::*; -use crate::poly::fit_nominal as fit_nominal_unidim; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } - fn cat(&mut self, probs: &[f64]) -> usize { - let u = self.next_f64(); - let mut acc = 0.0; - for (k, &p) in probs.iter().enumerate() { - acc += p; - if u < acc { - return k; - } - } - probs.len() - 1 - } -} - -fn softmax(eta: &[f64]) -> Vec { - let m = eta.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let ex: Vec = eta.iter().map(|e| (e - m).exp()).collect(); - let s: f64 = ex.iter().sum(); - ex.iter().map(|e| e / s).collect() -} -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::() / a.len() as f64).sqrt() -} -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let (mx, my) = (x.iter().sum::() / n, y.iter().sum::() / n); - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for (a, b) in x.iter().zip(y) { - sxy += (a - mx) * (b - my); - sxx += (a - mx) * (a - mx); - syy += (b - my) * (b - my); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -/// Simulate multidimensional nominal responses from a dense slope tensor (n_items*n_cat*n_dims, -/// baseline cat 0 = 0), intercepts (n_items*n_cat), and traits (n_persons*n_dims). -fn simulate( - slope: &[f64], - intercept: &[f64], - theta: &[f64], - n: usize, - n_items: usize, - n_dims: usize, - n_cat: usize, - rng: &mut Lcg, -) -> Vec { - let mut y = vec![0usize; n * n_items]; - let mut eta = vec![0.0f64; n_cat]; - for p in 0..n { - for i in 0..n_items { - eta[0] = 0.0; - for k in 1..n_cat { - let mut e = intercept[i * n_cat + k]; - for d in 0..n_dims { - e += slope[(i * n_cat + k) * n_dims + d] * theta[p * n_dims + d]; - } - eta[k] = e; - } - let probs = softmax(&eta); - y[p * n_items + i] = rng.cat(&probs); - } - } - y -} - -/// D = 1 REDUCTION: with D=1 and every item's S_i = {0}, fit_nominal reproduces -/// poly::fit_nominal BIT-EXACTLY (same init a_k=k / c_k=log(freq/freq0), same GH nodes+order, -/// same relative-tol + signed-monotone stopping, same nominal_m_step arithmetic generalized). -#[test] -fn nominal_reduces_to_fit_nominal_at_d1() { - let (n, n_items, n_cat) = (1500usize, 6usize, 4usize); - // truth: unidimensional nominal (a_k on the single dim, c_k intercepts) - let mut rng = Lcg(202401); - let mut slope = vec![0.0f64; n_items * n_cat * 1]; - let mut intercept = vec![0.0f64; n_items * n_cat]; - for i in 0..n_items { - for k in 1..n_cat { - slope[(i * n_cat + k) * 1] = 0.4 + 0.35 * k as f64 + 0.05 * i as f64; - intercept[i * n_cat + k] = -0.3 + 0.2 * k as f64 - 0.1 * i as f64; - } - } - let theta: Vec = (0..n).map(|_| rng.normal()).collect(); - let y = simulate(&slope, &intercept, &theta, n, n_items, 1, n_cat, &mut rng); - let pattern = vec![1u8; n_items]; // D=1, all load dim 0 - let cfg = NominalConfig { - q: 21, - ..NominalConfig::default() - }; - let mm = fit_nominal(&y, None, &pattern, n, n_items, 1, n_cat, &cfg).unwrap(); - let fnom = fit_nominal_unidim(&y, None, n, n_items, n_cat, 21, 500, 1e-6).unwrap(); - // loglik traces bit-identical - assert_eq!( - mm.loglik_trace.len(), - fnom.loglik_trace.len(), - "trace length" - ); - let dtrace = mm - .loglik_trace - .iter() - .zip(&fnom.loglik_trace) - .map(|(a, b)| (a - b).abs()) - .fold(0.0f64, f64::max); - assert!(dtrace < 1e-9, "loglik trace diff {dtrace}"); - // scores/intercepts bit-identical (fit_nominal stores z = n_cat-1 free per item; my slope - // has baseline cat 0 = 0 then a_1..a_{K-1} on dim 0). - let z = n_cat - 1; - let mut dmax = 0.0f64; - for i in 0..n_items { - for k in 1..n_cat { - let mine_a = mm.slope[(i * n_cat + k) * 1]; - let theirs_a = fnom.scores[i][k - 1]; - dmax = dmax.max((mine_a - theirs_a).abs()); - let mine_c = mm.intercept[i * n_cat + k]; - let theirs_c = fnom.intercepts[i][k - 1]; - dmax = dmax.max((mine_c - theirs_c).abs()); - } - } - let _ = z; - assert!(dmax < 1e-9, "param diff {dmax}"); - assert_eq!(mm.n_parameters, n_items * 2 * (n_cat - 1)); -} - -/// Deterministic FD GRADIENT anchor on FIXED nodes at D=2 (GH, dims=[0,1]) AND D=4 (Halton, -/// NON-IDENTITY dims=[0,2,3]), with M=4 categories and RANDOM DISTINCT per-category counts so a -/// category<->dimension index transposition or a sign error produces a detectably wrong slot. -/// The M-step uses an FD Hessian, so the correctness-bearing map lives in the GRADIENT — pin -/// EVERY free slot (all a_kd and all c_k) against central differences of the objective. -#[test] -fn nominal_gradient_matches_finite_difference() { - let n_cat = 4usize; - for &(n_dims, ref dims) in [(2usize, vec![0usize, 1]), (4usize, vec![0usize, 2, 3])].iter() { - let l = dims.len(); - let nodes: Vec; - let n_nodes: usize; - if n_dims == 2 { - let xn = build_xi_nodes(XiRule::GaussHermite { q_xi: 15 }, n_dims).unwrap(); - n_nodes = xn.logw.len(); - nodes = xn.grid; - } else { - let xn = build_xi_nodes( - XiRule::Halton { - n: 200, - shift_seed: 0, - }, - n_dims, - ) - .unwrap(); - n_nodes = xn.logw.len(); - nodes = xn.grid; - } - let mut rng = Lcg(2718 + n_dims as u64); - // RANDOM DISTINCT expected counts per (node, category) — not equal across categories. - let counts: Vec> = (0..n_nodes) - .map(|_| (0..n_cat).map(|_| 0.1 + rng.next_f64() * 3.0).collect()) - .collect(); - // free param vector: [a_{1,d..}, a_{2,d..}, .., c_1, c_2, ..] with distinct values - let z = n_cat - 1; - let mut params = vec![0.0f64; z * l + z]; - for m in 0..(z * l) { - params[m] = 0.3 + 0.17 * m as f64 - if m % 2 == 0 { 0.4 } else { 0.0 }; - } - for k in 0..z { - params[z * l + k] = -0.2 + 0.31 * k as f64; - } - let (_f0, grad) = nm_item_neg_ll_grad(¶ms, dims, &nodes, n_dims, &counts, n_cat); - let eps = 1e-6; - for j in 0..params.len() { - let mut pp = params.clone(); - pp[j] += eps; - let (fp, _) = nm_item_neg_ll_grad(&pp, dims, &nodes, n_dims, &counts, n_cat); - let mut pm = params.clone(); - pm[j] -= eps; - let (fm, _) = nm_item_neg_ll_grad(&pm, dims, &nodes, n_dims, &counts, n_cat); - let fd = (fp - fm) / (2.0 * eps); - assert!( - (grad[j] - fd).abs() < 1e-4, - "grad[{j}] {} vs fd {fd} (D={n_dims})", - grad[j] - ); - } - } -} - -// Per-dimension reflection alignment: flip dim d of `est` (negate every category slope on d) so -// its pure-anchor item's category-1 slope matches the sign of `truth`'s. Deterministic; applied -// identically so a genuine sign/compensation bug in `est` survives as a mismatch elsewhere. -fn align_reflection( - est: &mut [f64], - truth: &[f64], - anchor: &[usize], - n_items: usize, - n_cat: usize, - n_dims: usize, -) { - for d in 0..n_dims { - let a = anchor[d]; - let ref_est = est[(a * n_cat + 1) * n_dims + d]; - let ref_tru = truth[(a * n_cat + 1) * n_dims + d]; - if ref_est * ref_tru < 0.0 { - for i in 0..n_items { - for k in 0..n_cat { - est[(i * n_cat + k) * n_dims + d] = -est[(i * n_cat + k) * n_dims + d]; - } - } - } - } -} - -/// D = 2 recovery on GH nodes: pure anchors per dim + a CROSS-loader carrying a genuinely -/// NEGATIVE category slope AND two OPPOSITE-sign sibling categories on the same loaded dim -/// (which catches a mutation collapsing the free per-category slopes to a shared scalar -/// discrimination). Assessed up to per-dimension reflection (aligned to truth). -#[test] -fn nominal_recovers_d2_with_signed_categories() { - let (n_dims, n_cat) = (2usize, 3usize); - // items 0,1 pure dim0; items 2,3 pure dim1; item 4 cross-loader {0,1}. - let pattern: Vec = vec![1, 0, 1, 0, 0, 1, 0, 1, 1, 1]; - let n_items = 5usize; - let anchor = vec![0usize, 2]; // pure anchor per dim - let mut slope = vec![0.0f64; n_items * n_cat * n_dims]; - let mut intercept = vec![0.0f64; n_items * n_cat]; - // pure dim0 anchors: positive, distinct per category - slope[(0 * n_cat + 1) * n_dims + 0] = 1.4; - slope[(0 * n_cat + 2) * n_dims + 0] = 0.8; - slope[(1 * n_cat + 1) * n_dims + 0] = 1.0; - slope[(1 * n_cat + 2) * n_dims + 0] = 1.3; - // pure dim1 anchors - slope[(2 * n_cat + 1) * n_dims + 1] = 1.2; - slope[(2 * n_cat + 2) * n_dims + 1] = 0.9; - slope[(3 * n_cat + 1) * n_dims + 1] = 1.1; - slope[(3 * n_cat + 2) * n_dims + 1] = 1.4; - // cross-loader (item 4): dim0 category-1 NEGATIVE, category-2 POSITIVE (opposite siblings); - // dim1 positive. - slope[(4 * n_cat + 1) * n_dims + 0] = -1.1; // negative sibling - slope[(4 * n_cat + 2) * n_dims + 0] = 1.0; // positive sibling (same dim0) - slope[(4 * n_cat + 1) * n_dims + 1] = 0.9; - slope[(4 * n_cat + 2) * n_dims + 1] = 0.7; - for i in 0..n_items { - for k in 1..n_cat { - intercept[i * n_cat + k] = -0.2 + 0.15 * k as f64 - 0.05 * i as f64; - } - } - let n = 6000usize; - let mut rng = Lcg(9090); - let mut theta = vec![0.0f64; n * n_dims]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let y = simulate( - &slope, &intercept, &theta, n, n_items, n_dims, n_cat, &mut rng, - ); - let cfg = NominalConfig { - q: 21, - ..NominalConfig::default() - }; - let res = fit_nominal(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - assert!(res.converged); - // baseline + off-pattern EXACT zero - for i in 0..n_items { - for d in 0..n_dims { - assert_eq!( - res.slope[(i * n_cat + 0) * n_dims + d], - 0.0, - "baseline slope zero" - ); - if pattern[i * n_dims + d] == 0 { - for k in 0..n_cat { - assert_eq!( - res.slope[(i * n_cat + k) * n_dims + d], - 0.0, - "off-pattern zero" - ); - } - } - } - assert_eq!(res.intercept[i * n_cat + 0], 0.0, "baseline intercept zero"); - } - let mut est = res.slope.clone(); - align_reflection(&mut est, &slope, &anchor, n_items, n_cat, n_dims); - assert!( - rmse(&est, &slope) < 0.16, - "slope RMSE {}", - rmse(&est, &slope) - ); - // the negative cross-loader category-1 slope on dim0 (sign pinned by anchor item 0), and its - // opposite-sign sibling category-2 — both recovered with the right sign. - assert!( - est[(4 * n_cat + 1) * n_dims + 0] < -0.4, - "neg sibling: {}", - est[(4 * n_cat + 1) * n_dims + 0] - ); - assert!( - est[(4 * n_cat + 2) * n_dims + 0] > 0.4, - "pos sibling: {}", - est[(4 * n_cat + 2) * n_dims + 0] - ); - // per-dim trait EAP correlation (sign-aligned) - for d in 0..n_dims { - let mut th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| theta[j * n_dims + d]).collect(); - // align theta sign to truth via the same anchor reference - let ref_est = res.slope[(anchor[d] * n_cat + 1) * n_dims + d]; - let ref_tru = slope[(anchor[d] * n_cat + 1) * n_dims + d]; - if ref_est * ref_tru < 0.0 { - for v in th.iter_mut() { - *v = -*v; - } - } - assert!(corr(&th, &tt) > 0.6, "theta{d} corr {}", corr(&th, &tt)); - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-9, "EM monotone"); - } -} - -/// Softmax-sum, structural zeros, parameter count, and validation guards. -#[test] -fn nominal_validates_and_structural_invariants() { - let (n_dims, n_cat) = (2usize, 3usize); - let pattern: Vec = vec![1, 0, 0, 1, 1, 1]; - let n_items = 3usize; - let n = 400usize; - let mut slope = vec![0.0f64; n_items * n_cat * n_dims]; - let mut intercept = vec![0.0f64; n_items * n_cat]; - slope[(0 * n_cat + 1) * n_dims + 0] = 1.2; - slope[(0 * n_cat + 2) * n_dims + 0] = 1.0; - slope[(1 * n_cat + 1) * n_dims + 1] = 1.1; - slope[(1 * n_cat + 2) * n_dims + 1] = 0.9; - slope[(2 * n_cat + 1) * n_dims + 0] = 0.8; - slope[(2 * n_cat + 2) * n_dims + 0] = 0.7; - slope[(2 * n_cat + 1) * n_dims + 1] = 0.9; - slope[(2 * n_cat + 2) * n_dims + 1] = 0.6; - for i in 0..n_items { - for k in 1..n_cat { - intercept[i * n_cat + k] = 0.1 * k as f64; - } - } - let mut rng = Lcg(55); - let mut theta = vec![0.0f64; n * n_dims]; - for v in theta.iter_mut() { - *v = rng.normal(); - } - let y = simulate( - &slope, &intercept, &theta, n, n_items, n_dims, n_cat, &mut rng, - ); - let cfg = NominalConfig { - q: 15, - max_iter: 30, - ..NominalConfig::default() - }; - let res = fit_nominal(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - // parameter count invariant: sum_i (n_cat-1)*(|S_i|+1) = 2*(1+1) [item0] + 2*(1+1) [item1] + 2*(2+1) [item2] - assert_eq!(res.n_parameters, 2 * 2 + 2 * 2 + 2 * 3); - // softmax probabilities sum to 1 at a few nodes (recompute a category dist for item 2) - let eta = [ - 0.0, - slope[(2 * n_cat + 1) * n_dims + 0], - slope[(2 * n_cat + 2) * n_dims + 0], - ]; - let p = softmax(&eta); - assert!((p.iter().sum::() - 1.0).abs() < 1e-12); - // validation: GH D=4 rejected; no pure anchor rejected; category >= n_cat rejected; - // unobserved category rejected. - let gh4 = NominalConfig::default(); - let pat4: Vec = (0..4) - .flat_map(|d| (0..4).map(move |k| (k == d) as u8)) - .collect(); - // y4 cycles through every category (so the unobserved-category guard does NOT fire): the - // GH D>3 bound must be the SOLE rejection reason, else a NM_MAX_DIMS mutation survives (at - // q=21, 21^4=194481 nodes sits under the node cap, so only the dim bound rejects it). - let y4: Vec = (0..n * 4).map(|idx| idx % n_cat).collect(); - assert!( - fit_nominal(&y4, None, &pat4, n, 4, 4, n_cat, &gh4).is_err(), - "GH D=4 rejected" - ); - // no pure anchor for either dim (all three items load BOTH dims). Uses the full 3-item y so - // the y-length check passes and the pure-anchor identification guard is the failing branch. - let no_anchor: Vec = vec![1, 1, 1, 1, 1, 1]; - assert!( - fit_nominal(&y, None, &no_anchor, n, n_items, n_dims, n_cat, &cfg).is_err(), - "no pure anchor rejected" - ); - // category >= n_cat - let mut ybad = y.clone(); - ybad[0] = n_cat; - assert!( - fit_nominal(&ybad, None, &pattern, n, n_items, n_dims, n_cat, &cfg).is_err(), - "bad category rejected" - ); - // an item with an unobserved category (force item 0 to never show category 2) - let mut ygap = y.clone(); - for p in 0..n { - if ygap[p * n_items + 0] == 2 { - ygap[p * n_items + 0] = 1; - } - } - assert!( - fit_nominal(&ygap, None, &pattern, n, n_items, n_dims, n_cat, &cfg).is_err(), - "unobserved category rejected" - ); -} - -/// Literature-grade Monte-Carlo (>=500 reps): recover the multidimensional nominal at D=2 and -/// D=3 under normal AND per-dim-standardized right-skew traits, assessed up to per-dimension -/// reflection (aligned to truth) with label-invariant backstops (modal-category agreement, -/// per-dim trait EAP correlation). Per-rep monotone-EM + finiteness canaries. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_nominal_recovery_500() { - let reps = 500usize; - let n_cat = 3usize; - for &(n_dims, q, n) in [(2usize, 15usize, 2500usize), (3usize, 11usize, 2000usize)].iter() { - // 2 pure anchors per dim + one cross-loader per dim. - let mut pattern: Vec = Vec::new(); - for d in 0..n_dims { - for _ in 0..2 { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - pattern.extend_from_slice(&r); - } - } - for d in 0..n_dims { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - r[(d + 1) % n_dims] = 1; - pattern.extend_from_slice(&r); - } - let n_items = 2 * n_dims + n_dims; - let anchor: Vec = (0..n_dims).map(|d| 2 * d).collect(); - let mut slope = vec![0.0f64; n_items * n_cat * n_dims]; - let mut intercept = vec![0.0f64; n_items * n_cat]; - for d in 0..n_dims { - slope[((2 * d) * n_cat + 1) * n_dims + d] = 1.3; - slope[((2 * d) * n_cat + 2) * n_dims + d] = 0.8; - slope[((2 * d + 1) * n_cat + 1) * n_dims + d] = 1.0; - slope[((2 * d + 1) * n_cat + 2) * n_dims + d] = 1.2; - } - for d in 0..n_dims { - let ci = 2 * n_dims + d; - slope[(ci * n_cat + 1) * n_dims + d] = 1.0; - slope[(ci * n_cat + 2) * n_dims + d] = 0.7; - let d2 = (d + 1) % n_dims; - slope[(ci * n_cat + 1) * n_dims + d2] = if d % 2 == 0 { 0.7 } else { -0.7 }; - slope[(ci * n_cat + 2) * n_dims + d2] = if d % 2 == 0 { -0.6 } else { 0.6 }; - } - for i in 0..n_items { - for k in 1..n_cat { - intercept[i * n_cat + k] = -0.2 + 0.2 * k as f64 - 0.03 * i as f64; - } - } - for &skew in [false, true].iter() { - let (mut snum, mut sden, mut sbias) = (0.0f64, 0.0f64, 0.0f64); - let (mut csum, mut ccnt) = (0.0f64, 0.0f64); - let mut nconv = 0usize; - for rep in 0..reps { - let mut rng = Lcg(0x9E3779B97F4A7C15u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0xD1B54A32D192ED03) - .wrapping_add(n_dims as u64 * 0x100000001B3)); - let mut theta = vec![0.0f64; n * n_dims]; - for d in 0..n_dims { - let col: Vec = (0..n) - .map(|_| { - if skew { - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / 6f64.sqrt() - } else { - rng.normal() - } - }) - .collect(); - let m = col.iter().sum::() / n as f64; - let v = col.iter().map(|x| (x - m) * (x - m)).sum::() / n as f64; - let sd = v.sqrt(); - for j in 0..n { - theta[j * n_dims + d] = (col[j] - m) / sd; - } - } - let y = simulate( - &slope, &intercept, &theta, n, n_items, n_dims, n_cat, &mut rng, - ); - let cfg = NominalConfig { - q, - ..NominalConfig::default() - }; - let res = fit_nominal(&y, None, &pattern, n, n_items, n_dims, n_cat, &cfg).unwrap(); - if res.converged { - nconv += 1; - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-9, "monotone (rep {rep})"); - } - assert!( - res.slope.iter().all(|v| v.is_finite()), - "finite slope (rep {rep})" - ); - let mut est = res.slope.clone(); - align_reflection(&mut est, &slope, &anchor, n_items, n_cat, n_dims); - for i in 0..n_items { - for k in 1..n_cat { - for d in 0..n_dims { - if pattern[i * n_dims + d] != 0 { - let e = est[(i * n_cat + k) * n_dims + d] - - slope[(i * n_cat + k) * n_dims + d]; - snum += e * e; - sden += 1.0; - sbias += e; - } - } - } - } - for d in 0..n_dims { - let mut th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| theta[j * n_dims + d]).collect(); - let ref_est = res.slope[(anchor[d] * n_cat + 1) * n_dims + d]; - let ref_tru = slope[(anchor[d] * n_cat + 1) * n_dims + d]; - if ref_est * ref_tru < 0.0 { - for v in th.iter_mut() { - *v = -*v; - } - } - csum += corr(&th, &tt); - ccnt += 1.0; - } - } - let srmse = (snum / sden).sqrt(); - let (sb, tc, conv) = (sbias / sden, csum / ccnt, nconv as f64 / reps as f64); - println!( - "[nominal MC D={n_dims} q={q} N={n} skew={skew}] reps={reps} conv={conv:.3} \ - slopeRMSE={srmse:.4} slopeBias={sb:.4} thetaCorr={tc:.3}" - ); - assert!(conv > 0.90, "convergence {conv} (D={n_dims} skew={skew})"); - if skew { - assert!(srmse < 0.30, "skew slope RMSE {srmse} (D={n_dims})"); - assert!(tc > 0.45, "skew theta corr {tc} (D={n_dims})"); - } else { - assert!(sb.abs() < 0.08, "slope bias {sb} (D={n_dims})"); - assert!(srmse < 0.22, "slope RMSE {srmse} (D={n_dims})"); - assert!(tc > 0.5, "theta corr {tc} (D={n_dims})"); - } - } - } -} -#[test] -fn nominal_validation_sampling_rules_and_missing_paths() { - let base = NominalConfig { - q: 7, - max_iter: 1, - newton_iter: 1, - ..NominalConfig::default() - }; - let y = [0usize, 0, 1, 1, 0, 1, 1, 0]; - let observed = [true, false, true, true, true, true, true, true]; - let pattern = [1u8, 1]; - - assert!(validate(&y, None, &pattern, 0, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &pattern, 4, 2, 1, 1, &base).is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &NominalConfig { - max_iter: 0, - ..base - } - ) - .is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &NominalConfig { - max_iter: NM_MAX_ITER + 1, - ..base - } - ) - .is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &NominalConfig { - tol: f64::NAN, - ..base - } - ) - .is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &NominalConfig { ridge: 0.0, ..base } - ) - .is_err()); - assert!(validate(&y, None, &[], 4, 2, 0, 2, &base).is_err()); - assert!(validate(&y, None, &[1; 8], 4, 2, 4, 2, &base).is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &NominalConfig { q: 3, ..base } - ) - .is_err()); - let halton = NominalConfig { - xi_rule: XiRuleKind::Halton, - xi_points: 4, - ..base - }; - assert!(validate(&y, None, &[], 4, 2, 0, 2, &halton).is_err()); - assert!(validate(&y, None, &[1; 14], 4, 2, 7, 2, &halton).is_err()); - assert!(validate( - &y, - None, - &pattern, - 4, - 2, - 1, - 2, - &NominalConfig { - xi_points: 0, - ..halton - }, - ) - .is_err()); - assert!(validate(&y[..7], None, &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, Some(&[true]), &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[1], 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[2, 1], 4, 2, 1, 2, &base).is_err()); - let bad_y = [2usize, 0, 1, 1, 0, 1, 1, 0]; - assert!(validate(&bad_y, None, &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, None, &[0, 1], 4, 2, 1, 2, &base).is_err()); - assert!(validate(&y, Some(&[false; 8]), &pattern, 4, 2, 1, 2, &base).is_err()); - assert!(validate(&[0; 8], None, &pattern, 4, 2, 1, 2, &base).is_err()); - let cross = [1u8, 1, 1, 1]; - assert!(validate(&y, None, &cross, 4, 2, 2, 2, &base).is_err()); - for (n_items, xi_points, expected) in [ - (NM_MAX_NODES, NM_MAX_NODES, "count table"), - (usize::MAX, NM_MAX_NODES, "overflows usize"), - ] { - assert!(validate( - &[], - None, - &[], - 1, - n_items, - 1, - 2, - &NominalConfig { - xi_rule: XiRuleKind::Halton, - xi_points, - ..base - }, - ) - .unwrap_err() - .contains(expected)); - } - assert!(validate(&[], None, &[], usize::MAX, 2, 1, 2, &base) - .unwrap_err() - .contains("n_persons * n_items")); - - for xi_rule in [XiRuleKind::Halton, XiRuleKind::MonteCarlo] { - let result = fit_nominal( - &y, - Some(&observed), - &pattern, - 4, - 2, - 1, - 2, - &NominalConfig { - xi_rule, - xi_points: 16, - xi_seed: 0, - ..base - }, - ) - .unwrap(); - assert_eq!(result.n_iter, 1); - assert_eq!(result.termination_reason, "max_iter_reached"); - assert!(result.loglik_trace.iter().all(|value| value.is_finite())); - assert!(result.theta.iter().all(|value| value.is_finite())); - } -} - -#[test] -fn nominal_optimizer_and_em_diagnostics_cover_defensive_paths() { - let dims = [0usize]; - let nodes = [-2.0, 0.0, 2.0]; - let zero_counts = vec![vec![0.0; 3]; 3]; - let initial = vec![1.0, 2.0, 0.0, 0.0]; - assert_eq!( - nm_m_step(initial.clone(), &dims, &nodes, 1, &zero_counts, 3, 0.1, 2), - initial - ); - let separated_counts = vec![ - vec![1000.0, 0.0, 0.0], - vec![0.0, 1000.0, 0.0], - vec![0.0, 0.0, 1000.0], - ]; - let updated = nm_m_step( - vec![0.0; 4], - &dims, - &nodes, - 1, - &separated_counts, - 3, - -1.0e6, - 2, - ); - assert!(updated.iter().all(|value| value.is_finite())); - assert_eq!(checked_em_loglik_change(-10.0, None, 0).unwrap(), None); - assert_eq!( - checked_em_loglik_change(-9.5, Some(-10.0), 1).unwrap(), - Some(0.5) - ); - assert!(checked_em_loglik_change(f64::NAN, None, 2).is_err()); - assert!(checked_em_loglik_change(-10.5, Some(-10.0), 3).is_err()); -} diff --git a/tests/unit/oakes_tests.rs b/tests/unit/oakes_tests.rs deleted file mode 100644 index 160556bb9..000000000 --- a/tests/unit/oakes_tests.rs +++ /dev/null @@ -1,404 +0,0 @@ -use super::*; -use crate::marginal::{fit_marginal, MarginalConfig, PopulationSpec}; -use crate::{Device, ModelType, PenaltyConfig}; - -#[test] -fn oakes_matches_central_difference_of_the_score() { - // simulate a small 1PL-with-space fit, then check the Oakes assembly - // against the full central difference of the marginal score, and the - // SEs against 1/sqrt(n) scaling expectations. - let mut state = 4242u64; - let mut unif = move || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let (n_persons, n_items) = (400usize, 6usize); - let factor_id = vec![0usize; n_items]; - let b_true: Vec = (0..n_items).map(|i| -1.0 + 0.4 * i as f64).collect(); - let mut y = vec![0.0_f64; n_persons * n_items]; - for p in 0..n_persons { - let u1: f64 = unif().max(1e-12); - let u2: f64 = unif(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let eta: f64 = theta + b_true[i]; - if unif() < 1.0 / (1.0 + (-eta).exp()) { - y[p * n_items + i] = 1.0; - } - } - } - let observed = vec![true; n_persons * n_items]; - let config = ModelConfig { - n_persons, - n_items, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Mirt, - eps_distance: 1e-8, - }; - let mcfg = MarginalConfig { - q_theta: 15, - q_xi: 7, - max_iter: 80, - ..Default::default() - }; - let pen = PenaltyConfig::lsirm_prior(); - let fitted = fit_marginal( - &y, - &observed, - &factor_id, - &config, - &PopulationSpec::Single, - &mcfg, - &pen, - Device::Cpu, - ) - .unwrap(); - let final_change = (fitted.loglik_trace[fitted.loglik_trace.len() - 1] - - fitted.loglik_trace[fitted.loglik_trace.len() - 2]) - .abs(); - assert!(fitted.converged, "Oakes reference fit did not converge"); - assert!(fitted.n_iter < mcfg.max_iter, "fit exhausted max_iter"); - assert!( - final_change < mcfg.tol, - "final likelihood change {final_change} exceeds tolerance {}", - mcfg.tol - ); - assert!(fitted.loglik_trace.iter().all(|value| value.is_finite())); - assert!( - fitted - .loglik_trace - .windows(2) - .all(|pair| pair[1] + 1e-10 >= pair[0]), - "reference EM likelihood was not monotone" - ); - eprintln!( - "Oakes reference: converged=true reason=tolerance_met n_iter={}/{} final_change={} tolerance={}", - fitted.n_iter, mcfg.max_iter, final_change, mcfg.tol - ); - let res = observed_information_oakes( - &y, - &observed, - &factor_id, - &config, - &PopulationSpec::Single, - &mcfg, - &pen, - &fitted.alpha, - &fitted.b, - &fitted.zeta, - fitted.tau, - &fitted.mu, - &fitted.sigma, - fitted.sigma_u, - 1e-5, - ) - .unwrap(); - // MIRT free-alpha: labels alternate alpha/b per item - assert_eq!(res.labels.len(), 2 * n_items); - assert!(res.se.iter().all(|s| s.is_finite() && *s > 0.0)); - // b SEs at n=400 for a 1PL-ish item live in the 0.05..0.5 band - for (lab, se) in res.labels.iter().zip(&res.se) { - if lab.starts_with("b:") { - assert!((0.03..0.6).contains(se), "implausible SE for {lab}: {se}"); - } - } - // internal consistency: Oakes total equals the central FD of the - // marginal score for a couple of probe coordinates - let pv_probe = [1usize, 4usize]; - let pv = ParamVec { - free_alpha: true, - uses_space: false, - tau_free: false, - n_items, - latent_dim: 1, - }; - let (t_nodes, t_weights) = gh_rule(15).unwrap(); - let grids = Grids { - t_nodes: t_nodes.to_vec(), - t_logw: t_weights.iter().map(|w| w.ln()).collect(), - x_grid: vec![0.0; 1], - x_logw: vec![0.0], - q_t: 15, - n_x: 1, - }; - let ctx = build_contexts(&PopulationSpec::Single, &[], &[], 0.0, 1, 15); - let resp = index_responses(&y, &observed, n_persons, n_items); - let xi0 = pv.pack(&fitted.alpha, &fitted.b, &fitted.zeta, fitted.tau); - let score_at = |xv: &[f64]| -> Vec { - let (a0, b0, z0, t0) = pv.unpack(xv); - let tables = build_tables(&a0, &b0, &z0, t0, &config, &factor_id, &ctx, &grids); - let counts = e_step( - &tables, - &resp, - &factor_id, - &config, - &PopulationSpec::Single, - &ctx, - &grids, - ); - q_gradient(&pv, xv, &counts, &ctx, &grids, &config, &factor_id, &pen) - }; - for &j in &pv_probe { - let hj = 1e-5 * (1.0 + xi0[j].abs()); - let mut xp = xi0.clone(); - xp[j] += hj; - let mut xm = xi0.clone(); - xm[j] -= hj; - let sp = score_at(&xp); - let sm = score_at(&xm); - for c in 0..pv.len() { - let fd = -(sp[c] - sm[c]) / (2.0 * hj); - let oakes = res.information[j * pv.len() + c]; - assert!( - (fd - oakes).abs() < 1e-2 * (1.0 + fd.abs()), - "Oakes[{j},{c}] = {oakes} vs FD {fd}" - ); - } - } -} - -#[test] -fn inner_product_q_gradient_does_not_write_a_tau_slot() { - let pv = ParamVec { - free_alpha: true, - uses_space: true, - tau_free: false, - n_items: 1, - latent_dim: 1, - }; - let counts = EStepCounts { - nbar: vec![1.0], - rbar: vec![0.5], - mbar: vec![0.0], - }; - let ctx = Contexts { - n_ctx: 1, - shift: vec![0.0], - scale: vec![1.0], - u_nodes: Vec::new(), - u_logw: Vec::new(), - }; - let grids = Grids { - t_nodes: vec![0.0], - t_logw: vec![0.0], - x_grid: vec![0.25], - x_logw: vec![0.0], - q_t: 1, - n_x: 1, - }; - let config = ModelConfig { - n_persons: 1, - n_items: 1, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Bifac2plm, - eps_distance: 1e-8, - }; - - let gradient = q_gradient( - &pv, - &[0.0, 0.0, 0.1], - &counts, - &ctx, - &grids, - &config, - &[0], - &PenaltyConfig::lsirm_prior(), - ); - - assert_eq!(gradient.len(), pv.len()); - assert!(gradient.iter().all(|value| value.is_finite())); -} - -#[test] -fn oakes_private_numeric_helpers_cover_all_model_shapes() { - let pv = ParamVec { - free_alpha: true, - uses_space: true, - tau_free: true, - n_items: 1, - latent_dim: 2, - }; - let packed = pv.pack(&[0.2], &[-0.3], &[0.4, -0.5], 0.6); - assert_eq!(packed, vec![0.2, -0.3, 0.4, -0.5, 0.6]); - assert_eq!( - pv.unpack(&packed), - (vec![0.2], vec![-0.3], vec![0.4, -0.5], 0.6) - ); - assert_eq!( - pv.labels(), - vec!["alpha:0", "b:0", "zeta:0:0", "zeta:0:1", "tau"] - ); - assert!((sigmoid(2.0) + sigmoid(-2.0) - 1.0).abs() < 1e-12); - - let swapped = invert(vec![0.0, 1.0, 1.0, 0.0], 2).unwrap(); - assert_eq!(swapped, vec![0.0, 1.0, 1.0, 0.0]); - assert!(invert(vec![0.0, 0.0, 0.0, 0.0], 2).is_none()); - assert!(invert_information(vec![0.0; 4], 2).is_err()); - assert_eq!(invert_information(vec![1.0], 1).unwrap(), vec![1.0]); - - let ctx = Contexts { - n_ctx: 1, - shift: vec![0.0], - scale: vec![1.0], - u_nodes: Vec::new(), - u_logw: Vec::new(), - }; - let grids = Grids { - t_nodes: vec![0.5], - t_logw: vec![0.0], - x_grid: vec![0.25, -0.5], - x_logw: vec![0.0], - q_t: 1, - n_x: 1, - }; - let config = ModelConfig { - n_persons: 1, - n_items: 1, - n_dims: 1, - latent_dim: 2, - model_type: ModelType::Mls2plm, - eps_distance: 1e-8, - }; - let penalty = PenaltyConfig::lsirm_prior(); - let gradient = q_gradient( - &pv, - &packed, - &EStepCounts { - nbar: vec![1.0], - rbar: vec![0.75], - mbar: vec![0.0], - }, - &ctx, - &grids, - &config, - &[0], - &penalty, - ); - assert_eq!(gradient.len(), packed.len()); - assert!(gradient.iter().all(|value| value.is_finite())); - let empty_gradient = q_gradient( - &pv, - &packed, - &EStepCounts { - nbar: vec![0.0], - rbar: vec![0.0], - mbar: vec![0.0], - }, - &ctx, - &grids, - &config, - &[0], - &penalty, - ); - assert!(empty_gradient.iter().all(|value| value.is_finite())); -} - -#[test] -fn oakes_rejects_unsupported_modes_and_builds_every_xi_rule() { - let y = [0.0, 1.0, 1.0, 0.0]; - let observed = [true; 4]; - let factor_id = [0, 0]; - let config = ModelConfig { - n_persons: 2, - n_items: 2, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Mlsrm, - eps_distance: 1e-8, - }; - let penalty = PenaltyConfig::lsirm_prior(); - let call = |mcfg: &MarginalConfig, h: f64| { - observed_information_oakes( - &y, - &observed, - &factor_id, - &config, - &PopulationSpec::Single, - mcfg, - &penalty, - &[0.0; 2], - &[0.0; 2], - &[0.0; 2], - -2.0, - &[], - &[], - 0.0, - h, - ) - }; - - assert!(call( - &MarginalConfig { - zero_inflation: true, - ..Default::default() - }, - 1e-5 - ) - .is_err()); - assert!(call( - &MarginalConfig { - q_theta: 9, - ..Default::default() - }, - 1e-5 - ) - .is_err()); - assert!(call(&MarginalConfig::default(), 0.0).is_err()); - assert!(call(&MarginalConfig::default(), f64::NAN).is_err()); - for xi_rule in [ - XiRuleKind::GaussHermite, - XiRuleKind::Halton, - XiRuleKind::MonteCarlo, - ] { - let _ = call( - &MarginalConfig { - q_theta: 7, - q_xi: 7, - xi_points: 8, - xi_seed: 0, - xi_rule, - ..Default::default() - }, - 1e-5, - ); - } -} - -#[test] -fn oakes_rejects_malformed_core_inputs_without_panicking() { - let config = ModelConfig { - n_persons: 2, - n_items: 2, - n_dims: 1, - latent_dim: 1, - model_type: ModelType::Mlsrm, - eps_distance: 1e-8, - }; - let call = |y: &[f64], alpha: &[f64], b: &[f64]| { - observed_information_oakes( - y, - &[true; 4], - &[0, 0], - &config, - &PopulationSpec::Single, - &MarginalConfig::default(), - &PenaltyConfig::lsirm_prior(), - alpha, - b, - &[0.0; 2], - -2.0, - &[], - &[], - 0.0, - 1e-5, - ) - }; - - assert!(call(&[0.0], &[0.0; 2], &[0.0; 2]).is_err()); - assert!(call(&[0.0; 4], &[0.0], &[0.0; 2]).is_err()); - assert!(call(&[0.0; 4], &[0.0; 2], &[0.0, f64::NAN]).is_err()); -} diff --git a/tests/unit/parallel_tests.rs b/tests/unit/parallel_tests.rs deleted file mode 100644 index d87635a45..000000000 --- a/tests/unit/parallel_tests.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Tests for Horn's parallel analysis (`crate::parallel`). -//! -//! Fixture literals were computed by an independent NumPy replication -//! (session `files/pa_fixture.py`) that mirrors this crate's LCG/Box-Muller -//! stream with explicit `np.uint64` arithmetic and uses `np.corrcoef` + -//! `np.linalg.eigvalsh` (LAPACK) for the linear algebra, so every pinned -//! value cross-checks the Jacobi eigensolver, the correlation code, the -//! benchmark reduction, and the adjustment/retention pipeline against an -//! implementation that shares no code with this crate. Tolerance 1e-9 -//! (Jacobi converges to 1e-12 off-diagonal; LAPACK differs in the last bits). -//! -//! Mutation kills verified by hand (each mutant compiled and observed to -//! FAIL before being reverted): -//! - M1 uncentered correlation (skip mean subtraction): fixture A data has -//! nonzero column means by construction -> `fixture_a_mean_benchmark` fails. -//! - M2 quantile without interpolation (index `ceil(h)`): fixture B's -//! `h = 49 * 0.95 = 46.55` is fractional -> `fixture_b_centile_95` fails. -//! - M3 bias without the `- 1` (paran's CFA formula): fixture A bias/adjusted -//! literals and retained count shift -> `fixture_a_mean_benchmark` fails. -//! - M4 retention as `count(adj > 1)` instead of first-failure scan: -//! `retention_scan_stops_at_first_failure` uses [1.2, 0.9, 1.4] -> fails. -//! - M5 eigenvalues not re-sorted descending after Jacobi: -//! `eigen_order_anchor`'s matrix diagonalizes with an ascending diagonal -> -//! fails without the explicit sort. -//! -//! Known identity limits: the trace identity (sum of eigenvalues = p) holds -//! for any trace-preserving mutant and is asserted only as a sanity check, -//! never as the sole discriminating assert; the discriminating anchors are -//! the externally computed fixture literals above. - -use super::{parallel_analysis, retained_count, symmetric_eigenvalues_desc}; - -const TOL: f64 = 1e-9; - -const PA_N: usize = 40; -const PA_P: usize = 6; -#[rustfmt::skip] -const PA_DATA: [f64; 240] = [ - 0.333503, -2.453654, 3.848587, 0.369779, -1.916606, 4.949936, - 1.026322, -2.084842, 3.11915, -0.8241, -3.145648, 4.694622, - 1.552238, -2.359914, 2.981319, -0.127321, -3.118339, 3.789723, - 1.09053, -1.706376, 3.630015, -1.237264, -1.718574, 3.712301, - -0.011874, -2.792449, 2.230646, 0.954288, -0.676455, 4.202065, - 2.046904, -1.840269, 4.448778, -0.1642, -2.330755, 4.361154, - -0.070695, -3.141409, 3.780624, 0.945734, -0.868845, 6.67772, - 0.325687, -2.210104, 2.360288, 1.214687, -2.085856, 3.844716, - 1.987676, 0.250326, 4.229779, 1.201447, -1.766095, 4.466545, - 2.792376, -0.794664, 3.711836, 1.464382, -1.295142, 4.959527, - 0.333378, -2.316583, 2.953186, 1.215496, -1.016352, 5.35509, - 1.020831, -2.288995, 3.424951, -0.095608, -1.134892, 5.041666, - -0.258048, -2.688289, 3.104094, 0.41638, -1.675181, 5.081826, - 0.533767, -2.231017, 3.483801, 0.294858, -2.387986, 3.88554, - 0.603806, -2.543523, 3.252245, -0.091563, -1.735494, 4.115728, - 0.172629, -2.915245, 3.586819, 1.242912, -1.56492, 4.770616, - 2.226923, 0.563981, 4.284004, 1.589635, -0.429457, 5.496989, - 1.250357, -2.280015, 3.148378, 1.992453, -0.799593, 5.95754, - 1.20547, -2.280869, 4.473257, 0.455678, -2.19263, 4.121817, - 0.916669, -1.318089, 3.419921, 0.945613, -1.384316, 6.121018, - 1.196008, -1.730592, 3.418524, 1.383073, -1.061234, 5.365921, - -0.257571, -1.96941, 1.970674, 0.093193, -1.333776, 3.897921, - 0.61962, -1.820835, 4.064294, 2.006661, -0.789629, 4.325192, - 2.312542, -0.812288, 3.325681, 0.5785, -1.732155, 4.437357, - 1.41419, -1.973323, 3.083683, 1.075274, -1.480239, 5.052933, - 1.505653, -1.056415, 3.252816, 1.429761, -0.88199, 5.940036, - 1.066391, -1.652664, 4.712282, 1.062132, -0.848488, 4.366314, - 1.464433, -2.137135, 3.227048, 1.539832, -1.064145, 5.23423, - -0.135288, -1.620645, 3.866206, 0.174687, -0.699639, 5.210777, - 0.278362, -2.071635, 3.14087, 1.25105, -0.85097, 5.61079, - -0.032029, -3.293086, 3.63403, 0.508087, -0.659782, 4.454752, - 1.522665, -1.992241, 3.660703, 0.375077, -1.749287, 5.780725, - 0.471204, -2.859986, 2.957715, 1.59788, 0.069784, 4.820623, - 1.235528, -1.710864, 3.226485, 0.989522, -0.743298, 4.939406, - 0.587199, -3.078375, 3.47583, -0.519378, -2.137547, 3.895758, - -0.399063, -3.111449, 2.544858, 0.649683, -1.086784, 5.043018, - -0.013262, -4.922791, 2.067882, 1.336552, -1.08281, 4.974112, - 1.240054, -1.573638, 3.159136, 1.606172, -0.508916, 5.737783, - 1.17282, -1.835477, 3.961551, 2.061825, -0.716887, 5.814711, - 2.13695, -0.577062, 4.669558, 1.289963, 0.226037, 5.254548, -]; - -const PA_A_EV: [f64; 6] = [ - 2.3959774381235563, - 1.895617877600974, - 0.6090518440099216, - 0.5342611004737847, - 0.3505459023772192, - 0.21454583741454453, -]; -const PA_A_RND: [f64; 6] = [ - 1.5202762241108199, - 1.2751860889451079, - 1.0687715522961592, - 0.8847511020131834, - 0.7182772464541606, - 0.5327377861805684, -]; -const PA_A_BIAS: [f64; 6] = [ - 0.5202762241108199, - 0.2751860889451079, - 0.06877155229615917, - -0.11524889798681659, - -0.2817227535458394, - -0.46726221381943156, -]; -const PA_A_ADJ: [f64; 6] = [ - 1.8757012140127365, - 1.6204317886558661, - 0.5402802917137625, - 0.6495099984606013, - 0.6322686559230586, - 0.6818080512339761, -]; - -const PA_B_RND: [f64; 6] = [ - 1.726611194216871, - 1.4248947316409448, - 1.202107881131543, - 0.9872604909299066, - 0.8367765031192741, - 0.6608230139628977, -]; -const PA_B_ADJ: [f64; 6] = [ - 1.6693662439066854, - 1.4707231459600292, - 0.40694396287837853, - 0.5470006095438781, - 0.5137693992579451, - 0.5537228234516469, -]; - -const PA_D_RND: [f64; 2] = [1.265490626983912, 0.734509373016088]; -const PA_D_ADJ: [f64; 2] = [1.734509373016088, 0.265490626983912]; - -fn assert_close(got: &[f64], want: &[f64], label: &str) { - assert_eq!(got.len(), want.len(), "{label} length"); - for (i, (g, w)) in got.iter().zip(want).enumerate() { - assert!((g - w).abs() < TOL, "{label}[{i}]: got {g}, want {w}"); - } -} - -/// Kills M1 (uncentered correlation: nonzero column means shift every -/// eigenvalue) and M3 (bias without -1: bias/adjusted/retained all shift). -/// Every assert reads crate outputs from `parallel_analysis`. -#[test] -fn fixture_a_mean_benchmark() { - let res = parallel_analysis(&PA_DATA, PA_N, PA_P, 50, 0, 42).expect("fixture A"); - assert_close(&res.eigenvalues, &PA_A_EV, "eigenvalues"); - assert_close(&res.random_eigenvalues, &PA_A_RND, "random_eigenvalues"); - assert_close(&res.bias, &PA_A_BIAS, "bias"); - assert_close(&res.adjusted_eigenvalues, &PA_A_ADJ, "adjusted"); - assert_eq!(res.retained, 2); - // Sanity only (identity — holds for trace-preserving mutants too): - let trace: f64 = res.eigenvalues.iter().sum(); - assert!((trace - PA_P as f64).abs() < 1e-8, "trace identity"); -} - -/// Kills M2 (quantile without interpolation): with 50 replicates the type-7 -/// index h = 49 * 0.95 = 46.55 is fractional, so a ceil-indexed mutant -/// changes `random_eigenvalues`. Asserts read crate outputs. -#[test] -fn fixture_b_centile_95() { - let res = parallel_analysis(&PA_DATA, PA_N, PA_P, 50, 95, 42).expect("fixture B"); - assert_close(&res.eigenvalues, &PA_A_EV, "eigenvalues (same data)"); - assert_close(&res.random_eigenvalues, &PA_B_RND, "random_eigenvalues"); - assert_close(&res.adjusted_eigenvalues, &PA_B_ADJ, "adjusted"); - assert_eq!(res.retained, 2); - // The 95th-centile benchmark must dominate the mean benchmark (Glorfeld's - // point); read from crate outputs of both runs. - let mean_run = parallel_analysis(&PA_DATA, PA_N, PA_P, 50, 0, 42).expect("mean run"); - for (b, a) in res - .random_eigenvalues - .iter() - .zip(&mean_run.random_eigenvalues) - { - assert!(b > a, "95th centile must exceed mean per position"); - } -} - -/// Duplicate columns: observed correlation eigenvalues are analytically -/// {2, 0}; the rest of the pipeline is pinned deterministically. -#[test] -fn fixture_d_duplicate_columns() { - let mut data = Vec::with_capacity(24); - for i in 1..=12 { - data.push(i as f64); - data.push(i as f64); - } - let res = parallel_analysis(&data, 12, 2, 20, 0, 7).expect("fixture D"); - assert!((res.eigenvalues[0] - 2.0).abs() < TOL, "lambda_1 = 2"); - assert!(res.eigenvalues[1].abs() < TOL, "lambda_2 = 0"); - assert_close(&res.random_eigenvalues, &PA_D_RND, "random_eigenvalues"); - assert_close(&res.adjusted_eigenvalues, &PA_D_ADJ, "adjusted"); - assert_eq!(res.retained, 1); -} - -/// Kills M4 (count(adj > 1) instead of the first-failure scan, paran.R lines -/// 250-267): the resurgent third value 1.4 must NOT be retained. Reads the -/// crate's `retained_count` directly. -#[test] -fn retention_scan_stops_at_first_failure() { - assert_eq!(retained_count(&[1.2, 0.9, 1.4]), 1); - assert_eq!(retained_count(&[0.9, 1.4]), 0); - assert_eq!(retained_count(&[1.2, 1.1]), 2); - // Boundary: adj == 1 exactly is NOT retained (paran: AdjEv <= 1 breaks). - assert_eq!(retained_count(&[1.0, 2.0]), 0); -} - -/// Kills M5 (missing descending sort after Jacobi): this matrix's Jacobi -/// diagonal converges in ascending-ish order; eigenvalues are analytically -/// {1.9, 1.0, 0.1} (2x2 block [[1, .9], [.9, 1]] plus isolated 1). Reads the -/// crate eigensolver output. -#[test] -fn eigen_order_anchor() { - #[rustfmt::skip] - let m = [ - 1.0, 0.0, 0.9, - 0.0, 1.0, 0.0, - 0.9, 0.0, 1.0, - ]; - let ev = symmetric_eigenvalues_desc(&m, 3).expect("jacobi"); - assert_close(&ev, &[1.9, 1.0, 0.1], "eigenvalues descending"); -} - -#[test] -fn rejections() { - let ok = [1.0, 2.0, 2.0, 1.0, 3.0, 5.0, 4.0, 4.5, 5.5, 7.0, 6.5, 8.0]; - assert!(parallel_analysis(&ok, 6, 2, 10, 0, 1).is_ok()); - // Too few persons / items. - assert!(parallel_analysis(&ok[..4], 2, 2, 10, 0, 1).is_err()); - assert!(parallel_analysis(&ok[..6], 6, 1, 10, 0, 1).is_err()); - // Zero iterations, out-of-range centile. - assert!(parallel_analysis(&ok, 6, 2, 0, 0, 1).is_err()); - assert!(parallel_analysis(&ok, 6, 2, 10, 100, 1).is_err()); - // Non-finite cell. - let mut nan = ok; - nan[3] = f64::NAN; - assert!(parallel_analysis(&nan, 6, 2, 10, 0, 1).is_err()); - // Constant column (zero variance). - let constant = [1.0, 2.0, 1.0, 3.0, 1.0, 5.0, 1.0, 4.5, 1.0, 7.0, 1.0, 8.0]; - let err = parallel_analysis(&constant, 6, 2, 10, 0, 1).unwrap_err(); - assert!(err.contains("zero variance"), "got: {err}"); - // Length mismatch. - assert!(parallel_analysis(&ok, 5, 2, 10, 0, 1).is_err()); -} - -/// Monte-Carlo consistency check (>= 500 reps, ignored by default): for a -/// strong 2-factor design, parallel analysis should retain exactly 2 -/// components in at least 90% of replications (threshold deliberately below -/// the empirical rate to be binomial-safe; empirically the rate is ~100%). -/// Run with: cargo test -p mlsirm-core mc_two_factor_retention -- --ignored -#[test] -#[ignore] -fn mc_two_factor_retention() { - let n = 200; - let p = 10; - let mut hits = 0usize; - let reps = 500usize; - for rep in 0..reps { - // Generate 2-factor data with the same LCG idiom, offset seed stream. - let mut state = 1_000_003_u64.wrapping_mul(rep as u64 + 1) | 1; - let uniform = |st: &mut u64| { - *st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (*st >> 11) as f64 / (1u64 << 53) as f64 - }; - let normal = |st: &mut u64| { - let u1 = uniform(st).max(1e-12); - let u2 = uniform(st); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let mut data = vec![0.0_f64; n * p]; - for row in 0..n { - let f1 = normal(&mut state); - let f2 = normal(&mut state); - for j in 0..p { - let loading_f = if j < 5 { f1 } else { f2 }; - let loading = if j % 5 < 3 { 0.7 } else { 0.6 }; - data[row * p + j] = loading * loading_f + 0.5 * normal(&mut state); - } - } - let res = - parallel_analysis(&data, n, p, 100, 0, 42 + rep as u64).expect("MC parallel analysis"); - if res.retained == 2 { - hits += 1; - } - } - let rate = hits as f64 / reps as f64; - assert!( - rate >= 0.90, - "2-factor retention rate {rate} below 0.90 ({hits}/{reps})" - ); -} - -/// Impl-review regressions: finite-but-huge data must Err (not silently -/// return a bogus identity correlation), and n_iterations * n_items must -/// use checked arithmetic. Both asserts read the crate Result. -/// Mutations killed: removing the is_finite guards in correlation_matrix -/// (first assert flips to Ok), removing checked_mul (second panics in -/// release wrap-around). -#[test] -fn overflow_inputs_rejected() { - let big = 1e308_f64; - let data = [big, big, big, -big, -big, big, -big, -big]; - assert!(parallel_analysis(&data, 4, 2, 5, 0, 7).is_err()); - - let ok = [1.0, 2.0, 2.0, 1.0, 3.0, 5.0, 4.0, 4.5]; - assert!(parallel_analysis(&ok, 4, 2, usize::MAX, 0, 7).is_err()); -} diff --git a/tests/unit/poly_marginal_tests.rs b/tests/unit/poly_marginal_tests.rs deleted file mode 100644 index 55a52cfb3..000000000 --- a/tests/unit/poly_marginal_tests.rs +++ /dev/null @@ -1,265 +0,0 @@ -use super::*; - -#[test] -fn lsirm_rejects_unbounded_categories_and_iterations() { - let y = [0usize]; - assert!(fit_poly_lsirm( - &y, - None, - 1, - 1, - POLY_MAX_CAT + 1, - 1, - PolyModel::Grm, - 7, - 7, - 1, - 1e-6, - ) - .is_err()); - assert!(fit_poly_lsirm( - &y, - None, - 1, - 1, - 2, - 1, - PolyModel::Grm, - 7, - 7, - POLY_MAX_ITER + 1, - 1e-6, - ) - .is_err()); -} - -#[test] -fn poly_marginal_boundaries_and_grm_paths_are_explicit() { - assert!(xi_tensor_grid(99, 1).is_err()); - assert_eq!(xi_tensor_grid(41, 100).unwrap_err(), "xi grid too large"); - assert_eq!( - xi_tensor_grid(41, 4).unwrap_err(), - "q_xi ** latent_dim exceeds the tensor-grid limit" - ); - - let thresholds = [0.5, -0.5]; - let counts = [1.0, 2.0, 1.0]; - let lp = poly_cell(0.25, PolyModel::Grm, &thresholds, 3); - assert_eq!(lp.len(), 3); - assert!((lp.iter().map(|v| v.exp()).sum::() - 1.0).abs() < 1e-12); - let (g_thresholds, g_base) = poly_cat_grad(0.25, PolyModel::Grm, &thresholds, &counts); - assert_eq!(g_thresholds.len(), 2); - assert!(g_thresholds.iter().all(|v| v.is_finite())); - assert!(g_base.is_finite()); - - let theta = [0.0]; - let xi = [0.0]; - let empty_counts = [0.0, 0.0, 0.0]; - let ctx = ItemCtx { - model: PolyModel::Grm, - n_cat: 3, - latent_dim: 1, - eps: 1e-8, - theta: &theta, - xi_grid: &xi, - n_xi: 1, - rbar_i: &empty_counts, - lambda_alpha: 0.0, - mu_alpha: 0.0, - lambda_zeta: 0.0, - }; - let (objective, gradient) = item_neg_ll_grad(&[0.0, 0.5, -0.5, 0.0], &ctx); - assert_eq!(objective, 0.0); - assert_eq!(gradient, vec![0.0; 4]); - - assert!(fit_poly_lsirm(&[], None, 0, 1, 2, 1, PolyModel::Grm, 7, 7, 1, 1e-6).is_err()); - assert!(fit_poly_lsirm(&[], None, 1, 0, 2, 1, PolyModel::Grm, 7, 7, 1, 1e-6).is_err()); - assert!(fit_poly_lsirm(&[], None, 1, 1, 2, 1, PolyModel::Grm, 7, 7, 1, 1e-6).is_err()); - let overflow_lsirm = std::panic::catch_unwind(|| { - fit_poly_lsirm( - &[], - None, - usize::MAX / 2 + 1, - 2, - 2, - 1, - PolyModel::Grm, - 7, - 7, - 1, - 1e-6, - ) - }); - assert!( - overflow_lsirm.is_ok(), - "overflowed poly LSIRM shape must return an error instead of panicking" - ); - // Reads crate Result. Kills the mutation that multiplies n_persons*n_items - // directly before checking response length. - assert!(overflow_lsirm.unwrap().is_err()); - assert!(fit_poly_lsirm(&[0], Some(&[]), 1, 1, 2, 1, PolyModel::Grm, 7, 7, 1, 1e-6,).is_err()); - assert!(fit_poly_lsirm( - &[3], - Some(&[true]), - 1, - 1, - 3, - 1, - PolyModel::Grm, - 7, - 7, - 1, - 1e-6, - ) - .is_err()); - - // Reads crate output (`fit.loglik`, dimensions below). Kills the mutation - // that validates masked cells or indexes `freq[y]` without the observed guard. - let fit = fit_poly_lsirm( - &[0, 99], - Some(&[true, false]), - 2, - 1, - 3, - 3, - PolyModel::Grm, - 7, - 7, - 1, - 1e-6, - ) - .unwrap(); - assert_eq!(fit.n_iter, 1); - assert!(fit.loglik.is_finite()); - assert_eq!(fit.slope.len(), 1); - assert_eq!(fit.cat_params[0].len(), 2); - assert_eq!(fit.zeta.len(), 3); - assert_eq!(fit.theta_eap.len(), 2); - assert_eq!(fit.theta_sd.len(), 2); - assert_eq!(fit.xi_eap.len(), 6); - assert!(fit - .theta_eap - .iter() - .chain(&fit.theta_sd) - .chain(&fit.xi_eap) - .all(|v| v.is_finite())); -} - -fn dist_matrix(z: &[f64], n: usize, d: usize) -> Vec { - let mut out = Vec::new(); - for i in 0..n { - for j in i + 1..n { - let mut s = 0.0; - for k in 0..d { - let dd = z[i * d + k] - z[j * d + k]; - s += dd * dd; - } - out.push(s.sqrt()); - } - } - out -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64).sqrt() -} - -#[test] -fn fit_poly_lsirm_recovers_positions_and_slopes() { - let (n_persons, n_items, k, ld) = (1500usize, 6usize, 3usize, 2usize); - let mut st = 314159u64; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - macro_rules! nrm { - () => {{ - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0_f64 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }}; - } - // true item positions on two separated clusters, slopes, GPCM intercepts - let mut zeta_true = vec![0.0_f64; n_items * ld]; - for i in 0..n_items { - let cx = if i < n_items / 2 { -1.2 } else { 1.2 }; - zeta_true[i * ld] = cx + 0.3 * nrm!(); - zeta_true[i * ld + 1] = 0.3 * nrm!(); - } - let a_true: Vec = (0..n_items).map(|i| 1.0 + 0.08 * i as f64).collect(); - let c_true: Vec> = (0..n_items) - .map(|i| vec![0.0, 0.2 - 0.05 * i as f64, -0.2 + 0.05 * i as f64]) - .collect(); - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut y = vec![0usize; n_persons * n_items]; - let mut theta_true = vec![0.0_f64; n_persons]; - for p in 0..n_persons { - let theta = nrm!(); - theta_true[p] = theta; - let xi: Vec = (0..ld).map(|_| nrm!()).collect(); - for i in 0..n_items { - let mut dist2 = 1e-8; - for kk in 0..ld { - let dd = xi[kk] - zeta_true[i * ld + kk]; - dist2 += dd * dd; - } - let base = a_true[i] * theta - dist2.sqrt(); - let mut ic = vec![0.0; k]; - ic[1..].copy_from_slice(&c_true[i][1..]); - let lp = gpcm_logprobs(base, &scores, &ic); - let uu = u(); - let mut cum = 0.0; - let mut cat = k - 1; - for (c, l) in lp.iter().enumerate() { - cum += l.exp(); - if uu < cum { - cat = c; - break; - } - } - y[p * n_items + i] = cat; - } - } - let fit = fit_poly_lsirm( - &y, - None, - n_persons, - n_items, - k, - ld, - PolyModel::Gpcm, - 7, - 7, - 40, - 1e-5, - ) - .unwrap(); - assert!(fit.loglik.is_finite()); - // ABSOLUTE-agreement checks (correlation only shows association, not - // identity): slope RMSE, and RMSE of the item-item distance matrix, which - // is exactly invariant to the position rotation/reflection/translation - // ambiguity while gamma = 1 fixes its absolute scale. - let slope_rmse = rmse(&a_true, &fit.slope); - assert!(slope_rmse < 0.25, "slope RMSE {slope_rmse}"); - let dm_true = dist_matrix(&zeta_true, n_items, ld); - let dm_hat = dist_matrix(&fit.zeta, n_items, ld); - let pos_rmse = rmse(&dm_true, &dm_hat); - assert!(pos_rmse < 0.6, "position distance-matrix RMSE {pos_rmse}"); - // person trait recovery: EAP is shrunk toward the prior, so correlation - // (association) is the appropriate metric here, not RMSE - let corr = { - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let (mt, me) = (mean(&theta_true), mean(&fit.theta_eap)); - let (mut num, mut dt, mut de) = (0.0, 0.0, 0.0); - for p in 0..n_persons { - num += (theta_true[p] - mt) * (fit.theta_eap[p] - me); - dt += (theta_true[p] - mt).powi(2); - de += (fit.theta_eap[p] - me).powi(2); - } - num / (dt.sqrt() * de.sqrt()) - }; - assert!(corr > 0.6, "theta EAP corr {corr}"); - assert!(fit.theta_sd.iter().all(|s| s.is_finite() && *s > 0.0)); -} diff --git a/tests/unit/poly_tests.rs b/tests/unit/poly_tests.rs deleted file mode 100644 index 1aca59fdd..000000000 --- a/tests/unit/poly_tests.rs +++ /dev/null @@ -1,3089 +0,0 @@ -use super::*; - -#[test] -fn fitters_reject_unbounded_categories_and_iterations() { - let y = [0usize]; - assert!( - fit_poly_unidim(&y, None, 1, 1, POLY_MAX_CAT + 1, PolyModel::Grm, 7, 1, 1e-6,).is_err() - ); - assert!(fit_poly_unidim( - &y, - None, - 1, - 1, - 2, - PolyModel::Grm, - 7, - POLY_MAX_ITER + 1, - 1e-6, - ) - .is_err()); -} - -#[test] -fn poly_public_boundaries_and_small_diagnostic_paths() { - assert_eq!(grm_logprobs(0.0, &[]), vec![0.0]); - assert_eq!(grm_node_gradient(0.0, &[], &[]), (0.0, Vec::new())); - let (base, threshold) = grm_node_gradient(0.0, &[0.5], &[0.0, 0.0]); - assert_eq!(base, 0.0); - assert_eq!(threshold, vec![0.0]); - assert_eq!(solve_small(vec![vec![0.0]], vec![3.0]), vec![3.0]); - let swapped = solve_small(vec![vec![0.0, 1.0], vec![2.0, 3.0]], vec![1.0, 5.0]); - assert!(swapped.iter().all(|value| value.is_finite())); - let (fallback, directional, maximum) = stabilized_newton_step(vec![f64::NAN], &[3.0], 3.0); - assert_eq!(fallback, vec![2.0]); - assert_eq!(directional, 6.0); - assert_eq!(maximum, 2.0); - assert_eq!( - stabilized_newton_step(vec![1.0], &[1.0], 1.0), - (vec![1.0], 1.0, 1.0) - ); - assert_eq!(checked_em_delta(-4.0, None, 1e-6, 0).unwrap(), None); - assert!(checked_em_delta(f64::NAN, None, 1e-6, 0).is_err()); - assert!(checked_em_delta(-5.0, Some(-4.0), 1e-6, 1).is_err()); - let accepted = checked_em_delta(-3.5, Some(-4.0), 1e-6, 1) - .unwrap() - .unwrap(); - assert_eq!(accepted.0, 0.5); - assert!((accepted.1 - 5e-6).abs() < 1e-15); - assert!(matches!( - multigroup_em_status(f64::NAN, None, 1e-6), - MultigroupEmStatus::NonFinite - )); - assert!(matches!( - multigroup_em_status(-10.0, None, 1e-6), - MultigroupEmStatus::First - )); - assert!(matches!( - multigroup_em_status(-11.0, Some(-10.0), 1e-6), - MultigroupEmStatus::NonMonotone - )); - assert!(matches!( - multigroup_em_status(-9.999_999, Some(-10.0), 1e-6), - MultigroupEmStatus::Converged { .. } - )); - assert!(matches!( - multigroup_em_status(-9.0, Some(-10.0), 1e-6), - MultigroupEmStatus::Continue { .. } - )); - - for (status, reason, stops, trace_len, did_converge) in [ - (MultigroupEmStatus::NonFinite, "non_finite", true, 0, false), - ( - MultigroupEmStatus::NonMonotone, - "non_monotone", - true, - 1, - false, - ), - ( - MultigroupEmStatus::Converged { - delta: 1e-7, - tolerance: 1e-6, - }, - "tolerance", - true, - 1, - true, - ), - ( - MultigroupEmStatus::Continue { - delta: 0.5, - tolerance: 1e-6, - }, - "max_iter", - false, - 1, - false, - ), - (MultigroupEmStatus::First, "max_iter", false, 1, false), - ] { - let mut trace = Vec::new(); - let mut converged = false; - let mut termination_reason = "max_iter".to_owned(); - let mut final_delta = f64::NAN; - let mut stopping_tolerance = 0.0; - let stopped = record_multigroup_em_status( - status, - -10.0, - &mut trace, - &mut converged, - &mut termination_reason, - &mut final_delta, - &mut stopping_tolerance, - ); - assert_eq!(stopped, stops); - assert_eq!(termination_reason, reason); - assert_eq!(trace.len(), trace_len); - assert_eq!(converged, did_converge); - } - - let compact = TwoGroupPolyFit { - slope: vec![], - cat_params: vec![], - studied_slope: vec![], - studied_cat: vec![vec![0.0], vec![1.0]], - mu: vec![], - sigma: vec![], - loglik: f64::NAN, - n_iter: 1, - converged: false, - termination_reason: "non_finite".into(), - loglik_trace: vec![], - final_delta: f64::NAN, - stopping_tolerance: 1e-6, - }; - assert!(validate_poly_dif_compact(&compact, 10).is_err()); - let compact = TwoGroupPolyFit { - loglik: -10.0, - ..compact - }; - assert!(validate_poly_dif_compact(&compact, 10).is_err()); - assert!(poly_dif_metrics(&compact, -11.0, 3).0.is_nan()); - let augmented = TwoGroupPolyFit { - converged: true, - loglik: -9.0, - ..compact - }; - let metrics = poly_dif_metrics(&augmented, -10.0, 3); - assert_eq!(metrics.0, 2.0); - assert_eq!(metrics.2, 1.0); - - let y = [0usize, 1, 2, 1]; - let observed = [true, false, true, true]; - for args in [ - fit_poly_unidim(&[], None, 0, 1, 3, PolyModel::Gpcm, 7, 1, 1e-6), - fit_poly_unidim(&y, None, 2, 2, 3, PolyModel::Gpcm, 7, 1, 0.0), - fit_poly_unidim(&y[..3], None, 2, 2, 3, PolyModel::Gpcm, 7, 1, 1e-6), - fit_poly_unidim(&[0, 1, 3, 1], None, 2, 2, 3, PolyModel::Gpcm, 7, 1, 1e-6), - fit_poly_unidim(&[], None, usize::MAX, 2, 3, PolyModel::Gpcm, 7, 1, 1e-6), - fit_poly_unidim( - &y, - Some(&observed[..3]), - 2, - 2, - 3, - PolyModel::Gpcm, - 7, - 1, - 1e-6, - ), - ] { - assert!(args.is_err()); - } - - for result in [ - fit_nominal(&y, None, 2, 2, 1, 7, 1, 1e-6), - fit_nominal(&y[..3], None, 2, 2, 3, 7, 1, 1e-6), - fit_nominal(&y, Some(&observed[..3]), 2, 2, 3, 7, 1, 1e-6), - fit_nominal(&[0, 1, 3, 1], None, 2, 2, 3, 7, 1, 1e-6), - ] { - assert!(result.is_err()); - } - let nominal_missing = fit_nominal(&y, Some(&observed), 2, 2, 3, 7, 1, 1e9).unwrap(); - assert!(nominal_missing.converged); - - let slope = [0.8, 1.2]; - let cat = [0.5, -0.5, 0.8, -0.4]; - for result in [ - poly_person_fit( - &y, - None, - 2, - 2, - 1, - &slope, - &cat, - PolyModel::Gpcm, - 7, - 0.0, - 1.0, - -2.0, - ), - poly_person_fit( - &y, - None, - 2, - 2, - 3, - &[1.0], - &cat, - PolyModel::Gpcm, - 7, - 0.0, - 1.0, - -2.0, - ), - poly_person_fit( - &y, - None, - 2, - 2, - 3, - &slope, - &[0.0], - PolyModel::Gpcm, - 7, - 0.0, - 1.0, - -2.0, - ), - poly_person_fit( - &y, - None, - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - 0.0, - 0.0, - -2.0, - ), - poly_person_fit( - &[0, 1, 3, 1], - None, - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - 0.0, - 1.0, - -2.0, - ), - ] { - assert!(result.is_err()); - } - let overflow_person_fit = std::panic::catch_unwind(|| { - poly_person_fit( - &[], - None, - 1, - 2, - usize::MAX / 2 + 2, - &[1.0, 1.0], - &[], - PolyModel::Gpcm, - 7, - 0.0, - 1.0, - -2.0, - ) - }); - assert!( - overflow_person_fit.is_ok(), - "overflowed poly person-fit item shape must return an error instead of panicking" - ); - // Reads crate Result. Kills the mutation that multiplies n_items*(n_cat-1) - // directly before validating category-parameter length. - assert!(overflow_person_fit.unwrap().is_err()); - let equal_grm_thresholds = score_poly_eap( - &[1], - None, - 1, - 1, - 3, - &[1.0], - &[0.0, 0.0], - PolyModel::Grm, - 7, - ); - let equal_grm_error = equal_grm_thresholds.expect_err("equal GRM thresholds accepted"); - // Reads crate error text. Kills the mutation that permits a zero-probability - // middle category and lets Result APIs return Ok(NaN). - assert!(equal_grm_error.contains("thresholds")); - let sparse_observed = [false, false, true, true]; - let person_fit = poly_person_fit( - &y, - Some(&sparse_observed), - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Grm, - 7, - 0.0, - 1.0, - 100.0, - ) - .unwrap(); - assert!(person_fit.lz[0].is_nan()); - - assert!(poly_cat_simulate( - &[0.0], - &slope, - &cat, - 2, - 1, - PolyModel::Gpcm, - 7, - 0.0, - 1, - 2, - true, - 1 - ) - .is_err()); - assert!(poly_cat_simulate( - &[0.0], - &[1.0], - &cat, - 2, - 3, - PolyModel::Gpcm, - 7, - 0.0, - 1, - 2, - true, - 1 - ) - .is_err()); - assert!(poly_cat_simulate( - &[0.0], - &[1.0], - &[0.0, 0.0], - 1, - 3, - PolyModel::Gpcm, - 7, - 0.0, - 1, - 1, - true, - 1 - ) - .is_err()); - let cat_fit = poly_cat_simulate( - &[0.0], - &slope, - &cat, - 2, - 3, - PolyModel::Grm, - 7, - f64::INFINITY, - 1, - 2, - false, - 0, - ) - .unwrap(); - assert_eq!(cat_fit.n_used, vec![1]); - let scored_cat = poly_cat_simulate( - &[0.0], - &slope, - &cat, - 2, - 3, - PolyModel::Gpcm, - 7, - 0.0, - 2, - 2, - true, - 2, - ) - .unwrap(); - assert_eq!(scored_cat.n_used, vec![2]); - - let groups = [0usize, 1]; - for result in [ - fit_poly_multigroup( - &[], - None, - &[], - 2, - 0, - 1, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - ), - fit_poly_multigroup( - &y, - None, - &groups, - 2, - 2, - 2, - 1, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - ), - fit_poly_multigroup( - &y, - None, - &groups, - 2, - 2, - 2, - 3, - PolyModel::Gpcm, - None, - 7, - 0, - 1e-6, - ), - fit_poly_multigroup( - &y, - None, - &groups, - 2, - 2, - 2, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 0.0, - ), - fit_poly_multigroup( - &y, - None, - &groups, - 1, - 2, - 2, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - ), - fit_poly_multigroup( - &y[..3], - None, - &groups, - 2, - 2, - 2, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - ), - fit_poly_multigroup( - &y, - None, - &[0], - 2, - 2, - 2, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - ), - fit_poly_multigroup( - &y, - None, - &[0, 2], - 2, - 2, - 2, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - ), - fit_poly_multigroup( - &[0, 1, 3, 1], - None, - &groups, - 2, - 2, - 2, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - ), - fit_poly_multigroup( - &y, - Some(&observed[..3]), - &groups, - 2, - 2, - 2, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - ), - fit_poly_multigroup( - &y, - None, - &groups, - 2, - 2, - 2, - 3, - PolyModel::Gpcm, - Some(2), - 7, - 1, - 1e-6, - ), - ] { - assert!(result.is_err()); - } - let group_y = [0usize, 99, 1, 2, 0, 2, 1, 2]; - let group_id = [0usize, 0, 1, 1]; - let group_observed = [true, false, true, true, true, true, true, true]; - let grouped = fit_poly_multigroup( - &group_y, - Some(&group_observed), - &group_id, - 2, - 4, - 2, - 3, - PolyModel::Grm, - Some(0), - 7, - 1, - 1e9, - ) - .unwrap(); - // Reads crate output. Kills the mutation that validates masked categories - // before consulting `observed`. - assert!(grouped.loglik.is_finite()); - let mut group_observed_bad = group_observed; - group_observed_bad[1] = true; - assert!(fit_poly_multigroup( - &group_y, - Some(&group_observed_bad), - &group_id, - 2, - 4, - 2, - 3, - PolyModel::Grm, - Some(0), - 7, - 1, - 1e9, - ) - .is_err()); - assert!(grouped.converged); - assert_eq!(grouped.termination_reason, "tolerance"); - assert!(poly_dif_sweep( - &[], - None, - &[], - 2, - 0, - 1, - 3, - PolyModel::Gpcm, - None, - 7, - 1, - 1e-6, - 0.05, - ) - .is_err()); - assert!(poly_dif_sweep( - &group_y, - None, - &group_id, - 2, - 4, - 2, - 3, - PolyModel::Gpcm, - Some(&[2]), - 7, - 1, - 1e9, - 0.05, - ) - .is_err()); - - for result in [ - u3_poly_person_fit(&y, None, 2, 2, 1, None), - u3_poly_person_fit(&y[..3], None, 2, 2, 3, None), - u3_poly_person_fit(&[0, 1, 3, 1], None, 2, 2, 3, None), - u3_poly_person_fit(&[], None, usize::MAX, 2, 3, None), - u3_poly_person_fit(&y, Some(&observed[..3]), 2, 2, 3, None), - u3_poly_person_fit(&y, None, 2, 2, 3, Some(f64::NAN)), - ] { - assert!(result.is_err()); - } - let overflow_u3 = - std::panic::catch_unwind(|| u3_poly_person_fit(&[], None, usize::MAX / 2 + 1, 2, 3, None)); - assert!( - overflow_u3.is_ok(), - "overflowed U3 shape must return an error instead of panicking" - ); - assert!(overflow_u3.unwrap().is_err()); - let empty_bootstrap = std::panic::catch_unwind(|| { - u3_poly_bootstrap_cutoff(1, 1, 3, &[0.0], &[0.0, 0.0], PolyModel::Gpcm, 0.05, 1, 1) - }); - assert!( - empty_bootstrap.is_ok(), - "U3 bootstrap must return an error when all simulated U3 values are non-finite" - ); - assert!(empty_bootstrap.unwrap().is_err()); - let nan_cutoff = u3_poly_bootstrap_cutoff( - 1, - 1, - 3, - &[f64::NAN], - &[0.0, 0.0], - PolyModel::Gpcm, - 0.05, - 1, - 7, - ); - assert!(nan_cutoff.is_err()); - let unordered_grm_cutoff = u3_poly_bootstrap_cutoff( - 1, - 1, - 3, - &[1.0], - &[-1.0, 1.0], - PolyModel::Grm, - 0.05, - 1, - 7, - ); - let unordered_grm_error = unordered_grm_cutoff.expect_err("unordered GRM thresholds accepted"); - // Reads crate error text. Kills the mutation that bypasses shared GRM - // threshold validation and lets grm_logprobs produce non-finite bootstrap draws. - assert!(unordered_grm_error.contains("thresholds")); - let overflow_cutoff = std::panic::catch_unwind(|| { - u3_poly_bootstrap_cutoff( - usize::MAX / 2 + 1, - 2, - 3, - &[0.0, 0.0], - &[0.0; 4], - PolyModel::Gpcm, - 0.05, - 1, - 7, - ) - }); - assert!( - overflow_cutoff.is_ok(), - "U3 cutoff must return an error for overflowed bootstrap buffers" - ); - assert!(overflow_cutoff.unwrap().is_err()); - let none_observed = [false, false, true, false]; - let y_masked = [99usize, 99, 1, 99]; - let u3 = u3_poly_person_fit(&y_masked, Some(&none_observed), 2, 2, 3, Some(-1.0)).unwrap(); - // Reads crate output. Kills the mutation that validates masked categories - // before consulting `observed`. - assert!(u3.u3poly[0].is_nan()); - assert_eq!(u3.flagged.len(), 2); - assert!( - u3_poly_person_fit(&y_masked, Some(&[true, false, true, false]), 2, 2, 3, None).is_err() - ); - - for model in [PolyModel::Gpcm, PolyModel::Grm] { - let cutoff = u3_poly_bootstrap_cutoff(4, 2, 3, &slope, &cat, model, 0.1, 2, 0).unwrap(); - assert!(cutoff.is_finite()); - } - let exact_order_statistic = - u3_poly_bootstrap_cutoff(3, 2, 3, &slope, &cat, PolyModel::Gpcm, 0.5, 1, 7).unwrap(); - assert!(exact_order_statistic.is_finite()); - for result in [ - u3_poly_bootstrap_cutoff(2, 2, 1, &slope, &cat, PolyModel::Gpcm, 0.1, 1, 1), - u3_poly_bootstrap_cutoff(2, 2, 3, &[1.0], &cat, PolyModel::Gpcm, 0.1, 1, 1), - u3_poly_bootstrap_cutoff(0, 2, 3, &slope, &cat, PolyModel::Gpcm, 0.1, 1, 1), - u3_poly_bootstrap_cutoff(2, 2, 3, &slope, &cat, PolyModel::Gpcm, 1.0, 1, 1), - u3_poly_bootstrap_cutoff(2, 2, 3, &slope, &cat, PolyModel::Gpcm, 0.1, 0, 1), - ] { - assert!(result.is_err()); - } - - assert!(poly_information_curves(&[0.0], &slope, &cat, 2, 1, PolyModel::Gpcm).is_err()); - assert!(poly_information_curves(&[0.0], &slope, &[0.0], 2, 3, PolyModel::Gpcm).is_err()); - let unordered_grm = [-1.0, 1.0, 0.5, -0.5]; - assert!(poly_information_curves(&[0.0], &slope, &unordered_grm, 2, 3, PolyModel::Grm).is_err()); - for model in [PolyModel::Gpcm, PolyModel::Grm] { - let information = - poly_information_curves(&[-1.0, 0.0, 1.0], &slope, &cat, 2, 3, model).unwrap(); - assert_eq!(information.len(), 6); - assert!(information - .iter() - .all(|value| value.is_finite() && *value >= 0.0)); - } - - for result in [ - score_poly_eap(&y, None, 2, 2, 1, &slope, &cat, PolyModel::Gpcm, 7), - score_poly_eap(&y[..3], None, 2, 2, 3, &slope, &cat, PolyModel::Gpcm, 7), - score_poly_eap( - &y, - Some(&observed[..3]), - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - ), - score_poly_eap(&y, None, 2, 2, 3, &[1.0], &cat, PolyModel::Gpcm, 7), - score_poly_eap(&y, None, 2, 2, 3, &slope, &unordered_grm, PolyModel::Grm, 7), - ] { - assert!(result.is_err()); - } - let (eap, sd) = score_poly_eap( - &y, - Some(&observed), - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Grm, - 7, - ) - .unwrap(); - assert_eq!(eap.len(), 2); - assert!(sd.iter().all(|value| value.is_finite() && *value >= 0.0)); - - for result in [ - poly_s_x2(&y, None, 2, 2, 1, &slope, &cat, PolyModel::Gpcm, 7, 1.0), - poly_s_x2( - &[0], - None, - 1, - 1, - 3, - &[1.0], - &[0.0, 0.0], - PolyModel::Gpcm, - 7, - 1.0, - ), - poly_s_x2( - &y[..3], - None, - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - 1.0, - ), - poly_s_x2(&y, None, 2, 2, 3, &[1.0], &cat, PolyModel::Gpcm, 7, 1.0), - poly_s_x2(&y, None, 2, 2, 3, &slope, &[0.0], PolyModel::Gpcm, 7, 1.0), - poly_s_x2( - &[], - None, - usize::MAX, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - 1.0, - ), - poly_s_x2( - &y, - Some(&observed[..3]), - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - 1.0, - ), - poly_s_x2( - &[0, 1, 3, 1], - None, - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - 1.0, - ), - ] { - assert!(result.is_err()); - } - let overflow_sx2 = std::panic::catch_unwind(|| { - poly_s_x2( - &[], - None, - usize::MAX / 2 + 1, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - 1.0, - ) - }); - assert!( - overflow_sx2.is_ok(), - "overflowed poly S-X2 shape must return an error instead of panicking" - ); - assert!(overflow_sx2.unwrap().is_err()); - assert!(poly_s_x2( - &y, - None, - 2, - 2, - 3, - &[f64::NAN, 1.0], - &cat, - PolyModel::Gpcm, - 7, - 1.0, - ) - .is_err()); - let empty_sx2 = poly_s_x2( - &[99, 99, 1, 99], - Some(&[false; 4]), - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - f64::INFINITY, - ) - .unwrap(); - // Reads crate output. Kills the mutation that validates masked categories - // before consulting `observed`. - assert_eq!(empty_sx2.n_cells, vec![0, 0]); - assert!(poly_s_x2( - &[0, 99, 1, 2], - Some(&[true, true, true, true]), - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - f64::INFINITY, - ) - .is_err()); - let residual_sx2 = poly_s_x2( - &[0, 1], - None, - 1, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 7, - f64::INFINITY, - ) - .unwrap(); - assert_eq!(residual_sx2.n_cells, vec![0, 0]); -} - -fn logsumexp0(v: &[f64]) -> f64 { - let m = v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - m + v.iter().map(|&x| (x - m).exp()).sum::().ln() -} - -#[test] -fn grm_logprobs_normalize_and_binary_parity() { - // K=2, one threshold: P(Y=1)=sigmoid(base+beta), P(Y=0)=sigmoid(-(base+beta)) - let base = 0.4; - let beta = -0.3; - let lp = grm_logprobs(base, &[beta]); - let z = logsumexp0(&lp); - assert!(z.abs() < 1e-12, "not normalized: {z}"); - assert!((lp[1] - log_sigmoid(base + beta)).abs() < 1e-12); - assert!((lp[0] - log_sigmoid(-(base + beta))).abs() < 1e-12); - // K=4 normalization - let lp4 = grm_logprobs(0.2, &[1.0, 0.0, -1.2]); - assert!(logsumexp0(&lp4).abs() < 1e-10); - assert!(lp4.iter().all(|v| v.is_finite())); -} - -#[test] -fn grm_logprobs_and_gradient_remain_finite_at_extreme_bases() { - let thresholds = [1.0, 0.0]; - let expected_middle = -1000.0 + (-(-1.0_f64).exp()).ln_1p(); - let lp = grm_logprobs(1000.0, &thresholds); - assert!(lp.iter().all(|value| value.is_finite()), "{lp:?}"); - assert!((lp[1] - expected_middle).abs() < 1e-12, "{lp:?}"); - let (g_base, g_thresholds) = grm_node_gradient(1000.0, &thresholds, &[3.0, 5.0, 2.0]); - assert!(g_base.is_finite(), "g_base={g_base}"); - assert!( - g_thresholds.iter().all(|value| value.is_finite()), - "{g_thresholds:?}" - ); -} - -#[test] -fn grm_gradient_matches_finite_difference() { - let base = 0.3; - let thr = vec![1.1, 0.1, -0.9]; // decreasing => valid - let counts = vec![4.0, 6.0, 3.0, 5.0]; - let q = |b: f64, t: &[f64]| -> f64 { - grm_logprobs(b, t) - .iter() - .zip(&counts) - .map(|(l, r)| r * l) - .sum() - }; - let (g_base, g_t) = grm_node_gradient(base, &thr, &counts); - let h = 1e-6; - assert!(((q(base + h, &thr) - q(base - h, &thr)) / (2.0 * h) - g_base).abs() < 1e-5); - for j in 0..thr.len() { - let mut tp = thr.clone(); - let mut tm = thr.clone(); - tp[j] += h; - tm[j] -= h; - let fd = (q(base, &tp) - q(base, &tm)) / (2.0 * h); - assert!( - (fd - g_t[j]).abs() < 1e-5, - "grm g_t[{j}]: {} vs {}", - fd, - g_t[j] - ); - } -} - -#[test] -fn gpcm_logprobs_binary_parity_and_monotone() { - let base = 0.5; - let b = 0.2; - let lp = gpcm_logprobs(base, &[0.0, 1.0], &[0.0, b]); - assert!(logsumexp0(&lp).abs() < 1e-12); - assert!((lp[1] - log_sigmoid(base + b)).abs() < 1e-12); - // higher base -> more mass on top category (scores 0,1,2) - let lo = gpcm_logprobs(-2.0, &[0.0, 1.0, 2.0], &[0.0, 0.0, 0.0]); - let hi = gpcm_logprobs(2.0, &[0.0, 1.0, 2.0], &[0.0, 0.0, 0.0]); - assert!(hi[2].exp() > lo[2].exp()); -} - -#[test] -fn poly_k2_matches_trusted_binary_mmle() { - // Cross-validation against an ALREADY-VALIDATED reference (not self- - // recovery): at K=2 the GPCM cell is exactly the 2PL, P(Y=1) = - // sigmoid(a*theta + c_1). The polytomous fitter must reproduce the - // repo's binary MMLE-EM (mmle::fit_mmle_2pl, NumPy-parity + real-data - // validated) item parameters on the same data, to a small RMSE. - use crate::mmle::{fit_mmle_2pl, MmleConfig}; - let (n_persons, n_items) = (4000usize, 8usize); - let mut st = 271828u64; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let a_true: Vec = (0..n_items).map(|i| 0.8 + 0.12 * i as f64).collect(); - let b_true: Vec = (0..n_items).map(|i| -0.9 + 0.25 * i as f64).collect(); - let mut yf = vec![0.0_f64; n_persons * n_items]; - let mut yi = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let eta = a_true[i] * theta + b_true[i]; - let pr = 1.0 / (1.0 + (-eta).exp()); - let v = if u() < pr { 1.0 } else { 0.0 }; - yf[p * n_items + i] = v; - yi[p * n_items + i] = v as usize; - } - } - let observed = vec![true; n_persons * n_items]; - let bin = fit_mmle_2pl( - &yf, - &observed, - n_persons, - n_items, - &MmleConfig { - max_iter: 500, - tol: 1e-7, - ridge_a: 1e-4, - ridge_b: 1e-4, - newton_iter: 25, - }, - ); - let rmse = |a: &[f64], b: &[f64]| { - (a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64).sqrt() - }; - // BOTH cells reduce to the 2PL at K=2 (GRM is the default): each must - // match the trusted binary MMLE's item parameters on the same data. - for model in [PolyModel::Gpcm, PolyModel::Grm] { - let poly = fit_poly_unidim(&yi, None, n_persons, n_items, 2, model, 41, 300, 1e-7).unwrap(); - let c1: Vec = poly.cat_params.iter().map(|c| c[0]).collect(); - let ra = rmse(&poly.slope, &bin.a); - let rb = rmse(&c1, &bin.b); - assert!( - ra < 0.1, - "{model:?} slope RMSE vs trusted binary MMLE: {ra}" - ); - assert!( - rb < 0.1, - "{model:?} intercept RMSE vs trusted binary MMLE: {rb}" - ); - } -} - -#[test] -fn fit_poly_unidim_recovers_gpcm() { - let (n_persons, n_items, k) = (4000usize, 6usize, 3usize); - let mut st = 20260714u64; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let a_true: Vec = (0..n_items).map(|i| 0.8 + 0.16 * i as f64).collect(); - let c_true: Vec> = (0..n_items) - .map(|i| vec![0.0, 0.3 - 0.1 * i as f64, -0.2 + 0.15 * i as f64]) - .collect(); - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut y = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let lp = gpcm_logprobs(a_true[i] * theta, &scores, &c_true[i]); - let uu = u(); - let mut cum = 0.0_f64; - let mut cat = k - 1; - for (c, l) in lp.iter().enumerate() { - cum += l.exp(); - if uu < cum { - cat = c; - break; - } - } - y[p * n_items + i] = cat; - } - } - let fit = fit_poly_unidim( - &y, - None, - n_persons, - n_items, - k, - PolyModel::Gpcm, - 21, - 80, - 1e-6, - ) - .unwrap(); - assert!(fit.loglik.is_finite()); - assert!(fit.converged, "termination={}", fit.termination_reason); - assert_eq!(fit.termination_reason, "tolerance"); - assert!(fit.n_iter < 80); - assert_eq!(fit.loglik_trace.len(), fit.n_iter + 1); - assert_eq!(fit.loglik, *fit.loglik_trace.last().unwrap()); - let previous = fit.loglik_trace[fit.loglik_trace.len() - 2]; - let monotonic_tolerance = 32.0 * f64::EPSILON * (1.0 + previous.abs()); - assert!(fit.final_delta >= -monotonic_tolerance); - assert!(fit.final_delta <= fit.stopping_tolerance); - - let limited = fit_poly_unidim( - &y, - None, - n_persons, - n_items, - k, - PolyModel::Gpcm, - 21, - 1, - 1e-12, - ) - .unwrap(); - assert!(!limited.converged); - assert_eq!(limited.termination_reason, "max_iter"); - assert_eq!(limited.n_iter, 1); - assert_eq!(limited.loglik_trace.len(), 2); - assert_eq!(limited.loglik, *limited.loglik_trace.last().unwrap()); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let (ma, mh) = (mean(&a_true), mean(&fit.slope)); - let (mut num, mut da, mut dh) = (0.0, 0.0, 0.0); - for i in 0..n_items { - num += (a_true[i] - ma) * (fit.slope[i] - mh); - da += (a_true[i] - ma).powi(2); - dh += (fit.slope[i] - mh).powi(2); - } - let corr = num / (da.sqrt() * dh.sqrt()); - assert!(corr > 0.9, "slope corr {corr}; hat={:?}", fit.slope); -} - -#[test] -fn poly_item_information_matches_finite_difference() { - // I(theta) = sum_k (dP_k/dtheta)^2 / P_k, checked against a central FD of the cell. - let h = 1e-6; - let cases: [(PolyModel, &[f64]); 2] = [ - (PolyModel::Gpcm, &[0.2, -0.3]), - (PolyModel::Grm, &[1.1, -0.9]), - ]; - for (model, cat) in cases.iter().copied() { - let (a, theta) = (1.3_f64, 0.4_f64); - let cell = |t: f64| -> Vec { - let base = a * t; - match model { - PolyModel::Gpcm => { - let k = cat.len() + 1; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0; k]; - ic[1..].copy_from_slice(cat); - gpcm_logprobs(base, &scores, &ic) - .iter() - .map(|l| l.exp()) - .collect() - } - PolyModel::Grm => grm_logprobs(base, cat).iter().map(|l| l.exp()).collect(), - } - }; - let (pp, pm, p0) = (cell(theta + h), cell(theta - h), cell(theta)); - let mut fd_info = 0.0_f64; - for k in 0..p0.len() { - let dp = (pp[k] - pm[k]) / (2.0 * h); - fd_info += dp * dp / p0[k]; - } - let ana = poly_item_information(theta, a, cat, model); - assert!( - (ana - fd_info).abs() < 1e-4, - "{model:?}: analytic {ana} vs fd {fd_info}" - ); - } -} - -#[test] -fn poly_information_curves_rejects_nonfinite_or_empty_inputs() { - for (theta, slope, cat_params) in [ - (&[f64::NAN][..], &[1.0][..], &[0.0, 0.0][..]), - (&[0.0][..], &[f64::INFINITY][..], &[0.0, 0.0][..]), - (&[0.0][..], &[1.0][..], &[0.0, f64::NEG_INFINITY][..]), - ] { - assert!(poly_information_curves(theta, slope, cat_params, 1, 3, PolyModel::Gpcm,).is_err()); - } - assert!(poly_information_curves(&[], &[1.0], &[0.0, 0.0], 1, 3, PolyModel::Gpcm).is_err()); - assert!(poly_information_curves(&[0.0], &[], &[], 0, 3, PolyModel::Gpcm).is_err()); -} - -#[test] -fn fit_poly_unidim_recovers_with_missing_data() { - let (n_persons, n_items, k) = (5000usize, 6usize, 3usize); - let mut st = 5150u64; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let a_true: Vec = (0..n_items).map(|i| 0.9 + 0.15 * i as f64).collect(); - let c_true: Vec> = (0..n_items) - .map(|i| vec![0.0, 0.3 - 0.1 * i as f64, -0.2 + 0.1 * i as f64]) - .collect(); - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut y = vec![0usize; n_persons * n_items]; - let mut observed = vec![true; n_persons * n_items]; - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - if u() < 0.25 { - observed[p * n_items + i] = false; // ~25% MCAR missing - continue; - } - let lp = gpcm_logprobs(a_true[i] * theta, &scores, &c_true[i]); - let uu = u(); - let mut cum = 0.0_f64; - let mut cat = k - 1; - for (c, l) in lp.iter().enumerate() { - cum += l.exp(); - if uu < cum { - cat = c; - break; - } - } - y[p * n_items + i] = cat; - } - } - let fit = fit_poly_unidim( - &y, - Some(&observed), - n_persons, - n_items, - k, - PolyModel::Gpcm, - 21, - 80, - 1e-6, - ) - .unwrap(); - assert!(fit.loglik.is_finite()); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let (ma, mh) = (mean(&a_true), mean(&fit.slope)); - let (mut num, mut da, mut dh) = (0.0, 0.0, 0.0); - for i in 0..n_items { - num += (a_true[i] - ma) * (fit.slope[i] - mh); - da += (a_true[i] - ma).powi(2); - dh += (fit.slope[i] - mh).powi(2); - } - assert!( - num / (da.sqrt() * dh.sqrt()) > 0.9, - "slope corr under missingness" - ); - // absolute agreement, not just association - let s_rmse = (a_true - .iter() - .zip(&fit.slope) - .map(|(x, y)| (x - y).powi(2)) - .sum::() - / n_items as f64) - .sqrt(); - assert!(s_rmse < 0.2, "slope RMSE under missingness {s_rmse}"); -} - -#[test] -fn score_poly_eap_recovers_true_theta() { - let (n_persons, n_items, k) = (3000usize, 8usize, 3usize); - let mut st = 424242u64; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let a_true: Vec = (0..n_items).map(|i| 1.0 + 0.1 * i as f64).collect(); - let c_true: Vec> = (0..n_items) - .map(|i| vec![0.0, 0.2 - 0.05 * i as f64, -0.3 + 0.08 * i as f64]) - .collect(); - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut theta_true = vec![0.0_f64; n_persons]; - let mut y = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - theta_true[p] = theta; - for i in 0..n_items { - let lp = gpcm_logprobs(a_true[i] * theta, &scores, &c_true[i]); - let uu = u(); - let mut cum = 0.0_f64; - let mut cat = k - 1; - for (c, l) in lp.iter().enumerate() { - cum += l.exp(); - if uu < cum { - cat = c; - break; - } - } - y[p * n_items + i] = cat; - } - } - // score with the TRUE item params (isolates the scorer from fit error) - let cat_flat: Vec = c_true.iter().flat_map(|c| c[1..].iter().copied()).collect(); - let (eap, sd) = score_poly_eap( - &y, - None, - n_persons, - n_items, - k, - &a_true, - &cat_flat, - PolyModel::Gpcm, - 41, - ) - .unwrap(); - assert!(sd.iter().all(|s| s.is_finite() && *s > 0.0)); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let (mt, me) = (mean(&theta_true), mean(&eap)); - let (mut num, mut dt, mut de) = (0.0, 0.0, 0.0); - for p in 0..n_persons { - num += (theta_true[p] - mt) * (eap[p] - me); - dt += (theta_true[p] - mt).powi(2); - de += (eap[p] - me).powi(2); - } - let corr = num / (dt.sqrt() * de.sqrt()); - assert!(corr > 0.8, "theta EAP corr {corr}"); -} - -#[test] -fn score_poly_eap_rejects_invalid_inputs() { - let y = vec![3usize]; - let slope = vec![1.0]; - let cat_params = vec![0.2, -0.3]; - let err = - score_poly_eap(&y, None, 1, 1, 3, &slope, &cat_params, PolyModel::Gpcm, 21).unwrap_err(); - assert!(err.contains("categories")); - - let err = score_poly_eap( - &[1], - None, - 1, - 1, - 3, - &[f64::NAN], - &cat_params, - PolyModel::Gpcm, - 21, - ) - .unwrap_err(); - assert!(err.contains("finite")); -} - -#[test] -fn fit_poly_unidim_recovers_grm() { - let (n_persons, n_items, k) = (4000usize, 6usize, 4usize); - let mut st = 99887766u64; - let mut u = || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let a_true: Vec = (0..n_items).map(|i| 0.9 + 0.15 * i as f64).collect(); - // ordered-decreasing thresholds (valid GRM) - let thr_true: Vec> = (0..n_items).map(|_| vec![1.4, 0.1, -1.2]).collect(); - let mut y = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let lp = grm_logprobs(a_true[i] * theta, &thr_true[i]); - let uu = u(); - let mut cum = 0.0_f64; - let mut cat = k - 1; - for (c, l) in lp.iter().enumerate() { - cum += l.exp(); - if uu < cum { - cat = c; - break; - } - } - y[p * n_items + i] = cat; - } - } - let fit = fit_poly_unidim( - &y, - None, - n_persons, - n_items, - k, - PolyModel::Grm, - 21, - 80, - 1e-6, - ) - .unwrap(); - assert!(fit.loglik.is_finite()); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let (ma, mh) = (mean(&a_true), mean(&fit.slope)); - let (mut num, mut da, mut dh) = (0.0, 0.0, 0.0); - for i in 0..n_items { - num += (a_true[i] - ma) * (fit.slope[i] - mh); - da += (a_true[i] - ma).powi(2); - dh += (fit.slope[i] - mh).powi(2); - } - let corr = num / (da.sqrt() * dh.sqrt()); - assert!(corr > 0.9, "grm slope corr {corr}; hat={:?}", fit.slope); - // thresholds recovered near truth (pooled mean abs error, item 0) - let mae: f64 = (0..3) - .map(|j| (fit.cat_params[0][j] - thr_true[0][j]).abs()) - .sum::() - / 3.0; - assert!( - mae < 0.25, - "grm threshold MAE {mae}: {:?}", - fit.cat_params[0] - ); -} - -#[test] -fn gpcm_gradient_matches_finite_difference() { - let scores = vec![0.0, 1.0, 2.0, 3.0]; - let intercepts = vec![0.0, 0.2, -0.1, 0.3]; - let counts = vec![3.0, 5.0, 2.0, 4.0]; - let base = 0.4; - let q = |b: f64, ic: &[f64], sc: &[f64]| -> f64 { - gpcm_logprobs(b, sc, ic) - .iter() - .zip(&counts) - .map(|(l, r)| r * l) - .sum() - }; - let (g_ic, g_base, g_sc) = gpcm_node_gradient(base, &scores, &intercepts, &counts); - let h = 1e-6; - assert!( - ((q(base + h, &intercepts, &scores) - q(base - h, &intercepts, &scores)) / (2.0 * h) - - g_base) - .abs() - < 1e-5 - ); - for m in 1..scores.len() { - let mut ip = intercepts.clone(); - let mut im = intercepts.clone(); - ip[m] += h; - im[m] -= h; - let fd = (q(base, &ip, &scores) - q(base, &im, &scores)) / (2.0 * h); - assert!((fd - g_ic[m - 1]).abs() < 1e-5); - let mut sp = scores.clone(); - let mut sm = scores.clone(); - sp[m] += h; - sm[m] -= h; - let fds = (q(base, &intercepts, &sp) - q(base, &intercepts, &sm)) / (2.0 * h); - assert!((fds - g_sc[m - 1]).abs() < 1e-5); - } -} - -// deterministic uniform draws for the item-fit tests -fn rng(seed: u64) -> impl FnMut() -> f64 { - let mut st = seed; - move || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - } -} - -#[test] -fn poly_s_x2_reduces_to_binary_orlando_thissen() { - // At K=2 the generalized S-X² must equal the trusted binary Orlando- - // Thissen s_x2 (crate::fitstats) EXACTLY on the same quadrature grid: - // both GRM and GPCM cells reduce to the 2PL P(Y=1)=sigmoid(a*theta+b), - // and the summed-score recursion / expected proportions coincide. Large - // N + few centered items keep either statistic out of its collapsing - // regime, so the agreement is bit-for-bit (min_expected tiny on both). - use crate::fitstats::{s_x2, SX2Config}; - use crate::nodes::XiRule; - use crate::scoring::{ItemBank, PriorSpec}; - use crate::ModelType; - let (n_persons, n_items, q_theta) = (4000usize, 6usize, 41usize); - let mut u = rng(13579); - let a_true: Vec = (0..n_items).map(|i| 0.9 + 0.1 * i as f64).collect(); - let b_true: Vec = (0..n_items).map(|i| -0.6 + 0.24 * i as f64).collect(); - let mut yi = vec![0usize; n_persons * n_items]; - for _p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let pr = 1.0 / (1.0 + (-(a_true[i] * theta + b_true[i])).exp()); - yi[_p * n_items + i] = if u() < pr { 1 } else { 0 }; - } - } - let yf: Vec = yi.iter().map(|&v| v as f64).collect(); - let observed_bool = vec![true; n_persons * n_items]; - let alpha: Vec = a_true.iter().map(|a| a.ln()).collect(); - let zeta = vec![0.0_f64; n_items]; - let fid = vec![0usize; n_items]; - let bank = ItemBank { - alpha: &alpha, - b: &b_true, - zeta: &zeta, - tau: -50.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let bin = s_x2( - &bank, - &yf, - &observed_bool, - n_persons, - &PriorSpec::standard(1), - &SX2Config { - q_theta, - xi_rule: XiRule::GaussHermite { q_xi: 1 }, - min_expected: 1e-9, - ..Default::default() - }, - None, - ) - .unwrap(); - for model in [PolyModel::Grm, PolyModel::Gpcm] { - let poly = poly_s_x2( - &yi, None, n_persons, n_items, 2, &a_true, &b_true, model, q_theta, 1e-9, - ) - .unwrap(); - for i in 0..n_items { - assert!( - (poly.statistic[i] - bin.statistic[i]).abs() < 1e-8, - "{model:?} item {i}: poly {} vs binary {}", - poly.statistic[i], - bin.statistic[i] - ); - assert_eq!( - poly.df[i], bin.df[i], - "{model:?} item {i} df: poly {:?} vs binary {:?}", - poly.df[i], bin.df[i] - ); - } - } -} - -#[test] -fn poly_s_x2_is_calibrated_at_true_parameters() { - // Kang & Chen (2008/2011) headline: under the true model the generalized - // S-X² tracks its reference chi-square. Evaluated at the KNOWN generating - // parameters the reference df is the retained cell count (no −m estimation - // adjustment), so E[S-X²] ≈ Σ cells. We reproduce this — an ABSOLUTE - // agreement of the sampling mean with its theoretical value, the analogue - // of an RMSE recovery check for a fit statistic — for both GPCM (2008) and - // GRM (2011), which is exactly what a mis-calibrated index (e.g. Yen's - // Q1 / PARSCALE G², inflated to many times its df) would fail. - let (n_persons, n_items, n_cat, reps) = (1500usize, 8usize, 4usize, 24usize); - for model in [PolyModel::Gpcm, PolyModel::Grm] { - let a_true: Vec = (0..n_items).map(|i| 0.9 + 0.08 * i as f64).collect(); - let cat_true: Vec = (0..n_items) - .flat_map(|i| match model { - // GPCM additive intercepts (any reals) - PolyModel::Gpcm => vec![0.8 - 0.06 * i as f64, 0.0, -0.8 + 0.06 * i as f64], - // GRM thresholds must be strictly decreasing for a valid cdf - PolyModel::Grm => vec![1.1 + 0.04 * i as f64, 0.0, -1.1 - 0.04 * i as f64], - }) - .collect(); - let z = n_cat - 1; - let (mut stat_sum, mut cell_sum) = (0.0_f64, 0.0_f64); - let mut n_flagged = 0usize; - let mut n_tested = 0usize; - for r in 0..reps { - let mut u = rng(2024_0714 + r as u64 * 97); - let mut yi = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let base = a_true[i] * theta; - let cp = &cat_true[i * z..(i + 1) * z]; - let lp = match model { - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; n_cat]; - ic[1..].copy_from_slice(cp); - gpcm_logprobs(base, &scores, &ic) - } - PolyModel::Grm => grm_logprobs(base, cp), - }; - let draw = u(); - let mut acc = 0.0_f64; - let mut cat = n_cat - 1; - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - yi[p * n_items + i] = cat; - } - } - let res = poly_s_x2( - &yi, None, n_persons, n_items, n_cat, &a_true, &cat_true, model, 21, 1.0, - ) - .unwrap(); - for i in 0..n_items { - if res.n_cells[i] >= 1 && res.statistic[i].is_finite() { - stat_sum += res.statistic[i]; - cell_sum += res.n_cells[i] as f64; - n_tested += 1; - if res.p_value[i].is_finite() && res.p_value[i] < 0.05 { - n_flagged += 1; - } - } - } - } - let ratio = stat_sum / cell_sum; - assert!( - (0.85..=1.15).contains(&ratio), - "{model:?}: mean S-X² / cells = {ratio} (stat {stat_sum}, cells {cell_sum})" - ); - // df uses the −m adjustment, so p-values at true params are mildly - // conservative; the flag rate stays far below the >30% seen for G². - let flag_rate = n_flagged as f64 / n_tested as f64; - assert!( - flag_rate < 0.15, - "{model:?}: flag rate {flag_rate} too high for the true model" - ); - } -} - -/// One ability condition's aggregate recovery: absolute-agreement RMSE and -/// mean |bias| for the slope and the category intercepts. -struct McRecovery { - cond: &'static str, - a_rmse: f64, - a_bias: f64, - c_rmse: f64, - c_bias: f64, -} - -/// Monte-Carlo parameter-recovery study for the GPCM fitter, generating from -/// the published item-parameter scheme of Kang & Chen (2008, p. 397): slopes -/// `a_i ~ lognormal(0, 0.5²)` and four step difficulties `b_{i,c} ~ -/// N(means −1.5, −0.5, 0.5, 1.5; SD 0.5)`. Two ability conditions are run — -/// NORMAL `θ ~ N(0, 1)` (the fitter's prior, so recovery is near-unbiased) -/// and right-SKEWED `θ = Exp(1) − 1` (mean 0, var 1, skewness 2), a prior -/// misspecification Kang & Chen flag as future work. Returns per-condition -/// RMSE and mean |bias| (absolute agreement, not correlation) over `reps` -/// replications on a fixed true item bank. -/// -/// # References (APA 7th ed.) -/// -/// Kang, T., & Chen, T. T. (2008). Performance of the generalized S-X² item -/// fit index for polytomous IRT models. *Journal of Educational -/// Measurement, 45*(4), 391–406. -/// https://doi.org/10.1111/j.1745-3984.2008.00070.x -/// Muraki, E. (1992). A generalized partial credit model: Application of an -/// EM algorithm. *Applied Psychological Measurement, 16*(2), 159–176. -/// https://doi.org/10.1177/014662169201600206 -fn mc_gpcm_recovery(reps: usize, n_persons: usize) -> Vec { - let (n_items, k) = (5usize, 5usize); - let z_steps = k - 1; // 4 step difficulties - let step_means = [-1.5_f64, -0.5, 0.5, 1.5]; - - // fixed "true" item bank (drawn once) from the published scheme - let mut bu = rng(96100); - let mut bnorm = || { - let u1 = bu().max(1e-12); - let u2 = bu(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let mut a_true = vec![0.0_f64; n_items]; - let mut cat_true = vec![0.0_f64; n_items * z_steps]; // additive intercepts - for i in 0..n_items { - a_true[i] = (0.5 * bnorm()).exp(); // lognormal(0, 0.5²) - let mut cum = 0.0_f64; - for c in 0..z_steps { - let b = step_means[c] + 0.5 * bnorm(); // step difficulty - cum += b; - cat_true[i * z_steps + c] = -a_true[i] * cum; // GPCM intercept - } - } - - let mut out = Vec::new(); - for (cond, skew) in [("normal", false), ("skew", true)] { - // accumulate signed error and squared error per parameter over reps - let mut a_err = vec![0.0_f64; n_items]; - let mut a_sq = vec![0.0_f64; n_items]; - let mut c_err = vec![0.0_f64; n_items * z_steps]; - let mut c_sq = vec![0.0_f64; n_items * z_steps]; - for rep in 0..reps { - let mut u = rng(4242 + rep as u64 * 131 + if skew { 7 } else { 0 }); - let mut yi = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let theta = if skew { - -(u().max(1e-12)).ln() - 1.0 // Exp(1) − 1: mean 0, var 1, skew 2 - } else { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - for i in 0..n_items { - let base = a_true[i] * theta; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; k]; - ic[1..].copy_from_slice(&cat_true[i * z_steps..(i + 1) * z_steps]); - let lp = gpcm_logprobs(base, &scores, &ic); - let draw = u(); - let (mut acc, mut cat) = (0.0_f64, k - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - yi[p * n_items + i] = cat; - } - } - let fit = fit_poly_unidim( - &yi, - None, - n_persons, - n_items, - k, - PolyModel::Gpcm, - 21, - 100, - 1e-6, - ) - .unwrap(); - assert!( - fit.converged, - "GPCM recovery replicate {rep} ({cond}) did not converge: \ - reason={}, n_iter={}/{}, delta={:.6e}, tolerance={:.6e}", - fit.termination_reason, fit.n_iter, 100, fit.final_delta, fit.stopping_tolerance - ); - for i in 0..n_items { - let ea = fit.slope[i] - a_true[i]; - a_err[i] += ea; - a_sq[i] += ea * ea; - for c in 0..z_steps { - let ec = fit.cat_params[i][c] - cat_true[i * z_steps + c]; - c_err[i * z_steps + c] += ec; - c_sq[i * z_steps + c] += ec * ec; - } - } - } - let r = reps as f64; - let rmse = |sq: &[f64]| (sq.iter().sum::() / (sq.len() as f64 * r)).sqrt(); - let mean_bias = - |er: &[f64]| er.iter().map(|e| (e / r).abs()).sum::() / er.len() as f64; - out.push(McRecovery { - cond, - a_rmse: rmse(&a_sq), - a_bias: mean_bias(&a_err), - c_rmse: rmse(&c_sq), - c_bias: mean_bias(&c_err), - }); - } - out -} - -fn assert_recovery(out: &[McRecovery], reps: usize, n_persons: usize) { - for s in out { - println!( - "[MC recovery, θ={}] reps={reps} N={n_persons} \ - slope: RMSE={:.4} |bias|={:.4} intercept: RMSE={:.4} |bias|={:.4}", - s.cond, s.a_rmse, s.a_bias, s.c_rmse, s.c_bias - ); - assert!(s.a_rmse.is_finite() && s.c_rmse.is_finite()); - if s.cond == "skew" { - // prior misspecification: recovery holds but degrades (reported) - assert!(s.a_rmse < 0.45, "skew slope RMSE too large: {}", s.a_rmse); - assert!( - s.c_rmse < 1.2, - "skew intercept RMSE too large: {}", - s.c_rmse - ); - } else { - // matched prior: tight, near-unbiased recovery - assert!(s.a_rmse < 0.20, "normal slope RMSE too large: {}", s.a_rmse); - assert!( - s.c_rmse < 0.45, - "normal intercept RMSE too large: {}", - s.c_rmse - ); - assert!(s.a_bias < 0.10, "normal slope bias too large: {}", s.a_bias); - } - } -} - -#[test] -fn fit_poly_unidim_recovery_ci_guard() { - // Fast regression guard (few reps). The authoritative >=500-replication - // study is `fit_poly_unidim_recovery_monte_carlo_500` (ignored below); - // run it with: cargo test --release -- --ignored --nocapture - let (reps, n_persons) = (20usize, 1500usize); - assert_recovery(&mc_gpcm_recovery(reps, n_persons), reps, n_persons); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn fit_poly_unidim_recovery_monte_carlo_500() { - // 500-replication recovery study (the sample size common in the IRT - // Monte-Carlo literature), N = 2000 per replication. - let (reps, n_persons) = (500usize, 2000usize); - assert_recovery(&mc_gpcm_recovery(reps, n_persons), reps, n_persons); -} - -#[test] -fn fit_nominal_nests_gpcm() { - // The nominal model contains the GPCM (scores linear in k, a_k = a*k), so - // fitting nominal to GPCM data must (a) reach a log-likelihood at least as - // high as the GPCM fit and (b) recover linear scores: a_2/a_1 ≈ 2. - let (n_persons, n_items, k) = (3000usize, 5usize, 3usize); - let mut u = rng(778899); - let a_gpcm: Vec = (0..n_items).map(|i| 0.9 + 0.15 * i as f64).collect(); - let c_gpcm: Vec> = (0..n_items) - .map(|i| vec![0.3 - 0.1 * i as f64, -0.4 + 0.1 * i as f64]) - .collect(); - let mut yi = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let theta = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let base = a_gpcm[i] * theta; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; k]; - ic[1..].copy_from_slice(&c_gpcm[i]); - let lp = gpcm_logprobs(base, &scores, &ic); - let draw = u(); - let (mut acc, mut cat) = (0.0_f64, k - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - yi[p * n_items + i] = cat; - } - } - let gpcm = fit_poly_unidim( - &yi, - None, - n_persons, - n_items, - k, - PolyModel::Gpcm, - 41, - 300, - 1e-7, - ) - .unwrap(); - let nom = fit_nominal(&yi, None, n_persons, n_items, k, 41, 300, 1e-7).unwrap(); - assert!( - nom.loglik >= gpcm.loglik - 0.5, - "nominal loglik {} should be >= GPCM {}", - nom.loglik, - gpcm.loglik - ); - for i in 0..n_items { - let (a1, a2) = (nom.scores[i][0], nom.scores[i][1]); - assert!( - (a2 / a1 - 2.0).abs() < 0.4, - "item {i}: recovered scores not linear (a2/a1={})", - a2 / a1 - ); - } -} - -#[test] -fn fit_nominal_reports_convergence_and_rejects_invalid_controls() { - let (n_persons, n_items, n_cat) = (60usize, 3usize, 3usize); - let y: Vec = (0..n_persons) - .flat_map(|p| (0..n_items).map(move |i| (p + i) % n_cat)) - .collect(); - let fit = fit_nominal(&y, None, n_persons, n_items, n_cat, 21, 1, 1e-12).unwrap(); - assert!(!fit.converged); - assert_eq!(fit.termination_reason, "max_iter"); - assert_eq!(fit.n_iter, 1); - assert_eq!(fit.loglik_trace.len(), fit.n_iter + 1); - assert_eq!(fit.loglik, *fit.loglik_trace.last().unwrap()); - assert!(fit.final_delta.is_finite()); - assert!(fit.final_delta > fit.stopping_tolerance); - assert!(fit - .loglik_trace - .windows(2) - .all(|pair| pair[1] >= pair[0] - 1e-10)); - - assert!(fit_nominal(&[], None, 0, n_items, n_cat, 21, 10, 1e-6).is_err()); - assert!(fit_nominal(&y, None, n_persons, n_items, n_cat, 21, 0, 1e-6).is_err()); - assert!(fit_nominal(&y, None, n_persons, n_items, n_cat, 21, 10, f64::INFINITY).is_err()); - let observed: Vec = (0..n_persons) - .flat_map(|_| (0..n_items).map(|i| i != 1)) - .collect(); - assert!(fit_nominal(&y, Some(&observed), n_persons, n_items, n_cat, 21, 10, 1e-6).is_err()); -} - -/// Aggregate nominal-model recovery (RMSE and mean |bias|) for the free -/// scores and intercepts over `reps` datasets at fixed true parameters, with -/// per-item sign alignment (the model is identified up to (a_k,θ)→(−a_k,−θ)). -fn mc_nominal_recovery(reps: usize, n_persons: usize, skew: bool) -> (f64, f64, f64, f64) { - let (n_items, k) = (6usize, 4usize); - let z = k - 1; - let a_true: Vec> = (0..n_items) - .map(|i| { - vec![ - 0.9 + 0.04 * i as f64, - 2.0 - 0.03 * i as f64, - 2.7 + 0.05 * i as f64, - ] - }) - .collect(); - let c_true: Vec> = (0..n_items) - .map(|i| vec![0.5 - 0.05 * i as f64, 0.0, -0.6 + 0.05 * i as f64]) - .collect(); - let (mut a_err, mut a_sq, mut c_err, mut c_sq) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); - let mut cnt = 0.0_f64; - for rep in 0..reps { - let mut u = rng(31337 + rep as u64 * 131 + if skew { 9 } else { 0 }); - let mut yi = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let theta = if skew { - -(u().max(1e-12)).ln() - 1.0 - } else { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - for i in 0..n_items { - let mut scores = vec![0.0_f64; k]; - let mut intercepts = vec![0.0_f64; k]; - for m in 0..z { - scores[m + 1] = a_true[i][m]; - intercepts[m + 1] = c_true[i][m]; - } - let lp = gpcm_logprobs(theta, &scores, &intercepts); - let draw = u(); - let (mut acc, mut cat) = (0.0_f64, k - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - yi[p * n_items + i] = cat; - } - } - let fit = fit_nominal(&yi, None, n_persons, n_items, k, 21, 200, 1e-6).unwrap(); - assert!( - fit.converged, - "nominal recovery replicate {rep} did not converge: reason={} n_iter={} \ - final_delta={:.6e} tolerance={:.6e}", - fit.termination_reason, fit.n_iter, fit.final_delta, fit.stopping_tolerance - ); - for i in 0..n_items { - // align the reflection sign to the truth for this item - let dot: f64 = (0..z).map(|m| fit.scores[i][m] * a_true[i][m]).sum(); - let s = if dot >= 0.0 { 1.0 } else { -1.0 }; - for m in 0..z { - let ea = s * fit.scores[i][m] - a_true[i][m]; - a_err += ea; - a_sq += ea * ea; - let ec = fit.intercepts[i][m] - c_true[i][m]; - c_err += ec; - c_sq += ec * ec; - cnt += 1.0; - } - } - } - ( - (a_sq / cnt).sqrt(), - (a_err / cnt).abs(), - (c_sq / cnt).sqrt(), - (c_err / cnt).abs(), - ) -} - -#[test] -fn fit_nominal_recovery_ci_guard() { - // Fast guard. Authoritative >=500-rep study is - // fit_nominal_recovery_monte_carlo_500 (ignored). - let (reps, n) = (12usize, 2000usize); - let (ar, ab, cr, cb) = mc_nominal_recovery(reps, n, false); - let (asr, _, csr, _) = mc_nominal_recovery(reps, n, true); - println!( - "[nominal recovery] reps={reps} N={n} normal: score RMSE={ar:.4} |bias|={ab:.4} \ - intercept RMSE={cr:.4} |bias|={cb:.4} skew: score RMSE={asr:.4} intercept RMSE={csr:.4}" - ); - assert!( - ar < 0.25 && cr < 0.30, - "normal recovery too loose: a={ar}, c={cr}" - ); - assert!(ab < 0.12, "normal score bias too large: {ab}"); - assert!( - asr > ar, - "skew should degrade score recovery: {asr} vs {ar}" - ); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn fit_nominal_recovery_monte_carlo_500() { - let (reps, n) = (500usize, 2000usize); - let (ar, ab, cr, cb) = mc_nominal_recovery(reps, n, false); - let (asr, asb, csr, _) = mc_nominal_recovery(reps, n, true); - println!( - "[nominal recovery 500] N={n} normal: score RMSE={ar:.4} |bias|={ab:.4} \ - intercept RMSE={cr:.4} |bias|={cb:.4} skew: score RMSE={asr:.4} |bias|={asb:.4} \ - intercept RMSE={csr:.4}" - ); - assert!( - ar < 0.15 && cr < 0.20, - "normal recovery too loose: a={ar}, c={cr}" - ); - assert!(ab < 0.05, "normal score bias not near zero: {ab}"); - assert!( - asr > ar + 0.03, - "skew should measurably degrade recovery: {asr} vs {ar}" - ); -} - -#[test] -fn poly_person_fit_matches_binary_lz_at_k2() { - // At K=2 the polytomous l_z must equal the trusted binary person_fit l_z - // on the same EAP trait (both cells reduce to the 2PL); l_z* matches to - // finite-difference tolerance (poly uses a numerical trait derivative). - use crate::fitstats::person_fit; - use crate::scoring::ItemBank; - use crate::ModelType; - let (n_persons, n_items) = (1000usize, 12usize); - let mut u = rng(56789); - let a: Vec = (0..n_items).map(|i| 0.9 + 0.06 * i as f64).collect(); - let b: Vec = (0..n_items).map(|i| -0.8 + 0.14 * i as f64).collect(); - let mut yf = vec![0.0_f64; n_persons * n_items]; - let mut yi = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let u1 = u().max(1e-12); - let u2 = u(); - let th = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - for i in 0..n_items { - let pr = 1.0 / (1.0 + (-(a[i] * th + b[i])).exp()); - let v = if u() < pr { 1.0 } else { 0.0 }; - yf[p * n_items + i] = v; - yi[p * n_items + i] = v as usize; - } - } - let obs = vec![true; n_persons * n_items]; - let poly = poly_person_fit( - &yi, - None, - n_persons, - n_items, - 2, - &a, - &b, - PolyModel::Gpcm, - 41, - 0.0, - 1.0, - -1.645, - ) - .unwrap(); - let alpha: Vec = a.iter().map(|x| x.ln()).collect(); - let zeta = vec![0.0_f64; n_items]; - let fid = vec![0usize; n_items]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -50.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let xi = vec![0.0_f64; n_persons]; - let bin = person_fit( - &bank, - &yf, - &obs, - n_persons, - &poly.theta_eap, - &xi, - &[], - -1.645, - ) - .unwrap(); - let (mut d_lz, mut d_lzs) = (0.0_f64, 0.0_f64); - for p in 0..n_persons { - if poly.lz[p].is_finite() && bin.lz[p].is_finite() { - d_lz = d_lz.max((poly.lz[p] - bin.lz[p]).abs()); - d_lzs = d_lzs.max((poly.lz_star[p] - bin.lz_star[p]).abs()); - } - } - assert!(d_lz < 1e-6, "l_z max diff vs binary: {d_lz}"); - assert!(d_lzs < 5e-3, "l_z* max diff vs binary: {d_lzs}"); -} - -// GPCM person-fit Monte-Carlo: a fraction of respondents answer carelessly -// (uniform random categories) and the rest come from the model; evaluated at -// the true item parameters. Returns (Type I flag rate among model -// respondents, power among careless respondents, mean l_z*, sd l_z*). -fn mc_poly_person_fit(reps: usize, n_persons: usize, skew: bool) -> (f64, f64, f64, f64) { - let (n_items, k) = (20usize, 3usize); - let z = k - 1; - let a_true: Vec = (0..n_items).map(|i| 1.0 + 0.03 * i as f64).collect(); - let cat_true: Vec = (0..n_items) - .flat_map(|i| vec![0.6 - 0.01 * i as f64, -0.6 + 0.01 * i as f64]) - .collect(); - let n_care = n_persons / 10; // first 10% are careless - let (mut n_norm, mut flag_norm, mut flag_care) = (0usize, 0usize, 0usize); - let (mut sum, mut sum2) = (0.0_f64, 0.0_f64); - for rep in 0..reps { - let mut u = rng(7000 + rep as u64 * 131 + if skew { 3 } else { 0 }); - let mut yi = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let careless = p < n_care; - let theta = if skew { - -(u().max(1e-12)).ln() - 1.0 - } else { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - for i in 0..n_items { - // careless / inconsistent responder: the implied trait alternates - // +-1.6 across items, so no single theta fits the pattern. - let theta_use = if careless { - if i % 2 == 0 { - 1.6 - } else { - -1.6 - } - } else { - theta - }; - let base = a_true[i] * theta_use; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; k]; - ic[1..].copy_from_slice(&cat_true[i * z..(i + 1) * z]); - let lp = gpcm_logprobs(base, &scores, &ic); - let draw = u(); - let (mut acc, mut cat) = (0.0_f64, k - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - yi[p * n_items + i] = cat; - } - } - let pf = poly_person_fit( - &yi, - None, - n_persons, - n_items, - k, - &a_true, - &cat_true, - PolyModel::Gpcm, - 21, - 0.0, - 1.0, - -1.645, - ) - .unwrap(); - for p in 0..n_persons { - if p < n_care { - if pf.flagged[p] { - flag_care += 1; - } - } else { - n_norm += 1; - if pf.flagged[p] { - flag_norm += 1; - } - if pf.lz_star[p].is_finite() { - sum += pf.lz_star[p]; - sum2 += pf.lz_star[p] * pf.lz_star[p]; - } - } - } - } - let mean = sum / n_norm as f64; - let sd = (sum2 / n_norm as f64 - mean * mean).max(0.0).sqrt(); - ( - flag_norm as f64 / n_norm as f64, - flag_care as f64 / (n_care * reps) as f64, - mean, - sd, - ) -} - -#[test] -fn poly_person_fit_type1_and_power() { - // Fast guard. Authoritative >=500-rep study is - // poly_person_fit_monte_carlo_500 (ignored). - let (reps, n) = (8usize, 800usize); - let (t1, power, mean, sd) = mc_poly_person_fit(reps, n, false); - let (t1s, _, _, _) = mc_poly_person_fit(reps, n, true); - println!( - "[poly person-fit] normal: Type I(l_z*<-1.645)={t1:.3} power(careless)={power:.3} \ - mean(l_z*)={mean:.3} sd(l_z*)={sd:.3} skew: Type I={t1s:.3}" - ); - assert!((0.01..=0.12).contains(&t1), "Type I off nominal: {t1}"); - assert!( - power > 0.5, - "power to flag careless responders too low: {power}" - ); - assert!( - mean.abs() < 0.4 && (0.75..=1.3).contains(&sd), - "l_z* not ~N(0,1): mean={mean}, sd={sd}" - ); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn poly_person_fit_monte_carlo_500() { - let (reps, n) = (500usize, 600usize); - let (t1, power, mean, sd) = mc_poly_person_fit(reps, n, false); - println!( - "[poly person-fit 500] normal: Type I={t1:.4} power={power:.4} mean(l_z*)={mean:.4} \ - sd(l_z*)={sd:.4}" - ); - // l_z* runs slightly high at a 20-item test (a documented finite-length - // effect); it converges to nominal as the test lengthens. - assert!((0.02..=0.11).contains(&t1), "Type I off nominal: {t1}"); - assert!(power > 0.7, "power too low: {power}"); - assert!( - mean.abs() < 0.25 && (0.85..=1.2).contains(&sd), - "l_z* not ~N(0,1): mean={mean}, sd={sd}" - ); -} - -/// A GPCM item bank for the CAT tests: `n_items` items with difficulties -/// spread across the trait range so the adaptive selector has informative -/// items at every ability level. -fn cat_bank(n_items: usize, k: usize) -> (Vec, Vec) { - let z = k - 1; - let mut slope = vec![0.0_f64; n_items]; - let mut cat = vec![0.0_f64; n_items * z]; - for i in 0..n_items { - let a = 1.0 + 0.25 * (i % 3) as f64; // 1.0 / 1.25 / 1.5, cycling - slope[i] = a; - let b = -2.2 + 4.4 * i as f64 / (n_items - 1) as f64; // spread difficulty - let mut cum = 0.0_f64; - for m in 0..z { - let step = b + (m as f64 - (z as f64 - 1.0) / 2.0) * 0.9; - cum += step; - cat[i * z + m] = -a * cum; - } - } - (slope, cat) -} - -fn cat_rmse(eap: &[f64], true_theta: &[f64]) -> f64 { - let n = true_theta.len() as f64; - (eap.iter() - .zip(true_theta) - .map(|(e, t)| (e - t).powi(2)) - .sum::() - / n) - .sqrt() -} - -#[test] -fn poly_cat_recovers_and_beats_random() { - // Fast guard. Authoritative >=500-simulee study is - // poly_cat_monte_carlo_500 (ignored). - let (n_items, k) = (40usize, 4usize); - let (slope, cat) = cat_bank(n_items, k); - let n_sim = 300usize; - let mut u = rng(9001); - let true_theta: Vec = (0..n_sim) - .map(|_| { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }) - .collect(); - let bad_cat = std::panic::catch_unwind(|| { - poly_cat_simulate( - &[0.0], - &[f64::NAN; 2], - &[0.0; 4], - 2, - k, - PolyModel::Gpcm, - 21, - 0.30, - 1, - 2, - true, - 111, - ) - }); - assert!( - bad_cat.is_ok(), - "poly_cat_simulate must return an error for non-finite item parameters" - ); - assert!(bad_cat.unwrap().is_err()); - let bad_grm_cat = std::panic::catch_unwind(|| { - poly_cat_simulate( - &[0.0], - &[1.0; 2], - &[-1.0, 1.0, 0.5, -0.5], - 2, - k, - PolyModel::Grm, - 21, - 0.30, - 1, - 2, - true, - 113, - ) - }); - assert!( - bad_grm_cat.is_ok(), - "poly_cat_simulate must return an error for unordered GRM thresholds" - ); - assert!(bad_grm_cat.unwrap().is_err()); - assert!(poly_cat_simulate( - &[f64::NAN], - &slope, - &cat, - n_items, - k, - PolyModel::Gpcm, - 21, - 0.30, - 5, - 30, - false, - 112, - ) - .is_err()); - // adaptive, variable length: stop at SE < 0.30 - let var = poly_cat_simulate( - &true_theta, - &slope, - &cat, - n_items, - k, - PolyModel::Gpcm, - 21, - 0.30, - 5, - 30, - true, - 111, - ) - .unwrap(); - let rmse_var = cat_rmse(&var.theta_eap, &true_theta); - let mean_items = var.n_used.iter().sum::() as f64 / n_sim as f64; - println!("[poly CAT] var-len(SE<.30): RMSE={rmse_var:.3} mean_items={mean_items:.1}/{n_items}"); - assert!(rmse_var < 0.40, "CAT theta RMSE too high: {rmse_var}"); - assert!( - mean_items < 0.75 * n_items as f64, - "CAT should use fewer than the bank: {mean_items}" - ); - // fixed length L=12: maximum-information beats random selection - let adap = poly_cat_simulate( - &true_theta, - &slope, - &cat, - n_items, - k, - PolyModel::Gpcm, - 21, - 0.0, - 12, - 12, - true, - 222, - ) - .unwrap(); - let rand = poly_cat_simulate( - &true_theta, - &slope, - &cat, - n_items, - k, - PolyModel::Gpcm, - 21, - 0.0, - 12, - 12, - false, - 333, - ) - .unwrap(); - let (ra, rr) = ( - cat_rmse(&adap.theta_eap, &true_theta), - cat_rmse(&rand.theta_eap, &true_theta), - ); - println!("[poly CAT] fixed L=12: adaptive RMSE={ra:.3} random RMSE={rr:.3}"); - assert!( - ra < rr, - "max-information CAT should beat random selection: {ra} vs {rr}" - ); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 simulees); run with: cargo test --release -- --ignored --nocapture"] -fn poly_cat_monte_carlo_500() { - let (n_items, k) = (40usize, 4usize); - let (slope, cat) = cat_bank(n_items, k); - let n_sim = 500usize; - for (label, skew) in [("normal", false), ("skew", true)] { - let mut u = rng(if skew { 7001 } else { 7000 }); - let true_theta: Vec = (0..n_sim) - .map(|_| { - if skew { - -(u().max(1e-12)).ln() - 1.0 - } else { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } - }) - .collect(); - let var = poly_cat_simulate( - &true_theta, - &slope, - &cat, - n_items, - k, - PolyModel::Gpcm, - 21, - 0.30, - 5, - 30, - true, - 4242, - ) - .unwrap(); - let rmse = cat_rmse(&var.theta_eap, &true_theta); - let mean_items = var.n_used.iter().sum::() as f64 / n_sim as f64; - let adap = poly_cat_simulate( - &true_theta, - &slope, - &cat, - n_items, - k, - PolyModel::Gpcm, - 21, - 0.0, - 12, - 12, - true, - 5, - ) - .unwrap(); - let rand = poly_cat_simulate( - &true_theta, - &slope, - &cat, - n_items, - k, - PolyModel::Gpcm, - 21, - 0.0, - 12, - 12, - false, - 6, - ) - .unwrap(); - let (ra, rr) = ( - cat_rmse(&adap.theta_eap, &true_theta), - cat_rmse(&rand.theta_eap, &true_theta), - ); - println!( - "[poly CAT 500 θ={label}] var-len RMSE={rmse:.4} mean_items={mean_items:.2}/{n_items} \ - fixed L=12: adaptive RMSE={ra:.4} random RMSE={rr:.4}" - ); - assert!(rmse < 0.42, "{label} CAT RMSE too high: {rmse}"); - assert!( - mean_items < 0.7 * n_items as f64, - "{label} CAT not saving items: {mean_items}" - ); - assert!(ra < rr, "{label} adaptive should beat random: {ra} vs {rr}"); - } -} - -// Two-group GPCM dataset generator for the DIF tests. group 0 = reference -// theta~N(0,1); group 1 = focal theta~N(0.5, 1.2^2) (impact). `dif` on item 0 -// for the focal group: 0=none, 1=uniform (difficulty shift), 2=non-uniform -// (slope 1.6x). `skew` draws the focal trait from Exp(1)-1 instead. -fn gen_two_group_gpcm( - n_per_group: usize, - n_items: usize, - k: usize, - dif: u8, - skew: bool, - seed: u64, -) -> (Vec, Vec) { - let a_true: Vec = (0..n_items).map(|i| 1.0 + 0.05 * i as f64).collect(); - let int_true: Vec> = (0..n_items) - .map(|i| vec![0.7 - 0.05 * i as f64, -0.7 + 0.05 * i as f64]) - .collect(); - let n_persons = 2 * n_per_group; - let mut u = rng(seed); - let mut yi = vec![0usize; n_persons * n_items]; - let mut gid = vec![0usize; n_persons]; - for p in 0..n_persons { - let focal = p >= n_per_group; - gid[p] = focal as usize; - let theta = if !focal { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } else if skew { - -(u().max(1e-12)).ln() - 1.0 - } else { - let u1 = u().max(1e-12); - let u2 = u(); - 0.5 + 1.2 * (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - for i in 0..n_items { - let (a, ints) = if i == 0 && focal && dif == 1 { - let d = 0.6; // uniform: shift difficulty => intercept_k += k*a*d - ( - a_true[0], - vec![ - int_true[0][0] + a_true[0] * d, - int_true[0][1] + 2.0 * a_true[0] * d, - ], - ) - } else if i == 0 && focal && dif == 2 { - (a_true[0] * 1.6, int_true[0].clone()) - } else { - (a_true[i], int_true[i].clone()) - }; - let base = a * theta; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; k]; - ic[1..].copy_from_slice(&ints); - let lp = gpcm_logprobs(base, &scores, &ic); - let draw = u(); - let (mut acc, mut cat) = (0.0_f64, k - 1); - for (c, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - cat = c; - break; - } - } - yi[p * n_items + i] = cat; - } - } - (yi, gid) -} - -#[test] -fn poly_dif_structural_recovers_impact_and_nesting() { - // No DIF, but the focal group has impact N(0.5, 1.2^2): the estimator - // must recover the focal distribution and keep the reference pinned; the - // augmented (item-0-free) model must not fall below the compact one. - let (n_items, k) = (10usize, 3usize); - let (yi, gid) = gen_two_group_gpcm(1200, n_items, k, 0, false, 909); - let np = gid.len(); - let con = fit_poly_multigroup( - &yi, - None, - &gid, - 2, - np, - n_items, - k, - PolyModel::Gpcm, - None, - 21, - 200, - 1e-6, - ) - .unwrap(); - assert!(con.converged, "compact fit: {}", con.termination_reason); - assert!(con.n_iter < 200); - assert_eq!(con.loglik_trace.last().copied(), Some(con.loglik)); - assert!(con.final_delta <= con.stopping_tolerance); - assert_eq!(con.mu[0], 0.0); - assert_eq!(con.sigma[0], 1.0); - assert!( - (con.mu[1] - 0.5).abs() < 0.15, - "focal mean not recovered: {}", - con.mu[1] - ); - assert!( - (con.sigma[1] - 1.2).abs() < 0.2, - "focal sd not recovered: {}", - con.sigma[1] - ); - let aug = fit_poly_multigroup( - &yi, - None, - &gid, - 2, - np, - n_items, - k, - PolyModel::Gpcm, - Some(0), - 21, - 200, - 1e-6, - ) - .unwrap(); - assert!(aug.converged, "augmented fit: {}", aug.termination_reason); - assert!(aug.n_iter < 200); - assert_eq!(aug.loglik_trace.last().copied(), Some(aug.loglik)); - assert!(aug.final_delta <= aug.stopping_tolerance); - for fit in [&con, &aug] { - assert!(fit.loglik_trace.iter().all(|v| v.is_finite())); - assert!(fit.loglik_trace.windows(2).all(|w| w[1] >= w[0] - 1e-9)); - } - println!( - "[poly DIF convergence] compact: reason={} iter={}/200 delta={:.3e} tol={:.3e} ll={:.6}; \ - augmented: reason={} iter={}/200 delta={:.3e} tol={:.3e} ll={:.6}", - con.termination_reason, - con.n_iter, - con.final_delta, - con.stopping_tolerance, - con.loglik, - aug.termination_reason, - aug.n_iter, - aug.final_delta, - aug.stopping_tolerance, - aug.loglik, - ); - // nesting, with tolerance-scaled numerical slack - let slack = 1e-6_f64.max(1e-6 * (1.0 + con.loglik.abs())); - assert!( - aug.loglik >= con.loglik - slack, - "nesting violated: ll_aug={} ll_con={}", - aug.loglik, - con.loglik - ); - assert_eq!(aug.studied_slope.len(), 2); -} - -#[test] -fn poly_dif_rejects_empty_declared_group() { - // Declaring a group with no persons would make df = (n_groups-1)*n_cat - // count parameters no data can identify (conservative, miscalibrated LR). - // The data uses labels {0,1}; declaring n_groups=3 leaves group 2 empty. - let (yi, gid) = gen_two_group_gpcm(300, 6, 3, 0, false, 4242); - let np = gid.len(); - let err = fit_poly_multigroup( - &yi, - None, - &gid, - 3, - np, - 6, - 3, - PolyModel::Gpcm, - None, - 21, - 50, - 1e-4, - ); - assert!(err.is_err(), "empty declared group should be rejected"); -} - -#[test] -fn poly_dif_rejects_unconverged_compact_fit() { - let (yi, gid) = gen_two_group_gpcm(100, 4, 3, 0, false, 1701); - let np = gid.len(); - let result = poly_dif_sweep( - &yi, - None, - &gid, - 2, - np, - 4, - 3, - PolyModel::Gpcm, - Some(&[0]), - 7, - 1, - 1e-12, - 0.05, - ); - let err = match result { - Ok(_) => panic!("iteration-limited compact fit must fail closed"), - Err(err) => err, - }; - assert!(err.contains("did not converge"), "unexpected error: {err}"); - assert!(err.contains("reason=max_iter"), "unexpected error: {err}"); - assert!(err.contains("iteration=1/1"), "unexpected error: {err}"); -} - -// (Type I over non-DIF items, power on item 0 when DIF is present, mean LR -// among null items) over `reps` two-group datasets. df = (G-1)*K = K. -fn mc_poly_dif( - reps: usize, - n_per_group: usize, - n_items: usize, - dif: u8, - skew: bool, -) -> (f64, f64, f64) { - let k = 3usize; - let (mut t1_rej, mut t1_cnt) = (0usize, 0usize); - let (mut pow_rej, mut lr_sum, mut lr_cnt) = (0usize, 0.0_f64, 0usize); - for rep in 0..reps { - let seed = 88_000 + rep as u64 * 131 + skew as u64 * 3 + dif as u64 * 7; - let (yi, gid) = gen_two_group_gpcm(n_per_group, n_items, k, dif, skew, seed); - let np = gid.len(); - let rows = poly_dif_sweep( - &yi, - None, - &gid, - 2, - np, - n_items, - k, - PolyModel::Gpcm, - None, - 21, - 80, - 1e-5, - 0.05, - ) - .unwrap(); - for r in &rows { - let rej = r.p_value < 0.05; - if r.item == 0 && dif != 0 { - if rej { - pow_rej += 1; - } - } else { - // non-DIF items (and item 0 when dif==0) measure Type I - if rej { - t1_rej += 1; - } - t1_cnt += 1; - lr_sum += r.lr; - lr_cnt += 1; - } - } - } - let type1 = t1_rej as f64 / t1_cnt as f64; - let power = if dif != 0 { - pow_rej as f64 / reps as f64 - } else { - 0.0 - }; - (type1, power, lr_sum / lr_cnt as f64) -} - -#[test] -fn poly_dif_type1_and_power() { - // Fast guard (few reps => Type I lower bound is unmeasurable; mean(LR)~df - // is the robust cheap calibration). Authoritative >=500-rep study with a - // tight Type I band is poly_dif_monte_carlo_500. - let df = 3.0; // (G-1)*K = K = 3 - let (t1, _, mean_lr) = mc_poly_dif(3, 400, 6, 0, false); // no DIF - let (t1u, pow_u, _) = mc_poly_dif(3, 400, 6, 1, false); // uniform DIF on item 0 - println!( - "[poly DIF] df={df} no-DIF: Type I={t1:.3} mean(LR)={mean_lr:.2} \ - uniform: Type I(others)={t1u:.3} power(item0)={pow_u:.3}" - ); - assert!(t1 < 0.18, "Type I inflated: {t1}"); // lower bound needs the 500-rep test - assert!( - (df - 1.2..=df + 1.4).contains(&mean_lr), - "mean LR should ~ df={df}: {mean_lr}" - ); - assert!(pow_u > 0.6, "uniform DIF power too low: {pow_u}"); - assert!(t1u < 0.2, "non-DIF items over-flagged under DIF: {t1u}"); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn poly_dif_monte_carlo_500() { - let reps = 500usize; - let (t1, _, mean_lr) = mc_poly_dif(reps, 500, 8, 0, false); - let (_, pow_u, _) = mc_poly_dif(reps, 500, 8, 1, false); - let (_, pow_n, _) = mc_poly_dif(reps, 500, 8, 2, false); - let (t1s, _, _) = mc_poly_dif(reps, 500, 8, 0, true); - println!( - "[poly DIF 500] df=3 no-DIF: Type I={t1:.4} mean(LR)={mean_lr:.3} \ - power: uniform={pow_u:.3} non-uniform={pow_n:.3} skew: Type I={t1s:.4}" - ); - assert!((0.03..=0.075).contains(&t1), "Type I off nominal: {t1}"); - assert!( - (2.6..=3.4).contains(&mean_lr), - "mean LR should ~ df=3: {mean_lr}" - ); - assert!( - pow_u > 0.85 && pow_n > 0.7, - "DIF power too low: uniform={pow_u} nonuniform={pow_n}" - ); -} - -// Hand-coded van der Flier dichotomous U3 (the trusted binary reference the -// polytomous U3 must reduce to at n_cat=2), with the same den=1 boundary. -fn u3_binary_vdf(y: &[usize], n_persons: usize, n_items: usize) -> Vec { - let mut w = vec![0.0_f64; n_items]; - for i in 0..n_items { - let s: usize = (0..n_persons).map(|p| y[p * n_items + i]).sum(); - let pi = s as f64 / n_persons as f64; - w[i] = if pi <= 0.0 || pi >= 1.0 { - 0.0 - } else { - (pi / (1.0 - pi)).ln() - }; - } - let mut sorted = w.clone(); - sorted.sort_by(|a, b| b.partial_cmp(a).unwrap()); // descending - let mut topsum = vec![0.0_f64; n_items + 1]; - let mut botsum = vec![0.0_f64; n_items + 1]; - for s in 1..=n_items { - topsum[s] = topsum[s - 1] + sorted[s - 1]; - botsum[s] = botsum[s - 1] + sorted[n_items - s]; - } - let mut out = vec![0.0_f64; n_persons]; - for p in 0..n_persons { - let (mut sc, mut wsum) = (0usize, 0.0_f64); - for i in 0..n_items { - if y[p * n_items + i] == 1 { - sc += 1; - wsum += w[i]; - } - } - let den = if sc == 0 || sc == n_items { - 1.0 - } else { - topsum[sc] - botsum[sc] - }; - out[p] = if den > 1e-9 { - (topsum[sc] - wsum) / den - } else { - f64::NAN - }; - } - out -} - -#[test] -fn poly_u3_reduces_to_binary_vdf() { - // At n_cat=2 the polytomous U3 must be identical to van der Flier's U3 - // (the "reduce to a trusted binary" correctness anchor). - let mut u = rng(1234); - let (n_persons, n_items) = (400usize, 12usize); - let mut y = vec![0usize; n_persons * n_items]; - for v in y.iter_mut() { - *v = if u() < 0.5 { 1 } else { 0 }; - } - let res = u3_poly_person_fit(&y, None, n_persons, n_items, 2, None).unwrap(); - let vdf = u3_binary_vdf(&y, n_persons, n_items); - let mut maxdev = 0.0_f64; - for p in 0..n_persons { - let (a, b) = (res.u3poly[p], vdf[p]); - if a.is_nan() && b.is_nan() { - continue; - } - maxdev = maxdev.max((a - b).abs()); - } - assert!( - maxdev < 1e-10, - "U3poly(K=2) must equal vdF U3: maxdev={maxdev}" - ); - // orientation: a popularity-inconsistent person scores higher than a - // consistent one. Build two persons on a fixed 4-item bank. - let ni = 4; - // popularities descending: item 0 easiest .. item 3 hardest - let mut yy = vec![0usize; 40 * ni]; - let mut u2 = rng(99); - for p in 0..40 { - for i in 0..ni { - let pi = 0.8 - 0.18 * i as f64; // 0.80,0.62,0.44,0.26 - yy[p * ni + i] = if u2() < pi { 1 } else { 0 }; - } - } - // consistent person (easy items 1, hard 0) vs reversed (hard 1, easy 0) - yy[0 * ni..1 * ni].copy_from_slice(&[1, 1, 0, 0]); - yy[1 * ni..2 * ni].copy_from_slice(&[0, 0, 1, 1]); - let r2 = u3_poly_person_fit(&yy, None, 40, ni, 2, None).unwrap(); - assert!( - r2.u3poly[1] > r2.u3poly[0], - "reversed person must have larger U3" - ); - assert!( - r2.u3poly[0] < 0.5 && r2.u3poly[1] > 0.5, - "orientation off: {:?}", - &r2.u3poly[..2] - ); -} - -// GPCM data generator: first `n_care` persons are careless (uniform-random -// categories, ignoring item popularity); the rest respond from the model. -fn gen_u3_data( - slope: &[f64], - cat: &[f64], - n_persons: usize, - n_items: usize, - k: usize, - n_care: usize, - skew: bool, - seed: u64, -) -> Vec { - let z = k - 1; - let mut u = rng(seed); - let mut y = vec![0usize; n_persons * n_items]; - for p in 0..n_persons { - let careless = p < n_care; - let theta = if skew { - -(u().max(1e-12)).ln() - 1.0 - } else { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - for i in 0..n_items { - if careless { - y[p * n_items + i] = ((u() * k as f64) as usize).min(k - 1); - } else { - let base = slope[i] * theta; - let scores: Vec = (0..k).map(|c| c as f64).collect(); - let mut ic = vec![0.0_f64; k]; - ic[1..].copy_from_slice(&cat[i * z..(i + 1) * z]); - let lp = gpcm_logprobs(base, &scores, &ic); - let draw = u(); - let (mut acc, mut c) = (0.0_f64, k - 1); - for (cc, l) in lp.iter().enumerate() { - acc += l.exp(); - if draw <= acc { - c = cc; - break; - } - } - y[p * n_items + i] = c; - } - } - } - y -} - -fn quantile_sorted(v: &mut Vec, q: f64) -> f64 { - v.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let n = v.len(); - let idx = (n as f64 - 1.0) * q; - let (lo, hi) = (idx.floor() as usize, idx.ceil() as usize); - if lo == hi { - v[lo] - } else { - v[lo] + (idx - lo as f64) * (v[hi] - v[lo]) - } -} - -// Returns (marginal Type I, max |flag_rate - alpha| across total-score bins, -// power on careless responders). The cutoff is the (1-alpha) quantile of null -// U3poly estimated under the MATCHING latent shape from disjoint seeds. -fn mc_u3poly(reps: usize, n_persons: usize, skew: bool) -> (f64, f64, f64) { - let (n_items, k) = (20usize, 5usize); - let alpha = 0.05_f64; - let (slope, cat) = cat_bank(n_items, k); - let maxnc = n_items * (k - 1); - let so = if skew { 7 } else { 0 }; - // cutoff from pooled null U3poly (seed base 900000, disjoint from eval) - let mut pool = Vec::new(); - for b in 0..6u64 { - let y = gen_u3_data( - &slope, - &cat, - n_persons, - n_items, - k, - 0, - skew, - 900_000 + b * 131 + so, - ); - let r = u3_poly_person_fit(&y, None, n_persons, n_items, k, None).unwrap(); - pool.extend(r.u3poly.into_iter().filter(|v| v.is_finite())); - } - let cutoff = quantile_sorted(&mut pool, 1.0 - alpha); - let n_bins = 3usize; - let (mut bin_flag, mut bin_tot) = (vec![0usize; n_bins], vec![0usize; n_bins]); - let (mut t1_flag, mut t1_tot) = (0usize, 0usize); - let (mut pw_flag, mut pw_tot) = (0usize, 0usize); - let n_care = n_persons / 5; // 20% careless in the power datasets - for rep in 0..reps as u64 { - // null eval (disjoint seed base 100000) - let yn = gen_u3_data( - &slope, - &cat, - n_persons, - n_items, - k, - 0, - skew, - 100_000 + rep * 131 + so, - ); - let rn = u3_poly_person_fit(&yn, None, n_persons, n_items, k, Some(cutoff)).unwrap(); - for p in 0..n_persons { - if rn.u3poly[p].is_finite() { - t1_tot += 1; - if rn.flagged[p] { - t1_flag += 1; - } - let bin = (rn.total_score[p] * n_bins / (maxnc + 1)).min(n_bins - 1); - bin_tot[bin] += 1; - if rn.flagged[p] { - bin_flag[bin] += 1; - } - } - } - // power eval (careless responders, seed base 200000) - let ya = gen_u3_data( - &slope, - &cat, - n_persons, - n_items, - k, - n_care, - skew, - 200_000 + rep * 131 + so, - ); - let ra = u3_poly_person_fit(&ya, None, n_persons, n_items, k, Some(cutoff)).unwrap(); - for p in 0..n_care { - if ra.u3poly[p].is_finite() { - pw_tot += 1; - if ra.flagged[p] { - pw_flag += 1; - } - } - } - } - let type1 = t1_flag as f64 / t1_tot.max(1) as f64; - let bin_maxdev = (0..n_bins) - .map(|b| (bin_flag[b] as f64 / bin_tot[b].max(1) as f64 - alpha).abs()) - .fold(0.0_f64, f64::max); - let power = pw_flag as f64 / pw_tot.max(1) as f64; - (type1, bin_maxdev, power) -} - -#[test] -fn poly_u3_type1_and_power() { - // Fast guard. Authoritative >=500-rep study is poly_u3_monte_carlo_500. - let (t1, _bindev, power) = mc_u3poly(6, 500, false); - println!("[u3poly] normal: Type I={t1:.3} power(careless)={power:.3}"); - assert!((0.01..=0.12).contains(&t1), "Type I off nominal: {t1}"); - assert!(power > 0.5, "careless-detection power too low: {power}"); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn poly_u3_monte_carlo_500() { - let reps = 500usize; - let (t1n, bindev_n, pow_n) = mc_u3poly(reps, 600, false); - let (t1s, bindev_s, pow_s) = mc_u3poly(reps, 600, true); - println!( - "[u3poly 500] normal: Type I={t1n:.4} bin-maxdev={bindev_n:.3} power={pow_n:.3} \ - skew: Type I={t1s:.4} bin-maxdev={bindev_s:.3} power={pow_s:.3}" - ); - // marginal Type I calibrated by the simulated cutoff; per-NC-bin deviation - // reported (a single pooled cutoff cannot perfectly condition on the total - // score — Emons 2008 uses simulated critical values for this reason). - assert!( - (0.03..=0.08).contains(&t1n), - "normal Type I off nominal: {t1n}" - ); - assert!(pow_n > 0.7, "normal careless power too low: {pow_n}"); - assert!( - bindev_n < 0.10, - "per-score-group miscalibration too large: {bindev_n}" - ); -} diff --git a/tests/unit/quadrature_tests.rs b/tests/unit/quadrature_tests.rs deleted file mode 100644 index 1a3bec21d..000000000 --- a/tests/unit/quadrature_tests.rs +++ /dev/null @@ -1,12 +0,0 @@ -use super::*; - -#[test] -fn required_rule_covers_success_and_error_contracts() { - let (nodes, weights) = require_gh_rule(7, "quadrature size").unwrap(); - assert_eq!(nodes.len(), 7); - assert_eq!(weights.len(), 7); - assert_eq!( - require_gh_rule(8, "quadrature size").unwrap_err(), - "unsupported quadrature size 8" - ); -} diff --git a/tests/unit/rasch_cml_tests.rs b/tests/unit/rasch_cml_tests.rs deleted file mode 100644 index ef48ff5fa..000000000 --- a/tests/unit/rasch_cml_tests.rs +++ /dev/null @@ -1,286 +0,0 @@ -use super::*; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -/// Brute-force elementary symmetric function of order `r` (sum over all size-`r` subsets). -fn esf_brute(eps: &[f64], r: usize) -> f64 { - let k = eps.len(); - let mut total = 0.0; - for mask in 0u64..(1u64 << k) { - if (mask.count_ones() as usize) == r { - let mut prod = 1.0; - for i in 0..k { - if mask & (1 << i) != 0 { - prod *= eps[i]; - } - } - total += prod; - } - } - total -} - -/// The summation-algorithm ESF (and the leave-one-out / leave-two-out passes) match the brute-force -/// subset sums exactly. -#[test] -fn esf_matches_brute_force() { - let eps = [0.4, 1.1, 2.3, 0.7, 1.6]; - let k = eps.len(); - let g = esf(&eps); - for r in 0..=k { - assert!((g[r] - esf_brute(&eps, r)).abs() < 1e-10, "gamma_{r}"); - } - // leave-one-out - for omit in 0..k { - let gi = esf_omit(&eps, omit); - let sub: Vec = (0..k).filter(|&j| j != omit).map(|j| eps[j]).collect(); - for r in 0..k { - assert!( - (gi[r] - esf_brute(&sub, r)).abs() < 1e-10, - "gamma^({omit})_{r}" - ); - } - } - // leave-two-out - let gij = esf_omit2(&eps, 1, 3); - let sub: Vec = (0..k) - .filter(|&j| j != 1 && j != 3) - .map(|j| eps[j]) - .collect(); - for r in 0..k - 1 { - assert!( - (gij[r] - esf_brute(&sub, r)).abs() < 1e-10, - "gamma^(1,3)_{r}" - ); - } -} - -/// Deterministic anchor: the analytic CML gradient and Hessian match finite differences of the -/// conditional log-likelihood (pins the sign of `d eps/d beta = -eps` and the ESF derivative -/// recursions — a sign error would flip the whole Newton direction). -#[test] -fn cml_gradient_hessian_match_finite_difference() { - let beta = [-0.8, 0.3, 1.1, -0.2, 0.6]; - let k = beta.len(); - let s = [40.0, 55.0, 62.0, 48.0, 58.0]; - let nr = [0.0, 20.0, 30.0, 25.0, 15.0, 0.0]; // r = 0..=5, r=0,5 uninformative - let (_ll, grad, hess) = cml_eval(&beta, &s, &nr); - let eps = 1e-6; - for i in 0..k { - let mut bp = beta; - bp[i] += eps; - let mut bm = beta; - bm[i] -= eps; - let fd = (cml_eval(&bp, &s, &nr).0 - cml_eval(&bm, &s, &nr).0) / (2.0 * eps); - assert!( - (grad[i] - fd).abs() < 1e-4, - "grad[{i}] {} vs FD {fd}", - grad[i] - ); - } - let hh = 1e-4; - for a in 0..k { - for b in 0..k { - let mut pp = beta; - pp[a] += hh; - pp[b] += hh; - let mut pm = beta; - pm[a] += hh; - pm[b] -= hh; - let mut mp = beta; - mp[a] -= hh; - mp[b] += hh; - let mut mm = beta; - mm[a] -= hh; - mm[b] -= hh; - let d2 = - (cml_eval(&pp, &s, &nr).0 - cml_eval(&pm, &s, &nr).0 - cml_eval(&mp, &s, &nr).0 - + cml_eval(&mm, &s, &nr).0) - / (4.0 * hh * hh); - assert!( - (hess[a * k + b] - d2).abs() < 1e-2, - "hess[{a}][{b}] {} vs FD {d2}", - hess[a * k + b] - ); - } - } -} - -fn simulate(beta: &[f64], theta: &[f64], rng: &mut Lcg) -> Vec { - let k = beta.len(); - let n = theta.len(); - let mut y = vec![0u8; n * k]; - for p in 0..n { - for i in 0..k { - let pr = 1.0 / (1.0 + (-(theta[p] - beta[i])).exp()); - y[p * k + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - y -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64).sqrt() -} - -/// THE DEFINING CML PROPERTY (person-distribution-free): the same beta_hat (up to the sum-zero -/// constant) is recovered whether the simulating theta is N(0,1) or strongly right-skewed. A plain -/// value-recovery test is INSUFFICIENT — JML also recovers beta at large k — so the discriminating -/// assertion is the AGREEMENT between the two distributions' estimates, not merely closeness to -/// truth. -#[test] -fn cml_is_person_distribution_free() { - let mut beta = vec![-1.6, -0.9, -0.3, 0.2, 0.7, 1.2, 1.7, 0.0]; - center(&mut beta); - let k = beta.len(); - let n = 4000usize; - let mut rng = Lcg(918273); - // (a) theta ~ N(0,1) - let th_norm: Vec = (0..n).map(|_| rng.normal()).collect(); - // (b) theta strongly right-skew (standardized Exp - shifted), a very different distribution - let th_skew: Vec = (0..n) - .map(|_| 1.5 * (-(rng.next_f64().max(1e-12)).ln()) - 1.0) - .collect(); - let ya = simulate(&beta, &th_norm, &mut rng); - let yb = simulate(&beta, &th_skew, &mut rng); - let fa = fit_rasch_cml(&ya, n, k, 100, 1e-9).unwrap(); - let fb = fit_rasch_cml(&yb, n, k, 100, 1e-9).unwrap(); - assert!(fa.converged && fb.converged); - // both recover the truth within MC tolerance - assert!( - rmse(&fa.beta, &beta) < 0.15, - "N(0,1) beta RMSE {}", - rmse(&fa.beta, &beta) - ); - assert!( - rmse(&fb.beta, &beta) < 0.15, - "skew beta RMSE {}", - rmse(&fb.beta, &beta) - ); - // and — the CML signature — the two estimates AGREE despite the very different ability - // distributions (a distribution-DEPENDENT estimator would diverge here) - assert!( - rmse(&fa.beta, &fb.beta) < 0.15, - "distribution-free property violated: N(0,1) vs skew beta RMSE {}", - rmse(&fa.beta, &fb.beta) - ); - // SEs finite and positive on-support - assert!(fa.se.iter().all(|s| s.is_finite() && *s > 0.0)); -} - -/// Andersen (1973) LR: on Rasch-generated data an arbitrary (ability-independent) group split does -/// NOT reject (statistic near its df), while data with a group-specific difficulty shift (Rasch -/// misfit / DIF) is rejected with a large statistic. Pins the df and the upper-tail direction. -#[test] -fn andersen_lr_detects_group_difficulty_shift() { - let mut beta = vec![-1.2, -0.6, 0.0, 0.6, 1.2, -0.3, 0.3, 0.9]; - center(&mut beta); - let k = beta.len(); - let n = 3000usize; - let mut rng = Lcg(0xA9D5); - let theta: Vec = (0..n).map(|_| rng.normal()).collect(); - let group: Vec = (0..n).map(|p| (p % 2) as u8).collect(); - // (1) true Rasch, split by an ARBITRARY label (independent of ability): should NOT reject - let y_fit = simulate(&beta, &theta, &mut rng); - let t1 = andersen_lr_test(&y_fit, &group, 2, n, k, 100, 1e-9).unwrap(); - assert_eq!(t1.df, (2 - 1) * (k - 1)); - assert!( - t1.lr / (t1.df as f64) < 3.0, - "Rasch data over-rejected: LR {} df {}", - t1.lr, - t1.df - ); - assert!(t1.p_value > 0.01, "Rasch data p too small: {}", t1.p_value); - // (2) group 1 gets a difficulty shift on item 0 (violates Rasch invariance): should reject - let mut y_dif = vec![0u8; n * k]; - for p in 0..n { - for i in 0..k { - let mut bi = beta[i]; - if i == 0 && group[p] == 1 { - bi += 1.5; - } - let pr = 1.0 / (1.0 + (-(theta[p] - bi)).exp()); - y_dif[p * k + i] = if rng.next_f64() < pr { 1 } else { 0 }; - } - } - let t2 = andersen_lr_test(&y_dif, &group, 2, n, k, 100, 1e-9).unwrap(); - assert!( - t2.lr > t1.lr + 15.0, - "DIF not detected: LR {} vs baseline {}", - t2.lr, - t1.lr - ); - assert!(t2.p_value < 0.01, "DIF p not significant: {}", t2.p_value); - assert!( - t1.converged && t2.converged, - "converged flag not set on a converging fit" - ); - // a starved max_iter surfaces non-convergence rather than a silently clamped lr=0 - let t_bad = andersen_lr_test(&y_dif, &group, 2, n, k, 1, 1e-9).unwrap(); - assert!( - !t_bad.converged, - "non-convergence must be surfaced, not masked" - ); -} - -/// Validation guards. -#[test] -fn cml_validates() { - let y = vec![0u8, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1]; // 3 persons x 4 items - assert!(fit_rasch_cml(&y, 3, 4, 100, 1e-9).is_ok()); - assert!(fit_rasch_cml(&y, 3, 4, 0, 1e-9).is_err()); // max_iter 0 - let mut ybad = y.clone(); - ybad[0] = 2; - assert!(fit_rasch_cml(&ybad, 3, 4, 100, 1e-9).is_err()); // non-binary - assert!(fit_rasch_cml(&y, 3, 1, 100, 1e-9).is_err()); // n_items < 2 (length also wrong) - // all-perfect / all-zero -> no informative persons - let yflat = vec![1u8; 3 * 4]; - assert!(fit_rasch_cml(&yflat, 3, 4, 100, 1e-9).is_err()); -} - -#[test] -fn cml_covers_every_validation_and_degenerate_numeric_exit() { - let y = [1u8, 0, 0, 1, 1, 0]; - assert!(fit_rasch_cml(&y, 3, 2, 10, f64::NAN).is_err()); - assert!(fit_rasch_cml(&y, 3, 2, 10, 0.0).is_err()); - assert!(fit_rasch_cml(&y[..5], 3, 2, 10, 1e-8).is_err()); - assert!(fit_rasch_cml(&[], 1, CML_MAX_ITEMS + 1, 10, 1e-8).is_err()); - assert!(fit_rasch_cml(&[], usize::MAX, 2, 10, 1e-8).is_err()); - - assert!(andersen_lr_test(&y, &[0, 1], 2, 3, 2, 10, 1e-8).is_err()); - assert!(andersen_lr_test(&y, &[0, 0, 0], 1, 3, 2, 10, 1e-8).is_err()); - assert!(andersen_lr_test(&y, &[0, 1, 2], 2, 3, 2, 10, 1e-8).is_err()); - assert!(andersen_lr_test(&y, &[0, 0, 0], 2, 3, 2, 10, 1e-8).is_err()); - - let no_information_in_group_one = [1u8, 0, 0, 1, 1, 1, 1, 1]; - assert!(andersen_lr_test( - &no_information_in_group_one, - &[0, 0, 1, 1], - 2, - 4, - 2, - 10, - 1e-8, - ) - .is_err()); - - let stalled = fit_from_stats(&[f64::NAN, 0.0], &[0.0, 1.0, 0.0], 1, 1, 1e-8).unwrap(); - assert!(!stalled.converged); - assert_eq!(stalled.n_iter, 1); - let singular = fit_from_stats(&[0.0, 0.0], &[0.0, 0.0, 0.0], 0, 1, 1e-8).unwrap(); - assert!(singular.se.iter().all(|v| v.is_nan())); -} diff --git a/tests/unit/reliability_tests.rs b/tests/unit/reliability_tests.rs deleted file mode 100644 index a10eabd47..000000000 --- a/tests/unit/reliability_tests.rs +++ /dev/null @@ -1,645 +0,0 @@ -//! Tests for Guttman lambda reliability (`crate::reliability`). -//! -//! Fixture literals come from an INDEPENDENT NumPy replication -//! (session files/guttman_fixture.py): np.corrcoef + np.linalg.inv + -//! itertools.combinations, with data generated by a NumPy mirror of the -//! crate LCG (np.uint64). Every assert reads a `GuttmanResult` field -//! returned by `guttman_lambdas` — no test-local recomputation of any -//! lambda. -//! -//! Mutation kills verified by hand-editing the implementation (each FAILED, -//! then was restored): -//! - M1 drop `.abs()` in `split_rb` -> fixture A/B lambda4/beta/mean_split. -//! - M2 drop the `p/(p-1)` factor inside lambda2's sqrt -> lambda2 pins. -//! - M3 lambda5 uses the SUM of `C_j` instead of the max -> lambda5 pins. -//! - M4 drop the SMC clamp / use `1/diag` without `1 -` -> lambda6 pins. -//! - M5 break the combination stepper (skip last combination) -> n_splits -//! pin (exhaustive counts are exact binomials). -//! -//! Known identity limit (disclosed): `lambda3 = p/(p-1) * lambda1` is exact -//! algebra, so a mutation rescaling BOTH consistently cannot be caught by -//! any data fixture; the discriminating anchor is that lambda1 and lambda3 -//! are pinned to independent NumPy literals, so only jointly consistent -//! mutations survive. Likewise `Vt = sum(R)` equals the per-split collapsed -//! total for every partition, so substituting one for the other is -//! mathematically identical and unkillable. -use super::*; - -const TOL: f64 = 1e-9; - -/// NumPy-mirrored LCG data generator (fixture input construction only; the -/// code under test never sees these helpers). -fn gen_data( - n: usize, - p: usize, - seed: u64, - loadings: &[f64], - means: &[f64], - scales: &[f64], -) -> Vec { - let mut state = seed.max(1); - let mut uniform = |state: &mut u64| -> f64 { - *state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (*state >> 11) as f64 / (1u64 << 53) as f64 - }; - let mut normal = |state: &mut u64| -> f64 { - let u1 = uniform(state).max(1e-12); - let u2 = uniform(state); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let mut out = vec![0.0_f64; n * p]; - for i in 0..n { - let f = normal(&mut state); - for j in 0..p { - let e = normal(&mut state); - out[i * p + j] = means[j] + scales[j] * (loadings[j] * f + e); - } - } - out -} - -#[test] -fn fixture_a_exhaustive_even_p() { - let data = gen_data( - 30, - 6, - 42, - &[0.9, 0.8, 0.7, 0.6, 0.5, 0.2], - &[10.0, -3.0, 5.0, 0.5, 100.0, 7.0], - &[1.0, 2.0, 0.5, 3.0, 10.0, 1.5], - ); - let g = guttman_lambdas(&data, 30, 6, 15000, 1).expect("fixture A"); - assert!((g.lambda1 - 0.41068157604336863).abs() < TOL); - assert!((g.lambda2 - 0.5561900258418755).abs() < TOL); - assert!((g.lambda3 - 0.49281789125204234).abs() < TOL); - assert!((g.lambda4 - 0.6842463861953535).abs() < TOL); - assert!((g.lambda5 - 0.5602004712206824).abs() < TOL); - assert!((g.lambda6 - 0.59150154331973).abs() < TOL); - assert!((g.beta - 0.1784553694910821).abs() < TOL); - assert!((g.mean_split - 0.4928178912520423).abs() < TOL); - assert_eq!(g.n_splits, 20); // C(6, 3) - assert!(g.exhaustive); -} - -#[test] -fn fixture_b_exhaustive_odd_p() { - let data = gen_data( - 24, - 5, - 99, - &[0.8, 0.7, 0.6, 0.5, 0.4], - &[1.0, 2.0, 3.0, 4.0, 5.0], - &[1.0, 1.0, 2.0, 1.0, 0.5], - ); - let g = guttman_lambdas(&data, 24, 5, 15000, 1).expect("fixture B"); - assert!((g.lambda1 - 0.36304984346951963).abs() < TOL); - assert!((g.lambda2 - 0.5427967072982294).abs() < TOL); - assert!((g.lambda3 - 0.45381230433689956).abs() < TOL); - assert!((g.lambda4 - 0.6863584325671727).abs() < TOL); - assert!((g.lambda5 - 0.5668547649655623).abs() < TOL); - assert!((g.lambda6 - 0.5667118066898096).abs() < TOL); - assert!((g.beta - 0.04899616716352421).abs() < TOL); - assert!((g.mean_split - 0.44545904559612853).abs() < TOL); - assert_eq!(g.n_splits, 10); // C(5, 2): m = floor(5/2), A size 2 vs B size 3 - assert!(g.exhaustive); -} - -#[test] -fn fixture_c_sampled_branch() { - let mut loadings = vec![0.7_f64; 10]; - loadings.extend(vec![0.4_f64; 10]); - let data = gen_data(60, 20, 7, &loadings, &[0.0; 20], &[1.0; 20]); - let g = guttman_lambdas(&data, 60, 20, 200, 7).expect("fixture C"); - assert!((g.lambda1 - 0.8173350703037762).abs() < TOL); - assert!((g.lambda2 - 0.8657986449939672).abs() < TOL); - assert!((g.lambda3 - 0.8603527055829222).abs() < TOL); - assert!((g.lambda4 - 0.921571734274876).abs() < TOL); - assert!((g.lambda5 - 0.8439917779688363).abs() < TOL); - assert!((g.lambda6 - 0.8931192541579849).abs() < TOL); - assert!((g.beta - 0.7974800893409002).abs() < TOL); - assert!((g.mean_split - 0.8623803334548827).abs() < TOL); - assert_eq!(g.n_splits, 200); - assert!(!g.exhaustive); -} - -#[test] -fn rejections() { - let ok = gen_data(10, 4, 3, &[0.5; 4], &[0.0; 4], &[1.0; 4]); - assert!(guttman_lambdas(&ok, 2, 4, 100, 1).is_err()); // wrong length too - assert!(guttman_lambdas(&ok[..8], 2, 4, 100, 1).is_err()); // n < 3 - assert!(guttman_lambdas(&ok[..20], 10, 2, 100, 1).is_err()); // p < 3 - assert!(guttman_lambdas(&ok, 10, 4, 0, 1).is_err()); // budget 0 - let mut nan = ok.clone(); - nan[5] = f64::NAN; - assert!(guttman_lambdas(&nan, 10, 4, 100, 1).is_err()); - let mut constant = ok.clone(); - for i in 0..10 { - constant[i * 4] = 2.5; - } - assert!(guttman_lambdas(&constant, 10, 4, 100, 1).is_err()); // zero variance -} - -#[test] -fn singular_correlation_errs_for_lambda6() { - // Duplicate columns: correlation_matrix succeeds (r = 1) but R is - // exactly singular, so the SMC inverse must Err (divergence 4: psych - // would silently Pinv; we refuse). Assert reads the crate Result. - let base = gen_data(15, 1, 11, &[0.6], &[0.0], &[1.0]); - let mut data = vec![0.0_f64; 15 * 3]; - for i in 0..15 { - data[i * 3] = base[i]; - data[i * 3 + 1] = base[i]; // exact duplicate - data[i * 3 + 2] = (i as f64).sin() + base[i]; - } - let err = guttman_lambdas(&data, 15, 3, 100, 1).unwrap_err(); - assert!(err.contains("singular"), "unexpected error: {err}"); -} - -/// >= 500-replication Monte Carlo: tau-equivalent items (equal loadings, -/// equal noise), where coefficient alpha ( = lambda3) is consistent for the -/// true reliability rho = p*l^2 / (p*l^2 + p*s^2) of the unit-weighted sum -/// score under the one-factor model. With l = 0.7, s = 1, p = 8: -/// rho = 8*0.49 / (8*0.49 + 8) ... per-item communality basis; the sum-score -/// reliability is rho_sum = (p^2 l^2 sigma_f^2) / (p^2 l^2 sigma_f^2 + -/// p s^2) = (64*0.49)/(64*0.49 + 8) = 0.796696. Asserts read only crate -/// outputs (mean of returned lambda3 across reps). -#[test] -#[ignore] -fn mc_tau_equivalent_alpha_recovery() { - let p = 8; - let reps = 500; - let mut sum_l3 = 0.0_f64; - let mut ge = 0usize; - for rep in 0..reps { - let data = gen_data( - 300, - p, - 1000 + rep as u64, - &vec![0.7; p], - &vec![0.0; p], - &vec![1.0; p], - ); - let g = guttman_lambdas(&data, 300, p, 500, 17).expect("mc rep"); - sum_l3 += g.lambda3; - if g.lambda2 >= g.lambda3 - 1e-12 { - ge += 1; - } - } - let mean_l3 = sum_l3 / reps as f64; - let rho = (64.0 * 0.49) / (64.0 * 0.49 + 8.0); // 0.796696... - assert!( - (mean_l3 - rho).abs() < 0.01, - "mean lambda3 {mean_l3} vs analytic {rho}" - ); - // Secondary (identity-adjacent, disclosed): Guttman's lambda2 >= lambda3 - // holds sample-wise; violations flag algebra mutations. - assert_eq!(ge, reps); -} - -// --- ten Berge & Zegers mu coefficients ------------------------------- -// -// Fixture literals from an independent NumPy replication of tenberge.R -// (session files/tenberge_fixture.py: np.corrcoef + explicit off-diagonal -// power sums). Every assert reads TenBergeResult fields from the crate. -// -// Mutation kills verified by hand (each FAILED, then restored): -// - M1 drop the innermost `c` factor in mu2/mu3 -> mu2/mu3 pins (~5e-4 -// shift; tolerance 1e-9 kills it). -// - M2 use s2 where s4 belongs in mu2 -> mu2 pin. -// - M3 flatten one sqrt nesting level in mu3 -> mu3 pin. -// -// Disclosed identities: mu0 == guttman lambda3 and mu1 == guttman lambda2 -// by exact algebra; both are pinned to independent NumPy literals (not to -// each other), so only jointly consistent mutations survive. S_2 over all -// cells vs 2x the upper triangle is identical for symmetric R; unkillable. - -#[test] -fn tenberge_fixture_a() { - let data = gen_data( - 30, - 6, - 42, - &[0.9, 0.8, 0.7, 0.6, 0.5, 0.2], - &[10.0, -3.0, 5.0, 0.5, 100.0, 7.0], - &[1.0, 2.0, 0.5, 3.0, 10.0, 1.5], - ); - let t = tenberge_mu(&data, 30, 6).expect("tenberge A"); - assert!((t.mu0 - 0.49281789125204223).abs() < TOL); - assert!((t.mu1 - 0.5561900258418755).abs() < TOL); - assert!((t.mu2 - 0.566334883191414).abs() < TOL); - assert!((t.mu3 - 0.5694865282091219).abs() < TOL); - // Cauchy-Schwarz series ordering, read from crate outputs. - assert!(t.mu0 <= t.mu1 && t.mu1 <= t.mu2 && t.mu2 <= t.mu3); -} - -#[test] -fn tenberge_fixture_b() { - let data = gen_data( - 24, - 5, - 99, - &[0.8, 0.7, 0.6, 0.5, 0.4], - &[1.0, 2.0, 3.0, 4.0, 5.0], - &[1.0, 1.0, 2.0, 1.0, 0.5], - ); - let t = tenberge_mu(&data, 24, 5).expect("tenberge B"); - assert!((t.mu0 - 0.45381230433689973).abs() < TOL); - assert!((t.mu1 - 0.5427967072982294).abs() < TOL); - assert!((t.mu2 - 0.554450883437996).abs() < TOL); - assert!((t.mu3 - 0.5577786174323536).abs() < TOL); - assert!(t.mu0 <= t.mu1 && t.mu1 <= t.mu2 && t.mu2 <= t.mu3); -} - -#[test] -fn tenberge_matches_guttman_identities() { - // mu0 == lambda3 and mu1 == lambda2 (exact algebra, both sides crate - // outputs from independent code paths: direct off-diag sums vs Vt - p). - let data = gen_data( - 30, - 6, - 42, - &[0.9, 0.8, 0.7, 0.6, 0.5, 0.2], - &[10.0, -3.0, 5.0, 0.5, 100.0, 7.0], - &[1.0, 2.0, 0.5, 3.0, 10.0, 1.5], - ); - let t = tenberge_mu(&data, 30, 6).expect("mu"); - let g = guttman_lambdas(&data, 30, 6, 15000, 1).expect("lambdas"); - assert!((t.mu0 - g.lambda3).abs() < 1e-12); - assert!((t.mu1 - g.lambda2).abs() < 1e-12); -} - -#[test] -fn tenberge_rejections() { - let ok = gen_data(10, 4, 3, &[0.5; 4], &[0.0; 4], &[1.0; 4]); - assert!(tenberge_mu(&ok[..8], 2, 4).is_err()); // n < 3 - assert!(tenberge_mu(&ok[..20], 10, 2).is_err()); // p < 3 - assert!(tenberge_mu(&ok[..30], 10, 4).is_err()); // wrong length - let mut nan = ok.clone(); - nan[7] = f64::INFINITY; - assert!(tenberge_mu(&nan, 10, 4).is_err()); - let mut constant = ok.clone(); - for i in 0..10 { - constant[i * 4 + 2] = -1.0; - } - assert!(tenberge_mu(&constant, 10, 4).is_err()); // zero variance -} - -/// >= 500-replication Monte Carlo: same tau-equivalent design as the -/// Guttman MC. mu0 == alpha is consistent (asymptotically unbiased) for -/// rho_sum = (p^2 l^2)/(p^2 l^2 + p s^2) = 31.36/39.36 with l=0.7, s=1, -/// p=8, n=300. Also asserts the series ordering sample-wise on crate -/// outputs across all reps. -#[test] -#[ignore] -fn mc_tenberge_alpha_recovery_and_ordering() { - let p = 8; - let reps = 500; - let mut sum_mu0 = 0.0_f64; - let mut ordered = 0usize; - for rep in 0..reps { - let data = gen_data( - 300, - p, - 2000 + rep as u64, - &vec![0.7; p], - &vec![0.0; p], - &vec![1.0; p], - ); - let t = tenberge_mu(&data, 300, p).expect("mc rep"); - sum_mu0 += t.mu0; - if t.mu0 <= t.mu1 && t.mu1 <= t.mu2 && t.mu2 <= t.mu3 { - ordered += 1; - } - } - let mean_mu0 = sum_mu0 / reps as f64; - let rho = (64.0 * 0.49) / (64.0 * 0.49 + 8.0); - assert!( - (mean_mu0 - rho).abs() < 0.01, - "mean mu0 {mean_mu0} vs analytic {rho}" - ); - assert_eq!(ordered, reps); -} - -// --------------------------------------------------------------------------- -// Cronbach alpha + Feldt (1965) CI tests. -// -// Fixture literals from an independent NumPy+scipy replication -// (feldt_fixture.py; scipy.stats.f.ppf), pinned at 1e-9. Every assert reads -// crate outputs (cronbach_alpha / feldt_alpha_ci return values). -// -// Mutation kills verified by hand (see PR evidence): -// M1 swap delta/2 <-> 1-delta/2 in feldt_alpha_ci -> fixture lower/upper FAIL -// M2 flip z orientation in f_cdf (d2/(d1x+d2)) -> fixture FAIL -// M3 df2 = n-1 instead of (n-1)(p-1) -> fixtures A/B FAIL -// (p=2 fixture passes under M3: (n-1)(p-1) == n-1 there by identity) -// M4 tr/sum inversion in cronbach_alpha -> fixture FAIL -// Equivalent mutant (survives, disclosed): dropping the symmetry branch in -// inc_beta ("if true") still converges within 300 CF iterations at these dfs; -// the branch is convergence acceleration, not correctness, for this domain. -// Known limit: bisection iteration-count mutations (200 -> 50) survive at -// 1e-9 (2^-50 interval still beats the tolerance); the scipy literals are the -// discriminating anchor for everything coarser. -// --------------------------------------------------------------------------- - -const ATOL: f64 = 1e-9; - -#[test] -fn alpha_fixture_a_unequal_variances() { - let d = gen_data( - 30, - 6, - 42, - &[0.9, 0.8, 0.7, 0.6, 0.5, 0.2], - &[10.0, -3.0, 5.0, 0.5, 100.0, 7.0], - &[1.0, 2.0, 0.5, 3.0, 10.0, 1.5], - ); - let a = cronbach_alpha(&d, 30, 6).unwrap(); - assert!((a - 0.26300193786625514).abs() < ATOL, "alpha {a}"); - let ci = feldt_alpha_ci(a, 30, 6, 0.95).unwrap(); - assert!( - (ci.lower - -0.23710034474579156).abs() < ATOL, - "lower {}", - ci.lower - ); - assert!( - (ci.upper - 0.6065060295943752).abs() < ATOL, - "upper {}", - ci.upper - ); - assert!( - (ci.r_bar - 0.05613713592263862).abs() < ATOL, - "r_bar {}", - ci.r_bar - ); - assert_eq!(ci.df1, 29.0); - assert_eq!(ci.df2, 145.0); - // Narrower level nests strictly inside (kills tail-swap mutations too). - let ci90 = feldt_alpha_ci(a, 30, 6, 0.90).unwrap(); - assert!( - (ci90.lower - -0.13926063775356812).abs() < ATOL, - "lower90 {}", - ci90.lower - ); - assert!( - (ci90.upper - 0.563407303756001).abs() < ATOL, - "upper90 {}", - ci90.upper - ); - assert!(ci.lower < ci90.lower && ci90.upper < ci.upper); - assert!(ci90.lower < a && a < ci90.upper); -} - -#[test] -fn alpha_fixture_b() { - let d = gen_data( - 24, - 5, - 99, - &[0.7, 0.6, 0.5, 0.4, 0.3], - &[0.0, 1.0, 2.0, 3.0, 4.0], - &[1.0, 1.0, 1.0, 1.0, 1.0], - ); - let a = cronbach_alpha(&d, 24, 5).unwrap(); - assert!((a - 0.3580775296654029).abs() < ATOL, "alpha {a}"); - let ci = feldt_alpha_ci(a, 24, 5, 0.95).unwrap(); - assert!( - (ci.lower - -0.16244546058178866).abs() < ATOL, - "lower {}", - ci.lower - ); - assert!( - (ci.upper - 0.6888373608056122).abs() < ATOL, - "upper {}", - ci.upper - ); - assert!( - (ci.r_bar - 0.10036677558170307).abs() < ATOL, - "r_bar {}", - ci.r_bar - ); -} - -#[test] -fn alpha_fixture_negative_p2() { - // Anti-correlated pair -> negative alpha; p=2 boundary; CI must not clamp. - let mut d = gen_data(20, 2, 7, &[0.8, 0.8], &[0.0, 0.0], &[1.0, 1.0]); - for row in d.chunks_exact_mut(2) { - row[1] = -row[1]; - } - let a = cronbach_alpha(&d, 20, 2).unwrap(); - assert!((a - -1.2239392688014856).abs() < ATOL, "alpha {a}"); - let ci = feldt_alpha_ci(a, 20, 2, 0.95).unwrap(); - assert!( - (ci.lower - -4.618673441887094).abs() < ATOL, - "lower {}", - ci.lower - ); - assert!( - (ci.upper - 0.11973779532270723).abs() < ATOL, - "upper {}", - ci.upper - ); - assert!( - (ci.r_bar - -0.3796409196183434).abs() < ATOL, - "r_bar {}", - ci.r_bar - ); - assert_eq!(ci.df2, 19.0); -} - -#[test] -fn alpha_zscored_equals_lambda3() { - // Covariance of z-scored columns is the correlation matrix, so alpha on - // z-scores must match guttman lambda3 on the raw data (tolerance 1e-10, - // not exact: means/sds are recomputed in floating point). - let d = gen_data( - 30, - 6, - 42, - &[0.9, 0.8, 0.7, 0.6, 0.5, 0.2], - &[10.0, -3.0, 5.0, 0.5, 100.0, 7.0], - &[1.0, 2.0, 0.5, 3.0, 10.0, 1.5], - ); - let g = guttman_lambdas(&d, 30, 6, 15000, 1).unwrap(); - let (n, p) = (30usize, 6usize); - let mut z = d.clone(); - for j in 0..p { - let mean = (0..n).map(|i| d[i * p + j]).sum::() / n as f64; - let var = (0..n).map(|i| (d[i * p + j] - mean).powi(2)).sum::() / (n as f64 - 1.0); - let sd = var.sqrt(); - for i in 0..n { - z[i * p + j] = (d[i * p + j] - mean) / sd; - } - } - let a = cronbach_alpha(&z, n, p).unwrap(); - assert!( - (a - g.lambda3).abs() < 1e-10, - "alpha {a} vs lambda3 {}", - g.lambda3 - ); -} - -#[test] -fn feldt_ci_extreme_level_endpoints() { - // Regression (impl-review D1): level so close to 1 that the quantile - // probability rounds to 1.0 must give an infinite bound (as qf/scipy), - // not a finite bisection cap. Asserts read crate outputs. - let level = 1.0 - f64::EPSILON / 2.0; // nextdown(1.0), passes level < 1 - let ci = feldt_alpha_ci(0.8, 30, 6, level).unwrap(); - assert_eq!(ci.lower, f64::NEG_INFINITY, "lower {}", ci.lower); - // delta/2 is tiny but nonzero, so the upper bound is finite and < 1. - assert!(ci.upper.is_finite() && ci.upper < 1.0, "upper {}", ci.upper); -} - -#[test] -fn alpha_rejections() { - let d = gen_data( - 10, - 3, - 5, - &[0.5, 0.5, 0.5], - &[0.0, 0.0, 0.0], - &[1.0, 1.0, 1.0], - ); - assert!(cronbach_alpha(&d[..6], 2, 3).is_err()); - assert!(cronbach_alpha(&d[..10], 5, 2).is_ok()); - assert!(cronbach_alpha(&d, 10, 4).is_err()); // length mismatch - let mut bad = d.clone(); - bad[0] = f64::NAN; - assert!(cronbach_alpha(&bad, 10, 3).is_err()); - let mut flat = d.clone(); - for row in flat.chunks_exact_mut(3) { - row[0] = 1.0; // zero-variance item - } - assert!(cronbach_alpha(&flat, 10, 3).is_err()); - assert!(feldt_alpha_ci(1.0, 30, 6, 0.95).is_err()); - assert!(feldt_alpha_ci(f64::NAN, 30, 6, 0.95).is_err()); - assert!(feldt_alpha_ci(0.8, 2, 6, 0.95).is_err()); - assert!(feldt_alpha_ci(0.8, 30, 1, 0.95).is_err()); - assert!(feldt_alpha_ci(0.8, 30, 6, 0.0).is_err()); - assert!(feldt_alpha_ci(0.8, 30, 6, 1.0).is_err()); -} - -#[test] -#[ignore = "monte carlo: 500 reps"] -fn alpha_feldt_ci_coverage_mc() { - // Tau-equivalent model: p=8, loading 0.7, scale 1 -> true reliability of - // the sum score rho = 31.36/39.36 (see guttman MC derivation). Empirical - // 95% CI coverage over 500 reps should land in [0.925, 0.975]; every rep - // reads crate outputs. - let rho = 31.36 / 39.36; - let reps: u64 = 500; - let mut covered = 0; - for r in 0..reps { - let d = gen_data(300, 8, 1000 + r, &[0.7; 8], &[0.0; 8], &[1.0; 8]); - let a = cronbach_alpha(&d, 300, 8).unwrap(); - let ci = feldt_alpha_ci(a, 300, 8, 0.95).unwrap(); - if ci.lower <= rho && rho <= ci.upper { - covered += 1; - } - } - let cov = covered as f64 / reps as f64; - assert!((0.925..=0.975).contains(&cov), "coverage {cov}"); -} - -// --------------------------------------------------------------------------- -// separation_reliability (eRm SepRel transcription + hand-derived G) -// -// Mutation-kill audit (all asserts read crate outputs): -// - M1 EXECUTED: swap numerator to (mse - ssd) => fixture sep_rel FAILs. -// - M2 EXECUTED: population variance (n denominator) => ssd + sep_rel FAIL. -// - M3 EXECUTED: drop the square on se in mse => mse + sep_rel FAIL. -// - Disclosed non-discriminating identity: G^2 = R/(1-R) holds for ANY -// shared (ssd, mse), so it cannot catch shared-formula bugs; the pinned -// numpy fixture values (ssd, mse, R, G all asserted) are the anchors. - -/// Pinned numpy oracle (ddof=1 var; mean se^2), 12 decimals. -/// Asserts read: crate ssd, mse, sep_rel, sep_index. -#[test] -fn seprel_numpy_oracle_fixture() { - let x = [-1.8, -0.6, 0.15, 0.9, 2.1, -0.35, 1.4]; - let se = [0.45, 0.38, 0.35, 0.37, 0.52, 0.36, 0.41]; - let r = separation_reliability(&x, &se).unwrap(); - assert!((r.ssd - 1.743690476190).abs() < 1e-10, "ssd = {}", r.ssd); - assert!((r.mse - 0.167771428571).abs() < 1e-10, "mse = {}", r.mse); - assert!( - (r.sep_rel - 0.903783709975).abs() < 1e-10, - "sep_rel = {}", - r.sep_rel - ); - assert!( - (r.sep_index - 3.064841016127).abs() < 1e-10, - "sep_index = {}", - r.sep_index - ); -} - -/// Negative-reliability path (mse > ssd): sep_rel negative and unclamped -/// (eRm does not clamp), G NaN. Asserts read crate outputs. -#[test] -fn seprel_negative_unclamped_and_g_nan() { - let x = [0.0, 0.1, -0.1, 0.05]; - let se = [1.0, 1.0, 1.0, 1.0]; - let r = separation_reliability(&x, &se).unwrap(); - assert!(r.sep_rel < 0.0, "sep_rel = {}", r.sep_rel); - assert!(r.sep_index.is_nan()); - // Degenerate ssd = 0 -> sep_rel NaN (documented crate guard). - let r0 = separation_reliability(&[1.5, 1.5, 1.5], &[0.2, 0.2, 0.2]).unwrap(); - assert!(r0.sep_rel.is_nan()); -} - -/// Error contract. Asserts read crate Err values. -#[test] -fn seprel_error_paths() { - assert!(separation_reliability(&[1.0], &[0.1]).is_err()); - assert!(separation_reliability(&[1.0, 2.0], &[0.1]).is_err()); - assert!(separation_reliability(&[1.0, f64::NAN], &[0.1, 0.1]).is_err()); - assert!(separation_reliability(&[1.0, 2.0], &[0.1, -0.1]).is_err()); - assert!(separation_reliability(&[1.0, 2.0], &[0.1, f64::INFINITY]).is_err()); -} - -/// 500-rep MC (spec rev 2 design): x = theta + N(0, se) noise with -/// theta ~ N(0, 1), constant se = 0.5, n = 1000 per rep. Population -/// R = 1 / (1 + 0.25) = 0.8. Asserts read crate sep_rel across reps. -#[test] -#[ignore] -fn seprel_mc_recovery_500() { - let mut state: u64 = 0x9E3779B97F4A7C15; - let mut next_u = || { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((state >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut next_normal = || { - let (u1, u2): (f64, f64) = (next_u().max(1e-12), next_u()); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - }; - let n = 1000usize; - let se_true = 0.5f64; - let reps = 500; - let mut sum_r = 0.0; - for _ in 0..reps { - let x: Vec = (0..n) - .map(|_| next_normal() + se_true * next_normal()) - .collect(); - let se = vec![se_true; n]; - sum_r += separation_reliability(&x, &se).unwrap().sep_rel; - } - let mean_r = sum_r / reps as f64; - let pop_r = 1.0 / (1.0 + se_true * se_true); - assert!( - (mean_r - pop_r).abs() < 0.01, - "mean sep_rel = {mean_r} vs population {pop_r}" - ); -} - -/// Impl-review regression: finite-but-huge inputs must Err, not leak -/// non-finite ssd/mse. Asserts read crate Err values. -#[test] -fn seprel_overflow_guard() { - assert!(separation_reliability(&[1e308, 1e308], &[0.1, 0.1]).is_err()); - assert!(separation_reliability(&[0.0, 1.0], &[1e200, 1e200]).is_err()); -} diff --git a/tests/unit/rsm_tests.rs b/tests/unit/rsm_tests.rs deleted file mode 100644 index b404f4761..000000000 --- a/tests/unit/rsm_tests.rs +++ /dev/null @@ -1,297 +0,0 @@ -use super::*; - -struct Lcg(u64); -impl Lcg { - fn f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.f64().max(1e-12); - let u2 = self.f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } -} - -fn rmse(a: &[f64], b: &[f64]) -> f64 { - (a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64).sqrt() -} -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let mx = x.iter().sum::() / n; - let my = y.iter().sum::() / n; - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for i in 0..x.len() { - sxy += (x[i] - mx) * (y[i] - my); - sxx += (x[i] - mx).powi(2); - syy += (y[i] - my).powi(2); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -fn log_sigmoid(x: f64) -> f64 { - if x >= 0.0 { - -(-x).exp().ln_1p() - } else { - x - x.exp().ln_1p() - } -} - -/// Draw an RSM category for ability `theta`, location `delta`, thresholds `tau`. -fn draw_rsm(theta: f64, delta: f64, tau: &[f64], u: f64) -> usize { - let lp = rsm_logprobs(theta, delta, tau); - let mut cum = 0.0; - for (k, l) in lp.iter().enumerate() { - cum += l.exp(); - if u < cum { - return k; - } - } - lp.len() - 1 -} - -#[test] -fn rsm_k2_reduces_to_rasch() { - // K=2: single threshold, centered to 0, so P(X=1) = sigmoid(theta - delta). - let tau = [0.0f64]; - for ti in -20..=20 { - for di in -10..=10 { - let theta = ti as f64 * 0.3; - let delta = di as f64 * 0.4; - let lp = rsm_logprobs(theta, delta, &tau); - assert!((lp[0] - log_sigmoid(-(theta - delta))).abs() < 1e-12); - assert!((lp[1] - log_sigmoid(theta - delta)).abs() < 1e-12); - } - } -} - -#[test] -fn rsm_probs_sum_to_one() { - let tau = [0.7f64, -0.2, -0.5]; // K=4 - for ti in -20..=20 { - let theta = ti as f64 * 0.3; - let s: f64 = rsm_logprobs(theta, 0.3, &tau).iter().map(|l| l.exp()).sum(); - assert!((s - 1.0).abs() < 1e-12, "sum {s}"); - } -} - -#[test] -fn rsm_recovers_params() { - let (n_items, n_cat, n) = (12usize, 5usize, 2500usize); - let delta_true: Vec = (0..n_items).map(|i| -1.2 + 0.2 * i as f64).collect(); - let tau_true = vec![0.9f64, 0.2, -0.3, -0.8]; // sum = 0 - let mut rng = Lcg(1978); - let mut y = vec![0usize; n * n_items]; - let mut thetas = vec![0.0f64; n]; - for p in 0..n { - let theta = rng.normal(); - thetas[p] = theta; - for i in 0..n_items { - y[p * n_items + i] = draw_rsm(theta, delta_true[i], &tau_true, rng.f64()); - } - } - let res = fit_rsm(&y, None, n, n_items, n_cat, 41, 500, 1e-7).unwrap(); - assert!(res.converged); - // ECM ascends the marginal loglik monotonically (backtracked M-steps). - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "loglik decreased {} -> {}", w[0], w[1]); - } - assert_eq!(res.n_parameters, n_items + n_cat - 2); - assert!( - (res.thresholds.iter().sum::()).abs() < 1e-6, - "tau not centered" - ); - assert!( - rmse(&res.item_location, &delta_true) < 0.15, - "delta RMSE {}", - rmse(&res.item_location, &delta_true) - ); - assert!( - rmse(&res.thresholds, &tau_true) < 0.12, - "tau RMSE {}", - rmse(&res.thresholds, &tau_true) - ); - assert!( - corr(&res.theta, &thetas) > 0.85, - "theta corr {}", - corr(&res.theta, &thetas) - ); -} - -/// Data generated with NON-centered thresholds must be recovered as the centered -/// equivalent (tau - mean, delta + mean). This exercises the re-centering sign: -/// a wrong sign shifts the model and breaks recovery. -#[test] -fn rsm_centers_noncentered_truth() { - let (n_items, n_cat, n) = (10usize, 4usize, 2500usize); - let delta_gen: Vec = (0..n_items).map(|i| -0.8 + 0.15 * i as f64).collect(); - let tau_gen = vec![1.0f64, 0.5, -0.3]; // sum = 1.2, NOT centered - let shift = tau_gen.iter().sum::() / (n_cat - 1) as f64; // 0.4 - let tau_expect: Vec = tau_gen.iter().map(|t| t - shift).collect(); - let delta_expect: Vec = delta_gen.iter().map(|d| d + shift).collect(); - let mut rng = Lcg(4242); - let mut y = vec![0usize; n * n_items]; - for p in 0..n { - let theta = rng.normal(); - for i in 0..n_items { - y[p * n_items + i] = draw_rsm(theta, delta_gen[i], &tau_gen, rng.f64()); - } - } - let res = fit_rsm(&y, None, n, n_items, n_cat, 41, 500, 1e-7).unwrap(); - assert!(res.converged); - assert!((res.thresholds.iter().sum::()).abs() < 1e-6); - assert!( - rmse(&res.thresholds, &tau_expect) < 0.12, - "tau RMSE {}", - rmse(&res.thresholds, &tau_expect) - ); - assert!( - rmse(&res.item_location, &delta_expect) < 0.15, - "delta RMSE {}", - rmse(&res.item_location, &delta_expect) - ); -} - -#[test] -fn rsm_handles_missing_data() { - let (n_items, n_cat, n) = (8usize, 4usize, 800usize); - let delta_true = vec![-0.5f64, 0.0, 0.5, -0.3, 0.3, -0.6, 0.6, 0.1]; - let tau_true = vec![0.5f64, 0.0, -0.5]; - let mut rng = Lcg(55); - let mut y = vec![0usize; n * n_items]; - let mut observed = vec![true; n * n_items]; - for p in 0..n { - let theta = rng.normal(); - for i in 0..n_items { - y[p * n_items + i] = draw_rsm(theta, delta_true[i], &tau_true, rng.f64()); - if rng.f64() < 0.15 { - observed[p * n_items + i] = false; - } - } - } - let res = fit_rsm(&y, Some(&observed), n, n_items, n_cat, 21, 400, 1e-6).unwrap(); - assert!(res.loglik_trace.iter().all(|v| v.is_finite())); -} - -#[test] -fn rsm_validate_rejects_malformed() { - assert!(fit_rsm(&[0, 1], None, 1, 2, 1, 21, 10, 1e-6).is_err()); // n_cat<2 - assert!(fit_rsm(&[0, 1, 2], None, 1, 2, 3, 21, 10, 1e-6).is_err()); // wrong len - assert!(fit_rsm(&[0, 9], None, 1, 2, 3, 21, 10, 1e-6).is_err()); // category out of range - assert!(fit_rsm(&[0, 1, 0, 1], None, 2, 2, 2, 99, 10, 1e-6).is_err()); // bad q - assert!(fit_rsm(&[], None, 0, 1, 2, 21, 10, 1e-6).is_err()); // no persons - assert!(fit_rsm(&[], None, 1, 0, 2, 21, 10, 1e-6).is_err()); // no items - assert!(fit_rsm(&[0, 1], None, 1, 2, 2, 21, 0, 1e-6).is_err()); // no iterations - assert!(fit_rsm(&[0, 1], None, 1, 2, RSM_MAX_CAT + 1, 21, 10, 1e-6).is_err()); - assert!(fit_rsm(&[0, 1], None, 1, 2, 2, 21, RSM_MAX_ITER + 1, 1e-6).is_err()); - assert!(fit_rsm(&[0, 1], None, 1, 2, 2, 21, 10, f64::INFINITY).is_err()); - let observed = [true, false, true, false]; - assert!(fit_rsm(&[0, 0, 1, 0], Some(&observed), 2, 2, 2, 21, 10, 1e-6).is_err()); - assert!(fit_rsm(&[0, 1], Some(&[true]), 1, 2, 2, 21, 10, 1e-6).is_err()); - - let limited = fit_rsm(&[0, 1, 1, 0], None, 2, 2, 2, 21, 1, 1e-14).unwrap(); - assert!(!limited.converged); - assert_eq!(limited.n_iter, 1); - assert_eq!(limited.loglik_trace.len(), 2); - - let zero_gradient = tau_gradient(&[0.0], &[0.0], &[vec![0.0; 2]], &[0.0], 1, 2); - assert_eq!(zero_gradient, vec![0.0]); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_rsm_recovery_500() { - let (n_items, n_cat, n, reps) = (12usize, 5usize, 1000usize, 500usize); - let delta_true: Vec = (0..n_items).map(|i| -1.1 + 0.2 * i as f64).collect(); - let tau_true = vec![0.9f64, 0.2, -0.3, -0.8]; - for &skew in [false, true].iter() { - let (mut rd, mut rt, mut bd, mut bt, mut nconv, mut tcorr) = - (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0usize, 0.0f64); - let (mut max_n_iter, mut worst_stop_ratio, mut worst_delta, mut worst_tolerance) = - (0usize, 0.0f64, 0.0f64, 0.0f64); - for rep in 0..reps { - let mut rng = Lcg(0xB5297A4Du64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0x9E3779B97F4A7C15)); - let mut y = vec![0usize; n * n_items]; - let mut thetas = vec![0.0f64; n]; - for p in 0..n { - let theta = if skew { - let mut c = 0.0; - for _ in 0..3 { - let g = rng.normal(); - c += g * g; - } - (c - 3.0) / (6.0_f64).sqrt() - } else { - rng.normal() - }; - thetas[p] = theta; - for i in 0..n_items { - y[p * n_items + i] = draw_rsm(theta, delta_true[i], &tau_true, rng.f64()); - } - } - let res = fit_rsm(&y, None, n, n_items, n_cat, 41, 500, 1e-6).unwrap(); - assert!( - res.loglik_trace.iter().all(|value| value.is_finite()), - "non-finite likelihood trace at rep={rep} skew={skew}" - ); - for window in res.loglik_trace.windows(2) { - assert!( - window[1] >= window[0] - 1e-6, - "likelihood decreased {} -> {} at rep={rep} skew={skew}", - window[0], - window[1] - ); - } - assert!( - res.converged, - "RSM did not converge at rep={rep} skew={skew}: n_iter={}", - res.n_iter - ); - assert!(res.n_iter <= 500); - let trace_len = res.loglik_trace.len(); - let final_delta = - (res.loglik_trace[trace_len - 1] - res.loglik_trace[trace_len - 2]).abs(); - let stopping_tolerance = 1e-6 * (1.0 + res.loglik_trace[trace_len - 2].abs()); - assert!( - final_delta < stopping_tolerance, - "stopping metric {final_delta} did not meet tolerance {stopping_tolerance} at rep={rep} skew={skew}" - ); - let stop_ratio = final_delta / stopping_tolerance; - if stop_ratio > worst_stop_ratio { - worst_stop_ratio = stop_ratio; - worst_delta = final_delta; - worst_tolerance = stopping_tolerance; - } - max_n_iter = max_n_iter.max(res.n_iter); - if res.converged { - nconv += 1; - } - rd += rmse(&res.item_location, &delta_true) / reps as f64; - rt += rmse(&res.thresholds, &tau_true) / reps as f64; - bd += (res.item_location.iter().sum::() - delta_true.iter().sum::()) - / n_items as f64 - / reps as f64; - bt += (res.thresholds.iter().sum::()) / reps as f64; - tcorr += corr(&res.theta, &thetas) / reps as f64; - } - println!( - "[RSM MC skew={skew}] reps={reps} conv={:.2} max_iter={max_n_iter}/500 \ - worst_stop={worst_delta:.6}/{worst_tolerance:.6} ratio={worst_stop_ratio:.3} \ - RMSE(delta)={:.3} RMSE(tau)={:.3} bias(delta)={:.3} sum(tau)={:.4} theta-corr={:.3}", - nconv as f64 / reps as f64, - rd, - rt, - bd, - bt, - tcorr - ); - assert_eq!(nconv, reps, "not every RSM fit converged for skew={skew}"); - assert!(rd < 0.12, "RMSE(delta) {rd} skew={skew}"); - assert!(rt < 0.1, "RMSE(tau) {rt} skew={skew}"); - assert!(tcorr > 0.85, "theta corr {tcorr} skew={skew}"); - } -} diff --git a/tests/unit/rt_joint_tests.rs b/tests/unit/rt_joint_tests.rs deleted file mode 100644 index 463c8b026..000000000 --- a/tests/unit/rt_joint_tests.rs +++ /dev/null @@ -1,620 +0,0 @@ -use super::*; - -fn lcg(seed: u64) -> impl FnMut() -> f64 { - let mut st = seed.max(1); - move || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - } -} -fn normal(u: &mut impl FnMut() -> f64) -> f64 { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() -} -#[test] -fn fixed_variance_m_step_maximizes_conditional_q() { - let (s11, s12, s22, sigma_tau2) = (0.8_f64, 0.1_f64, 0.2_f64, 0.09_f64); - let got = maximize_fixed_variance_covariance(s11, s12, s22, sigma_tau2, 0.999); - let naive = s12 / s11; - let got_q = covariance_q(got, s11, s12, s22, sigma_tau2); - let naive_q = covariance_q(naive, s11, s12, s22, sigma_tau2); - assert!( - got_q > naive_q + 1e-6, - "fixed-variance optimum {got_q} must beat S12/S11 {naive_q}" - ); - - let h = 1e-6; - let numeric_score = (covariance_q(got + h, s11, s12, s22, sigma_tau2) - - covariance_q(got - h, s11, s12, s22, sigma_tau2)) - / (2.0 * h); - assert!( - numeric_score.abs() < 1e-6, - "fixed-variance score {numeric_score}" - ); -} - -#[test] -fn covariance_helpers_cover_invalid_and_all_cubic_root_shapes() { - assert_eq!(covariance_q(1.0, 1.0, 0.0, 1.0, 1.0), f64::NEG_INFINITY); - let one = cubic_real_roots(0.0, 1.0, 1.0); - let repeated = cubic_real_roots(0.0, -3.0, 2.0); - let three = cubic_real_roots(0.0, -3.0, 0.0); - assert_eq!(one.len(), 1); - assert_eq!(repeated.len(), 2); - assert_eq!(three.len(), 3); - for (qa, qb, qc, roots) in [ - (0.0, 1.0, 1.0, &one), - (0.0, -3.0, 2.0, &repeated), - (0.0, -3.0, 0.0, &three), - ] { - assert!(roots - .iter() - .all(|&root| (root * root * root + qa * root * root + qb * root + qc).abs() < 1e-10)); - } - assert_eq!( - maximize_fixed_variance_covariance(1.0, 0.0, 1.0, -1.0, 0.9), - 0.0 - ); - assert!(joint_summary_is_finite(0.0, 1.0, &[0.0], &[0.0])); - assert!(!joint_summary_is_finite(f64::NAN, 1.0, &[0.0], &[0.0])); - assert!(!joint_summary_is_finite(0.0, f64::INFINITY, &[0.0], &[0.0])); - assert!(!joint_summary_is_finite(0.0, 1.0, &[f64::NAN], &[0.0])); - assert!(ensure_joint_summary_is_finite(0.0, 1.0, &[0.0], &[0.0]).is_ok()); - assert!(ensure_joint_summary_is_finite(f64::NAN, 1.0, &[0.0], &[0.0]).is_err()); -} - -#[test] -fn rejects_every_shape_data_and_control_boundary() { - let response = [1.0]; - let time = [2.0]; - let observed = [true]; - let one = [1.0]; - let zero = [0.0]; - let base = SpeedAccuracyConfig::default(); - let call = |responses: &[f64], - times: &[f64], - mask: Option<&[bool]>, - a: &[f64], - b: &[f64], - alpha: &[f64], - beta: &[f64], - n_persons: usize, - n_items: usize, - config: SpeedAccuracyConfig| { - fit_speed_accuracy_covariance( - responses, times, mask, a, b, alpha, beta, n_persons, n_items, config, - ) - }; - - assert!(call(&[], &[], None, &[], &[], &[], &[], 0, 1, base).is_err()); - assert!(call(&[], &[], None, &[], &[], &[], &[], usize::MAX, 2, base).is_err()); - assert!(call(&[], &time, None, &one, &zero, &one, &zero, 1, 1, base).is_err()); - assert!(call(&response, &time, None, &[], &zero, &one, &zero, 1, 1, base).is_err()); - assert!(call( - &response, - &time, - Some(&[]), - &one, - &zero, - &one, - &zero, - 1, - 1, - base - ) - .is_err()); - assert!(call( - &response, - &time, - None, - &one, - &zero, - &one, - &zero, - 1, - 1, - SpeedAccuracyConfig { - max_iter: RT_JOINT_MAX_ITER + 1, - ..base - }, - ) - .is_err()); - assert!(call( - &response, - &time, - Some(&observed), - &one, - &zero, - &one, - &zero, - 1, - 1, - SpeedAccuracyConfig { - max_iter: 0, - ..base - }, - ) - .is_err()); - assert!(call( - &response, - &time, - None, - &one, - &zero, - &one, - &zero, - 1, - 1, - SpeedAccuracyConfig { - rho_floor: 1.0, - ..base - }, - ) - .is_err()); - assert!(call( - &response, - &time, - None, - &one, - &zero, - &one, - &zero, - 1, - 1, - SpeedAccuracyConfig { - sigma_floor: 0.0, - ..base - }, - ) - .is_err()); - assert!(call( - &response, - &time, - None, - &[f64::NAN], - &zero, - &one, - &zero, - 1, - 1, - base - ) - .is_err()); - assert!(call( - &response, - &time, - None, - &one, - &zero, - &one, - &zero, - 1, - 1, - SpeedAccuracyConfig { q: 9, ..base } - ) - .is_err()); - assert!(call(&[2.0], &time, None, &one, &zero, &one, &zero, 1, 1, base).is_err()); - assert!(call( - &response, - &[0.0], - None, - &one, - &zero, - &one, - &zero, - 1, - 1, - base - ) - .is_err()); - assert!(call( - &response, - &[f64::MIN_POSITIVE], - None, - &one, - &zero, - &[1e154], - &zero, - 1, - 1, - SpeedAccuracyConfig { - q: 7, - max_iter: 1, - ..base - }, - ) - .is_err()); -} - -#[test] -fn fixed_sigma_full_fit_executes_the_constrained_m_step() { - let fit = fit_speed_accuracy_covariance( - &[1.0, 0.0, 0.0, 1.0], - &[1.2, 0.8, 1.1, 0.9], - None, - &[1.0, 0.8], - &[0.0, 0.1], - &[1.0, 1.2], - &[0.0, 0.0], - 2, - 2, - SpeedAccuracyConfig { - q: 7, - max_iter: 2, - fix_sigma_tau: Some(0.3), - ..SpeedAccuracyConfig::default() - }, - ) - .unwrap(); - assert_eq!(fit.sigma_tau, 0.3); - assert_eq!(fit.n_iter, 2); - assert!(fit.converged); - assert_eq!(fit.termination_reason, "converged"); - assert!(fit.final_loglik_change <= SpeedAccuracyConfig::default().tol); -} - -#[test] -fn rejects_invalid_item_parameters_and_controls() { - let responses = [1.0]; - let times = [2.0]; - let a = [1.0]; - let b = [0.0]; - let beta = [1.0]; - let err = fit_speed_accuracy_covariance( - &responses, - ×, - None, - &a, - &b, - &[0.0], - &beta, - 1, - 1, - SpeedAccuracyConfig::default(), - ) - .unwrap_err(); - assert!(err.contains("alpha")); - - let err = fit_speed_accuracy_covariance( - &responses, - ×, - None, - &a, - &b, - &[1.0], - &beta, - 1, - 1, - SpeedAccuracyConfig { - tol: f64::NAN, - ..SpeedAccuracyConfig::default() - }, - ) - .unwrap_err(); - assert!(err.contains("tol")); - - let err = fit_speed_accuracy_covariance( - &responses, - ×, - None, - &a, - &b, - &[1.0], - &beta, - 1, - 1, - SpeedAccuracyConfig { - fix_sigma_tau: Some(1e308), - ..SpeedAccuracyConfig::default() - }, - ) - .unwrap_err(); - assert!(err.contains("fix_sigma_tau")); -} - -#[test] -fn rejects_unidentified_or_nonfinite_joint_calibrations() { - let responses = [1.0]; - let times = [2.0]; - let observed = [false]; - let err = fit_speed_accuracy_covariance( - &responses, - ×, - Some(&observed), - &[1.0], - &[0.0], - &[1.0], - &[1.0], - 1, - 1, - SpeedAccuracyConfig::default(), - ) - .unwrap_err(); - assert!(err.contains("observed")); - - let err = fit_speed_accuracy_covariance( - &responses, - ×, - None, - &[0.0], - &[0.0], - &[1.0], - &[1.0], - 1, - 1, - SpeedAccuracyConfig::default(), - ) - .unwrap_err(); - assert!(err.contains("discrimination")); - - let err = fit_speed_accuracy_covariance( - &responses, - ×, - None, - &[1.0], - &[0.0], - &[1e308], - &[1.0], - 1, - 1, - SpeedAccuracyConfig::default(), - ) - .unwrap_err(); - assert!(err.contains("non-finite")); -} - -// Anchor A: at rho=0 the 2-D grid log-likelihood factorizes into the sum of the -// two 1-D grid log-likelihoods (certifies the Cholesky map, tensor weights, and -// logsumexp wiring exactly). -#[test] -fn joint_rho0_factorizes() { - let (nodes, weights) = gh_rule(21).unwrap(); - let q = nodes.len(); - let lnw: Vec = weights.iter().map(|w| w.ln()).collect(); - // one 3-item person: accuracy la[a], and RT stats - let a = [1.0_f64, 1.3, 0.8]; - let b = [0.2_f64, -0.4, 0.1]; - let alpha = [1.5_f64, 2.0, 1.1]; - let beta = [4.0_f64, 3.6, 4.2]; - let u = [1.0_f64, 0.0, 1.0]; - let y = [3.8_f64, 3.9, 4.5]; - let sig = 0.35_f64; - let mut la = vec![0.0_f64; q]; - let (mut aj, mut bj, mut cj, mut kj) = (0.0, 0.0, 0.0, 0.0); - let ln2pi = (2.0 * std::f64::consts::PI).ln(); - for i in 0..3 { - for (ai, &z) in nodes.iter().enumerate() { - let eta = a[i] * z + b[i]; - la[ai] += if u[i] > 0.5 { - log_sigmoid(eta) - } else { - log_sigmoid(-eta) - }; - } - let a2 = alpha[i] * alpha[i]; - let d = y[i] - beta[i]; - aj += a2; - bj += a2 * d; - cj += a2 * d * d; - kj += alpha[i].ln() - 0.5 * ln2pi; - } - // 2-D logsumexp at rho=0 (c=0, l22=sigma_tau) - let mut mx = f64::NEG_INFINITY; - let mut grid = vec![0.0_f64; q * q]; - for ai in 0..q { - for bi in 0..q { - let tau = sig * nodes[bi]; - let lt = kj - 0.5 * (aj * tau * tau + 2.0 * bj * tau + cj); - let v = lnw[ai] + la[ai] + lnw[bi] + lt; - grid[ai * q + bi] = v; - if v > mx { - mx = v; - } - } - } - let joint = mx + grid.iter().map(|&v| (v - mx).exp()).sum::().ln(); - // two 1-D logsumexps - let mxa = (0..q) - .map(|ai| lnw[ai] + la[ai]) - .fold(f64::NEG_INFINITY, f64::max); - let la1 = mxa - + (0..q) - .map(|ai| (lnw[ai] + la[ai] - mxa).exp()) - .sum::() - .ln(); - let ltv: Vec = (0..q) - .map(|bi| { - let tau = sig * nodes[bi]; - lnw[bi] + kj - 0.5 * (aj * tau * tau + 2.0 * bj * tau + cj) - }) - .collect(); - let mxb = ltv.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let lt1 = mxb + ltv.iter().map(|&v| (v - mxb).exp()).sum::().ln(); - assert!( - (joint - (la1 + lt1)).abs() < 1e-10, - "rho=0 factorization: {joint} vs {}", - la1 + lt1 - ); -} - -// Anchor B/D + recovery: simulate under a known Sigma_P and recover (rho, -// sigma_tau) with the item banks frozen. -fn sim_and_fit(seed: u64, n: usize, rho_true: f64, sig_true: f64) -> SpeedAccuracyFit { - let ni = 20usize; - let a: Vec = (0..ni).map(|i| 0.9 + 0.6 * (i % 3) as f64 / 2.0).collect(); - let b: Vec = (0..ni) - .map(|i| -1.5 + 3.0 * i as f64 / (ni - 1) as f64) - .collect(); - let alpha: Vec = (0..ni) - .map(|i| 1.0 + 2.0 * i as f64 / (ni - 1) as f64) - .collect(); - let beta: Vec = (0..ni) - .map(|i| 3.5 + 1.0 * i as f64 / (ni - 1) as f64) - .collect(); - let mut u = lcg(seed); - let mut resp = vec![0.0_f64; n * ni]; - let mut times = vec![0.0_f64; n * ni]; - let l22 = sig_true * (1.0 - rho_true * rho_true).sqrt(); - for p in 0..n { - let za = normal(&mut u); - let zb = normal(&mut u); - let theta = za; - let tau = rho_true * sig_true * za + l22 * zb; - for i in 0..ni { - let pr = 1.0 / (1.0 + (-(a[i] * theta + b[i])).exp()); - resp[p * ni + i] = if u() < pr { 1.0 } else { 0.0 }; - let ylog = beta[i] - tau + (1.0 / alpha[i]) * normal(&mut u); - times[p * ni + i] = ylog.exp(); - } - } - fit_speed_accuracy_covariance( - &resp, - ×, - None, - &a, - &b, - &alpha, - &beta, - n, - ni, - SpeedAccuracyConfig::default(), - ) - .unwrap() -} - -#[test] -fn joint_recovers_rho_and_reduces_at_zero() { - // Anchor D: recovery at rho=0.5 - let fit = sim_and_fit(11, 1000, 0.5, 0.3); - assert!(fit.converged); - assert_eq!(fit.termination_reason, "converged"); - let max_drop = fit - .loglik_trace - .windows(2) - .map(|w| w[0] - w[1]) - .fold(f64::NEG_INFINITY, f64::max); - let final_delta = fit.final_loglik_change; - eprintln!( - "[joint] converged={} n_iter={} trace len={} first={:.4} last={:.4} final_delta={:.12e} tol={:.12e} max_drop={:.3e}", - fit.converged, - fit.n_iter, - fit.loglik_trace.len(), - fit.loglik_trace[0], - fit.loglik_trace.last().unwrap(), - final_delta, - SpeedAccuracyConfig::default().tol, - max_drop - ); - assert!( - final_delta < SpeedAccuracyConfig::default().tol, - "converged fit final delta {final_delta} exceeds tolerance" - ); - assert!( - fit.loglik_trace - .windows(2) - .all(|w| w[1] >= w[0] - 1e-6 * w[0].abs().max(1.0)), - "loglik must be monotone (max drop {max_drop:.3e})" - ); - assert!((fit.rho - 0.5).abs() < 0.1, "rho {}", fit.rho); - assert!( - (fit.sigma_tau - 0.3).abs() < 0.05, - "sigma_tau {}", - fit.sigma_tau - ); - // Anchor B: true independence -> rho ~= 0 - let fit0 = sim_and_fit(12, 1000, 0.0, 0.3); - assert!(fit0.converged); - assert_eq!(fit0.termination_reason, "converged"); - assert!(fit0.final_loglik_change < SpeedAccuracyConfig::default().tol); - assert!( - fit0.rho.abs() < 0.08, - "rho at independence should be ~0: {}", - fit0.rho - ); -} - -#[test] -fn joint_reports_max_iter_nonconvergence() { - let ni = 4usize; - let n = 20usize; - let responses: Vec = (0..n * ni) - .map(|idx| ((idx + idx / ni) % 2) as f64) - .collect(); - let times: Vec = (0..n * ni) - .map(|idx| 2.0 + (idx % ni) as f64 * 0.1) - .collect(); - let fit = fit_speed_accuracy_covariance( - &responses, - ×, - None, - &vec![1.0; ni], - &vec![0.0; ni], - &vec![1.5; ni], - &vec![1.0; ni], - n, - ni, - SpeedAccuracyConfig { - q: 7, - max_iter: 1, - ..SpeedAccuracyConfig::default() - }, - ) - .unwrap(); - assert!(!fit.converged); - assert_eq!(fit.termination_reason, "max_iter_reached"); - assert_eq!(fit.n_iter, 1); - assert_eq!(fit.loglik_trace.len(), 2); - assert!(fit.final_loglik_change.is_finite()); - assert!(fit.final_loglik_change >= SpeedAccuracyConfig::default().tol); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn joint_monte_carlo_500() { - let reps = 500usize; - for &rho_true in &[0.0_f64, 0.5, -0.5] { - let (mut sr, mut br, mut ss, mut bs, mut absr) = (0.0, 0.0, 0.0, 0.0, 0.0); - for r in 0..reps { - let fit = sim_and_fit(200 + r as u64, 800, rho_true, 0.3); - assert!( - fit.converged, - "replication {r} at rho={rho_true} exhausted {} iterations; final delta={}", - fit.n_iter, fit.final_loglik_change - ); - assert_eq!(fit.termination_reason, "converged"); - assert!(fit.final_loglik_change < SpeedAccuracyConfig::default().tol); - sr += (fit.rho - rho_true).powi(2); - br += fit.rho - rho_true; - ss += (fit.sigma_tau - 0.3).powi(2); - bs += fit.sigma_tau - 0.3; - absr += fit.rho.abs(); - } - let f = reps as f64; - println!( - "[joint 500] rho={rho_true}: RMSE(rho)={:.4} bias(rho)={:.4} RMSE(sigma)={:.4} \ - bias(sigma)={:.4} mean|rho|={:.4}", - (sr / f).sqrt(), - br / f, - (ss / f).sqrt(), - bs / f, - absr / f - ); - // provisional thresholds (retune after the first 500-rep run; with ~20 - // items the person-parameter measurement error inflates SD(rho_hat)) - assert!( - (sr / f).sqrt() < 0.06, - "rho RMSE too high: {}", - (sr / f).sqrt() - ); - assert!((br / f).abs() < 0.02, "rho bias too high: {}", br / f); - assert!((bs / f).abs() < 0.05, "sigma_tau bias too high: {}", bs / f); - if rho_true == 0.0 { - // mean|rho_hat| ~ RMSE*sqrt(2/pi) ~ 0.033 for an unbiased estimator - // (a dispersion sanity, not a bias check; bias(rho) above is the - // real "recovers independence" anchor) - assert!(absr / f < 0.05, "mean|rho| at rho=0: {}", absr / f); - } - } -} diff --git a/tests/unit/rt_tests.rs b/tests/unit/rt_tests.rs deleted file mode 100644 index 113ce869d..000000000 --- a/tests/unit/rt_tests.rs +++ /dev/null @@ -1,696 +0,0 @@ -use super::*; - -fn lcg(seed: u64) -> impl FnMut() -> f64 { - let mut st = seed.max(1); - move || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - } -} -fn normal(u: &mut impl FnMut() -> f64) -> f64 { - let u1 = u().max(1e-12); - let u2 = u(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() -} -fn corr(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - let (ma, mb) = (a.iter().sum::() / n, b.iter().sum::() / n); - let mut sab = 0.0; - let mut saa = 0.0; - let mut sbb = 0.0; - for (&x, &yv) in a.iter().zip(b) { - sab += (x - ma) * (yv - mb); - saa += (x - ma).powi(2); - sbb += (yv - mb).powi(2); - } - sab / (saa.sqrt() * sbb.sqrt()) -} - -// Anchor 1: the Woodbury/closed-form marginal log-likelihood equals a naive -// dense multivariate-normal log-pdf (certifies ln|Sigma|, the quadratic form, -// and every sign convention of the likelihood path). -#[test] -fn rt_marginal_loglik_matches_dense_mvn() { - let alpha = [1.5_f64, 2.0, 0.8]; - let beta = [4.0_f64, 3.5, 4.2]; - let sig2 = 0.09_f64; - let yv = [3.7_f64, 3.9, 4.5]; // one person's log-times - let n = 3usize; - // closed form (E-step block) - let a: Vec = alpha.iter().map(|&al| al * al).collect(); - let (mut a_sum, mut num, mut ar2, mut ld) = (0.0, 0.0, 0.0, 0.0); - for i in 0..n { - let r = yv[i] - beta[i]; - a_sum += a[i]; - num += a[i] * (-r); - ar2 += a[i] * r * r; - ld += a[i].ln(); - } - let pj = 1.0 / sig2 + a_sum; - let te = num / pj; - let ln2pi = (2.0 * std::f64::consts::PI).ln(); - let closed = -0.5 * (n as f64 * ln2pi - ld + sig2.ln() + pj.ln() + ar2 - pj * te * te); - // dense: Sigma = sig2*ones + diag(1/a_i); log N(y; beta, Sigma) - let mut sigma = vec![vec![0.0_f64; n]; n]; - for i in 0..n { - for j in 0..n { - sigma[i][j] = sig2 + if i == j { 1.0 / a[i] } else { 0.0 }; - } - } - // Cholesky L (SPD) - let mut l = vec![vec![0.0_f64; n]; n]; - for i in 0..n { - for j in 0..=i { - let mut s = sigma[i][j]; - for k in 0..j { - s -= l[i][k] * l[j][k]; - } - if i == j { - l[i][j] = s.sqrt(); - } else { - l[i][j] = s / l[j][j]; - } - } - } - let logdet = 2.0 * (0..n).map(|i| l[i][i].ln()).sum::(); - // solve Sigma x = r via L L^T x = r - let r: Vec = (0..n).map(|i| yv[i] - beta[i]).collect(); - let mut z = vec![0.0_f64; n]; - for i in 0..n { - let mut s = r[i]; - for k in 0..i { - s -= l[i][k] * z[k]; - } - z[i] = s / l[i][i]; - } - let mut x = vec![0.0_f64; n]; - for i in (0..n).rev() { - let mut s = z[i]; - for k in (i + 1)..n { - s -= l[k][i] * x[k]; - } - x[i] = s / l[i][i]; - } - let quad: f64 = (0..n).map(|i| r[i] * x[i]).sum(); - let dense = -0.5 * (n as f64 * ln2pi + logdet + quad); - assert!( - (closed - dense).abs() < 1e-9, - "Woodbury {closed} vs dense {dense}" - ); -} - -// Anchor 2: with sigma_tau -> 0 the model collapses to the per-item lognormal -// MLE (beta_i = mean log-time, 1/alpha_i^2 = var of log-time). -#[test] -fn rt_reduces_to_lognormal_mle_when_speed_degenerate() { - let mut u = lcg(5); - let (np, ni) = (600usize, 8usize); - let beta_t: Vec = (0..ni).map(|i| 3.5 + 0.1 * i as f64).collect(); - let alpha_t: Vec = (0..ni).map(|i| 1.2 + 0.1 * i as f64).collect(); - let mut times = vec![0.0_f64; np * ni]; - for p in 0..np { - for i in 0..ni { - let y = beta_t[i] + (1.0 / alpha_t[i]) * normal(&mut u); // tau ~ 0 - times[p * ni + i] = y.exp(); - } - } - let cfg = RtConfig { - fix_sigma_tau: Some(1e-6), - ..Default::default() - }; - let fit = fit_rt_lognormal(×, None, np, ni, cfg).unwrap(); - for i in 0..ni { - let col: Vec = (0..np).map(|p| (times[p * ni + i]).ln()).collect(); - let m = col.iter().sum::() / np as f64; - let var = col.iter().map(|&v| (v - m).powi(2)).sum::() / np as f64; - assert!( - (fit.beta[i] - m).abs() < 1e-2, - "beta {} vs mle {m}", - fit.beta[i] - ); - assert!( - (1.0 / (fit.alpha[i] * fit.alpha[i]) - var).abs() < 1e-2, - "alpha resvar mismatch" - ); - } -} - -#[test] -fn rt_reports_max_iter_nonconvergence() { - let n_persons = 20usize; - let n_items = 4usize; - let times: Vec = (0..n_persons * n_items) - .map(|idx| 2.0 + (idx % n_items) as f64 * 0.1) - .collect(); - let fit = fit_rt_lognormal( - ×, - None, - n_persons, - n_items, - RtConfig { - max_iter: 1, - ..RtConfig::default() - }, - ) - .unwrap(); - assert!(!fit.converged); - assert_eq!(fit.termination_reason, "max_iter_reached"); - assert_eq!(fit.n_iter, 1); - assert_eq!(fit.loglik_trace.len(), 2); - assert!(fit.final_loglik_change.is_finite()); - assert!(fit.final_loglik_change >= RtConfig::default().tol); - assert_eq!(fit.loglik, *fit.loglik_trace.last().unwrap()); -} - -#[test] -fn rt_rejects_invalid_controls() { - let times = [2.0_f64]; - for config in [ - RtConfig { - max_iter: 0, - ..RtConfig::default() - }, - RtConfig { - tol: f64::NAN, - ..RtConfig::default() - }, - RtConfig { - var_floor: f64::INFINITY, - ..RtConfig::default() - }, - RtConfig { - sigma_floor: 0.0, - ..RtConfig::default() - }, - ] { - assert!(fit_rt_lognormal(×, None, 1, 1, config).is_err()); - } -} - -#[test] -fn rt_rejects_every_shape_data_and_observation_boundary() { - let default = RtConfig::default(); - assert!(fit_rt_lognormal(&[], None, 0, 1, default).is_err()); - assert!(fit_rt_lognormal(&[1.0], None, 1, 2, default).is_err()); - assert!(fit_rt_lognormal(&[1.0, 2.0], Some(&[true]), 1, 2, default).is_err()); - assert!(fit_rt_lognormal( - &[1.0], - None, - 1, - 1, - RtConfig { - fix_sigma_tau: Some(f64::NAN), - ..default - }, - ) - .is_err()); - assert!(fit_rt_lognormal(&[0.0], None, 1, 1, default).is_err()); - assert!(fit_rt_lognormal(&[1.0, 1.0], Some(&[true, false]), 1, 2, default).is_err()); -} - -// Tier-1 recovery guard + monotone loglik. -#[test] -fn rt_recovers_parameters() { - let (recov, _bias) = mc_rt(1, 800, false); - assert!(recov.converged); - assert!(recov.mono, "loglik trace must be non-decreasing"); - assert!(recov.corr_alpha > 0.85, "alpha corr {}", recov.corr_alpha); - assert!(recov.corr_beta > 0.95, "beta corr {}", recov.corr_beta); - assert!(recov.corr_tau > 0.8, "tau corr {}", recov.corr_tau); - assert!( - (recov.sigma_hat - 0.3).abs() < 0.1, - "sigma_tau {}", - recov.sigma_hat - ); -} - -struct RtRecov { - converged: bool, - mono: bool, - corr_alpha: f64, - corr_beta: f64, - corr_tau: f64, - sigma_hat: f64, -} - -// One replication (or the aggregate for reps>1) of the recovery study. -// Returns per-item RMSE/bias via the `bias` out-struct for the MC. -fn mc_rt(seed: u64, n_persons: usize, skew: bool) -> (RtRecov, RtBias) { - let ni = 20usize; - let beta_t: Vec = (0..ni) - .map(|i| 3.5 + 1.0 * i as f64 / (ni - 1) as f64) - .collect(); - let alpha_t: Vec = (0..ni) - .map(|i| 1.0 + 2.0 * i as f64 / (ni - 1) as f64) - .collect(); - let sigma_true = 0.3_f64; - let mut u = lcg(6000 + seed); - let mut times = vec![0.0_f64; n_persons * ni]; - let mut obs = vec![true; n_persons * ni]; - let mut tau_true = vec![0.0_f64; n_persons]; - for p in 0..n_persons { - // speed: normal, or mean-0 standardized skew (shifted exponential) - let tau = if skew { - sigma_true * (-(u().max(1e-12)).ln() - 1.0) // Exp(1)-1 has mean 0, var 1 - } else { - sigma_true * normal(&mut u) - }; - tau_true[p] = tau; - for i in 0..ni { - if u() < 0.3 { - obs[p * ni + i] = false; - times[p * ni + i] = 1.0; // placeholder (masked) - continue; - } - let y = beta_t[i] - tau + (1.0 / alpha_t[i]) * normal(&mut u); - times[p * ni + i] = y.exp(); - } - } - let fit = fit_rt_lognormal(×, Some(&obs), n_persons, ni, RtConfig::default()).unwrap(); - let mono = fit.loglik_trace.windows(2).all(|w| w[1] >= w[0] - 1e-6); - let recov = RtRecov { - converged: fit.converged, - mono, - corr_alpha: corr(&fit.alpha, &alpha_t), - corr_beta: corr(&fit.beta, &beta_t), - corr_tau: corr(&fit.tau_eap, &tau_true), - sigma_hat: fit.sigma_tau, - }; - let rmse = |est: &[f64], tru: &[f64]| -> f64 { - (est.iter() - .zip(tru) - .map(|(&e, &t)| (e - t).powi(2)) - .sum::() - / est.len() as f64) - .sqrt() - }; - let bias = |est: &[f64], tru: &[f64]| -> f64 { - est.iter().zip(tru).map(|(&e, &t)| e - t).sum::() / est.len() as f64 - }; - let b = RtBias { - rmse_alpha: rmse(&fit.alpha, &alpha_t), - rmse_beta: rmse(&fit.beta, &beta_t), - bias_alpha: bias(&fit.alpha, &alpha_t), - bias_beta: bias(&fit.beta, &beta_t), - sigma_bias: fit.sigma_tau - sigma_true, - corr_tau: recov.corr_tau, - }; - (recov, b) -} - -struct RtBias { - rmse_alpha: f64, - rmse_beta: f64, - bias_alpha: f64, - bias_beta: f64, - sigma_bias: f64, - corr_tau: f64, -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn rt_monte_carlo_500() { - let reps = 500usize; - for skew in [false, true] { - let (mut ra, mut rb, mut ba, mut bb, mut sb, mut ct) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); - for r in 0..reps { - let (_rec, b) = mc_rt(100 + r as u64, 800, skew); - ra += b.rmse_alpha; - rb += b.rmse_beta; - ba += b.bias_alpha; - bb += b.bias_beta; - sb += b.sigma_bias; - ct += b.corr_tau; - } - let f = reps as f64; - let label = if skew { "skew" } else { "normal" }; - println!( - "[rt 500] {label}: RMSE(alpha)={:.4} RMSE(beta)={:.4} bias(alpha)={:.4} \ - bias(beta)={:.4} bias(sigma)={:.4} corr(tau)={:.3}", - ra / f, - rb / f, - ba / f, - bb / f, - sb / f, - ct / f - ); - // beta is a per-item weighted normal regression given tau -> robust to - // the speed-distribution shape in BOTH conditions: - assert!(rb / f < 0.05, "{label} beta RMSE too high: {}", rb / f); - assert!( - (bb / f).abs() < 0.02, - "{label} beta bias too high: {}", - bb / f - ); - assert!(ra / f < 0.15, "{label} alpha RMSE too high: {}", ra / f); - if !skew { - // under a correctly-specified normal speed prior, everything is - // unbiased and speed recovers well; under skew alpha may carry a - // small posterior-variance-correction bias (reported, not asserted) - assert!((ba / f).abs() < 0.05, "normal alpha bias: {}", ba / f); - assert!((sb / f).abs() < 0.05, "normal sigma_tau bias: {}", sb / f); - assert!(ct / f > 0.9, "normal tau corr: {}", ct / f); - } - } -} - -// Anchor: at true item params the residuals are N(0,1) and W is exactly -// chi-square — chi2(n) at known tau, chi2(n-1) once tau is profiled. -#[test] -fn rt_person_fit_chi2_at_true_params() { - let mut u = lcg(31); - let (np, ni) = (30000usize, 20usize); - let beta: Vec = (0..ni).map(|i| 3.5 + i as f64 / (ni - 1) as f64).collect(); - let alpha: Vec = (0..ni) - .map(|i| 1.0 + 2.0 * i as f64 / (ni - 1) as f64) - .collect(); - let mut times = vec![0.0_f64; np * ni]; - let mut tau = vec![0.0_f64; np]; - for p in 0..np { - let tj = 0.3 * normal(&mut u); - tau[p] = tj; - for i in 0..ni { - times[p * ni + i] = (beta[i] - tj + normal(&mut u) / alpha[i]).exp(); - } - } - // (1) known tau: z ~ N(0,1), mean(sum z^2) ~ n - let (mut sz, mut sz2, mut cnt, mut sw) = (0.0_f64, 0.0, 0.0, 0.0); - for p in 0..np { - let mut wk = 0.0; - for i in 0..ni { - let z = alpha[i] * (times[p * ni + i].ln() - beta[i] + tau[p]); - sz += z; - sz2 += z * z; - cnt += 1.0; - wk += z * z; - } - sw += wk; - } - let mz = sz / cnt; - let sdz = (sz2 / cnt - mz * mz).sqrt(); - assert!( - mz.abs() < 0.02 && (sdz - 1.0).abs() < 0.03, - "known-tau z not N(0,1): {mz}, {sdz}" - ); - assert!( - (sw / np as f64 - ni as f64).abs() < 0.03 * ni as f64, - "known-tau W not chi2(n)" - ); - // (2) profiled (production path): W ~ chi2(n-1), l_t ~ N(0,1), Type I ~ .05 - let pf = rt_person_fit(×, None, np, ni, &alpha, &beta, 0.05, 1.645).unwrap(); - let mw = pf.w.iter().sum::() / np as f64; - assert!( - (mw - (ni - 1) as f64).abs() < 0.03 * (ni - 1) as f64, - "profiled W not chi2(n-1): {mw}" - ); - let mlt = pf.l_t.iter().sum::() / np as f64; - let sdlt = (pf.l_t.iter().map(|&x| (x - mlt).powi(2)).sum::() / np as f64).sqrt(); - assert!( - mlt.abs() < 0.05 && (sdlt - 1.0).abs() < 0.05, - "l_t not N(0,1): {mlt}, {sdlt}" - ); - let t1 = pf.flagged.iter().filter(|&&f| f).count() as f64 / np as f64; - assert!((0.03..=0.07).contains(&t1), "Type I: {t1}"); - // (3) per-item studentized residual ~ N(0,1) - let iz: Vec = pf - .z_resid - .iter() - .cloned() - .filter(|v| v.is_finite()) - .collect(); - let miz = iz.iter().sum::() / iz.len() as f64; - let sdiz = (iz.iter().map(|&x| (x - miz).powi(2)).sum::() / iz.len() as f64).sqrt(); - assert!( - miz.abs() < 0.02 && (sdiz - 1.0).abs() < 0.03, - "item_z not N(0,1): {miz}, {sdiz}" - ); -} - -// (Type I over consistent responders, power over aberrant, l_t mean/sd, and -// per-item recall of tampered responses). mode 0 = rapid guessing on the last -// items; mode 1 = preknowledge on the first items. fit_items uses MML-estimated -// item params (production path) instead of the true ones. -fn mc_rt_pf( - reps: usize, - n_persons: usize, - skew: bool, - mode: u8, - fit_items: bool, -) -> (f64, f64, f64, f64, f64) { - let ni = 20usize; - let beta: Vec = (0..ni).map(|i| 3.5 + i as f64 / (ni - 1) as f64).collect(); - let alpha: Vec = (0..ni) - .map(|i| 1.0 + 2.0 * i as f64 / (ni - 1) as f64) - .collect(); - let n_ab = n_persons / 10; - let (mut t1n, mut t1c, mut pwn, mut pwc) = (0usize, 0usize, 0usize, 0usize); - let (mut lts, mut lt2, mut ltc) = (0.0_f64, 0.0, 0usize); - let (mut recn, mut recc) = (0usize, 0usize); - for rep in 0..reps as u64 { - let mut u = - lcg(70_000 + rep * 131 + skew as u64 * 3 + mode as u64 * 7 + fit_items as u64 * 11); - let mut times = vec![0.0_f64; n_persons * ni]; - let mut tampered = vec![false; n_persons * ni]; - for p in 0..n_persons { - let ab = p < n_ab; - let tj = if skew { - 0.3 * (-(u().max(1e-12)).ln() - 1.0) - } else { - 0.3 * normal(&mut u) - }; - for i in 0..ni { - let short = ab - && match mode { - 0 => i >= ni - ni * 35 / 100, // last 35% - _ => i < ni * 30 / 100, // first 30% - }; - let y = if short { - (beta[i] - tj) - 2.5 + 0.3 * normal(&mut u) - } else { - beta[i] - tj + normal(&mut u) / alpha[i] - }; - times[p * ni + i] = y.exp(); - tampered[p * ni + i] = short; - } - } - let (ea, eb) = if fit_items { - // calibrate on a FRESH CLEAN sample: isolates item-parameter - // sampling uncertainty (the production regime) rather than the - // separate contamination-by-aberrant-responders effect. - let mut uc = lcg(80_000 + rep * 131 + skew as u64 * 3); - let mut ct = vec![0.0_f64; n_persons * ni]; - for p in 0..n_persons { - let tj = if skew { - 0.3 * (-(uc().max(1e-12)).ln() - 1.0) - } else { - 0.3 * normal(&mut uc) - }; - for i in 0..ni { - ct[p * ni + i] = (beta[i] - tj + normal(&mut uc) / alpha[i]).exp(); - } - } - let fit = fit_rt_lognormal(&ct, None, n_persons, ni, RtConfig::default()).unwrap(); - (fit.alpha, fit.beta) - } else { - (alpha.clone(), beta.clone()) - }; - let pf = rt_person_fit(×, None, n_persons, ni, &ea, &eb, 0.05, 1.645).unwrap(); - for p in 0..n_persons { - if !pf.w[p].is_finite() { - continue; - } - if p < n_ab { - if pf.flagged[p] { - pwn += 1; - } - pwc += 1; - for i in 0..ni { - if tampered[p * ni + i] { - recc += 1; - if pf.item_flag[p * ni + i] { - recn += 1; - } - } - } - } else { - if pf.flagged[p] { - t1n += 1; - } - t1c += 1; - lts += pf.l_t[p]; - lt2 += pf.l_t[p] * pf.l_t[p]; - ltc += 1; - } - } - } - let mlt = lts / ltc as f64; - ( - t1n as f64 / t1c as f64, - pwn as f64 / pwc as f64, - mlt, - (lt2 / ltc as f64 - mlt * mlt).sqrt(), - recn as f64 / recc.max(1) as f64, - ) -} - -#[test] -fn rt_person_fit_type1_and_power() { - let (t1, pw, mlt, sdlt, _) = mc_rt_pf(6, 800, false, 0, false); - let (_, pw_pre, _, _, rec) = mc_rt_pf(6, 800, false, 1, false); - let (t1s, _, _, _, _) = mc_rt_pf(6, 800, true, 0, false); - let (t1f, pwf, _, _, _) = mc_rt_pf(4, 800, false, 0, true); // production path - println!( - "[rt-pf] Type I={t1:.3} power(guess)={pw:.3} power(preknow)={pw_pre:.3} \ - l_t=({mlt:.2},{sdlt:.2}) skew Type I={t1s:.3} fitted Type I={t1f:.3} recall={rec:.3}" - ); - assert!((0.01..=0.12).contains(&t1), "Type I: {t1}"); - assert!(pw > 0.5 && pw_pre > 0.5, "power: {pw}/{pw_pre}"); - assert!( - mlt.abs() < 0.4 && (0.75..=1.3).contains(&sdlt), - "l_t: {mlt}/{sdlt}" - ); - assert!((0.01..=0.12).contains(&t1s), "skew Type I: {t1s}"); - assert!( - (0.01..=0.13).contains(&t1f) && pwf > 0.5, - "fitted path: {t1f}/{pwf}" - ); -} - -#[test] -fn rt_person_fit_rejects_invalid_parameters_and_controls() { - let times = vec![1.0, 2.0, 1.5, 2.5]; - let alpha = vec![1.0, 1.5]; - let beta = vec![0.0, 0.5]; - let bad = |alpha: &[f64], beta: &[f64], alpha_level: f64, z_fast: f64| { - rt_person_fit(×, None, 2, 2, alpha, beta, alpha_level, z_fast).is_err() - }; - assert!(bad(&[0.0, 1.5], &beta, 0.05, 1.645)); - assert!(bad(&[f64::NAN, 1.5], &beta, 0.05, 1.645)); - assert!(bad(&[1e308, 1.5], &beta, 0.05, 1.645)); - assert!(bad(&[1e-308, 1e-308], &beta, 0.05, 1.645)); - assert!(bad(&alpha, &[0.0, f64::INFINITY], 0.05, 1.645)); - assert!(bad(&alpha, &[1e308, 1e308], 0.05, 1.645)); - assert!(bad(&alpha, &beta, f64::NAN, 1.645)); - assert!(bad(&alpha, &beta, 0.05, -0.1)); - assert!(bad(&alpha, &beta, 0.05, f64::INFINITY)); - assert!(rt_person_fit(&[], None, usize::MAX, 2, &alpha, &beta, 0.05, 1.645).is_err()); -} - -#[test] -fn rt_person_fit_covers_shapes_missingness_and_extreme_arithmetic() { - let alpha = [1.0, 1.0, 1.0]; - let beta = [0.0, 0.0, 0.0]; - assert!(rt_person_fit(&[], None, 0, 1, &[1.0], &[0.0], 0.05, 1.645).is_err()); - assert!(rt_person_fit(&[1.0], None, 1, 2, &[1.0, 1.0], &[0.0, 0.0], 0.05, 1.645).is_err()); - assert!(rt_person_fit(&[1.0, 1.0], None, 1, 2, &[1.0], &[0.0, 0.0], 0.05, 1.645).is_err()); - assert!(rt_person_fit( - &[1.0, 1.0], - Some(&[true]), - 1, - 2, - &[1.0, 1.0], - &[0.0, 0.0], - 0.05, - 1.645, - ) - .is_err()); - assert!(rt_person_fit( - &[0.0, 1.0], - None, - 1, - 2, - &[1.0, 1.0], - &[0.0, 0.0], - 0.05, - 1.645 - ) - .is_err()); - - let fit = rt_person_fit( - &[1.0, 2.0, 3.0], - Some(&[true, false, false]), - 1, - 3, - &alpha, - &beta, - 0.05, - 1.645, - ) - .unwrap(); - assert!(fit.w[0].is_nan()); - assert!(fit.z_resid.iter().all(|value| value.is_nan())); - - let masked = rt_person_fit( - &[1.0, 1.0, 2.0], - Some(&[true, false, true]), - 1, - 3, - &alpha, - &beta, - 0.05, - 1.645, - ) - .unwrap(); - assert!(masked.w[0].is_finite()); - assert!(masked.z_resid[1].is_nan()); - - assert!(rt_person_fit( - &[f64::MIN_POSITIVE; 3], - None, - 1, - 3, - &alpha, - &[1e308; 3], - 0.05, - 1.645, - ) - .is_err()); - assert!(rt_person_fit( - &[1.0, 1.0], - None, - 1, - 2, - &[1.0, 1.0], - &[1e200, -1e200], - 0.05, - 1.645, - ) - .is_err()); - assert!(rt_person_fit( - &[1.0, 1.0], - None, - 1, - 2, - &[1.0, 1.0], - &[1e154, -1e154], - 0.05, - 1.645, - ) - .is_err()); -} -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn rt_person_fit_monte_carlo_500() { - for skew in [false, true] { - for mode in [0u8, 1] { - let reps = 500usize; - let (t1, pw, mlt, sdlt, rec) = mc_rt_pf(reps, 600, skew, mode, false); - println!( - "[rt-pf 500] skew={skew} mode={mode}: Type I={t1:.4} power={pw:.3} \ - l_t=({mlt:.3},{sdlt:.3}) item-recall={rec:.3}" - ); - assert!((0.03..=0.08).contains(&t1), "Type I off nominal: {t1}"); - assert!(pw > 0.7, "power too low: {pw}"); - } - } - // production path: fit item params by MML, then person-fit - let reps = 500usize; - let (t1f, pwf, _, _, _) = mc_rt_pf(reps, 600, false, 0, true); - println!("[rt-pf 500] fitted-items: Type I={t1f:.4} power={pwf:.3}"); - assert!( - (0.03..=0.09).contains(&t1f), - "fitted-item Type I off nominal: {t1f}" - ); - assert!(pwf > 0.7, "fitted-item power too low: {pwf}"); -} diff --git a/tests/unit/scoring_cat_pv_tests.rs b/tests/unit/scoring_cat_pv_tests.rs deleted file mode 100644 index 14779504a..000000000 --- a/tests/unit/scoring_cat_pv_tests.rs +++ /dev/null @@ -1,186 +0,0 @@ -use super::*; -use crate::nodes::XiRule; -use crate::{Device, ModelType}; - -fn bank_fixture() -> (Vec, Vec, Vec, Vec) { - let alpha = vec![0.2, -0.1, 0.4, 0.0, 0.3, -0.2, 0.1, 0.25]; - let b = vec![0.5, -0.5, 0.0, 1.0, -1.0, 0.3, -0.3, 0.8]; - let zeta = vec![0.0; 8]; - let factor_id = vec![0, 1, 0, 1, 0, 1, 0, 1]; - (alpha, b, zeta, factor_id) -} - -#[test] -fn information_reduces_to_2pl_and_peaks_at_b() { - // 4PL formula with c=0, d=1 equals a^2 P (1-P) - let i1 = item_information_4pl(1.5, 0.4, 0.0, 1.0); - assert!((i1 - 1.5f64 * 1.5 * 0.4 * 0.6).abs() < 1e-12); - // guessing shrinks information (Magis 2013) - let i3pl = item_information_4pl(1.5, 0.4, 0.2, 1.0); - assert!(i3pl < i1); - assert_eq!(item_information_4pl(1.5, 0.0, 0.0, 1.0), 0.0); -} - -#[test] -fn cat_selects_informative_item_on_target_dim() { - let (alpha, b, zeta, fid) = bank_fixture(); - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 2, - latent_dim: 1, - eps_distance: 1e-8, - }; - // dim 0 already has two answers; dim 1 has none -> target dim 1 - let y = vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; - let administered = vec![true, false, true, false, false, false, false, false]; - let step = cat_next_item( - &bank, - &y, - &administered, - &PriorSpec::standard(2), - 15, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - assert_eq!(step.target_dim, 1, "unmeasured dimension must be targeted"); - assert!(step - .ranked_items - .iter() - .all(|&i| fid[i] == 1 && !administered[i])); - // ranked by information: descending - for w in step.ranked_info.windows(2) { - assert!(w[0] >= w[1]); - } - let mut invalid_y = y.clone(); - invalid_y[0] = 2.0; - assert!(cat_next_item( - &bank, - &invalid_y, - &administered, - &PriorSpec::standard(2), - 15, - XiRule::GaussHermite { q_xi: 7 }, - ) - .is_err()); - invalid_y[0] = f64::NAN; - assert!(cat_next_item( - &bank, - &invalid_y, - &administered, - &PriorSpec::standard(2), - 15, - XiRule::GaussHermite { q_xi: 7 }, - ) - .is_err()); -} - -#[test] -fn plausible_values_track_the_posterior() { - let (alpha, b, zeta, fid) = bank_fixture(); - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -30.0, - factor_id: &fid, - model_type: ModelType::Mirt, - n_dims: 2, - latent_dim: 1, - eps_distance: 1e-8, - }; - // person 0 passes everything on dim 0, person 1 fails everything - let y = vec![ - 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - ]; - let observed = vec![true; 16]; - let pv = plausible_values( - &bank, - &y, - &observed, - 2, - &PriorSpec::standard(2), - 15, - XiRule::GaussHermite { q_xi: 7 }, - 200, - 7, - ) - .unwrap(); - let mean_p0_d0: f64 = (0..200).map(|r| pv[(r) * 2]).sum::() / 200.0; - let mean_p1_d0: f64 = (0..200).map(|r| pv[(200 + r) * 2]).sum::() / 200.0; - assert!( - mean_p0_d0 > mean_p1_d0 + 0.5, - "PV means must separate pass-all from fail-all: {mean_p0_d0} vs {mean_p1_d0}" - ); - // draws are reproducible - let pv2 = plausible_values( - &bank, - &y, - &observed, - 2, - &PriorSpec::standard(2), - 15, - XiRule::GaussHermite { q_xi: 7 }, - 200, - 7, - ) - .unwrap(); - assert_eq!(pv, pv2); -} - -#[test] -fn plausible_values_gpu_matches_seeded_cpu_draws() { - let (alpha, b, mut zeta, fid) = bank_fixture(); - zeta.copy_from_slice(&[-0.9, 0.7, -0.4, 1.1, 0.2, -1.2, 0.8, -0.1]); - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -0.2, - factor_id: &fid, - model_type: ModelType::Mls2plm, - n_dims: 2, - latent_dim: 1, - eps_distance: 1e-8, - }; - let y = vec![ - 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, - ]; - let observed = vec![true; y.len()]; - let run = |device| { - plausible_values_device( - &bank, - &y, - &observed, - 2, - &PriorSpec::standard(2), - 21, - XiRule::GaussHermite { q_xi: 7 }, - 512, - 91, - device, - ) - .unwrap() - }; - let cpu = run(Device::Cpu); - let gpu = run(Device::Gpu); - let max_abs = cpu - .iter() - .zip(&gpu) - .map(|(left, right)| (left - right).abs()) - .fold(0.0_f64, f64::max); - assert!( - max_abs <= 2.0e-5, - "same seeded categorical selections must agree within f32 node precision; max_abs={max_abs:e}" - ); - let cpu_mean = cpu.iter().sum::() / cpu.len() as f64; - let gpu_mean = gpu.iter().sum::() / gpu.len() as f64; - eprintln!( - "plausible-values parity: max_abs={max_abs:e}, cpu_mean={cpu_mean:.12}, gpu_mean={gpu_mean:.12}" - ); - assert!((cpu_mean - gpu_mean).abs() <= 1.0e-6); -} diff --git a/tests/unit/scoring_gpu_score_tests.rs b/tests/unit/scoring_gpu_score_tests.rs deleted file mode 100644 index bb6425162..000000000 --- a/tests/unit/scoring_gpu_score_tests.rs +++ /dev/null @@ -1,288 +0,0 @@ -use super::*; -use crate::nodes::XiRule; - -#[test] -fn gpu_eap_matches_cpu_reduction() { - let (n_items, n_persons, latent_dim) = (6usize, 40usize, 1usize); - let alpha: Vec = (0..n_items).map(|i| 0.1 * i as f64 - 0.2).collect(); - let b: Vec = (0..n_items).map(|i| -0.5 + 0.2 * i as f64).collect(); - let zeta: Vec = (0..n_items * latent_dim) - .map(|i| 0.3 * (i % 3) as f64 - 0.3) - .collect(); - let fid = vec![0usize; n_items]; - let mut st = 12345u64; - let mut u = move || { - st = st - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((st >> 11) as f64) / ((1u64 << 53) as f64) - }; - let mut y = vec![0.0_f64; n_persons * n_items]; - for v in y.iter_mut() { - *v = if u() < 0.5 { 1.0 } else { 0.0 }; - } - let observed = vec![true; n_persons * n_items]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -0.3, - factor_id: &fid, - model_type: crate::ModelType::Mls2plm, - n_dims: 1, - latent_dim, - eps_distance: 1e-8, - }; - let prior = PriorSpec::standard(1); - let grids = scoring_grids(&bank, 21, XiRule::GaussHermite { q_xi: 11 }).unwrap(); - let ctx = prior_contexts(&prior); - let config = bank_model_config(&bank, n_persons, n_items); - let tables = build_tables( - bank.alpha, - bank.b, - bank.zeta, - bank.tau, - &config, - bank.factor_id, - &ctx, - &grids, - ); - let resp = index_responses(&y, &observed, n_persons, n_items); - let cpu = score_eap_cpu_reduce(&bank, &prior, &grids, &tables, &resp, n_persons, n_items); - let gpu = try_score_eap_gpu(&bank, &prior, &grids, &tables, &resp, n_persons, n_items); - if std::env::var("WGPU_BACKEND").is_ok_and(|backend| backend.eq_ignore_ascii_case("metal")) { - assert!( - gpu.is_some(), - "WGPU_BACKEND=metal was explicit, but no usable Metal adapter was selected" - ); - } - match gpu { - None => eprintln!("no GPU adapter present; skipping GPU EAP parity check"), - Some(gpu) => { - let mut max_abs = [0.0_f64; 4]; - for p in 0..n_persons { - max_abs[0] = max_abs[0].max((gpu.loglik[p] - cpu.loglik[p]).abs()); - max_abs[1] = max_abs[1].max((gpu.theta_eap[p] - cpu.theta_eap[p]).abs()); - max_abs[2] = max_abs[2].max((gpu.theta_sd[p] - cpu.theta_sd[p]).abs()); - max_abs[3] = max_abs[3].max((gpu.xi_eap[p] - cpu.xi_eap[p]).abs()); - assert!( - (gpu.loglik[p] - cpu.loglik[p]).abs() < 2e-3, - "loglik p={p}: gpu {} vs cpu {}", - gpu.loglik[p], - cpu.loglik[p] - ); - assert!((gpu.theta_eap[p] - cpu.theta_eap[p]).abs() < 2e-3); - assert!((gpu.theta_sd[p] - cpu.theta_sd[p]).abs() < 2e-3); - assert!((gpu.xi_eap[p] - cpu.xi_eap[p]).abs() < 2e-3); - } - eprintln!( - "GPU EAP parity max abs: loglik={:.3e}, theta={:.3e}, theta_sd={:.3e}, xi={:.3e}; tolerance=2e-3", - max_abs[0], max_abs[1], max_abs[2], max_abs[3] - ); - } - } -} - -#[test] -fn gpu_eapsum_tables_and_lookup_match_cpu() { - let n_items = 8usize; - let n_persons = 32usize; - let n_dims = 2usize; - let latent_dim = 2usize; - let alpha = vec![0.2, -0.1, 0.4, 0.0, 0.3, -0.2, 0.1, 0.25]; - let b = vec![0.5, -0.5, 0.0, 1.0, -1.0, 0.3, -0.3, 0.8]; - let zeta = vec![ - -0.4, 0.2, 0.1, -0.2, 0.3, 0.5, -0.1, 0.4, 0.6, -0.3, -0.2, -0.5, 0.2, 0.1, -0.5, 0.3, - ]; - let factor_id = vec![0, 1, 0, 1, 0, 1, 0, 1]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -0.25, - factor_id: &factor_id, - model_type: crate::ModelType::Mls2plm, - n_dims, - latent_dim, - eps_distance: 1e-8, - }; - let prior = PriorSpec::standard(n_dims); - let grids = scoring_grids(&bank, 21, XiRule::GaussHermite { q_xi: 11 }).unwrap(); - let ctx = prior_contexts(&prior); - let config = bank_model_config(&bank, 1, n_items); - let probability_tables = build_tables( - bank.alpha, - bank.b, - bank.zeta, - bank.tau, - &config, - bank.factor_id, - &ctx, - &grids, - ); - let cpu_tables = eapsum_tables_cpu_reduce(&bank, &prior, &grids, &probability_tables, n_items); - let gpu_tables = - crate::gpu_eapsum::eapsum_tables_gpu(&crate::gpu_eapsum::GpuEapSumTableInputs { - n_items, - n_dims, - q_t: grids.q_t, - n_x: grids.n_x, - factor_id: &factor_id, - logp1: &probability_tables.logp1, - t_logw: &grids.t_logw, - x_logw: &grids.x_logw, - t_nodes: &grids.t_nodes, - prior_mean: &prior.mean, - prior_sd: &prior.sd, - }); - if std::env::var("WGPU_BACKEND").is_ok_and(|backend| backend.eq_ignore_ascii_case("metal")) { - assert!( - gpu_tables.is_some(), - "explicit Metal EAPsum table dispatch failed" - ); - } - let Some(gpu_tables) = gpu_tables else { - eprintln!("no GPU adapter present; skipping GPU EAPsum parity check"); - return; - }; - let mut max_table = [0.0_f64; 3]; - for (gpu, cpu) in gpu_tables.iter().zip(&cpu_tables) { - for (actual, expected) in gpu.score_prob.iter().zip(&cpu.score_prob) { - max_table[0] = max_table[0].max((actual - expected).abs()); - } - for (actual, expected) in gpu.eap.iter().zip(&cpu.eap) { - max_table[1] = max_table[1].max((actual - expected).abs()); - } - for (actual, expected) in gpu.sd.iter().zip(&cpu.sd) { - max_table[2] = max_table[2].max((actual - expected).abs()); - } - } - assert!( - max_table[0] < 2e-5, - "score probability max abs={}", - max_table[0] - ); - assert!(max_table[1] < 2e-4, "EAP max abs={}", max_table[1]); - assert!(max_table[2] < 2e-4, "SD max abs={}", max_table[2]); - - let mut y = vec![0.0; n_persons * n_items]; - for person in 0..n_persons { - for item in 0..n_items { - y[person * n_items + item] = ((person + item) % 3 == 0) as u8 as f64; - } - } - let cpu_lookup = score_eapsum_cpu_reduce(&y, n_persons, n_items, &factor_id, &cpu_tables); - let gpu_lookup = - crate::gpu_eapsum::score_eapsum_gpu(&crate::gpu_eapsum::GpuEapSumLookupInputs { - y: &y, - n_persons, - n_items, - n_dims, - factor_id: &factor_id, - tables: &cpu_tables, - }) - .expect("the adapter used for EAPsum tables must also run lookup"); - let max_theta = gpu_lookup - .0 - .iter() - .zip(&cpu_lookup.theta_eap) - .map(|(gpu, cpu)| (gpu - cpu).abs()) - .fold(0.0_f64, f64::max); - let max_sd = gpu_lookup - .1 - .iter() - .zip(&cpu_lookup.theta_sd) - .map(|(gpu, cpu)| (gpu - cpu).abs()) - .fold(0.0_f64, f64::max); - assert!(max_theta < 2e-6, "lookup EAP max abs={max_theta}"); - assert!(max_sd < 2e-6, "lookup SD max abs={max_sd}"); - eprintln!( - "GPU EAPsum parity max abs: score_prob={:.3e}, eap={:.3e}, sd={:.3e}, lookup_eap={max_theta:.3e}, lookup_sd={max_sd:.3e}", - max_table[0], max_table[1], max_table[2] - ); -} - -#[test] -fn gpu_bank_information_matches_cpu_reduction() { - let n_items = 8usize; - let n_points = 5usize; - let n_dims = 2usize; - let latent_dim = 2usize; - let alpha = vec![0.2, -0.1, 0.4, 0.0, 0.3, -0.2, 0.1, 0.25]; - let b = vec![0.5, -0.5, 0.0, 1.0, -1.0, 0.3, -0.3, 0.8]; - let zeta = vec![ - -0.4, 0.2, 0.1, -0.2, 0.3, 0.5, -0.1, 0.4, 0.6, -0.3, -0.2, -0.5, 0.2, 0.1, -0.5, 0.3, - ]; - let factor_id = vec![0, 1, 0, 1, 0, 1, 0, 1]; - let theta = vec![-1.2, 0.7, -0.5, 0.3, 0.0, 0.0, 0.6, -0.4, 1.1, -0.8]; - let xi = vec![-0.7, 0.2, -0.2, 0.6, 0.0, 0.0, 0.4, -0.5, 0.8, 0.3]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: -0.25, - factor_id: &factor_id, - model_type: crate::ModelType::Mls2plm, - n_dims, - latent_dim, - eps_distance: 1e-8, - }; - let cpu = bank_information_cpu_reduce(&bank, &theta, &xi, n_points, n_items); - let gpu = try_bank_information_gpu(&bank, &theta, &xi, n_points, n_items); - if std::env::var("WGPU_BACKEND").is_ok_and(|backend| backend.eq_ignore_ascii_case("metal")) { - assert!( - gpu.is_some(), - "WGPU_BACKEND=metal was explicit, but no usable Metal adapter was selected" - ); - } - match gpu { - None => eprintln!("no GPU adapter present; skipping GPU bank-information parity check"), - Some((gpu_item, gpu_test)) => { - let max_item = gpu_item - .iter() - .zip(&cpu.0) - .map(|(gpu, cpu)| (gpu - cpu).abs()) - .fold(0.0_f64, f64::max); - let max_test = gpu_test - .iter() - .zip(&cpu.1) - .map(|(gpu, cpu)| (gpu - cpu).abs()) - .fold(0.0_f64, f64::max); - assert!(max_item < 2e-4, "item information max abs={max_item}"); - assert!(max_test < 5e-4, "test information max abs={max_test}"); - eprintln!( - "GPU bank-information parity max abs: item={max_item:.3e}, test={max_test:.3e}; tolerances=2e-4/5e-4" - ); - } - } - - // A log discrimination of 100 is valid in the f64 serving contract but - // exp(100) overflows f32. The GPU attempt must be discarded instead of - // leaking NaN information, and the public device path must return the - // finite CPU reference. - let extreme_alpha = vec![100.0; n_items]; - let extreme_bank = ItemBank { - alpha: &extreme_alpha, - b: &b, - zeta: &zeta, - tau: -0.25, - factor_id: &factor_id, - model_type: crate::ModelType::Mls2plm, - n_dims, - latent_dim, - eps_distance: 1e-8, - }; - let extreme_cpu = bank_information_cpu_reduce(&extreme_bank, &theta, &xi, n_points, n_items); - assert!(extreme_cpu - .0 - .iter() - .chain(&extreme_cpu.1) - .all(|value| value.is_finite())); - assert!( - try_bank_information_gpu(&extreme_bank, &theta, &xi, n_points, n_items).is_none(), - "non-finite f32 information must trigger CPU fallback" - ); - let extreme_device = - bank_information_device(&extreme_bank, &theta, &xi, n_points, crate::Device::Gpu).unwrap(); - assert_eq!(extreme_device, extreme_cpu); -} diff --git a/tests/unit/scoring_reliability_tests.rs b/tests/unit/scoring_reliability_tests.rs deleted file mode 100644 index 5db3cc6d8..000000000 --- a/tests/unit/scoring_reliability_tests.rs +++ /dev/null @@ -1,83 +0,0 @@ -use super::*; - -#[test] -fn empirical_reliability_tracks_signal_to_noise() { - // wide score spread + small SEs -> high rho; flat scores -> low rho - let n = 200usize; - let eap: Vec = (0..n).map(|p| -2.0 + 4.0 * p as f64 / n as f64).collect(); - let sd_small = vec![0.3_f64; n]; - let sd_large = vec![1.5_f64; n]; - let hi = empirical_reliability(&eap, &sd_small, n, 1).unwrap()[0]; - let lo = empirical_reliability(&eap, &sd_large, n, 1).unwrap()[0]; - assert!(hi > 0.85, "high-information scale must be reliable: {hi}"); - assert!( - lo < hi - 0.2, - "noisier scale must be less reliable: {lo} vs {hi}" - ); - assert!(empirical_reliability(&eap, &sd_small, 3, 1).is_err()); - assert!(empirical_reliability(&[], &[], 2, 0).is_err()); - assert!(empirical_reliability(&[0.0, f64::NAN], &[0.3, 0.3], 2, 1).is_err()); - assert!(empirical_reliability(&[0.0, 1.0], &[-0.3, 0.3], 2, 1).is_err()); - assert!(empirical_reliability(&[0.0, 1.0], &[0.3, f64::INFINITY], 2, 1).is_err()); -} - -#[test] -fn empirical_reliability_default_matches_cpu_reference() { - let n_persons = 257usize; - let n_dims = 3usize; - let theta: Vec = (0..n_persons) - .flat_map(|person| { - let x = person as f64 / n_persons as f64; - [x.sin(), 2.0 * x - 1.0, (3.0 * x).cos()] - }) - .collect(); - let sd: Vec = (0..n_persons) - .flat_map(|person| { - let x = person as f64 / n_persons as f64; - [0.2 + x / 10.0, 0.4, 0.1 + x / 20.0] - }) - .collect(); - - let reference = - empirical_reliability_device(&theta, &sd, n_persons, n_dims, crate::Device::Cpu).unwrap(); - let default = empirical_reliability(&theta, &sd, n_persons, n_dims).unwrap(); - for (actual, expected) in default.iter().zip(&reference) { - assert!((actual - expected).abs() < 1e-4, "{actual} vs {expected}"); - } -} - -#[cfg(all(feature = "gpu", not(coverage)))] -#[test] -fn empirical_reliability_explicit_gpu_matches_cpu_reference() { - let n_persons = 513usize; - let n_dims = 2usize; - let theta: Vec = (0..n_persons) - .flat_map(|person| { - let x = person as f64 / n_persons as f64; - [4.0 * x - 2.0, (6.0 * x).sin()] - }) - .collect(); - let sd: Vec = (0..n_persons) - .flat_map(|person| { - let x = person as f64 / n_persons as f64; - [0.3 + 0.1 * x, 0.5 - 0.1 * x] - }) - .collect(); - - let cpu = - empirical_reliability_device(&theta, &sd, n_persons, n_dims, crate::Device::Cpu).unwrap(); - let gpu = crate::gpu_scoring::empirical_reliability_gpu(&theta, &sd, n_persons, n_dims); - if std::env::var("WGPU_BACKEND").is_ok_and(|backend| backend.eq_ignore_ascii_case("metal")) { - assert!( - gpu.is_some(), - "WGPU_BACKEND=metal was explicit, but no usable Metal adapter was selected" - ); - } - let Some(gpu) = gpu else { - eprintln!("no GPU adapter present; skipping empirical-reliability GPU parity check"); - return; - }; - for (actual, expected) in gpu.iter().zip(&cpu) { - assert!((actual - expected).abs() < 1e-4, "{actual} vs {expected}"); - } -} diff --git a/tests/unit/scoring_tests.rs b/tests/unit/scoring_tests.rs deleted file mode 100644 index 31e4ae59f..000000000 --- a/tests/unit/scoring_tests.rs +++ /dev/null @@ -1,657 +0,0 @@ -use super::*; -use crate::nodes::XiRule; - -fn small_bank() -> (Vec, Vec, Vec, Vec) { - let alpha = vec![0.1, -0.1, 0.2, 0.0, 0.05, -0.05]; - let b = vec![0.4, -0.3, 0.1, -0.6, 0.2, 0.0]; - let zeta = vec![ - 0.5, -0.4, -0.6, 0.3, 0.2, 0.7, -0.1, -0.5, 0.4, 0.4, -0.3, 0.1, - ]; - let factor_id = vec![0, 1, 0, 1, 0, 1]; - (alpha, b, zeta, factor_id) -} - -fn bank<'a>( - alpha: &'a [f64], - b: &'a [f64], - zeta: &'a [f64], - factor_id: &'a [usize], -) -> ItemBank<'a> { - ItemBank { - alpha, - b, - zeta, - tau: 0.0, - factor_id, - model_type: ModelType::Mls2plm, - n_dims: 2, - latent_dim: 2, - eps_distance: 1e-8, - } -} - -#[test] -fn bank_information_rejects_overflowing_point_shapes() { - let (alpha, b, zeta, fid) = small_bank(); - let bk = bank(&alpha, &b, &zeta, &fid); - - let err = bank_information_device(&bk, &[], &[], usize::MAX, crate::Device::Cpu).unwrap_err(); - - assert_eq!(err, "n_points * n_dims overflows"); -} - -#[test] -fn default_eap_policy_matches_auto_device() { - let (alpha, b, zeta, fid) = small_bank(); - let bk = bank(&alpha, &b, &zeta, &fid); - let prior = PriorSpec::standard(2); - let y = vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0]; - let observed = vec![true; y.len()]; - let rule = XiRule::GaussHermite { q_xi: 7 }; - - let default = score_eap(&bk, &y, &observed, 1, &prior, 15, rule).unwrap(); - let auto = - score_eap_device(&bk, &y, &observed, 1, &prior, 15, rule, crate::Device::Auto).unwrap(); - - assert_eq!(default.theta_eap, auto.theta_eap); - assert_eq!(default.theta_sd, auto.theta_sd); - assert_eq!(default.xi_eap, auto.xi_eap); - assert_eq!(default.loglik, auto.loglik); -} - -#[test] -fn eap_map_agree_and_react_to_data() { - let (alpha, b, zeta, fid) = small_bank(); - let bk = bank(&alpha, &b, &zeta, &fid); - let prior = PriorSpec::standard(2); - // all-pass vs all-fail on dim 0 items (0, 2, 4) - let y = vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; - let observed = vec![true; 12]; - let eap = score_eap( - &bk, - &y, - &observed, - 2, - &prior, - 21, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - assert!( - eap.theta_eap[0] > eap.theta_eap[2], - "dim-0 pass > dim-0 fail" - ); - let map = score_map(&bk, &y, &observed, 2, &prior, 50, 1e-6).unwrap(); - assert!(map.converged.iter().all(|&c| c)); - // EAP and MAP should agree loosely for these smooth posteriors - for p in 0..2 { - for d in 0..2 { - let diff = (eap.theta_eap[p * 2 + d] - map.theta_map[p * 2 + d]).abs(); - assert!(diff < 0.6, "EAP/MAP disagree: {diff}"); - } - assert!(map.theta_se[p * 2].is_finite() && map.theta_se[p * 2] > 0.0); - } -} - -#[test] -fn map_does_not_relax_the_requested_gradient_tolerance() { - let alpha = [ - 0.04400557738268765, - -0.04623670215195566, - 0.2241479276551487, - 0.036715041003563896, - -0.18748428060638883, - 0.12655826921831964, - 0.456400015795548, - 0.3314783370952347, - -0.24630733253244738, - -0.44289751486611834, - -0.21814606188807326, - 0.014464092771535259, - ]; - let b = [ - -2.3250307746388343, - -0.21879166393254573, - -1.2459109472530652, - -0.7322673547034516, - -0.5442589828573099, - -0.31630015636915454, - 0.4116305363741328, - 1.0425133694426776, - -0.12853466294403426, - 1.3664634705496859, - -0.6651946734866135, - 0.3515100700930197, - ]; - let zeta = [ - 0.9034701816518086, - 0.09401229776087457, - -0.7434992493538084, - -0.9217253762584194, - -0.45772582566733916, - 0.2201951234700494, - -1.009618183538736, - -0.20917557487171307, - -0.15922500991447772, - 0.5408455846858077, - 0.2146591225063409, - 0.3553727090399214, - -0.6538286094183394, - -0.12961363369276946, - 0.7839754700613295, - 1.4934311452207607, - -1.2590655321041202, - 1.5139237747390626, - 1.3458754237823045, - 0.7813114007004275, - 0.2644556303293035, - -0.3139228145364278, - 1.4580206835369587, - 1.9602583164499647, - ]; - let factor_id: Vec = (0..12).map(|i| i % 2).collect(); - let y = [0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.9008174349330625, - factor_id: &factor_id, - model_type: ModelType::Mls2plm, - n_dims: 2, - latent_dim: 2, - eps_distance: 1e-8, - }; - let prior = PriorSpec { - mean: vec![0.3, -0.2], - sd: vec![1.2, 0.8], - }; - - let map = score_map(&bank, &y, &[true; 12], 1, &prior, 100, 1e-12).unwrap(); - assert!( - !map.converged[0], - "a failed line search must not relax tol=1e-12 to the internal 1e-4 scale" - ); -} - -#[test] -fn map_theta_se_uses_the_full_distance_observed_information() { - let alpha = [0.0]; - let b = [0.2]; - let zeta = [0.7]; - let factor_id = [0usize]; - let bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &factor_id, - model_type: ModelType::Mls2plm, - n_dims: 1, - latent_dim: 1, - eps_distance: 0.1, - }; - let map = score_map( - &bank, - &[1.0], - &[true], - 1, - &PriorSpec::standard(1), - 100, - 1e-6, - ) - .unwrap(); - let theta = map.theta_map[0]; - let xi = map.xi_map[0]; - let diff = xi - zeta[0]; - let dist = (bank.eps_distance + diff * diff).sqrt(); - let eta = theta + b[0] - dist; - let probability = 1.0 / (1.0 + (-eta).exp()); - let residual = 1.0 - probability; - let weight = probability * (1.0 - probability); - let derivative = -diff / dist; - let second_derivative = -(1.0 / dist - diff * diff / dist.powi(3)); - let info_tt = weight + 1.0; - let info_tx = weight * derivative; - let info_xx = weight * derivative * derivative - residual * second_derivative + 1.0; - let expected_se = (info_xx / (info_tt * info_xx - info_tx * info_tx)).sqrt(); - - assert!(map.converged[0]); - assert!( - (map.theta_se[0] - expected_se).abs() < 1e-8, - "theta SE {} omitted nonlinear distance curvature; expected {expected_se}", - map.theta_se[0] - ); -} - -#[test] -fn prior_shift_moves_scores() { - let (alpha, b, zeta, fid) = small_bank(); - let bk = bank(&alpha, &b, &zeta, &fid); - let empty_y = vec![0.0; 6]; - let none_obs = vec![false; 6]; - let base = score_eap_device( - &bk, - &empty_y, - &none_obs, - 1, - &PriorSpec::standard(2), - 15, - XiRule::GaussHermite { q_xi: 7 }, - crate::Device::Cpu, - ) - .unwrap(); - assert!(base.theta_eap[0].abs() < 1e-9, "no data -> prior mean"); - let shifted_prior = PriorSpec { - mean: vec![0.7, -0.2], - sd: vec![1.0, 1.0], - }; - let shifted = score_eap_device( - &bk, - &empty_y, - &none_obs, - 1, - &shifted_prior, - 15, - XiRule::GaussHermite { q_xi: 7 }, - crate::Device::Cpu, - ) - .unwrap(); - assert!((shifted.theta_eap[0] - 0.7).abs() < 1e-9); - assert!((shifted.theta_eap[1] + 0.2).abs() < 1e-9); -} - -#[test] -fn lord_wingersky_sums_to_one_and_matches_enumeration() { - let probs = vec![0.3, 0.6, 0.2, 0.8, 0.5, 0.5]; - let f = lord_wingersky(&probs, 3, 2); - for x in 0..2 { - let total: f64 = (0..4).map(|r| f[r * 2 + x]).sum(); - assert!((total - 1.0).abs() < 1e-12); - } - // enumeration for node 0: p = (0.3, 0.2, 0.5) - let (p1, p2, p3) = (0.3, 0.2, 0.5); - let expect0 = (1.0 - p1) * (1.0 - p2) * (1.0 - p3); - assert!((f[0] - expect0).abs() < 1e-12); - let expect3 = p1 * p2 * p3; - assert!((f[3 * 2] - expect3).abs() < 1e-12); -} - -#[test] -fn eapsum_tables_are_monotone_in_score() { - let (alpha, b, zeta, fid) = small_bank(); - let bk = bank(&alpha, &b, &zeta, &fid); - let tables = eapsum_tables( - &bk, - &PriorSpec::standard(2), - 21, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - assert_eq!(tables.len(), 2); - for tab in &tables { - assert_eq!(tab.eap.len(), tab.n_items_dim + 1); - let total: f64 = tab.score_prob.iter().sum(); - assert!((total - 1.0).abs() < 1e-9, "score probs must sum to 1"); - for s in 1..tab.eap.len() { - assert!( - tab.eap[s] > tab.eap[s - 1] - 1e-9, - "EAPsum must be nondecreasing in the summed score" - ); - } - } -} - -#[test] -fn eapsum_lookup_runs_in_rust_and_rejects_incomplete_patterns() { - let (alpha, b, zeta, fid) = small_bank(); - let bk = bank(&alpha, &b, &zeta, &fid); - let prior = PriorSpec::standard(2); - let tables = eapsum_tables_device( - &bk, - &prior, - 21, - XiRule::GaussHermite { q_xi: 7 }, - crate::Device::Cpu, - ) - .unwrap(); - let y = vec![ - 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, // score (3, 0) - 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, // score (0, 3) - ]; - let observed = vec![true; y.len()]; - let result = - score_eapsum_device(&y, &observed, 2, &fid, 2, &tables, crate::Device::Cpu).unwrap(); - assert_eq!( - result.theta_eap, - vec![ - tables[0].eap[3], - tables[1].eap[0], - tables[0].eap[0], - tables[1].eap[3] - ] - ); - assert_eq!( - result.theta_sd, - vec![ - tables[0].sd[3], - tables[1].sd[0], - tables[0].sd[0], - tables[1].sd[3] - ] - ); - assert_eq!(result.n_observed, vec![6, 6]); - - let mut incomplete = observed; - incomplete[0] = false; - let error = - score_eapsum_device(&y, &incomplete, 2, &fid, 2, &tables, crate::Device::Cpu).unwrap_err(); - assert!(error.contains("complete responses")); -} - -#[test] -fn multilevel_marginal_prior_widens_sd() { - let (alpha, b, zeta, fid) = small_bank(); - let bk = bank(&alpha, &b, &zeta, &fid); - let sigma_u = 0.8_f64; - let marginal_prior = PriorSpec { - mean: vec![0.0; 2], - sd: vec![(1.0 + sigma_u * sigma_u).sqrt(); 2], - }; - let t1 = eapsum_tables( - &bk, - &PriorSpec::standard(2), - 15, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - let t2 = eapsum_tables(&bk, &marginal_prior, 15, XiRule::GaussHermite { q_xi: 7 }).unwrap(); - // wider prior -> more extreme conversion at the top score - let top1 = *t1[0].eap.last().unwrap(); - let top2 = *t2[0].eap.last().unwrap(); - assert!( - top2 > top1, - "marginal multilevel prior should widen the scale" - ); -} - -#[test] -fn rejects_bad_inputs() { - let (alpha, b, zeta, fid) = small_bank(); - let bk = bank(&alpha, &b, &zeta, &fid); - let prior = PriorSpec::standard(2); - assert!(score_eap( - &bk, - &[0.0; 5], - &[true; 5], - 1, - &prior, - 21, - XiRule::GaussHermite { q_xi: 7 } - ) - .is_err()); - let bad_prior = PriorSpec { - mean: vec![0.0], - sd: vec![1.0], - }; - assert!(score_eap( - &bk, - &[0.0; 6], - &[true; 6], - 1, - &bad_prior, - 21, - XiRule::GaussHermite { q_xi: 7 } - ) - .is_err()); - let neg_sd = PriorSpec { - mean: vec![0.0; 2], - sd: vec![1.0, -1.0], - }; - assert!(eapsum_tables(&bk, &neg_sd, 21, XiRule::GaussHermite { q_xi: 7 }).is_err()); -} - -#[test] -fn scoring_public_boundaries_and_interaction_paths() { - assert_eq!(lord_wingersky(&[], 0, 3), vec![1.0, 1.0, 1.0]); - assert!(solve_sym(vec![0.0, 0.0, 0.0, 0.0], vec![1.0, 1.0], 2).is_none()); - let swapped = solve_sym(vec![1.0, 2.0, 3.0, 4.0], vec![1.0, 0.0], 2).unwrap(); - assert!(swapped.iter().all(|value| value.is_finite())); - - let alpha = [0.0, 0.2, -0.1]; - let b = [-0.5, 0.0, 0.5]; - let zeta = [0.2, -0.1, 0.3]; - let factor = [0usize, 0, 0]; - let y = [0.0, 1.0, 1.0]; - let observed = [true, false, true]; - let prior = PriorSpec::standard(1); - for model_type in [ModelType::Mls2plm, ModelType::Bifac2plm, ModelType::Mirt] { - let bk = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &factor, - model_type, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let map = score_map(&bk, &y, &observed, 1, &prior, 10, 1e-6).unwrap(); - assert!(map.log_posterior[0].is_finite()); - let (item, test) = bank_information(&bk, &[0.25], &[0.1], 1).unwrap(); - assert!(item.iter().chain(&test).all(|value| value.is_finite())); - let pv = plausible_values( - &bk, - &y, - &observed, - 1, - &prior, - 7, - XiRule::GaussHermite { q_xi: 7 }, - 2, - 0, - ) - .unwrap(); - assert_eq!(pv.len(), 2); - } - - let plain_bank = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - assert!(score_map(&plain_bank, &y, &observed, 1, &prior, 0, 1e-6).is_err()); - assert!(score_map(&plain_bank, &y, &observed, 1, &prior, 10, f64::NAN).is_err()); - assert!(cat_next_item( - &plain_bank, - &y[..2], - &observed[..2], - &prior, - 7, - XiRule::GaussHermite { q_xi: 7 }, - ) - .is_err()); - - // Finite but extreme calibration can underflow a summed-score cell to zero. The documented - // prior fallback must remain finite and deterministic for that representational boundary. - let extreme_b = [1e308]; - let one_alpha = [0.0]; - let one_zeta = [0.0]; - let one_factor = [0usize]; - let extreme_bank = ItemBank { - alpha: &one_alpha, - b: &extreme_b, - zeta: &one_zeta, - tau: 0.0, - factor_id: &one_factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }; - let extreme_table = eapsum_tables( - &extreme_bank, - &PriorSpec::standard(1), - 7, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - assert_eq!(extreme_table[0].eap[0], 0.0); - assert_eq!(extreme_table[0].sd[0], 1.0); - - let two_dim_factor = [0usize, 0, 0]; - let two_dim = ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &two_dim_factor, - model_type: ModelType::Mirt, - n_dims: 2, - latent_dim: 1, - eps_distance: 1e-8, - }; - let empty_dimension = eapsum_tables( - &two_dim, - &PriorSpec::standard(2), - 7, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - assert_eq!(empty_dimension[1].n_items_dim, 0); - let cat = cat_next_item( - &two_dim, - &y, - &[true; 3], - &PriorSpec::standard(2), - 7, - XiRule::GaussHermite { q_xi: 7 }, - ) - .unwrap(); - assert!(cat.ranked_items.is_empty()); - - let bad_mean = PriorSpec { - mean: vec![f64::NAN], - sd: vec![1.0], - }; - assert!(score_eap( - &ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }, - &y, - &observed, - 1, - &bad_mean, - 7, - XiRule::GaussHermite { q_xi: 7 }, - ) - .is_err()); - assert!(bank_information( - &ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }, - &[], - &[], - 1, - ) - .is_err()); - assert!(plausible_values( - &ItemBank { - alpha: &alpha, - b: &b, - zeta: &zeta, - tau: 0.0, - factor_id: &factor, - model_type: ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - }, - &y, - &observed, - 1, - &prior, - 7, - XiRule::GaussHermite { q_xi: 7 }, - 0, - 1, - ) - .is_err()); - assert!(empirical_reliability(&[0.0], &[1.0], 1, 1).is_err()); - - assert!(score_wle(&[], &[], &[], &[], &[], &[], 0, 6.0, 1e-6).is_err()); - assert!(score_wle(&[1.0], &[], &[0.0], &[1.0], &[0.0], &[true], 1, 6.0, 1e-6).is_err()); - assert!(score_wle( - &[f64::NAN], - &[0.0], - &[0.0], - &[1.0], - &[0.0], - &[true], - 1, - 6.0, - 1e-6, - ) - .is_err()); - assert!(score_wle( - &[1.0], - &[0.0], - &[0.5], - &[0.5], - &[0.0], - &[true], - 1, - 6.0, - 1e-6 - ) - .is_err()); - assert!(score_wle( - &[1.0], - &[0.0], - &[0.0], - &[1.0], - &[0.0], - &[true], - 1, - 0.0, - 1e-6 - ) - .is_err()); - assert!(score_wle(&[1.0], &[0.0], &[0.0], &[1.0], &[0.0], &[true], 1, 6.0, 0.0).is_err()); - let no_data = score_wle( - &[1.0], - &[0.0], - &[0.0], - &[1.0], - &[0.0], - &[false], - 1, - 6.0, - 1e-6, - ) - .unwrap(); - assert!(no_data.theta[0].is_nan() && no_data.boundary[0]); -} diff --git a/tests/unit/scoring_validate_branch_tests.rs b/tests/unit/scoring_validate_branch_tests.rs deleted file mode 100644 index c5c464f3b..000000000 --- a/tests/unit/scoring_validate_branch_tests.rs +++ /dev/null @@ -1,93 +0,0 @@ -use super::*; -use crate::nodes::XiRule; - -fn ok_bank<'a>(alpha: &'a [f64], b: &'a [f64], zeta: &'a [f64], fid: &'a [usize]) -> ItemBank<'a> { - ItemBank { - alpha, - b, - zeta, - tau: -30.0, - factor_id: fid, - model_type: crate::ModelType::Mirt, - n_dims: 1, - latent_dim: 1, - eps_distance: 1e-8, - } -} - -#[test] -fn validate_bank_rejects_malformed_banks() { - let y = vec![0.0; 3]; - let obs = vec![true; 3]; - let prior = PriorSpec::standard(1); - let rule = XiRule::GaussHermite { q_xi: 7 }; - // inconsistent alpha length - let (a, b, z, f) = (vec![0.0; 2], vec![0.0; 3], vec![0.0; 3], vec![0usize; 3]); - assert!(score_eap(&ok_bank(&a, &b, &z, &f), &y, &obs, 1, &prior, 7, rule).is_err()); - // factor_id out of range (>= n_dims) - let (a, b, z, f) = (vec![0.0; 3], vec![0.0; 3], vec![0.0; 3], vec![5usize, 0, 0]); - assert!(score_eap(&ok_bank(&a, &b, &z, &f), &y, &obs, 1, &prior, 7, rule).is_err()); - // latent_dim zero - let (a, b, z, f) = (vec![0.0; 3], vec![0.0; 3], vec![0.0; 0], vec![0usize; 3]); - let mut bk = ok_bank(&a, &b, &z, &f); - bk.latent_dim = 0; - assert!(score_eap(&bk, &y, &obs, 1, &prior, 7, rule).is_err()); - // eps_distance non-positive - let (a, b, z, f) = (vec![0.0; 3], vec![0.0; 3], vec![0.0; 3], vec![0usize; 3]); - let mut bk = ok_bank(&a, &b, &z, &f); - bk.eps_distance = 0.0; - assert!(score_eap(&bk, &y, &obs, 1, &prior, 7, rule).is_err()); - // y/observed length mismatch - let bk = ok_bank(&a, &b, &z, &f); - assert!(score_eap(&bk, &vec![0.0; 6], &vec![true; 6], 1, &prior, 7, rule).is_err()); - - // Public Rust scoring must reject non-finite calibrated parameters rather - // than returning an apparently successful result filled with NaNs. - let mut bad_b = b.clone(); - bad_b[0] = f64::NAN; - assert!(score_eap(&ok_bank(&a, &bad_b, &z, &f), &y, &obs, 1, &prior, 7, rule).is_err()); - let mut bk = ok_bank(&a, &b, &z, &f); - bk.tau = f64::INFINITY; - assert!(score_eap(&bk, &y, &obs, 1, &prior, 7, rule).is_err()); - let mut bk = ok_bank(&a, &b, &z, &f); - bk.eps_distance = f64::NAN; - assert!(score_eap(&bk, &y, &obs, 1, &prior, 7, rule).is_err()); - - // Observed responses are dichotomous. NaN and other categories were - // previously classified as zero by index_responses. - for bad in [f64::NAN, f64::INFINITY, -1.0, 2.0] { - let mut bad_y = y.clone(); - bad_y[0] = bad; - assert!(score_eap(&ok_bank(&a, &b, &z, &f), &bad_y, &obs, 1, &prior, 7, rule).is_err()); - } - - // Adversarial dimensions must return an error instead of overflowing - // n_persons * n_items in a debug-build panic. - assert!(score_eap( - &ok_bank(&a, &b, &z, &f), - &[], - &[], - usize::MAX, - &prior, - 7, - rule - ) - .is_err()); - - let overflow_alpha = [0.0, 0.0]; - let overflow_b = [0.0, 0.0]; - let overflow_factor = [0usize, 0]; - let overflow_bank = ItemBank { - alpha: &overflow_alpha, - b: &overflow_b, - zeta: &[], - tau: 0.0, - factor_id: &overflow_factor, - model_type: crate::ModelType::Mirt, - n_dims: 1, - latent_dim: usize::MAX, - eps_distance: 1e-8, - }; - assert!(score_eap(&overflow_bank, &[], &[], 0, &prior, 7, rule).is_err()); - assert!(score_eap(&ok_bank(&a, &b, &z, &f), &y, &obs, 1, &prior, 3, rule).is_err()); -} diff --git a/tests/unit/scoring_wle_poly_tests.rs b/tests/unit/scoring_wle_poly_tests.rs deleted file mode 100644 index ba57859d3..000000000 --- a/tests/unit/scoring_wle_poly_tests.rs +++ /dev/null @@ -1,530 +0,0 @@ -use super::{poly_wle_terms, score_wle, score_wle_poly}; -use crate::poly::{gpcm_logprobs, grm_logprobs, poly_item_information, PolyModel}; - -/// `I'(theta)` obtained by central-differencing the SHIPPED [`poly_item_information`], which is a -/// different code path from [`poly_wle_terms`]: the former sums `(v_k - v_{k+1})^2 / P_k` (GRM) or -/// `a^2 Var(k)` (GPCM), the latter accumulates `sum_k P_k r1_k^2` from the division-free ratios. -/// Both sides of the A1 comparison are therefore crate values, neither recomputed in the test. -fn info_prime_fd(theta: f64, a: f64, cat: &[f64], model: PolyModel) -> f64 { - let h = 1e-5; - (poly_item_information(theta + h, a, cat, model) - poly_item_information(theta - h, a, cat, model)) - / (2.0 * h) -} - -/// A1. `J == I'` for both shipped families, at asymmetric non-centred parameters and off-centre theta. -/// -/// This identity is a PROVED property of these two families (see the verification note on -/// `score_wle_poly`): `J - I' = -E[l' l'']`, which vanishes because the GPCM's `l''` is -/// category-free and the GRM's sum telescopes through `v_0 = v_K = 0`. It is used here as an -/// ORACLE ONLY -- the implementation computes `J` directly and never substitutes `I'`. -/// -/// NOTE ON WHAT THIS CANNOT SEE: because `J = I'` is EXACT for both shipped families, an -/// implementation that replaced `J` by a numerical derivative of `I` would be behaviour-preserving -/// here and no polytomous test can detect it. The discriminating anchors for that substitution are in -/// the dichotomous suite, where a lower asymptote breaks the identity — -/// `wle_estimating_equation_root` (3PL) and `wle_information_derivative_identity`. A NEW family added -/// later must re-derive `J` rather than assume it. -/// -/// The reference magnitudes are PINNED rather than narrated, which also subsumes a non-vacuity check -/// (a zeroed `jterm` fails them) and freezes the fixture against silent parameter edits. Values -/// measured on these exact fixtures at theta = +0.9: GRM K=5 `J = -0.068761`, GPCM K=4 -/// `J = -0.451118`. -/// -/// kills: sign flip on the `jterm` accumulator; a dropped category term; a `v`/`s` index off-by-one; -/// a silent edit to the fixture parameters; a symmetric fixture that passes because both sides are 0. -#[test] -fn poly_wle_j_equals_info_derivative() { - let cases: [(PolyModel, f64, Vec, f64); 2] = [ - (PolyModel::Grm, 2.1, vec![2.3, 0.4, -0.7, -2.9], -0.068761), - (PolyModel::Gpcm, 1.3, vec![0.4, -0.3, -1.9], -0.451118), - ]; - for (model, a, cat, j_at_0_9) in cases { - for theta in [-1.6, -0.3, 0.9, 2.4] { - let (info, jterm) = poly_wle_terms(theta, a, &cat, model); - let iprime = info_prime_fd(theta, a, &cat, model); - let scale = jterm.abs().max(iprime.abs()).max(1e-8); - assert!( - (jterm - iprime).abs() / scale < 1e-6, - "{model:?} theta={theta}: J={jterm} but I'={iprime}" - ); - // the shipped information must also agree with the accumulator's own I - let info_ref = poly_item_information(theta, a, &cat, model); - assert!( - (info - info_ref).abs() / info_ref.max(1e-12) < 1e-12, - "{model:?} theta={theta}: I={info} vs poly_item_information={info_ref}" - ); - } - // the reference magnitude is pinned, not merely asserted to be "not tiny" - let (_, j_mid) = poly_wle_terms(0.9, a, &cat, model); - assert!( - (j_mid - j_at_0_9).abs() < 1e-5, - "{model:?}: J at theta=0.9 is {j_mid}, expected {j_at_0_9}" - ); - } -} - -/// A2. A LEMMA ABOUT THE ORACLE, NOT A TEST OF THE CODE — and the distinction is the whole point. -/// -/// It would be natural to claim this "kills an implementation that sets `J := I'`". IT DOES NOT, AND -/// NOTHING HERE CAN: `J = I'` is an exact identity for both shipped families, so substituting one for -/// the other is BEHAVIOUR-PRESERVING for GRM and GPCM and no polytomous fixture can distinguish them. -/// The discriminating anchor for that substitution lives in the DICHOTOMOUS suite, where `c > 0` -/// breaks the identity: `wle_estimating_equation_root` (3PL, `c = 0.2`) and -/// `wle_information_derivative_identity` both fail under it. An earlier version of this comment -/// asserted the kill anyway, which was false. -/// -/// What this DOES establish is that A1's oracle is non-trivial — `J = I'` is a property of these two -/// families and not an arithmetic tautology — by exhibiting two response functions where it fails: -/// a graded model with PER-BOUNDARY slopes (the `a^3` cannot be factored out of the telescoping sum) -/// and the 3PL (a lower asymptote). Both are computed here from finite differences because the crate -/// has no such family; that is exactly why they cannot test crate code. -/// -/// The final block IS wired to the crate: it asserts the shipped GRM satisfies the identity on the -/// same `theta` where the per-boundary variant violates it, so the contrast is between a crate value -/// and a local one rather than between two local ones. -#[test] -fn poly_wle_identity_is_family_specific_not_universal() { - // (i) graded response with per-boundary slopes a_j -- J and I' must DISAGREE - let a = [1.3f64, 0.7, 1.9]; - let beta = [1.6f64, 0.1, -1.2]; - let sig = |x: f64| 1.0 / (1.0 + (-x).exp()); - // P_k(theta) for the 4-category per-boundary-slope graded model - let probs = |t: f64| -> Vec { - let s: Vec = (0..3).map(|j| sig(a[j] * t + beta[j])).collect(); - vec![1.0 - s[0], s[0] - s[1], s[1] - s[2], s[2]] - }; - let h = 1e-4; - let terms = |t: f64| -> (f64, f64) { - let (p0, pm, pp) = (probs(t), probs(t - h), probs(t + h)); - let (mut info, mut jterm) = (0.0, 0.0); - for k in 0..4 { - let d1 = (pp[k] - pm[k]) / (2.0 * h); - let d2 = (pp[k] - 2.0 * p0[k] + pm[k]) / (h * h); - info += d1 * d1 / p0[k]; - jterm += d1 * d2 / p0[k]; - } - (info, jterm) - }; - // measured relative gaps: 0.920 at theta = -1.6, 1.171 at theta = +0.9 - for theta in [-1.6f64, 0.9] { - let (_, j) = terms(theta); - let iprime = (terms(theta + h).0 - terms(theta - h).0) / (2.0 * h); - let scale = j.abs().max(iprime.abs()).max(1e-8); - assert!( - (j - iprime).abs() / scale > 0.5, - "per-boundary slopes must BREAK J == I' (theta={theta}): J={j} I'={iprime}" - ); - } - - // (ii) the shipped 3PL path: c > 0 breaks the identity too - let (a3, b3, c3) = (1.5f64, 0.2, 0.25); - let p3 = |t: f64| c3 + (1.0 - c3) * sig(a3 * (t - b3)); - let terms3 = |t: f64| -> (f64, f64) { - let (p0, pm, pp) = (p3(t), p3(t - h), p3(t + h)); - let d1 = (pp - pm) / (2.0 * h); - let d2 = (pp - 2.0 * p0 + pm) / (h * h); - let pq = p0 * (1.0 - p0); - (d1 * d1 / pq, d1 * d2 / pq) - }; - let theta = -1.6; - let (_, j3) = terms3(theta); - let ip3 = (terms3(theta + h).0 - terms3(theta - h).0) / (2.0 * h); - // measured relative gap 0.474 at c = 0.25, theta = -1.6 - assert!( - (j3 / ip3 - 1.0).abs() > 0.3, - "a lower asymptote must break J == I': J={j3} I'={ip3}" - ); - - // ...and on the SAME theta the SHIPPED graded family does satisfy it. This is the only assertion - // in this test that reads crate output, and it is what makes the contrast meaningful rather than - // a statement about the test's own arithmetic: the identity holds for what we ship and fails for - // the two variants above. - let (_, j_shipped) = poly_wle_terms(-1.6, 1.3, &beta, PolyModel::Grm); - let ip_shipped = info_prime_fd(-1.6, 1.3, &beta, PolyModel::Grm); - let scale = j_shipped.abs().max(ip_shipped.abs()).max(1e-8); - assert!( - (j_shipped - ip_shipped).abs() / scale < 1e-6, - "the shipped GRM must satisfy J == I' where the per-boundary variant does not: \ - J={j_shipped} I'={ip_shipped}" - ); -} - -/// A3. CROSS-PATH PIN AT K = 2. At two categories both polytomous families ARE the 2PL, so -/// `score_wle_poly` must reproduce the shipped dichotomous `score_wle`. -/// -/// Non-discriminating as a DESIGN argument -- it says nothing about which `J` formula is right, -/// because at K = 2 every candidate agrees. It is a PLUMBING pin, and it is the only anchor that -/// catches the whole layout class. -/// -/// kills: a dropped factor of 1/2; a category-index off-by-one; an item/person layout transpose; a -/// missing chain-rule `a`; a `cat_params` block-stride bug. -#[test] -fn poly_wle_reduces_to_dichotomous_wle_at_two_categories() { - // unequal slopes and asymmetric difficulties: a dropped `a` would be invisible at a = 1 - let a = [1.4f64, 0.8, 2.1, 1.1, 0.6]; - let b = [-0.9f64, 0.3, 1.4, -1.7, 0.8]; - let n_items = a.len(); - let n_persons = 4usize; - // off-centre patterns, not all-same - let y_bin: Vec = vec![ - 1.0, 1.0, 0.0, 1.0, 0.0, // - 0.0, 1.0, 0.0, 0.0, 0.0, // - 1.0, 1.0, 1.0, 1.0, 0.0, // - 1.0, 0.0, 0.0, 1.0, 1.0, - ]; - let y_cat: Vec = y_bin.iter().map(|v| *v as usize).collect(); - let c = vec![0.0; n_items]; - let d = vec![1.0; n_items]; - let observed = vec![true; n_persons * n_items]; - let dich = score_wle(&a, &b, &c, &d, &y_bin, &observed, n_persons, 6.0, 1e-10).unwrap(); - - // GPCM at K=2: psi = [0, a*theta + c_1] with c_1 = -a*b => logit = a(theta - b) - let gpcm_cat: Vec = (0..n_items).map(|i| -a[i] * b[i]).collect(); - // GRM at K=2: single threshold beta_0 with logit = a*theta + beta_0 => beta_0 = -a*b - let grm_cat: Vec = gpcm_cat.clone(); - for (model, cat) in [(PolyModel::Gpcm, &gpcm_cat), (PolyModel::Grm, &grm_cat)] { - let poly = score_wle_poly( - &y_cat, None, n_persons, n_items, 2, &a, cat, model, 6.0, 1e-10, - ) - .unwrap(); - for p in 0..n_persons { - assert!( - (poly.theta[p] - dich.theta[p]).abs() < 1e-6, - "{model:?} person {p}: poly theta {} != dichotomous {}", - poly.theta[p], - dich.theta[p] - ); - assert!( - (poly.se[p] - dich.se[p]).abs() < 1e-6, - "{model:?} person {p}: poly se {} != dichotomous {}", - poly.se[p], - dich.se[p] - ); - } - } -} - -/// A4. GLOBAL-MODE FIXTURE, GPCM. Both polytomous log-likelihoods are log-concave, but the Warm -/// WEIGHT is not, so `Phi` is genuinely multimodal. On this bank an independent high-precision scan -/// found stationary points at `+0.098789` (`Phi = -4.720843`), `+0.377438` (a minimum) and -/// `+1.331420` (`Phi = -3.878708`, the GLOBAL maximum, `dPhi = 0.842135`); `max lnL'' = -5.55e-5`, -/// confirming the second mode comes entirely from the weight. -/// -/// kills: replacing the grid scan with a single bracketed root, which converges to `+0.0988` and -/// errs by 1.23 logits. A residual-only check cannot catch this -- `g` vanishes at ALL THREE roots. -#[test] -fn poly_wle_gpcm_takes_the_global_mode_not_the_first_root() { - let slope = [2.42f64, 1.09, 1.53]; - let cat_params = [ - -3.78f64, 0.50, 3.70, -1.91, // - 3.11, -2.12, -2.68, -1.68, // - -0.35, -0.09, -3.78, 2.17, - ]; - let y = [3usize, 2, 4]; - let out = score_wle_poly( - &y, None, 1, 3, 5, &slope, &cat_params, PolyModel::Gpcm, 8.0, 1e-10, - ) - .unwrap(); - assert!( - (out.theta[0] - 1.331420).abs() < 1e-3, - "expected the global mode 1.331420, got {}", - out.theta[0] - ); - assert!( - (out.theta[0] - 0.098789).abs() > 0.1, - "returned the leftmost stationary point 0.098789, i.e. a bracketed-root solver" - ); - assert!(!out.boundary[0]); -} - -/// A5. GLOBAL-MODE FIXTURE, GRM. Same failure mode for the other family, where the error is larger -/// (2.36 logits) and so would be missed by a GPCM-only fixture. High-precision scan: stationary -/// points at `+3.340794` (`Phi = -9.846605`), `+3.749233` (minimum) and `+5.701184` -/// (`Phi = -9.294478`, GLOBAL, `dPhi = 0.552127`). -/// -/// kills: the same bracketed-root substitution, in the GRM branch specifically. -#[test] -fn poly_wle_grm_takes_the_global_mode_not_the_first_root() { - let slope = [1.164f64, 1.568]; - let cat_params = [ - 0.603f64, 0.230, -2.244, // - -3.301, -3.719, -9.479, - ]; - let y = [1usize, 3]; - let out = score_wle_poly( - &y, None, 1, 2, 4, &slope, &cat_params, PolyModel::Grm, 8.0, 1e-10, - ) - .unwrap(); - assert!( - (out.theta[0] - 5.701184).abs() < 1e-3, - "expected the global mode 5.701184, got {}", - out.theta[0] - ); - assert!( - (out.theta[0] - 3.340794).abs() > 0.1, - "returned the leftmost stationary point 3.340794, i.e. a bracketed-root solver" - ); -} - -/// A6. ESTIMATING-EQUATION RESIDUAL, from FINITE DIFFERENCES of the log-probability routines ONLY. -/// The closed forms the implementation ships (`r1`, `r2`) are never used here, so an analytic sign -/// error in the SCORE term -- which A1 cannot see, because A1 only pins `I` and `J` -- shows up as a -/// non-zero residual at the returned theta. -/// -/// Cannot catch a wrong-MODE error (`g` vanishes at every stationary point); that is A4/A5. -/// -/// kills: a sign error or missing chain-rule factor in `r1`; a wrong category index on the score. -#[test] -fn poly_wle_satisfies_the_estimating_equation_by_finite_difference() { - let h = 1e-4; - let cases: [(PolyModel, usize, Vec, Vec, Vec); 2] = [ - ( - PolyModel::Grm, - 4, - vec![1.7, 0.9, 1.3], - vec![1.6, 0.1, -1.2, 2.4, -0.5, -1.9, 0.8, -0.2, -2.6], - vec![1, 3, 2], - ), - ( - PolyModel::Gpcm, - 4, - vec![1.3, 2.0, 0.7], - vec![0.4, -0.3, -1.9, 1.1, 0.2, -0.8, -0.6, 1.4, 0.3], - vec![2, 0, 3], - ), - ]; - for (model, n_cat, slope, cat_params, y) in cases { - let n_items = slope.len(); - let out = score_wle_poly( - &y, None, 1, n_items, n_cat, &slope, &cat_params, model, 8.0, 1e-11, - ) - .unwrap(); - let theta = out.theta[0]; - // rebuild g from numeric derivatives of the shipped log-probability routines - let probs = |i: usize, t: f64| -> Vec { - let pars = &cat_params[i * (n_cat - 1)..(i + 1) * (n_cat - 1)]; - let base = slope[i] * t; - match model { - PolyModel::Grm => grm_logprobs(base, pars).iter().map(|l| l.exp()).collect(), - PolyModel::Gpcm => { - let scores: Vec = (0..n_cat).map(|c| c as f64).collect(); - let mut ints = vec![0.0; n_cat]; - ints[1..].copy_from_slice(pars); - gpcm_logprobs(base, &scores, &ints) - .iter() - .map(|l| l.exp()) - .collect() - } - } - }; - let (mut score, mut info, mut jterm) = (0.0f64, 0.0f64, 0.0f64); - for i in 0..n_items { - let (p0, pm, pp) = (probs(i, theta), probs(i, theta - h), probs(i, theta + h)); - for k in 0..n_cat { - let d1 = (pp[k] - pm[k]) / (2.0 * h); - let d2 = (pp[k] - 2.0 * p0[k] + pm[k]) / (h * h); - info += d1 * d1 / p0[k]; - jterm += d1 * d2 / p0[k]; - if k == y[i] { - score += d1 / p0[k]; - } - } - } - let g = score + jterm / (2.0 * info); - assert!( - g.abs() < 1e-4, - "{model:?}: estimating function {g} != 0 at the returned theta {theta}" - ); - } -} - -/// A7. FINITENESS AND CORRECTION MAGNITUDE on an ASYMMETRIC bank. The maximum-likelihood estimate -/// diverges for the all-lowest and all-highest patterns; the WLE must stay finite and interior. -/// A symmetric bank would put both estimates at 0 and make the comparison vacuous, so every slope -/// differs and no threshold set is mirrored. -/// -/// The "MLE diverges" claim is asserted in its checkable form: the UNWEIGHTED score, accumulated -/// independently here, keeps a constant sign across the whole grid, so it has no interior root. -/// -/// kills: a zeroed or sign-flipped correction term; a boundary-flag inversion. -#[test] -fn poly_wle_is_finite_where_the_mle_diverges() { - let cases: [(PolyModel, usize, Vec, Vec); 2] = [ - ( - PolyModel::Gpcm, - 4, - vec![1.3, 0.9, 1.8, 0.7, 1.5], - vec![ - 0.4, -0.3, -1.9, 1.2, 0.1, -0.7, -0.5, 0.9, 1.6, 2.1, -1.1, 0.3, -0.8, 0.6, -2.2, - ], - ), - ( - PolyModel::Grm, - 4, - vec![1.3, 0.9, 1.8, 0.7, 1.5], - vec![ - 1.6, 0.1, -1.2, 2.4, -0.5, -1.9, 0.8, -0.2, -2.6, 1.1, 0.4, -0.9, 2.0, 0.7, -1.4, - ], - ), - ]; - for (model, n_cat, slope, cat_params) in cases { - let n_items = slope.len(); - for (pattern, label) in [(0usize, "all-lowest"), (n_cat - 1, "all-highest")] { - let y = vec![pattern; n_items]; - let out = score_wle_poly( - &y, None, 1, n_items, n_cat, &slope, &cat_params, model, 8.0, 1e-10, - ) - .unwrap(); - assert!( - out.theta[0].is_finite(), - "{model:?} {label}: WLE must be finite where the MLE diverges" - ); - assert!( - !out.boundary[0] && out.theta[0].abs() <= 7.0, - "{model:?} {label}: theta {} hit the bound", - out.theta[0] - ); - // the MLE really does diverge here: the unweighted score never changes sign - let sign_at = |t: f64| -> f64 { - let mut score = 0.0f64; - for i in 0..n_items { - let pars = &cat_params[i * (n_cat - 1)..(i + 1) * (n_cat - 1)]; - let (_, r1, _) = super::poly_wle_ratios(t, slope[i], pars, model); - score += r1[y[i]]; - } - score - }; - let first = sign_at(-7.0).signum(); - for step in 1..=28 { - let t = -7.0 + 0.5 * step as f64; - assert!( - sign_at(t).signum() == first, - "{model:?} {label}: the unweighted score changed sign at {t}, so the MLE does \ - NOT diverge and this fixture proves nothing" - ); - } - } - } -} - -/// A8. The `(n_cat - 1)` grid factor is a derived worst-case margin whose ONLY observable -/// consequence is when the work limit is reached, so that is where it is pinned: a configuration -/// that crosses 65,536 intervals WITH the factor must be refused, while the same `(theta_bound, -/// max|a|)` at `n_cat = 2` must not be. -/// -/// kills: silently dropping the `(n_cat - 1)` factor. -#[test] -fn poly_wle_grid_factor_scales_with_category_count() { - // 2 * theta_bound * a * INTERVALS_PER_LOGIT * (K - 1) against MAX_GRID = 65536. - // At theta_bound = 8, a = 300 the base is 19_200, so the limit falls BETWEEN K = 4 (57_600, must - // be Ok) and K = 5 (76_800, must be Err). Straddling the boundary is what pins the multiplier as - // (n_cat - 1): a merely-large K would also fail under any constant factor >= 4. - let theta_bound = 8.0; - let big_a = [300.0f64]; - let cat = |k: usize| -> Vec { (0..k - 1).map(|j| 1.0 - j as f64 * 0.5).collect() }; - assert!( - score_wle_poly( - &[2usize], None, 1, 1, 4, &big_a, &cat(4), PolyModel::Gpcm, theta_bound, 1e-10 - ) - .is_ok(), - "K=4 needs 57600 intervals and must stay within the 65536 limit" - ); - assert!( - score_wle_poly( - &[2usize], None, 1, 1, 5, &big_a, &cat(5), PolyModel::Gpcm, theta_bound, 1e-10 - ) - .is_err(), - "K=5 needs 76800 intervals and must be refused rather than silently under-resolved" - ); -} - -/// Validation is non-vacuous: each guard trips on its own, with `score_poly_eap`'s wording. -#[test] -fn poly_wle_validates() { - let slope = [1.3f64, 0.9]; - let cat = [0.4f64, -0.3, 1.1, 0.2]; - let y = [1usize, 2, 0, 1]; - let ok = |n_cat: usize, tb: f64, tol: f64| { - score_wle_poly(&y, None, 2, 2, n_cat, &slope, &cat, PolyModel::Gpcm, tb, tol) - }; - assert!(ok(3, 6.0, 1e-8).is_ok()); - assert!(ok(1, 6.0, 1e-8).is_err(), "n_cat < 2"); - assert!(ok(3, 0.0, 1e-8).is_err(), "theta_bound must be positive"); - assert!(ok(3, 6.0, 0.0).is_err(), "tol must be positive"); - // response outside 0..n_cat - let bad = [1usize, 9, 0, 1]; - assert!( - score_wle_poly(&bad, None, 2, 2, 3, &slope, &cat, PolyModel::Gpcm, 6.0, 1e-8).is_err(), - "observed category >= n_cat" - ); - // ...but an UNOBSERVED out-of-range entry is ignored rather than rejected - let mask = [true, false, true, true]; - assert!( - score_wle_poly(&bad, Some(&mask), 2, 2, 3, &slope, &cat, PolyModel::Gpcm, 6.0, 1e-8).is_ok(), - "an unobserved cell must not be validated as a response" - ); - // zero discrimination everywhere - assert!( - score_wle_poly(&y, None, 2, 2, 3, &[0.0, 0.0], &cat, PolyModel::Gpcm, 6.0, 1e-8).is_err(), - "at least one item must have nonzero discrimination" - ); - // sizes - assert!( - score_wle_poly(&y, None, 2, 2, 3, &slope, &cat[..3], PolyModel::Gpcm, 6.0, 1e-8).is_err(), - "cat_params size inconsistent" - ); -} - -/// A person with no observed items has undefined ability: `NaN` with the boundary flag, never a -/// spurious `theta = 0` that would read as an average examinee. -#[test] -fn poly_wle_reports_nan_for_a_person_with_no_observed_items() { - let slope = [1.3f64, 0.9]; - let cat = [0.4f64, -0.3, 1.1, 0.2]; - let y = [1usize, 2, 0, 1]; - let observed = [true, true, false, false]; - let out = score_wle_poly( - &y, - Some(&observed), - 2, - 2, - 3, - &slope, - &cat, - PolyModel::Gpcm, - 6.0, - 1e-10, - ) - .unwrap(); - assert!(out.theta[0].is_finite()); - assert!(out.theta[1].is_nan() && out.se[1].is_nan() && out.boundary[1]); - - // The OTHER two degenerate branches, which nothing else reaches: a bound so tight that the global - // mode lies outside it must clamp AND flag; and a near-zero-information fit must report se = NaN - // while still returning a finite theta. - let extreme = [3usize, 3]; - let tight = score_wle_poly( - &extreme, None, 1, 2, 3, &slope, &cat, PolyModel::Gpcm, 0.05, 1e-10, - ); - // n_cat = 3 so category 3 is out of range; use an in-range extreme instead - assert!(tight.is_err()); - let hi = [2usize, 2]; - let clamped = - score_wle_poly(&hi, None, 1, 2, 3, &slope, &cat, PolyModel::Gpcm, 0.05, 1e-10).unwrap(); - assert!( - clamped.boundary[0] && (clamped.theta[0].abs() - 0.05).abs() < 1e-12, - "a mode beyond theta_bound must clamp to the bound and set boundary: theta={} bnd={}", - clamped.theta[0], - clamped.boundary[0] - ); - let flat = score_wle_poly( - &hi, None, 1, 2, 3, &[1e-7, 1e-7], &cat, PolyModel::Gpcm, 6.0, 1e-10, - ) - .unwrap(); - assert!( - flat.theta[0].is_finite() && flat.se[0].is_nan(), - "a near-zero-information fit must return a finite theta with se = NaN: theta={} se={}", - flat.theta[0], - flat.se[0] - ); -} diff --git a/tests/unit/scoring_wle_tests.rs b/tests/unit/scoring_wle_tests.rs deleted file mode 100644 index cbb35ae2f..000000000 --- a/tests/unit/scoring_wle_tests.rs +++ /dev/null @@ -1,470 +0,0 @@ -use super::{finite_wle_value, item_information_4pl, refine_wle_root, score_wle}; - -fn sig(x: f64) -> f64 { - 1.0 / (1.0 + (-x).exp()) -} - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } -} - -/// The Warm estimating function `g = score + J/(2I)` recomputed INDEPENDENTLY from FINITE-DIFFERENCE -/// derivatives of `P` (no analytic `P'`/`P''`), so a sign error in the implementation's `J = P' P''` -/// term is not shared. Returns `g` at `theta`. -fn g_fd(a: &[f64], b: &[f64], c: &[f64], d: &[f64], y: &[f64], theta: f64) -> f64 { - let h = 1e-4; - let pf = |i: usize, t: f64| c[i] + (d[i] - c[i]) * sig(a[i] * (t - b[i])); - let (mut score, mut info, mut jterm) = (0.0, 0.0, 0.0); - for i in 0..a.len() { - let p0 = pf(i, theta); - let p1 = (pf(i, theta + h) - pf(i, theta - h)) / (2.0 * h); // P' by FD - let p2 = (pf(i, theta + h) - 2.0 * p0 + pf(i, theta - h)) / (h * h); // P'' by FD - let pq = p0 * (1.0 - p0); - score += (y[i] - p0) * p1 / pq; - info += p1 * p1 / pq; - jterm += p1 * p2 / pq; - } - score + jterm / (2.0 * info) -} - -/// Root anchor across {2PL, 3PL, Rasch}: the returned `theta_hat` satisfies the Warm estimating -/// equation, verified by the FD-derivative recomputation (independent of the analytic derivatives). -#[test] -fn wle_estimating_equation_root() { - let j = 10usize; - let a2: Vec = (0..j).map(|i| 0.8 + 0.09 * i as f64).collect(); - let b: Vec = (0..j).map(|i| -2.0 + 0.4 * i as f64).collect(); - let y: Vec = (0..j).map(|i| (i % 2) as f64).collect(); // mixed -> interior root - let obs = vec![true; j]; - let one = vec![1.0f64; j]; - let zero = vec![0.0f64; j]; - let d = vec![1.0f64; j]; - let c02 = vec![0.2f64; j]; - for (label, a, c) in [ - ("2PL", &a2, &zero), - ("3PL", &a2, &c02), - ("Rasch", &one, &zero), - ] { - let res = score_wle(a, &b, c, &d, &y, &obs, 1, 20.0, 1e-9).unwrap(); - assert!(!res.boundary[0], "{label}: unexpected boundary"); - let g = g_fd(a, &b, c, &d, &y, res.theta[0]); - assert!( - g.abs() < 1e-4, - "{label}: WLE root residual {g} at theta {}", - res.theta[0] - ); - // SE matches 1/sqrt(I) recomputed from item_information_4pl at the estimate - let info: f64 = (0..j) - .map(|i| { - let p = c[i] + (d[i] - c[i]) * sig(a[i] * (res.theta[0] - b[i])); - item_information_4pl(a[i], p, c[i], d[i]) - }) - .sum(); - assert!( - (res.se[0] - (1.0 / info).sqrt()).abs() < 1e-9, - "{label}: SE" - ); - } -} - -/// Finiteness (scoped to the 2PL, `c=0, d=1`): the all-correct and all-incorrect patterns — where -/// the MLE is `+/-infinity` — return FINITE, interior WLE estimates, with correct > incorrect. -#[test] -fn wle_finite_at_perfect_score_2pl() { - let j = 6usize; - let a: Vec = (0..j).map(|i| 1.0 + 0.1 * i as f64).collect(); - let b: Vec = (0..j).map(|i| -1.5 + 0.6 * i as f64).collect(); - let c = vec![0.0f64; j]; - let d = vec![1.0f64; j]; - let obs = vec![true; j]; - let all1 = vec![1.0f64; j]; - let all0 = vec![0.0f64; j]; - let hi = score_wle(&a, &b, &c, &d, &all1, &obs, 1, 20.0, 1e-9).unwrap(); - let lo = score_wle(&a, &b, &c, &d, &all0, &obs, 1, 20.0, 1e-9).unwrap(); - assert!( - hi.theta[0].is_finite() && !hi.boundary[0], - "all-correct theta {}", - hi.theta[0] - ); - assert!( - lo.theta[0].is_finite() && !lo.boundary[0], - "all-incorrect theta {}", - lo.theta[0] - ); - assert!( - hi.theta[0] > lo.theta[0], - "correct {} !> incorrect {}", - hi.theta[0], - lo.theta[0] - ); - // the FD estimating equation is also ~0 at these finite roots - assert!(g_fd(&a, &b, &c, &d, &all1, hi.theta[0]).abs() < 1e-4); - assert!(g_fd(&a, &b, &c, &d, &all0, lo.theta[0]).abs() < 1e-4); -} - -/// Monotonicity: for a fixed Rasch item set the WLE is nondecreasing in the number-correct score. -#[test] -fn wle_monotone_in_raw_score() { - let j = 8usize; - let a = vec![1.0f64; j]; - let b: Vec = (0..j).map(|i| -2.0 + 0.5 * i as f64).collect(); - let c = vec![0.0f64; j]; - let d = vec![1.0f64; j]; - let obs = vec![true; j]; - let mut prev = f64::NEG_INFINITY; - for k in 0..=j { - let y: Vec = (0..j).map(|i| if i < k { 1.0 } else { 0.0 }).collect(); - let res = score_wle(&a, &b, &c, &d, &y, &obs, 1, 20.0, 1e-9).unwrap(); - assert!( - res.theta[0] >= prev - 1e-9, - "raw score {k}: theta {} < previous {prev}", - res.theta[0] - ); - prev = res.theta[0]; - } -} - -/// Validation guards trip non-vacuously. -#[test] -fn wle_validates() { - let a = vec![1.0, 1.2]; - let b = vec![0.0, 0.5]; - let c = vec![0.0, 0.0]; - let d = vec![1.0, 1.0]; - let y = vec![1.0, 0.0]; - let obs = vec![true, true]; - assert!(score_wle(&a, &b, &c, &d, &y, &obs, 1, 20.0, 1e-9).is_ok()); - // length mismatch - assert!(score_wle(&a, &b[..1], &c, &d, &y, &obs, 1, 20.0, 1e-9).is_err()); - // c >= d - let cbad = vec![1.0, 0.0]; - assert!(score_wle(&a, &b, &cbad, &d, &y, &obs, 1, 20.0, 1e-9).is_err()); - // response not 0/1 - let ybad = vec![2.0, 0.0]; - assert!(score_wle(&a, &b, &c, &d, &ybad, &obs, 1, 20.0, 1e-9).is_err()); - // theta_bound non-positive - assert!(score_wle(&a, &b, &c, &d, &y, &obs, 1, 0.0, 1e-9).is_err()); - // no information, and controls whose required adaptive grid would be intractable - assert!(score_wle(&[0.0, 0.0], &b, &c, &d, &y, &obs, 1, 20.0, 1e-9).is_err()); - assert!(score_wle(&a, &b, &c, &d, &y, &obs, 1, 1e308, 1e-9).is_err()); -} - -/// The 3PL weighted likelihood is multimodal here; the WLE must return the GLOBAL mode, not merely -/// a root of the estimating equation. Adversarial-review worst case: a single bracketed bisection -/// returns `theta ~ +1.70`, but the dominant weighted-likelihood mode is `theta ~ -4.13` (~10x more -/// probable). Pins the global-mode selection. -#[test] -fn wle_selects_global_mode_3pl_multimodal() { - let a = [0.59, 1.38, 2.16, 3.45, 1.53, 2.58, 1.13, 1.02, 2.9, 2.07]; - let b = [ - -3.5, -3.78, -0.06, 2.82, 2.51, 2.73, -2.84, 3.48, 1.77, 0.07, - ]; - let c = [0.37, 0.23, 0.26, 0.45, 0.28, 0.3, 0.4, 0.22, 0.22, 0.21]; - let d = [1.0f64; 10]; - let y = [1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0]; - let obs = [true; 10]; - let res = score_wle(&a, &b, &c, &d, &y, &obs, 1, 20.0, 1e-9).unwrap(); - assert!( - res.theta[0] < -3.0, - "did not select the global mode: theta {} (expected ~ -4.13, not the +1.70 root)", - res.theta[0] - ); -} - -/// A fixed 512-node theta grid misses the narrow dominant mode created by the third item's high -/// discrimination and returns the lower weighted-likelihood mode near -3.37. A 0.001-step -/// independent numerical integral of `g` places the global maximum near -2.74. -#[test] -fn wle_resolves_narrow_global_mode_4pl() { - let a = [ - 3.329447657883643, - 0.27232757528116147, - 84.38646237902715, - 4.507142332708399, - 0.216076032654272, - 1.152868526694496, - 0.5026701543207452, - 3.594020470848568, - ]; - let b = [ - -2.2559085720992726, - 4.784793518100594, - -2.7313173853279284, - 3.16639784715872, - 2.45483432935667, - 3.577399138394002, - -0.541499889021253, - -3.1606254220709538, - ]; - let c = [ - 0.4293154638946107, - 0.03968316086976924, - 0.2117187277379179, - 0.4041453105751009, - 0.14842532496042327, - 0.2781240730868334, - 0.07100800469041686, - 0.16882942315223948, - ]; - let d = [ - 0.9271440266982822, - 0.8326920519773708, - 0.7052699247299387, - 0.7321429393598535, - 0.7250331916969143, - 0.8800003001396377, - 0.7964931220169523, - 0.8078636510671307, - ]; - let y = [1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0]; - let obs = [true; 8]; - let res = score_wle(&a, &b, &c, &d, &y, &obs, 1, 20.0, 1e-10).unwrap(); - assert!(!res.boundary[0]); - assert!( - (res.theta[0] + 2.74).abs() < 0.02, - "selected theta {} instead of the narrow global mode near -2.74", - res.theta[0] - ); - assert!(g_fd(&a, &b, &c, &d, &y, res.theta[0]).abs() < 1e-3); -} - -/// A person with no observed items has undefined ability: `NaN` estimate and SE, flagged — not a -/// spurious `theta = 0` (which the `g == 0` bisection shortcut would otherwise return). -#[test] -fn wle_all_missing_is_nan() { - let a = [1.0, 1.2, 0.9]; - let b = [-0.5, 0.0, 0.7]; - let c = [0.0f64; 3]; - let d = [1.0f64; 3]; - let y = [0.0, 0.0, 0.0]; - let obs = [false, false, false]; - let res = score_wle(&a, &b, &c, &d, &y, &obs, 1, 20.0, 1e-9).unwrap(); - assert!(res.theta[0].is_nan() && res.se[0].is_nan() && res.boundary[0]); -} - -#[test] -fn wle_numeric_helpers_and_representational_boundaries() { - assert_eq!(finite_wle_value(1.0, "unused".into()).unwrap(), 1.0); - assert_eq!( - finite_wle_value(f64::NAN, "non-finite".into()).unwrap_err(), - "non-finite" - ); - let mut exact_root = |x| x; - assert_eq!( - refine_wle_root(-1.0, 1.0, 1e-12, &mut exact_root).unwrap(), - 0.0 - ); - let mut unbracketed = |x| x * x + 1.0; - assert!(refine_wle_root(-1.0, 1.0, 1e-12, &mut unbracketed).is_err()); - let mut discontinuous = |x| { - if x <= 1.0 / 3.0 { - 1.0 - } else { - -1.0 - } - }; - assert!(refine_wle_root(0.0, 1.0, f64::MIN_POSITIVE, &mut discontinuous).is_err()); - - let c = [0.0, 0.0]; - let d = [1.0, 1.0]; - let masked = score_wle( - &[1.0, 0.8], - &[0.0, 0.5], - &c, - &d, - &[1.0, 0.0], - &[true, false], - 1, - 6.0, - 1e-9, - ) - .unwrap(); - assert!(masked.theta[0].is_finite()); - - let boundary = score_wle( - &[1.0], - &[10.0], - &[0.0], - &[1.0], - &[1.0], - &[true], - 1, - 0.1, - 1e-9, - ) - .unwrap(); - assert!(boundary.boundary[0]); - assert_eq!(boundary.theta[0], 0.1); - - let negligible_information = score_wle( - &[1e-8], - &[10.0], - &[0.0], - &[1.0], - &[1.0], - &[true], - 1, - 0.1, - 1e-9, - ) - .unwrap(); - assert!(negligible_information.se[0].is_nan()); - - assert!(score_wle( - &[1e308], - &[1e308], - &[0.0], - &[1.0], - &[1.0], - &[true], - 1, - 1e-308, - 1e-12, - ) - .is_err()); - - assert!(score_wle( - &[1.0, 1.2], - &[0.0, 0.5], - &c, - &d, - &[1.0, 0.0], - &[true, true], - 1, - 6.0, - f64::MIN_POSITIVE, - ) - .is_err()); -} - -/// Literature-grade bias comparison (>=500 reps): Warm's WLE has smaller mean bias than the MLE, -/// especially at extreme abilities where perfect/near-perfect patterns bias the (boundary-clamped) -/// MLE. Run with: `cargo test -p mlsirm-core --release wle_reduces_mle_bias_500 -- --ignored`. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps)"] -fn wle_reduces_mle_bias_500() { - let reps = 500usize; - let j = 15usize; - let a: Vec = (0..j).map(|i| 0.9 + 0.05 * (i % 5) as f64).collect(); - let b: Vec = (0..j) - .map(|i| -2.0 + 4.0 * i as f64 / (j as f64 - 1.0)) - .collect(); - let c = vec![0.0f64; j]; - let d = vec![1.0f64; j]; - let obs = vec![true; j]; - // MLE by bisection on the score (clamped to +/-6 for separable patterns). - let mle = |y: &[f64]| -> f64 { - let score = |t: f64| -> f64 { - (0..j) - .map(|i| { - let p = sig(a[i] * (t - b[i])); - a[i] * (y[i] - p) - }) - .sum::() - }; - let (mut loi, mut hii) = (-6.0f64, 6.0f64); - let (glo, ghi) = (score(loi), score(hii)); - if glo * ghi > 0.0 { - return if glo > 0.0 { hii } else { loi }; - } - for _ in 0..100 { - let mid = 0.5 * (loi + hii); - if score(mid) > 0.0 { - loi = mid; - } else { - hii = mid; - } - } - 0.5 * (loi + hii) - }; - let grid = [-2.0, -1.0, 0.0, 1.0, 2.0]; - let (mut wle_abs, mut mle_abs) = (0.0f64, 0.0f64); - for &theta in &grid { - let (mut wsum, mut msum, mut n) = (0.0f64, 0.0f64, 0usize); - for rep in 0..reps { - let mut rng = Lcg(0x9E1E_u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((theta as i64 as u64).wrapping_mul(97))); - let y: Vec = (0..j) - .map(|i| { - let p = sig(a[i] * (theta - b[i])); - if rng.next_f64() < p { - 1.0 - } else { - 0.0 - } - }) - .collect(); - let w = score_wle(&a, &b, &c, &d, &y, &obs, 1, 20.0, 1e-9) - .unwrap() - .theta[0]; - wsum += w - theta; - msum += mle(&y) - theta; - n += 1; - } - let (wb, mb) = (wsum / n as f64, msum / n as f64); - println!("[wle bias theta={theta}] WLE={wb:.4} MLE={mb:.4}"); - wle_abs += wb.abs(); - mle_abs += mb.abs(); - } - println!("[wle] sum|bias| WLE={wle_abs:.4} MLE={mle_abs:.4}"); - assert!( - wle_abs < mle_abs, - "WLE did not reduce aggregate bias: {wle_abs} vs {mle_abs}" - ); -} - -/// Pins the identity `I' = 2J - T` with `T = sum_i P_i'^3 (1 - 2 P_i) / (P_i Q_i)^2`, stated in the -/// [`score_wle`] doc block. Both `J` and `T` are formed from the SHIPPED `item_information_4pl` and -/// the same closed forms the estimator uses, while `I'` comes from a central difference of -/// `item_information_4pl` — a different code path — so neither side is a private restatement. -/// -/// This exists because that sentence has been wrong twice: it first claimed `J` coincides with `I'/2` -/// for the 2PL, and the correction of that claim dropped the `(1 - 2 P)` factor from `T`. A formula -/// asserted in prose and checked by nothing is how that happens. -/// -/// kills: `T` written without the `(1 - 2 P)` factor (off by ~5x at the 2PL point below); a claim that -/// `T = J` for the 3PL, where the two genuinely differ. -#[test] -fn wle_information_derivative_identity() { - let jt = |a: f64, b: f64, c: f64, d: f64, t: f64| -> (f64, f64) { - let s = sig(a * (t - b)); - let dc = d - c; - let p = c + dc * s; - let pq = p * (1.0 - p); - let p1 = a * dc * s * (1.0 - s); - let p2 = a * a * dc * s * (1.0 - s) * (1.0 - 2.0 * s); - (p1 * p2 / pq, p1 * p1 * p1 * (1.0 - 2.0 * p) / (pq * pq)) - }; - let h = 1e-5; - for (a, b, c, d, t, two_pl) in [ - (1.5f64, 0.2, 0.0, 1.0, 0.7, true), - (1.5, 0.2, 0.25, 1.0, -1.6, false), - (1.2, -0.4, 0.0, 0.95, 0.3, false), - ] { - let (j, tt) = jt(a, b, c, d, t); - // `item_information_4pl` takes the PROBABILITY, so the finite difference is over theta via P. - let pf = |x: f64| c + (d - c) * sig(a * (x - b)); - let iprime = (item_information_4pl(a, pf(t + h), c, d) - - item_information_4pl(a, pf(t - h), c, d)) - / (2.0 * h); - assert!( - (2.0 * j - tt - iprime).abs() / iprime.abs().max(1e-8) < 1e-5, - "I' = 2J - T failed at a={a} b={b} c={c} d={d} theta={t}: 2J-T={} I'={iprime}", - 2.0 * j - tt - ); - // T = J exactly for the 2PL/Rasch (hence J = I' and the weight is sqrt(I)); NOT otherwise. - let same = (tt - j).abs() / j.abs().max(1e-8) < 1e-9; - assert_eq!( - same, two_pl, - "T == J must hold for the 2PL only: a={a} c={c} d={d} gave T={tt} J={j}" - ); - } -} diff --git a/tests/unit/subscores_tests.rs b/tests/unit/subscores_tests.rs deleted file mode 100644 index 0ea703ede..000000000 --- a/tests/unit/subscores_tests.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Tests for Haberman (2008) subscore added-value analysis. -//! -//! Fixture literals were generated by an independent Python/NumPy script -//! transcribing the CRAN `subscore` R semantics (ddof=1 throughout), never -//! calling this crate. Each test documents the crate value its asserts read -//! and at least one mutation it kills. - -use super::*; - -const TOL: f64 = 1e-12; - -/// 10 persons x 4 binary items, subscales {0,1} and {2,3}. Chosen so all -/// alphas are in (0,1), correlations are non-degenerate, and the two -/// subscales give DIFFERENT added_value_sx outcomes (asymmetric fixture). -fn fixture() -> (Vec>, Vec) { - let x = vec![ - vec![0., 0., 0., 1.], - vec![0., 1., 0., 0.], - vec![1., 1., 0., 1.], - vec![0., 0., 1., 0.], - vec![1., 1., 1., 1.], - vec![1., 0., 1., 1.], - vec![0., 0., 0., 0.], - vec![1., 1., 1., 0.], - vec![0., 1., 1., 1.], - vec![1., 1., 0., 0.], - ]; - (x, vec![0, 0, 1, 1]) -} - -/// Reads: alpha, alpha_total. Kills: dropping the m/(m-1) factor in -/// cronbach_alpha (alpha would become [0.4348, 0.2500], alpha_x 0.3356); -/// using biased (n) variance in only one of numerator/denominator. -#[test] -fn alpha_matches_independent_reference() { - let (x, g) = fixture(); - let r = subscores(&x, &g).unwrap(); - assert!((r.alpha[0] - 0.5797101449275359).abs() < TOL); - assert!((r.alpha[1] - 0.33333333333333326).abs() < 1e-10); - assert!((r.alpha_total - 0.44742729306487705).abs() < TOL); -} - -/// Reads: prmse_x. Kills the two documented rowsum mutants: -/// including the total column in cov_k gives r_stxt from -/// cov_rowsum = [1.4333, 1.1111]; skipping the true-variance diagonal -/// replacement gives cov_rowsum = [0.8778, 0.7778]. Both move PRMSE_x far -/// outside the asserted tolerance (reference cov_rowsum = [0.5556, 0.3333], -/// r_stxt = [0.9375, 0.675]). -#[test] -fn prmse_x_matches_independent_reference() { - let (x, g) = fixture(); - let r = subscores(&x, &g).unwrap(); - assert!((r.prmse_x[0] - 0.41946308724832204).abs() < 1e-10); - assert!((r.prmse_x[1] - 0.3020134228187919).abs() < 1e-10); -} - -/// Reads: tau, beta, gamma, prmse_sx. Kills: sign flip in the tau numerator -/// (tau[0] would be negative); swapping beta and gamma (they differ by an -/// order of magnitude here); using r instead of r^2 in the tau denominator. -#[test] -fn augmented_regression_matches_independent_reference() { - let (x, g) = fixture(); - let r = subscores(&x, &g).unwrap(); - assert!((r.tau[0] - 0.1385414635898375).abs() < 1e-10); - assert!((r.tau[1] - 0.27024443691002326).abs() < 1e-10); - assert!((r.beta[0] - 0.49752475247524747).abs() < 1e-10); - assert!((r.beta[1] - 0.21782178217821788).abs() < 1e-10); - assert!((r.gamma[0] - 0.07178217821782161).abs() < 1e-10); - assert!((r.gamma[1] - 0.09900990099009896).abs() < 1e-10); - assert!((r.prmse_sx[0] - 0.5872524752475244).abs() < 1e-10); - assert!((r.prmse_sx[1] - 0.3663366336633662).abs() < 1e-10); -} - -/// Reads: corr (K+1 x K+1, total last), prmse_s. Kills: variance/covariance -/// index transposition; normalizing by variance instead of SD. -#[test] -fn correlations_match_independent_reference() { - let (x, g) = fixture(); - let r = subscores(&x, &g).unwrap(); - assert!((r.corr[0][2] - 0.7791290756515445).abs() < 1e-10); - assert!((r.corr[1][2] - 0.7403367031320777).abs() < 1e-10); - assert!((r.corr[2][0] - 0.7791290756515445).abs() < 1e-10); - assert!((r.prmse_s[0] - r.alpha[0]).abs() < TOL); - assert!((r.prmse_s[1] - r.alpha[1]).abs() < TOL); - for k in 0..3 { - assert!((r.corr[k][k] - 1.0).abs() < TOL); - } -} - -/// Reads: subscore_s, subscore_x, subscore_sx for person 0 (s = [0, 1], -/// x = 1). Kills: using observed instead of true SD in the s_hat_x slope -/// (0.7309 vs 0.6152 for subscale 0); centering on x instead of s in -/// s_hat_s; swapping mean_s and mean_x. -#[test] -fn person_estimates_match_independent_reference() { - let (x, g) = fixture(); - let r = subscores(&x, &g).unwrap(); - assert!((r.subscore_s[0][0] - 0.4623188405797105).abs() < 1e-10); - assert!((r.subscore_s[0][1] - 1.0).abs() < 1e-10); - assert!((r.subscore_x[0][0] - 0.7308724832214768).abs() < 1e-10); - assert!((r.subscore_x[0][1] - 0.778523489932886).abs() < 1e-10); - assert!((r.subscore_sx[0][0] - 0.47376237623762407).abs() < 1e-10); - assert!((r.subscore_sx[0][1] - 0.8910891089108911).abs() < 1e-10); - assert!((r.observed[0][0] - 0.0).abs() < TOL); - assert!((r.observed[0][1] - 1.0).abs() < TOL); - assert!((r.total[0] - 1.0).abs() < TOL); -} - -/// Reads: added_value_s, added_value_sx. The fixture is deliberately -/// asymmetric across subscales in added_value_sx ([false, true]), so any -/// mutant collapsing the decision to a constant, comparing against the wrong -/// PRMSE, or dropping the +0.01 margin (subscale 0 has -/// PRMSE_sx - PRMSE_s = 0.00754 < 0.01, which flips to true without the -/// margin) fails here. -#[test] -fn added_value_decisions_match_independent_reference() { - let (x, g) = fixture(); - let r = subscores(&x, &g).unwrap(); - assert_eq!(r.added_value_s, vec![true, true]); - assert_eq!(r.added_value_sx, vec![false, true]); -} - -/// Reads: Err from negatively-keyed within-subscale items (alpha < 0). -/// Kills: removing the alpha-range guard (analysis would emit NaN PRMSEs). -#[test] -fn rejects_negative_alpha() { - // Item 1 is the reverse of item 0 within subscale 0 -> alpha_0 < 0. - let x: Vec> = (0..10) - .map(|p| { - let a = f64::from((p % 2 == 0) as u8); - let c = f64::from((p % 3 == 0) as u8); - let d = f64::from((p % 4 == 0) as u8); - vec![a, 1.0 - a, c, d] - }) - .collect(); - let err = subscores(&x, &[0, 0, 1, 1]).unwrap_err(); - assert!(err.contains("outside (0, 1]"), "{err}"); -} - -/// Reads: Err from a subscore collinear with the total. Duplicated subscales -/// make corr(s_k, x) = 1 exactly; the review mandated this be a REJECTION -/// (tau divides by 1 - r^2 = 0), not a parallel-forms anchor. Kills: -/// removing the collinearity guard (tau/PRMSE_sx would be inf/NaN). -#[test] -fn rejects_collinear_subscore() { - let (x, _) = fixture(); - // Both subscales are the SAME two items duplicated: x = 2 s_1. - let dup: Vec> = x - .iter() - .map(|row| vec![row[0], row[1], row[0], row[1]]) - .collect(); - let err = subscores(&dup, &[0, 0, 1, 1]).unwrap_err(); - assert!(err.contains("collinear"), "{err}"); -} - -/// Reads: Err on structural validation. Kills: skipping partition checks -/// (out-of-bounds panic or silent nonsense instead of Err). -#[test] -fn rejects_bad_inputs() { - let (x, _) = fixture(); - assert!(subscores(&x[..2], &[0, 0, 1, 1]).is_err()); // n < 3 - assert!(subscores(&x, &[0, 0, 1]).is_err()); // groups length mismatch - assert!(subscores(&x, &[0, 0, 0, 0]).is_err()); // K = 1 - assert!(subscores(&x, &[0, 1, 1, 1]).is_err()); // singleton subscale - assert!(subscores(&x, &[0, 0, 1_000_000_000, 1_000_000_000]).is_err()); // sparse index DoS - let mut nanx = x.clone(); - nanx[0][0] = f64::NAN; - assert!(subscores(&nanx, &[0, 0, 1, 1]).is_err()); // incomplete data - let zero = vec![vec![0., 0., 1., 0.]; 5]; - assert!(subscores(&zero, &[0, 0, 1, 1]).is_err()); // zero variance -} - -/// Reads: prmse_sx vs prmse_s/prmse_x across many random guard-passing -/// datasets. The dominance PRMSE_sx >= max(PRMSE_s, PRMSE_x) is CONDITIONAL -/// on the guards (spec-review finding); asserted only on accepted samples. -/// Note: PRMSE_sx >= PRMSE_s is the identity alpha + tau^2 (1 - r^2) >= -/// alpha, which no mutant of the comparison inputs can violate on its own; -/// the discriminating half is PRMSE_sx >= PRMSE_x, which fails under the -/// rowsum/diagonal mutants documented in `prmse_x_matches_independent_reference`. -#[test] -fn dominance_holds_on_guard_passing_data() { - // Deterministic LCG so the test is reproducible without rand dep. - let mut state = 0x2545F4914F6CDD1Du64; - let mut next = move || { - state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - ((state >> 33) as f64) / ((1u64 << 31) as f64) - }; - let mut accepted = 0; - for _ in 0..200 { - // Common-factor items so alphas are usually positive. - let n = 40; - let x: Vec> = (0..n) - .map(|_| { - let f = next(); - (0..6) - .map(|_| f64::from((0.6 * f + 0.4 * next() > 0.5) as u8)) - .collect() - }) - .collect(); - if let Ok(r) = subscores(&x, &[0, 0, 0, 1, 1, 1]) { - accepted += 1; - for k in 0..2 { - assert!( - r.prmse_sx[k] + 1e-9 >= r.prmse_s[k].max(r.prmse_x[k]), - "dominance violated: sx={} s={} x={}", - r.prmse_sx[k], - r.prmse_s[k], - r.prmse_x[k] - ); - } - } - } - assert!(accepted >= 50, "too few guard-passing samples: {accepted}"); -} - -/// Reads: disattenuated_corr. Kills: dividing by alpha instead of -/// sqrt(alpha_a alpha_b); forgetting the NaN diagonal. -#[test] -fn disattenuated_correlation() { - let (x, g) = fixture(); - let r = subscores(&x, &g).unwrap(); - let expect = 0.35355339059327384; - assert!((r.disattenuated_corr[0][1] - expect).abs() < 1e-10); - assert!((r.corr[0][1] - 0.15541746804005227).abs() < 1e-10); - assert!(r.disattenuated_corr[0][0].is_nan()); -} - -/// 500-replication Monte Carlo: with a strong common factor plus weak -/// specific factors, the augmented estimator's MSE against the true subscore -/// should not exceed the observed-subscore estimator's MSE on average. -/// Reads: subscore_sx, subscore_s. Kills gross beta/gamma miscalibration -/// that the single-fixture literals could compensate for by luck. -#[test] -#[ignore = "500-rep Monte Carlo; run with -- --ignored"] -fn monte_carlo_augmented_mse() { - let mut state = 0x9E3779B97F4A7C15u64; - let mut next = move || { - state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - ((state >> 33) as f64) / ((1u64 << 31) as f64) - }; - let mut mse_s = 0.0f64; - let mut mse_sx = 0.0f64; - let mut reps = 0; - for _ in 0..500 { - let n = 200; - let mut x = Vec::with_capacity(n); - let mut true_s0 = Vec::with_capacity(n); - for _ in 0..n { - let f = next() * 2.0 - 1.0; // common factor - let u0 = next() * 0.6 - 0.3; // weak specific factors - let u1 = next() * 0.6 - 0.3; - let mut row = Vec::with_capacity(8); - let mut t0 = 0.0; - for j in 0..8 { - let load = if j < 4 { f + u0 } else { f + u1 }; - let p = 1.0 / (1.0 + (-2.0 * load).exp()); - if j < 4 { - t0 += p; - } - row.push(f64::from((next() < p) as u8)); - } - true_s0.push(t0); - x.push(row); - } - if let Ok(r) = subscores(&x, &[0, 0, 0, 0, 1, 1, 1, 1]) { - reps += 1; - for p in 0..n { - mse_s += (r.subscore_s[p][0] - true_s0[p]).powi(2); - mse_sx += (r.subscore_sx[p][0] - true_s0[p]).powi(2); - } - } - } - assert!(reps >= 400, "too few valid replications: {reps}"); - assert!( - mse_sx <= mse_s * 1.001, - "augmented MSE {mse_sx} exceeds observed-subscore MSE {mse_s}" - ); -} diff --git a/tests/unit/testlet_tests.rs b/tests/unit/testlet_tests.rs deleted file mode 100644 index 4eace69e0..000000000 --- a/tests/unit/testlet_tests.rs +++ /dev/null @@ -1,549 +0,0 @@ -use super::*; -use crate::mmle::{fit_mmle_2pl, MmleConfig}; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } - fn skew(&mut self) -> f64 { - -(self.next_f64().max(1e-12)).ln() - 1.0 // Exp(1)-1: mean 0, var 1 - } - fn bern(&mut self, p: f64) -> f64 { - if self.next_f64() < p { - 1.0 - } else { - 0.0 - } - } -} -fn rmse(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - (a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::() / n).sqrt() -} -fn bias(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - a.iter().zip(b).map(|(x, y)| x - y).sum::() / n -} -fn nondecreasing(t: &[f64]) -> bool { - t.windows(2).all(|w| w[1] >= w[0] - 1e-6) -} - -/// The gamma quadrature must be the standard normal (unit variance) or the -/// sigma^2 = sigma^2 * mean(E[u^2]) update converges to a biased fixed point. -#[test] -fn gh_rule_is_unit_normal() { - for &q in &[11usize, 15, 21, 31, 41] { - let (u, v) = gh_rule(q).unwrap(); - assert!((v.iter().sum::() - 1.0).abs() < 1e-9); - assert!(u.iter().zip(v).map(|(x, w)| x * w).sum::().abs() < 1e-9); - let m2: f64 = u.iter().zip(v).map(|(x, w)| x * x * w).sum(); - assert!((m2 - 1.0).abs() < 1e-6, "gh_rule({q}) E[u^2] = {m2}"); - } -} - -#[test] -fn rejects_testlet_count_exceeding_item_count_before_allocation() { - let cfg = TestletConfig::default(); - let err = validate(&[1.0], &[true], &[0], 1, 1, 1_000_000_001, &cfg) - .expect_err("oversized testlet count must be rejected"); - assert!(err.contains("n_testlets must not exceed n_items")); -} - -/// Contiguous testlet assignment: testlet d owns items [d*size .. (d+1)*size). -fn contiguous_testlets(n_items: usize, n_testlets: usize) -> Vec { - let per = n_items / n_testlets; - (0..n_items) - .map(|i| (i / per).min(n_testlets - 1)) - .collect() -} - -/// Simulate testlet data: draw theta, per-testlet gamma ~ N(0, sigma^2_d), responses. -fn simulate( - a: &[f64], - beta: &[f64], - sigma2: &[f64], - testlet_id: &[usize], - n: usize, - j: usize, - skew: bool, - rng: &mut Lcg, -) -> Vec { - let d_n = sigma2.len(); - let mut y = vec![0.0f64; n * j]; - for p in 0..n { - let theta = if skew { rng.skew() } else { rng.normal() }; - let gamma: Vec = (0..d_n).map(|d| sigma2[d].sqrt() * rng.normal()).collect(); - for i in 0..j { - let eta = a[i] * theta + beta[i] - a[i] * gamma[testlet_id[i]]; - y[p * j + i] = rng.bern(sigmoid_stable(eta)); - } - } - y -} - -/// PRIMARY anchor: sigma^2 pinned to 0 reduces to fit_mmle_2pl (a/beta/loglik match). -#[test] -fn testlet_sigma0_equals_fit_mmle_2pl() { - let (n, j, d_n) = (700usize, 12usize, 3usize); - let tid = contiguous_testlets(j, d_n); - let mut rng = Lcg(7); - let a_t: Vec = (0..j).map(|_| 0.8 + 0.8 * rng.next_f64()).collect(); - let beta_t: Vec = (0..j) - .map(|i| -1.2 + 2.4 * i as f64 / (j - 1) as f64) - .collect(); - let y = simulate(&a_t, &beta_t, &vec![0.0; d_n], &tid, n, j, false, &mut rng); - let observed = vec![true; n * j]; - let mcfg = MmleConfig { - max_iter: 80, - tol: 0.0, - ridge_a: 1e-3, - ridge_b: 1e-3, - newton_iter: 25, - }; - let mmle = fit_mmle_2pl(&y, &observed, n, j, &mcfg); - let cfg = TestletConfig { - max_iter: 80, - tol: 0.0, - q_gamma: 21, - ridge_a: 1e-3, - ridge_b: 1e-3, - newton_iter: 25, - estimate_sigma: false, - init_sigma2: 0.0, - }; - let res = fit_testlet(&y, &observed, &tid, n, j, d_n, TestletModel::TwoPl, &cfg).unwrap(); - // a/beta bit-exact; theta OMITTED (mmle EAP uses a stale posterior — same reason - // the mixture/lltm anchors assert only item params). - assert!( - rmse(&res.a, &mmle.a) < 1e-12, - "a rmse {}", - rmse(&res.a, &mmle.a) - ); - assert!( - rmse(&res.beta, &mmle.b) < 1e-12, - "beta rmse {}", - rmse(&res.beta, &mmle.b) - ); - // loglik agrees on the common prefix (testlet may push an extra final_ll). - assert!( - res.loglik_trace - .iter() - .zip(&mmle.loglik_trace) - .all(|(x, y)| (x - y).abs() < 1e-12), - "loglik prefix mismatch" - ); - assert_eq!(res.n_parameters, 2 * j); // sigma^2 fixed => 0 free variance params - assert!(res.sigma2.iter().all(|&s| s == 0.0)); -} - -/// No-spurious-LD: pure 2PL data (all true sigma^2=0), full fit must not invent LD. -/// Ignored by default: shrinking sigma^2 to ~0 needs many iterations (the sigma->0 -/// tail of the variance-component EM is slow even with SQUAREM). -#[test] -#[ignore = "slow (sigma->0 convergence); run with: cargo test --release -- --ignored"] -fn testlet_no_spurious_ld() { - let (n, j, d_n) = (600usize, 12usize, 3usize); - let tid = contiguous_testlets(j, d_n); - let mut rng = Lcg(11); - let a_t: Vec = (0..j).map(|_| 0.8 + 0.8 * rng.next_f64()).collect(); - let beta_t: Vec = (0..j) - .map(|i| -1.5 + 3.0 * i as f64 / (j - 1) as f64) - .collect(); - let y = simulate(&a_t, &beta_t, &vec![0.0; d_n], &tid, n, j, false, &mut rng); - let observed = vec![true; n * j]; - let cfg = TestletConfig { - max_iter: 2000, - ..TestletConfig::default() - }; - let res = fit_testlet(&y, &observed, &tid, n, j, d_n, TestletModel::TwoPl, &cfg).unwrap(); - println!( - "no_spurious: converged={} reason={} n_iter={}/{} final_delta={:.12e} tol={:.12e} sigma2={:?}", - res.converged, - res.termination_reason, - res.n_iter, - cfg.max_iter, - res.final_loglik_change, - cfg.tol, - res.sigma2 - ); - assert!( - res.converged, - "testlet fit exhausted {} iterations", - cfg.max_iter - ); - assert_eq!(res.termination_reason, "converged"); - assert!(res.n_iter < cfg.max_iter); - assert!(res.final_loglik_change.is_finite()); - assert!(res.final_loglik_change < cfg.tol); - assert!(nondecreasing(&res.loglik_trace)); - assert!( - res.sigma2.iter().all(|&s| s < 0.08), - "spurious LD: {:?}", - res.sigma2 - ); -} - -/// Strong-LD: large true sigma^2 recovered, and modeling it improves the loglik over -/// the sigma=0 (naive-2PL) fit — the signature of unmodeled local dependence. -#[test] -fn testlet_recovers_strong_ld() { - // Rasch (a=1), 8 items per testlet (the well-identified testlet model; the 2PL - // discrimination trades off against the testlet SD via a_i*sigma_d). - let (n, j, d_n) = (800usize, 16usize, 2usize); - let tid = contiguous_testlets(j, d_n); - let sig2 = vec![0.6f64, 0.3]; - let mut rng = Lcg(2024); - let a_t = vec![1.0f64; j]; - let beta_t: Vec = (0..j).map(|i| -1.5 + 3.0 * (i % 8) as f64 / 7.0).collect(); - let y = simulate(&a_t, &beta_t, &sig2, &tid, n, j, false, &mut rng); - let observed = vec![true; n * j]; - let res = fit_testlet( - &y, - &observed, - &tid, - n, - j, - d_n, - TestletModel::Rasch, - &TestletConfig::default(), - ) - .unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); - assert!( - rmse(&res.sigma2, &sig2) < 0.2, - "sigma2 rmse {} ({:?})", - rmse(&res.sigma2, &sig2), - res.sigma2 - ); - assert!( - res.sigma2[0] > 0.35, - "strong LD not recovered: {}", - res.sigma2[0] - ); - // loglik gain over the naive sigma=0 fit - let naive = TestletConfig { - estimate_sigma: false, - init_sigma2: 0.0, - ..TestletConfig::default() - }; - let res0 = fit_testlet(&y, &observed, &tid, n, j, d_n, TestletModel::Rasch, &naive).unwrap(); - assert!( - *res.loglik_trace.last().unwrap() > *res0.loglik_trace.last().unwrap() + 5.0, - "testlet fit did not improve loglik over naive 2PL" - ); -} - -/// A singleton testlet's variance is non-identified => pinned to 0, not spurious. -#[test] -fn testlet_singleton_pinned() { - let (n, j) = (600usize, 7usize); - // testlets: {0,1,2}, {3,4,5}, {6} (singleton) - let tid = vec![0usize, 0, 0, 1, 1, 1, 2]; - let sig2 = vec![0.6f64, 0.6, 0.0]; - let mut rng = Lcg(5); - let a_t = vec![1.0f64; j]; - let beta_t: Vec = (0..j) - .map(|i| -1.0 + 2.0 * i as f64 / (j - 1) as f64) - .collect(); - let y = simulate(&a_t, &beta_t, &sig2, &tid, n, j, false, &mut rng); - let observed = vec![true; n * j]; - let res = fit_testlet( - &y, - &observed, - &tid, - n, - j, - 3, - TestletModel::Rasch, - &TestletConfig::default(), - ) - .unwrap(); - assert!(res.converged); - assert_eq!( - res.sigma2[2], 0.0, - "singleton testlet variance must be pinned to 0" - ); - // the singleton's pinned variance is NOT a free parameter (Rasch: J + 2 multi). - assert_eq!(res.n_parameters, j + 2); -} - -/// Missing-at-random cells are dropped. -#[test] -fn testlet_handles_missing_data() { - // Coverage instrumentation is intentionally much slower than a normal - // test build. Keep the same missing-data and convergence path while - // the full-size statistical check remains in ordinary CI. - let n = 500usize; - let (j, d_n) = (12usize, 3usize); - let tid = contiguous_testlets(j, d_n); - let sig2 = vec![0.5f64, 0.5, 0.5]; - let mut rng = Lcg(9); - let a_t = vec![1.0f64; j]; - let beta_t: Vec = (0..j) - .map(|i| -1.0 + 2.0 * i as f64 / (j - 1) as f64) - .collect(); - let y = simulate(&a_t, &beta_t, &sig2, &tid, n, j, false, &mut rng); - let mut observed = vec![true; n * j]; - for o in observed.iter_mut() { - if rng.next_f64() < 0.2 { - *o = false; - } - } - let res = fit_testlet( - &y, - &observed, - &tid, - n, - j, - d_n, - TestletModel::Rasch, - &TestletConfig::default(), - ) - .unwrap(); - assert!(res.converged && nondecreasing(&res.loglik_trace)); -} - -/// Malformed inputs are rejected (covers each validate branch, incl. tol=0 allowed). -#[test] -fn testlet_validate_rejects_malformed() { - let (n, j, d_n) = (5usize, 6usize, 2usize); - let tid = contiguous_testlets(j, d_n); - let y = vec![0.0f64; n * j]; - let obs = vec![true; n * j]; - let d = TestletConfig::default(); - let bad = |y: &[f64], obs: &[bool], tid: &[usize], n, j, dn, cfg: &TestletConfig| { - fit_testlet(y, obs, tid, n, j, dn, TestletModel::Rasch, cfg).is_err() - }; - assert!(bad(&y, &obs, &tid, 0, j, d_n, &d)); // n_persons - assert!(bad(&y, &obs, &tid, n, j, 0, &d)); // n_testlets - assert!(bad(&[], &[], &[], usize::MAX, 2, 1, &d)); // n_persons * n_items - assert!(bad( - &y, - &obs, - &tid, - n, - j, - d_n, - &TestletConfig { max_iter: 0, ..d } - )); - assert!(bad( - &y, - &obs, - &tid, - n, - j, - d_n, - &TestletConfig { - max_iter: TESTLET_MAX_ITER + 1, - ..d - } - )); - assert!(bad( - &y, - &obs, - &tid, - n, - j, - d_n, - &TestletConfig { tol: -1.0, ..d } - )); - assert!(bad( - &y, - &obs, - &tid, - n, - j, - d_n, - &TestletConfig { q_gamma: 8, ..d } - )); // not in SUPPORTED_Q - assert!(bad( - &y, - &obs, - &tid, - n, - j, - d_n, - &TestletConfig { - init_sigma2: -1.0, - ..d - } - )); - assert!(bad( - &y, - &obs, - &tid, - n, - j, - d_n, - &TestletConfig { - ridge_a: f64::NAN, - ..d - } - )); - assert!(bad( - &y, - &obs, - &tid, - n, - j, - d_n, - &TestletConfig { ridge_b: -1.0, ..d } - )); - assert!(bad(&vec![0.0; n * j - 1], &obs, &tid, n, j, d_n, &d)); // y length - assert!(bad(&y, &obs, &vec![0usize; j - 1], n, j, d_n, &d)); // testlet_id length - assert!(bad(&y, &obs, &vec![0, 0, 0, 5, 0, 0], n, j, d_n, &d)); // testlet_id out of range - assert!(bad(&vec![2.0; n * j], &obs, &tid, n, j, d_n, &d)); // y not 0/1 - let mut no_item_observations = obs.clone(); - for p in 0..n { - no_item_observations[p * j] = false; - } - assert!(bad(&y, &no_item_observations, &tid, n, j, d_n, &d)); - // an empty testlet (n_testlets says 3 but only 0,1 used) - assert!(bad(&y, &obs, &vec![0, 0, 0, 1, 1, 1], n, j, 3, &d)); - // tol == 0.0 accepted - assert!(fit_testlet( - &y, - &obs, - &tid, - n, - j, - d_n, - TestletModel::Rasch, - &TestletConfig { - tol: 0.0, - max_iter: 2, - ..d - } - ) - .is_ok()); - assert_eq!( - choose_squarem_parameters(Some(vec![1.0]), vec![2.0]), - vec![1.0] - ); - assert_eq!(choose_squarem_parameters(None, vec![2.0]), vec![2.0]); - assert_eq!(squarem_alpha(4.0, 1.0), -2.0); - assert_eq!(squarem_alpha(1.0, 4.0), -1.0); - assert_eq!(squarem_alpha(0.0, 0.0), -1.0); -} - -#[test] -fn zero_variance_testlet_path_handles_missing_cells() { - let (n, j, d_n) = (8usize, 4usize, 2usize); - let tid = contiguous_testlets(j, d_n); - let y: Vec = (0..n * j).map(|idx| (idx % 2) as f64).collect(); - let mut observed = vec![true; n * j]; - observed[1] = false; - observed[n * j - 2] = false; - let result = fit_testlet( - &y, - &observed, - &tid, - n, - j, - d_n, - TestletModel::Rasch, - &TestletConfig { - max_iter: 2, - q_gamma: 7, - estimate_sigma: false, - init_sigma2: 0.0, - ..TestletConfig::default() - }, - ) - .unwrap(); - assert!(result.loglik_trace.iter().all(|value| value.is_finite())); -} - -/// Iteration exhaustion is explicit and SQUAREM must not overrun max_iter. -#[test] -fn testlet_reports_max_iter_nonconvergence() { - let (n, j, d_n) = (40usize, 6usize, 2usize); - let tid = contiguous_testlets(j, d_n); - let y: Vec = (0..n * j).map(|idx| ((idx + idx / j) % 2) as f64).collect(); - let observed = vec![true; n * j]; - let cfg = TestletConfig { - max_iter: 2, - tol: 0.0, - q_gamma: 7, - ..TestletConfig::default() - }; - let res = fit_testlet(&y, &observed, &tid, n, j, d_n, TestletModel::Rasch, &cfg).unwrap(); - assert!(!res.converged); - assert_eq!(res.termination_reason, "max_iter_reached"); - assert_eq!(res.n_iter, cfg.max_iter); - assert!(res.final_loglik_change.is_finite()); - assert_eq!(res.loglik_trace.len(), cfg.max_iter); -} - -/// Literature-grade Monte-Carlo (>=500 reps): Bradlow-Wainer-Wang-style design. -/// Uses the RASCH testlet (the well-identified case; in the 2PL testlet the free -/// discrimination a_i and the testlet SD sigma_d both scale the LD via a_i*sigma_d -/// and separate only weakly with few testlets). Recovers the testlet variances and -/// item difficulties under normal and skew ability. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_testlet_recovery_500() { - let (n, j, d_n, per, reps) = (1000usize, 24usize, 4usize, 6usize, 500usize); - let tid = contiguous_testlets(j, d_n); - let sig2_t = vec![0.2f64, 0.4, 0.6, 0.8]; - assert_eq!(j, d_n * per); - let a_t = vec![1.0f64; j]; - let cfg = TestletConfig { - q_gamma: 15, - max_iter: 1500, - ..TestletConfig::default() - }; - for &skew in [false, true].iter() { - let (mut s_b, mut s_sig, mut s_bsig, mut n_conv) = (0.0, 0.0, 0.0, 0.0); - for rep in 0..reps { - let seed = 0xBADC0FFEE0DDF00Du64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add(if skew { 0x9E3779B97F4A7C15 } else { 0 }); - let mut rng = Lcg(seed); - let beta_t: Vec = (0..j) - .map(|i| -1.5 + 3.0 * (i % per) as f64 / (per - 1) as f64) - .collect(); - let y = simulate(&a_t, &beta_t, &sig2_t, &tid, n, j, skew, &mut rng); - let observed = vec![true; n * j]; - let res = - fit_testlet(&y, &observed, &tid, n, j, d_n, TestletModel::Rasch, &cfg).unwrap(); - assert!( - res.converged, - "testlet Monte-Carlo fit did not converge: skew={skew}, rep={rep}, n_iter={}, final_delta={}", - res.n_iter, - res.final_loglik_change - ); - s_b += rmse(&res.beta, &beta_t); - s_sig += rmse(&res.sigma2, &sig2_t); - s_bsig += bias(&res.sigma2, &sig2_t); - if res.converged { - n_conv += 1.0; - } - } - let r = reps as f64; - println!( - "skew={}: RMSE(beta)={:.4} RMSE(sigma2)={:.4} bias(sigma2)={:.4} converged={:.2}", - skew, - s_b / r, - s_sig / r, - s_bsig / r, - n_conv / r - ); - assert!(s_b / r < 0.12, "RMSE(beta) {} skew={skew}", s_b / r); - assert!(s_sig / r < 0.15, "RMSE(sigma2) {} skew={skew}", s_sig / r); - assert_eq!( - n_conv, r, - "not every Monte-Carlo fit converged (skew={skew})" - ); - } -} diff --git a/tests/unit/twopl_tests.rs b/tests/unit/twopl_tests.rs deleted file mode 100644 index 933028356..000000000 --- a/tests/unit/twopl_tests.rs +++ /dev/null @@ -1,1729 +0,0 @@ -use super::*; -use crate::mmle::{fit_mmle_2pl, MmleConfig}; - -struct Lcg(u64); -impl Lcg { - fn next_f64(&mut self) -> f64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - } - fn normal(&mut self) -> f64 { - let u1 = self.next_f64().max(1e-12); - let u2 = self.next_f64(); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() - } - fn bern(&mut self, p: f64) -> f64 { - if self.next_f64() < p { - 1.0 - } else { - 0.0 - } - } -} - -fn sigmoid(x: f64) -> f64 { - 1.0 / (1.0 + (-x).exp()) -} -fn rmse(a: &[f64], b: &[f64]) -> f64 { - let n = a.len() as f64; - (a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::() / n).sqrt() -} -fn corr(x: &[f64], y: &[f64]) -> f64 { - let n = x.len() as f64; - let (mx, my) = (x.iter().sum::() / n, y.iter().sum::() / n); - let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0); - for (a, b) in x.iter().zip(y) { - sxy += (a - mx) * (b - my); - sxx += (a - mx) * (a - mx); - syy += (b - my) * (b - my); - } - sxy / (sxx.sqrt() * syy.sqrt()) -} - -/// Simulate compensatory M2PL responses from loadings (J*D), intercepts (J), and person -/// traits (N*D) via the same additive-logit model the estimator recovers. -fn simulate( - loading: &[f64], - intercept: &[f64], - thetas: &[f64], - n: usize, - n_items: usize, - n_dims: usize, - rng: &mut Lcg, -) -> Vec { - let mut y = vec![0.0f64; n * n_items]; - for j in 0..n { - for i in 0..n_items { - let mut eta = intercept[i]; - for d in 0..n_dims { - eta += loading[i * n_dims + d] * thetas[j * n_dims + d]; - } - y[j * n_items + i] = rng.bern(sigmoid(eta)); - } - } - y -} - -/// The orthogonal product GH grid reproduces the N(0, I) moments (sum w = 1, E[theta_d]=0, -/// Var=1, Cov=0) - catches a transposed nodes[g*D+d] or a bad Cartesian product. -#[test] -fn mirt_grid_moments() { - let (nodes, logw) = build_grid(2, 15); - let n = logw.len(); - let w: Vec = logw.iter().map(|l| l.exp()).collect(); - assert!((w.iter().sum::() - 1.0).abs() < 1e-10, "sum w"); - let (mut e0, mut e1, mut v0, mut v1, mut c01) = (0.0, 0.0, 0.0, 0.0, 0.0); - for g in 0..n { - let (t0, t1) = (nodes[g * 2], nodes[g * 2 + 1]); - e0 += w[g] * t0; - e1 += w[g] * t1; - v0 += w[g] * t0 * t0; - v1 += w[g] * t1 * t1; - c01 += w[g] * t0 * t1; - } - assert!(e0.abs() < 1e-9 && e1.abs() < 1e-9, "means"); - assert!( - (v0 - 1.0).abs() < 1e-9 && (v1 - 1.0).abs() < 1e-9, - "variances" - ); - assert!(c01.abs() < 1e-9, "cross moment (orthogonality)"); -} - -/// Deterministic anchor: the analytic item gradient AND the full (n_i+1)x(n_i+1) Hessian -/// block - including the off-diagonal cross-Hessian H_{a0,a1} and the local->pattern-dim -/// map - match central finite differences of item_obj at D=2 for a BOTH-loading item, to -/// < 1e-4. A dims[k] indexing bug or a missing cross term fails this with no MC noise. -#[test] -fn mirt_item_grad_hess_matches_finite_difference() { - // Two configs: identity map (dims=[0,1] on a D=2 grid) AND a NON-IDENTITY map - // (dims=[0,2] on a D=3 grid, so nodes index dims[k]!=k) — the latter genuinely pins - // the local-param -> pattern-dimension map that a k-vs-dims[k] bug would break. - for &(n_dims, ref dims) in [(2usize, vec![0usize, 1]), (3usize, vec![0usize, 2])].iter() { - let (nodes, logw) = build_grid(n_dims, 15); - let n_nodes = logw.len(); - let mut rng = Lcg(99); - let (mut n_ig, mut r_ig) = (vec![0.0f64; n_nodes], vec![0.0f64; n_nodes]); - for g in 0..n_nodes { - n_ig[g] = 1.0 + rng.next_f64() * 3.0; - r_ig[g] = n_ig[g] * rng.next_f64(); - } - let (a, b) = (vec![0.8f64, -0.5], 0.3f64); // dims.len() == 2 for both configs - let (ra, rb) = (1e-3, 1e-3); - let np = dims.len() + 1; - let (grad, amat) = - item_grad_hess(dims, &a, b, &n_ig, &r_ig, &nodes, n_dims, n_nodes, ra, rb); - let obj = |aa: &[f64], bb: f64| { - item_obj(dims, aa, bb, &n_ig, &r_ig, &nodes, n_dims, n_nodes, ra, rb) - }; - let eps = 1e-6; - let perturb = |k: usize, s: f64| -> (Vec, f64) { - let mut aa = a.clone(); - let mut bb = b; - if k < dims.len() { - aa[k] += s; - } else { - bb += s; - } - (aa, bb) - }; - for k in 0..np { - let (ap, bp) = perturb(k, eps); - let (am, bm) = perturb(k, -eps); - let fd = (obj(&ap, bp) - obj(&am, bm)) / (2.0 * eps); - assert!( - (grad[k] - fd).abs() < 1e-4, - "grad[{k}] {} vs fd {fd} (D={n_dims})", - grad[k] - ); - } - for jp in 0..np { - let (ap, bp) = perturb(jp, eps); - let (am, bm) = perturb(jp, -eps); - let (gp, _) = - item_grad_hess(dims, &ap, bp, &n_ig, &r_ig, &nodes, n_dims, n_nodes, ra, rb); - let (gm, _) = - item_grad_hess(dims, &am, bm, &n_ig, &r_ig, &nodes, n_dims, n_nodes, ra, rb); - for k in 0..np { - let dfd = (gp[k] - gm[k]) / (2.0 * eps); - assert!((dfd + amat[k][jp]).abs() < 1e-4, "H[{k}][{jp}] D={n_dims}"); - } - } - } -} - -/// D=1 (all items load the single dimension) recovers known 2PL parameters and matches -/// fit_mmle_2pl on the same data (gh_rule(41) is the same 41-node grid as mmle::GH_NODES). -#[test] -fn mirt_reduces_to_2pl_at_d1() { - let (n, n_items) = (1500usize, 12usize); - let a_true: Vec = (0..n_items).map(|i| 0.7 + 0.1 * i as f64).collect(); - let b_true: Vec = (0..n_items).map(|i| -1.0 + 0.18 * i as f64).collect(); - let mut rng = Lcg(2024); - let thetas: Vec = (0..n).map(|_| rng.normal()).collect(); - let y = simulate(&a_true, &b_true, &thetas, n, n_items, 1, &mut rng); - let observed = vec![true; n * n_items]; - let pattern = vec![1u8; n_items]; - let cfg = TwoPlConfig { - q: 41, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, 1, &cfg).unwrap(); - assert!( - rmse(&res.loading, &a_true) < 0.12, - "loading RMSE {}", - rmse(&res.loading, &a_true) - ); - assert!(rmse(&res.intercept, &b_true) < 0.12, "intercept RMSE"); - let m = fit_mmle_2pl(&y, &observed, n, n_items, &MmleConfig::default()); - assert!( - rmse(&res.loading, &m.a) < 1e-2, - "vs mmle a {}", - rmse(&res.loading, &m.a) - ); - assert!( - rmse(&res.intercept, &m.b) < 1e-2, - "vs mmle b {}", - rmse(&res.intercept, &m.b) - ); - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "monotone"); - } -} - -/// Non-trivial D=2 compensatory recovery: a confirmatory pattern (dim0-only, dim1-only, -/// AND both-loading items) with ASYMMETRIC, non-centered true loadings INCLUDING genuinely -/// NEGATIVE loadings. Recovers loadings with correct sign and per-dimension theta EAP -/// correlation. A dim-swap or a compensation-sign bug fails this. -#[test] -fn mirt_recovers_compensatory_d2() { - let n_dims = 2usize; - let mut pattern: Vec = Vec::new(); - for _ in 0..4 { - pattern.extend_from_slice(&[1, 0]); - } - for _ in 0..4 { - pattern.extend_from_slice(&[0, 1]); - } - for _ in 0..3 { - pattern.extend_from_slice(&[1, 1]); - } - let n_items = 11usize; - let a0 = [1.2, 0.8, 1.5, -0.9]; - let a1 = [1.0, 1.3, 0.7, 1.1]; - let both = [(0.9, 1.1), (1.2, -0.7), (0.8, 0.9)]; - let mut loading = vec![0.0f64; n_items * n_dims]; - for i in 0..4 { - loading[i * 2] = a0[i]; - loading[(4 + i) * 2 + 1] = a1[i]; - } - for i in 0..3 { - loading[(8 + i) * 2] = both[i].0; - loading[(8 + i) * 2 + 1] = both[i].1; - } - let intercept: Vec = (0..n_items).map(|i| -0.8 + 0.16 * i as f64).collect(); - let n = 4000usize; - let mut rng = Lcg(777); - let mut thetas = vec![0.0f64; n * n_dims]; - for j in 0..n { - thetas[j * 2] = rng.normal(); - thetas[j * 2 + 1] = rng.normal(); - } - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = TwoPlConfig { - q: 21, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).unwrap(); - for i in 0..n_items { - for d in 0..n_dims { - if pattern[i * n_dims + d] == 0 { - assert_eq!(res.loading[i * n_dims + d], 0.0, "unloaded exactly zero"); - } - } - } - assert!( - rmse(&res.loading, &loading) < 0.12, - "loading RMSE {}", - rmse(&res.loading, &loading) - ); - assert!( - res.loading[3 * 2] < -0.5, - "negative dim0 loading recovered: {}", - res.loading[3 * 2] - ); - assert!( - res.loading[9 * 2 + 1] < -0.3, - "negative cross-loading: {}", - res.loading[9 * 2 + 1] - ); - let t0h: Vec = (0..n).map(|j| res.theta[j * 2]).collect(); - let t0t: Vec = (0..n).map(|j| thetas[j * 2]).collect(); - let t1h: Vec = (0..n).map(|j| res.theta[j * 2 + 1]).collect(); - let t1t: Vec = (0..n).map(|j| thetas[j * 2 + 1]).collect(); - // EAP shrinks toward the prior, so the true-vs-EAP correlation is bounded by test - // information (not N); ~0.75-0.85 is the expected range. The POSITIVE sign is the key - // faithfulness check (a dim-swap or sign bug would give a near-zero or negative corr). - assert!(corr(&t0h, &t0t) > 0.70, "theta0 corr {}", corr(&t0h, &t0t)); - assert!(corr(&t1h, &t1t) > 0.70, "theta1 corr {}", corr(&t1h, &t1t)); - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "monotone"); - } -} - -/// Deterministic reflection tests. (b) `flip_corr_dim` negates EXACTLY the off-diagonals that -/// involve the flipped dimension (packed pairs (i,j), i = vec![1, 0, 1, 0, 0, 1, 0, 1, 1, 1]; - let mut loading = vec![0.0f64; n_items * n_dims]; - loading[0 * 2] = -1.8; // reverse-keyed anchor, largest |loading| on dim 0 - loading[1 * 2] = 1.0; - loading[2 * 2 + 1] = 1.2; - loading[3 * 2 + 1] = 1.0; - loading[4 * 2] = 0.9; - loading[4 * 2 + 1] = 0.8; - let intercept = vec![0.1, -0.2, 0.15, -0.1, 0.05]; - let n = 3000usize; - let mut rng = Lcg(4242); - let mut thetas = vec![0.0f64; n * n_dims]; - for j in 0..n { - thetas[j * 2] = rng.normal(); - thetas[j * 2 + 1] = rng.normal(); - } - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = TwoPlConfig { - q: 21, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).unwrap(); - // Canonical output: the largest pure anchor on dim 0 (item 0) ends POSITIVE; because the - // whole dimension was reflected, the positively-keyed co-item (item 1) ends NEGATIVE. - assert!( - res.loading[0 * 2] > 0.8, - "reflected anchor should be positive: {}", - res.loading[0 * 2] - ); - assert!( - res.loading[1 * 2] < -0.3, - "co-item flipped negative: {}", - res.loading[1 * 2] - ); -} - -/// Two-sided reduction anchor at D=2: the Halton QMC fit AGREES with the Gauss-Hermite fit -/// within QMC error AND DIFFERS from it bit-wise. The disagreement guard is essential — a -/// silent fallback to GH nodes on the Halton arm would make the two fits bit-identical and -/// pass a one-sided within-error check trivially. -#[test] -fn qmc_reduces_to_gh_within_error_d2() { - let n_dims = 2usize; - let mut pattern: Vec = Vec::new(); - for _ in 0..4 { - pattern.extend_from_slice(&[1, 0]); - } - for _ in 0..4 { - pattern.extend_from_slice(&[0, 1]); - } - pattern.extend_from_slice(&[1, 1]); - let n_items = 9usize; - let mut loading = vec![0.0f64; n_items * n_dims]; - for i in 0..4 { - loading[i * 2] = 1.0 + 0.15 * i as f64; - loading[(4 + i) * 2 + 1] = 1.1 - 0.1 * i as f64; - } - loading[8 * 2] = 0.9; - loading[8 * 2 + 1] = 0.8; - let intercept: Vec = (0..n_items).map(|i| -0.6 + 0.15 * i as f64).collect(); - // A fixed moderate sample is enough to distinguish the QMC path from GH while keeping this - // structural regression inside the repository's coverage-job budget. - let (n, xi_points, max_error) = (750usize, 1500usize, 0.15); - let mut rng = Lcg(1357); - let mut thetas = vec![0.0f64; n * n_dims]; - for v in thetas.iter_mut() { - *v = rng.normal(); - } - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let gh = fit_2pl( - &y, - &observed, - &pattern, - n, - n_items, - n_dims, - &TwoPlConfig { - q: 21, - max_iter: 200, - tol: 1e-5, - ..TwoPlConfig::default() - }, - ) - .unwrap(); - let qmc = fit_2pl( - &y, - &observed, - &pattern, - n, - n_items, - n_dims, - &TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points, - xi_seed: 0, - max_iter: 200, - tol: 1e-5, - ..TwoPlConfig::default() - }, - ) - .unwrap(); - let max_abs = gh - .loading - .iter() - .zip(&qmc.loading) - .chain(gh.intercept.iter().zip(&qmc.intercept)) - .map(|(a, b)| (a - b).abs()) - .fold(0.0f64, f64::max); - assert!( - max_abs < max_error, - "QMC and GH disagree beyond QMC error: {max_abs}" - ); - assert!( - max_abs > 1e-10, - "QMC fit is bit-identical to GH (silent GH fallback?)" - ); - for fit in [&gh, &qmc] { - assert!(fit.converged, "fit did not converge: {fit:?}"); - assert!(fit.n_iter < 200); - assert!(fit.final_loglik_change <= 1e-5); - } -} - -/// Deterministic FD anchor on a FIXED Halton node set at D=4 with a NON-IDENTITY dims map -/// [0,2,3] (so nodes are indexed dims[k] != k). Pins the analytic gradient and the full -/// (n_i+1)^2 Hessian — including the off-diagonal cross-Hessian and the local->pattern -/// dimension map — against central differences of item_obj to < 1e-4, on the SAME QMC nodes -/// the estimator uses. This is deterministic (fixed seed) and node-source specific, so a -/// cross-Hessian sign error or a dims[k] mis-map at D>3 fails here with no MC noise. (The -/// grid LAYOUT itself is pinned independently in nodes::halton_grid_layout_is_prime_per_axis.) -#[test] -fn qmc_item_grad_hess_matches_fd_on_halton_d4() { - let n_dims = 4usize; - let dims = vec![0usize, 2, 3]; - let xn = build_xi_nodes( - XiRule::Halton { - n: 240, - shift_seed: 0, - }, - n_dims, - ) - .unwrap(); - let nodes = &xn.grid; - let n_nodes = xn.logw.len(); - let mut rng = Lcg(2718); - let (mut n_ig, mut r_ig) = (vec![0.0f64; n_nodes], vec![0.0f64; n_nodes]); - for g in 0..n_nodes { - n_ig[g] = 1.0 + rng.next_f64() * 3.0; - r_ig[g] = n_ig[g] * rng.next_f64(); - } - let (a, b) = (vec![0.7f64, -0.6, 0.9], 0.2f64); - let (ra, rb) = (1e-3, 1e-3); - let np = dims.len() + 1; - let (grad, amat) = item_grad_hess(&dims, &a, b, &n_ig, &r_ig, nodes, n_dims, n_nodes, ra, rb); - let obj = - |aa: &[f64], bb: f64| item_obj(&dims, aa, bb, &n_ig, &r_ig, nodes, n_dims, n_nodes, ra, rb); - let eps = 1e-6; - let perturb = |k: usize, s: f64| -> (Vec, f64) { - let mut aa = a.clone(); - let mut bb = b; - if k < dims.len() { - aa[k] += s; - } else { - bb += s; - } - (aa, bb) - }; - for k in 0..np { - let (ap, bp) = perturb(k, eps); - let (am, bm) = perturb(k, -eps); - let fd = (obj(&ap, bp) - obj(&am, bm)) / (2.0 * eps); - assert!( - (grad[k] - fd).abs() < 1e-4, - "grad[{k}] {} vs fd {fd}", - grad[k] - ); - } - for jp in 0..np { - let (ap, bp) = perturb(jp, eps); - let (am, bm) = perturb(jp, -eps); - let (gp, _) = item_grad_hess(&dims, &ap, bp, &n_ig, &r_ig, nodes, n_dims, n_nodes, ra, rb); - let (gm, _) = item_grad_hess(&dims, &am, bm, &n_ig, &r_ig, nodes, n_dims, n_nodes, ra, rb); - for k in 0..np { - let dfd = (gp[k] - gm[k]) / (2.0 * eps); - assert!((dfd + amat[k][jp]).abs() < 1e-4, "H[{k}][{jp}]"); - } - } -} - -/// D=4 orthogonal recovery on Halton QMC nodes (the headline D>3 capability the GH grid cannot -/// reach). Confirmatory pattern: 2 pure anchors per dimension + cross-loaders INCLUDING a -/// genuine negative one, which is asserted recovered < 0 explicitly (a compensation-sign bug on -/// a shared dimension cannot be averaged away by an aggregate RMSE). -#[test] -fn qmc_recovers_compensatory_d4() { - let n_dims = 4usize; - let mut pattern: Vec = Vec::new(); - for d in 0..n_dims { - for _ in 0..2 { - let mut row = vec![0u8; n_dims]; - row[d] = 1; - pattern.extend_from_slice(&row); - } - } - // cross-loaders: (0,1) with a NEGATIVE dim-1 loading; (1,2); (2,3). - pattern.extend_from_slice(&[1, 1, 0, 0]); - pattern.extend_from_slice(&[0, 1, 1, 0]); - pattern.extend_from_slice(&[0, 0, 1, 1]); - let n_items = 2 * n_dims + 3; // 11 - let mut loading = vec![0.0f64; n_items * n_dims]; - for d in 0..n_dims { - loading[(2 * d) * n_dims + d] = 1.2 + 0.1 * d as f64; - loading[(2 * d + 1) * n_dims + d] = 0.9; - } - let cross = 2 * n_dims; - loading[cross * n_dims + 0] = 1.0; - loading[cross * n_dims + 1] = -0.8; // the negative cross-loader - loading[(cross + 1) * n_dims + 1] = 1.1; - loading[(cross + 1) * n_dims + 2] = 0.7; - loading[(cross + 2) * n_dims + 2] = 0.8; - loading[(cross + 2) * n_dims + 3] = 1.0; - let intercept: Vec = (0..n_items).map(|i| -0.5 + 0.12 * i as f64).collect(); - let (n, xi_points, loading_rmse_limit, negative_loading_limit, theta_corr_limit) = - (800usize, 1600usize, 0.26, -0.15, 0.45); - let mut rng = Lcg(9001); - let mut thetas = vec![0.0f64; n * n_dims]; - for v in thetas.iter_mut() { - *v = rng.normal(); - } - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points, - xi_seed: 12345, - max_iter: 200, - tol: 1e-5, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert_eq!(res.n_dims, 4); - for i in 0..n_items { - for d in 0..n_dims { - if pattern[i * n_dims + d] == 0 { - assert_eq!(res.loading[i * n_dims + d], 0.0, "unloaded exactly zero"); - } - } - } - assert!( - rmse(&res.loading, &loading) < loading_rmse_limit, - "loading RMSE {}", - rmse(&res.loading, &loading) - ); - // the negative cross-loader recovered negative (sign / compensation guard). - assert!( - res.loading[cross * n_dims + 1] < negative_loading_limit, - "neg cross-loader: {}", - res.loading[cross * n_dims + 1] - ); - for d in 0..n_dims { - let th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| thetas[j * n_dims + d]).collect(); - assert!( - corr(&th, &tt) > theta_corr_limit, - "theta{d} corr {}", - corr(&th, &tt) - ); - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "monotone"); - } - assert!(res.converged, "fit did not converge: {res:?}"); - assert!(res.n_iter < 200); - assert!(res.final_loglik_change <= 1e-5); -} - -/// D=4 correlated WIRING on Halton QMC nodes: the correlated path runs at D>3 and returns a -/// valid positive-definite, unit-diagonal Sigma whose off-diagonals recover the POSITIVE -/// equicorrelation (truth rho=0.4) directionally, with monotone EM. This exercises the Cholesky -/// node map, Sigma M-step, and observed-objective backtracking at D>3. It is deliberately a -/// directional/structural check, NOT a -/// tight per-pair recovery: at an affordable point count the higher-prime Halton axes carry real -/// QMC error in individual Sigma off-diagonals (documented ceiling), so a broken M-step (Sigma=I, -/// non-PD, NaN, or sign-flipped) is what this catches. Tight per-pair Sigma recovery needs a much -/// larger point count (n>=8000 at N>=4000 brings the worst pair within ~0.14 of the realized -/// correlation) and is out of scope for a fast test. -#[test] -fn qmc_recovers_correlated_d4() { - let n_dims = 4usize; - // pure anchors: 2 per dim (identification under correlation needs pure indicators). - let mut pattern: Vec = Vec::new(); - for d in 0..n_dims { - for _ in 0..2 { - let mut row = vec![0u8; n_dims]; - row[d] = 1; - pattern.extend_from_slice(&row); - } - } - let n_items = 2 * n_dims; // 8, all pure - let mut loading = vec![0.0f64; n_items * n_dims]; - for d in 0..n_dims { - loading[(2 * d) * n_dims + d] = 1.3; - loading[(2 * d + 1) * n_dims + d] = 1.0; - } - let intercept: Vec = (0..n_items).map(|i| -0.4 + 0.1 * i as f64).collect(); - // Build an equicorrelation Sigma (all pairwise correlations = rho) and its Cholesky. - let rho = 0.4f64; - let mut sigma = vec![rho; n_dims * n_dims]; - for i in 0..n_dims { - sigma[i * n_dims + i] = 1.0; - } - let lchol = chol_lower(&sigma, n_dims).unwrap(); - let (n, xi_points, min_correlation) = (700usize, 1400usize, 0.1); - let mut rng = Lcg(20260716); - let mut thetas = vec![0.0f64; n * n_dims]; - for j in 0..n { - let z: Vec = (0..n_dims).map(|_| rng.normal()).collect(); - for k in 0..n_dims { - let mut t = 0.0f64; - for m in 0..=k { - t += lchol[k * n_dims + m] * z[m]; - } - thetas[j * n_dims + k] = t; - } - } - // realized sample correlation of the drawn traits (the estimable target under finite N). - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points, - xi_seed: 777, - estimate_corr: true, - max_iter: 200, - tol: 1e-5, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert_eq!(res.corr.len(), n_dims * n_dims); - for i in 0..n_dims { - assert!( - (res.corr[i * n_dims + i] - 1.0).abs() < 1e-9, - "unit diagonal" - ); - } - // Structural: the returned Sigma is a valid positive-definite correlation matrix, and every - // off-diagonal is a genuine (non-degenerate) correlation. - assert!(chol_lower(&res.corr, n_dims).is_some(), "Sigma is PD"); - // Directional: the positive equicorrelation (truth rho=0.4) is recovered as a clearly - // POSITIVE mean off-diagonal. A broken Sigma M-step returning I gives mean 0; a sign flip - // gives a negative mean. We do NOT assert closeness to the realized ~0.41: at this - // affordable point count the higher-prime Halton axes bias the recovered correlations UPWARD - // by ~0.15 on the mean (the documented QMC ceiling), so tight closeness needs a much larger n. - let mut rec_sum = 0.0f64; - let mut cnt = 0.0f64; - for i in 0..n_dims { - for j in (i + 1)..n_dims { - assert!( - res.corr[i * n_dims + j].abs() < 0.999, - "off-diagonal not degenerate" - ); - rec_sum += res.corr[i * n_dims + j]; - cnt += 1.0; - } - } - let rec_mean = rec_sum / cnt; - assert!( - rec_mean > min_correlation, - "recovered mean correlation {rec_mean} not clearly positive" - ); - assert!( - rec_mean < 0.85, - "recovered mean correlation {rec_mean} implausibly high" - ); - // Observed-objective backtracking rejects a Sigma step that decreases the finite-QMC marginal - // likelihood, so the converged trace must remain monotone up to roundoff. - let trace = &res.loglik_trace; - let max_dec = trace - .windows(2) - .map(|w| (w[0] - w[1]).max(0.0)) - .fold(0.0f64, f64::max); - assert!( - max_dec < 1e-6, - "per-step decrease {max_dec} violates EM ascent" - ); - assert!(*trace.last().unwrap() >= trace[0], "overall EM ascent"); - assert!(res.converged, "fit did not converge: {res:?}"); - assert!(res.n_iter < 200); - assert!(res.final_loglik_change <= 1e-5); - println!( - "[QMC correlated D4] converged={} reason={} n_iter={}/{} final_change={} tolerance={} max_drop={}", - res.converged, - res.termination_reason, - res.n_iter, - 200, - res.final_loglik_change, - 1e-5, - max_dec - ); -} - -/// Rule-dependent validation: GH stays D<=3, QMC allows D<=6 and bounds xi_points; `q` is -/// unused on the QMC arms (an out-of-set q must NOT reject a Halton fit). -#[test] -fn mirt_qmc_validates() { - let n = 200usize; - // GH rejects D=4; Halton accepts it (needs a D=4 pattern with pure anchors). - let gh4 = TwoPlConfig { - estimate_corr: false, - ..TwoPlConfig::default() - }; - // build a minimal D=4 pattern (one pure anchor per dim) + data of the right shape. - let n_dims4 = 4usize; - let mut pat4: Vec = Vec::new(); - for d in 0..n_dims4 { - let mut row = vec![0u8; n_dims4]; - row[d] = 1; - pat4.extend_from_slice(&row); - } - let ni4 = n_dims4; - let y4 = vec![1.0f64; n * ni4]; - let obs4 = vec![true; n * ni4]; - assert!( - fit_2pl(&y4, &obs4, &pat4, n, ni4, n_dims4, &gh4).is_err(), - "GH D=4 rejected" - ); - // Halton D=4 with an INVALID GH q (q ignored on the QMC arm) must SUCCEED. - let ok = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points: 400, - xi_seed: 1, - q: 99, - max_iter: 3, - ..TwoPlConfig::default() - }; - assert!( - fit_2pl(&y4, &obs4, &pat4, n, ni4, n_dims4, &ok).is_ok(), - "Halton D=4 q=99 ok" - ); - // Halton D=6 (the UPPER bound MIRT_MAX_DIMS_QMC = HALTON_PRIMES.len()) is ACCEPTED. Pins - // the boundary so a shrink of the constant to 5 (silently rejecting valid D=6) is caught; - // D=7 just below is REJECTED (beyond the prime axes). - let mut pat6 = Vec::new(); - for d in 0..6 { - let mut r = vec![0u8; 6]; - r[d] = 1; - pat6.extend_from_slice(&r); - } - let y6 = vec![1.0f64; n * 6]; - let obs6 = vec![true; n * 6]; - let d6 = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points: 200, - max_iter: 1, - ..TwoPlConfig::default() - }; - assert!( - fit_2pl(&y6, &obs6, &pat6, n, 6, 6, &d6).is_ok(), - "Halton D=6 accepted" - ); - let d7 = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points: 100, - ..TwoPlConfig::default() - }; - let mut pat7 = Vec::new(); - for d in 0..7 { - let mut r = vec![0u8; 7]; - r[d] = 1; - pat7.extend_from_slice(&r); - } - let y7 = vec![1.0f64; n * 7]; - let obs7 = vec![true; n * 7]; - assert!( - fit_2pl(&y7, &obs7, &pat7, n, 7, 7, &d7).is_err(), - "Halton D=7 rejected" - ); - // xi_points bounds: 0 rejected; MAX+1 rejected. - let zero = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points: 0, - ..TwoPlConfig::default() - }; - assert!( - fit_2pl(&y4, &obs4, &pat4, n, ni4, n_dims4, &zero).is_err(), - "xi_points=0 rejected" - ); - let huge = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points: MIRT_MAX_NODES + 1, - ..TwoPlConfig::default() - }; - assert!( - fit_2pl(&y4, &obs4, &pat4, n, ni4, n_dims4, &huge).is_err(), - "xi_points>MAX rejected" - ); - // MonteCarlo D=7 also rejected (its builder has no cap; validate is the sole guard). - let mc7 = TwoPlConfig { - xi_rule: XiRuleKind::MonteCarlo, - xi_points: 100, - ..TwoPlConfig::default() - }; - assert!( - fit_2pl(&y7, &obs7, &pat7, n, 7, 7, &mc7).is_err(), - "MC D=7 rejected" - ); - - // Individually valid xi_points and item counts must not combine into an unbounded dense - // E-step table. This input is tiny (one response per item), but without the aggregate guard - // it attempts four 200_000 x 301 f64 tables before doing any statistical work. - let table_items = MIRT_MAX_NODE_ITEM_CELLS / MIRT_MAX_NODES + 1; - let table_y = vec![0.0; table_items]; - let table_obs = vec![true; table_items]; - let table_pattern = vec![1u8; table_items]; - let table_cfg = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points: MIRT_MAX_NODES, - max_iter: 1, - ..TwoPlConfig::default() - }; - let err = fit_2pl( - &table_y, - &table_obs, - &table_pattern, - 1, - table_items, - 1, - &table_cfg, - ) - .unwrap_err(); - assert!(err.contains("node * item table"), "{err}"); -} - -fn small_design() -> (Vec, Vec, Vec, usize) { - let mut pattern: Vec = Vec::new(); - for _ in 0..3 { - pattern.extend_from_slice(&[1, 0]); - } - for _ in 0..3 { - pattern.extend_from_slice(&[0, 1]); - } - pattern.extend_from_slice(&[1, 1]); - let n_items = 7usize; - let mut loading = vec![0.0f64; n_items * 2]; - for i in 0..3 { - loading[i * 2] = 1.0 + 0.2 * i as f64; - loading[(3 + i) * 2 + 1] = 1.0 + 0.2 * i as f64; - } - loading[6 * 2] = 0.9; - loading[6 * 2 + 1] = 0.8; - let intercept: Vec = (0..n_items).map(|i| -0.5 + 0.15 * i as f64).collect(); - (pattern, loading, intercept, n_items) -} - -#[test] -fn mirt_validates_and_handles_missing() { - let (pattern, loading, intercept, n_items) = small_design(); - let (n, n_dims) = (400usize, 2usize); - let mut rng = Lcg(31); - let mut thetas = vec![0.0f64; n * n_dims]; - for j in 0..n { - thetas[j * 2] = rng.normal(); - thetas[j * 2 + 1] = rng.normal(); - } - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let cfg = TwoPlConfig::default(); - let mut observed = vec![true; n * n_items]; - observed[0] = false; - observed[n_items + 3] = false; - assert!(fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).is_ok()); - let obs = vec![true; n * n_items]; - let allones = vec![1u8; n_items * n_dims]; - assert!(fit_2pl(&y, &obs, &allones, n, n_items, n_dims, &cfg).is_err()); - let mut badrow = pattern.clone(); - badrow[0] = 0; - badrow[1] = 0; - assert!(fit_2pl(&y, &obs, &badrow, n, n_items, n_dims, &cfg).is_err()); - let mut nopure = pattern.clone(); - for i in 0..3 { - nopure[i * 2 + 1] = 1; // items 0,1,2 now load both dims -> dim0 has no pure anchor - } - assert!(fit_2pl(&y, &obs, &nopure, n, n_items, n_dims, &cfg).is_err()); - assert!(fit_2pl(&y, &obs, &vec![1u8; n_items * 4], n, n_items, 4, &cfg).is_err()); - let badq = TwoPlConfig { - q: 10, - ..TwoPlConfig::default() - }; - assert!(fit_2pl(&y, &obs, &pattern, n, n_items, n_dims, &badq).is_err()); - let mut ybad = y.clone(); - ybad[5] = 2.0; - assert!(fit_2pl(&ybad, &obs, &pattern, n, n_items, n_dims, &cfg).is_err()); -} - -#[test] -fn mirt_validation_covers_every_scalar_shape_and_item_boundary() { - let y = [0.0, 1.0]; - let observed = [true, true]; - let pattern = [1u8]; - let base = TwoPlConfig { - q: 7, - max_iter: 1, - ..TwoPlConfig::default() - }; - - assert_eq!(checked_grid_nodes(1, 7), Ok(7)); - assert!(checked_grid_nodes(MIRT_MAX_NODES, 2).is_err()); - assert!(should_stop_item_newton(false, f64::INFINITY)); - assert!(should_stop_item_newton(true, 0.0)); - assert!(!should_stop_item_newton(true, 1.0)); - let mut mapped = [0.0, 0.0]; - assert!(map_corr_nodes(&[], &[1.0, -1.0], 1, &mut mapped)); - assert_eq!(mapped, [1.0, -1.0]); - let mut invalid_mapped = [0.0; 3]; - assert!(!map_corr_nodes( - &[0.9, 0.9, -0.9], - &[0.0; 3], - 3, - &mut invalid_mapped - )); - assert_eq!( - marginal_loglik_on_nodes( - &[1.0], - &[false], - &[1.0], - &[0.0], - &[vec![0]], - 1, - 1, - 1, - &[0.0], - &[0.0] - ), - 0.0 - ); - - assert!(validate(&[], &[], &[], 0, 1, 1, &base).is_err()); - let cfg = TwoPlConfig { - max_iter: 0, - ..base - }; - assert!(validate(&y, &observed, &pattern, 2, 1, 1, &cfg).is_err()); - let cfg = TwoPlConfig { - max_iter: MIRT_MAX_ITER + 1, - ..base - }; - assert!(validate(&y, &observed, &pattern, 2, 1, 1, &cfg).is_err()); - for tol in [0.0, f64::NAN, f64::INFINITY] { - let cfg = TwoPlConfig { tol, ..base }; - assert!(validate(&y, &observed, &pattern, 2, 1, 1, &cfg).is_err()); - } - for (ridge_a, ridge_b) in [ - (0.0, base.ridge_b), - (f64::NAN, base.ridge_b), - (base.ridge_a, 0.0), - (base.ridge_a, f64::INFINITY), - ] { - let cfg = TwoPlConfig { - ridge_a, - ridge_b, - ..base - }; - assert!(validate(&y, &observed, &pattern, 2, 1, 1, &cfg).is_err()); - } - - assert!(validate(&y, &observed, &pattern, 2, 1, 0, &base).is_err()); - let bad_q = TwoPlConfig { q: 9, ..base }; - assert!(validate(&y, &observed, &pattern, 2, 1, 1, &bad_q).is_err()); - let qmc = TwoPlConfig { - xi_rule: XiRuleKind::MonteCarlo, - xi_points: 1, - ..base - }; - assert!(validate(&y, &observed, &pattern, 2, 1, 0, &qmc).is_err()); - let no_points = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points: 0, - ..base - }; - assert!(validate(&y, &observed, &pattern, 2, 1, 1, &no_points).is_err()); - - assert!(validate(&y[..1], &observed, &pattern, 2, 1, 1, &base).is_err()); - assert!(validate(&y, &observed[..1], &pattern, 2, 1, 1, &base).is_err()); - assert!(validate(&y, &observed, &[], 2, 1, 1, &base).is_err()); - assert!(validate(&[0.0, 2.0], &observed, &pattern, 2, 1, 1, &base).is_err()); - assert!(validate(&y, &observed, &[2], 2, 1, 1, &base).is_err()); - assert!(validate(&y, &observed, &[0], 2, 1, 1, &base).is_err()); - assert!(validate(&y, &[false, false], &pattern, 2, 1, 1, &base).is_err()); - - let two_dim_y = [0.0, 1.0, 1.0, 0.0]; - let two_dim_observed = [true; 4]; - assert!(validate(&two_dim_y, &two_dim_observed, &[1, 1, 0, 1], 2, 2, 2, &base,).is_err()); - - assert!(validate(&[], &[], &[], 1, usize::MAX, 1, &base).is_err()); - - let (gradient, information) = item_grad_hess( - &[0], - &[1.0], - 0.0, - &[0.0], - &[0.0], - &[0.0], - 1, - 1, - base.ridge_a, - base.ridge_b, - ); - assert_eq!(gradient, vec![-base.ridge_a, 0.0]); - assert_eq!(information[0][0], base.ridge_a); - - assert!(corr_line_search(&[0.0], &[f64::NAN], 0.0, &[1.0, 0.0, 0.0, 1.0], 2).is_none()); - let dims = vec![vec![0], vec![1]]; - let mut loading = vec![0.0; 4]; - let mut theta = vec![2.0, 3.0]; - let mut correlation = vec![0.5]; - reflect_mirt_dimensions(&mut loading, &mut theta, &mut correlation, &dims, 1, 2, 2); - assert_eq!(theta, vec![2.0, 3.0]); - loading[0] = -1.0; - loading[3] = 1.0; - reflect_mirt_dimensions(&mut loading, &mut theta, &mut correlation, &dims, 1, 2, 2); - assert_eq!(loading[0], 1.0); - assert_eq!(theta[0], -2.0); - assert_eq!(correlation, vec![-0.5]); -} - -#[test] -fn monte_carlo_fit_executes_the_seeded_node_path() { - let y = [0.0, 1.0, 1.0, 0.0]; - let observed = [true; 4]; - let pattern = [1u8, 1u8]; - let cfg = TwoPlConfig { - xi_rule: XiRuleKind::MonteCarlo, - xi_points: 16, - xi_seed: 17, - max_iter: 1, - newton_iter: 1, - ..TwoPlConfig::default() - }; - let fit = fit_2pl(&y, &observed, &pattern, 2, 2, 1, &cfg).unwrap(); - assert_eq!(fit.n_iter, 1); - assert!(fit.loglik_trace.iter().all(|value| value.is_finite())); -} - -/// The final E-step is a genuine evaluated stopping point: meeting tolerance there is -/// convergence even when it follows the last permitted M-step; otherwise exhaustion stays -/// explicit and reports the observed stopping metric. -#[test] -fn mirt_reports_final_stopping_evidence() { - let pattern = vec![1u8, 0, 0, 1]; - let balanced = vec![0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0]; - let observed = vec![true; balanced.len()]; - let cfg = TwoPlConfig { - q: 7, - max_iter: 1, - ..TwoPlConfig::default() - }; - let stable = fit_2pl(&balanced, &observed, &pattern, 4, 2, 2, &cfg).unwrap(); - assert!(stable.converged); - assert_eq!(stable.termination_reason, "converged"); - assert_eq!(stable.n_iter, cfg.max_iter); - assert_eq!(stable.loglik_trace.len(), 2); - assert!(stable.final_loglik_change <= cfg.tol); - - let mut y = vec![0.0f64; 20 * 4]; - for p in 0..20 { - y[p * 4] = if p % 5 == 0 { 0.0 } else { 1.0 }; - y[p * 4 + 1] = if p % 3 == 0 { 1.0 } else { 0.0 }; - y[p * 4 + 2] = if p % 4 == 0 { 0.0 } else { 1.0 }; - y[p * 4 + 3] = if p % 6 == 0 { 1.0 } else { 0.0 }; - } - let observed = vec![true; y.len()]; - let pattern4 = vec![1u8, 0, 1, 0, 0, 1, 0, 1]; - let strict = TwoPlConfig { - q: 7, - max_iter: 1, - tol: 1e-12, - ..TwoPlConfig::default() - }; - let unfinished = fit_2pl(&y, &observed, &pattern4, 20, 4, 2, &strict).unwrap(); - assert!(!unfinished.converged); - assert_eq!(unfinished.termination_reason, "max_iter_reached"); - assert_eq!(unfinished.n_iter, strict.max_iter); - assert_eq!(unfinished.loglik_trace.len(), 2); - assert!(unfinished.final_loglik_change >= strict.tol); -} - -/// Literature-grade Monte-Carlo (>=500 reps): recover the compensatory loadings and traits -/// at D=2 and D=3 under BOTH a normal and a right-skew (per-dim z-standardized, so only the -/// SHAPE is misspecified) trait distribution. Loading RMSE is the primary target; the skew -/// arm uses a looser bound (recovery is genuinely harder under shape misspecification). -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_mirt_recovery_500() { - let reps = 500usize; - for &(n_dims, q, n) in [(2usize, 15usize, 3000usize), (3usize, 11usize, 2000usize)].iter() { - let mut pattern: Vec = Vec::new(); - for d in 0..n_dims { - for _ in 0..3 { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - pattern.extend_from_slice(&r); - } - } - for d in 0..n_dims { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - r[(d + 1) % n_dims] = 1; - pattern.extend_from_slice(&r); - } - let n_items = 3 * n_dims + n_dims; - let mut loading = vec![0.0f64; n_items * n_dims]; - for d in 0..n_dims { - for k in 0..3 { - loading[(d * 3 + k) * n_dims + d] = 0.9 + 0.3 * k as f64; - } - } - for d in 0..n_dims { - let base = 3 * n_dims + d; - loading[base * n_dims + d] = 1.0; - loading[base * n_dims + (d + 1) % n_dims] = 0.7; - } - let intercept: Vec = (0..n_items).map(|i| -0.6 + 0.12 * i as f64).collect(); - - for &skew in [false, true].iter() { - let (mut lnum, mut lden, mut lbias) = (0.0f64, 0.0f64, 0.0f64); - let (mut csum, mut ccnt) = (0.0f64, 0.0f64); - let mut nconv = 0usize; - for rep in 0..reps { - let mut rng = Lcg(0x9E3779B97F4A7C15u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0xD1B54A32D192ED03) - .wrapping_add(n_dims as u64 * 0x100000001B3)); - let mut thetas = vec![0.0f64; n * n_dims]; - for d in 0..n_dims { - let col: Vec = (0..n) - .map(|_| { - if skew { - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / 6f64.sqrt() - } else { - rng.normal() - } - }) - .collect(); - let m = col.iter().sum::() / n as f64; - let v = col.iter().map(|x| (x - m) * (x - m)).sum::() / n as f64; - let sd = v.sqrt(); - for j in 0..n { - thetas[j * n_dims + d] = (col[j] - m) / sd; - } - } - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = TwoPlConfig { - q, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).unwrap(); - if res.converged { - nconv += 1; - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "monotone loglik (rep {rep})"); - } - for i in 0..n_items { - for d in 0..n_dims { - let v = res.loading[i * n_dims + d]; - if pattern[i * n_dims + d] == 0 { - assert_eq!(v, 0.0, "unloaded exactly zero"); - } else { - assert!(v.is_finite() && v.abs() <= 10.0, "loading in bound"); - let e = v - loading[i * n_dims + d]; - lnum += e * e; - lden += 1.0; - lbias += e; - } - } - } - for d in 0..n_dims { - let th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| thetas[j * n_dims + d]).collect(); - csum += corr(&th, &tt); - ccnt += 1.0; - } - } - let lrmse = (lnum / lden).sqrt(); - let (lb, tc, conv) = (lbias / lden, csum / ccnt, nconv as f64 / reps as f64); - println!( - "[mirt MC D={n_dims} q={q} N={n} skew={skew}] reps={reps} conv={conv:.3} \ - loadRMSE={lrmse:.4} loadBias={lb:.4} thetaCorr={tc:.3}" - ); - // Thresholds calibrated from a 40-rep pilot (D2/D3 x normal/skew, N=3000/2000). - assert!(conv > 0.95, "convergence {conv} (D={n_dims} skew={skew})"); - if skew { - // Shape misspecification: loadings attenuate (bias ~ -0.06..-0.09, expected); - // recovery is looser but the per-dim trait EAP stays clearly positive. - assert!(lrmse < 0.20, "skew loading RMSE {lrmse} (D={n_dims})"); - assert!(tc > 0.62, "skew theta corr {tc} (D={n_dims})"); - } else { - // Correctly-specified N(0,I): recovery is UNBIASED (the correctness signal). - assert!(lb.abs() < 0.03, "loading bias {lb} (D={n_dims})"); - assert!(lrmse < 0.14, "loading RMSE {lrmse} (D={n_dims})"); - assert!(tc > 0.68, "theta corr {tc} (D={n_dims})"); - } - } - } -} - -/// Literature-grade Monte-Carlo (>=500 reps) for the HIGH-DIMENSIONAL QMC path (`D > 3`, which -/// the Gauss-Hermite product grid cannot reach): recover the compensatory loadings and traits -/// at D=4 and D=5 on Halton QMC nodes, under a normal AND a per-dim-standardized right-skew -/// trait. The QMC node set is FIXED across the EM run (so EM is monotone) and across reps (a -/// deterministic quadrature); the finite-node QMC bias is what the looser-than-GH thresholds -/// absorb, and averaging over reps is what pins the low-variance recovery the single fast test -/// cannot. Per-rep finiteness + monotone-EM canaries; non-convergence tracked separately. -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_qmc_mirt_recovery_500() { - let reps = 500usize; - for &(n_dims, xi_points, n) in [ - (4usize, 4000usize, 2000usize), - (5usize, 6000usize, 1500usize), - ] - .iter() - { - // 2 pure anchors per dim (identification) + one cross-loader per dim. - let mut pattern: Vec = Vec::new(); - for d in 0..n_dims { - for _ in 0..2 { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - pattern.extend_from_slice(&r); - } - } - for d in 0..n_dims { - let mut r = vec![0u8; n_dims]; - r[d] = 1; - r[(d + 1) % n_dims] = 1; - pattern.extend_from_slice(&r); - } - let n_items = 2 * n_dims + n_dims; - let mut loading = vec![0.0f64; n_items * n_dims]; - for d in 0..n_dims { - loading[(2 * d) * n_dims + d] = 1.2; - loading[(2 * d + 1) * n_dims + d] = 0.9; - } - for d in 0..n_dims { - let base = 2 * n_dims + d; - loading[base * n_dims + d] = 1.0; - // alternate the cross-loader sign so a compensation-sign bug cannot hide. - loading[base * n_dims + (d + 1) % n_dims] = if d % 2 == 0 { 0.7 } else { -0.7 }; - } - let intercept: Vec = (0..n_items).map(|i| -0.5 + 0.1 * i as f64).collect(); - - for &skew in [false, true].iter() { - let (mut lnum, mut lden, mut lbias) = (0.0f64, 0.0f64, 0.0f64); - let (mut csum, mut ccnt) = (0.0f64, 0.0f64); - let mut nconv = 0usize; - for rep in 0..reps { - let mut rng = Lcg(0x9E3779B97F4A7C15u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0xD1B54A32D192ED03) - .wrapping_add(n_dims as u64 * 0x100000001B3)); - let mut thetas = vec![0.0f64; n * n_dims]; - for d in 0..n_dims { - let col: Vec = (0..n) - .map(|_| { - if skew { - let mut cc = 0.0; - for _ in 0..3 { - let z = rng.normal(); - cc += z * z; - } - (cc - 3.0) / 6f64.sqrt() - } else { - rng.normal() - } - }) - .collect(); - let m = col.iter().sum::() / n as f64; - let v = col.iter().map(|x| (x - m) * (x - m)).sum::() / n as f64; - let sd = v.sqrt(); - for j in 0..n { - thetas[j * n_dims + d] = (col[j] - m) / sd; - } - } - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = TwoPlConfig { - xi_rule: XiRuleKind::Halton, - xi_points, - xi_seed: 0x2545_F491_4F6C_DD1D, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).unwrap(); - if res.converged { - nconv += 1; - } - assert!( - res.loglik_trace.iter().all(|v| v.is_finite()), - "finite loglik (rep {rep})" - ); - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "monotone loglik (rep {rep})"); - } - for i in 0..n_items { - for d in 0..n_dims { - let v = res.loading[i * n_dims + d]; - if pattern[i * n_dims + d] == 0 { - assert_eq!(v, 0.0, "unloaded exactly zero"); - } else { - assert!(v.is_finite() && v.abs() <= 10.0, "loading in bound"); - let e = v - loading[i * n_dims + d]; - lnum += e * e; - lden += 1.0; - lbias += e; - } - } - } - assert!( - res.theta.iter().all(|v| v.is_finite()), - "finite theta (rep {rep})" - ); - for d in 0..n_dims { - let th: Vec = (0..n).map(|j| res.theta[j * n_dims + d]).collect(); - let tt: Vec = (0..n).map(|j| thetas[j * n_dims + d]).collect(); - csum += corr(&th, &tt); - ccnt += 1.0; - } - } - let lrmse = (lnum / lden).sqrt(); - let (lb, tc, conv) = (lbias / lden, csum / ccnt, nconv as f64 / reps as f64); - println!( - "[qmc-mirt MC D={n_dims} xi={xi_points} N={n} skew={skew}] reps={reps} \ - conv={conv:.3} loadRMSE={lrmse:.4} loadBias={lb:.4} thetaCorr={tc:.3}" - ); - // Looser than the GH MC: QMC carries an O(N^-1 (log N)^D) finite-node bias that - // grows with D. Calibrated from a 50-rep pilot at D=4/5 x normal/skew (conv=1.000; - // normal loadRMSE 0.13/0.17, bias ~0.01; skew loadRMSE 0.16/0.21, bias ~-0.07/-0.09; - // thetaCorr 0.58-0.64) with margin for the 500-rep estimate. - assert!(conv > 0.90, "convergence {conv} (D={n_dims} skew={skew})"); - if skew { - assert!(lrmse < 0.26, "skew loading RMSE {lrmse} (D={n_dims})"); - assert!(tc > 0.50, "skew theta corr {tc} (D={n_dims})"); - } else { - assert!(lb.abs() < 0.06, "loading bias {lb} (D={n_dims})"); - assert!(lrmse < 0.19, "loading RMSE {lrmse} (D={n_dims})"); - assert!(tc > 0.55, "theta corr {tc} (D={n_dims})"); - } - } - } -} - -// ----- Correlated-Sigma extension (theta ~ MVN(0, Sigma)) ----- - -/// Draw N x D standard normals correlated through L = chol(Sigma): theta = L z. -fn draw_corr(l: &[f64], n: usize, d: usize, rng: &mut Lcg) -> Vec { - let mut th = vec![0.0f64; n * d]; - for j in 0..n { - let z: Vec = (0..d).map(|_| rng.normal()).collect(); - for k in 0..d { - let mut t = 0.0; - for i in 0..=k { - t += l[k * d + i] * z[i]; - } - th[j * d + k] = t; - } - } - th -} - -/// Realized sample correlation off-diagonals (pairs i Vec { - let mut mean = vec![0.0f64; d]; - for j in 0..n { - for k in 0..d { - mean[k] += th[j * d + k]; - } - } - for m in mean.iter_mut() { - *m /= n as f64; - } - let mut var = vec![0.0f64; d]; - let mut off = Vec::new(); - for i in 0..d { - for j in 0..n { - var[i] += (th[j * d + i] - mean[i]).powi(2); - } - } - for i in 0..d { - for k in i + 1..d { - let mut cov = 0.0; - for j in 0..n { - cov += (th[j * d + i] - mean[i]) * (th[j * d + k] - mean[k]); - } - off.push(cov / (var[i] * var[k]).sqrt()); - } - } - off -} - -/// estimate_corr = false reports Sigma = I exactly and keeps the orthogonal parameter count. -#[test] -fn mirt_estimate_corr_false_is_identity() { - let (pattern, loading, intercept, n_items) = small_design(); - let (n, n_dims) = (300usize, 2usize); - let mut rng = Lcg(5); - let mut thetas = vec![0.0f64; n * n_dims]; - for t in thetas.iter_mut() { - *t = rng.normal(); - } - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let res = fit_2pl( - &y, - &observed, - &pattern, - n, - n_items, - n_dims, - &TwoPlConfig::default(), - ) - .unwrap(); - assert_eq!(res.corr, vec![1.0, 0.0, 0.0, 1.0], "Sigma == I exactly"); - let nfree = pattern.iter().filter(|&&v| v == 1).count(); - assert_eq!(res.n_parameters, nfree + n_items, "no extra corr params"); -} - -/// flip_corr_dim negates exactly the correlations that involve the flipped dimension. -#[test] -fn mirt_flip_corr_dim_negates_involving_dim() { - // D=3, off-diagonal order (0,1),(0,2),(1,2). - let mut r = vec![0.3f64, -0.2, 0.5]; - flip_corr_dim(&mut r, 3, 0); // negate pairs touching dim 0: (0,1),(0,2); (1,2) unchanged - assert_eq!(r, vec![-0.3, 0.2, 0.5]); - flip_corr_dim(&mut r, 3, 1); // negate pairs touching dim 1: (0,1),(1,2); (0,2) unchanged - assert_eq!(r, vec![0.3, 0.2, -0.5]); -} - -/// Deterministic FD anchor: the analytic correlation gradient matches central finite -/// differences of Q_prior at a Sigma with NONZERO off-diagonals and a non-diagonal C. -#[test] -fn mirt_sigma_grad_matches_finite_difference() { - for &(d, ref r0, ref c) in [ - (2usize, vec![0.35f64], vec![1.2f64, 0.5, 0.5, 0.9]), - ( - 3usize, - vec![0.3f64, -0.15, 0.25], - vec![1.1f64, 0.4, 0.2, 0.4, 0.95, -0.3, 0.2, -0.3, 1.05], - ), - ] - .iter() - { - let sigma = build_corr(r0, d); - let g = sigma_grad(&sigma, c, d).unwrap(); - let eps = 1e-6; - for m in 0..r0.len() { - let mut rp = r0.clone(); - let mut rm = r0.clone(); - rp[m] += eps; - rm[m] -= eps; - let qp = sigma_qprior(&build_corr(&rp, d), c, d).unwrap(); - let qm = sigma_qprior(&build_corr(&rm, d), c, d).unwrap(); - let fd = (qp - qm) / (2.0 * eps); - assert!( - (g[m] - fd).abs() < 1e-5, - "D={d} grad[{m}] {} vs fd {fd}", - g[m] - ); - } - } -} - -/// Recover a KNOWN correlated Sigma (rho = 0.5) AND loadings at D=2, with the largest-|loading| -/// PURE anchor on dim 0 genuinely NEGATIVE so the reflection FIRES: the reported correlation -/// must then carry the flip-consistent sign (a missing Sigma sign-flip would report +rho). -#[test] -fn mirt_recovers_correlated_d2_with_reflection() { - let n_dims = 2usize; - let mut pattern: Vec = Vec::new(); - for _ in 0..4 { - pattern.extend_from_slice(&[1, 0]); - } - for _ in 0..4 { - pattern.extend_from_slice(&[0, 1]); - } - for _ in 0..2 { - pattern.extend_from_slice(&[1, 1]); - } - let n_items = 10usize; - let mut loading = vec![0.0f64; n_items * n_dims]; - // dim0 pure anchors: largest |.| is -1.6 (NEGATIVE) -> reflection flips dim 0. - let a0 = [1.0, 0.8, -1.6, 1.1]; - let a1 = [1.2, 0.9, 1.4, 1.0]; - for i in 0..4 { - loading[i * 2] = a0[i]; - loading[(4 + i) * 2 + 1] = a1[i]; - } - loading[8 * 2] = 0.9; - loading[8 * 2 + 1] = 0.8; - loading[9 * 2] = 1.1; - loading[9 * 2 + 1] = 0.7; - let intercept: Vec = (0..n_items).map(|i| -0.6 + 0.13 * i as f64).collect(); - let rho = 0.5; - let lchol = chol_lower(&build_corr(&[rho], n_dims), n_dims).unwrap(); - let n = 5000usize; - let mut rng = Lcg(4242); - let thetas = draw_corr(&lchol, n, n_dims, &mut rng); - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = TwoPlConfig { - q: 15, - estimate_corr: true, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).unwrap(); - assert!(res.converged); - // Sigma is a valid unit-diagonal correlation matrix. - assert!((res.corr[0] - 1.0).abs() < 1e-12 && (res.corr[3] - 1.0).abs() < 1e-12); - assert!((res.corr[1] - res.corr[2]).abs() < 1e-12, "symmetric"); - // The reflection fired on dim 0 (its true anchor was negative), so the reported theta_0 - // is negated -> the reported correlation is the flip-consistent -rho. The realized sample - // correlation is the honest recovery target; after the flip its sign is negated. - let r_true = sample_corr(&thetas, n, n_dims)[0]; - assert!( - (res.corr[1] - (-r_true)).abs() < 0.06, - "corr {} vs -R {}", - res.corr[1], - -r_true - ); - assert!( - res.corr[1] < -0.3, - "flip-consistent NEGATIVE correlation, got {}", - res.corr[1] - ); - // Loadings recovered against the flip-adjusted truth (dim 0 negated by the reflection). - let mut expected = loading.clone(); - for i in 0..n_items { - expected[i * 2] = -expected[i * 2]; // dim 0 flipped - } - assert!( - rmse(&res.loading, &expected) < 0.12, - "loading RMSE {}", - rmse(&res.loading, &expected) - ); - assert!( - res.loading[2 * 2] > 0.9, - "flipped anchor now positive: {}", - res.loading[2 * 2] - ); - assert!(res.n_parameters == pattern.iter().filter(|&&v| v == 1).count() + n_items + 1); - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "EM monotone with the Sigma M-step"); - } -} - -/// Literature-grade Monte-Carlo (>=500 reps): recover loadings AND the latent correlation at -/// D=2 (rho=0.5) and D=3 (exchangeable rho=0.4, verified PD) under a normal and a NORTA -/// right-skew marginal (single correlated normal -> monotone per-dim skew, so the copula -/// keeps the sign; corr is scored against the REALIZED sample correlation R_rep, not nominal). -#[test] -#[ignore = "literature-grade Monte-Carlo (>=500 reps); run with: cargo test --release -- --ignored --nocapture"] -fn mc_corr_mirt_recovery_500() { - let reps = 500usize; - for &(n_dims, q, n, ref true_off) in [ - (2usize, 15usize, 3000usize, vec![0.5f64]), - (3usize, 11usize, 2000usize, vec![0.4f64, 0.4, 0.4]), // exchangeable, eig 1.8,0.6,0.6 - ] - .iter() - { - let sigma_true = build_corr(true_off, n_dims); - let lchol = chol_lower(&sigma_true, n_dims).expect("true Sigma must be PD"); - // pattern: 3 pure anchors per dim + one cross-loader per consecutive pair. - let mut pattern: Vec = Vec::new(); - for dd in 0..n_dims { - for _ in 0..3 { - let mut r = vec![0u8; n_dims]; - r[dd] = 1; - pattern.extend_from_slice(&r); - } - } - for dd in 0..n_dims { - let mut r = vec![0u8; n_dims]; - r[dd] = 1; - r[(dd + 1) % n_dims] = 1; - pattern.extend_from_slice(&r); - } - let n_items = 3 * n_dims + n_dims; - let mut loading = vec![0.0f64; n_items * n_dims]; - for dd in 0..n_dims { - for k in 0..3 { - loading[(dd * 3 + k) * n_dims + dd] = 0.9 + 0.3 * k as f64; // positive anchors - } - } - for dd in 0..n_dims { - let base = 3 * n_dims + dd; - loading[base * n_dims + dd] = 1.0; - loading[base * n_dims + (dd + 1) % n_dims] = 0.7; - } - let intercept: Vec = (0..n_items).map(|i| -0.6 + 0.12 * i as f64).collect(); - let n_off = n_dims * (n_dims - 1) / 2; - - for &skew in [false, true].iter() { - let (mut lnum, mut lden, mut lbias) = (0.0f64, 0.0f64, 0.0f64); - let (mut cnum, mut cbias) = (0.0f64, 0.0f64); - let (mut csum, mut ccnt) = (0.0f64, 0.0f64); - let (mut nconv, mut interior) = (0usize, 0usize); - for rep in 0..reps { - let mut rng = Lcg(0xD1B54A32D192ED03u64 - .wrapping_mul(rep as u64 + 1) - .wrapping_add((skew as u64 + 1) * 0x9E3779B97F4A7C15) - .wrapping_add(n_dims as u64 * 0x100000001B3)); - // NORTA: correlated normals z = L u; per-dim monotone right-skew then - // re-standardize (keeps the sign of the correlation, attenuated). - let mut thetas = draw_corr(&lchol, n, n_dims, &mut rng); - if skew { - for k in 0..n_dims { - for j in 0..n { - let z = thetas[j * n_dims + k]; - thetas[j * n_dims + k] = (0.5 * z).exp(); // monotone lognormal skew - } - let col: Vec = (0..n).map(|j| thetas[j * n_dims + k]).collect(); - let m = col.iter().sum::() / n as f64; - let v = col.iter().map(|x| (x - m) * (x - m)).sum::() / n as f64; - let sd = v.sqrt(); - for j in 0..n { - thetas[j * n_dims + k] = (thetas[j * n_dims + k] - m) / sd; - } - } - } - let r_rep = sample_corr(&thetas, n, n_dims); // honest recovery target - let y = simulate(&loading, &intercept, &thetas, n, n_items, n_dims, &mut rng); - let observed = vec![true; n * n_items]; - let cfg = TwoPlConfig { - q, - estimate_corr: true, - ..TwoPlConfig::default() - }; - let res = fit_2pl(&y, &observed, &pattern, n, n_items, n_dims, &cfg).unwrap(); - if res.converged { - nconv += 1; - } - for w in res.loglik_trace.windows(2) { - assert!(w[1] >= w[0] - 1e-6, "EM monotone (rep {rep})"); - } - // Sigma invariants: unit diagonal, symmetric, PD, |off|<1, all finite. - for k in 0..n_dims { - assert!( - (res.corr[k * n_dims + k] - 1.0).abs() < 1e-9, - "unit diagonal" - ); - } - assert!(chol_lower(&res.corr, n_dims).is_some(), "Sigma PD"); - let mut pinned = false; - let off_est: Vec = { - let mut o = Vec::new(); - for i in 0..n_dims { - for j in i + 1..n_dims { - let v = res.corr[i * n_dims + j]; - assert!(v.is_finite() && v.abs() < 1.0, "corr in (-1,1)"); - assert!((v - res.corr[j * n_dims + i]).abs() < 1e-12, "symmetric"); - if v.abs() > 0.99 { - pinned = true; - } - o.push(v); - } - } - o - }; - if !pinned { - interior += 1; - } - // Loadings: pure anchors positive -> reflection never fires -> no flip; score - // vs truth directly. - for i in 0..n_items { - for dd in 0..n_dims { - let v = res.loading[i * n_dims + dd]; - if pattern[i * n_dims + dd] == 0 { - assert_eq!(v, 0.0); - } else { - assert!(v.is_finite() && v.abs() <= 10.0); - let e = v - loading[i * n_dims + dd]; - lnum += e * e; - lden += 1.0; - lbias += e; - } - } - } - for m in 0..n_off { - let e = off_est[m] - r_rep[m]; // vs realized correlation - cnum += e * e; - cbias += e; - // correlation sign matches the (positive) truth - assert!(off_est[m] > 0.0, "corr sign matches truth (rep {rep})"); - } - for dd in 0..n_dims { - let th: Vec = (0..n).map(|j| res.theta[j * n_dims + dd]).collect(); - let tt: Vec = (0..n).map(|j| thetas[j * n_dims + dd]).collect(); - csum += corr(&th, &tt); - ccnt += 1.0; - } - } - let lrmse = (lnum / lden).sqrt(); - let crmse = (cnum / (reps * n_off) as f64).sqrt(); - let (lb, cb) = (lbias / lden, cbias / (reps * n_off) as f64); - let (tc, conv) = (csum / ccnt, nconv as f64 / reps as f64); - let int_frac = interior as f64 / reps as f64; - println!( - "[corr-mirt MC D={n_dims} q={q} N={n} skew={skew}] reps={reps} conv={conv:.3} \ - loadRMSE={lrmse:.4} loadBias={lb:.4} corrRMSE={crmse:.4} corrBias={cb:.4} \ - thetaCorr={tc:.3} interior={int_frac:.3}" - ); - assert!(conv > 0.95, "convergence {conv} (D={n_dims} skew={skew})"); - assert!( - int_frac > 0.95, - "Sigma interior fraction {int_frac} (D={n_dims})" - ); - assert!( - crmse < 0.06, - "correlation RMSE vs R_rep {crmse} (D={n_dims} skew={skew})" - ); - if skew { - assert!(lrmse < 0.20, "skew loading RMSE {lrmse} (D={n_dims})"); - assert!(tc > 0.62, "skew theta corr {tc} (D={n_dims})"); - } else { - assert!(lb.abs() < 0.03, "loading bias {lb} (D={n_dims})"); - assert!(lrmse < 0.14, "loading RMSE {lrmse} (D={n_dims})"); - assert!(tc > 0.68, "theta corr {tc} (D={n_dims})"); - } - } - } -} diff --git a/tests/unit/utility_tests.rs b/tests/unit/utility_tests.rs deleted file mode 100644 index d700ee322..000000000 --- a/tests/unit/utility_tests.rs +++ /dev/null @@ -1,266 +0,0 @@ -//! Tests for selection utility analysis (`utility.rs`). -//! -//! Mutation-kill audit (all asserts read crate return values; oracle values -//! pinned from scipy via `tests/oracles/oracle_utility.py` — runnable, and -//! regenerates every fixture below — verified adversarially during spec -//! review): -//! -//! - M1 `pux = rxy * ux` -> `pux = ux` (drop rxy): killed by -//! `utility_pux_fixture` (mutant 1.158975380667 vs good 0.579487690333). -//! - M2 Q integrand `Phi((rho*x - k)/s)` -> `Phi((rho*x + k)/s)`: killed by -//! `taylor_russell_oracle_fixtures` (br != .5 rows; e.g. success -//! 0.942953792170 -> 0.384157434057 at rho=.5, sr=.3, br=.8). -//! - M3 `ux = phi(xc)/sr` -> `phi(xc)/(1-sr)`: killed by `utility_ux_fixture` -//! at sr=.3 (mutant/good = 3/7). -//! - M4 role swap sr<->br inside taylor_russell (cutoffs AND divisor): killed -//! by any sr != br fixture (0.94295 -> 0.35361 at rho=.5, sr=.3, br=.8). -//! -//! IDENTITY LIMITATION (documented; cannot be killed in principle): the mutant -//! `Q(h,k,rho) -> Q(k,h,rho)` alone is output-identical everywhere by exchange -//! symmetry of the standard bivariate normal, including at the success-ratio -//! level (the divisor sr is unchanged). No test claims to kill it; the -//! discriminating anchor for cutoff-role bugs is the M4 role-swap mutant above. - -use super::*; - -fn assert_close(got: f64, want: f64, tol: f64, what: &str) { - // Tolerances of 1e-7 on cutoff-derived quantities reflect the Acklam - // inverse-normal-CDF precision (~1.15e-9 relative) propagated through - // phi(xc)/sr, NOT implementation slack; all planned mutants miss by - // >= 6 orders of magnitude more. - assert!( - (got - want).abs() <= tol, - "{what}: got {got}, want {want} (tol {tol})" - ); -} - -/// M3 kill: ux(sr) = phi(Phi^-1(1-sr))/sr. Oracle scipy.stats.norm. -#[test] -fn utility_ux_fixture() { - // asymmetric sr grid (0.5 is excluded from kills: xc=0 makes some - // mutants of xc invisible there; kept as an extra anchor only) - let r = selection_utility(1.0, 1.0, 0.5, 0.05, 0.0, 1.0).unwrap(); - assert_close(r.ux, 2.062712807507427, 1e-7, "ux(sr=.05)"); - assert_close(r.xc, 1.644853626951472, 1e-7, "xc(sr=.05)"); - let r = selection_utility(1.0, 1.0, 0.5, 0.3, 0.0, 1.0).unwrap(); - assert_close(r.ux, 1.158975380666913, 1e-7, "ux(sr=.3)"); - let r = selection_utility(1.0, 1.0, 0.5, 0.9, 0.0, 1.0).unwrap(); - assert_close(r.ux, 0.194998146591652, 1e-7, "ux(sr=.9)"); - assert_close(r.xc, -1.281551565544600, 1e-7, "xc(sr=.9)"); -} - -/// M1 kill: pux = rxy * ux, NOT ux. Oracle 0.5 * 1.158975380666913. -#[test] -fn utility_pux_fixture() { - let r = selection_utility(1.0, 1.0, 0.5, 0.3, 0.0, 1.0).unwrap(); - assert_close(r.pux, 0.579487690333456, 1e-7, "pux(rxy=.5, sr=.3)"); - // negative-validity asymmetry: pux flips sign with rxy - let r = selection_utility(1.0, 1.0, -0.5, 0.3, 0.0, 1.0).unwrap(); - assert_close(r.pux, -0.579487690333456, 1e-7, "pux(rxy=-.5)"); -} - -/// BCG utility gain: n*period*sdy*pux - cost_total (iopsych utilityBcg). -#[test] -fn utility_bcg_fixtures() { - let r = selection_utility(1.0, 10000.0, 0.5, 0.3, 0.0, 1.0).unwrap(); - assert_close(r.utility_gain, 5794.8769033346, 1e-4, "bcg base"); - // cost>0, period>1, n>1: exact algebra vs oracle (kills cost*n mutants - // and period misplacement) - let r = selection_utility(50.0, 8000.0, 0.4, 0.2, 25000.0, 3.0).unwrap(); - assert_close(r.utility_gain, 646908.6089787399, 1e-3, "bcg full"); -} - -/// M2 + M4 kills: Taylor-Russell success ratio vs scipy mvn oracle. -/// Rows are deliberately sr != br and include negative rho. -#[test] -fn taylor_russell_oracle_fixtures() { - let cases: [(f64, f64, f64, f64, f64); 5] = [ - // (rxy, sr, br, success, q) from tests/oracles/oracle_utility.py - (0.5, 0.5, 0.6, 0.760872752614590, 0.380436376307295), - (0.5, 0.3, 0.2, 0.384157434057053, 0.115247230217116), - (0.3, 0.05, 0.8, 0.935885600599995, 0.046794280030000), - (-0.6, 0.3, 0.5, 0.209092126479301, 0.062727637943790), - (0.8, 0.3, 0.2, 0.533433021447364, 0.160029906434209), - ]; - for (rxy, sr, br, success, q) in cases { - let r = taylor_russell(rxy, sr, br).unwrap(); - assert_close( - r.success_ratio, - success, - 1e-7, - &format!("success(rxy={rxy}, sr={sr}, br={br})"), - ); - assert_close( - r.q_joint, - q, - 1e-7, - &format!("q(rxy={rxy}, sr={sr}, br={br})"), - ); - assert_close(r.base_rate, br, 0.0, "base_rate echo"); - } -} - -/// Analytic anchor rho=0: success == br exactly (kills BVN wiring bugs); -/// plus strict monotonicity in rxy at fixed (sr, br). -#[test] -fn taylor_russell_structure_invariants() { - let r = taylor_russell(0.0, 0.3, 0.7).unwrap(); - assert_close(r.success_ratio, 0.7, 1e-6, "rho=0 success == br"); - assert_close(r.q_joint, 0.21, 1e-7, "rho=0 q == sr*br"); - // strict increase in rxy (structure invariant; not an identity because - // it compares two distinct crate outputs at asymmetric inputs) - let lo = taylor_russell(0.2, 0.3, 0.6).unwrap().success_ratio; - let hi = taylor_russell(0.6, 0.3, 0.6).unwrap().success_ratio; - assert!( - hi > lo + 1e-4, - "success ratio must increase in rxy: {lo} !< {hi}" - ); - // sr -> 1 limit approaches br - let near = taylor_russell(0.7, 0.9999, 0.37).unwrap().success_ratio; - assert_close(near, 0.370037002745, 1e-6, "sr->1 limit"); -} - -/// Input validation: reject out-of-domain arguments with Err (not panic). -#[test] -fn utility_error_paths() { - assert!(taylor_russell(1.0, 0.3, 0.5).is_err(), "rxy=1 rejected"); - assert!(taylor_russell(-1.0, 0.3, 0.5).is_err(), "rxy=-1 rejected"); - assert!(taylor_russell(f64::NAN, 0.3, 0.5).is_err(), "rxy NaN"); - assert!(taylor_russell(0.5, 0.0, 0.5).is_err(), "sr=0 rejected"); - assert!(taylor_russell(0.5, 1.0, 0.5).is_err(), "sr=1 rejected"); - assert!(taylor_russell(0.5, 0.3, 0.0).is_err(), "br=0 rejected"); - assert!(taylor_russell(0.5, 0.3, 1.0).is_err(), "br=1 rejected"); - assert!( - selection_utility(0.5, 1.0, 0.5, 0.3, 0.0, 1.0).is_err(), - "n < 1 rejected" - ); - assert!( - selection_utility(1.0, -1.0, 0.5, 0.3, 0.0, 1.0).is_err(), - "sdy < 0 rejected" - ); - assert!( - selection_utility(1.0, 1.0, 0.5, 0.3, f64::INFINITY, 1.0).is_err(), - "infinite cost rejected" - ); - assert!( - selection_utility(1.0, 1.0, 0.5, 0.3, 0.0, 0.5).is_err(), - "period < 1 rejected" - ); -} - -/// Monte-Carlo recovery (>= 500 reps): empirical selected-group success ratio -/// and mean criterion vs crate outputs. Run with: -/// `cargo test --lib utility_mc_recovery_500 -- --ignored --nocapture` -#[test] -#[ignore] -fn utility_mc_recovery_500() { - let (rxy, sr, br) = (0.5, 0.3, 0.6); - let expect = taylor_russell(rxy, sr, br).unwrap(); - let pux = selection_utility(1.0, 1.0, rxy, sr, 0.0, 1.0).unwrap().pux; - let reps = 500usize; - let n = 20_000usize; - let mut state: u64 = 0x1657_0016_D00D_F00Du64; - let mut succ_sum = 0.0; - let mut ybar_sum = 0.0; - for _ in 0..reps { - // draw BVN via Y = rho X + sqrt(1-rho^2) Z, top-down select on X - let mut xs: Vec<(f64, f64)> = (0..n) - .map(|_| { - let x = mc_normal(&mut state); - let z = mc_normal(&mut state); - (x, rxy * x + (1.0 - rxy * rxy).sqrt() * z) - }) - .collect(); - xs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap()); - let n_sel = (sr * n as f64).round() as usize; - let yc = crate::nodes::inv_normal_cdf(1.0 - br); - let sel = &xs[..n_sel]; - let succ = sel.iter().filter(|p| p.1 > yc).count() as f64 / n_sel as f64; - let ybar = sel.iter().map(|p| p.1).sum::() / n_sel as f64; - succ_sum += succ; - ybar_sum += ybar; - } - let succ_mc = succ_sum / reps as f64; - let ybar_mc = ybar_sum / reps as f64; - eprintln!( - "MC(500x20000): success {succ_mc:.6} vs crate {:.6}; pux {ybar_mc:.6} vs crate {pux:.6}", - expect.success_ratio - ); - // SE(success) ~ sqrt(p(1-p)/(reps*n_sel)) ~ 2.8e-4; band 3*SE rounded up - assert_close(succ_mc, expect.success_ratio, 1.5e-3, "MC success ratio"); - // SE(ybar) ~ sd(Y|sel)/sqrt(reps*n_sel) ~ 5e-4; band widened for - // top-down (order-statistic) vs threshold selection discrepancy at finite n - assert_close(ybar_mc, pux, 4e-3, "MC mean criterion of selected"); -} - -/// Adversarial impl-review regressions. -/// -/// HIGH: sr/br so small that `1.0 - v` rounds to 1.0 previously produced -/// NaN or silent-zero Ok results; must be Err. -#[test] -fn utility_subulp_ratio_rejected() { - assert!(taylor_russell(0.5, 1e-17, 0.5).is_err(), "sr=1e-17 Err"); - assert!(taylor_russell(0.5, 0.5, 1e-17).is_err(), "br=1e-17 Err"); - assert!( - selection_utility(1.0, 1.0, 0.5, 1e-17, 0.0, 1.0).is_err(), - "selection sr=1e-17 Err" - ); - // all finite outputs at a small-but-representable ratio - let r = taylor_russell(0.5, 1e-12, 0.5).unwrap(); - assert!( - r.success_ratio.is_finite() && r.q_joint.is_finite(), - "finite outputs at sr=1e-12: {r:?}" - ); -} - -/// MEDIUM: near-degenerate rho (transition width sqrt(1-rho^2) << old fixed -/// 0.25 panels) was off by ~1e-3. Oracle: scipy.integrate.quad on the same -/// conditional integral, epsrel 1e-12 (`q_quad` in -/// tests/oracles/oracle_utility.py — regenerates both fixtures below). -#[test] -fn taylor_russell_near_degenerate_rho() { - let r = taylor_russell(-0.999999, 0.9, 0.9).unwrap(); - assert_close( - r.success_ratio, - 0.8888888888911659, - 1e-6, - "success(rho=-0.999999)", - ); - let r = taylor_russell(0.999999, 1e-12, 1e-12).unwrap(); - assert_close( - r.success_ratio, - 0.9959319518472849, - 1e-6, - "success(rho=0.999999, tiny tails)", - ); - // |rho| beyond the quadrature's resolvable band must Err, not silently - // return an inaccurate value - assert!( - taylor_russell(1.0 - 1e-12, 0.3, 0.5).is_err(), - "rho ~ 1-1e-12 rejected" - ); -} - -/// LOW: q_joint must respect the probability bound Q <= min(sr, br); the -/// old code returned q_joint slightly above sr and clamped only success. -#[test] -fn taylor_russell_q_bound_invariant() { - let r = taylor_russell(0.95, 0.02, 0.9).unwrap(); - assert!( - r.q_joint <= 0.02 && r.success_ratio <= 1.0, - "Q <= sr bound: {r:?}" - ); -} - -fn mc_normal(state: &mut u64) -> f64 { - // Box-Muller on a SplitMix64-ish stream (test-local; independent of impl) - let next = |s: &mut u64| { - *s = s - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - ((*s >> 11) as f64) / ((1u64 << 53) as f64) - }; - let u1 = next(state).max(1e-15); - let u2 = next(state); - (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() -}