Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,28 @@

### Added

- **DIMTEST test of essential unidimensionality (original Stout-style
AT1/AT2 statistic)** (`fast_mlsirm.dimtest`; in Rust
`mlsirm_core::detect::dimtest`, PyO3 `py_dimtest`): confirmatory
hypothesis test with caller-supplied assessment subtests AT1/AT2 (equal
length >= 4, disjoint) and the complementary partitioning subtest PT;
examinees are grouped by raw PT total score (groups smaller than 20
discarded), within each retained group the observed ML variance of AT
totals is compared to the local-independence variance
`sum_i p_i (1 - p_i)` normalized by Stout's standard-error estimate
`S_k`, giving `T_L = K^{-1/2} sum_k (sigma_k^2 - sigma_U,k^2)/S_k`, the
AT2 bias correction `T_B`, and `T = (T_L - T_B)/sqrt(2)` with a one-sided
upper-tail normal p-value. Formulas transcribed from Nandakumar & Stout's
1992 ERIC technical report ED351383 (published 1993, *Journal of
Educational Statistics, 18*(1), 41-68), which describes Stout (1987,
Sec. 4); Kieftenbeld & Nandakumar (2015, PMC5978610) READ for the
original-vs-bootstrap bias-correction distinction. NOT read: Stout (1987)
original article, Stout et al. (2001), Froelich & Habing (2008), DIM-Pack
sources — no ATFIND, no DIMTEST 2 / bootstrap correction, no polytomous
items, no missing data. Pinned against an independent NumPy oracle
(500x18 two-dimensional fixture, agreement 1e-12 on `T_L`/`T_B`/`T`;
p-value at 5e-7 due to the crate's Numerical Recipes `erfc`).

- **Confidence-interval (ACI) classification for CAT**
(`fast_mlsirm.ci_classify`; in Rust `mlsirm_core::exposure::ci_classify`,
PyO3 `py_ci_classify`): single-cut binary-response classification by
Expand Down
44 changes: 44 additions & 0 deletions crates/fast-mlsirm-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use mlsirm_core::classification::{
};
use mlsirm_core::crm::fit_crm as core_fit_crm;
use mlsirm_core::detect::detect_analysis as core_detect_analysis;
use mlsirm_core::detect::dimtest as core_dimtest;
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,
Expand Down Expand Up @@ -1714,6 +1715,48 @@ fn detect_analysis(
Ok(out.into())
}

/// Confirmatory Stout-style DIMTEST statistic of essential unidimensionality
/// (`mlsirm_core::detect::dimtest`).
///
/// Formulas transcribed from Nandakumar & Stout's 1992 ERIC technical-report
/// version (ED351383) of "Refinements of Stout's Procedure for Assessing
/// Latent Trait Unidimensionality" (published 1993, *Journal of Educational
/// Statistics, 18*(1), 41-68), which describes Stout (1987, Sec. 4).
/// Kieftenbeld & Nandakumar (2015, PMC5978610) was READ for the original
/// second-AT bias correction vs. later bootstrap DIMTEST distinction.
/// NOT READ: Stout (1987) original Psychometrika article, Stout et al.
/// (2001), Froelich & Habing (2008), and DIM-Pack source code; Stout (1987)
/// is cited only as described by Nandakumar & Stout (1992/1993).
///
/// `x` is a flattened row-major `n_persons * n_items` binary (0/1, no
/// missing) response matrix; `at1`/`at2` are caller-supplied assessment
/// subtest item indices (equal length >= 4, disjoint); PT is the complement.
/// Persons are grouped by raw PT score; groups with fewer than 20 examinees
/// are discarded. Returns a dict with `t`, `t_l`, `t_b`, `p_value`
/// (one-sided upper tail), `groups_used`, `n_discarded`, and
/// `retained_pt_scores`.
#[pyfunction]
fn py_dimtest(
py: Python<'_>,
x: PyReadonlyArray1<'_, f64>,
n_persons: usize,
n_items: usize,
at1: Vec<usize>,
at2: Vec<usize>,
) -> PyResult<Py<pyo3::types::PyDict>> {
let flat = x.as_slice()?;
let res = core_dimtest(flat, n_persons, n_items, &at1, &at2).map_err(PyValueError::new_err)?;
let out = pyo3::types::PyDict::new(py);
out.set_item("t", res.t)?;
out.set_item("t_l", res.t_l)?;
out.set_item("t_b", res.t_b)?;
out.set_item("p_value", res.p_value)?;
out.set_item("groups_used", res.groups_used)?;
out.set_item("n_discarded", res.n_discarded)?;
out.set_item("retained_pt_scores", res.retained_pt_scores)?;
Ok(out.into())
}

fn classification_result_to_dict(
py: Python<'_>,
res: ClassificationResult,
Expand Down Expand Up @@ -6334,6 +6377,7 @@ fn fast_mlsirm_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(ksirt_occ, m)?)?;
m.add_function(wrap_pyfunction!(subscore_analysis, m)?)?;
m.add_function(wrap_pyfunction!(detect_analysis, m)?)?;
m.add_function(wrap_pyfunction!(py_dimtest, m)?)?;
m.add_function(wrap_pyfunction!(rudner_classification, m)?)?;
m.add_function(wrap_pyfunction!(lee_classification, m)?)?;
m.add_function(wrap_pyfunction!(livingston_lewis, m)?)?;
Expand Down
Loading