Skip to main content

solow_duration/
hazard.rs

1//! Cox proportional-hazards regression (`PHReg`) with Breslow ties.
2//!
3//! [`PHReg`] estimates the regression coefficients of a Cox proportional
4//! hazards model by maximizing the Breslow partial log-likelihood with a
5//! Newton step (driving the gradient of the partial likelihood to zero). This
6//! mirrors the reference's `hazard_regression.PHReg` with `ties='breslow'` for
7//! the single-stratum, no-entry, no-offset case.
8//!
9//! The estimator exposes the coefficient vector [`PHRegResults::params`], its
10//! standard errors [`PHRegResults::bse`] (from the inverse observed
11//! information), z-statistics [`PHRegResults::tvalues`], two-sided normal
12//! p-values [`PHRegResults::pvalues`], and the maximized partial
13//! log-likelihood [`PHRegResults::llf`].
14
15use ndarray::{Array1, Array2};
16use solow_core::error::{Error, Result};
17use solow_distributions::norm_sf;
18use solow_linalg::inv;
19use solow_optimize::newton_stationary;
20
21/// Pre-computed risk-set bookkeeping for a single (unstratified) sample.
22///
23/// All indices reference rows of the *filtered, time-sorted* exog matrix
24/// [`PHReg::exog_s`].
25struct Surv {
26    /// `ufailt_ix[k]` = indices of subjects failing at the k-th distinct
27    /// failure time (sorted ascending).
28    ufailt_ix: Vec<Vec<usize>>,
29    /// `risk_enter[k]` = indices of subjects entering the risk set at the
30    /// k-th distinct failure time.
31    risk_enter: Vec<Vec<usize>>,
32}
33
34/// A Cox proportional-hazards model awaiting estimation.
35#[derive(Clone, Debug)]
36pub struct PHReg {
37    /// Filtered, time-sorted covariate matrix (rows = informative subjects).
38    exog_s: Array2<f64>,
39    /// Number of covariates.
40    k: usize,
41    surv_ufailt_ix: Vec<Vec<usize>>,
42    surv_risk_enter: Vec<Vec<usize>>,
43    maxiter: usize,
44    gtol: f64,
45}
46
47impl PHReg {
48    /// Build a Cox PH model from event times, covariates and status flags.
49    ///
50    /// `time[i]` is the event or censoring time, `exog` has one row per subject
51    /// (covariates in columns, no implicit intercept — the Cox baseline hazard
52    /// absorbs it), and `status[i]` is `1.0` for an observed event and `0.0`
53    /// for right-censoring.
54    pub fn new(time: &[f64], exog: &Array2<f64>, status: &[f64]) -> Result<Self> {
55        let n = time.len();
56        if exog.nrows() != n || status.len() != n {
57            return Err(Error::Shape("time/exog/status length mismatch".into()));
58        }
59        if n == 0 {
60            return Err(Error::Shape("empty sample".into()));
61        }
62        let k = exog.ncols();
63
64        let has_event = (0..n).any(|i| status[i].round() as i64 == 1);
65        if !has_event {
66            return Err(Error::Convergence("no events in sample".into()));
67        }
68
69        // Reproduce the reference's subject filtering for a single stratum with
70        // entry time 0 (no left truncation):
71        //   * keep subjects whose entry (0) <= last failure time (always true);
72        //   * drop subjects censored strictly before the first failure time.
73        let mut first_failure = f64::INFINITY;
74        for i in 0..n {
75            if status[i].round() as i64 == 1 && time[i] < first_failure {
76                first_failure = time[i];
77            }
78        }
79        let mut rows: Vec<usize> = (0..n).filter(|&i| time[i] >= first_failure).collect();
80
81        // Order by time within the stratum (stable sort, matching argsort).
82        rows.sort_by(|&a, &b| time[a].total_cmp(&time[b]));
83
84        // Build the filtered/sorted exog and the corresponding time/status.
85        let m = rows.len();
86        let mut exog_s = Array2::<f64>::zeros((m, k));
87        let mut time_s = vec![0.0_f64; m];
88        let mut status_s = vec![0.0_f64; m];
89        for (new_i, &old_i) in rows.iter().enumerate() {
90            for j in 0..k {
91                exog_s[[new_i, j]] = exog[[old_i, j]];
92            }
93            time_s[new_i] = time[old_i];
94            status_s[new_i] = status[old_i];
95        }
96
97        let surv = build_surv(&time_s, &status_s);
98
99        Ok(PHReg {
100            exog_s,
101            k,
102            surv_ufailt_ix: surv.ufailt_ix,
103            surv_risk_enter: surv.risk_enter,
104            maxiter: 100,
105            gtol: 1e-10,
106        })
107    }
108
109    fn surv(&self) -> Surv {
110        Surv {
111            ufailt_ix: self.surv_ufailt_ix.clone(),
112            risk_enter: self.surv_risk_enter.clone(),
113        }
114    }
115
116    /// Breslow partial log-likelihood evaluated at `params`.
117    pub fn breslow_loglike(&self, params: &Array1<f64>) -> f64 {
118        let surv = self.surv();
119        let nuft = surv.ufailt_ix.len();
120
121        // Linear predictor, shifted by its max for numerical stability.
122        let mut linpred = self.exog_s.dot(params);
123        let lpmax = linpred.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
124        linpred.mapv_inplace(|v| v - lpmax);
125        let e_linpred: Vec<f64> = linpred.iter().map(|&v| v.exp()).collect();
126
127        let mut like = 0.0;
128        let mut xp0 = 0.0;
129        for i in (0..nuft).rev() {
130            for &ix in &surv.risk_enter[i] {
131                xp0 += e_linpred[ix];
132            }
133            for &ix in &surv.ufailt_ix[i] {
134                like += linpred[ix] - xp0.ln();
135            }
136            // No risk_exit in the no-entry case (all exit at time-bin 0,
137            // handled implicitly by never removing within the backward loop).
138        }
139        like
140    }
141
142    /// Gradient of the Breslow partial log-likelihood at `params`.
143    pub fn breslow_gradient(&self, params: &Array1<f64>) -> Array1<f64> {
144        let surv = self.surv();
145        let nuft = surv.ufailt_ix.len();
146
147        let mut linpred = self.exog_s.dot(params);
148        let lpmax = linpred.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
149        linpred.mapv_inplace(|v| v - lpmax);
150        let e_linpred: Vec<f64> = linpred.iter().map(|&v| v.exp()).collect();
151
152        let mut grad = Array1::<f64>::zeros(self.k);
153        let mut xp0 = 0.0;
154        let mut xp1 = Array1::<f64>::zeros(self.k);
155
156        for i in (0..nuft).rev() {
157            for &ix in &surv.risk_enter[i] {
158                xp0 += e_linpred[ix];
159                let row = self.exog_s.row(ix);
160                for j in 0..self.k {
161                    xp1[j] += e_linpred[ix] * row[j];
162                }
163            }
164            for &ix in &surv.ufailt_ix[i] {
165                let row = self.exog_s.row(ix);
166                for j in 0..self.k {
167                    grad[j] += row[j] - xp1[j] / xp0;
168                }
169            }
170        }
171        grad
172    }
173
174    /// Hessian of the Breslow partial log-likelihood at `params`.
175    ///
176    /// Negative-definite at the maximum; its negative is the observed
177    /// information used for standard errors.
178    pub fn breslow_hessian(&self, params: &Array1<f64>) -> Array2<f64> {
179        let surv = self.surv();
180        let nuft = surv.ufailt_ix.len();
181
182        let mut linpred = self.exog_s.dot(params);
183        let lpmax = linpred.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
184        linpred.mapv_inplace(|v| v - lpmax);
185        let e_linpred: Vec<f64> = linpred.iter().map(|&v| v.exp()).collect();
186
187        let mut hess = Array2::<f64>::zeros((self.k, self.k));
188        let mut xp0 = 0.0;
189        let mut xp1 = Array1::<f64>::zeros(self.k);
190        let mut xp2 = Array2::<f64>::zeros((self.k, self.k));
191
192        for i in (0..nuft).rev() {
193            for &ix in &surv.risk_enter[i] {
194                let el = e_linpred[ix];
195                xp0 += el;
196                let row = self.exog_s.row(ix);
197                for a in 0..self.k {
198                    xp1[a] += el * row[a];
199                    for b in 0..self.k {
200                        xp2[[a, b]] += el * row[a] * row[b];
201                    }
202                }
203            }
204            let mfail = surv.ufailt_ix[i].len() as f64;
205            for a in 0..self.k {
206                for b in 0..self.k {
207                    let val = xp2[[a, b]] / xp0 - (xp1[a] * xp1[b]) / (xp0 * xp0);
208                    hess[[a, b]] += mfail * val;
209                }
210            }
211        }
212        // The reference returns -hess.
213        hess.mapv_inplace(|v| -v);
214        hess
215    }
216
217    /// Estimate the model by Newton iteration on the partial likelihood.
218    pub fn fit(&self) -> Result<PHRegResults> {
219        let start = Array1::<f64>::zeros(self.k);
220        let opt = newton_stationary(
221            &start,
222            |b| {
223                let f = self.breslow_loglike(b);
224                let g = self.breslow_gradient(b);
225                let h = self.breslow_hessian(b);
226                (f, g, h)
227            },
228            self.maxiter,
229            self.gtol,
230        )?;
231
232        let params = opt.x;
233        // `breslow_hessian` returns the second derivative of the log partial
234        // likelihood (negative definite at the maximum). The observed
235        // information is its negative, and the coefficient covariance is the
236        // inverse of that information matrix.
237        let d2l = self.breslow_hessian(&params);
238        let info = d2l.mapv(|v| -v);
239        let cov = inv(&info)?;
240
241        let bse = Array1::from_iter((0..self.k).map(|i| cov[[i, i]].sqrt()));
242        let tvalues = Array1::from_iter((0..self.k).map(|i| params[i] / bse[i]));
243        let pvalues = Array1::from_iter((0..self.k).map(|i| 2.0 * norm_sf(tvalues[i].abs())));
244        let llf = self.breslow_loglike(&params);
245
246        Ok(PHRegResults {
247            params,
248            bse,
249            tvalues,
250            pvalues,
251            cov_params: cov,
252            llf,
253            converged: opt.converged,
254        })
255    }
256}
257
258/// Build the per-failure-time risk-set indices for a single stratum with no
259/// left truncation (entry time 0). Mirrors the reference `PHSurvivalTime`.
260fn build_surv(time_s: &[f64], status_s: &[f64]) -> Surv {
261    let m = time_s.len();
262
263    // Unique failure times (ascending).
264    let mut ft: Vec<f64> = (0..m)
265        .filter(|&i| status_s[i].round() as i64 == 1)
266        .map(|i| time_s[i])
267        .collect();
268    ft.sort_by(|a, b| a.total_cmp(b));
269    let mut uft: Vec<f64> = Vec::new();
270    for &t in &ft {
271        if uft.is_empty() || t != *uft.last().unwrap() {
272            uft.push(t);
273        }
274    }
275    let nuft = uft.len();
276
277    // ufailt_ix[k] = indices of subjects who fail at uft[k].
278    let mut ufailt_ix: Vec<Vec<usize>> = vec![Vec::new(); nuft];
279    for (i, &t) in time_s.iter().enumerate().take(m) {
280        if status_s[i].round() as i64 == 1 {
281            let k = uft.iter().position(|&u| u == t).unwrap();
282            ufailt_ix[k].push(i);
283        }
284    }
285
286    // risk_enter[k] = indices entering the risk set at uft[k]:
287    // searchsorted(uft, t, "right") - 1, the last failure time <= t.
288    let mut risk_enter: Vec<Vec<usize>> = vec![Vec::new(); nuft];
289    for (i, &t) in time_s.iter().enumerate().take(m) {
290        // number of uft strictly <= t, minus 1
291        let cnt = uft.iter().filter(|&&u| u <= t).count();
292        if cnt >= 1 {
293            risk_enter[cnt - 1].push(i);
294        }
295    }
296
297    Surv {
298        ufailt_ix,
299        risk_enter,
300    }
301}
302
303/// Results of a fitted Cox proportional-hazards model.
304#[derive(Clone, Debug)]
305pub struct PHRegResults {
306    /// Estimated regression coefficients (log hazard ratios).
307    pub params: Array1<f64>,
308    /// Standard errors of the coefficients.
309    pub bse: Array1<f64>,
310    /// z-statistics `params / bse`.
311    pub tvalues: Array1<f64>,
312    /// Two-sided p-values from the standard normal distribution.
313    pub pvalues: Array1<f64>,
314    /// Coefficient covariance matrix (inverse observed information).
315    pub cov_params: Array2<f64>,
316    /// Maximized Breslow partial log-likelihood.
317    pub llf: f64,
318    /// Whether the Newton iteration converged to the gradient tolerance.
319    pub converged: bool,
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use ndarray::array;
326
327    #[test]
328    fn gradient_zero_at_optimum() {
329        // A small data set; after fitting, the gradient should vanish.
330        let time = [4.0, 3.0, 1.0, 1.0, 2.0, 2.0, 3.0];
331        let status = [1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0];
332        let exog = array![[0.5_f64], [1.2], [-0.3], [0.8], [0.1], [-1.0], [0.4]];
333        let model = PHReg::new(&time, &exog, &status).unwrap();
334        let res = model.fit().unwrap();
335        assert!(res.converged);
336        let g = model.breslow_gradient(&res.params);
337        assert!(g.iter().all(|&v| v.abs() < 1e-8));
338    }
339
340    #[test]
341    fn hessian_matches_numeric_gradient_diff() {
342        // The analytic Hessian (negated, as returned) should equal the
343        // negative numeric derivative of the gradient.
344        let time = [1.0, 2.0, 3.0, 4.0, 5.0];
345        let status = [1.0, 1.0, 0.0, 1.0, 1.0];
346        let exog = array![[0.2_f64], [-0.5], [1.0], [0.3], [-0.8]];
347        let model = PHReg::new(&time, &exog, &status).unwrap();
348        let b = array![0.1_f64];
349        // breslow_hessian returns the second derivative of the log-likelihood
350        // (negative definite at the max), matching the reference convention.
351        let h = model.breslow_hessian(&b)[[0, 0]];
352        let eps = 1e-6;
353        let gp = model.breslow_gradient(&array![0.1 + eps])[0];
354        let gm = model.breslow_gradient(&array![0.1 - eps])[0];
355        let num_d2l = (gp - gm) / (2.0 * eps);
356        assert!((h - num_d2l).abs() < 1e-4, "h={h}, num={num_d2l}");
357    }
358}