diff --git a/AGENTS.md b/AGENTS.md index 3d3b4ae3e..56457d2a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,11 +133,12 @@ Guidance for ANY agent (Claude, Codex, Cursor, opencode, ...) working in this re ### Code exploration -- 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. +- 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. ### This repo's role in the ecosystem diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d502a0c..bf870b754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,1632 @@ # 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). + +### Added + +- **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 76de23942..9892bffc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,17 @@ 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 958118221..ef26f71e6 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 = "0.4.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" [[package]] name = "portable-atomic" diff --git a/crates/fast-mlsirm-py/src/lib.rs b/crates/fast-mlsirm-py/src/lib.rs index f33a7d94c..437e33ea8 100644 --- a/crates/fast-mlsirm-py/src/lib.rs +++ b/crates/fast-mlsirm-py/src/lib.rs @@ -1,6 +1,110 @@ 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::crm::fit_crm as core_fit_crm; +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, + sibtest as core_sibtest, LogisticDifConfig, LogisticDifRow, MhDifConfig, MhDifRow, + PurifyConfig, SibtestConfig, +}; +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::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::nominal::{fit_nominal as core_fit_nominal_model, NominalConfig}; +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::facets::fit_facets as core_fit_facets; +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::testlet::{fit_testlet as core_fit_testlet, TestletConfig, TestletModel}; +use mlsirm_core::twopl::{fit_2pl as core_fit_2pl, TwoPlConfig}; + +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, @@ -159,16 +263,4870 @@ 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()) +} + +/// Marginal-EM fit of a mixed Rasch / mixture-IRT model (`mlsirm_core::mixture`, Rost, +/// 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()) +} + +/// 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!(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!(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(()) } @@ -179,8 +5137,9 @@ 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']", + "model must be one of ['MIRT', 'MLS2PLM', 'MLSRM', 'ULS2PLM', 'ULSRM', 'BIFAC2PLM']", )), } } diff --git a/crates/mlsirm-core/src/agreement.rs b/crates/mlsirm-core/src/agreement.rs new file mode 100644 index 000000000..3b88a1de0 --- /dev/null +++ b/crates/mlsirm-core/src/agreement.rs @@ -0,0 +1,246 @@ +//! 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 new file mode 100644 index 000000000..32648f7d4 --- /dev/null +++ b/crates/mlsirm-core/src/cdm.rs @@ -0,0 +1,3386 @@ +//! 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/crm.rs b/crates/mlsirm-core/src/crm.rs new file mode 100644 index 000000000..eac674e7e --- /dev/null +++ b/crates/mlsirm-core/src/crm.rs @@ -0,0 +1,394 @@ +//! 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/dif.rs b/crates/mlsirm-core/src/dif.rs new file mode 100644 index 000000000..86d2e49ed --- /dev/null +++ b/crates/mlsirm-core/src/dif.rs @@ -0,0 +1,1516 @@ +//! 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) +} + +#[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 new file mode 100644 index 000000000..52e80e452 --- /dev/null +++ b/crates/mlsirm-core/src/equating.rs @@ -0,0 +1,1311 @@ +//! 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/facets.rs b/crates/mlsirm-core/src/facets.rs new file mode 100644 index 000000000..b3876efa0 --- /dev/null +++ b/crates/mlsirm-core/src/facets.rs @@ -0,0 +1,627 @@ +//! 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/fitstats.rs b/crates/mlsirm-core/src/fitstats.rs new file mode 100644 index 000000000..9c56e0df9 --- /dev/null +++ b/crates/mlsirm-core/src/fitstats.rs @@ -0,0 +1,2886 @@ +//! 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. +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, 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()); + } + if y.len() != n_persons * n_items { + return Err("y must have length n_persons * n_items".into()); + } + validate_optional_observed_length(observed, y.len())?; + if slope.len() != n_items { + return Err("slope must have length n_items".into()); + } + if cat_params.len() != n_items * (n_cat - 1) { + return Err("cat_params must have length n_items*(n_cat-1)".into()); + } + 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, 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()); + } + if y.len() != n_persons * n_items { + return Err("y must have length n_persons * n_items".into()); + } + validate_optional_observed_length(observed, y.len())?; + if slope.len() != n_items { + return Err("slope must have length n_items".into()); + } + if cat_params.len() != n_items * (n_cat - 1) { + return Err("cat_params must have length n_items*(n_cat-1)".into()); + } + 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 new file mode 100644 index 000000000..259288a68 --- /dev/null +++ b/crates/mlsirm-core/src/gpcm.rs @@ -0,0 +1,645 @@ +//! 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 new file mode 100644 index 000000000..e8b8758b2 --- /dev/null +++ b/crates/mlsirm-core/src/gpu_eapsum.rs @@ -0,0 +1,710 @@ +//! 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 new file mode 100644 index 000000000..1dd0eaaa9 --- /dev/null +++ b/crates/mlsirm-core/src/gpu_marginal.rs @@ -0,0 +1,963 @@ +//! 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 new file mode 100644 index 000000000..40741e8dd --- /dev/null +++ b/crates/mlsirm-core/src/gpu_plausible.rs @@ -0,0 +1,562 @@ +//! 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 new file mode 100644 index 000000000..dfd5baa47 --- /dev/null +++ b/crates/mlsirm-core/src/gpu_scoring.rs @@ -0,0 +1,558 @@ +//! 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 new file mode 100644 index 000000000..a23da2ad5 --- /dev/null +++ b/crates/mlsirm-core/src/grm.rs @@ -0,0 +1,663 @@ +//! 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/lib.rs b/crates/mlsirm-core/src/lib.rs index 09c97c23e..e55b47ff6 100644 --- a/crates/mlsirm-core/src/lib.rs +++ b/crates/mlsirm-core/src/lib.rs @@ -1,10 +1,65 @@ +pub mod agreement; +pub mod cdm; +pub mod crm; +pub mod dif; +pub mod equating; +pub mod facets; +pub mod fitstats; +pub mod gpcm; +pub mod grm; +pub mod linking; +pub mod lltm; +pub mod marginal; +pub mod mhrm; +pub mod mixed; +pub mod mixture; pub mod mmle; +pub mod nodes; +pub mod nominal; +pub mod oakes; +pub mod poly; +pub mod poly_marginal; +pub(crate) mod quadrature; +pub mod rasch_cml; +pub mod rsm; +pub mod rt; +pub mod rt_joint; +pub mod scoring; +pub mod testlet; +pub mod twopl; + +/// 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()), + } +} -// 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. +/// 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. #[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, @@ -12,6 +67,42 @@ 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. @@ -51,6 +142,15 @@ 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 @@ -155,6 +255,27 @@ 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, @@ -183,6 +304,7 @@ 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 { @@ -302,282 +424,17 @@ fn softplus(x: f64) -> f64 { } #[cfg(test)] -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, - } - } - - 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]); - } - } +#[path = "../../../tests/unit/lib_tests.rs"] +mod tests; - #[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); - } - } +#[cfg(test)] +#[path = "../../../tests/unit/lib_additional_tests.rs"] +mod additional_tests; - #[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/marginal_recovery_tests.rs"] +mod marginal_recovery_tests; #[cfg(test)] -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); - } -} +#[path = "../../../tests/unit/proptest_neg_loglik_tests.rs"] +mod proptest_neg_loglik_tests; diff --git a/crates/mlsirm-core/src/linking.rs b/crates/mlsirm-core/src/linking.rs new file mode 100644 index 000000000..00aa5d5d3 --- /dev/null +++ b/crates/mlsirm-core/src/linking.rs @@ -0,0 +1,414 @@ +//! 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 new file mode 100644 index 000000000..940e9b8fd --- /dev/null +++ b/crates/mlsirm-core/src/lltm.rs @@ -0,0 +1,590 @@ +//! 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 new file mode 100644 index 000000000..b19f854dc --- /dev/null +++ b/crates/mlsirm-core/src/marginal.rs @@ -0,0 +1,2390 @@ +//! 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 new file mode 100644 index 000000000..eb4de9f6c --- /dev/null +++ b/crates/mlsirm-core/src/mhrm.rs @@ -0,0 +1,1132 @@ +//! 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 new file mode 100644 index 000000000..190418f16 --- /dev/null +++ b/crates/mlsirm-core/src/mixed.rs @@ -0,0 +1,1197 @@ +//! 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 new file mode 100644 index 000000000..9f2975996 --- /dev/null +++ b/crates/mlsirm-core/src/mixture.rs @@ -0,0 +1,645 @@ +//! 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 26957abea..d522f7d05 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. -const GH_NODES: [f64; 41] = [ +pub(crate) const GH_NODES: [f64; 41] = [ -11.614937254337464, -10.647536786319334, -9.843433249157995, @@ -58,7 +58,7 @@ const GH_NODES: [f64; 41] = [ 10.647536786319334, 11.614937254337464, ]; -const GH_WEIGHTS: [f64; 41] = [ +pub(crate) const GH_WEIGHTS: [f64; 41] = [ 2.2578639565831077e-30, 8.308558938782659e-26, 2.7468912285223205e-22, @@ -123,12 +123,18 @@ 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] -fn log_sigmoid(x: f64) -> f64 { +pub(crate) fn log_sigmoid(x: f64) -> f64 { if x >= 0.0 { -(-x).exp().ln_1p() } else { @@ -137,7 +143,7 @@ fn log_sigmoid(x: f64) -> f64 { } #[inline] -fn sigmoid_stable(x: f64) -> f64 { +pub(crate) fn sigmoid_stable(x: f64) -> f64 { if x >= 0.0 { 1.0 / (1.0 + (-x).exp()) } else { @@ -173,7 +179,11 @@ 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(); } @@ -201,7 +211,8 @@ 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; @@ -288,111 +299,16 @@ 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)] -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"); - } -} +#[path = "../../../tests/unit/mmle_tests.rs"] +mod tests; diff --git a/crates/mlsirm-core/src/nodes.rs b/crates/mlsirm-core/src/nodes.rs new file mode 100644 index 000000000..a5ab250b0 --- /dev/null +++ b/crates/mlsirm-core/src/nodes.rs @@ -0,0 +1,232 @@ +//! 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 new file mode 100644 index 000000000..a4478c33d --- /dev/null +++ b/crates/mlsirm-core/src/nominal.rs @@ -0,0 +1,683 @@ +//! 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 new file mode 100644 index 000000000..98ec40c01 --- /dev/null +++ b/crates/mlsirm-core/src/oakes.rs @@ -0,0 +1,457 @@ +//! 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/poly.rs b/crates/mlsirm-core/src/poly.rs new file mode 100644 index 000000000..b3357be6a --- /dev/null +++ b/crates/mlsirm-core/src/poly.rs @@ -0,0 +1,2422 @@ +//! 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(()) +} + +#[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()); + } + if slope.len() != n_items { + return Err("slope must have length n_items".into()); + } + if cat_params.len() != n_items * (n_cat - 1) { + return Err("cat_params must have length n_items*(n_cat-1)".into()); + } + 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()); + } + if slope.len() != n_items || cat_params.len() != n_items * (n_cat - 1) { + return Err("slope/cat_params must match n_items and n_cat".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()); + } + if slope.len() != n_items || cat_params.len() != n_items * (n_cat - 1) { + return Err("slope/cat_params must match n_items and n_cat".into()); + } + 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 mut pool: Vec = Vec::with_capacity(n_rep * n_persons); + let mut y = vec![0usize; n_persons * n_items]; + 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())); + } + debug_assert!( + !pool.is_empty(), + "validated complete bootstrap samples have finite boundary U3 values" + ); + 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()); + } + let expected_cat_params = + crate::checked_mul_usize(n_items, n_cat - 1, "n_items * (n_cat - 1) overflows usize")?; + if slope.len() != n_items || cat_params.len() != expected_cat_params { + return Err("slope/cat_params sizes inconsistent with n_items/n_cat".into()); + } + if theta.iter().any(|value| !value.is_finite()) + || slope.iter().any(|value| !value.is_finite()) + || cat_params.iter().any(|value| !value.is_finite()) + { + return Err("theta, slope, and cat_params 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()); + } + } + let n_params = + 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_params { + return Err("slope/cat_params sizes inconsistent with n_items/n_cat".into()); + } + if slope.iter().any(|v| !v.is_finite()) || cat_params.iter().any(|v| !v.is_finite()) { + return Err("slope and cat_params must be finite".into()); + } + 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()); + } + if slope.len() != n_items { + return Err("slope must have length n_items".into()); + } + let n_item_steps = crate::checked_mul_usize( + n_items, + n_cat - 1, + "n_items * (n_cat - 1) overflows usize", + )?; + if cat_params.len() != n_item_steps { + return Err("cat_params must have length n_items * (n_cat - 1)".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 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 new file mode 100644 index 000000000..5d99a674a --- /dev/null +++ b/crates/mlsirm-core/src/poly_marginal.rs @@ -0,0 +1,461 @@ +//! 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. This is the +//! `fixed_gamma = 1` identification of Go et al. (2024) lsirm12pl (the distance +//! weight is fixed to standardize the map scale). +//! +//! 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()); + } + if y.len() != n_persons * n_items { + return Err("y 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()); + } + } + 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 new file mode 100644 index 000000000..f3260c459 --- /dev/null +++ b/crates/mlsirm-core/src/quadrature.rs @@ -0,0 +1,309 @@ +// 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 new file mode 100644 index 000000000..f40451175 --- /dev/null +++ b/crates/mlsirm-core/src/rasch_cml.rs @@ -0,0 +1,429 @@ +//! 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/rsm.rs b/crates/mlsirm-core/src/rsm.rs new file mode 100644 index 000000000..b7395ee11 --- /dev/null +++ b/crates/mlsirm-core/src/rsm.rs @@ -0,0 +1,427 @@ +//! 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 new file mode 100644 index 000000000..c06682137 --- /dev/null +++ b/crates/mlsirm-core/src/rt.rs @@ -0,0 +1,487 @@ +//! 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 new file mode 100644 index 000000000..5e16780af --- /dev/null +++ b/crates/mlsirm-core/src/rt_joint.rs @@ -0,0 +1,470 @@ +//! 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 new file mode 100644 index 000000000..7fec065cd --- /dev/null +++ b/crates/mlsirm-core/src/scoring.rs @@ -0,0 +1,2659 @@ +//! 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/testlet.rs b/crates/mlsirm-core/src/testlet.rs new file mode 100644 index 000000000..405d6b5c9 --- /dev/null +++ b/crates/mlsirm-core/src/testlet.rs @@ -0,0 +1,716 @@ +//! 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 new file mode 100644 index 000000000..096fdcec4 --- /dev/null +++ b/crates/mlsirm-core/src/twopl.rs @@ -0,0 +1,1138 @@ +//! 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/docs/mmle_marginal_lsirm_design.md b/docs/mmle_marginal_lsirm_design.md new file mode 100644 index 000000000..cd289b608 --- /dev/null +++ b/docs/mmle_marginal_lsirm_design.md @@ -0,0 +1,221 @@ +# 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 new file mode 100644 index 000000000..8868e2104 --- /dev/null +++ b/docs/papers/corpus-triage-batch3.md @@ -0,0 +1,117 @@ +# 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 new file mode 100644 index 000000000..3360cc252 --- /dev/null +++ b/docs/papers/corpus-triage-batch4.md @@ -0,0 +1,99 @@ +# 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 new file mode 100644 index 000000000..54183908f --- /dev/null +++ b/docs/papers/corpus-triage-batch5.md @@ -0,0 +1,73 @@ +# 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 new file mode 100644 index 000000000..2c71ab261 --- /dev/null +++ b/docs/papers/corpus-triage-batch6.md @@ -0,0 +1,85 @@ +# 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 new file mode 100644 index 000000000..f6ac026ea --- /dev/null +++ b/docs/papers/gpcm-nominal-design-spec.md @@ -0,0 +1,238 @@ +# 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 new file mode 100644 index 000000000..25e2b5100 --- /dev/null +++ b/docs/papers/group_a_specs.md @@ -0,0 +1,394 @@ +# 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 new file mode 100644 index 000000000..c23bd6c78 --- /dev/null +++ b/docs/papers/group_b_specs.md @@ -0,0 +1,540 @@ +# 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 new file mode 100644 index 000000000..518fc415a --- /dev/null +++ b/docs/papers/group_c_specs.md @@ -0,0 +1,344 @@ +# 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 new file mode 100644 index 000000000..ba4a68c57 --- /dev/null +++ b/docs/papers/implemented-literature-map.md @@ -0,0 +1,25 @@ +# 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 new file mode 100644 index 000000000..f8320b9fe --- /dev/null +++ b/docs/papers/mmle-lsirm-formula-compilation.md @@ -0,0 +1,1317 @@ +# 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 fdfe22299..d3121b1e9 100644 --- a/python/fast_mlsirm/__init__.py +++ b/python/fast_mlsirm/__init__.py @@ -3,9 +3,57 @@ 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 .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 .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 .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 .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) +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 @@ -21,6 +69,7 @@ "FitConfig", "FitDiagnostics", "FitResult", + "M2Result", "MLS2PLMConfig", "MLSIRMParams", "PenaltyConfig", @@ -29,8 +78,120 @@ "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", + "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", + "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 new file mode 100644 index 000000000..f40d9531c --- /dev/null +++ b/python/fast_mlsirm/cdm.py @@ -0,0 +1,1009 @@ +"""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/cli.py b/python/fast_mlsirm/cli.py index ef2c9e8c8..c57047ab8 100644 --- a/python/fast_mlsirm/cli.py +++ b/python/fast_mlsirm/cli.py @@ -17,11 +17,26 @@ response_process_fit_diagnostics, ) from .fit import fit -from .io import load_factor_csv, load_params, save_dimensionality_diagnostics, save_fit_diagnostics, save_fit_result, save_simulation +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 .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", @@ -30,6 +45,34 @@ 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) @@ -48,7 +91,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 = np.load(responses_path, allow_pickle=False) + responses = _load_numpy_bounded(responses_path) factors = load_factor_csv(factors_path) _validate_response_and_factors(responses, factors) return responses, factors @@ -96,6 +139,13 @@ 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).") @@ -105,6 +155,16 @@ 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.", @@ -114,8 +174,21 @@ 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) @@ -249,13 +322,60 @@ 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 @@ -279,6 +399,10 @@ 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( @@ -346,8 +470,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 = np.load(args.responses, allow_pickle=False) - probabilities = np.load(args.probabilities, allow_pickle=False) + responses = _load_numpy_bounded(args.responses) + probabilities = _load_numpy_bounded(args.probabilities) group_id = _load_optional_npy(args.group_id) cluster_id = _load_optional_npy(args.cluster_id) except FileNotFoundError as e: @@ -391,7 +515,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 = np.load(args.responses, allow_pickle=False) + responses = _load_numpy_bounded(args.responses) candidate_probabilities = _load_candidate_probabilities(args.candidate) except FileNotFoundError as e: if os.environ.get("FAST_MLSIRM_DEBUG"): @@ -433,7 +557,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 = np.load(args.responses, allow_pickle=False) + responses = _load_numpy_bounded(args.responses) candidate_probabilities = _load_candidate_probabilities(args.candidate) fixed_items = _load_optional_npy(args.fixed_items) except FileNotFoundError as e: @@ -534,12 +658,19 @@ 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"): @@ -588,18 +719,35 @@ 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 np.load(path, allow_pickle=False) + return None if path is None else _load_numpy_bounded(path) 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}") - candidates[label] = np.load(path, allow_pickle=False) + 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 return candidates diff --git a/python/fast_mlsirm/config.py b/python/fast_mlsirm/config.py index cbfdd8d99..b2b665db0 100644 --- a/python/fast_mlsirm/config.py +++ b/python/fast_mlsirm/config.py @@ -1,17 +1,43 @@ 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"} +VALID_MODELS = {"MIRT", "MLS2PLM", "MLSRM", "ULS2PLM", "ULSRM", "BIFAC2PLM"} 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: @@ -29,16 +55,47 @@ 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"}: @@ -83,6 +140,26 @@ 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() @@ -91,21 +168,65 @@ 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 self.latent_dim < 1: - raise ValueError("latent_dim must be >= 1") + if not (1 <= self.latent_dim <= MAX_LATENT_DIM): + raise ValueError(f"latent_dim must be >= 1 and <= {MAX_LATENT_DIM}") 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 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") + 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") normalize_backend(self.backend) normalize_device(self.rust_device) diff --git a/python/fast_mlsirm/crm.py b/python/fast_mlsirm/crm.py new file mode 100644 index 000000000..3769583b1 --- /dev/null +++ b/python/fast_mlsirm/crm.py @@ -0,0 +1,122 @@ +"""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/diagnostics.py b/python/fast_mlsirm/diagnostics.py index f719692a2..b8f9c3ee8 100644 --- a/python/fast_mlsirm/diagnostics.py +++ b/python/fast_mlsirm/diagnostics.py @@ -2,12 +2,13 @@ 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 +from .objective import linear_predictor, model_flags, prepare_response, validate_factor_id from .types import ( DimensionalityDiagnostics, FitDiagnostics, @@ -16,6 +17,21 @@ ) +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, @@ -23,14 +39,72 @@ 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, @@ -42,7 +116,49 @@ 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: @@ -62,6 +178,9 @@ 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 ) @@ -81,7 +200,102 @@ 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, @@ -116,11 +330,16 @@ 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 _validated_latent_dims(latent_dims): + for latent_dim in 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 @@ -863,19 +1082,31 @@ def _parameter_count(params: MLSIRMParams, model: str) -> int: def _validated_latent_dims(latent_dims: Iterable[int]) -> list[int]: - dims = [int(value) for value in latent_dims] + dims = list(dict.fromkeys(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 k_folds < 2: - raise ValueError("k_folds must be >= 2") + 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") 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 new file mode 100644 index 000000000..4c3013624 --- /dev/null +++ b/python/fast_mlsirm/dif.py @@ -0,0 +1,490 @@ +"""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 _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 new file mode 100644 index 000000000..10b255f7e --- /dev/null +++ b/python/fast_mlsirm/equating.py @@ -0,0 +1,361 @@ +"""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 new file mode 100644 index 000000000..22484c643 --- /dev/null +++ b/python/fast_mlsirm/estimators/marginal.py @@ -0,0 +1,1455 @@ +"""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/facets.py b/python/fast_mlsirm/facets.py new file mode 100644 index 000000000..57266c588 --- /dev/null +++ b/python/fast_mlsirm/facets.py @@ -0,0 +1,157 @@ +"""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`` or negative sentinels mark 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" + ) + if np.any(np.isinf(y)): + raise ValueError("observed responses must be finite integer categories") + observed = np.isfinite(y) & (y >= 0) + obs_values = y[observed] + if obs_values.size and np.any(obs_values != np.floor(obs_values)): + raise ValueError("observed responses must be 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/fit.py b/python/fast_mlsirm/fit.py index e185c667e..8f4d1c0e5 100644 --- a/python/fast_mlsirm/fit.py +++ b/python/fast_mlsirm/fit.py @@ -5,19 +5,98 @@ import numpy as np from .backend import normalize_device, resolve_backend -from .config import FitConfig +from .config import FitConfig, PenaltyConfig 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) @@ -27,7 +106,11 @@ def fit( y, observed = prepare_response(responses, mask) _, n_items = y.shape - factors = np.asarray(factor_id, dtype=np.int64) + 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") 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") @@ -36,13 +119,45 @@ 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 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 ( + 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 config.estimator in {"em", "bayes"}: raise NotImplementedError( f"estimator '{config.estimator}' is reserved for a future milestone; " @@ -136,6 +251,263 @@ 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 new file mode 100644 index 000000000..0511105b0 --- /dev/null +++ b/python/fast_mlsirm/fitstats.py @@ -0,0 +1,2930 @@ +"""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 new file mode 100644 index 000000000..5e9768fdf --- /dev/null +++ b/python/fast_mlsirm/gpcm.py @@ -0,0 +1,189 @@ +"""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 new file mode 100644 index 000000000..ca1767fe9 --- /dev/null +++ b/python/fast_mlsirm/grm.py @@ -0,0 +1,194 @@ +"""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/inference.py b/python/fast_mlsirm/inference.py index b94d3cb6c..3583b3d57 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 step <= 0: - raise ValueError("step must be > 0") + if not np.isfinite(step) or step <= 0: + raise ValueError("step must be > 0 and finite") def objective(x: np.ndarray) -> float: value, _, _ = neg_loglik_and_grad( @@ -46,6 +46,12 @@ 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) @@ -98,3 +104,129 @@ 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 1e0ce1dd8..e4100e35d 100644 --- a/python/fast_mlsirm/io.py +++ b/python/fast_mlsirm/io.py @@ -1,32 +1,225 @@ 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) - (out / "config.json").write_text(json.dumps(asdict(data.config), indent=2), encoding="utf-8") - np.save(out / "responses.npy", data.Y) - np.savez( + _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 / "truth.npz", - 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, + 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, + ), ) _write_factor_csv(out / "item_factor.csv", data.factor_id) manifest = { @@ -43,14 +236,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"}, } - (out / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + _atomic_write_text(out / "manifest.json", json.dumps(manifest, indent=2)) 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 - 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) + 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) summary = { "model": result.model, "optimizer": result.optimizer, @@ -61,7 +254,22 @@ 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, } - (out / "fit_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + 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)) def save_fit_diagnostics(diagnostics: FitDiagnostics, run_dir: str | Path) -> None: @@ -78,44 +286,64 @@ 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, } - (out / "fit_diagnostics.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + _atomic_write_text(out / "fit_diagnostics.json", json.dumps(payload, indent=2)) 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} - (out / "dimension_diagnostics.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + _atomic_write_text(out / "dimension_diagnostics.json", json.dumps(payload, indent=2)) def load_params(path: str | Path) -> MLSIRMParams: - # 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"])) + 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"])) def load_factor_csv(path: str | Path) -> np.ndarray: import warnings - content = Path(path).read_text(encoding="utf-8").strip() - if not content: + + content = _read_text_bounded( + path, + source="factor CSV", + max_bytes=MAX_FACTOR_CSV_BYTES, + ) + if not content or content.isspace(): raise ValueError("factor CSV is empty") with warnings.catch_warnings(): warnings.simplefilter("ignore") - return np.loadtxt(path, delimiter=',', skiprows=1, usecols=1, dtype=np.int64, ndmin=1) + return np.loadtxt( + io.StringIO(content), + 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)) - np.savetxt( - path, - data, - delimiter=',', - header='item_id,factor_id', - comments='', - fmt='%d' - ) + + 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) def _arrays_to_lists(values: dict[str, np.ndarray]) -> dict[str, list[float]]: diff --git a/python/fast_mlsirm/linking.py b/python/fast_mlsirm/linking.py index 77fa72301..1b09c1eab 100644 --- a/python/fast_mlsirm/linking.py +++ b/python/fast_mlsirm/linking.py @@ -12,22 +12,53 @@ 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 = np.asarray(anchor_items, dtype=np.int64) - if anchors.ndim != 1 or anchors.size == 0: + anchors_raw = np.asarray(anchor_items) + if anchors_raw.ndim != 1 or anchors_raw.size == 0: raise ValueError("anchor_items must be a non-empty 1D array") - if np.any(anchors < 0) or np.any(anchors >= source.alpha.size): + 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): 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] - factors = np.zeros(n_items, dtype=np.int64) if factor_id is None else np.asarray(factor_id, dtype=np.int64) + 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) if factors.shape != (n_items,): raise ValueError("factor_id length must match number of items") - if np.any(factors < 0) or np.any(factors >= n_dims): + if np.any(factors >= n_dims): raise ValueError("factor_id values must be in 0..n_dims-1") linked = source.copy() @@ -43,6 +74,8 @@ 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] @@ -50,3 +83,108 @@ 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 new file mode 100644 index 000000000..723b6f3fd --- /dev/null +++ b/python/fast_mlsirm/lltm.py @@ -0,0 +1,125 @@ +"""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 new file mode 100644 index 000000000..61d04fa67 --- /dev/null +++ b/python/fast_mlsirm/mhrm.py @@ -0,0 +1,243 @@ +"""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 new file mode 100644 index 000000000..032acaf06 --- /dev/null +++ b/python/fast_mlsirm/mixed.py @@ -0,0 +1,344 @@ +"""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 new file mode 100644 index 000000000..d074c7369 --- /dev/null +++ b/python/fast_mlsirm/mixture.py @@ -0,0 +1,148 @@ +"""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 new file mode 100644 index 000000000..afb00b512 --- /dev/null +++ b/python/fast_mlsirm/models.py @@ -0,0 +1,129 @@ +"""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/nominal.py b/python/fast_mlsirm/nominal.py new file mode 100644 index 000000000..6aa950510 --- /dev/null +++ b/python/fast_mlsirm/nominal.py @@ -0,0 +1,199 @@ +"""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 f6c9437d0..e43df9809 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 FitConfig, PenaltyConfig +from .config import VALID_MODELS, FitConfig, PenaltyConfig from .math import sigmoid, softplus from .types import MLSIRMParams @@ -32,16 +32,20 @@ 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: - factors = np.asarray(factor_id, dtype=np.int64) - if factors.shape != (n_items,): + raw = np.asarray(factor_id) + if raw.shape != (n_items,): raise ValueError("factor_id length must match number of items") - if np.any(factors < 0) or np.any(factors >= n_dims): + 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): raise ValueError("factor_id values must be in 0..n_dims-1") - return factors + return raw.astype(np.int64, copy=False) 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 @@ -53,23 +57,40 @@ def linear_predictor( model: str = "MLS2PLM", eps_distance: float = 1e-8, ) -> tuple[np.ndarray, np.ndarray]: - free_alpha, uses_space = model_flags(model) + """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) a = params.a if free_alpha else np.ones_like(params.alpha) theta_factor = params.theta[:, factor_id] - if uses_space: + 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: # 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) - gamma = params.gamma + interaction = -params.gamma * distance else: distance = np.zeros((params.theta.shape[0], len(factor_id)), dtype=np.float64) - gamma = 0.0 + interaction = 0.0 - eta = a[None, :] * theta_factor + params.b[None, :] - gamma * distance + eta = a[None, :] * theta_factor + params.b[None, :] + interaction return eta, distance @@ -83,13 +104,16 @@ 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/polytomous.py b/python/fast_mlsirm/polytomous.py new file mode 100644 index 000000000..595566287 --- /dev/null +++ b/python/fast_mlsirm/polytomous.py @@ -0,0 +1,1167 @@ +"""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 (Go et al. 2024 identification); 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 new file mode 100644 index 000000000..1d4928936 --- /dev/null +++ b/python/fast_mlsirm/preprocessing.py @@ -0,0 +1,110 @@ +"""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 new file mode 100644 index 000000000..2e3bb228d --- /dev/null +++ b/python/fast_mlsirm/rasch_cml.py @@ -0,0 +1,115 @@ +"""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/rsm.py b/python/fast_mlsirm/rsm.py new file mode 100644 index 000000000..c316b7ea0 --- /dev/null +++ b/python/fast_mlsirm/rsm.py @@ -0,0 +1,131 @@ +"""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 new file mode 100644 index 000000000..d6cd088be --- /dev/null +++ b/python/fast_mlsirm/rt.py @@ -0,0 +1,253 @@ +"""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 new file mode 100644 index 000000000..8bb005da9 --- /dev/null +++ b/python/fast_mlsirm/serving.py @@ -0,0 +1,798 @@ +"""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/testlet.py b/python/fast_mlsirm/testlet.py new file mode 100644 index 000000000..a4b4281cf --- /dev/null +++ b/python/fast_mlsirm/testlet.py @@ -0,0 +1,191 @@ +"""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 new file mode 100644 index 000000000..19b3ce26f --- /dev/null +++ b/python/fast_mlsirm/twopl.py @@ -0,0 +1,208 @@ +"""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 954755ebe..9044f991a 100644 --- a/python/fast_mlsirm/types.py +++ b/python/fast_mlsirm/types.py @@ -56,13 +56,20 @@ 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, float] + model_fit: dict[str, Any] 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/validation.py b/python/fast_mlsirm/validation.py new file mode 100644 index 000000000..61803de66 --- /dev/null +++ b/python/fast_mlsirm/validation.py @@ -0,0 +1,103 @@ +"""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 new file mode 100644 index 000000000..de557bc12 --- /dev/null +++ b/python/fast_mlsirm/wle.py @@ -0,0 +1,206 @@ +"""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/test_cli.py b/tests/test_cli.py index e79e7f0b5..322983811 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -173,6 +173,105 @@ 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" @@ -202,6 +301,58 @@ 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 6a810ac70..d5f06d727 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -94,3 +94,16 @@ 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 9a6767f18..788be227e 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -22,6 +22,30 @@ 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)) @@ -119,6 +143,95 @@ 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 new file mode 100644 index 000000000..0c27b6c8d --- /dev/null +++ b/tests/test_estimator_marginal.py @@ -0,0 +1,146 @@ +"""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 656574891..53a9e5fd3 100644 --- a/tests/test_estimator_mmle.py +++ b/tests/test_estimator_mmle.py @@ -49,10 +49,37 @@ def test_reserved_estimators_raise(estimator): fit(y, factors, FitConfig(model="ULS2PLM", estimator=estimator), mask=mask) -def test_mmle_rejects_spatial_models_until_supported(): +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(): y, factors, mask, *_ = _simulate_2pl(n_persons=50, n_items=5) - with pytest.raises(NotImplementedError, match="only unidimensional 2PL"): - fit(y, factors, FitConfig(model="MLS2PLM", estimator="mmle"), mask=mask) + 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), + ) def test_invalid_estimator_rejected_by_validate(): diff --git a/tests/test_fitstats.py b/tests/test_fitstats.py new file mode 100644 index 000000000..0206296be --- /dev/null +++ b/tests/test_fitstats.py @@ -0,0 +1,512 @@ +"""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 new file mode 100644 index 000000000..41d91076e --- /dev/null +++ b/tests/test_marginal_parity.py @@ -0,0 +1,148 @@ +"""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 new file mode 100644 index 000000000..e85a3b344 --- /dev/null +++ b/tests/test_mixed_items.py @@ -0,0 +1,332 @@ +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 eaae79c65..f6c1cbf63 100644 --- a/tests/test_objective.py +++ b/tests/test_objective.py @@ -421,6 +421,15 @@ 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 new file mode 100644 index 000000000..c0a6b2964 --- /dev/null +++ b/tests/test_paper_features.py @@ -0,0 +1,5010 @@ +"""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 numpy as np +import pytest + +from fast_mlsirm import ( + 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 (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 ( + 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 ( + 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) + nan_missing = valid.copy() + nan_missing[0, 0, 0] = np.nan + neg_missing = valid.copy() + neg_missing[0, 0, 0] = -1 + res_nan = fit_facets(nan_missing, n_cat=2, q_theta=7, max_iter=5) + res_neg = fit_facets(neg_missing, n_cat=2, q_theta=7, max_iter=5) + assert np.allclose(res_neg.item_difficulty, res_nan.item_difficulty) + assert np.allclose(res_neg.rater_severity, res_nan.rater_severity) + assert np.allclose(res_neg.thresholds, res_nan.thresholds) + assert np.allclose(res_neg.theta, res_nan.theta) + 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 diff --git a/tests/test_scoring_methods.py b/tests/test_scoring_methods.py new file mode 100644 index 000000000..ea285fd97 --- /dev/null +++ b/tests/test_scoring_methods.py @@ -0,0 +1,266 @@ +"""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 new file mode 100644 index 000000000..5d78a5f29 --- /dev/null +++ b/tests/test_security_hardening.py @@ -0,0 +1,1589 @@ +"""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 new file mode 100644 index 000000000..86fa7176d --- /dev/null +++ b/tests/test_serving.py @@ -0,0 +1,234 @@ +"""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 new file mode 100644 index 000000000..0b3ae850f --- /dev/null +++ b/tests/unit/agreement_tests.rs @@ -0,0 +1,136 @@ +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 new file mode 100644 index 000000000..7d4a71c5d --- /dev/null +++ b/tests/unit/cdm_tests.rs @@ -0,0 +1,4165 @@ +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/crm_tests.rs b/tests/unit/crm_tests.rs new file mode 100644 index 000000000..6b3a296ec --- /dev/null +++ b/tests/unit/crm_tests.rs @@ -0,0 +1,308 @@ +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/dif_tests.rs b/tests/unit/dif_tests.rs new file mode 100644 index 000000000..c540a9c4a --- /dev/null +++ b/tests/unit/dif_tests.rs @@ -0,0 +1,1487 @@ +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()); +} diff --git a/tests/unit/equating_tests.rs b/tests/unit/equating_tests.rs new file mode 100644 index 000000000..7e1ac27c7 --- /dev/null +++ b/tests/unit/equating_tests.rs @@ -0,0 +1,1397 @@ +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/facets_tests.rs b/tests/unit/facets_tests.rs new file mode 100644 index 000000000..e1a81a657 --- /dev/null +++ b/tests/unit/facets_tests.rs @@ -0,0 +1,365 @@ +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/fitstats_batch3_tests.rs b/tests/unit/fitstats_batch3_tests.rs new file mode 100644 index 000000000..7ebff94b9 --- /dev/null +++ b/tests/unit/fitstats_batch3_tests.rs @@ -0,0 +1,275 @@ +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 new file mode 100644 index 000000000..5b0a834fd --- /dev/null +++ b/tests/unit/fitstats_ic_tests.rs @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..517ee56f4 --- /dev/null +++ b/tests/unit/fitstats_ld_tests.rs @@ -0,0 +1,202 @@ +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 new file mode 100644 index 000000000..cc9cfa9ed --- /dev/null +++ b/tests/unit/fitstats_m2_branch_tests.rs @@ -0,0 +1,664 @@ +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 new file mode 100644 index 000000000..91fe15afe --- /dev/null +++ b/tests/unit/fitstats_tests.rs @@ -0,0 +1,1029 @@ +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, 2], None, 1, 2, 2, &[1.0, 1.0], &[0.0, 0.0], pm, 7).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.25, 0.75], + 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, 2], None, 1, 3, 2, &[1.0; 3], &[0.0; 3], pm, 7).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 new file mode 100644 index 000000000..ed09c7396 --- /dev/null +++ b/tests/unit/fitstats_vuong_tests.rs @@ -0,0 +1,75 @@ +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 new file mode 100644 index 000000000..524711cab --- /dev/null +++ b/tests/unit/gpcm_tests.rs @@ -0,0 +1,767 @@ +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 new file mode 100644 index 000000000..ac0058d92 --- /dev/null +++ b/tests/unit/grm_tests.rs @@ -0,0 +1,759 @@ +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/lib_additional_tests.rs b/tests/unit/lib_additional_tests.rs new file mode 100644 index 000000000..4cfbe858a --- /dev/null +++ b/tests/unit/lib_additional_tests.rs @@ -0,0 +1,54 @@ +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 new file mode 100644 index 000000000..be026abfb --- /dev/null +++ b/tests/unit/lib_tests.rs @@ -0,0 +1,233 @@ +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 new file mode 100644 index 000000000..dcd38d20e --- /dev/null +++ b/tests/unit/linking_branch_tests.rs @@ -0,0 +1,112 @@ +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 new file mode 100644 index 000000000..0c2e7c88e --- /dev/null +++ b/tests/unit/linking_tests.rs @@ -0,0 +1,113 @@ +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 new file mode 100644 index 000000000..dbda5af64 --- /dev/null +++ b/tests/unit/lltm_tests.rs @@ -0,0 +1,433 @@ +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 new file mode 100644 index 000000000..a8320b0c2 --- /dev/null +++ b/tests/unit/marginal_covariate_interaction_tests.rs @@ -0,0 +1,59 @@ +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 new file mode 100644 index 000000000..f97e54421 --- /dev/null +++ b/tests/unit/marginal_em_endpoint_tests.rs @@ -0,0 +1,479 @@ +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 new file mode 100644 index 000000000..b12970ee5 --- /dev/null +++ b/tests/unit/marginal_recovery_tests.rs @@ -0,0 +1,1230 @@ +//! 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 new file mode 100644 index 000000000..dbfe22a67 --- /dev/null +++ b/tests/unit/marginal_xirule_parse_tests.rs @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..42d1a1f41 --- /dev/null +++ b/tests/unit/mhrm_tests.rs @@ -0,0 +1,1505 @@ +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 new file mode 100644 index 000000000..b85d919ab --- /dev/null +++ b/tests/unit/mixed_tests.rs @@ -0,0 +1,495 @@ +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 new file mode 100644 index 000000000..bd5d51e89 --- /dev/null +++ b/tests/unit/mixture_tests.rs @@ -0,0 +1,456 @@ +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 new file mode 100644 index 000000000..8234eadb6 --- /dev/null +++ b/tests/unit/mmle_tests.rs @@ -0,0 +1,135 @@ +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/nodes_coverage_branch_tests.rs b/tests/unit/nodes_coverage_branch_tests.rs new file mode 100644 index 000000000..43aaeefbf --- /dev/null +++ b/tests/unit/nodes_coverage_branch_tests.rs @@ -0,0 +1,31 @@ +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 new file mode 100644 index 000000000..9f13a9881 --- /dev/null +++ b/tests/unit/nodes_tests.rs @@ -0,0 +1,205 @@ +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 new file mode 100644 index 000000000..e64ae6586 --- /dev/null +++ b/tests/unit/nominal_tests.rs @@ -0,0 +1,759 @@ +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 new file mode 100644 index 000000000..160556bb9 --- /dev/null +++ b/tests/unit/oakes_tests.rs @@ -0,0 +1,404 @@ +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/poly_marginal_tests.rs b/tests/unit/poly_marginal_tests.rs new file mode 100644 index 000000000..af86ce692 --- /dev/null +++ b/tests/unit/poly_marginal_tests.rs @@ -0,0 +1,243 @@ +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()); + 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 new file mode 100644 index 000000000..986f8d36d --- /dev/null +++ b/tests/unit/poly_tests.rs @@ -0,0 +1,2899 @@ +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 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 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()); + 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), + ] { + 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 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(); + // 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/crates/mlsirm-core/tests/proptest_neg_loglik.rs b/tests/unit/proptest_neg_loglik_tests.rs similarity index 96% rename from crates/mlsirm-core/tests/proptest_neg_loglik.rs rename to tests/unit/proptest_neg_loglik_tests.rs index 44c35d703..1e58e2691 100644 --- a/crates/mlsirm-core/tests/proptest_neg_loglik.rs +++ b/tests/unit/proptest_neg_loglik_tests.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 mlsirm_core::{ModelConfig, ModelType, Params, PenaltyConfig}; +use crate::{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) = - mlsirm_core::neg_loglik_and_grad(&y, mask_ref, &factor_id, ¶ms, &config, &penalty); + crate::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/tests/unit/quadrature_tests.rs b/tests/unit/quadrature_tests.rs new file mode 100644 index 000000000..1a3bec21d --- /dev/null +++ b/tests/unit/quadrature_tests.rs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..ef48ff5fa --- /dev/null +++ b/tests/unit/rasch_cml_tests.rs @@ -0,0 +1,286 @@ +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/rsm_tests.rs b/tests/unit/rsm_tests.rs new file mode 100644 index 000000000..b404f4761 --- /dev/null +++ b/tests/unit/rsm_tests.rs @@ -0,0 +1,297 @@ +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 new file mode 100644 index 000000000..463c8b026 --- /dev/null +++ b/tests/unit/rt_joint_tests.rs @@ -0,0 +1,620 @@ +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 new file mode 100644 index 000000000..113ce869d --- /dev/null +++ b/tests/unit/rt_tests.rs @@ -0,0 +1,696 @@ +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 new file mode 100644 index 000000000..14779504a --- /dev/null +++ b/tests/unit/scoring_cat_pv_tests.rs @@ -0,0 +1,186 @@ +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 new file mode 100644 index 000000000..bb6425162 --- /dev/null +++ b/tests/unit/scoring_gpu_score_tests.rs @@ -0,0 +1,288 @@ +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 new file mode 100644 index 000000000..5db3cc6d8 --- /dev/null +++ b/tests/unit/scoring_reliability_tests.rs @@ -0,0 +1,83 @@ +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 new file mode 100644 index 000000000..31e4ae59f --- /dev/null +++ b/tests/unit/scoring_tests.rs @@ -0,0 +1,657 @@ +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 new file mode 100644 index 000000000..c5c464f3b --- /dev/null +++ b/tests/unit/scoring_validate_branch_tests.rs @@ -0,0 +1,93 @@ +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 new file mode 100644 index 000000000..ba57859d3 --- /dev/null +++ b/tests/unit/scoring_wle_poly_tests.rs @@ -0,0 +1,530 @@ +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 new file mode 100644 index 000000000..cbb35ae2f --- /dev/null +++ b/tests/unit/scoring_wle_tests.rs @@ -0,0 +1,470 @@ +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/testlet_tests.rs b/tests/unit/testlet_tests.rs new file mode 100644 index 000000000..4eace69e0 --- /dev/null +++ b/tests/unit/testlet_tests.rs @@ -0,0 +1,549 @@ +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 new file mode 100644 index 000000000..933028356 --- /dev/null +++ b/tests/unit/twopl_tests.rs @@ -0,0 +1,1729 @@ +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})"); + } + } + } +}