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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Ties {
10 Efron,
13 Breslow,
16}
17
18#[derive(Debug, Clone)]
39pub struct CoxFit {
40 start: Array1<f64>,
42 time: Array1<f64>,
44 event: Array1<f64>,
46 strata: Vec<usize>,
48 x: Array2<f64>,
49 coefficients: Array1<f64>,
50 cov: Array2<f64>,
51 log_partial_likelihood: f64,
52 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 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 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 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 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 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 + δ
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 pub fn n_observations(&self) -> usize {
284 self.n
285 }
286
287 pub fn n_parameters(&self) -> usize {
289 self.p
290 }
291
292 pub fn n_events(&self) -> usize {
294 self.event.iter().filter(|&&e| e == 1.0).count()
295 }
296
297 pub fn n_strata(&self) -> usize {
299 self.n_strata.max(1)
300 }
301
302 pub fn ties(&self) -> Ties {
304 self.ties
305 }
306
307 pub fn iterations(&self) -> usize {
309 self.iterations
310 }
311
312 pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
314 self.x.view()
315 }
316
317 pub fn time(&self) -> ArrayView1<'_, f64> {
319 self.time.view()
320 }
321
322 pub fn event(&self) -> ArrayView1<'_, f64> {
324 self.event.view()
325 }
326
327 pub fn coefficients(&self) -> ArrayView1<'_, f64> {
330 self.coefficients.view()
331 }
332
333 pub fn hazard_ratios(&self) -> Array1<f64> {
335 self.coefficients.mapv(f64::exp)
336 }
337
338 pub fn covariance(&self) -> ArrayView2<'_, f64> {
340 self.cov.view()
341 }
342
343 pub fn log_partial_likelihood(&self) -> f64 {
345 self.log_partial_likelihood
346 }
347
348 pub fn linear_predictors(&self) -> Array1<f64> {
350 self.x.dot(&self.coefficients)
351 }
352
353 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 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 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 pub fn aic(&self) -> f64 {
385 -2.0 * self.log_partial_likelihood + 2.0 * self.p as f64
386 }
387
388 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 pub fn baseline_cumulative_hazard(&self) -> Vec<(f64, f64)> {
406 self.baseline_cumulative_hazard_stratum(0)
407 }
408
409 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 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 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#[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 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#[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
602fn 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}