Skip to main content

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 =
249                    a / (2.0 * std::f64::consts::PI * t.powi(3)).sqrt() * (-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!(pg1_untilted_cdf(50.0) > 1.0 - 1e-9, "right tail must reach 1");
357    }
358
359    #[test]
360    fn pg1_moments_high_precision() {
361        let pg = PolyaGamma::new();
362        let sample_count = 1_000_000usize;
363        for &c in &[0.0_f64, 0.1, 1.0, 3.0, 10.0, 30.0] {
364            let mut rng = StdRng::seed_from_u64(0xC0FFEE ^ (c.to_bits().wrapping_mul(7)));
365            let mut sum = 0.0_f64;
366            let mut sum_sq = 0.0_f64;
367            for _ in 0..sample_count {
368                let sample = pg.draw(&mut rng, c);
369                sum += sample;
370                sum_sq += sample * sample;
371            }
372            let mean = sum / sample_count as f64;
373            let variance = sum_sq / sample_count as f64 - mean * mean;
374            let expected_mean = theoretical_mean(c);
375            let expected_variance = theoretical_variance(c);
376            let mean_relative_error = (mean - expected_mean).abs() / expected_mean.max(1e-12);
377            let variance_relative_error =
378                (variance - expected_variance).abs() / expected_variance.max(1e-12);
379            assert!(
380                mean_relative_error < 5e-3,
381                "PG(1,{c}) mean: empirical {mean:.6e}, theory {expected_mean:.6e}, relative error {mean_relative_error:.3e}",
382            );
383            assert!(
384                variance_relative_error < 5e-3,
385                "PG(1,{c}) variance: empirical {variance:.6e}, theory {expected_variance:.6e}, relative error {variance_relative_error:.3e}",
386            );
387        }
388    }
389}