1use crate::probability::{normal_pdf, standard_normal_quantile};
13use crate::survival::location_scale::{
14 DEFAULT_SURVIVAL_LOCATION_SCALE_DERIVATIVE_GUARD, ResidualDistribution,
15 SurvivalCovariateTermBlockTemplate, SurvivalCovariateTimeBasis,
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_function_penalties,
21 buildwiggle_block_input_from_seed, monotone_wiggle_basis_with_derivative_order,
22 split_wiggle_penalty_orders,
23};
24use gam_linalg::matrix::{
25 DenseDesignMatrix, DesignMatrix, SparseDesignMatrix, symmetrize_in_place,
26};
27use gam_problem::outer_subsample::RowSet;
28use gam_problem::{InverseLink, StandardLink};
29use gam_terms::basis::{
30 BSplineBasisSpec, BSplineBoundaryConditions, BSplineIdentifiability, BSplineKnotSpec,
31 BasisMetadata, BasisOptions, Dense, KnotSource, OneDimensionalBoundary, build_bspline_basis_1d,
32 create_basis, evaluate_bspline_derivative_scalar,
33};
34use gam_terms::inference::formula_dsl::LinkWiggleFormulaSpec;
35use ndarray::{Array1, Array2, Array3, array, s};
36use rayon::prelude::*;
37
38#[derive(Clone, Debug)]
55pub enum SurvivalConstructionError {
56 InvalidConfig { reason: String },
59 MissingColumn { reason: String },
62 IncompatibleDimensions { reason: String },
65 DataValidationFailed { reason: String },
69 BasisConstructionFailed { reason: String },
73 UnsupportedDistribution { reason: String },
76}
77
78impl_reason_error_boilerplate! {
79 SurvivalConstructionError {
80 InvalidConfig,
81 MissingColumn,
82 IncompatibleDimensions,
83 DataValidationFailed,
84 BasisConstructionFailed,
85 UnsupportedDistribution,
86 }
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum SurvivalBaselineTarget {
95 Linear,
99 Weibull,
104 Gompertz,
109 GompertzMakeham,
114}
115
116#[derive(Clone, Debug)]
117pub struct SurvivalBaselineConfig {
118 pub target: SurvivalBaselineTarget,
119 pub scale: Option<f64>,
120 pub shape: Option<f64>,
121 pub rate: Option<f64>,
122 pub makeham: Option<f64>,
123}
124
125pub fn fitted_weibull_baseline_from_linear_time_beta(
134 beta: &Array1<f64>,
135 anchor: f64,
136) -> Option<SurvivalBaselineConfig> {
137 if beta.is_empty() {
138 return None;
139 }
140 let shape = beta[0];
141 if !shape.is_finite() || shape <= 0.0 || !anchor.is_finite() || anchor <= 0.0 {
142 return None;
143 }
144 Some(SurvivalBaselineConfig {
145 target: SurvivalBaselineTarget::Weibull,
146 scale: Some(anchor),
147 shape: Some(shape),
148 rate: None,
149 makeham: None,
150 })
151}
152
153#[derive(Clone, Debug)]
154pub enum SurvivalTimeBasisConfig {
155 None,
156 Linear,
157 BSpline {
158 degree: usize,
159 knots: Array1<f64>,
160 smooth_lambda: f64,
161 },
162 ISpline {
200 degree: usize,
201 knots: Array1<f64>,
202 keep_cols: Vec<usize>,
203 smooth_lambda: f64,
204 },
205}
206
207#[derive(Clone, Debug, PartialEq)]
221pub struct SavedSurvivalTimeBasis {
222 pub basisname: String,
223 pub degree: Option<usize>,
224 pub knots: Option<Vec<f64>>,
225 pub keep_cols: Option<Vec<usize>>,
226 pub smooth_lambda: Option<f64>,
227 pub anchor: f64,
228}
229
230impl SavedSurvivalTimeBasis {
231 pub fn from_build(build: &SurvivalTimeBuildOutput, anchor: f64) -> Self {
234 Self {
235 basisname: build.basisname.clone(),
236 degree: build.degree,
237 knots: build.knots.clone(),
238 keep_cols: build.keep_cols.clone(),
239 smooth_lambda: build.smooth_lambda,
240 anchor,
241 }
242 }
243}
244
245#[derive(Clone)]
246pub struct SurvivalTimeBuildOutput {
247 pub x_entry_time: DesignMatrix,
248 pub x_exit_time: DesignMatrix,
249 pub x_derivative_time: DesignMatrix,
250 pub penalties: Vec<Array2<f64>>,
251 pub nullspace_dims: Vec<usize>,
253 pub basisname: String,
254 pub degree: Option<usize>,
255 pub knots: Option<Vec<f64>>,
256 pub keep_cols: Option<Vec<usize>>,
257 pub smooth_lambda: Option<f64>,
258}
259
260pub const SURVIVAL_TIME_FLOOR: f64 = 1e-9;
261
262pub const SURVIVAL_DELAYED_ENTRY_THRESHOLD: f64 = 1e-8;
268
269const SURVIVAL_TIME_SMOOTH_LAMBDA_SEED: f64 = 1e-2;
277
278const GOMPERTZ_DEFAULT_SHAPE_SEED: f64 = 0.01;
286
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum SurvivalLikelihoodMode {
289 Transformation,
290 Weibull,
291 LocationScale,
292 MarginalSlope,
293 Latent,
294 LatentBinary,
295}
296
297pub struct SurvivalTimeWiggleBuild {
298 pub penalties: Vec<Array2<f64>>,
299 pub nullspace_dims: Vec<usize>,
300 pub knots: Array1<f64>,
301 pub degree: usize,
302 pub ncols: usize,
303}
304
305pub fn normalize_survival_time_pair(
310 entry_raw: f64,
311 exit_raw: f64,
312 row_index: usize,
313) -> Result<(f64, f64), String> {
314 if !entry_raw.is_finite() || !exit_raw.is_finite() {
315 return Err(SurvivalConstructionError::DataValidationFailed {
316 reason: format!("non-finite survival times at row {}", row_index + 1),
317 }
318 .into());
319 }
320 if entry_raw < 0.0 || exit_raw < 0.0 {
321 return Err(SurvivalConstructionError::DataValidationFailed {
322 reason: format!("negative survival times at row {}", row_index + 1),
323 }
324 .into());
325 }
326
327 let entry = entry_raw.max(SURVIVAL_TIME_FLOOR);
328 let exit = exit_raw.max(entry + SURVIVAL_TIME_FLOOR);
329 Ok((entry, exit))
330}
331
332pub fn survival_basis_supports_structural_monotonicity(basisname: &str) -> bool {
337 basisname.eq_ignore_ascii_case("ispline")
338}
339
340pub fn require_structural_survival_time_basis(
341 basisname: &str,
342 context: &str,
343) -> Result<(), String> {
344 if survival_basis_supports_structural_monotonicity(basisname) {
345 return Ok(());
346 }
347 Err(SurvivalConstructionError::UnsupportedDistribution {
348 reason: format!(
349 "{context} requires a structural monotone survival time basis, but got '{basisname}'. \
350Only `ispline` is accepted here because its basis functions enforce a monotone cumulative time effect by construction. \
351`{basisname}` can fit non-monotone shapes, which can break survival semantics. \
352Re-run with `--time-basis ispline`."
353 ),
354 }
355 .into())
356}
357
358pub fn parse_survival_baseline_config(
363 target_raw: &str,
364 scale: Option<f64>,
365 shape: Option<f64>,
366 rate: Option<f64>,
367 makeham: Option<f64>,
368) -> Result<SurvivalBaselineConfig, String> {
369 let target = match target_raw.to_ascii_lowercase().as_str() {
370 "linear" => SurvivalBaselineTarget::Linear,
371 "weibull" => SurvivalBaselineTarget::Weibull,
372 "gompertz" => SurvivalBaselineTarget::Gompertz,
373 "gompertz-makeham" => SurvivalBaselineTarget::GompertzMakeham,
374 other => {
375 return Err(SurvivalConstructionError::UnsupportedDistribution {
376 reason: format!(
377 "unsupported --baseline-target '{other}'; use linear|weibull|gompertz|gompertz-makeham"
378 ),
379 }
380 .into());
381 }
382 };
383
384 match target {
385 SurvivalBaselineTarget::Linear => Ok(SurvivalBaselineConfig {
386 target,
387 scale: None,
388 shape: None,
389 rate: None,
390 makeham: None,
391 }),
392 SurvivalBaselineTarget::Weibull => {
393 let scale = scale.ok_or_else(|| {
394 "--baseline-target weibull requires --baseline-scale > 0".to_string()
395 })?;
396 let shape = shape.ok_or_else(|| {
397 "--baseline-target weibull requires --baseline-shape > 0".to_string()
398 })?;
399 if !scale.is_finite() || scale <= 0.0 || !shape.is_finite() || shape <= 0.0 {
400 return Err(
401 "weibull baseline requires finite positive --baseline-scale and --baseline-shape"
402 .to_string(),
403 );
404 }
405 Ok(SurvivalBaselineConfig {
406 target,
407 scale: Some(scale),
408 shape: Some(shape),
409 rate: None,
410 makeham: None,
411 })
412 }
413 SurvivalBaselineTarget::Gompertz => {
414 let rate = rate.unwrap_or(1.0);
415 let shape = shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED);
416 if !rate.is_finite() || rate <= 0.0 || !shape.is_finite() {
417 return Err(
418 "gompertz baseline requires finite --baseline-shape and positive --baseline-rate"
419 .to_string(),
420 );
421 }
422 Ok(SurvivalBaselineConfig {
423 target,
424 scale: None,
425 shape: Some(shape),
426 rate: Some(rate),
427 makeham: None,
428 })
429 }
430 SurvivalBaselineTarget::GompertzMakeham => {
431 let rate = rate.unwrap_or(0.5);
432 let shape = shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED);
433 let makeham = makeham.unwrap_or(0.5);
434 if !rate.is_finite()
435 || rate <= 0.0
436 || !shape.is_finite()
437 || !makeham.is_finite()
438 || makeham <= 0.0
439 {
440 return Err(
441 "gompertz-makeham baseline requires finite --baseline-shape, positive --baseline-rate, and positive --baseline-makeham"
442 .to_string(),
443 );
444 }
445 Ok(SurvivalBaselineConfig {
446 target,
447 scale: None,
448 shape: Some(shape),
449 rate: Some(rate),
450 makeham: Some(makeham),
451 })
452 }
453 }
454}
455
456pub fn parse_survival_likelihood_mode(raw: &str) -> Result<SurvivalLikelihoodMode, String> {
461 match raw.to_ascii_lowercase().as_str() {
462 "transformation" => Ok(SurvivalLikelihoodMode::Transformation),
463 "weibull" => Ok(SurvivalLikelihoodMode::Weibull),
464 "location-scale" => Ok(SurvivalLikelihoodMode::LocationScale),
465 "marginal-slope" => Ok(SurvivalLikelihoodMode::MarginalSlope),
466 "latent" => Ok(SurvivalLikelihoodMode::Latent),
467 "latent-binary" => Ok(SurvivalLikelihoodMode::LatentBinary),
468 other => Err(SurvivalConstructionError::UnsupportedDistribution {
469 reason: format!(
470 "unsupported --survival-likelihood '{other}'; use transformation|weibull|location-scale|marginal-slope|latent|latent-binary"
471 ),
472 }
473 .into()),
474 }
475}
476
477pub const fn survival_likelihood_modename(mode: SurvivalLikelihoodMode) -> &'static str {
478 match mode {
479 SurvivalLikelihoodMode::Transformation => "transformation",
480 SurvivalLikelihoodMode::Weibull => "weibull",
481 SurvivalLikelihoodMode::LocationScale => "location-scale",
482 SurvivalLikelihoodMode::MarginalSlope => "marginal-slope",
483 SurvivalLikelihoodMode::Latent => "latent",
484 SurvivalLikelihoodMode::LatentBinary => "latent-binary",
485 }
486}
487
488pub fn parse_survival_distribution(raw: &str) -> Result<ResidualDistribution, String> {
489 match raw.to_ascii_lowercase().as_str() {
490 "gaussian" | "probit" => Ok(ResidualDistribution::Gaussian),
491 "gumbel" | "cloglog" => Ok(ResidualDistribution::Gumbel),
492 "logistic" | "logit" => Ok(ResidualDistribution::Logistic),
493 other => Err(SurvivalConstructionError::UnsupportedDistribution {
494 reason: format!(
495 "unsupported survmodel(distribution='{other}'); accepted: gaussian / probit, gumbel / cloglog, logistic / logit"
496 ),
497 }
498 .into()),
499 }
500}
501
502pub const fn survival_baseline_targetname(target: SurvivalBaselineTarget) -> &'static str {
503 match target {
504 SurvivalBaselineTarget::Linear => "linear",
505 SurvivalBaselineTarget::Weibull => "weibull",
506 SurvivalBaselineTarget::Gompertz => "gompertz",
507 SurvivalBaselineTarget::GompertzMakeham => "gompertz-makeham",
508 }
509}
510
511pub fn positive_survival_time_seed(age_exit: &Array1<f64>) -> f64 {
512 let sum = age_exit
513 .iter()
514 .copied()
515 .filter(|value| value.is_finite() && *value > 0.0)
516 .sum::<f64>();
517 let count = age_exit
518 .iter()
519 .filter(|value| value.is_finite() && **value > 0.0)
520 .count()
521 .max(1);
522 (sum / count as f64).max(SURVIVAL_TIME_FLOOR)
523}
524
525pub fn initial_survival_baseline_config_for_fit(
526 target_raw: &str,
527 scale: Option<f64>,
528 shape: Option<f64>,
529 rate: Option<f64>,
530 makeham: Option<f64>,
531 age_exit: &Array1<f64>,
532) -> Result<SurvivalBaselineConfig, String> {
533 let target = match target_raw.trim().to_ascii_lowercase().as_str() {
534 "linear" => SurvivalBaselineTarget::Linear,
535 "weibull" => SurvivalBaselineTarget::Weibull,
536 "gompertz" => SurvivalBaselineTarget::Gompertz,
537 "gompertz-makeham" => SurvivalBaselineTarget::GompertzMakeham,
538 other => {
539 return Err(SurvivalConstructionError::UnsupportedDistribution {
540 reason: format!(
541 "unsupported --baseline-target '{other}'; use linear|weibull|gompertz|gompertz-makeham"
542 ),
543 }
544 .into());
545 }
546 };
547 let time_scale_seed = positive_survival_time_seed(age_exit);
548 let cfg = match target {
549 SurvivalBaselineTarget::Linear => SurvivalBaselineConfig {
550 target,
551 scale: None,
552 shape: None,
553 rate: None,
554 makeham: None,
555 },
556 SurvivalBaselineTarget::Weibull => SurvivalBaselineConfig {
557 target,
558 scale: Some(scale.unwrap_or(time_scale_seed)),
559 shape: Some(shape.unwrap_or(1.0)),
560 rate: None,
561 makeham: None,
562 },
563 SurvivalBaselineTarget::Gompertz => SurvivalBaselineConfig {
564 target,
565 scale: None,
566 shape: Some(shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED)),
567 rate: Some(rate.unwrap_or(1.0 / time_scale_seed)),
568 makeham: None,
569 },
570 SurvivalBaselineTarget::GompertzMakeham => SurvivalBaselineConfig {
571 target,
572 scale: None,
573 shape: Some(shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED)),
574 rate: Some(rate.unwrap_or(0.5 / time_scale_seed)),
575 makeham: Some(makeham.unwrap_or(0.5 / time_scale_seed)),
576 },
577 };
578 parse_survival_baseline_config(
579 survival_baseline_targetname(cfg.target),
580 cfg.scale,
581 cfg.shape,
582 cfg.rate,
583 cfg.makeham,
584 )
585}
586
587pub fn survival_baseline_theta_from_config(
588 cfg: &SurvivalBaselineConfig,
589) -> Result<Option<Array1<f64>>, String> {
590 let theta = match cfg.target {
591 SurvivalBaselineTarget::Linear => None,
592 SurvivalBaselineTarget::Weibull => Some(array![
593 cfg.scale
594 .ok_or_else(|| "missing weibull baseline scale".to_string())?
595 .ln(),
596 cfg.shape
597 .ok_or_else(|| "missing weibull baseline shape".to_string())?
598 .ln(),
599 ]),
600 SurvivalBaselineTarget::Gompertz => Some(array![
601 cfg.rate
602 .ok_or_else(|| "missing gompertz baseline rate".to_string())?
603 .ln(),
604 cfg.shape
605 .ok_or_else(|| "missing gompertz baseline shape".to_string())?,
606 ]),
607 SurvivalBaselineTarget::GompertzMakeham => Some(array![
608 cfg.rate
609 .ok_or_else(|| "missing gompertz-makeham baseline rate".to_string())?
610 .ln(),
611 cfg.shape
612 .ok_or_else(|| "missing gompertz-makeham baseline shape".to_string())?,
613 cfg.makeham
614 .ok_or_else(|| "missing gompertz-makeham baseline makeham".to_string())?
615 .ln(),
616 ]),
617 };
618 if let Some(theta) = theta.as_ref() {
619 if theta.iter().any(|value| !value.is_finite()) {
620 return Err(format!(
621 "{} baseline theta coordinates must be finite",
622 survival_baseline_targetname(cfg.target)
623 ));
624 }
625 survival_baseline_config_from_theta(cfg.target, theta)?;
629 }
630 Ok(theta)
631}
632
633pub fn survival_baseline_config_from_theta(
634 target: SurvivalBaselineTarget,
635 theta: &Array1<f64>,
636) -> Result<SurvivalBaselineConfig, String> {
637 let cfg = match target {
638 SurvivalBaselineTarget::Linear => SurvivalBaselineConfig {
639 target,
640 scale: None,
641 shape: None,
642 rate: None,
643 makeham: None,
644 },
645 SurvivalBaselineTarget::Weibull => {
646 if theta.len() != 2 {
647 return Err(SurvivalConstructionError::IncompatibleDimensions {
648 reason: format!(
649 "weibull baseline parameter dimension mismatch: expected 2, got {}",
650 theta.len()
651 ),
652 }
653 .into());
654 }
655 SurvivalBaselineConfig {
656 target,
657 scale: Some(theta[0].exp()),
658 shape: Some(theta[1].exp()),
659 rate: None,
660 makeham: None,
661 }
662 }
663 SurvivalBaselineTarget::Gompertz => {
664 if theta.len() != 2 {
665 return Err(SurvivalConstructionError::IncompatibleDimensions {
666 reason: format!(
667 "gompertz baseline parameter dimension mismatch: expected 2, got {}",
668 theta.len()
669 ),
670 }
671 .into());
672 }
673 SurvivalBaselineConfig {
674 target,
675 scale: None,
676 shape: Some(theta[1]),
677 rate: Some(theta[0].exp()),
678 makeham: None,
679 }
680 }
681 SurvivalBaselineTarget::GompertzMakeham => {
682 if theta.len() != 3 {
683 return Err(SurvivalConstructionError::IncompatibleDimensions {
684 reason: format!(
685 "gompertz-makeham baseline parameter dimension mismatch: expected 3, got {}",
686 theta.len()
687 ),
688 }
689 .into());
690 }
691 SurvivalBaselineConfig {
692 target,
693 scale: None,
694 shape: Some(theta[1]),
695 rate: Some(theta[0].exp()),
696 makeham: Some(theta[2].exp()),
697 }
698 }
699 };
700 parse_survival_baseline_config(
701 survival_baseline_targetname(cfg.target),
702 cfg.scale,
703 cfg.shape,
704 cfg.rate,
705 cfg.makeham,
706 )
707}
708
709#[derive(Clone, Copy, Debug, PartialEq, Eq)]
722enum BaselineDerivativeContract {
723 GradientOnly,
726 GradientHessian,
729}
730
731impl BaselineDerivativeContract {
732 fn configure(
737 self,
738 problem: gam_solve::rho_optimizer::OuterProblem,
739 ) -> gam_solve::rho_optimizer::OuterProblem {
740 use gam_problem::{DeclaredHessianForm, Derivative};
741 match self {
742 BaselineDerivativeContract::GradientOnly => problem
745 .with_gradient(Derivative::Analytic)
746 .with_hessian(DeclaredHessianForm::Unavailable)
747 .with_tolerance(1e-4)
748 .with_max_iter(240),
749 BaselineDerivativeContract::GradientHessian => problem
750 .with_gradient(Derivative::Analytic)
751 .with_hessian(DeclaredHessianForm::Either)
752 .with_tolerance(1e-4)
753 .with_max_iter(240),
754 }
755 }
756}
757
758fn run_baseline_theta_optimizer<Fc, Fe>(
769 initial: &SurvivalBaselineConfig,
770 context: &str,
771 contract: BaselineDerivativeContract,
772 cost_fn: Fc,
773 eval_fn: Fe,
774) -> Result<SurvivalBaselineConfig, String>
775where
776 Fc: FnMut(&mut (), &Array1<f64>) -> Result<f64, crate::model_types::EstimationError>,
777 Fe: FnMut(
778 &mut (),
779 &Array1<f64>,
780 ) -> Result<gam_problem::OuterEval, crate::model_types::EstimationError>,
781{
782 use gam_solve::rho_optimizer::OuterProblem;
783 let Some(seed) = survival_baseline_theta_from_config(initial)? else {
784 return Ok(initial.clone());
785 };
786 let dim = seed.len();
787 let target = initial.target;
788 let lower = seed.mapv(|v| v - 6.0);
789 let upper = seed.mapv(|v| v + 6.0);
790 let problem = contract
791 .configure(OuterProblem::new(dim).with_prefer_gradient_only(true))
792 .with_bounds(lower, upper)
793 .with_initial_rho(seed.clone())
794 .with_seed_config(crate::seeding::SeedConfig {
795 max_seeds: 1,
796 seed_budget: 1,
797 num_auxiliary_trailing: dim,
798 ..Default::default()
799 });
800 let mut obj = problem.build_objective(
801 (),
802 cost_fn,
803 eval_fn,
804 None::<fn(&mut ())>,
805 None::<
806 fn(
807 &mut (),
808 &Array1<f64>,
809 ) -> Result<gam_problem::EfsEval, crate::model_types::EstimationError>,
810 >,
811 );
812 let result = problem
813 .run(&mut obj, context)
814 .map_err(|e| format!("{context} failed: {e}"))?;
815 if !result.converged {
816 return Err(SurvivalConstructionError::InvalidConfig {
817 reason: format!(
818 "{context} did not converge after {} iterations (final_objective={:.6e}, final_grad_norm={})",
819 result.iterations,
820 result.final_value,
821 result.final_grad_norm_report(),
822 ),
823 }
824 .into());
825 }
826 survival_baseline_config_from_theta(target, &result.rho)
827}
828
829fn run_baseline_theta_optimizer_with_eval<F>(
842 initial: &SurvivalBaselineConfig,
843 context: &str,
844 contract: BaselineDerivativeContract,
845 objective: F,
846) -> Result<SurvivalBaselineConfig, String>
847where
848 F: FnMut(&SurvivalBaselineConfig) -> Result<gam_problem::OuterEval, String>,
849{
850 let target = initial.target;
851 let engine_context = context.to_string();
852 let objective = std::rc::Rc::new(std::cell::RefCell::new(objective));
853 let eval_at = move |obj: &std::rc::Rc<std::cell::RefCell<F>>,
854 theta: &Array1<f64>|
855 -> Result<gam_problem::OuterEval, crate::model_types::EstimationError> {
856 let cfg = survival_baseline_config_from_theta(target, theta)
857 .map_err(crate::model_types::EstimationError::InvalidInput)?;
858 let eval =
859 obj.borrow_mut()(&cfg).map_err(crate::model_types::EstimationError::InvalidInput)?;
860 if eval.gradient.len() != theta.len() {
861 return Err(crate::model_types::EstimationError::InvalidInput(format!(
862 "{engine_context}: baseline gradient dimension mismatch: got {}, expected {}",
863 eval.gradient.len(),
864 theta.len()
865 )));
866 }
867 if let gam_problem::HessianValue::Dense(ref h) = eval.hessian {
868 if h.nrows() != theta.len() || h.ncols() != theta.len() {
869 return Err(crate::model_types::EstimationError::InvalidInput(format!(
870 "{engine_context}: baseline Hessian dimension mismatch: got {}x{}, expected {}x{}",
871 h.nrows(),
872 h.ncols(),
873 theta.len(),
874 theta.len()
875 )));
876 }
877 }
878 Ok(eval)
879 };
880 let cost_objective = std::rc::Rc::clone(&objective);
881 let cost_eval = eval_at.clone();
882 let cost_fn = move |_: &mut (), theta: &Array1<f64>| {
883 cost_eval(&cost_objective, theta).map(|eval| eval.cost)
884 };
885 let eval_fn = move |_: &mut (), theta: &Array1<f64>| eval_at(&objective, theta);
886 run_baseline_theta_optimizer(initial, context, contract, cost_fn, eval_fn)
887}
888
889pub fn optimize_survival_baseline_config_with_gradient_only<F>(
900 initial: &SurvivalBaselineConfig,
901 context: &str,
902 mut objective: F,
903) -> Result<SurvivalBaselineConfig, String>
904where
905 F: FnMut(&SurvivalBaselineConfig) -> Result<(f64, Array1<f64>), String>,
906{
907 use gam_problem::{HessianValue, OuterEval};
908 run_baseline_theta_optimizer_with_eval(
909 initial,
910 context,
911 BaselineDerivativeContract::GradientOnly,
912 move |cfg| {
913 let (cost, gradient) = objective(cfg)?;
914 Ok(OuterEval {
915 cost,
916 gradient,
917 hessian: HessianValue::Unavailable,
918 inner_beta_hint: None,
919 })
920 },
921 )
922}
923
924pub fn optimize_survival_baseline_config_with_gradient<F>(
929 initial: &SurvivalBaselineConfig,
930 context: &str,
931 mut objective: F,
932) -> Result<SurvivalBaselineConfig, String>
933where
934 F: FnMut(&SurvivalBaselineConfig) -> Result<(f64, Array1<f64>, Array2<f64>), String>,
935{
936 use gam_problem::{HessianValue, OuterEval};
937 run_baseline_theta_optimizer_with_eval(
938 initial,
939 context,
940 BaselineDerivativeContract::GradientHessian,
941 move |cfg| {
942 let (cost, gradient, hessian) = objective(cfg)?;
943 Ok(OuterEval {
944 cost,
945 gradient,
946 hessian: HessianValue::Dense(hessian),
947 inner_beta_hint: None,
948 })
949 },
950 )
951}
952
953pub fn parse_survival_time_basis_config(
958 time_basis: &str,
959 time_degree: usize,
960 time_num_internal_knots: usize,
961 time_smooth_lambda: f64,
962) -> Result<SurvivalTimeBasisConfig, String> {
963 match time_basis.to_ascii_lowercase().as_str() {
964 "none" => Ok(SurvivalTimeBasisConfig::None),
965 "ispline" => {
966 if time_degree < 1 {
967 return Err(
968 "time-basis degree must be >= 1 for ispline time basis (CLI: --time-degree; Python: time_degree=)"
969 .to_string(),
970 );
971 }
972 if time_num_internal_knots == 0 {
973 return Err(
974 "time-basis must have > 0 internal knots for ispline time basis (CLI: --time-num-internal-knots; Python: time_num_internal_knots=)"
975 .to_string(),
976 );
977 }
978 if !time_smooth_lambda.is_finite() || time_smooth_lambda < 0.0 {
979 return Err(
980 "time-basis smoothing lambda must be finite and >= 0 (CLI: --time-smooth-lambda; Python: time_smooth_lambda=)"
981 .to_string(),
982 );
983 }
984 Ok(SurvivalTimeBasisConfig::ISpline {
985 degree: time_degree,
986 knots: Array1::zeros(0),
987 keep_cols: Vec::new(),
988 smooth_lambda: time_smooth_lambda,
989 })
990 }
991 "linear" | "bspline" => {
992 match require_structural_survival_time_basis(time_basis, "survival model configuration")
999 {
1000 Err(e) => Err(e),
1001 Ok(()) => Err(format!(
1002 "internal: structural-basis check accepted non-structural \
1003 survival time basis '{time_basis}'"
1004 )),
1005 }
1006 }
1007 other => Err(format!(
1008 "unsupported --time-basis '{other}'; accepted values: ispline, none"
1009 )),
1010 }
1011}
1012
1013pub fn build_survival_time_basis(
1018 age_entry: &Array1<f64>,
1019 age_exit: &Array1<f64>,
1020 cfg: SurvivalTimeBasisConfig,
1021 infer_knots_if_needed: Option<(usize, f64)>,
1022) -> Result<SurvivalTimeBuildOutput, String> {
1023 fn checked_log_survival_times(times: &Array1<f64>, label: &str) -> Result<Array1<f64>, String> {
1024 if let Some(row) = times.iter().position(|t| !t.is_finite()) {
1025 return Err(SurvivalConstructionError::DataValidationFailed {
1026 reason: format!(
1027 "survival time basis requires finite {label} times (row {})",
1028 row + 1
1029 ),
1030 }
1031 .into());
1032 }
1033 if let Some(row) = times.iter().position(|t| *t < 0.0) {
1034 return Err(SurvivalConstructionError::DataValidationFailed {
1035 reason: format!(
1036 "survival time basis requires non-negative {label} times (row {})",
1037 row + 1
1038 ),
1039 }
1040 .into());
1041 }
1042 Ok(times.mapv(|t| t.max(SURVIVAL_TIME_FLOOR).ln()))
1043 }
1044
1045 let n = age_entry.len();
1046 if n != age_exit.len() {
1047 return Err(SurvivalConstructionError::IncompatibleDimensions {
1048 reason: "survival time basis requires matching entry/exit lengths".to_string(),
1049 }
1050 .into());
1051 }
1052 for i in 0..n {
1053 if age_exit[i] < age_entry[i] {
1054 return Err(format!(
1055 "survival time basis requires exit times >= entry times (row {})",
1056 i + 1
1057 ));
1058 }
1059 }
1060 let log_entry = checked_log_survival_times(age_entry, "entry")?;
1061 let log_exit = checked_log_survival_times(age_exit, "exit")?;
1062
1063 fn survival_time_knot_input(log_entry: &Array1<f64>, log_exit: &Array1<f64>) -> Array1<f64> {
1064 let n = log_entry.len();
1065 let entry_range = log_entry
1066 .iter()
1067 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1068 (lo.min(v), hi.max(v))
1069 });
1070 let entry_degenerate = (entry_range.1 - entry_range.0).abs() < 1e-8;
1071 if entry_degenerate {
1072 log_exit.clone()
1073 } else {
1074 let mut combined = Array1::<f64>::zeros(2 * n);
1075 for i in 0..n {
1076 combined[i] = log_entry[i];
1077 combined[n + i] = log_exit[i];
1078 }
1079 combined
1080 }
1081 }
1082
1083 fn data_capped_internal_knots(
1106 combined: &Array1<f64>,
1107 degree: usize,
1108 requested_internal_knots: usize,
1109 ) -> usize {
1110 if requested_internal_knots == 0 {
1111 return 0;
1112 }
1113 let mut sorted: Vec<f64> = combined.iter().copied().collect();
1114 sorted.sort_by(f64::total_cmp);
1115 let minval = sorted.first().copied().unwrap_or(0.0);
1116 let maxval = sorted.last().copied().unwrap_or(minval);
1117 if minval == maxval {
1118 return 1.min(requested_internal_knots);
1120 }
1121 let scale = (maxval - minval).abs().max(1.0);
1122 let tol = 1e-12 * scale;
1123 let mut distinct_interior = 0usize;
1126 let mut last: Option<f64> = None;
1127 for &x in &sorted {
1128 if x <= minval + tol || x >= maxval - tol {
1129 continue;
1130 }
1131 if last.is_some_and(|prev| (x - prev).abs() <= tol) {
1132 continue;
1133 }
1134 distinct_interior += 1;
1135 last = Some(x);
1136 }
1137 let mut cap = requested_internal_knots.min(distinct_interior.max(1));
1140 let n_distinct = {
1146 let mut count = 0usize;
1147 let mut last: Option<f64> = None;
1148 for &x in &sorted {
1149 if last.is_some_and(|prev| (x - prev).abs() <= tol) {
1150 continue;
1151 }
1152 count += 1;
1153 last = Some(x);
1154 }
1155 count
1156 };
1157 let dim_budget = n_distinct / 4;
1158 let dim_cap = dim_budget.saturating_sub(degree);
1159 cap = cap.min(dim_cap.max(1));
1160 cap.max(1)
1161 }
1162
1163 fn infer_survival_time_knots(
1164 combined: &Array1<f64>,
1165 knot_degree: usize,
1166 validation_degree: usize,
1167 num_internal_knots: usize,
1168 basis_options: BasisOptions,
1169 ) -> Result<Array1<f64>, String> {
1170 let num_internal_knots =
1176 data_capped_internal_knots(combined, validation_degree, num_internal_knots);
1177
1178 fn quantile_knot_inference_needs_uniform_fallback(
1179 combined: &Array1<f64>,
1180 num_internal_knots: usize,
1181 ) -> bool {
1182 if num_internal_knots == 0 || combined.is_empty() {
1183 return false;
1184 }
1185
1186 let mut sorted: Vec<f64> = combined.iter().copied().collect();
1187 sorted.sort_by(f64::total_cmp);
1188 let minval = sorted[0];
1189 let maxval = *sorted.last().unwrap_or(&minval);
1190 if minval == maxval {
1191 return false;
1192 }
1193
1194 let scale = (maxval - minval).abs().max(1.0);
1195 let tol = 1e-12 * scale;
1196 let mut support = Vec::with_capacity(sorted.len());
1197 let mut last: Option<f64> = None;
1198 for &x in &sorted {
1199 if x <= minval + tol || x >= maxval - tol {
1200 continue;
1201 }
1202 if last.map(|prev| (x - prev).abs() <= tol).unwrap_or(false) {
1203 continue;
1204 }
1205 support.push(x);
1206 last = Some(x);
1207 }
1208 if support.is_empty() {
1209 return true;
1210 }
1211
1212 let n = support.len();
1213 let mut prev_q = minval;
1214 for j in 1..=num_internal_knots {
1215 let p = j as f64 / (num_internal_knots + 1) as f64;
1216 let pos = p * (n.saturating_sub(1) as f64);
1217 let lo = pos.floor() as usize;
1218 let hi = pos.ceil() as usize;
1219 let frac = pos - lo as f64;
1220 let q = if lo == hi {
1221 support[lo]
1222 } else {
1223 support[lo] * (1.0 - frac) + support[hi] * frac
1224 }
1225 .clamp(minval, maxval);
1226 if q <= prev_q + tol || q >= maxval - tol {
1227 return true;
1228 }
1229 prev_q = q;
1230 }
1231
1232 false
1233 }
1234
1235 let inferwith =
1236 |placement: gam_terms::basis::BSplineKnotPlacement| -> Result<Array1<f64>, String> {
1237 let built = build_bspline_basis_1d(
1238 combined.view(),
1239 &BSplineBasisSpec {
1240 degree: knot_degree,
1241 penalty_order: 2,
1242 knotspec: BSplineKnotSpec::Automatic {
1243 num_internal_knots: Some(num_internal_knots),
1244 placement,
1245 },
1246 double_penalty: false,
1247 identifiability: BSplineIdentifiability::None,
1248 boundary: OneDimensionalBoundary::Open,
1249 boundary_conditions: BSplineBoundaryConditions::default(),
1250 },
1251 )
1252 .map_err(|e| format!("failed to infer survival time knots: {e}"))?;
1253 let knots = match built.metadata {
1254 BasisMetadata::BSpline1D { knots, .. } => knots,
1255 _ => {
1256 return Err(
1257 "internal error: expected BSpline1D metadata for survival time basis"
1258 .to_string(),
1259 );
1260 }
1261 };
1262 create_basis::<Dense>(
1271 combined.view(),
1272 KnotSource::Provided(knots.view()),
1273 validation_degree,
1274 basis_options,
1275 )
1276 .map_err(|e| e.to_string())?;
1277 Ok(knots)
1278 };
1279
1280 if quantile_knot_inference_needs_uniform_fallback(combined, num_internal_knots) {
1281 inferwith(gam_terms::basis::BSplineKnotPlacement::Uniform)
1282 } else {
1283 inferwith(gam_terms::basis::BSplineKnotPlacement::Quantile)
1284 }
1285 }
1286
1287 match cfg {
1288 SurvivalTimeBasisConfig::None => Ok(SurvivalTimeBuildOutput {
1289 x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1290 x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1291 x_derivative_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1292 penalties: Vec::new(),
1293 nullspace_dims: Vec::new(),
1294 basisname: "none".to_string(),
1295 degree: None,
1296 knots: None,
1297 keep_cols: None,
1298 smooth_lambda: None,
1299 }),
1300 SurvivalTimeBasisConfig::Linear => {
1301 let mut x_entry_time = Array2::<f64>::zeros((n, 1));
1315 let mut x_exit_time = Array2::<f64>::zeros((n, 1));
1316 let mut x_derivative_time = Array2::<f64>::zeros((n, 1));
1317 for i in 0..n {
1318 x_entry_time[[i, 0]] = log_entry[i];
1319 x_exit_time[[i, 0]] = log_exit[i];
1320 x_derivative_time[[i, 0]] = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1321 }
1322 Ok(SurvivalTimeBuildOutput {
1323 x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_entry_time)),
1324 x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_exit_time)),
1325 x_derivative_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_derivative_time)),
1326 penalties: Vec::new(),
1327 nullspace_dims: Vec::new(),
1328 basisname: "linear".to_string(),
1329 degree: None,
1330 knots: None,
1331 keep_cols: None,
1332 smooth_lambda: None,
1333 })
1334 }
1335 SurvivalTimeBasisConfig::BSpline {
1336 degree,
1337 knots,
1338 smooth_lambda,
1339 } => {
1340 let knotvec = if knots.is_empty() {
1341 let (num_internal_knots, _) = infer_knots_if_needed.ok_or_else(|| {
1342 "internal error: bspline time basis requested without knot source".to_string()
1343 })?;
1344 let combined = survival_time_knot_input(&log_entry, &log_exit);
1345 infer_survival_time_knots(
1346 &combined,
1347 degree,
1348 degree,
1349 num_internal_knots,
1350 BasisOptions::value(),
1351 )?
1352 } else {
1353 knots
1354 };
1355
1356 let entry_basis = build_bspline_basis_1d(
1357 log_entry.view(),
1358 &BSplineBasisSpec {
1359 degree,
1360 penalty_order: 2,
1361 knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1362 double_penalty: false,
1363 identifiability: BSplineIdentifiability::None,
1364 boundary: OneDimensionalBoundary::Open,
1365 boundary_conditions: BSplineBoundaryConditions::default(),
1366 },
1367 )
1368 .map_err(|e| format!("failed to build bspline entry basis: {e}"))?;
1369 let exit_basis = build_bspline_basis_1d(
1370 log_exit.view(),
1371 &BSplineBasisSpec {
1372 degree,
1373 penalty_order: 2,
1374 knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1375 double_penalty: false,
1376 identifiability: BSplineIdentifiability::None,
1377 boundary: OneDimensionalBoundary::Open,
1378 boundary_conditions: BSplineBoundaryConditions::default(),
1379 },
1380 )
1381 .map_err(|e| format!("failed to build bspline exit basis: {e}"))?;
1382
1383 let p_time = exit_basis.design.ncols();
1384 let mut deriv_triplets = Vec::with_capacity(n * (degree + 1));
1388 let mut deriv_buf = vec![0.0_f64; p_time];
1389 for i in 0..n {
1390 deriv_buf.fill(0.0);
1391 evaluate_bspline_derivative_scalar(
1392 log_exit[i],
1393 knotvec.view(),
1394 degree,
1395 &mut deriv_buf,
1396 )
1397 .map_err(|e| format!("failed to evaluate bspline derivative: {e}"))?;
1398 let chain = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1399 for j in 0..p_time {
1400 let v = deriv_buf[j] * chain;
1401 if v.abs() > 1e-15 {
1402 deriv_triplets.push(faer::sparse::Triplet::new(i, j, v));
1403 }
1404 }
1405 }
1406 let x_derivative_time =
1407 match faer::sparse::SparseColMat::try_new_from_triplets(n, p_time, &deriv_triplets)
1408 {
1409 Ok(sparse) => DesignMatrix::Sparse(SparseDesignMatrix::new(sparse)),
1410 Err(_) => {
1411 let mut dense = Array2::<f64>::zeros((n, p_time));
1413 for &faer::sparse::Triplet { row, col, val } in &deriv_triplets {
1414 dense[[row, col]] = val;
1415 }
1416 DesignMatrix::Dense(DenseDesignMatrix::from(dense))
1417 }
1418 };
1419
1420 let nullspace_dims = entry_basis
1421 .active_penalties
1422 .iter()
1423 .map(|penalty| penalty.nullity)
1424 .collect();
1425 let penalties = entry_basis
1426 .active_penalties
1427 .into_iter()
1428 .map(|penalty| penalty.matrix)
1429 .collect();
1430
1431 Ok(SurvivalTimeBuildOutput {
1432 x_entry_time: entry_basis.design,
1433 x_exit_time: exit_basis.design,
1434 x_derivative_time,
1435 nullspace_dims,
1436 penalties,
1437 basisname: "bspline".to_string(),
1438 degree: Some(degree),
1439 knots: Some(knotvec.to_vec()),
1440 keep_cols: None,
1441 smooth_lambda: Some(smooth_lambda),
1442 })
1443 }
1444 SurvivalTimeBasisConfig::ISpline {
1445 degree,
1446 knots,
1447 keep_cols,
1448 smooth_lambda,
1449 } => {
1450 let bspline_degree = degree
1451 .checked_add(1)
1452 .ok_or_else(|| "ispline degree overflow while building knot basis".to_string())?;
1453 let knotvec = if knots.is_empty() {
1454 let (num_internal_knots, _) = infer_knots_if_needed.ok_or_else(|| {
1455 "internal error: ispline time basis requested without knot source".to_string()
1456 })?;
1457 let combined = survival_time_knot_input(&log_entry, &log_exit);
1458 infer_survival_time_knots(
1459 &combined,
1460 bspline_degree,
1461 degree,
1462 num_internal_knots,
1463 BasisOptions::i_spline(),
1464 )?
1465 } else {
1466 knots
1467 };
1468
1469 let (db_exit_arc, _) = create_basis::<Dense>(
1470 log_exit.view(),
1471 KnotSource::Provided(knotvec.view()),
1472 bspline_degree,
1473 BasisOptions::first_derivative(),
1474 )
1475 .map_err(|e| format!("failed to build ispline derivative basis: {e}"))?;
1476
1477 let (x_entry_time, x_exit_time, keep_cols, p_time, p_time_full) = {
1480 let (entry_arc, _) = create_basis::<Dense>(
1481 log_entry.view(),
1482 KnotSource::Provided(knotvec.view()),
1483 degree,
1484 BasisOptions::i_spline(),
1485 )
1486 .map_err(|e| format!("failed to build ispline entry basis: {e}"))?;
1487 let (exit_arc, _) = create_basis::<Dense>(
1488 log_exit.view(),
1489 KnotSource::Provided(knotvec.view()),
1490 degree,
1491 BasisOptions::i_spline(),
1492 )
1493 .map_err(|e| format!("failed to build ispline exit basis: {e}"))?;
1494
1495 let x_entry_full = entry_arc.as_ref();
1496 let x_exit_full = exit_arc.as_ref();
1497 let p_time_full = x_exit_full.ncols();
1498 if p_time_full == 0 {
1499 return Err(SurvivalConstructionError::BasisConstructionFailed {
1500 reason: "internal error: empty ispline time basis".to_string(),
1501 }
1502 .into());
1503 }
1504 let db_exit = db_exit_arc.as_ref();
1505 if db_exit.ncols() != p_time_full + 1 {
1506 return Err(
1507 "internal error: ispline derivative basis width must exceed basis width by one"
1508 .to_string(),
1509 );
1510 }
1511
1512 let keep_cols = if keep_cols.is_empty() {
1513 let constant_tol = 1e-12_f64;
1514 let mut inferred_keep_cols: Vec<usize> = Vec::new();
1515 for j in 0..p_time_full {
1516 let mut minv = f64::INFINITY;
1517 let mut maxv = f64::NEG_INFINITY;
1518 for i in 0..n {
1519 let ve = x_exit_full[[i, j]];
1520 let vs = x_entry_full[[i, j]];
1521 minv = minv.min(ve.min(vs));
1522 maxv = maxv.max(ve.max(vs));
1523 }
1524 if (maxv - minv) > constant_tol {
1525 inferred_keep_cols.push(j);
1526 }
1527 }
1528 inferred_keep_cols
1529 } else {
1530 keep_cols
1531 };
1532 if keep_cols.is_empty() {
1533 return Err(
1534 "internal error: ispline basis has no shape-varying time columns"
1535 .to_string(),
1536 );
1537 }
1538 if keep_cols.iter().any(|&j| j >= p_time_full) {
1539 return Err(SurvivalConstructionError::MissingColumn {
1540 reason: "saved survival ispline keep_cols exceed basis width".to_string(),
1541 }
1542 .into());
1543 }
1544
1545 let p_time = keep_cols.len();
1546 let x_entry_time = x_entry_full.select(ndarray::Axis(1), &keep_cols);
1547 let x_exit_time = x_exit_full.select(ndarray::Axis(1), &keep_cols);
1548 (x_entry_time, x_exit_time, keep_cols, p_time, p_time_full)
1551 };
1552 let db_exit = db_exit_arc.as_ref();
1553
1554 let mut deriv_triplets = Vec::with_capacity(n * p_time.min(16));
1559 let mut found_nonfinite: Option<(usize, usize)> = None;
1560 for i in 0..n {
1561 let mut running = 0.0_f64;
1562 let mut d_i_log_full = vec![0.0_f64; p_time_full];
1563 for j in (1..db_exit.ncols()).rev() {
1564 let term = db_exit[[i, j]];
1565 if term.is_finite() {
1566 running += term;
1567 }
1568 d_i_log_full[j - 1] = running;
1569 }
1570 let chain = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1571 for (j_new, &j_old) in keep_cols.iter().enumerate() {
1572 let raw_v = d_i_log_full[j_old] * chain;
1573 let v = if (-1e-12..0.0).contains(&raw_v) {
1574 0.0
1575 } else {
1576 raw_v
1577 };
1578 if !v.is_finite() {
1579 found_nonfinite = Some((i, j_new));
1580 }
1581 if v < -1e-12 {
1582 return Err(format!(
1583 "survival ispline derivative basis must stay non-negative at row {}, column {}; found {:.3e}",
1584 i + 1,
1585 j_new + 1,
1586 v
1587 ));
1588 }
1589 if v.abs() > 1e-15 {
1590 deriv_triplets.push(faer::sparse::Triplet::new(i, j_new, v));
1591 }
1592 }
1593 }
1594 if let Some((row, col)) = found_nonfinite {
1595 return Err(format!(
1596 "survival ispline derivative basis produced non-finite value at row {}, column {}",
1597 row + 1,
1598 col + 1
1599 ));
1600 }
1601 let x_derivative_time =
1602 match faer::sparse::SparseColMat::try_new_from_triplets(n, p_time, &deriv_triplets)
1603 {
1604 Ok(sparse) => DesignMatrix::Sparse(SparseDesignMatrix::new(sparse)),
1605 Err(_) => {
1606 let mut dense = Array2::<f64>::zeros((n, p_time));
1607 for &faer::sparse::Triplet { row, col, val } in &deriv_triplets {
1608 dense[[row, col]] = val;
1609 }
1610 DesignMatrix::Dense(DenseDesignMatrix::from(dense))
1611 }
1612 };
1613
1614 let penalty_basis = build_bspline_basis_1d(
1615 log_exit.view(),
1616 &BSplineBasisSpec {
1617 degree: bspline_degree,
1618 penalty_order: 2,
1619 knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1620 double_penalty: false,
1621 identifiability: BSplineIdentifiability::None,
1622 boundary: OneDimensionalBoundary::Open,
1623 boundary_conditions: BSplineBoundaryConditions::default(),
1624 },
1625 )
1626 .map_err(|e| format!("failed to build ispline smoothing penalty: {e}"))?;
1627 if penalty_basis.design.ncols() != p_time_full + 1 {
1628 return Err("internal error: ispline penalty dimension mismatch".to_string());
1629 }
1630 let mut penalties = Vec::<Array2<f64>>::new();
1664 for active_penalty in &penalty_basis.active_penalties {
1665 let s_mat = &active_penalty.matrix;
1666 if s_mat.nrows() != p_time_full + 1 || s_mat.ncols() != p_time_full + 1 {
1667 continue;
1668 }
1669 let s_increment = s_mat.slice(s![1.., 1..]);
1698 if s_increment.nrows() != p_time_full || s_increment.ncols() != p_time_full {
1699 return Err(format!(
1700 "internal error: ispline penalty increment block must be {p_time_full}x{p_time_full}, got {}x{}",
1701 s_increment.nrows(),
1702 s_increment.ncols(),
1703 ));
1704 }
1705 let mut s_full = s_increment.to_owned();
1710 symmetrize_in_place(&mut s_full);
1711 let mut s_mid_full = Array2::<f64>::zeros((p_time_full, p_time_full));
1715 for i in 0..p_time_full {
1716 for j in 0..p_time_full {
1717 let mut v = 0.0;
1718 for k in j..p_time_full {
1719 v += s_full[[i, k]];
1720 }
1721 s_mid_full[[i, j]] = v;
1722 }
1723 }
1724 let mut s_full_congruent = Array2::<f64>::zeros((p_time_full, p_time_full));
1728 for i in 0..p_time_full {
1729 for j in 0..p_time_full {
1730 let mut v = 0.0;
1731 for k in i..p_time_full {
1732 v += s_mid_full[[k, j]];
1733 }
1734 s_full_congruent[[i, j]] = v;
1735 }
1736 }
1737 let mut local = Array2::<f64>::zeros((p_time, p_time));
1739 for (i_new, &i_old) in keep_cols.iter().enumerate() {
1740 for (j_new, &j_old) in keep_cols.iter().enumerate() {
1741 local[[i_new, j_new]] = 0.5
1744 * (s_full_congruent[[i_old, j_old]] + s_full_congruent[[j_old, i_old]]);
1745 }
1746 }
1747 penalties.push(local);
1748 }
1749
1750 for (idx, s_mat) in penalties.iter().enumerate() {
1760 let p = s_mat.nrows();
1761 if p == 0 {
1762 continue;
1763 }
1764 if let Ok((evals, _)) =
1765 gam_linalg::faer_ndarray::FaerEigh::eigh(s_mat, faer::Side::Lower)
1766 {
1767 let evals_slice: &[f64] = evals.as_slice().ok_or_else(|| {
1768 "internal error: ispline penalty eigenvalues not contiguous".to_string()
1769 })?;
1770 let max_ev = evals_slice
1771 .iter()
1772 .copied()
1773 .fold(0.0_f64, |a, b| a.max(b.abs()))
1774 .max(1.0);
1775 let min_ev = evals_slice.iter().copied().fold(f64::INFINITY, f64::min);
1776 let neg_tol = -100.0 * (p as f64) * f64::EPSILON * max_ev;
1777 if min_ev < neg_tol {
1778 return Err(format!(
1779 "internal error (gam#979): assembled ispline time-block penalty {idx} is \
1780 indefinite (min eigenvalue {min_ev:.3e} < tol {neg_tol:.3e}, max |eig| \
1781 {max_ev:.3e}); the value-space congruence Lᵀ S_B[1:,1:] L must be PSD"
1782 ));
1783 }
1784 }
1785 }
1786
1787 let nullspace_dims: Vec<usize> = penalties
1791 .iter()
1792 .map(|s_mat| {
1793 let p = s_mat.nrows();
1794 if p == 0 {
1795 return 0;
1796 }
1797 match gam_linalg::faer_ndarray::FaerEigh::eigh(s_mat, faer::Side::Lower) {
1798 Ok((evals, _)) => {
1799 let evals_slice: &[f64] = evals.as_slice().unwrap();
1800 let max_ev = evals_slice
1801 .iter()
1802 .copied()
1803 .fold(0.0_f64, |a, b| a.max(b.abs()))
1804 .max(1.0);
1805 let threshold = 100.0 * (p as f64) * f64::EPSILON * max_ev;
1806 evals_slice.iter().filter(|&&e| e <= threshold).count()
1807 }
1808 Err(_) => 0,
1809 }
1810 })
1811 .collect();
1812 Ok(SurvivalTimeBuildOutput {
1813 x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_entry_time)),
1814 x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_exit_time)),
1815 x_derivative_time,
1816 penalties,
1817 nullspace_dims,
1818 basisname: "ispline".to_string(),
1819 degree: Some(degree),
1820 knots: Some(knotvec.to_vec()),
1821 keep_cols: Some(keep_cols),
1822 smooth_lambda: Some(smooth_lambda),
1823 })
1824 }
1825 }
1826}
1827
1828pub fn resolved_survival_time_basis_config_from_build(
1829 basisname: &str,
1830 degree: Option<usize>,
1831 knots: Option<&Vec<f64>>,
1832 keep_cols: Option<&Vec<usize>>,
1833 smooth_lambda: Option<f64>,
1834) -> Result<SurvivalTimeBasisConfig, String> {
1835 match basisname {
1836 "none" => Ok(SurvivalTimeBasisConfig::None),
1837 "linear" => Ok(SurvivalTimeBasisConfig::Linear),
1838 "bspline" => Ok(SurvivalTimeBasisConfig::BSpline {
1839 degree: degree.ok_or_else(|| "survival bspline basis is missing degree".to_string())?,
1840 knots: Array1::from_vec(
1841 knots
1842 .cloned()
1843 .ok_or_else(|| "survival bspline basis is missing knots".to_string())?,
1844 ),
1845 smooth_lambda: smooth_lambda.unwrap_or(SURVIVAL_TIME_SMOOTH_LAMBDA_SEED),
1846 }),
1847 "ispline" => Ok(SurvivalTimeBasisConfig::ISpline {
1848 degree: degree.ok_or_else(|| "survival ispline basis is missing degree".to_string())?,
1849 knots: Array1::from_vec(
1850 knots
1851 .cloned()
1852 .ok_or_else(|| "survival ispline basis is missing knots".to_string())?,
1853 ),
1854 keep_cols: keep_cols
1855 .cloned()
1856 .ok_or_else(|| "survival ispline basis is missing keep_cols".to_string())?,
1857 smooth_lambda: smooth_lambda.unwrap_or(SURVIVAL_TIME_SMOOTH_LAMBDA_SEED),
1858 }),
1859 other => Err(format!("unsupported survival time basis '{other}'")),
1860 }
1861}
1862
1863pub fn resolve_survival_time_anchor_value(
1864 age_entry: &Array1<f64>,
1865 time_anchor: Option<f64>,
1866) -> Result<f64, String> {
1867 if age_entry.is_empty() {
1868 return Err("survival time anchor requires non-empty entry times".to_string());
1869 }
1870 let anchor = match time_anchor {
1871 Some(t_anchor) => {
1872 if !t_anchor.is_finite() || t_anchor < 0.0 {
1873 return Err(format!(
1874 "survival time anchor must be finite and non-negative, got {t_anchor}"
1875 ));
1876 }
1877 t_anchor
1878 }
1879 None => age_entry
1880 .iter()
1881 .copied()
1882 .min_by(f64::total_cmp)
1883 .ok_or_else(|| "failed to select survival time anchor".to_string())?,
1884 };
1885 Ok(anchor.max(SURVIVAL_TIME_FLOOR))
1886}
1887
1888pub fn resolve_survival_marginal_slope_time_anchor_value(
1920 age_entry: &Array1<f64>,
1921 age_exit: &Array1<f64>,
1922 time_anchor: Option<f64>,
1923) -> Result<f64, String> {
1924 if age_entry.is_empty() || age_exit.is_empty() {
1925 return Err(
1926 "survival marginal-slope time anchor requires non-empty entry/exit times".to_string(),
1927 );
1928 }
1929 let anchor = match time_anchor {
1930 Some(t_anchor) => {
1931 if !t_anchor.is_finite() || t_anchor < 0.0 {
1932 return Err(format!(
1933 "survival time anchor must be finite and non-negative, got {t_anchor}"
1934 ));
1935 }
1936 t_anchor
1937 }
1938 None => robust_interior_exit_anchor(age_exit),
1939 };
1940 Ok(anchor.max(SURVIVAL_TIME_FLOOR))
1941}
1942
1943fn robust_interior_exit_anchor(age_exit: &Array1<f64>) -> f64 {
1950 let mut sorted: Vec<f64> = age_exit.iter().copied().collect();
1951 sorted.sort_by(f64::total_cmp);
1952 let m = sorted.len();
1953 if m == 0 {
1954 return SURVIVAL_TIME_FLOOR;
1955 }
1956 if m % 2 == 1 {
1957 sorted[m / 2]
1958 } else {
1959 0.5 * (sorted[m / 2 - 1] + sorted[m / 2])
1960 }
1961}
1962
1963pub fn resolve_survival_transformation_time_anchor_value(
1984 age_entry: &Array1<f64>,
1985 age_exit: &Array1<f64>,
1986 time_anchor: Option<f64>,
1987) -> Result<f64, String> {
1988 if time_anchor.is_some() {
1989 return resolve_survival_time_anchor_value(age_entry, time_anchor);
1990 }
1991 if age_exit.is_empty() {
1992 return Err(
1993 "survival transformation time anchor requires non-empty exit times".to_string(),
1994 );
1995 }
1996 let min_entry = age_entry.iter().copied().fold(f64::INFINITY, f64::min);
1997 if min_entry > SURVIVAL_DELAYED_ENTRY_THRESHOLD {
1998 Ok(robust_interior_exit_anchor(age_exit).max(SURVIVAL_TIME_FLOOR))
1999 } else {
2000 resolve_survival_time_anchor_value(age_entry, None)
2001 }
2002}
2003
2004pub fn evaluate_survival_time_basis_row(
2005 age: f64,
2006 cfg: &SurvivalTimeBasisConfig,
2007) -> Result<Array1<f64>, String> {
2008 if !age.is_finite() || age < 0.0 {
2009 return Err(format!(
2010 "survival time basis row requires finite non-negative age, got {age}"
2011 ));
2012 }
2013 let age = age.max(SURVIVAL_TIME_FLOOR);
2014 let log_age = array![age.ln()];
2015 match cfg {
2016 SurvivalTimeBasisConfig::None => Ok(Array1::zeros(0)),
2017 SurvivalTimeBasisConfig::Linear => Ok(array![age.ln()]),
2021 SurvivalTimeBasisConfig::BSpline { degree, knots, .. } => {
2022 if knots.is_empty() {
2023 return Err(
2024 "survival BSpline anchor evaluation requires resolved knot metadata"
2025 .to_string(),
2026 );
2027 }
2028 let built = build_bspline_basis_1d(
2029 log_age.view(),
2030 &BSplineBasisSpec {
2031 degree: *degree,
2032 penalty_order: 2,
2033 knotspec: BSplineKnotSpec::Provided(knots.clone()),
2034 double_penalty: false,
2035 identifiability: BSplineIdentifiability::None,
2036 boundary: OneDimensionalBoundary::Open,
2037 boundary_conditions: BSplineBoundaryConditions::default(),
2038 },
2039 )
2040 .map_err(|e| format!("failed to evaluate survival bspline anchor row: {e}"))?;
2041 Ok(built.design.to_dense().row(0).to_owned())
2042 }
2043 SurvivalTimeBasisConfig::ISpline {
2044 degree,
2045 knots,
2046 keep_cols,
2047 ..
2048 } => {
2049 if knots.is_empty() {
2050 return Err(
2051 "survival ISpline anchor evaluation requires resolved knot metadata"
2052 .to_string(),
2053 );
2054 }
2055 let (basis_arc, _) = create_basis::<Dense>(
2056 log_age.view(),
2057 KnotSource::Provided(knots.view()),
2058 *degree,
2059 BasisOptions::i_spline(),
2060 )
2061 .map_err(|e| format!("failed to evaluate survival ispline anchor row: {e}"))?;
2062 let basis = basis_arc.as_ref();
2063 let row = basis.row(0);
2064 if keep_cols.is_empty() {
2065 return Ok(row.to_owned());
2066 }
2067 if keep_cols.iter().any(|&j| j >= row.len()) {
2068 return Err(SurvivalConstructionError::MissingColumn {
2069 reason: "survival ISpline anchor keep_cols exceed basis width".to_string(),
2070 }
2071 .into());
2072 }
2073 Ok(Array1::from_iter(keep_cols.iter().map(|&j| row[j])))
2074 }
2075 }
2076}
2077
2078pub fn center_survival_time_designs_at_anchor(
2079 design_entry: &mut DesignMatrix,
2080 design_exit: &mut DesignMatrix,
2081 anchor_row: &Array1<f64>,
2082) -> Result<(), String> {
2083 if design_entry.ncols() != anchor_row.len() || design_exit.ncols() != anchor_row.len() {
2084 return Err(format!(
2085 "survival time anchoring column mismatch: entry={}, exit={}, anchor={}",
2086 design_entry.ncols(),
2087 design_exit.ncols(),
2088 anchor_row.len()
2089 ));
2090 }
2091 fn center_dense(dm: &mut DesignMatrix, anchor: &Array1<f64>) {
2094 let mut dense = dm.to_dense();
2095 for mut row in dense.rows_mut() {
2096 row -= &anchor.view();
2097 }
2098 *dm = DesignMatrix::Dense(DenseDesignMatrix::from(dense));
2099 }
2100 center_dense(design_entry, anchor_row);
2101 center_dense(design_exit, anchor_row);
2102 Ok(())
2103}
2104
2105pub fn baseline_offset_theta_partials(
2135 age: f64,
2136 cfg: &SurvivalBaselineConfig,
2137) -> Result<Option<Vec<(f64, f64)>>, String> {
2138 let Some(params) = validated_baseline_params(age, cfg, "baseline derivative evaluation")?
2139 else {
2140 return Ok(None);
2141 };
2142
2143 match params {
2144 ValidatedBaselineTarget::Weibull { scale, shape } => {
2145 let eta = shape * (age.ln() - scale.ln());
2154 let o_d = shape / age;
2155 let d_eta_d_log_scale = -shape;
2156 let d_od_d_log_scale = 0.0;
2157 let d_eta_d_log_shape = eta;
2158 let d_od_d_log_shape = o_d;
2159 Ok(Some(vec![
2160 (d_eta_d_log_scale, d_od_d_log_scale),
2161 (d_eta_d_log_shape, d_od_d_log_shape),
2162 ]))
2163 }
2164 ValidatedBaselineTarget::Gompertz { shape, .. } => {
2165 let (d_eta_d_shape, d_od_d_shape) = gompertz_shape_derivatives(age, shape);
2175 Ok(Some(vec![(1.0, 0.0), (d_eta_d_shape, d_od_d_shape)]))
2176 }
2177 ValidatedBaselineTarget::GompertzMakeham {
2178 rate,
2179 shape,
2180 makeham,
2181 } => {
2182 let (cum_g, inst_g) = gompertz_hazard_components(age, rate, shape);
2197 let cum_total = makeham * age + cum_g;
2198 if cum_total <= 0.0 || !cum_total.is_finite() {
2199 return Err(SurvivalConstructionError::DataValidationFailed {
2200 reason: "gm baseline produced non-positive cumulative hazard".to_string(),
2201 }
2202 .into());
2203 }
2204 let inst_total = makeham + inst_g;
2205 let o_d = inst_total / cum_total;
2206 let inv_cum = 1.0 / cum_total;
2207 let d_cum_dlr = cum_g;
2212 let d_inst_dlr = inst_g;
2213 let d_eta_dlr = d_cum_dlr * inv_cum;
2214 let d_od_dlr = (d_inst_dlr - o_d * d_cum_dlr) * inv_cum;
2215 let (d_cum_dshape, d_inst_dshape) =
2217 gompertz_cumulative_shape_derivative(age, rate, shape);
2218 let d_eta_dshape = d_cum_dshape * inv_cum;
2219 let d_od_dshape = (d_inst_dshape - o_d * d_cum_dshape) * inv_cum;
2220 let d_cum_dlm = makeham * age;
2223 let d_inst_dlm = makeham;
2224 let d_eta_dlm = d_cum_dlm * inv_cum;
2225 let d_od_dlm = (d_inst_dlm - o_d * d_cum_dlm) * inv_cum;
2226 Ok(Some(vec![
2227 (d_eta_dlr, d_od_dlr),
2228 (d_eta_dshape, d_od_dshape),
2229 (d_eta_dlm, d_od_dlm),
2230 ]))
2231 }
2232 }
2233}
2234
2235fn baseline_chain_rule_gradient_with_partials<F>(
2263 label: &'static str,
2264 age_entry: ndarray::ArrayView1<'_, f64>,
2265 age_exit: ndarray::ArrayView1<'_, f64>,
2266 age_right: ndarray::ArrayView1<'_, f64>,
2267 cfg: &SurvivalBaselineConfig,
2268 residuals: &crate::survival::OffsetChannelResiduals,
2269 partials: F,
2270) -> Result<Option<Array1<f64>>, String>
2271where
2272 F: Fn(f64, &SurvivalBaselineConfig) -> Result<Option<Vec<(f64, f64)>>, String> + Sync,
2273{
2274 let n = age_exit.len();
2275 if age_entry.len() != n
2276 || age_right.len() != n
2277 || residuals.exit.len() != n
2278 || residuals.entry.len() != n
2279 || residuals.derivative.len() != n
2280 || residuals.right.len() != n
2281 {
2282 return Err(format!(
2283 "{label}: length mismatch (age_entry={}, age_exit={}, age_right={}, r_exit={}, r_entry={}, r_deriv={}, r_right={})",
2284 age_entry.len(),
2285 n,
2286 age_right.len(),
2287 residuals.exit.len(),
2288 residuals.entry.len(),
2289 residuals.derivative.len(),
2290 residuals.right.len(),
2291 ));
2292 }
2293 let probe_age = age_exit.iter().copied().find(|v| v.is_finite() && *v > 0.0);
2296 let theta_dim = match probe_age {
2297 Some(t) => match partials(t, cfg)? {
2298 None => return Ok(None),
2299 Some(v) => v.len(),
2300 },
2301 None => {
2302 return Err(format!("{label}: no valid positive age for dim probe"));
2303 }
2304 };
2305 let mut grad = Array1::<f64>::zeros(theta_dim);
2316 for i in 0..n {
2317 let partials_exit = partials(age_exit[i], cfg)?
2319 .ok_or_else(|| format!("{label}: unexpected None from partials at exit"))?;
2320 if partials_exit.len() != theta_dim {
2321 return Err(format!(
2322 "{label}: theta_dim drifted ({} != {})",
2323 partials_exit.len(),
2324 theta_dim
2325 ));
2326 }
2327 let r_x = residuals.exit[i];
2328 let r_d = residuals.derivative[i];
2329 for k in 0..theta_dim {
2330 let (d_eta_dk, d_od_dk) = partials_exit[k];
2331 grad[k] += r_x * d_eta_dk + r_d * d_od_dk;
2332 }
2333 let r_e = residuals.entry[i];
2337 if r_e != 0.0 {
2338 let partials_entry = partials(age_entry[i], cfg)?
2339 .ok_or_else(|| format!("{label}: unexpected None from partials at entry"))?;
2340 for k in 0..theta_dim {
2341 grad[k] += r_e * partials_entry[k].0;
2342 }
2343 }
2344 let r_r = residuals.right[i];
2353 if r_r != 0.0 {
2354 let partials_right = partials(age_right[i], cfg)?.ok_or_else(|| {
2355 format!("{label}: unexpected None from partials at right boundary")
2356 })?;
2357 if partials_right.len() != theta_dim {
2358 return Err(format!(
2359 "{label}: theta_dim drifted at right boundary ({} != {})",
2360 partials_right.len(),
2361 theta_dim
2362 ));
2363 }
2364 for k in 0..theta_dim {
2365 grad[k] += r_r * partials_right[k].0;
2366 }
2367 }
2368 }
2369 Ok(Some(grad))
2370}
2371
2372pub fn baseline_chain_rule_gradient(
2406 age_entry: ndarray::ArrayView1<'_, f64>,
2407 age_exit: ndarray::ArrayView1<'_, f64>,
2408 age_right: ndarray::ArrayView1<'_, f64>,
2409 cfg: &SurvivalBaselineConfig,
2410 residuals: &crate::survival::OffsetChannelResiduals,
2411) -> Result<Option<Array1<f64>>, String> {
2412 baseline_chain_rule_gradient_with_partials(
2413 "baseline_chain_rule_gradient",
2414 age_entry,
2415 age_exit,
2416 age_right,
2417 cfg,
2418 residuals,
2419 baseline_offset_theta_partials,
2420 )
2421}
2422
2423pub fn marginal_slope_baseline_chain_rule_gradient(
2430 age_entry: ndarray::ArrayView1<'_, f64>,
2431 age_exit: ndarray::ArrayView1<'_, f64>,
2432 cfg: &SurvivalBaselineConfig,
2433 residuals: &crate::survival::OffsetChannelResiduals,
2434) -> Result<Option<Array1<f64>>, String> {
2435 baseline_chain_rule_gradient_with_partials(
2439 "marginal_slope_baseline_chain_rule_gradient",
2440 age_entry,
2441 age_exit,
2442 age_exit,
2443 cfg,
2444 residuals,
2445 marginal_slope_baseline_offset_theta_partials,
2446 )
2447}
2448
2449#[inline]
2453fn gompertz_hazard_components(age: f64, rate: f64, shape: f64) -> (f64, f64) {
2454 if shape.abs() < 1e-10 {
2455 let x = shape * age;
2458 (
2459 rate * age * (1.0 + 0.5 * x + x * x / 6.0),
2460 rate * (1.0 + x + 0.5 * x * x),
2461 )
2462 } else {
2463 let shape_age = shape * age;
2464 let cumulative_hazard = (rate / shape) * shape_age.exp_m1();
2465 let instant_hazard = rate * shape_age.exp();
2466 (cumulative_hazard, instant_hazard)
2467 }
2468}
2469
2470#[inline]
2486fn gompertz_cumulative_shape_derivative(age: f64, rate: f64, shape: f64) -> (f64, f64) {
2487 let x = shape * age;
2488 let dinstg_dshape = rate * age * x.exp();
2489 let dhg_dshape = if x.abs() < 1e-4 {
2498 let t = age;
2499 rate * t * t * (0.5 + x / 3.0 + x * x / 8.0)
2501 } else {
2502 let e = x.exp();
2504 let em1 = x.exp_m1();
2505 let numerator = age * e * shape - em1;
2506 rate * numerator / (shape * shape)
2507 };
2508 (dhg_dshape, dinstg_dshape)
2509}
2510
2511#[inline]
2516fn gompertz_shape_derivatives(age: f64, shape: f64) -> (f64, f64) {
2517 if shape.abs() < 1e-10 {
2518 let t = age;
2528 let d_eta = 0.5 * t + shape * t * t / 12.0;
2529 let dlog_od = 0.5 * t - shape * t * t / 12.0;
2530 let o_d = 1.0 / t + 0.5 * shape + shape * shape * t / 12.0;
2531 (d_eta, o_d * dlog_od)
2532 } else {
2533 let x = shape * age;
2534 let e = x.exp();
2535 let em1 = x.exp_m1(); let d_eta = -1.0 / shape + age * e / em1;
2537 let o_d = shape * e / em1;
2539 let dlog_od = 1.0 / shape - age / em1;
2540 (d_eta, o_d * dlog_od)
2541 }
2542}
2543
2544#[derive(Clone, Copy, Debug)]
2553enum ValidatedBaselineTarget {
2554 Weibull { scale: f64, shape: f64 },
2555 Gompertz { rate: f64, shape: f64 },
2556 GompertzMakeham { rate: f64, shape: f64, makeham: f64 },
2557}
2558
2559fn validated_baseline_params(
2565 age: f64,
2566 cfg: &SurvivalBaselineConfig,
2567 context: &str,
2568) -> Result<Option<ValidatedBaselineTarget>, String> {
2569 if !age.is_finite() || age <= 0.0 {
2570 return Err(format!(
2571 "survival ages must be finite and positive for {context}"
2572 ));
2573 }
2574
2575 match cfg.target {
2576 SurvivalBaselineTarget::Linear => Ok(None),
2577 SurvivalBaselineTarget::Weibull => {
2578 let scale = cfg
2579 .scale
2580 .ok_or_else(|| "weibull missing scale".to_string())?;
2581 let shape = cfg
2582 .shape
2583 .ok_or_else(|| "weibull missing shape".to_string())?;
2584 if !(scale.is_finite() && shape.is_finite() && scale > 0.0 && shape > 0.0) {
2585 return Err(SurvivalConstructionError::InvalidConfig {
2586 reason: "weibull baseline requires finite positive scale and shape".to_string(),
2587 }
2588 .into());
2589 }
2590 Ok(Some(ValidatedBaselineTarget::Weibull { scale, shape }))
2591 }
2592 SurvivalBaselineTarget::Gompertz => {
2593 let rate = cfg
2594 .rate
2595 .ok_or_else(|| "gompertz missing rate".to_string())?;
2596 let shape = cfg
2597 .shape
2598 .ok_or_else(|| "gompertz missing shape".to_string())?;
2599 if !(rate.is_finite() && shape.is_finite() && rate > 0.0) {
2600 return Err(
2601 "gompertz baseline requires finite positive rate and finite shape".to_string(),
2602 );
2603 }
2604 Ok(Some(ValidatedBaselineTarget::Gompertz { rate, shape }))
2605 }
2606 SurvivalBaselineTarget::GompertzMakeham => {
2607 let rate = cfg
2608 .rate
2609 .ok_or_else(|| "gompertz-makeham missing rate".to_string())?;
2610 let shape = cfg
2611 .shape
2612 .ok_or_else(|| "gompertz-makeham missing shape".to_string())?;
2613 let makeham = cfg
2614 .makeham
2615 .ok_or_else(|| "gompertz-makeham missing makeham".to_string())?;
2616 if !(rate.is_finite()
2617 && shape.is_finite()
2618 && makeham.is_finite()
2619 && rate > 0.0
2620 && makeham > 0.0)
2621 {
2622 return Err(
2623 "gompertz-makeham baseline requires finite positive rate, makeham, and finite shape"
2624 .to_string(),
2625 );
2626 }
2627 Ok(Some(ValidatedBaselineTarget::GompertzMakeham {
2628 rate,
2629 shape,
2630 makeham,
2631 }))
2632 }
2633 }
2634}
2635
2636fn survival_hazard_theta_partials(
2637 age: f64,
2638 cfg: &SurvivalBaselineConfig,
2639) -> Result<Option<Vec<(f64, f64)>>, String> {
2640 let Some(params) = validated_baseline_params(age, cfg, "baseline hazard partials")? else {
2641 return Ok(None);
2642 };
2643
2644 match params {
2645 ValidatedBaselineTarget::Weibull { scale, shape } => {
2646 let log_time_ratio = age.ln() - scale.ln();
2647 let cumulative_hazard = (age / scale).powf(shape);
2648 let instant_hazard = shape * cumulative_hazard / age;
2649 let eta = shape * log_time_ratio;
2650 Ok(Some(vec![
2651 (-shape * cumulative_hazard, -shape * instant_hazard),
2652 (eta * cumulative_hazard, (1.0 + eta) * instant_hazard),
2653 ]))
2654 }
2655 ValidatedBaselineTarget::Gompertz { rate, shape } => {
2656 let (cumulative_hazard, instant_hazard) = gompertz_hazard_components(age, rate, shape);
2657 let (d_cum_dshape, d_inst_dshape) =
2658 gompertz_cumulative_shape_derivative(age, rate, shape);
2659 Ok(Some(vec![
2660 (cumulative_hazard, instant_hazard),
2661 (d_cum_dshape, d_inst_dshape),
2662 ]))
2663 }
2664 ValidatedBaselineTarget::GompertzMakeham {
2665 rate,
2666 shape,
2667 makeham,
2668 } => {
2669 let (cum_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2670 let (d_cum_dshape, d_inst_dshape) =
2671 gompertz_cumulative_shape_derivative(age, rate, shape);
2672 Ok(Some(vec![
2673 (cum_gompertz, inst_gompertz),
2674 (d_cum_dshape, d_inst_dshape),
2675 (makeham * age, makeham),
2676 ]))
2677 }
2678 }
2679}
2680
2681fn survival_cumulative_and_instant_hazard(
2682 age: f64,
2683 cfg: &SurvivalBaselineConfig,
2684) -> Result<Option<(f64, f64)>, String> {
2685 let Some(params) = validated_baseline_params(age, cfg, "baseline hazard evaluation")? else {
2686 return Ok(None);
2687 };
2688
2689 match params {
2690 ValidatedBaselineTarget::Weibull { scale, shape } => {
2691 let cumulative_hazard = (age / scale).powf(shape);
2692 let instant_hazard = shape * cumulative_hazard / age;
2693 Ok(Some((cumulative_hazard, instant_hazard)))
2694 }
2695 ValidatedBaselineTarget::Gompertz { rate, shape } => {
2696 let (cumulative_hazard, instant_hazard) = gompertz_hazard_components(age, rate, shape);
2697 Ok(Some((cumulative_hazard, instant_hazard)))
2698 }
2699 ValidatedBaselineTarget::GompertzMakeham {
2700 rate,
2701 shape,
2702 makeham,
2703 } => {
2704 let (h_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2705 Ok(Some((makeham * age + h_gompertz, makeham + inst_gompertz)))
2706 }
2707 }
2708}
2709
2710#[derive(Clone, Copy, Debug)]
2711struct MarginalSlopeBaselinePoint {
2712 instant_hazard: f64,
2713 q: f64,
2714 q_t: f64,
2715}
2716
2717fn evaluate_marginal_slope_baseline_point(
2718 age: f64,
2719 cfg: &SurvivalBaselineConfig,
2720) -> Result<Option<MarginalSlopeBaselinePoint>, String> {
2721 let Some((cumulative_hazard, instant_hazard)) =
2722 survival_cumulative_and_instant_hazard(age, cfg)?
2723 else {
2724 return Ok(None);
2725 };
2726 if !(cumulative_hazard.is_finite() && cumulative_hazard > 0.0) {
2727 return Err(format!(
2728 "{} marginal-slope baseline produced non-positive cumulative hazard",
2729 survival_baseline_targetname(cfg.target)
2730 ));
2731 }
2732 if !(instant_hazard.is_finite() && instant_hazard > 0.0) {
2733 return Err(format!(
2734 "{} marginal-slope baseline produced non-positive instant hazard",
2735 survival_baseline_targetname(cfg.target)
2736 ));
2737 }
2738 let survival = (-cumulative_hazard).exp();
2739 if !(survival.is_finite() && survival > 0.0 && survival < 1.0) {
2740 return Err(format!(
2741 "{} marginal-slope baseline survival must be strictly inside (0,1), got {survival}",
2742 survival_baseline_targetname(cfg.target)
2743 ));
2744 }
2745 let q = -standard_normal_quantile(survival).map_err(|e| {
2746 format!(
2747 "{} marginal-slope baseline failed to invert survival probability {survival}: {e}",
2748 survival_baseline_targetname(cfg.target)
2749 )
2750 })?;
2751 let phi_q = normal_pdf(q);
2752 if !(phi_q.is_finite() && phi_q > 0.0) {
2753 return Err(format!(
2754 "{} marginal-slope baseline produced non-positive probit density phi(q)={phi_q} at q={q}",
2755 survival_baseline_targetname(cfg.target)
2756 ));
2757 }
2758 Ok(Some(MarginalSlopeBaselinePoint {
2759 instant_hazard,
2760 q,
2761 q_t: instant_hazard * survival / phi_q,
2762 }))
2763}
2764
2765pub fn evaluate_survival_baseline(
2768 age: f64,
2769 cfg: &SurvivalBaselineConfig,
2770) -> Result<(f64, f64), String> {
2771 if !age.is_finite() || age < 0.0 {
2772 return Err(
2773 "survival ages must be finite and non-negative for baseline target evaluation"
2774 .to_string(),
2775 );
2776 }
2777
2778 if age == 0.0 {
2789 return match cfg.target {
2790 SurvivalBaselineTarget::Linear => Ok((0.0, 0.0)),
2791 SurvivalBaselineTarget::Weibull
2792 | SurvivalBaselineTarget::Gompertz
2793 | SurvivalBaselineTarget::GompertzMakeham => Ok((f64::NEG_INFINITY, 0.0)),
2794 };
2795 }
2796
2797 let Some(params) = validated_baseline_params(age, cfg, "baseline target evaluation")? else {
2798 return Ok((0.0, 0.0));
2799 };
2800
2801 match params {
2802 ValidatedBaselineTarget::Weibull { scale, shape } => {
2803 let eta = shape * (age.ln() - scale.ln());
2804 let derivative = shape / age;
2805 Ok((eta, derivative))
2806 }
2807 ValidatedBaselineTarget::Gompertz { rate, shape } => {
2808 let (h, inst) = gompertz_hazard_components(age, rate, shape);
2809 if h <= 0.0 || !h.is_finite() {
2810 return Err(if shape.abs() < 1e-10 {
2811 "invalid gompertz baseline at near-zero shape".to_string()
2812 } else {
2813 "gompertz baseline produced non-positive cumulative hazard".to_string()
2814 });
2815 }
2816 let derivative = inst / h;
2817 Ok((h.ln(), derivative))
2818 }
2819 ValidatedBaselineTarget::GompertzMakeham {
2820 rate,
2821 shape,
2822 makeham,
2823 } => {
2824 let (h_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2825 let h = makeham * age + h_gompertz;
2826 if h <= 0.0 || !h.is_finite() {
2827 return Err(
2828 "gompertz-makeham baseline produced non-positive cumulative hazard".to_string(),
2829 );
2830 }
2831 let inst = makeham + inst_gompertz;
2832 let derivative = inst / h;
2833 Ok((h.ln(), derivative))
2834 }
2835 }
2836}
2837
2838pub fn evaluate_survival_marginal_slope_baseline(
2844 age: f64,
2845 cfg: &SurvivalBaselineConfig,
2846) -> Result<(f64, f64), String> {
2847 if age == 0.0 {
2859 return Ok((0.0, 0.0));
2860 }
2861 let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
2862 return Ok((0.0, 0.0));
2863 };
2864 Ok((point.q, point.q_t))
2865}
2866
2867pub fn marginal_slope_baseline_offset_theta_partials(
2880 age: f64,
2881 cfg: &SurvivalBaselineConfig,
2882) -> Result<Option<Vec<(f64, f64)>>, String> {
2883 let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
2884 return Ok(None);
2885 };
2886 let hazard_partials = survival_hazard_theta_partials(age, cfg)?
2887 .ok_or_else(|| "unexpected missing hazard partials for nonlinear baseline".to_string())?;
2888 let a = point.q_t / point.instant_hazard;
2889 let a_log_derivative_factor = point.q * a - 1.0;
2890 Ok(Some(
2891 hazard_partials
2892 .into_iter()
2893 .map(|(d_h_cum, d_h_inst)| {
2894 (
2895 a * d_h_cum,
2896 a * (d_h_inst + point.instant_hazard * a_log_derivative_factor * d_h_cum),
2897 )
2898 })
2899 .collect(),
2900 ))
2901}
2902
2903pub fn marginal_slope_baseline_chain_rule_hessian(
2906 age_entry: ndarray::ArrayView1<'_, f64>,
2907 age_exit: ndarray::ArrayView1<'_, f64>,
2908 cfg: &SurvivalBaselineConfig,
2909 residuals: &crate::survival::OffsetChannelResiduals,
2910 curvatures: &crate::survival::OffsetChannelCurvatures,
2911) -> Result<Option<Array2<f64>>, String> {
2912 let n = age_exit.len();
2913 if age_entry.len() != n
2914 || residuals.exit.len() != n
2915 || residuals.entry.len() != n
2916 || residuals.derivative.len() != n
2917 || curvatures.rows.len() != n
2918 {
2919 return Err(format!(
2920 "marginal_slope_baseline_chain_rule_hessian: length mismatch (age_entry={}, age_exit={}, r_exit={}, r_entry={}, r_deriv={}, h_rows={})",
2921 age_entry.len(),
2922 n,
2923 residuals.exit.len(),
2924 residuals.entry.len(),
2925 residuals.derivative.len(),
2926 curvatures.rows.len(),
2927 ));
2928 }
2929 let probe_age = age_exit.iter().copied().find(|v| v.is_finite() && *v > 0.0);
2930 let dim = match probe_age {
2931 Some(t) => match marginal_slope_baseline_offset_theta_geometry(t, cfg)? {
2932 None => return Ok(None),
2933 Some(parts) => parts.first.len(),
2934 },
2935 None => {
2936 return Err(
2937 "marginal_slope_baseline_chain_rule_hessian: no valid positive age for dim probe"
2938 .to_string(),
2939 );
2940 }
2941 };
2942 let hessian = RowSet::All.par_try_reduce_fold(
2949 n,
2950 || Array2::<f64>::zeros((dim, dim)),
2951 |mut acc, i, _row_weight| -> Result<Array2<f64>, String> {
2952 let exit_parts = marginal_slope_baseline_offset_theta_geometry(age_exit[i], cfg)?
2953 .ok_or_else(|| {
2954 "unexpected None from marginal-slope second partials at exit".to_string()
2955 })?;
2956 if exit_parts.first.len() != dim {
2957 return Err(
2958 "marginal_slope_baseline_chain_rule_hessian: theta_dim drifted".to_string(),
2959 );
2960 }
2961 let mut entry_parts = None;
2962 if residuals.entry[i] != 0.0 {
2963 entry_parts = Some(
2964 marginal_slope_baseline_offset_theta_geometry(age_entry[i], cfg)?.ok_or_else(
2965 || {
2966 "unexpected None from marginal-slope second partials at entry"
2967 .to_string()
2968 },
2969 )?,
2970 );
2971 }
2972 for a in 0..dim {
2973 for b in 0..dim {
2974 let j_exit_a = exit_parts.first[a].0;
2975 let j_exit_b = exit_parts.first[b].0;
2976 let j_deriv_a = exit_parts.first[a].1;
2977 let j_deriv_b = exit_parts.first[b].1;
2978 let mut value = residuals.exit[i] * exit_parts.second[a][b].0
2979 + residuals.derivative[i] * exit_parts.second[a][b].1;
2980 if let Some(parts) = entry_parts.as_ref() {
2981 value += residuals.entry[i] * parts.second[a][b].0;
2982 }
2983 let curv = curvatures.rows[i];
2984 let j_entry_a = entry_parts.as_ref().map_or(0.0, |parts| parts.first[a].0);
2985 let j_entry_b = entry_parts.as_ref().map_or(0.0, |parts| parts.first[b].0);
2986 let ja = [j_entry_a, j_exit_a, j_deriv_a];
2987 let jb = [j_entry_b, j_exit_b, j_deriv_b];
2988 for u in 0..3 {
2989 for v in 0..3 {
2990 value += ja[u] * curv[u][v] * jb[v];
2991 }
2992 }
2993 acc[[a, b]] += value;
2994 }
2995 }
2996 Ok(acc)
2997 },
2998 |a, b| Ok(a + b),
2999 )?;
3000 Ok(Some(hessian))
3001}
3002
3003#[derive(Clone, Debug)]
3012pub struct MarginalSlopeBaselineOffsetThetaGeometry {
3013 pub value: (f64, f64),
3014 pub first: Vec<(f64, f64)>,
3015 pub second: Vec<Vec<(f64, f64)>>,
3016}
3017
3018pub fn marginal_slope_baseline_offset_theta_geometry(
3019 age: f64,
3020 cfg: &SurvivalBaselineConfig,
3021) -> Result<Option<MarginalSlopeBaselineOffsetThetaGeometry>, String> {
3022 if age == 0.0 {
3023 let Some(theta) = survival_baseline_theta_from_config(cfg)? else {
3024 return Ok(None);
3025 };
3026 let dim = theta.len();
3027 return Ok(Some(MarginalSlopeBaselineOffsetThetaGeometry {
3028 value: (0.0, 0.0),
3029 first: vec![(0.0, 0.0); dim],
3030 second: vec![vec![(0.0, 0.0); dim]; dim],
3031 }));
3032 }
3033 let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
3034 return Ok(None);
3035 };
3036 let Some((hazard, first, second)) = survival_hazard_theta_first_second(age, cfg)? else {
3037 return Ok(None);
3038 };
3039 let (cum_hazard, instant_hazard) = hazard;
3040 let survival = (-cum_hazard).exp();
3041 let a = survival / normal_pdf(point.q);
3042 let b = point.q * a - 1.0;
3043 let b_factor = a + point.q * b;
3044 let dim = first.len();
3045 let mut first_out = Vec::with_capacity(dim);
3046 let mut second_out = vec![vec![(0.0, 0.0); dim]; dim];
3047 for i in 0..dim {
3048 let (h_i, inst_i) = first[i];
3049 first_out.push((a * h_i, a * (inst_i + instant_hazard * b * h_i)));
3050 }
3051 for i in 0..dim {
3052 for j in i..dim {
3053 let (h_i, inst_i) = first[i];
3054 let (h_j, inst_j) = first[j];
3055 let (h_ij, inst_ij) = second[i][j];
3056 let a_j = a * b * h_j;
3057 let b_j = a * h_j * b_factor;
3058 let q_ij = a * h_ij + a * b * h_i * h_j;
3059 let qt_inner_i = inst_i + instant_hazard * b * h_i;
3060 let qt_ij = a_j * qt_inner_i
3061 + a * (inst_ij + inst_j * b * h_i + instant_hazard * (b_j * h_i + b * h_ij));
3062 let mixed = (q_ij, qt_ij);
3063 second_out[i][j] = mixed;
3064 second_out[j][i] = mixed;
3065 }
3066 }
3067 Ok(Some(MarginalSlopeBaselineOffsetThetaGeometry {
3068 value: (point.q, point.q_t),
3069 first: first_out,
3070 second: second_out,
3071 }))
3072}
3073
3074type HazardFirstSecond = ((f64, f64), Vec<(f64, f64)>, Vec<Vec<(f64, f64)>>);
3075
3076fn survival_hazard_theta_first_second(
3077 age: f64,
3078 cfg: &SurvivalBaselineConfig,
3079) -> Result<Option<HazardFirstSecond>, String> {
3080 let Some(hazard) = survival_cumulative_and_instant_hazard(age, cfg)? else {
3081 return Ok(None);
3082 };
3083 let first = survival_hazard_theta_partials(age, cfg)?
3084 .ok_or_else(|| "unexpected missing hazard partials".to_string())?;
3085 let dim = first.len();
3086 let mut second = vec![vec![(0.0, 0.0); dim]; dim];
3087 match cfg.target {
3088 SurvivalBaselineTarget::Linear => return Ok(None),
3089 SurvivalBaselineTarget::Weibull => {
3090 let scale = cfg
3091 .scale
3092 .ok_or_else(|| "weibull missing scale".to_string())?;
3093 let shape = cfg
3094 .shape
3095 .ok_or_else(|| "weibull missing shape".to_string())?;
3096 let log_time_ratio = age.ln() - scale.ln();
3097 let cumulative_hazard = hazard.0;
3098 let instant_hazard = hazard.1;
3099 let eta = shape * log_time_ratio;
3100 second[0][0] = (
3101 shape * shape * cumulative_hazard,
3102 shape * shape * instant_hazard,
3103 );
3104 second[0][1] = (
3105 -shape * cumulative_hazard * (1.0 + eta),
3106 -shape * instant_hazard * (2.0 + eta),
3107 );
3108 second[1][0] = second[0][1];
3109 second[1][1] = (
3110 eta * cumulative_hazard * (1.0 + eta),
3111 (eta + (1.0 + eta) * (1.0 + eta)) * instant_hazard,
3112 );
3113 }
3114 SurvivalBaselineTarget::Gompertz => {
3115 let rate = cfg
3116 .rate
3117 .ok_or_else(|| "gompertz missing rate".to_string())?;
3118 let shape = cfg
3119 .shape
3120 .ok_or_else(|| "gompertz missing shape".to_string())?;
3121 second[0][0] = first[0];
3122 second[0][1] = first[1];
3123 second[1][0] = first[1];
3124 second[1][1] = gompertz_cumulative_shape_second_derivative(age, rate, shape);
3125 }
3126 SurvivalBaselineTarget::GompertzMakeham => {
3127 let rate = cfg.rate.ok_or_else(|| "gm missing rate".to_string())?;
3128 let shape = cfg.shape.ok_or_else(|| "gm missing shape".to_string())?;
3129 second[0][0] = first[0];
3130 second[0][1] = first[1];
3131 second[1][0] = first[1];
3132 second[1][1] = gompertz_cumulative_shape_second_derivative(age, rate, shape);
3133 second[2][2] = first[2];
3134 }
3135 }
3136 Ok(Some((hazard, first, second)))
3137}
3138
3139#[inline]
3140fn gompertz_cumulative_shape_second_derivative(age: f64, rate: f64, shape: f64) -> (f64, f64) {
3141 let x = shape * age;
3142 if x.abs() < 1e-3 {
3154 let t = age;
3155 (
3156 rate * t * t * t * (1.0 / 3.0 + x / 4.0 + x * x / 10.0),
3157 rate * t * t * (1.0 + x + 0.5 * x * x),
3158 )
3159 } else {
3160 let e = x.exp();
3161 let em1 = x.exp_m1();
3162 let n = shape * age * e - em1;
3163 (
3164 rate * (age * age * e / shape - 2.0 * n / (shape * shape * shape)),
3165 rate * age * age * e,
3166 )
3167 }
3168}
3169
3170#[derive(Clone, Copy)]
3175enum BaselineOffsetEvaluator {
3176 LogCumulativeHazard,
3177 ProbitSurvival,
3178}
3179
3180impl BaselineOffsetEvaluator {
3181 fn length_error(self) -> String {
3182 match self {
3183 Self::LogCumulativeHazard => SurvivalConstructionError::IncompatibleDimensions {
3184 reason: "survival baseline offsets require matching entry/exit lengths".to_string(),
3185 }
3186 .into(),
3187 Self::ProbitSurvival => {
3188 "survival probit baseline offsets require matching entry/exit lengths".to_string()
3189 }
3190 }
3191 }
3192
3193 fn finite_error(self) -> &'static str {
3194 match self {
3195 Self::LogCumulativeHazard => "non-finite survival baseline offsets computed",
3196 Self::ProbitSurvival => "non-finite survival probit baseline offsets computed",
3197 }
3198 }
3199
3200 fn evaluate(self, age: f64, cfg: &SurvivalBaselineConfig) -> Result<(f64, f64), String> {
3201 match self {
3202 Self::LogCumulativeHazard => evaluate_survival_baseline(age, cfg),
3203 Self::ProbitSurvival => evaluate_survival_marginal_slope_baseline(age, cfg),
3204 }
3205 }
3206
3207 fn exit_is_finite(self, value: f64, age: f64) -> bool {
3208 match self {
3209 Self::LogCumulativeHazard => {
3210 value.is_finite() || (age == 0.0 && value == f64::NEG_INFINITY)
3211 }
3212 Self::ProbitSurvival => value.is_finite(),
3213 }
3214 }
3215}
3216
3217fn build_survival_offsets_with_evaluator(
3218 age_entry: &Array1<f64>,
3219 age_exit: &Array1<f64>,
3220 cfg: &SurvivalBaselineConfig,
3221 evaluator: BaselineOffsetEvaluator,
3222) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3223 if age_entry.len() != age_exit.len() {
3224 return Err(evaluator.length_error());
3225 }
3226 let n = age_entry.len();
3227 let triples: Vec<(f64, f64, f64)> = (0..n)
3230 .into_par_iter()
3231 .map(|i| -> Result<(f64, f64, f64), String> {
3232 let entry_age = age_entry[i];
3236 let e0 = if !entry_age.is_finite() {
3237 return Err(SurvivalConstructionError::DataValidationFailed {
3238 reason: format!("non-finite entry age at row {i}"),
3239 }
3240 .into());
3241 } else if entry_age <= 0.0 {
3242 0.0
3243 } else {
3244 evaluator.evaluate(entry_age, cfg)?.0
3245 };
3246 let exit_age = age_exit[i];
3247 let (e1, d1) = evaluator.evaluate(exit_age, cfg)?;
3248 if !e0.is_finite() || !evaluator.exit_is_finite(e1, exit_age) || !d1.is_finite() {
3249 return Err(SurvivalConstructionError::DataValidationFailed {
3250 reason: evaluator.finite_error().to_string(),
3251 }
3252 .into());
3253 }
3254 Ok((e0, e1, d1))
3255 })
3256 .collect::<Result<Vec<_>, String>>()?;
3257 let mut eta_entry = Array1::<f64>::zeros(n);
3258 let mut eta_exit = Array1::<f64>::zeros(n);
3259 let mut derivative_exit = Array1::<f64>::zeros(n);
3260 for (i, (e0, e1, d1)) in triples.into_iter().enumerate() {
3261 eta_entry[i] = e0;
3262 eta_exit[i] = e1;
3263 derivative_exit[i] = d1;
3264 }
3265 Ok((eta_entry, eta_exit, derivative_exit))
3266}
3267
3268pub fn build_survival_baseline_offsets(
3271 age_entry: &Array1<f64>,
3272 age_exit: &Array1<f64>,
3273 cfg: &SurvivalBaselineConfig,
3274) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3275 build_survival_offsets_with_evaluator(
3276 age_entry,
3277 age_exit,
3278 cfg,
3279 BaselineOffsetEvaluator::LogCumulativeHazard,
3280 )
3281}
3282
3283pub fn build_survival_marginal_slope_baseline_offsets(
3286 age_entry: &Array1<f64>,
3287 age_exit: &Array1<f64>,
3288 cfg: &SurvivalBaselineConfig,
3289) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3290 build_survival_offsets_with_evaluator(
3291 age_entry,
3292 age_exit,
3293 cfg,
3294 BaselineOffsetEvaluator::ProbitSurvival,
3295 )
3296}
3297
3298#[derive(Clone, Debug)]
3305pub struct SurvivalMarginalSlopeOffsetGeometry {
3306 pub baseline_config: SurvivalBaselineConfig,
3307 pub theta: Array1<f64>,
3308 pub offset_entry: Array1<f64>,
3309 pub offset_exit: Array1<f64>,
3310 pub derivative_offset_exit: Array1<f64>,
3311 pub offset_entry_theta_first: Array2<f64>,
3312 pub offset_exit_theta_first: Array2<f64>,
3313 pub derivative_offset_exit_theta_first: Array2<f64>,
3314 pub offset_entry_theta_second: Array3<f64>,
3315 pub offset_exit_theta_second: Array3<f64>,
3316 pub derivative_offset_exit_theta_second: Array3<f64>,
3317}
3318
3319fn validate_marginal_slope_baseline_row_geometry(
3320 row: &MarginalSlopeBaselineOffsetThetaGeometry,
3321 dim: usize,
3322 channel: &str,
3323) -> Result<(), String> {
3324 if row.first.len() != dim
3325 || row.second.len() != dim
3326 || row.second.iter().any(|axis| axis.len() != dim)
3327 {
3328 return Err(format!(
3329 "survival marginal-slope baseline {channel} theta dimension drifted"
3330 ));
3331 }
3332 if !row.value.0.is_finite()
3333 || !row.value.1.is_finite()
3334 || row
3335 .first
3336 .iter()
3337 .any(|&(value, derivative)| !value.is_finite() || !derivative.is_finite())
3338 || row
3339 .second
3340 .iter()
3341 .flatten()
3342 .any(|&(value, derivative)| !value.is_finite() || !derivative.is_finite())
3343 {
3344 return Err(format!(
3345 "survival marginal-slope baseline {channel} geometry must be finite"
3346 ));
3347 }
3348 Ok(())
3349}
3350
3351pub fn build_survival_marginal_slope_baseline_geometry(
3358 age_entry: &Array1<f64>,
3359 age_exit: &Array1<f64>,
3360 cfg: &SurvivalBaselineConfig,
3361) -> Result<Option<SurvivalMarginalSlopeOffsetGeometry>, String> {
3362 if age_entry.len() != age_exit.len() {
3363 return Err(
3364 "survival marginal-slope baseline geometry requires matching entry/exit lengths"
3365 .to_string(),
3366 );
3367 }
3368 let Some(theta) = survival_baseline_theta_from_config(cfg)? else {
3369 return Ok(None);
3370 };
3371 if theta.iter().any(|value| !value.is_finite()) {
3372 return Err(
3373 "survival marginal-slope baseline theta coordinates must be finite".to_string(),
3374 );
3375 }
3376 survival_baseline_config_from_theta(cfg.target, &theta)?;
3379 let dim = theta.len();
3380 let zero = || MarginalSlopeBaselineOffsetThetaGeometry {
3381 value: (0.0, 0.0),
3382 first: vec![(0.0, 0.0); dim],
3383 second: vec![vec![(0.0, 0.0); dim]; dim],
3384 };
3385 let rows = (0..age_exit.len())
3386 .into_par_iter()
3387 .map(
3388 |row_index| -> Result<
3389 (
3390 MarginalSlopeBaselineOffsetThetaGeometry,
3391 MarginalSlopeBaselineOffsetThetaGeometry,
3392 ),
3393 String,
3394 > {
3395 let entry_age = age_entry[row_index];
3396 if !entry_age.is_finite() || entry_age < 0.0 {
3397 return Err(format!(
3398 "survival marginal-slope entry age must be finite and non-negative at row {row_index}"
3399 ));
3400 }
3401 let exit_age = age_exit[row_index];
3402 if !exit_age.is_finite() || exit_age < 0.0 {
3403 return Err(format!(
3404 "survival marginal-slope exit age must be finite and non-negative at row {row_index}"
3405 ));
3406 }
3407 let entry = if entry_age == 0.0 {
3408 zero()
3409 } else {
3410 marginal_slope_baseline_offset_theta_geometry(entry_age, cfg)?.ok_or_else(
3411 || {
3412 "nonlinear survival baseline unexpectedly has no entry geometry"
3413 .to_string()
3414 },
3415 )?
3416 };
3417 let exit = marginal_slope_baseline_offset_theta_geometry(exit_age, cfg)?
3418 .ok_or_else(|| {
3419 "nonlinear survival baseline unexpectedly has no exit geometry".to_string()
3420 })?;
3421 validate_marginal_slope_baseline_row_geometry(&entry, dim, "entry")?;
3422 validate_marginal_slope_baseline_row_geometry(&exit, dim, "exit")?;
3423 Ok((entry, exit))
3424 },
3425 )
3426 .collect::<Result<Vec<_>, String>>()?;
3427
3428 let n = rows.len();
3429 let mut offset_entry = Array1::<f64>::zeros(n);
3430 let mut offset_exit = Array1::<f64>::zeros(n);
3431 let mut derivative_offset_exit = Array1::<f64>::zeros(n);
3432 let mut offset_entry_theta_first = Array2::<f64>::zeros((n, dim));
3433 let mut offset_exit_theta_first = Array2::<f64>::zeros((n, dim));
3434 let mut derivative_offset_exit_theta_first = Array2::<f64>::zeros((n, dim));
3435 let mut offset_entry_theta_second = Array3::<f64>::zeros((n, dim, dim));
3436 let mut offset_exit_theta_second = Array3::<f64>::zeros((n, dim, dim));
3437 let mut derivative_offset_exit_theta_second = Array3::<f64>::zeros((n, dim, dim));
3438 for (row_index, (entry, exit)) in rows.into_iter().enumerate() {
3439 offset_entry[row_index] = entry.value.0;
3440 offset_exit[row_index] = exit.value.0;
3441 derivative_offset_exit[row_index] = exit.value.1;
3442 for axis in 0..dim {
3443 offset_entry_theta_first[[row_index, axis]] = entry.first[axis].0;
3444 offset_exit_theta_first[[row_index, axis]] = exit.first[axis].0;
3445 derivative_offset_exit_theta_first[[row_index, axis]] = exit.first[axis].1;
3446 for other_axis in 0..dim {
3447 offset_entry_theta_second[[row_index, axis, other_axis]] =
3448 entry.second[axis][other_axis].0;
3449 offset_exit_theta_second[[row_index, axis, other_axis]] =
3450 exit.second[axis][other_axis].0;
3451 derivative_offset_exit_theta_second[[row_index, axis, other_axis]] =
3452 exit.second[axis][other_axis].1;
3453 }
3454 }
3455 }
3456 Ok(Some(SurvivalMarginalSlopeOffsetGeometry {
3457 baseline_config: cfg.clone(),
3458 theta,
3459 offset_entry,
3460 offset_exit,
3461 derivative_offset_exit,
3462 offset_entry_theta_first,
3463 offset_exit_theta_first,
3464 derivative_offset_exit_theta_first,
3465 offset_entry_theta_second,
3466 offset_exit_theta_second,
3467 derivative_offset_exit_theta_second,
3468 }))
3469}
3470
3471#[derive(Clone, Debug)]
3480pub struct SurvivalMarginalSlopeFrozenOffsetChart {
3481 age_entry: Array1<f64>,
3482 age_exit: Array1<f64>,
3483 target: SurvivalBaselineTarget,
3484 initial_theta: Array1<f64>,
3485 lower_theta: Array1<f64>,
3486 upper_theta: Array1<f64>,
3487 fixed_offset_entry: Array1<f64>,
3488 fixed_offset_exit: Array1<f64>,
3489 fixed_derivative_offset_exit: Array1<f64>,
3490}
3491
3492impl SurvivalMarginalSlopeFrozenOffsetChart {
3493 pub fn new(
3494 age_entry: &Array1<f64>,
3495 age_exit: &Array1<f64>,
3496 initial_config: &SurvivalBaselineConfig,
3497 prepared_offset_entry: &Array1<f64>,
3498 prepared_offset_exit: &Array1<f64>,
3499 prepared_derivative_offset_exit: &Array1<f64>,
3500 ) -> Result<Self, String> {
3501 let n = age_exit.len();
3502 if age_entry.len() != n
3503 || prepared_offset_entry.len() != n
3504 || prepared_offset_exit.len() != n
3505 || prepared_derivative_offset_exit.len() != n
3506 {
3507 return Err(format!(
3508 "survival marginal-slope frozen offset chart length mismatch: entry={}, exit={n}, prepared_entry={}, prepared_exit={}, prepared_derivative={}",
3509 age_entry.len(),
3510 prepared_offset_entry.len(),
3511 prepared_offset_exit.len(),
3512 prepared_derivative_offset_exit.len(),
3513 ));
3514 }
3515 if prepared_offset_entry
3516 .iter()
3517 .chain(prepared_offset_exit.iter())
3518 .chain(prepared_derivative_offset_exit.iter())
3519 .any(|value| !value.is_finite())
3520 {
3521 return Err(
3522 "survival marginal-slope prepared offsets must be finite before freezing"
3523 .to_string(),
3524 );
3525 }
3526 let initial_geometry =
3527 build_survival_marginal_slope_baseline_geometry(age_entry, age_exit, initial_config)?
3528 .ok_or_else(|| {
3529 String::from(
3530 "survival marginal-slope frozen offset chart requires a nonlinear baseline",
3531 )
3532 })?;
3533 let lower_theta = initial_geometry.theta.mapv(|value| value - 6.0);
3534 let upper_theta = initial_geometry.theta.mapv(|value| value + 6.0);
3535 Ok(Self {
3536 age_entry: age_entry.clone(),
3537 age_exit: age_exit.clone(),
3538 target: initial_config.target,
3539 initial_theta: initial_geometry.theta,
3540 lower_theta,
3541 upper_theta,
3542 fixed_offset_entry: prepared_offset_entry - &initial_geometry.offset_entry,
3543 fixed_offset_exit: prepared_offset_exit - &initial_geometry.offset_exit,
3544 fixed_derivative_offset_exit: prepared_derivative_offset_exit
3545 - &initial_geometry.derivative_offset_exit,
3546 })
3547 }
3548
3549 pub fn target(&self) -> SurvivalBaselineTarget {
3550 self.target
3551 }
3552
3553 pub fn initial_theta(&self) -> &Array1<f64> {
3554 &self.initial_theta
3555 }
3556
3557 pub fn theta_bounds(&self) -> (&Array1<f64>, &Array1<f64>) {
3562 (&self.lower_theta, &self.upper_theta)
3563 }
3564
3565 pub fn fixed_offsets(&self) -> (&Array1<f64>, &Array1<f64>, &Array1<f64>) {
3566 (
3567 &self.fixed_offset_entry,
3568 &self.fixed_offset_exit,
3569 &self.fixed_derivative_offset_exit,
3570 )
3571 }
3572
3573 pub fn evaluate_initial(&self) -> Result<SurvivalMarginalSlopeOffsetGeometry, String> {
3574 self.evaluate(&self.initial_theta)
3575 }
3576
3577 pub fn evaluate(
3578 &self,
3579 theta: &Array1<f64>,
3580 ) -> Result<SurvivalMarginalSlopeOffsetGeometry, String> {
3581 let config = survival_baseline_config_from_theta(self.target, theta)?;
3582 let mut geometry = build_survival_marginal_slope_baseline_geometry(
3583 &self.age_entry,
3584 &self.age_exit,
3585 &config,
3586 )?
3587 .ok_or_else(|| {
3588 "survival marginal-slope nonlinear baseline chart lost its theta coordinates"
3589 .to_string()
3590 })?;
3591 geometry.offset_entry += &self.fixed_offset_entry;
3592 geometry.offset_exit += &self.fixed_offset_exit;
3593 geometry.derivative_offset_exit += &self.fixed_derivative_offset_exit;
3594 Ok(geometry)
3595 }
3596}
3597
3598pub fn location_scale_uses_probit_survival_baseline(inverse_link: Option<&InverseLink>) -> bool {
3599 matches!(
3600 inverse_link,
3601 Some(
3602 InverseLink::Standard(StandardLink::Probit)
3603 | InverseLink::LatentCLogLog(_)
3604 | InverseLink::Sas(_)
3605 | InverseLink::BetaLogistic(_)
3606 | InverseLink::Mixture(_)
3607 )
3608 )
3609}
3610
3611pub fn survival_derivative_guard_for_likelihood(likelihood_mode: SurvivalLikelihoodMode) -> f64 {
3612 match likelihood_mode {
3613 SurvivalLikelihoodMode::LocationScale
3614 | SurvivalLikelihoodMode::Latent
3615 | SurvivalLikelihoodMode::LatentBinary => DEFAULT_SURVIVAL_LOCATION_SCALE_DERIVATIVE_GUARD,
3616 SurvivalLikelihoodMode::MarginalSlope => DEFAULT_SURVIVAL_MARGINAL_SLOPE_DERIVATIVE_GUARD,
3617 SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull => 0.0,
3618 }
3619}
3620
3621pub fn survival_marginal_slope_offset_baseline_config(
3630 age_exit: &Array1<f64>,
3631 requested: &SurvivalBaselineConfig,
3632) -> SurvivalBaselineConfig {
3633 if requested.target == SurvivalBaselineTarget::Linear {
3634 SurvivalBaselineConfig {
3635 target: SurvivalBaselineTarget::Weibull,
3636 scale: Some(positive_survival_time_seed(age_exit)),
3637 shape: Some(1.0),
3638 rate: None,
3639 makeham: None,
3640 }
3641 } else {
3642 requested.clone()
3643 }
3644}
3645
3646pub fn build_survival_time_offsets_for_likelihood(
3647 age_entry: &Array1<f64>,
3648 age_exit: &Array1<f64>,
3649 baseline_cfg: &SurvivalBaselineConfig,
3650 likelihood_mode: SurvivalLikelihoodMode,
3651 inverse_link: Option<&InverseLink>,
3652) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3653 if likelihood_mode == SurvivalLikelihoodMode::MarginalSlope
3654 || (likelihood_mode == SurvivalLikelihoodMode::LocationScale
3655 && location_scale_uses_probit_survival_baseline(inverse_link))
3656 {
3657 build_survival_marginal_slope_baseline_offsets(age_entry, age_exit, baseline_cfg)
3658 } else {
3659 build_survival_baseline_offsets(age_entry, age_exit, baseline_cfg)
3660 }
3661}
3662
3663pub fn add_survival_time_derivative_guard_offset(
3664 age_entry: &Array1<f64>,
3665 age_exit: &Array1<f64>,
3666 anchor_time: f64,
3667 derivative_guard: f64,
3668 eta_offset_entry: &mut Array1<f64>,
3669 eta_offset_exit: &mut Array1<f64>,
3670 derivative_offset_exit: &mut Array1<f64>,
3671) -> Result<(), String> {
3672 if derivative_guard <= 0.0 {
3673 return Ok(());
3674 }
3675 let n = age_entry.len();
3676 if age_exit.len() != n
3677 || eta_offset_entry.len() != n
3678 || eta_offset_exit.len() != n
3679 || derivative_offset_exit.len() != n
3680 {
3681 return Err(SurvivalConstructionError::IncompatibleDimensions {
3682 reason: "survival derivative-guard offset lengths must match".to_string(),
3683 }
3684 .into());
3685 }
3686 for i in 0..n {
3687 eta_offset_entry[i] += derivative_guard * (age_entry[i] - anchor_time);
3688 eta_offset_exit[i] += derivative_guard * (age_exit[i] - anchor_time);
3689 derivative_offset_exit[i] += derivative_guard;
3690 }
3691 Ok(())
3692}
3693
3694#[derive(Clone, Debug)]
3695pub struct LatentSurvivalBaselineOffsets {
3696 pub loaded_eta_entry: Array1<f64>,
3697 pub loaded_eta_exit: Array1<f64>,
3698 pub loaded_derivative_exit: Array1<f64>,
3699 pub unloaded_mass_entry: Array1<f64>,
3700 pub unloaded_mass_exit: Array1<f64>,
3701 pub unloaded_hazard_exit: Array1<f64>,
3702}
3703
3704pub fn build_latent_survival_baseline_offsets(
3705 age_entry: &Array1<f64>,
3706 age_exit: &Array1<f64>,
3707 cfg: &SurvivalBaselineConfig,
3708 loading: HazardLoading,
3709) -> Result<LatentSurvivalBaselineOffsets, String> {
3710 if age_entry.len() != age_exit.len() {
3711 return Err(
3712 "latent survival baseline offsets require matching entry/exit lengths".to_string(),
3713 );
3714 }
3715
3716 fn gompertz_components(age: f64, rate: f64, shape: f64) -> (f64, f64) {
3717 if shape.abs() < 1e-10 {
3718 let x = shape * age;
3725 return (
3726 rate * age * (1.0 + 0.5 * x + x * x / 6.0),
3727 rate * (1.0 + x + 0.5 * x * x),
3728 );
3729 }
3730 let shape_age = shape * age;
3731 let cumulative_hazard = (rate / shape) * shape_age.exp_m1();
3732 let instant_hazard = rate * shape_age.exp();
3733 (cumulative_hazard, instant_hazard)
3734 }
3735
3736 let n = age_entry.len();
3737
3738 let rows: Vec<[f64; 6]> = (0..n)
3741 .into_par_iter()
3742 .map(|i| -> Result<[f64; 6], String> {
3743 let entry = age_entry[i];
3744 let exit = age_exit[i];
3745 if !entry.is_finite()
3746 || !exit.is_finite()
3747 || entry <= 0.0
3748 || exit <= 0.0
3749 || exit < entry
3750 {
3751 return Err(format!(
3752 "latent survival baseline offsets require finite positive entry/exit ages with exit >= entry (row {})",
3753 i + 1
3754 ));
3755 }
3756 match loading {
3757 HazardLoading::Full => {
3758 let (eta_entry, _) = evaluate_survival_baseline(entry, cfg)?;
3759 let (eta_exit, derivative_exit) = evaluate_survival_baseline(exit, cfg)?;
3760 Ok([eta_entry, eta_exit, derivative_exit, 0.0, 0.0, 0.0])
3761 }
3762 HazardLoading::LoadedVsUnloaded => {
3763 if cfg.target != SurvivalBaselineTarget::GompertzMakeham {
3764 return Err(format!(
3765 "HazardLoading::LoadedVsUnloaded requires --baseline-target gompertz-makeham, got {}",
3766 survival_baseline_targetname(cfg.target)
3767 ));
3768 }
3769 let rate = cfg.rate.ok_or_else(|| {
3770 "gompertz-makeham latent survival is missing baseline rate".to_string()
3771 })?;
3772 let shape = cfg.shape.ok_or_else(|| {
3773 "gompertz-makeham latent survival is missing baseline shape".to_string()
3774 })?;
3775 let makeham = cfg.makeham.ok_or_else(|| {
3776 "gompertz-makeham latent survival is missing baseline makeham".to_string()
3777 })?;
3778 let (loaded_entry, _) = gompertz_components(entry, rate, shape);
3779 let (loaded_exit, loaded_hazard) = gompertz_components(exit, rate, shape);
3780 if !(loaded_entry.is_finite()
3781 && loaded_entry > 0.0
3782 && loaded_exit.is_finite()
3783 && loaded_exit > 0.0
3784 && loaded_hazard.is_finite()
3785 && loaded_hazard > 0.0)
3786 {
3787 return Err(format!(
3788 "gompertz-makeham latent loaded component produced a non-positive or non-finite hazard decomposition at row {}",
3789 i + 1
3790 ));
3791 }
3792 Ok([
3793 loaded_entry.ln(),
3794 loaded_exit.ln(),
3795 loaded_hazard / loaded_exit,
3796 makeham * entry,
3797 makeham * exit,
3798 makeham,
3799 ])
3800 }
3801 }
3802 })
3803 .collect::<Result<Vec<_>, String>>()?;
3804
3805 let mut loaded_eta_entry = Array1::<f64>::zeros(n);
3806 let mut loaded_eta_exit = Array1::<f64>::zeros(n);
3807 let mut loaded_derivative_exit = Array1::<f64>::zeros(n);
3808 let mut unloaded_mass_entry = Array1::<f64>::zeros(n);
3809 let mut unloaded_mass_exit = Array1::<f64>::zeros(n);
3810 let mut unloaded_hazard_exit = Array1::<f64>::zeros(n);
3811 for (i, row) in rows.into_iter().enumerate() {
3812 loaded_eta_entry[i] = row[0];
3813 loaded_eta_exit[i] = row[1];
3814 loaded_derivative_exit[i] = row[2];
3815 unloaded_mass_entry[i] = row[3];
3816 unloaded_mass_exit[i] = row[4];
3817 unloaded_hazard_exit[i] = row[5];
3818 }
3819
3820 Ok(LatentSurvivalBaselineOffsets {
3821 loaded_eta_entry,
3822 loaded_eta_exit,
3823 loaded_derivative_exit,
3824 unloaded_mass_entry,
3825 unloaded_mass_exit,
3826 unloaded_hazard_exit,
3827 })
3828}
3829
3830pub fn build_survival_timewiggle_derivative_design(
3835 eta_exit: &Array1<f64>,
3836 derivative_exit: &Array1<f64>,
3837 knots: &Array1<f64>,
3838 degree: usize,
3839) -> Result<Array2<f64>, String> {
3840 let mut design_derivative_exit =
3841 monotone_wiggle_basis_with_derivative_order(eta_exit.view(), knots, degree, 1)?;
3842 for i in 0..design_derivative_exit.nrows() {
3843 let chain = derivative_exit[i];
3844 for j in 0..design_derivative_exit.ncols() {
3845 design_derivative_exit[[i, j]] *= chain;
3846 }
3847 }
3848 Ok(design_derivative_exit)
3849}
3850
3851pub fn build_survival_timewiggle_from_baseline(
3861 eta_entry: &Array1<f64>,
3862 eta_exit: &Array1<f64>,
3863 derivative_exit: &Array1<f64>,
3864 cfg: &LinkWiggleFormulaSpec,
3865) -> Result<SurvivalTimeWiggleBuild, String> {
3866 if eta_entry.len() != eta_exit.len() || eta_exit.len() != derivative_exit.len() {
3867 return Err(
3868 "baseline-timewiggle requires matching entry/exit/derivative lengths".to_string(),
3869 );
3870 }
3871 let all_zero = eta_entry.iter().all(|&v| v.abs() < 1e-15)
3874 && eta_exit.iter().all(|&v| v.abs() < 1e-15)
3875 && derivative_exit.iter().all(|&v| v.abs() < 1e-15);
3876 if all_zero {
3877 return Err(
3878 "timewiggle requires a non-linear scalar survival baseline target; \
3879 the provided baseline offsets are all zero (linear baseline)"
3880 .to_string(),
3881 );
3882 }
3883 let n = eta_exit.len();
3884 let mut seed = Array1::<f64>::zeros(2 * n);
3885 for i in 0..n {
3886 seed[i] = eta_entry[i];
3887 seed[n + i] = eta_exit[i];
3888 }
3889 let (primary_order, extra_orders) = split_wiggle_penalty_orders(2, &cfg.penalty_orders)?;
3893 let wiggle_cfg = WiggleBlockConfig {
3894 degree: cfg.degree,
3895 num_internal_knots: cfg.num_internal_knots,
3896 penalty_order: primary_order,
3897 double_penalty: cfg.double_penalty,
3898 };
3899 let (mut combined_block, knots) = buildwiggle_block_input_from_seed(seed.view(), &wiggle_cfg)?;
3900 append_selected_wiggle_function_penalties(
3901 &mut combined_block,
3902 &knots,
3903 cfg.degree,
3904 &extra_orders,
3905 )?;
3906 let ncols = combined_block.design.ncols();
3907 Ok(SurvivalTimeWiggleBuild {
3908 nullspace_dims: combined_block.nullspace_dims.clone(),
3909 penalties: {
3910 combined_block
3911 .penalties
3912 .into_iter()
3913 .map(|ps| ps.to_global(ncols))
3914 .collect()
3915 },
3916 knots,
3917 degree: cfg.degree,
3918 ncols,
3919 })
3920}
3921
3922pub fn append_zero_tail_columns(
3923 x_entry: &mut DesignMatrix,
3924 x_exit: &mut DesignMatrix,
3925 x_derivative: &mut DesignMatrix,
3926 tail_cols: usize,
3927) {
3928 if tail_cols == 0 {
3929 return;
3930 }
3931 fn append_dense(dm: &mut DesignMatrix, tail: usize) {
3934 let old = dm.to_dense();
3935 let n = old.nrows();
3936 let p_base = old.ncols();
3937 let mut out = Array2::<f64>::zeros((n, p_base + tail));
3938 out.slice_mut(s![.., 0..p_base]).assign(&old);
3939 *dm = DesignMatrix::Dense(DenseDesignMatrix::from(out));
3940 }
3941 append_dense(x_entry, tail_cols);
3942 append_dense(x_exit, tail_cols);
3943 append_dense(x_derivative, tail_cols);
3944}
3945
3946pub fn build_time_varying_survival_covariate_template(
3957 age_entry: &Array1<f64>,
3958 age_exit: &Array1<f64>,
3959 time_k: usize,
3960 time_degree: usize,
3961 block_name: &str,
3962) -> Result<SurvivalCovariateTermBlockTemplate, String> {
3963 if time_k < time_degree + 1 {
3964 return Err(format!(
3965 "--{block_name}-time-k must be >= degree + 1 = {}, got {time_k}",
3966 time_degree + 1
3967 ));
3968 }
3969 let num_internal_knots = time_k - (time_degree + 1);
3970
3971 let log_exit = age_exit.mapv(|t| t.max(1e-12).ln());
3972
3973 let time_spec = BSplineBasisSpec {
3974 degree: time_degree,
3975 penalty_order: 2,
3976 knotspec: BSplineKnotSpec::Automatic {
3977 num_internal_knots: Some(num_internal_knots),
3978 placement: gam_terms::basis::BSplineKnotPlacement::Quantile,
3979 },
3980 double_penalty: false,
3981 identifiability: BSplineIdentifiability::None,
3982 boundary: OneDimensionalBoundary::Open,
3983 boundary_conditions: BSplineBoundaryConditions::default(),
3984 };
3985
3986 let time_build = build_bspline_basis_1d(log_exit.view(), &time_spec)
3987 .map_err(|e| format!("failed to build {block_name} time-margin B-spline basis: {e}"))?;
3988 let time_design_exit = time_build.design.to_dense();
3989
3990 let knots = match &time_build.metadata {
3991 BasisMetadata::BSpline1D { knots, .. } => knots.clone(),
3992 _ => {
3993 return Err(format!(
3994 "{block_name} time-margin basis returned unexpected metadata type"
3995 ));
3996 }
3997 };
3998
3999 let time_penalties = time_build
4000 .active_penalties
4001 .into_iter()
4002 .map(|penalty| penalty.matrix)
4003 .collect();
4004
4005 finish_time_varying_survival_covariate_template(
4006 age_entry,
4007 age_exit,
4008 time_degree,
4009 knots,
4010 time_design_exit,
4011 time_penalties,
4012 block_name,
4013 )
4014}
4015
4016pub fn replay_time_varying_survival_covariate_template(
4020 age_entry: &Array1<f64>,
4021 age_exit: &Array1<f64>,
4022 time_basis: &SurvivalCovariateTimeBasis,
4023 block_name: &str,
4024) -> Result<SurvivalCovariateTermBlockTemplate, String> {
4025 let log_exit = age_exit.mapv(|t| t.max(1e-12).ln());
4026 let knots = Array1::from_vec(time_basis.knots.clone());
4027 let time_build = build_bspline_basis_1d(
4028 log_exit.view(),
4029 &BSplineBasisSpec {
4030 degree: time_basis.degree,
4031 penalty_order: 2,
4032 knotspec: BSplineKnotSpec::Provided(knots.clone()),
4033 double_penalty: false,
4034 identifiability: BSplineIdentifiability::None,
4035 boundary: OneDimensionalBoundary::Open,
4036 boundary_conditions: BSplineBoundaryConditions::default(),
4037 },
4038 )
4039 .map_err(|e| format!("failed to replay {block_name} time-margin B-spline basis: {e}"))?;
4040 let time_design_exit = time_build.design.to_dense();
4041 let time_penalties = time_build
4042 .active_penalties
4043 .into_iter()
4044 .map(|penalty| penalty.matrix)
4045 .collect();
4046 finish_time_varying_survival_covariate_template(
4047 age_entry,
4048 age_exit,
4049 time_basis.degree,
4050 knots,
4051 time_design_exit,
4052 time_penalties,
4053 block_name,
4054 )
4055}
4056
4057fn finish_time_varying_survival_covariate_template(
4058 age_entry: &Array1<f64>,
4059 age_exit: &Array1<f64>,
4060 time_degree: usize,
4061 knots: Array1<f64>,
4062 time_design_exit: Array2<f64>,
4063 time_penalties: Vec<Array2<f64>>,
4064 block_name: &str,
4065) -> Result<SurvivalCovariateTermBlockTemplate, String> {
4066 if age_entry.len() != age_exit.len() {
4067 return Err(format!(
4068 "{block_name} time-margin entry/exit row mismatch: {} versus {}",
4069 age_entry.len(),
4070 age_exit.len()
4071 ));
4072 }
4073 let log_entry = age_entry.mapv(|t| t.max(1e-12).ln());
4074 let log_exit = age_exit.mapv(|t| t.max(1e-12).ln());
4075 let time_build_entry = build_bspline_basis_1d(
4076 log_entry.view(),
4077 &BSplineBasisSpec {
4078 degree: time_degree,
4079 penalty_order: 2,
4080 knotspec: BSplineKnotSpec::Provided(knots.clone()),
4081 double_penalty: false,
4082 identifiability: BSplineIdentifiability::None,
4083 boundary: OneDimensionalBoundary::Open,
4084 boundary_conditions: BSplineBoundaryConditions::default(),
4085 },
4086 )
4087 .map_err(|e| format!("failed to evaluate {block_name} time-margin basis at entry: {e}"))?;
4088 let time_design_entry = time_build_entry.design.to_dense();
4089 let p_time = time_design_exit.ncols();
4090 if p_time == 0 {
4091 return Err(format!(
4092 "{block_name} time-margin basis resolved to zero columns"
4093 ));
4094 }
4095 let mut time_design_derivative_exit = Array2::<f64>::zeros((age_exit.len(), p_time));
4096 time_design_derivative_exit
4097 .as_slice_mut()
4098 .expect("zeros are contiguous")
4099 .par_chunks_mut(p_time)
4100 .enumerate()
4101 .try_for_each(|(i, row_out)| -> Result<(), String> {
4102 let mut deriv_buf = vec![0.0_f64; p_time];
4103 evaluate_bspline_derivative_scalar(
4104 log_exit[i],
4105 knots.view(),
4106 time_degree,
4107 &mut deriv_buf,
4108 )
4109 .map_err(|e| {
4110 format!("failed to evaluate {block_name} time-margin derivative basis: {e}")
4111 })?;
4112 let chain = 1.0 / age_exit[i].max(1e-12);
4113 for j in 0..p_time {
4114 row_out[j] = deriv_buf[j] * chain;
4115 }
4116 Ok(())
4117 })?;
4118
4119 Ok(SurvivalCovariateTermBlockTemplate::TimeVarying {
4120 time_basis: SurvivalCovariateTimeBasis {
4121 degree: time_degree,
4122 knots: knots.to_vec(),
4123 },
4124 time_basis_entry: time_design_entry,
4125 time_basis_exit: time_design_exit,
4126 time_basis_derivative_exit: time_design_derivative_exit,
4127 time_penalties,
4128 })
4129}
4130
4131#[cfg(test)]
4132mod tests {
4133 use super::{
4134 SurvivalBaselineConfig, SurvivalBaselineTarget, SurvivalMarginalSlopeFrozenOffsetChart,
4135 SurvivalTimeBasisConfig, baseline_chain_rule_gradient, baseline_offset_theta_partials,
4136 build_survival_marginal_slope_baseline_geometry,
4137 build_survival_marginal_slope_baseline_offsets, build_survival_time_basis,
4138 build_survival_timewiggle_from_baseline, evaluate_survival_baseline,
4139 evaluate_survival_marginal_slope_baseline, fitted_weibull_baseline_from_linear_time_beta,
4140 gompertz_cumulative_shape_derivative, gompertz_cumulative_shape_second_derivative,
4141 gompertz_hazard_components, marginal_slope_baseline_chain_rule_gradient,
4142 marginal_slope_baseline_chain_rule_hessian, marginal_slope_baseline_offset_theta_partials,
4143 optimize_survival_baseline_config_with_gradient,
4144 optimize_survival_baseline_config_with_gradient_only,
4145 resolve_survival_marginal_slope_time_anchor_value, survival_baseline_config_from_theta,
4146 survival_baseline_theta_from_config,
4147 };
4148 use crate::probability::normal_cdf;
4149 use crate::survival::{OffsetChannelCurvatures, OffsetChannelResiduals};
4150 use gam_terms::inference::formula_dsl::LinkWiggleFormulaSpec;
4151 use ndarray::{Array1, Array2, array};
4152
4153 #[test]
4154 fn fitted_weibull_baseline_uses_identified_anchor_and_slope() {
4155 let fitted = fitted_weibull_baseline_from_linear_time_beta(&array![1.75], 4.5)
4157 .expect("valid Weibull baseline");
4158 assert_eq!(fitted.target, SurvivalBaselineTarget::Weibull);
4159 assert_eq!(fitted.scale, Some(4.5));
4160 assert_eq!(fitted.shape, Some(1.75));
4161 assert_eq!(fitted.rate, None);
4162 assert_eq!(fitted.makeham, None);
4163
4164 assert!(
4166 fitted_weibull_baseline_from_linear_time_beta(&Array1::<f64>::zeros(0), 4.5).is_none()
4167 );
4168 assert!(fitted_weibull_baseline_from_linear_time_beta(&array![0.0], 4.5).is_none());
4170 assert!(fitted_weibull_baseline_from_linear_time_beta(&array![1.0], 0.0).is_none());
4172 }
4173
4174 #[test]
4175 fn survival_timewiggle_keeps_requested_order_one_penalty() {
4176 let eta_entry = array![0.1, 0.3, 0.5, 0.8];
4177 let eta_exit = array![0.4, 0.7, 1.0, 1.4];
4178 let derivative_exit = array![0.9, 1.1, 1.2, 1.3];
4179 let cfg = LinkWiggleFormulaSpec {
4180 degree: 3,
4181 num_internal_knots: 4,
4182 penalty_orders: vec![1, 2, 3],
4183 double_penalty: false,
4184 };
4185
4186 let build =
4187 build_survival_timewiggle_from_baseline(&eta_entry, &eta_exit, &derivative_exit, &cfg)
4188 .expect("build survival timewiggle");
4189
4190 assert_eq!(build.penalties.len(), 3);
4191 assert_eq!(build.nullspace_dims, vec![0, 1, 2]);
4196 assert!(build.ncols > 0);
4197 }
4198
4199 #[test]
4200 fn marginal_slope_frozen_offset_chart_moves_only_parametric_offsets() {
4201 let invalid_empty_config = SurvivalBaselineConfig {
4202 target: SurvivalBaselineTarget::Gompertz,
4203 scale: None,
4204 shape: Some(0.1),
4205 rate: Some(f64::NAN),
4206 makeham: None,
4207 };
4208 assert!(
4209 build_survival_marginal_slope_baseline_geometry(
4210 &Array1::zeros(0),
4211 &Array1::zeros(0),
4212 &invalid_empty_config,
4213 )
4214 .is_err(),
4215 "invalid baseline config must be rejected even with no rows"
4216 );
4217
4218 let age_entry = array![0.0, 0.75, 2.0];
4219 let age_exit = array![1.5, 3.0, 5.5];
4220 let initial_config = SurvivalBaselineConfig {
4221 target: SurvivalBaselineTarget::GompertzMakeham,
4222 scale: None,
4223 shape: Some(0.08),
4224 rate: Some(0.22),
4225 makeham: Some(0.04),
4226 };
4227 let initial_baseline =
4228 build_survival_marginal_slope_baseline_geometry(&age_entry, &age_exit, &initial_config)
4229 .expect("initial baseline geometry")
4230 .expect("nonlinear chart");
4231 let fixed_entry = array![0.125, -0.25, 0.375];
4232 let fixed_exit = array![-0.45, 0.55, 0.65];
4233 let fixed_derivative = array![0.015, 0.025, 0.035];
4234 let prepared_entry = &initial_baseline.offset_entry + &fixed_entry;
4235 let prepared_exit = &initial_baseline.offset_exit + &fixed_exit;
4236 let prepared_derivative = &initial_baseline.derivative_offset_exit + &fixed_derivative;
4237 let chart = SurvivalMarginalSlopeFrozenOffsetChart::new(
4238 &age_entry,
4239 &age_exit,
4240 &initial_config,
4241 &prepared_entry,
4242 &prepared_exit,
4243 &prepared_derivative,
4244 )
4245 .expect("freeze prepared offsets");
4246
4247 let initial = chart.evaluate_initial().expect("evaluate initial theta");
4248 for row in 0..age_exit.len() {
4249 assert!((initial.offset_entry[row] - prepared_entry[row]).abs() < 1e-14);
4250 assert!((initial.offset_exit[row] - prepared_exit[row]).abs() < 1e-14);
4251 assert!((initial.derivative_offset_exit[row] - prepared_derivative[row]).abs() < 1e-14);
4252 }
4253
4254 let mut candidate_theta = chart.initial_theta().clone();
4255 candidate_theta[0] += 0.3;
4256 candidate_theta[1] -= 0.025;
4257 candidate_theta[2] -= 0.2;
4258 let candidate = chart
4259 .evaluate(&candidate_theta)
4260 .expect("evaluate candidate theta");
4261 let candidate_baseline = build_survival_marginal_slope_baseline_geometry(
4262 &age_entry,
4263 &age_exit,
4264 &candidate.baseline_config,
4265 )
4266 .expect("candidate baseline geometry")
4267 .expect("nonlinear chart");
4268 let frozen = chart.fixed_offsets();
4269 for row in 0..age_exit.len() {
4270 assert!(
4271 (candidate.offset_entry[row]
4272 - candidate_baseline.offset_entry[row]
4273 - frozen.0[row])
4274 .abs()
4275 < 1e-14
4276 );
4277 assert!(
4278 (candidate.offset_exit[row] - candidate_baseline.offset_exit[row] - frozen.1[row])
4279 .abs()
4280 < 1e-14
4281 );
4282 assert!(
4283 (candidate.derivative_offset_exit[row]
4284 - candidate_baseline.derivative_offset_exit[row]
4285 - frozen.2[row])
4286 .abs()
4287 < 1e-14
4288 );
4289 }
4290 assert_eq!(
4291 candidate.offset_entry_theta_first,
4292 candidate_baseline.offset_entry_theta_first
4293 );
4294 assert_eq!(
4295 candidate.offset_exit_theta_first,
4296 candidate_baseline.offset_exit_theta_first
4297 );
4298 assert_eq!(
4299 candidate.derivative_offset_exit_theta_first,
4300 candidate_baseline.derivative_offset_exit_theta_first
4301 );
4302 assert_eq!(
4303 candidate.offset_entry_theta_second,
4304 candidate_baseline.offset_entry_theta_second
4305 );
4306 assert_eq!(
4307 candidate.offset_exit_theta_second,
4308 candidate_baseline.offset_exit_theta_second
4309 );
4310 assert_eq!(
4311 candidate.derivative_offset_exit_theta_second,
4312 candidate_baseline.derivative_offset_exit_theta_second
4313 );
4314 assert_eq!(candidate_baseline.offset_entry[0], 0.0);
4315 assert_eq!(
4316 candidate_baseline.offset_entry_theta_first.row(0).sum(),
4317 0.0
4318 );
4319 assert_eq!(
4320 candidate_baseline
4321 .offset_entry_theta_second
4322 .index_axis(ndarray::Axis(0), 0)
4323 .sum(),
4324 0.0
4325 );
4326 assert!(
4327 candidate
4328 .offset_entry_theta_first
4329 .row(0)
4330 .iter()
4331 .all(|value| *value == 0.0)
4332 );
4333 assert!(
4334 candidate
4335 .offset_entry_theta_second
4336 .index_axis(ndarray::Axis(0), 0)
4337 .iter()
4338 .all(|value| *value == 0.0)
4339 );
4340 for row in 0..age_exit.len() {
4341 for axis in 0..candidate_theta.len() {
4342 for other_axis in 0..candidate_theta.len() {
4343 assert_eq!(
4344 candidate.offset_entry_theta_second[[row, axis, other_axis]],
4345 candidate.offset_entry_theta_second[[row, other_axis, axis]],
4346 );
4347 assert_eq!(
4348 candidate.offset_exit_theta_second[[row, axis, other_axis]],
4349 candidate.offset_exit_theta_second[[row, other_axis, axis]],
4350 );
4351 assert_eq!(
4352 candidate.derivative_offset_exit_theta_second[[row, axis, other_axis]],
4353 candidate.derivative_offset_exit_theta_second[[row, other_axis, axis]],
4354 );
4355 }
4356 }
4357 }
4358 }
4359
4360 #[test]
4361 fn marginal_slope_time_anchor_defaults_to_median_exit() {
4362 let age_entry = array![9.0, 1.0, 4.0, 6.0];
4363 let age_exit = array![20.0, 12.0, 18.0, 30.0];
4364 let anchor = resolve_survival_marginal_slope_time_anchor_value(&age_entry, &age_exit, None)
4365 .expect("resolve marginal-slope default time anchor");
4366
4367 assert!(
4368 (anchor - 19.0).abs() <= 1e-12,
4369 "marginal-slope default anchor should be median exit, got {anchor}"
4370 );
4371 }
4372
4373 #[test]
4374 fn marginal_slope_time_anchor_honors_explicit_value() {
4375 let age_entry = array![9.0, 1.0, 4.0, 6.0];
4376 let age_exit = array![20.0, 12.0, 18.0, 30.0];
4377 let anchor =
4378 resolve_survival_marginal_slope_time_anchor_value(&age_entry, &age_exit, Some(7.5))
4379 .expect("resolve explicit marginal-slope time anchor");
4380
4381 assert!(
4382 (anchor - 7.5).abs() <= 1e-12,
4383 "explicit marginal-slope anchor should round-trip, got {anchor}"
4384 );
4385 }
4386
4387 #[test]
4398 fn baseline_optimizer_contracts_agree_on_shared_surface() {
4399 let curvature: Array2<f64> = array![[3.0, 0.5], [0.5, 2.0]];
4404 let theta_star: Array1<f64> = array![2.5_f64.ln(), 1.3_f64.ln()];
4405
4406 let initial = SurvivalBaselineConfig {
4409 target: SurvivalBaselineTarget::Weibull,
4410 scale: Some(1.0),
4411 shape: Some(1.0),
4412 rate: None,
4413 makeham: None,
4414 };
4415
4416 let recovered_theta = |cfg: &SurvivalBaselineConfig| -> Array1<f64> {
4419 survival_baseline_theta_from_config(cfg)
4420 .expect("config→θ")
4421 .expect("Weibull config has a θ")
4422 };
4423
4424 let curvature_cost = curvature.clone();
4427 let star_cost = theta_star.clone();
4428 let cost_at = move |cfg: &SurvivalBaselineConfig| -> Result<f64, String> {
4429 let theta = survival_baseline_theta_from_config(cfg)?
4430 .ok_or_else(|| "expected a θ for the cost surface".to_string())?;
4431 let d = &theta - &star_cost;
4432 let ad = curvature_cost.dot(&d);
4433 Ok(0.5 * d.dot(&ad))
4434 };
4435
4436 let curvature_grad = curvature.clone();
4437 let star_grad = theta_star.clone();
4438 let cost_for_grad = cost_at.clone();
4439 let result_grad_only = optimize_survival_baseline_config_with_gradient_only(
4440 &initial,
4441 "baseline parity (gradient-only)",
4442 move |cfg| {
4443 let cost = cost_for_grad(cfg)?;
4444 let theta = survival_baseline_theta_from_config(cfg)?
4445 .ok_or_else(|| "expected a θ for the gradient".to_string())?;
4446 let gradient = curvature_grad.dot(&(&theta - &star_grad));
4447 Ok((cost, gradient))
4448 },
4449 )
4450 .expect("gradient-only baseline optimization converges");
4451
4452 let curvature_hess = curvature.clone();
4453 let star_hess = theta_star.clone();
4454 let cost_for_hess = cost_at.clone();
4455 let result_grad_hess = optimize_survival_baseline_config_with_gradient(
4456 &initial,
4457 "baseline parity (gradient+Hessian)",
4458 move |cfg| {
4459 let cost = cost_for_hess(cfg)?;
4460 let theta = survival_baseline_theta_from_config(cfg)?
4461 .ok_or_else(|| "expected a θ for the gradient".to_string())?;
4462 let gradient = curvature_hess.dot(&(&theta - &star_hess));
4463 Ok((cost, gradient, curvature_hess.clone()))
4464 },
4465 )
4466 .expect("gradient+Hessian baseline optimization converges");
4467
4468 let theta_grad_only = recovered_theta(&result_grad_only);
4469 let theta_grad_hess = recovered_theta(&result_grad_hess);
4470
4471 for (label, theta) in [
4474 ("gradient-only", &theta_grad_only),
4475 ("gradient+Hessian", &theta_grad_hess),
4476 ] {
4477 let err = (theta - &theta_star)
4478 .mapv(f64::abs)
4479 .fold(0.0_f64, |a, &v| a.max(v));
4480 assert!(
4481 err <= 2e-3,
4482 "{label} contract recovered θ {theta:?} off true minimizer {theta_star:?} by {err:e}"
4483 );
4484 }
4485
4486 let pairwise_max = |a: &Array1<f64>, b: &Array1<f64>| -> f64 {
4490 (a - b).mapv(f64::abs).fold(0.0_f64, |acc, &v| acc.max(v))
4491 };
4492 assert!(
4493 pairwise_max(&theta_grad_only, &theta_grad_hess) <= 2e-3,
4494 "gradient-only vs gradient+Hessian disagree: {theta_grad_only:?} vs {theta_grad_hess:?}"
4495 );
4496 }
4497
4498 #[test]
4499 fn automatic_ispline_time_knots_are_sized_for_antiderivative_degree() {
4500 let age_entry = array![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0];
4501 let age_exit = array![2.0_f64, 3.0, 5.0, 8.0, 13.0, 21.0];
4502 let requested_degree = 3;
4503 let num_internal_knots = 1;
4504
4505 let built = build_survival_time_basis(
4506 &age_entry,
4507 &age_exit,
4508 SurvivalTimeBasisConfig::ISpline {
4509 degree: requested_degree,
4510 knots: Array1::zeros(0),
4511 keep_cols: Vec::new(),
4512 smooth_lambda: 1e-2,
4513 },
4514 Some((num_internal_knots, 1e-2)),
4515 )
4516 .expect("automatic cubic ispline with one interior knot builds");
4517
4518 let working_degree = requested_degree + 1;
4519 let knots = built.knots.expect("resolved ispline knots");
4520 assert_eq!(
4521 knots.len(),
4522 num_internal_knots + 2 * (working_degree + 1),
4523 "I-spline automatic knots must be clamped for the working B-spline degree"
4524 );
4525 assert_eq!(built.degree, Some(requested_degree));
4526 assert!(built.x_exit_time.ncols() > 0);
4527 assert_eq!(built.x_entry_time.ncols(), built.x_exit_time.ncols());
4528 assert_eq!(built.x_derivative_time.ncols(), built.x_exit_time.ncols());
4529 }
4530
4531 #[test]
4532 fn linear_weibull_time_basis_is_a_single_log_t_column() {
4533 let age_entry = array![1.0_f64, 1.0, 1.0, 1.0];
4545 let age_exit = array![2.0_f64, 3.0, 5.0, 8.0];
4546
4547 let built = build_survival_time_basis(
4548 &age_entry,
4549 &age_exit,
4550 SurvivalTimeBasisConfig::Linear,
4551 None,
4552 )
4553 .expect("build linear Weibull time basis");
4554
4555 assert_eq!(
4556 built.x_exit_time.ncols(),
4557 1,
4558 "the linear Weibull time basis must emit exactly one column (`log t`); \
4559 the confounded constant column was dropped in #2301"
4560 );
4561 assert_eq!(built.x_entry_time.ncols(), 1, "entry basis width must match");
4562 assert_eq!(
4563 built.x_derivative_time.ncols(),
4564 1,
4565 "derivative basis width must match"
4566 );
4567 assert_eq!(built.basisname, "linear");
4568 assert!(
4569 built.penalties.is_empty(),
4570 "the linear parametric time block is unpenalized"
4571 );
4572
4573 let exit = built.x_exit_time.as_dense_cow();
4575 for (i, &t) in age_exit.iter().enumerate() {
4576 assert!(
4577 (exit[[i, 0]] - t.ln()).abs() < 1e-12,
4578 "exit column must carry log t: row {i} got {} want {}",
4579 exit[[i, 0]],
4580 t.ln()
4581 );
4582 }
4583
4584 let anchor_row = super::evaluate_survival_time_basis_row(4.5, &SurvivalTimeBasisConfig::Linear)
4587 .expect("evaluate linear anchor row");
4588 assert_eq!(anchor_row.len(), 1, "linear anchor row must be one element");
4589 assert!((anchor_row[0] - 4.5_f64.ln()).abs() < 1e-12);
4590 }
4591
4592 #[test]
4593 fn ispline_time_derivative_is_nonzero_at_right_boundary() {
4594 let age_entry = array![1.0_f64, 1.0, 1.0];
4595 let age_exit = array![4.0_f64, 4.0, 4.0];
4596 let left = 1.0_f64.ln();
4597 let right = 4.0_f64.ln();
4598 let mid = left + 0.5 * (right - left);
4599 let knots = array![left, left, left, left, mid, right, right, right, right];
4600
4601 let built = build_survival_time_basis(
4602 &age_entry,
4603 &age_exit,
4604 SurvivalTimeBasisConfig::ISpline {
4605 degree: 2,
4606 knots,
4607 keep_cols: Vec::new(),
4608 smooth_lambda: 1e-2,
4609 },
4610 None,
4611 )
4612 .expect("build right-boundary ispline time basis");
4613
4614 let derivative = built.x_derivative_time.as_dense_cow();
4615 let max_abs = derivative.iter().fold(0.0_f64, |acc, v| acc.max(v.abs()));
4616 assert!(
4617 max_abs > 1e-8,
4618 "right-boundary I-spline derivative must use the left-hand endpoint slope"
4619 );
4620 for row in derivative.rows() {
4621 assert!(
4622 row.iter().any(|v| *v > 1e-8),
4623 "each row at the right boundary needs a positive hazard derivative"
4624 );
4625 }
4626 }
4627
4628 #[test]
4629 fn ispline_time_penalty_is_psd_under_nontrivial_keep_cols() {
4630 let age_entry = array![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0];
4649 let age_exit = array![2.0_f64, 3.0, 5.0, 8.0, 13.0, 21.0];
4650 let left = 1.0_f64.ln();
4651 let right = 21.0_f64.ln();
4652 let q1 = left + 0.25 * (right - left);
4653 let mid = left + 0.5 * (right - left);
4654 let q3 = left + 0.75 * (right - left);
4655 let knots = array![
4659 left, left, left, left, q1, mid, q3, right, right, right, right
4660 ];
4661
4662 let full = build_survival_time_basis(
4664 &age_entry,
4665 &age_exit,
4666 SurvivalTimeBasisConfig::ISpline {
4667 degree: 2,
4668 knots: knots.clone(),
4669 keep_cols: Vec::new(),
4670 smooth_lambda: 1e-2,
4671 },
4672 None,
4673 )
4674 .expect("build full-width ispline time basis");
4675 let p_time_full = full
4676 .keep_cols
4677 .as_ref()
4678 .map(|k| k.len())
4679 .unwrap_or_else(|| full.x_exit_time.ncols());
4680 assert!(
4681 p_time_full >= 3,
4682 "test needs at least 3 shape-varying columns to drop an interior one; got {p_time_full}"
4683 );
4684
4685 let keep_cols: Vec<usize> = (0..p_time_full).filter(|&j| j != 1).collect();
4688
4689 let built = build_survival_time_basis(
4690 &age_entry,
4691 &age_exit,
4692 SurvivalTimeBasisConfig::ISpline {
4693 degree: 2,
4694 knots,
4695 keep_cols: keep_cols.clone(),
4696 smooth_lambda: 1e-2,
4697 },
4698 None,
4699 )
4700 .expect(
4701 "reduced ispline penalty must build (PSD contract must accept the \
4702 congruence-first / select-second ordering)",
4703 );
4704
4705 assert_eq!(
4706 built.penalties.len(),
4707 1,
4708 "the ispline time basis should carry exactly one curvature penalty"
4709 );
4710 let s = &built.penalties[0];
4711 assert_eq!(s.nrows(), keep_cols.len());
4712 assert_eq!(s.ncols(), keep_cols.len());
4713
4714 let (evals, _) = gam_linalg::faer_ndarray::FaerEigh::eigh(s, faer::Side::Lower)
4715 .expect("eigh of penalty");
4716 let evals_slice = evals.as_slice().expect("contiguous eigenvalues");
4717 let max_abs = evals_slice
4718 .iter()
4719 .copied()
4720 .fold(0.0_f64, |a, b| a.max(b.abs()))
4721 .max(1.0);
4722 let min_ev = evals_slice.iter().copied().fold(f64::INFINITY, f64::min);
4723 let tol = -100.0 * (s.nrows() as f64) * f64::EPSILON * max_abs;
4724 assert!(
4725 min_ev >= tol,
4726 "reduced I-spline time penalty must be PSD (gam#979): min eigenvalue \
4727 {min_ev:.3e} < tol {tol:.3e}, max|eig| {max_abs:.3e}"
4728 );
4729 }
4730
4731 #[test]
4732 fn marginal_slope_baseline_maps_gompertz_makeham_survival_to_probit_index() {
4733 let cfg = SurvivalBaselineConfig {
4734 target: SurvivalBaselineTarget::GompertzMakeham,
4735 scale: None,
4736 shape: Some(0.07),
4737 rate: Some(0.012),
4738 makeham: Some(0.003),
4739 };
4740 let age = 11.5;
4741 let (q, q_derivative) = evaluate_survival_marginal_slope_baseline(age, &cfg)
4742 .expect("evaluate marginal-slope gompertz-makeham baseline");
4743 let shape = cfg.shape.expect("shape");
4744 let rate = cfg.rate.expect("rate");
4745 let makeham = cfg.makeham.expect("makeham");
4746 let cumulative_hazard = makeham * age + (rate / shape) * ((shape * age).exp() - 1.0);
4747 let instant_hazard = makeham + rate * (shape * age).exp();
4748 let expected_survival = (-cumulative_hazard).exp();
4749 let actual_survival = normal_cdf(-q);
4750 assert!((actual_survival - expected_survival).abs() <= 1e-12);
4751
4752 let h = 1e-5;
4753 let q_plus = evaluate_survival_marginal_slope_baseline(age + h, &cfg)
4754 .expect("q plus")
4755 .0;
4756 let q_minus = evaluate_survival_marginal_slope_baseline(age - h, &cfg)
4757 .expect("q minus")
4758 .0;
4759 let fd = (q_plus - q_minus) / (2.0 * h);
4760 assert!((q_derivative - fd).abs() <= 1e-7);
4761 assert!(instant_hazard > 0.0);
4762 }
4763
4764 #[test]
4765 fn marginal_slope_baseline_is_evaluable_at_the_survival_curve_origin() {
4766 let configs = [
4775 SurvivalBaselineConfig {
4776 target: SurvivalBaselineTarget::Linear,
4777 scale: None,
4778 shape: None,
4779 rate: None,
4780 makeham: None,
4781 },
4782 SurvivalBaselineConfig {
4783 target: SurvivalBaselineTarget::Weibull,
4784 scale: Some(2.5),
4785 shape: Some(1.3),
4786 rate: None,
4787 makeham: None,
4788 },
4789 SurvivalBaselineConfig {
4790 target: SurvivalBaselineTarget::Gompertz,
4791 scale: None,
4792 shape: Some(0.05),
4793 rate: Some(0.01),
4794 makeham: None,
4795 },
4796 SurvivalBaselineConfig {
4797 target: SurvivalBaselineTarget::GompertzMakeham,
4798 scale: None,
4799 shape: Some(0.07),
4800 rate: Some(0.012),
4801 makeham: Some(0.003),
4802 },
4803 ];
4804 for cfg in &configs {
4805 let (q0, q0_derivative) = evaluate_survival_marginal_slope_baseline(0.0, cfg)
4808 .expect("marginal-slope baseline must be evaluable at the origin");
4809 assert_eq!(q0, 0.0);
4810 assert_eq!(q0_derivative, 0.0);
4811
4812 let (eta0, eta0_derivative) =
4816 evaluate_survival_baseline(0.0, cfg).expect("log-cum-hazard baseline at origin");
4817 assert!(eta0_derivative.is_finite());
4818 assert!(eta0.is_finite() || eta0 == f64::NEG_INFINITY);
4819
4820 let age_entry = array![0.0, 0.0];
4824 let age_exit = array![0.0, 1.5];
4825 let (entry, exit, derivative) =
4826 build_survival_marginal_slope_baseline_offsets(&age_entry, &age_exit, cfg)
4827 .expect("probit baseline offsets must build through the origin");
4828 assert!(entry.iter().all(|v| v.is_finite()));
4829 assert!(exit.iter().all(|v| v.is_finite()));
4830 assert!(derivative.iter().all(|v| v.is_finite()));
4831 assert_eq!(exit[0], 0.0);
4833 }
4834 }
4835
4836 #[test]
4837 fn marginal_slope_baseline_offsets_use_true_gompertz_makeham_survival() {
4838 let cfg = SurvivalBaselineConfig {
4839 target: SurvivalBaselineTarget::GompertzMakeham,
4840 scale: None,
4841 shape: Some(0.03),
4842 rate: Some(0.01),
4843 makeham: Some(0.002),
4844 };
4845 let age_entry = array![2.0, 4.0];
4846 let age_exit = array![5.0, 9.0];
4847 let (entry, exit, derivative) =
4848 build_survival_marginal_slope_baseline_offsets(&age_entry, &age_exit, &cfg)
4849 .expect("marginal-slope baseline offsets");
4850 for i in 0..age_entry.len() {
4851 let entry_h = cfg.makeham.expect("makeham") * age_entry[i]
4852 + (cfg.rate.expect("rate") / cfg.shape.expect("shape"))
4853 * ((cfg.shape.expect("shape") * age_entry[i]).exp() - 1.0);
4854 let exit_h = cfg.makeham.expect("makeham") * age_exit[i]
4855 + (cfg.rate.expect("rate") / cfg.shape.expect("shape"))
4856 * ((cfg.shape.expect("shape") * age_exit[i]).exp() - 1.0);
4857 assert!((normal_cdf(-entry[i]) - (-entry_h).exp()).abs() <= 1e-12);
4858 assert!((normal_cdf(-exit[i]) - (-exit_h).exp()).abs() <= 1e-12);
4859 assert!(derivative[i].is_finite() && derivative[i] > 0.0);
4860 }
4861 }
4862
4863 fn fd_marginal_slope_baseline_offset(
4864 age: f64,
4865 cfg: &SurvivalBaselineConfig,
4866 steps: &[f64],
4867 ) -> Vec<(f64, f64)> {
4868 let theta = survival_baseline_theta_from_config(cfg)
4869 .expect("theta")
4870 .expect("non-linear baseline");
4871 assert_eq!(
4872 steps.len(),
4873 theta.len(),
4874 "fd_marginal_slope_baseline_offset: step vector length must match θ dimension"
4875 );
4876 (0..theta.len())
4877 .map(|k| {
4878 let h = steps[k];
4879 let mut theta_plus = theta.clone();
4880 theta_plus[k] += h;
4881 let mut theta_minus = theta.clone();
4882 theta_minus[k] -= h;
4883 let cfg_plus =
4884 survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("plus cfg");
4885 let cfg_minus = survival_baseline_config_from_theta(cfg.target, &theta_minus)
4886 .expect("minus cfg");
4887 let (q_p, qt_p) =
4888 evaluate_survival_marginal_slope_baseline(age, &cfg_plus).expect("q+");
4889 let (q_m, qt_m) =
4890 evaluate_survival_marginal_slope_baseline(age, &cfg_minus).expect("q-");
4891 ((q_p - q_m) / (2.0 * h), (qt_p - qt_m) / (2.0 * h))
4892 })
4893 .collect()
4894 }
4895
4896 #[test]
4897 fn marginal_slope_baseline_theta_partials_match_fd_for_gompertz_makeham() {
4898 let cfg = SurvivalBaselineConfig {
4899 target: SurvivalBaselineTarget::GompertzMakeham,
4900 scale: None,
4901 shape: Some(0.04),
4902 rate: Some(0.013),
4903 makeham: Some(0.002),
4904 };
4905 let age = 17.0;
4906 let analytic = marginal_slope_baseline_offset_theta_partials(age, &cfg)
4907 .expect("partials")
4908 .expect("nonlinear");
4909 let fd = fd_marginal_slope_baseline_offset(age, &cfg, &[1e-5, 1e-5, 1e-5]);
4910 assert_eq!(analytic.len(), fd.len());
4911 for (k, ((aq, aqt), (fq, fqt))) in analytic.iter().zip(fd.iter()).enumerate() {
4912 assert_close(*aq, *fq, 1e-6, &format!("gm-probit q theta[{k}]"));
4913 assert_close(*aqt, *fqt, 1e-6, &format!("gm-probit q' theta[{k}]"));
4914 }
4915 }
4916
4917 #[test]
4918 fn marginal_slope_baseline_theta_partials_match_fd_near_zero_gompertz_shape() {
4919 let cfg = SurvivalBaselineConfig {
4920 target: SurvivalBaselineTarget::GompertzMakeham,
4921 scale: None,
4922 shape: Some(1e-14),
4923 rate: Some(0.013),
4924 makeham: Some(0.002),
4925 };
4926 let age = 17.0;
4927 let analytic = marginal_slope_baseline_offset_theta_partials(age, &cfg)
4928 .expect("partials")
4929 .expect("nonlinear");
4930 let fd = fd_marginal_slope_baseline_offset(age, &cfg, &[1e-5, 1e-11, 1e-5]);
4931 assert_eq!(analytic.len(), fd.len());
4932 for (k, ((aq, aqt), (fq, fqt))) in analytic.iter().zip(fd.iter()).enumerate() {
4933 assert_close(*aq, *fq, 1e-5, &format!("near-zero gm-probit q theta[{k}]"));
4934 assert_close(
4935 *aqt,
4936 *fqt,
4937 1e-5,
4938 &format!("near-zero gm-probit q' theta[{k}]"),
4939 );
4940 }
4941 }
4942
4943 fn shifted_quadratic_offset_residuals(
4944 age_entry: ndarray::ArrayView1<'_, f64>,
4945 age_exit: ndarray::ArrayView1<'_, f64>,
4946 base_cfg: &SurvivalBaselineConfig,
4947 candidate_cfg: &SurvivalBaselineConfig,
4948 base: &OffsetChannelResiduals,
4949 curvatures: &OffsetChannelCurvatures,
4950 ) -> OffsetChannelResiduals {
4951 let n = age_exit.len();
4952 let mut entry = base.entry.clone();
4953 let mut exit = base.exit.clone();
4954 let mut derivative = base.derivative.clone();
4955 for row in 0..n {
4956 let (_, base_exit, base_deriv) =
4957 baseline_marginal_slope_channels(age_exit[row], base_cfg);
4958 let (_, cand_exit, cand_deriv) =
4959 baseline_marginal_slope_channels(age_exit[row], candidate_cfg);
4960 let base_entry = if base.entry[row] == 0.0 {
4961 0.0
4962 } else {
4963 baseline_marginal_slope_channels(age_entry[row], base_cfg).1
4964 };
4965 let cand_entry = if base.entry[row] == 0.0 {
4966 0.0
4967 } else {
4968 baseline_marginal_slope_channels(age_entry[row], candidate_cfg).1
4969 };
4970 let delta = [
4971 cand_entry - base_entry,
4972 cand_exit - base_exit,
4973 cand_deriv - base_deriv,
4974 ];
4975 let mut shift = [0.0; 3];
4976 for i in 0..3 {
4977 for j in 0..3 {
4978 shift[i] += curvatures.rows[row][i][j] * delta[j];
4979 }
4980 }
4981 if base.entry[row] != 0.0 {
4982 entry[row] += shift[0];
4983 }
4984 exit[row] += shift[1];
4985 derivative[row] += shift[2];
4986 }
4987 OffsetChannelResiduals {
4988 entry,
4989 exit,
4990 derivative,
4991 right: base.right.clone(),
4992 }
4993 }
4994
4995 fn baseline_marginal_slope_channels(age: f64, cfg: &SurvivalBaselineConfig) -> (f64, f64, f64) {
4996 let (q, q_t) = evaluate_survival_marginal_slope_baseline(age, cfg).expect("baseline");
4997 (q, q, q_t)
4998 }
4999
5000 #[test]
5001 fn marginal_slope_baseline_chain_rule_hessian_matches_fd_gradient() {
5002 let cfg = SurvivalBaselineConfig {
5003 target: SurvivalBaselineTarget::GompertzMakeham,
5004 scale: None,
5005 shape: Some(0.025),
5006 rate: Some(0.012),
5007 makeham: Some(0.003),
5008 };
5009 let theta = survival_baseline_theta_from_config(&cfg)
5010 .expect("theta")
5011 .expect("nonlinear");
5012 let age_entry = array![2.5, 0.0, 5.0];
5013 let age_exit = array![7.5, 11.0, 15.0];
5014 let base_residuals = OffsetChannelResiduals {
5015 entry: array![0.2, 0.0, -0.1],
5016 exit: array![0.6, -0.3, 0.4],
5017 derivative: array![-0.5, 0.25, 0.15],
5018 right: Array1::<f64>::zeros(3),
5019 };
5020 let curvatures = OffsetChannelCurvatures {
5021 rows: vec![
5022 [[1.4, 0.2, -0.1], [0.2, 1.1, 0.05], [-0.1, 0.05, 0.7]],
5023 [[0.9, -0.15, 0.0], [-0.15, 1.3, 0.12], [0.0, 0.12, 0.8]],
5024 [[1.2, 0.05, 0.09], [0.05, 0.95, -0.04], [0.09, -0.04, 0.6]],
5025 ],
5026 };
5027 let analytic = marginal_slope_baseline_chain_rule_hessian(
5028 age_entry.view(),
5029 age_exit.view(),
5030 &cfg,
5031 &base_residuals,
5032 &curvatures,
5033 )
5034 .expect("hessian")
5035 .expect("nonlinear");
5036
5037 let gradient_at = |theta_candidate: &Array1<f64>| -> Array1<f64> {
5038 let candidate = survival_baseline_config_from_theta(cfg.target, theta_candidate)
5039 .expect("candidate cfg");
5040 let residuals = shifted_quadratic_offset_residuals(
5041 age_entry.view(),
5042 age_exit.view(),
5043 &cfg,
5044 &candidate,
5045 &base_residuals,
5046 &curvatures,
5047 );
5048 marginal_slope_baseline_chain_rule_gradient(
5049 age_entry.view(),
5050 age_exit.view(),
5051 &candidate,
5052 &residuals,
5053 )
5054 .expect("gradient")
5055 .expect("nonlinear")
5056 };
5057
5058 for j in 0..theta.len() {
5059 let step = if j == 1 { 2e-5 } else { 1e-5 };
5060 let mut plus = theta.clone();
5061 plus[j] += step;
5062 let mut minus = theta.clone();
5063 minus[j] -= step;
5064 let fd_col = (&gradient_at(&plus) - &gradient_at(&minus)) / (2.0 * step);
5065 for i in 0..theta.len() {
5066 assert_close(
5067 analytic[[i, j]],
5068 fd_col[i],
5069 2e-5,
5070 &format!("baseline Hessian ({i},{j})"),
5071 );
5072 }
5073 }
5074 }
5075
5076 #[test]
5077 fn marginal_slope_baseline_chain_rule_gradient_contracts_probit_partials() {
5078 let cfg = SurvivalBaselineConfig {
5079 target: SurvivalBaselineTarget::GompertzMakeham,
5080 scale: None,
5081 shape: Some(0.03),
5082 rate: Some(0.01),
5083 makeham: Some(0.002),
5084 };
5085 let age_entry = array![3.0, 6.0];
5086 let age_exit = array![8.0, 12.0];
5087 let residuals = OffsetChannelResiduals {
5088 exit: array![0.7, -0.2],
5089 entry: array![0.1, 0.4],
5090 derivative: array![1.3, -0.6],
5091 right: Array1::<f64>::zeros(2),
5092 };
5093 let grad = marginal_slope_baseline_chain_rule_gradient(
5094 age_entry.view(),
5095 age_exit.view(),
5096 &cfg,
5097 &residuals,
5098 )
5099 .expect("gradient")
5100 .expect("nonlinear");
5101
5102 let mut expected = Array1::<f64>::zeros(3);
5103 for i in 0..age_exit.len() {
5104 let exit_partials = marginal_slope_baseline_offset_theta_partials(age_exit[i], &cfg)
5105 .expect("exit partials")
5106 .expect("nonlinear");
5107 let entry_partials = marginal_slope_baseline_offset_theta_partials(age_entry[i], &cfg)
5108 .expect("entry partials")
5109 .expect("nonlinear");
5110 for k in 0..3 {
5111 expected[k] += residuals.exit[i] * exit_partials[k].0
5112 + residuals.derivative[i] * exit_partials[k].1
5113 + residuals.entry[i] * entry_partials[k].0;
5114 }
5115 }
5116 for k in 0..3 {
5117 assert_close(
5118 grad[k],
5119 expected[k],
5120 1e-12,
5121 &format!("gm-probit chain gradient theta[{k}]"),
5122 );
5123 }
5124 }
5125
5126 #[test]
5136 fn baseline_chain_rule_gradient_engine_matches_inline_reference() {
5137 let cfg = SurvivalBaselineConfig {
5138 target: SurvivalBaselineTarget::GompertzMakeham,
5139 scale: None,
5140 shape: Some(0.028),
5141 rate: Some(0.011),
5142 makeham: Some(0.0025),
5143 };
5144 let age_entry = array![3.0, 0.0, 5.5];
5147 let age_exit = array![8.0, 12.0, 16.0];
5148 let residuals = OffsetChannelResiduals {
5149 exit: array![0.7, -0.2, 0.45],
5150 entry: array![0.1, 0.0, -0.3],
5151 derivative: array![1.3, -0.6, 0.2],
5152 right: Array1::<f64>::zeros(3),
5153 };
5154
5155 let reference_gradient = |partials: &dyn Fn(
5158 f64,
5159 &SurvivalBaselineConfig,
5160 )
5161 -> Result<Option<Vec<(f64, f64)>>, String>|
5162 -> Array1<f64> {
5163 let theta_dim = partials(age_exit[0], &cfg)
5164 .expect("probe partials")
5165 .expect("nonlinear")
5166 .len();
5167 let mut acc = Array1::<f64>::zeros(theta_dim);
5168 for i in 0..age_exit.len() {
5169 let p_exit = partials(age_exit[i], &cfg)
5170 .expect("exit partials")
5171 .expect("nonlinear");
5172 let r_x = residuals.exit[i];
5173 let r_d = residuals.derivative[i];
5174 for k in 0..theta_dim {
5175 acc[k] += r_x * p_exit[k].0 + r_d * p_exit[k].1;
5176 }
5177 let r_e = residuals.entry[i];
5178 if r_e != 0.0 {
5179 let p_entry = partials(age_entry[i], &cfg)
5180 .expect("entry partials")
5181 .expect("nonlinear");
5182 for k in 0..theta_dim {
5183 acc[k] += r_e * p_entry[k].0;
5184 }
5185 }
5186 }
5187 acc
5188 };
5189
5190 let rp_engine = baseline_chain_rule_gradient(
5192 age_entry.view(),
5193 age_exit.view(),
5194 age_exit.view(),
5195 &cfg,
5196 &residuals,
5197 )
5198 .expect("rp gradient")
5199 .expect("rp nonlinear");
5200 let rp_reference = reference_gradient(&baseline_offset_theta_partials);
5201 assert_eq!(rp_engine.len(), rp_reference.len());
5202 for k in 0..rp_engine.len() {
5203 assert_close(
5204 rp_engine[k],
5205 rp_reference[k],
5206 0.0,
5207 &format!("rp engine vs inline reference theta[{k}]"),
5208 );
5209 }
5210
5211 let probit_engine = marginal_slope_baseline_chain_rule_gradient(
5213 age_entry.view(),
5214 age_exit.view(),
5215 &cfg,
5216 &residuals,
5217 )
5218 .expect("probit gradient")
5219 .expect("probit nonlinear");
5220 let probit_reference = reference_gradient(&marginal_slope_baseline_offset_theta_partials);
5221 assert_eq!(probit_engine.len(), probit_reference.len());
5222 for k in 0..probit_engine.len() {
5223 assert_close(
5224 probit_engine[k],
5225 probit_reference[k],
5226 0.0,
5227 &format!("probit engine vs inline reference theta[{k}]"),
5228 );
5229 }
5230 }
5231
5232 #[test]
5253 fn gompertz_makeham_baseline_chain_rule_gradient_matches_finite_difference() {
5254 let cfg = SurvivalBaselineConfig {
5255 target: SurvivalBaselineTarget::GompertzMakeham,
5256 scale: None,
5257 shape: Some(0.05),
5258 rate: Some(0.012),
5259 makeham: Some(0.003),
5260 };
5261 let age_entry = array![5.0, 8.0, 12.0, 0.5, 20.0, 30.0, 45.0, 60.0];
5263 let age_exit = array![10.0, 15.0, 25.0, 4.0, 35.0, 50.0, 65.0, 80.0];
5264 let residuals = OffsetChannelResiduals {
5267 exit: array![0.42, -0.18, 0.73, -0.91, 0.05, -0.27, 0.61, -0.34],
5268 entry: array![-0.12, 0.31, -0.44, 0.0, 0.16, -0.22, 0.07, -0.51],
5269 derivative: array![1.04, -0.65, 0.18, -1.21, 0.42, -0.13, 0.88, -0.27],
5270 right: Array1::<f64>::zeros(8),
5271 };
5272
5273 let analytic = baseline_chain_rule_gradient(
5274 age_entry.view(),
5275 age_exit.view(),
5276 age_exit.view(),
5277 &cfg,
5278 &residuals,
5279 )
5280 .expect("analytic gradient ok")
5281 .expect("GM baseline has a θ-gradient");
5282 assert_eq!(analytic.len(), 3, "GM θ has 3 components");
5283
5284 let loss_at_cfg = |cfg_eval: &SurvivalBaselineConfig| -> f64 {
5290 let mut acc = 0.0;
5291 for i in 0..age_exit.len() {
5292 let (eta_exit_i, od_exit_i) =
5293 evaluate_survival_baseline(age_exit[i], cfg_eval).expect("eval exit");
5294 acc += residuals.exit[i] * eta_exit_i + residuals.derivative[i] * od_exit_i;
5295 if residuals.entry[i] != 0.0 {
5296 let (eta_entry_i, _) =
5297 evaluate_survival_baseline(age_entry[i], cfg_eval).expect("eval entry");
5298 acc += residuals.entry[i] * eta_entry_i;
5299 }
5300 }
5301 acc
5302 };
5303
5304 let theta0 = survival_baseline_theta_from_config(&cfg)
5305 .expect("theta seed")
5306 .expect("GM has θ");
5307 let delta = 1e-4;
5309 let mut fd = Array1::<f64>::zeros(analytic.len());
5310 for k in 0..analytic.len() {
5311 let mut theta_plus = theta0.clone();
5312 theta_plus[k] += delta;
5313 let mut theta_minus = theta0.clone();
5314 theta_minus[k] -= delta;
5315 let cfg_plus =
5316 survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("cfg(θ+δ)");
5317 let cfg_minus =
5318 survival_baseline_config_from_theta(cfg.target, &theta_minus).expect("cfg(θ-δ)");
5319 let lp = loss_at_cfg(&cfg_plus);
5320 let lm = loss_at_cfg(&cfg_minus);
5321 fd[k] = (lp - lm) / (2.0 * delta);
5322 }
5323
5324 let analytic_norm = analytic.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
5325 let max_err = analytic
5326 .iter()
5327 .zip(fd.iter())
5328 .map(|(a, b)| (a - b).abs())
5329 .fold(0.0_f64, f64::max);
5330 let rel = max_err / (analytic_norm + 1e-12);
5331 eprintln!(
5333 "gompertz_makeham_baseline_chain_rule_gradient_matches_finite_difference: \
5334 analytic={analytic:?} fd={fd:?} max_err={max_err:.3e} \
5335 analytic_inf_norm={analytic_norm:.3e} rel={rel:.3e}"
5336 );
5337 assert!(
5338 rel < 1e-2,
5339 "analytic θ-gradient disagrees with central FD beyond 1%: \
5340 analytic={analytic:?}, fd={fd:?}, max_err={max_err:.3e}, \
5341 rel={rel:.3e} (analytic_inf_norm={analytic_norm:.3e})"
5342 );
5343 }
5344
5345 #[test]
5360 fn weibull_baseline_chain_rule_gradient_matches_finite_difference() {
5361 let cfg = SurvivalBaselineConfig {
5362 target: SurvivalBaselineTarget::Weibull,
5363 scale: Some(11.0),
5364 shape: Some(1.4),
5365 rate: None,
5366 makeham: None,
5367 };
5368 let age_entry = array![5.0, 8.0, 12.0, 0.5, 20.0, 30.0, 45.0, 60.0];
5369 let age_exit = array![10.0, 15.0, 25.0, 4.0, 35.0, 50.0, 65.0, 80.0];
5370 let residuals = OffsetChannelResiduals {
5371 exit: array![0.42, -0.18, 0.73, -0.91, 0.05, -0.27, 0.61, -0.34],
5372 entry: array![-0.12, 0.31, -0.44, 0.0, 0.16, -0.22, 0.07, -0.51],
5373 derivative: array![1.04, -0.65, 0.18, -1.21, 0.42, -0.13, 0.88, -0.27],
5374 right: Array1::<f64>::zeros(8),
5375 };
5376
5377 let analytic = baseline_chain_rule_gradient(
5378 age_entry.view(),
5379 age_exit.view(),
5380 age_exit.view(),
5381 &cfg,
5382 &residuals,
5383 )
5384 .expect("analytic gradient ok")
5385 .expect("Weibull baseline has a θ-gradient");
5386 assert_eq!(analytic.len(), 2, "Weibull θ has 2 components");
5387
5388 let loss_at_cfg = |cfg_eval: &SurvivalBaselineConfig| -> f64 {
5389 let mut acc = 0.0;
5390 for i in 0..age_exit.len() {
5391 let (eta_exit_i, od_exit_i) =
5392 evaluate_survival_baseline(age_exit[i], cfg_eval).expect("eval exit");
5393 acc += residuals.exit[i] * eta_exit_i + residuals.derivative[i] * od_exit_i;
5394 if residuals.entry[i] != 0.0 {
5395 let (eta_entry_i, _) =
5396 evaluate_survival_baseline(age_entry[i], cfg_eval).expect("eval entry");
5397 acc += residuals.entry[i] * eta_entry_i;
5398 }
5399 }
5400 acc
5401 };
5402
5403 let theta0 = survival_baseline_theta_from_config(&cfg)
5404 .expect("theta seed")
5405 .expect("Weibull has θ");
5406 let delta = 1e-4;
5407 let mut fd = Array1::<f64>::zeros(analytic.len());
5408 for k in 0..analytic.len() {
5409 let mut theta_plus = theta0.clone();
5410 theta_plus[k] += delta;
5411 let mut theta_minus = theta0.clone();
5412 theta_minus[k] -= delta;
5413 let cfg_plus =
5414 survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("cfg(θ+δ)");
5415 let cfg_minus =
5416 survival_baseline_config_from_theta(cfg.target, &theta_minus).expect("cfg(θ-δ)");
5417 let lp = loss_at_cfg(&cfg_plus);
5418 let lm = loss_at_cfg(&cfg_minus);
5419 fd[k] = (lp - lm) / (2.0 * delta);
5420 }
5421
5422 let analytic_norm = analytic.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
5423 let max_err = analytic
5424 .iter()
5425 .zip(fd.iter())
5426 .map(|(a, b)| (a - b).abs())
5427 .fold(0.0_f64, f64::max);
5428 let rel = max_err / (analytic_norm + 1e-12);
5429 eprintln!(
5430 "weibull_baseline_chain_rule_gradient_matches_finite_difference: \
5431 analytic={analytic:?} fd={fd:?} max_err={max_err:.3e} \
5432 analytic_inf_norm={analytic_norm:.3e} rel={rel:.3e}"
5433 );
5434 assert!(
5435 rel < 1e-2,
5436 "analytic θ-gradient disagrees with central FD beyond 1%: \
5437 analytic={analytic:?}, fd={fd:?}, max_err={max_err:.3e}, \
5438 rel={rel:.3e} (analytic_inf_norm={analytic_norm:.3e})"
5439 );
5440 }
5441
5442 fn fd_baseline_offset(
5455 age: f64,
5456 cfg: &SurvivalBaselineConfig,
5457 steps: &[f64],
5458 ) -> Vec<(f64, f64)> {
5459 let theta = survival_baseline_theta_from_config(cfg)
5460 .expect("theta")
5461 .expect("non-linear baseline");
5462 assert_eq!(
5463 steps.len(),
5464 theta.len(),
5465 "fd_baseline_offset: step vector length must match θ dimension"
5466 );
5467 (0..theta.len())
5468 .map(|k| {
5469 let h = steps[k];
5470 let mut theta_plus = theta.clone();
5471 theta_plus[k] += h;
5472 let mut theta_minus = theta.clone();
5473 theta_minus[k] -= h;
5474 let cfg_plus =
5475 survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("plus cfg");
5476 let cfg_minus = survival_baseline_config_from_theta(cfg.target, &theta_minus)
5477 .expect("minus cfg");
5478 let (eta_p, od_p) = evaluate_survival_baseline(age, &cfg_plus).expect("eta+");
5479 let (eta_m, od_m) = evaluate_survival_baseline(age, &cfg_minus).expect("eta-");
5480 ((eta_p - eta_m) / (2.0 * h), (od_p - od_m) / (2.0 * h))
5481 })
5482 .collect()
5483 }
5484
5485 fn assert_close(actual: f64, expected: f64, tol: f64, what: &str) {
5486 let ok = if expected.abs() < 1.0 {
5490 (actual - expected).abs() <= tol
5491 } else {
5492 (actual - expected).abs() <= tol * expected.abs().max(1.0)
5493 };
5494 assert!(
5495 ok,
5496 "{what}: analytic={actual:.6e} fd={expected:.6e} (tol={tol:.1e})"
5497 );
5498 }
5499
5500 #[test]
5501 fn gompertz_offset_partials_match_central_diff() {
5502 let cases = [
5506 (0.5_f64, 0.01_f64, 30.0_f64),
5507 (0.2, 0.05, 60.0),
5508 (1.0, 0.001, 10.0),
5509 (0.4, 5e-11, 25.0),
5510 (0.4, -5e-11, 25.0),
5511 (0.3, -0.02, 40.0),
5512 (0.8, 0.2, 5.0),
5513 ];
5514 for &(rate, shape, age) in &cases {
5515 let cfg = SurvivalBaselineConfig {
5516 target: SurvivalBaselineTarget::Gompertz,
5517 scale: None,
5518 shape: Some(shape),
5519 rate: Some(rate),
5520 makeham: None,
5521 };
5522 let analytic = baseline_offset_theta_partials(age, &cfg)
5523 .expect("ok")
5524 .expect("non-linear");
5525 let h_shape = if shape.abs() < 1e-9 { 1e-11 } else { 1e-5 };
5531 let fd = fd_baseline_offset(age, &cfg, &[1e-5, h_shape]);
5532 assert_eq!(analytic.len(), 2);
5533 assert_close(
5535 analytic[0].0,
5536 fd[0].0,
5537 1e-7,
5538 &format!("gompertz ∂eta/∂log_rate (rate={rate}, shape={shape}, age={age})"),
5539 );
5540 assert_close(
5541 analytic[0].1,
5542 fd[0].1,
5543 1e-7,
5544 &format!("gompertz ∂o_D/∂log_rate (rate={rate}, shape={shape}, age={age})"),
5545 );
5546 assert_close(
5549 analytic[1].0,
5550 fd[1].0,
5551 1e-5,
5552 &format!("gompertz ∂eta/∂shape (rate={rate}, shape={shape}, age={age})"),
5553 );
5554 assert_close(
5555 analytic[1].1,
5556 fd[1].1,
5557 1e-5,
5558 &format!("gompertz ∂o_D/∂shape (rate={rate}, shape={shape}, age={age})"),
5559 );
5560 }
5561 }
5562
5563 #[test]
5564 fn gompertz_offset_partials_log_rate_channel_is_trivial() {
5565 let cfg = SurvivalBaselineConfig {
5569 target: SurvivalBaselineTarget::Gompertz,
5570 scale: None,
5571 shape: Some(0.05),
5572 rate: Some(0.3),
5573 makeham: None,
5574 };
5575 let partials = baseline_offset_theta_partials(42.0, &cfg)
5576 .expect("ok")
5577 .expect("non-linear");
5578 assert_eq!(partials[0].0, 1.0);
5579 assert_eq!(partials[0].1, 0.0);
5580 }
5581
5582 #[test]
5583 fn gompertz_offset_partials_small_shape_taylor_agrees_with_direct_branch() {
5584 let age = 25.0;
5591 let rate = 0.4;
5592 let cfg_taylor = SurvivalBaselineConfig {
5593 target: SurvivalBaselineTarget::Gompertz,
5594 scale: None,
5595 shape: Some(0.5e-10),
5596 rate: Some(rate),
5597 makeham: None,
5598 };
5599 let cfg_direct = SurvivalBaselineConfig {
5600 target: SurvivalBaselineTarget::Gompertz,
5601 scale: None,
5602 shape: Some(2.0e-10),
5603 rate: Some(rate),
5604 makeham: None,
5605 };
5606 let p_t = baseline_offset_theta_partials(age, &cfg_taylor)
5607 .expect("ok")
5608 .expect("nl");
5609 let p_d = baseline_offset_theta_partials(age, &cfg_direct)
5610 .expect("ok")
5611 .expect("nl");
5612 assert_close(p_t[1].0, 12.5, 1e-8, "taylor ∂eta/∂shape near 0");
5614 assert_close(p_d[1].0, 12.5, 1e-8, "direct ∂eta/∂shape near 0");
5615 assert_close(p_t[1].1, 0.5, 1e-8, "taylor ∂o_D/∂shape near 0");
5617 assert_close(p_d[1].1, 0.5, 1e-8, "direct ∂o_D/∂shape near 0");
5618 }
5619
5620 #[test]
5632 fn gompertz_hazard_shape_derivatives_match_central_diff() {
5633 let cases = [
5638 (10.0_f64, 0.012_f64, 0.05_f64),
5639 (2.5, 0.5, 0.2),
5640 (15.0, 0.003, 0.01),
5641 (40.0, 0.3, 0.001),
5642 ];
5643 let h = 1e-6;
5644 for &(age, rate, shape) in &cases {
5645 let (d_cum, d_inst) = gompertz_cumulative_shape_derivative(age, rate, shape);
5647 let (cum_p, inst_p) = gompertz_hazard_components(age, rate, shape + h);
5648 let (cum_m, inst_m) = gompertz_hazard_components(age, rate, shape - h);
5649 assert_close(
5650 d_cum,
5651 (cum_p - cum_m) / (2.0 * h),
5652 1e-6,
5653 &format!("∂H_G/∂shape (age={age}, rate={rate}, shape={shape})"),
5654 );
5655 assert_close(
5656 d_inst,
5657 (inst_p - inst_m) / (2.0 * h),
5658 1e-6,
5659 &format!("∂h_G/∂shape (age={age}, rate={rate}, shape={shape})"),
5660 );
5661
5662 let (d2_cum, d2_inst) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
5664 let (dcum_p, dinst_p) = gompertz_cumulative_shape_derivative(age, rate, shape + h);
5665 let (dcum_m, dinst_m) = gompertz_cumulative_shape_derivative(age, rate, shape - h);
5666 assert_close(
5667 d2_cum,
5668 (dcum_p - dcum_m) / (2.0 * h),
5669 1e-5,
5670 &format!("∂²H_G/∂shape² (age={age}, rate={rate}, shape={shape})"),
5671 );
5672 assert_close(
5673 d2_inst,
5674 (dinst_p - dinst_m) / (2.0 * h),
5675 1e-5,
5676 &format!("∂²h_G/∂shape² (age={age}, rate={rate}, shape={shape})"),
5677 );
5678 }
5679 }
5680
5681 #[test]
5682 fn gompertz_hazard_shape_derivatives_small_shape_match_analytic_limit() {
5683 let cases = [
5695 (25.0_f64, 0.4_f64, 1e-9_f64),
5696 (100.0, 0.4, 1e-6), (100.0, 0.012, 1e-6), (50.0, 1.2, 1e-8),
5699 ];
5700 for &(age, rate, shape) in &cases {
5711 let t = age;
5712 let (d_cum, d_inst) = gompertz_cumulative_shape_derivative(age, rate, shape);
5713 assert_close(
5714 d_cum,
5715 rate * t * t / 2.0,
5716 1e-3,
5717 &format!("∂H_G/∂shape limit (age={age}, shape={shape})"),
5718 );
5719 assert_close(
5720 d_inst,
5721 rate * t,
5722 1e-3,
5723 &format!("∂h_G/∂shape limit (age={age}, shape={shape})"),
5724 );
5725
5726 let (d2_cum, d2_inst) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
5727 assert_close(
5728 d2_cum,
5729 rate * t * t * t / 3.0,
5730 1e-3,
5731 &format!("∂²H_G/∂shape² limit (age={age}, shape={shape})"),
5732 );
5733 assert_close(
5734 d2_inst,
5735 rate * t * t,
5736 1e-3,
5737 &format!("∂²h_G/∂shape² limit (age={age}, shape={shape})"),
5738 );
5739 }
5740 }
5741
5742 #[test]
5743 fn gompertz_second_shape_derivative_is_accurate_in_old_pivot_gap() {
5744 let age = 100.0;
5751 let rate = 0.4;
5752 let t = age;
5753 let truth = rate * t * t * t / 3.0; for k in 5..=12 {
5760 let shape = 10f64.powi(-(k as i32)); let (d2_cum, _) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
5762 assert_close(
5763 d2_cum,
5764 truth,
5765 1e-3,
5766 &format!("∂²H_G/∂shape² in old-pivot gap (age={age}, shape=1e-{k})"),
5767 );
5768 }
5769 }
5770
5771 #[test]
5772 fn weibull_offset_partials_match_central_diff() {
5773 let cases = [
5774 (0.5_f64, 1.2_f64, 25.0_f64),
5775 (2.0, 0.8, 60.0),
5776 (0.1, 3.0, 10.0),
5777 ];
5778 for &(scale, shape, age) in &cases {
5779 let cfg = SurvivalBaselineConfig {
5780 target: SurvivalBaselineTarget::Weibull,
5781 scale: Some(scale),
5782 shape: Some(shape),
5783 rate: None,
5784 makeham: None,
5785 };
5786 let analytic = baseline_offset_theta_partials(age, &cfg)
5787 .expect("ok")
5788 .expect("nl");
5789 let fd = fd_baseline_offset(age, &cfg, &[1e-5, 1e-5]);
5790 assert_eq!(analytic.len(), 2);
5791 for k in 0..2 {
5792 assert_close(
5793 analytic[k].0,
5794 fd[k].0,
5795 1e-7,
5796 &format!("weibull ∂eta/∂θ[{k}] (scale={scale}, shape={shape}, age={age})"),
5797 );
5798 assert_close(
5799 analytic[k].1,
5800 fd[k].1,
5801 1e-7,
5802 &format!("weibull ∂o_D/∂θ[{k}] (scale={scale}, shape={shape}, age={age})"),
5803 );
5804 }
5805 assert_eq!(analytic[0].1, 0.0);
5807 }
5808 }
5809
5810 #[test]
5811 fn gompertz_makeham_offset_partials_match_central_diff() {
5812 let cases = [
5813 (0.3_f64, 0.05_f64, 0.002_f64, 40.0_f64),
5814 (0.5, 0.01, 0.01, 25.0),
5815 (0.2, 0.001, 0.005, 60.0),
5816 (0.4, 5e-11, 0.01, 25.0),
5817 (0.4, -5e-11, 0.01, 25.0),
5818 (0.8, 0.2, 0.05, 5.0),
5819 ];
5820 for &(rate, shape, makeham, age) in &cases {
5821 let cfg = SurvivalBaselineConfig {
5822 target: SurvivalBaselineTarget::GompertzMakeham,
5823 scale: None,
5824 shape: Some(shape),
5825 rate: Some(rate),
5826 makeham: Some(makeham),
5827 };
5828 let analytic = baseline_offset_theta_partials(age, &cfg)
5829 .expect("ok")
5830 .expect("nl");
5831 let h_shape = if shape.abs() < 1e-9 { 1e-11 } else { 1e-5 };
5835 let fd = fd_baseline_offset(age, &cfg, &[1e-5, h_shape, 1e-5]);
5836 assert_eq!(analytic.len(), 3);
5837 for k in 0..3 {
5838 assert_close(
5839 analytic[k].0,
5840 fd[k].0,
5841 1e-5,
5842 &format!(
5843 "gm ∂eta/∂θ[{k}] (rate={rate}, shape={shape}, mk={makeham}, age={age})"
5844 ),
5845 );
5846 assert_close(
5847 analytic[k].1,
5848 fd[k].1,
5849 1e-5,
5850 &format!(
5851 "gm ∂o_D/∂θ[{k}] (rate={rate}, shape={shape}, mk={makeham}, age={age})"
5852 ),
5853 );
5854 }
5855 }
5856 }
5857
5858 #[test]
5859 fn linear_baseline_has_no_theta_partials() {
5860 let cfg = SurvivalBaselineConfig {
5861 target: SurvivalBaselineTarget::Linear,
5862 scale: None,
5863 shape: None,
5864 rate: None,
5865 makeham: None,
5866 };
5867 assert!(baseline_offset_theta_partials(5.0, &cfg).unwrap().is_none());
5868 }
5869
5870 #[test]
5871 fn baseline_offset_partials_reject_non_positive_ages() {
5872 let cfg = SurvivalBaselineConfig {
5873 target: SurvivalBaselineTarget::Gompertz,
5874 scale: None,
5875 shape: Some(0.01),
5876 rate: Some(0.5),
5877 makeham: None,
5878 };
5879 assert!(baseline_offset_theta_partials(0.0, &cfg).is_err());
5880 assert!(baseline_offset_theta_partials(-1.0, &cfg).is_err());
5881 assert!(baseline_offset_theta_partials(f64::NAN, &cfg).is_err());
5882 }
5883
5884 #[test]
5890 fn chain_rule_gradient_single_obs_reduces_to_pointwise_contract() {
5891 let cfg = SurvivalBaselineConfig {
5892 target: SurvivalBaselineTarget::Gompertz,
5893 scale: None,
5894 shape: Some(0.05),
5895 rate: Some(0.3),
5896 makeham: None,
5897 };
5898 let age_entry = array![10.0_f64];
5899 let age_exit = array![25.0_f64];
5900 let residuals = OffsetChannelResiduals {
5901 exit: array![0.7_f64],
5902 entry: array![-0.2_f64],
5903 derivative: array![-0.4_f64],
5904 right: Array1::<f64>::zeros(1),
5905 };
5906 let grad = baseline_chain_rule_gradient(
5907 age_entry.view(),
5908 age_exit.view(),
5909 age_exit.view(),
5910 &cfg,
5911 &residuals,
5912 )
5913 .expect("ok")
5914 .expect("non-linear");
5915 let p_exit = baseline_offset_theta_partials(age_exit[0], &cfg)
5917 .unwrap()
5918 .unwrap();
5919 let p_entry = baseline_offset_theta_partials(age_entry[0], &cfg)
5920 .unwrap()
5921 .unwrap();
5922 for k in 0..p_exit.len() {
5923 let expected = 0.7 * p_exit[k].0 + (-0.4) * p_exit[k].1 + (-0.2) * p_entry[k].0;
5924 assert!(
5925 (grad[k] - expected).abs() < 1e-12,
5926 "chain-rule contract mismatch at k={k}: got={:.6e} expected={:.6e}",
5927 grad[k],
5928 expected
5929 );
5930 }
5931 }
5932
5933 #[test]
5936 fn chain_rule_gradient_skips_entry_call_for_origin_entry_rows() {
5937 let cfg = SurvivalBaselineConfig {
5938 target: SurvivalBaselineTarget::Gompertz,
5939 scale: None,
5940 shape: Some(0.05),
5941 rate: Some(0.3),
5942 makeham: None,
5943 };
5944 let age_entry = array![0.0_f64, 5.0_f64];
5945 let age_exit = array![10.0_f64, 20.0_f64];
5946 let residuals = OffsetChannelResiduals {
5947 exit: array![0.5_f64, 0.3_f64],
5948 entry: array![0.0_f64, -0.1_f64], derivative: array![-0.2_f64, 0.0_f64],
5950 right: Array1::<f64>::zeros(2),
5951 };
5952 let grad = baseline_chain_rule_gradient(
5954 age_entry.view(),
5955 age_exit.view(),
5956 age_exit.view(),
5957 &cfg,
5958 &residuals,
5959 )
5960 .expect("must not fail on origin-entry row with r_entry=0")
5961 .expect("non-linear");
5962 assert_eq!(grad.len(), 2);
5963 let p_exit_0 = baseline_offset_theta_partials(10.0, &cfg).unwrap().unwrap();
5965 let p_exit_1 = baseline_offset_theta_partials(20.0, &cfg).unwrap().unwrap();
5966 let p_entry_1 = baseline_offset_theta_partials(5.0, &cfg).unwrap().unwrap();
5967 for k in 0..2 {
5968 let expected = 0.5 * p_exit_0[k].0
5969 + (-0.2) * p_exit_0[k].1
5970 + 0.3 * p_exit_1[k].0
5971 + (-0.1) * p_entry_1[k].0;
5972 assert!(
5973 (grad[k] - expected).abs() < 1e-12,
5974 "origin-entry contract at k={k}: got={:.6e} expected={:.6e}",
5975 grad[k],
5976 expected
5977 );
5978 }
5979 }
5980
5981 #[test]
5983 fn chain_rule_gradient_linear_target_returns_none() {
5984 let cfg = SurvivalBaselineConfig {
5985 target: SurvivalBaselineTarget::Linear,
5986 scale: None,
5987 shape: None,
5988 rate: None,
5989 makeham: None,
5990 };
5991 let age_entry = array![1.0_f64];
5992 let age_exit = array![2.0_f64];
5993 let residuals = OffsetChannelResiduals {
5994 exit: array![0.1_f64],
5995 entry: array![0.0_f64],
5996 derivative: array![0.0_f64],
5997 right: Array1::<f64>::zeros(1),
5998 };
5999 let grad = baseline_chain_rule_gradient(
6000 age_entry.view(),
6001 age_exit.view(),
6002 age_exit.view(),
6003 &cfg,
6004 &residuals,
6005 )
6006 .expect("ok");
6007 assert!(grad.is_none());
6008 }
6009
6010 #[test]
6029 fn chain_rule_gradient_matches_fd_of_nll_through_offset_perturbation() {
6030 let cfg = SurvivalBaselineConfig {
6033 target: SurvivalBaselineTarget::Gompertz,
6034 scale: None,
6035 shape: Some(0.03),
6036 rate: Some(0.25),
6037 makeham: None,
6038 };
6039 let age_entry = array![0.0_f64, 5.0, 8.0];
6040 let age_exit = array![4.0_f64, 12.0, 20.0];
6041 let weights = array![1.0_f64, 2.0, 0.5];
6044 let events = [1.0_f64, 1.0, 0.0];
6045 let eta_entry_vals = [-100.0_f64, 0.5, 0.8]; let eta_exit_vals = [0.4_f64, 0.9, 1.3];
6050 let s_vals = [0.7_f64, 1.1, 1.5];
6051 let (r_x, r_e, r_d) = {
6052 let mut rx = Array1::<f64>::zeros(3);
6053 let mut re = Array1::<f64>::zeros(3);
6054 let mut rd = Array1::<f64>::zeros(3);
6055 for i in 0..3 {
6056 let w = weights[i];
6057 let d = events[i];
6058 rx[i] = w * (eta_exit_vals[i].exp() - d);
6059 re[i] = if i == 0 {
6060 0.0 } else {
6062 -w * eta_entry_vals[i].exp()
6063 };
6064 rd[i] = if d > 0.0 { -w * d / s_vals[i] } else { 0.0 };
6065 }
6066 (rx, re, rd)
6067 };
6068 let residuals = OffsetChannelResiduals {
6069 exit: r_x.clone(),
6070 entry: r_e.clone(),
6071 derivative: r_d.clone(),
6072 right: Array1::<f64>::zeros(3),
6073 };
6074 let grad = baseline_chain_rule_gradient(
6075 age_entry.view(),
6076 age_exit.view(),
6077 age_exit.view(),
6078 &cfg,
6079 &residuals,
6080 )
6081 .expect("ok")
6082 .expect("non-linear");
6083
6084 let nll = |theta_plus: &Array1<f64>| -> f64 {
6089 let cfg_p = survival_baseline_config_from_theta(cfg.target, theta_plus).expect("cfg_p");
6090 let mut sum = 0.0_f64;
6091 for i in 0..3 {
6092 let (eta_x_p, d_x_p) = evaluate_survival_baseline(age_exit[i], &cfg_p).unwrap();
6093 let base = evaluate_survival_baseline(age_exit[i], &cfg).unwrap();
6094 let d_eta_x = eta_x_p - base.0;
6095 let d_d_x = d_x_p - base.1;
6096 let eta_exit_new = eta_exit_vals[i] + d_eta_x;
6097 let s_new = s_vals[i] + d_d_x;
6098 let interval_entry = if i == 0 {
6099 0.0_f64
6100 } else {
6101 let (eta_e_p, _) = evaluate_survival_baseline(age_entry[i], &cfg_p).unwrap();
6102 let base_e = evaluate_survival_baseline(age_entry[i], &cfg).unwrap();
6103 let d_eta_e = eta_e_p - base_e.0;
6104 let eta_entry_new = eta_entry_vals[i] + d_eta_e;
6105 eta_entry_new.exp()
6106 };
6107 let w = weights[i];
6108 let d = events[i];
6109 let nll_i =
6110 w * (eta_exit_new.exp() - interval_entry - d * (eta_exit_new + s_new.ln()));
6111 sum += nll_i;
6112 }
6113 sum
6114 };
6115
6116 let theta_base = survival_baseline_theta_from_config(&cfg).unwrap().unwrap();
6117 let h = 1e-6;
6118 for k in 0..theta_base.len() {
6119 let mut tp = theta_base.clone();
6120 let mut tm = theta_base.clone();
6121 tp[k] += h;
6122 tm[k] -= h;
6123 let fd = (nll(&tp) - nll(&tm)) / (2.0 * h);
6124 assert!(
6125 (grad[k] - fd).abs() < 1e-5 * grad[k].abs().max(1.0),
6126 "chain-rule θ[{k}]: analytic={:.6e} fd={:.6e}",
6127 grad[k],
6128 fd
6129 );
6130 }
6131 }
6132
6133 #[test]
6135 fn chain_rule_gradient_rejects_length_mismatch() {
6136 let cfg = SurvivalBaselineConfig {
6137 target: SurvivalBaselineTarget::Gompertz,
6138 scale: None,
6139 shape: Some(0.05),
6140 rate: Some(0.3),
6141 makeham: None,
6142 };
6143 let age_entry = array![1.0_f64, 2.0]; let age_exit = array![5.0_f64, 6.0, 7.0]; let residuals = OffsetChannelResiduals {
6146 exit: array![0.1_f64, 0.2, 0.3],
6147 entry: array![0.0_f64, 0.0, 0.0],
6148 derivative: array![0.0_f64, 0.0, 0.0],
6149 right: Array1::<f64>::zeros(3),
6150 };
6151 let err = baseline_chain_rule_gradient(
6152 age_entry.view(),
6153 age_exit.view(),
6154 age_exit.view(),
6155 &cfg,
6156 &residuals,
6157 )
6158 .expect_err("length mismatch must error");
6159 assert!(err.contains("length mismatch"), "err={err}");
6160 }
6161}