feat!: N-team outcome prediction with draw mass, replacing the 2-team panic
`predict_outcome` asserted `teams.len() == 2` and returned `[p, 1 - p]`, allocating no probability to a draw even with `p_draw > 0`. For a draw-enabled model the numbers were simply wrong, at any team count. It now returns `Result<Prediction, InferenceError>` and supports N teams. Two algorithms, both deterministic: - Who finishes first. Performances are independent Gaussians, so this separates into a one-dimensional integral per team rather than a multivariate orthant probability. Adaptive Gauss-Kronrod evaluates it to ~1e-15, matching the exact two-team closed form. - A specific finishing order. The factor graph only constrains rank-adjacent teams, so a full order is a chain of local constraints, not a general orthant integral. That chain collapses into a sequential recursion over cumulative integrals: O(teams * grid) per order. Fixed-node Gauss-Hermite is the obvious tool for the first and is a trap: when a rival's sigma is small the CDF product becomes a step narrower than the node spacing, and the nodes step over it. Measured 4.4e-4 off the closed form on a mildly skewed matchup and 1.7e-2 on a small-sigma one, while still returning something that looks like a probability. Adaptive refinement is what makes that case safe, and `win_probabilities_survive_a_rival_with_a_tiny_sigma` pins it down. The acceptance test is an identity rather than a golden: the outcome space is exhaustive and disjoint, so the probabilities sum to one. Any drift is integration error and nothing else. Gauss-Hermite failed it at 4.4e-4; this holds to ~1e-9. Also from #21: unknown keys are now reported rather than dropped, so a team of strangers can no longer produce a confident-looking prediction. `predict_quality` returns `Result` for the same reason. BREAKING CHANGE: `predict_outcome` returns `Result<Prediction, _>` instead of `Vec<f64>`; `predict_quality` returns `Result<f64, _>`. Refs #21, #39 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+197
-44
@@ -9,6 +9,7 @@ use crate::{
|
||||
gaussian::Gaussian,
|
||||
key_table::KeyTable,
|
||||
observer::{NullObserver, Observer},
|
||||
predict::Prediction,
|
||||
rating::Rating,
|
||||
sort_time,
|
||||
storage::CompetitorStore,
|
||||
@@ -522,61 +523,213 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Draw-probability quality metric for the given teams (key slices).
|
||||
/// Each team's performance Gaussian, and its member count.
|
||||
///
|
||||
/// Values range roughly [0, 1]; 1 == perfectly matched. Supports any
|
||||
/// number of teams.
|
||||
/// Performance is skill inflated by `beta`: the question a prediction
|
||||
/// answers is "how will they do today", not "how good are they".
|
||||
///
|
||||
/// # Panics
|
||||
/// # Errors
|
||||
///
|
||||
/// Panics if fewer than two teams are supplied, or if a team resolves to
|
||||
/// no known competitors — keys absent from the history, or competitors
|
||||
/// with no recorded skill, are dropped, so a team of entirely-unknown
|
||||
/// keys becomes empty. Use `lookup` to check keys first.
|
||||
pub fn predict_quality(&self, teams: &[&[&K]]) -> f64 {
|
||||
let groups: Vec<Vec<Gaussian>> = teams
|
||||
.iter()
|
||||
.map(|team| {
|
||||
team.iter()
|
||||
.filter_map(|k| self.keys.get(*k))
|
||||
.filter_map(|idx| {
|
||||
self.time_slices
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|ts| ts.skills.get(idx).map(|s| s.posterior()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let group_refs: Vec<&[Gaussian]> = groups.iter().map(|g| g.as_slice()).collect();
|
||||
crate::quality(&group_refs, self.beta)
|
||||
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`. Unknown keys are
|
||||
/// reported rather than dropped — silently skipping them would turn a team
|
||||
/// of strangers into a confident-looking prediction about nobody, which is
|
||||
/// the failure this replaced.
|
||||
fn performances(&self, teams: &[&[&K]]) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError> {
|
||||
if teams.len() < 2 {
|
||||
return Err(InferenceError::NotEnoughTeams { got: teams.len() });
|
||||
}
|
||||
|
||||
let mut performances = Vec::with_capacity(teams.len());
|
||||
let mut sizes = Vec::with_capacity(teams.len());
|
||||
|
||||
for (team_idx, team) in teams.iter().enumerate() {
|
||||
if team.is_empty() {
|
||||
return Err(InferenceError::EmptyTeam { team: team_idx });
|
||||
}
|
||||
|
||||
let mut total = crate::N00;
|
||||
for (member_idx, key) in team.iter().enumerate() {
|
||||
let unknown = InferenceError::UnknownKey {
|
||||
team: team_idx,
|
||||
member: member_idx,
|
||||
};
|
||||
let index = self.keys.get(*key).ok_or(unknown.clone())?;
|
||||
let skill = self
|
||||
.time_slices
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|ts| ts.skills.get(index).map(|s| s.posterior()))
|
||||
.ok_or(unknown)?;
|
||||
total = total + skill.forget(self.beta.powi(2));
|
||||
}
|
||||
|
||||
performances.push(total);
|
||||
sizes.push(team.len());
|
||||
}
|
||||
|
||||
Ok((performances, sizes))
|
||||
}
|
||||
|
||||
/// 2-team win probability: returns `[P(team0 wins), P(team1 wins)]`.
|
||||
/// Draw margins per team pair.
|
||||
///
|
||||
/// Only two teams are supported.
|
||||
/// Inference derives the margin per rank-adjacent pair from those two
|
||||
/// teams' betas (`Game::likelihoods`), so prediction must too — a single
|
||||
/// game-wide margin would describe a different model than the one that
|
||||
/// will actually be fitted.
|
||||
fn margins(&self, sizes: &[usize]) -> crate::predict::Margins {
|
||||
let beta_sq = self.beta.powi(2);
|
||||
let p_draw = self.p_draw;
|
||||
|
||||
crate::predict::Margins::new(sizes.len(), |i, j| {
|
||||
if p_draw == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let sd = ((sizes[i] + sizes[j]) as f64 * beta_sq).sqrt();
|
||||
crate::compute_margin(p_draw, sd)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw-probability quality metric for the given teams (key slices).
|
||||
///
|
||||
/// # Panics
|
||||
/// Values range roughly `[0, 1]`; 1 == perfectly matched. Supports any
|
||||
/// number of teams.
|
||||
///
|
||||
/// Panics if `teams.len() != 2`.
|
||||
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Vec<f64> {
|
||||
assert_eq!(teams.len(), 2, "predict_outcome supports exactly 2 teams");
|
||||
let gather = |team: &[&K]| -> Gaussian {
|
||||
team.iter()
|
||||
.filter_map(|k| self.keys.get(*k))
|
||||
.filter_map(|idx| {
|
||||
/// Note this answers "is this matchup *fair*", which is not the same as
|
||||
/// "is this matchup *informative*" — the two coincide for two evenly
|
||||
/// matched teams and diverge elsewhere.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`.
|
||||
pub fn predict_quality(&self, teams: &[&[&K]]) -> Result<f64, InferenceError> {
|
||||
let mut groups: Vec<Vec<Gaussian>> = Vec::with_capacity(teams.len());
|
||||
|
||||
for (team_idx, team) in teams.iter().enumerate() {
|
||||
if team.is_empty() {
|
||||
return Err(InferenceError::EmptyTeam { team: team_idx });
|
||||
}
|
||||
let mut members = Vec::with_capacity(team.len());
|
||||
for (member_idx, key) in team.iter().enumerate() {
|
||||
let unknown = InferenceError::UnknownKey {
|
||||
team: team_idx,
|
||||
member: member_idx,
|
||||
};
|
||||
let index = self.keys.get(*key).ok_or(unknown.clone())?;
|
||||
members.push(
|
||||
self.time_slices
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|ts| ts.skills.get(idx).map(|s| s.posterior()))
|
||||
})
|
||||
.fold(crate::N00, |acc, g| acc + g.forget(self.beta.powi(2)))
|
||||
};
|
||||
let a = gather(teams[0]);
|
||||
let b = gather(teams[1]);
|
||||
let diff = a - b;
|
||||
let p_a = 1.0 - crate::cdf(0.0, diff.mu(), diff.sigma());
|
||||
vec![p_a, 1.0 - p_a]
|
||||
.find_map(|ts| ts.skills.get(index).map(|s| s.posterior()))
|
||||
.ok_or(unknown)?,
|
||||
);
|
||||
}
|
||||
groups.push(members);
|
||||
}
|
||||
|
||||
if groups.len() < 2 {
|
||||
return Err(InferenceError::NotEnoughTeams { got: groups.len() });
|
||||
}
|
||||
|
||||
let group_refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect();
|
||||
Ok(crate::quality(&group_refs, self.beta))
|
||||
}
|
||||
|
||||
/// `P(team i finishes strictly first)`, for every team.
|
||||
///
|
||||
/// Supports any number of teams. Because performances are independent
|
||||
/// Gaussians, this separates into a one-dimensional integral per team —
|
||||
/// no multivariate orthant probability is involved — and is evaluated by
|
||||
/// adaptive quadrature to within the precision of the underlying normal
|
||||
/// CDF (~1e-8).
|
||||
///
|
||||
/// With a zero `p_draw` these sum to one. With a positive `p_draw` the
|
||||
/// shortfall is the probability that the top place is shared.
|
||||
///
|
||||
/// Cheap at any team count: cost grows as the square of the team count,
|
||||
/// not factorially. Prefer this to [`History::predict_outcome`] when you
|
||||
/// only need to know who wins.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`.
|
||||
pub fn predict_win_probabilities(&self, teams: &[&[&K]]) -> Result<Vec<f64>, InferenceError> {
|
||||
let (performances, sizes) = self.performances(teams)?;
|
||||
Ok(crate::predict::win_probabilities(
|
||||
&performances,
|
||||
&self.margins(&sizes),
|
||||
))
|
||||
}
|
||||
|
||||
/// The full distribution over finishing orders.
|
||||
///
|
||||
/// Every entry is a rank vector — the shape [`crate::Outcome::ranking`]
|
||||
/// takes, equal ranks meaning a tie — paired with its probability. The
|
||||
/// entries are exhaustive and disjoint, so they sum to one; that identity
|
||||
/// is the strongest available check on the numerics and is worth asserting
|
||||
/// in tests via [`Prediction::total`].
|
||||
///
|
||||
/// Accounts for `p_draw`: with a positive draw probability, tied outcomes
|
||||
/// carry real mass rather than being silently omitted.
|
||||
///
|
||||
/// # Cost
|
||||
///
|
||||
/// This enumerates the outcome space, which holds `n! * 2^(n-1)` events —
|
||||
/// 24 at three teams, 192 at four, 1_920 at five, 23_040 at six. Each
|
||||
/// costs one `O(teams * grid)` pass, so this is milliseconds at three or
|
||||
/// four teams and seconds at six. Above
|
||||
/// [`MAX_TEAMS_FOR_DISTRIBUTION`](crate::MAX_PREDICTED_TEAMS) it returns
|
||||
/// `TooManyTeams` rather than hanging. When you need one specific ordering
|
||||
/// use [`History::predict_ranking`], and when you only need the winner use
|
||||
/// [`History::predict_win_probabilities`]; both stay cheap at any size.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `TooManyTeams`.
|
||||
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Result<Prediction, InferenceError> {
|
||||
if teams.len() > crate::MAX_PREDICTED_TEAMS {
|
||||
return Err(InferenceError::TooManyTeams {
|
||||
got: teams.len(),
|
||||
max: crate::MAX_PREDICTED_TEAMS,
|
||||
});
|
||||
}
|
||||
|
||||
let (performances, sizes) = self.performances(teams)?;
|
||||
Ok(Prediction::new(crate::predict::outcome_distribution(
|
||||
&performances,
|
||||
&self.margins(&sizes),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Probability of one specific finishing order.
|
||||
///
|
||||
/// `ranks` follows [`crate::Outcome::ranking`]: lower is better, and equal
|
||||
/// values mean those teams tied. Teams sharing a rank may finish in any
|
||||
/// internal order, so this sums over those orders rather than picking one.
|
||||
///
|
||||
/// Unlike [`History::predict_outcome`] this does not enumerate the outcome
|
||||
/// space, so it stays cheap at any team count — use it when you know which
|
||||
/// orderings you care about.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `MismatchedShape` if
|
||||
/// `ranks` does not have one entry per team.
|
||||
pub fn predict_ranking(&self, teams: &[&[&K]], ranks: &[u32]) -> Result<f64, InferenceError> {
|
||||
if ranks.len() != teams.len() {
|
||||
return Err(InferenceError::MismatchedShape {
|
||||
kind: "ranks vs teams",
|
||||
expected: teams.len(),
|
||||
got: ranks.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let (performances, sizes) = self.performances(teams)?;
|
||||
Ok(crate::predict::ranking_probability(
|
||||
&performances,
|
||||
&self.margins(&sizes),
|
||||
ranks,
|
||||
))
|
||||
}
|
||||
|
||||
/// Run the full forward+backward convergence loop and return a summary.
|
||||
|
||||
Reference in New Issue
Block a user