Skip to main content

gam_solve/inference/
pg_gate_evidence.rs

1//! Deterministic Pólya–Gamma gate-block evidence for logit SAE gates (#1016).
2//!
3//! The gate/assignment-logit block is the weakest Gaussian piece of the SAE
4//! evidence: a Laplace approximation there replaces a skew logistic posterior
5//! with a single quadratic, and near a birth event (gate logits ≈ 0) the
6//! logistic block is *least* Gaussian, so the `K` vs `K+1` Occam comparison is
7//! mispriced on both sides. The PG augmentation makes the gate block Gaussian
8//! conditional on independent augmentation variables `ω_i`. This module uses
9//! the exact first two moments of each `PG(b_i, ψ_i)` law and a deterministic
10//! second-order cumulant expansion around `ω̄ = E[ω]`; the neglected error is
11//! the third- and higher-order joint cumulant contribution. The result is a
12//! deterministic approximate likelihood correction, not an exact marginal.
13//!
14//! ## The conditional-Gaussian block
15//!
16//! For a gate block with design `X_g` (n × d_g), shape vector `b`, binomial
17//! responses `y`, offset `o`, and `κ = y − b/2`, the negative log integrand
18//! conditional on `ω` is, in the gate coordinates `g`,
19//!
20//! ```text
21//! F_ω(g) = c_ω + ½ gᵀ Q_ω g − h_ωᵀ g
22//! Q_ω    = H_rest,gg + S_g + X_gᵀ Ω X_g           (Ω = diag(ω))
23//! h_ω    = h_rest,g + X_gᵀ (κ − Ω o)
24//! ```
25//!
26//! so the Gaussian integral is closed:
27//!
28//! ```text
29//! −log ∫ exp(−F_ω(g)) dg
30//!   = c_ω − ½ h_ωᵀ Q_ω⁻¹ h_ω + ½ log|Q_ω| − ½ d_g log(2π).
31//! ```
32//!
33//! The `ω`-independent constant `c_ω` collects the `2^{−b}` PSW prefactor and
34//! any `H_rest` / `h_rest` constant; it cancels in every consumer that uses the
35//! gate evidence as a *correction* (the difference between the PG block and the
36//! plain Laplace gate block), so we drop it and document that the returned
37//! value is the gate block up to that fixed additive constant.
38//!
39//! The marginal over independent `ω_i` is approximated by expanding
40//! `log E[exp(-V(ω))]` around the moment-matched point:
41//!
42//! ```text
43//! log E[exp(-V(ω))]
44//!   = -V(ω̄) + ½ Σ_i Var(ω_i) · ((∂_i V)^2 - ∂_{ii} V)
45//!     + third- and higher-order cumulants.
46//! ```
47//!
48//! where `V(ω) = ½ log|Q_ω| − ½ h_ωᵀ Q_ω⁻¹ h_ω` is the `ω`-dependent part of
49//! `F_ω` after the Gaussian integral.
50
51use crate::inference::pg_moments::pg_moments;
52use faer::Side;
53use gam_linalg::faer_ndarray::{FaerArrayView, factorize_symmetricwith_fallback};
54use gam_linalg::matrix::FactorizedSystem;
55use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
56
57/// The data of one logit gate block to be evidence-integrated.
58///
59/// All matrices are in the *gate coordinates* `g` (dimension `d_g`). The
60/// `h_rest` / `hess_rest` blocks carry whatever the surrounding arrow Schur
61/// system contributes to the gate coordinates from the rest of the model
62/// (decoder/coordinate cross-terms already Schur-folded in); pass zeros when the
63/// gate block is isolated.
64pub struct GateBlock<'a> {
65    /// Gate design `X_g`, shape (n, d_g): row `i` is `x_i` with `ψ_i = x_iᵀγ + o_i`.
66    pub design: ArrayView2<'a, f64>,
67    /// Binomial responses `y_i` (counts; `0..=b_i`).
68    pub y: ArrayView1<'a, f64>,
69    /// Binomial shapes `b_i` (`1.0` for Bernoulli).
70    pub b: ArrayView1<'a, f64>,
71    /// Per-row offset `o_i` (the fixed part of the gate logit). Empty ⇒ zeros.
72    pub offset: Option<ArrayView1<'a, f64>>,
73    /// Current gate linear predictor `ψ̂_i` used to tilt the PG law (the inner
74    /// optimum's logits). Empty ⇒ the untilted `PG(b, 0)` rule.
75    pub psi_hat: Option<ArrayView1<'a, f64>>,
76    /// Penalty `S_g` on the gate coordinates (d_g × d_g, SPD-or-PSD). Empty ⇒ zero.
77    pub penalty: Option<ArrayView2<'a, f64>>,
78    /// Rest-of-model Hessian contribution to the gate coordinates `H_rest,gg`
79    /// (d_g × d_g). Empty ⇒ zero.
80    pub hess_rest: Option<ArrayView2<'a, f64>>,
81    /// Rest-of-model linear contribution `h_rest,g` (length d_g). Empty ⇒ zero.
82    pub h_rest: Option<ArrayView1<'a, f64>>,
83}
84
85/// Which deterministic lane priced the gate block.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum PgGateLane {
88    /// Deterministic second-order independent-row PG correction around `E[ω]`.
89    CurvatureCorrected,
90    /// Single moment-matched node `ω = E[PG]`: deterministic but only
91    /// first-order in the `ω` integral. The cheap debug comparator.
92    MomentMatched,
93}
94
95/// The PG-corrected gate-block evidence.
96#[derive(Clone, Debug)]
97pub struct PgGateEvidence {
98    /// `−log p(y | rest)` for the gate block, up to the fixed additive constant
99    /// `c_ω` documented in the module header (drops out of every correction).
100    pub neg_log_evidence: f64,
101    /// The lane that produced it.
102    pub lane: PgGateLane,
103}
104
105/// Compute the deterministic second-order PG gate-block evidence correction.
106pub fn pg_gate_evidence(block: &GateBlock<'_>) -> Result<PgGateEvidence, String> {
107    evaluate(block, Lane::CurvatureCorrected)
108}
109
110enum Lane {
111    CurvatureCorrected,
112}
113
114fn evaluate(block: &GateBlock<'_>, lane: Lane) -> Result<PgGateEvidence, String> {
115    let n = block.design.nrows();
116    let d_g = block.design.ncols();
117    if d_g == 0 {
118        return Err("PG gate evidence requires a non-empty gate design".into());
119    }
120    if block.y.len() != n || block.b.len() != n {
121        return Err("PG gate evidence: y/b length must match design rows".into());
122    }
123    let psi_hat = block.psi_hat;
124    if let Some(offset) = block.offset {
125        if offset.len() != n {
126            return Err("PG gate evidence: offset length must match design rows".into());
127        }
128    }
129    if let Some(psi) = psi_hat {
130        if psi.len() != n {
131            return Err("PG gate evidence: psi_hat length must match design rows".into());
132        }
133    }
134    if let Some(penalty) = block.penalty {
135        if penalty.nrows() != d_g || penalty.ncols() != d_g {
136            return Err("PG gate evidence: penalty shape must match gate dimension".into());
137        }
138    }
139    if let Some(hess_rest) = block.hess_rest {
140        if hess_rest.nrows() != d_g || hess_rest.ncols() != d_g {
141            return Err("PG gate evidence: hess_rest shape must match gate dimension".into());
142        }
143    }
144    if let Some(h_rest) = block.h_rest {
145        if h_rest.len() != d_g {
146            return Err("PG gate evidence: h_rest length must match gate dimension".into());
147        }
148    }
149
150    // κ = y − b/2.
151    let kappa: Array1<f64> = &block.y.to_owned() - &(&block.b.to_owned() * 0.5);
152
153    // Per-row independent PG moments under the tilted law at ψ̂.
154    let mut omega_bar = Array1::<f64>::zeros(n);
155    let mut omega_var = Array1::<f64>::zeros(n);
156    for i in 0..n {
157        let c = psi_hat.map(|p| p[i]).unwrap_or(0.0);
158        let moments = pg_moments(block.b[i], c);
159        omega_bar[i] = moments.mean;
160        omega_var[i] = moments.variance;
161    }
162
163    // h_const = h_rest,g + X_gᵀ κ  (the ω-independent part of h_ω, minus the
164    // ω·o piece handled at evaluation time).
165    let xt_kappa = block.design.t().dot(&kappa);
166    let h_const = match block.h_rest {
167        Some(hr) => &hr.to_owned() + &xt_kappa,
168        None => xt_kappa,
169    };
170
171    // Assemble the ω-independent base of Q: H_rest,gg + S_g.
172    let mut q_base = Array2::<f64>::zeros((d_g, d_g));
173    if let Some(hr) = block.hess_rest {
174        q_base += &hr;
175    }
176    if let Some(s) = block.penalty {
177        q_base += &s;
178    }
179
180    let eval = evaluate_at_omega(block, q_base.view(), h_const.view(), omega_bar.view())?;
181    let correction = match lane {
182        Lane::CurvatureCorrected => {
183            second_order_correction(eval.first.view(), eval.second.view(), omega_var.view())
184        }
185    };
186    let log_two_pi = (2.0 * std::f64::consts::PI).ln();
187    let neg_log_evidence = eval.value - 0.5 * d_g as f64 * log_two_pi - 0.5 * correction;
188    let lane_tag = match lane {
189        Lane::CurvatureCorrected => PgGateLane::CurvatureCorrected,
190    };
191    Ok(PgGateEvidence {
192        neg_log_evidence,
193        lane: lane_tag,
194    })
195}
196
197struct OmegaEvaluation {
198    value: f64,
199    first: Array1<f64>,
200    second: Array1<f64>,
201}
202
203fn evaluate_at_omega(
204    block: &GateBlock<'_>,
205    q_base: ArrayView2<'_, f64>,
206    h_const: ArrayView1<'_, f64>,
207    omega_diag: ArrayView1<'_, f64>,
208) -> Result<OmegaEvaluation, String> {
209    let n = block.design.nrows();
210    let mut q_mat = q_base.to_owned();
211    weighted_gram_into(block.design, omega_diag.view(), &mut q_mat);
212
213    let mut h = h_const.to_owned();
214    if let Some(o) = block.offset {
215        let omega_o = &omega_diag.to_owned() * &o.to_owned();
216        let xt_omega_o = block.design.t().dot(&omega_o);
217        h -= &xt_omega_o;
218    }
219
220    let q_view = FaerArrayView::new(&q_mat);
221    let factor = factorize_symmetricwith_fallback(q_view.as_ref(), Side::Lower)
222        .map_err(|e| format!("PG gate block factorization failed: {e:?}"))?;
223    let log_det = factor.logdet();
224    if !log_det.is_finite() {
225        return Err("PG gate block Hessian is not positive definite".into());
226    }
227    let q_inv_h = FactorizedSystem::solve(&factor, &h)?;
228    let quad = h.dot(&q_inv_h);
229    let value = 0.5 * log_det - 0.5 * quad;
230
231    let rhs = block.design.t().to_owned();
232    let q_inv_xt = FactorizedSystem::solvemulti(&factor, &rhs)?;
233    let mut first = Array1::<f64>::zeros(n);
234    let mut second = Array1::<f64>::zeros(n);
235    for i in 0..n {
236        let row = block.design.row(i);
237        let solved_x = q_inv_xt.column(i);
238        let t = row.dot(&solved_x);
239        let w = row.dot(&q_inv_h);
240        let offset = block.offset.map(|o| o[i]).unwrap_or(0.0);
241        first[i] = 0.5 * t + offset * w + 0.5 * w * w;
242        let shifted_w = offset + w;
243        second[i] = -0.5 * t * t - t * shifted_w * shifted_w;
244    }
245    Ok(OmegaEvaluation {
246        value,
247        first,
248        second,
249    })
250}
251
252fn second_order_correction(
253    first: ArrayView1<'_, f64>,
254    second: ArrayView1<'_, f64>,
255    variance: ArrayView1<'_, f64>,
256) -> f64 {
257    first
258        .iter()
259        .zip(second.iter())
260        .zip(variance.iter())
261        .map(|((&d_v, &d2_v), &var)| var * (d_v * d_v - d2_v))
262        .sum()
263}
264
265/// Accumulate `Xᵀ diag(w) X` into `out` (d × d), row-streaming so the n × d
266/// design is never densely reweighted in place.
267fn weighted_gram_into(x: ArrayView2<'_, f64>, w: ArrayView1<'_, f64>, out: &mut Array2<f64>) {
268    let d = x.ncols();
269    for (row, &wi) in x.rows().into_iter().zip(w.iter()) {
270        if wi == 0.0 {
271            continue;
272        }
273        for a in 0..d {
274            let xa = row[a] * wi;
275            for c in a..d {
276                let v = xa * row[c];
277                out[[a, c]] += v;
278                if c != a {
279                    out[[c, a]] += v;
280                }
281            }
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use ndarray::{Array1, Array2, array};
290
291    fn assemble_terms(block: &GateBlock<'_>) -> (Array2<f64>, Array1<f64>, Array1<f64>) {
292        let d_g = block.design.ncols();
293        let kappa: Array1<f64> = &block.y.to_owned() - &(&block.b.to_owned() * 0.5);
294        let xt_kappa = block.design.t().dot(&kappa);
295        let h_const = match block.h_rest {
296            Some(hr) => &hr.to_owned() + &xt_kappa,
297            None => xt_kappa,
298        };
299        let mut q_base = Array2::<f64>::zeros((d_g, d_g));
300        if let Some(hr) = block.hess_rest {
301            q_base += &hr;
302        }
303        if let Some(s) = block.penalty {
304            q_base += &s;
305        }
306        let mut omega_bar = Array1::<f64>::zeros(block.design.nrows());
307        for i in 0..block.design.nrows() {
308            let c = block.psi_hat.map(|p| p[i]).unwrap_or(0.0);
309            omega_bar[i] = pg_moments(block.b[i], c).mean;
310        }
311        (q_base, h_const, omega_bar)
312    }
313
314    /// Determinism: identical inputs produce byte-identical evidence, no RNG.
315    #[test]
316    fn evidence_is_bit_deterministic() {
317        let design = array![[1.0, 0.2], [1.0, -0.5], [1.0, 0.9], [1.0, -0.1]];
318        let y = array![1.0, 0.0, 1.0, 0.0];
319        let b = Array1::<f64>::ones(4);
320        let s = Array2::<f64>::eye(2);
321        let mk = || GateBlock {
322            design: design.view(),
323            y: y.view(),
324            b: b.view(),
325            offset: None,
326            psi_hat: None,
327            penalty: Some(s.view()),
328            hess_rest: None,
329            h_rest: None,
330        };
331        let a = pg_gate_evidence(&mk()).unwrap();
332        let c = pg_gate_evidence(&mk()).unwrap();
333        assert_eq!(a.neg_log_evidence.to_bits(), c.neg_log_evidence.to_bits());
334        assert_eq!(a.lane, c.lane);
335    }
336
337    #[test]
338    fn derivatives_match_refactorized_finite_differences() {
339        let design = array![[1.0, 0.3], [-0.4, 1.2], [0.8, -0.7]];
340        let y = array![1.0, 0.0, 1.0];
341        let b = array![1.0, 2.0, 1.5];
342        let offset = array![0.2, -0.1, 0.4];
343        let psi = array![0.1, -0.5, 0.8];
344        let penalty = array![[2.0, 0.2], [0.2, 1.5]];
345        let hess_rest = array![[0.7, 0.1], [0.1, 0.9]];
346        let h_rest = array![0.3, -0.2];
347        let block = GateBlock {
348            design: design.view(),
349            y: y.view(),
350            b: b.view(),
351            offset: Some(offset.view()),
352            psi_hat: Some(psi.view()),
353            penalty: Some(penalty.view()),
354            hess_rest: Some(hess_rest.view()),
355            h_rest: Some(h_rest.view()),
356        };
357        let (q_base, h_const, omega_bar) = assemble_terms(&block);
358        let eval =
359            evaluate_at_omega(&block, q_base.view(), h_const.view(), omega_bar.view()).unwrap();
360        let eps = 1e-5;
361        for i in 0..omega_bar.len() {
362            let mut omega_plus = omega_bar.clone();
363            let mut omega_minus = omega_bar.clone();
364            omega_plus[i] += eps;
365            omega_minus[i] -= eps;
366            let plus = evaluate_at_omega(&block, q_base.view(), h_const.view(), omega_plus.view())
367                .unwrap();
368            let minus =
369                evaluate_at_omega(&block, q_base.view(), h_const.view(), omega_minus.view())
370                    .unwrap();
371            let first_fd = (plus.value - minus.value) / (2.0 * eps);
372            let second_fd = (plus.value - 2.0 * eval.value + minus.value) / (eps * eps);
373            let first_scale = eval.first[i].abs().max(first_fd.abs()).max(1.0);
374            let second_scale = eval.second[i].abs().max(second_fd.abs()).max(1.0);
375            assert!(
376                (eval.first[i] - first_fd).abs() <= 1e-7 * first_scale,
377                "row {i}: analytic first {} vs finite difference {first_fd}",
378                eval.first[i],
379            );
380            assert!(
381                (eval.second[i] - second_fd).abs() <= 1e-5 * second_scale,
382                "row {i}: analytic second {} vs finite difference {second_fd}",
383                eval.second[i],
384            );
385        }
386    }
387
388    #[test]
389    fn duplicated_row_correction_uses_independent_variances() {
390        let design = array![[1.0], [1.0]];
391        let y = array![1.0, 1.0];
392        let b = array![2.0, 2.0];
393        let penalty = array![[2.0]];
394        let block = GateBlock {
395            design: design.view(),
396            y: y.view(),
397            b: b.view(),
398            offset: None,
399            psi_hat: None,
400            penalty: Some(penalty.view()),
401            hess_rest: None,
402            h_rest: None,
403        };
404        let (q_base, h_const, omega_bar) = assemble_terms(&block);
405        let eval =
406            evaluate_at_omega(&block, q_base.view(), h_const.view(), omega_bar.view()).unwrap();
407        let variance = array![pg_moments(2.0, 0.0).variance, pg_moments(2.0, 0.0).variance];
408        let first_row = variance[0] * (eval.first[0] * eval.first[0] - eval.second[0]);
409        let second_row = variance[1] * (eval.first[1] * eval.first[1] - eval.second[1]);
410        let correction =
411            second_order_correction(eval.first.view(), eval.second.view(), variance.view());
412
413        assert!((variance[0] - 1.0 / 12.0).abs() < 1e-15);
414        assert!(first_row > 0.0);
415        assert!((first_row - second_row).abs() < 1e-15);
416        assert!((correction - 2.0 * first_row).abs() < 1e-15);
417        assert!((correction - 4.0 * first_row).abs() > first_row);
418    }
419
420}