Twenty-one breaking changes in one release, with a live consumer. The
changelog lists them; `MIGRATING.md` says what to do about them, leading
with the three that change what an existing, *compiling* call returns —
unknown keys, predictions from a broken fit, and `Gaussian`'s operators
— since those are the ones the compiler will not find for you.
Every "after" snippet was compiled, not written from memory, and doing
so caught three errors in my own guide:
- `log_evidence_for(&[&"alice"])` does not compile at `K = String`. The
right spelling is `&["alice"]`, which works at *both* key types —
checked, because a guide that is right for half its readers is worse
than no guide.
- the same for `filtered_log_evidence_for`
- the `Analysis<'h> { joint: Joint<'h> }` example needs a history at the
default key type; pairing it with a `History<String>` does not compile
git-cliff skips merge commits now. Every branch lands with `--no-ff`, so
a release's merges outnumber its real commits and say nothing the merged
ones do not — 0.9.0's changelog had fourteen lines of them under "Other
(unconventional)". `ci:` commits get a group instead of falling through
to that catch-all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
441 lines
16 KiB
Markdown
441 lines
16 KiB
Markdown
# TrueSkill - Through Time
|
||
|
||
Bayesian skill rating over a time axis.
|
||
|
||
Where plain TrueSkill gives each competitor one running estimate, TrueSkill
|
||
Through Time treats a whole history as a single model and infers skill *at every
|
||
point in time*. Evidence flows both directions: a result today sharpens the
|
||
estimate of who someone was last year, so early estimates stop being frozen
|
||
guesses and comparisons across eras become meaningful.
|
||
|
||
A Rust port of
|
||
[TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
||
|
||
## Install
|
||
|
||
```toml
|
||
[dependencies]
|
||
trueskill-tt = "0.8"
|
||
```
|
||
|
||
Optional features, both off by default:
|
||
|
||
- `approx` — `approx`'s equality traits for `Gaussian`. Useful in tests.
|
||
- `rayon` — parallelises the within-slice sweep and the per-slice passes of
|
||
`learning_curves` / `log_evidence`. Results stay bit-identical regardless of
|
||
worker count; `just determinism` asserts it at 1, 2, 4 and 8 threads.
|
||
|
||
## Quickstart
|
||
|
||
Record results, converge, then read off skills.
|
||
|
||
```rust
|
||
use trueskill_tt::History;
|
||
|
||
let mut history = History::default();
|
||
|
||
history.record_winner(&"alice", &"bob", 1)?;
|
||
history.record_winner(&"bob", &"carol", 2)?;
|
||
history.record_winner(&"alice", &"carol", 3)?;
|
||
|
||
history.converge()?;
|
||
|
||
let alice = history.current_skill("alice").unwrap();
|
||
assert!(alice.mu() > 0.0, "alice won every game she played");
|
||
# Ok::<(), trueskill_tt::InferenceError>(())
|
||
```
|
||
|
||
The third argument is the time. It is what makes this Through Time rather than
|
||
plain TrueSkill: skill is inferred at each of those moments, not once at the
|
||
end. `learning_curve` reads the whole trajectory back.
|
||
|
||
```rust
|
||
# use trueskill_tt::History;
|
||
# let mut history = History::default();
|
||
# history.record_winner(&"alice", &"bob", 1)?;
|
||
# history.record_winner(&"bob", &"carol", 2)?;
|
||
# history.record_winner(&"alice", &"carol", 3)?;
|
||
# history.converge()?;
|
||
// `None` means the key is unknown; `Some(vec![])` means known but unplayed.
|
||
let curve = history.learning_curve("alice").unwrap();
|
||
for (time, skill) in &curve {
|
||
println!("t={time}: {:.2} ± {:.2}", skill.mu(), skill.sigma());
|
||
}
|
||
|
||
// Everyone's latest posterior in one pass — the leaderboard query.
|
||
let latest = history.current_skills();
|
||
assert_eq!(latest.len(), 3);
|
||
# Ok::<(), trueskill_tt::InferenceError>(())
|
||
```
|
||
|
||
## Teams, rankings and draws
|
||
|
||
Anything beyond one-versus-one goes through the fluent event builder. An event
|
||
is only recorded by the terminal `.commit()`.
|
||
|
||
```rust
|
||
use trueskill_tt::History;
|
||
|
||
let mut history = History::builder().p_draw(0.1).build();
|
||
|
||
history
|
||
.event(1)
|
||
.team(["alice", "bob"])
|
||
.team(["carol", "dave"])
|
||
.ranking([0, 1]) // lower is better; equal values are a tie
|
||
.commit()?;
|
||
|
||
history.converge()?;
|
||
# Ok::<(), trueskill_tt::InferenceError>(())
|
||
```
|
||
|
||
**A tie needs a positive `p_draw`.** A `p_draw` of zero asserts draws cannot
|
||
happen, so a tied result has no representable likelihood and is rejected rather
|
||
than fitted to something else:
|
||
|
||
```rust
|
||
use trueskill_tt::{History, InferenceError};
|
||
|
||
let mut history = History::default(); // p_draw defaults to 0.0
|
||
let err = history.record_draw(&"alice", &"bob", 1).unwrap_err();
|
||
assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. }));
|
||
```
|
||
|
||
This also catches `Outcome::winner(w, n)` for three or more teams, which ties
|
||
every loser.
|
||
|
||
## Which entry point?
|
||
|
||
| You want to | Use |
|
||
|---|---|
|
||
| One match, two competitors | `record_winner` / `record_draw` |
|
||
| Teams, explicit ranks, scores, per-member weights | `history.event(t)…commit()` |
|
||
| A batch you already have as values | `add_events(iter)` |
|
||
| Score a hypothetical with no history at all | `Game` |
|
||
|
||
`Game` is the odd one out and worth being explicit about: it is a single match's
|
||
factor graph, it does not participate in a `History`, and nothing it computes is
|
||
remembered. Reach for it to evaluate a matchup in isolation; reach for `History`
|
||
for everything that accumulates.
|
||
|
||
## `converge` is strict
|
||
|
||
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
|
||
step still above `epsilon`, and `Err(NonFiniteStep)` if a sweep produces NaN.
|
||
|
||
It used to return `Ok` with `converged: false`, which was the worst available
|
||
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
|
||
the ordering looks sensible, and nothing about the output says the numbers were
|
||
still moving. Detection was opt-in, and `let _ = h.converge()` silently opted
|
||
out — which is how a real defect hid in this crate's own test suite.
|
||
|
||
The default `max_iter` is high enough that reaching it means something is
|
||
genuinely wrong rather than that the history is large; the loop exits at
|
||
`epsilon` long before, so raising the cap costs nothing when it is not needed.
|
||
Use `converge_partial` when a deliberately capped, unconverged fit is the point.
|
||
|
||
Predictions are strict for the same reason: every `predict_*` method reads
|
||
skills through one gate that refuses a NaN-poisoned fit, rather than returning a
|
||
plausible number computed from it.
|
||
|
||
## Drift
|
||
|
||
Skill drift models how a competitor's true skill can change between appearances.
|
||
Each time they reappear after a gap, their skill uncertainty is widened by the
|
||
drift model before the new evidence is incorporated.
|
||
|
||
Drift is represented by the `Drift` trait (`src/drift.rs`), generic over the
|
||
history's time type:
|
||
|
||
```text
|
||
pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
||
fn variance_delta(&self, from: &T, to: &T) -> f64;
|
||
fn variance_for_elapsed(&self, elapsed: i64) -> f64;
|
||
}
|
||
```
|
||
|
||
Both methods return the amount to add to `σ²`, not to `σ`. `variance_delta`
|
||
works from two timestamps; `variance_for_elapsed` takes an already-computed
|
||
elapsed count, and is used on the paths that cache it. `Gaussian::forget`
|
||
applies the result entirely in variance space — `from_mv(mu, variance() +
|
||
variance_delta)` — taking no square root.
|
||
|
||
That block is a quotation rather than a doctest. The custom-drift example below
|
||
is compiled by CI, so it is what actually pins the signature.
|
||
|
||
### ConstantDrift
|
||
|
||
The built-in `ConstantDrift` implements a linear random walk — skill uncertainty
|
||
grows proportionally to time:
|
||
|
||
```text
|
||
variance_delta = elapsed * γ²
|
||
```
|
||
|
||
This is the standard TrueSkill Through Time model. Pass a `ConstantDrift::new(gamma)`
|
||
when constructing a `Rating`:
|
||
|
||
```rust
|
||
use trueskill_tt::{ConstantDrift, Gaussian, Rating};
|
||
|
||
// gamma = 0.1 means skill can shift ~0.1 per time unit.
|
||
let rating: Rating<i64, ConstantDrift> =
|
||
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift::new(0.1));
|
||
|
||
assert_eq!(rating.drift().gamma(), 0.1);
|
||
```
|
||
|
||
The type annotation is load-bearing: `ConstantDrift` implements `Drift<T>` for
|
||
every `T: Time`, so without it `T` is ambiguous.
|
||
|
||
### Custom drift
|
||
|
||
Implement `Drift<T>` to express any other model. For example, a drift that
|
||
saturates after a long absence, with uncertainty growing as the square root of
|
||
elapsed time instead of linearly:
|
||
|
||
```rust
|
||
use trueskill_tt::{Drift, Gaussian, History, Rating, Time};
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct SqrtDrift {
|
||
gamma: f64,
|
||
}
|
||
|
||
impl<T: Time> Drift<T> for SqrtDrift {
|
||
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
||
let elapsed = from.elapsed_to(to).max(0) as f64;
|
||
elapsed.sqrt() * self.gamma * self.gamma
|
||
}
|
||
|
||
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
||
(elapsed.max(0) as f64).sqrt() * self.gamma * self.gamma
|
||
}
|
||
}
|
||
|
||
// On a single Rating:
|
||
let rating: Rating<i64, SqrtDrift> =
|
||
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, SqrtDrift { gamma: 0.5 });
|
||
|
||
// Or for a whole History, via the builder:
|
||
let history = History::builder().drift(SqrtDrift { gamma: 0.5 }).build();
|
||
|
||
assert_eq!(rating.beta(), 1.0);
|
||
assert_eq!(history.log_evidence(), 0.0);
|
||
```
|
||
|
||
`HistoryBuilder::drift` is the only way to set a history's drift model; there is
|
||
no `gamma()` shorthand. The default is `ConstantDrift::new(GAMMA)`.
|
||
|
||
### Per-competitor drift
|
||
|
||
A `History` has one drift model, but individual competitors can scale it.
|
||
`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor
|
||
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift::new(g)` at
|
||
scale `s` behaves exactly as `ConstantDrift::new(g * s)` would, for that competitor
|
||
alone.
|
||
|
||
`0.0` pins a competitor still. That is what makes a **fixed reference point**
|
||
expressible in the same graph as moving competitors — a bot at a known
|
||
strength, a rating floor, a course difficulty:
|
||
|
||
```rust
|
||
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
||
|
||
let mut h = History::builder().drift(ConstantDrift::new(0.1)).build();
|
||
|
||
h.add_events(vec![Event {
|
||
time: 0,
|
||
teams: [
|
||
Team::with_members([Member::new("player")]),
|
||
// A course does not improve. Pin it, and the round's evidence
|
||
// lands on the player instead of being split between the two.
|
||
Team::with_members([Member::new("layout_7").with_drift_scale(0.0)]),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
outcome: Outcome::winner(0, 2),
|
||
}])
|
||
.unwrap();
|
||
|
||
h.converge().unwrap();
|
||
```
|
||
|
||
Like `with_prior`, the scale is **competitor configuration, not a per-event
|
||
value**: it applies to the competitor for the whole history, and it applies
|
||
whenever it is supplied — including on a key the history already knows.
|
||
Configuring one late still refits the whole history rather than taking effect
|
||
only from that event onward, because `converge` refits from competitor state.
|
||
Repeating the same value is inert; supplying two *different* values for one
|
||
competitor within a single batch is `InferenceError::ConflictingCompetitorConfig`,
|
||
since events in a batch have no order. The scale must be finite and
|
||
non-negative; ingestion otherwise fails with `InferenceError::InvalidParameter`.
|
||
|
||
The fluent `EventBuilder` reaches this too: `.team([...])` is the common case
|
||
and leaves both unset, while `.members([...])` takes `Member` values directly,
|
||
so `h.event(t).members([Member::new("layout_7").with_drift_scale(0.0)])` is
|
||
equivalent to the typed shape above.
|
||
|
||
## Scored outcomes
|
||
|
||
Use `Outcome::scores([...])` when you have continuous per-team scores rather
|
||
than just ranks. Adjacent score margins flow into a `MarginFactor` that adds
|
||
soft Gaussian evidence about the latent performance diff. Configure
|
||
`HistoryBuilder::score_sigma(σ)` to control how much you trust the margins
|
||
(smaller σ = more trust).
|
||
|
||
```rust
|
||
use trueskill_tt::History;
|
||
|
||
let mut h = History::builder().score_sigma(2.0).build();
|
||
h.event(1)
|
||
.team(["alice"])
|
||
.team(["bob"])
|
||
.scores([21.0, 9.0])
|
||
.commit()
|
||
.unwrap();
|
||
h.converge().unwrap();
|
||
```
|
||
|
||
## Prediction
|
||
|
||
`predict_outcome` gives the full distribution over finishing orders. Each entry
|
||
is a rank vector in the same shape `Outcome::ranking` takes — equal ranks mean a
|
||
tie — so an outcome feeds straight back into inference.
|
||
|
||
```rust
|
||
use trueskill_tt::History;
|
||
|
||
let mut h = History::builder().p_draw(0.1).build();
|
||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||
h.converge().unwrap();
|
||
|
||
let p = h.predict_outcome(&[&[&"alice"], &[&"bob"]]).unwrap();
|
||
|
||
// Probabilities are exhaustive and disjoint, so they sum to one.
|
||
assert!((p.total() - 1.0).abs() < 1e-6);
|
||
|
||
let (best, likelihood) = p.most_likely().unwrap();
|
||
println!("most likely: {best:?} at {likelihood:.3}");
|
||
println!("draw: {:.3}", p.probability_of(&[0, 0]));
|
||
```
|
||
|
||
Supports any number of teams. Because the outcome space grows factorially, the
|
||
full distribution is capped at `MAX_PREDICTED_TEAMS`; two cheaper entry points
|
||
stay available at any size:
|
||
|
||
- `predict_win_probabilities(teams)` — `P(team i finishes strictly first)`,
|
||
quadratic in team count.
|
||
- `predict_ranking(teams, ranks)` — one specific finishing order.
|
||
|
||
Unknown keys are an error by default, not a silent omission: a team the history
|
||
has never seen cannot produce a confident-looking probability. The error names
|
||
the key, and every key must already be known — pre-filter with
|
||
`current_skill` if your caller cannot guarantee that.
|
||
|
||
If predicting for competitors you have never seen is the point rather than a
|
||
mistake, say so once:
|
||
|
||
```rust
|
||
use trueskill_tt::{History, UnknownKeys};
|
||
|
||
let h = History::builder().unknown_keys(UnknownKeys::Prior).build();
|
||
```
|
||
|
||
An unknown competitor is then answered from the configured prior, which is the
|
||
honest reading — you have no evidence about them — and correctly *widens* a team
|
||
that contains one. There is deliberately no "skip the member" mode: a team's
|
||
performance is the sum of its members, so dropping one would make the model more
|
||
certain because it knows less.
|
||
|
||
### Asking about one competitor
|
||
|
||
`Gaussian` answers tail questions directly, which is what a stopping rule needs:
|
||
|
||
```rust
|
||
use trueskill_tt::History;
|
||
|
||
let mut h = History::default();
|
||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||
h.converge().unwrap();
|
||
|
||
let skill = h.current_skill("alice").unwrap();
|
||
|
||
// "How sure am I that this is below the cutoff?" — a probability, not a
|
||
// `mu + z * sigma` band whose confidence drifts as sigma changes.
|
||
let _ = skill.probability_below(20.0);
|
||
|
||
// Use this rather than `1.0 - probability_below(x)`: the complement cancels
|
||
// away every digit in the upper tail, which is where a stopping rule lives.
|
||
let _ = skill.probability_above(30.0);
|
||
```
|
||
|
||
## Which match to play next
|
||
|
||
`quality()` measures whether a matchup is *fair*. That is not the same as
|
||
whether it is *informative*, and the two only coincide for two evenly matched
|
||
competitors. When each observation costs something, ask
|
||
`expected_information_gain` instead — the outcome-weighted divergence between
|
||
what you believe now and what you would believe afterwards.
|
||
|
||
```rust
|
||
use trueskill_tt::History;
|
||
|
||
let mut h = History::default();
|
||
for t in 1..=10 {
|
||
h.record_winner(&"veteran", &"regular", t).unwrap();
|
||
h.record_winner(&"regular", &"veteran", t + 100).unwrap();
|
||
}
|
||
h.record_winner(&"veteran", &"newcomer", 500).unwrap();
|
||
h.converge().unwrap();
|
||
|
||
let settled = h.expected_information_gain(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
||
let unknown = h.expected_information_gain(&[&[&"veteran"], &[&"newcomer"]]).unwrap();
|
||
|
||
// Playing the newcomer teaches you more than replaying a settled rivalry.
|
||
assert!(unknown > settled);
|
||
```
|
||
|
||
The result is in nats, and is bounded by the entropy of the outcome: at most
|
||
`ln 2 ≈ 0.693` for a two-way result, `ln 3` once draws are possible, `ln k` for
|
||
`k` outcomes. A value near zero means you already know how it ends.
|
||
|
||
This costs one full inference pass **per possible outcome**, so it is far more
|
||
expensive than `quality()`. Scoring every pairing among `n` competitors is
|
||
`O(n² × outcomes)` passes — shortlist with `quality()` or
|
||
`predict_win_probabilities` first, then score only the shortlist.
|
||
|
||
## Other implementations
|
||
|
||
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
|
||
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
|
||
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
|
||
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
|
||
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
|
||
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
|
||
|
||
## Status
|
||
|
||
Every box on the old todo list is ticked, so it has been retired; open work
|
||
lives in the issue tracker instead. The crate is in use and the API is still
|
||
moving — breaking changes are batched into minor releases rather than dribbled
|
||
out. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
|
||
to do about them.
|
||
|
||
## License
|
||
|
||
Licensed under either of
|
||
|
||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
|
||
<http://www.apache.org/licenses/LICENSE-2.0>)
|
||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or
|
||
<http://opensource.org/licenses/MIT>)
|
||
|
||
at your option.
|
||
|
||
### Contribution
|
||
|
||
Unless you explicitly state otherwise, any contribution intentionally submitted
|
||
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
|
||
dual licensed as above, without any additional terms or conditions.
|