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#[derive(Debug)]
10pub struct MNLogitResult {
11 pub params: Array2<f64>,
13 pub std_errors: Array2<f64>,
15 pub z_values: Array2<f64>,
17 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 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; 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 etas[j_minus_1] = 0.0;
120 max_eta = max_eta.max(0.0);
121
122 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 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 pub fn rrr(&self) -> Array2<f64> {
159 self.params.mapv(f64::exp)
160 }
161
162 pub fn model_stats(&self) -> (f64, f64, f64, f64) {
164 (self.aic, self.bic, self.log_likelihood, self.pseudo_r2)
165 }
166}
167
168pub struct MNLogit;
170
171impl MNLogit {
172 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 pub fn fit(y: &Array1<f64>, x: &Array2<f64>) -> Result<MNLogitResult, GreenersError> {
184 Self::fit_with_names(y, x, None)
185 }
186
187 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 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 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 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 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 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 = 0.0;
288 for i in 0..n {
289 log_likelihood += probs[[i, y_idx[i]]].ln();
290 }
291
292 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 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 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 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 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 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 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}