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