Skip to main content

regression_diagnostics/survival/
cox.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/// How tied event times are handled in the Cox partial likelihood.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Ties {
10    /// **Efron's** approximation — more accurate with ties; the default in R's
11    /// `survival::coxph` and lifelines.
12    Efron,
13    /// **Breslow's** approximation — simpler, treats each tied event against the
14    /// full risk set.
15    Breslow,
16}
17
18/// A fitted **Cox proportional-hazards** model and its diagnostics.
19///
20/// Models the hazard `hᵢ(t) = h₀(t)·exp(xᵢᵀβ)` and estimates `β` by maximizing
21/// the **partial likelihood** — the baseline `h₀(t)` drops out, so no intercept
22/// is used (a constant column would be unidentifiable and is rejected). Fit by
23/// Newton–Raphson; at the maximum the score `U(β) = 0` and the inverse observed
24/// information gives the coefficient covariance behind the Wald `z`-statistics.
25///
26/// The coefficients are **log hazard ratios**: `exp(βⱼ)` is the multiplicative
27/// effect of a one-unit increase in predictor `j` on the hazard. Tied event
28/// times use [`Ties::Efron`] by default.
29///
30/// Two extensions share the same machinery:
31///
32/// * [`stratified`](CoxFit::stratified) fits a **separate baseline per stratum**
33///   with shared coefficients — the standard remedy when the proportional-hazards
34///   assumption holds only within subgroups.
35/// * [`counting_process`](CoxFit::counting_process) accepts `(start, stop]`
36///   intervals for **time-varying covariates**, where each row is one at-risk
37///   episode and a subject contributes several rows.
38#[derive(Debug, Clone)]
39pub struct CoxFit {
40    /// Interval start times (`0` for the ordinary right-censored form).
41    start: Array1<f64>,
42    /// Event/censoring (stop) times.
43    time: Array1<f64>,
44    /// Event indicators (`1.0` event, `0.0` right-censored).
45    event: Array1<f64>,
46    /// Stratum label per row (all `0` when unstratified).
47    strata: Vec<usize>,
48    x: Array2<f64>,
49    coefficients: Array1<f64>,
50    cov: Array2<f64>,
51    log_partial_likelihood: f64,
52    /// Baseline hazard increments as `(stratum, time, dĤ₀)`, ascending by time
53    /// within each stratum.
54    baseline: Vec<(usize, f64, f64)>,
55    ties: Ties,
56    n_strata: usize,
57    iterations: usize,
58    n: usize,
59    p: usize,
60}
61
62impl CoxFit {
63    /// Fit a Cox model of survival `(time, event)` on covariates `X` with Efron
64    /// tie handling (default: up to 100 Newton iterations, tolerance `1e-9`).
65    ///
66    /// `event[i]` is `1.0` for an observed event and `0.0` for right-censoring.
67    /// **Do not include an intercept column** — the baseline hazard absorbs it.
68    ///
69    /// # Errors
70    ///
71    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
72    /// * [`RegressionError::InvalidResponse`] if any time is non-positive, an
73    ///   event flag is not `0/1`, there are no events, or `X` has a constant
74    ///   (intercept) column.
75    /// * [`RegressionError::RankDeficient`] / [`RegressionError::NotConverged`].
76    pub fn new(time: Array1<f64>, event: Array1<f64>, x: Array2<f64>) -> Result<Self> {
77        Self::with_options(time, event, x, Ties::Efron, 100, 1e-9)
78    }
79
80    /// Like [`CoxFit::new`] with an explicit tie-handling rule, iteration cap and
81    /// tolerance.
82    pub fn with_options(
83        time: Array1<f64>,
84        event: Array1<f64>,
85        x: Array2<f64>,
86        ties: Ties,
87        max_iter: usize,
88        tol: f64,
89    ) -> Result<Self> {
90        let n = x.nrows();
91        let start = Array1::<f64>::zeros(n);
92        let strata = vec![0usize; n];
93        Self::fit(start, time, event, x, strata, ties, max_iter, tol)
94    }
95
96    /// Fit a **stratified** Cox model: a separate baseline hazard per stratum,
97    /// with the coefficients `β` shared across strata. The partial likelihood is
98    /// summed over strata, each contributing only its own risk sets.
99    ///
100    /// `strata[i]` is the stratum label of observation `i` (arbitrary integers).
101    ///
102    /// # Errors
103    ///
104    /// As [`CoxFit::with_options`], plus [`RegressionError::ShapeMismatch`] if
105    /// `strata` has the wrong length.
106    pub fn stratified(
107        time: Array1<f64>,
108        event: Array1<f64>,
109        x: Array2<f64>,
110        strata: &[usize],
111        ties: Ties,
112    ) -> Result<Self> {
113        let n = x.nrows();
114        if strata.len() != n {
115            return Err(RegressionError::ShapeMismatch {
116                what: "strata length vs X rows",
117                expected: n,
118                got: strata.len(),
119            });
120        }
121        let start = Array1::<f64>::zeros(n);
122        Self::fit(start, time, event, x, densify(strata), ties, 100, 1e-9)
123    }
124
125    /// Fit a Cox model on **counting-process** `(start, stop]` data, the format
126    /// for **time-varying covariates**: each row is one at-risk episode with
127    /// covariates constant over `(startᵢ, stopᵢ]`, and a subject spanning changing
128    /// covariates appears as several consecutive rows. A row is in the risk set at
129    /// event time `t` when `startᵢ < t ≤ stopᵢ`.
130    ///
131    /// # Errors
132    ///
133    /// As [`CoxFit::with_options`], plus [`RegressionError::InvalidResponse`] if
134    /// any interval has `start ≥ stop`.
135    pub fn counting_process(
136        start: Array1<f64>,
137        stop: Array1<f64>,
138        event: Array1<f64>,
139        x: Array2<f64>,
140        ties: Ties,
141    ) -> Result<Self> {
142        let n = x.nrows();
143        if start.len() != n {
144            return Err(RegressionError::ShapeMismatch {
145                what: "start length vs X rows",
146                expected: n,
147                got: start.len(),
148            });
149        }
150        for i in 0..n {
151            if !matches!(
152                start[i].partial_cmp(&stop[i]),
153                Some(std::cmp::Ordering::Less)
154            ) {
155                return Err(RegressionError::InvalidResponse {
156                    msg: format!("interval {i} has start {} >= stop {}", start[i], stop[i]),
157                });
158            }
159        }
160        let strata = vec![0usize; n];
161        Self::fit(start, stop, event, x, strata, ties, 100, 1e-9)
162    }
163
164    #[allow(clippy::too_many_arguments)]
165    fn fit(
166        start: Array1<f64>,
167        time: Array1<f64>,
168        event: Array1<f64>,
169        x: Array2<f64>,
170        strata: Vec<usize>,
171        ties: Ties,
172        max_iter: usize,
173        tol: f64,
174    ) -> Result<Self> {
175        let n = x.nrows();
176        let p = x.ncols();
177        if n == 0 || p == 0 {
178            return Err(RegressionError::EmptyInput { what: "X" });
179        }
180        if time.len() != n || event.len() != n {
181            return Err(RegressionError::ShapeMismatch {
182                what: "time/event length vs X rows",
183                expected: n,
184                got: time.len().min(event.len()),
185            });
186        }
187        for &t in time.iter() {
188            if !t.is_finite() || t <= 0.0 {
189                return Err(RegressionError::InvalidResponse {
190                    msg: format!("survival times must be positive, found {t}"),
191                });
192            }
193        }
194        let mut n_events = 0usize;
195        for &e in event.iter() {
196            if e == 1.0 {
197                n_events += 1;
198            } else if e != 0.0 {
199                return Err(RegressionError::InvalidResponse {
200                    msg: format!("event indicator must be 0 or 1, found {e}"),
201                });
202            }
203        }
204        if n_events == 0 {
205            return Err(RegressionError::InvalidResponse {
206                msg: "no events observed; the partial likelihood is empty".into(),
207            });
208        }
209        if detect_constant_column(&x).is_some() {
210            return Err(RegressionError::InvalidResponse {
211                msg: "Cox design must not include an intercept/constant column".into(),
212            });
213        }
214
215        let n_strata = strata.iter().copied().max().map_or(0, |m| m + 1);
216
217        // Event points as (stratum, time), ascending, deduped within stratum.
218        let mut ev_points: Vec<(usize, f64)> = (0..n)
219            .filter(|&i| event[i] == 1.0)
220            .map(|i| (strata[i], time[i]))
221            .collect();
222        ev_points.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.partial_cmp(&b.1).unwrap()));
223        ev_points.dedup();
224
225        let mut beta = Array1::<f64>::zeros(p);
226        let mut cov = Array2::<f64>::zeros((p, p));
227        let mut iterations = 0usize;
228        let mut converged = false;
229
230        while iterations < max_iter {
231            iterations += 1;
232            let (_ll, score, info) =
233                partial_likelihood(&start, &time, &event, &x, &strata, &beta, &ev_points, ties);
234            let info_dm = dmatrix_from_rows(p, p, info.as_standard_layout().as_slice().unwrap());
235            let inv = info_dm.try_inverse().ok_or(RegressionError::RankDeficient)?;
236            let inv_arr = Array2::from_shape_fn((p, p), |(i, j)| inv[(i, j)]);
237            let delta = inv_arr.dot(&score);
238            beta = &beta + &delta;
239            cov = inv_arr;
240
241            let step = delta.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
242            if !beta.iter().all(|v| v.is_finite()) || beta.iter().any(|v| v.abs() > 1e8) {
243                return Err(RegressionError::NotConverged {
244                    iterations,
245                    msg: "coefficients diverging".into(),
246                });
247            }
248            if step < tol {
249                converged = true;
250                break;
251            }
252        }
253        if !converged {
254            return Err(RegressionError::NotConverged {
255                iterations,
256                msg: "Newton iteration did not reach tolerance".into(),
257            });
258        }
259
260        let (log_partial_likelihood, _, _) =
261            partial_likelihood(&start, &time, &event, &x, &strata, &beta, &ev_points, ties);
262        let baseline = breslow_baseline(&start, &time, &event, &x, &strata, &beta, &ev_points);
263
264        Ok(Self {
265            start,
266            time,
267            event,
268            strata,
269            x,
270            coefficients: beta,
271            cov,
272            log_partial_likelihood,
273            baseline,
274            ties,
275            n_strata,
276            iterations,
277            n,
278            p,
279        })
280    }
281
282    /// Number of observations (episodes, for counting-process data).
283    pub fn n_observations(&self) -> usize {
284        self.n
285    }
286
287    /// Number of covariates.
288    pub fn n_parameters(&self) -> usize {
289        self.p
290    }
291
292    /// Number of observed events.
293    pub fn n_events(&self) -> usize {
294        self.event.iter().filter(|&&e| e == 1.0).count()
295    }
296
297    /// Number of strata (`1` for an unstratified fit).
298    pub fn n_strata(&self) -> usize {
299        self.n_strata.max(1)
300    }
301
302    /// Tie-handling rule used.
303    pub fn ties(&self) -> Ties {
304        self.ties
305    }
306
307    /// Newton iterations taken to converge.
308    pub fn iterations(&self) -> usize {
309        self.iterations
310    }
311
312    /// The covariate matrix as fitted.
313    pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
314        self.x.view()
315    }
316
317    /// Event/censoring (stop) times.
318    pub fn time(&self) -> ArrayView1<'_, f64> {
319        self.time.view()
320    }
321
322    /// Event indicators.
323    pub fn event(&self) -> ArrayView1<'_, f64> {
324        self.event.view()
325    }
326
327    /// Estimated coefficients — **log hazard ratios**, aligned to the covariate
328    /// columns.
329    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
330        self.coefficients.view()
331    }
332
333    /// Hazard ratios `exp(βⱼ)`.
334    pub fn hazard_ratios(&self) -> Array1<f64> {
335        self.coefficients.mapv(f64::exp)
336    }
337
338    /// Coefficient covariance (inverse observed information at the MLE).
339    pub fn covariance(&self) -> ArrayView2<'_, f64> {
340        self.cov.view()
341    }
342
343    /// Maximized log partial likelihood.
344    pub fn log_partial_likelihood(&self) -> f64 {
345        self.log_partial_likelihood
346    }
347
348    /// Linear predictors (risk scores) `ηᵢ = xᵢᵀβ`.
349    pub fn linear_predictors(&self) -> Array1<f64> {
350        self.x.dot(&self.coefficients)
351    }
352
353    /// Coefficient standard errors `√diag(cov)`.
354    pub fn coefficient_standard_errors(&self) -> Array1<f64> {
355        Array1::from_shape_fn(self.p, |j| self.cov[(j, j)].max(0.0).sqrt())
356    }
357
358    /// Wald `z`-statistics `βⱼ / seⱼ`.
359    pub fn z_values(&self) -> Array1<f64> {
360        let se = self.coefficient_standard_errors();
361        Array1::from_shape_fn(self.p, |j| {
362            if se[j] > 0.0 {
363                self.coefficients[j] / se[j]
364            } else {
365                f64::NAN
366            }
367        })
368    }
369
370    /// Two-sided Wald p-values from the standard normal.
371    pub fn p_values(&self) -> Array1<f64> {
372        let z = self.z_values();
373        let normal = Normal::new(0.0, 1.0).expect("standard normal");
374        Array1::from_shape_fn(self.p, |j| {
375            if z[j].is_finite() {
376                2.0 * (1.0 - normal.cdf(z[j].abs()))
377            } else {
378                f64::NAN
379            }
380        })
381    }
382
383    /// AIC for the Cox model, `−2·ℓ_partial + 2p`.
384    pub fn aic(&self) -> f64 {
385        -2.0 * self.log_partial_likelihood + 2.0 * self.p as f64
386    }
387
388    /// The **Breslow baseline cumulative hazard** `Ĥ₀(t)` for stratum `s` as
389    /// `(time, H₀)` pairs at the distinct event times (a right-continuous step
390    /// function). For an unstratified fit use `s = 0`.
391    pub fn baseline_cumulative_hazard_stratum(&self, s: usize) -> Vec<(f64, f64)> {
392        let mut cum = 0.0;
393        self.baseline
394            .iter()
395            .filter(|(st, _, _)| *st == s)
396            .map(|&(_, t, dh)| {
397                cum += dh;
398                (t, cum)
399            })
400            .collect()
401    }
402
403    /// The Breslow baseline cumulative hazard for the first stratum — the common
404    /// unstratified case.
405    pub fn baseline_cumulative_hazard(&self) -> Vec<(f64, f64)> {
406        self.baseline_cumulative_hazard_stratum(0)
407    }
408
409    /// Estimated cumulative hazard for observation `i`,
410    /// `Ĥᵢ = exp(ηᵢ)·[Ĥ₀(stopᵢ) − Ĥ₀(startᵢ)]` in its own stratum — used by the
411    /// martingale / deviance residuals (the `startᵢ` term is zero for the
412    /// ordinary form).
413    pub(crate) fn cumulative_hazard_at(&self, i: usize) -> f64 {
414        let eta_i: f64 = (0..self.p).map(|j| self.x[(i, j)] * self.coefficients[j]).sum();
415        let s = self.strata[i];
416        let (a, b) = (self.start[i], self.time[i]);
417        let h0: f64 = self
418            .baseline
419            .iter()
420            .filter(|(st, t, _)| *st == s && *t > a && *t <= b)
421            .map(|(_, _, dh)| *dh)
422            .sum();
423        eta_i.exp() * h0
424    }
425
426    /// Harrell's **concordance index** (C-index): the fraction of comparable
427    /// subject pairs (within the same stratum) whose predicted risk ordering
428    /// agrees with their observed survival ordering. `0.5` is chance, `1.0`
429    /// perfect; ties in risk count as half. `NaN` if there are no comparable
430    /// pairs.
431    pub fn concordance(&self) -> f64 {
432        let eta = self.linear_predictors();
433        let mut concordant = 0.0;
434        let mut comparable = 0.0;
435        for i in 0..self.n {
436            if self.event[i] != 1.0 {
437                continue;
438            }
439            for j in 0..self.n {
440                if i == j || self.strata[i] != self.strata[j] {
441                    continue;
442                }
443                if self.time[j] > self.time[i]
444                    || (self.time[j] == self.time[i] && self.event[j] == 0.0)
445                {
446                    comparable += 1.0;
447                    if eta[i] > eta[j] {
448                        concordant += 1.0;
449                    } else if (eta[i] - eta[j]).abs() < 1e-12 {
450                        concordant += 0.5;
451                    }
452                }
453            }
454        }
455        if comparable > 0.0 {
456            concordant / comparable
457        } else {
458            f64::NAN
459        }
460    }
461
462    pub(crate) fn coef_slice(&self) -> ArrayView1<'_, f64> {
463        self.coefficients.view()
464    }
465
466    /// Whether observation `j` is in the risk set at time `t` in stratum `s`
467    /// (used by the Schoenfeld residuals).
468    pub(crate) fn at_risk(&self, j: usize, t: f64, s: usize) -> bool {
469        self.strata[j] == s && self.start[j] < t && self.time[j] >= t
470    }
471
472    pub(crate) fn stratum_of(&self, i: usize) -> usize {
473        self.strata[i]
474    }
475}
476
477/// Compute the (Efron/Breslow) log partial likelihood, score and observed
478/// information at `beta`, summed over strata. `ev_points` are `(stratum, time)`.
479#[allow(clippy::too_many_arguments)]
480fn partial_likelihood(
481    start: &Array1<f64>,
482    time: &Array1<f64>,
483    event: &Array1<f64>,
484    x: &Array2<f64>,
485    strata: &[usize],
486    beta: &Array1<f64>,
487    ev_points: &[(usize, f64)],
488    ties: Ties,
489) -> (f64, Array1<f64>, Array2<f64>) {
490    let n = x.nrows();
491    let p = x.ncols();
492    let eta: Vec<f64> = (0..n)
493        .map(|i| (0..p).map(|j| x[(i, j)] * beta[j]).sum::<f64>())
494        .collect();
495    let w: Vec<f64> = eta.iter().map(|e| e.exp()).collect();
496
497    let mut ll = 0.0;
498    let mut score = Array1::<f64>::zeros(p);
499    let mut info = Array2::<f64>::zeros((p, p));
500
501    for &(stratum, t) in ev_points {
502        let mut sr0 = 0.0;
503        let mut sr1 = vec![0.0; p];
504        let mut sr2 = vec![0.0; p * p];
505        let mut sd0 = 0.0;
506        let mut sd1 = vec![0.0; p];
507        let mut sd2 = vec![0.0; p * p];
508        let mut m = 0usize;
509        for i in 0..n {
510            if strata[i] != stratum {
511                continue;
512            }
513            // Risk set: start < t <= stop.
514            if start[i] < t && time[i] >= t {
515                sr0 += w[i];
516                for a in 0..p {
517                    sr1[a] += w[i] * x[(i, a)];
518                    for b in 0..p {
519                        sr2[a * p + b] += w[i] * x[(i, a)] * x[(i, b)];
520                    }
521                }
522                if time[i] == t && event[i] == 1.0 {
523                    m += 1;
524                    ll += eta[i];
525                    for a in 0..p {
526                        score[a] += x[(i, a)];
527                        sd1[a] += w[i] * x[(i, a)];
528                        for b in 0..p {
529                            sd2[a * p + b] += w[i] * x[(i, a)] * x[(i, b)];
530                        }
531                    }
532                    sd0 += w[i];
533                }
534            }
535        }
536        if m == 0 {
537            continue;
538        }
539        let steps = match ties {
540            Ties::Breslow => 1,
541            Ties::Efron => m,
542        };
543        for l in 0..steps {
544            let frac = match ties {
545                Ties::Breslow => 0.0,
546                Ties::Efron => l as f64 / m as f64,
547            };
548            let mult = match ties {
549                Ties::Breslow => m as f64,
550                Ties::Efron => 1.0,
551            };
552            let d0 = sr0 - frac * sd0;
553            ll -= mult * d0.ln();
554            for a in 0..p {
555                let d1a = sr1[a] - frac * sd1[a];
556                score[a] -= mult * d1a / d0;
557                for b in 0..p {
558                    let d1b = sr1[b] - frac * sd1[b];
559                    let d2ab = sr2[a * p + b] - frac * sd2[a * p + b];
560                    info[(a, b)] += mult * (d2ab / d0 - (d1a * d1b) / (d0 * d0));
561                }
562            }
563        }
564    }
565    (ll, score, info)
566}
567
568/// Breslow baseline increments `(stratum, time, dĤ₀)` at each event point.
569#[allow(clippy::too_many_arguments)]
570fn breslow_baseline(
571    start: &Array1<f64>,
572    time: &Array1<f64>,
573    event: &Array1<f64>,
574    x: &Array2<f64>,
575    strata: &[usize],
576    beta: &Array1<f64>,
577    ev_points: &[(usize, f64)],
578) -> Vec<(usize, f64, f64)> {
579    let n = x.nrows();
580    let p = x.ncols();
581    let w: Vec<f64> = (0..n)
582        .map(|i| (0..p).map(|j| x[(i, j)] * beta[j]).sum::<f64>().exp())
583        .collect();
584    ev_points
585        .iter()
586        .map(|&(stratum, t)| {
587            let mut risk = 0.0;
588            let mut d = 0.0;
589            for i in 0..n {
590                if strata[i] == stratum && start[i] < t && time[i] >= t {
591                    risk += w[i];
592                    if time[i] == t && event[i] == 1.0 {
593                        d += 1.0;
594                    }
595                }
596            }
597            (stratum, t, if risk > 0.0 { d / risk } else { 0.0 })
598        })
599        .collect()
600}
601
602/// Remap arbitrary integer labels to a dense `0..k` range, preserving first-seen
603/// order.
604fn densify(labels: &[usize]) -> Vec<usize> {
605    let mut map = std::collections::BTreeMap::new();
606    labels
607        .iter()
608        .map(|&l| {
609            let next = map.len();
610            *map.entry(l).or_insert(next)
611        })
612        .collect()
613}
614
615fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
616    for (j, col) in x.columns().into_iter().enumerate() {
617        let first = col[0];
618        let scale = first.abs().max(1.0);
619        if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
620            return Some(j);
621        }
622    }
623    None
624}