feat: add expected information gain for active matchup selection
`quality()` answers "is this matchup fair". Callers picking which
comparison to run next need "is this matchup informative", and the two
coincide only for two evenly matched competitors. Without a principled
alternative, downstream code was reaching for hand-rolled heuristics
like `quality * sigma_a^2 * sigma_b^2`, which double-counts uncertainty:
the two factors are not independent.
Adds `expected_information_gain`, the outcome-weighted divergence
between current beliefs and the beliefs each result would produce:
EIG = SUM P(outcome) * KL(posterior_after(outcome) || prior)
Available standalone over `Rating`s, and as
`History::expected_information_gain` using current skills and the
history's own beta, drift and p_draw — so the outcomes it weighs are the
ones that would actually be fitted.
This is the mutual information between the outcome and the skills, which
gives an analytic ceiling: gain cannot exceed the entropy of the thing
being observed, so at most `ln k` nats for k outcomes. That bound is the
sharpest test available, because an acquisition function is unusually
exposed to returning finite, plausible, monotone numbers while being
wrong — it would simply select slightly worse matchups forever. A
prototype of this returned 4.77 nats from a sign error while passing
every monotonicity check; `never_exceeds_the_entropy_of_the_outcome`
catches that class unconditionally.
Measured against the ceiling the values are meaningful rather than
vacuous: 0.382 nats for an even matchup between diffuse priors against
an 0.693 ceiling, falling to 0.013 for a lopsided one and 0.000 for a
hopeless one.
`disagrees_with_the_quality_times_variance_heuristic` pins down that
this is not a monotone transform of the heuristic it replaces — the two
rank a lopsided matchup and a confident even one in opposite orders — so
a later "simplification" cannot quietly revert to it.
Cost is one inference pass per possible outcome, documented on the
public API alongside the shortlist-then-score pattern, so callers do not
discover it in production.
Also folds the duplicated key-gathering in `predict_quality` and
`performances` into one validated `member_skills`.
Refs #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:
+80
-45
@@ -538,10 +538,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Each team's performance Gaussian, and its member count.
|
||||
///
|
||||
/// Performance is skill inflated by `beta`: the question a prediction
|
||||
/// answers is "how will they do today", not "how good are they".
|
||||
/// Every team's member skills, validated.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -549,39 +546,60 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// 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> {
|
||||
fn member_skills(&self, teams: &[&[&K]]) -> Result<Vec<Vec<Gaussian>>, 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());
|
||||
let mut gathered = 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;
|
||||
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())?;
|
||||
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));
|
||||
members.push(
|
||||
self.time_slices
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|ts| ts.skills.get(index).map(|s| s.posterior()))
|
||||
.ok_or(unknown)?,
|
||||
);
|
||||
}
|
||||
|
||||
performances.push(total);
|
||||
sizes.push(team.len());
|
||||
gathered.push(members);
|
||||
}
|
||||
|
||||
Ok(gathered)
|
||||
}
|
||||
|
||||
/// Each team's performance Gaussian, and its member count.
|
||||
///
|
||||
/// Performance is skill inflated by `beta`: the question a prediction
|
||||
/// answers is "how will they do today", not "how good are they".
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`History::member_skills`].
|
||||
fn performances(&self, teams: &[&[&K]]) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError> {
|
||||
let skills = self.member_skills(teams)?;
|
||||
|
||||
let performances = skills
|
||||
.iter()
|
||||
.map(|team| {
|
||||
team.iter()
|
||||
.fold(crate::N00, |acc, s| acc + s.forget(self.beta.powi(2)))
|
||||
})
|
||||
.collect();
|
||||
let sizes = skills.iter().map(Vec::len).collect();
|
||||
|
||||
Ok((performances, sizes))
|
||||
}
|
||||
|
||||
@@ -618,38 +636,55 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
///
|
||||
/// `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(index).map(|s| s.posterior()))
|
||||
.ok_or(unknown)?,
|
||||
);
|
||||
}
|
||||
groups.push(members);
|
||||
}
|
||||
|
||||
if groups.len() < 2 {
|
||||
return Err(InferenceError::NotEnoughTeams { got: groups.len() });
|
||||
}
|
||||
|
||||
let groups = self.member_skills(teams)?;
|
||||
let group_refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect();
|
||||
Ok(crate::quality(&group_refs, self.beta))
|
||||
}
|
||||
|
||||
/// Expected information gain of running this matchup, in nats.
|
||||
///
|
||||
/// Answers "which comparison should I run next" rather than "who will
|
||||
/// win": the outcome-weighted divergence between current beliefs and the
|
||||
/// beliefs each possible result would produce. Higher means the result
|
||||
/// would teach you more.
|
||||
///
|
||||
/// Uses each competitor's current skill as the prior, and the history's
|
||||
/// own `beta`, `drift` and `p_draw`, so the outcomes weighted here are the
|
||||
/// ones that would actually be fitted if the matchup were played and
|
||||
/// recorded.
|
||||
///
|
||||
/// Distinct from [`History::predict_quality`], which measures *fairness*.
|
||||
/// The two coincide for two evenly matched competitors and diverge
|
||||
/// elsewhere. See [`expected_information_gain`](crate::expected_information_gain)
|
||||
/// for the scale, the analytic `ln k` ceiling, and the cost.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`History::member_skills`], plus `TooManyTeams` and anything
|
||||
/// inference returns for a hypothetical outcome.
|
||||
pub fn expected_information_gain(&self, teams: &[&[&K]]) -> Result<f64, InferenceError> {
|
||||
let skills = self.member_skills(teams)?;
|
||||
|
||||
let ratings: Vec<Vec<Rating<T, D>>> = skills
|
||||
.iter()
|
||||
.map(|team| {
|
||||
team.iter()
|
||||
.map(|&skill| Rating::new(skill, self.beta, self.drift))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let team_refs: Vec<&[Rating<T, D>]> = ratings.iter().map(Vec::as_slice).collect();
|
||||
|
||||
crate::expected_information_gain(
|
||||
&team_refs,
|
||||
&crate::GameOptions {
|
||||
p_draw: self.p_draw,
|
||||
score_sigma: self.score_sigma,
|
||||
convergence: self.convergence,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `P(team i finishes strictly first)`, for every team.
|
||||
///
|
||||
/// Supports any number of teams. Because performances are independent
|
||||
|
||||
Reference in New Issue
Block a user