feat!: Gaussian's EP operations stop wearing arithmetic's clothes
`Gaussian` publicly implemented `Mul`, `Div`, `Add` and `Sub`. They were
the EP product, cavity and variance-space convolutions, and every one of
them lies to a reader who takes the operator at face value:
a = N(10, 2) b = N(4, 3) c = N(1, 1)
a * b N(8.15, 1.66) not 40
a - b sigma GREW, 2 -> sqrt(4 + 9)
a * N(1, 0) mu = NaN "multiply by one"
a / c pi = -0.75 mu() prints a confident 0
The last is this crate's signature defect on a public operator. `Div` is
the cavity and can legitimately leave a negative precision, which is not
a distribution — and `mu()`/`sigma()` guard `pi <= 0` and report `0.0`
and `inf`, so it comes back as a plausible number with no panic, no
`Debug` marker and nothing to test against.
The four impls are now `pub(crate)` inherent methods that say what they
do: `ep_product`, `cavity`, `convolve`, `convolve_diff`, plus `scale`
for the one operation that genuinely is arithmetic. Nothing in a user's
workflow needed operator syntax; inference did, and it still has it.
`pi()` and `tau()` follow. Storing natural parameters is a performance
decision — it makes message passing two adds — not a contract. The
public surface is now exactly: `from_ms`, `from_mv`, `mu`, `sigma`,
`variance`, `probability_below`, `probability_above`. `from_mv` and
`variance` are promoted from `pub(crate)`; they are the honest pair for
callers who already hold a variance and should not pay a round trip
through the square root.
Four integration tests asserted bit-identity on `(pi, tau)`. They assert
it on `(mu, variance)` instead — still `assert_eq!`, still exact, and
`1/pi` and `tau/pi` are deterministic, so bit-equal natural parameters
give bit-equal moments. `a_nan_sigma_passes_through_from_ms` drops its
`|| g.pi().is_nan()` half: `sigma()` substitutes for `pi <= 0` and
`pi == inf`, so NaN survives to it only from a NaN precision.
`benches/gaussian.rs` is deleted. It timed two f64 additions through the
public operators, and keeping those public solely to feed it is the same
thing #73 objected to when a benchmark was dictating five public types.
The paths it covered are exercised by `batch` and `history_converge`
through the real call chain.
Closes #71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+31
-22
@@ -205,7 +205,12 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
||||
self.likelihoods
|
||||
.iter()
|
||||
.zip(self.teams.iter())
|
||||
.map(|(l, t)| l.iter().zip(t.iter()).map(|(&l, r)| l * r.prior).collect())
|
||||
.map(|(l, t)| {
|
||||
l.iter()
|
||||
.zip(t.iter())
|
||||
.map(|(&l, r)| l.ep_product(r.prior))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -364,7 +369,7 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
|
||||
.iter()
|
||||
.zip(self.weights[t].iter())
|
||||
.fold(N00, |p, (competitor, &w)| {
|
||||
p + (competitor.performance() * w)
|
||||
p.convolve(competitor.performance().scale(w))
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -384,28 +389,28 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
|
||||
step = (0.0_f64, 0.0_f64);
|
||||
|
||||
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
|
||||
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
|
||||
let raw = pw.convolve_diff(pl);
|
||||
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
|
||||
let d = lf.propagate(&mut arena.vars, alpha);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_ll = pw - lf.msg();
|
||||
let new_ll = pw.convolve_diff(lf.msg());
|
||||
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
|
||||
arena.lhood_lose[e + 1] = new_ll;
|
||||
}
|
||||
|
||||
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
|
||||
let e = n_diffs - 1 - rev_i;
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
|
||||
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
|
||||
let raw = pw.convolve_diff(pl);
|
||||
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
|
||||
let d = lf.propagate(&mut arena.vars, alpha);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_lw = pl + lf.msg();
|
||||
let new_lw = pl.convolve(lf.msg());
|
||||
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
|
||||
arena.lhood_win[e] = new_lw;
|
||||
}
|
||||
@@ -415,18 +420,21 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
|
||||
|
||||
// Special case: exactly 1 diff (2-team game); loop body was empty.
|
||||
if n_diffs == 1 {
|
||||
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
|
||||
- (arena.team_prior[1] * arena.lhood_win[1]);
|
||||
arena.vars.set(links[0].diff(), raw * links[0].msg());
|
||||
let raw = arena.team_prior[0]
|
||||
.ep_product(arena.lhood_lose[0])
|
||||
.convolve_diff(arena.team_prior[1].ep_product(arena.lhood_win[1]));
|
||||
arena
|
||||
.vars
|
||||
.set(links[0].diff(), raw.ep_product(links[0].msg()));
|
||||
links[0].propagate(&mut arena.vars, alpha);
|
||||
}
|
||||
|
||||
// Boundary updates: close the chain at both ends.
|
||||
if n_diffs > 0 {
|
||||
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
|
||||
arena.lhood_win[0] = pl1 + links[0].msg();
|
||||
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
|
||||
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
|
||||
let pl1 = arena.team_prior[1].ep_product(arena.lhood_win[1]);
|
||||
arena.lhood_win[0] = pl1.convolve(links[0].msg());
|
||||
let pw_last = arena.team_prior[n_teams - 2].ep_product(arena.lhood_lose[n_teams - 2]);
|
||||
arena.lhood_lose[n_teams - 1] = pw_last.convolve_diff(links[n_diffs - 1].msg());
|
||||
}
|
||||
|
||||
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
|
||||
@@ -444,7 +452,7 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
|
||||
.enumerate()
|
||||
.map(|(orig_i, (competitors, weights))| {
|
||||
let si = arena.inv_buf[orig_i];
|
||||
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
||||
let m = arena.lhood_win[si].ep_product(arena.lhood_lose[si]);
|
||||
// Already folded into `team_prior` at the top of the chain,
|
||||
// indexed by sorted position.
|
||||
let performance = arena.team_prior[si];
|
||||
@@ -452,7 +460,8 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(competitor, &w)| {
|
||||
((m - performance.exclude(competitor.performance() * w)) * (1.0 / w))
|
||||
m.convolve_diff(performance.exclude(competitor.performance().scale(w)))
|
||||
.scale(1.0 / w)
|
||||
.forget(competitor.beta.powi(2))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -504,7 +513,7 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
|
||||
.map(|(l, t)| {
|
||||
l.iter()
|
||||
.zip(t.iter())
|
||||
.map(|(&l, p)| l * p.prior)
|
||||
.map(|(&l, p)| l.ep_product(p.prior))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
Reference in New Issue
Block a user