gam_inference/polya_gamma.rs
1//! Narrow `PG(1, c)` adapter over the upstream `polya-gamma` crate.
2//!
3//! The workspace uses `rand` 0.10 while `polya-gamma` 0.5 uses `rand` 0.8.
4//! `Rand08` bridges only the old `rand_core` random-bit interface, so callers
5//! keep the workspace RNG API and the sampler implementation remains wholly
6//! upstream.
7
8use rand::Rng;
9
10/// Borrow a workspace `rand` 0.10 generator through the `rand` 0.8 interface
11/// required by `polya-gamma` 0.5. Random bits are forwarded without reseeding
12/// or buffering, preserving ownership of the caller's RNG stream.
13struct Rand08<'a, R: Rng + ?Sized>(&'a mut R);
14
15impl<R: Rng + ?Sized> rand_core_06::RngCore for Rand08<'_, R> {
16 #[inline]
17 fn next_u32(&mut self) -> u32 {
18 rand::Rng::next_u32(self.0)
19 }
20
21 #[inline]
22 fn next_u64(&mut self) -> u64 {
23 rand::Rng::next_u64(self.0)
24 }
25
26 #[inline]
27 fn fill_bytes(&mut self, dest: &mut [u8]) {
28 rand::Rng::fill_bytes(self.0, dest);
29 }
30
31 #[inline]
32 fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core_06::Error> {
33 self.fill_bytes(dest);
34 Ok(())
35 }
36}
37
38/// Sampler for the Pólya–Gamma `PG(1, c)` distribution.
39///
40/// This type intentionally exposes only the shape-one surface used by GAM's
41/// Gibbs and validation paths. Shape selection and all sampling mathematics
42/// stay inside the upstream crate.
43#[derive(Debug, Clone)]
44pub struct PolyaGamma {
45 upstream: polya_gamma::PolyaGamma,
46}
47
48impl PolyaGamma {
49 /// Construct an upstream sampler fixed at shape `b = 1`.
50 pub fn new() -> Self {
51 Self {
52 upstream: polya_gamma::PolyaGamma::new(1.0),
53 }
54 }
55
56 /// Draw a single `PG(1, c)` variate from the caller's `rand` 0.10 stream.
57 ///
58 /// A Pólya–Gamma variate is strictly positive with a continuous density,
59 /// so an exact `0.0`, a negative value, or a non-finite value can only be
60 /// a numerical artifact of the upstream sampler. Rejecting those
61 /// measure-zero artifacts preserves the target distribution without a
62 /// finite retry budget. Non-finite tilts are rejected before entering the
63 /// upstream rejection sampler, where they would make its loop undefined.
64 pub fn draw<R: Rng + ?Sized>(&self, rng: &mut R, tilt: f64) -> f64 {
65 assert!(
66 tilt.is_finite(),
67 "PG(1, c) requires a finite tilt, got {tilt}"
68 );
69 loop {
70 let v = self.upstream.draw(&mut Rand08(rng), tilt);
71 if v.is_finite() && v > 0.0 {
72 return v;
73 }
74 }
75 }
76}
77
78/// Exact cumulative distribution function of the untilted `PG(1, 0)` law,
79/// `P(X <= x)` for `X ~ PG(1, 0)`.
80///
81/// This is the analytic companion to [`PolyaGamma::draw`] at tilt `c = 0`: an
82/// exact, Monte-Carlo-free reference that lets sampler correctness be gated on
83/// distribution *shape* (KS/DKW), not just the first two moments. Moment-only
84/// gates pass for any sampler whose mean and variance happen to match, so a
85/// shape error slips through; a CDF gate does not.
86///
87/// # Derivation
88///
89/// `PG(1, 0)` has the Jacobi (theta) density
90///
91/// ```text
92/// f(x) = Σ_{k>=0} (-1)^k (2k+1) / sqrt(2π x³) · exp(-(2k+1)² / (8x)), x > 0.
93/// ```
94///
95/// Integrating term by term uses the exact identity
96///
97/// ```text
98/// d/dx [ 4 Φ(-(k+1/2) / sqrt(x)) ] = (2k+1)/sqrt(2π x³) · exp(-(2k+1)²/(8x)),
99/// ```
100///
101/// (with `Φ` the standard normal CDF and `2k+1 = 2(k+1/2)`), so, since
102/// `F(0) = 0`, the **small-argument** representation is
103///
104/// ```text
105/// F(x) = Σ_{k>=0} (-1)^k · 4 Φ(-(k+1/2) / sqrt(x)).
106/// ```
107///
108/// The theta transformation of the same law gives the **large-argument**
109/// representation
110///
111/// ```text
112/// F(x) = 1 - Σ_{k>=0} (-1)^k · (2 / (π (k+1/2))) · exp(-2π² (k+1/2)² x).
113/// ```
114///
115/// Each series is alternating and converges geometrically on the side named;
116/// the two agree at the crossover `x = 1/(2π)` (a continuity check the tests
117/// pin). The mean of the large-argument density integrates to exactly `1/4`,
118/// which is `E[PG(1,0)]`, fixing the overall normalization.
119///
120/// The small-argument branch is written with `sqrt(x)` — **not** `sqrt(2x)`.
121/// The `sqrt(2x)` form evaluates `F(2x)` instead of `F(x)`; it disagrees with
122/// the large branch at the crossover (`0.735` vs `0.419`) and with a direct
123/// definition-based `PG(1,0)` sampler for every `x <= 1/(2π)`.
124pub fn pg1_untilted_cdf(x: f64) -> f64 {
125 use gam_math::probability::normal_cdf;
126 if x <= 0.0 {
127 return 0.0;
128 }
129
130 let mut sum = 0.0;
131 let mut n = 0usize;
132 if x <= 1.0 / (2.0 * std::f64::consts::PI) {
133 loop {
134 let k = n as f64 + 0.5;
135 let term = 4.0 * normal_cdf(-k / x.sqrt());
136 sum += if n % 2 == 0 { term } else { -term };
137 if term <= f64::EPSILON {
138 break;
139 }
140 n += 1;
141 }
142 sum.clamp(0.0, 1.0)
143 } else {
144 loop {
145 let k = n as f64 + 0.5;
146 let term = 2.0 / (std::f64::consts::PI * k)
147 * (-2.0 * std::f64::consts::PI.powi(2) * k * k * x).exp();
148 sum += if n % 2 == 0 { term } else { -term };
149 if term <= f64::EPSILON {
150 break;
151 }
152 n += 1;
153 }
154 (1.0 - sum).clamp(0.0, 1.0)
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use rand::{SeedableRng, rngs::StdRng};
162
163 fn empirical_mean(c: f64, n: usize, seed: u64) -> f64 {
164 let pg = PolyaGamma::new();
165 let mut rng = StdRng::seed_from_u64(seed);
166 (0..n).map(|_| pg.draw(&mut rng, c)).sum::<f64>() / n as f64
167 }
168
169 /// `E[PG(1,c)] = tanh(c/2) / (2c)`, with limit `1/4` at `c = 0`.
170 fn theoretical_mean(c: f64) -> f64 {
171 if c.abs() < 1e-12 {
172 0.25
173 } else {
174 (0.5 * c).tanh() / (2.0 * c)
175 }
176 }
177
178 /// #2245 findings 26/27: every draw must lie in the strictly-positive
179 /// PG(1, c) support and be finite, including the extreme-tilt regime where
180 /// the exponential-tail mass computation is numerically delicate.
181 #[test]
182 fn pg1_draws_are_strictly_positive_and_finite_at_extreme_tilt() {
183 let pg = PolyaGamma::new();
184 for &c in &[0.0_f64, 1.0, 30.0, 95.0, 200.0, 700.0] {
185 let mut rng = StdRng::seed_from_u64(0xABAD_1DEA ^ c.to_bits());
186 for _ in 0..20_000 {
187 let v = pg.draw(&mut rng, c);
188 assert!(
189 v.is_finite() && v > 0.0,
190 "PG(1,{c}) draw outside strict-positive support: {v}"
191 );
192 }
193 }
194 }
195
196 #[test]
197 #[should_panic(expected = "PG(1, c) requires a finite tilt")]
198 fn pg1_rejects_non_finite_tilt_before_calling_upstream() {
199 let pg = PolyaGamma::new();
200 let mut rng = StdRng::seed_from_u64(7);
201 pg.draw(&mut rng, f64::NAN);
202 }
203
204 #[test]
205 fn pg1_mean_matches_theory() {
206 let n = 25_000;
207 for (c, tol) in [(0.0, 0.05), (1.0, 0.10), (3.0, 0.10)] {
208 let empirical = empirical_mean(c, n, 42);
209 let theoretical = theoretical_mean(c);
210 assert!(
211 (empirical - theoretical).abs() / theoretical.max(1e-12) < tol,
212 "PG(1,{c}): empirical {empirical}, theory {theoretical}",
213 );
214 }
215 }
216
217 /// `Var[PG(1,c)] = (sinh(c) - c) / (2 c³ (1 + cosh(c)))`, with
218 /// limit `1/24` at `c = 0`.
219 fn theoretical_variance(c: f64) -> f64 {
220 if c.abs() < 1e-6 {
221 1.0 / 24.0
222 } else {
223 (c.sinh() - c) / (2.0 * c * c * c * (1.0 + c.cosh()))
224 }
225 }
226
227 /// Independent, definition-based `PG(1, 0)` CDF used to pin the analytic
228 /// [`pg1_untilted_cdf`] at grid points. `PG(1, 0)` is the infinite
229 /// convolution `X = (1 / 2π²) Σ_k E_k / (k - 1/2)²` with `E_k ~ Exp(1)`;
230 /// its density has the closed form below, and Simpson quadrature of that
231 /// density is an oracle that shares no code with the series representation
232 /// under test. This is deliberately *not* the production series: it exists
233 /// so a transcription error in either representation (e.g. the historical
234 /// `sqrt(2x)` factor-2 bug) cannot hide behind a self-consistent series.
235 fn pg10_cdf_quadrature_reference(x: f64) -> f64 {
236 if x <= 0.0 {
237 return 0.0;
238 }
239 // f(t) = Σ_k (-1)^k (2k+1)/sqrt(2π t³) exp(-(2k+1)²/(8t)).
240 let density = |t: f64| -> f64 {
241 if t <= 0.0 {
242 return 0.0;
243 }
244 let mut sum = 0.0;
245 let mut k = 0usize;
246 loop {
247 let a = (2 * k + 1) as f64;
248 let term = a / (2.0 * std::f64::consts::PI * t.powi(3)).sqrt()
249 * (-a * a / (8.0 * t)).exp();
250 sum += if k % 2 == 0 { term } else { -term };
251 if term <= 1e-18 {
252 break;
253 }
254 k += 1;
255 }
256 sum
257 };
258 // Composite Simpson on [eps, x]; the density → 0 super-exponentially at
259 // 0, so a small positive floor carries no measurable mass.
260 let steps = 4_000usize;
261 let lo = 1e-6_f64.min(x * 0.5);
262 let h = (x - lo) / steps as f64;
263 let mut acc = density(lo) + density(x);
264 for i in 1..steps {
265 let t = lo + i as f64 * h;
266 acc += if i % 2 == 1 { 4.0 } else { 2.0 } * density(t);
267 }
268 (acc * h / 3.0).clamp(0.0, 1.0)
269 }
270
271 #[test]
272 fn pg10_distribution_matches_exact_cdf() {
273 let sample_count = 20_000usize;
274 let pg = PolyaGamma::new();
275 let mut rng = StdRng::seed_from_u64(0xD15C_1CDF);
276 let mut samples: Vec<f64> = (0..sample_count).map(|_| pg.draw(&mut rng, 0.0)).collect();
277 samples.sort_by(f64::total_cmp);
278
279 let n = sample_count as f64;
280 let statistic = samples
281 .iter()
282 .enumerate()
283 .map(|(i, &sample)| {
284 let cdf = pg1_untilted_cdf(sample);
285 let empirical_below = i as f64 / n;
286 let empirical_through = (i + 1) as f64 / n;
287 (cdf - empirical_below)
288 .abs()
289 .max((empirical_through - cdf).abs())
290 })
291 .fold(0.0_f64, f64::max);
292
293 // Dvoretzky–Kiefer–Wolfowitz: P(D_n > epsilon) <=
294 // 2 exp(-2 n epsilon²). Use a one-in-a-million false-rejection bound.
295 let false_rejection_probability = 1e-6_f64;
296 let critical = (-(false_rejection_probability / 2.0).ln() / (2.0 * n)).sqrt();
297 assert!(
298 statistic <= critical,
299 "PG(1,0) one-sample KS statistic {statistic} exceeds DKW critical value {critical}",
300 );
301 }
302
303 /// Pin the analytic CDF against an independent, code-disjoint quadrature
304 /// oracle on BOTH series branches. This is the direct regression for the
305 /// `sqrt(2x)` factor-2 bug: the buggy small-x branch returned `F(2x)`, so
306 /// e.g. `F(0.10)` read `0.526` instead of `0.227` — a `0.30` error the KS
307 /// test above also catches, pinned here without Monte-Carlo noise.
308 #[test]
309 fn pg1_untilted_cdf_matches_independent_quadrature_on_both_branches() {
310 let crossover = 1.0 / (2.0 * std::f64::consts::PI);
311 // Small-x branch (x <= crossover) and large-x branch (x > crossover).
312 for &x in &[0.02_f64, 0.05, 0.08, 0.12, 0.15, 0.20, 0.30, 0.50, 1.0, 2.0] {
313 let analytic = pg1_untilted_cdf(x);
314 let reference = pg10_cdf_quadrature_reference(x);
315 let branch = if x <= crossover { "small" } else { "large" };
316 assert!(
317 (analytic - reference).abs() < 2e-4,
318 "{branch}-branch CDF at x={x}: analytic {analytic:.6}, quadrature {reference:.6}",
319 );
320 }
321 }
322
323 /// A valid CDF built from two series must be continuous where they are
324 /// stitched. The factor-2 bug made the small branch evaluate `F(2x)`, so
325 /// the two branches disagreed by ~0.32 at the crossover — a discontinuity
326 /// this test forbids directly.
327 #[test]
328 fn pg1_untilted_cdf_is_continuous_at_the_series_crossover() {
329 let crossover = 1.0 / (2.0 * std::f64::consts::PI);
330 let below = pg1_untilted_cdf(crossover - 1e-9);
331 let above = pg1_untilted_cdf(crossover + 1e-9);
332 assert!(
333 (below - above).abs() < 1e-6,
334 "CDF discontinuous across the small/large series crossover: \
335 below {below:.9}, above {above:.9}",
336 );
337 }
338
339 /// Basic CDF sanity: monotone non-decreasing, in `[0, 1]`, `F(0)=0`, and
340 /// `F(x) → 1`. A branch that silently evaluated a scaled argument would
341 /// still be monotone, so this complements (does not replace) the pinned
342 /// per-point checks above.
343 #[test]
344 fn pg1_untilted_cdf_is_a_monotone_probability() {
345 assert_eq!(pg1_untilted_cdf(0.0), 0.0);
346 assert_eq!(pg1_untilted_cdf(-1.0), 0.0);
347 let mut prev = 0.0;
348 let mut x = 1e-3;
349 while x < 8.0 {
350 let f = pg1_untilted_cdf(x);
351 assert!((0.0..=1.0).contains(&f), "CDF out of [0,1] at x={x}: {f}");
352 assert!(f >= prev - 1e-12, "CDF decreased at x={x}: {prev} -> {f}");
353 prev = f;
354 x += 1e-3;
355 }
356 assert!(
357 pg1_untilted_cdf(50.0) > 1.0 - 1e-9,
358 "right tail must reach 1"
359 );
360 }
361
362 #[test]
363 fn pg1_moments_high_precision() {
364 let pg = PolyaGamma::new();
365 let sample_count = 1_000_000usize;
366 for &c in &[0.0_f64, 0.1, 1.0, 3.0, 10.0, 30.0] {
367 let mut rng = StdRng::seed_from_u64(0xC0FFEE ^ (c.to_bits().wrapping_mul(7)));
368 let mut sum = 0.0_f64;
369 let mut sum_sq = 0.0_f64;
370 for _ in 0..sample_count {
371 let sample = pg.draw(&mut rng, c);
372 sum += sample;
373 sum_sq += sample * sample;
374 }
375 let mean = sum / sample_count as f64;
376 let variance = sum_sq / sample_count as f64 - mean * mean;
377 let expected_mean = theoretical_mean(c);
378 let expected_variance = theoretical_variance(c);
379 let mean_relative_error = (mean - expected_mean).abs() / expected_mean.max(1e-12);
380 let variance_relative_error =
381 (variance - expected_variance).abs() / expected_variance.max(1e-12);
382 assert!(
383 mean_relative_error < 5e-3,
384 "PG(1,{c}) mean: empirical {mean:.6e}, theory {expected_mean:.6e}, relative error {mean_relative_error:.3e}",
385 );
386 assert!(
387 variance_relative_error < 5e-3,
388 "PG(1,{c}) variance: empirical {variance:.6e}, theory {expected_variance:.6e}, relative error {variance_relative_error:.3e}",
389 );
390 }
391 }
392}