1use std::collections::HashMap;
11
12use ndarray::{Array1, Array2, ArrayView2, s};
13
14use crate::fit_orchestration::prepare_survival_time_stack;
15use crate::inference::model::{
16 FittedFamily, FittedModel as SavedModel, FittedModelPayload,
17 SavedBaselineTimeWiggleRuntime, load_survival_time_basis_config_from_model,
18 survival_baseline_config_from_model,
19};
20use gam_data::EncodedDataset;
21use crate::inference::predict_io::{
22 BernoulliMarginalSlopePredictor, LatentConditioningSpan, PredictInput,
23};
24use crate::model_types::{BlockRole, FittedBlock, FittedLinkState, UnifiedFitResult};
25use crate::probability::signed_probit_logcdf_and_mills_ratio;
26use crate::survival::construction::{
27 SurvivalBaselineConfig, SurvivalBaselineTarget, SurvivalLikelihoodMode,
28 SurvivalTimeBuildOutput, add_survival_time_derivative_guard_offset, build_survival_time_basis,
29 build_survival_time_offsets_for_likelihood, build_survival_timewiggle_derivative_design,
30 center_survival_time_designs_at_anchor, evaluate_survival_time_basis_row,
31 normalize_survival_time_pair, parse_survival_likelihood_mode,
32 require_structural_survival_time_basis, resolved_survival_time_basis_config_from_build,
33 survival_derivative_guard_for_likelihood, survival_likelihood_modename,
34};
35use crate::survival::location_scale::SurvivalCovariateTimeBasis;
36use crate::survival::latent::fixed_latent_hazard_frailty;
37use crate::survival::lognormal_kernel::FrailtySpec;
38use crate::survival::{CompetingRisksCifResult, assemble_competing_risks_cif_from_endpoints};
39use crate::wiggle::monotone_wiggle_basis_with_derivative_order;
40use gam_linalg::matrix::DesignMatrix;
41use gam_problem::{InverseLink, LikelihoodSpec, ResponseFamily, StandardLink};
42use gam_solve::mixture_link::inverse_link_jet_for_inverse_link;
43use gam_terms::smooth::TermCollectionSpec;
44use gam_terms::smooth::build_term_collection_design;
45use gam_terms::term_builder::resolve_role_col;
46
47pub struct SurvivalTimeColumns {
55 pub entry_col: Option<usize>,
56 pub exit_col: usize,
57}
58
59impl SurvivalTimeColumns {
60 #[inline]
63 pub fn row_entry_time(&self, data: ArrayView2<'_, f64>, i: usize) -> f64 {
64 self.entry_col.map_or(0.0, |idx| data[[i, idx]])
65 }
66}
67
68pub fn resolve_saved_survival_time_columns(
72 model: &SavedModel,
73 col_map: &HashMap<String, usize>,
74) -> Result<SurvivalTimeColumns, String> {
75 let entry_col: Option<usize> = model
76 .survival_entry
77 .as_deref()
78 .map(|name| resolve_role_col(col_map, name, "entry"))
79 .transpose()?;
80 let exitname = model
81 .survival_exit
82 .as_ref()
83 .ok_or_else(|| "survival model missing exit column metadata".to_string())?;
84 let exit_col = resolve_role_col(col_map, exitname, "exit")?;
85 Ok(SurvivalTimeColumns {
86 entry_col,
87 exit_col,
88 })
89}
90
91const SURVIVAL_PROB_MIN_FOR_LOG: f64 = 1e-300;
97
98#[derive(Debug, Clone)]
105pub enum SurvivalPredictError {
106 InvalidInput { reason: String },
109 MissingFitMetadata { reason: String },
113 IncompatibleSchema { reason: String },
116 UnsupportedConfiguration { reason: String },
121 PosteriorCovariance { reason: String },
127 NumericalFailure { reason: String },
131 ModelPayload {
134 context: &'static str,
135 source: crate::inference::model::FittedModelError,
136 },
137}
138
139impl std::fmt::Display for SurvivalPredictError {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 SurvivalPredictError::InvalidInput { reason }
143 | SurvivalPredictError::MissingFitMetadata { reason }
144 | SurvivalPredictError::IncompatibleSchema { reason }
145 | SurvivalPredictError::UnsupportedConfiguration { reason }
146 | SurvivalPredictError::PosteriorCovariance { reason }
147 | SurvivalPredictError::NumericalFailure { reason } => f.write_str(reason),
148 SurvivalPredictError::ModelPayload { context, source } => {
149 write!(f, "{context}: {source}")
150 }
151 }
152 }
153}
154
155impl std::error::Error for SurvivalPredictError {
156 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
157 match self {
158 SurvivalPredictError::ModelPayload { source, .. } => Some(source),
159 SurvivalPredictError::InvalidInput { .. }
160 | SurvivalPredictError::MissingFitMetadata { .. }
161 | SurvivalPredictError::IncompatibleSchema { .. }
162 | SurvivalPredictError::UnsupportedConfiguration { .. }
163 | SurvivalPredictError::PosteriorCovariance { .. }
164 | SurvivalPredictError::NumericalFailure { .. } => None,
165 }
166 }
167}
168
169impl From<SurvivalPredictError> for String {
170 fn from(err: SurvivalPredictError) -> String {
171 err.to_string()
172 }
173}
174
175impl From<String> for SurvivalPredictError {
176 fn from(reason: String) -> SurvivalPredictError {
182 SurvivalPredictError::InvalidInput { reason }
183 }
184}
185
186impl From<gam_data::DataError> for SurvivalPredictError {
187 fn from(err: gam_data::DataError) -> SurvivalPredictError {
191 SurvivalPredictError::InvalidInput {
192 reason: err.to_string(),
193 }
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
205pub enum SurvivalPredictEstimand {
206 #[default]
207 PosteriorMean,
208 Plugin,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum SurvivalPredictionCovarianceMode {
220 Conditional,
221 SmoothingCorrected,
222}
223
224impl SurvivalPredictionCovarianceMode {
225 pub const fn as_str(self) -> &'static str {
226 match self {
227 Self::Conditional => "conditional",
228 Self::SmoothingCorrected => "smoothing-corrected",
229 }
230 }
231}
232
233pub struct SurvivalPredictRequest<'a> {
235 pub model: &'a SavedModel,
236 pub data: ArrayView2<'a, f64>,
237 pub col_map: &'a HashMap<String, usize>,
238 pub training_headers: Option<&'a Vec<String>>,
239 pub primary_offset: &'a Array1<f64>,
240 pub noise_offset: &'a Array1<f64>,
241 pub time_grid: Option<&'a [f64]>,
244 pub with_uncertainty: bool,
250 pub estimand: SurvivalPredictEstimand,
253}
254
255pub struct SurvivalPredictResult {
257 pub times: Vec<f64>,
258 pub hazard: Array2<f64>,
259 pub survival: Array2<f64>,
260 pub cumulative_hazard: Array2<f64>,
261 pub linear_predictor: Array1<f64>,
262 pub likelihood_mode: SurvivalLikelihoodMode,
263 pub survival_se: Option<Array2<f64>>,
267 pub eta_se: Option<Array1<f64>>,
271 pub covariance_source: Option<SurvivalPredictionCovarianceMode>,
276}
277
278pub struct LatentWindowSurvivalResult {
287 pub window_survival: Array1<f64>,
288 pub likelihood_mode: SurvivalLikelihoodMode,
289}
290
291pub fn predict_latent_window_survival(
299 req: SurvivalPredictRequest<'_>,
300) -> Result<LatentWindowSurvivalResult, SurvivalPredictError> {
301 let SurvivalPredictRequest {
302 model,
303 data,
304 col_map,
305 training_headers,
306 primary_offset,
307 noise_offset,
308 time_grid,
309 with_uncertainty,
310 estimand,
311 } = req;
312 if time_grid.is_some() {
313 return Err(SurvivalPredictError::InvalidInput {
314 reason: "latent-window prediction consumes each row's saved entry/exit columns; an independent time_grid is not a window law".to_string(),
315 });
316 }
317 if with_uncertainty || estimand != SurvivalPredictEstimand::Plugin {
318 return Err(SurvivalPredictError::UnsupportedConfiguration {
319 reason: "latent-window observation generation requires the fitted plug-in hazard law; posterior coefficient integration is a different sampling target".to_string(),
320 });
321 }
322
323 let likelihood_mode = require_saved_survival_likelihood_mode(model)?;
324 if !matches!(
325 likelihood_mode,
326 SurvivalLikelihoodMode::Latent | SurvivalLikelihoodMode::LatentBinary
327 ) {
328 return Err(SurvivalPredictError::UnsupportedConfiguration {
329 reason: format!(
330 "latent-window prediction requires latent or latent-binary likelihood mode, got {}",
331 survival_likelihood_modename(likelihood_mode)
332 ),
333 });
334 }
335 if model.has_baseline_time_wiggle() {
336 return Err(SurvivalPredictError::IncompatibleSchema {
337 reason:
338 "saved latent survival/binary model contains forbidden baseline timewiggle metadata"
339 .to_string(),
340 });
341 }
342
343 let n = data.nrows();
344 if primary_offset.len() != n || noise_offset.len() != n {
345 return Err(SurvivalPredictError::InvalidInput {
346 reason: format!(
347 "latent-window offset length mismatch: rows={n}, primary={}, noise={}",
348 primary_offset.len(),
349 noise_offset.len()
350 ),
351 });
352 }
353 if noise_offset.iter().any(|value| *value != 0.0) {
354 return Err(SurvivalPredictError::InvalidInput {
355 reason: "latent-window survival has no secondary offset coordinate".to_string(),
356 });
357 }
358
359 let termspec = resolve_termspec_for_prediction(
360 &model.resolved_termspec,
361 training_headers,
362 col_map,
363 "resolved_termspec",
364 )?;
365 let clipped = model.axis_clip_to_training_ranges(data, col_map);
366 let covariate_input = clipped.as_ref().map_or(data, |array| array.view());
367 let covariate_design = build_term_collection_design(covariate_input, &termspec)
368 .map_err(|error| format!("failed to build latent-window covariate design: {error}"))?;
369 let effective_primary_offset = covariate_design
370 .compose_offset(primary_offset.view(), "latent-window covariate block")
371 .map_err(|error| error.to_string())?;
372
373 let time_columns = resolve_saved_survival_time_columns(model, col_map)?;
374 let mut age_entry = Array1::<f64>::zeros(n);
375 let mut age_exit = Array1::<f64>::zeros(n);
376 for row in 0..n {
377 let (entry, exit) = normalize_survival_time_pair(
378 time_columns.row_entry_time(data, row),
379 data[[row, time_columns.exit_col]],
380 row,
381 )?;
382 age_entry[row] = entry;
383 age_exit[row] = exit;
384 }
385
386 let time_config = load_survival_time_basis_config_from_model(model)?;
387 let mut time_build = build_survival_time_basis(&age_entry, &age_exit, time_config, None)?;
388 let resolved_time_config = resolved_survival_time_basis_config_from_build(
389 &time_build.basisname,
390 time_build.degree,
391 time_build.knots.as_ref(),
392 time_build.keep_cols.as_ref(),
393 time_build.smooth_lambda,
394 )?;
395 let time_anchor =
396 model
397 .survival_time_anchor
398 .ok_or_else(|| SurvivalPredictError::MissingFitMetadata {
399 reason: "saved latent-window model is missing survival_time_anchor".to_string(),
400 })?;
401 let anchor_row = evaluate_survival_time_basis_row(time_anchor, &resolved_time_config)?;
402 center_survival_time_designs_at_anchor(
403 &mut time_build.x_entry_time,
404 &mut time_build.x_exit_time,
405 &anchor_row,
406 )?;
407 require_structural_survival_time_basis(
408 &time_build.basisname,
409 "saved latent-window prediction",
410 )?;
411
412 let frailty =
413 model
414 .family_state
415 .frailty()
416 .ok_or_else(|| SurvivalPredictError::MissingFitMetadata {
417 reason: "saved latent-window model is missing its hazard-multiplier frailty"
418 .to_string(),
419 })?;
420 let (sigma, loading) = fixed_latent_hazard_frailty(frailty, "saved latent-window prediction")
421 .map_err(|reason| SurvivalPredictError::MissingFitMetadata { reason })?;
422 let baseline_config = saved_survival_runtime_baseline_config(model)?;
423 let prepared = prepare_survival_time_stack(
424 &age_entry,
425 &age_exit,
426 &baseline_config,
427 likelihood_mode,
428 None,
429 time_anchor,
430 survival_derivative_guard_for_likelihood(likelihood_mode),
431 &time_build,
432 None,
433 Some(loading),
434 )?;
435
436 let fit = fit_result_from_saved_model_for_prediction(model)?;
437 let mean_block = fit.block_by_role(BlockRole::Mean).ok_or_else(|| {
438 SurvivalPredictError::MissingFitMetadata {
439 reason: "saved latent-window model is missing its mean coefficient block".to_string(),
440 }
441 })?;
442 let time_block = fit.block_by_role(BlockRole::Time).ok_or_else(|| {
443 SurvivalPredictError::MissingFitMetadata {
444 reason: "saved latent-window model is missing its time coefficient block".to_string(),
445 }
446 })?;
447 if mean_block.beta.len() != covariate_design.design.ncols() {
448 return Err(SurvivalPredictError::IncompatibleSchema {
449 reason: format!(
450 "latent-window mean/design mismatch: beta has {} coefficients but design has {} columns",
451 mean_block.beta.len(),
452 covariate_design.design.ncols()
453 ),
454 });
455 }
456 if time_block.beta.len() != prepared.time_design_exit.ncols() {
457 let hint = stale_weibull_time_basis_hint(
458 &time_build.basisname,
459 time_block.beta.len() == prepared.time_design_exit.ncols() + 1,
460 );
461 return Err(SurvivalPredictError::IncompatibleSchema {
462 reason: format!(
463 "latent-window time/design mismatch: beta has {} coefficients but design has {} columns{hint}",
464 time_block.beta.len(),
465 prepared.time_design_exit.ncols()
466 ),
467 });
468 }
469
470 let eta = covariate_design.design.dot(&mean_block.beta) + &effective_primary_offset;
471 let q_entry = prepared.time_design_entry.dot(&time_block.beta) + &prepared.eta_offset_entry;
472 let q_exit = prepared.time_design_exit.dot(&time_block.beta) + &prepared.eta_offset_exit;
473 let quadrature = gam_solve::quadrature::QuadratureContext::new();
474 let mut window_survival = Array1::<f64>::zeros(n);
475 for row in 0..n {
476 let latent_row = crate::survival::lognormal_kernel::LatentSurvivalRow::right_censored(
477 q_entry[row].exp(),
478 q_exit[row].exp(),
479 prepared.unloaded_mass_entry[row],
480 prepared.unloaded_mass_exit[row],
481 );
482 let jet = crate::survival::lognormal_kernel::LatentSurvivalRowJet::evaluate(
483 &quadrature,
484 &latent_row,
485 eta[row],
486 sigma,
487 )
488 .map_err(|error| SurvivalPredictError::NumericalFailure {
489 reason: format!("latent-window row {row} evaluation failed: {error}"),
490 })?;
491 let survival = jet.log_lik.exp();
492 if !(survival.is_finite() && (0.0..=1.0).contains(&survival)) {
493 return Err(SurvivalPredictError::NumericalFailure {
494 reason: format!(
495 "latent-window row {row} produced invalid conditional survival {survival}"
496 ),
497 });
498 }
499 window_survival[row] = survival;
500 }
501
502 Ok(LatentWindowSurvivalResult {
503 window_survival,
504 likelihood_mode,
505 })
506}
507
508fn select_survival_prediction_covariance<'a>(
509 conditional: Option<&'a Array2<f64>>,
510 smoothing_corrected: Option<&'a Array2<f64>>,
511 mode: SurvivalPredictionCovarianceMode,
512) -> Result<&'a Array2<f64>, SurvivalPredictError> {
513 match mode {
514 SurvivalPredictionCovarianceMode::Conditional => {
515 conditional.ok_or_else(|| SurvivalPredictError::PosteriorCovariance {
516 reason: "fit result does not contain conditional covariance".to_string(),
517 })
518 }
519 SurvivalPredictionCovarianceMode::SmoothingCorrected => {
520 smoothing_corrected.ok_or_else(|| SurvivalPredictError::PosteriorCovariance {
521 reason: "fit result does not contain smoothing-corrected covariance".to_string(),
522 })
523 }
524 }
525}
526
527fn survival_prediction_posterior_factor(
533 model: &SavedModel,
534 covariance_mode: SurvivalPredictionCovarianceMode,
535) -> Result<(Array1<f64>, Array2<f64>, Vec<usize>), SurvivalPredictError> {
536 let fit = fit_result_from_saved_model_for_prediction(model)?;
537 let inactive_tail = if require_saved_survival_likelihood_mode(model)?
538 == SurvivalLikelihoodMode::MarginalSlope
539 {
540 model
541 .saved_prediction_runtime()?
542 .influence_absorber_width
543 .unwrap_or(0)
544 } else {
545 0
546 };
547 let active_len = fit.beta.len().checked_sub(inactive_tail).ok_or_else(|| {
548 SurvivalPredictError::IncompatibleSchema {
549 reason: format!(
550 "saved survival influence-absorber width {inactive_tail} exceeds the {} fitted coefficients",
551 fit.beta.len()
552 ),
553 }
554 })?;
555 let covariance = select_survival_prediction_covariance(
556 fit.beta_covariance(),
557 fit.beta_covariance_corrected(),
558 covariance_mode,
559 )?;
560 if covariance.nrows() != fit.beta.len() || covariance.ncols() != fit.beta.len() {
561 return Err(SurvivalPredictError::PosteriorCovariance {
562 reason: format!(
563 "saved survival {} covariance has shape {}x{}, expected {}x{} in fitted block order",
564 covariance_mode.as_str(),
565 covariance.nrows(),
566 covariance.ncols(),
567 fit.beta.len(),
568 fit.beta.len(),
569 ),
570 });
571 }
572 let cone_coords = survival_posterior_cone_coordinates(model, active_len)?;
573 Ok((
574 fit.beta.clone(),
575 covariance.slice(s![..active_len, ..active_len]).to_owned(),
576 cone_coords,
577 ))
578}
579
580fn saved_model_with_survival_coefficients(
581 model: &SavedModel,
582 coefficients: &Array1<f64>,
583) -> Result<SavedModel, SurvivalPredictError> {
584 let mut draw_model = model.clone();
585 let payload = match &mut draw_model {
586 SavedModel::Standard { payload }
587 | SavedModel::LocationScale { payload }
588 | SavedModel::MarginalSlope { payload }
589 | SavedModel::Survival { payload }
590 | SavedModel::TransformationNormal { payload } => payload,
591 };
592
593 let (beta_time, beta_threshold, beta_log_sigma, beta_link_wiggle, beta_time_blocks) = {
594 let fit = payload.fit_result.as_mut().ok_or_else(|| {
595 SurvivalPredictError::MissingFitMetadata {
596 reason: "saved survival model is missing canonical fit_result".to_string(),
597 }
598 })?;
599 if coefficients.len() != fit.beta.len() {
600 return Err(SurvivalPredictError::IncompatibleSchema {
601 reason: format!(
602 "posterior survival coefficient draw has length {}, expected {}",
603 coefficients.len(),
604 fit.beta.len()
605 ),
606 });
607 }
608 fit.beta.assign(coefficients);
609 let mut cursor = 0usize;
610 for block in &mut fit.blocks {
611 let end = cursor + block.beta.len();
612 block.beta.assign(&coefficients.slice(s![cursor..end]));
613 cursor = end;
614 }
615 if cursor != coefficients.len() {
616 return Err(SurvivalPredictError::IncompatibleSchema {
617 reason: format!(
618 "saved survival coefficient blocks total {cursor} entries, but the joint vector has {}",
619 coefficients.len()
620 ),
621 });
622 }
623 (
624 fit.block_by_role(BlockRole::Time)
625 .map(|block| block.beta.to_vec()),
626 fit.block_by_role(BlockRole::Threshold)
627 .map(|block| block.beta.to_vec()),
628 fit.block_by_role(BlockRole::Scale)
629 .map(|block| block.beta.to_vec()),
630 fit.block_by_role(BlockRole::LinkWiggle)
631 .map(|block| block.beta.to_vec()),
632 fit.blocks
633 .iter()
634 .map(|block| block.beta.to_vec())
635 .collect::<Vec<_>>(),
636 )
637 };
638
639 if payload.survival_beta_time.is_some() {
640 payload.survival_beta_time = beta_time.clone();
641 }
642 if payload.survival_beta_threshold.is_some() {
643 payload.survival_beta_threshold = beta_threshold;
644 }
645 if payload.survival_beta_log_sigma.is_some() {
646 payload.survival_beta_log_sigma = beta_log_sigma;
647 }
648 if payload.beta_link_wiggle.is_some() {
649 payload.beta_link_wiggle = beta_link_wiggle;
650 }
651 if let (Some(saved), Some(time_beta)) = (
652 payload.beta_baseline_timewiggle.as_mut(),
653 beta_time.as_ref(),
654 ) {
655 if saved.len() > time_beta.len() {
656 return Err(SurvivalPredictError::IncompatibleSchema {
657 reason: format!(
658 "saved baseline-timewiggle has {} coefficients, but the time block has {}",
659 saved.len(),
660 time_beta.len()
661 ),
662 });
663 }
664 *saved = time_beta[time_beta.len() - saved.len()..].to_vec();
665 }
666 if let Some(saved_by_cause) = payload.beta_baseline_timewiggle_by_cause.as_mut() {
667 if saved_by_cause.len() != beta_time_blocks.len() {
668 return Err(SurvivalPredictError::IncompatibleSchema {
669 reason: format!(
670 "saved cause-specific timewiggles have {} blocks, but the fit has {} cause blocks",
671 saved_by_cause.len(),
672 beta_time_blocks.len()
673 ),
674 });
675 }
676 for (saved, block) in saved_by_cause.iter_mut().zip(&beta_time_blocks) {
677 if saved.len() > block.len() {
678 return Err(SurvivalPredictError::IncompatibleSchema {
679 reason: format!(
680 "saved cause-specific timewiggle has {} coefficients, but its endpoint block has {}",
681 saved.len(),
682 block.len()
683 ),
684 });
685 }
686 *saved = block[block.len() - saved.len()..].to_vec();
687 }
688 }
689 Ok(draw_model)
690}
691
692fn conditional_event_density(
693 survival: f64,
694 cumulative_hazard: f64,
695 hazard: f64,
696) -> Result<f64, SurvivalPredictError> {
697 if hazard == 0.0 {
698 return Ok(0.0);
699 }
700 if survival > 0.0 && hazard.is_finite() {
701 return Ok(survival * hazard);
702 }
703 if cumulative_hazard.is_finite() && hazard > 0.0 {
704 return Ok((hazard.ln() - cumulative_hazard).exp());
705 }
706 if cumulative_hazard == f64::INFINITY && hazard.is_finite() && hazard >= 0.0 {
707 return Ok(0.0);
708 }
709 Err(SurvivalPredictError::NumericalFailure {
710 reason: format!(
711 "posterior survival quadrature could not resolve conditional density from S={survival}, H={cumulative_hazard}, h={hazard}"
712 ),
713 })
714}
715
716fn for_each_survival_posterior_node<F>(
747 posterior_mean: &Array1<f64>,
748 active_covariance: &Array2<f64>,
749 cone_coords: &[usize],
750 mut consume: F,
751) -> Result<(), SurvivalPredictError>
752where
753 F: FnMut(&Array1<f64>, f64) -> Result<(), SurvivalPredictError>,
754{
755 let active_len = active_covariance.nrows();
756 if active_covariance.ncols() != active_len || active_len > posterior_mean.len() {
757 return Err(SurvivalPredictError::PosteriorCovariance {
758 reason: format!(
759 "survival posterior quadrature received mean length {} and active covariance {}x{}",
760 posterior_mean.len(),
761 active_covariance.nrows(),
762 active_covariance.ncols(),
763 ),
764 });
765 }
766 let factorization = crate::survival::location_scale::factorize_psd_covariance(
767 active_covariance,
768 "survival posterior coefficient covariance",
769 )
770 .map_err(|reason| SurvivalPredictError::PosteriorCovariance { reason })?;
771 let rank = factorization.factor.ncols();
772 if rank == 0 {
773 return consume(posterior_mean, 1.0);
774 }
775 let nominal_scale = (rank as f64).sqrt();
776 let weight = 1.0 / (2 * rank) as f64;
777 for column in 0..rank {
778 let mut scale = nominal_scale;
785 for &j in cone_coords {
786 if j >= active_len {
787 continue;
788 }
789 let load = factorization.factor[[j, column]].abs();
790 if load == 0.0 {
791 continue;
792 }
793 let limit = posterior_mean[j].max(0.0) / load;
794 if limit < scale {
795 scale = limit;
796 }
797 }
798 for sign in [-1.0_f64, 1.0_f64] {
799 let mut node = posterior_mean.clone();
800 for row in 0..active_len {
801 node[row] += sign * scale * factorization.factor[[row, column]];
802 }
803 consume(&node, weight)?;
804 }
805 }
806 Ok(())
807}
808
809fn survival_posterior_cone_coordinates(
823 model: &SavedModel,
824 active_len: usize,
825) -> Result<Vec<usize>, SurvivalPredictError> {
826 if require_saved_survival_likelihood_mode(model)? != SurvivalLikelihoodMode::Transformation {
827 return Ok(Vec::new());
828 }
829 let time_cfg = load_survival_time_basis_config_from_model(model)
834 .map_err(|err| SurvivalPredictError::MissingFitMetadata {
835 reason: err.to_string(),
836 })?;
837 let p_time_base = if matches!(
838 time_cfg,
839 crate::survival::construction::SurvivalTimeBasisConfig::None
840 ) {
841 0
842 } else {
843 let dummy = Array1::from_elem(1, 1.0_f64);
844 build_survival_time_basis(&dummy, &dummy, time_cfg, None)
845 .map_err(|reason| SurvivalPredictError::MissingFitMetadata { reason })?
846 .x_exit_time
847 .ncols()
848 };
849
850 let fit = fit_result_from_saved_model_for_prediction(model)?;
851 let cause_count = model
852 .survival_cause_count
853 .unwrap_or(fit.blocks.len())
854 .max(1);
855 let per_cause_wiggle: Vec<usize> = if cause_count > 1 {
859 saved_cause_specific_timewiggles(model, &fit, cause_count)?
860 .iter()
861 .map(|w| w.as_ref().map_or(0, |runtime| runtime.beta.len()))
862 .collect()
863 } else {
864 vec![
865 model
866 .saved_baseline_time_wiggle()
867 .map_err(|err| SurvivalPredictError::MissingFitMetadata {
868 reason: err.to_string(),
869 })?
870 .map_or(0, |runtime| runtime.beta.len()),
871 ]
872 };
873
874 let mut cone = Vec::new();
875 let mut cursor = 0usize;
876 for (cause, block) in fit.blocks.iter().enumerate() {
877 let block_len = block.beta.len();
878 let width = (p_time_base + per_cause_wiggle.get(cause).copied().unwrap_or(0)).min(block_len);
879 for j in cursor..cursor + width {
880 if j < active_len {
881 cone.push(j);
882 }
883 }
884 cursor += block_len;
885 }
886 Ok(cone)
887}
888
889fn posterior_standard_error_matrix(
890 mean: &Array2<f64>,
891 second_moment: &Array2<f64>,
892 label: &str,
893) -> Result<Array2<f64>, SurvivalPredictError> {
894 if second_moment.dim() != mean.dim() {
895 return Err(SurvivalPredictError::IncompatibleSchema {
896 reason: format!(
897 "posterior {label} moment shape mismatch: mean={:?}, second={:?}",
898 mean.dim(),
899 second_moment.dim(),
900 ),
901 });
902 }
903 let mut standard_error = Array2::<f64>::zeros(mean.raw_dim());
904 for ((row, column), slot) in standard_error.indexed_iter_mut() {
905 let first = mean[[row, column]];
906 let second = second_moment[[row, column]];
907 if !(first.is_finite() && second.is_finite()) {
908 return Err(SurvivalPredictError::NumericalFailure {
909 reason: format!(
910 "posterior {label} moments must be finite at row {row}, time column {column}: mean={first}, second={second}"
911 ),
912 });
913 }
914 let variance = second - first * first;
915 let roundoff_tolerance =
916 128.0 * f64::EPSILON * second.abs().max((first * first).abs()).max(1.0);
917 if variance < -roundoff_tolerance {
918 return Err(SurvivalPredictError::NumericalFailure {
919 reason: format!(
920 "posterior {label} variance is negative beyond roundoff at row {row}, time column {column}: {variance}"
921 ),
922 });
923 }
924 *slot = variance.max(0.0).sqrt();
925 }
926 Ok(standard_error)
927}
928
929fn posterior_standard_error_vector(
930 mean: &Array1<f64>,
931 second_moment: &Array1<f64>,
932 label: &str,
933) -> Result<Array1<f64>, SurvivalPredictError> {
934 if second_moment.len() != mean.len() {
935 return Err(SurvivalPredictError::IncompatibleSchema {
936 reason: format!(
937 "posterior {label} moment length mismatch: mean={}, second={}",
938 mean.len(),
939 second_moment.len(),
940 ),
941 });
942 }
943 let mut standard_error = Array1::<f64>::zeros(mean.len());
944 for row in 0..mean.len() {
945 let first = mean[row];
946 let second = second_moment[row];
947 if !(first.is_finite() && second.is_finite()) {
948 return Err(SurvivalPredictError::NumericalFailure {
949 reason: format!(
950 "posterior {label} moments must be finite at row {row}: mean={first}, second={second}"
951 ),
952 });
953 }
954 let variance = second - first * first;
955 let roundoff_tolerance =
956 128.0 * f64::EPSILON * second.abs().max((first * first).abs()).max(1.0);
957 if variance < -roundoff_tolerance {
958 return Err(SurvivalPredictError::NumericalFailure {
959 reason: format!(
960 "posterior {label} variance is negative beyond roundoff at row {row}: {variance}"
961 ),
962 });
963 }
964 standard_error[row] = variance.max(0.0).sqrt();
965 }
966 Ok(standard_error)
967}
968
969fn posterior_standard_error_surfaces(
970 mean: &[Array2<f64>],
971 second_moment: &[Array2<f64>],
972 label: &str,
973) -> Result<Vec<Array2<f64>>, SurvivalPredictError> {
974 if second_moment.len() != mean.len() {
975 return Err(SurvivalPredictError::IncompatibleSchema {
976 reason: format!(
977 "posterior {label} cause count mismatch: mean={}, second={}",
978 mean.len(),
979 second_moment.len(),
980 ),
981 });
982 }
983 mean.iter()
984 .zip(second_moment)
985 .enumerate()
986 .map(|(cause, (first, second))| {
987 posterior_standard_error_matrix(first, second, &format!("{label} cause {}", cause + 1))
988 })
989 .collect()
990}
991
992fn posterior_standard_error_vectors(
993 mean: &[Array1<f64>],
994 second_moment: &[Array1<f64>],
995 label: &str,
996) -> Result<Vec<Array1<f64>>, SurvivalPredictError> {
997 if second_moment.len() != mean.len() {
998 return Err(SurvivalPredictError::IncompatibleSchema {
999 reason: format!(
1000 "posterior {label} cause count mismatch: mean={}, second={}",
1001 mean.len(),
1002 second_moment.len(),
1003 ),
1004 });
1005 }
1006 mean.iter()
1007 .zip(second_moment)
1008 .enumerate()
1009 .map(|(cause, (first, second))| {
1010 posterior_standard_error_vector(first, second, &format!("{label} cause {}", cause + 1))
1011 })
1012 .collect()
1013}
1014
1015fn predict_survival_posterior_mean(
1016 req: SurvivalPredictRequest<'_>,
1017 covariance_mode: SurvivalPredictionCovarianceMode,
1018) -> Result<SurvivalPredictResult, SurvivalPredictError> {
1019 let (posterior_mean, active_covariance, cone_coords) =
1020 survival_prediction_posterior_factor(req.model, covariance_mode)?;
1021 let mut result = predict_survival(
1022 SurvivalPredictRequest {
1023 model: req.model,
1024 data: req.data,
1025 col_map: req.col_map,
1026 training_headers: req.training_headers,
1027 primary_offset: req.primary_offset,
1028 noise_offset: req.noise_offset,
1029 time_grid: req.time_grid,
1030 with_uncertainty: false,
1031 estimand: SurvivalPredictEstimand::Plugin,
1032 },
1033 covariance_mode,
1034 )?;
1035 let (n_rows, n_times) = result.survival.dim();
1036 let mut survival_mean = Array2::<f64>::zeros((n_rows, n_times));
1037 let mut survival_second = Array2::<f64>::zeros((n_rows, n_times));
1038 let mut density_mean = Array2::<f64>::zeros((n_rows, n_times));
1039 let mut hazard_mean = Array2::<f64>::zeros((n_rows, n_times));
1040 let mut eta_mean = Array1::<f64>::zeros(n_rows);
1041 let mut eta_second = Array1::<f64>::zeros(n_rows);
1042
1043 for_each_survival_posterior_node(&posterior_mean, &active_covariance, &cone_coords, |node, weight| {
1044 let draw_model = saved_model_with_survival_coefficients(req.model, node)?;
1045 let draw = predict_survival(
1046 SurvivalPredictRequest {
1047 model: &draw_model,
1048 data: req.data,
1049 col_map: req.col_map,
1050 training_headers: req.training_headers,
1051 primary_offset: req.primary_offset,
1052 noise_offset: req.noise_offset,
1053 time_grid: req.time_grid,
1054 with_uncertainty: false,
1055 estimand: SurvivalPredictEstimand::Plugin,
1056 },
1057 covariance_mode,
1058 )?;
1059 if draw.survival.dim() != (n_rows, n_times)
1060 || draw.hazard.dim() != (n_rows, n_times)
1061 || draw.cumulative_hazard.dim() != (n_rows, n_times)
1062 || draw.linear_predictor.len() != n_rows
1063 || draw.times != result.times
1064 || draw.likelihood_mode != result.likelihood_mode
1065 {
1066 return Err(SurvivalPredictError::IncompatibleSchema {
1067 reason: "posterior survival quadrature node changed the prediction schema"
1068 .to_string(),
1069 });
1070 }
1071 for row in 0..n_rows {
1072 let eta = draw.linear_predictor[row];
1073 eta_mean[row] += weight * eta;
1074 eta_second[row] += weight * eta * eta;
1075 for time in 0..n_times {
1076 let survival = draw.survival[[row, time]];
1077 let hazard = draw.hazard[[row, time]];
1078 let density = conditional_event_density(
1079 survival,
1080 draw.cumulative_hazard[[row, time]],
1081 hazard,
1082 )?;
1083 survival_mean[[row, time]] += weight * survival;
1084 survival_second[[row, time]] += weight * survival * survival;
1085 density_mean[[row, time]] += weight * density;
1086 hazard_mean[[row, time]] += weight * hazard;
1087 }
1088 }
1089 Ok(())
1090 })?;
1091
1092 for row in 0..n_rows {
1093 for time in 0..n_times {
1094 let survival = survival_mean[[row, time]].clamp(0.0, 1.0);
1095 let density = density_mean[[row, time]];
1096 if !(density.is_finite() && density >= 0.0) {
1097 return Err(SurvivalPredictError::NumericalFailure {
1098 reason: format!(
1099 "posterior survival density is invalid at row {row}, time column {time}: {density}"
1100 ),
1101 });
1102 }
1103 result.survival[[row, time]] = survival;
1104 result.cumulative_hazard[[row, time]] = -survival.ln();
1105 result.hazard[[row, time]] = if survival > 0.0 {
1106 density / survival
1107 } else if hazard_mean[[row, time]] == 0.0 {
1108 0.0
1109 } else {
1110 f64::INFINITY
1111 };
1112 }
1113 }
1114 result.survival_se = req.with_uncertainty.then(|| {
1115 Array2::from_shape_fn((n_rows, n_times), |(row, time)| {
1116 (survival_second[[row, time]] - survival_mean[[row, time]] * survival_mean[[row, time]])
1117 .max(0.0)
1118 .sqrt()
1119 })
1120 });
1121 result.eta_se = req.with_uncertainty.then(|| {
1122 Array1::from_shape_fn(n_rows, |row| {
1123 (eta_second[row] - eta_mean[row] * eta_mean[row])
1124 .max(0.0)
1125 .sqrt()
1126 })
1127 });
1128 result.covariance_source = req.with_uncertainty.then_some(covariance_mode);
1129 Ok(result)
1130}
1131
1132fn predict_competing_risks_with_posterior(
1133 req: SurvivalPredictRequest<'_>,
1134 covariance_mode: SurvivalPredictionCovarianceMode,
1135) -> Result<CompetingRisksPredictResult, SurvivalPredictError> {
1136 let posterior_mean_estimand = req.estimand == SurvivalPredictEstimand::PosteriorMean;
1137 let (posterior_mean, active_covariance, cone_coords) =
1138 survival_prediction_posterior_factor(req.model, covariance_mode)?;
1139 let separate_conditional_point = posterior_mean_estimand
1146 && req.with_uncertainty
1147 && covariance_mode == SurvivalPredictionCovarianceMode::SmoothingCorrected;
1148 let mut result = if separate_conditional_point {
1149 predict_competing_risks_with_posterior(
1150 SurvivalPredictRequest {
1151 model: req.model,
1152 data: req.data,
1153 col_map: req.col_map,
1154 training_headers: req.training_headers,
1155 primary_offset: req.primary_offset,
1156 noise_offset: req.noise_offset,
1157 time_grid: req.time_grid,
1158 with_uncertainty: false,
1159 estimand: SurvivalPredictEstimand::PosteriorMean,
1160 },
1161 SurvivalPredictionCovarianceMode::Conditional,
1162 )?
1163 } else {
1164 predict_competing_risks_survival(
1165 SurvivalPredictRequest {
1166 model: req.model,
1167 data: req.data,
1168 col_map: req.col_map,
1169 training_headers: req.training_headers,
1170 primary_offset: req.primary_offset,
1171 noise_offset: req.noise_offset,
1172 time_grid: req.time_grid,
1173 with_uncertainty: false,
1174 estimand: SurvivalPredictEstimand::Plugin,
1175 },
1176 SurvivalPredictionCovarianceMode::Conditional,
1177 )?
1178 };
1179 let cause_count = result.cif.len();
1180 let (n_rows, n_times) = result.overall_survival.dim();
1181 let mut survival_mean = (0..cause_count)
1182 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1183 .collect::<Vec<_>>();
1184 let mut survival_second = (0..cause_count)
1185 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1186 .collect::<Vec<_>>();
1187 let mut hazard_mean = (0..cause_count)
1188 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1189 .collect::<Vec<_>>();
1190 let mut hazard_second = (0..cause_count)
1191 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1192 .collect::<Vec<_>>();
1193 let mut cumulative_hazard_mean = (0..cause_count)
1194 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1195 .collect::<Vec<_>>();
1196 let mut cumulative_hazard_second = (0..cause_count)
1197 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1198 .collect::<Vec<_>>();
1199 let mut cif_mean = (0..cause_count)
1200 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1201 .collect::<Vec<_>>();
1202 let mut cif_second = (0..cause_count)
1203 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1204 .collect::<Vec<_>>();
1205 let mut overall_mean = Array2::<f64>::zeros((n_rows, n_times));
1206 let mut overall_second = Array2::<f64>::zeros((n_rows, n_times));
1207 let mut eta_mean = (0..cause_count)
1208 .map(|_| Array1::<f64>::zeros(n_rows))
1209 .collect::<Vec<_>>();
1210 let mut eta_second = (0..cause_count)
1211 .map(|_| Array1::<f64>::zeros(n_rows))
1212 .collect::<Vec<_>>();
1213
1214 for_each_survival_posterior_node(&posterior_mean, &active_covariance, &cone_coords, |node, weight| {
1215 let draw_model = saved_model_with_survival_coefficients(req.model, node)?;
1216 let draw = predict_competing_risks_survival(
1217 SurvivalPredictRequest {
1218 model: &draw_model,
1219 data: req.data,
1220 col_map: req.col_map,
1221 training_headers: req.training_headers,
1222 primary_offset: req.primary_offset,
1223 noise_offset: req.noise_offset,
1224 time_grid: req.time_grid,
1225 with_uncertainty: false,
1226 estimand: SurvivalPredictEstimand::Plugin,
1227 },
1228 SurvivalPredictionCovarianceMode::Conditional,
1229 )?;
1230 if draw.cif.len() != cause_count
1231 || draw.survival.len() != cause_count
1232 || draw.hazard.len() != cause_count
1233 || draw.cumulative_hazard.len() != cause_count
1234 || draw.linear_predictor.len() != cause_count
1235 || draw.overall_survival.dim() != (n_rows, n_times)
1236 || draw.times != result.times
1237 || draw.endpoint_names != result.endpoint_names
1238 || draw.likelihood_mode != result.likelihood_mode
1239 {
1240 return Err(SurvivalPredictError::IncompatibleSchema {
1241 reason: "posterior competing-risks quadrature node changed the prediction schema"
1242 .to_string(),
1243 });
1244 }
1245 for cause in 0..cause_count {
1246 if draw.survival[cause].dim() != (n_rows, n_times)
1247 || draw.hazard[cause].dim() != (n_rows, n_times)
1248 || draw.cumulative_hazard[cause].dim() != (n_rows, n_times)
1249 || draw.cif[cause].dim() != (n_rows, n_times)
1250 || draw.linear_predictor[cause].len() != n_rows
1251 {
1252 return Err(SurvivalPredictError::IncompatibleSchema {
1253 reason: format!(
1254 "posterior competing-risks quadrature node changed cause {} surface dimensions",
1255 cause + 1
1256 ),
1257 });
1258 }
1259 for row in 0..n_rows {
1260 let eta = draw.linear_predictor[cause][row];
1261 eta_mean[cause][row] += weight * eta;
1262 eta_second[cause][row] += weight * eta * eta;
1263 for time in 0..n_times {
1264 let survival = draw.survival[cause][[row, time]];
1265 let hazard = draw.hazard[cause][[row, time]];
1266 let cumulative_hazard = draw.cumulative_hazard[cause][[row, time]];
1267 let cif = draw.cif[cause][[row, time]];
1268 survival_mean[cause][[row, time]] += weight * survival;
1269 survival_second[cause][[row, time]] += weight * survival * survival;
1270 hazard_mean[cause][[row, time]] += weight * hazard;
1271 hazard_second[cause][[row, time]] += weight * hazard * hazard;
1272 cumulative_hazard_mean[cause][[row, time]] += weight * cumulative_hazard;
1273 cumulative_hazard_second[cause][[row, time]] +=
1274 weight * cumulative_hazard * cumulative_hazard;
1275 cif_mean[cause][[row, time]] += weight * cif;
1276 cif_second[cause][[row, time]] += weight * cif * cif;
1277 }
1278 }
1279 }
1280 for row in 0..n_rows {
1281 for time in 0..n_times {
1282 let overall_survival = draw.overall_survival[[row, time]];
1283 overall_mean[[row, time]] += weight * overall_survival;
1284 overall_second[[row, time]] += weight * overall_survival * overall_survival;
1285 }
1286 }
1287 Ok(())
1288 })?;
1289
1290 let (hazard_se, survival_se, cumulative_hazard_se, cif_se, overall_survival_se, eta_se) =
1291 if req.with_uncertainty {
1292 (
1293 Some(posterior_standard_error_surfaces(
1294 &hazard_mean,
1295 &hazard_second,
1296 "competing-risks hazard",
1297 )?),
1298 Some(posterior_standard_error_surfaces(
1299 &survival_mean,
1300 &survival_second,
1301 "competing-risks survival",
1302 )?),
1303 Some(posterior_standard_error_surfaces(
1304 &cumulative_hazard_mean,
1305 &cumulative_hazard_second,
1306 "competing-risks cumulative hazard",
1307 )?),
1308 Some(posterior_standard_error_surfaces(
1309 &cif_mean,
1310 &cif_second,
1311 "competing-risks cumulative incidence",
1312 )?),
1313 Some(posterior_standard_error_matrix(
1314 &overall_mean,
1315 &overall_second,
1316 "competing-risks overall survival",
1317 )?),
1318 Some(posterior_standard_error_vectors(
1319 &eta_mean,
1320 &eta_second,
1321 "competing-risks linear predictor",
1322 )?),
1323 )
1324 } else {
1325 (None, None, None, None, None, None)
1326 };
1327
1328 if posterior_mean_estimand && !separate_conditional_point {
1329 result.hazard = hazard_mean;
1330 result.survival = survival_mean
1331 .into_iter()
1332 .map(|surface| surface.mapv(|value| value.clamp(0.0, 1.0)))
1333 .collect();
1334 result.cumulative_hazard = cumulative_hazard_mean;
1335 result.cif = cif_mean
1336 .into_iter()
1337 .map(|surface| surface.mapv(|value| value.clamp(0.0, 1.0)))
1338 .collect();
1339 result.overall_survival = overall_mean.mapv(|value| value.clamp(0.0, 1.0));
1340 result.linear_predictor = eta_mean;
1341 }
1342 result.hazard_se = hazard_se;
1343 result.survival_se = survival_se;
1344 result.cumulative_hazard_se = cumulative_hazard_se;
1345 result.cif_se = cif_se;
1346 result.overall_survival_se = overall_survival_se;
1347 result.eta_se = eta_se;
1348 result.covariance_source = req.with_uncertainty.then_some(covariance_mode);
1349 Ok(result)
1350}
1351
1352fn restricted_mean_survival_time_from_curve(
1368 times: &[f64],
1369 survival_row: ndarray::ArrayView1<'_, f64>,
1370 tau: f64,
1371) -> Option<f64> {
1372 if times.is_empty() || !(tau > 0.0) || !tau.is_finite() {
1373 return None;
1374 }
1375 if times.len() != survival_row.len() {
1376 return None;
1377 }
1378
1379 let mut prev_t = 0.0_f64;
1381 let mut prev_s = 1.0_f64;
1382 let mut area = 0.0_f64;
1383
1384 for (idx, &t) in times.iter().enumerate() {
1385 if !t.is_finite() || t < prev_t {
1386 return None;
1387 }
1388 let s = survival_row[idx];
1389 if !s.is_finite() {
1390 return None;
1391 }
1392 if t >= tau {
1393 let span = t - prev_t;
1395 let s_tau = if span > 0.0 {
1396 let w = (tau - prev_t) / span;
1397 prev_s + w * (s - prev_s)
1398 } else {
1399 prev_s
1400 };
1401 area += 0.5 * (prev_s + s_tau) * (tau - prev_t);
1402 return Some(area);
1403 }
1404 area += 0.5 * (prev_s + s) * (t - prev_t);
1405 prev_t = t;
1406 prev_s = s;
1407 }
1408
1409 area += prev_s * (tau - prev_t);
1413 Some(area)
1414}
1415
1416#[derive(Clone, Debug)]
1423pub struct RestrictedMeanSurvival {
1424 pub tau: f64,
1426 pub values: Array1<f64>,
1428}
1429
1430fn prediction_horizon(times: &[f64]) -> Option<f64> {
1435 let tau = *times.last()?;
1436 (tau.is_finite() && tau > 0.0).then_some(tau)
1437}
1438
1439impl SurvivalPredictResult {
1440 pub fn rmst_over_prediction_horizon(&self) -> Option<RestrictedMeanSurvival> {
1447 let tau = prediction_horizon(&self.times)?;
1448 Some(RestrictedMeanSurvival {
1449 tau,
1450 values: self.restricted_mean_survival_time(tau)?,
1451 })
1452 }
1453
1454 pub fn restricted_mean_survival_time(&self, tau: f64) -> Option<Array1<f64>> {
1461 let n = self.survival.nrows();
1462 let mut out = Array1::<f64>::zeros(n);
1463 for i in 0..n {
1464 let rmst =
1465 restricted_mean_survival_time_from_curve(&self.times, self.survival.row(i), tau)?;
1466 out[i] = rmst;
1467 }
1468 Some(out)
1469 }
1470}
1471
1472impl CompetingRisksPredictResult {
1473 pub fn overall_rmst_over_prediction_horizon(&self) -> Option<RestrictedMeanSurvival> {
1479 let tau = prediction_horizon(&self.times)?;
1480 Some(RestrictedMeanSurvival {
1481 tau,
1482 values: self.restricted_mean_overall_survival_time(tau)?,
1483 })
1484 }
1485
1486 pub fn restricted_mean_overall_survival_time(&self, tau: f64) -> Option<Array1<f64>> {
1492 let n = self.overall_survival.nrows();
1493 let mut out = Array1::<f64>::zeros(n);
1494 for i in 0..n {
1495 let rmst = restricted_mean_survival_time_from_curve(
1496 &self.times,
1497 self.overall_survival.row(i),
1498 tau,
1499 )?;
1500 out[i] = rmst;
1501 }
1502 Some(out)
1503 }
1504}
1505
1506pub fn harrell_concordance(time: &[f64], event: &[f64], risk: &[f64]) -> Option<f64> {
1519 let n = time.len();
1520 if n != event.len() || n != risk.len() {
1521 return None;
1522 }
1523 let mut comparable = 0.0_f64;
1524 let mut concordant = 0.0_f64;
1525 for i in 0..n {
1526 for j in (i + 1)..n {
1527 let (early, late) = if time[i] < time[j] {
1528 (i, j)
1529 } else if time[j] < time[i] {
1530 (j, i)
1531 } else {
1532 if event[i] > 0.5 && event[j] > 0.5 {
1535 comparable += 1.0;
1536 concordant += 0.5;
1537 }
1538 continue;
1539 };
1540 if event[early] < 0.5 {
1541 continue;
1543 }
1544 comparable += 1.0;
1545 if risk[early] > risk[late] {
1546 concordant += 1.0;
1547 } else if risk[early] == risk[late] {
1548 concordant += 0.5;
1549 }
1550 }
1551 }
1552 if comparable == 0.0 {
1553 return None;
1554 }
1555 Some(concordant / comparable)
1556}
1557
1558pub fn ipcw_brier_score(
1587 s_pred: &[f64],
1588 time: &[f64],
1589 event: &[f64],
1590 tau: f64,
1591 g_cens: impl Fn(f64) -> f64,
1592) -> Option<f64> {
1593 let n = s_pred.len();
1594 if n != time.len() || n != event.len() {
1595 return None;
1596 }
1597 let mut n_valid = 0.0_f64;
1598 let mut acc = 0.0_f64;
1599 for i in 0..n {
1600 if !time[i].is_finite() || !event[i].is_finite() || time[i] <= 0.0 {
1601 continue;
1602 }
1603 n_valid += 1.0;
1606 let (target, weight) = if time[i] <= tau && event[i] > 0.5 {
1607 let g = g_cens(time[i]);
1609 if !(g > 0.0) {
1610 continue;
1611 }
1612 (0.0, 1.0 / g)
1613 } else if time[i] > tau {
1614 let g = g_cens(tau);
1616 if !(g > 0.0) {
1617 continue;
1618 }
1619 (1.0, 1.0 / g)
1620 } else {
1621 continue;
1623 };
1624 let resid = target - s_pred[i];
1625 acc += weight * resid * resid;
1626 }
1627 if n_valid == 0.0 {
1628 return None;
1629 }
1630 Some(acc / n_valid)
1631}
1632
1633#[derive(Clone, Debug, Default)]
1636pub struct HazardPathScores {
1637 pub log_losses: Vec<f64>,
1640 pub hazard_quadratic_losses: Vec<f64>,
1643}
1644
1645pub fn monotone_survival_and_hazard_scores(
1664 raw: ArrayView2<f64>,
1665 event_times: &[f64],
1666 observed: &[bool],
1667 grid: &[f64],
1668 eps: f64,
1669) -> (Array2<f64>, HazardPathScores) {
1670 let mut surv = raw.to_owned();
1671 for mut row in surv.rows_mut() {
1672 row[0] = 1.0;
1673 let mut prev = 1.0;
1674 for value in row.iter_mut() {
1675 *value = value.clamp(eps, 1.0).min(prev);
1676 prev = *value;
1677 }
1678 }
1679 let dt: Vec<f64> = grid.windows(2).map(|pair| pair[1] - pair[0]).collect();
1680 let cumhaz = surv.mapv(|value| -value.clamp(eps, 1.0).ln());
1681 let mut haz = Array2::<f64>::zeros((surv.nrows(), surv.ncols() - 1));
1682 for row in 0..surv.nrows() {
1683 for col in 0..surv.ncols() - 1 {
1684 haz[[row, col]] = ((cumhaz[[row, col + 1]] - cumhaz[[row, col]]) / dt[col]).max(0.0);
1685 }
1686 }
1687 let mut haz_sq_prefix = Array2::<f64>::zeros((surv.nrows(), surv.ncols()));
1688 for row in 0..surv.nrows() {
1689 for col in 0..haz.ncols() {
1690 haz_sq_prefix[[row, col + 1]] =
1691 haz_sq_prefix[[row, col]] + haz[[row, col]] * haz[[row, col]] * dt[col];
1692 }
1693 }
1694 let mut log_losses = vec![0.0; event_times.len()];
1695 let mut hazard_quadratic_losses = vec![0.0; event_times.len()];
1696 for (row, &time) in event_times.iter().enumerate() {
1697 let mut j = grid.partition_point(|value| *value < time);
1698 if j >= grid.len() {
1699 j = grid.len() - 1;
1700 }
1701 let interval_idx = j.saturating_sub(1);
1702 let (h_z, h2_int, hcum_z) = if (grid[j] - time).abs() <= GRID_COINCIDENCE_TOLERANCE {
1703 (
1704 haz[[row, interval_idx]],
1705 haz_sq_prefix[[row, j]],
1706 cumhaz[[row, j]],
1707 )
1708 } else {
1709 let elapsed = time - grid[interval_idx];
1710 let h = haz[[row, interval_idx]];
1711 (
1712 h,
1713 haz_sq_prefix[[row, interval_idx]] + h * h * elapsed,
1714 cumhaz[[row, interval_idx]] + h * elapsed,
1715 )
1716 };
1717 log_losses[row] = hcum_z - if observed[row] { h_z.max(eps).ln() } else { 0.0 };
1718 hazard_quadratic_losses[row] = 0.5 * h2_int - if observed[row] { h_z } else { 0.0 };
1719 }
1720 (
1721 surv,
1722 HazardPathScores {
1723 log_losses,
1724 hazard_quadratic_losses,
1725 },
1726 )
1727}
1728
1729const GRID_COINCIDENCE_TOLERANCE: f64 = 1.0e-12;
1733
1734pub fn integrated_ipcw_brier_score(
1753 s_pred: ArrayView2<f64>,
1754 time: &[f64],
1755 event: &[f64],
1756 grid: &[f64],
1757 horizon: f64,
1758 g_cens: impl Fn(f64) -> f64,
1759) -> Option<f64> {
1760 let m = grid.len();
1761 if m < 2 || s_pred.ncols() != m || s_pred.nrows() != time.len() {
1762 return None;
1763 }
1764 if grid.windows(2).any(|pair| !(pair[1] > pair[0])) {
1765 return None;
1766 }
1767 let mut pts: Vec<(f64, f64)> = Vec::with_capacity(m);
1769 for k in 0..m {
1770 if grid[k] > horizon {
1771 break;
1772 }
1773 let col = s_pred.column(k);
1774 let col_slice: Vec<f64> = col.to_vec();
1775 if let Some(bs) = ipcw_brier_score(&col_slice, time, event, grid[k], &g_cens) {
1776 pts.push((grid[k], bs));
1777 }
1778 }
1779 if pts.len() < 2 {
1780 return None;
1781 }
1782 let span = pts[pts.len() - 1].0 - pts[0].0;
1783 if !(span > 0.0) {
1784 return None;
1785 }
1786 let mut integral = 0.0_f64;
1787 for w in pts.windows(2) {
1788 integral += 0.5 * (w[1].1 + w[0].1) * (w[1].0 - w[0].0);
1789 }
1790 Some(integral / span)
1791}
1792
1793#[derive(Clone, Debug, Default)]
1800pub struct KaplanMeier {
1801 steps: Vec<(f64, f64)>,
1803}
1804
1805impl KaplanMeier {
1806 pub fn fit(time: &[f64], event: &[f64]) -> Self {
1808 let mut rows: Vec<(f64, bool)> = time
1809 .iter()
1810 .zip(event.iter())
1811 .filter_map(|(&t, &e)| {
1812 (t.is_finite() && e.is_finite() && t > 0.0).then_some((t, e > 0.5))
1813 })
1814 .collect();
1815 rows.sort_by(|a, b| a.0.total_cmp(&b.0));
1816 let mut steps = Vec::new();
1817 let mut at_risk = rows.len() as f64;
1818 let mut survival = 1.0_f64;
1819 let mut i = 0usize;
1820 while i < rows.len() {
1821 let t = rows[i].0;
1822 let mut j = i;
1823 let mut deaths = 0usize;
1824 while j < rows.len() && rows[j].0 == t {
1825 deaths += usize::from(rows[j].1);
1826 j += 1;
1827 }
1828 if deaths > 0 && at_risk > 0.0 {
1829 survival *= ((at_risk - deaths as f64) / at_risk).max(0.0);
1830 steps.push((t, survival));
1831 }
1832 at_risk -= (j - i) as f64;
1833 i = j;
1834 }
1835 Self { steps }
1836 }
1837
1838 pub fn fit_censoring(time: &[f64], event: &[f64]) -> Self {
1842 let flipped: Vec<f64> = event
1843 .iter()
1844 .map(|&e| if e > 0.5 { 0.0 } else { 1.0 })
1845 .collect();
1846 Self::fit(time, &flipped)
1847 }
1848
1849 pub fn on_grid(&self, grid: &[f64]) -> Vec<f64> {
1854 grid.iter()
1855 .map(|&t| {
1856 let idx = self.steps.partition_point(|&(time, _)| time <= t);
1857 if idx == 0 { 1.0 } else { self.steps[idx - 1].1 }
1858 })
1859 .collect()
1860 }
1861
1862 pub fn at(&self, t: f64) -> f64 {
1865 let mut s = 1.0_f64;
1866 for &(time, surv) in &self.steps {
1867 if time <= t {
1868 s = surv;
1869 } else {
1870 break;
1871 }
1872 }
1873 s
1874 }
1875}
1876
1877pub struct CompetingRisksPredictResult {
1879 pub times: Vec<f64>,
1880 pub endpoint_names: Vec<String>,
1881 pub hazard: Vec<Array2<f64>>,
1883 pub survival: Vec<Array2<f64>>,
1885 pub cumulative_hazard: Vec<Array2<f64>>,
1887 pub cif: Vec<Array2<f64>>,
1889 pub overall_survival: Array2<f64>,
1891 pub linear_predictor: Vec<Array1<f64>>,
1893 pub likelihood_mode: SurvivalLikelihoodMode,
1894 pub covariance_source: Option<SurvivalPredictionCovarianceMode>,
1897 pub hazard_se: Option<Vec<Array2<f64>>>,
1899 pub survival_se: Option<Vec<Array2<f64>>>,
1901 pub cumulative_hazard_se: Option<Vec<Array2<f64>>>,
1903 pub cif_se: Option<Vec<Array2<f64>>>,
1905 pub overall_survival_se: Option<Array2<f64>>,
1907 pub eta_se: Option<Vec<Array1<f64>>>,
1909}
1910
1911pub fn predict_survival(
1917 req: SurvivalPredictRequest<'_>,
1918 covariance_mode: SurvivalPredictionCovarianceMode,
1919) -> Result<SurvivalPredictResult, SurvivalPredictError> {
1920 if req.estimand == SurvivalPredictEstimand::PosteriorMean {
1921 return predict_survival_posterior_mean(req, covariance_mode);
1922 }
1923 let SurvivalPredictRequest {
1924 model,
1925 data,
1926 col_map,
1927 training_headers,
1928 primary_offset,
1929 noise_offset,
1930 time_grid,
1931 with_uncertainty,
1932 estimand: _,
1933 } = req;
1934
1935 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1944 let exit_col = time_cols.exit_col;
1945
1946 let termspec = resolve_termspec_for_prediction(
1947 &model.resolved_termspec,
1948 training_headers,
1949 col_map,
1950 "resolved_termspec",
1951 )?;
1952 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1958 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1959 let cov_design = build_term_collection_design(cov_input, &termspec)
1960 .map_err(|e| format!("failed to build survival prediction design: {e}"))?;
1961
1962 let n = data.nrows();
1963 if primary_offset.len() != n || noise_offset.len() != n {
1964 return Err(SurvivalPredictError::InvalidInput {
1965 reason: format!(
1966 "survival prediction offset length mismatch: rows={n}, offset={}, noise_offset={}",
1967 primary_offset.len(),
1968 noise_offset.len()
1969 ),
1970 });
1971 }
1972 let effective_primary_offset = cov_design
1973 .compose_offset(primary_offset.view(), "survival prediction covariate block")
1974 .map_err(|error| error.to_string())?;
1975
1976 use rayon::iter::{IntoParallelIterator, ParallelIterator};
1977 let pairs: Result<Vec<(f64, f64)>, String> = (0..n)
1978 .into_par_iter()
1979 .map(|i| {
1980 normalize_survival_time_pair(time_cols.row_entry_time(data, i), data[[i, exit_col]], i)
1981 })
1982 .collect();
1983 let pairs = pairs?;
1984 let mut age_entry = Array1::<f64>::zeros(n);
1985 let mut age_exit = Array1::<f64>::zeros(n);
1986 for (i, (t0, t1)) in pairs.into_iter().enumerate() {
1987 age_entry[i] = t0;
1988 age_exit[i] = t1;
1989 }
1990
1991 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1992
1993 if matches!(
1997 saved_likelihood_mode,
1998 SurvivalLikelihoodMode::Latent | SurvivalLikelihoodMode::LatentBinary
1999 ) {
2000 return Err(SurvivalPredictError::UnsupportedConfiguration {
2001 reason: format!(
2002 "survival prediction via predict_survival does not support likelihood_mode={} yet; \
2003 latent window prediction lives in the CLI's run_predict_saved_latent_window_impl \
2004 pipeline and has not yet been ported to the library. Use the CLI predict command.",
2005 survival_likelihood_modename(saved_likelihood_mode)
2006 ),
2007 });
2008 }
2009 if saved_likelihood_mode == SurvivalLikelihoodMode::LocationScale {
2012 return predict_survival_location_scale_batch(
2013 model,
2014 &age_entry,
2015 &age_exit,
2016 &cov_design,
2017 &effective_primary_offset,
2018 noise_offset,
2019 training_headers,
2020 col_map,
2021 data,
2022 time_grid,
2023 with_uncertainty,
2024 covariance_mode,
2025 )
2026 .map_err(SurvivalPredictError::from);
2027 }
2028 if with_uncertainty {
2029 return Err(SurvivalPredictError::from(format!(
2030 "predict_survival: with_uncertainty is currently supported only for the \
2031 location-scale likelihood mode; got {}",
2032 survival_likelihood_modename(saved_likelihood_mode)
2033 )));
2034 }
2035
2036 let time_cfg = load_survival_time_basis_config_from_model(model)?;
2039 let mut time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
2040 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
2041 &time_build.basisname,
2042 time_build.degree,
2043 time_build.knots.as_ref(),
2044 time_build.keep_cols.as_ref(),
2045 time_build.smooth_lambda,
2046 )?;
2047 let weibull_baseline_in_beta = saved_likelihood_mode == SurvivalLikelihoodMode::Weibull
2064 && !model.has_baseline_time_wiggle();
2065 let mut time_anchor: Option<f64> = None;
2092 let mut time_anchor_row_cached: Option<Array1<f64>> = None;
2093 if time_build.x_exit_time.ncols() > 0 {
2094 let anchor = model
2095 .survival_time_anchor
2096 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
2097 let time_anchor_row = evaluate_survival_time_basis_row(anchor, &resolved_time_cfg)?;
2098 center_survival_time_designs_at_anchor(
2099 &mut time_build.x_entry_time,
2100 &mut time_build.x_exit_time,
2101 &time_anchor_row,
2102 )?;
2103 time_anchor = Some(anchor);
2104 time_anchor_row_cached = Some(time_anchor_row);
2105 }
2106 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull && !model.has_baseline_time_wiggle()
2107 {
2108 require_structural_survival_time_basis(&time_build.basisname, "saved survival sampling")?;
2109 }
2110 let mut baseline_cfg = saved_survival_runtime_baseline_config(model)?;
2111 if weibull_baseline_in_beta {
2112 baseline_cfg = SurvivalBaselineConfig {
2113 target: SurvivalBaselineTarget::Linear,
2114 scale: None,
2115 shape: None,
2116 rate: None,
2117 makeham: None,
2118 };
2119 }
2120
2121 let per_row_eval = time_grid.is_none();
2124 let eval_times: Vec<f64> = match time_grid {
2125 Some(grid) => {
2126 if grid.is_empty() {
2127 return Err(SurvivalPredictError::InvalidInput {
2128 reason: "survival time_grid must contain at least one time".to_string(),
2129 });
2130 }
2131 for (idx, &t) in grid.iter().enumerate() {
2132 if !t.is_finite() || t < 0.0 {
2133 return Err(SurvivalPredictError::InvalidInput {
2134 reason: format!(
2135 "survival time_grid requires finite non-negative times (index {idx})",
2136 ),
2137 });
2138 }
2139 }
2140 grid.to_vec()
2141 }
2142 None => Vec::new(),
2143 };
2144
2145 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
2146 let mut hazard = Array2::<f64>::zeros((n, t_cols));
2147 let mut survival = Array2::<f64>::zeros((n, t_cols));
2148 let mut cumulative_hazard = Array2::<f64>::zeros((n, t_cols));
2149 let mut linear_predictor = Array1::<f64>::zeros(n);
2150
2151 let marginal_slope_ctx = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
2157 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
2161 build_survival_time_offsets_for_likelihood(
2162 &age_entry,
2163 &age_exit,
2164 &baseline_cfg,
2165 saved_likelihood_mode,
2166 None,
2167 )?;
2168 add_survival_time_derivative_guard_offset(
2169 &age_entry,
2170 &age_exit,
2171 time_anchor.ok_or_else(|| {
2172 "saved survival marginal-slope model missing survival_time_anchor".to_string()
2173 })?,
2174 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
2175 &mut eta_offset_entry,
2176 &mut eta_offset_exit,
2177 &mut derivative_offset_exit,
2178 )?;
2179 Some(build_marginal_slope_predict_context(
2180 model,
2181 data,
2182 col_map,
2183 training_headers,
2184 &cov_design.design,
2185 &effective_primary_offset,
2186 noise_offset,
2187 &time_build,
2188 &eta_offset_entry,
2189 &eta_offset_exit,
2190 &derivative_offset_exit,
2191 &age_exit,
2192 )?)
2193 } else {
2194 None
2195 };
2196
2197 struct SurvivalPredictionRow {
2201 hazard: Vec<f64>,
2202 survival: Vec<f64>,
2203 cumulative_hazard: Vec<f64>,
2204 linear_predictor: f64,
2205 }
2206
2207 let row_results: Result<Vec<SurvivalPredictionRow>, SurvivalPredictError> = (0..n)
2208 .into_par_iter()
2209 .map(|i| {
2210 let cov_row = if matches!(
2211 saved_likelihood_mode,
2212 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull
2213 ) {
2214 Some(design_row_owned(
2215 &cov_design.design,
2216 i,
2217 "survival predict covariate row",
2218 )?)
2219 } else {
2220 None
2221 };
2222 let evaluate_at = |t_query: f64| -> Result<(f64, f64, f64), SurvivalPredictError> {
2223 let t_entry = age_entry[i].min(t_query);
2224 let single_entry = Array1::from_elem(1, t_entry);
2225 let single_exit = Array1::from_elem(1, t_query);
2226 let mut row_time =
2227 build_survival_time_basis(&single_entry, &single_exit, time_cfg.clone(), None)?;
2228 if let Some(anchor_row) = time_anchor_row_cached.as_ref() {
2229 center_survival_time_designs_at_anchor(
2230 &mut row_time.x_entry_time,
2231 &mut row_time.x_exit_time,
2232 anchor_row,
2233 )?;
2234 }
2235 let (mut r_eta_entry, mut r_eta_exit, mut r_deriv_exit) =
2236 build_survival_time_offsets_for_likelihood(
2237 &single_entry,
2238 &single_exit,
2239 &baseline_cfg,
2240 saved_likelihood_mode,
2241 None,
2242 )?;
2243 if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
2244 add_survival_time_derivative_guard_offset(
2245 &single_entry,
2246 &single_exit,
2247 time_anchor.ok_or_else(|| {
2248 "saved survival marginal-slope model missing survival_time_anchor"
2249 .to_string()
2250 })?,
2251 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
2252 &mut r_eta_entry,
2253 &mut r_eta_exit,
2254 &mut r_deriv_exit,
2255 )?;
2256 }
2257
2258 match saved_likelihood_mode {
2259 SurvivalLikelihoodMode::MarginalSlope => {
2260 let ctx = marginal_slope_ctx.as_ref().ok_or_else(|| {
2261 "internal error: marginal-slope context missing for marginal-slope mode"
2262 .to_string()
2263 })?;
2264 evaluate_marginal_slope_row(
2265 i,
2266 ctx,
2267 &row_time,
2268 &r_eta_exit,
2269 &r_deriv_exit,
2270 effective_primary_offset[i],
2271 t_query,
2272 )
2273 }
2274 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull => {
2275 let cov_row = cov_row.as_ref().ok_or_else(|| {
2276 "internal error: covariate row missing for Royston-Parmar prediction"
2277 .to_string()
2278 })?;
2279 evaluate_rp_row(
2280 model,
2281 &row_time,
2282 cov_row,
2283 r_eta_exit[0],
2284 r_deriv_exit[0],
2285 effective_primary_offset[i],
2286 )
2287 }
2288 SurvivalLikelihoodMode::Latent
2289 | SurvivalLikelihoodMode::LatentBinary
2290 | SurvivalLikelihoodMode::LocationScale => {
2291 Err(SurvivalPredictError::NumericalFailure {
2292 reason: "unreachable: unsupported likelihood_mode filtered earlier"
2293 .to_string(),
2294 })
2295 }
2296 }
2297 };
2298
2299 let mut row = SurvivalPredictionRow {
2300 hazard: vec![0.0; t_cols],
2301 survival: vec![0.0; t_cols],
2302 cumulative_hazard: vec![0.0; t_cols],
2303 linear_predictor: 0.0,
2304 };
2305 if per_row_eval {
2306 let (eta_t, cum_t, haz_t) = evaluate_at(age_exit[i])?;
2307 row.linear_predictor = eta_t;
2308 row.hazard[0] = haz_t;
2309 row.cumulative_hazard[0] = cum_t;
2310 row.survival[0] = (-cum_t).exp().clamp(0.0, 1.0);
2311 } else {
2312 for (j, &t_query) in eval_times.iter().enumerate() {
2313 if t_query <= 0.0 {
2314 row.hazard[j] = 0.0;
2315 row.cumulative_hazard[j] = 0.0;
2316 row.survival[j] = 1.0;
2317 } else {
2318 let (_eta_t, cum_t, haz_t) = evaluate_at(t_query)?;
2319 row.hazard[j] = haz_t;
2320 row.cumulative_hazard[j] = cum_t;
2321 row.survival[j] = (-cum_t).exp().clamp(0.0, 1.0);
2322 }
2323 }
2324 let (eta_t, _, _) = evaluate_at(age_exit[i])?;
2325 row.linear_predictor = eta_t;
2326 }
2327 Ok(row)
2328 })
2329 .collect();
2330
2331 for (i, row) in row_results?.into_iter().enumerate() {
2332 linear_predictor[i] = row.linear_predictor;
2333 for j in 0..t_cols {
2334 hazard[[i, j]] = row.hazard[j];
2335 cumulative_hazard[[i, j]] = row.cumulative_hazard[j];
2336 survival[[i, j]] = row.survival[j];
2337 }
2338 }
2339
2340 let times_out: Vec<f64> = if per_row_eval {
2341 age_exit.to_vec()
2342 } else {
2343 eval_times
2344 };
2345
2346 Ok(SurvivalPredictResult {
2347 times: times_out,
2348 hazard,
2349 survival,
2350 cumulative_hazard,
2351 linear_predictor,
2352 likelihood_mode: saved_likelihood_mode,
2353 survival_se: None,
2354 eta_se: None,
2355 covariance_source: None,
2356 })
2357}
2358
2359pub fn predict_competing_risks_survival(
2360 req: SurvivalPredictRequest<'_>,
2361 covariance_mode: SurvivalPredictionCovarianceMode,
2362) -> Result<CompetingRisksPredictResult, SurvivalPredictError> {
2363 if req.estimand == SurvivalPredictEstimand::PosteriorMean || req.with_uncertainty {
2364 return predict_competing_risks_with_posterior(req, covariance_mode);
2365 }
2366 let SurvivalPredictRequest {
2367 model,
2368 data,
2369 col_map,
2370 training_headers,
2371 primary_offset,
2372 noise_offset,
2373 time_grid,
2374 with_uncertainty: _,
2375 estimand: _,
2376 } = req;
2377
2378 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
2379 if !matches!(
2380 saved_likelihood_mode,
2381 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull
2382 ) {
2383 return Err(SurvivalPredictError::UnsupportedConfiguration {
2384 reason: format!(
2385 "joint cause-specific prediction supports transformation/weibull survival only; got {}",
2386 survival_likelihood_modename(saved_likelihood_mode)
2387 ),
2388 });
2389 }
2390
2391 let fit = fit_result_from_saved_model_for_prediction(model)?;
2392 let cause_count = model
2393 .survival_cause_count
2394 .unwrap_or(fit.blocks.len())
2395 .max(1);
2396 if cause_count <= 1 {
2397 return Err(SurvivalPredictError::MissingFitMetadata {
2398 reason: "competing-risks survival prediction requires a saved model with at least two causes"
2399 .to_string(),
2400 });
2401 }
2402 if fit.blocks.len() != cause_count {
2403 return Err(SurvivalPredictError::IncompatibleSchema {
2404 reason: format!(
2405 "saved competing-risks survival fit has {} coefficient blocks but metadata says {cause_count} causes",
2406 fit.blocks.len()
2407 ),
2408 });
2409 }
2410 let endpoint_names = model.survival_endpoint_names.clone().unwrap_or_else(|| {
2411 (1..=cause_count)
2412 .map(|idx| format!("cause_{idx}"))
2413 .collect()
2414 });
2415 if endpoint_names.len() != cause_count {
2416 return Err(SurvivalPredictError::IncompatibleSchema {
2417 reason: format!(
2418 "saved competing-risks survival endpoint_names has length {}, expected {cause_count}",
2419 endpoint_names.len()
2420 ),
2421 });
2422 }
2423
2424 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
2428 let exit_col = time_cols.exit_col;
2429
2430 let termspec = resolve_termspec_for_prediction(
2431 &model.resolved_termspec,
2432 training_headers,
2433 col_map,
2434 "resolved_termspec",
2435 )?;
2436 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
2437 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
2438 let cov_design = build_term_collection_design(cov_input, &termspec)
2439 .map_err(|e| format!("failed to build competing-risks prediction design: {e}"))?;
2440
2441 let n = data.nrows();
2442 if primary_offset.len() != n || noise_offset.len() != n {
2443 return Err(SurvivalPredictError::InvalidInput {
2444 reason: format!(
2445 "competing-risks prediction offset length mismatch: rows={n}, offset={}, noise_offset={}",
2446 primary_offset.len(),
2447 noise_offset.len()
2448 ),
2449 });
2450 }
2451 let effective_primary_offset = cov_design
2452 .compose_offset(
2453 primary_offset.view(),
2454 "competing-risks prediction covariate block",
2455 )
2456 .map_err(|error| error.to_string())?;
2457
2458 use rayon::iter::{IntoParallelIterator, ParallelIterator};
2459 let pairs: Result<Vec<(f64, f64)>, String> = (0..n)
2460 .into_par_iter()
2461 .map(|i| {
2462 normalize_survival_time_pair(time_cols.row_entry_time(data, i), data[[i, exit_col]], i)
2463 })
2464 .collect();
2465 let pairs = pairs?;
2466 let mut age_entry = Array1::<f64>::zeros(n);
2467 let mut age_exit = Array1::<f64>::zeros(n);
2468 for (i, (t0, t1)) in pairs.into_iter().enumerate() {
2469 age_entry[i] = t0;
2470 age_exit[i] = t1;
2471 }
2472
2473 let time_cfg = load_survival_time_basis_config_from_model(model)?;
2474 let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
2475 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
2476 &time_build.basisname,
2477 time_build.degree,
2478 time_build.knots.as_ref(),
2479 time_build.keep_cols.as_ref(),
2480 time_build.smooth_lambda,
2481 )?;
2482 let weibull_baseline_in_beta = saved_likelihood_mode == SurvivalLikelihoodMode::Weibull
2491 && !model.has_baseline_time_wiggle();
2492 let cr_time_anchor_row: Option<Array1<f64>> = if time_build.x_exit_time.ncols() > 0 {
2498 let anchor = model
2499 .survival_time_anchor
2500 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
2501 Some(evaluate_survival_time_basis_row(
2502 anchor,
2503 &resolved_time_cfg,
2504 )?)
2505 } else {
2506 None
2507 };
2508 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull && !model.has_baseline_time_wiggle()
2509 {
2510 require_structural_survival_time_basis(
2511 &time_build.basisname,
2512 "saved competing-risks survival prediction",
2513 )?;
2514 }
2515 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
2516
2517 let per_row_eval = time_grid.is_none();
2518 let eval_times: Vec<f64> = match time_grid {
2519 Some(grid) => {
2520 if grid.is_empty() {
2521 return Err(SurvivalPredictError::InvalidInput {
2522 reason: "survival time_grid must contain at least one time".to_string(),
2523 });
2524 }
2525 for (idx, &t) in grid.iter().enumerate() {
2526 if !t.is_finite() || t < 0.0 {
2527 return Err(SurvivalPredictError::InvalidInput {
2528 reason: format!(
2529 "survival time_grid requires finite non-negative times (index {idx})",
2530 ),
2531 });
2532 }
2533 }
2534 grid.to_vec()
2535 }
2536 None => Vec::new(),
2537 };
2538 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
2539
2540 const CIF_REFINE_SUBINTERVALS: usize = 32;
2559 let (refined_times, user_time_to_refined_index): (Vec<f64>, Vec<usize>) = if per_row_eval {
2560 (Vec::new(), Vec::new())
2561 } else {
2562 let mut order: Vec<usize> = (0..eval_times.len()).collect();
2570 order.sort_by(|&a, &b| {
2571 eval_times[a]
2572 .partial_cmp(&eval_times[b])
2573 .expect("survival time_grid entries are validated finite above")
2574 });
2575 let mut refined: Vec<f64> = Vec::new();
2576 let mut user_index: Vec<usize> = vec![0; eval_times.len()];
2577 let mut prev = 0.0_f64;
2578 for &j_user in &order {
2579 let t_user = eval_times[j_user];
2580 let gap = t_user - prev;
2585 if gap > 0.0 {
2586 for s in 1..CIF_REFINE_SUBINTERVALS {
2587 let t_mid = prev + gap * (s as f64) / (CIF_REFINE_SUBINTERVALS as f64);
2588 if refined.last().is_none_or(|&last| t_mid > last) {
2590 refined.push(t_mid);
2591 }
2592 }
2593 }
2594 if refined.last().is_none_or(|&last| t_user > last) {
2595 refined.push(t_user);
2596 }
2597 user_index[j_user] = refined.len() - 1;
2598 prev = t_user;
2599 }
2600 (refined, user_index)
2601 };
2602 let refined_cols = if per_row_eval {
2605 CIF_REFINE_SUBINTERVALS
2606 } else {
2607 refined_times.len()
2608 };
2609
2610 let saved_timewiggle_by_cause = saved_cause_specific_timewiggles(model, &fit, cause_count)?;
2611 let cov_rows = (0..n)
2612 .map(|i| design_row_owned(&cov_design.design, i, "competing-risks covariate row"))
2613 .collect::<Result<Vec<_>, _>>()?;
2614
2615 let mut hazard = (0..cause_count)
2616 .map(|_| Array2::<f64>::zeros((n, t_cols)))
2617 .collect::<Vec<_>>();
2618 let mut survival = (0..cause_count)
2619 .map(|_| Array2::<f64>::zeros((n, t_cols)))
2620 .collect::<Vec<_>>();
2621 let mut cumulative_hazard = (0..cause_count)
2622 .map(|_| Array2::<f64>::zeros((n, t_cols)))
2623 .collect::<Vec<_>>();
2624 let mut cumulative_hazard_refined = (0..cause_count)
2627 .map(|_| Array2::<f64>::zeros((n, refined_cols)))
2628 .collect::<Vec<_>>();
2629 let mut linear_predictor = (0..cause_count)
2630 .map(|_| Array1::<f64>::zeros(n))
2631 .collect::<Vec<_>>();
2632
2633 struct CauseRow {
2634 cause: usize,
2635 row: usize,
2636 hazard: Vec<f64>,
2637 survival: Vec<f64>,
2638 cumulative: Vec<f64>,
2639 cumulative_refined: Vec<f64>,
2642 eta_exit: f64,
2643 }
2644
2645 let rows: Result<Vec<CauseRow>, SurvivalPredictError> = (0..cause_count * n)
2646 .into_par_iter()
2647 .map(|flat| {
2648 let cause = flat / n;
2649 let i = flat % n;
2650 let block = &fit.blocks[cause];
2651 let timewiggle = saved_timewiggle_by_cause[cause].as_ref();
2652 let evaluate_at = |t_query: f64| -> Result<(f64, f64, f64), SurvivalPredictError> {
2653 let t_entry = age_entry[i].min(t_query);
2654 let single_entry = Array1::from_elem(1, t_entry);
2655 let single_exit = Array1::from_elem(1, t_query);
2656 let mut row_time =
2657 build_survival_time_basis(&single_entry, &single_exit, time_cfg.clone(), None)?;
2658 if let Some(anchor_row) = cr_time_anchor_row.as_ref() {
2659 center_survival_time_designs_at_anchor(
2660 &mut row_time.x_entry_time,
2661 &mut row_time.x_exit_time,
2662 anchor_row,
2663 )?;
2664 }
2665 let (r_eta_exit, r_deriv_exit) = if weibull_baseline_in_beta {
2666 (0.0, 0.0)
2667 } else {
2668 let (_, eta_exit, deriv_exit) = build_survival_time_offsets_for_likelihood(
2669 &single_entry,
2670 &single_exit,
2671 &baseline_cfg,
2672 saved_likelihood_mode,
2673 None,
2674 )?;
2675 (eta_exit[0], deriv_exit[0])
2676 };
2677 evaluate_rp_row_with_beta(
2678 &block.beta,
2679 timewiggle,
2680 &row_time,
2681 &cov_rows[i],
2682 r_eta_exit,
2683 r_deriv_exit,
2684 effective_primary_offset[i],
2685 )
2686 };
2687
2688 let mut out = CauseRow {
2689 cause,
2690 row: i,
2691 hazard: vec![0.0; t_cols],
2692 survival: vec![0.0; t_cols],
2693 cumulative: vec![0.0; t_cols],
2694 cumulative_refined: vec![0.0; refined_cols],
2695 eta_exit: 0.0,
2696 };
2697 if per_row_eval {
2698 let (eta_t, cum_t, haz_t) = evaluate_at(age_exit[i])?;
2699 out.eta_exit = eta_t;
2700 out.hazard[0] = haz_t;
2701 out.cumulative[0] = cum_t;
2702 out.survival[0] = (-cum_t).exp().clamp(0.0, 1.0);
2703 for s in 1..=CIF_REFINE_SUBINTERVALS {
2710 let frac = (s as f64) / (CIF_REFINE_SUBINTERVALS as f64);
2711 let t_query = age_exit[i] * frac;
2712 out.cumulative_refined[s - 1] = if t_query <= 0.0 {
2713 0.0
2714 } else if s == CIF_REFINE_SUBINTERVALS {
2715 cum_t
2719 } else {
2720 evaluate_at(t_query)?.1
2721 };
2722 }
2723 } else {
2724 for (j, &t_query) in eval_times.iter().enumerate() {
2725 if t_query <= 0.0 {
2732 out.hazard[j] = 0.0;
2733 out.cumulative[j] = 0.0;
2734 out.survival[j] = 1.0;
2735 } else {
2736 let (_eta_t, cum_t, haz_t) = evaluate_at(t_query)?;
2737 out.hazard[j] = haz_t;
2738 out.cumulative[j] = cum_t;
2739 out.survival[j] = (-cum_t).exp().clamp(0.0, 1.0);
2740 }
2741 }
2742 for (jr, &t_query) in refined_times.iter().enumerate() {
2748 out.cumulative_refined[jr] = if t_query <= 0.0 {
2749 0.0
2750 } else {
2751 evaluate_at(t_query)?.1
2752 };
2753 }
2754 let (eta_t, _, _) = evaluate_at(age_exit[i])?;
2755 out.eta_exit = eta_t;
2756 }
2757 Ok(out)
2758 })
2759 .collect();
2760
2761 for row in rows? {
2762 linear_predictor[row.cause][row.row] = row.eta_exit;
2763 for j in 0..t_cols {
2764 hazard[row.cause][[row.row, j]] = row.hazard[j];
2765 survival[row.cause][[row.row, j]] = row.survival[j];
2766 cumulative_hazard[row.cause][[row.row, j]] = row.cumulative[j];
2767 }
2768 for jr in 0..refined_cols {
2769 cumulative_hazard_refined[row.cause][[row.row, jr]] = row.cumulative_refined[jr];
2770 }
2771 }
2772
2773 let assembled = if per_row_eval {
2777 let assembly_times = Array1::from_shape_fn(CIF_REFINE_SUBINTERVALS, |s| {
2784 ((s + 1) as f64) / (CIF_REFINE_SUBINTERVALS as f64)
2785 });
2786 let refined_assembled = assemble_competing_risks_cif_from_endpoints(
2787 assembly_times.view(),
2788 &cumulative_hazard_refined,
2789 )
2790 .map_err(|err| err.to_string())?;
2791 let last = CIF_REFINE_SUBINTERVALS - 1;
2792 let mut cif_user = (0..cause_count)
2793 .map(|_| Array2::<f64>::zeros((n, 1)))
2794 .collect::<Vec<_>>();
2795 let mut overall_user = Array2::<f64>::zeros((n, 1));
2796 for cause in 0..cause_count {
2797 for row in 0..n {
2798 cif_user[cause][[row, 0]] = refined_assembled.cif[cause][[row, last]];
2799 }
2800 }
2801 for row in 0..n {
2802 overall_user[[row, 0]] = refined_assembled.overall_survival[[row, last]];
2803 }
2804 CompetingRisksCifResult {
2805 cif: cif_user,
2806 overall_survival: overall_user,
2807 }
2808 } else {
2809 let assembly_times = Array1::from_vec(refined_times.clone());
2810 let refined_assembled = assemble_competing_risks_cif_from_endpoints(
2811 assembly_times.view(),
2812 &cumulative_hazard_refined,
2813 )
2814 .map_err(|err| err.to_string())?;
2815 let mut cif_user = (0..cause_count)
2817 .map(|_| Array2::<f64>::zeros((n, t_cols)))
2818 .collect::<Vec<_>>();
2819 let mut overall_user = Array2::<f64>::zeros((n, t_cols));
2820 for (j_user, &jr) in user_time_to_refined_index.iter().enumerate() {
2821 for cause in 0..cause_count {
2822 for row in 0..n {
2823 cif_user[cause][[row, j_user]] = refined_assembled.cif[cause][[row, jr]];
2824 }
2825 }
2826 for row in 0..n {
2827 overall_user[[row, j_user]] = refined_assembled.overall_survival[[row, jr]];
2828 }
2829 }
2830 CompetingRisksCifResult {
2831 cif: cif_user,
2832 overall_survival: overall_user,
2833 }
2834 };
2835 if assembled.cif.len() != cause_count {
2836 return Err(format!(
2837 "competing-risks CIF assembly produced {} endpoint matrices, expected {cause_count}",
2838 assembled.cif.len()
2839 )
2840 .into());
2841 }
2842 let cif = assembled.cif;
2843 let overall_survival = assembled.overall_survival;
2844 let times_out = if per_row_eval {
2845 age_exit.to_vec()
2846 } else {
2847 eval_times
2848 };
2849 Ok(CompetingRisksPredictResult {
2850 times: times_out,
2851 endpoint_names,
2852 hazard,
2853 survival,
2854 cumulative_hazard,
2855 cif,
2856 overall_survival,
2857 linear_predictor,
2858 likelihood_mode: saved_likelihood_mode,
2859 covariance_source: None,
2860 hazard_se: None,
2861 survival_se: None,
2862 cumulative_hazard_se: None,
2863 cif_se: None,
2864 overall_survival_se: None,
2865 eta_se: None,
2866 })
2867}
2868
2869fn saved_cause_specific_timewiggles(
2870 model: &SavedModel,
2871 fit: &UnifiedFitResult,
2872 cause_count: usize,
2873) -> Result<Vec<Option<SavedBaselineTimeWiggleRuntime>>, SurvivalPredictError> {
2874 let has_metadata = model.baseline_timewiggle_knots.is_some()
2875 || model.baseline_timewiggle_degree.is_some()
2876 || model.baseline_timewiggle_penalty_orders.is_some()
2877 || model.baseline_timewiggle_double_penalty.is_some()
2878 || model.beta_baseline_timewiggle_by_cause.is_some();
2879 if !has_metadata {
2880 return Ok(vec![None; cause_count]);
2881 }
2882 let knots = model.baseline_timewiggle_knots.clone().ok_or_else(|| {
2883 "joint cause-specific survival missing baseline_timewiggle_knots".to_string()
2884 })?;
2885 let degree = model.baseline_timewiggle_degree.ok_or_else(|| {
2886 "joint cause-specific survival missing baseline_timewiggle_degree".to_string()
2887 })?;
2888 let penalty_orders = model
2889 .baseline_timewiggle_penalty_orders
2890 .clone()
2891 .ok_or_else(|| {
2892 "joint cause-specific survival missing baseline_timewiggle_penalty_orders".to_string()
2893 })?;
2894 let double_penalty = model.baseline_timewiggle_double_penalty.ok_or_else(|| {
2895 "joint cause-specific survival missing baseline_timewiggle_double_penalty".to_string()
2896 })?;
2897 let by_cause = model
2898 .beta_baseline_timewiggle_by_cause
2899 .as_ref()
2900 .ok_or_else(|| {
2901 "joint cause-specific survival missing beta_baseline_timewiggle_by_cause".to_string()
2902 })?;
2903 if by_cause.len() != cause_count {
2904 return Err(SurvivalPredictError::IncompatibleSchema {
2905 reason: format!(
2906 "joint cause-specific survival has {} timewiggle coefficient blocks, expected {cause_count}",
2907 by_cause.len()
2908 ),
2909 });
2910 }
2911 for (cause, (block, beta_w)) in fit.blocks.iter().zip(by_cause).enumerate() {
2912 if beta_w.len() > block.beta.len() {
2913 return Err(SurvivalPredictError::IncompatibleSchema {
2914 reason: format!(
2915 "joint cause-specific survival cause {} timewiggle beta has length {}, but endpoint beta has {} coefficients",
2916 cause + 1,
2917 beta_w.len(),
2918 block.beta.len()
2919 ),
2920 });
2921 }
2922 }
2923 Ok(by_cause
2924 .iter()
2925 .map(|beta| {
2926 Some(SavedBaselineTimeWiggleRuntime {
2927 knots: knots.clone(),
2928 degree,
2929 penalty_orders: penalty_orders.clone(),
2930 double_penalty,
2931 beta: beta.clone(),
2932 })
2933 })
2934 .collect())
2935}
2936
2937struct MarginalSlopePredictContext {
2945 predictor: BernoulliMarginalSlopePredictor,
2946 beta_time: Array1<f64>,
2948 beta_marginal: Array1<f64>,
2950 saved_timewiggle: Option<SavedBaselineTimeWiggleRuntime>,
2951 cov_design: DesignMatrix,
2953 logslope_design: DesignMatrix,
2958 logslope_cov_design: DesignMatrix,
2962 logslope_time_basis: Option<SurvivalCovariateTimeBasis>,
2966 cov_eta: Array1<f64>,
2969 z_raw: Array1<f64>,
2972 noise_offset: Array1<f64>,
2975}
2976
2977fn design_row_owned(
2978 design: &DesignMatrix,
2979 row: usize,
2980 context: &str,
2981) -> Result<Array1<f64>, SurvivalPredictError> {
2982 let chunk = design
2983 .try_row_chunk(row..row + 1)
2984 .map_err(|e| format!("{context}: {e}"))?;
2985 Ok(chunk.row(0).to_owned())
2986}
2987
2988fn build_marginal_slope_predict_context(
2989 model: &SavedModel,
2990 data: ArrayView2<'_, f64>,
2991 col_map: &HashMap<String, usize>,
2992 training_headers: Option<&Vec<String>>,
2993 cov_design: &DesignMatrix,
2994 primary_offset: &Array1<f64>,
2995 noise_offset: &Array1<f64>,
2996 time_build: &SurvivalTimeBuildOutput,
2997 eta_offset_entry: &Array1<f64>,
2998 eta_offset_exit: &Array1<f64>,
2999 derivative_offset_exit: &Array1<f64>,
3000 age_exit: &Array1<f64>,
3001) -> Result<MarginalSlopePredictContext, SurvivalPredictError> {
3002 let z_name = model
3003 .z_column
3004 .as_ref()
3005 .ok_or_else(|| "saved survival marginal-slope model missing z_column".to_string())?;
3006 let z_col = resolve_role_col(col_map, z_name, "z")?;
3007 let z_raw = data.column(z_col).to_owned();
3008
3009 let logslopespec = resolve_termspec_for_prediction(
3010 &model.resolved_termspec_logslope.as_ref().cloned(),
3011 training_headers,
3012 col_map,
3013 "resolved_termspec_logslope",
3014 )?;
3015 let logslope_clipped = model.axis_clip_to_training_ranges(data, col_map);
3016 let logslope_input = logslope_clipped.as_ref().map_or(data, |arr| arr.view());
3017 let logslope_design = build_term_collection_design(logslope_input, &logslopespec)
3018 .map_err(|e| format!("failed to build survival marginal-slope logslope design: {e}"))?;
3019 let effective_noise_offset = logslope_design
3020 .compose_offset(
3021 noise_offset.view(),
3022 "survival marginal-slope logslope block",
3023 )
3024 .map_err(|error| error.to_string())?;
3025 let logslope_time_basis = model.logslope_time_basis.clone();
3031 let logslope_cov_design = logslope_design.design.clone();
3032 let logslope_exit_design = match logslope_time_basis.as_ref() {
3033 None => logslope_cov_design.clone(),
3034 Some(time_basis) => crate::survival::construction::replay_logslope_time_margin_design(
3035 age_exit.view(),
3036 time_basis,
3037 &logslope_cov_design,
3038 )?,
3039 };
3040
3041 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
3042 let (predictor, _pred_input, _predictor_fit) = build_saved_survival_marginal_slope_predictor(
3043 model,
3044 &fit_saved,
3045 z_name,
3046 &z_raw,
3047 cov_design,
3048 &logslope_exit_design,
3049 time_build,
3050 eta_offset_entry,
3051 eta_offset_exit,
3052 derivative_offset_exit,
3053 primary_offset,
3054 &effective_noise_offset,
3055 )?;
3056
3057 let blocks = &fit_saved.blocks;
3058 if blocks.len() < 3 {
3059 return Err(SurvivalPredictError::IncompatibleSchema {
3060 reason: format!(
3061 "saved survival marginal-slope model requires at least 3 blocks [time, marginal, slope], got {}",
3062 blocks.len()
3063 ),
3064 });
3065 }
3066 let beta_time = blocks[0].beta.clone();
3067 let beta_marginal = blocks[1].beta.clone();
3068 let saved_runtime = model.saved_prediction_runtime()?;
3069 let saved_timewiggle = saved_runtime.baseline_time_wiggle.clone();
3070
3071 let cov_eta = cov_design.dot(&beta_marginal);
3074
3075 Ok(MarginalSlopePredictContext {
3076 predictor,
3077 beta_time,
3078 beta_marginal,
3079 saved_timewiggle,
3080 cov_design: cov_design.clone(),
3081 logslope_design: logslope_exit_design,
3082 logslope_cov_design,
3083 logslope_time_basis,
3084 cov_eta,
3085 z_raw,
3086 noise_offset: effective_noise_offset,
3087 })
3088}
3089
3090fn evaluate_marginal_slope_row(
3101 row_index: usize,
3102 ctx: &MarginalSlopePredictContext,
3103 row_time: &SurvivalTimeBuildOutput,
3104 r_eta_exit: &Array1<f64>,
3105 r_deriv_exit: &Array1<f64>,
3106 primary_offset_row: f64,
3107 evaluation_time: f64,
3108) -> Result<(f64, f64, f64), SurvivalPredictError> {
3109 let beta_time = &ctx.beta_time;
3110 let p_time_base = row_time.x_exit_time.ncols();
3111 let p_timewiggle = ctx
3112 .saved_timewiggle
3113 .as_ref()
3114 .map_or(0, |runtime| runtime.beta.len());
3115 if beta_time.len() != p_time_base + p_timewiggle {
3116 let hint = stale_weibull_time_basis_hint(
3117 &row_time.basisname,
3118 beta_time.len() == p_time_base + p_timewiggle + 1,
3119 );
3120 return Err(SurvivalPredictError::IncompatibleSchema {
3121 reason: format!(
3122 "saved survival marginal-slope time coefficient mismatch: beta has {} entries but expected base={} plus timewiggle={}{hint}",
3123 beta_time.len(),
3124 p_time_base,
3125 p_timewiggle
3126 ),
3127 });
3128 }
3129 let beta_time_base = beta_time.slice(s![..p_time_base]).to_owned();
3130
3131 let q_exit_base = row_time.x_exit_time.dot(&beta_time_base)[0]
3136 + ctx.cov_eta[row_index]
3137 + r_eta_exit[0]
3138 + primary_offset_row;
3139 let qd_exit_base = row_time.x_derivative_time.dot(&beta_time_base)[0] + r_deriv_exit[0];
3140
3141 let (qd_with_wiggle, exit_wiggle_design) = if let Some(runtime) = ctx.saved_timewiggle.as_ref()
3145 {
3146 let knots = Array1::from_vec(runtime.knots.clone());
3147 let beta_w = beta_time.slice(s![p_time_base..]).to_owned();
3148 let eta_exit_row = Array1::from_elem(1, q_exit_base);
3149 let deriv_row = Array1::from_elem(1, qd_exit_base);
3150 let exit_design = monotone_wiggle_basis_with_derivative_order(
3155 eta_exit_row.view(),
3156 &knots,
3157 runtime.degree,
3158 0,
3159 )?;
3160 let derivative_design = build_survival_timewiggle_derivative_design(
3161 &eta_exit_row,
3162 &deriv_row,
3163 &knots,
3164 runtime.degree,
3165 )?;
3166 (
3167 qd_exit_base + derivative_design.dot(&beta_w)[0],
3168 Some(exit_design),
3169 )
3170 } else {
3171 (qd_exit_base, None)
3172 };
3173
3174 let cov_dim = ctx.beta_marginal.len();
3183 let q_design_ncols = p_time_base + p_timewiggle + cov_dim;
3184 let mut q_design_full = Array2::<f64>::zeros((1, q_design_ncols));
3185 q_design_full
3186 .slice_mut(s![.., ..p_time_base])
3187 .assign(&row_time.x_exit_time.to_dense());
3188 if let Some(exit_w) = exit_wiggle_design.as_ref() {
3189 q_design_full
3190 .slice_mut(s![.., p_time_base..p_time_base + p_timewiggle])
3191 .assign(exit_w);
3192 }
3193 if cov_dim > 0 {
3194 let cov_row = design_row_owned(
3195 &ctx.cov_design,
3196 row_index,
3197 "survival marginal covariate row",
3198 )?;
3199 q_design_full
3200 .slice_mut(s![.., p_time_base + p_timewiggle..])
3201 .row_mut(0)
3202 .assign(&cov_row);
3203 }
3204
3205 let logslope_row = match ctx.logslope_time_basis.as_ref() {
3217 None => design_row_owned(
3218 &ctx.logslope_design,
3219 row_index,
3220 "survival marginal logslope row",
3221 )?,
3222 Some(time_basis) => {
3223 let cov_row = design_row_owned(
3224 &ctx.logslope_cov_design,
3225 row_index,
3226 "survival marginal logslope covariate row",
3227 )?;
3228 let cov_row_design = DesignMatrix::from(
3229 cov_row
3230 .into_shape_with_order((1, ctx.logslope_cov_design.ncols()))
3231 .map_err(|e| format!("survival marginal logslope covariate row shape: {e}"))?,
3232 );
3233 let tensored = crate::survival::construction::replay_logslope_time_margin_design(
3234 Array1::from_elem(1, evaluation_time).view(),
3235 time_basis,
3236 &cov_row_design,
3237 )?;
3238 design_row_owned(&tensored, 0, "survival marginal logslope row at t")?
3239 }
3240 };
3241 let mut logslope_design_2d = Array2::<f64>::zeros((1, logslope_row.len()));
3242 logslope_design_2d.row_mut(0).assign(&logslope_row);
3243
3244 let pred_input = PredictInput {
3245 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(q_design_full)),
3246 offset: Array1::from_elem(1, r_eta_exit[0] + primary_offset_row),
3247 design_noise: Some(DesignMatrix::Dense(
3248 gam_linalg::matrix::DenseDesignMatrix::from(logslope_design_2d),
3249 )),
3250 offset_noise: Some(Array1::from_elem(1, ctx.noise_offset[row_index])),
3251 auxiliary_scalar: Some(Array1::from_elem(1, ctx.z_raw[row_index])),
3252 auxiliary_matrix: None,
3253 };
3254
3255 let (eta_arr, deta_dq_arr) = ctx
3259 .predictor
3260 .predict_eta_and_q_chain(&pred_input)
3261 .map_err(|e| format!("saved survival marginal-slope predictor eta failed: {e}"))?;
3262 let eta = eta_arr[0];
3263 let eta_derivative = marginal_slope_index_derivative_at_horizon(deta_dq_arr[0], qd_with_wiggle);
3283 let (cum, haz) = probit_survival_hazard_components(eta, eta_derivative)?;
3284 Ok((eta, cum, haz))
3285}
3286
3287#[inline]
3300fn marginal_slope_index_derivative_at_horizon(deta_dq: f64, qd_with_wiggle: f64) -> f64 {
3301 let eta_derivative = deta_dq * qd_with_wiggle;
3302 if eta_derivative.is_finite() {
3303 eta_derivative.max(0.0)
3304 } else {
3305 eta_derivative
3306 }
3307}
3308
3309#[inline]
3310fn probit_survival_hazard_components(
3311 eta: f64,
3312 eta_derivative: f64,
3313) -> Result<(f64, f64), SurvivalPredictError> {
3314 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative >= 0.0) {
3315 return Err(SurvivalPredictError::NumericalFailure {
3316 reason: format!(
3317 "saved survival marginal-slope prediction produced invalid survival index derivative: eta={eta}, eta_t={eta_derivative}"
3318 ),
3319 });
3320 }
3321
3322 let (log_survival, mills_ratio) = signed_probit_logcdf_and_mills_ratio(-eta);
3327 let cumulative_hazard = -log_survival;
3328 let hazard = if eta_derivative == 0.0 {
3329 0.0
3330 } else {
3331 mills_ratio * eta_derivative
3332 };
3333 if !(cumulative_hazard >= 0.0 && hazard >= 0.0) {
3340 return Err(SurvivalPredictError::NumericalFailure {
3341 reason: format!(
3342 "saved survival marginal-slope prediction produced invalid survival components: eta={eta}, eta_t={eta_derivative}, log_survival={log_survival}, hazard={hazard}"
3343 ),
3344 });
3345 }
3346 Ok((cumulative_hazard, hazard))
3347}
3348
3349fn evaluate_rp_row(
3350 model: &SavedModel,
3351 row_time: &SurvivalTimeBuildOutput,
3352 cov_row: &Array1<f64>,
3353 eta_time_offset_row: f64,
3354 derivative_time_offset_row: f64,
3355 primary_offset_row: f64,
3356) -> Result<(f64, f64, f64), SurvivalPredictError> {
3357 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
3358 let saved_runtime = model.saved_prediction_runtime()?;
3359 evaluate_rp_row_with_beta(
3360 &fit_saved.beta,
3361 saved_runtime.baseline_time_wiggle.as_ref(),
3362 row_time,
3363 cov_row,
3364 eta_time_offset_row,
3365 derivative_time_offset_row,
3366 primary_offset_row,
3367 )
3368}
3369
3370fn evaluate_rp_row_with_beta(
3371 beta: &Array1<f64>,
3372 saved_timewiggle: Option<&SavedBaselineTimeWiggleRuntime>,
3373 row_time: &SurvivalTimeBuildOutput,
3374 cov_row: &Array1<f64>,
3375 eta_time_offset_row: f64,
3376 derivative_time_offset_row: f64,
3377 primary_offset_row: f64,
3378) -> Result<(f64, f64, f64), SurvivalPredictError> {
3379 let p_time = row_time.x_exit_time.ncols();
3380 let p_timewiggle = saved_timewiggle.map_or(0, |runtime| runtime.beta.len());
3381 let p_cov = cov_row.len();
3382 let p = p_time + p_timewiggle + p_cov;
3383 if beta.len() != p {
3384 let hint = stale_weibull_time_basis_hint(&row_time.basisname, beta.len() == p + 1);
3385 return Err(SurvivalPredictError::IncompatibleSchema {
3386 reason: format!(
3387 "survival RP coefficient mismatch: beta has {} entries but design has {} columns{hint}",
3388 beta.len(),
3389 p
3390 ),
3391 });
3392 }
3393 let mut x_exit = Array2::<f64>::zeros((1, p));
3394 if p_time > 0 {
3395 x_exit
3396 .slice_mut(s![.., ..p_time])
3397 .assign(&row_time.x_exit_time.to_dense());
3398 }
3399 let offset_derivative_component = derivative_time_offset_row;
3400 let mut eta_derivative = offset_derivative_component;
3401 let mut time_derivative_component = 0.0_f64;
3402 if p_time > 0 {
3403 time_derivative_component = row_time
3404 .x_derivative_time
3405 .dot(&beta.slice(s![..p_time]).to_owned())[0];
3406 eta_derivative += time_derivative_component;
3407 }
3408 let mut wiggle_derivative_component = 0.0_f64;
3409 if let Some(runtime) = saved_timewiggle {
3410 let knots = Array1::from_vec(runtime.knots.clone());
3411 let beta_w = beta.slice(s![p_time..p_time + p_timewiggle]).to_owned();
3412 let eta_exit_row = Array1::from_elem(1, eta_time_offset_row);
3413 let derivative_exit_row = Array1::from_elem(1, derivative_time_offset_row);
3414 let exit_design = monotone_wiggle_basis_with_derivative_order(
3415 eta_exit_row.view(),
3416 &knots,
3417 runtime.degree,
3418 0,
3419 )?;
3420 if exit_design.ncols() != p_timewiggle {
3421 return Err(SurvivalPredictError::IncompatibleSchema {
3422 reason: format!(
3423 "survival RP timewiggle design mismatch: rebuilt {} columns but runtime expects {}",
3424 exit_design.ncols(),
3425 p_timewiggle
3426 ),
3427 });
3428 }
3429 x_exit
3430 .slice_mut(s![.., p_time..p_time + p_timewiggle])
3431 .assign(&exit_design);
3432 let derivative_design = build_survival_timewiggle_derivative_design(
3433 &eta_exit_row,
3434 &derivative_exit_row,
3435 &knots,
3436 runtime.degree,
3437 )?;
3438 wiggle_derivative_component = derivative_design.dot(&beta_w)[0];
3439 eta_derivative += wiggle_derivative_component;
3440 }
3441 if !(eta_derivative.is_finite() && eta_derivative >= 0.0) {
3447 let time_beta = beta.slice(s![..p_time]);
3448 let beta_min = time_beta.iter().copied().fold(f64::INFINITY, f64::min);
3449 let beta_max = time_beta.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3450 let dtime = row_time.x_derivative_time.to_dense();
3451 let dmin = dtime.iter().copied().fold(f64::INFINITY, f64::min);
3452 let dmax = dtime.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3453 log::info!(
3454 "[rp-predict/eta_t-refusal] eta_t={eta_derivative:.12e} = offset({offset_derivative_component:.12e}) + time({time_derivative_component:.12e}) + wiggle({wiggle_derivative_component:.12e}); p_time={p_time} p_timewiggle={p_timewiggle} p_cov={p_cov} time_beta=[{beta_min:.6e},{beta_max:.6e}] x_derivative_time=[{dmin:.6e},{dmax:.6e}] has_wiggle={}",
3455 saved_timewiggle.is_some(),
3456 );
3457 }
3458 if p_cov > 0 {
3459 x_exit
3460 .slice_mut(s![
3461 ..,
3462 (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov)
3463 ])
3464 .row_mut(0)
3465 .assign(cov_row);
3466 }
3467 let offset_view = Array1::from_elem(1, eta_time_offset_row + primary_offset_row);
3468 let likelihood = LikelihoodSpec::new(
3469 ResponseFamily::RoystonParmar,
3470 InverseLink::Standard(StandardLink::Identity),
3471 );
3472 let eta =
3473 predict_royston_parmar_eta(x_exit.view(), beta.view(), offset_view.view(), &likelihood)?[0];
3474 let (cum, haz) = royston_parmar_survival_hazard_components(eta, eta_derivative)?;
3475 Ok((eta, cum, haz))
3476}
3477
3478fn predict_royston_parmar_eta<X>(
3479 x: X,
3480 beta: ndarray::ArrayView1<'_, f64>,
3481 offset: ndarray::ArrayView1<'_, f64>,
3482 likelihood: &LikelihoodSpec,
3483) -> Result<Array1<f64>, SurvivalPredictError>
3484where
3485 X: Into<DesignMatrix>,
3486{
3487 if !matches!(likelihood.response, ResponseFamily::RoystonParmar)
3488 || !matches!(
3489 likelihood.link,
3490 InverseLink::Standard(StandardLink::Identity)
3491 )
3492 {
3493 return Err(SurvivalPredictError::UnsupportedConfiguration {
3494 reason: "survival prediction requires RoystonParmar with identity link".to_string(),
3495 });
3496 }
3497 let x = x.into();
3498 if x.nrows() != offset.len() || x.ncols() != beta.len() {
3499 return Err(SurvivalPredictError::IncompatibleSchema {
3500 reason: format!(
3501 "survival prediction design dimensions disagree: design is {}x{}, beta has length {}, offset has length {}",
3502 x.nrows(),
3503 x.ncols(),
3504 beta.len(),
3505 offset.len()
3506 ),
3507 });
3508 }
3509 let mut eta = x.matrixvectormultiply(&beta.to_owned());
3510 eta += &offset;
3511 Ok(eta)
3512}
3513
3514#[inline]
3515fn royston_parmar_survival_hazard_components(
3516 eta: f64,
3517 eta_derivative: f64,
3518) -> Result<(f64, f64), SurvivalPredictError> {
3519 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative >= 0.0) {
3535 return Err(SurvivalPredictError::NumericalFailure {
3536 reason: format!(
3537 "saved Royston-Parmar survival prediction produced invalid log-cumulative-hazard derivative: eta={eta}, eta_t={eta_derivative}"
3538 ),
3539 });
3540 }
3541 let cumulative_hazard = eta.exp();
3542 let hazard = if eta_derivative == 0.0 {
3548 0.0
3549 } else {
3550 cumulative_hazard * eta_derivative
3551 };
3552 if !(cumulative_hazard >= 0.0 && hazard >= 0.0) {
3561 return Err(SurvivalPredictError::NumericalFailure {
3562 reason: format!(
3563 "saved Royston-Parmar survival prediction produced invalid survival components: eta={eta}, eta_t={eta_derivative}, cumulative_hazard={cumulative_hazard}, hazard={hazard}"
3564 ),
3565 });
3566 }
3567 Ok((cumulative_hazard, hazard))
3568}
3569
3570fn predict_survival_location_scale_batch(
3579 model: &SavedModel,
3580 age_entry: &Array1<f64>,
3581 age_exit: &Array1<f64>,
3582 cov_design: &gam_terms::smooth::TermCollectionDesign,
3583 primary_offset: &Array1<f64>,
3584 noise_offset: &Array1<f64>,
3585 training_headers: Option<&Vec<String>>,
3586 col_map: &HashMap<String, usize>,
3587 data: ArrayView2<'_, f64>,
3588 time_grid: Option<&[f64]>,
3589 with_uncertainty: bool,
3590 covariance_mode: SurvivalPredictionCovarianceMode,
3591) -> Result<SurvivalPredictResult, String> {
3592 use crate::survival::construction::evaluate_survival_time_basis_row;
3593 use crate::survival::location_scale::{
3594 SurvivalLocationScalePredictInput, predict_survival_location_scale,
3595 predict_survival_location_scalewith_uncertainty, replay_survival_covariate_channels,
3596 };
3597 use gam_linalg::matrix::DesignMatrix;
3598
3599 let n = age_entry.len();
3600 let per_row_eval = time_grid.is_none();
3601 let eval_times: Vec<f64> = match time_grid {
3602 Some(grid) => {
3603 if grid.is_empty() {
3604 return Err("survival time_grid must contain at least one time".to_string());
3605 }
3606 for (idx, &t) in grid.iter().enumerate() {
3607 if !t.is_finite() || t < 0.0 {
3608 return Err(format!(
3609 "survival time_grid requires finite non-negative times (index {idx})",
3610 ));
3611 }
3612 }
3613 grid.to_vec()
3614 }
3615 None => Vec::new(),
3616 };
3617 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
3618 let eval_width = if per_row_eval { 1 } else { t_cols + 1 };
3619 let saved_likelihood_mode = SurvivalLikelihoodMode::LocationScale;
3620 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
3621 let saved_fit = saved_survival_location_scale_fit_result(model)?;
3622 let saved_structure = model
3626 .survival_location_scale_structure
3627 .as_ref()
3628 .ok_or_else(|| {
3629 "saved location-scale survival model is missing exact replay structure".to_string()
3630 })?;
3631 let reduced_parametric_aft = matches!(
3632 saved_structure.time_parameterization,
3633 crate::survival::location_scale::SurvivalLocationScaleTimeParameterization::ReducedParametricAft
3634 );
3635 let time_cfg = load_survival_time_basis_config_from_model(model)?;
3636 let mut time_build = build_survival_time_basis(age_entry, age_exit, time_cfg.clone(), None)?;
3637 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
3638 &time_build.basisname,
3639 time_build.degree,
3640 time_build.knots.as_ref(),
3641 time_build.keep_cols.as_ref(),
3642 time_build.smooth_lambda,
3643 )?;
3644 let time_anchor = model
3645 .survival_time_anchor
3646 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
3647 let time_anchor_row = evaluate_survival_time_basis_row(time_anchor, &resolved_time_cfg)?;
3648 center_survival_time_designs_at_anchor(
3649 &mut time_build.x_entry_time,
3650 &mut time_build.x_exit_time,
3651 &time_anchor_row,
3652 )?;
3653 if !model.has_baseline_time_wiggle() && !reduced_parametric_aft {
3657 require_structural_survival_time_basis(&time_build.basisname, "saved survival sampling")?;
3658 }
3659 let saved_inverse_link = resolve_survival_inverse_link_from_saved(model)?;
3660 let (eval_entry, eval_exit) = if per_row_eval {
3661 (age_entry.clone(), age_exit.clone())
3662 } else {
3663 let total = n * eval_width;
3664 let mut entry = Array1::<f64>::zeros(total);
3665 let mut exit = Array1::<f64>::zeros(total);
3666 {
3667 use rayon::iter::{IntoParallelIterator, ParallelIterator};
3668 let pairs: Vec<(f64, f64)> = (0..total)
3669 .into_par_iter()
3670 .map(|k| {
3671 let i = k / eval_width;
3672 let col = k % eval_width;
3673 let t = if col < t_cols {
3674 eval_times[col]
3675 } else {
3676 age_exit[i]
3677 };
3678 (age_entry[i].min(t), t)
3679 })
3680 .collect();
3681 for (k, (t0, t1)) in pairs.into_iter().enumerate() {
3682 entry[k] = t0;
3683 exit[k] = t1;
3684 }
3685 }
3686 (entry, exit)
3687 };
3688 let mut time_build =
3689 build_survival_time_basis(&eval_entry, &eval_exit, time_cfg.clone(), None)?;
3690 center_survival_time_designs_at_anchor(
3691 &mut time_build.x_entry_time,
3692 &mut time_build.x_exit_time,
3693 &time_anchor_row,
3694 )?;
3695 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
3696 build_survival_time_offsets_for_likelihood(
3697 &eval_entry,
3698 &eval_exit,
3699 &baseline_cfg,
3700 saved_likelihood_mode,
3701 Some(&saved_inverse_link),
3702 )?;
3703 add_survival_time_derivative_guard_offset(
3704 &eval_entry,
3705 &eval_exit,
3706 time_anchor,
3707 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
3708 &mut eta_offset_entry,
3709 &mut eta_offset_exit,
3710 &mut derivative_offset_exit,
3711 )?;
3712 if reduced_parametric_aft {
3713 eta_offset_exit = Array1::<f64>::zeros(eval_exit.len());
3725 }
3726
3727 let saved_timewiggle_runtime = model.saved_baseline_time_wiggle()?;
3728
3729 let threshold_design = cov_design;
3735 let log_sigmaspec = resolve_termspec_for_prediction(
3736 &model.resolved_termspec_noise,
3737 training_headers,
3738 col_map,
3739 "resolved_termspec_noise",
3740 )?;
3741 let sigma_clipped = model.axis_clip_to_training_ranges(data, col_map);
3742 let sigma_input = sigma_clipped.as_ref().map_or(data, |arr| arr.view());
3743 let raw_sigma_design =
3744 gam_terms::smooth::build_term_collection_design(sigma_input, &log_sigmaspec)
3745 .map_err(|err| format!("failed to build survival log-sigma design: {err}"))?;
3746 let effective_noise_offset = raw_sigma_design
3747 .compose_offset(
3748 noise_offset.view(),
3749 "survival location-scale log-sigma block",
3750 )
3751 .map_err(|error| error.to_string())?;
3752
3753 let x_time_exit_dense = time_build
3754 .x_exit_time
3755 .try_to_dense_by_chunks("survival location-scale prediction time-exit design")?;
3756 let total_rows = eval_exit.len();
3757 let x_time_exit = if let Some(runtime) = saved_timewiggle_runtime.as_ref() {
3758 let mut full =
3759 Array2::<f64>::zeros((total_rows, x_time_exit_dense.ncols() + runtime.beta.len()));
3760 full.slice_mut(s![.., 0..x_time_exit_dense.ncols()])
3761 .assign(&x_time_exit_dense);
3762 full
3763 } else {
3764 x_time_exit_dense
3765 };
3766
3767 let repeat_rows =
3768 |matrix: &DesignMatrix, label: &str| -> Result<DesignMatrix, SurvivalPredictError> {
3769 if per_row_eval {
3770 return Ok(matrix.clone());
3771 }
3772 let dense = matrix.try_to_dense_by_chunks(label)?;
3773 let mut repeated = Array2::<f64>::zeros((total_rows, dense.ncols()));
3774 use rayon::iter::{IntoParallelIterator, ParallelIterator};
3775 let rows: Vec<Vec<f64>> = (0..total_rows)
3776 .into_par_iter()
3777 .map(|k| dense.row(k / eval_width).to_vec())
3778 .collect();
3779 for (k, row) in rows.into_iter().enumerate() {
3780 for (j, value) in row.into_iter().enumerate() {
3781 repeated[[k, j]] = value;
3782 }
3783 }
3784 Ok(DesignMatrix::from(repeated))
3785 };
3786 let expand_vector = |values: &Array1<f64>| -> Array1<f64> {
3787 if per_row_eval {
3788 values.clone()
3789 } else {
3790 Array1::from_shape_fn(total_rows, |k| values[k / eval_width])
3791 }
3792 };
3793 if saved_structure.threshold_time_basis.is_some()
3794 && threshold_design
3795 .affine_offset
3796 .iter()
3797 .any(|value| *value != 0.0)
3798 {
3799 return Err(
3800 "saved time-varying survival threshold cannot carry a non-zero smooth anchor"
3801 .to_string(),
3802 );
3803 }
3804 if saved_structure.log_sigma_time_basis.is_some()
3805 && raw_sigma_design
3806 .affine_offset
3807 .iter()
3808 .any(|value| *value != 0.0)
3809 {
3810 return Err(
3811 "saved time-varying survival log-sigma cannot carry a non-zero smooth anchor"
3812 .to_string(),
3813 );
3814 }
3815 let threshold_base_matrix = repeat_rows(
3816 &threshold_design.design,
3817 "survival location-scale prediction threshold design",
3818 )?;
3819 let raw_sigma_base_matrix = repeat_rows(
3820 &raw_sigma_design.design,
3821 "survival location-scale prediction log-sigma design",
3822 )?;
3823 let mut threshold_replay = replay_survival_covariate_channels(
3824 &threshold_base_matrix,
3825 &expand_vector(primary_offset),
3826 &eval_entry,
3827 &eval_exit,
3828 saved_structure.threshold_time_basis.as_ref(),
3829 "survival location-scale threshold",
3830 )?;
3831 let sigma_replay = replay_survival_covariate_channels(
3832 &raw_sigma_base_matrix,
3833 &expand_vector(&effective_noise_offset),
3834 &eval_entry,
3835 &eval_exit,
3836 saved_structure.log_sigma_time_basis.as_ref(),
3837 "survival location-scale log-sigma",
3838 )?;
3839 let link_wiggle_knots = model
3840 .linkwiggle_knots
3841 .as_ref()
3842 .map(|k| Array1::from_vec(k.clone()));
3843 let link_wiggle_degree = model.linkwiggle_degree;
3844 let time_wiggle_knots = saved_timewiggle_runtime
3845 .as_ref()
3846 .map(|w| Array1::from_vec(w.knots.clone()));
3847 let time_wiggle_degree = saved_timewiggle_runtime.as_ref().map(|w| w.degree);
3848 let time_wiggle_ncols = saved_timewiggle_runtime
3849 .as_ref()
3850 .map_or(0, |w| w.beta.len());
3851
3852 if reduced_parametric_aft {
3861 for (slot, &t) in threshold_replay.offset.iter_mut().zip(eval_exit.iter()) {
3862 *slot -= t
3863 .max(crate::survival::construction::SURVIVAL_TIME_FLOOR)
3864 .ln();
3865 }
3866 }
3867 let pred_input = SurvivalLocationScalePredictInput {
3872 x_time_exit,
3873 eta_time_offset_exit: eta_offset_exit.clone(),
3874 time_wiggle_knots: time_wiggle_knots.clone(),
3875 time_wiggle_degree,
3876 time_wiggle_ncols,
3877 x_threshold: threshold_replay.design_exit.clone(),
3878 eta_threshold_offset: threshold_replay.offset.clone(),
3879 x_log_sigma: sigma_replay.design_exit.clone(),
3880 eta_log_sigma_offset: sigma_replay.offset.clone(),
3881 x_link_wiggle: None,
3882 link_wiggle_knots: link_wiggle_knots.clone(),
3883 link_wiggle_degree,
3884 inverse_link: saved_inverse_link.clone(),
3885 };
3886
3887 let (eta_full, survival_prob_full, response_se_full, eta_se_full): (
3890 Array1<f64>,
3891 Array1<f64>,
3892 Option<Array1<f64>>,
3893 Option<Array1<f64>>,
3894 ) = if with_uncertainty {
3895 let cov = match select_survival_prediction_covariance(
3901 saved_fit.beta_covariance(),
3902 saved_fit.beta_covariance_corrected(),
3903 covariance_mode,
3904 ) {
3905 Ok(cov) => cov,
3906 Err(SurvivalPredictError::PosteriorCovariance { reason })
3907 if covariance_mode == SurvivalPredictionCovarianceMode::Conditional =>
3908 {
3909 return Err(format!(
3910 "survival location-scale uncertainty: {reason}; refit with the \
3911 current CLI / library to populate beta_covariance"
3912 ));
3913 }
3914 Err(err) => return Err(String::from(err)),
3915 };
3916 let unc = predict_survival_location_scalewith_uncertainty(
3917 &pred_input,
3918 &saved_fit,
3919 cov,
3920 false,
3921 true,
3922 )
3923 .map_err(|err| format!("survival location-scale uncertainty predict failed: {err}"))?;
3924 let response_se = unc.response_standard_error.ok_or_else(|| {
3925 "survival location-scale uncertainty: response_standard_error \
3926 missing despite include_response_sd=true"
3927 .to_string()
3928 })?;
3929 (
3930 unc.eta,
3931 unc.survival_prob,
3932 Some(response_se),
3933 Some(unc.eta_standard_error),
3934 )
3935 } else {
3936 let pred = predict_survival_location_scale(&pred_input, &saved_fit)
3937 .map_err(|err| format!("survival location-scale predict failed: {err}"))?;
3938 (pred.eta, pred.survival_prob, None, None)
3939 };
3940
3941 let beta_threshold = saved_fit.beta_threshold();
3942 let beta_log_sigma = saved_fit.beta_log_sigma();
3943 let eta_threshold = threshold_replay
3944 .design_exit
3945 .matrixvectormultiply(&beta_threshold)
3946 + &threshold_replay.offset;
3947 let mut eta_threshold_derivative = threshold_replay
3948 .design_derivative_exit
3949 .as_ref()
3950 .map(|design| design.matrixvectormultiply(&beta_threshold))
3951 .unwrap_or_else(|| Array1::zeros(total_rows));
3952 if reduced_parametric_aft {
3953 for (slot, &time) in eta_threshold_derivative.iter_mut().zip(eval_exit.iter()) {
3954 *slot -= 1.0 / time.max(crate::survival::construction::SURVIVAL_TIME_FLOOR);
3955 }
3956 }
3957 let eta_log_sigma = sigma_replay
3958 .design_exit
3959 .matrixvectormultiply(&beta_log_sigma)
3960 + &sigma_replay.offset;
3961 let eta_log_sigma_derivative = sigma_replay
3962 .design_derivative_exit
3963 .as_ref()
3964 .map(|design| design.matrixvectormultiply(&beta_log_sigma))
3965 .unwrap_or_else(|| Array1::zeros(total_rows));
3966 let hdot = if reduced_parametric_aft {
3967 Array1::zeros(total_rows)
3968 } else {
3969 let x_time_derivative = time_build
3970 .x_derivative_time
3971 .try_to_dense_by_chunks("survival location-scale prediction time-derivative design")?;
3972 location_scale_eta_derivative_components(
3973 &x_time_derivative,
3974 &derivative_offset_exit,
3975 &pred_input.x_time_exit,
3976 &pred_input.eta_time_offset_exit,
3977 time_wiggle_knots.as_ref(),
3978 time_wiggle_degree,
3979 time_wiggle_ncols,
3980 &saved_fit,
3981 )?
3982 };
3983 let inv_sigma = eta_log_sigma.mapv(crate::sigma_link::exp_sigma_inverse_from_eta_scalar);
3984 let q_base = -&eta_threshold * &inv_sigma;
3985 let mut qdot =
3986 &inv_sigma * &(&eta_threshold * &eta_log_sigma_derivative - &eta_threshold_derivative);
3987 if let Some(beta_wiggle) = saved_fit.beta_link_wiggle() {
3988 let knots = link_wiggle_knots.as_ref().ok_or_else(|| {
3989 "saved location-scale link-wiggle coefficients are missing knots".to_string()
3990 })?;
3991 let degree = link_wiggle_degree.ok_or_else(|| {
3992 "saved location-scale link-wiggle coefficients are missing degree".to_string()
3993 })?;
3994 let derivative_basis = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
3995 q_base.view(),
3996 knots,
3997 degree,
3998 1,
3999 )?;
4000 if derivative_basis.ncols() != beta_wiggle.len() {
4001 return Err(format!(
4002 "saved location-scale link-wiggle derivative width mismatch: design={}, beta={}",
4003 derivative_basis.ncols(),
4004 beta_wiggle.len()
4005 ));
4006 }
4007 qdot *= &(derivative_basis.dot(&beta_wiggle) + 1.0);
4008 }
4009 let eta_derivative_full = hdot + qdot;
4010 if eta_derivative_full
4011 .iter()
4012 .any(|value| !(value.is_finite() && *value > 0.0))
4013 {
4014 return Err(
4015 "saved location-scale survival event-rate derivative must be finite and positive"
4016 .to_string(),
4017 );
4018 }
4019 let hazard_full = location_scale_hazard_from_eta_derivative(
4020 &eta_full,
4021 &eta_derivative_full,
4022 &saved_inverse_link,
4023 )?;
4024
4025 let mut survival = Array2::<f64>::zeros((n, t_cols));
4026 let mut cumulative_hazard = Array2::<f64>::zeros((n, t_cols));
4027 let mut hazard = Array2::<f64>::zeros((n, t_cols));
4028 ndarray::Zip::indexed(&mut survival)
4029 .and(&mut cumulative_hazard)
4030 .and(&mut hazard)
4031 .par_for_each(|(i, j), s, ch, h| {
4032 let query_time = if per_row_eval {
4041 age_exit[i]
4042 } else {
4043 eval_times[j]
4044 };
4045 if query_time <= 0.0 {
4046 *s = 1.0;
4047 *ch = 0.0;
4048 *h = 0.0;
4049 return;
4050 }
4051 let k = if per_row_eval { i } else { i * eval_width + j };
4052 let surv = survival_prob_full[k].clamp(SURVIVAL_PROB_MIN_FOR_LOG, 1.0);
4053 *s = surv;
4054 *ch = -surv.ln();
4055 *h = hazard_full[k];
4056 });
4057
4058 let linear_predictor = if per_row_eval {
4059 eta_full.clone()
4060 } else {
4061 Array1::from_shape_fn(n, |i| eta_full[i * eval_width + t_cols])
4062 };
4063 let times = if per_row_eval {
4064 age_exit.to_vec()
4065 } else {
4066 eval_times.clone()
4069 };
4070
4071 let survival_se = response_se_full.as_ref().map(|response_se| {
4072 let mut out = Array2::<f64>::zeros((n, t_cols));
4073 ndarray::Zip::indexed(&mut out).par_for_each(|(i, j), slot| {
4074 let query_time = if per_row_eval {
4077 age_exit[i]
4078 } else {
4079 eval_times[j]
4080 };
4081 if query_time <= 0.0 {
4082 *slot = 0.0;
4083 return;
4084 }
4085 let k = if per_row_eval { i } else { i * eval_width + j };
4086 *slot = response_se[k].max(0.0);
4087 });
4088 out
4089 });
4090 let eta_se_per_row = eta_se_full.as_ref().map(|eta_se| {
4091 if per_row_eval {
4092 eta_se.clone()
4093 } else {
4094 Array1::from_shape_fn(n, |i| eta_se[i * eval_width + t_cols])
4095 }
4096 });
4097
4098 Ok(SurvivalPredictResult {
4099 times,
4100 hazard,
4101 survival,
4102 cumulative_hazard,
4103 linear_predictor,
4104 likelihood_mode: saved_likelihood_mode,
4105 survival_se,
4106 eta_se: eta_se_per_row,
4107 covariance_source: with_uncertainty.then_some(covariance_mode),
4108 })
4109}
4110
4111pub(crate) struct LocationScaleEtaComponents {
4112 pub h: Array1<f64>,
4113 pub time_jac: Array2<f64>,
4114 pub eta_t: Array1<f64>,
4115 pub eta_ls: Array1<f64>,
4116 pub inv_sigma: Array1<f64>,
4117}
4118
4119pub(crate) struct LocationScaleTimeWarpComponents {
4120 pub(crate) h: Array1<f64>,
4121 pub(crate) time_jac: Array2<f64>,
4122 pub(crate) time_wiggle_dq: Option<Array1<f64>>,
4123}
4124
4125pub(crate) fn location_scale_time_warp_components(
4126 x_time_exit: &Array2<f64>,
4127 eta_time_offset_exit: &Array1<f64>,
4128 time_wiggle_knots: Option<&Array1<f64>>,
4129 time_wiggle_degree: Option<usize>,
4130 time_wiggle_ncols: usize,
4131 fit: &UnifiedFitResult,
4132) -> Result<LocationScaleTimeWarpComponents, String> {
4133 let n = x_time_exit.nrows();
4134 if eta_time_offset_exit.len() != n {
4135 return Err("survival location-scale time-warp row mismatch across inputs".to_string());
4136 }
4137 let beta_time = fit.beta_time();
4138 if x_time_exit.ncols() != beta_time.len() {
4139 return Err(format!(
4140 "survival location-scale time-warp design mismatch: x_exit={} beta_time={}",
4141 x_time_exit.ncols(),
4142 beta_time.len()
4143 ));
4144 }
4145
4146 let p_time_total = beta_time.len();
4147 let p_wiggle = time_wiggle_ncols.min(p_time_total);
4148 let p_base = p_time_total - p_wiggle;
4149 let beta_base = beta_time.slice(s![..p_base]).to_owned();
4150 let h_base = if p_base > 0 {
4151 x_time_exit.slice(s![.., ..p_base]).dot(&beta_base) + eta_time_offset_exit
4152 } else {
4153 eta_time_offset_exit.clone()
4154 };
4155 let mut h = h_base.clone();
4156 let mut time_jac = x_time_exit.clone();
4157 let mut time_wiggle_dq = None;
4158 if p_wiggle > 0 {
4159 if x_time_exit
4160 .slice(s![.., p_base..p_time_total])
4161 .iter()
4162 .any(|&value| value != 0.0)
4163 {
4164 return Err(
4165 "survival location-scale timewiggle prediction requires zero placeholder tail columns"
4166 .to_string(),
4167 );
4168 }
4169 let knots = time_wiggle_knots.ok_or_else(|| {
4170 "survival location-scale time-warp: timewiggle coefficients are missing knot metadata"
4171 .to_string()
4172 })?;
4173 let degree = time_wiggle_degree.ok_or_else(|| {
4174 "survival location-scale time-warp: timewiggle coefficients are missing degree metadata"
4175 .to_string()
4176 })?;
4177 let beta_w = beta_time.slice(s![p_base..p_time_total]).to_owned();
4178 let time_basis = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
4179 h_base.view(),
4180 knots,
4181 degree,
4182 0,
4183 )?;
4184 let time_basis_d1 = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
4185 h_base.view(),
4186 knots,
4187 degree,
4188 1,
4189 )?;
4190 if time_basis.ncols() != p_wiggle || time_basis_d1.ncols() != p_wiggle {
4191 return Err(format!(
4192 "survival location-scale time-warp timewiggle mismatch: value basis has {} columns, derivative basis has {}, beta has {}",
4193 time_basis.ncols(),
4194 time_basis_d1.ncols(),
4195 p_wiggle
4196 ));
4197 }
4198 let dq = time_basis_d1.dot(&beta_w) + 1.0;
4199 h = &h_base + &time_basis.dot(&beta_w);
4200 time_jac = Array2::<f64>::zeros((n, p_time_total));
4201 if p_base > 0 {
4202 let scaled_base = crate::survival::location_scale::scale_dense_rows(
4203 &x_time_exit.slice(s![.., ..p_base]).to_owned(),
4204 &dq,
4205 )?;
4206 time_jac.slice_mut(s![.., ..p_base]).assign(&scaled_base);
4207 }
4208 time_jac
4209 .slice_mut(s![.., p_base..p_time_total])
4210 .assign(&time_basis);
4211 time_wiggle_dq = Some(dq);
4212 }
4213
4214 Ok(LocationScaleTimeWarpComponents {
4215 h,
4216 time_jac,
4217 time_wiggle_dq,
4218 })
4219}
4220
4221pub(crate) fn location_scale_eta_components(
4222 x_time_exit: &Array2<f64>,
4223 eta_time_offset_exit: &Array1<f64>,
4224 time_wiggle_knots: Option<&Array1<f64>>,
4225 time_wiggle_degree: Option<usize>,
4226 time_wiggle_ncols: usize,
4227 x_threshold: &gam_linalg::matrix::DesignMatrix,
4228 eta_threshold_offset: &Array1<f64>,
4229 x_log_sigma: &gam_linalg::matrix::DesignMatrix,
4230 eta_log_sigma_offset: &Array1<f64>,
4231 fit: &UnifiedFitResult,
4232) -> Result<LocationScaleEtaComponents, String> {
4233 let n = x_time_exit.nrows();
4234 if x_threshold.nrows() != n
4235 || eta_threshold_offset.len() != n
4236 || x_log_sigma.nrows() != n
4237 || eta_log_sigma_offset.len() != n
4238 {
4239 return Err("survival location-scale eta component row mismatch across inputs".to_string());
4240 }
4241 let time_components = location_scale_time_warp_components(
4242 x_time_exit,
4243 eta_time_offset_exit,
4244 time_wiggle_knots,
4245 time_wiggle_degree,
4246 time_wiggle_ncols,
4247 fit,
4248 )?;
4249 let beta_threshold = fit.beta_threshold();
4250 let beta_log_sigma = fit.beta_log_sigma();
4251 let eta_t = x_threshold.matrixvectormultiply(&beta_threshold) + eta_threshold_offset;
4252 let eta_ls = x_log_sigma.matrixvectormultiply(&beta_log_sigma) + eta_log_sigma_offset;
4253 let inv_sigma = eta_ls.mapv(crate::sigma_link::exp_sigma_inverse_from_eta_scalar);
4254 Ok(LocationScaleEtaComponents {
4255 h: time_components.h,
4256 time_jac: time_components.time_jac,
4257 eta_t,
4258 eta_ls,
4259 inv_sigma,
4260 })
4261}
4262
4263fn location_scale_eta_derivative_components(
4264 x_time_derivative: &Array2<f64>,
4265 derivative_offset_exit: &Array1<f64>,
4266 x_time_exit: &Array2<f64>,
4267 eta_time_offset_exit: &Array1<f64>,
4268 time_wiggle_knots: Option<&Array1<f64>>,
4269 time_wiggle_degree: Option<usize>,
4270 time_wiggle_ncols: usize,
4271 fit: &UnifiedFitResult,
4272) -> Result<Array1<f64>, String> {
4273 let n = x_time_exit.nrows();
4274 if x_time_derivative.nrows() != n
4275 || derivative_offset_exit.len() != n
4276 || eta_time_offset_exit.len() != n
4277 {
4278 return Err(
4279 "survival location-scale hazard derivative row mismatch across inputs".to_string(),
4280 );
4281 }
4282 let beta_time = fit.beta_time();
4283 let p_time_total = beta_time.len();
4284 let p_wiggle = time_wiggle_ncols.min(p_time_total);
4285 let p_base = p_time_total - p_wiggle;
4286 if x_time_exit.ncols() != p_time_total || x_time_derivative.ncols() != p_base {
4287 return Err(format!(
4288 "survival location-scale hazard derivative design mismatch: x_exit={} beta_time={} x_derivative={} base={}",
4289 x_time_exit.ncols(),
4290 p_time_total,
4291 x_time_derivative.ncols(),
4292 p_base
4293 ));
4294 }
4295
4296 let time_components = location_scale_time_warp_components(
4297 x_time_exit,
4298 eta_time_offset_exit,
4299 time_wiggle_knots,
4300 time_wiggle_degree,
4301 time_wiggle_ncols,
4302 fit,
4303 )?;
4304 let beta_base = beta_time.slice(s![..p_base]).to_owned();
4305 let mut eta_derivative = if p_base > 0 {
4306 x_time_derivative.dot(&beta_base) + derivative_offset_exit
4307 } else {
4308 derivative_offset_exit.clone()
4309 };
4310 if let Some(dq) = time_components.time_wiggle_dq.as_ref() {
4311 eta_derivative *= dq;
4312 }
4313 if eta_derivative
4314 .iter()
4315 .any(|value| !(value.is_finite() && *value > 0.0))
4316 {
4317 return Err(
4318 "survival location-scale hazard derivative must be finite and positive".to_string(),
4319 );
4320 }
4321 Ok(eta_derivative)
4322}
4323
4324fn location_scale_hazard_from_eta_derivative(
4325 eta: &Array1<f64>,
4326 eta_derivative: &Array1<f64>,
4327 inverse_link: &InverseLink,
4328) -> Result<Array1<f64>, String> {
4329 if eta.len() != eta_derivative.len() {
4330 return Err(format!(
4331 "survival location-scale hazard row mismatch: eta={} eta_derivative={}",
4332 eta.len(),
4333 eta_derivative.len()
4334 ));
4335 }
4336 let values = eta
4337 .iter()
4338 .zip(eta_derivative.iter())
4339 .map(|(&q, &q_t)| location_scale_hazard_component(q, q_t, inverse_link))
4340 .collect::<Result<Vec<_>, _>>()?;
4341 Ok(Array1::from_vec(values))
4342}
4343
4344fn location_scale_hazard_component(
4345 eta: f64,
4346 eta_derivative: f64,
4347 inverse_link: &InverseLink,
4348) -> Result<f64, String> {
4349 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative > 0.0) {
4350 return Err(format!(
4351 "survival location-scale hazard requires finite eta and positive eta_t, got eta={eta}, eta_t={eta_derivative}"
4352 ));
4353 }
4354 match inverse_link {
4355 InverseLink::Standard(StandardLink::Probit) => {
4356 let (_, hazard) = probit_survival_hazard_components(eta, eta_derivative)?;
4357 Ok(hazard)
4358 }
4359 InverseLink::Standard(StandardLink::CLogLog) => {
4360 let (_, hazard) = royston_parmar_survival_hazard_components(eta, eta_derivative)?;
4361 Ok(hazard)
4362 }
4363 InverseLink::Standard(StandardLink::Logit) => {
4364 let failure = if eta >= 0.0 {
4365 1.0 / (1.0 + (-eta).exp())
4366 } else {
4367 let exp_eta = eta.exp();
4368 exp_eta / (1.0 + exp_eta)
4369 };
4370 Ok(failure * eta_derivative)
4371 }
4372 InverseLink::Standard(StandardLink::Identity) => {
4373 let survival = 1.0 - eta;
4374 if !(survival.is_finite() && survival > 0.0) {
4375 return Err(format!(
4376 "survival location-scale identity link produced invalid survival={survival} at eta={eta}"
4377 ));
4378 }
4379 Ok(eta_derivative / survival)
4380 }
4381 _ => {
4382 let jet = inverse_link_jet_for_inverse_link(inverse_link, eta)
4383 .map_err(|err| format!("survival location-scale inverse-link jet failed: {err}"))?;
4384 let survival = 1.0 - jet.mu;
4385 let hazard = jet.d1 * eta_derivative / survival;
4386 if !(survival.is_finite() && survival > 0.0 && hazard.is_finite() && hazard >= 0.0) {
4387 return Err(format!(
4388 "survival location-scale inverse link produced invalid hazard components: eta={eta}, eta_t={eta_derivative}, failure={}, d_failure={}, survival={survival}, hazard={hazard}",
4389 jet.mu, jet.d1
4390 ));
4391 }
4392 Ok(hazard)
4393 }
4394 }
4395}
4396
4397pub fn require_saved_survival_likelihood_mode(
4403 model: &SavedModel,
4404) -> Result<SurvivalLikelihoodMode, SurvivalPredictError> {
4405 if matches!(&model.family_state, FittedFamily::LatentSurvival { .. }) {
4406 return match model.survival_likelihood.as_deref() {
4407 Some("latent") => Ok(SurvivalLikelihoodMode::Latent),
4408 Some(other) => Err(SurvivalPredictError::MissingFitMetadata { reason: format!(
4409 "saved latent survival model has contradictory survival_likelihood metadata: expected 'latent', got '{other}'"
4410 ) }),
4411 None => Err(SurvivalPredictError::MissingFitMetadata {
4412 reason:
4413 "saved latent survival model is missing survival_likelihood=latent metadata; refit"
4414 .to_string(),
4415 }),
4416 };
4417 }
4418 if matches!(&model.family_state, FittedFamily::LatentBinary { .. }) {
4419 return match model.survival_likelihood.as_deref() {
4420 Some("latent-binary") => Ok(SurvivalLikelihoodMode::LatentBinary),
4421 Some(other) => Err(SurvivalPredictError::MissingFitMetadata { reason: format!(
4422 "saved latent binary model has contradictory survival_likelihood metadata: expected 'latent-binary', got '{other}'"
4423 ) }),
4424 None => Err(SurvivalPredictError::MissingFitMetadata {
4425 reason:
4426 "saved latent binary model is missing survival_likelihood=latent-binary metadata; refit"
4427 .to_string(),
4428 }),
4429 };
4430 }
4431 let raw = model.survival_likelihood.as_deref().ok_or_else(|| {
4432 "saved survival model is missing survival_likelihood metadata; refit".to_string()
4433 })?;
4434 parse_survival_likelihood_mode(raw).map_err(SurvivalPredictError::from)
4435}
4436
4437pub fn saved_survival_runtime_baseline_config(
4439 model: &SavedModel,
4440) -> Result<SurvivalBaselineConfig, SurvivalPredictError> {
4441 survival_baseline_config_from_model(model).map_err(SurvivalPredictError::from)
4442}
4443
4444pub fn resolve_termspec_for_prediction(
4447 modelspec: &Option<TermCollectionSpec>,
4448 training_headers: Option<&Vec<String>>,
4449 col_map: &HashMap<String, usize>,
4450 spec_label: &str,
4451) -> Result<TermCollectionSpec, SurvivalPredictError> {
4452 let saved = modelspec.as_ref().ok_or_else(|| {
4453 format!(
4454 "model is missing {spec_label}; refit to guarantee train/predict design consistency"
4455 )
4456 })?;
4457 saved.validate_frozen(spec_label)?;
4458 let headers = training_headers.ok_or_else(|| {
4459 "model is missing training_headers; refit to guarantee stable feature mapping at prediction time"
4460 .to_string()
4461 })?;
4462 let remapped = remap_term_collectionspec_columns(saved, headers, col_map)?;
4463 remapped.validate_frozen(spec_label)?;
4464 Ok(remapped)
4465}
4466
4467fn remap_term_collectionspec_columns(
4468 spec: &TermCollectionSpec,
4469 training_headers: &[String],
4470 prediction_column_map: &HashMap<String, usize>,
4471) -> Result<TermCollectionSpec, SurvivalPredictError> {
4472 spec.remap_feature_columns(|index| -> Result<usize, SurvivalPredictError> {
4476 let name = training_headers
4477 .get(index)
4478 .ok_or_else(|| format!("saved training column index {index} is out of bounds"))?;
4479 resolve_role_col(prediction_column_map, name, "prediction")
4480 .map_err(SurvivalPredictError::from)
4481 })
4482}
4483
4484pub fn fit_result_from_saved_model_for_prediction(
4486 model: &SavedModel,
4487) -> Result<UnifiedFitResult, String> {
4488 model
4489 .fit_result
4490 .clone()
4491 .ok_or_else(|| "model is missing canonical fit_result payload; refit".to_string())
4492}
4493
4494pub fn saved_survival_location_scale_fit_result(
4500 model: &SavedModel,
4501) -> Result<UnifiedFitResult, SurvivalPredictError> {
4502 model.saved_prediction_runtime()?;
4503 let mut fit = model.fit_result.clone().ok_or_else(|| {
4504 "saved location-scale survival model missing canonical fit_result; refit".to_string()
4505 })?;
4506 let inverse_link = resolve_survival_inverse_link_from_saved(model)?;
4507 apply_inverse_link_state_to_fit_result(&mut fit, &inverse_link);
4508 Ok(fit)
4509}
4510
4511pub fn apply_inverse_link_state_to_fit_result(
4512 fit_result: &mut UnifiedFitResult,
4513 inverse_link: &InverseLink,
4514) {
4515 fit_result.fitted_link = match inverse_link {
4516 InverseLink::LatentCLogLog(state) => FittedLinkState::LatentCLogLog { state: *state },
4517 InverseLink::Sas(state) => FittedLinkState::Sas {
4518 state: *state,
4519 covariance: None,
4520 },
4521 InverseLink::BetaLogistic(state) => FittedLinkState::BetaLogistic {
4522 state: *state,
4523 covariance: None,
4524 },
4525 InverseLink::Mixture(state) => FittedLinkState::Mixture {
4526 state: state.clone(),
4527 covariance: None,
4528 },
4529 InverseLink::Standard(_) => FittedLinkState::Standard(None),
4530 };
4531}
4532
4533pub fn resolve_survival_inverse_link_from_saved(
4536 model: &SavedModel,
4537) -> Result<InverseLink, SurvivalPredictError> {
4538 if let Some(link) = model.link.as_ref() {
4539 return Ok(link.clone());
4540 }
4541 Err(SurvivalPredictError::MissingFitMetadata {
4542 reason: "saved survival model is missing link metadata; refit".to_string(),
4543 })
4544}
4545
4546pub fn concat_array1_refs(parts: &[&Array1<f64>]) -> Array1<f64> {
4548 let total: usize = parts.iter().map(|part| part.len()).sum();
4549 let mut out = Array1::<f64>::zeros(total);
4550 let mut offset = 0usize;
4551 for part in parts {
4552 let width = part.len();
4553 out.slice_mut(s![offset..offset + width]).assign(part);
4554 offset += width;
4555 }
4556 out
4557}
4558
4559pub fn saved_baseline_timewiggle_components(
4563 eta_entry: &Array1<f64>,
4564 eta_exit: &Array1<f64>,
4565 derivative_exit: &Array1<f64>,
4566 model: &SavedModel,
4567) -> Result<Option<(Array2<f64>, Array2<f64>, Array2<f64>)>, SurvivalPredictError> {
4568 match model.saved_baseline_time_wiggle()? {
4569 None => Ok(None),
4570 Some(runtime) => {
4571 runtime.validate_global_monotonicity()?;
4572 let SavedBaselineTimeWiggleRuntime {
4573 knots,
4574 degree,
4575 beta,
4576 ..
4577 } = runtime;
4578 let knots = Array1::from_vec(knots);
4579 let entry =
4580 monotone_wiggle_basis_with_derivative_order(eta_entry.view(), &knots, degree, 0)?;
4581 let exit =
4582 monotone_wiggle_basis_with_derivative_order(eta_exit.view(), &knots, degree, 0)?;
4583 let betaw = beta;
4584 if entry.ncols() != betaw.len() || exit.ncols() != betaw.len() {
4585 return Err(SurvivalPredictError::IncompatibleSchema {
4586 reason: format!(
4587 "saved baseline-timewiggle dimension mismatch: coefficients have {} entries but basis has entry={} exit={}",
4588 betaw.len(),
4589 entry.ncols(),
4590 exit.ncols()
4591 ),
4592 });
4593 }
4594 let derivative = build_survival_timewiggle_derivative_design(
4595 eta_exit,
4596 derivative_exit,
4597 &knots,
4598 degree,
4599 )
4600 .map_err(|e| {
4601 e.replace(
4602 "build baseline-timewiggle",
4603 "evaluate saved baseline-timewiggle",
4604 )
4605 })?;
4606 if derivative.ncols() != betaw.len() {
4607 return Err(SurvivalPredictError::IncompatibleSchema {
4608 reason: format!(
4609 "saved baseline-timewiggle derivative dimension mismatch: coefficients have {} entries but derivative basis has {} columns",
4610 betaw.len(),
4611 derivative.ncols()
4612 ),
4613 });
4614 }
4615 Ok(Some((entry, exit, derivative)))
4616 }
4617 }
4618}
4619
4620pub fn build_saved_survival_marginal_slope_predictor(
4629 model: &SavedModel,
4630 fit_saved: &UnifiedFitResult,
4631 z_name: &str,
4632 z: &Array1<f64>,
4633 cov_design: &DesignMatrix,
4634 logslope_design: &DesignMatrix,
4635 time_build: &SurvivalTimeBuildOutput,
4636 eta_offset_entry: &Array1<f64>,
4637 eta_offset_exit: &Array1<f64>,
4638 derivative_offset_exit: &Array1<f64>,
4639 primary_offset: &Array1<f64>,
4640 noise_offset: &Array1<f64>,
4641) -> Result<
4642 (
4643 BernoulliMarginalSlopePredictor,
4644 PredictInput,
4645 UnifiedFitResult,
4646 ),
4647 SurvivalPredictError,
4648> {
4649 let saved_runtime = model.saved_prediction_runtime()?;
4650 if saved_runtime.link_wiggle.is_some() {
4651 return Err(SurvivalPredictError::MissingFitMetadata {
4652 reason:
4653 "saved survival marginal-slope model contains legacy linkwiggle metadata; refit with the anchored link-deviation runtime"
4654 .to_string(),
4655 });
4656 }
4657
4658 let saved_score_runtime = saved_runtime.score_warp;
4659 let saved_link_runtime = saved_runtime.link_deviation;
4660 let influence_absorber_width = saved_runtime.influence_absorber_width;
4665 let blocks = &fit_saved.blocks;
4666 let expected_blocks = 3
4667 + usize::from(saved_score_runtime.is_some())
4668 + usize::from(saved_link_runtime.is_some())
4669 + usize::from(influence_absorber_width.is_some());
4670 if blocks.len() != expected_blocks {
4671 return Err(SurvivalPredictError::IncompatibleSchema {
4672 reason: format!(
4673 "saved survival marginal-slope model requires {} blocks [time, marginal, slope{}{}{}], got {}",
4674 expected_blocks,
4675 if saved_score_runtime.is_some() {
4676 ", score-warp"
4677 } else {
4678 ""
4679 },
4680 if saved_link_runtime.is_some() {
4681 ", link-deviation"
4682 } else {
4683 ""
4684 },
4685 if influence_absorber_width.is_some() {
4686 ", influence-absorber(dropped)"
4687 } else {
4688 ""
4689 },
4690 blocks.len(),
4691 ),
4692 });
4693 }
4694
4695 let beta_time = &blocks[0].beta;
4696 let beta_marginal = &blocks[1].beta;
4697 let beta_logslope = &blocks[2].beta;
4698 if let Some(runtime) = saved_score_runtime.as_ref() {
4699 let beta = &blocks[3].beta;
4700 if beta.len() != runtime.basis_dim {
4701 return Err(SurvivalPredictError::IncompatibleSchema {
4702 reason: format!(
4703 "saved survival marginal-slope score-warp coefficient mismatch: beta has {} entries but runtime expects {}",
4704 beta.len(),
4705 runtime.basis_dim
4706 ),
4707 });
4708 }
4709 }
4710 if let Some(runtime) = saved_link_runtime.as_ref() {
4711 let idx = 3 + usize::from(saved_score_runtime.is_some());
4712 let beta = &blocks[idx].beta;
4713 if beta.len() != runtime.basis_dim {
4714 return Err(SurvivalPredictError::IncompatibleSchema {
4715 reason: format!(
4716 "saved survival marginal-slope link-deviation coefficient mismatch: beta has {} entries but runtime expects {}",
4717 beta.len(),
4718 runtime.basis_dim
4719 ),
4720 });
4721 }
4722 }
4723
4724 if beta_marginal.len() != cov_design.ncols() {
4725 return Err(SurvivalPredictError::IncompatibleSchema {
4726 reason: format!(
4727 "saved survival marginal-slope marginal coefficient mismatch: beta has {} entries but baseline design has {} columns",
4728 beta_marginal.len(),
4729 cov_design.ncols()
4730 ),
4731 });
4732 }
4733 if beta_logslope.len() != logslope_design.ncols() {
4734 return Err(SurvivalPredictError::IncompatibleSchema {
4735 reason: format!(
4736 "saved survival marginal-slope slope coefficient mismatch: beta has {} entries but slope design has {} columns",
4737 beta_logslope.len(),
4738 logslope_design.ncols()
4739 ),
4740 });
4741 }
4742
4743 let p_time_base = time_build.x_exit_time.ncols();
4744 let saved_timewiggle = saved_runtime.baseline_time_wiggle;
4745 let p_timewiggle = saved_timewiggle
4746 .as_ref()
4747 .map_or(0, |runtime| runtime.beta.len());
4748 if beta_time.len() != p_time_base + p_timewiggle {
4749 let hint = stale_weibull_time_basis_hint(
4750 &time_build.basisname,
4751 beta_time.len() == p_time_base + p_timewiggle + 1,
4752 );
4753 return Err(SurvivalPredictError::IncompatibleSchema {
4754 reason: format!(
4755 "saved survival marginal-slope time coefficient mismatch: beta has {} entries but expected base={} plus timewiggle={}{hint}",
4756 beta_time.len(),
4757 p_time_base,
4758 p_timewiggle
4759 ),
4760 });
4761 }
4762
4763 let beta_time_base = beta_time.slice(s![..p_time_base]).to_owned();
4764 let cov_eta_marginal = cov_design.dot(beta_marginal);
4768 let q_entry_base = time_build.x_entry_time.dot(&beta_time_base)
4769 + &cov_eta_marginal
4770 + eta_offset_entry
4771 + primary_offset;
4772 let q_exit_base = time_build.x_exit_time.dot(&beta_time_base)
4773 + &cov_eta_marginal
4774 + eta_offset_exit
4775 + primary_offset;
4776 let qd_exit_base = time_build.x_derivative_time.dot(&beta_time_base) + derivative_offset_exit;
4777
4778 let mut q_design_parts = vec![time_build.x_exit_time.clone()];
4779 if saved_timewiggle.is_some() {
4780 let (_, exit_w, _) = saved_baseline_timewiggle_components(
4781 &q_entry_base,
4782 &q_exit_base,
4783 &qd_exit_base,
4784 model,
4785 )?
4786 .ok_or_else(|| {
4787 "saved survival marginal-slope model is missing baseline-timewiggle runtime metadata"
4788 .to_string()
4789 })?;
4790 if exit_w.ncols() != p_timewiggle {
4791 return Err(SurvivalPredictError::IncompatibleSchema {
4792 reason: format!(
4793 "saved survival marginal-slope timewiggle design mismatch: rebuilt {} columns but runtime expects {}",
4794 exit_w.ncols(),
4795 p_timewiggle
4796 ),
4797 });
4798 }
4799 q_design_parts.push(DesignMatrix::from(exit_w));
4800 }
4801 q_design_parts.push(cov_design.clone());
4802 let q_design = DesignMatrix::hstack(q_design_parts)?;
4803
4804 let combined_q_beta = concat_array1_refs(&[beta_time, beta_marginal]);
4805 let combined_q_lambdas = concat_array1_refs(&[&blocks[0].lambdas, &blocks[1].lambdas]);
4806 let mut predictor_blocks = Vec::with_capacity(
4807 2 + usize::from(saved_score_runtime.is_some()) + usize::from(saved_link_runtime.is_some()),
4808 );
4809 predictor_blocks.push(FittedBlock {
4810 beta: combined_q_beta.clone(),
4811 role: BlockRole::Mean,
4812 edf: blocks[0].edf + blocks[1].edf,
4813 lambdas: combined_q_lambdas,
4814 });
4815 predictor_blocks.push(FittedBlock {
4816 beta: beta_logslope.clone(),
4817 role: BlockRole::Scale,
4818 edf: blocks[2].edf,
4819 lambdas: blocks[2].lambdas.clone(),
4820 });
4821 if saved_score_runtime.is_some() {
4822 let mut block = blocks[3].clone();
4823 block.role = BlockRole::Mean;
4824 predictor_blocks.push(block);
4825 }
4826 if saved_link_runtime.is_some() {
4827 let idx = 3 + usize::from(saved_score_runtime.is_some());
4828 let mut block = blocks[idx].clone();
4829 block.role = BlockRole::LinkWiggle;
4830 predictor_blocks.push(block);
4831 }
4832
4833 let mut predictor_fit = fit_saved.clone();
4834 predictor_fit.blocks = predictor_blocks;
4835 predictor_fit.beta = concat_array1_refs(
4836 &predictor_fit
4837 .blocks
4838 .iter()
4839 .map(|block| &block.beta)
4840 .collect::<Vec<_>>(),
4841 );
4842 predictor_fit.block_states.clear();
4843
4844 let predictor = BernoulliMarginalSlopePredictor::from_unified(
4845 &predictor_fit,
4846 z_name.to_string(),
4847 model.latent_z_normalization.ok_or_else(|| {
4848 "saved survival marginal-slope model missing latent_z_normalization".to_string()
4849 })?,
4850 model.latent_measure.clone().ok_or_else(|| {
4851 "saved survival marginal-slope model missing latent_measure".to_string()
4852 })?,
4853 0.0,
4854 model.logslope_baseline.ok_or_else(|| {
4855 "saved survival marginal-slope model missing logslope_baseline".to_string()
4856 })?,
4857 model
4858 .resolved_inverse_link()?
4859 .unwrap_or(InverseLink::Standard(StandardLink::Probit)),
4860 model
4861 .family_state
4862 .frailty()
4863 .cloned()
4864 .unwrap_or(FrailtySpec::None),
4865 saved_score_runtime,
4866 saved_link_runtime,
4867 model.latent_z_rank_int_calibration.clone(),
4868 model.latent_z_conditional_calibration.clone(),
4869 LatentConditioningSpan::PrimaryDesignTail {
4877 ncols: cov_design.ncols(),
4878 },
4879 )?;
4880
4881 let pred_input = PredictInput {
4882 design: q_design,
4883 offset: eta_offset_exit + primary_offset,
4884 design_noise: Some(logslope_design.clone()),
4885 offset_noise: Some(noise_offset.clone()),
4886 auxiliary_scalar: Some(z.clone()),
4887 auxiliary_matrix: None,
4888 };
4889
4890 Ok((predictor, pred_input, predictor_fit))
4891}
4892
4893fn stale_weibull_time_basis_hint(basisname: &str, extra_time_coefficient: bool) -> &'static str {
4900 if basisname == "linear" && extra_time_coefficient {
4901 " (this looks like a model saved before the #2301 Weibull time-basis \
4902 change, which removed the redundant constant column; refit the model)"
4903 } else {
4904 ""
4905 }
4906}
4907
4908#[cfg(test)]
4909mod tests {
4910 use super::*;
4911 use crate::probability::{normal_cdf, normal_pdf};
4912
4913 #[test]
4914 fn competing_risks_covariance_mode_selects_exact_requested_matrix() {
4915 let conditional = ndarray::array![[1.0, 0.2], [0.2, 2.0]];
4916 let corrected = ndarray::array![[1.5, 0.4], [0.4, 3.0]];
4917
4918 let selected_conditional = select_survival_prediction_covariance(
4919 Some(&conditional),
4920 Some(&corrected),
4921 SurvivalPredictionCovarianceMode::Conditional,
4922 )
4923 .expect("conditional covariance");
4924 let selected_corrected = select_survival_prediction_covariance(
4925 Some(&conditional),
4926 Some(&corrected),
4927 SurvivalPredictionCovarianceMode::SmoothingCorrected,
4928 )
4929 .expect("smoothing-corrected covariance");
4930
4931 assert_eq!(selected_conditional, &conditional);
4932 assert_eq!(selected_corrected, &corrected);
4933 assert_eq!(
4934 SurvivalPredictionCovarianceMode::Conditional.as_str(),
4935 "conditional"
4936 );
4937 assert_eq!(
4938 SurvivalPredictionCovarianceMode::SmoothingCorrected.as_str(),
4939 "smoothing-corrected"
4940 );
4941 }
4942
4943 #[test]
4944 fn competing_risks_smoothing_covariance_never_falls_back() {
4945 let conditional = ndarray::array![[1.0]];
4946 let error = select_survival_prediction_covariance(
4947 Some(&conditional),
4948 None,
4949 SurvivalPredictionCovarianceMode::SmoothingCorrected,
4950 )
4951 .expect_err("a corrected request must not substitute conditional covariance");
4952 assert_eq!(
4953 error.to_string(),
4954 "fit result does not contain smoothing-corrected covariance"
4955 );
4956 }
4957
4958 #[test]
4959 fn posterior_quadrature_second_moment_honors_cross_coordinate_covariance() {
4960 let posterior_mean = ndarray::array![0.4, -0.2];
4961 let covariance = ndarray::array![[0.9, 0.35], [0.35, 0.6]];
4962 let mut functional_mean = 0.0_f64;
4963 let mut functional_second = 0.0_f64;
4964 let mut recovered_cross_covariance = 0.0_f64;
4965
4966 for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
4967 let functional = node[0] + 2.0 * node[1];
4968 functional_mean += weight * functional;
4969 functional_second += weight * functional * functional;
4970 recovered_cross_covariance +=
4971 weight * (node[0] - posterior_mean[0]) * (node[1] - posterior_mean[1]);
4972 Ok(())
4973 })
4974 .expect("joint posterior quadrature");
4975
4976 let expected_mean = posterior_mean[0] + 2.0 * posterior_mean[1];
4977 let expected_variance =
4978 covariance[[0, 0]] + 4.0 * covariance[[1, 1]] + 4.0 * covariance[[0, 1]];
4979 assert!((functional_mean - expected_mean).abs() <= 1e-12);
4980 assert!((recovered_cross_covariance - covariance[[0, 1]]).abs() <= 1e-12);
4981
4982 let mean_surface = Array2::from_elem((1, 1), functional_mean);
4983 let second_surface = Array2::from_elem((1, 1), functional_second);
4984 let standard_error = posterior_standard_error_matrix(
4985 &mean_surface,
4986 &second_surface,
4987 "joint-covariance witness",
4988 )
4989 .expect("posterior standard error");
4990 assert!((standard_error[[0, 0]].powi(2) - expected_variance).abs() <= 1e-11);
4991 }
4992
4993 #[test]
4994 fn posterior_quadrature_zero_covariance_has_zero_standard_error() {
4995 let posterior_mean = ndarray::array![0.25, -0.75];
4996 let covariance = Array2::<f64>::zeros((2, 2));
4997 let mut functional_mean = 0.0_f64;
4998 let mut functional_second = 0.0_f64;
4999 let mut node_count = 0usize;
5000
5001 for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
5002 let functional = node[0].exp() + node[1].sin();
5003 functional_mean += weight * functional;
5004 functional_second += weight * functional * functional;
5005 node_count += 1;
5006 Ok(())
5007 })
5008 .expect("rank-zero posterior quadrature");
5009
5010 assert_eq!(node_count, 1, "rank-zero covariance has one exact node");
5011 let standard_error = posterior_standard_error_matrix(
5012 &Array2::from_elem((1, 1), functional_mean),
5013 &Array2::from_elem((1, 1), functional_second),
5014 "rank-zero witness",
5015 )
5016 .expect("rank-zero posterior standard error");
5017 assert_eq!(standard_error[[0, 0]], 0.0);
5018 }
5019
5020 #[test]
5021 fn posterior_quadrature_keeps_cone_coordinates_feasible_and_unbiased() {
5022 let posterior_mean = ndarray::array![0.354, -8.30];
5029 let covariance = ndarray::array![[0.2304, 0.30], [0.30, 0.9604]];
5030
5031 let mut min_cone0_unconstrained = f64::INFINITY;
5033 for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, _| {
5034 min_cone0_unconstrained = min_cone0_unconstrained.min(node[0]);
5035 Ok(())
5036 })
5037 .expect("unconstrained quadrature");
5038 assert!(
5039 min_cone0_unconstrained < 0.0,
5040 "fixture must reproduce the infeasible-node bug (min β_0 = {min_cone0_unconstrained})"
5041 );
5042
5043 let mut mean0 = 0.0_f64;
5046 let mut mean1 = 0.0_f64;
5047 let mut weight_sum = 0.0_f64;
5048 let mut min_cone0 = f64::INFINITY;
5049 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
5050 assert!(
5051 node[0] >= -1e-12,
5052 "cone coordinate stepped below its β_0 ≥ 0 wall: {}",
5053 node[0]
5054 );
5055 min_cone0 = min_cone0.min(node[0]);
5056 mean0 += weight * node[0];
5057 mean1 += weight * node[1];
5058 weight_sum += weight;
5059 Ok(())
5060 })
5061 .expect("cone-truncated quadrature");
5062 assert!((weight_sum - 1.0).abs() <= 1e-12, "weights must sum to one");
5063 assert!(
5064 (mean0 - posterior_mean[0]).abs() <= 1e-12
5065 && (mean1 - posterior_mean[1]).abs() <= 1e-12,
5066 "cone truncation must leave the posterior mean unbiased (got [{mean0}, {mean1}])"
5067 );
5068
5069 let mut var0_unconstrained = 0.0_f64;
5072 for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
5073 var0_unconstrained += weight * (node[0] - posterior_mean[0]).powi(2);
5074 Ok(())
5075 })
5076 .expect("unconstrained spread");
5077 let mut var0_cone = 0.0_f64;
5078 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
5079 var0_cone += weight * (node[0] - posterior_mean[0]).powi(2);
5080 Ok(())
5081 })
5082 .expect("cone spread");
5083 assert!(
5084 var0_cone <= var0_unconstrained + 1e-12 && var0_cone < var0_unconstrained,
5085 "cone spread {var0_cone} must be strictly smaller than the untruncated {var0_unconstrained}"
5086 );
5087 }
5088
5089 #[test]
5090 fn posterior_quadrature_cone_is_a_noop_far_from_the_wall() {
5091 let posterior_mean = ndarray::array![40.0, -0.2];
5096 let covariance = ndarray::array![[0.9, 0.35], [0.35, 0.6]];
5097 let mut recovered_var0 = 0.0_f64;
5098 let mut recovered_cross = 0.0_f64;
5099 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
5100 recovered_var0 += weight * (node[0] - posterior_mean[0]).powi(2);
5101 recovered_cross +=
5102 weight * (node[0] - posterior_mean[0]) * (node[1] - posterior_mean[1]);
5103 Ok(())
5104 })
5105 .expect("cone quadrature far from the wall");
5106 assert!((recovered_var0 - covariance[[0, 0]]).abs() <= 1e-11);
5107 assert!((recovered_cross - covariance[[0, 1]]).abs() <= 1e-11);
5108 }
5109
5110 #[test]
5124 fn posterior_quadrature_radius_collapses_on_an_active_bound() {
5125 let posterior_mean = ndarray::array![0.0, 0.75];
5128 let covariance = ndarray::array![[0.5, 0.0], [0.0, 0.2]];
5129
5130 let mut min_pinned = f64::INFINITY;
5131 let mut max_pinned = f64::NEG_INFINITY;
5132 let mut spread_unpinned = 0.0_f64;
5133 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
5134 min_pinned = min_pinned.min(node[0]);
5135 max_pinned = max_pinned.max(node[0]);
5136 spread_unpinned += weight * (node[1] - posterior_mean[1]).powi(2);
5137 Ok(())
5138 })
5139 .expect("active-bound quadrature");
5140
5141 assert!(
5142 min_pinned >= 0.0,
5143 "an active bound must never be crossed, got {min_pinned}"
5144 );
5145 assert!(
5146 max_pinned.abs() <= 1e-12,
5147 "a direction loading an active-bound coordinate carries zero symmetric spread, \
5148 but the coordinate reached {max_pinned}"
5149 );
5150 assert!(
5153 (spread_unpinned - covariance[[1, 1]]).abs() <= 1e-11,
5154 "a coordinate outside the cone keeps its full spread, got {spread_unpinned} want {}",
5155 covariance[[1, 1]]
5156 );
5157 }
5158
5159 #[test]
5167 fn posterior_quadrature_clamps_a_roundoff_negative_cone_coordinate() {
5168 let roundoff_below_wall = -1e-15_f64;
5169 let posterior_mean = ndarray::array![roundoff_below_wall, 0.75];
5170 let covariance = ndarray::array![[0.5, 0.0], [0.0, 0.2]];
5171
5172 let mut nodes = Vec::new();
5173 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, _| {
5174 nodes.push(node[0]);
5175 Ok(())
5176 })
5177 .expect("round-off-negative cone quadrature");
5178
5179 for value in &nodes {
5180 assert!(
5181 *value >= roundoff_below_wall,
5182 "truncation must never push a cone coordinate further below the wall than the \
5183 fit left it: node {value} < β̂ {roundoff_below_wall}"
5184 );
5185 assert!(
5186 (*value - roundoff_below_wall).abs() <= 1e-12,
5187 "a coordinate at the wall carries no spread, got {value}"
5188 );
5189 }
5190 }
5191
5192 #[test]
5193 fn probit_survival_hazard_uses_density_over_survival() {
5194 let eta = 2.0;
5195 let eta_t = 0.3;
5196
5197 let (cum, hazard) =
5198 probit_survival_hazard_components(eta, eta_t).expect("valid components");
5199
5200 let survival = normal_cdf(-eta);
5201 let expected_cum = -survival.ln();
5202 let expected_hazard = normal_pdf(eta) * eta_t / survival;
5203 assert!((cum - expected_cum).abs() <= 1e-14);
5204 assert!((hazard - expected_hazard).abs() <= 1e-14);
5205 }
5206
5207 #[test]
5208 fn probit_survival_hazard_stays_finite_in_right_tail() {
5209 let eta = 40.0;
5210 let eta_t = 9.694_340_360_912_401e-5;
5211
5212 let event_density =
5213 (-0.5_f64 * eta * eta).exp() / (2.0 * std::f64::consts::PI).sqrt() * eta_t;
5214 assert_eq!(event_density, 0.0);
5215
5216 let (cum, hazard) =
5217 probit_survival_hazard_components(eta, eta_t).expect("valid tail components");
5218 assert!(cum > 800.0, "right-tail cumulative hazard was {cum}");
5219 assert!(
5220 (3.87e-3..3.89e-3).contains(&hazard),
5221 "right-tail hazard was {hazard}"
5222 );
5223 }
5224
5225 #[test]
5226 fn probit_survival_hazard_accepts_zero_time_derivative_as_flat_hazard() {
5227 let (cum, hazard) =
5228 probit_survival_hazard_components(1.0, 0.0).expect("zero derivative is flat hazard");
5229 assert!(cum > 0.0);
5230 assert_eq!(hazard, 0.0);
5231 }
5232
5233 #[test]
5234 fn marginal_slope_index_derivative_clamps_extrapolation_negative_to_flat_hazard() {
5235 let deta_dq = (1.0_f64 + 0.4 * 0.4).sqrt(); let qd_with_wiggle = -1.35e-3;
5243 let eta_t = marginal_slope_index_derivative_at_horizon(deta_dq, qd_with_wiggle);
5244 assert_eq!(
5245 eta_t, 0.0,
5246 "negative extrapolation derivative must clamp to 0"
5247 );
5248 let (cum, hazard) = probit_survival_hazard_components(-0.563, eta_t)
5250 .expect("clamped flat-hazard prediction must validate");
5251 assert!(
5252 cum >= 0.0,
5253 "cumulative hazard must be well-posed, got {cum}"
5254 );
5255 assert_eq!(
5256 hazard, 0.0,
5257 "clamped derivative gives zero instantaneous hazard"
5258 );
5259 }
5260
5261 #[test]
5262 fn marginal_slope_index_derivative_preserves_positive_and_nonfinite() {
5263 let positive = marginal_slope_index_derivative_at_horizon(1.25, 0.8);
5267 assert!(
5268 (positive - 1.0).abs() <= 1e-15,
5269 "positive derivative scaled by chain factor"
5270 );
5271 let nonfinite = marginal_slope_index_derivative_at_horizon(1.25, f64::NAN);
5272 assert!(
5273 nonfinite.is_nan(),
5274 "non-finite derivative passes through unclamped"
5275 );
5276 assert!(
5277 probit_survival_hazard_components(0.5, nonfinite).is_err(),
5278 "non-finite derivative must still be rejected by the validator"
5279 );
5280 }
5281
5282 #[test]
5283 fn probit_survival_hazard_rejects_infinite_time_derivative() {
5284 let err = probit_survival_hazard_components(1.0, f64::INFINITY)
5285 .expect_err("infinite derivative should be invalid");
5286 assert!(
5287 err.to_string()
5288 .contains("invalid survival index derivative")
5289 );
5290 }
5291
5292 #[test]
5293 fn probit_survival_hazard_rejects_nan_inputs() {
5294 let err_eta =
5300 probit_survival_hazard_components(f64::NAN, 0.5).expect_err("NaN eta must be rejected");
5301 assert!(
5302 err_eta
5303 .to_string()
5304 .contains("invalid survival index derivative")
5305 );
5306 let err_dt = probit_survival_hazard_components(1.0, f64::NAN)
5307 .expect_err("NaN eta_derivative must be rejected");
5308 assert!(
5309 err_dt
5310 .to_string()
5311 .contains("invalid survival index derivative")
5312 );
5313 }
5314
5315 #[test]
5316 fn probit_survival_hazard_rejects_negative_time_derivative() {
5317 let err = probit_survival_hazard_components(1.0, -0.5)
5321 .expect_err("negative derivative should be invalid");
5322 assert!(
5323 err.to_string()
5324 .contains("invalid survival index derivative")
5325 );
5326 }
5327
5328 #[test]
5329 fn royston_parmar_hazard_is_cumulative_hazard_derivative() {
5330 let eta = 2.0_f64.ln();
5331 let eta_t = 0.25;
5332
5333 let (cum, hazard) =
5334 royston_parmar_survival_hazard_components(eta, eta_t).expect("valid components");
5335
5336 assert!((cum - 2.0).abs() <= 1e-14);
5337 assert!((hazard - 0.5).abs() <= 1e-14);
5338 assert_ne!(hazard, cum);
5339 }
5340
5341 #[test]
5342 fn royston_parmar_hazard_rejects_negative_log_hazard_derivative() {
5343 let err = royston_parmar_survival_hazard_components(0.0, -0.5)
5347 .expect_err("negative derivative should be invalid");
5348 assert!(
5349 err.to_string()
5350 .contains("invalid log-cumulative-hazard derivative")
5351 );
5352 }
5353
5354 #[test]
5355 fn royston_parmar_hazard_accepts_zero_derivative_as_flat_boundary() {
5356 let eta = 1.9909019457445971_f64; let (cum, hazard) = royston_parmar_survival_hazard_components(eta, 0.0)
5363 .expect("zero derivative is a valid flat boundary, not an error");
5364 assert!((cum - eta.exp()).abs() <= 1e-12, "cum = Λ(t) = exp(η)");
5365 assert_eq!(
5366 hazard, 0.0,
5367 "flat cumulative hazard ⇒ zero instantaneous hazard"
5368 );
5369 let survival = (-cum).exp().clamp(0.0, 1.0);
5371 assert!(survival.is_finite() && (0.0..=1.0).contains(&survival));
5372 }
5373
5374 #[test]
5375 fn royston_parmar_hazard_zero_derivative_in_saturated_tail_is_zero_not_nan() {
5376 let eta = 1000.0_f64;
5382 assert!(
5383 eta.exp().is_infinite(),
5384 "test premise: exp(1000) overflows to +∞"
5385 );
5386 assert!(
5387 (f64::INFINITY * 0.0).is_nan(),
5388 "test premise: the naive product is NaN"
5389 );
5390 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, 0.0)
5391 .expect("saturated + flat boundary must be valid");
5392 assert!(cum.is_infinite() && cum > 0.0, "cum saturates to +∞");
5393 assert_eq!(hazard, 0.0, "hazard at a flat boundary is 0, never NaN");
5394 }
5395
5396 #[test]
5397 fn royston_parmar_hazard_propagates_saturation_as_infinity() {
5398 let eta = 1000.0_f64;
5403 let eta_t = 0.5_f64;
5404 assert!(eta.exp().is_infinite(), "test premise: exp(1000) overflows");
5405
5406 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, eta_t)
5407 .expect("saturated RP fit must yield a result, not an error");
5408 assert!(cum.is_infinite() && cum > 0.0, "expected +∞ cum, got {cum}");
5409 assert!(
5410 hazard.is_infinite() && hazard > 0.0,
5411 "expected +∞ hazard, got {hazard}"
5412 );
5413
5414 let survival = (-cum).exp().clamp(0.0, 1.0);
5416 assert_eq!(survival, 0.0, "saturated cum_hazard must give survival 0");
5417 }
5418
5419 #[test]
5420 fn royston_parmar_hazard_rejects_nan_eta() {
5421 let err = royston_parmar_survival_hazard_components(f64::NAN, 0.5)
5422 .expect_err("NaN eta should be invalid");
5423 assert!(
5424 err.to_string()
5425 .contains("invalid log-cumulative-hazard derivative")
5426 );
5427 }
5428
5429 #[test]
5430 fn royston_parmar_hazard_left_tail_collapses_to_zero() {
5431 let eta = -1000.0_f64;
5434 let eta_t = 2.0_f64;
5435 assert_eq!(eta.exp(), 0.0, "test premise: exp(-1000) underflows to 0");
5436
5437 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, eta_t)
5438 .expect("RP left tail must remain valid");
5439 assert_eq!(
5440 cum, 0.0,
5441 "left-tail cum_hazard should underflow to 0, got {cum}"
5442 );
5443 assert_eq!(
5444 hazard, 0.0,
5445 "left-tail hazard should underflow to 0, got {hazard}"
5446 );
5447
5448 let survival = (-cum).exp().clamp(0.0, 1.0);
5450 assert_eq!(survival, 1.0);
5451 }
5452
5453 #[test]
5454 fn probit_survival_hazard_left_tail_collapses_to_zero() {
5455 let eta = -40.0_f64;
5459 let eta_t = 1.5_f64;
5460
5461 let (cum, hazard) =
5462 probit_survival_hazard_components(eta, eta_t).expect("left tail must remain valid");
5463 assert!(
5464 (0.0..1e-300).contains(&cum),
5465 "left-tail cum should be ~0, got {cum}"
5466 );
5467 assert_eq!(
5468 hazard, 0.0,
5469 "left-tail hazard should underflow to 0, got {hazard}"
5470 );
5471 }
5472
5473 #[test]
5474 fn location_scale_logit_hazard_is_failure_slope_over_survival() {
5475 let eta = 0.7;
5476 let eta_t = 0.4;
5477
5478 let hazard = location_scale_hazard_component(
5479 eta,
5480 eta_t,
5481 &InverseLink::Standard(StandardLink::Logit),
5482 )
5483 .expect("valid logit hazard");
5484
5485 let failure = 1.0 / (1.0 + (-eta).exp());
5486 assert!((hazard - failure * eta_t).abs() <= 1e-14);
5487 }
5488
5489 #[test]
5490 fn location_scale_cloglog_hazard_matches_log_cumulative_hazard_derivative() {
5491 let eta = 1.5;
5492 let eta_t = 0.2;
5493
5494 let hazard = location_scale_hazard_component(
5495 eta,
5496 eta_t,
5497 &InverseLink::Standard(StandardLink::CLogLog),
5498 )
5499 .expect("valid cloglog hazard");
5500
5501 assert!((hazard - eta.exp() * eta_t).abs() <= 1e-14);
5502 }
5503
5504 #[test]
5507 fn kaplan_meier_censoring_is_right_continuous_step() {
5508 let time = [2.0, 4.0, 6.0, 8.0];
5510 let event = [1.0, 0.0, 1.0, 0.0];
5511 let g = KaplanMeier::fit_censoring(&time, &event);
5512 assert!((g.at(0.0) - 1.0).abs() <= 1e-15);
5514 assert!((g.at(2.0) - 1.0).abs() <= 1e-15);
5515 assert!((g.at(3.999) - 1.0).abs() <= 1e-15);
5516 assert!((g.at(4.0) - 2.0 / 3.0).abs() <= 1e-12);
5518 assert!((g.at(5.0) - 2.0 / 3.0).abs() <= 1e-12);
5519 assert!((g.at(6.0) - 2.0 / 3.0).abs() <= 1e-12);
5521 assert!(g.at(8.0).abs() <= 1e-15);
5523 }
5524
5525 #[test]
5526 fn ipcw_brier_no_censoring_reduces_to_plain_brier() {
5527 let s_pred = [0.3, 0.7, 0.6, 0.2];
5530 let time = [2.0, 8.0, 10.0, 3.0];
5531 let event = [1.0, 1.0, 0.0, 1.0];
5532 let tau = 5.0;
5533 let g = KaplanMeier::fit_censoring(&time, &event);
5534 let bs = ipcw_brier_score(&s_pred, &time, &event, tau, |t| g.at(t)).unwrap();
5535 let expected =
5537 (0.3f64.powi(2) + (1.0 - 0.7f64).powi(2) + (1.0 - 0.6f64).powi(2) + 0.2f64.powi(2))
5538 / 4.0;
5539 assert!(
5540 (bs - expected).abs() <= 1e-12,
5541 "bs={bs} expected={expected}"
5542 );
5543 }
5544
5545 #[test]
5546 fn ipcw_brier_reweights_by_inverse_censoring_probability() {
5547 let s_pred = [0.4, 0.5, 0.7, 0.8];
5551 let time = [2.0, 4.0, 6.0, 8.0];
5552 let event = [1.0, 0.0, 1.0, 0.0];
5553 let tau = 5.0;
5554 let g = KaplanMeier::fit_censoring(&time, &event);
5555 let bs = ipcw_brier_score(&s_pred, &time, &event, tau, |t| g.at(t)).unwrap();
5556 let expected = (0.16 + 0.0 + 0.135 + 0.06) / 4.0;
5561 assert!(
5562 (bs - expected).abs() <= 1e-12,
5563 "bs={bs} expected={expected}"
5564 );
5565 }
5566
5567 #[test]
5568 fn ipcw_brier_drops_invalid_rows_from_both_numerator_and_denominator() {
5569 let s_pred = [0.3, 0.7, 0.5, 0.5];
5571 let time = [2.0, 8.0, f64::NAN, -1.0];
5572 let event = [1.0, 1.0, 1.0, 0.0];
5573 let g = KaplanMeier::fit_censoring(&time, &event);
5574 let bs = ipcw_brier_score(&s_pred, &time, &event, 5.0, |t| g.at(t)).unwrap();
5575 let expected = (0.3f64.powi(2) + (1.0 - 0.7f64).powi(2)) / 2.0;
5578 assert!(
5579 (bs - expected).abs() <= 1e-12,
5580 "bs={bs} expected={expected}"
5581 );
5582 }
5583
5584 #[test]
5585 fn integrated_ipcw_brier_of_constant_brier_is_that_constant() {
5586 let time = [2.0, 8.0, 10.0, 3.0];
5589 let event = [1.0, 1.0, 0.0, 1.0];
5590 let grid = [0.0, 1.0, 2.5, 4.0, 6.0];
5591 let col = [0.3, 0.7, 0.6, 0.2];
5595 let mut surv = Array2::<f64>::zeros((4, grid.len()));
5596 for k in 0..grid.len() {
5597 for i in 0..4 {
5598 surv[[i, k]] = col[i];
5599 }
5600 }
5601 let g = KaplanMeier::fit_censoring(&time, &event);
5602 let per_time = ipcw_brier_score(&col, &time, &event, grid[2], |t| g.at(t)).unwrap();
5603 let mut oracle_pts = Vec::new();
5607 for k in 0..grid.len() {
5608 oracle_pts.push((
5609 grid[k],
5610 ipcw_brier_score(&col, &time, &event, grid[k], |t| g.at(t)).unwrap(),
5611 ));
5612 }
5613 let mut integral = 0.0;
5614 for w in oracle_pts.windows(2) {
5615 integral += 0.5 * (w[0].1 + w[1].1) * (w[1].0 - w[0].0);
5616 }
5617 let oracle = integral / (grid[grid.len() - 1] - grid[0]);
5618 let ibs =
5619 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, f64::INFINITY, |t| {
5620 g.at(t)
5621 })
5622 .unwrap();
5623 assert!((ibs - oracle).abs() <= 1e-12, "ibs={ibs} oracle={oracle}");
5624 assert!(per_time >= 0.0);
5626 }
5627
5628 #[test]
5629 fn integrated_ipcw_brier_respects_the_horizon_cutoff() {
5630 let time = [2.0, 8.0, 10.0, 3.0];
5631 let event = [1.0, 1.0, 0.0, 1.0];
5632 let grid = [0.0, 2.0, 4.0, 100.0];
5633 let col = [0.3, 0.7, 0.6, 0.2];
5634 let mut surv = Array2::<f64>::zeros((4, grid.len()));
5635 for k in 0..grid.len() {
5636 for i in 0..4 {
5637 surv[[i, k]] = col[i];
5638 }
5639 }
5640 let g = KaplanMeier::fit_censoring(&time, &event);
5641 let restricted =
5643 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, 5.0, |t| g.at(t))
5644 .unwrap();
5645 let full =
5646 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, f64::INFINITY, |t| {
5647 g.at(t)
5648 })
5649 .unwrap();
5650 assert!(
5653 (restricted - full).abs() > 1e-3,
5654 "horizon cutoff had no effect: restricted={restricted} full={full}"
5655 );
5656 }
5657
5658 #[test]
5659 fn integrated_ipcw_brier_rejects_malformed_grids() {
5660 let time = [2.0, 8.0];
5661 let event = [1.0, 0.0];
5662 let surv = Array2::<f64>::from_elem((2, 3), 0.5);
5663 let g = KaplanMeier::fit_censoring(&time, &event);
5664 let bad = [0.0, 2.0, 1.0];
5666 assert!(
5667 integrated_ipcw_brier_score(surv.view(), &time, &event, &bad, f64::INFINITY, |t| g
5668 .at(t))
5669 .is_none()
5670 );
5671 let short = [0.0, 1.0];
5673 assert!(
5674 integrated_ipcw_brier_score(surv.view(), &time, &event, &short, f64::INFINITY, |t| g
5675 .at(t))
5676 .is_none()
5677 );
5678 }
5679
5680 fn exponential_survival_result(times: Vec<f64>, rate: f64, rows: usize) -> SurvivalPredictResult {
5682 let t = times.len();
5683 let mut survival = Array2::<f64>::zeros((rows, t));
5684 let mut hazard = Array2::<f64>::zeros((rows, t));
5685 let mut cumulative_hazard = Array2::<f64>::zeros((rows, t));
5686 for i in 0..rows {
5687 for (j, &time) in times.iter().enumerate() {
5688 survival[[i, j]] = (-rate * time).exp();
5689 hazard[[i, j]] = rate;
5690 cumulative_hazard[[i, j]] = rate * time;
5691 }
5692 }
5693 SurvivalPredictResult {
5694 times,
5695 hazard,
5696 survival,
5697 cumulative_hazard,
5698 linear_predictor: Array1::zeros(rows),
5699 likelihood_mode: SurvivalLikelihoodMode::MarginalSlope,
5700 survival_se: None,
5701 eta_se: None,
5702 covariance_source: None,
5703 }
5704 }
5705
5706 #[test]
5707 fn rmst_over_prediction_horizon_matches_the_exponential_closed_form() {
5708 let rate = 0.35_f64;
5712 let tau = 4.0_f64;
5713 let steps = 4000_usize;
5714 let times: Vec<f64> = (1..=steps)
5715 .map(|k| tau * (k as f64) / (steps as f64))
5716 .collect();
5717 let result = exponential_survival_result(times, rate, 3);
5718
5719 let rmst = result
5720 .rmst_over_prediction_horizon()
5721 .expect("a positive finite grid yields an RMST column");
5722 let expected = (1.0 - (-rate * tau).exp()) / rate;
5723
5724 assert!((rmst.tau - tau).abs() < 1e-12, "tau = {}", rmst.tau);
5725 assert_eq!(rmst.values.len(), 3);
5726 for value in rmst.values.iter() {
5727 assert!(
5731 (value - expected).abs() < 1e-6,
5732 "rmst {value} vs closed form {expected}"
5733 );
5734 }
5735 }
5736
5737 #[test]
5738 fn rmst_over_prediction_horizon_integrates_to_the_last_grid_time() {
5739 let times = vec![0.5, 1.0, 2.5, 6.0];
5740 let result = exponential_survival_result(times.clone(), 0.2, 2);
5741
5742 let horizon = result
5743 .rmst_over_prediction_horizon()
5744 .expect("non-empty grid");
5745 let explicit = result
5746 .restricted_mean_survival_time(6.0)
5747 .expect("explicit tau at the same horizon");
5748
5749 assert_eq!(horizon.tau, 6.0, "tau is the last grid point");
5750 assert_eq!(horizon.values, explicit, "no second integration policy");
5751 }
5752
5753 #[test]
5754 fn rmst_over_prediction_horizon_declines_an_empty_or_degenerate_grid() {
5755 let empty = exponential_survival_result(Vec::new(), 0.2, 2);
5756 assert!(empty.rmst_over_prediction_horizon().is_none(), "empty grid");
5757
5758 let origin_only = exponential_survival_result(vec![0.0], 0.2, 2);
5760 assert!(
5761 origin_only.rmst_over_prediction_horizon().is_none(),
5762 "tau = 0 encloses no area"
5763 );
5764 }
5765
5766 #[test]
5767 fn rmst_over_prediction_horizon_declines_a_non_finite_curve() {
5768 let mut result = exponential_survival_result(vec![1.0, 2.0], 0.2, 2);
5769 result.survival[[1, 0]] = f64::NAN;
5770 assert!(
5771 result.rmst_over_prediction_horizon().is_none(),
5772 "a NaN anywhere on the integrated span refuses the whole column"
5773 );
5774 }
5775
5776 #[test]
5777 fn overall_rmst_over_prediction_horizon_reads_the_all_cause_surface() {
5778 let rate = 0.25_f64;
5781 let tau = 3.0_f64;
5782 let steps = 3000_usize;
5783 let times: Vec<f64> = (1..=steps)
5784 .map(|k| tau * (k as f64) / (steps as f64))
5785 .collect();
5786 let rows = 2_usize;
5787 let t = times.len();
5788 let mut overall_survival = Array2::<f64>::zeros((rows, t));
5789 for i in 0..rows {
5790 for (j, &time) in times.iter().enumerate() {
5791 overall_survival[[i, j]] = (-2.0 * rate * time).exp();
5792 }
5793 }
5794 let per_cause = Array2::<f64>::zeros((rows, t));
5795 let result = CompetingRisksPredictResult {
5796 times,
5797 endpoint_names: vec!["a".to_string(), "b".to_string()],
5798 hazard: vec![per_cause.clone(), per_cause.clone()],
5799 survival: vec![per_cause.clone(), per_cause.clone()],
5800 cumulative_hazard: vec![per_cause.clone(), per_cause.clone()],
5801 cif: vec![per_cause.clone(), per_cause],
5802 overall_survival,
5803 linear_predictor: vec![Array1::zeros(rows), Array1::zeros(rows)],
5804 likelihood_mode: SurvivalLikelihoodMode::MarginalSlope,
5805 covariance_source: None,
5806 hazard_se: None,
5807 survival_se: None,
5808 cumulative_hazard_se: None,
5809 cif_se: None,
5810 overall_survival_se: None,
5811 eta_se: None,
5812 };
5813
5814 let rmst = result
5815 .overall_rmst_over_prediction_horizon()
5816 .expect("all-cause RMST over a positive grid");
5817 let expected = (1.0 - (-2.0 * rate * tau).exp()) / (2.0 * rate);
5818
5819 assert!((rmst.tau - tau).abs() < 1e-12);
5820 for value in rmst.values.iter() {
5821 assert!(
5822 (value - expected).abs() < 1e-6,
5823 "all-cause rmst {value} vs closed form {expected}"
5824 );
5825 }
5826 }
5827
5828}
5829
5830const SURVIVAL_DEFAULT_GRID_SCALE_MARGIN: f64 = 5.0;
5834
5835pub fn survival_training_time_upper_bound(payload: &FittedModelPayload) -> Option<f64> {
5848 if let Some(knots) = payload.survival_time_knots.as_ref() {
5849 let max_log_knot = knots
5850 .iter()
5851 .copied()
5852 .filter(|value| value.is_finite())
5853 .fold(f64::NEG_INFINITY, f64::max);
5854 if max_log_knot.is_finite() {
5855 let hi = max_log_knot.exp();
5856 if hi.is_finite() && hi > 0.0 {
5857 return Some(hi);
5858 }
5859 }
5860 }
5861
5862 let mut upper = f64::NEG_INFINITY;
5878 if let Some(training_hi) = survival_training_exit_upper_bound(payload) {
5879 upper = upper.max(training_hi);
5880 }
5881 if let Some(scale) = payload.survival_baseline_scale
5882 && scale.is_finite()
5883 && scale > 0.0
5884 {
5885 upper = upper.max(scale * SURVIVAL_DEFAULT_GRID_SCALE_MARGIN);
5886 }
5887 (upper.is_finite() && upper > 0.0).then_some(upper)
5888}
5889
5890fn survival_training_exit_upper_bound(payload: &FittedModelPayload) -> Option<f64> {
5898 let exit_name = payload.survival_exit.as_deref()?;
5899 let headers = payload.training_headers.as_ref()?;
5900 let ranges = payload.training_feature_ranges.as_ref()?;
5901 let idx = headers.iter().position(|h| h == exit_name)?;
5902 let (_, hi) = *ranges.get(idx)?;
5903 (hi.is_finite() && hi > 0.0).then_some(hi)
5904}
5905
5906pub fn default_survival_time_grid(
5923 formula: &str,
5924 dataset: &EncodedDataset,
5925 training_time_upper: Option<f64>,
5926) -> Result<Option<Vec<f64>>, String> {
5927 let parsed = gam_terms::inference::formula_dsl::parse_formula(formula)
5928 .map_err(|err| format!("failed to parse survival formula: {err}"))?;
5929 let Some((entry_name, exit_name, _event_name)) =
5930 gam_terms::inference::formula_dsl::parse_surv_response(&parsed.response)
5931 .map_err(|err| format!("failed to parse Surv(...) response: {err}"))?
5932 else {
5933 return Ok(None);
5934 };
5935
5936 let header_to_index: HashMap<&str, usize> = dataset
5937 .headers
5938 .iter()
5939 .enumerate()
5940 .map(|(index, name)| (name.as_str(), index))
5941 .collect();
5942 let entry_idx = match entry_name.as_deref() {
5943 Some(name) => match header_to_index.get(name).copied() {
5944 Some(idx) => Some(idx),
5945 None => {
5946 return Err(format!(
5947 "survival prediction data is missing required time column(s): {name}"
5948 ));
5949 }
5950 },
5951 None => None,
5952 };
5953 let exit_idx = match header_to_index.get(exit_name.as_str()).copied() {
5954 Some(idx) => idx,
5955 None => {
5956 return Err(format!(
5957 "survival prediction data is missing required time column(s): {exit_name}"
5958 ));
5959 }
5960 };
5961
5962 if let Some(index) = entry_idx
5963 && matches!(
5964 dataset.schema.columns[index].kind,
5965 gam_data::ColumnKindTag::Categorical
5966 )
5967 {
5968 return Err(format!(
5969 "survival entry column '{}' is categorical, expected numeric times",
5970 entry_name.as_deref().unwrap_or_default()
5971 ));
5972 }
5973 if matches!(
5974 dataset.schema.columns[exit_idx].kind,
5975 gam_data::ColumnKindTag::Categorical
5976 ) {
5977 return Err(format!(
5978 "survival exit column '{exit_name}' is categorical, expected numeric times"
5979 ));
5980 }
5981 let mut lo = f64::INFINITY;
5982 let mut hi = f64::NEG_INFINITY;
5983 for row_index in 0..dataset.values.nrows() {
5984 let entry_value = match entry_idx {
5985 None => 0.0,
5986 Some(index) => dataset.values[[row_index, index]],
5987 };
5988 let exit_value = dataset.values[[row_index, exit_idx]];
5989 if !entry_value.is_finite() || !exit_value.is_finite() {
5990 return Err("survival time columns must contain only finite values".to_string());
5991 }
5992 lo = lo.min(entry_value);
5993 hi = hi.max(exit_value);
5994 }
5995 if dataset.values.nrows() == 0 {
5996 return Ok(None);
5997 }
5998 if let Some(training_hi) = training_time_upper
5999 && training_hi.is_finite()
6000 {
6001 hi = training_hi;
6002 }
6003 if hi <= lo {
6004 return Err(format!(
6005 "survival exit times must extend beyond entry times; got min entry {lo:?} and max exit {hi:?}"
6006 ));
6007 }
6008 let span = hi - lo;
6009 let hi_padded = hi + (span * 1.0e-6).max(1.0e-9);
6010 let step = (hi_padded - lo) / 63.0;
6011 Ok(Some(
6012 (0..64).map(|index| lo + step * (index as f64)).collect(),
6013 ))
6014}