1use coefficient_transforms::{
2 convex_derivative_control_transform_matrix, cumulative_exp, cumulative_sum_transform_matrix,
3 second_cumulative_exp,
4};
5
6pub use error::SmoothError;
7
8use input_standardization::estimate_isotropic_scale;
9
10use shape_constraints::{
11 bspline_first_derivative_control_spans, shape_lower_bounds_local, shape_order_and_sign,
12 shape_supports_basis, shape_uses_box_reparameterization,
13};
14
15pub fn describe_thin_plate_center_request(strategy: &CenterStrategy) -> String {
16 match strategy {
17 CenterStrategy::Auto(inner) => describe_thin_plate_center_request(inner),
18 CenterStrategy::UserProvided(centers) => format!("{} centers", centers.nrows()),
19 CenterStrategy::EqualMass { num_centers }
20 | CenterStrategy::EqualMassCovarRepresentative { num_centers }
21 | CenterStrategy::FarthestPoint { num_centers }
22 | CenterStrategy::KMeans { num_centers, .. } => format!("{num_centers} centers"),
23 CenterStrategy::UniformGrid { points_per_dim } => {
24 format!("uniform grid with {points_per_dim} points per dimension")
25 }
26 }
27}
28
29pub fn rewrite_thin_plate_knots_error(
30 err: BasisError,
31 termname: &str,
32 feature_count: usize,
33 spec: &ThinPlateBasisSpec,
34) -> BasisError {
35 match err {
36 BasisError::InvalidInput(msg)
39 if msg.contains("thin-plate spline requires at least")
40 && (msg.contains("centers to span") || msg.contains("knots to span")) =>
41 {
42 let min_centers = crate::basis::thin_plate_polynomial_basis_dimension(feature_count);
43 let requested = describe_thin_plate_center_request(&spec.center_strategy);
44 BasisError::InvalidInput(format!(
45 "joint TPS term '{termname}' over {feature_count} covariates with {requested} is invalid; minimum centers is {min_centers}"
46 ))
47 }
48 BasisError::InvalidInput(msg)
53 if msg.starts_with("requested ") && msg.contains(" knots but only ") =>
54 {
55 let min_centers = crate::basis::thin_plate_polynomial_basis_dimension(feature_count);
56 let requested = describe_thin_plate_center_request(&spec.center_strategy);
57 BasisError::InvalidInput(format!(
58 "joint TPS term '{termname}' over {feature_count} covariates with {requested} is invalid; minimum centers is {min_centers}"
59 ))
60 }
61 other => other,
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub enum ShapeConstraint {
67 None,
68 MonotoneIncreasing,
69 MonotoneDecreasing,
70 Convex,
71 Concave,
72}
73
74pub fn parse_shape_constraint(raw: &str) -> Result<ShapeConstraint, String> {
85 let normalized = raw.trim().to_ascii_lowercase().replace('-', "_");
86 match normalized.as_str() {
87 "" | "none" => Ok(ShapeConstraint::None),
88 "monotone_increasing" | "monotonic_increasing" | "increasing" | "mono_inc" | "mpi" => {
89 Ok(ShapeConstraint::MonotoneIncreasing)
90 }
91 "monotone_decreasing" | "monotonic_decreasing" | "decreasing" | "mono_dec" | "mpd" => {
92 Ok(ShapeConstraint::MonotoneDecreasing)
93 }
94 "convex" | "cvx" => Ok(ShapeConstraint::Convex),
95 "concave" | "ccv" => Ok(ShapeConstraint::Concave),
96 other => Err(format!(
97 "unknown shape constraint {other:?}; expected one of \
98 \"none\", \"monotone_increasing\", \"monotone_decreasing\", \
99 \"convex\", \"concave\""
100 )),
101 }
102}
103
104impl ShapeConstraint {
105 pub fn dsl_str(&self) -> &'static str {
108 match self {
109 ShapeConstraint::None => "none",
110 ShapeConstraint::MonotoneIncreasing => "monotone_increasing",
111 ShapeConstraint::MonotoneDecreasing => "monotone_decreasing",
112 ShapeConstraint::Convex => "convex",
113 ShapeConstraint::Concave => "concave",
114 }
115 }
116}
117
118pub const SMOOTH_HEAD_KEYWORDS: [&str; 11] = [
121 "s",
122 "smooth",
123 "te",
124 "tensor",
125 "thinplate",
126 "tps",
127 "duchon",
128 "matern",
129 "sphere",
130 "bs",
131 "bspline",
132];
133
134pub fn apply_shape_constraints_to_formula(
147 formula: &str,
148 constraints: &[(String, String)],
149) -> Result<String, String> {
150 use std::collections::{BTreeMap, BTreeSet};
151
152 if constraints.is_empty() {
153 return Ok(formula.to_string());
154 }
155 let strip_ws = |s: &str| -> String { s.chars().filter(|c| !c.is_whitespace()).collect() };
156
157 let mut wanted: BTreeMap<String, &'static str> = BTreeMap::new();
159 let mut originals: BTreeMap<String, String> = BTreeMap::new();
161 for (key, kind_raw) in constraints {
162 let kind = parse_shape_constraint(kind_raw)?;
163 let nk = strip_ws(key);
164 originals.entry(nk.clone()).or_insert_with(|| key.clone());
165 if kind != ShapeConstraint::None {
166 wanted.insert(nk, kind.dsl_str());
167 }
168 }
169 if wanted.is_empty() {
170 return Ok(formula.to_string());
171 }
172
173 let chars: Vec<char> = formula.chars().collect();
174 let n = chars.len();
175 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
176
177 let mut out = String::with_capacity(formula.len() + 32);
178 let mut matched: BTreeSet<String> = BTreeSet::new();
179 let mut i = 0usize;
180 while i < n {
181 let mut head: Option<(usize, usize)> = None; let mut p = i;
185 while p < n {
186 let boundary = p == 0 || !is_ident(chars[p - 1]);
187 if boundary {
188 for kw in SMOOTH_HEAD_KEYWORDS.iter() {
189 let klen = kw.chars().count();
190 if p + klen > n || chars[p..p + klen].iter().collect::<String>() != **kw {
191 continue;
192 }
193 let mut q = p + klen;
194 while q < n && chars[q].is_whitespace() {
195 q += 1;
196 }
197 if q < n && chars[q] == '(' {
198 head = Some((p, q));
199 break;
200 }
201 }
202 }
203 if head.is_some() {
204 break;
205 }
206 p += 1;
207 }
208 let (head_start, paren_open) = match head {
209 Some(h) => h,
210 None => {
211 out.extend(chars[i..].iter());
212 break;
213 }
214 };
215 out.extend(chars[i..head_start].iter());
216
217 let body_start = paren_open + 1;
219 let mut depth = 1i32;
220 let mut j = body_start;
221 let mut in_str: Option<char> = None;
222 let mut closed = false;
223 while j < n {
224 let ch = chars[j];
225 if let Some(quote) = in_str {
226 if ch == quote {
227 in_str = None;
228 }
229 } else if ch == '\'' || ch == '"' {
230 in_str = Some(ch);
231 } else if ch == '(' {
232 depth += 1;
233 } else if ch == ')' {
234 depth -= 1;
235 if depth == 0 {
236 closed = true;
237 break;
238 }
239 }
240 j += 1;
241 }
242
243 if !closed {
244 out.extend(chars[head_start..].iter());
247 break;
248 }
249
250 let term_text: String = chars[head_start..=j].iter().collect();
251
252 let key_norm = strip_ws(&term_text);
253
254 match wanted.get(&key_norm) {
255 None => out.extend(chars[head_start..=j].iter()),
256 Some(kind) => {
257 let head_paren: String = chars[head_start..body_start].iter().collect();
258 let inside: String = chars[body_start..j].iter().collect();
259 let inside = inside.trim();
260 if inside.is_empty() {
261 out.push_str(&format!("{head_paren}shape={kind})"));
262 } else {
263 out.push_str(&format!("{head_paren}{inside}, shape={kind})"));
264 }
265 matched.insert(key_norm);
266 }
267 }
268
269 i = j + 1;
270 }
271
272 let mut missing: Vec<String> = wanted
273 .keys()
274 .filter(|k| !matched.contains(*k))
275 .map(|k| originals.get(k).cloned().unwrap_or_else(|| k.clone()))
276 .collect();
277
278 if !missing.is_empty() {
279 missing.sort();
280 return Err(format!(
281 "shape constraints referenced smooth term(s) not found in formula: {}",
282 missing.join(", ")
283 ));
284 }
285
286 Ok(out)
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub enum BySmoothKind {
291 Numeric,
292 Level { level_bits: u64 },
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub enum SmoothBasisSpec {
298 ByVariable {
308 inner: Box<SmoothBasisSpec>,
309 by_col: usize,
310 kind: BySmoothKind,
311 by: ByVariableSpec,
312 },
313 FactorSumToZero {
317 inner: Box<SmoothBasisSpec>,
318 by_col: usize,
319 levels: Vec<u64>,
320 #[serde(default)]
331 frozen_global_orthogonality: Option<Array2<f64>>,
332 },
333 BSpline1D {
334 feature_col: usize,
335 spec: BSplineBasisSpec,
336 },
337 BySmooth {
340 smooth: Box<SmoothBasisSpec>,
341 by_kind: ByVarKind,
342 },
343 FactorSmooth { spec: FactorSmoothSpec },
346 ThinPlate {
347 feature_cols: Vec<usize>,
348 spec: ThinPlateBasisSpec,
349 input_scale: Option<crate::IsotropicScale>,
352 },
353 Sphere {
354 feature_cols: Vec<usize>,
355 spec: SphericalSplineBasisSpec,
356 },
357 ConstantCurvature {
363 feature_cols: Vec<usize>,
364 spec: ConstantCurvatureBasisSpec,
365 },
366 Matern {
367 feature_cols: Vec<usize>,
368 spec: MaternBasisSpec,
369 input_scale: Option<crate::IsotropicScale>,
370 },
371 MeasureJet {
377 feature_cols: Vec<usize>,
378 spec: MeasureJetBasisSpec,
379 input_scale: Option<crate::IsotropicScale>,
380 },
381 Duchon {
382 feature_cols: Vec<usize>,
383 spec: DuchonBasisSpec,
384 input_scale: Option<crate::IsotropicScale>,
385 },
386 Pca {
387 feature_cols: Vec<usize>,
388 basis_matrix: Array2<f64>,
389 centered: bool,
390 #[serde(default = "default_pca_smooth_penalty")]
391 smooth_penalty: f64,
392 #[serde(default)]
393 center_mean: Option<Array1<f64>>,
394 #[serde(default)]
395 pca_basis_path: Option<PathBuf>,
396 #[serde(default = "default_pca_chunk_size")]
397 chunk_size: usize,
398 },
399 TensorBSpline {
404 feature_cols: Vec<usize>,
405 spec: TensorBSplineSpec,
406 },
407}
408
409impl SmoothBasisSpec {
410 pub fn min_sample_rows(&self) -> usize {
427 const RADIAL_FLOOR: usize = 5;
432
433 match self {
434 Self::ByVariable { inner, .. } => inner.min_sample_rows(),
435 Self::FactorSumToZero { inner, levels, .. } => {
436 let inner_min = inner.min_sample_rows();
440 let lvls = levels.len().saturating_sub(1).max(1);
441 inner_min.saturating_mul(lvls)
442 }
443 Self::BSpline1D { spec, .. } => bspline_basis_min_rows(spec),
444 Self::BySmooth { smooth, .. } => smooth.min_sample_rows(),
445 Self::FactorSmooth { spec } => {
446 bspline_basis_min_rows(&spec.marginal)
450 }
451 Self::ThinPlate { .. }
452 | Self::Sphere { .. }
453 | Self::ConstantCurvature { .. }
454 | Self::Matern { .. }
455 | Self::MeasureJet { .. }
456 | Self::Duchon { .. } => RADIAL_FLOOR,
457 Self::Pca { basis_matrix, .. } => basis_matrix.ncols().max(1),
458 Self::TensorBSpline { spec, .. } => {
459 let mut total: usize = 0;
505 for marginal in &spec.marginalspecs {
506 let m = bspline_basis_min_rows(marginal);
507 total = total.saturating_add(m.max(1));
508 }
509 total.max(RADIAL_FLOOR)
510 }
511 }
512 }
513
514 pub fn structural_kind(&self) -> &'static str {
525 match self {
526 Self::ByVariable { .. } => "by_variable",
527 Self::FactorSumToZero { .. } => "factor_sum_to_zero",
528 Self::BSpline1D { .. } => "bspline_1d",
529 Self::BySmooth { .. } => "by_smooth",
530 Self::FactorSmooth { .. } => "factor_smooth",
531 Self::ThinPlate { .. } => "thin_plate",
532 Self::Sphere { .. } => "sphere",
533 Self::ConstantCurvature { .. } => "constant_curvature",
534 Self::Matern { .. } => "matern",
535 Self::MeasureJet { .. } => "measurejet",
536 Self::Duchon { .. } => "duchon",
537 Self::Pca { .. } => "pca",
538 Self::TensorBSpline { .. } => "tensor_bspline",
539 }
540 }
541
542 pub fn is_marginally_centered_tensor(&self) -> bool {
551 matches!(
552 self,
553 Self::TensorBSpline { spec, .. }
554 if matches!(spec.identifiability, TensorBSplineIdentifiability::MarginalSumToZero)
555 )
556 }
557
558 pub fn is_sum_to_zero_factor_smooth(&self) -> bool {
575 matches!(
576 self,
577 Self::FactorSumToZero { .. }
578 | Self::FactorSmooth {
579 spec: FactorSmoothSpec {
580 flavour: FactorSmoothFlavour::Sz,
581 ..
582 }
583 }
584 )
585 }
586
587 pub fn structural_feature_cols(&self) -> Vec<usize> {
591 match self {
592 Self::ByVariable { inner, .. } | Self::FactorSumToZero { inner, .. } => {
593 inner.structural_feature_cols()
594 }
595 Self::BySmooth { smooth, .. } => smooth.structural_feature_cols(),
596 Self::FactorSmooth { .. } => Vec::new(),
597 Self::BSpline1D { feature_col, .. } => vec![*feature_col],
598 Self::ThinPlate { feature_cols, .. }
599 | Self::Sphere { feature_cols, .. }
600 | Self::ConstantCurvature { feature_cols, .. }
601 | Self::Matern { feature_cols, .. }
602 | Self::MeasureJet { feature_cols, .. }
603 | Self::Duchon { feature_cols, .. }
604 | Self::Pca { feature_cols, .. }
605 | Self::TensorBSpline { feature_cols, .. } => feature_cols.clone(),
606 }
607 }
608}
609
610pub fn bspline_basis_min_rows(spec: &crate::basis::BSplineBasisSpec) -> usize {
635 use crate::basis::BSplineKnotSpec;
636 let columns = match &spec.knotspec {
637 BSplineKnotSpec::Generate {
638 num_internal_knots, ..
639 } => *num_internal_knots + spec.degree + 1,
640 BSplineKnotSpec::Automatic {
641 num_internal_knots: Some(k),
642 ..
643 } => *k + spec.degree + 1,
644 BSplineKnotSpec::Automatic {
645 num_internal_knots: None,
646 ..
647 } => {
648 spec.degree + 2
652 }
653 BSplineKnotSpec::Provided(knots) => knots.len().saturating_sub(spec.degree + 1).max(1),
654 BSplineKnotSpec::NaturalCubicRegression { knots } => knots.len(),
656 BSplineKnotSpec::PeriodicUniform { num_basis, .. } => *num_basis,
657 };
658 let columns = columns.max(spec.degree + 2);
659
660 if spec.double_penalty {
661 const DOUBLE_PENALTY_FLOOR: usize = 2;
664 DOUBLE_PENALTY_FLOOR.min(columns).max(1)
665 } else {
666 columns
667 }
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize)]
671pub enum ByVariableSpec {
672 Numeric,
673 Level { value_bits: u64, label: String },
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize)]
677pub enum ByVarKind {
678 Numeric {
679 feature_col: usize,
680 },
681 Factor {
682 feature_col: usize,
683 ordered: bool,
684 frozen_levels: Option<Vec<u64>>,
685 },
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct FactorSmoothSpec {
690 pub continuous_cols: Vec<usize>,
691 pub group_col: usize,
692 pub marginal: BSplineBasisSpec,
693 pub flavour: FactorSmoothFlavour,
694 pub group_frozen_levels: Option<Vec<u64>>,
695 #[serde(default)]
701 pub frozen_global_orthogonality: Option<Array2<f64>>,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize)]
705pub enum FactorSmoothFlavour {
706 Fs { m_null_penalty_orders: Vec<usize> },
707 Sz,
708 Re,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct TensorBSplineSpec {
713 pub marginalspecs: Vec<BSplineBasisSpec>,
714 #[serde(default)]
715 pub periods: Vec<Option<f64>>,
716 #[serde(default = "default_tensor_double_penalty")]
717 pub double_penalty: bool,
718 #[serde(default)]
719 pub identifiability: TensorBSplineIdentifiability,
720 #[serde(default)]
721 pub penalty_decomposition: TensorBSplinePenaltyDecomposition,
722}
723
724pub const fn default_tensor_double_penalty() -> bool {
725 true
726}
727
728impl Default for TensorBSplineSpec {
729 fn default() -> Self {
730 Self {
731 marginalspecs: Vec::new(),
732 periods: Vec::new(),
733 double_penalty: default_tensor_double_penalty(),
734 identifiability: TensorBSplineIdentifiability::default(),
735 penalty_decomposition: TensorBSplinePenaltyDecomposition::default(),
736 }
737 }
738}
739
740#[derive(Debug, Default, Clone, Serialize, Deserialize)]
741pub enum TensorBSplineIdentifiability {
742 None,
743 #[default]
744 SumToZero,
745 MarginalSumToZero,
755 FrozenTransform {
756 transform: Array2<f64>,
757 },
758}
759
760#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
761pub enum TensorBSplinePenaltyDecomposition {
762 #[default]
765 MarginalKroneckerSum,
766 Separable,
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize)]
773pub struct SmoothTermSpec {
774 pub name: String,
775 pub basis: SmoothBasisSpec,
776 pub shape: ShapeConstraint,
777 #[serde(default)]
786 pub joint_null_rotation: Option<crate::basis::JointNullRotation>,
787}
788
789#[derive(Debug, Clone)]
790pub struct SmoothTerm {
791 pub name: String,
792 pub coeff_range: Range<usize>,
793 pub shape: ShapeConstraint,
794 pub active_penalties: Vec<ActivePenalty>,
797 pub dropped_penalties: Vec<DroppedPenaltyInfo>,
798 pub metadata: BasisMetadata,
799 pub lower_bounds_local: Option<Array1<f64>>,
802 pub linear_constraints_local: Option<LinearInequalityConstraints>,
805 pub kronecker_factored: Option<KroneckerFactoredBasis>,
808 pub joint_null_rotation: Option<crate::basis::JointNullRotation>,
831 pub unabsorbed_global_orthogonality: Option<Array2<f64>>,
841}
842
843impl SmoothTerm {
844 pub fn apply_rotation_to_predict(
860 &self,
861 x_new_raw: Array2<f64>,
862 ) -> Result<Array2<f64>, BasisError> {
863 let Some(rot) = self.joint_null_rotation.as_ref() else {
864 return Ok(x_new_raw);
865 };
866 let p_local = rot.rotation.nrows();
867 if x_new_raw.ncols() != p_local {
868 crate::bail_dim_basis!(
869 "joint-null rotation replay for term '{}': raw design has {} columns, \
870 rotation expects {} (the raw basis builder must emit the same column \
871 count as at fit time)",
872 self.name,
873 x_new_raw.ncols(),
874 p_local,
875 );
876 }
877 Ok(gam_linalg::faer_ndarray::fast_ab(&x_new_raw, &rot.rotation))
878 }
879
880 pub fn wald_unpenalized_dim(&self) -> usize {
903 joint_unpenalized_dim(self.coeff_range.len(), &self.active_penalties)
904 }
905}
906
907pub fn joint_unpenalized_dim(p_local: usize, active_penalties: &[ActivePenalty]) -> usize {
912 use gam_linalg::faer_ndarray::FaerEigh;
913 if p_local == 0 {
914 return 0;
915 }
916 if active_penalties.is_empty() {
917 return p_local;
919 }
920 let mut s_total = Array2::<f64>::zeros((p_local, p_local));
925 let mut materialized = 0usize;
926 for penalty in active_penalties {
927 let s = &penalty.matrix;
928 if s.nrows() == p_local && s.ncols() == p_local {
929 s_total += s;
930 materialized += 1;
931 }
932 }
933 if materialized == active_penalties.len() {
934 let symmetric = {
935 let transpose = s_total.t().to_owned();
936 (&s_total + &transpose) * 0.5
937 };
938 if let Ok((evals, _)) = symmetric.eigh(faer::Side::Lower) {
939 let max_abs = evals.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
940 if max_abs == 0.0 {
941 return p_local;
943 }
944 let tol = max_abs * (p_local as f64) * 1e-12;
945 let rank = evals.iter().filter(|&&v| v > tol).count();
946 return p_local.saturating_sub(rank);
947 }
948 }
949 if active_penalties.len() >= 2 {
954 0
955 } else {
956 active_penalties
957 .iter()
958 .map(|penalty| penalty.nullity)
959 .min()
960 .unwrap_or(0)
961 .min(p_local)
962 }
963}
964
965#[derive(Debug, Clone, Serialize, Deserialize)]
966pub struct PenaltyBlockInfo {
967 pub global_index: usize,
968 pub termname: Option<String>,
969 pub penalty: ActivePenaltyInfo,
970}
971
972#[derive(Debug, Clone, Serialize, Deserialize)]
973pub struct DroppedPenaltyBlockInfo {
974 pub termname: Option<String>,
975 pub penalty: DroppedPenaltyInfo,
976}
977
978#[derive(Debug, Clone)]
979pub struct SmoothDesign {
980 pub term_designs: Vec<DesignMatrix>,
981 pub penalties: Vec<BlockwisePenalty>,
984 pub nullspace_dims: Vec<usize>,
985 pub penaltyinfo: Vec<PenaltyBlockInfo>,
986 pub dropped_penaltyinfo: Vec<DroppedPenaltyBlockInfo>,
987 pub terms: Vec<SmoothTerm>,
988 pub coefficient_lower_bounds: Option<Array1<f64>>,
991 pub linear_constraints: Option<LinearInequalityConstraints>,
994}
995
996impl SmoothDesign {
997 pub fn total_smooth_cols(&self) -> usize {
998 self.term_designs.iter().map(DesignMatrix::ncols).sum()
999 }
1000 pub fn nrows(&self) -> usize {
1001 self.term_designs.first().map_or(0, DesignMatrix::nrows)
1002 }
1003}
1004
1005#[derive(Debug, Clone)]
1006pub struct RawSmoothDesign {
1007 pub term_designs: Vec<DesignMatrix>,
1008 pub affine_offset: Array1<f64>,
1010 pub penalties: Vec<BlockwisePenalty>,
1013 pub nullspace_dims: Vec<usize>,
1014 pub penaltyinfo: Vec<PenaltyBlockInfo>,
1015 pub dropped_penaltyinfo: Vec<DroppedPenaltyBlockInfo>,
1016 pub terms: Vec<SmoothTerm>,
1017 pub coefficient_lower_bounds: Option<Array1<f64>>,
1018 pub linear_constraints: Option<LinearInequalityConstraints>,
1019}
1020
1021impl RawSmoothDesign {
1022 pub fn total_smooth_cols(&self) -> usize {
1023 self.term_designs.iter().map(DesignMatrix::ncols).sum()
1024 }
1025 pub fn nrows(&self) -> usize {
1026 self.term_designs.first().map_or(0, DesignMatrix::nrows)
1027 }
1028}
1029
1030#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1031pub enum BoundedCoefficientPriorSpec {
1032 #[default]
1033 None,
1034 Uniform,
1035 Beta {
1036 a: f64,
1037 b: f64,
1038 },
1039}
1040
1041#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1042pub enum LinearCoefficientGeometry {
1043 #[default]
1044 Unconstrained,
1045 Bounded {
1046 min: f64,
1047 max: f64,
1048 #[serde(default)]
1049 prior: BoundedCoefficientPriorSpec,
1050 },
1051}
1052
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1054pub struct LinearTermSpec {
1055 pub name: String,
1056 pub feature_col: usize,
1062 #[serde(default)]
1065 pub feature_cols: Vec<usize>,
1066 #[serde(default)]
1080 pub categorical_levels: Vec<(usize, u64)>,
1081 #[serde(default = "default_linear_term_double_penalty")]
1085 pub double_penalty: bool,
1086 #[serde(default)]
1087 pub coefficient_geometry: LinearCoefficientGeometry,
1088 #[serde(default)]
1089 pub coefficient_min: Option<f64>,
1090 #[serde(default)]
1091 pub coefficient_max: Option<f64>,
1092 #[serde(default)]
1107 pub frozen_function_mass: Option<f64>,
1108}
1109
1110impl LinearTermSpec {
1111 pub fn effective_feature_cols(&self) -> Vec<usize> {
1114 if self.feature_cols.is_empty() {
1115 vec![self.feature_col]
1116 } else {
1117 self.feature_cols.clone()
1118 }
1119 }
1120
1121 pub fn is_interaction(&self) -> bool {
1123 self.feature_cols.len() > 1 || !self.categorical_levels.is_empty()
1124 }
1125
1126 pub fn realized_design_column(&self, data: ArrayView2<'_, f64>) -> Result<Array1<f64>, String> {
1139 let n = data.nrows();
1140 let p = data.ncols();
1141 let bounds = |col: usize| -> Result<(), String> {
1142 if col >= p {
1143 Err(format!(
1144 "linear term '{}' feature column {} out of bounds for {} columns",
1145 self.name, col, p
1146 ))
1147 } else {
1148 Ok(())
1149 }
1150 };
1151
1152 let mut column = if self.categorical_levels.is_empty() {
1157 let cols = self.effective_feature_cols();
1158 for &c in &cols {
1159 bounds(c)?;
1160 }
1161 let mut acc = data.column(cols[0]).to_owned();
1162 for &c in cols.iter().skip(1) {
1163 acc *= &data.column(c);
1164 }
1165 acc
1166 } else {
1167 let mut acc = Array1::<f64>::ones(n);
1168 for &c in &self.feature_cols {
1169 bounds(c)?;
1170 acc *= &data.column(c);
1171 }
1172 acc
1173 };
1174
1175 for &(col, level_bits) in &self.categorical_levels {
1176 bounds(col)?;
1177 let level_bits = gam_data::canonical_level_bits(f64::from_bits(level_bits));
1181 let gate = data.column(col);
1182 for (out, &v) in column.iter_mut().zip(gate.iter()) {
1183 if gam_data::canonical_level_bits(v) != level_bits {
1184 *out = 0.0;
1185 }
1186 }
1187 }
1188
1189 Ok(column)
1190 }
1191}
1192
1193pub const fn default_linear_term_double_penalty() -> bool {
1194 false
1195}
1196
1197pub const fn default_pca_smooth_penalty() -> f64 {
1198 1.0
1199}
1200
1201pub const fn default_pca_chunk_size() -> usize {
1202 4096
1203}
1204
1205#[derive(Debug, Clone, Serialize, Deserialize)]
1211pub struct RandomEffectTermSpec {
1212 pub name: String,
1213 pub feature_col: usize,
1214 pub drop_first_level: bool,
1217 #[serde(default = "default_random_effect_penalized")]
1221 pub penalized: bool,
1222 #[serde(default)]
1225 pub frozen_levels: Option<Vec<u64>>,
1226 #[serde(default = "default_random_effect_lenient_unseen")]
1243 pub lenient_unseen: bool,
1244}
1245
1246pub fn default_random_effect_penalized() -> bool {
1247 true
1248}
1249
1250pub fn default_random_effect_lenient_unseen() -> bool {
1251 true
1252}
1253
1254pub fn validate_measure_jet_positive_vec_len(
1255 label: &str,
1256 term_name: &str,
1257 field: &str,
1258 values: &[f64],
1259 expected: usize,
1260) -> Result<(), String> {
1261 if values.len() != expected {
1262 return Err(SmoothError::invalid_config(format!(
1263 "{label} term '{term_name}' frozen MeasureJet {field} has length {}, expected {expected}",
1264 values.len()
1265 ))
1266 .into());
1267 }
1268 if values
1269 .iter()
1270 .any(|value| !(value.is_finite() && *value > 0.0))
1271 {
1272 return Err(SmoothError::invalid_config(format!(
1273 "{label} term '{term_name}' frozen MeasureJet {field} values must be positive and finite"
1274 ))
1275 .into());
1276 }
1277 Ok(())
1278}
1279
1280#[derive(Debug, Clone, Serialize, Deserialize)]
1281pub struct TermCollectionSpec {
1282 pub linear_terms: Vec<LinearTermSpec>,
1283 pub random_effect_terms: Vec<RandomEffectTermSpec>,
1284 pub smooth_terms: Vec<SmoothTermSpec>,
1285}
1286
1287pub fn validate_smooth_basis_frozen(
1288 basis: &SmoothBasisSpec,
1289 label: &str,
1290 term_name: &str,
1291) -> Result<(), String> {
1292 if let Err(error) = basis.validate_scale_configuration() {
1293 return Err(SmoothError::invalid_config(format!(
1294 "{label} term '{term_name}' has an invalid scale contract: {error}"
1295 ))
1296 .into());
1297 }
1298 match basis {
1299 SmoothBasisSpec::ByVariable { inner, .. }
1300 | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
1301 validate_smooth_basis_frozen(inner, label, term_name)
1302 }
1303 SmoothBasisSpec::BSpline1D { spec, .. } => {
1304 if !matches!(
1305 spec.knotspec,
1306 BSplineKnotSpec::Provided(_)
1307 | BSplineKnotSpec::PeriodicUniform { .. }
1308 | BSplineKnotSpec::NaturalCubicRegression { .. }
1309 ) {
1310 return Err(format!(
1311 "{label} term '{term_name}' is not frozen: BSpline knotspec must be Provided, PeriodicUniform, or NaturalCubicRegression"
1312 ));
1313 }
1314 Ok(())
1315 }
1316 SmoothBasisSpec::ThinPlate { spec, .. } => {
1317 if !matches!(spec.center_strategy, CenterStrategy::UserProvided(_)) {
1318 return Err(format!(
1319 "{label} term '{term_name}' is not frozen: ThinPlate centers must be UserProvided"
1320 ));
1321 }
1322 if matches!(
1323 spec.identifiability,
1324 SpatialIdentifiability::OrthogonalToParametric
1325 ) {
1326 return Err(format!(
1327 "{label} term '{term_name}' is not frozen: ThinPlate identifiability must be FrozenTransform or None"
1328 ));
1329 }
1330 Ok(())
1331 }
1332 _ => Ok(()),
1333 }
1334}
1335
1336impl TermCollectionSpec {
1337 pub fn write_structural_shape_hash(&self, h: &mut gam_runtime::warm_start::Fingerprinter) {
1351 h.write_str("term-collection");
1352 h.write_usize(self.linear_terms.len());
1353 for linear in &self.linear_terms {
1354 h.write_str(&linear.name);
1355 }
1356 h.write_usize(self.random_effect_terms.len());
1357 h.write_usize(self.smooth_terms.len());
1358 for smooth in &self.smooth_terms {
1359 h.write_str(&smooth.name);
1360 h.write_str(smooth.basis.structural_kind());
1361 for col in smooth.basis.structural_feature_cols() {
1362 h.write_usize(col);
1363 }
1364 }
1365 }
1366
1367 pub fn validate_frozen(&self, label: &str) -> Result<(), String> {
1371 for linear in &self.linear_terms {
1372 if let (Some(min), Some(max)) = (linear.coefficient_min, linear.coefficient_max)
1373 && (!min.is_finite() || !max.is_finite() || min > max)
1374 {
1375 return Err(SmoothError::invalid_config(format!(
1376 "{label} linear term '{}' has invalid coefficient constraint [{min}, {max}]",
1377 linear.name
1378 ))
1379 .into());
1380 }
1381 if let Some(min) = linear.coefficient_min
1382 && !min.is_finite()
1383 {
1384 return Err(SmoothError::invalid_config(format!(
1385 "{label} linear term '{}' has non-finite coefficient minimum {min}",
1386 linear.name
1387 ))
1388 .into());
1389 }
1390 if let Some(max) = linear.coefficient_max
1391 && !max.is_finite()
1392 {
1393 return Err(SmoothError::invalid_config(format!(
1394 "{label} linear term '{}' has non-finite coefficient maximum {max}",
1395 linear.name
1396 ))
1397 .into());
1398 }
1399 if let LinearCoefficientGeometry::Bounded { min, max, prior } =
1400 &linear.coefficient_geometry
1401 {
1402 if !min.is_finite() || !max.is_finite() || min >= max {
1403 return Err(SmoothError::invalid_config(format!(
1404 "{label} bounded term '{}' has invalid bounds [{min}, {max}]",
1405 linear.name
1406 ))
1407 .into());
1408 }
1409 match prior {
1410 BoundedCoefficientPriorSpec::None | BoundedCoefficientPriorSpec::Uniform => {}
1411 BoundedCoefficientPriorSpec::Beta { a, b } => {
1412 if !a.is_finite() || !b.is_finite() || *a < 1.0 || *b < 1.0 {
1413 return Err(SmoothError::invalid_config(format!(
1414 "{label} bounded term '{}' has invalid Beta prior ({a}, {b})",
1415 linear.name
1416 ))
1417 .into());
1418 }
1419 }
1420 }
1421 }
1422 }
1423 for st in &self.smooth_terms {
1424 if let Err(error) = st.basis.validate_scale_configuration() {
1425 return Err(SmoothError::invalid_config(format!(
1426 "{label} term '{}' has an invalid scale contract: {error}",
1427 st.name
1428 ))
1429 .into());
1430 }
1431 match &st.basis {
1432 SmoothBasisSpec::ByVariable { inner, .. } => {
1433 validate_smooth_basis_frozen(inner, label, &st.name)?;
1434 let nested = SmoothTermSpec {
1435 name: st.name.clone(),
1436 basis: (**inner).clone(),
1437 shape: st.shape,
1438 joint_null_rotation: None,
1439 };
1440 TermCollectionSpec {
1441 linear_terms: Vec::new(),
1442 random_effect_terms: Vec::new(),
1443 smooth_terms: vec![nested],
1444 }
1445 .validate_frozen(label)?;
1446 }
1447 SmoothBasisSpec::FactorSumToZero { inner, levels, .. } => {
1448 if levels.len() < 2 {
1449 return Err(format!(
1450 "{label} term '{}' has invalid frozen sz levels",
1451 st.name
1452 ));
1453 }
1454 validate_smooth_basis_frozen(inner, label, &st.name)?;
1455 }
1456 SmoothBasisSpec::BSpline1D { spec, .. } => {
1457 if !matches!(
1458 spec.knotspec,
1459 BSplineKnotSpec::Provided(_)
1460 | BSplineKnotSpec::PeriodicUniform { .. }
1461 | BSplineKnotSpec::NaturalCubicRegression { .. }
1462 ) {
1463 return Err(SmoothError::invalid_config(format!(
1464 "{label} term '{}' is not frozen: BSpline knotspec must be Provided, PeriodicUniform, or NaturalCubicRegression",
1465 st.name
1466 ))
1467 .into());
1468 }
1469 }
1470 SmoothBasisSpec::ThinPlate {
1471 spec, input_scale, ..
1472 } => {
1473 if input_scale.is_none() {
1474 return Err(SmoothError::invalid_config(format!(
1475 "{label} term '{}' is not frozen: ThinPlate input_scale is missing",
1476 st.name
1477 ))
1478 .into());
1479 }
1480 if !matches!(spec.center_strategy, CenterStrategy::UserProvided(_)) {
1481 return Err(SmoothError::invalid_config(format!(
1482 "{label} term '{}' is not frozen: ThinPlate centers must be UserProvided",
1483 st.name
1484 ))
1485 .into());
1486 }
1487 if matches!(
1488 spec.identifiability,
1489 SpatialIdentifiability::OrthogonalToParametric
1490 ) {
1491 return Err(SmoothError::invalid_config(format!(
1492 "{label} term '{}' is not frozen: ThinPlate identifiability must be FrozenTransform or None",
1493 st.name
1494 ))
1495 .into());
1496 }
1497 }
1498 SmoothBasisSpec::Sphere { spec, .. } => {
1499 if !matches!(spec.center_strategy, CenterStrategy::UserProvided(_)) {
1500 return Err(SmoothError::invalid_config(format!(
1501 "{label} term '{}' is not frozen: Sphere centers must be UserProvided",
1502 st.name
1503 ))
1504 .into());
1505 }
1506 if matches!(spec.method, crate::basis::SphereMethod::Harmonic)
1507 && spec.max_degree.is_none_or(|d| d == 0)
1508 {
1509 return Err(format!(
1510 "{label} term '{}' is not frozen: sphere max_degree must be positive",
1511 st.name
1512 ));
1513 }
1514 }
1515 SmoothBasisSpec::ConstantCurvature { spec, .. } => {
1516 if !matches!(spec.center_strategy, CenterStrategy::UserProvided(_)) {
1517 return Err(SmoothError::invalid_config(format!(
1518 "{label} term '{}' is not frozen: ConstantCurvature centers must be UserProvided",
1519 st.name
1520 ))
1521 .into());
1522 }
1523 if !(spec.length_scale.is_finite() && spec.length_scale > 0.0) {
1524 return Err(SmoothError::invalid_config(format!(
1525 "{label} term '{}' is not frozen: ConstantCurvature length_scale must be the realized positive value",
1526 st.name
1527 ))
1528 .into());
1529 }
1530 }
1531 SmoothBasisSpec::MeasureJet {
1532 spec, input_scale, ..
1533 } => {
1534 if input_scale.is_none() {
1535 return Err(SmoothError::invalid_config(format!(
1536 "{label} term '{}' is not frozen: MeasureJet input_scale is missing",
1537 st.name
1538 ))
1539 .into());
1540 }
1541 let centers = match &spec.center_strategy {
1542 CenterStrategy::UserProvided(centers) => centers,
1543 _ => {
1544 return Err(SmoothError::invalid_config(format!(
1545 "{label} term '{}' is not frozen: MeasureJet centers must be UserProvided",
1546 st.name
1547 ))
1548 .into());
1549 }
1550 };
1551 if centers.nrows() == 0 {
1552 return Err(SmoothError::invalid_config(format!(
1553 "{label} term '{}' is not frozen: MeasureJet centers are empty",
1554 st.name
1555 ))
1556 .into());
1557 }
1558 if !(spec.length_scale.is_finite() && spec.length_scale > 0.0) {
1559 return Err(SmoothError::invalid_config(format!(
1560 "{label} term '{}' is not frozen: MeasureJet length_scale must be the realized positive value",
1561 st.name
1562 ))
1563 .into());
1564 }
1565 let frozen = spec.frozen_quadrature.as_ref().ok_or_else(|| {
1568 SmoothError::invalid_config(format!(
1569 "{label} term '{}' is not frozen: MeasureJet frozen_quadrature payload is missing",
1570 st.name
1571 ))
1572 })?;
1573 if frozen.masses.len() != centers.nrows() {
1574 return Err(SmoothError::invalid_config(format!(
1575 "{label} term '{}' frozen MeasureJet has {} masses for {} centers",
1576 st.name,
1577 frozen.masses.len(),
1578 centers.nrows()
1579 ))
1580 .into());
1581 }
1582 let total_mass = frozen.masses.sum();
1583 if frozen
1584 .masses
1585 .iter()
1586 .any(|mass| !(mass.is_finite() && *mass >= 0.0))
1587 || !(total_mass.is_finite() && total_mass > 0.0)
1588 {
1589 return Err(SmoothError::invalid_config(format!(
1590 "{label} term '{}' frozen MeasureJet masses must be finite, nonnegative, and have positive total mass",
1591 st.name
1592 ))
1593 .into());
1594 }
1595 let n_levels = frozen.eps_band.len();
1596 if n_levels == 0
1597 || frozen
1598 .eps_band
1599 .iter()
1600 .any(|eps| !(eps.is_finite() && *eps > 0.0))
1601 {
1602 return Err(SmoothError::invalid_config(format!(
1603 "{label} term '{}' frozen MeasureJet eps_band must be nonempty, finite, and positive",
1604 st.name
1605 ))
1606 .into());
1607 }
1608 for (idx, pair) in frozen.eps_band.windows(2).enumerate() {
1609 if pair[1] <= pair[0] {
1610 return Err(SmoothError::invalid_config(format!(
1611 "{label} term '{}' frozen MeasureJet eps_band is not strictly ascending at {idx}: {} then {}",
1612 st.name,
1613 pair[0],
1614 pair[1]
1615 ))
1616 .into());
1617 }
1618 }
1619 validate_measure_jet_positive_vec_len(
1620 label,
1621 &st.name,
1622 "support_means",
1623 &frozen.support_means,
1624 n_levels,
1625 )?;
1626 let per_level = crate::basis::measure_jet_multiscale_mode(spec);
1634 if per_level {
1635 validate_measure_jet_positive_vec_len(
1636 label,
1637 &st.name,
1638 "penalty_normalization_scales",
1639 &frozen.penalty_normalization_scales,
1640 n_levels,
1641 )?;
1642 validate_measure_jet_positive_vec_len(
1643 label,
1644 &st.name,
1645 "raw_penalty_normalization_scales",
1646 &frozen.raw_penalty_normalization_scales,
1647 n_levels,
1648 )?;
1649 if frozen.fused_penalty_normalization_scale.is_some() {
1650 return Err(SmoothError::invalid_config(format!(
1651 "{label} term '{}' per-level MeasureJet must not carry a fused penalty normalization scale",
1652 st.name
1653 ))
1654 .into());
1655 }
1656 } else {
1657 if !frozen.penalty_normalization_scales.is_empty()
1658 || !frozen.raw_penalty_normalization_scales.is_empty()
1659 {
1660 return Err(SmoothError::invalid_config(format!(
1661 "{label} term '{}' fused MeasureJet must not carry per-level penalty normalization scales",
1662 st.name
1663 ))
1664 .into());
1665 }
1666 match frozen.fused_penalty_normalization_scale {
1667 Some(scale) if scale.is_finite() && scale > 0.0 => {}
1668 Some(scale) => {
1669 return Err(SmoothError::invalid_config(format!(
1670 "{label} term '{}' fused MeasureJet penalty normalization scale must be positive and finite, got {scale}",
1671 st.name
1672 ))
1673 .into());
1674 }
1675 None => {
1676 return Err(SmoothError::invalid_config(format!(
1677 "{label} term '{}' fused MeasureJet is missing its penalty normalization scale",
1678 st.name
1679 ))
1680 .into());
1681 }
1682 }
1683 }
1684 }
1685 SmoothBasisSpec::Matern {
1686 spec, input_scale, ..
1687 } => {
1688 if input_scale.is_none() {
1689 return Err(SmoothError::invalid_config(format!(
1690 "{label} term '{}' is not frozen: Matern input_scale is missing",
1691 st.name
1692 ))
1693 .into());
1694 }
1695 if !matches!(spec.center_strategy, CenterStrategy::UserProvided(_)) {
1696 return Err(SmoothError::invalid_config(format!(
1697 "{label} term '{}' is not frozen: Matern centers must be UserProvided",
1698 st.name
1699 ))
1700 .into());
1701 }
1702 if spec
1703 .length_scale
1704 .resolved()
1705 .is_none_or(|value| !value.is_finite() || value <= 0.0)
1706 {
1707 return Err(SmoothError::invalid_config(format!(
1708 "{label} term '{}' is not frozen: Matern length_scale must be resolved, finite, and positive",
1709 st.name
1710 ))
1711 .into());
1712 }
1713 }
1714 SmoothBasisSpec::Duchon {
1715 spec, input_scale, ..
1716 } => {
1717 if input_scale.is_none() {
1718 return Err(SmoothError::invalid_config(format!(
1719 "{label} term '{}' is not frozen: Duchon input_scale is missing",
1720 st.name
1721 ))
1722 .into());
1723 }
1724 if !matches!(spec.center_strategy, CenterStrategy::UserProvided(_)) {
1725 return Err(SmoothError::invalid_config(format!(
1726 "{label} term '{}' is not frozen: Duchon centers must be UserProvided",
1727 st.name
1728 ))
1729 .into());
1730 }
1731 if matches!(
1732 spec.identifiability,
1733 SpatialIdentifiability::OrthogonalToParametric
1734 ) {
1735 return Err(SmoothError::invalid_config(format!(
1736 "{label} term '{}' is not frozen: Duchon identifiability must be FrozenTransform or None",
1737 st.name
1738 ))
1739 .into());
1740 }
1741 }
1742 SmoothBasisSpec::Pca {
1743 centered,
1744 center_mean,
1745 pca_basis_path,
1746 ..
1747 } => {
1748 if *centered && center_mean.is_none() && pca_basis_path.is_none() {
1749 return Err(SmoothError::invalid_config(format!(
1750 "{label} term '{}' is not frozen: centered Pca missing center_mean",
1751 st.name
1752 ))
1753 .into());
1754 }
1755 }
1756 SmoothBasisSpec::BySmooth { smooth, by_kind } => {
1757 if let SmoothBasisSpec::BySmooth { .. } = smooth.as_ref() {
1758 return Err(format!("{label} term '{}' has nested by-smooths", st.name));
1759 }
1760 match by_kind {
1761 ByVarKind::Numeric { .. } => {}
1762 ByVarKind::Factor { frozen_levels, .. } if frozen_levels.is_none() => {
1763 return Err(format!(
1764 "{label} term '{}' is not frozen: by-factor levels missing",
1765 st.name
1766 ));
1767 }
1768 ByVarKind::Factor { .. } => {}
1769 }
1770 let nested = TermCollectionSpec {
1771 linear_terms: vec![],
1772 random_effect_terms: vec![],
1773 smooth_terms: vec![SmoothTermSpec {
1774 name: st.name.clone(),
1775 basis: (**smooth).clone(),
1776 shape: st.shape,
1777 joint_null_rotation: None,
1778 }],
1779 };
1780 nested.validate_frozen(label)?;
1781 }
1782 SmoothBasisSpec::FactorSmooth { spec } => {
1783 if spec.group_frozen_levels.is_none() {
1784 return Err(format!(
1785 "{label} term '{}' is not frozen: factor-smooth levels missing",
1786 st.name
1787 ));
1788 }
1789 if !matches!(
1790 spec.marginal.knotspec,
1791 BSplineKnotSpec::Provided(_)
1792 | BSplineKnotSpec::PeriodicUniform { .. }
1793 | BSplineKnotSpec::NaturalCubicRegression { .. }
1805 ) {
1806 return Err(format!(
1807 "{label} term '{}' is not frozen: factor-smooth marginal knots missing",
1808 st.name
1809 ));
1810 }
1811 }
1812 SmoothBasisSpec::TensorBSpline { spec, .. } => {
1813 for (dim, marginal) in spec.marginalspecs.iter().enumerate() {
1814 if !matches!(
1815 marginal.knotspec,
1816 BSplineKnotSpec::Provided(_)
1817 | BSplineKnotSpec::PeriodicUniform { .. }
1818 | BSplineKnotSpec::NaturalCubicRegression { .. }
1819 ) {
1820 return Err(SmoothError::invalid_config(format!(
1821 "{label} term '{}' dim {} is not frozen: tensor marginal knotspec must be Provided, PeriodicUniform, or NaturalCubicRegression",
1822 st.name, dim
1823 ))
1824 .into());
1825 }
1826 }
1827 if matches!(
1828 spec.identifiability,
1829 TensorBSplineIdentifiability::SumToZero
1830 | TensorBSplineIdentifiability::MarginalSumToZero
1831 ) {
1832 return Err(SmoothError::invalid_config(format!(
1833 "{label} term '{}' is not frozen: tensor identifiability must be FrozenTransform or None",
1834 st.name
1835 ))
1836 .into());
1837 }
1838 }
1839 }
1840 }
1841
1842 for rt in &self.random_effect_terms {
1843 if rt.frozen_levels.is_none() {
1844 return Err(SmoothError::invalid_config(format!(
1845 "{label} random-effect term '{}' is not frozen: missing frozen_levels",
1846 rt.name
1847 ))
1848 .into());
1849 }
1850 }
1851
1852 Ok(())
1853 }
1854
1855 pub fn remap_feature_columns<E, F>(&self, mut remap: F) -> Result<TermCollectionSpec, E>
1874 where
1875 F: FnMut(usize) -> Result<usize, E>,
1876 {
1877 let mut out = self.clone();
1878 for lt in &mut out.linear_terms {
1879 lt.feature_col = remap(lt.feature_col)?;
1880 for fc in lt.feature_cols.iter_mut() {
1890 *fc = remap(*fc)?;
1891 }
1892 for (col, _bits) in lt.categorical_levels.iter_mut() {
1897 *col = remap(*col)?;
1898 }
1899 }
1900 for rt in &mut out.random_effect_terms {
1901 rt.feature_col = remap(rt.feature_col)?;
1902 }
1903 for st in &mut out.smooth_terms {
1904 remap_smooth_basis_feature_columns(&mut st.basis, &mut remap)?;
1905 }
1906 Ok(out)
1907 }
1908}
1909
1910pub fn remap_smooth_basis_feature_columns<E, F>(
1915 basis: &mut SmoothBasisSpec,
1916 remap: &mut F,
1917) -> Result<(), E>
1918where
1919 F: FnMut(usize) -> Result<usize, E>,
1920{
1921 match basis {
1922 SmoothBasisSpec::ByVariable { inner, by_col, .. }
1923 | SmoothBasisSpec::FactorSumToZero { inner, by_col, .. } => {
1924 *by_col = remap(*by_col)?;
1925 remap_smooth_basis_feature_columns(inner, remap)?;
1926 }
1927 SmoothBasisSpec::BSpline1D { feature_col, .. } => {
1928 *feature_col = remap(*feature_col)?;
1929 }
1930 SmoothBasisSpec::BySmooth { smooth, by_kind } => {
1931 let by_feature_col = match by_kind {
1932 ByVarKind::Numeric { feature_col } | ByVarKind::Factor { feature_col, .. } => {
1933 feature_col
1934 }
1935 };
1936 *by_feature_col = remap(*by_feature_col)?;
1937 remap_smooth_basis_feature_columns(smooth, remap)?;
1938 }
1939 SmoothBasisSpec::FactorSmooth { spec } => {
1940 for fc in spec.continuous_cols.iter_mut() {
1941 *fc = remap(*fc)?;
1942 }
1943 spec.group_col = remap(spec.group_col)?;
1944 }
1945 SmoothBasisSpec::ThinPlate { feature_cols, .. }
1946 | SmoothBasisSpec::Sphere { feature_cols, .. }
1947 | SmoothBasisSpec::ConstantCurvature { feature_cols, .. }
1948 | SmoothBasisSpec::Matern { feature_cols, .. }
1949 | SmoothBasisSpec::MeasureJet { feature_cols, .. }
1950 | SmoothBasisSpec::Duchon { feature_cols, .. }
1951 | SmoothBasisSpec::Pca { feature_cols, .. }
1952 | SmoothBasisSpec::TensorBSpline { feature_cols, .. } => {
1953 for fc in feature_cols.iter_mut() {
1954 *fc = remap(*fc)?;
1955 }
1956 }
1957 }
1958 Ok(())
1959}
1960
1961#[derive(Debug, Clone)]
1962pub enum PenaltyStructureHint {
1963 Ridge(f64),
1964 Kronecker(Vec<Array2<f64>>),
1965}
1966
1967#[derive(Clone)]
1974pub struct BlockwisePenalty {
1975 pub col_range: Range<usize>,
1977 pub local: Array2<f64>,
1980 pub prior_mean: gam_problem::CoefficientPriorMean,
1982 pub structure_hint: Option<PenaltyStructureHint>,
1985 pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
1990}
1991
1992impl std::fmt::Debug for BlockwisePenalty {
1993 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1994 f.debug_struct("BlockwisePenalty")
1995 .field("col_range", &self.col_range)
1996 .field(
1997 "local",
1998 &format_args!("{}×{}", self.local.nrows(), self.local.ncols()),
1999 )
2000 .field("prior_mean", &self.prior_mean)
2001 .field("structure_hint", &self.structure_hint)
2002 .field("op", &self.op.as_ref().map(|o| o.dim()))
2003 .finish()
2004 }
2005}
2006
2007impl BlockwisePenalty {
2008 pub fn new(col_range: Range<usize>, local: Array2<f64>) -> Self {
2010 assert_eq!(col_range.len(), local.nrows());
2011 assert_eq!(col_range.len(), local.ncols());
2012 Self {
2013 col_range,
2014 local,
2015 prior_mean: gam_problem::CoefficientPriorMean::Zero,
2016 structure_hint: None,
2017 op: None,
2018 }
2019 }
2020
2021 pub fn with_prior_mean(mut self, prior_mean: gam_problem::CoefficientPriorMean) -> Self {
2022 self.prior_mean = prior_mean;
2023 self
2024 }
2025
2026 pub fn with_op(
2028 mut self,
2029 op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
2030 ) -> Self {
2031 self.op = op;
2032 self
2033 }
2034
2035 pub fn ridge(col_range: Range<usize>, scale: f64) -> Self {
2036 let block_size = col_range.len();
2037 let mut local = Array2::<f64>::zeros((block_size, block_size));
2038 for i in 0..block_size {
2039 local[[i, i]] = scale;
2040 }
2041 Self {
2042 col_range,
2043 local,
2044 prior_mean: gam_problem::CoefficientPriorMean::Zero,
2045 structure_hint: Some(PenaltyStructureHint::Ridge(scale)),
2046 op: None,
2047 }
2048 }
2049
2050 pub fn kronecker(
2051 col_range: Range<usize>,
2052 local: Array2<f64>,
2053 factors: Vec<Array2<f64>>,
2054 ) -> Self {
2055 assert_eq!(col_range.len(), local.nrows());
2056 assert_eq!(col_range.len(), local.ncols());
2057 Self {
2058 col_range,
2059 local,
2060 prior_mean: gam_problem::CoefficientPriorMean::Zero,
2061 structure_hint: Some(PenaltyStructureHint::Kronecker(factors)),
2062 op: None,
2063 }
2064 }
2065
2066 pub fn to_global(&self, p_total: usize) -> Array2<f64> {
2070 let mut g = Array2::<f64>::zeros((p_total, p_total));
2071 let r = &self.col_range;
2072 assert!(
2073 r.end <= p_total && self.local.nrows() == r.len() && self.local.ncols() == r.len(),
2074 "BlockwisePenalty::to_global shape invariant violated: \
2075 col_range={}..{}, local={}x{}, p_total={}",
2076 r.start,
2077 r.end,
2078 self.local.nrows(),
2079 self.local.ncols(),
2080 p_total,
2081 );
2082 g.slice_mut(s![r.start..r.end, r.start..r.end])
2083 .assign(&self.local);
2084 g
2085 }
2086
2087 pub fn to_penalty_matrix(&self, total_dim: usize) -> gam_problem::PenaltyMatrix {
2090 gam_problem::PenaltyMatrix::Blockwise {
2091 local: self.local.clone(),
2092 col_range: self.col_range.clone(),
2093 total_dim,
2094 }
2095 }
2096
2097 #[inline]
2099 pub fn block_size(&self) -> usize {
2100 self.col_range.len()
2101 }
2102}
2103
2104pub fn weighted_blockwise_penalty_sum(
2108 penalties: &[BlockwisePenalty],
2109 lambdas: &[f64],
2110 p_total: usize,
2111) -> Array2<f64> {
2112 assert_eq!(penalties.len(), lambdas.len());
2113 for (idx, &lam) in lambdas.iter().enumerate() {
2120 assert!(
2121 lam.is_finite() && lam >= 0.0,
2122 "weighted_blockwise_penalty_sum: lambdas[{idx}] = {lam} is invalid (must be finite and non-negative; negative smoothing parameters violate S_λ ⪰ 0)",
2123 );
2124 }
2125 for (idx, bp) in penalties.iter().enumerate() {
2129 let r = &bp.col_range;
2130 assert!(
2131 r.end <= p_total,
2132 "weighted_blockwise_penalty_sum: penalties[{idx}] col_range {:?} exceeds p_total = {p_total}",
2133 r,
2134 );
2135 }
2136 let mut out = Array2::<f64>::zeros((p_total, p_total));
2137 for (bp, &lam) in penalties.iter().zip(lambdas.iter()) {
2138 let r = &bp.col_range;
2139 let mut slice = out.slice_mut(s![r.start..r.end, r.start..r.end]);
2140 slice.scaled_add(lam, &bp.local);
2141 }
2142 out
2143}
2144
2145#[derive(Debug, Clone)]
2152pub struct KroneckerPenaltySystem {
2153 pub marginal_penalties: Vec<Array2<f64>>,
2155 pub marginal_eigensystems: Vec<(Array1<f64>, Array2<f64>)>,
2157 pub marginal_dims: Vec<usize>,
2159 pub has_double_penalty: bool,
2161}
2162
2163impl KroneckerPenaltySystem {
2164 pub fn new(
2165 marginal_penalties: Vec<Array2<f64>>,
2166 marginal_dims: Vec<usize>,
2167 has_double_penalty: bool,
2168 ) -> Result<Self, BasisError> {
2169 if marginal_penalties.len() != marginal_dims.len() {
2170 crate::bail_dim_basis!(
2171 "KroneckerPenaltySystem: {} penalties vs {} dims",
2172 marginal_penalties.len(),
2173 marginal_dims.len()
2174 );
2175 }
2176 let eigensystems =
2177 kronecker_marginal_eigensystems(&marginal_penalties, "KroneckerPenaltySystem")
2178 .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
2179 Ok(Self {
2180 marginal_penalties,
2181 marginal_eigensystems: eigensystems,
2182 marginal_dims,
2183 has_double_penalty,
2184 })
2185 }
2186
2187 pub fn p_total(&self) -> usize {
2188 self.marginal_dims.iter().copied().product()
2189 }
2190
2191 pub fn ndim(&self) -> usize {
2192 self.marginal_dims.len()
2193 }
2194
2195 pub fn num_penalties(&self) -> usize {
2196 self.marginal_dims.len() + if self.has_double_penalty { 1 } else { 0 }
2197 }
2198
2199 pub fn logdet_and_derivatives(
2203 &self,
2204 lambdas: &[f64],
2205 ridge: f64,
2206 ) -> (f64, Array1<f64>, Array2<f64>) {
2207 let n_pen = self.num_penalties();
2208 assert_eq!(lambdas.len(), n_pen, "lambda count mismatch");
2209 let marginal_evals: Vec<_> = self
2210 .marginal_eigensystems
2211 .iter()
2212 .map(|(evals, _)| evals.view())
2213 .collect();
2214 kronecker_logdet_and_derivatives(
2215 &marginal_evals,
2216 &self.marginal_dims,
2217 lambdas,
2218 self.has_double_penalty,
2219 ridge,
2220 )
2221 }
2222
2223 pub fn logdet_rank_and_derivatives(
2224 &self,
2225 lambdas: &[f64],
2226 ridge: f64,
2227 ) -> (f64, usize, Array1<f64>, Array2<f64>) {
2228 let n_pen = self.num_penalties();
2229 assert_eq!(lambdas.len(), n_pen, "lambda count mismatch");
2230 let d = self.marginal_dims.len();
2231 let mut logdet = 0.0;
2232 let mut rank = 0usize;
2233 let mut grad = Array1::<f64>::zeros(n_pen);
2234 let mut hess = Array2::<f64>::zeros((n_pen, n_pen));
2235 const EIGENVALUE_POSITIVITY_FLOOR: f64 = 1e-12;
2239 const STRUCTURAL_ZERO_FLOOR: f64 = 1e-12;
2243 let mut multi_idx = vec![0usize; d];
2244 loop {
2245 let mut sigma = 0.0;
2246 let mut structural_sigma = 0.0;
2247 for k in 0..d {
2248 let marginal_eigenvalue = self.marginal_eigensystems[k].0[multi_idx[k]];
2249 structural_sigma += marginal_eigenvalue;
2250 sigma += lambdas[k] * marginal_eigenvalue;
2251 }
2252 let joint_null = structural_sigma <= STRUCTURAL_ZERO_FLOOR;
2253 if self.has_double_penalty && joint_null {
2254 sigma += lambdas[d];
2255 }
2256 if structural_sigma > STRUCTURAL_ZERO_FLOOR {
2257 sigma += ridge;
2258 }
2259
2260 if sigma > EIGENVALUE_POSITIVITY_FLOOR {
2261 rank += 1;
2262 logdet += sigma.ln();
2263 let inv_sigma = 1.0 / sigma;
2264 let inv_sigma2 = inv_sigma * inv_sigma;
2265 for k in 0..n_pen {
2266 let ck = if k < d {
2267 lambdas[k] * self.marginal_eigensystems[k].0[multi_idx[k]]
2268 } else if joint_null {
2269 lambdas[d]
2270 } else {
2271 0.0
2272 };
2273 grad[k] += ck * inv_sigma;
2274 hess[[k, k]] += ck * inv_sigma - ck * ck * inv_sigma2;
2275 for l in (k + 1)..n_pen {
2276 let cl = if l < d {
2277 lambdas[l] * self.marginal_eigensystems[l].0[multi_idx[l]]
2278 } else if joint_null {
2279 lambdas[d]
2280 } else {
2281 0.0
2282 };
2283 let off = -ck * cl * inv_sigma2;
2284 hess[[k, l]] += off;
2285 hess[[l, k]] += off;
2286 }
2287 }
2288 }
2289
2290 let mut carry = true;
2291 for dim in (0..d).rev() {
2292 if carry {
2293 multi_idx[dim] += 1;
2294 if multi_idx[dim] < self.marginal_dims[dim] {
2295 carry = false;
2296 } else {
2297 multi_idx[dim] = 0;
2298 }
2299 }
2300 }
2301 if carry {
2302 break;
2303 }
2304 }
2305 (logdet, rank, grad, hess)
2306 }
2307}
2308
2309#[cfg(test)]
2310mod joint_unpenalized_dim_tests {
2311 use super::{ActivePenalty, ActivePenaltyInfo, PenaltySource, joint_unpenalized_dim};
2312 use ndarray::{Array2, array};
2313
2314 fn active_penalty(
2315 matrix: Array2<f64>,
2316 effective_rank: usize,
2317 nullity: usize,
2318 original_index: usize,
2319 source: PenaltySource,
2320 ) -> ActivePenalty {
2321 ActivePenalty {
2322 matrix,
2323 nullity,
2324 null_eigenvectors: None,
2325 op: None,
2326 info: ActivePenaltyInfo {
2327 source,
2328 original_index,
2329 effective_rank,
2330 normalization_scale: 1.0,
2331 kronecker_factors: None,
2332 structural_null_frame: None,
2333 },
2334 }
2335 }
2336
2337 #[test]
2338 fn no_penalty_is_fully_unpenalized() {
2339 assert_eq!(joint_unpenalized_dim(4, &[]), 4);
2340 }
2341
2342 #[test]
2343 fn single_penalty_returns_its_own_null_space() {
2344 let s = array![[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 5.0]];
2347 let penalties = [active_penalty(s, 1, 2, 0, PenaltySource::Primary)];
2348 assert_eq!(joint_unpenalized_dim(3, &penalties), 2);
2349 }
2350
2351 #[test]
2352 fn complementary_double_penalty_has_empty_joint_null_space() {
2353 let bending = array![[0.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]];
2360 let ridge = array![[2.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]];
2361 let penalties = [
2362 active_penalty(bending, 2, 1, 0, PenaltySource::Primary),
2363 active_penalty(ridge, 1, 2, 1, PenaltySource::DoublePenaltyNullspace),
2364 ];
2365 assert_eq!(joint_unpenalized_dim(3, &penalties), 0);
2366 }
2367
2368 #[test]
2369 fn partial_overlap_keeps_shared_null_direction() {
2370 let a = array![[0.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 0.0]];
2374 let b = array![[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 3.0]];
2375 let penalties = [
2376 active_penalty(a, 1, 2, 0, PenaltySource::Primary),
2377 active_penalty(b, 1, 2, 1, PenaltySource::OperatorStiffness),
2378 ];
2379 assert_eq!(joint_unpenalized_dim(3, &penalties), 1);
2380 }
2381
2382 #[test]
2383 fn non_materialized_penalty_falls_back_conservatively() {
2384 let full: Array2<f64> = array![[0.0, 0.0], [0.0, 1.0]];
2388 let factor: Array2<f64> = array![[1.0]]; let mixed_penalties = [
2390 active_penalty(full, 1, 1, 0, PenaltySource::Primary),
2391 active_penalty(
2392 factor.clone(),
2393 2,
2394 0,
2395 1,
2396 PenaltySource::TensorMarginal { dim: 0 },
2397 ),
2398 ];
2399 assert_eq!(joint_unpenalized_dim(2, &mixed_penalties), 0);
2400 let factor_penalties = [active_penalty(
2402 factor,
2403 2,
2404 2,
2405 0,
2406 PenaltySource::TensorMarginal { dim: 0 },
2407 )];
2408 assert_eq!(joint_unpenalized_dim(4, &factor_penalties), 2);
2409 }
2410}
2411
2412#[cfg(test)]
2413mod kronecker_penalty_system_tests {
2414 use super::KroneckerPenaltySystem;
2415 use ndarray::array;
2416
2417 #[test]
2418 fn double_penalty_rank_derivatives_use_only_joint_null_space() {
2419 let penalties = vec![
2420 array![[0.0, 0.0], [0.0, 2.0]],
2421 array![[0.0, 0.0], [0.0, 3.0]],
2422 ];
2423 let system = KroneckerPenaltySystem::new(penalties, vec![2usize, 2usize], true).unwrap();
2424 let lambdas = vec![5.0, 7.0, 11.0];
2425
2426 let (logdet, rank, grad, hess) = system.logdet_rank_and_derivatives(&lambdas, 0.0);
2427
2428 let expected_diag = [11.0_f64, 21.0, 10.0, 31.0];
2429 let expected_logdet: f64 = expected_diag.iter().map(|v| v.ln()).sum();
2430 assert_eq!(rank, 4);
2431 assert!((logdet - expected_logdet).abs() <= 1e-12);
2432 assert!(
2433 (grad[2] - 1.0).abs() <= 1e-12,
2434 "double-penalty rank derivative must count only the joint null mode, got {}",
2435 grad[2]
2436 );
2437 assert!(hess[[2, 2]].abs() <= 1e-12);
2438 }
2439}
2440
2441#[derive(Clone, Debug)]
2442pub struct TermCollectionDesign {
2443 pub design: DesignMatrix,
2452 pub affine_offset: Array1<f64>,
2459 pub penalties: Vec<BlockwisePenalty>,
2460 pub nullspace_dims: Vec<usize>,
2461 pub penaltyinfo: Vec<PenaltyBlockInfo>,
2462 pub dropped_penaltyinfo: Vec<DroppedPenaltyBlockInfo>,
2463 pub coefficient_lower_bounds: Option<Array1<f64>>,
2466 pub linear_constraints: Option<LinearInequalityConstraints>,
2469 pub intercept_range: Range<usize>,
2470 pub linear_ranges: Vec<(String, Range<usize>)>,
2471 pub linear_function_masses: Vec<Option<f64>>,
2480 pub random_effect_ranges: Vec<(String, Range<usize>)>,
2481 pub random_effect_levels: Vec<(String, Vec<u64>)>,
2482 pub smooth: SmoothDesign,
2483}
2484
2485impl TermCollectionDesign {
2486 pub fn compose_offset(
2490 &self,
2491 base: ArrayView1<'_, f64>,
2492 context: &str,
2493 ) -> Result<Array1<f64>, BasisError> {
2494 let n = self.design.nrows();
2495 if self.affine_offset.len() != n || base.len() != n {
2496 crate::bail_dim_basis!(
2497 "{context}: design rows={n}, affine offset rows={}, base offset rows={}",
2498 self.affine_offset.len(),
2499 base.len()
2500 );
2501 }
2502 if self.affine_offset.iter().any(|value| !value.is_finite())
2503 || base.iter().any(|value| !value.is_finite())
2504 {
2505 crate::bail_invalid_basis!("{context}: offsets must be finite");
2506 }
2507 Ok(base.to_owned() + &self.affine_offset)
2508 }
2509
2510 pub fn apply(&self, beta: ArrayView1<'_, f64>) -> Result<Array1<f64>, BasisError> {
2514 if beta.len() != self.design.ncols() {
2515 crate::bail_dim_basis!(
2516 "term-collection predictor coefficient length {} does not match design width {}",
2517 beta.len(),
2518 self.design.ncols()
2519 );
2520 }
2521 if beta.iter().any(|value| !value.is_finite()) {
2522 crate::bail_invalid_basis!("term-collection predictor coefficients must be finite");
2523 }
2524 if self.affine_offset.len() != self.design.nrows() {
2525 crate::bail_dim_basis!(
2526 "term-collection affine offset has {} rows but design has {}",
2527 self.affine_offset.len(),
2528 self.design.nrows()
2529 );
2530 }
2531 if self.affine_offset.iter().any(|value| !value.is_finite()) {
2532 crate::bail_invalid_basis!("term-collection affine offset must be finite");
2533 }
2534 Ok(self.design.apply(&beta.to_owned()) + &self.affine_offset)
2535 }
2536
2537 pub fn leading_penalty_blocks_before_smooth(&self) -> usize {
2545 self.penaltyinfo
2546 .iter()
2547 .take_while(|info| {
2548 matches!(
2549 &info.penalty.source,
2550 crate::basis::PenaltySource::Other(source)
2551 if source == "LinearTermRidge"
2552 || source.starts_with("RandomEffectRidge(")
2553 )
2554 })
2555 .count()
2556 }
2557
2558 pub fn smooth_term_penalty_range(
2566 &self,
2567 term_idx: usize,
2568 ) -> Result<Option<Range<usize>>, String> {
2569 let Some(term) = self.smooth.terms.get(term_idx) else {
2570 return Ok(None);
2571 };
2572 if term.active_penalties.is_empty() {
2573 return Ok(None);
2574 }
2575
2576 let leading = self.leading_penalty_blocks_before_smooth();
2577 let smooth_count = self
2578 .smooth
2579 .terms
2580 .iter()
2581 .map(|smooth| smooth.active_penalties.len())
2582 .sum::<usize>();
2583 let expected = leading
2584 .checked_add(smooth_count)
2585 .ok_or_else(|| "term-collection penalty count overflow".to_string())?;
2586 if expected != self.penalties.len() || self.penaltyinfo.len() != self.penalties.len() {
2587 return Err(format!(
2588 "term-collection penalty layout is inconsistent: {leading} leading blocks + \
2589 {smooth_count} smooth blocks = {expected}, but there are {} penalties and {} \
2590 metadata records",
2591 self.penalties.len(),
2592 self.penaltyinfo.len()
2593 ));
2594 }
2595
2596 let local_offset = self
2597 .smooth
2598 .terms
2599 .iter()
2600 .take(term_idx)
2601 .map(|smooth| smooth.active_penalties.len())
2602 .sum::<usize>();
2603 let start = leading
2604 .checked_add(local_offset)
2605 .ok_or_else(|| "smooth penalty offset overflow".to_string())?;
2606 let end = start
2607 .checked_add(term.active_penalties.len())
2608 .ok_or_else(|| "smooth penalty range overflow".to_string())?;
2609 Ok(Some(start..end))
2610 }
2611
2612 pub fn penalties_as_penalty_matrix(&self) -> Vec<gam_problem::PenaltyMatrix> {
2616 let p = self.design.ncols();
2617 self.penalties
2618 .iter()
2619 .map(|bp| bp.to_penalty_matrix(p))
2620 .collect()
2621 }
2622
2623 #[inline]
2625 pub fn num_penalties(&self) -> usize {
2626 self.penalties.len()
2627 }
2628
2629 pub fn realize_coefficient_groups(
2632 &self,
2633 groups: &[CoefficientGroupSpec],
2634 base_prior: &gam_spec::RhoPrior,
2635 ) -> Result<RealizedCoefficientGroups, BasisError> {
2636 realize_coefficient_groups(self, groups, base_prior)
2637 }
2638
2639 pub fn kronecker_penalty_system(&self) -> Option<KroneckerPenaltySystem> {
2650 let [only_term] = self.smooth.terms.as_slice() else {
2651 return None;
2652 };
2653 let kron = only_term.kronecker_factored.as_ref()?;
2654 if kron.marginal_dims.len() < 2
2660 || kron.marginal_penalties.len() != kron.marginal_dims.len()
2661 || kron.marginal_designs.len() != kron.marginal_dims.len()
2662 {
2663 return None;
2664 }
2665 KroneckerPenaltySystem::new(
2666 kron.marginal_penalties.clone(),
2667 kron.marginal_dims.clone(),
2668 kron.has_double_penalty,
2669 )
2670 .ok()
2671 }
2672}
2673
2674#[derive(Clone)]
2680pub struct StandardLatentCoordConfig {
2681 pub values: std::sync::Arc<crate::latent::LatentCoordValues>,
2682 pub term_index: gam_problem::types::SmoothTermIdx,
2683 pub feature_cols: Vec<usize>,
2684 pub manifold: crate::latent::LatentManifold,
2685 pub manifold_auto: bool,
2686 pub retraction_registry: gam_problem::LatentRetractionRegistry,
2687 pub analytic_penalties: Option<std::sync::Arc<crate::AnalyticPenaltyRegistry>>,
2688}
2689
2690#[derive(Clone, Debug, Serialize, Deserialize)]
2691pub struct AdaptiveSpatialMap {
2692 pub termname: String,
2693 pub feature_cols: Vec<usize>,
2694 pub collocation_points: Array2<f64>,
2695 pub inv_magweight: Array1<f64>,
2696 pub invgradweight: Array1<f64>,
2697 pub inv_lapweight: Array1<f64>,
2698}
2699
2700#[derive(Clone, Debug, Serialize, Deserialize)]
2701pub struct AdaptiveRegularizationDiagnostics {
2702 pub epsilon_0: f64,
2703 pub epsilon_g: f64,
2704 pub epsilon_c: f64,
2705 pub epsilon_outer_iterations: usize,
2706 pub mm_iterations: usize,
2707 pub converged: bool,
2708 pub maps: Vec<AdaptiveSpatialMap>,
2709}
2710
2711#[derive(Debug, Clone)]
2712pub struct LinearColumnConditioning {
2713 col_idx: usize,
2714 mean: f64,
2715 scale: f64,
2716}
2717
2718#[derive(Debug, Clone, Default)]
2719pub struct LinearFitConditioning {
2720 pub intercept_idx: usize,
2721 pub columns: Vec<LinearColumnConditioning>,
2722}
2723
2724#[derive(Clone)]
2725pub struct SpatialPsiDerivative {
2726 pub penalty_index: usize,
2728 pub penalty_indices: Vec<usize>,
2729 pub global_range: Range<usize>,
2730 pub total_p: usize,
2731 pub x_psi_local: Array2<f64>,
2732 pub s_psi_components_local: Vec<Array2<f64>>,
2733 pub x_psi_psi_local: Array2<f64>,
2734 pub s_psi_psi_components_local: Vec<Array2<f64>>,
2735 pub aniso_group_id: Option<usize>,
2736 pub aniso_cross_designs: Option<Vec<(usize, Array2<f64>)>>,
2739 pub aniso_cross_penalty_provider: Option<
2743 std::sync::Arc<
2744 dyn Fn(usize) -> Result<Vec<Array2<f64>>, EstimationError> + Send + Sync + 'static,
2745 >,
2746 >,
2747 pub implicit_operator: Option<std::sync::Arc<crate::basis::ImplicitDesignPsiDerivative>>,
2752 pub implicit_axis: usize,
2754}
2755
2756#[derive(Debug, Clone)]
2757pub struct SpatialLogKappaCoords {
2758 pub values: Array1<f64>,
2761 pub dims_per_term: Vec<usize>,
2763}
2764
2765#[derive(Clone, Copy)]
2769pub enum AnisoBoundEnd {
2770 Lower,
2771 Upper,
2772}
2773
2774impl SpatialLogKappaCoords {
2775 pub fn new_with_dims(values: Array1<f64>, dims_per_term: Vec<usize>) -> Self {
2777 assert_eq!(
2778 values.len(),
2779 dims_per_term.iter().sum::<usize>(),
2780 "SpatialLogKappaCoords: values length {} != sum of dims_per_term {}",
2781 values.len(),
2782 dims_per_term.iter().sum::<usize>(),
2783 );
2784 Self {
2785 values,
2786 dims_per_term,
2787 }
2788 }
2789
2790 pub fn from_length_scales(
2792 spec: &TermCollectionSpec,
2793 term_indices: &[usize],
2794 options: &SpatialLengthScaleOptimizationOptions,
2795 ) -> Self {
2796 let mut out = Array1::<f64>::zeros(term_indices.len());
2797 for (slot, &term_idx) in term_indices.iter().enumerate() {
2798 if let Some(cc) = constant_curvature_term_spec(spec, term_idx) {
2804 out[slot] = cc.kappa;
2805 continue;
2806 }
2807 let length_scale = get_spatial_length_scale(spec, term_idx)
2808 .unwrap_or(options.min_length_scale)
2809 .clamp(options.min_length_scale, options.max_length_scale);
2810 out[slot] = -length_scale.ln();
2811 }
2812 Self {
2813 values: out,
2814 dims_per_term: vec![1; term_indices.len()],
2815 }
2816 }
2817
2818 pub fn from_length_scales_aniso(
2832 spec: &TermCollectionSpec,
2833 term_indices: &[usize],
2834 options: &SpatialLengthScaleOptimizationOptions,
2835 ) -> Self {
2836 let mut vals = Vec::new();
2837 let mut dims = Vec::new();
2838 for &term_idx in term_indices {
2839 if let Some(mj) = measure_jet_term_spec(spec, term_idx) {
2843 let seed = measure_jet_psi_seed(mj);
2844 dims.push(seed.len());
2845 vals.extend(seed);
2846 continue;
2847 }
2848 if let Some(cc) = constant_curvature_term_spec(spec, term_idx) {
2854 vals.push(cc.kappa);
2855 dims.push(1);
2856 continue;
2857 }
2858 let length_scale = get_spatial_length_scale(spec, term_idx)
2859 .unwrap_or(options.min_length_scale)
2860 .clamp(options.min_length_scale, options.max_length_scale);
2861 let psi_bar = -length_scale.ln(); if spatial_term_uses_per_axis_psi(spec, term_idx) {
2864 let d = get_spatial_feature_dim(spec, term_idx).unwrap_or(1);
2869 let eta_raw = get_spatial_aniso_log_scales(spec, term_idx)
2870 .expect("predicate guarantees aniso_log_scales is Some");
2871 let eta = center_aniso_log_scales(&eta_raw);
2872 for &eta_a in &eta {
2873 vals.push(psi_bar + eta_a);
2874 }
2875 dims.push(d);
2876 } else {
2877 vals.push(psi_bar);
2884 dims.push(1);
2885 }
2886 }
2887 Self {
2888 values: Array1::from_vec(vals),
2889 dims_per_term: dims,
2890 }
2891 }
2892
2893 pub fn lower_bounds_from_data(
2897 data: ArrayView2<'_, f64>,
2898 spec: &TermCollectionSpec,
2899 term_indices: &[usize],
2900 options: &SpatialLengthScaleOptimizationOptions,
2901 ) -> Result<Self, BasisError> {
2902 let mut values = Array1::<f64>::zeros(term_indices.len());
2903 for (slot, &term_idx) in term_indices.iter().enumerate() {
2904 values[slot] = spatial_term_psi_bounds(data, spec, term_idx, options)?.0;
2905 }
2906 Ok(Self {
2907 values,
2908 dims_per_term: vec![1; term_indices.len()],
2909 })
2910 }
2911
2912 pub fn upper_bounds_from_data(
2914 data: ArrayView2<'_, f64>,
2915 spec: &TermCollectionSpec,
2916 term_indices: &[usize],
2917 options: &SpatialLengthScaleOptimizationOptions,
2918 ) -> Result<Self, BasisError> {
2919 let mut values = Array1::<f64>::zeros(term_indices.len());
2920 for (slot, &term_idx) in term_indices.iter().enumerate() {
2921 values[slot] = spatial_term_psi_bounds(data, spec, term_idx, options)?.1;
2922 }
2923 Ok(Self {
2924 values,
2925 dims_per_term: vec![1; term_indices.len()],
2926 })
2927 }
2928
2929 pub fn lower_bounds_aniso_from_data(
2938 data: ArrayView2<'_, f64>,
2939 spec: &TermCollectionSpec,
2940 term_indices: &[usize],
2941 dims_per_term: &[usize],
2942 options: &SpatialLengthScaleOptimizationOptions,
2943 ) -> Result<Self, BasisError> {
2944 Self::aniso_bounds_from_data(
2945 data,
2946 spec,
2947 term_indices,
2948 dims_per_term,
2949 options,
2950 AnisoBoundEnd::Lower,
2951 )
2952 }
2953
2954 pub fn upper_bounds_aniso_from_data(
2958 data: ArrayView2<'_, f64>,
2959 spec: &TermCollectionSpec,
2960 term_indices: &[usize],
2961 dims_per_term: &[usize],
2962 options: &SpatialLengthScaleOptimizationOptions,
2963 ) -> Result<Self, BasisError> {
2964 Self::aniso_bounds_from_data(
2965 data,
2966 spec,
2967 term_indices,
2968 dims_per_term,
2969 options,
2970 AnisoBoundEnd::Upper,
2971 )
2972 }
2973
2974 fn aniso_bounds_from_data(
2978 data: ArrayView2<'_, f64>,
2979 spec: &TermCollectionSpec,
2980 term_indices: &[usize],
2981 dims_per_term: &[usize],
2982 options: &SpatialLengthScaleOptimizationOptions,
2983 end: AnisoBoundEnd,
2984 ) -> Result<Self, BasisError> {
2985 assert_eq!(term_indices.len(), dims_per_term.len());
2986 let total: usize = dims_per_term.iter().sum();
2987 let mut values = Array1::<f64>::zeros(total);
2988 let mut cursor = 0;
2989 for (slot, &term_idx) in term_indices.iter().enumerate() {
2990 let d = dims_per_term[slot];
2991 if let Some(mj) = measure_jet_term_spec(spec, term_idx) {
2994 let bounds = measure_jet_psi_bound_values(mj, matches!(end, AnisoBoundEnd::Upper));
2995 for (offset, bound) in bounds.into_iter().enumerate() {
2996 if offset < d {
2997 values[cursor + offset] = bound;
2998 }
2999 }
3000 cursor += d;
3001 continue;
3002 }
3003 if constant_curvature_term_spec(spec, term_idx).is_some() {
3006 let (lo, hi) = constant_curvature_kappa_bounds(data, spec, term_idx);
3007 if d >= 1 {
3008 values[cursor] = match end {
3009 AnisoBoundEnd::Lower => lo,
3010 AnisoBoundEnd::Upper => hi,
3011 };
3012 }
3013 cursor += d;
3014 continue;
3015 }
3016 let psi_bound = {
3017 let (lo, hi) = spatial_term_psi_bounds(data, spec, term_idx, options)?;
3018 match end {
3019 AnisoBoundEnd::Lower => lo,
3020 AnisoBoundEnd::Upper => hi,
3021 }
3022 };
3023 let axis_offsets = if d <= 1 {
3024 vec![0.0; d]
3025 } else {
3026 get_spatial_aniso_log_scales(spec, term_idx)
3027 .filter(|eta| eta.len() == d)
3028 .map(|eta| center_aniso_log_scales(&eta))
3029 .unwrap_or_else(|| vec![0.0; d])
3030 };
3031 for offset in 0..d {
3032 values[cursor + offset] = psi_bound + axis_offsets[offset];
3033 }
3034 cursor += d;
3035 }
3036 Ok(Self {
3037 values,
3038 dims_per_term: dims_per_term.to_vec(),
3039 })
3040 }
3041
3042 pub fn reseed_from_data(
3051 mut self,
3052 data: ArrayView2<'_, f64>,
3053 spec: &TermCollectionSpec,
3054 term_indices: &[usize],
3055 options: &SpatialLengthScaleOptimizationOptions,
3056 ) -> Result<Self, BasisError> {
3057 assert_eq!(term_indices.len(), self.dims_per_term.len());
3058 let mut cursor = 0;
3059 for (slot, &term_idx) in term_indices.iter().enumerate() {
3060 let d = self.dims_per_term[slot];
3061 if measure_jet_term_spec(spec, term_idx).is_some() {
3064 cursor += d;
3065 continue;
3066 }
3067 if constant_curvature_term_spec(spec, term_idx).is_some() {
3071 cursor += d;
3072 continue;
3073 }
3074 let Some(psi_bar_new) = spatial_term_psi_seed(data, spec, term_idx, options)? else {
3075 cursor += d;
3076 continue;
3077 };
3078 if d == 0 {
3079 continue;
3080 }
3081 let current: Vec<f64> = self.values.slice(s![cursor..cursor + d]).to_vec();
3082 let psi_bar_old = current.iter().sum::<f64>() / d as f64;
3083 for (offset, &old_value) in current.iter().enumerate() {
3084 self.values[cursor + offset] = psi_bar_new + (old_value - psi_bar_old);
3085 }
3086 cursor += d;
3087 }
3088 Ok(self)
3089 }
3090
3091 pub fn clamp_to_bounds(
3102 mut self,
3103 lower: &SpatialLogKappaCoords,
3104 upper: &SpatialLogKappaCoords,
3105 ) -> Self {
3106 assert_eq!(self.values.len(), lower.values.len());
3107 assert_eq!(self.values.len(), upper.values.len());
3108 let mut n_projected = 0usize;
3109 let mut worst_delta = 0.0_f64;
3110 for idx in 0..self.values.len() {
3111 let lo = lower.values[idx];
3112 let hi = upper.values[idx];
3113 if !(lo.is_finite() && hi.is_finite()) {
3114 continue;
3115 }
3116 let v = self.values[idx];
3117 if v < lo {
3118 worst_delta = worst_delta.max(lo - v);
3119 self.values[idx] = lo;
3120 n_projected += 1;
3121 } else if v > hi {
3122 worst_delta = worst_delta.max(v - hi);
3123 self.values[idx] = hi;
3124 n_projected += 1;
3125 }
3126 }
3127 if n_projected > 0 {
3128 log::info!(
3129 "[spatial-kappa] projected {n_projected}/{} ψ seed coords into data-derived bounds \
3130 (worst excess={worst_delta:.3} log units); user length_scale falls outside \
3131 [{KERNEL_RANGE_MIN_DIAMETER_FRACTION}/r_max, {KERNEL_RANGE_MAX_SPACING_MULTIPLE}/r_min] geometry window",
3132 self.values.len()
3133 );
3134 }
3135 self
3136 }
3137
3138 pub fn from_theta_tail_with_dims(
3140 theta: &Array1<f64>,
3141 start: usize,
3142 dims_per_term: Vec<usize>,
3143 ) -> Self {
3144 let total: usize = dims_per_term.iter().sum();
3145 Self {
3146 values: theta.slice(s![start..start + total]).to_owned(),
3147 dims_per_term,
3148 }
3149 }
3150
3151 pub fn len(&self) -> usize {
3153 self.values.len()
3154 }
3155
3156 pub fn dims_per_term(&self) -> &[usize] {
3158 &self.dims_per_term
3159 }
3160
3161 fn term_offset(&self, term_idx: usize) -> usize {
3163 self.dims_per_term[..term_idx].iter().sum()
3164 }
3165
3166 pub fn term_slice(&self, term_idx: usize) -> &[f64] {
3168 let offset = self.term_offset(term_idx);
3169 let d = self.dims_per_term[term_idx];
3170 &self.values.as_slice().unwrap()[offset..offset + d]
3171 }
3172
3173 pub fn as_array(&self) -> &Array1<f64> {
3174 &self.values
3175 }
3176
3177 pub fn set_scalar_slot(&mut self, slot: usize, value: f64) -> bool {
3183 if slot >= self.dims_per_term.len() || self.dims_per_term[slot] != 1 {
3184 return false;
3185 }
3186 let offset = self.term_offset(slot);
3187 self.values[offset] = value;
3188 true
3189 }
3190
3191 pub fn split_at(&self, mid: usize) -> (Self, Self) {
3194 let flat_mid: usize = self.dims_per_term[..mid].iter().sum();
3195 (
3196 Self {
3197 values: self.values.slice(s![0..flat_mid]).to_owned(),
3198 dims_per_term: self.dims_per_term[..mid].to_vec(),
3199 },
3200 Self {
3201 values: self.values.slice(s![flat_mid..]).to_owned(),
3202 dims_per_term: self.dims_per_term[mid..].to_vec(),
3203 },
3204 )
3205 }
3206
3207 pub fn apply_tospec(
3214 &self,
3215 spec: &TermCollectionSpec,
3216 term_indices: &[usize],
3217 ) -> Result<TermCollectionSpec, EstimationError> {
3218 if term_indices.len() != self.dims_per_term.len() {
3219 crate::bail_invalid_estim!(
3220 "SpatialLogKappaCoords::apply_tospec: term count mismatch: \
3221 term_indices={} dims_per_term={}",
3222 term_indices.len(),
3223 self.dims_per_term.len()
3224 );
3225 }
3226 let mut updated = spec.clone();
3227 for (slot, &term_idx) in term_indices.iter().enumerate() {
3228 let psi = self.term_slice(slot);
3229 let d = self.dims_per_term[slot];
3230 if measure_jet_term_spec(&updated, term_idx).is_some() {
3233 set_measure_jet_psi_dials(&mut updated, term_idx, psi)?;
3234 continue;
3235 }
3236 if constant_curvature_term_spec(&updated, term_idx).is_some() {
3240 set_constant_curvature_kappa(&mut updated, term_idx, psi)?;
3241 continue;
3242 }
3243 let (next_length_scale, next_aniso) = spatial_term_psi_to_length_scale_and_aniso(psi);
3244 if (d == 1 || next_length_scale.is_some())
3245 && let Some(length_scale) = next_length_scale
3246 {
3247 set_spatial_length_scale(&mut updated, term_idx, length_scale)?;
3248 }
3249 if let Some(eta) = next_aniso {
3250 set_spatial_aniso_log_scales(&mut updated, term_idx, eta)?;
3251 }
3252 }
3253 Ok(updated)
3254 }
3255}
3256
3257pub fn center_aniso_log_scales(eta: &[f64]) -> Vec<f64> {
3258 if eta.len() <= 1 {
3259 return eta.to_vec();
3260 }
3261 let mean = eta.iter().sum::<f64>() / eta.len() as f64;
3262 eta.iter()
3263 .map(|&v| {
3264 let centered = v - mean;
3265 if centered.abs() <= 1e-15 {
3266 0.0
3267 } else {
3268 centered
3269 }
3270 })
3271 .collect()
3272}
3273
3274pub fn spatial_term_uses_per_axis_psi(resolvedspec: &TermCollectionSpec, term_idx: usize) -> bool {
3277 if let Some(mj) = measure_jet_term_spec(resolvedspec, term_idx) {
3278 return measure_jet_enrolls_psi(mj);
3279 }
3280 let Some(d) = get_spatial_feature_dim(resolvedspec, term_idx) else {
3281 return false;
3282 };
3283 if d <= 1 {
3284 return false;
3285 }
3286 let Some(eta) = get_spatial_aniso_log_scales(resolvedspec, term_idx) else {
3287 return false;
3288 };
3289 if eta.len() != d {
3290 return false;
3291 }
3292 !matches!(
3293 resolvedspec
3294 .smooth_terms
3295 .get(term_idx)
3296 .map(|term| &term.basis),
3297 Some(SmoothBasisSpec::Duchon { .. })
3298 )
3299}
3300
3301pub fn set_spatial_length_scale(
3302 spec: &mut TermCollectionSpec,
3303 term_idx: usize,
3304 length_scale: f64,
3305) -> Result<(), EstimationError> {
3306 let Some(term) = spec.smooth_terms.get_mut(term_idx) else {
3307 crate::bail_invalid_estim!("spatial length-scale term index {term_idx} out of range");
3308 };
3309 match &mut term.basis {
3310 SmoothBasisSpec::ThinPlate { spec, .. } => {
3311 spec.length_scale = length_scale;
3312 Ok(())
3313 }
3314 SmoothBasisSpec::Matern { spec, .. } => {
3315 spec.length_scale.set_resolved(length_scale);
3316 Ok(())
3317 }
3318 SmoothBasisSpec::Duchon { spec, .. } => {
3319 spec.length_scale = Some(length_scale);
3320 Ok(())
3321 }
3322 _ => Err(EstimationError::InvalidInput(format!(
3323 "term '{}' does not expose a spatial length scale",
3324 term.name
3325 ))),
3326 }
3327}
3328
3329pub fn get_spatial_length_scale(spec: &TermCollectionSpec, term_idx: usize) -> Option<f64> {
3330 spec.smooth_terms
3331 .get(term_idx)
3332 .and_then(|term| match &term.basis {
3333 SmoothBasisSpec::ThinPlate { spec, .. } => Some(spec.length_scale),
3334 SmoothBasisSpec::Matern { spec, .. } => spec.length_scale.resolved(),
3335 SmoothBasisSpec::Duchon { spec, .. } => spec.length_scale,
3336 _ => None,
3337 })
3338}
3339
3340pub fn spatial_term_supports_hyper_optimization(
3341 spec: &TermCollectionSpec,
3342 term_idx: usize,
3343) -> bool {
3344 if let Some(term) = spec.smooth_terms.get(term_idx)
3350 && let SmoothBasisSpec::ThinPlate { .. } = &term.basis
3351 {
3352 return false;
3353 }
3354
3355 if let Some(term) = spec.smooth_terms.get(term_idx)
3380 && let SmoothBasisSpec::Matern { .. } = &term.basis
3381 {
3382 return true;
3383 }
3384
3385 if let Some(mj) = measure_jet_term_spec(spec, term_idx) {
3388 return measure_jet_enrolls_psi(mj);
3389 }
3390
3391 if constant_curvature_term_spec(spec, term_idx).is_some() {
3398 return true;
3399 }
3400
3401 get_spatial_length_scale(spec, term_idx).is_some()
3402}
3403
3404pub fn measure_jet_term_spec(
3407 spec: &TermCollectionSpec,
3408 term_idx: usize,
3409) -> Option<&crate::basis::MeasureJetBasisSpec> {
3410 spec.smooth_terms
3411 .get(term_idx)
3412 .and_then(|term| match &term.basis {
3413 SmoothBasisSpec::MeasureJet { spec, .. } => Some(spec),
3414 _ => None,
3415 })
3416}
3417
3418pub fn measure_jet_enrolls_psi(mj: &crate::basis::MeasureJetBasisSpec) -> bool {
3425 measure_jet_learns_length_scale(mj)
3434 || (mj.tau0 > 0.0 && crate::basis::measure_jet_multiscale_mode(mj))
3435}
3436
3437pub fn measure_jet_learns_length_scale(mj: &crate::basis::MeasureJetBasisSpec) -> bool {
3440 mj.learn_length_scale
3441}
3442
3443pub fn freeze_measure_jet_length_scale_learning(spec: &mut TermCollectionSpec) -> usize {
3444 let mut frozen = 0;
3445 for term in spec.smooth_terms.iter_mut() {
3446 if let SmoothBasisSpec::MeasureJet { spec: mj, .. } = &mut term.basis
3447 && mj.learn_length_scale
3448 {
3449 mj.learn_length_scale = false;
3450 frozen += 1;
3451 }
3452 }
3453 frozen
3454}
3455
3456pub const MEASURE_JET_PSI_ALPHA_BOUNDS: (f64, f64) = (-1.0, 3.0);
3464
3465pub const MEASURE_JET_PSI_LN_TAU_BOUNDS: (f64, f64) = (-18.420680743952367, 4.605170185988092);
3466
3467pub const MEASURE_JET_PSI_LN_LENGTH_SCALE_BOUNDS: (f64, f64) =
3473 (-6.907755278982137, 4.605170185988092);
3474
3475pub fn measure_jet_penalty_psi_dim(mj: &crate::basis::MeasureJetBasisSpec) -> usize {
3483 if crate::basis::measure_jet_multiscale_mode(mj) {
3484 2
3485 } else {
3486 0
3487 }
3488}
3489
3490pub fn measure_jet_psi_dim(mj: &crate::basis::MeasureJetBasisSpec) -> usize {
3494 usize::from(measure_jet_learns_length_scale(mj)) + measure_jet_penalty_psi_dim(mj)
3495}
3496
3497pub fn measure_jet_psi_seed(mj: &crate::basis::MeasureJetBasisSpec) -> Vec<f64> {
3502 let mut seed = Vec::with_capacity(measure_jet_psi_dim(mj));
3503 if measure_jet_learns_length_scale(mj) {
3504 let ell = if mj.length_scale > 0.0 {
3508 mj.length_scale
3509 } else {
3510 1.0
3511 };
3512 seed.push(ell.ln());
3513 }
3514 if measure_jet_penalty_psi_dim(mj) > 0 {
3515 let ln_tau = mj.tau0.max(f64::MIN_POSITIVE).ln();
3517 seed.extend_from_slice(&[mj.alpha, ln_tau]);
3518 }
3519 seed
3520}
3521
3522pub fn measure_jet_psi_bound_values(
3525 mj: &crate::basis::MeasureJetBasisSpec,
3526 upper: bool,
3527) -> Vec<f64> {
3528 let pick = |b: (f64, f64)| if upper { b.1 } else { b.0 };
3529 let mut bounds = Vec::with_capacity(measure_jet_psi_dim(mj));
3530 if measure_jet_learns_length_scale(mj) {
3531 bounds.push(pick(MEASURE_JET_PSI_LN_LENGTH_SCALE_BOUNDS));
3532 }
3533 if measure_jet_penalty_psi_dim(mj) > 0 {
3534 bounds.push(pick(MEASURE_JET_PSI_ALPHA_BOUNDS));
3536 bounds.push(pick(MEASURE_JET_PSI_LN_TAU_BOUNDS));
3537 }
3538 bounds
3539}
3540
3541pub fn apply_measure_jet_psi(
3546 mj: &mut crate::basis::MeasureJetBasisSpec,
3547 psi: &[f64],
3548) -> Result<bool, EstimationError> {
3549 if psi.len() != measure_jet_psi_dim(mj) {
3550 crate::bail_invalid_estim!(
3551 "measure-jet ψ write-back dimension mismatch: got {} values for a {}-dial term",
3552 psi.len(),
3553 measure_jet_psi_dim(mj)
3554 );
3555 }
3556 let mut changed = false;
3557 let mut cursor = 0usize;
3561 if measure_jet_learns_length_scale(mj) {
3562 let next_ell = psi[cursor].exp();
3563 cursor += 1;
3564 if !(next_ell.is_finite() && next_ell > 0.0) {
3565 crate::bail_invalid_estim!(
3566 "measure-jet ψ write-back produced a non-finite/non-positive length_scale (ℓ={next_ell})"
3567 );
3568 }
3569 if next_ell != mj.length_scale {
3570 mj.length_scale = next_ell;
3571 changed = true;
3572 }
3573 }
3574 if measure_jet_penalty_psi_dim(mj) > 0 {
3575 let next_alpha = psi[cursor];
3578 let next_tau = psi[cursor + 1].exp();
3579 if !(next_alpha.is_finite() && next_tau.is_finite() && next_tau > 0.0) {
3580 crate::bail_invalid_estim!(
3581 "measure-jet ψ write-back produced non-finite dials (alpha={next_alpha}, tau={next_tau})"
3582 );
3583 }
3584 if next_alpha != mj.alpha {
3585 mj.alpha = next_alpha;
3586 changed = true;
3587 }
3588 if next_tau != mj.tau0 {
3589 mj.tau0 = next_tau;
3590 changed = true;
3591 }
3592 }
3593 Ok(changed)
3594}
3595
3596pub fn set_measure_jet_psi_dials(
3599 spec: &mut TermCollectionSpec,
3600 term_idx: usize,
3601 psi: &[f64],
3602) -> Result<bool, EstimationError> {
3603 let Some(term) = spec.smooth_terms.get_mut(term_idx) else {
3604 crate::bail_invalid_estim!("measure-jet ψ write-back: term index {term_idx} out of range");
3605 };
3606 set_single_term_measure_jet_psi_dials(term, psi)
3607}
3608
3609pub fn set_single_term_measure_jet_psi_dials(
3614 term: &mut SmoothTermSpec,
3615 psi: &[f64],
3616) -> Result<bool, EstimationError> {
3617 let SmoothBasisSpec::MeasureJet { spec: mj, .. } = &mut term.basis else {
3618 crate::bail_invalid_estim!("measure-jet ψ write-back targeted a non-measure-jet term");
3619 };
3620 apply_measure_jet_psi(mj, psi)
3621}
3622
3623pub fn constant_curvature_term_spec(
3626 spec: &TermCollectionSpec,
3627 term_idx: usize,
3628) -> Option<&crate::basis::ConstantCurvatureBasisSpec> {
3629 spec.smooth_terms
3630 .get(term_idx)
3631 .and_then(|term| match &term.basis {
3632 SmoothBasisSpec::ConstantCurvature { spec, .. } => Some(spec),
3633 _ => None,
3634 })
3635}
3636
3637pub const CONSTANT_CURVATURE_KAPPA_CHART_FRACTION: f64 = 0.5;
3645
3646pub const CONSTANT_CURVATURE_MIN_CHART_RADIUS2: f64 = 1e-8;
3650
3651pub fn constant_curvature_kappa_bounds(
3656 data: ArrayView2<'_, f64>,
3657 spec: &TermCollectionSpec,
3658 term_idx: usize,
3659) -> (f64, f64) {
3660 let feature_cols = match spec.smooth_terms.get(term_idx).map(|t| &t.basis) {
3661 Some(SmoothBasisSpec::ConstantCurvature { feature_cols, .. }) => feature_cols,
3662 _ => return (-1.0, 1.0),
3663 };
3664 let mut max_r2 = CONSTANT_CURVATURE_MIN_CHART_RADIUS2;
3665 for row in data.outer_iter() {
3666 let mut r2 = 0.0_f64;
3667 for &c in feature_cols.iter() {
3668 if let Some(&v) = row.get(c)
3669 && v.is_finite()
3670 {
3671 r2 += v * v;
3672 }
3673 }
3674 if r2 > max_r2 {
3675 max_r2 = r2;
3676 }
3677 }
3678 let half = CONSTANT_CURVATURE_KAPPA_CHART_FRACTION / max_r2;
3679 (-half, half)
3680}
3681
3682pub fn set_constant_curvature_kappa(
3686 spec: &mut TermCollectionSpec,
3687 term_idx: usize,
3688 psi: &[f64],
3689) -> Result<bool, EstimationError> {
3690 let Some(term) = spec.smooth_terms.get_mut(term_idx) else {
3691 crate::bail_invalid_estim!(
3692 "constant-curvature κ write-back: term index {term_idx} out of range"
3693 );
3694 };
3695 set_single_term_constant_curvature_kappa(term, psi)
3696}
3697
3698pub fn set_single_term_constant_curvature_kappa(
3703 term: &mut SmoothTermSpec,
3704 psi: &[f64],
3705) -> Result<bool, EstimationError> {
3706 if psi.len() != 1 {
3707 crate::bail_invalid_estim!(
3708 "constant-curvature κ write-back expects exactly one value, got {}",
3709 psi.len()
3710 );
3711 }
3712 let next_kappa = psi[0];
3713 if !next_kappa.is_finite() {
3714 crate::bail_invalid_estim!(
3715 "constant-curvature κ write-back produced a non-finite κ = {next_kappa}"
3716 );
3717 }
3718 let SmoothBasisSpec::ConstantCurvature { spec: cc, .. } = &mut term.basis else {
3719 crate::bail_invalid_estim!(
3720 "constant-curvature κ write-back targeted a non-constant-curvature term"
3721 );
3722 };
3723 if cc.kappa != next_kappa {
3724 cc.kappa = next_kappa;
3725 Ok(true)
3726 } else {
3727 Ok(false)
3728 }
3729}
3730
3731pub fn spatial_term_has_locked_kappa(spec: &TermCollectionSpec, term_idx: usize) -> bool {
3742 let explicitly_fixed = spec
3743 .smooth_terms
3744 .get(term_idx)
3745 .is_some_and(|term| match &term.basis {
3746 SmoothBasisSpec::Matern { spec, .. } => spec.length_scale.is_fixed(),
3747 SmoothBasisSpec::ThinPlate { .. } => true,
3748 SmoothBasisSpec::Duchon { spec, .. } => spec.length_scale.is_some(),
3749 _ => false,
3750 });
3751 explicitly_fixed && !spatial_term_uses_per_axis_psi(spec, term_idx)
3752}
3753
3754pub fn all_spatial_terms_kappa_fixed(spec: &TermCollectionSpec) -> bool {
3755 spec.smooth_terms.iter().enumerate().all(|(idx, _)| {
3756 !spatial_term_supports_hyper_optimization(spec, idx)
3757 || spatial_term_has_locked_kappa(spec, idx)
3758 })
3759}
3760
3761pub fn spatial_identifiability_policy(
3762 termspec: &SmoothTermSpec,
3763) -> Option<&SpatialIdentifiability> {
3764 match &termspec.basis {
3765 SmoothBasisSpec::ThinPlate { spec, .. } => Some(&spec.identifiability),
3766 SmoothBasisSpec::Duchon { spec, .. } => Some(&spec.identifiability),
3767 _ => None,
3768 }
3769}
3770
3771pub const NULLSPACE_WELLDET_DEGENERACY_RHO_SD: f64 = 15.0;
3775
3776pub fn is_nullspace_degeneracy_prior(prior: &gam_spec::RhoPrior) -> bool {
3779 matches!(
3780 prior,
3781 gam_spec::RhoPrior::Normal { mean, sd }
3782 if *mean == 0.0 && *sd == NULLSPACE_WELLDET_DEGENERACY_RHO_SD
3783 )
3784}
3785
3786pub const KERNEL_RANGE_MIN_DIAMETER_FRACTION: f64 = 2.0;
3798
3799pub const KERNEL_RANGE_MAX_SPACING_MULTIPLE: f64 = 1e2;
3804
3805fn spatial_term_stored_input_scale(term: &SmoothTermSpec) -> Option<crate::IsotropicScale> {
3806 match &term.basis {
3807 SmoothBasisSpec::ThinPlate { input_scale, .. }
3808 | SmoothBasisSpec::Matern { input_scale, .. }
3809 | SmoothBasisSpec::Duchon { input_scale, .. } => *input_scale,
3810 _ => None,
3811 }
3812}
3813
3814fn spatial_term_realized_input_scale(
3815 data: ArrayView2<'_, f64>,
3816 term: &SmoothTermSpec,
3817) -> Result<crate::IsotropicScale, BasisError> {
3818 let (feature_cols, stored) = match &term.basis {
3819 SmoothBasisSpec::ThinPlate {
3820 feature_cols,
3821 input_scale,
3822 ..
3823 }
3824 | SmoothBasisSpec::Matern {
3825 feature_cols,
3826 input_scale,
3827 ..
3828 }
3829 | SmoothBasisSpec::Duchon {
3830 feature_cols,
3831 input_scale,
3832 ..
3833 } => (feature_cols, input_scale),
3834 _ => {
3835 return Err(BasisError::InvalidInput(format!(
3836 "term '{}' does not have an isotropic Euclidean input frame",
3837 term.name
3838 )));
3839 }
3840 };
3841 if let Some(scale) = stored {
3842 return Ok(*scale);
3843 }
3844 let x = select_columns(data, feature_cols)?;
3845 estimate_isotropic_scale(x.view())
3846}
3847
3848pub fn spatial_term_psi_bounds(
3855 data: ArrayView2<'_, f64>,
3856 spec: &TermCollectionSpec,
3857 term_idx: usize,
3858 options: &SpatialLengthScaleOptimizationOptions,
3859) -> Result<(f64, f64), BasisError> {
3860 let options_window = (
3861 -options.max_length_scale.ln(),
3862 -options.min_length_scale.ln(),
3863 );
3864 if constant_curvature_term_spec(spec, term_idx).is_some() {
3869 return Ok(constant_curvature_kappa_bounds(data, spec, term_idx));
3870 }
3871 let term = spec.smooth_terms.get(term_idx).ok_or_else(|| {
3872 BasisError::InvalidInput(format!(
3873 "spatial term index {term_idx} is out of bounds for {} smooth terms",
3874 spec.smooth_terms.len()
3875 ))
3876 })?;
3877 let aniso = get_spatial_aniso_log_scales(spec, term_idx);
3890 let stored_input_scale = spatial_term_stored_input_scale(term);
3891 let input_scale = spatial_term_realized_input_scale(data, term)?;
3892 let r_bounds = match spatial_term_center_strategy(term) {
3893 Some(CenterStrategy::UserProvided(centers)) if centers.nrows() >= 2 => {
3894 let mut centers_in_frame = centers.clone();
3895 if stored_input_scale.is_none() {
3896 input_scale.standardize(&mut centers_in_frame);
3897 }
3898 let bounds = match aniso.as_deref() {
3899 Some(eta) if eta.len() == centers_in_frame.ncols() => {
3900 let y = points_in_aniso_y_space(centers_in_frame.view(), eta);
3901 pairwise_distance_bounds(y.view())
3902 }
3903 _ => pairwise_distance_bounds(centers_in_frame.view()),
3904 };
3905 bounds
3906 }
3907 _ => {
3908 let x = standardized_spatial_term_data(data, term)?;
3909 match aniso.as_deref() {
3910 Some(eta) if eta.len() == x.ncols() => {
3911 let y = points_in_aniso_y_space(x.view(), eta);
3912 pairwise_distance_bounds_sampled(y.view())
3913 }
3914 _ => pairwise_distance_bounds_sampled(x.view()),
3915 }
3916 }
3917 };
3918 let (r_min, r_max) = r_bounds.ok_or_else(|| {
3919 BasisError::InvalidInput(format!(
3920 "term '{}' has no positive finite pairwise-distance range",
3921 term.name
3922 ))
3923 })?;
3924 let inverse_sigma = input_scale.reciprocal();
3941 let psi_chart_offset = inverse_sigma.ln();
3942 let psi_lo_data = (KERNEL_RANGE_MIN_DIAMETER_FRACTION / r_max).ln() + psi_chart_offset;
3943 let psi_hi_data = (KERNEL_RANGE_MAX_SPACING_MULTIPLE / r_min).ln() + psi_chart_offset;
3944 let psi_lo = psi_lo_data.max(options_window.0);
3954 let psi_hi = psi_hi_data.min(options_window.1);
3955 if psi_lo >= psi_hi {
3956 return Err(BasisError::InvalidInput(format!(
3957 "term '{}' has an empty spatial ψ window after intersecting data bounds [{psi_lo_data}, {psi_hi_data}] with configured bounds [{}, {}]",
3958 term.name, options_window.0, options_window.1
3959 )));
3960 }
3961 Ok((psi_lo, psi_hi))
3962}
3963
3964#[cfg(test)]
3965mod spatial_psi_bound_coordinate_tests {
3966 use super::*;
3967 use crate::basis::{MaternIdentifiability, MaternNu};
3968 use ndarray::array;
3969
3970 fn frozen_matern_bounds(theta: f64, dilation: f64) -> (f64, f64) {
3971 let source = array![
3972 [-1.7, -0.4],
3973 [-1.1, 0.8],
3974 [-0.2, -1.3],
3975 [0.5, 1.6],
3976 [1.4, -0.7],
3977 [2.1, 0.5],
3978 ];
3979 let (cos_theta, sin_theta) = (theta.cos(), theta.sin());
3980 let mut data = Array2::<f64>::zeros(source.raw_dim());
3981 for row in 0..source.nrows() {
3982 let x = source[[row, 0]];
3983 let y = source[[row, 1]];
3984 data[[row, 0]] = dilation * (cos_theta * x - sin_theta * y);
3985 data[[row, 1]] = dilation * (sin_theta * x + cos_theta * y);
3986 }
3987 let input_scale = estimate_isotropic_scale(data.view()).expect("isotropic input scale");
3988 let mut centers = data.clone();
3989 input_scale.standardize(&mut centers);
3990 let spec = TermCollectionSpec {
3991 linear_terms: Vec::new(),
3992 random_effect_terms: Vec::new(),
3993 smooth_terms: vec![SmoothTermSpec {
3994 name: "matern".to_string(),
3995 basis: SmoothBasisSpec::Matern {
3996 feature_cols: vec![0, 1],
3997 spec: MaternBasisSpec {
3998 periodic: None,
3999 center_strategy: CenterStrategy::UserProvided(centers),
4000 length_scale: crate::basis::MaternLengthScale::fixed(1.0),
4001 nu: MaternNu::FiveHalves,
4002 include_intercept: false,
4003 double_penalty: true,
4004 identifiability: MaternIdentifiability::CenterSumToZero,
4005 aniso_log_scales: None,
4006 },
4007 input_scale: Some(input_scale),
4008 },
4009 shape: ShapeConstraint::None,
4010 joint_null_rotation: None,
4011 }],
4012 };
4013 spatial_term_psi_bounds(
4014 data.view(),
4015 &spec,
4016 0,
4017 &SpatialLengthScaleOptimizationOptions::default(),
4018 )
4019 .expect("finite spatial ψ bounds")
4020 }
4021
4022 fn assert_close(left: f64, right: f64) {
4023 assert!(
4024 (left - right).abs() <= 1e-12,
4025 "coordinate-equivalent bounds differ: left={left:.16e}, right={right:.16e}"
4026 );
4027 }
4028
4029 #[test]
4030 fn standardized_center_bounds_return_to_original_units_under_rotation_and_scaling() {
4031 let base = frozen_matern_bounds(0.0, 1.0);
4032 let rotated = frozen_matern_bounds(0.61, 1.0);
4033 assert_close(rotated.0, base.0);
4034 assert_close(rotated.1, base.1);
4035
4036 let dilation = 4.0_f64;
4037 let rotated_scaled = frozen_matern_bounds(0.61, dilation);
4038 let expected_shift = dilation.ln();
4039 assert_close(rotated_scaled.0, base.0 - expected_shift);
4040 assert_close(rotated_scaled.1, base.1 - expected_shift);
4041 }
4042}
4043
4044pub fn spatial_term_psi_seed(
4048 data: ArrayView2<'_, f64>,
4049 spec: &TermCollectionSpec,
4050 term_idx: usize,
4051 options: &SpatialLengthScaleOptimizationOptions,
4052) -> Result<Option<f64>, BasisError> {
4053 if get_spatial_length_scale(spec, term_idx).is_some() {
4054 return Ok(None); }
4056 let (psi_lo, psi_hi) = spatial_term_psi_bounds(data, spec, term_idx, options)?;
4057 Ok(Some(0.5 * (psi_lo + psi_hi)))
4058}
4059
4060pub fn spatial_term_psi_to_length_scale_and_aniso(psi: &[f64]) -> (Option<f64>, Option<Vec<f64>>) {
4061 if psi.len() <= 1 {
4062 (Some((-psi.first().copied().unwrap_or(0.0)).exp()), None)
4063 } else {
4064 let psi_bar = psi.iter().sum::<f64>() / psi.len() as f64;
4065 (
4066 Some((-psi_bar).exp()),
4067 Some(psi.iter().map(|&value| value - psi_bar).collect()),
4068 )
4069 }
4070}
4071
4072pub fn get_spatial_aniso_log_scales(
4074 spec: &TermCollectionSpec,
4075 term_idx: usize,
4076) -> Option<Vec<f64>> {
4077 spec.smooth_terms
4078 .get(term_idx)
4079 .and_then(|term| match &term.basis {
4080 SmoothBasisSpec::Matern { spec, .. } => spec.aniso_log_scales.clone(),
4081 SmoothBasisSpec::Duchon { spec, .. } => spec.aniso_log_scales.clone(),
4082 _ => None,
4083 })
4084}
4085
4086pub fn response_aware_axis_contrasts(
4106 x: ndarray::ArrayView2<'_, f64>,
4107 y: ndarray::ArrayView1<'_, f64>,
4108) -> Option<Vec<f64>> {
4109 let n = x.nrows();
4110 let d = x.ncols();
4111 if d <= 1 || n < 4 || y.len() != n {
4112 return None;
4113 }
4114 if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
4115 return None;
4116 }
4117 let mut scores = Vec::with_capacity(d);
4118 for a in 0..d {
4119 let mut order: Vec<usize> = (0..n).collect();
4120 let col = x.column(a);
4121 order.sort_by(|&i, &j| {
4122 col[i]
4123 .partial_cmp(&col[j])
4124 .unwrap_or(std::cmp::Ordering::Equal)
4125 });
4126 let mut tv = 0.0_f64;
4127 for w in order.windows(2) {
4128 let diff = y[w[1]] - y[w[0]];
4129 tv += diff * diff;
4130 }
4131 scores.push(-0.5 * (tv + 1e-12).ln());
4133 }
4134 if scores.iter().any(|v| !v.is_finite()) {
4135 return None;
4136 }
4137 let mean = scores.iter().sum::<f64>() / d as f64;
4138 let centered: Vec<f64> = scores.iter().map(|&s| s - mean).collect();
4139 if centered.iter().all(|&v| v.abs() < 1e-9) {
4142 return None;
4143 }
4144 Some(centered)
4145}
4146
4147pub fn apply_response_aware_anisotropy_seed(
4156 data: ArrayView2<'_, f64>,
4157 y: ndarray::ArrayView1<'_, f64>,
4158 spec: &mut TermCollectionSpec,
4159 spatial_terms: &[usize],
4160) {
4161 const MAX_NUDGE: f64 = std::f64::consts::LN_2;
4166 for &term_idx in spatial_terms {
4167 let Some(current_eta) = get_spatial_aniso_log_scales(spec, term_idx) else {
4168 continue;
4169 };
4170 let d = current_eta.len();
4171 if d <= 1 {
4172 continue;
4173 }
4174 let Some(term) = spec.smooth_terms.get(term_idx) else {
4175 continue;
4176 };
4177 let feature_cols = term.basis.structural_feature_cols();
4178 if feature_cols.len() != d {
4179 continue;
4180 }
4181 let Ok(x) = select_columns(data, &feature_cols) else {
4182 continue;
4183 };
4184 let Some(contrast) = response_aware_axis_contrasts(x.view(), y) else {
4185 continue;
4186 };
4187 let nudged: Vec<f64> = current_eta
4188 .iter()
4189 .zip(contrast.iter())
4190 .map(|(&eta_a, &c_a)| eta_a + c_a.clamp(-MAX_NUDGE, MAX_NUDGE))
4191 .collect();
4192 if let Err(err) = set_spatial_aniso_log_scales(spec, term_idx, nudged) {
4195 log::debug!(
4196 "[spatial-kappa] response-aware anisotropy seed skipped for term {term_idx}: {err}"
4197 );
4198 }
4199 }
4200}
4201
4202pub fn get_spatial_feature_dim(spec: &TermCollectionSpec, term_idx: usize) -> Option<usize> {
4204 spec.smooth_terms
4205 .get(term_idx)
4206 .and_then(|term| match &term.basis {
4207 SmoothBasisSpec::ThinPlate { feature_cols, .. } => Some(feature_cols.len()),
4208 SmoothBasisSpec::Matern { feature_cols, .. } => Some(feature_cols.len()),
4209 SmoothBasisSpec::Duchon { feature_cols, .. } => Some(feature_cols.len()),
4210 _ => None,
4211 })
4212}
4213
4214pub fn log_spatial_aniso_scales(spec: &TermCollectionSpec) {
4221 for (term_idx, term) in spec.smooth_terms.iter().enumerate() {
4222 let (aniso, length_scale) = match &term.basis {
4223 SmoothBasisSpec::Matern { spec, .. } => {
4224 (spec.aniso_log_scales.as_ref(), spec.length_scale.resolved())
4225 }
4226 SmoothBasisSpec::Duchon { spec, .. } => {
4227 (spec.aniso_log_scales.as_ref(), spec.length_scale)
4228 }
4229 _ => (None, None),
4230 };
4231 let Some(eta) = aniso else { continue };
4232 if eta.is_empty() {
4233 continue;
4234 }
4235 let mut lines = match length_scale {
4236 Some(ls) => format!(
4237 "[spatial-kappa] term {} (\"{}\"): anisotropic length scales optimized (global length_scale={:.4})",
4238 term_idx, term.name, ls
4239 ),
4240 None => format!(
4241 "[spatial-kappa] term {} (\"{}\"): pure Duchon shape anisotropy optimized",
4242 term_idx, term.name
4243 ),
4244 };
4245 for (a, &eta_a) in eta.iter().enumerate() {
4246 if let Some(ls) = length_scale {
4247 let length_a = ls * (-eta_a).exp();
4248 let kappa_a = (1.0 / ls) * eta_a.exp();
4249 lines.push_str(&format!(
4250 "\n axis {}: eta={:+.4}, length={:.4}, kappa={:.4}",
4251 a, eta_a, length_a, kappa_a
4252 ));
4253 } else {
4254 lines.push_str(&format!("\n axis {}: eta={:+.4}", a, eta_a));
4255 }
4256 }
4257 log::info!("{}", lines);
4258 }
4259}
4260
4261pub fn set_spatial_aniso_log_scales(
4263 spec: &mut TermCollectionSpec,
4264 term_idx: usize,
4265 eta: Vec<f64>,
4266) -> Result<(), EstimationError> {
4267 let eta = center_aniso_log_scales(&eta);
4268 let Some(term) = spec.smooth_terms.get_mut(term_idx) else {
4269 crate::bail_invalid_estim!("spatial aniso_log_scales term index {term_idx} out of range");
4270 };
4271 match &mut term.basis {
4272 SmoothBasisSpec::Matern { spec, .. } => {
4273 spec.aniso_log_scales = Some(eta);
4274 Ok(())
4275 }
4276 SmoothBasisSpec::Duchon { spec, .. } => {
4277 spec.aniso_log_scales = Some(eta);
4278 Ok(())
4279 }
4280 _ => Err(EstimationError::InvalidInput(format!(
4281 "term '{}' does not support aniso_log_scales",
4282 term.name
4283 ))),
4284 }
4285}
4286
4287pub fn sync_aniso_contrasts_from_metadata(spec: &mut TermCollectionSpec, design: &SmoothDesign) {
4294 for (term_idx, term) in design.terms.iter().enumerate() {
4295 let meta_aniso = match &term.metadata {
4296 BasisMetadata::Matern {
4297 aniso_log_scales, ..
4298 } => aniso_log_scales.clone(),
4299 BasisMetadata::Duchon {
4300 aniso_log_scales, ..
4301 } => aniso_log_scales.clone(),
4302 _ => None,
4303 };
4304 if let Some(eta) = meta_aniso
4305 && eta.len() > 1
4306 {
4307 set_spatial_aniso_log_scales(spec, term_idx, eta).ok();
4308 }
4309 }
4310}
4311
4312#[derive(Debug, Clone)]
4313pub struct SpatialLengthScaleOptimizationOptions {
4314 pub enabled: bool,
4318 pub max_outer_iter: usize,
4320 pub rel_tol: f64,
4322 pub log_step: f64,
4324 pub min_length_scale: f64,
4326 pub max_length_scale: f64,
4328 pub pilot_subsample_threshold: usize,
4341}
4342
4343impl Default for SpatialLengthScaleOptimizationOptions {
4344 fn default() -> Self {
4345 Self {
4346 enabled: true,
4347 max_outer_iter: 80,
4348 rel_tol: 1e-4,
4349 log_step: std::f64::consts::LN_2,
4350 min_length_scale: 1e-3,
4351 max_length_scale: 1e3,
4352 pilot_subsample_threshold: 10_000,
4353 }
4354 }
4355}
4356
4357impl SpatialLengthScaleOptimizationOptions {
4358 pub fn validate(&self) -> Result<(), String> {
4376 if !self.min_length_scale.is_finite() || self.min_length_scale <= 0.0 {
4377 return Err(SmoothError::invalid_config(format!(
4378 "SpatialLengthScaleOptimizationOptions::min_length_scale must be > 0 and finite, got {}",
4379 self.min_length_scale
4380 ))
4381 .into());
4382 }
4383 if !self.max_length_scale.is_finite() || self.max_length_scale <= 0.0 {
4384 return Err(SmoothError::invalid_config(format!(
4385 "SpatialLengthScaleOptimizationOptions::max_length_scale must be > 0 and finite, got {}",
4386 self.max_length_scale
4387 ))
4388 .into());
4389 }
4390 if self.min_length_scale >= self.max_length_scale {
4391 return Err(SmoothError::invalid_config(format!(
4392 "SpatialLengthScaleOptimizationOptions requires min_length_scale < max_length_scale, got min={} max={}",
4393 self.min_length_scale, self.max_length_scale
4394 ))
4395 .into());
4396 }
4397 if !self.rel_tol.is_finite() || self.rel_tol <= 0.0 {
4398 return Err(SmoothError::invalid_config(format!(
4399 "SpatialLengthScaleOptimizationOptions::rel_tol must be > 0 and finite, got {}",
4400 self.rel_tol
4401 ))
4402 .into());
4403 }
4404 if !self.log_step.is_finite() || self.log_step <= 0.0 {
4405 return Err(SmoothError::invalid_config(format!(
4406 "SpatialLengthScaleOptimizationOptions::log_step must be > 0 and finite, got {}",
4407 self.log_step
4408 ))
4409 .into());
4410 }
4411 Ok(())
4412 }
4413}
4414
4415#[derive(Debug, Clone)]
4416pub struct RandomEffectBlock {
4417 pub name: String,
4418 pub group_ids: Vec<Option<usize>>,
4421 pub num_groups: usize,
4422 pub kept_levels: Vec<u64>,
4423}
4424
4425pub const BLOCK_SPARSE_ZERO_EPS: f64 = 1e-12;
4426
4427pub const BLOCK_SPARSE_MAX_DENSITY: f64 = 0.20;
4428
4429pub fn blocks_have_intrinsic_sparse_structure(blocks: &[DesignBlock]) -> bool {
4430 blocks
4431 .iter()
4432 .any(|block| matches!(block, DesignBlock::Sparse(_) | DesignBlock::RandomEffect(_)))
4433}
4434
4435pub fn sparse_compatible_block_nnz(block: &DesignBlock) -> Option<usize> {
4436 match block {
4437 DesignBlock::Intercept(n) => Some(*n),
4438 DesignBlock::RandomEffect(op) => {
4439 Some(op.group_ids.iter().filter(|gid| gid.is_some()).count())
4440 }
4441 DesignBlock::Sparse(sparse) => Some(sparse.val().len()),
4442 DesignBlock::Dense(dense) => dense.as_dense_ref().map(|matrix| {
4443 matrix
4444 .iter()
4445 .filter(|&&value| value.abs() > BLOCK_SPARSE_ZERO_EPS)
4446 .count()
4447 }),
4448 }
4449}
4450
4451pub fn try_build_sparse_design_from_blocks(
4452 blocks: &[DesignBlock],
4453) -> Result<Option<DesignMatrix>, BasisError> {
4454 if blocks.is_empty() {
4455 return Ok(None);
4456 }
4457 let nrows = blocks[0].nrows();
4458 let ncols: usize = blocks.iter().map(DesignBlock::ncols).sum();
4459 if nrows == 0 || ncols == 0 || ncols <= 32 {
4460 return Ok(None);
4461 }
4462
4463 let preserve_sparse_storage = blocks_have_intrinsic_sparse_structure(blocks);
4464 let sparse_nnz_limit = if preserve_sparse_storage {
4465 usize::MAX
4466 } else {
4467 let total_cells = nrows.saturating_mul(ncols);
4468 ((total_cells as f64) * BLOCK_SPARSE_MAX_DENSITY).floor() as usize
4469 };
4470 let mut nnz = 0usize;
4471 for block in blocks {
4472 let block_nnz = if let Some(block_nnz) = sparse_compatible_block_nnz(block) {
4473 block_nnz
4474 } else {
4475 return Ok(None);
4476 };
4477 nnz = nnz.saturating_add(block_nnz);
4478 if nnz > sparse_nnz_limit {
4479 return Ok(None);
4480 }
4481 }
4482
4483 let mut triplets = Vec::<Triplet<usize, usize, f64>>::with_capacity(nnz);
4484 let mut col_offset = 0usize;
4485 for block in blocks {
4486 match block {
4487 DesignBlock::Intercept(n) => {
4488 for row in 0..*n {
4489 triplets.push(Triplet::new(row, col_offset, 1.0));
4490 }
4491 }
4492 DesignBlock::RandomEffect(op) => {
4493 for (row, group_id) in op.group_ids.iter().enumerate() {
4494 if let Some(group) = group_id {
4495 triplets.push(Triplet::new(row, col_offset + group, 1.0));
4496 }
4497 }
4498 }
4499 DesignBlock::Sparse(sparse) => {
4500 let (symbolic, values) = sparse.parts();
4501 let col_ptr = symbolic.col_ptr();
4502 let row_idx = symbolic.row_idx();
4503 for col in 0..sparse.ncols() {
4504 for idx in col_ptr[col]..col_ptr[col + 1] {
4505 let value = values[idx];
4506 if value.abs() > BLOCK_SPARSE_ZERO_EPS {
4507 triplets.push(Triplet::new(row_idx[idx], col_offset + col, value));
4508 }
4509 }
4510 }
4511 }
4512 DesignBlock::Dense(dense) => {
4513 let matrix = dense.as_dense_ref().ok_or_else(|| {
4514 BasisError::InvalidInput(
4515 "sparse-compatible block assembly requires materialized dense blocks"
4516 .to_string(),
4517 )
4518 })?;
4519 for row in 0..matrix.nrows() {
4520 for col in 0..matrix.ncols() {
4521 let value = matrix[[row, col]];
4522 if value.abs() > BLOCK_SPARSE_ZERO_EPS {
4523 triplets.push(Triplet::new(row, col_offset + col, value));
4524 }
4525 }
4526 }
4527 }
4528 }
4529 col_offset += block.ncols();
4530 }
4531
4532 let sparse = SparseColMat::try_new_from_triplets(nrows, ncols, &triplets).map_err(|_| {
4533 BasisError::SparseCreation("failed to assemble sparse term-collection design".to_string())
4534 })?;
4535 Ok(Some(DesignMatrix::Sparse(
4536 gam_linalg::matrix::SparseDesignMatrix::new(sparse),
4537 )))
4538}
4539
4540pub fn assemble_term_collection_design_matrix(
4541 blocks: Vec<DesignBlock>,
4542) -> Result<DesignMatrix, BasisError> {
4543 if let Some(sparse) = try_build_sparse_design_from_blocks(&blocks)? {
4544 return Ok(sparse);
4545 }
4546 let block_op = BlockDesignOperator::new(blocks).map_err(|e| {
4547 BasisError::InvalidInput(format!("failed to build block design operator: {e}"))
4548 })?;
4549 Ok(DesignMatrix::Dense(
4550 gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(block_op)),
4551 ))
4552}
4553
4554pub fn select_columns(
4555 data: ArrayView2<'_, f64>,
4556 cols: &[usize],
4557) -> Result<Array2<f64>, BasisError> {
4558 let n = data.nrows();
4559 let p = data.ncols();
4560 for &c in cols {
4561 if c >= p {
4562 crate::bail_dim_basis!("feature column {c} is out of bounds for data with {p} columns");
4563 }
4564 }
4565 let mut out = Array2::<f64>::zeros((n, cols.len()));
4566 for (j, &c) in cols.iter().enumerate() {
4567 out.column_mut(j).assign(&data.column(c));
4568 }
4569 Ok(out)
4570}
4571
4572pub fn nonfinite_value_label(value: f64) -> &'static str {
4573 if value.is_nan() {
4574 "NaN"
4575 } else if value.is_sign_positive() {
4576 "+Inf"
4577 } else {
4578 "-Inf"
4579 }
4580}
4581
4582pub fn validate_term_feature_column_finite(
4583 data: ArrayView2<'_, f64>,
4584 term_kind: &str,
4585 term_name: &str,
4586 feature_col: usize,
4587) -> Result<(), BasisError> {
4588 let p = data.ncols();
4589 if feature_col >= p {
4590 crate::bail_dim_basis!(
4591 "{term_kind} term '{term_name}' feature column {feature_col} out of bounds for {p} columns"
4592 );
4593 }
4594 for (row, &value) in data.column(feature_col).iter().enumerate() {
4595 if !value.is_finite() {
4596 crate::bail_invalid_basis!(
4597 "{term_kind} term '{term_name}' feature column {feature_col} row {row} contains non-finite value {}",
4598 nonfinite_value_label(value)
4599 );
4600 }
4601 }
4602 Ok(())
4603}
4604
4605pub fn validate_smooth_terms_finite_inputs(
4606 data: ArrayView2<'_, f64>,
4607 terms: &[SmoothTermSpec],
4608) -> Result<(), BasisError> {
4609 for term in terms {
4610 for feature_col in smooth_term_feature_cols(term) {
4611 validate_term_feature_column_finite(data, "smooth", &term.name, feature_col)?;
4612 }
4613 }
4614 Ok(())
4615}
4616
4617pub fn validate_term_collection_finite_inputs(
4618 data: ArrayView2<'_, f64>,
4619 spec: &TermCollectionSpec,
4620) -> Result<(), BasisError> {
4621 for term in &spec.linear_terms {
4622 validate_term_feature_column_finite(data, "linear", &term.name, term.feature_col)?;
4623 }
4624 for term in &spec.random_effect_terms {
4625 validate_term_feature_column_finite(data, "random-effect", &term.name, term.feature_col)?;
4626 }
4627 validate_smooth_terms_finite_inputs(data, &spec.smooth_terms)
4628}
4629
4630#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
4631pub struct JointSpatialCenterGroupKey {
4632 feature_cols: Vec<usize>,
4633 strategy_kind: CenterStrategyKind,
4634 strategy_aux: usize,
4635 requested_num_centers: usize,
4636 input_scale_bits: Option<u64>,
4637}
4638
4639pub fn spatial_term_min_center_count(term: &SmoothTermSpec) -> usize {
4640 match &term.basis {
4641 SmoothBasisSpec::ThinPlate { feature_cols, .. } => feature_cols.len() + 1,
4642 SmoothBasisSpec::Duchon {
4643 feature_cols, spec, ..
4644 } => match spec.nullspace_order {
4645 crate::basis::DuchonNullspaceOrder::Zero => 1,
4646 crate::basis::DuchonNullspaceOrder::Linear => feature_cols.len() + 1,
4647 crate::basis::DuchonNullspaceOrder::Degree(degree) => {
4648 crate::basis::duchon_nullspace_dimension(feature_cols.len(), degree)
4649 }
4650 },
4651 SmoothBasisSpec::Matern { .. } => 1,
4652 _ => 1,
4653 }
4654}
4655
4656pub fn spatial_term_group_key(term: &SmoothTermSpec) -> Option<JointSpatialCenterGroupKey> {
4657 let (feature_cols, strategy, input_scale) = match &term.basis {
4658 SmoothBasisSpec::ThinPlate {
4659 feature_cols,
4660 spec,
4661 input_scale,
4662 } => (feature_cols, &spec.center_strategy, *input_scale),
4663 SmoothBasisSpec::Matern {
4664 feature_cols,
4665 spec,
4666 input_scale,
4667 } => (feature_cols, &spec.center_strategy, *input_scale),
4668 SmoothBasisSpec::Duchon {
4669 feature_cols,
4670 spec,
4671 input_scale,
4672 } => (feature_cols, &spec.center_strategy, *input_scale),
4673 _ => return None,
4674 };
4675 let strategy_kind = center_strategy_kind(strategy);
4676 let strategy_aux = match strategy {
4677 CenterStrategy::Auto(inner) => match inner.as_ref() {
4678 CenterStrategy::KMeans { max_iter, .. } => *max_iter,
4679 CenterStrategy::UniformGrid { points_per_dim } => *points_per_dim,
4680 _ => 0,
4681 },
4682 CenterStrategy::KMeans { max_iter, .. } => *max_iter,
4683 CenterStrategy::UniformGrid { points_per_dim } => *points_per_dim,
4684 _ => 0,
4685 };
4686 Some(JointSpatialCenterGroupKey {
4687 feature_cols: feature_cols.clone(),
4688 strategy_kind,
4689 strategy_aux,
4690 requested_num_centers: strategy.planned_num_centers(feature_cols.len()),
4691 input_scale_bits: input_scale.map(crate::IsotropicScale::to_bits),
4692 })
4693}
4694
4695pub fn spatial_term_center_strategy(term: &SmoothTermSpec) -> Option<&CenterStrategy> {
4696 match &term.basis {
4697 SmoothBasisSpec::ThinPlate { spec, .. } => Some(&spec.center_strategy),
4698 SmoothBasisSpec::Matern { spec, .. } => Some(&spec.center_strategy),
4699 SmoothBasisSpec::Duchon { spec, .. } => Some(&spec.center_strategy),
4700 _ => None,
4701 }
4702}
4703
4704pub fn set_spatial_term_centers(
4705 term: &mut SmoothTermSpec,
4706 centers: Array2<f64>,
4707) -> Result<(), BasisError> {
4708 match &mut term.basis {
4709 SmoothBasisSpec::ThinPlate { spec, .. } => {
4710 spec.center_strategy = CenterStrategy::UserProvided(centers);
4711 Ok(())
4712 }
4713 SmoothBasisSpec::Matern { spec, .. } => {
4714 spec.center_strategy = CenterStrategy::UserProvided(centers);
4715 Ok(())
4716 }
4717 SmoothBasisSpec::Duchon { spec, .. } => {
4718 spec.center_strategy = CenterStrategy::UserProvided(centers);
4719 Ok(())
4720 }
4721 _ => Err(BasisError::InvalidInput(format!(
4722 "term '{}' does not support spatial center planning",
4723 term.name
4724 ))),
4725 }
4726}
4727
4728pub fn standardized_spatial_term_data(
4729 data: ArrayView2<'_, f64>,
4730 term: &SmoothTermSpec,
4731) -> Result<Array2<f64>, BasisError> {
4732 let (feature_cols, input_scale) = match &term.basis {
4733 SmoothBasisSpec::ThinPlate {
4734 feature_cols,
4735 input_scale,
4736 ..
4737 }
4738 | SmoothBasisSpec::Matern {
4739 feature_cols,
4740 input_scale,
4741 ..
4742 }
4743 | SmoothBasisSpec::Duchon {
4744 feature_cols,
4745 input_scale,
4746 ..
4747 } => (feature_cols, *input_scale),
4748 _ => {
4749 crate::bail_invalid_basis!("term '{}' is not a spatial smooth", term.name);
4750 }
4751 };
4752 let mut x = select_columns(data, feature_cols)?;
4753 input_scale
4754 .map_or_else(|| estimate_isotropic_scale(x.view()), Ok)?
4755 .standardize(&mut x);
4756 Ok(x)
4757}
4758
4759pub fn plan_joint_spatial_centers_for_term_blocks(
4760 data: ArrayView2<'_, f64>,
4761 term_blocks: &[Vec<SmoothTermSpec>],
4762) -> Result<Vec<Vec<SmoothTermSpec>>, BasisError> {
4763 let mut planned_blocks = term_blocks.to_vec();
4764 let n = data.nrows();
4765 let mut groups: BTreeMap<JointSpatialCenterGroupKey, Vec<(usize, usize)>> = BTreeMap::new();
4766
4767 for (block_idx, terms) in planned_blocks.iter().enumerate() {
4768 for (term_idx, term) in terms.iter().enumerate() {
4769 let Some(strategy) = spatial_term_center_strategy(term) else {
4770 continue;
4771 };
4772 if !center_strategy_is_auto(strategy) {
4773 continue;
4774 }
4775 let Some(group_key) = spatial_term_group_key(term) else {
4776 continue;
4777 };
4778 if !matches!(
4779 group_key.strategy_kind,
4780 CenterStrategyKind::EqualMass
4781 | CenterStrategyKind::EqualMassCovarRepresentative
4782 | CenterStrategyKind::FarthestPoint
4783 | CenterStrategyKind::KMeans
4784 | CenterStrategyKind::UniformGrid
4785 ) {
4786 continue;
4787 }
4788 groups
4789 .entry(group_key)
4790 .or_default()
4791 .push((block_idx, term_idx));
4792 }
4793 }
4794
4795 for (group_key, members) in groups {
4796 if members.len() < 2 {
4797 continue;
4798 }
4799 let min_required = members
4800 .iter()
4801 .map(|&(block_idx, term_idx)| {
4802 spatial_term_min_center_count(&planned_blocks[block_idx][term_idx])
4803 })
4804 .max()
4805 .unwrap_or(1);
4806 let joint_centers = group_key
4807 .requested_num_centers
4808 .max(min_required)
4809 .min(n.max(1));
4810 let (first_block_idx, first_term_idx) = members[0];
4811 let prototype = &planned_blocks[first_block_idx][first_term_idx];
4812 let standardized = standardized_spatial_term_data(data, prototype)?;
4813 let strategy = spatial_term_center_strategy(prototype).ok_or_else(|| {
4814 BasisError::InvalidInput(format!(
4815 "term '{}' lost its spatial center strategy during joint planning",
4816 prototype.name
4817 ))
4818 })?;
4819 let joint_strategy = center_strategy_with_num_centers(
4820 strategy,
4821 joint_centers,
4822 group_key.feature_cols.len(),
4823 )?;
4824 let shared_centers = select_centers_by_strategy(standardized.view(), &joint_strategy)?;
4825 log::info!(
4826 "sharing {} spatial centers across {} smooth terms over columns {:?} (requested {} centers)",
4827 shared_centers.nrows(),
4828 members.len(),
4829 group_key.feature_cols,
4830 group_key.requested_num_centers,
4831 );
4832 for (block_idx, term_idx) in members {
4833 set_spatial_term_centers(
4834 &mut planned_blocks[block_idx][term_idx],
4835 shared_centers.clone(),
4836 )?;
4837 }
4838 }
4839
4840 for block in planned_blocks.iter_mut() {
4845 for term in block.iter_mut() {
4846 auto_init_length_scale_in_place(data, term);
4847 }
4848 }
4849
4850 Ok(planned_blocks)
4851}
4852
4853const AUTO_LENGTH_SCALE_FLOOR: f64 = 1e-6;
4856
4857fn feature_columns_max_range(data: ArrayView2<'_, f64>, feature_cols: &[usize]) -> Option<f64> {
4860 let mut max_range = 0.0_f64;
4861 for &c in feature_cols {
4862 if c >= data.ncols() {
4863 continue;
4864 }
4865 let col = data.column(c);
4866 let mut lo = f64::INFINITY;
4867 let mut hi = f64::NEG_INFINITY;
4868 for &v in col.iter() {
4869 if v.is_finite() {
4870 if v < lo {
4871 lo = v;
4872 }
4873 if v > hi {
4874 hi = v;
4875 }
4876 }
4877 }
4878 if hi > lo {
4879 let r = hi - lo;
4880 if r > max_range {
4881 max_range = r;
4882 }
4883 }
4884 }
4885 if max_range.is_finite() && max_range > 0.0 {
4886 Some(max_range)
4887 } else {
4888 None
4889 }
4890}
4891
4892fn feature_columns_rotation_invariant_range(
4903 data: ArrayView2<'_, f64>,
4904 feature_cols: &[usize],
4905) -> Option<f64> {
4906 let cols: Vec<usize> = feature_cols
4907 .iter()
4908 .copied()
4909 .filter(|&c| c < data.ncols())
4910 .collect();
4911 if cols.is_empty() {
4912 return None;
4913 }
4914 let mut points: Vec<Vec<f64>> = data
4915 .rows()
4916 .into_iter()
4917 .filter_map(|row| {
4918 let point: Vec<f64> = cols.iter().map(|&column| row[column]).collect();
4919 point.iter().all(|value| value.is_finite()).then_some(point)
4920 })
4921 .collect();
4922 if points.is_empty() {
4923 return None;
4924 }
4925 points.sort_by(|left, right| {
4926 left.iter()
4927 .zip(right)
4928 .find_map(|(a, b)| {
4929 let ordering = a.total_cmp(b);
4930 ordering.is_ne().then_some(ordering)
4931 })
4932 .unwrap_or(std::cmp::Ordering::Equal)
4933 });
4934
4935 let dimensions = cols.len();
4936 let count = points.len() as f64;
4937 let mut centroid = vec![0.0_f64; dimensions];
4938 for point in &points {
4939 for (coordinate, value) in centroid.iter_mut().zip(point) {
4940 *coordinate += *value;
4941 }
4942 }
4943 for coordinate in &mut centroid {
4944 *coordinate /= count;
4945 }
4946
4947 let mut covariance = Array2::<f64>::zeros((dimensions, dimensions));
4948 for point in &points {
4949 for row in 0..dimensions {
4950 let centered_row = point[row] - centroid[row];
4951 for column in 0..=row {
4952 covariance[[row, column]] += centered_row * (point[column] - centroid[column]);
4953 }
4954 }
4955 }
4956 for row in 0..dimensions {
4957 for column in 0..=row {
4958 let value = covariance[[row, column]] / count;
4959 covariance[[row, column]] = value;
4960 covariance[[column, row]] = value;
4961 }
4962 }
4963
4964 use gam_linalg::faer_ndarray::FaerEigh;
4965 let (eigenvalues, _) = covariance
4966 .eigh(faer::Side::Lower)
4967 .expect("finite covariance must have a symmetric eigendecomposition");
4968 let leading_variance = eigenvalues[eigenvalues.len() - 1];
4969 let extent = (12.0 * leading_variance).sqrt();
4970 if extent.is_finite() && extent > 0.0 {
4971 Some(extent)
4972 } else {
4973 None
4974 }
4975}
4976
4977pub fn auto_initial_length_scale(data: ArrayView2<'_, f64>, feature_cols: &[usize]) -> f64 {
4984 let n = data.nrows();
4985 if n == 0 || feature_cols.is_empty() {
4986 return 1.0;
4987 }
4988 let Some(max_range) = feature_columns_max_range(data, feature_cols) else {
4989 return 1.0;
4990 };
4991 let init = max_range / (n as f64).sqrt();
4992 init.max(AUTO_LENGTH_SCALE_FLOOR).min(max_range)
4993}
4994
4995pub fn auto_initial_length_scale_for_centers(
5018 data: ArrayView2<'_, f64>,
5019 feature_cols: &[usize],
5020 num_centers: usize,
5021) -> f64 {
5022 let n = data.nrows();
5023 if n == 0 || feature_cols.is_empty() {
5024 return 1.0;
5025 }
5026 let Some(max_range) = feature_columns_rotation_invariant_range(data, feature_cols) else {
5037 return 1.0;
5038 };
5039 let resolution_points = n.max(num_centers).max(1) as f64;
5045 let spacing = max_range / resolution_points.sqrt();
5046 spacing.max(AUTO_LENGTH_SCALE_FLOOR).min(max_range)
5047}
5048
5049pub fn matern_low_rank_center_resolution_length_scale(
5059 data: ArrayView2<'_, f64>,
5060 feature_cols: &[usize],
5061 num_centers: usize,
5062) -> Option<f64> {
5063 if data.nrows() == 0 || feature_cols.is_empty() || num_centers == 0 {
5064 return None;
5065 }
5066 let extent = feature_columns_rotation_invariant_range(data, feature_cols)?;
5067 let length_scale = extent / (num_centers as f64).sqrt();
5068 Some(length_scale.max(AUTO_LENGTH_SCALE_FLOOR).min(extent))
5069}
5070
5071pub fn auto_initial_length_scale_for_low_rank_centers(
5081 data: ArrayView2<'_, f64>,
5082 feature_cols: &[usize],
5083 num_centers: usize,
5084) -> f64 {
5085 if data.nrows() == 0 || feature_cols.is_empty() {
5086 return 1.0;
5087 }
5088 let Some(max_range) = feature_columns_max_range(data, feature_cols) else {
5089 return 1.0;
5090 };
5091 let resolution_points = num_centers.max(1) as f64;
5092 let spacing = max_range / resolution_points.sqrt();
5093 spacing.max(AUTO_LENGTH_SCALE_FLOOR).min(max_range)
5094}
5095
5096fn center_strategy_requested_count(strategy: &CenterStrategy) -> Option<usize> {
5099 match strategy {
5100 CenterStrategy::Auto(inner) => center_strategy_requested_count(inner),
5101 CenterStrategy::UserProvided(centers) => Some(centers.nrows()),
5102 CenterStrategy::EqualMass { num_centers }
5103 | CenterStrategy::EqualMassCovarRepresentative { num_centers }
5104 | CenterStrategy::FarthestPoint { num_centers }
5105 | CenterStrategy::KMeans { num_centers, .. } => Some(*num_centers),
5106 CenterStrategy::UniformGrid { .. } => None,
5107 }
5108}
5109
5110pub fn auto_init_length_scale_in_place(data: ArrayView2<'_, f64>, term: &mut SmoothTermSpec) {
5114 auto_init_length_scale_in_basis(data, &mut term.basis);
5115}
5116
5117pub fn auto_init_length_scale_in_basis(data: ArrayView2<'_, f64>, basis: &mut SmoothBasisSpec) {
5129 match basis {
5130 SmoothBasisSpec::Matern {
5131 feature_cols, spec, ..
5132 } => {
5133 if spec.length_scale.resolved().is_none() {
5134 let resolved = match center_strategy_requested_count(&spec.center_strategy) {
5143 Some(k) => auto_initial_length_scale_for_centers(data, feature_cols, k),
5144 None => auto_initial_length_scale(data, feature_cols),
5145 };
5146 spec.length_scale.resolve_auto_once(resolved);
5147 }
5148 }
5149 SmoothBasisSpec::ThinPlate {
5150 feature_cols, spec, ..
5151 } => {
5152 if spec.length_scale == 0.0 {
5153 spec.length_scale = match center_strategy_requested_count(&spec.center_strategy) {
5154 Some(k) => {
5155 auto_initial_length_scale_for_low_rank_centers(data, feature_cols, k)
5156 }
5157 None => auto_initial_length_scale(data, feature_cols),
5158 };
5159 }
5160 }
5161 SmoothBasisSpec::ByVariable { inner, .. }
5162 | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
5163 auto_init_length_scale_in_basis(data, inner);
5164 }
5165 SmoothBasisSpec::BySmooth { smooth, .. } => {
5166 auto_init_length_scale_in_basis(data, smooth);
5167 }
5168 _ => {}
5169 }
5170}
5171
5172impl LinearFitConditioning {
5173 pub fn from_columns(design: &TermCollectionDesign, selected_cols: &[usize]) -> Self {
5174 const SCALE_EPS: f64 = 1e-12;
5175 let n = design.design.nrows();
5176 let p = design.design.ncols();
5177 let mut columns = Vec::with_capacity(selected_cols.len());
5178 if n == 0 || selected_cols.is_empty() {
5179 return Self {
5180 intercept_idx: design.intercept_range.start,
5181 columns,
5182 };
5183 }
5184 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, p);
5185 let mut sums = vec![0.0_f64; selected_cols.len()];
5191 for start in (0..n).step_by(chunk_rows) {
5192 let end = (start + chunk_rows).min(n);
5193 let chunk = design
5194 .design
5195 .try_row_chunk(start..end)
5196 .expect("LinearFitConditioning::from_columns row chunk failed");
5197 for (k, &col_idx) in selected_cols.iter().enumerate() {
5198 let column = chunk.column(col_idx);
5199 for &v in column.iter() {
5200 sums[k] += v;
5201 }
5202 }
5203 }
5204 let inv_n = 1.0_f64 / n as f64;
5205 let means: Vec<f64> = sums.iter().map(|&s| s * inv_n).collect();
5206 let mut sq_devs = vec![0.0_f64; selected_cols.len()];
5207 for start in (0..n).step_by(chunk_rows) {
5208 let end = (start + chunk_rows).min(n);
5209 let chunk = design
5210 .design
5211 .try_row_chunk(start..end)
5212 .expect("LinearFitConditioning::from_columns row chunk failed");
5213 for (k, &col_idx) in selected_cols.iter().enumerate() {
5214 let mean_k = means[k];
5215 let column = chunk.column(col_idx);
5216 for &v in column.iter() {
5217 let d = v - mean_k;
5218 sq_devs[k] += d * d;
5219 }
5220 }
5221 }
5222 for (k, &col_idx) in selected_cols.iter().enumerate() {
5223 let mean = means[k];
5224 let var = sq_devs[k] * inv_n;
5225 let (mean, scale) = if var.is_finite() && var > SCALE_EPS * SCALE_EPS {
5226 (mean, var.sqrt())
5227 } else {
5228 (0.0, 1.0)
5231 };
5232 columns.push(LinearColumnConditioning {
5233 col_idx,
5234 mean,
5235 scale,
5236 });
5237 }
5238 Self {
5239 intercept_idx: design.intercept_range.start,
5240 columns,
5241 }
5242 }
5243
5244 pub fn apply_to_design(&self, design: &Array2<f64>) -> Array2<f64> {
5245 let mut out = design.clone();
5246 for col in &self.columns {
5247 {
5248 let mut dst = out.column_mut(col.col_idx);
5249 dst -= col.mean;
5250 }
5251 if col.scale != 1.0 {
5252 out.column_mut(col.col_idx).mapv_inplace(|v| v / col.scale);
5253 }
5254 }
5255 out
5256 }
5257
5258 fn transform_matrix_columnswith_a(&self, mat: &Array2<f64>) -> Array2<f64> {
5259 let mut out = mat.clone();
5260 let intercept = self.intercept_idx;
5261 for col in &self.columns {
5262 let intercept_col = out.column(intercept).to_owned();
5263 let mut target = out.column_mut(col.col_idx);
5264 target -= &(intercept_col * col.mean);
5265 if col.scale != 1.0 {
5266 target.mapv_inplace(|v| v / col.scale);
5267 }
5268 }
5269 out
5270 }
5271
5272 fn transform_matrixrowswith_a_transpose(&self, mat: &Array2<f64>) -> Array2<f64> {
5273 let mut out = mat.clone();
5274 let intercept = self.intercept_idx;
5275 for col in &self.columns {
5276 let interceptrow = out.row(intercept).to_owned();
5277 let mut target = out.row_mut(col.col_idx);
5278 target -= &(interceptrow * col.mean);
5279 if col.scale != 1.0 {
5280 target.mapv_inplace(|v| v / col.scale);
5281 }
5282 }
5283 out
5284 }
5285
5286 fn left_multiply_by_m_inv_transpose(&self, mat_internal: &Array2<f64>) -> Array2<f64> {
5291 let mut out = mat_internal.clone();
5292 let intercept = self.intercept_idx;
5293 let interceptrow_snapshot = mat_internal.row(intercept).to_owned();
5294 for col in &self.columns {
5295 if col.scale != 1.0 {
5296 out.row_mut(col.col_idx).mapv_inplace(|v| v * col.scale);
5297 }
5298 if col.mean != 0.0 {
5299 let mut target = out.row_mut(col.col_idx);
5300 target += &(&interceptrow_snapshot * col.mean);
5301 }
5302 }
5303 out
5304 }
5305
5306 fn right_multiply_by_m_inv(&self, mat_internal: &Array2<f64>) -> Array2<f64> {
5309 let mut out = mat_internal.clone();
5310 let intercept = self.intercept_idx;
5311 let intercept_col_snapshot = mat_internal.column(intercept).to_owned();
5312 for col in &self.columns {
5313 if col.scale != 1.0 {
5314 out.column_mut(col.col_idx).mapv_inplace(|v| v * col.scale);
5315 }
5316 if col.mean != 0.0 {
5317 let mut target = out.column_mut(col.col_idx);
5318 target += &(&intercept_col_snapshot * col.mean);
5319 }
5320 }
5321 out
5322 }
5323
5324 pub fn transform_blockwise_penalties_to_internal(
5331 &self,
5332 penalties: &[BlockwisePenalty],
5333 p: usize,
5334 ) -> Vec<crate::penalty_spec::PenaltySpec> {
5335 let conditioning_cols: std::collections::HashSet<usize> =
5336 self.columns.iter().map(|c| c.col_idx).collect();
5337 penalties
5338 .iter()
5339 .map(|bp| {
5340 let overlaps =
5341 (bp.col_range.start..bp.col_range.end).any(|j| conditioning_cols.contains(&j));
5342 if overlaps {
5343 let global = bp.to_global(p);
5346 let right = self.transform_matrix_columnswith_a(&global);
5347 let transformed = self.transform_matrixrowswith_a_transpose(&right);
5348 crate::penalty_spec::PenaltySpec::Dense(transformed)
5349 } else {
5350 crate::penalty_spec::PenaltySpec::from_blockwise(bp.clone())
5353 }
5354 })
5355 .collect()
5356 }
5357
5358 pub fn backtransform_beta(&self, beta_internal: &Array1<f64>) -> Array1<f64> {
5359 let mut beta = beta_internal.clone();
5360 let intercept = self.intercept_idx;
5361 for col in &self.columns {
5362 beta[intercept] -= beta_internal[col.col_idx] * col.mean / col.scale;
5363 beta[col.col_idx] = beta_internal[col.col_idx] / col.scale;
5364 }
5365 beta
5366 }
5367
5368 pub fn transform_penalized_hessian_to_original(&self, h_internal: &Array2<f64>) -> Array2<f64> {
5371 let right = self.right_multiply_by_m_inv(h_internal);
5372 self.left_multiply_by_m_inv_transpose(&right)
5373 }
5374
5375 pub fn internal_bounds_for(&self, col_idx: usize, min: f64, max: f64) -> (f64, f64) {
5376 if let Some(col) = self.columns.iter().find(|c| c.col_idx == col_idx) {
5377 (min * col.scale, max * col.scale)
5378 } else {
5379 (min, max)
5380 }
5381 }
5382}
5383
5384pub fn freeze_raw_spatial_metadata(metadata: BasisMetadata, raw_cols: usize) -> BasisMetadata {
5385 match metadata {
5386 BasisMetadata::ThinPlate {
5387 centers,
5388 length_scale,
5389 periodic,
5390 identifiability_transform: None,
5391 input_scale,
5392 radial_reparam,
5393 } => BasisMetadata::ThinPlate {
5394 centers,
5395 length_scale,
5396 periodic,
5397 identifiability_transform: Some(Array2::eye(raw_cols)),
5398 input_scale,
5399 radial_reparam,
5400 },
5401 BasisMetadata::Duchon {
5402 centers,
5403 length_scale,
5404 periodic,
5405 power,
5406 nullspace_order,
5407 identifiability_transform: None,
5408 input_scale,
5409 aniso_log_scales,
5410 operator_collocation_points,
5411 radial_reparam,
5412 } => BasisMetadata::Duchon {
5413 centers,
5414 length_scale,
5415 periodic,
5416 power,
5417 nullspace_order,
5418 identifiability_transform: Some(Array2::eye(raw_cols)),
5419 input_scale,
5420 aniso_log_scales,
5421 operator_collocation_points,
5422 radial_reparam,
5423 },
5424 other => other,
5425 }
5426}
5427
5428pub fn matern_operator_penalty_triplet_from_metadata(
5429 metadata: &BasisMetadata,
5430) -> Result<crate::basis::FilteredPenalties, BasisError> {
5431 let BasisMetadata::Matern {
5432 centers,
5433 length_scale,
5434 periodic,
5435 nu,
5436 include_intercept,
5437 identifiability_transform,
5438 aniso_log_scales,
5439 input_scale,
5440 ..
5441 } = metadata
5442 else {
5443 crate::bail_invalid_basis!("Matérn operator penalties require Matérn metadata");
5444 };
5445 let penalty_length_scale = input_scale.to_standardized_units(*length_scale);
5457 matern_operator_penalty_triplet_at_length_scale(
5458 centers.view(),
5459 periodic.as_deref(),
5460 identifiability_transform.as_ref(),
5461 *nu,
5462 *include_intercept,
5463 aniso_log_scales.as_deref(),
5464 penalty_length_scale,
5465 )
5466}
5467
5468pub fn matern_operator_penalty_triplet_at_length_scale(
5486 centers: ArrayView2<'_, f64>,
5487 periodic: Option<&[Option<f64>]>,
5488 identifiability_transform: Option<&Array2<f64>>,
5489 nu: crate::basis::MaternNu,
5490 include_intercept: bool,
5491 aniso_log_scales: Option<&[f64]>,
5492 effective_length_scale: f64,
5493) -> Result<crate::basis::FilteredPenalties, BasisError> {
5494 let penalty_centers = crate::basis::expand_periodic_centers(¢ers.to_owned(), periodic)?;
5495 let ops = build_matern_collocation_operator_matrices(
5496 penalty_centers.view(),
5497 None,
5498 effective_length_scale,
5499 nu,
5500 include_intercept,
5501 identifiability_transform.map(|z| z.view()),
5502 aniso_log_scales,
5503 )?;
5504 const ORDER_EPS: f64 = 1e-9;
5511 let d = penalty_centers.ncols();
5512 let m = nu.half_integer_value() + 0.5 * d as f64;
5513 let mut candidates = Vec::with_capacity(3);
5514 for (raw, source, min_order) in [
5515 (ops.d0.t().dot(&ops.d0), PenaltySource::OperatorMass, 0.0),
5516 (ops.d1.t().dot(&ops.d1), PenaltySource::OperatorTension, 1.0),
5517 (
5518 ops.d2.t().dot(&ops.d2),
5519 PenaltySource::OperatorStiffness,
5520 2.0,
5521 ),
5522 ] {
5523 let nondifferentiable_ou = matches!(nu, crate::basis::MaternNu::Half);
5524 if min_order > 0.0 && (nondifferentiable_ou || m + ORDER_EPS < min_order) {
5525 continue;
5526 }
5527 let sym = (&raw + &raw.t()) * 0.5;
5528 let (matrix, normalization_scale) = normalize_penalty_in_constrained_space(&sym);
5529 candidates.push(PenaltyCandidate {
5530 matrix: ConstructiveQuadratic::try_from_dense_psd(
5531 matrix,
5532 "Matérn operator penalty",
5533 )?,
5534 source,
5535 normalization_scale,
5536 kronecker_factors: None,
5537 op: None,
5538 });
5539 }
5540 filter_penalty_candidates(candidates)
5541}
5542
5543pub fn normalize_penalty_in_constrained_space(matrix: &Array2<f64>) -> (Array2<f64>, f64) {
5544 let matrix = (matrix + &matrix.t().to_owned()) * 0.5;
5549 let matrix = crate::basis::project_penalty_to_psd_cone(&matrix);
5551 let c = matrix.iter().map(|v| v * v).sum::<f64>().sqrt();
5552 if c.is_finite() && c > 0.0 {
5553 (matrix.mapv(|v| v / c), c)
5554 } else {
5555 (matrix, 1.0)
5556 }
5557}
5558
5559pub fn tensor_product_design_from_sparse_marginals(
5560 marginal_sparse: &[&SparseColMat<usize, f64>],
5561) -> Result<SparseColMat<usize, f64>, BasisError> {
5562 if marginal_sparse.is_empty() {
5563 crate::bail_invalid_basis!("TensorBSpline requires at least one marginal basis");
5564 }
5565 let n = marginal_sparse[0].nrows();
5566 for (i, m) in marginal_sparse.iter().enumerate().skip(1) {
5567 if m.nrows() != n {
5568 crate::bail_dim_basis!(
5569 "tensor sparse marginal row mismatch at dim {i}: expected {n}, got {}",
5570 m.nrows()
5571 );
5572 }
5573 }
5574 let dims: Vec<usize> = marginal_sparse.iter().map(|m| m.ncols()).collect();
5575 let total_cols = dims.iter().try_fold(1usize, |acc, &q| {
5576 acc.checked_mul(q)
5577 .ok_or_else(|| BasisError::DimensionMismatch("tensor basis too large".to_string()))
5578 })?;
5579 let mut strides = vec![1usize; dims.len()];
5580 for d in (0..dims.len().saturating_sub(1)).rev() {
5581 strides[d] = strides[d + 1]
5582 .checked_mul(dims[d + 1])
5583 .ok_or_else(|| BasisError::DimensionMismatch("tensor basis too large".to_string()))?;
5584 }
5585
5586 use faer::sparse::SparseRowMat;
5587 let csrs: Vec<SparseRowMat<usize, f64>> = marginal_sparse
5588 .iter()
5589 .enumerate()
5590 .map(|(d, m)| {
5591 m.as_ref().to_row_major().map_err(|e| {
5592 BasisError::SparseCreation(format!(
5593 "tensor sparse marginal {d} CSR conversion failed: {e:?}"
5594 ))
5595 })
5596 })
5597 .collect::<Result<Vec<_>, _>>()?;
5598 let row_ptrs: Vec<&[usize]> = csrs.iter().map(|c| c.symbolic().row_ptr()).collect();
5599 let col_idxs: Vec<&[usize]> = csrs.iter().map(|c| c.symbolic().col_idx()).collect();
5600 let vals: Vec<&[f64]> = csrs.iter().map(|c| c.val()).collect();
5601
5602 use rayon::prelude::*;
5603 const CHUNK: usize = 1024;
5604 let num_chunks = n.div_ceil(CHUNK);
5605 let per_chunk: Vec<Vec<Triplet<usize, usize, f64>>> = (0..num_chunks)
5606 .into_par_iter()
5607 .map(|chunk_idx| {
5608 let row_start = chunk_idx * CHUNK;
5609 let row_end = (row_start + CHUNK).min(n);
5610 let mut chunk_triplets = Vec::<Triplet<usize, usize, f64>>::new();
5611 let mut cur_cols = Vec::<usize>::with_capacity(64);
5612 let mut cur_vals = Vec::<f64>::with_capacity(64);
5613 let mut next_cols = Vec::<usize>::with_capacity(64);
5614 let mut next_vals = Vec::<f64>::with_capacity(64);
5615 for i in row_start..row_end {
5616 cur_cols.clear();
5617 cur_vals.clear();
5618 cur_cols.push(0);
5619 cur_vals.push(1.0);
5620 let mut row_is_zero = false;
5621 for d in 0..dims.len() {
5622 let row_start_d = row_ptrs[d][i];
5623 let row_end_d = row_ptrs[d][i + 1];
5624 if row_start_d == row_end_d {
5625 row_is_zero = true;
5626 break;
5627 }
5628 let stride = strides[d];
5629 next_cols.clear();
5630 next_vals.clear();
5631 next_cols.reserve(cur_cols.len() * (row_end_d - row_start_d));
5632 next_vals.reserve(cur_vals.len() * (row_end_d - row_start_d));
5633 for (&prev_col, &prev_val) in cur_cols.iter().zip(cur_vals.iter()) {
5634 for ptr in row_start_d..row_end_d {
5635 let cj = col_idxs[d][ptr];
5636 let vj = vals[d][ptr];
5637 next_cols.push(prev_col + cj * stride);
5638 next_vals.push(prev_val * vj);
5639 }
5640 }
5641 std::mem::swap(&mut cur_cols, &mut next_cols);
5642 std::mem::swap(&mut cur_vals, &mut next_vals);
5643 }
5644 if row_is_zero {
5645 continue;
5646 }
5647 for (&col, &val) in cur_cols.iter().zip(cur_vals.iter()) {
5648 chunk_triplets.push(Triplet::new(i, col, val));
5649 }
5650 }
5651 chunk_triplets
5652 })
5653 .collect();
5654 let total_nnz: usize = per_chunk.iter().map(Vec::len).sum();
5655 let mut triplets = Vec::<Triplet<usize, usize, f64>>::with_capacity(total_nnz);
5656 for chunk in per_chunk {
5657 triplets.extend(chunk);
5658 }
5659 SparseColMat::try_new_from_triplets(n, total_cols, &triplets).map_err(|e| {
5660 BasisError::SparseCreation(format!(
5661 "failed to assemble sparse tensor product design: {e:?}"
5662 ))
5663 })
5664}
5665
5666pub fn dense_local_margin_to_sparse(
5667 dense: &Array2<f64>,
5668) -> Result<SparseColMat<usize, f64>, BasisError> {
5669 let expected_row_nnz = dense.ncols().min(4);
5670 let mut triplets =
5671 Vec::<Triplet<usize, usize, f64>>::with_capacity(dense.nrows() * expected_row_nnz);
5672 for ((row, col), &value) in dense.indexed_iter() {
5673 if value != 0.0 {
5674 triplets.push(Triplet::new(row, col, value));
5675 }
5676 }
5677 SparseColMat::try_new_from_triplets(dense.nrows(), dense.ncols(), &triplets).map_err(|e| {
5678 BasisError::SparseCreation(format!(
5679 "failed to convert tensor marginal design to sparse form: {e:?}"
5680 ))
5681 })
5682}
5683
5684pub struct TensorMarginRangeNullProjectors {
5685 range: Array2<f64>,
5686 null: Array2<f64>,
5687}
5688
5689pub fn projector_from_columns(columns: &Array2<f64>, indices: &[usize]) -> Array2<f64> {
5690 if indices.is_empty() {
5691 return Array2::<f64>::zeros((columns.nrows(), columns.nrows()));
5692 }
5693 let basis = columns.select(Axis(1), indices);
5694 basis.dot(&basis.t())
5695}
5696
5697pub fn tensor_margin_range_null_projectors(
5698 normalized_marginal_penalties: &[(Array2<f64>, f64)],
5699) -> Result<Vec<TensorMarginRangeNullProjectors>, BasisError> {
5700 normalized_marginal_penalties
5701 .iter()
5702 .enumerate()
5703 .map(|(dim, (penalty, _))| {
5704 let analysis = crate::basis::analyze_penalty_block(penalty)?;
5705 if analysis.rank == 0 {
5706 crate::bail_invalid_basis!(
5707 "t2 separable tensor penalty margin {dim} has rank-zero penalty; \
5708 cannot split penalized and null subspaces"
5709 );
5710 }
5711 let mut range_idx = Vec::<usize>::new();
5712 let mut null_idx = Vec::<usize>::new();
5713 for (idx, &ev) in analysis.eigenvalues.iter().enumerate() {
5714 if ev > analysis.tol {
5715 range_idx.push(idx);
5716 } else {
5717 null_idx.push(idx);
5718 }
5719 }
5720 Ok(TensorMarginRangeNullProjectors {
5721 range: projector_from_columns(&analysis.eigenvectors, &range_idx),
5722 null: projector_from_columns(&analysis.eigenvectors, &null_idx),
5723 })
5724 })
5725 .collect()
5726}
5727
5728pub fn build_tensor_bspline_basis(
5729 data: ArrayView2<'_, f64>,
5730 feature_cols: &[usize],
5731 spec: &TensorBSplineSpec,
5732) -> Result<BasisBuildResult, BasisError> {
5733 if feature_cols.is_empty() {
5734 crate::bail_invalid_basis!("TensorBSpline requires at least one feature column");
5735 }
5736 if feature_cols.len() != spec.marginalspecs.len() {
5737 crate::bail_dim_basis!(
5738 "TensorBSpline feature/spec mismatch: feature_cols={}, marginalspecs={}",
5739 feature_cols.len(),
5740 spec.marginalspecs.len()
5741 );
5742 }
5743 if let Some((margin, _)) = spec
5744 .marginalspecs
5745 .iter()
5746 .enumerate()
5747 .find(|(_, marginal)| marginal.boundary_conditions.has_nonzero_anchor())
5748 {
5749 crate::bail_invalid_basis!(
5750 "TensorBSpline margin {margin} has a non-zero endpoint anchor. An inhomogeneous \
5751 marginal constraint cannot be represented by the tensor's homogeneous coefficient \
5752 chart plus one scalar row offset; use a separate anchored 1-D smooth or an explicit \
5753 model offset"
5754 );
5755 }
5756 if !spec.periods.is_empty() && spec.periods.len() != feature_cols.len() {
5757 crate::bail_dim_basis!(
5758 "TensorBSpline periods length {} does not match feature count {}",
5759 spec.periods.len(),
5760 feature_cols.len()
5761 );
5762 }
5763 let p = data.ncols();
5764 for &c in feature_cols {
5765 if c >= p {
5766 crate::bail_dim_basis!(
5767 "tensor feature column {c} is out of bounds for data with {p} columns"
5768 );
5769 }
5770 }
5771
5772 let mut marginal_knots = Vec::<Array1<f64>>::with_capacity(feature_cols.len());
5773 let mut marginal_is_cr_flags = Vec::<bool>::with_capacity(feature_cols.len());
5776 let mut marginal_degrees = Vec::<usize>::with_capacity(feature_cols.len());
5777 let mut marginalnum_basis = Vec::<usize>::with_capacity(feature_cols.len());
5778 let mut marginal_penalties = Vec::<Array2<f64>>::with_capacity(feature_cols.len());
5779 let mut marginal_function_grams = Vec::<Array2<f64>>::with_capacity(feature_cols.len());
5780 let mut marginal_designs = Vec::<Array2<f64>>::with_capacity(feature_cols.len());
5781 let mut marginal_effective_periods = Vec::<Option<f64>>::with_capacity(feature_cols.len());
5789 let mut marginal_sparse =
5797 Vec::<Option<SparseColMat<usize, f64>>>::with_capacity(feature_cols.len());
5798
5799 for (dim, (&col, marginalspec)) in feature_cols
5802 .iter()
5803 .zip(spec.marginalspecs.iter())
5804 .enumerate()
5805 {
5806 let mut marginal_unconstrained = marginalspec.clone();
5811 marginal_unconstrained.identifiability = BSplineIdentifiability::None;
5812 let built = build_bspline_basis_1d(data.column(col), &marginal_unconstrained)?;
5813 let (knots, marginal_is_cr, effective_degree, function_gram) = match built.metadata {
5818 BasisMetadata::BSpline1D {
5819 knots,
5820 periodic,
5821 degree,
5822 ..
5823 } => {
5824 let effective_degree = degree.unwrap_or(marginal_unconstrained.degree);
5825 let gram = if spec.double_penalty {
5826 Some(match periodic {
5827 Some((start, period, num_basis)) => {
5828 crate::basis::periodic_bspline_function_gram(
5829 start,
5830 start + period,
5831 effective_degree,
5832 num_basis,
5833 )?
5834 }
5835 None => crate::basis::bspline_function_gram(&knots, effective_degree)?,
5836 })
5837 } else {
5838 None
5839 };
5840 (knots, false, effective_degree, gram)
5841 }
5842 BasisMetadata::CubicRegression1D { knots, .. } => {
5843 let gram = spec
5844 .double_penalty
5845 .then(|| crate::basis::cubic_regression_function_gram(&knots))
5846 .transpose()?;
5847 (knots, true, marginalspec.degree, gram)
5848 }
5849 _ => {
5850 crate::bail_invalid_basis!(
5851 "internal TensorBSpline error at dim {dim}: expected BSpline1D or CubicRegression1D metadata"
5852 );
5853 }
5854 };
5855 let metadata_knots = match marginalspec.knotspec {
5856 BSplineKnotSpec::PeriodicUniform {
5857 data_range,
5858 num_basis,
5859 } => Array1::linspace(data_range.0, data_range.1, num_basis),
5860 _ => knots,
5861 };
5862 if let Some(function_gram) = function_gram {
5863 if function_gram.dim() != (built.design.ncols(), built.design.ncols()) {
5864 crate::bail_dim_basis!(
5865 "internal TensorBSpline error at dim {dim}: function Gram is {:?}, basis has {} columns",
5866 function_gram.dim(),
5867 built.design.ncols()
5868 );
5869 }
5870 marginal_function_grams.push(function_gram);
5871 }
5872 marginal_knots.push(metadata_knots);
5873 marginal_is_cr_flags.push(marginal_is_cr);
5874 marginal_degrees.push(effective_degree);
5875 marginalnum_basis.push(built.design.ncols());
5876 let dense_marginal = built.design.to_dense();
5881 let sparse_view: Option<SparseColMat<usize, f64>> = match built.design.as_sparse() {
5882 Some(sd) => {
5883 let inner: &SparseColMat<usize, f64> = sd;
5884 Some(inner.clone())
5885 }
5886 None => match marginalspec.knotspec {
5887 BSplineKnotSpec::PeriodicUniform { .. } => {
5888 Some(dense_local_margin_to_sparse(&dense_marginal)?)
5889 }
5890 _ => None,
5891 },
5892 };
5893 marginal_sparse.push(sparse_view);
5894 marginal_designs.push(dense_marginal);
5895 marginal_penalties.push(
5896 built
5897 .active_penalties
5898 .first()
5899 .ok_or_else(|| {
5900 BasisError::InvalidInput(format!(
5901 "internal TensorBSpline error at dim {dim}: missing marginal penalty"
5902 ))
5903 })?
5904 .matrix
5905 .clone(),
5906 );
5907 built.active_penalties.first().ok_or_else(|| {
5908 BasisError::InvalidInput(format!(
5909 "internal TensorBSpline error at dim {dim}: missing marginal nullspace dim"
5910 ))
5911 })?;
5912 let implied_period = match marginalspec.knotspec {
5920 BSplineKnotSpec::PeriodicUniform { data_range, .. } => {
5921 Some(data_range.1 - data_range.0)
5922 }
5923 _ => spec.periods.get(dim).and_then(|p| *p),
5924 };
5925 marginal_effective_periods.push(implied_period);
5926 }
5927
5928 let total_cols: usize = marginalnum_basis.iter().product();
5929 let mut dense_design = (!matches!(spec.identifiability, TensorBSplineIdentifiability::None))
5930 .then(|| tensor_product_design_from_marginals(&marginal_designs))
5931 .transpose()?;
5932 let mut candidates = Vec::<PenaltyCandidate>::with_capacity(
5933 match spec.penalty_decomposition {
5934 TensorBSplinePenaltyDecomposition::MarginalKroneckerSum => marginal_penalties.len(),
5935 TensorBSplinePenaltyDecomposition::Separable => marginal_penalties.len() * 2,
5936 } + if spec.double_penalty { 1 } else { 0 },
5937 );
5938
5939 let normalized_marginal_penalties: Vec<(Array2<f64>, f64)> = marginal_penalties
5947 .iter()
5948 .map(normalize_penalty_in_constrained_space)
5949 .collect();
5950 let tensor_function_gram = if spec.double_penalty {
5951 if marginal_function_grams.len() != marginalnum_basis.len() {
5952 crate::bail_dim_basis!(
5953 "TensorBSpline double penalty requires one function Gram per margin; got {} for {} margins",
5954 marginal_function_grams.len(),
5955 marginalnum_basis.len()
5956 );
5957 }
5958 let mut gram = Array2::<f64>::eye(1);
5959 for marginal_gram in &marginal_function_grams {
5960 gram = kronecker_product(&gram, marginal_gram);
5961 }
5962 Some(gram)
5963 } else {
5964 None
5965 };
5966 let joint_wiggliness = if spec.double_penalty {
5971 let mut sum = Array2::<f64>::zeros((total_cols, total_cols));
5972 for dim in 0..normalized_marginal_penalties.len() {
5973 let mut embedded = Array2::<f64>::eye(1);
5974 for (margin, &width) in marginalnum_basis.iter().enumerate() {
5975 let factor = if margin == dim {
5976 normalized_marginal_penalties[margin].0.clone()
5977 } else {
5978 Array2::<f64>::eye(width)
5979 };
5980 embedded = kronecker_product(&embedded, &factor);
5981 }
5982 sum += &embedded;
5983 }
5984 Some(sum)
5985 } else {
5986 None
5987 };
5988 let mut kronecker_marginal_penalties =
5989 Vec::<Array2<f64>>::with_capacity(normalized_marginal_penalties.len());
5990
5991 match spec.penalty_decomposition {
5992 TensorBSplinePenaltyDecomposition::MarginalKroneckerSum => {
5993 for dim in 0..normalized_marginal_penalties.len() {
5999 let mut s_dim = Array2::<f64>::eye(1);
6000 let mut factors = Vec::<Array2<f64>>::with_capacity(marginalnum_basis.len());
6001 for (j, &qj) in marginalnum_basis.iter().enumerate() {
6002 let factor = if j == dim {
6003 normalized_marginal_penalties[j].0.clone()
6004 } else {
6005 Array2::<f64>::eye(qj)
6006 };
6007 factors.push(factor.clone());
6008 s_dim = kronecker_product(&s_dim, &factor);
6009 }
6010 if dim == kronecker_marginal_penalties.len() {
6011 kronecker_marginal_penalties.push(normalized_marginal_penalties[dim].0.clone());
6012 }
6013 candidates.push(PenaltyCandidate {
6014 matrix: ConstructiveQuadratic::try_from_dense_psd(
6015 s_dim,
6016 "tensor marginal penalty",
6017 )?,
6018 source: PenaltySource::TensorMarginal { dim },
6019 normalization_scale: normalized_marginal_penalties[dim].1,
6020 kronecker_factors: Some(factors),
6021 op: None,
6022 });
6023 }
6024
6025 if let (Some(primary), Some(gram)) =
6026 (joint_wiggliness.as_ref(), tensor_function_gram.as_ref())
6027 && let Some(shrink) =
6028 crate::basis::function_space_nullspace_shrinkage(primary, gram)?
6029 {
6030 let (matrix, normalization_scale) = normalize_penalty_in_constrained_space(&shrink);
6031 candidates.push(PenaltyCandidate {
6032 matrix: ConstructiveQuadratic::try_from_dense_psd(
6033 matrix,
6034 "tensor global null-function ridge",
6035 )?,
6036 source: PenaltySource::TensorGlobalRidge,
6037 normalization_scale,
6038 kronecker_factors: None,
6039 op: None,
6040 });
6041 }
6042 }
6043 TensorBSplinePenaltyDecomposition::Separable => {
6044 let projectors = tensor_margin_range_null_projectors(&normalized_marginal_penalties)?;
6045 let n_masks = 1usize.checked_shl(projectors.len() as u32).ok_or_else(|| {
6046 BasisError::InvalidInput(format!(
6047 "t2 separable tensor penalty supports at most {} margins, got {}",
6048 usize::BITS - 1,
6049 projectors.len()
6050 ))
6051 })?;
6052 for mask in 1..n_masks {
6053 let mut matrix = Array2::<f64>::eye(1);
6054 let mut factors = Vec::<Array2<f64>>::with_capacity(projectors.len());
6055 let mut penalized_margins = Vec::<usize>::new();
6056 for (dim, projector) in projectors.iter().enumerate() {
6057 let use_range = ((mask >> dim) & 1) == 1;
6058 let factor = if use_range {
6059 penalized_margins.push(dim);
6060 projector.range.clone()
6061 } else {
6062 projector.null.clone()
6063 };
6064 matrix = kronecker_product(&matrix, &factor);
6065 factors.push(factor);
6066 }
6067 let (matrix, normalization_scale) = normalize_penalty_in_constrained_space(&matrix);
6068 candidates.push(PenaltyCandidate {
6069 matrix: ConstructiveQuadratic::try_from_dense_psd(
6070 matrix,
6071 "tensor separable penalty",
6072 )?,
6073 source: PenaltySource::TensorSeparable { penalized_margins },
6074 normalization_scale,
6075 kronecker_factors: Some(factors),
6076 op: None,
6077 });
6078 }
6079
6080 if let (Some(primary), Some(gram)) =
6081 (joint_wiggliness.as_ref(), tensor_function_gram.as_ref())
6082 && let Some(matrix) =
6083 crate::basis::function_space_nullspace_shrinkage(primary, gram)?
6084 {
6085 let (matrix, normalization_scale) = normalize_penalty_in_constrained_space(&matrix);
6086 candidates.push(PenaltyCandidate {
6087 matrix: ConstructiveQuadratic::try_from_dense_psd(
6088 matrix,
6089 "separable tensor global null-function ridge",
6090 )?,
6091 source: PenaltySource::TensorGlobalRidge,
6092 normalization_scale,
6093 kronecker_factors: None,
6094 op: None,
6095 });
6096 }
6097 }
6098 }
6099
6100 let z_opt = match &spec.identifiability {
6101 TensorBSplineIdentifiability::None => None,
6102 TensorBSplineIdentifiability::SumToZero => {
6103 if total_cols < 2 {
6104 crate::bail_invalid_basis!(
6105 "TensorBSpline requires at least 2 basis coefficients to enforce sum-to-zero identifiability"
6106 );
6107 }
6108 let dense_design_ref = dense_design.as_ref().ok_or_else(|| {
6109 BasisError::InvalidInput(
6110 "tensor sum-to-zero identifiability requires a realized basis".to_string(),
6111 )
6112 })?;
6113 let (_, z) = apply_sum_to_zero_constraint(dense_design_ref.view(), None)?;
6114 let gauge = gam_problem::Gauge::sum_to_zero(z);
6115 Some(gauge.block_transform(0))
6116 }
6117 TensorBSplineIdentifiability::MarginalSumToZero => {
6118 if marginal_designs.len() < 2 {
6129 crate::bail_invalid_basis!(
6130 "tensor interaction (ti) identifiability requires at least 2 margins"
6131 );
6132 }
6133 let mut z = Array2::<f64>::eye(1);
6134 for (dim, marginal) in marginal_designs.iter().enumerate() {
6135 if marginal.ncols() < 2 {
6136 crate::bail_invalid_basis!(
6137 "tensor interaction (ti) margin {dim} has fewer than 2 basis functions; \
6138 cannot remove its marginal main effect"
6139 );
6140 }
6141 let (_, z_dim) = apply_sum_to_zero_constraint(marginal.view(), None)?;
6142 let gauge_dim = gam_problem::Gauge::sum_to_zero(z_dim);
6143 let z_dim = gauge_dim.block_transform(0);
6144 z = kronecker_product(&z, &z_dim);
6145 }
6146 Some(z)
6147 }
6148 TensorBSplineIdentifiability::FrozenTransform { transform } => {
6149 if transform.nrows() != total_cols {
6150 crate::bail_dim_basis!(
6151 "frozen tensor identifiability transform mismatch: design has {} columns but transform has {} rows",
6152 total_cols,
6153 transform.nrows()
6154 );
6155 }
6156 Some(transform.clone())
6157 }
6158 };
6159
6160 if let Some(z) = z_opt.as_ref() {
6161 let gauge = gam_problem::Gauge::from_block_transforms(&[z.clone()]);
6162 let dense = dense_design.as_mut().ok_or_else(|| {
6163 BasisError::InvalidInput(
6164 "tensor identifiability transform requires a realized basis".to_string(),
6165 )
6166 })?;
6167 let restricted_design = gauge.restrict_design(dense);
6168 *dense = restricted_design;
6169 candidates = candidates
6170 .into_iter()
6171 .map(|candidate| -> Result<PenaltyCandidate, BasisError> {
6172 let restricted = candidate
6173 .matrix
6174 .restricted(&gauge, "tensor identifiability restriction")?;
6175 let (_, c_new) = normalize_penalty_in_constrained_space(restricted.dense());
6183 let matrix = restricted.scaled(
6184 1.0 / c_new,
6185 "normalized tensor penalty after identifiability",
6186 )?;
6187 Ok(PenaltyCandidate {
6188 matrix,
6189 source: candidate.source,
6190 normalization_scale: candidate.normalization_scale * c_new,
6191 kronecker_factors: None,
6197 op: candidate.op.clone(),
6198 })
6199 })
6200 .collect::<Result<Vec<_>, _>>()?;
6201
6202 if candidates
6203 .iter()
6204 .any(|candidate| matches!(candidate.source, PenaltySource::TensorGlobalRidge))
6205 {
6206 let width = candidates
6207 .first()
6208 .ok_or_else(|| {
6209 BasisError::InvalidInput(
6210 "TensorBSpline global ridge has no penalty candidates".to_string(),
6211 )
6212 })?
6213 .matrix
6214 .nrows();
6215 let physical_primary_terms = candidates
6216 .iter()
6217 .filter(|candidate| {
6218 !matches!(candidate.source, PenaltySource::TensorGlobalRidge)
6219 })
6220 .map(|candidate| {
6221 candidate.matrix.scaled(
6222 candidate.normalization_scale,
6223 "physical tensor primary penalty",
6224 )
6225 })
6226 .collect::<Result<Vec<_>, _>>()?;
6227 let joint_primary = ConstructiveQuadratic::sum(
6228 &physical_primary_terms,
6229 "joint tensor primary penalty",
6230 )?;
6231 for candidate in &mut candidates {
6232 if !matches!(candidate.source, PenaltySource::TensorGlobalRidge) {
6233 continue;
6234 }
6235 let physical_ridge = candidate.matrix.scaled(
6236 candidate.normalization_scale,
6237 "physical tensor null ridge",
6238 )?;
6239 match crate::basis::rebuild_metric_consistent_ridge(
6240 &joint_primary,
6241 &physical_ridge,
6242 )? {
6243 Some(rebuilt) => {
6244 let (_, scale) =
6245 normalize_penalty_in_constrained_space(rebuilt.dense());
6246 candidate.matrix = rebuilt.scaled(
6247 1.0 / scale,
6248 "normalized rebuilt tensor null ridge",
6249 )?;
6250 candidate.normalization_scale = scale;
6251 }
6252 None => {
6253 candidate.matrix = ConstructiveQuadratic::zero(width);
6254 candidate.normalization_scale = 1.0;
6255 }
6256 }
6257 candidate.kronecker_factors = None;
6258 candidate.op = None;
6259 }
6260 }
6261 }
6262
6263 let filtered = filter_penalty_candidates(candidates)?;
6264 let identifiability_is_none =
6265 matches!(spec.identifiability, TensorBSplineIdentifiability::None);
6266 let all_marginals_sparse = marginal_sparse.iter().all(Option::is_some);
6274 let design = if let Some(dense_design) = dense_design {
6275 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(dense_design))
6276 } else if identifiability_is_none && all_marginals_sparse {
6277 let sparse_marginals: Vec<&SparseColMat<usize, f64>> = marginal_sparse
6283 .iter()
6284 .map(|m| m.as_ref().expect("all_marginals_sparse just verified"))
6285 .collect();
6286 let sparse_design = tensor_product_design_from_sparse_marginals(&sparse_marginals)?;
6287 DesignMatrix::Sparse(gam_linalg::matrix::SparseDesignMatrix::new(sparse_design))
6288 } else {
6289 let marginals: Vec<Arc<Array2<f64>>> = marginal_designs
6290 .iter()
6291 .map(|m| Arc::new(m.clone()))
6292 .collect();
6293 let op = TensorProductDesignOperator::new(marginals).map_err(|e| {
6294 BasisError::InvalidInput(format!("TensorProductDesignOperator build failed: {e}"))
6295 })?;
6296 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(op)))
6297 };
6298
6299 Ok(BasisBuildResult {
6300 design,
6301 affine_offset: None,
6302 active_penalties: filtered.active,
6303 dropped_penalties: filtered.dropped,
6304 joint_null_rotation: None,
6305 metadata: BasisMetadata::TensorBSpline {
6306 feature_cols: feature_cols.to_vec(),
6307 knots: marginal_knots,
6308 degrees: marginal_degrees,
6309 periods: marginal_effective_periods,
6316 is_cr: marginal_is_cr_flags,
6317 identifiability_transform: z_opt,
6318 },
6319 kronecker_factored: if !spec.double_penalty
6326 && matches!(spec.identifiability, TensorBSplineIdentifiability::None)
6327 && matches!(
6328 spec.penalty_decomposition,
6329 TensorBSplinePenaltyDecomposition::MarginalKroneckerSum
6330 ) {
6331 Some(KroneckerFactoredBasis::new(
6332 marginal_designs,
6333 kronecker_marginal_penalties,
6334 marginalnum_basis.clone(),
6335 spec.double_penalty,
6336 ))
6337 } else {
6338 None
6339 },
6340 })
6341}
6342
6343#[cfg(test)]
6344mod tensor_function_space_runtime_tests {
6345 use super::*;
6346 use crate::basis::{
6347 BSplineBoundaryConditions, BSplineEndpointBoundaryCondition, OneDimensionalBoundary,
6348 };
6349 use ndarray::array;
6350
6351 fn marginal() -> BSplineBasisSpec {
6352 BSplineBasisSpec {
6353 degree: 2,
6354 penalty_order: 1,
6355 knotspec: BSplineKnotSpec::Generate {
6356 data_range: (0.0, 1.0),
6357 num_internal_knots: 2,
6358 },
6359 double_penalty: false,
6360 identifiability: BSplineIdentifiability::None,
6361 boundary: OneDimensionalBoundary::Open,
6362 boundary_conditions: BSplineBoundaryConditions::default(),
6363 }
6364 }
6365
6366 #[test]
6367 fn function_space_tensor_ridge_uses_exact_canonical_runtime() {
6368 let data = array![
6369 [0.00, 0.13],
6370 [0.15, 0.82],
6371 [0.29, 0.37],
6372 [0.43, 0.95],
6373 [0.58, 0.21],
6374 [0.71, 0.66],
6375 [0.86, 0.48],
6376 [1.00, 0.04]
6377 ];
6378 let mut spec = TensorBSplineSpec {
6379 marginalspecs: vec![marginal(), marginal()],
6380 periods: Vec::new(),
6381 double_penalty: true,
6382 identifiability: TensorBSplineIdentifiability::None,
6383 penalty_decomposition: TensorBSplinePenaltyDecomposition::MarginalKroneckerSum,
6384 };
6385 let built = build_tensor_bspline_basis(data.view(), &[0, 1], &spec)
6386 .expect("double-penalty tensor basis");
6387 assert!(
6388 built
6389 .active_penalties
6390 .iter()
6391 .any(|penalty| { matches!(penalty.info.source, PenaltySource::TensorGlobalRidge) })
6392 );
6393 assert!(
6394 built.kronecker_factored.is_none(),
6395 "the legacy factored runtime cannot represent a function-metric global ridge"
6396 );
6397
6398 spec.double_penalty = false;
6399 let singly_penalized = build_tensor_bspline_basis(data.view(), &[0, 1], &spec)
6400 .expect("single-penalty tensor basis");
6401 assert!(
6402 singly_penalized.kronecker_factored.is_some(),
6403 "the exact marginal-only fast path must remain available"
6404 );
6405 }
6406
6407 #[test]
6408 fn tensor_nonzero_anchor_is_rejected_before_its_affine_lift_can_be_dropped() {
6409 let data = array![[0.0, 0.0], [0.25, 0.75], [0.75, 0.25], [1.0, 1.0]];
6410 let mut anchored = marginal();
6411 anchored.boundary_conditions.left =
6412 BSplineEndpointBoundaryCondition::Anchored { value: 1.25 };
6413 let spec = TensorBSplineSpec {
6414 marginalspecs: vec![anchored, marginal()],
6415 periods: Vec::new(),
6416 double_penalty: false,
6417 identifiability: TensorBSplineIdentifiability::None,
6418 penalty_decomposition: TensorBSplinePenaltyDecomposition::MarginalKroneckerSum,
6419 };
6420
6421 let error = build_tensor_bspline_basis(data.view(), &[0, 1], &spec)
6422 .expect_err("a tensor margin cannot silently discard an inhomogeneous lift");
6423 let message = error.to_string();
6424 assert!(message.contains("TensorBSpline margin 0"));
6425 assert!(message.contains("non-zero endpoint anchor"));
6426 assert!(message.contains("explicit model offset"));
6427 }
6428
6429}
6430
6431pub fn tensor_product_design_from_marginals(
6432 marginal_designs: &[Array2<f64>],
6433) -> Result<Array2<f64>, BasisError> {
6434 if marginal_designs.is_empty() {
6435 crate::bail_invalid_basis!("TensorBSpline requires at least one marginal basis");
6436 }
6437 let n = marginal_designs[0].nrows();
6438 for (i, b) in marginal_designs.iter().enumerate().skip(1) {
6439 if b.nrows() != n {
6440 crate::bail_dim_basis!(
6441 "tensor marginal row mismatch at dim {i}: expected {n}, got {}",
6442 b.nrows()
6443 );
6444 }
6445 }
6446 let total_cols = marginal_designs.iter().try_fold(1usize, |acc, b| {
6447 acc.checked_mul(b.ncols())
6448 .ok_or_else(|| BasisError::DimensionMismatch("tensor basis too large".to_string()))
6449 })?;
6450 use ndarray::parallel::prelude::*;
6456 use rayon::iter::{IntoParallelIterator, ParallelIterator};
6457 let mut design = Array2::<f64>::zeros((n, total_cols));
6458 design
6459 .axis_chunks_iter_mut(ndarray::Axis(0), 1024)
6460 .into_par_iter()
6461 .enumerate()
6462 .for_each(|(chunk_idx, mut block)| {
6463 let row_offset = chunk_idx * 1024;
6464 let mut cur = Vec::<f64>::with_capacity(total_cols);
6466 let mut next = Vec::<f64>::with_capacity(total_cols);
6467 for (local_i, mut out_row) in block.outer_iter_mut().enumerate() {
6468 let i = row_offset + local_i;
6469 cur.clear();
6470 cur.push(1.0);
6471 for b in marginal_designs {
6472 let q = b.ncols();
6473 next.clear();
6474 next.resize(cur.len() * q, 0.0);
6475 let b_row = b.row(i);
6479 let b_slice = b_row
6480 .as_slice()
6481 .expect("Array2 row from outer_iter is contiguous");
6482 for (a_idx, &aval) in cur.iter().enumerate() {
6483 let off = a_idx * q;
6484 let dst = &mut next[off..off + q];
6485 for col in 0..q {
6486 dst[col] = aval * b_slice[col];
6487 }
6488 }
6489 std::mem::swap(&mut cur, &mut next);
6490 }
6491 let out_slice = out_row
6496 .as_slice_mut()
6497 .expect("design row is contiguous in C-major Array2");
6498 out_slice.copy_from_slice(&cur);
6499 }
6500 });
6501 Ok(design)
6502}
6503
6504fn fmt_level_value(v: f64) -> String {
6508 if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e15 {
6509 format!("{}", v as i64)
6510 } else {
6511 format!("{v}")
6512 }
6513}
6514
6515pub fn build_random_effect_block(
6516 data: ArrayView2<'_, f64>,
6517 spec: &RandomEffectTermSpec,
6518) -> Result<RandomEffectBlock, BasisError> {
6519 let n = data.nrows();
6520 let p = data.ncols();
6521 if spec.feature_col >= p {
6522 crate::bail_dim_basis!(
6523 "random-effect term '{}' feature column {} out of bounds for {} columns",
6524 spec.name,
6525 spec.feature_col,
6526 p
6527 );
6528 }
6529
6530 let col = data.column(spec.feature_col);
6531 if col.iter().any(|v| !v.is_finite()) {
6532 crate::bail_invalid_basis!(
6533 "random-effect term '{}' contains non-finite group values",
6534 spec.name
6535 );
6536 }
6537
6538 let kept_levels: Vec<u64> = if let Some(levels) = spec.frozen_levels.as_ref() {
6539 if levels.is_empty() {
6540 crate::bail_invalid_basis!(
6541 "random-effect term '{}' has empty frozen_levels",
6542 spec.name
6543 );
6544 }
6545 levels
6549 .iter()
6550 .map(|&b| gam_data::canonical_level_bits(f64::from_bits(b)))
6551 .collect()
6552 } else {
6553 let mut seen = BTreeSet::<u64>::new();
6554 let mut levels = Vec::<u64>::new();
6555 for &v in col {
6556 let bits = gam_data::canonical_level_bits(v);
6557 if seen.insert(bits) {
6558 levels.push(bits);
6559 }
6560 }
6561 if levels.is_empty() {
6562 crate::bail_invalid_basis!("random-effect term '{}' has no observed levels", spec.name);
6563 }
6564 let start_idx = if spec.drop_first_level && levels.len() > 1 {
6565 1usize
6566 } else {
6567 0usize
6568 };
6569 levels[start_idx..].to_vec()
6570 };
6571
6572 if kept_levels.is_empty() {
6573 crate::bail_invalid_basis!(
6574 "random-effect term '{}' drops all levels; keep at least one level",
6575 spec.name
6576 );
6577 }
6578
6579 let q = kept_levels.len();
6580 let mut level_to_col = BTreeMap::<u64, usize>::new();
6581 for (idx, &bits) in kept_levels.iter().enumerate() {
6582 if level_to_col.insert(bits, idx).is_some() {
6583 crate::bail_invalid_basis!(
6584 "random-effect term '{}' has duplicate frozen level bits {bits}",
6585 spec.name
6586 );
6587 }
6588 }
6589 let strict_unseen =
6603 !spec.lenient_unseen && !spec.drop_first_level && spec.frozen_levels.is_some();
6604 let mut group_ids = Vec::with_capacity(n);
6605 for (row, &v) in col.iter().enumerate() {
6606 let bits = gam_data::canonical_level_bits(v);
6607 let group_id = level_to_col.get(&bits).copied();
6608 if strict_unseen && group_id.is_none() {
6609 crate::bail_invalid_basis!(
6610 "unseen level '{}' in fixed factor column '{}' at row {}; the factor's levels \
6611 were fixed at fit time and an out-of-vocabulary level cannot be predicted \
6612 (use group({}) for a random effect that tolerates held-out levels)",
6613 fmt_level_value(v),
6614 spec.name,
6615 row,
6616 spec.name
6617 );
6618 }
6619 group_ids.push(group_id);
6620 }
6621
6622 Ok(RandomEffectBlock {
6623 name: spec.name.clone(),
6624 group_ids,
6625 num_groups: q,
6626 kept_levels,
6627 })
6628}
6629
6630#[cfg(test)]
6631mod random_effect_signed_zero_tests {
6632 use super::{RandomEffectTermSpec, build_random_effect_block};
6633 use ndarray::array;
6634
6635 fn spec() -> RandomEffectTermSpec {
6636 RandomEffectTermSpec {
6637 name: "g".to_string(),
6638 feature_col: 0,
6639 drop_first_level: false,
6640 penalized: true,
6641 frozen_levels: None,
6642 lenient_unseen: true,
6643 }
6644 }
6645
6646 #[test]
6647 fn signed_zero_rows_share_one_group() {
6648 let data = array![[-0.0_f64], [0.0], [1.0], [-0.0], [1.0]];
6652 let block = build_random_effect_block(data.view(), &spec()).unwrap();
6653 assert_eq!(
6654 block.num_groups, 2,
6655 "0.0/-0.0 must not split into two groups"
6656 );
6657 assert_eq!(block.group_ids[0], block.group_ids[1]);
6659 assert_eq!(block.group_ids[0], block.group_ids[3]);
6660 assert_eq!(block.group_ids[2], block.group_ids[4]);
6661 assert_ne!(block.group_ids[0], block.group_ids[2]);
6662 }
6663
6664 #[test]
6665 fn frozen_positive_zero_matches_negative_zero_row() {
6666 let mut s = spec();
6669 s.frozen_levels = Some(vec![0.0_f64.to_bits(), 1.0_f64.to_bits()]);
6670 let data = array![[-0.0_f64], [1.0]];
6671 let block = build_random_effect_block(data.view(), &s).unwrap();
6672 assert_eq!(
6673 block.group_ids[0],
6674 Some(0),
6675 "-0.0 must match the +0.0 column"
6676 );
6677 assert_eq!(block.group_ids[1], Some(1));
6678 }
6679
6680 #[test]
6681 fn frozen_negative_zero_matches_positive_zero_row() {
6682 let mut s = spec();
6685 s.frozen_levels = Some(vec![(-0.0_f64).to_bits(), 1.0_f64.to_bits()]);
6686 let data = array![[0.0_f64], [1.0]];
6687 let block = build_random_effect_block(data.view(), &s).unwrap();
6688 assert_eq!(
6689 block.group_ids[0],
6690 Some(0),
6691 "+0.0 must match the -0.0 column"
6692 );
6693 }
6694
6695 fn fixed_factor_spec() -> RandomEffectTermSpec {
6698 let mut s = spec();
6701 s.name = "year".to_string();
6702 s.lenient_unseen = false;
6703 s
6704 }
6705
6706 #[test]
6707 fn fixed_factor_rejects_unseen_numeric_level_at_predict() {
6708 let mut s = fixed_factor_spec();
6713 s.frozen_levels = Some(vec![2000.0_f64.to_bits(), 2001.0_f64.to_bits()]);
6714 let data = array![[2000.0_f64], [1999.0]];
6715 let err = build_random_effect_block(data.view(), &s)
6716 .expect_err("an unseen fixed-factor level must be rejected");
6717 let msg = format!("{err}");
6718 assert!(
6719 msg.contains("unseen level"),
6720 "message must name the defect: {msg}"
6721 );
6722 assert!(
6723 msg.contains("1999"),
6724 "message must name the integer level (not 1999.0): {msg}"
6725 );
6726 assert!(msg.contains("year"), "message must name the column: {msg}");
6727 }
6728
6729 #[test]
6730 fn fixed_factor_accepts_seen_numeric_levels_at_predict() {
6731 let mut s = fixed_factor_spec();
6734 s.frozen_levels = Some(vec![2000.0_f64.to_bits(), 2001.0_f64.to_bits()]);
6735 let data = array![[2001.0_f64], [2000.0]];
6736 let block = build_random_effect_block(data.view(), &s).unwrap();
6737 assert_eq!(block.group_ids[0], Some(1));
6738 assert_eq!(block.group_ids[1], Some(0));
6739 }
6740
6741 #[test]
6742 fn fixed_factor_at_fit_time_derives_vocabulary_and_never_false_rejects() {
6743 let mut s = fixed_factor_spec();
6747 s.frozen_levels = None;
6748 let data = array![[2000.0_f64], [2001.0], [2002.0], [2000.0]];
6749 let block = build_random_effect_block(data.view(), &s)
6750 .expect("fit-time build must not reject its own levels");
6751 assert_eq!(block.num_groups, 3);
6752 }
6753
6754 #[test]
6755 fn random_effect_still_tolerates_unseen_numeric_level() {
6756 let mut s = spec(); s.frozen_levels = Some(vec![2000.0_f64.to_bits(), 2001.0_f64.to_bits()]);
6761 let data = array![[2000.0_f64], [1999.0]];
6762 let block = build_random_effect_block(data.view(), &s)
6763 .expect("a random effect tolerates unseen levels");
6764 assert_eq!(block.group_ids[0], Some(0));
6765 assert_eq!(
6766 block.group_ids[1], None,
6767 "unseen level → population mean, not a reject"
6768 );
6769 }
6770}
6771
6772impl SmoothDesign {
6773 pub fn map_term_coefficients(
6776 unconstrained: &Array1<f64>,
6777 shape: ShapeConstraint,
6778 ) -> Result<Array1<f64>, BasisError> {
6779 if unconstrained.is_empty() {
6780 crate::bail_invalid_basis!("unconstrained coefficient vector cannot be empty");
6781 }
6782 let mapped = match shape {
6783 ShapeConstraint::None => unconstrained.clone(),
6784 ShapeConstraint::MonotoneIncreasing => cumulative_exp(unconstrained, 1.0),
6785 ShapeConstraint::MonotoneDecreasing => cumulative_exp(unconstrained, -1.0),
6786 ShapeConstraint::Convex => second_cumulative_exp(unconstrained, 1.0),
6787 ShapeConstraint::Concave => second_cumulative_exp(unconstrained, -1.0),
6788 };
6789 Ok(mapped)
6790 }
6791}
6792
6793pub struct LocalSmoothTermBuild {
6794 pub dim: usize,
6795 pub design: DesignMatrix,
6796 pub affine_offset: Option<Array1<f64>>,
6798 pub active_penalties: Vec<ActivePenalty>,
6799 pub joint_null_rotation: Option<crate::basis::JointNullRotation>,
6806 pub dropped_penalties: Vec<DroppedPenaltyInfo>,
6807 pub metadata: BasisMetadata,
6808 pub linear_constraints: Option<LinearInequalityConstraints>,
6809 pub box_reparam: bool,
6810 pub kronecker_factored: Option<KroneckerFactoredBasis>,
6811}
6812
6813#[derive(Clone)]
6814pub struct PcaScoresMemmapDesignOperator {
6815 mmap: Arc<memmap2::Mmap>,
6816 data_offset: usize,
6817 nrows: usize,
6818 ncols: usize,
6819 chunk_size: usize,
6820}
6821
6822impl PcaScoresMemmapDesignOperator {
6823 fn open(path: PathBuf, chunk_size: usize) -> Result<Self, BasisError> {
6824 let file = File::open(&path).map_err(|err| {
6825 BasisError::InvalidInput(format!(
6826 "failed to open lazy Pca .npy scores '{}': {err}",
6827 path.display()
6828 ))
6829 })?;
6830 let mmap = unsafe {
6836 memmap2::Mmap::map(&file).map_err(|err| {
6837 BasisError::InvalidInput(format!(
6838 "failed to memmap lazy Pca .npy scores '{}': {err}",
6839 path.display()
6840 ))
6841 })?
6842 };
6843 let (data_offset, nrows, ncols) = parse_f64_2d_npy_header(&mmap, &path)?;
6844 let expected = data_offset
6845 .checked_add(nrows.saturating_mul(ncols).saturating_mul(8))
6846 .ok_or_else(|| {
6847 BasisError::InvalidInput(format!(
6848 "lazy Pca .npy scores '{}' shape is too large",
6849 path.display()
6850 ))
6851 })?;
6852 if mmap.len() < expected {
6853 crate::bail_invalid_basis!(
6854 "lazy Pca .npy scores '{}' is truncated: header expects {} bytes, file has {}",
6855 path.display(),
6856 expected,
6857 mmap.len()
6858 );
6859 }
6860 Ok(Self {
6861 mmap: Arc::new(mmap),
6862 data_offset,
6863 nrows,
6864 ncols,
6865 chunk_size: chunk_size.max(1),
6866 })
6867 }
6868
6869 fn value(&self, row: usize, col: usize) -> f64 {
6870 let offset = self.data_offset + (row * self.ncols + col) * 8;
6871 let mut bytes = [0_u8; 8];
6872 bytes.copy_from_slice(&self.mmap[offset..offset + 8]);
6873 f64::from_le_bytes(bytes)
6874 }
6875
6876 fn chunk_rows(&self) -> usize {
6877 self.chunk_size.min(self.nrows.max(1))
6878 }
6879}
6880
6881impl LinearOperator for PcaScoresMemmapDesignOperator {
6882 fn nrows(&self) -> usize {
6883 self.nrows
6884 }
6885
6886 fn ncols(&self) -> usize {
6887 self.ncols
6888 }
6889
6890 fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
6891 assert_eq!(
6892 vector.len(),
6893 self.ncols,
6894 "lazy Pca apply vector length mismatch"
6895 );
6896 let mut out = Array1::<f64>::zeros(self.nrows);
6897 for start in (0..self.nrows).step_by(self.chunk_rows()) {
6898 let end = (start + self.chunk_rows()).min(self.nrows);
6899 for row in start..end {
6900 let mut acc = 0.0;
6901 for col in 0..self.ncols {
6902 acc += self.value(row, col) * vector[col];
6903 }
6904 out[row] = acc;
6905 }
6906 }
6907 out
6908 }
6909
6910 fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
6911 assert_eq!(
6912 vector.len(),
6913 self.nrows,
6914 "lazy Pca apply_transpose vector length mismatch"
6915 );
6916 let mut out = Array1::<f64>::zeros(self.ncols);
6917 for start in (0..self.nrows).step_by(self.chunk_rows()) {
6918 let end = (start + self.chunk_rows()).min(self.nrows);
6919 for row in start..end {
6920 let scale = vector[row];
6921 if scale == 0.0 {
6922 continue;
6923 }
6924 for col in 0..self.ncols {
6925 out[col] += scale * self.value(row, col);
6926 }
6927 }
6928 }
6929 out
6930 }
6931
6932 fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
6933 if weights.len() != self.nrows {
6934 return Err(format!(
6935 "lazy Pca diag_xtw_x weight length mismatch: weights={}, nrows={}",
6936 weights.len(),
6937 self.nrows
6938 ));
6939 }
6940 FiniteSignedWeightsView::try_from_array(weights)
6941 .map_err(|reason| format!("lazy Pca diag_xtw_x: {reason}"))?;
6942 let mut gram = Array2::<f64>::zeros((self.ncols, self.ncols));
6943 for start in (0..self.nrows).step_by(self.chunk_rows()) {
6944 let end = (start + self.chunk_rows()).min(self.nrows);
6945 for row in start..end {
6946 let w = weights[row];
6947 if w == 0.0 {
6948 continue;
6949 }
6950 for a in 0..self.ncols {
6951 let xa = self.value(row, a);
6952 if xa == 0.0 {
6953 continue;
6954 }
6955 for b in a..self.ncols {
6956 gram[[a, b]] += w * xa * self.value(row, b);
6957 }
6958 }
6959 }
6960 }
6961 for a in 0..self.ncols {
6962 for b in 0..a {
6963 gram[[a, b]] = gram[[b, a]];
6964 }
6965 }
6966 Ok(gram)
6967 }
6968
6969 fn apply_weighted_normal(
6970 &self,
6971 weights: FiniteSignedWeightsView<'_>,
6972 vector: &Array1<f64>,
6973 penalty: Option<&Array2<f64>>,
6974 ridge: f64,
6975 ) -> Array1<f64> {
6976 assert_eq!(
6977 weights.len(),
6978 self.nrows,
6979 "lazy Pca weighted-normal weight mismatch"
6980 );
6981 assert_eq!(
6982 vector.len(),
6983 self.ncols,
6984 "lazy Pca weighted-normal vector mismatch"
6985 );
6986 let weights = weights.view();
6987 let mut out = Array1::<f64>::zeros(self.ncols);
6988 for start in (0..self.nrows).step_by(self.chunk_rows()) {
6989 let end = (start + self.chunk_rows()).min(self.nrows);
6990 for row in start..end {
6991 let w = weights[row];
6992 if w == 0.0 {
6993 continue;
6994 }
6995 let mut row_dot = 0.0;
6996 for col in 0..self.ncols {
6997 row_dot += self.value(row, col) * vector[col];
6998 }
6999 if row_dot == 0.0 {
7000 continue;
7001 }
7002 let scaled = w * row_dot;
7003 for col in 0..self.ncols {
7004 out[col] += scaled * self.value(row, col);
7005 }
7006 }
7007 }
7008 if let Some(pen) = penalty {
7009 out += &pen.dot(vector);
7010 }
7011 if ridge > 0.0 {
7012 out += &vector.mapv(|x| ridge * x);
7013 }
7014 out
7015 }
7016}
7017
7018impl DenseDesignOperator for PcaScoresMemmapDesignOperator {
7019 fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
7020 if weights.len() != self.nrows || y.len() != self.nrows {
7021 return Err(format!(
7022 "lazy Pca compute_xtwy dimension mismatch: weights={}, y={}, nrows={}",
7023 weights.len(),
7024 y.len(),
7025 self.nrows
7026 ));
7027 }
7028 FiniteSignedWeightsView::try_from_array(weights)
7029 .map_err(|reason| format!("lazy Pca compute_xtwy: {reason}"))?;
7030 let mut out = Array1::<f64>::zeros(self.ncols);
7031 for start in (0..self.nrows).step_by(self.chunk_rows()) {
7032 let end = (start + self.chunk_rows()).min(self.nrows);
7033 for row in start..end {
7034 let scale = weights[row] * y[row];
7035 if scale == 0.0 {
7036 continue;
7037 }
7038 for col in 0..self.ncols {
7039 out[col] += scale * self.value(row, col);
7040 }
7041 }
7042 }
7043 Ok(out)
7044 }
7045
7046 fn row_chunk_into(
7047 &self,
7048 rows: Range<usize>,
7049 mut out: ArrayViewMut2<'_, f64>,
7050 ) -> Result<(), MatrixMaterializationError> {
7051 if rows.end > self.nrows || rows.start > rows.end {
7052 return Err(MatrixMaterializationError::MissingRowChunk {
7053 context: "lazy Pca row range out of bounds",
7054 });
7055 }
7056 if out.nrows() != rows.end - rows.start || out.ncols() != self.ncols {
7057 return Err(MatrixMaterializationError::MissingRowChunk {
7058 context: "lazy Pca row_chunk_into shape mismatch",
7059 });
7060 }
7061 for (local, row) in (rows.start..rows.end).enumerate() {
7062 for col in 0..self.ncols {
7063 out[[local, col]] = self.value(row, col);
7064 }
7065 }
7066 Ok(())
7067 }
7068
7069 fn to_dense(&self) -> Array2<f64> {
7070 let mut out = Array2::<f64>::zeros((self.nrows, self.ncols));
7071 self.row_chunk_into(0..self.nrows, out.view_mut())
7072 .expect("lazy Pca full materialization failed");
7073 out
7074 }
7075}
7076
7077pub fn parse_f64_2d_npy_header(
7078 bytes: &[u8],
7079 path: &PathBuf,
7080) -> Result<(usize, usize, usize), BasisError> {
7081 let mut reader = std::io::Cursor::new(bytes);
7082 let header = npyz::NpyHeader::from_reader(&mut reader).map_err(|err| {
7083 BasisError::InvalidInput(format!(
7084 "lazy Pca scores '{}' has an invalid .npy header: {err}",
7085 path.display()
7086 ))
7087 })?;
7088 let is_little_endian_f64 = matches!(
7089 header.dtype(),
7090 npyz::DType::Plain(ref dtype)
7091 if dtype.type_char() == npyz::TypeChar::Float
7092 && dtype.size_field() == 8
7093 && dtype.endianness() == npyz::Endianness::Little
7094 );
7095 if !is_little_endian_f64 {
7096 crate::bail_invalid_basis!(
7097 "lazy Pca scores '{}' must be scalar little-endian float64 .npy, got {}",
7098 path.display(),
7099 header.dtype().descr()
7100 );
7101 }
7102 if header.order() != npyz::Order::C {
7103 crate::bail_invalid_basis!(
7104 "lazy Pca scores '{}' must be C-contiguous, not Fortran-ordered",
7105 path.display()
7106 );
7107 }
7108 if header.shape().len() != 2 {
7109 crate::bail_invalid_basis!(
7110 "lazy Pca scores '{}' must have shape (N, K), got {:?}",
7111 path.display(),
7112 header.shape()
7113 );
7114 }
7115 let nrows = usize::try_from(header.shape()[0]).map_err(|_| {
7116 BasisError::InvalidInput(format!(
7117 "lazy Pca scores '{}' row count {} exceeds this platform's address space",
7118 path.display(),
7119 header.shape()[0]
7120 ))
7121 })?;
7122 let ncols = usize::try_from(header.shape()[1]).map_err(|_| {
7123 BasisError::InvalidInput(format!(
7124 "lazy Pca scores '{}' column count {} exceeds this platform's address space",
7125 path.display(),
7126 header.shape()[1]
7127 ))
7128 })?;
7129 let data_offset = usize::try_from(reader.position()).map_err(|_| {
7130 BasisError::InvalidInput(format!(
7131 "lazy Pca scores '{}' header offset exceeds this platform's address space",
7132 path.display()
7133 ))
7134 })?;
7135 Ok((data_offset, nrows, ncols))
7136}
7137
7138pub fn pca_center_mean(x: ArrayView2<'_, f64>) -> Result<Array1<f64>, BasisError> {
7139 if x.nrows() == 0 {
7140 crate::bail_invalid_basis!("Pca basis requires at least one row to compute center mean");
7141 }
7142 let mut mean = Array1::<f64>::zeros(x.ncols());
7143 for row in x.rows() {
7144 mean += &row;
7145 }
7146 mean.mapv_inplace(|v| v / x.nrows() as f64);
7147 Ok(mean)
7148}
7149
7150fn pca_function_mass_penalty(
7161 mut raw_score_gram: Array2<f64>,
7162 n_rows: usize,
7163 smooth_penalty: f64,
7164) -> Result<Array2<f64>, BasisError> {
7165 let k = raw_score_gram.ncols();
7166 if raw_score_gram.nrows() != k {
7167 crate::bail_dim_basis!(
7168 "Pca score Gram must be square, got {}x{}",
7169 raw_score_gram.nrows(),
7170 k
7171 );
7172 }
7173 if n_rows == 0 {
7174 crate::bail_invalid_basis!("Pca basis requires at least one score row");
7175 }
7176 if k == 0 {
7177 crate::bail_invalid_basis!("Pca basis requires at least one score column");
7178 }
7179 if k > n_rows {
7180 crate::bail_invalid_basis!(
7181 "Pca score design is rank deficient: {} score columns cannot have full column rank with only {} rows; remove redundant components",
7182 k,
7183 n_rows
7184 );
7185 }
7186 if raw_score_gram.iter().any(|value| !value.is_finite()) {
7187 crate::bail_invalid_basis!("Pca score design produced a non-finite function Gram");
7188 }
7189
7190 let rrqr = gam_linalg::faer_ndarray::rrqr_from_gram_with_permutation(
7194 &raw_score_gram,
7195 n_rows,
7196 gam_linalg::faer_ndarray::default_rrqr_rank_alpha(),
7197 )
7198 .map_err(BasisError::LinalgError)?;
7199 if rrqr.rank != k {
7200 let redundant_columns = &rrqr.column_permutation[rrqr.rank..];
7201 crate::bail_invalid_basis!(
7202 "Pca score design is rank deficient under canonical RRQR: rank {} < {} (tolerance {:.6e}); redundant score columns {:?}; remove zero or dependent components instead of stabilizing them with a coefficient ridge",
7203 rrqr.rank,
7204 k,
7205 rrqr.rank_tol,
7206 redundant_columns
7207 );
7208 }
7209
7210 raw_score_gram.mapv_inplace(|value| value * smooth_penalty / n_rows as f64);
7211 Ok(raw_score_gram)
7212}
7213
7214pub fn build_pca_smooth_basis(
7215 data: ArrayView2<'_, f64>,
7216 feature_cols: &[usize],
7217 basis_matrix: &Array2<f64>,
7218 centered: bool,
7219 smooth_penalty: f64,
7220 center_mean: Option<&Array1<f64>>,
7221 pca_basis_path: Option<&PathBuf>,
7222 chunk_size: usize,
7223) -> Result<BasisBuildResult, BasisError> {
7224 if !smooth_penalty.is_finite() || smooth_penalty < 0.0 {
7225 crate::bail_invalid_basis!(
7226 "Pca smooth_penalty must be finite and non-negative, got {}",
7227 smooth_penalty
7228 );
7229 }
7230 if data.nrows() == 0 {
7231 crate::bail_invalid_basis!("Pca basis requires at least one data row");
7232 }
7233
7234 if let Some(path) = pca_basis_path {
7235 let op = PcaScoresMemmapDesignOperator::open(path.clone(), chunk_size)?;
7236 if op.nrows != data.nrows() {
7237 crate::bail_dim_basis!(
7238 "lazy Pca scores row mismatch: .npy has {}, data has {}",
7239 op.nrows,
7240 data.nrows()
7241 );
7242 }
7243 let raw_score_gram = op
7246 .diag_xtw_x(&Array1::<f64>::ones(op.nrows))
7247 .map_err(|err| {
7248 BasisError::InvalidInput(format!(
7249 "lazy Pca function-mass Gram construction failed: {err}"
7250 ))
7251 })?;
7252 let penalty = pca_function_mass_penalty(raw_score_gram, op.nrows, smooth_penalty)?;
7253 let filtered = filter_penalty_candidates(vec![PenaltyCandidate {
7254 matrix: ConstructiveQuadratic::try_from_dense_psd(
7255 penalty,
7256 "lazy PCA function-mass penalty",
7257 )?,
7258 source: PenaltySource::OperatorMass,
7259 normalization_scale: 1.0,
7260 kronecker_factors: None,
7261 op: None,
7262 }])?;
7263 return Ok(BasisBuildResult {
7264 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(op))),
7265 affine_offset: None,
7266 active_penalties: filtered.active,
7267 dropped_penalties: filtered.dropped,
7268 joint_null_rotation: None,
7269 metadata: BasisMetadata::Pca {
7270 feature_cols: feature_cols.to_vec(),
7271 basis_matrix: basis_matrix.clone(),
7272 centered,
7273 smooth_penalty,
7274 center_mean: center_mean.cloned(),
7275 pca_basis_path: Some(path.clone()),
7276 chunk_size: chunk_size.max(1),
7277 },
7278 kronecker_factored: None,
7279 });
7280 }
7281 if basis_matrix.nrows() != feature_cols.len() {
7282 crate::bail_dim_basis!(
7283 "Pca basis row mismatch: basis rows={}, feature columns={}",
7284 basis_matrix.nrows(),
7285 feature_cols.len()
7286 );
7287 }
7288 let mut x = select_columns(data, feature_cols)?;
7289 let mean = if centered {
7290 match center_mean {
7291 Some(mean) => mean.clone(),
7292 None => pca_center_mean(x.view())?,
7293 }
7294 } else {
7295 Array1::<f64>::zeros(feature_cols.len())
7296 };
7297 if centered {
7298 for mut row in x.rows_mut() {
7299 row -= &mean;
7300 }
7301 }
7302 let design = fast_ab(&x, basis_matrix);
7303 let raw_score_gram = gam_linalg::faer_ndarray::fast_ata(&design);
7304 let penalty = pca_function_mass_penalty(raw_score_gram, design.nrows(), smooth_penalty)?;
7305 let filtered = filter_penalty_candidates(vec![PenaltyCandidate {
7306 matrix: ConstructiveQuadratic::try_from_dense_psd(
7307 penalty,
7308 "PCA function-mass penalty",
7309 )?,
7310 source: PenaltySource::OperatorMass,
7311 normalization_scale: 1.0,
7312 kronecker_factors: None,
7313 op: None,
7314 }])?;
7315 Ok(BasisBuildResult {
7316 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(design)),
7317 affine_offset: None,
7318 active_penalties: filtered.active,
7319 dropped_penalties: filtered.dropped,
7320 joint_null_rotation: None,
7321 metadata: BasisMetadata::Pca {
7322 feature_cols: feature_cols.to_vec(),
7323 basis_matrix: basis_matrix.clone(),
7324 centered,
7325 smooth_penalty,
7326 center_mean: centered.then_some(mean),
7327 pca_basis_path: None,
7328 chunk_size: chunk_size.max(1),
7329 },
7330 kronecker_factored: None,
7331 })
7332}
7333
7334#[cfg(test)]
7335mod pca_function_mass_tests {
7336 use super::{PenaltySource, build_pca_smooth_basis, parse_f64_2d_npy_header};
7337 use ndarray::{Array1, Array2, array};
7338 use std::io::Write;
7339 use std::path::PathBuf;
7340
7341 fn quadratic_form(matrix: &Array2<f64>, coefficients: &Array1<f64>) -> f64 {
7342 coefficients.dot(&matrix.dot(coefficients))
7343 }
7344
7345 fn assert_close(left: f64, right: f64) {
7346 let scale = left.abs().max(right.abs()).max(1.0);
7347 assert!(
7348 (left - right).abs() <= 1e-11 * scale,
7349 "values differ: left={left:.16e}, right={right:.16e}"
7350 );
7351 }
7352
7353 fn write_f64_npy(scores: &Array2<f64>) -> PathBuf {
7354 let path = std::env::temp_dir().join(format!(
7355 "gam_terms_pca_function_mass_{}.npy",
7356 std::process::id()
7357 ));
7358 let mut header = format!(
7359 "{{'descr': '<f8', 'fortran_order': False, 'shape': ({}, {}), }}",
7360 scores.nrows(),
7361 scores.ncols()
7362 );
7363 while (10 + header.len() + 1) % 16 != 0 {
7364 header.push(' ');
7365 }
7366 header.push('\n');
7367 let header_len = u16::try_from(header.len()).expect("test .npy header fits u16");
7368
7369 let mut file = std::fs::File::create(&path).expect("create test .npy");
7370 file.write_all(b"\x93NUMPY").expect("write .npy magic");
7371 file.write_all(&[1, 0]).expect("write .npy version");
7372 file.write_all(&header_len.to_le_bytes())
7373 .expect("write .npy header length");
7374 file.write_all(header.as_bytes())
7375 .expect("write .npy header");
7376 for &value in scores {
7377 file.write_all(&value.to_le_bytes())
7378 .expect("write .npy score");
7379 }
7380 path
7381 }
7382
7383 fn npy_v1_bytes(mut header: String) -> Vec<u8> {
7384 while (10 + header.len() + 1) % 16 != 0 {
7385 header.push(' ');
7386 }
7387 header.push('\n');
7388 let header_len = u16::try_from(header.len()).expect("test header fits v1");
7389 let mut bytes = b"\x93NUMPY".to_vec();
7390 bytes.extend_from_slice(&[1, 0]);
7391 bytes.extend_from_slice(&header_len.to_le_bytes());
7392 bytes.extend_from_slice(header.as_bytes());
7393 bytes
7394 }
7395
7396 #[test]
7397 fn npy_header_parser_uses_exact_ast_fields_2293() {
7398 let path = PathBuf::from("scores.npy");
7399 let bytes = npy_v1_bytes(
7400 "{'shape':(3, 2), 'note':'True', 'descr':'<f8', 'fortran_order':False,}".to_string(),
7401 );
7402 let (offset, rows, cols) =
7403 parse_f64_2d_npy_header(&bytes, &path).expect("valid reordered header");
7404 assert_eq!((rows, cols), (3, 2));
7405 assert_eq!(offset, bytes.len());
7406
7407 for header in [
7408 "{'descr':'<f8','fortran_order':True,'shape':(3,2),}",
7409 "{'descr':'>f8','fortran_order':False,'shape':(3,2),}",
7410 "{'descr':'<f8','shape':(3,2),}",
7411 "{'descr':'<f8','fortran_order':'False','shape':(3,2),}",
7412 "{'descr':'<f8','fortran_order':False,'shape':(6,),}",
7413 ] {
7414 let invalid = npy_v1_bytes(header.to_string());
7415 assert!(
7416 parse_f64_2d_npy_header(&invalid, &path).is_err(),
7417 "{header}"
7418 );
7419 }
7420 }
7421
7422 #[test]
7423 fn pca_penalty_quadratic_equals_empirical_fitted_function_norm() {
7424 let data = array![[1.0, 2.0], [-1.0, 0.5], [2.0, -0.5], [0.25, -1.5]];
7425 let basis = array![[1.0, 0.5], [-0.25, 2.0]];
7426 let smooth_penalty = 2.5;
7427 let built = build_pca_smooth_basis(
7428 data.view(),
7429 &[0, 1],
7430 &basis,
7431 false,
7432 smooth_penalty,
7433 None,
7434 None,
7435 2,
7436 )
7437 .expect("full-rank PCA basis");
7438 let coefficients = array![0.7, -1.2];
7439 let design = built.design.to_dense();
7440 let fitted = design.dot(&coefficients);
7441 let expected = smooth_penalty * fitted.dot(&fitted) / fitted.len() as f64;
7442 let actual = quadratic_form(&built.active_penalties[0].matrix, &coefficients);
7443
7444 assert_close(actual, expected);
7445 assert_eq!(built.active_penalties[0].nullity, 0);
7446 assert_eq!(
7447 built.active_penalties[0].info.source,
7448 PenaltySource::OperatorMass
7449 );
7450 }
7451
7452 #[test]
7453 fn pca_function_mass_is_invariant_to_nonorthogonal_score_reparameterization() {
7454 let scores = array![[1.0, 2.0], [-1.0, 0.5], [2.0, -0.5], [0.25, -1.5]];
7455 let identity = Array2::<f64>::eye(2);
7456 let transform = array![[2.0, 0.5], [0.0, 0.25]];
7458 let base_coefficients = array![0.8, -1.1];
7459 let transformed_coefficients = array![1.5, -4.4];
7461 let smooth_penalty = 1.7;
7462
7463 let base = build_pca_smooth_basis(
7464 scores.view(),
7465 &[0, 1],
7466 &identity,
7467 false,
7468 smooth_penalty,
7469 None,
7470 None,
7471 2,
7472 )
7473 .expect("base PCA chart");
7474 let transformed = build_pca_smooth_basis(
7475 scores.view(),
7476 &[0, 1],
7477 &transform,
7478 false,
7479 smooth_penalty,
7480 None,
7481 None,
7482 2,
7483 )
7484 .expect("reparameterized PCA chart");
7485
7486 let fitted_base = base.design.to_dense().dot(&base_coefficients);
7487 let fitted_transformed = transformed.design.to_dense().dot(&transformed_coefficients);
7488 for (&left, &right) in fitted_base.iter().zip(fitted_transformed.iter()) {
7489 assert_close(left, right);
7490 }
7491 assert_close(
7492 quadratic_form(&base.active_penalties[0].matrix, &base_coefficients),
7493 quadratic_form(
7494 &transformed.active_penalties[0].matrix,
7495 &transformed_coefficients,
7496 ),
7497 );
7498 }
7499
7500 #[test]
7501 fn rank_deficient_pca_score_design_is_rejected() {
7502 let scores = array![[1.0, 0.0], [2.0, 0.0], [3.0, 0.0], [4.0, 0.0]];
7503 let result = build_pca_smooth_basis(
7504 scores.view(),
7505 &[0, 1],
7506 &Array2::<f64>::eye(2),
7507 false,
7508 1.0,
7509 None,
7510 None,
7511 2,
7512 );
7513 let err = result.err().expect("zero score column must be rejected");
7514 let message = err.to_string();
7515 assert!(
7516 message.contains("rank deficient"),
7517 "unexpected error: {message}"
7518 );
7519 assert!(
7520 message.contains("rank 1 < 2"),
7521 "missing RRQR evidence: {message}"
7522 );
7523 }
7524
7525 #[test]
7526 fn lazy_and_dense_pca_function_mass_penalties_match() {
7527 let scores = array![[1.0, 2.0], [-1.0, 0.5], [2.0, -0.5], [0.25, -1.5]];
7528 let smooth_penalty = 2.25;
7529 let path = write_f64_npy(&scores);
7530 let dense = build_pca_smooth_basis(
7531 scores.view(),
7532 &[0, 1],
7533 &Array2::<f64>::eye(2),
7534 false,
7535 smooth_penalty,
7536 None,
7537 None,
7538 2,
7539 )
7540 .expect("dense PCA basis");
7541 let lazy_data = Array2::<f64>::zeros((scores.nrows(), 0));
7542 let lazy = build_pca_smooth_basis(
7543 lazy_data.view(),
7544 &[],
7545 &Array2::<f64>::zeros((0, scores.ncols())),
7546 false,
7547 smooth_penalty,
7548 None,
7549 Some(&path),
7550 2,
7551 )
7552 .expect("lazy PCA basis");
7553 std::fs::remove_file(&path).expect("remove test .npy");
7554
7555 for (&left, &right) in dense.active_penalties[0]
7556 .matrix
7557 .iter()
7558 .zip(lazy.active_penalties[0].matrix.iter())
7559 {
7560 assert_close(left, right);
7561 }
7562 for (&left, &right) in dense
7563 .design
7564 .to_dense()
7565 .iter()
7566 .zip(lazy.design.to_dense().iter())
7567 {
7568 assert_close(left, right);
7569 }
7570 }
7571}
7572
7573pub fn defer_inner_model_centering_to_factor_level_wrapper(basis: &mut SmoothBasisSpec) {
7589 if let SmoothBasisSpec::BSpline1D { spec, .. } = basis
7590 && matches!(
7591 spec.identifiability,
7592 BSplineIdentifiability::WeightedSumToZero { .. }
7593 )
7594 {
7595 spec.identifiability = BSplineIdentifiability::None;
7596 }
7597}
7598
7599pub fn apply_by_variable_to_local_build(
7600 mut built: LocalSmoothTermBuild,
7601 data: ArrayView2<'_, f64>,
7602 by_col: usize,
7603 by: &ByVariableSpec,
7604 term_name: &str,
7605) -> Result<LocalSmoothTermBuild, BasisError> {
7606 if by_col >= data.ncols() {
7607 crate::bail_dim_basis!(
7608 "by-variable smooth term '{term_name}' references column {by_col}, but data has {} columns",
7609 data.ncols()
7610 );
7611 }
7612 let weights = match by {
7613 ByVariableSpec::Numeric => data.column(by_col).to_owned(),
7614 ByVariableSpec::Level { value_bits, .. } => {
7615 let value_bits = gam_data::canonical_level_bits(f64::from_bits(*value_bits));
7616 data.column(by_col).mapv(|value| {
7617 if gam_data::canonical_level_bits(value) == value_bits {
7618 1.0
7619 } else {
7620 0.0
7621 }
7622 })
7623 }
7624 };
7625 if weights.iter().any(|value| !value.is_finite()) {
7626 crate::bail_invalid_basis!(
7627 "by-variable smooth term '{term_name}' has non-finite by-column values"
7628 );
7629 }
7630
7631 let mut dense = built
7632 .design
7633 .try_to_dense_by_chunks("by-variable smooth row gating")
7634 .map_err(BasisError::InvalidInput)?;
7635 for (mut row, &weight) in dense.rows_mut().into_iter().zip(weights.iter()) {
7636 row.mapv_inplace(|value| value * weight);
7637 }
7638 if let Some(offset) = built.affine_offset.as_mut() {
7639 if offset.len() != weights.len() {
7640 crate::bail_dim_basis!(
7641 "by-variable smooth term '{term_name}' affine offset has {} rows but the by-variable has {}",
7642 offset.len(),
7643 weights.len()
7644 );
7645 }
7646 *offset *= &weights;
7647 }
7648 built.design = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(dense));
7649 built.kronecker_factored = None;
7650 Ok(built)
7651}
7652
7653pub fn build_by_smooth_local(
7664 data: ArrayView2<'_, f64>,
7665 term: &SmoothTermSpec,
7666 smooth: &SmoothBasisSpec,
7667 by_kind: &ByVarKind,
7668 workspace: &mut crate::basis::BasisWorkspace,
7669) -> Result<LocalSmoothTermBuild, BasisError> {
7670 let inner_term = SmoothTermSpec {
7671 name: term.name.clone(),
7672 basis: (*smooth).clone(),
7673 shape: term.shape,
7674 joint_null_rotation: None,
7675 };
7676 let inner = build_single_local_smooth_term(data, &inner_term, workspace)?;
7677
7678 match by_kind {
7679 ByVarKind::Numeric { feature_col } => {
7680 let inner_meta = inner.metadata.clone();
7681 let mut built = apply_by_variable_to_local_build(
7682 inner,
7683 data,
7684 *feature_col,
7685 &ByVariableSpec::Numeric,
7686 &term.name,
7687 )?;
7688 built.metadata = BasisMetadata::BySmooth {
7689 inner: Box::new(inner_meta),
7690 by_col: *feature_col,
7691 levels: None,
7692 ordered: false,
7693 };
7694 Ok(built)
7695 }
7696 ByVarKind::Factor {
7697 feature_col,
7698 frozen_levels,
7699 ordered,
7700 } => {
7701 let level_bits: Vec<u64> = if let Some(fl) = frozen_levels {
7704 fl.iter()
7705 .map(|&b| gam_data::canonical_level_bits(f64::from_bits(b)))
7706 .collect()
7707 } else {
7708 let col = data.column(*feature_col);
7709 let mut seen = BTreeSet::<u64>::new();
7710 for &v in col.iter() {
7711 if v.is_finite() {
7712 seen.insert(gam_data::canonical_level_bits(v));
7713 }
7714 }
7715 seen.into_iter().collect()
7716 };
7717 let n_levels = level_bits.len();
7718 if n_levels == 0 {
7719 crate::bail_invalid_basis!(
7720 "by-factor smooth term '{}': factor column {} has no observed levels",
7721 term.name,
7722 feature_col
7723 );
7724 }
7725 let p = inner.dim;
7726 let q = n_levels * p;
7727 let n = data.nrows();
7728
7729 let inner_dense = inner
7730 .design
7731 .try_to_dense_by_chunks("by-factor smooth design gating")
7732 .map_err(BasisError::InvalidInput)?;
7733
7734 let mut combined = Array2::<f64>::zeros((n, q));
7736 for (lvl_idx, &bits) in level_bits.iter().enumerate() {
7737 let col_start = lvl_idx * p;
7738 for row in 0..n {
7739 if gam_data::canonical_level_bits(data[[row, *feature_col]]) == bits {
7740 combined
7741 .slice_mut(s![row, col_start..col_start + p])
7742 .assign(&inner_dense.row(row));
7743 }
7744 }
7745 }
7746
7747 let inner_meta = inner.metadata.clone();
7759 let n_penalties = inner.active_penalties.len();
7760 let n_blocks = n_penalties.saturating_mul(n_levels);
7761 let mut candidates = Vec::<PenaltyCandidate>::with_capacity(n_blocks);
7762 for base_penalty in &inner.active_penalties {
7763 for lvl in 0..n_levels {
7764 let off = lvl * p;
7765 let mut s_big = Array2::<f64>::zeros((q, q));
7766 s_big
7767 .slice_mut(s![off..off + p, off..off + p])
7768 .assign(&base_penalty.matrix);
7769 let (s_big, scale) = normalize_penalty_in_constrained_space(&s_big);
7770 candidates.push(PenaltyCandidate {
7771 matrix: ConstructiveQuadratic::try_from_dense_psd(
7772 s_big,
7773 "factor-smooth replicated penalty",
7774 )?,
7775 source: base_penalty.info.source.clone(),
7776 normalization_scale: base_penalty.info.normalization_scale * scale,
7777 kronecker_factors: None,
7778 op: None,
7779 });
7780 }
7781 }
7782
7783 let filtered = crate::basis::filter_penalty_candidates(candidates)?;
7789 let joint_null_rotation = crate::basis::compute_joint_null_rotation(&filtered.active)?;
7790 let mut dropped_penalties = inner.dropped_penalties;
7791 dropped_penalties.extend(filtered.dropped);
7792
7793 Ok(LocalSmoothTermBuild {
7794 dim: q,
7795 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(combined)),
7796 affine_offset: inner.affine_offset,
7800 active_penalties: filtered.active,
7801 joint_null_rotation,
7802 dropped_penalties,
7803 metadata: BasisMetadata::BySmooth {
7804 inner: Box::new(inner_meta),
7805 by_col: *feature_col,
7806 levels: Some(level_bits),
7807 ordered: *ordered,
7808 },
7809 linear_constraints: None,
7810 box_reparam: false,
7811 kronecker_factored: None,
7812 })
7813 }
7814 }
7815}
7816
7817pub fn ensure_by_variable_specs_match(
7818 kind: &BySmoothKind,
7819 by: &ByVariableSpec,
7820 term_name: &str,
7821) -> Result<(), BasisError> {
7822 match (kind, by) {
7823 (BySmoothKind::Numeric, ByVariableSpec::Numeric) => Ok(()),
7824 (BySmoothKind::Level { level_bits }, ByVariableSpec::Level { value_bits, .. })
7825 if level_bits == value_bits =>
7826 {
7827 Ok(())
7828 }
7829 _ => Err(BasisError::InvalidInput(format!(
7830 "by-variable smooth term '{term_name}' has inconsistent by-variable specifications"
7831 ))),
7832 }
7833}
7834
7835fn canonical_nullspace_directions(z: &Array2<f64>) -> Result<Array2<f64>, BasisError> {
7846 let (coefficient_dim, nullity) = z.dim();
7847 if nullity == 0 {
7848 return Ok(Array2::zeros((coefficient_dim, 0)));
7849 }
7850 if coefficient_dim < nullity || z.iter().any(|value| !value.is_finite()) {
7851 crate::bail_invalid_basis!(
7852 "null-space basis must be finite with rows >= columns, got {}x{}",
7853 coefficient_dim,
7854 nullity
7855 );
7856 }
7857
7858 let tolerance = 128.0 * f64::EPSILON * coefficient_dim.max(1) as f64;
7859 let mut canonical = Array2::<f64>::zeros((coefficient_dim, nullity));
7860 for accepted in 0..nullity {
7861 let mut best_coordinate = usize::MAX;
7862 let mut best_norm = 0.0_f64;
7863 let mut best = Array1::<f64>::zeros(coefficient_dim);
7864
7865 for coordinate in 0..coefficient_dim {
7866 let mut candidate = Array1::<f64>::zeros(coefficient_dim);
7868 for row in 0..coefficient_dim {
7869 candidate[row] = (0..nullity)
7870 .map(|axis| z[[row, axis]] * z[[coordinate, axis]])
7871 .sum();
7872 }
7873 for _ in 0..2 {
7876 for axis in 0..accepted {
7877 let direction = canonical.column(axis);
7878 let projection = direction.dot(&candidate);
7879 candidate.scaled_add(-projection, &direction);
7880 }
7881 }
7882 let norm = candidate.dot(&candidate).sqrt();
7883 let tie_band = tolerance * best_norm.max(1.0);
7884 if best_coordinate == usize::MAX || norm > best_norm + tie_band {
7885 best_coordinate = coordinate;
7886 best_norm = norm;
7887 best = candidate;
7888 }
7889 }
7890
7891 if best_coordinate == usize::MAX || best_norm <= tolerance {
7892 crate::bail_invalid_basis!(
7893 "null-space projector exposed only {} of {} independent directions",
7894 accepted,
7895 nullity
7896 );
7897 }
7898 best.mapv_inplace(|value| value / best_norm);
7899 let sign_anchor = best
7901 .iter()
7902 .enumerate()
7903 .max_by(|(left_index, left), (right_index, right)| {
7904 left.abs()
7905 .partial_cmp(&right.abs())
7906 .unwrap_or(std::cmp::Ordering::Equal)
7907 .then_with(|| right_index.cmp(left_index))
7908 })
7909 .map(|(_, value)| *value)
7910 .unwrap_or(1.0);
7911 if sign_anchor < 0.0 {
7912 best.mapv_inplace(|value| -value);
7913 }
7914 canonical.column_mut(accepted).assign(&best);
7915 }
7916 Ok(canonical)
7917}
7918
7919#[cfg(test)]
7920mod canonical_nullspace_direction_tests {
7921 use super::*;
7922 use ndarray::array;
7923
7924 #[test]
7925 fn per_axis_null_penalties_are_invariant_to_eigensolver_gauge_2315() {
7926 let inv_sqrt_two = 0.5_f64.sqrt();
7927 let z = array![
7928 [inv_sqrt_two, 0.0],
7929 [inv_sqrt_two, 0.0],
7930 [0.0, 1.0],
7931 [0.0, 0.0]
7932 ];
7933 let rotation = array![[0.6, -0.8], [0.8, 0.6]];
7934 let rotated = z.dot(&rotation);
7935 let reference = canonical_nullspace_directions(&z).expect("canonical null basis");
7936 let actual =
7937 canonical_nullspace_directions(&rotated).expect("rotated canonical null basis");
7938 for axis in 0..reference.ncols() {
7939 let reference_penalty = reference
7940 .column(axis)
7941 .to_owned()
7942 .insert_axis(Axis(1))
7943 .dot(&reference.column(axis).insert_axis(Axis(0)));
7944 let actual_penalty = actual
7945 .column(axis)
7946 .to_owned()
7947 .insert_axis(Axis(1))
7948 .dot(&actual.column(axis).insert_axis(Axis(0)));
7949 let max_error = reference_penalty
7950 .iter()
7951 .zip(actual_penalty.iter())
7952 .map(|(left, right)| (left - right).abs())
7953 .fold(0.0_f64, f64::max);
7954 assert!(
7955 max_error <= 256.0 * f64::EPSILON,
7956 "axis {axis} changed by {max_error:e}"
7957 );
7958 }
7959 }
7960}
7961
7962pub fn build_factor_smooth(
7990 data: ArrayView2<'_, f64>,
7991 spec: &FactorSmoothSpec,
7992 term_name: &str,
7993 workspace: &mut crate::basis::BasisWorkspace,
7994) -> Result<LocalSmoothTermBuild, BasisError> {
7995 if spec.continuous_cols.len() != 1 {
7996 crate::bail_invalid_basis!(
7997 "factor smooth term '{}' currently supports exactly one continuous covariate; found {}",
7998 term_name,
7999 spec.continuous_cols.len()
8000 );
8001 }
8002 let feature_col = spec.continuous_cols[0];
8003 let group_col = spec.group_col;
8004 if feature_col >= data.ncols() || group_col >= data.ncols() {
8005 crate::bail_dim_basis!(
8006 "factor smooth term '{}' references columns ({}, {}) out of bounds for {} columns",
8007 term_name,
8008 feature_col,
8009 group_col,
8010 data.ncols()
8011 );
8012 }
8013
8014 if matches!(spec.flavour, FactorSmoothFlavour::Sz) {
8017 let levels = resolve_factor_smooth_levels(data, group_col, spec, term_name)?;
8018 let inner = SmoothBasisSpec::BSpline1D {
8019 feature_col,
8020 spec: factor_smooth_marginal_for_replay(&spec.marginal),
8021 };
8022 let sz_term = SmoothTermSpec {
8023 name: term_name.to_string(),
8024 basis: SmoothBasisSpec::FactorSumToZero {
8025 inner: Box::new(inner),
8026 by_col: group_col,
8027 levels: levels.clone(),
8028 frozen_global_orthogonality: None,
8029 },
8030 shape: ShapeConstraint::None,
8031 joint_null_rotation: None,
8032 };
8033 let mut built = build_single_local_smooth_term(data, &sz_term, workspace)?;
8034 let (knots, degree, periodic, marginal_is_cr) = match &built.metadata {
8055 BasisMetadata::BSpline1D {
8056 knots,
8057 periodic,
8058 degree,
8059 ..
8060 } => (
8061 knots.clone(),
8062 degree.unwrap_or(spec.marginal.degree),
8063 *periodic,
8064 false,
8065 ),
8066 BasisMetadata::CubicRegression1D { knots, .. } => {
8067 (knots.clone(), spec.marginal.degree, None, true)
8068 }
8069 other => {
8070 crate::bail_invalid_basis!(
8071 "sz factor smooth term '{}' produced an unexpected marginal metadata variant {:?}",
8072 term_name,
8073 other
8074 );
8075 }
8076 };
8077 built.metadata = BasisMetadata::FactorSmooth {
8078 continuous_cols: spec.continuous_cols.clone(),
8079 group_col,
8080 knots,
8081 degree,
8082 periodic,
8083 group_levels: levels,
8084 flavour: "sz".to_string(),
8085 marginal_is_cr,
8086 };
8087 return Ok(built);
8088 }
8089
8090 let levels = resolve_factor_smooth_levels(data, group_col, spec, term_name)?;
8091 let n_levels = levels.len();
8092 if n_levels < 2 {
8093 crate::bail_invalid_basis!(
8094 "factor smooth term '{}' requires at least two grouping levels; found {}",
8095 term_name,
8096 n_levels
8097 );
8098 }
8099
8100 let use_per_dim_null = matches!(
8108 &spec.flavour,
8109 FactorSmoothFlavour::Fs { m_null_penalty_orders }
8110 if m_null_penalty_orders.iter().copied().max().unwrap_or(0) >= 1
8111 );
8112
8113 let mut marginal_spec = factor_smooth_marginal_for_replay(&spec.marginal);
8119 if use_per_dim_null {
8120 marginal_spec.double_penalty = false;
8121 }
8122 let inner_term = SmoothTermSpec {
8123 name: format!("{term_name}::marginal"),
8124 basis: SmoothBasisSpec::BSpline1D {
8125 feature_col,
8126 spec: marginal_spec,
8127 },
8128 shape: ShapeConstraint::None,
8129 joint_null_rotation: None,
8130 };
8131 let inner = build_single_local_smooth_term(data, &inner_term, workspace)?;
8132 let mut base = inner
8133 .design
8134 .try_to_dense_by_chunks("factor smooth marginal")
8135 .map_err(BasisError::InvalidInput)?;
8136 if matches!(spec.flavour, FactorSmoothFlavour::Re) {
8137 let center = match &inner.metadata {
8147 BasisMetadata::BSpline1D { knots, .. } if !knots.is_empty() => {
8148 0.5 * (knots[0] + knots[knots.len() - 1])
8149 }
8150 _ => 0.0,
8151 };
8152 let mut linear = Array2::<f64>::ones((data.nrows(), 2));
8153 linear
8154 .column_mut(1)
8155 .assign(&data.column(feature_col).mapv(|x| x - center));
8156 base = linear;
8157 }
8158 let n = base.nrows();
8159 let p = base.ncols();
8160 let q = p * n_levels;
8161
8162 let mut dense = Array2::<f64>::zeros((n, q));
8165 for i in 0..n {
8166 let bits = gam_data::canonical_level_bits(data[[i, group_col]]);
8167 let Some(level_idx) = levels.iter().position(|b| *b == bits) else {
8168 if matches!(spec.flavour, FactorSmoothFlavour::Re)
8179 && spec.group_frozen_levels.is_some()
8180 {
8181 continue;
8182 }
8183 return Err(BasisError::InvalidInput(format!(
8184 "factor smooth term '{term_name}' saw an unseen grouping level at row {}",
8185 i + 1
8186 )));
8187 };
8188 let start = level_idx * p;
8189 dense
8190 .slice_mut(s![i, start..start + p])
8191 .assign(&base.row(i));
8192 }
8193
8194 let marginal_penalties: Vec<(Array2<f64>, PenaltySource, f64)> =
8200 if matches!(spec.flavour, FactorSmoothFlavour::Re) {
8201 (0..p)
8202 .map(|j| {
8203 let mut matrix = Array2::<f64>::zeros((p, p));
8204 matrix[[j, j]] = 1.0;
8205 (matrix, PenaltySource::Primary, 1.0)
8206 })
8207 .collect()
8208 } else {
8209 inner
8210 .active_penalties
8211 .iter()
8212 .map(|penalty| {
8213 (
8214 penalty.matrix.clone(),
8215 penalty.info.source.clone(),
8216 penalty.info.normalization_scale,
8217 )
8218 })
8219 .collect()
8220 };
8221
8222 let mut candidates = Vec::<PenaltyCandidate>::with_capacity(marginal_penalties.len());
8223 for (s_inner, source, base_scale) in marginal_penalties {
8224 let mut s_big = Array2::<f64>::zeros((q, q));
8225 for level in 0..n_levels {
8226 let start = level * p;
8227 s_big
8228 .slice_mut(s![start..start + p, start..start + p])
8229 .assign(&s_inner);
8230 }
8231 let (s_big, factor_smooth_scale) = normalize_penalty_in_constrained_space(&s_big);
8232 candidates.push(PenaltyCandidate {
8233 matrix: ConstructiveQuadratic::try_from_dense_psd(
8234 s_big,
8235 "factor-smooth shared penalty",
8236 )?,
8237 source,
8238 normalization_scale: base_scale * factor_smooth_scale,
8239 kronecker_factors: None,
8240 op: None,
8241 });
8242 }
8243
8244 if use_per_dim_null
8274 && let Some(Some(z)) = inner
8275 .active_penalties
8276 .first()
8277 .map(|penalty| &penalty.null_eigenvectors)
8278 && z.nrows() == p
8279 {
8280 let z = canonical_nullspace_directions(z)?;
8281 for k in 0..z.ncols() {
8282 let zk = z.column(k);
8287 let mut p_k = Array2::<f64>::zeros((p, p));
8288 for a in 0..p {
8289 for b in 0..p {
8290 p_k[[a, b]] = zk[a] * zk[b];
8291 }
8292 }
8293 let mut s_null = Array2::<f64>::zeros((q, q));
8294 for level in 0..n_levels {
8295 let start = level * p;
8296 s_null
8297 .slice_mut(s![start..start + p, start..start + p])
8298 .assign(&p_k);
8299 }
8300 let (s_null, null_scale) = normalize_penalty_in_constrained_space(&s_null);
8301 candidates.push(PenaltyCandidate {
8302 matrix: ConstructiveQuadratic::try_from_dense_psd(
8303 s_null,
8304 "factor-smooth null-function penalty",
8305 )?,
8306 source: PenaltySource::Primary,
8307 normalization_scale: null_scale,
8308 kronecker_factors: None,
8309 op: None,
8310 });
8311 }
8312 }
8313 let filtered = crate::basis::filter_penalty_candidates(candidates)?;
8314 let joint_null_rotation = crate::basis::compute_joint_null_rotation(&filtered.active)?;
8315 let mut dropped_penalties = inner.dropped_penalties;
8316 dropped_penalties.extend(filtered.dropped);
8317
8318 let (knots, degree, periodic) = match &inner.metadata {
8321 BasisMetadata::BSpline1D {
8322 knots,
8323 periodic,
8324 degree,
8325 ..
8326 } => (
8327 knots.clone(),
8328 degree.unwrap_or(spec.marginal.degree),
8329 *periodic,
8330 ),
8331 other => {
8332 crate::bail_invalid_basis!(
8333 "factor smooth term '{}' produced an unexpected marginal metadata variant {:?}",
8334 term_name,
8335 other
8336 );
8337 }
8338 };
8339 let flavour_tag = match &spec.flavour {
8340 FactorSmoothFlavour::Fs { .. } => "fs",
8341 FactorSmoothFlavour::Sz => "sz",
8342 FactorSmoothFlavour::Re => "re",
8343 }
8344 .to_string();
8345 let metadata = BasisMetadata::FactorSmooth {
8346 continuous_cols: spec.continuous_cols.clone(),
8347 group_col,
8348 knots,
8349 degree,
8350 periodic,
8351 group_levels: levels,
8352 flavour: flavour_tag,
8353 marginal_is_cr: false,
8356 };
8357
8358 Ok(LocalSmoothTermBuild {
8359 dim: q,
8360 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(dense)),
8361 affine_offset: inner.affine_offset,
8364 active_penalties: filtered.active,
8365 joint_null_rotation,
8366 dropped_penalties,
8367 metadata,
8368 linear_constraints: None,
8369 box_reparam: false,
8370 kronecker_factored: None,
8371 })
8372}
8373
8374pub fn resolve_factor_smooth_levels(
8378 data: ArrayView2<'_, f64>,
8379 group_col: usize,
8380 spec: &FactorSmoothSpec,
8381 term_name: &str,
8382) -> Result<Vec<u64>, BasisError> {
8383 if let Some(frozen) = &spec.group_frozen_levels {
8384 if frozen.is_empty() {
8385 crate::bail_invalid_basis!(
8386 "factor smooth term '{}' has an empty frozen level list",
8387 term_name
8388 );
8389 }
8390 return Ok(frozen
8391 .iter()
8392 .map(|&b| gam_data::canonical_level_bits(f64::from_bits(b)))
8393 .collect());
8394 }
8395 let mut bits: Vec<u64> = data
8396 .column(group_col)
8397 .iter()
8398 .map(|v| gam_data::canonical_level_bits(*v))
8399 .collect();
8400 bits.sort_by(|a, b| {
8401 f64::from_bits(*a)
8402 .partial_cmp(&f64::from_bits(*b))
8403 .unwrap_or(std::cmp::Ordering::Equal)
8404 });
8405 bits.dedup();
8406 Ok(bits)
8407}
8408
8409pub fn factor_smooth_marginal_for_replay(marginal: &BSplineBasisSpec) -> BSplineBasisSpec {
8416 let mut m = marginal.clone();
8417 m.identifiability = BSplineIdentifiability::None;
8418 m
8419}
8420
8421pub fn build_single_local_smooth_term(
8422 data: ArrayView2<'_, f64>,
8423 term: &SmoothTermSpec,
8424 workspace: &mut crate::basis::BasisWorkspace,
8425) -> Result<LocalSmoothTermBuild, BasisError> {
8426 term.basis.validate_scale_configuration()?;
8427 if term.shape != ShapeConstraint::None && !shape_supports_basis(term) {
8428 crate::bail_invalid_basis!(
8429 "ShapeConstraint::{:?} is unsupported for term '{}'",
8430 term.shape,
8431 term.name
8432 );
8433 }
8434 if let SmoothBasisSpec::ByVariable {
8435 inner,
8436 by_col,
8437 kind,
8438 by,
8439 } = &term.basis
8440 {
8441 ensure_by_variable_specs_match(kind, by, &term.name)?;
8442 let mut inner_basis = (**inner).clone();
8443 if matches!(by, ByVariableSpec::Level { .. }) {
8450 defer_inner_model_centering_to_factor_level_wrapper(&mut inner_basis);
8451 }
8452 let inner_term = SmoothTermSpec {
8453 name: term.name.clone(),
8454 basis: inner_basis,
8455 shape: term.shape,
8456 joint_null_rotation: None,
8457 };
8458 let built = build_single_local_smooth_term(data, &inner_term, workspace)?;
8459 return apply_by_variable_to_local_build(built, data, *by_col, by, &term.name);
8460 }
8461
8462 if let SmoothBasisSpec::BySmooth { smooth, by_kind } = &term.basis {
8465 return build_by_smooth_local(data, term, smooth, by_kind, workspace);
8466 }
8467
8468 let mut built: BasisBuildResult = match &term.basis {
8469 SmoothBasisSpec::FactorSumToZero {
8470 inner,
8471 by_col,
8472 levels,
8473 ..
8474 } => {
8475 if *by_col >= data.ncols() {
8476 crate::bail_dim_basis!(
8477 "term '{}' by column {} out of bounds for {} columns",
8478 term.name,
8479 by_col,
8480 data.ncols()
8481 );
8482 }
8483 if levels.len() < 2 {
8484 crate::bail_invalid_basis!(
8485 "sum-to-zero factor smooth term '{}' requires at least two levels",
8486 term.name
8487 );
8488 }
8489 if term.shape != ShapeConstraint::None {
8490 crate::bail_invalid_basis!(
8491 "ShapeConstraint::{:?} is unsupported for sum-to-zero factor smooth term '{}'",
8492 term.shape,
8493 term.name
8494 );
8495 }
8496 let inner_term = SmoothTermSpec {
8497 name: format!("{}::inner", term.name),
8498 basis: (**inner).clone(),
8499 shape: ShapeConstraint::None,
8500 joint_null_rotation: None,
8501 };
8502 let mut inner_built = build_single_local_smooth_term(data, &inner_term, workspace)?;
8503 if inner_built.affine_offset.is_some() {
8504 crate::bail_invalid_basis!(
8505 "sum-to-zero factor smooth term '{}' cannot contain a non-zero endpoint anchor: a shared fixed affine lift would violate the per-covariate zero-sum deviation identity",
8506 term.name
8507 );
8508 }
8509 let inner_null_eigenvectors = inner_built
8513 .active_penalties
8514 .first()
8515 .and_then(|penalty| penalty.null_eigenvectors.clone());
8516 let base = inner_built
8517 .design
8518 .try_to_dense_by_chunks("sum-to-zero factor smooth")
8519 .map_err(BasisError::InvalidInput)?;
8520 let n = base.nrows();
8521 let p = base.ncols();
8522 let l_minus_one = levels.len() - 1;
8523 let canon_levels: Vec<u64> = levels
8526 .iter()
8527 .map(|&b| gam_data::canonical_level_bits(f64::from_bits(b)))
8528 .collect();
8529 let mut dense = Array2::<f64>::zeros((n, p * l_minus_one));
8530 for i in 0..n {
8531 let bits = gam_data::canonical_level_bits(data[[i, *by_col]]);
8532 let level_idx = canon_levels
8533 .iter()
8534 .position(|b| *b == bits)
8535 .ok_or_else(|| {
8536 BasisError::InvalidInput(format!(
8537 "sum-to-zero factor smooth term '{}' saw an unseen level at row {}",
8538 term.name,
8539 i + 1
8540 ))
8541 })?;
8542 if level_idx < l_minus_one {
8543 let start = level_idx * p;
8544 dense
8545 .slice_mut(s![i, start..start + p])
8546 .assign(&base.row(i));
8547 } else {
8548 for level in 0..l_minus_one {
8549 let start = level * p;
8550 dense
8551 .slice_mut(s![i, start..start + p])
8552 .assign(&base.row(i).mapv(|v| -v));
8553 }
8554 }
8555 }
8556 let mut candidates = Vec::<PenaltyCandidate>::with_capacity(
8557 inner_built.active_penalties.len() * levels.len(),
8558 );
8559 let stz_per_group_penalty =
8594 |s_inner: &Array2<f64>, which_level: usize| -> Array2<f64> {
8595 let mut s_big = Array2::<f64>::zeros((p * l_minus_one, p * l_minus_one));
8596 if which_level < l_minus_one {
8597 let k = which_level;
8599 let mut block = s_big.slice_mut(s![k * p..(k + 1) * p, k * p..(k + 1) * p]);
8600 block.assign(s_inner);
8601 } else {
8602 for a in 0..l_minus_one {
8604 for b in 0..l_minus_one {
8605 let mut block =
8606 s_big.slice_mut(s![a * p..(a + 1) * p, b * p..(b + 1) * p]);
8607 block.assign(s_inner);
8608 }
8609 }
8610 }
8611 s_big
8612 };
8613 for base_penalty in &inner_built.active_penalties {
8614 for which_level in 0..=l_minus_one {
8616 let raw = stz_per_group_penalty(&base_penalty.matrix, which_level);
8617 let (s_big, group_scale) = normalize_penalty_in_constrained_space(&raw);
8618 candidates.push(PenaltyCandidate {
8619 matrix: ConstructiveQuadratic::try_from_dense_psd(
8620 s_big,
8621 "grouped factor-smooth penalty",
8622 )?,
8623 source: base_penalty.info.source.clone(),
8624 normalization_scale: base_penalty.info.normalization_scale * group_scale,
8625 kronecker_factors: None,
8626 op: None,
8627 });
8628 }
8629 }
8630
8631 if let Some(z) = inner_null_eigenvectors.as_ref()
8649 && z.nrows() == p
8650 {
8651 let z = canonical_nullspace_directions(z)?;
8652 for k in 0..z.ncols() {
8653 let zk = z.column(k);
8654 let mut p_k = Array2::<f64>::zeros((p, p));
8655 for a in 0..p {
8656 for b in 0..p {
8657 p_k[[a, b]] = zk[a] * zk[b];
8658 }
8659 }
8660 let stz_pooled_null = {
8665 let mut s_big = Array2::<f64>::zeros((p * l_minus_one, p * l_minus_one));
8666 for a in 0..l_minus_one {
8667 for b in 0..l_minus_one {
8668 let factor = if a == b { 2.0 } else { 1.0 };
8669 let mut block =
8670 s_big.slice_mut(s![a * p..(a + 1) * p, b * p..(b + 1) * p]);
8671 block.assign(&p_k.mapv(|v| v * factor));
8672 }
8673 }
8674 s_big
8675 };
8676 let (s_null, null_scale) =
8677 normalize_penalty_in_constrained_space(&stz_pooled_null);
8678 candidates.push(PenaltyCandidate {
8679 matrix: ConstructiveQuadratic::try_from_dense_psd(
8680 s_null,
8681 "grouped factor-smooth null penalty",
8682 )?,
8683 source: PenaltySource::DoublePenaltyNullspace,
8684 normalization_scale: null_scale,
8685 kronecker_factors: None,
8686 op: None,
8687 });
8688 }
8689 }
8690 let filtered = crate::basis::filter_penalty_candidates(candidates)?;
8691 let mut dropped_penalties = std::mem::take(&mut inner_built.dropped_penalties);
8692 dropped_penalties.extend(filtered.dropped);
8693 inner_built.dim = p * l_minus_one;
8694 inner_built.design =
8695 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(dense));
8696 inner_built.active_penalties = filtered.active;
8697 inner_built.dropped_penalties = dropped_penalties;
8698 inner_built.joint_null_rotation =
8699 crate::basis::compute_joint_null_rotation(&inner_built.active_penalties)?;
8700 inner_built.kronecker_factored = None;
8701 return Ok(inner_built);
8702 }
8703 SmoothBasisSpec::BSpline1D { feature_col, spec } => {
8704 if *feature_col >= data.ncols() {
8705 crate::bail_dim_basis!(
8706 "term '{}' feature column {} out of bounds for {} columns",
8707 term.name,
8708 feature_col,
8709 data.ncols()
8710 );
8711 }
8712 let mut spec_local = spec.clone();
8713 if term.shape != ShapeConstraint::None {
8714 spec_local.identifiability = BSplineIdentifiability::None;
8717 }
8718 build_bspline_basis_1d(data.column(*feature_col), &spec_local)?
8722 }
8723 SmoothBasisSpec::ThinPlate {
8724 feature_cols,
8725 spec,
8726 input_scale,
8727 } => {
8728 if term.shape != ShapeConstraint::None {
8729 if feature_cols.len() != 1 {
8730 crate::bail_invalid_basis!(
8731 "ShapeConstraint::{:?} for term '{}' on ThinPlate basis requires exactly 1 feature axis; found {}",
8732 term.shape,
8733 term.name,
8734 feature_cols.len()
8735 );
8736 }
8737 }
8738 let frame = term.basis.scale_contract().normalize_euclidean_frame(
8739 select_columns(data, feature_cols)?,
8740 *input_scale,
8741 Some(spec.length_scale),
8742 )?;
8743 let x = frame.coordinates;
8744 let realized_input_scale = frame.input_scale;
8745 let length_scale_eff = frame
8746 .length_scale
8747 .expect("ThinPlate declares a required length-scale coordinate");
8748 let mut spec_local = spec.clone();
8749 spec_local.length_scale = length_scale_eff;
8750 if matches!(
8751 spec_local.identifiability,
8752 SpatialIdentifiability::OrthogonalToParametric
8753 ) {
8754 spec_local.identifiability = SpatialIdentifiability::None;
8755 }
8756 let mut result = build_thin_plate_basis(x.view(), &spec_local).map_err(|err| {
8757 rewrite_thin_plate_knots_error(err, &term.name, feature_cols.len(), spec)
8758 })?;
8759 match &mut result.metadata {
8767 BasisMetadata::ThinPlate {
8768 input_scale: metadata_scale,
8769 length_scale,
8770 ..
8771 } => {
8772 *metadata_scale = realized_input_scale;
8773 *length_scale = spec.length_scale;
8774 }
8775 BasisMetadata::Duchon {
8776 input_scale: metadata_scale,
8777 length_scale,
8778 ..
8779 } => {
8780 if let Some(realized) = *length_scale {
8804 *length_scale = Some(realized * realized_input_scale.get());
8805 }
8806 *metadata_scale = realized_input_scale;
8807 }
8808 _ => {}
8809 }
8810 result
8811 }
8812 SmoothBasisSpec::Sphere { feature_cols, spec } => {
8813 if term.shape != ShapeConstraint::None {
8814 crate::bail_invalid_basis!(
8815 "ShapeConstraint::{:?} for term '{}' is not supported on spherical splines",
8816 term.shape,
8817 term.name
8818 );
8819 }
8820 let x = select_columns(data, feature_cols)?;
8821 build_spherical_spline_basis(x.view(), spec)?
8822 }
8823 SmoothBasisSpec::ConstantCurvature { feature_cols, spec } => {
8824 if term.shape != ShapeConstraint::None {
8825 crate::bail_invalid_basis!(
8826 "ShapeConstraint::{:?} for term '{}' is not supported on constant-curvature smooths",
8827 term.shape,
8828 term.name
8829 );
8830 }
8831 let x = select_columns(data, feature_cols)?;
8838 build_constant_curvature_basis(x.view(), spec)?
8839 }
8840 SmoothBasisSpec::MeasureJet {
8841 feature_cols,
8842 spec,
8843 input_scale,
8844 } => {
8845 if term.shape != ShapeConstraint::None {
8846 crate::bail_invalid_basis!(
8847 "ShapeConstraint::{:?} for term '{}' is not supported on measure-jet smooths",
8848 term.shape,
8849 term.name
8850 );
8851 }
8852 let frame = term.basis.scale_contract().normalize_euclidean_frame(
8856 select_columns(data, feature_cols)?,
8857 *input_scale,
8858 Some(spec.length_scale),
8859 )?;
8860 let x = frame.coordinates;
8861 let realized_input_scale = frame.input_scale;
8862 let length_scale_eff = frame
8863 .length_scale
8864 .expect("MeasureJet declares a required length-scale coordinate");
8865 let mut spec_local = spec.clone();
8866 spec_local.length_scale = length_scale_eff;
8867 let mut result = build_measure_jet_basis(x.view(), &spec_local)?;
8868 if let BasisMetadata::MeasureJet {
8869 input_scale: metadata_scale,
8870 ..
8871 } = &mut result.metadata
8872 {
8873 *metadata_scale = realized_input_scale;
8874 }
8875 result
8876 }
8877 SmoothBasisSpec::Matern {
8878 feature_cols,
8879 spec,
8880 input_scale,
8881 } => {
8882 if term.shape != ShapeConstraint::None {
8883 if feature_cols.len() != 1 {
8884 crate::bail_invalid_basis!(
8885 "ShapeConstraint::{:?} for term '{}' on Matern basis requires exactly 1 feature axis; found {}",
8886 term.shape,
8887 term.name,
8888 feature_cols.len()
8889 );
8890 }
8891 }
8892 let original_length_scale = spec.length_scale.resolved().ok_or_else(|| {
8893 BasisError::InvalidInput(format!(
8894 "term '{}' reached Matérn construction before its Auto length scale was resolved",
8895 term.name
8896 ))
8897 })?;
8898 let frame = term.basis.scale_contract().normalize_euclidean_frame(
8899 select_columns(data, feature_cols)?,
8900 *input_scale,
8901 Some(original_length_scale),
8902 )?;
8903 let x = frame.coordinates;
8904 let realized_input_scale = frame.input_scale;
8905 let length_scale_eff = frame
8906 .length_scale
8907 .expect("Matérn declares a required length-scale coordinate");
8908 let mut spec_local = spec.clone();
8909 spec_local.length_scale.set_resolved(length_scale_eff);
8910 let mut result = build_matern_basiswithworkspace(x.view(), &spec_local, workspace)?;
8911 if let BasisMetadata::Matern {
8912 input_scale: metadata_scale,
8913 length_scale,
8914 ..
8915 } = &mut result.metadata
8916 {
8917 *metadata_scale = realized_input_scale;
8918 *length_scale = original_length_scale;
8919 }
8920 result
8921 }
8922 SmoothBasisSpec::Duchon {
8923 feature_cols,
8924 spec,
8925 input_scale,
8926 } => {
8927 if term.shape != ShapeConstraint::None {
8928 if feature_cols.len() != 1 {
8929 crate::bail_invalid_basis!(
8930 "ShapeConstraint::{:?} for term '{}' on Duchon basis requires exactly 1 feature axis; found {}",
8931 term.shape,
8932 term.name,
8933 feature_cols.len()
8934 );
8935 }
8936 }
8937 let frame = term.basis.scale_contract().normalize_euclidean_frame(
8938 select_columns(data, feature_cols)?,
8939 *input_scale,
8940 spec.length_scale,
8941 )?;
8942 let x = frame.coordinates;
8943 let realized_input_scale = frame.input_scale;
8944 let length_scale_eff = frame.length_scale;
8945 let mut spec_local = spec.clone();
8946 spec_local.length_scale = length_scale_eff;
8947 if let crate::basis::OneDimensionalBoundary::Cyclic { start, end } =
8960 spec_local.boundary.clone()
8961 {
8962 spec_local.boundary = crate::basis::OneDimensionalBoundary::Cyclic {
8963 start: realized_input_scale.to_standardized_units(start),
8964 end: realized_input_scale.to_standardized_units(end),
8965 };
8966 }
8967 if let Some(periods) = spec_local.periodic.as_mut() {
8975 for axis_period in periods {
8976 if let Some(period) = axis_period.as_mut() {
8977 *period = realized_input_scale.to_standardized_units(*period);
8978 }
8979 }
8980 }
8981 if matches!(
8982 spec_local.identifiability,
8983 SpatialIdentifiability::OrthogonalToParametric
8984 ) {
8985 spec_local.identifiability = SpatialIdentifiability::None;
8986 }
8987 let mut result = build_duchon_basiswithworkspace(x.view(), &spec_local, workspace)?;
8988 if let BasisMetadata::Duchon {
8989 input_scale: metadata_scale,
8990 length_scale,
8991 periodic,
8992 ..
8993 } = &mut result.metadata
8994 {
8995 *metadata_scale = realized_input_scale;
8996 *length_scale = spec.length_scale;
8997 if spec.periodic.is_some() || spec.boundary.period().is_some() {
9013 *periodic = spec
9014 .periodic
9015 .clone()
9016 .or_else(|| spec.boundary.period().map(|(_, _, p)| vec![Some(p)]));
9017 }
9018 }
9019 result
9020 }
9021 SmoothBasisSpec::Pca {
9022 feature_cols,
9023 basis_matrix,
9024 centered,
9025 smooth_penalty,
9026 center_mean,
9027 pca_basis_path,
9028 chunk_size,
9029 } => {
9030 if term.shape != ShapeConstraint::None {
9031 crate::bail_invalid_basis!(
9032 "ShapeConstraint::{:?} for term '{}' is not supported on Pca basis",
9033 term.shape,
9034 term.name
9035 );
9036 }
9037 build_pca_smooth_basis(
9038 data,
9039 feature_cols,
9040 basis_matrix,
9041 *centered,
9042 *smooth_penalty,
9043 center_mean.as_ref(),
9044 pca_basis_path.as_ref(),
9045 *chunk_size,
9046 )?
9047 }
9048 SmoothBasisSpec::TensorBSpline { feature_cols, spec } => {
9049 build_tensor_bspline_basis(data, feature_cols, spec)?
9050 }
9051 SmoothBasisSpec::ByVariable { .. } => {
9052 crate::bail_invalid_basis!(
9053 "internal: ByVariable smooths must return before inner basis dispatch"
9054 );
9055 }
9056 SmoothBasisSpec::BySmooth { .. } => {
9057 crate::bail_invalid_basis!("internal: BySmooth smooths must be lowered to ByVariable before inner basis dispatch"
9058 .to_string(),);
9059 }
9060 SmoothBasisSpec::FactorSmooth { spec } => {
9061 if term.shape != ShapeConstraint::None {
9062 crate::bail_invalid_basis!(
9063 "ShapeConstraint::{:?} is unsupported for factor smooth term '{}'",
9064 term.shape,
9065 term.name
9066 );
9067 }
9068 return build_factor_smooth(data, spec, &term.name, workspace);
9069 }
9070 };
9071
9072 if let SmoothBasisSpec::Matern { .. } = &term.basis {
9088 let filtered = matern_operator_penalty_triplet_from_metadata(&built.metadata)?;
9089 built.active_penalties = filtered.active;
9090 built.dropped_penalties = filtered.dropped;
9091 }
9092
9093 if built.affine_offset.is_some() && term.shape != ShapeConstraint::None {
9094 crate::bail_invalid_basis!(
9095 "non-zero endpoint anchors cannot be combined with ShapeConstraint::{:?} on term '{}': the coefficient cone constrains only the homogeneous spline and would not certify the final affine function",
9096 term.shape,
9097 term.name
9098 );
9099 }
9100 let p_local = built.design.ncols();
9101 let affine_offset = built.affine_offset;
9102 let mut metadata = built.metadata.clone();
9103 let kron_factored = if term.shape == ShapeConstraint::None {
9106 built.kronecker_factored
9107 } else {
9108 None
9109 };
9110 let mut design_t = built.design;
9111 let mut penalties_t = built.active_penalties;
9112 let mut dropped_penalties_t = built.dropped_penalties;
9113 if matches!(
9114 spatial_identifiability_policy(term),
9115 Some(SpatialIdentifiability::OrthogonalToParametric)
9116 ) {
9117 metadata = freeze_raw_spatial_metadata(metadata, design_t.ncols());
9118 }
9119
9120 let use_box_reparam =
9121 term.shape != ShapeConstraint::None && shape_uses_box_reparameterization(&term.basis);
9122 if let Some((order, sign)) = shape_order_and_sign(term.shape)
9123 && use_box_reparam
9124 {
9125 let t = if order == 2 {
9139 let (knots, degree) = match &metadata {
9140 BasisMetadata::BSpline1D {
9141 knots,
9142 degree: Some(degree),
9143 periodic,
9144 ..
9145 } if periodic.is_none() => (knots, *degree),
9146 _ => {
9147 crate::bail_invalid_basis!(
9148 "shape-constrained convex/concave term '{}' requires realized open B-spline knot and degree metadata",
9149 term.name
9150 );
9151 }
9152 };
9153 let spans = bspline_first_derivative_control_spans(knots.view(), degree)?;
9154 if spans.len() + 1 != p_local {
9155 crate::bail_invalid_basis!(
9156 "shape-constraint derivative-control span count {} does not match basis dim {} for term '{}'",
9157 spans.len(),
9158 p_local,
9159 term.name
9160 );
9161 }
9162 convex_derivative_control_transform_matrix(&spans, sign)?
9163 } else {
9164 cumulative_sum_transform_matrix(p_local, order, sign)
9165 };
9166 let inner_dense = match design_t {
9170 DesignMatrix::Dense(d) => d,
9171 DesignMatrix::Sparse(sp) => gam_linalg::matrix::DenseDesignMatrix::from(
9172 sp.try_to_dense_arc("shape-constrained coefficient transform")
9173 .map_err(BasisError::InvalidInput)?,
9174 ),
9175 };
9176 let coeff_op =
9177 gam_linalg::matrix::CoefficientTransformOperator::new(inner_dense, t.clone()).map_err(
9178 |e| BasisError::InvalidInput(format!("CoefficientTransformOperator: {e}")),
9179 )?;
9180 design_t = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(
9181 coeff_op,
9182 )));
9183 for penalty in &mut penalties_t {
9190 let tt_s = fast_atb(&t, &penalty.matrix);
9191 penalty.matrix = fast_ab(&tt_s, &t);
9192 penalty.op = None;
9193 penalty.info.kronecker_factors = None;
9194 }
9195 }
9196 let penalty_candidates = penalties_t
9197 .into_iter()
9198 .map(|penalty| -> Result<PenaltyCandidate, BasisError> {
9199 let ActivePenalty {
9200 matrix,
9201 op: op_in,
9202 info,
9203 ..
9204 } = penalty;
9205 let (matrix, c_new) = normalize_penalty_in_constrained_space(&matrix);
9206 let normalization_scale = info.normalization_scale * c_new;
9207 let op_scale = 1.0 / c_new;
9208 let kronecker_scale = 1.0 / c_new;
9209 let scaled_op = if op_scale > 0.0 && op_scale.is_finite() {
9212 op_in.map(|op| {
9213 std::sync::Arc::new(crate::analytic_penalties::ScaledPenaltyOp::new(
9214 op, op_scale,
9215 ))
9216 as std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>
9217 })
9218 } else {
9219 None
9220 };
9221 let kronecker_factors = info.kronecker_factors.map(|mut factors| {
9222 if let Some(first) = factors.first_mut() {
9223 first.mapv_inplace(|v| v * kronecker_scale);
9224 }
9225 factors
9226 });
9227 Ok(PenaltyCandidate {
9228 matrix: ConstructiveQuadratic::try_from_dense_psd(
9229 matrix,
9230 "shape-constrained transformed penalty",
9231 )?,
9232 source: info.source,
9233 normalization_scale,
9234 kronecker_factors,
9235 op: scaled_op,
9236 })
9237 })
9238 .collect::<Result<Vec<_>, _>>()?;
9239 let filtered = crate::basis::filter_penalty_candidates(penalty_candidates)?;
9240 dropped_penalties_t.extend(filtered.dropped);
9241 let joint_null_rotation = match term.joint_null_rotation.clone() {
9260 Some(persisted) => Some(persisted),
9261 None if smooth_has_frozen_identifiability(term) => None,
9262 None if kron_factored.is_some() => None,
9263 None => crate::basis::compute_joint_null_rotation(&filtered.active)?,
9264 };
9265
9266 Ok(LocalSmoothTermBuild {
9267 dim: p_local,
9268 design: design_t,
9269 affine_offset,
9270 active_penalties: filtered.active,
9271 joint_null_rotation,
9272 dropped_penalties: dropped_penalties_t,
9273 metadata,
9274 linear_constraints: None,
9275 box_reparam: use_box_reparam,
9276 kronecker_factored: kron_factored,
9277 })
9278}
9279
9280pub fn build_smooth_design(
9281 data: ArrayView2<'_, f64>,
9282 terms: &[SmoothTermSpec],
9283) -> Result<RawSmoothDesign, BasisError> {
9284 let mut ws = crate::basis::BasisWorkspace::new();
9285 build_smooth_design_withworkspace(data, terms, &mut ws)
9286}
9287
9288pub fn build_smooth_design_withworkspace(
9295 data: ArrayView2<'_, f64>,
9296 terms: &[SmoothTermSpec],
9297 workspace: &mut crate::basis::BasisWorkspace,
9298) -> Result<RawSmoothDesign, BasisError> {
9299 validate_smooth_terms_finite_inputs(data, terms)?;
9300 build_smooth_design_withworkspace_unvalidated(data, terms, workspace)
9301}
9302
9303pub fn build_smooth_design_withworkspace_unvalidated(
9304 data: ArrayView2<'_, f64>,
9305 terms: &[SmoothTermSpec],
9306 workspace: &mut crate::basis::BasisWorkspace,
9307) -> Result<RawSmoothDesign, BasisError> {
9308 let mut planned_blocks = plan_joint_spatial_centers_for_term_blocks(data, &[terms.to_vec()])?;
9309 let planned_terms = planned_blocks.pop().ok_or_else(|| {
9310 BasisError::InvalidInput(
9311 "joint spatial center planner returned no smooth blocks".to_string(),
9312 )
9313 })?;
9314 let policy = workspace.policy().clone();
9315 let local_builds: Vec<LocalSmoothTermBuild> = {
9316 use rayon::iter::{IntoParallelIterator, ParallelIterator};
9317 planned_terms
9318 .into_par_iter()
9319 .map(|term| {
9320 let mut term_workspace = crate::basis::BasisWorkspace::with_policy(policy.clone());
9321 build_single_local_smooth_term(data, &term, &mut term_workspace)
9322 })
9323 .collect::<Result<Vec<_>, _>>()?
9324 };
9325
9326 let total_p: usize = local_builds.iter().map(|built| built.dim).sum();
9327
9328 let mut local_designs: Vec<DesignMatrix> = Vec::with_capacity(local_builds.len());
9329 let mut affine_offset = Array1::<f64>::zeros(data.nrows());
9330 let mut terms_out = Vec::<SmoothTerm>::with_capacity(terms.len());
9331 let mut penalties_global = Vec::<BlockwisePenalty>::new();
9332 let mut nullspace_dims_global = Vec::<usize>::new();
9333 let mut penaltyinfo_global = Vec::<PenaltyBlockInfo>::new();
9334 let mut dropped_penaltyinfo_global = Vec::<DroppedPenaltyBlockInfo>::new();
9335 let mut coefficient_lower_bounds = Array1::<f64>::from_elem(total_p, f64::NEG_INFINITY);
9336 let mut any_bounds = false;
9337 let mut linear_constraintsrows: Vec<(usize, usize, Array1<f64>)> = Vec::new();
9342 let mut linear_constraints_b: Vec<f64> = Vec::new();
9343
9344 let mut col_start = 0usize;
9345 for (term, mut built) in terms.iter().zip(local_builds.into_iter()) {
9346 let p_local = built.dim;
9347 let col_end = col_start + p_local;
9348 let lb_local = if built.box_reparam {
9349 shape_lower_bounds_local(term.shape, p_local)
9350 } else {
9351 None
9352 };
9353
9354 let applied_rotation: Option<crate::basis::JointNullRotation> = match (
9386 built.joint_null_rotation.take(),
9387 lb_local.is_some(),
9388 built.linear_constraints.is_some(),
9389 ) {
9390 (Some(rot), false, false) => {
9391 let q = &rot.rotation;
9392 built.design =
9393 apply_smooth_transform_to_design(built.design.clone(), q, &term.name)?;
9394 for penalty in &mut built.active_penalties {
9395 let qt_s = gam_linalg::faer_ndarray::fast_atb(q, &penalty.matrix);
9396 penalty.matrix = gam_linalg::faer_ndarray::fast_ab(&qt_s, q);
9397 penalty.null_eigenvectors = penalty
9398 .null_eigenvectors
9399 .as_ref()
9400 .map(|basis| gam_linalg::faer_ndarray::fast_atb(q, basis));
9401 penalty.op = None;
9402 penalty.info.kronecker_factors = None;
9403 }
9404 built.kronecker_factored = None;
9405 Some(rot)
9406 }
9407 (Some(_), _, _) => None,
9408 (None, _, _) => None,
9409 };
9410
9411 for active_penalty in &built.active_penalties {
9412 let global_index = penalties_global.len();
9413 penalties_global.push(
9414 BlockwisePenalty::new(col_start..col_end, active_penalty.matrix.clone())
9415 .with_op(active_penalty.op.clone()),
9416 );
9417 nullspace_dims_global.push(active_penalty.nullity);
9418 penaltyinfo_global.push(PenaltyBlockInfo {
9419 global_index,
9420 termname: Some(term.name.clone()),
9421 penalty: active_penalty.info.clone(),
9422 });
9423 }
9424 for info in &built.dropped_penalties {
9425 dropped_penaltyinfo_global.push(DroppedPenaltyBlockInfo {
9426 termname: Some(term.name.clone()),
9427 penalty: info.clone(),
9428 });
9429 }
9430
9431 if let Some(lin_local) = &built.linear_constraints {
9432 for r in 0..lin_local.a.nrows() {
9433 linear_constraintsrows.push((col_start, col_end, lin_local.a.row(r).to_owned()));
9434 linear_constraints_b.push(lin_local.b[r]);
9435 }
9436 }
9437 if let Some(lb_local) = &lb_local {
9438 coefficient_lower_bounds
9439 .slice_mut(s![col_start..col_end])
9440 .assign(lb_local);
9441 any_bounds = true;
9442 }
9443
9444 if let Some(term_offset) = built.affine_offset.as_ref() {
9445 if term_offset.len() != data.nrows() {
9446 crate::bail_dim_basis!(
9447 "smooth term '{}' affine offset has {} rows but the realized data has {}",
9448 term.name,
9449 term_offset.len(),
9450 data.nrows()
9451 );
9452 }
9453 affine_offset += term_offset;
9454 }
9455
9456 local_designs.push(built.design);
9458
9459 terms_out.push(SmoothTerm {
9460 name: term.name.clone(),
9461 coeff_range: col_start..col_end,
9462 shape: term.shape,
9463 active_penalties: built.active_penalties,
9464 dropped_penalties: built.dropped_penalties,
9465 metadata: built.metadata,
9466 lower_bounds_local: lb_local,
9467 linear_constraints_local: built.linear_constraints,
9468 kronecker_factored: built.kronecker_factored.take(),
9469 joint_null_rotation: applied_rotation,
9470 unabsorbed_global_orthogonality: None,
9471 });
9472
9473 col_start = col_end;
9474 }
9475
9476 assert_eq!(
9477 penalties_global.len(),
9478 nullspace_dims_global.len(),
9479 "global smooth penalty/nullspace bookkeeping diverged"
9480 );
9481 assert_eq!(
9482 penalties_global.len(),
9483 penaltyinfo_global.len(),
9484 "global smooth penalty metadata bookkeeping diverged"
9485 );
9486
9487 Ok(RawSmoothDesign {
9488 term_designs: local_designs,
9489 affine_offset,
9490 penalties: penalties_global,
9491 nullspace_dims: nullspace_dims_global,
9492 penaltyinfo: penaltyinfo_global,
9493 dropped_penaltyinfo: dropped_penaltyinfo_global,
9494 terms: terms_out,
9495 coefficient_lower_bounds: if any_bounds {
9496 Some(coefficient_lower_bounds)
9497 } else {
9498 None
9499 },
9500 linear_constraints: if linear_constraintsrows.is_empty() {
9501 None
9502 } else {
9503 let mut a = Array2::<f64>::zeros((linear_constraintsrows.len(), total_p));
9504 for (i, (cs, ce, values)) in linear_constraintsrows.iter().enumerate() {
9505 a.row_mut(i).slice_mut(s![*cs..*ce]).assign(values);
9506 }
9507 Some(LinearInequalityConstraints {
9508 a,
9509 b: Array1::from_vec(linear_constraints_b),
9510 })
9511 },
9512 })
9513}
9514
9515#[cfg(test)]
9516mod factor_smooth_heldout_group_tests {
9517 use super::*;
9518 use crate::basis::BasisWorkspace;
9519 use ndarray::{Array1, array};
9520
9521 fn pinned_marginal() -> BSplineBasisSpec {
9522 BSplineBasisSpec {
9523 degree: 3,
9524 penalty_order: 2,
9525 knotspec: BSplineKnotSpec::Provided(Array1::from(vec![
9526 0.0, 0.0, 0.0, 0.0, 0.25, 0.6, 1.0, 1.0, 1.0, 1.0,
9527 ])),
9528 double_penalty: false,
9529 identifiability: BSplineIdentifiability::None,
9530 boundary: crate::basis::OneDimensionalBoundary::Open,
9531 boundary_conditions: crate::basis::BSplineBoundaryConditions::default(),
9532 }
9533 }
9534
9535 fn factor_smooth_term(flavour: FactorSmoothFlavour, frozen: Option<Vec<u64>>) -> SmoothTermSpec {
9536 SmoothTermSpec {
9537 name: "fs_heldout".to_string(),
9538 basis: SmoothBasisSpec::FactorSmooth {
9539 spec: FactorSmoothSpec {
9540 continuous_cols: vec![0],
9541 group_col: 1,
9542 marginal: pinned_marginal(),
9543 flavour,
9544 group_frozen_levels: frozen,
9545 frozen_global_orthogonality: None,
9546 },
9547 },
9548 shape: ShapeConstraint::None,
9549 joint_null_rotation: None,
9550 }
9551 }
9552
9553 const FROZEN_01: [f64; 2] = [0.0, 1.0];
9554
9555 fn frozen_bits() -> Vec<u64> {
9556 FROZEN_01.iter().map(|v| v.to_bits()).collect()
9557 }
9558
9559 #[test]
9565 fn re_heldout_group_row_is_zero_deviation() {
9566 let data = array![[0.1, 0.0], [0.5, 1.0], [0.9, 7.0]];
9567 let term = factor_smooth_term(FactorSmoothFlavour::Re, Some(frozen_bits()));
9568 let mut workspace = BasisWorkspace::default();
9569 let build = build_single_local_smooth_term(data.view(), &term, &mut workspace)
9570 .expect("a held-out group must not fail the bs=\"re\" design build");
9571 let dense = build
9572 .design
9573 .try_to_dense_by_chunks("heldout test")
9574 .expect("dense");
9575 assert!(
9576 dense.row(2).iter().all(|&v| v == 0.0),
9577 "unseen-group row must carry zero deviation across every group block, got {:?}",
9578 dense.row(2)
9579 );
9580 assert!(
9581 dense.row(0).iter().any(|&v| v != 0.0) && dense.row(1).iter().any(|&v| v != 0.0),
9582 "in-vocabulary rows must still populate their group blocks"
9583 );
9584 }
9585
9586 #[test]
9590 fn fs_heldout_group_stays_strict() {
9591 let data = array![[0.1, 0.0], [0.5, 1.0], [0.9, 7.0]];
9592 let term = factor_smooth_term(
9593 FactorSmoothFlavour::Fs {
9594 m_null_penalty_orders: vec![1],
9595 },
9596 Some(frozen_bits()),
9597 );
9598 let mut workspace = BasisWorkspace::default();
9599 let err = match build_single_local_smooth_term(data.view(), &term, &mut workspace) {
9600 Ok(_) => panic!("fs must reject an unseen grouping level"),
9601 Err(err) => err,
9602 };
9603 assert!(
9604 err.to_string().contains("unseen grouping level"),
9605 "fs unseen-level refusal must name the defect, got: {err}"
9606 );
9607 }
9608}
9609
9610#[cfg(test)]
9611mod linear_term_contract_tests {
9612 use super::LinearTermSpec;
9613
9614 #[test]
9615 fn missing_linear_double_penalty_deserializes_to_unpenalized_mle() {
9616 let term: LinearTermSpec =
9617 serde_json::from_str(r#"{"name":"x","feature_col":0}"#)
9618 .expect("minimal saved linear term");
9619 assert!(
9620 !term.double_penalty,
9621 "descriptor and formula defaults must both preserve parametric MLE semantics"
9622 );
9623 }
9624}