1use std::collections::HashMap;
11
12use ndarray::{Array1, Array2, ArrayView2, s};
13
14use crate::inference::model::{
15 FittedFamily, FittedModel as SavedModel, SavedBaselineTimeWiggleRuntime,
16 load_survival_time_basis_config_from_model, survival_baseline_config_from_model,
17};
18use crate::inference::predict_io::{BernoulliMarginalSlopePredictor, PredictInput};
19use crate::model_types::{BlockRole, FittedBlock, FittedLinkState, UnifiedFitResult};
20use crate::probability::signed_probit_logcdf_and_mills_ratio;
21use crate::scale_design::scale_transform_from_payload;
22use crate::survival::construction::{
23 SurvivalBaselineConfig, SurvivalBaselineTarget, SurvivalLikelihoodMode,
24 SurvivalTimeBuildOutput, add_survival_time_derivative_guard_offset, build_survival_time_basis,
25 build_survival_time_offsets_for_likelihood, build_survival_timewiggle_derivative_design,
26 center_survival_time_designs_at_anchor, evaluate_survival_time_basis_row,
27 normalize_survival_time_pair, parse_survival_likelihood_mode,
28 require_structural_survival_time_basis, resolved_survival_time_basis_config_from_build,
29 survival_derivative_guard_for_likelihood, survival_likelihood_modename,
30};
31use crate::survival::lognormal_kernel::FrailtySpec;
32use crate::survival::{CompetingRisksCifResult, assemble_competing_risks_cif_from_endpoints};
33use crate::wiggle::buildwiggle_block_input_from_knots;
34use gam_linalg::matrix::DesignMatrix;
35use gam_problem::{InverseLink, LikelihoodSpec, ResponseFamily, StandardLink};
36use gam_solve::mixture_link::inverse_link_jet_for_inverse_link;
37use gam_terms::smooth::TermCollectionSpec;
38use gam_terms::smooth::build_term_collection_design;
39use gam_terms::term_builder::resolve_role_col;
40
41pub struct SurvivalTimeColumns {
49 pub entry_col: Option<usize>,
50 pub exit_col: usize,
51}
52
53impl SurvivalTimeColumns {
54 #[inline]
57 pub fn row_entry_time(&self, data: ArrayView2<'_, f64>, i: usize) -> f64 {
58 self.entry_col.map_or(0.0, |idx| data[[i, idx]])
59 }
60}
61
62pub fn resolve_saved_survival_time_columns(
66 model: &SavedModel,
67 col_map: &HashMap<String, usize>,
68) -> Result<SurvivalTimeColumns, String> {
69 let entry_col: Option<usize> = model
70 .survival_entry
71 .as_deref()
72 .map(|name| resolve_role_col(col_map, name, "entry"))
73 .transpose()?;
74 let exitname = model
75 .survival_exit
76 .as_ref()
77 .ok_or_else(|| "survival model missing exit column metadata".to_string())?;
78 let exit_col = resolve_role_col(col_map, exitname, "exit")?;
79 Ok(SurvivalTimeColumns {
80 entry_col,
81 exit_col,
82 })
83}
84
85const SURVIVAL_PROB_MIN_FOR_LOG: f64 = 1e-300;
91
92#[derive(Debug, Clone)]
99pub enum SurvivalPredictError {
100 InvalidInput { reason: String },
103 MissingFitMetadata { reason: String },
107 IncompatibleSchema { reason: String },
110 UnsupportedConfiguration { reason: String },
115 PosteriorCovariance { reason: String },
121 NumericalFailure { reason: String },
125 ModelPayload {
128 context: &'static str,
129 source: crate::inference::model::FittedModelError,
130 },
131}
132
133impl std::fmt::Display for SurvivalPredictError {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 match self {
136 SurvivalPredictError::InvalidInput { reason }
137 | SurvivalPredictError::MissingFitMetadata { reason }
138 | SurvivalPredictError::IncompatibleSchema { reason }
139 | SurvivalPredictError::UnsupportedConfiguration { reason }
140 | SurvivalPredictError::PosteriorCovariance { reason }
141 | SurvivalPredictError::NumericalFailure { reason } => f.write_str(reason),
142 SurvivalPredictError::ModelPayload { context, source } => {
143 write!(f, "{context}: {source}")
144 }
145 }
146 }
147}
148
149impl std::error::Error for SurvivalPredictError {
150 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
151 match self {
152 SurvivalPredictError::ModelPayload { source, .. } => Some(source),
153 SurvivalPredictError::InvalidInput { .. }
154 | SurvivalPredictError::MissingFitMetadata { .. }
155 | SurvivalPredictError::IncompatibleSchema { .. }
156 | SurvivalPredictError::UnsupportedConfiguration { .. }
157 | SurvivalPredictError::PosteriorCovariance { .. }
158 | SurvivalPredictError::NumericalFailure { .. } => None,
159 }
160 }
161}
162
163impl From<SurvivalPredictError> for String {
164 fn from(err: SurvivalPredictError) -> String {
165 err.to_string()
166 }
167}
168
169impl From<String> for SurvivalPredictError {
170 fn from(reason: String) -> SurvivalPredictError {
176 SurvivalPredictError::InvalidInput { reason }
177 }
178}
179
180impl From<gam_data::DataError> for SurvivalPredictError {
181 fn from(err: gam_data::DataError) -> SurvivalPredictError {
185 SurvivalPredictError::InvalidInput {
186 reason: err.to_string(),
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
199pub enum SurvivalPredictEstimand {
200 #[default]
201 PosteriorMean,
202 Plugin,
203}
204
205pub struct SurvivalPredictRequest<'a> {
207 pub model: &'a SavedModel,
208 pub data: ArrayView2<'a, f64>,
209 pub col_map: &'a HashMap<String, usize>,
210 pub training_headers: Option<&'a Vec<String>>,
211 pub primary_offset: &'a Array1<f64>,
212 pub noise_offset: &'a Array1<f64>,
213 pub time_grid: Option<&'a [f64]>,
216 pub with_uncertainty: bool,
222 pub estimand: SurvivalPredictEstimand,
225}
226
227pub struct SurvivalPredictResult {
229 pub times: Vec<f64>,
230 pub hazard: Array2<f64>,
231 pub survival: Array2<f64>,
232 pub cumulative_hazard: Array2<f64>,
233 pub linear_predictor: Array1<f64>,
234 pub likelihood_mode: SurvivalLikelihoodMode,
235 pub survival_se: Option<Array2<f64>>,
239 pub eta_se: Option<Array1<f64>>,
243}
244
245fn survival_prediction_posterior_factor(
251 model: &SavedModel,
252) -> Result<(Array1<f64>, Array2<f64>), SurvivalPredictError> {
253 let fit = fit_result_from_saved_model_for_prediction(model)?;
254 let inactive_tail = if require_saved_survival_likelihood_mode(model)?
255 == SurvivalLikelihoodMode::MarginalSlope
256 {
257 model
258 .saved_prediction_runtime()?
259 .influence_absorber_width
260 .unwrap_or(0)
261 } else {
262 0
263 };
264 let active_len = fit.beta.len().checked_sub(inactive_tail).ok_or_else(|| {
265 SurvivalPredictError::IncompatibleSchema {
266 reason: format!(
267 "saved survival influence-absorber width {inactive_tail} exceeds the {} fitted coefficients",
268 fit.beta.len()
269 ),
270 }
271 })?;
272 let covariance = fit.beta_covariance().ok_or_else(|| {
273 SurvivalPredictError::PosteriorCovariance {
274 reason: "posterior-mean survival prediction requires the saved conditional coefficient covariance; refit with the current REML path"
275 .to_string(),
276 }
277 })?;
278 if covariance.nrows() != fit.beta.len() || covariance.ncols() != fit.beta.len() {
279 return Err(SurvivalPredictError::PosteriorCovariance {
280 reason: format!(
281 "saved survival conditional covariance has shape {}x{}, expected {}x{} in fitted block order",
282 covariance.nrows(),
283 covariance.ncols(),
284 fit.beta.len(),
285 fit.beta.len(),
286 ),
287 });
288 }
289 Ok((
290 fit.beta.clone(),
291 covariance.slice(s![..active_len, ..active_len]).to_owned(),
292 ))
293}
294
295fn saved_model_with_survival_coefficients(
296 model: &SavedModel,
297 coefficients: &Array1<f64>,
298) -> Result<SavedModel, SurvivalPredictError> {
299 let mut draw_model = model.clone();
300 let payload = match &mut draw_model {
301 SavedModel::Standard { payload }
302 | SavedModel::LocationScale { payload }
303 | SavedModel::MarginalSlope { payload }
304 | SavedModel::Survival { payload }
305 | SavedModel::TransformationNormal { payload } => payload,
306 };
307
308 let (beta_time, beta_threshold, beta_log_sigma, beta_link_wiggle, beta_time_blocks) = {
309 let fit = payload.fit_result.as_mut().ok_or_else(|| {
310 SurvivalPredictError::MissingFitMetadata {
311 reason: "saved survival model is missing canonical fit_result".to_string(),
312 }
313 })?;
314 if coefficients.len() != fit.beta.len() {
315 return Err(SurvivalPredictError::IncompatibleSchema {
316 reason: format!(
317 "posterior survival coefficient draw has length {}, expected {}",
318 coefficients.len(),
319 fit.beta.len()
320 ),
321 });
322 }
323 fit.beta.assign(coefficients);
324 let mut cursor = 0usize;
325 for block in &mut fit.blocks {
326 let end = cursor + block.beta.len();
327 block.beta.assign(&coefficients.slice(s![cursor..end]));
328 cursor = end;
329 }
330 if cursor != coefficients.len() {
331 return Err(SurvivalPredictError::IncompatibleSchema {
332 reason: format!(
333 "saved survival coefficient blocks total {cursor} entries, but the joint vector has {}",
334 coefficients.len()
335 ),
336 });
337 }
338 (
339 fit.block_by_role(BlockRole::Time)
340 .map(|block| block.beta.to_vec()),
341 fit.block_by_role(BlockRole::Threshold)
342 .map(|block| block.beta.to_vec()),
343 fit.block_by_role(BlockRole::Scale)
344 .map(|block| block.beta.to_vec()),
345 fit.block_by_role(BlockRole::LinkWiggle)
346 .map(|block| block.beta.to_vec()),
347 fit.blocks
348 .iter()
349 .map(|block| block.beta.to_vec())
350 .collect::<Vec<_>>(),
351 )
352 };
353
354 if payload.survival_beta_time.is_some() {
355 payload.survival_beta_time = beta_time.clone();
356 }
357 if payload.survival_beta_threshold.is_some() {
358 payload.survival_beta_threshold = beta_threshold;
359 }
360 if payload.survival_beta_log_sigma.is_some() {
361 payload.survival_beta_log_sigma = beta_log_sigma;
362 }
363 if payload.beta_link_wiggle.is_some() {
364 payload.beta_link_wiggle = beta_link_wiggle;
365 }
366 if let (Some(saved), Some(time_beta)) = (
367 payload.beta_baseline_timewiggle.as_mut(),
368 beta_time.as_ref(),
369 ) {
370 if saved.len() > time_beta.len() {
371 return Err(SurvivalPredictError::IncompatibleSchema {
372 reason: format!(
373 "saved baseline-timewiggle has {} coefficients, but the time block has {}",
374 saved.len(),
375 time_beta.len()
376 ),
377 });
378 }
379 *saved = time_beta[time_beta.len() - saved.len()..].to_vec();
380 }
381 if let Some(saved_by_cause) = payload.beta_baseline_timewiggle_by_cause.as_mut() {
382 if saved_by_cause.len() != beta_time_blocks.len() {
383 return Err(SurvivalPredictError::IncompatibleSchema {
384 reason: format!(
385 "saved cause-specific timewiggles have {} blocks, but the fit has {} cause blocks",
386 saved_by_cause.len(),
387 beta_time_blocks.len()
388 ),
389 });
390 }
391 for (saved, block) in saved_by_cause.iter_mut().zip(&beta_time_blocks) {
392 if saved.len() > block.len() {
393 return Err(SurvivalPredictError::IncompatibleSchema {
394 reason: format!(
395 "saved cause-specific timewiggle has {} coefficients, but its endpoint block has {}",
396 saved.len(),
397 block.len()
398 ),
399 });
400 }
401 *saved = block[block.len() - saved.len()..].to_vec();
402 }
403 }
404 Ok(draw_model)
405}
406
407fn conditional_event_density(
408 survival: f64,
409 cumulative_hazard: f64,
410 hazard: f64,
411) -> Result<f64, SurvivalPredictError> {
412 if hazard == 0.0 {
413 return Ok(0.0);
414 }
415 if survival > 0.0 && hazard.is_finite() {
416 return Ok(survival * hazard);
417 }
418 if cumulative_hazard.is_finite() && hazard > 0.0 {
419 return Ok((hazard.ln() - cumulative_hazard).exp());
420 }
421 if cumulative_hazard == f64::INFINITY && hazard.is_finite() && hazard >= 0.0 {
422 return Ok(0.0);
423 }
424 Err(SurvivalPredictError::NumericalFailure {
425 reason: format!(
426 "posterior survival quadrature could not resolve conditional density from S={survival}, H={cumulative_hazard}, h={hazard}"
427 ),
428 })
429}
430
431fn for_each_survival_posterior_node<F>(
437 posterior_mean: &Array1<f64>,
438 active_covariance: &Array2<f64>,
439 mut consume: F,
440) -> Result<(), SurvivalPredictError>
441where
442 F: FnMut(&Array1<f64>, f64) -> Result<(), SurvivalPredictError>,
443{
444 let active_len = active_covariance.nrows();
445 if active_covariance.ncols() != active_len || active_len > posterior_mean.len() {
446 return Err(SurvivalPredictError::PosteriorCovariance {
447 reason: format!(
448 "survival posterior quadrature received mean length {} and active covariance {}x{}",
449 posterior_mean.len(),
450 active_covariance.nrows(),
451 active_covariance.ncols(),
452 ),
453 });
454 }
455 let factorization = crate::survival::location_scale::factorize_psd_covariance(
456 active_covariance,
457 "survival posterior coefficient covariance",
458 )
459 .map_err(|reason| SurvivalPredictError::PosteriorCovariance { reason })?;
460 let rank = factorization.factor.ncols();
461 if rank == 0 {
462 return consume(posterior_mean, 1.0);
463 }
464 let scale = (rank as f64).sqrt();
465 let weight = 1.0 / (2 * rank) as f64;
466 for column in 0..rank {
467 for sign in [-1.0_f64, 1.0_f64] {
468 let mut node = posterior_mean.clone();
469 for row in 0..active_len {
470 node[row] += sign * scale * factorization.factor[[row, column]];
471 }
472 consume(&node, weight)?;
473 }
474 }
475 Ok(())
476}
477
478fn predict_survival_posterior_mean(
479 req: SurvivalPredictRequest<'_>,
480) -> Result<SurvivalPredictResult, SurvivalPredictError> {
481 let (posterior_mean, active_covariance) = survival_prediction_posterior_factor(req.model)?;
482 let mut result = predict_survival(SurvivalPredictRequest {
483 model: req.model,
484 data: req.data,
485 col_map: req.col_map,
486 training_headers: req.training_headers,
487 primary_offset: req.primary_offset,
488 noise_offset: req.noise_offset,
489 time_grid: req.time_grid,
490 with_uncertainty: false,
491 estimand: SurvivalPredictEstimand::Plugin,
492 })?;
493 let (n_rows, n_times) = result.survival.dim();
494 let mut survival_mean = Array2::<f64>::zeros((n_rows, n_times));
495 let mut survival_second = Array2::<f64>::zeros((n_rows, n_times));
496 let mut density_mean = Array2::<f64>::zeros((n_rows, n_times));
497 let mut hazard_mean = Array2::<f64>::zeros((n_rows, n_times));
498 let mut eta_mean = Array1::<f64>::zeros(n_rows);
499 let mut eta_second = Array1::<f64>::zeros(n_rows);
500
501 for_each_survival_posterior_node(&posterior_mean, &active_covariance, |node, weight| {
502 let draw_model = saved_model_with_survival_coefficients(req.model, node)?;
503 let draw = predict_survival(SurvivalPredictRequest {
504 model: &draw_model,
505 data: req.data,
506 col_map: req.col_map,
507 training_headers: req.training_headers,
508 primary_offset: req.primary_offset,
509 noise_offset: req.noise_offset,
510 time_grid: req.time_grid,
511 with_uncertainty: false,
512 estimand: SurvivalPredictEstimand::Plugin,
513 })?;
514 if draw.survival.dim() != (n_rows, n_times)
515 || draw.hazard.dim() != (n_rows, n_times)
516 || draw.cumulative_hazard.dim() != (n_rows, n_times)
517 || draw.linear_predictor.len() != n_rows
518 || draw.times != result.times
519 || draw.likelihood_mode != result.likelihood_mode
520 {
521 return Err(SurvivalPredictError::IncompatibleSchema {
522 reason: "posterior survival quadrature node changed the prediction schema"
523 .to_string(),
524 });
525 }
526 for row in 0..n_rows {
527 let eta = draw.linear_predictor[row];
528 eta_mean[row] += weight * eta;
529 eta_second[row] += weight * eta * eta;
530 for time in 0..n_times {
531 let survival = draw.survival[[row, time]];
532 let hazard = draw.hazard[[row, time]];
533 let density = conditional_event_density(
534 survival,
535 draw.cumulative_hazard[[row, time]],
536 hazard,
537 )?;
538 survival_mean[[row, time]] += weight * survival;
539 survival_second[[row, time]] += weight * survival * survival;
540 density_mean[[row, time]] += weight * density;
541 hazard_mean[[row, time]] += weight * hazard;
542 }
543 }
544 Ok(())
545 })?;
546
547 for row in 0..n_rows {
548 for time in 0..n_times {
549 let survival = survival_mean[[row, time]].clamp(0.0, 1.0);
550 let density = density_mean[[row, time]];
551 if !(density.is_finite() && density >= 0.0) {
552 return Err(SurvivalPredictError::NumericalFailure {
553 reason: format!(
554 "posterior survival density is invalid at row {row}, time column {time}: {density}"
555 ),
556 });
557 }
558 result.survival[[row, time]] = survival;
559 result.cumulative_hazard[[row, time]] = -survival.ln();
560 result.hazard[[row, time]] = if survival > 0.0 {
561 density / survival
562 } else if hazard_mean[[row, time]] == 0.0 {
563 0.0
564 } else {
565 f64::INFINITY
566 };
567 }
568 }
569 result.survival_se = req.with_uncertainty.then(|| {
570 Array2::from_shape_fn((n_rows, n_times), |(row, time)| {
571 (survival_second[[row, time]] - survival_mean[[row, time]] * survival_mean[[row, time]])
572 .max(0.0)
573 .sqrt()
574 })
575 });
576 result.eta_se = req.with_uncertainty.then(|| {
577 Array1::from_shape_fn(n_rows, |row| {
578 (eta_second[row] - eta_mean[row] * eta_mean[row])
579 .max(0.0)
580 .sqrt()
581 })
582 });
583 Ok(result)
584}
585
586fn predict_competing_risks_posterior_mean(
587 req: SurvivalPredictRequest<'_>,
588) -> Result<CompetingRisksPredictResult, SurvivalPredictError> {
589 if req.with_uncertainty {
590 return Err(SurvivalPredictError::UnsupportedConfiguration {
591 reason: "competing-risks survival prediction does not yet expose posterior surface standard errors"
592 .to_string(),
593 });
594 }
595 let (posterior_mean, active_covariance) = survival_prediction_posterior_factor(req.model)?;
596 let mut result = predict_competing_risks_survival(SurvivalPredictRequest {
597 model: req.model,
598 data: req.data,
599 col_map: req.col_map,
600 training_headers: req.training_headers,
601 primary_offset: req.primary_offset,
602 noise_offset: req.noise_offset,
603 time_grid: req.time_grid,
604 with_uncertainty: false,
605 estimand: SurvivalPredictEstimand::Plugin,
606 })?;
607 let cause_count = result.cif.len();
608 let (n_rows, n_times) = result.overall_survival.dim();
609 let mut survival_mean = (0..cause_count)
610 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
611 .collect::<Vec<_>>();
612 let mut density_mean = (0..cause_count)
613 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
614 .collect::<Vec<_>>();
615 let mut hazard_mean = (0..cause_count)
616 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
617 .collect::<Vec<_>>();
618 let mut cif_mean = (0..cause_count)
619 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
620 .collect::<Vec<_>>();
621 let mut overall_mean = Array2::<f64>::zeros((n_rows, n_times));
622
623 for_each_survival_posterior_node(&posterior_mean, &active_covariance, |node, weight| {
624 let draw_model = saved_model_with_survival_coefficients(req.model, node)?;
625 let draw = predict_competing_risks_survival(SurvivalPredictRequest {
626 model: &draw_model,
627 data: req.data,
628 col_map: req.col_map,
629 training_headers: req.training_headers,
630 primary_offset: req.primary_offset,
631 noise_offset: req.noise_offset,
632 time_grid: req.time_grid,
633 with_uncertainty: false,
634 estimand: SurvivalPredictEstimand::Plugin,
635 })?;
636 if draw.cif.len() != cause_count
637 || draw.survival.len() != cause_count
638 || draw.hazard.len() != cause_count
639 || draw.cumulative_hazard.len() != cause_count
640 || draw.overall_survival.dim() != (n_rows, n_times)
641 || draw.times != result.times
642 || draw.endpoint_names != result.endpoint_names
643 || draw.likelihood_mode != result.likelihood_mode
644 {
645 return Err(SurvivalPredictError::IncompatibleSchema {
646 reason: "posterior competing-risks quadrature node changed the prediction schema"
647 .to_string(),
648 });
649 }
650 for cause in 0..cause_count {
651 if draw.survival[cause].dim() != (n_rows, n_times)
652 || draw.hazard[cause].dim() != (n_rows, n_times)
653 || draw.cumulative_hazard[cause].dim() != (n_rows, n_times)
654 || draw.cif[cause].dim() != (n_rows, n_times)
655 {
656 return Err(SurvivalPredictError::IncompatibleSchema {
657 reason: format!(
658 "posterior competing-risks quadrature node changed cause {} surface dimensions",
659 cause + 1
660 ),
661 });
662 }
663 for row in 0..n_rows {
664 for time in 0..n_times {
665 let survival = draw.survival[cause][[row, time]];
666 let hazard = draw.hazard[cause][[row, time]];
667 let density = conditional_event_density(
668 survival,
669 draw.cumulative_hazard[cause][[row, time]],
670 hazard,
671 )?;
672 survival_mean[cause][[row, time]] += weight * survival;
673 density_mean[cause][[row, time]] += weight * density;
674 hazard_mean[cause][[row, time]] += weight * hazard;
675 cif_mean[cause][[row, time]] += weight * draw.cif[cause][[row, time]];
676 }
677 }
678 }
679 for row in 0..n_rows {
680 for time in 0..n_times {
681 overall_mean[[row, time]] += weight * draw.overall_survival[[row, time]];
682 }
683 }
684 Ok(())
685 })?;
686
687 for cause in 0..cause_count {
688 for row in 0..n_rows {
689 for time in 0..n_times {
690 let survival = survival_mean[cause][[row, time]].clamp(0.0, 1.0);
691 let density = density_mean[cause][[row, time]];
692 if !(density.is_finite() && density >= 0.0) {
693 return Err(SurvivalPredictError::NumericalFailure {
694 reason: format!(
695 "posterior competing-risks density is invalid for cause {}, row {row}, time column {time}: {density}",
696 cause + 1
697 ),
698 });
699 }
700 result.survival[cause][[row, time]] = survival;
701 result.cumulative_hazard[cause][[row, time]] = -survival.ln();
702 result.hazard[cause][[row, time]] = if survival > 0.0 {
703 density / survival
704 } else if hazard_mean[cause][[row, time]] == 0.0 {
705 0.0
706 } else {
707 f64::INFINITY
708 };
709 result.cif[cause][[row, time]] = cif_mean[cause][[row, time]].clamp(0.0, 1.0);
710 }
711 }
712 }
713 result.overall_survival = overall_mean.mapv(|value| value.clamp(0.0, 1.0));
714 Ok(result)
715}
716
717fn restricted_mean_survival_time_from_curve(
733 times: &[f64],
734 survival_row: ndarray::ArrayView1<'_, f64>,
735 tau: f64,
736) -> Option<f64> {
737 if times.is_empty() || !(tau > 0.0) || !tau.is_finite() {
738 return None;
739 }
740 if times.len() != survival_row.len() {
741 return None;
742 }
743
744 let mut prev_t = 0.0_f64;
746 let mut prev_s = 1.0_f64;
747 let mut area = 0.0_f64;
748
749 for (idx, &t) in times.iter().enumerate() {
750 if !t.is_finite() || t < prev_t {
751 return None;
752 }
753 let s = survival_row[idx];
754 if !s.is_finite() {
755 return None;
756 }
757 if t >= tau {
758 let span = t - prev_t;
760 let s_tau = if span > 0.0 {
761 let w = (tau - prev_t) / span;
762 prev_s + w * (s - prev_s)
763 } else {
764 prev_s
765 };
766 area += 0.5 * (prev_s + s_tau) * (tau - prev_t);
767 return Some(area);
768 }
769 area += 0.5 * (prev_s + s) * (t - prev_t);
770 prev_t = t;
771 prev_s = s;
772 }
773
774 area += prev_s * (tau - prev_t);
778 Some(area)
779}
780
781impl SurvivalPredictResult {
782 pub fn restricted_mean_survival_time(&self, tau: f64) -> Option<Array1<f64>> {
789 let n = self.survival.nrows();
790 let mut out = Array1::<f64>::zeros(n);
791 for i in 0..n {
792 let rmst =
793 restricted_mean_survival_time_from_curve(&self.times, self.survival.row(i), tau)?;
794 out[i] = rmst;
795 }
796 Some(out)
797 }
798}
799
800impl CompetingRisksPredictResult {
801 pub fn restricted_mean_overall_survival_time(&self, tau: f64) -> Option<Array1<f64>> {
807 let n = self.overall_survival.nrows();
808 let mut out = Array1::<f64>::zeros(n);
809 for i in 0..n {
810 let rmst = restricted_mean_survival_time_from_curve(
811 &self.times,
812 self.overall_survival.row(i),
813 tau,
814 )?;
815 out[i] = rmst;
816 }
817 Some(out)
818 }
819}
820
821pub fn harrell_concordance(time: &[f64], event: &[f64], risk: &[f64]) -> Option<f64> {
834 let n = time.len();
835 if n != event.len() || n != risk.len() {
836 return None;
837 }
838 let mut comparable = 0.0_f64;
839 let mut concordant = 0.0_f64;
840 for i in 0..n {
841 for j in (i + 1)..n {
842 let (early, late) = if time[i] < time[j] {
843 (i, j)
844 } else if time[j] < time[i] {
845 (j, i)
846 } else {
847 if event[i] > 0.5 && event[j] > 0.5 {
850 comparable += 1.0;
851 concordant += 0.5;
852 }
853 continue;
854 };
855 if event[early] < 0.5 {
856 continue;
858 }
859 comparable += 1.0;
860 if risk[early] > risk[late] {
861 concordant += 1.0;
862 } else if risk[early] == risk[late] {
863 concordant += 0.5;
864 }
865 }
866 }
867 if comparable == 0.0 {
868 return None;
869 }
870 Some(concordant / comparable)
871}
872
873pub fn ipcw_brier_score(
902 s_pred: &[f64],
903 time: &[f64],
904 event: &[f64],
905 tau: f64,
906 g_cens: impl Fn(f64) -> f64,
907) -> Option<f64> {
908 let n = s_pred.len();
909 if n != time.len() || n != event.len() {
910 return None;
911 }
912 let mut n_valid = 0.0_f64;
913 let mut acc = 0.0_f64;
914 for i in 0..n {
915 if !time[i].is_finite() || !event[i].is_finite() || time[i] <= 0.0 {
916 continue;
917 }
918 n_valid += 1.0;
921 let (target, weight) = if time[i] <= tau && event[i] > 0.5 {
922 let g = g_cens(time[i]);
924 if !(g > 0.0) {
925 continue;
926 }
927 (0.0, 1.0 / g)
928 } else if time[i] > tau {
929 let g = g_cens(tau);
931 if !(g > 0.0) {
932 continue;
933 }
934 (1.0, 1.0 / g)
935 } else {
936 continue;
938 };
939 let resid = target - s_pred[i];
940 acc += weight * resid * resid;
941 }
942 if n_valid == 0.0 {
943 return None;
944 }
945 Some(acc / n_valid)
946}
947
948pub fn integrated_ipcw_brier_score(
967 s_pred: ArrayView2<f64>,
968 time: &[f64],
969 event: &[f64],
970 grid: &[f64],
971 horizon: f64,
972 g_cens: impl Fn(f64) -> f64,
973) -> Option<f64> {
974 let m = grid.len();
975 if m < 2 || s_pred.ncols() != m || s_pred.nrows() != time.len() {
976 return None;
977 }
978 if grid.windows(2).any(|pair| !(pair[1] > pair[0])) {
979 return None;
980 }
981 let mut pts: Vec<(f64, f64)> = Vec::with_capacity(m);
983 for k in 0..m {
984 if grid[k] > horizon {
985 break;
986 }
987 let col = s_pred.column(k);
988 let col_slice: Vec<f64> = col.to_vec();
989 if let Some(bs) = ipcw_brier_score(&col_slice, time, event, grid[k], &g_cens) {
990 pts.push((grid[k], bs));
991 }
992 }
993 if pts.len() < 2 {
994 return None;
995 }
996 let span = pts[pts.len() - 1].0 - pts[0].0;
997 if !(span > 0.0) {
998 return None;
999 }
1000 let mut integral = 0.0_f64;
1001 for w in pts.windows(2) {
1002 integral += 0.5 * (w[1].1 + w[0].1) * (w[1].0 - w[0].0);
1003 }
1004 Some(integral / span)
1005}
1006
1007#[derive(Clone, Debug, Default)]
1014pub struct KaplanMeier {
1015 steps: Vec<(f64, f64)>,
1017}
1018
1019impl KaplanMeier {
1020 pub fn fit(time: &[f64], event: &[f64]) -> Self {
1022 let mut rows: Vec<(f64, bool)> = time
1023 .iter()
1024 .zip(event.iter())
1025 .filter_map(|(&t, &e)| {
1026 (t.is_finite() && e.is_finite() && t > 0.0).then_some((t, e > 0.5))
1027 })
1028 .collect();
1029 rows.sort_by(|a, b| a.0.total_cmp(&b.0));
1030 let mut steps = Vec::new();
1031 let mut at_risk = rows.len() as f64;
1032 let mut survival = 1.0_f64;
1033 let mut i = 0usize;
1034 while i < rows.len() {
1035 let t = rows[i].0;
1036 let mut j = i;
1037 let mut deaths = 0usize;
1038 while j < rows.len() && rows[j].0 == t {
1039 deaths += usize::from(rows[j].1);
1040 j += 1;
1041 }
1042 if deaths > 0 && at_risk > 0.0 {
1043 survival *= ((at_risk - deaths as f64) / at_risk).max(0.0);
1044 steps.push((t, survival));
1045 }
1046 at_risk -= (j - i) as f64;
1047 i = j;
1048 }
1049 Self { steps }
1050 }
1051
1052 pub fn fit_censoring(time: &[f64], event: &[f64]) -> Self {
1056 let flipped: Vec<f64> = event
1057 .iter()
1058 .map(|&e| if e > 0.5 { 0.0 } else { 1.0 })
1059 .collect();
1060 Self::fit(time, &flipped)
1061 }
1062
1063 pub fn at(&self, t: f64) -> f64 {
1066 let mut s = 1.0_f64;
1067 for &(time, surv) in &self.steps {
1068 if time <= t {
1069 s = surv;
1070 } else {
1071 break;
1072 }
1073 }
1074 s
1075 }
1076}
1077
1078pub struct CompetingRisksPredictResult {
1080 pub times: Vec<f64>,
1081 pub endpoint_names: Vec<String>,
1082 pub hazard: Vec<Array2<f64>>,
1084 pub survival: Vec<Array2<f64>>,
1086 pub cumulative_hazard: Vec<Array2<f64>>,
1088 pub cif: Vec<Array2<f64>>,
1090 pub overall_survival: Array2<f64>,
1092 pub linear_predictor: Vec<Array1<f64>>,
1094 pub likelihood_mode: SurvivalLikelihoodMode,
1095}
1096
1097pub fn predict_survival(
1103 req: SurvivalPredictRequest<'_>,
1104) -> Result<SurvivalPredictResult, SurvivalPredictError> {
1105 if req.estimand == SurvivalPredictEstimand::PosteriorMean {
1106 return predict_survival_posterior_mean(req);
1107 }
1108 let SurvivalPredictRequest {
1109 model,
1110 data,
1111 col_map,
1112 training_headers,
1113 primary_offset,
1114 noise_offset,
1115 time_grid,
1116 with_uncertainty,
1117 estimand: _,
1118 } = req;
1119
1120 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1129 let exit_col = time_cols.exit_col;
1130
1131 let termspec = resolve_termspec_for_prediction(
1132 &model.resolved_termspec,
1133 training_headers,
1134 col_map,
1135 "resolved_termspec",
1136 )?;
1137 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1143 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1144 let cov_design = build_term_collection_design(cov_input, &termspec)
1145 .map_err(|e| format!("failed to build survival prediction design: {e}"))?;
1146
1147 let n = data.nrows();
1148 if primary_offset.len() != n || noise_offset.len() != n {
1149 return Err(SurvivalPredictError::InvalidInput {
1150 reason: format!(
1151 "survival prediction offset length mismatch: rows={n}, offset={}, noise_offset={}",
1152 primary_offset.len(),
1153 noise_offset.len()
1154 ),
1155 });
1156 }
1157
1158 use rayon::iter::{IntoParallelIterator, ParallelIterator};
1159 let pairs: Result<Vec<(f64, f64)>, String> = (0..n)
1160 .into_par_iter()
1161 .map(|i| {
1162 normalize_survival_time_pair(time_cols.row_entry_time(data, i), data[[i, exit_col]], i)
1163 })
1164 .collect();
1165 let pairs = pairs?;
1166 let mut age_entry = Array1::<f64>::zeros(n);
1167 let mut age_exit = Array1::<f64>::zeros(n);
1168 for (i, (t0, t1)) in pairs.into_iter().enumerate() {
1169 age_entry[i] = t0;
1170 age_exit[i] = t1;
1171 }
1172
1173 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1174
1175 if matches!(
1179 saved_likelihood_mode,
1180 SurvivalLikelihoodMode::Latent | SurvivalLikelihoodMode::LatentBinary
1181 ) {
1182 return Err(SurvivalPredictError::UnsupportedConfiguration {
1183 reason: format!(
1184 "survival prediction via predict_survival does not support likelihood_mode={} yet; \
1185 latent window prediction lives in the CLI's run_predict_saved_latent_window_impl \
1186 pipeline and has not yet been ported to the library. Use the CLI predict command.",
1187 survival_likelihood_modename(saved_likelihood_mode)
1188 ),
1189 });
1190 }
1191 if saved_likelihood_mode == SurvivalLikelihoodMode::LocationScale {
1194 return predict_survival_location_scale_batch(
1195 model,
1196 &age_entry,
1197 &age_exit,
1198 &cov_design,
1199 primary_offset,
1200 noise_offset,
1201 training_headers,
1202 col_map,
1203 data,
1204 time_grid,
1205 with_uncertainty,
1206 )
1207 .map_err(SurvivalPredictError::from);
1208 }
1209 if with_uncertainty {
1210 return Err(SurvivalPredictError::from(format!(
1211 "predict_survival: with_uncertainty is currently supported only for the \
1212 location-scale likelihood mode; got {}",
1213 survival_likelihood_modename(saved_likelihood_mode)
1214 )));
1215 }
1216
1217 let time_cfg = load_survival_time_basis_config_from_model(model)?;
1220 let mut time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
1221 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
1222 &time_build.basisname,
1223 time_build.degree,
1224 time_build.knots.as_ref(),
1225 time_build.keep_cols.as_ref(),
1226 time_build.smooth_lambda,
1227 )?;
1228 let weibull_baseline_in_beta = saved_likelihood_mode == SurvivalLikelihoodMode::Weibull
1245 && !model.has_baseline_time_wiggle();
1246 let mut time_anchor: Option<f64> = None;
1247 let mut time_anchor_row_cached: Option<Array1<f64>> = None;
1248 if matches!(
1249 saved_likelihood_mode,
1250 SurvivalLikelihoodMode::LocationScale | SurvivalLikelihoodMode::MarginalSlope
1251 ) || weibull_baseline_in_beta
1252 {
1253 let anchor = model
1254 .survival_time_anchor
1255 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1256 let time_anchor_row = evaluate_survival_time_basis_row(anchor, &resolved_time_cfg)?;
1257 center_survival_time_designs_at_anchor(
1258 &mut time_build.x_entry_time,
1259 &mut time_build.x_exit_time,
1260 &time_anchor_row,
1261 )?;
1262 time_anchor = Some(anchor);
1263 time_anchor_row_cached = Some(time_anchor_row);
1264 }
1265 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull && !model.has_baseline_time_wiggle()
1266 {
1267 require_structural_survival_time_basis(&time_build.basisname, "saved survival sampling")?;
1268 }
1269 let mut baseline_cfg = saved_survival_runtime_baseline_config(model)?;
1270 if weibull_baseline_in_beta {
1271 baseline_cfg = SurvivalBaselineConfig {
1272 target: SurvivalBaselineTarget::Linear,
1273 scale: None,
1274 shape: None,
1275 rate: None,
1276 makeham: None,
1277 };
1278 }
1279
1280 let per_row_eval = time_grid.is_none();
1283 let eval_times: Vec<f64> = match time_grid {
1284 Some(grid) => {
1285 if grid.is_empty() {
1286 return Err(SurvivalPredictError::InvalidInput {
1287 reason: "survival time_grid must contain at least one time".to_string(),
1288 });
1289 }
1290 for (idx, &t) in grid.iter().enumerate() {
1291 if !t.is_finite() || t < 0.0 {
1292 return Err(SurvivalPredictError::InvalidInput {
1293 reason: format!(
1294 "survival time_grid requires finite non-negative times (index {idx})",
1295 ),
1296 });
1297 }
1298 }
1299 grid.to_vec()
1300 }
1301 None => Vec::new(),
1302 };
1303
1304 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
1305 let mut hazard = Array2::<f64>::zeros((n, t_cols));
1306 let mut survival = Array2::<f64>::zeros((n, t_cols));
1307 let mut cumulative_hazard = Array2::<f64>::zeros((n, t_cols));
1308 let mut linear_predictor = Array1::<f64>::zeros(n);
1309
1310 let marginal_slope_ctx = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1316 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
1320 build_survival_time_offsets_for_likelihood(
1321 &age_entry,
1322 &age_exit,
1323 &baseline_cfg,
1324 saved_likelihood_mode,
1325 None,
1326 )?;
1327 add_survival_time_derivative_guard_offset(
1328 &age_entry,
1329 &age_exit,
1330 time_anchor.ok_or_else(|| {
1331 "saved survival marginal-slope model missing survival_time_anchor".to_string()
1332 })?,
1333 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
1334 &mut eta_offset_entry,
1335 &mut eta_offset_exit,
1336 &mut derivative_offset_exit,
1337 )?;
1338 Some(build_marginal_slope_predict_context(
1339 model,
1340 data,
1341 col_map,
1342 training_headers,
1343 &cov_design.design,
1344 primary_offset,
1345 noise_offset,
1346 &time_build,
1347 &eta_offset_entry,
1348 &eta_offset_exit,
1349 &derivative_offset_exit,
1350 )?)
1351 } else {
1352 None
1353 };
1354
1355 struct SurvivalPredictionRow {
1359 hazard: Vec<f64>,
1360 survival: Vec<f64>,
1361 cumulative_hazard: Vec<f64>,
1362 linear_predictor: f64,
1363 }
1364
1365 let row_results: Result<Vec<SurvivalPredictionRow>, SurvivalPredictError> = (0..n)
1366 .into_par_iter()
1367 .map(|i| {
1368 let cov_row = if matches!(
1369 saved_likelihood_mode,
1370 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull
1371 ) {
1372 Some(design_row_owned(
1373 &cov_design.design,
1374 i,
1375 "survival predict covariate row",
1376 )?)
1377 } else {
1378 None
1379 };
1380 let evaluate_at = |t_query: f64| -> Result<(f64, f64, f64), SurvivalPredictError> {
1381 let t_entry = age_entry[i].min(t_query);
1382 let single_entry = Array1::from_elem(1, t_entry);
1383 let single_exit = Array1::from_elem(1, t_query);
1384 let mut row_time =
1385 build_survival_time_basis(&single_entry, &single_exit, time_cfg.clone(), None)?;
1386 if let Some(anchor_row) = time_anchor_row_cached.as_ref() {
1387 center_survival_time_designs_at_anchor(
1388 &mut row_time.x_entry_time,
1389 &mut row_time.x_exit_time,
1390 anchor_row,
1391 )?;
1392 }
1393 let (mut r_eta_entry, mut r_eta_exit, mut r_deriv_exit) =
1394 build_survival_time_offsets_for_likelihood(
1395 &single_entry,
1396 &single_exit,
1397 &baseline_cfg,
1398 saved_likelihood_mode,
1399 None,
1400 )?;
1401 if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1402 add_survival_time_derivative_guard_offset(
1403 &single_entry,
1404 &single_exit,
1405 time_anchor.ok_or_else(|| {
1406 "saved survival marginal-slope model missing survival_time_anchor"
1407 .to_string()
1408 })?,
1409 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
1410 &mut r_eta_entry,
1411 &mut r_eta_exit,
1412 &mut r_deriv_exit,
1413 )?;
1414 }
1415
1416 match saved_likelihood_mode {
1417 SurvivalLikelihoodMode::MarginalSlope => {
1418 let ctx = marginal_slope_ctx.as_ref().ok_or_else(|| {
1419 "internal error: marginal-slope context missing for marginal-slope mode"
1420 .to_string()
1421 })?;
1422 evaluate_marginal_slope_row(
1423 i,
1424 ctx,
1425 &row_time,
1426 &r_eta_exit,
1427 &r_deriv_exit,
1428 primary_offset[i],
1429 )
1430 }
1431 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull => {
1432 let cov_row = cov_row.as_ref().ok_or_else(|| {
1433 "internal error: covariate row missing for Royston-Parmar prediction"
1434 .to_string()
1435 })?;
1436 evaluate_rp_row(
1437 model,
1438 &row_time,
1439 cov_row,
1440 r_eta_exit[0],
1441 r_deriv_exit[0],
1442 primary_offset[i],
1443 )
1444 }
1445 SurvivalLikelihoodMode::Latent
1446 | SurvivalLikelihoodMode::LatentBinary
1447 | SurvivalLikelihoodMode::LocationScale => {
1448 Err(SurvivalPredictError::NumericalFailure {
1449 reason: "unreachable: unsupported likelihood_mode filtered earlier"
1450 .to_string(),
1451 })
1452 }
1453 }
1454 };
1455
1456 let mut row = SurvivalPredictionRow {
1457 hazard: vec![0.0; t_cols],
1458 survival: vec![0.0; t_cols],
1459 cumulative_hazard: vec![0.0; t_cols],
1460 linear_predictor: 0.0,
1461 };
1462 if per_row_eval {
1463 let (eta_t, cum_t, haz_t) = evaluate_at(age_exit[i])?;
1464 row.linear_predictor = eta_t;
1465 row.hazard[0] = haz_t;
1466 row.cumulative_hazard[0] = cum_t;
1467 row.survival[0] = (-cum_t).exp().clamp(0.0, 1.0);
1468 } else {
1469 for (j, &t_query) in eval_times.iter().enumerate() {
1470 if t_query <= 0.0 {
1471 row.hazard[j] = 0.0;
1472 row.cumulative_hazard[j] = 0.0;
1473 row.survival[j] = 1.0;
1474 } else {
1475 let (_eta_t, cum_t, haz_t) = evaluate_at(t_query)?;
1476 row.hazard[j] = haz_t;
1477 row.cumulative_hazard[j] = cum_t;
1478 row.survival[j] = (-cum_t).exp().clamp(0.0, 1.0);
1479 }
1480 }
1481 let (eta_t, _, _) = evaluate_at(age_exit[i])?;
1482 row.linear_predictor = eta_t;
1483 }
1484 Ok(row)
1485 })
1486 .collect();
1487
1488 for (i, row) in row_results?.into_iter().enumerate() {
1489 linear_predictor[i] = row.linear_predictor;
1490 for j in 0..t_cols {
1491 hazard[[i, j]] = row.hazard[j];
1492 cumulative_hazard[[i, j]] = row.cumulative_hazard[j];
1493 survival[[i, j]] = row.survival[j];
1494 }
1495 }
1496
1497 let times_out: Vec<f64> = if per_row_eval {
1498 age_exit.to_vec()
1499 } else {
1500 eval_times
1501 };
1502
1503 Ok(SurvivalPredictResult {
1504 times: times_out,
1505 hazard,
1506 survival,
1507 cumulative_hazard,
1508 linear_predictor,
1509 likelihood_mode: saved_likelihood_mode,
1510 survival_se: None,
1511 eta_se: None,
1512 })
1513}
1514
1515pub fn predict_competing_risks_survival(
1516 req: SurvivalPredictRequest<'_>,
1517) -> Result<CompetingRisksPredictResult, SurvivalPredictError> {
1518 if req.estimand == SurvivalPredictEstimand::PosteriorMean {
1519 return predict_competing_risks_posterior_mean(req);
1520 }
1521 let SurvivalPredictRequest {
1522 model,
1523 data,
1524 col_map,
1525 training_headers,
1526 primary_offset,
1527 noise_offset,
1528 time_grid,
1529 with_uncertainty,
1530 estimand: _,
1531 } = req;
1532
1533 if with_uncertainty {
1534 return Err(SurvivalPredictError::UnsupportedConfiguration {
1535 reason: "competing-risks survival prediction does not yet support with_uncertainty"
1536 .to_string(),
1537 });
1538 }
1539
1540 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1541 if !matches!(
1542 saved_likelihood_mode,
1543 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull
1544 ) {
1545 return Err(SurvivalPredictError::UnsupportedConfiguration {
1546 reason: format!(
1547 "joint cause-specific prediction supports transformation/weibull survival only; got {}",
1548 survival_likelihood_modename(saved_likelihood_mode)
1549 ),
1550 });
1551 }
1552
1553 let fit = fit_result_from_saved_model_for_prediction(model)?;
1554 let cause_count = model
1555 .survival_cause_count
1556 .unwrap_or(fit.blocks.len())
1557 .max(1);
1558 if cause_count <= 1 {
1559 return Err(SurvivalPredictError::MissingFitMetadata {
1560 reason: "competing-risks survival prediction requires a saved model with at least two causes"
1561 .to_string(),
1562 });
1563 }
1564 if fit.blocks.len() != cause_count {
1565 return Err(SurvivalPredictError::IncompatibleSchema {
1566 reason: format!(
1567 "saved competing-risks survival fit has {} coefficient blocks but metadata says {cause_count} causes",
1568 fit.blocks.len()
1569 ),
1570 });
1571 }
1572 let endpoint_names = model.survival_endpoint_names.clone().unwrap_or_else(|| {
1573 (1..=cause_count)
1574 .map(|idx| format!("cause_{idx}"))
1575 .collect()
1576 });
1577 if endpoint_names.len() != cause_count {
1578 return Err(SurvivalPredictError::IncompatibleSchema {
1579 reason: format!(
1580 "saved competing-risks survival endpoint_names has length {}, expected {cause_count}",
1581 endpoint_names.len()
1582 ),
1583 });
1584 }
1585
1586 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1590 let exit_col = time_cols.exit_col;
1591
1592 let termspec = resolve_termspec_for_prediction(
1593 &model.resolved_termspec,
1594 training_headers,
1595 col_map,
1596 "resolved_termspec",
1597 )?;
1598 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1599 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1600 let cov_design = build_term_collection_design(cov_input, &termspec)
1601 .map_err(|e| format!("failed to build competing-risks prediction design: {e}"))?;
1602
1603 let n = data.nrows();
1604 if primary_offset.len() != n || noise_offset.len() != n {
1605 return Err(SurvivalPredictError::InvalidInput {
1606 reason: format!(
1607 "competing-risks prediction offset length mismatch: rows={n}, offset={}, noise_offset={}",
1608 primary_offset.len(),
1609 noise_offset.len()
1610 ),
1611 });
1612 }
1613
1614 use rayon::iter::{IntoParallelIterator, ParallelIterator};
1615 let pairs: Result<Vec<(f64, f64)>, String> = (0..n)
1616 .into_par_iter()
1617 .map(|i| {
1618 normalize_survival_time_pair(time_cols.row_entry_time(data, i), data[[i, exit_col]], i)
1619 })
1620 .collect();
1621 let pairs = pairs?;
1622 let mut age_entry = Array1::<f64>::zeros(n);
1623 let mut age_exit = Array1::<f64>::zeros(n);
1624 for (i, (t0, t1)) in pairs.into_iter().enumerate() {
1625 age_entry[i] = t0;
1626 age_exit[i] = t1;
1627 }
1628
1629 let time_cfg = load_survival_time_basis_config_from_model(model)?;
1630 let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
1631 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
1632 &time_build.basisname,
1633 time_build.degree,
1634 time_build.knots.as_ref(),
1635 time_build.keep_cols.as_ref(),
1636 time_build.smooth_lambda,
1637 )?;
1638 let weibull_baseline_in_beta = saved_likelihood_mode == SurvivalLikelihoodMode::Weibull
1647 && !model.has_baseline_time_wiggle();
1648 let cr_time_anchor_row: Option<Array1<f64>> = if weibull_baseline_in_beta {
1649 let anchor = model
1650 .survival_time_anchor
1651 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1652 Some(evaluate_survival_time_basis_row(
1653 anchor,
1654 &resolved_time_cfg,
1655 )?)
1656 } else {
1657 None
1658 };
1659 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull && !model.has_baseline_time_wiggle()
1660 {
1661 require_structural_survival_time_basis(
1662 &time_build.basisname,
1663 "saved competing-risks survival prediction",
1664 )?;
1665 }
1666 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
1667
1668 let per_row_eval = time_grid.is_none();
1669 let eval_times: Vec<f64> = match time_grid {
1670 Some(grid) => {
1671 if grid.is_empty() {
1672 return Err(SurvivalPredictError::InvalidInput {
1673 reason: "survival time_grid must contain at least one time".to_string(),
1674 });
1675 }
1676 for (idx, &t) in grid.iter().enumerate() {
1677 if !t.is_finite() || t < 0.0 {
1678 return Err(SurvivalPredictError::InvalidInput {
1679 reason: format!(
1680 "survival time_grid requires finite non-negative times (index {idx})",
1681 ),
1682 });
1683 }
1684 }
1685 grid.to_vec()
1686 }
1687 None => Vec::new(),
1688 };
1689 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
1690
1691 const CIF_REFINE_SUBINTERVALS: usize = 32;
1710 let (refined_times, user_time_to_refined_index): (Vec<f64>, Vec<usize>) = if per_row_eval {
1711 (Vec::new(), Vec::new())
1712 } else {
1713 let mut order: Vec<usize> = (0..eval_times.len()).collect();
1721 order.sort_by(|&a, &b| {
1722 eval_times[a]
1723 .partial_cmp(&eval_times[b])
1724 .expect("survival time_grid entries are validated finite above")
1725 });
1726 let mut refined: Vec<f64> = Vec::new();
1727 let mut user_index: Vec<usize> = vec![0; eval_times.len()];
1728 let mut prev = 0.0_f64;
1729 for &j_user in &order {
1730 let t_user = eval_times[j_user];
1731 let gap = t_user - prev;
1736 if gap > 0.0 {
1737 for s in 1..CIF_REFINE_SUBINTERVALS {
1738 let t_mid = prev + gap * (s as f64) / (CIF_REFINE_SUBINTERVALS as f64);
1739 if refined.last().is_none_or(|&last| t_mid > last) {
1741 refined.push(t_mid);
1742 }
1743 }
1744 }
1745 if refined.last().is_none_or(|&last| t_user > last) {
1746 refined.push(t_user);
1747 }
1748 user_index[j_user] = refined.len() - 1;
1749 prev = t_user;
1750 }
1751 (refined, user_index)
1752 };
1753 let refined_cols = if per_row_eval {
1756 CIF_REFINE_SUBINTERVALS
1757 } else {
1758 refined_times.len()
1759 };
1760
1761 let saved_timewiggle_by_cause = saved_cause_specific_timewiggles(model, &fit, cause_count)?;
1762 let cov_rows = (0..n)
1763 .map(|i| design_row_owned(&cov_design.design, i, "competing-risks covariate row"))
1764 .collect::<Result<Vec<_>, _>>()?;
1765
1766 let mut hazard = (0..cause_count)
1767 .map(|_| Array2::<f64>::zeros((n, t_cols)))
1768 .collect::<Vec<_>>();
1769 let mut survival = (0..cause_count)
1770 .map(|_| Array2::<f64>::zeros((n, t_cols)))
1771 .collect::<Vec<_>>();
1772 let mut cumulative_hazard = (0..cause_count)
1773 .map(|_| Array2::<f64>::zeros((n, t_cols)))
1774 .collect::<Vec<_>>();
1775 let mut cumulative_hazard_refined = (0..cause_count)
1778 .map(|_| Array2::<f64>::zeros((n, refined_cols)))
1779 .collect::<Vec<_>>();
1780 let mut linear_predictor = (0..cause_count)
1781 .map(|_| Array1::<f64>::zeros(n))
1782 .collect::<Vec<_>>();
1783
1784 struct CauseRow {
1785 cause: usize,
1786 row: usize,
1787 hazard: Vec<f64>,
1788 survival: Vec<f64>,
1789 cumulative: Vec<f64>,
1790 cumulative_refined: Vec<f64>,
1793 eta_exit: f64,
1794 }
1795
1796 let rows: Result<Vec<CauseRow>, SurvivalPredictError> = (0..cause_count * n)
1797 .into_par_iter()
1798 .map(|flat| {
1799 let cause = flat / n;
1800 let i = flat % n;
1801 let block = &fit.blocks[cause];
1802 let timewiggle = saved_timewiggle_by_cause[cause].as_ref();
1803 let evaluate_at = |t_query: f64| -> Result<(f64, f64, f64), SurvivalPredictError> {
1804 let t_entry = age_entry[i].min(t_query);
1805 let single_entry = Array1::from_elem(1, t_entry);
1806 let single_exit = Array1::from_elem(1, t_query);
1807 let mut row_time =
1808 build_survival_time_basis(&single_entry, &single_exit, time_cfg.clone(), None)?;
1809 if let Some(anchor_row) = cr_time_anchor_row.as_ref() {
1810 center_survival_time_designs_at_anchor(
1811 &mut row_time.x_entry_time,
1812 &mut row_time.x_exit_time,
1813 anchor_row,
1814 )?;
1815 }
1816 let (r_eta_exit, r_deriv_exit) = if weibull_baseline_in_beta {
1817 (0.0, 0.0)
1818 } else {
1819 let (_, eta_exit, deriv_exit) = build_survival_time_offsets_for_likelihood(
1820 &single_entry,
1821 &single_exit,
1822 &baseline_cfg,
1823 saved_likelihood_mode,
1824 None,
1825 )?;
1826 (eta_exit[0], deriv_exit[0])
1827 };
1828 evaluate_rp_row_with_beta(
1829 &block.beta,
1830 timewiggle,
1831 &row_time,
1832 &cov_rows[i],
1833 r_eta_exit,
1834 r_deriv_exit,
1835 primary_offset[i],
1836 )
1837 };
1838
1839 let mut out = CauseRow {
1840 cause,
1841 row: i,
1842 hazard: vec![0.0; t_cols],
1843 survival: vec![0.0; t_cols],
1844 cumulative: vec![0.0; t_cols],
1845 cumulative_refined: vec![0.0; refined_cols],
1846 eta_exit: 0.0,
1847 };
1848 if per_row_eval {
1849 let (eta_t, cum_t, haz_t) = evaluate_at(age_exit[i])?;
1850 out.eta_exit = eta_t;
1851 out.hazard[0] = haz_t;
1852 out.cumulative[0] = cum_t;
1853 out.survival[0] = (-cum_t).exp().clamp(0.0, 1.0);
1854 for s in 1..=CIF_REFINE_SUBINTERVALS {
1861 let frac = (s as f64) / (CIF_REFINE_SUBINTERVALS as f64);
1862 let t_query = age_exit[i] * frac;
1863 out.cumulative_refined[s - 1] = if t_query <= 0.0 {
1864 0.0
1865 } else if s == CIF_REFINE_SUBINTERVALS {
1866 cum_t
1870 } else {
1871 evaluate_at(t_query)?.1
1872 };
1873 }
1874 } else {
1875 for (j, &t_query) in eval_times.iter().enumerate() {
1876 if t_query <= 0.0 {
1883 out.hazard[j] = 0.0;
1884 out.cumulative[j] = 0.0;
1885 out.survival[j] = 1.0;
1886 } else {
1887 let (_eta_t, cum_t, haz_t) = evaluate_at(t_query)?;
1888 out.hazard[j] = haz_t;
1889 out.cumulative[j] = cum_t;
1890 out.survival[j] = (-cum_t).exp().clamp(0.0, 1.0);
1891 }
1892 }
1893 for (jr, &t_query) in refined_times.iter().enumerate() {
1899 out.cumulative_refined[jr] = if t_query <= 0.0 {
1900 0.0
1901 } else {
1902 evaluate_at(t_query)?.1
1903 };
1904 }
1905 let (eta_t, _, _) = evaluate_at(age_exit[i])?;
1906 out.eta_exit = eta_t;
1907 }
1908 Ok(out)
1909 })
1910 .collect();
1911
1912 for row in rows? {
1913 linear_predictor[row.cause][row.row] = row.eta_exit;
1914 for j in 0..t_cols {
1915 hazard[row.cause][[row.row, j]] = row.hazard[j];
1916 survival[row.cause][[row.row, j]] = row.survival[j];
1917 cumulative_hazard[row.cause][[row.row, j]] = row.cumulative[j];
1918 }
1919 for jr in 0..refined_cols {
1920 cumulative_hazard_refined[row.cause][[row.row, jr]] = row.cumulative_refined[jr];
1921 }
1922 }
1923
1924 let assembled = if per_row_eval {
1928 let assembly_times = Array1::from_shape_fn(CIF_REFINE_SUBINTERVALS, |s| {
1935 ((s + 1) as f64) / (CIF_REFINE_SUBINTERVALS as f64)
1936 });
1937 let refined_assembled = assemble_competing_risks_cif_from_endpoints(
1938 assembly_times.view(),
1939 &cumulative_hazard_refined,
1940 )
1941 .map_err(|err| err.to_string())?;
1942 let last = CIF_REFINE_SUBINTERVALS - 1;
1943 let mut cif_user = (0..cause_count)
1944 .map(|_| Array2::<f64>::zeros((n, 1)))
1945 .collect::<Vec<_>>();
1946 let mut overall_user = Array2::<f64>::zeros((n, 1));
1947 for cause in 0..cause_count {
1948 for row in 0..n {
1949 cif_user[cause][[row, 0]] = refined_assembled.cif[cause][[row, last]];
1950 }
1951 }
1952 for row in 0..n {
1953 overall_user[[row, 0]] = refined_assembled.overall_survival[[row, last]];
1954 }
1955 CompetingRisksCifResult {
1956 cif: cif_user,
1957 overall_survival: overall_user,
1958 }
1959 } else {
1960 let assembly_times = Array1::from_vec(refined_times.clone());
1961 let refined_assembled = assemble_competing_risks_cif_from_endpoints(
1962 assembly_times.view(),
1963 &cumulative_hazard_refined,
1964 )
1965 .map_err(|err| err.to_string())?;
1966 let mut cif_user = (0..cause_count)
1968 .map(|_| Array2::<f64>::zeros((n, t_cols)))
1969 .collect::<Vec<_>>();
1970 let mut overall_user = Array2::<f64>::zeros((n, t_cols));
1971 for (j_user, &jr) in user_time_to_refined_index.iter().enumerate() {
1972 for cause in 0..cause_count {
1973 for row in 0..n {
1974 cif_user[cause][[row, j_user]] = refined_assembled.cif[cause][[row, jr]];
1975 }
1976 }
1977 for row in 0..n {
1978 overall_user[[row, j_user]] = refined_assembled.overall_survival[[row, jr]];
1979 }
1980 }
1981 CompetingRisksCifResult {
1982 cif: cif_user,
1983 overall_survival: overall_user,
1984 }
1985 };
1986 if assembled.cif.len() != cause_count {
1987 return Err(format!(
1988 "competing-risks CIF assembly produced {} endpoint matrices, expected {cause_count}",
1989 assembled.cif.len()
1990 )
1991 .into());
1992 }
1993 let cif = assembled.cif;
1994 let overall_survival = assembled.overall_survival;
1995 let times_out = if per_row_eval {
1996 age_exit.to_vec()
1997 } else {
1998 eval_times
1999 };
2000 Ok(CompetingRisksPredictResult {
2001 times: times_out,
2002 endpoint_names,
2003 hazard,
2004 survival,
2005 cumulative_hazard,
2006 cif,
2007 overall_survival,
2008 linear_predictor,
2009 likelihood_mode: saved_likelihood_mode,
2010 })
2011}
2012
2013fn saved_cause_specific_timewiggles(
2014 model: &SavedModel,
2015 fit: &UnifiedFitResult,
2016 cause_count: usize,
2017) -> Result<Vec<Option<SavedBaselineTimeWiggleRuntime>>, SurvivalPredictError> {
2018 let has_metadata = model.baseline_timewiggle_knots.is_some()
2019 || model.baseline_timewiggle_degree.is_some()
2020 || model.baseline_timewiggle_penalty_orders.is_some()
2021 || model.baseline_timewiggle_double_penalty.is_some()
2022 || model.beta_baseline_timewiggle_by_cause.is_some();
2023 if !has_metadata {
2024 return Ok(vec![None; cause_count]);
2025 }
2026 let knots = model.baseline_timewiggle_knots.clone().ok_or_else(|| {
2027 "joint cause-specific survival missing baseline_timewiggle_knots".to_string()
2028 })?;
2029 let degree = model.baseline_timewiggle_degree.ok_or_else(|| {
2030 "joint cause-specific survival missing baseline_timewiggle_degree".to_string()
2031 })?;
2032 let penalty_orders = model
2033 .baseline_timewiggle_penalty_orders
2034 .clone()
2035 .ok_or_else(|| {
2036 "joint cause-specific survival missing baseline_timewiggle_penalty_orders".to_string()
2037 })?;
2038 let double_penalty = model.baseline_timewiggle_double_penalty.ok_or_else(|| {
2039 "joint cause-specific survival missing baseline_timewiggle_double_penalty".to_string()
2040 })?;
2041 let by_cause = model
2042 .beta_baseline_timewiggle_by_cause
2043 .as_ref()
2044 .ok_or_else(|| {
2045 "joint cause-specific survival missing beta_baseline_timewiggle_by_cause".to_string()
2046 })?;
2047 if by_cause.len() != cause_count {
2048 return Err(SurvivalPredictError::IncompatibleSchema {
2049 reason: format!(
2050 "joint cause-specific survival has {} timewiggle coefficient blocks, expected {cause_count}",
2051 by_cause.len()
2052 ),
2053 });
2054 }
2055 for (cause, (block, beta_w)) in fit.blocks.iter().zip(by_cause).enumerate() {
2056 if beta_w.len() > block.beta.len() {
2057 return Err(SurvivalPredictError::IncompatibleSchema {
2058 reason: format!(
2059 "joint cause-specific survival cause {} timewiggle beta has length {}, but endpoint beta has {} coefficients",
2060 cause + 1,
2061 beta_w.len(),
2062 block.beta.len()
2063 ),
2064 });
2065 }
2066 }
2067 Ok(by_cause
2068 .iter()
2069 .map(|beta| {
2070 Some(SavedBaselineTimeWiggleRuntime {
2071 knots: knots.clone(),
2072 degree,
2073 penalty_orders: penalty_orders.clone(),
2074 double_penalty,
2075 beta: beta.clone(),
2076 })
2077 })
2078 .collect())
2079}
2080
2081struct MarginalSlopePredictContext {
2089 predictor: BernoulliMarginalSlopePredictor,
2090 beta_time: Array1<f64>,
2092 beta_marginal: Array1<f64>,
2094 saved_timewiggle: Option<SavedBaselineTimeWiggleRuntime>,
2095 cov_design: DesignMatrix,
2097 logslope_design: DesignMatrix,
2099 cov_eta: Array1<f64>,
2102 z_raw: Array1<f64>,
2105 noise_offset: Array1<f64>,
2108}
2109
2110fn design_row_owned(
2111 design: &DesignMatrix,
2112 row: usize,
2113 context: &str,
2114) -> Result<Array1<f64>, SurvivalPredictError> {
2115 let chunk = design
2116 .try_row_chunk(row..row + 1)
2117 .map_err(|e| format!("{context}: {e}"))?;
2118 Ok(chunk.row(0).to_owned())
2119}
2120
2121fn build_marginal_slope_predict_context(
2122 model: &SavedModel,
2123 data: ArrayView2<'_, f64>,
2124 col_map: &HashMap<String, usize>,
2125 training_headers: Option<&Vec<String>>,
2126 cov_design: &DesignMatrix,
2127 primary_offset: &Array1<f64>,
2128 noise_offset: &Array1<f64>,
2129 time_build: &SurvivalTimeBuildOutput,
2130 eta_offset_entry: &Array1<f64>,
2131 eta_offset_exit: &Array1<f64>,
2132 derivative_offset_exit: &Array1<f64>,
2133) -> Result<MarginalSlopePredictContext, SurvivalPredictError> {
2134 let z_name = model
2135 .z_column
2136 .as_ref()
2137 .ok_or_else(|| "saved survival marginal-slope model missing z_column".to_string())?;
2138 let z_col = resolve_role_col(col_map, z_name, "z")?;
2139 let z_raw = data.column(z_col).to_owned();
2140
2141 let logslopespec = resolve_termspec_for_prediction(
2142 &model.resolved_termspec_logslope.as_ref().cloned(),
2143 training_headers,
2144 col_map,
2145 "resolved_termspec_logslope",
2146 )?;
2147 let logslope_clipped = model.axis_clip_to_training_ranges(data, col_map);
2148 let logslope_input = logslope_clipped.as_ref().map_or(data, |arr| arr.view());
2149 let logslope_design = build_term_collection_design(logslope_input, &logslopespec)
2150 .map_err(|e| format!("failed to build survival marginal-slope logslope design: {e}"))?;
2151
2152 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
2153 let (predictor, _pred_input, _predictor_fit) = build_saved_survival_marginal_slope_predictor(
2154 model,
2155 &fit_saved,
2156 z_name,
2157 &z_raw,
2158 cov_design,
2159 &logslope_design.design,
2160 time_build,
2161 eta_offset_entry,
2162 eta_offset_exit,
2163 derivative_offset_exit,
2164 primary_offset,
2165 noise_offset,
2166 )?;
2167
2168 let blocks = &fit_saved.blocks;
2169 if blocks.len() < 3 {
2170 return Err(SurvivalPredictError::IncompatibleSchema {
2171 reason: format!(
2172 "saved survival marginal-slope model requires at least 3 blocks [time, marginal, slope], got {}",
2173 blocks.len()
2174 ),
2175 });
2176 }
2177 let beta_time = blocks[0].beta.clone();
2178 let beta_marginal = blocks[1].beta.clone();
2179 let saved_runtime = model.saved_prediction_runtime()?;
2180 let saved_timewiggle = saved_runtime.baseline_time_wiggle.clone();
2181
2182 let cov_eta = cov_design.dot(&beta_marginal);
2185
2186 Ok(MarginalSlopePredictContext {
2187 predictor,
2188 beta_time,
2189 beta_marginal,
2190 saved_timewiggle,
2191 cov_design: cov_design.clone(),
2192 logslope_design: logslope_design.design.clone(),
2193 cov_eta,
2194 z_raw,
2195 noise_offset: noise_offset.clone(),
2196 })
2197}
2198
2199fn evaluate_marginal_slope_row(
2210 row_index: usize,
2211 ctx: &MarginalSlopePredictContext,
2212 row_time: &SurvivalTimeBuildOutput,
2213 r_eta_exit: &Array1<f64>,
2214 r_deriv_exit: &Array1<f64>,
2215 primary_offset_row: f64,
2216) -> Result<(f64, f64, f64), SurvivalPredictError> {
2217 let beta_time = &ctx.beta_time;
2218 let p_time_base = row_time.x_exit_time.ncols();
2219 let p_timewiggle = ctx
2220 .saved_timewiggle
2221 .as_ref()
2222 .map_or(0, |runtime| runtime.beta.len());
2223 if beta_time.len() != p_time_base + p_timewiggle {
2224 return Err(SurvivalPredictError::IncompatibleSchema {
2225 reason: format!(
2226 "saved survival marginal-slope time coefficient mismatch: beta has {} entries but expected base={} plus timewiggle={}",
2227 beta_time.len(),
2228 p_time_base,
2229 p_timewiggle
2230 ),
2231 });
2232 }
2233 let beta_time_base = beta_time.slice(s![..p_time_base]).to_owned();
2234
2235 let q_exit_base = row_time.x_exit_time.dot(&beta_time_base)[0]
2240 + ctx.cov_eta[row_index]
2241 + r_eta_exit[0]
2242 + primary_offset_row;
2243 let qd_exit_base = row_time.x_derivative_time.dot(&beta_time_base)[0] + r_deriv_exit[0];
2244
2245 let (qd_with_wiggle, exit_wiggle_design) = if let Some(runtime) = ctx.saved_timewiggle.as_ref()
2249 {
2250 let knots = Array1::from_vec(runtime.knots.clone());
2251 let beta_w = beta_time.slice(s![p_time_base..]).to_owned();
2252 let eta_exit_row = Array1::from_elem(1, q_exit_base);
2253 let deriv_row = Array1::from_elem(1, qd_exit_base);
2254 let exit_design = match buildwiggle_block_input_from_knots(
2255 eta_exit_row.view(),
2256 &knots,
2257 runtime.degree,
2258 2,
2259 false,
2260 )?
2261 .design
2262 {
2263 DesignMatrix::Dense(m) => m.to_dense_arc().as_ref().clone(),
2264 _ => {
2265 return Err(SurvivalPredictError::IncompatibleSchema {
2266 reason: "saved baseline-timewiggle exit design must be dense".to_string(),
2267 });
2268 }
2269 };
2270 let derivative_design = build_survival_timewiggle_derivative_design(
2271 &eta_exit_row,
2272 &deriv_row,
2273 &knots,
2274 runtime.degree,
2275 )?;
2276 (
2277 qd_exit_base + derivative_design.dot(&beta_w)[0],
2278 Some(exit_design),
2279 )
2280 } else {
2281 (qd_exit_base, None)
2282 };
2283
2284 let cov_dim = ctx.beta_marginal.len();
2293 let q_design_ncols = p_time_base + p_timewiggle + cov_dim;
2294 let mut q_design_full = Array2::<f64>::zeros((1, q_design_ncols));
2295 q_design_full
2296 .slice_mut(s![.., ..p_time_base])
2297 .assign(&row_time.x_exit_time.to_dense());
2298 if let Some(exit_w) = exit_wiggle_design.as_ref() {
2299 q_design_full
2300 .slice_mut(s![.., p_time_base..p_time_base + p_timewiggle])
2301 .assign(exit_w);
2302 }
2303 if cov_dim > 0 {
2304 let cov_row = design_row_owned(
2305 &ctx.cov_design,
2306 row_index,
2307 "survival marginal covariate row",
2308 )?;
2309 q_design_full
2310 .slice_mut(s![.., p_time_base + p_timewiggle..])
2311 .row_mut(0)
2312 .assign(&cov_row);
2313 }
2314
2315 let logslope_row = design_row_owned(
2322 &ctx.logslope_design,
2323 row_index,
2324 "survival marginal logslope row",
2325 )?;
2326 let mut logslope_design_2d = Array2::<f64>::zeros((1, logslope_row.len()));
2327 logslope_design_2d.row_mut(0).assign(&logslope_row);
2328
2329 let pred_input = PredictInput {
2330 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(q_design_full)),
2331 offset: Array1::from_elem(1, r_eta_exit[0] + primary_offset_row),
2332 design_noise: Some(DesignMatrix::Dense(
2333 gam_linalg::matrix::DenseDesignMatrix::from(logslope_design_2d),
2334 )),
2335 offset_noise: Some(Array1::from_elem(1, ctx.noise_offset[row_index])),
2336 auxiliary_scalar: Some(Array1::from_elem(1, ctx.z_raw[row_index])),
2337 auxiliary_matrix: None,
2338 };
2339
2340 let (eta_arr, deta_dq_arr) = ctx
2344 .predictor
2345 .predict_eta_and_q_chain(&pred_input)
2346 .map_err(|e| format!("saved survival marginal-slope predictor eta failed: {e}"))?;
2347 let eta = eta_arr[0];
2348 let eta_derivative = marginal_slope_index_derivative_at_horizon(deta_dq_arr[0], qd_with_wiggle);
2368 let (cum, haz) = probit_survival_hazard_components(eta, eta_derivative)?;
2369 Ok((eta, cum, haz))
2370}
2371
2372#[inline]
2385fn marginal_slope_index_derivative_at_horizon(deta_dq: f64, qd_with_wiggle: f64) -> f64 {
2386 let eta_derivative = deta_dq * qd_with_wiggle;
2387 if eta_derivative.is_finite() {
2388 eta_derivative.max(0.0)
2389 } else {
2390 eta_derivative
2391 }
2392}
2393
2394#[inline]
2395fn probit_survival_hazard_components(
2396 eta: f64,
2397 eta_derivative: f64,
2398) -> Result<(f64, f64), SurvivalPredictError> {
2399 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative >= 0.0) {
2400 return Err(SurvivalPredictError::NumericalFailure {
2401 reason: format!(
2402 "saved survival marginal-slope prediction produced invalid survival index derivative: eta={eta}, eta_t={eta_derivative}"
2403 ),
2404 });
2405 }
2406
2407 let (log_survival, mills_ratio) = signed_probit_logcdf_and_mills_ratio(-eta);
2412 let cumulative_hazard = -log_survival;
2413 let hazard = if eta_derivative == 0.0 {
2414 0.0
2415 } else {
2416 mills_ratio * eta_derivative
2417 };
2418 if !(cumulative_hazard >= 0.0 && hazard >= 0.0) {
2425 return Err(SurvivalPredictError::NumericalFailure {
2426 reason: format!(
2427 "saved survival marginal-slope prediction produced invalid survival components: eta={eta}, eta_t={eta_derivative}, log_survival={log_survival}, hazard={hazard}"
2428 ),
2429 });
2430 }
2431 Ok((cumulative_hazard, hazard))
2432}
2433
2434fn evaluate_rp_row(
2435 model: &SavedModel,
2436 row_time: &SurvivalTimeBuildOutput,
2437 cov_row: &Array1<f64>,
2438 eta_time_offset_row: f64,
2439 derivative_time_offset_row: f64,
2440 primary_offset_row: f64,
2441) -> Result<(f64, f64, f64), SurvivalPredictError> {
2442 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
2443 let saved_runtime = model.saved_prediction_runtime()?;
2444 evaluate_rp_row_with_beta(
2445 &fit_saved.beta,
2446 saved_runtime.baseline_time_wiggle.as_ref(),
2447 row_time,
2448 cov_row,
2449 eta_time_offset_row,
2450 derivative_time_offset_row,
2451 primary_offset_row,
2452 )
2453}
2454
2455fn evaluate_rp_row_with_beta(
2456 beta: &Array1<f64>,
2457 saved_timewiggle: Option<&SavedBaselineTimeWiggleRuntime>,
2458 row_time: &SurvivalTimeBuildOutput,
2459 cov_row: &Array1<f64>,
2460 eta_time_offset_row: f64,
2461 derivative_time_offset_row: f64,
2462 primary_offset_row: f64,
2463) -> Result<(f64, f64, f64), SurvivalPredictError> {
2464 let p_time = row_time.x_exit_time.ncols();
2465 let p_timewiggle = saved_timewiggle.map_or(0, |runtime| runtime.beta.len());
2466 let p_cov = cov_row.len();
2467 let p = p_time + p_timewiggle + p_cov;
2468 if beta.len() != p {
2469 return Err(SurvivalPredictError::IncompatibleSchema {
2470 reason: format!(
2471 "survival RP coefficient mismatch: beta has {} entries but design has {} columns",
2472 beta.len(),
2473 p
2474 ),
2475 });
2476 }
2477 let mut x_exit = Array2::<f64>::zeros((1, p));
2478 if p_time > 0 {
2479 x_exit
2480 .slice_mut(s![.., ..p_time])
2481 .assign(&row_time.x_exit_time.to_dense());
2482 }
2483 let mut eta_derivative = derivative_time_offset_row;
2484 if p_time > 0 {
2485 eta_derivative += row_time
2486 .x_derivative_time
2487 .dot(&beta.slice(s![..p_time]).to_owned())[0];
2488 }
2489 if let Some(runtime) = saved_timewiggle {
2490 let knots = Array1::from_vec(runtime.knots.clone());
2491 let beta_w = beta.slice(s![p_time..p_time + p_timewiggle]).to_owned();
2492 let eta_exit_row = Array1::from_elem(1, eta_time_offset_row);
2493 let derivative_exit_row = Array1::from_elem(1, derivative_time_offset_row);
2494 let exit_design = match buildwiggle_block_input_from_knots(
2495 eta_exit_row.view(),
2496 &knots,
2497 runtime.degree,
2498 2,
2499 false,
2500 )?
2501 .design
2502 {
2503 DesignMatrix::Dense(m) => m.to_dense_arc().as_ref().clone(),
2504 _ => {
2505 return Err(SurvivalPredictError::IncompatibleSchema {
2506 reason: "saved baseline-timewiggle exit design must be dense".to_string(),
2507 });
2508 }
2509 };
2510 if exit_design.ncols() != p_timewiggle {
2511 return Err(SurvivalPredictError::IncompatibleSchema {
2512 reason: format!(
2513 "survival RP timewiggle design mismatch: rebuilt {} columns but runtime expects {}",
2514 exit_design.ncols(),
2515 p_timewiggle
2516 ),
2517 });
2518 }
2519 x_exit
2520 .slice_mut(s![.., p_time..p_time + p_timewiggle])
2521 .assign(&exit_design);
2522 let derivative_design = build_survival_timewiggle_derivative_design(
2523 &eta_exit_row,
2524 &derivative_exit_row,
2525 &knots,
2526 runtime.degree,
2527 )?;
2528 eta_derivative += derivative_design.dot(&beta_w)[0];
2529 }
2530 if p_cov > 0 {
2531 x_exit
2532 .slice_mut(s![
2533 ..,
2534 (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov)
2535 ])
2536 .row_mut(0)
2537 .assign(cov_row);
2538 }
2539 let offset_view = Array1::from_elem(1, eta_time_offset_row + primary_offset_row);
2540 let likelihood = LikelihoodSpec::new(
2541 ResponseFamily::RoystonParmar,
2542 InverseLink::Standard(StandardLink::Identity),
2543 );
2544 let eta =
2545 predict_royston_parmar_eta(x_exit.view(), beta.view(), offset_view.view(), &likelihood)?[0];
2546 let (cum, haz) = royston_parmar_survival_hazard_components(eta, eta_derivative)?;
2547 Ok((eta, cum, haz))
2548}
2549
2550fn predict_royston_parmar_eta<X>(
2551 x: X,
2552 beta: ndarray::ArrayView1<'_, f64>,
2553 offset: ndarray::ArrayView1<'_, f64>,
2554 likelihood: &LikelihoodSpec,
2555) -> Result<Array1<f64>, SurvivalPredictError>
2556where
2557 X: Into<DesignMatrix>,
2558{
2559 if !matches!(likelihood.response, ResponseFamily::RoystonParmar)
2560 || !matches!(
2561 likelihood.link,
2562 InverseLink::Standard(StandardLink::Identity)
2563 )
2564 {
2565 return Err(SurvivalPredictError::UnsupportedConfiguration {
2566 reason: "survival prediction requires RoystonParmar with identity link".to_string(),
2567 });
2568 }
2569 let x = x.into();
2570 if x.nrows() != offset.len() || x.ncols() != beta.len() {
2571 return Err(SurvivalPredictError::IncompatibleSchema {
2572 reason: format!(
2573 "survival prediction design dimensions disagree: design is {}x{}, beta has length {}, offset has length {}",
2574 x.nrows(),
2575 x.ncols(),
2576 beta.len(),
2577 offset.len()
2578 ),
2579 });
2580 }
2581 let mut eta = x.matrixvectormultiply(&beta.to_owned());
2582 eta += &offset;
2583 Ok(eta)
2584}
2585
2586#[inline]
2587fn royston_parmar_survival_hazard_components(
2588 eta: f64,
2589 eta_derivative: f64,
2590) -> Result<(f64, f64), SurvivalPredictError> {
2591 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative >= 0.0) {
2607 return Err(SurvivalPredictError::NumericalFailure {
2608 reason: format!(
2609 "saved Royston-Parmar survival prediction produced invalid log-cumulative-hazard derivative: eta={eta}, eta_t={eta_derivative}"
2610 ),
2611 });
2612 }
2613 let cumulative_hazard = eta.exp();
2614 let hazard = if eta_derivative == 0.0 {
2620 0.0
2621 } else {
2622 cumulative_hazard * eta_derivative
2623 };
2624 if !(cumulative_hazard >= 0.0 && hazard >= 0.0) {
2633 return Err(SurvivalPredictError::NumericalFailure {
2634 reason: format!(
2635 "saved Royston-Parmar survival prediction produced invalid survival components: eta={eta}, eta_t={eta_derivative}, cumulative_hazard={cumulative_hazard}, hazard={hazard}"
2636 ),
2637 });
2638 }
2639 Ok((cumulative_hazard, hazard))
2640}
2641
2642fn predict_survival_location_scale_batch(
2652 model: &SavedModel,
2653 age_entry: &Array1<f64>,
2654 age_exit: &Array1<f64>,
2655 cov_design: &gam_terms::smooth::TermCollectionDesign,
2656 primary_offset: &Array1<f64>,
2657 noise_offset: &Array1<f64>,
2658 training_headers: Option<&Vec<String>>,
2659 col_map: &HashMap<String, usize>,
2660 data: ArrayView2<'_, f64>,
2661 time_grid: Option<&[f64]>,
2662 with_uncertainty: bool,
2663) -> Result<SurvivalPredictResult, String> {
2664 use crate::scale_design::build_scale_deviation_operator;
2665 use crate::survival::construction::evaluate_survival_time_basis_row;
2666 use crate::survival::location_scale::{
2667 SurvivalLocationScalePredictInput, predict_survival_location_scale,
2668 predict_survival_location_scale_from_linear_components,
2669 predict_survival_location_scalewith_uncertainty,
2670 };
2671 use gam_linalg::matrix::DesignMatrix;
2672
2673 let n = age_entry.len();
2674 let per_row_eval = time_grid.is_none();
2675 let eval_times: Vec<f64> = match time_grid {
2676 Some(grid) => {
2677 if grid.is_empty() {
2678 return Err("survival time_grid must contain at least one time".to_string());
2679 }
2680 for (idx, &t) in grid.iter().enumerate() {
2681 if !t.is_finite() || t < 0.0 {
2682 return Err(format!(
2683 "survival time_grid requires finite non-negative times (index {idx})",
2684 ));
2685 }
2686 }
2687 grid.to_vec()
2688 }
2689 None => Vec::new(),
2690 };
2691 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
2692 let eval_width = if per_row_eval { 1 } else { t_cols + 1 };
2693 let saved_likelihood_mode = SurvivalLikelihoodMode::LocationScale;
2694 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
2695 let saved_fit = saved_survival_location_scale_fit_result(model)?;
2696 let reduced_parametric_aft =
2715 !model.has_baseline_time_wiggle() && saved_fit.beta_time().iter().all(|&b| b == 0.0);
2716 let time_cfg = load_survival_time_basis_config_from_model(model)?;
2717 let mut time_build = build_survival_time_basis(age_entry, age_exit, time_cfg.clone(), None)?;
2718 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
2719 &time_build.basisname,
2720 time_build.degree,
2721 time_build.knots.as_ref(),
2722 time_build.keep_cols.as_ref(),
2723 time_build.smooth_lambda,
2724 )?;
2725 let time_anchor = model
2726 .survival_time_anchor
2727 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
2728 let time_anchor_row = evaluate_survival_time_basis_row(time_anchor, &resolved_time_cfg)?;
2729 center_survival_time_designs_at_anchor(
2730 &mut time_build.x_entry_time,
2731 &mut time_build.x_exit_time,
2732 &time_anchor_row,
2733 )?;
2734 if !model.has_baseline_time_wiggle() && !reduced_parametric_aft {
2738 require_structural_survival_time_basis(&time_build.basisname, "saved survival sampling")?;
2739 }
2740 let saved_inverse_link = resolve_survival_inverse_link_from_saved(model)?;
2741 let (eval_entry, eval_exit) = if per_row_eval {
2742 (age_entry.clone(), age_exit.clone())
2743 } else {
2744 let total = n * eval_width;
2745 let mut entry = Array1::<f64>::zeros(total);
2746 let mut exit = Array1::<f64>::zeros(total);
2747 {
2748 use rayon::iter::{IntoParallelIterator, ParallelIterator};
2749 let pairs: Vec<(f64, f64)> = (0..total)
2750 .into_par_iter()
2751 .map(|k| {
2752 let i = k / eval_width;
2753 let col = k % eval_width;
2754 let t = if col < t_cols {
2755 eval_times[col]
2756 } else {
2757 age_exit[i]
2758 };
2759 (age_entry[i].min(t), t)
2760 })
2761 .collect();
2762 for (k, (t0, t1)) in pairs.into_iter().enumerate() {
2763 entry[k] = t0;
2764 exit[k] = t1;
2765 }
2766 }
2767 (entry, exit)
2768 };
2769 let mut time_build =
2770 build_survival_time_basis(&eval_entry, &eval_exit, time_cfg.clone(), None)?;
2771 center_survival_time_designs_at_anchor(
2772 &mut time_build.x_entry_time,
2773 &mut time_build.x_exit_time,
2774 &time_anchor_row,
2775 )?;
2776 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
2777 build_survival_time_offsets_for_likelihood(
2778 &eval_entry,
2779 &eval_exit,
2780 &baseline_cfg,
2781 saved_likelihood_mode,
2782 Some(&saved_inverse_link),
2783 )?;
2784 add_survival_time_derivative_guard_offset(
2785 &eval_entry,
2786 &eval_exit,
2787 time_anchor,
2788 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
2789 &mut eta_offset_entry,
2790 &mut eta_offset_exit,
2791 &mut derivative_offset_exit,
2792 )?;
2793 if reduced_parametric_aft {
2794 eta_offset_exit = Array1::<f64>::zeros(eval_exit.len());
2806 }
2807
2808 let saved_timewiggle_runtime = model.saved_baseline_time_wiggle()?;
2809
2810 let threshold_design = cov_design;
2816 let log_sigmaspec = resolve_termspec_for_prediction(
2817 &model.resolved_termspec_noise,
2818 training_headers,
2819 col_map,
2820 "resolved_termspec_noise",
2821 )?;
2822 let sigma_clipped = model.axis_clip_to_training_ranges(data, col_map);
2823 let sigma_input = sigma_clipped.as_ref().map_or(data, |arr| arr.view());
2824 let raw_sigma_design =
2825 gam_terms::smooth::build_term_collection_design(sigma_input, &log_sigmaspec)
2826 .map_err(|err| format!("failed to build survival log-sigma design: {err}"))?;
2827 let survival_noise_transform = scale_transform_from_payload(
2828 &model.survival_noise_projection,
2829 &model.survival_noise_center,
2830 &model.survival_noise_scale,
2831 model.survival_noise_non_intercept_start,
2832 model.survival_noise_projection_ridge_alpha,
2833 )?;
2834
2835 let x_time_exit_dense = time_build
2836 .x_exit_time
2837 .try_to_dense_by_chunks("survival location-scale prediction time-exit design")?;
2838 let total_rows = eval_exit.len();
2839 let x_time_exit = if let Some(runtime) = saved_timewiggle_runtime.as_ref() {
2840 let mut full =
2841 Array2::<f64>::zeros((total_rows, x_time_exit_dense.ncols() + runtime.beta.len()));
2842 full.slice_mut(s![.., 0..x_time_exit_dense.ncols()])
2843 .assign(&x_time_exit_dense);
2844 full
2845 } else {
2846 x_time_exit_dense
2847 };
2848
2849 let repeat_rows =
2850 |matrix: &DesignMatrix, label: &str| -> Result<DesignMatrix, SurvivalPredictError> {
2851 if per_row_eval {
2852 return Ok(matrix.clone());
2853 }
2854 let dense = matrix.try_to_dense_by_chunks(label)?;
2855 let mut repeated = Array2::<f64>::zeros((total_rows, dense.ncols()));
2856 use rayon::iter::{IntoParallelIterator, ParallelIterator};
2857 let rows: Vec<Vec<f64>> = (0..total_rows)
2858 .into_par_iter()
2859 .map(|k| dense.row(k / eval_width).to_vec())
2860 .collect();
2861 for (k, row) in rows.into_iter().enumerate() {
2862 for (j, value) in row.into_iter().enumerate() {
2863 repeated[[k, j]] = value;
2864 }
2865 }
2866 Ok(DesignMatrix::from(repeated))
2867 };
2868 let threshold_matrix = repeat_rows(
2869 &threshold_design.design,
2870 "survival location-scale prediction threshold design",
2871 )?;
2872 let raw_sigma_matrix = repeat_rows(
2873 &raw_sigma_design.design,
2874 "survival location-scale prediction log-sigma design",
2875 )?;
2876
2877 let time_design = DesignMatrix::from(x_time_exit.clone());
2882 let survival_primary_design =
2883 DesignMatrix::hstack(vec![time_design, threshold_matrix.clone()])?;
2884 let prepared_sigma_design = if let Some(transform) = survival_noise_transform.as_ref() {
2885 build_scale_deviation_operator(survival_primary_design, raw_sigma_matrix, transform)?
2886 } else {
2887 raw_sigma_matrix
2888 };
2889 let link_wiggle_knots = model
2890 .linkwiggle_knots
2891 .as_ref()
2892 .map(|k| Array1::from_vec(k.clone()));
2893 let link_wiggle_degree = model.linkwiggle_degree;
2894 let time_wiggle_knots = saved_timewiggle_runtime
2895 .as_ref()
2896 .map(|w| Array1::from_vec(w.knots.clone()));
2897 let time_wiggle_degree = saved_timewiggle_runtime.as_ref().map(|w| w.degree);
2898 let time_wiggle_ncols = saved_timewiggle_runtime
2899 .as_ref()
2900 .map_or(0, |w| w.beta.len());
2901
2902 let expand_vector = |values: &Array1<f64>| -> Array1<f64> {
2903 if per_row_eval {
2904 values.clone()
2905 } else {
2906 Array1::from_shape_fn(total_rows, |k| values[k / eval_width])
2907 }
2908 };
2909 let eta_threshold_offset = {
2918 let mut offset = expand_vector(primary_offset);
2919 if reduced_parametric_aft {
2920 for (slot, &t) in offset.iter_mut().zip(eval_exit.iter()) {
2921 *slot -= t
2922 .max(crate::survival::construction::SURVIVAL_TIME_FLOOR)
2923 .ln();
2924 }
2925 }
2926 offset
2927 };
2928 let pred_input = SurvivalLocationScalePredictInput {
2933 x_time_exit,
2934 eta_time_offset_exit: eta_offset_exit.clone(),
2935 time_wiggle_knots: time_wiggle_knots.clone(),
2936 time_wiggle_degree,
2937 time_wiggle_ncols,
2938 x_threshold: threshold_matrix,
2939 eta_threshold_offset,
2940 x_log_sigma: prepared_sigma_design,
2941 eta_log_sigma_offset: expand_vector(noise_offset),
2942 x_link_wiggle: None,
2943 link_wiggle_knots: link_wiggle_knots.clone(),
2944 link_wiggle_degree,
2945 inverse_link: saved_inverse_link.clone(),
2946 };
2947
2948 let (eta_full, survival_prob_full, response_se_full, eta_se_full): (
2951 Array1<f64>,
2952 Array1<f64>,
2953 Option<Array1<f64>>,
2954 Option<Array1<f64>>,
2955 ) = if with_uncertainty {
2956 let cov = saved_fit.beta_covariance().ok_or_else(|| {
2957 "survival location-scale uncertainty: saved fit is missing the \
2958 posterior covariance; refit with the current CLI / library to \
2959 populate beta_covariance"
2960 .to_string()
2961 })?;
2962 let unc = predict_survival_location_scalewith_uncertainty(
2963 &pred_input,
2964 &saved_fit,
2965 cov,
2966 false,
2967 true,
2968 )
2969 .map_err(|err| format!("survival location-scale uncertainty predict failed: {err}"))?;
2970 let response_se = unc.response_standard_error.ok_or_else(|| {
2971 "survival location-scale uncertainty: response_standard_error \
2972 missing despite include_response_sd=true"
2973 .to_string()
2974 })?;
2975 (
2976 unc.eta,
2977 unc.survival_prob,
2978 Some(response_se),
2979 Some(unc.eta_standard_error),
2980 )
2981 } else if per_row_eval {
2982 let pred = predict_survival_location_scale(&pred_input, &saved_fit)
2983 .map_err(|err| format!("survival location-scale predict failed: {err}"))?;
2984 (pred.eta, pred.survival_prob, None, None)
2985 } else {
2986 let beta_threshold = saved_fit.beta_threshold();
2987 let beta_log_sigma = saved_fit.beta_log_sigma();
2988 let eta_t_subject =
2989 cov_design.design.matrixvectormultiply(&beta_threshold) + primary_offset;
2990 let eta_ls_subject = prepared_sigma_design_view(&pred_input)
2994 .matrixvectormultiply(&beta_log_sigma)
2995 + &pred_input.eta_log_sigma_offset;
2996 let mut eta_t = expand_vector(&eta_t_subject);
3002 if reduced_parametric_aft {
3003 for (slot, &t) in eta_t.iter_mut().zip(eval_exit.iter()) {
3004 *slot -= t
3005 .max(crate::survival::construction::SURVIVAL_TIME_FLOOR)
3006 .ln();
3007 }
3008 }
3009 let pred = predict_survival_location_scale_from_linear_components(
3010 &pred_input.x_time_exit,
3011 &eta_offset_exit,
3012 time_wiggle_knots.as_ref(),
3013 time_wiggle_degree,
3014 time_wiggle_ncols,
3015 &eta_t,
3016 &eta_ls_subject,
3017 link_wiggle_knots.as_ref(),
3018 link_wiggle_degree,
3019 &saved_inverse_link,
3020 &saved_fit,
3021 )
3022 .map_err(|err| format!("survival location-scale predict failed: {err}"))?;
3023 (pred.eta, pred.survival_prob, None, None)
3024 };
3025
3026 let eta_derivative_full = if reduced_parametric_aft {
3027 use crate::sigma_link::exp_sigma_inverse_from_eta_scalar;
3034 let beta_log_sigma = saved_fit.beta_log_sigma();
3035 let eta_ls = prepared_sigma_design_view(&pred_input).matrixvectormultiply(&beta_log_sigma)
3036 + &pred_input.eta_log_sigma_offset;
3037 let mut deriv = Array1::<f64>::zeros(eval_exit.len());
3038 for (k, slot) in deriv.iter_mut().enumerate() {
3039 let inv_sigma = exp_sigma_inverse_from_eta_scalar(eta_ls[k]);
3040 let t = eval_exit[k].max(crate::survival::construction::SURVIVAL_TIME_FLOOR);
3041 *slot = inv_sigma / t;
3042 }
3043 deriv
3044 } else {
3045 let x_time_derivative = time_build
3046 .x_derivative_time
3047 .try_to_dense_by_chunks("survival location-scale prediction time-derivative design")?;
3048 location_scale_eta_derivative_components(
3049 &x_time_derivative,
3050 &derivative_offset_exit,
3051 &pred_input.x_time_exit,
3052 &pred_input.eta_time_offset_exit,
3053 time_wiggle_knots.as_ref(),
3054 time_wiggle_degree,
3055 time_wiggle_ncols,
3056 &saved_fit,
3057 )?
3058 };
3059 let hazard_full = location_scale_hazard_from_eta_derivative(
3060 &eta_full,
3061 &eta_derivative_full,
3062 &saved_inverse_link,
3063 )?;
3064
3065 let mut survival = Array2::<f64>::zeros((n, t_cols));
3066 let mut cumulative_hazard = Array2::<f64>::zeros((n, t_cols));
3067 let mut hazard = Array2::<f64>::zeros((n, t_cols));
3068 ndarray::Zip::indexed(&mut survival)
3069 .and(&mut cumulative_hazard)
3070 .and(&mut hazard)
3071 .par_for_each(|(i, j), s, ch, h| {
3072 let query_time = if per_row_eval {
3081 age_exit[i]
3082 } else {
3083 eval_times[j]
3084 };
3085 if query_time <= 0.0 {
3086 *s = 1.0;
3087 *ch = 0.0;
3088 *h = 0.0;
3089 return;
3090 }
3091 let k = if per_row_eval { i } else { i * eval_width + j };
3092 let surv = survival_prob_full[k].clamp(SURVIVAL_PROB_MIN_FOR_LOG, 1.0);
3093 *s = surv;
3094 *ch = -surv.ln();
3095 *h = hazard_full[k];
3096 });
3097
3098 let linear_predictor = if per_row_eval {
3099 eta_full.clone()
3100 } else {
3101 Array1::from_shape_fn(n, |i| eta_full[i * eval_width + t_cols])
3102 };
3103 let times = if per_row_eval {
3104 age_exit.to_vec()
3105 } else {
3106 eval_times.clone()
3109 };
3110
3111 let survival_se = response_se_full.as_ref().map(|response_se| {
3112 let mut out = Array2::<f64>::zeros((n, t_cols));
3113 ndarray::Zip::indexed(&mut out).par_for_each(|(i, j), slot| {
3114 let query_time = if per_row_eval {
3117 age_exit[i]
3118 } else {
3119 eval_times[j]
3120 };
3121 if query_time <= 0.0 {
3122 *slot = 0.0;
3123 return;
3124 }
3125 let k = if per_row_eval { i } else { i * eval_width + j };
3126 *slot = response_se[k].max(0.0);
3127 });
3128 out
3129 });
3130 let eta_se_per_row = eta_se_full.as_ref().map(|eta_se| {
3131 if per_row_eval {
3132 eta_se.clone()
3133 } else {
3134 Array1::from_shape_fn(n, |i| eta_se[i * eval_width + t_cols])
3135 }
3136 });
3137
3138 Ok(SurvivalPredictResult {
3139 times,
3140 hazard,
3141 survival,
3142 cumulative_hazard,
3143 linear_predictor,
3144 likelihood_mode: saved_likelihood_mode,
3145 survival_se,
3146 eta_se: eta_se_per_row,
3147 })
3148}
3149
3150fn prepared_sigma_design_view(
3154 input: &crate::survival::location_scale::SurvivalLocationScalePredictInput,
3155) -> &gam_linalg::matrix::DesignMatrix {
3156 &input.x_log_sigma
3157}
3158
3159pub(crate) struct LocationScaleEtaComponents {
3160 pub h: Array1<f64>,
3161 pub time_jac: Array2<f64>,
3162 pub eta_t: Array1<f64>,
3163 pub eta_ls: Array1<f64>,
3164 pub inv_sigma: Array1<f64>,
3165}
3166
3167pub(crate) struct LocationScaleTimeWarpComponents {
3168 pub(crate) h: Array1<f64>,
3169 pub(crate) time_jac: Array2<f64>,
3170 pub(crate) time_wiggle_dq: Option<Array1<f64>>,
3171}
3172
3173pub(crate) fn location_scale_time_warp_components(
3174 x_time_exit: &Array2<f64>,
3175 eta_time_offset_exit: &Array1<f64>,
3176 time_wiggle_knots: Option<&Array1<f64>>,
3177 time_wiggle_degree: Option<usize>,
3178 time_wiggle_ncols: usize,
3179 fit: &UnifiedFitResult,
3180) -> Result<LocationScaleTimeWarpComponents, String> {
3181 let n = x_time_exit.nrows();
3182 if eta_time_offset_exit.len() != n {
3183 return Err("survival location-scale time-warp row mismatch across inputs".to_string());
3184 }
3185 let beta_time = fit.beta_time();
3186 if x_time_exit.ncols() != beta_time.len() {
3187 return Err(format!(
3188 "survival location-scale time-warp design mismatch: x_exit={} beta_time={}",
3189 x_time_exit.ncols(),
3190 beta_time.len()
3191 ));
3192 }
3193
3194 let p_time_total = beta_time.len();
3195 let p_wiggle = time_wiggle_ncols.min(p_time_total);
3196 let p_base = p_time_total - p_wiggle;
3197 let beta_base = beta_time.slice(s![..p_base]).to_owned();
3198 let h_base = if p_base > 0 {
3199 x_time_exit.slice(s![.., ..p_base]).dot(&beta_base) + eta_time_offset_exit
3200 } else {
3201 eta_time_offset_exit.clone()
3202 };
3203 let mut h = h_base.clone();
3204 let mut time_jac = x_time_exit.clone();
3205 let mut time_wiggle_dq = None;
3206 if p_wiggle > 0 {
3207 if x_time_exit
3208 .slice(s![.., p_base..p_time_total])
3209 .iter()
3210 .any(|&value| value != 0.0)
3211 {
3212 return Err(
3213 "survival location-scale timewiggle prediction requires zero placeholder tail columns"
3214 .to_string(),
3215 );
3216 }
3217 let knots = time_wiggle_knots.ok_or_else(|| {
3218 "survival location-scale time-warp: timewiggle coefficients are missing knot metadata"
3219 .to_string()
3220 })?;
3221 let degree = time_wiggle_degree.ok_or_else(|| {
3222 "survival location-scale time-warp: timewiggle coefficients are missing degree metadata"
3223 .to_string()
3224 })?;
3225 let beta_w = beta_time.slice(s![p_base..p_time_total]).to_owned();
3226 let time_basis = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
3227 h_base.view(),
3228 knots,
3229 degree,
3230 0,
3231 )?;
3232 let time_basis_d1 = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
3233 h_base.view(),
3234 knots,
3235 degree,
3236 1,
3237 )?;
3238 if time_basis.ncols() != p_wiggle || time_basis_d1.ncols() != p_wiggle {
3239 return Err(format!(
3240 "survival location-scale time-warp timewiggle mismatch: value basis has {} columns, derivative basis has {}, beta has {}",
3241 time_basis.ncols(),
3242 time_basis_d1.ncols(),
3243 p_wiggle
3244 ));
3245 }
3246 let dq = time_basis_d1.dot(&beta_w) + 1.0;
3247 h = &h_base + &time_basis.dot(&beta_w);
3248 time_jac = Array2::<f64>::zeros((n, p_time_total));
3249 if p_base > 0 {
3250 let scaled_base = crate::survival::location_scale::scale_dense_rows(
3251 &x_time_exit.slice(s![.., ..p_base]).to_owned(),
3252 &dq,
3253 )?;
3254 time_jac.slice_mut(s![.., ..p_base]).assign(&scaled_base);
3255 }
3256 time_jac
3257 .slice_mut(s![.., p_base..p_time_total])
3258 .assign(&time_basis);
3259 time_wiggle_dq = Some(dq);
3260 }
3261
3262 Ok(LocationScaleTimeWarpComponents {
3263 h,
3264 time_jac,
3265 time_wiggle_dq,
3266 })
3267}
3268
3269pub(crate) fn location_scale_eta_components(
3270 x_time_exit: &Array2<f64>,
3271 eta_time_offset_exit: &Array1<f64>,
3272 time_wiggle_knots: Option<&Array1<f64>>,
3273 time_wiggle_degree: Option<usize>,
3274 time_wiggle_ncols: usize,
3275 x_threshold: &gam_linalg::matrix::DesignMatrix,
3276 eta_threshold_offset: &Array1<f64>,
3277 x_log_sigma: &gam_linalg::matrix::DesignMatrix,
3278 eta_log_sigma_offset: &Array1<f64>,
3279 fit: &UnifiedFitResult,
3280) -> Result<LocationScaleEtaComponents, String> {
3281 let n = x_time_exit.nrows();
3282 if x_threshold.nrows() != n
3283 || eta_threshold_offset.len() != n
3284 || x_log_sigma.nrows() != n
3285 || eta_log_sigma_offset.len() != n
3286 {
3287 return Err("survival location-scale eta component row mismatch across inputs".to_string());
3288 }
3289 let time_components = location_scale_time_warp_components(
3290 x_time_exit,
3291 eta_time_offset_exit,
3292 time_wiggle_knots,
3293 time_wiggle_degree,
3294 time_wiggle_ncols,
3295 fit,
3296 )?;
3297 let beta_threshold = fit.beta_threshold();
3298 let beta_log_sigma = fit.beta_log_sigma();
3299 let eta_t = x_threshold.matrixvectormultiply(&beta_threshold) + eta_threshold_offset;
3300 let eta_ls = x_log_sigma.matrixvectormultiply(&beta_log_sigma) + eta_log_sigma_offset;
3301 let inv_sigma = eta_ls.mapv(crate::sigma_link::exp_sigma_inverse_from_eta_scalar);
3302 Ok(LocationScaleEtaComponents {
3303 h: time_components.h,
3304 time_jac: time_components.time_jac,
3305 eta_t,
3306 eta_ls,
3307 inv_sigma,
3308 })
3309}
3310
3311fn location_scale_eta_derivative_components(
3312 x_time_derivative: &Array2<f64>,
3313 derivative_offset_exit: &Array1<f64>,
3314 x_time_exit: &Array2<f64>,
3315 eta_time_offset_exit: &Array1<f64>,
3316 time_wiggle_knots: Option<&Array1<f64>>,
3317 time_wiggle_degree: Option<usize>,
3318 time_wiggle_ncols: usize,
3319 fit: &UnifiedFitResult,
3320) -> Result<Array1<f64>, String> {
3321 let n = x_time_exit.nrows();
3322 if x_time_derivative.nrows() != n
3323 || derivative_offset_exit.len() != n
3324 || eta_time_offset_exit.len() != n
3325 {
3326 return Err(
3327 "survival location-scale hazard derivative row mismatch across inputs".to_string(),
3328 );
3329 }
3330 let beta_time = fit.beta_time();
3331 let p_time_total = beta_time.len();
3332 let p_wiggle = time_wiggle_ncols.min(p_time_total);
3333 let p_base = p_time_total - p_wiggle;
3334 if x_time_exit.ncols() != p_time_total || x_time_derivative.ncols() != p_base {
3335 return Err(format!(
3336 "survival location-scale hazard derivative design mismatch: x_exit={} beta_time={} x_derivative={} base={}",
3337 x_time_exit.ncols(),
3338 p_time_total,
3339 x_time_derivative.ncols(),
3340 p_base
3341 ));
3342 }
3343
3344 let time_components = location_scale_time_warp_components(
3345 x_time_exit,
3346 eta_time_offset_exit,
3347 time_wiggle_knots,
3348 time_wiggle_degree,
3349 time_wiggle_ncols,
3350 fit,
3351 )?;
3352 let beta_base = beta_time.slice(s![..p_base]).to_owned();
3353 let mut eta_derivative = if p_base > 0 {
3354 x_time_derivative.dot(&beta_base) + derivative_offset_exit
3355 } else {
3356 derivative_offset_exit.clone()
3357 };
3358 if let Some(dq) = time_components.time_wiggle_dq.as_ref() {
3359 eta_derivative *= dq;
3360 }
3361 if eta_derivative
3362 .iter()
3363 .any(|value| !(value.is_finite() && *value > 0.0))
3364 {
3365 return Err(
3366 "survival location-scale hazard derivative must be finite and positive".to_string(),
3367 );
3368 }
3369 Ok(eta_derivative)
3370}
3371
3372fn location_scale_hazard_from_eta_derivative(
3373 eta: &Array1<f64>,
3374 eta_derivative: &Array1<f64>,
3375 inverse_link: &InverseLink,
3376) -> Result<Array1<f64>, String> {
3377 if eta.len() != eta_derivative.len() {
3378 return Err(format!(
3379 "survival location-scale hazard row mismatch: eta={} eta_derivative={}",
3380 eta.len(),
3381 eta_derivative.len()
3382 ));
3383 }
3384 let values = eta
3385 .iter()
3386 .zip(eta_derivative.iter())
3387 .map(|(&q, &q_t)| location_scale_hazard_component(q, q_t, inverse_link))
3388 .collect::<Result<Vec<_>, _>>()?;
3389 Ok(Array1::from_vec(values))
3390}
3391
3392fn location_scale_hazard_component(
3393 eta: f64,
3394 eta_derivative: f64,
3395 inverse_link: &InverseLink,
3396) -> Result<f64, String> {
3397 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative > 0.0) {
3398 return Err(format!(
3399 "survival location-scale hazard requires finite eta and positive eta_t, got eta={eta}, eta_t={eta_derivative}"
3400 ));
3401 }
3402 match inverse_link {
3403 InverseLink::Standard(StandardLink::Probit) => {
3404 let (_, hazard) = probit_survival_hazard_components(eta, eta_derivative)?;
3405 Ok(hazard)
3406 }
3407 InverseLink::Standard(StandardLink::CLogLog) => {
3408 let (_, hazard) = royston_parmar_survival_hazard_components(eta, eta_derivative)?;
3409 Ok(hazard)
3410 }
3411 InverseLink::Standard(StandardLink::Logit) => {
3412 let failure = if eta >= 0.0 {
3413 1.0 / (1.0 + (-eta).exp())
3414 } else {
3415 let exp_eta = eta.exp();
3416 exp_eta / (1.0 + exp_eta)
3417 };
3418 Ok(failure * eta_derivative)
3419 }
3420 InverseLink::Standard(StandardLink::Identity) => {
3421 let survival = 1.0 - eta;
3422 if !(survival.is_finite() && survival > 0.0) {
3423 return Err(format!(
3424 "survival location-scale identity link produced invalid survival={survival} at eta={eta}"
3425 ));
3426 }
3427 Ok(eta_derivative / survival)
3428 }
3429 _ => {
3430 let jet = inverse_link_jet_for_inverse_link(inverse_link, eta)
3431 .map_err(|err| format!("survival location-scale inverse-link jet failed: {err}"))?;
3432 let survival = 1.0 - jet.mu;
3433 let hazard = jet.d1 * eta_derivative / survival;
3434 if !(survival.is_finite() && survival > 0.0 && hazard.is_finite() && hazard >= 0.0) {
3435 return Err(format!(
3436 "survival location-scale inverse link produced invalid hazard components: eta={eta}, eta_t={eta_derivative}, failure={}, d_failure={}, survival={survival}, hazard={hazard}",
3437 jet.mu, jet.d1
3438 ));
3439 }
3440 Ok(hazard)
3441 }
3442 }
3443}
3444
3445pub fn require_saved_survival_likelihood_mode(
3451 model: &SavedModel,
3452) -> Result<SurvivalLikelihoodMode, SurvivalPredictError> {
3453 if matches!(&model.family_state, FittedFamily::LatentSurvival { .. }) {
3454 return match model.survival_likelihood.as_deref() {
3455 Some("latent") => Ok(SurvivalLikelihoodMode::Latent),
3456 Some(other) => Err(SurvivalPredictError::MissingFitMetadata { reason: format!(
3457 "saved latent survival model has contradictory survival_likelihood metadata: expected 'latent', got '{other}'"
3458 ) }),
3459 None => Err(SurvivalPredictError::MissingFitMetadata {
3460 reason:
3461 "saved latent survival model is missing survival_likelihood=latent metadata; refit"
3462 .to_string(),
3463 }),
3464 };
3465 }
3466 if matches!(&model.family_state, FittedFamily::LatentBinary { .. }) {
3467 return match model.survival_likelihood.as_deref() {
3468 Some("latent-binary") => Ok(SurvivalLikelihoodMode::LatentBinary),
3469 Some(other) => Err(SurvivalPredictError::MissingFitMetadata { reason: format!(
3470 "saved latent binary model has contradictory survival_likelihood metadata: expected 'latent-binary', got '{other}'"
3471 ) }),
3472 None => Err(SurvivalPredictError::MissingFitMetadata {
3473 reason:
3474 "saved latent binary model is missing survival_likelihood=latent-binary metadata; refit"
3475 .to_string(),
3476 }),
3477 };
3478 }
3479 let raw = model.survival_likelihood.as_deref().ok_or_else(|| {
3480 "saved survival model is missing survival_likelihood metadata; refit".to_string()
3481 })?;
3482 parse_survival_likelihood_mode(raw).map_err(SurvivalPredictError::from)
3483}
3484
3485pub fn saved_survival_runtime_baseline_config(
3487 model: &SavedModel,
3488) -> Result<SurvivalBaselineConfig, SurvivalPredictError> {
3489 survival_baseline_config_from_model(model).map_err(SurvivalPredictError::from)
3490}
3491
3492pub fn resolve_termspec_for_prediction(
3495 modelspec: &Option<TermCollectionSpec>,
3496 training_headers: Option<&Vec<String>>,
3497 col_map: &HashMap<String, usize>,
3498 spec_label: &str,
3499) -> Result<TermCollectionSpec, SurvivalPredictError> {
3500 let saved = modelspec.as_ref().ok_or_else(|| {
3501 format!(
3502 "model is missing {spec_label}; refit to guarantee train/predict design consistency"
3503 )
3504 })?;
3505 saved.validate_frozen(spec_label)?;
3506 let headers = training_headers.ok_or_else(|| {
3507 "model is missing training_headers; refit to guarantee stable feature mapping at prediction time"
3508 .to_string()
3509 })?;
3510 let remapped = remap_term_collectionspec_columns(saved, headers, col_map)?;
3511 remapped.validate_frozen(spec_label)?;
3512 Ok(remapped)
3513}
3514
3515fn remap_term_collectionspec_columns(
3516 spec: &TermCollectionSpec,
3517 training_headers: &[String],
3518 prediction_column_map: &HashMap<String, usize>,
3519) -> Result<TermCollectionSpec, SurvivalPredictError> {
3520 spec.remap_feature_columns(|index| -> Result<usize, SurvivalPredictError> {
3524 let name = training_headers
3525 .get(index)
3526 .ok_or_else(|| format!("saved training column index {index} is out of bounds"))?;
3527 resolve_role_col(prediction_column_map, name, "prediction")
3528 .map_err(SurvivalPredictError::from)
3529 })
3530}
3531
3532pub fn fit_result_from_saved_model_for_prediction(
3534 model: &SavedModel,
3535) -> Result<UnifiedFitResult, String> {
3536 model
3537 .fit_result
3538 .clone()
3539 .ok_or_else(|| "model is missing canonical fit_result payload; refit".to_string())
3540}
3541
3542pub fn saved_survival_location_scale_fit_result(
3548 model: &SavedModel,
3549) -> Result<UnifiedFitResult, SurvivalPredictError> {
3550 model.saved_prediction_runtime()?;
3551 let mut fit = model.fit_result.clone().ok_or_else(|| {
3552 "saved location-scale survival model missing canonical fit_result; refit".to_string()
3553 })?;
3554 let inverse_link = resolve_survival_inverse_link_from_saved(model)?;
3555 apply_inverse_link_state_to_fit_result(&mut fit, &inverse_link);
3556 Ok(fit)
3557}
3558
3559pub fn apply_inverse_link_state_to_fit_result(
3560 fit_result: &mut UnifiedFitResult,
3561 inverse_link: &InverseLink,
3562) {
3563 fit_result.fitted_link = match inverse_link {
3564 InverseLink::LatentCLogLog(state) => FittedLinkState::LatentCLogLog { state: *state },
3565 InverseLink::Sas(state) => FittedLinkState::Sas {
3566 state: *state,
3567 covariance: None,
3568 },
3569 InverseLink::BetaLogistic(state) => FittedLinkState::BetaLogistic {
3570 state: *state,
3571 covariance: None,
3572 },
3573 InverseLink::Mixture(state) => FittedLinkState::Mixture {
3574 state: state.clone(),
3575 covariance: None,
3576 },
3577 InverseLink::Standard(_) => FittedLinkState::Standard(None),
3578 };
3579}
3580
3581pub fn resolve_survival_inverse_link_from_saved(
3584 model: &SavedModel,
3585) -> Result<InverseLink, SurvivalPredictError> {
3586 if let Some(link) = model.link.as_ref() {
3587 return Ok(link.clone());
3588 }
3589 Err(SurvivalPredictError::MissingFitMetadata {
3590 reason: "saved survival model is missing link metadata; refit".to_string(),
3591 })
3592}
3593
3594pub fn concat_array1_refs(parts: &[&Array1<f64>]) -> Array1<f64> {
3596 let total: usize = parts.iter().map(|part| part.len()).sum();
3597 let mut out = Array1::<f64>::zeros(total);
3598 let mut offset = 0usize;
3599 for part in parts {
3600 let width = part.len();
3601 out.slice_mut(s![offset..offset + width]).assign(part);
3602 offset += width;
3603 }
3604 out
3605}
3606
3607pub fn saved_baseline_timewiggle_components(
3611 eta_entry: &Array1<f64>,
3612 eta_exit: &Array1<f64>,
3613 derivative_exit: &Array1<f64>,
3614 model: &SavedModel,
3615) -> Result<Option<(Array2<f64>, Array2<f64>, Array2<f64>)>, SurvivalPredictError> {
3616 match model.saved_baseline_time_wiggle()? {
3617 None => Ok(None),
3618 Some(runtime) => {
3619 runtime.validate_global_monotonicity()?;
3620 let SavedBaselineTimeWiggleRuntime {
3621 knots,
3622 degree,
3623 beta,
3624 ..
3625 } = runtime;
3626 let knots = Array1::from_vec(knots);
3627 let entry = match buildwiggle_block_input_from_knots(
3628 eta_entry.view(),
3629 &knots,
3630 degree,
3631 2,
3632 false,
3633 )?
3634 .design
3635 {
3636 DesignMatrix::Dense(m) => m.to_dense_arc().as_ref().clone(),
3637 _ => {
3638 return Err(SurvivalPredictError::IncompatibleSchema {
3639 reason: "saved baseline-timewiggle entry design must be dense".to_string(),
3640 });
3641 }
3642 };
3643 let exit = match buildwiggle_block_input_from_knots(
3644 eta_exit.view(),
3645 &knots,
3646 degree,
3647 2,
3648 false,
3649 )?
3650 .design
3651 {
3652 DesignMatrix::Dense(m) => m.to_dense_arc().as_ref().clone(),
3653 _ => {
3654 return Err(SurvivalPredictError::IncompatibleSchema {
3655 reason: "saved baseline-timewiggle exit design must be dense".to_string(),
3656 });
3657 }
3658 };
3659 let betaw = beta;
3660 if entry.ncols() != betaw.len() || exit.ncols() != betaw.len() {
3661 return Err(SurvivalPredictError::IncompatibleSchema {
3662 reason: format!(
3663 "saved baseline-timewiggle dimension mismatch: coefficients have {} entries but basis has entry={} exit={}",
3664 betaw.len(),
3665 entry.ncols(),
3666 exit.ncols()
3667 ),
3668 });
3669 }
3670 let derivative = build_survival_timewiggle_derivative_design(
3671 eta_exit,
3672 derivative_exit,
3673 &knots,
3674 degree,
3675 )
3676 .map_err(|e| {
3677 e.replace(
3678 "build baseline-timewiggle",
3679 "evaluate saved baseline-timewiggle",
3680 )
3681 })?;
3682 if derivative.ncols() != betaw.len() {
3683 return Err(SurvivalPredictError::IncompatibleSchema {
3684 reason: format!(
3685 "saved baseline-timewiggle derivative dimension mismatch: coefficients have {} entries but derivative basis has {} columns",
3686 betaw.len(),
3687 derivative.ncols()
3688 ),
3689 });
3690 }
3691 Ok(Some((entry, exit, derivative)))
3692 }
3693 }
3694}
3695
3696pub fn build_saved_survival_marginal_slope_predictor(
3705 model: &SavedModel,
3706 fit_saved: &UnifiedFitResult,
3707 z_name: &str,
3708 z: &Array1<f64>,
3709 cov_design: &DesignMatrix,
3710 logslope_design: &DesignMatrix,
3711 time_build: &SurvivalTimeBuildOutput,
3712 eta_offset_entry: &Array1<f64>,
3713 eta_offset_exit: &Array1<f64>,
3714 derivative_offset_exit: &Array1<f64>,
3715 primary_offset: &Array1<f64>,
3716 noise_offset: &Array1<f64>,
3717) -> Result<
3718 (
3719 BernoulliMarginalSlopePredictor,
3720 PredictInput,
3721 UnifiedFitResult,
3722 ),
3723 SurvivalPredictError,
3724> {
3725 let saved_runtime = model.saved_prediction_runtime()?;
3726 if saved_runtime.link_wiggle.is_some() {
3727 return Err(SurvivalPredictError::MissingFitMetadata {
3728 reason:
3729 "saved survival marginal-slope model contains legacy linkwiggle metadata; refit with the anchored link-deviation runtime"
3730 .to_string(),
3731 });
3732 }
3733
3734 let saved_score_runtime = saved_runtime.score_warp;
3735 let saved_link_runtime = saved_runtime.link_deviation;
3736 let influence_absorber_width = saved_runtime.influence_absorber_width;
3741 let blocks = &fit_saved.blocks;
3742 let expected_blocks = 3
3743 + usize::from(saved_score_runtime.is_some())
3744 + usize::from(saved_link_runtime.is_some())
3745 + usize::from(influence_absorber_width.is_some());
3746 if blocks.len() != expected_blocks {
3747 return Err(SurvivalPredictError::IncompatibleSchema {
3748 reason: format!(
3749 "saved survival marginal-slope model requires {} blocks [time, marginal, slope{}{}{}], got {}",
3750 expected_blocks,
3751 if saved_score_runtime.is_some() {
3752 ", score-warp"
3753 } else {
3754 ""
3755 },
3756 if saved_link_runtime.is_some() {
3757 ", link-deviation"
3758 } else {
3759 ""
3760 },
3761 if influence_absorber_width.is_some() {
3762 ", influence-absorber(dropped)"
3763 } else {
3764 ""
3765 },
3766 blocks.len(),
3767 ),
3768 });
3769 }
3770
3771 let beta_time = &blocks[0].beta;
3772 let beta_marginal = &blocks[1].beta;
3773 let beta_logslope = &blocks[2].beta;
3774 if let Some(runtime) = saved_score_runtime.as_ref() {
3775 let beta = &blocks[3].beta;
3776 if beta.len() != runtime.basis_dim {
3777 return Err(SurvivalPredictError::IncompatibleSchema {
3778 reason: format!(
3779 "saved survival marginal-slope score-warp coefficient mismatch: beta has {} entries but runtime expects {}",
3780 beta.len(),
3781 runtime.basis_dim
3782 ),
3783 });
3784 }
3785 }
3786 if let Some(runtime) = saved_link_runtime.as_ref() {
3787 let idx = 3 + usize::from(saved_score_runtime.is_some());
3788 let beta = &blocks[idx].beta;
3789 if beta.len() != runtime.basis_dim {
3790 return Err(SurvivalPredictError::IncompatibleSchema {
3791 reason: format!(
3792 "saved survival marginal-slope link-deviation coefficient mismatch: beta has {} entries but runtime expects {}",
3793 beta.len(),
3794 runtime.basis_dim
3795 ),
3796 });
3797 }
3798 }
3799
3800 if beta_marginal.len() != cov_design.ncols() {
3801 return Err(SurvivalPredictError::IncompatibleSchema {
3802 reason: format!(
3803 "saved survival marginal-slope marginal coefficient mismatch: beta has {} entries but baseline design has {} columns",
3804 beta_marginal.len(),
3805 cov_design.ncols()
3806 ),
3807 });
3808 }
3809 if beta_logslope.len() != logslope_design.ncols() {
3810 return Err(SurvivalPredictError::IncompatibleSchema {
3811 reason: format!(
3812 "saved survival marginal-slope slope coefficient mismatch: beta has {} entries but slope design has {} columns",
3813 beta_logslope.len(),
3814 logslope_design.ncols()
3815 ),
3816 });
3817 }
3818
3819 let p_time_base = time_build.x_exit_time.ncols();
3820 let saved_timewiggle = saved_runtime.baseline_time_wiggle;
3821 let p_timewiggle = saved_timewiggle
3822 .as_ref()
3823 .map_or(0, |runtime| runtime.beta.len());
3824 if beta_time.len() != p_time_base + p_timewiggle {
3825 return Err(SurvivalPredictError::IncompatibleSchema {
3826 reason: format!(
3827 "saved survival marginal-slope time coefficient mismatch: beta has {} entries but expected base={} plus timewiggle={}",
3828 beta_time.len(),
3829 p_time_base,
3830 p_timewiggle
3831 ),
3832 });
3833 }
3834
3835 let beta_time_base = beta_time.slice(s![..p_time_base]).to_owned();
3836 let cov_eta_marginal = cov_design.dot(beta_marginal);
3840 let q_entry_base = time_build.x_entry_time.dot(&beta_time_base)
3841 + &cov_eta_marginal
3842 + eta_offset_entry
3843 + primary_offset;
3844 let q_exit_base = time_build.x_exit_time.dot(&beta_time_base)
3845 + &cov_eta_marginal
3846 + eta_offset_exit
3847 + primary_offset;
3848 let qd_exit_base = time_build.x_derivative_time.dot(&beta_time_base) + derivative_offset_exit;
3849
3850 let mut q_design_parts = vec![time_build.x_exit_time.clone()];
3851 if saved_timewiggle.is_some() {
3852 let (_, exit_w, _) = saved_baseline_timewiggle_components(
3853 &q_entry_base,
3854 &q_exit_base,
3855 &qd_exit_base,
3856 model,
3857 )?
3858 .ok_or_else(|| {
3859 "saved survival marginal-slope model is missing baseline-timewiggle runtime metadata"
3860 .to_string()
3861 })?;
3862 if exit_w.ncols() != p_timewiggle {
3863 return Err(SurvivalPredictError::IncompatibleSchema {
3864 reason: format!(
3865 "saved survival marginal-slope timewiggle design mismatch: rebuilt {} columns but runtime expects {}",
3866 exit_w.ncols(),
3867 p_timewiggle
3868 ),
3869 });
3870 }
3871 q_design_parts.push(DesignMatrix::from(exit_w));
3872 }
3873 q_design_parts.push(cov_design.clone());
3874 let q_design = DesignMatrix::hstack(q_design_parts)?;
3875
3876 let combined_q_beta = concat_array1_refs(&[beta_time, beta_marginal]);
3877 let combined_q_lambdas = concat_array1_refs(&[&blocks[0].lambdas, &blocks[1].lambdas]);
3878 let mut predictor_blocks = Vec::with_capacity(
3879 2 + usize::from(saved_score_runtime.is_some()) + usize::from(saved_link_runtime.is_some()),
3880 );
3881 predictor_blocks.push(FittedBlock {
3882 beta: combined_q_beta.clone(),
3883 role: BlockRole::Mean,
3884 edf: blocks[0].edf + blocks[1].edf,
3885 lambdas: combined_q_lambdas,
3886 });
3887 predictor_blocks.push(FittedBlock {
3888 beta: beta_logslope.clone(),
3889 role: BlockRole::Scale,
3890 edf: blocks[2].edf,
3891 lambdas: blocks[2].lambdas.clone(),
3892 });
3893 if saved_score_runtime.is_some() {
3894 let mut block = blocks[3].clone();
3895 block.role = BlockRole::Mean;
3896 predictor_blocks.push(block);
3897 }
3898 if saved_link_runtime.is_some() {
3899 let idx = 3 + usize::from(saved_score_runtime.is_some());
3900 let mut block = blocks[idx].clone();
3901 block.role = BlockRole::LinkWiggle;
3902 predictor_blocks.push(block);
3903 }
3904
3905 let mut predictor_fit = fit_saved.clone();
3906 predictor_fit.blocks = predictor_blocks;
3907 predictor_fit.beta = concat_array1_refs(
3908 &predictor_fit
3909 .blocks
3910 .iter()
3911 .map(|block| &block.beta)
3912 .collect::<Vec<_>>(),
3913 );
3914 predictor_fit.block_states.clear();
3915
3916 let predictor = BernoulliMarginalSlopePredictor::from_unified(
3917 &predictor_fit,
3918 z_name.to_string(),
3919 model.latent_z_normalization.ok_or_else(|| {
3920 "saved survival marginal-slope model missing latent_z_normalization".to_string()
3921 })?,
3922 model.latent_measure.clone().ok_or_else(|| {
3923 "saved survival marginal-slope model missing latent_measure".to_string()
3924 })?,
3925 0.0,
3926 model.logslope_baseline.ok_or_else(|| {
3927 "saved survival marginal-slope model missing logslope_baseline".to_string()
3928 })?,
3929 model
3930 .resolved_inverse_link()?
3931 .unwrap_or(InverseLink::Standard(StandardLink::Probit)),
3932 model
3933 .family_state
3934 .frailty()
3935 .cloned()
3936 .unwrap_or(FrailtySpec::None),
3937 saved_score_runtime,
3938 saved_link_runtime,
3939 model.latent_z_rank_int_calibration.clone(),
3940 model.latent_z_conditional_calibration.clone(),
3943 )?;
3944
3945 let pred_input = PredictInput {
3946 design: q_design,
3947 offset: eta_offset_exit + primary_offset,
3948 design_noise: Some(logslope_design.clone()),
3949 offset_noise: Some(noise_offset.clone()),
3950 auxiliary_scalar: Some(z.clone()),
3951 auxiliary_matrix: None,
3952 };
3953
3954 Ok((predictor, pred_input, predictor_fit))
3955}
3956
3957#[cfg(test)]
3958mod tests {
3959 use super::*;
3960 use crate::probability::{normal_cdf, normal_pdf};
3961
3962 #[test]
3963 fn probit_survival_hazard_uses_density_over_survival() {
3964 let eta = 2.0;
3965 let eta_t = 0.3;
3966
3967 let (cum, hazard) =
3968 probit_survival_hazard_components(eta, eta_t).expect("valid components");
3969
3970 let survival = normal_cdf(-eta);
3971 let expected_cum = -survival.ln();
3972 let expected_hazard = normal_pdf(eta) * eta_t / survival;
3973 assert!((cum - expected_cum).abs() <= 1e-14);
3974 assert!((hazard - expected_hazard).abs() <= 1e-14);
3975 }
3976
3977 #[test]
3978 fn probit_survival_hazard_stays_finite_in_right_tail() {
3979 let eta = 40.0;
3980 let eta_t = 9.694_340_360_912_401e-5;
3981
3982 let event_density =
3983 (-0.5_f64 * eta * eta).exp() / (2.0 * std::f64::consts::PI).sqrt() * eta_t;
3984 assert_eq!(event_density, 0.0);
3985
3986 let (cum, hazard) =
3987 probit_survival_hazard_components(eta, eta_t).expect("valid tail components");
3988 assert!(cum > 800.0, "right-tail cumulative hazard was {cum}");
3989 assert!(
3990 (3.87e-3..3.89e-3).contains(&hazard),
3991 "right-tail hazard was {hazard}"
3992 );
3993 }
3994
3995 #[test]
3996 fn probit_survival_hazard_accepts_zero_time_derivative_as_flat_hazard() {
3997 let (cum, hazard) =
3998 probit_survival_hazard_components(1.0, 0.0).expect("zero derivative is flat hazard");
3999 assert!(cum > 0.0);
4000 assert_eq!(hazard, 0.0);
4001 }
4002
4003 #[test]
4004 fn marginal_slope_index_derivative_clamps_extrapolation_negative_to_flat_hazard() {
4005 let deta_dq = (1.0_f64 + 0.4 * 0.4).sqrt(); let qd_with_wiggle = -1.35e-3;
4013 let eta_t = marginal_slope_index_derivative_at_horizon(deta_dq, qd_with_wiggle);
4014 assert_eq!(
4015 eta_t, 0.0,
4016 "negative extrapolation derivative must clamp to 0"
4017 );
4018 let (cum, hazard) = probit_survival_hazard_components(-0.563, eta_t)
4020 .expect("clamped flat-hazard prediction must validate");
4021 assert!(
4022 cum >= 0.0,
4023 "cumulative hazard must be well-posed, got {cum}"
4024 );
4025 assert_eq!(
4026 hazard, 0.0,
4027 "clamped derivative gives zero instantaneous hazard"
4028 );
4029 }
4030
4031 #[test]
4032 fn marginal_slope_index_derivative_preserves_positive_and_nonfinite() {
4033 let positive = marginal_slope_index_derivative_at_horizon(1.25, 0.8);
4037 assert!(
4038 (positive - 1.0).abs() <= 1e-15,
4039 "positive derivative scaled by chain factor"
4040 );
4041 let nonfinite = marginal_slope_index_derivative_at_horizon(1.25, f64::NAN);
4042 assert!(
4043 nonfinite.is_nan(),
4044 "non-finite derivative passes through unclamped"
4045 );
4046 assert!(
4047 probit_survival_hazard_components(0.5, nonfinite).is_err(),
4048 "non-finite derivative must still be rejected by the validator"
4049 );
4050 }
4051
4052 #[test]
4053 fn probit_survival_hazard_rejects_infinite_time_derivative() {
4054 let err = probit_survival_hazard_components(1.0, f64::INFINITY)
4055 .expect_err("infinite derivative should be invalid");
4056 assert!(
4057 err.to_string()
4058 .contains("invalid survival index derivative")
4059 );
4060 }
4061
4062 #[test]
4063 fn probit_survival_hazard_rejects_nan_inputs() {
4064 let err_eta =
4070 probit_survival_hazard_components(f64::NAN, 0.5).expect_err("NaN eta must be rejected");
4071 assert!(
4072 err_eta
4073 .to_string()
4074 .contains("invalid survival index derivative")
4075 );
4076 let err_dt = probit_survival_hazard_components(1.0, f64::NAN)
4077 .expect_err("NaN eta_derivative must be rejected");
4078 assert!(
4079 err_dt
4080 .to_string()
4081 .contains("invalid survival index derivative")
4082 );
4083 }
4084
4085 #[test]
4086 fn probit_survival_hazard_rejects_negative_time_derivative() {
4087 let err = probit_survival_hazard_components(1.0, -0.5)
4091 .expect_err("negative derivative should be invalid");
4092 assert!(
4093 err.to_string()
4094 .contains("invalid survival index derivative")
4095 );
4096 }
4097
4098 #[test]
4099 fn royston_parmar_hazard_is_cumulative_hazard_derivative() {
4100 let eta = 2.0_f64.ln();
4101 let eta_t = 0.25;
4102
4103 let (cum, hazard) =
4104 royston_parmar_survival_hazard_components(eta, eta_t).expect("valid components");
4105
4106 assert!((cum - 2.0).abs() <= 1e-14);
4107 assert!((hazard - 0.5).abs() <= 1e-14);
4108 assert_ne!(hazard, cum);
4109 }
4110
4111 #[test]
4112 fn royston_parmar_hazard_rejects_negative_log_hazard_derivative() {
4113 let err = royston_parmar_survival_hazard_components(0.0, -0.5)
4117 .expect_err("negative derivative should be invalid");
4118 assert!(
4119 err.to_string()
4120 .contains("invalid log-cumulative-hazard derivative")
4121 );
4122 }
4123
4124 #[test]
4125 fn royston_parmar_hazard_accepts_zero_derivative_as_flat_boundary() {
4126 let eta = 1.9909019457445971_f64; let (cum, hazard) = royston_parmar_survival_hazard_components(eta, 0.0)
4133 .expect("zero derivative is a valid flat boundary, not an error");
4134 assert!((cum - eta.exp()).abs() <= 1e-12, "cum = Λ(t) = exp(η)");
4135 assert_eq!(
4136 hazard, 0.0,
4137 "flat cumulative hazard ⇒ zero instantaneous hazard"
4138 );
4139 let survival = (-cum).exp().clamp(0.0, 1.0);
4141 assert!(survival.is_finite() && (0.0..=1.0).contains(&survival));
4142 }
4143
4144 #[test]
4145 fn royston_parmar_hazard_zero_derivative_in_saturated_tail_is_zero_not_nan() {
4146 let eta = 1000.0_f64;
4152 assert!(
4153 eta.exp().is_infinite(),
4154 "test premise: exp(1000) overflows to +∞"
4155 );
4156 assert!(
4157 (f64::INFINITY * 0.0).is_nan(),
4158 "test premise: the naive product is NaN"
4159 );
4160 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, 0.0)
4161 .expect("saturated + flat boundary must be valid");
4162 assert!(cum.is_infinite() && cum > 0.0, "cum saturates to +∞");
4163 assert_eq!(hazard, 0.0, "hazard at a flat boundary is 0, never NaN");
4164 }
4165
4166 #[test]
4167 fn royston_parmar_hazard_propagates_saturation_as_infinity() {
4168 let eta = 1000.0_f64;
4173 let eta_t = 0.5_f64;
4174 assert!(eta.exp().is_infinite(), "test premise: exp(1000) overflows");
4175
4176 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, eta_t)
4177 .expect("saturated RP fit must yield a result, not an error");
4178 assert!(cum.is_infinite() && cum > 0.0, "expected +∞ cum, got {cum}");
4179 assert!(
4180 hazard.is_infinite() && hazard > 0.0,
4181 "expected +∞ hazard, got {hazard}"
4182 );
4183
4184 let survival = (-cum).exp().clamp(0.0, 1.0);
4186 assert_eq!(survival, 0.0, "saturated cum_hazard must give survival 0");
4187 }
4188
4189 #[test]
4190 fn royston_parmar_hazard_rejects_nan_eta() {
4191 let err = royston_parmar_survival_hazard_components(f64::NAN, 0.5)
4192 .expect_err("NaN eta should be invalid");
4193 assert!(
4194 err.to_string()
4195 .contains("invalid log-cumulative-hazard derivative")
4196 );
4197 }
4198
4199 #[test]
4200 fn royston_parmar_hazard_left_tail_collapses_to_zero() {
4201 let eta = -1000.0_f64;
4204 let eta_t = 2.0_f64;
4205 assert_eq!(eta.exp(), 0.0, "test premise: exp(-1000) underflows to 0");
4206
4207 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, eta_t)
4208 .expect("RP left tail must remain valid");
4209 assert_eq!(
4210 cum, 0.0,
4211 "left-tail cum_hazard should underflow to 0, got {cum}"
4212 );
4213 assert_eq!(
4214 hazard, 0.0,
4215 "left-tail hazard should underflow to 0, got {hazard}"
4216 );
4217
4218 let survival = (-cum).exp().clamp(0.0, 1.0);
4220 assert_eq!(survival, 1.0);
4221 }
4222
4223 #[test]
4224 fn probit_survival_hazard_left_tail_collapses_to_zero() {
4225 let eta = -40.0_f64;
4229 let eta_t = 1.5_f64;
4230
4231 let (cum, hazard) =
4232 probit_survival_hazard_components(eta, eta_t).expect("left tail must remain valid");
4233 assert!(
4234 (0.0..1e-300).contains(&cum),
4235 "left-tail cum should be ~0, got {cum}"
4236 );
4237 assert_eq!(
4238 hazard, 0.0,
4239 "left-tail hazard should underflow to 0, got {hazard}"
4240 );
4241 }
4242
4243 #[test]
4244 fn location_scale_logit_hazard_is_failure_slope_over_survival() {
4245 let eta = 0.7;
4246 let eta_t = 0.4;
4247
4248 let hazard = location_scale_hazard_component(
4249 eta,
4250 eta_t,
4251 &InverseLink::Standard(StandardLink::Logit),
4252 )
4253 .expect("valid logit hazard");
4254
4255 let failure = 1.0 / (1.0 + (-eta).exp());
4256 assert!((hazard - failure * eta_t).abs() <= 1e-14);
4257 }
4258
4259 #[test]
4260 fn location_scale_cloglog_hazard_matches_log_cumulative_hazard_derivative() {
4261 let eta = 1.5;
4262 let eta_t = 0.2;
4263
4264 let hazard = location_scale_hazard_component(
4265 eta,
4266 eta_t,
4267 &InverseLink::Standard(StandardLink::CLogLog),
4268 )
4269 .expect("valid cloglog hazard");
4270
4271 assert!((hazard - eta.exp() * eta_t).abs() <= 1e-14);
4272 }
4273
4274 #[test]
4277 fn kaplan_meier_censoring_is_right_continuous_step() {
4278 let time = [2.0, 4.0, 6.0, 8.0];
4280 let event = [1.0, 0.0, 1.0, 0.0];
4281 let g = KaplanMeier::fit_censoring(&time, &event);
4282 assert!((g.at(0.0) - 1.0).abs() <= 1e-15);
4284 assert!((g.at(2.0) - 1.0).abs() <= 1e-15);
4285 assert!((g.at(3.999) - 1.0).abs() <= 1e-15);
4286 assert!((g.at(4.0) - 2.0 / 3.0).abs() <= 1e-12);
4288 assert!((g.at(5.0) - 2.0 / 3.0).abs() <= 1e-12);
4289 assert!((g.at(6.0) - 2.0 / 3.0).abs() <= 1e-12);
4291 assert!(g.at(8.0).abs() <= 1e-15);
4293 }
4294
4295 #[test]
4296 fn ipcw_brier_no_censoring_reduces_to_plain_brier() {
4297 let s_pred = [0.3, 0.7, 0.6, 0.2];
4300 let time = [2.0, 8.0, 10.0, 3.0];
4301 let event = [1.0, 1.0, 0.0, 1.0];
4302 let tau = 5.0;
4303 let g = KaplanMeier::fit_censoring(&time, &event);
4304 let bs = ipcw_brier_score(&s_pred, &time, &event, tau, |t| g.at(t)).unwrap();
4305 let expected =
4307 (0.3f64.powi(2) + (1.0 - 0.7f64).powi(2) + (1.0 - 0.6f64).powi(2) + 0.2f64.powi(2))
4308 / 4.0;
4309 assert!(
4310 (bs - expected).abs() <= 1e-12,
4311 "bs={bs} expected={expected}"
4312 );
4313 }
4314
4315 #[test]
4316 fn ipcw_brier_reweights_by_inverse_censoring_probability() {
4317 let s_pred = [0.4, 0.5, 0.7, 0.8];
4321 let time = [2.0, 4.0, 6.0, 8.0];
4322 let event = [1.0, 0.0, 1.0, 0.0];
4323 let tau = 5.0;
4324 let g = KaplanMeier::fit_censoring(&time, &event);
4325 let bs = ipcw_brier_score(&s_pred, &time, &event, tau, |t| g.at(t)).unwrap();
4326 let expected = (0.16 + 0.0 + 0.135 + 0.06) / 4.0;
4331 assert!(
4332 (bs - expected).abs() <= 1e-12,
4333 "bs={bs} expected={expected}"
4334 );
4335 }
4336
4337 #[test]
4338 fn ipcw_brier_drops_invalid_rows_from_both_numerator_and_denominator() {
4339 let s_pred = [0.3, 0.7, 0.5, 0.5];
4341 let time = [2.0, 8.0, f64::NAN, -1.0];
4342 let event = [1.0, 1.0, 1.0, 0.0];
4343 let g = KaplanMeier::fit_censoring(&time, &event);
4344 let bs = ipcw_brier_score(&s_pred, &time, &event, 5.0, |t| g.at(t)).unwrap();
4345 let expected = (0.3f64.powi(2) + (1.0 - 0.7f64).powi(2)) / 2.0;
4348 assert!(
4349 (bs - expected).abs() <= 1e-12,
4350 "bs={bs} expected={expected}"
4351 );
4352 }
4353
4354 #[test]
4355 fn integrated_ipcw_brier_of_constant_brier_is_that_constant() {
4356 let time = [2.0, 8.0, 10.0, 3.0];
4359 let event = [1.0, 1.0, 0.0, 1.0];
4360 let grid = [0.0, 1.0, 2.5, 4.0, 6.0];
4361 let col = [0.3, 0.7, 0.6, 0.2];
4365 let mut surv = Array2::<f64>::zeros((4, grid.len()));
4366 for k in 0..grid.len() {
4367 for i in 0..4 {
4368 surv[[i, k]] = col[i];
4369 }
4370 }
4371 let g = KaplanMeier::fit_censoring(&time, &event);
4372 let per_time = ipcw_brier_score(&col, &time, &event, grid[2], |t| g.at(t)).unwrap();
4373 let mut oracle_pts = Vec::new();
4377 for k in 0..grid.len() {
4378 oracle_pts.push((
4379 grid[k],
4380 ipcw_brier_score(&col, &time, &event, grid[k], |t| g.at(t)).unwrap(),
4381 ));
4382 }
4383 let mut integral = 0.0;
4384 for w in oracle_pts.windows(2) {
4385 integral += 0.5 * (w[0].1 + w[1].1) * (w[1].0 - w[0].0);
4386 }
4387 let oracle = integral / (grid[grid.len() - 1] - grid[0]);
4388 let ibs =
4389 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, f64::INFINITY, |t| {
4390 g.at(t)
4391 })
4392 .unwrap();
4393 assert!((ibs - oracle).abs() <= 1e-12, "ibs={ibs} oracle={oracle}");
4394 assert!(per_time >= 0.0);
4396 }
4397
4398 #[test]
4399 fn integrated_ipcw_brier_respects_the_horizon_cutoff() {
4400 let time = [2.0, 8.0, 10.0, 3.0];
4401 let event = [1.0, 1.0, 0.0, 1.0];
4402 let grid = [0.0, 2.0, 4.0, 100.0];
4403 let col = [0.3, 0.7, 0.6, 0.2];
4404 let mut surv = Array2::<f64>::zeros((4, grid.len()));
4405 for k in 0..grid.len() {
4406 for i in 0..4 {
4407 surv[[i, k]] = col[i];
4408 }
4409 }
4410 let g = KaplanMeier::fit_censoring(&time, &event);
4411 let restricted =
4413 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, 5.0, |t| g.at(t))
4414 .unwrap();
4415 let full =
4416 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, f64::INFINITY, |t| {
4417 g.at(t)
4418 })
4419 .unwrap();
4420 assert!(
4423 (restricted - full).abs() > 1e-3,
4424 "horizon cutoff had no effect: restricted={restricted} full={full}"
4425 );
4426 }
4427
4428 #[test]
4429 fn integrated_ipcw_brier_rejects_malformed_grids() {
4430 let time = [2.0, 8.0];
4431 let event = [1.0, 0.0];
4432 let surv = Array2::<f64>::from_elem((2, 3), 0.5);
4433 let g = KaplanMeier::fit_censoring(&time, &event);
4434 let bad = [0.0, 2.0, 1.0];
4436 assert!(
4437 integrated_ipcw_brier_score(surv.view(), &time, &event, &bad, f64::INFINITY, |t| g
4438 .at(t))
4439 .is_none()
4440 );
4441 let short = [0.0, 1.0];
4443 assert!(
4444 integrated_ipcw_brier_score(surv.view(), &time, &event, &short, f64::INFINITY, |t| g
4445 .at(t))
4446 .is_none()
4447 );
4448 }
4449}