Skip to main content

stats_claw/likelihood/
binomial.rs

1//! Binomial maximum-likelihood numerics, for the
2//! [`BinomialLikelihood`].
3//!
4//! # Model
5//!
6//! `N` independent draws from `Binomial(n_trials, p)`, observed as per-draw
7//! success counts `x ∈ {0, 1, …, n_trials}` stored as `f64`. The single free
8//! parameter is the success probability `p ∈ (0, 1)`, so `n_params() == 1`. The
9//! total log-likelihood is
10//!
11//! ```text
12//! ℓ(p; x) = Σᵢ [ ln C(n, xᵢ) + xᵢ ln p + (n − xᵢ) ln(1 − p) ]
13//! ```
14//!
15//! matching `scipy.stats.binom.logpmf(x, n, p).sum()`.
16//!
17//! # Design: where `n_trials` lives
18//!
19//! The frozen [`LogLikelihood`] trait carries
20//! no slot for the number of trials — its `log_likelihood(params, data)` sees
21//! only the free parameter vector and the data. The
22//! [`BinomialLikelihood`] struct, however, *already* carries a
23//! [`number_of_trials`](crate::likelihood::BinomialLikelihood) field, so this
24//! module attaches the numerics directly to that struct and reads `n_trials`
25//! from it (rather than introducing a separate wrapper type). This keeps the
26//! trait impl honest — `n_trials` is genuine model state owned by the model —
27//! and lets [`fit_mle`](crate::likelihood::fit_mle) fit a `&BinomialLikelihood`
28//! directly. Construct the model with the trials set, e.g.
29//! `BinomialLikelihood { number_of_trials: 10, ..Default::default() }`.
30//!
31//! # Examples
32//!
33//! ```
34//! use stats_claw::likelihood::BinomialLikelihood;
35//! use stats_claw::likelihood::LogLikelihood;
36//!
37//! let model = BinomialLikelihood { number_of_trials: 10, ..Default::default() };
38//! // scipy: binom.logpmf([3,5,2,4,6], 10, 0.4).sum() = -8.83278496896409
39//! let ll = model.log_likelihood(&[0.4], &[3.0, 5.0, 2.0, 4.0, 6.0]);
40//! assert!((ll - -8.832_784_968_964_091_4).abs() < 1e-10, "ll was {ll}");
41//! ```
42
43use crate::algorithms::count_to_f64;
44use crate::error::{Error, Result};
45use crate::likelihood::BinomialLikelihood;
46use crate::likelihood::{LogLikelihood, MleFit};
47use crate::special::ln_choose;
48
49/// Absolute tolerance within which a data point is accepted as an integer
50/// success count. Success counts are exact integers stored as `f64`, so this
51/// only forgives sub-ULP rounding noise; genuinely fractional values (e.g.
52/// `2.5`) are rejected as invalid.
53const INTEGRALITY_TOL: f64 = 1e-9;
54
55/// Upper bit used when recovering the `usize` value of an integral `f64`. Covers
56/// success counts up to `2^40`, far beyond any realistic binomial trial count.
57const FLOOR_SEARCH_TOP_BIT: usize = 1 << 40;
58
59impl BinomialLikelihood {
60    /// Closed-form maximum-likelihood fit of the success probability `p`.
61    ///
62    /// The binomial MLE is `p̂ = (Σᵢ xᵢ) / (N · n_trials)` — the pooled success
63    /// rate across all `N` draws. Because the estimate is analytic, the returned
64    /// [`MleFit`] reports `converged() == true` and `iterations() == 0`, with the
65    /// AIC/BIC computed for `k = 1` parameter over `N` observations.
66    ///
67    /// # Arguments
68    ///
69    /// * `data` — the observed per-draw success counts; each must be an integer
70    ///   in `[0, n_trials]`. Must be non-empty.
71    ///
72    /// # Returns
73    ///
74    /// An [`MleFit`] whose single parameter is `p̂`.
75    ///
76    /// # Errors
77    ///
78    /// * [`Error::InsufficientData`] if `data` is empty.
79    /// * [`Error::InvalidInput`] if this model's `number_of_trials` is not a
80    ///   positive integer, or if any observation is negative, non-integral, or
81    ///   greater than `number_of_trials`.
82    /// * [`Error::DegenerateInput`] if every observation is `0` or every
83    ///   observation is `n_trials` (so `p̂` lands on the boundary `{0, 1}` and the
84    ///   log-likelihood is degenerate).
85    ///
86    /// # Examples
87    ///
88    /// ```
89    /// use stats_claw::likelihood::BinomialLikelihood;
90    ///
91    /// let model = BinomialLikelihood { number_of_trials: 10, ..Default::default() };
92    /// let fit = model.fit(&[3.0, 5.0, 2.0, 4.0, 6.0])?;
93    /// // p̂ = 20 / (5·10) = 0.4.
94    /// assert!((fit.params()[0] - 0.4).abs() < 1e-12, "p_hat was {}", fit.params()[0]);
95    /// assert!(fit.converged());
96    /// # Ok::<(), stats_claw::error::Error>(())
97    /// ```
98    pub fn fit(&self, data: &[f64]) -> Result<MleFit> {
99        if data.is_empty() {
100            return Err(Error::InsufficientData);
101        }
102        let n = self.trials().ok_or_else(|| {
103            Error::InvalidInput("number_of_trials must be a positive integer".to_owned())
104        })?;
105        let n_f = count_to_f64(n);
106        let mut sum_x = 0.0;
107        for &x in data {
108            let k = success_count(x, n).ok_or_else(|| {
109                Error::InvalidInput(format!(
110                    "observation {x} is not an integer success count in [0, {n}]"
111                ))
112            })?;
113            sum_x += count_to_f64(k);
114        }
115        let total = count_to_f64(data.len()) * n_f;
116        let p_hat = sum_x / total;
117        if !(p_hat > 0.0 && p_hat < 1.0) {
118            return Err(Error::DegenerateInput(format!(
119                "all observations on the boundary (p_hat = {p_hat}); \
120                 the binomial log-likelihood is degenerate"
121            )));
122        }
123        let log_likelihood = self.log_likelihood(&[p_hat], data);
124        Ok(MleFit::from_closed_form(
125            vec![p_hat],
126            log_likelihood,
127            data.len(),
128        ))
129    }
130
131    /// Returns this model's number of trials as a positive `usize`, or `None`
132    /// when the stored `number_of_trials` is zero or negative (an unusable
133    /// model).
134    fn trials(&self) -> Option<usize> {
135        usize::try_from(self.number_of_trials)
136            .ok()
137            .filter(|&n| n > 0)
138    }
139}
140
141impl LogLikelihood for BinomialLikelihood {
142    /// The binomial has a single free parameter, the success probability `p`.
143    fn n_params(&self) -> usize {
144        1
145    }
146
147    /// Evaluates `ℓ(p; data)` for the success probability `params[0]`.
148    ///
149    /// Returns [`f64::NEG_INFINITY`] outside the valid domain: when
150    /// `number_of_trials` is not positive, `p ∉ (0, 1)`, or any observation is
151    /// negative, non-integral, or exceeds `number_of_trials`.
152    fn log_likelihood(&self, params: &[f64], data: &[f64]) -> f64 {
153        let Some(&p) = params.first() else {
154            return f64::NEG_INFINITY;
155        };
156        let Some(n) = self.trials() else {
157            return f64::NEG_INFINITY;
158        };
159        if !(p > 0.0 && p < 1.0) {
160            return f64::NEG_INFINITY;
161        }
162        let ln_p = p.ln();
163        let ln_q = (1.0 - p).ln();
164        let n_f = count_to_f64(n);
165        let mut sum = 0.0;
166        for &x in data {
167            let Some(k) = success_count(x, n) else {
168                return f64::NEG_INFINITY;
169            };
170            let k_f = count_to_f64(k);
171            // ln C(n, k) + k ln p + (n − k) ln(1 − p).
172            sum += ln_choose(n, k) + k_f.mul_add(ln_p, (n_f - k_f) * ln_q);
173        }
174        sum
175    }
176}
177
178/// Validates and converts one observation to an integer success count.
179///
180/// # Arguments
181///
182/// * `x` — a candidate observation.
183/// * `n_trials` — the binomial trial count; the returned count must not exceed
184///   it.
185///
186/// # Returns
187///
188/// `Some(k)` when `x` is a finite, non-negative integer (within
189/// [`INTEGRALITY_TOL`]) no greater than `n_trials`; `None` for negative,
190/// non-integral, non-finite, or out-of-range `x`.
191fn success_count(x: f64, n_trials: usize) -> Option<usize> {
192    if !x.is_finite() || x < 0.0 {
193        return None;
194    }
195    let rounded = x.round();
196    if (x - rounded).abs() > INTEGRALITY_TOL {
197        return None;
198    }
199    let k = f64_integer_to_usize(rounded)?;
200    (k <= n_trials).then_some(k)
201}
202
203/// Recovers the `usize` value of a non-negative, exactly integral `f64` without
204/// an `as` cast (which the crate's style gate forbids in `src/`).
205///
206/// # Arguments
207///
208/// * `x` — a non-negative `f64` that is an exact integer (e.g. the output of
209///   [`f64::round`]).
210///
211/// # Returns
212///
213/// `Some(k)` such that `count_to_f64(k) == x`, or `None` if `x` is negative or
214/// lies beyond the [`FLOOR_SEARCH_TOP_BIT`] search range.
215fn f64_integer_to_usize(x: f64) -> Option<usize> {
216    if x < 0.0 {
217        return None;
218    }
219    let mut acc: usize = 0;
220    let mut bit: usize = FLOOR_SEARCH_TOP_BIT;
221    while bit > 0 {
222        let candidate = acc | bit;
223        if count_to_f64(candidate) <= x {
224            acc = candidate;
225        }
226        bit >>= 1;
227    }
228    // `x` is an exact integer, so an in-range search reproduces it exactly; a
229    // half-unit tolerance both confirms the match and rejects out-of-range `x`.
230    ((count_to_f64(acc) - x).abs() < 0.5).then_some(acc)
231}
232
233#[cfg(test)]
234mod tests;