Skip to content

Add icc intraclass correlation coefficients (Shrout-Fleiss taxonomy, irr 0.85) - #309

Closed
seonghobae wants to merge 9 commits into
seonghobae-brattfrom
seonghobae-icc
Closed

Add icc intraclass correlation coefficients (Shrout-Fleiss taxonomy, irr 0.85)#309
seonghobae wants to merge 9 commits into
seonghobae-brattfrom
seonghobae-icc

Conversation

@seonghobae

@seonghobae seonghobae commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Implements icc — intraclass correlation coefficients for inter-rater reliability, the complete Shrout-Fleiss taxonomy, transcribed from CRAN irr 0.85 R/icc.R (READ, normative source).

Stacked on #308 (seonghobae-fleiss).

What

  • Rust core icc() + IccResult in crates/mlsirm-core/src/reliability.rs: all six variants (model oneway/twoway × type consistency/agreement × unit single/average) from one-pass ANOVA mean squares (MSr, MSw, MSc, MSe; sample-variance divisor n−1), the F test of H0: icc = r0, and unclamped confidence bounds. Reuses the existing private f_cdf/f_quantile.
  • Two-way agreement uses the Satterthwaite approximation; the R quirk that both units' CI bounds plug the estimate into the nr-scaled a,b form (icc.R lines 139-141) is preserved verbatim and documented.
  • Listwise NaN row drop (R na.omit); infinities rejected; degenerate zero-variance / icc=1 pivots return explicit errors instead of leaking non-finite output.
  • PyO3 binding (plain name icc) + thin Python wrapper with IccResult dataclass (masked/complex/object/bool rejected before casts).

Sources

  • Gamer et al., irr 0.85 R/icc.R — READ line by line, normative.
  • Shrout & Fleiss (1979), McGraw & Wong (1996), Bartko (1966) — NOT READ; cited as model origins only, attribution "as cited in" the R package.

