Three issues from two downstream consumers, all small, all sharing a theme: the crate had the information and would not hand it over. #44 — `UnknownKey { team: 0, member: 0 }` did not say which key. A consumer upgrading 0.1.2 -> 0.4.1 had every one of 5591 predictions return this error, fell back to a neutral 0.5, and lost its entire metadata model for a day. Nothing crashed and nothing logged; it was found by sweeping an unrelated parameter and noticing the output did not move. The 0.4.0 change that made unknown keys an error was right — the error was just too anonymous to act on. It now carries the key's `Debug` rendering, and its `Display` says what to do about it. The precondition is documented on every prediction entry point, which the reporter said would alone have saved the day. #43 — `cdf` was `pub(crate)`, so a consumer asking "is this competitor below the cutoff" approximated it with a `mu + z * sigma` band and had no way to say what confidence any `z` bought. Adds `Gaussian::probability_below` / `probability_above`. The second is separate on purpose: `1 - cdf` collapses to exactly zero past ~8.3 sigma, and a stopping rule is evaluated precisely there. Both route through the survival function added in 0.4.1, so this is visibility rather than new numerics. #50 — `ConvergenceReport` was not `#[must_use]`, so the one signal that a fit stopped short was trivially discarded. It now is, and that immediately found 78 sites doing exactly that — including this crate's own ATP example, which was capped at 10 sweeps when the history needs 30. The example now reads the report and says so. `ITERATIONS = 30` is documented as the floor it is, with the three measurements to hand: 400 events over 100 competitors already stops there at ~7e-3 against a 1e-6 tolerance, the ATP example needs 30 at a much looser one, and a consumer's 2000-node model needs 76 to 161. BREAKING CHANGE: `InferenceError::UnknownKey` gains a `key` field, and the prediction methods now require `K: Debug` in order to fill it. Closes #43, #50. Refs #44 — its third ask, an opt-in `UnknownKeys::Skip` mode, is a live API question and deliberately not answered here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
253 lines
7.3 KiB
Rust
253 lines
7.3 KiB
Rust
use plotters::prelude::*;
|
|
use smallvec::smallvec;
|
|
use time::{Date, Month};
|
|
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
|
|
|
|
fn main() {
|
|
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
|
|
|
|
let from = Date::from_calendar_date(1900, Month::January, 1).unwrap();
|
|
let time_format = time::format_description::parse("[year]-[month]-[day]").unwrap();
|
|
|
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
|
|
|
for row in csv.records() {
|
|
let date = Date::parse(&row["time_start"], &time_format).unwrap();
|
|
let time = (date - from).whole_days();
|
|
|
|
if &row["double"] == "t" {
|
|
events.push(Event {
|
|
time,
|
|
teams: smallvec![
|
|
Team::with_members([
|
|
Member::new(row["w1_id"].to_owned()),
|
|
Member::new(row["w2_id"].to_owned()),
|
|
]),
|
|
Team::with_members([
|
|
Member::new(row["l1_id"].to_owned()),
|
|
Member::new(row["l2_id"].to_owned()),
|
|
]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
});
|
|
} else {
|
|
events.push(Event {
|
|
time,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new(row["w1_id"].to_owned())]),
|
|
Team::with_members([Member::new(row["l1_id"].to_owned())]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
});
|
|
}
|
|
}
|
|
|
|
let mut hist: History<i64, _, _, String> = History::builder_with_key()
|
|
.sigma(1.6)
|
|
.drift(ConstantDrift(0.036))
|
|
.convergence(trueskill_tt::ConvergenceOptions {
|
|
// This history needs 30 sweeps to reach the epsilon below. It was
|
|
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
|
|
// surfaced that the example had been shipping a short fit.
|
|
max_iter: 100,
|
|
epsilon: 0.01,
|
|
alpha: 1.0,
|
|
})
|
|
.build();
|
|
|
|
hist.add_events(events).unwrap();
|
|
|
|
// Read the report rather than discarding it. A fit that hits `max_iter`
|
|
// without reaching `epsilon` is not an error and does not look wrong — every
|
|
// rating comes back finite and sensibly ordered — so this flag is the only
|
|
// thing that says the numbers were still moving when the sweep stopped.
|
|
let report = hist.converge().unwrap();
|
|
eprintln!(
|
|
"converged={} after {} sweeps, final step {:?}",
|
|
report.converged, report.iterations, report.final_step
|
|
);
|
|
if !report.converged {
|
|
eprintln!(
|
|
"warning: stopped after {} sweeps with a final step of {:?}, \
|
|
short of epsilon — raise ConvergenceOptions::max_iter",
|
|
report.iterations, report.final_step
|
|
);
|
|
}
|
|
|
|
let players = [
|
|
("aggasi", "a092", 38800i64),
|
|
("borg", "b058", 30300),
|
|
("connors", "c044", 31250),
|
|
("courier", "c243", 35750),
|
|
("djokovic", "d643", i64::MAX),
|
|
("edberg", "e004", 34750),
|
|
("federer", "f324", i64::MAX),
|
|
("hewitt", "h432", 40750),
|
|
("mcenroe", "m047", 33000),
|
|
("lendl", "l018", 33750),
|
|
("murray", "mc10", 60750),
|
|
("nadal", "n409", i64::MAX),
|
|
("nastase", "n008", 28750),
|
|
("sampras", "s402", i64::MAX),
|
|
("wilander", "w023", 32600),
|
|
];
|
|
|
|
let mut x_spec = (f64::MAX, f64::MIN);
|
|
let mut y_spec = (f64::MAX, f64::MIN);
|
|
|
|
for &(_, id, cutoff) in &players {
|
|
for (ts, gs) in hist.learning_curve(id) {
|
|
if ts >= cutoff {
|
|
continue;
|
|
}
|
|
|
|
let ts = ts as f64;
|
|
|
|
if ts < x_spec.0 {
|
|
x_spec.0 = ts;
|
|
}
|
|
|
|
if ts > x_spec.1 {
|
|
x_spec.1 = ts;
|
|
}
|
|
|
|
let upper = gs.mu() + gs.sigma();
|
|
let lower = gs.mu() - gs.sigma();
|
|
|
|
if lower < y_spec.0 {
|
|
y_spec.0 = lower;
|
|
}
|
|
|
|
if upper > y_spec.1 {
|
|
y_spec.1 = upper;
|
|
}
|
|
}
|
|
}
|
|
|
|
let root = SVGBackend::new("plot.svg", (1280, 640)).into_drawing_area();
|
|
|
|
root.fill(&WHITE).unwrap();
|
|
|
|
let mut chart = ChartBuilder::on(&root)
|
|
.margin(5)
|
|
.x_label_area_size(30)
|
|
.y_label_area_size(30)
|
|
.build_cartesian_2d(x_spec.0..x_spec.1, y_spec.0..y_spec.1)
|
|
.unwrap();
|
|
|
|
chart.configure_mesh().draw().unwrap();
|
|
|
|
for (idx, &(player, id, cutoff)) in players.iter().enumerate() {
|
|
let mut data = Vec::new();
|
|
let mut upper = Vec::new();
|
|
let mut lower = Vec::new();
|
|
|
|
for (ts, gs) in hist.learning_curve(id) {
|
|
if ts >= cutoff {
|
|
continue;
|
|
}
|
|
|
|
data.push((ts as f64, gs.mu()));
|
|
upper.push((ts as f64, gs.mu() + gs.sigma()));
|
|
lower.push((ts as f64, gs.mu() - gs.sigma()));
|
|
}
|
|
|
|
let color = Palette99::pick(idx);
|
|
|
|
let band = upper
|
|
.into_iter()
|
|
.chain(lower.into_iter().rev())
|
|
.collect::<Vec<_>>();
|
|
|
|
chart
|
|
.plotting_area()
|
|
.draw(&Polygon::new(band, color.mix(0.15)))
|
|
.unwrap();
|
|
|
|
chart
|
|
.draw_series(LineSeries::new(data, &color))
|
|
.unwrap()
|
|
.label(player)
|
|
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], &color));
|
|
}
|
|
|
|
chart
|
|
.configure_series_labels()
|
|
.background_style(WHITE.mix(0.8))
|
|
.border_style(BLACK)
|
|
.draw()
|
|
.unwrap();
|
|
}
|
|
|
|
mod csv {
|
|
use std::{
|
|
fs::File,
|
|
io::{self, BufRead, BufReader, Lines},
|
|
ops,
|
|
path::Path,
|
|
};
|
|
|
|
pub struct Reader {
|
|
header_map: Vec<String>,
|
|
lines: Lines<BufReader<File>>,
|
|
}
|
|
|
|
impl Reader {
|
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
|
|
let mut lines = File::open(path).map(BufReader::new)?.lines();
|
|
|
|
let header_map = if let Some(header) = lines.next() {
|
|
let header = header?;
|
|
|
|
header.split(',').map(Into::into).collect::<Vec<_>>()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(Self { header_map, lines })
|
|
}
|
|
|
|
pub fn records(&mut self) -> Records<'_> {
|
|
Records {
|
|
header_map: &self.header_map,
|
|
lines: &mut self.lines,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct Records<'a> {
|
|
header_map: &'a Vec<String>,
|
|
lines: &'a mut Lines<BufReader<File>>,
|
|
}
|
|
|
|
impl<'a> Iterator for Records<'a> {
|
|
type Item = Record<'a>;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let line = self.lines.next()?;
|
|
|
|
Some(Record {
|
|
header_map: self.header_map,
|
|
columns: line.unwrap().split(',').map(Into::into).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct Record<'a> {
|
|
header_map: &'a Vec<String>,
|
|
columns: Vec<String>,
|
|
}
|
|
|
|
impl<'a> ops::Index<&str> for Record<'a> {
|
|
type Output = str;
|
|
|
|
fn index(&self, index: &str) -> &Self::Output {
|
|
&self.columns[self
|
|
.header_map
|
|
.iter()
|
|
.position(|header| header == index)
|
|
.unwrap()]
|
|
}
|
|
}
|
|
}
|