Skip to main content

stats_claw/likelihood/
poisson.rs

1//! Poisson likelihood: the one-parameter count model for the
2//! [`PoissonLikelihood`](crate::likelihood::PoissonLikelihood).
3//!
4//! The Poisson model has a single rate parameter `λ > 0` and describes counts
5//! `x ∈ {0, 1, 2, …}`. Its log-likelihood of a sample is
6//! `ℓ(λ) = Σᵢ (xᵢ·ln λ − λ − ln Γ(xᵢ + 1))`, and the maximum-likelihood estimate
7//! is available in closed form as the sample mean `λ̂ = x̄`.
8//!
9//! [`PoissonLikelihood`](crate::likelihood::PoissonLikelihood) implements the
10//! generic [`LogLikelihood`] trait (so it can
11//! be fed to [`fit_mle`](crate::likelihood::fit_mle)) and additionally offers a
12//! [`fit`](crate::likelihood::PoissonLikelihood::fit) that returns the exact
13//! closed-form estimate.
14
15use crate::algorithms::count_to_f64;
16use crate::error::{Error, Result};
17use crate::likelihood::{LogLikelihood, MleFit};
18use crate::special::ln_gamma;
19
20/// Reports whether `x` is a valid Poisson observation: a finite, non-negative
21/// integer count expressed as an `f64`.
22///
23/// The integrality test compares `x` against its rounded value with a relational
24/// (`<= 0.0`) operator rather than `==`, which keeps it clear of the crate's
25/// `float_cmp` lint while still accepting only exact integers.
26///
27/// # Arguments
28///
29/// * `x` — a candidate observation.
30///
31/// # Returns
32///
33/// `true` iff `x` is finite, `x ≥ 0`, and `x` has no fractional part.
34fn is_count(x: f64) -> bool {
35    x.is_finite() && x >= 0.0 && (x - x.round()).abs() <= 0.0
36}
37
38impl crate::likelihood::PoissonLikelihood {
39    /// Computes the closed-form maximum-likelihood fit `λ̂ = x̄` (the sample
40    /// mean) of the Poisson rate.
41    ///
42    /// The estimate is exact, so the returned [`MleFit`] reports
43    /// [`converged`](MleFit::converged) as `true` and zero
44    /// [`iterations`](MleFit::iterations); its AIC/BIC follow the shared
45    /// information-criteria formulas with `k = 1` parameter.
46    ///
47    /// # Arguments
48    ///
49    /// * `data` — the observed counts; each entry must be a finite, non-negative
50    ///   integer, and the sample must contain at least one non-zero count.
51    ///
52    /// # Returns
53    ///
54    /// An [`MleFit`] whose single parameter is `λ̂` and whose
55    /// [`log_likelihood`](MleFit::log_likelihood) is `ℓ(λ̂; data)`.
56    ///
57    /// # Errors
58    ///
59    /// * [`Error::InsufficientData`] if `data` is empty.
60    /// * [`Error::InvalidInput`] if any observation is negative, non-integer, or
61    ///   non-finite.
62    /// * [`Error::DegenerateInput`] if every observation is zero, which drives
63    ///   `λ̂ = 0` and leaves the log-likelihood undefined (`ln 0`).
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use stats_claw::likelihood::PoissonLikelihood;
69    ///
70    /// let model = PoissonLikelihood::default();
71    /// let fit = model.fit(&[2.0, 3.0, 1.0, 5.0, 0.0, 4.0, 2.0, 3.0])?;
72    /// // The MLE of the Poisson rate is the sample mean, here 20 / 8 = 2.5.
73    /// assert!((fit.params()[0] - 2.5).abs() < 1e-12, "lambda_hat was {}", fit.params()[0]);
74    /// # Ok::<(), stats_claw::error::Error>(())
75    /// ```
76    pub fn fit(&self, data: &[f64]) -> Result<MleFit> {
77        if data.is_empty() {
78            return Err(Error::InsufficientData);
79        }
80        if data.iter().any(|&x| !is_count(x)) {
81            return Err(Error::InvalidInput(
82                "Poisson data must be finite non-negative integer counts".to_owned(),
83            ));
84        }
85        let sum: f64 = data.iter().sum();
86        let lambda_hat = sum / count_to_f64(data.len());
87        if lambda_hat <= 0.0 {
88            return Err(Error::DegenerateInput(
89                "all-zero counts drive lambda_hat to 0, where the log-likelihood is undefined"
90                    .to_owned(),
91            ));
92        }
93        let log_likelihood = self.log_likelihood(&[lambda_hat], data);
94        Ok(MleFit::from_closed_form(
95            vec![lambda_hat],
96            log_likelihood,
97            data.len(),
98        ))
99    }
100}
101
102impl LogLikelihood for crate::likelihood::PoissonLikelihood {
103    /// Returns the single free parameter count of the Poisson model, `1` (the
104    /// rate `λ`).
105    fn n_params(&self) -> usize {
106        1
107    }
108
109    /// Evaluates `ℓ([λ]; data) = Σᵢ (xᵢ·ln λ − λ − ln Γ(xᵢ + 1))`.
110    ///
111    /// Returns [`f64::NEG_INFINITY`] when `params[0] = λ ≤ 0` (or is `NaN`), and
112    /// likewise when any observation is negative, non-integer, or non-finite —
113    /// all of which lie outside the Poisson support.
114    ///
115    /// # Arguments
116    ///
117    /// * `params` — the one-element rate vector `[λ]`; a shorter slice yields
118    ///   [`f64::NEG_INFINITY`].
119    /// * `data` — the observed counts.
120    ///
121    /// # Returns
122    ///
123    /// The total log-likelihood, or [`f64::NEG_INFINITY`] outside the valid
124    /// domain.
125    fn log_likelihood(&self, params: &[f64], data: &[f64]) -> f64 {
126        let Some(&lambda) = params.first() else {
127            return f64::NEG_INFINITY;
128        };
129        if lambda.is_nan() || lambda <= 0.0 {
130            return f64::NEG_INFINITY;
131        }
132        if data.iter().any(|&x| !is_count(x)) {
133            return f64::NEG_INFINITY;
134        }
135        let ln_lambda = lambda.ln();
136        data.iter()
137            .map(|&x| x.mul_add(ln_lambda, -lambda) - ln_gamma(x + 1.0))
138            .sum()
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use crate::error::Error;
145    use crate::likelihood::LogLikelihood;
146    use crate::likelihood::PoissonLikelihood;
147
148    #[test]
149    fn fit_empty_data_is_insufficient() {
150        let model = PoissonLikelihood::default();
151        assert!(
152            matches!(model.fit(&[]), Err(Error::InsufficientData)),
153            "empty data must be rejected"
154        );
155    }
156
157    #[test]
158    fn fit_negative_count_is_invalid() {
159        let model = PoissonLikelihood::default();
160        assert!(
161            matches!(model.fit(&[1.0, -2.0, 3.0]), Err(Error::InvalidInput(_))),
162            "a negative count must be rejected"
163        );
164    }
165
166    #[test]
167    fn fit_non_integer_count_is_invalid() {
168        let model = PoissonLikelihood::default();
169        assert!(
170            matches!(model.fit(&[1.0, 2.5, 3.0]), Err(Error::InvalidInput(_))),
171            "a fractional count must be rejected"
172        );
173    }
174
175    #[test]
176    fn fit_all_zero_counts_is_degenerate() {
177        let model = PoissonLikelihood::default();
178        assert!(
179            matches!(model.fit(&[0.0, 0.0, 0.0]), Err(Error::DegenerateInput(_))),
180            "all-zero counts must be rejected"
181        );
182    }
183
184    /// Sample fixture shared across the numeric tests (sum 20, n 8, mean 2.5).
185    const DATA: [f64; 8] = [2.0, 3.0, 1.0, 5.0, 0.0, 4.0, 2.0, 3.0];
186
187    #[test]
188    fn log_likelihood_matches_scipy_at_lambda_3() {
189        // python3:
190        //   import numpy as np; from scipy.stats import poisson
191        //   data=np.array([2,3,1,5,0,4,2,3])
192        //   poisson.logpmf(data, 3.0).sum()  ->  -14.963113099343797
193        let model = PoissonLikelihood::default();
194        let got = model.log_likelihood(&[3.0], &DATA);
195        let want = -14.963_113_099_343_797;
196        assert!(
197            (got - want).abs() <= 1e-10 * want.abs(),
198            "logL(3.0) was {got}, want {want}"
199        );
200    }
201
202    #[test]
203    fn log_likelihood_matches_scipy_at_lambda_1() {
204        // python3:
205        //   poisson.logpmf(np.array([2,3,1,5,0,4,2,3]), 1.0).sum()
206        //     ->  -20.93535887270599
207        let model = PoissonLikelihood::default();
208        let got = model.log_likelihood(&[1.0], &DATA);
209        let want = -20.935_358_872_705_99;
210        assert!(
211            (got - want).abs() <= 1e-10 * want.abs(),
212            "logL(1.0) was {got}, want {want}"
213        );
214    }
215
216    #[test]
217    fn log_likelihood_non_positive_lambda_is_neg_inf() {
218        let model = PoissonLikelihood::default();
219        assert!(
220            model.log_likelihood(&[0.0], &DATA) == f64::NEG_INFINITY,
221            "lambda = 0 must give -inf"
222        );
223        assert!(
224            model.log_likelihood(&[-1.0], &DATA) == f64::NEG_INFINITY,
225            "negative lambda must give -inf"
226        );
227    }
228
229    #[test]
230    fn log_likelihood_negative_observation_is_neg_inf() {
231        let model = PoissonLikelihood::default();
232        assert!(
233            model.log_likelihood(&[2.5], &[1.0, -2.0, 3.0]) == f64::NEG_INFINITY,
234            "a negative observation must give -inf"
235        );
236    }
237
238    #[test]
239    fn log_likelihood_non_finite_observation_is_neg_inf() {
240        // Pins the D3 contract: a non-finite observation is rejected as −∞. The
241        // `is_count` guard already handles this (NaN/±∞ are not finite counts);
242        // this test locks the behavior against future refactors.
243        let model = PoissonLikelihood::default();
244        assert!(
245            model.log_likelihood(&[2.5], &[1.0, f64::NAN, 3.0]) == f64::NEG_INFINITY,
246            "a NaN observation must give -inf"
247        );
248        assert!(
249            model.log_likelihood(&[2.5], &[1.0, f64::INFINITY, 3.0]) == f64::NEG_INFINITY,
250            "a +inf observation must give -inf"
251        );
252    }
253
254    #[test]
255    fn fit_recovers_sample_mean_and_criteria() -> Result<(), Error> {
256        // python3:
257        //   lh = data.mean()  -> 2.5
258        //   ll = poisson.logpmf(data, lh).sum()  -> -14.60954423522289
259        //   aic = 2*1 - 2*ll  -> 31.21908847044578
260        //   bic = 1*math.log(8) - 2*ll  -> 31.298530012125614
261        let model = PoissonLikelihood::default();
262        let fit = model.fit(&DATA)?;
263        let lambda_hat = *fit.params().first().unwrap_or(&f64::NAN);
264        assert!(
265            (lambda_hat - 2.5).abs() <= 1e-12,
266            "lambda_hat was {lambda_hat}"
267        );
268        let ll = -14.609_544_235_222_89;
269        assert!(
270            (fit.log_likelihood() - ll).abs() <= 1e-10 * ll.abs(),
271            "logL was {}",
272            fit.log_likelihood()
273        );
274        assert!(fit.converged(), "closed-form fit must report converged");
275        assert_eq!(
276            fit.iterations(),
277            0,
278            "closed-form fit performs no iterations"
279        );
280        assert!(
281            (fit.aic() - 31.219_088_470_445_78).abs() <= 1e-10,
282            "aic was {}",
283            fit.aic()
284        );
285        assert!(
286            (fit.bic() - 31.298_530_012_125_614).abs() <= 1e-10,
287            "bic was {}",
288            fit.bic()
289        );
290        Ok(())
291    }
292
293    #[test]
294    fn fit_mle_from_perturbed_init_matches_closed_form() -> Result<(), Error> {
295        let model = PoissonLikelihood::default();
296        let closed = model.fit(&DATA)?;
297        // Start the free optimizer away from the true rate; it must recover it.
298        let numeric = crate::likelihood::fit_mle(&model, &DATA, &[1.0], 1e-10)?;
299        let numeric_lambda = *numeric.params().first().unwrap_or(&f64::NAN);
300        let closed_lambda = *closed.params().first().unwrap_or(&f64::NAN);
301        assert!(
302            (numeric_lambda - closed_lambda).abs() <= 1e-5,
303            "numeric lambda_hat {numeric_lambda} vs closed {closed_lambda}"
304        );
305        Ok(())
306    }
307}