Skip to main content

regression_diagnostics/survival/
aft.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2use statrs::distribution::{ContinuousCDF, Normal};
3
4use crate::error::{RegressionError, Result};
5use crate::linalg::{dmatrix_from_rows, dvector_from_slice};
6use crate::optimize::{nelder_mead, numerical_hessian};
7
8/// Error / baseline distribution of an [`AftFit`], on the log-time scale
9/// `log T = xᵀβ + σ·W`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AftDistribution {
12    /// `W` standard extreme-value (Gumbel-min): `T` is **Weibull**. The workhorse
13    /// AFT model; also proportional-hazards.
14    Weibull,
15    /// Weibull with the scale fixed at `σ = 1`: `T` is **exponential** (constant
16    /// hazard).
17    Exponential,
18    /// `W` standard normal: `T` is **log-normal**.
19    LogNormal,
20    /// `W` standard logistic: `T` is **log-logistic**.
21    LogLogistic,
22}
23
24impl AftDistribution {
25    /// Log standard density `ln f₀(w)`.
26    fn log_pdf(self, w: f64) -> f64 {
27        match self {
28            AftDistribution::Weibull | AftDistribution::Exponential => w - w.exp(),
29            AftDistribution::LogNormal => {
30                -0.5 * (2.0 * std::f64::consts::PI).ln() - 0.5 * w * w
31            }
32            AftDistribution::LogLogistic => {
33                // w − 2 ln(1 + e^w), computed stably.
34                w - 2.0 * log1p_exp(w)
35            }
36        }
37    }
38
39    /// Log standard survivor `ln S₀(w)`.
40    fn log_surv(self, w: f64) -> f64 {
41        match self {
42            AftDistribution::Weibull | AftDistribution::Exponential => -w.exp(),
43            AftDistribution::LogNormal => {
44                let n = Normal::new(0.0, 1.0).unwrap();
45                (1.0 - n.cdf(w)).max(1e-300).ln()
46            }
47            AftDistribution::LogLogistic => -log1p_exp(w),
48        }
49    }
50
51    /// Median of the standard variate `W` (for median survival predictions).
52    fn median_w(self) -> f64 {
53        match self {
54            // median of extreme-value-min: ln(ln 2).
55            AftDistribution::Weibull | AftDistribution::Exponential => (2.0_f64.ln()).ln(),
56            AftDistribution::LogNormal | AftDistribution::LogLogistic => 0.0,
57        }
58    }
59
60    fn scale_fixed(self) -> bool {
61        matches!(self, AftDistribution::Exponential)
62    }
63}
64
65/// Numerically stable `ln(1 + eˣ)`.
66fn log1p_exp(x: f64) -> f64 {
67    if x > 0.0 {
68        x + (-x).exp().ln_1p()
69    } else {
70        x.exp().ln_1p()
71    }
72}
73
74/// A fitted **accelerated failure time (AFT)** parametric survival model,
75///
76/// `log Tᵢ = xᵢᵀβ + σ·Wᵢ`,
77///
78/// where `W` has the standard [`AftDistribution`]. Unlike the semiparametric Cox
79/// model this specifies the full baseline, so it yields absolute time
80/// predictions (e.g. median survival) and coefficients read as **log
81/// time-acceleration**: `exp(βⱼ) > 1` multiplies survival time. Fit by maximum
82/// likelihood over right-censored data (Nelder–Mead on the log-likelihood, with
83/// standard errors from the numerical observed information).
84#[derive(Debug, Clone)]
85pub struct AftFit {
86    dist: AftDistribution,
87    coefficients: Array1<f64>,
88    scale: f64,
89    cov: Array2<f64>,
90    log_likelihood: f64,
91    n: usize,
92    p: usize,
93}
94
95impl AftFit {
96    /// Fit an AFT model of survival `(time, event)` on `X` under `dist`.
97    ///
98    /// `event[i]` is `1.0` for an observed event, `0.0` for right-censoring. `X`
99    /// carries the fixed effects **including an intercept** (the model has a
100    /// location term). Returns the standard-error covariance for the `β`
101    /// coefficients (the scale is reported separately).
102    ///
103    /// # Errors
104    ///
105    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
106    /// * [`RegressionError::InvalidResponse`] for non-positive times or event
107    ///   flags outside `{0, 1}`, or no events.
108    /// * [`RegressionError::NotConverged`] if the optimizer stalls.
109    pub fn new(
110        time: Array1<f64>,
111        event: Array1<f64>,
112        x: Array2<f64>,
113        dist: AftDistribution,
114    ) -> Result<Self> {
115        let n = x.nrows();
116        let p = x.ncols();
117        if n == 0 || p == 0 {
118            return Err(RegressionError::EmptyInput { what: "X" });
119        }
120        if time.len() != n || event.len() != n {
121            return Err(RegressionError::ShapeMismatch {
122                what: "time/event length vs X rows",
123                expected: n,
124                got: time.len().min(event.len()),
125            });
126        }
127        let mut n_events = 0usize;
128        for i in 0..n {
129            if !time[i].is_finite() || time[i] <= 0.0 {
130                return Err(RegressionError::InvalidResponse {
131                    msg: format!("survival times must be positive, found {}", time[i]),
132                });
133            }
134            if event[i] == 1.0 {
135                n_events += 1;
136            } else if event[i] != 0.0 {
137                return Err(RegressionError::InvalidResponse {
138                    msg: format!("event indicator must be 0 or 1, found {}", event[i]),
139                });
140            }
141        }
142        if n_events == 0 {
143            return Err(RegressionError::InvalidResponse {
144                msg: "no events observed".into(),
145            });
146        }
147
148        let logt: Vec<f64> = time.iter().map(|t| t.ln()).collect();
149        let scale_fixed = dist.scale_fixed();
150
151        // Parameter vector: β (p) then, unless fixed, ln σ.
152        let n_par = if scale_fixed { p } else { p + 1 };
153
154        // Negative log-likelihood at parameter vector θ.
155        let nll = |theta: &[f64]| -> f64 {
156            let sigma = if scale_fixed { 1.0 } else { theta[p].exp() };
157            if !sigma.is_finite() || sigma <= 0.0 {
158                return f64::INFINITY;
159            }
160            let mut s = 0.0;
161            for i in 0..n {
162                let mut eta = 0.0;
163                for j in 0..p {
164                    eta += x[(i, j)] * theta[j];
165                }
166                let z = (logt[i] - eta) / sigma;
167                if event[i] == 1.0 {
168                    s -= -sigma.ln() - logt[i] + dist.log_pdf(z);
169                } else {
170                    s -= dist.log_surv(z);
171                }
172            }
173            if s.is_finite() {
174                s
175            } else {
176                f64::INFINITY
177            }
178        };
179
180        // Warm start: OLS of log-time on X (ignoring censoring) for β; residual
181        // spread for ln σ.
182        let xd = dmatrix_from_rows(n, p, x.as_standard_layout().as_slice().unwrap());
183        let ld = dvector_from_slice(&logt);
184        let beta0 = match (xd.transpose() * &xd).try_inverse() {
185            Some(inv) => inv * xd.transpose() * ld,
186            None => return Err(RegressionError::RankDeficient),
187        };
188        let mut theta0 = vec![0.0; n_par];
189        for j in 0..p {
190            theta0[j] = beta0[j];
191        }
192        if !scale_fixed {
193            let resid_var = (0..n)
194                .map(|i| {
195                    let fit: f64 = (0..p).map(|j| x[(i, j)] * beta0[j]).sum();
196                    (logt[i] - fit).powi(2)
197                })
198                .sum::<f64>()
199                / n as f64;
200            theta0[p] = (0.5 * resid_var.max(1e-6).ln()).max(-5.0);
201        }
202
203        let nll_ref = &nll;
204        let theta = nelder_mead(nll_ref, &theta0, 0.1, 1e-10, 5000);
205        let final_nll = nll(&theta);
206        if !final_nll.is_finite() {
207            return Err(RegressionError::NotConverged {
208                iterations: 5000,
209                msg: "AFT optimizer failed to find a finite optimum".into(),
210            });
211        }
212
213        // Numerical observed information for the covariance.
214        let grad = |t: &[f64]| -> Vec<f64> {
215            let mut g = vec![0.0; n_par];
216            for j in 0..n_par {
217                let h = 1e-6 * t[j].abs().max(1.0);
218                let mut tp = t.to_vec();
219                let mut tm = t.to_vec();
220                tp[j] += h;
221                tm[j] -= h;
222                g[j] = (nll(&tp) - nll(&tm)) / (2.0 * h);
223            }
224            g
225        };
226        let hess = numerical_hessian(grad, &theta);
227        let flat: Vec<f64> = hess.iter().flat_map(|r| r.iter().copied()).collect();
228        let hd = dmatrix_from_rows(n_par, n_par, &flat);
229        let cov_full = hd.try_inverse().ok_or(RegressionError::RankDeficient)?;
230
231        let coefficients = Array1::from_shape_fn(p, |j| theta[j]);
232        let cov = Array2::from_shape_fn((p, p), |(i, j)| cov_full[(i, j)]);
233        let scale = if scale_fixed { 1.0 } else { theta[p].exp() };
234
235        Ok(Self {
236            dist,
237            coefficients,
238            scale,
239            cov,
240            log_likelihood: -final_nll,
241            n,
242            p,
243        })
244    }
245
246    /// The assumed distribution.
247    pub fn distribution(&self) -> AftDistribution {
248        self.dist
249    }
250
251    /// Number of observations.
252    pub fn n_observations(&self) -> usize {
253        self.n
254    }
255
256    /// Number of coefficients (design columns).
257    pub fn n_parameters(&self) -> usize {
258        self.p
259    }
260
261    /// AFT coefficients `β` (log time-acceleration scale), aligned to the design
262    /// columns.
263    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
264        self.coefficients.view()
265    }
266
267    /// The scale parameter `σ` (`1` for the exponential).
268    pub fn scale(&self) -> f64 {
269        self.scale
270    }
271
272    /// Coefficient covariance (inverse observed information).
273    pub fn covariance(&self) -> ArrayView2<'_, f64> {
274        self.cov.view()
275    }
276
277    /// Maximized log-likelihood.
278    pub fn log_likelihood(&self) -> f64 {
279        self.log_likelihood
280    }
281
282    /// AIC, `−2ℓ + 2k`, counting the scale parameter unless it is fixed.
283    pub fn aic(&self) -> f64 {
284        let k = self.p as f64 + if self.dist.scale_fixed() { 0.0 } else { 1.0 };
285        -2.0 * self.log_likelihood + 2.0 * k
286    }
287
288    /// Coefficient standard errors `√diag(cov)`.
289    pub fn coefficient_standard_errors(&self) -> Array1<f64> {
290        Array1::from_shape_fn(self.p, |j| self.cov[(j, j)].max(0.0).sqrt())
291    }
292
293    /// Wald `z`-statistics `βⱼ / seⱼ`.
294    pub fn z_values(&self) -> Array1<f64> {
295        let se = self.coefficient_standard_errors();
296        Array1::from_shape_fn(self.p, |j| {
297            if se[j] > 0.0 {
298                self.coefficients[j] / se[j]
299            } else {
300                f64::NAN
301            }
302        })
303    }
304
305    /// Two-sided Wald p-values from the standard normal.
306    pub fn p_values(&self) -> Array1<f64> {
307        let z = self.z_values();
308        let normal = Normal::new(0.0, 1.0).expect("standard normal");
309        Array1::from_shape_fn(self.p, |j| {
310            if z[j].is_finite() {
311                2.0 * (1.0 - normal.cdf(z[j].abs()))
312            } else {
313                f64::NAN
314            }
315        })
316    }
317
318    /// Predicted **median survival time** for a new design matrix `x`:
319    /// `exp(xβ + σ·median(W))`.
320    pub fn predict_median(&self, x: ArrayView2<'_, f64>) -> Array1<f64> {
321        let shift = self.scale * self.dist.median_w();
322        x.dot(&self.coefficients).mapv(|eta| (eta + shift).exp())
323    }
324}