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;