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, SavedBaselineTimeWiggleRuntime,
17 load_survival_time_basis_config_from_model, survival_baseline_config_from_model,
18};
19use crate::inference::predict_io::{BernoulliMarginalSlopePredictor, PredictInput};
20use crate::model_types::{BlockRole, FittedBlock, FittedLinkState, UnifiedFitResult};
21use crate::probability::signed_probit_logcdf_and_mills_ratio;
22use crate::survival::construction::{
23 SurvivalBaselineConfig, SurvivalBaselineTarget, SurvivalLikelihoodMode,
24 SurvivalTimeBuildOutput, add_survival_time_derivative_guard_offset, build_survival_time_basis,
25 build_survival_time_offsets_for_likelihood, build_survival_timewiggle_derivative_design,
26 center_survival_time_designs_at_anchor, evaluate_survival_time_basis_row,
27 normalize_survival_time_pair, parse_survival_likelihood_mode,
28 require_structural_survival_time_basis, resolved_survival_time_basis_config_from_build,
29 survival_derivative_guard_for_likelihood, survival_likelihood_modename,
30};
31use crate::survival::latent::fixed_latent_hazard_frailty;
32use crate::survival::lognormal_kernel::FrailtySpec;
33use crate::survival::{CompetingRisksCifResult, assemble_competing_risks_cif_from_endpoints};
34use crate::wiggle::buildwiggle_block_input_from_knots;
35use gam_linalg::matrix::DesignMatrix;
36use gam_problem::{InverseLink, LikelihoodSpec, ResponseFamily, StandardLink};
37use gam_solve::mixture_link::inverse_link_jet_for_inverse_link;
38use gam_terms::smooth::TermCollectionSpec;
39use gam_terms::smooth::build_term_collection_design;
40use gam_terms::term_builder::resolve_role_col;
41
42pub struct SurvivalTimeColumns {
50 pub entry_col: Option<usize>,
51 pub exit_col: usize,
52}
53
54impl SurvivalTimeColumns {
55 #[inline]
58 pub fn row_entry_time(&self, data: ArrayView2<'_, f64>, i: usize) -> f64 {
59 self.entry_col.map_or(0.0, |idx| data[[i, idx]])
60 }
61}
62
63pub fn resolve_saved_survival_time_columns(
67 model: &SavedModel,
68 col_map: &HashMap<String, usize>,
69) -> Result<SurvivalTimeColumns, String> {
70 let entry_col: Option<usize> = model
71 .survival_entry
72 .as_deref()
73 .map(|name| resolve_role_col(col_map, name, "entry"))
74 .transpose()?;
75 let exitname = model
76 .survival_exit
77 .as_ref()
78 .ok_or_else(|| "survival model missing exit column metadata".to_string())?;
79 let exit_col = resolve_role_col(col_map, exitname, "exit")?;
80 Ok(SurvivalTimeColumns {
81 entry_col,
82 exit_col,
83 })
84}
85
86const SURVIVAL_PROB_MIN_FOR_LOG: f64 = 1e-300;
92
93#[derive(Debug, Clone)]
100pub enum SurvivalPredictError {
101 InvalidInput { reason: String },
104 MissingFitMetadata { reason: String },
108 IncompatibleSchema { reason: String },
111 UnsupportedConfiguration { reason: String },
116 PosteriorCovariance { reason: String },
122 NumericalFailure { reason: String },
126 ModelPayload {
129 context: &'static str,
130 source: crate::inference::model::FittedModelError,
131 },
132}
133
134impl std::fmt::Display for SurvivalPredictError {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 match self {
137 SurvivalPredictError::InvalidInput { reason }
138 | SurvivalPredictError::MissingFitMetadata { reason }
139 | SurvivalPredictError::IncompatibleSchema { reason }
140 | SurvivalPredictError::UnsupportedConfiguration { reason }
141 | SurvivalPredictError::PosteriorCovariance { reason }
142 | SurvivalPredictError::NumericalFailure { reason } => f.write_str(reason),
143 SurvivalPredictError::ModelPayload { context, source } => {
144 write!(f, "{context}: {source}")
145 }
146 }
147 }
148}
149
150impl std::error::Error for SurvivalPredictError {
151 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
152 match self {
153 SurvivalPredictError::ModelPayload { source, .. } => Some(source),
154 SurvivalPredictError::InvalidInput { .. }
155 | SurvivalPredictError::MissingFitMetadata { .. }
156 | SurvivalPredictError::IncompatibleSchema { .. }
157 | SurvivalPredictError::UnsupportedConfiguration { .. }
158 | SurvivalPredictError::PosteriorCovariance { .. }
159 | SurvivalPredictError::NumericalFailure { .. } => None,
160 }
161 }
162}
163
164impl From<SurvivalPredictError> for String {
165 fn from(err: SurvivalPredictError) -> String {
166 err.to_string()
167 }
168}
169
170impl From<String> for SurvivalPredictError {
171 fn from(reason: String) -> SurvivalPredictError {
177 SurvivalPredictError::InvalidInput { reason }
178 }
179}
180
181impl From<gam_data::DataError> for SurvivalPredictError {
182 fn from(err: gam_data::DataError) -> SurvivalPredictError {
186 SurvivalPredictError::InvalidInput {
187 reason: err.to_string(),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
200pub enum SurvivalPredictEstimand {
201 #[default]
202 PosteriorMean,
203 Plugin,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum SurvivalPredictionCovarianceMode {
215 Conditional,
216 SmoothingCorrected,
217}
218
219impl SurvivalPredictionCovarianceMode {
220 pub const fn as_str(self) -> &'static str {
221 match self {
222 Self::Conditional => "conditional",
223 Self::SmoothingCorrected => "smoothing-corrected",
224 }
225 }
226}
227
228pub struct SurvivalPredictRequest<'a> {
230 pub model: &'a SavedModel,
231 pub data: ArrayView2<'a, f64>,
232 pub col_map: &'a HashMap<String, usize>,
233 pub training_headers: Option<&'a Vec<String>>,
234 pub primary_offset: &'a Array1<f64>,
235 pub noise_offset: &'a Array1<f64>,
236 pub time_grid: Option<&'a [f64]>,
239 pub with_uncertainty: bool,
245 pub estimand: SurvivalPredictEstimand,
248}
249
250pub struct SurvivalPredictResult {
252 pub times: Vec<f64>,
253 pub hazard: Array2<f64>,
254 pub survival: Array2<f64>,
255 pub cumulative_hazard: Array2<f64>,
256 pub linear_predictor: Array1<f64>,
257 pub likelihood_mode: SurvivalLikelihoodMode,
258 pub survival_se: Option<Array2<f64>>,
262 pub eta_se: Option<Array1<f64>>,
266 pub covariance_source: Option<SurvivalPredictionCovarianceMode>,
271}
272
273pub struct LatentWindowSurvivalResult {
282 pub window_survival: Array1<f64>,
283 pub likelihood_mode: SurvivalLikelihoodMode,
284}
285
286pub fn predict_latent_window_survival(
294 req: SurvivalPredictRequest<'_>,
295) -> Result<LatentWindowSurvivalResult, SurvivalPredictError> {
296 let SurvivalPredictRequest {
297 model,
298 data,
299 col_map,
300 training_headers,
301 primary_offset,
302 noise_offset,
303 time_grid,
304 with_uncertainty,
305 estimand,
306 } = req;
307 if time_grid.is_some() {
308 return Err(SurvivalPredictError::InvalidInput {
309 reason: "latent-window prediction consumes each row's saved entry/exit columns; an independent time_grid is not a window law".to_string(),
310 });
311 }
312 if with_uncertainty || estimand != SurvivalPredictEstimand::Plugin {
313 return Err(SurvivalPredictError::UnsupportedConfiguration {
314 reason: "latent-window observation generation requires the fitted plug-in hazard law; posterior coefficient integration is a different sampling target".to_string(),
315 });
316 }
317
318 let likelihood_mode = require_saved_survival_likelihood_mode(model)?;
319 if !matches!(
320 likelihood_mode,
321 SurvivalLikelihoodMode::Latent | SurvivalLikelihoodMode::LatentBinary
322 ) {
323 return Err(SurvivalPredictError::UnsupportedConfiguration {
324 reason: format!(
325 "latent-window prediction requires latent or latent-binary likelihood mode, got {}",
326 survival_likelihood_modename(likelihood_mode)
327 ),
328 });
329 }
330 if model.has_baseline_time_wiggle() {
331 return Err(SurvivalPredictError::IncompatibleSchema {
332 reason:
333 "saved latent survival/binary model contains forbidden baseline timewiggle metadata"
334 .to_string(),
335 });
336 }
337
338 let n = data.nrows();
339 if primary_offset.len() != n || noise_offset.len() != n {
340 return Err(SurvivalPredictError::InvalidInput {
341 reason: format!(
342 "latent-window offset length mismatch: rows={n}, primary={}, noise={}",
343 primary_offset.len(),
344 noise_offset.len()
345 ),
346 });
347 }
348 if noise_offset.iter().any(|value| *value != 0.0) {
349 return Err(SurvivalPredictError::InvalidInput {
350 reason: "latent-window survival has no secondary offset coordinate".to_string(),
351 });
352 }
353
354 let termspec = resolve_termspec_for_prediction(
355 &model.resolved_termspec,
356 training_headers,
357 col_map,
358 "resolved_termspec",
359 )?;
360 let clipped = model.axis_clip_to_training_ranges(data, col_map);
361 let covariate_input = clipped.as_ref().map_or(data, |array| array.view());
362 let covariate_design = build_term_collection_design(covariate_input, &termspec)
363 .map_err(|error| format!("failed to build latent-window covariate design: {error}"))?;
364 let effective_primary_offset = covariate_design
365 .compose_offset(primary_offset.view(), "latent-window covariate block")
366 .map_err(|error| error.to_string())?;
367
368 let time_columns = resolve_saved_survival_time_columns(model, col_map)?;
369 let mut age_entry = Array1::<f64>::zeros(n);
370 let mut age_exit = Array1::<f64>::zeros(n);
371 for row in 0..n {
372 let (entry, exit) = normalize_survival_time_pair(
373 time_columns.row_entry_time(data, row),
374 data[[row, time_columns.exit_col]],
375 row,
376 )?;
377 age_entry[row] = entry;
378 age_exit[row] = exit;
379 }
380
381 let time_config = load_survival_time_basis_config_from_model(model)?;
382 let mut time_build = build_survival_time_basis(&age_entry, &age_exit, time_config, None)?;
383 let resolved_time_config = resolved_survival_time_basis_config_from_build(
384 &time_build.basisname,
385 time_build.degree,
386 time_build.knots.as_ref(),
387 time_build.keep_cols.as_ref(),
388 time_build.smooth_lambda,
389 )?;
390 let time_anchor =
391 model
392 .survival_time_anchor
393 .ok_or_else(|| SurvivalPredictError::MissingFitMetadata {
394 reason: "saved latent-window model is missing survival_time_anchor".to_string(),
395 })?;
396 let anchor_row = evaluate_survival_time_basis_row(time_anchor, &resolved_time_config)?;
397 center_survival_time_designs_at_anchor(
398 &mut time_build.x_entry_time,
399 &mut time_build.x_exit_time,
400 &anchor_row,
401 )?;
402 require_structural_survival_time_basis(
403 &time_build.basisname,
404 "saved latent-window prediction",
405 )?;
406
407 let frailty =
408 model
409 .family_state
410 .frailty()
411 .ok_or_else(|| SurvivalPredictError::MissingFitMetadata {
412 reason: "saved latent-window model is missing its hazard-multiplier frailty"
413 .to_string(),
414 })?;
415 let (sigma, loading) = fixed_latent_hazard_frailty(frailty, "saved latent-window prediction")
416 .map_err(|reason| SurvivalPredictError::MissingFitMetadata { reason })?;
417 let baseline_config = saved_survival_runtime_baseline_config(model)?;
418 let prepared = prepare_survival_time_stack(
419 &age_entry,
420 &age_exit,
421 &baseline_config,
422 likelihood_mode,
423 None,
424 time_anchor,
425 survival_derivative_guard_for_likelihood(likelihood_mode),
426 &time_build,
427 None,
428 Some(loading),
429 )?;
430
431 let fit = fit_result_from_saved_model_for_prediction(model)?;
432 let mean_block = fit.block_by_role(BlockRole::Mean).ok_or_else(|| {
433 SurvivalPredictError::MissingFitMetadata {
434 reason: "saved latent-window model is missing its mean coefficient block".to_string(),
435 }
436 })?;
437 let time_block = fit.block_by_role(BlockRole::Time).ok_or_else(|| {
438 SurvivalPredictError::MissingFitMetadata {
439 reason: "saved latent-window model is missing its time coefficient block".to_string(),
440 }
441 })?;
442 if mean_block.beta.len() != covariate_design.design.ncols() {
443 return Err(SurvivalPredictError::IncompatibleSchema {
444 reason: format!(
445 "latent-window mean/design mismatch: beta has {} coefficients but design has {} columns",
446 mean_block.beta.len(),
447 covariate_design.design.ncols()
448 ),
449 });
450 }
451 if time_block.beta.len() != prepared.time_design_exit.ncols() {
452 let hint = stale_weibull_time_basis_hint(
453 &time_build.basisname,
454 time_block.beta.len() == prepared.time_design_exit.ncols() + 1,
455 );
456 return Err(SurvivalPredictError::IncompatibleSchema {
457 reason: format!(
458 "latent-window time/design mismatch: beta has {} coefficients but design has {} columns{hint}",
459 time_block.beta.len(),
460 prepared.time_design_exit.ncols()
461 ),
462 });
463 }
464
465 let eta = covariate_design.design.dot(&mean_block.beta) + &effective_primary_offset;
466 let q_entry = prepared.time_design_entry.dot(&time_block.beta) + &prepared.eta_offset_entry;
467 let q_exit = prepared.time_design_exit.dot(&time_block.beta) + &prepared.eta_offset_exit;
468 let quadrature = gam_solve::quadrature::QuadratureContext::new();
469 let mut window_survival = Array1::<f64>::zeros(n);
470 for row in 0..n {
471 let latent_row = crate::survival::lognormal_kernel::LatentSurvivalRow::right_censored(
472 q_entry[row].exp(),
473 q_exit[row].exp(),
474 prepared.unloaded_mass_entry[row],
475 prepared.unloaded_mass_exit[row],
476 );
477 let jet = crate::survival::lognormal_kernel::LatentSurvivalRowJet::evaluate(
478 &quadrature,
479 &latent_row,
480 eta[row],
481 sigma,
482 )
483 .map_err(|error| SurvivalPredictError::NumericalFailure {
484 reason: format!("latent-window row {row} evaluation failed: {error}"),
485 })?;
486 let survival = jet.log_lik.exp();
487 if !(survival.is_finite() && (0.0..=1.0).contains(&survival)) {
488 return Err(SurvivalPredictError::NumericalFailure {
489 reason: format!(
490 "latent-window row {row} produced invalid conditional survival {survival}"
491 ),
492 });
493 }
494 window_survival[row] = survival;
495 }
496
497 Ok(LatentWindowSurvivalResult {
498 window_survival,
499 likelihood_mode,
500 })
501}
502
503fn select_survival_prediction_covariance<'a>(
504 conditional: Option<&'a Array2<f64>>,
505 smoothing_corrected: Option<&'a Array2<f64>>,
506 mode: SurvivalPredictionCovarianceMode,
507) -> Result<&'a Array2<f64>, SurvivalPredictError> {
508 match mode {
509 SurvivalPredictionCovarianceMode::Conditional => {
510 conditional.ok_or_else(|| SurvivalPredictError::PosteriorCovariance {
511 reason: "fit result does not contain conditional covariance".to_string(),
512 })
513 }
514 SurvivalPredictionCovarianceMode::SmoothingCorrected => {
515 smoothing_corrected.ok_or_else(|| SurvivalPredictError::PosteriorCovariance {
516 reason: "fit result does not contain smoothing-corrected covariance".to_string(),
517 })
518 }
519 }
520}
521
522fn survival_prediction_posterior_factor(
528 model: &SavedModel,
529 covariance_mode: SurvivalPredictionCovarianceMode,
530) -> Result<(Array1<f64>, Array2<f64>, Vec<usize>), SurvivalPredictError> {
531 let fit = fit_result_from_saved_model_for_prediction(model)?;
532 let inactive_tail = if require_saved_survival_likelihood_mode(model)?
533 == SurvivalLikelihoodMode::MarginalSlope
534 {
535 model
536 .saved_prediction_runtime()?
537 .influence_absorber_width
538 .unwrap_or(0)
539 } else {
540 0
541 };
542 let active_len = fit.beta.len().checked_sub(inactive_tail).ok_or_else(|| {
543 SurvivalPredictError::IncompatibleSchema {
544 reason: format!(
545 "saved survival influence-absorber width {inactive_tail} exceeds the {} fitted coefficients",
546 fit.beta.len()
547 ),
548 }
549 })?;
550 let covariance = select_survival_prediction_covariance(
551 fit.beta_covariance(),
552 fit.beta_covariance_corrected(),
553 covariance_mode,
554 )?;
555 if covariance.nrows() != fit.beta.len() || covariance.ncols() != fit.beta.len() {
556 return Err(SurvivalPredictError::PosteriorCovariance {
557 reason: format!(
558 "saved survival {} covariance has shape {}x{}, expected {}x{} in fitted block order",
559 covariance_mode.as_str(),
560 covariance.nrows(),
561 covariance.ncols(),
562 fit.beta.len(),
563 fit.beta.len(),
564 ),
565 });
566 }
567 let cone_coords = survival_posterior_cone_coordinates(model, active_len)?;
568 Ok((
569 fit.beta.clone(),
570 covariance.slice(s![..active_len, ..active_len]).to_owned(),
571 cone_coords,
572 ))
573}
574
575fn saved_model_with_survival_coefficients(
576 model: &SavedModel,
577 coefficients: &Array1<f64>,
578) -> Result<SavedModel, SurvivalPredictError> {
579 let mut draw_model = model.clone();
580 let payload = match &mut draw_model {
581 SavedModel::Standard { payload }
582 | SavedModel::LocationScale { payload }
583 | SavedModel::MarginalSlope { payload }
584 | SavedModel::Survival { payload }
585 | SavedModel::TransformationNormal { payload } => payload,
586 };
587
588 let (beta_time, beta_threshold, beta_log_sigma, beta_link_wiggle, beta_time_blocks) = {
589 let fit = payload.fit_result.as_mut().ok_or_else(|| {
590 SurvivalPredictError::MissingFitMetadata {
591 reason: "saved survival model is missing canonical fit_result".to_string(),
592 }
593 })?;
594 if coefficients.len() != fit.beta.len() {
595 return Err(SurvivalPredictError::IncompatibleSchema {
596 reason: format!(
597 "posterior survival coefficient draw has length {}, expected {}",
598 coefficients.len(),
599 fit.beta.len()
600 ),
601 });
602 }
603 fit.beta.assign(coefficients);
604 let mut cursor = 0usize;
605 for block in &mut fit.blocks {
606 let end = cursor + block.beta.len();
607 block.beta.assign(&coefficients.slice(s![cursor..end]));
608 cursor = end;
609 }
610 if cursor != coefficients.len() {
611 return Err(SurvivalPredictError::IncompatibleSchema {
612 reason: format!(
613 "saved survival coefficient blocks total {cursor} entries, but the joint vector has {}",
614 coefficients.len()
615 ),
616 });
617 }
618 (
619 fit.block_by_role(BlockRole::Time)
620 .map(|block| block.beta.to_vec()),
621 fit.block_by_role(BlockRole::Threshold)
622 .map(|block| block.beta.to_vec()),
623 fit.block_by_role(BlockRole::Scale)
624 .map(|block| block.beta.to_vec()),
625 fit.block_by_role(BlockRole::LinkWiggle)
626 .map(|block| block.beta.to_vec()),
627 fit.blocks
628 .iter()
629 .map(|block| block.beta.to_vec())
630 .collect::<Vec<_>>(),
631 )
632 };
633
634 if payload.survival_beta_time.is_some() {
635 payload.survival_beta_time = beta_time.clone();
636 }
637 if payload.survival_beta_threshold.is_some() {
638 payload.survival_beta_threshold = beta_threshold;
639 }
640 if payload.survival_beta_log_sigma.is_some() {
641 payload.survival_beta_log_sigma = beta_log_sigma;
642 }
643 if payload.beta_link_wiggle.is_some() {
644 payload.beta_link_wiggle = beta_link_wiggle;
645 }
646 if let (Some(saved), Some(time_beta)) = (
647 payload.beta_baseline_timewiggle.as_mut(),
648 beta_time.as_ref(),
649 ) {
650 if saved.len() > time_beta.len() {
651 return Err(SurvivalPredictError::IncompatibleSchema {
652 reason: format!(
653 "saved baseline-timewiggle has {} coefficients, but the time block has {}",
654 saved.len(),
655 time_beta.len()
656 ),
657 });
658 }
659 *saved = time_beta[time_beta.len() - saved.len()..].to_vec();
660 }
661 if let Some(saved_by_cause) = payload.beta_baseline_timewiggle_by_cause.as_mut() {
662 if saved_by_cause.len() != beta_time_blocks.len() {
663 return Err(SurvivalPredictError::IncompatibleSchema {
664 reason: format!(
665 "saved cause-specific timewiggles have {} blocks, but the fit has {} cause blocks",
666 saved_by_cause.len(),
667 beta_time_blocks.len()
668 ),
669 });
670 }
671 for (saved, block) in saved_by_cause.iter_mut().zip(&beta_time_blocks) {
672 if saved.len() > block.len() {
673 return Err(SurvivalPredictError::IncompatibleSchema {
674 reason: format!(
675 "saved cause-specific timewiggle has {} coefficients, but its endpoint block has {}",
676 saved.len(),
677 block.len()
678 ),
679 });
680 }
681 *saved = block[block.len() - saved.len()..].to_vec();
682 }
683 }
684 Ok(draw_model)
685}
686
687fn conditional_event_density(
688 survival: f64,
689 cumulative_hazard: f64,
690 hazard: f64,
691) -> Result<f64, SurvivalPredictError> {
692 if hazard == 0.0 {
693 return Ok(0.0);
694 }
695 if survival > 0.0 && hazard.is_finite() {
696 return Ok(survival * hazard);
697 }
698 if cumulative_hazard.is_finite() && hazard > 0.0 {
699 return Ok((hazard.ln() - cumulative_hazard).exp());
700 }
701 if cumulative_hazard == f64::INFINITY && hazard.is_finite() && hazard >= 0.0 {
702 return Ok(0.0);
703 }
704 Err(SurvivalPredictError::NumericalFailure {
705 reason: format!(
706 "posterior survival quadrature could not resolve conditional density from S={survival}, H={cumulative_hazard}, h={hazard}"
707 ),
708 })
709}
710
711fn for_each_survival_posterior_node<F>(
742 posterior_mean: &Array1<f64>,
743 active_covariance: &Array2<f64>,
744 cone_coords: &[usize],
745 mut consume: F,
746) -> Result<(), SurvivalPredictError>
747where
748 F: FnMut(&Array1<f64>, f64) -> Result<(), SurvivalPredictError>,
749{
750 let active_len = active_covariance.nrows();
751 if active_covariance.ncols() != active_len || active_len > posterior_mean.len() {
752 return Err(SurvivalPredictError::PosteriorCovariance {
753 reason: format!(
754 "survival posterior quadrature received mean length {} and active covariance {}x{}",
755 posterior_mean.len(),
756 active_covariance.nrows(),
757 active_covariance.ncols(),
758 ),
759 });
760 }
761 let factorization = crate::survival::location_scale::factorize_psd_covariance(
762 active_covariance,
763 "survival posterior coefficient covariance",
764 )
765 .map_err(|reason| SurvivalPredictError::PosteriorCovariance { reason })?;
766 let rank = factorization.factor.ncols();
767 if rank == 0 {
768 return consume(posterior_mean, 1.0);
769 }
770 let nominal_scale = (rank as f64).sqrt();
771 let weight = 1.0 / (2 * rank) as f64;
772 for column in 0..rank {
773 let mut scale = nominal_scale;
780 for &j in cone_coords {
781 if j >= active_len {
782 continue;
783 }
784 let load = factorization.factor[[j, column]].abs();
785 if load == 0.0 {
786 continue;
787 }
788 let limit = posterior_mean[j].max(0.0) / load;
789 if limit < scale {
790 scale = limit;
791 }
792 }
793 for sign in [-1.0_f64, 1.0_f64] {
794 let mut node = posterior_mean.clone();
795 for row in 0..active_len {
796 node[row] += sign * scale * factorization.factor[[row, column]];
797 }
798 consume(&node, weight)?;
799 }
800 }
801 Ok(())
802}
803
804fn survival_posterior_cone_coordinates(
818 model: &SavedModel,
819 active_len: usize,
820) -> Result<Vec<usize>, SurvivalPredictError> {
821 if require_saved_survival_likelihood_mode(model)? != SurvivalLikelihoodMode::Transformation {
822 return Ok(Vec::new());
823 }
824 let time_cfg = load_survival_time_basis_config_from_model(model)
829 .map_err(|err| SurvivalPredictError::MissingFitMetadata {
830 reason: err.to_string(),
831 })?;
832 let p_time_base = if matches!(
833 time_cfg,
834 crate::survival::construction::SurvivalTimeBasisConfig::None
835 ) {
836 0
837 } else {
838 let dummy = Array1::from_elem(1, 1.0_f64);
839 build_survival_time_basis(&dummy, &dummy, time_cfg, None)
840 .map_err(|reason| SurvivalPredictError::MissingFitMetadata { reason })?
841 .x_exit_time
842 .ncols()
843 };
844
845 let fit = fit_result_from_saved_model_for_prediction(model)?;
846 let cause_count = model
847 .survival_cause_count
848 .unwrap_or(fit.blocks.len())
849 .max(1);
850 let per_cause_wiggle: Vec<usize> = if cause_count > 1 {
854 saved_cause_specific_timewiggles(model, &fit, cause_count)?
855 .iter()
856 .map(|w| w.as_ref().map_or(0, |runtime| runtime.beta.len()))
857 .collect()
858 } else {
859 vec![
860 model
861 .saved_baseline_time_wiggle()
862 .map_err(|err| SurvivalPredictError::MissingFitMetadata {
863 reason: err.to_string(),
864 })?
865 .map_or(0, |runtime| runtime.beta.len()),
866 ]
867 };
868
869 let mut cone = Vec::new();
870 let mut cursor = 0usize;
871 for (cause, block) in fit.blocks.iter().enumerate() {
872 let block_len = block.beta.len();
873 let width = (p_time_base + per_cause_wiggle.get(cause).copied().unwrap_or(0)).min(block_len);
874 for j in cursor..cursor + width {
875 if j < active_len {
876 cone.push(j);
877 }
878 }
879 cursor += block_len;
880 }
881 Ok(cone)
882}
883
884fn posterior_standard_error_matrix(
885 mean: &Array2<f64>,
886 second_moment: &Array2<f64>,
887 label: &str,
888) -> Result<Array2<f64>, SurvivalPredictError> {
889 if second_moment.dim() != mean.dim() {
890 return Err(SurvivalPredictError::IncompatibleSchema {
891 reason: format!(
892 "posterior {label} moment shape mismatch: mean={:?}, second={:?}",
893 mean.dim(),
894 second_moment.dim(),
895 ),
896 });
897 }
898 let mut standard_error = Array2::<f64>::zeros(mean.raw_dim());
899 for ((row, column), slot) in standard_error.indexed_iter_mut() {
900 let first = mean[[row, column]];
901 let second = second_moment[[row, column]];
902 if !(first.is_finite() && second.is_finite()) {
903 return Err(SurvivalPredictError::NumericalFailure {
904 reason: format!(
905 "posterior {label} moments must be finite at row {row}, time column {column}: mean={first}, second={second}"
906 ),
907 });
908 }
909 let variance = second - first * first;
910 let roundoff_tolerance =
911 128.0 * f64::EPSILON * second.abs().max((first * first).abs()).max(1.0);
912 if variance < -roundoff_tolerance {
913 return Err(SurvivalPredictError::NumericalFailure {
914 reason: format!(
915 "posterior {label} variance is negative beyond roundoff at row {row}, time column {column}: {variance}"
916 ),
917 });
918 }
919 *slot = variance.max(0.0).sqrt();
920 }
921 Ok(standard_error)
922}
923
924fn posterior_standard_error_vector(
925 mean: &Array1<f64>,
926 second_moment: &Array1<f64>,
927 label: &str,
928) -> Result<Array1<f64>, SurvivalPredictError> {
929 if second_moment.len() != mean.len() {
930 return Err(SurvivalPredictError::IncompatibleSchema {
931 reason: format!(
932 "posterior {label} moment length mismatch: mean={}, second={}",
933 mean.len(),
934 second_moment.len(),
935 ),
936 });
937 }
938 let mut standard_error = Array1::<f64>::zeros(mean.len());
939 for row in 0..mean.len() {
940 let first = mean[row];
941 let second = second_moment[row];
942 if !(first.is_finite() && second.is_finite()) {
943 return Err(SurvivalPredictError::NumericalFailure {
944 reason: format!(
945 "posterior {label} moments must be finite at row {row}: mean={first}, second={second}"
946 ),
947 });
948 }
949 let variance = second - first * first;
950 let roundoff_tolerance =
951 128.0 * f64::EPSILON * second.abs().max((first * first).abs()).max(1.0);
952 if variance < -roundoff_tolerance {
953 return Err(SurvivalPredictError::NumericalFailure {
954 reason: format!(
955 "posterior {label} variance is negative beyond roundoff at row {row}: {variance}"
956 ),
957 });
958 }
959 standard_error[row] = variance.max(0.0).sqrt();
960 }
961 Ok(standard_error)
962}
963
964fn posterior_standard_error_surfaces(
965 mean: &[Array2<f64>],
966 second_moment: &[Array2<f64>],
967 label: &str,
968) -> Result<Vec<Array2<f64>>, SurvivalPredictError> {
969 if second_moment.len() != mean.len() {
970 return Err(SurvivalPredictError::IncompatibleSchema {
971 reason: format!(
972 "posterior {label} cause count mismatch: mean={}, second={}",
973 mean.len(),
974 second_moment.len(),
975 ),
976 });
977 }
978 mean.iter()
979 .zip(second_moment)
980 .enumerate()
981 .map(|(cause, (first, second))| {
982 posterior_standard_error_matrix(first, second, &format!("{label} cause {}", cause + 1))
983 })
984 .collect()
985}
986
987fn posterior_standard_error_vectors(
988 mean: &[Array1<f64>],
989 second_moment: &[Array1<f64>],
990 label: &str,
991) -> Result<Vec<Array1<f64>>, SurvivalPredictError> {
992 if second_moment.len() != mean.len() {
993 return Err(SurvivalPredictError::IncompatibleSchema {
994 reason: format!(
995 "posterior {label} cause count mismatch: mean={}, second={}",
996 mean.len(),
997 second_moment.len(),
998 ),
999 });
1000 }
1001 mean.iter()
1002 .zip(second_moment)
1003 .enumerate()
1004 .map(|(cause, (first, second))| {
1005 posterior_standard_error_vector(first, second, &format!("{label} cause {}", cause + 1))
1006 })
1007 .collect()
1008}
1009
1010fn predict_survival_posterior_mean(
1011 req: SurvivalPredictRequest<'_>,
1012 covariance_mode: SurvivalPredictionCovarianceMode,
1013) -> Result<SurvivalPredictResult, SurvivalPredictError> {
1014 let (posterior_mean, active_covariance, cone_coords) =
1015 survival_prediction_posterior_factor(req.model, covariance_mode)?;
1016 let mut result = predict_survival(
1017 SurvivalPredictRequest {
1018 model: req.model,
1019 data: req.data,
1020 col_map: req.col_map,
1021 training_headers: req.training_headers,
1022 primary_offset: req.primary_offset,
1023 noise_offset: req.noise_offset,
1024 time_grid: req.time_grid,
1025 with_uncertainty: false,
1026 estimand: SurvivalPredictEstimand::Plugin,
1027 },
1028 covariance_mode,
1029 )?;
1030 let (n_rows, n_times) = result.survival.dim();
1031 let mut survival_mean = Array2::<f64>::zeros((n_rows, n_times));
1032 let mut survival_second = Array2::<f64>::zeros((n_rows, n_times));
1033 let mut density_mean = Array2::<f64>::zeros((n_rows, n_times));
1034 let mut hazard_mean = Array2::<f64>::zeros((n_rows, n_times));
1035 let mut eta_mean = Array1::<f64>::zeros(n_rows);
1036 let mut eta_second = Array1::<f64>::zeros(n_rows);
1037
1038 for_each_survival_posterior_node(&posterior_mean, &active_covariance, &cone_coords, |node, weight| {
1039 let draw_model = saved_model_with_survival_coefficients(req.model, node)?;
1040 let draw = predict_survival(
1041 SurvivalPredictRequest {
1042 model: &draw_model,
1043 data: req.data,
1044 col_map: req.col_map,
1045 training_headers: req.training_headers,
1046 primary_offset: req.primary_offset,
1047 noise_offset: req.noise_offset,
1048 time_grid: req.time_grid,
1049 with_uncertainty: false,
1050 estimand: SurvivalPredictEstimand::Plugin,
1051 },
1052 covariance_mode,
1053 )?;
1054 if draw.survival.dim() != (n_rows, n_times)
1055 || draw.hazard.dim() != (n_rows, n_times)
1056 || draw.cumulative_hazard.dim() != (n_rows, n_times)
1057 || draw.linear_predictor.len() != n_rows
1058 || draw.times != result.times
1059 || draw.likelihood_mode != result.likelihood_mode
1060 {
1061 return Err(SurvivalPredictError::IncompatibleSchema {
1062 reason: "posterior survival quadrature node changed the prediction schema"
1063 .to_string(),
1064 });
1065 }
1066 for row in 0..n_rows {
1067 let eta = draw.linear_predictor[row];
1068 eta_mean[row] += weight * eta;
1069 eta_second[row] += weight * eta * eta;
1070 for time in 0..n_times {
1071 let survival = draw.survival[[row, time]];
1072 let hazard = draw.hazard[[row, time]];
1073 let density = conditional_event_density(
1074 survival,
1075 draw.cumulative_hazard[[row, time]],
1076 hazard,
1077 )?;
1078 survival_mean[[row, time]] += weight * survival;
1079 survival_second[[row, time]] += weight * survival * survival;
1080 density_mean[[row, time]] += weight * density;
1081 hazard_mean[[row, time]] += weight * hazard;
1082 }
1083 }
1084 Ok(())
1085 })?;
1086
1087 for row in 0..n_rows {
1088 for time in 0..n_times {
1089 let survival = survival_mean[[row, time]].clamp(0.0, 1.0);
1090 let density = density_mean[[row, time]];
1091 if !(density.is_finite() && density >= 0.0) {
1092 return Err(SurvivalPredictError::NumericalFailure {
1093 reason: format!(
1094 "posterior survival density is invalid at row {row}, time column {time}: {density}"
1095 ),
1096 });
1097 }
1098 result.survival[[row, time]] = survival;
1099 result.cumulative_hazard[[row, time]] = -survival.ln();
1100 result.hazard[[row, time]] = if survival > 0.0 {
1101 density / survival
1102 } else if hazard_mean[[row, time]] == 0.0 {
1103 0.0
1104 } else {
1105 f64::INFINITY
1106 };
1107 }
1108 }
1109 result.survival_se = req.with_uncertainty.then(|| {
1110 Array2::from_shape_fn((n_rows, n_times), |(row, time)| {
1111 (survival_second[[row, time]] - survival_mean[[row, time]] * survival_mean[[row, time]])
1112 .max(0.0)
1113 .sqrt()
1114 })
1115 });
1116 result.eta_se = req.with_uncertainty.then(|| {
1117 Array1::from_shape_fn(n_rows, |row| {
1118 (eta_second[row] - eta_mean[row] * eta_mean[row])
1119 .max(0.0)
1120 .sqrt()
1121 })
1122 });
1123 result.covariance_source = req.with_uncertainty.then_some(covariance_mode);
1124 Ok(result)
1125}
1126
1127fn predict_competing_risks_with_posterior(
1128 req: SurvivalPredictRequest<'_>,
1129 covariance_mode: SurvivalPredictionCovarianceMode,
1130) -> Result<CompetingRisksPredictResult, SurvivalPredictError> {
1131 let posterior_mean_estimand = req.estimand == SurvivalPredictEstimand::PosteriorMean;
1132 let (posterior_mean, active_covariance, cone_coords) =
1133 survival_prediction_posterior_factor(req.model, covariance_mode)?;
1134 let separate_conditional_point = posterior_mean_estimand
1141 && req.with_uncertainty
1142 && covariance_mode == SurvivalPredictionCovarianceMode::SmoothingCorrected;
1143 let mut result = if separate_conditional_point {
1144 predict_competing_risks_with_posterior(
1145 SurvivalPredictRequest {
1146 model: req.model,
1147 data: req.data,
1148 col_map: req.col_map,
1149 training_headers: req.training_headers,
1150 primary_offset: req.primary_offset,
1151 noise_offset: req.noise_offset,
1152 time_grid: req.time_grid,
1153 with_uncertainty: false,
1154 estimand: SurvivalPredictEstimand::PosteriorMean,
1155 },
1156 SurvivalPredictionCovarianceMode::Conditional,
1157 )?
1158 } else {
1159 predict_competing_risks_survival(
1160 SurvivalPredictRequest {
1161 model: req.model,
1162 data: req.data,
1163 col_map: req.col_map,
1164 training_headers: req.training_headers,
1165 primary_offset: req.primary_offset,
1166 noise_offset: req.noise_offset,
1167 time_grid: req.time_grid,
1168 with_uncertainty: false,
1169 estimand: SurvivalPredictEstimand::Plugin,
1170 },
1171 SurvivalPredictionCovarianceMode::Conditional,
1172 )?
1173 };
1174 let cause_count = result.cif.len();
1175 let (n_rows, n_times) = result.overall_survival.dim();
1176 let mut survival_mean = (0..cause_count)
1177 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1178 .collect::<Vec<_>>();
1179 let mut survival_second = (0..cause_count)
1180 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1181 .collect::<Vec<_>>();
1182 let mut hazard_mean = (0..cause_count)
1183 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1184 .collect::<Vec<_>>();
1185 let mut hazard_second = (0..cause_count)
1186 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1187 .collect::<Vec<_>>();
1188 let mut cumulative_hazard_mean = (0..cause_count)
1189 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1190 .collect::<Vec<_>>();
1191 let mut cumulative_hazard_second = (0..cause_count)
1192 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1193 .collect::<Vec<_>>();
1194 let mut cif_mean = (0..cause_count)
1195 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1196 .collect::<Vec<_>>();
1197 let mut cif_second = (0..cause_count)
1198 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1199 .collect::<Vec<_>>();
1200 let mut overall_mean = Array2::<f64>::zeros((n_rows, n_times));
1201 let mut overall_second = Array2::<f64>::zeros((n_rows, n_times));
1202 let mut eta_mean = (0..cause_count)
1203 .map(|_| Array1::<f64>::zeros(n_rows))
1204 .collect::<Vec<_>>();
1205 let mut eta_second = (0..cause_count)
1206 .map(|_| Array1::<f64>::zeros(n_rows))
1207 .collect::<Vec<_>>();
1208
1209 for_each_survival_posterior_node(&posterior_mean, &active_covariance, &cone_coords, |node, weight| {
1210 let draw_model = saved_model_with_survival_coefficients(req.model, node)?;
1211 let draw = predict_competing_risks_survival(
1212 SurvivalPredictRequest {
1213 model: &draw_model,
1214 data: req.data,
1215 col_map: req.col_map,
1216 training_headers: req.training_headers,
1217 primary_offset: req.primary_offset,
1218 noise_offset: req.noise_offset,
1219 time_grid: req.time_grid,
1220 with_uncertainty: false,
1221 estimand: SurvivalPredictEstimand::Plugin,
1222 },
1223 SurvivalPredictionCovarianceMode::Conditional,
1224 )?;
1225 if draw.cif.len() != cause_count
1226 || draw.survival.len() != cause_count
1227 || draw.hazard.len() != cause_count
1228 || draw.cumulative_hazard.len() != cause_count
1229 || draw.linear_predictor.len() != cause_count
1230 || draw.overall_survival.dim() != (n_rows, n_times)
1231 || draw.times != result.times
1232 || draw.endpoint_names != result.endpoint_names
1233 || draw.likelihood_mode != result.likelihood_mode
1234 {
1235 return Err(SurvivalPredictError::IncompatibleSchema {
1236 reason: "posterior competing-risks quadrature node changed the prediction schema"
1237 .to_string(),
1238 });
1239 }
1240 for cause in 0..cause_count {
1241 if draw.survival[cause].dim() != (n_rows, n_times)
1242 || draw.hazard[cause].dim() != (n_rows, n_times)
1243 || draw.cumulative_hazard[cause].dim() != (n_rows, n_times)
1244 || draw.cif[cause].dim() != (n_rows, n_times)
1245 || draw.linear_predictor[cause].len() != n_rows
1246 {
1247 return Err(SurvivalPredictError::IncompatibleSchema {
1248 reason: format!(
1249 "posterior competing-risks quadrature node changed cause {} surface dimensions",
1250 cause + 1
1251 ),
1252 });
1253 }
1254 for row in 0..n_rows {
1255 let eta = draw.linear_predictor[cause][row];
1256 eta_mean[cause][row] += weight * eta;
1257 eta_second[cause][row] += weight * eta * eta;
1258 for time in 0..n_times {
1259 let survival = draw.survival[cause][[row, time]];
1260 let hazard = draw.hazard[cause][[row, time]];
1261 let cumulative_hazard = draw.cumulative_hazard[cause][[row, time]];
1262 let cif = draw.cif[cause][[row, time]];
1263 survival_mean[cause][[row, time]] += weight * survival;
1264 survival_second[cause][[row, time]] += weight * survival * survival;
1265 hazard_mean[cause][[row, time]] += weight * hazard;
1266 hazard_second[cause][[row, time]] += weight * hazard * hazard;
1267 cumulative_hazard_mean[cause][[row, time]] += weight * cumulative_hazard;
1268 cumulative_hazard_second[cause][[row, time]] +=
1269 weight * cumulative_hazard * cumulative_hazard;
1270 cif_mean[cause][[row, time]] += weight * cif;
1271 cif_second[cause][[row, time]] += weight * cif * cif;
1272 }
1273 }
1274 }
1275 for row in 0..n_rows {
1276 for time in 0..n_times {
1277 let overall_survival = draw.overall_survival[[row, time]];
1278 overall_mean[[row, time]] += weight * overall_survival;
1279 overall_second[[row, time]] += weight * overall_survival * overall_survival;
1280 }
1281 }
1282 Ok(())
1283 })?;
1284
1285 let (hazard_se, survival_se, cumulative_hazard_se, cif_se, overall_survival_se, eta_se) =
1286 if req.with_uncertainty {
1287 (
1288 Some(posterior_standard_error_surfaces(
1289 &hazard_mean,
1290 &hazard_second,
1291 "competing-risks hazard",
1292 )?),
1293 Some(posterior_standard_error_surfaces(
1294 &survival_mean,
1295 &survival_second,
1296 "competing-risks survival",
1297 )?),
1298 Some(posterior_standard_error_surfaces(
1299 &cumulative_hazard_mean,
1300 &cumulative_hazard_second,
1301 "competing-risks cumulative hazard",
1302 )?),
1303 Some(posterior_standard_error_surfaces(
1304 &cif_mean,
1305 &cif_second,
1306 "competing-risks cumulative incidence",
1307 )?),
1308 Some(posterior_standard_error_matrix(
1309 &overall_mean,
1310 &overall_second,
1311 "competing-risks overall survival",
1312 )?),
1313 Some(posterior_standard_error_vectors(
1314 &eta_mean,
1315 &eta_second,
1316 "competing-risks linear predictor",
1317 )?),
1318 )
1319 } else {
1320 (None, None, None, None, None, None)
1321 };
1322
1323 if posterior_mean_estimand && !separate_conditional_point {
1324 result.hazard = hazard_mean;
1325 result.survival = survival_mean
1326 .into_iter()
1327 .map(|surface| surface.mapv(|value| value.clamp(0.0, 1.0)))
1328 .collect();
1329 result.cumulative_hazard = cumulative_hazard_mean;
1330 result.cif = cif_mean
1331 .into_iter()
1332 .map(|surface| surface.mapv(|value| value.clamp(0.0, 1.0)))
1333 .collect();
1334 result.overall_survival = overall_mean.mapv(|value| value.clamp(0.0, 1.0));
1335 result.linear_predictor = eta_mean;
1336 }
1337 result.hazard_se = hazard_se;
1338 result.survival_se = survival_se;
1339 result.cumulative_hazard_se = cumulative_hazard_se;
1340 result.cif_se = cif_se;
1341 result.overall_survival_se = overall_survival_se;
1342 result.eta_se = eta_se;
1343 result.covariance_source = req.with_uncertainty.then_some(covariance_mode);
1344 Ok(result)
1345}
1346
1347fn restricted_mean_survival_time_from_curve(
1363 times: &[f64],
1364 survival_row: ndarray::ArrayView1<'_, f64>,
1365 tau: f64,
1366) -> Option<f64> {
1367 if times.is_empty() || !(tau > 0.0) || !tau.is_finite() {
1368 return None;
1369 }
1370 if times.len() != survival_row.len() {
1371 return None;
1372 }
1373
1374 let mut prev_t = 0.0_f64;
1376 let mut prev_s = 1.0_f64;
1377 let mut area = 0.0_f64;
1378
1379 for (idx, &t) in times.iter().enumerate() {
1380 if !t.is_finite() || t < prev_t {
1381 return None;
1382 }
1383 let s = survival_row[idx];
1384 if !s.is_finite() {
1385 return None;
1386 }
1387 if t >= tau {
1388 let span = t - prev_t;
1390 let s_tau = if span > 0.0 {
1391 let w = (tau - prev_t) / span;
1392 prev_s + w * (s - prev_s)
1393 } else {
1394 prev_s
1395 };
1396 area += 0.5 * (prev_s + s_tau) * (tau - prev_t);
1397 return Some(area);
1398 }
1399 area += 0.5 * (prev_s + s) * (t - prev_t);
1400 prev_t = t;
1401 prev_s = s;
1402 }
1403
1404 area += prev_s * (tau - prev_t);
1408 Some(area)
1409}
1410
1411impl SurvivalPredictResult {
1412 pub fn restricted_mean_survival_time(&self, tau: f64) -> Option<Array1<f64>> {
1419 let n = self.survival.nrows();
1420 let mut out = Array1::<f64>::zeros(n);
1421 for i in 0..n {
1422 let rmst =
1423 restricted_mean_survival_time_from_curve(&self.times, self.survival.row(i), tau)?;
1424 out[i] = rmst;
1425 }
1426 Some(out)
1427 }
1428}
1429
1430impl CompetingRisksPredictResult {
1431 pub fn restricted_mean_overall_survival_time(&self, tau: f64) -> Option<Array1<f64>> {
1437 let n = self.overall_survival.nrows();
1438 let mut out = Array1::<f64>::zeros(n);
1439 for i in 0..n {
1440 let rmst = restricted_mean_survival_time_from_curve(
1441 &self.times,
1442 self.overall_survival.row(i),
1443 tau,
1444 )?;
1445 out[i] = rmst;
1446 }
1447 Some(out)
1448 }
1449}
1450
1451pub fn harrell_concordance(time: &[f64], event: &[f64], risk: &[f64]) -> Option<f64> {
1464 let n = time.len();
1465 if n != event.len() || n != risk.len() {
1466 return None;
1467 }
1468 let mut comparable = 0.0_f64;
1469 let mut concordant = 0.0_f64;
1470 for i in 0..n {
1471 for j in (i + 1)..n {
1472 let (early, late) = if time[i] < time[j] {
1473 (i, j)
1474 } else if time[j] < time[i] {
1475 (j, i)
1476 } else {
1477 if event[i] > 0.5 && event[j] > 0.5 {
1480 comparable += 1.0;
1481 concordant += 0.5;
1482 }
1483 continue;
1484 };
1485 if event[early] < 0.5 {
1486 continue;
1488 }
1489 comparable += 1.0;
1490 if risk[early] > risk[late] {
1491 concordant += 1.0;
1492 } else if risk[early] == risk[late] {
1493 concordant += 0.5;
1494 }
1495 }
1496 }
1497 if comparable == 0.0 {
1498 return None;
1499 }
1500 Some(concordant / comparable)
1501}
1502
1503pub fn ipcw_brier_score(
1532 s_pred: &[f64],
1533 time: &[f64],
1534 event: &[f64],
1535 tau: f64,
1536 g_cens: impl Fn(f64) -> f64,
1537) -> Option<f64> {
1538 let n = s_pred.len();
1539 if n != time.len() || n != event.len() {
1540 return None;
1541 }
1542 let mut n_valid = 0.0_f64;
1543 let mut acc = 0.0_f64;
1544 for i in 0..n {
1545 if !time[i].is_finite() || !event[i].is_finite() || time[i] <= 0.0 {
1546 continue;
1547 }
1548 n_valid += 1.0;
1551 let (target, weight) = if time[i] <= tau && event[i] > 0.5 {
1552 let g = g_cens(time[i]);
1554 if !(g > 0.0) {
1555 continue;
1556 }
1557 (0.0, 1.0 / g)
1558 } else if time[i] > tau {
1559 let g = g_cens(tau);
1561 if !(g > 0.0) {
1562 continue;
1563 }
1564 (1.0, 1.0 / g)
1565 } else {
1566 continue;
1568 };
1569 let resid = target - s_pred[i];
1570 acc += weight * resid * resid;
1571 }
1572 if n_valid == 0.0 {
1573 return None;
1574 }
1575 Some(acc / n_valid)
1576}
1577
1578pub fn integrated_ipcw_brier_score(
1597 s_pred: ArrayView2<f64>,
1598 time: &[f64],
1599 event: &[f64],
1600 grid: &[f64],
1601 horizon: f64,
1602 g_cens: impl Fn(f64) -> f64,
1603) -> Option<f64> {
1604 let m = grid.len();
1605 if m < 2 || s_pred.ncols() != m || s_pred.nrows() != time.len() {
1606 return None;
1607 }
1608 if grid.windows(2).any(|pair| !(pair[1] > pair[0])) {
1609 return None;
1610 }
1611 let mut pts: Vec<(f64, f64)> = Vec::with_capacity(m);
1613 for k in 0..m {
1614 if grid[k] > horizon {
1615 break;
1616 }
1617 let col = s_pred.column(k);
1618 let col_slice: Vec<f64> = col.to_vec();
1619 if let Some(bs) = ipcw_brier_score(&col_slice, time, event, grid[k], &g_cens) {
1620 pts.push((grid[k], bs));
1621 }
1622 }
1623 if pts.len() < 2 {
1624 return None;
1625 }
1626 let span = pts[pts.len() - 1].0 - pts[0].0;
1627 if !(span > 0.0) {
1628 return None;
1629 }
1630 let mut integral = 0.0_f64;
1631 for w in pts.windows(2) {
1632 integral += 0.5 * (w[1].1 + w[0].1) * (w[1].0 - w[0].0);
1633 }
1634 Some(integral / span)
1635}
1636
1637#[derive(Clone, Debug, Default)]
1644pub struct KaplanMeier {
1645 steps: Vec<(f64, f64)>,
1647}
1648
1649impl KaplanMeier {
1650 pub fn fit(time: &[f64], event: &[f64]) -> Self {
1652 let mut rows: Vec<(f64, bool)> = time
1653 .iter()
1654 .zip(event.iter())
1655 .filter_map(|(&t, &e)| {
1656 (t.is_finite() && e.is_finite() && t > 0.0).then_some((t, e > 0.5))
1657 })
1658 .collect();
1659 rows.sort_by(|a, b| a.0.total_cmp(&b.0));
1660 let mut steps = Vec::new();
1661 let mut at_risk = rows.len() as f64;
1662 let mut survival = 1.0_f64;
1663 let mut i = 0usize;
1664 while i < rows.len() {
1665 let t = rows[i].0;
1666 let mut j = i;
1667 let mut deaths = 0usize;
1668 while j < rows.len() && rows[j].0 == t {
1669 deaths += usize::from(rows[j].1);
1670 j += 1;
1671 }
1672 if deaths > 0 && at_risk > 0.0 {
1673 survival *= ((at_risk - deaths as f64) / at_risk).max(0.0);
1674 steps.push((t, survival));
1675 }
1676 at_risk -= (j - i) as f64;
1677 i = j;
1678 }
1679 Self { steps }
1680 }
1681
1682 pub fn fit_censoring(time: &[f64], event: &[f64]) -> Self {
1686 let flipped: Vec<f64> = event
1687 .iter()
1688 .map(|&e| if e > 0.5 { 0.0 } else { 1.0 })
1689 .collect();
1690 Self::fit(time, &flipped)
1691 }
1692
1693 pub fn at(&self, t: f64) -> f64 {
1696 let mut s = 1.0_f64;
1697 for &(time, surv) in &self.steps {
1698 if time <= t {
1699 s = surv;
1700 } else {
1701 break;
1702 }
1703 }
1704 s
1705 }
1706}
1707
1708pub struct CompetingRisksPredictResult {
1710 pub times: Vec<f64>,
1711 pub endpoint_names: Vec<String>,
1712 pub hazard: Vec<Array2<f64>>,
1714 pub survival: Vec<Array2<f64>>,
1716 pub cumulative_hazard: Vec<Array2<f64>>,
1718 pub cif: Vec<Array2<f64>>,
1720 pub overall_survival: Array2<f64>,
1722 pub linear_predictor: Vec<Array1<f64>>,
1724 pub likelihood_mode: SurvivalLikelihoodMode,
1725 pub covariance_source: Option<SurvivalPredictionCovarianceMode>,
1728 pub hazard_se: Option<Vec<Array2<f64>>>,
1730 pub survival_se: Option<Vec<Array2<f64>>>,
1732 pub cumulative_hazard_se: Option<Vec<Array2<f64>>>,
1734 pub cif_se: Option<Vec<Array2<f64>>>,
1736 pub overall_survival_se: Option<Array2<f64>>,
1738 pub eta_se: Option<Vec<Array1<f64>>>,
1740}
1741
1742pub fn predict_survival(
1748 req: SurvivalPredictRequest<'_>,
1749 covariance_mode: SurvivalPredictionCovarianceMode,
1750) -> Result<SurvivalPredictResult, SurvivalPredictError> {
1751 if req.estimand == SurvivalPredictEstimand::PosteriorMean {
1752 return predict_survival_posterior_mean(req, covariance_mode);
1753 }
1754 let SurvivalPredictRequest {
1755 model,
1756 data,
1757 col_map,
1758 training_headers,
1759 primary_offset,
1760 noise_offset,
1761 time_grid,
1762 with_uncertainty,
1763 estimand: _,
1764 } = req;
1765
1766 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1775 let exit_col = time_cols.exit_col;
1776
1777 let termspec = resolve_termspec_for_prediction(
1778 &model.resolved_termspec,
1779 training_headers,
1780 col_map,
1781 "resolved_termspec",
1782 )?;
1783 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1789 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1790 let cov_design = build_term_collection_design(cov_input, &termspec)
1791 .map_err(|e| format!("failed to build survival prediction design: {e}"))?;
1792
1793 let n = data.nrows();
1794 if primary_offset.len() != n || noise_offset.len() != n {
1795 return Err(SurvivalPredictError::InvalidInput {
1796 reason: format!(
1797 "survival prediction offset length mismatch: rows={n}, offset={}, noise_offset={}",
1798 primary_offset.len(),
1799 noise_offset.len()
1800 ),
1801 });
1802 }
1803 let effective_primary_offset = cov_design
1804 .compose_offset(primary_offset.view(), "survival prediction covariate block")
1805 .map_err(|error| error.to_string())?;
1806
1807 use rayon::iter::{IntoParallelIterator, ParallelIterator};
1808 let pairs: Result<Vec<(f64, f64)>, String> = (0..n)
1809 .into_par_iter()
1810 .map(|i| {
1811 normalize_survival_time_pair(time_cols.row_entry_time(data, i), data[[i, exit_col]], i)
1812 })
1813 .collect();
1814 let pairs = pairs?;
1815 let mut age_entry = Array1::<f64>::zeros(n);
1816 let mut age_exit = Array1::<f64>::zeros(n);
1817 for (i, (t0, t1)) in pairs.into_iter().enumerate() {
1818 age_entry[i] = t0;
1819 age_exit[i] = t1;
1820 }
1821
1822 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1823
1824 if matches!(
1828 saved_likelihood_mode,
1829 SurvivalLikelihoodMode::Latent | SurvivalLikelihoodMode::LatentBinary
1830 ) {
1831 return Err(SurvivalPredictError::UnsupportedConfiguration {
1832 reason: format!(
1833 "survival prediction via predict_survival does not support likelihood_mode={} yet; \
1834 latent window prediction lives in the CLI's run_predict_saved_latent_window_impl \
1835 pipeline and has not yet been ported to the library. Use the CLI predict command.",
1836 survival_likelihood_modename(saved_likelihood_mode)
1837 ),
1838 });
1839 }
1840 if saved_likelihood_mode == SurvivalLikelihoodMode::LocationScale {
1843 return predict_survival_location_scale_batch(
1844 model,
1845 &age_entry,
1846 &age_exit,
1847 &cov_design,
1848 &effective_primary_offset,
1849 noise_offset,
1850 training_headers,
1851 col_map,
1852 data,
1853 time_grid,
1854 with_uncertainty,
1855 covariance_mode,
1856 )
1857 .map_err(SurvivalPredictError::from);
1858 }
1859 if with_uncertainty {
1860 return Err(SurvivalPredictError::from(format!(
1861 "predict_survival: with_uncertainty is currently supported only for the \
1862 location-scale likelihood mode; got {}",
1863 survival_likelihood_modename(saved_likelihood_mode)
1864 )));
1865 }
1866
1867 let time_cfg = load_survival_time_basis_config_from_model(model)?;
1870 let mut time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
1871 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
1872 &time_build.basisname,
1873 time_build.degree,
1874 time_build.knots.as_ref(),
1875 time_build.keep_cols.as_ref(),
1876 time_build.smooth_lambda,
1877 )?;
1878 let weibull_baseline_in_beta = saved_likelihood_mode == SurvivalLikelihoodMode::Weibull
1895 && !model.has_baseline_time_wiggle();
1896 let mut time_anchor: Option<f64> = None;
1897 let mut time_anchor_row_cached: Option<Array1<f64>> = None;
1898 if matches!(
1899 saved_likelihood_mode,
1900 SurvivalLikelihoodMode::LocationScale | SurvivalLikelihoodMode::MarginalSlope
1901 ) || weibull_baseline_in_beta
1902 {
1903 let anchor = model
1904 .survival_time_anchor
1905 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1906 let time_anchor_row = evaluate_survival_time_basis_row(anchor, &resolved_time_cfg)?;
1907 center_survival_time_designs_at_anchor(
1908 &mut time_build.x_entry_time,
1909 &mut time_build.x_exit_time,
1910 &time_anchor_row,
1911 )?;
1912 time_anchor = Some(anchor);
1913 time_anchor_row_cached = Some(time_anchor_row);
1914 }
1915 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull && !model.has_baseline_time_wiggle()
1916 {
1917 require_structural_survival_time_basis(&time_build.basisname, "saved survival sampling")?;
1918 }
1919 let mut baseline_cfg = saved_survival_runtime_baseline_config(model)?;
1920 if weibull_baseline_in_beta {
1921 baseline_cfg = SurvivalBaselineConfig {
1922 target: SurvivalBaselineTarget::Linear,
1923 scale: None,
1924 shape: None,
1925 rate: None,
1926 makeham: None,
1927 };
1928 }
1929
1930 let per_row_eval = time_grid.is_none();
1933 let eval_times: Vec<f64> = match time_grid {
1934 Some(grid) => {
1935 if grid.is_empty() {
1936 return Err(SurvivalPredictError::InvalidInput {
1937 reason: "survival time_grid must contain at least one time".to_string(),
1938 });
1939 }
1940 for (idx, &t) in grid.iter().enumerate() {
1941 if !t.is_finite() || t < 0.0 {
1942 return Err(SurvivalPredictError::InvalidInput {
1943 reason: format!(
1944 "survival time_grid requires finite non-negative times (index {idx})",
1945 ),
1946 });
1947 }
1948 }
1949 grid.to_vec()
1950 }
1951 None => Vec::new(),
1952 };
1953
1954 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
1955 let mut hazard = Array2::<f64>::zeros((n, t_cols));
1956 let mut survival = Array2::<f64>::zeros((n, t_cols));
1957 let mut cumulative_hazard = Array2::<f64>::zeros((n, t_cols));
1958 let mut linear_predictor = Array1::<f64>::zeros(n);
1959
1960 let marginal_slope_ctx = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1966 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
1970 build_survival_time_offsets_for_likelihood(
1971 &age_entry,
1972 &age_exit,
1973 &baseline_cfg,
1974 saved_likelihood_mode,
1975 None,
1976 )?;
1977 add_survival_time_derivative_guard_offset(
1978 &age_entry,
1979 &age_exit,
1980 time_anchor.ok_or_else(|| {
1981 "saved survival marginal-slope model missing survival_time_anchor".to_string()
1982 })?,
1983 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
1984 &mut eta_offset_entry,
1985 &mut eta_offset_exit,
1986 &mut derivative_offset_exit,
1987 )?;
1988 Some(build_marginal_slope_predict_context(
1989 model,
1990 data,
1991 col_map,
1992 training_headers,
1993 &cov_design.design,
1994 &effective_primary_offset,
1995 noise_offset,
1996 &time_build,
1997 &eta_offset_entry,
1998 &eta_offset_exit,
1999 &derivative_offset_exit,
2000 )?)
2001 } else {
2002 None
2003 };
2004
2005 struct SurvivalPredictionRow {
2009 hazard: Vec<f64>,
2010 survival: Vec<f64>,
2011 cumulative_hazard: Vec<f64>,
2012 linear_predictor: f64,
2013 }
2014
2015 let row_results: Result<Vec<SurvivalPredictionRow>, SurvivalPredictError> = (0..n)
2016 .into_par_iter()
2017 .map(|i| {
2018 let cov_row = if matches!(
2019 saved_likelihood_mode,
2020 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull
2021 ) {
2022 Some(design_row_owned(
2023 &cov_design.design,
2024 i,
2025 "survival predict covariate row",
2026 )?)
2027 } else {
2028 None
2029 };
2030 let evaluate_at = |t_query: f64| -> Result<(f64, f64, f64), SurvivalPredictError> {
2031 let t_entry = age_entry[i].min(t_query);
2032 let single_entry = Array1::from_elem(1, t_entry);
2033 let single_exit = Array1::from_elem(1, t_query);
2034 let mut row_time =
2035 build_survival_time_basis(&single_entry, &single_exit, time_cfg.clone(), None)?;
2036 if let Some(anchor_row) = time_anchor_row_cached.as_ref() {
2037 center_survival_time_designs_at_anchor(
2038 &mut row_time.x_entry_time,
2039 &mut row_time.x_exit_time,
2040 anchor_row,
2041 )?;
2042 }
2043 let (mut r_eta_entry, mut r_eta_exit, mut r_deriv_exit) =
2044 build_survival_time_offsets_for_likelihood(
2045 &single_entry,
2046 &single_exit,
2047 &baseline_cfg,
2048 saved_likelihood_mode,
2049 None,
2050 )?;
2051 if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
2052 add_survival_time_derivative_guard_offset(
2053 &single_entry,
2054 &single_exit,
2055 time_anchor.ok_or_else(|| {
2056 "saved survival marginal-slope model missing survival_time_anchor"
2057 .to_string()
2058 })?,
2059 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
2060 &mut r_eta_entry,
2061 &mut r_eta_exit,
2062 &mut r_deriv_exit,
2063 )?;
2064 }
2065
2066 match saved_likelihood_mode {
2067 SurvivalLikelihoodMode::MarginalSlope => {
2068 let ctx = marginal_slope_ctx.as_ref().ok_or_else(|| {
2069 "internal error: marginal-slope context missing for marginal-slope mode"
2070 .to_string()
2071 })?;
2072 evaluate_marginal_slope_row(
2073 i,
2074 ctx,
2075 &row_time,
2076 &r_eta_exit,
2077 &r_deriv_exit,
2078 effective_primary_offset[i],
2079 )
2080 }
2081 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull => {
2082 let cov_row = cov_row.as_ref().ok_or_else(|| {
2083 "internal error: covariate row missing for Royston-Parmar prediction"
2084 .to_string()
2085 })?;
2086 evaluate_rp_row(
2087 model,
2088 &row_time,
2089 cov_row,
2090 r_eta_exit[0],
2091 r_deriv_exit[0],
2092 effective_primary_offset[i],
2093 )
2094 }
2095 SurvivalLikelihoodMode::Latent
2096 | SurvivalLikelihoodMode::LatentBinary
2097 | SurvivalLikelihoodMode::LocationScale => {
2098 Err(SurvivalPredictError::NumericalFailure {
2099 reason: "unreachable: unsupported likelihood_mode filtered earlier"
2100 .to_string(),
2101 })
2102 }
2103 }
2104 };
2105
2106 let mut row = SurvivalPredictionRow {
2107 hazard: vec![0.0; t_cols],
2108 survival: vec![0.0; t_cols],
2109 cumulative_hazard: vec![0.0; t_cols],
2110 linear_predictor: 0.0,
2111 };
2112 if per_row_eval {
2113 let (eta_t, cum_t, haz_t) = evaluate_at(age_exit[i])?;
2114 row.linear_predictor = eta_t;
2115 row.hazard[0] = haz_t;
2116 row.cumulative_hazard[0] = cum_t;
2117 row.survival[0] = (-cum_t).exp().clamp(0.0, 1.0);
2118 } else {
2119 for (j, &t_query) in eval_times.iter().enumerate() {
2120 if t_query <= 0.0 {
2121 row.hazard[j] = 0.0;
2122 row.cumulative_hazard[j] = 0.0;
2123 row.survival[j] = 1.0;
2124 } else {
2125 let (_eta_t, cum_t, haz_t) = evaluate_at(t_query)?;
2126 row.hazard[j] = haz_t;
2127 row.cumulative_hazard[j] = cum_t;
2128 row.survival[j] = (-cum_t).exp().clamp(0.0, 1.0);
2129 }
2130 }
2131 let (eta_t, _, _) = evaluate_at(age_exit[i])?;
2132 row.linear_predictor = eta_t;
2133 }
2134 Ok(row)
2135 })
2136 .collect();
2137
2138 for (i, row) in row_results?.into_iter().enumerate() {
2139 linear_predictor[i] = row.linear_predictor;
2140 for j in 0..t_cols {
2141 hazard[[i, j]] = row.hazard[j];
2142 cumulative_hazard[[i, j]] = row.cumulative_hazard[j];
2143 survival[[i, j]] = row.survival[j];
2144 }
2145 }
2146
2147 let times_out: Vec<f64> = if per_row_eval {
2148 age_exit.to_vec()
2149 } else {
2150 eval_times
2151 };
2152
2153 Ok(SurvivalPredictResult {
2154 times: times_out,
2155 hazard,
2156 survival,
2157 cumulative_hazard,
2158 linear_predictor,
2159 likelihood_mode: saved_likelihood_mode,
2160 survival_se: None,
2161 eta_se: None,
2162 covariance_source: None,
2163 })
2164}
2165
2166pub fn predict_competing_risks_survival(
2167 req: SurvivalPredictRequest<'_>,
2168 covariance_mode: SurvivalPredictionCovarianceMode,
2169) -> Result<CompetingRisksPredictResult, SurvivalPredictError> {
2170 if req.estimand == SurvivalPredictEstimand::PosteriorMean || req.with_uncertainty {
2171 return predict_competing_risks_with_posterior(req, covariance_mode);
2172 }
2173 let SurvivalPredictRequest {
2174 model,
2175 data,
2176 col_map,
2177 training_headers,
2178 primary_offset,
2179 noise_offset,
2180 time_grid,
2181 with_uncertainty: _,
2182 estimand: _,
2183 } = req;
2184
2185 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
2186 if !matches!(
2187 saved_likelihood_mode,
2188 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull
2189 ) {
2190 return Err(SurvivalPredictError::UnsupportedConfiguration {
2191 reason: format!(
2192 "joint cause-specific prediction supports transformation/weibull survival only; got {}",
2193 survival_likelihood_modename(saved_likelihood_mode)
2194 ),
2195 });
2196 }
2197
2198 let fit = fit_result_from_saved_model_for_prediction(model)?;
2199 let cause_count = model
2200 .survival_cause_count
2201 .unwrap_or(fit.blocks.len())
2202 .max(1);
2203 if cause_count <= 1 {
2204 return Err(SurvivalPredictError::MissingFitMetadata {
2205 reason: "competing-risks survival prediction requires a saved model with at least two causes"
2206 .to_string(),
2207 });
2208 }
2209 if fit.blocks.len() != cause_count {
2210 return Err(SurvivalPredictError::IncompatibleSchema {
2211 reason: format!(
2212 "saved competing-risks survival fit has {} coefficient blocks but metadata says {cause_count} causes",
2213 fit.blocks.len()
2214 ),
2215 });
2216 }
2217 let endpoint_names = model.survival_endpoint_names.clone().unwrap_or_else(|| {
2218 (1..=cause_count)
2219 .map(|idx| format!("cause_{idx}"))
2220 .collect()
2221 });
2222 if endpoint_names.len() != cause_count {
2223 return Err(SurvivalPredictError::IncompatibleSchema {
2224 reason: format!(
2225 "saved competing-risks survival endpoint_names has length {}, expected {cause_count}",
2226 endpoint_names.len()
2227 ),
2228 });
2229 }
2230
2231 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
2235 let exit_col = time_cols.exit_col;
2236
2237 let termspec = resolve_termspec_for_prediction(
2238 &model.resolved_termspec,
2239 training_headers,
2240 col_map,
2241 "resolved_termspec",
2242 )?;
2243 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
2244 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
2245 let cov_design = build_term_collection_design(cov_input, &termspec)
2246 .map_err(|e| format!("failed to build competing-risks prediction design: {e}"))?;
2247
2248 let n = data.nrows();
2249 if primary_offset.len() != n || noise_offset.len() != n {
2250 return Err(SurvivalPredictError::InvalidInput {
2251 reason: format!(
2252 "competing-risks prediction offset length mismatch: rows={n}, offset={}, noise_offset={}",
2253 primary_offset.len(),
2254 noise_offset.len()
2255 ),
2256 });
2257 }
2258 let effective_primary_offset = cov_design
2259 .compose_offset(
2260 primary_offset.view(),
2261 "competing-risks prediction covariate block",
2262 )
2263 .map_err(|error| error.to_string())?;
2264
2265 use rayon::iter::{IntoParallelIterator, ParallelIterator};
2266 let pairs: Result<Vec<(f64, f64)>, String> = (0..n)
2267 .into_par_iter()
2268 .map(|i| {
2269 normalize_survival_time_pair(time_cols.row_entry_time(data, i), data[[i, exit_col]], i)
2270 })
2271 .collect();
2272 let pairs = pairs?;
2273 let mut age_entry = Array1::<f64>::zeros(n);
2274 let mut age_exit = Array1::<f64>::zeros(n);
2275 for (i, (t0, t1)) in pairs.into_iter().enumerate() {
2276 age_entry[i] = t0;
2277 age_exit[i] = t1;
2278 }
2279
2280 let time_cfg = load_survival_time_basis_config_from_model(model)?;
2281 let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
2282 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
2283 &time_build.basisname,
2284 time_build.degree,
2285 time_build.knots.as_ref(),
2286 time_build.keep_cols.as_ref(),
2287 time_build.smooth_lambda,
2288 )?;
2289 let weibull_baseline_in_beta = saved_likelihood_mode == SurvivalLikelihoodMode::Weibull
2298 && !model.has_baseline_time_wiggle();
2299 let cr_time_anchor_row: Option<Array1<f64>> = if weibull_baseline_in_beta {
2300 let anchor = model
2301 .survival_time_anchor
2302 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
2303 Some(evaluate_survival_time_basis_row(
2304 anchor,
2305 &resolved_time_cfg,
2306 )?)
2307 } else {
2308 None
2309 };
2310 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull && !model.has_baseline_time_wiggle()
2311 {
2312 require_structural_survival_time_basis(
2313 &time_build.basisname,
2314 "saved competing-risks survival prediction",
2315 )?;
2316 }
2317 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
2318
2319 let per_row_eval = time_grid.is_none();
2320 let eval_times: Vec<f64> = match time_grid {
2321 Some(grid) => {
2322 if grid.is_empty() {
2323 return Err(SurvivalPredictError::InvalidInput {
2324 reason: "survival time_grid must contain at least one time".to_string(),
2325 });
2326 }
2327 for (idx, &t) in grid.iter().enumerate() {
2328 if !t.is_finite() || t < 0.0 {
2329 return Err(SurvivalPredictError::InvalidInput {
2330 reason: format!(
2331 "survival time_grid requires finite non-negative times (index {idx})",
2332 ),
2333 });
2334 }
2335 }
2336 grid.to_vec()
2337 }
2338 None => Vec::new(),
2339 };
2340 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
2341
2342 const CIF_REFINE_SUBINTERVALS: usize = 32;
2361 let (refined_times, user_time_to_refined_index): (Vec<f64>, Vec<usize>) = if per_row_eval {
2362 (Vec::new(), Vec::new())
2363 } else {
2364 let mut order: Vec<usize> = (0..eval_times.len()).collect();
2372 order.sort_by(|&a, &b| {
2373 eval_times[a]
2374 .partial_cmp(&eval_times[b])
2375 .expect("survival time_grid entries are validated finite above")
2376 });
2377 let mut refined: Vec<f64> = Vec::new();
2378 let mut user_index: Vec<usize> = vec![0; eval_times.len()];
2379 let mut prev = 0.0_f64;
2380 for &j_user in &order {
2381 let t_user = eval_times[j_user];
2382 let gap = t_user - prev;
2387 if gap > 0.0 {
2388 for s in 1..CIF_REFINE_SUBINTERVALS {
2389 let t_mid = prev + gap * (s as f64) / (CIF_REFINE_SUBINTERVALS as f64);
2390 if refined.last().is_none_or(|&last| t_mid > last) {
2392 refined.push(t_mid);
2393 }
2394 }
2395 }
2396 if refined.last().is_none_or(|&last| t_user > last) {
2397 refined.push(t_user);
2398 }
2399 user_index[j_user] = refined.len() - 1;
2400 prev = t_user;
2401 }
2402 (refined, user_index)
2403 };
2404 let refined_cols = if per_row_eval {
2407 CIF_REFINE_SUBINTERVALS
2408 } else {
2409 refined_times.len()
2410 };
2411
2412 let saved_timewiggle_by_cause = saved_cause_specific_timewiggles(model, &fit, cause_count)?;
2413 let cov_rows = (0..n)
2414 .map(|i| design_row_owned(&cov_design.design, i, "competing-risks covariate row"))
2415 .collect::<Result<Vec<_>, _>>()?;
2416
2417 let mut hazard = (0..cause_count)
2418 .map(|_| Array2::<f64>::zeros((n, t_cols)))
2419 .collect::<Vec<_>>();
2420 let mut survival = (0..cause_count)
2421 .map(|_| Array2::<f64>::zeros((n, t_cols)))
2422 .collect::<Vec<_>>();
2423 let mut cumulative_hazard = (0..cause_count)
2424 .map(|_| Array2::<f64>::zeros((n, t_cols)))
2425 .collect::<Vec<_>>();
2426 let mut cumulative_hazard_refined = (0..cause_count)
2429 .map(|_| Array2::<f64>::zeros((n, refined_cols)))
2430 .collect::<Vec<_>>();
2431 let mut linear_predictor = (0..cause_count)
2432 .map(|_| Array1::<f64>::zeros(n))
2433 .collect::<Vec<_>>();
2434
2435 struct CauseRow {
2436 cause: usize,
2437 row: usize,
2438 hazard: Vec<f64>,
2439 survival: Vec<f64>,
2440 cumulative: Vec<f64>,
2441 cumulative_refined: Vec<f64>,
2444 eta_exit: f64,
2445 }
2446
2447 let rows: Result<Vec<CauseRow>, SurvivalPredictError> = (0..cause_count * n)
2448 .into_par_iter()
2449 .map(|flat| {
2450 let cause = flat / n;
2451 let i = flat % n;
2452 let block = &fit.blocks[cause];
2453 let timewiggle = saved_timewiggle_by_cause[cause].as_ref();
2454 let evaluate_at = |t_query: f64| -> Result<(f64, f64, f64), SurvivalPredictError> {
2455 let t_entry = age_entry[i].min(t_query);
2456 let single_entry = Array1::from_elem(1, t_entry);
2457 let single_exit = Array1::from_elem(1, t_query);
2458 let mut row_time =
2459 build_survival_time_basis(&single_entry, &single_exit, time_cfg.clone(), None)?;
2460 if let Some(anchor_row) = cr_time_anchor_row.as_ref() {
2461 center_survival_time_designs_at_anchor(
2462 &mut row_time.x_entry_time,
2463 &mut row_time.x_exit_time,
2464 anchor_row,
2465 )?;
2466 }
2467 let (r_eta_exit, r_deriv_exit) = if weibull_baseline_in_beta {
2468 (0.0, 0.0)
2469 } else {
2470 let (_, eta_exit, deriv_exit) = build_survival_time_offsets_for_likelihood(
2471 &single_entry,
2472 &single_exit,
2473 &baseline_cfg,
2474 saved_likelihood_mode,
2475 None,
2476 )?;
2477 (eta_exit[0], deriv_exit[0])
2478 };
2479 evaluate_rp_row_with_beta(
2480 &block.beta,
2481 timewiggle,
2482 &row_time,
2483 &cov_rows[i],
2484 r_eta_exit,
2485 r_deriv_exit,
2486 effective_primary_offset[i],
2487 )
2488 };
2489
2490 let mut out = CauseRow {
2491 cause,
2492 row: i,
2493 hazard: vec![0.0; t_cols],
2494 survival: vec![0.0; t_cols],
2495 cumulative: vec![0.0; t_cols],
2496 cumulative_refined: vec![0.0; refined_cols],
2497 eta_exit: 0.0,
2498 };
2499 if per_row_eval {
2500 let (eta_t, cum_t, haz_t) = evaluate_at(age_exit[i])?;
2501 out.eta_exit = eta_t;
2502 out.hazard[0] = haz_t;
2503 out.cumulative[0] = cum_t;
2504 out.survival[0] = (-cum_t).exp().clamp(0.0, 1.0);
2505 for s in 1..=CIF_REFINE_SUBINTERVALS {
2512 let frac = (s as f64) / (CIF_REFINE_SUBINTERVALS as f64);
2513 let t_query = age_exit[i] * frac;
2514 out.cumulative_refined[s - 1] = if t_query <= 0.0 {
2515 0.0
2516 } else if s == CIF_REFINE_SUBINTERVALS {
2517 cum_t
2521 } else {
2522 evaluate_at(t_query)?.1
2523 };
2524 }
2525 } else {
2526 for (j, &t_query) in eval_times.iter().enumerate() {
2527 if t_query <= 0.0 {
2534 out.hazard[j] = 0.0;
2535 out.cumulative[j] = 0.0;
2536 out.survival[j] = 1.0;
2537 } else {
2538 let (_eta_t, cum_t, haz_t) = evaluate_at(t_query)?;
2539 out.hazard[j] = haz_t;
2540 out.cumulative[j] = cum_t;
2541 out.survival[j] = (-cum_t).exp().clamp(0.0, 1.0);
2542 }
2543 }
2544 for (jr, &t_query) in refined_times.iter().enumerate() {
2550 out.cumulative_refined[jr] = if t_query <= 0.0 {
2551 0.0
2552 } else {
2553 evaluate_at(t_query)?.1
2554 };
2555 }
2556 let (eta_t, _, _) = evaluate_at(age_exit[i])?;
2557 out.eta_exit = eta_t;
2558 }
2559 Ok(out)
2560 })
2561 .collect();
2562
2563 for row in rows? {
2564 linear_predictor[row.cause][row.row] = row.eta_exit;
2565 for j in 0..t_cols {
2566 hazard[row.cause][[row.row, j]] = row.hazard[j];
2567 survival[row.cause][[row.row, j]] = row.survival[j];
2568 cumulative_hazard[row.cause][[row.row, j]] = row.cumulative[j];
2569 }
2570 for jr in 0..refined_cols {
2571 cumulative_hazard_refined[row.cause][[row.row, jr]] = row.cumulative_refined[jr];
2572 }
2573 }
2574
2575 let assembled = if per_row_eval {
2579 let assembly_times = Array1::from_shape_fn(CIF_REFINE_SUBINTERVALS, |s| {
2586 ((s + 1) as f64) / (CIF_REFINE_SUBINTERVALS as f64)
2587 });
2588 let refined_assembled = assemble_competing_risks_cif_from_endpoints(
2589 assembly_times.view(),
2590 &cumulative_hazard_refined,
2591 )
2592 .map_err(|err| err.to_string())?;
2593 let last = CIF_REFINE_SUBINTERVALS - 1;
2594 let mut cif_user = (0..cause_count)
2595 .map(|_| Array2::<f64>::zeros((n, 1)))
2596 .collect::<Vec<_>>();
2597 let mut overall_user = Array2::<f64>::zeros((n, 1));
2598 for cause in 0..cause_count {
2599 for row in 0..n {
2600 cif_user[cause][[row, 0]] = refined_assembled.cif[cause][[row, last]];
2601 }
2602 }
2603 for row in 0..n {
2604 overall_user[[row, 0]] = refined_assembled.overall_survival[[row, last]];
2605 }
2606 CompetingRisksCifResult {
2607 cif: cif_user,
2608 overall_survival: overall_user,
2609 }
2610 } else {
2611 let assembly_times = Array1::from_vec(refined_times.clone());
2612 let refined_assembled = assemble_competing_risks_cif_from_endpoints(
2613 assembly_times.view(),
2614 &cumulative_hazard_refined,
2615 )
2616 .map_err(|err| err.to_string())?;
2617 let mut cif_user = (0..cause_count)
2619 .map(|_| Array2::<f64>::zeros((n, t_cols)))
2620 .collect::<Vec<_>>();
2621 let mut overall_user = Array2::<f64>::zeros((n, t_cols));
2622 for (j_user, &jr) in user_time_to_refined_index.iter().enumerate() {
2623 for cause in 0..cause_count {
2624 for row in 0..n {
2625 cif_user[cause][[row, j_user]] = refined_assembled.cif[cause][[row, jr]];
2626 }
2627 }
2628 for row in 0..n {
2629 overall_user[[row, j_user]] = refined_assembled.overall_survival[[row, jr]];
2630 }
2631 }
2632 CompetingRisksCifResult {
2633 cif: cif_user,
2634 overall_survival: overall_user,
2635 }
2636 };
2637 if assembled.cif.len() != cause_count {
2638 return Err(format!(
2639 "competing-risks CIF assembly produced {} endpoint matrices, expected {cause_count}",
2640 assembled.cif.len()
2641 )
2642 .into());
2643 }
2644 let cif = assembled.cif;
2645 let overall_survival = assembled.overall_survival;
2646 let times_out = if per_row_eval {
2647 age_exit.to_vec()
2648 } else {
2649 eval_times
2650 };
2651 Ok(CompetingRisksPredictResult {
2652 times: times_out,
2653 endpoint_names,
2654 hazard,
2655 survival,
2656 cumulative_hazard,
2657 cif,
2658 overall_survival,
2659 linear_predictor,
2660 likelihood_mode: saved_likelihood_mode,
2661 covariance_source: None,
2662 hazard_se: None,
2663 survival_se: None,
2664 cumulative_hazard_se: None,
2665 cif_se: None,
2666 overall_survival_se: None,
2667 eta_se: None,
2668 })
2669}
2670
2671fn saved_cause_specific_timewiggles(
2672 model: &SavedModel,
2673 fit: &UnifiedFitResult,
2674 cause_count: usize,
2675) -> Result<Vec<Option<SavedBaselineTimeWiggleRuntime>>, SurvivalPredictError> {
2676 let has_metadata = model.baseline_timewiggle_knots.is_some()
2677 || model.baseline_timewiggle_degree.is_some()
2678 || model.baseline_timewiggle_penalty_orders.is_some()
2679 || model.baseline_timewiggle_double_penalty.is_some()
2680 || model.beta_baseline_timewiggle_by_cause.is_some();
2681 if !has_metadata {
2682 return Ok(vec![None; cause_count]);
2683 }
2684 let knots = model.baseline_timewiggle_knots.clone().ok_or_else(|| {
2685 "joint cause-specific survival missing baseline_timewiggle_knots".to_string()
2686 })?;
2687 let degree = model.baseline_timewiggle_degree.ok_or_else(|| {
2688 "joint cause-specific survival missing baseline_timewiggle_degree".to_string()
2689 })?;
2690 let penalty_orders = model
2691 .baseline_timewiggle_penalty_orders
2692 .clone()
2693 .ok_or_else(|| {
2694 "joint cause-specific survival missing baseline_timewiggle_penalty_orders".to_string()
2695 })?;
2696 let double_penalty = model.baseline_timewiggle_double_penalty.ok_or_else(|| {
2697 "joint cause-specific survival missing baseline_timewiggle_double_penalty".to_string()
2698 })?;
2699 let by_cause = model
2700 .beta_baseline_timewiggle_by_cause
2701 .as_ref()
2702 .ok_or_else(|| {
2703 "joint cause-specific survival missing beta_baseline_timewiggle_by_cause".to_string()
2704 })?;
2705 if by_cause.len() != cause_count {
2706 return Err(SurvivalPredictError::IncompatibleSchema {
2707 reason: format!(
2708 "joint cause-specific survival has {} timewiggle coefficient blocks, expected {cause_count}",
2709 by_cause.len()
2710 ),
2711 });
2712 }
2713 for (cause, (block, beta_w)) in fit.blocks.iter().zip(by_cause).enumerate() {
2714 if beta_w.len() > block.beta.len() {
2715 return Err(SurvivalPredictError::IncompatibleSchema {
2716 reason: format!(
2717 "joint cause-specific survival cause {} timewiggle beta has length {}, but endpoint beta has {} coefficients",
2718 cause + 1,
2719 beta_w.len(),
2720 block.beta.len()
2721 ),
2722 });
2723 }
2724 }
2725 Ok(by_cause
2726 .iter()
2727 .map(|beta| {
2728 Some(SavedBaselineTimeWiggleRuntime {
2729 knots: knots.clone(),
2730 degree,
2731 penalty_orders: penalty_orders.clone(),
2732 double_penalty,
2733 beta: beta.clone(),
2734 })
2735 })
2736 .collect())
2737}
2738
2739struct MarginalSlopePredictContext {
2747 predictor: BernoulliMarginalSlopePredictor,
2748 beta_time: Array1<f64>,
2750 beta_marginal: Array1<f64>,
2752 saved_timewiggle: Option<SavedBaselineTimeWiggleRuntime>,
2753 cov_design: DesignMatrix,
2755 logslope_design: DesignMatrix,
2757 cov_eta: Array1<f64>,
2760 z_raw: Array1<f64>,
2763 noise_offset: Array1<f64>,
2766}
2767
2768fn design_row_owned(
2769 design: &DesignMatrix,
2770 row: usize,
2771 context: &str,
2772) -> Result<Array1<f64>, SurvivalPredictError> {
2773 let chunk = design
2774 .try_row_chunk(row..row + 1)
2775 .map_err(|e| format!("{context}: {e}"))?;
2776 Ok(chunk.row(0).to_owned())
2777}
2778
2779fn build_marginal_slope_predict_context(
2780 model: &SavedModel,
2781 data: ArrayView2<'_, f64>,
2782 col_map: &HashMap<String, usize>,
2783 training_headers: Option<&Vec<String>>,
2784 cov_design: &DesignMatrix,
2785 primary_offset: &Array1<f64>,
2786 noise_offset: &Array1<f64>,
2787 time_build: &SurvivalTimeBuildOutput,
2788 eta_offset_entry: &Array1<f64>,
2789 eta_offset_exit: &Array1<f64>,
2790 derivative_offset_exit: &Array1<f64>,
2791) -> Result<MarginalSlopePredictContext, SurvivalPredictError> {
2792 let z_name = model
2793 .z_column
2794 .as_ref()
2795 .ok_or_else(|| "saved survival marginal-slope model missing z_column".to_string())?;
2796 let z_col = resolve_role_col(col_map, z_name, "z")?;
2797 let z_raw = data.column(z_col).to_owned();
2798
2799 let logslopespec = resolve_termspec_for_prediction(
2800 &model.resolved_termspec_logslope.as_ref().cloned(),
2801 training_headers,
2802 col_map,
2803 "resolved_termspec_logslope",
2804 )?;
2805 let logslope_clipped = model.axis_clip_to_training_ranges(data, col_map);
2806 let logslope_input = logslope_clipped.as_ref().map_or(data, |arr| arr.view());
2807 let logslope_design = build_term_collection_design(logslope_input, &logslopespec)
2808 .map_err(|e| format!("failed to build survival marginal-slope logslope design: {e}"))?;
2809 let effective_noise_offset = logslope_design
2810 .compose_offset(
2811 noise_offset.view(),
2812 "survival marginal-slope logslope block",
2813 )
2814 .map_err(|error| error.to_string())?;
2815
2816 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
2817 let (predictor, _pred_input, _predictor_fit) = build_saved_survival_marginal_slope_predictor(
2818 model,
2819 &fit_saved,
2820 z_name,
2821 &z_raw,
2822 cov_design,
2823 &logslope_design.design,
2824 time_build,
2825 eta_offset_entry,
2826 eta_offset_exit,
2827 derivative_offset_exit,
2828 primary_offset,
2829 &effective_noise_offset,
2830 )?;
2831
2832 let blocks = &fit_saved.blocks;
2833 if blocks.len() < 3 {
2834 return Err(SurvivalPredictError::IncompatibleSchema {
2835 reason: format!(
2836 "saved survival marginal-slope model requires at least 3 blocks [time, marginal, slope], got {}",
2837 blocks.len()
2838 ),
2839 });
2840 }
2841 let beta_time = blocks[0].beta.clone();
2842 let beta_marginal = blocks[1].beta.clone();
2843 let saved_runtime = model.saved_prediction_runtime()?;
2844 let saved_timewiggle = saved_runtime.baseline_time_wiggle.clone();
2845
2846 let cov_eta = cov_design.dot(&beta_marginal);
2849
2850 Ok(MarginalSlopePredictContext {
2851 predictor,
2852 beta_time,
2853 beta_marginal,
2854 saved_timewiggle,
2855 cov_design: cov_design.clone(),
2856 logslope_design: logslope_design.design.clone(),
2857 cov_eta,
2858 z_raw,
2859 noise_offset: effective_noise_offset,
2860 })
2861}
2862
2863fn evaluate_marginal_slope_row(
2874 row_index: usize,
2875 ctx: &MarginalSlopePredictContext,
2876 row_time: &SurvivalTimeBuildOutput,
2877 r_eta_exit: &Array1<f64>,
2878 r_deriv_exit: &Array1<f64>,
2879 primary_offset_row: f64,
2880) -> Result<(f64, f64, f64), SurvivalPredictError> {
2881 let beta_time = &ctx.beta_time;
2882 let p_time_base = row_time.x_exit_time.ncols();
2883 let p_timewiggle = ctx
2884 .saved_timewiggle
2885 .as_ref()
2886 .map_or(0, |runtime| runtime.beta.len());
2887 if beta_time.len() != p_time_base + p_timewiggle {
2888 let hint = stale_weibull_time_basis_hint(
2889 &row_time.basisname,
2890 beta_time.len() == p_time_base + p_timewiggle + 1,
2891 );
2892 return Err(SurvivalPredictError::IncompatibleSchema {
2893 reason: format!(
2894 "saved survival marginal-slope time coefficient mismatch: beta has {} entries but expected base={} plus timewiggle={}{hint}",
2895 beta_time.len(),
2896 p_time_base,
2897 p_timewiggle
2898 ),
2899 });
2900 }
2901 let beta_time_base = beta_time.slice(s![..p_time_base]).to_owned();
2902
2903 let q_exit_base = row_time.x_exit_time.dot(&beta_time_base)[0]
2908 + ctx.cov_eta[row_index]
2909 + r_eta_exit[0]
2910 + primary_offset_row;
2911 let qd_exit_base = row_time.x_derivative_time.dot(&beta_time_base)[0] + r_deriv_exit[0];
2912
2913 let (qd_with_wiggle, exit_wiggle_design) = if let Some(runtime) = ctx.saved_timewiggle.as_ref()
2917 {
2918 let knots = Array1::from_vec(runtime.knots.clone());
2919 let beta_w = beta_time.slice(s![p_time_base..]).to_owned();
2920 let eta_exit_row = Array1::from_elem(1, q_exit_base);
2921 let deriv_row = Array1::from_elem(1, qd_exit_base);
2922 let exit_design = match buildwiggle_block_input_from_knots(
2923 eta_exit_row.view(),
2924 &knots,
2925 runtime.degree,
2926 2,
2927 false,
2928 )?
2929 .design
2930 {
2931 DesignMatrix::Dense(m) => m.to_dense_arc().as_ref().clone(),
2932 _ => {
2933 return Err(SurvivalPredictError::IncompatibleSchema {
2934 reason: "saved baseline-timewiggle exit design must be dense".to_string(),
2935 });
2936 }
2937 };
2938 let derivative_design = build_survival_timewiggle_derivative_design(
2939 &eta_exit_row,
2940 &deriv_row,
2941 &knots,
2942 runtime.degree,
2943 )?;
2944 (
2945 qd_exit_base + derivative_design.dot(&beta_w)[0],
2946 Some(exit_design),
2947 )
2948 } else {
2949 (qd_exit_base, None)
2950 };
2951
2952 let cov_dim = ctx.beta_marginal.len();
2961 let q_design_ncols = p_time_base + p_timewiggle + cov_dim;
2962 let mut q_design_full = Array2::<f64>::zeros((1, q_design_ncols));
2963 q_design_full
2964 .slice_mut(s![.., ..p_time_base])
2965 .assign(&row_time.x_exit_time.to_dense());
2966 if let Some(exit_w) = exit_wiggle_design.as_ref() {
2967 q_design_full
2968 .slice_mut(s![.., p_time_base..p_time_base + p_timewiggle])
2969 .assign(exit_w);
2970 }
2971 if cov_dim > 0 {
2972 let cov_row = design_row_owned(
2973 &ctx.cov_design,
2974 row_index,
2975 "survival marginal covariate row",
2976 )?;
2977 q_design_full
2978 .slice_mut(s![.., p_time_base + p_timewiggle..])
2979 .row_mut(0)
2980 .assign(&cov_row);
2981 }
2982
2983 let logslope_row = design_row_owned(
2990 &ctx.logslope_design,
2991 row_index,
2992 "survival marginal logslope row",
2993 )?;
2994 let mut logslope_design_2d = Array2::<f64>::zeros((1, logslope_row.len()));
2995 logslope_design_2d.row_mut(0).assign(&logslope_row);
2996
2997 let pred_input = PredictInput {
2998 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(q_design_full)),
2999 offset: Array1::from_elem(1, r_eta_exit[0] + primary_offset_row),
3000 design_noise: Some(DesignMatrix::Dense(
3001 gam_linalg::matrix::DenseDesignMatrix::from(logslope_design_2d),
3002 )),
3003 offset_noise: Some(Array1::from_elem(1, ctx.noise_offset[row_index])),
3004 auxiliary_scalar: Some(Array1::from_elem(1, ctx.z_raw[row_index])),
3005 auxiliary_matrix: None,
3006 };
3007
3008 let (eta_arr, deta_dq_arr) = ctx
3012 .predictor
3013 .predict_eta_and_q_chain(&pred_input)
3014 .map_err(|e| format!("saved survival marginal-slope predictor eta failed: {e}"))?;
3015 let eta = eta_arr[0];
3016 let eta_derivative = marginal_slope_index_derivative_at_horizon(deta_dq_arr[0], qd_with_wiggle);
3036 let (cum, haz) = probit_survival_hazard_components(eta, eta_derivative)?;
3037 Ok((eta, cum, haz))
3038}
3039
3040#[inline]
3053fn marginal_slope_index_derivative_at_horizon(deta_dq: f64, qd_with_wiggle: f64) -> f64 {
3054 let eta_derivative = deta_dq * qd_with_wiggle;
3055 if eta_derivative.is_finite() {
3056 eta_derivative.max(0.0)
3057 } else {
3058 eta_derivative
3059 }
3060}
3061
3062#[inline]
3063fn probit_survival_hazard_components(
3064 eta: f64,
3065 eta_derivative: f64,
3066) -> Result<(f64, f64), SurvivalPredictError> {
3067 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative >= 0.0) {
3068 return Err(SurvivalPredictError::NumericalFailure {
3069 reason: format!(
3070 "saved survival marginal-slope prediction produced invalid survival index derivative: eta={eta}, eta_t={eta_derivative}"
3071 ),
3072 });
3073 }
3074
3075 let (log_survival, mills_ratio) = signed_probit_logcdf_and_mills_ratio(-eta);
3080 let cumulative_hazard = -log_survival;
3081 let hazard = if eta_derivative == 0.0 {
3082 0.0
3083 } else {
3084 mills_ratio * eta_derivative
3085 };
3086 if !(cumulative_hazard >= 0.0 && hazard >= 0.0) {
3093 return Err(SurvivalPredictError::NumericalFailure {
3094 reason: format!(
3095 "saved survival marginal-slope prediction produced invalid survival components: eta={eta}, eta_t={eta_derivative}, log_survival={log_survival}, hazard={hazard}"
3096 ),
3097 });
3098 }
3099 Ok((cumulative_hazard, hazard))
3100}
3101
3102fn evaluate_rp_row(
3103 model: &SavedModel,
3104 row_time: &SurvivalTimeBuildOutput,
3105 cov_row: &Array1<f64>,
3106 eta_time_offset_row: f64,
3107 derivative_time_offset_row: f64,
3108 primary_offset_row: f64,
3109) -> Result<(f64, f64, f64), SurvivalPredictError> {
3110 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
3111 let saved_runtime = model.saved_prediction_runtime()?;
3112 evaluate_rp_row_with_beta(
3113 &fit_saved.beta,
3114 saved_runtime.baseline_time_wiggle.as_ref(),
3115 row_time,
3116 cov_row,
3117 eta_time_offset_row,
3118 derivative_time_offset_row,
3119 primary_offset_row,
3120 )
3121}
3122
3123fn evaluate_rp_row_with_beta(
3124 beta: &Array1<f64>,
3125 saved_timewiggle: Option<&SavedBaselineTimeWiggleRuntime>,
3126 row_time: &SurvivalTimeBuildOutput,
3127 cov_row: &Array1<f64>,
3128 eta_time_offset_row: f64,
3129 derivative_time_offset_row: f64,
3130 primary_offset_row: f64,
3131) -> Result<(f64, f64, f64), SurvivalPredictError> {
3132 let p_time = row_time.x_exit_time.ncols();
3133 let p_timewiggle = saved_timewiggle.map_or(0, |runtime| runtime.beta.len());
3134 let p_cov = cov_row.len();
3135 let p = p_time + p_timewiggle + p_cov;
3136 if beta.len() != p {
3137 let hint = stale_weibull_time_basis_hint(&row_time.basisname, beta.len() == p + 1);
3138 return Err(SurvivalPredictError::IncompatibleSchema {
3139 reason: format!(
3140 "survival RP coefficient mismatch: beta has {} entries but design has {} columns{hint}",
3141 beta.len(),
3142 p
3143 ),
3144 });
3145 }
3146 let mut x_exit = Array2::<f64>::zeros((1, p));
3147 if p_time > 0 {
3148 x_exit
3149 .slice_mut(s![.., ..p_time])
3150 .assign(&row_time.x_exit_time.to_dense());
3151 }
3152 let offset_derivative_component = derivative_time_offset_row;
3153 let mut eta_derivative = offset_derivative_component;
3154 let mut time_derivative_component = 0.0_f64;
3155 if p_time > 0 {
3156 time_derivative_component = row_time
3157 .x_derivative_time
3158 .dot(&beta.slice(s![..p_time]).to_owned())[0];
3159 eta_derivative += time_derivative_component;
3160 }
3161 let mut wiggle_derivative_component = 0.0_f64;
3162 if let Some(runtime) = saved_timewiggle {
3163 let knots = Array1::from_vec(runtime.knots.clone());
3164 let beta_w = beta.slice(s![p_time..p_time + p_timewiggle]).to_owned();
3165 let eta_exit_row = Array1::from_elem(1, eta_time_offset_row);
3166 let derivative_exit_row = Array1::from_elem(1, derivative_time_offset_row);
3167 let exit_design = match buildwiggle_block_input_from_knots(
3168 eta_exit_row.view(),
3169 &knots,
3170 runtime.degree,
3171 2,
3172 false,
3173 )?
3174 .design
3175 {
3176 DesignMatrix::Dense(m) => m.to_dense_arc().as_ref().clone(),
3177 _ => {
3178 return Err(SurvivalPredictError::IncompatibleSchema {
3179 reason: "saved baseline-timewiggle exit design must be dense".to_string(),
3180 });
3181 }
3182 };
3183 if exit_design.ncols() != p_timewiggle {
3184 return Err(SurvivalPredictError::IncompatibleSchema {
3185 reason: format!(
3186 "survival RP timewiggle design mismatch: rebuilt {} columns but runtime expects {}",
3187 exit_design.ncols(),
3188 p_timewiggle
3189 ),
3190 });
3191 }
3192 x_exit
3193 .slice_mut(s![.., p_time..p_time + p_timewiggle])
3194 .assign(&exit_design);
3195 let derivative_design = build_survival_timewiggle_derivative_design(
3196 &eta_exit_row,
3197 &derivative_exit_row,
3198 &knots,
3199 runtime.degree,
3200 )?;
3201 wiggle_derivative_component = derivative_design.dot(&beta_w)[0];
3202 eta_derivative += wiggle_derivative_component;
3203 }
3204 if !(eta_derivative.is_finite() && eta_derivative >= 0.0) {
3210 let time_beta = beta.slice(s![..p_time]);
3211 let beta_min = time_beta.iter().copied().fold(f64::INFINITY, f64::min);
3212 let beta_max = time_beta.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3213 let dtime = row_time.x_derivative_time.to_dense();
3214 let dmin = dtime.iter().copied().fold(f64::INFINITY, f64::min);
3215 let dmax = dtime.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3216 log::info!(
3217 "[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={}",
3218 saved_timewiggle.is_some(),
3219 );
3220 }
3221 if p_cov > 0 {
3222 x_exit
3223 .slice_mut(s![
3224 ..,
3225 (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov)
3226 ])
3227 .row_mut(0)
3228 .assign(cov_row);
3229 }
3230 let offset_view = Array1::from_elem(1, eta_time_offset_row + primary_offset_row);
3231 let likelihood = LikelihoodSpec::new(
3232 ResponseFamily::RoystonParmar,
3233 InverseLink::Standard(StandardLink::Identity),
3234 );
3235 let eta =
3236 predict_royston_parmar_eta(x_exit.view(), beta.view(), offset_view.view(), &likelihood)?[0];
3237 let (cum, haz) = royston_parmar_survival_hazard_components(eta, eta_derivative)?;
3238 Ok((eta, cum, haz))
3239}
3240
3241fn predict_royston_parmar_eta<X>(
3242 x: X,
3243 beta: ndarray::ArrayView1<'_, f64>,
3244 offset: ndarray::ArrayView1<'_, f64>,
3245 likelihood: &LikelihoodSpec,
3246) -> Result<Array1<f64>, SurvivalPredictError>
3247where
3248 X: Into<DesignMatrix>,
3249{
3250 if !matches!(likelihood.response, ResponseFamily::RoystonParmar)
3251 || !matches!(
3252 likelihood.link,
3253 InverseLink::Standard(StandardLink::Identity)
3254 )
3255 {
3256 return Err(SurvivalPredictError::UnsupportedConfiguration {
3257 reason: "survival prediction requires RoystonParmar with identity link".to_string(),
3258 });
3259 }
3260 let x = x.into();
3261 if x.nrows() != offset.len() || x.ncols() != beta.len() {
3262 return Err(SurvivalPredictError::IncompatibleSchema {
3263 reason: format!(
3264 "survival prediction design dimensions disagree: design is {}x{}, beta has length {}, offset has length {}",
3265 x.nrows(),
3266 x.ncols(),
3267 beta.len(),
3268 offset.len()
3269 ),
3270 });
3271 }
3272 let mut eta = x.matrixvectormultiply(&beta.to_owned());
3273 eta += &offset;
3274 Ok(eta)
3275}
3276
3277#[inline]
3278fn royston_parmar_survival_hazard_components(
3279 eta: f64,
3280 eta_derivative: f64,
3281) -> Result<(f64, f64), SurvivalPredictError> {
3282 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative >= 0.0) {
3298 return Err(SurvivalPredictError::NumericalFailure {
3299 reason: format!(
3300 "saved Royston-Parmar survival prediction produced invalid log-cumulative-hazard derivative: eta={eta}, eta_t={eta_derivative}"
3301 ),
3302 });
3303 }
3304 let cumulative_hazard = eta.exp();
3305 let hazard = if eta_derivative == 0.0 {
3311 0.0
3312 } else {
3313 cumulative_hazard * eta_derivative
3314 };
3315 if !(cumulative_hazard >= 0.0 && hazard >= 0.0) {
3324 return Err(SurvivalPredictError::NumericalFailure {
3325 reason: format!(
3326 "saved Royston-Parmar survival prediction produced invalid survival components: eta={eta}, eta_t={eta_derivative}, cumulative_hazard={cumulative_hazard}, hazard={hazard}"
3327 ),
3328 });
3329 }
3330 Ok((cumulative_hazard, hazard))
3331}
3332
3333fn predict_survival_location_scale_batch(
3342 model: &SavedModel,
3343 age_entry: &Array1<f64>,
3344 age_exit: &Array1<f64>,
3345 cov_design: &gam_terms::smooth::TermCollectionDesign,
3346 primary_offset: &Array1<f64>,
3347 noise_offset: &Array1<f64>,
3348 training_headers: Option<&Vec<String>>,
3349 col_map: &HashMap<String, usize>,
3350 data: ArrayView2<'_, f64>,
3351 time_grid: Option<&[f64]>,
3352 with_uncertainty: bool,
3353 covariance_mode: SurvivalPredictionCovarianceMode,
3354) -> Result<SurvivalPredictResult, String> {
3355 use crate::survival::construction::evaluate_survival_time_basis_row;
3356 use crate::survival::location_scale::{
3357 SurvivalLocationScalePredictInput, predict_survival_location_scale,
3358 predict_survival_location_scalewith_uncertainty, replay_survival_covariate_channels,
3359 };
3360 use gam_linalg::matrix::DesignMatrix;
3361
3362 let n = age_entry.len();
3363 let per_row_eval = time_grid.is_none();
3364 let eval_times: Vec<f64> = match time_grid {
3365 Some(grid) => {
3366 if grid.is_empty() {
3367 return Err("survival time_grid must contain at least one time".to_string());
3368 }
3369 for (idx, &t) in grid.iter().enumerate() {
3370 if !t.is_finite() || t < 0.0 {
3371 return Err(format!(
3372 "survival time_grid requires finite non-negative times (index {idx})",
3373 ));
3374 }
3375 }
3376 grid.to_vec()
3377 }
3378 None => Vec::new(),
3379 };
3380 let t_cols = if per_row_eval { 1 } else { eval_times.len() };
3381 let eval_width = if per_row_eval { 1 } else { t_cols + 1 };
3382 let saved_likelihood_mode = SurvivalLikelihoodMode::LocationScale;
3383 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
3384 let saved_fit = saved_survival_location_scale_fit_result(model)?;
3385 let saved_structure = model
3389 .survival_location_scale_structure
3390 .as_ref()
3391 .ok_or_else(|| {
3392 "saved location-scale survival model is missing exact replay structure".to_string()
3393 })?;
3394 let reduced_parametric_aft = matches!(
3395 saved_structure.time_parameterization,
3396 crate::survival::location_scale::SurvivalLocationScaleTimeParameterization::ReducedParametricAft
3397 );
3398 let time_cfg = load_survival_time_basis_config_from_model(model)?;
3399 let mut time_build = build_survival_time_basis(age_entry, age_exit, time_cfg.clone(), None)?;
3400 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
3401 &time_build.basisname,
3402 time_build.degree,
3403 time_build.knots.as_ref(),
3404 time_build.keep_cols.as_ref(),
3405 time_build.smooth_lambda,
3406 )?;
3407 let time_anchor = model
3408 .survival_time_anchor
3409 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
3410 let time_anchor_row = evaluate_survival_time_basis_row(time_anchor, &resolved_time_cfg)?;
3411 center_survival_time_designs_at_anchor(
3412 &mut time_build.x_entry_time,
3413 &mut time_build.x_exit_time,
3414 &time_anchor_row,
3415 )?;
3416 if !model.has_baseline_time_wiggle() && !reduced_parametric_aft {
3420 require_structural_survival_time_basis(&time_build.basisname, "saved survival sampling")?;
3421 }
3422 let saved_inverse_link = resolve_survival_inverse_link_from_saved(model)?;
3423 let (eval_entry, eval_exit) = if per_row_eval {
3424 (age_entry.clone(), age_exit.clone())
3425 } else {
3426 let total = n * eval_width;
3427 let mut entry = Array1::<f64>::zeros(total);
3428 let mut exit = Array1::<f64>::zeros(total);
3429 {
3430 use rayon::iter::{IntoParallelIterator, ParallelIterator};
3431 let pairs: Vec<(f64, f64)> = (0..total)
3432 .into_par_iter()
3433 .map(|k| {
3434 let i = k / eval_width;
3435 let col = k % eval_width;
3436 let t = if col < t_cols {
3437 eval_times[col]
3438 } else {
3439 age_exit[i]
3440 };
3441 (age_entry[i].min(t), t)
3442 })
3443 .collect();
3444 for (k, (t0, t1)) in pairs.into_iter().enumerate() {
3445 entry[k] = t0;
3446 exit[k] = t1;
3447 }
3448 }
3449 (entry, exit)
3450 };
3451 let mut time_build =
3452 build_survival_time_basis(&eval_entry, &eval_exit, time_cfg.clone(), None)?;
3453 center_survival_time_designs_at_anchor(
3454 &mut time_build.x_entry_time,
3455 &mut time_build.x_exit_time,
3456 &time_anchor_row,
3457 )?;
3458 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
3459 build_survival_time_offsets_for_likelihood(
3460 &eval_entry,
3461 &eval_exit,
3462 &baseline_cfg,
3463 saved_likelihood_mode,
3464 Some(&saved_inverse_link),
3465 )?;
3466 add_survival_time_derivative_guard_offset(
3467 &eval_entry,
3468 &eval_exit,
3469 time_anchor,
3470 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
3471 &mut eta_offset_entry,
3472 &mut eta_offset_exit,
3473 &mut derivative_offset_exit,
3474 )?;
3475 if reduced_parametric_aft {
3476 eta_offset_exit = Array1::<f64>::zeros(eval_exit.len());
3488 }
3489
3490 let saved_timewiggle_runtime = model.saved_baseline_time_wiggle()?;
3491
3492 let threshold_design = cov_design;
3498 let log_sigmaspec = resolve_termspec_for_prediction(
3499 &model.resolved_termspec_noise,
3500 training_headers,
3501 col_map,
3502 "resolved_termspec_noise",
3503 )?;
3504 let sigma_clipped = model.axis_clip_to_training_ranges(data, col_map);
3505 let sigma_input = sigma_clipped.as_ref().map_or(data, |arr| arr.view());
3506 let raw_sigma_design =
3507 gam_terms::smooth::build_term_collection_design(sigma_input, &log_sigmaspec)
3508 .map_err(|err| format!("failed to build survival log-sigma design: {err}"))?;
3509 let effective_noise_offset = raw_sigma_design
3510 .compose_offset(
3511 noise_offset.view(),
3512 "survival location-scale log-sigma block",
3513 )
3514 .map_err(|error| error.to_string())?;
3515
3516 let x_time_exit_dense = time_build
3517 .x_exit_time
3518 .try_to_dense_by_chunks("survival location-scale prediction time-exit design")?;
3519 let total_rows = eval_exit.len();
3520 let x_time_exit = if let Some(runtime) = saved_timewiggle_runtime.as_ref() {
3521 let mut full =
3522 Array2::<f64>::zeros((total_rows, x_time_exit_dense.ncols() + runtime.beta.len()));
3523 full.slice_mut(s![.., 0..x_time_exit_dense.ncols()])
3524 .assign(&x_time_exit_dense);
3525 full
3526 } else {
3527 x_time_exit_dense
3528 };
3529
3530 let repeat_rows =
3531 |matrix: &DesignMatrix, label: &str| -> Result<DesignMatrix, SurvivalPredictError> {
3532 if per_row_eval {
3533 return Ok(matrix.clone());
3534 }
3535 let dense = matrix.try_to_dense_by_chunks(label)?;
3536 let mut repeated = Array2::<f64>::zeros((total_rows, dense.ncols()));
3537 use rayon::iter::{IntoParallelIterator, ParallelIterator};
3538 let rows: Vec<Vec<f64>> = (0..total_rows)
3539 .into_par_iter()
3540 .map(|k| dense.row(k / eval_width).to_vec())
3541 .collect();
3542 for (k, row) in rows.into_iter().enumerate() {
3543 for (j, value) in row.into_iter().enumerate() {
3544 repeated[[k, j]] = value;
3545 }
3546 }
3547 Ok(DesignMatrix::from(repeated))
3548 };
3549 let expand_vector = |values: &Array1<f64>| -> Array1<f64> {
3550 if per_row_eval {
3551 values.clone()
3552 } else {
3553 Array1::from_shape_fn(total_rows, |k| values[k / eval_width])
3554 }
3555 };
3556 if saved_structure.threshold_time_basis.is_some()
3557 && threshold_design
3558 .affine_offset
3559 .iter()
3560 .any(|value| *value != 0.0)
3561 {
3562 return Err(
3563 "saved time-varying survival threshold cannot carry a non-zero smooth anchor"
3564 .to_string(),
3565 );
3566 }
3567 if saved_structure.log_sigma_time_basis.is_some()
3568 && raw_sigma_design
3569 .affine_offset
3570 .iter()
3571 .any(|value| *value != 0.0)
3572 {
3573 return Err(
3574 "saved time-varying survival log-sigma cannot carry a non-zero smooth anchor"
3575 .to_string(),
3576 );
3577 }
3578 let threshold_base_matrix = repeat_rows(
3579 &threshold_design.design,
3580 "survival location-scale prediction threshold design",
3581 )?;
3582 let raw_sigma_base_matrix = repeat_rows(
3583 &raw_sigma_design.design,
3584 "survival location-scale prediction log-sigma design",
3585 )?;
3586 let mut threshold_replay = replay_survival_covariate_channels(
3587 &threshold_base_matrix,
3588 &expand_vector(primary_offset),
3589 &eval_entry,
3590 &eval_exit,
3591 saved_structure.threshold_time_basis.as_ref(),
3592 "survival location-scale threshold",
3593 )?;
3594 let sigma_replay = replay_survival_covariate_channels(
3595 &raw_sigma_base_matrix,
3596 &expand_vector(&effective_noise_offset),
3597 &eval_entry,
3598 &eval_exit,
3599 saved_structure.log_sigma_time_basis.as_ref(),
3600 "survival location-scale log-sigma",
3601 )?;
3602 let link_wiggle_knots = model
3603 .linkwiggle_knots
3604 .as_ref()
3605 .map(|k| Array1::from_vec(k.clone()));
3606 let link_wiggle_degree = model.linkwiggle_degree;
3607 let time_wiggle_knots = saved_timewiggle_runtime
3608 .as_ref()
3609 .map(|w| Array1::from_vec(w.knots.clone()));
3610 let time_wiggle_degree = saved_timewiggle_runtime.as_ref().map(|w| w.degree);
3611 let time_wiggle_ncols = saved_timewiggle_runtime
3612 .as_ref()
3613 .map_or(0, |w| w.beta.len());
3614
3615 if reduced_parametric_aft {
3624 for (slot, &t) in threshold_replay.offset.iter_mut().zip(eval_exit.iter()) {
3625 *slot -= t
3626 .max(crate::survival::construction::SURVIVAL_TIME_FLOOR)
3627 .ln();
3628 }
3629 }
3630 let pred_input = SurvivalLocationScalePredictInput {
3635 x_time_exit,
3636 eta_time_offset_exit: eta_offset_exit.clone(),
3637 time_wiggle_knots: time_wiggle_knots.clone(),
3638 time_wiggle_degree,
3639 time_wiggle_ncols,
3640 x_threshold: threshold_replay.design_exit.clone(),
3641 eta_threshold_offset: threshold_replay.offset.clone(),
3642 x_log_sigma: sigma_replay.design_exit.clone(),
3643 eta_log_sigma_offset: sigma_replay.offset.clone(),
3644 x_link_wiggle: None,
3645 link_wiggle_knots: link_wiggle_knots.clone(),
3646 link_wiggle_degree,
3647 inverse_link: saved_inverse_link.clone(),
3648 };
3649
3650 let (eta_full, survival_prob_full, response_se_full, eta_se_full): (
3653 Array1<f64>,
3654 Array1<f64>,
3655 Option<Array1<f64>>,
3656 Option<Array1<f64>>,
3657 ) = if with_uncertainty {
3658 let cov = match select_survival_prediction_covariance(
3664 saved_fit.beta_covariance(),
3665 saved_fit.beta_covariance_corrected(),
3666 covariance_mode,
3667 ) {
3668 Ok(cov) => cov,
3669 Err(SurvivalPredictError::PosteriorCovariance { reason })
3670 if covariance_mode == SurvivalPredictionCovarianceMode::Conditional =>
3671 {
3672 return Err(format!(
3673 "survival location-scale uncertainty: {reason}; refit with the \
3674 current CLI / library to populate beta_covariance"
3675 ));
3676 }
3677 Err(err) => return Err(String::from(err)),
3678 };
3679 let unc = predict_survival_location_scalewith_uncertainty(
3680 &pred_input,
3681 &saved_fit,
3682 cov,
3683 false,
3684 true,
3685 )
3686 .map_err(|err| format!("survival location-scale uncertainty predict failed: {err}"))?;
3687 let response_se = unc.response_standard_error.ok_or_else(|| {
3688 "survival location-scale uncertainty: response_standard_error \
3689 missing despite include_response_sd=true"
3690 .to_string()
3691 })?;
3692 (
3693 unc.eta,
3694 unc.survival_prob,
3695 Some(response_se),
3696 Some(unc.eta_standard_error),
3697 )
3698 } else {
3699 let pred = predict_survival_location_scale(&pred_input, &saved_fit)
3700 .map_err(|err| format!("survival location-scale predict failed: {err}"))?;
3701 (pred.eta, pred.survival_prob, None, None)
3702 };
3703
3704 let beta_threshold = saved_fit.beta_threshold();
3705 let beta_log_sigma = saved_fit.beta_log_sigma();
3706 let eta_threshold = threshold_replay
3707 .design_exit
3708 .matrixvectormultiply(&beta_threshold)
3709 + &threshold_replay.offset;
3710 let mut eta_threshold_derivative = threshold_replay
3711 .design_derivative_exit
3712 .as_ref()
3713 .map(|design| design.matrixvectormultiply(&beta_threshold))
3714 .unwrap_or_else(|| Array1::zeros(total_rows));
3715 if reduced_parametric_aft {
3716 for (slot, &time) in eta_threshold_derivative.iter_mut().zip(eval_exit.iter()) {
3717 *slot -= 1.0 / time.max(crate::survival::construction::SURVIVAL_TIME_FLOOR);
3718 }
3719 }
3720 let eta_log_sigma = sigma_replay
3721 .design_exit
3722 .matrixvectormultiply(&beta_log_sigma)
3723 + &sigma_replay.offset;
3724 let eta_log_sigma_derivative = sigma_replay
3725 .design_derivative_exit
3726 .as_ref()
3727 .map(|design| design.matrixvectormultiply(&beta_log_sigma))
3728 .unwrap_or_else(|| Array1::zeros(total_rows));
3729 let hdot = if reduced_parametric_aft {
3730 Array1::zeros(total_rows)
3731 } else {
3732 let x_time_derivative = time_build
3733 .x_derivative_time
3734 .try_to_dense_by_chunks("survival location-scale prediction time-derivative design")?;
3735 location_scale_eta_derivative_components(
3736 &x_time_derivative,
3737 &derivative_offset_exit,
3738 &pred_input.x_time_exit,
3739 &pred_input.eta_time_offset_exit,
3740 time_wiggle_knots.as_ref(),
3741 time_wiggle_degree,
3742 time_wiggle_ncols,
3743 &saved_fit,
3744 )?
3745 };
3746 let inv_sigma = eta_log_sigma.mapv(crate::sigma_link::exp_sigma_inverse_from_eta_scalar);
3747 let q_base = -&eta_threshold * &inv_sigma;
3748 let mut qdot =
3749 &inv_sigma * &(&eta_threshold * &eta_log_sigma_derivative - &eta_threshold_derivative);
3750 if let Some(beta_wiggle) = saved_fit.beta_link_wiggle() {
3751 let knots = link_wiggle_knots.as_ref().ok_or_else(|| {
3752 "saved location-scale link-wiggle coefficients are missing knots".to_string()
3753 })?;
3754 let degree = link_wiggle_degree.ok_or_else(|| {
3755 "saved location-scale link-wiggle coefficients are missing degree".to_string()
3756 })?;
3757 let derivative_basis = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
3758 q_base.view(),
3759 knots,
3760 degree,
3761 1,
3762 )?;
3763 if derivative_basis.ncols() != beta_wiggle.len() {
3764 return Err(format!(
3765 "saved location-scale link-wiggle derivative width mismatch: design={}, beta={}",
3766 derivative_basis.ncols(),
3767 beta_wiggle.len()
3768 ));
3769 }
3770 qdot *= &(derivative_basis.dot(&beta_wiggle) + 1.0);
3771 }
3772 let eta_derivative_full = hdot + qdot;
3773 if eta_derivative_full
3774 .iter()
3775 .any(|value| !(value.is_finite() && *value > 0.0))
3776 {
3777 return Err(
3778 "saved location-scale survival event-rate derivative must be finite and positive"
3779 .to_string(),
3780 );
3781 }
3782 let hazard_full = location_scale_hazard_from_eta_derivative(
3783 &eta_full,
3784 &eta_derivative_full,
3785 &saved_inverse_link,
3786 )?;
3787
3788 let mut survival = Array2::<f64>::zeros((n, t_cols));
3789 let mut cumulative_hazard = Array2::<f64>::zeros((n, t_cols));
3790 let mut hazard = Array2::<f64>::zeros((n, t_cols));
3791 ndarray::Zip::indexed(&mut survival)
3792 .and(&mut cumulative_hazard)
3793 .and(&mut hazard)
3794 .par_for_each(|(i, j), s, ch, h| {
3795 let query_time = if per_row_eval {
3804 age_exit[i]
3805 } else {
3806 eval_times[j]
3807 };
3808 if query_time <= 0.0 {
3809 *s = 1.0;
3810 *ch = 0.0;
3811 *h = 0.0;
3812 return;
3813 }
3814 let k = if per_row_eval { i } else { i * eval_width + j };
3815 let surv = survival_prob_full[k].clamp(SURVIVAL_PROB_MIN_FOR_LOG, 1.0);
3816 *s = surv;
3817 *ch = -surv.ln();
3818 *h = hazard_full[k];
3819 });
3820
3821 let linear_predictor = if per_row_eval {
3822 eta_full.clone()
3823 } else {
3824 Array1::from_shape_fn(n, |i| eta_full[i * eval_width + t_cols])
3825 };
3826 let times = if per_row_eval {
3827 age_exit.to_vec()
3828 } else {
3829 eval_times.clone()
3832 };
3833
3834 let survival_se = response_se_full.as_ref().map(|response_se| {
3835 let mut out = Array2::<f64>::zeros((n, t_cols));
3836 ndarray::Zip::indexed(&mut out).par_for_each(|(i, j), slot| {
3837 let query_time = if per_row_eval {
3840 age_exit[i]
3841 } else {
3842 eval_times[j]
3843 };
3844 if query_time <= 0.0 {
3845 *slot = 0.0;
3846 return;
3847 }
3848 let k = if per_row_eval { i } else { i * eval_width + j };
3849 *slot = response_se[k].max(0.0);
3850 });
3851 out
3852 });
3853 let eta_se_per_row = eta_se_full.as_ref().map(|eta_se| {
3854 if per_row_eval {
3855 eta_se.clone()
3856 } else {
3857 Array1::from_shape_fn(n, |i| eta_se[i * eval_width + t_cols])
3858 }
3859 });
3860
3861 Ok(SurvivalPredictResult {
3862 times,
3863 hazard,
3864 survival,
3865 cumulative_hazard,
3866 linear_predictor,
3867 likelihood_mode: saved_likelihood_mode,
3868 survival_se,
3869 eta_se: eta_se_per_row,
3870 covariance_source: with_uncertainty.then_some(covariance_mode),
3871 })
3872}
3873
3874pub(crate) struct LocationScaleEtaComponents {
3875 pub h: Array1<f64>,
3876 pub time_jac: Array2<f64>,
3877 pub eta_t: Array1<f64>,
3878 pub eta_ls: Array1<f64>,
3879 pub inv_sigma: Array1<f64>,
3880}
3881
3882pub(crate) struct LocationScaleTimeWarpComponents {
3883 pub(crate) h: Array1<f64>,
3884 pub(crate) time_jac: Array2<f64>,
3885 pub(crate) time_wiggle_dq: Option<Array1<f64>>,
3886}
3887
3888pub(crate) fn location_scale_time_warp_components(
3889 x_time_exit: &Array2<f64>,
3890 eta_time_offset_exit: &Array1<f64>,
3891 time_wiggle_knots: Option<&Array1<f64>>,
3892 time_wiggle_degree: Option<usize>,
3893 time_wiggle_ncols: usize,
3894 fit: &UnifiedFitResult,
3895) -> Result<LocationScaleTimeWarpComponents, String> {
3896 let n = x_time_exit.nrows();
3897 if eta_time_offset_exit.len() != n {
3898 return Err("survival location-scale time-warp row mismatch across inputs".to_string());
3899 }
3900 let beta_time = fit.beta_time();
3901 if x_time_exit.ncols() != beta_time.len() {
3902 return Err(format!(
3903 "survival location-scale time-warp design mismatch: x_exit={} beta_time={}",
3904 x_time_exit.ncols(),
3905 beta_time.len()
3906 ));
3907 }
3908
3909 let p_time_total = beta_time.len();
3910 let p_wiggle = time_wiggle_ncols.min(p_time_total);
3911 let p_base = p_time_total - p_wiggle;
3912 let beta_base = beta_time.slice(s![..p_base]).to_owned();
3913 let h_base = if p_base > 0 {
3914 x_time_exit.slice(s![.., ..p_base]).dot(&beta_base) + eta_time_offset_exit
3915 } else {
3916 eta_time_offset_exit.clone()
3917 };
3918 let mut h = h_base.clone();
3919 let mut time_jac = x_time_exit.clone();
3920 let mut time_wiggle_dq = None;
3921 if p_wiggle > 0 {
3922 if x_time_exit
3923 .slice(s![.., p_base..p_time_total])
3924 .iter()
3925 .any(|&value| value != 0.0)
3926 {
3927 return Err(
3928 "survival location-scale timewiggle prediction requires zero placeholder tail columns"
3929 .to_string(),
3930 );
3931 }
3932 let knots = time_wiggle_knots.ok_or_else(|| {
3933 "survival location-scale time-warp: timewiggle coefficients are missing knot metadata"
3934 .to_string()
3935 })?;
3936 let degree = time_wiggle_degree.ok_or_else(|| {
3937 "survival location-scale time-warp: timewiggle coefficients are missing degree metadata"
3938 .to_string()
3939 })?;
3940 let beta_w = beta_time.slice(s![p_base..p_time_total]).to_owned();
3941 let time_basis = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
3942 h_base.view(),
3943 knots,
3944 degree,
3945 0,
3946 )?;
3947 let time_basis_d1 = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
3948 h_base.view(),
3949 knots,
3950 degree,
3951 1,
3952 )?;
3953 if time_basis.ncols() != p_wiggle || time_basis_d1.ncols() != p_wiggle {
3954 return Err(format!(
3955 "survival location-scale time-warp timewiggle mismatch: value basis has {} columns, derivative basis has {}, beta has {}",
3956 time_basis.ncols(),
3957 time_basis_d1.ncols(),
3958 p_wiggle
3959 ));
3960 }
3961 let dq = time_basis_d1.dot(&beta_w) + 1.0;
3962 h = &h_base + &time_basis.dot(&beta_w);
3963 time_jac = Array2::<f64>::zeros((n, p_time_total));
3964 if p_base > 0 {
3965 let scaled_base = crate::survival::location_scale::scale_dense_rows(
3966 &x_time_exit.slice(s![.., ..p_base]).to_owned(),
3967 &dq,
3968 )?;
3969 time_jac.slice_mut(s![.., ..p_base]).assign(&scaled_base);
3970 }
3971 time_jac
3972 .slice_mut(s![.., p_base..p_time_total])
3973 .assign(&time_basis);
3974 time_wiggle_dq = Some(dq);
3975 }
3976
3977 Ok(LocationScaleTimeWarpComponents {
3978 h,
3979 time_jac,
3980 time_wiggle_dq,
3981 })
3982}
3983
3984pub(crate) fn location_scale_eta_components(
3985 x_time_exit: &Array2<f64>,
3986 eta_time_offset_exit: &Array1<f64>,
3987 time_wiggle_knots: Option<&Array1<f64>>,
3988 time_wiggle_degree: Option<usize>,
3989 time_wiggle_ncols: usize,
3990 x_threshold: &gam_linalg::matrix::DesignMatrix,
3991 eta_threshold_offset: &Array1<f64>,
3992 x_log_sigma: &gam_linalg::matrix::DesignMatrix,
3993 eta_log_sigma_offset: &Array1<f64>,
3994 fit: &UnifiedFitResult,
3995) -> Result<LocationScaleEtaComponents, String> {
3996 let n = x_time_exit.nrows();
3997 if x_threshold.nrows() != n
3998 || eta_threshold_offset.len() != n
3999 || x_log_sigma.nrows() != n
4000 || eta_log_sigma_offset.len() != n
4001 {
4002 return Err("survival location-scale eta component row mismatch across inputs".to_string());
4003 }
4004 let time_components = location_scale_time_warp_components(
4005 x_time_exit,
4006 eta_time_offset_exit,
4007 time_wiggle_knots,
4008 time_wiggle_degree,
4009 time_wiggle_ncols,
4010 fit,
4011 )?;
4012 let beta_threshold = fit.beta_threshold();
4013 let beta_log_sigma = fit.beta_log_sigma();
4014 let eta_t = x_threshold.matrixvectormultiply(&beta_threshold) + eta_threshold_offset;
4015 let eta_ls = x_log_sigma.matrixvectormultiply(&beta_log_sigma) + eta_log_sigma_offset;
4016 let inv_sigma = eta_ls.mapv(crate::sigma_link::exp_sigma_inverse_from_eta_scalar);
4017 Ok(LocationScaleEtaComponents {
4018 h: time_components.h,
4019 time_jac: time_components.time_jac,
4020 eta_t,
4021 eta_ls,
4022 inv_sigma,
4023 })
4024}
4025
4026fn location_scale_eta_derivative_components(
4027 x_time_derivative: &Array2<f64>,
4028 derivative_offset_exit: &Array1<f64>,
4029 x_time_exit: &Array2<f64>,
4030 eta_time_offset_exit: &Array1<f64>,
4031 time_wiggle_knots: Option<&Array1<f64>>,
4032 time_wiggle_degree: Option<usize>,
4033 time_wiggle_ncols: usize,
4034 fit: &UnifiedFitResult,
4035) -> Result<Array1<f64>, String> {
4036 let n = x_time_exit.nrows();
4037 if x_time_derivative.nrows() != n
4038 || derivative_offset_exit.len() != n
4039 || eta_time_offset_exit.len() != n
4040 {
4041 return Err(
4042 "survival location-scale hazard derivative row mismatch across inputs".to_string(),
4043 );
4044 }
4045 let beta_time = fit.beta_time();
4046 let p_time_total = beta_time.len();
4047 let p_wiggle = time_wiggle_ncols.min(p_time_total);
4048 let p_base = p_time_total - p_wiggle;
4049 if x_time_exit.ncols() != p_time_total || x_time_derivative.ncols() != p_base {
4050 return Err(format!(
4051 "survival location-scale hazard derivative design mismatch: x_exit={} beta_time={} x_derivative={} base={}",
4052 x_time_exit.ncols(),
4053 p_time_total,
4054 x_time_derivative.ncols(),
4055 p_base
4056 ));
4057 }
4058
4059 let time_components = location_scale_time_warp_components(
4060 x_time_exit,
4061 eta_time_offset_exit,
4062 time_wiggle_knots,
4063 time_wiggle_degree,
4064 time_wiggle_ncols,
4065 fit,
4066 )?;
4067 let beta_base = beta_time.slice(s![..p_base]).to_owned();
4068 let mut eta_derivative = if p_base > 0 {
4069 x_time_derivative.dot(&beta_base) + derivative_offset_exit
4070 } else {
4071 derivative_offset_exit.clone()
4072 };
4073 if let Some(dq) = time_components.time_wiggle_dq.as_ref() {
4074 eta_derivative *= dq;
4075 }
4076 if eta_derivative
4077 .iter()
4078 .any(|value| !(value.is_finite() && *value > 0.0))
4079 {
4080 return Err(
4081 "survival location-scale hazard derivative must be finite and positive".to_string(),
4082 );
4083 }
4084 Ok(eta_derivative)
4085}
4086
4087fn location_scale_hazard_from_eta_derivative(
4088 eta: &Array1<f64>,
4089 eta_derivative: &Array1<f64>,
4090 inverse_link: &InverseLink,
4091) -> Result<Array1<f64>, String> {
4092 if eta.len() != eta_derivative.len() {
4093 return Err(format!(
4094 "survival location-scale hazard row mismatch: eta={} eta_derivative={}",
4095 eta.len(),
4096 eta_derivative.len()
4097 ));
4098 }
4099 let values = eta
4100 .iter()
4101 .zip(eta_derivative.iter())
4102 .map(|(&q, &q_t)| location_scale_hazard_component(q, q_t, inverse_link))
4103 .collect::<Result<Vec<_>, _>>()?;
4104 Ok(Array1::from_vec(values))
4105}
4106
4107fn location_scale_hazard_component(
4108 eta: f64,
4109 eta_derivative: f64,
4110 inverse_link: &InverseLink,
4111) -> Result<f64, String> {
4112 if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative > 0.0) {
4113 return Err(format!(
4114 "survival location-scale hazard requires finite eta and positive eta_t, got eta={eta}, eta_t={eta_derivative}"
4115 ));
4116 }
4117 match inverse_link {
4118 InverseLink::Standard(StandardLink::Probit) => {
4119 let (_, hazard) = probit_survival_hazard_components(eta, eta_derivative)?;
4120 Ok(hazard)
4121 }
4122 InverseLink::Standard(StandardLink::CLogLog) => {
4123 let (_, hazard) = royston_parmar_survival_hazard_components(eta, eta_derivative)?;
4124 Ok(hazard)
4125 }
4126 InverseLink::Standard(StandardLink::Logit) => {
4127 let failure = if eta >= 0.0 {
4128 1.0 / (1.0 + (-eta).exp())
4129 } else {
4130 let exp_eta = eta.exp();
4131 exp_eta / (1.0 + exp_eta)
4132 };
4133 Ok(failure * eta_derivative)
4134 }
4135 InverseLink::Standard(StandardLink::Identity) => {
4136 let survival = 1.0 - eta;
4137 if !(survival.is_finite() && survival > 0.0) {
4138 return Err(format!(
4139 "survival location-scale identity link produced invalid survival={survival} at eta={eta}"
4140 ));
4141 }
4142 Ok(eta_derivative / survival)
4143 }
4144 _ => {
4145 let jet = inverse_link_jet_for_inverse_link(inverse_link, eta)
4146 .map_err(|err| format!("survival location-scale inverse-link jet failed: {err}"))?;
4147 let survival = 1.0 - jet.mu;
4148 let hazard = jet.d1 * eta_derivative / survival;
4149 if !(survival.is_finite() && survival > 0.0 && hazard.is_finite() && hazard >= 0.0) {
4150 return Err(format!(
4151 "survival location-scale inverse link produced invalid hazard components: eta={eta}, eta_t={eta_derivative}, failure={}, d_failure={}, survival={survival}, hazard={hazard}",
4152 jet.mu, jet.d1
4153 ));
4154 }
4155 Ok(hazard)
4156 }
4157 }
4158}
4159
4160pub fn require_saved_survival_likelihood_mode(
4166 model: &SavedModel,
4167) -> Result<SurvivalLikelihoodMode, SurvivalPredictError> {
4168 if matches!(&model.family_state, FittedFamily::LatentSurvival { .. }) {
4169 return match model.survival_likelihood.as_deref() {
4170 Some("latent") => Ok(SurvivalLikelihoodMode::Latent),
4171 Some(other) => Err(SurvivalPredictError::MissingFitMetadata { reason: format!(
4172 "saved latent survival model has contradictory survival_likelihood metadata: expected 'latent', got '{other}'"
4173 ) }),
4174 None => Err(SurvivalPredictError::MissingFitMetadata {
4175 reason:
4176 "saved latent survival model is missing survival_likelihood=latent metadata; refit"
4177 .to_string(),
4178 }),
4179 };
4180 }
4181 if matches!(&model.family_state, FittedFamily::LatentBinary { .. }) {
4182 return match model.survival_likelihood.as_deref() {
4183 Some("latent-binary") => Ok(SurvivalLikelihoodMode::LatentBinary),
4184 Some(other) => Err(SurvivalPredictError::MissingFitMetadata { reason: format!(
4185 "saved latent binary model has contradictory survival_likelihood metadata: expected 'latent-binary', got '{other}'"
4186 ) }),
4187 None => Err(SurvivalPredictError::MissingFitMetadata {
4188 reason:
4189 "saved latent binary model is missing survival_likelihood=latent-binary metadata; refit"
4190 .to_string(),
4191 }),
4192 };
4193 }
4194 let raw = model.survival_likelihood.as_deref().ok_or_else(|| {
4195 "saved survival model is missing survival_likelihood metadata; refit".to_string()
4196 })?;
4197 parse_survival_likelihood_mode(raw).map_err(SurvivalPredictError::from)
4198}
4199
4200pub fn saved_survival_runtime_baseline_config(
4202 model: &SavedModel,
4203) -> Result<SurvivalBaselineConfig, SurvivalPredictError> {
4204 survival_baseline_config_from_model(model).map_err(SurvivalPredictError::from)
4205}
4206
4207pub fn resolve_termspec_for_prediction(
4210 modelspec: &Option<TermCollectionSpec>,
4211 training_headers: Option<&Vec<String>>,
4212 col_map: &HashMap<String, usize>,
4213 spec_label: &str,
4214) -> Result<TermCollectionSpec, SurvivalPredictError> {
4215 let saved = modelspec.as_ref().ok_or_else(|| {
4216 format!(
4217 "model is missing {spec_label}; refit to guarantee train/predict design consistency"
4218 )
4219 })?;
4220 saved.validate_frozen(spec_label)?;
4221 let headers = training_headers.ok_or_else(|| {
4222 "model is missing training_headers; refit to guarantee stable feature mapping at prediction time"
4223 .to_string()
4224 })?;
4225 let remapped = remap_term_collectionspec_columns(saved, headers, col_map)?;
4226 remapped.validate_frozen(spec_label)?;
4227 Ok(remapped)
4228}
4229
4230fn remap_term_collectionspec_columns(
4231 spec: &TermCollectionSpec,
4232 training_headers: &[String],
4233 prediction_column_map: &HashMap<String, usize>,
4234) -> Result<TermCollectionSpec, SurvivalPredictError> {
4235 spec.remap_feature_columns(|index| -> Result<usize, SurvivalPredictError> {
4239 let name = training_headers
4240 .get(index)
4241 .ok_or_else(|| format!("saved training column index {index} is out of bounds"))?;
4242 resolve_role_col(prediction_column_map, name, "prediction")
4243 .map_err(SurvivalPredictError::from)
4244 })
4245}
4246
4247pub fn fit_result_from_saved_model_for_prediction(
4249 model: &SavedModel,
4250) -> Result<UnifiedFitResult, String> {
4251 model
4252 .fit_result
4253 .clone()
4254 .ok_or_else(|| "model is missing canonical fit_result payload; refit".to_string())
4255}
4256
4257pub fn saved_survival_location_scale_fit_result(
4263 model: &SavedModel,
4264) -> Result<UnifiedFitResult, SurvivalPredictError> {
4265 model.saved_prediction_runtime()?;
4266 let mut fit = model.fit_result.clone().ok_or_else(|| {
4267 "saved location-scale survival model missing canonical fit_result; refit".to_string()
4268 })?;
4269 let inverse_link = resolve_survival_inverse_link_from_saved(model)?;
4270 apply_inverse_link_state_to_fit_result(&mut fit, &inverse_link);
4271 Ok(fit)
4272}
4273
4274pub fn apply_inverse_link_state_to_fit_result(
4275 fit_result: &mut UnifiedFitResult,
4276 inverse_link: &InverseLink,
4277) {
4278 fit_result.fitted_link = match inverse_link {
4279 InverseLink::LatentCLogLog(state) => FittedLinkState::LatentCLogLog { state: *state },
4280 InverseLink::Sas(state) => FittedLinkState::Sas {
4281 state: *state,
4282 covariance: None,
4283 },
4284 InverseLink::BetaLogistic(state) => FittedLinkState::BetaLogistic {
4285 state: *state,
4286 covariance: None,
4287 },
4288 InverseLink::Mixture(state) => FittedLinkState::Mixture {
4289 state: state.clone(),
4290 covariance: None,
4291 },
4292 InverseLink::Standard(_) => FittedLinkState::Standard(None),
4293 };
4294}
4295
4296pub fn resolve_survival_inverse_link_from_saved(
4299 model: &SavedModel,
4300) -> Result<InverseLink, SurvivalPredictError> {
4301 if let Some(link) = model.link.as_ref() {
4302 return Ok(link.clone());
4303 }
4304 Err(SurvivalPredictError::MissingFitMetadata {
4305 reason: "saved survival model is missing link metadata; refit".to_string(),
4306 })
4307}
4308
4309pub fn concat_array1_refs(parts: &[&Array1<f64>]) -> Array1<f64> {
4311 let total: usize = parts.iter().map(|part| part.len()).sum();
4312 let mut out = Array1::<f64>::zeros(total);
4313 let mut offset = 0usize;
4314 for part in parts {
4315 let width = part.len();
4316 out.slice_mut(s![offset..offset + width]).assign(part);
4317 offset += width;
4318 }
4319 out
4320}
4321
4322pub fn saved_baseline_timewiggle_components(
4326 eta_entry: &Array1<f64>,
4327 eta_exit: &Array1<f64>,
4328 derivative_exit: &Array1<f64>,
4329 model: &SavedModel,
4330) -> Result<Option<(Array2<f64>, Array2<f64>, Array2<f64>)>, SurvivalPredictError> {
4331 match model.saved_baseline_time_wiggle()? {
4332 None => Ok(None),
4333 Some(runtime) => {
4334 runtime.validate_global_monotonicity()?;
4335 let SavedBaselineTimeWiggleRuntime {
4336 knots,
4337 degree,
4338 beta,
4339 ..
4340 } = runtime;
4341 let knots = Array1::from_vec(knots);
4342 let entry = match buildwiggle_block_input_from_knots(
4343 eta_entry.view(),
4344 &knots,
4345 degree,
4346 2,
4347 false,
4348 )?
4349 .design
4350 {
4351 DesignMatrix::Dense(m) => m.to_dense_arc().as_ref().clone(),
4352 _ => {
4353 return Err(SurvivalPredictError::IncompatibleSchema {
4354 reason: "saved baseline-timewiggle entry design must be dense".to_string(),
4355 });
4356 }
4357 };
4358 let exit = match buildwiggle_block_input_from_knots(
4359 eta_exit.view(),
4360 &knots,
4361 degree,
4362 2,
4363 false,
4364 )?
4365 .design
4366 {
4367 DesignMatrix::Dense(m) => m.to_dense_arc().as_ref().clone(),
4368 _ => {
4369 return Err(SurvivalPredictError::IncompatibleSchema {
4370 reason: "saved baseline-timewiggle exit design must be dense".to_string(),
4371 });
4372 }
4373 };
4374 let betaw = beta;
4375 if entry.ncols() != betaw.len() || exit.ncols() != betaw.len() {
4376 return Err(SurvivalPredictError::IncompatibleSchema {
4377 reason: format!(
4378 "saved baseline-timewiggle dimension mismatch: coefficients have {} entries but basis has entry={} exit={}",
4379 betaw.len(),
4380 entry.ncols(),
4381 exit.ncols()
4382 ),
4383 });
4384 }
4385 let derivative = build_survival_timewiggle_derivative_design(
4386 eta_exit,
4387 derivative_exit,
4388 &knots,
4389 degree,
4390 )
4391 .map_err(|e| {
4392 e.replace(
4393 "build baseline-timewiggle",
4394 "evaluate saved baseline-timewiggle",
4395 )
4396 })?;
4397 if derivative.ncols() != betaw.len() {
4398 return Err(SurvivalPredictError::IncompatibleSchema {
4399 reason: format!(
4400 "saved baseline-timewiggle derivative dimension mismatch: coefficients have {} entries but derivative basis has {} columns",
4401 betaw.len(),
4402 derivative.ncols()
4403 ),
4404 });
4405 }
4406 Ok(Some((entry, exit, derivative)))
4407 }
4408 }
4409}
4410
4411pub fn build_saved_survival_marginal_slope_predictor(
4420 model: &SavedModel,
4421 fit_saved: &UnifiedFitResult,
4422 z_name: &str,
4423 z: &Array1<f64>,
4424 cov_design: &DesignMatrix,
4425 logslope_design: &DesignMatrix,
4426 time_build: &SurvivalTimeBuildOutput,
4427 eta_offset_entry: &Array1<f64>,
4428 eta_offset_exit: &Array1<f64>,
4429 derivative_offset_exit: &Array1<f64>,
4430 primary_offset: &Array1<f64>,
4431 noise_offset: &Array1<f64>,
4432) -> Result<
4433 (
4434 BernoulliMarginalSlopePredictor,
4435 PredictInput,
4436 UnifiedFitResult,
4437 ),
4438 SurvivalPredictError,
4439> {
4440 let saved_runtime = model.saved_prediction_runtime()?;
4441 if saved_runtime.link_wiggle.is_some() {
4442 return Err(SurvivalPredictError::MissingFitMetadata {
4443 reason:
4444 "saved survival marginal-slope model contains legacy linkwiggle metadata; refit with the anchored link-deviation runtime"
4445 .to_string(),
4446 });
4447 }
4448
4449 let saved_score_runtime = saved_runtime.score_warp;
4450 let saved_link_runtime = saved_runtime.link_deviation;
4451 let influence_absorber_width = saved_runtime.influence_absorber_width;
4456 let blocks = &fit_saved.blocks;
4457 let expected_blocks = 3
4458 + usize::from(saved_score_runtime.is_some())
4459 + usize::from(saved_link_runtime.is_some())
4460 + usize::from(influence_absorber_width.is_some());
4461 if blocks.len() != expected_blocks {
4462 return Err(SurvivalPredictError::IncompatibleSchema {
4463 reason: format!(
4464 "saved survival marginal-slope model requires {} blocks [time, marginal, slope{}{}{}], got {}",
4465 expected_blocks,
4466 if saved_score_runtime.is_some() {
4467 ", score-warp"
4468 } else {
4469 ""
4470 },
4471 if saved_link_runtime.is_some() {
4472 ", link-deviation"
4473 } else {
4474 ""
4475 },
4476 if influence_absorber_width.is_some() {
4477 ", influence-absorber(dropped)"
4478 } else {
4479 ""
4480 },
4481 blocks.len(),
4482 ),
4483 });
4484 }
4485
4486 let beta_time = &blocks[0].beta;
4487 let beta_marginal = &blocks[1].beta;
4488 let beta_logslope = &blocks[2].beta;
4489 if let Some(runtime) = saved_score_runtime.as_ref() {
4490 let beta = &blocks[3].beta;
4491 if beta.len() != runtime.basis_dim {
4492 return Err(SurvivalPredictError::IncompatibleSchema {
4493 reason: format!(
4494 "saved survival marginal-slope score-warp coefficient mismatch: beta has {} entries but runtime expects {}",
4495 beta.len(),
4496 runtime.basis_dim
4497 ),
4498 });
4499 }
4500 }
4501 if let Some(runtime) = saved_link_runtime.as_ref() {
4502 let idx = 3 + usize::from(saved_score_runtime.is_some());
4503 let beta = &blocks[idx].beta;
4504 if beta.len() != runtime.basis_dim {
4505 return Err(SurvivalPredictError::IncompatibleSchema {
4506 reason: format!(
4507 "saved survival marginal-slope link-deviation coefficient mismatch: beta has {} entries but runtime expects {}",
4508 beta.len(),
4509 runtime.basis_dim
4510 ),
4511 });
4512 }
4513 }
4514
4515 if beta_marginal.len() != cov_design.ncols() {
4516 return Err(SurvivalPredictError::IncompatibleSchema {
4517 reason: format!(
4518 "saved survival marginal-slope marginal coefficient mismatch: beta has {} entries but baseline design has {} columns",
4519 beta_marginal.len(),
4520 cov_design.ncols()
4521 ),
4522 });
4523 }
4524 if beta_logslope.len() != logslope_design.ncols() {
4525 return Err(SurvivalPredictError::IncompatibleSchema {
4526 reason: format!(
4527 "saved survival marginal-slope slope coefficient mismatch: beta has {} entries but slope design has {} columns",
4528 beta_logslope.len(),
4529 logslope_design.ncols()
4530 ),
4531 });
4532 }
4533
4534 let p_time_base = time_build.x_exit_time.ncols();
4535 let saved_timewiggle = saved_runtime.baseline_time_wiggle;
4536 let p_timewiggle = saved_timewiggle
4537 .as_ref()
4538 .map_or(0, |runtime| runtime.beta.len());
4539 if beta_time.len() != p_time_base + p_timewiggle {
4540 let hint = stale_weibull_time_basis_hint(
4541 &time_build.basisname,
4542 beta_time.len() == p_time_base + p_timewiggle + 1,
4543 );
4544 return Err(SurvivalPredictError::IncompatibleSchema {
4545 reason: format!(
4546 "saved survival marginal-slope time coefficient mismatch: beta has {} entries but expected base={} plus timewiggle={}{hint}",
4547 beta_time.len(),
4548 p_time_base,
4549 p_timewiggle
4550 ),
4551 });
4552 }
4553
4554 let beta_time_base = beta_time.slice(s![..p_time_base]).to_owned();
4555 let cov_eta_marginal = cov_design.dot(beta_marginal);
4559 let q_entry_base = time_build.x_entry_time.dot(&beta_time_base)
4560 + &cov_eta_marginal
4561 + eta_offset_entry
4562 + primary_offset;
4563 let q_exit_base = time_build.x_exit_time.dot(&beta_time_base)
4564 + &cov_eta_marginal
4565 + eta_offset_exit
4566 + primary_offset;
4567 let qd_exit_base = time_build.x_derivative_time.dot(&beta_time_base) + derivative_offset_exit;
4568
4569 let mut q_design_parts = vec![time_build.x_exit_time.clone()];
4570 if saved_timewiggle.is_some() {
4571 let (_, exit_w, _) = saved_baseline_timewiggle_components(
4572 &q_entry_base,
4573 &q_exit_base,
4574 &qd_exit_base,
4575 model,
4576 )?
4577 .ok_or_else(|| {
4578 "saved survival marginal-slope model is missing baseline-timewiggle runtime metadata"
4579 .to_string()
4580 })?;
4581 if exit_w.ncols() != p_timewiggle {
4582 return Err(SurvivalPredictError::IncompatibleSchema {
4583 reason: format!(
4584 "saved survival marginal-slope timewiggle design mismatch: rebuilt {} columns but runtime expects {}",
4585 exit_w.ncols(),
4586 p_timewiggle
4587 ),
4588 });
4589 }
4590 q_design_parts.push(DesignMatrix::from(exit_w));
4591 }
4592 q_design_parts.push(cov_design.clone());
4593 let q_design = DesignMatrix::hstack(q_design_parts)?;
4594
4595 let combined_q_beta = concat_array1_refs(&[beta_time, beta_marginal]);
4596 let combined_q_lambdas = concat_array1_refs(&[&blocks[0].lambdas, &blocks[1].lambdas]);
4597 let mut predictor_blocks = Vec::with_capacity(
4598 2 + usize::from(saved_score_runtime.is_some()) + usize::from(saved_link_runtime.is_some()),
4599 );
4600 predictor_blocks.push(FittedBlock {
4601 beta: combined_q_beta.clone(),
4602 role: BlockRole::Mean,
4603 edf: blocks[0].edf + blocks[1].edf,
4604 lambdas: combined_q_lambdas,
4605 });
4606 predictor_blocks.push(FittedBlock {
4607 beta: beta_logslope.clone(),
4608 role: BlockRole::Scale,
4609 edf: blocks[2].edf,
4610 lambdas: blocks[2].lambdas.clone(),
4611 });
4612 if saved_score_runtime.is_some() {
4613 let mut block = blocks[3].clone();
4614 block.role = BlockRole::Mean;
4615 predictor_blocks.push(block);
4616 }
4617 if saved_link_runtime.is_some() {
4618 let idx = 3 + usize::from(saved_score_runtime.is_some());
4619 let mut block = blocks[idx].clone();
4620 block.role = BlockRole::LinkWiggle;
4621 predictor_blocks.push(block);
4622 }
4623
4624 let mut predictor_fit = fit_saved.clone();
4625 predictor_fit.blocks = predictor_blocks;
4626 predictor_fit.beta = concat_array1_refs(
4627 &predictor_fit
4628 .blocks
4629 .iter()
4630 .map(|block| &block.beta)
4631 .collect::<Vec<_>>(),
4632 );
4633 predictor_fit.block_states.clear();
4634
4635 let predictor = BernoulliMarginalSlopePredictor::from_unified(
4636 &predictor_fit,
4637 z_name.to_string(),
4638 model.latent_z_normalization.ok_or_else(|| {
4639 "saved survival marginal-slope model missing latent_z_normalization".to_string()
4640 })?,
4641 model.latent_measure.clone().ok_or_else(|| {
4642 "saved survival marginal-slope model missing latent_measure".to_string()
4643 })?,
4644 0.0,
4645 model.logslope_baseline.ok_or_else(|| {
4646 "saved survival marginal-slope model missing logslope_baseline".to_string()
4647 })?,
4648 model
4649 .resolved_inverse_link()?
4650 .unwrap_or(InverseLink::Standard(StandardLink::Probit)),
4651 model
4652 .family_state
4653 .frailty()
4654 .cloned()
4655 .unwrap_or(FrailtySpec::None),
4656 saved_score_runtime,
4657 saved_link_runtime,
4658 model.latent_z_rank_int_calibration.clone(),
4659 model.latent_z_conditional_calibration.clone(),
4662 )?;
4663
4664 let pred_input = PredictInput {
4665 design: q_design,
4666 offset: eta_offset_exit + primary_offset,
4667 design_noise: Some(logslope_design.clone()),
4668 offset_noise: Some(noise_offset.clone()),
4669 auxiliary_scalar: Some(z.clone()),
4670 auxiliary_matrix: None,
4671 };
4672
4673 Ok((predictor, pred_input, predictor_fit))
4674}
4675
4676fn stale_weibull_time_basis_hint(basisname: &str, extra_time_coefficient: bool) -> &'static str {
4683 if basisname == "linear" && extra_time_coefficient {
4684 " (this looks like a model saved before the #2301 Weibull time-basis \
4685 change, which removed the redundant constant column; refit the model)"
4686 } else {
4687 ""
4688 }
4689}
4690
4691#[cfg(test)]
4692mod tests {
4693 use super::*;
4694 use crate::probability::{normal_cdf, normal_pdf};
4695
4696 #[test]
4697 fn competing_risks_covariance_mode_selects_exact_requested_matrix() {
4698 let conditional = ndarray::array![[1.0, 0.2], [0.2, 2.0]];
4699 let corrected = ndarray::array![[1.5, 0.4], [0.4, 3.0]];
4700
4701 let selected_conditional = select_survival_prediction_covariance(
4702 Some(&conditional),
4703 Some(&corrected),
4704 SurvivalPredictionCovarianceMode::Conditional,
4705 )
4706 .expect("conditional covariance");
4707 let selected_corrected = select_survival_prediction_covariance(
4708 Some(&conditional),
4709 Some(&corrected),
4710 SurvivalPredictionCovarianceMode::SmoothingCorrected,
4711 )
4712 .expect("smoothing-corrected covariance");
4713
4714 assert_eq!(selected_conditional, &conditional);
4715 assert_eq!(selected_corrected, &corrected);
4716 assert_eq!(
4717 SurvivalPredictionCovarianceMode::Conditional.as_str(),
4718 "conditional"
4719 );
4720 assert_eq!(
4721 SurvivalPredictionCovarianceMode::SmoothingCorrected.as_str(),
4722 "smoothing-corrected"
4723 );
4724 }
4725
4726 #[test]
4727 fn competing_risks_smoothing_covariance_never_falls_back() {
4728 let conditional = ndarray::array![[1.0]];
4729 let error = select_survival_prediction_covariance(
4730 Some(&conditional),
4731 None,
4732 SurvivalPredictionCovarianceMode::SmoothingCorrected,
4733 )
4734 .expect_err("a corrected request must not substitute conditional covariance");
4735 assert_eq!(
4736 error.to_string(),
4737 "fit result does not contain smoothing-corrected covariance"
4738 );
4739 }
4740
4741 #[test]
4742 fn posterior_quadrature_second_moment_honors_cross_coordinate_covariance() {
4743 let posterior_mean = ndarray::array![0.4, -0.2];
4744 let covariance = ndarray::array![[0.9, 0.35], [0.35, 0.6]];
4745 let mut functional_mean = 0.0_f64;
4746 let mut functional_second = 0.0_f64;
4747 let mut recovered_cross_covariance = 0.0_f64;
4748
4749 for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
4750 let functional = node[0] + 2.0 * node[1];
4751 functional_mean += weight * functional;
4752 functional_second += weight * functional * functional;
4753 recovered_cross_covariance +=
4754 weight * (node[0] - posterior_mean[0]) * (node[1] - posterior_mean[1]);
4755 Ok(())
4756 })
4757 .expect("joint posterior quadrature");
4758
4759 let expected_mean = posterior_mean[0] + 2.0 * posterior_mean[1];
4760 let expected_variance =
4761 covariance[[0, 0]] + 4.0 * covariance[[1, 1]] + 4.0 * covariance[[0, 1]];
4762 assert!((functional_mean - expected_mean).abs() <= 1e-12);
4763 assert!((recovered_cross_covariance - covariance[[0, 1]]).abs() <= 1e-12);
4764
4765 let mean_surface = Array2::from_elem((1, 1), functional_mean);
4766 let second_surface = Array2::from_elem((1, 1), functional_second);
4767 let standard_error = posterior_standard_error_matrix(
4768 &mean_surface,
4769 &second_surface,
4770 "joint-covariance witness",
4771 )
4772 .expect("posterior standard error");
4773 assert!((standard_error[[0, 0]].powi(2) - expected_variance).abs() <= 1e-11);
4774 }
4775
4776 #[test]
4777 fn posterior_quadrature_zero_covariance_has_zero_standard_error() {
4778 let posterior_mean = ndarray::array![0.25, -0.75];
4779 let covariance = Array2::<f64>::zeros((2, 2));
4780 let mut functional_mean = 0.0_f64;
4781 let mut functional_second = 0.0_f64;
4782 let mut node_count = 0usize;
4783
4784 for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
4785 let functional = node[0].exp() + node[1].sin();
4786 functional_mean += weight * functional;
4787 functional_second += weight * functional * functional;
4788 node_count += 1;
4789 Ok(())
4790 })
4791 .expect("rank-zero posterior quadrature");
4792
4793 assert_eq!(node_count, 1, "rank-zero covariance has one exact node");
4794 let standard_error = posterior_standard_error_matrix(
4795 &Array2::from_elem((1, 1), functional_mean),
4796 &Array2::from_elem((1, 1), functional_second),
4797 "rank-zero witness",
4798 )
4799 .expect("rank-zero posterior standard error");
4800 assert_eq!(standard_error[[0, 0]], 0.0);
4801 }
4802
4803 #[test]
4804 fn posterior_quadrature_keeps_cone_coordinates_feasible_and_unbiased() {
4805 let posterior_mean = ndarray::array![0.354, -8.30];
4812 let covariance = ndarray::array![[0.2304, 0.30], [0.30, 0.9604]];
4813
4814 let mut min_cone0_unconstrained = f64::INFINITY;
4816 for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, _weight| {
4817 min_cone0_unconstrained = min_cone0_unconstrained.min(node[0]);
4818 Ok(())
4819 })
4820 .expect("unconstrained quadrature");
4821 assert!(
4822 min_cone0_unconstrained < 0.0,
4823 "fixture must reproduce the infeasible-node bug (min β_0 = {min_cone0_unconstrained})"
4824 );
4825
4826 let mut mean0 = 0.0_f64;
4829 let mut mean1 = 0.0_f64;
4830 let mut weight_sum = 0.0_f64;
4831 let mut min_cone0 = f64::INFINITY;
4832 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
4833 assert!(
4834 node[0] >= -1e-12,
4835 "cone coordinate stepped below its β_0 ≥ 0 wall: {}",
4836 node[0]
4837 );
4838 min_cone0 = min_cone0.min(node[0]);
4839 mean0 += weight * node[0];
4840 mean1 += weight * node[1];
4841 weight_sum += weight;
4842 Ok(())
4843 })
4844 .expect("cone-truncated quadrature");
4845 assert!((weight_sum - 1.0).abs() <= 1e-12, "weights must sum to one");
4846 assert!(
4847 (mean0 - posterior_mean[0]).abs() <= 1e-12
4848 && (mean1 - posterior_mean[1]).abs() <= 1e-12,
4849 "cone truncation must leave the posterior mean unbiased (got [{mean0}, {mean1}])"
4850 );
4851
4852 let mut var0_unconstrained = 0.0_f64;
4855 for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
4856 var0_unconstrained += weight * (node[0] - posterior_mean[0]).powi(2);
4857 Ok(())
4858 })
4859 .expect("unconstrained spread");
4860 let mut var0_cone = 0.0_f64;
4861 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
4862 var0_cone += weight * (node[0] - posterior_mean[0]).powi(2);
4863 Ok(())
4864 })
4865 .expect("cone spread");
4866 assert!(
4867 var0_cone <= var0_unconstrained + 1e-12 && var0_cone < var0_unconstrained,
4868 "cone spread {var0_cone} must be strictly smaller than the untruncated {var0_unconstrained}"
4869 );
4870 }
4871
4872 #[test]
4873 fn posterior_quadrature_cone_is_a_noop_far_from_the_wall() {
4874 let posterior_mean = ndarray::array![40.0, -0.2];
4879 let covariance = ndarray::array![[0.9, 0.35], [0.35, 0.6]];
4880 let mut recovered_var0 = 0.0_f64;
4881 let mut recovered_cross = 0.0_f64;
4882 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
4883 recovered_var0 += weight * (node[0] - posterior_mean[0]).powi(2);
4884 recovered_cross +=
4885 weight * (node[0] - posterior_mean[0]) * (node[1] - posterior_mean[1]);
4886 Ok(())
4887 })
4888 .expect("cone quadrature far from the wall");
4889 assert!((recovered_var0 - covariance[[0, 0]]).abs() <= 1e-11);
4890 assert!((recovered_cross - covariance[[0, 1]]).abs() <= 1e-11);
4891 }
4892
4893 #[test]
4907 fn posterior_quadrature_radius_collapses_on_an_active_bound() {
4908 let posterior_mean = ndarray::array![0.0, 0.75];
4911 let covariance = ndarray::array![[0.5, 0.0], [0.0, 0.2]];
4912
4913 let mut min_pinned = f64::INFINITY;
4914 let mut max_pinned = f64::NEG_INFINITY;
4915 let mut spread_unpinned = 0.0_f64;
4916 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
4917 min_pinned = min_pinned.min(node[0]);
4918 max_pinned = max_pinned.max(node[0]);
4919 spread_unpinned += weight * (node[1] - posterior_mean[1]).powi(2);
4920 Ok(())
4921 })
4922 .expect("active-bound quadrature");
4923
4924 assert!(
4925 min_pinned >= 0.0,
4926 "an active bound must never be crossed, got {min_pinned}"
4927 );
4928 assert!(
4929 max_pinned.abs() <= 1e-12,
4930 "a direction loading an active-bound coordinate carries zero symmetric spread, \
4931 but the coordinate reached {max_pinned}"
4932 );
4933 assert!(
4936 (spread_unpinned - covariance[[1, 1]]).abs() <= 1e-11,
4937 "a coordinate outside the cone keeps its full spread, got {spread_unpinned} want {}",
4938 covariance[[1, 1]]
4939 );
4940 }
4941
4942 #[test]
4950 fn posterior_quadrature_clamps_a_roundoff_negative_cone_coordinate() {
4951 let roundoff_below_wall = -1e-15_f64;
4952 let posterior_mean = ndarray::array![roundoff_below_wall, 0.75];
4953 let covariance = ndarray::array![[0.5, 0.0], [0.0, 0.2]];
4954
4955 let mut nodes = Vec::new();
4956 for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, _weight| {
4957 nodes.push(node[0]);
4958 Ok(())
4959 })
4960 .expect("round-off-negative cone quadrature");
4961
4962 for value in &nodes {
4963 assert!(
4964 *value >= roundoff_below_wall,
4965 "truncation must never push a cone coordinate further below the wall than the \
4966 fit left it: node {value} < β̂ {roundoff_below_wall}"
4967 );
4968 assert!(
4969 (*value - roundoff_below_wall).abs() <= 1e-12,
4970 "a coordinate at the wall carries no spread, got {value}"
4971 );
4972 }
4973 }
4974
4975 #[test]
4976 fn probit_survival_hazard_uses_density_over_survival() {
4977 let eta = 2.0;
4978 let eta_t = 0.3;
4979
4980 let (cum, hazard) =
4981 probit_survival_hazard_components(eta, eta_t).expect("valid components");
4982
4983 let survival = normal_cdf(-eta);
4984 let expected_cum = -survival.ln();
4985 let expected_hazard = normal_pdf(eta) * eta_t / survival;
4986 assert!((cum - expected_cum).abs() <= 1e-14);
4987 assert!((hazard - expected_hazard).abs() <= 1e-14);
4988 }
4989
4990 #[test]
4991 fn probit_survival_hazard_stays_finite_in_right_tail() {
4992 let eta = 40.0;
4993 let eta_t = 9.694_340_360_912_401e-5;
4994
4995 let event_density =
4996 (-0.5_f64 * eta * eta).exp() / (2.0 * std::f64::consts::PI).sqrt() * eta_t;
4997 assert_eq!(event_density, 0.0);
4998
4999 let (cum, hazard) =
5000 probit_survival_hazard_components(eta, eta_t).expect("valid tail components");
5001 assert!(cum > 800.0, "right-tail cumulative hazard was {cum}");
5002 assert!(
5003 (3.87e-3..3.89e-3).contains(&hazard),
5004 "right-tail hazard was {hazard}"
5005 );
5006 }
5007
5008 #[test]
5009 fn probit_survival_hazard_accepts_zero_time_derivative_as_flat_hazard() {
5010 let (cum, hazard) =
5011 probit_survival_hazard_components(1.0, 0.0).expect("zero derivative is flat hazard");
5012 assert!(cum > 0.0);
5013 assert_eq!(hazard, 0.0);
5014 }
5015
5016 #[test]
5017 fn marginal_slope_index_derivative_clamps_extrapolation_negative_to_flat_hazard() {
5018 let deta_dq = (1.0_f64 + 0.4 * 0.4).sqrt(); let qd_with_wiggle = -1.35e-3;
5026 let eta_t = marginal_slope_index_derivative_at_horizon(deta_dq, qd_with_wiggle);
5027 assert_eq!(
5028 eta_t, 0.0,
5029 "negative extrapolation derivative must clamp to 0"
5030 );
5031 let (cum, hazard) = probit_survival_hazard_components(-0.563, eta_t)
5033 .expect("clamped flat-hazard prediction must validate");
5034 assert!(
5035 cum >= 0.0,
5036 "cumulative hazard must be well-posed, got {cum}"
5037 );
5038 assert_eq!(
5039 hazard, 0.0,
5040 "clamped derivative gives zero instantaneous hazard"
5041 );
5042 }
5043
5044 #[test]
5045 fn marginal_slope_index_derivative_preserves_positive_and_nonfinite() {
5046 let positive = marginal_slope_index_derivative_at_horizon(1.25, 0.8);
5050 assert!(
5051 (positive - 1.0).abs() <= 1e-15,
5052 "positive derivative scaled by chain factor"
5053 );
5054 let nonfinite = marginal_slope_index_derivative_at_horizon(1.25, f64::NAN);
5055 assert!(
5056 nonfinite.is_nan(),
5057 "non-finite derivative passes through unclamped"
5058 );
5059 assert!(
5060 probit_survival_hazard_components(0.5, nonfinite).is_err(),
5061 "non-finite derivative must still be rejected by the validator"
5062 );
5063 }
5064
5065 #[test]
5066 fn probit_survival_hazard_rejects_infinite_time_derivative() {
5067 let err = probit_survival_hazard_components(1.0, f64::INFINITY)
5068 .expect_err("infinite derivative should be invalid");
5069 assert!(
5070 err.to_string()
5071 .contains("invalid survival index derivative")
5072 );
5073 }
5074
5075 #[test]
5076 fn probit_survival_hazard_rejects_nan_inputs() {
5077 let err_eta =
5083 probit_survival_hazard_components(f64::NAN, 0.5).expect_err("NaN eta must be rejected");
5084 assert!(
5085 err_eta
5086 .to_string()
5087 .contains("invalid survival index derivative")
5088 );
5089 let err_dt = probit_survival_hazard_components(1.0, f64::NAN)
5090 .expect_err("NaN eta_derivative must be rejected");
5091 assert!(
5092 err_dt
5093 .to_string()
5094 .contains("invalid survival index derivative")
5095 );
5096 }
5097
5098 #[test]
5099 fn probit_survival_hazard_rejects_negative_time_derivative() {
5100 let err = probit_survival_hazard_components(1.0, -0.5)
5104 .expect_err("negative derivative should be invalid");
5105 assert!(
5106 err.to_string()
5107 .contains("invalid survival index derivative")
5108 );
5109 }
5110
5111 #[test]
5112 fn royston_parmar_hazard_is_cumulative_hazard_derivative() {
5113 let eta = 2.0_f64.ln();
5114 let eta_t = 0.25;
5115
5116 let (cum, hazard) =
5117 royston_parmar_survival_hazard_components(eta, eta_t).expect("valid components");
5118
5119 assert!((cum - 2.0).abs() <= 1e-14);
5120 assert!((hazard - 0.5).abs() <= 1e-14);
5121 assert_ne!(hazard, cum);
5122 }
5123
5124 #[test]
5125 fn royston_parmar_hazard_rejects_negative_log_hazard_derivative() {
5126 let err = royston_parmar_survival_hazard_components(0.0, -0.5)
5130 .expect_err("negative derivative should be invalid");
5131 assert!(
5132 err.to_string()
5133 .contains("invalid log-cumulative-hazard derivative")
5134 );
5135 }
5136
5137 #[test]
5138 fn royston_parmar_hazard_accepts_zero_derivative_as_flat_boundary() {
5139 let eta = 1.9909019457445971_f64; let (cum, hazard) = royston_parmar_survival_hazard_components(eta, 0.0)
5146 .expect("zero derivative is a valid flat boundary, not an error");
5147 assert!((cum - eta.exp()).abs() <= 1e-12, "cum = Λ(t) = exp(η)");
5148 assert_eq!(
5149 hazard, 0.0,
5150 "flat cumulative hazard ⇒ zero instantaneous hazard"
5151 );
5152 let survival = (-cum).exp().clamp(0.0, 1.0);
5154 assert!(survival.is_finite() && (0.0..=1.0).contains(&survival));
5155 }
5156
5157 #[test]
5158 fn royston_parmar_hazard_zero_derivative_in_saturated_tail_is_zero_not_nan() {
5159 let eta = 1000.0_f64;
5165 assert!(
5166 eta.exp().is_infinite(),
5167 "test premise: exp(1000) overflows to +∞"
5168 );
5169 assert!(
5170 (f64::INFINITY * 0.0).is_nan(),
5171 "test premise: the naive product is NaN"
5172 );
5173 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, 0.0)
5174 .expect("saturated + flat boundary must be valid");
5175 assert!(cum.is_infinite() && cum > 0.0, "cum saturates to +∞");
5176 assert_eq!(hazard, 0.0, "hazard at a flat boundary is 0, never NaN");
5177 }
5178
5179 #[test]
5180 fn royston_parmar_hazard_propagates_saturation_as_infinity() {
5181 let eta = 1000.0_f64;
5186 let eta_t = 0.5_f64;
5187 assert!(eta.exp().is_infinite(), "test premise: exp(1000) overflows");
5188
5189 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, eta_t)
5190 .expect("saturated RP fit must yield a result, not an error");
5191 assert!(cum.is_infinite() && cum > 0.0, "expected +∞ cum, got {cum}");
5192 assert!(
5193 hazard.is_infinite() && hazard > 0.0,
5194 "expected +∞ hazard, got {hazard}"
5195 );
5196
5197 let survival = (-cum).exp().clamp(0.0, 1.0);
5199 assert_eq!(survival, 0.0, "saturated cum_hazard must give survival 0");
5200 }
5201
5202 #[test]
5203 fn royston_parmar_hazard_rejects_nan_eta() {
5204 let err = royston_parmar_survival_hazard_components(f64::NAN, 0.5)
5205 .expect_err("NaN eta should be invalid");
5206 assert!(
5207 err.to_string()
5208 .contains("invalid log-cumulative-hazard derivative")
5209 );
5210 }
5211
5212 #[test]
5213 fn royston_parmar_hazard_left_tail_collapses_to_zero() {
5214 let eta = -1000.0_f64;
5217 let eta_t = 2.0_f64;
5218 assert_eq!(eta.exp(), 0.0, "test premise: exp(-1000) underflows to 0");
5219
5220 let (cum, hazard) = royston_parmar_survival_hazard_components(eta, eta_t)
5221 .expect("RP left tail must remain valid");
5222 assert_eq!(
5223 cum, 0.0,
5224 "left-tail cum_hazard should underflow to 0, got {cum}"
5225 );
5226 assert_eq!(
5227 hazard, 0.0,
5228 "left-tail hazard should underflow to 0, got {hazard}"
5229 );
5230
5231 let survival = (-cum).exp().clamp(0.0, 1.0);
5233 assert_eq!(survival, 1.0);
5234 }
5235
5236 #[test]
5237 fn probit_survival_hazard_left_tail_collapses_to_zero() {
5238 let eta = -40.0_f64;
5242 let eta_t = 1.5_f64;
5243
5244 let (cum, hazard) =
5245 probit_survival_hazard_components(eta, eta_t).expect("left tail must remain valid");
5246 assert!(
5247 (0.0..1e-300).contains(&cum),
5248 "left-tail cum should be ~0, got {cum}"
5249 );
5250 assert_eq!(
5251 hazard, 0.0,
5252 "left-tail hazard should underflow to 0, got {hazard}"
5253 );
5254 }
5255
5256 #[test]
5257 fn location_scale_logit_hazard_is_failure_slope_over_survival() {
5258 let eta = 0.7;
5259 let eta_t = 0.4;
5260
5261 let hazard = location_scale_hazard_component(
5262 eta,
5263 eta_t,
5264 &InverseLink::Standard(StandardLink::Logit),
5265 )
5266 .expect("valid logit hazard");
5267
5268 let failure = 1.0 / (1.0 + (-eta).exp());
5269 assert!((hazard - failure * eta_t).abs() <= 1e-14);
5270 }
5271
5272 #[test]
5273 fn location_scale_cloglog_hazard_matches_log_cumulative_hazard_derivative() {
5274 let eta = 1.5;
5275 let eta_t = 0.2;
5276
5277 let hazard = location_scale_hazard_component(
5278 eta,
5279 eta_t,
5280 &InverseLink::Standard(StandardLink::CLogLog),
5281 )
5282 .expect("valid cloglog hazard");
5283
5284 assert!((hazard - eta.exp() * eta_t).abs() <= 1e-14);
5285 }
5286
5287 #[test]
5290 fn kaplan_meier_censoring_is_right_continuous_step() {
5291 let time = [2.0, 4.0, 6.0, 8.0];
5293 let event = [1.0, 0.0, 1.0, 0.0];
5294 let g = KaplanMeier::fit_censoring(&time, &event);
5295 assert!((g.at(0.0) - 1.0).abs() <= 1e-15);
5297 assert!((g.at(2.0) - 1.0).abs() <= 1e-15);
5298 assert!((g.at(3.999) - 1.0).abs() <= 1e-15);
5299 assert!((g.at(4.0) - 2.0 / 3.0).abs() <= 1e-12);
5301 assert!((g.at(5.0) - 2.0 / 3.0).abs() <= 1e-12);
5302 assert!((g.at(6.0) - 2.0 / 3.0).abs() <= 1e-12);
5304 assert!(g.at(8.0).abs() <= 1e-15);
5306 }
5307
5308 #[test]
5309 fn ipcw_brier_no_censoring_reduces_to_plain_brier() {
5310 let s_pred = [0.3, 0.7, 0.6, 0.2];
5313 let time = [2.0, 8.0, 10.0, 3.0];
5314 let event = [1.0, 1.0, 0.0, 1.0];
5315 let tau = 5.0;
5316 let g = KaplanMeier::fit_censoring(&time, &event);
5317 let bs = ipcw_brier_score(&s_pred, &time, &event, tau, |t| g.at(t)).unwrap();
5318 let expected =
5320 (0.3f64.powi(2) + (1.0 - 0.7f64).powi(2) + (1.0 - 0.6f64).powi(2) + 0.2f64.powi(2))
5321 / 4.0;
5322 assert!(
5323 (bs - expected).abs() <= 1e-12,
5324 "bs={bs} expected={expected}"
5325 );
5326 }
5327
5328 #[test]
5329 fn ipcw_brier_reweights_by_inverse_censoring_probability() {
5330 let s_pred = [0.4, 0.5, 0.7, 0.8];
5334 let time = [2.0, 4.0, 6.0, 8.0];
5335 let event = [1.0, 0.0, 1.0, 0.0];
5336 let tau = 5.0;
5337 let g = KaplanMeier::fit_censoring(&time, &event);
5338 let bs = ipcw_brier_score(&s_pred, &time, &event, tau, |t| g.at(t)).unwrap();
5339 let expected = (0.16 + 0.0 + 0.135 + 0.06) / 4.0;
5344 assert!(
5345 (bs - expected).abs() <= 1e-12,
5346 "bs={bs} expected={expected}"
5347 );
5348 }
5349
5350 #[test]
5351 fn ipcw_brier_drops_invalid_rows_from_both_numerator_and_denominator() {
5352 let s_pred = [0.3, 0.7, 0.5, 0.5];
5354 let time = [2.0, 8.0, f64::NAN, -1.0];
5355 let event = [1.0, 1.0, 1.0, 0.0];
5356 let g = KaplanMeier::fit_censoring(&time, &event);
5357 let bs = ipcw_brier_score(&s_pred, &time, &event, 5.0, |t| g.at(t)).unwrap();
5358 let expected = (0.3f64.powi(2) + (1.0 - 0.7f64).powi(2)) / 2.0;
5361 assert!(
5362 (bs - expected).abs() <= 1e-12,
5363 "bs={bs} expected={expected}"
5364 );
5365 }
5366
5367 #[test]
5368 fn integrated_ipcw_brier_of_constant_brier_is_that_constant() {
5369 let time = [2.0, 8.0, 10.0, 3.0];
5372 let event = [1.0, 1.0, 0.0, 1.0];
5373 let grid = [0.0, 1.0, 2.5, 4.0, 6.0];
5374 let col = [0.3, 0.7, 0.6, 0.2];
5378 let mut surv = Array2::<f64>::zeros((4, grid.len()));
5379 for k in 0..grid.len() {
5380 for i in 0..4 {
5381 surv[[i, k]] = col[i];
5382 }
5383 }
5384 let g = KaplanMeier::fit_censoring(&time, &event);
5385 let per_time = ipcw_brier_score(&col, &time, &event, grid[2], |t| g.at(t)).unwrap();
5386 let mut oracle_pts = Vec::new();
5390 for k in 0..grid.len() {
5391 oracle_pts.push((
5392 grid[k],
5393 ipcw_brier_score(&col, &time, &event, grid[k], |t| g.at(t)).unwrap(),
5394 ));
5395 }
5396 let mut integral = 0.0;
5397 for w in oracle_pts.windows(2) {
5398 integral += 0.5 * (w[0].1 + w[1].1) * (w[1].0 - w[0].0);
5399 }
5400 let oracle = integral / (grid[grid.len() - 1] - grid[0]);
5401 let ibs =
5402 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, f64::INFINITY, |t| {
5403 g.at(t)
5404 })
5405 .unwrap();
5406 assert!((ibs - oracle).abs() <= 1e-12, "ibs={ibs} oracle={oracle}");
5407 assert!(per_time >= 0.0);
5409 }
5410
5411 #[test]
5412 fn integrated_ipcw_brier_respects_the_horizon_cutoff() {
5413 let time = [2.0, 8.0, 10.0, 3.0];
5414 let event = [1.0, 1.0, 0.0, 1.0];
5415 let grid = [0.0, 2.0, 4.0, 100.0];
5416 let col = [0.3, 0.7, 0.6, 0.2];
5417 let mut surv = Array2::<f64>::zeros((4, grid.len()));
5418 for k in 0..grid.len() {
5419 for i in 0..4 {
5420 surv[[i, k]] = col[i];
5421 }
5422 }
5423 let g = KaplanMeier::fit_censoring(&time, &event);
5424 let restricted =
5426 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, 5.0, |t| g.at(t))
5427 .unwrap();
5428 let full =
5429 integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, f64::INFINITY, |t| {
5430 g.at(t)
5431 })
5432 .unwrap();
5433 assert!(
5436 (restricted - full).abs() > 1e-3,
5437 "horizon cutoff had no effect: restricted={restricted} full={full}"
5438 );
5439 }
5440
5441 #[test]
5442 fn integrated_ipcw_brier_rejects_malformed_grids() {
5443 let time = [2.0, 8.0];
5444 let event = [1.0, 0.0];
5445 let surv = Array2::<f64>::from_elem((2, 3), 0.5);
5446 let g = KaplanMeier::fit_censoring(&time, &event);
5447 let bad = [0.0, 2.0, 1.0];
5449 assert!(
5450 integrated_ipcw_brier_score(surv.view(), &time, &event, &bad, f64::INFINITY, |t| g
5451 .at(t))
5452 .is_none()
5453 );
5454 let short = [0.0, 1.0];
5456 assert!(
5457 integrated_ipcw_brier_score(surv.view(), &time, &event, &short, f64::INFINITY, |t| g
5458 .at(t))
5459 .is_none()
5460 );
5461 }
5462}