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}