Skip to main content

greeners_glm/
mnlogit.rs

1use greeners_core::error::GreenersError;
2use greeners_core::linalg::LinalgInverse as _;
3use greeners_core::{DataFrame, Formula, InferenceType};
4use ndarray::{Array1, Array2};
5use statrs::distribution::{ContinuousCDF, Normal};
6use std::fmt;
7
8/// Result from Multinomial Logit regression.
9#[derive(Debug)]
10pub struct MNLogitResult {
11    /// Coefficients: (k x J-1) — one column per non-base category.
12    pub params: Array2<f64>,
13    /// Standard errors: (k x J-1).
14    pub std_errors: Array2<f64>,
15    /// Z-values: (k x J-1).
16    pub z_values: Array2<f64>,
17    /// P-values: (k x J-1).
18    pub p_values: Array2<f64>,
19    pub log_likelihood: f64,
20    pub pseudo_r2: f64,
21    pub aic: f64,
22    pub bic: f64,
23    pub n_obs: usize,
24    pub n_categories: usize,
25    pub iterations: usize,
26    pub converged: bool,
27    pub category_labels: Vec<f64>,
28    pub inference_type: InferenceType,
29    pub variable_names: Option<Vec<String>>,
30    pub omitted_vars: Vec<(usize, String)>,
31    _x_data: Array2<f64>,
32}
33
34impl fmt::Display for MNLogitResult {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        writeln!(f, "\n{:=^78}", " Multinomial Logit Regression Results ")?;
37        writeln!(
38            f,
39            "{:<20} {:>15} || {:<20} {:>15.4}",
40            "No. Observations:", self.n_obs, "Log-Likelihood:", self.log_likelihood
41        )?;
42        writeln!(
43            f,
44            "{:<20} {:>15} || {:<20} {:>15.4}",
45            "No. Categories:", self.n_categories, "Pseudo R-sq:", self.pseudo_r2
46        )?;
47        writeln!(
48            f,
49            "{:<20} {:>15} || {:<20} {:>15.4}",
50            "Method:", "Newton-Raphson", "AIC:", self.aic
51        )?;
52        writeln!(
53            f,
54            "{:<20} {:>15} || {:<20} {:>15.4}",
55            "Iterations:", self.iterations, "BIC:", self.bic
56        )?;
57
58        let j_minus_1 = self.n_categories - 1;
59        let base_label = self.category_labels[self.n_categories - 1];
60
61        for j in 0..j_minus_1 {
62            let cat_label = self.category_labels[j];
63            writeln!(
64                f,
65                "\n{:-^78}",
66                format!("y={} vs base y={}", cat_label, base_label)
67            )?;
68            writeln!(
69                f,
70                "{:<12} {:>10} {:>10} {:>8} {:>8}",
71                "", "coef", "std err", "z", "P>|z|"
72            )?;
73            writeln!(f, "{:-^78}", "")?;
74
75            for i in 0..self.params.nrows() {
76                let name = self
77                    .variable_names
78                    .as_ref()
79                    .and_then(|n| n.get(i).cloned())
80                    .unwrap_or_else(|| format!("x{}", i));
81                writeln!(
82                    f,
83                    "{:<12} {:>10.4} {:>10.4} {:>8.3} {:>8.3}",
84                    name,
85                    self.params[[i, j]],
86                    self.std_errors[[i, j]],
87                    self.z_values[[i, j]],
88                    self.p_values[[i, j]]
89                )?;
90            }
91        }
92
93        writeln!(f, "{:=^78}", "")?;
94        for (_, name) in &self.omitted_vars {
95            writeln!(f, "note: {} omitted because of collinearity", name)?;
96        }
97        Ok(())
98    }
99}
100
101impl MNLogitResult {
102    /// Predicted probabilities for each category: (n x J).
103    pub fn predict_proba(&self, x: &Array2<f64>) -> Array2<f64> {
104        let n = x.nrows();
105        let j = self.n_categories;
106        let j_minus_1 = j - 1;
107        let mut probs = Array2::<f64>::zeros((n, j));
108
109        for i in 0..n {
110            let x_i = x.row(i);
111            let mut max_eta = 0.0f64; //base category eta = 0
112            let mut etas = vec![0.0; j];
113            #[allow(clippy::needless_range_loop)]
114            for c in 0..j_minus_1 {
115                etas[c] = x_i.dot(&self.params.column(c));
116                max_eta = max_eta.max(etas[c]);
117            }
118            //Category
119            etas[j_minus_1] = 0.0;
120            max_eta = max_eta.max(0.0);
121
122            // Softmax with log-sum-exp trick
123            let mut sum_exp = 0.0;
124            for c in 0..j {
125                let e = (etas[c] - max_eta).exp();
126                probs[[i, c]] = e;
127                sum_exp += e;
128            }
129            for c in 0..j {
130                probs[[i, c]] /= sum_exp;
131            }
132        }
133
134        probs
135    }
136
137    /// Predicted category (argmax of probabilities).
138    pub fn predict(&self, x: &Array2<f64>) -> Array1<f64> {
139        let probs = self.predict_proba(x);
140        let n = probs.nrows();
141        let mut predictions = Array1::<f64>::zeros(n);
142        for i in 0..n {
143            let row = probs.row(i);
144            let mut max_idx = 0;
145            let mut max_val = row[0];
146            for (c, &v) in row.iter().enumerate() {
147                if v > max_val {
148                    max_val = v;
149                    max_idx = c;
150                }
151            }
152            predictions[i] = self.category_labels[max_idx];
153        }
154        predictions
155    }
156
157    /// Relative Risk Ratios: exp(β). Shape (k x J-1).
158    pub fn rrr(&self) -> Array2<f64> {
159        self.params.mapv(f64::exp)
160    }
161
162    /// Model stats: (AIC, BIC, LogLik, PseudoR2).
163    pub fn model_stats(&self) -> (f64, f64, f64, f64) {
164        (self.aic, self.bic, self.log_likelihood, self.pseudo_r2)
165    }
166}
167
168/// Multinomial Logit estimator.
169pub struct MNLogit;
170
171impl MNLogit {
172    /// Fit via formula.
173    pub fn from_formula(
174        formula: &Formula,
175        data: &DataFrame,
176    ) -> Result<MNLogitResult, GreenersError> {
177        let (y, x) = data.to_design_matrix(formula)?;
178        let var_names = data.formula_var_names(formula)?;
179        Self::fit_with_names(&y, &x, Some(var_names))
180    }
181
182    /// Fit from arrays.
183    pub fn fit(y: &Array1<f64>, x: &Array2<f64>) -> Result<MNLogitResult, GreenersError> {
184        Self::fit_with_names(y, x, None)
185    }
186
187    /// Fit with variable names.
188    pub fn fit_with_names(
189        y: &Array1<f64>,
190        x: &Array2<f64>,
191        variable_names: Option<Vec<String>>,
192    ) -> Result<MNLogitResult, GreenersError> {
193        let n = x.nrows();
194        let _k = x.ncols();
195
196        // Validate input
197        if y.iter().any(|v| !v.is_finite()) || x.iter().any(|v| !v.is_finite()) {
198            return Err(GreenersError::InvalidOperation(
199                "Input data contains NaN or Inf values".into(),
200            ));
201        }
202
203        // Detect and sort unique categories
204        let mut categories: Vec<f64> = y.iter().copied().collect();
205        categories.sort_by(|a, b| a.total_cmp(b));
206        categories.dedup();
207        let j = categories.len();
208
209        if j < 3 {
210            return Err(GreenersError::InvalidOperation(
211                "MNLogit requires at least 3 categories. Use Logit for binary outcomes.".into(),
212            ));
213        }
214
215        let j_minus_1 = j - 1;
216
217        // Map y values to category indices
218        let y_idx: Vec<usize> = y
219            .iter()
220            .map(|val| {
221                categories
222                    .iter()
223                    .position(|c| (c - val).abs() < 1e-10)
224                    .unwrap_or(0)
225            })
226            .collect();
227
228        let (x_clean, omitted_positioned, clean_var_names) = if let Some(ref names) = variable_names
229        {
230            let cr = greeners_core::linalg::drop_collinear(x, names, 1e-10);
231            (cr.x_clean, cr.omitted, cr.clean_names)
232        } else {
233            (x.clone(), vec![], vec![])
234        };
235
236        let x_use = &x_clean;
237        let k_clean = x_use.ncols();
238
239        if n <= k_clean * j_minus_1 {
240            return Err(GreenersError::ShapeMismatch(
241                "Not enough observations for multinomial logit".into(),
242            ));
243        }
244
245        // Newton-Raphson optimization
246        // Parameter vector: β = [β_1; β_2; ...; β_{J-1}] of length k*(J-1)
247        let total_params = k_clean * j_minus_1;
248        let mut beta = Array1::<f64>::zeros(total_params);
249
250        let tol = 1e-6;
251        let max_iter = 100;
252        let mut converged = false;
253        let mut iter = 0;
254        let mut log_likelihood = 0.0;
255
256        for iteration in 0..max_iter {
257            iter = iteration + 1;
258
259            // Compute probabilities (softmax)
260            let mut probs = Array2::<f64>::zeros((n, j));
261            for i in 0..n {
262                let x_i = x_use.row(i);
263                let mut max_eta = 0.0f64;
264                let mut etas = vec![0.0; j];
265                #[allow(clippy::needless_range_loop)]
266                for c in 0..j_minus_1 {
267                    let beta_c = beta.slice(ndarray::s![c * k_clean..(c + 1) * k_clean]);
268                    etas[c] = x_i.dot(&beta_c);
269                    max_eta = max_eta.max(etas[c]);
270                }
271                etas[j_minus_1] = 0.0;
272                max_eta = max_eta.max(0.0);
273
274                let mut sum_exp = 0.0;
275                for c in 0..j {
276                    let e = (etas[c] - max_eta).exp();
277                    probs[[i, c]] = e;
278                    sum_exp += e;
279                }
280                for c in 0..j {
281                    probs[[i, c]] /= sum_exp;
282                    probs[[i, c]] = probs[[i, c]].clamp(1e-15, 1.0 - 1e-15);
283                }
284            }
285
286            // Log-likelihood
287            log_likelihood = 0.0;
288            for i in 0..n {
289                log_likelihood += probs[[i, y_idx[i]]].ln();
290            }
291
292            // Gradient: g_c = X' * (d_c - p_c) for each c in 0..J-1
293            let mut gradient = Array1::<f64>::zeros(total_params);
294            for c in 0..j_minus_1 {
295                for i in 0..n {
296                    let indicator = if y_idx[i] == c { 1.0 } else { 0.0 };
297                    let diff = indicator - probs[[i, c]];
298                    for kk in 0..k_clean {
299                        gradient[c * k_clean + kk] += x_use[[i, kk]] * diff;
300                    }
301                }
302            }
303
304            // Hessian: H_{c,c'} = -X' diag(p_c * (δ_{cc'} - p_{c'})) X
305            let mut hessian = Array2::<f64>::zeros((total_params, total_params));
306            for c in 0..j_minus_1 {
307                for c2 in 0..j_minus_1 {
308                    // Block (c, c2) of size k_clean x k_clean
309                    for i in 0..n {
310                        let w = if c == c2 {
311                            -probs[[i, c]] * (1.0 - probs[[i, c]])
312                        } else {
313                            probs[[i, c]] * probs[[i, c2]]
314                        };
315                        for kk in 0..k_clean {
316                            for ll in 0..k_clean {
317                                hessian[[c * k_clean + kk, c2 * k_clean + ll]] +=
318                                    w * x_use[[i, kk]] * x_use[[i, ll]];
319                            }
320                        }
321                    }
322                }
323            }
324
325            // Newton step: delta = -H^{-1} * g
326            let neg_hessian = -&hessian;
327            let inv_neg_hessian = match neg_hessian.inv() {
328                Ok(m) => m,
329                Err(_) => return Err(GreenersError::OptimizationFailed),
330            };
331
332            let change = inv_neg_hessian.dot(&gradient);
333            beta = &beta + &change;
334
335            let diff = change.mapv(|v| v.powi(2)).sum().sqrt();
336            if diff < tol {
337                converged = true;
338                break;
339            }
340        }
341
342        if !converged {
343            return Err(GreenersError::OptimizationFailed);
344        }
345
346        // Extract parameter matrices and compute standard errors
347        // Recompute Hessian at final estimates for covariance
348        let mut probs = Array2::<f64>::zeros((n, j));
349        for i in 0..n {
350            let x_i = x_use.row(i);
351            let mut max_eta = 0.0f64;
352            let mut etas = vec![0.0; j];
353            #[allow(clippy::needless_range_loop)]
354            for c in 0..j_minus_1 {
355                let beta_c = beta.slice(ndarray::s![c * k_clean..(c + 1) * k_clean]);
356                etas[c] = x_i.dot(&beta_c);
357                max_eta = max_eta.max(etas[c]);
358            }
359            etas[j_minus_1] = 0.0;
360            max_eta = max_eta.max(0.0);
361
362            let mut sum_exp = 0.0;
363            for c in 0..j {
364                let e = (etas[c] - max_eta).exp();
365                probs[[i, c]] = e;
366                sum_exp += e;
367            }
368            for c in 0..j {
369                probs[[i, c]] /= sum_exp;
370                probs[[i, c]] = probs[[i, c]].clamp(1e-15, 1.0 - 1e-15);
371            }
372        }
373
374        let mut hessian = Array2::<f64>::zeros((total_params, total_params));
375        for c in 0..j_minus_1 {
376            for c2 in 0..j_minus_1 {
377                for i in 0..n {
378                    let w = if c == c2 {
379                        -probs[[i, c]] * (1.0 - probs[[i, c]])
380                    } else {
381                        probs[[i, c]] * probs[[i, c2]]
382                    };
383                    for kk in 0..k_clean {
384                        for ll in 0..k_clean {
385                            hessian[[c * k_clean + kk, c2 * k_clean + ll]] +=
386                                w * x_use[[i, kk]] * x_use[[i, ll]];
387                        }
388                    }
389                }
390            }
391        }
392
393        let cov_matrix = (-&hessian).inv()?;
394
395        // Build result matrices
396        let mut params_mat = Array2::<f64>::zeros((k_clean, j_minus_1));
397        let mut se_mat = Array2::<f64>::zeros((k_clean, j_minus_1));
398        let mut z_mat = Array2::<f64>::zeros((k_clean, j_minus_1));
399        let mut p_mat = Array2::<f64>::zeros((k_clean, j_minus_1));
400
401        let normal_dist = Normal::standard();
402
403        for c in 0..j_minus_1 {
404            for kk in 0..k_clean {
405                let idx = c * k_clean + kk;
406                params_mat[[kk, c]] = beta[idx];
407                let se = cov_matrix[[idx, idx]].max(0.0).sqrt();
408                se_mat[[kk, c]] = se;
409                let z = if se > 1e-15 { beta[idx] / se } else { 0.0 };
410                z_mat[[kk, c]] = z;
411                p_mat[[kk, c]] = 2.0 * (1.0 - normal_dist.cdf(z.abs()));
412            }
413        }
414
415        // Null log-likelihood (intercept only = proportional frequencies)
416        let mut freq = vec![0.0; j];
417        for &idx in &y_idx {
418            freq[idx] += 1.0;
419        }
420        let ll_null: f64 = y_idx.iter().map(|&idx| (freq[idx] / n as f64).ln()).sum();
421
422        let pseudo_r2 = 1.0 - log_likelihood / ll_null;
423        let k_total = total_params as f64;
424        let aic = -2.0 * log_likelihood + 2.0 * k_total;
425        let bic = -2.0 * log_likelihood + k_total * (n as f64).ln();
426
427        Ok(MNLogitResult {
428            params: params_mat,
429            std_errors: se_mat,
430            z_values: z_mat,
431            p_values: p_mat,
432            log_likelihood,
433            pseudo_r2,
434            aic,
435            bic,
436            n_obs: n,
437            n_categories: j,
438            iterations: iter,
439            converged,
440            category_labels: categories,
441            inference_type: InferenceType::Normal,
442            variable_names: if !clean_var_names.is_empty() {
443                Some(clean_var_names)
444            } else {
445                variable_names
446            },
447            omitted_vars: omitted_positioned,
448            _x_data: x_use.clone(),
449        })
450    }
451}