Skip to main content

regression_diagnostics/categorical/
multinomial.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2use statrs::distribution::{ContinuousCDF, Normal};
3
4use crate::error::{RegressionError, Result};
5use crate::linalg::dmatrix_from_rows;
6
7/// Probability floor used to keep log-likelihood and weights finite.
8const PROB_EPS: f64 = 1e-12;
9
10/// A fitted **baseline-category (multinomial) logistic** model for an unordered
11/// categorical response with `K ≥ 2` classes.
12///
13/// Class `0` is the reference. For each non-reference class `k = 1 … K−1` there
14/// is a coefficient vector `βₖ` and linear predictor `ηᵢₖ = xᵢᵀβₖ` (with
15/// `ηᵢ₀ ≡ 0`), giving the softmax probabilities
16///
17/// `P(yᵢ = k) = exp(ηᵢₖ) / (1 + Σⱼ exp(ηᵢⱼ))`.
18///
19/// Fit by **Newton–Raphson** on the full `(K−1)·p` parameter vector: at
20/// convergence the per-class score equations `Xᵀ(yₖ − pₖ) = 0` hold, and the
21/// inverse of the block information matrix gives the coefficient covariance the
22/// Wald statistics use.
23///
24/// With `K = 2` this reduces exactly to binary
25/// [`LogisticFit`](crate::logistic::LogisticFit) (the `βₖ` for the single
26/// non-reference class equal the logistic coefficients).
27#[derive(Debug, Clone)]
28pub struct MultinomialFit {
29    x: Array2<f64>,
30    /// Integer class labels `0 … K−1`, one per observation.
31    y: Array1<f64>,
32    /// Coefficients, shape `(K−1) × p`; row `k−1` is `βₖ` for class `k`.
33    coefficients: Array2<f64>,
34    /// Fitted class probabilities, shape `n × K`.
35    probabilities: Array2<f64>,
36    /// Covariance of the stacked `(K−1)·p` coefficient vector (class-major:
37    /// block `k−1` spans rows `(k−1)·p … k·p`).
38    cov: Array2<f64>,
39    log_likelihood: f64,
40    intercept_col: Option<usize>,
41    iterations: usize,
42    n: usize,
43    p: usize,
44    k: usize,
45}
46
47impl MultinomialFit {
48    /// Fit multinomial logistic regression of class-labelled `y` on `X`
49    /// (default: up to 100 Newton iterations, tolerance `1e-10` on the step).
50    ///
51    /// The response must hold integer class labels `0 … K−1` with every class
52    /// present; `K` is inferred as `max(y) + 1`. The caller owns the design
53    /// matrix, intercept column included.
54    ///
55    /// # Errors
56    ///
57    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
58    /// * [`RegressionError::InvalidResponse`] if labels are not consecutive
59    ///   integers from `0`, some class is empty, or there are fewer than two
60    ///   classes.
61    /// * [`RegressionError::RankDeficient`] if the information matrix is singular.
62    /// * [`RegressionError::NotConverged`] if Newton's method fails to converge.
63    pub fn new(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
64        Self::with_options(x, y, 100, 1e-10)
65    }
66
67    /// Like [`MultinomialFit::new`] with an explicit iteration cap and tolerance.
68    pub fn with_options(x: Array2<f64>, y: Array1<f64>, max_iter: usize, tol: f64) -> Result<Self> {
69        let n = x.nrows();
70        let p = x.ncols();
71        if n == 0 || p == 0 {
72            return Err(RegressionError::EmptyInput { what: "X" });
73        }
74        if y.len() != n {
75            return Err(RegressionError::ShapeMismatch {
76                what: "y length vs X rows",
77                expected: n,
78                got: y.len(),
79            });
80        }
81        let k = validate_labels(&y)?;
82        let m = (k - 1) * p; // stacked parameter length
83
84        let mut beta = Array2::<f64>::zeros((k - 1, p));
85        let mut probs = Array2::<f64>::zeros((n, k));
86        let mut cov = Array2::<f64>::zeros((m, m));
87        let mut iterations = 0usize;
88        let mut converged = false;
89
90        while iterations < max_iter {
91            iterations += 1;
92
93            fill_probabilities(&x, &beta, &mut probs);
94
95            // Gradient (length m) and information A = −H (m × m, class-major).
96            let mut grad = Array1::<f64>::zeros(m);
97            let mut info = Array2::<f64>::zeros((m, m));
98            for kk in 1..k {
99                let bk = kk - 1;
100                for a in 0..p {
101                    let mut g = 0.0;
102                    for i in 0..n {
103                        let yik = if y[i] as usize == kk { 1.0 } else { 0.0 };
104                        g += x[(i, a)] * (yik - probs[(i, kk)]);
105                    }
106                    grad[bk * p + a] = g;
107                }
108            }
109            for kk in 1..k {
110                for ll in 1..k {
111                    let bk = kk - 1;
112                    let bl = ll - 1;
113                    let delta = if kk == ll { 1.0 } else { 0.0 };
114                    for a in 0..p {
115                        for b in 0..p {
116                            let mut s = 0.0;
117                            for i in 0..n {
118                                let w = probs[(i, kk)] * (delta - probs[(i, ll)]);
119                                s += x[(i, a)] * w * x[(i, b)];
120                            }
121                            info[(bk * p + a, bl * p + b)] = s;
122                        }
123                    }
124                }
125            }
126
127            let info_dm = dmatrix_from_rows(m, m, info.as_standard_layout().as_slice().unwrap());
128            let inv = info_dm.try_inverse().ok_or(RegressionError::RankDeficient)?;
129            let inv_arr = Array2::from_shape_fn((m, m), |(i, j)| inv[(i, j)]);
130
131            // Newton ascent step Δ = A⁻¹ g.
132            let delta = inv_arr.dot(&grad);
133            for kk in 1..k {
134                let bk = kk - 1;
135                for a in 0..p {
136                    beta[(bk, a)] += delta[bk * p + a];
137                }
138            }
139            cov = inv_arr;
140
141            let step = delta.iter().fold(0.0_f64, |mx, v| mx.max(v.abs()));
142            if !beta.iter().all(|v| v.is_finite()) || beta.iter().any(|v| v.abs() > 1e8) {
143                return Err(RegressionError::NotConverged {
144                    iterations,
145                    msg: "coefficients diverging (likely separation)".into(),
146                });
147            }
148            if step < tol {
149                converged = true;
150                break;
151            }
152        }
153
154        if !converged {
155            return Err(RegressionError::NotConverged {
156                iterations,
157                msg: "Newton iteration did not reach tolerance".into(),
158            });
159        }
160
161        fill_probabilities(&x, &beta, &mut probs);
162        let log_likelihood = (0..n)
163            .map(|i| probs[(i, y[i] as usize)].max(PROB_EPS).ln())
164            .sum();
165
166        let intercept_col = detect_constant_column(&x);
167
168        Ok(Self {
169            x,
170            y,
171            coefficients: beta,
172            probabilities: probs,
173            cov,
174            log_likelihood,
175            intercept_col,
176            iterations,
177            n,
178            p,
179            k,
180        })
181    }
182
183    /// Number of observations.
184    pub fn n_observations(&self) -> usize {
185        self.n
186    }
187
188    /// Number of design columns `p` (per class).
189    pub fn n_features(&self) -> usize {
190        self.p
191    }
192
193    /// Number of response classes `K`.
194    pub fn n_classes(&self) -> usize {
195        self.k
196    }
197
198    /// Total number of free coefficients, `(K − 1)·p`.
199    pub fn n_parameters(&self) -> usize {
200        (self.k - 1) * self.p
201    }
202
203    /// Whether a constant (intercept) column was detected.
204    pub fn has_intercept(&self) -> bool {
205        self.intercept_col.is_some()
206    }
207
208    /// Newton iterations taken to converge.
209    pub fn iterations(&self) -> usize {
210        self.iterations
211    }
212
213    /// The design matrix as fitted.
214    pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
215        self.x.view()
216    }
217
218    /// The integer class labels.
219    pub fn response(&self) -> ArrayView1<'_, f64> {
220        self.y.view()
221    }
222
223    /// Coefficients, shape `(K−1) × p`; row `k−1` is `βₖ` for class `k` relative
224    /// to the reference class `0`.
225    pub fn coefficients(&self) -> ArrayView2<'_, f64> {
226        self.coefficients.view()
227    }
228
229    /// Fitted class probabilities, shape `n × K`.
230    pub fn fitted_probabilities(&self) -> ArrayView2<'_, f64> {
231        self.probabilities.view()
232    }
233
234    /// Covariance of the stacked `(K−1)·p` coefficient vector (class-major).
235    pub fn covariance(&self) -> ArrayView2<'_, f64> {
236        self.cov.view()
237    }
238
239    /// Maximized log-likelihood.
240    pub fn log_likelihood(&self) -> f64 {
241        self.log_likelihood
242    }
243
244    /// Coefficient standard errors, shape `(K−1) × p`, aligned with
245    /// [`coefficients`](Self::coefficients).
246    pub fn coefficient_standard_errors(&self) -> Array2<f64> {
247        Array2::from_shape_fn((self.k - 1, self.p), |(bk, a)| {
248            let idx = bk * self.p + a;
249            self.cov[(idx, idx)].max(0.0).sqrt()
250        })
251    }
252
253    /// Wald `z`-statistics `βₖⱼ / seₖⱼ`, shape `(K−1) × p`.
254    pub fn z_values(&self) -> Array2<f64> {
255        let se = self.coefficient_standard_errors();
256        Array2::from_shape_fn((self.k - 1, self.p), |(bk, a)| {
257            if se[(bk, a)] > 0.0 {
258                self.coefficients[(bk, a)] / se[(bk, a)]
259            } else {
260                f64::NAN
261            }
262        })
263    }
264
265    /// Two-sided Wald p-values from the standard normal, shape `(K−1) × p`.
266    pub fn p_values(&self) -> Array2<f64> {
267        let z = self.z_values();
268        let normal = Normal::new(0.0, 1.0).expect("standard normal");
269        Array2::from_shape_fn((self.k - 1, self.p), |(bk, a)| {
270            let zv = z[(bk, a)];
271            if zv.is_finite() {
272                2.0 * (1.0 - normal.cdf(zv.abs()))
273            } else {
274                f64::NAN
275            }
276        })
277    }
278
279    /// Residual deviance `−2ℓ`.
280    pub fn residual_deviance(&self) -> f64 {
281        -2.0 * self.log_likelihood
282    }
283
284    /// Deviance of the intercept-only model (class marginals `nₖ/n`).
285    pub fn null_deviance(&self) -> f64 {
286        -2.0 * self.null_log_likelihood()
287    }
288
289    fn null_log_likelihood(&self) -> f64 {
290        let n = self.n as f64;
291        let mut counts = vec![0.0_f64; self.k];
292        for &yi in self.y.iter() {
293            counts[yi as usize] += 1.0;
294        }
295        counts
296            .iter()
297            .filter(|&&c| c > 0.0)
298            .map(|&c| c * (c / n).ln())
299            .sum()
300    }
301
302    /// McFadden's pseudo-R², `1 − ℓ/ℓ₀`.
303    pub fn mcfadden_r2(&self) -> f64 {
304        let ll0 = self.null_log_likelihood();
305        if ll0 != 0.0 {
306            1.0 - self.log_likelihood / ll0
307        } else {
308            f64::NAN
309        }
310    }
311
312    /// Akaike information criterion, `−2ℓ + 2·(K−1)·p`.
313    pub fn aic(&self) -> f64 {
314        self.residual_deviance() + 2.0 * self.n_parameters() as f64
315    }
316
317    /// Bayesian information criterion, `−2ℓ + ln(n)·(K−1)·p`.
318    pub fn bic(&self) -> f64 {
319        self.residual_deviance() + (self.n as f64).ln() * self.n_parameters() as f64
320    }
321
322    /// Predicted class probabilities for a new design matrix `x` (same column
323    /// layout as training), shape `rows × K`.
324    pub fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Array2<f64> {
325        let rows = x.nrows();
326        let mut out = Array2::<f64>::zeros((rows, self.k));
327        // Reuse the softmax with x as design; borrow via a temporary owned copy.
328        let xo = x.to_owned();
329        fill_probabilities(&xo, &self.coefficients, &mut out);
330        out
331    }
332}
333
334/// Per-observation deviance residuals `√(−2 ln p_{i,yᵢ}) ≥ 0`.
335///
336/// Each observation's contribution to the residual deviance is `−2 ln p_{i,yᵢ}`,
337/// the log-probability the model assigned to the class that actually occurred;
338/// the residuals square to the residual deviance. Unlike the binary case there
339/// is no natural sign, so these are returned non-negative — large values flag
340/// observations the model fits poorly (assigned low probability to the truth).
341pub fn deviance_residuals(fit: &MultinomialFit) -> Array1<f64> {
342    let y = fit.response();
343    let p = fit.fitted_probabilities();
344    Array1::from_shape_fn(fit.n_observations(), |i| {
345        let pi = p[(i, y[i] as usize)].max(PROB_EPS);
346        (-2.0 * pi.ln()).max(0.0).sqrt()
347    })
348}
349
350/// Softmax probabilities into `probs` (n × K) given `beta` ((K−1) × p).
351fn fill_probabilities(x: &Array2<f64>, beta: &Array2<f64>, probs: &mut Array2<f64>) {
352    let n = x.nrows();
353    let p = x.ncols();
354    let k = beta.nrows() + 1;
355    for i in 0..n {
356        // η_i0 = 0; η_ik = xᵢ·βₖ. Subtract the max for numerical stability.
357        let mut eta = vec![0.0_f64; k];
358        let mut maxe = 0.0_f64;
359        for kk in 1..k {
360            let mut e = 0.0;
361            for a in 0..p {
362                e += x[(i, a)] * beta[(kk - 1, a)];
363            }
364            eta[kk] = e;
365            if e > maxe {
366                maxe = e;
367            }
368        }
369        let mut denom = 0.0;
370        for e in eta.iter_mut() {
371            *e = (*e - maxe).exp();
372            denom += *e;
373        }
374        for kk in 0..k {
375            probs[(i, kk)] = eta[kk] / denom;
376        }
377    }
378}
379
380/// Validate that labels are consecutive integers `0 … K−1`, each class present,
381/// with `K ≥ 2`. Returns `K`.
382fn validate_labels(y: &Array1<f64>) -> Result<usize> {
383    let mut max_label = 0usize;
384    for &v in y.iter() {
385        if !v.is_finite() || v < 0.0 || v.fract() != 0.0 {
386            return Err(RegressionError::InvalidResponse {
387                msg: format!("class labels must be non-negative integers, found {v}"),
388            });
389        }
390        max_label = max_label.max(v as usize);
391    }
392    let k = max_label + 1;
393    if k < 2 {
394        return Err(RegressionError::InvalidResponse {
395            msg: "multinomial response needs at least two classes".into(),
396        });
397    }
398    let mut present = vec![false; k];
399    for &v in y.iter() {
400        present[v as usize] = true;
401    }
402    if let Some(missing) = present.iter().position(|&b| !b) {
403        return Err(RegressionError::InvalidResponse {
404            msg: format!("class {missing} has no observations; labels must be 0..K-1 with all present"),
405        });
406    }
407    Ok(k)
408}
409
410/// Detect the first constant column (treated as the intercept).
411fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
412    for (j, col) in x.columns().into_iter().enumerate() {
413        let first = col[0];
414        let scale = first.abs().max(1.0);
415        if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
416            return Some(j);
417        }
418    }
419    None
420}