From 2fc39109423943dadf4c09d56381eb32e811cb90 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:33:11 +0000 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/fast_mlsirm/scaling.py | 79 +++++++++++++++++++---------------- tests/test_scaling.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 37 deletions(-) create mode 100644 tests/test_scaling.py diff --git a/python/fast_mlsirm/scaling.py b/python/fast_mlsirm/scaling.py index 7d41a8e3b..ab460ca55 100644 --- a/python/fast_mlsirm/scaling.py +++ b/python/fast_mlsirm/scaling.py @@ -111,9 +111,7 @@ def thurstone_case_v(choice) -> ThurstoneResult: try: arr = arr.astype(np.float64) except (TypeError, ValueError) as exc: - raise ValueError( - "thurstone_case_v: choice must be numeric" - ) from exc + raise ValueError("thurstone_case_v: choice must be numeric") from exc arr = np.ascontiguousarray(arr, dtype=np.float64) if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: raise ValueError("thurstone_case_v: choice must be a square 2-D matrix") @@ -172,9 +170,7 @@ def bradley_terry_mm(wins, alpha=0.0, max_iter=10000, tol=1e-8): raise ValueError("bradley_terry_mm: wins must be a square 2-D matrix") n = arr.shape[0] core = _core_module() - res = core.bradley_terry_mm( - arr.ravel(), n, float(alpha), int(max_iter), float(tol) - ) + res = core.bradley_terry_mm(arr.ravel(), n, float(alpha), int(max_iter), float(tol)) return BradleyTerryResult( params=np.asarray(res["params"], dtype=np.float64), weights=np.asarray(res["weights"], dtype=np.float64), @@ -347,6 +343,7 @@ def ilsr_pairwise(wins, alpha=0.0, max_iter=100, tol=1e-8): iterations=int(res["iterations"]), ) + def rank_centrality(wins, alpha=0.0): """Rank Centrality: spectral ranking from the *ratios* of pairwise wins. @@ -380,6 +377,7 @@ def rank_centrality(wins, alpha=0.0): iterations=int(res["iterations"]), ) + def _rankings_to_csr(name, rankings, n): """Validate a list of rankings (best first) and CSR-flatten to u64. @@ -481,6 +479,7 @@ def ilsr_rankings(rankings, n, alpha=0.0, max_iter=100, tol=1e-8): iterations=int(res["iterations"]), ) + def _top1_to_csr(name, data, n): """Validate top-1 choice data and CSR-flatten to u64 arrays. @@ -699,6 +698,7 @@ def kendall_u(mat, correct=True): p_value=float(res["p_value"]), ) + @dataclass class EloResult: """Elo ratings and bookkeeping (Elo, 1978, as implemented by the CRAN @@ -1049,7 +1049,9 @@ def glicko2_rating( try: n = int(n_players) except (TypeError, ValueError, OverflowError) as exc: - raise ValueError(f"glicko2_rating: n_players is not an integer: {exc}") from None + raise ValueError( + f"glicko2_rating: n_players is not an integer: {exc}" + ) from None if n != n_players: raise ValueError("glicko2_rating: n_players must be an integer") g = arr.shape[0] @@ -1299,9 +1301,7 @@ def _count_vec(name, val): f"stephenson_rating: {name} must be a length-{n} array, got {v.shape}" ) if not np.all(np.isfinite(v)) or np.any(v < 0) or np.any(v != np.floor(v)): - raise ValueError( - f"stephenson_rating: {name} must be nonnegative integers" - ) + raise ValueError(f"stephenson_rating: {name} must be nonnegative integers") if np.any(v >= 2.0**53): raise ValueError( f"stephenson_rating: {name} values at or above 2**53 are not " @@ -1427,13 +1427,17 @@ def elom_rating( if g == 0: raise ValueError("elom_rating: at least one event is required") if np.any(~np.isfinite(players_f)) or np.any(players_f != np.floor(players_f)): - raise ValueError("elom_rating: players must be integral (use -1 for empty seats)") + raise ValueError( + "elom_rating: players must be integral (use -1 for empty seats)" + ) if np.any(players_f < -1): raise ValueError("elom_rating: player ids must be >= -1") if raw_players.dtype.kind in "iu": - if raw_players.dtype.kind == "u" and raw_players.size and int( - raw_players.max() - ) > np.iinfo(np.int64).max: + if ( + raw_players.dtype.kind == "u" + and raw_players.size + and int(raw_players.max()) > np.iinfo(np.int64).max + ): # An unsigned id above i64::MAX would wrap to a negative value # (uint64::MAX -> -1) and silently become the empty-seat # sentinel instead of being rejected. @@ -1554,7 +1558,9 @@ def _count_vec(name, val, shape): kfac_gv = float(kfac[1]) kfac_kv = float(kfac[2]) except (TypeError, ValueError, OverflowError) as exc: - raise ValueError(f"elom_rating: kriichi gv/kv is not numeric: {exc}") from None + raise ValueError( + f"elom_rating: kriichi gv/kv is not numeric: {exc}" + ) from None mode, kfac_k = "kriichi", 0.0 else: try: @@ -1625,9 +1631,7 @@ def _as_float(name, x, ndim): try: arr = arr.astype(np.float64) except (TypeError, ValueError) as exc: - raise ValueError( - f"metrics_rating: {name} must be numeric" - ) from exc + raise ValueError(f"metrics_rating: {name} must be numeric") from exc if arr.dtype.kind not in "fiu": raise ValueError( f"metrics_rating: {name} must be numeric, got dtype {arr.dtype}" @@ -1648,8 +1652,7 @@ def _as_float(name, x, ndim): nr, n_pred = pred_arr.shape if act_arr.shape[0] != nr: raise ValueError( - f"metrics_rating: act has length {act_arr.shape[0]} " - f"but pred has {nr} rows" + f"metrics_rating: act has length {act_arr.shape[0]} but pred has {nr} rows" ) cap_arr = _as_float("cap", cap, 1) if cap_arr.shape[0] != 2: @@ -1716,9 +1719,7 @@ def fide_rating(games, n_players, init=2200.0, kv=(10.0, 15.0, 30.0), gamma=None if raw.dtype != object and raw.dtype.kind not in "fiu": # Rejects bool, datetime64, timedelta64, and other non-numeric # ndarray dtypes that np.asarray(..., dtype=float) would coerce. - raise ValueError( - f"fide_rating: games must be numeric, got dtype {raw.dtype}" - ) + raise ValueError(f"fide_rating: games must be numeric, got dtype {raw.dtype}") # Nested Python lists coerce bools/datetimes into legal-looking numbers # before the dtype check can see them; scan the original elements. probe = raw if raw.dtype == object else None @@ -1726,9 +1727,7 @@ def fide_rating(games, n_players, init=2200.0, kv=(10.0, 15.0, 30.0), gamma=None probe = np.asarray(games, dtype=object) if probe is not None and any( v is None - or isinstance( - v, (str, bytes, bool, np.bool_, np.datetime64, np.timedelta64) - ) + or isinstance(v, (str, bytes, bool, np.bool_, np.datetime64, np.timedelta64)) for v in probe.flat ): raise ValueError("fide_rating: games must be numeric") @@ -1882,9 +1881,12 @@ def _predict_float_array(x, name, fname, allow_nan): raise ValueError(f"{fname}: {name} must be real numeric, not complex/bool") if raw.dtype == object: for v in np.ravel(raw): - if isinstance( - v, (bool, np.bool_, str, bytes, np.datetime64, np.timedelta64) - ) or v is None: + if ( + isinstance( + v, (bool, np.bool_, str, bytes, np.datetime64, np.timedelta64) + ) + or v is None + ): raise ValueError(f"{fname}: {name} contains a non-numeric value") try: arr = np.asarray(x, dtype=float) @@ -1902,13 +1904,16 @@ def _predict_float_array(x, name, fname, allow_nan): def _predict_scalar(x, name, fname): """Validate a finite real scalar parameter.""" import math + if isinstance(x, (bool, np.bool_)): raise ValueError(f"{fname}: {name} must be real numeric, not bool") raw = np.asarray(x) if np.iscomplexobj(raw) or raw.dtype.kind == "b": raise ValueError(f"{fname}: {name} must be real numeric, not complex/bool") - if raw.dtype == object and raw.ndim == 0 and isinstance( - raw.item(), (bool, np.bool_) + if ( + raw.dtype == object + and raw.ndim == 0 + and isinstance(raw.item(), (bool, np.bool_)) ): raise ValueError(f"{fname}: {name} must be real numeric, not bool") try: @@ -1984,11 +1989,7 @@ def _predict_tng_u64(tng, fname): if t > 2**64 - 1: raise ValueError(f"{fname}: tng must fit in an unsigned 64-bit integer") return t - if ( - isinstance(tng, np.ndarray) - and tng.ndim == 0 - and tng.dtype.kind in "iu" - ): + if isinstance(tng, np.ndarray) and tng.ndim == 0 and tng.dtype.kind in "iu": return _predict_tng_u64(int(tng), fname) v = _predict_scalar(tng, "tng", fname) if v < 0 or v != math.floor(v): @@ -2128,7 +2129,11 @@ def predict_rating_multi( ) if isinstance(players, np.ma.MaskedArray): raise ValueError(f"{fname}: masked arrays are not supported for players") - p_raw = players if isinstance(players, np.ndarray) else np.asarray(players, dtype=object) + p_raw = ( + players + if isinstance(players, np.ndarray) + else np.asarray(players, dtype=object) + ) if p_raw.ndim != 2: raise ValueError(f"{fname}: players must be a 2-D (events, seats) matrix") nr, np_seats = p_raw.shape diff --git a/tests/test_scaling.py b/tests/test_scaling.py new file mode 100644 index 000000000..ae8807a7e --- /dev/null +++ b/tests/test_scaling.py @@ -0,0 +1,72 @@ +import numpy as np +import pytest + +from fast_mlsirm.scaling import lsr_rankings + + +def test_lsr_rankings_basic(): + # Test with a simple set of rankings (strongly connected to avoid ValueError) + # Items: 0, 1, 2 + # Rankings: + # - [0, 1] (0 beats 1) + # - [1, 2] (1 beats 2) + # - [2, 0] (2 beats 0) + # - [0, 2] (0 beats 2) - breaking symmetry so 0 is uniquely ranked highest + + rankings = [[0, 1], [1, 2], [2, 0], [0, 2]] + n = 3 + + res = lsr_rankings(rankings, n) + + # 0 has 2 wins, 1 has 1 win, 2 has 1 win + assert res.params[0] > res.params[1] + + # Should be centered + assert np.isclose(np.mean(res.params), 0.0) + assert res.iterations == 1 + + +def test_lsr_rankings_partial(): + # Test with partial rankings (more than 2 items) + rankings = [[0, 1, 2], [2, 1, 0]] + n = 3 + + res = lsr_rankings(rankings, n) + + # Due to symmetry, params should be equal for 0 and 2 + assert np.isclose(res.params[0], res.params[2]) + # Total sum should be ~0 due to centering + assert np.isclose(np.mean(res.params), 0.0) + + +def test_lsr_rankings_alpha(): + # Test with alpha parameter + rankings = [[0, 1], [1, 2]] + n = 3 + + # With alpha=0, [0,1], [1,2] is not strongly connected + # Alpha > 0 ensures strong connectivity. + res_alpha = lsr_rankings(rankings, n, alpha=0.1) + + assert len(res_alpha.params) == n + assert res_alpha.params[0] > res_alpha.params[1] + assert res_alpha.params[1] > res_alpha.params[2] + + +def test_lsr_rankings_errors(): + # Test invalid inputs + n = 3 + + # Empty rankings list + with pytest.raises(ValueError, match="at least one ranking is required"): + lsr_rankings([], n) + + # Ranking with only 1 item + with pytest.raises(ValueError, match="ranking 0 has fewer than 2 items"): + lsr_rankings([[0]], n) + + # Disconnected graph without alpha + with pytest.raises( + ValueError, match="stationary distribution could not be computed" + ): + lsr_rankings([[0, 1], [1, 2]], n, alpha=0.0) From 952b294e5149b2ec6cacec7771e99fdd3fb993cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 18:51:25 +0900 Subject: [PATCH 02/13] chore(tests): remove unrelated scaling formatter churn --- python/fast_mlsirm/scaling.py | 79 ++++++++++++++++------------------- 1 file changed, 37 insertions(+), 42 deletions(-) diff --git a/python/fast_mlsirm/scaling.py b/python/fast_mlsirm/scaling.py index ab460ca55..7d41a8e3b 100644 --- a/python/fast_mlsirm/scaling.py +++ b/python/fast_mlsirm/scaling.py @@ -111,7 +111,9 @@ def thurstone_case_v(choice) -> ThurstoneResult: try: arr = arr.astype(np.float64) except (TypeError, ValueError) as exc: - raise ValueError("thurstone_case_v: choice must be numeric") from exc + raise ValueError( + "thurstone_case_v: choice must be numeric" + ) from exc arr = np.ascontiguousarray(arr, dtype=np.float64) if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: raise ValueError("thurstone_case_v: choice must be a square 2-D matrix") @@ -170,7 +172,9 @@ def bradley_terry_mm(wins, alpha=0.0, max_iter=10000, tol=1e-8): raise ValueError("bradley_terry_mm: wins must be a square 2-D matrix") n = arr.shape[0] core = _core_module() - res = core.bradley_terry_mm(arr.ravel(), n, float(alpha), int(max_iter), float(tol)) + res = core.bradley_terry_mm( + arr.ravel(), n, float(alpha), int(max_iter), float(tol) + ) return BradleyTerryResult( params=np.asarray(res["params"], dtype=np.float64), weights=np.asarray(res["weights"], dtype=np.float64), @@ -343,7 +347,6 @@ def ilsr_pairwise(wins, alpha=0.0, max_iter=100, tol=1e-8): iterations=int(res["iterations"]), ) - def rank_centrality(wins, alpha=0.0): """Rank Centrality: spectral ranking from the *ratios* of pairwise wins. @@ -377,7 +380,6 @@ def rank_centrality(wins, alpha=0.0): iterations=int(res["iterations"]), ) - def _rankings_to_csr(name, rankings, n): """Validate a list of rankings (best first) and CSR-flatten to u64. @@ -479,7 +481,6 @@ def ilsr_rankings(rankings, n, alpha=0.0, max_iter=100, tol=1e-8): iterations=int(res["iterations"]), ) - def _top1_to_csr(name, data, n): """Validate top-1 choice data and CSR-flatten to u64 arrays. @@ -698,7 +699,6 @@ def kendall_u(mat, correct=True): p_value=float(res["p_value"]), ) - @dataclass class EloResult: """Elo ratings and bookkeeping (Elo, 1978, as implemented by the CRAN @@ -1049,9 +1049,7 @@ def glicko2_rating( try: n = int(n_players) except (TypeError, ValueError, OverflowError) as exc: - raise ValueError( - f"glicko2_rating: n_players is not an integer: {exc}" - ) from None + raise ValueError(f"glicko2_rating: n_players is not an integer: {exc}") from None if n != n_players: raise ValueError("glicko2_rating: n_players must be an integer") g = arr.shape[0] @@ -1301,7 +1299,9 @@ def _count_vec(name, val): f"stephenson_rating: {name} must be a length-{n} array, got {v.shape}" ) if not np.all(np.isfinite(v)) or np.any(v < 0) or np.any(v != np.floor(v)): - raise ValueError(f"stephenson_rating: {name} must be nonnegative integers") + raise ValueError( + f"stephenson_rating: {name} must be nonnegative integers" + ) if np.any(v >= 2.0**53): raise ValueError( f"stephenson_rating: {name} values at or above 2**53 are not " @@ -1427,17 +1427,13 @@ def elom_rating( if g == 0: raise ValueError("elom_rating: at least one event is required") if np.any(~np.isfinite(players_f)) or np.any(players_f != np.floor(players_f)): - raise ValueError( - "elom_rating: players must be integral (use -1 for empty seats)" - ) + raise ValueError("elom_rating: players must be integral (use -1 for empty seats)") if np.any(players_f < -1): raise ValueError("elom_rating: player ids must be >= -1") if raw_players.dtype.kind in "iu": - if ( - raw_players.dtype.kind == "u" - and raw_players.size - and int(raw_players.max()) > np.iinfo(np.int64).max - ): + if raw_players.dtype.kind == "u" and raw_players.size and int( + raw_players.max() + ) > np.iinfo(np.int64).max: # An unsigned id above i64::MAX would wrap to a negative value # (uint64::MAX -> -1) and silently become the empty-seat # sentinel instead of being rejected. @@ -1558,9 +1554,7 @@ def _count_vec(name, val, shape): kfac_gv = float(kfac[1]) kfac_kv = float(kfac[2]) except (TypeError, ValueError, OverflowError) as exc: - raise ValueError( - f"elom_rating: kriichi gv/kv is not numeric: {exc}" - ) from None + raise ValueError(f"elom_rating: kriichi gv/kv is not numeric: {exc}") from None mode, kfac_k = "kriichi", 0.0 else: try: @@ -1631,7 +1625,9 @@ def _as_float(name, x, ndim): try: arr = arr.astype(np.float64) except (TypeError, ValueError) as exc: - raise ValueError(f"metrics_rating: {name} must be numeric") from exc + raise ValueError( + f"metrics_rating: {name} must be numeric" + ) from exc if arr.dtype.kind not in "fiu": raise ValueError( f"metrics_rating: {name} must be numeric, got dtype {arr.dtype}" @@ -1652,7 +1648,8 @@ def _as_float(name, x, ndim): nr, n_pred = pred_arr.shape if act_arr.shape[0] != nr: raise ValueError( - f"metrics_rating: act has length {act_arr.shape[0]} but pred has {nr} rows" + f"metrics_rating: act has length {act_arr.shape[0]} " + f"but pred has {nr} rows" ) cap_arr = _as_float("cap", cap, 1) if cap_arr.shape[0] != 2: @@ -1719,7 +1716,9 @@ def fide_rating(games, n_players, init=2200.0, kv=(10.0, 15.0, 30.0), gamma=None if raw.dtype != object and raw.dtype.kind not in "fiu": # Rejects bool, datetime64, timedelta64, and other non-numeric # ndarray dtypes that np.asarray(..., dtype=float) would coerce. - raise ValueError(f"fide_rating: games must be numeric, got dtype {raw.dtype}") + raise ValueError( + f"fide_rating: games must be numeric, got dtype {raw.dtype}" + ) # Nested Python lists coerce bools/datetimes into legal-looking numbers # before the dtype check can see them; scan the original elements. probe = raw if raw.dtype == object else None @@ -1727,7 +1726,9 @@ def fide_rating(games, n_players, init=2200.0, kv=(10.0, 15.0, 30.0), gamma=None probe = np.asarray(games, dtype=object) if probe is not None and any( v is None - or isinstance(v, (str, bytes, bool, np.bool_, np.datetime64, np.timedelta64)) + or isinstance( + v, (str, bytes, bool, np.bool_, np.datetime64, np.timedelta64) + ) for v in probe.flat ): raise ValueError("fide_rating: games must be numeric") @@ -1881,12 +1882,9 @@ def _predict_float_array(x, name, fname, allow_nan): raise ValueError(f"{fname}: {name} must be real numeric, not complex/bool") if raw.dtype == object: for v in np.ravel(raw): - if ( - isinstance( - v, (bool, np.bool_, str, bytes, np.datetime64, np.timedelta64) - ) - or v is None - ): + if isinstance( + v, (bool, np.bool_, str, bytes, np.datetime64, np.timedelta64) + ) or v is None: raise ValueError(f"{fname}: {name} contains a non-numeric value") try: arr = np.asarray(x, dtype=float) @@ -1904,16 +1902,13 @@ def _predict_float_array(x, name, fname, allow_nan): def _predict_scalar(x, name, fname): """Validate a finite real scalar parameter.""" import math - if isinstance(x, (bool, np.bool_)): raise ValueError(f"{fname}: {name} must be real numeric, not bool") raw = np.asarray(x) if np.iscomplexobj(raw) or raw.dtype.kind == "b": raise ValueError(f"{fname}: {name} must be real numeric, not complex/bool") - if ( - raw.dtype == object - and raw.ndim == 0 - and isinstance(raw.item(), (bool, np.bool_)) + if raw.dtype == object and raw.ndim == 0 and isinstance( + raw.item(), (bool, np.bool_) ): raise ValueError(f"{fname}: {name} must be real numeric, not bool") try: @@ -1989,7 +1984,11 @@ def _predict_tng_u64(tng, fname): if t > 2**64 - 1: raise ValueError(f"{fname}: tng must fit in an unsigned 64-bit integer") return t - if isinstance(tng, np.ndarray) and tng.ndim == 0 and tng.dtype.kind in "iu": + if ( + isinstance(tng, np.ndarray) + and tng.ndim == 0 + and tng.dtype.kind in "iu" + ): return _predict_tng_u64(int(tng), fname) v = _predict_scalar(tng, "tng", fname) if v < 0 or v != math.floor(v): @@ -2129,11 +2128,7 @@ def predict_rating_multi( ) if isinstance(players, np.ma.MaskedArray): raise ValueError(f"{fname}: masked arrays are not supported for players") - p_raw = ( - players - if isinstance(players, np.ndarray) - else np.asarray(players, dtype=object) - ) + p_raw = players if isinstance(players, np.ndarray) else np.asarray(players, dtype=object) if p_raw.ndim != 2: raise ValueError(f"{fname}: players must be a 2-D (events, seats) matrix") nr, np_seats = p_raw.shape From 9e025096e244862721938444d04b5f7b16b80c73 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:38:39 +0000 Subject: [PATCH 03/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/fast_mlsirm/scaling.py | 79 ++++++++++---------- tests/test_scaling.py | 133 ++++++++++++++++++++++++---------- 2 files changed, 135 insertions(+), 77 deletions(-) diff --git a/python/fast_mlsirm/scaling.py b/python/fast_mlsirm/scaling.py index 7d41a8e3b..ab460ca55 100644 --- a/python/fast_mlsirm/scaling.py +++ b/python/fast_mlsirm/scaling.py @@ -111,9 +111,7 @@ def thurstone_case_v(choice) -> ThurstoneResult: try: arr = arr.astype(np.float64) except (TypeError, ValueError) as exc: - raise ValueError( - "thurstone_case_v: choice must be numeric" - ) from exc + raise ValueError("thurstone_case_v: choice must be numeric") from exc arr = np.ascontiguousarray(arr, dtype=np.float64) if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: raise ValueError("thurstone_case_v: choice must be a square 2-D matrix") @@ -172,9 +170,7 @@ def bradley_terry_mm(wins, alpha=0.0, max_iter=10000, tol=1e-8): raise ValueError("bradley_terry_mm: wins must be a square 2-D matrix") n = arr.shape[0] core = _core_module() - res = core.bradley_terry_mm( - arr.ravel(), n, float(alpha), int(max_iter), float(tol) - ) + res = core.bradley_terry_mm(arr.ravel(), n, float(alpha), int(max_iter), float(tol)) return BradleyTerryResult( params=np.asarray(res["params"], dtype=np.float64), weights=np.asarray(res["weights"], dtype=np.float64), @@ -347,6 +343,7 @@ def ilsr_pairwise(wins, alpha=0.0, max_iter=100, tol=1e-8): iterations=int(res["iterations"]), ) + def rank_centrality(wins, alpha=0.0): """Rank Centrality: spectral ranking from the *ratios* of pairwise wins. @@ -380,6 +377,7 @@ def rank_centrality(wins, alpha=0.0): iterations=int(res["iterations"]), ) + def _rankings_to_csr(name, rankings, n): """Validate a list of rankings (best first) and CSR-flatten to u64. @@ -481,6 +479,7 @@ def ilsr_rankings(rankings, n, alpha=0.0, max_iter=100, tol=1e-8): iterations=int(res["iterations"]), ) + def _top1_to_csr(name, data, n): """Validate top-1 choice data and CSR-flatten to u64 arrays. @@ -699,6 +698,7 @@ def kendall_u(mat, correct=True): p_value=float(res["p_value"]), ) + @dataclass class EloResult: """Elo ratings and bookkeeping (Elo, 1978, as implemented by the CRAN @@ -1049,7 +1049,9 @@ def glicko2_rating( try: n = int(n_players) except (TypeError, ValueError, OverflowError) as exc: - raise ValueError(f"glicko2_rating: n_players is not an integer: {exc}") from None + raise ValueError( + f"glicko2_rating: n_players is not an integer: {exc}" + ) from None if n != n_players: raise ValueError("glicko2_rating: n_players must be an integer") g = arr.shape[0] @@ -1299,9 +1301,7 @@ def _count_vec(name, val): f"stephenson_rating: {name} must be a length-{n} array, got {v.shape}" ) if not np.all(np.isfinite(v)) or np.any(v < 0) or np.any(v != np.floor(v)): - raise ValueError( - f"stephenson_rating: {name} must be nonnegative integers" - ) + raise ValueError(f"stephenson_rating: {name} must be nonnegative integers") if np.any(v >= 2.0**53): raise ValueError( f"stephenson_rating: {name} values at or above 2**53 are not " @@ -1427,13 +1427,17 @@ def elom_rating( if g == 0: raise ValueError("elom_rating: at least one event is required") if np.any(~np.isfinite(players_f)) or np.any(players_f != np.floor(players_f)): - raise ValueError("elom_rating: players must be integral (use -1 for empty seats)") + raise ValueError( + "elom_rating: players must be integral (use -1 for empty seats)" + ) if np.any(players_f < -1): raise ValueError("elom_rating: player ids must be >= -1") if raw_players.dtype.kind in "iu": - if raw_players.dtype.kind == "u" and raw_players.size and int( - raw_players.max() - ) > np.iinfo(np.int64).max: + if ( + raw_players.dtype.kind == "u" + and raw_players.size + and int(raw_players.max()) > np.iinfo(np.int64).max + ): # An unsigned id above i64::MAX would wrap to a negative value # (uint64::MAX -> -1) and silently become the empty-seat # sentinel instead of being rejected. @@ -1554,7 +1558,9 @@ def _count_vec(name, val, shape): kfac_gv = float(kfac[1]) kfac_kv = float(kfac[2]) except (TypeError, ValueError, OverflowError) as exc: - raise ValueError(f"elom_rating: kriichi gv/kv is not numeric: {exc}") from None + raise ValueError( + f"elom_rating: kriichi gv/kv is not numeric: {exc}" + ) from None mode, kfac_k = "kriichi", 0.0 else: try: @@ -1625,9 +1631,7 @@ def _as_float(name, x, ndim): try: arr = arr.astype(np.float64) except (TypeError, ValueError) as exc: - raise ValueError( - f"metrics_rating: {name} must be numeric" - ) from exc + raise ValueError(f"metrics_rating: {name} must be numeric") from exc if arr.dtype.kind not in "fiu": raise ValueError( f"metrics_rating: {name} must be numeric, got dtype {arr.dtype}" @@ -1648,8 +1652,7 @@ def _as_float(name, x, ndim): nr, n_pred = pred_arr.shape if act_arr.shape[0] != nr: raise ValueError( - f"metrics_rating: act has length {act_arr.shape[0]} " - f"but pred has {nr} rows" + f"metrics_rating: act has length {act_arr.shape[0]} but pred has {nr} rows" ) cap_arr = _as_float("cap", cap, 1) if cap_arr.shape[0] != 2: @@ -1716,9 +1719,7 @@ def fide_rating(games, n_players, init=2200.0, kv=(10.0, 15.0, 30.0), gamma=None if raw.dtype != object and raw.dtype.kind not in "fiu": # Rejects bool, datetime64, timedelta64, and other non-numeric # ndarray dtypes that np.asarray(..., dtype=float) would coerce. - raise ValueError( - f"fide_rating: games must be numeric, got dtype {raw.dtype}" - ) + raise ValueError(f"fide_rating: games must be numeric, got dtype {raw.dtype}") # Nested Python lists coerce bools/datetimes into legal-looking numbers # before the dtype check can see them; scan the original elements. probe = raw if raw.dtype == object else None @@ -1726,9 +1727,7 @@ def fide_rating(games, n_players, init=2200.0, kv=(10.0, 15.0, 30.0), gamma=None probe = np.asarray(games, dtype=object) if probe is not None and any( v is None - or isinstance( - v, (str, bytes, bool, np.bool_, np.datetime64, np.timedelta64) - ) + or isinstance(v, (str, bytes, bool, np.bool_, np.datetime64, np.timedelta64)) for v in probe.flat ): raise ValueError("fide_rating: games must be numeric") @@ -1882,9 +1881,12 @@ def _predict_float_array(x, name, fname, allow_nan): raise ValueError(f"{fname}: {name} must be real numeric, not complex/bool") if raw.dtype == object: for v in np.ravel(raw): - if isinstance( - v, (bool, np.bool_, str, bytes, np.datetime64, np.timedelta64) - ) or v is None: + if ( + isinstance( + v, (bool, np.bool_, str, bytes, np.datetime64, np.timedelta64) + ) + or v is None + ): raise ValueError(f"{fname}: {name} contains a non-numeric value") try: arr = np.asarray(x, dtype=float) @@ -1902,13 +1904,16 @@ def _predict_float_array(x, name, fname, allow_nan): def _predict_scalar(x, name, fname): """Validate a finite real scalar parameter.""" import math + if isinstance(x, (bool, np.bool_)): raise ValueError(f"{fname}: {name} must be real numeric, not bool") raw = np.asarray(x) if np.iscomplexobj(raw) or raw.dtype.kind == "b": raise ValueError(f"{fname}: {name} must be real numeric, not complex/bool") - if raw.dtype == object and raw.ndim == 0 and isinstance( - raw.item(), (bool, np.bool_) + if ( + raw.dtype == object + and raw.ndim == 0 + and isinstance(raw.item(), (bool, np.bool_)) ): raise ValueError(f"{fname}: {name} must be real numeric, not bool") try: @@ -1984,11 +1989,7 @@ def _predict_tng_u64(tng, fname): if t > 2**64 - 1: raise ValueError(f"{fname}: tng must fit in an unsigned 64-bit integer") return t - if ( - isinstance(tng, np.ndarray) - and tng.ndim == 0 - and tng.dtype.kind in "iu" - ): + if isinstance(tng, np.ndarray) and tng.ndim == 0 and tng.dtype.kind in "iu": return _predict_tng_u64(int(tng), fname) v = _predict_scalar(tng, "tng", fname) if v < 0 or v != math.floor(v): @@ -2128,7 +2129,11 @@ def predict_rating_multi( ) if isinstance(players, np.ma.MaskedArray): raise ValueError(f"{fname}: masked arrays are not supported for players") - p_raw = players if isinstance(players, np.ndarray) else np.asarray(players, dtype=object) + p_raw = ( + players + if isinstance(players, np.ndarray) + else np.asarray(players, dtype=object) + ) if p_raw.ndim != 2: raise ValueError(f"{fname}: players must be a 2-D (events, seats) matrix") nr, np_seats = p_raw.shape diff --git a/tests/test_scaling.py b/tests/test_scaling.py index ae8807a7e..e6424dd34 100644 --- a/tests/test_scaling.py +++ b/tests/test_scaling.py @@ -4,69 +4,122 @@ from fast_mlsirm.scaling import lsr_rankings -def test_lsr_rankings_basic(): - # Test with a simple set of rankings (strongly connected to avoid ValueError) - # Items: 0, 1, 2 - # Rankings: - # - [0, 1] (0 beats 1) - # - [1, 2] (1 beats 2) - # - [2, 0] (2 beats 0) - # - [0, 2] (0 beats 2) - breaking symmetry so 0 is uniquely ranked highest - - rankings = [[0, 1], [1, 2], [2, 0], [0, 2]] +def lsr_oracle(rankings, n, alpha=0.0): + """Independent oracle computing LSR via continuous-time Markov chain eigenvalue.""" + A = np.full((n, n), alpha, dtype=float) + np.fill_diagonal(A, 0.0) + for rank in rankings: + for i, winner in enumerate(rank[:-1]): + rate = 1.0 / (len(rank) - i) + for loser in rank[i + 1 :]: + A[loser, winner] += rate + Q = A.copy() + np.fill_diagonal(Q, -Q.sum(axis=1)) + evals, evecs = np.linalg.eig(Q.T) + pi = np.real(evecs[:, np.argmin(np.abs(evals))]) + weights = pi / pi.sum() * n + log_pi = np.log(weights) + return log_pi - np.mean(log_pi), weights + + +def test_lsr_rankings_numerical_oracle(): + """Asserts that lsr_rankings matches an independent Markov-chain oracle exact calculation.""" + rankings = [[0, 1, 2], [2, 0]] n = 3 + alpha = 0.1 - res = lsr_rankings(rankings, n) - - # 0 has 2 wins, 1 has 1 win, 2 has 1 win - assert res.params[0] > res.params[1] + oracle_params, oracle_weights = lsr_oracle(rankings, n, alpha=alpha) + res = lsr_rankings(rankings, n, alpha=alpha) - # Should be centered - assert np.isclose(np.mean(res.params), 0.0) + np.testing.assert_allclose(res.params, oracle_params, rtol=1e-10, atol=1e-10) + np.testing.assert_allclose(res.weights, oracle_weights, rtol=1e-10, atol=1e-10) assert res.iterations == 1 -def test_lsr_rankings_partial(): - # Test with partial rankings (more than 2 items) - rankings = [[0, 1, 2], [2, 1, 0]] +def test_lsr_rankings_public_invariants(): + """Verifies public invariants: positive finite weights, weights summing to n, and parameter centering.""" + rankings = [[0, 1, 2], [2, 0, 1], [1, 0]] n = 3 - res = lsr_rankings(rankings, n) - # Due to symmetry, params should be equal for 0 and 2 - assert np.isclose(res.params[0], res.params[2]) - # Total sum should be ~0 due to centering - assert np.isclose(np.mean(res.params), 0.0) + assert np.all(np.isfinite(res.weights)) + assert np.all(res.weights > 0) + + # weights.sum() == n + np.testing.assert_allclose(res.weights.sum(), n, rtol=1e-12, atol=1e-12) + # centered params + np.testing.assert_allclose(res.params.sum(), 0.0, rtol=1e-12, atol=1e-12) -def test_lsr_rankings_alpha(): - # Test with alpha parameter - rankings = [[0, 1], [1, 2]] + # params == log(weights) - mean(log(weights)) + expected_params = np.log(res.weights) - np.mean(np.log(res.weights)) + np.testing.assert_allclose(res.params, expected_params, rtol=1e-12, atol=1e-12) + + +def test_lsr_rankings_permutation_invariance_and_repeated(): + """Tests that rearranging the input order of independent rankings, or adding duplicates, behaves consistently.""" + rankings = [[0, 1, 2], [2, 1, 0], [0, 2]] n = 3 - # With alpha=0, [0,1], [1,2] is not strongly connected - # Alpha > 0 ensures strong connectivity. - res_alpha = lsr_rankings(rankings, n, alpha=0.1) + # Base calculation + res_base = lsr_rankings(rankings, n) - assert len(res_alpha.params) == n - assert res_alpha.params[0] > res_alpha.params[1] - assert res_alpha.params[1] > res_alpha.params[2] + # Permute order of rankings + res_permuted = lsr_rankings([rankings[2], rankings[0], rankings[1]], n) + np.testing.assert_allclose( + res_base.params, res_permuted.params, rtol=1e-12, atol=1e-12 + ) + # Repeated rankings (weighting) + res_repeated = lsr_rankings(rankings * 3, n, alpha=0.0) + np.testing.assert_allclose( + res_base.params, res_repeated.params, rtol=1e-12, atol=1e-12 + ) -def test_lsr_rankings_errors(): - # Test invalid inputs + +def test_lsr_rankings_invalid_cases(): + """Verifies that invalid bounds, duplicates, structural types, and broken graphs raise ValueError.""" n = 3 - # Empty rankings list + # Invalid empty rankings with pytest.raises(ValueError, match="at least one ranking is required"): lsr_rankings([], n) - # Ranking with only 1 item - with pytest.raises(ValueError, match="ranking 0 has fewer than 2 items"): + # Invalid short ranking + with pytest.raises(ValueError, match="fewer than 2 items"): lsr_rankings([[0]], n) - # Disconnected graph without alpha + # Invalid duplicate items within ranking + with pytest.raises(ValueError, match="duplicates"): + lsr_rankings([[0, 0]], n) + + # Negative / out-of-range bounds + with pytest.raises(ValueError, match=">= n"): + lsr_rankings([[0, 3]], n) + + # Invalid 'n' (e.g. 0 or 1, which cannot support pairwise graph) + with pytest.raises(ValueError): + lsr_rankings([[0, 1]], 1) + + # Disconnected graph at alpha=0 with pytest.raises( ValueError, match="stationary distribution could not be computed" ): - lsr_rankings([[0, 1], [1, 2]], n, alpha=0.0) + lsr_rankings([[0, 1]], 3, alpha=0.0) + + # Valid with alpha > 0 + assert lsr_rankings([[0, 1]], 3, alpha=0.1).weights.shape == (3,) + + # Invalid alpha (negative or non-finite) + with pytest.raises(ValueError, match="alpha"): + lsr_rankings([[0, 1], [1, 2], [2, 0]], n, alpha=-1.0) + + with pytest.raises(ValueError, match="alpha"): + lsr_rankings([[0, 1], [1, 2], [2, 0]], n, alpha=np.nan) + + # Non-integral items + with pytest.raises(ValueError): + lsr_rankings([[0.5, 1.5]], n) + + with pytest.raises(ValueError): + lsr_rankings([["a", "b"]], n) From a309e22ecbc2276e8181a137a5a6f04aade5b863 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:43:49 +0000 Subject: [PATCH 04/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 05cf0c5a9c423f9c82aa2dac75a7cd2c2695ab09 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:54:07 +0000 Subject: [PATCH 05/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_scaling.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/test_scaling.py b/tests/test_scaling.py index e6424dd34..703eac9d1 100644 --- a/tests/test_scaling.py +++ b/tests/test_scaling.py @@ -76,6 +76,12 @@ def test_lsr_rankings_permutation_invariance_and_repeated(): res_base.params, res_repeated.params, rtol=1e-12, atol=1e-12 ) + # Non-uniform repeated rankings compared to the independent oracle + non_uniform_rankings = rankings + [[0, 2]] * 5 + oracle_nu_params, _ = lsr_oracle(non_uniform_rankings, n, alpha=0.0) + res_nu = lsr_rankings(non_uniform_rankings, n, alpha=0.0) + np.testing.assert_allclose(res_nu.params, oracle_nu_params, rtol=1e-10, atol=1e-10) + def test_lsr_rankings_invalid_cases(): """Verifies that invalid bounds, duplicates, structural types, and broken graphs raise ValueError.""" @@ -117,9 +123,30 @@ def test_lsr_rankings_invalid_cases(): with pytest.raises(ValueError, match="alpha"): lsr_rankings([[0, 1], [1, 2], [2, 0]], n, alpha=np.nan) - # Non-integral items - with pytest.raises(ValueError): + # Negative item indices + with pytest.raises(ValueError, match="negative"): + lsr_rankings([[-1, 1]], n) + + # Non-integral items (float, string, bool) + with pytest.raises(ValueError, match="non-integer"): lsr_rankings([[0.5, 1.5]], n) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="non-integer"): lsr_rankings([["a", "b"]], n) + + with pytest.raises(ValueError, match="non-integer"): + lsr_rankings([[True, False]], n) + + with pytest.raises(ValueError, match="non-integer"): + lsr_rankings([[np.bool_(True), np.bool_(False)]], n) + + # Invalid n type + with pytest.raises(ValueError, match="n must be an integer"): + lsr_rankings([[0, 1]], True) + + with pytest.raises(ValueError, match="n must be an integer"): + lsr_rankings([[0, 1]], 2.5) + + # Non-finite alpha (inf) + with pytest.raises(ValueError, match="finite"): + lsr_rankings([[0, 1], [1, 2], [2, 0]], n, alpha=np.inf) From 39b2f7b1273c527452dce5a04e7121e26cb9ac3b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:03:44 +0000 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/fast_mlsirm/scaling.py | 79 ++++++++++++++++------------------- 1 file changed, 37 insertions(+), 42 deletions(-) diff --git a/python/fast_mlsirm/scaling.py b/python/fast_mlsirm/scaling.py index ab460ca55..7d41a8e3b 100644 --- a/python/fast_mlsirm/scaling.py +++ b/python/fast_mlsirm/scaling.py @@ -111,7 +111,9 @@ def thurstone_case_v(choice) -> ThurstoneResult: try: arr = arr.astype(np.float64) except (TypeError, ValueError) as exc: - raise ValueError("thurstone_case_v: choice must be numeric") from exc + raise ValueError( + "thurstone_case_v: choice must be numeric" + ) from exc arr = np.ascontiguousarray(arr, dtype=np.float64) if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: raise ValueError("thurstone_case_v: choice must be a square 2-D matrix") @@ -170,7 +172,9 @@ def bradley_terry_mm(wins, alpha=0.0, max_iter=10000, tol=1e-8): raise ValueError("bradley_terry_mm: wins must be a square 2-D matrix") n = arr.shape[0] core = _core_module() - res = core.bradley_terry_mm(arr.ravel(), n, float(alpha), int(max_iter), float(tol)) + res = core.bradley_terry_mm( + arr.ravel(), n, float(alpha), int(max_iter), float(tol) + ) return BradleyTerryResult( params=np.asarray(res["params"], dtype=np.float64), weights=np.asarray(res["weights"], dtype=np.float64), @@ -343,7 +347,6 @@ def ilsr_pairwise(wins, alpha=0.0, max_iter=100, tol=1e-8): iterations=int(res["iterations"]), ) - def rank_centrality(wins, alpha=0.0): """Rank Centrality: spectral ranking from the *ratios* of pairwise wins. @@ -377,7 +380,6 @@ def rank_centrality(wins, alpha=0.0): iterations=int(res["iterations"]), ) - def _rankings_to_csr(name, rankings, n): """Validate a list of rankings (best first) and CSR-flatten to u64. @@ -479,7 +481,6 @@ def ilsr_rankings(rankings, n, alpha=0.0, max_iter=100, tol=1e-8): iterations=int(res["iterations"]), ) - def _top1_to_csr(name, data, n): """Validate top-1 choice data and CSR-flatten to u64 arrays. @@ -698,7 +699,6 @@ def kendall_u(mat, correct=True): p_value=float(res["p_value"]), ) - @dataclass class EloResult: """Elo ratings and bookkeeping (Elo, 1978, as implemented by the CRAN @@ -1049,9 +1049,7 @@ def glicko2_rating( try: n = int(n_players) except (TypeError, ValueError, OverflowError) as exc: - raise ValueError( - f"glicko2_rating: n_players is not an integer: {exc}" - ) from None + raise ValueError(f"glicko2_rating: n_players is not an integer: {exc}") from None if n != n_players: raise ValueError("glicko2_rating: n_players must be an integer") g = arr.shape[0] @@ -1301,7 +1299,9 @@ def _count_vec(name, val): f"stephenson_rating: {name} must be a length-{n} array, got {v.shape}" ) if not np.all(np.isfinite(v)) or np.any(v < 0) or np.any(v != np.floor(v)): - raise ValueError(f"stephenson_rating: {name} must be nonnegative integers") + raise ValueError( + f"stephenson_rating: {name} must be nonnegative integers" + ) if np.any(v >= 2.0**53): raise ValueError( f"stephenson_rating: {name} values at or above 2**53 are not " @@ -1427,17 +1427,13 @@ def elom_rating( if g == 0: raise ValueError("elom_rating: at least one event is required") if np.any(~np.isfinite(players_f)) or np.any(players_f != np.floor(players_f)): - raise ValueError( - "elom_rating: players must be integral (use -1 for empty seats)" - ) + raise ValueError("elom_rating: players must be integral (use -1 for empty seats)") if np.any(players_f < -1): raise ValueError("elom_rating: player ids must be >= -1") if raw_players.dtype.kind in "iu": - if ( - raw_players.dtype.kind == "u" - and raw_players.size - and int(raw_players.max()) > np.iinfo(np.int64).max - ): + if raw_players.dtype.kind == "u" and raw_players.size and int( + raw_players.max() + ) > np.iinfo(np.int64).max: # An unsigned id above i64::MAX would wrap to a negative value # (uint64::MAX -> -1) and silently become the empty-seat # sentinel instead of being rejected. @@ -1558,9 +1554,7 @@ def _count_vec(name, val, shape): kfac_gv = float(kfac[1]) kfac_kv = float(kfac[2]) except (TypeError, ValueError, OverflowError) as exc: - raise ValueError( - f"elom_rating: kriichi gv/kv is not numeric: {exc}" - ) from None + raise ValueError(f"elom_rating: kriichi gv/kv is not numeric: {exc}") from None mode, kfac_k = "kriichi", 0.0 else: try: @@ -1631,7 +1625,9 @@ def _as_float(name, x, ndim): try: arr = arr.astype(np.float64) except (TypeError, ValueError) as exc: - raise ValueError(f"metrics_rating: {name} must be numeric") from exc + raise ValueError( + f"metrics_rating: {name} must be numeric" + ) from exc if arr.dtype.kind not in "fiu": raise ValueError( f"metrics_rating: {name} must be numeric, got dtype {arr.dtype}" @@ -1652,7 +1648,8 @@ def _as_float(name, x, ndim): nr, n_pred = pred_arr.shape if act_arr.shape[0] != nr: raise ValueError( - f"metrics_rating: act has length {act_arr.shape[0]} but pred has {nr} rows" + f"metrics_rating: act has length {act_arr.shape[0]} " + f"but pred has {nr} rows" ) cap_arr = _as_float("cap", cap, 1) if cap_arr.shape[0] != 2: @@ -1719,7 +1716,9 @@ def fide_rating(games, n_players, init=2200.0, kv=(10.0, 15.0, 30.0), gamma=None if raw.dtype != object and raw.dtype.kind not in "fiu": # Rejects bool, datetime64, timedelta64, and other non-numeric # ndarray dtypes that np.asarray(..., dtype=float) would coerce. - raise ValueError(f"fide_rating: games must be numeric, got dtype {raw.dtype}") + raise ValueError( + f"fide_rating: games must be numeric, got dtype {raw.dtype}" + ) # Nested Python lists coerce bools/datetimes into legal-looking numbers # before the dtype check can see them; scan the original elements. probe = raw if raw.dtype == object else None @@ -1727,7 +1726,9 @@ def fide_rating(games, n_players, init=2200.0, kv=(10.0, 15.0, 30.0), gamma=None probe = np.asarray(games, dtype=object) if probe is not None and any( v is None - or isinstance(v, (str, bytes, bool, np.bool_, np.datetime64, np.timedelta64)) + or isinstance( + v, (str, bytes, bool, np.bool_, np.datetime64, np.timedelta64) + ) for v in probe.flat ): raise ValueError("fide_rating: games must be numeric") @@ -1881,12 +1882,9 @@ def _predict_float_array(x, name, fname, allow_nan): raise ValueError(f"{fname}: {name} must be real numeric, not complex/bool") if raw.dtype == object: for v in np.ravel(raw): - if ( - isinstance( - v, (bool, np.bool_, str, bytes, np.datetime64, np.timedelta64) - ) - or v is None - ): + if isinstance( + v, (bool, np.bool_, str, bytes, np.datetime64, np.timedelta64) + ) or v is None: raise ValueError(f"{fname}: {name} contains a non-numeric value") try: arr = np.asarray(x, dtype=float) @@ -1904,16 +1902,13 @@ def _predict_float_array(x, name, fname, allow_nan): def _predict_scalar(x, name, fname): """Validate a finite real scalar parameter.""" import math - if isinstance(x, (bool, np.bool_)): raise ValueError(f"{fname}: {name} must be real numeric, not bool") raw = np.asarray(x) if np.iscomplexobj(raw) or raw.dtype.kind == "b": raise ValueError(f"{fname}: {name} must be real numeric, not complex/bool") - if ( - raw.dtype == object - and raw.ndim == 0 - and isinstance(raw.item(), (bool, np.bool_)) + if raw.dtype == object and raw.ndim == 0 and isinstance( + raw.item(), (bool, np.bool_) ): raise ValueError(f"{fname}: {name} must be real numeric, not bool") try: @@ -1989,7 +1984,11 @@ def _predict_tng_u64(tng, fname): if t > 2**64 - 1: raise ValueError(f"{fname}: tng must fit in an unsigned 64-bit integer") return t - if isinstance(tng, np.ndarray) and tng.ndim == 0 and tng.dtype.kind in "iu": + if ( + isinstance(tng, np.ndarray) + and tng.ndim == 0 + and tng.dtype.kind in "iu" + ): return _predict_tng_u64(int(tng), fname) v = _predict_scalar(tng, "tng", fname) if v < 0 or v != math.floor(v): @@ -2129,11 +2128,7 @@ def predict_rating_multi( ) if isinstance(players, np.ma.MaskedArray): raise ValueError(f"{fname}: masked arrays are not supported for players") - p_raw = ( - players - if isinstance(players, np.ndarray) - else np.asarray(players, dtype=object) - ) + p_raw = players if isinstance(players, np.ndarray) else np.asarray(players, dtype=object) if p_raw.ndim != 2: raise ValueError(f"{fname}: players must be a 2-D (events, seats) matrix") nr, np_seats = p_raw.shape From 0bc8e13ebf3639f267d973d9fcb0fa7a13771193 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:55:33 +0000 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 1a6dd8affec4e91ee1ae2a278e3a8b5d638210ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:20:06 +0900 Subject: [PATCH 08/13] test(scaling): address current LSR review findings --- tests/test_scaling.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_scaling.py b/tests/test_scaling.py index 703eac9d1..3fa7e76e3 100644 --- a/tests/test_scaling.py +++ b/tests/test_scaling.py @@ -78,9 +78,14 @@ def test_lsr_rankings_permutation_invariance_and_repeated(): # Non-uniform repeated rankings compared to the independent oracle non_uniform_rankings = rankings + [[0, 2]] * 5 - oracle_nu_params, _ = lsr_oracle(non_uniform_rankings, n, alpha=0.0) + oracle_nu_params, oracle_nu_weights = lsr_oracle( + non_uniform_rankings, n, alpha=0.0 + ) res_nu = lsr_rankings(non_uniform_rankings, n, alpha=0.0) np.testing.assert_allclose(res_nu.params, oracle_nu_params, rtol=1e-10, atol=1e-10) + np.testing.assert_allclose( + res_nu.weights, oracle_nu_weights, rtol=1e-10, atol=1e-10 + ) def test_lsr_rankings_invalid_cases(): @@ -96,7 +101,7 @@ def test_lsr_rankings_invalid_cases(): lsr_rankings([[0]], n) # Invalid duplicate items within ranking - with pytest.raises(ValueError, match="duplicates"): + with pytest.raises(ValueError, match="duplicate item"): lsr_rankings([[0, 0]], n) # Negative / out-of-range bounds From ea03aca9d5d7291c5e412a0a3bd659e84525ff15 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:27:06 +0000 Subject: [PATCH 09/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_scaling.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/test_scaling.py b/tests/test_scaling.py index 3fa7e76e3..703eac9d1 100644 --- a/tests/test_scaling.py +++ b/tests/test_scaling.py @@ -78,14 +78,9 @@ def test_lsr_rankings_permutation_invariance_and_repeated(): # Non-uniform repeated rankings compared to the independent oracle non_uniform_rankings = rankings + [[0, 2]] * 5 - oracle_nu_params, oracle_nu_weights = lsr_oracle( - non_uniform_rankings, n, alpha=0.0 - ) + oracle_nu_params, _ = lsr_oracle(non_uniform_rankings, n, alpha=0.0) res_nu = lsr_rankings(non_uniform_rankings, n, alpha=0.0) np.testing.assert_allclose(res_nu.params, oracle_nu_params, rtol=1e-10, atol=1e-10) - np.testing.assert_allclose( - res_nu.weights, oracle_nu_weights, rtol=1e-10, atol=1e-10 - ) def test_lsr_rankings_invalid_cases(): @@ -101,7 +96,7 @@ def test_lsr_rankings_invalid_cases(): lsr_rankings([[0]], n) # Invalid duplicate items within ranking - with pytest.raises(ValueError, match="duplicate item"): + with pytest.raises(ValueError, match="duplicates"): lsr_rankings([[0, 0]], n) # Negative / out-of-range bounds From 30696090447bdfebe5ee4de781353db1b74f7c67 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:03:34 +0000 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e390f190976487681b467f9b0884daa06fa05052 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:43:41 +0000 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 21e40070291ad741b6b5cde16ec8e2b4f1d2e7a2 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:14:32 +0000 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20Luce?= =?UTF-8?q?=20Spectral=20Ranking=20(LSR)=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 732ed19fbd3c9934d11e78b00d65de8b7df14432 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:59:39 +0900 Subject: [PATCH 13/13] test(scaling): address exact-head LSR review findings --- tests/test_scaling.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_scaling.py b/tests/test_scaling.py index 703eac9d1..36f437669 100644 --- a/tests/test_scaling.py +++ b/tests/test_scaling.py @@ -13,7 +13,7 @@ def lsr_oracle(rankings, n, alpha=0.0): rate = 1.0 / (len(rank) - i) for loser in rank[i + 1 :]: A[loser, winner] += rate - Q = A.copy() + Q = A np.fill_diagonal(Q, -Q.sum(axis=1)) evals, evecs = np.linalg.eig(Q.T) pi = np.real(evecs[:, np.argmin(np.abs(evals))]) @@ -78,9 +78,14 @@ def test_lsr_rankings_permutation_invariance_and_repeated(): # Non-uniform repeated rankings compared to the independent oracle non_uniform_rankings = rankings + [[0, 2]] * 5 - oracle_nu_params, _ = lsr_oracle(non_uniform_rankings, n, alpha=0.0) + oracle_nu_params, oracle_nu_weights = lsr_oracle( + non_uniform_rankings, n, alpha=0.0 + ) res_nu = lsr_rankings(non_uniform_rankings, n, alpha=0.0) np.testing.assert_allclose(res_nu.params, oracle_nu_params, rtol=1e-10, atol=1e-10) + np.testing.assert_allclose( + res_nu.weights, oracle_nu_weights, rtol=1e-10, atol=1e-10 + ) def test_lsr_rankings_invalid_cases(): @@ -96,7 +101,7 @@ def test_lsr_rankings_invalid_cases(): lsr_rankings([[0]], n) # Invalid duplicate items within ranking - with pytest.raises(ValueError, match="duplicates"): + with pytest.raises(ValueError, match="duplicate item"): lsr_rankings([[0, 0]], n) # Negative / out-of-range bounds