1use crate::custom_family::{
2 BlockWorkingSet, CustomFamily, FamilyEvaluation, ParameterBlockState,
3 projected_linear_constraint_stationarity_vector,
4};
5use crate::model_types::EstimationError;
6use gam_linalg::faer_ndarray::{fast_atv, fast_av, fast_xt_diag_x, fast_xt_diag_y};
7use gam_linalg::matrix::SymmetricMatrix;
8use gam_problem::{Coefficients, LinearPredictor};
9use gam_row_macros::row_atom;
10use gam_solve::pirls::{
11 ConstraintSet, LinearInequalityConstraints, WorkingModel as PirlsWorkingModel, WorkingState,
12 array1_l2_norm,
13};
14use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayView3, Axis};
15use opt::{BacktrackConfig, RidgeSchedule, backtracking_line_search, constants, escalate_ridge};
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18use std::convert::Infallible;
19use std::ops::Range;
20use std::sync::LazyLock;
21use thiserror::Error;
22
23#[derive(Debug, Error)]
24pub enum SurvivalError {
25 #[error("input dimensions are inconsistent")]
26 DimensionMismatch,
27 #[error("inputs contain non-finite values")]
28 NonFiniteInput,
29 #[error("survival spec '{0}' is not supported by the one-hazard survival engine")]
30 UnsupportedSpec(&'static str),
31 #[error("crude risk integration setup is invalid")]
32 InvalidIntegrationSetup,
33 #[error("survival time grid must be finite, non-negative, and strictly increasing")]
34 InvalidTimeGrid,
35 #[error("cumulative hazard must be nondecreasing")]
36 NonMonotoneCumulativeHazard,
37 #[error("instantaneous hazard must stay strictly positive during integration")]
38 NonPositiveHazard,
39 #[error("{reason}")]
40 InvalidInput { reason: String },
41 #[error("{reason}")]
42 CauseSpecificDimensionMismatch { reason: String },
43 #[error("{reason}")]
44 NumericalFailure { reason: String },
45 #[error("{reason}")]
46 EventCodeInvalid { reason: String },
47 #[error("cause-specific survival block {block}: {source}")]
48 CauseSpecificBlock {
49 block: usize,
50 #[source]
51 source: Box<SurvivalError>,
52 },
53}
54
55impl From<SurvivalError> for String {
56 fn from(err: SurvivalError) -> Self {
57 err.to_string()
58 }
59}
60
61impl From<crate::block_layout::block_count::BlockCountMismatch> for SurvivalError {
62 fn from(err: crate::block_layout::block_count::BlockCountMismatch) -> SurvivalError {
63 SurvivalError::CauseSpecificDimensionMismatch {
64 reason: err.message(),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
70pub enum SurvivalSpec {
71 #[default]
72 Net,
73 Crude,
74}
75
76#[derive(Debug, Clone)]
77pub struct SurvivalEngineInputs<'a> {
78 pub age_entry: ArrayView1<'a, f64>,
79 pub age_exit: ArrayView1<'a, f64>,
80 pub event_target: ArrayView1<'a, u8>,
81 pub event_competing: ArrayView1<'a, u8>,
82 pub sampleweight: ArrayView1<'a, f64>,
83 pub x_entry: ArrayView2<'a, f64>,
84 pub x_exit: ArrayView2<'a, f64>,
85 pub x_derivative: ArrayView2<'a, f64>,
86 pub monotonicity_constraint_rows: Option<ArrayView2<'a, f64>>,
90 pub monotonicity_constraint_offsets: Option<ArrayView1<'a, f64>>,
92}
93
94#[derive(Debug, Clone)]
95pub struct SurvivalTimeCovarInputs<'a> {
96 pub age_entry: ArrayView1<'a, f64>,
97 pub age_exit: ArrayView1<'a, f64>,
98 pub event_target: ArrayView1<'a, u8>,
99 pub event_competing: ArrayView1<'a, u8>,
100 pub sampleweight: ArrayView1<'a, f64>,
101 pub time_entry: ArrayView2<'a, f64>,
102 pub time_exit: ArrayView2<'a, f64>,
103 pub time_derivative: ArrayView2<'a, f64>,
104 pub covariates: ArrayView2<'a, f64>,
105 pub monotonicity_constraint_rows: Option<ArrayView2<'a, f64>>,
109 pub monotonicity_constraint_offsets: Option<ArrayView1<'a, f64>>,
111}
112
113#[derive(Debug, Clone)]
114pub struct SurvivalBaselineOffsets<'a> {
115 pub eta_entry: ArrayView1<'a, f64>,
117 pub eta_exit: ArrayView1<'a, f64>,
119 pub derivative_exit: ArrayView1<'a, f64>,
127}
128
129#[derive(Debug, Clone)]
130pub struct PenaltyBlock {
131 pub matrix: Array2<f64>,
132 pub lambda: f64,
133 pub range: Range<usize>,
134 pub nullspace_dim: usize,
137}
138
139#[derive(Debug, Clone)]
140pub struct PenaltyBlocks {
141 pub blocks: Vec<PenaltyBlock>,
142}
143
144impl PenaltyBlocks {
145 pub fn new(blocks: Vec<PenaltyBlock>) -> Self {
146 Self { blocks }
147 }
148
149 pub fn gradient(&self, beta: &Array1<f64>) -> Array1<f64> {
150 let mut grad = Array1::zeros(beta.len());
151 for block in &self.blocks {
152 if block.lambda == 0.0 {
153 continue;
154 }
155 let b = beta.slice(ndarray::s![block.range.clone()]);
156 let g = block.matrix.dot(&b);
157 let mut dst = grad.slice_mut(ndarray::s![block.range.clone()]);
158 dst += &(block.lambda * g);
159 }
160 grad
161 }
162
163 pub fn hessian(&self, dim: usize) -> Array2<f64> {
164 let mut h = Array2::zeros((dim, dim));
165 self.addhessian_inplace(&mut h);
166 h
167 }
168
169 pub fn deviance(&self, beta: &Array1<f64>) -> f64 {
170 let mut value = 0.0;
171 for block in &self.blocks {
172 if block.lambda == 0.0 {
173 continue;
174 }
175 let b = beta.slice(ndarray::s![block.range.clone()]);
176 value += 0.5 * block.lambda * b.dot(&block.matrix.dot(&b));
177 }
178 value
179 }
180
181 pub fn addhessian_inplace(&self, h: &mut Array2<f64>) {
182 for block in &self.blocks {
183 if block.lambda == 0.0 {
184 continue;
185 }
186 let start = block.range.start;
187 let end = block.range.end;
188 h.slice_mut(ndarray::s![start..end, start..end])
189 .scaled_add(block.lambda, &block.matrix);
190 }
191 }
192}
193
194pub const ENTRY_AT_ORIGIN_THRESHOLD: f64 = 1e-8;
207
208const DERIVATIVE_FRACTION_TO_BOUNDARY: f64 = 0.995;
216
217pub(crate) const SURVIVAL_LAML_STATIONARITY_RELATIVE_TOL: f64 = 1.0e-8;
224
225#[derive(Debug, Clone)]
226pub struct CauseSpecificRoystonParmarBlock {
227 pub age_entry: Array1<f64>,
228 pub age_exit: Array1<f64>,
229 pub event_target: Array1<u8>,
230 pub sampleweight: Array1<f64>,
231 pub x_entry: Array2<f64>,
232 pub x_exit: Array2<f64>,
233 pub x_derivative: Array2<f64>,
234 pub offset_eta_entry: Array1<f64>,
235 pub offset_eta_exit: Array1<f64>,
236 pub offset_derivative_exit: Array1<f64>,
237 pub derivative_floor: f64,
238 pub structural_time_columns: usize,
255}
256
257#[derive(Debug, Clone)]
263pub struct CauseSpecificRoystonParmarFamily {
264 blocks: Vec<CauseSpecificRoystonParmarBlock>,
265}
266
267impl CauseSpecificRoystonParmarFamily {
268 pub fn new(blocks: Vec<CauseSpecificRoystonParmarBlock>) -> Result<Self, String> {
269 if blocks.is_empty() {
270 return Err(SurvivalError::InvalidInput {
271 reason: "cause-specific survival family requires at least one endpoint".to_string(),
272 }
273 .into());
274 }
275 for (idx, block) in blocks.iter().enumerate() {
276 validate_cause_specific_block(block).map_err(|err| {
277 SurvivalError::CauseSpecificBlock {
278 block: idx + 1,
279 source: Box::new(err),
280 }
281 .to_string()
282 })?;
283 }
284 Ok(Self { blocks })
285 }
286
287 pub fn cause_count(&self) -> usize {
288 self.blocks.len()
289 }
290}
291
292fn validate_cause_specific_block(
293 block: &CauseSpecificRoystonParmarBlock,
294) -> Result<(), SurvivalError> {
295 let n = block.event_target.len();
296 let p = block.x_exit.ncols();
297 if n == 0 || p == 0 {
298 bail_invalid_surv!("empty event vector or coefficient block");
299 }
300 if block.age_entry.len() != n
301 || block.age_exit.len() != n
302 || block.sampleweight.len() != n
303 || block.x_entry.nrows() != n
304 || block.x_exit.nrows() != n
305 || block.x_derivative.nrows() != n
306 || block.x_entry.ncols() != p
307 || block.x_derivative.ncols() != p
308 || block.offset_eta_entry.len() != n
309 || block.offset_eta_exit.len() != n
310 || block.offset_derivative_exit.len() != n
311 {
312 return Err(SurvivalError::CauseSpecificDimensionMismatch {
313 reason: "dimension mismatch".to_string(),
314 });
315 }
316 if let Some(&label) = block.event_target.iter().find(|&&v| v > 1) {
322 return Err(SurvivalError::EventCodeInvalid {
323 reason: format!(
324 "cause-specific block event_target must be the binary cause indicator {{0, 1}}, got multi-cause label {label}; project raw codes per cause via cause_specific_event_indicator"
325 ),
326 });
327 }
328 if block.age_entry.iter().any(|v| !v.is_finite())
329 || block.age_exit.iter().any(|v| !v.is_finite())
330 || block
331 .sampleweight
332 .iter()
333 .any(|v| !v.is_finite() || *v < 0.0)
334 || block.x_entry.iter().any(|v| !v.is_finite())
335 || block.x_exit.iter().any(|v| !v.is_finite())
336 || block.x_derivative.iter().any(|v| !v.is_finite())
337 || block.offset_eta_entry.iter().any(|v| !v.is_finite())
338 || block.offset_eta_exit.iter().any(|v| !v.is_finite())
339 || block.offset_derivative_exit.iter().any(|v| !v.is_finite())
340 || !block.derivative_floor.is_finite()
341 || block.derivative_floor < 0.0
342 {
343 bail_invalid_surv!("non-finite input");
344 }
345 Ok(())
346}
347
348row_atom! {
349 fn cause_specific_row [generic, order2, third, fourth](
350 eta_exit,
351 eta_entry,
352 derivative;
353 weight: scale,
354 entry_active: bool,
355 event: bool
356 ) {
357 weight
358 * (exp(eta_exit)
359 - entry_active * exp(eta_entry)
360 - event * (eta_exit + ln(derivative)))
361 }
362}
363
364pub struct CauseSpecificSurvivalAloRowInput {
367 pub eta_exit: f64,
368 pub eta_entry: f64,
369 pub derivative_exit: f64,
370 pub prior_weight: f64,
371 pub entry_active: bool,
372 pub event: bool,
373}
374
375#[derive(Clone, Debug, PartialEq)]
378pub struct CauseSpecificSurvivalAloRowGeometry {
379 pub negative_log_likelihood: f64,
380 pub nll_score: [f64; 3],
381 pub observed_hessian: [[f64; 3]; 3],
382}
383
384pub fn cause_specific_survival_alo_row_geometry(
390 input: CauseSpecificSurvivalAloRowInput,
391) -> Result<CauseSpecificSurvivalAloRowGeometry, String> {
392 if !input.prior_weight.is_finite() || input.prior_weight < 0.0 {
393 return Err(format!(
394 "cause-specific saved ALO prior weight must be finite and non-negative, got {}",
395 input.prior_weight
396 ));
397 }
398 if input.prior_weight == 0.0 {
404 return Ok(CauseSpecificSurvivalAloRowGeometry {
405 negative_log_likelihood: 0.0,
406 nll_score: [0.0; 3],
407 observed_hessian: [[0.0; 3]; 3],
408 });
409 }
410 if !input.eta_exit.is_finite() {
411 return Err(format!(
412 "cause-specific saved ALO exit index must be finite, got {}",
413 input.eta_exit
414 ));
415 }
416 let eta_entry = if input.entry_active {
417 if !input.eta_entry.is_finite() {
418 return Err(format!(
419 "cause-specific saved ALO active entry index must be finite, got {}",
420 input.eta_entry
421 ));
422 }
423 input.eta_entry
424 } else {
425 0.0
426 };
427 let derivative_exit = if input.event {
428 if !input.derivative_exit.is_finite() || input.derivative_exit <= 0.0 {
429 return Err(format!(
430 "cause-specific saved ALO event derivative must be positive and finite, got {}",
431 input.derivative_exit
432 ));
433 }
434 input.derivative_exit
435 } else {
436 1.0
437 };
438 let atom = cause_specific_row_order2(
439 input.eta_exit,
440 eta_entry,
441 derivative_exit,
442 input.prior_weight,
443 input.entry_active,
444 input.event,
445 );
446 let gradient = atom.gradient();
447 let observed_hessian =
448 std::array::from_fn(|row| std::array::from_fn(|column| atom.hessian_at(row, column)));
449 if !atom.value().is_finite()
450 || gradient.iter().any(|value| !value.is_finite())
451 || observed_hessian
452 .iter()
453 .flatten()
454 .any(|value| !value.is_finite())
455 {
456 return Err(format!(
457 "cause-specific saved ALO row geometry is non-finite: nll={}, score={gradient:?}, hessian={observed_hessian:?}",
458 atom.value(),
459 ));
460 }
461 Ok(CauseSpecificSurvivalAloRowGeometry {
462 negative_log_likelihood: atom.value(),
463 nll_score: gradient,
464 observed_hessian,
465 })
466}
467
468#[derive(Clone, Copy)]
469struct CauseSpecificAtomInput {
470 primary: [f64; 3],
471 weight: f64,
472 entry_active: bool,
473 event: bool,
474}
475
476pub struct CauseSpecificRowProgram {
484 primary: [f64; 3],
485 weight: f64,
486 entry_active: bool,
487 event: bool,
488}
489
490impl CauseSpecificRowProgram {
491 pub fn new(primary: [f64; 3], weight: f64, entry_active: bool, event: bool) -> Self {
493 Self {
494 primary,
495 weight,
496 entry_active,
497 event,
498 }
499 }
500
501 fn require_row(row: usize) -> Result<(), String> {
502 if row != 0 {
503 return Err(format!(
504 "CauseSpecificRowProgram holds exactly one row; got row {row}"
505 ));
506 }
507 Ok(())
508 }
509}
510
511impl gam_math::jet_tower::RowProgram<3> for CauseSpecificRowProgram {
512 fn n_rows(&self) -> usize {
513 1
514 }
515
516 fn primaries(&self, row: usize) -> Result<[f64; 3], String> {
517 Self::require_row(row)?;
518 Ok(self.primary)
519 }
520
521 fn eval<S: gam_math::jet_scalar::JetScalar<3>>(
522 &self,
523 row: usize,
524 p: &[S; 3],
525 ) -> Result<S, String> {
526 Self::require_row(row)?;
527 Ok(cause_specific_row(
528 &p[0],
529 &p[1],
530 &p[2],
531 self.weight,
532 self.entry_active,
533 self.event,
534 ))
535 }
536}
537
538fn cause_specific_atom_input(
543 block: &CauseSpecificRoystonParmarBlock,
544 row: usize,
545 eta_entry: f64,
546 eta_exit: f64,
547 derivative: f64,
548) -> Result<Option<CauseSpecificAtomInput>, SurvivalError> {
549 let weight = block.sampleweight[row];
550 if weight <= 0.0 {
551 return Ok(None);
552 }
553 if block.age_exit[row] < block.age_entry[row] {
554 bail_invalid_surv!("age_exit < age_entry at row {row}");
555 }
556 let entry_active = block.age_entry[row] > ENTRY_AT_ORIGIN_THRESHOLD;
557 let event = block.event_target[row] > 0;
558 let eta_entry = if entry_active { eta_entry } else { 0.0 };
559 let derivative = if event {
560 if !(derivative.is_finite() && derivative > 0.0) {
561 return Err(SurvivalError::NumericalFailure {
562 reason: format!(
563 "cause-specific survival derivative must be positive at row {row}, got {derivative}"
564 ),
565 });
566 }
567 derivative
568 } else {
569 1.0
570 };
571 let h_exit = eta_exit.exp();
572 let h_entry = eta_entry.exp();
573 if !(h_exit.is_finite() && h_entry.is_finite()) {
574 return Err(SurvivalError::NumericalFailure {
575 reason: format!("non-finite cumulative hazard at row {row}"),
576 });
577 }
578 Ok(Some(CauseSpecificAtomInput {
579 primary: [eta_exit, eta_entry, derivative],
580 weight,
581 entry_active,
582 event,
583 }))
584}
585
586const CAUSE_SPECIFIC_PRIMARY_PAIRS: [(usize, usize); 6] =
587 [(0, 0), (0, 1), (0, 2), (1, 1), (1, 2), (2, 2)];
588
589fn cause_specific_pullback_hessian(
595 block: &CauseSpecificRoystonParmarBlock,
596 weights: &[Array1<f64>; 6],
597) -> Array2<f64> {
598 let designs = [&block.x_exit, &block.x_entry, &block.x_derivative];
599 let p = block.x_exit.ncols();
600 let mut hessian = Array2::<f64>::zeros((p, p));
601 for (slot, &(left, right)) in CAUSE_SPECIFIC_PRIMARY_PAIRS.iter().enumerate() {
602 let channel = &weights[slot];
603 if channel.iter().all(|&value| value == 0.0) {
604 continue;
605 }
606 if left == right {
607 hessian += &fast_xt_diag_x(designs[left], channel);
608 } else {
609 let cross = fast_xt_diag_y(designs[left], channel, designs[right]);
610 hessian += ✗
611 hessian += &cross.t();
612 }
613 }
614 hessian
615}
616
617fn evaluate_cause_specific_block(
618 block: &CauseSpecificRoystonParmarBlock,
619 beta: &Array1<f64>,
620) -> Result<(f64, Array1<f64>, Array2<f64>), SurvivalError> {
621 let n = block.event_target.len();
622 let p = block.x_exit.ncols();
623 if beta.len() != p {
624 return Err(SurvivalError::CauseSpecificDimensionMismatch {
625 reason: format!("beta length mismatch: got {}, expected {p}", beta.len()),
626 });
627 }
628 let eta_entry = fast_av(&block.x_entry, beta) + &block.offset_eta_entry;
629 let eta_exit = fast_av(&block.x_exit, beta) + &block.offset_eta_exit;
630 let derivative = fast_av(&block.x_derivative, beta) + &block.offset_derivative_exit;
631 let mut log_likelihood = 0.0;
632 let mut gradient_weights: [Array1<f64>; 3] = std::array::from_fn(|_| Array1::<f64>::zeros(n));
633 let mut hessian_weights: [Array1<f64>; 6] = std::array::from_fn(|_| Array1::<f64>::zeros(n));
634
635 for i in 0..n {
636 let Some(input) =
637 cause_specific_atom_input(block, i, eta_entry[i], eta_exit[i], derivative[i])?
638 else {
639 continue;
640 };
641 let atom = cause_specific_row_order2(
642 input.primary[0],
643 input.primary[1],
644 input.primary[2],
645 input.weight,
646 input.entry_active,
647 input.event,
648 );
649 log_likelihood -= atom.value();
650 let gradient = atom.gradient();
651 for axis in 0..3 {
652 gradient_weights[axis][i] = -gradient[axis];
653 }
654 for (slot, &(left, right)) in CAUSE_SPECIFIC_PRIMARY_PAIRS.iter().enumerate() {
655 hessian_weights[slot][i] = atom.hessian_at(left, right);
656 }
657 }
658
659 let designs = [&block.x_exit, &block.x_entry, &block.x_derivative];
660 let mut gradient = Array1::<f64>::zeros(p);
661 for axis in 0..3 {
662 gradient += &fast_atv(designs[axis], &gradient_weights[axis]);
663 }
664 let hessian = cause_specific_pullback_hessian(block, &hessian_weights);
665 Ok((log_likelihood, gradient, hessian))
666}
667
668impl CustomFamily for CauseSpecificRoystonParmarFamily {
669 fn joint_jeffreys_term_required(&self) -> bool {
673 true
674 }
675
676 fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
677 crate::block_layout::block_count::validate_block_count::<SurvivalError>(
678 "cause-specific survival",
679 self.blocks.len(),
680 block_states.len(),
681 )?;
682 let mut log_likelihood = 0.0;
683 let mut blockworking_sets = Vec::with_capacity(self.blocks.len());
684 for (block, state) in self.blocks.iter().zip(block_states.iter()) {
685 let (ll, gradient, hessian) = evaluate_cause_specific_block(block, &state.beta)?;
686 log_likelihood += ll;
687 blockworking_sets.push(BlockWorkingSet::ExactNewton {
688 gradient,
689 hessian: SymmetricMatrix::Dense(hessian),
690 });
691 }
692 Ok(FamilyEvaluation {
693 log_likelihood,
694 blockworking_sets,
695 })
696 }
697
698 fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
699 crate::block_layout::block_count::validate_block_count::<SurvivalError>(
700 "cause-specific survival",
701 self.blocks.len(),
702 block_states.len(),
703 )?;
704 let mut log_likelihood = 0.0;
705 for (block, state) in self.blocks.iter().zip(block_states.iter()) {
706 let (ll, _, _) = evaluate_cause_specific_block(block, &state.beta)?;
707 log_likelihood += ll;
708 }
709 Ok(log_likelihood)
710 }
711
712 fn likelihood_blocks_uncoupled(&self) -> bool {
713 true
714 }
715
716 fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
717 true
718 }
719
720 fn output_channel_assignment(
721 &self,
722 specs: &[crate::custom_family::ParameterBlockSpec],
723 ) -> Option<Vec<usize>> {
724 if specs.len() != self.blocks.len() {
725 return Some((0..self.blocks.len()).collect());
726 }
727 Some((0..specs.len()).collect())
728 }
729
730 fn coefficient_hessian_cost(&self, specs: &[crate::custom_family::ParameterBlockSpec]) -> u64 {
731 crate::custom_family::default_coefficient_hessian_cost(specs)
732 }
733
734 fn block_linear_constraints(
735 &self,
736 block_states: &[ParameterBlockState],
737 block_idx: usize,
738 spec: &crate::custom_family::ParameterBlockSpec,
739 ) -> Result<Option<ConstraintSet>, String> {
740 let state = block_states.get(block_idx).ok_or_else(|| {
744 SurvivalError::CauseSpecificDimensionMismatch {
745 reason: format!(
746 "cause-specific survival expected block index < {}, got {block_idx}",
747 block_states.len()
748 ),
749 }
750 .to_string()
751 })?;
752 if state.beta.len() != spec.design.ncols() {
753 return Err(SurvivalError::CauseSpecificDimensionMismatch {
754 reason: format!(
755 "cause-specific survival block {block_idx} carries {} coefficient(s) but spec \
756 '{}' has {} design column(s)",
757 state.beta.len(),
758 spec.name,
759 spec.design.ncols()
760 ),
761 }
762 .into());
763 }
764 let block = self.blocks.get(block_idx).ok_or_else(|| {
765 SurvivalError::CauseSpecificDimensionMismatch {
766 reason: format!(
767 "cause-specific survival expected block index < {}, got {block_idx}",
768 self.blocks.len()
769 ),
770 }
771 .to_string()
772 })?;
773 if block.x_derivative.ncols() != spec.design.ncols() {
774 return Err(SurvivalError::CauseSpecificDimensionMismatch {
775 reason: format!(
776 "cause-specific survival derivative design has {} columns but block '{}' has {}",
777 block.x_derivative.ncols(),
778 spec.name,
779 spec.design.ncols()
780 ),
781 }
782 .into());
783 }
784 let rhs = block
785 .offset_derivative_exit
786 .mapv(|offset| block.derivative_floor - offset);
787 let p = block.x_derivative.ncols();
788 let n_rows = block.x_derivative.nrows();
789 let structural_cols = block.structural_time_columns.min(p);
800 if structural_cols == 0 {
801 return Ok(Some(ConstraintSet::Dense(LinearInequalityConstraints {
802 a: block.x_derivative.clone(),
803 b: rhs,
804 })));
805 }
806 let mut a = Array2::<f64>::zeros((n_rows + structural_cols, p));
807 a.slice_mut(ndarray::s![..n_rows, ..])
808 .assign(&block.x_derivative);
809 for j in 0..structural_cols {
810 a[[n_rows + j, j]] = 1.0;
811 }
812 let mut b = Array1::<f64>::zeros(n_rows + structural_cols);
813 b.slice_mut(ndarray::s![..n_rows]).assign(&rhs);
814 Ok(Some(ConstraintSet::Dense(LinearInequalityConstraints {
815 a,
816 b,
817 })))
818 }
819
820 fn max_feasible_step_size(
821 &self,
822 block_states: &[ParameterBlockState],
823 block_idx: usize,
824 delta: &Array1<f64>,
825 ) -> Result<Option<f64>, String> {
826 let block = self.blocks.get(block_idx).ok_or_else(|| {
827 SurvivalError::CauseSpecificDimensionMismatch {
828 reason: format!(
829 "cause-specific survival expected block index < {}, got {block_idx}",
830 self.blocks.len()
831 ),
832 }
833 .to_string()
834 })?;
835 let state = block_states.get(block_idx).ok_or_else(|| {
836 SurvivalError::CauseSpecificDimensionMismatch {
837 reason: format!(
838 "cause-specific survival expected {} block states, got {}",
839 self.blocks.len(),
840 block_states.len()
841 ),
842 }
843 .to_string()
844 })?;
845 if delta.len() != state.beta.len() || block.x_derivative.ncols() != delta.len() {
846 return Err(SurvivalError::CauseSpecificDimensionMismatch {
847 reason: "cause-specific survival feasible-step dimension mismatch".to_string(),
848 }
849 .into());
850 }
851 let derivative = fast_av(&block.x_derivative, &state.beta) + &block.offset_derivative_exit;
852 let derivative_delta = fast_av(&block.x_derivative, delta);
853 let mut alpha_max = 1.0_f64;
854 for i in 0..derivative.len() {
855 if block.sampleweight[i] <= 0.0 {
856 continue;
857 }
858 let current = derivative[i] - block.derivative_floor;
859 let slope = derivative_delta[i];
860 if slope < 0.0 {
861 if current <= 0.0 {
862 return Ok(Some(0.0));
863 }
864 alpha_max = alpha_max.min(DERIVATIVE_FRACTION_TO_BOUNDARY * current / -slope);
865 }
866 }
867 Ok(Some(alpha_max.clamp(0.0, 1.0)))
868 }
869
870 fn exact_newton_hessian_directional_derivative(
871 &self,
872 block_states: &[ParameterBlockState],
873 block_idx: usize,
874 d_beta: &Array1<f64>,
875 ) -> Result<Option<Array2<f64>>, String> {
876 let block = self.blocks.get(block_idx).ok_or_else(|| {
877 SurvivalError::CauseSpecificDimensionMismatch {
878 reason: format!(
879 "cause-specific survival expected block index < {}, got {block_idx}",
880 self.blocks.len()
881 ),
882 }
883 .to_string()
884 })?;
885 let state = block_states.get(block_idx).ok_or_else(|| {
886 SurvivalError::CauseSpecificDimensionMismatch {
887 reason: format!(
888 "cause-specific survival expected {} block states, got {}",
889 self.blocks.len(),
890 block_states.len()
891 ),
892 }
893 .to_string()
894 })?;
895 Ok(Some(cause_specific_hessian_directional_derivative(
896 block,
897 &state.beta,
898 d_beta,
899 )?))
900 }
901
902 fn exact_newton_hessian_second_directional_derivative(
903 &self,
904 block_states: &[ParameterBlockState],
905 block_idx: usize,
906 d_beta_u: &Array1<f64>,
907 d_beta_v: &Array1<f64>,
908 ) -> Result<Option<Array2<f64>>, String> {
909 let block = self.blocks.get(block_idx).ok_or_else(|| {
910 SurvivalError::CauseSpecificDimensionMismatch {
911 reason: format!(
912 "cause-specific survival expected block index < {}, got {block_idx}",
913 self.blocks.len()
914 ),
915 }
916 .to_string()
917 })?;
918 let state = block_states.get(block_idx).ok_or_else(|| {
919 SurvivalError::CauseSpecificDimensionMismatch {
920 reason: format!(
921 "cause-specific survival expected {} block states, got {}",
922 self.blocks.len(),
923 block_states.len()
924 ),
925 }
926 .to_string()
927 })?;
928 Ok(Some(cause_specific_hessian_second_directional_derivative(
929 block,
930 &state.beta,
931 d_beta_u,
932 d_beta_v,
933 )?))
934 }
935}
936
937fn cause_specific_hessian_directional_derivative(
940 block: &CauseSpecificRoystonParmarBlock,
941 beta: &Array1<f64>,
942 d_beta: &Array1<f64>,
943) -> Result<Array2<f64>, SurvivalError> {
944 let p = block.x_exit.ncols();
945 if beta.len() != p || d_beta.len() != p {
946 return Err(SurvivalError::CauseSpecificDimensionMismatch {
947 reason: "cause-specific survival Hessian derivative dimension mismatch".to_string(),
948 });
949 }
950 let eta_entry = fast_av(&block.x_entry, beta) + &block.offset_eta_entry;
951 let eta_exit = fast_av(&block.x_exit, beta) + &block.offset_eta_exit;
952 let derivative = fast_av(&block.x_derivative, beta) + &block.offset_derivative_exit;
953 let d_eta_entry = fast_av(&block.x_entry, d_beta);
954 let d_eta_exit = fast_av(&block.x_exit, d_beta);
955 let d_derivative = fast_av(&block.x_derivative, d_beta);
956 let n = block.event_target.len();
957 let mut weights: [Array1<f64>; 6] = std::array::from_fn(|_| Array1::zeros(n));
958
959 for i in 0..n {
960 let Some(input) =
961 cause_specific_atom_input(block, i, eta_entry[i], eta_exit[i], derivative[i])?
962 else {
963 continue;
964 };
965 let direction = [
966 d_eta_exit[i],
967 d_eta_entry[i] * f64::from(input.entry_active),
968 d_derivative[i] * f64::from(input.event),
969 ];
970 let matrix = cause_specific_row_third_contracted(
971 input.primary[0],
972 input.primary[1],
973 input.primary[2],
974 input.weight,
975 input.entry_active,
976 input.event,
977 &direction,
978 );
979 for (slot, &(left, right)) in CAUSE_SPECIFIC_PRIMARY_PAIRS.iter().enumerate() {
980 weights[slot][i] = matrix[left][right];
981 }
982 }
983 Ok(cause_specific_pullback_hessian(block, &weights))
984}
985
986fn cause_specific_hessian_second_directional_derivative(
989 block: &CauseSpecificRoystonParmarBlock,
990 beta: &Array1<f64>,
991 d_beta_u: &Array1<f64>,
992 d_beta_v: &Array1<f64>,
993) -> Result<Array2<f64>, SurvivalError> {
994 let p = block.x_exit.ncols();
995 if beta.len() != p || d_beta_u.len() != p || d_beta_v.len() != p {
996 return Err(SurvivalError::CauseSpecificDimensionMismatch {
997 reason: "cause-specific survival second Hessian derivative dimension mismatch"
998 .to_string(),
999 });
1000 }
1001 let eta_entry = fast_av(&block.x_entry, beta) + &block.offset_eta_entry;
1002 let eta_exit = fast_av(&block.x_exit, beta) + &block.offset_eta_exit;
1003 let derivative = fast_av(&block.x_derivative, beta) + &block.offset_derivative_exit;
1004 let u_eta_entry = fast_av(&block.x_entry, d_beta_u);
1005 let u_eta_exit = fast_av(&block.x_exit, d_beta_u);
1006 let u_derivative = fast_av(&block.x_derivative, d_beta_u);
1007 let v_eta_entry = fast_av(&block.x_entry, d_beta_v);
1008 let v_eta_exit = fast_av(&block.x_exit, d_beta_v);
1009 let v_derivative = fast_av(&block.x_derivative, d_beta_v);
1010 let n = block.event_target.len();
1011 let mut weights: [Array1<f64>; 6] = std::array::from_fn(|_| Array1::zeros(n));
1012
1013 for i in 0..n {
1014 let Some(input) =
1015 cause_specific_atom_input(block, i, eta_entry[i], eta_exit[i], derivative[i])?
1016 else {
1017 continue;
1018 };
1019 let direction_u = [
1020 u_eta_exit[i],
1021 u_eta_entry[i] * f64::from(input.entry_active),
1022 u_derivative[i] * f64::from(input.event),
1023 ];
1024 let direction_v = [
1025 v_eta_exit[i],
1026 v_eta_entry[i] * f64::from(input.entry_active),
1027 v_derivative[i] * f64::from(input.event),
1028 ];
1029 let matrix = cause_specific_row_fourth_contracted(
1030 input.primary[0],
1031 input.primary[1],
1032 input.primary[2],
1033 input.weight,
1034 input.entry_active,
1035 input.event,
1036 &direction_u,
1037 &direction_v,
1038 );
1039 for (slot, &(left, right)) in CAUSE_SPECIFIC_PRIMARY_PAIRS.iter().enumerate() {
1040 weights[slot][i] = matrix[left][right];
1041 }
1042 }
1043 Ok(cause_specific_pullback_hessian(block, &weights))
1044}
1045
1046pub fn survival_event_code_from_value(value: f64, row_index: usize) -> Result<u8, String> {
1047 const INTEGER_TOL: f64 = 1e-8;
1048 const MAX_AUTO_CAUSES: u8 = 32;
1049 if !value.is_finite() {
1050 return Err(SurvivalError::EventCodeInvalid {
1051 reason: format!(
1052 "survival event value at row {} is non-finite",
1053 row_index + 1
1054 ),
1055 }
1056 .into());
1057 }
1058 if value < 0.0 {
1059 return Err(SurvivalError::EventCodeInvalid {
1060 reason: format!(
1061 "survival event value at row {} is negative: {value}",
1062 row_index + 1
1063 ),
1064 }
1065 .into());
1066 }
1067 let rounded = value.round();
1068 if (value - rounded).abs() > INTEGER_TOL {
1069 return Err(SurvivalError::EventCodeInvalid {
1070 reason: format!(
1071 "survival event value at row {} must be an integer code with 0=censored, got {value}",
1072 row_index + 1
1073 ),
1074 }
1075 .into());
1076 }
1077 if rounded > f64::from(MAX_AUTO_CAUSES) {
1078 return Err(SurvivalError::EventCodeInvalid {
1079 reason: format!(
1080 "survival event value at row {} has code {rounded}; automatic competing-risks detection supports codes 0..={MAX_AUTO_CAUSES}",
1081 row_index + 1
1082 ),
1083 }
1084 .into());
1085 }
1086 Ok(rounded as u8)
1087}
1088
1089pub fn cause_count_from_event_codes(
1090 event_codes: ArrayView1<'_, u8>,
1091) -> Result<usize, SurvivalError> {
1092 let max_code = event_codes.iter().copied().max().map_or(0, usize::from);
1093 if max_code == 0 {
1094 return Ok(1);
1095 }
1096
1097 let mut present = vec![false; max_code + 1];
1098 for code in event_codes.iter().copied() {
1099 present[usize::from(code)] = true;
1100 }
1101 if (1..=max_code).any(|code| !present[code]) {
1102 let actual = present
1103 .iter()
1104 .enumerate()
1105 .skip(1)
1106 .filter_map(|(code, &seen)| seen.then_some(code.to_string()))
1107 .collect::<Vec<_>>()
1108 .join(", ");
1109 return Err(SurvivalError::EventCodeInvalid {
1110 reason: format!(
1111 "survival competing-risks event codes must use contiguous positive codes; observed nonzero codes are {{{actual}}}. Remap event codes contiguously (for example, {{0,1,3}} -> {{0,1,2}}), otherwise a phantom cause is fit with no events and pollutes CIF assembly."
1112 ),
1113 });
1114 }
1115
1116 Ok(max_code)
1117}
1118
1119pub fn pooled_any_event_indicator(event_codes: ArrayView1<'_, u8>) -> Array1<u8> {
1132 event_codes.mapv(|label| u8::from(label > 0))
1133}
1134
1135pub fn cause_specific_event_indicator(event_codes: ArrayView1<'_, u8>, cause: usize) -> Array1<u8> {
1145 let cause_code = cause as u8;
1146 event_codes.mapv(|observed| u8::from(observed == cause_code))
1147}
1148
1149fn compress_positive_collinear_constraints(
1150 a: &Array2<f64>,
1151 b: &Array1<f64>,
1152) -> LinearInequalityConstraints {
1153 const SCALE_TOL: f64 = 1e-14;
1154 const KEY_TOL: f64 = 1e-8;
1155
1156 let mut grouped: BTreeMap<Vec<i64>, (Vec<f64>, f64)> = BTreeMap::new();
1157 let mut fallbackrows: Vec<(Vec<f64>, f64)> = Vec::new();
1158
1159 for i in 0..a.nrows() {
1160 let row = a.row(i);
1161 let scale = row.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1162 if !scale.is_finite() || scale <= SCALE_TOL {
1163 if b[i] > 0.0 {
1164 fallbackrows.push((row.to_vec(), b[i]));
1165 }
1166 continue;
1167 }
1168
1169 let normalizedrow: Vec<f64> = row
1170 .iter()
1171 .map(|&v| {
1172 let scaled = v / scale;
1173 if scaled.abs() <= KEY_TOL { 0.0 } else { scaled }
1174 })
1175 .collect();
1176 let normalized_rhs = b[i] / scale;
1177 let key: Vec<i64> = normalizedrow
1178 .iter()
1179 .map(|&v| (v / KEY_TOL).round() as i64)
1180 .collect();
1181
1182 match grouped.get_mut(&key) {
1183 Some((_, rhs_max)) => {
1184 if normalized_rhs > *rhs_max {
1185 *rhs_max = normalized_rhs;
1186 }
1187 }
1188 None => {
1189 grouped.insert(key, (normalizedrow, normalized_rhs));
1190 }
1191 }
1192 }
1193
1194 let nrows = grouped.len() + fallbackrows.len();
1195 let n_cols = a.ncols();
1196 let mut a_out = Array2::<f64>::zeros((nrows, n_cols));
1197 let mut b_out = Array1::<f64>::zeros(nrows);
1198
1199 let mut outrow = 0usize;
1200 for (_, (row, rhs)) in grouped {
1201 for (j, value) in row.into_iter().enumerate() {
1202 a_out[[outrow, j]] = value;
1203 }
1204 b_out[outrow] = rhs;
1205 outrow += 1;
1206 }
1207 for (row, rhs) in fallbackrows {
1208 for (j, value) in row.into_iter().enumerate() {
1209 a_out[[outrow, j]] = value;
1210 }
1211 b_out[outrow] = rhs;
1212 outrow += 1;
1213 }
1214
1215 LinearInequalityConstraints { a: a_out, b: b_out }
1216}
1217
1218#[derive(Debug, Clone, Copy, Default)]
1219pub struct SurvivalMonotonicityPenalty {
1220 pub tolerance: f64,
1221}
1222
1223#[derive(Debug, Clone)]
1224enum SurvivalDesign {
1225 Flat {
1226 x_entry: Array2<f64>,
1227 x_exit: Array2<f64>,
1228 x_derivative: Array2<f64>,
1229 },
1230 TimeCovariateShared {
1231 time_entry: Array2<f64>,
1232 time_exit: Array2<f64>,
1233 time_derivative: Array2<f64>,
1234 covariates: Array2<f64>,
1235 },
1236}
1237
1238impl SurvivalDesign {
1239 fn p_total(&self) -> usize {
1240 match self {
1241 Self::Flat { x_exit, .. } => x_exit.ncols(),
1242 Self::TimeCovariateShared {
1243 time_exit,
1244 covariates,
1245 ..
1246 } => time_exit.ncols() + covariates.ncols(),
1247 }
1248 }
1249
1250 fn design_dot(&self, time_mat: &Array2<f64>, beta: &Array1<f64>) -> Array1<f64> {
1251 match self {
1252 Self::Flat { .. } => time_mat.dot(beta),
1253 Self::TimeCovariateShared { covariates, .. } => {
1254 let p_time = time_mat.ncols();
1255 let mut out = time_mat.dot(&beta.slice(ndarray::s![..p_time]));
1256 if covariates.ncols() > 0 {
1257 out += &covariates.dot(&beta.slice(ndarray::s![p_time..]));
1258 }
1259 out
1260 }
1261 }
1262 }
1263
1264 fn fill_row(&self, time_mat: &Array2<f64>, i: usize, out: &mut [f64]) {
1265 match self {
1266 Self::Flat { .. } => {
1267 for (dst, &src) in out.iter_mut().zip(time_mat.row(i).iter()) {
1268 *dst = src;
1269 }
1270 }
1271 Self::TimeCovariateShared { covariates, .. } => {
1272 let p_time = time_mat.ncols();
1273 for j in 0..p_time {
1274 out[j] = time_mat[[i, j]];
1275 }
1276 for j in 0..covariates.ncols() {
1277 out[p_time + j] = covariates[[i, j]];
1278 }
1279 }
1280 }
1281 }
1282}
1283
1284#[derive(Debug, Clone)]
1286struct SurvivalWorkspace {
1287 w_event: Array1<f64>,
1288 w_event_inv_deriv: Array1<f64>,
1289 w_event_outer: Array1<f64>,
1290 w_hess_exit: Array1<f64>,
1291 w_hess_entry: Array1<f64>,
1292}
1293
1294impl SurvivalWorkspace {
1295 fn new(n: usize) -> Self {
1296 Self {
1297 w_event: Array1::zeros(n),
1298 w_event_inv_deriv: Array1::zeros(n),
1299 w_event_outer: Array1::zeros(n),
1300 w_hess_exit: Array1::zeros(n),
1301 w_hess_entry: Array1::zeros(n),
1302 }
1303 }
1304
1305 fn reset(&mut self, n: usize) {
1306 if self.w_event.len() != n {
1307 *self = Self::new(n);
1308 } else {
1309 self.w_event.fill(0.0);
1310 self.w_event_inv_deriv.fill(0.0);
1311 self.w_event_outer.fill(0.0);
1312 self.w_hess_exit.fill(0.0);
1313 self.w_hess_entry.fill(0.0);
1314 }
1315 }
1316}
1317
1318#[derive(Clone, Debug)]
1331pub struct OffsetChannelResiduals {
1332 pub exit: Array1<f64>,
1334 pub entry: Array1<f64>,
1336 pub derivative: Array1<f64>,
1338 pub right: Array1<f64>,
1342}
1343
1344#[derive(Clone, Debug)]
1347pub struct OffsetChannelCurvatures {
1348 pub rows: Vec<[[f64; 3]; 3]>,
1349}
1350
1351#[derive(Debug)]
1352pub struct WorkingModelSurvival {
1353 age_entry: Array1<f64>,
1354 age_exit: Array1<f64>,
1355 entry_at_origin: Array1<bool>,
1356 event_target: Array1<u8>,
1357 sampleweight: Array1<f64>,
1358 design: SurvivalDesign,
1359 offset_eta_entry: Array1<f64>,
1360 offset_eta_exit: Array1<f64>,
1361 offset_derivative_exit: Array1<f64>,
1362 penalties: PenaltyBlocks,
1363 monotonicity: SurvivalMonotonicityPenalty,
1364 structurally_monotonic: bool,
1365 structural_time_columns: usize,
1366 monotonicity_constraint_rows: Option<Array2<f64>>,
1367 monotonicity_constraint_offsets: Option<Array1<f64>>,
1368 workspace: std::sync::Mutex<SurvivalWorkspace>,
1369}
1370
1371impl Clone for WorkingModelSurvival {
1372 fn clone(&self) -> Self {
1373 let workspace = self
1374 .workspace
1375 .lock()
1376 .expect("survival workspace mutex was not poisoned by a panicking holder")
1377 .clone();
1378 Self {
1379 age_entry: self.age_entry.clone(),
1380 age_exit: self.age_exit.clone(),
1381 entry_at_origin: self.entry_at_origin.clone(),
1382 event_target: self.event_target.clone(),
1383 sampleweight: self.sampleweight.clone(),
1384 design: self.design.clone(),
1385 offset_eta_entry: self.offset_eta_entry.clone(),
1386 offset_eta_exit: self.offset_eta_exit.clone(),
1387 offset_derivative_exit: self.offset_derivative_exit.clone(),
1388 penalties: self.penalties.clone(),
1389 monotonicity: self.monotonicity,
1390 structurally_monotonic: self.structurally_monotonic,
1391 structural_time_columns: self.structural_time_columns,
1392 monotonicity_constraint_rows: self.monotonicity_constraint_rows.clone(),
1393 monotonicity_constraint_offsets: self.monotonicity_constraint_offsets.clone(),
1394 workspace: std::sync::Mutex::new(workspace),
1395 }
1396 }
1397}
1398
1399impl WorkingModelSurvival {
1400 const LOG_F64_MAX: f64 = 709.782712893384;
1401
1402 #[inline]
1403 fn scaled_exp_component(log_scale: f64, base: f64) -> Result<f64, EstimationError> {
1404 if base == 0.0 {
1405 return Ok(0.0);
1406 }
1407 let log_abs = log_scale + base.abs().ln();
1408 if !log_abs.is_finite() {
1409 crate::bail_invalid_estim!("survival interval term produced non-finite log-magnitude");
1410 }
1411 if log_abs > Self::LOG_F64_MAX {
1412 crate::bail_invalid_estim!(
1413 "survival interval term exceeds f64 range (log-magnitude={log_abs:.3e})"
1414 );
1415 }
1416 Ok(base.signum() * log_abs.exp())
1417 }
1418
1419 fn coefficient_dim(&self) -> usize {
1420 self.design.p_total()
1421 }
1422
1423 fn nrows(&self) -> usize {
1424 self.sampleweight.len()
1425 }
1426
1427 fn entry_dot(&self, beta: &Array1<f64>) -> Array1<f64> {
1428 let time_mat = match &self.design {
1429 SurvivalDesign::Flat { x_entry, .. } => x_entry,
1430 SurvivalDesign::TimeCovariateShared { time_entry, .. } => time_entry,
1431 };
1432 self.design.design_dot(time_mat, beta)
1433 }
1434
1435 fn exit_dot(&self, beta: &Array1<f64>) -> Array1<f64> {
1436 let time_mat = match &self.design {
1437 SurvivalDesign::Flat { x_exit, .. } => x_exit,
1438 SurvivalDesign::TimeCovariateShared { time_exit, .. } => time_exit,
1439 };
1440 self.design.design_dot(time_mat, beta)
1441 }
1442
1443 fn derivative_dot(&self, beta: &Array1<f64>) -> Array1<f64> {
1444 match &self.design {
1445 SurvivalDesign::Flat { x_derivative, .. } => x_derivative.dot(beta),
1446 SurvivalDesign::TimeCovariateShared {
1447 time_derivative, ..
1448 } => time_derivative.dot(&beta.slice(ndarray::s![..time_derivative.ncols()])),
1449 }
1450 }
1451
1452 fn fill_entry_row(&self, i: usize, out: &mut [f64]) {
1453 let time_mat = match &self.design {
1454 SurvivalDesign::Flat { x_entry, .. } => x_entry,
1455 SurvivalDesign::TimeCovariateShared { time_entry, .. } => time_entry,
1456 };
1457 self.design.fill_row(time_mat, i, out);
1458 }
1459
1460 fn fill_exit_row(&self, i: usize, out: &mut [f64]) {
1461 let time_mat = match &self.design {
1462 SurvivalDesign::Flat { x_exit, .. } => x_exit,
1463 SurvivalDesign::TimeCovariateShared { time_exit, .. } => time_exit,
1464 };
1465 self.design.fill_row(time_mat, i, out);
1466 }
1467
1468 fn fill_derivative_row(&self, i: usize, out: &mut [f64]) {
1469 match &self.design {
1470 SurvivalDesign::Flat { x_derivative, .. } => {
1471 for (dst, &src) in out.iter_mut().zip(x_derivative.row(i).iter()) {
1472 *dst = src;
1473 }
1474 }
1475 SurvivalDesign::TimeCovariateShared {
1476 time_derivative, ..
1477 } => {
1478 let p_time = time_derivative.ncols();
1479 for j in 0..p_time {
1480 out[j] = time_derivative[[i, j]];
1481 }
1482 for dst in out.iter_mut().skip(p_time) {
1483 *dst = 0.0;
1484 }
1485 }
1486 }
1487 }
1488
1489 fn derivative_xt_diag_x(&self, weights: &Array1<f64>) -> Array2<f64> {
1490 match &self.design {
1491 SurvivalDesign::Flat { x_derivative, .. } => fast_xt_diag_x(x_derivative, weights),
1492 SurvivalDesign::TimeCovariateShared {
1493 time_derivative,
1494 covariates,
1495 ..
1496 } => {
1497 let p_time = time_derivative.ncols();
1498 let p_cov = covariates.ncols();
1499 let mut out = Array2::<f64>::zeros((p_time + p_cov, p_time + p_cov));
1500 let time_block = fast_xt_diag_x(time_derivative, weights);
1501 out.slice_mut(ndarray::s![..p_time, ..p_time])
1502 .assign(&time_block);
1503 out
1504 }
1505 }
1506 }
1507
1508 fn interval_hessian_blas(&self, w_exit: &Array1<f64>, w_entry: &Array1<f64>) -> Array2<f64> {
1512 match &self.design {
1513 SurvivalDesign::Flat {
1514 x_entry, x_exit, ..
1515 } => {
1516 let mut h = fast_xt_diag_x(x_exit, w_exit);
1517 h -= &fast_xt_diag_x(x_entry, w_entry);
1518 h
1519 }
1520 SurvivalDesign::TimeCovariateShared {
1521 time_entry,
1522 time_exit,
1523 covariates,
1524 ..
1525 } => {
1526 let p_time = time_exit.ncols();
1527 let p_cov = covariates.ncols();
1528 let p = p_time + p_cov;
1529 let mut h = Array2::<f64>::zeros((p, p));
1530 let tt = {
1532 let mut block = fast_xt_diag_x(time_exit, w_exit);
1533 block -= &fast_xt_diag_x(time_entry, w_entry);
1534 block
1535 };
1536 h.slice_mut(ndarray::s![..p_time, ..p_time]).assign(&tt);
1537 if p_cov > 0 {
1538 let tc = {
1540 let mut block = fast_xt_diag_y(time_exit, w_exit, covariates);
1541 block -= &fast_xt_diag_y(time_entry, w_entry, covariates);
1542 block
1543 };
1544 h.slice_mut(ndarray::s![..p_time, p_time..]).assign(&tc);
1545 h.slice_mut(ndarray::s![p_time.., ..p_time]).assign(&tc.t());
1546 let w_diff = w_exit - w_entry;
1548 let cc = fast_xt_diag_x(covariates, &w_diff);
1549 h.slice_mut(ndarray::s![p_time.., p_time..]).assign(&cc);
1550 }
1551 h
1552 }
1553 }
1554 }
1555
1556 fn stabilized_structural_derivative(&self, deriv: f64) -> Option<(f64, f64)> {
1570 const STRUCTURAL_MONO_ROUNDOFF_TOL: f64 = 1e-7;
1571 const STRUCTURAL_DERIV_FLOOR: f64 = 1e-12;
1572 if !self.structurally_monotonic {
1573 return None;
1574 }
1575 if deriv >= STRUCTURAL_DERIV_FLOOR {
1576 return Some((deriv, 1.0));
1577 }
1578 if deriv >= -STRUCTURAL_MONO_ROUNDOFF_TOL {
1579 return Some((STRUCTURAL_DERIV_FLOOR, 0.0));
1580 }
1581 None
1582 }
1583
1584 fn validate_penalties(
1585 penalties: &PenaltyBlocks,
1586 coefficient_dim: usize,
1587 ) -> Result<(), SurvivalError> {
1588 for block in &penalties.blocks {
1589 if !block.lambda.is_finite() || block.lambda < 0.0 {
1590 return Err(SurvivalError::NonFiniteInput);
1591 }
1592 if block.range.start > block.range.end || block.range.end > coefficient_dim {
1593 return Err(SurvivalError::DimensionMismatch);
1594 }
1595 let block_dim = block.range.end - block.range.start;
1596 if block.matrix.nrows() != block_dim || block.matrix.ncols() != block_dim {
1597 return Err(SurvivalError::DimensionMismatch);
1598 }
1599 if block.matrix.iter().any(|v| !v.is_finite()) {
1600 return Err(SurvivalError::NonFiniteInput);
1601 }
1602 }
1603 Ok(())
1604 }
1605
1606 fn derivative_guard(&self) -> f64 {
1607 if self.structurally_monotonic {
1608 return 0.0;
1612 }
1613 self.monotonicity.tolerance.max(0.0)
1614 }
1615
1616 fn derivative_guard_numerical(&self) -> f64 {
1617 let derivative_guard = self.derivative_guard();
1618 if derivative_guard <= 0.0 {
1619 if self.structurally_monotonic {
1628 -1e-10
1629 } else {
1630 1e-12
1631 }
1632 } else {
1633 (derivative_guard - (1e-10_f64).min(0.01 * derivative_guard)).max(1e-12)
1634 }
1635 }
1636
1637 fn interval_increment_guard(&self, h_entry: f64, h_exit: f64) -> f64 {
1638 let scale = h_entry.abs().max(h_exit.abs()).max(1.0);
1639 1e-10 * scale
1640 }
1641
1642 fn structural_time_coefficient_constraints(&self) -> Option<LinearInequalityConstraints> {
1643 if !self.structurally_monotonic {
1644 return None;
1645 }
1646 let p = self.coefficient_dim();
1647 let time_columns = self.structural_time_columns.min(p);
1648 if time_columns == 0 {
1649 return None;
1650 }
1651 let mut a = Array2::<f64>::zeros((time_columns, p));
1672 let b = Array1::<f64>::zeros(time_columns);
1673 for j in 0..time_columns {
1674 a[[j, j]] = 1.0;
1675 }
1676 Some(LinearInequalityConstraints { a, b })
1677 }
1678
1679 pub fn monotonicity_linear_constraints(&self) -> Option<LinearInequalityConstraints> {
1680 let p = self.coefficient_dim();
1681 const DERIVATIVE_ROW_NORM_TOL: f64 = 1e-12;
1682 if p == 0 {
1683 return None;
1684 }
1685 if self.structurally_monotonic {
1686 return self.structural_time_coefficient_constraints();
1687 }
1688 if let (Some(rows), Some(offsets)) = (
1689 self.monotonicity_constraint_rows.as_ref(),
1690 self.monotonicity_constraint_offsets.as_ref(),
1691 ) {
1692 let activerows: Vec<usize> = (0..rows.nrows())
1693 .filter(|&i| {
1694 rows.row(i).iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
1695 > DERIVATIVE_ROW_NORM_TOL
1696 })
1697 .collect();
1698 if activerows.is_empty() {
1699 return None;
1700 }
1701 let mut a = Array2::<f64>::zeros((activerows.len(), p));
1702 let mut b = Array1::<f64>::zeros(activerows.len());
1703 for (r, &i) in activerows.iter().enumerate() {
1704 a.row_mut(r).assign(&rows.row(i));
1705 b[r] = self.derivative_guard() - offsets[i];
1706 }
1707 return Some(compress_positive_collinear_constraints(&a, &b));
1708 }
1709 None
1710 }
1711
1712 pub fn from_engine_inputs(
1713 inputs: SurvivalEngineInputs<'_>,
1714 penalties: PenaltyBlocks,
1715 monotonicity: SurvivalMonotonicityPenalty,
1716 spec: SurvivalSpec,
1717 ) -> Result<Self, SurvivalError> {
1718 Self::from_engine_inputswith_offsets(inputs, None, penalties, monotonicity, spec)
1719 }
1720
1721 fn validate_offsets(
1722 offsets: Option<SurvivalBaselineOffsets<'_>>,
1723 n: usize,
1724 ) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), SurvivalError> {
1725 if let Some(off) = offsets {
1726 if off.eta_entry.len() != n || off.eta_exit.len() != n || off.derivative_exit.len() != n
1727 {
1728 return Err(SurvivalError::DimensionMismatch);
1729 }
1730 if off.eta_entry.iter().any(|v| !v.is_finite())
1731 || off.eta_exit.iter().any(|v| !v.is_finite())
1732 || off.derivative_exit.iter().any(|v| !v.is_finite())
1733 {
1734 return Err(SurvivalError::NonFiniteInput);
1735 }
1736 Ok((
1737 off.eta_entry.to_owned(),
1738 off.eta_exit.to_owned(),
1739 off.derivative_exit.to_owned(),
1740 ))
1741 } else {
1742 Ok((Array1::zeros(n), Array1::zeros(n), Array1::zeros(n)))
1743 }
1744 }
1745
1746 fn validate_common_inputs(
1747 age_entry: &ArrayView1<f64>,
1748 age_exit: &ArrayView1<f64>,
1749 event_target: &ArrayView1<u8>,
1750 event_competing: &ArrayView1<u8>,
1751 sampleweight: &ArrayView1<f64>,
1752 ) -> Result<(), SurvivalError> {
1753 if age_entry.iter().any(|v| !v.is_finite())
1754 || age_exit.iter().any(|v| !v.is_finite())
1755 || sampleweight.iter().any(|v| !v.is_finite() || *v < 0.0)
1756 {
1757 return Err(SurvivalError::NonFiniteInput);
1758 }
1759 if let Some(&label) = event_target.iter().find(|&&v| v > 1) {
1766 return Err(SurvivalError::EventCodeInvalid {
1767 reason: format!(
1768 "single-hazard survival engine requires a binary {{0, 1}} event_target, got multi-cause label {label}; competing-risks codes must be projected via pooled_any_event_indicator / cause_specific_event_indicator before construction"
1769 ),
1770 });
1771 }
1772 if let Some(&label) = event_competing.iter().find(|&&v| v > 1) {
1773 return Err(SurvivalError::EventCodeInvalid {
1774 reason: format!(
1775 "single-hazard survival engine requires a binary {{0, 1}} event_competing, got multi-cause label {label}"
1776 ),
1777 });
1778 }
1779 if event_target
1780 .iter()
1781 .zip(event_competing.iter())
1782 .any(|(&target, &competing)| target > 0 && competing > 0)
1783 {
1784 return Err(SurvivalError::EventCodeInvalid {
1785 reason: "a row cannot be simultaneously a target event and a competing event"
1786 .to_string(),
1787 });
1788 }
1789 if age_entry
1803 .iter()
1804 .zip(age_exit.iter())
1805 .any(|(&entry, &exit)| entry < 0.0 || exit <= 0.0)
1806 {
1807 return Err(SurvivalError::NonFiniteInput);
1808 }
1809 Ok::<(), _>(())
1810 }
1811
1812 fn validate_monotonicity_constraints(
1813 rows: Option<ArrayView2<'_, f64>>,
1814 offsets: Option<ArrayView1<'_, f64>>,
1815 coefficient_dim: usize,
1816 ) -> Result<(Option<Array2<f64>>, Option<Array1<f64>>), SurvivalError> {
1817 match (rows, offsets) {
1818 (None, None) => Ok((None, None)),
1819 (Some(rows), Some(offsets)) => {
1820 if rows.ncols() != coefficient_dim
1821 || rows.nrows() != offsets.len()
1822 || rows.iter().any(|v| !v.is_finite())
1823 || offsets.iter().any(|v| !v.is_finite())
1824 {
1825 return Err(SurvivalError::DimensionMismatch);
1826 }
1827 Ok((Some(rows.to_owned()), Some(offsets.to_owned())))
1828 }
1829 _ => Err(SurvivalError::DimensionMismatch),
1830 }
1831 }
1832
1833 fn finish_construction(
1834 age_entry: ArrayView1<f64>,
1835 age_exit: ArrayView1<f64>,
1836 event_target: ArrayView1<u8>,
1837 sampleweight: ArrayView1<f64>,
1838 design: SurvivalDesign,
1839 offset_eta_entry: Array1<f64>,
1840 offset_eta_exit: Array1<f64>,
1841 offset_derivative_exit: Array1<f64>,
1842 penalties: PenaltyBlocks,
1843 monotonicity: SurvivalMonotonicityPenalty,
1844 monotonicity_constraint_rows: Option<Array2<f64>>,
1845 monotonicity_constraint_offsets: Option<Array1<f64>>,
1846 ) -> Self {
1847 let n = age_entry.len();
1848 Self {
1849 age_entry: age_entry.to_owned(),
1850 age_exit: age_exit.to_owned(),
1851 entry_at_origin: age_entry.mapv(|t| t <= ENTRY_AT_ORIGIN_THRESHOLD),
1852 event_target: event_target.to_owned(),
1853 sampleweight: sampleweight.to_owned(),
1854 design,
1855 offset_eta_entry,
1856 offset_eta_exit,
1857 offset_derivative_exit,
1858 penalties,
1859 monotonicity,
1860 structurally_monotonic: false,
1861 structural_time_columns: 0,
1862 monotonicity_constraint_rows,
1863 monotonicity_constraint_offsets,
1864 workspace: std::sync::Mutex::new(SurvivalWorkspace::new(n)),
1865 }
1866 }
1867
1868 pub fn from_engine_inputswith_offsets(
1869 inputs: SurvivalEngineInputs<'_>,
1870 offsets: Option<SurvivalBaselineOffsets<'_>>,
1871 penalties: PenaltyBlocks,
1872 monotonicity: SurvivalMonotonicityPenalty,
1873 spec: SurvivalSpec,
1874 ) -> Result<Self, SurvivalError> {
1875 if spec == SurvivalSpec::Crude {
1876 return Err(SurvivalError::UnsupportedSpec("crude"));
1877 }
1878 let n = inputs.age_entry.len();
1879 let p = inputs.x_entry.ncols();
1880 if inputs.age_exit.len() != n
1881 || inputs.event_target.len() != n
1882 || inputs.event_competing.len() != n
1883 || inputs.sampleweight.len() != n
1884 || inputs.x_entry.nrows() != n
1885 || inputs.x_exit.nrows() != n
1886 || inputs.x_derivative.nrows() != n
1887 || inputs.x_entry.ncols() != inputs.x_exit.ncols()
1888 || inputs.x_entry.ncols() != inputs.x_derivative.ncols()
1889 {
1890 return Err(SurvivalError::DimensionMismatch);
1891 }
1892 Self::validate_penalties(&penalties, p)?;
1893 Self::validate_common_inputs(
1894 &inputs.age_entry,
1895 &inputs.age_exit,
1896 &inputs.event_target,
1897 &inputs.event_competing,
1898 &inputs.sampleweight,
1899 )?;
1900 if inputs.x_entry.iter().any(|v| !v.is_finite())
1901 || inputs.x_exit.iter().any(|v| !v.is_finite())
1902 || inputs.x_derivative.iter().any(|v| !v.is_finite())
1903 {
1904 return Err(SurvivalError::NonFiniteInput);
1905 }
1906 let (offset_eta_entry, offset_eta_exit, offset_derivative_exit) =
1907 Self::validate_offsets(offsets, n)?;
1908 let (monotonicity_constraint_rows, monotonicity_constraint_offsets) =
1909 Self::validate_monotonicity_constraints(
1910 inputs.monotonicity_constraint_rows,
1911 inputs.monotonicity_constraint_offsets,
1912 p,
1913 )?;
1914
1915 Ok(Self::finish_construction(
1916 inputs.age_entry,
1917 inputs.age_exit,
1918 inputs.event_target,
1919 inputs.sampleweight,
1920 SurvivalDesign::Flat {
1921 x_entry: inputs.x_entry.to_owned(),
1922 x_exit: inputs.x_exit.to_owned(),
1923 x_derivative: inputs.x_derivative.to_owned(),
1924 },
1925 offset_eta_entry,
1926 offset_eta_exit,
1927 offset_derivative_exit,
1928 penalties,
1929 monotonicity,
1930 monotonicity_constraint_rows,
1931 monotonicity_constraint_offsets,
1932 ))
1933 }
1934
1935 pub fn from_time_covariate_inputswith_offsets(
1936 inputs: SurvivalTimeCovarInputs<'_>,
1937 offsets: Option<SurvivalBaselineOffsets<'_>>,
1938 penalties: PenaltyBlocks,
1939 monotonicity: SurvivalMonotonicityPenalty,
1940 spec: SurvivalSpec,
1941 ) -> Result<Self, SurvivalError> {
1942 if spec == SurvivalSpec::Crude {
1943 return Err(SurvivalError::UnsupportedSpec("crude"));
1944 }
1945 let n = inputs.age_entry.len();
1946 let p_time = inputs.time_entry.ncols();
1947 let p_cov = inputs.covariates.ncols();
1948 let p = p_time + p_cov;
1949 if inputs.age_exit.len() != n
1950 || inputs.event_target.len() != n
1951 || inputs.event_competing.len() != n
1952 || inputs.sampleweight.len() != n
1953 || inputs.time_entry.nrows() != n
1954 || inputs.time_exit.nrows() != n
1955 || inputs.time_derivative.nrows() != n
1956 || inputs.covariates.nrows() != n
1957 || inputs.time_entry.ncols() != inputs.time_exit.ncols()
1958 || inputs.time_entry.ncols() != inputs.time_derivative.ncols()
1959 {
1960 return Err(SurvivalError::DimensionMismatch);
1961 }
1962 Self::validate_penalties(&penalties, p)?;
1963 Self::validate_common_inputs(
1964 &inputs.age_entry,
1965 &inputs.age_exit,
1966 &inputs.event_target,
1967 &inputs.event_competing,
1968 &inputs.sampleweight,
1969 )?;
1970 if inputs.time_entry.iter().any(|v| !v.is_finite())
1971 || inputs.time_exit.iter().any(|v| !v.is_finite())
1972 || inputs.time_derivative.iter().any(|v| !v.is_finite())
1973 || inputs.covariates.iter().any(|v| !v.is_finite())
1974 {
1975 return Err(SurvivalError::NonFiniteInput);
1976 }
1977 let (offset_eta_entry, offset_eta_exit, offset_derivative_exit) =
1978 Self::validate_offsets(offsets, n)?;
1979 let (monotonicity_constraint_rows, monotonicity_constraint_offsets) =
1980 Self::validate_monotonicity_constraints(
1981 inputs.monotonicity_constraint_rows,
1982 inputs.monotonicity_constraint_offsets,
1983 p,
1984 )?;
1985
1986 Ok(Self::finish_construction(
1987 inputs.age_entry,
1988 inputs.age_exit,
1989 inputs.event_target,
1990 inputs.sampleweight,
1991 SurvivalDesign::TimeCovariateShared {
1992 time_entry: inputs.time_entry.to_owned(),
1993 time_exit: inputs.time_exit.to_owned(),
1994 time_derivative: inputs.time_derivative.to_owned(),
1995 covariates: inputs.covariates.to_owned(),
1996 },
1997 offset_eta_entry,
1998 offset_eta_exit,
1999 offset_derivative_exit,
2000 penalties,
2001 monotonicity,
2002 monotonicity_constraint_rows,
2003 monotonicity_constraint_offsets,
2004 ))
2005 }
2006
2007 pub fn set_penalty_lambdas(&mut self, lambdas: &[f64]) -> Result<(), EstimationError> {
2021 if lambdas.len() != self.penalties.blocks.len() {
2022 crate::bail_invalid_estim!(
2023 "set_penalty_lambdas expects {} lambdas, got {}",
2024 self.penalties.blocks.len(),
2025 lambdas.len()
2026 );
2027 }
2028 for (block, &lambda) in self.penalties.blocks.iter_mut().zip(lambdas.iter()) {
2029 if !lambda.is_finite() || lambda < 0.0 {
2030 crate::bail_invalid_estim!("penalty lambda must be finite and >= 0, got {lambda}");
2031 }
2032 block.lambda = lambda;
2033 }
2034 Ok(())
2035 }
2036
2037 pub fn set_structural_monotonicity(
2038 &mut self,
2039 enabled: bool,
2040 time_columns: usize,
2041 ) -> Result<(), EstimationError> {
2042 let p = self.coefficient_dim();
2043 if time_columns > p {
2044 crate::bail_invalid_estim!(
2045 "structural time columns {} exceed coefficient dimension {}",
2046 time_columns,
2047 p
2048 );
2049 }
2050 if enabled && time_columns == 0 {
2051 crate::bail_invalid_estim!("structural monotonicity requires at least one time column");
2052 }
2053 if enabled {
2054 const STRUCTURAL_DERIV_TOL: f64 = 1e-12;
2055 for (i, &offset) in self.offset_derivative_exit.iter().enumerate() {
2056 if offset < -STRUCTURAL_DERIV_TOL {
2057 crate::bail_invalid_estim!(
2058 "structural monotonicity requires nonnegative derivative offsets; found offset_derivative_exit[{i}]={offset:.3e}"
2059 );
2060 }
2061 }
2062 let mut derivative_row = vec![0.0_f64; p];
2063 for i in 0..self.nrows() {
2064 self.fill_derivative_row(i, &mut derivative_row);
2065 for j in 0..time_columns {
2066 let v = derivative_row[j];
2067 if v < -STRUCTURAL_DERIV_TOL {
2068 crate::bail_invalid_estim!(
2069 "structural monotonicity requires nonnegative time-derivative basis entries; found x_derivative[{i},{j}]={v:.3e}"
2070 );
2071 }
2072 }
2073 for j in time_columns..p {
2074 let v = derivative_row[j];
2075 if v.abs() > STRUCTURAL_DERIV_TOL {
2076 crate::bail_invalid_estim!(
2077 "structural monotonicity requires zero derivative contribution outside the time block; found x_derivative[{i},{j}]={v:.3e}"
2078 );
2079 }
2080 }
2081 }
2082 if let (Some(rows), Some(offsets)) = (
2083 self.monotonicity_constraint_rows.as_ref(),
2084 self.monotonicity_constraint_offsets.as_ref(),
2085 ) {
2086 for (i, &offset) in offsets.iter().enumerate() {
2087 if offset < -STRUCTURAL_DERIV_TOL {
2088 crate::bail_invalid_estim!(
2089 "structural monotonicity requires nonnegative collocation derivative offsets; found monotonicity_constraint_offsets[{i}]={offset:.3e}"
2090 );
2091 }
2092 }
2093 for i in 0..rows.nrows() {
2094 for j in 0..time_columns {
2095 let v = rows[[i, j]];
2096 if v < -STRUCTURAL_DERIV_TOL {
2097 crate::bail_invalid_estim!(
2098 "structural monotonicity requires nonnegative collocation derivative basis entries; found monotonicity_constraint_rows[{i},{j}]={v:.3e}"
2099 );
2100 }
2101 }
2102 for j in time_columns..p {
2103 let v = rows[[i, j]];
2104 if v.abs() > STRUCTURAL_DERIV_TOL {
2105 crate::bail_invalid_estim!(
2106 "structural monotonicity requires zero collocation derivative contribution outside the time block; found monotonicity_constraint_rows[{i},{j}]={v:.3e}"
2107 );
2108 }
2109 }
2110 }
2111 }
2112 }
2113 self.structurally_monotonic = enabled;
2114 self.structural_time_columns = if enabled { time_columns } else { 0 };
2115 Ok(())
2116 }
2117
2118 pub fn update_state(&self, beta: &Array1<f64>) -> Result<WorkingState, EstimationError> {
2119 if beta.len() != self.coefficient_dim() {
2120 crate::bail_invalid_estim!("survival beta dimension mismatch");
2121 }
2122
2123 let n = self.nrows();
2124 let p = self.coefficient_dim();
2125
2126 let eta_entry = self.entry_dot(beta) + &self.offset_eta_entry;
2152 let eta_exit = self.exit_dot(beta) + &self.offset_eta_exit;
2153 let derivative_raw = self.derivative_dot(beta) + &self.offset_derivative_exit;
2154
2155 let mut nll = 0.0;
2156 let derivative_guard = self.derivative_guard();
2157 let derivative_guard_numerical = self.derivative_guard_numerical();
2158 let mut workspace = self
2159 .workspace
2160 .lock()
2161 .expect("survival workspace mutex was not poisoned by a panicking holder");
2162 workspace.reset(n);
2163 let SurvivalWorkspace {
2164 w_event,
2165 w_event_inv_deriv,
2166 w_event_outer,
2167 w_hess_exit,
2168 w_hess_entry,
2169 } = &mut *workspace;
2170
2171 for i in 0..n {
2173 let w = self.sampleweight[i];
2174 if w <= 0.0 {
2175 continue;
2176 }
2177 let entry_age = self.age_entry[i];
2178 let exit_age = self.age_exit[i];
2179 if !entry_age.is_finite() || !exit_age.is_finite() || exit_age < entry_age {
2180 crate::bail_invalid_estim!(
2181 "survival ages must be finite with age_exit >= age_entry"
2182 );
2183 }
2184 let d = f64::from(self.event_target[i]);
2185
2186 let has_entry_interval = !self.entry_at_origin[i];
2187 let interval_scale = if has_entry_interval {
2188 eta_exit[i].max(eta_entry[i])
2189 } else {
2190 eta_exit[i]
2191 };
2192 let h_e_scaled = (eta_exit[i] - interval_scale).exp();
2193 let h_s_scaled = if has_entry_interval {
2194 (eta_entry[i] - interval_scale).exp()
2195 } else {
2196 0.0
2197 };
2198 let interval_scaled = h_e_scaled - h_s_scaled;
2199 let interval = Self::scaled_exp_component(interval_scale, interval_scaled)?;
2200 let (deriv, deriv_slope) = self
2201 .stabilized_structural_derivative(derivative_raw[i])
2202 .unwrap_or((derivative_raw[i], 1.0));
2203 let mono_floor = if d > 0.0 {
2212 derivative_guard_numerical
2213 } else {
2214 0.0
2215 };
2216 if !deriv.is_finite() || deriv < mono_floor {
2217 return Err(EstimationError::ParameterConstraintViolation(format!(
2218 "survival monotonicity violated at row {}: d_eta/dt={:.3e} <= tolerance={:.3e}",
2219 i, deriv, derivative_guard
2220 )));
2221 }
2222 if has_entry_interval {
2223 let increment_guard = self.interval_increment_guard(h_s_scaled, h_e_scaled);
2224 if interval_scaled + increment_guard < 0.0 {
2225 return Err(EstimationError::ParameterConstraintViolation(format!(
2226 "survival cumulative hazard decreased over row {}: H(exit)-H(entry)={:.6e}",
2227 i, interval
2228 )));
2229 }
2230 }
2231 nll += w * interval;
2232
2233 let w_exit_i = w * eta_exit[i].exp();
2237 let w_entry_i = if has_entry_interval {
2238 w * eta_entry[i].exp()
2239 } else {
2240 0.0
2241 };
2242 if !w_exit_i.is_finite() {
2243 crate::bail_invalid_estim!(
2244 "survival interval term exceeds f64 range at row {i} (w*exp(eta_exit)={w_exit_i:.3e})"
2245 );
2246 }
2247 w_hess_exit[i] = w_exit_i;
2248 w_hess_entry[i] = w_entry_i;
2249
2250 if d > 0.0 {
2251 let inv_deriv = deriv_slope / deriv;
2255 nll += -w * (eta_exit[i] + deriv.ln());
2256 w_event[i] = w;
2257 w_event_inv_deriv[i] = w * inv_deriv;
2258 w_event_outer[i] = w * inv_deriv * inv_deriv;
2259 }
2260 }
2261
2262 let mut h = self.interval_hessian_blas(w_hess_exit, w_hess_entry);
2266 let mut grad = Array1::<f64>::zeros(p);
2270 let mut grad_comp = Array1::<f64>::zeros(p);
2271 let mut row_exit = vec![0.0_f64; p];
2272 let mut row_entry = vec![0.0_f64; p];
2273 let mut row_derivative = vec![0.0_f64; p];
2274 for i in 0..n {
2275 let w_interval_exit = w_hess_exit[i];
2276 let w_interval_entry = w_hess_entry[i];
2277 let w_event_exit = w_event[i];
2278 let w_event_derivative = w_event_inv_deriv[i];
2279 if w_interval_exit == 0.0
2280 && w_interval_entry == 0.0
2281 && w_event_exit == 0.0
2282 && w_event_derivative == 0.0
2283 {
2284 continue;
2285 }
2286 self.fill_exit_row(i, &mut row_exit);
2287 self.fill_entry_row(i, &mut row_entry);
2288 self.fill_derivative_row(i, &mut row_derivative);
2289 for j in 0..p {
2290 let contribution = w_interval_exit * row_exit[j]
2291 - w_interval_entry * row_entry[j]
2292 - w_event_exit * row_exit[j]
2293 - w_event_derivative * row_derivative[j];
2294 let t = grad[j] + contribution;
2295 if grad[j].abs() >= contribution.abs() {
2296 grad_comp[j] += (grad[j] - t) + contribution;
2297 } else {
2298 grad_comp[j] += (contribution - t) + grad[j];
2299 }
2300 grad[j] = t;
2301 }
2302 }
2303 grad += &grad_comp;
2304
2305 h += &self.derivative_xt_diag_x(w_event_outer);
2306
2307 let score_norm = array1_l2_norm(&grad);
2311
2312 let penaltygrad = self.penalties.gradient(beta);
2313 let penalty_quadratic_form = 2.0 * self.penalties.deviance(beta);
2324 let penaltygrad_norm = array1_l2_norm(&penaltygrad);
2325
2326 let mut totalgrad = grad;
2327 totalgrad += &penaltygrad;
2328
2329 self.penalties.addhessian_inplace(&mut h);
2330 let log_likelihood = -nll;
2337 let deviance = 2.0 * nll;
2338
2339 Ok(WorkingState {
2340 eta: LinearPredictor::new(eta_exit),
2341 gradient: totalgrad,
2342 hessian: gam_linalg::matrix::SymmetricMatrix::Dense(h),
2343 log_likelihood,
2344 deviance,
2345 penalty_term: penalty_quadratic_form,
2346 firth: gam_solve::pirls::FirthDiagnostics::Inactive,
2347 ridge_used: 0.0,
2348 hessian_curvature: gam_solve::pirls::HessianCurvatureKind::Observed,
2349 gradient_natural_scale: score_norm + penaltygrad_norm,
2350 })
2351 }
2352
2353 pub(crate) fn survival_hessian_derivative_correction(
2363 &self,
2364 beta: &Array1<f64>,
2365 u_k: &Array1<f64>,
2366 ) -> Result<Array2<f64>, EstimationError> {
2367 let p = beta.len();
2368 let n = self.nrows();
2369
2370 let eta_entry = self.entry_dot(beta) + &self.offset_eta_entry;
2371 let eta_exit = self.exit_dot(beta) + &self.offset_eta_exit;
2372 let deriv_raw = self.derivative_dot(beta) + &self.offset_derivative_exit;
2373 let exp_entry = eta_entry.mapv(f64::exp);
2374 let exp_exit = eta_exit.mapv(f64::exp);
2375 let guard = self.derivative_guard();
2376 let guard_numerical = self.derivative_guard_numerical();
2377
2378 let jac = Array1::<f64>::ones(p);
2379 let curvature = Array1::<f64>::zeros(p);
2380 let third = Array1::<f64>::zeros(p);
2381
2382 let mut row_exit = vec![0.0_f64; p];
2383 let mut row_entry = vec![0.0_f64; p];
2384 let mut row_derivative = vec![0.0_f64; p];
2385 let mut ge = vec![0.0_f64; p];
2386 let mut gs = vec![0.0_f64; p];
2387 let mut gsd = vec![0.0_f64; p];
2388 let mut he = vec![0.0_f64; p];
2389 let mut hs = vec![0.0_f64; p];
2390 let mut hsd = vec![0.0_f64; p];
2391 let mut te = vec![0.0_f64; p];
2392 let mut ts = vec![0.0_f64; p];
2393 let mut tsd = vec![0.0_f64; p];
2394
2395 let mut b_dir = Array2::<f64>::zeros((p, p));
2396
2397 for i in 0..n {
2398 let w_i = self.sampleweight[i];
2399 if w_i <= 0.0 {
2400 continue;
2401 }
2402 let has_entry = !self.entry_at_origin[i];
2403 let mut deta_e = 0.0_f64;
2404 let mut deta_s = 0.0_f64;
2405 let mut ds = 0.0_f64;
2406 self.fill_exit_row(i, &mut row_exit);
2407 self.fill_entry_row(i, &mut row_entry);
2408 self.fill_derivative_row(i, &mut row_derivative);
2409 for j in 0..p {
2410 ge[j] = row_exit[j] * jac[j];
2411 gs[j] = row_entry[j] * jac[j];
2412 gsd[j] = row_derivative[j] * jac[j];
2413 he[j] = row_exit[j] * curvature[j];
2414 hs[j] = row_entry[j] * curvature[j];
2415 hsd[j] = row_derivative[j] * curvature[j];
2416 te[j] = row_exit[j] * third[j];
2417 ts[j] = row_entry[j] * third[j];
2418 tsd[j] = row_derivative[j] * third[j];
2419 deta_e += ge[j] * u_k[j];
2420 if has_entry {
2421 deta_s += gs[j] * u_k[j];
2422 }
2423 ds += gsd[j] * u_k[j];
2424 }
2425
2426 for r in 0..p {
2428 let dge_r = he[r] * u_k[r];
2429 let dgs_r = hs[r] * u_k[r];
2430 let dhe_r = te[r] * u_k[r];
2431 let dhs_r = ts[r] * u_k[r];
2432 for c in 0..p {
2433 let dge_c = he[c] * u_k[c];
2434 let dgs_c = hs[c] * u_k[c];
2435 let mut d_h_rc =
2436 exp_exit[i] * (deta_e * ge[r] * ge[c] + dge_r * ge[c] + ge[r] * dge_c);
2437 if r == c {
2438 d_h_rc += exp_exit[i] * (deta_e * he[r] + dhe_r);
2439 }
2440 if has_entry {
2441 d_h_rc -=
2442 exp_entry[i] * (deta_s * gs[r] * gs[c] + dgs_r * gs[c] + gs[r] * dgs_c);
2443 if r == c {
2444 d_h_rc -= exp_entry[i] * (deta_s * hs[r] + dhs_r);
2445 }
2446 }
2447 b_dir[[r, c]] += w_i * d_h_rc;
2448 }
2449 }
2450
2451 let (s_i, s_slope) = self
2453 .stabilized_structural_derivative(deriv_raw[i])
2454 .unwrap_or((deriv_raw[i], 1.0));
2455 if !s_i.is_finite() {
2456 return Err(EstimationError::ParameterConstraintViolation(format!(
2457 "survival monotonicity violated in unified trace contraction at row {i}: \
2458 d_eta/dt={s_i:.3e} <= tolerance={guard:.3e}",
2459 )));
2460 }
2461 if self.event_target[i] > 0 && s_slope != 0.0 {
2462 if s_i < guard_numerical {
2467 return Err(EstimationError::ParameterConstraintViolation(format!(
2468 "survival monotonicity violated in unified trace contraction at row {i}: \
2469 d_eta/dt={s_i:.3e} <= tolerance={guard:.3e}",
2470 )));
2471 }
2472 let inv_s = 1.0 / s_i;
2473 let inv_s2 = inv_s * inv_s;
2474 let inv_s3 = inv_s2 * inv_s;
2475 for r in 0..p {
2476 let dgd_r = hsd[r] * u_k[r];
2477 let dtsd_r = tsd[r] * u_k[r];
2478 let dte_r = te[r] * u_k[r];
2479 for c in 0..p {
2480 let dgd_c = hsd[c] * u_k[c];
2481 let mut d_h_rc = (dgd_r * gsd[c] + gsd[r] * dgd_c) * inv_s2
2482 - 2.0 * gsd[r] * gsd[c] * ds * inv_s3;
2483 if r == c {
2484 d_h_rc += -dte_r;
2485 d_h_rc += -(dtsd_r * inv_s - hsd[r] * ds * inv_s2);
2486 }
2487 b_dir[[r, c]] += w_i * d_h_rc;
2488 }
2489 }
2490 }
2491 }
2492
2493 Ok(b_dir)
2494 }
2495
2496 pub fn offset_channel_residuals(
2534 &self,
2535 beta: &Array1<f64>,
2536 ) -> Result<OffsetChannelResiduals, EstimationError> {
2537 if beta.len() != self.coefficient_dim() {
2538 crate::bail_invalid_estim!(
2539 "survival beta dimension mismatch in offset_channel_residuals"
2540 );
2541 }
2542 let n = self.nrows();
2543 let eta_entry = self.entry_dot(beta) + &self.offset_eta_entry;
2544 let eta_exit = self.exit_dot(beta) + &self.offset_eta_exit;
2545 let derivative_raw = self.derivative_dot(beta) + &self.offset_derivative_exit;
2546
2547 let derivative_guard_numerical = self.derivative_guard_numerical();
2548 let mut r_exit = Array1::<f64>::zeros(n);
2549 let mut r_entry = Array1::<f64>::zeros(n);
2550 let mut r_deriv = Array1::<f64>::zeros(n);
2551
2552 for i in 0..n {
2553 let w = self.sampleweight[i];
2554 if w <= 0.0 {
2555 continue;
2556 }
2557 let entry_age = self.age_entry[i];
2558 let exit_age = self.age_exit[i];
2559 if !entry_age.is_finite() || !exit_age.is_finite() || exit_age < entry_age {
2560 crate::bail_invalid_estim!(
2561 "survival ages must be finite with age_exit >= age_entry"
2562 );
2563 }
2564 let has_entry_interval = !self.entry_at_origin[i];
2565 let d = f64::from(self.event_target[i]);
2566 let w_exit_i = w * eta_exit[i].exp();
2570 let w_entry_i = if has_entry_interval {
2571 w * eta_entry[i].exp()
2572 } else {
2573 0.0
2574 };
2575 if !w_exit_i.is_finite() {
2576 crate::bail_invalid_estim!(
2577 "offset_channel_residuals: w*exp(eta_exit)={w_exit_i:.3e} non-finite at row {i}"
2578 );
2579 }
2580 r_exit[i] = w_exit_i - d * w;
2581 r_entry[i] = -w_entry_i;
2582 let deriv_raw = derivative_raw[i];
2587 let (deriv, deriv_slope) = self
2588 .stabilized_structural_derivative(deriv_raw)
2589 .unwrap_or((deriv_raw, 1.0));
2590 let mono_floor = if d > 0.0 {
2591 derivative_guard_numerical
2592 } else {
2593 0.0
2594 };
2595 if !deriv.is_finite() || deriv < mono_floor {
2596 return Err(EstimationError::ParameterConstraintViolation(format!(
2597 "offset_channel_residuals: derivative ≤ numerical guard at row {i}: {deriv:.3e}"
2598 )));
2599 }
2600 if d > 0.0 {
2601 r_deriv[i] = -w * d * deriv_slope / deriv;
2604 }
2605 }
2606
2607 let right = Array1::<f64>::zeros(r_exit.len());
2608 Ok(OffsetChannelResiduals {
2609 exit: r_exit,
2610 entry: r_entry,
2611 derivative: r_deriv,
2612 right,
2613 })
2614 }
2615
2616 pub fn unified_lamlobjective_and_rhogradient(
2622 &self,
2623 beta: &Array1<f64>,
2624 state: &WorkingState,
2625 rho: &Array1<f64>,
2626 ) -> Result<(f64, Array1<f64>), EstimationError> {
2627 use gam_problem::{EvalMode, PseudoLogdetMode};
2628 use gam_solve::estimate::reml::assembly::InnerAssembly;
2629 use gam_solve::estimate::reml::reml_outer_engine::{
2630 DenseSpectralOperator, DispersionHandling,
2631 };
2632 use gam_solve::estimate::reml::reparameterized_inner::{
2633 RawInnerReparamContext, assemble_reparameterized_inner,
2634 };
2635 use gam_terms::construction::{
2636 canonicalize_penalty_specs, precompute_reparam_invariant_from_canonical,
2637 stable_reparameterizationwith_invariant,
2638 };
2639 use gam_terms::penalty_spec::PenaltySpec;
2640
2641 let p = beta.len();
2642 let active_penalty_blocks: Vec<&PenaltyBlock> = self
2643 .penalties
2644 .blocks
2645 .iter()
2646 .filter(|b| b.lambda > 0.0)
2647 .collect();
2648 if rho.len() != active_penalty_blocks.len() {
2649 crate::bail_invalid_estim!(
2650 "survival LAML rho dimension {} does not match active penalty block count {}",
2651 rho.len(),
2652 active_penalty_blocks.len()
2653 );
2654 }
2655 let k_count = active_penalty_blocks.len();
2656
2657 let projected_norm = {
2675 let raw = state.gradient.clone();
2676 let projected = match self.monotonicity_linear_constraints() {
2677 Some(constraints) => {
2678 let constraints = ConstraintSet::Dense(constraints);
2679 projected_linear_constraint_stationarity_vector(&raw, beta, &constraints, None)
2680 .ok_or_else(|| {
2681 EstimationError::InvalidInput(
2682 "survival LAML could not project the monotonicity KKT residual"
2683 .to_string(),
2684 )
2685 })?
2686 }
2687 None => raw,
2688 };
2689 array1_l2_norm(&projected)
2690 };
2691 if !projected_norm.is_finite()
2701 || !state.certifies_kkt(projected_norm, SURVIVAL_LAML_STATIONARITY_RELATIVE_TOL)
2702 {
2703 return Err(EstimationError::TrialPointRefused {
2715 reason: format!(
2716 "survival LAML requires a stationary inner mode: projected KKT residual \
2717 {projected_norm:.3e} (relative {:.3e}) is not certified by the inner \
2718 solver's convergence test at tolerance \
2719 {SURVIVAL_LAML_STATIONARITY_RELATIVE_TOL:.3e}; a one-step residual \
2720 surrogate is not a differentiable substitute for the Laplace mode",
2721 state.relative_gradient_norm(projected_norm)
2722 ),
2723 });
2724 }
2725
2726 let lambdas: Vec<f64> = rho.iter().map(|&r| r.exp()).collect();
2729
2730 let h_dense = state.hessian.to_dense();
2739 let hessian_logdet_mode = PseudoLogdetMode::PositiveDefinite;
2740
2741 let s_k_embedded: Vec<Array2<f64>> = active_penalty_blocks
2749 .iter()
2750 .map(|b| {
2751 let mut s = Array2::<f64>::zeros((p, p));
2752 let (rs, re) = (b.range.start, b.range.end);
2753 s.slice_mut(ndarray::s![rs..re, rs..re]).assign(&b.matrix);
2754 s
2755 })
2756 .collect();
2757
2758 let penalty_specs: Vec<PenaltySpec> = active_penalty_blocks
2778 .iter()
2779 .map(|b| PenaltySpec::Block {
2780 local: b.matrix.clone(),
2781 col_range: b.range.clone(),
2782 prior_mean: gam_problem::CoefficientPriorMean::Zero,
2783 structure_hint: None,
2784 op: None,
2785 })
2786 .collect();
2787 let nullspace_dims: Vec<usize> = active_penalty_blocks
2788 .iter()
2789 .map(|b| b.nullspace_dim)
2790 .collect();
2791 let (canonical_penalties, _canonical_nullspace) = canonicalize_penalty_specs(
2792 &penalty_specs,
2793 &nullspace_dims,
2794 p,
2795 "survival LAML seam-A reparameterization",
2796 )
2797 .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
2798 if canonical_penalties.len() != k_count {
2799 return Err(EstimationError::InvalidInput(format!(
2800 "survival LAML reparameterization dropped {} of {} active (λ>0) penalty \
2801 block(s) as numerically rank-0; cannot align transformed penalty \
2802 coordinates with ρ",
2803 k_count - canonical_penalties.len(),
2804 k_count
2805 )));
2806 }
2807 let reparam_invariant =
2808 precompute_reparam_invariant_from_canonical(&canonical_penalties, p)
2809 .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
2810 let reparam = stable_reparameterizationwith_invariant(
2811 &canonical_penalties,
2812 &lambdas,
2813 p,
2814 &reparam_invariant,
2815 )
2816 .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
2817
2818 let provider = SurvivalDerivProvider::new(self.clone(), beta.clone());
2824 let ctx = RawInnerReparamContext {
2825 hessian: &h_dense,
2826 beta,
2827 penalties_embedded: &s_k_embedded,
2828 lambdas: &lambdas,
2829 };
2830 let reparam_inner = assemble_reparameterized_inner(
2831 &ctx,
2832 Some(Box::new(provider)),
2833 &reparam,
2834 )
2835 .map_err(EstimationError::InvalidInput)?;
2836
2837 let hop = DenseSpectralOperator::from_symmetric_with_mode(
2858 &reparam_inner.hessian_transformed,
2859 hessian_logdet_mode,
2860 )
2861 .map_err(|reason| EstimationError::TrialPointRefused {
2862 reason: format!(
2863 "survival LAML requires a positive-definite inner Hessian at this rho: {reason}"
2864 ),
2865 })?;
2866
2867 let penalty_coords = reparam
2874 .canonical_transformed
2875 .iter()
2876 .map(|cp| cp.to_penalty_coordinate())
2877 .collect::<Vec<_>>();
2878
2879 let penalty_quadratic = state.penalty_term;
2884
2885 let result = InnerAssembly {
2886 log_likelihood: state.log_likelihood,
2887 penalty_quadratic,
2888 beta: reparam_inner.beta_transformed,
2889 n_observations: self.nrows(),
2890 hessian_op: std::sync::Arc::new(hop),
2891 mode_response_op: None,
2894 penalty_coords,
2895 penalty_logdet: reparam_inner.penalty_logdet,
2896 dispersion: DispersionHandling::Fixed {
2897 phi: 1.0,
2898 include_logdet_h: true,
2899 include_logdet_s: true,
2900 },
2901 rho_curvature_scale: 1.0,
2902 rho_prior: gam_problem::RhoPrior::Flat,
2903 hessian_logdet_correction: 0.0,
2904 penalty_subspace_trace: None,
2905 deriv_provider: reparam_inner.deriv_provider,
2906 firth: None,
2907 nullspace_dim: None,
2908 barrier_config: None,
2909 ext_coords: Vec::new(),
2910 ext_coord_pair_fn: None,
2911 rho_ext_pair_fn: None,
2912 fixed_drift_deriv: None,
2913 contracted_psi_second_order: None,
2914 kkt_residual: None,
2915 active_constraints: None,
2916 }
2917 .evaluate(
2918 rho.as_slice().expect("rho must be contiguous"),
2919 EvalMode::ValueAndGradient,
2920 None,
2921 )
2922 .map_err(EstimationError::InvalidInput)?;
2923
2924 let gradient = result.gradient.unwrap_or_else(|| Array1::zeros(rho.len()));
2925 Ok((result.cost, gradient))
2926 }
2927
2928
2929 pub fn evaluate_survival_lamlcost_and_gradient(
2950 &self,
2951 rho: &[f64],
2952 beta0: &Array1<f64>,
2953 ) -> Result<(f64, Array1<f64>), EstimationError> {
2954 let (candidate, beta) = self.reconverge_survival_inner_mode(rho, beta0)?;
2955 let rho_arr = Array1::from_vec(rho.to_vec());
2960 let state = candidate.update_state(&beta)?;
2961 candidate.unified_lamlobjective_and_rhogradient(&beta, &state, &rho_arr)
2962 }
2963
2964 fn reconverge_survival_inner_mode(
2974 &self,
2975 rho: &[f64],
2976 beta0: &Array1<f64>,
2977 ) -> Result<(WorkingModelSurvival, Array1<f64>), EstimationError> {
2978 const SHIM_PIRLS_MAX_ITERATIONS: usize = 600;
2983 const SHIM_PIRLS_CONVERGENCE_TOL: f64 = 1e-12;
2984 const SHIM_PIRLS_MAX_STEP_HALVING: usize = 40;
2985 const SHIM_PIRLS_MIN_STEP_SIZE: f64 = 1e-12;
2986
2987 let active_block_count = self
2988 .penalties
2989 .blocks
2990 .iter()
2991 .filter(|b| b.lambda > 0.0)
2992 .count();
2993 if rho.len() != active_block_count {
2994 crate::bail_invalid_estim!(
2995 "reconverge_survival_inner_mode: rho dimension {} does not match active penalty block count {}",
2996 rho.len(),
2997 active_block_count
2998 );
2999 }
3000 if beta0.len() != self.coefficient_dim() {
3001 crate::bail_invalid_estim!(
3002 "reconverge_survival_inner_mode: beta0 dimension {} does not match coefficient dimension {}",
3003 beta0.len(),
3004 self.coefficient_dim()
3005 );
3006 }
3007 let active_lambdas = gam_problem::checked_exp_log_strengths(rho.iter().copied())?;
3008
3009 let mut candidate = self.clone();
3012 let mut lambdas: Vec<f64> = candidate
3013 .penalties
3014 .blocks
3015 .iter()
3016 .map(|b| b.lambda)
3017 .collect();
3018 let mut active_idx = 0usize;
3019 for (block, lambda) in candidate.penalties.blocks.iter().zip(lambdas.iter_mut()) {
3020 if block.lambda > 0.0 {
3021 *lambda = active_lambdas[active_idx];
3022 active_idx += 1;
3023 }
3024 }
3025 candidate.set_penalty_lambdas(&lambdas)?;
3026
3027 let opts = gam_solve::pirls::WorkingModelPirlsOptions {
3028 max_iterations: SHIM_PIRLS_MAX_ITERATIONS,
3029 convergence_tolerance: SHIM_PIRLS_CONVERGENCE_TOL,
3030 adaptive_kkt_tolerance: None,
3031 max_step_halving: SHIM_PIRLS_MAX_STEP_HALVING,
3032 min_step_size: SHIM_PIRLS_MIN_STEP_SIZE,
3033 firth_bias_reduction: false,
3034 coefficient_lower_bounds: None,
3035 linear_constraints: None,
3036 initial_lm_lambda: None,
3037 arrow_schur: None,
3038 };
3039 let summary = gam_solve::pirls::runworking_model_pirls(
3040 &mut candidate,
3041 Coefficients::new(beta0.clone()),
3042 &opts,
3043 Some(&mut |info: &gam_solve::pirls::WorkingModelIterationInfo| {
3049 log::trace!(
3050 "[survival LAML shim] inner PIRLS iter={} deviance={:.6e} \
3051 |grad|={:.3e} step={:.3e} halvings={}",
3052 info.iteration,
3053 info.deviance,
3054 info.gradient_norm,
3055 info.step_size,
3056 info.step_halving
3057 );
3058 }),
3059 )?;
3060 let mut beta = summary.beta.as_ref().to_owned();
3061
3062 {
3084 const POLISH_MAX_ITERS: usize = 400;
3085 const POLISH_TOL: f64 = 1e-13;
3086 const ARMIJO_C: f64 = constants::ARMIJO_C1;
3090 const BACKTRACK: f64 = constants::BACKTRACK_CONTRACTION;
3091 const MAX_BACKTRACK: usize = 80;
3092 let p = beta.len();
3093 let penalized_objective =
3100 |st: &WorkingState| -> f64 { -st.log_likelihood + 0.5 * st.penalty_term };
3101 for _ in 0..POLISH_MAX_ITERS {
3102 let st = match candidate.update_state(&beta) {
3103 Ok(st) => st,
3104 Err(_) => break,
3105 };
3106 let r = st.gradient.clone();
3107 let r_norm = r.iter().map(|v| v * v).sum::<f64>().sqrt();
3108 if !r_norm.is_finite() || r_norm < POLISH_TOL {
3109 break;
3110 }
3111 let h = st.hessian.to_dense();
3112 let f0 = penalized_objective(&st);
3113 let h_scale = (0..p)
3128 .map(|d| h[[d, d]].abs())
3129 .fold(0.0_f64, f64::max)
3130 .max(1.0);
3131 let try_lm = |lambda_lm: f64| -> Option<(Array1<f64>, f64)> {
3147 let mut h_reg = h.clone();
3148 for d in 0..p {
3149 h_reg[[d, d]] += lambda_lm;
3150 }
3151 let factor =
3152 gam_linalg::faer_ndarray::FaerCholesky::cholesky(&h_reg, faer::Side::Lower)
3153 .ok()?;
3154 let candidate_step = factor.solvevec(&r);
3155 if candidate_step.iter().any(|v| !v.is_finite()) {
3156 return None;
3157 }
3158 let dd = -r.dot(&candidate_step);
3159 (dd.is_finite() && dd < -1e-14 * r_norm * r_norm)
3160 .then_some((candidate_step, dd))
3161 };
3162 let (step, dir_deriv) = try_lm(0.0)
3165 .or_else(|| {
3166 escalate_ridge(RidgeSchedule::geometric(1e-11 * h_scale, 17), try_lm)
3167 .ok()
3168 .map(|success| success.value)
3169 })
3170 .unwrap_or_else(|| {
3171 (r.clone(), -r_norm * r_norm)
3174 });
3175 let accepted = match backtracking_line_search::<_, Infallible>(
3192 BacktrackConfig {
3193 contraction: BACKTRACK,
3194 max_steps: MAX_BACKTRACK,
3195 ..BacktrackConfig::default()
3196 },
3197 |alpha| {
3198 let trial = &beta - &(alpha * &step);
3199 let Ok(ts) = candidate.update_state(&trial) else {
3200 return Ok(None);
3201 };
3202 let ft = penalized_objective(&ts);
3203 let tn = ts.gradient.iter().map(|v| v * v).sum::<f64>().sqrt();
3204 let armijo_ok = ft.is_finite() && ft <= f0 + ARMIJO_C * alpha * dir_deriv;
3205 let residual_ok = tn.is_finite() && tn < r_norm;
3206 Ok((armijo_ok || residual_ok).then_some((ft, trial)))
3207 },
3208 |_, value| value.is_finite(),
3216 ) {
3217 Ok(result) => result,
3218 Err(never) => match never {},
3219 };
3220 let Some(ls) = accepted else {
3221 break;
3222 };
3223 beta = ls.payload;
3224 }
3225 }
3226
3227 Ok((candidate, beta))
3228 }
3229}
3230
3231pub(crate) struct SurvivalDerivProvider {
3240 model: WorkingModelSurvival,
3241 beta: Array1<f64>,
3242}
3243
3244impl SurvivalDerivProvider {
3245 pub(crate) fn new(model: WorkingModelSurvival, beta: Array1<f64>) -> Self {
3246 Self { model, beta }
3247 }
3248}
3249
3250impl gam_solve::estimate::reml::reml_outer_engine::HessianDerivativeProvider
3251 for SurvivalDerivProvider
3252{
3253 fn hessian_derivative_correction(
3254 &self,
3255 v_k: &Array1<f64>,
3256 ) -> Result<Option<Array2<f64>>, String> {
3257 let u_k = -v_k;
3260 match self
3261 .model
3262 .survival_hessian_derivative_correction(&self.beta, &u_k)
3263 {
3264 Ok(correction) => Ok(Some(correction)),
3265 Err(e) => Err(e.to_string()),
3266 }
3267 }
3268
3269 fn has_corrections(&self) -> bool {
3270 true
3271 }
3272}
3273
3274#[derive(Debug, Clone)]
3275pub struct CrudeRiskResult {
3276 pub risk: f64,
3277 pub diseasegradient: Array1<f64>,
3278 pub mortalitygradient: Array1<f64>,
3279}
3280
3281#[derive(Debug, Clone)]
3282pub struct CompetingRisksCifResult {
3283 pub cif: Vec<Array2<f64>>,
3288 pub overall_survival: Array2<f64>,
3289}
3290
3291const COMPETING_RISKS_CIF_PARALLEL_ROW_MIN: usize = 256;
3296
3297pub fn assemble_competing_risks_cif(
3298 times: ArrayView1<'_, f64>,
3299 cumulative_hazard: ArrayView3<'_, f64>,
3300) -> Result<CompetingRisksCifResult, SurvivalError> {
3301 let (n_endpoints, n_rows, n_times) = cumulative_hazard.dim();
3302 if n_endpoints == 0 {
3303 return Err(SurvivalError::DimensionMismatch);
3304 }
3305 let endpoint_hazards = cumulative_hazard
3306 .axis_iter(Axis(0))
3307 .map(|view| view.to_owned())
3308 .collect::<Vec<_>>();
3309 assemble_competing_risks_cif_from_endpoints(times, &endpoint_hazards).and_then(|result| {
3310 if result.overall_survival.dim() != (n_rows, n_times) {
3311 Err(SurvivalError::DimensionMismatch)
3312 } else {
3313 Ok(result)
3314 }
3315 })
3316}
3317
3318pub fn assemble_competing_risks_cif_from_endpoints(
3319 times: ArrayView1<'_, f64>,
3320 cumulative_hazards: &[Array2<f64>],
3321) -> Result<CompetingRisksCifResult, SurvivalError> {
3322 let n_endpoints = cumulative_hazards.len();
3323 if n_endpoints == 0 || times.is_empty() {
3324 return Err(SurvivalError::DimensionMismatch);
3325 }
3326 let (n_rows, n_times) = cumulative_hazards[0].dim();
3327 if n_rows == 0 || n_times == 0 || times.len() != n_times {
3328 return Err(SurvivalError::DimensionMismatch);
3329 }
3330 if times.iter().any(|time| !time.is_finite() || *time < 0.0) {
3331 return Err(SurvivalError::InvalidTimeGrid);
3332 }
3333 if times
3334 .iter()
3335 .zip(times.iter().skip(1))
3336 .any(|(previous, current)| current <= previous)
3337 {
3338 return Err(SurvivalError::InvalidTimeGrid);
3339 }
3340 for endpoint_hazard in cumulative_hazards {
3341 if endpoint_hazard.dim() != (n_rows, n_times) {
3342 return Err(SurvivalError::DimensionMismatch);
3343 }
3344 if endpoint_hazard.iter().any(|value| !value.is_finite()) {
3345 return Err(SurvivalError::NonFiniteInput);
3346 }
3347 }
3348
3349 let max_abs_hazard = cumulative_hazards
3350 .iter()
3351 .flat_map(|endpoint_hazard| endpoint_hazard.iter())
3352 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
3353 let monotone_tolerance = 1.0e-10_f64 * max_abs_hazard.max(1.0);
3354 let mut cif: Vec<Array2<f64>> = (0..n_endpoints)
3355 .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
3356 .collect();
3357 let mut overall_survival = Array2::<f64>::zeros((n_rows, n_times));
3358
3359 let assemble_row = |row: usize| -> Result<(Vec<f64>, Vec<f64>), SurvivalError> {
3371 let mut cif_flat = vec![0.0_f64; n_endpoints * n_times];
3372 let mut surv_row = vec![0.0_f64; n_times];
3373 let mut previous_cif = vec![0.0_f64; n_endpoints];
3374 let mut previous_cumulative = vec![0.0_f64; n_endpoints];
3375 let mut increments = vec![0.0_f64; n_endpoints];
3376 let mut previous_total_cumulative = 0.0_f64;
3377 for time_idx in 0..n_times {
3378 let mut total_increment = 0.0_f64;
3379 for endpoint in 0..n_endpoints {
3380 let current = cumulative_hazards[endpoint][[row, time_idx]];
3381 if current < -monotone_tolerance {
3382 return Err(SurvivalError::NonMonotoneCumulativeHazard);
3383 }
3384 let raw_increment = current - previous_cumulative[endpoint];
3385 if raw_increment < -monotone_tolerance {
3386 return Err(SurvivalError::NonMonotoneCumulativeHazard);
3387 }
3388 let increment = raw_increment.max(0.0);
3389 increments[endpoint] = increment;
3390 total_increment += increment;
3391 previous_cumulative[endpoint] += increment;
3392 }
3393
3394 let survival_left = (-previous_total_cumulative).exp();
3395 let interval_failure = -(-total_increment).exp_m1();
3396 for endpoint in 0..n_endpoints {
3397 if total_increment > 0.0 {
3398 previous_cif[endpoint] +=
3399 survival_left * interval_failure * increments[endpoint] / total_increment;
3400 }
3401 cif_flat[endpoint * n_times + time_idx] = previous_cif[endpoint].clamp(0.0, 1.0);
3402 }
3403 previous_total_cumulative += total_increment;
3404 let mut fsum_at_t = 0.0_f64;
3421 for endpoint in 0..n_endpoints {
3422 fsum_at_t += cif_flat[endpoint * n_times + time_idx];
3423 }
3424 surv_row[time_idx] = (1.0_f64 - fsum_at_t).clamp(0.0, 1.0);
3425 }
3426 Ok((cif_flat, surv_row))
3427 };
3428
3429 let rows: Vec<(Vec<f64>, Vec<f64>)> = if n_rows >= COMPETING_RISKS_CIF_PARALLEL_ROW_MIN
3433 && rayon::current_thread_index().is_none()
3434 {
3435 use rayon::prelude::*;
3436 (0..n_rows)
3437 .into_par_iter()
3438 .map(assemble_row)
3439 .collect::<Result<_, _>>()?
3440 } else {
3441 (0..n_rows).map(assemble_row).collect::<Result<_, _>>()?
3442 };
3443
3444 for (row, (cif_flat, surv_row)) in rows.into_iter().enumerate() {
3445 for endpoint in 0..n_endpoints {
3446 for time_idx in 0..n_times {
3447 cif[endpoint][[row, time_idx]] = cif_flat[endpoint * n_times + time_idx];
3448 }
3449 }
3450 for time_idx in 0..n_times {
3451 overall_survival[[row, time_idx]] = surv_row[time_idx];
3452 }
3453 }
3454
3455 Ok(CompetingRisksCifResult {
3456 cif,
3457 overall_survival,
3458 })
3459}
3460
3461fn compute_gauss_legendre_nodes(n: usize) -> Vec<(f64, f64)> {
3465 let (nodes, weights) = gam_math::special::gauss_legendre(n);
3466 nodes.into_iter().zip(weights).collect()
3467}
3468
3469fn gauss_legendre_quadrature() -> &'static [(f64, f64)] {
3470 static CACHE: LazyLock<Vec<(f64, f64)>> = LazyLock::new(|| compute_gauss_legendre_nodes(40));
3476 &CACHE
3477}
3478
3479pub fn calculate_crude_risk_quadrature<F>(
3503 t0: f64,
3504 t1: f64,
3505 breakpoints: &[f64],
3506 h_dis_t0: f64,
3507 h_mor_t0: f64,
3508 design_d_t0: ArrayView1<'_, f64>,
3509 design_m_t0: ArrayView1<'_, f64>,
3510 mut eval_at: F,
3511) -> Result<CrudeRiskResult, SurvivalError>
3512where
3513 F: FnMut(
3514 f64,
3515 &mut Array1<f64>,
3516 &mut Array1<f64>,
3517 &mut Array1<f64>,
3518 ) -> Result<(f64, f64, f64), SurvivalError>,
3519{
3520 let coeff_len_d = design_d_t0.len();
3521 let coeff_len_m = design_m_t0.len();
3522 if coeff_len_d == 0 || coeff_len_m == 0 {
3523 return Err(SurvivalError::InvalidIntegrationSetup);
3524 }
3525 if !t0.is_finite()
3526 || !t1.is_finite()
3527 || !h_dis_t0.is_finite()
3528 || !h_mor_t0.is_finite()
3529 || design_d_t0.iter().any(|v| !v.is_finite())
3530 || design_m_t0.iter().any(|v| !v.is_finite())
3531 {
3532 return Err(SurvivalError::NonFiniteInput);
3533 }
3534 if t1 <= t0 {
3535 return Ok(CrudeRiskResult {
3536 risk: 0.0,
3537 diseasegradient: Array1::zeros(coeff_len_d),
3538 mortalitygradient: Array1::zeros(coeff_len_m),
3539 });
3540 }
3541
3542 let mut sorted_breaks: Vec<f64> = breakpoints
3543 .iter()
3544 .copied()
3545 .filter(|x| x.is_finite() && *x >= t0 && *x <= t1)
3546 .collect();
3547 sorted_breaks.push(t0);
3548 sorted_breaks.push(t1);
3549 sorted_breaks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3550 sorted_breaks.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
3551 if sorted_breaks.len() < 2 {
3552 return Err(SurvivalError::InvalidIntegrationSetup);
3553 }
3554
3555 let mut total_risk = 0.0;
3556 let mut diseasegradient = Array1::zeros(coeff_len_d);
3557 let mut mortalitygradient = Array1::zeros(coeff_len_m);
3558 let nodesweights = gauss_legendre_quadrature();
3559
3560 let mut design_d = Array1::<f64>::zeros(coeff_len_d);
3561 let mut deriv_d = Array1::<f64>::zeros(coeff_len_d);
3562 let mut design_m = Array1::<f64>::zeros(coeff_len_m);
3563
3564 for segment in sorted_breaks.windows(2) {
3565 let a = segment[0];
3566 let b = segment[1];
3567 let center = 0.5 * (b + a);
3568 let halfwidth = 0.5 * (b - a);
3569 if halfwidth <= 0.0 {
3570 continue;
3571 }
3572
3573 for &(x, w) in nodesweights {
3574 let u = center + halfwidth * x;
3575 let (inst_hazard_d, hazard_d, hazard_m) =
3576 eval_at(u, &mut design_d, &mut deriv_d, &mut design_m)?;
3577 if !inst_hazard_d.is_finite() || !hazard_d.is_finite() || !hazard_m.is_finite() {
3578 return Err(SurvivalError::NonFiniteInput);
3579 }
3580 if inst_hazard_d <= 0.0 {
3581 return Err(SurvivalError::NonPositiveHazard);
3582 }
3583
3584 if hazard_d < h_dis_t0 || hazard_m < h_mor_t0 {
3585 return Err(SurvivalError::NonMonotoneCumulativeHazard);
3586 }
3587
3588 let h_dis_cond = hazard_d - h_dis_t0;
3589 let h_mor_cond = hazard_m - h_mor_t0;
3590 let s_total = (-(h_dis_cond + h_mor_cond)).exp();
3591
3592 total_risk += w * inst_hazard_d * s_total * halfwidth;
3593
3594 let weight = w * s_total * halfwidth;
3600 for j in 0..coeff_len_d {
3601 let d_inst_hazard = inst_hazard_d * design_d[j] + hazard_d * deriv_d[j];
3602 let d_hazard_cond = hazard_d * design_d[j] - h_dis_t0 * design_d_t0[j];
3603 let g = d_inst_hazard - inst_hazard_d * d_hazard_cond;
3604 diseasegradient[j] += weight * g;
3605 }
3606
3607 let weight = w * inst_hazard_d * s_total * halfwidth;
3610 for j in 0..coeff_len_m {
3611 let g = -hazard_m * design_m[j] + h_mor_t0 * design_m_t0[j];
3612 mortalitygradient[j] += weight * g;
3613 }
3614 }
3615 }
3616
3617 Ok(CrudeRiskResult {
3618 risk: total_risk,
3619 diseasegradient,
3620 mortalitygradient,
3621 })
3622}
3623
3624impl PirlsWorkingModel for WorkingModelSurvival {
3625 fn update(&mut self, beta: &Coefficients) -> Result<WorkingState, EstimationError> {
3626 self.update_state(beta)
3627 }
3628}
3629
3630#[cfg(test)]
3631mod tests {
3632 use super::*;
3633 use ndarray::{Array1, Array2, Array3, array, s};
3634
3635 #[test]
3636 fn saved_cause_specific_alo_matches_independent_closed_form() {
3637 let eta_exit = 0.4_f64;
3638 let eta_entry = -0.3_f64;
3639 let derivative_exit = 1.7_f64;
3640 let weight = 2.2_f64;
3641 let geometry = cause_specific_survival_alo_row_geometry(CauseSpecificSurvivalAloRowInput {
3642 eta_exit,
3643 eta_entry,
3644 derivative_exit,
3645 prior_weight: weight,
3646 entry_active: true,
3647 event: true,
3648 })
3649 .expect("valid cause-specific row");
3650 let expected_nll =
3651 weight * (eta_exit.exp() - eta_entry.exp() - eta_exit - derivative_exit.ln());
3652 let expected_score = [
3653 weight * (eta_exit.exp() - 1.0),
3654 -weight * eta_entry.exp(),
3655 -weight / derivative_exit,
3656 ];
3657 let expected_hessian = [
3658 [weight * eta_exit.exp(), 0.0, 0.0],
3659 [0.0, -weight * eta_entry.exp(), 0.0],
3660 [0.0, 0.0, weight / derivative_exit.powi(2)],
3661 ];
3662 assert!((geometry.negative_log_likelihood - expected_nll).abs() <= 2.0e-14);
3663 for row in 0..3 {
3664 assert!((geometry.nll_score[row] - expected_score[row]).abs() <= 2.0e-14);
3665 for column in 0..3 {
3666 assert!(
3667 (geometry.observed_hessian[row][column] - expected_hessian[row][column]).abs()
3668 <= 2.0e-14
3669 );
3670 }
3671 }
3672 let score_meat = geometry.nll_score[0] * geometry.nll_score[0];
3673 assert!(
3674 (geometry.observed_hessian[0][0] - score_meat).abs() > 1.0e-2,
3675 "survival observed W and empirical score meat C must remain separate"
3676 );
3677 }
3678
3679 mod jet_cause_specific_production_parity {
3690 use super::*;
3691 use gam_math::jet_tower::{
3692 program_fourth_contracted, program_row_kernel, program_third_contracted,
3693 };
3694
3695 fn identity_block(w: f64, has_entry: bool, event: bool) -> CauseSpecificRoystonParmarBlock {
3702 let age_entry = if has_entry { 1.0 } else { 0.0 };
3703 CauseSpecificRoystonParmarBlock {
3704 age_entry: array![age_entry],
3705 age_exit: array![2.0],
3706 event_target: array![if event { 1u8 } else { 0u8 }],
3707 sampleweight: array![w],
3708 x_entry: array![[0.0, 1.0, 0.0]],
3709 x_exit: array![[1.0, 0.0, 0.0]],
3710 x_derivative: array![[0.0, 0.0, 1.0]],
3711 offset_eta_entry: array![0.0],
3712 offset_eta_exit: array![0.0],
3713 offset_derivative_exit: array![0.0],
3714 derivative_floor: 0.0,
3715 structural_time_columns: 0,
3716 }
3717 }
3718
3719 fn close(hand: f64, jet: f64, tol: f64, label: &str) {
3720 let band = tol + tol * hand.abs().max(jet.abs());
3721 assert!(
3722 (hand - jet).abs() <= band,
3723 "{label}: hand {hand:+.15e} vs jet {jet:+.15e} (|Δ|={:.3e} band {band:.3e})",
3724 (hand - jet).abs()
3725 );
3726 }
3727
3728 const JET_TOL: f64 = 1e-9;
3729
3730 fn run_corner(has_entry: bool, event: bool) {
3731 let beta = array![0.4_f64, -0.3_f64, 1.3_f64];
3733 let d_beta = array![0.7_f64, -0.5_f64, 0.6_f64];
3734 let v_beta = array![-0.2_f64, 0.8_f64, -0.4_f64];
3735 let w = 1.4_f64;
3736 let block = identity_block(w, has_entry, event);
3737 let prog = crate::survival::CauseSpecificRowProgram::new(
3738 [beta[0], beta[1], beta[2]],
3739 w,
3740 has_entry,
3741 event,
3742 );
3743 let label = format!("entry={has_entry} event={event}");
3744
3745 let (ll, grad, hess) =
3747 evaluate_cause_specific_block(&block, &beta).expect("evaluate block");
3748 let (jet_v, jet_g, jet_h) = program_row_kernel(&prog, 0).expect("jet kernel");
3749 close(jet_v, -ll, JET_TOL, &format!("{label} value"));
3750 for a in 0..3 {
3751 close(jet_g[a], -grad[a], JET_TOL, &format!("{label} grad[{a}]"));
3752 for b in 0..3 {
3753 close(
3754 jet_h[a][b],
3755 hess[[a, b]],
3756 JET_TOL,
3757 &format!("{label} H[{a}][{b}]"),
3758 );
3759 }
3760 }
3761
3762 let dh = cause_specific_hessian_directional_derivative(&block, &beta, &d_beta)
3764 .expect("live third");
3765 let dir = [d_beta[0], d_beta[1], d_beta[2]];
3766 let jet_t3 = program_third_contracted(&prog, 0, &dir).expect("jet third");
3767 for a in 0..3 {
3768 for b in 0..3 {
3769 close(
3770 jet_t3[a][b],
3771 dh[[a, b]],
3772 JET_TOL,
3773 &format!("{label} third[{a}][{b}]"),
3774 );
3775 }
3776 }
3777
3778 let d2h = cause_specific_hessian_second_directional_derivative(
3780 &block, &beta, &d_beta, &v_beta,
3781 )
3782 .expect("live fourth");
3783 let uu = [d_beta[0], d_beta[1], d_beta[2]];
3784 let vv = [v_beta[0], v_beta[1], v_beta[2]];
3785 let jet_t4 = program_fourth_contracted(&prog, 0, &uu, &vv).expect("jet fourth");
3786 for a in 0..3 {
3787 for b in 0..3 {
3788 close(
3789 jet_t4[a][b],
3790 d2h[[a, b]],
3791 JET_TOL,
3792 &format!("{label} fourth[{a}][{b}]"),
3793 );
3794 }
3795 }
3796
3797 let h_fd = 1e-5;
3800 let bp = &beta + &(&d_beta * h_fd);
3801 let bm = &beta - &(&d_beta * h_fd);
3802 let (_, _, hp) = evaluate_cause_specific_block(&block, &bp).expect("evaluate +");
3803 let (_, _, hm) = evaluate_cause_specific_block(&block, &bm).expect("evaluate -");
3804 for a in 0..3 {
3805 for b in 0..3 {
3806 let fd = (hp[[a, b]] - hm[[a, b]]) / (2.0 * h_fd);
3807 close(dh[[a, b]], fd, 1e-5, &format!("{label} FD third[{a}][{b}]"));
3808 }
3809 }
3810 let dhp = cause_specific_hessian_directional_derivative(
3812 &block,
3813 &bp_along(&beta, &v_beta, h_fd),
3814 &d_beta,
3815 )
3816 .expect("live third +");
3817 let dhm = cause_specific_hessian_directional_derivative(
3818 &block,
3819 &bm_along(&beta, &v_beta, h_fd),
3820 &d_beta,
3821 )
3822 .expect("live third -");
3823 for a in 0..3 {
3824 for b in 0..3 {
3825 let fd = (dhp[[a, b]] - dhm[[a, b]]) / (2.0 * h_fd);
3826 close(
3827 d2h[[a, b]],
3828 fd,
3829 1e-5,
3830 &format!("{label} FD fourth[{a}][{b}]"),
3831 );
3832 }
3833 }
3834 }
3835
3836 fn bp_along(beta: &Array1<f64>, v: &Array1<f64>, h: f64) -> Array1<f64> {
3837 beta + &(v * h)
3838 }
3839 fn bm_along(beta: &Array1<f64>, v: &Array1<f64>, h: f64) -> Array1<f64> {
3840 beta - &(v * h)
3841 }
3842
3843 #[test]
3849 fn cause_specific_live_tower_matches_jet_and_fd() {
3850 for &has_entry in &[false, true] {
3851 for &event in &[false, true] {
3852 run_corner(has_entry, event);
3853 }
3854 }
3855 }
3856
3857 #[test]
3879 fn release_measure_cause_specific_vs_generic_tower_diagnostic() {
3880 use std::time::Instant;
3881
3882 const ROWS: usize = 512;
3883 let mut rows: Vec<([f64; 3], f64, bool, bool)> = Vec::with_capacity(ROWS);
3884 for idx in 0..ROWS {
3885 let f = idx as f64;
3886 let eta_exit = 1.6 * (f * 0.17 + 0.3).sin() - 0.4 * (f * 0.09).cos();
3887 let eta_entry = 1.1 * (f * 0.13 + 0.7).cos() + 0.35 * (f * 0.05).sin();
3888 let derivative = 0.5 + 0.45 * (f * 0.31 + 0.2).sin().abs();
3890 let weight = 0.6 + 0.4 * (f * 0.07 + 1.0).sin().abs();
3891 let entry_active = idx % 2 == 0;
3892 let event = (idx / 2) % 2 == 0;
3893 rows.push((
3894 [eta_exit, eta_entry, derivative],
3895 weight,
3896 entry_active,
3897 event,
3898 ));
3899 }
3900 let programs: Vec<crate::survival::CauseSpecificRowProgram> = rows
3901 .iter()
3902 .map(|&(primary, weight, entry_active, event)| {
3903 crate::survival::CauseSpecificRowProgram::new(
3904 primary,
3905 weight,
3906 entry_active,
3907 event,
3908 )
3909 })
3910 .collect();
3911 let dir_u: Vec<[f64; 3]> = (0..ROWS)
3914 .map(|idx| {
3915 let f = idx as f64;
3916 [
3917 0.7 * (f * 0.23 + 0.4).cos() - 0.2 * (f * 0.03).sin(),
3918 -0.6 * (f * 0.29 + 0.1).sin() + 0.25 * (f * 0.15).cos(),
3919 0.5 * (f * 0.19 + 0.6).cos() - 0.3 * (f * 0.08).sin(),
3920 ]
3921 })
3922 .collect();
3923 let dir_v: Vec<[f64; 3]> = (0..ROWS)
3924 .map(|idx| {
3925 let f = idx as f64;
3926 [
3927 -0.5 * (f * 0.21 + 0.9).sin() + 0.3 * (f * 0.06).cos(),
3928 0.8 * (f * 0.27 + 0.5).cos() - 0.15 * (f * 0.04).sin(),
3929 0.4 * (f * 0.13 + 0.3).sin() - 0.2 * (f * 0.11).cos(),
3930 ]
3931 })
3932 .collect();
3933
3934 for (idx, (row, program)) in rows.iter().zip(programs.iter()).enumerate() {
3937 let (primary, weight, entry_active, event) = *row;
3938 let atom = cause_specific_row_order2(
3939 primary[0],
3940 primary[1],
3941 primary[2],
3942 weight,
3943 entry_active,
3944 event,
3945 );
3946 let (tower_value, tower_gradient, tower_hessian) =
3947 program_row_kernel(program, 0).expect("tower warm kernel");
3948 close(
3949 atom.value(),
3950 tower_value,
3951 JET_TOL,
3952 "release-measure value parity",
3953 );
3954 let production_gradient = atom.gradient();
3955 for a in 0..3 {
3956 close(
3957 production_gradient[a],
3958 tower_gradient[a],
3959 JET_TOL,
3960 "release-measure gradient parity",
3961 );
3962 for b in 0..3 {
3963 close(
3964 atom.hessian_at(a, b),
3965 tower_hessian[a][b],
3966 JET_TOL,
3967 "release-measure hessian parity",
3968 );
3969 }
3970 }
3971 let production_third = cause_specific_row_third_contracted(
3972 primary[0],
3973 primary[1],
3974 primary[2],
3975 weight,
3976 entry_active,
3977 event,
3978 &dir_u[idx],
3979 );
3980 let tower_third =
3981 program_third_contracted(program, 0, &dir_u[idx]).expect("tower warm third");
3982 let production_fourth = cause_specific_row_fourth_contracted(
3983 primary[0],
3984 primary[1],
3985 primary[2],
3986 weight,
3987 entry_active,
3988 event,
3989 &dir_u[idx],
3990 &dir_v[idx],
3991 );
3992 let tower_fourth = program_fourth_contracted(program, 0, &dir_u[idx], &dir_v[idx])
3993 .expect("tower warm fourth");
3994 for a in 0..3 {
3995 for b in 0..3 {
3996 close(
3997 production_third[a][b],
3998 tower_third[a][b],
3999 JET_TOL,
4000 "release-measure third parity",
4001 );
4002 close(
4003 production_fourth[a][b],
4004 tower_fourth[a][b],
4005 JET_TOL,
4006 "release-measure fourth parity",
4007 );
4008 }
4009 }
4010 }
4011
4012 let best_secs = |sweep: &mut dyn FnMut() -> f64| -> f64 {
4013 let mut best = f64::INFINITY;
4014 for _ in 0..5 {
4015 let started = Instant::now();
4016 let checksum = sweep();
4017 assert!(
4018 checksum.is_finite(),
4019 "cause-specific release-measure checksum must stay finite"
4020 );
4021 best = best.min(started.elapsed().as_secs_f64());
4022 }
4023 best
4024 };
4025
4026 let mut production_sweep = || {
4027 let mut checksum = 0.0_f64;
4028 for &(primary, weight, entry_active, event) in &rows {
4029 let atom = cause_specific_row_order2(
4030 primary[0],
4031 primary[1],
4032 primary[2],
4033 weight,
4034 entry_active,
4035 event,
4036 );
4037 checksum += atom.value() + atom.gradient()[0] + atom.hessian_at(0, 0);
4038 }
4039 checksum
4040 };
4041 let production_secs = best_secs(&mut production_sweep);
4042
4043 let mut tower_sweep = || {
4044 let mut checksum = 0.0_f64;
4045 for program in &programs {
4046 let (value, gradient, hessian) =
4047 program_row_kernel(program, 0).expect("tower kernel");
4048 checksum += value + gradient[0] + hessian[0][0];
4049 }
4050 checksum
4051 };
4052 let tower_secs = best_secs(&mut tower_sweep);
4053
4054 let mut production_third_sweep = || {
4055 let mut checksum = 0.0_f64;
4056 for (idx, &(primary, weight, entry_active, event)) in rows.iter().enumerate() {
4057 let third = cause_specific_row_third_contracted(
4058 primary[0],
4059 primary[1],
4060 primary[2],
4061 weight,
4062 entry_active,
4063 event,
4064 &dir_u[idx],
4065 );
4066 checksum += third[0][0] + third[0][1] + third[1][1];
4067 }
4068 checksum
4069 };
4070 let production_third_secs = best_secs(&mut production_third_sweep);
4071 let mut tower_third_sweep = || {
4072 let mut checksum = 0.0_f64;
4073 for (idx, program) in programs.iter().enumerate() {
4074 let third = program_third_contracted(program, 0, &dir_u[idx])
4075 .expect("tower third kernel");
4076 checksum += third[0][0] + third[0][1] + third[1][1];
4077 }
4078 checksum
4079 };
4080 let tower_third_secs = best_secs(&mut tower_third_sweep);
4081
4082 let mut production_fourth_sweep = || {
4083 let mut checksum = 0.0_f64;
4084 for (idx, &(primary, weight, entry_active, event)) in rows.iter().enumerate() {
4085 let fourth = cause_specific_row_fourth_contracted(
4086 primary[0],
4087 primary[1],
4088 primary[2],
4089 weight,
4090 entry_active,
4091 event,
4092 &dir_u[idx],
4093 &dir_v[idx],
4094 );
4095 checksum += fourth[0][0] + fourth[0][1] + fourth[1][1];
4096 }
4097 checksum
4098 };
4099 let production_fourth_secs = best_secs(&mut production_fourth_sweep);
4100 let mut tower_fourth_sweep = || {
4101 let mut checksum = 0.0_f64;
4102 for (idx, program) in programs.iter().enumerate() {
4103 let fourth = program_fourth_contracted(program, 0, &dir_u[idx], &dir_v[idx])
4104 .expect("tower fourth kernel");
4105 checksum += fourth[0][0] + fourth[0][1] + fourth[1][1];
4106 }
4107 checksum
4108 };
4109 let tower_fourth_secs = best_secs(&mut tower_fourth_sweep);
4110
4111 for (channel, production_secs, tower_secs) in [
4112 ("order2", production_secs, tower_secs),
4113 ("third", production_third_secs, tower_third_secs),
4114 ("fourth", production_fourth_secs, tower_fourth_secs),
4115 ] {
4116 let production_ns = production_secs * 1e9 / ROWS as f64;
4117 let tower_ns = tower_secs * 1e9 / ROWS as f64;
4118 eprintln!(
4119 "CAUSE-SPECIFIC-RELEASE-932 channel={channel} rows={ROWS} \
4120 production_ns={production_ns:.3} generic_tower_ns={tower_ns:.3} \
4121 generic_over_production={:.6}",
4122 tower_ns / production_ns,
4123 );
4124 }
4125 }
4126 }
4127
4128 #[test]
4129 fn competing_risks_cif_constant_hazard_matches_closed_form() {
4130 let times = array![0.0, 2.0, 5.0, 10.0];
4131 let disease_rates = [0.12, 0.06];
4132 let death_rates = [0.05, 0.02];
4133 let cumulative = Array3::from_shape_fn((2, 2, times.len()), |(endpoint, row, time_idx)| {
4134 let rate = if endpoint == 0 {
4135 disease_rates[row]
4136 } else {
4137 death_rates[row]
4138 };
4139 rate * times[time_idx]
4140 });
4141
4142 let result =
4143 assemble_competing_risks_cif(times.view(), cumulative.view()).expect("assemble CIF");
4144
4145 for row in 0..2 {
4146 let total_rate = disease_rates[row] + death_rates[row];
4147 for time_idx in 0..times.len() {
4148 let failure = 1.0 - (-total_rate * times[time_idx]).exp();
4149 let expected_disease = disease_rates[row] / total_rate * failure;
4150 let expected_death = death_rates[row] / total_rate * failure;
4151 assert!((result.cif[0][[row, time_idx]] - expected_disease).abs() < 1e-12);
4152 assert!((result.cif[1][[row, time_idx]] - expected_death).abs() < 1e-12);
4153 assert!(
4154 (result.cif[0][[row, time_idx]]
4155 + result.cif[1][[row, time_idx]]
4156 + result.overall_survival[[row, time_idx]]
4157 - 1.0)
4158 .abs()
4159 < 1e-12
4160 );
4161 }
4162 }
4163 }
4164
4165 #[test]
4166 fn competing_risks_cif_rejects_nonmonotone_hazards() {
4167 let times = array![0.0, 1.0, 2.0];
4168 let cumulative = Array3::from_shape_vec((1, 1, 3), vec![0.0, 0.2, 0.1]).expect("shape");
4169 let err = assemble_competing_risks_cif(times.view(), cumulative.view())
4170 .expect_err("nonmonotone cumulative hazard should be rejected");
4171 assert!(matches!(err, SurvivalError::NonMonotoneCumulativeHazard));
4172 }
4173
4174 #[test]
4175 fn competing_risks_cif_plateaus_and_three_causes_conserve_probability() {
4176 let times = array![0.0, 1.0, 3.0, 7.0, 12.0];
4177 let cumulative = Array3::from_shape_vec(
4178 (3, 2, 5),
4179 vec![
4180 0.0, 0.2, 0.2, 0.5, 1.1, 0.0, 0.0, 0.4, 0.4, 0.9, 0.0, 0.1, 0.3, 0.3, 0.7, 0.0, 0.2, 0.2, 0.8, 0.8, 0.0, 0.0, 0.2, 0.6, 0.6, 0.0, 0.1, 0.5, 0.5, 1.5,
4184 ],
4185 )
4186 .expect("shape");
4187
4188 let result =
4189 assemble_competing_risks_cif(times.view(), cumulative.view()).expect("assemble CIF");
4190
4191 for row in 0..2 {
4192 for time_idx in 0..times.len() {
4193 let total_cif = result.cif[0][[row, time_idx]]
4194 + result.cif[1][[row, time_idx]]
4195 + result.cif[2][[row, time_idx]];
4196 assert!(
4197 (total_cif + result.overall_survival[[row, time_idx]] - 1.0).abs() < 1e-12,
4198 "probability mass mismatch at row={row}, time_idx={time_idx}"
4199 );
4200 assert!((0.0..=1.0).contains(&result.overall_survival[[row, time_idx]]));
4201 for cause in 0..3 {
4202 assert!((0.0..=1.0).contains(&result.cif[cause][[row, time_idx]]));
4203 if time_idx > 0 {
4204 assert!(
4205 result.cif[cause][[row, time_idx]] + 1e-12
4206 >= result.cif[cause][[row, time_idx - 1]],
4207 "CIF decreased for cause={cause}, row={row}, time_idx={time_idx}"
4208 );
4209 }
4210 }
4211 }
4212 }
4213
4214 assert_eq!(result.cif[0][[0, 1]], result.cif[0][[0, 2]]);
4217 assert_eq!(result.cif[0][[1, 2]], result.cif[0][[1, 3]]);
4220 assert_eq!(result.cif[2][[1, 2]], result.cif[2][[1, 3]]);
4221 }
4222
4223 #[test]
4224 fn competing_risks_cif_rejects_bad_time_grids_and_nonfinite_hazards() {
4225 let cumulative = Array3::zeros((2, 1, 2));
4226
4227 for times in [array![0.0, 0.0], array![1.0, 0.5], array![-1.0, 1.0]] {
4228 let err = assemble_competing_risks_cif(times.view(), cumulative.view())
4229 .expect_err("bad time grid should be rejected");
4230 assert!(matches!(err, SurvivalError::InvalidTimeGrid));
4231 }
4232
4233 let times = array![0.0, 1.0];
4234 let nonfinite = Array3::from_shape_vec((1, 1, 2), vec![0.0, f64::NAN]).expect("shape");
4235 let err = assemble_competing_risks_cif(times.view(), nonfinite.view())
4236 .expect_err("nonfinite hazard should be rejected");
4237 assert!(matches!(err, SurvivalError::NonFiniteInput));
4238 }
4239
4240 #[test]
4241 fn competing_risks_cif_extreme_hazards_remain_bounded() {
4242 let times = array![0.0, 1.0, 2.0];
4243 let cumulative =
4244 Array3::from_shape_vec((2, 1, 3), vec![0.0, 500.0, 1000.0, 0.0, 250.0, 1000.0])
4245 .expect("shape");
4246
4247 let result =
4248 assemble_competing_risks_cif(times.view(), cumulative.view()).expect("assemble CIF");
4249
4250 for value in result
4251 .cif
4252 .iter()
4253 .flat_map(|m| m.iter())
4254 .chain(result.overall_survival.iter())
4255 {
4256 assert!(value.is_finite());
4257 assert!((0.0..=1.0).contains(value));
4258 }
4259 assert!((result.cif[0][[0, 2]] + result.cif[1][[0, 2]] - 1.0).abs() < 1e-12);
4260 assert_eq!(result.overall_survival[[0, 2]], 0.0);
4261 }
4262
4263 fn toy_penalties() -> PenaltyBlocks {
4264 let s = array![[2.0, 0.5], [0.5, 3.0]];
4265 PenaltyBlocks::new(vec![PenaltyBlock {
4266 matrix: s,
4267 lambda: 1.7,
4268 range: 1..3,
4269 nullspace_dim: 0,
4270 }])
4271 }
4272
4273 fn survival_inputs<'a>(
4274 age_entry: &'a Array1<f64>,
4275 age_exit: &'a Array1<f64>,
4276 event_target: &'a Array1<u8>,
4277 event_competing: &'a Array1<u8>,
4278 sampleweight: &'a Array1<f64>,
4279 x_entry: &'a Array2<f64>,
4280 x_exit: &'a Array2<f64>,
4281 x_derivative: &'a Array2<f64>,
4282 ) -> SurvivalEngineInputs<'a> {
4283 SurvivalEngineInputs {
4284 age_entry: age_entry.view(),
4285 age_exit: age_exit.view(),
4286 event_target: event_target.view(),
4287 event_competing: event_competing.view(),
4288 sampleweight: sampleweight.view(),
4289 x_entry: x_entry.view(),
4290 x_exit: x_exit.view(),
4291 x_derivative: x_derivative.view(),
4292 monotonicity_constraint_rows: None,
4293 monotonicity_constraint_offsets: None,
4294 }
4295 }
4296
4297 fn survival_model(
4298 inputs: SurvivalEngineInputs<'_>,
4299 penalties: PenaltyBlocks,
4300 monotonicity: SurvivalMonotonicityPenalty,
4301 spec: SurvivalSpec,
4302 ) -> Result<WorkingModelSurvival, SurvivalError> {
4303 WorkingModelSurvival::from_engine_inputs(inputs, penalties, monotonicity, spec)
4304 }
4305
4306 fn survival_model_with_offsets(
4307 inputs: SurvivalEngineInputs<'_>,
4308 offsets: Option<SurvivalBaselineOffsets<'_>>,
4309 penalties: PenaltyBlocks,
4310 monotonicity: SurvivalMonotonicityPenalty,
4311 spec: SurvivalSpec,
4312 ) -> Result<WorkingModelSurvival, SurvivalError> {
4313 WorkingModelSurvival::from_engine_inputswith_offsets(
4314 inputs,
4315 offsets,
4316 penalties,
4317 monotonicity,
4318 spec,
4319 )
4320 }
4321
4322 #[test]
4323 fn penaltyhessian_matchesgradient_jacobian() {
4324 let penalties = toy_penalties();
4325 let beta = array![10.0, -0.3, 1.2, 7.0];
4326
4327 let grad = penalties.gradient(&beta);
4328 let h = penalties.hessian(beta.len());
4329 let b_block = beta.slice(s![1..3]).to_owned();
4330 let expected = 1.7 * array![[2.0, 0.5], [0.5, 3.0]].dot(&b_block);
4331
4332 assert!((grad[1] - expected[0]).abs() < 1e-12);
4333 assert!((grad[2] - expected[1]).abs() < 1e-12);
4334 assert!((h[[1, 1]] - 1.7 * 2.0).abs() < 1e-12);
4335 assert!((h[[1, 2]] - 1.7 * 0.5).abs() < 1e-12);
4336 assert!((h[[2, 1]] - 1.7 * 0.5).abs() < 1e-12);
4337 assert!((h[[2, 2]] - 1.7 * 3.0).abs() < 1e-12);
4338 }
4339
4340 #[test]
4341 fn penaltygradient_matches_deviance_finite_difference() {
4342 let penalties = toy_penalties();
4343 let beta = array![10.0, -0.3, 1.2, 7.0];
4344 let grad = penalties.gradient(&beta);
4345 let eps = 1e-7;
4346
4347 for idx in 0..beta.len() {
4348 let mut plus = beta.clone();
4349 let mut minus = beta.clone();
4350 plus[idx] += eps;
4351 minus[idx] -= eps;
4352 let fd = (penalties.deviance(&plus) - penalties.deviance(&minus)) / (2.0 * eps);
4353 assert_eq!(
4354 grad[idx].signum(),
4355 fd.signum(),
4356 "gradient/deviance sign mismatch at idx={idx}: grad={} fd={fd}",
4357 grad[idx]
4358 );
4359 assert!(
4360 (grad[idx] - fd).abs() < 1e-6,
4361 "gradient/deviance mismatch at idx={idx}: grad={} fd={fd}",
4362 grad[idx]
4363 );
4364 }
4365 }
4366
4367 #[test]
4368 fn zero_offsets_match_default_survival_state() {
4369 let age_entry = array![1.0_f64, 2.0_f64];
4370 let age_exit = array![2.0_f64, 3.5_f64];
4371 let event_target = array![1u8, 0u8];
4372 let event_competing = array![0u8, 0u8];
4373 let sampleweight = array![1.0, 1.0];
4374 let x_entry = array![[1.0, age_entry[0].ln()], [1.0, age_entry[1].ln()]];
4375 let x_exit = array![[1.0, age_exit[0].ln()], [1.0, age_exit[1].ln()]];
4376 let x_derivative = array![[0.0, 1.0 / age_exit[0]], [0.0, 1.0 / age_exit[1]]];
4377 let penalties = PenaltyBlocks::new(Vec::new());
4378 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4379 let beta = array![-1.0, 0.8];
4380
4381 let base = survival_model(
4382 survival_inputs(
4383 &age_entry,
4384 &age_exit,
4385 &event_target,
4386 &event_competing,
4387 &sampleweight,
4388 &x_entry,
4389 &x_exit,
4390 &x_derivative,
4391 ),
4392 penalties.clone(),
4393 mono,
4394 SurvivalSpec::Net,
4395 )
4396 .expect("construct base survival model");
4397
4398 let zero_offsets = survival_model_with_offsets(
4399 survival_inputs(
4400 &age_entry,
4401 &age_exit,
4402 &event_target,
4403 &event_competing,
4404 &sampleweight,
4405 &x_entry,
4406 &x_exit,
4407 &x_derivative,
4408 ),
4409 Some(SurvivalBaselineOffsets {
4410 eta_entry: array![0.0, 0.0].view(),
4411 eta_exit: array![0.0, 0.0].view(),
4412 derivative_exit: array![0.0, 0.0].view(),
4413 }),
4414 penalties,
4415 mono,
4416 SurvivalSpec::Net,
4417 )
4418 .expect("construct offset survival model");
4419
4420 let state_base = base.update_state(&beta).expect("base state");
4421 let statezero = zero_offsets.update_state(&beta).expect("zero-offset state");
4422 assert!((state_base.deviance - statezero.deviance).abs() < 1e-12);
4423 assert!(
4424 state_base
4425 .gradient
4426 .iter()
4427 .zip(statezero.gradient.iter())
4428 .all(|(a, b)| (a - b).abs() < 1e-12)
4429 );
4430 }
4431
4432 #[test]
4433 fn competing_risk_cause_labels_collapse_to_pooled_baseline_indicator() {
4434 let age_entry = array![0.0_f64, 0.0, 0.0, 0.0];
4448 let age_exit = array![1.2_f64, 0.8, 2.1, 1.5];
4449 let cause_labels = array![0u8, 1u8, 2u8, 0u8];
4451 let event_competing = Array1::<u8>::zeros(cause_labels.len());
4452 let sampleweight = array![1.0_f64, 1.0, 1.0, 1.0];
4453 let x_entry = array![
4454 [1.0, age_entry[0].max(1e-8).ln()],
4455 [1.0, age_entry[1].max(1e-8).ln()],
4456 [1.0, age_entry[2].max(1e-8).ln()],
4457 [1.0, age_entry[3].max(1e-8).ln()],
4458 ];
4459 let x_exit = array![
4460 [1.0, age_exit[0].ln()],
4461 [1.0, age_exit[1].ln()],
4462 [1.0, age_exit[2].ln()],
4463 [1.0, age_exit[3].ln()],
4464 ];
4465 let x_derivative = array![
4466 [0.0, 1.0 / age_exit[0]],
4467 [0.0, 1.0 / age_exit[1]],
4468 [0.0, 1.0 / age_exit[2]],
4469 [0.0, 1.0 / age_exit[3]],
4470 ];
4471 let penalties = PenaltyBlocks::new(Vec::new());
4472 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4473
4474 let raw = survival_model(
4479 survival_inputs(
4480 &age_entry,
4481 &age_exit,
4482 &cause_labels,
4483 &event_competing,
4484 &sampleweight,
4485 &x_entry,
4486 &x_exit,
4487 &x_derivative,
4488 ),
4489 penalties.clone(),
4490 mono,
4491 SurvivalSpec::Net,
4492 );
4493 assert!(
4494 matches!(raw, Err(SurvivalError::EventCodeInvalid { .. })),
4495 "raw competing-risks cause labels must be rejected as EventCodeInvalid (not NonFiniteInput), got {raw:?}"
4496 );
4497
4498 let any_event = pooled_any_event_indicator(cause_labels.view());
4501 assert_eq!(any_event, array![0u8, 1u8, 1u8, 0u8]);
4502 assert_eq!(
4504 cause_specific_event_indicator(cause_labels.view(), 1),
4505 array![0u8, 1u8, 0u8, 0u8]
4506 );
4507 assert_eq!(
4508 cause_specific_event_indicator(cause_labels.view(), 2),
4509 array![0u8, 0u8, 1u8, 0u8]
4510 );
4511 let model = survival_model(
4512 survival_inputs(
4513 &age_entry,
4514 &age_exit,
4515 &any_event,
4516 &event_competing,
4517 &sampleweight,
4518 &x_entry,
4519 &x_exit,
4520 &x_derivative,
4521 ),
4522 penalties,
4523 mono,
4524 SurvivalSpec::Net,
4525 )
4526 .expect("pooled any-event baseline model must construct from competing-risks data");
4527
4528 let beta = array![-1.0_f64, 0.8];
4531 let state = model.update_state(&beta).expect("pooled baseline state");
4532 assert!(
4533 state.deviance.is_finite(),
4534 "pooled baseline deviance must be finite, got {}",
4535 state.deviance
4536 );
4537 assert!(
4538 state.gradient.iter().all(|g| g.is_finite()),
4539 "pooled baseline gradient must be finite"
4540 );
4541 }
4542
4543 #[test]
4544 fn offset_channel_residuals_match_central_fd_of_nll() {
4545 let age_entry = array![0.5_f64, 0.0, 0.3];
4550 let age_exit = array![1.4_f64, 1.0, 2.0];
4551 let event_target = array![1u8, 1u8, 0u8];
4552 let event_competing = array![0u8, 0u8, 0u8];
4553 let sampleweight = array![1.0_f64, 2.5, 0.7];
4554 let x_entry = array![
4555 [1.0, age_entry[0].ln()],
4556 [1.0, age_entry[1].max(1e-8).ln()],
4557 [1.0, age_entry[2].ln()]
4558 ];
4559 let x_exit = array![
4560 [1.0, age_exit[0].ln()],
4561 [1.0, age_exit[1].ln()],
4562 [1.0, age_exit[2].ln()]
4563 ];
4564 let x_derivative = array![
4565 [0.0, 1.0 / age_exit[0]],
4566 [0.0, 1.0 / age_exit[1]],
4567 [0.0, 1.0 / age_exit[2]]
4568 ];
4569 let o_entry = array![0.2_f64, 0.0, 0.1];
4572 let o_exit = array![0.4_f64, 0.5, 0.7];
4573 let o_deriv = array![0.3_f64, 0.8, 0.5];
4574 let penalties = PenaltyBlocks::new(Vec::new());
4575 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4576 let beta = array![-0.7_f64, 0.6];
4577
4578 let build = |o_e: &Array1<f64>, o_x: &Array1<f64>, o_d: &Array1<f64>| {
4579 survival_model_with_offsets(
4580 survival_inputs(
4581 &age_entry,
4582 &age_exit,
4583 &event_target,
4584 &event_competing,
4585 &sampleweight,
4586 &x_entry,
4587 &x_exit,
4588 &x_derivative,
4589 ),
4590 Some(SurvivalBaselineOffsets {
4591 eta_entry: o_e.view(),
4592 eta_exit: o_x.view(),
4593 derivative_exit: o_d.view(),
4594 }),
4595 penalties.clone(),
4596 mono,
4597 SurvivalSpec::Net,
4598 )
4599 .expect("model build")
4600 };
4601
4602 let base = build(&o_entry, &o_exit, &o_deriv);
4603 let resid = base
4604 .offset_channel_residuals(&beta)
4605 .expect("offset residuals");
4606 assert_eq!(resid.exit.len(), 3);
4607 assert_eq!(resid.entry.len(), 3);
4608 assert_eq!(resid.derivative.len(), 3);
4609
4610 let nll = |m: &WorkingModelSurvival| 0.5 * m.update_state(&beta).expect("state").deviance;
4613 let h = 1e-6;
4614
4615 assert_eq!(resid.entry[1], 0.0);
4619 assert_eq!(resid.derivative[2], 0.0);
4620
4621 for i in 0..3 {
4622 {
4624 let mut op = o_exit.clone();
4625 let mut om = o_exit.clone();
4626 op[i] += h;
4627 om[i] -= h;
4628 let fd = (nll(&build(&o_entry, &op, &o_deriv))
4629 - nll(&build(&o_entry, &om, &o_deriv)))
4630 / (2.0 * h);
4631 assert!(
4632 (resid.exit[i] - fd).abs() < 1e-6,
4633 "∂NLL/∂o_X[{i}]: analytic={:.6e} fd={:.6e}",
4634 resid.exit[i],
4635 fd
4636 );
4637 }
4638 {
4642 let mut op = o_entry.clone();
4643 let mut om = o_entry.clone();
4644 op[i] += h;
4645 om[i] -= h;
4646 let fd = (nll(&build(&op, &o_exit, &o_deriv))
4647 - nll(&build(&om, &o_exit, &o_deriv)))
4648 / (2.0 * h);
4649 assert!(
4650 (resid.entry[i] - fd).abs() < 1e-6,
4651 "∂NLL/∂o_E[{i}]: analytic={:.6e} fd={:.6e}",
4652 resid.entry[i],
4653 fd
4654 );
4655 }
4656 {
4658 let mut op = o_deriv.clone();
4659 let mut om = o_deriv.clone();
4660 op[i] += h;
4661 om[i] -= h;
4662 let fd = (nll(&build(&o_entry, &o_exit, &op))
4663 - nll(&build(&o_entry, &o_exit, &om)))
4664 / (2.0 * h);
4665 assert!(
4666 (resid.derivative[i] - fd).abs() < 1e-6,
4667 "∂NLL/∂o_D[{i}]: analytic={:.6e} fd={:.6e}",
4668 resid.derivative[i],
4669 fd
4670 );
4671 }
4672 }
4673 }
4674
4675 #[test]
4676 fn offset_channel_residuals_respect_zero_sampleweight() {
4677 let age_entry = array![1.0_f64, 2.0];
4678 let age_exit = array![2.0_f64, 3.5];
4679 let event_target = array![1u8, 1u8];
4680 let event_competing = array![0u8, 0u8];
4681 let sampleweight = array![0.0_f64, 1.2]; let x_entry = array![[1.0, age_entry[0].ln()], [1.0, age_entry[1].ln()]];
4683 let x_exit = array![[1.0, age_exit[0].ln()], [1.0, age_exit[1].ln()]];
4684 let x_derivative = array![[0.0, 1.0 / age_exit[0]], [0.0, 1.0 / age_exit[1]]];
4685 let penalties = PenaltyBlocks::new(Vec::new());
4686 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4687 let beta = array![-1.0_f64, 0.8];
4688
4689 let model = survival_model_with_offsets(
4690 survival_inputs(
4691 &age_entry,
4692 &age_exit,
4693 &event_target,
4694 &event_competing,
4695 &sampleweight,
4696 &x_entry,
4697 &x_exit,
4698 &x_derivative,
4699 ),
4700 Some(SurvivalBaselineOffsets {
4701 eta_entry: array![0.0_f64, 0.1].view(),
4702 eta_exit: array![0.0_f64, 0.2].view(),
4703 derivative_exit: array![0.0_f64, 0.1].view(),
4704 }),
4705 penalties,
4706 mono,
4707 SurvivalSpec::Net,
4708 )
4709 .expect("model");
4710 let r = model.offset_channel_residuals(&beta).expect("resid");
4711 assert_eq!(r.exit[0], 0.0);
4713 assert_eq!(r.entry[0], 0.0);
4714 assert_eq!(r.derivative[0], 0.0);
4715 assert!(r.exit[1] != 0.0);
4717 }
4718
4719 #[test]
4720 fn offset_channel_residuals_reject_beta_dim_mismatch() {
4721 let age_entry = array![1.0_f64];
4722 let age_exit = array![2.0_f64];
4723 let event_target = array![1u8];
4724 let event_competing = array![0u8];
4725 let sampleweight = array![1.0_f64];
4726 let x_entry = array![[1.0, 0.0]];
4727 let x_exit = array![[1.0, 0.7]];
4728 let x_derivative = array![[0.0, 0.5]];
4729 let penalties = PenaltyBlocks::new(Vec::new());
4730 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4731 let model = survival_model(
4732 survival_inputs(
4733 &age_entry,
4734 &age_exit,
4735 &event_target,
4736 &event_competing,
4737 &sampleweight,
4738 &x_entry,
4739 &x_exit,
4740 &x_derivative,
4741 ),
4742 penalties,
4743 mono,
4744 SurvivalSpec::Net,
4745 )
4746 .expect("model");
4747 let bad_beta = array![0.0_f64]; let err = model
4749 .offset_channel_residuals(&bad_beta)
4750 .expect_err("mismatch must error");
4751 match err {
4752 EstimationError::InvalidInput(msg) => {
4753 assert!(msg.contains("beta dimension mismatch"), "msg={msg}")
4754 }
4755 other => panic!("expected InvalidInput, got {other:?}"),
4756 }
4757 }
4758
4759 #[test]
4760 fn crudespec_is_rejected_by_one_hazard_engine() {
4761 let age_entry = array![1.0_f64];
4762 let age_exit = array![2.0_f64];
4763 let event_target = array![0u8];
4764 let event_competing = array![1u8];
4765 let sampleweight = array![1.0];
4766 let x_entry = array![[0.1]];
4767 let x_exit = array![[0.4]];
4768 let x_derivative = array![[1.0]];
4769 let penalties = PenaltyBlocks::new(Vec::new());
4770 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4771
4772 let err = survival_model(
4773 survival_inputs(
4774 &age_entry,
4775 &age_exit,
4776 &event_target,
4777 &event_competing,
4778 &sampleweight,
4779 &x_entry,
4780 &x_exit,
4781 &x_derivative,
4782 ),
4783 penalties,
4784 mono,
4785 SurvivalSpec::Crude,
4786 )
4787 .expect_err("crude fitting should be rejected by the one-hazard engine");
4788 assert!(matches!(err, SurvivalError::UnsupportedSpec("crude")));
4789 }
4790
4791 #[test]
4792 fn nonstructural_models_require_explicit_monotonicity_collocation() {
4793 let age_entry = array![1.0_f64, 1.5_f64];
4794 let age_exit = array![2.0_f64, 2.5_f64];
4795 let event_target = array![0u8, 0u8];
4796 let event_competing = array![0u8, 1u8];
4797 let sampleweight = array![1.0, 1.0];
4798 let x_entry = array![[0.2], [0.1]];
4799 let x_exit = array![[0.3], [0.2]];
4800 let x_derivative = array![[1.0], [1.0]];
4801
4802 let model = survival_model(
4803 survival_inputs(
4804 &age_entry,
4805 &age_exit,
4806 &event_target,
4807 &event_competing,
4808 &sampleweight,
4809 &x_entry,
4810 &x_exit,
4811 &x_derivative,
4812 ),
4813 PenaltyBlocks::new(Vec::new()),
4814 SurvivalMonotonicityPenalty { tolerance: 0.0 },
4815 SurvivalSpec::Net,
4816 )
4817 .expect("construct censored survival model");
4818
4819 assert!(
4820 model.monotonicity_linear_constraints().is_none(),
4821 "non-structural survival models must not fabricate rowwise monotonicity constraints"
4822 );
4823 }
4824
4825 #[test]
4826 fn decreasing_interval_is_rejectedwithout_target_events() {
4827 let age_entry = array![1.0_f64];
4828 let age_exit = array![2.0_f64];
4829 let event_target = array![0u8];
4830 let event_competing = array![0u8];
4831 let sampleweight = array![1.0];
4832 let x_entry = array![[0.5]];
4833 let x_exit = array![[0.0]];
4834 let x_derivative = array![[1.0]];
4835
4836 let model = survival_model(
4837 survival_inputs(
4838 &age_entry,
4839 &age_exit,
4840 &event_target,
4841 &event_competing,
4842 &sampleweight,
4843 &x_entry,
4844 &x_exit,
4845 &x_derivative,
4846 ),
4847 PenaltyBlocks::new(Vec::new()),
4848 SurvivalMonotonicityPenalty { tolerance: 0.0 },
4849 SurvivalSpec::Net,
4850 )
4851 .expect("construct censored survival model");
4852
4853 let err = model
4854 .update_state(&array![1.0])
4855 .expect_err("decreasing cumulative hazard increment should be rejected");
4856 assert!(
4857 err.to_string().contains("cumulative hazard decreased"),
4858 "unexpected error: {err}"
4859 );
4860 }
4861
4862 fn smooth_crude_risk(beta_d: f64, beta_m: f64) -> CrudeRiskResult {
4863 calculate_crude_risk_quadrature(
4864 0.0,
4865 1.0,
4866 &[0.0, 1.0],
4867 beta_d.exp(),
4868 beta_m.exp(),
4869 array![1.0].view(),
4870 array![1.0].view(),
4871 |u, design_d, deriv_d, design_m| {
4872 let cumulative_d = beta_d.exp() * (1.0 + 0.2 * u);
4873 let cumulative_m = beta_m.exp() * (1.0 + 0.1 * u);
4874 let inst_hazard_d = 0.2 * beta_d.exp();
4875 design_d[0] = 1.0;
4876 deriv_d[0] = 0.0;
4879 design_m[0] = 1.0;
4880 Ok((inst_hazard_d, cumulative_d, cumulative_m))
4881 },
4882 )
4883 .expect("smooth crude-risk quadrature should succeed")
4884 }
4885
4886 #[test]
4887 fn crude_riskgradient_matches_monotoneobjective() {
4888 let beta_d = -0.2_f64;
4889 let beta_m = -0.5_f64;
4890 let result = smooth_crude_risk(beta_d, beta_m);
4891 let eps = 1e-6;
4892
4893 let fd_d = (smooth_crude_risk(beta_d + eps, beta_m).risk
4894 - smooth_crude_risk(beta_d - eps, beta_m).risk)
4895 / (2.0 * eps);
4896 let fd_m = (smooth_crude_risk(beta_d, beta_m + eps).risk
4897 - smooth_crude_risk(beta_d, beta_m - eps).risk)
4898 / (2.0 * eps);
4899
4900 assert!(
4901 (result.diseasegradient[0] - fd_d).abs() < 1e-5,
4902 "disease gradient mismatch for monotone crude risk: analytic={} fd={fd_d}",
4903 result.diseasegradient[0]
4904 );
4905 assert!(
4906 (result.mortalitygradient[0] - fd_m).abs() < 1e-5,
4907 "mortality gradient mismatch for monotone crude risk: analytic={} fd={fd_m}",
4908 result.mortalitygradient[0]
4909 );
4910 }
4911
4912 #[test]
4913 fn survival_working_state_is_ridge_free() {
4914 let age_entry = array![1.0_f64, 2.0_f64];
4915 let age_exit = array![2.0_f64, 3.5_f64];
4916 let event_target = array![1u8, 0u8];
4917 let event_competing = array![0u8, 0u8];
4918 let sampleweight = array![1.0, 1.0];
4919 let x_entry = array![[1.0, age_entry[0].ln()], [1.0, age_entry[1].ln()]];
4920 let x_exit = array![[1.0, age_exit[0].ln()], [1.0, age_exit[1].ln()]];
4921 let x_derivative = array![[0.0, 1.0 / age_exit[0]], [0.0, 1.0 / age_exit[1]]];
4922 let penalties = PenaltyBlocks::new(vec![PenaltyBlock {
4923 matrix: array![[2.0]],
4924 lambda: 1.7,
4925 range: 1..2,
4926 nullspace_dim: 0,
4927 }]);
4928 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4929 let beta = array![-1.2, 0.4];
4930
4931 let model = survival_model(
4932 survival_inputs(
4933 &age_entry,
4934 &age_exit,
4935 &event_target,
4936 &event_competing,
4937 &sampleweight,
4938 &x_entry,
4939 &x_exit,
4940 &x_derivative,
4941 ),
4942 penalties.clone(),
4943 mono,
4944 SurvivalSpec::Net,
4945 )
4946 .expect("construct survival model");
4947
4948 let state = model.update_state(&beta).expect("survival state");
4949 assert_eq!(
4950 state.ridge_used, 0.0,
4951 "survival objective must not fuse a coefficient ridge"
4952 );
4953 let expected_penalty = 2.0 * penalties.deviance(&beta);
4955 assert!(
4956 (state.penalty_term - expected_penalty).abs() < 1e-12,
4957 "penalty_term mismatch: state={} expected={}",
4958 state.penalty_term,
4959 expected_penalty
4960 );
4961 }
4962
4963 #[test]
4964 fn negative_penalty_lambda_is_rejected() {
4965 let age_entry = array![1.0_f64];
4966 let age_exit = array![2.0_f64];
4967 let event_target = array![1u8];
4968 let event_competing = array![0u8];
4969 let sampleweight = array![1.0];
4970 let x_entry = array![[1.0, 0.0]];
4971 let x_exit = array![[1.0, 0.5]];
4972 let x_derivative = array![[0.0, 1.0]];
4973 let penalties = PenaltyBlocks::new(vec![PenaltyBlock {
4974 matrix: array![[1.0]],
4975 lambda: -0.1,
4976 range: 1..2,
4977 nullspace_dim: 0,
4978 }]);
4979
4980 let err = survival_model(
4981 survival_inputs(
4982 &age_entry,
4983 &age_exit,
4984 &event_target,
4985 &event_competing,
4986 &sampleweight,
4987 &x_entry,
4988 &x_exit,
4989 &x_derivative,
4990 ),
4991 penalties,
4992 SurvivalMonotonicityPenalty { tolerance: 0.0 },
4993 SurvivalSpec::Net,
4994 )
4995 .expect_err("negative lambda must be rejected");
4996
4997 assert!(matches!(err, SurvivalError::NonFiniteInput));
4998 }
4999
5000 #[test]
5001 fn penalty_block_range_and_shapemust_match_coefficients() {
5002 let age_entry = array![1.0_f64];
5003 let age_exit = array![2.0_f64];
5004 let event_target = array![1u8];
5005 let event_competing = array![0u8];
5006 let sampleweight = array![1.0];
5007 let x_entry = array![[1.0, 0.0]];
5008 let x_exit = array![[1.0, 0.5]];
5009 let x_derivative = array![[0.0, 1.0]];
5010 let penalties = PenaltyBlocks::new(vec![PenaltyBlock {
5011 matrix: array![[1.0]],
5012 lambda: 0.5,
5013 range: 0..2,
5014 nullspace_dim: 0,
5015 }]);
5016
5017 let err = survival_model(
5018 survival_inputs(
5019 &age_entry,
5020 &age_exit,
5021 &event_target,
5022 &event_competing,
5023 &sampleweight,
5024 &x_entry,
5025 &x_exit,
5026 &x_derivative,
5027 ),
5028 penalties,
5029 SurvivalMonotonicityPenalty { tolerance: 1e-8 },
5030 SurvivalSpec::Net,
5031 )
5032 .expect_err("penalty block geometry must match coefficient support");
5033
5034 assert!(matches!(err, SurvivalError::DimensionMismatch));
5035 }
5036
5037 #[test]
5038 fn survivalgradient_matches_ridge_free_objective_fd() {
5039 let age_entry = array![1.0_f64, 2.0_f64, 3.0_f64];
5040 let age_exit = array![2.0_f64, 3.5_f64, 4.0_f64];
5041 let event_target = array![1u8, 0u8, 1u8];
5042 let event_competing = array![0u8, 0u8, 0u8];
5043 let sampleweight = array![1.0, 1.0, 1.0];
5044 let x_entry = array![
5045 [1.0, age_entry[0].ln()],
5046 [1.0, age_entry[1].ln()],
5047 [1.0, age_entry[2].ln()]
5048 ];
5049 let x_exit = array![
5050 [1.0, age_exit[0].ln()],
5051 [1.0, age_exit[1].ln()],
5052 [1.0, age_exit[2].ln()]
5053 ];
5054 let x_derivative = array![
5055 [0.0, 1.0 / age_exit[0]],
5056 [0.0, 1.0 / age_exit[1]],
5057 [0.0, 1.0 / age_exit[2]]
5058 ];
5059 let penalties = PenaltyBlocks::new(Vec::new());
5060 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
5061 let beta = array![-1.0, 3.0];
5062
5063 let model = survival_model(
5064 survival_inputs(
5065 &age_entry,
5066 &age_exit,
5067 &event_target,
5068 &event_competing,
5069 &sampleweight,
5070 &x_entry,
5071 &x_exit,
5072 &x_derivative,
5073 ),
5074 penalties,
5075 mono,
5076 SurvivalSpec::Net,
5077 )
5078 .expect("construct survival model");
5079
5080 let state = model.update_state(&beta).expect("state at beta");
5081 let eps = 1e-7;
5082 for j in 0..beta.len() {
5083 let mut plus = beta.clone();
5084 let mut minus = beta.clone();
5085 plus[j] += eps;
5086 minus[j] -= eps;
5087 let state_plus = model.update_state(&plus).expect("state at beta + eps");
5088 let state_minus = model.update_state(&minus).expect("state at beta - eps");
5089 let obj_plus = 0.5 * (state_plus.deviance + state_plus.penalty_term);
5090 let obj_minus = 0.5 * (state_minus.deviance + state_minus.penalty_term);
5091 let fd = (obj_plus - obj_minus) / (2.0 * eps);
5092 assert_eq!(
5093 state.gradient[j].signum(),
5094 fd.signum(),
5095 "objective/gradient sign mismatch at j={j}: grad={} fd={fd}",
5096 state.gradient[j]
5097 );
5098 assert!(
5099 (state.gradient[j] - fd).abs() < 1e-5,
5100 "objective/gradient mismatch at j={j}: grad={} fd={fd}",
5101 state.gradient[j]
5102 );
5103 }
5104 }
5105
5106 fn laml_fd_test_model(lambda: f64) -> WorkingModelSurvival {
5107 let age_entry: Array1<f64> = Array1::from(vec![
5114 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 32.0, 37.0, 42.0, 47.0, 52.0, 57.0, 62.0,
5115 34.0, 39.0, 44.0, 49.0, 54.0, 59.0,
5116 ]);
5117 let age_exit: Array1<f64> = Array1::from(vec![
5118 45.0, 48.0, 55.0, 58.0, 62.0, 66.0, 68.0, 47.0, 52.0, 53.0, 55.0, 60.0, 63.0, 70.0,
5119 48.0, 51.0, 58.0, 62.0, 66.0, 69.0,
5120 ]);
5121 let event_target = Array1::from(vec![
5122 1u8, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0,
5123 ]);
5124 let event_competing = Array1::<u8>::zeros(age_entry.len());
5125 let sampleweight = Array1::from_elem(age_entry.len(), 1.0_f64);
5126 let n = age_entry.len();
5127 let ln_age_mean: f64 = {
5128 let mut sum = 0.0;
5129 for i in 0..n {
5130 sum += age_entry[i].ln() + age_exit[i].ln();
5131 }
5132 sum / (2.0 * n as f64)
5133 };
5134 let mut x_entry = Array2::<f64>::zeros((n, 2));
5135 let mut x_exit = Array2::<f64>::zeros((n, 2));
5136 let mut x_derivative = Array2::<f64>::zeros((n, 2));
5137 for i in 0..n {
5138 x_entry[[i, 0]] = 1.0;
5139 x_exit[[i, 0]] = 1.0;
5140 x_entry[[i, 1]] = age_entry[i].ln() - ln_age_mean;
5141 x_exit[[i, 1]] = age_exit[i].ln() - ln_age_mean;
5142 x_derivative[[i, 0]] = 0.0;
5143 x_derivative[[i, 1]] = 1.0 / age_exit[i];
5144 }
5145 let penalties = PenaltyBlocks::new(vec![
5146 PenaltyBlock {
5147 matrix: array![[3.0]],
5148 lambda: 0.0,
5149 range: 0..1,
5150 nullspace_dim: 0,
5151 },
5152 PenaltyBlock {
5153 matrix: array![[2.5]],
5154 lambda,
5155 range: 1..2,
5156 nullspace_dim: 0,
5157 },
5158 ]);
5159 survival_model(
5160 survival_inputs(
5161 &age_entry,
5162 &age_exit,
5163 &event_target,
5164 &event_competing,
5165 &sampleweight,
5166 &x_entry,
5167 &x_exit,
5168 &x_derivative,
5169 ),
5170 penalties,
5171 SurvivalMonotonicityPenalty { tolerance: 1e-8 },
5172 SurvivalSpec::Net,
5173 )
5174 .expect("construct LAML FD survival model")
5175 }
5176
5177 fn laml_test_logdet_h(state: &WorkingState) -> f64 {
5178 use gam_problem::PseudoLogdetMode;
5179 use gam_solve::estimate::reml::reml_outer_engine::{
5180 DenseSpectralOperator, HessianFactorization,
5181 };
5182
5183 DenseSpectralOperator::from_symmetric_with_mode(
5184 &state.hessian.to_dense(),
5185 PseudoLogdetMode::PositiveDefinite,
5186 )
5187 .expect("positive-definite fitted survival Hessian")
5188 .logdet()
5189 }
5190
5191 #[test]
5197 fn survival_laml_mode_response_and_hessian_drift_match_finite_differences() {
5198 use gam_linalg::faer_ndarray::FaerCholesky;
5199 use gam_problem::PseudoLogdetMode;
5200 use gam_solve::estimate::reml::reml_outer_engine::{
5201 DenseSpectralOperator, HessianFactorization,
5202 };
5203
5204 const RHO: f64 = 4.0;
5205 const RHO_STEP: f64 = 1.0e-5;
5206 const BETA_STEP: f64 = 1.0e-5;
5207 const REL_TOL: f64 = 2.0e-4;
5208
5209 let beta0 = array![-2.5_f64, 1.0];
5210 let model = laml_fd_test_model(1.0);
5211 let (center_model, beta_hat) = model
5212 .reconverge_survival_inner_mode(&[RHO], &beta0)
5213 .expect("reconverge survival mode at rho=4");
5214 let center_state = center_model
5215 .update_state(&beta_hat)
5216 .expect("center survival state");
5217 let h_center = center_state.hessian.to_dense();
5218 let p = beta_hat.len();
5219
5220 let active: Vec<&PenaltyBlock> = center_model
5222 .penalties
5223 .blocks
5224 .iter()
5225 .filter(|block| block.lambda > 0.0)
5226 .collect();
5227 assert_eq!(active.len(), 1, "fixture must have one active penalty block");
5228 let block = active[0];
5229 let mut a = Array2::<f64>::zeros((p, p));
5230 for i in 0..block.matrix.nrows() {
5231 for j in 0..block.matrix.ncols() {
5232 a[[block.range.start + i, block.range.start + j]] =
5233 block.lambda * block.matrix[[i, j]];
5234 }
5235 }
5236
5237 let factor = h_center
5239 .cholesky(faer::Side::Lower)
5240 .expect("center Hessian Cholesky");
5241 let v = factor.solvevec(&a.dot(&beta_hat));
5242 let u = -&v;
5243 let (plus_model, beta_plus) = model
5244 .reconverge_survival_inner_mode(&[RHO + RHO_STEP], &beta_hat)
5245 .expect("rho-plus survival mode");
5246 let (minus_model, beta_minus) = model
5247 .reconverge_survival_inner_mode(&[RHO - RHO_STEP], &beta_hat)
5248 .expect("rho-minus survival mode");
5249 let beta_fd = (&beta_plus - &beta_minus) / (2.0 * RHO_STEP);
5250
5251 let correction = center_model
5254 .survival_hessian_derivative_correction(&beta_hat, &u)
5255 .expect("analytic survival Hessian correction");
5256 let beta_dir_plus = &beta_hat + &u.mapv(|value| BETA_STEP * value);
5257 let beta_dir_minus = &beta_hat - &u.mapv(|value| BETA_STEP * value);
5258 let h_beta_plus = center_model
5259 .update_state(&beta_dir_plus)
5260 .expect("beta-direction plus state")
5261 .hessian
5262 .to_dense();
5263 let h_beta_minus = center_model
5264 .update_state(&beta_dir_minus)
5265 .expect("beta-direction minus state")
5266 .hessian
5267 .to_dense();
5268 let correction_fd = (&h_beta_plus - &h_beta_minus) / (2.0 * BETA_STEP);
5269
5270 let state_plus = plus_model
5272 .update_state(&beta_plus)
5273 .expect("rho-plus state");
5274 let state_minus = minus_model
5275 .update_state(&beta_minus)
5276 .expect("rho-minus state");
5277 let total_fd =
5278 (state_plus.hessian.to_dense() - state_minus.hessian.to_dense()) / (2.0 * RHO_STEP);
5279 let total_analytic = &a + &correction;
5280 let total_sign_reversed = &a - &correction;
5281
5282 let relative_vector_error = |actual: &Array1<f64>, expected: &Array1<f64>| {
5283 let difference = actual
5284 .iter()
5285 .zip(expected.iter())
5286 .map(|(&lhs, &rhs)| (lhs - rhs) * (lhs - rhs))
5287 .sum::<f64>()
5288 .sqrt();
5289 let scale = expected.iter().map(|value| value * value).sum::<f64>().sqrt();
5290 difference / scale.max(1.0e-12)
5291 };
5292 let relative_matrix_error = |actual: &Array2<f64>, expected: &Array2<f64>| {
5293 let difference = actual
5294 .iter()
5295 .zip(expected.iter())
5296 .map(|(&lhs, &rhs)| (lhs - rhs) * (lhs - rhs))
5297 .sum::<f64>()
5298 .sqrt();
5299 let scale = expected.iter().map(|value| value * value).sum::<f64>().sqrt();
5300 difference / scale.max(1.0e-12)
5301 };
5302
5303 let mode_error = relative_vector_error(&u, &beta_fd);
5304 let correction_error = relative_matrix_error(&correction, &correction_fd);
5305 let correction_reversed_error = relative_matrix_error(&(-&correction), &correction_fd);
5306 let total_error = relative_matrix_error(&total_analytic, &total_fd);
5307 let total_reversed_error = relative_matrix_error(&total_sign_reversed, &total_fd);
5308
5309 let t1_plus = 0.5 * (state_plus.deviance + state_plus.penalty_term);
5313 let t1_minus = 0.5 * (state_minus.deviance + state_minus.penalty_term);
5314 let t1_fd = (t1_plus - t1_minus) / (2.0 * RHO_STEP);
5315 let t2_fd = 0.5
5316 * (laml_test_logdet_h(&state_plus) - laml_test_logdet_h(&state_minus))
5317 / (2.0 * RHO_STEP);
5318 let t3_fd = -0.5_f64;
5319
5320 let positive_hop = DenseSpectralOperator::from_symmetric_with_mode(
5325 &h_center,
5326 PseudoLogdetMode::PositiveDefinite,
5327 )
5328 .expect("positive-definite operator at the fitted survival mode");
5329 let half_trace_a = 0.5 * positive_hop.trace_hinv_product(&a);
5330 let half_trace_c = 0.5 * positive_hop.trace_hinv_product(&correction);
5331 let t1_analytic = 0.5 * beta_hat.dot(&a.dot(&beta_hat));
5332 let expected_gradient = t1_analytic + half_trace_a + half_trace_c - 0.5;
5333 let rho = array![RHO];
5334 let (_, public_gradient) = center_model
5335 .unified_lamlobjective_and_rhogradient(&beta_hat, ¢er_state, &rho)
5336 .expect("public survival LAML gradient at fitted mode");
5337
5338 eprintln!(
5339 "survival rho-chain decomposition: mode_error={mode_error:.6e} \
5340 correction_error={correction_error:.6e} correction_reversed_error={correction_reversed_error:.6e} \
5341 total_error={total_error:.6e} total_reversed_error={total_reversed_error:.6e} \
5342 t1_fd={t1_fd:+.12e} t2_fd={t2_fd:+.12e} t3_fd={t3_fd:+.12e} \
5343 t1_analytic={t1_analytic:+.12e} half_trace_a={half_trace_a:+.12e} \
5344 half_trace_c={half_trace_c:+.12e} expected_gradient={expected_gradient:+.12e} \
5345 public_gradient={:?} beta_analytic={:?} beta_fd={:?} correction={:?} correction_fd={:?} \
5346 total_analytic={:?} total_fd={:?}",
5347 public_gradient.to_vec(),
5348 u.to_vec(),
5349 beta_fd.to_vec(),
5350 correction,
5351 correction_fd,
5352 total_analytic,
5353 total_fd,
5354 );
5355
5356 let public_gradient_error = (public_gradient[0] - expected_gradient).abs()
5357 / expected_gradient.abs().max(1.0);
5358 assert!(
5359 mode_error <= REL_TOL
5360 && correction_error <= REL_TOL
5361 && total_error <= REL_TOL
5362 && public_gradient_error <= REL_TOL,
5363 "survival rho chain-rule identity failed: mode_error={mode_error:.6e}, \
5364 correction_error={correction_error:.6e} (sign-reversed={correction_reversed_error:.6e}), \
5365 total_error={total_error:.6e} (sign-reversed={total_reversed_error:.6e}), \
5366 public_gradient_error={public_gradient_error:.6e}"
5367 );
5368 }
5369
5370 fn laml_rail_fd_test_model(lambda0: f64, lambda1: f64) -> WorkingModelSurvival {
5377 let age_entry: Array1<f64> = Array1::from(vec![
5378 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 32.0, 37.0, 42.0, 47.0, 52.0, 57.0, 62.0,
5379 34.0, 39.0, 44.0, 49.0, 54.0, 59.0,
5380 ]);
5381 let age_exit: Array1<f64> = Array1::from(vec![
5382 45.0, 48.0, 55.0, 58.0, 62.0, 66.0, 68.0, 47.0, 52.0, 53.0, 55.0, 60.0, 63.0, 70.0,
5383 48.0, 51.0, 58.0, 62.0, 66.0, 69.0,
5384 ]);
5385 let event_target = Array1::from(vec![
5386 1u8, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0,
5387 ]);
5388 let event_competing = Array1::<u8>::zeros(age_entry.len());
5389 let sampleweight = Array1::from_elem(age_entry.len(), 1.0_f64);
5390 let n = age_entry.len();
5391 let ln_age_mean: f64 = {
5392 let mut sum = 0.0;
5393 for i in 0..n {
5394 sum += age_entry[i].ln() + age_exit[i].ln();
5395 }
5396 sum / (2.0 * n as f64)
5397 };
5398 let mut x_entry = Array2::<f64>::zeros((n, 2));
5399 let mut x_exit = Array2::<f64>::zeros((n, 2));
5400 let mut x_derivative = Array2::<f64>::zeros((n, 2));
5401 for i in 0..n {
5402 x_entry[[i, 0]] = 1.0;
5403 x_exit[[i, 0]] = 1.0;
5404 x_entry[[i, 1]] = age_entry[i].ln() - ln_age_mean;
5405 x_exit[[i, 1]] = age_exit[i].ln() - ln_age_mean;
5406 x_derivative[[i, 0]] = 0.0;
5407 x_derivative[[i, 1]] = 1.0 / age_exit[i];
5408 }
5409 let penalties = PenaltyBlocks::new(vec![
5410 PenaltyBlock {
5411 matrix: array![[3.0]],
5412 lambda: lambda0,
5413 range: 0..1,
5414 nullspace_dim: 0,
5415 },
5416 PenaltyBlock {
5417 matrix: array![[2.5]],
5418 lambda: lambda1,
5419 range: 1..2,
5420 nullspace_dim: 0,
5421 },
5422 ]);
5423 survival_model(
5424 survival_inputs(
5425 &age_entry,
5426 &age_exit,
5427 &event_target,
5428 &event_competing,
5429 &sampleweight,
5430 &x_entry,
5431 &x_exit,
5432 &x_derivative,
5433 ),
5434 penalties,
5435 SurvivalMonotonicityPenalty { tolerance: 1e-8 },
5436 SurvivalSpec::Net,
5437 )
5438 .expect("construct two-active-block rail LAML FD model")
5439 }
5440
5441 #[test]
5466 fn survival_laml_rho_gradient_matches_fd_at_the_over_smoothing_rail() {
5467 use gam_linalg::faer_ndarray::FaerEigh;
5468
5469 const RAIL_RHO0: f64 = 7.394829814011909;
5470 const FREE_RHO1: f64 = -2.45;
5471 const FD_STEP: f64 = 1.0e-4;
5472
5473 let beta0 = array![-2.5_f64, 1.0];
5474 let rho = array![RAIL_RHO0, FREE_RHO1];
5475 let model = laml_rail_fd_test_model(RAIL_RHO0.exp(), FREE_RHO1.exp());
5476
5477 let (value, analytic) = model
5479 .evaluate_survival_lamlcost_and_gradient(
5480 rho.as_slice().expect("contiguous rho"),
5481 &beta0,
5482 )
5483 .expect("rail LAML analytic value+gradient (inner solve must converge at the rail)");
5484
5485 let (rail_model, beta_hat) = model
5487 .reconverge_survival_inner_mode(rho.as_slice().expect("contiguous rho"), &beta0)
5488 .expect("reconverge inner mode at the rail");
5489 let state = rail_model
5490 .update_state(&beta_hat)
5491 .expect("inner state at the rail");
5492 let h_dense = state.hessian.to_dense();
5493 let (evals, _) = h_dense.eigh(faer::Side::Lower).expect("eigh at rail");
5494 let min_ev = evals.iter().copied().fold(f64::INFINITY, f64::min);
5495 let max_ev = evals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
5496 let cond = max_ev / min_ev.abs().max(f64::MIN_POSITIVE);
5497
5498 let term_values = |r: &Array1<f64>| -> (f64, f64, f64) {
5501 let (cand, b) = model
5502 .reconverge_survival_inner_mode(r.as_slice().expect("contiguous rho"), &beta0)
5503 .expect("reconverge for per-term FD");
5504 let st = cand.update_state(&b).expect("state for per-term FD");
5505 let t1 = 0.5 * (st.deviance + st.penalty_term);
5506 let t2 = 0.5 * laml_test_logdet_h(&st);
5507 let t3 = -0.5 * (r[0] + 3.0_f64.ln() + r[1] + 2.5_f64.ln());
5509 (t1, t2, t3)
5510 };
5511
5512 let mut fd = vec![0.0_f64; rho.len()];
5513 let mut fd_terms = vec![(0.0_f64, 0.0_f64, 0.0_f64); rho.len()];
5514 for j in 0..rho.len() {
5515 let mut plus = rho.clone();
5516 plus[j] += FD_STEP;
5517 let mut minus = rho.clone();
5518 minus[j] -= FD_STEP;
5519 let fp = model
5520 .evaluate_survival_lamlcost_and_gradient(
5521 plus.as_slice().expect("contiguous rho"),
5522 &beta0,
5523 )
5524 .expect("rail LAML f+ (probe ρ inner solve must converge)")
5525 .0;
5526 let fm = model
5527 .evaluate_survival_lamlcost_and_gradient(
5528 minus.as_slice().expect("contiguous rho"),
5529 &beta0,
5530 )
5531 .expect("rail LAML f- (probe ρ inner solve must converge)")
5532 .0;
5533 fd[j] = (fp - fm) / (2.0 * FD_STEP);
5534 let (p1, p2, p3) = term_values(&plus);
5535 let (m1, m2, m3) = term_values(&minus);
5536 fd_terms[j] = (
5537 (p1 - m1) / (2.0 * FD_STEP),
5538 (p2 - m2) / (2.0 * FD_STEP),
5539 (p3 - m3) / (2.0 * FD_STEP),
5540 );
5541 }
5542
5543 eprintln!(
5544 "[RAIL-FD] rho=[{RAIL_RHO0:.9}, {FREE_RHO1}] value={value:.9} inner_H min_ev={min_ev:.3e} max_ev={max_ev:.3e} cond={cond:.3e}"
5545 );
5546 let mut amplification = vec![0.0_f64; rho.len()];
5553 for j in 0..rho.len() {
5554 let (dt1, dt2, dt3) = fd_terms[j];
5555 amplification[j] = dt2.abs().max(dt3.abs()) / analytic[j].abs().max(f64::MIN_POSITIVE);
5556 let amp = amplification[j];
5557 eprintln!(
5558 "[RAIL-FD] rho[{j}]: analytic_total={:.6e} fd_total={:.6e} abs_err={:.3e} amplification={amp:.3e} | fd_terms: half_d(dev+pen)={dt1:.6e} half_dlogdetH={dt2:.6e} neg_half_dlogdetS={dt3:.6e}",
5559 analytic[j],
5560 fd[j],
5561 (analytic[j] - fd[j]).abs()
5562 );
5563 }
5564
5565 for j in 0..rho.len() {
5566 let tol = 1.0e-4 * (1.0 + analytic[j].abs().max(fd[j].abs()));
5567 assert!(
5568 (analytic[j] - fd[j]).abs() <= tol,
5569 "survival LAML ρ-gradient desync at coordinate {j} in the over-smoothing rail regime: \
5570 analytic={:.6e} fd={:.6e} (inner H cond={cond:.3e}); see the per-term [RAIL-FD] grid above",
5571 analytic[j],
5572 fd[j],
5573 );
5574 }
5575
5576 let max_amplification = amplification
5580 .iter()
5581 .copied()
5582 .fold(0.0_f64, f64::max);
5583 assert!(
5584 max_amplification.is_finite() && max_amplification <= 1.0e8,
5585 "rail logdet-gradient amplification ratio not finite/bounded: {max_amplification:.3e}"
5586 );
5587 }
5588
5589 #[test]
5596 fn survival_laml_rho_gradient_matches_fd_at_interior_rho() {
5597 const INTERIOR_RHO0: f64 = 0.3;
5598 const INTERIOR_RHO1: f64 = -0.5;
5599 const FD_STEP: f64 = 1.0e-4;
5600
5601 let beta0 = array![-2.5_f64, 1.0];
5602 let rho = array![INTERIOR_RHO0, INTERIOR_RHO1];
5603 let model = laml_rail_fd_test_model(INTERIOR_RHO0.exp(), INTERIOR_RHO1.exp());
5604 let (_value, analytic) = model
5605 .evaluate_survival_lamlcost_and_gradient(
5606 rho.as_slice().expect("contiguous rho"),
5607 &beta0,
5608 )
5609 .expect("interior LAML analytic value+gradient");
5610
5611 for j in 0..rho.len() {
5612 let mut plus = rho.clone();
5613 plus[j] += FD_STEP;
5614 let mut minus = rho.clone();
5615 minus[j] -= FD_STEP;
5616 let fp = model
5617 .evaluate_survival_lamlcost_and_gradient(
5618 plus.as_slice().expect("contiguous rho"),
5619 &beta0,
5620 )
5621 .expect("interior LAML f+")
5622 .0;
5623 let fm = model
5624 .evaluate_survival_lamlcost_and_gradient(
5625 minus.as_slice().expect("contiguous rho"),
5626 &beta0,
5627 )
5628 .expect("interior LAML f-")
5629 .0;
5630 let fd = (fp - fm) / (2.0 * FD_STEP);
5631 let tol = 1.0e-4 * (1.0 + analytic[j].abs().max(fd.abs()));
5632 assert!(
5633 (analytic[j] - fd).abs() <= tol,
5634 "interior survival LAML ρ-gradient mismatch at coordinate {j}: \
5635 analytic={:.6e} fd={:.6e}",
5636 analytic[j],
5637 fd,
5638 );
5639 }
5640 }
5641
5642 #[test]
5643 fn survival_solver_damping_converges_undamped_objective() {
5644 let rho = -0.35_f64;
5645 let model = laml_fd_test_model(rho.exp());
5646 let beta0 = array![-2.5_f64, 1.0];
5647 let (converged_model, beta) = model
5648 .reconverge_survival_inner_mode(&[rho], &beta0)
5649 .expect("converge survival mode with solver-only damping");
5650 let state = converged_model
5651 .update_state(&beta)
5652 .expect("evaluate undamped objective at converged mode");
5653
5654 assert_eq!(
5655 state.ridge_used, 0.0,
5656 "solver damping must not enter the converged statistical objective"
5657 );
5658 let undamped_stationarity = array1_l2_norm(&state.gradient);
5659 assert!(
5660 undamped_stationarity <= 1.0e-9,
5661 "solver must converge the undamped objective; ||gradient||={undamped_stationarity:.3e}"
5662 );
5663 }
5664
5665 #[test]
5666 fn laml_gradient_and_objective_ignore_inactive_penalty_prefix_blocks() {
5667 let rho0 = -0.35_f64;
5677 let beta0 = array![-2.5_f64, 1.0];
5678 let model = laml_fd_test_model(rho0.exp());
5679 let (model, beta) = model
5680 .reconverge_survival_inner_mode(&[rho0], &beta0)
5681 .expect("converge inner mode for LAML prefix-skip test");
5682 let state = model
5683 .update_state(&beta)
5684 .expect("state for LAML prefix-skip test");
5685
5686 assert_eq!(model.penalties.blocks.len(), 2);
5691 assert_eq!(model.penalties.blocks[0].lambda, 0.0);
5692 assert!(model.penalties.blocks[1].lambda > 0.0);
5693
5694 let rho = Array1::from_iter(
5695 model
5696 .penalties
5697 .blocks
5698 .iter()
5699 .filter(|b| b.lambda > 0.0)
5700 .map(|b| b.lambda.ln()),
5701 );
5702 assert_eq!(
5703 rho.len(),
5704 1,
5705 "fixture should expose exactly one active penalty block for the rho vector"
5706 );
5707
5708 let (obj, grad) = model
5709 .unified_lamlobjective_and_rhogradient(&beta, &state, &rho)
5710 .expect("survival LAML objective and gradient");
5711
5712 let expected = 0.5 * (state.deviance + state.penalty_term)
5713 + 0.5 * laml_test_logdet_h(&state)
5714 - 0.5 * (rho0 + 2.5_f64.ln());
5715 assert_eq!(
5716 grad.len(),
5717 1,
5718 "rho-gradient must match the active-penalty count, not the full block list"
5719 );
5720 assert!(
5721 (obj - expected).abs() < 1e-10,
5722 "survival LAML objective mismatch with inactive prefix block: obj={obj} expected={expected}",
5723 );
5724 assert!(
5725 grad[0].is_finite(),
5726 "rho-gradient must be finite: {}",
5727 grad[0]
5728 );
5729 }
5730
5731 #[test]
5732 fn survival_laml_refuses_nonstationary_inner_state() {
5733 let rho0 = -0.35_f64;
5734 let beta0 = array![-2.5_f64, 1.0];
5735 let model = laml_fd_test_model(rho0.exp());
5736 let (model, beta_hat) = model
5737 .reconverge_survival_inner_mode(&[rho0], &beta0)
5738 .expect("converge reference survival mode");
5739
5740 let mut beta_off_mode = beta_hat;
5743 beta_off_mode[0] += 0.25;
5744 let state = model
5745 .update_state(&beta_off_mode)
5746 .expect("off-mode state remains in the survival domain");
5747 let rho = array![rho0];
5748 let error = model
5749 .unified_lamlobjective_and_rhogradient(&beta_off_mode, &state, &rho)
5750 .expect_err("LAML must refuse a nonstationary inner state");
5751 assert!(
5752 error
5753 .to_string()
5754 .contains("survival LAML requires a stationary inner mode"),
5755 "unexpected off-mode refusal: {error}"
5756 );
5757 }
5758
5759 #[test]
5760 fn structural_monotonicgradient_matchesobjectivefd() {
5761 let age_entry = array![1.0_f64, 1.3_f64, 1.8_f64];
5762 let age_exit = array![1.6_f64, 2.1_f64, 2.7_f64];
5763 let event_target = array![1u8, 0u8, 1u8];
5764 let event_competing = array![0u8, 0u8, 0u8];
5765 let sampleweight = array![1.0, 1.0, 1.0];
5766
5767 let x_entry = array![
5770 [1.0, 0.2, 0.05, -0.7],
5771 [1.0, 0.5, 0.20, 0.1],
5772 [1.0, 0.9, 0.60, 1.2]
5773 ];
5774 let x_exit = array![
5775 [1.0, 0.4, 0.16, -0.7],
5776 [1.0, 0.8, 0.64, 0.1],
5777 [1.0, 1.1, 1.21, 1.2]
5778 ];
5779 let x_derivative = array![
5780 [0.0, 0.8, 0.64, 0.0],
5781 [0.0, 0.7, 1.12, 0.0],
5782 [0.0, 0.6, 1.32, 0.0]
5783 ];
5784 let penalties = PenaltyBlocks::new(Vec::new());
5785 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
5786 let mut model = survival_model(
5787 survival_inputs(
5788 &age_entry,
5789 &age_exit,
5790 &event_target,
5791 &event_competing,
5792 &sampleweight,
5793 &x_entry,
5794 &x_exit,
5795 &x_derivative,
5796 ),
5797 penalties,
5798 mono,
5799 SurvivalSpec::Net,
5800 )
5801 .expect("construct structural survival model");
5802 model
5803 .set_structural_monotonicity(true, 3)
5804 .expect("enable structural monotonicity");
5805 let constraints = model
5806 .monotonicity_linear_constraints()
5807 .expect("structural derivative constraints");
5808 assert_eq!(constraints.a.nrows(), 3);
5818 assert_eq!(constraints.a.ncols(), 4);
5819 assert_eq!(constraints.a.row(0).to_vec(), vec![1.0, 0.0, 0.0, 0.0]);
5820 assert_eq!(constraints.a.row(1).to_vec(), vec![0.0, 1.0, 0.0, 0.0]);
5821 assert_eq!(constraints.a.row(2).to_vec(), vec![0.0, 0.0, 1.0, 0.0]);
5822 assert!(constraints.b.iter().all(|&v| v.abs() <= 1e-12));
5823
5824 let beta = array![0.2, 0.2, 0.1, 0.2];
5825 let state = model.update_state(&beta).expect("state at structural beta");
5826 let eps = 1e-7;
5827 for j in 0..beta.len() {
5828 let mut plus = beta.clone();
5829 let mut minus = beta.clone();
5830 plus[j] += eps;
5831 minus[j] -= eps;
5832 let state_plus = model.update_state(&plus).expect("state at beta + eps");
5833 let state_minus = model.update_state(&minus).expect("state at beta - eps");
5834 let obj_plus = 0.5 * (state_plus.deviance + state_plus.penalty_term);
5835 let obj_minus = 0.5 * (state_minus.deviance + state_minus.penalty_term);
5836 let fd = (obj_plus - obj_minus) / (2.0 * eps);
5837 assert_eq!(
5838 state.gradient[j].signum(),
5839 fd.signum(),
5840 "structural objective/gradient sign mismatch at j={j}: grad={} fd={fd}",
5841 state.gradient[j]
5842 );
5843 assert!(
5844 (state.gradient[j] - fd).abs() < 2e-5,
5845 "structural objective/gradient mismatch at j={j}: grad={} fd={fd}",
5846 state.gradient[j]
5847 );
5848 }
5849 }
5850
5851 #[test]
5852 fn structural_monotonic_lamlgradient_returns_finitevalues() {
5853 let age_entry = array![1.0_f64, 1.2_f64];
5854 let age_exit = array![1.5_f64, 2.0_f64];
5855 let event_target = array![1u8, 0u8];
5856 let event_competing = array![0u8, 0u8];
5857 let sampleweight = array![1.0, 1.0];
5858 let x_entry = array![[1.0, 0.2, -0.5], [1.0, 0.4, 0.2]];
5859 let x_exit = array![[1.0, 0.5, -0.5], [1.0, 0.8, 0.2]];
5860 let x_derivative = array![[0.0, 0.9, 0.0], [0.0, 0.7, 0.0]];
5861 let penalties = PenaltyBlocks::new(Vec::new());
5862 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
5863 let mut model = survival_model(
5864 survival_inputs(
5865 &age_entry,
5866 &age_exit,
5867 &event_target,
5868 &event_competing,
5869 &sampleweight,
5870 &x_entry,
5871 &x_exit,
5872 &x_derivative,
5873 ),
5874 penalties,
5875 mono,
5876 SurvivalSpec::Net,
5877 )
5878 .expect("construct structural survival model");
5879 model
5880 .set_structural_monotonicity(true, 2)
5881 .expect("enable structural monotonicity");
5882 model.penalties = PenaltyBlocks::new(vec![PenaltyBlock {
5884 matrix: array![[1.0]],
5885 lambda: 0.7,
5886 range: 1..2,
5887 nullspace_dim: 0,
5888 }]);
5889 let beta0 = array![0.2, 0.2, 0.1];
5890 let rho = Array1::from_iter(
5891 model
5892 .penalties
5893 .blocks
5894 .iter()
5895 .filter(|b| b.lambda > 0.0)
5896 .map(|b| b.lambda.ln()),
5897 );
5898 let (model, beta) = model
5899 .reconverge_survival_inner_mode(
5900 rho.as_slice().expect("contiguous structural rho"),
5901 &beta0,
5902 )
5903 .expect("converge structural survival mode");
5904 let state = model.update_state(&beta).expect("state at structural mode");
5905 let (obj, grad) = model
5906 .unified_lamlobjective_and_rhogradient(&beta, &state, &rho)
5907 .expect("laml gradient should work in structural mode");
5908 assert!(obj.is_finite());
5909 assert_eq!(grad.len(), 1);
5910 assert!(grad[0].is_finite());
5911 }
5912
5913 #[test]
5914 fn structural_monotonicity_switches_to_tiny_derivative_guard_constraints() {
5915 let age_entry = array![1.0_f64];
5916 let age_exit = array![2.0_f64];
5917 let event_target = array![1u8];
5918 let event_competing = array![0u8];
5919 let sampleweight = array![1.0];
5920 let x_entry = array![[0.0]];
5921 let x_exit = array![[0.2]];
5922 let x_derivative = array![[1.0]];
5923
5924 let penalties = PenaltyBlocks::new(Vec::new());
5925 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
5926 let mut model = survival_model(
5927 survival_inputs(
5928 &age_entry,
5929 &age_exit,
5930 &event_target,
5931 &event_competing,
5932 &sampleweight,
5933 &x_entry,
5934 &x_exit,
5935 &x_derivative,
5936 ),
5937 penalties,
5938 mono,
5939 SurvivalSpec::Net,
5940 )
5941 .expect("construct structural survival model");
5942
5943 let beta = array![-3.0];
5944 assert!(
5945 model.update_state(&beta).is_err(),
5946 "negative derivative coefficient should violate derivative guard"
5947 );
5948
5949 model
5950 .set_structural_monotonicity(true, 1)
5951 .expect("enable structural monotonicity");
5952 let constraints = model
5953 .monotonicity_linear_constraints()
5954 .expect("structural derivative constraints");
5955 assert_eq!(constraints.a.nrows(), 1);
5956 assert_eq!(constraints.a.ncols(), 1);
5957 assert!((constraints.a[[0, 0]] - 1.0).abs() <= 1e-12);
5958 assert!(constraints.b[0].abs() <= 1e-12);
5960 let state = model
5961 .update_state(&array![1e-6])
5962 .expect("small positive derivative coefficient should remain feasible");
5963 assert!(state.deviance.is_finite());
5964 }
5965
5966 #[test]
5967 fn derivative_offset_must_clear_nonstructural_monotonicity_threshold() {
5968 let age_entry = array![1.0_f64];
5969 let age_exit = array![2.0_f64];
5970 let event_target = array![1u8];
5971 let event_competing = array![0u8];
5972 let sampleweight = array![1.0];
5973 let x_entry = array![[1.0, 0.0]];
5974 let x_exit = array![[1.0, 0.0]];
5975 let x_derivative = array![[0.0, 0.0]];
5976 let penalties = PenaltyBlocks::new(Vec::new());
5977 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
5978 let eta_entry_offset = array![0.0];
5979 let eta_exit_offset = array![0.0];
5980 let derivative_offset_below_guard = array![2.0];
5981 let derivative_offset_above_guard = array![3.1];
5982 let offsets_below_guard = SurvivalBaselineOffsets {
5983 eta_entry: eta_entry_offset.view(),
5984 eta_exit: eta_exit_offset.view(),
5985 derivative_exit: derivative_offset_below_guard.view(),
5986 };
5987 let offsets_above_guard = SurvivalBaselineOffsets {
5988 eta_entry: eta_entry_offset.view(),
5989 eta_exit: eta_exit_offset.view(),
5990 derivative_exit: derivative_offset_above_guard.view(),
5991 };
5992
5993 let model_below_guard = survival_model_with_offsets(
5994 survival_inputs(
5995 &age_entry,
5996 &age_exit,
5997 &event_target,
5998 &event_competing,
5999 &sampleweight,
6000 &x_entry,
6001 &x_exit,
6002 &x_derivative,
6003 ),
6004 Some(offsets_below_guard),
6005 penalties.clone(),
6006 monotonicity,
6007 SurvivalSpec::Net,
6008 )
6009 .expect("construct model with derivative offset below guard");
6010 let err = model_below_guard
6011 .update_state(&array![0.0, 0.0])
6012 .expect_err("derivative offset below guard should be rejected");
6013 let err_text = err.to_string();
6014 assert!(
6015 err_text.contains("d_eta/dt=2.000e0") && err_text.contains("tolerance=3.000e0"),
6016 "expected derivative guard rejection to report the offset-driven derivative: {err_text}"
6017 );
6018
6019 let model_above_guard = survival_model_with_offsets(
6020 survival_inputs(
6021 &age_entry,
6022 &age_exit,
6023 &event_target,
6024 &event_competing,
6025 &sampleweight,
6026 &x_entry,
6027 &x_exit,
6028 &x_derivative,
6029 ),
6030 Some(offsets_above_guard),
6031 penalties,
6032 SurvivalMonotonicityPenalty { tolerance: 3.0 },
6033 SurvivalSpec::Net,
6034 )
6035 .expect("construct model with derivative offset above guard");
6036 let state = model_above_guard
6037 .update_state(&array![0.0, 0.0])
6038 .expect("derivative offset above guard should remain feasible");
6039 assert!(state.deviance.is_finite());
6040 }
6041
6042 #[test]
6043 fn structural_monotonicity_rejects_negative_derivative_offsets() {
6044 let age_entry = array![1.0_f64];
6045 let age_exit = array![2.0_f64];
6046 let event_target = array![1u8];
6047 let event_competing = array![0u8];
6048 let sampleweight = array![1.0];
6049 let x_entry = array![[0.0]];
6050 let x_exit = array![[0.2]];
6051 let x_derivative = array![[1.0]];
6052 let eta_entry = array![0.0];
6053 let eta_exit = array![0.0];
6054 let derivative_exit = array![-1e-3];
6055 let offsets = SurvivalBaselineOffsets {
6056 eta_entry: eta_entry.view(),
6057 eta_exit: eta_exit.view(),
6058 derivative_exit: derivative_exit.view(),
6059 };
6060
6061 let mut model = survival_model_with_offsets(
6062 survival_inputs(
6063 &age_entry,
6064 &age_exit,
6065 &event_target,
6066 &event_competing,
6067 &sampleweight,
6068 &x_entry,
6069 &x_exit,
6070 &x_derivative,
6071 ),
6072 Some(offsets),
6073 PenaltyBlocks::new(Vec::new()),
6074 SurvivalMonotonicityPenalty { tolerance: 0.0 },
6075 SurvivalSpec::Net,
6076 )
6077 .expect("construct structural survival model");
6078 let err = model
6079 .set_structural_monotonicity(true, 1)
6080 .expect_err("negative derivative offsets must be rejected");
6081 assert!(
6082 err.to_string()
6083 .contains("structural monotonicity requires nonnegative derivative offsets"),
6084 "unexpected error: {err}"
6085 );
6086 }
6087
6088 #[test]
6089 fn structural_monotonicity_emits_coefficient_constraints() {
6090 let age_entry = array![1.0_f64, 1.5_f64];
6091 let age_exit = array![2.0_f64, 3.0_f64];
6092 let event_target = array![1u8, 0u8];
6093 let event_competing = array![0u8, 0u8];
6094 let sampleweight = array![1.0, 1.0];
6095 let x_entry = array![[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]];
6096 let x_exit = array![[0.2, 0.4, 1.0], [0.3, 0.5, 1.0]];
6097 let x_derivative = array![[0.3, 0.2, 0.0], [0.4, 0.1, 0.0]];
6098
6099 let mut model = survival_model(
6100 survival_inputs(
6101 &age_entry,
6102 &age_exit,
6103 &event_target,
6104 &event_competing,
6105 &sampleweight,
6106 &x_entry,
6107 &x_exit,
6108 &x_derivative,
6109 ),
6110 PenaltyBlocks::new(Vec::new()),
6111 SurvivalMonotonicityPenalty { tolerance: 0.0 },
6112 SurvivalSpec::Net,
6113 )
6114 .expect("construct structural survival model");
6115 model
6116 .set_structural_monotonicity(true, 2)
6117 .expect("enable structural monotonicity");
6118
6119 let constraints = model
6120 .monotonicity_linear_constraints()
6121 .expect("structural derivative constraints");
6122
6123 assert_eq!(constraints.a.nrows(), 2);
6124 assert_eq!(constraints.a.ncols(), 3);
6125 assert_eq!(constraints.a.row(0).to_vec(), vec![1.0, 0.0, 0.0]);
6126 assert_eq!(constraints.a.row(1).to_vec(), vec![0.0, 1.0, 0.0]);
6127 assert!(constraints.b.iter().all(|&v| v.abs() <= 1e-12));
6128 }
6129
6130 #[test]
6131 fn structural_monotonicity_preserves_inactive_time_columns_in_constraints() {
6132 let age_entry = array![1.0_f64];
6133 let age_exit = array![2.0_f64];
6134 let event_target = array![1u8];
6135 let event_competing = array![0u8];
6136 let sampleweight = array![1.0];
6137 let x_entry = array![[1.0, 0.2]];
6138 let x_exit = array![[1.0, 0.6]];
6139 let x_derivative = array![[0.0, 1.0]];
6140
6141 let mut model = survival_model(
6142 survival_inputs(
6143 &age_entry,
6144 &age_exit,
6145 &event_target,
6146 &event_competing,
6147 &sampleweight,
6148 &x_entry,
6149 &x_exit,
6150 &x_derivative,
6151 ),
6152 PenaltyBlocks::new(Vec::new()),
6153 SurvivalMonotonicityPenalty { tolerance: 0.0 },
6154 SurvivalSpec::Net,
6155 )
6156 .expect("construct structural survival model");
6157 model
6158 .set_structural_monotonicity(true, 2)
6159 .expect("enable structural monotonicity");
6160
6161 let constraints = model
6162 .monotonicity_linear_constraints()
6163 .expect("structural derivative constraints");
6164
6165 assert_eq!(constraints.a.nrows(), 2);
6173 assert!(
6174 (constraints.a[[0, 0]] - 1.0).abs() <= 1e-12,
6175 "inactive time column must still be constrained (domain-wide certificate)"
6176 );
6177 assert!(
6178 (constraints.a[[1, 1]] - 1.0).abs() <= 1e-12,
6179 "active time column should remain constrained"
6180 );
6181 assert!(
6182 constraints.a[[0, 1]].abs() <= 1e-12 && constraints.a[[1, 0]].abs() <= 1e-12,
6183 "each row must constrain exactly its own time coefficient"
6184 );
6185 }
6186
6187 #[test]
6188 fn structural_monotonicity_preserves_sparse_row_patterns() {
6189 let age_entry = array![1.0_f64, 1.5_f64];
6190 let age_exit = array![2.0_f64, 2.5_f64];
6191 let event_target = array![1u8, 1u8];
6192 let event_competing = array![0u8, 0u8];
6193 let sampleweight = array![1.0, 1.0];
6194 let x_entry = array![[0.0, 0.0], [0.0, 0.0]];
6195 let x_exit = array![[0.4, 0.2], [0.6, 0.3]];
6196 let x_derivative = array![[1.0, 0.0], [1.0, 0.5]];
6197
6198 let mut model = survival_model(
6199 survival_inputs(
6200 &age_entry,
6201 &age_exit,
6202 &event_target,
6203 &event_competing,
6204 &sampleweight,
6205 &x_entry,
6206 &x_exit,
6207 &x_derivative,
6208 ),
6209 PenaltyBlocks::new(Vec::new()),
6210 SurvivalMonotonicityPenalty { tolerance: 0.0 },
6211 SurvivalSpec::Net,
6212 )
6213 .expect("construct structural survival model");
6214 model
6215 .set_structural_monotonicity(true, 2)
6216 .expect("enable structural monotonicity");
6217
6218 let constraints = model
6219 .monotonicity_linear_constraints()
6220 .expect("structural derivative constraints");
6221
6222 assert_eq!(constraints.a.nrows(), 2);
6223 assert_eq!(constraints.a.row(0).to_vec(), vec![1.0, 0.0]);
6224 assert_eq!(constraints.a.row(1).to_vec(), vec![0.0, 1.0]);
6225 }
6226
6227 #[test]
6228 fn update_state_rejects_negative_exit_derivative_for_censoredrows() {
6229 let age_entry = array![1.0_f64];
6230 let age_exit = array![1.1_f64];
6231 let event_target = array![0u8];
6232 let event_competing = array![0u8];
6233 let sampleweight = array![1.0];
6234 let x_entry = array![[0.0]];
6235 let x_exit = array![[0.0]];
6236 let x_derivative = array![[-1.0]];
6237 let penalties = PenaltyBlocks::new(Vec::new());
6238 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
6239 let model = survival_model(
6240 survival_inputs(
6241 &age_entry,
6242 &age_exit,
6243 &event_target,
6244 &event_competing,
6245 &sampleweight,
6246 &x_entry,
6247 &x_exit,
6248 &x_derivative,
6249 ),
6250 penalties,
6251 mono,
6252 SurvivalSpec::Net,
6253 )
6254 .expect("construct censored survival model");
6255
6256 let err = model
6257 .update_state(&array![1.0])
6258 .expect_err("censored row should still enforce monotonic derivative");
6259 assert!(
6260 matches!(err, EstimationError::ParameterConstraintViolation(_)),
6261 "unexpected error: {err:?}"
6262 );
6263 }
6264
6265 fn crude_risk_quadrature_error(
6266 cumulative_entry: f64,
6267 cumulative_exit: f64,
6268 hazard_exit: f64,
6269 ) -> SurvivalError {
6270 calculate_crude_risk_quadrature(
6271 1.0,
6272 2.0,
6273 &[],
6274 0.4,
6275 0.2,
6276 array![1.0].view(),
6277 array![1.0].view(),
6278 |_, design_d, deriv_d, design_m| {
6279 design_d[0] = 1.0;
6280 deriv_d[0] = 0.0;
6281 design_m[0] = 1.0;
6282 Ok((cumulative_entry, cumulative_exit, hazard_exit))
6283 },
6284 )
6285 .expect_err("invalid hazards should fail")
6286 }
6287
6288 #[test]
6289 fn crude_risk_quadrature_rejects_decreasing_cumulative_hazard() {
6290 let err = crude_risk_quadrature_error(0.1, 0.3, 0.25);
6291 assert!(matches!(err, SurvivalError::NonMonotoneCumulativeHazard));
6292 }
6293
6294 #[test]
6295 fn crude_risk_quadrature_rejects_nonpositive_instantaneous_hazard() {
6296 let err = crude_risk_quadrature_error(0.0, 0.4, 0.25);
6297 assert!(matches!(err, SurvivalError::NonPositiveHazard));
6298 }
6299
6300 #[test]
6301 fn monotonicity_constraints_collapse_positive_collinearrows() {
6302 let a = array![[0.0, 0.5, 0.0], [0.0, 0.25, 0.0], [0.0, 0.125, 0.0]];
6303 let b = array![1e-8, 1e-8, 1e-8];
6304
6305 let compressed = compress_positive_collinear_constraints(&a, &b);
6306
6307 assert_eq!(compressed.a.nrows(), 1);
6308 assert_eq!(compressed.a.ncols(), 3);
6309 assert!(compressed.a[[0, 0]].abs() <= 1e-12);
6310 assert!((compressed.a[[0, 1]] - 1.0).abs() <= 1e-12);
6311 assert!(compressed.a[[0, 2]].abs() <= 1e-12);
6312 assert!((compressed.b[0] - 8e-8).abs() <= 1e-18);
6313 }
6314
6315 #[test]
6316 fn monotonicity_constraints_preserve_distinct_directions() {
6317 let a = array![[1.0, 0.0], [0.0, 1.0], [2.0, 0.0]];
6318 let b = array![0.2, 0.3, 0.1];
6319
6320 let compressed = compress_positive_collinear_constraints(&a, &b);
6321
6322 assert_eq!(compressed.a.nrows(), 2);
6323 let mut saw_x = false;
6324 let mut saw_y = false;
6325 for i in 0..compressed.a.nrows() {
6326 if (compressed.a[[i, 0]] - 1.0).abs() <= 1e-12 && compressed.a[[i, 1]].abs() <= 1e-12 {
6327 saw_x = true;
6328 assert!((compressed.b[i] - 0.2).abs() <= 1e-12);
6329 }
6330 if compressed.a[[i, 0]].abs() <= 1e-12 && (compressed.a[[i, 1]] - 1.0).abs() <= 1e-12 {
6331 saw_y = true;
6332 assert!((compressed.b[i] - 0.3).abs() <= 1e-12);
6333 }
6334 }
6335 assert!(saw_x);
6336 assert!(saw_y);
6337 }
6338
6339 #[test]
6340 fn monotonicity_constraints_cluster_near_collinearrows() {
6341 let a = array![
6342 [0.0, 0.5, 0.0],
6343 [0.0, 0.50000000003, 0.0],
6344 [0.0, 0.49999999997, 0.0]
6345 ];
6346 let b = array![1e-8, 1.00000000005e-8, 0.99999999995e-8];
6347
6348 let compressed = compress_positive_collinear_constraints(&a, &b);
6349
6350 assert_eq!(compressed.a.nrows(), 1);
6351 assert_eq!(compressed.a.ncols(), 3);
6352 assert!(compressed.a[[0, 0]].abs() <= 1e-12);
6353 assert!((compressed.a[[0, 1]] - 1.0).abs() <= 1e-12);
6354 assert!(compressed.a[[0, 2]].abs() <= 1e-12);
6355 assert!((compressed.b[0] - 2.0e-8).abs() <= 1e-18);
6356 }
6357
6358 #[test]
6359 fn monotonicity_constraints_cluster_spline_like_near_duplicates() {
6360 let a = array![
6361 [0.0, 0.401, 0.302, 0.197],
6362 [0.0, 0.40100000003, 0.30199999998, 0.19700000001],
6363 [0.0, 0.40099999997, 0.30200000002, 0.19699999999],
6364 [0.0, 0.125, 0.500, 0.375]
6365 ];
6366 let b = array![2.0e-8, 2.00000000004e-8, 1.99999999996e-8, 3.0e-8];
6367
6368 let compressed = compress_positive_collinear_constraints(&a, &b);
6369
6370 assert_eq!(compressed.a.nrows(), 2);
6371 let mut clustered_face = false;
6372 let mut distinct_face = false;
6373 for i in 0..compressed.a.nrows() {
6374 let row = compressed.a.row(i);
6375 if row[1] > 0.99 && row[2] > 0.7 && row[3] > 0.49 {
6376 clustered_face = true;
6377 assert!((compressed.b[i] - (2.0e-8 / 0.401)).abs() <= 1e-12);
6378 } else {
6379 distinct_face = true;
6380 assert!((row[1] - 0.25).abs() <= 1e-12);
6381 assert!((row[2] - 1.0).abs() <= 1e-12);
6382 assert!((row[3] - 0.75).abs() <= 1e-12);
6383 assert!((compressed.b[i] - 6.0e-8).abs() <= 1e-18);
6384 }
6385 }
6386 assert!(clustered_face);
6387 assert!(distinct_face);
6388 }
6389
6390 #[test]
6391 fn linear_time_monotonicity_constraints_reduce_to_single_halfspace() {
6392 let age_entry = array![1.0_f64, 1.0, 1.0];
6393 let age_exit = array![2.0_f64, 4.0, 8.0];
6394 let event_target = array![0u8, 1u8, 0u8];
6395 let event_competing = array![0u8, 0u8, 0u8];
6396 let sampleweight = array![1.0, 1.0, 1.0];
6397 let x_entry = array![
6398 [1.0, age_entry[0].ln()],
6399 [1.0, age_entry[1].ln()],
6400 [1.0, age_entry[2].ln()]
6401 ];
6402 let x_exit = array![
6403 [1.0, age_exit[0].ln()],
6404 [1.0, age_exit[1].ln()],
6405 [1.0, age_exit[2].ln()]
6406 ];
6407 let x_derivative = array![[0.0, 0.5], [0.0, 0.25], [0.0, 0.125]];
6408 let penalties = PenaltyBlocks::new(Vec::new());
6409 let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
6410
6411 let collocation_offsets = Array1::zeros(x_derivative.nrows());
6412 let mut inputs = survival_inputs(
6413 &age_entry,
6414 &age_exit,
6415 &event_target,
6416 &event_competing,
6417 &sampleweight,
6418 &x_entry,
6419 &x_exit,
6420 &x_derivative,
6421 );
6422 inputs.monotonicity_constraint_rows = Some(x_derivative.view());
6423 inputs.monotonicity_constraint_offsets = Some(collocation_offsets.view());
6424
6425 let model = survival_model(inputs, penalties, mono, SurvivalSpec::Net)
6426 .expect("construct linear survival model");
6427
6428 let constraints = model
6429 .monotonicity_linear_constraints()
6430 .expect("monotonicity constraints");
6431 assert_eq!(constraints.a.nrows(), 1);
6432 assert!((constraints.a[[0, 1]] - 1.0).abs() <= 1e-12);
6433 assert!((constraints.b[0] - 8e-8).abs() <= 1e-12);
6434 }
6435
6436 #[test]
6437 fn monotonicity_constraints_skip_numericallyzerorows() {
6438 let age_entry = array![1.0_f64, 1.0, 1.0];
6439 let age_exit = array![2.0_f64, 3.0, 4.0];
6440 let event_target = array![0u8, 0u8, 0u8];
6441 let event_competing = array![0u8, 0u8, 0u8];
6442 let sampleweight = array![1.0, 1.0, 1.0];
6443 let x_entry = array![[1.0, 0.0], [1.0, 0.0], [1.0, 0.0]];
6444 let x_exit = x_entry.clone();
6445 let x_derivative = array![[0.0, 0.0], [0.0, 1e-16], [0.0, 0.25]];
6446
6447 let collocation_offsets = Array1::zeros(x_derivative.nrows());
6448 let mut inputs = survival_inputs(
6449 &age_entry,
6450 &age_exit,
6451 &event_target,
6452 &event_competing,
6453 &sampleweight,
6454 &x_entry,
6455 &x_exit,
6456 &x_derivative,
6457 );
6458 inputs.monotonicity_constraint_rows = Some(x_derivative.view());
6459 inputs.monotonicity_constraint_offsets = Some(collocation_offsets.view());
6460
6461 let model = survival_model(
6462 inputs,
6463 PenaltyBlocks::new(Vec::new()),
6464 SurvivalMonotonicityPenalty { tolerance: 0.0 },
6465 SurvivalSpec::Net,
6466 )
6467 .expect("construct survival model");
6468
6469 let constraints = model
6470 .monotonicity_linear_constraints()
6471 .expect("nonzero derivative row should remain");
6472 assert_eq!(constraints.a.nrows(), 1);
6473 assert!((constraints.a[[0, 1]] - 1.0).abs() <= 1e-12);
6474 assert!(constraints.b[0].abs() <= 1e-18);
6475 }
6476
6477 #[test]
6478 fn censoredrows_allowzero_boundary_derivative() {
6479 let age_entry = array![1.0_f64];
6480 let age_exit = array![2.0_f64];
6481 let event_target = array![0u8];
6482 let event_competing = array![0u8];
6483 let sampleweight = array![1.0];
6484 let x_entry = array![[0.0]];
6485 let x_exit = array![[0.0]];
6486 let x_derivative = array![[1.0]];
6487
6488 let model = survival_model(
6489 survival_inputs(
6490 &age_entry,
6491 &age_exit,
6492 &event_target,
6493 &event_competing,
6494 &sampleweight,
6495 &x_entry,
6496 &x_exit,
6497 &x_derivative,
6498 ),
6499 PenaltyBlocks::new(Vec::new()),
6500 SurvivalMonotonicityPenalty { tolerance: 0.0 },
6501 SurvivalSpec::Net,
6502 )
6503 .expect("construct censored survival model");
6504
6505 let state = model
6506 .update_state(&array![0.0])
6507 .expect("censored boundary derivative should remain feasible with zero tolerance");
6508 assert_eq!(state.deviance, 0.0);
6509 assert_eq!(state.log_likelihood, 0.0);
6510 assert_eq!(state.gradient, array![0.0]);
6511 }
6512
6513 #[test]
6514 fn eventrows_keep_positive_derivative_constraint() {
6515 let age_entry = array![1.0_f64, 1.0];
6516 let age_exit = array![2.0_f64, 4.0];
6517 let event_target = array![0u8, 1u8];
6518 let event_competing = array![0u8, 0u8];
6519 let sampleweight = array![1.0, 1.0];
6520 let x_entry = array![[0.0], [0.0]];
6521 let x_exit = array![[0.0], [0.0]];
6522 let x_derivative = array![[0.5], [0.25]];
6523
6524 let collocation_offsets = Array1::zeros(x_derivative.nrows());
6525 let mut inputs = survival_inputs(
6526 &age_entry,
6527 &age_exit,
6528 &event_target,
6529 &event_competing,
6530 &sampleweight,
6531 &x_entry,
6532 &x_exit,
6533 &x_derivative,
6534 );
6535 inputs.monotonicity_constraint_rows = Some(x_derivative.view());
6536 inputs.monotonicity_constraint_offsets = Some(collocation_offsets.view());
6537
6538 let model = survival_model(
6539 inputs,
6540 PenaltyBlocks::new(Vec::new()),
6541 SurvivalMonotonicityPenalty { tolerance: 1e-8 },
6542 SurvivalSpec::Net,
6543 )
6544 .expect("construct mixed survival model");
6545
6546 let constraints = model
6547 .monotonicity_linear_constraints()
6548 .expect("event row should induce positive lower bound");
6549 assert_eq!(constraints.a.nrows(), 1);
6550 assert!((constraints.a[[0, 0]] - 1.0).abs() <= 1e-12);
6551 assert!((constraints.b[0] - 4e-8).abs() <= 1e-18);
6552 }
6553
6554 #[test]
6555 fn structural_monotonicity_clamps_tiny_negative_roundoff() {
6556 let age_entry = array![1.0_f64];
6557 let age_exit = array![2.0_f64];
6558 let event_target = array![1u8];
6559 let event_competing = array![0u8];
6560 let sampleweight = array![1.0];
6561 let x_entry = array![[0.0]];
6562 let x_exit = array![[0.0]];
6563 let x_derivative = array![[1.0]];
6564 let mut model = survival_model(
6565 survival_inputs(
6566 &age_entry,
6567 &age_exit,
6568 &event_target,
6569 &event_competing,
6570 &sampleweight,
6571 &x_entry,
6572 &x_exit,
6573 &x_derivative,
6574 ),
6575 PenaltyBlocks::new(Vec::new()),
6576 SurvivalMonotonicityPenalty { tolerance: 1e-8 },
6577 SurvivalSpec::Net,
6578 )
6579 .expect("construct survival model");
6580 model
6581 .set_structural_monotonicity(true, 1)
6582 .expect("enable structural monotonicity");
6583
6584 let state = model
6585 .update_state(&array![-1e-8])
6586 .expect("tiny structural roundoff should be clamped");
6587 let expected_deviance = -2.0 * (1.0e-12_f64).ln();
6588 assert!(
6589 (state.deviance - expected_deviance).abs() <= 1e-12,
6590 "floored structural event deviance: expected {expected_deviance}, got {}",
6591 state.deviance
6592 );
6593 assert_eq!(state.gradient, array![0.0]);
6594 }
6595
6596 #[test]
6597 fn compressed_monotonicity_constraints_preserve_uncompressed_feasible_region() {
6598 let uncompressed_constraints = LinearInequalityConstraints {
6599 a: array![
6600 [0.0, 0.5, 0.0],
6601 [0.0, 1.0 / 3.0, 0.0],
6602 [0.0, 0.2, 0.0],
6603 [0.0, 0.125, 0.0]
6604 ],
6605 b: Array1::from_elem(4, 1e-8),
6606 };
6607 let compressed_constraints = compress_positive_collinear_constraints(
6608 &uncompressed_constraints.a,
6609 &uncompressed_constraints.b,
6610 );
6611
6612 let candidates = [
6613 array![0.0, 1e-9, 0.0],
6614 array![0.0, 4e-8, 0.0],
6615 array![0.0, 8e-8, 0.0],
6616 array![0.0, 2e-7, 1.5],
6617 ];
6618 for beta in candidates {
6619 let uncompressed_ok = (0..uncompressed_constraints.a.nrows()).all(|i| {
6620 uncompressed_constraints.a.row(i).dot(&beta) >= uncompressed_constraints.b[i]
6621 });
6622 let compressed_ok = (0..compressed_constraints.a.nrows())
6623 .all(|i| compressed_constraints.a.row(i).dot(&beta) >= compressed_constraints.b[i]);
6624 assert_eq!(compressed_ok, uncompressed_ok);
6625 }
6626 }
6627
6628 #[test]
6629 fn exact_survival_derivatives_are_time_unit_invariant_up_to_constant_shift() {
6630 let age_entry = array![10.0_f64, 20.0, 25.0];
6631 let age_exit = array![15.0_f64, 30.0, 40.0];
6632 let event_target = array![1u8, 0u8, 1u8];
6633 let event_competing = array![0u8, 0u8, 0u8];
6634 let sampleweight = array![1.0, 2.0, 0.5];
6635 let x_entry = array![[0.1, 0.2, 1.0], [0.3, 0.4, 1.0], [0.2, 0.6, 1.0]];
6636 let x_exit = array![[0.2, 0.3, 1.0], [0.5, 0.7, 1.0], [0.4, 0.8, 1.0]];
6637 let x_derivative = array![[0.04, 0.02, 0.0], [0.03, 0.01, 0.0], [0.02, 0.03, 0.0]];
6638 let beta = array![0.8, 1.1, -0.2];
6639
6640 let base_model = survival_model(
6641 survival_inputs(
6642 &age_entry,
6643 &age_exit,
6644 &event_target,
6645 &event_competing,
6646 &sampleweight,
6647 &x_entry,
6648 &x_exit,
6649 &x_derivative,
6650 ),
6651 PenaltyBlocks::new(Vec::new()),
6652 SurvivalMonotonicityPenalty { tolerance: 0.0 },
6653 SurvivalSpec::Net,
6654 )
6655 .expect("construct base survival model");
6656 let base_state = base_model
6657 .update_state(&beta)
6658 .expect("evaluate base survival state");
6659
6660 let time_scale = 365.25;
6661 let scaled_age_entry = age_entry.mapv(|v| v * time_scale);
6662 let scaled_age_exit = age_exit.mapv(|v| v * time_scale);
6663 let scaled_x_derivative = x_derivative.mapv(|v| v / time_scale);
6664 let scaled_model = survival_model(
6665 survival_inputs(
6666 &scaled_age_entry,
6667 &scaled_age_exit,
6668 &event_target,
6669 &event_competing,
6670 &sampleweight,
6671 &x_entry,
6672 &x_exit,
6673 &scaled_x_derivative,
6674 ),
6675 PenaltyBlocks::new(Vec::new()),
6676 SurvivalMonotonicityPenalty { tolerance: 0.0 },
6677 SurvivalSpec::Net,
6678 )
6679 .expect("construct scaled survival model");
6680 let scaled_state = scaled_model
6681 .update_state(&beta)
6682 .expect("evaluate scaled survival state");
6683
6684 let weighted_events = sampleweight
6685 .iter()
6686 .zip(event_target.iter())
6687 .map(|(w, d)| *w * f64::from(*d))
6688 .sum::<f64>();
6689 let expected_deviance_shift = 2.0 * weighted_events * time_scale.ln();
6690 assert!(
6691 (scaled_state.deviance - base_state.deviance - expected_deviance_shift).abs() <= 1e-10,
6692 "deviance shift mismatch: scaled={} base={} expected_shift={expected_deviance_shift}",
6693 scaled_state.deviance,
6694 base_state.deviance
6695 );
6696
6697 for j in 0..beta.len() {
6698 assert!(
6699 (scaled_state.gradient[j] - base_state.gradient[j]).abs() <= 1e-12,
6700 "gradient mismatch at j={j}: scaled={} base={}",
6701 scaled_state.gradient[j],
6702 base_state.gradient[j]
6703 );
6704 }
6705
6706 let base_hessian = base_state.hessian.to_dense();
6707 let scaled_hessian = scaled_state.hessian.to_dense();
6708 for r in 0..beta.len() {
6709 for c in 0..beta.len() {
6710 assert!(
6711 (scaled_hessian[[r, c]] - base_hessian[[r, c]]).abs() <= 1e-12,
6712 "hessian mismatch at ({r},{c}): scaled={} base={}",
6713 scaled_hessian[[r, c]],
6714 base_hessian[[r, c]]
6715 );
6716 }
6717 }
6718 }
6719
6720 #[test]
6721 fn survival_laml_rho_gradient_invariant_under_injected_orthogonal_frame_at_the_rail() {
6722 use gam_linalg::faer_ndarray::FaerEigh;
6723 use gam_problem::{EvalMode, PseudoLogdetMode};
6724 use gam_solve::estimate::reml::assembly::InnerAssembly;
6725 use gam_solve::estimate::reml::reml_outer_engine::{
6726 DenseSpectralOperator, DispersionHandling,
6727 };
6728 use gam_solve::estimate::reml::reparameterized_inner::{
6729 RawInnerReparamContext, assemble_reparameterized_inner,
6730 };
6731 use gam_terms::construction::{
6732 canonicalize_penalty_specs, precompute_reparam_invariant_from_canonical,
6733 stable_reparameterizationwith_invariant,
6734 };
6735 use gam_terms::penalty_spec::PenaltySpec;
6736
6737 const RAIL_RHO0: f64 = 7.394829814011909;
6738 const FREE_RHO1: f64 = -2.45;
6739 const DECISION_MARGIN: f64 = 1.0e-9;
6743
6744 let beta0 = array![-2.5_f64, 1.0];
6745 let rho = array![RAIL_RHO0, FREE_RHO1];
6746 let model = laml_rail_fd_test_model(RAIL_RHO0.exp(), FREE_RHO1.exp());
6747
6748 let (rail_model, beta_hat) = model
6750 .reconverge_survival_inner_mode(rho.as_slice().expect("contiguous rho"), &beta0)
6751 .expect("reconverge inner mode at the rail");
6752 let state = rail_model
6753 .update_state(&beta_hat)
6754 .expect("inner state at the rail");
6755 let p = beta_hat.len();
6756 let h_dense = state.hessian.to_dense();
6757 let lambdas: Vec<f64> = rho.iter().map(|&r| r.exp()).collect();
6758
6759 let active_blocks: Vec<&PenaltyBlock> = rail_model
6760 .penalties
6761 .blocks
6762 .iter()
6763 .filter(|b| b.lambda > 0.0)
6764 .collect();
6765 let s_k_embedded: Vec<Array2<f64>> = active_blocks
6766 .iter()
6767 .map(|b| {
6768 let mut s = Array2::<f64>::zeros((p, p));
6769 let (rs, re) = (b.range.start, b.range.end);
6770 s.slice_mut(ndarray::s![rs..re, rs..re]).assign(&b.matrix);
6771 s
6772 })
6773 .collect();
6774 let penalty_specs: Vec<PenaltySpec> = active_blocks
6775 .iter()
6776 .map(|b| PenaltySpec::Block {
6777 local: b.matrix.clone(),
6778 col_range: b.range.clone(),
6779 prior_mean: gam_problem::CoefficientPriorMean::Zero,
6780 structure_hint: None,
6781 op: None,
6782 })
6783 .collect();
6784 let nullspace_dims: Vec<usize> = active_blocks.iter().map(|b| b.nullspace_dim).collect();
6785 let (canonical, _) = canonicalize_penalty_specs(
6786 &penalty_specs,
6787 &nullspace_dims,
6788 p,
6789 "rail-stability gate reparameterization",
6790 )
6791 .expect("canonicalize rail penalties");
6792 let invariant =
6793 precompute_reparam_invariant_from_canonical(&canonical, p).expect("reparam invariant");
6794 let reparam_prod =
6795 stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &invariant)
6796 .expect("production reparameterization");
6797
6798 let hessian_logdet_mode = if rail_model
6799 .age_entry
6800 .iter()
6801 .any(|&t| t > ENTRY_AT_ORIGIN_THRESHOLD)
6802 {
6803 PseudoLogdetMode::HardPseudo
6804 } else {
6805 PseudoLogdetMode::Smooth
6806 };
6807
6808 let g = {
6811 let sym = Array2::<f64>::from_shape_fn((p, p), |(i, j)| {
6812 (((i * j) as f64) * 0.37).sin() + (((i + j) as f64) * 0.11 + 1.0).cos()
6813 });
6814 let (_, evecs) = sym.eigh(faer::Side::Lower).expect("orthogonal factor from eigh");
6815 evecs
6816 };
6817
6818 let grad_for_reparam = |reparam: &gam_terms::construction::ReparamResult| -> Array1<f64> {
6822 let provider = SurvivalDerivProvider::new(rail_model.clone(), beta_hat.clone());
6823 let ctx = RawInnerReparamContext {
6824 hessian: &h_dense,
6825 beta: &beta_hat,
6826 penalties_embedded: &s_k_embedded,
6827 lambdas: &lambdas,
6828 };
6829 let reparam_inner =
6830 assemble_reparameterized_inner(&ctx, Some(Box::new(provider)), reparam)
6831 .expect("reparameterized inner assembly");
6832 let hop = DenseSpectralOperator::from_symmetric_with_mode(
6833 &reparam_inner.hessian_transformed,
6834 hessian_logdet_mode,
6835 )
6836 .expect("transformed Hessian operator");
6837 let penalty_coords = reparam
6838 .canonical_transformed
6839 .iter()
6840 .map(|cp| cp.to_penalty_coordinate())
6841 .collect::<Vec<_>>();
6842 let result = InnerAssembly {
6843 log_likelihood: state.log_likelihood,
6844 penalty_quadratic: state.penalty_term,
6845 beta: reparam_inner.beta_transformed,
6846 n_observations: rail_model.nrows(),
6847 hessian_op: std::sync::Arc::new(hop),
6848 mode_response_op: None,
6851 penalty_coords,
6852 penalty_logdet: reparam_inner.penalty_logdet,
6853 dispersion: DispersionHandling::Fixed {
6854 phi: 1.0,
6855 include_logdet_h: true,
6856 include_logdet_s: true,
6857 },
6858 rho_curvature_scale: 1.0,
6859 rho_prior: gam_problem::RhoPrior::Flat,
6860 hessian_logdet_correction: 0.0,
6861 penalty_subspace_trace: None,
6862 deriv_provider: reparam_inner.deriv_provider,
6863 firth: None,
6864 nullspace_dim: None,
6865 barrier_config: None,
6866 ext_coords: Vec::new(),
6867 ext_coord_pair_fn: None,
6868 rho_ext_pair_fn: None,
6869 fixed_drift_deriv: None,
6870 contracted_psi_second_order: None,
6871 kkt_residual: None,
6872 active_constraints: None,
6873 }
6874 .evaluate(
6875 rho.as_slice().expect("contiguous rho"),
6876 EvalMode::ValueAndGradient,
6877 None,
6878 )
6879 .expect("transformed-frame LAML evaluate");
6880 result.gradient.expect("analytic ρ-gradient present")
6881 };
6882
6883 let g_prod = grad_for_reparam(&reparam_prod);
6885
6886 let mut reparam_conj = reparam_prod.clone();
6890 reparam_conj.qs = reparam_prod.qs.dot(&g);
6891 reparam_conj.canonical_transformed = reparam_prod
6892 .canonical_transformed
6893 .iter()
6894 .map(|cp| {
6895 let mut rotated = cp.clone();
6896 rotated.root = cp.root.dot(&g);
6897 rotated.local = rotated.root.t().dot(&rotated.root);
6898 rotated
6899 })
6900 .collect();
6901 let g_conj = grad_for_reparam(&reparam_conj);
6902
6903 for k in 0..rho.len() {
6904 let drift = (g_prod[k] - g_conj[k]).abs();
6905 assert!(
6906 drift <= DECISION_MARGIN * (1.0 + g_prod[k].abs()),
6907 "rail ρ-gradient not frame-invariant at coordinate {k}: \
6908 Q_s frame {} vs Q_s·G frame {} (drift {:.3e} > margin {:.3e})",
6909 g_prod[k],
6910 g_conj[k],
6911 drift,
6912 DECISION_MARGIN * (1.0 + g_prod[k].abs())
6913 );
6914 }
6915 }
6916
6917}