1use super::*;
6
7#[derive(Clone, Copy)]
8pub(crate) struct GamlssLambdaLayout {
9 pub(crate) k_mean: usize,
10 pub(crate) k_noise: usize,
11 pub(crate) kwiggle: usize,
12}
13
14impl GamlssLambdaLayout {
15 pub(crate) fn two_block(k_mean: usize, k_noise: usize) -> Self {
16 Self {
17 k_mean,
18 k_noise,
19 kwiggle: 0,
20 }
21 }
22
23 pub(crate) fn withwiggle(k_mean: usize, k_noise: usize, kwiggle: usize) -> Self {
24 Self {
25 k_mean,
26 k_noise,
27 kwiggle,
28 }
29 }
30
31 pub(crate) fn total(self) -> usize {
32 self.k_mean + self.k_noise + self.kwiggle
33 }
34
35 pub(crate) fn noise_start(self) -> usize {
36 self.k_mean
37 }
38
39 pub(crate) fn noise_end(self) -> usize {
40 self.k_mean + self.k_noise
41 }
42
43 pub(crate) fn wiggle_start(self) -> usize {
44 self.k_mean + self.k_noise
45 }
46
47 pub(crate) fn wiggle_end(self) -> usize {
48 self.k_mean + self.k_noise + self.kwiggle
49 }
50
51 pub(crate) fn validate_theta_len(self, theta_len: usize, context: &str) -> Result<(), String> {
52 let needed = self.total();
53 if theta_len < needed {
54 return Err(GamlssError::DimensionMismatch {
55 reason: format!(
56 "{context} theta too short: got {}, need at least {}",
57 theta_len, needed
58 ),
59 }
60 .into());
61 }
62 Ok(())
63 }
64
65 pub(crate) fn mean_from(self, theta: &Array1<f64>) -> Array1<f64> {
66 theta.slice(s![0..self.k_mean]).to_owned()
67 }
68
69 pub(crate) fn noise_from(self, theta: &Array1<f64>) -> Array1<f64> {
70 theta
71 .slice(s![self.noise_start()..self.noise_end()])
72 .to_owned()
73 }
74
75 pub(crate) fn wiggle_from(self, theta: &Array1<f64>) -> Array1<f64> {
76 theta
77 .slice(s![self.wiggle_start()..self.wiggle_end()])
78 .to_owned()
79 }
80}
81
82#[derive(Clone, Copy)]
83pub(crate) struct GamlssBetaLayout {
84 pub(crate) pt: usize,
85 pub(crate) pls: usize,
86 pub(crate) pw: usize,
87}
88
89impl GamlssBetaLayout {
90 pub(crate) fn withwiggle(pt: usize, pls: usize, pw: usize) -> Self {
91 Self { pt, pls, pw }
92 }
93
94 pub(crate) fn total(self) -> usize {
95 self.pt + self.pls + self.pw
96 }
97
98 pub(crate) fn split_three(
99 self,
100 flat: &Array1<f64>,
101 context: &str,
102 ) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
103 if flat.len() != self.total() {
104 return Err(GamlssError::DimensionMismatch {
105 reason: format!(
106 "{context} length mismatch: got {}, expected {}",
107 flat.len(),
108 self.total()
109 ),
110 }
111 .into());
112 }
113 Ok((
114 flat.slice(s![0..self.pt]).to_owned(),
115 flat.slice(s![self.pt..self.pt + self.pls]).to_owned(),
116 flat.slice(s![self.pt + self.pls..self.total()]).to_owned(),
117 ))
118 }
119}
120
121#[derive(Clone, Debug)]
122pub struct FamilyMetadata {
123 pub name: &'static str,
124 pub parameternames: &'static [&'static str],
125 pub parameter_links: &'static [ParameterLink],
126}
127
128pub(crate) const DEFAULT_GAUGE_PRIORITY: u8 = 100;
129
130pub(crate) const LINK_WIGGLE_GAUGE_PRIORITY: u8 = 80;
131
132pub(crate) fn initial_log_lambdas_orzeros(
133 block: &ParameterBlockInput,
134) -> Result<Array1<f64>, String> {
135 let k = block.penalties.len();
136 let lambdas = block
137 .initial_log_lambdas
138 .clone()
139 .unwrap_or_else(|| Array1::<f64>::zeros(k));
140 if lambdas.len() != k {
141 return Err(GamlssError::DimensionMismatch {
142 reason: format!(
143 "initial_log_lambdas length mismatch: got {}, expected {}",
144 lambdas.len(),
145 k
146 ),
147 }
148 .into());
149 }
150 gam_problem::validate_log_strengths(lambdas.iter().copied())
151 .map_err(|error| format!("initial_log_lambdas: {error}"))?;
152 Ok(lambdas)
153}
154
155fn fitted_log_lambdas(lambdas: &Array1<f64>, context: &str) -> Result<Array1<f64>, String> {
156 lambdas
157 .iter()
158 .copied()
159 .enumerate()
160 .map(|(coordinate, value)| {
161 gam_problem::checked_log_strength(value)
162 .map_err(|error| format!("{context} coordinate {coordinate}: {error}"))
163 })
164 .collect::<Result<Vec<_>, _>>()
165 .map(Array1::from_vec)
166}
167
168pub(crate) fn build_two_block_exact_joint_setup(
169 data: ArrayView2<'_, f64>,
170 meanspec: &TermCollectionSpec,
171 noisespec: &TermCollectionSpec,
172 mean_penalties: usize,
173 noise_penalties: usize,
174 extra_rho0: &[f64],
175 rho0_override: Option<&Array1<f64>>,
176 kappa_options: &SpatialLengthScaleOptimizationOptions,
177) -> Result<ExactJointHyperSetup, gam_terms::basis::BasisError> {
178 let rho_dim = mean_penalties + noise_penalties + extra_rho0.len();
181 let mut rho0vec = Array1::<f64>::zeros(rho_dim);
182 if let Some(rho0) = rho0_override.filter(|rho0| rho0.len() == rho_dim) {
183 rho0vec.assign(rho0);
184 } else {
185 for (i, &rho_init) in extra_rho0.iter().enumerate() {
186 rho0vec[mean_penalties + noise_penalties + i] = rho_init;
187 }
188 }
189
190 build_location_scale_exact_joint_setup(data, &[meanspec, noisespec], rho0vec, kappa_options)
193}
194
195pub(crate) fn gaussian_location_scalewarm_start(
196 y: &Array1<f64>,
197 weights: &Array1<f64>,
198 mu_block: &ParameterBlockSpec,
199 log_sigma_block: &ParameterBlockSpec,
200 ridge_floor: f64,
201 mean_beta_hint: Option<&Array1<f64>>,
202 noise_beta_hint: Option<&Array1<f64>>,
203) -> Result<(Array1<f64>, Array1<f64>, f64), String> {
204 let betamu = if let Some(beta) = mean_beta_hint {
205 beta.clone()
206 } else {
207 solve_penalizedweighted_projection(
208 &mu_block.design,
209 &mu_block.offset,
210 y,
211 weights,
212 &mu_block.penalties,
213 &mu_block.initial_log_lambdas,
214 ridge_floor,
215 )?
216 };
217 let mut mu_hat = mu_block.solver_design().matrixvectormultiply(&betamu);
218 mu_hat += mu_block.solver_offset();
219 let mut weighted_ss = 0.0;
220 let mut weight_sum = 0.0;
221 for i in 0..y.len() {
222 let wi = weights[i].max(0.0);
223 let resid = y[i] - mu_hat[i];
224 weighted_ss += wi * resid * resid;
225 weight_sum += wi;
226 }
227 if !weighted_ss.is_finite() || !weight_sum.is_finite() || weight_sum <= 0.0 {
228 return Err(
229 "gaussian location-scale warm start could not estimate residual scale".to_string(),
230 );
231 }
232 let sigma_hat = (weighted_ss / weight_sum)
237 .sqrt()
238 .max(LOGB_SIGMA_FLOOR * 1.5);
239 let beta_log_sigma = if let Some(beta) = noise_beta_hint {
240 beta.clone()
241 } else {
242 let eta_sigma = (sigma_hat - LOGB_SIGMA_FLOOR).ln();
243 let sigma_target = Array1::from_elem(y.len(), eta_sigma);
244 solve_penalizedweighted_projection(
245 &log_sigma_block.design,
246 &log_sigma_block.offset,
247 &sigma_target,
248 weights,
249 &log_sigma_block.penalties,
250 &log_sigma_block.initial_log_lambdas,
251 ridge_floor,
252 )?
253 };
254 Ok((betamu, beta_log_sigma, sigma_hat))
255}
256
257pub(crate) const LOCATION_SCALE_N_OUTPUTS: usize = 2;
261
262pub(crate) fn build_location_scale_block(
276 name: impl Into<String>,
277 design: DesignMatrix,
278 offset: Array1<f64>,
279 penalties: Vec<PenaltyMatrix>,
280 nullspace_dims: Vec<usize>,
281 initial_log_lambdas: Array1<f64>,
282 initial_beta: Option<Array1<f64>>,
283 own_output: usize,
284 n_family_outputs: usize,
285 caller: &str,
286) -> Result<ParameterBlockSpec, String> {
287 if own_output >= n_family_outputs {
288 return Err(format!(
289 "{caller}: own_output={own_output} >= n_family_outputs={n_family_outputs}"
290 ));
291 }
292 let mut spec = ParameterBlockSpec {
293 name: name.into(),
294 design,
295 offset,
296 penalties,
297 nullspace_dims,
298 initial_log_lambdas,
299 initial_beta,
300 gauge_priority: 100,
301 jacobian_callback: None,
302 stacked_design: None,
303 stacked_offset: None,
304 };
305 let dense = spec.effective_design(caller)?;
306 spec.jacobian_callback = Some(std::sync::Arc::new(AdditiveBlockJacobian {
307 design: dense,
308 own_output,
309 n_family_outputs,
310 }));
311 Ok(spec)
312}
313
314pub(crate) fn build_location_scale_wiggle_block(
320 name: impl Into<String>,
321 design: DesignMatrix,
322 offset: Array1<f64>,
323 penalties: Vec<PenaltyMatrix>,
324 nullspace_dims: Vec<usize>,
325 initial_log_lambdas: Array1<f64>,
326 initial_beta: Option<Array1<f64>>,
327 n_rows: usize,
328) -> Result<ParameterBlockSpec, String> {
329 let p_w = design.ncols();
330 let mut spec = ParameterBlockSpec {
331 name: name.into(),
332 design,
333 offset,
334 penalties,
335 nullspace_dims,
336 initial_log_lambdas,
337 initial_beta,
338 gauge_priority: 100,
339 jacobian_callback: None,
340 stacked_design: None,
341 stacked_offset: None,
342 };
343 spec.jacobian_callback = Some(std::sync::Arc::new(AdditiveBlockJacobian {
344 design: ndarray::Array2::<f64>::zeros((n_rows, p_w)),
345 own_output: 0,
346 n_family_outputs: LOCATION_SCALE_N_OUTPUTS,
347 }));
348 Ok(spec)
349}
350
351pub(crate) fn prepared_gaussian_log_sigma_design(
352 mu_design: &DesignMatrix,
353 log_sigma_design: &DesignMatrix,
354) -> Result<DesignMatrix, String> {
355 if mu_design.nrows() != log_sigma_design.nrows() {
356 return Err(GamlssError::DimensionMismatch {
357 reason: format!(
358 "gaussian log-sigma design row mismatch: mean rows={}, log_sigma rows={}",
359 mu_design.nrows(),
360 log_sigma_design.nrows()
361 ),
362 }
363 .into());
364 }
365 Ok(log_sigma_design.clone())
376}
377
378pub(crate) fn identified_binomial_log_sigma_design(
379 threshold_design: &TermCollectionDesign,
380 log_sigma_design: &TermCollectionDesign,
381 weights: &Array1<f64>,
382) -> Result<DesignMatrix, String> {
383 let non_intercept_start = log_sigma_design
384 .intercept_range
385 .end
386 .min(log_sigma_design.design.ncols());
387 let transform = build_scale_deviation_transform_design(
388 &threshold_design.design,
389 &log_sigma_design.design,
390 weights,
391 non_intercept_start,
392 )?;
393 build_scale_deviation_operator(
394 threshold_design.design.clone(),
395 log_sigma_design.design.clone(),
396 &transform,
397 )
398}
399
400pub(crate) fn identity_penalty(dim: usize) -> Array2<f64> {
401 let mut penalty = Array2::<f64>::zeros((dim, dim));
402 for i in 0..dim {
403 penalty[[i, i]] = 1.0;
404 }
405 penalty
406}
407
408pub(crate) fn append_binomial_log_sigma_shrinkage_penalty_design(
409 design: &mut TermCollectionDesign,
410) {
411 let p = design.design.ncols();
412 design
413 .penalties
414 .push(BlockwisePenalty::new(0..p, identity_penalty(p)));
415 design.nullspace_dims.push(0);
417 design.penaltyinfo.push(PenaltyBlockInfo {
418 global_index: design.penaltyinfo.len(),
419 termname: Some("log_sigma_shrinkage".to_string()),
420 penalty: ActivePenaltyInfo {
421 source: PenaltySource::Other("shrinkage".to_string()),
422 original_index: 0,
423 effective_rank: p,
424 normalization_scale: 1.0,
425 kronecker_factors: None,
426 structural_null_frame: None,
427 },
428 });
429}
430
431pub(crate) fn build_gaussian_mean_and_scale_blocks(
438 y: &Array1<f64>,
439 weights: &Array1<f64>,
440 mean_design: &TermCollectionDesign,
441 noise_design: &TermCollectionDesign,
442 mean_offset: &Array1<f64>,
443 noise_offset: &Array1<f64>,
444 mean_log_lambdas: Array1<f64>,
445 noise_log_lambdas: Array1<f64>,
446 mean_beta_hint: Option<Array1<f64>>,
447 noise_beta_hint: Option<Array1<f64>>,
448 context: &str,
449) -> Result<(ParameterBlockSpec, ParameterBlockSpec), String> {
450 let mean_offset = mean_design
451 .compose_offset(mean_offset.view(), &format!("{context}: mu"))
452 .map_err(|error| error.to_string())?;
453 let noise_offset = noise_design
454 .compose_offset(noise_offset.view(), &format!("{context}: log_sigma"))
455 .map_err(|error| error.to_string())?;
456 let mut meanspec = build_location_scale_block(
457 "mu",
458 mean_design.design.clone(),
459 mean_offset,
460 mean_design.penalties_as_penalty_matrix(),
461 mean_design.nullspace_dims.clone(),
462 mean_log_lambdas,
463 mean_beta_hint,
464 0,
465 LOCATION_SCALE_N_OUTPUTS,
466 &format!("{context}: mu"),
467 )?;
468 let prepared_noise_design =
469 prepared_gaussian_log_sigma_design(&mean_design.design, &noise_design.design)?;
470 let mut noisespec = build_location_scale_block(
478 "log_sigma",
479 prepared_noise_design,
480 noise_offset,
481 noise_design.penalties_as_penalty_matrix(),
482 noise_design.nullspace_dims.clone(),
483 noise_log_lambdas,
484 noise_beta_hint,
485 1,
486 LOCATION_SCALE_N_OUTPUTS,
487 &format!("{context}: log_sigma"),
488 )?;
489 if meanspec.initial_beta.is_none() || noisespec.initial_beta.is_none() {
490 let (betamu0, beta_ls0, _) = gaussian_location_scalewarm_start(
491 y,
492 weights,
493 &meanspec,
494 &noisespec,
495 1e-10,
496 meanspec.initial_beta.as_ref(),
497 noisespec.initial_beta.as_ref(),
498 )?;
499 if meanspec.initial_beta.is_none() {
500 meanspec.initial_beta = Some(betamu0);
501 }
502 if noisespec.initial_beta.is_none() {
503 noisespec.initial_beta = Some(beta_ls0);
504 }
505 }
506 Ok((meanspec, noisespec))
507}
508
509pub(crate) fn build_binomial_threshold_and_scale_blocks(
515 y: &Array1<f64>,
516 weights: &Array1<f64>,
517 link_kind: &InverseLink,
518 mean_design: &TermCollectionDesign,
519 noise_design: &TermCollectionDesign,
520 mean_offset: &Array1<f64>,
521 noise_offset: &Array1<f64>,
522 mean_log_lambdas: Array1<f64>,
523 noise_log_lambdas: Array1<f64>,
524 mean_beta_hint: Option<Array1<f64>>,
525 noise_beta_hint: Option<Array1<f64>>,
526 context: &str,
527) -> Result<(ParameterBlockSpec, ParameterBlockSpec), String> {
528 let mean_offset = mean_design
529 .compose_offset(mean_offset.view(), &format!("{context}: threshold"))
530 .map_err(|error| error.to_string())?;
531 let noise_offset = noise_design
532 .compose_offset(noise_offset.view(), &format!("{context}: log_sigma"))
533 .map_err(|error| error.to_string())?;
534 let identifiednoise_design =
535 identified_binomial_log_sigma_design(mean_design, noise_design, weights)?;
536 let p_noise = identifiednoise_design.ncols();
537 let mut log_sigma_penalty_matrices: Vec<PenaltyMatrix> =
538 noise_design.penalties_as_penalty_matrix();
539 log_sigma_penalty_matrices.push(PenaltyMatrix::Dense(identity_penalty(p_noise)));
540 let mut thresholdspec = build_location_scale_block(
541 "threshold",
542 mean_design.design.clone(),
543 mean_offset,
544 mean_design.penalties_as_penalty_matrix(),
545 vec![],
546 mean_log_lambdas,
547 mean_beta_hint,
548 0,
549 LOCATION_SCALE_N_OUTPUTS,
550 &format!("{context}: threshold"),
551 )?;
552 let mut log_sigmaspec = build_location_scale_block(
553 "log_sigma",
554 identifiednoise_design,
555 noise_offset,
556 log_sigma_penalty_matrices,
557 vec![],
558 noise_log_lambdas,
559 noise_beta_hint,
560 1,
561 LOCATION_SCALE_N_OUTPUTS,
562 &format!("{context}: log_sigma"),
563 )?;
564 if thresholdspec.initial_beta.is_none() || log_sigmaspec.initial_beta.is_none() {
565 let (beta_t0, beta_ls0) = binomial_location_scalewarm_start(
566 y,
567 weights,
568 link_kind,
569 &thresholdspec,
570 &log_sigmaspec,
571 thresholdspec.initial_beta.as_ref(),
572 log_sigmaspec.initial_beta.as_ref(),
573 )?;
574 if thresholdspec.initial_beta.is_none() {
575 thresholdspec.initial_beta = Some(beta_t0);
576 }
577 if log_sigmaspec.initial_beta.is_none() {
578 log_sigmaspec.initial_beta = Some(beta_ls0);
579 }
580 }
581 Ok((thresholdspec, log_sigmaspec))
582}
583
584pub(crate) fn wiggle_block_penalty_matrices(
588 wiggle_block: &ParameterBlockInput,
589) -> Vec<PenaltyMatrix> {
590 let p_wiggle = wiggle_block.design.ncols();
591 wiggle_block
592 .penalties
593 .iter()
594 .map(|spec| match spec {
595 crate::model_types::PenaltySpec::Block {
596 local, col_range, ..
597 } => PenaltyMatrix::Blockwise {
598 local: local.clone(),
599 col_range: col_range.clone(),
600 total_dim: p_wiggle,
601 },
602 crate::model_types::PenaltySpec::Dense(m)
603 | crate::model_types::PenaltySpec::DenseWithMean { matrix: m, .. } => {
604 PenaltyMatrix::Dense(m.clone())
605 }
606 })
607 .collect()
608}
609
610pub(crate) fn binomial_location_scale_link_eta_from_probability(
611 link_kind: &InverseLink,
612 probability: f64,
613) -> Result<f64, String> {
614 let target = probability.clamp(1e-6, 1.0 - 1e-6);
615 match link_kind {
616 InverseLink::Standard(StandardLink::Logit) => Ok((target / (1.0 - target)).ln()),
617 InverseLink::Standard(StandardLink::Probit) => standard_normal_quantile(target)
618 .map_err(|err| format!("failed to invert probit warm-start probability: {err}")),
619 InverseLink::Standard(StandardLink::CLogLog) => Ok((-((1.0 - target).ln())).ln()),
620 other => Err(GamlssError::UnsupportedConfiguration { reason: format!(
621 "binomial location-scale warm start requires logit, probit, or cloglog link, got {other:?}"
622 ) }.into()),
623 }
624}
625
626pub(crate) fn weighted_binomial_prevalence(
627 y: &Array1<f64>,
628 weights: &Array1<f64>,
629) -> Result<f64, String> {
630 if y.len() != weights.len() {
631 return Err(GamlssError::DimensionMismatch { reason: format!(
632 "binomial location-scale warm start dimension mismatch: y has length {}, weights have length {}",
633 y.len(),
634 weights.len()
635 ) }.into());
636 }
637 let mut weight_sum = 0.0;
638 let mut success_sum = 0.0;
639 for (&yi, &wi) in y.iter().zip(weights.iter()) {
640 if !yi.is_finite() {
641 return Err(GamlssError::NonFinite {
642 reason: format!(
643 "binomial location-scale warm start encountered non-finite response {yi}"
644 ),
645 }
646 .into());
647 }
648 if !wi.is_finite() || wi < 0.0 {
649 return Err(GamlssError::InvalidInput {
650 reason: format!(
651 "binomial location-scale warm start requires finite non-negative weights; weight={wi}"
652 ),
653 }
654 .into());
655 }
656 if wi > 0.0 {
657 weight_sum += wi;
658 success_sum += wi * yi;
659 }
660 }
661 if !weight_sum.is_finite() || weight_sum <= 0.0 {
662 return Err(
663 "binomial location-scale warm start requires positive total weight".to_string(),
664 );
665 }
666 Ok(success_sum / weight_sum)
667}
668
669pub(crate) fn project_constant_eta_into_block(
670 block: &ParameterBlockSpec,
671 weights: &Array1<f64>,
672 eta: f64,
673) -> Result<Array1<f64>, String> {
674 let target_eta = Array1::from_elem(block.design.nrows(), eta);
675 solve_penalizedweighted_projection(
676 &block.design,
677 &block.offset,
678 &target_eta,
679 weights,
680 &block.penalties,
681 &block.initial_log_lambdas,
682 1e-10,
683 )
684}
685
686pub(crate) fn binomial_location_scalewarm_start(
690 y: &Array1<f64>,
691 weights: &Array1<f64>,
692 link_kind: &InverseLink,
693 threshold_block: &ParameterBlockSpec,
694 log_sigma_block: &ParameterBlockSpec,
695 mean_beta_hint: Option<&Array1<f64>>,
696 noise_beta_hint: Option<&Array1<f64>>,
697) -> Result<(Array1<f64>, Array1<f64>), String> {
698 if let (Some(mean_beta), Some(noise_beta)) = (mean_beta_hint, noise_beta_hint) {
699 return Ok((mean_beta.clone(), noise_beta.clone()));
700 }
701
702 let beta_threshold = match mean_beta_hint {
703 Some(beta) => beta.clone(),
704 None => {
705 let prevalence = weighted_binomial_prevalence(y, weights)?;
706 let eta = binomial_location_scale_link_eta_from_probability(link_kind, prevalence)?;
707 project_constant_eta_into_block(threshold_block, weights, eta)?
708 }
709 };
710 let beta_log_sigma = match noise_beta_hint {
711 Some(beta) => beta.clone(),
712 None => project_constant_eta_into_block(log_sigma_block, weights, 0.0)?,
713 };
714 Ok((beta_threshold, beta_log_sigma))
715}
716
717#[derive(Clone)]
718pub(crate) struct BinomialMeanWiggleSpec {
719 pub y: Array1<f64>,
720 pub weights: Array1<f64>,
721 pub link_kind: InverseLink,
722 pub wiggle_knots: Array1<f64>,
723 pub wiggle_degree: usize,
724 pub eta_block: ParameterBlockInput,
725 pub wiggle_block: ParameterBlockInput,
726}
727
728#[derive(Clone)]
729pub struct GaussianLocationScaleTermSpec {
730 pub y: Array1<f64>,
731 pub weights: Array1<f64>,
732 pub meanspec: TermCollectionSpec,
733 pub log_sigmaspec: TermCollectionSpec,
734 pub mean_offset: Array1<f64>,
735 pub log_sigma_offset: Array1<f64>,
736}
737
738#[derive(Clone)]
739pub struct GaussianLocationScaleWiggleTermSpec {
740 pub y: Array1<f64>,
741 pub weights: Array1<f64>,
742 pub meanspec: TermCollectionSpec,
743 pub log_sigmaspec: TermCollectionSpec,
744 pub mean_offset: Array1<f64>,
745 pub log_sigma_offset: Array1<f64>,
746 pub wiggle_knots: Array1<f64>,
747 pub wiggle_degree: usize,
748 pub wiggle_block: ParameterBlockInput,
749}
750
751#[derive(Clone)]
752pub struct BinomialLocationScaleTermSpec {
753 pub y: Array1<f64>,
754 pub weights: Array1<f64>,
755 pub link_kind: InverseLink,
756 pub thresholdspec: TermCollectionSpec,
757 pub log_sigmaspec: TermCollectionSpec,
758 pub threshold_offset: Array1<f64>,
759 pub log_sigma_offset: Array1<f64>,
760}
761
762#[derive(Clone)]
763pub struct BinomialLocationScaleWiggleTermSpec {
764 pub y: Array1<f64>,
765 pub weights: Array1<f64>,
766 pub link_kind: InverseLink,
767 pub thresholdspec: TermCollectionSpec,
768 pub log_sigmaspec: TermCollectionSpec,
769 pub threshold_offset: Array1<f64>,
770 pub log_sigma_offset: Array1<f64>,
771 pub wiggle_knots: Array1<f64>,
772 pub wiggle_degree: usize,
773 pub wiggle_block: ParameterBlockInput,
774}
775
776#[derive(Clone, Debug)]
777pub struct BlockwiseTermFitResult {
778 pub fit: UnifiedFitResult,
779 pub meanspec_resolved: TermCollectionSpec,
780 pub noisespec_resolved: TermCollectionSpec,
781 pub mean_design: TermCollectionDesign,
782 pub noise_design: TermCollectionDesign,
783}
784
785pub(crate) struct BlockwiseTermFitResultParts {
786 pub fit: UnifiedFitResult,
787 pub meanspec_resolved: TermCollectionSpec,
788 pub noisespec_resolved: TermCollectionSpec,
789 pub mean_design: TermCollectionDesign,
790 pub noise_design: TermCollectionDesign,
791}
792
793pub struct BlockwiseTermWiggleFitResult {
794 pub fit: BlockwiseTermFitResult,
795 pub wiggle_knots: Array1<f64>,
796 pub wiggle_degree: usize,
797}
798
799pub struct BinomialMeanWiggleTermFitResult {
800 pub fit: UnifiedFitResult,
801 pub resolvedspec: TermCollectionSpec,
802 pub design: TermCollectionDesign,
803 pub wiggle_knots: Array1<f64>,
804 pub wiggle_degree: usize,
805 pub saved_warp_beta: Option<Vec<f64>>,
810 pub saved_index_shift: Option<Vec<f64>>,
815}
816
817pub(crate) struct BlockwiseTermWiggleFitResultParts {
818 pub fit: BlockwiseTermFitResult,
819 pub wiggle_knots: Array1<f64>,
820 pub wiggle_degree: usize,
821}
822
823pub(crate) fn validate_term_collection_design(
824 label: &str,
825 design: &TermCollectionDesign,
826) -> Result<(), String> {
827 let p = design.design.ncols();
828 let n = design.design.nrows();
829 for rows in exact_design_row_chunks(n, p) {
830 let chunk = design
831 .design
832 .try_row_chunk(rows)
833 .map_err(|e| format!("{label}.design row chunk materialization failed: {e}"))?;
834 validate_all_finite_estimation(&format!("{label}.design"), chunk.iter().copied())
835 .map_err(|e| e.to_string())?;
836 }
837 if design.nullspace_dims.len() != design.penalties.len() {
838 return Err(GamlssError::DimensionMismatch {
839 reason: format!(
840 "{label}.nullspace_dims length mismatch: got {}, expected {}",
841 design.nullspace_dims.len(),
842 design.penalties.len()
843 ),
844 }
845 .into());
846 }
847 if design.penaltyinfo.len() != design.penalties.len() {
848 return Err(GamlssError::DimensionMismatch {
849 reason: format!(
850 "{label}.penaltyinfo length mismatch: got {}, expected {}",
851 design.penaltyinfo.len(),
852 design.penalties.len()
853 ),
854 }
855 .into());
856 }
857 for (idx, bp) in design.penalties.iter().enumerate() {
858 validate_all_finite_estimation(
859 &format!("{label}.penalties[{idx}]"),
860 bp.local.iter().copied(),
861 )
862 .map_err(|e| e.to_string())?;
863 if bp.col_range.end > p {
864 return Err(GamlssError::DimensionMismatch {
865 reason: format!(
866 "{label}.penalties[{idx}] col_range {}..{} exceeds design width {}",
867 bp.col_range.start, bp.col_range.end, p
868 ),
869 }
870 .into());
871 }
872 }
873 if let Some(bounds) = design.coefficient_lower_bounds.as_ref() {
874 if bounds.len() != p {
875 return Err(GamlssError::ConstraintViolation {
876 reason: format!(
877 "{label}.coefficient_lower_bounds length mismatch: got {}, expected {p}",
878 bounds.len()
879 ),
880 }
881 .into());
882 }
883 for (idx, &bound) in bounds.iter().enumerate() {
884 if !(bound.is_finite() || bound == f64::NEG_INFINITY) {
885 return Err(GamlssError::NonFinite { reason: format!(
886 "{label}.coefficient_lower_bounds[{idx}] must be finite or -inf, got {bound}",
887 ) }.into());
888 }
889 }
890 }
891 if let Some(constraints) = design.linear_constraints.as_ref() {
892 validate_all_finite_estimation(
893 &format!("{label}.linear_constraints.a"),
894 constraints.a.iter().copied(),
895 )
896 .map_err(|e| e.to_string())?;
897 validate_all_finite_estimation(
898 &format!("{label}.linear_constraints.b"),
899 constraints.b.iter().copied(),
900 )
901 .map_err(|e| e.to_string())?;
902 if constraints.a.ncols() != p {
903 return Err(GamlssError::DimensionMismatch {
904 reason: format!(
905 "{label}.linear_constraints.a column mismatch: got {}, expected {p}",
906 constraints.a.ncols()
907 ),
908 }
909 .into());
910 }
911 if constraints.a.nrows() != constraints.b.len() {
912 return Err(GamlssError::DimensionMismatch {
913 reason: format!(
914 "{label}.linear_constraints row mismatch: a has {}, b has {}",
915 constraints.a.nrows(),
916 constraints.b.len()
917 ),
918 }
919 .into());
920 }
921 }
922 if design.intercept_range.start > design.intercept_range.end || design.intercept_range.end > p {
923 return Err(GamlssError::ConstraintViolation {
924 reason: format!(
925 "{label}.intercept_range out of bounds: {:?} for {} columns",
926 design.intercept_range, p
927 ),
928 }
929 .into());
930 }
931 Ok(())
932}
933
934impl BlockwiseTermFitResult {
935 pub(crate) fn try_from_parts(parts: BlockwiseTermFitResultParts) -> Result<Self, String> {
936 let BlockwiseTermFitResultParts {
937 fit,
938 meanspec_resolved,
939 noisespec_resolved,
940 mean_design,
941 noise_design,
942 } = parts;
943
944 fit.validate_numeric_finiteness()
945 .map_err(|e| format!("{e}"))?;
946 if fit.block_states.len() < 2 {
947 return Err(GamlssError::DimensionMismatch {
948 reason: format!(
949 "BlockwiseTermFitResult requires at least 2 block states, got {}",
950 fit.block_states.len()
951 ),
952 }
953 .into());
954 }
955 validate_term_collection_design("blockwise_term.mean_design", &mean_design)?;
956 validate_term_collection_design("blockwise_term.noise_design", &noise_design)?;
957 if mean_design.design.nrows() != noise_design.design.nrows() {
958 return Err(GamlssError::DimensionMismatch {
959 reason: format!(
960 "BlockwiseTermFitResult row mismatch: mean_design={}, noise_design={}",
961 mean_design.design.nrows(),
962 noise_design.design.nrows()
963 ),
964 }
965 .into());
966 }
967 if fit.block_states[0].beta.len() != mean_design.design.ncols() {
968 return Err(GamlssError::DimensionMismatch {
969 reason: format!(
970 "BlockwiseTermFitResult mean beta length mismatch: got {}, expected {}",
971 fit.block_states[0].beta.len(),
972 mean_design.design.ncols()
973 ),
974 }
975 .into());
976 }
977 if fit.block_states[1].beta.len() != noise_design.design.ncols() {
978 return Err(GamlssError::DimensionMismatch {
979 reason: format!(
980 "BlockwiseTermFitResult noise beta length mismatch: got {}, expected {}",
981 fit.block_states[1].beta.len(),
982 noise_design.design.ncols()
983 ),
984 }
985 .into());
986 }
987 if fit.block_states[0].eta.len() != mean_design.design.nrows() {
988 return Err(GamlssError::DimensionMismatch {
989 reason: format!(
990 "BlockwiseTermFitResult mean eta length mismatch: got {}, expected {}",
991 fit.block_states[0].eta.len(),
992 mean_design.design.nrows()
993 ),
994 }
995 .into());
996 }
997 if fit.block_states[1].eta.len() != noise_design.design.nrows() {
998 return Err(GamlssError::DimensionMismatch {
999 reason: format!(
1000 "BlockwiseTermFitResult noise eta length mismatch: got {}, expected {}",
1001 fit.block_states[1].eta.len(),
1002 noise_design.design.nrows()
1003 ),
1004 }
1005 .into());
1006 }
1007
1008 Ok(Self {
1009 fit,
1010 meanspec_resolved,
1011 noisespec_resolved,
1012 mean_design,
1013 noise_design,
1014 })
1015 }
1016
1017 pub(crate) fn validate_numeric_finiteness(&self) -> Result<(), String> {
1018 Self::try_from_parts(BlockwiseTermFitResultParts {
1019 fit: self.fit.clone(),
1020 meanspec_resolved: self.meanspec_resolved.clone(),
1021 noisespec_resolved: self.noisespec_resolved.clone(),
1022 mean_design: self.mean_design.clone(),
1023 noise_design: self.noise_design.clone(),
1024 })
1025 .map(|_| ())
1026 }
1027}
1028
1029impl BlockwiseTermWiggleFitResult {
1030 pub(crate) fn try_from_parts(parts: BlockwiseTermWiggleFitResultParts) -> Result<Self, String> {
1031 let BlockwiseTermWiggleFitResultParts {
1032 fit,
1033 wiggle_knots,
1034 wiggle_degree,
1035 } = parts;
1036
1037 fit.validate_numeric_finiteness()
1038 .map_err(|e| e.to_string())?;
1039 if fit.fit.block_states.len() < 3 {
1040 return Err(GamlssError::DimensionMismatch {
1041 reason: format!(
1042 "BlockwiseTermWiggleFitResult requires at least 3 block states, got {}",
1043 fit.fit.block_states.len()
1044 ),
1045 }
1046 .into());
1047 }
1048 if wiggle_knots.is_empty() {
1049 return Err(GamlssError::UnsupportedConfiguration {
1050 reason: "BlockwiseTermWiggleFitResult requires non-empty wiggle_knots".to_string(),
1051 }
1052 .into());
1053 }
1054 validate_all_finite_estimation(
1055 "blockwise_term_wiggle.wiggle_knots",
1056 wiggle_knots.iter().copied(),
1057 )
1058 .map_err(|e| e.to_string())?;
1059
1060 Ok(Self {
1061 fit,
1062 wiggle_knots,
1063 wiggle_degree,
1064 })
1065 }
1066}
1067
1068pub struct BinomialLocationScaleFitResult {
1069 pub fit: BlockwiseTermFitResult,
1070 pub wiggle_knots: Option<Array1<f64>>,
1071 pub wiggle_degree: Option<usize>,
1072 pub beta_link_wiggle: Option<Vec<f64>>,
1073}
1074
1075pub struct GaussianLocationScaleFitResult {
1076 pub fit: BlockwiseTermFitResult,
1077 pub wiggle_knots: Option<Array1<f64>>,
1078 pub wiggle_degree: Option<usize>,
1079 pub beta_link_wiggle: Option<Vec<f64>>,
1080 pub response_scale: f64,
1109}
1110
1111fn binomial_mean_wiggle_saved_frame_gauge(
1128 alias: &Array2<f64>,
1129 mean_width: usize,
1130 wiggle_width: usize,
1131) -> Result<gam_problem::Gauge, String> {
1132 if alias.dim() != (mean_width, wiggle_width) {
1133 return Err(format!(
1134 "binomial mean-wiggle de-alias map is {}x{}, expected {mean_width}x{wiggle_width}",
1135 alias.nrows(),
1136 alias.ncols(),
1137 ));
1138 }
1139 let total_width = mean_width
1140 .checked_add(wiggle_width)
1141 .ok_or_else(|| "binomial mean-wiggle coefficient dimension overflows usize".to_string())?;
1142 let mut transform = Array2::<f64>::eye(total_width);
1143 for row in 0..mean_width {
1144 for column in 0..wiggle_width {
1145 transform[[row, mean_width + column]] = -alias[[row, column]];
1146 }
1147 }
1148 let gauge = gam_problem::Gauge::from_t(
1149 transform,
1150 &[mean_width, wiggle_width],
1151 &[mean_width, wiggle_width],
1152 );
1153 gauge.validate().map_err(|reason| {
1154 format!("binomial mean-wiggle saved coefficient gauge is invalid: {reason}")
1155 })?;
1156 Ok(gauge)
1157}
1158
1159fn binomial_mean_wiggle_saved_geometry(
1160 geometry: &gam_solve::model_types::FitGeometry,
1161 saved_frame: &gam_problem::Gauge,
1162) -> Result<gam_solve::model_types::FitGeometry, String> {
1163 let mut saved_geometry = geometry.clone();
1164 saved_geometry.coefficient_gauge = geometry
1165 .coefficient_gauge
1166 .left_compose(saved_frame)
1167 .map_err(|reason| {
1168 format!(
1169 "binomial mean-wiggle active geometry cannot compose with its exact saved-result gauge: {reason}"
1170 )
1171 })?;
1172 Ok(saved_geometry)
1173}
1174
1175fn binomial_mean_wiggle_saved_covariance(
1176 covariance: &Array2<f64>,
1177 saved_frame: &gam_problem::Gauge,
1178 label: &str,
1179) -> Result<Array2<f64>, String> {
1180 let expected = saved_frame.reduced_total();
1181 if covariance.dim() != (expected, expected) {
1182 return Err(format!(
1183 "binomial mean-wiggle {label} is {}x{}; exact saved-result gauge requires {expected}x{expected} solver-frame coordinates",
1184 covariance.nrows(),
1185 covariance.ncols(),
1186 ));
1187 }
1188 if let Some(((row, column), value)) = covariance
1189 .indexed_iter()
1190 .find(|(_, value)| !value.is_finite())
1191 {
1192 return Err(format!(
1193 "binomial mean-wiggle {label} is non-finite at ({row}, {column}): {value}"
1194 ));
1195 }
1196 let saved = saved_frame.lift_covariance(covariance);
1197 if let Some(((row, column), value)) = saved.indexed_iter().find(|(_, value)| !value.is_finite())
1198 {
1199 return Err(format!(
1200 "binomial mean-wiggle saved-frame {label} is non-finite at ({row}, {column}): {value}"
1201 ));
1202 }
1203 Ok(saved)
1204}
1205
1206fn finalize_binomial_mean_wiggle_saved_frame(
1215 fit: &mut UnifiedFitResult,
1216 alias: &Array2<f64>,
1217 mean_design: &Array2<f64>,
1218 mean_offset: &Array1<f64>,
1219) -> Result<(), String> {
1220 use gam_problem::BlockRole;
1221
1222 if fit.blocks.len() != 2
1223 || fit.blocks[0].role != BlockRole::Mean
1224 || fit.blocks[1].role != BlockRole::LinkWiggle
1225 {
1226 return Err(format!(
1227 "binomial mean-wiggle saved-frame finalization requires fitted blocks [Mean, LinkWiggle], got {:?}",
1228 fit.blocks
1229 .iter()
1230 .map(|block| block.role)
1231 .collect::<Vec<_>>()
1232 ));
1233 }
1234 if fit.block_states.len() != 2 {
1235 return Err(format!(
1236 "binomial mean-wiggle saved-frame finalization requires two fitted block states, got {}",
1237 fit.block_states.len(),
1238 ));
1239 }
1240 if mean_offset.len() != mean_design.nrows() {
1241 return Err(format!(
1242 "binomial mean-wiggle mean offset has {} rows, expected {}",
1243 mean_offset.len(),
1244 mean_design.nrows(),
1245 ));
1246 }
1247
1248 let mean_width = fit.blocks[0].beta.len();
1249 let wiggle_width = fit.blocks[1].beta.len();
1250 if mean_design.ncols() != mean_width {
1251 return Err(format!(
1252 "binomial mean-wiggle saved mean design has {} columns, expected fitted width {mean_width}",
1253 mean_design.ncols(),
1254 ));
1255 }
1256 for block_index in 0..2 {
1257 if fit.block_states[block_index].beta != fit.blocks[block_index].beta {
1258 return Err(format!(
1259 "binomial mean-wiggle fitted block {block_index} and block-state coefficients disagree before saved-frame finalization"
1260 ));
1261 }
1262 }
1263 let total_width = mean_width
1264 .checked_add(wiggle_width)
1265 .ok_or_else(|| "binomial mean-wiggle coefficient dimension overflows usize".to_string())?;
1266 if fit.beta.len() != total_width {
1267 return Err(format!(
1268 "binomial mean-wiggle flat coefficient vector has width {}, expected {total_width}",
1269 fit.beta.len(),
1270 ));
1271 }
1272 if fit.beta.slice(s![0..mean_width]) != fit.blocks[0].beta
1273 || fit.beta.slice(s![mean_width..total_width]) != fit.blocks[1].beta
1274 {
1275 return Err(
1276 "binomial mean-wiggle flat and block coefficient vectors disagree before saved-frame finalization"
1277 .to_string(),
1278 );
1279 }
1280
1281 let saved_frame = binomial_mean_wiggle_saved_frame_gauge(alias, mean_width, wiggle_width)?;
1282 let saved_blocks =
1283 saved_frame.lift_block_betas(&[fit.blocks[0].beta.clone(), fit.blocks[1].beta.clone()]);
1284 let saved_mean_eta = mean_design.dot(&saved_blocks[0]) + mean_offset;
1285 let mut saved_beta = Array1::<f64>::zeros(total_width);
1286 saved_beta
1287 .slice_mut(s![0..mean_width])
1288 .assign(&saved_blocks[0]);
1289 saved_beta
1290 .slice_mut(s![mean_width..total_width])
1291 .assign(&saved_blocks[1]);
1292
1293 let saved_conditional = fit
1294 .covariance_conditional
1295 .as_ref()
1296 .map(|covariance| {
1297 binomial_mean_wiggle_saved_covariance(
1298 covariance,
1299 &saved_frame,
1300 "conditional covariance",
1301 )
1302 })
1303 .transpose()?;
1304 let saved_corrected = fit
1305 .covariance_corrected
1306 .as_ref()
1307 .map(|covariance| {
1308 binomial_mean_wiggle_saved_covariance(covariance, &saved_frame, "corrected covariance")
1309 })
1310 .transpose()?;
1311 let saved_geometry = binomial_mean_wiggle_saved_geometry(
1312 fit.geometry.as_ref().ok_or_else(|| {
1313 "binomial mean-wiggle fit is missing its exact active geometry".to_string()
1314 })?,
1315 &saved_frame,
1316 )?;
1317
1318 let mut saved_inference = fit.inference.clone();
1319 if let Some(inference) = saved_inference.as_mut() {
1320 if inference.beta_covariance.is_none() && inference.beta_standard_errors.is_some() {
1321 return Err(
1322 "binomial mean-wiggle inference has conditional standard errors without their covariance"
1323 .to_string(),
1324 );
1325 }
1326 if inference.beta_covariance_corrected.is_none()
1327 && inference.beta_standard_errors_corrected.is_some()
1328 {
1329 return Err(
1330 "binomial mean-wiggle inference has corrected standard errors without their covariance"
1331 .to_string(),
1332 );
1333 }
1334 if let Some(covariance) = inference.beta_covariance.take() {
1335 let covariance = binomial_mean_wiggle_saved_covariance(
1336 covariance.as_array(),
1337 &saved_frame,
1338 "inference conditional covariance",
1339 )?;
1340 if inference.beta_standard_errors.is_some() {
1341 inference.beta_standard_errors = Some(
1342 gam_problem::se_from_covariance(&covariance).map_err(|reason| {
1343 format!(
1344 "binomial mean-wiggle saved conditional standard errors are invalid: {reason}"
1345 )
1346 })?,
1347 );
1348 }
1349 inference.beta_covariance = Some(covariance.into());
1350 }
1351 if let Some(covariance) = inference.beta_covariance_corrected.take() {
1352 let covariance = binomial_mean_wiggle_saved_covariance(
1353 &covariance,
1354 &saved_frame,
1355 "inference corrected covariance",
1356 )?;
1357 if inference.beta_standard_errors_corrected.is_some() {
1358 inference.beta_standard_errors_corrected = Some(
1359 gam_problem::se_from_covariance(&covariance).map_err(|reason| {
1360 format!(
1361 "binomial mean-wiggle saved corrected standard errors are invalid: {reason}"
1362 )
1363 })?,
1364 );
1365 }
1366 inference.beta_covariance_corrected = Some(covariance);
1367 }
1368 if let Some(covariance) = inference.beta_covariance_frequentist.take() {
1369 inference.beta_covariance_frequentist = Some(binomial_mean_wiggle_saved_covariance(
1370 &covariance,
1371 &saved_frame,
1372 "frequentist covariance",
1373 )?);
1374 }
1375 if let Some(correction) = inference.smoothing_correction.take() {
1376 inference.smoothing_correction = Some(binomial_mean_wiggle_saved_covariance(
1377 &correction,
1378 &saved_frame,
1379 "smoothing covariance correction",
1380 )?);
1381 }
1382 }
1383
1384 fit.blocks[0].beta = saved_blocks[0].clone();
1385 fit.blocks[1].beta = saved_blocks[1].clone();
1386 fit.block_states[0].beta = saved_blocks[0].clone();
1387 fit.block_states[0].eta = saved_mean_eta;
1388 fit.block_states[1].beta = saved_blocks[1].clone();
1389 fit.beta = saved_beta;
1390 fit.covariance_conditional = saved_conditional;
1391 fit.covariance_corrected = saved_corrected;
1392 fit.geometry = Some(saved_geometry);
1393 fit.inference = saved_inference;
1394 Ok(())
1395}
1396
1397#[cfg(test)]
1398mod binomial_mean_wiggle_saved_frame_tests {
1399 use super::*;
1400 use ndarray::array;
1401
1402 #[test]
1403 fn cross_block_dealias_composes_non_square_geometry_and_pushes_covariance() {
1404 let alias = array![[2.0], [-0.5]];
1405 let saved_frame = binomial_mean_wiggle_saved_frame_gauge(&alias, 2, 1)
1406 .expect("valid cross-block de-alias map");
1407
1408 let active_to_solver = gam_problem::Gauge::from_t(
1411 array![[1.0, 0.0], [0.0, 0.0], [0.0, 1.0]],
1412 &[2, 1],
1413 &[1, 1],
1414 );
1415 let active_hessian = array![[7.0, 1.5], [1.5, 4.0]];
1416 let geometry = gam_solve::model_types::FitGeometry {
1417 coefficient_gauge: active_to_solver,
1418 penalized_hessian: active_hessian.clone().into(),
1419 constrained_posterior: None,
1420 working: None,
1421 };
1422 let saved_geometry = binomial_mean_wiggle_saved_geometry(&geometry, &saved_frame)
1423 .expect("non-square active geometry composes through saved frame");
1424
1425 assert_eq!(
1426 saved_geometry.coefficient_gauge.t_full,
1427 array![[1.0, -2.0], [0.0, 0.5], [0.0, 1.0]],
1428 );
1429 assert_eq!(
1430 saved_geometry.penalized_hessian.as_array(),
1431 &active_hessian,
1432 "precision stays in the canonical active frame",
1433 );
1434
1435 let solver_covariance = Array2::<f64>::eye(3);
1436 let saved_covariance = binomial_mean_wiggle_saved_covariance(
1437 &solver_covariance,
1438 &saved_frame,
1439 "test covariance",
1440 )
1441 .expect("covariance pushes into saved frame");
1442 assert_eq!(
1443 saved_covariance,
1444 array![[5.0, -1.0, -2.0], [-1.0, 1.25, 0.5], [-2.0, 0.5, 1.0]],
1445 "the -A cross block must alter both Mean variance and Mean/Wiggle covariance",
1446 );
1447 }
1448}
1449
1450pub(crate) fn fit_binomial_mean_wiggle(
1454 spec: BinomialMeanWiggleSpec,
1455 options: &BlockwiseFitOptions,
1456) -> Result<(UnifiedFitResult, Option<Vec<f64>>, Option<Vec<f64>>), String> {
1457 let n = spec.y.len();
1458 validate_len_match("weights vs y", n, spec.weights.len())?;
1459 validateweights(&spec.weights, "fit_binomial_mean_wiggle")?;
1460 validate_binomial_response(&spec.y, "fit_binomial_mean_wiggle")?;
1461 validate_blockrows("eta", n, &spec.eta_block)?;
1462 validate_blockrows("wiggle", n, &spec.wiggle_block)?;
1463 if matches!(
1464 spec.link_kind,
1465 InverseLink::Standard(StandardLink::Identity)
1466 ) {
1467 return Err(GamlssError::UnsupportedConfiguration {
1468 reason: "fit_binomial_mean_wiggle does not support identity link".to_string(),
1469 }
1470 .into());
1471 }
1472 gam_terms::inference::formula_dsl::require_binomial_inverse_link_supports_joint_wiggle(
1473 &spec.link_kind,
1474 "fit_binomial_mean_wiggle",
1475 )?;
1476 if spec.wiggle_degree < 2 {
1477 return Err(GamlssError::ConstraintViolation {
1478 reason: format!(
1479 "fit_binomial_mean_wiggle: wiggle_degree must be >= 2, got {}",
1480 spec.wiggle_degree
1481 ),
1482 }
1483 .into());
1484 }
1485 let minimum_knots = minimum_monotone_wiggle_knot_count(spec.wiggle_degree)?;
1486 if spec.wiggle_knots.len() < minimum_knots {
1487 return Err(GamlssError::DimensionMismatch { reason: format!(
1488 "fit_binomial_mean_wiggle: wiggle_knots length {} is too short for degree {} (need at least {})",
1489 spec.wiggle_knots.len(),
1490 spec.wiggle_degree,
1491 minimum_knots
1492 ) }.into());
1493 }
1494
1495 let x_dense: Array2<f64> = spec.eta_block.design.to_dense();
1504 let (pilot_beta, pilot_eta): (Array1<f64>, Array1<f64>) = {
1505 let pilot_beta = spec.eta_block.initial_beta.clone().ok_or_else(|| {
1506 "fit_binomial_mean_wiggle: eta block carries no pilot β to seed the \
1507 frozen-basis warp index"
1508 .to_string()
1509 })?;
1510 if x_dense.ncols() != pilot_beta.len() {
1511 return Err(GamlssError::DimensionMismatch {
1512 reason: format!(
1513 "fit_binomial_mean_wiggle: eta design has {} columns but pilot β has {} \
1514 coefficients",
1515 x_dense.ncols(),
1516 pilot_beta.len()
1517 ),
1518 }
1519 .into());
1520 }
1521 let mut eta = x_dense.dot(&pilot_beta);
1522 eta += &spec.eta_block.offset;
1523 (pilot_beta, eta)
1524 };
1525
1526 let wiggle_penalties_full = spec.wiggle_block.penalties.clone();
1530 let wiggle_nullspace_dims = spec.wiggle_block.nullspace_dims.clone();
1531 if !wiggle_nullspace_dims.is_empty()
1532 && wiggle_nullspace_dims.len() != wiggle_penalties_full.len()
1533 {
1534 return Err(GamlssError::DimensionMismatch {
1535 reason: format!(
1536 "fit_binomial_mean_wiggle: wiggle block has {} penalties but {} nullspace dimensions",
1537 wiggle_penalties_full.len(),
1538 wiggle_nullspace_dims.len()
1539 ),
1540 }
1541 .into());
1542 }
1543 let wiggle_log_lambdas = spec.wiggle_block.initial_log_lambdas.clone();
1544 let wiggle_beta_initial = spec.wiggle_block.initial_beta.clone();
1545 let eta_block_input = spec.eta_block.clone();
1546
1547 let family = BinomialMeanWiggleFamily {
1548 y: spec.y,
1549 weights: spec.weights,
1550 link_kind: spec.link_kind,
1551 wiggle_knots: spec.wiggle_knots,
1552 wiggle_degree: spec.wiggle_degree,
1553 policy: gam_runtime::resource::ResourcePolicy::default_library(),
1554 frozen_warp_design: None,
1555 };
1556
1557 let build_dealiased = |frozen: &Array1<f64>,
1575 beta_hint: Option<&Array1<f64>>,
1576 log_lambda_hint: Option<&Array1<f64>>|
1577 -> Result<
1578 (
1579 ParameterBlockInput,
1580 Array2<f64>,
1581 std::sync::Arc<Array2<f64>>,
1582 ),
1583 String,
1584 > {
1585 use faer::Side;
1586 use gam_linalg::faer_ndarray::FaerEigh;
1587
1588 let b_full = family.wiggle_design(frozen.view())?;
1589 let xtx = x_dense.t().dot(&x_dense);
1590 let xtb = x_dense.t().dot(&b_full);
1591 let (evals, evecs) = xtx
1592 .eigh(Side::Lower)
1593 .map_err(|e| format!("frozen-basis warp de-aliasing mean QR failed: {e}"))?;
1594 let max_eval = evals.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1595 let cutoff = 1.0e3 * f64::EPSILON * (xtx.nrows().max(1) as f64) * max_eval.max(1.0);
1596 let mut alias = Array2::<f64>::zeros((x_dense.ncols(), b_full.ncols()));
1597 for k in 0..evals.len() {
1598 let lam = evals[k];
1599 if !lam.is_finite() || lam.abs() <= cutoff {
1600 continue;
1601 }
1602 let uk = evecs.column(k);
1603 let uk_xtb = uk.t().dot(&xtb);
1604 for i in 0..alias.nrows() {
1605 for j in 0..alias.ncols() {
1606 alias[[i, j]] += uk[i] * uk_xtb[j] / lam;
1607 }
1608 }
1609 }
1610 let bda = &b_full - &x_dense.dot(&alias);
1611 let max_b = b_full.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1612 let max_resid = bda.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1613 let resid_tol =
1614 1.0e3 * f64::EPSILON * (bda.nrows().max(bda.ncols()).max(1) as f64) * max_b.max(1.0);
1615 if max_resid <= resid_tol {
1616 return Err("frozen-basis warp de-aliasing left no identifiable warp \
1617 direction (the mean block already spans the warp in \
1618 observation space)"
1619 .to_string());
1620 }
1621 let penalties: Vec<crate::model_types::PenaltySpec> = wiggle_penalties_full
1622 .iter()
1623 .map(|p| {
1624 let s = penalty_spec_to_dense(p, b_full.ncols())?;
1625 Ok(crate::model_types::PenaltySpec::Dense(s))
1626 })
1627 .collect::<Result<_, String>>()?;
1628 let q = bda.ncols();
1629 let initial_beta = match beta_hint {
1630 Some(beta) if beta.len() == q => Some(beta.clone()),
1631 Some(beta) => {
1632 return Err(format!(
1633 "frozen-basis warp warm start has {} coefficients but the realized basis has {q}",
1634 beta.len()
1635 ));
1636 }
1637 None => Some(Array1::zeros(q)),
1638 };
1639 let block = ParameterBlockInput {
1640 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(bda.clone())),
1641 offset: Array1::zeros(frozen.len()),
1642 penalties,
1643 nullspace_dims: wiggle_nullspace_dims.clone(),
1644 initial_log_lambdas: log_lambda_hint
1645 .cloned()
1646 .or_else(|| wiggle_log_lambdas.clone()),
1647 initial_beta,
1648 };
1649 Ok((block, alias, std::sync::Arc::new(bda)))
1650 };
1651
1652 if options.outer_max_iter == 0 || !options.outer_tol.is_finite() || options.outer_tol <= 0.0 {
1659 return Err(GamlssError::InvalidInput {
1660 reason: format!(
1661 "fit_binomial_mean_wiggle requires positive outer convergence policy; outer_max_iter={}, outer_tol={}",
1662 options.outer_max_iter, options.outer_tol
1663 ),
1664 }
1665 .into());
1666 }
1667 let mut frozen_source_beta = pilot_beta;
1668 let mut frozen_eta = pilot_eta;
1669 let mut eta_block_warm = eta_block_input.clone();
1670 let mut wiggle_beta_warm = wiggle_beta_initial;
1671 let mut wiggle_log_lambda_warm = wiggle_log_lambdas.clone();
1672 let mut converged: Option<(UnifiedFitResult, Array2<f64>, Array1<f64>)> = None;
1673 let mut last_delta = f64::INFINITY;
1674 let mut last_scale = 1.0_f64;
1675 for _outer in 0..options.outer_max_iter {
1676 let (wiggle_block, alias, bda) = build_dealiased(
1677 &frozen_eta,
1678 wiggle_beta_warm.as_ref(),
1679 wiggle_log_lambda_warm.as_ref(),
1680 )?;
1681 let eta_penalty_count = eta_block_warm.penalties.len();
1682 let wiggle_penalty_count = wiggle_block.penalties.len();
1683 let blocks = vec![
1684 eta_block_warm.clone().intospec("eta")?,
1685 wiggle_block.intospec("wiggle")?,
1686 ];
1687 let mut fam = family.clone();
1688 fam.frozen_warp_design = Some(bda);
1689 let fit = fit_custom_family(&fam, &blocks, options).map_err(|e| e.to_string())?;
1690 let mean_state = fit
1691 .block_states
1692 .get(BinomialMeanWiggleFamily::BLOCK_ETA)
1693 .ok_or_else(|| {
1694 "fit_binomial_mean_wiggle: frozen-basis refit did not expose a fitted eta block"
1695 .to_string()
1696 })?;
1697 if mean_state.eta.len() != frozen_eta.len()
1698 || mean_state.beta.len() != frozen_source_beta.len()
1699 {
1700 return Err(GamlssError::DimensionMismatch {
1701 reason: "fit_binomial_mean_wiggle: frozen-basis refit returned an incompatible eta block"
1702 .to_string(),
1703 }
1704 .into());
1705 }
1706 let new_eta = mean_state.eta.clone();
1707 let new_source_beta = mean_state.beta.clone();
1708 let new_wiggle_beta = fit
1709 .block_states
1710 .get(BinomialMeanWiggleFamily::BLOCK_WIGGLE)
1711 .map(|state| state.beta.clone())
1712 .ok_or_else(|| {
1713 "fit_binomial_mean_wiggle: frozen-basis refit did not expose a fitted wiggle block"
1714 .to_string()
1715 })?;
1716 last_scale = frozen_eta
1717 .iter()
1718 .chain(new_eta.iter())
1719 .map(|value| value.abs())
1720 .fold(1.0_f64, f64::max);
1721 last_delta = new_eta
1722 .iter()
1723 .zip(frozen_eta.iter())
1724 .map(|(a, b)| (a - b).abs())
1725 .fold(0.0_f64, f64::max);
1726 if last_delta <= options.outer_tol * last_scale {
1727 converged = Some((fit, alias, frozen_source_beta));
1728 break;
1729 }
1730
1731 let expected_log_lambdas = eta_penalty_count + wiggle_penalty_count;
1732 if fit.log_lambdas.len() != expected_log_lambdas {
1733 return Err(GamlssError::DimensionMismatch {
1734 reason: format!(
1735 "fit_binomial_mean_wiggle: refit returned {} log-lambdas for {expected_log_lambdas} penalties",
1736 fit.log_lambdas.len()
1737 ),
1738 }
1739 .into());
1740 }
1741 eta_block_warm.initial_beta = Some(new_source_beta.clone());
1742 eta_block_warm.initial_log_lambdas =
1743 Some(fit.log_lambdas.slice(s![0..eta_penalty_count]).to_owned());
1744 wiggle_beta_warm = Some(new_wiggle_beta);
1745 wiggle_log_lambda_warm = Some(
1746 fit.log_lambdas
1747 .slice(s![eta_penalty_count..expected_log_lambdas])
1748 .to_owned(),
1749 );
1750 frozen_source_beta = new_source_beta;
1751 frozen_eta = new_eta;
1752 }
1753 let (mut fit, last_alias, frozen_source_beta) = converged.ok_or_else(|| {
1754 GamlssError::NumericalFailure {
1755 reason: format!(
1756 "fit_binomial_mean_wiggle frozen-index fixed point did not converge in {} outer iterations: delta={last_delta:.3e}, scale={last_scale:.3e}, tolerance={:.3e}",
1757 options.outer_max_iter,
1758 options.outer_tol * last_scale,
1759 ),
1760 }
1761 .to_string()
1762 })?;
1763 let saved_warp_beta = fit
1779 .block_states
1780 .get(BinomialMeanWiggleFamily::BLOCK_WIGGLE)
1781 .map(|state| state.beta.to_vec())
1782 .ok_or_else(|| {
1783 "fit_binomial_mean_wiggle: converged fit is missing its LinkWiggle block state"
1784 .to_string()
1785 })?;
1786 validate_monotone_wiggle_beta_nonnegative(
1787 &saved_warp_beta,
1788 "fit_binomial_mean_wiggle saved warp",
1789 )?;
1790 finalize_binomial_mean_wiggle_saved_frame(
1791 &mut fit,
1792 &last_alias,
1793 &x_dense,
1794 &eta_block_input.offset,
1795 )?;
1796 let saved_mean_state = fit
1801 .block_states
1802 .get(BinomialMeanWiggleFamily::BLOCK_ETA)
1803 .ok_or_else(|| {
1804 "fit_binomial_mean_wiggle: finalized fit is missing its Mean block state".to_string()
1805 })?;
1806 if frozen_source_beta.len() != saved_mean_state.beta.len() {
1807 return Err(format!(
1808 "fit_binomial_mean_wiggle: frozen-index source has {} coefficients, but saved Mean block has {}",
1809 frozen_source_beta.len(),
1810 saved_mean_state.beta.len(),
1811 ));
1812 }
1813 let saved_index_shift = Some((&frozen_source_beta - &saved_mean_state.beta).to_vec());
1814 Ok((fit, Some(saved_warp_beta), saved_index_shift))
1815}
1816
1817fn penalty_spec_to_dense(
1821 spec: &crate::model_types::PenaltySpec,
1822 p: usize,
1823) -> Result<Array2<f64>, String> {
1824 use crate::model_types::PenaltySpec;
1825 match spec {
1826 PenaltySpec::Dense(m) | PenaltySpec::DenseWithMean { matrix: m, .. } => {
1827 if m.nrows() != p || m.ncols() != p {
1828 return Err(format!(
1829 "frozen-basis warp penalty must be {p}x{p}, got {}x{}",
1830 m.nrows(),
1831 m.ncols()
1832 ));
1833 }
1834 Ok(m.clone())
1835 }
1836 PenaltySpec::Block {
1837 local, col_range, ..
1838 } => {
1839 let mut full = Array2::<f64>::zeros((p, p));
1840 if col_range.end > p || local.nrows() != col_range.len() {
1841 return Err("frozen-basis warp penalty block range out of bounds".to_string());
1842 }
1843 full.slice_mut(s![col_range.clone(), col_range.clone()])
1844 .assign(local);
1845 Ok(full)
1846 }
1847 }
1848}
1849
1850pub(crate) trait LocationScaleFamilyBuilder {
1851 type Family: CustomFamily + Clone + Send + Sync + 'static;
1852
1853 fn meanspec(&self) -> &TermCollectionSpec;
1854 fn noisespec(&self) -> &TermCollectionSpec;
1855
1856 fn build_blocks(
1857 &self,
1858 theta: &Array1<f64>,
1859 mean_design: &TermCollectionDesign,
1860 noise_design: &TermCollectionDesign,
1861 mean_beta_hint: Option<Array1<f64>>,
1862 noise_beta_hint: Option<Array1<f64>>,
1863 ) -> Result<Vec<ParameterBlockSpec>, String>;
1864
1865 fn build_family(
1866 &self,
1867 mean_design: &TermCollectionDesign,
1868 noise_design: &TermCollectionDesign,
1869 ) -> Self::Family;
1870
1871 fn extract_primary_betas(
1872 &self,
1873 fit: &UnifiedFitResult,
1874 ) -> Result<(Array1<f64>, Array1<f64>), String>;
1875
1876 fn mean_penalty_count(&self, mean_design: &TermCollectionDesign) -> usize {
1877 mean_design.penalties.len()
1878 }
1879
1880 fn noise_penalty_count(&self, noise_design: &TermCollectionDesign) -> usize {
1881 noise_design.penalties.len()
1882 }
1883
1884 fn exact_spatial_joint_supported(&self) -> bool {
1885 false
1886 }
1887
1888 fn require_exact_spatial_joint(&self) -> bool {
1889 false
1890 }
1891
1892 fn exact_spatial_seed_risk_profile(&self) -> crate::seeding::SeedRiskProfile {
1893 crate::seeding::SeedRiskProfile::GeneralizedLinear
1894 }
1895
1896 fn extra_rho0(&self) -> Result<Array1<f64>, String> {
1897 Ok(Array1::zeros(0))
1898 }
1899
1900 fn build_psiderivative_blocks(
1901 &self,
1902 arr: ndarray::ArrayView2<'_, f64>,
1903 term_spec: &TermCollectionSpec,
1904 term_spec2: &TermCollectionSpec,
1905 term_design: &TermCollectionDesign,
1906 term_design2: &TermCollectionDesign,
1907 ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String>;
1908}
1909
1910pub(crate) fn fit_location_scale_terms<B: LocationScaleFamilyBuilder>(
1911 data: ndarray::ArrayView2<'_, f64>,
1912 builder: B,
1913 options: &BlockwiseFitOptions,
1914 kappa_options: &SpatialLengthScaleOptimizationOptions,
1915) -> Result<BlockwiseTermFitResult, String> {
1916 let mut mean_beta_hint: Option<Array1<f64>> = None;
1922 let mut noise_beta_hint: Option<Array1<f64>> = None;
1923 let extra_rho0 = builder.extra_rho0()?;
1924
1925 let mean_boot_design =
1926 build_term_collection_design(data, builder.meanspec()).map_err(|e| e.to_string())?;
1927 let noise_boot_design =
1928 build_term_collection_design(data, builder.noisespec()).map_err(|e| e.to_string())?;
1929 let mean_bootspec = freeze_term_collection_from_design(builder.meanspec(), &mean_boot_design)
1930 .map_err(|e| e.to_string())?;
1931 let noise_bootspec =
1932 freeze_term_collection_from_design(builder.noisespec(), &noise_boot_design)
1933 .map_err(|e| e.to_string())?;
1934
1935 let require_exact_spatial_joint = builder.require_exact_spatial_joint();
1936 let analytic_joint_derivatives_check = if builder.exact_spatial_joint_supported() {
1937 builder
1938 .build_psiderivative_blocks(
1939 data,
1940 &mean_bootspec,
1941 &noise_bootspec,
1942 &mean_boot_design,
1943 &noise_boot_design,
1944 )
1945 .map(|_| ())
1946 } else {
1947 Err(
1948 "analytic spatial psi derivatives are unavailable for this location-scale family"
1949 .to_string(),
1950 )
1951 };
1952 let analytic_joint_derivatives_available = analytic_joint_derivatives_check.is_ok();
1953 if require_exact_spatial_joint {
1954 analytic_joint_derivatives_check.map_err(|err| {
1955 format!("exact two-block spatial path requires analytic psi derivatives: {err}")
1956 })?;
1957 }
1958 let mean_penalty_count = builder.mean_penalty_count(&mean_boot_design);
1959 let noise_penalty_count = builder.noise_penalty_count(&noise_boot_design);
1960
1961 let mut effective_kappa_options = kappa_options.clone();
1972 if effective_kappa_options.enabled
1973 && gam_terms::smooth::all_spatial_terms_kappa_fixed(&mean_bootspec)
1974 && gam_terms::smooth::all_spatial_terms_kappa_fixed(&noise_bootspec)
1975 {
1976 log::info!(
1977 "[GAMLSS spatial] disabling κ/ψ optimization: every spatial term in \
1978 both blocks has an explicit length_scale and no anisotropy; \
1979 user-supplied kernel scale is fixed"
1980 );
1981 effective_kappa_options.enabled = false;
1982 }
1983 let kappa_options: &SpatialLengthScaleOptimizationOptions = &effective_kappa_options;
1984
1985 macro_rules! run_exact_joint_spatial {
1989 () => {{
1990 let joint_setup = build_two_block_exact_joint_setup(
1991 data,
1992 builder.meanspec(),
1993 builder.noisespec(),
1994 mean_penalty_count,
1995 noise_penalty_count,
1996 extra_rho0.as_slice().unwrap_or(&[]),
1997 None,
1998 kappa_options,
1999 )
2000 .map_err(|error| error.to_string())?;
2001 let mean_terms = spatial_length_scale_term_indices(builder.meanspec());
2002 let noise_terms = spatial_length_scale_term_indices(builder.noisespec());
2003 let mean_beta_hint_cell = std::cell::RefCell::new(mean_beta_hint.clone());
2004 let noise_beta_hint_cell = std::cell::RefCell::new(noise_beta_hint.clone());
2005 let hyper_warm_start_cell =
2006 std::cell::RefCell::new(None::<CustomFamilyWarmStart>);
2007 let gamlss_disable_fixed_point = true;
2017 let outer_policy = {
2018 let theta_seed = joint_setup.theta0();
2030 let rho_dim = joint_setup.rho_dim();
2031 let psi_dim = theta_seed.len() - rho_dim;
2032 let rho_seed = theta_seed.slice(s![..rho_dim]).to_owned();
2033 let policy_blocks_res = builder.build_blocks(
2034 &rho_seed,
2035 &mean_boot_design,
2036 &noise_boot_design,
2037 mean_beta_hint_cell.borrow().clone(),
2038 noise_beta_hint_cell.borrow().clone(),
2039 );
2040 let mut policy = match policy_blocks_res {
2041 Ok(policy_blocks) => {
2042 let policy_family =
2043 builder.build_family(&mean_boot_design, &noise_boot_design);
2044 crate::custom_family::CustomFamily::outer_derivative_policy(
2045 &policy_family,
2046 &policy_blocks,
2047 psi_dim,
2048 options,
2049 )
2050 }
2051 Err(err) => {
2052 log::warn!(
2060 "[GAMLSS spatial] failed to realize policy blocks at seed rho ({err}); \
2061 routing outer optimizer through gradient-only BFGS"
2062 );
2063 let capability = if analytic_joint_derivatives_available {
2064 crate::custom_family::ExactOuterDerivativeOrder::Second
2065 } else {
2066 crate::custom_family::ExactOuterDerivativeOrder::First
2067 };
2068 crate::custom_family::OuterDerivativePolicy {
2069 capability,
2070 predicted_gradient_work: u128::MAX,
2071 predicted_hessian_work: u128::MAX,
2072 subsample_capable: false,
2077 }
2078 }
2079 };
2080 if !analytic_joint_derivatives_available {
2081 policy.capability =
2085 crate::custom_family::ExactOuterDerivativeOrder::First;
2086 }
2087 policy
2088 };
2089 optimize_spatial_length_scale_exact_joint(
2090 data,
2091 &[builder.meanspec().clone(), builder.noisespec().clone()],
2092 &[mean_terms, noise_terms],
2093 kappa_options,
2094 &joint_setup,
2095 builder.exact_spatial_seed_risk_profile(),
2096 analytic_joint_derivatives_available,
2097 analytic_joint_derivatives_available,
2098 gamlss_disable_fixed_point,
2099 None,
2100 outer_policy,
2101 |theta,
2102 specs: &[TermCollectionSpec],
2103 designs: &[TermCollectionDesign],
2104 provenance| {
2105 assert_eq!(
2106 specs.len(),
2107 2,
2108 "joint spatial closure expects exactly two block specs (mean, noise); got {}",
2109 specs.len(),
2110 );
2111 assert_eq!(
2112 designs.len(),
2113 2,
2114 "joint spatial closure expects exactly two block designs (mean, noise); got {}",
2115 designs.len(),
2116 );
2117 let rho = theta.slice(s![..joint_setup.rho_dim()]).to_owned();
2118 let fit = {
2119 let blocks = builder.build_blocks(
2120 &rho,
2121 &designs[0],
2122 &designs[1],
2123 mean_beta_hint_cell.borrow().clone(),
2124 noise_beta_hint_cell.borrow().clone(),
2125 )?;
2126 if mean_beta_hint_cell.borrow().is_none()
2127 && let Some(beta) = blocks.first().and_then(|block| block.initial_beta.clone())
2128 {
2129 *mean_beta_hint_cell.borrow_mut() = Some(beta);
2130 }
2131 if noise_beta_hint_cell.borrow().is_none()
2132 && let Some(beta) =
2133 blocks.get(1).and_then(|block| block.initial_beta.clone())
2134 {
2135 *noise_beta_hint_cell.borrow_mut() = Some(beta);
2136 }
2137 let family = builder.build_family(&designs[0], &designs[1]);
2138 if joint_setup.log_kappa_dim() > 0 && kappa_options.enabled {
2160 let (certified_outer, mode) = match provenance {
2161 SpatialFitProvenance::Certified { outer, mode } => (outer, mode),
2162 SpatialFitProvenance::NoOuterOptimization => {
2163 return Err(
2164 "active GAMLSS spatial optimization returned no certified outer provenance"
2165 .to_string(),
2166 );
2167 }
2168 };
2169 let exact_options =
2170 crate::outer_subsample::exact_outer_options_for_row_set(
2171 options,
2172 &crate::row_kernel::RowSet::All,
2173 );
2174 fit_custom_family_fixed_log_lambdas_from_owned_mode(
2175 &family,
2176 &blocks,
2177 &exact_options,
2178 mode,
2179 theta,
2180 certified_outer,
2181 )?
2182 } else {
2183 fit_custom_family(&family, &blocks, options)?
2184 }
2185 };
2186 let (mean_beta, noise_beta) = builder.extract_primary_betas(&fit)?;
2187 mean_beta_hint = Some(mean_beta);
2188 noise_beta_hint = Some(noise_beta);
2189 *mean_beta_hint_cell.borrow_mut() = mean_beta_hint.clone();
2190 *noise_beta_hint_cell.borrow_mut() = noise_beta_hint.clone();
2191 Ok(fit)
2192 },
2193 |theta,
2194 specs: &[TermCollectionSpec],
2195 designs: &[TermCollectionDesign],
2196 eval_mode,
2197 row_set: &crate::row_kernel::RowSet| {
2198 use gam_problem::EvalMode;
2199 if !analytic_joint_derivatives_available {
2200 return Err(
2201 "analytic spatial psi derivatives are unavailable for this exact two-block path"
2202 .to_string(),
2203 );
2204 }
2205 let rho = theta.slice(s![..joint_setup.rho_dim()]).to_owned();
2206 let blocks = builder.build_blocks(
2207 &rho,
2208 &designs[0],
2209 &designs[1],
2210 mean_beta_hint_cell.borrow().clone(),
2211 noise_beta_hint_cell.borrow().clone(),
2212 )?;
2213 if mean_beta_hint_cell.borrow().is_none()
2214 && let Some(beta) = blocks.first().and_then(|block| block.initial_beta.clone())
2215 {
2216 *mean_beta_hint_cell.borrow_mut() = Some(beta);
2217 }
2218 if noise_beta_hint_cell.borrow().is_none()
2219 && let Some(beta) = blocks.get(1).and_then(|block| block.initial_beta.clone())
2220 {
2221 *noise_beta_hint_cell.borrow_mut() = Some(beta);
2222 }
2223 let family = builder.build_family(&designs[0], &designs[1]);
2224 let psiderivative_blocks = builder.build_psiderivative_blocks(
2225 data,
2226 &specs[0],
2227 &specs[1],
2228 &designs[0],
2229 &designs[1],
2230 )?;
2231 let hyper_layout = crate::custom_family::CustomFamilyHyperLayout::new(
2232 psiderivative_blocks,
2233 Vec::new(),
2234 theta.slice(s![joint_setup.rho_dim()..]).to_owned(),
2235 )?;
2236 let warm_start = hyper_warm_start_cell.borrow().clone();
2237 let eval_options =
2244 crate::outer_subsample::exact_outer_options_for_row_set(options, row_set);
2245 let owned = evaluate_custom_family_joint_hyper_owned(
2246 &family,
2247 &blocks,
2248 &eval_options,
2249 &rho,
2250 &hyper_layout,
2251 warm_start.as_ref(),
2252 eval_mode,
2253 )?;
2254 *hyper_warm_start_cell.borrow_mut() = Some(owned.result.warm_start.clone());
2255 if !owned.result.inner_converged {
2256 return Err(
2257 "exact two-block spatial inner solve did not converge".to_string(),
2258 );
2259 }
2260 if matches!(eval_mode, EvalMode::ValueGradientHessian)
2261 && !owned.result.outer_hessian.is_analytic()
2262 {
2263 return Err(
2264 "exact two-block spatial objective requires a full joint [rho, psi] hessian"
2265 .to_string(),
2266 );
2267 }
2268 Ok(ExactJointEvaluation {
2269 objective: owned.result.objective,
2270 gradient: owned.result.gradient,
2271 hessian: owned.result.outer_hessian,
2272 mode: owned.mode,
2273 })
2274 },
2275 |theta,
2276 specs: &[TermCollectionSpec],
2277 designs: &[TermCollectionDesign],
2278 row_set: &crate::row_kernel::RowSet| {
2279 if !analytic_joint_derivatives_available {
2280 return Err(
2281 "analytic spatial psi derivatives are unavailable for this exact two-block path"
2282 .to_string(),
2283 );
2284 }
2285 let rho = theta.slice(s![..joint_setup.rho_dim()]).to_owned();
2286 let blocks = builder.build_blocks(
2287 &rho,
2288 &designs[0],
2289 &designs[1],
2290 mean_beta_hint_cell.borrow().clone(),
2291 noise_beta_hint_cell.borrow().clone(),
2292 )?;
2293 if mean_beta_hint_cell.borrow().is_none()
2294 && let Some(beta) = blocks.first().and_then(|block| block.initial_beta.clone())
2295 {
2296 *mean_beta_hint_cell.borrow_mut() = Some(beta);
2297 }
2298 if noise_beta_hint_cell.borrow().is_none()
2299 && let Some(beta) = blocks.get(1).and_then(|block| block.initial_beta.clone())
2300 {
2301 *noise_beta_hint_cell.borrow_mut() = Some(beta);
2302 }
2303 let family = builder.build_family(&designs[0], &designs[1]);
2304 let psiderivative_blocks = builder.build_psiderivative_blocks(
2305 data,
2306 &specs[0],
2307 &specs[1],
2308 &designs[0],
2309 &designs[1],
2310 )?;
2311 let hyper_layout = crate::custom_family::CustomFamilyHyperLayout::new(
2312 psiderivative_blocks,
2313 Vec::new(),
2314 theta.slice(s![joint_setup.rho_dim()..]).to_owned(),
2315 )?;
2316 let warm_start = hyper_warm_start_cell.borrow().clone();
2317 let eval_options =
2318 crate::outer_subsample::exact_outer_options_for_row_set(options, row_set);
2319 let owned = evaluate_custom_family_joint_hyper_efs_owned(
2320 &family,
2321 &blocks,
2322 &eval_options,
2323 &rho,
2324 &hyper_layout,
2325 warm_start.as_ref(),
2326 )?;
2327 *hyper_warm_start_cell.borrow_mut() = Some(owned.result.warm_start.clone());
2328 if !owned.result.inner_converged {
2329 return Err(
2330 "exact two-block spatial EFS inner solve did not converge".to_string(),
2331 );
2332 }
2333 Ok(ExactJointEfsEvaluation {
2334 evaluation: owned.result.efs_eval,
2335 mode: owned.mode,
2336 })
2337 },
2338 |_beta: &Array1<f64>| Ok(gam_solve::rho_optimizer::SeedOutcome::NoSlot),
2339 )
2340 }};
2341 }
2342
2343 let mut solved = run_exact_joint_spatial!()
2344 .map_err(|err| format!("exact two-block spatial optimization failed: {err}"))?;
2345
2346 let expected_noise_penalty_count = builder.noise_penalty_count(&solved.designs[1]);
2347 let actual_noise_penalty_count = solved.designs[1].penalties.len();
2348 if expected_noise_penalty_count > actual_noise_penalty_count {
2349 if expected_noise_penalty_count != actual_noise_penalty_count + 1 {
2350 return Err(GamlssError::UnsupportedConfiguration {
2351 reason: format!(
2352 "location-scale result noise design expected {} penalties after augmentation, got {} before augmentation",
2353 expected_noise_penalty_count, actual_noise_penalty_count
2354 ),
2355 }
2356 .into());
2357 }
2358 append_binomial_log_sigma_shrinkage_penalty_design(&mut solved.designs[1]);
2359 }
2360
2361 BlockwiseTermFitResult::try_from_parts(BlockwiseTermFitResultParts {
2362 fit: solved.fit,
2363 meanspec_resolved: solved.resolved_specs.remove(0),
2364 noisespec_resolved: solved.resolved_specs.remove(0),
2365 mean_design: solved.designs.remove(0),
2366 noise_design: solved.designs.remove(0),
2367 })
2368}
2369
2370pub(crate) struct GaussianLocationScaleTermBuilder {
2371 pub(crate) y: Array1<f64>,
2372 pub(crate) weights: Array1<f64>,
2373 pub(crate) meanspec: TermCollectionSpec,
2374 pub(crate) noisespec: TermCollectionSpec,
2375 pub(crate) mean_offset: Array1<f64>,
2376 pub(crate) noise_offset: Array1<f64>,
2377}
2378
2379impl LocationScaleFamilyBuilder for GaussianLocationScaleTermBuilder {
2380 type Family = GaussianLocationScaleFamily;
2381
2382 fn meanspec(&self) -> &TermCollectionSpec {
2383 &self.meanspec
2384 }
2385
2386 fn noisespec(&self) -> &TermCollectionSpec {
2387 &self.noisespec
2388 }
2389
2390 fn exact_spatial_joint_supported(&self) -> bool {
2391 true
2392 }
2393
2394 fn exact_spatial_seed_risk_profile(&self) -> crate::seeding::SeedRiskProfile {
2395 crate::seeding::SeedRiskProfile::GaussianLocationScale
2396 }
2397
2398 fn build_blocks(
2399 &self,
2400 theta: &Array1<f64>,
2401 mean_design: &TermCollectionDesign,
2402 noise_design: &TermCollectionDesign,
2403 mean_beta_hint: Option<Array1<f64>>,
2404 noise_beta_hint: Option<Array1<f64>>,
2405 ) -> Result<Vec<ParameterBlockSpec>, String> {
2406 let layout = GamlssLambdaLayout::two_block(
2407 mean_design.penalties.len(),
2408 self.noise_penalty_count(noise_design),
2409 );
2410 layout.validate_theta_len(theta.len(), "gaussian location-scale")?;
2411 let (meanspec, noisespec) = build_gaussian_mean_and_scale_blocks(
2412 &self.y,
2413 &self.weights,
2414 mean_design,
2415 noise_design,
2416 &self.mean_offset,
2417 &self.noise_offset,
2418 layout.mean_from(theta),
2419 layout.noise_from(theta),
2420 mean_beta_hint,
2421 noise_beta_hint,
2422 "GaussianLocationScale::build_blocks",
2423 )?;
2424 Ok(vec![meanspec, noisespec])
2425 }
2426
2427 fn build_family(
2428 &self,
2429 mean_design: &TermCollectionDesign,
2430 noise_design: &TermCollectionDesign,
2431 ) -> Self::Family {
2432 let preparednoise_design =
2433 prepared_gaussian_log_sigma_design(&mean_design.design, &noise_design.design)
2434 .expect("prepared Gaussian log-sigma design should match block construction");
2435 GaussianLocationScaleFamily {
2436 y: self.y.clone(),
2437 weights: self.weights.clone(),
2438 mu_design: Some(mean_design.design.clone()),
2439 log_sigma_design: Some(preparednoise_design),
2440 policy: gam_runtime::resource::ResourcePolicy::default_library(),
2441 cached_row_scalars: std::sync::RwLock::new(None),
2442 }
2443 }
2444
2445 fn extract_primary_betas(
2446 &self,
2447 fit: &UnifiedFitResult,
2448 ) -> Result<(Array1<f64>, Array1<f64>), String> {
2449 let mean_beta = fit
2450 .block_states
2451 .get(GaussianLocationScaleFamily::BLOCK_MU)
2452 .ok_or_else(|| "missing Gaussian mu block state".to_string())?
2453 .beta
2454 .clone();
2455 let noise_beta = fit
2456 .block_states
2457 .get(GaussianLocationScaleFamily::BLOCK_LOG_SIGMA)
2458 .ok_or_else(|| "missing Gaussian log_sigma block state".to_string())?
2459 .beta
2460 .clone();
2461 Ok((mean_beta, noise_beta))
2462 }
2463
2464 fn build_psiderivative_blocks(
2465 &self,
2466 data: ndarray::ArrayView2<'_, f64>,
2467 meanspec_resolved: &TermCollectionSpec,
2468 noisespec_resolved: &TermCollectionSpec,
2469 mean_design: &TermCollectionDesign,
2470 noise_design: &TermCollectionDesign,
2471 ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
2472 let mean_derivs =
2473 build_block_spatial_psi_derivatives(data, meanspec_resolved, mean_design)?
2474 .ok_or_else(|| "missing Gaussian mean spatial psi derivatives".to_string())?;
2475 let noise_derivs =
2476 build_block_spatial_psi_derivatives(data, noisespec_resolved, noise_design)?
2477 .ok_or_else(|| "missing Gaussian log-sigma spatial psi derivatives".to_string())?;
2478 Ok(vec![mean_derivs, noise_derivs])
2479 }
2480}
2481
2482pub(crate) struct GaussianLocationScaleWiggleTermBuilder {
2483 pub(crate) y: Array1<f64>,
2484 pub(crate) weights: Array1<f64>,
2485 pub(crate) meanspec: TermCollectionSpec,
2486 pub(crate) noisespec: TermCollectionSpec,
2487 pub(crate) mean_offset: Array1<f64>,
2488 pub(crate) noise_offset: Array1<f64>,
2489 pub(crate) wiggle_knots: Array1<f64>,
2490 pub(crate) wiggle_degree: usize,
2491 pub(crate) wiggle_block: ParameterBlockInput,
2492}
2493
2494impl LocationScaleFamilyBuilder for GaussianLocationScaleWiggleTermBuilder {
2495 type Family = GaussianLocationScaleWiggleFamily;
2496
2497 fn meanspec(&self) -> &TermCollectionSpec {
2498 &self.meanspec
2499 }
2500
2501 fn noisespec(&self) -> &TermCollectionSpec {
2502 &self.noisespec
2503 }
2504
2505 fn exact_spatial_joint_supported(&self) -> bool {
2506 true
2507 }
2508
2509 fn exact_spatial_seed_risk_profile(&self) -> crate::seeding::SeedRiskProfile {
2510 crate::seeding::SeedRiskProfile::GaussianLocationScale
2511 }
2512
2513 fn require_exact_spatial_joint(&self) -> bool {
2514 true
2515 }
2516
2517 fn extra_rho0(&self) -> Result<Array1<f64>, String> {
2518 initial_log_lambdas_orzeros(&self.wiggle_block)
2519 }
2520
2521 fn build_blocks(
2522 &self,
2523 theta: &Array1<f64>,
2524 mean_design: &TermCollectionDesign,
2525 noise_design: &TermCollectionDesign,
2526 mean_beta_hint: Option<Array1<f64>>,
2527 noise_beta_hint: Option<Array1<f64>>,
2528 ) -> Result<Vec<ParameterBlockSpec>, String> {
2529 let layout = GamlssLambdaLayout::withwiggle(
2530 mean_design.penalties.len(),
2531 self.noise_penalty_count(noise_design),
2532 self.wiggle_block.penalties.len(),
2533 );
2534 layout.validate_theta_len(theta.len(), "gaussian location-scale wiggle")?;
2535 let (mut meanspec, mut noisespec) = build_gaussian_mean_and_scale_blocks(
2536 &self.y,
2537 &self.weights,
2538 mean_design,
2539 noise_design,
2540 &self.mean_offset,
2541 &self.noise_offset,
2542 layout.mean_from(theta),
2543 layout.noise_from(theta),
2544 mean_beta_hint,
2545 noise_beta_hint,
2546 "GaussianLocationScaleWiggle::build_blocks",
2547 )?;
2548 meanspec.gauge_priority = LINK_WIGGLE_GAUGE_PRIORITY;
2554 noisespec.gauge_priority = LINK_WIGGLE_GAUGE_PRIORITY;
2555 let n_rows = meanspec.design.nrows();
2556 let wigglespec = build_location_scale_wiggle_block(
2557 "wiggle",
2558 self.wiggle_block.design.clone(),
2559 self.wiggle_block.offset.clone(),
2560 wiggle_block_penalty_matrices(&self.wiggle_block),
2561 self.wiggle_block.nullspace_dims.clone(),
2562 layout.wiggle_from(theta),
2563 self.wiggle_block.initial_beta.clone(),
2564 n_rows,
2565 )?;
2566 Ok(vec![meanspec, noisespec, wigglespec])
2567 }
2568
2569 fn build_family(
2570 &self,
2571 mean_design: &TermCollectionDesign,
2572 noise_design: &TermCollectionDesign,
2573 ) -> Self::Family {
2574 let preparednoise_design =
2575 prepared_gaussian_log_sigma_design(&mean_design.design, &noise_design.design).expect(
2576 "prepared Gaussian log-sigma design should match wiggle block construction",
2577 );
2578 GaussianLocationScaleWiggleFamily {
2579 y: self.y.clone(),
2580 weights: self.weights.clone(),
2581 mu_design: Some(mean_design.design.clone()),
2582 log_sigma_design: Some(preparednoise_design),
2583 wiggle_knots: self.wiggle_knots.clone(),
2584 wiggle_degree: self.wiggle_degree,
2585 policy: gam_runtime::resource::ResourcePolicy::default_library(),
2586 cached_row_scalars: std::sync::RwLock::new(None),
2587 }
2588 }
2589
2590 fn extract_primary_betas(
2591 &self,
2592 fit: &UnifiedFitResult,
2593 ) -> Result<(Array1<f64>, Array1<f64>), String> {
2594 let mean_beta = fit
2595 .block_states
2596 .get(GaussianLocationScaleWiggleFamily::BLOCK_MU)
2597 .ok_or_else(|| "missing Gaussian wiggle mu block state".to_string())?
2598 .beta
2599 .clone();
2600 let noise_beta = fit
2601 .block_states
2602 .get(GaussianLocationScaleWiggleFamily::BLOCK_LOG_SIGMA)
2603 .ok_or_else(|| "missing Gaussian wiggle log_sigma block state".to_string())?
2604 .beta
2605 .clone();
2606 Ok((mean_beta, noise_beta))
2607 }
2608
2609 fn build_psiderivative_blocks(
2610 &self,
2611 data: ndarray::ArrayView2<'_, f64>,
2612 meanspec_resolved: &TermCollectionSpec,
2613 noisespec_resolved: &TermCollectionSpec,
2614 mean_design: &TermCollectionDesign,
2615 noise_design: &TermCollectionDesign,
2616 ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
2617 let mean_derivs =
2618 build_block_spatial_psi_derivatives(data, meanspec_resolved, mean_design)?.ok_or_else(
2619 || "missing Gaussian wiggle mean spatial psi derivatives".to_string(),
2620 )?;
2621 let noise_derivs =
2622 build_block_spatial_psi_derivatives(data, noisespec_resolved, noise_design)?
2623 .ok_or_else(|| {
2624 "missing Gaussian wiggle log-sigma spatial psi derivatives".to_string()
2625 })?;
2626 Ok(vec![mean_derivs, noise_derivs, Vec::new()])
2627 }
2628}
2629
2630pub(crate) struct BinomialLocationScaleTermBuilder {
2631 pub(crate) y: Array1<f64>,
2632 pub(crate) weights: Array1<f64>,
2633 pub(crate) link_kind: InverseLink,
2634 pub(crate) meanspec: TermCollectionSpec,
2635 pub(crate) noisespec: TermCollectionSpec,
2636 pub(crate) mean_offset: Array1<f64>,
2637 pub(crate) noise_offset: Array1<f64>,
2638}
2639
2640impl LocationScaleFamilyBuilder for BinomialLocationScaleTermBuilder {
2641 type Family = BinomialLocationScaleFamily;
2642
2643 fn meanspec(&self) -> &TermCollectionSpec {
2644 &self.meanspec
2645 }
2646
2647 fn noisespec(&self) -> &TermCollectionSpec {
2648 &self.noisespec
2649 }
2650
2651 fn exact_spatial_joint_supported(&self) -> bool {
2652 true
2653 }
2654
2655 fn require_exact_spatial_joint(&self) -> bool {
2656 true
2657 }
2658
2659 fn noise_penalty_count(&self, noise_design: &TermCollectionDesign) -> usize {
2660 noise_design.penalties.len() + 1
2661 }
2662
2663 fn build_blocks(
2664 &self,
2665 theta: &Array1<f64>,
2666 mean_design: &TermCollectionDesign,
2667 noise_design: &TermCollectionDesign,
2668 mean_beta_hint: Option<Array1<f64>>,
2669 noise_beta_hint: Option<Array1<f64>>,
2670 ) -> Result<Vec<ParameterBlockSpec>, String> {
2671 let layout = GamlssLambdaLayout::two_block(
2672 mean_design.penalties.len(),
2673 self.noise_penalty_count(noise_design),
2674 );
2675 layout.validate_theta_len(theta.len(), "binomial location-scale")?;
2676 let (thresholdspec, log_sigmaspec) = build_binomial_threshold_and_scale_blocks(
2677 &self.y,
2678 &self.weights,
2679 &self.link_kind,
2680 mean_design,
2681 noise_design,
2682 &self.mean_offset,
2683 &self.noise_offset,
2684 layout.mean_from(theta),
2685 layout.noise_from(theta),
2686 mean_beta_hint,
2687 noise_beta_hint,
2688 "BinomialLocationScale::build_blocks",
2689 )?;
2690 Ok(vec![thresholdspec, log_sigmaspec])
2691 }
2692
2693 fn build_family(
2694 &self,
2695 mean_design: &TermCollectionDesign,
2696 noise_design: &TermCollectionDesign,
2697 ) -> Self::Family {
2698 let identifiednoise_design =
2699 identified_binomial_log_sigma_design(mean_design, noise_design, &self.weights)
2700 .expect("identified binomial log-sigma design");
2701 BinomialLocationScaleFamily {
2702 y: self.y.clone(),
2703 weights: self.weights.clone(),
2704 link_kind: self.link_kind.clone(),
2705 threshold_design: Some(mean_design.design.clone()),
2706 log_sigma_design: Some(identifiednoise_design),
2707 policy: gam_runtime::resource::ResourcePolicy::default_library(),
2708 }
2709 }
2710
2711 fn extract_primary_betas(
2712 &self,
2713 fit: &UnifiedFitResult,
2714 ) -> Result<(Array1<f64>, Array1<f64>), String> {
2715 let mean_beta = fit
2716 .block_states
2717 .get(BinomialLocationScaleFamily::BLOCK_T)
2718 .ok_or_else(|| "missing Binomial threshold block state".to_string())?
2719 .beta
2720 .clone();
2721 let noise_beta = fit
2722 .block_states
2723 .get(BinomialLocationScaleFamily::BLOCK_LOG_SIGMA)
2724 .ok_or_else(|| "missing Binomial log_sigma block state".to_string())?
2725 .beta
2726 .clone();
2727 Ok((mean_beta, noise_beta))
2728 }
2729
2730 fn build_psiderivative_blocks(
2731 &self,
2732 data: ndarray::ArrayView2<'_, f64>,
2733 meanspec_resolved: &TermCollectionSpec,
2734 noisespec_resolved: &TermCollectionSpec,
2735 mean_design: &TermCollectionDesign,
2736 noise_design: &TermCollectionDesign,
2737 ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
2738 let mean_derivs =
2739 build_block_spatial_psi_derivatives(data, meanspec_resolved, mean_design)?
2740 .ok_or_else(|| "missing threshold spatial psi derivatives".to_string())?;
2741 let noise_derivs =
2742 build_block_spatial_psi_derivatives(data, noisespec_resolved, noise_design)?
2743 .ok_or_else(|| "missing log_sigma spatial psi derivatives".to_string())?;
2744 Ok(vec![mean_derivs, noise_derivs])
2745 }
2746}
2747
2748pub(crate) struct BinomialLocationScaleWiggleTermBuilder {
2749 pub(crate) y: Array1<f64>,
2750 pub(crate) weights: Array1<f64>,
2751 pub(crate) link_kind: InverseLink,
2752 pub(crate) meanspec: TermCollectionSpec,
2753 pub(crate) noisespec: TermCollectionSpec,
2754 pub(crate) mean_offset: Array1<f64>,
2755 pub(crate) noise_offset: Array1<f64>,
2756 pub(crate) wiggle_knots: Array1<f64>,
2757 pub(crate) wiggle_degree: usize,
2758 pub(crate) wiggle_block: ParameterBlockInput,
2759}
2760
2761impl LocationScaleFamilyBuilder for BinomialLocationScaleWiggleTermBuilder {
2762 type Family = BinomialLocationScaleWiggleFamily;
2763
2764 fn meanspec(&self) -> &TermCollectionSpec {
2765 &self.meanspec
2766 }
2767
2768 fn noisespec(&self) -> &TermCollectionSpec {
2769 &self.noisespec
2770 }
2771
2772 fn exact_spatial_joint_supported(&self) -> bool {
2773 true
2774 }
2775
2776 fn require_exact_spatial_joint(&self) -> bool {
2777 true
2778 }
2779
2780 fn extra_rho0(&self) -> Result<Array1<f64>, String> {
2781 initial_log_lambdas_orzeros(&self.wiggle_block)
2782 }
2783
2784 fn noise_penalty_count(&self, noise_design: &TermCollectionDesign) -> usize {
2785 noise_design.penalties.len() + 1
2786 }
2787
2788 fn build_blocks(
2789 &self,
2790 theta: &Array1<f64>,
2791 mean_design: &TermCollectionDesign,
2792 noise_design: &TermCollectionDesign,
2793 mean_beta_hint: Option<Array1<f64>>,
2794 noise_beta_hint: Option<Array1<f64>>,
2795 ) -> Result<Vec<ParameterBlockSpec>, String> {
2796 let layout = GamlssLambdaLayout::withwiggle(
2797 mean_design.penalties.len(),
2798 self.noise_penalty_count(noise_design),
2799 self.wiggle_block.penalties.len(),
2800 );
2801 layout.validate_theta_len(theta.len(), "wiggle location-scale")?;
2802 let (mut thresholdspec, mut log_sigmaspec) = build_binomial_threshold_and_scale_blocks(
2803 &self.y,
2804 &self.weights,
2805 &self.link_kind,
2806 mean_design,
2807 noise_design,
2808 &self.mean_offset,
2809 &self.noise_offset,
2810 layout.mean_from(theta),
2811 layout.noise_from(theta),
2812 mean_beta_hint,
2813 noise_beta_hint,
2814 "BinomialLocationScaleWiggle::build_blocks",
2815 )?;
2816 thresholdspec.gauge_priority = LINK_WIGGLE_GAUGE_PRIORITY;
2828 log_sigmaspec.gauge_priority = LINK_WIGGLE_GAUGE_PRIORITY;
2829 let n_rows = thresholdspec.design.nrows();
2830 let wigglespec = build_location_scale_wiggle_block(
2831 "wiggle",
2832 self.wiggle_block.design.clone(),
2833 self.wiggle_block.offset.clone(),
2834 wiggle_block_penalty_matrices(&self.wiggle_block),
2835 vec![],
2836 layout.wiggle_from(theta),
2837 self.wiggle_block.initial_beta.clone(),
2838 n_rows,
2839 )?;
2840 Ok(vec![thresholdspec, log_sigmaspec, wigglespec])
2841 }
2842
2843 fn build_family(
2844 &self,
2845 mean_design: &TermCollectionDesign,
2846 noise_design: &TermCollectionDesign,
2847 ) -> Self::Family {
2848 let identifiednoise_design =
2849 identified_binomial_log_sigma_design(mean_design, noise_design, &self.weights)
2850 .expect("identified binomial log-sigma design should match block construction");
2851 BinomialLocationScaleWiggleFamily {
2852 y: self.y.clone(),
2853 weights: self.weights.clone(),
2854 link_kind: self.link_kind.clone(),
2855 threshold_design: Some(mean_design.design.clone()),
2856 log_sigma_design: Some(identifiednoise_design),
2857 wiggle_knots: self.wiggle_knots.clone(),
2858 wiggle_degree: self.wiggle_degree,
2859 policy: gam_runtime::resource::ResourcePolicy::default_library(),
2860 }
2861 }
2862
2863 fn extract_primary_betas(
2864 &self,
2865 fit: &UnifiedFitResult,
2866 ) -> Result<(Array1<f64>, Array1<f64>), String> {
2867 let mean_beta = fit
2868 .block_states
2869 .get(BinomialLocationScaleWiggleFamily::BLOCK_T)
2870 .ok_or_else(|| "missing Binomial wiggle threshold block state".to_string())?
2871 .beta
2872 .clone();
2873 let noise_beta = fit
2874 .block_states
2875 .get(BinomialLocationScaleWiggleFamily::BLOCK_LOG_SIGMA)
2876 .ok_or_else(|| "missing Binomial wiggle log_sigma block state".to_string())?
2877 .beta
2878 .clone();
2879 Ok((mean_beta, noise_beta))
2880 }
2881
2882 fn build_psiderivative_blocks(
2883 &self,
2884 data: ndarray::ArrayView2<'_, f64>,
2885 meanspec_resolved: &TermCollectionSpec,
2886 noisespec_resolved: &TermCollectionSpec,
2887 mean_design: &TermCollectionDesign,
2888 noise_design: &TermCollectionDesign,
2889 ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
2890 let mean_derivs =
2891 build_block_spatial_psi_derivatives(data, meanspec_resolved, mean_design)?
2892 .ok_or_else(|| "missing threshold spatial psi derivatives".to_string())?;
2893 let noise_derivs =
2894 build_block_spatial_psi_derivatives(data, noisespec_resolved, noise_design)?
2895 .ok_or_else(|| "missing log_sigma spatial psi derivatives".to_string())?;
2896 Ok(vec![mean_derivs, noise_derivs, Vec::new()])
2905 }
2906}
2907
2908pub(crate) fn fit_gaussian_location_scale_terms(
2909 data: ndarray::ArrayView2<'_, f64>,
2910 spec: GaussianLocationScaleTermSpec,
2911 options: &BlockwiseFitOptions,
2912 kappa_options: &SpatialLengthScaleOptimizationOptions,
2913) -> Result<BlockwiseTermFitResult, String> {
2914 validate_gaussian_location_scale_termspec(data, &spec, "fit_gaussian_location_scale_terms")?;
2915 fit_location_scale_terms(
2916 data,
2917 GaussianLocationScaleTermBuilder {
2918 y: spec.y,
2919 weights: spec.weights,
2920 meanspec: spec.meanspec,
2921 noisespec: spec.log_sigmaspec,
2922 mean_offset: spec.mean_offset,
2923 noise_offset: spec.log_sigma_offset,
2924 },
2925 options,
2926 kappa_options,
2927 )
2928}
2929
2930pub(crate) fn fit_gaussian_location_scalewiggle_terms(
2931 data: ndarray::ArrayView2<'_, f64>,
2932 spec: GaussianLocationScaleWiggleTermSpec,
2933 options: &BlockwiseFitOptions,
2934 kappa_options: &SpatialLengthScaleOptimizationOptions,
2935) -> Result<BlockwiseTermFitResult, String> {
2936 validate_gaussian_location_scalewiggle_termspec(
2937 data,
2938 &spec,
2939 "fit_gaussian_location_scalewiggle_terms",
2940 )?;
2941 fit_location_scale_terms(
2942 data,
2943 GaussianLocationScaleWiggleTermBuilder {
2944 y: spec.y,
2945 weights: spec.weights,
2946 meanspec: spec.meanspec,
2947 noisespec: spec.log_sigmaspec,
2948 mean_offset: spec.mean_offset,
2949 noise_offset: spec.log_sigma_offset,
2950 wiggle_knots: spec.wiggle_knots,
2951 wiggle_degree: spec.wiggle_degree,
2952 wiggle_block: spec.wiggle_block,
2953 },
2954 options,
2955 kappa_options,
2956 )
2957}
2958
2959pub(crate) fn select_gaussian_location_scale_link_wiggle_basis_from_pilot(
2960 pilot: &BlockwiseTermFitResult,
2961 wiggle_cfg: &WiggleBlockConfig,
2962 wiggle_penalty_orders: &[usize],
2963) -> Result<SelectedWiggleBasis, String> {
2964 let q_seed = pilot
2965 .fit
2966 .block_states
2967 .first()
2968 .ok_or_else(|| "pilot Gaussian wiggle fit is missing mean block".to_string())?
2969 .eta
2970 .view();
2971 select_wiggle_basis_from_seed(q_seed, wiggle_cfg, wiggle_penalty_orders)
2972}
2973
2974pub(crate) fn fit_gaussian_location_scale_terms_with_selected_wiggle(
2975 data: ndarray::ArrayView2<'_, f64>,
2976 spec: GaussianLocationScaleTermSpec,
2977 selected_wiggle_basis: SelectedWiggleBasis,
2978 options: &BlockwiseFitOptions,
2979 kappa_options: &SpatialLengthScaleOptimizationOptions,
2980) -> Result<BlockwiseTermWiggleFitResult, String> {
2981 let SelectedWiggleBasis {
2982 knots: wiggle_knots,
2983 degree: wiggle_degree,
2984 block: wiggle_block,
2985 ..
2986 } = selected_wiggle_basis;
2987 let solved = fit_gaussian_location_scalewiggle_terms(
2988 data,
2989 GaussianLocationScaleWiggleTermSpec {
2990 y: spec.y,
2991 weights: spec.weights,
2992 meanspec: spec.meanspec,
2993 log_sigmaspec: spec.log_sigmaspec,
2994 mean_offset: spec.mean_offset,
2995 log_sigma_offset: spec.log_sigma_offset,
2996 wiggle_knots: wiggle_knots.clone(),
2997 wiggle_degree,
2998 wiggle_block,
2999 },
3000 options,
3001 kappa_options,
3002 )?;
3003
3004 BlockwiseTermWiggleFitResult::try_from_parts(BlockwiseTermWiggleFitResultParts {
3005 fit: solved,
3006 wiggle_knots,
3007 wiggle_degree,
3008 })
3009}
3010
3011pub(crate) fn fit_binomial_location_scale_terms(
3012 data: ndarray::ArrayView2<'_, f64>,
3013 spec: BinomialLocationScaleTermSpec,
3014 options: &BlockwiseFitOptions,
3015 kappa_options: &SpatialLengthScaleOptimizationOptions,
3016) -> Result<BlockwiseTermFitResult, String> {
3017 validate_binomial_location_scale_termspec(data, &spec, "fit_binomial_location_scale_terms")?;
3018 fit_location_scale_terms(
3019 data,
3020 BinomialLocationScaleTermBuilder {
3021 y: spec.y,
3022 weights: spec.weights,
3023 link_kind: spec.link_kind,
3024 meanspec: spec.thresholdspec,
3025 noisespec: spec.log_sigmaspec,
3026 mean_offset: spec.threshold_offset,
3027 noise_offset: spec.log_sigma_offset,
3028 },
3029 options,
3030 kappa_options,
3031 )
3032}
3033
3034pub(crate) fn fit_binomial_location_scalewiggle_terms(
3035 data: ndarray::ArrayView2<'_, f64>,
3036 spec: BinomialLocationScaleWiggleTermSpec,
3037 options: &BlockwiseFitOptions,
3038 kappa_options: &SpatialLengthScaleOptimizationOptions,
3039) -> Result<BlockwiseTermFitResult, String> {
3040 validate_binomial_location_scalewiggle_termspec(
3041 data,
3042 &spec,
3043 "fit_binomial_location_scalewiggle_terms",
3044 )?;
3045 fit_location_scale_terms(
3046 data,
3047 BinomialLocationScaleWiggleTermBuilder {
3048 y: spec.y,
3049 weights: spec.weights,
3050 link_kind: spec.link_kind,
3051 meanspec: spec.thresholdspec,
3052 noisespec: spec.log_sigmaspec,
3053 mean_offset: spec.threshold_offset,
3054 noise_offset: spec.log_sigma_offset,
3055 wiggle_knots: spec.wiggle_knots,
3056 wiggle_degree: spec.wiggle_degree,
3057 wiggle_block: spec.wiggle_block,
3058 },
3059 options,
3060 kappa_options,
3061 )
3062}
3063
3064pub(crate) fn select_binomial_location_scale_link_wiggle_basis_from_pilot(
3065 pilot: &BlockwiseTermFitResult,
3066 wiggle_cfg: &WiggleBlockConfig,
3067 wiggle_penalty_orders: &[usize],
3068) -> Result<SelectedWiggleBasis, String> {
3069 let eta_t = pilot
3070 .fit
3071 .block_states
3072 .first()
3073 .ok_or_else(|| "pilot fit is missing threshold block".to_string())?
3074 .eta
3075 .view();
3076 let eta_ls = pilot
3077 .fit
3078 .block_states
3079 .get(1)
3080 .ok_or_else(|| "pilot fit is missing log_sigma block".to_string())?
3081 .eta
3082 .view();
3083 let sigma = eta_ls.mapv(safe_exp);
3084 let q_seed = Array1::from_iter(eta_t.iter().zip(sigma.iter()).map(|(&t, &s)| -t / s));
3085 select_wiggle_basis_from_seed(q_seed.view(), wiggle_cfg, wiggle_penalty_orders)
3086}
3087
3088pub(crate) fn fit_binomial_location_scale_terms_with_selected_wiggle(
3089 data: ndarray::ArrayView2<'_, f64>,
3090 spec: BinomialLocationScaleTermSpec,
3091 selected_wiggle_basis: SelectedWiggleBasis,
3092 options: &BlockwiseFitOptions,
3093 kappa_options: &SpatialLengthScaleOptimizationOptions,
3094) -> Result<BlockwiseTermWiggleFitResult, String> {
3095 let SelectedWiggleBasis {
3096 knots: wiggle_knots,
3097 degree: wiggle_degree,
3098 block: wiggle_block,
3099 ..
3100 } = selected_wiggle_basis;
3101 let solved = fit_binomial_location_scalewiggle_terms(
3102 data,
3103 BinomialLocationScaleWiggleTermSpec {
3104 y: spec.y,
3105 weights: spec.weights,
3106 link_kind: spec.link_kind,
3107 thresholdspec: spec.thresholdspec,
3108 log_sigmaspec: spec.log_sigmaspec,
3109 threshold_offset: spec.threshold_offset,
3110 log_sigma_offset: spec.log_sigma_offset,
3111 wiggle_knots: wiggle_knots.clone(),
3112 wiggle_degree,
3113 wiggle_block,
3114 },
3115 options,
3116 kappa_options,
3117 )?;
3118
3119 BlockwiseTermWiggleFitResult::try_from_parts(BlockwiseTermWiggleFitResultParts {
3120 fit: solved,
3121 wiggle_knots,
3122 wiggle_degree,
3123 })
3124}
3125
3126pub(crate) fn select_binomial_mean_link_wiggle_basis_from_pilot(
3127 pilot_design: &TermCollectionDesign,
3128 pilot_fit: &UnifiedFitResult,
3129 wiggle_cfg: &WiggleBlockConfig,
3130 wiggle_penalty_orders: &[usize],
3131) -> Result<SelectedWiggleBasis, String> {
3132 let q_seed = pilot_design
3133 .apply(pilot_fit.beta.view())
3134 .map_err(|error| error.to_string())?;
3135 select_wiggle_basis_from_seed(q_seed.view(), wiggle_cfg, wiggle_penalty_orders)
3136}
3137
3138pub(crate) fn fit_binomial_mean_wiggle_terms_with_selected_basis(
3139 data: ndarray::ArrayView2<'_, f64>,
3140 pilot_spec: &TermCollectionSpec,
3141 pilot_design: &TermCollectionDesign,
3142 pilot_fit: &UnifiedFitResult,
3143 y: &Array1<f64>,
3144 weights: &Array1<f64>,
3145 link_kind: InverseLink,
3146 selected_wiggle_basis: SelectedWiggleBasis,
3147 options: &BlockwiseFitOptions,
3148 kappa_options: &SpatialLengthScaleOptimizationOptions,
3149) -> Result<BinomialMeanWiggleTermFitResult, String> {
3150 const RHO_BOUND: f64 = 12.0;
3151
3152 validate_term_weights(
3153 data,
3154 y.len(),
3155 weights,
3156 "fit_binomial_mean_wiggle_terms_with_selected_basis",
3157 )?;
3158 validate_binomial_response(y, "fit_binomial_mean_wiggle_terms_with_selected_basis")?;
3159
3160 let SelectedWiggleBasis {
3166 knots: wiggle_knots,
3167 degree: wiggle_degree,
3168 block: wiggle_block,
3169 ..
3170 } = selected_wiggle_basis;
3171
3172 let spatial_terms = spatial_length_scale_term_indices(pilot_spec);
3173 if spatial_terms.is_empty() {
3174 let (fit, saved_warp_beta, saved_index_shift) = fit_binomial_mean_wiggle(
3175 BinomialMeanWiggleSpec {
3176 y: y.clone(),
3177 weights: weights.clone(),
3178 link_kind,
3179 wiggle_knots: wiggle_knots.clone(),
3180 wiggle_degree,
3181 eta_block: ParameterBlockInput {
3182 design: pilot_design.design.clone(),
3183 offset: pilot_design.affine_offset.clone(),
3184 penalties: pilot_design
3185 .penalties
3186 .iter()
3187 .map(crate::model_types::PenaltySpec::from_blockwise_ref)
3188 .collect(),
3189 nullspace_dims: vec![],
3190 initial_log_lambdas: Some(fitted_log_lambdas(
3191 &pilot_fit.lambdas,
3192 "binomial mean-wiggle pilot lambda",
3193 )?),
3194 initial_beta: Some(pilot_fit.beta.clone()),
3195 },
3196 wiggle_block,
3197 },
3198 options,
3199 )?;
3200 return Ok(BinomialMeanWiggleTermFitResult {
3201 fit,
3202 resolvedspec: pilot_spec.clone(),
3203 design: pilot_design.clone(),
3204 wiggle_knots,
3205 wiggle_degree,
3206 saved_warp_beta,
3207 saved_index_shift,
3208 });
3209 }
3210
3211 let dims_per_term = spatial_dims_per_term(pilot_spec, &spatial_terms);
3212 let log_kappa0 =
3213 SpatialLogKappaCoords::from_length_scales_aniso(pilot_spec, &spatial_terms, kappa_options)
3214 .reseed_from_data(data, pilot_spec, &spatial_terms, kappa_options)
3215 .map_err(|error| error.to_string())?;
3216 let log_kappa_lower = SpatialLogKappaCoords::lower_bounds_aniso_from_data(
3217 data,
3218 pilot_spec,
3219 &spatial_terms,
3220 &dims_per_term,
3221 kappa_options,
3222 )
3223 .map_err(|error| error.to_string())?;
3224 let log_kappa_upper = SpatialLogKappaCoords::upper_bounds_aniso_from_data(
3225 data,
3226 pilot_spec,
3227 &spatial_terms,
3228 &dims_per_term,
3229 kappa_options,
3230 )
3231 .map_err(|error| error.to_string())?;
3232 let log_kappa0 = log_kappa0.clamp_to_bounds(&log_kappa_lower, &log_kappa_upper);
3234
3235 let eta_penalty_count = pilot_design.penalties.len();
3236 let wiggle_penalty_count = initial_log_lambdas_orzeros(&wiggle_block)?.len();
3237 let rho_dim = eta_penalty_count + wiggle_penalty_count;
3238 let baseline_resolvedspec = log_kappa0
3239 .apply_tospec(pilot_spec, &spatial_terms)
3240 .map_err(|e| e.to_string())?;
3241 let baseline_design =
3242 build_term_collection_design(data, &baseline_resolvedspec).map_err(|e| e.to_string())?;
3243 let baseline_fit = fit_binomial_mean_wiggle(
3244 BinomialMeanWiggleSpec {
3245 y: y.clone(),
3246 weights: weights.clone(),
3247 link_kind: link_kind.clone(),
3248 wiggle_knots: wiggle_knots.clone(),
3249 wiggle_degree,
3250 eta_block: ParameterBlockInput {
3251 design: baseline_design.design.clone(),
3252 offset: baseline_design.affine_offset.clone(),
3253 penalties: baseline_design
3254 .penalties
3255 .iter()
3256 .map(crate::model_types::PenaltySpec::from_blockwise_ref)
3257 .collect(),
3258 nullspace_dims: vec![],
3259 initial_log_lambdas: Some(fitted_log_lambdas(
3260 &pilot_fit.lambdas,
3261 "binomial mean-wiggle pilot lambda",
3262 )?),
3263 initial_beta: Some(pilot_fit.beta.clone()),
3264 },
3265 wiggle_block: wiggle_block.clone(),
3266 },
3267 options,
3268 )?
3269 .0;
3270 let baseline_log_lambdas = fitted_log_lambdas(
3271 &baseline_fit.lambdas,
3272 "binomial mean-wiggle baseline lambda",
3273 )?;
3274 if baseline_log_lambdas.len() != rho_dim {
3275 return Err(GamlssError::DimensionMismatch {
3276 reason: format!(
3277 "baseline binomial mean-wiggle fit returned {} log-lambdas, expected {rho_dim}",
3278 baseline_log_lambdas.len()
3279 ),
3280 }
3281 .into());
3282 }
3283 let baseline_eta_beta = baseline_fit
3284 .block_states
3285 .get(BinomialMeanWiggleFamily::BLOCK_ETA)
3286 .ok_or_else(|| "baseline binomial mean-wiggle fit missing eta block".to_string())?
3287 .beta
3288 .clone();
3289 let baseline_wiggle_beta = Some(
3290 baseline_fit
3291 .block_states
3292 .get(BinomialMeanWiggleFamily::BLOCK_WIGGLE)
3293 .ok_or_else(|| "baseline binomial mean-wiggle fit missing wiggle block".to_string())?
3294 .beta
3295 .clone(),
3296 );
3297 let theta_dim = rho_dim + log_kappa0.len();
3298 let mut theta0 = Array1::<f64>::zeros(theta_dim);
3299 theta0
3300 .slice_mut(s![0..rho_dim])
3301 .assign(&baseline_log_lambdas);
3302 theta0
3303 .slice_mut(s![rho_dim..theta_dim])
3304 .assign(log_kappa0.as_array());
3305
3306 let mut lower = Array1::<f64>::from_elem(theta_dim, -RHO_BOUND);
3307 let mut upper = Array1::<f64>::from_elem(theta_dim, RHO_BOUND);
3308 lower
3309 .slice_mut(s![rho_dim..theta_dim])
3310 .assign(log_kappa_lower.as_array());
3311 upper
3312 .slice_mut(s![rho_dim..theta_dim])
3313 .assign(log_kappa_upper.as_array());
3314
3315 let pilot_spec_cloned = pilot_spec.clone();
3316 let pilot_beta = baseline_eta_beta;
3317 let wiggle_design = wiggle_block.design.clone();
3318 let wiggle_offset = wiggle_block.offset.clone();
3319 let wiggle_penalties = wiggle_block.penalties.clone();
3320 let wiggle_initial_beta = baseline_wiggle_beta;
3321 let wiggle_knots_cloned = wiggle_knots.clone();
3322 let y_cloned = y.clone();
3323 let weights_cloned = weights.clone();
3324 let link_kind_cloned = link_kind.clone();
3325 let outer_family = BinomialMeanWiggleFamily {
3326 y: y_cloned.clone(),
3327 weights: weights_cloned.clone(),
3328 link_kind: link_kind_cloned.clone(),
3329 wiggle_knots: wiggle_knots_cloned.clone(),
3330 wiggle_degree,
3331 policy: gam_runtime::resource::ResourcePolicy::default_library(),
3332 frozen_warp_design: None,
3335 };
3336 let screening_cap = Arc::new(AtomicUsize::new(0));
3337 let mut outer_options = options.clone();
3338 outer_options.screening_max_inner_iterations = Some(Arc::clone(&screening_cap));
3339 struct MeanWiggleOuterState {
3340 pub(crate) warm_cache: Option<crate::custom_family::CustomFamilyWarmStart>,
3341 pub(crate) last_eval: Option<(
3342 Array1<f64>,
3343 f64,
3344 Array1<f64>,
3345 gam_problem::HessianValue,
3346 crate::custom_family::CustomFamilyWarmStart,
3347 )>,
3348 }
3349
3350 let build_realized_blocks = |theta: &Array1<f64>| -> Result<
3351 (
3352 TermCollectionSpec,
3353 TermCollectionDesign,
3354 Vec<ParameterBlockSpec>,
3355 Vec<CustomFamilyBlockPsiDerivative>,
3356 ),
3357 String,
3358 > {
3359 let log_kappa =
3360 SpatialLogKappaCoords::from_theta_tail_with_dims(theta, rho_dim, dims_per_term.clone());
3361 let resolvedspec = log_kappa
3362 .apply_tospec(&pilot_spec_cloned, &spatial_terms)
3363 .map_err(|e| e.to_string())?;
3364 let design =
3365 build_term_collection_design(data, &resolvedspec).map_err(|e| e.to_string())?;
3366 let eta_derivs = build_block_spatial_psi_derivatives(data, &resolvedspec, &design)?
3367 .ok_or_else(|| {
3368 "missing eta spatial psi derivatives for binomial mean wiggle".to_string()
3369 })?;
3370 let blocks = vec![
3371 ParameterBlockSpec {
3372 name: "eta".to_string(),
3373 design: design.design.clone(),
3374 offset: design.affine_offset.clone(),
3375 penalties: design.penalties_as_penalty_matrix(),
3376 nullspace_dims: vec![],
3377 initial_log_lambdas: theta.slice(s![0..eta_penalty_count]).to_owned(),
3378 initial_beta: Some(pilot_beta.clone()),
3379 gauge_priority: LINK_WIGGLE_GAUGE_PRIORITY,
3383 jacobian_callback: None,
3384 stacked_design: None,
3385 stacked_offset: None,
3386 },
3387 ParameterBlockSpec {
3388 name: "wiggle".to_string(),
3389 design: wiggle_design.clone(),
3390 offset: wiggle_offset.clone(),
3391 penalties: {
3392 let p_wiggle = wiggle_design.ncols();
3393 wiggle_penalties
3394 .iter()
3395 .map(|spec| match spec {
3396 crate::model_types::PenaltySpec::Block {
3397 local, col_range, ..
3398 } => PenaltyMatrix::Blockwise {
3399 local: local.clone(),
3400 col_range: col_range.clone(),
3401 total_dim: p_wiggle,
3402 },
3403 crate::model_types::PenaltySpec::Dense(m)
3404 | crate::model_types::PenaltySpec::DenseWithMean {
3405 matrix: m, ..
3406 } => PenaltyMatrix::Dense(m.clone()),
3407 })
3408 .collect()
3409 },
3410 nullspace_dims: vec![],
3411 initial_log_lambdas: theta.slice(s![eta_penalty_count..rho_dim]).to_owned(),
3412 initial_beta: wiggle_initial_beta.clone(),
3413 gauge_priority: DEFAULT_GAUGE_PRIORITY,
3414 jacobian_callback: None,
3415 stacked_design: None,
3416 stacked_offset: None,
3417 },
3418 ];
3419 Ok((resolvedspec, design, blocks, eta_derivs))
3420 };
3421
3422 let build_eval = |theta: &Array1<f64>,
3423 warm_cache: Option<&crate::custom_family::CustomFamilyWarmStart>,
3424 need_hessian: bool|
3425 -> Result<
3426 (
3427 crate::custom_family::CustomFamilyJointHyperResult,
3428 TermCollectionSpec,
3429 TermCollectionDesign,
3430 ),
3431 String,
3432 > {
3433 let (resolvedspec, design, blocks, eta_derivs) = build_realized_blocks(theta)?;
3434 let hyper_layout = crate::custom_family::CustomFamilyHyperLayout::new(
3435 vec![eta_derivs, Vec::new()],
3436 Vec::new(),
3437 theta.slice(s![rho_dim..]).to_owned(),
3438 )?;
3439 let eval = evaluate_custom_family_joint_hyper(
3440 &outer_family,
3441 &blocks,
3442 &outer_options,
3443 &theta.slice(s![0..rho_dim]).to_owned(),
3444 &hyper_layout,
3445 warm_cache,
3446 if need_hessian {
3447 gam_problem::EvalMode::ValueGradientHessian
3448 } else {
3449 gam_problem::EvalMode::ValueAndGradient
3450 },
3451 )?;
3452 Ok((eval, resolvedspec, design))
3453 };
3454
3455 let build_efs = |theta: &Array1<f64>,
3456 warm_cache: Option<&crate::custom_family::CustomFamilyWarmStart>|
3457 -> Result<crate::custom_family::CustomFamilyJointHyperEfsResult, String> {
3458 let (_, _, blocks, eta_derivs) = build_realized_blocks(theta)?;
3459 let hyper_layout = crate::custom_family::CustomFamilyHyperLayout::new(
3460 vec![eta_derivs, Vec::new()],
3461 Vec::new(),
3462 theta.slice(s![rho_dim..]).to_owned(),
3463 )?;
3464 evaluate_custom_family_joint_hyper_efs(
3465 &outer_family,
3466 &blocks,
3467 &outer_options,
3468 &theta.slice(s![0..rho_dim]).to_owned(),
3469 &hyper_layout,
3470 warm_cache,
3471 )
3472 .map_err(|e| e.to_string())
3473 };
3474
3475 use crate::model_types::EstimationError;
3476 use gam_problem::{DeclaredHessianForm, Derivative, OuterEval};
3477 use gam_solve::rho_optimizer::OuterEvalOrder;
3478
3479 let analytic_outer_hessian_available = true;
3489 let mut seed_heuristic = theta0.to_vec();
3490 for value in &mut seed_heuristic[..rho_dim] {
3491 *value = value.exp();
3492 }
3493 let problem = gam_solve::rho_optimizer::OuterProblem::new(theta_dim)
3494 .with_gradient(Derivative::Analytic)
3495 .with_hessian(if analytic_outer_hessian_available {
3496 DeclaredHessianForm::Either
3497 } else {
3498 DeclaredHessianForm::Unavailable
3499 })
3500 .with_prefer_gradient_only(true)
3503 .with_psi_dim(theta_dim - rho_dim)
3504 .with_tolerance(options.outer_tol)
3505 .with_max_iter(options.outer_max_iter)
3506 .with_bounds(lower.clone(), upper.clone())
3507 .with_initial_rho(theta0.clone())
3508 .with_seed_config(crate::seeding::SeedConfig {
3509 max_seeds: 4,
3510 seed_budget: 2,
3511 risk_profile: crate::seeding::SeedRiskProfile::GeneralizedLinear,
3512 num_auxiliary_trailing: theta_dim - rho_dim,
3513 ..Default::default()
3514 })
3515 .with_screening_cap(Arc::clone(&screening_cap))
3516 .with_rho_bound(12.0)
3517 .with_heuristic_lambdas(seed_heuristic);
3518
3519 let eval_outer = |state: &mut MeanWiggleOuterState,
3520 theta: &Array1<f64>,
3521 order: OuterEvalOrder|
3522 -> Result<OuterEval, EstimationError> {
3523 if let Some((cached_theta, cached_cost, cached_grad, cached_hess, cached_warm)) =
3524 &state.last_eval
3525 && cached_theta == theta
3526 && (!matches!(order, OuterEvalOrder::ValueGradientHessian)
3527 || matches!(
3528 cached_hess,
3529 gam_problem::HessianValue::Dense(_) | gam_problem::HessianValue::Operator(_)
3530 ))
3531 {
3532 state.warm_cache = Some(cached_warm.clone());
3533 return Ok(OuterEval {
3534 cost: *cached_cost,
3535 gradient: cached_grad.clone(),
3536 hessian: cached_hess.clone(),
3537 inner_beta_hint: None,
3538 });
3539 }
3540 let need_hessian = matches!(order, OuterEvalOrder::ValueGradientHessian)
3541 && analytic_outer_hessian_available;
3542 let (eval, _, _) = build_eval(theta, state.warm_cache.as_ref(), need_hessian)
3543 .map_err(EstimationError::InvalidInput)?;
3544 if !eval.inner_converged {
3545 state.warm_cache = Some(eval.warm_start);
3546 crate::bail_invalid_estim!(
3547 "binomial mean-wiggle exact spatial inner solve did not converge"
3548 );
3549 }
3550 let hessian_result = eval.outer_hessian.clone();
3551 state.last_eval = Some((
3552 theta.clone(),
3553 eval.objective,
3554 eval.gradient.clone(),
3555 eval.outer_hessian.clone(),
3556 eval.warm_start.clone(),
3557 ));
3558 state.warm_cache = Some(eval.warm_start);
3559 Ok(OuterEval {
3560 cost: eval.objective,
3561 gradient: eval.gradient,
3562 hessian: hessian_result,
3563 inner_beta_hint: None,
3564 })
3565 };
3566
3567 let mut obj = problem.build_objective_with_screening_proxy(
3568 MeanWiggleOuterState {
3569 warm_cache: None,
3570 last_eval: None,
3571 },
3572 |state: &mut MeanWiggleOuterState, theta: &Array1<f64>| {
3573 if let Some((cached_theta, cached_cost, _, _, cached_warm)) = &state.last_eval
3574 && cached_theta == theta
3575 {
3576 state.warm_cache = Some(cached_warm.clone());
3577 return Ok(*cached_cost);
3578 }
3579 let (eval, _, _) = build_eval(theta, state.warm_cache.as_ref(), false)
3580 .map_err(EstimationError::InvalidInput)?;
3581 if !eval.inner_converged {
3582 state.warm_cache = Some(eval.warm_start);
3583 crate::bail_invalid_estim!(
3584 "binomial mean-wiggle exact spatial cost inner solve did not converge"
3585 .to_string(),
3586 );
3587 }
3588 state.warm_cache = Some(eval.warm_start);
3589 Ok(eval.objective)
3590 },
3591 |state: &mut MeanWiggleOuterState, theta: &Array1<f64>| {
3592 eval_outer(
3593 state,
3594 theta,
3595 if analytic_outer_hessian_available {
3596 OuterEvalOrder::ValueGradientHessian
3597 } else {
3598 OuterEvalOrder::ValueAndGradient
3599 },
3600 )
3601 },
3602 |state: &mut MeanWiggleOuterState, theta: &Array1<f64>, order: OuterEvalOrder| {
3603 eval_outer(state, theta, order)
3604 },
3605 Some(|state: &mut MeanWiggleOuterState| {
3606 state.warm_cache = None;
3607 state.last_eval = None;
3608 }),
3609 Some(|state: &mut MeanWiggleOuterState, theta: &Array1<f64>| {
3610 let eval = build_efs(theta, state.warm_cache.as_ref())
3611 .map_err(EstimationError::InvalidInput)?;
3612 if !eval.inner_converged {
3613 state.warm_cache = Some(eval.warm_start);
3614 crate::bail_invalid_estim!(
3615 "binomial mean-wiggle exact spatial EFS inner solve did not converge"
3616 .to_string(),
3617 );
3618 }
3619 state.warm_cache = Some(eval.warm_start);
3620 Ok(eval.efs_eval)
3621 }),
3622 |state: &mut MeanWiggleOuterState, theta: &Array1<f64>| {
3632 if let Some((cached_theta, cached_cost, _, _, cached_warm)) = &state.last_eval
3633 && cached_theta == theta
3634 {
3635 state.warm_cache = Some(cached_warm.clone());
3636 return Ok(*cached_cost);
3637 }
3638 let (eval, _, _) = build_eval(theta, state.warm_cache.as_ref(), false)
3639 .map_err(EstimationError::InvalidInput)?;
3640 state.warm_cache = Some(eval.warm_start);
3641 Ok(eval.objective)
3642 },
3643 );
3644
3645 let outer = problem
3646 .run(&mut obj, "binomial mean wiggle exact spatial hyper")
3647 .map_err(|e| e.to_string())?;
3648 if !outer.converged {
3649 return Err(GamlssError::NumericalFailure { reason: format!(
3650 "binomial mean wiggle exact spatial hyper did not converge after {} iterations (final_objective={:.6e}, final_grad_norm={})",
3651 outer.iterations,
3652 outer.final_value,
3653 outer.final_grad_norm_report(),
3654 ) }.into());
3655 }
3656 let theta_star = outer.rho;
3657
3658 let log_kappa =
3659 SpatialLogKappaCoords::from_theta_tail_with_dims(&theta_star, rho_dim, dims_per_term);
3660 let resolvedspec = log_kappa
3661 .apply_tospec(&pilot_spec_cloned, &spatial_terms)
3662 .map_err(|e| e.to_string())?;
3663 let design = build_term_collection_design(data, &resolvedspec).map_err(|e| e.to_string())?;
3664 let resolvedspec =
3665 freeze_term_collection_from_design(&resolvedspec, &design).map_err(|e| e.to_string())?;
3666 let fit = fit_binomial_mean_wiggle(
3667 BinomialMeanWiggleSpec {
3668 y: y_cloned,
3669 weights: weights_cloned,
3670 link_kind: link_kind_cloned,
3671 wiggle_knots: wiggle_knots.clone(),
3672 wiggle_degree,
3673 eta_block: ParameterBlockInput {
3674 design: design.design.clone(),
3675 offset: design.affine_offset.clone(),
3676 penalties: design
3677 .penalties
3678 .iter()
3679 .map(crate::model_types::PenaltySpec::from_blockwise_ref)
3680 .collect(),
3681 nullspace_dims: vec![],
3682 initial_log_lambdas: Some(theta_star.slice(s![0..eta_penalty_count]).to_owned()),
3683 initial_beta: Some(pilot_beta),
3684 },
3685 wiggle_block: ParameterBlockInput {
3686 design: wiggle_design,
3687 offset: wiggle_offset,
3688 penalties: wiggle_penalties,
3689 nullspace_dims: vec![],
3690 initial_log_lambdas: Some(
3691 theta_star.slice(s![eta_penalty_count..rho_dim]).to_owned(),
3692 ),
3693 initial_beta: wiggle_initial_beta,
3694 },
3695 },
3696 options,
3697 )?;
3698 let (fit, saved_warp_beta, saved_index_shift) = fit;
3699
3700 Ok(BinomialMeanWiggleTermFitResult {
3701 fit,
3702 resolvedspec,
3703 design,
3704 wiggle_knots,
3705 wiggle_degree,
3706 saved_warp_beta,
3707 saved_index_shift,
3708 })
3709}