Skip to content

Add Plackett-Luce rankings LSR/I-LSR estimators (choix lsr_rankings/ilsr_rankings) - #294

Merged
seonghobae merged 2 commits into
seonghobae-rank-centralityfrom
seonghobae-pl-rankings
Jul 31, 2026
Merged

Add Plackett-Luce rankings LSR/I-LSR estimators (choix lsr_rankings/ilsr_rankings)#294
seonghobae merged 2 commits into
seonghobae-rank-centralityfrom
seonghobae-pl-rankings

Conversation

@seonghobae

@seonghobae seonghobae commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements Plackett-Luce ranking estimation via Luce Spectral Rankinglsr_rankings (one-shot) and ilsr_rankings (iterative MLE) for full and partial rankings, porting choix 0.4.1 exactly.

Stacked on #293 (Rank Centrality); base seonghobae-rank-centrality.

Source status

  • READ: choix 0.4.1 source (lsr.py lsr_rankings lines 272-317, ilsr_rankings 320-363, _init_lsr, _ilsr; utils.py statdist/log_transform). Every formula traceable.
  • NOT READ (as-cited): Maystre & Grossglauser (2015), Fast and accurate inference of Plackett-Luce models, NeurIPS 28 — cited as the algorithm origin per choix's docstrings.

Algorithm

Each ranking (best first, length >= 2) is a sequence of Luce choices: position i accrues rate 1/(sum of remaining ranked worths) on every loser->winner edge (suffix losers only), plus alpha regularization everywhere. Params = centered log stationary distribution. I-LSR feeds each pass the previous params via exp_transform until L1 change <= tol*n.

Documented divergences from choix (all tested)

  1. Rankings shorter than 2 items rejected (choix silently no-ops them).
  2. Within-ranking duplicates rejected (choix accepts them when the chain stays connected).
  3. Negative indices rejected in the Python wrapper BEFORE the unsigned cast (Python's would silently wrap).

Spec-verify (adversarial, BEFORE implementation)

Verdict: APPROVED-WITH-CHANGES — all 6 mandatory changes adopted: (1) MU2 rationale corrected (RA kills transpose, probe maxdiff 1.67); (2) divergences documented + tested; (3) iteration pins 8/11 at tol=1e-8 are choix-equivalent probes, not dps-50 artifacts; (4) atol 1e-7 is oracle-MEASURED margin (converged iterates 8.6e-11 / 1.7e-9 from fixed points); (5) weights==exp_transform(params) invariant pinned (kills stale-weights mutants); (6) MC-500 uses local test Lcg with sequential-Luce categorical draws, centered truth.

Oracle (EXECUTED, exact Fraction + mpmath dps=50)

  • RA (n=3, 4 full rankings): weights exactly [6/11, 21/11, 6/11]; alpha=1/2 [3/4, 3/2, 3/4]; duplication exactly invariant at alpha=0, NOT at alpha>0.
  • RB (n=4, partial rankings): weights exactly [172/175, 12/7, 16/35, 148/175] — the ONLY fixture that can see a wrong all-items denominator (MU4 proven invisible on full rankings).
  • I-LSR fixed points pinned; length-2 equivalence with lsr_pairwise probe maxdiff 0.0 (pinned bit-exact with assert_eq!).
  • pip choix 0.4.1 cross-checks <= 2.3e-13.

Mutation kills (all EXECUTED)

Mutant Change Result
MU1 drop s -= w[winner] (stale denominator) KILLED (3 tests fail)
MU2 transpose chain update KILLED (4 fail)
MU3 losers over full ranking, not suffix KILLED (3 fail)
MU4 all-items sum instead of ranked-subset sum KILLED (2 fail — RB partial fixture)
MU5 I-LSR feeds uniform worths each pass KILLED (1 fail)

Baseline re-verified clean after restore (25 scaling tests).

Validation

  • cargo mlsirm-core --lib: 754 passed (6 new); MC-500 --ignored passes with measured worst MAE 0.1440 vs bound 0.2.
  • pytest tests/test_paper_features.py: 278 passed (3 new).
  • pyd rebuilt; smoke test vs oracle pins OK.

Adversarial impl-review outcome

Round 1: FINDINGS (3) — (1) High: public API could abort the process on oversized n (dense O(n^2) chain attempted terabyte allocations); (2) Medium: np.bool_ items bypassed the boolean rejection; (3) Low: infinite items leaked OverflowError instead of ValueError.

Fixed in 37f6b88: rankings_validate now rejects n > 10000 in the shared Rust core (covers PyO3 callers too, documented as a dense-chain ceiling); _rankings_to_csr rejects np.bool_ alongside bool and catches OverflowError; regression tests added on both the Rust error contract and the Python validation test.

Round 2: CLEAN — reviewer re-ran all round-1 break-it probes in child processes (huge n, np.bool_ item, inf item → all ValueError, no abort), confirmed the fix layers, cargo scaling 25 pass, pytest PlackettLuce 3 pass. Full review: session files pl_rankings_impl_review.md.

…lsr_rankings)

Rust cores scaling::lsr_rankings (one-shot) and scaling::ilsr_rankings
(iterative MLE) for full/partial rankings in CSR layout, porting choix
0.4.1 exactly (source READ; Maystre & Grossglauser 2015 NOT READ,
cited as-cited per choix docstrings). Python wrappers accept lists of
rankings, validate before unsigned casts (negatives, non-integers,
length<2 rejected), and return LsrResult.

Documented divergences from choix: length<2 rankings rejected (choix
no-ops), within-ranking duplicates rejected (choix accepts if
connected), negative indices rejected (Python would wrap).

Tests: exact rational anchors from an executed exact-Fraction/mpmath
oracle (full + partial fixtures; the partial fixture is the only one
that can see a wrong all-items denominator), bit-exact length-2
equivalence with lsr_pairwise, I-LSR fixed-point pins (atol 1e-7 =
oracle-measured margin) + iteration-count pins (8/11 at tol=1e-8) +
weights==exp_transform(params) invariant, full error contract incl.
disconnected graph and alpha overflow, MC-500 recovery (#[ignore],
bound 0.2 vs measured worst 0.1440). Five mutation kills executed
(MU1 stale denominator, MU2 transpose, MU3 full-ranking losers, MU4
all-items sum, MU5 uniform I-LSR worths).

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

coderabbitai Bot commented Jul 26, 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: 20d765e1-c66e-49a5-9629-00ff50c93bf9

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-pl-rankings

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

- Cap n at 10000 in rankings_validate: the dense O(n^2) chain would
  otherwise attempt terabyte allocations and abort the process on tiny
  inputs like lsr_rankings([[0,1]], 1_000_000) (finding 1, High).
- Reject np.bool_ items alongside Python bool (finding 2, Medium).
- Catch OverflowError from int(x) so infinite items raise ValueError
  per the wrapper contract (finding 3, Low).

Regression tests added on both the Rust error contract and the Python
validation test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@seonghobae
seonghobae merged commit 738c58f into seonghobae-rank-centrality Jul 31, 2026
6 checks passed
@seonghobae
seonghobae deleted the seonghobae-pl-rankings branch July 31, 2026 12:37
@seonghobae

Copy link
Copy Markdown
Contributor Author

Note: this PR was squash-merged into a non-main stacked base, so its commits did not land on main via this PR. The feature set reached main via #374 (stack tip integration) after #290.

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