Skip to main content

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}