Skip to main content

solow_duration/
survfunc.rs

1//! Right-censored survival function estimation (Kaplan–Meier).
2//!
3//! [`SurvfuncRight`] computes the product-limit estimator of the survival
4//! function `S(t) = P(T > t)` from right-censored data, together with the
5//! Greenwood estimate of its standard error. The construction matches the
6//! reference implementation exactly: the reported times are the *distinct
7//! event times only* (times at which at least one failure occurred), censored
8//! times never appearing on their own.
9
10use ndarray::Array1;
11use solow_core::error::{Error, Result};
12
13/// The Kaplan–Meier estimate of a right-censored survival function.
14///
15/// Construct with [`SurvfuncRight::new`], passing observation times and a
16/// status indicator (`1.0` = event/failure observed, `0.0` = right-censored).
17#[derive(Clone, Debug)]
18pub struct SurvfuncRight {
19    /// Distinct event times, ascending. Only times with at least one failure.
20    pub surv_times: Array1<f64>,
21    /// The product-limit survival probability `S(t)` at each event time.
22    pub surv_prob: Array1<f64>,
23    /// Greenwood standard error of `S(t)` at each event time (`NaN` where the
24    /// risk set is exhausted, i.e. `n == d`).
25    pub surv_prob_se: Array1<f64>,
26    /// Size of the risk set just prior to each event time.
27    pub n_risk: Array1<f64>,
28    /// Number of events (failures) at each event time.
29    pub n_events: Array1<f64>,
30}
31
32impl SurvfuncRight {
33    /// Estimate the survival function from right-censored data.
34    ///
35    /// `time` and `status` must have equal length. `status[i]` is treated as an
36    /// event when it rounds to `1` and as censoring otherwise.
37    pub fn new(time: &[f64], status: &[f64]) -> Result<Self> {
38        if time.len() != status.len() {
39            return Err(Error::Shape("time and status length differ".into()));
40        }
41        if time.is_empty() {
42            return Err(Error::Shape("empty time vector".into()));
43        }
44
45        // Distinct times, ascending, plus the inverse mapping (each
46        // observation's index into the unique-time array).
47        let mut order: Vec<usize> = (0..time.len()).collect();
48        order.sort_by(|&a, &b| time[a].total_cmp(&time[b]));
49        let mut utime: Vec<f64> = Vec::new();
50        let mut rtime: Vec<usize> = vec![0; time.len()];
51        for &i in &order {
52            if utime.is_empty() || time[i] != *utime.last().unwrap() {
53                utime.push(time[i]);
54            }
55            rtime[i] = utime.len() - 1;
56        }
57        let ml = utime.len();
58
59        // d[k] = number of failures at the k-th distinct time.
60        // raw_n[k] = number of observations whose time equals the k-th time.
61        let mut d = vec![0.0_f64; ml];
62        let mut raw_n = vec![0.0_f64; ml];
63        for i in 0..time.len() {
64            let k = rtime[i];
65            raw_n[k] += 1.0;
66            if status[i].round() as i64 == 1 {
67                d[k] += 1.0;
68            }
69        }
70
71        // n[k] = size of risk set just before the k-th time = sum of raw_n at
72        // times >= the k-th time (reverse cumulative sum).
73        let mut n = vec![0.0_f64; ml];
74        let mut acc = 0.0;
75        for k in (0..ml).rev() {
76            acc += raw_n[k];
77            n[k] = acc;
78        }
79
80        // Retain only times where an event occurred.
81        let keep: Vec<usize> = (0..ml).filter(|&k| d[k] > 0.0).collect();
82        let nk = keep.len();
83        let dk: Vec<f64> = keep.iter().map(|&k| d[k]).collect();
84        let nrisk: Vec<f64> = keep.iter().map(|&k| n[k]).collect();
85        let times: Vec<f64> = keep.iter().map(|&k| utime[k]).collect();
86
87        // Product-limit survival probability via cumulative sum of logs.
88        let mut sp = vec![0.0_f64; nk];
89        let mut zero_flag = vec![false; nk];
90        let mut log_cumsum = 0.0;
91        for j in 0..nk {
92            let mut frac = 1.0 - dk[j] / nrisk[j];
93            if frac < 1e-16 {
94                frac = 1e-16;
95                zero_flag[j] = true;
96            }
97            log_cumsum += frac.ln();
98            sp[j] = log_cumsum.exp();
99            if zero_flag[j] {
100                sp[j] = 0.0;
101            }
102        }
103
104        // Greenwood standard error.
105        // term = d / (n * (n - d)); NaN where n == d or n == 0; cumulative sum;
106        // sqrt; then multiply by S(t) where the value is finite or S != 0.
107        let mut se = vec![0.0_f64; nk];
108        let mut cum = 0.0;
109        for j in 0..nk {
110            let denom = (nrisk[j] * (nrisk[j] - dk[j])).max(1e-12);
111            let mut term = dk[j] / denom;
112            if nrisk[j] == dk[j] || nrisk[j] == 0.0 {
113                term = f64::NAN;
114            }
115            cum += term;
116            let s = cum.sqrt();
117            // locs = isfinite(se) | (sp != 0)
118            if s.is_finite() || sp[j] != 0.0 {
119                se[j] = s * sp[j];
120            } else {
121                se[j] = f64::NAN;
122            }
123        }
124
125        Ok(SurvfuncRight {
126            surv_times: Array1::from_vec(times),
127            surv_prob: Array1::from_vec(sp),
128            surv_prob_se: Array1::from_vec(se),
129            n_risk: Array1::from_vec(nrisk),
130            n_events: Array1::from_vec(dk),
131        })
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn all_events_no_censoring() {
141        // With all distinct event times and no censoring, S drops by 1/n each
142        // step: 1 - i/n.
143        let time = [1.0, 2.0, 3.0, 4.0];
144        let status = [1.0, 1.0, 1.0, 1.0];
145        let s = SurvfuncRight::new(&time, &status).unwrap();
146        assert_eq!(s.surv_times.to_vec(), vec![1.0, 2.0, 3.0, 4.0]);
147        let exp = [0.75, 0.5, 0.25, 0.0];
148        for (i, &e) in exp.iter().enumerate() {
149            assert!((s.surv_prob[i] - e).abs() < 1e-12);
150        }
151        // Last point: n == d, so Greenwood SE is NaN.
152        assert!(s.surv_prob_se[3].is_nan());
153    }
154
155    #[test]
156    fn censored_times_excluded_from_surv_times() {
157        // A censored observation at a time with no event must not appear.
158        let time = [1.0, 2.0, 3.0];
159        let status = [1.0, 0.0, 1.0];
160        let s = SurvfuncRight::new(&time, &status).unwrap();
161        assert_eq!(s.surv_times.to_vec(), vec![1.0, 3.0]);
162        // At t=1: 1 - 1/3; at t=3: risk set is just {t=3}, so S -> 0.
163        assert!((s.surv_prob[0] - (2.0 / 3.0)).abs() < 1e-12);
164        assert!((s.surv_prob[1]).abs() < 1e-12);
165    }
166
167    #[test]
168    fn mismatched_lengths_error() {
169        assert!(SurvfuncRight::new(&[1.0, 2.0], &[1.0]).is_err());
170    }
171}