Skip to main content

stats_claw/likelihood/
normal.rs

1//! Normal (Gaussian) maximum-likelihood numerics, for the
2//! [`NormalLikelihood`].
3//!
4//! The two-parameter model `θ = [μ, σ]` (σ the standard deviation) has the
5//! log-likelihood
6//! `ℓ(μ, σ; x) = −n/2·ln(2π) − n·ln σ − Σ(xᵢ − μ)²/(2σ²)`,
7//! matching `scipy.stats.norm.logpdf(x, μ, σ).sum()`. Its maximum-likelihood
8//! estimate is closed form: `μ̂` is the sample mean and `σ̂` the *biased*
9//! (population) standard deviation `√(Σ(xᵢ − μ̂)²/n)`, so [`NormalLikelihood::fit`]
10//! returns an exact [`MleFit`] without invoking the numerical optimizer.
11//!
12//! # Examples
13//!
14//! ```
15//! use stats_claw::likelihood::NormalLikelihood;
16//!
17//! let model = NormalLikelihood::default();
18//! let fit = model.fit(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])?;
19//! // Closed-form MLE: sample mean 5, biased std 2.
20//! assert!((fit.params()[0] - 5.0).abs() < 1e-12, "mu_hat was {}", fit.params()[0]);
21//! assert!((fit.params()[1] - 2.0).abs() < 1e-12, "sigma_hat was {}", fit.params()[1]);
22//! # Ok::<(), stats_claw::error::Error>(())
23//! ```
24
25use crate::algorithms::count_to_f64;
26use crate::error::{Error, Result};
27use crate::likelihood::NormalLikelihood;
28use crate::likelihood::{LogLikelihood, MleFit};
29use std::f64::consts::PI;
30
31impl LogLikelihood for NormalLikelihood {
32    /// Returns `2` — the model's free parameters are `μ` (mean) and `σ`
33    /// (standard deviation), in that order.
34    fn n_params(&self) -> usize {
35        2
36    }
37
38    /// Evaluates the Gaussian log-likelihood
39    /// `ℓ = −n/2·ln(2π) − n·ln σ − Σ(xᵢ − μ)²/(2σ²)` with `params = [μ, σ]`.
40    ///
41    /// Returns [`f64::NEG_INFINITY`] for any `θ` outside the valid domain: a
42    /// non-positive or non-finite `σ`, or a non-finite `μ`. Per the
43    /// [`LogLikelihood`] contract, any non-finite observation (`NaN` or `±∞`) also
44    /// yields [`f64::NEG_INFINITY`] rather than letting `(xᵢ − μ)²` propagate a
45    /// `NaN` (or an incidental `−∞` for `+∞`). An empty `data` yields `0.0` (the
46    /// empty product's log-likelihood); callers wanting an error on empty input
47    /// use [`NormalLikelihood::fit`].
48    fn log_likelihood(&self, params: &[f64], data: &[f64]) -> f64 {
49        let mu = *params.first().unwrap_or(&f64::NAN);
50        let sigma = *params.get(1).unwrap_or(&f64::NAN);
51        if !mu.is_finite() || !sigma.is_finite() || sigma <= 0.0 {
52            return f64::NEG_INFINITY;
53        }
54        // A non-finite observation is outside the support: return −∞ explicitly
55        // instead of propagating the NaN/±∞ that the squared-error sum would.
56        if !data.iter().all(|x| x.is_finite()) {
57            return f64::NEG_INFINITY;
58        }
59        let n = count_to_f64(data.len());
60        let inv_var = 1.0 / (sigma * sigma);
61        let sse: f64 = data.iter().map(|x| (x - mu) * (x - mu)).sum();
62        // −n/2·ln(2π) − n·ln σ − sse/(2σ²), grouped to fused multiply-adds.
63        let neg_half_n = -0.5 * n;
64        let two_pi_term = neg_half_n * (2.0 * PI).ln();
65        let sigma_term = (-n).mul_add(sigma.ln(), two_pi_term);
66        (0.5 * sse).mul_add(-inv_var, sigma_term)
67    }
68}
69
70impl NormalLikelihood {
71    /// Closed-form maximum-likelihood fit of `θ = [μ, σ]` to `data`.
72    ///
73    /// The Gaussian MLE is analytic: `μ̂` is the sample mean and `σ̂` the
74    /// *biased* (population) standard deviation `√(Σ(xᵢ − μ̂)²/n)` — matching
75    /// `numpy.std(data, ddof=0)`. The result is exact, so the returned
76    /// [`MleFit`] reports `converged() == true` and `iterations() == 0`.
77    ///
78    /// # Arguments
79    ///
80    /// * `data` — the observed sample; must contain at least two points with
81    ///   non-zero spread.
82    ///
83    /// # Returns
84    ///
85    /// An [`MleFit`] whose `params()` are `[μ̂, σ̂]`, carrying the attained
86    /// log-likelihood and the AIC/BIC (`k = 2`, `n = data.len()`).
87    ///
88    /// # Errors
89    ///
90    /// * [`Error::InsufficientData`] if `data` has fewer than two points (with a
91    ///   single point `σ̂ = 0`, which the density cannot represent).
92    /// * [`Error::DegenerateInput`] if every observation is identical, so the
93    ///   estimated `σ̂` is zero and the log-likelihood is undefined.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// use stats_claw::likelihood::NormalLikelihood;
99    ///
100    /// let fit = NormalLikelihood::default().fit(&[1.0, 2.0, 3.0])?;
101    /// assert!((fit.params()[0] - 2.0).abs() < 1e-12, "mu_hat was {}", fit.params()[0]);
102    /// assert!(fit.converged());
103    /// # Ok::<(), stats_claw::error::Error>(())
104    /// ```
105    pub fn fit(&self, data: &[f64]) -> Result<MleFit> {
106        if data.len() < 2 {
107            return Err(Error::InsufficientData);
108        }
109        let n = count_to_f64(data.len());
110        let mu_hat = data.iter().sum::<f64>() / n;
111        let sse: f64 = data.iter().map(|x| (x - mu_hat) * (x - mu_hat)).sum();
112        let sigma_hat = (sse / n).sqrt();
113        if sigma_hat <= 0.0 {
114            return Err(Error::DegenerateInput(
115                "all observations are identical (zero variance)".to_owned(),
116            ));
117        }
118        let params = vec![mu_hat, sigma_hat];
119        let log_likelihood = self.log_likelihood(&params, data);
120        Ok(MleFit::from_closed_form(params, log_likelihood, data.len()))
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::likelihood::fit_mle;
128
129    /// Textbook sample: sample mean 5, biased MLE std 2 (Σ(x−5)² = 32, /8 = 4,
130    /// √4 = 2). Reused by the fit and consistency tests.
131    const DATA: [f64; 8] = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
132
133    /// Reads parameter `i` from a fit, mapping an out-of-range index to an error
134    /// so tests use `?` rather than an index that could panic.
135    fn param(fit: &MleFit, i: usize) -> Result<f64> {
136        fit.params()
137            .get(i)
138            .copied()
139            .ok_or_else(|| Error::InvalidInput(format!("missing parameter {i}")))
140    }
141
142    /// Returns whether `x` is exactly `−∞` without a lint-tripping float `==`.
143    fn is_neg_inf(x: f64) -> bool {
144        x.is_infinite() && x.is_sign_negative()
145    }
146
147    #[test]
148    fn fit_rejects_bad_input() {
149        let model = NormalLikelihood::default();
150        assert!(
151            matches!(model.fit(&[]), Err(Error::InsufficientData)),
152            "empty data should be InsufficientData"
153        );
154        assert!(
155            matches!(model.fit(&[3.0]), Err(Error::InsufficientData)),
156            "single point should be InsufficientData"
157        );
158        assert!(
159            matches!(model.fit(&[3.0, 3.0, 3.0]), Err(Error::DegenerateInput(_))),
160            "zero variance should be DegenerateInput"
161        );
162    }
163
164    #[test]
165    fn log_likelihood_matches_scipy() {
166        // python3:
167        //   import numpy as np; from scipy import stats
168        //   data = np.array([2.,4.,4.,4.,5.,5.,7.,9.])
169        //   stats.norm.logpdf(data, 4.5, 2.3).sum()  # -17.228391835129557
170        let model = NormalLikelihood::default();
171        let got = model.log_likelihood(&[4.5, 2.3], &DATA);
172        let want = -17.228_391_835_129_557;
173        assert!(
174            ((got - want) / want).abs() < 1e-10,
175            "log_likelihood was {got}, want {want}"
176        );
177    }
178
179    #[test]
180    fn log_likelihood_out_of_domain_is_neg_inf() {
181        let model = NormalLikelihood::default();
182        assert!(
183            is_neg_inf(model.log_likelihood(&[1.0, 0.0], &DATA)),
184            "sigma = 0 should be NEG_INFINITY"
185        );
186        assert!(
187            is_neg_inf(model.log_likelihood(&[1.0, -1.0], &DATA)),
188            "negative sigma should be NEG_INFINITY"
189        );
190        assert!(
191            is_neg_inf(model.log_likelihood(&[f64::NAN, 2.0], &DATA)),
192            "non-finite mu should be NEG_INFINITY"
193        );
194        assert!(
195            is_neg_inf(model.log_likelihood(&[1.0, f64::INFINITY], &DATA)),
196            "non-finite sigma should be NEG_INFINITY"
197        );
198    }
199
200    #[test]
201    fn log_likelihood_non_finite_observation_is_neg_inf() {
202        let model = NormalLikelihood::default();
203        // A NaN observation makes the whole log-likelihood NEG_INFINITY, not the
204        // NaN that (xᵢ − μ)² would otherwise propagate.
205        assert!(
206            is_neg_inf(model.log_likelihood(&[5.0, 2.0], &[1.0, f64::NAN, 3.0])),
207            "NaN observation should give NEG_INFINITY, got {}",
208            model.log_likelihood(&[5.0, 2.0], &[1.0, f64::NAN, 3.0])
209        );
210        // A +∞ observation likewise — pinned explicitly rather than left to the
211        // sign that `−inv_var · Σ(xᵢ − μ)²` happens to produce.
212        assert!(
213            is_neg_inf(model.log_likelihood(&[5.0, 2.0], &[1.0, f64::INFINITY, 3.0])),
214            "+inf observation should give NEG_INFINITY, got {}",
215            model.log_likelihood(&[5.0, 2.0], &[1.0, f64::INFINITY, 3.0])
216        );
217    }
218
219    #[test]
220    fn fit_recovers_closed_form_and_information_criteria() -> Result<()> {
221        // python3:
222        //   import numpy as np; from scipy import stats
223        //   data = np.array([2.,4.,4.,4.,5.,5.,7.,9.])
224        //   data.mean()             # 5.0
225        //   data.std(ddof=0)        # 2.0  (biased MLE)
226        //   ll = stats.norm.logpdf(data, 5.0, 2.0).sum()  # -16.896685710116945
227        let model = NormalLikelihood::default();
228        let fit = model.fit(&DATA)?;
229        let mu_hat = param(&fit, 0)?;
230        let sigma_hat = param(&fit, 1)?;
231        assert!(
232            (mu_hat - 5.0).abs() < 1e-12,
233            "mu_hat was {mu_hat}, want 5.0"
234        );
235        assert!(
236            (sigma_hat - 2.0).abs() < 1e-12,
237            "sigma_hat was {sigma_hat}, want 2.0"
238        );
239        let want_ll = -16.896_685_710_116_945;
240        let ll = fit.log_likelihood();
241        assert!(
242            ((ll - want_ll) / want_ll).abs() < 1e-10,
243            "log_likelihood was {ll}, want {want_ll}"
244        );
245        assert!(fit.converged(), "closed-form fit must report converged");
246        assert_eq!(fit.iterations(), 0, "closed-form fit does no iterations");
247        // Information-criteria arithmetic identity: 2k − 2ℓ and k·ln(n) − 2ℓ, k = 2.
248        let n = count_to_f64(DATA.len());
249        let akaike = 2.0f64.mul_add(2.0, -2.0 * ll);
250        let bayesian = 2.0f64.mul_add(n.ln(), -2.0 * ll);
251        assert!(
252            (fit.aic() - akaike).abs() < 1e-12,
253            "aic was {}, want {akaike}",
254            fit.aic()
255        );
256        assert!(
257            (fit.bic() - bayesian).abs() < 1e-12,
258            "bic was {}, want {bayesian}",
259            fit.bic()
260        );
261        Ok(())
262    }
263
264    #[test]
265    fn fit_mle_from_perturbed_init_recovers_closed_form() -> Result<()> {
266        // The free L-BFGS optimizer, started away from the optimum, must land on
267        // the same [μ̂, σ̂] the closed form gives (μ = 5, σ = 2).
268        let model = NormalLikelihood::default();
269        let fit = fit_mle(&model, &DATA, &[3.5, 3.0], 1e-10)?;
270        let mu_hat = param(&fit, 0)?;
271        let sigma_hat = param(&fit, 1)?;
272        assert!(
273            (mu_hat - 5.0).abs() <= 1e-5,
274            "mu_hat was {mu_hat}, want 5.0"
275        );
276        assert!(
277            (sigma_hat - 2.0).abs() <= 1e-5,
278            "sigma_hat was {sigma_hat}, want 2.0"
279        );
280        Ok(())
281    }
282}