regression_diagnostics/categorical/
multinomial.rs1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2use statrs::distribution::{ContinuousCDF, Normal};
3
4use crate::error::{RegressionError, Result};
5use crate::linalg::dmatrix_from_rows;
6
7const PROB_EPS: f64 = 1e-12;
9
10#[derive(Debug, Clone)]
28pub struct MultinomialFit {
29 x: Array2<f64>,
30 y: Array1<f64>,
32 coefficients: Array2<f64>,
34 probabilities: Array2<f64>,
36 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 pub fn new(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
64 Self::with_options(x, y, 100, 1e-10)
65 }
66
67 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; 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 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 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 pub fn n_observations(&self) -> usize {
185 self.n
186 }
187
188 pub fn n_features(&self) -> usize {
190 self.p
191 }
192
193 pub fn n_classes(&self) -> usize {
195 self.k
196 }
197
198 pub fn n_parameters(&self) -> usize {
200 (self.k - 1) * self.p
201 }
202
203 pub fn has_intercept(&self) -> bool {
205 self.intercept_col.is_some()
206 }
207
208 pub fn iterations(&self) -> usize {
210 self.iterations
211 }
212
213 pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
215 self.x.view()
216 }
217
218 pub fn response(&self) -> ArrayView1<'_, f64> {
220 self.y.view()
221 }
222
223 pub fn coefficients(&self) -> ArrayView2<'_, f64> {
226 self.coefficients.view()
227 }
228
229 pub fn fitted_probabilities(&self) -> ArrayView2<'_, f64> {
231 self.probabilities.view()
232 }
233
234 pub fn covariance(&self) -> ArrayView2<'_, f64> {
236 self.cov.view()
237 }
238
239 pub fn log_likelihood(&self) -> f64 {
241 self.log_likelihood
242 }
243
244 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 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 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 pub fn residual_deviance(&self) -> f64 {
281 -2.0 * self.log_likelihood
282 }
283
284 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 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 pub fn aic(&self) -> f64 {
314 self.residual_deviance() + 2.0 * self.n_parameters() as f64
315 }
316
317 pub fn bic(&self) -> f64 {
319 self.residual_deviance() + (self.n as f64).ln() * self.n_parameters() as f64
320 }
321
322 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 let xo = x.to_owned();
329 fill_probabilities(&xo, &self.coefficients, &mut out);
330 out
331 }
332}
333
334pub 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
350fn 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 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
380fn 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
410fn 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}