feat: factorise the joint once with History::joint
`posterior_of`, `posterior_of_at` and `expected_variance_reduction` each
built the joint precision matrix, factorised it, asked one question and
threw it away. The factorisation is O(n^3) in the history's appearances
and depends only on the fit, so a caller asking about every pair in a
standings table, every cell in a grid, or every candidate in an
active-learning sweep paid for the same factorisation once per question.
`History::joint()` returns a `Joint` handle that pays it once. Measured
on 1976 appearances, 90 queries: 68.4s one-shot against 745ms factorise
plus 93ms of queries — 81.6x, with bit-identical answers. Per query,
Criterion at 480 appearances: 9.0ms one-shot against 48us cached, 187x.
The handle borrows the history, which is what makes it correct with no
invalidation logic: the borrow checker forbids adding events or refitting
while it is alive, so there is no window in which the factorisation could
describe a fit that no longer exists. It also makes the lifetime of the
n^2 factor explicit rather than parking it in the history forever — at
4000 appearances that is 128MB, which is not something to cache silently.
Every question the joint answers turns out to be a bilinear form,
c^T A^-1 a = (L^-1 c) . (L^-1 a)
so no caller ever needs L^-1 c itself. Replacing the general solve with a
forward substitution drops the back substitution as wasted work, halving
a query, and removes a failure mode: a variance as `c . (A^-1 c)` is a
difference of products that can round negative, where `|L^-1 c|^2` is a
sum of squares and cannot.
The one-shot calls are unchanged in cost and now delegate to the handle,
so the two paths cannot drift apart. tests/joint_handle.rs asserts they
agree bit for bit, including at pinned times, under UnknownKeys::Prior,
and across candidate matchups.
Refs #51
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+267
-148
@@ -950,6 +950,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// per-day or per-event slices, competitors are rarely all present in any
|
||||
/// one of them. Use [`History::posterior_of_at`] to pin a time instead.
|
||||
///
|
||||
/// # Asking more than one question
|
||||
///
|
||||
/// This factorises the joint, uses it once, and throws it away. The
|
||||
/// factorisation is the expensive part and it depends only on the fit, so
|
||||
/// asking `n` questions this way pays for it `n` times. Take a
|
||||
/// [`Joint`] with [`History::joint`] instead — the answers are identical,
|
||||
/// and only the first one pays.
|
||||
///
|
||||
/// # Limitations
|
||||
///
|
||||
/// Exact only for a history whose events are all scored, because a scored
|
||||
@@ -959,10 +967,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// so a history containing ranked events returns `JointUnavailable` rather
|
||||
/// than a plausible wrong number.
|
||||
///
|
||||
/// Cost is a dense solve over the history's *appearances*, not its
|
||||
/// competitors: a competitor contributes one variable per slice it appears
|
||||
/// in, minus any consecutive pair with no drift between them.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `UnknownKey` for a competitor the history has never seen, and
|
||||
@@ -972,42 +976,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
if self.time_slices.is_empty() {
|
||||
return Err(InferenceError::JointUnavailable {
|
||||
reason: "the history has no events",
|
||||
});
|
||||
}
|
||||
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
||||
return Err(InferenceError::JointUnavailable {
|
||||
reason: "the history contains ranked events, whose EP factors are \
|
||||
not retained after convergence",
|
||||
});
|
||||
}
|
||||
|
||||
let TimeExpanded {
|
||||
lambda,
|
||||
latest,
|
||||
width,
|
||||
..
|
||||
} = self.time_expanded_joint();
|
||||
|
||||
let ResolvedTerms {
|
||||
contrast,
|
||||
unseen,
|
||||
mean,
|
||||
} = self.resolve_terms(terms, width, |index| latest.get(&index).copied())?;
|
||||
|
||||
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 prior_var = self.sigma * self.sigma;
|
||||
let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>()
|
||||
+ unseen.values().map(|c| c * c * prior_var).sum::<f64>();
|
||||
|
||||
Ok(Gaussian::from_mv(mean, variance))
|
||||
self.joint()?.posterior_of(terms)
|
||||
}
|
||||
|
||||
/// Posterior of a linear combination, read as of `time`.
|
||||
@@ -1018,6 +987,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// stand at the end of last season" — rather than to wherever each
|
||||
/// competitor was last seen.
|
||||
///
|
||||
/// As with [`History::posterior_of`], this factorises the joint for one
|
||||
/// question; [`History::joint`] amortises that across many.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no
|
||||
@@ -1026,54 +998,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
if self.time_slices.is_empty() {
|
||||
return Err(InferenceError::JointUnavailable {
|
||||
reason: "the history has no events",
|
||||
});
|
||||
}
|
||||
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
||||
return Err(InferenceError::JointUnavailable {
|
||||
reason: "the history contains ranked events, whose EP factors are \
|
||||
not retained after convergence",
|
||||
});
|
||||
}
|
||||
|
||||
let TimeExpanded {
|
||||
lambda,
|
||||
at_slice,
|
||||
width,
|
||||
..
|
||||
} = self.time_expanded_joint();
|
||||
|
||||
// Latest appearance at or before `time`, per competitor.
|
||||
let mut as_of: HashMap<Index, (usize, usize)> = HashMap::new();
|
||||
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
|
||||
if slice.time > time {
|
||||
break;
|
||||
}
|
||||
for (agent, _) in slice.appearances() {
|
||||
if let Some(row) = at_slice.get(&(agent, slice_idx)) {
|
||||
as_of.insert(agent, (*row, slice_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ResolvedTerms {
|
||||
contrast,
|
||||
unseen,
|
||||
mean,
|
||||
} = self.resolve_terms(terms, width, |index| as_of.get(&index).copied())?;
|
||||
|
||||
let z =
|
||||
crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable {
|
||||
reason: "the precision matrix is not positive-definite",
|
||||
})?;
|
||||
|
||||
let prior_var = self.sigma * self.sigma;
|
||||
let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>()
|
||||
+ unseen.values().map(|c| c * c * prior_var).sum::<f64>();
|
||||
|
||||
Ok(Gaussian::from_mv(mean, variance))
|
||||
self.joint()?.posterior_of_at(time, terms)
|
||||
}
|
||||
|
||||
/// How much observing this matchup would shrink the variance of `target`.
|
||||
@@ -1089,6 +1014,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// score. It is also far cheaper: one linear solve rather than a full
|
||||
/// inference pass per possible outcome.
|
||||
///
|
||||
/// Scoring a field of candidates is the whole point of this call, and each
|
||||
/// candidate is one question against an unchanged fit — so use
|
||||
/// [`Joint::expected_variance_reduction`] for anything past a single
|
||||
/// candidate, or pay for the factorisation once per candidate.
|
||||
///
|
||||
/// # There is no expectation to take
|
||||
///
|
||||
/// Observing a scored event is a rank-one update to the precision matrix,
|
||||
@@ -1118,14 +1048,56 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
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(),
|
||||
});
|
||||
}
|
||||
self.joint()?.expected_variance_reduction(teams, target)
|
||||
}
|
||||
|
||||
/// Factorise the joint posterior once, to answer many questions against it.
|
||||
///
|
||||
/// [`History::posterior_of`] and its neighbours each build and factorise
|
||||
/// the joint, use it once, and drop it. The factorisation is `O(n^3)` in
|
||||
/// the history's *appearances* and depends only on the fit, so a caller
|
||||
/// asking about every pair in a standings table, every cell in a grid, or
|
||||
/// every candidate in an active-learning sweep pays for the same
|
||||
/// factorisation once per question.
|
||||
///
|
||||
/// A `Joint` pays it once. Each subsequent query is `O(n^2)` — one forward
|
||||
/// substitution — and returns exactly what the one-shot call would.
|
||||
///
|
||||
/// ```
|
||||
/// # use smallvec::smallvec;
|
||||
/// # use trueskill_tt::{Event, History, Member, Outcome, Team};
|
||||
/// # let mut h = History::builder().score_sigma(1.0).build();
|
||||
/// # let round = |x, y, sx, sy, t| Event {
|
||||
/// # time: t,
|
||||
/// # teams: smallvec![
|
||||
/// # Team::with_members([Member::new(x)]),
|
||||
/// # Team::with_members([Member::new(y)]),
|
||||
/// # ],
|
||||
/// # outcome: Outcome::scores([sx, sy]),
|
||||
/// # };
|
||||
/// # h.add_events(vec![
|
||||
/// # round("a", "b", 3.0, 1.0, 1),
|
||||
/// # round("b", "c", 2.0, 1.0, 2),
|
||||
/// # ]).unwrap();
|
||||
/// # h.converge().unwrap();
|
||||
/// let joint = h.joint()?;
|
||||
/// for (a, b) in [("a", "b"), ("a", "c"), ("b", "c")] {
|
||||
/// let gap = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)])?;
|
||||
/// println!("{a} - {b}: {:.3} +/- {:.3}", gap.mu(), gap.sigma());
|
||||
/// }
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
///
|
||||
/// The handle borrows the history, so the borrow checker enforces what a
|
||||
/// cache would otherwise have to invalidate: no events can be added and no
|
||||
/// refit can run while it is alive. Drop it to release the factorisation,
|
||||
/// which is `n^2` floats and is the largest thing this crate allocates.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `JointUnavailable` if the history is empty, contains ranked events, or
|
||||
/// yields a precision matrix that is not positive-definite.
|
||||
pub fn joint(&self) -> Result<Joint<'_, T, D, O, K>, InferenceError> {
|
||||
if self.time_slices.is_empty() {
|
||||
return Err(InferenceError::JointUnavailable {
|
||||
reason: "the history has no events",
|
||||
@@ -1138,70 +1110,28 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
});
|
||||
}
|
||||
|
||||
// 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 TimeExpanded {
|
||||
lambda,
|
||||
latest,
|
||||
at_slice,
|
||||
width,
|
||||
..
|
||||
} = self.time_expanded_joint();
|
||||
|
||||
let target = self.resolve_terms(target, width, |i| latest.get(&i).copied())?;
|
||||
let matchup = self.resolve_terms(&matchup, width, |i| latest.get(&i).copied())?;
|
||||
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(
|
||||
let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
|
||||
InferenceError::JointUnavailable {
|
||||
reason: "the precision matrix is not positive-definite",
|
||||
reason: "the precision matrix is not positive-definite, which means \
|
||||
a competitor has neither a proper prior nor any evidence",
|
||||
},
|
||||
)?;
|
||||
|
||||
let prior_var = self.sigma * self.sigma;
|
||||
// Competitors outside the history 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::<f64>()
|
||||
+ cross_unseen;
|
||||
let matchup_var: f64 = matchup_contrast
|
||||
.iter()
|
||||
.zip(&z)
|
||||
.map(|(a, z)| a * z)
|
||||
.sum::<f64>()
|
||||
+ self_unseen;
|
||||
|
||||
Ok(cross * cross / (noise + matchup_var))
|
||||
Ok(Joint {
|
||||
history: self,
|
||||
cholesky,
|
||||
latest,
|
||||
at_slice,
|
||||
width,
|
||||
})
|
||||
}
|
||||
|
||||
/// Predictive distribution of the score margin between two teams.
|
||||
///
|
||||
/// Answers "what will the gap be, and how wide is that interval" for a
|
||||
@@ -1980,6 +1910,195 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
}
|
||||
|
||||
/// A factorised joint posterior, reusable across many queries.
|
||||
///
|
||||
/// Built by [`History::joint`]. Every question the joint answers — the width of
|
||||
/// a contrast, the covariance of two, how much a candidate matchup would
|
||||
/// sharpen either — is a bilinear form in the inverse precision matrix, and all
|
||||
/// of them share one factorisation. That factorisation is the whole cost:
|
||||
/// `O(n^3)` in the history's appearances to build, `O(n^2)` per question after.
|
||||
///
|
||||
/// The handle borrows the history, so no refit can run and no events can be
|
||||
/// added while it is alive. That is what makes it correct without any
|
||||
/// invalidation logic: there is no window in which the factorisation could
|
||||
/// describe a fit that no longer exists.
|
||||
pub struct Joint<'h, T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> {
|
||||
history: &'h History<T, D, O, K>,
|
||||
cholesky: crate::joint::Cholesky,
|
||||
/// `(row, slice)` of each competitor's latest appearance.
|
||||
latest: HashMap<Index, (usize, usize)>,
|
||||
/// Row of each `(competitor, slice)` appearance.
|
||||
at_slice: HashMap<(Index, usize), usize>,
|
||||
/// Side length of the precision matrix.
|
||||
width: usize,
|
||||
}
|
||||
|
||||
/// Deliberately does not print the factorisation, which is `n^2` floats and
|
||||
/// would make a `{:?}` of a large joint unreadable and slow.
|
||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
|
||||
for Joint<'_, T, D, O, K>
|
||||
{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Joint")
|
||||
.field("variables", &self.width)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D, O, K> {
|
||||
/// Number of variables in the joint: the history's appearances, after
|
||||
/// collapsing consecutive pairs a competitor does not drift between.
|
||||
///
|
||||
/// This is what the cost scales in, and it is not the competitor count — a
|
||||
/// competitor contributes one variable per slice it appears in. Worth
|
||||
/// checking before asking for a joint over a long history.
|
||||
#[must_use]
|
||||
pub fn variables(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// Turn a resolved functional into its posterior.
|
||||
///
|
||||
/// The variance is `|L^-1 c|^2` over the competitors the history knows,
|
||||
/// plus an independent prior variance for each competitor it does not —
|
||||
/// unseen competitors are uncorrelated with everything by construction.
|
||||
fn distribution(&self, resolved: &ResolvedTerms) -> Gaussian {
|
||||
let y = self.cholesky.whiten(&resolved.contrast);
|
||||
let prior_var = self.history.sigma * self.history.sigma;
|
||||
let variance = crate::joint::bilinear(&y, &y)
|
||||
+ resolved
|
||||
.unseen
|
||||
.values()
|
||||
.map(|c| c * c * prior_var)
|
||||
.sum::<f64>();
|
||||
Gaussian::from_mv(resolved.mean, variance)
|
||||
}
|
||||
|
||||
/// Posterior of a linear combination of competitors' skills.
|
||||
///
|
||||
/// Identical to [`History::posterior_of`], including which appearance each
|
||||
/// competitor is read at, without re-paying the factorisation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `UnknownKey` for a competitor the history has never seen.
|
||||
pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
let resolved = self
|
||||
.history
|
||||
.resolve_terms(terms, self.width, |index| self.latest.get(&index).copied())?;
|
||||
Ok(self.distribution(&resolved))
|
||||
}
|
||||
|
||||
/// Posterior of a linear combination, read as of `time`.
|
||||
///
|
||||
/// Identical to [`History::posterior_of_at`] without re-paying the
|
||||
/// factorisation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `UnknownKey` for a competitor with no appearance at or before `time`.
|
||||
pub fn posterior_of_at(&self, time: T, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
let as_of = self.rows_as_of(time);
|
||||
let resolved = self
|
||||
.history
|
||||
.resolve_terms(terms, self.width, |index| as_of.get(&index).copied())?;
|
||||
Ok(self.distribution(&resolved))
|
||||
}
|
||||
|
||||
/// Latest appearance at or before `time`, per competitor.
|
||||
fn rows_as_of(&self, time: T) -> HashMap<Index, (usize, usize)> {
|
||||
let mut as_of: HashMap<Index, (usize, usize)> = HashMap::new();
|
||||
for (slice_idx, slice) in self.history.time_slices.iter().enumerate() {
|
||||
if slice.time > time {
|
||||
break;
|
||||
}
|
||||
for (agent, _) in slice.appearances() {
|
||||
if let Some(row) = self.at_slice.get(&(agent, slice_idx)) {
|
||||
as_of.insert(agent, (*row, slice_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
as_of
|
||||
}
|
||||
|
||||
/// How much observing this matchup would shrink the variance of `target`.
|
||||
///
|
||||
/// Identical to [`History::expected_variance_reduction`] without re-paying
|
||||
/// the factorisation, which is the shape this call is normally used in:
|
||||
/// one target, a field of candidate matchups, one unchanged fit.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam` for
|
||||
/// an empty one, and `UnknownKey` for an unseen competitor.
|
||||
pub fn expected_variance_reduction(
|
||||
&self,
|
||||
teams: &[&[&K]],
|
||||
target: &[(&K, f64)],
|
||||
) -> Result<f64, InferenceError>
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
// 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.history.score_sigma * self.history.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
|
||||
.history
|
||||
.keys
|
||||
.get(*key)
|
||||
.map_or(self.history.beta, |index| {
|
||||
self.history.agents[index].rating.beta
|
||||
});
|
||||
noise += beta * beta;
|
||||
}
|
||||
}
|
||||
|
||||
let row_for = |index: Index| self.latest.get(&index).copied();
|
||||
let target = self.history.resolve_terms(target, self.width, row_for)?;
|
||||
let matchup = self.history.resolve_terms(&matchup, self.width, row_for)?;
|
||||
|
||||
let y_target = self.cholesky.whiten(&target.contrast);
|
||||
let y_matchup = self.cholesky.whiten(&matchup.contrast);
|
||||
|
||||
let prior_var = self.history.sigma * self.history.sigma;
|
||||
// Competitors outside the history 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 = crate::joint::bilinear(&y_target, &y_matchup) + cross_unseen;
|
||||
let matchup_var = crate::joint::bilinear(&y_matchup, &y_matchup) + self_unseen;
|
||||
|
||||
Ok(cross * cross / (noise + matchup_var))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use approx::assert_ulps_eq;
|
||||
|
||||
Reference in New Issue
Block a user