1use crate::probability::{normal_pdf, standard_normal_quantile};
13use crate::survival::location_scale::{
14 DEFAULT_SURVIVAL_LOCATION_SCALE_DERIVATIVE_GUARD, ResidualDistribution,
15 SurvivalCovariateTermBlockTemplate,
16};
17use crate::survival::lognormal_kernel::HazardLoading;
18use crate::survival::marginal_slope::DEFAULT_SURVIVAL_MARGINAL_SLOPE_DERIVATIVE_GUARD;
19use crate::wiggle::{
20 WiggleBlockConfig, append_selected_wiggle_penalty_orders, buildwiggle_block_input_from_seed,
21 monotone_wiggle_basis_with_derivative_order, split_wiggle_penalty_orders,
22};
23use gam_linalg::matrix::{
24 DenseDesignMatrix, DesignMatrix, SparseDesignMatrix, symmetrize_in_place,
25};
26use gam_problem::outer_subsample::RowSet;
27use gam_problem::{InverseLink, StandardLink};
28use gam_terms::basis::{
29 BSplineBasisSpec, BSplineBoundaryConditions, BSplineIdentifiability, BSplineKnotSpec,
30 BasisMetadata, BasisOptions, Dense, KnotSource, OneDimensionalBoundary, build_bspline_basis_1d,
31 create_basis, evaluate_bspline_derivative_scalar,
32};
33use gam_terms::inference::formula_dsl::LinkWiggleFormulaSpec;
34use ndarray::{Array1, Array2, array, s};
35use rayon::prelude::*;
36
37#[derive(Clone, Debug)]
54pub enum SurvivalConstructionError {
55 InvalidConfig { reason: String },
58 MissingColumn { reason: String },
61 IncompatibleDimensions { reason: String },
64 DataValidationFailed { reason: String },
68 BasisConstructionFailed { reason: String },
72 UnsupportedDistribution { reason: String },
75}
76
77impl_reason_error_boilerplate! {
78 SurvivalConstructionError {
79 InvalidConfig,
80 MissingColumn,
81 IncompatibleDimensions,
82 DataValidationFailed,
83 BasisConstructionFailed,
84 UnsupportedDistribution,
85 }
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum SurvivalBaselineTarget {
94 Linear,
98 Weibull,
103 Gompertz,
108 GompertzMakeham,
113}
114
115#[derive(Clone, Debug)]
116pub struct SurvivalBaselineConfig {
117 pub target: SurvivalBaselineTarget,
118 pub scale: Option<f64>,
119 pub shape: Option<f64>,
120 pub rate: Option<f64>,
121 pub makeham: Option<f64>,
122}
123
124pub fn fitted_weibull_baseline_from_linear_time_beta(
132 beta: &Array1<f64>,
133 anchor: f64,
134) -> Option<SurvivalBaselineConfig> {
135 if beta.len() < 2 {
136 return None;
137 }
138 let shape = beta[1];
139 if !shape.is_finite() || shape <= 0.0 || !anchor.is_finite() || anchor <= 0.0 {
140 return None;
141 }
142 Some(SurvivalBaselineConfig {
143 target: SurvivalBaselineTarget::Weibull,
144 scale: Some(anchor),
145 shape: Some(shape),
146 rate: None,
147 makeham: None,
148 })
149}
150
151#[derive(Clone, Debug)]
152pub enum SurvivalTimeBasisConfig {
153 None,
154 Linear,
155 BSpline {
156 degree: usize,
157 knots: Array1<f64>,
158 smooth_lambda: f64,
159 },
160 ISpline {
198 degree: usize,
199 knots: Array1<f64>,
200 keep_cols: Vec<usize>,
201 smooth_lambda: f64,
202 },
203}
204
205#[derive(Clone, Debug, PartialEq)]
219pub struct SavedSurvivalTimeBasis {
220 pub basisname: String,
221 pub degree: Option<usize>,
222 pub knots: Option<Vec<f64>>,
223 pub keep_cols: Option<Vec<usize>>,
224 pub smooth_lambda: Option<f64>,
225 pub anchor: f64,
226}
227
228impl SavedSurvivalTimeBasis {
229 pub fn from_build(build: &SurvivalTimeBuildOutput, anchor: f64) -> Self {
232 Self {
233 basisname: build.basisname.clone(),
234 degree: build.degree,
235 knots: build.knots.clone(),
236 keep_cols: build.keep_cols.clone(),
237 smooth_lambda: build.smooth_lambda,
238 anchor,
239 }
240 }
241}
242
243#[derive(Clone)]
244pub struct SurvivalTimeBuildOutput {
245 pub x_entry_time: DesignMatrix,
246 pub x_exit_time: DesignMatrix,
247 pub x_derivative_time: DesignMatrix,
248 pub penalties: Vec<Array2<f64>>,
249 pub nullspace_dims: Vec<usize>,
251 pub basisname: String,
252 pub degree: Option<usize>,
253 pub knots: Option<Vec<f64>>,
254 pub keep_cols: Option<Vec<usize>>,
255 pub smooth_lambda: Option<f64>,
256}
257
258pub const SURVIVAL_TIME_FLOOR: f64 = 1e-9;
259
260pub const SURVIVAL_DELAYED_ENTRY_THRESHOLD: f64 = 1e-8;
266
267const SURVIVAL_TIME_SMOOTH_LAMBDA_SEED: f64 = 1e-2;
275
276const GOMPERTZ_DEFAULT_SHAPE_SEED: f64 = 0.01;
284
285#[derive(Clone, Copy, Debug, PartialEq, Eq)]
286pub enum SurvivalLikelihoodMode {
287 Transformation,
288 Weibull,
289 LocationScale,
290 MarginalSlope,
291 Latent,
292 LatentBinary,
293}
294
295pub struct SurvivalTimeWiggleBuild {
296 pub penalties: Vec<Array2<f64>>,
297 pub nullspace_dims: Vec<usize>,
298 pub knots: Array1<f64>,
299 pub degree: usize,
300 pub ncols: usize,
301}
302
303pub fn normalize_survival_time_pair(
308 entry_raw: f64,
309 exit_raw: f64,
310 row_index: usize,
311) -> Result<(f64, f64), String> {
312 if !entry_raw.is_finite() || !exit_raw.is_finite() {
313 return Err(SurvivalConstructionError::DataValidationFailed {
314 reason: format!("non-finite survival times at row {}", row_index + 1),
315 }
316 .into());
317 }
318 if entry_raw < 0.0 || exit_raw < 0.0 {
319 return Err(SurvivalConstructionError::DataValidationFailed {
320 reason: format!("negative survival times at row {}", row_index + 1),
321 }
322 .into());
323 }
324
325 let entry = entry_raw.max(SURVIVAL_TIME_FLOOR);
326 let exit = exit_raw.max(entry + SURVIVAL_TIME_FLOOR);
327 Ok((entry, exit))
328}
329
330pub fn survival_basis_supports_structural_monotonicity(basisname: &str) -> bool {
335 basisname.eq_ignore_ascii_case("ispline")
336}
337
338pub fn require_structural_survival_time_basis(
339 basisname: &str,
340 context: &str,
341) -> Result<(), String> {
342 if survival_basis_supports_structural_monotonicity(basisname) {
343 return Ok(());
344 }
345 Err(SurvivalConstructionError::UnsupportedDistribution {
346 reason: format!(
347 "{context} requires a structural monotone survival time basis, but got '{basisname}'. \
348Only `ispline` is accepted here because its basis functions enforce a monotone cumulative time effect by construction. \
349`{basisname}` can fit non-monotone shapes, which can break survival semantics. \
350Re-run with `--time-basis ispline`."
351 ),
352 }
353 .into())
354}
355
356pub fn parse_survival_baseline_config(
361 target_raw: &str,
362 scale: Option<f64>,
363 shape: Option<f64>,
364 rate: Option<f64>,
365 makeham: Option<f64>,
366) -> Result<SurvivalBaselineConfig, String> {
367 let target = match target_raw.to_ascii_lowercase().as_str() {
368 "linear" => SurvivalBaselineTarget::Linear,
369 "weibull" => SurvivalBaselineTarget::Weibull,
370 "gompertz" => SurvivalBaselineTarget::Gompertz,
371 "gompertz-makeham" => SurvivalBaselineTarget::GompertzMakeham,
372 other => {
373 return Err(SurvivalConstructionError::UnsupportedDistribution {
374 reason: format!(
375 "unsupported --baseline-target '{other}'; use linear|weibull|gompertz|gompertz-makeham"
376 ),
377 }
378 .into());
379 }
380 };
381
382 match target {
383 SurvivalBaselineTarget::Linear => Ok(SurvivalBaselineConfig {
384 target,
385 scale: None,
386 shape: None,
387 rate: None,
388 makeham: None,
389 }),
390 SurvivalBaselineTarget::Weibull => {
391 let scale = scale.ok_or_else(|| {
392 "--baseline-target weibull requires --baseline-scale > 0".to_string()
393 })?;
394 let shape = shape.ok_or_else(|| {
395 "--baseline-target weibull requires --baseline-shape > 0".to_string()
396 })?;
397 if !scale.is_finite() || scale <= 0.0 || !shape.is_finite() || shape <= 0.0 {
398 return Err(
399 "weibull baseline requires finite positive --baseline-scale and --baseline-shape"
400 .to_string(),
401 );
402 }
403 Ok(SurvivalBaselineConfig {
404 target,
405 scale: Some(scale),
406 shape: Some(shape),
407 rate: None,
408 makeham: None,
409 })
410 }
411 SurvivalBaselineTarget::Gompertz => {
412 let rate = rate.unwrap_or(1.0);
413 let shape = shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED);
414 if !rate.is_finite() || rate <= 0.0 || !shape.is_finite() {
415 return Err(
416 "gompertz baseline requires finite --baseline-shape and positive --baseline-rate"
417 .to_string(),
418 );
419 }
420 Ok(SurvivalBaselineConfig {
421 target,
422 scale: None,
423 shape: Some(shape),
424 rate: Some(rate),
425 makeham: None,
426 })
427 }
428 SurvivalBaselineTarget::GompertzMakeham => {
429 let rate = rate.unwrap_or(0.5);
430 let shape = shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED);
431 let makeham = makeham.unwrap_or(0.5);
432 if !rate.is_finite()
433 || rate <= 0.0
434 || !shape.is_finite()
435 || !makeham.is_finite()
436 || makeham <= 0.0
437 {
438 return Err(
439 "gompertz-makeham baseline requires finite --baseline-shape, positive --baseline-rate, and positive --baseline-makeham"
440 .to_string(),
441 );
442 }
443 Ok(SurvivalBaselineConfig {
444 target,
445 scale: None,
446 shape: Some(shape),
447 rate: Some(rate),
448 makeham: Some(makeham),
449 })
450 }
451 }
452}
453
454pub fn parse_survival_likelihood_mode(raw: &str) -> Result<SurvivalLikelihoodMode, String> {
459 match raw.to_ascii_lowercase().as_str() {
460 "transformation" => Ok(SurvivalLikelihoodMode::Transformation),
461 "weibull" => Ok(SurvivalLikelihoodMode::Weibull),
462 "location-scale" => Ok(SurvivalLikelihoodMode::LocationScale),
463 "marginal-slope" => Ok(SurvivalLikelihoodMode::MarginalSlope),
464 "latent" => Ok(SurvivalLikelihoodMode::Latent),
465 "latent-binary" => Ok(SurvivalLikelihoodMode::LatentBinary),
466 other => Err(SurvivalConstructionError::UnsupportedDistribution {
467 reason: format!(
468 "unsupported --survival-likelihood '{other}'; use transformation|weibull|location-scale|marginal-slope|latent|latent-binary"
469 ),
470 }
471 .into()),
472 }
473}
474
475pub const fn survival_likelihood_modename(mode: SurvivalLikelihoodMode) -> &'static str {
476 match mode {
477 SurvivalLikelihoodMode::Transformation => "transformation",
478 SurvivalLikelihoodMode::Weibull => "weibull",
479 SurvivalLikelihoodMode::LocationScale => "location-scale",
480 SurvivalLikelihoodMode::MarginalSlope => "marginal-slope",
481 SurvivalLikelihoodMode::Latent => "latent",
482 SurvivalLikelihoodMode::LatentBinary => "latent-binary",
483 }
484}
485
486pub fn parse_survival_distribution(raw: &str) -> Result<ResidualDistribution, String> {
487 match raw.to_ascii_lowercase().as_str() {
488 "gaussian" | "probit" => Ok(ResidualDistribution::Gaussian),
489 "gumbel" | "cloglog" => Ok(ResidualDistribution::Gumbel),
490 "logistic" | "logit" => Ok(ResidualDistribution::Logistic),
491 other => Err(SurvivalConstructionError::UnsupportedDistribution {
492 reason: format!(
493 "unsupported survmodel(distribution='{other}'); accepted: gaussian / probit, gumbel / cloglog, logistic / logit"
494 ),
495 }
496 .into()),
497 }
498}
499
500pub const fn survival_baseline_targetname(target: SurvivalBaselineTarget) -> &'static str {
501 match target {
502 SurvivalBaselineTarget::Linear => "linear",
503 SurvivalBaselineTarget::Weibull => "weibull",
504 SurvivalBaselineTarget::Gompertz => "gompertz",
505 SurvivalBaselineTarget::GompertzMakeham => "gompertz-makeham",
506 }
507}
508
509pub fn positive_survival_time_seed(age_exit: &Array1<f64>) -> f64 {
510 let sum = age_exit
511 .iter()
512 .copied()
513 .filter(|value| value.is_finite() && *value > 0.0)
514 .sum::<f64>();
515 let count = age_exit
516 .iter()
517 .filter(|value| value.is_finite() && **value > 0.0)
518 .count()
519 .max(1);
520 (sum / count as f64).max(SURVIVAL_TIME_FLOOR)
521}
522
523pub fn initial_survival_baseline_config_for_fit(
524 target_raw: &str,
525 scale: Option<f64>,
526 shape: Option<f64>,
527 rate: Option<f64>,
528 makeham: Option<f64>,
529 age_exit: &Array1<f64>,
530) -> Result<SurvivalBaselineConfig, String> {
531 let target = match target_raw.trim().to_ascii_lowercase().as_str() {
532 "linear" => SurvivalBaselineTarget::Linear,
533 "weibull" => SurvivalBaselineTarget::Weibull,
534 "gompertz" => SurvivalBaselineTarget::Gompertz,
535 "gompertz-makeham" => SurvivalBaselineTarget::GompertzMakeham,
536 other => {
537 return Err(SurvivalConstructionError::UnsupportedDistribution {
538 reason: format!(
539 "unsupported --baseline-target '{other}'; use linear|weibull|gompertz|gompertz-makeham"
540 ),
541 }
542 .into());
543 }
544 };
545 let time_scale_seed = positive_survival_time_seed(age_exit);
546 let cfg = match target {
547 SurvivalBaselineTarget::Linear => SurvivalBaselineConfig {
548 target,
549 scale: None,
550 shape: None,
551 rate: None,
552 makeham: None,
553 },
554 SurvivalBaselineTarget::Weibull => SurvivalBaselineConfig {
555 target,
556 scale: Some(scale.unwrap_or(time_scale_seed)),
557 shape: Some(shape.unwrap_or(1.0)),
558 rate: None,
559 makeham: None,
560 },
561 SurvivalBaselineTarget::Gompertz => SurvivalBaselineConfig {
562 target,
563 scale: None,
564 shape: Some(shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED)),
565 rate: Some(rate.unwrap_or(1.0 / time_scale_seed)),
566 makeham: None,
567 },
568 SurvivalBaselineTarget::GompertzMakeham => SurvivalBaselineConfig {
569 target,
570 scale: None,
571 shape: Some(shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED)),
572 rate: Some(rate.unwrap_or(0.5 / time_scale_seed)),
573 makeham: Some(makeham.unwrap_or(0.5 / time_scale_seed)),
574 },
575 };
576 parse_survival_baseline_config(
577 survival_baseline_targetname(cfg.target),
578 cfg.scale,
579 cfg.shape,
580 cfg.rate,
581 cfg.makeham,
582 )
583}
584
585fn survival_baseline_theta_from_config(
586 cfg: &SurvivalBaselineConfig,
587) -> Result<Option<Array1<f64>>, String> {
588 Ok(match cfg.target {
589 SurvivalBaselineTarget::Linear => None,
590 SurvivalBaselineTarget::Weibull => Some(array![
591 cfg.scale
592 .ok_or_else(|| "missing weibull baseline scale".to_string())?
593 .ln(),
594 cfg.shape
595 .ok_or_else(|| "missing weibull baseline shape".to_string())?
596 .ln(),
597 ]),
598 SurvivalBaselineTarget::Gompertz => Some(array![
599 cfg.rate
600 .ok_or_else(|| "missing gompertz baseline rate".to_string())?
601 .ln(),
602 cfg.shape
603 .ok_or_else(|| "missing gompertz baseline shape".to_string())?,
604 ]),
605 SurvivalBaselineTarget::GompertzMakeham => Some(array![
606 cfg.rate
607 .ok_or_else(|| "missing gompertz-makeham baseline rate".to_string())?
608 .ln(),
609 cfg.shape
610 .ok_or_else(|| "missing gompertz-makeham baseline shape".to_string())?,
611 cfg.makeham
612 .ok_or_else(|| "missing gompertz-makeham baseline makeham".to_string())?
613 .ln(),
614 ]),
615 })
616}
617
618fn survival_baseline_config_from_theta(
619 target: SurvivalBaselineTarget,
620 theta: &Array1<f64>,
621) -> Result<SurvivalBaselineConfig, String> {
622 let cfg = match target {
623 SurvivalBaselineTarget::Linear => SurvivalBaselineConfig {
624 target,
625 scale: None,
626 shape: None,
627 rate: None,
628 makeham: None,
629 },
630 SurvivalBaselineTarget::Weibull => {
631 if theta.len() != 2 {
632 return Err(SurvivalConstructionError::IncompatibleDimensions {
633 reason: format!(
634 "weibull baseline parameter dimension mismatch: expected 2, got {}",
635 theta.len()
636 ),
637 }
638 .into());
639 }
640 SurvivalBaselineConfig {
641 target,
642 scale: Some(theta[0].exp()),
643 shape: Some(theta[1].exp()),
644 rate: None,
645 makeham: None,
646 }
647 }
648 SurvivalBaselineTarget::Gompertz => {
649 if theta.len() != 2 {
650 return Err(SurvivalConstructionError::IncompatibleDimensions {
651 reason: format!(
652 "gompertz baseline parameter dimension mismatch: expected 2, got {}",
653 theta.len()
654 ),
655 }
656 .into());
657 }
658 SurvivalBaselineConfig {
659 target,
660 scale: None,
661 shape: Some(theta[1]),
662 rate: Some(theta[0].exp()),
663 makeham: None,
664 }
665 }
666 SurvivalBaselineTarget::GompertzMakeham => {
667 if theta.len() != 3 {
668 return Err(SurvivalConstructionError::IncompatibleDimensions {
669 reason: format!(
670 "gompertz-makeham baseline parameter dimension mismatch: expected 3, got {}",
671 theta.len()
672 ),
673 }
674 .into());
675 }
676 SurvivalBaselineConfig {
677 target,
678 scale: None,
679 shape: Some(theta[1]),
680 rate: Some(theta[0].exp()),
681 makeham: Some(theta[2].exp()),
682 }
683 }
684 };
685 parse_survival_baseline_config(
686 survival_baseline_targetname(cfg.target),
687 cfg.scale,
688 cfg.shape,
689 cfg.rate,
690 cfg.makeham,
691 )
692}
693
694#[derive(Clone, Copy, Debug, PartialEq, Eq)]
707enum BaselineDerivativeContract {
708 GradientOnly,
711 GradientHessian,
715}
716
717impl BaselineDerivativeContract {
718 fn configure(
723 self,
724 problem: gam_solve::rho_optimizer::OuterProblem,
725 ) -> gam_solve::rho_optimizer::OuterProblem {
726 use gam_problem::{DeclaredHessianForm, Derivative};
727 match self {
728 BaselineDerivativeContract::GradientOnly => problem
731 .with_gradient(Derivative::Analytic)
732 .with_hessian(DeclaredHessianForm::Unavailable)
733 .with_tolerance(1e-4)
734 .with_max_iter(240),
735 BaselineDerivativeContract::GradientHessian => problem
736 .with_gradient(Derivative::Analytic)
737 .with_hessian(DeclaredHessianForm::Either)
738 .with_tolerance(1e-4)
739 .with_max_iter(240),
740 }
741 }
742}
743
744fn run_baseline_theta_optimizer<Fc, Fe>(
755 initial: &SurvivalBaselineConfig,
756 context: &str,
757 contract: BaselineDerivativeContract,
758 cost_fn: Fc,
759 eval_fn: Fe,
760) -> Result<SurvivalBaselineConfig, String>
761where
762 Fc: FnMut(&mut (), &Array1<f64>) -> Result<f64, crate::model_types::EstimationError>,
763 Fe: FnMut(
764 &mut (),
765 &Array1<f64>,
766 ) -> Result<gam_problem::OuterEval, crate::model_types::EstimationError>,
767{
768 use gam_solve::rho_optimizer::OuterProblem;
769 let Some(seed) = survival_baseline_theta_from_config(initial)? else {
770 return Ok(initial.clone());
771 };
772 let dim = seed.len();
773 let target = initial.target;
774 let lower = seed.mapv(|v| v - 6.0);
775 let upper = seed.mapv(|v| v + 6.0);
776 let problem = contract
777 .configure(OuterProblem::new(dim))
778 .with_bounds(lower, upper)
779 .with_initial_rho(seed.clone())
780 .with_seed_config(crate::seeding::SeedConfig {
781 max_seeds: 1,
782 seed_budget: 1,
783 num_auxiliary_trailing: dim,
784 ..Default::default()
785 });
786 let mut obj = problem.build_objective(
787 (),
788 cost_fn,
789 eval_fn,
790 None::<fn(&mut ())>,
791 None::<
792 fn(
793 &mut (),
794 &Array1<f64>,
795 ) -> Result<gam_problem::EfsEval, crate::model_types::EstimationError>,
796 >,
797 );
798 let result = problem
799 .run(&mut obj, context)
800 .map_err(|e| format!("{context} failed: {e}"))?;
801 if !result.converged {
802 return Err(SurvivalConstructionError::InvalidConfig {
803 reason: format!(
804 "{context} did not converge after {} iterations (final_objective={:.6e}, final_grad_norm={})",
805 result.iterations,
806 result.final_value,
807 result.final_grad_norm_report(),
808 ),
809 }
810 .into());
811 }
812 survival_baseline_config_from_theta(target, &result.rho)
813}
814
815fn run_baseline_theta_optimizer_with_eval<F>(
828 initial: &SurvivalBaselineConfig,
829 context: &str,
830 contract: BaselineDerivativeContract,
831 objective: F,
832) -> Result<SurvivalBaselineConfig, String>
833where
834 F: FnMut(&SurvivalBaselineConfig) -> Result<gam_problem::OuterEval, String>,
835{
836 let target = initial.target;
837 let engine_context = context.to_string();
838 let objective = std::rc::Rc::new(std::cell::RefCell::new(objective));
839 let eval_at = move |obj: &std::rc::Rc<std::cell::RefCell<F>>,
840 theta: &Array1<f64>|
841 -> Result<gam_problem::OuterEval, crate::model_types::EstimationError> {
842 let cfg = survival_baseline_config_from_theta(target, theta)
843 .map_err(crate::model_types::EstimationError::InvalidInput)?;
844 let eval =
845 obj.borrow_mut()(&cfg).map_err(crate::model_types::EstimationError::InvalidInput)?;
846 if eval.gradient.len() != theta.len() {
847 return Err(crate::model_types::EstimationError::InvalidInput(format!(
848 "{engine_context}: baseline gradient dimension mismatch: got {}, expected {}",
849 eval.gradient.len(),
850 theta.len()
851 )));
852 }
853 if let gam_problem::HessianValue::Dense(ref h) = eval.hessian {
854 if h.nrows() != theta.len() || h.ncols() != theta.len() {
855 return Err(crate::model_types::EstimationError::InvalidInput(format!(
856 "{engine_context}: baseline Hessian dimension mismatch: got {}x{}, expected {}x{}",
857 h.nrows(),
858 h.ncols(),
859 theta.len(),
860 theta.len()
861 )));
862 }
863 }
864 Ok(eval)
865 };
866 let cost_objective = std::rc::Rc::clone(&objective);
867 let cost_eval = eval_at.clone();
868 let cost_fn = move |_: &mut (), theta: &Array1<f64>| {
869 cost_eval(&cost_objective, theta).map(|eval| eval.cost)
870 };
871 let eval_fn = move |_: &mut (), theta: &Array1<f64>| eval_at(&objective, theta);
872 run_baseline_theta_optimizer(initial, context, contract, cost_fn, eval_fn)
873}
874
875pub fn optimize_survival_baseline_config_with_gradient_only<F>(
886 initial: &SurvivalBaselineConfig,
887 context: &str,
888 mut objective: F,
889) -> Result<SurvivalBaselineConfig, String>
890where
891 F: FnMut(&SurvivalBaselineConfig) -> Result<(f64, Array1<f64>), String>,
892{
893 use gam_problem::{HessianValue, OuterEval};
894 run_baseline_theta_optimizer_with_eval(
895 initial,
896 context,
897 BaselineDerivativeContract::GradientOnly,
898 move |cfg| {
899 let (cost, gradient) = objective(cfg)?;
900 Ok(OuterEval {
901 cost,
902 gradient,
903 hessian: HessianValue::Unavailable,
904 inner_beta_hint: None,
905 })
906 },
907 )
908}
909
910pub fn optimize_survival_baseline_config_with_gradient<F>(
915 initial: &SurvivalBaselineConfig,
916 context: &str,
917 mut objective: F,
918) -> Result<SurvivalBaselineConfig, String>
919where
920 F: FnMut(&SurvivalBaselineConfig) -> Result<(f64, Array1<f64>, Array2<f64>), String>,
921{
922 use gam_problem::{HessianValue, OuterEval};
923 run_baseline_theta_optimizer_with_eval(
924 initial,
925 context,
926 BaselineDerivativeContract::GradientHessian,
927 move |cfg| {
928 let (cost, gradient, hessian) = objective(cfg)?;
929 Ok(OuterEval {
930 cost,
931 gradient,
932 hessian: HessianValue::Dense(hessian),
933 inner_beta_hint: None,
934 })
935 },
936 )
937}
938
939pub fn parse_survival_time_basis_config(
944 time_basis: &str,
945 time_degree: usize,
946 time_num_internal_knots: usize,
947 time_smooth_lambda: f64,
948) -> Result<SurvivalTimeBasisConfig, String> {
949 match time_basis.to_ascii_lowercase().as_str() {
950 "none" => Ok(SurvivalTimeBasisConfig::None),
951 "ispline" => {
952 if time_degree < 1 {
953 return Err(
954 "time-basis degree must be >= 1 for ispline time basis (CLI: --time-degree; Python: time_degree=)"
955 .to_string(),
956 );
957 }
958 if time_num_internal_knots == 0 {
959 return Err(
960 "time-basis must have > 0 internal knots for ispline time basis (CLI: --time-num-internal-knots; Python: time_num_internal_knots=)"
961 .to_string(),
962 );
963 }
964 if !time_smooth_lambda.is_finite() || time_smooth_lambda < 0.0 {
965 return Err(
966 "time-basis smoothing lambda must be finite and >= 0 (CLI: --time-smooth-lambda; Python: time_smooth_lambda=)"
967 .to_string(),
968 );
969 }
970 Ok(SurvivalTimeBasisConfig::ISpline {
971 degree: time_degree,
972 knots: Array1::zeros(0),
973 keep_cols: Vec::new(),
974 smooth_lambda: time_smooth_lambda,
975 })
976 }
977 "linear" | "bspline" => {
978 match require_structural_survival_time_basis(time_basis, "survival model configuration")
985 {
986 Err(e) => Err(e),
987 Ok(()) => Err(format!(
988 "internal: structural-basis check accepted non-structural \
989 survival time basis '{time_basis}'"
990 )),
991 }
992 }
993 other => Err(format!(
994 "unsupported --time-basis '{other}'; accepted values: ispline, none"
995 )),
996 }
997}
998
999pub fn build_survival_time_basis(
1004 age_entry: &Array1<f64>,
1005 age_exit: &Array1<f64>,
1006 cfg: SurvivalTimeBasisConfig,
1007 infer_knots_if_needed: Option<(usize, f64)>,
1008) -> Result<SurvivalTimeBuildOutput, String> {
1009 fn checked_log_survival_times(times: &Array1<f64>, label: &str) -> Result<Array1<f64>, String> {
1010 if let Some(row) = times.iter().position(|t| !t.is_finite()) {
1011 return Err(SurvivalConstructionError::DataValidationFailed {
1012 reason: format!(
1013 "survival time basis requires finite {label} times (row {})",
1014 row + 1
1015 ),
1016 }
1017 .into());
1018 }
1019 if let Some(row) = times.iter().position(|t| *t < 0.0) {
1020 return Err(SurvivalConstructionError::DataValidationFailed {
1021 reason: format!(
1022 "survival time basis requires non-negative {label} times (row {})",
1023 row + 1
1024 ),
1025 }
1026 .into());
1027 }
1028 Ok(times.mapv(|t| t.max(SURVIVAL_TIME_FLOOR).ln()))
1029 }
1030
1031 let n = age_entry.len();
1032 if n != age_exit.len() {
1033 return Err(SurvivalConstructionError::IncompatibleDimensions {
1034 reason: "survival time basis requires matching entry/exit lengths".to_string(),
1035 }
1036 .into());
1037 }
1038 for i in 0..n {
1039 if age_exit[i] < age_entry[i] {
1040 return Err(format!(
1041 "survival time basis requires exit times >= entry times (row {})",
1042 i + 1
1043 ));
1044 }
1045 }
1046 let log_entry = checked_log_survival_times(age_entry, "entry")?;
1047 let log_exit = checked_log_survival_times(age_exit, "exit")?;
1048
1049 fn survival_time_knot_input(log_entry: &Array1<f64>, log_exit: &Array1<f64>) -> Array1<f64> {
1050 let n = log_entry.len();
1051 let entry_range = log_entry
1052 .iter()
1053 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1054 (lo.min(v), hi.max(v))
1055 });
1056 let entry_degenerate = (entry_range.1 - entry_range.0).abs() < 1e-8;
1057 if entry_degenerate {
1058 log_exit.clone()
1059 } else {
1060 let mut combined = Array1::<f64>::zeros(2 * n);
1061 for i in 0..n {
1062 combined[i] = log_entry[i];
1063 combined[n + i] = log_exit[i];
1064 }
1065 combined
1066 }
1067 }
1068
1069 fn data_capped_internal_knots(
1092 combined: &Array1<f64>,
1093 degree: usize,
1094 requested_internal_knots: usize,
1095 ) -> usize {
1096 if requested_internal_knots == 0 {
1097 return 0;
1098 }
1099 let mut sorted: Vec<f64> = combined.iter().copied().collect();
1100 sorted.sort_by(f64::total_cmp);
1101 let minval = sorted.first().copied().unwrap_or(0.0);
1102 let maxval = sorted.last().copied().unwrap_or(minval);
1103 if minval == maxval {
1104 return 1.min(requested_internal_knots);
1106 }
1107 let scale = (maxval - minval).abs().max(1.0);
1108 let tol = 1e-12 * scale;
1109 let mut distinct_interior = 0usize;
1112 let mut last: Option<f64> = None;
1113 for &x in &sorted {
1114 if x <= minval + tol || x >= maxval - tol {
1115 continue;
1116 }
1117 if last.is_some_and(|prev| (x - prev).abs() <= tol) {
1118 continue;
1119 }
1120 distinct_interior += 1;
1121 last = Some(x);
1122 }
1123 let mut cap = requested_internal_knots.min(distinct_interior.max(1));
1126 let n_distinct = {
1132 let mut count = 0usize;
1133 let mut last: Option<f64> = None;
1134 for &x in &sorted {
1135 if last.is_some_and(|prev| (x - prev).abs() <= tol) {
1136 continue;
1137 }
1138 count += 1;
1139 last = Some(x);
1140 }
1141 count
1142 };
1143 let dim_budget = n_distinct / 4;
1144 let dim_cap = dim_budget.saturating_sub(degree);
1145 cap = cap.min(dim_cap.max(1));
1146 cap.max(1)
1147 }
1148
1149 fn infer_survival_time_knots(
1150 combined: &Array1<f64>,
1151 knot_degree: usize,
1152 validation_degree: usize,
1153 num_internal_knots: usize,
1154 basis_options: BasisOptions,
1155 ) -> Result<Array1<f64>, String> {
1156 let num_internal_knots =
1162 data_capped_internal_knots(combined, validation_degree, num_internal_knots);
1163
1164 fn quantile_knot_inference_needs_uniform_fallback(
1165 combined: &Array1<f64>,
1166 num_internal_knots: usize,
1167 ) -> bool {
1168 if num_internal_knots == 0 || combined.is_empty() {
1169 return false;
1170 }
1171
1172 let mut sorted: Vec<f64> = combined.iter().copied().collect();
1173 sorted.sort_by(f64::total_cmp);
1174 let minval = sorted[0];
1175 let maxval = *sorted.last().unwrap_or(&minval);
1176 if minval == maxval {
1177 return false;
1178 }
1179
1180 let scale = (maxval - minval).abs().max(1.0);
1181 let tol = 1e-12 * scale;
1182 let mut support = Vec::with_capacity(sorted.len());
1183 let mut last: Option<f64> = None;
1184 for &x in &sorted {
1185 if x <= minval + tol || x >= maxval - tol {
1186 continue;
1187 }
1188 if last.map(|prev| (x - prev).abs() <= tol).unwrap_or(false) {
1189 continue;
1190 }
1191 support.push(x);
1192 last = Some(x);
1193 }
1194 if support.is_empty() {
1195 return true;
1196 }
1197
1198 let n = support.len();
1199 let mut prev_q = minval;
1200 for j in 1..=num_internal_knots {
1201 let p = j as f64 / (num_internal_knots + 1) as f64;
1202 let pos = p * (n.saturating_sub(1) as f64);
1203 let lo = pos.floor() as usize;
1204 let hi = pos.ceil() as usize;
1205 let frac = pos - lo as f64;
1206 let q = if lo == hi {
1207 support[lo]
1208 } else {
1209 support[lo] * (1.0 - frac) + support[hi] * frac
1210 }
1211 .clamp(minval, maxval);
1212 if q <= prev_q + tol || q >= maxval - tol {
1213 return true;
1214 }
1215 prev_q = q;
1216 }
1217
1218 false
1219 }
1220
1221 let inferwith =
1222 |placement: gam_terms::basis::BSplineKnotPlacement| -> Result<Array1<f64>, String> {
1223 let built = build_bspline_basis_1d(
1224 combined.view(),
1225 &BSplineBasisSpec {
1226 degree: knot_degree,
1227 penalty_order: 2,
1228 knotspec: BSplineKnotSpec::Automatic {
1229 num_internal_knots: Some(num_internal_knots),
1230 placement,
1231 },
1232 double_penalty: false,
1233 identifiability: BSplineIdentifiability::None,
1234 boundary: OneDimensionalBoundary::Open,
1235 boundary_conditions: BSplineBoundaryConditions::default(),
1236 },
1237 )
1238 .map_err(|e| format!("failed to infer survival time knots: {e}"))?;
1239 let knots = match built.metadata {
1240 BasisMetadata::BSpline1D { knots, .. } => knots,
1241 _ => {
1242 return Err(
1243 "internal error: expected BSpline1D metadata for survival time basis"
1244 .to_string(),
1245 );
1246 }
1247 };
1248 create_basis::<Dense>(
1257 combined.view(),
1258 KnotSource::Provided(knots.view()),
1259 validation_degree,
1260 basis_options,
1261 )
1262 .map_err(|e| e.to_string())?;
1263 Ok(knots)
1264 };
1265
1266 if quantile_knot_inference_needs_uniform_fallback(combined, num_internal_knots) {
1267 inferwith(gam_terms::basis::BSplineKnotPlacement::Uniform)
1268 } else {
1269 inferwith(gam_terms::basis::BSplineKnotPlacement::Quantile)
1270 }
1271 }
1272
1273 match cfg {
1274 SurvivalTimeBasisConfig::None => Ok(SurvivalTimeBuildOutput {
1275 x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1276 x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1277 x_derivative_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1278 penalties: Vec::new(),
1279 nullspace_dims: Vec::new(),
1280 basisname: "none".to_string(),
1281 degree: None,
1282 knots: None,
1283 keep_cols: None,
1284 smooth_lambda: None,
1285 }),
1286 SurvivalTimeBasisConfig::Linear => {
1287 let mut x_entry_time = Array2::<f64>::zeros((n, 2));
1288 let mut x_exit_time = Array2::<f64>::zeros((n, 2));
1289 let mut x_derivative_time = Array2::<f64>::zeros((n, 2));
1290 for i in 0..n {
1291 x_entry_time[[i, 0]] = 1.0;
1292 x_exit_time[[i, 0]] = 1.0;
1293 x_entry_time[[i, 1]] = log_entry[i];
1294 x_exit_time[[i, 1]] = log_exit[i];
1295 x_derivative_time[[i, 1]] = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1296 }
1297 Ok(SurvivalTimeBuildOutput {
1298 x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_entry_time)),
1299 x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_exit_time)),
1300 x_derivative_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_derivative_time)),
1301 penalties: Vec::new(),
1302 nullspace_dims: Vec::new(),
1303 basisname: "linear".to_string(),
1304 degree: None,
1305 knots: None,
1306 keep_cols: None,
1307 smooth_lambda: None,
1308 })
1309 }
1310 SurvivalTimeBasisConfig::BSpline {
1311 degree,
1312 knots,
1313 smooth_lambda,
1314 } => {
1315 let knotvec = if knots.is_empty() {
1316 let (num_internal_knots, _) = infer_knots_if_needed.ok_or_else(|| {
1317 "internal error: bspline time basis requested without knot source".to_string()
1318 })?;
1319 let combined = survival_time_knot_input(&log_entry, &log_exit);
1320 infer_survival_time_knots(
1321 &combined,
1322 degree,
1323 degree,
1324 num_internal_knots,
1325 BasisOptions::value(),
1326 )?
1327 } else {
1328 knots
1329 };
1330
1331 let entry_basis = build_bspline_basis_1d(
1332 log_entry.view(),
1333 &BSplineBasisSpec {
1334 degree,
1335 penalty_order: 2,
1336 knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1337 double_penalty: false,
1338 identifiability: BSplineIdentifiability::None,
1339 boundary: OneDimensionalBoundary::Open,
1340 boundary_conditions: BSplineBoundaryConditions::default(),
1341 },
1342 )
1343 .map_err(|e| format!("failed to build bspline entry basis: {e}"))?;
1344 let exit_basis = build_bspline_basis_1d(
1345 log_exit.view(),
1346 &BSplineBasisSpec {
1347 degree,
1348 penalty_order: 2,
1349 knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1350 double_penalty: false,
1351 identifiability: BSplineIdentifiability::None,
1352 boundary: OneDimensionalBoundary::Open,
1353 boundary_conditions: BSplineBoundaryConditions::default(),
1354 },
1355 )
1356 .map_err(|e| format!("failed to build bspline exit basis: {e}"))?;
1357
1358 let p_time = exit_basis.design.ncols();
1359 let mut deriv_triplets = Vec::with_capacity(n * (degree + 1));
1363 let mut deriv_buf = vec![0.0_f64; p_time];
1364 for i in 0..n {
1365 deriv_buf.fill(0.0);
1366 evaluate_bspline_derivative_scalar(
1367 log_exit[i],
1368 knotvec.view(),
1369 degree,
1370 &mut deriv_buf,
1371 )
1372 .map_err(|e| format!("failed to evaluate bspline derivative: {e}"))?;
1373 let chain = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1374 for j in 0..p_time {
1375 let v = deriv_buf[j] * chain;
1376 if v.abs() > 1e-15 {
1377 deriv_triplets.push(faer::sparse::Triplet::new(i, j, v));
1378 }
1379 }
1380 }
1381 let x_derivative_time =
1382 match faer::sparse::SparseColMat::try_new_from_triplets(n, p_time, &deriv_triplets)
1383 {
1384 Ok(sparse) => DesignMatrix::Sparse(SparseDesignMatrix::new(sparse)),
1385 Err(_) => {
1386 let mut dense = Array2::<f64>::zeros((n, p_time));
1388 for &faer::sparse::Triplet { row, col, val } in &deriv_triplets {
1389 dense[[row, col]] = val;
1390 }
1391 DesignMatrix::Dense(DenseDesignMatrix::from(dense))
1392 }
1393 };
1394
1395 Ok(SurvivalTimeBuildOutput {
1396 x_entry_time: entry_basis.design,
1397 x_exit_time: exit_basis.design,
1398 x_derivative_time,
1399 nullspace_dims: entry_basis.nullspace_dims,
1400 penalties: entry_basis.penalties,
1401 basisname: "bspline".to_string(),
1402 degree: Some(degree),
1403 knots: Some(knotvec.to_vec()),
1404 keep_cols: None,
1405 smooth_lambda: Some(smooth_lambda),
1406 })
1407 }
1408 SurvivalTimeBasisConfig::ISpline {
1409 degree,
1410 knots,
1411 keep_cols,
1412 smooth_lambda,
1413 } => {
1414 let bspline_degree = degree
1415 .checked_add(1)
1416 .ok_or_else(|| "ispline degree overflow while building knot basis".to_string())?;
1417 let knotvec = if knots.is_empty() {
1418 let (num_internal_knots, _) = infer_knots_if_needed.ok_or_else(|| {
1419 "internal error: ispline time basis requested without knot source".to_string()
1420 })?;
1421 let combined = survival_time_knot_input(&log_entry, &log_exit);
1422 infer_survival_time_knots(
1423 &combined,
1424 bspline_degree,
1425 degree,
1426 num_internal_knots,
1427 BasisOptions::i_spline(),
1428 )?
1429 } else {
1430 knots
1431 };
1432
1433 let (db_exit_arc, _) = create_basis::<Dense>(
1434 log_exit.view(),
1435 KnotSource::Provided(knotvec.view()),
1436 bspline_degree,
1437 BasisOptions::first_derivative(),
1438 )
1439 .map_err(|e| format!("failed to build ispline derivative basis: {e}"))?;
1440
1441 let (x_entry_time, x_exit_time, keep_cols, p_time, p_time_full) = {
1444 let (entry_arc, _) = create_basis::<Dense>(
1445 log_entry.view(),
1446 KnotSource::Provided(knotvec.view()),
1447 degree,
1448 BasisOptions::i_spline(),
1449 )
1450 .map_err(|e| format!("failed to build ispline entry basis: {e}"))?;
1451 let (exit_arc, _) = create_basis::<Dense>(
1452 log_exit.view(),
1453 KnotSource::Provided(knotvec.view()),
1454 degree,
1455 BasisOptions::i_spline(),
1456 )
1457 .map_err(|e| format!("failed to build ispline exit basis: {e}"))?;
1458
1459 let x_entry_full = entry_arc.as_ref();
1460 let x_exit_full = exit_arc.as_ref();
1461 let p_time_full = x_exit_full.ncols();
1462 if p_time_full == 0 {
1463 return Err(SurvivalConstructionError::BasisConstructionFailed {
1464 reason: "internal error: empty ispline time basis".to_string(),
1465 }
1466 .into());
1467 }
1468 let db_exit = db_exit_arc.as_ref();
1469 if db_exit.ncols() != p_time_full + 1 {
1470 return Err(
1471 "internal error: ispline derivative basis width must exceed basis width by one"
1472 .to_string(),
1473 );
1474 }
1475
1476 let keep_cols = if keep_cols.is_empty() {
1477 let constant_tol = 1e-12_f64;
1478 let mut inferred_keep_cols: Vec<usize> = Vec::new();
1479 for j in 0..p_time_full {
1480 let mut minv = f64::INFINITY;
1481 let mut maxv = f64::NEG_INFINITY;
1482 for i in 0..n {
1483 let ve = x_exit_full[[i, j]];
1484 let vs = x_entry_full[[i, j]];
1485 minv = minv.min(ve.min(vs));
1486 maxv = maxv.max(ve.max(vs));
1487 }
1488 if (maxv - minv) > constant_tol {
1489 inferred_keep_cols.push(j);
1490 }
1491 }
1492 inferred_keep_cols
1493 } else {
1494 keep_cols
1495 };
1496 if keep_cols.is_empty() {
1497 return Err(
1498 "internal error: ispline basis has no shape-varying time columns"
1499 .to_string(),
1500 );
1501 }
1502 if keep_cols.iter().any(|&j| j >= p_time_full) {
1503 return Err(SurvivalConstructionError::MissingColumn {
1504 reason: "saved survival ispline keep_cols exceed basis width".to_string(),
1505 }
1506 .into());
1507 }
1508
1509 let p_time = keep_cols.len();
1510 let x_entry_time = x_entry_full.select(ndarray::Axis(1), &keep_cols);
1511 let x_exit_time = x_exit_full.select(ndarray::Axis(1), &keep_cols);
1512 (x_entry_time, x_exit_time, keep_cols, p_time, p_time_full)
1515 };
1516 let db_exit = db_exit_arc.as_ref();
1517
1518 let mut deriv_triplets = Vec::with_capacity(n * p_time.min(16));
1523 let mut found_nonfinite: Option<(usize, usize)> = None;
1524 for i in 0..n {
1525 let mut running = 0.0_f64;
1526 let mut d_i_log_full = vec![0.0_f64; p_time_full];
1527 for j in (1..db_exit.ncols()).rev() {
1528 let term = db_exit[[i, j]];
1529 if term.is_finite() {
1530 running += term;
1531 }
1532 d_i_log_full[j - 1] = running;
1533 }
1534 let chain = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1535 for (j_new, &j_old) in keep_cols.iter().enumerate() {
1536 let raw_v = d_i_log_full[j_old] * chain;
1537 let v = if (-1e-12..0.0).contains(&raw_v) {
1538 0.0
1539 } else {
1540 raw_v
1541 };
1542 if !v.is_finite() {
1543 found_nonfinite = Some((i, j_new));
1544 }
1545 if v < -1e-12 {
1546 return Err(format!(
1547 "survival ispline derivative basis must stay non-negative at row {}, column {}; found {:.3e}",
1548 i + 1,
1549 j_new + 1,
1550 v
1551 ));
1552 }
1553 if v.abs() > 1e-15 {
1554 deriv_triplets.push(faer::sparse::Triplet::new(i, j_new, v));
1555 }
1556 }
1557 }
1558 if let Some((row, col)) = found_nonfinite {
1559 return Err(format!(
1560 "survival ispline derivative basis produced non-finite value at row {}, column {}",
1561 row + 1,
1562 col + 1
1563 ));
1564 }
1565 let x_derivative_time =
1566 match faer::sparse::SparseColMat::try_new_from_triplets(n, p_time, &deriv_triplets)
1567 {
1568 Ok(sparse) => DesignMatrix::Sparse(SparseDesignMatrix::new(sparse)),
1569 Err(_) => {
1570 let mut dense = Array2::<f64>::zeros((n, p_time));
1571 for &faer::sparse::Triplet { row, col, val } in &deriv_triplets {
1572 dense[[row, col]] = val;
1573 }
1574 DesignMatrix::Dense(DenseDesignMatrix::from(dense))
1575 }
1576 };
1577
1578 let penalty_basis = build_bspline_basis_1d(
1579 log_exit.view(),
1580 &BSplineBasisSpec {
1581 degree: bspline_degree,
1582 penalty_order: 2,
1583 knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1584 double_penalty: false,
1585 identifiability: BSplineIdentifiability::None,
1586 boundary: OneDimensionalBoundary::Open,
1587 boundary_conditions: BSplineBoundaryConditions::default(),
1588 },
1589 )
1590 .map_err(|e| format!("failed to build ispline smoothing penalty: {e}"))?;
1591 if penalty_basis.design.ncols() != p_time_full + 1 {
1592 return Err("internal error: ispline penalty dimension mismatch".to_string());
1593 }
1594 let mut penalties = Vec::<Array2<f64>>::new();
1628 for s_mat in &penalty_basis.penalties {
1629 if s_mat.nrows() != p_time_full + 1 || s_mat.ncols() != p_time_full + 1 {
1630 continue;
1631 }
1632 let s_increment = s_mat.slice(s![1.., 1..]);
1661 if s_increment.nrows() != p_time_full || s_increment.ncols() != p_time_full {
1662 return Err(format!(
1663 "internal error: ispline penalty increment block must be {p_time_full}x{p_time_full}, got {}x{}",
1664 s_increment.nrows(),
1665 s_increment.ncols(),
1666 ));
1667 }
1668 let mut s_full = s_increment.to_owned();
1673 symmetrize_in_place(&mut s_full);
1674 let mut s_mid_full = Array2::<f64>::zeros((p_time_full, p_time_full));
1678 for i in 0..p_time_full {
1679 for j in 0..p_time_full {
1680 let mut v = 0.0;
1681 for k in j..p_time_full {
1682 v += s_full[[i, k]];
1683 }
1684 s_mid_full[[i, j]] = v;
1685 }
1686 }
1687 let mut s_full_congruent = Array2::<f64>::zeros((p_time_full, p_time_full));
1691 for i in 0..p_time_full {
1692 for j in 0..p_time_full {
1693 let mut v = 0.0;
1694 for k in i..p_time_full {
1695 v += s_mid_full[[k, j]];
1696 }
1697 s_full_congruent[[i, j]] = v;
1698 }
1699 }
1700 let mut local = Array2::<f64>::zeros((p_time, p_time));
1702 for (i_new, &i_old) in keep_cols.iter().enumerate() {
1703 for (j_new, &j_old) in keep_cols.iter().enumerate() {
1704 local[[i_new, j_new]] = 0.5
1707 * (s_full_congruent[[i_old, j_old]] + s_full_congruent[[j_old, i_old]]);
1708 }
1709 }
1710 penalties.push(local);
1711 }
1712
1713 for (idx, s_mat) in penalties.iter().enumerate() {
1723 let p = s_mat.nrows();
1724 if p == 0 {
1725 continue;
1726 }
1727 if let Ok((evals, _)) =
1728 gam_linalg::faer_ndarray::FaerEigh::eigh(s_mat, faer::Side::Lower)
1729 {
1730 let evals_slice: &[f64] = evals.as_slice().ok_or_else(|| {
1731 "internal error: ispline penalty eigenvalues not contiguous".to_string()
1732 })?;
1733 let max_ev = evals_slice
1734 .iter()
1735 .copied()
1736 .fold(0.0_f64, |a, b| a.max(b.abs()))
1737 .max(1.0);
1738 let min_ev = evals_slice.iter().copied().fold(f64::INFINITY, f64::min);
1739 let neg_tol = -100.0 * (p as f64) * f64::EPSILON * max_ev;
1740 if min_ev < neg_tol {
1741 return Err(format!(
1742 "internal error (gam#979): assembled ispline time-block penalty {idx} is \
1743 indefinite (min eigenvalue {min_ev:.3e} < tol {neg_tol:.3e}, max |eig| \
1744 {max_ev:.3e}); the value-space congruence Lᵀ S_B[1:,1:] L must be PSD"
1745 ));
1746 }
1747 }
1748 }
1749
1750 let nullspace_dims: Vec<usize> = penalties
1754 .iter()
1755 .map(|s_mat| {
1756 let p = s_mat.nrows();
1757 if p == 0 {
1758 return 0;
1759 }
1760 match gam_linalg::faer_ndarray::FaerEigh::eigh(s_mat, faer::Side::Lower) {
1761 Ok((evals, _)) => {
1762 let evals_slice: &[f64] = evals.as_slice().unwrap();
1763 let max_ev = evals_slice
1764 .iter()
1765 .copied()
1766 .fold(0.0_f64, |a, b| a.max(b.abs()))
1767 .max(1.0);
1768 let threshold = 100.0 * (p as f64) * f64::EPSILON * max_ev;
1769 evals_slice.iter().filter(|&&e| e <= threshold).count()
1770 }
1771 Err(_) => 0,
1772 }
1773 })
1774 .collect();
1775 Ok(SurvivalTimeBuildOutput {
1776 x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_entry_time)),
1777 x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_exit_time)),
1778 x_derivative_time,
1779 penalties,
1780 nullspace_dims,
1781 basisname: "ispline".to_string(),
1782 degree: Some(degree),
1783 knots: Some(knotvec.to_vec()),
1784 keep_cols: Some(keep_cols),
1785 smooth_lambda: Some(smooth_lambda),
1786 })
1787 }
1788 }
1789}
1790
1791pub fn resolved_survival_time_basis_config_from_build(
1792 basisname: &str,
1793 degree: Option<usize>,
1794 knots: Option<&Vec<f64>>,
1795 keep_cols: Option<&Vec<usize>>,
1796 smooth_lambda: Option<f64>,
1797) -> Result<SurvivalTimeBasisConfig, String> {
1798 match basisname {
1799 "none" => Ok(SurvivalTimeBasisConfig::None),
1800 "linear" => Ok(SurvivalTimeBasisConfig::Linear),
1801 "bspline" => Ok(SurvivalTimeBasisConfig::BSpline {
1802 degree: degree.ok_or_else(|| "survival bspline basis is missing degree".to_string())?,
1803 knots: Array1::from_vec(
1804 knots
1805 .cloned()
1806 .ok_or_else(|| "survival bspline basis is missing knots".to_string())?,
1807 ),
1808 smooth_lambda: smooth_lambda.unwrap_or(SURVIVAL_TIME_SMOOTH_LAMBDA_SEED),
1809 }),
1810 "ispline" => Ok(SurvivalTimeBasisConfig::ISpline {
1811 degree: degree.ok_or_else(|| "survival ispline basis is missing degree".to_string())?,
1812 knots: Array1::from_vec(
1813 knots
1814 .cloned()
1815 .ok_or_else(|| "survival ispline basis is missing knots".to_string())?,
1816 ),
1817 keep_cols: keep_cols
1818 .cloned()
1819 .ok_or_else(|| "survival ispline basis is missing keep_cols".to_string())?,
1820 smooth_lambda: smooth_lambda.unwrap_or(SURVIVAL_TIME_SMOOTH_LAMBDA_SEED),
1821 }),
1822 other => Err(format!("unsupported survival time basis '{other}'")),
1823 }
1824}
1825
1826pub fn resolve_survival_time_anchor_value(
1827 age_entry: &Array1<f64>,
1828 time_anchor: Option<f64>,
1829) -> Result<f64, String> {
1830 if age_entry.is_empty() {
1831 return Err("survival time anchor requires non-empty entry times".to_string());
1832 }
1833 let anchor = match time_anchor {
1834 Some(t_anchor) => {
1835 if !t_anchor.is_finite() || t_anchor < 0.0 {
1836 return Err(format!(
1837 "survival time anchor must be finite and non-negative, got {t_anchor}"
1838 ));
1839 }
1840 t_anchor
1841 }
1842 None => age_entry
1843 .iter()
1844 .copied()
1845 .min_by(f64::total_cmp)
1846 .ok_or_else(|| "failed to select survival time anchor".to_string())?,
1847 };
1848 Ok(anchor.max(SURVIVAL_TIME_FLOOR))
1849}
1850
1851pub fn resolve_survival_marginal_slope_time_anchor_value(
1883 age_entry: &Array1<f64>,
1884 age_exit: &Array1<f64>,
1885 time_anchor: Option<f64>,
1886) -> Result<f64, String> {
1887 if age_entry.is_empty() || age_exit.is_empty() {
1888 return Err(
1889 "survival marginal-slope time anchor requires non-empty entry/exit times".to_string(),
1890 );
1891 }
1892 let anchor = match time_anchor {
1893 Some(t_anchor) => {
1894 if !t_anchor.is_finite() || t_anchor < 0.0 {
1895 return Err(format!(
1896 "survival time anchor must be finite and non-negative, got {t_anchor}"
1897 ));
1898 }
1899 t_anchor
1900 }
1901 None => robust_interior_exit_anchor(age_exit),
1902 };
1903 Ok(anchor.max(SURVIVAL_TIME_FLOOR))
1904}
1905
1906fn robust_interior_exit_anchor(age_exit: &Array1<f64>) -> f64 {
1913 let mut sorted: Vec<f64> = age_exit.iter().copied().collect();
1914 sorted.sort_by(f64::total_cmp);
1915 let m = sorted.len();
1916 if m == 0 {
1917 return SURVIVAL_TIME_FLOOR;
1918 }
1919 if m % 2 == 1 {
1920 sorted[m / 2]
1921 } else {
1922 0.5 * (sorted[m / 2 - 1] + sorted[m / 2])
1923 }
1924}
1925
1926pub fn resolve_survival_transformation_time_anchor_value(
1947 age_entry: &Array1<f64>,
1948 age_exit: &Array1<f64>,
1949 time_anchor: Option<f64>,
1950) -> Result<f64, String> {
1951 if time_anchor.is_some() {
1952 return resolve_survival_time_anchor_value(age_entry, time_anchor);
1953 }
1954 if age_exit.is_empty() {
1955 return Err(
1956 "survival transformation time anchor requires non-empty exit times".to_string(),
1957 );
1958 }
1959 let min_entry = age_entry.iter().copied().fold(f64::INFINITY, f64::min);
1960 if min_entry > SURVIVAL_DELAYED_ENTRY_THRESHOLD {
1961 Ok(robust_interior_exit_anchor(age_exit).max(SURVIVAL_TIME_FLOOR))
1962 } else {
1963 resolve_survival_time_anchor_value(age_entry, None)
1964 }
1965}
1966
1967pub fn evaluate_survival_time_basis_row(
1968 age: f64,
1969 cfg: &SurvivalTimeBasisConfig,
1970) -> Result<Array1<f64>, String> {
1971 if !age.is_finite() || age < 0.0 {
1972 return Err(format!(
1973 "survival time basis row requires finite non-negative age, got {age}"
1974 ));
1975 }
1976 let age = age.max(SURVIVAL_TIME_FLOOR);
1977 let log_age = array![age.ln()];
1978 match cfg {
1979 SurvivalTimeBasisConfig::None => Ok(Array1::zeros(0)),
1980 SurvivalTimeBasisConfig::Linear => Ok(array![1.0, age.ln()]),
1981 SurvivalTimeBasisConfig::BSpline { degree, knots, .. } => {
1982 if knots.is_empty() {
1983 return Err(
1984 "survival BSpline anchor evaluation requires resolved knot metadata"
1985 .to_string(),
1986 );
1987 }
1988 let built = build_bspline_basis_1d(
1989 log_age.view(),
1990 &BSplineBasisSpec {
1991 degree: *degree,
1992 penalty_order: 2,
1993 knotspec: BSplineKnotSpec::Provided(knots.clone()),
1994 double_penalty: false,
1995 identifiability: BSplineIdentifiability::None,
1996 boundary: OneDimensionalBoundary::Open,
1997 boundary_conditions: BSplineBoundaryConditions::default(),
1998 },
1999 )
2000 .map_err(|e| format!("failed to evaluate survival bspline anchor row: {e}"))?;
2001 Ok(built.design.to_dense().row(0).to_owned())
2002 }
2003 SurvivalTimeBasisConfig::ISpline {
2004 degree,
2005 knots,
2006 keep_cols,
2007 ..
2008 } => {
2009 if knots.is_empty() {
2010 return Err(
2011 "survival ISpline anchor evaluation requires resolved knot metadata"
2012 .to_string(),
2013 );
2014 }
2015 let (basis_arc, _) = create_basis::<Dense>(
2016 log_age.view(),
2017 KnotSource::Provided(knots.view()),
2018 *degree,
2019 BasisOptions::i_spline(),
2020 )
2021 .map_err(|e| format!("failed to evaluate survival ispline anchor row: {e}"))?;
2022 let basis = basis_arc.as_ref();
2023 let row = basis.row(0);
2024 if keep_cols.is_empty() {
2025 return Ok(row.to_owned());
2026 }
2027 if keep_cols.iter().any(|&j| j >= row.len()) {
2028 return Err(SurvivalConstructionError::MissingColumn {
2029 reason: "survival ISpline anchor keep_cols exceed basis width".to_string(),
2030 }
2031 .into());
2032 }
2033 Ok(Array1::from_iter(keep_cols.iter().map(|&j| row[j])))
2034 }
2035 }
2036}
2037
2038pub fn center_survival_time_designs_at_anchor(
2039 design_entry: &mut DesignMatrix,
2040 design_exit: &mut DesignMatrix,
2041 anchor_row: &Array1<f64>,
2042) -> Result<(), String> {
2043 if design_entry.ncols() != anchor_row.len() || design_exit.ncols() != anchor_row.len() {
2044 return Err(format!(
2045 "survival time anchoring column mismatch: entry={}, exit={}, anchor={}",
2046 design_entry.ncols(),
2047 design_exit.ncols(),
2048 anchor_row.len()
2049 ));
2050 }
2051 fn center_dense(dm: &mut DesignMatrix, anchor: &Array1<f64>) {
2054 let mut dense = dm.to_dense();
2055 for mut row in dense.rows_mut() {
2056 row -= &anchor.view();
2057 }
2058 *dm = DesignMatrix::Dense(DenseDesignMatrix::from(dense));
2059 }
2060 center_dense(design_entry, anchor_row);
2061 center_dense(design_exit, anchor_row);
2062 Ok(())
2063}
2064
2065pub fn baseline_offset_theta_partials(
2095 age: f64,
2096 cfg: &SurvivalBaselineConfig,
2097) -> Result<Option<Vec<(f64, f64)>>, String> {
2098 let Some(params) = validated_baseline_params(age, cfg, "baseline derivative evaluation")?
2099 else {
2100 return Ok(None);
2101 };
2102
2103 match params {
2104 ValidatedBaselineTarget::Weibull { scale, shape } => {
2105 let eta = shape * (age.ln() - scale.ln());
2114 let o_d = shape / age;
2115 let d_eta_d_log_scale = -shape;
2116 let d_od_d_log_scale = 0.0;
2117 let d_eta_d_log_shape = eta;
2118 let d_od_d_log_shape = o_d;
2119 Ok(Some(vec![
2120 (d_eta_d_log_scale, d_od_d_log_scale),
2121 (d_eta_d_log_shape, d_od_d_log_shape),
2122 ]))
2123 }
2124 ValidatedBaselineTarget::Gompertz { shape, .. } => {
2125 let (d_eta_d_shape, d_od_d_shape) = gompertz_shape_derivatives(age, shape);
2135 Ok(Some(vec![(1.0, 0.0), (d_eta_d_shape, d_od_d_shape)]))
2136 }
2137 ValidatedBaselineTarget::GompertzMakeham {
2138 rate,
2139 shape,
2140 makeham,
2141 } => {
2142 let (cum_g, inst_g) = gompertz_hazard_components(age, rate, shape);
2157 let cum_total = makeham * age + cum_g;
2158 if cum_total <= 0.0 || !cum_total.is_finite() {
2159 return Err(SurvivalConstructionError::DataValidationFailed {
2160 reason: "gm baseline produced non-positive cumulative hazard".to_string(),
2161 }
2162 .into());
2163 }
2164 let inst_total = makeham + inst_g;
2165 let o_d = inst_total / cum_total;
2166 let inv_cum = 1.0 / cum_total;
2167 let d_cum_dlr = cum_g;
2172 let d_inst_dlr = inst_g;
2173 let d_eta_dlr = d_cum_dlr * inv_cum;
2174 let d_od_dlr = (d_inst_dlr - o_d * d_cum_dlr) * inv_cum;
2175 let (d_cum_dshape, d_inst_dshape) =
2177 gompertz_cumulative_shape_derivative(age, rate, shape);
2178 let d_eta_dshape = d_cum_dshape * inv_cum;
2179 let d_od_dshape = (d_inst_dshape - o_d * d_cum_dshape) * inv_cum;
2180 let d_cum_dlm = makeham * age;
2183 let d_inst_dlm = makeham;
2184 let d_eta_dlm = d_cum_dlm * inv_cum;
2185 let d_od_dlm = (d_inst_dlm - o_d * d_cum_dlm) * inv_cum;
2186 Ok(Some(vec![
2187 (d_eta_dlr, d_od_dlr),
2188 (d_eta_dshape, d_od_dshape),
2189 (d_eta_dlm, d_od_dlm),
2190 ]))
2191 }
2192 }
2193}
2194
2195fn baseline_chain_rule_gradient_with_partials<F>(
2223 label: &'static str,
2224 age_entry: ndarray::ArrayView1<'_, f64>,
2225 age_exit: ndarray::ArrayView1<'_, f64>,
2226 age_right: ndarray::ArrayView1<'_, f64>,
2227 cfg: &SurvivalBaselineConfig,
2228 residuals: &crate::survival::OffsetChannelResiduals,
2229 partials: F,
2230) -> Result<Option<Array1<f64>>, String>
2231where
2232 F: Fn(f64, &SurvivalBaselineConfig) -> Result<Option<Vec<(f64, f64)>>, String> + Sync,
2233{
2234 let n = age_exit.len();
2235 if age_entry.len() != n
2236 || age_right.len() != n
2237 || residuals.exit.len() != n
2238 || residuals.entry.len() != n
2239 || residuals.derivative.len() != n
2240 || residuals.right.len() != n
2241 {
2242 return Err(format!(
2243 "{label}: length mismatch (age_entry={}, age_exit={}, age_right={}, r_exit={}, r_entry={}, r_deriv={}, r_right={})",
2244 age_entry.len(),
2245 n,
2246 age_right.len(),
2247 residuals.exit.len(),
2248 residuals.entry.len(),
2249 residuals.derivative.len(),
2250 residuals.right.len(),
2251 ));
2252 }
2253 let probe_age = age_exit.iter().copied().find(|v| v.is_finite() && *v > 0.0);
2256 let theta_dim = match probe_age {
2257 Some(t) => match partials(t, cfg)? {
2258 None => return Ok(None),
2259 Some(v) => v.len(),
2260 },
2261 None => {
2262 return Err(format!("{label}: no valid positive age for dim probe"));
2263 }
2264 };
2265 let mut grad = Array1::<f64>::zeros(theta_dim);
2276 for i in 0..n {
2277 let partials_exit = partials(age_exit[i], cfg)?
2279 .ok_or_else(|| format!("{label}: unexpected None from partials at exit"))?;
2280 if partials_exit.len() != theta_dim {
2281 return Err(format!(
2282 "{label}: theta_dim drifted ({} != {})",
2283 partials_exit.len(),
2284 theta_dim
2285 ));
2286 }
2287 let r_x = residuals.exit[i];
2288 let r_d = residuals.derivative[i];
2289 for k in 0..theta_dim {
2290 let (d_eta_dk, d_od_dk) = partials_exit[k];
2291 grad[k] += r_x * d_eta_dk + r_d * d_od_dk;
2292 }
2293 let r_e = residuals.entry[i];
2297 if r_e != 0.0 {
2298 let partials_entry = partials(age_entry[i], cfg)?
2299 .ok_or_else(|| format!("{label}: unexpected None from partials at entry"))?;
2300 for k in 0..theta_dim {
2301 grad[k] += r_e * partials_entry[k].0;
2302 }
2303 }
2304 let r_r = residuals.right[i];
2313 if r_r != 0.0 {
2314 let partials_right = partials(age_right[i], cfg)?.ok_or_else(|| {
2315 format!("{label}: unexpected None from partials at right boundary")
2316 })?;
2317 if partials_right.len() != theta_dim {
2318 return Err(format!(
2319 "{label}: theta_dim drifted at right boundary ({} != {})",
2320 partials_right.len(),
2321 theta_dim
2322 ));
2323 }
2324 for k in 0..theta_dim {
2325 grad[k] += r_r * partials_right[k].0;
2326 }
2327 }
2328 }
2329 Ok(Some(grad))
2330}
2331
2332pub fn baseline_chain_rule_gradient(
2366 age_entry: ndarray::ArrayView1<'_, f64>,
2367 age_exit: ndarray::ArrayView1<'_, f64>,
2368 age_right: ndarray::ArrayView1<'_, f64>,
2369 cfg: &SurvivalBaselineConfig,
2370 residuals: &crate::survival::OffsetChannelResiduals,
2371) -> Result<Option<Array1<f64>>, String> {
2372 baseline_chain_rule_gradient_with_partials(
2373 "baseline_chain_rule_gradient",
2374 age_entry,
2375 age_exit,
2376 age_right,
2377 cfg,
2378 residuals,
2379 baseline_offset_theta_partials,
2380 )
2381}
2382
2383pub fn marginal_slope_baseline_chain_rule_gradient(
2390 age_entry: ndarray::ArrayView1<'_, f64>,
2391 age_exit: ndarray::ArrayView1<'_, f64>,
2392 cfg: &SurvivalBaselineConfig,
2393 residuals: &crate::survival::OffsetChannelResiduals,
2394) -> Result<Option<Array1<f64>>, String> {
2395 baseline_chain_rule_gradient_with_partials(
2399 "marginal_slope_baseline_chain_rule_gradient",
2400 age_entry,
2401 age_exit,
2402 age_exit,
2403 cfg,
2404 residuals,
2405 marginal_slope_baseline_offset_theta_partials,
2406 )
2407}
2408
2409#[inline]
2413fn gompertz_hazard_components(age: f64, rate: f64, shape: f64) -> (f64, f64) {
2414 if shape.abs() < 1e-10 {
2415 let x = shape * age;
2418 (
2419 rate * age * (1.0 + 0.5 * x + x * x / 6.0),
2420 rate * (1.0 + x + 0.5 * x * x),
2421 )
2422 } else {
2423 let shape_age = shape * age;
2424 let cumulative_hazard = (rate / shape) * shape_age.exp_m1();
2425 let instant_hazard = rate * shape_age.exp();
2426 (cumulative_hazard, instant_hazard)
2427 }
2428}
2429
2430#[inline]
2446fn gompertz_cumulative_shape_derivative(age: f64, rate: f64, shape: f64) -> (f64, f64) {
2447 let x = shape * age;
2448 let dinstg_dshape = rate * age * x.exp();
2449 let dhg_dshape = if x.abs() < 1e-4 {
2458 let t = age;
2459 rate * t * t * (0.5 + x / 3.0 + x * x / 8.0)
2461 } else {
2462 let e = x.exp();
2464 let em1 = x.exp_m1();
2465 let numerator = age * e * shape - em1;
2466 rate * numerator / (shape * shape)
2467 };
2468 (dhg_dshape, dinstg_dshape)
2469}
2470
2471#[inline]
2476fn gompertz_shape_derivatives(age: f64, shape: f64) -> (f64, f64) {
2477 if shape.abs() < 1e-10 {
2478 let t = age;
2488 let d_eta = 0.5 * t + shape * t * t / 12.0;
2489 let dlog_od = 0.5 * t - shape * t * t / 12.0;
2490 let o_d = 1.0 / t + 0.5 * shape + shape * shape * t / 12.0;
2491 (d_eta, o_d * dlog_od)
2492 } else {
2493 let x = shape * age;
2494 let e = x.exp();
2495 let em1 = x.exp_m1(); let d_eta = -1.0 / shape + age * e / em1;
2497 let o_d = shape * e / em1;
2499 let dlog_od = 1.0 / shape - age / em1;
2500 (d_eta, o_d * dlog_od)
2501 }
2502}
2503
2504#[derive(Clone, Copy, Debug)]
2513enum ValidatedBaselineTarget {
2514 Weibull { scale: f64, shape: f64 },
2515 Gompertz { rate: f64, shape: f64 },
2516 GompertzMakeham { rate: f64, shape: f64, makeham: f64 },
2517}
2518
2519fn validated_baseline_params(
2525 age: f64,
2526 cfg: &SurvivalBaselineConfig,
2527 context: &str,
2528) -> Result<Option<ValidatedBaselineTarget>, String> {
2529 if !age.is_finite() || age <= 0.0 {
2530 return Err(format!(
2531 "survival ages must be finite and positive for {context}"
2532 ));
2533 }
2534
2535 match cfg.target {
2536 SurvivalBaselineTarget::Linear => Ok(None),
2537 SurvivalBaselineTarget::Weibull => {
2538 let scale = cfg
2539 .scale
2540 .ok_or_else(|| "weibull missing scale".to_string())?;
2541 let shape = cfg
2542 .shape
2543 .ok_or_else(|| "weibull missing shape".to_string())?;
2544 if !(scale.is_finite() && shape.is_finite() && scale > 0.0 && shape > 0.0) {
2545 return Err(SurvivalConstructionError::InvalidConfig {
2546 reason: "weibull baseline requires finite positive scale and shape".to_string(),
2547 }
2548 .into());
2549 }
2550 Ok(Some(ValidatedBaselineTarget::Weibull { scale, shape }))
2551 }
2552 SurvivalBaselineTarget::Gompertz => {
2553 let rate = cfg
2554 .rate
2555 .ok_or_else(|| "gompertz missing rate".to_string())?;
2556 let shape = cfg
2557 .shape
2558 .ok_or_else(|| "gompertz missing shape".to_string())?;
2559 if !(rate.is_finite() && shape.is_finite() && rate > 0.0) {
2560 return Err(
2561 "gompertz baseline requires finite positive rate and finite shape".to_string(),
2562 );
2563 }
2564 Ok(Some(ValidatedBaselineTarget::Gompertz { rate, shape }))
2565 }
2566 SurvivalBaselineTarget::GompertzMakeham => {
2567 let rate = cfg
2568 .rate
2569 .ok_or_else(|| "gompertz-makeham missing rate".to_string())?;
2570 let shape = cfg
2571 .shape
2572 .ok_or_else(|| "gompertz-makeham missing shape".to_string())?;
2573 let makeham = cfg
2574 .makeham
2575 .ok_or_else(|| "gompertz-makeham missing makeham".to_string())?;
2576 if !(rate.is_finite()
2577 && shape.is_finite()
2578 && makeham.is_finite()
2579 && rate > 0.0
2580 && makeham > 0.0)
2581 {
2582 return Err(
2583 "gompertz-makeham baseline requires finite positive rate, makeham, and finite shape"
2584 .to_string(),
2585 );
2586 }
2587 Ok(Some(ValidatedBaselineTarget::GompertzMakeham {
2588 rate,
2589 shape,
2590 makeham,
2591 }))
2592 }
2593 }
2594}
2595
2596fn survival_hazard_theta_partials(
2597 age: f64,
2598 cfg: &SurvivalBaselineConfig,
2599) -> Result<Option<Vec<(f64, f64)>>, String> {
2600 let Some(params) = validated_baseline_params(age, cfg, "baseline hazard partials")? else {
2601 return Ok(None);
2602 };
2603
2604 match params {
2605 ValidatedBaselineTarget::Weibull { scale, shape } => {
2606 let log_time_ratio = age.ln() - scale.ln();
2607 let cumulative_hazard = (age / scale).powf(shape);
2608 let instant_hazard = shape * cumulative_hazard / age;
2609 let eta = shape * log_time_ratio;
2610 Ok(Some(vec![
2611 (-shape * cumulative_hazard, -shape * instant_hazard),
2612 (eta * cumulative_hazard, (1.0 + eta) * instant_hazard),
2613 ]))
2614 }
2615 ValidatedBaselineTarget::Gompertz { rate, shape } => {
2616 let (cumulative_hazard, instant_hazard) = gompertz_hazard_components(age, rate, shape);
2617 let (d_cum_dshape, d_inst_dshape) =
2618 gompertz_cumulative_shape_derivative(age, rate, shape);
2619 Ok(Some(vec![
2620 (cumulative_hazard, instant_hazard),
2621 (d_cum_dshape, d_inst_dshape),
2622 ]))
2623 }
2624 ValidatedBaselineTarget::GompertzMakeham {
2625 rate,
2626 shape,
2627 makeham,
2628 } => {
2629 let (cum_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2630 let (d_cum_dshape, d_inst_dshape) =
2631 gompertz_cumulative_shape_derivative(age, rate, shape);
2632 Ok(Some(vec![
2633 (cum_gompertz, inst_gompertz),
2634 (d_cum_dshape, d_inst_dshape),
2635 (makeham * age, makeham),
2636 ]))
2637 }
2638 }
2639}
2640
2641fn survival_cumulative_and_instant_hazard(
2642 age: f64,
2643 cfg: &SurvivalBaselineConfig,
2644) -> Result<Option<(f64, f64)>, String> {
2645 let Some(params) = validated_baseline_params(age, cfg, "baseline hazard evaluation")? else {
2646 return Ok(None);
2647 };
2648
2649 match params {
2650 ValidatedBaselineTarget::Weibull { scale, shape } => {
2651 let cumulative_hazard = (age / scale).powf(shape);
2652 let instant_hazard = shape * cumulative_hazard / age;
2653 Ok(Some((cumulative_hazard, instant_hazard)))
2654 }
2655 ValidatedBaselineTarget::Gompertz { rate, shape } => {
2656 let (cumulative_hazard, instant_hazard) = gompertz_hazard_components(age, rate, shape);
2657 Ok(Some((cumulative_hazard, instant_hazard)))
2658 }
2659 ValidatedBaselineTarget::GompertzMakeham {
2660 rate,
2661 shape,
2662 makeham,
2663 } => {
2664 let (h_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2665 Ok(Some((makeham * age + h_gompertz, makeham + inst_gompertz)))
2666 }
2667 }
2668}
2669
2670#[derive(Clone, Copy, Debug)]
2671struct MarginalSlopeBaselinePoint {
2672 instant_hazard: f64,
2673 q: f64,
2674 q_t: f64,
2675}
2676
2677fn evaluate_marginal_slope_baseline_point(
2678 age: f64,
2679 cfg: &SurvivalBaselineConfig,
2680) -> Result<Option<MarginalSlopeBaselinePoint>, String> {
2681 let Some((cumulative_hazard, instant_hazard)) =
2682 survival_cumulative_and_instant_hazard(age, cfg)?
2683 else {
2684 return Ok(None);
2685 };
2686 if !(cumulative_hazard.is_finite() && cumulative_hazard > 0.0) {
2687 return Err(format!(
2688 "{} marginal-slope baseline produced non-positive cumulative hazard",
2689 survival_baseline_targetname(cfg.target)
2690 ));
2691 }
2692 if !(instant_hazard.is_finite() && instant_hazard > 0.0) {
2693 return Err(format!(
2694 "{} marginal-slope baseline produced non-positive instant hazard",
2695 survival_baseline_targetname(cfg.target)
2696 ));
2697 }
2698 let survival = (-cumulative_hazard).exp();
2699 if !(survival.is_finite() && survival > 0.0 && survival < 1.0) {
2700 return Err(format!(
2701 "{} marginal-slope baseline survival must be strictly inside (0,1), got {survival}",
2702 survival_baseline_targetname(cfg.target)
2703 ));
2704 }
2705 let q = -standard_normal_quantile(survival).map_err(|e| {
2706 format!(
2707 "{} marginal-slope baseline failed to invert survival probability {survival}: {e}",
2708 survival_baseline_targetname(cfg.target)
2709 )
2710 })?;
2711 let phi_q = normal_pdf(q);
2712 if !(phi_q.is_finite() && phi_q > 0.0) {
2713 return Err(format!(
2714 "{} marginal-slope baseline produced non-positive probit density phi(q)={phi_q} at q={q}",
2715 survival_baseline_targetname(cfg.target)
2716 ));
2717 }
2718 Ok(Some(MarginalSlopeBaselinePoint {
2719 instant_hazard,
2720 q,
2721 q_t: instant_hazard * survival / phi_q,
2722 }))
2723}
2724
2725pub fn evaluate_survival_baseline(
2728 age: f64,
2729 cfg: &SurvivalBaselineConfig,
2730) -> Result<(f64, f64), String> {
2731 if !age.is_finite() || age < 0.0 {
2732 return Err(
2733 "survival ages must be finite and non-negative for baseline target evaluation"
2734 .to_string(),
2735 );
2736 }
2737
2738 if age == 0.0 {
2749 return match cfg.target {
2750 SurvivalBaselineTarget::Linear => Ok((0.0, 0.0)),
2751 SurvivalBaselineTarget::Weibull
2752 | SurvivalBaselineTarget::Gompertz
2753 | SurvivalBaselineTarget::GompertzMakeham => Ok((f64::NEG_INFINITY, 0.0)),
2754 };
2755 }
2756
2757 let Some(params) = validated_baseline_params(age, cfg, "baseline target evaluation")? else {
2758 return Ok((0.0, 0.0));
2759 };
2760
2761 match params {
2762 ValidatedBaselineTarget::Weibull { scale, shape } => {
2763 let eta = shape * (age.ln() - scale.ln());
2764 let derivative = shape / age;
2765 Ok((eta, derivative))
2766 }
2767 ValidatedBaselineTarget::Gompertz { rate, shape } => {
2768 let (h, inst) = gompertz_hazard_components(age, rate, shape);
2769 if h <= 0.0 || !h.is_finite() {
2770 return Err(if shape.abs() < 1e-10 {
2771 "invalid gompertz baseline at near-zero shape".to_string()
2772 } else {
2773 "gompertz baseline produced non-positive cumulative hazard".to_string()
2774 });
2775 }
2776 let derivative = inst / h;
2777 Ok((h.ln(), derivative))
2778 }
2779 ValidatedBaselineTarget::GompertzMakeham {
2780 rate,
2781 shape,
2782 makeham,
2783 } => {
2784 let (h_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2785 let h = makeham * age + h_gompertz;
2786 if h <= 0.0 || !h.is_finite() {
2787 return Err(
2788 "gompertz-makeham baseline produced non-positive cumulative hazard".to_string(),
2789 );
2790 }
2791 let inst = makeham + inst_gompertz;
2792 let derivative = inst / h;
2793 Ok((h.ln(), derivative))
2794 }
2795 }
2796}
2797
2798pub fn evaluate_survival_marginal_slope_baseline(
2804 age: f64,
2805 cfg: &SurvivalBaselineConfig,
2806) -> Result<(f64, f64), String> {
2807 if age == 0.0 {
2819 return Ok((0.0, 0.0));
2820 }
2821 let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
2822 return Ok((0.0, 0.0));
2823 };
2824 Ok((point.q, point.q_t))
2825}
2826
2827pub fn marginal_slope_baseline_offset_theta_partials(
2840 age: f64,
2841 cfg: &SurvivalBaselineConfig,
2842) -> Result<Option<Vec<(f64, f64)>>, String> {
2843 let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
2844 return Ok(None);
2845 };
2846 let hazard_partials = survival_hazard_theta_partials(age, cfg)?
2847 .ok_or_else(|| "unexpected missing hazard partials for nonlinear baseline".to_string())?;
2848 let a = point.q_t / point.instant_hazard;
2849 let a_log_derivative_factor = point.q * a - 1.0;
2850 Ok(Some(
2851 hazard_partials
2852 .into_iter()
2853 .map(|(d_h_cum, d_h_inst)| {
2854 (
2855 a * d_h_cum,
2856 a * (d_h_inst + point.instant_hazard * a_log_derivative_factor * d_h_cum),
2857 )
2858 })
2859 .collect(),
2860 ))
2861}
2862
2863pub fn marginal_slope_baseline_chain_rule_hessian(
2866 age_entry: ndarray::ArrayView1<'_, f64>,
2867 age_exit: ndarray::ArrayView1<'_, f64>,
2868 cfg: &SurvivalBaselineConfig,
2869 residuals: &crate::survival::OffsetChannelResiduals,
2870 curvatures: &crate::survival::OffsetChannelCurvatures,
2871) -> Result<Option<Array2<f64>>, String> {
2872 let n = age_exit.len();
2873 if age_entry.len() != n
2874 || residuals.exit.len() != n
2875 || residuals.entry.len() != n
2876 || residuals.derivative.len() != n
2877 || curvatures.rows.len() != n
2878 {
2879 return Err(format!(
2880 "marginal_slope_baseline_chain_rule_hessian: length mismatch (age_entry={}, age_exit={}, r_exit={}, r_entry={}, r_deriv={}, h_rows={})",
2881 age_entry.len(),
2882 n,
2883 residuals.exit.len(),
2884 residuals.entry.len(),
2885 residuals.derivative.len(),
2886 curvatures.rows.len(),
2887 ));
2888 }
2889 let probe_age = age_exit.iter().copied().find(|v| v.is_finite() && *v > 0.0);
2890 let dim = match probe_age {
2891 Some(t) => match marginal_slope_baseline_offset_theta_second_partials(t, cfg)? {
2892 None => return Ok(None),
2893 Some(parts) => parts.first.len(),
2894 },
2895 None => {
2896 return Err(
2897 "marginal_slope_baseline_chain_rule_hessian: no valid positive age for dim probe"
2898 .to_string(),
2899 );
2900 }
2901 };
2902 let hessian = RowSet::All.par_try_reduce_fold(
2909 n,
2910 || Array2::<f64>::zeros((dim, dim)),
2911 |mut acc, i, _row_weight| -> Result<Array2<f64>, String> {
2912 let exit_parts =
2913 marginal_slope_baseline_offset_theta_second_partials(age_exit[i], cfg)?
2914 .ok_or_else(|| {
2915 "unexpected None from marginal-slope second partials at exit".to_string()
2916 })?;
2917 if exit_parts.first.len() != dim {
2918 return Err(
2919 "marginal_slope_baseline_chain_rule_hessian: theta_dim drifted".to_string(),
2920 );
2921 }
2922 let mut entry_parts = None;
2923 if residuals.entry[i] != 0.0 {
2924 entry_parts = Some(
2925 marginal_slope_baseline_offset_theta_second_partials(age_entry[i], cfg)?
2926 .ok_or_else(|| {
2927 "unexpected None from marginal-slope second partials at entry"
2928 .to_string()
2929 })?,
2930 );
2931 }
2932 for a in 0..dim {
2933 for b in 0..dim {
2934 let j_exit_a = exit_parts.first[a].0;
2935 let j_exit_b = exit_parts.first[b].0;
2936 let j_deriv_a = exit_parts.first[a].1;
2937 let j_deriv_b = exit_parts.first[b].1;
2938 let mut value = residuals.exit[i] * exit_parts.second[a][b].0
2939 + residuals.derivative[i] * exit_parts.second[a][b].1;
2940 if let Some(parts) = entry_parts.as_ref() {
2941 value += residuals.entry[i] * parts.second[a][b].0;
2942 }
2943 let curv = curvatures.rows[i];
2944 let j_entry_a = entry_parts.as_ref().map_or(0.0, |parts| parts.first[a].0);
2945 let j_entry_b = entry_parts.as_ref().map_or(0.0, |parts| parts.first[b].0);
2946 let ja = [j_entry_a, j_exit_a, j_deriv_a];
2947 let jb = [j_entry_b, j_exit_b, j_deriv_b];
2948 for u in 0..3 {
2949 for v in 0..3 {
2950 value += ja[u] * curv[u][v] * jb[v];
2951 }
2952 }
2953 acc[[a, b]] += value;
2954 }
2955 }
2956 Ok(acc)
2957 },
2958 |a, b| Ok(a + b),
2959 )?;
2960 Ok(Some(hessian))
2961}
2962
2963struct MarginalSlopeThetaSecondPartials {
2964 first: Vec<(f64, f64)>,
2965 second: Vec<Vec<(f64, f64)>>,
2966}
2967
2968fn marginal_slope_baseline_offset_theta_second_partials(
2969 age: f64,
2970 cfg: &SurvivalBaselineConfig,
2971) -> Result<Option<MarginalSlopeThetaSecondPartials>, String> {
2972 let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
2973 return Ok(None);
2974 };
2975 let Some((hazard, first, second)) = survival_hazard_theta_first_second(age, cfg)? else {
2976 return Ok(None);
2977 };
2978 let (cum_hazard, instant_hazard) = hazard;
2979 let survival = (-cum_hazard).exp();
2980 let a = survival / normal_pdf(point.q);
2981 let b = point.q * a - 1.0;
2982 let b_factor = a + point.q * b;
2983 let dim = first.len();
2984 let mut first_out = Vec::with_capacity(dim);
2985 let mut second_out = vec![vec![(0.0, 0.0); dim]; dim];
2986 for i in 0..dim {
2987 let (h_i, inst_i) = first[i];
2988 first_out.push((a * h_i, a * (inst_i + instant_hazard * b * h_i)));
2989 }
2990 for i in 0..dim {
2991 for j in 0..dim {
2992 let (h_i, inst_i) = first[i];
2993 let (h_j, inst_j) = first[j];
2994 let (h_ij, inst_ij) = second[i][j];
2995 let a_j = a * b * h_j;
2996 let b_j = a * h_j * b_factor;
2997 let q_ij = a * h_ij + a * b * h_i * h_j;
2998 let qt_inner_i = inst_i + instant_hazard * b * h_i;
2999 let qt_ij = a_j * qt_inner_i
3000 + a * (inst_ij + inst_j * b * h_i + instant_hazard * (b_j * h_i + b * h_ij));
3001 second_out[i][j] = (q_ij, qt_ij);
3002 }
3003 }
3004 Ok(Some(MarginalSlopeThetaSecondPartials {
3005 first: first_out,
3006 second: second_out,
3007 }))
3008}
3009
3010type HazardFirstSecond = ((f64, f64), Vec<(f64, f64)>, Vec<Vec<(f64, f64)>>);
3011
3012fn survival_hazard_theta_first_second(
3013 age: f64,
3014 cfg: &SurvivalBaselineConfig,
3015) -> Result<Option<HazardFirstSecond>, String> {
3016 let Some(hazard) = survival_cumulative_and_instant_hazard(age, cfg)? else {
3017 return Ok(None);
3018 };
3019 let first = survival_hazard_theta_partials(age, cfg)?
3020 .ok_or_else(|| "unexpected missing hazard partials".to_string())?;
3021 let dim = first.len();
3022 let mut second = vec![vec![(0.0, 0.0); dim]; dim];
3023 match cfg.target {
3024 SurvivalBaselineTarget::Linear => return Ok(None),
3025 SurvivalBaselineTarget::Weibull => {
3026 let scale = cfg
3027 .scale
3028 .ok_or_else(|| "weibull missing scale".to_string())?;
3029 let shape = cfg
3030 .shape
3031 .ok_or_else(|| "weibull missing shape".to_string())?;
3032 let log_time_ratio = age.ln() - scale.ln();
3033 let cumulative_hazard = hazard.0;
3034 let instant_hazard = hazard.1;
3035 let eta = shape * log_time_ratio;
3036 second[0][0] = (
3037 shape * shape * cumulative_hazard,
3038 shape * shape * instant_hazard,
3039 );
3040 second[0][1] = (
3041 -shape * cumulative_hazard * (1.0 + eta),
3042 -shape * instant_hazard * (2.0 + eta),
3043 );
3044 second[1][0] = second[0][1];
3045 second[1][1] = (
3046 eta * cumulative_hazard * (1.0 + eta),
3047 (eta + (1.0 + eta) * (1.0 + eta)) * instant_hazard,
3048 );
3049 }
3050 SurvivalBaselineTarget::Gompertz => {
3051 let rate = cfg
3052 .rate
3053 .ok_or_else(|| "gompertz missing rate".to_string())?;
3054 let shape = cfg
3055 .shape
3056 .ok_or_else(|| "gompertz missing shape".to_string())?;
3057 second[0][0] = first[0];
3058 second[0][1] = first[1];
3059 second[1][0] = first[1];
3060 second[1][1] = gompertz_cumulative_shape_second_derivative(age, rate, shape);
3061 }
3062 SurvivalBaselineTarget::GompertzMakeham => {
3063 let rate = cfg.rate.ok_or_else(|| "gm missing rate".to_string())?;
3064 let shape = cfg.shape.ok_or_else(|| "gm missing shape".to_string())?;
3065 second[0][0] = first[0];
3066 second[0][1] = first[1];
3067 second[1][0] = first[1];
3068 second[1][1] = gompertz_cumulative_shape_second_derivative(age, rate, shape);
3069 second[2][2] = first[2];
3070 }
3071 }
3072 Ok(Some((hazard, first, second)))
3073}
3074
3075#[inline]
3076fn gompertz_cumulative_shape_second_derivative(age: f64, rate: f64, shape: f64) -> (f64, f64) {
3077 let x = shape * age;
3078 if x.abs() < 1e-3 {
3090 let t = age;
3091 (
3092 rate * t * t * t * (1.0 / 3.0 + x / 4.0 + x * x / 10.0),
3093 rate * t * t * (1.0 + x + 0.5 * x * x),
3094 )
3095 } else {
3096 let e = x.exp();
3097 let em1 = x.exp_m1();
3098 let n = shape * age * e - em1;
3099 (
3100 rate * (age * age * e / shape - 2.0 * n / (shape * shape * shape)),
3101 rate * age * age * e,
3102 )
3103 }
3104}
3105
3106#[derive(Clone, Copy)]
3111enum BaselineOffsetEvaluator {
3112 LogCumulativeHazard,
3113 ProbitSurvival,
3114}
3115
3116impl BaselineOffsetEvaluator {
3117 fn length_error(self) -> String {
3118 match self {
3119 Self::LogCumulativeHazard => SurvivalConstructionError::IncompatibleDimensions {
3120 reason: "survival baseline offsets require matching entry/exit lengths".to_string(),
3121 }
3122 .into(),
3123 Self::ProbitSurvival => {
3124 "survival probit baseline offsets require matching entry/exit lengths".to_string()
3125 }
3126 }
3127 }
3128
3129 fn finite_error(self) -> &'static str {
3130 match self {
3131 Self::LogCumulativeHazard => "non-finite survival baseline offsets computed",
3132 Self::ProbitSurvival => "non-finite survival probit baseline offsets computed",
3133 }
3134 }
3135
3136 fn evaluate(self, age: f64, cfg: &SurvivalBaselineConfig) -> Result<(f64, f64), String> {
3137 match self {
3138 Self::LogCumulativeHazard => evaluate_survival_baseline(age, cfg),
3139 Self::ProbitSurvival => evaluate_survival_marginal_slope_baseline(age, cfg),
3140 }
3141 }
3142
3143 fn exit_is_finite(self, value: f64, age: f64) -> bool {
3144 match self {
3145 Self::LogCumulativeHazard => {
3146 value.is_finite() || (age == 0.0 && value == f64::NEG_INFINITY)
3147 }
3148 Self::ProbitSurvival => value.is_finite(),
3149 }
3150 }
3151}
3152
3153fn build_survival_offsets_with_evaluator(
3154 age_entry: &Array1<f64>,
3155 age_exit: &Array1<f64>,
3156 cfg: &SurvivalBaselineConfig,
3157 evaluator: BaselineOffsetEvaluator,
3158) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3159 if age_entry.len() != age_exit.len() {
3160 return Err(evaluator.length_error());
3161 }
3162 let n = age_entry.len();
3163 let triples: Vec<(f64, f64, f64)> = (0..n)
3166 .into_par_iter()
3167 .map(|i| -> Result<(f64, f64, f64), String> {
3168 let entry_age = age_entry[i];
3172 let e0 = if !entry_age.is_finite() {
3173 return Err(SurvivalConstructionError::DataValidationFailed {
3174 reason: format!("non-finite entry age at row {i}"),
3175 }
3176 .into());
3177 } else if entry_age <= 0.0 {
3178 0.0
3179 } else {
3180 evaluator.evaluate(entry_age, cfg)?.0
3181 };
3182 let exit_age = age_exit[i];
3183 let (e1, d1) = evaluator.evaluate(exit_age, cfg)?;
3184 if !e0.is_finite() || !evaluator.exit_is_finite(e1, exit_age) || !d1.is_finite() {
3185 return Err(SurvivalConstructionError::DataValidationFailed {
3186 reason: evaluator.finite_error().to_string(),
3187 }
3188 .into());
3189 }
3190 Ok((e0, e1, d1))
3191 })
3192 .collect::<Result<Vec<_>, String>>()?;
3193 let mut eta_entry = Array1::<f64>::zeros(n);
3194 let mut eta_exit = Array1::<f64>::zeros(n);
3195 let mut derivative_exit = Array1::<f64>::zeros(n);
3196 for (i, (e0, e1, d1)) in triples.into_iter().enumerate() {
3197 eta_entry[i] = e0;
3198 eta_exit[i] = e1;
3199 derivative_exit[i] = d1;
3200 }
3201 Ok((eta_entry, eta_exit, derivative_exit))
3202}
3203
3204pub fn build_survival_baseline_offsets(
3207 age_entry: &Array1<f64>,
3208 age_exit: &Array1<f64>,
3209 cfg: &SurvivalBaselineConfig,
3210) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3211 build_survival_offsets_with_evaluator(
3212 age_entry,
3213 age_exit,
3214 cfg,
3215 BaselineOffsetEvaluator::LogCumulativeHazard,
3216 )
3217}
3218
3219pub fn build_survival_marginal_slope_baseline_offsets(
3222 age_entry: &Array1<f64>,
3223 age_exit: &Array1<f64>,
3224 cfg: &SurvivalBaselineConfig,
3225) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3226 build_survival_offsets_with_evaluator(
3227 age_entry,
3228 age_exit,
3229 cfg,
3230 BaselineOffsetEvaluator::ProbitSurvival,
3231 )
3232}
3233
3234pub fn location_scale_uses_probit_survival_baseline(inverse_link: Option<&InverseLink>) -> bool {
3235 matches!(
3236 inverse_link,
3237 Some(
3238 InverseLink::Standard(StandardLink::Probit)
3239 | InverseLink::LatentCLogLog(_)
3240 | InverseLink::Sas(_)
3241 | InverseLink::BetaLogistic(_)
3242 | InverseLink::Mixture(_)
3243 )
3244 )
3245}
3246
3247pub fn survival_derivative_guard_for_likelihood(likelihood_mode: SurvivalLikelihoodMode) -> f64 {
3248 match likelihood_mode {
3249 SurvivalLikelihoodMode::LocationScale
3250 | SurvivalLikelihoodMode::Latent
3251 | SurvivalLikelihoodMode::LatentBinary => DEFAULT_SURVIVAL_LOCATION_SCALE_DERIVATIVE_GUARD,
3252 SurvivalLikelihoodMode::MarginalSlope => DEFAULT_SURVIVAL_MARGINAL_SLOPE_DERIVATIVE_GUARD,
3253 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull => 0.0,
3254 }
3255}
3256
3257pub fn build_survival_time_offsets_for_likelihood(
3258 age_entry: &Array1<f64>,
3259 age_exit: &Array1<f64>,
3260 baseline_cfg: &SurvivalBaselineConfig,
3261 likelihood_mode: SurvivalLikelihoodMode,
3262 inverse_link: Option<&InverseLink>,
3263) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3264 if likelihood_mode == SurvivalLikelihoodMode::MarginalSlope
3265 || (likelihood_mode == SurvivalLikelihoodMode::LocationScale
3266 && location_scale_uses_probit_survival_baseline(inverse_link))
3267 {
3268 build_survival_marginal_slope_baseline_offsets(age_entry, age_exit, baseline_cfg)
3269 } else {
3270 build_survival_baseline_offsets(age_entry, age_exit, baseline_cfg)
3271 }
3272}
3273
3274pub fn add_survival_time_derivative_guard_offset(
3275 age_entry: &Array1<f64>,
3276 age_exit: &Array1<f64>,
3277 anchor_time: f64,
3278 derivative_guard: f64,
3279 eta_offset_entry: &mut Array1<f64>,
3280 eta_offset_exit: &mut Array1<f64>,
3281 derivative_offset_exit: &mut Array1<f64>,
3282) -> Result<(), String> {
3283 if derivative_guard <= 0.0 {
3284 return Ok(());
3285 }
3286 let n = age_entry.len();
3287 if age_exit.len() != n
3288 || eta_offset_entry.len() != n
3289 || eta_offset_exit.len() != n
3290 || derivative_offset_exit.len() != n
3291 {
3292 return Err(SurvivalConstructionError::IncompatibleDimensions {
3293 reason: "survival derivative-guard offset lengths must match".to_string(),
3294 }
3295 .into());
3296 }
3297 for i in 0..n {
3298 eta_offset_entry[i] += derivative_guard * (age_entry[i] - anchor_time);
3299 eta_offset_exit[i] += derivative_guard * (age_exit[i] - anchor_time);
3300 derivative_offset_exit[i] += derivative_guard;
3301 }
3302 Ok(())
3303}
3304
3305#[derive(Clone, Debug)]
3306pub struct LatentSurvivalBaselineOffsets {
3307 pub loaded_eta_entry: Array1<f64>,
3308 pub loaded_eta_exit: Array1<f64>,
3309 pub loaded_derivative_exit: Array1<f64>,
3310 pub unloaded_mass_entry: Array1<f64>,
3311 pub unloaded_mass_exit: Array1<f64>,
3312 pub unloaded_hazard_exit: Array1<f64>,
3313}
3314
3315pub fn build_latent_survival_baseline_offsets(
3316 age_entry: &Array1<f64>,
3317 age_exit: &Array1<f64>,
3318 cfg: &SurvivalBaselineConfig,
3319 loading: HazardLoading,
3320) -> Result<LatentSurvivalBaselineOffsets, String> {
3321 if age_entry.len() != age_exit.len() {
3322 return Err(
3323 "latent survival baseline offsets require matching entry/exit lengths".to_string(),
3324 );
3325 }
3326
3327 fn gompertz_components(age: f64, rate: f64, shape: f64) -> (f64, f64) {
3328 if shape.abs() < 1e-10 {
3329 let x = shape * age;
3336 return (
3337 rate * age * (1.0 + 0.5 * x + x * x / 6.0),
3338 rate * (1.0 + x + 0.5 * x * x),
3339 );
3340 }
3341 let shape_age = shape * age;
3342 let cumulative_hazard = (rate / shape) * shape_age.exp_m1();
3343 let instant_hazard = rate * shape_age.exp();
3344 (cumulative_hazard, instant_hazard)
3345 }
3346
3347 let n = age_entry.len();
3348
3349 let rows: Vec<[f64; 6]> = (0..n)
3352 .into_par_iter()
3353 .map(|i| -> Result<[f64; 6], String> {
3354 let entry = age_entry[i];
3355 let exit = age_exit[i];
3356 if !entry.is_finite()
3357 || !exit.is_finite()
3358 || entry <= 0.0
3359 || exit <= 0.0
3360 || exit < entry
3361 {
3362 return Err(format!(
3363 "latent survival baseline offsets require finite positive entry/exit ages with exit >= entry (row {})",
3364 i + 1
3365 ));
3366 }
3367 match loading {
3368 HazardLoading::Full => {
3369 let (eta_entry, _) = evaluate_survival_baseline(entry, cfg)?;
3370 let (eta_exit, derivative_exit) = evaluate_survival_baseline(exit, cfg)?;
3371 Ok([eta_entry, eta_exit, derivative_exit, 0.0, 0.0, 0.0])
3372 }
3373 HazardLoading::LoadedVsUnloaded => {
3374 if cfg.target != SurvivalBaselineTarget::GompertzMakeham {
3375 return Err(format!(
3376 "HazardLoading::LoadedVsUnloaded requires --baseline-target gompertz-makeham, got {}",
3377 survival_baseline_targetname(cfg.target)
3378 ));
3379 }
3380 let rate = cfg.rate.ok_or_else(|| {
3381 "gompertz-makeham latent survival is missing baseline rate".to_string()
3382 })?;
3383 let shape = cfg.shape.ok_or_else(|| {
3384 "gompertz-makeham latent survival is missing baseline shape".to_string()
3385 })?;
3386 let makeham = cfg.makeham.ok_or_else(|| {
3387 "gompertz-makeham latent survival is missing baseline makeham".to_string()
3388 })?;
3389 let (loaded_entry, _) = gompertz_components(entry, rate, shape);
3390 let (loaded_exit, loaded_hazard) = gompertz_components(exit, rate, shape);
3391 if !(loaded_entry.is_finite()
3392 && loaded_entry > 0.0
3393 && loaded_exit.is_finite()
3394 && loaded_exit > 0.0
3395 && loaded_hazard.is_finite()
3396 && loaded_hazard > 0.0)
3397 {
3398 return Err(format!(
3399 "gompertz-makeham latent loaded component produced a non-positive or non-finite hazard decomposition at row {}",
3400 i + 1
3401 ));
3402 }
3403 Ok([
3404 loaded_entry.ln(),
3405 loaded_exit.ln(),
3406 loaded_hazard / loaded_exit,
3407 makeham * entry,
3408 makeham * exit,
3409 makeham,
3410 ])
3411 }
3412 }
3413 })
3414 .collect::<Result<Vec<_>, String>>()?;
3415
3416 let mut loaded_eta_entry = Array1::<f64>::zeros(n);
3417 let mut loaded_eta_exit = Array1::<f64>::zeros(n);
3418 let mut loaded_derivative_exit = Array1::<f64>::zeros(n);
3419 let mut unloaded_mass_entry = Array1::<f64>::zeros(n);
3420 let mut unloaded_mass_exit = Array1::<f64>::zeros(n);
3421 let mut unloaded_hazard_exit = Array1::<f64>::zeros(n);
3422 for (i, row) in rows.into_iter().enumerate() {
3423 loaded_eta_entry[i] = row[0];
3424 loaded_eta_exit[i] = row[1];
3425 loaded_derivative_exit[i] = row[2];
3426 unloaded_mass_entry[i] = row[3];
3427 unloaded_mass_exit[i] = row[4];
3428 unloaded_hazard_exit[i] = row[5];
3429 }
3430
3431 Ok(LatentSurvivalBaselineOffsets {
3432 loaded_eta_entry,
3433 loaded_eta_exit,
3434 loaded_derivative_exit,
3435 unloaded_mass_entry,
3436 unloaded_mass_exit,
3437 unloaded_hazard_exit,
3438 })
3439}
3440
3441pub fn build_survival_timewiggle_derivative_design(
3446 eta_exit: &Array1<f64>,
3447 derivative_exit: &Array1<f64>,
3448 knots: &Array1<f64>,
3449 degree: usize,
3450) -> Result<Array2<f64>, String> {
3451 let mut design_derivative_exit =
3452 monotone_wiggle_basis_with_derivative_order(eta_exit.view(), knots, degree, 1)?;
3453 for i in 0..design_derivative_exit.nrows() {
3454 let chain = derivative_exit[i];
3455 for j in 0..design_derivative_exit.ncols() {
3456 design_derivative_exit[[i, j]] *= chain;
3457 }
3458 }
3459 Ok(design_derivative_exit)
3460}
3461
3462pub fn build_survival_timewiggle_from_baseline(
3472 eta_entry: &Array1<f64>,
3473 eta_exit: &Array1<f64>,
3474 derivative_exit: &Array1<f64>,
3475 cfg: &LinkWiggleFormulaSpec,
3476) -> Result<SurvivalTimeWiggleBuild, String> {
3477 if eta_entry.len() != eta_exit.len() || eta_exit.len() != derivative_exit.len() {
3478 return Err(
3479 "baseline-timewiggle requires matching entry/exit/derivative lengths".to_string(),
3480 );
3481 }
3482 let all_zero = eta_entry.iter().all(|&v| v.abs() < 1e-15)
3485 && eta_exit.iter().all(|&v| v.abs() < 1e-15)
3486 && derivative_exit.iter().all(|&v| v.abs() < 1e-15);
3487 if all_zero {
3488 return Err(
3489 "timewiggle requires a non-linear scalar survival baseline target; \
3490 the provided baseline offsets are all zero (linear baseline)"
3491 .to_string(),
3492 );
3493 }
3494 let n = eta_exit.len();
3495 let mut seed = Array1::<f64>::zeros(2 * n);
3496 for i in 0..n {
3497 seed[i] = eta_entry[i];
3498 seed[n + i] = eta_exit[i];
3499 }
3500 let (primary_order, extra_orders) = split_wiggle_penalty_orders(2, &cfg.penalty_orders);
3504 let wiggle_cfg = WiggleBlockConfig {
3505 degree: cfg.degree,
3506 num_internal_knots: cfg.num_internal_knots,
3507 penalty_order: primary_order,
3508 double_penalty: cfg.double_penalty,
3509 };
3510 let (mut combined_block, knots) = buildwiggle_block_input_from_seed(seed.view(), &wiggle_cfg)?;
3511 append_selected_wiggle_penalty_orders(&mut combined_block, &extra_orders)?;
3512 let ncols = combined_block.design.ncols();
3513 Ok(SurvivalTimeWiggleBuild {
3514 nullspace_dims: combined_block.nullspace_dims.clone(),
3515 penalties: {
3516 combined_block
3517 .penalties
3518 .into_iter()
3519 .map(|ps| ps.to_global(ncols))
3520 .collect()
3521 },
3522 knots,
3523 degree: cfg.degree,
3524 ncols,
3525 })
3526}
3527
3528pub fn append_zero_tail_columns(
3529 x_entry: &mut DesignMatrix,
3530 x_exit: &mut DesignMatrix,
3531 x_derivative: &mut DesignMatrix,
3532 tail_cols: usize,
3533) {
3534 if tail_cols == 0 {
3535 return;
3536 }
3537 fn append_dense(dm: &mut DesignMatrix, tail: usize) {
3540 let old = dm.to_dense();
3541 let n = old.nrows();
3542 let p_base = old.ncols();
3543 let mut out = Array2::<f64>::zeros((n, p_base + tail));
3544 out.slice_mut(s![.., 0..p_base]).assign(&old);
3545 *dm = DesignMatrix::Dense(DenseDesignMatrix::from(out));
3546 }
3547 append_dense(x_entry, tail_cols);
3548 append_dense(x_exit, tail_cols);
3549 append_dense(x_derivative, tail_cols);
3550}
3551
3552pub fn build_time_varying_survival_covariate_template(
3563 age_entry: &Array1<f64>,
3564 age_exit: &Array1<f64>,
3565 time_k: usize,
3566 time_degree: usize,
3567 block_name: &str,
3568) -> Result<SurvivalCovariateTermBlockTemplate, String> {
3569 if time_k < time_degree + 1 {
3570 return Err(format!(
3571 "--{block_name}-time-k must be >= degree + 1 = {}, got {time_k}",
3572 time_degree + 1
3573 ));
3574 }
3575 let num_internal_knots = time_k - (time_degree + 1);
3576
3577 let log_entry = age_entry.mapv(|t| t.max(1e-12).ln());
3578 let log_exit = age_exit.mapv(|t| t.max(1e-12).ln());
3579
3580 let time_spec = BSplineBasisSpec {
3581 degree: time_degree,
3582 penalty_order: 2,
3583 knotspec: BSplineKnotSpec::Automatic {
3584 num_internal_knots: Some(num_internal_knots),
3585 placement: gam_terms::basis::BSplineKnotPlacement::Quantile,
3586 },
3587 double_penalty: false,
3588 identifiability: BSplineIdentifiability::None,
3589 boundary: OneDimensionalBoundary::Open,
3590 boundary_conditions: BSplineBoundaryConditions::default(),
3591 };
3592
3593 let time_build = build_bspline_basis_1d(log_exit.view(), &time_spec)
3594 .map_err(|e| format!("failed to build {block_name} time-margin B-spline basis: {e}"))?;
3595 let time_design_exit = time_build.design.to_dense();
3596
3597 let knots = match &time_build.metadata {
3598 BasisMetadata::BSpline1D { knots, .. } => knots.clone(),
3599 _ => {
3600 return Err(format!(
3601 "{block_name} time-margin basis returned unexpected metadata type"
3602 ));
3603 }
3604 };
3605
3606 let time_build_entry = build_bspline_basis_1d(
3607 log_entry.view(),
3608 &BSplineBasisSpec {
3609 degree: time_degree,
3610 penalty_order: 2,
3611 knotspec: BSplineKnotSpec::Provided(knots.clone()),
3612 double_penalty: false,
3613 identifiability: BSplineIdentifiability::None,
3614 boundary: OneDimensionalBoundary::Open,
3615 boundary_conditions: BSplineBoundaryConditions::default(),
3616 },
3617 )
3618 .map_err(|e| format!("failed to evaluate {block_name} time-margin basis at entry: {e}"))?;
3619 let time_design_entry = time_build_entry.design.to_dense();
3620 let p_time = time_design_exit.ncols();
3621 let mut time_design_derivative_exit = Array2::<f64>::zeros((age_exit.len(), p_time));
3622 time_design_derivative_exit
3626 .as_slice_mut()
3627 .expect("zeros are contiguous")
3628 .par_chunks_mut(p_time)
3629 .enumerate()
3630 .try_for_each(|(i, row_out)| -> Result<(), String> {
3631 let mut deriv_buf = vec![0.0_f64; p_time];
3632 evaluate_bspline_derivative_scalar(
3633 log_exit[i],
3634 knots.view(),
3635 time_degree,
3636 &mut deriv_buf,
3637 )
3638 .map_err(|e| {
3639 format!("failed to evaluate {block_name} time-margin derivative basis: {e}")
3640 })?;
3641 let chain = 1.0 / age_exit[i].max(1e-12);
3642 for j in 0..p_time {
3643 row_out[j] = deriv_buf[j] * chain;
3644 }
3645 Ok(())
3646 })?;
3647
3648 Ok(SurvivalCovariateTermBlockTemplate::TimeVarying {
3649 time_basis_entry: time_design_entry,
3650 time_basis_exit: time_design_exit,
3651 time_basis_derivative_exit: time_design_derivative_exit,
3652 time_penalties: time_build.penalties,
3653 })
3654}
3655
3656#[cfg(test)]
3657mod tests {
3658 use super::{
3659 SurvivalBaselineConfig, SurvivalBaselineTarget, SurvivalTimeBasisConfig,
3660 baseline_chain_rule_gradient, baseline_offset_theta_partials,
3661 build_survival_marginal_slope_baseline_offsets, build_survival_time_basis,
3662 build_survival_timewiggle_from_baseline, evaluate_survival_baseline,
3663 evaluate_survival_marginal_slope_baseline, fitted_weibull_baseline_from_linear_time_beta,
3664 gompertz_cumulative_shape_derivative, gompertz_cumulative_shape_second_derivative,
3665 gompertz_hazard_components, marginal_slope_baseline_chain_rule_gradient,
3666 marginal_slope_baseline_chain_rule_hessian, marginal_slope_baseline_offset_theta_partials,
3667 optimize_survival_baseline_config_with_gradient,
3668 optimize_survival_baseline_config_with_gradient_only,
3669 resolve_survival_marginal_slope_time_anchor_value, survival_baseline_config_from_theta,
3670 survival_baseline_theta_from_config,
3671 };
3672 use crate::probability::normal_cdf;
3673 use crate::survival::{OffsetChannelCurvatures, OffsetChannelResiduals};
3674 use gam_terms::inference::formula_dsl::LinkWiggleFormulaSpec;
3675 use ndarray::{Array1, Array2, array};
3676
3677 #[test]
3678 fn fitted_weibull_baseline_uses_identified_anchor_and_slope() {
3679 let fitted = fitted_weibull_baseline_from_linear_time_beta(&array![123.0, 1.75], 4.5)
3680 .expect("valid Weibull baseline");
3681 assert_eq!(fitted.target, SurvivalBaselineTarget::Weibull);
3682 assert_eq!(fitted.scale, Some(4.5));
3683 assert_eq!(fitted.shape, Some(1.75));
3684 assert_eq!(fitted.rate, None);
3685 assert_eq!(fitted.makeham, None);
3686
3687 assert!(fitted_weibull_baseline_from_linear_time_beta(&array![1.0], 4.5).is_none());
3688 assert!(fitted_weibull_baseline_from_linear_time_beta(&array![0.0, 0.0], 4.5).is_none());
3689 assert!(fitted_weibull_baseline_from_linear_time_beta(&array![0.0, 1.0], 0.0).is_none());
3690 }
3691
3692 #[test]
3693 fn survival_timewiggle_keeps_requested_order_one_penalty() {
3694 let eta_entry = array![0.1, 0.3, 0.5, 0.8];
3695 let eta_exit = array![0.4, 0.7, 1.0, 1.4];
3696 let derivative_exit = array![0.9, 1.1, 1.2, 1.3];
3697 let cfg = LinkWiggleFormulaSpec {
3698 degree: 3,
3699 num_internal_knots: 4,
3700 penalty_orders: vec![1, 2, 3],
3701 double_penalty: false,
3702 };
3703
3704 let build =
3705 build_survival_timewiggle_from_baseline(&eta_entry, &eta_exit, &derivative_exit, &cfg)
3706 .expect("build survival timewiggle");
3707
3708 assert_eq!(build.penalties.len(), 3);
3709 assert_eq!(build.nullspace_dims, vec![1, 2, 3]);
3710 assert!(build.ncols > 0);
3711 }
3712
3713 #[test]
3714 fn marginal_slope_time_anchor_defaults_to_median_exit() {
3715 let age_entry = array![9.0, 1.0, 4.0, 6.0];
3716 let age_exit = array![20.0, 12.0, 18.0, 30.0];
3717 let anchor = resolve_survival_marginal_slope_time_anchor_value(&age_entry, &age_exit, None)
3718 .expect("resolve marginal-slope default time anchor");
3719
3720 assert!(
3721 (anchor - 19.0).abs() <= 1e-12,
3722 "marginal-slope default anchor should be median exit, got {anchor}"
3723 );
3724 }
3725
3726 #[test]
3727 fn marginal_slope_time_anchor_honors_explicit_value() {
3728 let age_entry = array![9.0, 1.0, 4.0, 6.0];
3729 let age_exit = array![20.0, 12.0, 18.0, 30.0];
3730 let anchor =
3731 resolve_survival_marginal_slope_time_anchor_value(&age_entry, &age_exit, Some(7.5))
3732 .expect("resolve explicit marginal-slope time anchor");
3733
3734 assert!(
3735 (anchor - 7.5).abs() <= 1e-12,
3736 "explicit marginal-slope anchor should round-trip, got {anchor}"
3737 );
3738 }
3739
3740 #[test]
3751 fn baseline_optimizer_contracts_agree_on_shared_surface() {
3752 let curvature: Array2<f64> = array![[3.0, 0.5], [0.5, 2.0]];
3757 let theta_star: Array1<f64> = array![2.5_f64.ln(), 1.3_f64.ln()];
3758
3759 let initial = SurvivalBaselineConfig {
3762 target: SurvivalBaselineTarget::Weibull,
3763 scale: Some(1.0),
3764 shape: Some(1.0),
3765 rate: None,
3766 makeham: None,
3767 };
3768
3769 let recovered_theta = |cfg: &SurvivalBaselineConfig| -> Array1<f64> {
3772 survival_baseline_theta_from_config(cfg)
3773 .expect("config→θ")
3774 .expect("Weibull config has a θ")
3775 };
3776
3777 let curvature_cost = curvature.clone();
3780 let star_cost = theta_star.clone();
3781 let cost_at = move |cfg: &SurvivalBaselineConfig| -> Result<f64, String> {
3782 let theta = survival_baseline_theta_from_config(cfg)?
3783 .ok_or_else(|| "expected a θ for the cost surface".to_string())?;
3784 let d = &theta - &star_cost;
3785 let ad = curvature_cost.dot(&d);
3786 Ok(0.5 * d.dot(&ad))
3787 };
3788
3789 let curvature_grad = curvature.clone();
3790 let star_grad = theta_star.clone();
3791 let cost_for_grad = cost_at.clone();
3792 let result_grad_only = optimize_survival_baseline_config_with_gradient_only(
3793 &initial,
3794 "baseline parity (gradient-only)",
3795 move |cfg| {
3796 let cost = cost_for_grad(cfg)?;
3797 let theta = survival_baseline_theta_from_config(cfg)?
3798 .ok_or_else(|| "expected a θ for the gradient".to_string())?;
3799 let gradient = curvature_grad.dot(&(&theta - &star_grad));
3800 Ok((cost, gradient))
3801 },
3802 )
3803 .expect("gradient-only baseline optimization converges");
3804
3805 let curvature_hess = curvature.clone();
3806 let star_hess = theta_star.clone();
3807 let cost_for_hess = cost_at.clone();
3808 let result_grad_hess = optimize_survival_baseline_config_with_gradient(
3809 &initial,
3810 "baseline parity (gradient+Hessian)",
3811 move |cfg| {
3812 let cost = cost_for_hess(cfg)?;
3813 let theta = survival_baseline_theta_from_config(cfg)?
3814 .ok_or_else(|| "expected a θ for the gradient".to_string())?;
3815 let gradient = curvature_hess.dot(&(&theta - &star_hess));
3816 Ok((cost, gradient, curvature_hess.clone()))
3817 },
3818 )
3819 .expect("gradient+Hessian baseline optimization converges");
3820
3821 let theta_grad_only = recovered_theta(&result_grad_only);
3822 let theta_grad_hess = recovered_theta(&result_grad_hess);
3823
3824 for (label, theta) in [
3827 ("gradient-only", &theta_grad_only),
3828 ("gradient+Hessian", &theta_grad_hess),
3829 ] {
3830 let err = (theta - &theta_star)
3831 .mapv(f64::abs)
3832 .fold(0.0_f64, |a, &v| a.max(v));
3833 assert!(
3834 err <= 2e-3,
3835 "{label} contract recovered θ {theta:?} off true minimizer {theta_star:?} by {err:e}"
3836 );
3837 }
3838
3839 let pairwise_max = |a: &Array1<f64>, b: &Array1<f64>| -> f64 {
3843 (a - b).mapv(f64::abs).fold(0.0_f64, |acc, &v| acc.max(v))
3844 };
3845 assert!(
3846 pairwise_max(&theta_grad_only, &theta_grad_hess) <= 2e-3,
3847 "gradient-only vs gradient+Hessian disagree: {theta_grad_only:?} vs {theta_grad_hess:?}"
3848 );
3849 }
3850
3851 #[test]
3852 fn automatic_ispline_time_knots_are_sized_for_antiderivative_degree() {
3853 let age_entry = array![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0];
3854 let age_exit = array![2.0_f64, 3.0, 5.0, 8.0, 13.0, 21.0];
3855 let requested_degree = 3;
3856 let num_internal_knots = 1;
3857
3858 let built = build_survival_time_basis(
3859 &age_entry,
3860 &age_exit,
3861 SurvivalTimeBasisConfig::ISpline {
3862 degree: requested_degree,
3863 knots: Array1::zeros(0),
3864 keep_cols: Vec::new(),
3865 smooth_lambda: 1e-2,
3866 },
3867 Some((num_internal_knots, 1e-2)),
3868 )
3869 .expect("automatic cubic ispline with one interior knot builds");
3870
3871 let working_degree = requested_degree + 1;
3872 let knots = built.knots.expect("resolved ispline knots");
3873 assert_eq!(
3874 knots.len(),
3875 num_internal_knots + 2 * (working_degree + 1),
3876 "I-spline automatic knots must be clamped for the working B-spline degree"
3877 );
3878 assert_eq!(built.degree, Some(requested_degree));
3879 assert!(built.x_exit_time.ncols() > 0);
3880 assert_eq!(built.x_entry_time.ncols(), built.x_exit_time.ncols());
3881 assert_eq!(built.x_derivative_time.ncols(), built.x_exit_time.ncols());
3882 }
3883
3884 #[test]
3885 fn ispline_time_derivative_is_nonzero_at_right_boundary() {
3886 let age_entry = array![1.0_f64, 1.0, 1.0];
3887 let age_exit = array![4.0_f64, 4.0, 4.0];
3888 let left = 1.0_f64.ln();
3889 let right = 4.0_f64.ln();
3890 let mid = left + 0.5 * (right - left);
3891 let knots = array![left, left, left, left, mid, right, right, right, right];
3892
3893 let built = build_survival_time_basis(
3894 &age_entry,
3895 &age_exit,
3896 SurvivalTimeBasisConfig::ISpline {
3897 degree: 2,
3898 knots,
3899 keep_cols: Vec::new(),
3900 smooth_lambda: 1e-2,
3901 },
3902 None,
3903 )
3904 .expect("build right-boundary ispline time basis");
3905
3906 let derivative = built.x_derivative_time.as_dense_cow();
3907 let max_abs = derivative.iter().fold(0.0_f64, |acc, v| acc.max(v.abs()));
3908 assert!(
3909 max_abs > 1e-8,
3910 "right-boundary I-spline derivative must use the left-hand endpoint slope"
3911 );
3912 for row in derivative.rows() {
3913 assert!(
3914 row.iter().any(|v| *v > 1e-8),
3915 "each row at the right boundary needs a positive hazard derivative"
3916 );
3917 }
3918 }
3919
3920 #[test]
3921 fn ispline_time_penalty_is_psd_under_nontrivial_keep_cols() {
3922 let age_entry = array![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0];
3941 let age_exit = array![2.0_f64, 3.0, 5.0, 8.0, 13.0, 21.0];
3942 let left = 1.0_f64.ln();
3943 let right = 21.0_f64.ln();
3944 let q1 = left + 0.25 * (right - left);
3945 let mid = left + 0.5 * (right - left);
3946 let q3 = left + 0.75 * (right - left);
3947 let knots = array![
3951 left, left, left, left, q1, mid, q3, right, right, right, right
3952 ];
3953
3954 let full = build_survival_time_basis(
3956 &age_entry,
3957 &age_exit,
3958 SurvivalTimeBasisConfig::ISpline {
3959 degree: 2,
3960 knots: knots.clone(),
3961 keep_cols: Vec::new(),
3962 smooth_lambda: 1e-2,
3963 },
3964 None,
3965 )
3966 .expect("build full-width ispline time basis");
3967 let p_time_full = full
3968 .keep_cols
3969 .as_ref()
3970 .map(|k| k.len())
3971 .unwrap_or_else(|| full.x_exit_time.ncols());
3972 assert!(
3973 p_time_full >= 3,
3974 "test needs at least 3 shape-varying columns to drop an interior one; got {p_time_full}"
3975 );
3976
3977 let keep_cols: Vec<usize> = (0..p_time_full).filter(|&j| j != 1).collect();
3980
3981 let built = build_survival_time_basis(
3982 &age_entry,
3983 &age_exit,
3984 SurvivalTimeBasisConfig::ISpline {
3985 degree: 2,
3986 knots,
3987 keep_cols: keep_cols.clone(),
3988 smooth_lambda: 1e-2,
3989 },
3990 None,
3991 )
3992 .expect(
3993 "reduced ispline penalty must build (PSD contract must accept the \
3994 congruence-first / select-second ordering)",
3995 );
3996
3997 assert_eq!(
3998 built.penalties.len(),
3999 1,
4000 "the ispline time basis should carry exactly one curvature penalty"
4001 );
4002 let s = &built.penalties[0];
4003 assert_eq!(s.nrows(), keep_cols.len());
4004 assert_eq!(s.ncols(), keep_cols.len());
4005
4006 let (evals, _) = gam_linalg::faer_ndarray::FaerEigh::eigh(s, faer::Side::Lower)
4007 .expect("eigh of penalty");
4008 let evals_slice = evals.as_slice().expect("contiguous eigenvalues");
4009 let max_abs = evals_slice
4010 .iter()
4011 .copied()
4012 .fold(0.0_f64, |a, b| a.max(b.abs()))
4013 .max(1.0);
4014 let min_ev = evals_slice.iter().copied().fold(f64::INFINITY, f64::min);
4015 let tol = -100.0 * (s.nrows() as f64) * f64::EPSILON * max_abs;
4016 assert!(
4017 min_ev >= tol,
4018 "reduced I-spline time penalty must be PSD (gam#979): min eigenvalue \
4019 {min_ev:.3e} < tol {tol:.3e}, max|eig| {max_abs:.3e}"
4020 );
4021 }
4022
4023 #[test]
4024 fn marginal_slope_baseline_maps_gompertz_makeham_survival_to_probit_index() {
4025 let cfg = SurvivalBaselineConfig {
4026 target: SurvivalBaselineTarget::GompertzMakeham,
4027 scale: None,
4028 shape: Some(0.07),
4029 rate: Some(0.012),
4030 makeham: Some(0.003),
4031 };
4032 let age = 11.5;
4033 let (q, q_derivative) = evaluate_survival_marginal_slope_baseline(age, &cfg)
4034 .expect("evaluate marginal-slope gompertz-makeham baseline");
4035 let shape = cfg.shape.expect("shape");
4036 let rate = cfg.rate.expect("rate");
4037 let makeham = cfg.makeham.expect("makeham");
4038 let cumulative_hazard = makeham * age + (rate / shape) * ((shape * age).exp() - 1.0);
4039 let instant_hazard = makeham + rate * (shape * age).exp();
4040 let expected_survival = (-cumulative_hazard).exp();
4041 let actual_survival = normal_cdf(-q);
4042 assert!((actual_survival - expected_survival).abs() <= 1e-12);
4043
4044 let h = 1e-5;
4045 let q_plus = evaluate_survival_marginal_slope_baseline(age + h, &cfg)
4046 .expect("q plus")
4047 .0;
4048 let q_minus = evaluate_survival_marginal_slope_baseline(age - h, &cfg)
4049 .expect("q minus")
4050 .0;
4051 let fd = (q_plus - q_minus) / (2.0 * h);
4052 assert!((q_derivative - fd).abs() <= 1e-7);
4053 assert!(instant_hazard > 0.0);
4054 }
4055
4056 #[test]
4057 fn marginal_slope_baseline_is_evaluable_at_the_survival_curve_origin() {
4058 let configs = [
4067 SurvivalBaselineConfig {
4068 target: SurvivalBaselineTarget::Linear,
4069 scale: None,
4070 shape: None,
4071 rate: None,
4072 makeham: None,
4073 },
4074 SurvivalBaselineConfig {
4075 target: SurvivalBaselineTarget::Weibull,
4076 scale: Some(2.5),
4077 shape: Some(1.3),
4078 rate: None,
4079 makeham: None,
4080 },
4081 SurvivalBaselineConfig {
4082 target: SurvivalBaselineTarget::Gompertz,
4083 scale: None,
4084 shape: Some(0.05),
4085 rate: Some(0.01),
4086 makeham: None,
4087 },
4088 SurvivalBaselineConfig {
4089 target: SurvivalBaselineTarget::GompertzMakeham,
4090 scale: None,
4091 shape: Some(0.07),
4092 rate: Some(0.012),
4093 makeham: Some(0.003),
4094 },
4095 ];
4096 for cfg in &configs {
4097 let (q0, q0_derivative) = evaluate_survival_marginal_slope_baseline(0.0, cfg)
4100 .expect("marginal-slope baseline must be evaluable at the origin");
4101 assert_eq!(q0, 0.0);
4102 assert_eq!(q0_derivative, 0.0);
4103
4104 let (eta0, eta0_derivative) =
4108 evaluate_survival_baseline(0.0, cfg).expect("log-cum-hazard baseline at origin");
4109 assert!(eta0_derivative.is_finite());
4110 assert!(eta0.is_finite() || eta0 == f64::NEG_INFINITY);
4111
4112 let age_entry = array![0.0, 0.0];
4116 let age_exit = array![0.0, 1.5];
4117 let (entry, exit, derivative) =
4118 build_survival_marginal_slope_baseline_offsets(&age_entry, &age_exit, cfg)
4119 .expect("probit baseline offsets must build through the origin");
4120 assert!(entry.iter().all(|v| v.is_finite()));
4121 assert!(exit.iter().all(|v| v.is_finite()));
4122 assert!(derivative.iter().all(|v| v.is_finite()));
4123 assert_eq!(exit[0], 0.0);
4125 }
4126 }
4127
4128 #[test]
4129 fn marginal_slope_baseline_offsets_use_true_gompertz_makeham_survival() {
4130 let cfg = SurvivalBaselineConfig {
4131 target: SurvivalBaselineTarget::GompertzMakeham,
4132 scale: None,
4133 shape: Some(0.03),
4134 rate: Some(0.01),
4135 makeham: Some(0.002),
4136 };
4137 let age_entry = array![2.0, 4.0];
4138 let age_exit = array![5.0, 9.0];
4139 let (entry, exit, derivative) =
4140 build_survival_marginal_slope_baseline_offsets(&age_entry, &age_exit, &cfg)
4141 .expect("marginal-slope baseline offsets");
4142 for i in 0..age_entry.len() {
4143 let entry_h = cfg.makeham.expect("makeham") * age_entry[i]
4144 + (cfg.rate.expect("rate") / cfg.shape.expect("shape"))
4145 * ((cfg.shape.expect("shape") * age_entry[i]).exp() - 1.0);
4146 let exit_h = cfg.makeham.expect("makeham") * age_exit[i]
4147 + (cfg.rate.expect("rate") / cfg.shape.expect("shape"))
4148 * ((cfg.shape.expect("shape") * age_exit[i]).exp() - 1.0);
4149 assert!((normal_cdf(-entry[i]) - (-entry_h).exp()).abs() <= 1e-12);
4150 assert!((normal_cdf(-exit[i]) - (-exit_h).exp()).abs() <= 1e-12);
4151 assert!(derivative[i].is_finite() && derivative[i] > 0.0);
4152 }
4153 }
4154
4155 fn fd_marginal_slope_baseline_offset(
4156 age: f64,
4157 cfg: &SurvivalBaselineConfig,
4158 steps: &[f64],
4159 ) -> Vec<(f64, f64)> {
4160 let theta = survival_baseline_theta_from_config(cfg)
4161 .expect("theta")
4162 .expect("non-linear baseline");
4163 assert_eq!(
4164 steps.len(),
4165 theta.len(),
4166 "fd_marginal_slope_baseline_offset: step vector length must match θ dimension"
4167 );
4168 (0..theta.len())
4169 .map(|k| {
4170 let h = steps[k];
4171 let mut theta_plus = theta.clone();
4172 theta_plus[k] += h;
4173 let mut theta_minus = theta.clone();
4174 theta_minus[k] -= h;
4175 let cfg_plus =
4176 survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("plus cfg");
4177 let cfg_minus = survival_baseline_config_from_theta(cfg.target, &theta_minus)
4178 .expect("minus cfg");
4179 let (q_p, qt_p) =
4180 evaluate_survival_marginal_slope_baseline(age, &cfg_plus).expect("q+");
4181 let (q_m, qt_m) =
4182 evaluate_survival_marginal_slope_baseline(age, &cfg_minus).expect("q-");
4183 ((q_p - q_m) / (2.0 * h), (qt_p - qt_m) / (2.0 * h))
4184 })
4185 .collect()
4186 }
4187
4188 #[test]
4189 fn marginal_slope_baseline_theta_partials_match_fd_for_gompertz_makeham() {
4190 let cfg = SurvivalBaselineConfig {
4191 target: SurvivalBaselineTarget::GompertzMakeham,
4192 scale: None,
4193 shape: Some(0.04),
4194 rate: Some(0.013),
4195 makeham: Some(0.002),
4196 };
4197 let age = 17.0;
4198 let analytic = marginal_slope_baseline_offset_theta_partials(age, &cfg)
4199 .expect("partials")
4200 .expect("nonlinear");
4201 let fd = fd_marginal_slope_baseline_offset(age, &cfg, &[1e-5, 1e-5, 1e-5]);
4202 assert_eq!(analytic.len(), fd.len());
4203 for (k, ((aq, aqt), (fq, fqt))) in analytic.iter().zip(fd.iter()).enumerate() {
4204 assert_close(*aq, *fq, 1e-6, &format!("gm-probit q theta[{k}]"));
4205 assert_close(*aqt, *fqt, 1e-6, &format!("gm-probit q' theta[{k}]"));
4206 }
4207 }
4208
4209 #[test]
4210 fn marginal_slope_baseline_theta_partials_match_fd_near_zero_gompertz_shape() {
4211 let cfg = SurvivalBaselineConfig {
4212 target: SurvivalBaselineTarget::GompertzMakeham,
4213 scale: None,
4214 shape: Some(1e-14),
4215 rate: Some(0.013),
4216 makeham: Some(0.002),
4217 };
4218 let age = 17.0;
4219 let analytic = marginal_slope_baseline_offset_theta_partials(age, &cfg)
4220 .expect("partials")
4221 .expect("nonlinear");
4222 let fd = fd_marginal_slope_baseline_offset(age, &cfg, &[1e-5, 1e-11, 1e-5]);
4223 assert_eq!(analytic.len(), fd.len());
4224 for (k, ((aq, aqt), (fq, fqt))) in analytic.iter().zip(fd.iter()).enumerate() {
4225 assert_close(*aq, *fq, 1e-5, &format!("near-zero gm-probit q theta[{k}]"));
4226 assert_close(
4227 *aqt,
4228 *fqt,
4229 1e-5,
4230 &format!("near-zero gm-probit q' theta[{k}]"),
4231 );
4232 }
4233 }
4234
4235 fn shifted_quadratic_offset_residuals(
4236 age_entry: ndarray::ArrayView1<'_, f64>,
4237 age_exit: ndarray::ArrayView1<'_, f64>,
4238 base_cfg: &SurvivalBaselineConfig,
4239 candidate_cfg: &SurvivalBaselineConfig,
4240 base: &OffsetChannelResiduals,
4241 curvatures: &OffsetChannelCurvatures,
4242 ) -> OffsetChannelResiduals {
4243 let n = age_exit.len();
4244 let mut entry = base.entry.clone();
4245 let mut exit = base.exit.clone();
4246 let mut derivative = base.derivative.clone();
4247 for row in 0..n {
4248 let (_, base_exit, base_deriv) =
4249 baseline_marginal_slope_channels(age_exit[row], base_cfg);
4250 let (_, cand_exit, cand_deriv) =
4251 baseline_marginal_slope_channels(age_exit[row], candidate_cfg);
4252 let base_entry = if base.entry[row] == 0.0 {
4253 0.0
4254 } else {
4255 baseline_marginal_slope_channels(age_entry[row], base_cfg).1
4256 };
4257 let cand_entry = if base.entry[row] == 0.0 {
4258 0.0
4259 } else {
4260 baseline_marginal_slope_channels(age_entry[row], candidate_cfg).1
4261 };
4262 let delta = [
4263 cand_entry - base_entry,
4264 cand_exit - base_exit,
4265 cand_deriv - base_deriv,
4266 ];
4267 let mut shift = [0.0; 3];
4268 for i in 0..3 {
4269 for j in 0..3 {
4270 shift[i] += curvatures.rows[row][i][j] * delta[j];
4271 }
4272 }
4273 if base.entry[row] != 0.0 {
4274 entry[row] += shift[0];
4275 }
4276 exit[row] += shift[1];
4277 derivative[row] += shift[2];
4278 }
4279 OffsetChannelResiduals {
4280 entry,
4281 exit,
4282 derivative,
4283 right: base.right.clone(),
4284 }
4285 }
4286
4287 fn baseline_marginal_slope_channels(age: f64, cfg: &SurvivalBaselineConfig) -> (f64, f64, f64) {
4288 let (q, q_t) = evaluate_survival_marginal_slope_baseline(age, cfg).expect("baseline");
4289 (q, q, q_t)
4290 }
4291
4292 #[test]
4293 fn marginal_slope_baseline_chain_rule_hessian_matches_fd_gradient() {
4294 let cfg = SurvivalBaselineConfig {
4295 target: SurvivalBaselineTarget::GompertzMakeham,
4296 scale: None,
4297 shape: Some(0.025),
4298 rate: Some(0.012),
4299 makeham: Some(0.003),
4300 };
4301 let theta = survival_baseline_theta_from_config(&cfg)
4302 .expect("theta")
4303 .expect("nonlinear");
4304 let age_entry = array![2.5, 0.0, 5.0];
4305 let age_exit = array![7.5, 11.0, 15.0];
4306 let base_residuals = OffsetChannelResiduals {
4307 entry: array![0.2, 0.0, -0.1],
4308 exit: array![0.6, -0.3, 0.4],
4309 derivative: array![-0.5, 0.25, 0.15],
4310 right: Array1::<f64>::zeros(3),
4311 };
4312 let curvatures = OffsetChannelCurvatures {
4313 rows: vec![
4314 [[1.4, 0.2, -0.1], [0.2, 1.1, 0.05], [-0.1, 0.05, 0.7]],
4315 [[0.9, -0.15, 0.0], [-0.15, 1.3, 0.12], [0.0, 0.12, 0.8]],
4316 [[1.2, 0.05, 0.09], [0.05, 0.95, -0.04], [0.09, -0.04, 0.6]],
4317 ],
4318 };
4319 let analytic = marginal_slope_baseline_chain_rule_hessian(
4320 age_entry.view(),
4321 age_exit.view(),
4322 &cfg,
4323 &base_residuals,
4324 &curvatures,
4325 )
4326 .expect("hessian")
4327 .expect("nonlinear");
4328
4329 let gradient_at = |theta_candidate: &Array1<f64>| -> Array1<f64> {
4330 let candidate = survival_baseline_config_from_theta(cfg.target, theta_candidate)
4331 .expect("candidate cfg");
4332 let residuals = shifted_quadratic_offset_residuals(
4333 age_entry.view(),
4334 age_exit.view(),
4335 &cfg,
4336 &candidate,
4337 &base_residuals,
4338 &curvatures,
4339 );
4340 marginal_slope_baseline_chain_rule_gradient(
4341 age_entry.view(),
4342 age_exit.view(),
4343 &candidate,
4344 &residuals,
4345 )
4346 .expect("gradient")
4347 .expect("nonlinear")
4348 };
4349
4350 for j in 0..theta.len() {
4351 let step = if j == 1 { 2e-5 } else { 1e-5 };
4352 let mut plus = theta.clone();
4353 plus[j] += step;
4354 let mut minus = theta.clone();
4355 minus[j] -= step;
4356 let fd_col = (&gradient_at(&plus) - &gradient_at(&minus)) / (2.0 * step);
4357 for i in 0..theta.len() {
4358 assert_close(
4359 analytic[[i, j]],
4360 fd_col[i],
4361 2e-5,
4362 &format!("baseline Hessian ({i},{j})"),
4363 );
4364 }
4365 }
4366 }
4367
4368 #[test]
4369 fn marginal_slope_baseline_chain_rule_gradient_contracts_probit_partials() {
4370 let cfg = SurvivalBaselineConfig {
4371 target: SurvivalBaselineTarget::GompertzMakeham,
4372 scale: None,
4373 shape: Some(0.03),
4374 rate: Some(0.01),
4375 makeham: Some(0.002),
4376 };
4377 let age_entry = array![3.0, 6.0];
4378 let age_exit = array![8.0, 12.0];
4379 let residuals = OffsetChannelResiduals {
4380 exit: array![0.7, -0.2],
4381 entry: array![0.1, 0.4],
4382 derivative: array![1.3, -0.6],
4383 right: Array1::<f64>::zeros(2),
4384 };
4385 let grad = marginal_slope_baseline_chain_rule_gradient(
4386 age_entry.view(),
4387 age_exit.view(),
4388 &cfg,
4389 &residuals,
4390 )
4391 .expect("gradient")
4392 .expect("nonlinear");
4393
4394 let mut expected = Array1::<f64>::zeros(3);
4395 for i in 0..age_exit.len() {
4396 let exit_partials = marginal_slope_baseline_offset_theta_partials(age_exit[i], &cfg)
4397 .expect("exit partials")
4398 .expect("nonlinear");
4399 let entry_partials = marginal_slope_baseline_offset_theta_partials(age_entry[i], &cfg)
4400 .expect("entry partials")
4401 .expect("nonlinear");
4402 for k in 0..3 {
4403 expected[k] += residuals.exit[i] * exit_partials[k].0
4404 + residuals.derivative[i] * exit_partials[k].1
4405 + residuals.entry[i] * entry_partials[k].0;
4406 }
4407 }
4408 for k in 0..3 {
4409 assert_close(
4410 grad[k],
4411 expected[k],
4412 1e-12,
4413 &format!("gm-probit chain gradient theta[{k}]"),
4414 );
4415 }
4416 }
4417
4418 #[test]
4428 fn baseline_chain_rule_gradient_engine_matches_inline_reference() {
4429 let cfg = SurvivalBaselineConfig {
4430 target: SurvivalBaselineTarget::GompertzMakeham,
4431 scale: None,
4432 shape: Some(0.028),
4433 rate: Some(0.011),
4434 makeham: Some(0.0025),
4435 };
4436 let age_entry = array![3.0, 0.0, 5.5];
4439 let age_exit = array![8.0, 12.0, 16.0];
4440 let residuals = OffsetChannelResiduals {
4441 exit: array![0.7, -0.2, 0.45],
4442 entry: array![0.1, 0.0, -0.3],
4443 derivative: array![1.3, -0.6, 0.2],
4444 right: Array1::<f64>::zeros(3),
4445 };
4446
4447 let reference_gradient = |partials: &dyn Fn(
4450 f64,
4451 &SurvivalBaselineConfig,
4452 )
4453 -> Result<Option<Vec<(f64, f64)>>, String>|
4454 -> Array1<f64> {
4455 let theta_dim = partials(age_exit[0], &cfg)
4456 .expect("probe partials")
4457 .expect("nonlinear")
4458 .len();
4459 let mut acc = Array1::<f64>::zeros(theta_dim);
4460 for i in 0..age_exit.len() {
4461 let p_exit = partials(age_exit[i], &cfg)
4462 .expect("exit partials")
4463 .expect("nonlinear");
4464 let r_x = residuals.exit[i];
4465 let r_d = residuals.derivative[i];
4466 for k in 0..theta_dim {
4467 acc[k] += r_x * p_exit[k].0 + r_d * p_exit[k].1;
4468 }
4469 let r_e = residuals.entry[i];
4470 if r_e != 0.0 {
4471 let p_entry = partials(age_entry[i], &cfg)
4472 .expect("entry partials")
4473 .expect("nonlinear");
4474 for k in 0..theta_dim {
4475 acc[k] += r_e * p_entry[k].0;
4476 }
4477 }
4478 }
4479 acc
4480 };
4481
4482 let rp_engine = baseline_chain_rule_gradient(
4484 age_entry.view(),
4485 age_exit.view(),
4486 age_exit.view(),
4487 &cfg,
4488 &residuals,
4489 )
4490 .expect("rp gradient")
4491 .expect("rp nonlinear");
4492 let rp_reference = reference_gradient(&baseline_offset_theta_partials);
4493 assert_eq!(rp_engine.len(), rp_reference.len());
4494 for k in 0..rp_engine.len() {
4495 assert_close(
4496 rp_engine[k],
4497 rp_reference[k],
4498 0.0,
4499 &format!("rp engine vs inline reference theta[{k}]"),
4500 );
4501 }
4502
4503 let probit_engine = marginal_slope_baseline_chain_rule_gradient(
4505 age_entry.view(),
4506 age_exit.view(),
4507 &cfg,
4508 &residuals,
4509 )
4510 .expect("probit gradient")
4511 .expect("probit nonlinear");
4512 let probit_reference = reference_gradient(&marginal_slope_baseline_offset_theta_partials);
4513 assert_eq!(probit_engine.len(), probit_reference.len());
4514 for k in 0..probit_engine.len() {
4515 assert_close(
4516 probit_engine[k],
4517 probit_reference[k],
4518 0.0,
4519 &format!("probit engine vs inline reference theta[{k}]"),
4520 );
4521 }
4522 }
4523
4524 #[test]
4545 fn gompertz_makeham_baseline_chain_rule_gradient_matches_finite_difference() {
4546 let cfg = SurvivalBaselineConfig {
4547 target: SurvivalBaselineTarget::GompertzMakeham,
4548 scale: None,
4549 shape: Some(0.05),
4550 rate: Some(0.012),
4551 makeham: Some(0.003),
4552 };
4553 let age_entry = array![5.0, 8.0, 12.0, 0.5, 20.0, 30.0, 45.0, 60.0];
4555 let age_exit = array![10.0, 15.0, 25.0, 4.0, 35.0, 50.0, 65.0, 80.0];
4556 let residuals = OffsetChannelResiduals {
4559 exit: array![0.42, -0.18, 0.73, -0.91, 0.05, -0.27, 0.61, -0.34],
4560 entry: array![-0.12, 0.31, -0.44, 0.0, 0.16, -0.22, 0.07, -0.51],
4561 derivative: array![1.04, -0.65, 0.18, -1.21, 0.42, -0.13, 0.88, -0.27],
4562 right: Array1::<f64>::zeros(8),
4563 };
4564
4565 let analytic = baseline_chain_rule_gradient(
4566 age_entry.view(),
4567 age_exit.view(),
4568 age_exit.view(),
4569 &cfg,
4570 &residuals,
4571 )
4572 .expect("analytic gradient ok")
4573 .expect("GM baseline has a θ-gradient");
4574 assert_eq!(analytic.len(), 3, "GM θ has 3 components");
4575
4576 let loss_at_cfg = |cfg_eval: &SurvivalBaselineConfig| -> f64 {
4582 let mut acc = 0.0;
4583 for i in 0..age_exit.len() {
4584 let (eta_exit_i, od_exit_i) =
4585 evaluate_survival_baseline(age_exit[i], cfg_eval).expect("eval exit");
4586 acc += residuals.exit[i] * eta_exit_i + residuals.derivative[i] * od_exit_i;
4587 if residuals.entry[i] != 0.0 {
4588 let (eta_entry_i, _) =
4589 evaluate_survival_baseline(age_entry[i], cfg_eval).expect("eval entry");
4590 acc += residuals.entry[i] * eta_entry_i;
4591 }
4592 }
4593 acc
4594 };
4595
4596 let theta0 = survival_baseline_theta_from_config(&cfg)
4597 .expect("theta seed")
4598 .expect("GM has θ");
4599 let delta = 1e-4;
4601 let mut fd = Array1::<f64>::zeros(analytic.len());
4602 for k in 0..analytic.len() {
4603 let mut theta_plus = theta0.clone();
4604 theta_plus[k] += delta;
4605 let mut theta_minus = theta0.clone();
4606 theta_minus[k] -= delta;
4607 let cfg_plus =
4608 survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("cfg(θ+δ)");
4609 let cfg_minus =
4610 survival_baseline_config_from_theta(cfg.target, &theta_minus).expect("cfg(θ-δ)");
4611 let lp = loss_at_cfg(&cfg_plus);
4612 let lm = loss_at_cfg(&cfg_minus);
4613 fd[k] = (lp - lm) / (2.0 * delta);
4614 }
4615
4616 let analytic_norm = analytic.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
4617 let max_err = analytic
4618 .iter()
4619 .zip(fd.iter())
4620 .map(|(a, b)| (a - b).abs())
4621 .fold(0.0_f64, f64::max);
4622 let rel = max_err / (analytic_norm + 1e-12);
4623 eprintln!(
4625 "gompertz_makeham_baseline_chain_rule_gradient_matches_finite_difference: \
4626 analytic={analytic:?} fd={fd:?} max_err={max_err:.3e} \
4627 analytic_inf_norm={analytic_norm:.3e} rel={rel:.3e}"
4628 );
4629 assert!(
4630 rel < 1e-2,
4631 "analytic θ-gradient disagrees with central FD beyond 1%: \
4632 analytic={analytic:?}, fd={fd:?}, max_err={max_err:.3e}, \
4633 rel={rel:.3e} (analytic_inf_norm={analytic_norm:.3e})"
4634 );
4635 }
4636
4637 #[test]
4652 fn weibull_baseline_chain_rule_gradient_matches_finite_difference() {
4653 let cfg = SurvivalBaselineConfig {
4654 target: SurvivalBaselineTarget::Weibull,
4655 scale: Some(11.0),
4656 shape: Some(1.4),
4657 rate: None,
4658 makeham: None,
4659 };
4660 let age_entry = array![5.0, 8.0, 12.0, 0.5, 20.0, 30.0, 45.0, 60.0];
4661 let age_exit = array![10.0, 15.0, 25.0, 4.0, 35.0, 50.0, 65.0, 80.0];
4662 let residuals = OffsetChannelResiduals {
4663 exit: array![0.42, -0.18, 0.73, -0.91, 0.05, -0.27, 0.61, -0.34],
4664 entry: array![-0.12, 0.31, -0.44, 0.0, 0.16, -0.22, 0.07, -0.51],
4665 derivative: array![1.04, -0.65, 0.18, -1.21, 0.42, -0.13, 0.88, -0.27],
4666 right: Array1::<f64>::zeros(8),
4667 };
4668
4669 let analytic = baseline_chain_rule_gradient(
4670 age_entry.view(),
4671 age_exit.view(),
4672 age_exit.view(),
4673 &cfg,
4674 &residuals,
4675 )
4676 .expect("analytic gradient ok")
4677 .expect("Weibull baseline has a θ-gradient");
4678 assert_eq!(analytic.len(), 2, "Weibull θ has 2 components");
4679
4680 let loss_at_cfg = |cfg_eval: &SurvivalBaselineConfig| -> f64 {
4681 let mut acc = 0.0;
4682 for i in 0..age_exit.len() {
4683 let (eta_exit_i, od_exit_i) =
4684 evaluate_survival_baseline(age_exit[i], cfg_eval).expect("eval exit");
4685 acc += residuals.exit[i] * eta_exit_i + residuals.derivative[i] * od_exit_i;
4686 if residuals.entry[i] != 0.0 {
4687 let (eta_entry_i, _) =
4688 evaluate_survival_baseline(age_entry[i], cfg_eval).expect("eval entry");
4689 acc += residuals.entry[i] * eta_entry_i;
4690 }
4691 }
4692 acc
4693 };
4694
4695 let theta0 = survival_baseline_theta_from_config(&cfg)
4696 .expect("theta seed")
4697 .expect("Weibull has θ");
4698 let delta = 1e-4;
4699 let mut fd = Array1::<f64>::zeros(analytic.len());
4700 for k in 0..analytic.len() {
4701 let mut theta_plus = theta0.clone();
4702 theta_plus[k] += delta;
4703 let mut theta_minus = theta0.clone();
4704 theta_minus[k] -= delta;
4705 let cfg_plus =
4706 survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("cfg(θ+δ)");
4707 let cfg_minus =
4708 survival_baseline_config_from_theta(cfg.target, &theta_minus).expect("cfg(θ-δ)");
4709 let lp = loss_at_cfg(&cfg_plus);
4710 let lm = loss_at_cfg(&cfg_minus);
4711 fd[k] = (lp - lm) / (2.0 * delta);
4712 }
4713
4714 let analytic_norm = analytic.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
4715 let max_err = analytic
4716 .iter()
4717 .zip(fd.iter())
4718 .map(|(a, b)| (a - b).abs())
4719 .fold(0.0_f64, f64::max);
4720 let rel = max_err / (analytic_norm + 1e-12);
4721 eprintln!(
4722 "weibull_baseline_chain_rule_gradient_matches_finite_difference: \
4723 analytic={analytic:?} fd={fd:?} max_err={max_err:.3e} \
4724 analytic_inf_norm={analytic_norm:.3e} rel={rel:.3e}"
4725 );
4726 assert!(
4727 rel < 1e-2,
4728 "analytic θ-gradient disagrees with central FD beyond 1%: \
4729 analytic={analytic:?}, fd={fd:?}, max_err={max_err:.3e}, \
4730 rel={rel:.3e} (analytic_inf_norm={analytic_norm:.3e})"
4731 );
4732 }
4733
4734 fn fd_baseline_offset(
4747 age: f64,
4748 cfg: &SurvivalBaselineConfig,
4749 steps: &[f64],
4750 ) -> Vec<(f64, f64)> {
4751 let theta = survival_baseline_theta_from_config(cfg)
4752 .expect("theta")
4753 .expect("non-linear baseline");
4754 assert_eq!(
4755 steps.len(),
4756 theta.len(),
4757 "fd_baseline_offset: step vector length must match θ dimension"
4758 );
4759 (0..theta.len())
4760 .map(|k| {
4761 let h = steps[k];
4762 let mut theta_plus = theta.clone();
4763 theta_plus[k] += h;
4764 let mut theta_minus = theta.clone();
4765 theta_minus[k] -= h;
4766 let cfg_plus =
4767 survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("plus cfg");
4768 let cfg_minus = survival_baseline_config_from_theta(cfg.target, &theta_minus)
4769 .expect("minus cfg");
4770 let (eta_p, od_p) = evaluate_survival_baseline(age, &cfg_plus).expect("eta+");
4771 let (eta_m, od_m) = evaluate_survival_baseline(age, &cfg_minus).expect("eta-");
4772 ((eta_p - eta_m) / (2.0 * h), (od_p - od_m) / (2.0 * h))
4773 })
4774 .collect()
4775 }
4776
4777 fn assert_close(actual: f64, expected: f64, tol: f64, what: &str) {
4778 let ok = if expected.abs() < 1.0 {
4782 (actual - expected).abs() <= tol
4783 } else {
4784 (actual - expected).abs() <= tol * expected.abs().max(1.0)
4785 };
4786 assert!(
4787 ok,
4788 "{what}: analytic={actual:.6e} fd={expected:.6e} (tol={tol:.1e})"
4789 );
4790 }
4791
4792 #[test]
4793 fn gompertz_offset_partials_match_central_diff() {
4794 let cases = [
4798 (0.5_f64, 0.01_f64, 30.0_f64),
4799 (0.2, 0.05, 60.0),
4800 (1.0, 0.001, 10.0),
4801 (0.4, 5e-11, 25.0),
4802 (0.4, -5e-11, 25.0),
4803 (0.3, -0.02, 40.0),
4804 (0.8, 0.2, 5.0),
4805 ];
4806 for &(rate, shape, age) in &cases {
4807 let cfg = SurvivalBaselineConfig {
4808 target: SurvivalBaselineTarget::Gompertz,
4809 scale: None,
4810 shape: Some(shape),
4811 rate: Some(rate),
4812 makeham: None,
4813 };
4814 let analytic = baseline_offset_theta_partials(age, &cfg)
4815 .expect("ok")
4816 .expect("non-linear");
4817 let h_shape = if shape.abs() < 1e-9 { 1e-11 } else { 1e-5 };
4823 let fd = fd_baseline_offset(age, &cfg, &[1e-5, h_shape]);
4824 assert_eq!(analytic.len(), 2);
4825 assert_close(
4827 analytic[0].0,
4828 fd[0].0,
4829 1e-7,
4830 &format!("gompertz ∂eta/∂log_rate (rate={rate}, shape={shape}, age={age})"),
4831 );
4832 assert_close(
4833 analytic[0].1,
4834 fd[0].1,
4835 1e-7,
4836 &format!("gompertz ∂o_D/∂log_rate (rate={rate}, shape={shape}, age={age})"),
4837 );
4838 assert_close(
4841 analytic[1].0,
4842 fd[1].0,
4843 1e-5,
4844 &format!("gompertz ∂eta/∂shape (rate={rate}, shape={shape}, age={age})"),
4845 );
4846 assert_close(
4847 analytic[1].1,
4848 fd[1].1,
4849 1e-5,
4850 &format!("gompertz ∂o_D/∂shape (rate={rate}, shape={shape}, age={age})"),
4851 );
4852 }
4853 }
4854
4855 #[test]
4856 fn gompertz_offset_partials_log_rate_channel_is_trivial() {
4857 let cfg = SurvivalBaselineConfig {
4861 target: SurvivalBaselineTarget::Gompertz,
4862 scale: None,
4863 shape: Some(0.05),
4864 rate: Some(0.3),
4865 makeham: None,
4866 };
4867 let partials = baseline_offset_theta_partials(42.0, &cfg)
4868 .expect("ok")
4869 .expect("non-linear");
4870 assert_eq!(partials[0].0, 1.0);
4871 assert_eq!(partials[0].1, 0.0);
4872 }
4873
4874 #[test]
4875 fn gompertz_offset_partials_small_shape_taylor_agrees_with_direct_branch() {
4876 let age = 25.0;
4883 let rate = 0.4;
4884 let cfg_taylor = SurvivalBaselineConfig {
4885 target: SurvivalBaselineTarget::Gompertz,
4886 scale: None,
4887 shape: Some(0.5e-10),
4888 rate: Some(rate),
4889 makeham: None,
4890 };
4891 let cfg_direct = SurvivalBaselineConfig {
4892 target: SurvivalBaselineTarget::Gompertz,
4893 scale: None,
4894 shape: Some(2.0e-10),
4895 rate: Some(rate),
4896 makeham: None,
4897 };
4898 let p_t = baseline_offset_theta_partials(age, &cfg_taylor)
4899 .expect("ok")
4900 .expect("nl");
4901 let p_d = baseline_offset_theta_partials(age, &cfg_direct)
4902 .expect("ok")
4903 .expect("nl");
4904 assert_close(p_t[1].0, 12.5, 1e-8, "taylor ∂eta/∂shape near 0");
4906 assert_close(p_d[1].0, 12.5, 1e-8, "direct ∂eta/∂shape near 0");
4907 assert_close(p_t[1].1, 0.5, 1e-8, "taylor ∂o_D/∂shape near 0");
4909 assert_close(p_d[1].1, 0.5, 1e-8, "direct ∂o_D/∂shape near 0");
4910 }
4911
4912 #[test]
4924 fn gompertz_hazard_shape_derivatives_match_central_diff() {
4925 let cases = [
4930 (10.0_f64, 0.012_f64, 0.05_f64),
4931 (2.5, 0.5, 0.2),
4932 (15.0, 0.003, 0.01),
4933 (40.0, 0.3, 0.001),
4934 ];
4935 let h = 1e-6;
4936 for &(age, rate, shape) in &cases {
4937 let (d_cum, d_inst) = gompertz_cumulative_shape_derivative(age, rate, shape);
4939 let (cum_p, inst_p) = gompertz_hazard_components(age, rate, shape + h);
4940 let (cum_m, inst_m) = gompertz_hazard_components(age, rate, shape - h);
4941 assert_close(
4942 d_cum,
4943 (cum_p - cum_m) / (2.0 * h),
4944 1e-6,
4945 &format!("∂H_G/∂shape (age={age}, rate={rate}, shape={shape})"),
4946 );
4947 assert_close(
4948 d_inst,
4949 (inst_p - inst_m) / (2.0 * h),
4950 1e-6,
4951 &format!("∂h_G/∂shape (age={age}, rate={rate}, shape={shape})"),
4952 );
4953
4954 let (d2_cum, d2_inst) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
4956 let (dcum_p, dinst_p) = gompertz_cumulative_shape_derivative(age, rate, shape + h);
4957 let (dcum_m, dinst_m) = gompertz_cumulative_shape_derivative(age, rate, shape - h);
4958 assert_close(
4959 d2_cum,
4960 (dcum_p - dcum_m) / (2.0 * h),
4961 1e-5,
4962 &format!("∂²H_G/∂shape² (age={age}, rate={rate}, shape={shape})"),
4963 );
4964 assert_close(
4965 d2_inst,
4966 (dinst_p - dinst_m) / (2.0 * h),
4967 1e-5,
4968 &format!("∂²h_G/∂shape² (age={age}, rate={rate}, shape={shape})"),
4969 );
4970 }
4971 }
4972
4973 #[test]
4974 fn gompertz_hazard_shape_derivatives_small_shape_match_analytic_limit() {
4975 let cases = [
4987 (25.0_f64, 0.4_f64, 1e-9_f64),
4988 (100.0, 0.4, 1e-6), (100.0, 0.012, 1e-6), (50.0, 1.2, 1e-8),
4991 ];
4992 for &(age, rate, shape) in &cases {
5003 let t = age;
5004 let (d_cum, d_inst) = gompertz_cumulative_shape_derivative(age, rate, shape);
5005 assert_close(
5006 d_cum,
5007 rate * t * t / 2.0,
5008 1e-3,
5009 &format!("∂H_G/∂shape limit (age={age}, shape={shape})"),
5010 );
5011 assert_close(
5012 d_inst,
5013 rate * t,
5014 1e-3,
5015 &format!("∂h_G/∂shape limit (age={age}, shape={shape})"),
5016 );
5017
5018 let (d2_cum, d2_inst) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
5019 assert_close(
5020 d2_cum,
5021 rate * t * t * t / 3.0,
5022 1e-3,
5023 &format!("∂²H_G/∂shape² limit (age={age}, shape={shape})"),
5024 );
5025 assert_close(
5026 d2_inst,
5027 rate * t * t,
5028 1e-3,
5029 &format!("∂²h_G/∂shape² limit (age={age}, shape={shape})"),
5030 );
5031 }
5032 }
5033
5034 #[test]
5035 fn gompertz_second_shape_derivative_is_accurate_in_old_pivot_gap() {
5036 let age = 100.0;
5043 let rate = 0.4;
5044 let t = age;
5045 let truth = rate * t * t * t / 3.0; for k in 5..=12 {
5052 let shape = 10f64.powi(-(k as i32)); let (d2_cum, _) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
5054 assert_close(
5055 d2_cum,
5056 truth,
5057 1e-3,
5058 &format!("∂²H_G/∂shape² in old-pivot gap (age={age}, shape=1e-{k})"),
5059 );
5060 }
5061 }
5062
5063 #[test]
5064 fn weibull_offset_partials_match_central_diff() {
5065 let cases = [
5066 (0.5_f64, 1.2_f64, 25.0_f64),
5067 (2.0, 0.8, 60.0),
5068 (0.1, 3.0, 10.0),
5069 ];
5070 for &(scale, shape, age) in &cases {
5071 let cfg = SurvivalBaselineConfig {
5072 target: SurvivalBaselineTarget::Weibull,
5073 scale: Some(scale),
5074 shape: Some(shape),
5075 rate: None,
5076 makeham: None,
5077 };
5078 let analytic = baseline_offset_theta_partials(age, &cfg)
5079 .expect("ok")
5080 .expect("nl");
5081 let fd = fd_baseline_offset(age, &cfg, &[1e-5, 1e-5]);
5082 assert_eq!(analytic.len(), 2);
5083 for k in 0..2 {
5084 assert_close(
5085 analytic[k].0,
5086 fd[k].0,
5087 1e-7,
5088 &format!("weibull ∂eta/∂θ[{k}] (scale={scale}, shape={shape}, age={age})"),
5089 );
5090 assert_close(
5091 analytic[k].1,
5092 fd[k].1,
5093 1e-7,
5094 &format!("weibull ∂o_D/∂θ[{k}] (scale={scale}, shape={shape}, age={age})"),
5095 );
5096 }
5097 assert_eq!(analytic[0].1, 0.0);
5099 }
5100 }
5101
5102 #[test]
5103 fn gompertz_makeham_offset_partials_match_central_diff() {
5104 let cases = [
5105 (0.3_f64, 0.05_f64, 0.002_f64, 40.0_f64),
5106 (0.5, 0.01, 0.01, 25.0),
5107 (0.2, 0.001, 0.005, 60.0),
5108 (0.4, 5e-11, 0.01, 25.0),
5109 (0.4, -5e-11, 0.01, 25.0),
5110 (0.8, 0.2, 0.05, 5.0),
5111 ];
5112 for &(rate, shape, makeham, age) in &cases {
5113 let cfg = SurvivalBaselineConfig {
5114 target: SurvivalBaselineTarget::GompertzMakeham,
5115 scale: None,
5116 shape: Some(shape),
5117 rate: Some(rate),
5118 makeham: Some(makeham),
5119 };
5120 let analytic = baseline_offset_theta_partials(age, &cfg)
5121 .expect("ok")
5122 .expect("nl");
5123 let h_shape = if shape.abs() < 1e-9 { 1e-11 } else { 1e-5 };
5127 let fd = fd_baseline_offset(age, &cfg, &[1e-5, h_shape, 1e-5]);
5128 assert_eq!(analytic.len(), 3);
5129 for k in 0..3 {
5130 assert_close(
5131 analytic[k].0,
5132 fd[k].0,
5133 1e-5,
5134 &format!(
5135 "gm ∂eta/∂θ[{k}] (rate={rate}, shape={shape}, mk={makeham}, age={age})"
5136 ),
5137 );
5138 assert_close(
5139 analytic[k].1,
5140 fd[k].1,
5141 1e-5,
5142 &format!(
5143 "gm ∂o_D/∂θ[{k}] (rate={rate}, shape={shape}, mk={makeham}, age={age})"
5144 ),
5145 );
5146 }
5147 }
5148 }
5149
5150 #[test]
5151 fn linear_baseline_has_no_theta_partials() {
5152 let cfg = SurvivalBaselineConfig {
5153 target: SurvivalBaselineTarget::Linear,
5154 scale: None,
5155 shape: None,
5156 rate: None,
5157 makeham: None,
5158 };
5159 assert!(baseline_offset_theta_partials(5.0, &cfg).unwrap().is_none());
5160 }
5161
5162 #[test]
5163 fn baseline_offset_partials_reject_non_positive_ages() {
5164 let cfg = SurvivalBaselineConfig {
5165 target: SurvivalBaselineTarget::Gompertz,
5166 scale: None,
5167 shape: Some(0.01),
5168 rate: Some(0.5),
5169 makeham: None,
5170 };
5171 assert!(baseline_offset_theta_partials(0.0, &cfg).is_err());
5172 assert!(baseline_offset_theta_partials(-1.0, &cfg).is_err());
5173 assert!(baseline_offset_theta_partials(f64::NAN, &cfg).is_err());
5174 }
5175
5176 #[test]
5182 fn chain_rule_gradient_single_obs_reduces_to_pointwise_contract() {
5183 let cfg = SurvivalBaselineConfig {
5184 target: SurvivalBaselineTarget::Gompertz,
5185 scale: None,
5186 shape: Some(0.05),
5187 rate: Some(0.3),
5188 makeham: None,
5189 };
5190 let age_entry = array![10.0_f64];
5191 let age_exit = array![25.0_f64];
5192 let residuals = OffsetChannelResiduals {
5193 exit: array![0.7_f64],
5194 entry: array![-0.2_f64],
5195 derivative: array![-0.4_f64],
5196 right: Array1::<f64>::zeros(1),
5197 };
5198 let grad = baseline_chain_rule_gradient(
5199 age_entry.view(),
5200 age_exit.view(),
5201 age_exit.view(),
5202 &cfg,
5203 &residuals,
5204 )
5205 .expect("ok")
5206 .expect("non-linear");
5207 let p_exit = baseline_offset_theta_partials(age_exit[0], &cfg)
5209 .unwrap()
5210 .unwrap();
5211 let p_entry = baseline_offset_theta_partials(age_entry[0], &cfg)
5212 .unwrap()
5213 .unwrap();
5214 for k in 0..p_exit.len() {
5215 let expected = 0.7 * p_exit[k].0 + (-0.4) * p_exit[k].1 + (-0.2) * p_entry[k].0;
5216 assert!(
5217 (grad[k] - expected).abs() < 1e-12,
5218 "chain-rule contract mismatch at k={k}: got={:.6e} expected={:.6e}",
5219 grad[k],
5220 expected
5221 );
5222 }
5223 }
5224
5225 #[test]
5228 fn chain_rule_gradient_skips_entry_call_for_origin_entry_rows() {
5229 let cfg = SurvivalBaselineConfig {
5230 target: SurvivalBaselineTarget::Gompertz,
5231 scale: None,
5232 shape: Some(0.05),
5233 rate: Some(0.3),
5234 makeham: None,
5235 };
5236 let age_entry = array![0.0_f64, 5.0_f64];
5237 let age_exit = array![10.0_f64, 20.0_f64];
5238 let residuals = OffsetChannelResiduals {
5239 exit: array![0.5_f64, 0.3_f64],
5240 entry: array![0.0_f64, -0.1_f64], derivative: array![-0.2_f64, 0.0_f64],
5242 right: Array1::<f64>::zeros(2),
5243 };
5244 let grad = baseline_chain_rule_gradient(
5246 age_entry.view(),
5247 age_exit.view(),
5248 age_exit.view(),
5249 &cfg,
5250 &residuals,
5251 )
5252 .expect("must not fail on origin-entry row with r_entry=0")
5253 .expect("non-linear");
5254 assert_eq!(grad.len(), 2);
5255 let p_exit_0 = baseline_offset_theta_partials(10.0, &cfg).unwrap().unwrap();
5257 let p_exit_1 = baseline_offset_theta_partials(20.0, &cfg).unwrap().unwrap();
5258 let p_entry_1 = baseline_offset_theta_partials(5.0, &cfg).unwrap().unwrap();
5259 for k in 0..2 {
5260 let expected = 0.5 * p_exit_0[k].0
5261 + (-0.2) * p_exit_0[k].1
5262 + 0.3 * p_exit_1[k].0
5263 + (-0.1) * p_entry_1[k].0;
5264 assert!(
5265 (grad[k] - expected).abs() < 1e-12,
5266 "origin-entry contract at k={k}: got={:.6e} expected={:.6e}",
5267 grad[k],
5268 expected
5269 );
5270 }
5271 }
5272
5273 #[test]
5275 fn chain_rule_gradient_linear_target_returns_none() {
5276 let cfg = SurvivalBaselineConfig {
5277 target: SurvivalBaselineTarget::Linear,
5278 scale: None,
5279 shape: None,
5280 rate: None,
5281 makeham: None,
5282 };
5283 let age_entry = array![1.0_f64];
5284 let age_exit = array![2.0_f64];
5285 let residuals = OffsetChannelResiduals {
5286 exit: array![0.1_f64],
5287 entry: array![0.0_f64],
5288 derivative: array![0.0_f64],
5289 right: Array1::<f64>::zeros(1),
5290 };
5291 let grad = baseline_chain_rule_gradient(
5292 age_entry.view(),
5293 age_exit.view(),
5294 age_exit.view(),
5295 &cfg,
5296 &residuals,
5297 )
5298 .expect("ok");
5299 assert!(grad.is_none());
5300 }
5301
5302 #[test]
5321 fn chain_rule_gradient_matches_fd_of_nll_through_offset_perturbation() {
5322 let cfg = SurvivalBaselineConfig {
5325 target: SurvivalBaselineTarget::Gompertz,
5326 scale: None,
5327 shape: Some(0.03),
5328 rate: Some(0.25),
5329 makeham: None,
5330 };
5331 let age_entry = array![0.0_f64, 5.0, 8.0];
5332 let age_exit = array![4.0_f64, 12.0, 20.0];
5333 let weights = array![1.0_f64, 2.0, 0.5];
5336 let events = [1.0_f64, 1.0, 0.0];
5337 let eta_entry_vals = [-100.0_f64, 0.5, 0.8]; let eta_exit_vals = [0.4_f64, 0.9, 1.3];
5342 let s_vals = [0.7_f64, 1.1, 1.5];
5343 let (r_x, r_e, r_d) = {
5344 let mut rx = Array1::<f64>::zeros(3);
5345 let mut re = Array1::<f64>::zeros(3);
5346 let mut rd = Array1::<f64>::zeros(3);
5347 for i in 0..3 {
5348 let w = weights[i];
5349 let d = events[i];
5350 rx[i] = w * (eta_exit_vals[i].exp() - d);
5351 re[i] = if i == 0 {
5352 0.0 } else {
5354 -w * eta_entry_vals[i].exp()
5355 };
5356 rd[i] = if d > 0.0 { -w * d / s_vals[i] } else { 0.0 };
5357 }
5358 (rx, re, rd)
5359 };
5360 let residuals = OffsetChannelResiduals {
5361 exit: r_x.clone(),
5362 entry: r_e.clone(),
5363 derivative: r_d.clone(),
5364 right: Array1::<f64>::zeros(3),
5365 };
5366 let grad = baseline_chain_rule_gradient(
5367 age_entry.view(),
5368 age_exit.view(),
5369 age_exit.view(),
5370 &cfg,
5371 &residuals,
5372 )
5373 .expect("ok")
5374 .expect("non-linear");
5375
5376 let nll = |theta_plus: &Array1<f64>| -> f64 {
5381 let cfg_p = survival_baseline_config_from_theta(cfg.target, theta_plus).expect("cfg_p");
5382 let mut sum = 0.0_f64;
5383 for i in 0..3 {
5384 let (eta_x_p, d_x_p) = evaluate_survival_baseline(age_exit[i], &cfg_p).unwrap();
5385 let base = evaluate_survival_baseline(age_exit[i], &cfg).unwrap();
5386 let d_eta_x = eta_x_p - base.0;
5387 let d_d_x = d_x_p - base.1;
5388 let eta_exit_new = eta_exit_vals[i] + d_eta_x;
5389 let s_new = s_vals[i] + d_d_x;
5390 let interval_entry = if i == 0 {
5391 0.0_f64
5392 } else {
5393 let (eta_e_p, _) = evaluate_survival_baseline(age_entry[i], &cfg_p).unwrap();
5394 let base_e = evaluate_survival_baseline(age_entry[i], &cfg).unwrap();
5395 let d_eta_e = eta_e_p - base_e.0;
5396 let eta_entry_new = eta_entry_vals[i] + d_eta_e;
5397 eta_entry_new.exp()
5398 };
5399 let w = weights[i];
5400 let d = events[i];
5401 let nll_i =
5402 w * (eta_exit_new.exp() - interval_entry - d * (eta_exit_new + s_new.ln()));
5403 sum += nll_i;
5404 }
5405 sum
5406 };
5407
5408 let theta_base = survival_baseline_theta_from_config(&cfg).unwrap().unwrap();
5409 let h = 1e-6;
5410 for k in 0..theta_base.len() {
5411 let mut tp = theta_base.clone();
5412 let mut tm = theta_base.clone();
5413 tp[k] += h;
5414 tm[k] -= h;
5415 let fd = (nll(&tp) - nll(&tm)) / (2.0 * h);
5416 assert!(
5417 (grad[k] - fd).abs() < 1e-5 * grad[k].abs().max(1.0),
5418 "chain-rule θ[{k}]: analytic={:.6e} fd={:.6e}",
5419 grad[k],
5420 fd
5421 );
5422 }
5423 }
5424
5425 #[test]
5427 fn chain_rule_gradient_rejects_length_mismatch() {
5428 let cfg = SurvivalBaselineConfig {
5429 target: SurvivalBaselineTarget::Gompertz,
5430 scale: None,
5431 shape: Some(0.05),
5432 rate: Some(0.3),
5433 makeham: None,
5434 };
5435 let age_entry = array![1.0_f64, 2.0]; let age_exit = array![5.0_f64, 6.0, 7.0]; let residuals = OffsetChannelResiduals {
5438 exit: array![0.1_f64, 0.2, 0.3],
5439 entry: array![0.0_f64, 0.0, 0.0],
5440 derivative: array![0.0_f64, 0.0, 0.0],
5441 right: Array1::<f64>::zeros(3),
5442 };
5443 let err = baseline_chain_rule_gradient(
5444 age_entry.view(),
5445 age_exit.view(),
5446 age_exit.view(),
5447 &cfg,
5448 &residuals,
5449 )
5450 .expect_err("length mismatch must error");
5451 assert!(err.contains("length mismatch"), "err={err}");
5452 }
5453}