Evidence

  • Adversarial spec-verify: APPROVED-WITH-CHANGES (4 mandatory changes, all adopted: agreement covered in the MC Spearman-Brown bridge; non-finite intermediate guards; precise listwise NaN contract; degenerate/perfect-reliability anchors).
  • Exact-Fraction oracle (EXECUTED) on Shrout-Fleiss Table 2: all six coefficients exact (448/2703, 920/1287, 184/635, 1792/4047, 3680/4047, 736/1187), F/df/p and CI pins from scipy 1.18.
  • 6 mutation kills EXECUTED (each mutant compiled, ran, FAILED the targeted test, restored): MSw divisor swap, F-quantile df order, agreement-single denominator drop, r0 ignored in F, row-mean dimension swap, CI Satterthwaite r0-instead-of-plug-in.
  • MC-500 (#[ignore]): subject/rater permutation invariance + Spearman-Brown single↔average bridge for all three families, all values read from crate outputs.
  • Full suites: cargo 853 pass; pytest paper suite 340 pass.

Adversarial implementation review (rounds 1-6)

Six review rounds executed against the wrapper and Rust core. Rounds 1-5
each found an object-dtype coercion bypass (object bools, nested 0-D
ndarray wrappers, 0-D object wrappers, np.void structured scalars, then
timedelta64 / self-referential 0-D arrays / float-lying subclasses).
Fixes 88c56c0, 21a544c, c51ad5f, b9e12f5 hardened per-element vetting;
9ebd18f resolved the class root-cause by rejecting object-dtype arrays
outright (numeric data never requires object dtype). Round 6: CLEAN ? no
remaining input bypass or R-semantics defect confirmed.

seonghobae and others added 3 commits July 27, 2026 09:55
Reimplements CRAN irr 0.85 kappam.fleiss() (R source READ and normative;
Fleiss 1971 and Conger 1980 NOT READ, cited as model origins only) in the
Rust core (mlsirm_core::agreement::fleiss_kappa) with a thin PyO3 binding
and NumPy wrapper:

- classification-table agreement, classic (sum p_j^2) and exact
  (sum p_j^2 - (1/nr) sum s2_j) chance agreement; kappa, Fleiss' z test,
  and category-wise kappas (classic mode; NaN for empty categories,
  matching R's 0/0)
- listwise row drop for missing ratings (negative code / NaN)
- documented API deviations: index codes 0..k-1 with explicit/inferred k,
  error on degenerate 1 - chanceP = 0 (R returns NaN), size caps

Evidence: exact-Fraction oracle anchors FK1-FK5 (classic kappa 139/399,
exact 37/102, category kappas [1/21, 31/91, 43/63]); 6 mutants EXECUTED
and killed (agreeP centering, row-vs-column chance sums, exact==classic,
variance sign, missing-as-category, pjk centering); MC-500 subject/rater
permutation-invariance test (#[ignore], executed); cargo 847 pass;
TestFleiss pytest pass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Impl-review round 1 findings:
- MAJOR: uint64 values above i64::MAX wrapped negative via astype(int64)
  and were silently dropped as missing; now rejected before conversion.
- MINOR: explicit k accepted lossy coercions (3.9, '3', bool); now
  requires a true integer (int or np.integer, bool excluded).

Regression tests added to TestFleiss.test_validation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Transcribed from CRAN irr 0.85 R/icc.R (READ, normative source; Shrout &
Fleiss 1979, McGraw & Wong 1996, Bartko 1966 NOT READ, cited as origins
only). Rust core computes all six variants (oneway/twoway x
consistency/agreement x single/average) from one-pass ANOVA mean squares,
the F test of H0: icc = r0 (two-way agreement via Satterthwaite df,
preserving the R quirk that both units' CI bounds reuse the nr-scaled
plug-in form, icc.R lines 139-141), and unclamped confidence bounds.
Listwise NaN row drop; Inf rejected; degenerate zero-variance / icc=1
pivots error instead of leaking non-finite output.

Evidence: exact-Fraction oracle on Shrout-Fleiss Table 2 (all six
coefficients + scipy F CI pins), 6 EXECUTED mutation kills (MSw divisor,
quantile df order, agreement denominator, r0-in-F, dimension map, CI
plug-in), MC-500 permutation invariance + Spearman-Brown single/average
bridge for all three families. cargo 853 pass; pytest paper suite 340
pass.

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

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c03df648-45c5-4260-897d-fbb590383c9d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch seonghobae-icc

Comment @coderabbitai help to get the list of available commands.

seonghobae and others added 5 commits July 27, 2026 10:53
Impl-review finding (MINOR): object arrays of Python/numpy bools were
silently coerced to 0.0/1.0 before the bool-dtype check, bypassing the
boolean rejection contract. Scan object arrays for bool elements before
the float64 conversion; regression test added.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round-2 impl-review finding (MINOR): an object array whose element is a
0-D np.ndarray of dtype bool coerced to 1.0 and bypassed the boolean
rejection. The object-array scan now also rejects ndarray elements with
boolean dtype; regression test added.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round-3 impl-review finding (MINOR): 0-D object-dtype ndarrays wrapping
a bool still bypassed the scan. The guard now iteratively unwraps 0-D
ndarray elements of any dtype via .item() before the bool check, closing
the wrapper-nesting family of bypasses at the root; regression tests for
object-wrapped bool and np.bool_ added. 0-D float ndarray elements
remain accepted and bitwise-match the plain-float result.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the boolean blacklist with a numeric whitelist: after unwrapping
0-D ndarrays, only int/float/np.integer/np.floating scalars are accepted
(bool excluded as an int subclass). This closes the np.void structured-
scalar bypass and any future exotic-scalar coercion path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Five review rounds produced successive bypasses of per-element vetting
(object bools, nested 0-D wrappers, np.void, timedelta64, self-
referential 0-D arrays hanging the unwrap loop, __float__-lying
int/float subclasses). Numeric data never requires object dtype, so the
wrapper now rejects it categorically, eliminating the entire coercion
attack class.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Base automatically changed from seonghobae-fleiss to seonghobae-bratt July 31, 2026 12:38
…ce) (#311)

* Add kripp_alpha Krippendorff's alpha for inter-rater agreement

Rust reimplementation of CRAN irr 0.85 kripp.alpha() (R/kripp.alpha.R,
READ and normative; Krippendorff 1980 NOT READ, cited as method origin
only). Coincidence matrix over unordered rater pairs with the irr
divisor quirk preserved verbatim (mc = #nonmissing-1 per column only
when any value is missing, else 1), all four metrics (nominal, ordinal
half-endpoint weights, interval, ratio), alpha = 1 when fewer than two
observed levels. Documented deviations: all-missing, infinities, and
ratio zero-sum level pairs are explicit errors.

Evidence: exact-Fraction oracle anchors K1-K4 (nominal 113/152,
ordinal 108577/133160, interval 951/1120, ratio 18222619/22852465,
nmv=40; no-NA quirk pin 43/72 vs m-1 mutant's 11/18), 6-mutant
EXECUTED kill map, MC-500 permutation invariance. cargo 858 pass,
pytest paper suite 344 pass.

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

* Reject integer rating levels beyond 2**53 in kripp_alpha wrapper

Review finding (MAJOR): int64/uint64 rating labels beyond 2**53 are not
exactly representable as float64, so distinct levels silently collapsed
during the cast — complete disagreement returned alpha=1 with a single
level. The wrapper now rejects any integer array with values outside
[-2**53, 2**53]; the boundary itself remains accepted (exactly
representable). Regression test executes the reviewer's repro and was
red-green verified against the unguarded version.

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

---------

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

Copy link
Copy Markdown
Contributor Author

Superseded by #374 which lands the remaining #290#328 stack tip (seonghobae-ncohen feature set) onto main after #290 squash-merge made intermediate retargets CONFLICTING. Content preserved in #374 merge.

@seonghobae seonghobae closed this Jul 31, 2026
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.

1 participant