Skip to content

feat(facets): many-facet Rasch model rater-severity calibration (Linacre, 1989) - #218

Merged
seonghobae merged 2 commits into
seonghobae-validate-commit-160from
seonghobae-pr-213-completion-loop
Jul 24, 2026
Merged

feat(facets): many-facet Rasch model rater-severity calibration (Linacre, 1989)#218
seonghobae merged 2 commits into
seonghobae-validate-commit-160from
seonghobae-pr-213-completion-loop

Conversation

@seonghobae

Copy link
Copy Markdown
Contributor

Many-Facet Rasch Model (MFRM) rater-severity calibration

Stacked on #213 (base: seonghobae-validate-commit-160).

Implements Linacre's (1989) many-facet Rasch model: the rating scale model (Andrich, 1978) extended with a rater-severity facet,

ln[P(Y=k)/P(Y=k-1)] = theta_p - d_i - c_j - f_k

fitted by marginal-ML EM on a Gauss-Hermite trait grid (Bock & Aitkin, 1981). For this library's LLM-as-a-Judge mission, raters are judges: rater_severity puts each judge's harshness on a common logit scale adjusted for item difficulty and respondent ability.

Scope and caveats (stated in code)

  • Estimation is MMLE, not Linacre's JMLE — estimates match the Facets program only up to the JMLE-vs-MMLE difference.
  • Identification: theta ~ N(0,1), sum(c)=0, sum(f)=0; n_parameters = I + (J-1) + (K-2). Per-cycle recentering is likelihood-invariant (re-derived and reviewed).
  • Connectedness (Linacre's requirement): 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.

Architecture

  • crates/mlsirm-core/src/facets.rs — all numerics (reuses rsm_logprobs, solve_small)
  • crates/fast-mlsirm-py fit_facets PyO3 binding
  • python/fast_mlsirm/facets.py — validation + marshaling only

Verification evidence

  • Spec-verify: hand derivation adversarially reviewed before implementation; all gradients/Hessian/identification CONFIRMED; two reviewer fixes applied (GH-rule normalization confirmed; person-mediated connectivity union + prior-linking docs).
  • Impl-review: adversarial review re-derived the math, rebuilt the wheel, ran all tests; one confirmed defect (false mutation-kill claim in a test docstring) fixed with a positive connectivity assert.
  • Mutation check: gradient sign-flip mutant fails 4 tests.
  • Tests: FD anchors, J=1 reduction to fit_rsm, asymmetric severity recovery, sparse + disconnected + bridged designs, monotone loglik, #[ignore] 500-rep Monte Carlo (normal + skew-normal traits) bounding severity bias/RMSE.
  • cargo test -p mlsirm-core: 426 passed. PyO3 crate tests pass. Python facets tests pass.

References (APA 7th ed., all formulations verified against sources)

  • Linacre, J. M. (1989). Many-facet Rasch measurement. MESA Press.
  • Eckes, T. (2015). Introduction to many-facet Rasch measurement (2nd ed.). Peter Lang.
  • Bock, R. D., & Aitkin, M. (1981). Marginal maximum likelihood estimation of item parameters. Psychometrika, 46(4), 443-459.
  • Andrich, D. (1978). A rating formulation for ordered response categories. Psychometrika, 43(4), 561-573.

Add mlsirm_core::facets implementing the MFRM (Linacre, 1989; Eckes,
2015): adjacent-category log-odds theta - d_i - c_j - f_k, i.e. the
rating scale model (Andrich, 1978) with a rater facet, estimated by
marginal-ML EM on a Gauss-Hermite grid (Bock & Aitkin, 1981). This is
MMLE, not Linacre's JMLE, and the docs state the Facets-comparability
caveat. Identification: theta ~ N(0,1), severities and thresholds
centered (n_parameters = I + (J-1) + (K-2)); each EM cycle re-absorbs
centering shifts into item difficulty, which is likelihood-invariant.

Reports Linacre's connectedness requirement via union-find over the
person-mediated item/rater co-observation graph; connected=false means
cross-component comparisons rest solely on the trait prior.

Rust-only numerics reusing rsm_logprobs and solve_small; PyO3
fit_facets binding; thin validating Python wrapper fast_mlsirm.fit_facets
over a persons x items x raters NaN-missing array.

Tests: FD gradient anchors for locations and thresholds, J=1 reduction
to fit_rsm, asymmetric severity recovery, sparse-design recovery,
disconnected + bridged connectivity, input rejection, monotone
loglik trace, and an #[ignore] 500-rep Monte Carlo (normal and
skew-normal traits) bounding severity bias/RMSE. A gradient sign-flip
mutant was verified to fail 4 tests. Adversarial spec review and
implementation review completed; the one confirmed defect (a false
mutation-kill claim in a test docstring) is fixed by a positive
connectivity assert.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Many-Facet Rasch Model (MFRM; Linacre, 1989) rating-scale variant with a rater-severity facet, implemented in the Rust core with PyO3 and a thin Python validation/marshaling layer to support LLM-as-a-Judge “judge severity” calibration workflows.

