regression_diagnostics/categorical/
ordinal.rs1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2use statrs::distribution::{ContinuousCDF, Normal};
3
4use crate::error::{RegressionError, Result};
5use crate::linalg::dmatrix_from_rows;
6
7#[derive(Debug, Clone)]
24pub struct OrdinalFit {
25 x: Array2<f64>,
26 y: Array1<f64>,
27 thresholds: Array1<f64>,
29 coefficients: Array1<f64>,
31 probabilities: Array2<f64>,
33 cov: Array2<f64>,
35 log_likelihood: f64,
36 iterations: usize,
37 n: usize,
38 p: usize,
39 k: usize,
40}
41
42impl OrdinalFit {
43 pub fn new(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
57 Self::with_options(x, y, 100, 1e-10)
58 }
59
60 pub fn with_options(x: Array2<f64>, y: Array1<f64>, max_iter: usize, tol: f64) -> Result<Self> {
62 let n = x.nrows();
63 let p = x.ncols();
64 if n == 0 || p == 0 {
65 return Err(RegressionError::EmptyInput { what: "X" });
66 }
67 if y.len() != n {
68 return Err(RegressionError::ShapeMismatch {
69 what: "y length vs X rows",
70 expected: n,
71 got: y.len(),
72 });
73 }
74 let k = validate_labels(&y)?;
75 let n_thresh = k - 1;
76 let m = n_thresh + p;
77
78 let mut counts = vec![0.0_f64; k];
80 for &yi in y.iter() {
81 counts[yi as usize] += 1.0;
82 }
83 let mut theta = Array1::<f64>::zeros(m);
84 let mut cum = 0.0;
85 for kk in 0..n_thresh {
86 cum += counts[kk];
87 let prop = (cum / n as f64).clamp(1e-4, 1.0 - 1e-4);
88 theta[kk] = (prop / (1.0 - prop)).ln();
89 }
90
91 let mut iterations = 0usize;
92 let mut converged = false;
93 let mut cov = Array2::<f64>::zeros((m, m));
94
95 let mut nll = neg_log_likelihood(&x, &y, &theta, k);
96 while iterations < max_iter {
97 iterations += 1;
98
99 let grad = gradient(&x, &y, &theta, k);
100 let hess = hessian(&x, &y, &theta, k);
101
102 let hess_dm = dmatrix_from_rows(m, m, hess.as_standard_layout().as_slice().unwrap());
103 let inv = hess_dm.try_inverse().ok_or(RegressionError::RankDeficient)?;
104 let inv_arr = Array2::from_shape_fn((m, m), |(i, j)| inv[(i, j)]);
105 cov = inv_arr.clone();
106
107 let mut step = inv_arr.dot(&grad);
109 step.mapv_inplace(|v| -v);
110
111 let mut scale = 1.0_f64;
114 let mut new_theta = &theta + &step;
115 let mut new_nll = f64::INFINITY;
116 for _ in 0..30 {
117 new_theta = &theta + &(&step * scale);
118 if thresholds_ordered(&new_theta, n_thresh) {
119 new_nll = neg_log_likelihood(&x, &y, &new_theta, k);
120 if new_nll.is_finite() && new_nll <= nll + 1e-12 {
121 break;
122 }
123 }
124 scale *= 0.5;
125 }
126
127 let max_step = step
128 .iter()
129 .map(|v| (v * scale).abs())
130 .fold(0.0_f64, f64::max);
131 theta = new_theta;
132 nll = new_nll;
133
134 if !theta.iter().all(|v| v.is_finite()) {
135 return Err(RegressionError::NotConverged {
136 iterations,
137 msg: "parameters diverging".into(),
138 });
139 }
140 if max_step < tol {
141 converged = true;
142 break;
143 }
144 }
145
146 if !converged {
147 return Err(RegressionError::NotConverged {
148 iterations,
149 msg: "Newton iteration did not reach tolerance".into(),
150 });
151 }
152
153 let thresholds = Array1::from_shape_fn(n_thresh, |i| theta[i]);
154 let coefficients = Array1::from_shape_fn(p, |i| theta[n_thresh + i]);
155
156 let mut probabilities = Array2::<f64>::zeros((n, k));
157 fill_probabilities(&x, &thresholds, &coefficients, &mut probabilities);
158 let log_likelihood = -nll;
159
160 Ok(Self {
161 x,
162 y,
163 thresholds,
164 coefficients,
165 probabilities,
166 cov,
167 log_likelihood,
168 iterations,
169 n,
170 p,
171 k,
172 })
173 }
174
175 pub fn n_observations(&self) -> usize {
177 self.n
178 }
179
180 pub fn n_features(&self) -> usize {
182 self.p
183 }
184
185 pub fn n_classes(&self) -> usize {
187 self.k
188 }
189
190 pub fn n_parameters(&self) -> usize {
192 (self.k - 1) + self.p
193 }
194
195 pub fn iterations(&self) -> usize {
197 self.iterations
198 }
199
200 pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
202 self.x.view()
203 }
204
205 pub fn response(&self) -> ArrayView1<'_, f64> {
207 self.y.view()
208 }
209
210 pub fn thresholds(&self) -> ArrayView1<'_, f64> {
212 self.thresholds.view()
213 }
214
215 pub fn coefficients(&self) -> ArrayView1<'_, f64> {
217 self.coefficients.view()
218 }
219
220 pub fn fitted_probabilities(&self) -> ArrayView2<'_, f64> {
222 self.probabilities.view()
223 }
224
225 pub fn covariance(&self) -> ArrayView2<'_, f64> {
227 self.cov.view()
228 }
229
230 pub fn log_likelihood(&self) -> f64 {
232 self.log_likelihood
233 }
234
235 pub fn coefficient_standard_errors(&self) -> Array1<f64> {
238 let off = self.k - 1;
239 Array1::from_shape_fn(self.p, |j| self.cov[(off + j, off + j)].max(0.0).sqrt())
240 }
241
242 pub fn threshold_standard_errors(&self) -> Array1<f64> {
244 Array1::from_shape_fn(self.k - 1, |j| self.cov[(j, j)].max(0.0).sqrt())
245 }
246
247 pub fn z_values(&self) -> Array1<f64> {
249 let se = self.coefficient_standard_errors();
250 Array1::from_shape_fn(self.p, |j| {
251 if se[j] > 0.0 {
252 self.coefficients[j] / se[j]
253 } else {
254 f64::NAN
255 }
256 })
257 }
258
259 pub fn p_values(&self) -> Array1<f64> {
261 let z = self.z_values();
262 let normal = Normal::new(0.0, 1.0).expect("standard normal");
263 Array1::from_shape_fn(self.p, |j| {
264 if z[j].is_finite() {
265 2.0 * (1.0 - normal.cdf(z[j].abs()))
266 } else {
267 f64::NAN
268 }
269 })
270 }
271
272 pub fn residual_deviance(&self) -> f64 {
274 -2.0 * self.log_likelihood
275 }
276
277 pub fn null_deviance(&self) -> f64 {
279 -2.0 * self.null_log_likelihood()
280 }
281
282 fn null_log_likelihood(&self) -> f64 {
283 let n = self.n as f64;
284 let mut counts = vec![0.0_f64; self.k];
285 for &yi in self.y.iter() {
286 counts[yi as usize] += 1.0;
287 }
288 counts
289 .iter()
290 .filter(|&&c| c > 0.0)
291 .map(|&c| c * (c / n).ln())
292 .sum()
293 }
294
295 pub fn mcfadden_r2(&self) -> f64 {
297 let ll0 = self.null_log_likelihood();
298 if ll0 != 0.0 {
299 1.0 - self.log_likelihood / ll0
300 } else {
301 f64::NAN
302 }
303 }
304
305 pub fn aic(&self) -> f64 {
307 self.residual_deviance() + 2.0 * self.n_parameters() as f64
308 }
309
310 pub fn bic(&self) -> f64 {
312 self.residual_deviance() + (self.n as f64).ln() * self.n_parameters() as f64
313 }
314
315 pub fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Array2<f64> {
318 let xo = x.to_owned();
319 let mut out = Array2::<f64>::zeros((xo.nrows(), self.k));
320 fill_probabilities(&xo, &self.thresholds, &self.coefficients, &mut out);
321 out
322 }
323}
324
325pub fn deviance_residuals(fit: &OrdinalFit) -> Array1<f64> {
328 let y = fit.response();
329 let p = fit.fitted_probabilities();
330 Array1::from_shape_fn(fit.n_observations(), |i| {
331 let pi = p[(i, y[i] as usize)].max(1e-12);
332 (-2.0 * pi.ln()).max(0.0).sqrt()
333 })
334}
335
336fn sigmoid(z: f64) -> f64 {
337 if z >= 0.0 {
338 1.0 / (1.0 + (-z).exp())
339 } else {
340 let e = z.exp();
341 e / (1.0 + e)
342 }
343}
344
345fn cell_terms(eta: f64, alpha: &[f64], c: usize, k: usize) -> (f64, f64, f64, f64) {
348 let (s_a, sp_a) = if c == k - 1 {
349 (1.0, 0.0)
350 } else {
351 let a = alpha[c] - eta;
352 let s = sigmoid(a);
353 (s, s * (1.0 - s))
354 };
355 let (s_b, sp_b) = if c == 0 {
356 (0.0, 0.0)
357 } else {
358 let b = alpha[c - 1] - eta;
359 let s = sigmoid(b);
360 (s, s * (1.0 - s))
361 };
362 (s_a, s_b, sp_a, sp_b)
363}
364
365fn neg_log_likelihood(x: &Array2<f64>, y: &Array1<f64>, theta: &Array1<f64>, k: usize) -> f64 {
366 let n = x.nrows();
367 let p = x.ncols();
368 let n_thresh = k - 1;
369 let alpha = &theta.as_slice().unwrap()[0..n_thresh];
370 let beta = &theta.as_slice().unwrap()[n_thresh..];
371 let mut nll = 0.0;
372 for i in 0..n {
373 let mut eta = 0.0;
374 for j in 0..p {
375 eta += x[(i, j)] * beta[j];
376 }
377 let c = y[i] as usize;
378 let (s_a, s_b, _, _) = cell_terms(eta, alpha, c, k);
379 let prob = (s_a - s_b).max(1e-12);
380 nll -= prob.ln();
381 }
382 nll
383}
384
385fn gradient(x: &Array2<f64>, y: &Array1<f64>, theta: &Array1<f64>, k: usize) -> Array1<f64> {
386 let n = x.nrows();
387 let p = x.ncols();
388 let n_thresh = k - 1;
389 let m = n_thresh + p;
390 let alpha = &theta.as_slice().unwrap()[0..n_thresh];
391 let beta = &theta.as_slice().unwrap()[n_thresh..];
392 let mut g = Array1::<f64>::zeros(m); for i in 0..n {
394 let mut eta = 0.0;
395 for j in 0..p {
396 eta += x[(i, j)] * beta[j];
397 }
398 let c = y[i] as usize;
399 let (s_a, s_b, sp_a, sp_b) = cell_terms(eta, alpha, c, k);
400 let prob = (s_a - s_b).max(1e-12);
401 if c < n_thresh {
403 g[c] -= sp_a / prob;
404 }
405 if c >= 1 {
406 g[c - 1] -= -sp_b / prob;
407 }
408 let common = (sp_a - sp_b) / prob;
410 for j in 0..p {
411 g[n_thresh + j] += x[(i, j)] * common;
412 }
413 }
414 g
415}
416
417fn hessian(x: &Array2<f64>, y: &Array1<f64>, theta: &Array1<f64>, k: usize) -> Array2<f64> {
420 let m = theta.len();
421 let mut h = Array2::<f64>::zeros((m, m));
422 let eps = 1e-6;
423 for j in 0..m {
424 let mut tp = theta.clone();
425 let mut tm = theta.clone();
426 let step = eps * theta[j].abs().max(1.0);
427 tp[j] += step;
428 tm[j] -= step;
429 let gp = gradient(x, y, &tp, k);
430 let gm = gradient(x, y, &tm, k);
431 for i in 0..m {
432 h[(i, j)] = (gp[i] - gm[i]) / (2.0 * step);
433 }
434 }
435 for i in 0..m {
437 for j in (i + 1)..m {
438 let avg = 0.5 * (h[(i, j)] + h[(j, i)]);
439 h[(i, j)] = avg;
440 h[(j, i)] = avg;
441 }
442 }
443 h
444}
445
446fn thresholds_ordered(theta: &Array1<f64>, n_thresh: usize) -> bool {
447 for k in 1..n_thresh {
448 if theta[k] <= theta[k - 1] {
449 return false;
450 }
451 }
452 true
453}
454
455fn fill_probabilities(
456 x: &Array2<f64>,
457 alpha: &Array1<f64>,
458 beta: &Array1<f64>,
459 probs: &mut Array2<f64>,
460) {
461 let n = x.nrows();
462 let p = x.ncols();
463 let k = alpha.len() + 1;
464 for i in 0..n {
465 let mut eta = 0.0;
466 for j in 0..p {
467 eta += x[(i, j)] * beta[j];
468 }
469 let mut prev = 0.0;
470 for c in 0..k {
471 let cdf = if c == k - 1 {
472 1.0
473 } else {
474 sigmoid(alpha[c] - eta)
475 };
476 probs[(i, c)] = (cdf - prev).max(0.0);
477 prev = cdf;
478 }
479 }
480}
481
482fn validate_labels(y: &Array1<f64>) -> Result<usize> {
483 let mut max_label = 0usize;
484 for &v in y.iter() {
485 if !v.is_finite() || v < 0.0 || v.fract() != 0.0 {
486 return Err(RegressionError::InvalidResponse {
487 msg: format!("ordinal labels must be non-negative integers, found {v}"),
488 });
489 }
490 max_label = max_label.max(v as usize);
491 }
492 let k = max_label + 1;
493 if k < 2 {
494 return Err(RegressionError::InvalidResponse {
495 msg: "ordinal response needs at least two levels".into(),
496 });
497 }
498 let mut present = vec![false; k];
499 for &v in y.iter() {
500 present[v as usize] = true;
501 }
502 if let Some(missing) = present.iter().position(|&b| !b) {
503 return Err(RegressionError::InvalidResponse {
504 msg: format!("level {missing} has no observations; labels must be 0..K-1 with all present"),
505 });
506 }
507 Ok(k)
508}