Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,49 @@

### Added

- `metrics_rating` prediction-quality metrics for binary-outcome forecasts (binomial deviance on capped predictions, RMSE/MAE on raw predictions, optional 0.5-baseline scaling), a Rust reimplementation of CRAN PlayerRatings 1.1-0 `metrics()` with its cap quirk and elementwise NaN semantics preserved; exact-oracle anchor tests, mutation-kill map, and MC-500 invariants.
- **Multiplayer Elo rating (CRAN PlayerRatings 1.1-0 `elom()`
`R/ratings.R` lines 739–932 + `elom_c` C kernel `src/ratings.c`
lines 45–80, and `kriichi` K-factor lines 1006–1020 — all READ and
normative; no journal paper exists for this system, CRAN package
provenance only)**: `elom_rating` scores nn-seat events (empty seats
as player −1/NaN score) with rank base scores, a per-period single
update `K·(actual − expected)` where expected sums
`(r_p − event mean rating)/40`, and either a constant K or the
kriichi experience-decay `max(kv, 1 − (1−kv)·games/gv)`. Faithfully
reproduces the R quirk that partial events shrink the ORIGINAL base
exactly once regardless of empty-seat count (R:855-866 `sbase <-
basev` resets inside the loop). REDUCED-SCOPE vs R: player −1 ⟺ NaN
score jointly enforced, sorted periods required, in-event duplicate
players rejected, kriichi bounds `gv > 0`, `0 < kv ≤ 1`. Rust core
`mlsirm_core::scaling::elom_rating` with exact-value anchors E1–E9
(dyadic rationals; hand-derived oracle executed against the R
semantics) and a 500-rep Monte-Carlo invariance test; 6 mutation
kills executed (K-scaling, event-mean, cumulative-shrink, kriichi
games-timing, tie-rank, per-event-update).