Changes:

  • Implement mlsirm_core::facets::fit_facets (MMLE EM on a Gauss–Hermite trait grid) with a connectedness diagnostic.
  • Expose fit_facets to Python via the PyO3 core module and a public fast_mlsirm.fit_facets wrapper + FacetsFit result type.
  • Add Rust unit tests and Python feature tests, and document the feature in CHANGELOG.md.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/unit/facets_tests.rs New Rust unit tests covering FD gradient anchors, reduction to RSM (J=1), recovery, sparsity, connectedness, and validation.
tests/test_paper_features.py Adds Python-level tests for recovery, reduction-to-RSM, validation errors, and connectedness flag behavior.
python/fast_mlsirm/facets.py New public Python wrapper + FacetsFit dataclass; validates inputs and marshals to Rust core.
python/fast_mlsirm/init.py Re-exports fit_facets / FacetsFit in the package public API.
crates/mlsirm-core/src/lib.rs Registers the new facets module in the Rust core crate.
crates/mlsirm-core/src/facets.rs Core MFRM EM implementation, including design connectedness union-find.
crates/fast-mlsirm-py/src/lib.rs PyO3 binding exposing fit_facets to Python as a core function.
CHANGELOG.md Documents the new MFRM feature, identification, connectedness flag, and tests.
Comments suppressed due to low confidence (1)

python/fast_mlsirm/facets.py:117

  • Missing-value handling here treats only NaN as missing and rejects -1/negative sentinels as invalid categories. That diverges from the documented repo-wide missing-response convention (NaN, -1, or explicit mask) and makes it harder to pass integer arrays with missing entries. Consider treating finite negative values as missing when building the observed mask (still erroring on +/-inf).
    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")

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread python/fast_mlsirm/facets.py Outdated
The repo-wide missing-response convention is NaN, -1 (negative
sentinels), or an explicit mask; the facets wrapper previously accepted
only NaN and rejected negatives with a ValueError. The observed mask now
excludes negative cells before marshaling to the Rust core (which already
honors the mask), docstrings document the convention, and a regression
test asserts -1-coded and NaN-coded missing cells produce identical fits.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@seonghobae
seonghobae merged commit 375f390 into seonghobae-validate-commit-160 Jul 24, 2026
5 checks passed
@seonghobae
seonghobae deleted the seonghobae-pr-213-completion-loop branch July 24, 2026 07:26
seonghobae added a commit that referenced this pull request Jul 24, 2026
…cre, 1989) (#218)

* feat(facets): many-facet Rasch model rater-severity calibration

Add mlsirm_core::facets implementing the MFRM (Linacre, 1989; Eckes,
2015): adjacent-category log-odds theta - d_i - c_j - f_k, i.e. the
rating scale model (Andrich, 1978) with a rater facet, estimated by
marginal-ML EM on a Gauss-Hermite grid (Bock & Aitkin, 1981). This is
MMLE, not Linacre's JMLE, and the docs state the Facets-comparability
caveat. Identification: theta ~ N(0,1), severities and thresholds
centered (n_parameters = I + (J-1) + (K-2)); each EM cycle re-absorbs
centering shifts into item difficulty, which is likelihood-invariant.

Reports Linacre's connectedness requirement via union-find over the
person-mediated item/rater co-observation graph; connected=false means
cross-component comparisons rest solely on the trait prior.

Rust-only numerics reusing rsm_logprobs and solve_small; PyO3
fit_facets binding; thin validating Python wrapper fast_mlsirm.fit_facets
over a persons x items x raters NaN-missing array.

Tests: FD gradient anchors for locations and thresholds, J=1 reduction
to fit_rsm, asymmetric severity recovery, sparse-design recovery,
disconnected + bridged connectivity, input rejection, monotone
loglik trace, and an #[ignore] 500-rep Monte Carlo (normal and
skew-normal traits) bounding severity bias/RMSE. A gradient sign-flip
mutant was verified to fail 4 tests. Adversarial spec review and
implementation review completed; the one confirmed defect (a false
mutation-kill claim in a test docstring) is fixed by a positive
connectivity assert.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Treat negative sentinels as missing in fit_facets (review feedback)

The repo-wide missing-response convention is NaN, -1 (negative
sentinels), or an explicit mask; the facets wrapper previously accepted
only NaN and rejected negatives with a ValueError. The observed mask now
excludes negative cells before marshaling to the Rust core (which already
honors the mask), docstrings document the convention, and a regression
test asserts -1-coded and NaN-coded missing cells produce identical fits.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants