Skip to content

Add bratt_mm: Bradley-Terry model with additive ties parameter (VGAM bratt) - #307

Closed
seonghobae wants to merge 5 commits into
seonghobae-fidefrom
seonghobae-bratt
Closed

Add bratt_mm: Bradley-Terry model with additive ties parameter (VGAM bratt)#307
seonghobae wants to merge 5 commits into
seonghobae-fidefrom
seonghobae-bratt

Conversation

@seonghobae

@seonghobae seonghobae commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements bratt_mm — the Bradley-Terry model with an additive ties parameter alpha0, as specified by the VGAM bratt() family (VGAM 1.1-14, R/family.categorical.R — READ and normative source):

  • P(i beats j) = alpha_i / (alpha_i + alpha_j + alpha0)
  • P(i ties j) = alpha0 / (alpha_i + alpha_j + alpha0)

Fitted by a hand-derived supporting-hyperplane MM ascent (same derivation pattern as the crates existing bradley_terry_mm) with a **joint** reference rescale of alpha AND alpha0 each iteration — likelihood-preserving via the verified identity sum(wins) + T = sum_{i<j} n_ij`. This is the additive-alpha0 ties model, NOT Rao-Kupper or Davidson (neither read; named for disambiguation only). Bradley & Terry (1952) NOT READ — cited as model origin only.

Stacked on #306 (seonghobae-predict).

Spec-verify (adversarial, BEFORE implementation)

Verdict: APPROVED-WITH-CHANGES — all 5 findings adopted:

  1. (MAJOR) T==0 rejection documented as a local MM API contract, not VGAM behavior (VGAM silently continues with ties <- 0*y).
  2. (MAJOR) Hunter (2004) marked NOT READ / non-normative; MM pattern cited from the crates own bradley_terry_mm`.
  3. (MINOR) MU4 kill re-anchored to iteration-2 pins (constant-D shift cancels in the rescale from an all-one start).
  4. (MINOR) n <= 10000 cap validated BEFORE any n*n arithmetic (new O(n^2) guard).
  5. (MINOR) fractional f64 weighted counts explicitly accepted.

Reviewer independently verified the scale-invariance identity and hand-checked the B1 anchor arithmetic.

Oracle (exact Fractions, EXECUTED)

files/bratt_oracle.py — all anchors pass:

  • B1 iter-1 (exact dyadic pins): alpha = [1, 27/40, 3/4], alpha0 = 9/14
  • B1 iter-2: alpha = [1, 0.6276558170061743, 0.7021719314027881], alpha0 = 0.6129259568116415
  • B2 converged (tol 1e-13, 22 updates): alpha = [1, 0.6150318884241122, 0.686344995662376], alpha0 = 0.6038293879270596, LL = -15.12765635227613, spec-gradient < 1e-13
  • B4 rescale (ref value 2): alpha = [3.2518639076171687, 2, 2.2319005195681427], alpha0 = 1.9635709929585714

Mutation kills (all EXECUTED against the real crate)

Mutant Description Killed by
MU1 W_i includes tie counts bt2_anchor_b1_exact_iter1
MU2 alpha0 denominator double-counts pairs bt2_anchor_b1_exact_iter1
MU3 rescale skips alpha0 bt2_anchor_b1_exact_iter1 / bt2_rescale_b4
MU4 D missing alpha0 bt2_anchor_b1_iter2 (iter-1 blind by design — documented)
MU5 convergence check ignores alpha0 delta bt2_alpha0_delta_convergence (tol=0.34 separates d_alpha=0.325 < d_0=0.357 at update 1; pins iterations==2 + iter-2 values)

MU5 note: symmetric-wins fixtures are provably blind to this mutant (the joint rescale fixes alpha0 = 0.5 after one update); the kill uses a tolerance placed strictly between the two deltas instead.

Tests

  • Rust bt2_ block: 7 tests + bt2_mc_500_dominance (#[ignore], 500-rep MC log-likelihood dominance over 20 perturbations with an independent LL-field recomputation cross-check). Every assert reads crate outputs.
  • Error contract: 14 pinned error paths.
  • Python TestBratt: 4 tests (oracle pins through the PyO3 binding, validation rejects).

Suite evidence

  • cargo test -p mlsirm-core --lib: 840 passed (833 + 7 new)
  • pytest tests/test_paper_features.py -q: 333 passed (329 + 4 new)
  • bt2_mc_500_dominance: passed (run explicitly)

Adversarial impl-review outcome

Round 1: FINDINGS (2) ? both fixed in commit 9e32315:

  • MAJOR: finite-but-huge counts could overflow derived aggregates (w_tot, t_tot, pair totals n_ij) to +inf, letting the MM update return exactly-zero parameters and a NaN log-likelihood as Ok. Fixed with up-front finiteness checks on all derived aggregates, a strictly-positive requirement on updated parameters, and a log-likelihood finiteness check. Regression bt2_huge_counts_overflow_rejected covers both the pair-total and row-total overflow paths.
  • MINOR: object-dtype matrices of Python bools bypassed the boolean rejection at the Python boundary. The object path now rejects bool/np.bool_ elements before casting; regression added to TestBratt.test_validation.

Round 2: CLEAN (0 findings) ? round-1 demos now raise ValueError; bt2_ 8 passed / 1 ignored; TestBratt 4 passed; adversarial probes for residual overflow paths (denominator accumulation, LL accumulation, Decimal/Fraction/None object arrays, 0-d arrays) found no confirmed defects.

seonghobae and others added 3 commits July 27, 2026 08:04
…ting)

Rust core predict_rating_two/predict_rating_multi in mlsirm-core scaling
(Elo logistic, deviation-shrunk Glicko-family with qip3 = 3(ln10/400/pi)^2,
EloM rowmean branch with optional min-tie placing), PyO3 bindings, Python
wrappers, exact-oracle anchor tests P1-P9, 7-mutant EXECUTED kill map,
MC-500 invariants, TestPredict pytest coverage, CHANGELOG entry.

Normative source: CRAN PlayerRatings 1.1-0 R/ratings.R lines 1056-1133
(READ). REDUCED-SCOPE: index-based players, per-game/scalar gamma.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Impl-review round-1 findings: (1) games/tng went through a float
round-trip in the Python wrappers, losing exact integer counts at or
above 2^53 and silently shifting the strict games < tng cutoff — new
_predict_games_u64/_predict_tng_u64 keep integer inputs lossless and
reject float inputs at/above the source dtype's exact-integer bound;
(2) predict_rating_multi computed nr*np unchecked, so a wrapping
product could pass the length check and panic on indexing — now
checked_mul with a checked error. Regression tests at both the Rust
error contract and pytest levels.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Implements the VGAM bratt() family model (VGAM 1.1-14
R/family.categorical.R, READ and normative): P(i beats j) =
alpha_i / (alpha_i + alpha_j + alpha0), P(tie) = alpha0 / (...),
fitted by a hand-derived supporting-hyperplane MM ascent with a
joint likelihood-preserving rescale of alpha AND alpha0. This is
the additive-alpha0 ties model, NOT Rao-Kupper or Davidson.

- Rust core bratt_mm + BrattResult in mlsirm-core scaling.rs with
  full error contract (n cap 10000 before O(n^2), symmetric ties,
  zero-win and tie-free rejection directing to bradley_terry_mm).
- bt2_ test block: exact-Fraction oracle anchors B1-B4 (iter-1 pins
  [1, 27/40, 3/4], alpha0 = 9/14; converged pins at 1e-13 with an
  independent spec-gradient check), permutation equivariance,
  rescale anchor, 14-path error contract, and an MC-500
  log-likelihood dominance test (#[ignore]).
- 5 mutation kills EXECUTED (W-includes-ties, alpha0 denominator
  double-count, rescale-skips-alpha0, D-missing-alpha0, and an
  alpha0-blind convergence check killed by a tol-separated
  convergence anchor pinning iterations == 2).
- PyO3 binding bratt_mm + Python wrapper/BrattResult dataclass with
  hardened input validation; TestBratt (4 tests).

cargo test -p mlsirm-core --lib: 840 passed.
pytest tests/test_paper_features.py: 333 passed.

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: cee7372f-be7b-4a87-bdd3-4c1fdebaf155

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-bratt

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

- MAJOR: finite-but-huge counts could overflow derived aggregates
  (w_tot, t_tot, pair totals n_ij) to +inf, letting the MM update
  return exactly-zero parameters and a NaN log-likelihood as Ok.
  Now every derived aggregate is checked for finiteness up front,
  updated parameters must be finite AND strictly positive, and a
  non-finite log-likelihood is rejected. Regression
  bt2_huge_counts_overflow_rejected covers both the pair-total and
  row-total overflow paths (asserts read the crate Err).
- MINOR: object-dtype arrays of Python bools bypassed the boolean
  rejection at the Python boundary (cast cleanly to float64). The
  object path now rejects bool/np.bool_ elements before casting;
  regression added to TestBratt.test_validation.

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

* Add fleiss_kappa: Fleiss' multi-rater kappa with exact (Conger) variant

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>

* fix(fleiss): reject uint64 overflow and lossy k coercion in wrapper

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>

---------

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