1use ndarray::{Array1, Array2};
20use solow_core::error::{Error, Result};
21use solow_distributions::chi2_sf;
22use solow_linalg::solve;
23
24#[derive(Clone, Copy, Debug, PartialEq)]
26pub enum WeightType {
27 LogRank,
29 GehanBreslow,
31 TaroneWare,
33 FlemingHarrington(f64),
35}
36
37#[derive(Clone, Debug)]
39pub struct SurvDiffResult {
40 pub chisq: f64,
42 pub pvalue: f64,
44 pub df: usize,
46}
47
48pub fn survdiff(
56 time: &[f64],
57 status: &[f64],
58 group: &[f64],
59 weight_type: WeightType,
60) -> Result<SurvDiffResult> {
61 let n = time.len();
62 if status.len() != n || group.len() != n {
63 return Err(Error::Shape("time/status/group length mismatch".into()));
64 }
65 if n == 0 {
66 return Err(Error::Shape("empty sample".into()));
67 }
68
69 let mut gr: Vec<f64> = group.to_vec();
71 gr.sort_by(|a, b| a.total_cmp(b));
72 gr.dedup();
73 let ng = gr.len();
74 if ng < 2 {
75 return Err(Error::Shape("survdiff requires at least two groups".into()));
76 }
77
78 let mut utimes: Vec<f64> = time.to_vec();
80 utimes.sort_by(|a, b| a.total_cmp(b));
81 utimes.dedup();
82 let ml = utimes.len();
83 let time_rank = |t: f64| -> usize { utimes.partition_point(|&u| u < t) };
84
85 let mut obsv: Vec<Array1<f64>> = vec![Array1::zeros(ml); ng];
87 let mut nrisk: Vec<Array1<f64>> = vec![Array1::zeros(ml); ng];
88 let group_idx = |g: f64| -> usize { gr.iter().position(|&x| x == g).unwrap() };
89
90 let mut nbin: Vec<Array1<f64>> = vec![Array1::zeros(ml); ng];
92 for i in 0..n {
93 let gi = group_idx(group[i]);
94 let k = time_rank(time[i]);
95 nbin[gi][k] += 1.0;
96 if status[i].round() as i64 == 1 {
97 obsv[gi][k] += 1.0;
98 }
99 }
100 for g in 0..ng {
102 let mut acc = 0.0;
103 for k in (0..ml).rev() {
104 acc += nbin[g][k];
105 nrisk[g][k] = acc;
106 }
107 }
108
109 let mut obs = Array1::<f64>::zeros(ml);
111 let mut nrisk_tot = Array1::<f64>::zeros(ml);
112 for g in 0..ng {
113 for k in 0..ml {
114 obs[k] += obsv[g][k];
115 nrisk_tot[k] += nrisk[g][k];
116 }
117 }
118
119 let ix: Vec<usize> = (0..ml).filter(|&k| nrisk_tot[k] > 1.0).collect();
121
122 let weights: Option<Array1<f64>> = match weight_type {
124 WeightType::LogRank => None,
125 WeightType::GehanBreslow => Some(nrisk_tot.clone()),
126 WeightType::TaroneWare => Some(nrisk_tot.mapv(f64::sqrt)),
127 WeightType::FlemingHarrington(p) => {
128 let mut sp = Array1::<f64>::zeros(ml);
130 let mut logcum = 0.0;
131 for k in 0..ml {
132 let frac = 1.0 - obs[k] / nrisk_tot[k];
133 logcum += frac.ln();
134 sp[k] = logcum.exp();
135 }
136 let mut w = sp.mapv(|v| v.powf(p));
137 let mut rolled = Array1::<f64>::zeros(ml);
139 for k in 0..ml {
140 rolled[k] = w[(k + ml - 1) % ml];
141 }
142 rolled[0] = 1.0;
143 w = rolled;
144 Some(w)
145 }
146 };
147
148 let dfs = ng - 1;
149
150 let mut r: Vec<Array1<f64>> = vec![Array1::zeros(ml); ng];
152 for g in 0..ng {
153 for k in 0..ml {
154 let denom = nrisk_tot[k].max(1e-10);
155 r[g][k] = nrisk[g][k] / denom;
156 }
157 }
158
159 let var_denom: Array1<f64> = nrisk_tot.mapv(|v| (v - 1.0).max(1e-10));
161 let var_scalar: Array1<f64> =
163 Array1::from_iter((0..ml).map(|k| obs[k] * (nrisk_tot[k] - obs[k]) / var_denom[k]));
164
165 let mut obs_vec = Array1::<f64>::zeros(dfs);
168 let mut var_mat = Array2::<f64>::zeros((dfs, dfs));
169
170 for g in 1..=dfs {
171 let mut oe = Array1::<f64>::zeros(ml);
173 for k in 0..ml {
174 oe[k] = obsv[g][k] - r[g][k] * obs[k];
175 }
176
177 let mut var_row = Array1::<f64>::zeros(dfs);
181
182 let (oe_w, w2): (Array1<f64>, Option<Array1<f64>>) = match &weights {
184 None => (oe, None),
185 Some(w) => {
186 let mut oew = Array1::<f64>::zeros(ml);
187 for k in 0..ml {
188 oew[k] = w[k] * oe[k];
189 }
190 (oew, Some(w.mapv(|v| v * v)))
191 }
192 };
193
194 for &k in &ix {
195 obs_vec[g - 1] += oe_w[k];
196 for (ci, c) in (1..=dfs).enumerate() {
197 let ind = if c == g { 1.0 } else { 0.0 };
198 let mut v = r[c][k] * (ind - r[g][k]) * var_scalar[k];
199 if let Some(w2v) = &w2 {
200 v *= w2v[k];
201 }
202 var_row[ci] += v;
203 }
204 }
205 for ci in 0..dfs {
206 var_mat[[g - 1, ci]] = var_row[ci];
207 }
208 }
209
210 let sol = solve(&var_mat, &obs_vec)?;
212 let chisq = obs_vec.dot(&sol);
213 let pvalue = chi2_sf(chisq, dfs as f64);
214
215 Ok(SurvDiffResult {
216 chisq,
217 pvalue,
218 df: dfs,
219 })
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn identical_groups_zero_statistic() {
228 let time = [1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0];
230 let status = [1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0];
231 let group = [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
232 let res = survdiff(&time, &status, &group, WeightType::LogRank).unwrap();
233 assert!(res.chisq.abs() < 1e-12, "chisq={}", res.chisq);
234 assert!((res.pvalue - 1.0).abs() < 1e-12);
235 assert_eq!(res.df, 1);
236 }
237
238 #[test]
239 fn requires_two_groups() {
240 let time = [1.0, 2.0, 3.0];
241 let status = [1.0, 1.0, 1.0];
242 let group = [0.0, 0.0, 0.0];
243 assert!(survdiff(&time, &status, &group, WeightType::LogRank).is_err());
244 }
245
246 #[test]
247 fn statistic_nonnegative() {
248 let time = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
249 let status = [1.0, 1.0, 0.0, 1.0, 1.0, 1.0];
250 let group = [0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
251 for wt in [
252 WeightType::LogRank,
253 WeightType::GehanBreslow,
254 WeightType::TaroneWare,
255 WeightType::FlemingHarrington(1.0),
256 ] {
257 let res = survdiff(&time, &status, &group, wt).unwrap();
258 assert!(res.chisq >= 0.0);
259 assert!((0.0..=1.0).contains(&res.pvalue));
260 }
261 }
262}