1use 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
57pub struct GateBlock<'a> {
65 pub design: ArrayView2<'a, f64>,
67 pub y: ArrayView1<'a, f64>,
69 pub b: ArrayView1<'a, f64>,
71 pub offset: Option<ArrayView1<'a, f64>>,
73 pub psi_hat: Option<ArrayView1<'a, f64>>,
76 pub penalty: Option<ArrayView2<'a, f64>>,
78 pub hess_rest: Option<ArrayView2<'a, f64>>,
81 pub h_rest: Option<ArrayView1<'a, f64>>,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum PgGateLane {
88 CurvatureCorrected,
90 MomentMatched,
93}
94
95#[derive(Clone, Debug)]
97pub struct PgGateEvidence {
98 pub neg_log_evidence: f64,
101 pub lane: PgGateLane,
103}
104
105pub 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 let kappa: Array1<f64> = &block.y.to_owned() - &(&block.b.to_owned() * 0.5);
152
153 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 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 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
265fn 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 #[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}