diff --git a/src/history.rs b/src/history.rs index b3f2e2b..76e4ea6 100644 --- a/src/history.rs +++ b/src/history.rs @@ -202,6 +202,16 @@ pub(crate) struct CompetitorConfig { drift_scale: Option, } +/// A linear functional resolved against one time slice. +struct ResolvedTerms { + /// Coefficients over the slice's own competitors, in its ordering. + contrast: Vec, + /// Coefficients of competitors the slice has never seen, keyed by their + /// rendering. Independent of everything in the slice by construction. + unseen: HashMap, + mean: f64, +} + impl CompetitorConfig { fn is_empty(self) -> bool { self.prior.is_none() && self.drift_scale.is_none() @@ -755,6 +765,67 @@ impl, O: Observer, K: Eq + Hash + Clone> History, + row_of: &HashMap, + width: usize, + ) -> Result + where + K: std::fmt::Debug, + { + let mut contrast = vec![0.0; width]; + let mut unseen: HashMap = HashMap::new(); + let mut mean = 0.0; + + for (member, (key, coefficient)) in terms.iter().enumerate() { + let located = self + .keys + .get(*key) + .and_then(|index| row_of.get(&index).map(|row| (index, *row))); + + match located { + Some((index, row)) => { + contrast[row] += coefficient; + mean += coefficient + * slice + .skills + .get(index) + .expect("index came from this slice") + .posterior() + .mu(); + } + None => match self.unknown_keys { + crate::UnknownKeys::Prior => { + mean += coefficient * self.mu; + *unseen.entry(format!("{key:?}")).or_insert(0.0) += coefficient; + } + _ => { + return Err(InferenceError::UnknownKey { + team: 0, + member, + key: format!("{key:?}"), + }); + } + }, + } + } + + Ok(ResolvedTerms { + contrast, + unseen, + mean, + }) + } + /// Posterior of a linear combination of competitors' skills. /// /// `terms` pairs each competitor with its coefficient, so @@ -811,57 +882,151 @@ impl, O: Observer, K: Eq + Hash + Clone> History { - contrast[row] += coefficient; - mean += coefficient - * slice - .skills - .get(index) - .expect("index came from this slice") - .posterior() - .mu(); - } - None => match self.unknown_keys { - crate::UnknownKeys::Prior => { - mean += coefficient * self.mu; - independent_variance += coefficient * coefficient * self.sigma * self.sigma; - } - _ => { - return Err(InferenceError::UnknownKey { - team: 0, - member, - key: format!("{key:?}"), - }); - } - }, - } - } + let ResolvedTerms { + contrast, + unseen, + mean, + } = self.resolve_terms(terms, slice, &row_of, order.len())?; let z = crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable { reason: "the precision matrix is not positive-definite, which means \ a competitor has neither a proper prior nor any evidence", })?; - let variance: f64 = - contrast.iter().zip(&z).map(|(c, z)| c * z).sum::() + independent_variance; + + let prior_var = self.sigma * self.sigma; + let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum::() + + unseen.values().map(|c| c * c * prior_var).sum::(); Ok(Gaussian::from_mv(mean, variance)) } + /// How much observing this matchup would shrink the variance of `target`. + /// + /// `target` is a linear functional in the same shape + /// [`History::posterior_of`] takes, so the usual question — "which round + /// would best tell these two competitors apart" — is + /// `target = [(a, 1.0), (b, -1.0)]` scored across candidate matchups. + /// + /// This is the scored counterpart to + /// [`expected_information_gain`](crate::expected_information_gain), which + /// enumerates discrete outcomes and cannot be asked about a continuous + /// score. It is also far cheaper: one linear solve rather than a full + /// inference pass per possible outcome. + /// + /// # There is no expectation to take + /// + /// Observing a scored event is a rank-one update to the precision matrix, + /// and by the Sherman-Morrison identity the resulting variance reduction is + /// + /// ```text + /// (c^T L^-1 a)^2 / (v + a^T L^-1 a) + /// ``` + /// + /// which depends on *which* matchup is played but not on how it turns out. + /// For a Gaussian likelihood the posterior variance is data-independent, so + /// the expectation over outcomes is over a constant. The name keeps the + /// term the active-learning literature uses; no averaging happens. + /// + /// Verified against an actual refit to six decimal places for four + /// candidate matchups. + /// + /// # Errors + /// + /// As [`History::posterior_of`], plus `MismatchedShape` unless exactly two + /// teams are supplied and `EmptyTeam` for an empty one. + pub fn expected_variance_reduction( + &self, + teams: &[&[&K]], + target: &[(&K, f64)], + ) -> Result + where + K: std::fmt::Debug, + { + if teams.len() != 2 { + return Err(InferenceError::MismatchedShape { + kind: "expected_variance_reduction takes exactly 2 teams", + expected: 2, + got: teams.len(), + }); + } + + let slice = self + .time_slices + .last() + .ok_or(InferenceError::JointUnavailable { + reason: "the history has no events", + })?; + if !slice.all_scored() { + return Err(InferenceError::JointUnavailable { + reason: "the latest slice contains ranked events, whose EP factors \ + are not retained after convergence", + }); + } + + // The candidate matchup, expressed as the same kind of linear + // functional as the target. + let mut matchup: Vec<(&K, f64)> = Vec::new(); + let mut noise = self.score_sigma * self.score_sigma; + for (team_idx, team) in teams.iter().enumerate() { + if team.is_empty() { + return Err(InferenceError::EmptyTeam { team: team_idx }); + } + let sign = if team_idx == 0 { 1.0 } else { -1.0 }; + for key in team.iter() { + matchup.push((*key, sign)); + let beta = self + .keys + .get(*key) + .map_or(self.beta, |index| self.agents[index].rating.beta); + noise += beta * beta; + } + } + + let (order, lambda) = slice.joint_precision(&self.agents); + let mut row_of = HashMap::with_capacity(order.len()); + for (r, idx) in order.iter().enumerate() { + row_of.insert(*idx, r); + } + + let target = self.resolve_terms(target, slice, &row_of, order.len())?; + let matchup = self.resolve_terms(&matchup, slice, &row_of, order.len())?; + let (target_contrast, target_unseen) = (target.contrast, target.unseen); + let (matchup_contrast, matchup_unseen) = (matchup.contrast, matchup.unseen); + + // One solve: z = L^-1 a serves both inner products, since + // c^T L^-1 a = c^T z and a^T L^-1 a = a^T z. + let z = crate::joint::solve_spd(lambda, &matchup_contrast).ok_or( + InferenceError::JointUnavailable { + reason: "the precision matrix is not positive-definite", + }, + )?; + + let prior_var = self.sigma * self.sigma; + // Competitors outside the slice are independent, so they contribute + // only where the same key appears in both functionals. + let cross_unseen: f64 = target_unseen + .iter() + .map(|(k, tc)| tc * matchup_unseen.get(k).copied().unwrap_or(0.0) * prior_var) + .sum(); + let self_unseen: f64 = matchup_unseen.values().map(|c| c * c * prior_var).sum(); + + let cross: f64 = target_contrast + .iter() + .zip(&z) + .map(|(c, z)| c * z) + .sum::() + + cross_unseen; + let matchup_var: f64 = matchup_contrast + .iter() + .zip(&z) + .map(|(a, z)| a * z) + .sum::() + + self_unseen; + + Ok(cross * cross / (noise + matchup_var)) + } + /// Predictive distribution of the score margin between two teams. /// /// Answers "what will the gap be, and how wide is that interval" for a diff --git a/tests/variance_reduction.rs b/tests/variance_reduction.rs new file mode 100644 index 0000000..0255061 --- /dev/null +++ b/tests/variance_reduction.rs @@ -0,0 +1,156 @@ +//! `expected_variance_reduction`: which matchup best sharpens a given question. + +use smallvec::smallvec; +use trueskill_tt::{ + ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team, + UnknownKeys, +}; + +type H = History; + +fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event { + Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new(a)]), + Team::with_members([Member::new(b)]), + ], + outcome: Outcome::scores([sa, sb]), + } +} + +fn base() -> Vec> { + vec![ + round("a", "b", 5.0, 2.0), + round("a", "c", 6.0, 1.0), + round("b", "c", 4.0, 3.0), + round("c", "d", 2.0, 1.0), + round("a", "d", 7.0, 2.0), + ] +} + +fn fit(extra: Option>, policy: UnknownKeys) -> H { + let mut h: History = History::builder() + .mu(0.0) + .sigma(6.0) + .beta(1.0) + .score_sigma(2.0) + .drift(ConstantDrift(0.0)) + .unknown_keys(policy) + .convergence(ConvergenceOptions { + max_iter: 20_000, + epsilon: 1e-13, + alpha: 1.0, + }) + .build(); + let mut ev = base(); + if let Some(e) = extra { + ev.push(e); + } + h.add_events(ev).unwrap(); + let _ = h.converge().unwrap(); + h +} + +/// The closed form must equal what actually happens if the matchup is played. +/// This is the assertion that makes the whole call trustworthy: a wrong +/// acquisition function returns plausible numbers and quietly picks worse +/// matchups forever. +#[test] +fn the_closed_form_matches_an_actual_refit() { + let h = fit(None, UnknownKeys::Reject); + let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; + let before = h.posterior_of(&target).unwrap().sigma().powi(2); + + for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] { + let predicted = h + .expected_variance_reduction(&[&[&x], &[&y]], &target) + .unwrap(); + + let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject); + let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2); + + assert!( + (predicted - actual).abs() / actual.abs() < 1e-9, + "{x} vs {y}: predicted {predicted}, actual {actual}" + ); + } +} + +/// The reduction cannot depend on the score, because for a Gaussian likelihood +/// the posterior variance update is data-independent. This is why the call +/// needs no expectation despite its name. +#[test] +fn the_outcome_does_not_change_the_reduction() { + let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; + let h = fit(None, UnknownKeys::Reject); + let before = h.posterior_of(&target).unwrap().sigma().powi(2); + + let mut seen = Vec::new(); + for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] { + let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject); + seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2)); + } + for w in seen.windows(2) { + assert!( + (w[0] - w[1]).abs() < 1e-12, + "variance reduction moved with the observed score: {seen:?}" + ); + } +} + +/// The point of the call: it must rank candidate matchups usefully. Playing the +/// pair you are trying to separate helps most; an unrelated pair helps least. +#[test] +fn it_ranks_candidates_by_how_much_they_answer_the_question() { + let h = fit(None, UnknownKeys::Reject); + let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; + + let direct = h + .expected_variance_reduction(&[&[&"a"], &[&"b"]], &target) + .unwrap(); + let unrelated = h + .expected_variance_reduction(&[&[&"c"], &[&"d"]], &target) + .unwrap(); + + assert!(direct > 0.0 && unrelated > 0.0); + assert!( + direct > 5.0 * unrelated, + "playing the target pair should dominate: {direct} vs {unrelated}" + ); +} + +/// A matchup between two competitors nobody has seen still teaches something +/// about them, but nothing about a target that does not involve them. +#[test] +fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() { + let h = fit(None, UnknownKeys::Prior); + let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; + + let reduction = h + .expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target) + .unwrap(); + assert!( + reduction.abs() < 1e-12, + "an unseen pair shares nothing with the target: {reduction}" + ); +} + +#[test] +fn shape_errors_are_reported() { + let h = fit(None, UnknownKeys::Reject); + let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; + + assert!(matches!( + h.expected_variance_reduction(&[&[&"a"]], &target), + Err(InferenceError::MismatchedShape { + expected: 2, + got: 1, + .. + }) + )); + assert!(matches!( + h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target), + Err(InferenceError::UnknownKey { .. }) + )); +}