Skip to main content

solow_duration/
phreg_ties.rs

1//! Cox proportional-hazards regression with selectable tie handling.
2//!
3//! [`PHRegTies`] extends the Breslow-only [`crate::PHReg`] with an explicit
4//! choice between the **Breslow** and **Efron** approximations for tied event
5//! times via the [`Ties`] enum. The Efron correction is the more accurate of
6//! the two when several subjects fail at exactly the same time and is the
7//! default in many statistical packages.
8//!
9//! The model maximizes the partial log-likelihood with a Newton step (driving
10//! the gradient to zero), mirroring the reference
11//! `hazard_regression.PHReg` with `ties='breslow'` or `ties='efron'` for the
12//! single-stratum, no-entry, no-offset case. It exposes the coefficient vector
13//! [`PHRegTiesResults::params`], its standard errors
14//! [`PHRegTiesResults::bse`] (from the inverse observed information),
15//! z-statistics [`PHRegTiesResults::tvalues`], two-sided normal p-values
16//! [`PHRegTiesResults::pvalues`], and the maximized partial log-likelihood
17//! [`PHRegTiesResults::llf`].
18
19use ndarray::{Array1, Array2};
20use solow_core::error::{Error, Result};
21use solow_distributions::norm_sf;
22use solow_linalg::inv;
23use solow_optimize::newton_stationary;
24
25/// Method for handling tied event times in the Cox partial likelihood.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Ties {
28    /// Breslow approximation (simplest; assumes the risk set is unchanged
29    /// while the tied failures occur).
30    Breslow,
31    /// Efron approximation (more accurate; progressively deflates the tied
32    /// failures' contribution to the risk-set denominator).
33    Efron,
34}
35
36/// Pre-computed risk-set bookkeeping for a single (unstratified) sample.
37///
38/// All indices reference rows of the *filtered, time-sorted* covariate matrix.
39struct Surv {
40    /// `ufailt_ix[k]` = indices of subjects failing at the k-th distinct
41    /// failure time (sorted ascending).
42    ufailt_ix: Vec<Vec<usize>>,
43    /// `risk_enter[k]` = indices of subjects entering the risk set at the
44    /// k-th distinct failure time.
45    risk_enter: Vec<Vec<usize>>,
46}
47
48/// A Cox proportional-hazards model with a selectable tie-handling method.
49#[derive(Clone, Debug)]
50pub struct PHRegTies {
51    /// Filtered, time-sorted covariate matrix (rows = informative subjects).
52    exog_s: Array2<f64>,
53    /// Number of covariates.
54    k: usize,
55    surv_ufailt_ix: Vec<Vec<usize>>,
56    surv_risk_enter: Vec<Vec<usize>>,
57    ties: Ties,
58    maxiter: usize,
59    gtol: f64,
60}
61
62impl PHRegTies {
63    /// Build a Cox PH model with the chosen tie-handling method.
64    ///
65    /// `time[i]` is the event or censoring time, `exog` has one row per subject
66    /// (covariates in columns, no implicit intercept — the Cox baseline hazard
67    /// absorbs it), and `status[i]` is `1.0` for an observed event and `0.0`
68    /// for right-censoring.
69    pub fn new(time: &[f64], exog: &Array2<f64>, status: &[f64], ties: Ties) -> Result<Self> {
70        let n = time.len();
71        if exog.nrows() != n || status.len() != n {
72            return Err(Error::Shape("time/exog/status length mismatch".into()));
73        }
74        if n == 0 {
75            return Err(Error::Shape("empty sample".into()));
76        }
77        let k = exog.ncols();
78
79        let has_event = (0..n).any(|i| status[i].round() as i64 == 1);
80        if !has_event {
81            return Err(Error::Convergence("no events in sample".into()));
82        }
83
84        // Reproduce the reference's subject filtering for a single stratum with
85        // entry time 0 (no left truncation): drop subjects censored strictly
86        // before the first failure time.
87        let mut first_failure = f64::INFINITY;
88        for i in 0..n {
89            if status[i].round() as i64 == 1 && time[i] < first_failure {
90                first_failure = time[i];
91            }
92        }
93        let mut rows: Vec<usize> = (0..n).filter(|&i| time[i] >= first_failure).collect();
94
95        // Order by time within the stratum (stable sort, matching argsort).
96        rows.sort_by(|&a, &b| time[a].total_cmp(&time[b]));
97
98        let m = rows.len();
99        let mut exog_s = Array2::<f64>::zeros((m, k));
100        let mut time_s = vec![0.0_f64; m];
101        let mut status_s = vec![0.0_f64; m];
102        for (new_i, &old_i) in rows.iter().enumerate() {
103            for j in 0..k {
104                exog_s[[new_i, j]] = exog[[old_i, j]];
105            }
106            time_s[new_i] = time[old_i];
107            status_s[new_i] = status[old_i];
108        }
109
110        let surv = build_surv(&time_s, &status_s);
111
112        Ok(PHRegTies {
113            exog_s,
114            k,
115            surv_ufailt_ix: surv.ufailt_ix,
116            surv_risk_enter: surv.risk_enter,
117            ties,
118            maxiter: 100,
119            gtol: 1e-10,
120        })
121    }
122
123    fn surv(&self) -> Surv {
124        Surv {
125            ufailt_ix: self.surv_ufailt_ix.clone(),
126            risk_enter: self.surv_risk_enter.clone(),
127        }
128    }
129
130    /// Max-shifted exponentiated linear predictor (numerical stability).
131    fn e_linpred(&self, params: &Array1<f64>) -> (Array1<f64>, Vec<f64>) {
132        let mut linpred = self.exog_s.dot(params);
133        let lpmax = linpred.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
134        linpred.mapv_inplace(|v| v - lpmax);
135        let e_linpred: Vec<f64> = linpred.iter().map(|&v| v.exp()).collect();
136        (linpred, e_linpred)
137    }
138
139    /// Breslow partial log-likelihood evaluated at `params`.
140    pub fn breslow_loglike(&self, params: &Array1<f64>) -> f64 {
141        let surv = self.surv();
142        let nuft = surv.ufailt_ix.len();
143        let (linpred, e_linpred) = self.e_linpred(params);
144
145        let mut like = 0.0;
146        let mut xp0 = 0.0;
147        for i in (0..nuft).rev() {
148            for &ix in &surv.risk_enter[i] {
149                xp0 += e_linpred[ix];
150            }
151            for &ix in &surv.ufailt_ix[i] {
152                like += linpred[ix] - xp0.ln();
153            }
154        }
155        like
156    }
157
158    /// Efron partial log-likelihood evaluated at `params`.
159    pub fn efron_loglike(&self, params: &Array1<f64>) -> f64 {
160        let surv = self.surv();
161        let nuft = surv.ufailt_ix.len();
162        let (linpred, e_linpred) = self.e_linpred(params);
163
164        let mut like = 0.0;
165        let mut xp0 = 0.0;
166        for i in (0..nuft).rev() {
167            for &ix in &surv.risk_enter[i] {
168                xp0 += e_linpred[ix];
169            }
170            let fail = &surv.ufailt_ix[i];
171            let xp0f: f64 = fail.iter().map(|&ix| e_linpred[ix]).sum();
172            for &ix in fail {
173                like += linpred[ix];
174            }
175            let m = fail.len();
176            for j in 0..m {
177                let jf = j as f64 / m as f64;
178                like -= (xp0 - jf * xp0f).ln();
179            }
180        }
181        like
182    }
183
184    /// Partial log-likelihood under the configured tie-handling method.
185    pub fn loglike(&self, params: &Array1<f64>) -> f64 {
186        match self.ties {
187            Ties::Breslow => self.breslow_loglike(params),
188            Ties::Efron => self.efron_loglike(params),
189        }
190    }
191
192    /// Breslow gradient of the partial log-likelihood at `params`.
193    pub fn breslow_gradient(&self, params: &Array1<f64>) -> Array1<f64> {
194        let surv = self.surv();
195        let nuft = surv.ufailt_ix.len();
196        let (_linpred, e_linpred) = self.e_linpred(params);
197
198        let mut grad = Array1::<f64>::zeros(self.k);
199        let mut xp0 = 0.0;
200        let mut xp1 = Array1::<f64>::zeros(self.k);
201
202        for i in (0..nuft).rev() {
203            for &ix in &surv.risk_enter[i] {
204                xp0 += e_linpred[ix];
205                let row = self.exog_s.row(ix);
206                for j in 0..self.k {
207                    xp1[j] += e_linpred[ix] * row[j];
208                }
209            }
210            for &ix in &surv.ufailt_ix[i] {
211                let row = self.exog_s.row(ix);
212                for j in 0..self.k {
213                    grad[j] += row[j] - xp1[j] / xp0;
214                }
215            }
216        }
217        grad
218    }
219
220    /// Efron gradient of the partial log-likelihood at `params`.
221    pub fn efron_gradient(&self, params: &Array1<f64>) -> Array1<f64> {
222        let surv = self.surv();
223        let nuft = surv.ufailt_ix.len();
224        let (_linpred, e_linpred) = self.e_linpred(params);
225
226        let mut grad = Array1::<f64>::zeros(self.k);
227        let mut xp0 = 0.0;
228        let mut xp1 = Array1::<f64>::zeros(self.k);
229
230        for i in (0..nuft).rev() {
231            for &ix in &surv.risk_enter[i] {
232                xp0 += e_linpred[ix];
233                let row = self.exog_s.row(ix);
234                for j in 0..self.k {
235                    xp1[j] += e_linpred[ix] * row[j];
236                }
237            }
238            let fail = &surv.ufailt_ix[i];
239            if fail.is_empty() {
240                continue;
241            }
242            // Tied-failure accumulators.
243            let xp0f: f64 = fail.iter().map(|&ix| e_linpred[ix]).sum();
244            let mut xp1f = Array1::<f64>::zeros(self.k);
245            for &ix in fail {
246                let row = self.exog_s.row(ix);
247                for j in 0..self.k {
248                    xp1f[j] += e_linpred[ix] * row[j];
249                    grad[j] += row[j];
250                }
251            }
252            let m = fail.len();
253            for jj in 0..m {
254                let jf = jj as f64 / m as f64;
255                let denom = xp0 - jf * xp0f;
256                for j in 0..self.k {
257                    grad[j] -= (xp1[j] - jf * xp1f[j]) / denom;
258                }
259            }
260        }
261        grad
262    }
263
264    /// Gradient under the configured tie-handling method.
265    pub fn gradient(&self, params: &Array1<f64>) -> Array1<f64> {
266        match self.ties {
267            Ties::Breslow => self.breslow_gradient(params),
268            Ties::Efron => self.efron_gradient(params),
269        }
270    }
271
272    /// Breslow Hessian of the partial log-likelihood at `params`.
273    ///
274    /// Negative-definite at the maximum; its negative is the observed
275    /// information used for standard errors.
276    pub fn breslow_hessian(&self, params: &Array1<f64>) -> Array2<f64> {
277        let surv = self.surv();
278        let nuft = surv.ufailt_ix.len();
279        let (_linpred, e_linpred) = self.e_linpred(params);
280
281        let mut hess = Array2::<f64>::zeros((self.k, self.k));
282        let mut xp0 = 0.0;
283        let mut xp1 = Array1::<f64>::zeros(self.k);
284        let mut xp2 = Array2::<f64>::zeros((self.k, self.k));
285
286        for i in (0..nuft).rev() {
287            for &ix in &surv.risk_enter[i] {
288                let el = e_linpred[ix];
289                xp0 += el;
290                let row = self.exog_s.row(ix);
291                for a in 0..self.k {
292                    xp1[a] += el * row[a];
293                    for b in 0..self.k {
294                        xp2[[a, b]] += el * row[a] * row[b];
295                    }
296                }
297            }
298            let mfail = surv.ufailt_ix[i].len() as f64;
299            for a in 0..self.k {
300                for b in 0..self.k {
301                    let val = xp2[[a, b]] / xp0 - (xp1[a] * xp1[b]) / (xp0 * xp0);
302                    hess[[a, b]] += mfail * val;
303                }
304            }
305        }
306        hess.mapv_inplace(|v| -v);
307        hess
308    }
309
310    /// Efron Hessian of the partial log-likelihood at `params`.
311    ///
312    /// Negative-definite at the maximum; its negative is the observed
313    /// information used for standard errors.
314    pub fn efron_hessian(&self, params: &Array1<f64>) -> Array2<f64> {
315        let surv = self.surv();
316        let nuft = surv.ufailt_ix.len();
317        let (_linpred, e_linpred) = self.e_linpred(params);
318
319        let mut hess = Array2::<f64>::zeros((self.k, self.k));
320        let mut xp0 = 0.0;
321        let mut xp1 = Array1::<f64>::zeros(self.k);
322        let mut xp2 = Array2::<f64>::zeros((self.k, self.k));
323
324        for i in (0..nuft).rev() {
325            for &ix in &surv.risk_enter[i] {
326                let el = e_linpred[ix];
327                xp0 += el;
328                let row = self.exog_s.row(ix);
329                for a in 0..self.k {
330                    xp1[a] += el * row[a];
331                    for b in 0..self.k {
332                        xp2[[a, b]] += el * row[a] * row[b];
333                    }
334                }
335            }
336            let fail = &surv.ufailt_ix[i];
337            if fail.is_empty() {
338                continue;
339            }
340            // Tied-failure accumulators.
341            let xp0f: f64 = fail.iter().map(|&ix| e_linpred[ix]).sum();
342            let mut xp1f = Array1::<f64>::zeros(self.k);
343            let mut xp2f = Array2::<f64>::zeros((self.k, self.k));
344            for &ix in fail {
345                let el = e_linpred[ix];
346                let row = self.exog_s.row(ix);
347                for a in 0..self.k {
348                    xp1f[a] += el * row[a];
349                    for b in 0..self.k {
350                        xp2f[[a, b]] += el * row[a] * row[b];
351                    }
352                }
353            }
354            let m = fail.len();
355            // hess += xp2 * sum(1/c0) - xp2f * sum(J/c0)
356            //       - sum_j outer(mat_j, mat_j), mat_j = (xp1 - J_j*xp1f)/c0_j
357            let mut sum_inv = 0.0;
358            let mut sum_jinv = 0.0;
359            // Per-tie "mat" rows: accumulate outer product sum directly.
360            let mut outer_acc = Array2::<f64>::zeros((self.k, self.k));
361            for jj in 0..m {
362                let jf = jj as f64 / m as f64;
363                let c0 = xp0 - jf * xp0f;
364                sum_inv += 1.0 / c0;
365                sum_jinv += jf / c0;
366                // mat_j vector
367                let mut matj = Array1::<f64>::zeros(self.k);
368                for a in 0..self.k {
369                    matj[a] = (xp1[a] - jf * xp1f[a]) / c0;
370                }
371                for a in 0..self.k {
372                    for b in 0..self.k {
373                        outer_acc[[a, b]] += matj[a] * matj[b];
374                    }
375                }
376            }
377            for a in 0..self.k {
378                for b in 0..self.k {
379                    hess[[a, b]] +=
380                        xp2[[a, b]] * sum_inv - xp2f[[a, b]] * sum_jinv - outer_acc[[a, b]];
381                }
382            }
383        }
384        hess.mapv_inplace(|v| -v);
385        hess
386    }
387
388    /// Hessian under the configured tie-handling method.
389    pub fn hessian(&self, params: &Array1<f64>) -> Array2<f64> {
390        match self.ties {
391            Ties::Breslow => self.breslow_hessian(params),
392            Ties::Efron => self.efron_hessian(params),
393        }
394    }
395
396    /// Estimate the model by Newton iteration on the partial likelihood.
397    pub fn fit(&self) -> Result<PHRegTiesResults> {
398        let start = Array1::<f64>::zeros(self.k);
399        let opt = newton_stationary(
400            &start,
401            |b| {
402                let f = self.loglike(b);
403                let g = self.gradient(b);
404                let h = self.hessian(b);
405                (f, g, h)
406            },
407            self.maxiter,
408            self.gtol,
409        )?;
410
411        let params = opt.x;
412        // `hessian` returns the second derivative of the log partial likelihood
413        // (negative definite at the maximum). The observed information is its
414        // negative, and the coefficient covariance is the inverse information.
415        let d2l = self.hessian(&params);
416        let info = d2l.mapv(|v| -v);
417        let cov = inv(&info)?;
418
419        let bse = Array1::from_iter((0..self.k).map(|i| cov[[i, i]].sqrt()));
420        let tvalues = Array1::from_iter((0..self.k).map(|i| params[i] / bse[i]));
421        let pvalues = Array1::from_iter((0..self.k).map(|i| 2.0 * norm_sf(tvalues[i].abs())));
422        let llf = self.loglike(&params);
423
424        Ok(PHRegTiesResults {
425            params,
426            bse,
427            tvalues,
428            pvalues,
429            cov_params: cov,
430            llf,
431            converged: opt.converged,
432            ties: self.ties,
433        })
434    }
435}
436
437/// Build the per-failure-time risk-set indices for a single stratum with no
438/// left truncation (entry time 0).
439fn build_surv(time_s: &[f64], status_s: &[f64]) -> Surv {
440    let m = time_s.len();
441
442    // Unique failure times (ascending).
443    let mut ft: Vec<f64> = (0..m)
444        .filter(|&i| status_s[i].round() as i64 == 1)
445        .map(|i| time_s[i])
446        .collect();
447    ft.sort_by(|a, b| a.total_cmp(b));
448    let mut uft: Vec<f64> = Vec::new();
449    for &t in &ft {
450        if uft.is_empty() || t != *uft.last().unwrap() {
451            uft.push(t);
452        }
453    }
454    let nuft = uft.len();
455
456    // ufailt_ix[k] = indices of subjects who fail at uft[k].
457    let mut ufailt_ix: Vec<Vec<usize>> = vec![Vec::new(); nuft];
458    for (i, &t) in time_s.iter().enumerate().take(m) {
459        if status_s[i].round() as i64 == 1 {
460            let k = uft.iter().position(|&u| u == t).unwrap();
461            ufailt_ix[k].push(i);
462        }
463    }
464
465    // risk_enter[k] = indices entering the risk set at uft[k]:
466    // searchsorted(uft, t, "right") - 1, the last failure time <= t.
467    let mut risk_enter: Vec<Vec<usize>> = vec![Vec::new(); nuft];
468    for (i, &t) in time_s.iter().enumerate().take(m) {
469        let cnt = uft.iter().filter(|&&u| u <= t).count();
470        if cnt >= 1 {
471            risk_enter[cnt - 1].push(i);
472        }
473    }
474
475    Surv {
476        ufailt_ix,
477        risk_enter,
478    }
479}
480
481/// Results of a fitted Cox proportional-hazards model with selectable ties.
482#[derive(Clone, Debug)]
483pub struct PHRegTiesResults {
484    /// Estimated regression coefficients (log hazard ratios).
485    pub params: Array1<f64>,
486    /// Standard errors of the coefficients.
487    pub bse: Array1<f64>,
488    /// z-statistics `params / bse`.
489    pub tvalues: Array1<f64>,
490    /// Two-sided p-values from the standard normal distribution.
491    pub pvalues: Array1<f64>,
492    /// Coefficient covariance matrix (inverse observed information).
493    pub cov_params: Array2<f64>,
494    /// Maximized partial log-likelihood.
495    pub llf: f64,
496    /// Whether the Newton iteration converged to the gradient tolerance.
497    pub converged: bool,
498    /// The tie-handling method used.
499    pub ties: Ties,
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use ndarray::array;
506
507    #[test]
508    fn efron_gradient_zero_at_optimum() {
509        let time = [4.0, 3.0, 1.0, 1.0, 2.0, 2.0, 3.0];
510        let status = [1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0];
511        let exog = array![[0.5_f64], [1.2], [-0.3], [0.8], [0.1], [-1.0], [0.4]];
512        let model = PHRegTies::new(&time, &exog, &status, Ties::Efron).unwrap();
513        let res = model.fit().unwrap();
514        assert!(res.converged);
515        let g = model.efron_gradient(&res.params);
516        assert!(g.iter().all(|&v| v.abs() < 1e-8));
517    }
518
519    #[test]
520    fn efron_hessian_matches_numeric_gradient_diff() {
521        // Tied failure times exercise the Efron correction.
522        let time = [1.0, 1.0, 2.0, 2.0, 3.0, 3.0];
523        let status = [1.0, 1.0, 1.0, 1.0, 0.0, 1.0];
524        let exog = array![[0.2_f64], [-0.5], [1.0], [0.3], [-0.8], [0.6]];
525        let model = PHRegTies::new(&time, &exog, &status, Ties::Efron).unwrap();
526        let b = array![0.15_f64];
527        let h = model.efron_hessian(&b)[[0, 0]];
528        let eps = 1e-6;
529        let gp = model.efron_gradient(&array![0.15 + eps])[0];
530        let gm = model.efron_gradient(&array![0.15 - eps])[0];
531        let num_d2l = (gp - gm) / (2.0 * eps);
532        assert!((h - num_d2l).abs() < 1e-4, "h={h}, num={num_d2l}");
533    }
534
535    #[test]
536    fn breslow_matches_existing_phreg() {
537        // Without ties, Breslow and Efron coincide; with the same data the new
538        // Breslow path should reproduce the canonical Cox estimate.
539        let time = [1.0, 2.0, 3.0, 4.0, 5.0];
540        let status = [1.0, 1.0, 0.0, 1.0, 1.0];
541        let exog = array![[0.2_f64], [-0.5], [1.0], [0.3], [-0.8]];
542        let breslow = PHRegTies::new(&time, &exog, &status, Ties::Breslow)
543            .unwrap()
544            .fit()
545            .unwrap();
546        let efron = PHRegTies::new(&time, &exog, &status, Ties::Efron)
547            .unwrap()
548            .fit()
549            .unwrap();
550        // No tied event times -> identical results.
551        assert!((breslow.params[0] - efron.params[0]).abs() < 1e-10);
552    }
553}