Skip to main content

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(&params, 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;