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(¶ms, 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}