stats_claw/likelihood/exponential.rs
1//! Exponential-distribution maximum-likelihood, for the
2//! [`ExponentialLikelihood`](crate::likelihood::ExponentialLikelihood).
3//!
4//! The model is the rate parameterization `f(x; λ) = λ·e^(−λx)` for `x ≥ 0`,
5//! `λ > 0`, so the total log-likelihood of a sample is
6//! `ℓ(λ; x) = n·ln λ − λ·Σxᵢ`. This is `scipy.stats.expon` with `scale = 1/λ`.
7//! [`ExponentialLikelihood`](crate::likelihood::ExponentialLikelihood) supplies the
8//! parameter struct; the numerics — the [`LogLikelihood`] impl and the
9//! closed-form MLE `λ̂ = n / Σxᵢ` in [`fit`](crate::likelihood::ExponentialLikelihood::fit)
10//! — are written here.
11//!
12//! # Examples
13//!
14//! ```
15//! use stats_claw::likelihood::ExponentialLikelihood;
16//!
17//! let model = ExponentialLikelihood::default();
18//! // Closed-form rate MLE of [0.5, 1.2, 2.3, 0.8, 3.1] is 5 / 7.9.
19//! let fit = model.fit(&[0.5, 1.2, 2.3, 0.8, 3.1])?;
20//! assert!((fit.params()[0] - 5.0 / 7.9).abs() < 1e-12, "lambda_hat was {}", fit.params()[0]);
21//! # Ok::<(), stats_claw::error::Error>(())
22//! ```
23
24use crate::algorithms::count_to_f64;
25use crate::error::{Error, Result};
26use crate::likelihood::{LogLikelihood, MleFit};
27
28impl crate::likelihood::ExponentialLikelihood {
29 /// Fits this exponential model to `data` by its closed-form maximum-likelihood
30 /// estimate `λ̂ = n / Σxᵢ`.
31 ///
32 /// The rate MLE is analytic, so the returned [`MleFit`] reports
33 /// [`converged`](MleFit::converged) `= true` and zero
34 /// [`iterations`](MleFit::iterations); its
35 /// [`log_likelihood`](MleFit::log_likelihood) is `ℓ(λ̂; data)` and the AIC/BIC
36 /// follow from `k = 1` parameter and `n = data.len()`.
37 ///
38 /// # Arguments
39 ///
40 /// * `data` — the observed sample; every value must be `≥ 0` (the exponential
41 /// support) and the sample must be non-empty with a strictly positive sum.
42 ///
43 /// # Returns
44 ///
45 /// An [`MleFit`] whose single parameter is the rate estimate `λ̂`.
46 ///
47 /// # Errors
48 ///
49 /// * [`Error::InsufficientData`] if `data` is empty.
50 /// * [`Error::InvalidInput`] if any observation is negative (outside the
51 /// exponential support) or non-finite.
52 /// * [`Error::DegenerateInput`] if every observation is zero (`Σxᵢ = 0`), which
53 /// would make `λ̂ = n / 0` infinite.
54 ///
55 /// # Examples
56 ///
57 /// ```
58 /// use stats_claw::likelihood::ExponentialLikelihood;
59 ///
60 /// let fit = ExponentialLikelihood::default().fit(&[1.0, 2.0, 3.0])?;
61 /// // λ̂ = 3 / 6 = 0.5.
62 /// assert!((fit.params()[0] - 0.5).abs() < 1e-12, "lambda_hat was {}", fit.params()[0]);
63 /// assert!(fit.converged());
64 /// # Ok::<(), stats_claw::error::Error>(())
65 /// ```
66 pub fn fit(&self, data: &[f64]) -> Result<MleFit> {
67 if data.is_empty() {
68 return Err(Error::InsufficientData);
69 }
70 let mut sum = 0.0_f64;
71 for &x in data {
72 if !x.is_finite() || x < 0.0 {
73 return Err(Error::InvalidInput(format!(
74 "exponential data must be finite and >= 0, got {x}"
75 )));
76 }
77 sum += x;
78 }
79 if sum <= 0.0 {
80 return Err(Error::DegenerateInput(
81 "all observations are zero, so the rate MLE n / sum(x) is infinite".to_owned(),
82 ));
83 }
84 let n = count_to_f64(data.len());
85 let lambda_hat = n / sum;
86 // ℓ(λ̂) = n·ln λ̂ − λ̂·Σx, evaluated through the trait for a single source
87 // of truth for the formula.
88 let log_likelihood = self.log_likelihood(&[lambda_hat], data);
89 Ok(MleFit::from_closed_form(
90 vec![lambda_hat],
91 log_likelihood,
92 data.len(),
93 ))
94 }
95}
96
97impl LogLikelihood for crate::likelihood::ExponentialLikelihood {
98 /// Returns `1`: the exponential rate model has the single parameter `λ`.
99 ///
100 /// # Examples
101 ///
102 /// ```
103 /// use stats_claw::likelihood::ExponentialLikelihood;
104 /// use stats_claw::likelihood::LogLikelihood;
105 ///
106 /// assert_eq!(ExponentialLikelihood::default().n_params(), 1);
107 /// ```
108 fn n_params(&self) -> usize {
109 1
110 }
111
112 /// Evaluates the total exponential log-likelihood `ℓ(λ; data) = n·ln λ − λ·Σxᵢ`.
113 ///
114 /// # Arguments
115 ///
116 /// * `params` — the one-element rate vector `[λ]`; only `params[0]` is read.
117 /// * `data` — the observed sample.
118 ///
119 /// # Returns
120 ///
121 /// The scalar log-likelihood, or [`f64::NEG_INFINITY`] when `λ ≤ 0`, or any
122 /// observation is negative or non-finite — all lie outside the model's valid
123 /// domain. Per the [`LogLikelihood`] contract a non-finite observation (`NaN`
124 /// or `±∞`) yields `−∞` rather than propagating a `NaN`/incidental `−∞`.
125 ///
126 /// # Examples
127 ///
128 /// ```
129 /// use stats_claw::likelihood::ExponentialLikelihood;
130 /// use stats_claw::likelihood::LogLikelihood;
131 ///
132 /// let model = ExponentialLikelihood::default();
133 /// // ℓ(1; [1, 2]) = 2·ln 1 − 1·3 = −3.
134 /// assert!((model.log_likelihood(&[1.0], &[1.0, 2.0]) + 3.0).abs() < 1e-12);
135 /// // A non-positive rate is outside the domain.
136 /// assert_eq!(model.log_likelihood(&[0.0], &[1.0]), f64::NEG_INFINITY);
137 /// ```
138 fn log_likelihood(&self, params: &[f64], data: &[f64]) -> f64 {
139 let lambda = *params.first().unwrap_or(&0.0);
140 // A non-positive rate is outside the domain; `is_nan` guards the case a
141 // caller passes a NaN rate directly (for which `lambda <= 0.0` is false).
142 if lambda <= 0.0 || lambda.is_nan() {
143 return f64::NEG_INFINITY;
144 }
145 let mut sum = 0.0_f64;
146 for &x in data {
147 // A non-finite observation (NaN/±∞) is outside the support; guard it
148 // explicitly since `x < 0.0` is false for NaN and `+∞` would leak an
149 // incidental `−∞` through `−λ·Σx`.
150 if !x.is_finite() || x < 0.0 {
151 return f64::NEG_INFINITY;
152 }
153 sum += x;
154 }
155 let n = count_to_f64(data.len());
156 // n·ln λ − λ·Σx.
157 n.mul_add(lambda.ln(), -lambda * sum)
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use crate::error::Error;
164 use crate::likelihood::ExponentialLikelihood;
165 use crate::likelihood::{LogLikelihood, fit_mle};
166
167 /// Shared golden sample. Reference values below were produced by:
168 ///
169 /// ```python
170 /// import numpy as np
171 /// from scipy import stats
172 /// data = np.array([0.5, 1.2, 2.3, 0.8, 3.1])
173 /// n, sx = len(data), data.sum() # n = 5, sx = 7.9
174 /// lam_hat = n / sx # 0.6329113924050632
175 /// stats.expon.logpdf(data, scale=1/0.5).sum() # -7.415735902799727
176 /// stats.expon.logpdf(data, scale=1/lam_hat).sum() # -7.287124235194378 (= ll at lam_hat)
177 /// 2*1 - 2*ll_hat # aic = 16.574248470388756
178 /// 1*np.log(n) - 2*ll_hat # bic = 16.183686382822856
179 /// ```
180 const DATA: [f64; 5] = [0.5, 1.2, 2.3, 0.8, 3.1];
181 /// Closed-form rate MLE `λ̂ = 5 / 7.9`.
182 const LAMBDA_HAT: f64 = 0.632_911_392_405_063_2;
183 /// `scipy.stats.expon.logpdf(DATA, scale=1/0.5).sum()`.
184 const LL_AT_HALF: f64 = -7.415_735_902_799_727;
185 /// `ℓ(λ̂; DATA)` — the log-likelihood at the fitted rate.
186 const LL_AT_HAT: f64 = -7.287_124_235_194_378;
187 /// `2k − 2ℓ(λ̂)` with `k = 1`.
188 const AIC_AT_HAT: f64 = 16.574_248_470_388_756;
189 /// `k·ln n − 2ℓ(λ̂)` with `k = 1`, `n = 5`.
190 const BIC_AT_HAT: f64 = 16.183_686_382_822_856;
191
192 /// Builds the model under test; its descriptive string fields are irrelevant to
193 /// the numerics, so they are left at their defaults.
194 fn model() -> ExponentialLikelihood {
195 ExponentialLikelihood::default()
196 }
197
198 /// Asserts `a` and `b` agree to `rel` relative error.
199 fn rel_close(a: f64, b: f64, rel: f64) -> bool {
200 (a - b).abs() <= rel * b.abs().max(1.0)
201 }
202
203 /// Returns whether `x` is exactly negative infinity, without an exact `==`
204 /// float comparison (which the lint gate rejects).
205 fn is_neg_inf(x: f64) -> bool {
206 x.is_infinite() && x.is_sign_negative()
207 }
208
209 /// Reads the sole fitted rate without slice indexing (kept lint-clean).
210 fn first_param(fit: &crate::likelihood::MleFit) -> f64 {
211 fit.params().first().copied().unwrap_or(f64::NAN)
212 }
213
214 #[test]
215 fn fit_rejects_empty_data() {
216 let got = model().fit(&[]);
217 assert!(
218 matches!(got, Err(Error::InsufficientData)),
219 "empty fit was {got:?}"
220 );
221 }
222
223 #[test]
224 fn fit_rejects_negative_observation() {
225 let got = model().fit(&[1.0, -0.5, 2.0]);
226 assert!(
227 matches!(got, Err(Error::InvalidInput(_))),
228 "negative fit was {got:?}"
229 );
230 }
231
232 #[test]
233 fn fit_rejects_all_zero_data() {
234 let got = model().fit(&[0.0, 0.0, 0.0]);
235 assert!(
236 matches!(got, Err(Error::DegenerateInput(_))),
237 "all-zero fit was {got:?}"
238 );
239 }
240
241 #[test]
242 fn log_likelihood_matches_scipy() {
243 let got = model().log_likelihood(&[0.5], &DATA);
244 assert!(
245 rel_close(got, LL_AT_HALF, 1e-10),
246 "ll@0.5 was {got}, expected {LL_AT_HALF}"
247 );
248 }
249
250 #[test]
251 fn log_likelihood_is_neg_inf_for_nonpositive_rate() {
252 let m = model();
253 assert!(
254 is_neg_inf(m.log_likelihood(&[0.0], &DATA)),
255 "ll at rate 0 was {}",
256 m.log_likelihood(&[0.0], &DATA)
257 );
258 assert!(
259 is_neg_inf(m.log_likelihood(&[-1.0], &DATA)),
260 "ll at rate -1 was {}",
261 m.log_likelihood(&[-1.0], &DATA)
262 );
263 }
264
265 #[test]
266 fn log_likelihood_is_neg_inf_for_negative_observation() {
267 let got = model().log_likelihood(&[1.0], &[1.0, -2.0]);
268 assert!(is_neg_inf(got), "ll with negative x was {got}");
269 }
270
271 #[test]
272 fn log_likelihood_non_finite_observation_is_neg_inf() {
273 let m = model();
274 // A NaN observation gives NEG_INFINITY, not the NaN that `Σxᵢ` would
275 // otherwise propagate (`x < 0.0` is false for NaN).
276 assert!(
277 is_neg_inf(m.log_likelihood(&[1.0], &[1.0, f64::NAN, 2.0])),
278 "NaN observation should give NEG_INFINITY, got {}",
279 m.log_likelihood(&[1.0], &[1.0, f64::NAN, 2.0])
280 );
281 // A +∞ observation likewise — pinned explicitly rather than left to the
282 // `−λ·Σxᵢ` sign.
283 assert!(
284 is_neg_inf(m.log_likelihood(&[1.0], &[1.0, f64::INFINITY, 2.0])),
285 "+inf observation should give NEG_INFINITY, got {}",
286 m.log_likelihood(&[1.0], &[1.0, f64::INFINITY, 2.0])
287 );
288 }
289
290 #[test]
291 fn fit_recovers_closed_form_estimate() -> Result<(), Error> {
292 let fit = model().fit(&DATA)?;
293 let lambda = first_param(&fit);
294 assert!(
295 (lambda - LAMBDA_HAT).abs() <= 1e-12,
296 "lambda_hat was {lambda}"
297 );
298 assert!(
299 rel_close(fit.log_likelihood(), LL_AT_HAT, 1e-10),
300 "ll was {}",
301 fit.log_likelihood()
302 );
303 assert!(fit.converged(), "closed-form fit should report converged");
304 assert_eq!(fit.iterations(), 0, "closed-form fit does zero iterations");
305 assert!(
306 rel_close(fit.aic(), AIC_AT_HAT, 1e-10),
307 "aic was {}",
308 fit.aic()
309 );
310 assert!(
311 rel_close(fit.bic(), BIC_AT_HAT, 1e-10),
312 "bic was {}",
313 fit.bic()
314 );
315 Ok(())
316 }
317
318 #[test]
319 fn fit_mle_from_perturbed_init_recovers_lambda_hat() -> Result<(), Error> {
320 // Start well away from λ̂ ≈ 0.633 but inside the valid domain (λ > 0).
321 let fit = fit_mle(&model(), &DATA, &[0.9], 1e-10)?;
322 let lambda = first_param(&fit);
323 assert!(
324 (lambda - LAMBDA_HAT).abs() <= 1e-5,
325 "lambda_hat from optimizer was {lambda}"
326 );
327 Ok(())
328 }
329}