Skip to main content

stats_claw/likelihood/
categorical.rs

1//! Categorical (multinomial-per-observation) likelihood, layered onto the
2//! [`CategoricalLikelihood`](crate::likelihood::CategoricalLikelihood).
3//!
4//! Models `n` i.i.d. draws from a `Categorical(p₀ … p_{k−1})` distribution whose
5//! observations are category indices (`0 ≤ j < k`) stored as `f64`. The
6//! log-likelihood of a sample is `Σⱼ countⱼ · ln pⱼ`, and the closed-form
7//! maximum-likelihood estimate is the empirical frequency `p̂ⱼ = countⱼ / n`.
8//!
9//! # `n_categories` as an explicit argument
10//!
11//! The number of categories `k` is the model's structural dimension, not a
12//! fitted quantity, so the inherent methods take it as an explicit
13//! `n_categories` argument (mirroring the binomial `trials` parameter). The
14//! struct's own `number_of_categories` field is descriptive metadata and
15//! is deliberately not consulted, keeping the numerics independent of how the
16//! struct was populated. The [`LogLikelihood`] trait — whose signature is frozen
17//! and carries no `k` — is implemented on the small wrapper
18//! [`CategoricalLikelihoodModel`], which stores `k` so that
19//! [`n_params`](LogLikelihood::n_params) can report it.
20//!
21//! # Simplex and zero-count conventions
22//!
23//! A parameter vector must sum to `1` within [`SIMPLEX_TOLERANCE`] to be a valid
24//! probability vector; otherwise the log-likelihood is
25//! [`f64::NEG_INFINITY`]. Empty categories are permitted: a category with zero
26//! observed count contributes `0` to the log-likelihood under the convention
27//! `0 · ln 0 ≔ 0`, so a fitted `p̂ⱼ = 0` does not make the fitted
28//! log-likelihood degenerate.
29//!
30//! # Simplex constraint and the log-odds escape hatch
31//!
32//! [`CategoricalLikelihoodModel`]'s parameters live on the probability simplex
33//! `Σ pⱼ = 1, pⱼ ≥ 0`, a constraint the unconstrained L-BFGS optimizer behind
34//! [`fit_mle`](crate::likelihood::fit_mle) cannot respect: its iterates leave the
35//! valid domain, so fitting the *simplex* parameterization directly is not
36//! meaningful. The escape hatch is [`CategoricalLogOdds`], which reparametrizes
37//! the family by `k − 1` free real logits (softmax back to probabilities); it is
38//! unconstrained, fits cleanly through [`fit_mle`](crate::likelihood::fit_mle), and its
39//! [`fit_mle`](crate::likelihood::fit_mle) estimate matches this module's
40//! closed-form `p̂` after softmax.
41//!
42//! # Examples
43//!
44//! ```
45//! use stats_claw::likelihood::CategoricalLikelihood;
46//!
47//! let model = CategoricalLikelihood::default();
48//! // Six draws over k = 3 categories; MLE is the empirical frequency.
49//! let fit = model.fit(3, &[0.0, 0.0, 1.0, 2.0, 2.0, 2.0])?;
50//! assert!((fit.params()[0] - 2.0 / 6.0).abs() < 1e-12, "p0 was {}", fit.params()[0]);
51//! # Ok::<(), stats_claw::error::Error>(())
52//! ```
53
54use crate::algorithms::count_to_f64;
55use crate::error::{Error, Result};
56use crate::likelihood::{LogLikelihood, MleFit};
57
58/// Maximum absolute deviation of `Σ pⱼ` from `1` for a valid probability vector.
59///
60/// A small finite tolerance absorbs the floating-point rounding of an
61/// otherwise-normalized vector (e.g. the sum of closed-form empirical
62/// frequencies) without admitting genuinely unnormalized input.
63pub const SIMPLEX_TOLERANCE: f64 = 1e-9;
64
65/// Maximum absolute distance a datum may sit from the nearest integer and still
66/// be accepted as a category index. Observations are category labels, so they
67/// must be (floating-point representations of) non-negative integers below `k`.
68const INDEX_TOLERANCE: f64 = 1e-9;
69
70/// A [`LogLikelihood`] wrapper carrying the category count `k` for the
71/// categorical family.
72///
73/// The frozen [`LogLikelihood`] trait exposes no place for the structural
74/// dimension `k`, so this small struct stores it and reports it from
75/// [`n_params`](LogLikelihood::n_params). Construct it directly with the number
76/// of categories.
77///
78/// # Examples
79///
80/// ```
81/// use stats_claw::likelihood::LogLikelihood;
82/// use stats_claw::likelihood::categorical::CategoricalLikelihoodModel;
83///
84/// let model = CategoricalLikelihoodModel { n_categories: 3 };
85/// assert_eq!(model.n_params(), 3);
86/// ```
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct CategoricalLikelihoodModel {
89    /// The number of categories `k`, i.e. the required parameter-vector length.
90    pub n_categories: usize,
91}
92
93impl LogLikelihood for CategoricalLikelihoodModel {
94    fn n_params(&self) -> usize {
95        self.n_categories
96    }
97
98    fn log_likelihood(&self, params: &[f64], data: &[f64]) -> f64 {
99        log_likelihood_core(self.n_categories, params, data)
100    }
101}
102
103/// Returns whether `x` is a valid category index for `k` categories, i.e. a
104/// non-negative integer (within [`INDEX_TOLERANCE`]) strictly below `k`.
105fn is_valid_index(x: f64, k: usize) -> bool {
106    x.is_finite() && x >= 0.0 && (x - x.round()).abs() < INDEX_TOLERANCE && x < count_to_f64(k)
107}
108
109/// Counts how many observations in `data` fall in category `j`.
110///
111/// Indices are integers, so an observation belongs to category `j` when it lies
112/// within half a unit of `j`. Callers validate index range separately; this only
113/// tallies membership, returning the count directly as `f64` for the
114/// log-likelihood sum.
115fn category_count(data: &[f64], j: usize) -> f64 {
116    let target = count_to_f64(j);
117    let count = data.iter().filter(|&&x| (x - target).abs() < 0.5).count();
118    count_to_f64(count)
119}
120
121/// Computes `Σⱼ countⱼ · ln pⱼ`, the categorical log-likelihood.
122///
123/// Returns [`f64::NEG_INFINITY`] when `params` is not length `n_categories`, does
124/// not sum to `1` within [`SIMPLEX_TOLERANCE`], contains a non-positive
125/// probability for a non-empty category, or when `data` holds an index outside
126/// `0 ≤ j < n_categories`. Empty categories contribute `0` under the convention
127/// `0 · ln 0 ≔ 0`.
128fn log_likelihood_core(n_categories: usize, params: &[f64], data: &[f64]) -> f64 {
129    if params.len() != n_categories {
130        return f64::NEG_INFINITY;
131    }
132    let sum: f64 = params.iter().sum();
133    if (sum - 1.0).abs() > SIMPLEX_TOLERANCE {
134        return f64::NEG_INFINITY;
135    }
136    if !data.iter().all(|&x| is_valid_index(x, n_categories)) {
137        return f64::NEG_INFINITY;
138    }
139    let mut total = 0.0;
140    for (j, &p) in params.iter().enumerate() {
141        let count = category_count(data, j);
142        if count > 0.0 {
143            if p <= 0.0 {
144                return f64::NEG_INFINITY;
145            }
146            total = count.mul_add(p.ln(), total);
147        }
148        // A zero-count category contributes 0 (the 0·ln0 ≔ 0 convention), so it
149        // is skipped even when p ≤ 0.
150    }
151    total
152}
153
154impl crate::likelihood::CategoricalLikelihood {
155    /// Evaluates the categorical log-likelihood `Σⱼ countⱼ · ln pⱼ` of `data`
156    /// under the probability vector `params`.
157    ///
158    /// The number of categories `k` is passed explicitly rather than read from
159    /// the struct (see the [module docs](self)); the frozen [`LogLikelihood`]
160    /// trait is instead implemented on [`CategoricalLikelihoodModel`].
161    ///
162    /// # Arguments
163    ///
164    /// * `n_categories` — the number of categories `k`.
165    /// * `params` — the probability vector `[p₀ … p_{k−1}]`; length must equal
166    ///   `n_categories` and the entries must sum to `1` within
167    ///   [`SIMPLEX_TOLERANCE`].
168    /// * `data` — the observed category indices, each a non-negative integer
169    ///   below `k` stored as `f64`.
170    ///
171    /// # Returns
172    ///
173    /// The log-likelihood, or [`f64::NEG_INFINITY`] when `params` has the wrong
174    /// length, is not normalized, assigns a non-positive probability to a
175    /// non-empty category, or `data` contains an invalid index. A category with
176    /// zero observed count contributes `0` (the `0 · ln 0 ≔ 0` convention).
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use stats_claw::likelihood::CategoricalLikelihood;
182    ///
183    /// let model = CategoricalLikelihood::default();
184    /// // counts = [2, 1, 3] over k = 3; ℓ = 2·ln0.2 + 1·ln0.3 + 3·ln0.5.
185    /// let ll = model.log_likelihood(3, &[0.2, 0.3, 0.5], &[0.0, 0.0, 1.0, 2.0, 2.0, 2.0]);
186    /// assert!((ll - (-6.502_290_170_873_972)).abs() < 1e-10, "ll was {ll}");
187    /// ```
188    #[must_use]
189    pub fn log_likelihood(&self, n_categories: usize, params: &[f64], data: &[f64]) -> f64 {
190        log_likelihood_core(n_categories, params, data)
191    }
192
193    /// Closed-form maximum-likelihood fit of the category probabilities,
194    /// `p̂ⱼ = countⱼ / n`.
195    ///
196    /// # Arguments
197    ///
198    /// * `n_categories` — the number of categories `k`.
199    /// * `data` — the observed category indices, each a non-negative integer
200    ///   below `k` stored as `f64`.
201    ///
202    /// # Returns
203    ///
204    /// An [`MleFit`] whose parameters are the empirical frequencies (length
205    /// `n_categories`, summing to `1`) and whose log-likelihood is `ℓ(p̂; data)`.
206    /// Categories with zero observed count are allowed and receive `p̂ⱼ = 0`,
207    /// contributing `0` to the log-likelihood.
208    ///
209    /// # Errors
210    ///
211    /// * [`Error::InsufficientData`] if `data` is empty.
212    /// * [`Error::InvalidInput`] if any datum is not a non-negative integer
213    ///   below `n_categories`.
214    ///
215    /// # Examples
216    ///
217    /// ```
218    /// use stats_claw::likelihood::CategoricalLikelihood;
219    ///
220    /// let model = CategoricalLikelihood::default();
221    /// let fit = model.fit(3, &[0.0, 0.0, 1.0, 2.0, 2.0, 2.0])?;
222    /// assert!((fit.params()[2] - 0.5).abs() < 1e-12, "p2 was {}", fit.params()[2]);
223    /// # Ok::<(), stats_claw::error::Error>(())
224    /// ```
225    pub fn fit(&self, n_categories: usize, data: &[f64]) -> Result<MleFit> {
226        if data.is_empty() {
227            return Err(Error::InsufficientData);
228        }
229        if let Some(&bad) = data.iter().find(|&&x| !is_valid_index(x, n_categories)) {
230            return Err(Error::InvalidInput(format!(
231                "datum {bad} is not a non-negative integer below {n_categories} categories"
232            )));
233        }
234        let n = count_to_f64(data.len());
235        let params: Vec<f64> = (0..n_categories)
236            .map(|j| category_count(data, j) / n)
237            .collect();
238        let log_likelihood = log_likelihood_core(n_categories, &params, data);
239        Ok(MleFit::from_closed_form(params, log_likelihood, data.len()))
240    }
241}
242
243/// Unconstrained log-odds (softmax) reparametrization of the categorical family.
244///
245/// [`CategoricalLikelihoodModel`] parameterizes the categorical distribution by
246/// the probabilities themselves, which live on the simplex `Σ pⱼ = 1, pⱼ ≥ 0` — a
247/// constraint the framework's free L-BFGS optimizer cannot honor. This type
248/// instead carries `k − 1` *free* real logits `z₁ … z_{k−1}` (category 0 is the
249/// reference with logit `z₀ ≔ 0`) and recovers the probabilities through the
250/// softmax
251/// `pⱼ = exp(zⱼ) / Σᵢ exp(zᵢ)`.
252///
253/// Because the logits range over all of `ℝᵏ⁻¹` with no constraint, the model can
254/// be fed directly to [`fit_mle`](crate::likelihood::fit_mle): the optimizer's
255/// iterates are always valid and the softmax maps them back onto the simplex.
256/// Use [`probabilities`](CategoricalLogOdds::probabilities) to read the fitted
257/// probabilities and [`from_probabilities`](CategoricalLogOdds::from_probabilities)
258/// to build an initial logit vector from a probability guess.
259///
260/// # Numerical stability
261///
262/// All probabilities are computed in log-space via the log-sum-exp identity
263/// `ln Σᵢ exp(zᵢ) = m + ln Σᵢ exp(zᵢ − m)` with `m = maxᵢ zᵢ`, so no term ever
264/// overflows even for large logits (each `exp(zᵢ − m) ≤ 1`).
265///
266/// # Examples
267///
268/// ```
269/// use stats_claw::likelihood::LogLikelihood;
270/// use stats_claw::likelihood::categorical::{CategoricalLikelihoodModel, CategoricalLogOdds};
271///
272/// let p = [0.2, 0.3, 0.5];
273/// let data = [0.0, 0.0, 1.0, 2.0, 2.0, 2.0];
274/// // The log-odds model reproduces the simplex model's log-likelihood exactly.
275/// let z = CategoricalLogOdds::from_probabilities(&p)?;
276/// let via_logodds = CategoricalLogOdds { n_categories: 3 }.log_likelihood(&z, &data);
277/// let via_simplex = CategoricalLikelihoodModel { n_categories: 3 }.log_likelihood(&p, &data);
278/// assert!((via_logodds - via_simplex).abs() < 1e-12);
279/// # Ok::<(), stats_claw::error::Error>(())
280/// ```
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub struct CategoricalLogOdds {
283    /// The number of categories `k`; the free-parameter count is `k − 1`.
284    pub n_categories: usize,
285}
286
287impl CategoricalLogOdds {
288    /// Computes the log-probabilities `[ln p₀ … ln p_{k−1}]` from the free logits.
289    ///
290    /// The full logit vector pins the reference category at `z₀ = 0` and appends
291    /// the `k − 1` free logits; the normalizer is evaluated with log-sum-exp for
292    /// stability.
293    ///
294    /// # Arguments
295    ///
296    /// * `params` — the `k − 1` free logits `z₁ … z_{k−1}`.
297    ///
298    /// # Returns
299    ///
300    /// `Some([ln p₀ … ln p_{k−1}])` (length `k`), or `None` when `k == 0`, when
301    /// `params.len() != k − 1`, or when any logit is non-finite.
302    fn log_probabilities(self, params: &[f64]) -> Option<Vec<f64>> {
303        let k = self.n_categories;
304        if k == 0 || params.len() + 1 != k {
305            return None;
306        }
307        if !params.iter().all(|z| z.is_finite()) {
308            return None;
309        }
310        // Full logits: the reference category 0 pinned at 0, then the free logits.
311        let mut logits = Vec::with_capacity(k);
312        logits.push(0.0_f64);
313        logits.extend_from_slice(params);
314        // log-sum-exp normalizer: m + ln Σ exp(zᵢ − m). Subtracting the max keeps
315        // every exponential in (0, 1], so nothing overflows.
316        let m = logits.iter().copied().fold(f64::NEG_INFINITY, f64::max);
317        let sum_exp: f64 = logits.iter().map(|z| (z - m).exp()).sum();
318        let lse = m + sum_exp.ln();
319        Some(logits.iter().map(|z| z - lse).collect())
320    }
321
322    /// Converts the free logits `params` into the probability vector `[p₀ … p_{k−1}]`.
323    ///
324    /// This is the softmax `pⱼ = exp(zⱼ) / Σᵢ exp(zᵢ)` (with `z₀ ≔ 0`), the
325    /// inverse of [`from_probabilities`](CategoricalLogOdds::from_probabilities).
326    ///
327    /// # Arguments
328    ///
329    /// * `params` — the `k − 1` free logits `z₁ … z_{k−1}`.
330    ///
331    /// # Returns
332    ///
333    /// The probability vector `[p₀ … p_{k−1}]`, which sums to `1`.
334    ///
335    /// # Errors
336    ///
337    /// [`Error::InvalidInput`] when `params` does not have length `k − 1` (or
338    /// `k == 0`), or when any logit is non-finite.
339    ///
340    /// # Examples
341    ///
342    /// ```
343    /// use stats_claw::likelihood::categorical::CategoricalLogOdds;
344    ///
345    /// let model = CategoricalLogOdds { n_categories: 3 };
346    /// // Equal logits give the uniform distribution.
347    /// let p = model.probabilities(&[0.0, 0.0])?;
348    /// assert!((p[0] - 1.0 / 3.0).abs() < 1e-12, "p0 was {}", p[0]);
349    /// # Ok::<(), stats_claw::error::Error>(())
350    /// ```
351    pub fn probabilities(&self, params: &[f64]) -> Result<Vec<f64>> {
352        self.log_probabilities(params)
353            .map(|log_p| log_p.iter().map(|l| l.exp()).collect())
354            .ok_or_else(|| {
355                Error::InvalidInput(format!(
356                    "params must be {} finite logits for {} categories",
357                    self.n_categories.saturating_sub(1),
358                    self.n_categories
359                ))
360            })
361    }
362
363    /// Builds a free-logit vector from a probability vector `p`, `zⱼ = ln(pⱼ / p₀)`.
364    ///
365    /// This is the inverse of [`probabilities`](CategoricalLogOdds::probabilities):
366    /// it maps an interior simplex point to the `k − 1` free logits, taking
367    /// category 0 as the reference. It is the natural way to seed
368    /// [`fit_mle`](crate::likelihood::fit_mle) from a probability guess.
369    ///
370    /// # Arguments
371    ///
372    /// * `p` — an interior probability vector `[p₀ … p_{k−1}]`: non-empty, every
373    ///   entry finite and strictly positive, summing to `1` within
374    ///   [`SIMPLEX_TOLERANCE`].
375    ///
376    /// # Returns
377    ///
378    /// The `k − 1` free logits `[z₁ … z_{k−1}]` (empty when `k == 1`).
379    ///
380    /// # Errors
381    ///
382    /// [`Error::InvalidInput`] when `p` is empty, when any entry (in particular
383    /// the reference `p₀`) is non-finite or not strictly positive, or when the
384    /// entries do not sum to `1` within [`SIMPLEX_TOLERANCE`]. Strict positivity
385    /// is required because `ln(pⱼ / p₀)` is finite only on the open simplex.
386    ///
387    /// # Examples
388    ///
389    /// ```
390    /// use stats_claw::likelihood::categorical::CategoricalLogOdds;
391    ///
392    /// let z = CategoricalLogOdds::from_probabilities(&[0.2, 0.3, 0.5])?;
393    /// // z₁ = ln(0.3 / 0.2) = ln 1.5.
394    /// assert!((z[0] - 1.5_f64.ln()).abs() < 1e-12, "z1 was {}", z[0]);
395    /// # Ok::<(), stats_claw::error::Error>(())
396    /// ```
397    pub fn from_probabilities(p: &[f64]) -> Result<Vec<f64>> {
398        let Some((&p0, rest)) = p.split_first() else {
399            return Err(Error::InvalidInput(
400                "probability vector must be non-empty".to_owned(),
401            ));
402        };
403        if !p.iter().all(|&pj| pj.is_finite() && pj > 0.0) {
404            return Err(Error::InvalidInput(format!(
405                "probabilities must be finite and strictly positive for log-odds, p0 was {p0}"
406            )));
407        }
408        let sum: f64 = p.iter().sum();
409        if (sum - 1.0).abs() > SIMPLEX_TOLERANCE {
410            return Err(Error::InvalidInput(format!(
411                "probabilities must sum to 1 within tolerance, sum was {sum}"
412            )));
413        }
414        let ln_p0 = p0.ln();
415        Ok(rest.iter().map(|&pj| pj.ln() - ln_p0).collect())
416    }
417}
418
419impl LogLikelihood for CategoricalLogOdds {
420    /// Returns `k − 1`, the number of free logits (`0` when `k ≤ 1`).
421    fn n_params(&self) -> usize {
422        self.n_categories.saturating_sub(1)
423    }
424
425    /// Evaluates `Σⱼ countⱼ · ln pⱼ(z)`, the categorical log-likelihood under the
426    /// softmax of the free logits `params`.
427    ///
428    /// # Arguments
429    ///
430    /// * `params` — the `k − 1` free logits `z₁ … z_{k−1}`.
431    /// * `data` — the observed category indices, each a non-negative integer
432    ///   below `k` stored as `f64`.
433    ///
434    /// # Returns
435    ///
436    /// The log-likelihood, or [`f64::NEG_INFINITY`] when `params` has the wrong
437    /// length or a non-finite logit, or when `data` holds an out-of-range or
438    /// non-finite index (the same data-validation rules as
439    /// [`CategoricalLikelihoodModel`], per the [`LogLikelihood`] contract). Every
440    /// `pⱼ` is strictly positive here (finite logits), so `ln pⱼ` is always
441    /// finite; a zero-count category still contributes `0` (the `0 · ln 0 ≔ 0`
442    /// convention).
443    fn log_likelihood(&self, params: &[f64], data: &[f64]) -> f64 {
444        let Some(log_p) = self.log_probabilities(params) else {
445            return f64::NEG_INFINITY;
446        };
447        let k = self.n_categories;
448        if !data.iter().all(|&x| is_valid_index(x, k)) {
449            return f64::NEG_INFINITY;
450        }
451        let mut total = 0.0;
452        for (j, &log_pj) in log_p.iter().enumerate() {
453            let count = category_count(data, j);
454            // 0·ln0 ≔ 0: an unobserved category adds nothing (log_pj is finite).
455            if count > 0.0 {
456                total = count.mul_add(log_pj, total);
457            }
458        }
459        total
460    }
461}
462
463#[cfg(test)]
464mod tests;