Skip to main content

regression_diagnostics/categorical/
ordinal.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/// A fitted **proportional-odds (ordinal logistic)** model for an ordered
8/// categorical response with `K ≥ 2` levels `0 < 1 < … < K−1`.
9///
10/// Uses the cumulative-logit parametrization of R's `MASS::polr`:
11///
12/// `logit P(yᵢ ≤ k) = αₖ − xᵢᵀβ`,  `k = 0 … K−2`,
13///
14/// with strictly increasing thresholds `α₀ < α₁ < … < α_{K−2}` and a **single**
15/// coefficient vector `β` shared across all thresholds (the proportional-odds
16/// assumption). Positive `βⱼ` raises the odds of falling in a *higher* category.
17/// The design matrix carries **no intercept** — the thresholds play that role.
18///
19/// Fit by Newton–Raphson on the full `(K−1) + p` parameter vector (thresholds
20/// then coefficients) with a backtracking line search. With `K = 2` this reduces
21/// exactly to binary [`LogisticFit`](crate::logistic::LogisticFit): `β` equals
22/// the logistic slopes and `α₀` equals the negated logistic intercept.
23#[derive(Debug, Clone)]
24pub struct OrdinalFit {
25    x: Array2<f64>,
26    y: Array1<f64>,
27    /// Threshold (cutpoint) parameters `α₀ < … < α_{K−2}`, length `K−1`.
28    thresholds: Array1<f64>,
29    /// Shared coefficient vector `β`, length `p`.
30    coefficients: Array1<f64>,
31    /// Fitted class probabilities, shape `n × K`.
32    probabilities: Array2<f64>,
33    /// Covariance of the stacked `(K−1)+p` parameter vector (thresholds first).
34    cov: Array2<f64>,
35    log_likelihood: f64,
36    iterations: usize,
37    n: usize,
38    p: usize,
39    k: usize,
40}
41
42impl OrdinalFit {
43    /// Fit a proportional-odds model of ordered `y` on `X` (default: up to 100
44    /// Newton iterations, tolerance `1e-10`).
45    ///
46    /// The response holds integer levels `0 … K−1`, every level present; `K` is
47    /// inferred as `max(y) + 1`. **Do not include an intercept column** in `X`.
48    ///
49    /// # Errors
50    ///
51    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
52    /// * [`RegressionError::InvalidResponse`] for non-integer/negative labels, a
53    ///   missing level, or fewer than two levels.
54    /// * [`RegressionError::RankDeficient`] if the information matrix is singular.
55    /// * [`RegressionError::NotConverged`] if Newton's method fails to converge.
56    pub fn new(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
57        Self::with_options(x, y, 100, 1e-10)
58    }
59
60    /// Like [`OrdinalFit::new`] with an explicit iteration cap and tolerance.
61    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        // Initialize thresholds from cumulative class frequencies, β = 0.
79        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            // Newton step for minimizing the nll: Δ = −H⁻¹ g.
108            let mut step = inv_arr.dot(&grad);
109            step.mapv_inplace(|v| -v);
110
111            // Backtracking line search: accept the step only if it decreases the
112            // nll (and keeps thresholds ordered), else halve.
113            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    /// Number of observations.
176    pub fn n_observations(&self) -> usize {
177        self.n
178    }
179
180    /// Number of design columns `p`.
181    pub fn n_features(&self) -> usize {
182        self.p
183    }
184
185    /// Number of ordered levels `K`.
186    pub fn n_classes(&self) -> usize {
187        self.k
188    }
189
190    /// Total number of parameters, `(K − 1) + p` (thresholds plus coefficients).
191    pub fn n_parameters(&self) -> usize {
192        (self.k - 1) + self.p
193    }
194
195    /// Newton iterations taken to converge.
196    pub fn iterations(&self) -> usize {
197        self.iterations
198    }
199
200    /// The design matrix as fitted.
201    pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
202        self.x.view()
203    }
204
205    /// The integer level labels.
206    pub fn response(&self) -> ArrayView1<'_, f64> {
207        self.y.view()
208    }
209
210    /// Threshold (cutpoint) parameters `α₀ < … < α_{K−2}`.
211    pub fn thresholds(&self) -> ArrayView1<'_, f64> {
212        self.thresholds.view()
213    }
214
215    /// Shared coefficient vector `β` (proportional-odds effects).
216    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
217        self.coefficients.view()
218    }
219
220    /// Fitted class probabilities, shape `n × K`.
221    pub fn fitted_probabilities(&self) -> ArrayView2<'_, f64> {
222        self.probabilities.view()
223    }
224
225    /// Covariance of the stacked `(K−1)+p` parameter vector (thresholds first).
226    pub fn covariance(&self) -> ArrayView2<'_, f64> {
227        self.cov.view()
228    }
229
230    /// Maximized log-likelihood.
231    pub fn log_likelihood(&self) -> f64 {
232        self.log_likelihood
233    }
234
235    /// Standard errors of the coefficients `β` (the last `p` diagonal entries of
236    /// the covariance).
237    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    /// Standard errors of the thresholds `α`.
243    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    /// Wald `z`-statistics for the coefficients, `βⱼ / seⱼ`.
248    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    /// Two-sided Wald p-values for the coefficients from the standard normal.
260    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    /// Residual deviance `−2ℓ`.
273    pub fn residual_deviance(&self) -> f64 {
274        -2.0 * self.log_likelihood
275    }
276
277    /// Deviance of the intercept-only (threshold-only, `β = 0`) model.
278    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    /// McFadden's pseudo-R², `1 − ℓ/ℓ₀`.
296    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    /// Akaike information criterion, `−2ℓ + 2·[(K−1)+p]`.
306    pub fn aic(&self) -> f64 {
307        self.residual_deviance() + 2.0 * self.n_parameters() as f64
308    }
309
310    /// Bayesian information criterion, `−2ℓ + ln(n)·[(K−1)+p]`.
311    pub fn bic(&self) -> f64 {
312        self.residual_deviance() + (self.n as f64).ln() * self.n_parameters() as f64
313    }
314
315    /// Predicted class probabilities for a new design matrix `x`, shape
316    /// `rows × K`.
317    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
325/// Per-observation deviance residuals `√(−2 ln P(yᵢ = cᵢ)) ≥ 0`; they square to
326/// the residual deviance.
327pub 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
345/// Cumulative CDFs for observation `i`: returns `(P(y≤c), P(y≤c−1))` and their
346/// densities `(σ'(A), σ'(B))`, handling the open ends `c = 0` and `c = K−1`.
347fn 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); // gradient of the nll
393    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        // ∂nll/∂α_m = −(1/P)(σ'(A)[m==c] − σ'(B)[m==c−1]).
402        if c < n_thresh {
403            g[c] -= sp_a / prob;
404        }
405        if c >= 1 {
406            g[c - 1] -= -sp_b / prob;
407        }
408        // ∂nll/∂β_j = (x_ij/P)(σ'(A) − σ'(B)).
409        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
417/// Observed information (Hessian of the nll) by central differences of the
418/// analytic gradient — robust and, for these small parameter vectors, cheap.
419fn 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    // Symmetrize to counter finite-difference asymmetry.
436    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}