Skip to main content

qualia_core_db/solvers/learning/survival/
cox.rs

1//! Cox proportional-hazards regression (ISL ch 11.5) — semiparametric hazard model
2//! `h(t|x) = h₀(t)·exp(βᵀx)`, fit by maximizing the Breslow partial likelihood with
3//! Newton–Raphson. The p×p information-matrix solve reuses `linear_algebra::cholesky`
4//! (no new solver); Wald standard errors / p-values use `statistics::distributions`.
5
6use crate::solvers::learning::LearningError;
7use crate::solvers::linear_algebra::cholesky::{cholesky_factor, cholesky_solve};
8use crate::solvers::statistics::distributions::normal;
9
10/// A fitted Cox model.
11#[derive(Debug, Clone)]
12pub struct CoxModel {
13    pub coefficients: Vec<f64>,
14    pub std_errors: Vec<f64>,
15    /// Wald z-statistics (coefficient / std error). A positive coefficient means the
16    /// covariate increases the hazard (shortens survival).
17    pub z_values: Vec<f64>,
18    pub p_values: Vec<f64>,
19    pub log_partial_likelihood: f64,
20    pub n_iter: usize,
21    pub converged: bool,
22}
23
24const MAX_ITER: usize = 100;
25const TOL: f64 = 1e-8;
26
27/// Fit Cox PH of right-censored `(times, event)` on a row-major `n × p` covariate
28/// matrix. `event[i] = true` is an observed event, `false` is right-censoring.
29/// Fails closed: `InvalidDimension`, `InsufficientData`, `Singular`, `NotConverged`.
30pub fn fit(
31    x: &[f64],
32    times: &[f64],
33    event: &[bool],
34    n: usize,
35    p: usize,
36) -> Result<CoxModel, LearningError> {
37    if n == 0 || p == 0 || x.len() != n * p || times.len() != n || event.len() != n {
38        return Err(LearningError::InvalidDimension);
39    }
40    if event.iter().filter(|&&e| e).count() == 0 {
41        return Err(LearningError::InsufficientData); // no events ⇒ nothing to fit
42    }
43
44    let mut beta = vec![0.0; p];
45    let mut grad = vec![0.0; p];
46    let mut info = vec![0.0; p * p];
47    let mut converged = false;
48    let mut iters = 0;
49    let mut log_pl = 0.0;
50
51    for it in 1..=MAX_ITER {
52        iters = it;
53        grad.iter_mut().for_each(|v| *v = 0.0);
54        info.iter_mut().for_each(|v| *v = 0.0);
55        log_pl = 0.0;
56
57        // Linear predictors and weights.
58        let mut w = vec![0.0; n];
59        for j in 0..n {
60            let eta: f64 = (0..p).map(|c| beta[c] * x[j * p + c]).sum();
61            w[j] = eta.exp();
62        }
63
64        for i in 0..n {
65            if !event[i] {
66                continue;
67            }
68            // Risk set R_i = { j : times[j] >= times[i] }, Breslow.
69            let ti = times[i];
70            let mut sum_w = 0.0;
71            let mut sum_wx = vec![0.0; p];
72            let mut sum_wxx = vec![0.0; p * p];
73            for j in 0..n {
74                if times[j] >= ti {
75                    let wj = w[j];
76                    sum_w += wj;
77                    for a in 0..p {
78                        let xa = x[j * p + a];
79                        sum_wx[a] += wj * xa;
80                        for b in 0..p {
81                            sum_wxx[a * p + b] += wj * xa * x[j * p + b];
82                        }
83                    }
84                }
85            }
86            if sum_w <= 0.0 {
87                continue;
88            }
89            let eta_i: f64 = (0..p).map(|c| beta[c] * x[i * p + c]).sum();
90            log_pl += eta_i - sum_w.ln();
91            // Gradient + observed information.
92            for a in 0..p {
93                let mean_a = sum_wx[a] / sum_w;
94                grad[a] += x[i * p + a] - mean_a;
95                for b in 0..p {
96                    let mean_b = sum_wx[b] / sum_w;
97                    info[a * p + b] += sum_wxx[a * p + b] / sum_w - mean_a * mean_b;
98                }
99            }
100        }
101
102        // Newton step: solve info · delta = grad (info is the observed information,
103        // positive-definite near the maximum).
104        let mut l = vec![0.0; p * p];
105        cholesky_factor(p, &info, &mut l).map_err(|_| LearningError::Singular)?;
106        let mut delta = vec![0.0; p];
107        cholesky_solve(p, &l, &grad, &mut delta)?;
108        let mut max_step = 0.0_f64;
109        for c in 0..p {
110            beta[c] += delta[c];
111            max_step = max_step.max(delta[c].abs());
112        }
113        if max_step < TOL {
114            converged = true;
115            break;
116        }
117    }
118
119    // Standard errors from info⁻¹ at the solution.
120    let mut l = vec![0.0; p * p];
121    cholesky_factor(p, &info, &mut l).map_err(|_| LearningError::Singular)?;
122
123    let mut std_errors = vec![0.0; p];
124    let mut z_values = vec![0.0; p];
125    let mut p_values = vec![0.0; p];
126    let mut ej = vec![0.0; p];
127    let mut cj = vec![0.0; p];
128    for a in 0..p {
129        ej.iter_mut().for_each(|v| *v = 0.0);
130        ej[a] = 1.0;
131        cholesky_solve(p, &l, &ej, &mut cj)?;
132        let se = if cj[a] > 0.0 { cj[a].sqrt() } else { 0.0 };
133        std_errors[a] = se;
134        if se > 0.0 {
135            let z = beta[a] / se;
136            z_values[a] = z;
137            p_values[a] = normal::two_sided_p(z);
138        }
139    }
140
141    Ok(CoxModel {
142        coefficients: beta,
143        std_errors,
144        z_values,
145        p_values,
146        log_partial_likelihood: log_pl,
147        n_iter: iters,
148        converged,
149    })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn higher_covariate_increases_hazard() {
158        // Higher x ⇒ generally shorter survival (with inversions so the MLE is
159        // finite) ⇒ positive coefficient.
160        let x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
161        let times = [8.0, 7.0, 5.0, 6.0, 3.0, 4.0, 2.0, 1.0];
162        let event = [true, true, true, true, true, true, true, true];
163        let m = fit(&x, &times, &event, 8, 1).unwrap();
164        assert!(m.converged);
165        assert!(m.coefficients[0] > 0.0, "coef {}", m.coefficients[0]);
166        assert!(m.std_errors[0] > 0.0 && m.std_errors[0].is_finite());
167        assert!(m.log_partial_likelihood.is_finite());
168    }
169
170    #[test]
171    fn protective_covariate_is_negative() {
172        // Higher x ⇒ generally LONGER survival (with inversions) ⇒ negative coef.
173        let x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
174        let times = [1.0, 2.0, 4.0, 3.0, 6.0, 5.0, 7.0, 8.0];
175        let event = [true, true, true, true, true, true, true, true];
176        let m = fit(&x, &times, &event, 8, 1).unwrap();
177        assert!(m.coefficients[0] < 0.0, "coef {}", m.coefficients[0]);
178    }
179
180    #[test]
181    fn guards() {
182        assert_eq!(
183            fit(&[1.0, 2.0], &[1.0, 2.0], &[false, false], 2, 1).unwrap_err(),
184            LearningError::InsufficientData
185        );
186        assert_eq!(
187            fit(&[1.0], &[1.0], &[true], 1, 2).unwrap_err(),
188            LearningError::InvalidDimension
189        );
190    }
191}