stats_claw/likelihood/mle.rs
1//! Generic maximum-likelihood estimation for the
2//! [`MaximumLikelihood`](crate::likelihood::MaximumLikelihood).
3//!
4//! A parametric model implements [`LogLikelihood`] — the log-likelihood
5//! `ℓ(θ; data)` of a parameter vector `θ` given `f64` observations. [`fit_mle`]
6//! then finds the maximum-likelihood estimate `θ̂ = argmaxθ ℓ` by *minimizing*
7//! `−ℓ` with the framework's L-BFGS optimizer, reporting the fitted parameters
8//! alongside the Akaike and Bayesian information criteria in an [`MleFit`].
9//!
10//! This module is the shared foundation the concrete likelihood models
11//! (Normal, Poisson, Binomial, Categorical, Exponential) build on: each supplies
12//! its own [`LogLikelihood`] and defers the numerical optimization to [`fit_mle`].
13
14use crate::algorithms::count_to_f64;
15use crate::error::{Error, Result};
16use crate::optimizers::second_order::lbfgs;
17use crate::optimizers::{ConvergenceStatus, Objective};
18
19/// Iteration budget handed to the underlying L-BFGS optimizer. Large enough that
20/// convergence is governed by the gradient-norm `tolerance` rather than the
21/// budget for the smooth likelihoods this framework fits.
22const MAX_ITER: usize = 1_000;
23
24/// Finite penalty substituted for `+∞` when the model reports `ℓ = −∞` (a `θ`
25/// outside its valid domain). A large finite value keeps the optimizer's line
26/// search and finite-difference gradient well defined and steers trial steps
27/// back toward the interior instead of stalling on a non-finite objective.
28const DOMAIN_PENALTY: f64 = 1e300;
29
30/// Fallback gradient-norm tolerance used when
31/// [`MaximumLikelihood::fit`](crate::likelihood::MaximumLikelihood::fit) is
32/// called with a non-positive stored `convergence_tolerance` (e.g. the default
33/// `0.0`).
34const DEFAULT_TOLERANCE: f64 = 1e-8;
35
36/// A parametric log-likelihood `ℓ(θ; data)` over `f64` observations.
37///
38/// Implementors describe a family of probability models indexed by a parameter
39/// vector `params` (`θ`); [`log_likelihood`](LogLikelihood::log_likelihood)
40/// returns the total log-likelihood of `data` under `θ`. Returning
41/// [`f64::NEG_INFINITY`] marks a `θ` outside the valid parameter domain (for
42/// example a non-positive standard deviation), which [`fit_mle`] treats as a
43/// hard constraint.
44///
45/// Implementations must return [`f64::NEG_INFINITY`] for any non-finite
46/// observation (`NaN` or `±∞`) rather than propagating a `NaN`: a non-finite
47/// datum lies outside every model's support and must not silently corrupt the
48/// objective the optimizer sees.
49///
50/// # Examples
51///
52/// ```
53/// use stats_claw::likelihood::LogLikelihood;
54///
55/// // A one-parameter Gaussian-mean model: ℓ(μ) = −Σ(xᵢ − μ)².
56/// struct MeanModel;
57/// impl LogLikelihood for MeanModel {
58/// fn n_params(&self) -> usize { 1 }
59/// fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
60/// }
61///
62/// let m = MeanModel;
63/// // The likelihood is higher at the sample mean (2) than away from it (0).
64/// assert!(m.log_likelihood(&[2.0], &[1.0, 3.0]) > m.log_likelihood(&[0.0], &[1.0, 3.0]));
65/// ```
66pub trait LogLikelihood {
67 /// Returns the number of free parameters, i.e. the required length of
68 /// `params`.
69 fn n_params(&self) -> usize;
70
71 /// Evaluates `ℓ(params; data)`, the total log-likelihood of `data`.
72 ///
73 /// # Arguments
74 ///
75 /// * `params` — the parameter vector `θ`; length must equal
76 /// [`n_params`](LogLikelihood::n_params).
77 /// * `data` — the observed sample.
78 ///
79 /// # Returns
80 ///
81 /// The scalar log-likelihood, or [`f64::NEG_INFINITY`] when `params` lies
82 /// outside the model's valid domain.
83 fn log_likelihood(&self, params: &[f64], data: &[f64]) -> f64;
84}
85
86/// The outcome of a maximum-likelihood fit produced by [`fit_mle`].
87///
88/// The fitted parameters and diagnostics are read through accessor methods (see
89/// [`MleFit::params`]); the fields are private because the struct owns a heap
90/// parameter vector and the framework keeps memory-owning types fully
91/// encapsulated.
92#[derive(Debug, Clone)]
93pub struct MleFit {
94 /// The fitted maximum-likelihood estimate `θ̂` (length `n_params`).
95 params: Vec<f64>,
96 /// The log-likelihood `ℓ(θ̂; data)` at the fitted parameters.
97 log_likelihood: f64,
98 /// Whether the optimizer's convergence criterion was met (vs. budget hit).
99 converged: bool,
100 /// Number of optimizer iterations performed.
101 iterations: usize,
102 /// Akaike information criterion, `2k − 2ℓ` (`k` = number of parameters).
103 aic: f64,
104 /// Bayesian information criterion, `k·ln(n) − 2ℓ` (`n` = sample size).
105 bic: f64,
106}
107
108impl MleFit {
109 /// Returns the fitted maximum-likelihood estimate `θ̂` as a slice.
110 ///
111 /// # Returns
112 ///
113 /// The fitted parameters in the model's parameter order (length
114 /// `n_params`).
115 ///
116 /// # Examples
117 ///
118 /// ```
119 /// use stats_claw::likelihood::{fit_mle, LogLikelihood};
120 /// # struct M;
121 /// # impl LogLikelihood for M {
122 /// # fn n_params(&self) -> usize { 1 }
123 /// # fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
124 /// # }
125 /// let fit = fit_mle(&M, &[2.0, 4.0], &[0.0], 1e-9)?;
126 /// assert_eq!(fit.params().len(), 1);
127 /// # Ok::<(), stats_claw::error::Error>(())
128 /// ```
129 #[must_use]
130 pub fn params(&self) -> &[f64] {
131 &self.params
132 }
133
134 /// Returns the log-likelihood `ℓ(θ̂; data)` at the fitted parameters.
135 ///
136 /// # Examples
137 ///
138 /// ```
139 /// use stats_claw::likelihood::{fit_mle, LogLikelihood};
140 /// # struct M;
141 /// # impl LogLikelihood for M {
142 /// # fn n_params(&self) -> usize { 1 }
143 /// # fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
144 /// # }
145 /// let fit = fit_mle(&M, &[2.0, 4.0], &[0.0], 1e-9)?;
146 /// assert!(fit.log_likelihood().is_finite());
147 /// # Ok::<(), stats_claw::error::Error>(())
148 /// ```
149 #[must_use]
150 pub const fn log_likelihood(&self) -> f64 {
151 self.log_likelihood
152 }
153
154 /// Returns whether the optimizer's convergence criterion was met (as opposed
155 /// to exhausting its iteration budget).
156 ///
157 /// # Examples
158 ///
159 /// ```
160 /// use stats_claw::likelihood::{fit_mle, LogLikelihood};
161 /// # struct M;
162 /// # impl LogLikelihood for M {
163 /// # fn n_params(&self) -> usize { 1 }
164 /// # fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
165 /// # }
166 /// let fit = fit_mle(&M, &[2.0, 4.0], &[0.0], 1e-9)?;
167 /// assert!(fit.converged());
168 /// # Ok::<(), stats_claw::error::Error>(())
169 /// ```
170 #[must_use]
171 pub const fn converged(&self) -> bool {
172 self.converged
173 }
174
175 /// Returns the number of optimizer iterations performed.
176 ///
177 /// # Examples
178 ///
179 /// ```
180 /// use stats_claw::likelihood::{fit_mle, LogLikelihood};
181 /// # struct M;
182 /// # impl LogLikelihood for M {
183 /// # fn n_params(&self) -> usize { 1 }
184 /// # fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
185 /// # }
186 /// let fit = fit_mle(&M, &[2.0, 4.0], &[0.0], 1e-9)?;
187 /// assert!(fit.iterations() >= 1);
188 /// # Ok::<(), stats_claw::error::Error>(())
189 /// ```
190 #[must_use]
191 pub const fn iterations(&self) -> usize {
192 self.iterations
193 }
194
195 /// Returns the Akaike information criterion, `2k − 2ℓ`.
196 ///
197 /// # Examples
198 ///
199 /// ```
200 /// use stats_claw::likelihood::{fit_mle, LogLikelihood};
201 /// # struct M;
202 /// # impl LogLikelihood for M {
203 /// # fn n_params(&self) -> usize { 1 }
204 /// # fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
205 /// # }
206 /// let fit = fit_mle(&M, &[2.0, 4.0], &[0.0], 1e-9)?;
207 /// assert!(fit.aic().is_finite());
208 /// # Ok::<(), stats_claw::error::Error>(())
209 /// ```
210 #[must_use]
211 pub const fn aic(&self) -> f64 {
212 self.aic
213 }
214
215 /// Returns the Bayesian information criterion, `k·ln(n) − 2ℓ`.
216 ///
217 /// # Examples
218 ///
219 /// ```
220 /// use stats_claw::likelihood::{fit_mle, LogLikelihood};
221 /// # struct M;
222 /// # impl LogLikelihood for M {
223 /// # fn n_params(&self) -> usize { 1 }
224 /// # fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
225 /// # }
226 /// let fit = fit_mle(&M, &[2.0, 4.0], &[0.0], 1e-9)?;
227 /// assert!(fit.bic().is_finite());
228 /// # Ok::<(), stats_claw::error::Error>(())
229 /// ```
230 #[must_use]
231 pub const fn bic(&self) -> f64 {
232 self.bic
233 }
234
235 /// Builds a fit from a closed-form (analytic) maximum-likelihood solution.
236 ///
237 /// Sibling likelihood modules whose MLE has a closed form (e.g. the Normal
238 /// mean/variance) use this to return an [`MleFit`] without exposing the
239 /// private fields. Because the solution is exact, `converged` is `true` and
240 /// `iterations` is `0`; the AIC/BIC are computed from `k = params.len()` and
241 /// `n = n_observations` with the same [`info_criteria`] formulas as
242 /// [`fit_mle`].
243 ///
244 /// # Arguments
245 ///
246 /// * `params` — the analytic estimate `θ̂`.
247 /// * `log_likelihood` — the log-likelihood `ℓ(θ̂; data)` at that estimate.
248 /// * `n_observations` — the sample size `n`, used only for the BIC.
249 // Exercised by this module's tests and consumed by the sibling likelihood
250 // modules (Normal/Poisson/…) landing in follow-up tasks; it therefore has no
251 // in-crate caller yet in a non-test build.
252 #[allow(dead_code)]
253 pub(crate) fn from_closed_form(
254 params: Vec<f64>,
255 log_likelihood: f64,
256 n_observations: usize,
257 ) -> Self {
258 let (aic, bic) = info_criteria(params.len(), n_observations, log_likelihood);
259 Self {
260 params,
261 log_likelihood,
262 converged: true,
263 iterations: 0,
264 aic,
265 bic,
266 }
267 }
268}
269
270/// Numerically maximizes `ℓ` from `init` by minimizing `−ℓ` with L-BFGS.
271///
272/// # Arguments
273///
274/// * `model` — the parametric log-likelihood to fit.
275/// * `data` — the observed sample; must be non-empty.
276/// * `init` — the starting parameter vector; length must equal
277/// `model.n_params()`.
278/// * `tolerance` — the gradient-norm convergence threshold; must be `> 0`.
279///
280/// # Returns
281///
282/// An [`MleFit`] with the fitted parameters, the attained log-likelihood, the
283/// convergence flag, the iteration count, and the AIC/BIC.
284///
285/// # Errors
286///
287/// * [`Error::InsufficientData`] if `data` is empty.
288/// * [`Error::InvalidInput`] if `init.len() != model.n_params()`, if
289/// `tolerance <= 0`, or if `init` lies outside the model's valid domain (i.e.
290/// `model.log_likelihood(init, data)` is not finite).
291///
292/// # Examples
293///
294/// ```
295/// use stats_claw::likelihood::{fit_mle, LogLikelihood};
296///
297/// struct MeanModel;
298/// impl LogLikelihood for MeanModel {
299/// fn n_params(&self) -> usize { 1 }
300/// fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
301/// }
302///
303/// // The MLE of the mean model is the sample mean, here 2.0.
304/// let fit = fit_mle(&MeanModel, &[1.0, 2.0, 3.0], &[0.0], 1e-9)?;
305/// assert!((fit.params()[0] - 2.0).abs() < 1e-5, "mu_hat was {}", fit.params()[0]);
306/// # Ok::<(), stats_claw::error::Error>(())
307/// ```
308pub fn fit_mle(
309 model: &impl LogLikelihood,
310 data: &[f64],
311 init: &[f64],
312 tolerance: f64,
313) -> Result<MleFit> {
314 if data.is_empty() {
315 return Err(Error::InsufficientData);
316 }
317 if init.len() != model.n_params() {
318 return Err(Error::InvalidInput(format!(
319 "init has {} entries but model has {} parameters",
320 init.len(),
321 model.n_params()
322 )));
323 }
324 if tolerance <= 0.0 {
325 return Err(Error::InvalidInput("tolerance must be > 0".to_owned()));
326 }
327 // The objective substitutes a flat penalty for `ℓ = −∞`, so a start outside
328 // the valid domain yields a zero numerical gradient and lbfgs would "converge"
329 // immediately at the invalid point. Reject such a start up front.
330 if !model.log_likelihood(init, data).is_finite() {
331 return Err(Error::InvalidInput(
332 "init is outside the model's valid parameter domain (log-likelihood is not finite)"
333 .to_owned(),
334 ));
335 }
336 let objective = NegLogLikelihood { model, data };
337 let result = lbfgs(&objective, init, MAX_ITER, tolerance);
338 let params = result.x;
339 let log_likelihood = model.log_likelihood(¶ms, data);
340 let (aic, bic) = info_criteria(model.n_params(), data.len(), log_likelihood);
341 Ok(MleFit {
342 params,
343 log_likelihood,
344 converged: matches!(result.status, ConvergenceStatus::Converged),
345 iterations: result.iterations,
346 aic,
347 bic,
348 })
349}
350
351/// Computes the Akaike and Bayesian information criteria.
352///
353/// # Arguments
354///
355/// * `k` — the number of free parameters.
356/// * `n` — the sample size (`≥ 1`, guaranteed by [`fit_mle`]'s guards).
357/// * `log_likelihood` — the maximized log-likelihood `ℓ(θ̂)`.
358///
359/// # Returns
360///
361/// The pair `(aic, bic)` where `aic = 2k − 2ℓ` and `bic = k·ln(n) − 2ℓ`.
362fn info_criteria(k: usize, n: usize, log_likelihood: f64) -> (f64, f64) {
363 let kf = count_to_f64(k);
364 let nf = count_to_f64(n);
365 let aic = 2.0_f64.mul_add(kf, -2.0 * log_likelihood);
366 let bic = kf.mul_add(nf.ln(), -2.0 * log_likelihood);
367 (aic, bic)
368}
369
370/// Adapts `−ℓ` of a [`LogLikelihood`] into an [`Objective`] for the optimizer.
371///
372/// [`value`](Objective::value) returns `−ℓ`, substituting [`DOMAIN_PENALTY`] for
373/// any non-finite value so out-of-domain trial points stay usable;
374/// [`grad`](Objective::grad) is a central finite-difference approximation, since
375/// the generic likelihood exposes no analytic gradient. The model is held as a
376/// trait object so the [`Objective`] impl is fully concrete.
377///
378/// # Notes
379///
380/// The finite-difference step `h = max(1e-6, 1e-6·|xᵢ|)` is deliberately coarse:
381/// for parameters legitimately scaled far below `1e-3`, the absolute floor of
382/// `1e-6` dominates and the differencing step is large relative to `|xᵢ|`, so the
383/// gradient there is only crudely accurate. Rescale such parameters before
384/// fitting if a tight gradient is required.
385struct NegLogLikelihood<'a> {
386 /// The wrapped log-likelihood model.
387 model: &'a dyn LogLikelihood,
388 /// The observed sample the log-likelihood is evaluated against.
389 data: &'a [f64],
390}
391
392impl Objective for NegLogLikelihood<'_> {
393 fn value(&self, x: &[f64]) -> f64 {
394 let ll = self.model.log_likelihood(x, self.data);
395 if ll.is_finite() { -ll } else { DOMAIN_PENALTY }
396 }
397
398 fn grad(&self, x: &[f64]) -> Vec<f64> {
399 // Central difference with a relative step h = max(1e-6, 1e-6·|xᵢ|).
400 // Near a domain boundary a probe can land on the penalized (`ℓ = −∞`)
401 // side; using it would corrupt the derivative, so fall back to a
402 // one-sided difference against the (finite) centre when that happens.
403 let center = self.value(x);
404 (0..x.len())
405 .map(|i| {
406 let xi = *x.get(i).unwrap_or(&0.0);
407 let h = 1e-6_f64.max(1e-6 * xi.abs());
408 let mut probe = x.to_vec();
409 if let Some(slot) = probe.get_mut(i) {
410 *slot = xi + h;
411 }
412 let forward = self.value(&probe);
413 if let Some(slot) = probe.get_mut(i) {
414 *slot = xi - h;
415 }
416 let backward = self.value(&probe);
417 let fwd_ok = forward < DOMAIN_PENALTY;
418 let bwd_ok = backward < DOMAIN_PENALTY;
419 match (fwd_ok, bwd_ok) {
420 (true, true) => (forward - backward) / (2.0 * h),
421 (true, false) => (forward - center) / h,
422 (false, true) => (center - backward) / h,
423 // Reachable: when the valid domain is narrower than 2h around
424 // the iterate, both probes fall on the penalized side and
425 // neither one-sided difference is trustworthy. Report a zero
426 // component — the conservative choice, since it neither pushes
427 // the optimizer out of the feasible region nor invents a slope
428 // from the flat penalty; the line search then relies on the
429 // other (finite) components to make progress.
430 (false, false) => 0.0,
431 }
432 })
433 .collect()
434 }
435}
436
437impl crate::likelihood::MaximumLikelihood {
438 /// Fits `model` to `data` from `init`, using this instance's
439 /// [`convergence_tolerance`](crate::likelihood::MaximumLikelihood::convergence_tolerance).
440 ///
441 /// The stored tolerance is the gradient-norm threshold forwarded to
442 /// [`fit_mle`]. When the field is left at its default of `0.0` (a
443 /// non-positive, unusable threshold), it falls back to `1e-8`.
444 ///
445 /// # Arguments
446 ///
447 /// * `model` — the parametric log-likelihood to fit.
448 /// * `data` — the observed sample; must be non-empty.
449 /// * `init` — the starting parameter vector; length must equal
450 /// `model.n_params()`.
451 ///
452 /// # Errors
453 ///
454 /// Propagates the errors of [`fit_mle`]: [`Error::InsufficientData`] for
455 /// empty `data`, and [`Error::InvalidInput`] for an `init`/parameter-count
456 /// mismatch, a non-positive resolved tolerance, or an `init` outside the
457 /// model's valid domain (non-finite log-likelihood).
458 ///
459 /// # Examples
460 ///
461 /// ```
462 /// use stats_claw::likelihood::MaximumLikelihood;
463 /// use stats_claw::likelihood::LogLikelihood;
464 ///
465 /// struct MeanModel;
466 /// impl LogLikelihood for MeanModel {
467 /// fn n_params(&self) -> usize { 1 }
468 /// fn log_likelihood(&self, p: &[f64], d: &[f64]) -> f64 { -d.iter().map(|x| (x - p[0]).powi(2)).sum::<f64>() }
469 /// }
470 ///
471 /// let mle = MaximumLikelihood { convergence_tolerance: 1e-9, ..Default::default() };
472 /// let fit = mle.fit(&MeanModel, &[2.0, 4.0, 6.0], &[0.0])?;
473 /// assert!((fit.params()[0] - 4.0).abs() < 1e-5, "mu_hat was {}", fit.params()[0]);
474 /// # Ok::<(), stats_claw::error::Error>(())
475 /// ```
476 pub fn fit(&self, model: &impl LogLikelihood, data: &[f64], init: &[f64]) -> Result<MleFit> {
477 let tolerance = if self.convergence_tolerance > 0.0 {
478 self.convergence_tolerance
479 } else {
480 DEFAULT_TOLERANCE
481 };
482 fit_mle(model, data, init, tolerance)
483 }
484}
485
486#[cfg(test)]
487mod tests;