1use crate::bms::deviation_runtime::AnchorComponentTag;
19use crate::bms::{
20 DeviationRuntime, LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration,
21};
22use crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL;
23use crate::fit_orchestration::drivers::freeze_term_collection_from_design;
24use crate::fit_orchestration::{FitConfig, StandardFitResult};
25use crate::inference::model::{
26 FittedFamily, FittedModelPayload, MODEL_PAYLOAD_VERSION, ModelKind, SavedAnchorComponent,
27 SavedAnchorKind, SavedCompiledFlexBlock, SavedLatentZNormalization, SavedResidualCascade,
28 SavedSplineScan, TransformationScoreCalibration,
29};
30use crate::scale_design::ScaleDeviationTransform;
31use crate::survival::construction::{
32 SavedSurvivalTimeBasis, SurvivalBaselineConfig, survival_baseline_targetname,
33};
34use crate::survival::location_scale::{
35 ResidualDistribution, residual_distribution_from_inverse_link,
36};
37use crate::transformation_normal::TransformationNormalFamily;
38use faer::Side;
39use gam_data::{DataSchema, EncodedDataset};
40use gam_linalg::faer_ndarray::{FaerCholesky, array2_to_nested_vec};
41use gam_problem::types::{
42 InverseLink, LikelihoodSpec, ResponseFamily, StandardLink, inverse_link_to_binomial_spec,
43};
44use gam_solve::estimate::{
45 FittedLinkState, UnifiedFitResult, saved_latent_cloglog_state_from_fit,
46 saved_mixture_state_from_fit, saved_sas_state_from_fit,
47};
48use gam_terms::smooth::{TermCollectionDesign, TermCollectionSpec};
49use ndarray::{Array1, Array2, s};
50
51const FAMILY_BERNOULLI_MARGINAL_SLOPE: &str = "bernoulli-marginal-slope";
53
54const FAMILY_TRANSFORMATION_NORMAL: &str = "transformation-normal";
56
57pub fn serialize_anchored_deviation_runtime(runtime: &DeviationRuntime) -> SavedCompiledFlexBlock {
64 let mut anchor_correction: Option<Vec<Vec<f64>>> = None;
65 let mut anchor_components: Vec<SavedAnchorComponent> = Vec::new();
66 if let Some(installed) = runtime.installed_flex_block() {
67 anchor_correction = Some(
68 installed
69 .anchor_correction
70 .rows()
71 .into_iter()
72 .map(|row| row.to_vec())
73 .collect::<Vec<Vec<f64>>>(),
74 );
75 for component in &installed.anchor_components {
76 anchor_components.push(SavedAnchorComponent {
77 kind: match component {
78 AnchorComponentTag::Parametric { block, ncols } => {
79 SavedAnchorKind::Parametric {
80 block: *block,
81 ncols: *ncols,
82 }
83 }
84 AnchorComponentTag::FlexEvaluation { ncols } => {
85 SavedAnchorKind::FlexEvaluation { ncols: *ncols }
86 }
87 },
88 });
89 }
90 }
91 SavedCompiledFlexBlock {
92 kernel: ANCHORED_DEVIATION_KERNEL.to_string(),
93 breakpoints: runtime.breakpoints().to_vec(),
94 basis_dim: runtime.basis_dim(),
95 span_c0: runtime
96 .span_c0()
97 .rows()
98 .into_iter()
99 .map(|row| row.to_vec())
100 .collect(),
101 span_c1: runtime
102 .span_c1()
103 .rows()
104 .into_iter()
105 .map(|row| row.to_vec())
106 .collect(),
107 span_c2: runtime
108 .span_c2()
109 .rows()
110 .into_iter()
111 .map(|row| row.to_vec())
112 .collect(),
113 span_c3: runtime
114 .span_c3()
115 .rows()
116 .into_iter()
117 .map(|row| row.to_vec())
118 .collect(),
119 anchor_correction,
120 anchor_components,
121 }
122}
123
124pub struct SavedModelSourceMetadata {
132 pub training_headers: Vec<String>,
133 pub training_feature_ranges: Option<Vec<(f64, f64)>>,
134 pub offset_column: Option<String>,
135 pub noise_offset_column: Option<String>,
136}
137
138impl SavedModelSourceMetadata {
139 fn apply_to(self, payload: &mut FittedModelPayload) {
140 match self.training_feature_ranges {
141 Some(ranges) => payload.set_training_feature_metadata(self.training_headers, ranges),
142 None => payload.training_headers = Some(self.training_headers),
143 }
144 payload.offset_column = self.offset_column;
145 payload.noise_offset_column = self.noise_offset_column;
146 }
147}
148
149pub struct StandardPayloadInputs<'a> {
156 pub formula: String,
157 pub dataset: &'a EncodedDataset,
158 pub fit_config: &'a FitConfig,
159 pub result: StandardFitResult,
160}
161
162fn fitted_inverse_link(state: &FittedLinkState) -> Option<InverseLink> {
163 match state {
164 FittedLinkState::Standard(Some(link)) => Some(InverseLink::Standard(*link)),
165 FittedLinkState::Standard(None) => None,
166 FittedLinkState::LatentCLogLog { state } => Some(InverseLink::LatentCLogLog(*state)),
167 FittedLinkState::Sas { state, .. } => Some(InverseLink::Sas(*state)),
168 FittedLinkState::BetaLogistic { state, .. } => Some(InverseLink::BetaLogistic(*state)),
169 FittedLinkState::Mixture { state, .. } => Some(InverseLink::Mixture(state.clone())),
170 }
171}
172
173fn standard_null_space_metadata(
174 design: &TermCollectionDesign,
175 fit: &UnifiedFitResult,
176) -> Result<(usize, f64), String> {
177 let hessian = fit
178 .penalized_hessian()
179 .ok_or_else(|| "null-space Hessian logdet requires fitted penalized Hessian".to_string())?;
180 let hessian_dim = hessian.nrows();
181 if hessian.ncols() != hessian_dim {
182 return Err(format!(
183 "null-space Hessian logdet requires a square Hessian, got {}x{}",
184 hessian.nrows(),
185 hessian.ncols()
186 ));
187 }
188 let p = design.design.ncols();
189 if hessian_dim < p {
190 return Err(format!(
191 "null-space Hessian logdet design/Hessian mismatch: design has {p} columns but \
192 Hessian is only {hessian_dim}x{hessian_dim}"
193 ));
194 }
195 if design.penalties.is_empty() {
196 return Ok((0, 0.0));
197 }
198 let hessian = if hessian_dim > p {
199 hessian.slice(s![0..p, 0..p]).to_owned()
200 } else {
201 hessian.clone()
202 };
203 let mut penalty = Array2::<f64>::zeros((p, p));
204 for (idx, block) in design.penalties.iter().enumerate() {
205 let range = block.col_range.clone();
206 if range.start > range.end
207 || range.end > p
208 || block.local.nrows() != range.len()
209 || block.local.ncols() != range.len()
210 {
211 return Err(format!(
212 "null-space Hessian logdet penalty {idx} shape mismatch: range {}..{}, local {}x{}, p={p}",
213 range.start,
214 range.end,
215 block.local.nrows(),
216 block.local.ncols()
217 ));
218 }
219 penalty
220 .slice_mut(s![range.clone(), range])
221 .scaled_add(1.0, &block.local);
222 }
223 let (null_basis, _) = gam_linalg::faer_ndarray::rrqr_nullspace_basis(
224 &penalty,
225 gam_linalg::faer_ndarray::default_rrqr_rank_alpha(),
226 )
227 .map_err(|err| format!("failed to compute penalty null-space basis: {err}"))?;
228 let q = null_basis.ncols();
229 if q == 0 {
230 return Ok((0, 0.0));
231 }
232 let projected = hessian.dot(&null_basis);
233 let mut restricted = null_basis.t().dot(&projected);
234 restricted = (&restricted + &restricted.t()) * 0.5;
235 let chol = restricted
236 .cholesky(Side::Lower)
237 .map_err(|err| format!("null-space Hessian is not positive definite: {err}"))?;
238 let logdet = 2.0 * chol.diag().iter().map(|value| value.ln()).sum::<f64>();
239 if logdet.is_finite() {
240 Ok((q, logdet))
241 } else {
242 Err(format!("null-space Hessian logdet is not finite: {logdet}"))
243 }
244}
245
246fn response_for_standard_payload(formula: &str, dataset: &EncodedDataset) -> Option<Array1<f64>> {
247 let response = gam_terms::inference::formula_dsl::parse_formula(formula)
248 .ok()?
249 .response;
250 let column = *dataset.column_map().get(&response)?;
251 Some(dataset.values.column(column).to_owned())
252}
253
254fn standard_conformal_substrates(
255 formula: &str,
256 dataset: &EncodedDataset,
257 fit_config: &FitConfig,
258 family: &LikelihoodSpec,
259 fit: &UnifiedFitResult,
260 design: &TermCollectionDesign,
261) -> (
262 Option<crate::inference::full_conformal::GaussianJackknifePlusStats>,
263 Option<crate::inference::full_conformal::ExactFullConformalSubstrate>,
264) {
265 let expectile = fit_config.family.as_deref().is_some_and(|family| {
266 let family = family.trim().to_ascii_lowercase();
267 family == "expectile" || family.starts_with("expectile(")
268 });
269 if expectile
270 || !family.is_gaussian_identity()
271 || fit_config.weight_column.is_some()
272 || fit_config.offset_column.is_some()
273 || fit_config.flexible_link
274 {
275 return (None, None);
276 }
277 let Some(y) = response_for_standard_payload(formula, dataset) else {
278 return (None, None);
279 };
280 let Ok(x) = design.design.try_to_dense_arc("standard conformal design") else {
281 return (None, None);
282 };
283 let Some(normal_matrix) = fit.penalized_hessian() else {
284 return (None, None);
285 };
286 if x.nrows() != y.len()
287 || normal_matrix.nrows() != x.ncols()
288 || normal_matrix.ncols() != x.ncols()
289 {
290 return (None, None);
291 }
292 let weights = Array1::<f64>::ones(y.len());
293 let jackknife = crate::inference::full_conformal::GaussianJackknifePlusStats::from_design_unit_weight_normal_matrix(
294 x.as_ref(),
295 &y,
296 &weights,
297 normal_matrix,
298 )
299 .ok();
300 let full = crate::inference::full_conformal::ExactFullConformalSubstrate::from_design_unit_weight_normal_matrix(
301 x.as_ref(),
302 &y,
303 &weights,
304 normal_matrix,
305 )
306 .ok();
307 (jackknife, full)
308}
309
310pub fn assemble_standard_payload(
312 inputs: StandardPayloadInputs<'_>,
313) -> Result<FittedModelPayload, String> {
314 let StandardPayloadInputs {
315 formula,
316 dataset,
317 fit_config,
318 result,
319 } = inputs;
320 let StandardFitResult {
321 mut fit,
322 design,
323 resolvedspec,
324 adaptive_diagnostics,
325 saved_link_state,
326 wiggle_knots,
327 wiggle_degree,
328 wiggle_saved_warp_beta,
329 wiggle_saved_index_shift,
330 ..
331 } = result;
332 fit.fitted_link = saved_link_state;
333 let resolved_termspec = freeze_term_collection_from_design(&resolvedspec, &design)
334 .map_err(|err| format!("failed to freeze standard term specification: {err}"))?;
335 let (null_space_dim, null_space_logdet) = standard_null_space_metadata(&design, &fit)?;
336 fit.artifacts.null_space_dim = Some(null_space_dim);
337 fit.artifacts.null_space_logdet = Some(null_space_logdet);
338 let family = fit
339 .likelihood_family
340 .clone()
341 .unwrap_or_else(LikelihoodSpec::gaussian_identity);
342 let (gaussian_jackknife_plus, full_conformal) =
343 standard_conformal_substrates(&formula, dataset, fit_config, &family, &fit, &design);
344 let latent_cloglog_state = if family.is_latent_cloglog() {
345 Some(saved_latent_cloglog_state_from_fit(&fit).ok_or_else(|| {
346 "latent-cloglog-binomial fit did not produce a fitted latent-cloglog state".to_string()
347 })?)
348 } else {
349 saved_latent_cloglog_state_from_fit(&fit)
350 };
351 let mut payload = FittedModelPayload::new(
352 MODEL_PAYLOAD_VERSION,
353 formula,
354 ModelKind::Standard,
355 FittedFamily::Standard {
356 likelihood: family.clone(),
357 link: StandardLink::try_from(family.link_function()).ok(),
358 latent_cloglog_state,
359 mixture_state: saved_mixture_state_from_fit(&fit),
360 sas_state: saved_sas_state_from_fit(&fit),
361 },
362 family.name().to_string(),
363 );
364 payload.unified = Some(fit.clone());
365 payload.fit_result = Some(fit.clone());
366 payload.data_schema = Some(dataset.schema.clone());
367 payload.link = fitted_inverse_link(&fit.fitted_link).or_else(|| Some(family.link.clone()));
368 payload.linkwiggle_knots = wiggle_knots.map(|knots| knots.to_vec());
369 payload.linkwiggle_degree = wiggle_degree;
370 payload.beta_link_wiggle = wiggle_saved_warp_beta;
371 payload.link_wiggle_index_shift = wiggle_saved_index_shift;
372 match &fit.fitted_link {
373 FittedLinkState::Mixture { covariance, .. } => {
374 payload.mixture_link_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
375 }
376 FittedLinkState::Sas { covariance, .. }
377 | FittedLinkState::BetaLogistic { covariance, .. } => {
378 payload.sas_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
379 }
380 FittedLinkState::Standard(_) | FittedLinkState::LatentCLogLog { .. } => {}
381 }
382 payload.set_training_feature_metadata(dataset.headers.clone(), dataset.feature_ranges());
383 payload.resolved_termspec = Some(resolved_termspec);
384 payload.adaptive_regularization_diagnostics = adaptive_diagnostics;
385 payload.offset_column = fit_config.offset_column.clone();
386 payload.noise_offset_column = fit_config.noise_offset_column.clone();
387 payload.weight_column = fit_config.weight_column.clone();
388 payload.gaussian_jackknife_plus = gaussian_jackknife_plus;
389 payload.full_conformal = full_conformal;
390 Ok(payload)
391}
392
393pub struct BernoulliMarginalSlopeInputs<'a> {
400 pub formula: String,
401 pub data_schema: DataSchema,
402 pub logslope_formula: String,
403 pub z_column: String,
404 pub resolved_marginalspec: TermCollectionSpec,
405 pub resolved_logslopespec: TermCollectionSpec,
406 pub fit_result: UnifiedFitResult,
407 pub p_marginal: usize,
420 pub baseline_marginal: f64,
421 pub baseline_logslope: f64,
422 pub latent_z_normalization: SavedLatentZNormalization,
423 pub latent_measure: LatentMeasureKind,
424 pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
425 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
426 pub score_warp_runtime: Option<&'a DeviationRuntime>,
427 pub link_dev_runtime: Option<&'a DeviationRuntime>,
428 pub base_link: InverseLink,
429 pub frailty: crate::survival::lognormal_kernel::FrailtySpec,
430}
431
432fn truncate_marginal_slope_influence_absorber(
465 fit_result: UnifiedFitResult,
466 p_marginal: usize,
467) -> Result<UnifiedFitResult, String> {
468 let Some(block0) = fit_result.blocks.first() else {
469 return Err("marginal-slope fit result has no coefficient blocks".to_string());
470 };
471 let widened_len = block0.beta.len();
472 if widened_len <= p_marginal {
473 return Ok(fit_result);
475 }
476 let p_influence = widened_len - p_marginal;
477
478 let pirls_status = fit_result.convergence_evidence().inner_status();
482 let UnifiedFitResult {
483 mut blocks,
484 log_lambdas,
485 lambdas,
486 likelihood_family,
487 likelihood_scale,
488 log_likelihood_normalization,
489 log_likelihood,
490 deviance,
491 reml_score,
492 stable_penalty_term,
493 penalized_objective,
494 used_device,
495 outer_iterations,
496 outer_gradient_norm,
497 standard_deviation,
498 covariance_conditional,
499 covariance_corrected,
500 inference,
501 fitted_link,
502 geometry: _,
503 mut block_states,
504 beta: _,
505 max_abs_eta,
506 constraint_kkt,
507 artifacts,
508 inner_cycles,
509 outer_cost_evals: _,
510 inner_pirls_solves: _,
511 ..
512 } = fit_result;
513
514 blocks[0].beta = blocks[0].beta.slice(ndarray::s![..p_marginal]).to_owned();
517 if let Some(state0) = block_states.first_mut() {
518 state0.beta = state0.beta.slice(ndarray::s![..p_marginal]).to_owned();
519 }
520
521 let drop_gamma_block = |cov: Option<Array2<f64>>| -> Option<Array2<f64>> {
524 cov.map(|cov| {
525 let total = cov.nrows();
526 let kept: Vec<usize> = (0..p_marginal)
527 .chain((p_marginal + p_influence)..total)
528 .collect();
529 let mut out = Array2::<f64>::zeros((kept.len(), kept.len()));
530 for (ri, &r) in kept.iter().enumerate() {
531 for (ci, &c) in kept.iter().enumerate() {
532 out[[ri, ci]] = cov[[r, c]];
533 }
534 }
535 out
536 })
537 };
538 let covariance_conditional = drop_gamma_block(covariance_conditional);
539 let covariance_corrected = drop_gamma_block(covariance_corrected);
540
541 UnifiedFitResult::try_from_parts(gam_solve::estimate::UnifiedFitResultParts {
542 blocks,
543 log_lambdas,
544 lambdas,
545 likelihood_family,
546 likelihood_scale,
547 log_likelihood_normalization,
548 log_likelihood,
549 deviance,
550 reml_score,
551 stable_penalty_term,
552 penalized_objective,
553 used_device,
557 outer_iterations,
558 outer_converged: true,
559 outer_gradient_norm,
560 standard_deviation,
561 covariance_conditional,
562 covariance_corrected,
563 inference,
564 fitted_link,
565 geometry: None,
567 block_states,
568 pirls_status,
569 max_abs_eta,
570 constraint_kkt,
571 artifacts,
572 inner_cycles,
573 })
574 .map_err(|e| {
575 format!("marginal-slope influence-absorber truncation produced an invalid fit result: {e}")
576 })
577}
578
579pub fn assemble_spline_scan_payload(
585 formula: String,
586 feature_column: String,
587 fit: &gam_solve::spline_scan::SplineScanFit,
588 data_schema: DataSchema,
589 training_headers: Vec<String>,
590 training_feature_ranges: Vec<(f64, f64)>,
591) -> FittedModelPayload {
592 let mut payload = FittedModelPayload::new(
593 MODEL_PAYLOAD_VERSION,
594 formula,
595 ModelKind::Standard,
596 FittedFamily::Standard {
597 likelihood: LikelihoodSpec::gaussian_identity(),
598 link: None,
599 latent_cloglog_state: None,
600 mixture_state: None,
601 sas_state: None,
602 },
603 "gaussian".to_string(),
604 );
605 payload.spline_scan = Some(SavedSplineScan {
606 feature_column,
607 state: fit.to_state(),
608 });
609 payload.data_schema = Some(data_schema);
610 payload.set_training_feature_metadata(training_headers, training_feature_ranges);
611 payload
612}
613
614pub fn assemble_residual_cascade_payload(
620 formula: String,
621 feature_columns: Vec<String>,
622 fit: &gam_solve::residual_cascade::ResidualCascadeFit,
623 data_schema: DataSchema,
624 training_headers: Vec<String>,
625 training_feature_ranges: Vec<(f64, f64)>,
626) -> Result<FittedModelPayload, String> {
627 let mut payload = FittedModelPayload::new(
628 MODEL_PAYLOAD_VERSION,
629 formula,
630 ModelKind::Standard,
631 FittedFamily::Standard {
632 likelihood: gam_problem::types::LikelihoodSpec::gaussian_identity(),
633 link: None,
634 latent_cloglog_state: None,
635 mixture_state: None,
636 sas_state: None,
637 },
638 "gaussian".to_string(),
639 );
640 payload.residual_cascade = Some(SavedResidualCascade {
641 feature_columns,
642 state: fit.to_state().map_err(|e| {
643 format!("residual-cascade to_state failed during payload assembly: {e}")
644 })?,
645 });
646 payload.data_schema = Some(data_schema);
647 payload.set_training_feature_metadata(training_headers, training_feature_ranges);
648 Ok(payload)
649}
650
651pub fn assemble_bernoulli_marginal_slope_payload(
659 inputs: BernoulliMarginalSlopeInputs<'_>,
660 source: SavedModelSourceMetadata,
661) -> Result<FittedModelPayload, String> {
662 let BernoulliMarginalSlopeInputs {
663 formula,
664 data_schema,
665 logslope_formula,
666 z_column,
667 resolved_marginalspec,
668 resolved_logslopespec,
669 fit_result,
670 p_marginal,
671 baseline_marginal,
672 baseline_logslope,
673 latent_z_normalization,
674 latent_measure,
675 latent_z_rank_int_calibration,
676 latent_z_conditional_calibration,
677 score_warp_runtime,
678 link_dev_runtime,
679 base_link,
680 frailty,
681 } = inputs;
682
683 let fit_result = truncate_marginal_slope_influence_absorber(fit_result, p_marginal)?;
687
688 let marginal_likelihood_spec =
689 inverse_link_to_binomial_spec(&base_link).map_err(|e| e.to_string())?;
690
691 let mut payload = FittedModelPayload::new(
692 MODEL_PAYLOAD_VERSION,
693 formula,
694 ModelKind::MarginalSlope,
695 FittedFamily::MarginalSlope {
696 likelihood: marginal_likelihood_spec,
697 base_link: base_link.clone(),
698 frailty,
699 },
700 FAMILY_BERNOULLI_MARGINAL_SLOPE.to_string(),
701 );
702 payload.unified = Some(fit_result.clone());
703 payload.fit_result = Some(fit_result);
704 payload.data_schema = Some(data_schema);
705 payload.formula_logslope = Some(logslope_formula.clone());
706 payload.z_column = Some(z_column.clone());
707 payload.formula_logslopes = Some(vec![logslope_formula]);
708 payload.z_columns = Some(vec![z_column]);
709 payload.latent_z_normalization = Some(latent_z_normalization);
710 payload.latent_measure = Some(latent_measure);
711 payload.latent_z_rank_int_calibration = latent_z_rank_int_calibration;
712 payload.latent_z_conditional_calibration = latent_z_conditional_calibration;
713 payload.marginal_baseline = Some(baseline_marginal);
714 payload.logslope_baseline = Some(baseline_logslope);
715 payload.logslope_baselines = Some(vec![baseline_logslope]);
716 payload.link = Some(base_link);
717 payload.resolved_termspec = Some(resolved_marginalspec);
718 payload.resolved_termspec_logslopes = Some(vec![resolved_logslopespec.clone()]);
719 payload.resolved_termspec_logslope = Some(resolved_logslopespec);
720 payload.score_warp_runtime = score_warp_runtime.map(serialize_anchored_deviation_runtime);
721 payload.link_deviation_runtime = link_dev_runtime.map(serialize_anchored_deviation_runtime);
722 source.apply_to(&mut payload);
723 Ok(payload)
724}
725
726pub struct TransformationNormalInputs<'a> {
733 pub formula: String,
734 pub data_schema: DataSchema,
735 pub resolved_covariate_spec: TermCollectionSpec,
736 pub fit_result: UnifiedFitResult,
737 pub family: &'a TransformationNormalFamily,
738 pub score_calibration: TransformationScoreCalibration,
739}
740
741pub fn assemble_transformation_normal_payload(
747 inputs: TransformationNormalInputs<'_>,
748 source: SavedModelSourceMetadata,
749) -> FittedModelPayload {
750 let TransformationNormalInputs {
751 formula,
752 data_schema,
753 resolved_covariate_spec,
754 fit_result,
755 family,
756 score_calibration,
757 } = inputs;
758
759 let mut payload = FittedModelPayload::new(
760 MODEL_PAYLOAD_VERSION,
761 formula,
762 ModelKind::TransformationNormal,
763 FittedFamily::TransformationNormal {
764 likelihood: LikelihoodSpec::new(
765 ResponseFamily::Gaussian,
766 InverseLink::Standard(StandardLink::Identity),
767 ),
768 },
769 FAMILY_TRANSFORMATION_NORMAL.to_string(),
770 );
771 payload.unified = Some(fit_result.clone());
772 payload.fit_result = Some(fit_result);
773 payload.data_schema = Some(data_schema);
774 payload.resolved_termspec = Some(resolved_covariate_spec);
775 payload.transformation_response_knots = Some(family.response_knots().to_vec());
776 payload.transformation_response_transform = Some(
777 family
778 .response_transform()
779 .rows()
780 .into_iter()
781 .map(|row| row.to_vec())
782 .collect(),
783 );
784 payload.transformation_response_degree = Some(family.response_degree());
785 payload.transformation_response_median = Some(family.response_median());
786 payload.transformation_score_calibration = Some(score_calibration);
787 source.apply_to(&mut payload);
788 payload
789}
790
791pub enum LocationScaleResponse<'a> {
797 Gaussian {
800 response_scale: f64,
801 base_link: Option<InverseLink>,
802 },
803 Binomial {
805 link: InverseLink,
806 noise_transform: &'a ScaleDeviationTransform,
807 },
808 Dispersion {
814 likelihood: LikelihoodSpec,
815 base_link: InverseLink,
816 family_tag: &'static str,
817 },
818}
819
820pub struct LocationScaleWiggle {
825 pub knots: Vec<f64>,
826 pub degree: usize,
827 pub beta_link_wiggle: Vec<f64>,
828}
829
830pub struct LocationScaleInputs {
834 pub formula: String,
835 pub data_schema: DataSchema,
836 pub noise_formula: String,
837 pub resolved_termspec: TermCollectionSpec,
838 pub resolved_termspec_noise: TermCollectionSpec,
839 pub fit_result: UnifiedFitResult,
840 pub beta_noise: Option<Vec<f64>>,
841 pub wiggle: Option<LocationScaleWiggle>,
842}
843
844pub fn assemble_location_scale_payload(
849 inputs: LocationScaleInputs,
850 response: LocationScaleResponse<'_>,
851 source: SavedModelSourceMetadata,
852) -> Result<FittedModelPayload, String> {
853 let (family_tag, likelihood, base_link, link, response_scale, noise_transform) = match response
854 {
855 LocationScaleResponse::Gaussian {
856 response_scale,
857 base_link,
858 } => (
859 "gaussian-location-scale".to_string(),
860 LikelihoodSpec::gaussian_identity(),
861 None,
865 Some(base_link.unwrap_or(InverseLink::Standard(StandardLink::Identity))),
866 Some(response_scale),
867 None,
868 ),
869 LocationScaleResponse::Binomial {
870 link,
871 noise_transform,
872 } => {
873 let likelihood = inverse_link_to_binomial_spec(&link).map_err(|e| {
874 format!("failed to resolve LikelihoodSpec for binomial location-scale link {link:?}: {e}")
875 })?;
876 (
877 "binomial-location-scale".to_string(),
878 likelihood,
879 Some(link.clone()),
880 Some(link),
881 None,
882 Some(noise_transform),
883 )
884 }
885 LocationScaleResponse::Dispersion {
886 likelihood,
887 base_link,
888 family_tag,
889 } => (
890 family_tag.to_string(),
891 likelihood,
892 Some(base_link.clone()),
893 Some(base_link),
894 None,
895 None,
896 ),
897 };
898
899 let mut payload = FittedModelPayload::new(
900 MODEL_PAYLOAD_VERSION,
901 inputs.formula,
902 ModelKind::LocationScale,
903 FittedFamily::LocationScale {
904 likelihood,
905 base_link,
906 },
907 family_tag,
908 );
909 payload.unified = Some(inputs.fit_result.clone());
910 payload.fit_result = Some(inputs.fit_result);
911 payload.data_schema = Some(inputs.data_schema);
912 payload.link = link;
913 payload.formula_noise = Some(inputs.noise_formula);
914 payload.beta_noise = inputs.beta_noise;
915 payload.gaussian_response_scale = response_scale;
916 if let Some(transform) = noise_transform {
917 payload.noise_projection = Some(
918 transform
919 .projection_coef
920 .rows()
921 .into_iter()
922 .map(|row| row.to_vec())
923 .collect(),
924 );
925 payload.noise_center = Some(transform.weighted_column_mean.to_vec());
926 payload.noise_scale = Some(transform.rescale.to_vec());
927 payload.noise_non_intercept_start = Some(transform.non_intercept_start);
928 payload.noise_projection_ridge_alpha = Some(transform.projection_ridge_alpha);
929 }
930 payload.resolved_termspec = Some(inputs.resolved_termspec);
931 payload.resolved_termspec_noise = Some(inputs.resolved_termspec_noise);
932 if let Some(wiggle) = inputs.wiggle {
933 payload.linkwiggle_knots = Some(wiggle.knots);
934 payload.linkwiggle_degree = Some(wiggle.degree);
935 payload.beta_link_wiggle = Some(wiggle.beta_link_wiggle);
936 }
937 source.apply_to(&mut payload);
938 Ok(payload)
939}
940
941pub struct SurvivalMarginalSlopeInputs<'a> {
946 pub formula: String,
947 pub data_schema: DataSchema,
948 pub fit_result: UnifiedFitResult,
949 pub frailty: crate::survival::lognormal_kernel::FrailtySpec,
950 pub survival_entry: Option<String>,
951 pub survival_exit: String,
952 pub survival_event: String,
953 pub survivalspec: String,
954 pub baseline_cfg: SurvivalBaselineConfig,
955 pub time_basis: SavedSurvivalTimeBasis,
956 pub ridge_lambda: f64,
957 pub survival_likelihood_label: String,
958 pub resolved_marginalspec: TermCollectionSpec,
959 pub resolved_logslopespec: TermCollectionSpec,
960 pub logslope_formula: String,
961 pub z_column: String,
962 pub latent_z_normalization: SavedLatentZNormalization,
963 pub baseline_logslope: f64,
964 pub score_warp_runtime: Option<&'a DeviationRuntime>,
965 pub link_dev_runtime: Option<&'a DeviationRuntime>,
966 pub influence_absorber_width: Option<usize>,
971}
972
973fn new_royston_parmar_survival_payload(
981 formula: String,
982 fit_result: UnifiedFitResult,
983 data_schema: DataSchema,
984 survival_likelihood_label: &str,
985 survival_distribution: Option<ResidualDistribution>,
986 frailty: crate::survival::lognormal_kernel::FrailtySpec,
987) -> FittedModelPayload {
988 let mut payload = FittedModelPayload::new(
989 MODEL_PAYLOAD_VERSION,
990 formula,
991 ModelKind::Survival,
992 FittedFamily::Survival {
993 likelihood: LikelihoodSpec::new(
994 ResponseFamily::RoystonParmar,
995 InverseLink::Standard(StandardLink::Identity),
996 ),
997 survival_likelihood: Some(survival_likelihood_label.to_string()),
998 survival_distribution,
999 frailty,
1000 },
1001 ResponseFamily::RoystonParmar.name().to_string(),
1002 );
1003 payload.unified = Some(fit_result.clone());
1004 payload.fit_result = Some(fit_result);
1005 payload.data_schema = Some(data_schema);
1006 payload
1007}
1008
1009pub fn assemble_survival_marginal_slope_payload(
1012 inputs: SurvivalMarginalSlopeInputs<'_>,
1013 source: SavedModelSourceMetadata,
1014) -> FittedModelPayload {
1015 let mut payload = new_royston_parmar_survival_payload(
1016 inputs.formula,
1017 inputs.fit_result,
1018 inputs.data_schema,
1019 &inputs.survival_likelihood_label,
1020 Some(ResidualDistribution::Gaussian),
1021 inputs.frailty,
1022 );
1023 payload.survival_entry = inputs.survival_entry;
1024 payload.survival_exit = Some(inputs.survival_exit);
1025 payload.survival_event = Some(inputs.survival_event);
1026 payload.survivalspec = Some(inputs.survivalspec);
1027 payload.survival_baseline_target =
1028 Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1029 payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1030 payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1031 payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1032 payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1033 payload.apply_survival_time_basis(&inputs.time_basis);
1034 payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1035 payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1036 payload.survival_distribution = Some(ResidualDistribution::Gaussian);
1037 payload.link = Some(InverseLink::Standard(StandardLink::Probit));
1038 payload.resolved_termspec = Some(inputs.resolved_marginalspec);
1039 payload.resolved_termspec_logslopes = Some(vec![inputs.resolved_logslopespec.clone()]);
1040 payload.resolved_termspec_logslope = Some(inputs.resolved_logslopespec);
1041 payload.formula_logslope = Some(inputs.logslope_formula.clone());
1042 payload.formula_logslopes = Some(vec![inputs.logslope_formula]);
1043 payload.z_column = Some(inputs.z_column.clone());
1044 payload.z_columns = Some(vec![inputs.z_column]);
1045 payload.latent_z_normalization = Some(inputs.latent_z_normalization);
1046 payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
1047 payload.logslope_baseline = Some(inputs.baseline_logslope);
1048 payload.logslope_baselines = Some(vec![inputs.baseline_logslope]);
1049 payload.score_warp_runtime = inputs
1050 .score_warp_runtime
1051 .map(serialize_anchored_deviation_runtime);
1052 payload.link_deviation_runtime = inputs
1053 .link_dev_runtime
1054 .map(serialize_anchored_deviation_runtime);
1055 payload.influence_absorber_width = inputs.influence_absorber_width;
1056 source.apply_to(&mut payload);
1057 payload
1058}
1059
1060pub enum SurvivalTimewiggleBeta {
1063 Single(Vec<f64>),
1064 ByCause(Vec<Vec<f64>>),
1065}
1066
1067fn apply_timewiggle_beta(payload: &mut FittedModelPayload, beta: SurvivalTimewiggleBeta) {
1073 match beta {
1074 SurvivalTimewiggleBeta::Single(beta) => {
1075 payload.beta_baseline_timewiggle = Some(beta);
1076 }
1077 SurvivalTimewiggleBeta::ByCause(by_cause) => {
1078 payload.beta_baseline_timewiggle_by_cause = Some(by_cause);
1079 }
1080 }
1081}
1082
1083pub struct SurvivalTimewiggle {
1085 pub degree: usize,
1086 pub knots: Vec<f64>,
1087 pub penalty_orders: Option<Vec<usize>>,
1088 pub double_penalty: Option<bool>,
1089 pub beta: SurvivalTimewiggleBeta,
1090}
1091
1092pub struct SurvivalTransformationInputs {
1095 pub formula: String,
1096 pub data_schema: DataSchema,
1097 pub fit_result: UnifiedFitResult,
1098 pub survival_entry: Option<String>,
1099 pub survival_exit: String,
1100 pub survival_event: String,
1101 pub survivalspec: String,
1102 pub cause_count: Option<usize>,
1105 pub baseline_cfg: SurvivalBaselineConfig,
1106 pub time_basis: SavedSurvivalTimeBasis,
1107 pub ridge_lambda: f64,
1108 pub survival_likelihood_label: String,
1109 pub resolved_termspec: TermCollectionSpec,
1110 pub survival_beta_time: Option<Vec<f64>>,
1112 pub timewiggle: Option<SurvivalTimewiggle>,
1113}
1114
1115pub fn assemble_survival_transformation_payload(
1118 inputs: SurvivalTransformationInputs,
1119 source: SavedModelSourceMetadata,
1120) -> FittedModelPayload {
1121 let mut payload = new_royston_parmar_survival_payload(
1122 inputs.formula,
1123 inputs.fit_result,
1124 inputs.data_schema,
1125 &inputs.survival_likelihood_label,
1126 None,
1127 crate::survival::lognormal_kernel::FrailtySpec::None,
1128 );
1129 payload.survival_entry = inputs.survival_entry;
1130 payload.survival_exit = Some(inputs.survival_exit);
1131 payload.survival_event = Some(inputs.survival_event);
1132 payload.survivalspec = Some(inputs.survivalspec);
1133 if let Some(cause_count) = inputs.cause_count {
1134 payload.survival_cause_count = Some(cause_count);
1135 payload.survival_endpoint_names = Some(
1136 (1..=cause_count)
1137 .map(|idx| format!("cause_{idx}"))
1138 .collect(),
1139 );
1140 }
1141 payload.survival_baseline_target =
1142 Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1143 payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1144 payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1145 payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1146 payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1147 payload.apply_survival_time_basis(&inputs.time_basis);
1148 if let Some(timewiggle) = inputs.timewiggle {
1149 payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1150 payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1151 payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1152 payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1153 apply_timewiggle_beta(&mut payload, timewiggle.beta);
1154 }
1155 payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1156 payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1157 payload.survival_beta_time = inputs.survival_beta_time;
1158 payload.resolved_termspec = Some(inputs.resolved_termspec);
1159 source.apply_to(&mut payload);
1160 payload
1161}
1162
1163pub struct SurvivalLocationScaleInputs<'a> {
1168 pub formula: String,
1169 pub data_schema: DataSchema,
1170 pub fit_result: UnifiedFitResult,
1173 pub fitted_inverse_link: InverseLink,
1174 pub linkwiggle_degree: Option<usize>,
1177 pub linkwiggle_knots: Option<Vec<f64>>,
1178 pub beta_link_wiggle: Option<Vec<f64>>,
1179 pub baseline_timewiggle: Option<SurvivalTimewiggle>,
1180 pub survival_entry: Option<String>,
1181 pub survival_exit: String,
1182 pub survival_event: String,
1183 pub survivalspec: String,
1184 pub baseline_cfg: SurvivalBaselineConfig,
1185 pub time_basis: SavedSurvivalTimeBasis,
1186 pub ridge_lambda: f64,
1187 pub survival_likelihood_label: String,
1188 pub formula_noise: Option<String>,
1189 pub survival_beta_time: Vec<f64>,
1190 pub survival_beta_threshold: Vec<f64>,
1191 pub survival_beta_log_sigma: Vec<f64>,
1192 pub noise_transform: &'a ScaleDeviationTransform,
1193 pub resolved_thresholdspec: TermCollectionSpec,
1194 pub resolved_log_sigmaspec: TermCollectionSpec,
1195}
1196
1197pub fn assemble_survival_location_scale_payload(
1200 inputs: SurvivalLocationScaleInputs<'_>,
1201 source: SavedModelSourceMetadata,
1202) -> FittedModelPayload {
1203 let survival_distribution =
1204 residual_distribution_from_inverse_link(&inputs.fitted_inverse_link);
1205 let mut payload = new_royston_parmar_survival_payload(
1206 inputs.formula,
1207 inputs.fit_result,
1208 inputs.data_schema,
1209 &inputs.survival_likelihood_label,
1210 survival_distribution,
1211 crate::survival::lognormal_kernel::FrailtySpec::None,
1212 );
1213 payload.link = Some(inputs.fitted_inverse_link);
1214 payload.linkwiggle_degree = inputs.linkwiggle_degree;
1215 payload.linkwiggle_knots = inputs.linkwiggle_knots;
1216 payload.beta_link_wiggle = inputs.beta_link_wiggle;
1217 if let Some(timewiggle) = inputs.baseline_timewiggle {
1218 payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1219 payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1220 payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1221 payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1222 apply_timewiggle_beta(&mut payload, timewiggle.beta);
1223 }
1224 payload.survival_entry = inputs.survival_entry;
1225 payload.survival_exit = Some(inputs.survival_exit);
1226 payload.survival_event = Some(inputs.survival_event);
1227 payload.survivalspec = Some(inputs.survivalspec);
1228 payload.survival_baseline_target =
1229 Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1230 payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1231 payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1232 payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1233 payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1234 payload.apply_survival_time_basis(&inputs.time_basis);
1235 payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1236 payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1237 payload.formula_noise = inputs.formula_noise;
1238 payload.survival_beta_time = Some(inputs.survival_beta_time);
1239 payload.survival_beta_threshold = Some(inputs.survival_beta_threshold);
1240 payload.survival_beta_log_sigma = Some(inputs.survival_beta_log_sigma);
1241 payload.survival_noise_projection = Some(
1242 inputs
1243 .noise_transform
1244 .projection_coef
1245 .rows()
1246 .into_iter()
1247 .map(|row| row.to_vec())
1248 .collect(),
1249 );
1250 payload.survival_noise_center = Some(inputs.noise_transform.weighted_column_mean.to_vec());
1251 payload.survival_noise_scale = Some(inputs.noise_transform.rescale.to_vec());
1252 payload.survival_noise_non_intercept_start = Some(inputs.noise_transform.non_intercept_start);
1253 payload.survival_noise_projection_ridge_alpha =
1254 Some(inputs.noise_transform.projection_ridge_alpha);
1255 payload.survival_distribution = survival_distribution;
1256 payload.resolved_termspec = Some(inputs.resolved_thresholdspec);
1257 payload.resolved_termspec_noise = Some(inputs.resolved_log_sigmaspec);
1258 source.apply_to(&mut payload);
1259 payload
1260}
1261
1262pub struct LatentWindowInputs {
1266 pub formula: String,
1267 pub data_schema: DataSchema,
1268 pub fit_result: UnifiedFitResult,
1269 pub family: FittedFamily,
1270 pub model_class_label: String,
1271 pub likelihood_label: String,
1272 pub survival_entry: Option<String>,
1273 pub survival_exit: String,
1274 pub survival_event: String,
1275 pub baseline_cfg: SurvivalBaselineConfig,
1276 pub time_basis: SavedSurvivalTimeBasis,
1277 pub ridge_lambda: f64,
1278 pub beta_time: Vec<f64>,
1279 pub resolved_termspec: TermCollectionSpec,
1280}
1281
1282pub fn assemble_latent_window_payload(
1284 inputs: LatentWindowInputs,
1285 source: SavedModelSourceMetadata,
1286) -> FittedModelPayload {
1287 let mut payload = FittedModelPayload::new(
1288 MODEL_PAYLOAD_VERSION,
1289 inputs.formula,
1290 ModelKind::Survival,
1291 inputs.family,
1292 inputs.model_class_label,
1293 );
1294 payload.unified = Some(inputs.fit_result.clone());
1295 payload.fit_result = Some(inputs.fit_result);
1296 payload.data_schema = Some(inputs.data_schema);
1297 payload.survival_entry = inputs.survival_entry;
1298 payload.survival_exit = Some(inputs.survival_exit);
1299 payload.survival_event = Some(inputs.survival_event);
1300 payload.survivalspec = Some("net".to_string());
1301 payload.survival_baseline_target =
1302 Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1303 payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1304 payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1305 payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1306 payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1307 payload.apply_survival_time_basis(&inputs.time_basis);
1308 payload.survival_likelihood = Some(inputs.likelihood_label);
1309 payload.survival_beta_time = Some(inputs.beta_time);
1310 payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1311 payload.resolved_termspec = Some(inputs.resolved_termspec);
1312 source.apply_to(&mut payload);
1313 payload
1314}
1315
1316#[cfg(test)]
1317mod apply_timewiggle_beta_tests {
1318 use super::*;
1319
1320 fn empty_payload() -> FittedModelPayload {
1323 FittedModelPayload::new(
1324 MODEL_PAYLOAD_VERSION,
1325 "y ~ 1".to_string(),
1326 ModelKind::Survival,
1327 FittedFamily::LatentBinary {
1328 frailty: crate::survival::lognormal_kernel::FrailtySpec::None,
1329 },
1330 "test".to_string(),
1331 )
1332 }
1333
1334 #[test]
1335 fn by_cause_beta_populates_only_the_by_cause_slot() {
1336 let mut payload = empty_payload();
1337 apply_timewiggle_beta(
1338 &mut payload,
1339 SurvivalTimewiggleBeta::ByCause(vec![vec![1.0, 2.0], vec![3.0]]),
1340 );
1341 assert_eq!(
1342 payload.beta_baseline_timewiggle_by_cause,
1343 Some(vec![vec![1.0, 2.0], vec![3.0]]),
1344 "ByCause coefficients must land in the by-cause slot (regression: the \
1345 location-scale assembler used to silently drop them)"
1346 );
1347 assert!(
1348 payload.beta_baseline_timewiggle.is_none(),
1349 "ByCause must not populate the single-block slot"
1350 );
1351 }
1352
1353 #[test]
1354 fn single_beta_populates_only_the_flat_slot() {
1355 let mut payload = empty_payload();
1356 apply_timewiggle_beta(&mut payload, SurvivalTimewiggleBeta::Single(vec![4.0, 5.0]));
1357 assert_eq!(payload.beta_baseline_timewiggle, Some(vec![4.0, 5.0]));
1358 assert!(payload.beta_baseline_timewiggle_by_cause.is_none());
1359 }
1360}