- **Stephenson rating system (CRAN PlayerRatings 1.1-0 `steph()`
`R/ratings.R` lines 591–737 + `stephenson_c` C kernel
`src/ratings.c` lines 157–202 — both READ and normative; no journal
paper exists for this system, which the package attributes to Alec
Stephenson's winning entry in the 2010 Kaggle chess-rating contest,
NOT independently verifiable beyond the package provenance).** New
Rust core `mlsirm_core::scaling::stephenson_rating` extending Glicko
with a per-game neighborhood variance term (`ngames·hval²`), a
per-game bonus `bval/100` added to each played game's score on BOTH
sides, a participants-only lambda drift toward opponents' ratings
(`(λ/100)·Σ(r_opp−r_self)/ngames`), and `(lag+1)·cval²` per-period
deviation-variance inflation clamped at `rdmax²` (all formulas
line-cited to the READ R/C source in the code header). Supports
prior-run continuation via `init_games`/`init_lag` and per-game
white-advantage `gamma`. PyO3 binding + thin NumPy wrapper
`fast_mlsirm.stephenson_rating` (defaults `init=(2200, 300)`,
`cval=10`, `hval=10`, `bval=0`, `lambda_=2`, `rdmax=350` matching
PlayerRatings). Anchored against an EXECUTED faithful oracle port of
the R driver + C kernel (heterogeneous-init, two-period draw/lag,
full-knobs, rdmax-clamp, bval-symmetry, and λ=0 contrast fixtures
pinned to 1e-12; five mutation kills EXECUTED: bval drop, λ sign
flip, per-game hval scaling drop, `(lag+1)→lag`, opponent-g→own-g).
- **Glicko-2 rating system (Glickman's 2022 *Example of the Glicko-2
system* note READ — worked example reproduced; CRAN PlayerRatings
1.1-0's `glicko2()` `R/ratings.R` + `glicko2_c` C kernel source READ;
Expand Down
160 changes: 160 additions & 0 deletions crates/fast-mlsirm-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5528,6 +5528,163 @@ fn glicko2_rating(
Ok(d.into())
}

/// Stephenson rating for a game schedule, PlayerRatings `steph()` semantics
/// (see `mlsirm_core::scaling::stephenson_rating`). Returns dict with
/// ratings, deviations, games, wins, draws, losses, lag.
#[pyfunction]
#[pyo3(signature = (periods, white, black, score, gamma, init_rating, init_dev, init_games, init_lag, cval, hval, bval, lambda_, rdmax))]
#[allow(clippy::too_many_arguments)]
fn stephenson_rating(
py: Python<'_>,
periods: PyReadonlyArray1<'_, u64>,
white: PyReadonlyArray1<'_, u64>,
black: PyReadonlyArray1<'_, u64>,
score: PyReadonlyArray1<'_, f64>,
gamma: PyReadonlyArray1<'_, f64>,
init_rating: PyReadonlyArray1<'_, f64>,
init_dev: PyReadonlyArray1<'_, f64>,
init_games: PyReadonlyArray1<'_, u64>,
init_lag: PyReadonlyArray1<'_, u64>,
cval: f64,
hval: f64,
bval: f64,
lambda_: f64,
rdmax: f64,
) -> PyResult<Py<pyo3::types::PyDict>> {
// usize::try_from (not `as`): a u64 id above usize::MAX must fail
// loudly on 32-bit targets instead of truncating into a valid index.
let white: Vec<usize> = white
.as_slice()?
.iter()
.map(|&v| usize::try_from(v))
.collect::<Result<_, _>>()
.map_err(|_| {
PyValueError::new_err("stephenson_rating: player index exceeds platform usize")
})?;
let black: Vec<usize> = black
.as_slice()?
.iter()
.map(|&v| usize::try_from(v))
.collect::<Result<_, _>>()
.map_err(|_| {
PyValueError::new_err("stephenson_rating: player index exceeds platform usize")
})?;
let res = mlsirm_core::scaling::stephenson_rating(
periods.as_slice()?,
&white,
&black,
score.as_slice()?,
gamma.as_slice()?,
init_rating.as_slice()?,
init_dev.as_slice()?,
init_games.as_slice()?,
init_lag.as_slice()?,
cval,
hval,
bval,
lambda_,
rdmax,
)
.map_err(PyValueError::new_err)?;
let d = pyo3::types::PyDict::new(py);
d.set_item("ratings", PyArray1::from_slice(py, &res.ratings))?;
d.set_item("deviations", PyArray1::from_slice(py, &res.deviations))?;
d.set_item("games", PyArray1::from_slice(py, &res.games))?;
d.set_item("wins", PyArray1::from_slice(py, &res.wins))?;
d.set_item("draws", PyArray1::from_slice(py, &res.draws))?;
d.set_item("losses", PyArray1::from_slice(py, &res.losses))?;
d.set_item("lag", PyArray1::from_slice(py, &res.lag))?;
Ok(d.into())
}

/// Multiplayer Elo rating for nn-player events, PlayerRatings `elom()`
/// semantics (see `mlsirm_core::scaling::elom_rating`). `players` and
/// `scores` are flattened g x nn (row-major); empty seats are player -1
/// with NaN score. `kfac_mode` is "scalar" (uses `kfac_k`) or "kriichi"
/// (uses `kfac_gv`/`kfac_kv`). Returns dict with ratings, games, places
/// (flattened n x nn), lag.
#[pyfunction]
#[pyo3(signature = (periods, players, scores, base, init_ratings, init_games, init_lag, init_places, kfac_mode, kfac_k, kfac_gv, kfac_kv, placing))]
#[allow(clippy::too_many_arguments)]
fn elom_rating(
py: Python<'_>,
periods: PyReadonlyArray1<'_, u64>,
players: PyReadonlyArray1<'_, i64>,
scores: PyReadonlyArray1<'_, f64>,
base: PyReadonlyArray1<'_, f64>,
init_ratings: PyReadonlyArray1<'_, f64>,
init_games: PyReadonlyArray1<'_, u64>,
init_lag: PyReadonlyArray1<'_, u64>,
init_places: PyReadonlyArray1<'_, u64>,
kfac_mode: &str,
kfac_k: f64,
kfac_gv: f64,
kfac_kv: f64,
placing: bool,
) -> PyResult<Py<pyo3::types::PyDict>> {
let kfac = match kfac_mode {
"scalar" => mlsirm_core::scaling::ElomKFactor::Scalar(kfac_k),
"kriichi" => mlsirm_core::scaling::ElomKFactor::Kriichi {
gv: kfac_gv,
kv: kfac_kv,
},
other => {
return Err(PyValueError::new_err(format!(
"elom_rating: kfac_mode {:?} must be \"scalar\" or \"kriichi\"",
other
)))
}
};
let res = mlsirm_core::scaling::elom_rating(
periods.as_slice()?,
players.as_slice()?,
scores.as_slice()?,
base.as_slice()?,
init_ratings.as_slice()?,
init_games.as_slice()?,
init_lag.as_slice()?,
init_places.as_slice()?,
kfac,
placing,
)
.map_err(PyValueError::new_err)?;
let d = pyo3::types::PyDict::new(py);
d.set_item("ratings", PyArray1::from_slice(py, &res.ratings))?;
d.set_item("games", PyArray1::from_slice(py, &res.games))?;
d.set_item("places", PyArray1::from_slice(py, &res.places))?;
d.set_item("lag", PyArray1::from_slice(py, &res.lag))?;
Ok(d.into())
}


/// Prediction-quality metrics for binary-outcome forecasts, PlayerRatings
/// `metrics()` semantics (see `mlsirm_core::scaling::metrics_rating`).
/// `pred` is flattened row-major nr x np; the return value is the
/// flattened row-major np x 3 matrix of per-column [bdev, mse, mae].
#[pyfunction]
#[pyo3(signature = (act, pred, nr, np, cap_lo, cap_hi, scale))]
fn metrics_rating(
py: Python<'_>,
act: PyReadonlyArray1<'_, f64>,
pred: PyReadonlyArray1<'_, f64>,
nr: usize,
np: usize,
cap_lo: f64,
cap_hi: f64,
scale: bool,
) -> PyResult<Py<PyArray1<f64>>> {
let out = mlsirm_core::scaling::metrics_rating(
act.as_slice()?,
pred.as_slice()?,
nr,
np,
(cap_lo, cap_hi),
scale,
)
.map_err(PyValueError::new_err)?;
Ok(PyArray1::from_slice(py, &out).into())
}

/// GPCM/nominal softmax cell log-probabilities at one node (parity surface for
/// the NumPy `category_logprobs` reference).
#[pyfunction]
Expand Down Expand Up @@ -7878,6 +8035,9 @@ fn fast_mlsirm_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(elo_rating, m)?)?;
m.add_function(wrap_pyfunction!(glicko_rating, m)?)?;
m.add_function(wrap_pyfunction!(glicko2_rating, m)?)?;
m.add_function(wrap_pyfunction!(stephenson_rating, m)?)?;
m.add_function(wrap_pyfunction!(elom_rating, m)?)?;
m.add_function(wrap_pyfunction!(metrics_rating, m)?)?;
m.add_function(wrap_pyfunction!(circle_arc_middle_anchor, m)?)?;
m.add_function(wrap_pyfunction!(loglinear_smooth, m)?)?;
m.add_function(wrap_pyfunction!(person_fit_stat, m)?)?;
Expand Down
Loading